Tabelog Tech Blog

食べログの開発者による技術ブログです

FirestoreによるPush型の情報パイプライン設計と運用 in 食べログオーダー

はじめに

食べログオーダーチームの大内です。

食べログオーダーではリアルタイムアップデート基盤としてFirestoreを採用し、システム上を流れる来店や注文といった情報をリアルタイムで店舗オペレーションへと繋げています。 本記事では私たちがFirestoreを使ってどのようにモバイルオーダーシステムを設計・運用しているかについてご紹介いたします。

背景: モバイルオーダーに求められるリアルタイム要件

食べログオーダーは食べログが取り組むモバイルオーダーサービスです。 モバイルオーダーの典型的なサービスフローは以下の通りです。
(https://order.tabelog.com)

  • 来店: 座席確保・QRコード発行印刷・QRコードを読み込んでセッション開始
  • 注文: メニューから注文をリクエスト・注文伝票を印刷
  • 会計: お客様がスマホから会計依頼・会計伝票を印刷・退店処理

従来の飲食サービスでは注文や会計といったサービスフロー進行のためにお客様と店舗スタッフがお互いのタイミングを合わせ、対面コミュニケーションを行なって意思表示する必要がありました。 繁忙している時間帯でなかなか注文を取りに来てくれないので、店員さんを呼び出そうと「すいません」と発声したら、2日ぶりの発声だったので1オクターブ高い音で絶叫してしまう恥ずかしい経験。皆様はございませんでしょうか。

モバイルオーダーを導入いただくとお客様と店舗スタッフがお互いのタイミングを合わせることなくサービスフローを進行できるようになります。この時、お客様がストレスを感じることなくサービスを提供するためには、「店舗のタイミング」ではなく「お客様が望むタイミング」で要望が店舗スタッフにPushされるリアルタイムの情報パイプラインが必要です。

システム上に生成されたデータをリアルタイムで店舗スタッフへ伝達し、調理・配膳・お会計といった具体的なアクションへつなげる際は、飲食店で従来から使われている伝票による作業内容のキューイングが有用です。 食べログオーダーでは店舗内に設置されたiOSアプリから情報パイプラインを常時監視し、注文伝票や会計伝票をプリンタから印刷、新着データ通知音を再生して店舗スタッフのリアクションへと繋げています。

Firestoreをどのように使うか: Firestore as a Pipeline

先述したリアルタイムの情報パイプラインを実現するために、食べログオーダーではFirestoreのスナップショットリスナーによるリアルタイムアップデートを採用しています。 データフローとシステム構成の要点を以下に示します。

上掲のデータフローは下記の方向性で整理されます。

  • CQRS
    • Command to API: データの登録はAPI
    • Adhoc Query to API: 任意のタイミングで最新のデータを取得したいときはAPI
    • Subscription Query to Firestore: 刻々と発生するイベントをリアルタイムに購読したいときはFirestore
  • 単一方向データフロー
    • 戻り値を期待せずCommandをAPIに投げて...
    • スナップショットリスナーを通じてFirestoreからデータを取得
  • リアルタイム性の要件が絡まないシステムは単純なCRUDのメンタルモデルで実装
    • 管理画面とか

このように全体感を整理してFirestoreはSubscription Query向けの非正規化データのパイプラインであることを意識しておきます。パイプラインとしての限定的な用途で利用する限り、データの整合性やマイグレーションに配慮することなく、非正規化したデータを突っ込めばいいだけの雑な扱いで運用できます。

アドホックなクエリに対しては常に最新のデータを返せるようにデータの整合性を維持し続ける必要があるため、そちらもFirestoreに寄せてしまうとRDBと二重データソースとなり運用が面倒になります。 そのため、食べログオーダーではアドホックなクエリは原則として従来通りRESTのAPIで整合性が確保されたDBからデータを取得し、購読型のクエリだけFirestoreに流すという方針で整理しています。 アドホックなクエリをFirestoreに寄せた時の辛みは後ほどご紹介します。

サーバーサイド実装の指針: コールバックによる同期キューイング

ControllerやServiceオブジェクトなどの手続型実装の中に永続化の読み書きオペレーションが散在するとコードの見通しとメンテナンス性が低下します。 DB以外に永続化層が存在することを意識することなくロジックを記述できるように、パイプラインであるFirestoreへの書き込みは以下のようにパターン化しています。

  • ActiveRecordコールバック(after_xxx)で書き込みオペレーションをキューに積み、DBがコミットする直前にFirestoreのバッチで書き込む
  • APIサーバーからはFirestoreのデータを読み込まず、DBが持っている情報だけで一方通行で書き込む
  • Firestoreにはクライアントが欲しい形で非正規化して格納する

私たちのチームでは after_commit_everywhererequest_storeGoogle::Cloud::Firestore::Batch を組み合わせてミックスインを作っています。

雰囲気実装を以下に示します。

# 注文モデル
class Order < ApplicationRecord
  # このミックスインでFirestoreバッチへのキューイングのメソッドを利用可能になる。
  # (読み込みの機能は提供していない)
  include FirestoreBatchWritable

  # 変更時にFirestoreに同期
  after_save :sync_with_firestore

  def sync_with_firestore
    firestore_batch_set(
      # ドキュメントパスについては主キーや関連レコードから一意に決められる
      FirestoreDocumentPath::Order.new(self).to_s,
      # クライアントの要求に応じた非正規化データを生成
      FirestoreSerializer::Order.new(self).to_h  
    )
  end
end
module FirestoreBatchWritable
  # チラッ
  def firestore_batch_set(document_path, document_hash, merge: nil)
    firestore_batch.set(document_path, document_hash, merge: merge)

    firestore_bacth_commit_finally
  end

  # チラッ
  def firestore_bacth_commit_finally
    return if RequestStore.store[:firestore_batch_queued]
    # トランザクションで一回だけ実行されるようにするフラグ
    RequestStore.store[:firestore_batch_queued] = true
    # AfterCommitEveryWhereのbefore_commitでコミットオペレーションを登録
    write_before_commit
    # AfterCommitEveryWehreでロールバック時の後片付けを登録
    clean_after_rollback
  end
end

rspecではFirestore emulatorを立てて、ActiveRecordのコールバックで 期待したパスに期待されたドキュメントが作成されているかをテストしています。

アドホックなクエリを寄せるとどうなるか

前々節でアドホックなクエリはAPIに流す方針などと偉そうに述べました。 実は私たちの実装でもリロードレスのUX目的でガッツリFirestoreに寄せてしまった部分があり、ほんのりとクソコード臭を漂わせています。クソ臭の原因は非正規化データの依存関係とデータの整合性維持の必要性から発生するActiveRecordコールバック祭りです。

# お祭り会場
class Hoge < ApplicationRecord
  include FirestoreBatchWritable

  after_save :sync_with_firestore

  def sync_with_firestore
    firestore_batch_set(
      FirestoreDocumentPath::Hoge.new(self).to_s,
      # このシリアライザ内でFugaやHigeといった非正規化したデータを生成しているとき...
      FirestoreSerializer::Hoge.new(self).to_h  
    )
  end
end

class Fuga < ApplicationRecord
  # 非正規化したデータに導かれしコールバック1
  after_update :sync_hoge_with_firestore

  def sync_hoge_with_firestore
    hoge.sync_with_firestore
  end
end

class Hige < ApplicationRecord
  # 非正規化したデータに導かれしコールバック2
  after_update :sync_hoge_with_firestore

  def sync_hoge_with_firestore
    hoge.sync_with_firestore
  end
end

# 非正規化データの依存関係分だけコールバック同期の記述が散らばっていく...

非正規化データが肥大化していくにつれてデータの依存関係が広がり「Higeのafter_updateでHogeのFirestore同期するのって何故でしたっけ」という会話が増えていきます。シリアライザ(ActiveModel::Serializer)を読んでデータの依存関係を確認する必要があるなど開発者体験も良くありません。すぐに直したいという温度感ではありませんが経過観察している箇所になります。

こういったユースケースではイベントソーシング、Change Data Capture、APIをトリガーしてジャストインタイムでPULLさせるFirestoreのパイプラインを作るなど別解も検討してみてください。

クライアント実装の指針: Avoid Stream Hell

Firestoreのスナップショットリスナーを通じてリアルタイムアップデートを受けるクライアント側実装は流れてきたデータを受動的にそのまま表示することを基本方針としています。 複数のスナップショットリスナーをRx系のストリームに流して、複数のストリームを合成するといった実装は私たちの設計指針からするとクライアントの複雑度が高すぎると感じます。

このような処理が必要になってきたらパイプライン設計が間違えていると考え、ドキュメント構造を見直して1本のスナップショットリスナーからクライアントが必要とするデータを充足できるように修正します。

ストリームの分岐や合成が複雑になると入力から出力を予測するのが難しくなりアプリ単体の複雑度が高くなるだけではありません。複数のドキュメントがクライアント側でジョインされるようになるとサーバーサイドがもつロジックの複雑さとクライアントが持つロジックの複雑さが掛け算され、修正の影響箇所を読むのが難しくなります。

私たちはトピック単位で修正の影響箇所をレビューしてQA計画を立案・実施し、受け入れ基準を満たしたもののみリリースされるようなリリースプロセスを運用しています。 プラットフォームを跨いで複雑さが掛け算されているような実装はテスト計画の立案負荷と実施コストが高くなります。

開発全体のプロセスを見渡したときにこのように実装された箇所がホットスポットになるとリグレッションテスト負荷も増大し、コードの可読性やメンテナンス性云々以上のネガティブインパクトがあると実感しています。 (QAチームが立ち上がる前はエンジニアチームが自らテスト計画立案と実施を担っていたので...)

Firestoreのパフォーマンスを引き出す

レイテンシのモニタリング: TTFBとTATの計測

Firestoreのパフォーマンスを引き出すには公式ドキュメントのベストプラクティスを実践すればいいわけですが、ホットスポットを発見し、原因を修正していくインクリメンタルなプロセスも重要です。

このようなインクリメンタルなプロセスを回すにはFirestoreのパフォーマンスをモニタリングする必要があります。 KeyVisualizerやCloud Monitoringで監視できる項目に加えて私たちは以下の2つのメトリクスを計測しています。

  • TTFB: スナップショットリスナーを作成してから最初のコールバック呼び出しまでの期間をミリ秒単位で計測
  • TAT: サーバーサイドでデータを書き込んでからスナップショットリスナーで受信するまでの期間をミリ秒単位で計測

それぞれFirebase Performance Monitoring, Firebase Analytics to BigQueryで分析・モニタリングしています。計測の際はキャッシュや既存データがノイズとなるので適宜読み捨てています。

Firestoreでは読み書きエラーとして発生が顕在化しやすいハードリミットだけでなく、 レイテンシの悪化として影響するため発生を検知しにくいソフトリミットがいくつかあります。

私たちの事例をご紹介すると、とある一覧・詳細画面で画面遷移時にスナップショットリスナをチャーンさせていたことが原因で、ランチタイムに詳細画面のTTFBが顕著に悪化する事例に遭遇しました。 チャーンレートが高すぎるとレイテンシ増加に繋がりうることはドキュメントに書かれています。 書かれていますが、開発環境では問題なく動いてしまうのでリリース前に気付くのが難しく、開発環境で再現性が低いためたまたまFirestoreの調子が悪かったんじゃないのと対応が遅れる原因にもなります。

本番環境と同等の読み書き状況を作った上で動作検証するというものなかなか難しいので、リアルユーザーモニタリングで迅速に問題を把握できる体制を構築しておくことをお勧めします。

不要インデックスの除外: マップフィールドによるインデックス除外

開発中には便利な自動フィールドインデックスですが、クエリパターンが固まった後は開発者体験を目的とした自動フィールドインデックスはパフォーマンスの足枷になります。特にタイムスタンプなどの順次増加するフィールドはコレクションへの最大書き込み速度を劣化させるのでクエリで使わないならインデックス除外しておきましょう。 順次増加するフィールドがパフォーマンスを劣化させる原因はシャーディングされたタイムスタンプのドキュメントを参考になさってください。

私たちは不要なインデックスが作成されないようにクエリに使わないインデックスは全て除外していますが、ドキュメントをフラットな構造にしてしまったのでインデックス除外を記述するのがとてもめんどくさいです...。最大200の上限があるフィールド構成もインデックス除外を記述するために無駄に消費してしまっています。

公式ドキュメントにはこのようなケースでマップフィールドをサブフィールドにするテクニックが紹介されています。クエリに使わないフィールドをマップフィールドのサブフィールドにしておくとフィールド除外も書きやすく、無駄なフィールド構成の消費も抑えられます。 https://cloud.google.com/firestore/docs/solutions/index-map-field?hl=ja

セキュリティ周りの実装

私たちのドキュメント設計ではトップレベルにテナント(店子)のコレクションを起き、テナントのデータはそのテナントで認証したユーザーに限定されるようセキュリティルールを作成しています。

セキュリティルールを実装する際に、えーっと...これ何をどうすれば良いんだっけ...ととてつもなく時間を浪費したので彷徨えるググり人のためにテンプレ実装をチラ見せさていただきます。

サーバーサイドでカスタムトークンの生成

サーバーサイドでfirebase認証用トークンを発行します。

    # @see https://firebase.google.com/docs/auth/admin/create-custom-tokens?hl=ja#create_custom_tokens_using_a_third-party_jwt_library
    def token(uid, client_email, private_key, iat, exp)
      payload = {
        iss: client_email, # サービスアカウントのメールアドレス
        sub: client_email, # サービスアカウントのメールアドレス
        aud: 'https://identitytoolkit.googleapis.com/google.identity.identitytoolkit.v1.IdentityToolkit',
        iat: iat, # UNIXエポックからの現在の時刻(秒単位)
        exp: iat + exp, # トークンの有効期限が切れるUNIXエポックからの秒単位の時間
        uid: uid # サインインしたユーザーの一意の識別子は、1〜36文字の長さの文字列である必要があります
      }
      private_key = OpenSSL::PKey::RSA.new private_key

      JWT.encode payload, private_key, 'RS256'
    end

セキュリティルールで request.auth.uid をドキュメントパスやドキュメントフィールドと比較したいので、ペイロードのuidにはテナントのドキュメントIDを渡します。 uidがフィットしなければカスタムクレームに適当な制御用のIDを埋め込んでセキュリティルール内で検証するのが良いでしょう。

クライアントでのカスタムトークン認証

クライアントはサーバーから取得したトークンでカスタムトークン認証するだけです。

// Swiftです
Auth.auth().signIn(withCustomToken: customToken) { result, error in
  // 適当に実装ください
}

セキュリティルールによるアクセス制御

  • コレクションクエリはテナントのドキュメントIDと request.auth.uid が一致すればreadを許可
  • コレクショングループクエリの場合はクライアントがwhere句で自身のuidを指定していればreadを許可
rules_version = '2';

service cloud.firestore {

  // コレクションへのアクセス制御
  function isOwnerOf(tenant) {
    return request.auth != null && request.auth.uid == tenant;
  }

  // コレクショングループのクエリ制御 
  function isTenantsDocumentOnlyRequired() {
    return request.auth != null && resource.data.uid == request.auth.uid;
  }

  match /databases/{database}/documents {
    // コレクション制御
    match /tenants/{tenant} {
      // 来店コレクションへの制御
      match /visits/{visit} {
        allow read: if isOwnerOf(tenant);
      }
    }

    // コレクショングループ制御
    match /{anyPath=**} {
      // 注文が来店のサブコレクションになっている時に複数の来店にまたがってクエリしたいときはコレクショングループから取得
      // /tenants/(テナントドキュメント)/visits/(来店ドキュメント)/orders/(注文ドキュメント)
      match /orders/{order} {
        allow list: if isTenantsDocumentOnlyRequired();
      }
    }
  }

  // 上記にマッチしない全てのオペレーションを禁止
  match /{anyPaths=**} {
    allow read, write: if false;
  }
}

セキュリティルールの自動テストとデプロイ

私たちはセキュリティルールの検証テストをCIに組み込んでいます。 テストコードはざっくり以下のような感じです。

import * as unitTesting from "@firebase/rules-unit-testing";

let testEnv: unitTesting.RulesTestEnvironment;
const makePath = (tenantId: string) => `tenants/${tenantId}`;

beforeAll(async () => {
  testEnv = await unitTesting.initializeTestEnvironment({
    projectId: "test-project",
    firestore: {
      rules: fs.readFileSync("./firestore/firestore.rules", "utf8"),
    },
  });
});

afterAll(async () => {
  await testEnv.cleanup();
});

describe("コレクション制御", () => {
  it("ログインしたテナントIDと一致したテナントの来店コレクションを読み込めること", async () => {
    const context = testEnv.authenticatedContext("some_tenant_id");
    await unitTesting.assertSucceeds(context.firestore().doc(makePath("some_tenant_id")).collection("visits").get());
  });
})

describe("コレクショングループ制御", () => {
  beforeAll(async () => {
    await testEnv.withSecurityRulesDisabled(async (context) => {
      const firestore = context.firestore();
      // ここでテスト用に適当なデータをセットしてください。
    });
  });
  it("ログインしたテナントIDで絞り込んだ注文コレクショングループを読み込めること", async () => {
    const firestore = testEnv.authenticatedContext("some_tenant_id").firestore();
    const operation = firestore.collectionGroup("orders").where("uid", "==", "some_tenant_id");
    await unitTesting.assertSucceeds(operation.get());
    // 適当なデータが読み込めるかテストしてみてください。
    expect((await operation.get()).size).toEqual(1);
  });
})

マージトリガーでデプロイするデリバリーのワークフローを作っておき、セキュリティルールのテストを通ったものしかデプロイできないようにしておくと安全でしょう。

最後に

駆け足で食べログオーダーでのFirestoreの設計と運用についてご紹介させていただきました。

私たちのシステムにとってFirestoreはモバイルオーダーをリアルタイムで店舗オペレーションにつなげる要ですが、システム上は単一障害点でもあります。 システムをより堅牢にしていくために今後もFirestoreにがっつりコミット...というほどの距離感ではないのですが、長期的な運用の維持可能性を考えながらより良いアーキテクチャを目指していきます。

私たちも依然試行錯誤の日々で改善すべきこともまだまだたくさんありますが、本記事が皆様のお役に立てれば幸いです。