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

## 前提

チャットアプリを作っており、以下の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

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

```typescript
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のブログ](https://cloud.google.com/blog/products/serverless/cloud-functions-pro-tips-building-idempotent-functions) をまねた実装例。GCPブログはカウンティングではなくEmailの送信だからこその実装であって、Firestoreを使ったカウンティングのために、この実装を採用する必要はない気がする。

```typescript
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に持たせたり、分散カウンタを利用する方が良い。

## 参考にしたページ

- [Cloud Functions for Firestoreの重複発火を見越した冪等性の実装方法を考える](https://qiita.com/mogmet/items/394fb13ea39a9f587a1e#%E6%96%B9%E6%B3%953-eventid%E3%81%AE%E9%87%8D%E8%A4%87%E3%82%92%E9%98%B2%E3%81%90)