Cloud Firestoreの勘所 パート2 — データ設計
投稿型のブログサービスを設計しながらデータ構造について考える
パート1の前置きが長くなりましたが、これからFirestoreの具体的な使い方を見ていきます。
実例があると分かりやすいかと思い、Qiita のような投稿型のブログサービスを題材に考えていきます。
Firebaseプロジェクトの作成
https://console.firebase.google.com にて適当なサンプルプロジェクトを作ります。今回は firestore-sandbox
を作りました(実アプリの場合、開発環境・テスト環境・本番環境など3つ以上用意するのが良いですがサンプルなので1つで済ませます)。
Firebaseプロジェクトはデフォルトで無料なので、個人アカウントでも安心して作れます🤗無料のままでもそれなりに本格的な利用に耐えられますし、有料プランに上げることになっても大抵は驚くほど安く済むと思います。
Firestoreのセキュリティルール初期設定
プロジェクト上でFirestoreを選ぶと、セキュリティルール設定の選択を迫られます。
初めての場合は Start in test mode
がおすすめです。全ドキュメントに対して誰でもアクセスできるので大抵のサービスではこのままリリースはできないですが、セキュリティルール(パート3で説明します)のことを考えるのを後回しにできて楽になります。
ENABLE
を押すと、次のようにFirestoreの空の画面に移ります。 RULES
タブに❗️マークが付いていて、危険なセキュリティルールのままリリースしてしまうミスを防げるようになっています。
投稿型のブログサービスを設計
とりあえず、素朴にルートに posts
コレクションと users
コレクションを設けてみます。
string型のauthorIdを参照型のauthorRefへ改善
まず、 authorId
がstring
型になっていますが、Firestoreには参照型があるので、基本的にはそちらを使った方が良いです。名前は個人的には authorRef
などとしています。
参照型を使うと格納されているものが明確になり、他の文字列との取り違えも防ぎやすいです。また、どの階層のコレクションのどのドキュメントを指しているのかが明確になります(文字列の authorId
では「多分ルートのusersコレクションのドキュメントを指しているのかな?」程度の推測しかできません)。
その他、合計11個の種類のデータ型に対応しています。JSONより表現力豊かなので、用意されているものの中から最も用途に適したものをしっかり選んで使いましょう。
ついでに、上図のように作成日時を表す、timestamp型の createTime
も追加してみました。
authorにuserデータそのものを入れるのもあり
参照型のauthorRef
ではなくオブジェクト型の author
として定義して、そこにuserデータそのものを入れるのもありです。
こうすると、 posts
コレクション一覧の1クエリで、 author
の name
など必要な情報を取れる利点があります。 参照型のauthorRef
として持っていた場合は、1 postごとに author
取得の1クエリが必要になって、例えば1ページ10件の記事一覧を表示する際に1 + 10 = 11クエリが必要になります。RDBのN+1問題のように深刻に思うかもしれませんが、Firestoreはクライアントからこのように表示件数分のクエリを発行することはよくあること(クライアントサイドジョインと言います)で、あまり気にせずでOKです。とはいえ1クエリで取れる方がクライアント実装もシンプルできる・表示速度も少し上がる・課金額も抑えられる(この場合およそ半分)などのメリットなどもあり、一覧表示の都合だけ考えればオブジェクト型の author
として持つことの方が優れています。
一方、オブジェクト型の author
方式では、プロフィール情報変更などで author
の name
などが変わってしまった場合、紐付いたpostsすべての author
情報変更が必要になります。具体的には次の操作が必要になります。
posts
から特定のauthor
のドキュメント群を取得- 取得された
posts
ドキュメント全件のauthor
データを更新
その author
の既存 posts
数が多くなれば理屈的には青天井に重い処理になって行きます。とはいえ、記事数の規模としては高が知れているはずという見方もできるので、ブログサービスの場合は現実的にはこのやり方でも破綻はしないと思います。
というわけで、参照型のauthorRef
方式と、オブジェクト型の author
方式と、迷うところですが、今回は前者のやり方で進めていきます。実際には細かい要件に応じてどちらが良いかはケースバイケースになるはずです。
Swiftでクエリしてみる
とりあえず、以上の状態でSwiftでクエリしてみます。まず、ログを見やすくするために、以下を定義しておきます。
extension DocumentSnapshot {
open override var description: String {
return "id: \(documentID), data: \(data() ?? [:])"
}
}extension DocumentReference {
open override var description: String {
return path
}
}
次のように posts
一覧を取得してみると、
let db = Firestore.firestore()db.collection("posts")
.getDocuments { (snapshot, error) in
snapshot!.documents.forEach { doc in
print(doc)
}
}
期待通りのログが出力されました。
id: HuvWyuX0NhjkNQu4LGOY, data: [“body”: Firebase🐶, “title”: Firebaseの記事です, “authorRef”: users/user2]
id: aAtCN1BLstIwYpNxB8X3, data: [“body”: Firestoreの記事です。, “title”: Hello Firestore, “authorRef”: users/mono]
単純な1フィールドのみでのクエリ
先ほどの結果にはmono
さんだけでなく user2
さんの記事も含まれていますが、今度はクエリで mono
さんだけにしてみます。 `.whereField(“authorRef”, isEqualTo: db.collection(“users”).document(“mono”))`の条件を追加すればOKです。
db.collection("posts")
.whereField("authorRef",
isEqualTo: db.collection("users").document("mono"))
.getDocuments { (snapshot, error) in
snapshot!.documents.forEach { doc in
print(doc)
}
}
さらにcreateTimeでソート
次に、timestamp型のcreateTimeでもソートしてみます。`.order(by: “createTime”, descending: true)`を追加します。
db.collection("posts")
.whereField("authorRef",
isEqualTo: db.collection("users").document("mono"))
.order(by: "createTime", descending: true)
.getDocuments { (snapshot, error) in
print(error as Any)
snapshot!.documents.forEach { doc in
print(doc)
}
}
すると、次のエラーが発生してしまいます。
Error Domain=FIRFirestoreErrorDomain Code=9 “The query requires an index.
パート1の記事で書きましたが、デフォルトでインデックスが有効なのは単一フィールドによるクエリのみなので、2つ以上のフィールドの組み合わせによるクエリでは予めインデックスを設定しておく必要があるのです。
usersのサブコレクションにpostsを移動
このままエラーログの指示通りに authorRef
と createTime
の複合インデックスを追加するのもありですが、そもそも posts
を author
でクエリする機会が多そうな場合、先ほどのように createTime
など他のフィールドを組み合わせて絞り込みやソートをすることが頻発しそうなことに気づきます🤔そこで、Firestoreのサブコレクションの出番です。
users
のサブコレクションに posts
を置くことで、その posts
がどのuserに属しているのかの情報が加わり、そもそも author
フィールドが不要になります。
mono
さんの posts
を createTime
順で取得したい場合、次のようなクエリとなります。 .order
に createTime
を使っている単一フィールドによるソートのみなので、インデックスの追加作成が不要になりました🎉
db.collection("users").document("mono")
.collection("posts")
.order(by: "createTime", descending: true)
.getDocuments { (snapshot, error) in
snapshot!.documents.forEach { doc in
print(doc)
}
}
サブコレクション化したPostsすべてを取得したい場合は?
ただ、users
のサブコレクションに posts
を移動してしまったため、今度はすべての posts
を取得したい時に困ります🤔
元々は db.collection("posts")
へのクエリで全件取れていましたが、サブコレクション化したことによって db.collection("users").document("mono").collection("posts")
という users
を介するアクセスとなり全件取得にはユーザー数分のクエリが必要となります。非効率ですしユーザー数増加とともに破綻するやり方です。
ここで、やはりサブコレクションはやめて posts
をルートに配置して authorRef
フィールドを持たせて、複合インデックスを必要に応じて作るやり方に戻そうかな、というのも1つの考え方です。
一方、サブコレクションは上で述べたような利点や、パート3で説明するセキュリティルールでベターな設定をしやすいなどの理由もあり、やはりサブコレクション構造を維持したいとも思います。
Cloud Functionsで冗長化(コピー)
そこで、Cloud Functionsを使って冗長化です。次のような流れで、サブコレクションの posts
をルートのコレクションにもコピーします。
- Cloud FunctionsのFirestoreトリガーで、
users/{userId}/posts
へのドキュメント追加・更新処理を監視 - 追加・更新処理がなされたら、ルートの
posts
にコピー
こうすることで、 users
ごとに分かれた posts
も、全体の posts
もどちらにもアクセスしやすくなります。多少更新処理が増えてデータ量も2倍になりますが、その分読み取り処理に有利になります。データは更新処理よりも読み取り処理が支配的なため、更新処理やデータの持ち方を多少犠牲にしてでも読み取り処理の都合になるべく合わせようというのはFirestoreなどNoSQLデータベースを扱う上での定石です。RDBでは非正規化は原則しない(特にこのケースのような非正規化は絶対しないレベル)ですが、Firestoreを使う上ではこのあたり柔軟に頭を切り替える必要があります。
クライアントからFirestoreの一括書き込み機能を使うやり方もあり
Firestoreはクライアントから2以上(500以下)のドキュメントに対して一括書き込みする機能(Batch Write)があります。
Cloud Functionsによるコピーの代わりに、これを使ってクライアントから次のように処理することも可能です。
batch
オブジェクトを生成batch
オブジェクトを用いてusers/{userId}/posts
のドキュメントを更新batch
オブジェクトを用いてposts
のドキュメントを更新batch
をcommit
2・3のいずれかの処理が失敗したらすべての処理が失敗するため、片方だけしか更新されなかったという状況も起こり得ません。
一方、 users/{自分のID}/posts
という自分のユーザー配下だけでなくルートの posts
という全ユーザーが閲覧可能な領域の書き込み権限を直接与えることになるため、パブリックなWebサービスでこの方法を取るのは少し慎重になった方が良いと筆者は考えています。セキュリティルールで authorRef
が自身のものであるものしか書き込みできないようにしたりデータ形式を検証したりで想定できる多くの攻撃は防げるとは思いますが(このあたりのセキュリティルールに関しての詳細はパート3で説明します)。
また、複数クライアント対応など考える場合も、すべてのクライアントに上記手順のような複数ドキュメント更新を強いることになる(初期実装もその後の変更も)ため、メンテナンス難易度が若干上がる懸念もあります。
Cloud Functionsでコピーするやり方では、基本的にクライアントは1ドキュメントだけの更新で済み、その後のコピーの仕方はクライアントアップデートとは関係なくいつでも好きなタイミングで自由に変更でき、柔軟性が高いです。そのため、筆者はこういった場合はCloud Functionsでコピーするやり方を取ることが多いです。
Cloud Functionsによるドキュメントコピーの実装
Cloud FunctionsでFirestoreトリガーを元にドキュメントコピーをするためには、諸々のセットアップがが必要です。
Node.js・npmのインストール
次の指示に従ってインストールします。
現時点でのCloud FunctionsランタイムバージョンがNode v6.11.5なのでクライアントもそれに揃えることを強くお勧めします。僕は nodebrew
でインストールしています。
TypeScriptのインストール
この後、JavaScriptではなくTypeScriptで書いていきますのでインストールが必要です。
npm install -g typescript
Firebase CLIのインストール
以下でインストールできます。
npm install -g firebase-tools
プロジェクトセットアップ
以上の準備が整ったら、作業ディレクトリにて、 firebase init
を実行します。その時点で不要なサービスもあとから使いたくなることが良くあるので、全部有効にしておくのがお勧めです。
また、途中でTypeScriptかJavaScriptか聞かれますが、次のような理由で断然TypeScriptがおすすめです。今後の記述もTypeScript選んだ前提で進めていきます。
- 良い感じの下地が整えられていてTypeScript使い始めるまでの煩わしさが無い
- 型のおかげでミスなくスラスラ書ける
- async/awaitが使える(前述の通りCloud FunctionsランタイムのNode.jsがv6.11.5と古めなのでJavaScriptでは使えません)
それ以外の選択肢は適当にデフォルトのまま進めていけばとりあえず良いです。どれも後から生成ファイルの記述を書き換えるだけで変更可能です。
functions/src/index.tsに処理を記述
色々済むと次のような状態になります。
Visual Studio Code を使っていると、Command Palletにて次のように tsc: watch
コマンドを実行すると、
次のようにTypeScriptファイルが監視され、JavaScriptへのコンパイルがなされます。
これでCloud Functionsを書く環境が無事に整いました🎵
ドキュメントコピー処理を書く
users/{userId}/posts
へのドキュメント追加・更新タイミングで、 posts
へ そのユーザーの authorRef
付きでコピーする処理は次のように書けます(実プロダクトでは users
などコレクション名に定数を充てるタイポが発生しないように徹底しているなどより丁寧に書いていますがそれらは割愛しました)。
そしてデプロイすると無事に動くはずです🎉
firebase deploy --only functions
うまく動かなかったら、Cloud FunctionsのLOGSタブを見るなどしてデバッグしましょう💪
console.log
なども出力されるので、いわゆるprintデバッグでがんばりましょう。もう少し賢いデバッグ方法もありますが、ここでは割愛します。興味があれば、以下など見て試してみて下さい💁♀️
更新メソッドの補足
上のドキュメントコピー処理のコードは特に混みいった処理はないため、ここまで理解できていればほぼ疑問なく読めると思いますが、最後の set
メソッドについて補足します。
まず、更新系のメソッドは次の3つがあります。
create
: 引数のdataで新規作成(すでに存在していればエラー)update
: すでにあるデータの更新(存在しなければエラー、キー配下のものは置き換わる)set
: すでに存在しているかは関係無く全置き換え
先ほどのコードでは set
を使いつつ { merge: true }
というオプションを指定してキーの被らない既存データはそのまま残すようにしています。個人的にはオプションでは無く、これに相当する merge
メソッドがあっても良かったと思うくらい振る舞いが変わるなあと感じています🤔
どのメソッドが適切かどうかは仕様次第ですが、今回はとりあえず既存の他のフィールドの値を保ちつつすでに存在しているかどうか関係無く使える{ merge: true }
オプション付きの set
を使っています。
Cloud Functionsの注意点
Cloud Functionsもまだベータであり、Firestoreトリガーについては「制約と保証」に書かれていることは強く意識することが求められます。
- トリガー実行まで5秒以上かかる場合がある
- 関数が呼び出されないことが稀にある(ベータ時点での制約)
- 正式版になれば「少なくとも1回は実行される」ことが保証されるものの、複数回実行されることもあるため、トリガーを起点に行われる処理はべき等にする必要がある
- 呼び出し順序は保証されない
また、上に書いたクライアントから一括書き込みする機能(Batch Write)に比べて、データが伝播されるまで多少タイムラグが発生することに注意です。こういった不整合の瞬間が全く許されないときは、以下などの方法があります。
- クライアントから一括書き込みする機能(Batch Write)を利用
- クライアントからはCloud FunctionsのCallableを呼び出して、Cloud Functions上で一括書き込みする機能(Batch Write)を利用
以上の手順で、次のようなデータ構成となりました。
次回のパート3では、セキュリティルール中心に触れていきます🐶