Cloud Firestoreで既存機能の一部をリプレースしました

TL;DR

こんにちは、スタメンの津田です。スタメンが提供しているサービス、TUNAGには、チャット機能があります。ブラウザベース、RailsによるREST API + Reactで構築されていたのですが、特にモバイルアプリケーションのユーザー体験を向上させたいということで、昨年末にモバイルアプリケーションチャット機能のネイティブ化と、それに伴うサーバー側の一部再構築を行いました。

その際、Cloud Firestoreクラウド上のキャッシュデータのような形で利用したため、その利用方法について紹介させていただきます。

なお、同時期に行っていたモバイルアプリケーション側の改修については、 @temokiが、「TUNAG iOSアプリのチャット機能をVIPERアーキテクチャで開発した話」で紹介しています。

設計概要

Cloud Firestoreは、NoSQL クラウド データベースで、SDKを通してモバイルアプリケーションから直接アクセスできます。そのため、いわゆるWeb API + DBを用意せずにモバイルアプリケーションのみで他ユーザーとのデータ共有を含むアプリケーションを作成できます。

今回のチャット再構築に関しては、すでにDBとAPIは存在していたのですが、そのような状況でも、Cloud Firestoreには以下のメリットがあると考え、採用しました。説明に関しては、Cloud Firestoreからの引用です。

  • リアルタイム アップデート
    • Realtime Database と同様に、Cloud Firestore はデータ同期を使用して、すべての接続端末のデータを更新します。ただし、シンプルな 1 回限りの取得クエリを効率的に実行するようにも設計されています。
  • オフライン サポート
    • Cloud Firestore は、アプリでアクティブに使用されるデータをキャッシュします。これによりアプリは、端末がオフラインになっている場合でもデータの書き込み、読み取り、聞き取り、クエリを実行できます。端末がオンラインに戻ると、Cloud Firestore でローカルに行われた変更がすべて同期されます。

上記の、モバイルアプリケーションとのデータ同期に関する利点を得るために、Cloud Firestoreをモバイル端末に対するデータの配信・同期の仕組みとして利用することにしました。具体的には、以下の図のような構成になります。

データの更新、チャットの発言、設定の更新等に関しては、WebブラウザのクライアントもモバイルアプリケーションもREST API経由で行います。モバイルアプリケーションから、Cloud Firestoreへ直接書き込むことはありません。

データの表示に関しては、Webブラウザは従来通りDB(Aurora)からREST API経由での取得、モバイルアプリケーションは、AuroraからCloud Firestoreを経由して伝達されます。

REST APIRuby on Railsで作成しているため、Cloud Firestoreへのデータ同期は、非同期処理のGem、sidekiqを利用しました。

比較対象とした構成

Cloud Firestoreを利用しない場合、オフライン時キャッシュや、リアルタイムのデータ取得などの機能をモバイルアプリケーション側で実装する必要があるのはもちろん、サーバー側でもデータ取得の効率等を考えてAPIを追加実装する必要があります。Cloud Firestoreにも制限はありますが、メリットがデメリットを上回ると考えました。

同期するデータ

同期するデータは、基本的にはRDBにあるもののコピーなのですが、「セキュリティルール」と、「平坦化」に留意する必要がありました。

セキュリティルール

モバイルアプリケーションは直接Cloud Firestoreに対してクエリを発行するため、Cloud Firestoreは単体で特定のユーザーに対して公開可能な情報を判断できる必要があります。

具体的には、 Cloud Firestore セキュリティ ルールを使ってみるで説明されているセキュリティルールでデータに対するアクセス制御が表現できるように設計する必要がありました。Cloud Firestoreのデータはファイルシステムのようなツリー状になっているので、「あるパスに対しての権限をカスタム関数で表現できるか?」を考えると判断しやすかったです。

平坦化

データの内容に関しては、どこまでデータの平坦化をするかを考える必要がありました。Cloud FirestoreはNoSQLデータベースなので、RDBのようなデータのジョインは得意ではありません。たとえば、あるユーザーのデータをCloud Firestoreに同期する際、所属する部署の情報がRDBでは別のテーブルで管理されていたとしても、Cloud Firestoreへ同期する際は全てを結合して、一つのドキュメントとして同期する必要があります。

RDB内でのデータ。

Cloud Firestoreのドキュメントとしては、以下のように平坦化。

user: {
  name: 'マルチ 太郎',
  departments: ['花火部', '火薬部']
}

平坦化自体はそれほど大変ではないのですが、平坦化した結果、RDB側の一つのレコードに対する変更が、Cloud Firestoreの複数のドキュメント更新の引き金となるケースが出てきます。ユーザーと所属部署の例で言えば、部署の名前が変更されると、その部署に所属している全てのユーザーデータをCloud Firestoreへ同期し直す必要が発生します。このようなケースの洗い出しは、データの構造によってはかなり大変になります。

また、基本的にデータは平坦化するのですが、5つの部屋に入室しているユーザーがいたとして、その5室すべてに、ユーザーの情報を登録していると、データの重複が多くなってしまい、また同期も大変になります。こういったケースではデータの平坦化をせず、クライアント側でジョインしてもらうような選択も行いました。

認証

Cloud Firestoreへの接続は、ネイティブアプリケーションから直接行うため、そのネイティブアプリケーションがどのユーザーに該当するのかを、Cloud Firestoreへ認証させる必要があります。

Firebase : カスタム トークンを作成する」には、以下のように記載されています。

Firebase では、保護された JSON Web Token(JWT)を使用したユーザーまたはデバイスの認証が可能であるため、認証に対する完全な制御が得られます。サーバーでこうしたトークンを生成し、クライアント デバイスに返した後、signInWithCustomToken() メソッドで認証するためにこのトークンを使用します。 これを行うには、ユーザー名やパスワードなどのログイン認証情報を受け入れて、その認証情報が有効であればカスタム JWT を返すサーバー エンドポイントを作成する必要があります。

今回は、既存のアプリケーション自体でログインの認証が行えるため、認証後にカスタムJWTを返すエンドポイント(REST API)を追加しました。

トークンの作成に関しては、Firebase Admin SDKRubyをサポートしていないので、「カスタム トークンを作成する : サードパーティの JWT ライブラリを使用したカスタム トークンの作成」の例を参考にして実装しました。

同期の制御

データの同期に関しては、各モデルで若干の違いはあるのですが、概ね以下のような方針で共通の処理を作り、利用しました。

  1. 対象のドキュメントを更新する必要があるかどうかを、RDB側で判断できる情報を持たせる
  2. リトライを前提とし、何度実行しても問題ないように作成する
  3. 複数のドキュメント更新が必要となるケースがほとんどなので、一括書き込みを使用する

1.同期判定

sidekiqのワーカー自体、ドキュメント更新のきっかけとなりうる処理が発生したために起動しているのですが、その中でも、「どのドキュメントが実際に同期する必要があるのか?」は個別にレコードをみて判断するようにしました。これは、同じレコードに対して複数の同期処理が呼び出されるケースがあるのと、リトライを前提としているために、リトライ時、既に同期が終わっているドキュメントを再度処理しないようにするためです。

Cloud Firestoreに登録されているドキュメントと、RDBに登録されているレコードに差が生じているかについては、DBのレコードに、updated_at(更新時刻)と、firestored_at(前回Cloud Firestoreに同期された際のupdated_at)を持たせ、一致しているかどうかを見ることにしました。差が生じていた場合は、Cloud Firestoreへ同期を行い、完了後にfirestored_atをupdated_atの値で更新します。

データの平坦化を行った関係で、あるテーブル(A)のレコードに基づくドキュメント更新要否が、他のテーブル(B)のレコードに依存する場合は、

  • Aのfirestored_atと、A, Bのupdated_atのより新しい方、を比較する
  • Bのレコードに更新があった場合、関連するAのupdated_atを更新しておく(Active Recordのtouchを利用)

のどちらかで行いました。ここが一番面倒だったので、もっといい方法はないかなー、と思っています。

2. リトライを前提とする

Cloud FirestoreのAPIは結構エラーを返します。リトライをすれば成功することがほとんどなので、リトライ自体はsidekiqの仕組みを利用し、更新の処理は何度実行しても問題ないように作成を行いました。

3. 一括書き込み

Cloud Firestoreには、トランザクションと一括書き込みという機能があります。処理が失敗した場合は単純にリトライするため、トランザクション機能は必須ではなかったのですが、一つ一つ書き込むのに比べてパフォーマンスが出るため、基本的にこちらを利用して書き込みを行いました。

ただ、一括書き込みには最大 500 件という制限があります。前述の注意点も含め、以下のような処理を行う共通機能を作成しました。

1. 更新が必要な可能性のあるレコードのリストを受け取る
2. レコードがなくなるまでループ
  a. Cloud Firestoreのトランザクションを開始
  b. 一件ずつループ
    - レコードの更新要否をチェック
    - 必要であれば、 Cloud Firestoreに対して書き込み
  c. 書き込みが500件溜まったら、トランザクションをコミットし、対象となったレコードのfirestored_atを更新

ただ、この方法だとCloud Firestoreへデータが反映されるタイミングが遅くなる(他のレコードも含めてコミットされた後になる)ため、特に更新を早くしたいドキュメントがある場合は、トランザクションを分けるなどの対応もしています。

テスト環境の構築

Realtime Databaseと違い、Cloud FirestoreはFirebaseプロジェクト一つにつき、一つしか存在しません。そのため、開発環境や、ステージング、テスト環境用に独立したものをいくつも用意するのが困難です。

そこで、production以外の環境では、Cloud Firestoreをルート直下から、

/env/developer-A/ここ以下にプロダクションのツリー
    /developer-B/

のように分けて、環境変数で指定するようにしました。

ただ、開発環境では、しばしばRDBの中身を入れ替えたりします。その場合は、全データの再同期をしない限り、どうしてもRDBのデータとは差が出てしまいます。これについて、現在は諦めています。

データの検索

Frebase : 全文検索では、Cloud Firestoreでの全文検索について、以下のように説明されています。

Cloud Firestore では、ネイティブ インデックスの作成やドキュメント内のテキスト フィールドの検索をサポートしていません。さらに、コレクション全体をダウンロードして、クライアント側でフィールドを検索することは現実的ではありません。

基本的には、Cloud Firestoreではテキストの検索は実現できません。今回は、Cloud Firestoreへ同期するのと同じように、Amazon Elasticsearch Serviceへ同期し、検索に関してはそちらで行うようにしました。

Elasticsearchをモバイルアプリケーションへ直接公開するのは難しいので、検索リクエストはRailsから行うようにしました。モバイルアプリケーションはまずRailsに対して検索を行い、レスポンスに含まれる、Cloud Firestore上のドキュメントのパスリストから、Cloud Firestoreに格納されている実際のデータを参照することになります。

料金

料金に関しては、「Cloud Firestore の課金について」のデータをもとに試算し、試験運用で確認しました。すでに動いているアプリケーションなので書き込み数は計算しやすかったのですが、読み取り数に関しては実際に動かしてみないとどれほど発生するのかが推測しづらかったです。

まとめ

Cloud Firestoreを使用した新しいモバイルアプリケーションでは、大きくユーザー体験を向上させることが出来ました。モバイルアプリケーションからは直接更新しないなど、若干、変則的な使い方ではあると思いますが、利用して正解だったと思っています。