【Ruby/Rails】Firebase Cloud Messaging を利用してプッシュ通知を一括送信する

f:id:natsuokawai:20200522155622j:plain


こんにちは。スタメンで主にバックエンドの開発を担当している河井です。 今回は Firebase Cloud Messaging(以下 FCM)を利用したプッシュ通知の一括送信について書いてみます。

背景

実は以前にも FCM を利用した通知の記事を書いていて、そこでは各デバイスへの通知1回につき1回 FCM へリクエストをする方法を紹介しました。

しかしサービスが拡大してくると通知先のデバイスも増え、個別にリクエストをしていることによる効率の悪さが目立ってきます。

そこで解決策となるのが複数デバイスへの一括通知です。 複数のリクエストを一回でまとめて送信することでパフォーマンスの向上を期待できます。

複数デバイス通知の仕様について

複数デバイス通知の公式ドキュメントはこちらです。

以前のブログ記事でも触れたように、Ruby には Firebase 公式の SDK はありません。 非公式の Gem もありますが、ここではドキュメントの REST の項目を見て自前で実装してみます。

リクエス

まずはリクエストの中身を見てみましょう。

--subrequest_boundary
Content-Type: application/http
Content-Transfer-Encoding: binary
Authorization: Bearer ya29.ElqKBGN2Ri_Uz...HnS_uNreA

POST /v1/projects/myproject-b5ae1/messages:send
Content-Type: application/json
accept: application/json

{
  "message":{
     "token":"bk3RNwTe3H0:CI2k_HHwgIpoDKCIZvvDMExUdFQ3P1...",
     "notification":{
       "title":"FCM Message",
       "body":"This is an FCM notification message!"
     }
  }
}

...

--subrequest_boundary
Content-Type: application/http
Content-Transfer-Encoding: binary
Authorization: Bearer ya29.ElqKBGN2Ri_Uz...HnS_uNreA

POST /v1/projects/myproject-b5ae1/messages:send
Content-Type: application/json
accept: application/json

{
  "message":{
     "token":"cR1rjyj4_Kc:APA91bGusqbypSuMdsh7jSNrW4nzsM...",
     "notification":{
       "title":"FCM Message",
       "body":"This is an FCM notification message!"
     }
  }
}
--subrequest_boundary--

FCM のドキュメントではこの形式について特に解説していませんが、これは

  • "--区切り文字列" という形式の文字列で始まり
  • 個々のリクエスト内容を文字列として"--区切り文字列"で連結し
  • 最後を "--区切り文字列--" で閉じた
  • 1つの文字列(テキストファイル)

です。改行はすべて CRLF(\r\n) を使います。 区切り文字列 にはリクエスト送信時の boundary=" " で任意の文字列を使用できます。

これは mulitpart/mixed という形式で、詳しい説明は RFC 2046 に譲ります。

なお、個々のリクエストに含める内容としては個別通知のときと同じものなので、こちらについての詳細は過去の記事を見てください。

レスポンス

レスポンスも同じように、個々のリクエストのレスポンスが1つの文字列になったものが返ってきます。

--batch_nDhMX4IzFTDLsCJ3kHH7v_44ua-aJT6q
Content-Type: application/http
Content-ID: response-

HTTP/1.1 200 OK
Content-Type: application/json; charset=UTF-8
Vary: Origin
Vary: X-Origin
Vary: Referer

{
  "name": "projects/35006771263/messages/0:1570471792141125%43c11b7043c11b70"
}

...

--batch_nDhMX4IzFTDLsCJ3kHH7v_44ua-aJT6q
Content-Type: application/http
Content-ID: response-

HTTP/1.1 200 OK
Content-Type: application/json; charset=UTF-8
Vary: Origin
Vary: X-Origin
Vary: Referer

{
  "name": "projects/35006771263/messages/0:1570471792141696%43c11b7043c11b70"
}

--batch_nDhMX4IzFTDLsCJ3kHH7v_44ua-aJT6q--

このバッチレスポンスにはバッチリクエストに含めたリクエストと同じ順番で対応するレスポンスが含まれています。

先程触れませんでしたが、Content-ID: foo というフィールドを個々のリクエストに入れておくとレスポンスに Content-ID: response-foo という形で入ってきます。
各リクエストにつきユニークな文字列を使用することでリクエストに対応したものかどうかを確認できます。

注意点として、バッチリクエスト自体の成否と個々のリクエストの成否は独立しているということが挙げられます。

例えば、バッチレスポンス自体は正常に動作したのでステータスコードは 200、ただしあるリクエストの通知先のデバイスが存在せずそのリクエストに対するレスポンスコードは 404 となっている、という状況がありえます。

そのため個々のレスポンスに分割してリクエストの成否を確認する必要があります。

Ruby での実装

一通り仕様については把握できたので、これを Ruby で実装してみます。 HTTP リクエストには Ruby 標準の Net::HTTP を使います。

リクエストボディの作成

まずはリクエストボディを作成します。 上で触れた形式に沿って、以下のように1行ずつ文字列を足していきます。

boundary = "subrequest_boundary" # 区切り文字列
buffer = ""
tokens.each do |token|
  buffer += "--#{boundary}\r\n"
  buffer += "Content-Type: application/http\r\n"
  buffer += "Content-Transfer-Encoding: binary\r\n"
  buffer += "Content-Id: #{token}\r\n"
  buffer += "Authorization: Bearer ya29.ElqKBGN2Ri_Uz...HnS_uNreA\r\n"
  buffer += "\r\n"
  buffer += "POST https://fcm.googleapis.com/v1/projects/myproject-b5ae1/messages:send\r\n"
  buffer += "Content-Type: application/json\r\n"
  buffer += "accept: application/json\r\n"
  buffer += "\r\n"
  body = {
    message: {
      token: token,
      notification: {
        title: "FCM Message",
        body: "This is an FCM notification message to device 0!"
      }
    }
  }
  buffer += body.to_json
  buffer += "\r\n"
end
buffer += "--#{boundary}--\r\n"

区切り文字列には公式ドキュメントに従って subrequest_boundary としています。
Content_id にはデバイスの識別トークンを入れてみました。
なお制限として、1回のリクエストには最大500個までトークンを含めることができます。

バッチリクエストの実行

curl で書かれているバッチリクエストを Net::HTTP の形式に変換します。

require 'net/http'
require 'uri'

uri = URI.parse("https://fcm.googleapis.com/batch")
request = Net::HTTP::Post.new(uri)
request.content_type = "multipart/mixed; boundary=\"#{boundary}\""
request.body = buffer # <= ここでさっき作った文字列を入れる
req_options = {
  use_ssl: uri.scheme == "https",
}

response = Net::HTTP.start(uri.hostname, uri.port, req_options) do |http|
  http.request(request)
end

変換には curl-to-ruby を使いました。

content_type で区切り文字を指定し、request body に先ほど作成したバッチリクエストボディを入れます。

レスポンスボディの解析

バッチリクエストのレスポンスはリクエスト時に指定してた文字列で区切られていて、かつバッチリクエストと同じ順番でレスポンスを含んでいます。

まずは区切り文字列を使ってレスポンスを分割します。

responses = response.body.split(boundary)[1..-2]

レスポンスは --区切り文字 で始まり --区切り文字列-- で終わることを思い出すと、分割した配列は

['', res1, res2, ... , '--']

となるので先頭と末尾の要素は除いておきます。 個々の要素は以下のような文字列です。

Content-Type: application/http
Content-ID: response-

HTTP/1.1 200 OK
Content-Type: application/json; charset=UTF-8
Vary: Origin
Vary: X-Origin
Vary: Referer

{
  "name": "projects/35006771263/messages/0:1570471792141696%43c11b7043c11b70"
}

ここで正規表現ステータスコードと content_id を抜き出してみます。

code = res.match(/HTTP\/1\.1 (\d{3})/)[1]
token = res.match(/Content-ID: response-(.*)\r\n/)[1]

あとはこれらの情報を元にリトライなりしましょう。

まとめ

FCM を利用して複数デバイスに一括で通知する方法を紹介しました。
一括で送信することでリクエスト数を大幅に減らすことができます。 今のところ一括送信を利用することによる配信の遅延も感じていないので、パフォーマンスにお悩みの方はぜひ試してみてください。

最後に、株式会社スタメンでは一緒に働くエンジニアを募集しています。 ご興味のある方はぜひエンジニア採用サイトWantedly をご覧ください。

Photo by Jamie Street on Unsplash