はじめに
第1章 Firestoreの正体
第2章 データアクセスの基礎
第3章 オフラインモード
第4章 セキュリティルール
第5章 Firestore データモデリング
第6章 Firestoreでユーザーを管理する
第7章 Firestoreでショッピングサイトを実装してみる
近年さまざまな開発シーンで用いられるようになったFirebaseは、Googleのパブリッククラウドをベースに構築されたバックエンドサービス群です。スケーラブルなモバイル・Webアプリの開発をサポートするさまざまな機能を、非常に低価格で利用できることで人気を集めています。本書が取り上げるFirestoreは、Firebaseにおける主要なマネージドデータベースであり、アプリケーションにおいて中心的な役割を担うプロダクトです。
Firestoreは2019年1月にベータ版を卒業したばかりでありながら、Cloud Datastoreの後継サービスとしてGoogle Cloud Platformへ逆輸入されることが発表されています。Google Cloud Platformのデータベースとして、デファクトスタンダードの地位を獲得したと言っても過言ではありません。
昨今、インターネット上のメディアなどで「Firebaseは学習コストが低い」といった旨の主張を目にしますが、これは明確に誤りです。Firestoreに関しても、SDKを介してデータの読み書きをするだけであれば非常に簡単にできてしまうのですが、その見た目のシンプルさこそが落とし穴なのです。
Firestoreは適切なアーキテクチャとデータモデル設計の下で使うと極めて優れた性能とメンテナンス性を発揮します。その一方で、Googleが意図したことかどうかはわかりませんが、もし道を踏み違えればただちに技術的負債となって利用者を苦しめるように作られています。『Firestoreはクセがあって使いにくい』という言説は、このあたりの難しさに原因があるのではないかと考えています。
従来から広く採用されているリレーショナル・データベースのスキーマ設計にはボイス・コッド正規形(あるいは第三正規形)というおおよそ普遍的に通用する正解がありますが、FirestoreをはじめとするNoSQL系データベースにはそのような一般化されたベストプラクティスはまだ確立されていません。その理由の一端は、NoSQL系データベースにおける最適なデータモデル設計が『アプリケーションがデータをどのように利用するか』に依存していて、一般化が難しいことにあります。Firestoreもその例に漏れず、それに加えて次にあげるような独自の要素が設計をさらに難しくしています。
・性能や課金の特性
・クエリに関する制限
・サブコレクションという独自の階層化概念
・SDKを介したクライアントからの直接アクセス
・セキュリティルール
・Cloud Functionsを起動するイベントトリガーとしての役割
Firestoreはこれまでのどんなデータベースとも大きく異なる機能性を備えています。もはや単なるデータベースとして捉えること自体が間違いであり、従来のデータベースと同様の考え方はほぼ通用しないと思ったほうがよいでしょう。
本書は、Firestoreを利用するすべてのエンジニアに、高いスケーラビリティ、高いパフォーマンス、そして十分な信頼性を備えたアプリケーションを開発できるようになるための道筋を示すことを目標として執筆したものです。公式ドキュメントに書いてあるような小手先のテクニックの範疇を超えて、アプリケーションの要件から最適なデータモデル設計を導き出す高次のスキルを習得することをゴールとして設定しています。読者の皆様がFirestoreを駆使して、魅力的なサービスを世に送り出す一助となれば幸いです。
Firebaseはプラットフォームごとにさまざまな言語でSDKが提供されています。本書で取り上げているサンプルコードは、TypeScript(JavaScript SDK)で記述しています。各SDKはプログラミング言語の仕様や慣習に則り、それぞれメソッド名やメソッドのシグネチャー、非同期処理の扱い、エラーハンドリングの方法などが異なります。どのSDKを利用しても大きな機能差分はありませんので、本書のサンプルコードを利用される際はFirebaseの公式ドキュメント(APIリファレンス)を参考に適宜読み替えてください。
例として、Firestoreからデータを取得するクエリにフィルターを適用するコードの違いを示します。
JavaScript SDKではwhere()メソッドの第2引数としてクエリ演算子を指定します。
firebase.firestore()
.collection('products')
.where('price', '<=', 10000)
.get()
Swift SDKでは、whereField()メソッドと名前付き引数によってフィルタリングを行います。
Firestore.firestore()
.collection('products')
.whereField('price', isLessThanOrEqualTo: 10000)
.get()
Kotlin SDKでは、フィルタ操作ごとにメソッドが定義されています。
FirebaseFirestore.getInstance()
.collection('products')
.whereLessThanOrEqualTo('price', 10000)
.get()
本書では、Firebaseの機能の一部であるFirestoreを対象としています。Google Cloud Platformで提供されているCloud Firestoreとは提供されているSDKの機能に違いがあります。特にDatastoreモードを採用する場合は、本書に記述されている内容がベストプラクティスとはならない場合がありますので注意してください。
本書を執筆するにあたり、技術的な検証に利用した主要なライブラリーのバージョンは次のとおりです。
・Firebase JavaScript SDK: 7.6.1
・Firebase Admin Node.js SDK: 8.9.0
・Firebase SDK for Cloud Functions: 3.3.0
本書に記載されている内容は個人の知見に基づくものであり、所属する組織の見解ではありません。また、情報の正確性を保証するものではありません。いかなる結果に関しても責任を負いかねます。みなさまの判断でご利用ください。
本書に記載された内容は、情報の提供のみを目的としています。したがって、本書を用いた開発、製作、運用は、必ずご自身の責任と判断によって行ってください。これらの情報による開発、製作、運用の結果について、著者はいかなる責任も負いません。
本書に記載されている会社名、製品名などは、一般に各社の登録商標または商標、商品名です。会社名、製品名については、本文中では©、®、™マークなどは表示していません。
本書籍は、技術系同人誌即売会「技術書典」で頒布されたものを底本としています。
Webアプリケーションの隆盛が起こってから今日広く普及しているネイティブアプリに至るまで、データベースを使わずに構築されたアプリケーションはほぼありません。それほど多種多様なアプリケーションで利用されているデータベースですが、その利用方法には1つの共通点が見られます。それは、モノリシックなサーバーサイド構成をとるWebアプリケーションでも、SPAやネイティブアプリのようにクライアント-サーバー構成をとる場合でも、データベースはサーバーサイドロジックの背後に隠蔽されるということです。これはデータベースの役割や機能性を考慮した結果、他の手段を検討する余地のないスタンダードな構成であり、システム開発における常識と言っても差し支えありません。
Firestoreの「クライアントアプリケーション(すなわちエンドユーザー)からの直接アクセスを許可する」という特徴は、これまでの常識に真っ向から対立し、アーキテクチャに大きな変化をもたらします。Firestoreには、この変化を支えるために従来のデータベースにはない機能を備えています。もはや、Firestoreを単なるデータベースの枠組みで捉えようとするのは間違っているのかもしれません。
この章では、Firestoreがどのような特徴を持つプロダクトなのかを解説します。Firestoreそのものの特性や、Firestoreに関連する技術要素について理解を深め、それらが示唆する「Firestoreを活用するための基本的な考え方」を身につけることを目標とします。
ネイティブアプリやSPAと呼ばれる形態のWebアプリケーションでは、ユーザーの端末上で動作するアプリケーションとサーバーサイドAPIによって構成されるクライアント-サーバー型のアプリケーション構成が主流となっています。クライアント-サーバー型の構成では、クライアントアプリケーションは主にデータの取得とユーザーインタフェースの描画、サーバーサイドアプリケーションはデータの提供とビジネスロジックの実行を担います。データを永続化し、永続化されているデータの機密性・可用性・完全性を担保するというデータベースの役割を考慮すると、データ処理の主担当であるビジネスロジックの実行部とあわせてサーバーサイドに集約することが構成としてもシンプルで合理的です。
企業で運用されるミッションクリティカルなサーバーサイドアプリケーションの開発では、単にビジネス上の要件を満たすだけではなく、次にあげるような問題を高いレベルでバランスを取りながら解決することが求められます。
・セキュリティの問題
・パフォーマンスの問題
・運用・保守の問題
・信頼性の問題
・Web, iOS, Androidなどの複数プラットフォームへの対応
・ビジネス要件の変化への対応
データベースを含むサーバーサイドアプリケーションは、さまざまな要素が複雑に絡みあうシステム上の結合点となる傾向にあります。結果として、クライアントアプリケーションに対してデータを提供するという一見シンプルな目的を達成するために、さまざまな開発タスクが発生します。
例えば、APIを呼び出したユーザー(あるいはクライアントアプリケーション)の認証やデータのバリデーション、スケーラビリティとメンテナビリティを考慮したインフラの構築、それと対立する分散システムにおけるデータ整合性の担保など、本来のビジネスとは関係のないところにかなりの工数がかかってしまうのです。とはいえ、これらをおろそかにして無理に開発を進めようとすれば、後になって大きな手戻りが発生してしまうのも事実です。
このような事情により、サーバーサイドを最低限動く状態まで持っていくためには、開発の初期にそれなりの時間がかかります。実際に、サーバーサイドの開発がクライアントアプリケーション開発をブロックしてしまうケースも少なくありません。一方で、クライアントアプリケーションの開発を進めるために必要なものはサーバーサイドから提供されるデータです。サーバーサイド側で苦心している課題は、クライアントからしてみれば大した関心事ではありません。
そこで、サーバーサイドアプリケーションのリソース・プロバイダーとしての側面に着目し、それをひとつの機能として取り込んだデータベースが(Firestoreの前身である)Realtime Databaseです。従来の思想とは全く異なるアクセス制御ポリシーと柔軟なデータモデルを備えたRealtime Databaseは、クライアントアプリケーションからの直接アクセスという手段によって開発のボトルネックの解消を試みました。データベースをサーバーサイドに隠蔽するという考え方に真っ向から挑戦し、アプリケーション開発の常識に大きな変化をもたらした画期的なプロダクトであると言えます。
Firestoreは、Realtime Databaseの特徴を受け継いだドキュメント指向NoSQLデータベースです。Realtime Databaseの弱点であったデータモデルを改善したり、クエリを強化したりするなど、大規模なアプリケーションでも使いやすくなっています。これらのプロダクトは、データベースの役割をサーバーサイドの裏に隠蔽されたデータストアから、クライアントアプリケーションに対するリソース・プロバイダーの地位まで引き上げました。Firestoreはデータベースでありながら、従来のアーキテクチャにおけるサーバーサイドAPIのように振る舞います。特に、マイクロサービスアーキテクチャでしばしば登場するBackend for Frontendとは極めて近い特性をもっています。
Firestoreの機能を最大限活用するのであれば、クライアントからの直接アクセスを原則とします。従来のアプリケーションのようなサーバーサイドAPIを介してデータベースに読み書きするような構成にすると、Firestoreが備えるさまざまな機能から得られるメリットが大きく損なわれてしまいます。サーバーサイドAPIを中心としたアーキテクチャを採用するのであれば、あえてFirestoreを選ぶ必要はありません。単純なデータベースとして見たときには他にも優れた選択肢はたくさんありますので、アプリケーションの要求に合ったものを選択すべきです。
「クライアントからの直接アクセス」という変化は、データモデリングのベストプラクティスとスキーマ変更との向き合い方にも大きな影響を及ぼします。
クライアントから使いやすい理想的なサーバーサイドAPIの特徴は、必要なデータが加工の必要がない形式で簡単かつ高速に取得できることです。Firestoreで同等の要請を満たす(すなわち、シンプルなクエリで加工の必要がないデータを提供する)ためには、その目的に特化したデータモデリングが必要となります。
リレーショナルデータベースの世界では忌避されている非正規化されたデータ形式が採用されることも珍しくありません。
一般的なアプリケーションが採用しているオブジェクトモデルに近いドキュメント指向であるという利点を活かし、Firestoreから取得したデータをそのまますぐ利用できることに重点を置いた設計は極めて合理的であるといえます。
Firestoreを活用する上では、アプリケーションとデータベースの結合度をなるべく小さくすることがアーキテクチャ上重要になります。データの利用に際して複雑なクエリや結合、加工、コストの高いO/Rマッピングなどが必要になってしまうと、クライアントアプリケーション側にビジネスロジックが記述されることになり、アプリケーションとデータベースが強い結合を引き起こします。
とりわけマルチプラットフォームでアプリを展開する場合に、このデメリットが無視できなくなります。Firestoreに永続化されたデータは、アプリケーションの成長にあわせて変化できるようになっていなければなりません。アプリケーションとデータベースが密結合していると、いざスキーマの変更が必要になったときに、ただでさえ困難な修正対応が余計に難しくなります。クライアントアプリケーションとの不必要な強い結合が発生するのを避けるためには、Firestoreのデータモデルを注意深く設計しなければなりません。
Firestoreの機能面を見ても、このような思想を支持していることが伺えます。例えば、Firestoreでは集計や結合を行うためのクエリは用意されておらず、フィルターによるクエリを高速に処理することに特化してます。複雑なクエリによってデータの処理や加工を行うユースケースは、よく設計されたデータモデルと単純なクエリによって置き換えることが求められます。
一般的なアプリケーションでは、データベースのスキーマがアプリケーションのソースコードほどの頻度で変更されることはまずありません。しかし、Firestoreをデータベースとして採用する場合は、一概にそうとも言えなくなります。少なくとも、サーバーサイドAPIのクライアント向けインタフェースの変更と同程度にはFirestoreのスキーマ変更が発生することを見込まなければなりません。
本来はデータベースの選択とは関係なく、アプリケーションの変化に伴ってデータモデルも変化すべきであり、これを怠ると現実の業務とアプリケーションが扱うモデルが乖離します。このようなビジネスとモデルの不整合は、いわゆる技術的負債の源泉となります。
一方で、多くのシステムにおいてデータやデータベースが神聖視されているのも残念ながら事実です。エンジニアはデータベースに変更を入れることを嫌い、本来データベースが扱うべきデータモデルの変化をビジネスロジックの修正で吸収しようとする傾向にあります。
サーバーサイドAPIが存在している構成ではこのような解決方法を選ぶことも可能でした。クライアントからの直接アクセスを受け入れたFirestoreの世界には、もはや業務とシステムの不整合を吸収(あるいは隠蔽)するレイヤーが存在しません。したがって、これまでと同じような回避策を適用すること自体ができなくなっています。
Firestoreを使うと、これまでと比べてかなりアグレッシブにスキーマの変更に取り組むことになるでしょう。将来の変更を予測して、データの欠損を起こさずにスキーマを変更できるような拡張性の高いデータモデルを考えるスキルは必須になります。サービスを停止させずにスキーマを順次アップデートするようなシステムを開発が当たり前になる日もそう遠くないでしょう。
このような状況において、FirestoreをはじめとするNoSQL系データベースのスキーマレスという特性が真価を発揮します。実際に出番がやって来ることはそれほど多くありませんが、スキーマの漸進的な変更が可能であることは覚えておいて損はありません。
Firestoreと従来のデータベースを比べると、大小様々な機能の違いがありますが、それらの中でもFirestoreを象徴する機能といえるのがセキュリティルール、リアルタイム・リスナー、オフライン対応の3つです。
これらの機能はデータモデルやアーキテクチャの設計に影響する特徴を内包しており、正しく理解した上で上手く活用できるかどうかがアプリケーションの品質を大きく左右します。
Realtime Database以前のほぼすべてのデータベース製品は、サーバーサイドアプリケーションと一体となって利用されることを前提に開発されています。データベースへアクセスするための認証情報をエンドユーザーがアクセスできないサーバーサイド環境に置くことにより、データのセキュリティに関わる問題の大部分を考えなくてもよいようにしていました。データベースへの読み書きに際して必要となるデータの処理やエンドユーザー単位の認証・認可は、信頼されたサーバーサイド環境で実行されるビジネスロジックとの合わせ技によって実現されています。
クライアントからの直接アクセスを実現するというのは、これらの常識をすべて覆す非常に大きな変更です。(SPAのようなWebアプリを含む)ユーザーの端末にインストールされて実行されるクライアントアプリケーションは、悪意ある攻撃者によってそれ自体が改ざんされてしまう可能性があり、信頼できるロジックの実行環境とは言えません。システム全体の安定稼働に係る処理や他のエンドユーザーに影響を及ぼす可能性のある処理は、やはりサーバーサイドに置く必要があります。つまり、ビジネスロジックのサポートが必要な従来のセキュリティモデルでは、クライアントからデータベースへの直接アクセスを実現するには役不足なのです。
セキュリティルールとは、データの機密性と完全性を担保するためのロジックを記述する、バックエンドロジックの新しい形態です。FirestoreのセキュリティルールをFirebase上にデプロイすると、記述された条件に従ってFirestoreへのリクエストの妥当性が評価されます。セキュリティルールの導入によって、Firestoreではエンドユーザーごと、また従来のDBにおけるテーブルに相当する粒度でのアクセスコントロールが可能となっています。
リアルタイム・リスナーはFirestoreの最新の状態をクライアントに同期するための仕組みです。複数のクライアントが同一のデータを参照し、その最新の状態を取得し続ける形にすることで、ユーザー間のリアルタイムなデータ共有が可能になります。
同期する対象は必ずしも複数のユーザー間である必要はありません。例えば、ひとりのユーザーのPCブラウザとスマートフォンアプリの間でリアルタイム同期を行ったり、(他により優れた方式がない場合には)ひとつの画面内で離れた位置にあるUIコンポーネント間でデータを同期したりといったように、工夫と使い方次第でユーザー体験を劇的に向上させる可能性を秘めています。
また、Firestoreのリアルタイム・リスナーは、非常に高いネットワーク切断からの復旧能力を備えています。通信中にネットワーク切断が起こったときの例外ハンドリングや切断状態から自動で復旧するような処理の実装は、ただでさえ難易度が高いものです。ましてデータベースへのアクセスという整合性の担保が重要となる処理ですから、自分で同等のものを開発するとなるとそれだけでかなりコストがかかってしまいます。FirestoreのSDKはこのあたりが非常にうまく作られており、開発者が異常発生をほぼ意識しなくて良いことが強みと言えます。
さらに、リアルタイム・リスナーはリアルタイムのデータ同期を実現する上でFirestoreに対する読み取りオペレーションの回数を最少にするための仕組みでもあります。ポーリングのようにクライアントからサーバーに問い合わせを繰り返すような方式でも同様の仕組みを実現することは可能ですが、データが更新されたかどうかに関わらず都度読み取りオペレーションとしてカウントされ、そのたびにコストが発生してしまいます。読み取りオペレーションを最少にするには、データの変更を検知してサーバーからクライアントに通知する方式で実装することが条件となります。Firestoreはまさにこの方式をリアルタイム・リスナーという形で提供しています。無駄な読み取りオペレーションが発生せず、費用面の効率でも優れています。
より抽象度の高い考え方をすれば、リアルタイム・リスナーは『サーバーサイドで起こったデータの変化を、サーバー側からクライアントに向けて通知する仕組み』でもあります。通常のデータベースへのアクセスは(読み取りでも書き込みでも)クライアントが起点となりますが、リアルタイム・リスナーを使うと逆方向の通信を発生させることができます。
クライアントからの要求を起点としてサーバーサイドでビジネスロジックを実行し、結果としてデータに変化が起こり、そのデータの変化、つまりビジネスロジックの完了をリアルタイム・リスナーを通じてクライアントに通知するという一連のサイクルは、ReactやVue.jsといったフロントエンドフレームワークで採用されているリアクティブ・システムとの親和性が非常に高く、データの永続化層であるFirestoreをその構成要素として組み込むことも可能です。
Firebaseが提供する各サービスは、不安定なネットワーク下でもアプリケーションをシームレスに継続稼働させるための機能を備えています。中でもFirestoreは非常に強力なオフラインサポートが特徴であり、開発者を煩雑なエラーハンドリングから開放してくれます。ただし、その恩恵にあずかるためにはオフライン時の動作を熟知した上で、どのような体験をユーザーに提供するのかを設計するスキルが必要となります。
Firestoreでは、データ書き込みのオフライン対応としてオペレーション・キューが実装されています。オフライン時に行われた書き込み要求はエラーとならず、キューに蓄積されます。端末がオンラインに戻ると、自動的にサーバーへのリクエストを再開し、蓄積された書き込み処理が解決されます。
このオフライン対応が存在するため、(極めて例外的な状況を除いて)Firestoreの書き込み処理がエラーとなることはありません。書き込みエラーが発生することを想定した通常の例外処理は原則として必要ありません。一方で、書き込み処理自体がいつ完了するかを制御することが難しくなっています。ユーザーの操作によって書き込みオペレーションが呼びされてから、それが完了するまでの間をどう見せるかという点については重要な設計上の課題となるでしょう。
データ読み込みに関しては、オフラインでのデータ永続化の有効・無効を設定できます。オフラインデータを有効にすると、オンラインの間にFirestoreと読み書きしたデータが透過的にキャッシュされるようになります。サーバーに接続できない状況でデータの読み取りリクエストを発行すると、キャッシュにあるデータが返却されます。
キャッシュを利用するアプリケーションでは、『どの機能でキャッシュを活用し、どの機能ではサーバー上のデータを取得するのか』というキャッシュ戦略や、『キャッシュから取得した(サーバーとの間で整合性が失われている)データを利用したとしてもユーザーに違和感を与えることがないか』といったユーザー体験観点でのアプリケーション設計が必要になるでしょう。