RELATIONS Developers Blog

RELATIONS株式会社の開発ブログです。

ExpoとFirebaseを使った、サーバーレスでのプッシュ通知配信の検証続き

あけましておめでとうございます!RELATIONS株式会社の大川です。

弊社では1月が新しい年度の始まりなので 今期目標やその達成に向けたアクションをチームメンバーで共有していました。 (その様子はこちらからご覧いただけます。) 自分はこの開発者ブログを毎月更新することを目標のひとつとして、 引き続き業務の中で得られたノウハウをどんどんオープンにしていければと思います。

前回のおさらい

前回の記事では、 Expo Push APIとFirebaseを組み合わせてサーバーレスでのプッシュ通知配信を検証していました。

投稿されたメッセージはCloud Firestoreに保存され、保存されたことをトリガーにしてCloud Functionsが起動し、Expo Push APIをリクエストしてプッシュ通知を配信します。
前回検証したプッシュ通知の配信フロー

当初は新しいデータがFirestoreに保存されると、登録されているすべての端末にプッシュ通知を配信していました。しかし今回は実際のアプリ運用を想定して、ユーザーを絞り込んで配信するように変更しました。

投稿されたメッセージはCloud Firestoreに保存され、それをトリガーにCloud Functionsが起動します。メッセージが投稿されたトピックを購読しているユーザーを検索し、そのユーザーがもつデバイストークンをもとにExpo Push APIをリクエストして、一部のユーザーにだけプッシュ通知を配信するようにします。
今回検証したプッシュ通知の配信フロー

ユーザーごとにデバイストークンを管理する

デバイストークンをFirestoreに保存する処理は、以下のsaveDeviceTokenというメソッドで実装しています。
前回はFirestore上のtokensコレクションにデバイストークンをただ保存しているだけだったので、
アプリ起動時にsaveDeviceTokenメソッドが呼び出されると、その度にデバイストークンのデータが増えていく実装になっていました。
(同じデバイストークンが何回も登録されており、その分だけ同じプッシュ通知が配信されてしまう状態でした。)

// 前回の記事で実装したデバイストークン保存処理
const saveDeviceToken = async (token) => {
  try {
    // Firestore にデバイストークンを保存
    const docRef = await db.collection("tokens").add({ token })
    console.log(`document written with ID:${docRef}`); // デバッグ用
  } catch (err) {
    console.error(`error: ${err}`);
  }
};

今回はFirebase Authenticationも組み合わせてユーザー情報をFirestoreで管理できるようにしているので、ログイン中のユーザーが保持しているデバイストークンをFirestoreから取得し、saveDeviceTokenに渡されたトークンがすでに登録されていないかを確認しています。

// ユーザー情報と紐づけて管理するように修正したデバイストークン保存処理
const saveDeviceToken = async (token) => {
  try {
    // token(ExponentPushToken[xxxxx]の形式)からランダム文字列そうなところだけ取り出す
    // firestoreのパスに記号が使えないため
    const matchResults = token.match(/ExponentPushToken\[(.*)\]/);
    const actualToken = matchResults[1];
    if (!actualToken) return;

    // Firestoreから保存済みのデバイストークンを取得
    const user = await firebase.auth().currentUser;
    const userRef = db.collection("users").doc(user.uid);
    const userDoc = await userRef.get();
    const userInfo = userDoc.data();
    const currentTokens = userInfo.tokens || [];

    // デバイストークンが保存済みかを確認
    if(!currentTokens[actualToken]) {
      // Firestore にデバイストークンを保存
      currentTokens[actualToken] = true;
      await userRef.update({ tokens: { ...currentTokens }});
    }
  } catch (err) {
    console.error(`device token save error: ${err}`);
  }
};

プッシュ通知を配信するユーザーを絞り込む

プッシュ通知配信をリクエストする処理は、FirebaseのCloud Functionsで実装しています。
前回の記事ではmessagesコレクションに新規メッセージが投稿されたことをトリガーにして、全デバイストークンを取得しExpo Push APIを利用してプッシュ通知を配信していました。

// トリガー設定とデバイストークン検索部分のみ抜き出したもの
// tokensコレクションのデータを全取得しています
exports.sendNotification = functions.firestore
  .document('messages/{messageId}')
  .onCreate(async () => {
    // デバイストークンを Firestore から全検索
    const query = db.collection('tokens').where('token', '>', '');
    const snapshot = await query.get();
    const tokens = snapshot.docs.map(doc => doc.get('token'));
(...以下略)

今回はメッセージが投稿されたトピックの購読ユーザーを検索し、そのユーザーが持っているデバイストークンの端末だけにプッシュ通知を配信をしていきます。

まずは、関数が起動するトリガー設定を変えてメッセージが投稿されたトピックのIDを取得します。
トピックIDは、ドキュメントパスでワイルドカード指定した部分のパラメーターをコンテキストから取得しています。

// トリガー指定とパラメーターの取得
exports.sendNotification = functions.firestore
  .document('/topics/{topicId}/messages/{messageId}')
  .onCreate(async (_, context) => {
    const { topicId } = context.params;  // ワイルドカード指定したパラメーターの値を取得
(...以下略)

取得したトピックIDをもとに、そのトピックを購読しているユーザーを検索します。
そして、購読ユーザーが保持しているExpo用のトークン文字列からデバイストークンを組み直して、tokens配列に追加しています。

// メッセージが投稿されたトピックを購読しているユーザーを検索
const usersSnapshot = await db
  .collection('users')
  .where(`topic.${topicId}`, '==', true)
  .get();
if (usersSnapshot.empty) {
  console.log('empty query result');
  return;
}

const usersDoc = usersSnapshot.docs;
// usersからデバイストークンを取得
const tokens = usersDoc.reduce((t, doc) => {
  const u = doc.data();
  if (!u || !u.tokens) return t;
  // アプリ側でトークンのランダム文字列だけをFirestoreに保存するような処理をしているので本来のデバイストークンに復元
  const userTokens = Object.keys(u.tokens).map(t => `ExponentPushToken[${t}]`);
  return t.concat(userTokens);
}, []);

Expo Push APIをリクエストする部分では、配信する通知メッセージにトリガーとなったトピック名を含めるようにしました。

// メッセージが投稿されたトピックのデータを取得
const topicDoc = await db
  .collection('topics')
  .doc(topicId)
  .get();
const topic = topicDoc.data();

// プッシュ通知用のメッセージオブジェクトを作成
const messages = [];
tokens.forEach(pushToken => {
  if (!Expo.isExpoPushToken(pushToken)) {
    console.error(`Push token ${pushToken} is not a valid Expo push token`);
  }

  messages.push({
    to: pushToken,
    sound: 'default',
    body: `${topic.name} が更新されました!!`,  // トピックの名前を通知内容に利用
    data: { withSome: 'data' },
  });
});
const chunks = expo.chunkPushNotifications(messages);
const tickets = [];
(async () => {
  // Expo Push API をリクエスト
  for (let chunk of chunks) {
    try {
      const ticketChunk = await expo.sendPushNotificationsAsync(chunk);
      console.log(ticketChunk);
      tickets.push(...ticketChunk);
    } catch (error) {
      console.error(error);
    }
  }
})();

(関数の全体はこちら からご覧いただけます。)

これで、各トピックを購読しているユーザーにだけ、新規メッセージが投稿されたときのプッシュ通知を配信することができるようになりました!

Expo Client Appで動作確認した端末にプッシュ通知が配信されました
配信されたプッシュ通知

Firestoreで配列を扱うためのTips

上記のサンプルコードでは、ユーザーが購読しているトピックやデバイストークンを配列形式ではなく、値のマップとして保存しています。

こうすることで、Firestoreからデータ検索をするためのクエリを書くときの条件指定が簡単になります。
(参考: 配列、リスト、セットの操作 | Firebase

// プッシュ通知配信をリクエストするCloud Functionsから抜粋

// メッセージが投稿されたトピックを購読しているユーザーを検索
const usersSnapshot = await db
  .collection('users')
  .where(`topic.${topicId}`, '==', true) // 購読しているトピックを {“トピックID”: true} の形式で保持しているので、whereの条件指定が簡単になる
  .get();

ただし、フィールド名に記号が使えないので、Expoのデバイストークンはランダム文字列部分だけを保存するようにしています。

// React Nativeアプリのデバイストークン保存処理から抜粋

// Expoのトークン(ExponentPushToken[xxxxx]の形式)からランダム文字列だけ取り出す
const matchResults = token.match(/ExponentPushToken\[(.*)\]/);
const actualToken = matchResults[1];

(...略...)

// Firestore にデバイストークンを保存
currentTokens[actualToken] = true; // トークンを {“トークン文字列”: true} 形式でセットしている
await userRef.update({ tokens: { ...currentTokens }});

デバイストークンの消し込み処理

iOS端末に対してプッシュ通知の配信を行うAPNsでは、無効になっているデバイストークンをフィードバックするサービスがあります。
これを利用することで不要なプッシュ通知配信をAPNsにリクエストせずに済みます。
(参考: APNsのフィードバックサービスについて

Expo Push APIは、プッシュ通知配信をリクエストしたときに無効になっているデバイストークンが指定されてもOKが返ります。
(Expo Push APIが、処理を完了したことを意味しているようです。)

// プッシュ通知を配信する関数をローカルで実行した結果
firebase > sendNotification({ text: 'test' }, { params: { topicId: 'MwaYLDF1PQQebt6Vuggu', messageId: 'testId' }})
'Successfully invoked function.'
firebase > info: User function triggered, starting execution
info: Execution took 3667 ms, user function completed successfully
info: [ { status: 'ok', id: '5cd87dce-d426-4865-8c9e-aa5fa1b36bb7' } ]

ただし、指定されたデバイストークンがAPNs側で無効になっている場合は、Expo側がAPNsからのフィードバックを処理しているので、その結果を確認することができます。

// プッシュ通知の処理結果を確認
$ curl -H "Content-Type: application/json" -X POST "https://exp.host/--/api/v2/push/getReceipts" -d '{
>   "ids": ["5cd87dce-d426-4865-8c9e-aa5fa1b36bb7"]
> }'
{"data":{"5cd87dce-d426-4865-8c9e-aa5fa1b36bb7":{"status":"error","message":"The Apple Push Notification service failed to send the notification (reason: Unregistered, status code: 410). Read Apple's docs about \"Communicating with APNs\" to learn what this error means.","details":{"apns":{"reason":"Unregistered","statusCode":410},"error":"DeviceNotRegistered","sentAt":1547691632},"__debug":{}}}}

この仕組みを利用して不要なプッシュ通知配信をリクエストしないためには、いくつかの実装が必要になります。

まずExpo Push APIでプッシュ通知配信したときに、リクエストしたデバイストークンごとに割り振られるidをデバイストークンとセットで保存する必要があります。
フィードバックサービスの結果を確認するときには、そのidを指定して結果を確認し、APNs側で無効となっていればデバイストークンを削除していきます。
ここで重要なのは、APNs側でデバイストークンが無効になるまでにタイムラグがあるので、この処理をプッシュ通知配信のCloud Functionsとは別で日次のバッチ処理として実装するがあるということです。

今回のような検証やプロトタイピングの場合など、デバイストークンがそんなに増加しないのであればフィードバックサービスに対応する必要はなさそうですが、実際のアプリを運用していくタイミングで用意したほうがいいと思います。
(バッチを実装するよりはFirebase Cloud Messagingに対応したほうが楽かもしれないとも正直考えました。。。)

まとめ

今回の検証では実際のアプリ運用を想定して、ユーザーを絞り込んでプッシュ通知を配信してみました。

今後もExpo関連で気になったこと(年明けにリリースされたSDK v32の機能など)を検証していこうと思います。