前提
チャットアプリを作っており、以下の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に持たせたり、分散カウンタを利用する方が良い。