Firestore を使いたいので調べたことを記録する自分用の記事


せっかくプログラミングの仕事をしているので、自分でほしいアプリを自分で作ることにした。そのバックエンドとして Firebase を使うことを考えている。理由は 3 つある。

  • 面倒な認証の実装を Firebase Authentication で省略できるから
  • データの保存先として Cloud Firestore が便利そうだから(動画
  • 仕事で AWS を使うことがありそうなので、個人では Google Cloud を使ってみたかったから

アプリの開発には React Native(Expo) を使うことにする。Expo の公式ドキュメントはとても充実しており、 Expo アプリから Firebase を使うための初期設定ガイドも親切に用意されている。Expo の話はまた別に書きたい。 React Native ってどう使われてるの?という人は最近開催された React Native Matsuri 2021 のアーカイブ動画を見るといいと思う。

React Native の話は置いといて、 Firebase の中心(?)となるデータストアである Firestore について一通り調べてみたので、自分が気になったことを書き留めておいた。

初期設定の手順

とりあえず動かしてみる。

まずは Firebase のプロジェクトを作り、 Expo アプリ上で Firebase を使うための初期設定をしてみた。下記の手順で公式ドキュメントを見ながらやれば簡単にできそう。

1. ドキュメントを読む

  • 公式の quick start を読む
  • 先に気になるところをざっと読んで概要を掴んでから試していくとよさそう

2. Firebase でプロジェクトを作成する

  • 参考になりそうな公式のドキュメント
  • Firebase コンソールからでも GCP コンソールからでも作れる
  • どっちで作っても中身は同じ(参考
  • GCP の管理コンソールで API Key を取得できるようになる
    • 対象のプロジェクトを選んで「API & Services > Credentials」を見ると、自動生成された Key を取得できる
    • auto created by Firebase とか書いてあるはず
    • ここから新しく API Key を作ることもできる

3. 開発用のマシンに Firebase SDK をインストールする

  • 普通は npm installyarn add でインストールするが、 expo は expo install コマンドを使うのがよい
  • こうすると、 expo SDK と互換性のあるバージョンをインストールしてくれるので助かる

4. Firebase にアプリを追加する

5. アプリ側で Firebase を initialize する

  • ↑ でアプリ作成時に自動生成したコードを使う
    • API Key は公開しても良い(参考)ので、コードに書いてリポジトリにコミットできる
    • ただし、セキュリティルールで適切なアクセス制御をしていない場合、 Firestore に対して意図しない不正なアクセスをされる恐れがある
    • 開発中は特に問題なさそう
    • Firebase を initialize するための config、 Auth や Firestore のインスタンスを取得するコードをクラスにまとめておくと便利そう
  • Cloud Functions やサーバーから Firestore を触る場合は Admin SDK が必要になる

今まで Firestore を使った経験はなかったのだが、ドキュメントや記事を読む限りでは普通のデータベースとだいぶ勝手が違っており(例えばスキーマが無い)、将来的に他に移行するのが大変そうという印象がある。

Firebase SDK を扱うクライアントのクラス

初期設定の 4. で書いたが、 Firebase SDK を扱うクライアント用のクラスを作成しておき、これのインスタンスを使い回すようにするとよさそう(実際は auth とかの実装も必要なはずだが最低限はこれで動く)。

import firebase from "firebase/app";
import "firebase/firestore";
import { firebaseConfig } from "../../config/dev"; // Firebase の apiKey 等の設定を別ファイルに分けておいて import するとよさそう

export default class Firebase {
  public static _instance: Firebase;
  private _store: firebase.firestore.Firestore;

  private constructor() {
    firebase.initializeApp(firebaseConfig);
    this._store = firebase.firestore();
  }

  public static get instance(): Firebase {
    if (!this._instance) {
      this._instance = new Firebase();
    }
    return this._instance;
  }

  public get store(): firebase.firestore.Firestore {
    return this._store ?? firebase.firestore();
  }
}

これを static メソッドとして、データを Firestore に投稿する際には

await Firebase.instance.store
  .collection("コレクション名")
  .doc("ドキュメント名")
  .set({ フィールド: "値", フィールド: "値" });

みたいに呼び出して使うイメージ。これで singleton 的に?インスタンスを使い回すことができる。

これを使ってスタートガイドを参考にアプリから適当なデータを Firestore に投稿し、それを別の画面で読み取れるようになった。いろいろメモしたのだが、正直このガイドを見れば十分なので特に書くべきことはなかった。

Firestore のデータモデル

データモデルは通常の RDB とだいぶ違うので注意が必要。

Firestore は ドキュメントコレクション それから リファレンス という 3 つの概念でデータを管理する。

  • ドキュメントは普通の RDB における「レコード」のようなもので、key と value の組み合わせを格納する
    • JSON のように内部でネストして複雑な構造を作ることもできる
    • ドキュメント内にコレクションを持つこともでき、これは サブコレクション と呼ばれる
    • ドキュメントはその key および value としてドキュメントを持つことはできない
  • コレクション はドキュメントをまとめた「テーブル」のようなもので、多数のドキュメントを特定の名前(例: users というコレクションでアプリのユーザーに関する user ドキュメントを…)でまとめておくことができる
    • 完全にスキーマレスで、各ドキュメントは異なるフィールドを持つことができる
    • ドキュメントと組み合わせて階層的なデータ構造を作ることができる(参考
    • コレクションが持てるのはドキュメントのみ
  • Reference はドキュメントやコレクションを参照するだけの軽量なオブジェクト
    • これを通じてデータの追加や更新、削除を行う

データを初めて追加したときに「暗黙的に」ドキュメントやコレクションが追加されるため、管理コンソール等を通じて明示的に作成する必要はない。

Firestore のセキュリティルール

実は上記の「暗黙的な追加」はテスト用のデータベースを「セキュリティルール」無しで作ったときの挙動である。

Firestore の「セキュリティルール」を「ロックモード」にしてデータを追加しようとすると、無効な操作として次のようなエラーを吐く。

[Unhandled promise rejection: FirebaseError: Missing or insufficient permissions.]

Firestore ではプロジェクトの追加時にセキュリティルールの開始モードを「テストモード」と「ロックモード」の 2 種類から選択でき、ロックモードのときは全ての読み書きを拒否する。テストモードでは全ての読み書きを通す。

ロックモードでプロジェクトを開始し管理コンソールで Firestore のセキュリティルールを見ると、次のようなルールが書かれている。

rules_version = '2';
service cloud.firestore {
  match /databases/{database}/documents {
    match /{document=**} {
      allow read, write: if false;
    }
  }
}

基本的には match 式で記述したドキュメント or コレクションへの path に allow していく。より詳しい書き方は公式ドキュメントを参照。

5 行目の if false;if true; にするとテストモードと同じルールになり、全ての読み書きが承認される。これは本番環境では絶対にやってはいけない。また、 if false;if request.auth != null; にすると認証を必須にできる。

なお、アクセス制御だけでなくデータの検証(例えばこのフィールドには 1 以上 5 未満の数字が入る…など)も定義できる。

なぜセキュリティルールがあるのか?

このセキュリティルールとは、要するにデータの保護ポリシーである。クライアントから送られるデータは信頼できないので、セキュリティルールでデータの機密性と完全性を担保する。

しかし何故このような仕組みになっているのか。

Firestore ではサーバーレスで、直接クライアント(SDK)とデータのやり取りをする。通常の Web アプリケーションではクライアントとデータベースの間に Web サーバー的なプロキシがあって、不正なリクエストはそこで弾くような実装をする。つまり、データベースはサーバーの裏に隠されておりユーザーが直接データベースに書き込むことは無い。

しかし Firestore にはそのレイヤーがなく、 SDK を通じて API を呼び出すために必要な情報は全てアプリにバンドルされている。つまり、セキュリティルールが存在しない場合(アプリから Firestore の操作をする導線が一切なくても)アプリから入手した API Key 等を使って好き勝手に Firestore のデータを読み出したり書き換えることができる。これは非常に危険なので、セキュリティルールを通じてデータのアクセス制御を行う。

セキュリティルールで実現できることは以下の動画が大変わかりやすい。

実装の方針

ドキュメントや動画を見ることでセキュリティルールが何のために存在するのか、どういう書式で何を書くべきか、どうやってデプロイするかは理解できた。しかし、詳細なルールの設定をする前にアプリ全体でどういうデータを持ち、それらをどのような画面からどのように処理するかを固める必要がありそうなので、実際に適用するセキュリティルールは後で考えることにした。この辺の話は別で書きたい。

ちなみにサーバーや Cloud Functions から Firestore の操作をするときは普通に IAM を使って権限を制御する(参考)。

Firebase のセキュリティに関しては以下のブログが参考になりそう。

Firestore のデータ構造を設計する上での注意点

通常の RDBMS を用いた Web アプリケーションであれば、データベースに保存するデータは正規化して重複を排除するようなデータ構造にしておき、必要に応じて結合して使う。しかし Firestore でそれを実践しようとすると難しい。

Firestore には JOIN が無い(!)ので、結合をやろうとすると必要なデータを取得した上でクライアント側で結合する必要がある。不可能ではないが、特定のビューを組み立てるために複数の異なるコレクションからデータを取得するクエリを発行するのは効率が悪い。さらに、クライアント側で複雑なビジネスロジックを記述しなければならないので、作るのも後から修正するのも大変になる。

そこで、 Firestore(や一般的な NoSQL データベース)では基本的に使う形でそのままデータを保存する。よって、同じデータが複数のドキュメントに重複して存在するような構造になる可能性がある。なお、 Firebase のクエリはデフォルトで shallow な挙動をするので、あるドキュメントにサブコレクションが保持されていても明示的にそれを指定しない限り取得はされない。これは Firebase Realtime Database と Firestore の最大の違いでもある。

Firestore のデータ構造については以下の動画が参考になる。

Firestore のクエリには制約がいろいろある。JOIN 以外に驚いたのが OR が無いこと。他にもいろいろあるので注意。

https://firebase.google.com/docs/firestore/query-data/queries#query_limitations

インデックス

Firestore はドキュメントをコレクションに追加したとき、デフォルトで全てのフィールドに対してインデックスを作成する。これにより、ドキュメントやコレクションを横断して特定の条件を満たすフィールドを検索するのが速くなる。

複数の条件を OR 指定するクエリはない。これを高速に処理するには、 OR 条件を満たすフィールドを追加する。

複数の条件を AND 指定した場合は「ジグザグマージ結合」が行われる(この記事の理由 3 を参照)。しかし、この記事にもある通り、これはソートされた複数のインデックスの間を行き来して結果を結合するので、クエリ対象のデータ間に共通点が少なすぎる場合は効率が悪くなってしまう。

それを改善する手段として「複合インデックス」がある。通常のインデックスと違い、 Firestore は自動では複合インデックスを作らないので自分で作る必要がある。なぜデフォルトではないかというと、複合インデックスは全ての場合でクエリのパフォーマンス向上につながるとは限らないかららしい。

効率的なクエリやインデックスの詳しい話は以下の動画が参考になる。

課金 - Pricing

Firestore は Read/Write/Delete クエリの実行回数に応じて課金されるので、効率の良いクエリは節約につながる。料金の計算にはいろいろ注意が必要…

  • Read: 検索対象のボリュームによらず、クライアントに返すドキュメントが 1 つであれば 1 回、ドキュメントが 20 個であれば 20 回分の料金になる
    • つまり、ページネーションを実装する等して「必要ないデータを取得しないこと」が非常に重要
    • クエリの結果をクライアントでリッスンする場合、結果セット内のドキュメントが追加または更新されるたびに Read として課金される
    • セキュリティルールでドキュメントの読み取りが発生する場合、それも Read として課金される
  • Write: あるドキュメントのある 1 箇所のフィールドを更新するのも同じドキュメント内で 30 箇所のフィールドを更新するのも同じ料金になる
  • Delete: 削除したドキュメント数に比例する

このようなルールは、作るアプリによっては実装に大きな影響を与える可能性がある。

例えば LINE のようなチャットアプリで 5 秒に 1 度ルームにメッセージを送ることを考えると、参加者が 2 人なら 1 分あたり 2 * 60/5 = 24 回の書き込みと 2 * 24 = 48 回の読み込みが発生する。しかし、これが n 人なら n * 60/5 = 12n 回の書き込みと n * 12n = 12 * n^2 回の読み込みが発生する。つまり、指数関数的に課金額が増加していく。

このようなケースでは書き込みの度に読み込ませるのではなく、 Cloud Functions で一定間隔ごとにメッセージを一括更新しまとめて読み込ませるのがよさそうだ。

課金額を抑える方法については以下の動画が参考になる。

これは結構注意していても課金で爆死する匂いがするので Budget や Alert 的な閾値を設定しておいた方がよさそう。

https://firebase.google.com/docs/firestore/monitor-usage

ドキュメントの制約

Firestore のドキュメントには様々な制約がある。

  • 1 つのドキュメントには 1MB までしか入れられず、インデックスの数は 40,000 まで
    • このような制限が設けられている理由は、 Firestore がデータの追加時に全てのフィールドに対してインデックスを作るから
    • ドキュメントの更新時にはインデックスを再構築するため、多すぎるフィールドは Write のパフォーマンスに悪影響をもたらす
  • 同じドキュメントに対して Write できるのは 1 秒に 1 回のみ
  • ドキュメントの取得時にはそのドキュメントの全てのフィールドを取得し、一部分だけを取得することはできない
    • Firestore のクエリのパフォーマンスは対象となるデータセットのサイズではなく結果セットのサイズに比例する
    • つまり、この点でも一つのドキュメントに必要以上のデータを詰め込むことは得策ではない
  • Firestore のクエリは shallow で、明示的に指定したドキュメントのみを取得しそのサブコレクションは取得しない
    • つまり、一緒に使うデータはサブコレクションに分けるのではなく同じドキュメントに詰めるのがよい
  • Array の特定の位置に要素を挿入、更新、削除することはできない
    • Array 内の要素を追加するには arrayUnion()、削除するには arrayRemove() を使う

ドキュメントの構造や制約については以下の動画が参考になる。

ドキュメント設計の工夫

これらの制約や Firestore の特性から、 Firestore のドキュメント - コレクションのデータ構造設計は多少の工夫が必要になる。

例えば特定の画面を構成するのに必要で読み込みの頻度が多く書き込みの頻度が少ないデータは非正規化して持ち、 Cloud Functions 等でまとめて更新して整合性を保つ。

階層的な関係にあるデータであっても、それが異なるドキュメント間で N:N の関係にあるのであれば、それぞれとそのマッピング(例: Users, Restaurants, FavoriteRestaurants)をトップレベルのコレクションとして持つのがよい。

データ構造の工夫については以下の動画が参考になる。

非正規化したデータの更新には Cloud Functions を使う。使い方については以下の動画が参考になる。

ページネーション

Firestore ではドキュメントの取得回数に応じて課金されるので、ページネーションが重要になる。また、ユーザー体験的にも必要な分だけ取得できたほうがよい。最後のドキュメントを previousDoc として次のクエリで .start(after: previousDoc) 的な使い方が多そう。

以下の動画にだいたい説明されている。

トランザクション

Firestore では、「全ての操作が成功する」か「いずれも適用されない」という操作を行うための atomic な処理が 2 種類用意されている。

  • Transaction: 1 つまたは複数のドキュメントに対する読み取りと書き込みの操作
  • Batch Write: 1 つまたは複数のドキュメントに対する一連の書き込み操作

Batch Write の利点は、読み取りができない代わりに読み取り起因の失敗がないこと、全てのクライアントがオフラインでも実行できることがある。

両者には幾つかの注意点がある。

  1. Reads Before Write: 書く前に読む
  2. No Side Effects: retry の可能性があるので、副作用をトランザクション内で実行しない
  3. Don’t Go Overboard: 関係ないドキュメントをトランザクション内に入れない(ロックによるパフォーマンス低下など、デメリットしかない)
  4. No Offline Support: トランザクションはクライアントがオフライン状態のとき必ず失敗する
  5. 500 Documents: 1 トランザクションで処理できるドキュメントの上限数は 500

5 の制限により、 500 を超える数のドキュメントを一つの Transaction/Batch Write で更新することはできず、複数回に分けて実行することになる。トランザクションについては以下の動画が参考になる。

オフラインサポート

Firestore にはオフラインサポートという機能がある。端末がインターネットから切断しても、オフラインでキャッシュを保持してそれをいい感じに Firestore に反映してくれる。オフラインキャッシュは無制限ではなく(とはいえ通常の使用では気にしなくてもいいサイズらしい)、最も使われていないデータから消されていく。

ちなみに、 Firestore にはインデックスがあるがキャッシュにはそれが無いので、巨大なデータをクライアント側のキャッシュで扱うのは得策ではない。小さなデータで変更の頻度が少なければオフラインに持っておくことで節約になるかもしれないが、ほとんどのケースではそのような使い方はユーザー体験の悪化を招きそうだ。

なお、オフラインサポートは iOS/Android ではデフォルトで有効だが、 Web では少しだけコードを書く必要がある。オフラインサポートについては以下の動画が参考になる。

リアルタイムリスナー

Firestore には「リアルタイムリスナー」と呼ばれる仕組みがあり、ドキュメントに対する変更を検知することができる。

これによって明示的に fetch しなくても新しいデータを取得できユーザー体験が格段によくなるため、特に理由がなければこれを使うべきだろう。しかし、以下の点に注意する必要がある。

  • 必要以上のデータをダウンロードしない(その分課金されるので)
    • ドキュメントを適切に設計する
      • サブコレクションに入れておくなど
    • 必要ないときはリスナーを deactivate する
      • 例えば、コンポーネントを生成時に listen するならコンポーネントがなくなるときに切る
  • リアルタイムリスナーはバックグラウンドで動作しない
    • つまり、ユーザーが戻ってきたときは多少のロードが必要になる

リアルタイムリスナーについては以下の動画が参考になる。

Firestore のデータ設計まとめ

まとめると、 Firestore は以下の点に注意して使う必要がありそうだ。

画面にそのまま使えるような構造にデータをモデリングする

  • Firestore にはクライアントから直接アクセスする、かつ JOIN が無い
    • つまり、画面を組み立てるのにデータの加工が必要な場合はクライアント側でしなければならない
    • さらに、データが複数のドキュメントに分かれていると複数回のクエリが必要になる
    • データの重複を許容して非正規化し画面ごとにドキュメントを用意することで、パフォーマンスが上がり課金額は下がる
  • 全てのケースで非正規化が有効というわけではない
    • 時間の経過やアプリの利用とともにデータが増えていく場合、後述の Cloud Functions による更新が難しくなっていく
    • このような場合では、
      • ドキュメント間の関係性を表現する「リファレンス型」をドキュメントのフィールドとして持つことで JOIN っぽいドキュメント取得を行う
        • Read が増えるので万能ではない
      • 更新の頻度を抑えるような仕様をアプリに追加する
      • 一定期間を過ぎたデータをクライアントから取得できないようにする
  • 1:1 の関係性を表現する場合は、同一の id をドキュメントに割り当てる
  • 1:n の場合は…以下のいずれかを行う

ドキュメントは大きくしすぎない

  • Firestore ではデータをドキュメント単位で取得し、ドキュメント内の一部のフィールドのみを取得するような操作ができないため
  • フィールド数が増えるとその分だけスキーマやデータの検証も複雑になる
  • ドキュメントには保持できるサイズ(1MB)、フィールドの数(40,000)に制約がある
  • 増え続けるデータはサブコレクションに隔離する
  • 「画面にそのまま使えるような設計」とは対立する可能性がある
    • CQRS 的に(?)読み取り用のドキュメントと書き込み用のドキュメントを分離するのもいいかもしれない
      • この場合、副次的なメリットもある
        • セキュリティルールの記述が簡潔になる
        • 書き込みの履歴を残すことができる
    • 読み取りと書き込みを分離するデメリットとして、書き込み用ドキュメントの更新をトリガーにして Cloud Functions で読み取り用のドキュメントを更新することで整合性を保つ必要がある

非正規化されたデータを一斉に更新する際は Cloud Functions を使う

  • 以下の点に注意する
    • セキュリティルールの対象外となるので、 Firestore のセキュリティルールで設定したスキーマやデータの検証をすり抜ける可能性がある
    • 1 トランザクションで更新できるドキュメント数には 500 までという制限がある
    • 更新中はドキュメントをロックするのでパフォーマンスが落ちる

ドキュメント内で access level が異なる情報を保持してはならない

  • 住所や支払情報など
  • Firestore ではデータの検証やアクセスレベルの管理には必ずセキュリティルールを使う
  • セキュリティルールはドキュメント単位で設定される
  • また、ドキュメントの一部取得を担保することは不可能(表示上は一部しか使わなくとも、クエリとしては完全なドキュメントを取得している
  • ドキュメントを分け、必要ならリファレンスとして持つ
    • リファレンスはセキュリティルールで保護できるので access level が異なってもよい