lasciva blog

開発して得た知見やwebビジネスのストック

FCM(Firebase Cloud Messaging) の新APIに移行した

背景

FCM(Firebase Cloud Messaging)は、モバイルアプリやwebアプリのpush通知の配信を簡単に行える、Googleの提供するサービス。

firebase.google.com

関わっているサービスのiOSアプリとAndroidアプリのpush通知で、FCMのHTTP APIを用いている。 サーバは、Ruby on Rails。 FCM サーバーでは、プロトコルを3種類用意されている。

今回は、「レガシー HTTP プロトコル」から、「FCM HTTP v1 API」に移行した。

レガシープロトコルと新HTTPプロトコルの違い

FCMでは、送付対象を特定するのに、大きく2種類の方法がある。

  1. topic
    • あるtopicを購読している全端末に対して、通知を行う。
  2. token
    • 端末ごとに発行されるtokenを指定して、通知を行う。

ざっくりとした対応表が下表になる。

レガシー FCM HTTP v1
単一のtopic
複数のtopic
単一のtoken
複数のtoken ○(1リクエスト1000個まで)

要件と設計方針

  1. ユーザが設定画面でpush通知の対象を限定することができる。
  2. (非通知設定していない)全ユーザを対象とした毎朝、夜の通知を行いたい。
    • アクセスが一度に来ると、サーバに負荷がかかるので、ある程度分散して通知を行いたい
  3. 特定のユーザのみを対象とした通知も行いたい。

プロトコルでは、複数のtokenを対象に1度のAPIリクエストで行えたため、全ユーザに対する通知は、tokenを1000件ずつに区切り、時間間隔を空けながら、送ることができていた。 しかし、上表の通り新プロトコルでは複数のtokenを指定することができなくなったため、tokenを1000件ずつにグループ化して、事前にtopicを登録して通知を行うようにした。

実装

移行方法は、こちらに記載されており、以下の3つの修正が必要。

  1. サーバー エンドポイントの更新
  2. 認証(アクセストークンの変更)
  3. 送信リクエストのペイロードを更新する

以前の HTTP から HTTP v1 に移行する  |  Firebase

基本的には、以下のドキュメントを参考に進めればできるはずだが、ハマったところを中心に記載する。

アプリサーバーからの送信リクエストを作成する  |  Firebase

サーバー エンドポイントの更新

全然ハマるポイントがなさそうだが、結構ハマった。

// 旧エンドポイント
https://fcm.googleapis.com/fcm/send
// 新エンドポイント
https://fcm.googleapis.com/v1/projects/myproject-b5ae1/messages:send

myproject-b5ae1の部分が、FirebaseでのプロジェクトIDを指定するのだが、これがわかりにくい。。 Firebaseのコンソールの「設定」の「クラウド メッセージング」タブ中の 送信者IDを指定する。

f:id:hacking15dog:20181024100739p:plain

認証

プロトコルに対応したGoogleの提供するSDKは、Rubyが非対応のため、標準のSDKではなく、認証用のライブラリを使用した。

サーバーに Firebase Admin SDK を追加する  |  Firebase

github.com

事前の準備として、Firebase admin SDKのサービスアカウントをFirebaseコンソールで作成し、キーのjsonファイルGCPコンソールで取得しておく。

# 1時間で有効期限が切れるので注意
def authorization_header
  authorizer = Google::Auth::ServiceAccountCredentials.make_creds(
    json_key_io: File.open("#{Rails.root}/config/google-services.json"),
    scope: 'https://www.googleapis.com/auth/firebase.messaging'
  )
  access_token = authorizer.fetch_access_token!['access_token']
  "Bearer #{access_token}"
end

ペイロード

ここに一番デバッグの時間がかかった。

REST Resource: projects.messages  |  Firebase

送信データ

今回の変更点の良い点は、iOSAndroid、Webなどのマルチプラットフォームに柔軟に対応できる形式になった点である。 一方辛いのは、今までに使用できていたデータの形式が一部使えなくなったことである。 アプリは配布されているため、互換性のないpush通知のフォーマットになってしまうため、移行に時間がかかった。

def data
  args = {}
  args[:android] = {
    priority: 10,
    data: {
      image_url: image_url
    }
  }
  args[:apns] = {
    headers: {
      'apns-priority': '10'
    },
    payload: {
      aps: {
        'mutable-content': 1,
        badge: 1,
      },
      'gcm.notification.image_url': image_url # イケてないkeyの命名だが、互換性を保つため
    }
  }
  # key, valueともにstringしか使えない。(ネストもできない)
  args[:data] = {
    url:             target_url,
    notification_id: notification_id.to_s
  }
  args[:notification] = {
    title: title,
    body:  body
  }

  # 送信先の指定: tokenかtopic、conditionのいずれか一つのみ指定
  if token.present?
    args[:token] = token
  elsif topic.present?
    args[:topic] = topic
  elsif condition.present?
    args[:condition] = condition
  end

  { message: args }
end

送信先の指定

前述した通り、今回はtokenに対してtopicを事前に登録しておく設計にした。 topicはクライアント側でしか実装できないかと思っていたが、サーバからもAPIで管理することができた。

Server Reference  |  Instance ID  |  Google Developers

紆余曲折あり、バッチで更新することになったので今回はbatchAddbatchRemoveAPIを使用した。 ここは、ドキュメント通りに実装するだけで良かったので、割愛する。

サンプルコード

細かい部分や処理は省略した。
あくまでも、送るのに最低限のコード。

class PushNotification
  include ActiveModel::Model

  KEY_FILE_PATH = "#{Rails.root}/config/google-services.json"
  FIREBASE_URI = URI.parse("https://fcm.googleapis.com/v1/projects/my_project_id/messages:send")

  attr_accessor :token, :body, :image_url #他にbody, titleなど

  validates :token, presence: true
  validates :body,  presence: true

  def send_notification
    http = build_http
    req = build_request
    errors = []

    if req.present?
      res = http.request(req)
      if res.code != '200'
        # エラーハンドリング
      end
    else
      # エラーハンドリング
    end

    return true if errors.empty?

    errors.unshift('プッシュ通知の送信に失敗しました。')
    self.error_message = errors.join("\n")
    false
  end

  private

  def build_http
    http = Net::HTTP.new(FIREBASE_URI.host, FIREBASE_URI.port)
    http.use_ssl = true
    http
  end

  def build_request
    return if invalid?

    req = Net::HTTP::Post.new(FIREBASE_URI.request_uri)
    req['Content-Type'] = 'application/json'
    req['Authorization'] = authorization

    req.body = data.to_json
    req
  end

  def data
    args = {}
    args[:android] = {
      priority: 10,
      data: {
        image_url: image_url
      }
    }
    args[:apns] = {
      headers: {
        'apns-priority': '10'
      },
      payload: {
        aps: {
          'mutable-content': 1,
          badge: 1,
        },
        'gcm.notification.image_url': image_url # イケてないkeyの命名だが、互換性を保つため
      }
    }
    # key, valueともにstringしか使えない。(ネストもできない)
    args[:data] = {
      url:             target_url,
      notification_id: notification_id.to_s
    }
    args[:notification] = {
      title: title,
      body:  body
    }

    # 送信先の指定: tokenかtopic、conditionのいずれか一つのみ指定
    if token.present?
      args[:token] = token
    elsif topic.present?
      args[:topic] = topic
    elsif condition.present?
      args[:condition] = condition
    end

    { message: args }
  end

  def authorization_header
    authorizer = Google::Auth::ServiceAccountCredentials.make_creds(
      json_key_io: File.open(KEY_FILE_PATH),
      scope: 'https://www.googleapis.com/auth/firebase.messaging'
    )
    access_token = authorizer.fetch_access_token!['access_token']
    "Bearer #{access_token}"
  end
end