既存のデータベースでPrisma migrateする。しかもPostGISを使う

既存のデータベースをPrismaマイグレーションできるようにしたくなった。理由はいろいろあるがやはりローカル環境 → 開発環境 → ステージング環境 → 本番環境へとDBの定義を反映していくのが手作業はさすがにないなと思えてきたからだ。もちろん実際には毎回SQLを直接手で入力なんてことはないだろうけど。

あとは他の人が開発するのにDBのセットアップをする際にも楽だ。

というわけで導入しようとしたのだがそれなりに悩んだりしたのでメモを残しておく。

前提

  • すでに存在しているDBをPrismaのmigrationツール管理下におく
  • これまでは直接DBにDDL文を実行して定義などしていた
  • DBの定義はprisma db pullを実行してsyncしていた

やっていく

まずは既存のDBの定義とPrismaのモデルを同期します。普段からやってるものの念の為。

prisma db pull

同期したら早速マイグレーションのコマンドを実行します。以下ではDBのURLをマスクしてます。

prisma migrate dev --name initial-migration --create-only

✔ We need to reset the PostgreSQL database "postgres" at "xxxxxxxxxx.cluster-xxxxxxxxxxx.us-east-1.rds.amazonaws.com:5432".
Do you want to continue? All data will be lost. … yes

--nameはこのマイグレーションをわかりやすく識別するためにつける任意の名前。--create-onlyというのはマイグレーションファイルの作成だけで実際の適用はしないというオプション。

さて、こんな感じで実行すると不穏なメッセージが表示されている。Yesを選択して進めるとメッセージの通り、この時点で既存のDBのテーブルとデータは全て消えてしまう。--create-onlyにしているにも関わらず、だ。

Prisma migrationを導入しようとすると、最初のタイミングでmigration用の管理テーブルが作られる。そして手動で定義していたものについてはその時点で既存のテーブルは一度削除されてしまうという仕様のようだ。テーブルが削除されるのでもちろんデータも消える。開発環境とは言えそれなりの量のデータが入っていること、本番適用時にデータが全部消えてしまうのは論外なこともあり色々と調べたり試したりしたものの良い解決策は見つからなかった。そう、消えるのだ。--create-onlyを指定したところで消えるのだ。この場合の--create-onlyは確かに適用まではされない。

--create-onlyをつけて実行すると実行日時のフォルダとともにマイグレーションファイルができあがり、マイグレーションの管理テーブルだけが作成された状態でマイグレーションファイルの内容は反映されていない状態となる。大事なことなので2回言うが、DB上は全部のテーブルが削除された上でマイグレーションの管理テーブルのみが存在しているという状態になる。

これに関してはどうしようもないので既存のものに導入する場合、かつデータも残したい場合は事前にバックアップするなり、テーブル単位でエクスポートしておくことは必須といえる。

PostGISをどう扱うか

さて、自分のようにPostGISを使っている場合はそのまま実行すると失敗する。なぜならば、先の処理で一度DBの中身がまっさらになっていてPostGISのセットアップについてもリセットされた状態になるからだ。

これは作成したマイグレーションファイルの先頭に以下を追加することで対応できる。これは初回のマイグレーションファイルだけでいい。なお、このようにマイグレーションファイルを直接的に修正していいのは適用前のものに対してだけですでに適用したマイグレーションファイルの修正は禁物だ。

CREATE EXTENSION postgis;
CREATE EXTENSION fuzzystrmatch;
CREATE EXTENSION postgis_tiger_geocoder;
CREATE EXTENSION postgis_topology;
ALTER SCHEMA tiger OWNER TO rds_superuser;
ALTER SCHEMA tiger_data OWNER TO rds_superuser; 
ALTER SCHEMA topology OWNER TO rds_superuser;
CREATE FUNCTION exec(text) returns text language plpgsql volatile AS $f$ BEGIN EXECUTE $1; RETURN $1; END; $f$;
SELECT exec('ALTER TABLE ' || quote_ident(s.nspname) || '.' || quote_ident(s.relname) || ' OWNER TO rds_superuser;')
  FROM (
    SELECT nspname, relname
    FROM pg_class c JOIN pg_namespace n ON (c.relnamespace = n.oid) 
    WHERE nspname in ('tiger','topology') AND
    relkind IN ('r','S','v') ORDER BY relkind = 'S')
s;

そしてPostGISのgeometry型をPrismaはサポートしていない。したがってここについても修正しておく必要がある。実際には以下のように単なるgeometry型として生成されている。

CREATE TABLE "sample" (
    "id" SERIAL NOT NULL,
    "name" TEXT,
    "coordinates" geometry,

    CONSTRAINT "sample_pkey" PRIMARY KEY ("id")
);

これを以下のようにGeometryのタイプも指定する形に修正しておく。ここではPOINTを指定すると同時に座標系も4326を指定している。

CREATE TABLE "sample" (
    "id" SERIAL NOT NULL,
    "name" TEXT,
    "coordinates" GEOMETRY(POINT,4326),

    CONSTRAINT "sample_pkey" PRIMARY KEY ("id")
);

追加・修正したらマイグレーションを実行する。

データを戻す

今回のように--create-onlyで実行したあとに適用する場合はprisma migrate devを実行するだけでいい。なお、コマンドを見てわかるようにこの手順は開発環境向けであって本番適用手順はまた別だ。

prisma migrate devを実行したら必要に応じて先程のバックアップからデータを戻す。なぜならば前述のとおり一度テーブルが削除されているためデータが空っぽになっているからだ。自分の場合はpg_dumpを使ってテーブル単位でデータだけバックアップをとっておいてそれをインポートする方式で対応した。

2回目以降

2回目以降も同様にprisma migrate dev --name <名前>を実行していく。だが、個人的には毎回--create-onlyをつけてマイグレーションファイルの中身を目視で確認するのをおすすめしたい。

そして、2回目以降も実行ごとに日時のフォルダとマイグレーションファイルが作成される。

その他

さて、このようにPrismaによるMigrationを導入したあとはDBの定義に関して基本的にはschema.prismaで定義を行ってはマイグレーションを実行するという流れになる。仮にDB側で直接テーブルを作ったりするとprisma migrate resetが必要になってしまい、またテーブルがまるっと削除されてしまうので要注意だ。

また、前述のPostGISに限らず生成されたSQLが意図したものではない場合や望ましくない場合があるだろう。そんなときもマイグレーションファイルを修正して実行すればいいのでやはり--create-onlyをつけて確認するというのをおすすめしたい。

例えば列名の変更は通常は以下のようなSQLで実行できる。

ALTER TABLE テーブル RENAME COLUMN 旧列名 TO 新列名

だが、これをPrismaでやろうとしてschema.prismaのモデル上で変更すると以下のようなSQLが生成されてしまう。

ALTER TABLE テーブル名 DROP COLUMN 旧列名,
ADD COLUMN     新列名 TEXT;

そう、この場合だと旧列名の列を削除して新しい列名の列を追加するというSQLが発行されてしまう。つまりデータも消える。これはよくない。

そんなときにマイグレーションファイルの中身を修正して実行してあげれば問題ない。

ついでにPostGISを使っている場合にGeometry型の列に対してgistインデックスを張っている場合もあると思う。これに関してもPrismaでは対応していないのでまずは普通のインデックスを張るようにschema.prismaで設定した上で--create-onlyを実行する。作成されたマイグレーションファイルでは当然ながら普通のbtreeなインデックスが作成されるようになっているのでこれをgistインデックスに手動で書き換える。

具体的には、列指定の前にUSING gistを追加してあげるだけでいい。以下のように。

CREATE INDEX "idx_geo" ON "sample" USING gist("geo");

まとめ

というわけでPostGISを使っている場合は少し手を入れる必要はあるが、基本的には問題なくPrismaによるマイグレーションができる。そしてPrismaを信じきらず必ず--create-onlyで適用前に確認することをおすすめする

2022年7月に買った書籍/マンガ

すでに8月も半ばになってしまったが7月に買った書籍たちを紹介していく。7月はAmazonプライムデーに関連してKindle本のセールが多かったので購入したものも比較的多かったと思う。

こういうセールのときには今必要なもの、興味のあるものだけでなく将来的に必要になったときのために定番本を安くなっているうちに購入しておくってこともよくする。

あと7月に買ったものではなく随分前のセールで全巻大人買いをしたものの、途中で読むのが止まってた『金色のガッシュ』をようやく一気に読み終えた。

書籍

はじめてのAndroidプログラミング 第5版

  • 仕事で脱React Nativeしようかなって考えているので、セールになってたこちらをひとまず購入してみた
  • 未読

パケットキャプチャの教科書

  • 定番の本がセールで安くなってたので購入しておいた

今いるメンバーで「大金星」を挙げるチームの法則 『ジャイアントキリング』の流儀

  • シードラウンドのスタートアップに身を置くものとしてエンジニアリソースの問題は常につきまとうので某か参考になればと思って購入
  • ちなみにジャイキリは途中まで読んでたが止まってしまった
  • 未読

会社四季報」業界地図 2022年版

  • 日替わりセールで安くなってたので試しに購入してみたんだがとてもおもしろかった
  • 自分の関係ある業界はもちろんのこと、関係ない業界、知らない業界についても市場の状況や主要プレイヤーなど知れる
  • 暇つぶしにとても良い

確かな力が身につくJavaScript「超」入門 第2版

  • 確かセールになってたので購入
  • 未読

インフラ/ネットワークエンジニアのためのネットワーク技術&設計入門 第2版

  • これも安くなってたので購入

Pythonでいかにして暗号を破るか

  • これも安くなってたから購入
  • 『暗号を破る』ていうワードが心くすぐられる
  • 未読

私はどのようにしてLinuxカーネルを学んだか Device Tree編ゆたかさんの技術書

  • 日替わりセールの通知で発見して購入
  • もっとライトな読み物かと思ったらLinuxのデバイス周りに関するガチな内容だった
  • タイトルからは物語、エッセイ的なのを想像してた

失敗から学ぶRDBの正しい歩き方

  • 前から買おうと思ってた本を購入
  • 最近、脱Firebaseを進めていることもありパラパラと眺めている
  • あるあるって思うものもあれば、なるほどなーという知らなかったものも。一方でこれはちょっとどうなのだろうか?と疑問に思うものも
  • 自分くらいの知識レベルと同じ人同志でこういうの飲みながら議論できると楽しそうだなといつも思う

キャリア不要の時代 僕が飲食店で成功を続ける理由

  • セールになっていたので購入
  • ホリエモンて色々あるもののビジネス周りに関してはすごいと思うことも多く、将来的に飲食に関わる何かをやりたいのでどういう感じでやってるのか知りたくて買ってみた
  • 本としては対談の文字起こしメインで微妙
  • 将来的にこの本で言うところの料理しない飲食店経営者として飲食やりたいと思ってるのだけど、全然勉強足りないと感じた
  • もっと美味いもの食べて勉強しないと

今こそ行きたい日本の神社200選

  • これは正確には自分で買ったのではなく誕生日プレゼントとして戴いたもの
  • 神社仏閣好きとしてはたまらない

コミック

九条の大罪(6)

  • 感想は…

金色のガッシュ!! 2 Page 3 〜 5

  • 金色のガッシュをようやく読み終わったので購入
  • 綺麗な続きものて、みんな成長している

降り積もれ孤独な死よ(3)

  • 最近の中では一番続きを楽しみにしているマンガ
  • 読み終わったと同時に続きが気になった
  • 同じコンビによる『親愛なる僕へ〜』より展開はゆっくりめかな

サマータイムレンダ2026 未然事故物件

ちはやふる(49)

  • いよいよ佳境
  • ついに次巻が最終巻で完結

SANDA 4

  • 続・大人 vs 子ども
  • 続、というかこの物語のテーマ自体が大人 vs 子どもか
  • 前作が草食動物 vs 肉食動物だったので作者はこういう対立構造が好きなんだろう

さよなら絵梨

  • 何かと最近話題の藤本タツキの読み切り
  • 買ったものの前にWebで読んだやつだった…

小説

この子は邪悪

  • タイトルにひかれて買ったんだけどやばい家族の話だった
  • ホラー風味
  • 分量も少なく読みやすい

アムステルダム

脱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のトリガーを用いたファンクションで連携しつつどこかのタイミングでアプリを切り替えるということになると思う。問題はモバイルアプリだがそれも含めてこのあたりに関しては内容的に公開できるような話ではない気がしている。

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

最近、一意な識別子について検討することがあったのでその検討メモ。

一意な識別子とは

つまり、重複しない、ユニークな識別子(Identifier, 以下id)のこと。ここではRDBのテーブルにおける主キーとして使うことを想定かつ前提としている。したがって、主キーの要件であるユニーク性を持ったidをどうやって生成していくか。

そんなのDBの連番でいいじゃんて話もあるがここではその話はせず、あくまでも一意な識別子をどう生成するかの話に絞る。

選択肢

一番有名だと思われるUUIDを筆頭にいくつかの選択肢がある。

  • UUID
  • ULID
  • CUID
  • Nano ID

他にもTwitter発のSnowflakeとか今はDeprecatedになってるshortidなどがあるが、キリがないのでここでは上記の4種類だけで簡単に比較した。また、実際にはUUIDはバージョンによってSpecが異なるがここではバージョン4を前提として話をすすめる。また、本来はパフォーマンス比較もしたほうがいいが今回はやれていない。

UUIDの例は 712a865f-79e1-4976-91f1-5150b0e1b9c0 といった形式。UUIDはバージョン4だとランダムに生成されるので耐衝突性が高い、つまりユニーク性は高いとされている。一方で連続性が低く時系列的なソートが効かないなどある。

import { v4 as uuidv4 } from 'uuid';
console.log(uuidv4());

それを解決したといえるものがULIDだ。ULIDはユニーク性を維持しつつミリ秒単位でソートが可能というもので、サンプルとしては01G9BRJVBB3GTYDRT01FBPY24Vという感じだ。ランダムに生成される部分がUUIDは122ビットに対してULIDは80ビットなのでランダム部分のユニーク性はUUIDよりは劣るがタイムスタンプ部分との組み合わせで実質的に問題なさそう。

import { ulid } from 'ulid';
console.log(ulid());

次のCUIDはUUIDの問題を解決しつつULIDよりもいいとされているもの。水平方向のスケーリングとバイナリサーチにおける検索性能の高さを目的としているそうで、cl6a7y23f00006ipga93g41x0といった形式。UILDとの比較はこちらにあるが端的に言うと、

  • CUIDはCSRNGに依存していない
  • これまで衝突が報告されたことはないし、多くのパッケージやリポジトリで使われている

だそうだ。多くのパッケージで使われているとはいえUUIDには遠く及ばない。だが個人的には十分な数ではないかと思う。

import cuid = require('cuid');
console.log(cuid());

Nano IDは端的に言うとUUIDをより小さく・高速にしたものと言える。実際の例はFijSqjkogl2JzJ16P1trhといった形式で確かに文字数としても少ないことがわかる。

import { nanoid } from 'nanoid';
console.log(nanoid());

最後に、UUIDはRFC4122 (https://datatracker.ietf.org/doc/html/rfc4122)として標準化されているのは人によってはポイント高いかも?

ダウンロード数

npmのダウンロード数も比較してみた。

うん、比較が意味をなさないくらいに圧倒的にUUID。そしてNano ID。ULIDとCUIDについてはこの2強の前には大した違いがないレベル。

というわけでULIDとCUIDだけで比較。

CUIDのほうが倍くらい使われてるっぽい。これはちょっと意外。Googleで検索すると体感ではULIDのページのほうがヒットした印象だったので。

注意

前述の通り、上記の比較はあくまでも簡単な比較であって生成にかかるパフォーマンスの比較やベンチマークはしていない。これはどこかで実際に自分でやってみたいところ。

まとめ

結論として何を使うかって話だけど基本はUUID、個人的にはUUIDの長ったらしいフォーマットが好みではないのでNano IDでいいかなと思っているがこれは利用するアプリケーションなどにもよりそう。

また、時系列によるソートが必要ならULIDかCUIDだがここは好みだけど実績の多そうなCUIDか。

なお、自分が使っているPrismaではUUID (v4のみ)とCUIDしかサポートされていないこの場合、時系列性のないUUIDのv4だといろんな方面で議論されているようにMySQLではインサート時のパフォーマンスが悪い。なのでPrismaとの組み合わせで使う場合は基本的にCUIDかなと考えている。自分の場合、現在使っているRDBPostgreSQLなのであまり関係ないけど。

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