Rails で iOS のプッシュ通知を実装する手順
(最終更新日: 2017/08/23)
はじめに
自前でプッシュ通知を実装する手順をまとめます。サーバからクライアントまで。
この記事に書いてあること:
- プッシュ通知の疎通確認までの手順
- 個別送信の実装例
- 複数送信の実装例
- デプロイ時の工夫
- Feedback のハンドリング
前提
- クライアント
- 対応OS
- iOS7, iOS8
- サーバ
- 単一サービスのプッシュ通知を実装する
- 複数サービスになると rpush はスレッド数が比例して増大するので向いてないことがある
証明書/プロビジョニングを用意
Developer Center から以下のファイルを取得します。
- プッシュ通知用の証明書
- プロビジョニング
- Provisioning Profiles から適宜選択
- 証明書を変えたりプッシュ通知を enable にしたら作りなおして登録するのを忘れずに
プロビジョニングと APNS環境の対応は以下のとおりです。必要に応じて準備してください。
- development のプロビジョニングでビルドすると APNS環境は sandbox となる
- distribution (adhoc/appstore) のプロビジョニングでビルドすると APNS環境は production となる
異なる APNS環境のデバイストークンを DB に混在させないように注意しましょう。
クライアントを実装
プッシュ通知を送るにはデバイストークンを発行する必要があります。デバイストークンはプッシュ通知を送る時の識別子です。
ためしに起動時に発行してみましょう。
- (BOOL)application:(UIApplication *)application didFinishLaunchingWithOptions:(NSDictionary *)launchOptions { [self registerPushNotificationToken]; // 省略 } - (void)registerPushNotificationToken { UIApplication *sharedApplication = [UIApplication sharedApplication]; if ([sharedApplication respondsToSelector:@selector(registerUserNotificationSettings:)]) { // iOS 8 UIUserNotificationSettings *userNotificationSettings = [UIUserNotificationSettings settingsForTypes:UIUserNotificationTypeAlert | UIUserNotificationTypeBadge | UIUserNotificationTypeSound categories:nil]; [sharedApplication registerUserNotificationSettings:userNotificationSettings]; [sharedApplication registerForRemoteNotifications]; } else { // iOS 7 [sharedApplication registerForRemoteNotificationTypes:(UIRemoteNotificationTypeBadge | UIRemoteNotificationTypeSound | UIRemoteNotificationTypeAlert)]; } } /* デバイストークン発行成功 */ - (void)application:(UIApplication*)app didRegisterForRemoteNotificationsWithDeviceToken:(NSData*)deviceToken { NSLog(@"deviceToken: %@", deviceToken); NSString *deviceTokenString = [[[[deviceToken description] stringByReplacingOccurrencesOfString:@"<" withString:@""] stringByReplacingOccurrencesOfString:@">" withString:@""] stringByReplacingOccurrencesOfString:@" " withString:@""]; // サーバにデバイストークンを送信 // // ローカルに前回のデバイストークンを保存してあるなら、比較して同じだったら送信する必要はない。余計なリクエストはしない } /* デバイストークン発行失敗 */ - (void)application:(UIApplication*)app didFailToRegisterForRemoteNotificationsWithError:(NSError*)error { // ハンドルが必要なら処理を書く }
デバイストークンはバックアップをインストールしたときやOSの再インストール時などに変わることがあります。一度送信したらその後送信しない、と実装すると変わった拍子にプッシュ通知が届かなくなってしまいます。デバイストークンが変わったら適宜サーバへ送りましょう。
参考: Is the device token as unique as the device ID?
デバイストークンは実機のみで発行できます。シミュレータだとデバイストークンを発行できないので注意しましょう。ログを出すなどしてデバイストークンが発行できていることを確認してください。
サーバに鍵を持たせる
プッシュ通知を送るためにはサーバが正しい送信元だと証明しなければなりません。そのために先ほど証明書から鍵を書き出してサーバに配置する必要があります。
以下の手順で鍵を書き出します。
- キーチェーンアクセスを起動します
- 分類> 自分の証明書 > “Apple <Development|Production> IOS Push Services:
” の証明書を選択 - 右クリックして書き出すを選択し、p12 として保存します
- このとき「種類」が「証明書」のところで書き出しをします。それにより証明書と秘密鍵を含めたものが書き出されます
- 「種類」が「秘密鍵」のところで書き出すとプッシュ通知が飛ばないので注意しましょう
- また「種類」の「秘密鍵」が存在することも確認しましょう。存在しないとプッシュ通知は飛びません。p12 を別ファイルとして持っているならダブルクリックすると入ります
保存した p12 を pem に変換します <CERT_NAME> は適宜うめてください。
$ openssl pkcs12 -in <CERT_NAME>.p12 -out <CERT_NAME>.pem -nodes -clcerts
pem を $RAILS_ROOT/certificates/apns
配下に [sandbox|production].pem
という名前をつけて配置します。
サーバでデバイストークンを受け取る
トークンを受け取る API を用意しましょう。User モデルが device_token アトリビュートを持つとします。
config/routes.rb
... namespace :api, defaults: {format: 'json'} do namespace :v1 do namespace :users do resource :device_token, only: [:update] end end end ...
app/controllers/api/v1/users/device_tokens_controller.rb
class Api::V1::Users::DeviceTokensController < Api::V1::ApplicationController # @param [String] device_token def update current_user.device_token = params[:device_token] ActiveRecord::Base.transaction do current_user.save! current_user.sweep_same_device_tokens end render status: :ok end end
app/models/user.rb
class User < ActiveRecord::Base # 自分以外の同じデバイストークンを持つユーザがいたら nil で上書き # 一つのデバイストークンは複数のユーザで持たないべき def sweep_same_device_tokens(updated_time: Time.zone.now) User .where(device_token: self.device_token) .where.not(id: self.id) .update_all(['device_token = NULL, updated_at = ?', updated_time]) end end
イメージはこんな感じです。sweep の処理があるので API の口は専用で用意したほうがいいと考えています。
さて、クライアントからデバイストークンを受け取りましょう。受け取れたら次のステップです。
まずは開発コンソールからプッシュ通知を送って疎通を確認
rpush という gem を使います。
Gemfile に追加し $ bundle install
し $ rpush init
します。
config/initializer/rpush.rb を適宜設定します。
lib/tasks/rpush.rake に環境をロードするスクリプトを配置します。
require_relative '../../config/environment' namespace :rpush do # USAGE: # () 内は省略可 # $ bundle exec rake rpush:install (APNS_ENV=[sandbox|production]) task install: :environment do |current_task| puts "start #{current_task.name}" app_name = 'myproject' apns_env = ENV['APNS_ENV'] ? ENV['APNS_ENV'] : (Rails.env.production? ? 'production' : 'sandbox') if Rpush::Apns::App.where(name: app_name, environment: apns_env).exists? puts 'already exists: (Rpush::Apns::App (name:%s environment:%s))' % [app_name, apns_env] next # end the task (almost same as method's return) end certificate_path = Rails.root.join('certificates', 'apns', "#{apns_env}.pem") Rpush::Apns::App.create!( name: app_name, certificate: File.read(certificate_path), environment: apns_env, password: '', connections: 1 ) puts "saved: RAILS_ENV=#{Rails.env}, APNS_ENV=#{apns_env}, certificate_path=#{certificate_path}" puts "end #{current_task.name}" end end
$ bundle exec rake rpush:install
そしてプッシュ通知を送るためのプロセスも立ち上げます。
$ bundle exec rpush start
ようやく準備が整いました。プッシュ通知を送ってみましょう。
irb> n = Rpush::Apns::Notification.new irb> n.alert = 'hello apns!' irb> ... irb> n.save!
これでクライアントに ‘hello apns!’ という文言でプッシュ通知が届けば OK です!
個別にプッシュ通知を送る実装
送信するメソッドを定義します。
# モデルを分けるなり、親controller に持たせるなり def send_notification(device_token:, i18n_key:) return nil unless device_token return nil unless i18n_key n = Rpush::Apns::Notification.new n.app = Rpush::Apns::App.find_by_name('myproject') n.device_token = device_token n.alert = I18n.t(i18n_key) n.save! end
送信したいところで
send_notification(device_token: current_user.device_token, i18n_key: 'errors.notifications.got_message')
と呼びます。
複数にプッシュ通知を送る通知の実装例
ユーザ全員に送るお知らせプッシュ通知などを想定しています。
実装の雰囲気こんな感じです。rake task や管理画面などから送ることが多い印象です。
message = 'プッシュ通知で送りたい文字列。' sent_users_count = 0 # プッシュ通知を送れたユーザ数。端数は無視していることに注意 batch_size = Settings.apns.batch_size # 100 程度がいいかも。一つの通知に失敗するとすべて fail することに注意 notificatees = User.where('device_token IS NOT NULL') logger = ActiveSupport::BufferedLogger.new(Rails.root.join('log', 'users_notification.log')) APNS.host = Settings.apns.host APNS.pem = Settings.apns.pem logger.info "\n\n\n\n[#{Time.now}] START Push Notification, number of receivers will be #{notificatees.count}. Message: #{message}" logger.info "[#{Time.now}] notificatees.to_sql: #{notificatees.to_sql}" notificatees.find_in_batches(batch_size: batch_size) do |batched_notificatees| retry_count = 0 time = Time.now notifications = batched_notificatees.map {|notificatee| APNS::Notification.new(notificatee.device_token, alert: message, badge: 1, sound: 'default') } # 通知の送信 # APNS の調子次第で Errno::EPIPE が稀に発生するのでリトライ # TODO retryable gem でもっと綺麗に書けそう begin APNS.send_notifications(notifications) rescue Errno::EPIPE => e logger.error "[#{time}] Failed(##{retry_count + 1}) and retry #{e.class}, #{e.message}\n#{e.backtrace.join("\n")}" retry_count += 1 retry if retry_count < 3 logger.error "[#{time}] *** RETRY FAILED ***" rescue => e logger.error "#{e.class}, #{e.message}\n#{e.backtrace.join("\n")}" end sent_users_count += batch_size logger.info "[#{time}] Push was sent to about #{sent_users_count} users." end
デプロイ時の工夫
capistrano をこんなふうにしておくとラクです。環境をロードしたりプロセスを立ち上げたりしてくれます。
lib/capistrano/tasks/rpush.rake
namespace :rpush do task :environment do set :rpush_pid, "#{shared_path}/tmp/pids/rpush.pid" end def start_rpush within current_path do execute :bundle, :exec, :rpush, :start, "-e #{fetch(:rails_env)}" end end def stop_rpush execute :kill, "-s QUIT $(< #{fetch(:rpush_pid)})" # XXX: kill時に pidファイルが消えないので消す。もっといい方法あれば知りたい # たとえば unicorn gem のプロセスは、 # kill時にプロセスが pidファイルを消してくれるからこういうのを書かなくていい execute "rm #{fetch(:rpush_pid)}" end def reload_rpush execute :kill, "-s HUP $(< #{fetch(:rpush_pid)})" end def rpush_process_exists? test("[ -f #{fetch(:rpush_pid)} ]") end desc 'Install rpush settings to DB' task :install => :environment do on roles(:app) do within release_path do with rails_env: fetch(:rails_env) do execute :rake, 'rpush:install' end end end end desc 'Start rpush server' task :start => :environment do on roles(:app) do start_rpush end end desc 'Stop rpush server' task :stop => :environment do on roles(:app) do stop_rpush end end desc 'Restart rpush server' task :restart => :environment do on roles(:app) do if rpush_process_exists? reload_rpush else start_rpush end end end end
config/deploy.rb
after :migrate, 'rpush:install' namespace :deploy do after :restart, 'rpush:restart' end
Feedback を受け取って無効なデバイストークンを掃除する
ユーザがアプリを消すなどしてプッシュ通知がとどかないデバイストークンがある。そういったデバイストークンに通知を送るのは帯域がムダ。APNS は通知できなかったデバイストークンを Feedback サーバに保持している。production 環境から定期的に Feedback へ問い合わせて無効なデバイストークンを削除しましょう。
一括で掃除できるメソッドをどこかしらのモデルに定義しておきます。
class << self def sweep_invaild_device_tokens(tokens:) where(device_token: tokens).each do |user| user.update(device_token: nil) end end end
それを rpush の initializer で呼びます。ポーリングの秒数も設定しましょう。
config/initializers/rpush.rb
Rpush.configure do |config| # Frequency in seconds to check for feedback config.feedback_poll = 60 ... end Rpush.reflect do |on| on.apns_feedback do |feedback| logger = Logger.new(Rails.root.join('log', 'rpush_feedback.log')) logger.formatter = Logger::Formatter.new tokens = [feedback.device_token].flatten.compact logger.info('Start sweeping %d invalid device tokens' % tokens.count) if tokens.present? tokens.each do |token| logger.info(token) end User.sweep_invaild_device_tokens(tokens: tokens) end logger.info 'End sweeping invalid device tokens' end ... end
メモ
- 動作確認は production で確認するしかない? sandbox だと on.apns_feedback が kick されていないような気がする。TODO もうちょっと調べる。
- 100 件をまとめて送信する際に、1 件でも無効なトークンが含まれていると、100 件とも送信失敗になったような気がする。要確認。
環境まわりの考察
ちょっとモヤモヤしていることをダンプしておきます。
- PROVISIONING=development では IPA を抽出できない。つまり deploygate で配布できない。端末をつないでインストールしかできない。開発時にリモートの人に確認してもらいたい時に困る
- staging では APNS_ENV=production トークンを使って feedback を受け取らないように rpush を改造。ゴリ押し感
- rpush で feedback を取るときに取り合いになる。これは gem の設定で off にするか改造するか pull req すれば解決する
- 2015/2/18 に feedback_receiver.enabled オプションが追加されたので改造しなくて大丈夫
- 申請前なら APNS_ENV=production で確認してもらえないこともないが、それ以降はできない方法
- RAILS_ENV=staging に APNS_ENV=production のトークンを混ぜたくない理由 (悩ましい・・)
- 本番じゃないのに本番じゃないトークンはいれたくない
- ないとは思うが RAILS_ENV=staging から APNS_ENV=production への誤送信が怖い
理想:
- RAILS_ENV=development,staging, PROVISIONING=development では PUSH_ENV=sandbox
- RAILS_ENV=production, PROVISIONING=distribution (adhoc/appstore) では PUSH_ENV=production