読者です 読者をやめる 読者になる 読者になる

bekkou68の日記

開発しているサービス, IT技術, 英語など。

Rails で iOS のプッシュ通知を実装する手順

プッシュ通知 ObjectiveC Ruby on Rails

はじめに

自前でプッシュ通知を実装する手順をまとめます。サーバからクライアントまで。

この記事に書いてあること:

  • プッシュ通知の疎通確認までの手順
  • 個別送信の実装例
  • 複数送信の実装例
  • デプロイ時の工夫
  • Feedback のハンドリング

前提

  • クライアント
  • 対応OS
    • iOS7, iOS8
  • サーバ
  • 単一サービスのプッシュ通知を実装する
    • 複数サービスになると rpush はスレッド数が比例して増大するので向いてないことがある

証明書/プロビジョニングを用意

Developer Center から以下のファイルを取得します。

  • プッシュ通知用の証明書
    • Identifier > App IDs > IDを選択
    • ※証明書はつくる度に CSR は別のものにすること。同じものを使いまわすと前のプッシュ通知が届かなくなることがあるらしい
    • 証明書のつくりかた
  • プロビジョニング
    • 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 もうちょっと調べる。

環境まわりの考察 (2015/03/23 編集)

ちょっとモヤモヤしていることをダンプしておきます。

  • PROVISIONING=development では IPA を抽出できない。つまり deploygate で配布できない。端末をつないでインストールしかできない。開発時にリモートの人に確認してもらいたい時に困る
    • staging では APNS_ENV=production トークンを使って feedback を受け取らないように rpush を改造。ゴリ押し感
    • rpush で feedback を取るときに取り合いになる。これは gem の設定で off にするか改造するか pull req すれば解決する
    • 申請前なら 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

参考