Cloud Firestoreの勘所 パート2 — データ設計

投稿型のブログサービスを設計しながらデータ構造について考える

mono 
google-cloud-jp

--

パート1の前置きが長くなりましたが、これからFirestoreの具体的な使い方を見ていきます。

実例があると分かりやすいかと思い、Qiita のような投稿型のブログサービスを題材に考えていきます。

Firebaseプロジェクトの作成

https://console.firebase.google.com にて適当なサンプルプロジェクトを作ります。今回は firestore-sandbox を作りました(実アプリの場合、開発環境・テスト環境・本番環境など3つ以上用意するのが良いですがサンプルなので1つで済ませます)。

Firebaseプロジェクト一覧

Firebaseプロジェクトはデフォルトで無料なので、個人アカウントでも安心して作れます🤗無料のままでもそれなりに本格的な利用に耐えられますし、有料プランに上げることになっても大抵は驚くほど安く済むと思います。

Firestoreのセキュリティルール初期設定

プロジェクト上でFirestoreを選ぶと、セキュリティルール設定の選択を迫られます。

Firestoreのセキュリティルール設定

初めての場合は Start in test mode がおすすめです。全ドキュメントに対して誰でもアクセスできるので大抵のサービスではこのままリリースはできないですが、セキュリティルール(パート3で説明します)のことを考えるのを後回しにできて楽になります。

ENABLE を押すと、次のようにFirestoreの空の画面に移ります。 RULES タブに❗️マークが付いていて、危険なセキュリティルールのままリリースしてしまうミスを防げるようになっています。

空のFirestore

投稿型のブログサービスを設計

とりあえず、素朴にルートに posts コレクションと users コレクションを設けてみます。

postsコレクション
usersコレクション

string型のauthorIdを参照型のauthorRefへ改善

まず、 authorIdstring 型になっていますが、Firestoreには参照型があるので、基本的にはそちらを使った方が良いです。名前は個人的には authorRef などとしています。

参照型(reference型)

参照型を使うと格納されているものが明確になり、他の文字列との取り違えも防ぎやすいです。また、どの階層のコレクションのどのドキュメントを指しているのかが明確になります(文字列の authorId では「多分ルートのusersコレクションのドキュメントを指しているのかな?」程度の推測しかできません)。

その他、合計11個の種類のデータ型に対応しています。JSONより表現力豊かなので、用意されているものの中から最も用途に適したものをしっかり選んで使いましょう。

ついでに、上図のように作成日時を表す、timestamp型の createTime も追加してみました。

authorにuserデータそのものを入れるのもあり

参照型のauthorRef ではなくオブジェクト型の author として定義して、そこにuserデータそのものを入れるのもありです。

こうすると、 posts コレクション一覧の1クエリで、 authorname など必要な情報を取れる利点があります。 参照型のauthorRef として持っていた場合は、1 postごとに author 取得の1クエリが必要になって、例えば1ページ10件の記事一覧を表示する際に1 + 10 = 11クエリが必要になります。RDBのN+1問題のように深刻に思うかもしれませんが、Firestoreはクライアントからこのように表示件数分のクエリを発行することはよくあること(クライアントサイドジョインと言います)で、あまり気にせずでOKです。とはいえ1クエリで取れる方がクライアント実装もシンプルできる・表示速度も少し上がる・課金額も抑えられる(この場合およそ半分)などのメリットなどもあり、一覧表示の都合だけ考えればオブジェクト型の author として持つことの方が優れています。

一方、オブジェクト型の author 方式では、プロフィール情報変更などで authorname などが変わってしまった場合、紐付いたpostsすべての author 情報変更が必要になります。具体的には次の操作が必要になります。

  1. posts から特定の author のドキュメント群を取得
  2. 取得された 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を移動

このままエラーログの指示通りに authorRefcreateTime の複合インデックスを追加するのもありですが、そもそも postsauthor でクエリする機会が多そうな場合、先ほどのように createTime など他のフィールドを組み合わせて絞り込みやソートをすることが頻発しそうなことに気づきます🤔そこで、Firestoreのサブコレクションの出番です。

usersコレクションの配下にサブコレクションとしてpostsを配置

users のサブコレクションに posts を置くことで、その posts がどのuserに属しているのかの情報が加わり、そもそも author フィールドが不要になります。

mono さんの postscreateTime 順で取得したい場合、次のようなクエリとなります。 .ordercreateTime を使っている単一フィールドによるソートのみなので、インデックスの追加作成が不要になりました🎉

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 をルートのコレクションにもコピーします。

  1. Cloud FunctionsのFirestoreトリガーで、users/{userId}/posts へのドキュメント追加・更新処理を監視
  2. 追加・更新処理がなされたら、ルートの posts にコピー

こうすることで、 users ごとに分かれた posts も、全体の posts もどちらにもアクセスしやすくなります。多少更新処理が増えてデータ量も2倍になりますが、その分読み取り処理に有利になります。データは更新処理よりも読み取り処理が支配的なため、更新処理やデータの持ち方を多少犠牲にしてでも読み取り処理の都合になるべく合わせようというのはFirestoreなどNoSQLデータベースを扱う上での定石です。RDBでは非正規化は原則しない(特にこのケースのような非正規化は絶対しないレベル)ですが、Firestoreを使う上ではこのあたり柔軟に頭を切り替える必要があります。

クライアントからFirestoreの一括書き込み機能を使うやり方もあり

Firestoreはクライアントから2以上(500以下)のドキュメントに対して一括書き込みする機能(Batch Write)があります。

Cloud Functionsによるコピーの代わりに、これを使ってクライアントから次のように処理することも可能です。

  1. batch オブジェクトを生成
  2. batch オブジェクトを用いてusers/{userId}/posts のドキュメントを更新
  3. batch オブジェクトを用いてposts のドキュメントを更新
  4. batchcommit

2・3のいずれかの処理が失敗したらすべての処理が失敗するため、片方だけしか更新されなかったという状況も起こり得ません。

一方、 users/{自分のID}/posts という自分のユーザー配下だけでなくルートの posts という全ユーザーが閲覧可能な領域の書き込み権限を直接与えることになるため、パブリックなWebサービスでこの方法を取るのは少し慎重になった方が良いと筆者は考えています。セキュリティルールで authorRef が自身のものであるものしか書き込みできないようにしたりデータ形式を検証したりで想定できる多くの攻撃は防げるとは思いますが(このあたりのセキュリティルールに関しての詳細はパート3で説明します)。

また、複数クライアント対応など考える場合も、すべてのクライアントに上記手順のような複数ドキュメント更新を強いることになる(初期実装もその後の変更も)ため、メンテナンス難易度が若干上がる懸念もあります。

Cloud Functionsでコピーするやり方では、基本的にクライアントは1ドキュメントだけの更新で済み、その後のコピーの仕方はクライアントアップデートとは関係なくいつでも好きなタイミングで自由に変更でき、柔軟性が高いです。そのため、筆者はこういった場合はCloud Functionsでコピーするやり方を取ることが多いです。

Cloud Functionsによるドキュメントコピーの実装

Cloud FunctionsでFirestoreトリガーを元にドキュメントコピーをするためには、諸々のセットアップがが必要です。

Node.jsnpmのインストール

次の指示に従ってインストールします。

現時点でのCloud FunctionsランタイムバージョンがNode v6.11.5なのでクライアントもそれに揃えることを強くお勧めします。僕は nodebrew でインストールしています。

TypeScriptのインストール

この後、JavaScriptではなくTypeScriptで書いていきますのでインストールが必要です。

npm install -g typescript

Firebase CLIのインストール

以下でインストールできます。

npm install -g firebase-tools

プロジェクトセットアップ

以上の準備が整ったら、作業ディレクトリにて、 firebase init を実行します。その時点で不要なサービスもあとから使いたくなることが良くあるので、全部有効にしておくのがお勧めです。

firebase init

また、途中でTypeScriptかJavaScriptか聞かれますが、次のような理由で断然TypeScriptがおすすめです。今後の記述もTypeScript選んだ前提で進めていきます。

  • 良い感じの下地が整えられていてTypeScript使い始めるまでの煩わしさが無い
  • 型のおかげでミスなくスラスラ書ける
  • async/awaitが使える(前述の通りCloud FunctionsランタイムのNode.jsがv6.11.5と古めなのでJavaScriptでは使えません)

それ以外の選択肢は適当にデフォルトのまま進めていけばとりあえず良いです。どれも後から生成ファイルの記述を書き換えるだけで変更可能です。

functions/src/index.tsに処理を記述

色々済むと次のような状態になります。

functions/src/index.ts

Visual Studio Code を使っていると、Command Palletにて次のように tsc: watch コマンドを実行すると、

Command Palletでtsc: watchを実行

次のようにTypeScriptファイルが監視され、JavaScriptへのコンパイルがなされます。

これでCloud Functionsを書く環境が無事に整いました🎵

ドキュメントコピー処理を書く

users/{userId}/posts へのドキュメント追加・更新タイミングで、 posts へ そのユーザーの authorRef 付きでコピーする処理は次のように書けます(実プロダクトでは users などコレクション名に定数を充てるタイポが発生しないように徹底しているなどより丁寧に書いていますがそれらは割愛しました)。

/users/{userId}/posts を authorRef付きで /postsにコピー

そしてデプロイすると無事に動くはずです🎉

firebase deploy --only functions

うまく動かなかったら、Cloud FunctionsのLOGSタブを見るなどしてデバッグしましょう💪

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では、セキュリティルール中心に触れていきます🐶

--

--