KomercoとFirebaseの話【前編】 - Firestoreの設計パターン

こんにちは。Komercoの高橋です。

Komercoがリリースされてからもうすぐ3年が経とうとしています。 クックパッドの新規事業「Komerco」ではバックエンドのほぼ全てをFirebaseで運用してします。 新規事業のためエンジニアの数はまだ少ないものの、Firebaseのおかげでエンジニアはサービス開発に専念できている状況で、昨年はWeb版のリリースや送料無料イベントもあり、ユーザ数がさらに増加してきています。

これまで多くの機能を開発してきましたが、その中でFirebaseをより有効に使えるよう試行錯誤してきました。 これからFirebaseを使おうか考えている人にも、今現在Firebaseを使っている人にも参考になるよう、Komercoで得た知見を書いていこうと思います。

前後編となっており、前半はFirestoreの設計についてお話しようと思います。

当たり前の機能だけどFirestoreだと実現しにくいもの

Komercoは器や料理道具、食材や調味料を扱うECサービスです。 そのためFirestoreには「ユーザ」「商品」「ショップ」などのコレクションが存在し、主にこれらを操作しながら買い物の機能を実現しています。 また、ユーザとショップがやり取りを行うメッセージ機能や、Komercoからのお知らせ機能など、サービスとして標準的な機能もFirestoreで実現しています。

開発を続ける中で気づいたのは、一般的なサービスでよく見る機能でも、Firestoreで実現すにはひと工夫必要なことがあることです。 こういった問題にぶつかる度に、開発メンバーでどんな設計ができそうかアイデアを出し合ってきました。

ここでは、どういった機能がFirestoreで実現しにくいのか、それに対してどんな設計パターンがあるのか、それらのメリット・デメリットは何かを書いていこうと思います。

リスト表示に複数のコレクションの情報が必要

最初はよく語られる内容ですが、クライアントサイドジョインの問題です。 あるコレクションのリストを表示したいときに、各リストアイテムごとに別コレクションのデータが必要になるケースです。

例えばKomercoでは商品のリストを表示する画面で、各セルにショップの情報も表示しています。

f:id:yosuke403:20210420151618j:plain

Firestoreでは単一のクエリで単一のコレクションしか取得できない制約上、次のいずれかの手段を取ることになります。

冗長化: 取得するコレクションに別コレクションの情報を含めるようにする。

f:id:yosuke403:20210420151722p:plain:w700

クライアントサイドジョイン: クライアント側でコレクションを別々に取得して結合する。

f:id:yosuke403:20210420151700p:plain:w380

ちなみに、最初は単一コレクションの取得で済むよう設計できていても、その後の拡張で別コレクションのデータが必要になることも多いと思います。 Komercoも最近のアップデートで、商品のセルにショップの情報も載せるようになりました。 冗長化するかクライアントサイドジョインにするかの選択は、サービスを続けている間に必ず経験することになるのではないでしょうか。

実態としてクライアントサイドジョインが多くなりがち

冗長化には次のメリットには、

  • リスト表示に必要なデータの取得が一回のアクセスで済む。
  • クライアント側の実装がシンプルになる。

などがあり、クライアントサイドジョインのメリットは、

  • Firestoreのストレージ消費が少ない。
  • 冗長化の仕組み(Cloud Functionsの実装等)が不要。

などが挙げられると思います。

Komercoはあまり冗長化の選択ができていません。というのも冗長化の仕組みの実装・メンテナンスコストが大きくなりがちだからです。 例えば先ほどの商品にショップの情報を載せる例では、冗長化を行う場合、商品のコレクションにショップの情報の一部をCloud Functionsで付与する訳ですが、

  • ショップ情報更新時にそのショップの商品すべてを更新するCloud Function
  • 商品情報の新規作成時にショップの情報を取得して付与するCloud Function
  • Firestoreセキュリティルールの確認・変更
  • Firestoreインデックスの確認・変更(大きいフィールドを除外するなど)
  • (リリース後の場合)すでにFirestoreに存在する商品に対してショップ情報を付与する作業

等々が必要になります。 うまく管理していかないと、冗長化のCloud Functionsが増え続け、どのドキュメントを更新すると何に影響が出るのか分からなる可能性があります。

クライアントサイドジョインの場合、確かにクライアント側の実装コストは増えますが、大抵の場合はセルが画面に表示される直前にデータ取得する実装をするだけです。 セルに画像を表示する際によく行う実装と一緒です。 そのため、基本的にクライアントサイドジョインの選択を行っている状況です。

冗長化がうまくいった例

Komercoで冗長化がうまくいった例としては、商品一覧画面の画像取得の高速化があり、以前ブログでも書きました。

techlife.cookpad.com

この例のように、

  • クライアントサイドジョインでは処理完了までの時間が遅い
  • 対象の処理速度はサービスにとって重要である

といった場合において、冗長化の仕組みの導入コストに見合うのではないかと思っています。

Cloud Functionsで取得するという選択肢について

Cloud Functionsでジョインしてから返すことも考えたことがありますが、個人的にはあまりよくない手法だと思っています。 理由はFirestoreのセキュリティルールを無視した取得が可能になってしまうからです。

セキュティルールによって誰がクライアントを実装しても想定外の更新はなされないという安心感があり、特に新しいメンバーがジョインしたときになどに有効です。

どうしてもCloud Functionsで実装したい場合はそのようなケースに気をつけつつ、Firestoreと同じリージョンで作成するのがよさそうです。

リスト表示にページャーをつける

f:id:yosuke403:20210420153231p:plain

Firestoreで特定のコレクションのデータを一覧表示していく場合、一番簡単なのはアプリ上で一番下までスクロールしたら続く内容を追加ロードする仕様です。 これはFirestoreのstartAfterなどのカーソル句を使ったクエリと相性がいいためです。

一方で困るのは特定のページを指定して、その範囲のリストを取得することです。 特にPCの場合、追加ロードよりもページャーでリストを見ていく方が一般的かと思います。 ページを指定するということはつまり、取得開始位置と取得個数をクエリに含められばよいのですが、取得開始位置の指定はクライアントライブラリにはありません。

KomercoのWeb版はPCでの利用も想定しており、実現方法についてよく議論しました。

ドキュメントに何番目かを表す番号を付与する

クライアント的に理想的な状態は、何番目かを示すフィールドがドキュメントに追加されることだと思います。 取得範囲を指定が簡単で、全部で何件あるかも最後のドキュメントを取得すれば分かります。

ただこれは一方で、ドキュメントの順序変更や削除が発生した場合に、影響するコレクションの番号を振り直しをCloud Functionsで行う必要があります。 頻繁に更新されるようなコレクションの場合は、書き込み数の上限などに注意が必要です。

f:id:yosuke403:20210420153434p:plain:h400

ページ指定以外の方法を考えてみる

例えば週に1回定期的に更新される記事コンテンツのようなものの場合、ページではなく年月を指定してその範囲の記事を取得する方法が考えられます。 これであればFirestoreのクエリが書けるので、実現は容易です。

f:id:yosuke403:20210420153455p:plain:h400

全件取得

先に挙げた戦略が取れない場合、データ量的に問題ないことを前提に、全件取得の判断をすることもあります。 これは最後の手段なので、仕様を調整するなど、できるだけ別の解は無いか考えるようにしています。

未読にバッジをつける

サービスからのお知らせやメッセージ機能を実装すると必要になるのが未読機能です。 未読の保存方法についてはいくつかパターンがあります。

未読管理対象のドキュメントに保存する

未読管理対象のコレクションに対して、

  • 閲覧するユーザ数が限定されている
  • 各ユーザの未読・既読状況が、他の閲覧可能ユーザに公開されてよい

のであれば、そのコレクション自体に未読・既読を表す情報を入れてしまうのがよいと思います。 未読の有無の確認もクエリひとつで確認できますし、クライアントサイドジョインも不要です。

Komercoのメッセージ機能を例に説明します。 この機能は、クリエイターとカスタマーの1対1チャットを行うものです。 チャットールムに当たるRoomコレクションがあり、そのサブコレクションとして各チャットメッセージに当たるMessageコレクションがあります。

例えばクリエイターがメッセージを送信すると、Messageコレクションにドキュメントが追加されます。 Cloud Functionはそのイベントを受けて、親のRoomのドキュメントを更新し、カスタマーの未読有を示す isCustomerRead フラグを false にします。 カスタマーは isCustomerRead == false で絞り込むことで未読メッセージを見つけられますし、Messageコレクションを取得しなくてもそのチャットルームに未読があることが分かります。

f:id:yosuke403:20210420154207p:plain

既読管理用のサブコレクションを作る

「サービスからのお知らせ」のように、全体向けの情報に対して未読管理したい場合、先の例のようにお知らせのドキュメントに全ユーザの未読情報を書き込むのは現実的ではありません。

そこで例えば、ユーザのサブコレクションに既読管理用コレクションを作成し、お知らせを読んだ場合はそのお知らせのIDを持つドキュメントを既読管理用コレクションに追加します。 お知らせ表示時はそのコレクションを比較し、既読管理用コレクションに存在しなければ未読と判断されます。

f:id:yosuke403:20210420154225p:plain

ただし、「1つでも既読のお知らせがあればバッジを表示したい」といったときに、お知らせも既読管理用コレクションも、最悪全件取得しないと分からないという問題があります。

最後に閲覧した日付より前の記事を未読とする

例えばお知らせで、お知らせごとに個別に未読管理する必要がない場合は、最後にお知らせ一覧を開いた日付を覚えておき、それ以前のお知らせを既読としてしまう方法があります。 この場合、未読の有無はその日付を使ってクエリすることができます。

f:id:yosuke403:20210420154315p:plain

ドキュメントごとの既読管理の重要性が低い場合は、こちらの手法がよいと思います。

まとめ

Komercoの経験をベースに、Firestoreを使った開発で多くの人が悩みそうなケースについて説明しました。 どの設計を選ぶかは結局仕様次第になるので、仕様をよく分析して設計を選んでみてください。

弊社もまだまだ試行錯誤中なので「自分はこうしています!」というアイデアをお持ちの方、ぜひお話聞かせてください。

info.cookpad.com

後編は、Firebaseに関する仕組化を行って、運用コストを下げている話をしようと思います。

/* */ @import "/css/theme/report/report.css"; /* */ /* */ body{ background-image: url('https://cdn-ak.f.st-hatena.com/images/fotolife/c/cookpadtech/20140527/20140527163350.png'); background-repeat: repeat-x; background-color:transparent; background-attachment: scroll; background-position: left top;} /* */ body{ border-top: 3px solid orange; color: #3c3c3c; font-family: 'Helvetica Neue', Helvetica, 'ヒラギノ角ゴ Pro W3', 'Hiragino Kaku Gothic Pro', Meiryo, Osaka, 'MS Pゴシック', sans-serif; line-height: 1.8; font-size: 16px; } a { text-decoration: underline; color: #693e1c; } a:hover { color: #80400e; text-decoration: underline; } .entry-title a{ color: rgb(176, 108, 28); cursor: auto; display: inline; font-family: 'Helvetica Neue', Helvetica, 'ヒラギノ角ゴ Pro W3', 'Hiragino Kaku Gothic Pro', Meiryo, Osaka, 'MS Pゴシック', sans-serif; font-size: 30px; font-weight: bold; height: auto; line-height: 40.5px; text-decoration: underline solid rgb(176, 108, 28); width: auto; line-height: 1.35; } .date a { color: #9b8b6c; font-size: 14px; text-decoration: none; font-weight: normal; } .urllist-title-link { font-size: 14px; } /* Recent Entries */ .recent-entries a{ color: #693e1c; } .recent-entries a:visited { color: #4d2200; text-decoration: none; } .hatena-module-recent-entries li { padding-bottom: 8px; border-bottom-width: 0px; } /*Widget*/ .hatena-module-body li { list-style-type: circle; } .hatena-module-body a{ text-decoration: none; } .hatena-module-body a:hover{ text-decoration: underline; } /* Widget name */ .hatena-module-title, .hatena-module-title a{ color: #b06c1c; margin-top: 20px; margin-bottom: 7px; } /* work frame*/ #container { width: 970px; text-align: center; margin: 0 auto; background: transparent; padding: 0 30px; } #wrapper { float: left; overflow: hidden; width: 660px; } #box2 { width: 240px; float: right; font-size: 14px; word-wrap: break-word; } /*#blog-title-inner{*/ /*margin-top: 3px;*/ /*height: 125px;*/ /*background-position: left 0px;*/ /*}*/ /*.header-image-only #blog-title-inner {*/ /*background-repeat: no-repeat;*/ /*position: relative;*/ /*height: 200px;*/ /*display: none;*/ /*}*/ /*#blog-title {*/ /*margin-top: 3px;*/ /*height: 125px;*/ /*background-image: url('https://cdn-ak.f.st-hatena.com/images/fotolife/c/cookpadtech/20140527/20140527172848.png');*/ /*background-repeat: no-repeat;*/ /*background-position: left 0px;*/ /*}*/