先日、pplogが4歳になったタイミングのちょいまえにバージョンアップしたpplog iOSだけども、いくつか新しいことをナンカしている。それを記録がてら記事にしていこうというこのシリーズ。ネタとしては3回分ぐらいはある。今回は push 通知周りのお話。
新生 pplog iOS で push 通知を Amazon SNS
で送るように変えた。
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でもはもっと楽かもしれない。知らない。もっとあれなら検討しても良いかも。