Firebase Cloud Functionsのスケジュール関数でユーザーごとのリマインダーを実装する

この記事はFirebaseアドベントカレンダー 22日目の記事です。
元ラーメン屋店長プログラマのObjective-ひろC([@hirothings](https://twitter.com/hirothings))です🍜

[個人開発したTODOアプリ](https://apps.apple.com/jp/app/id1460380962)でCloud Functionsのスケジュール関数 + Firestore + FCM (Firebase Cloud Messaging)でリマインダーを実装しました。
FCMの記事はたくさんありますが、スケジューリングされたユーザーごとのリマインダー通知の実装についてあまりネットに知見がないので、今回その手順について書きます。

▼弊アプリのリマインダー登録画面
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/30423/64e8738f-482f-4af1-0442-8e0e24bc457f.png)


## Cloud Functionsのスケジュール関数について
指定した時刻に実行されるように関数をスケジュール設定できる機能です。無料枠は3ジョブまで。
内部的には、GCPの[Cloud Pub/Sub](https://cloud.google.com/pubsub/)トピックが作成され、[Cloud Scheduler](https://cloud.google.com/scheduler/)のCronを使用してこのトピックに関するイベントがトリガーされる仕組みです。

詳しくは公式ドキュメント参照
https://firebase.google.com/docs/functions/schedule-functions

料金について
https://cloud.google.com/scheduler/pricing?hl=ja

## リマインダーの実装
コスト面やPub/Subの割り当て上限を考慮すると、単純にユーザーのリマインダーごとにCron Jobを作ることは現実的ではないことがわかります。
そこで、**1分ごとのクーロンを回して、実行したタイミングの日時に一致するリマインダーがFirestoreのドキュメントに存在したら、それをもとにリマインダー通知する**という実装にしました。

1. リマインダーのコレクションから現在時刻のリマインダーをクエリ
2. 完了済みフラグの立っていないリマインダーが1件以上あったら、FCMのPUSH通知を実行

実際のコード(TypeScript)はこちらです。途中、型の変換などしてますが、概ねの流れは掴めると思います

``` ts
// 1分ごとのクーロンでPUSH通知を送信する
exports.sendReminder = functions.region('asia-northeast1')
  .runWith({ memory: '512MB' })
  .pubsub.schedule('every 1 minutes')
  .timeZone('Asia/Tokyo')
  .onRun(async (context) => {
    // 秒を切り捨てた現在時刻
    const now = (() => {
      let s = admin.firestore.Timestamp.now().seconds
      s = s - s % 60
      return new admin.firestore.Timestamp(s, 0)
    })()
    // リマインダーコレクションから現在時刻のリマインダーをクエリ
    const remindersRef = await admin.firestore().collection('reminders')
      .where('remindAt', '==', now)
      .get()

    const notCompletedReminderTasks: Model.Task[] = remindersRef.docs
      .map((doc) => doc.data() as Model.Task)
      .filter((task) => !task.isDone) // 完了していないタスクにフィルタする

    const reminders = await prepareReminders(notCompletedReminderTasks)
    // リマインダーが1件以上あったら、FCMのPUSH通知を実行
    if (reminders.length !== 0) {
      await sendPushNotificationsForReminder(reminders)
    }
    return 'リマインダー通知処理終了'
  })
```

#### `const now` の部分について補足。
関数の実行後、`Timestamp.now()` で日時を取得したところ、秒単位で誤差があったので、秒単位で日時の比較をするとアプリ側で登録した時間とズレるため、秒を切り捨てる処理をしてから比較をしています。
(アプリ側もDate Pickerで時間を選択したあと、秒を切り捨ててます)

``` ts
const now = (() => {
  let s = admin.firestore.Timestamp.now().seconds
  s = s - s % 60
  return new admin.firestore.Timestamp(s, 0)
})()
```

## 実際の通知(Firebase Cloud Messaging)
ユーザーのregistration tokenとリマインダーの内容を紐付けて、FCMに必要な`admin.messaging.Message` 型を準備し、FCMの `sendAll()` メソッドで一斉に配信します。

``` ts
async function sendPushNotificationsForReminder(reminders: Model.Reminder[]) {
  const messages: admin.messaging.Message[] = []
  for (const reminder of reminders) {
    // ユーザーのトークンは複数ある可能性があるので、ユーザーのトークン分メッセージを作る
    const msgs: admin.messaging.Message[] = reminder.tokens.map((token) => {
      const notification: admin.messaging.Notification = {
        title: "リマインダー",
        body: reminder.task.content
      }
      const message: admin.messaging.Message = {
        token: token,
        notification: notification
      }
      return message
    })
    Array.prototype.push.apply(messages, msgs)
  }
  if (messages.length === 0) { return }
  await sendAll(messages)
}
```

sendAllメソッドは一回の送信で500件までしか送れません。（[最近100→500件までAdmin Node.js SDKも上限が引き上がった](https://firebase.google.com/support/release-notes/admin/node#cloud-messaging_1)）
そのため500件ごとに通知の配列を区切ってからsendAllに渡して実行してます。

``` ts
async function sendAll(messages: admin.messaging.Message[]) {
  // 500件ずつに分割する(500件以上は、sendAllメソッド側でエラーになるため)
  const msgs = messages.reduce((acc: admin.messaging.Message[][], val) => {
    const MAX_BATCH_SIZE = 500
    const last = acc[acc.length - 1]
    if (last.length === MAX_BATCH_SIZE) {
      acc.push([val])
      return acc
    }
    last.push(val)
    return acc
  }, [[]])

  // Promise.allで分割して配信
  const promises = msgs
    .map((ms) => admin.messaging()
      .sendAll(ms)
      .then((batchResponse) => {
        batchResponse.responses.forEach((res) => {
          // FCM側でエラーが発生した場合、StackDriverにログを残す
          if (res.error) {
            console.error(res.error)
          }
        })
        console.log(`send messages: ${ms.length} count`)
      })
      .catch((e) => {
        console.error(e)
      })
    )
  await Promise.all(promises)
}
```

実行する通知の件数が多い場合、メモリ不足が懸念されるため512MBまでメモリ上限を引き上げています。`Promise.all`を使って並列処理をしていますが、1秒ごとに処理が完結しても1分で30,000件送れるので直列実行で良いかもしれません。
ここら辺のパフォーマンスに関して現在はリマインダーの数が少なく問題になっていません。詳しくは、[mono](https://qiita.com/mono0926)さん[su-](https://qiita.com/sgr-ksmt)さんにツイートいただいた内容の方が参考になるのでそちらを見てください。

[monoさんのツイート](https://twitter.com/_mono/status/1191469693120286721?s=20)
[su-さんのツイート](https://twitter.com/_sgr_ksmt/status/1191346061295509507?s=20)

## reminders/ドキュメントの準備・更新・削除について
このセクションについてはアプリの仕様によりけりと思いますので参考程度に見てください。

先のリマインダー通知処理で参照するreminders/配下のドキュメントの準備について説明します。
ユーザーが登録したTODO自体はセキュリティルールの関係上、`families/{familyID}/tasks/`に保存しています。そのTODO内にリマインダーが設定されていたら、トリガーイベントで`reminders/`配下にドキュメントコピーしています。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/30423/d105927b-c0d1-90ab-9047-4f804ce8d0ea.png)


#### なぜドキュメントコピーしているか
コレクショングループクエリで`tasks/`にクエリを実行してremindAtのフィールドが現在時刻のドキュメントを取得する方法も考えましたが、`tasks/`の総数がすでに1万件を超えているため、このアプローチは辞めました。リマインダーが確実に存在する`reminders/`の件数は`tasks/`の1/10以下なので省エネです。
毎分クーロンが走ると年間で525,600回クエリが実行されるので負荷を極力減らしたい意図があります。

* `tasks/{taskID}`ドキュメントの更新・削除
* `remindAt`フィールドの削除

時も`reminders/`のドキュメントに同期を取っています

## 考察: Cloud Tasks でのリマインダー実装について
最近、GCPの[Cloud Tasks](https://cloud.google.com/tasks/)を知って少し調べてみたのですが、

* タスクの最大実行予定時間: 現在の日時から 30 日
* 1 分あたり 60 回のリクエスト

とあり、リマインダー用途には要件的に難しそうです。
スケジューリングできるので、日時指定のイベントの配信などに使えそうです

## 個人開発アプリの宣伝
今回の記事の内容でリマインダーも対応しているTODOアプリを作成しました！

家族・カップル向けのTODOアプリです👨‍👩‍👦
招待リンクをパートナーにLINEなどで送って、参加してもらうだけで、TODOをリアルタイムで共有できるようになります。

* ダークモード対応
* フルFirebase

..などこだわって作っているので、ぜひ触ってみてもらえると嬉しいです！

ダウンロードは[こちら](https://apps.apple.com/jp/app/id1460380962)から:iphone:
※現状、iOS版のみ

![store_1.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/30423/7fac911a-11ed-fcd6-f2b8-7a79c9d89fce.png)