はじめに
Firebase#2 の9日目の記事となります。
投稿が1日遅れてしまいました。。。
皆さんは、Firebase の Firestore を触ったことがあるでしょうか?
筆者もそうですが、RDBから入ったWebエンジニアにとって、NoSQLデータベースである Firestore は、RDBとはまったく違うので、最初は慣れが必要です。
今回は、Firestore のトランザクション処理、バッチ処理について、具体的なユースケースを交えて、どのように使うのかを書いていきます。
Firestore に登場する基本的な概念
RDBでは、基本的な概念として、テーブルがあり、カラム、レコードがありますが、Firestore にも以下のような基本的な概念が登場します。
まずこれらを知らないと Firestore のバッチ処理、トランザクション処理は、使いこなせません。
こちらの記事が、とてもわかりやすいです。
Firestore のトランザクション処理について
公式ドキュメントにて、トランザクションと一括書き込み(以下、バッチと略称) について言及されています。
公式ドキュメントを読んだ上で、以下のように自分なりに整理してみました。
Firestore のトランザクションの特徴
- トランザクション処理として当たり前ですが、途中で失敗すれば、トランザクション処理実行前の状態に戻す(原子性)
- Write処理の前にかならずRead処理をしなければならない
- トランザクション対象のドキュメントが変更中(編集中)の場合、リトライされる
- 1 回のトランザクションまたはバッチでは、ドキュメントに書き込めるのは、最大500個まで
- クライアントがオフラインの時は、トランザクションが失敗する
- Read処理は、
get()
、Write処理はset()
,update()
,delete()
のいずれか - Write処理は、一部のWrite処理だけ実行されることはなく、トランザクション成功時に、すべてのWrite処理が実行される
トランザクションの失敗条件
- Write処理の後にRead処理をしたとき
- トランザクション対象のドキュメントが、実行中のトランザクション処理とは別の処理で変更されているとき
- クライアントがオフラインの時
Firestore のバッチ処理について
トランザクション処理と違い、Read処理は必要とせず、Write処理だけ一括でやってしまいたいときが、こいつの出番です!
特徴まとめます。
Firestore のバッチ処理の特徴
- 1回の処理で、最大500個のドキュメンにWrite処理できる
- 使用するAPIは、
get()
は存在せず、set()
,update()
,delete()
のいずれか - トランザクションと比較して、シンプルに書ける
- データ整合性は度外視しているため、別の処理の影響を受けない(競合問題が発生しない)
- クライアントが、オフラインでも使用可能
- トランザクションとの使い分けの判断は、データのズレがユーザーに実害与えないあるいは、被害が小さいならバッチ処理を使うでもよい?
Firestore で、バッチ処理・トランザクション処理をしたいシーン
Firestore は、異なるモデル同士で、リレーションをもたせる場合、いくつかパターン1がありますが、FirestoreのようなNoSQLデータベースだからこそできる方法として、冗長化によるリレーション構築が挙げられます。
具体例として、Meetup
のようなイベント系SNSサービスを例に考えます。
このようなサービスの場合、イベントを主催・参加するUser
モデルとイベント自体のEvent
モデルが必要になるでしょう。
RDBであれば、Events
テーブルにuser_id
のようなキーを持たせて、JOIN
するでよいでしょう。
Firestore でもJOIN
は使えませんが、キーを持たせてリレーションを持たせることはできますが、あえて、FirestoreのようなNoSQLデータベースだからできる方法として、重複するデータを持たせて(非正規化)、リレーションを表現する場合について書いていきます。
(RDBでは、非正規化は、悪手ですが、Firestoreの場合は、必ずしもそうとは限りません)
しかし、重複したデータを許容する場合、片方のデータに変更があった場合、もう片方のデータも最新の変更を反映させて、 データの一貫性を担保させなければなりません。
このような問題に対して、データの一貫性を担保するためのバッチ処理と一貫性だけでなくデータの整合性も担保したい場合にトランザクション処理を行うのが有効だと考えられます。
Firestore のトランザクション処理の実装
あるユーザーが、イベントに参加するときの処理をトランザクション処理にします。
今回は、イベントそのものとイベントに参加するユーザーとして、それぞれEvent
モデル、User
モデル。
またイベントとユーザーは、それぞれ N:Nの関係になるよう中間テーブル的なEventAttendee
モデルがあるとします。
(Firestore は、JSONツリーのようにデータ構造を表現します。)
Event
{
id: 'event_1',
title: '痛風鍋会',
date: '20180-12-23 19:00:00 +0900',
attendees: {
attendee_1: { name: 'hoge' },
attendee_2: { name: 'fuga' },
attendee_3: { name: 'spam' }
}
}
EventAttendee
{
userId: '123456',
eventId: 'xxx'
}
User
{
name: 'samuraikun'
}
トランザクション処理を想定する例として、今まさに参加しようとしているイベントが、偶然、編集中だった場合に、イベントが編集し終えるのを待ってから、
Event
ドキュメントに参加者の情報を追加する処理をトランザクションで行います。
ユーザーが参加ボタンをクリックした時に発火するイベント処理を想定
const user = firebase.auth().currentUser;
const event = {
id: 'event_1',
date: '20180-12-23 19:00:00 +0900',
title: '痛風鍋会DX'
}
const newAttendee = {
userId: user.uid,
eventId: event.id
};
let eventDocRef = firestore.collection('events').doc(event.id);
// EventAttendeeドキュメントは、'eventID_ユーザーID'という命名規則にする
let eventAttendeeDocRef = firestore.collection('event_attendee').doc(`${event.id}_${user.uid}`);
await firestore.runTransaction(async transaction => {
// Eventドキュメントの参照を取得する
await transaction.get(eventDocRef);
// Eventドキュメント内の Map オブジェクトである attendees に新しい参加者情報を追加する
await transaction.update(eventDocRef, {
[`attendees.${user.uid}`]: newAttendee
});
// Event と User の紐づけ情報を持つ EventAttendee ドキュメントを新規作成する
await transaction.set(eventAttendeeDocRef, {
eventId: event.id,
userUid: user.uid,
eventDate: event.date
});
});
Firestore のバッチ処理の実装
バッチ処理の例としては、イベント参加者のプロフィール画像が更新された際、
そのユーザーが参加している現在・未来のイベントで、既に保持している重複したユーザー情報も最新のプロフィール画像に更新するというケースについてです。
実装は以下のようなイメージになります。
const firestore = firebase.firestore();
const user = firebase.auth().currentUser;
const today = new Date(Date.now());
// ユーザードキュメントの参照を取得
let userDocRef = firestore.collection('users').doc(user.uid);
// イベント参加者情報のドキュメントの参照を取得
let eventAttendeeRef = firestore.collection('event_attendee');
const updateProfilePhoto = async photo => {
try {
let batch = firestore.batch();
// User ドキュメントのプロフィール画像を更新
await batch.update(userDocRef, {
photoURL: photo.url
});
// 複数フィールドによるWHERE句のため、'firestore.indexes.json' 内で、
// 複合インデックスを設定していることを前提にしています。
let eventAttendeeQuery = await eventAttendeeRef
.where('userUid', '==', user.uid)
.where('eventDate', '>', today);
// 対象ユーザーがどのイベントに参加するかの情報を持つドキュメントを取得
let eventAttendeeQuerySnap = await eventAttendeeQuery.get();
for (let i = 0; i < eventAttendeeQuerySnap.docs.length; i++) {
// EventAttendeeドキュメントを元に、更新対象のイベントの参照を取得
let eventDocRef = await firestore
.collection('events')
.doc(eventAttendeeQuerySnap.docs[i].data().eventId);
let event = await eventDocRef.get();
// イベントに紐づく参加者のプロフィール画像を更新
batch.update(eventDocRef, {
[`attendees.${user.uid}.photoURL`]: photo.url
});
}
// バッチ処理の完了を宣言
await batch.commit();
} catch (error) {
console.log(error);
}
}
トランザクション処理と違い、Read処理が不要で、firestore.batch()
を定義してbatch.commit()
するだけで、それ以外は、通常の Firestoreの操作とほぼ変わらないのがいいですね!
ちょっと、話が広がってしまい申し訳ないのですが、Firestore では、複数フィールドを条件にしたクエリ検索は、事前にどのフィールドを条件にするかを定義する必要があります。
それが、firebase-tools
で、firebase init
した際に、生成されるfirestore.indexes.json
になります。
今回は、以下のような内容で、インデックスを設定し、firebase deploy --only firestore:indexes
で、Firestoreに設定を反映させました。
firestore.indexes.json
{
"indexes": [
{
"collectionId": "event_attendee",
"fields": [
{ "fieldPath": "userUid", "mode": "ASCENDING" },
{ "fieldPath": "eventDate", "mode": "ASCENDING"}
]
}
]
}
おわりに
Firebase は、個人開発界隈では、けっこう使われている印象がありますが、やはり企業の一プロダクトとして、Firebaseを使っているところは、まだまだ少ないかなと思っています。
その理由の1つに、Firestore がRDBではなく、NoSQLデータベースであるがゆえDB設計で悩むことや
そもそもトランザクションできんの?みたいな疑問が、多少あるのかなと思っています。
やはりWeb開発の肝は、データベースですから、RDBではない NoSQLデータベースで、本当にやっていけるのか?という不安はあると思います。僕も不安です。
なので、現実的には、RDBを使用した既存サービスよりも、新規プロダクトにおいて、FirebaseもといFirestoreを使うのがメリットが大きいと思っています。
まだ人気が出るかわからない状況で、リリース日に急かされ、RDBで、雑なテーブル設計をして、後で苦しむよりかは、スキーマレスな Firestore でDB設計を柔軟に変更していく方が、Webサービスのスピード感に合うかと思います。
つまりは、Firebase 大好きだからみんな使ってくれよな! っていうことが伝えたかったのでした!