Ruby
iOS
APNS
Push通知
OriginalVASILYDay 13

APNsとHTTP/2通信でiOSのPush通知

APNs(Apple Push Notificationサービス)がHTTP/2に対応したのは2年ほど前でしょうか。それまでは、socket通信を直に使って送信する方式だったので、待望の変更だったと思います。
また、去年からAPNsの接続認証に「プロバイダー認証トークン」が使えるようになりました。従来の証明書形式のものは年に1回更新しなければならなかったため、更新を忘れると事故になってしまいます。一方、トークン認証は一度発行したら更新する必要がないだけでなく、トークンを発行したデベロッパーアカウントに紐付いている別のアプリへの再利用も可能です。継続利用も横転も楽にできるようになり、運用面でとてもありがたい変更でした。

そんな新しいアップデートもあり、あらためてPush通知の構造を見直す機会があったので、Push通知初心者向けにHTTP/2通信でPushを送る際のポイントをまとめます。黒帯の方は指摘などあればおねがいします!

プロバイダー認証トークンについては今回は詳しく書かないのでこちらの記事などを参考にしてください。
iOSのプッシュ通知送信時にトークン認証が使えるようになったので調べてみた
Golangで「プロバイダー認証トークン」を生成して、APNsにプッシュを送ろう

APNsのHTTP/2対応

そもそも、APNsはHTTP/2対応によって何が改善されたのでしょうか?

1. 処理が簡単に書けるようになった

従来のAPNsだと、socket通信のみのpush配信だったのが、HTTP/2対応によって、curlをつかって簡単に送れるようになりました。
※ただし、HTTP/2の良さを最大限に活かそうとする(大量かつ高速にPush通知を送信する)場合には簡単かどうかは怪しいです。

2. 配信効率の向上

HTTP/2のストリームという概念により、1つのコネクション内で同時に並行して複数のリクエスト/レスポンスを処理できます。短時間で複数の送信先に送信する必要があるPush通知のような場合、HTTP/2を使えば短時間での配信が可能となります。

3. レスポンスのリッチ化

従来のAPNsでは、Pushの送信が失敗した理由の詳細がわかりませんでした。また、送信先のデバイスでデバイストークンが生きているかを知るには別途APIを叩く必要がありました。
しかし、HTTP/2のバージョンではPush通知のレスポンスとしてエラーの詳細や、その端末のデバイストークンが有効であるか無効であるかが返ってくるようになり、より効率的にPushの送信結果を知ることができるようになりました。

4. 送信可能なペイロードの容量増加

2KBから4KB送信できるようになりました。

実装について

上記でも触れたようにHTTP/2対応したことにより、curlで簡単にPush通知を送信できるようになりました。
しかし、大量にPush通知を送れるようにHTTP/2のストリームの良さを活かした通知の仕組みを作る場合、スクラッチで書くのは結構時間がかかると思います。

HTTP/2をつかったPush通知の主流はGoなのかと思いますが、今回は運用面を考えてRubyで実装しました。
そこで、HTTP/2通信にもトークン認証にも対応しているgemを紹介します。

ostinelli/apnotic

スターも多く、HTTP/2通信はもちろんのこと、今年(2017年)の9月ごろからトークン認証にも対応してくれています。

ただ、先日ostinelli/apnoticの中で使われているライブラリに手放しでオススメできないコードを見つけてしまったのでissueにてコードの意図を質問中です。
https://github.com/ostinelli/net-http2/issues/23

HTTP/2のPush通知実装のポイント

1. ノンブロッキング処理をつかって送信する

ライブラリをつかってPush通知を送る場合、だいたいはsyncやasyncか、with_blockingやnon_blockingというメソッドがそれぞれ用意されています。大量にPush通知を送る場合は、ノンブロッキング処理を使ってPushを送信します。

そもそも、ブロッキング処理とノンブロッキング処理はなにが違うのでしょうか?それは、レスポンスを同期的に受け取るか、非同期的に受け取るかの違いです。

レスポンスを同期的に送る場合、つまり、ブロッキング処理を用いてPush通知を送信する場合、送信先のサーバーからレスポンスが返ってくるまで処理を待機してから次のPush通知の送信処理をします。
例えば、

# ブロッキング処理を使ってPush通知を送るメソッド
def send_sync_push(device_id, message)
  ensure_socket_open # socketをはる、すでに存在していたら使いまわす
  stream = new_stream # 新しいストリームの生成
  request = new_request(device_id, message) # HTTP/2のリクエスト作成
  response = stream.call(request) # ブロッキング送信処理
end

push_device_ids = ['端末1のデバイストークン', '端末2のデバイストークン'...]
push_device_ids.each do |device_id|
  response = send_sync_push(device_id, 'Merry Christmas!!')
  puts response.status
end

というコードを書いた場合、処理の順序としては、

①端末1を送信するための新しいストリームを生成する
②端末1へPush通知を送信する
③サーバーからレスポンスが返ってくるのを待機
④端末1へ送ったPush通知のレスポンスを受け取る
⑤端末2を送信するための新しいストリームを生成する
⑥端末2へPush通知を送信する
⑦サーバーからレスポンスが返ってくるのを待機
(これを繰り返す)

となります。これだと、結局従来のHTTP通信と変わらず、1つめの端末のレスポンスが返ってきた後に次の端末の送信を行うため、1コネクションで並列に処理を投げるということができていません。HTTP/2の良さが半減してしまいます。
一方、ノンブロッキング処理を使うと、

①端末1を送信するための新しいストリームを生成する
②端末1へPush通知を送信する
③端末2を送信するための新しいストリームを生成する
④端末2へPush通知を送信する
(これを繰り返す)
⑤レスポンスが随時返ってくるので受け取る

このように、前の送信分のレスポンスを待たずに次の送信をどんどん行うため、並列に処理を行うことができます。
また、送信したPush通知たちのレスポンスを受け取るのは、送信先のサーバーからレスポンスが返ってきたタイミングです。当たり前のように聞こえますが、ブロッキング処理のように他の処理を止めてレスポンスを待つのではなく、返ってきたタイミングで発火するようなcallbackイベントを登録することで、レスポンスを受け取ったタイミングで所望の処理をすることができます。

2. コネクションのjoin/closeを忘れない

close

HTTP/2特有の気をつけることというわけではないのですが、普段HTTPを触っていると、コネクションのcloseまでライブラリが面倒をみてくれることが多い気がします。

しかし、HTTP/2を使ったPush通知では、コネクションをcloseするタイミングは用途によって異なってくるためか、こちら側で制御できるようになっていることがあります。裏を返せば、closeの面倒は自分でみる必要があります。ライブラリによってcloseを記述する必要があるかないかを判断し、使い終わったコネクションのcloseを忘れないようにしてください。

join

シングルスレッドで処理する場合レスポンスが返り次第closeのみをおこなえばよいですが、ノンブロッキング処理のようなマルチスレッドの場合はコネクションをjoinする必要があります。

joinを行う理由は、Push通知のレスポンスを適切に受け取るためです。
ノンブロッキング処理を使った場合、レスポンスは返ってき次第メインスレッドとは別のスレッドで順次処理されます。joinをすることでメインスレッドは他のスレッドの処理の終了まで待機することになります。
この処理をしないと、まだレスポンスが返ってきていないスレッドが処理をする前にメインスレッドが終了するか、その次の処理にうつってしまいます。

3. レスポンスを受け取る処理はスレッドセーフなコードを書く

ノンブロッキング処理でレスポンスを受け取る際にスレッドセーフでないコードを書くことで、意図した結果にならない事があります。

下記はスレッドセーフでないコードのサンプルです。

class Pusher
  @@send_count = 0

  def connection
    # コネクション
  end

  def send_async_push(push_device_id, message)
    @@send_count += 1
    push = connection.prepare_push(push_device_id, message) # Push通知送信の準備
    push.on(:response) do |response|   # レスポンス受取時のcallbackイベントの設定
      puts "--#{@@send_count}回目の送信です"
      response.status   # => '200'
    end
    connection.call_async(push)  # ノンブロッキング送信処理
  end
end

10.times do |i|
  send_async_push('hogehoge', 'Merry Christmas!!')
end

=> 
2回目の送信です
10回目の送信です
10回目の送信です
10回目の送信です
10回目の送信です
10回目の送信です
10回目の送信です
10回目の送信です
10回目の送信です
10回目の送信です

これだと、レスポンスのブロックを実行する前に@@send_countが書き換えられてしまい、レスポンスをもらうときに処理をすると違う値になってしまいます。本当は1回目~10回目の送信となるはずが、@@send_countがインクリメントされた後なのですべて10回目となってしまいました。

まとめ

HTTP/2を使ったPush通知の実装をする際のポイントは、

  1. ノンブロッキング処理をつかって送信する
  2. コネクションのjoin/closeを忘れない
  3. レスポンスを受け取る処理はスレッドセーフなコードを書く

です。

gemを使えば実装はそこまで難しくないですが、気をつけるべきところは気をつけて実装しましょう!