76
44

More than 3 years have passed since last update.

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

Last updated at Posted at 2019-12-22

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

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

▼弊アプリのリマインダー登録画面
image.png

Cloud Functionsのスケジュール関数について

指定した時刻に実行されるように関数をスケジュール設定できる機能です。無料枠は3ジョブまで。
内部的には、GCPのCloud Pub/Subトピックが作成され、Cloud 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)はこちらです。途中、型の変換などしてますが、概ねの流れは掴めると思います

// 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で時間を選択したあと、秒を切り捨ててます)

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() メソッドで一斉に配信します。

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も上限が引き上がった
そのため500件ごとに通知の配列を区切ってからsendAllに渡して実行してます。

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さんsu-さんにツイートいただいた内容の方が参考になるのでそちらを見てください。

monoさんのツイート
su-さんのツイート

reminders/ドキュメントの準備・更新・削除について

このセクションについてはアプリの仕様によりけりと思いますので参考程度に見てください。

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

image.png

なぜドキュメントコピーしているか

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

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

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

考察: Cloud Tasks でのリマインダー実装について

最近、GCPのCloud Tasksを知って少し調べてみたのですが、

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

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

個人開発アプリの宣伝

今回の記事の内容でリマインダーも対応しているTODOアプリを作成しました!

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

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

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

ダウンロードはこちらから:iphone:
※現状、iOS版のみ

store_1.png

76
44
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
76
44