FCMを使ったWEARプッシュ通知基盤リプレイス

f:id:takanamito:20201120165719p:plain

こんにちは。WEARバックエンドエンジニアのid:takanamitoです。先日リリースしたWEARの新プッシュ通知基盤の紹介をしようと思います。

新プッシュ通知基盤開発の背景と目的

WEARでは既にiOS/Androidアプリに向けたプッシュ通知配信基盤が存在していました。

しかし、かなり昔につくられた基盤ということで運用にコストがかかったり、必要な機能が足りていなかったりします。

例えば、ユーザー全体にプッシュ通知を送りたい場合に以下のような問題が存在しました。

  1. ログイン済みユーザーにしかプッシュ通知を送信できない
  2. プッシュ通知の送信開始から完了までに半日以上かかる
  3. 配信サーバーのスケールに手作業が発生する

1.についてはWEAR開発当初、はじめてプッシュ通知を導入するきっかけとなったキャンペーンが存在したものの、そのキャンペーンの対象がWEARアカウントを持っている人だったために、このような仕様でプッシュ通知の機能が作られてしまい、今まで運用が続いていたことが理由のようでした。今回の新基盤開発のモチベーションの1つに「未ログインユーザーも含めたプッシュ通知配信」の実現が挙げられます。

2.と3.については、詳しいアーキテクチャの説明は割愛しますが、1.の経緯で作られた機能のため、現在のように月間1000万ユーザーを抱えることを想定せず作られていたようです。

また、配信時に以下のような運用が実際になされていました。

  • DBから配信対象デバイスリストを抽出
  • 手元の開発用マシンでバッチを実行し、配信サーバーに対して1件ずつHTTPで配信リクエスト
  • 配信サーバーからAPNs/FCMに対して1件ずつ配信リクエスト

この配信サーバーはオートスケール設定などされておらず、台数を増やしたい場合は秘伝の手作業によって作成されたAWS EC2のAMIを使って配信サーバーを手動で増やすという作業が必要でした。

このように既存の仕組みは配信数が増えるほどコスト(時間とお金)がかかるものであり、この状態でさらに送信対象が増える「未ログインユーザーも含めたプッシュ通知配信」を行うことは非現実的だろうという判断から、プッシュ通知基盤を作り直すことにしました。

新プッシュ通知基盤の要件

新しい基盤を設計する際には以下のような要件を意識していました。

  • WEARユーザーが増えた場合にも短時間で配信が可能であること
  • 低価格で配信が可能であること
  • 配信サーバーの運用をしなくてもよいこと

結果、Googleが提供するFirebase Cloud Messaging(FCM)を採用することにしました。

以下のポイントが決め手になりました。

  • ZOZOTOWNで先行して採用されており、配信速度の実績が十分高速だった
  • topicの機能により、API経由の1リクエストで多数のデバイスへの配信が実現可能である
  • 無料である
  • 我々が配信サーバーを保守運用する必要がない

WEARにおけるプッシュ通知の種類

WEARにはいくつかの種類のプッシュ通知が存在します。

  • 全体プッシュ通知:WEARユーザー全員が対象の通知
  • ユーザー指定プッシュ通知:WEARISTA向けのお知らせなど、特定のセグメントのユーザーが対象の通知
  • ユーザーのアクションによるプッシュ通知:「フォローされました」「フォロー中のユーザーがコーデを投稿しました」など、WEARユーザーのアクションをきっかけに配信される通知

今回の新基盤開発では初期段階のスコープはWEARユーザー全体に対する一斉通知を対象としており、ユーザーのアクションによって配信されるプッシュ通知は後から改修をすることにしています。

配信フロー

全体お知らせプッシュ通知は以下のようなフローでFCMのtopicを利用して配信しています。

f:id:takanamito:20201120165723p:plain
プッシュ通知シーケンス図

非同期処理を含むシーケンス図なのでいびつですが、流れはイメージしていただけるかと思います。

実装

WEARは以前のテックブログでも紹介したように、Railsにリプレイスをしている最中です。

techblog.zozo.com

そのため今回作る新基盤では、プッシュ通知配信機能を持った管理画面をRailsで実装しました。

FCMコンソールにはプッシュ通知を配信できる機能が存在しますが、WEARのネイティブアプリで既に実装されたプッシュ通知payloadと互換性をもった形で配信ができなかったため、APIを通じた配信の仕組みを作っています。

サーバーからFCMの機能を利用するとなるとFirebase Admin SDKを使いたくなるのですが、残念なことにRuby SDKは提供されていません。また、gemもいくつか存在したのでその実装方法を確認したのですが、私が確認した範囲ではFCM HTTP v1 APIに対応する新しい認証方式を採用しているgemがまだ存在せず、今回は自分で実装することにしました。

Railsのlib以下にFCM用の実装を置いてます。現状はtopicを使った配信をしているため Fcm::Topic クラスを実装していますが、今後別の配信方式を採用する場合はここに実装が増えていく予定です。

以下にサンプルコードを置いておきます。設定値などは適宜読みかえてください。

require 'googleauth'

module Fcm
  class BadRequestError < StandardError; end
  class UnauthorizedError < StandardError; end
  class ForbiddenError < StandardError; end
  class UnregisteredError < StandardError; end
  class QuotaExceededError < StandardError; end
  class InternalServerError < StandardError; end
  class ServiceUnavailableError < StandardError; end

  class Topic
    class << self
      def send_notification(payload)
        response = Fcm::Client.connection.post("/v1/projects/#{project_id}/messages:send", payload)

        # Errors: https://firebase.google.com/docs/reference/fcm/rest/v1/ErrorCode
        case response.status
        when 400
          raise Fcm::BadRequestError, response.body[:error][:message]
        when 401
          raise Fcm::UnauthorizedError, response.body[:error][:message]
        when 403
          raise Fcm::ForbiddenError, response.body[:error][:message]
        when 404
          raise Fcm::UnregisteredError, response.body[:error][:message]
        when 429
          raise Fcm::QuotaExceededError, response.body[:error][:message]
        when 500
          raise Fcm::InternalServerError, response.body[:error][:message]
        when 503
          raise Fcm::ServiceUnavailableError, response.body[:error][:message]
        else
          response
        end
      end
    end
  end

  module Client
    class << self
      def connection
        Faraday.new(base_url) do |builder|
          builder.request :oauth2, bearer_token, token_type: :bearer
          builder.request :json

          builder.response :json, parser_options: { symbolize_names: true }, content_type: 'application/json'

          builder.adapter Faraday.default_adapter
        end
      end

      private

      def bearer_token
        authorizer = Google::Auth::ServiceAccountCredentials.make_creds(
          json_key_io: StringIO.new(fcm_key.to_json),
          scope: 'https://www.googleapis.com/auth/firebase.messaging'
        )
        response = authorizer.fetch_access_token!
        response['access_token']
      end
    end
  end
end

FCM運用方針

Rubyクライアント以外にもFCMを使ったプッシュ通知に関して、いくつか検討した項目があります。

メッセージのカスタマイズについて

WEARアプリには既にプッシュ通知の仕組みが実装されています。そのため今回の新プッシュ通知基盤を導入するにあたり過去のバージョンのクライアントアプリと互換性を考慮する必要がありました。

FCMでは送信するメッセージをカスタマイズする機能が存在しますが、細かなカスタマイズはAPIを通じてでしか行えず、FCMコンソールを使ったプッシュ通知配信ではメッセージの表現の幅に制約がありました。

参考: FCM メッセージについて  |  Firebase

既存のクライアントアプリの実装を活かしたかったので、FCMコンソールからの配信を諦め、すべて内製のツールを通じて配信することにしました。

ただし、FCMコンソールでは利用可能な「スケジュール配信」などの仕組みを自分で用意する必要があるため、配信予約をしたい場合は注意が必要です。

topic設計について

先述の通り、今回実装した全体プッシュ通知にはFCMのtopic機能を使っています。サーバーから1リクエストで大量のプッシュ通知配信ができて非常に便利な機能ですが「API経由でtopicの一覧が取得できない」という制約が存在します。

そのためWEARではtopicの命名規則をルール化して運用することにしました。

具体的には以下のようなルールです。

wear-${environment}-${language}-${os}-all

WEARは複数環境(本番、開発、QAなど)、複数言語、複数プラットフォームが存在するサービスなので、topic名からそれぞれ識別できるようにした設計です。

全体プッシュ通知用のtopicが増えることは稀なので、命名規則に基づいたtopic名をRailsの設定ファイルで管理して運用しています。

topic削除について

topic自体の作成上限はありませんが、1つのアプリインスタンス(FCMトークンと同義だと解釈しています)を登録できるtopicの上限が2000件となっています。

さらに困ったことにtopicを削除するインタフェースが用意されていません。topicを削除したい場合は登録されたアプリインスタンスを全て登録解除する必要があるようです。

1 つのアプリ インスタンスを登録できるのは、2,000 トピックまでです。

参考: iOS でトピックにメッセージを送信する  |  Firebase

そのため、一度作成したtopicを削除するにはFCMトークンが必須です。一度でもtopic登録されたFCMトークンはDBで永続化し、いつかtopicを削除したくなった際に対応できるようにしています。

まとめ

WEARにおけるFCM導入事例を紹介しました。topicを使ったプッシュ通知配信においてAPIが不足している印象ですが、やはりプッシュ通知配信サーバーの運用から脱却できるなど、利点の方が上回っていると判断し導入しました。

今回は全体プッシュ通知について事例をご紹介しましたが、まだ全てのプッシュ通知を新基盤に移せたわけではありません。引き続き、地道な改善を続け高速にプッシュ通知を届けたり、コストの削減をしていく必要があると考えています。

技術的な基盤改善でサービスをより良くすることに興味がある方は、以下のリンクからぜひお声がけください。

hrmos.co

カテゴリー