脱Firestoreするために考えていること(追記あり)

FirebaseのFirestoreをやめることにしたので雑なメモを残しておく。なお、まだ走り始めたばかりなので、内容には間違いや考慮不足も多数含まれる可能性があるので読む人はその点注意を。あと、あくまでも雑なメモなので細かいところは書いていない。

なぜ脱Firestoreするのか?

まず、脱Firestoreする理由は

  • ユースケースとしてFirestoreでは対応できないケースが出てきた
  • ニーズが変わってきて機能拡張の足かせと感じることが増えた
  • 将来的なビジネスロードマップ上の布石
  • モバイルとWebアプリケーションが存在する環境であるため、共通のロジックをサーバーサイドにまとめたほうが効率的と感じることも増えた

1つめ、2つめは一番わかりやすいこととしてSQLで言うところの集約関数が必要になったということ。これまでは運用的に月イチでの集計で良かったため、月イチでBigQuery(BQ)にインポートしてBQ側でSQL使って実行していた。しかし、これらに加えてユーザ操作によってその時点での集計結果を得たいという要件が生まれた。

通常こういったケースであってもリアルタイム性を問わないのであればバッチ処理的にであったり、更新等をトリガーとしたFunctionsによるバックエンドでの集計処理を行い、その集計結果を別のコレクションに格納しておくといったことで対応することも多いと思う。しかし、今回は値の更新頻度がもう少し高いこともあってFirestoreに依存した処理を継ぎ接ぎ的に実装するよりもRDBを利用したほうがいいと結論づけている。

3つめは詳細についてはあまり言えないのだがそもそも政治的・環境的理由でFirestoreを使えないケースが近い将来に見えつつあるということだ。

4つめは単純に単一コレクションをクエリして表示するだけなら特に問題ないと思っている。だが、前述の集計機能であったり、単にDBの値をクエリするだけでなくクエリの前後にロジックを含むような場合や複数のコレクションの値を使って処理をする場合にAPIとしてまとめられるといいと感じている。

というような理由であり決してFirestoreそのものがダメだとかそういう話ではない。あくまでもマッチしなくなってきたということだけだし、人によってはFirestoreでもっと頑張れると思う人もいるだろうが自分はそこで頑張らない選択をするだけだ

なぜGraphQLではなくREST APIなのか?

さて、脱FirestoreするにあたってGraphQLという選択肢もあったが今回はREST APIという選択肢をとった。

まず、GraphQLの利点として一般的に以下のようなものがあげられる。

  • クライアントで取得したいデータを決められる。その結果、ちょっとした仕様変更にAPI側の修正とリリースが不要。
  • RESTのように固定的なリクエスト/レスポンスに従うわけではないので必要なデータのみを含むレスポンスとなり効率がいい
  • 型がある
  • フロントエンドのためのゲートウェイとして振る舞える。いわゆるフェデレーション的なこともできる

正直なところRESTと比べると利点しかない気がしている。だが、今回はRESTを採用した。一番の理由はGraphQLでアプリケーション開発をするだけのエコシステムがまだこなれていないと感じているからだ。例えば自分の場合はチーム体制などの問題もありできるだけマネージド・サービスを使いたいと考えている。そうするとこれっていうサービスがないのが実態だ。

もちろんいくつかはあるのは知ってるし、普段メインで使っているAWSにもAppSyncというサービスがあるのも知っている。いくつかあるサービスを使うのもいいかもしれないがメインの部分に使うのはさすがに感情的に不安なところもあるし、AppSyncはとある機能のためにGraphQLのsubscriptionの機能が必要で利用しているがVTLとか書く時点でなかなか辛い。VTLは特にデバッグが辛い。というわけでRESTである。これには異論・反論あるだろうが。

移行にあたって検討したこと、決め事

実際に移行するにあたり以下のようなスタックでAPIを実装していくことにした。

RDBPostgreSQLなのはPostGISを使う必要があったからだ。

実際に上記のようなスタックで移行していくにあたり検討の多くはデータ格納先となるRDBに既存のデータをどう格納していくか、だ。つまりテーブル設計だがFirestoreはご存知のとおりNoSQLと呼ばれるタイプのDBであり、RDBではない。今回のようなこともあろうかとあまりFirestoreに最適化しすぎていない、比較的シンプルな構成で使ってきたがいざ考え出すとそれなりに悩ましいところも多い。

具体的には以下のような点が自分たちの場合は検討が必要だった。

  • ドキュメントIDをどう扱うか
  • サブコレクションをどう扱うか
  • 配列やマップといったフィールドのタイプをどう扱うか
  • Firebase Authenticationとセキュリティルールで実現しているセキュリティ機能をどうするか

ここからの話をする前にFirestoreのデータの格納についておさらいがてら振り返っておく。

FirestoreはRDBではないのでRDBには存在するテーブルや行といったものがない。その代わりにデータは『ドキュメント』として扱われ、ドキュメントをまとめたものとして『コレクション』という概念がある。そして『ドキュメント』はJSのオブジェクトのようなものでフィールドとその値が含まれている。そしてこのフィールドは可変だ。

ドキュメントIDをどう扱うか

これは移行後のRDBのテーブルでプライマリーキーをどうするかという問題だ。

Firestoreには相当するものとしてドキュメントIDというものがある。このドキュメントIDは自動でセットされるものを利用している場合、06XWvXOqtUmLR2BnC7fZみたいな文字列となる。RDBのテーブルではプライマリーキーをどうするか考える必要があるが、DBのシーケンスなどの機能に任せたい場合は数値の型になるので使えない。そして別の値をキーにして再設定する場合、微妙にリレーションっぽいものが存在すると全てを書き換える必要も出てくる。

そうするとプライマリーキーとして文字列型で用意し、既存データはドキュメントIDをそのまま移行し、新規のデータについては別途キーを生成して払い出すしかなさそうだ。というわけでそうするのだがこのときRDB側のプライマリーキーをどうするか。これは別途検討を行った結果、今回はCUIDを使うことにする。

一意な識別子の生成でUUID/ULID/CUID/Nano IDなど検討してみた - Sweet Escape

ちなみに型についてはVARCHARもしくはTEXTを使う。ID列なので結果的に固定長になるものの、公式ドキュメントにも記載の通りPostgreSQLの場合はTEXTVARCHARはパフォーマンス的には同等だし、他のDBと異なりCHARが一番遅いとのことで使う利点はない。

There is no performance difference among these three types, apart from increased storage space when using the blank-padded type, and a few extra CPU cycles to check the length when storing into a length-constrained column. While character(n) has performance advantages in some other database systems, there is no such advantage in PostgreSQL; in fact character(n) is usually the slowest of the three because of its additional storage costs. In most situations text or character varying should be used instead.

VARCHARTEXTはパフォーマンス的に同等ということでありどちらでもいいと思う。実際のところ文字数を制限したところで見積もりが楽になるくらいなのでは?あとは標準SQLに含まれるかどうかとか?ORMを使うので独自方言を使うと互換性の問題でORMの利点を得られないケースがあると思うが、TEXTPrismaでもサポートされてるしって感じ。というわけで自分の場合は基本的にTEXTを使います。

外部キー制約については要検討だけど、基本的に既存のドキュメントIDをそのままIDとして格納するので制約を用意しても問題ないはず。後から設定するつもり。

サブコレクションをどう扱うか

サブコレクションとは特定のドキュメントに関連付けられたコレクションのことだ。つまり特定のドキュメントにぶら下がる形で定義されたコレクション。特定のドキュメントに配列とかMapでデータをネストして格納するのと何が違うかというとネストするデータのサイズが増えても親のドキュメントのサイズが変わらないということがある。ネストの場合はちょっとしたサイズなら問題ないがそれなりに大きいものを格納すると親ドキュメントをクエリするだけでまるっとデータが取得されるためかなり効率が悪くなる。

さて、そんなサブコレクションを使っている箇所がいくつかある。これをRDBに持っていくにあたってどうするかだが、ドキュメントをレコード、コレクションをテーブルだと見立てるとシンプルに関連する別テーブルと見れなくもない。というわけでサブコレクションに関しては別テーブルとして切り出し、コレクションの移行先を親テーブルとしてその子テーブルとする。

配列やマップといったフィールドのタイプをどう扱うか

サブコレクションのところで少し触れたが、Firestoreではデータを構造化するにあたり特定のドキュメントにネストすることも可能だ。その際、フィールドの型としては配列もしくはMapなどを利用する。サブコレクションを使うのではなくこちらを使うのは単純な構造のときやそれほどデータの量が大きくない場合、可変でない場合があげられると思う。

では、そんな配列やMapのデータを持つドキュメントをRDBに持っていくにはどうするか。正直なところ少し悩ましい。先のサブコレクションと異なりデータの数量は大きくないものの一つのドキュメントに複数存在しているという状況がほとんどだ。そんな状況でこれをサブコレクション同様に別テーブルに切り出すと親子関係を持つテーブルが大量になってしまうし、大量のJOINが発生してしまう。

配列に関してはPostgreSQLには配列型というものがあるのでこれを利用するのも一つの手だ。Prismaでも普通にサポートしている。MapについてはJSON型にするのがいいのだろうか。JSON型についてもPrismaはサポートしているようだし。

だがしかし、この配列型とJSON型を使うことにどうにも抵抗がある。そもそもこれらを使うと『正規化とは?』みたいなことになってしまう。

なお、配列に関してはGINインデックスで配列の各要素にインデックスを張れるがJSON型ではインデックスはサポートされない。その代わりJSONBという型が用意されていてこちらであればGINインデックスがサポートされている模様。JSON型は入力テキストのコピーがそのまま格納されるのに対して、JSONBはバイナリ形式で保存されるとのこと。JSONBは入力テキストを分解してバイナリにするため、入力時は少しオーバーヘッドがあるものの先のとおりGINインデックスの利用がサポートされるのだ。このJSONB型もPrismaではサポートしているのでJSON型を使うならば基本的にはJSONB型を利用するのがいいと思われる。

さて、それらを踏まえてである。それらを踏まえて今回は以下のとおりとした。

  • 配列フィールドはRDBにも配列型の列を用意してそのままそこに
  • Mapに関してはJSON/JSONB型を使うのではなく、展開して列として定義する

理由だが、まず既存のFirestore上のドキュメントに含まれる配列とMapのフィールドはどちらも要素の数としては多くないし、更新も行っていないケースが多かったのでそれらを別テーブルにするほうがSQL的なコストがかさむと考えた。

配列については要素数が可変なこともあるのでそのまま配列型で格納することとしている。

さて、Mapである。当初はJSON/JSONB型で格納することも考えていたが以下の理由でやめることにした。

  • 正規化崩れる(これは配列型も同じ)
  • 型が指定できない
  • クエリにRDBごとの方言が強めで、結果的にSQLの可読性が悪い
  • スキーマレスなのでJSON/JSONB型の列に何が格納されているかわかりづらい
  • 既存データではMapのキーの個数が可変なものがない

SQLの可読性に関してはORMであるPrismaが吸収してくれる部分もあるとは思うものの、$queryRawで生SQLに近い形で書くシチュエーションもまだまだあると思われる。

というわけでJSON/JSONB型を使うのはやめ、Mapで格納されているものについては列として展開して格納することにする。そうすると型も指定できるし。

追記: Mapの配列をどうするか

上記で配列はそのまま、Mapについては列として展開するとしたがその後早々に壁にあたってしまった。それは配列の要素としてMap型のデータを持っているケースである。例えばこんなデータ。

[
    { "name": "Scott", "age": 30},
    { "name": "John", "age": 35},
    { "name": "Bill", "age": 25}
]

JSON/JSONB型の誘惑に屈しそうである。JSON型の配列にすれば解決ではある。だが上記理由もあり使いたくない。

そこで悩んだり参考書籍にあたった結果、Mapの配列に関してはこういう構造に置き換えることとした。

列名 データ型 備考
id text 主キー、CUID
sequense integer いわゆる配列の要素番号に相当
name text Mapに含まれるキー
age integer Mapに含まれるキー

これはいわゆる『行持ちのテーブル』などと呼ばれる方式ですね。

なお、今回の事例ではMapの配列として保持されているデータとしては位置情報が多かったのだけれども、これに関してはPostGISのGEOMETRY型の列を用意するだけで解決する。例えば以下のようなデータ構造の場合。これはとあるルートの情報。

[
  {
    "latitude": -73.993433,
    "longitude": 40.736274
  },
  {
    "latitude": -73.993632,
    "longitude": 40.736007
  },
  {
    "latitude": -73.984937,
    "longitude": 40.732353
  },
  {
    "latitude": -73.986374,
    "longitude": 40.730382
  },
  {
    "latitude": -73.98686,
    "longitude": 40.730587
  }
]

実はFirestoreでは座標を扱うためのgeopointという型があるのだが使っていなかったりする。その代わりに上記のようなlongitudelatitudeというキーを持つMapの配列として保存されている。

これがPostGISであればGEOMETRY(LineString, 4326)みたいな列を一つ用意するだけで済む。

Firebase Authenticationとセキュリティルールで実現しているセキュリティ機能をどうするか

これまでFirestoreだけでなく認証にはFirebase Authenticationを利用してきており、Firestoreに格納されたデータへの権限チェックはセキュリティルールを使って実現されていた。シンプルに認証済のユーザであるかのチェックの後、自身の権限をチェックして操作対象のドキュメントを触れるかどうかをチェックして弾いていただけである。

これに関してはセキュリティルールは使えなくなるので自前で相当のものを実装するしかないと考えている。ここはやむなしかと。また、あくまでも今回やめるのはFirestoreだけでありFirebase Authenticationについては使い続ける予定だ。

どうするか。

現時点ではFirebase Authenticationでサインインすると取得できるID Tokenをサーバーサイドに送り、サーバーサイドではFirebase Admin SDKを利用してそのTokenを検証、問題なければログイン済ユーザ情報を元に権限チェックということを考えている。

クライアントサイドではID Tokenは firebase.auth().currentUser.getIdToken()で取得可能だし、サーバーサイドでは getAuth().verifyIdToken(idToken)で検証ができる。

また、Admin SDKを使わずとも任意のJWTライブラリを用いて検証することも可能だ。送られてきたIDトークンをデコードするとペイロードsubおよびuser_idというキーとその値があることがわかる。ここにはFirebase AuthenticationのUIDが格納されているのでそれを用いてRDBに保存したユーザ情報を引っ張るなんてことができる。また、emailとパスワードで認証している場合などはemailというキーにサインインに使用されたemailアドレスが格納されているのでそれを使うことも可能。

いずれにせよ、Firebase AuthenticationのID TokenとRDBに保管したユーザ情報を用いて権限管理を自前で実装することはそんなに難しくなさそう。

では実際にどんなテーブル設計にするのか

基本的には上記の方針に従いつつ、既存のコレクションをドキュメントIDを主キーとして設定したテーブルとして用意していく。テーブルのカラムはドキュメントのフィールドに対応させる。カラム名については既存のものをそのまま使う感じでひとまず機械的にやってしまう。

テーブルができたらあとはCRUD作成おじさんとなって一通りのテーブルに対するRESTfulなCRUDAPIを用意していく。もちろん既存のアプリケーションのクエリを確認しつつ、検索条件にあわせたAPIも用意していく場合もあるがここまではそんなに難しくないと思っている。

なお、このタイミングで負債となっているまずいDB設計についても直してしまいたいところ。

次にやること

それは移行の過渡期のデータをどうするかの検討だ。一通りのAPIができたら実際の移行、特にデータの移行について検討を行う必要がある。Firestoreを使ったアプリケーションはすでに利用されている。なので既存データをどうサービスの中断を限りなく少なく移行していくかという問題がある。

これから検討していくのだが、基本的にバッチ処理的にデータを移行した上でFirestoreのトリガーを用いたファンクションで連携しつつどこかのタイミングでアプリを切り替えるということになると思う。問題はモバイルアプリだがそれも含めてこのあたりに関しては内容的に公開できるような話ではない気がしている。

©Keisuke Nishitani, 2023   プライバシーポリシー