先日、pplogが4歳になったタイミングのちょいまえにバージョンアップしたpplog iOSだけども、いくつか新しいことをナンカしている。それを記録がてら記事にしていこうというこのシリーズ。ネタとしては3回分ぐらいはある。今回は push 通知周りのお話。
新生 pplog iOS で push 通知を Amazon SNS
aws.amazon.com
で送るように変えた。
Swift側
特筆することがないので省略。紹介すると言いつつ、いきなり紹介しない。
Rails側
Rails側の機能を洗い出すと以下のようになる
- iOSからのデバイストークンの受取
- iOSからのデバイストークンの削除
- ユーザーのアクションを Trigger にした push通知配信
次に、これらの機能をブレイクダウンする。
iOSからのデバイストークンの受取
- ユーザーが push 通知に同意した際の処理
- AppDeviceToken#create するリソースを定義(routingの設定とcontrollerの作成をしておく)
iOSからのデバイストークンの削除
- ユーザーがログアウトとした際に一旦デバイストークンを消す
- AppDeviceToken#destroy するリソースを定義(routingの設定とcontrollerの作成をしておく)
ユーザーのアクションを 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
}
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でもはもっと楽かもしれない。知らない。もっとあれなら検討しても良いかも。