はじめに
FirebaseのNoSQLデータベースであるFireStoreは、トランザクション制御が可能です。
しかし、RDBとは異なりクセのある概念や書き方に戸惑う方が多いのではないでしょうか?
私自身、受託開発の案件でFireStoteを扱っておりますが、とにかくクセが強くて何度も挫折しかけました。今回、そんな初見殺しのトランザクションの制御方法についてコードベースで紹介しようと思います。FireStoreを採用した開発で苦戦されてる方は是非参考にしてみてください。
※この記事は、Admin SDKが対象です
※この記事で紹介する制御方法はあくまで私個人のやり方です。正しい考え方・書き方等のベストプラクティスがあれば、ご教授いただけますと幸いです!
対象読者
- FireStoreのトランザクションに戸惑っている方
- Admin SDKを使っている方
SDKについて
FirebaseのSDKは、以下の2種類あります。
- クライアントSDK(web、アプリ)
- Admin SDK (サーバー)
サーバーサイドでは、Admin SDK を使います。
ex) APIサーバ、サーバーレスな実行環境(AWS Lambda、CloudFunctions)
排他制御
SDKによって、トランザクションのロック方法が異なります。
詳しくは、トランザクションの直列化可能性と分離をご覧ください。
RDBの悲観的ロックとFireStoreの悲観的ロックは別物と捉えて良いかもしれません
RDBの悲観的ロックは、他からの読み取りをブロックします。readすら出来ません。
FireStoreの悲観的ロックは、他からの読み取りはブロックしません。他からのreadを許しますが、変更(update/delete)はブロックされます。
【実践】 トランザクション書いてみる
例題)ドキュメント作成時に連番を振る
FireStoreは、RDBのような自動連番の概念がありません。
もし連番を持たせる場合は、自前で実装する必要があります。
例では、連番フィールド → serialNumberとしてます。
トランザクションなしの場合
実装の流れ
- 最も最新の連番を持つドキュメントを取得
- 1で取得したドキュメントの連番に1足した合計値を得る
- 2の合計値を、次に作成するドキュメントの連番にセットして更新する
コード例(Node.js)
admin.initializeApp();
const db = admin.firestore();
// 1. 最も最新の連番を持つドキュメントを取得
const testQuery = await db.collection('test')
.orderBy('serialNumber', 'desc')
.limit(1)
.get();
const topSerialNumber = testQuery.docs[0].data().get('serialNumber');
// 2. 1で取得したドキュメントの連番に1足した合計値を得る
const serialNumber = topSerialNumber + 1;
// 3. 2の合計値を、次に作成するドキュメントの連番にセットする
const testDoc = await db.collection('test').doc().get()
testDoc.set({
id: testDoc.id,
serialNumber: serialNumber,
});
実行結果
同時リクエストを想定し、並列処理で実行した結果です。
{
"id": "sijfhiouf9w3rss2",
"serialNumber": 1
},
{
"id": "wfwfkjw94h2ihfw3",
"serialNumber": 1
},
...
どちらも最新の連番が0に見えているので、両方 0 + 1 = 1 が 連番となってしまいました
トランザクションありの場合
実装の流れ
- トランザクションを開始
- 最も最新の連番を持つドキュメントを取得
- 2で取得したドキュメントをトランザクションで取得
- 3で取得したドキュメントを一度更新する
- 2で取得したドキュメントの連番に1足した合計値を得る
- 5の合計値を、次に作成するドキュメントの連番にセットして更新する
- トランザクション終了
コード例(Node.js)
admin.initializeApp();
const db = admin.firestore();
// 1. トランザクション開始
db.runTransaction(async (tx) => {
// 2. 最も最新の連番を持つドキュメントを取得
const testQuery = await db.collection('test')
.orderBy('serialNumber', 'desc')
.limit(1)
.get();
let topSerialNumber = 0;
if(! testQuery.empty) {
const topTestRef = testQuery.docs[0].ref;
// 3. 2で取得したドキュメントをトランザクションで取得
const topTestDoc = await tx.get(topTestRef);
topSerialNumber = topTestDoc.data().get('serialNumber');
// 4. 3で取得したドキュメントを一旦更新する
tx.update(topTestRef, ...);
}
// 5. 2で取得したドキュメントの連番に1足した合計値を得る
const serialNumber = topSerialNumber + 1;
// 6. 5の合計値を、次に作成するドキュメントの連番にセットする
const testDoc = await db.collection('test').doc().get()
tx.create(testDoc.ref, {
id: testDoc.id,
serialNumber: serialNumber,
});
// 7. トランザクション終了
});
実行結果
同時リクエストを想定し、並列処理で実行した結果です。
{
"id": "sijfhiouf9w3rss2",
"serialNumber": 1
},
{
"id": "wfwfkjw94h2ihfw3",
"serialNumber": 2
},
...
正しい連番になりました
重要なのは、実装の流れ 4(3で取得したドキュメントを一度更新する)です。
FireStoreの悲観的ロックは同時書き込み(update/delete)時に、片方をプロックする仕様になっています。意図的に片方の処理を再試行もしくは失敗させるために、手順4であえて同じドキュメントを更新しています。
最後に
今回の記事では、Firestoreトランザクションの扱い方をシェアさせていただきました。
少しでもご参考になりますと幸いです。
ちなみに、本記事のトランザクションに対するアプローチは、私個人の独断と偏見が表れております。
ベストな方法・考え方がありましたら、ご教授及びご指摘をいただけると幸いです。
最後までお読みくださりありがとうございました!