pplog iOS で push 通知をAmazon SNSに変えた

先日、pplog4歳になったタイミングのちょいまえにバージョンアップしたpplog iOSだけども、いくつか新しいことをナンカしている。それを記録がてら記事にしていこうというこのシリーズ。ネタとしては3回分ぐらいはある。今回は push 通知周りのお話。

新生 pplog iOS で push 通知を Amazon SNS

aws.amazon.com

で送るように変えた。

Swift側

特筆することがないので省略。紹介すると言いつつ、いきなり紹介しない。

Rails側

Rails側の機能を洗い出すと以下のようになる

  • iOSからのデバイストークンの受取
    • Amazon SNSにEndPointを生成
  • iOSからのデバイストークンの削除
    • Amazon SNSからEndPointを削除
  • ユーザーのアクションを Trigger にした push通知配信
    • Amazon SNSのEndPointに配信指示

次に、これらの機能をブレイクダウンする。

iOSからのデバイストークンの受取

  • ユーザーが push 通知に同意した際の処理
  • AppDeviceToken#create するリソースを定義(routingの設定とcontrollerの作成をしておく)
    • 普通のCRUDの一つなので今回は省略

iOSからのデバイストークンの削除

  • ユーザーがログアウトとした際に一旦デバイストークンを消す
  • AppDeviceToken#destroy するリソースを定義(routingの設定とcontrollerの作成をしておく)
    • 普通のCRUDの一つなので今回は省略

ユーザーのアクションを Trigger にした push通知配信

  • あるユーザーがポエムを書くと購読しているユーザーに通知を配信
  • あるユーザーがポエムを「読んだよ」して「足あと」を付けると、ポエムを書いたユーザーへ通知を配信

紹介するコード

  • ActiveRecordモデル
  • Amazon SNS クライアント
  • 通知周りのActiveJob

あたりを紹介しておく。

token保存用のテーブル定義

ユーザーのテーブルからhas_many :apple_device_tokensで関連付けるイメージ。

class CreateAppleDeviceTokens < ActiveRecord::Migration[5.1]
  def change
    create_table :apple_device_tokens do |t|
      t.references :user, null: false, foreign_key: true
      t.string :token, null: false, index: { unique: true }
      t.string :endpoint_arn, null: false, default: ''
      t.integer :via, null: false, default: 0
      t.timestamps
    end
  end
end
  • token: APNから取得するデバイストークン
  • endpoint_arn: Amazon SNS の Endpoint の ARN
  • via: iOS アプリの push 通知証明書が sandbox 用か、プロダクション用かを示す

token保存用モデル

Aws::SNS::Errors::EndpointDisabledが発生するのは、APN側で無効となったデバイストークンだったり、サンドボックスのものをプロダクションに送ったりした場合など。 その場合は、Amazon SNS上で勝手にdisabledになっているので、RDB側からも消しておくわけ。

ActiveRecordのモデルにしては責務が多い気もするのでクラスを分けても良いかもしれない。

class AppleDeviceToken < ApplicationRecord
  enum via: {
    sandbox: 0,
    prod: 1
  }

  belongs_to :user

  validates :token, presence: true, uniqueness: true

  after_commit :fetch_endpoint_arn_async, on: :create

  def fetch_endpoint_arn!
    created_response = client.create_platform_endpoint(
      token: token,
      custom_user_data: { user_id: self.user.id }.to_json
    )

    if created_response.successful?
      update!(endpoint_arn: created_response.endpoint_arn)
    end
  end

  def send!(alert:, path:)
    return if self.endpoint_arn.blank?
    return if alert.nil? || path.nil?

    message = {
      default: alert,
      json_root => {
        aps: {
          alert: alert
        },
        path: path
      }.to_json
    }

    # http://docs.aws.amazon.com/sdkforruby/api/Aws/SNS/Client.html#publish-instance_method
    client.publish(
      target_arn: self.endpoint_arn,
      message: message.to_json,
      message_structure: 'json'
    )
  rescue Aws::SNS::Errors::EndpointDisabled
    self.destroy!
  end

  private

  attr_reader :client

  def client
    @client ||= self.sandbox? ? AwsSnsClient.sandbox : AwsSnsClient.prod
  end

  def json_root
    sandbox? ? 'APNS_SANDBOX' : 'APNS'
  end

  def fetch_endpoint_arn_async
    SnsEndpointJob.perform_later(self)
  end
end

AwsSnsClient

Amazon SNS の処理。今回使う API に特化して雑なクライアント設計となった。まあ最小限でいいかという判断。

ポイントは、already exists with the same Token, but different attributes.が返ってきたら、雑に一旦消して投げ直す処理かな。

class AwsSnsClient
  APP_ARN = ENV.fetch('SNS_APP_ARN_PROD_ENDPOINT')
  APP_ARN_SANDBOX = ENV.fetch('APP_ARN_SANDBOX_ENDPOINT')

  def initialize(app_arn:)
    @aws = Aws::SNS::Client.new(
      access_key_id: ENV.fetch('AWS_ACCESS_KEY_ID'),
      secret_access_key: ENV.fetch('AWS_SECRET_ACCESS_KEY'),
      region: ENV.fetch('AWS_SNS_REGION'))
    @app_arn = app_arn
  end

  def create_platform_endpoint(token:, custom_user_data: nil)
    aws.create_platform_endpoint(
      platform_application_arn: app_arn,
      token: token,
      custom_user_data: custom_user_data
    )
  rescue Aws::SNS::Errors::InvalidParameter => e
    if e.message =~ /already exists with the same Token, but different attributes./
      if end_point_arn = e.message.scan(/arn:aws:sns[\S]+/).first
        delete_endpoint(endpoint_arn: end_point_arn)
        retry
      end
    end
  end

  def delete_endpoint(endpoint_arn:)
    aws.delete_endpoint(
      endpoint_arn: endpoint_arn
    )
  end

  def publish(options = {})
    aws.publish(options)
  end

  class << self
    def prod
      self.new(app_arn: APP_ARN)
    end

    def sandbox
      self.new(app_arn: APP_ARN_SANDBOX)
    end
  end

  private

  attr_reader :aws, :app_arn
end

Active Job

  • EndPointを登録するJob
  • EndPointに配信するJob

の2つを定義した。

SnsEndpointJob

EndPointを登録するジョブ。

class SnsEndpointJob < ApplicationJob
  queue_as :aws_sns

  def perform(apple_device_token)
    apple_device_token.fetch_endpoint_arn!
  end
end

SnsSendingJob

EndPointに配信するジョブ。

通知のメッセージをモデルに定義したくなかったので、app/views/application配下にメッセージのテンプレートを置いて、ApplicationController.render()でレンダリングして配信メッセージとして送るようにした。

class SnsSendingJob < ApplicationJob
  queue_as :aws_sns

  def perform(apple_device_token, trigger)
    payload_json = ApplicationController.render(
      "apple_push_notification_for_#{trigger.class.name.downcase}",
      assigns: {
        trigger: trigger
      }
    )
    payload = JSON.parse(payload_json)

    apple_device_token.send!(alert: payload['alert'], path: payload['path'])
  end
end

以下のように呼ぶ

user.apple_device_tokens.each do |apple_device_token|
  SnsSendingJob.perform_later(apple_device_token, trigger)
end

感想

Amazon SNSに雑に投げれば良いのはホント楽だったけど、GAEでもはもっと楽かもしれない。知らない。もっとあれなら検討しても良いかも。