🔔

【Ruby on Rails】FCMのHTTP v1 APIでのプッシュ通知配信の基本や上限の話

2024/08/30に公開

はじめに

Firebase Cloud Messaging(FCM)を利用したリモートプッシュ通知の配信について、2024年6月20日で従来利用していたLegacy APIが廃止になり、HTTP v1 APIへの対応を行いました。

少し記事にするのが遅くなりましたが、対応を行ううえで色々と調べたので、自分の中での整理も兼ねて、これから対応される方や新たにRuby on RailsアプリやRubyでFCMを使ってリモートプッシュ通知を配信したい方のお役に立てればと思い、まとめてみたいと思います。

HTTP v1 APIについて
https://firebase.google.com/docs/cloud-messaging/migrate-v1?hl=ja

ドキュメントが色々あって網羅的に把握するのが大変で、特にエラー・上限周りの取り扱いがまとまっているところが調査していて見つけづらかったため、そのあたりをできるだけ網羅してまとめてみます。(2024年8月時点)

自分が対応を行った後に、以下のようなベストプラクティスの記事がFCMから出ていたりもするので、そちらも参考になると思います。
https://firebase.google.com/docs/cloud-messaging/scale-fcm?hl=ja

必要な認証情報

HTTP v1 APIでは以下の認証情報が必要となります。
Firebaseプロジェクトの作成についてはここでの説明は割愛します。

  • アクセストークン(ベアラートークン)
    サービス アカウントから派生した、有効期間が短いOAuth 2.0アクセストークン。
    有効期間は1時間のため、定期的に更新して取得する必要があります。

  • サービスアカウント用の秘密鍵ファイル
    アクセストークン取得に必要。公式ドキュメントに従って以下で取得します。

  1. Firebase コンソールで、[設定] > [サービス アカウント] を開きます。
  2. [新しい秘密鍵の生成] をクリックし、[キーを生成] をクリックして確定します。
  3. キーを含む JSON ファイルを安全に保管します。

本記事ではこちらの秘密鍵ファイル内の文字列情報を、 FIREBASE_CREDENTIALSという環境変数として扱います。

JSONファイルは、一例として以下のように環境変数に設定できます。

export FIREBASE_CREDENTIALS=$(cat ./config/firebase_credentials.json)
  • 送信者ID
    Firebaseコンソールから取得できます。

本記事ではこちらの送信者IDの文字列を、 FCM_SENDER_IDという環境変数として扱います。

ベアラートークンは、googleauthのgemを利用して以下のように取得することができます。
トークンのexpireと再取得を考慮しています。

def bearer_token
  if @service_account_credentials.nil?
    json_key_io = StringIO.new(ENV["FIREBASE_CREDENTIALS"].to_json)
    scope = "https://www.googleapis.com/auth/firebase.messaging"
    @service_account_credentials = Google::Auth::ServiceAccountCredentials.make_creds(json_key_io: json_key_io, scope: scope)
  end

  @token_expired_at ||= Time.current
  if @token_expired_at - Time.current < 10.seconds # 更新ラグを考慮
    new_token = @service_account_credentials.fetch_access_token!
    if new_token["access_token"].present?
      @token = new_token["access_token"]
      @token_expired_at = Time.current + new_token["expires_in"] # expires_inは秒単位(3600 - 通信時間ぐらい)
    else
        # error
    end
  end

  @token
end

@service_account_credentials.fetch_access_token!は、アクセスするたびに新しいトークンがexpires_in: 3600で発行されました。
小規模なアプリケーションであれば毎回アクセスするでもいいかもしれませんが、GoogleAuthとの通信で少なくない通信時間がかかるため、キャッシュして expires_in をきちんと取り扱うような実装にしています。

個別ユーザーに向けて配信する

前提として、リモートプッシュ通知を配信するためにはクライアントから各ユーザーのFCMトークンを取得する必要があります。

Unity製のゲームクライアントであれば以下などを参考にして取得し、それをサーバー側に送信するような対応が必要となります。(取り扱い注意)
https://firebase.google.com/docs/cloud-messaging/unity/client?hl=ja

トークンが取得できたら、以下のような処理で配信を行うことができます。

title = "notification title"
body = "notification body"
registration_token = "クライアントから取得したトークン"

request_params = {
  message: {
    notification: {
      title: title,
      body: body,
    },
    token: registration_token,
  },
}

# bearer_tokenは前の項をもとに取得してください
request_headers = {
  Content-Type: "application/json",
  Authorization: "Bearer #{bearer_token}"
  access_token_auth: "true"
}

uri = URI.parse("https://fcm.googleapis.com/v1/projects/#{ENV["FCM_SENDER_ID"]}/messages:send")
http = Net::HTTP.new(uri.host, uri.port)
http.use_ssl = true
response = http.post(uri.path, request_params.to_json, request_headers)
response.is_a?(Net::HTTPSuccess)

コード中のrequest_params[:message]はプッシュ通知のカスタマイズが色々とあります。
今回は最低限のパラメーターしか設定していないですが、以下を参考に様々な設定を行うことができます。
https://firebase.google.com/docs/reference/fcm/rest/v1/projects.messages

トピック配信をする

FCMには、複数のユーザーに一括で配信する「トピック」という仕組みが用意されています。
https://firebase.google.com/docs/cloud-messaging/android/topic-messaging?hl=ja

トピックを用いると、ユーザーのFCMトークンに対してPub/Subで通知を送信することができます。
この配信方法はかなり高速で、どれだけユーザーがいてもほぼ瞬時に配信することが可能です。(Pub/Subなので)

トピックへの購読/解除はGoogle InstanceID Server APIを利用します。
https://developers.google.com/instance-id/reference/server?hl=ja#manage_relationship_maps_for_multiple_app_instances

トピックの購読・解除

トピックの購読/解除はバッチ処理できるAPIがあり、1リクエストで1000トークンまで処理することができます。

しかし、トピック購読には制限があり、

  • 1ユーザートークンで購読できるトピックは2000まで
  • 購読処理にはレート制限があり、制限に達した場合は指数バックオフによるリトライが推奨

となっています。
1プロジェクトあたりのトピック数自体に制限はありません。(Pub/Subなので)
購読/解除のAPIも先ほどと同じベアラートークンで認証できます。

topic = "topic_name"
registration_tokens = ["トークン1", "トークン2", ..] # 1000トークンまで
max_retry_count = 5

error_status_map = {}
max_retry_count.times |retry_count| do
  sleep (retry_count ** 2 + rand(1)).seconds if retry_count > 0

  request_params = {
    to: "/topics/#{topic}",
    registration_tokens: registration_tokens
  }

  # bearer_tokenは前の項をもとに取得してください
  request_headers = {
    Content-Type: "application/json",
    Authorization: "Bearer #{bearer_token}"
    access_token_auth: "true"
  }

  # 購読解除の場合はbatchAdd → batchRemove
  uri = URI.parse("https://iid.googleapis.com/iid/v1:batchAdd")
  http = Net::HTTP.new(uri.host, uri.port)
  http.use_ssl = true
  response = http.post(uri.path, request_params.to_json, request_headers)
  next unless response.is_a?(Net::HTTPSuccess)

  results = JSON.parse(response.body)["results"]
  results.each_with_index do |result, index|
    error_status = result["error"] # ex. "INTERNAL", "RESOURCE_EXHAUSTED"
    if error_status.present?      
      error_status_map[registration_tokens[index]] = error_status
      # error_status_mapをもとにリトライするなど
    end
  end
end

上記のコードでは error_status_map でエラーステータスを取得できるようにしています。
公式ドキュメントにも書いていますが、バッチ購読・購読解除のエラーステータスは以下のように返ってきます。

NOT_FOUND - 登録トークンが削除されたか、アプリがアンインストールされたことを示します。
INVALID_ARGUMENT - 提供された登録トークンがこの送信者 ID に対して有効でない。
INTERNAL - バックエンド サーバーでエラーが発生しました。リクエストを再試行します。
TOO_MANY_TOPICS - アプリ インスタンスあたりのトピック数が多すぎる。
RESOURCE_EXHAUSTED - 短期間に行われた登録または登録解除のリクエストが多すぎます。指数バックオフを使用して再試行します。

この中で一番嫌なのが、購読解除がINTERNALとなってしまうケースかなと思います。

一応公式ドキュメントには以下のように書いてあるので、解除がRESOURCE_EXHAUSTEDとなることはないのだろうと思っています。

新規サブスクリプションの頻度がプロジェクトごとにレート制限されます。短期間に送信するサブスクリプション リクエストが多すぎると、FCM サーバーから 429 RESOURCE_EXHAUSTED(「割り当てを超過した」)というレスポンスが返されます。この場合は、指数バックオフを使用して再試行します。

解除できなかった場合は意図せぬプッシュ通知を送信してしまうことになりかねないため、エラー情報を保存しておいて、後から再度解除を試みてみるなどできるようにしておいた方が良さそうに思います。

一応自分が対応を行った万を超えるユーザーを抱えるアプリケーションで数ヶ月運用している状況としては、解除がRESOURCE_EXHAUSTEDになったログは検出されていません。INTERNAL が起きたログは検出されています。

購読・解除レートについては以下の記載があります。

トピック登録の追加 / 解除レートは、プロジェクトごとに 3,000 QPS に制限されています

https://firebase.google.com/docs/cloud-messaging/concept-options?hl=ja#topics_throttling

トピック配信

トピックに向けて配信する場合は、前項の個別ユーザー配信の request_paramsを以下のように変更します

topic = "topic_name"
request_params = {
  message: {
    notification: {
      title: title,
      body: body,
    },
    topic: topic,
  },
}

複数トピックに向けた配信も可能です

topic1 = "topic_name1"
topic2 = "topic_name2"

# 両方購読しているユーザーに配信するパラーメーター
request_params = {
  message: {
    notification: {
      title: title,
      body: body,
    },
    condition: "'#{topic1}' in topics && '#{topic2}' in topics",
  },
}

複数トークンに一括で送る方法としてもう一つ「デバイスグループ」というものもあります。
こちらは1人のユーザーが複数の端末を所持している場合などに利用するものに思っていて、自分のプロジェクトでは利用していないため割愛します。
https://firebase.google.com/docs/cloud-messaging/android/device-group?hl=ja

一度に複数ユーザー(トークン)へ配信する

今回自分の方では実装しませんでしたが、このあたりの情報から可能そうに思っています。(1回あたり500トークン)
https://firebase.google.com/docs/cloud-messaging/send-message?hl=ja#send-messages-to-multiple-devices

自分のプロジェクトでは一時的にトピックを購読させて配信→配信後に解除しています。

動作確認時に気付きましたが、購読→配信をすぐにやりすぎるとまだ内部処理が完了していないようで、数秒待ってから配信を行う必要があるようです。

(余談)プッシュ通知のテストは気を付けましょう

ときどき「テスト これは通知テストです」みたいな通知をリリースアプリで配信してしまっている事例を見かけます。プッシュ通知のテストをする場合必ず誰しもやりかねないものではあるので、個人的なテクニックとしては、「仮に送信されてしまっても恥ずかしくないもの」にしておくようにしています。

すでにプッシュ通知が導入されているサービスであれば、前回配信した内容をコピーしてテストに使ったりします。そうすることで、もし何かの間違いで配信してしまっても「2回通知来た?」ぐらいで済ませてもらえるのでダメージは低いです。

新しくプッシュ通知を導入する場合は、「〜開催中!」などそれっぽいものにしておくのがいいと思います。

おわりに

細かい仕様など追っていくとまだまだ拾えていない部分もあるかもしれませんが、調べた範囲をまとめてみました。対応時にまとめながら進めていたのですが、記事執筆時点で情報が増えていたりもしていて、ちょうど情報が充実していっているところだと思います。

Rubyだと公式のgemはないですが、扱いやすくしたgemはいくつかあるようなので必要に応じて利用を検討してもいいと思います。

Happy Elements

Discussion