LoginSignup
0
0

More than 1 year has passed since last update.

CloudFunctionsを使ったFirestoreのDocumentのカウンティング

Last updated at Posted at 2022-10-12

前提

チャットアプリを作っており、以下の2つのDocumentがあるとする。

  • チャットメッセージ(Message)
    • Path: chats/{chatId}/messages/{messageId}
    • フィールド
      • answerCount:メッセージへの回答総数
      • 後述のAnswererの内、回答済みの人数を設定する
  • チャットメッセージへの回答者(Answerer)
    • Path: chats/{chatId}/messages/{messageId}/answerers/{answererId}
    • フィールド
      • answer:メッセージに回答済みならtrue

Answerer.answerがtrueの回答者の数をMessage.answerCountに設定したい。以下、Cloud Functionsを用いた実現方法を書く。

実装のポイント

冪等性を担保することが必須。Cloud Functionsは同じeventに対して何回もFunctionが実行されたり、並列でFunctionが実行されることがある。処理済みのeventを表すDocumentを用意し、それでCloud Functionsが処理済み/処理中/未処理なのかを判定する。

実装例1

トランザクションを使ってストレートに実装。

import * as functions from "firebase-functions";
import * as admin from "firebase-admin";
import {firestore} from "firebase-admin";
import FieldValue = firestore.FieldValue;

admin.initializeApp();

export const addAnswer = functions
    .region("asia-northeast1")
    .firestore
    .document("chats/{chatId}/messages/{messageId}/answerers/{answererId}")
    .onCreate(async (snapshot, context) => {
      const newValue = snapshot.data();
      if (!newValue.answer) {
        return;
      }

      await handleIfNeeded(context.eventId, "addAnswer", (t) => {
        const messageRef = snapshot.ref.parent.parent!;
        t.update(messageRef, {"answerCount": FieldValue.increment(1)});
      });
    });

export const updateAnswer = functions
    .region("asia-northeast1")
    .firestore
    .document("chats/{chatId}/messages/{messageId}/answerers/{answererId}")
    .onUpdate(async (snapshot, context) => {
      const oldValue = snapshot.before.data();
      const newValue = snapshot.after.data();
      if (newValue.answer === oldValue.answer) {
        return;
      }

      await handleIfNeeded(context.eventId, "updateAnswer", (t) => {
        const messageRef = snapshot.after.ref.parent.parent!;
        if (newValue.answer) {
          t.update(messageRef, {"answerCount": FieldValue.increment(1)});
        } else {
          t.update(messageRef, {"answerCount": FieldValue.increment(-1)});
        }
      });
    });

export const deleteAnswer = functions
    .region("asia-northeast1")
    .firestore
    .document("chats/{chatId}/messages/{messageId}/answerers/{answererId}")
    .onDelete(async (snapshot, context) => {
      const oldValue = snapshot.data();
      if (!oldValue.answer) {
        return;
      }

      await handleIfNeeded(context.eventId, "deleteAnswer", (t) => {
        const messageRef = snapshot.ref.parent.parent!;
        t.update(messageRef, {"answerCount": FieldValue.increment(-1)});
      });
    });

// eventが未処理のときだけhandlerを実行する。
const handleIfNeeded = async (
    eventId: string,
    key: string,
    handler: (t: FirebaseFirestore.Transaction) => void,
) => {
  const eventRef = admin.firestore()
      .collection("functions-events")
      .doc([eventId, key].join("-"));

  return admin.firestore().runTransaction(async (t) => {
    const doc = await t.get(eventRef);
    if (doc.exists) {
      return;
    }

    handler(t);
    t.set(eventRef, {});
  });
};

実装例2

GCPのブログ をまねた実装例。GCPブログはカウンティングではなくEmailの送信だからこその実装であって、Firestoreを使ったカウンティングのために、この実装を採用する必要はない気がする。

import * as functions from "firebase-functions";
import * as admin from "firebase-admin";
import {firestore} from "firebase-admin";
import FieldValue = firestore.FieldValue;

const leaseTime = 60 * 1000;

admin.initializeApp();

export const addAnswer = functions
    .region("asia-northeast1")
    .firestore
    .document("chats/{chatId}/messages/{messageId}/answerers/{answererId}")
    .onCreate(async (snapshot, context) => {
      const newValue = snapshot.data();
      if (!newValue.answer) {
        return;
      }

      await once(context.eventId, "addAnswer", (batch) => {
        const messageRef = snapshot.ref.parent.parent!;
        batch.update(messageRef, {"answerCount": FieldValue.increment(1)});
      });
    });

export const updateAnswer = functions
    .region("asia-northeast1")
    .firestore
    .document("chats/{chatId}/messages/{messageId}/answerers/{answererId}")
    .onUpdate(async (snapshot, context) => {
      const oldValue = snapshot.before.data();
      const newValue = snapshot.after.data();
      if (newValue.answer === oldValue.answer) {
        return;
      }

      await once(context.eventId, "updateAnswer", (batch) => {
        const messageRef = snapshot.after.ref.parent.parent!;
        if (newValue.answer) {
          batch.update(messageRef, {"answerCount": FieldValue.increment(1)});
        } else {
          batch.update(messageRef, {"answerCount": FieldValue.increment(-1)});
        }
      });
    });

export const deleteAnswer = functions
    .region("asia-northeast1")
    .firestore
    .document("chats/{chatId}/messages/{messageId}/answerers/{answererId}")
    .onDelete(async (snapshot, context) => {
      const oldValue = snapshot.data();
      if (!oldValue.answer) {
        return;
      }

      await once(context.eventId, "deleteAnswer", (batch) => {
        const messageRef = snapshot.ref.parent.parent!;
        batch.update(messageRef, {"answerCount": FieldValue.increment(-1)});
      });
    });

// once は、handlerを1回だけ実行する。
const once = async (
    eventId: string,
    key: string,
    handler: (batch: FirebaseFirestore.WriteBatch) => void,
) => {
  // CloudFunctionsのevent IDをFirestoreのDocumentに保存して、eventが処理済みかの判定に使う。
  // DocumentIDにeventIDそのものではなく、Function名をつけておくのがポイント。
  // そうしておけば、1つのイベントに複数のFunctionを登録している場合に対応できる。
  const eventRef = admin.firestore()
      .collection("functions-events")
      .doc([eventId, key].join("-"));
  if (!await shouldHandle(eventRef)) {
    return;
  }

  const batch = admin.firestore().batch();
  handler(batch);
  // 処理が完了したことを記録する
  batch.update(eventRef, {"done": true});
  await batch.commit();
};

// eventを処理すべきか判定する。
// eventの処理が成功していない かつ eventを処理中でなければtrueを返す。
// eventが処理中のときはエラーを発生させる。
// それ以外の場合はfalseを返す。
const shouldHandle = (
    eventRef: FirebaseFirestore.DocumentReference<FirebaseFirestore.DocumentData>,
): Promise<boolean> => {
  return admin.firestore().runTransaction(async (t) => {
    const doc = await t.get(eventRef);

    if (doc.exists) {
      const data = doc.data()!;

      if (data.done) {
        // event処理済み
        return false;
      }

      if (new Date() <= data.lease) {
        // event処理中と思われるので、エラーを発生させる。
        // Cloud Functionsのリトライ設定を有効にしておけば、再試行が行われる。
        return Promise.reject(new Error("Lease already taken, try later."));
      }
    }

    // eventの処理が開始したことを記録する
    t.set(eventRef, {
      lease: new Date(new Date().getTime() + leaseTime), // 他の処理を禁止する時間
      done: false, // 処理は未完了
    });
    return true;
  });
};

補足

ここでは、Messageにカウント用のフィールドをもたせているが、更新頻度が高いならカウント用のフィールドは別Documentに持たせたり、分散カウンタを利用する方が良い。

参考にしたページ

0
0
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
0
0