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に関する仕組化を行って、運用コストを下げている話をしようと思います。

クックパッドマートの生鮮食品を SORACOM の IoT デバイスで遠隔温度監視している話

クックパッドマートでサーバーサイドなどのソフトウェアエンジニアをしている石川です。

この記事では、クックパッドマートの物流の一部で SORACOM のサービスを活用して生鮮食品の遠隔温度監視を行っている話について、主にサーバーサイドの取り組みを紹介します。

クックパッドマートの物流

クックパッドマートの物流は、非常に大雑把に言うと以下のような手順になっています。

  1. 販売者さんが集荷場所に置いてあるコンテナへ商品を届ける。
  2. ドライバーさんが集荷場所からコンテナを集めて回る。
  3. 集めたコンテナを、商品の受け取り場所の冷蔵庫に届ける。
  4. 冷蔵庫に届いた商品をユーザーさんが受け取る。

まとめると、(販売者さん)→集荷場所→(ドライバーさん)→ステーション→(ユーザーさん)、という図式になっています。

ここでコンテナと言っているのは物理的な容器の方のコンテナです。以下のようなコンテナに食品を入れて運んでいます。

緑色の折り畳めるコンテナの写真です。
コンテナ

今回注目するのは、ドライバーさんが集荷場所から受け取り場所に届けるまでの間です。集荷場所から受け取り場所の間ではドライバーさんが車両でコンテナを移動させています。お肉やお魚などの生鮮食品を扱っているため移動の間も温度を低く保ちたいのですが、コンテナをそのまま運んでしまうと温度を低く保つのが難しいため、蓄冷剤の入った保冷用のボックスにコンテナを入れて運んでいます。

保冷用のボックスというのは次の写真のような、銀色の箱です。コンテナがいくつか入る程度の大きさになっています。

外側が銀色になっている、直方体の箱です。
保冷用ボックス

この保冷用ボックスの温度を遠隔監視しよう、というのがこの記事のテーマです。

GPS マルチユニット SORACOM Edition

2021 年 4 月現在、私たちはこの保冷ボックスの温度を「GPS マルチユニット SORACOM Edition」を使って計測しています。このセンサーは京セラさんのセンサー「GPS マルチユニット」をベースに SORACOM プラットフォームで利用しやすくするためのカスタムがなされているセンサーです。温度の他、湿度、加速度、位置情報を計測できます。

GPS マルチユニット SORACOM Edition の見た目は下のような感じです。後ろでコンテナの中に入っているものは食品サンプルです。

白くて薄い直方体の形をしたセンサーです。背景には保冷用ボックスが写り込んでいます。
GPS マルチユニット SORACOM Edition

GPS マルチユニット SORACOM Edition は、実装当初候補に挙がっていた他のセンサーと比較しても実装工数が少なく済みそうなことや、今回のユースケースに充分な測定精度があること、充分な数を入手できそうなことなどを理由に採用しました。

SORACOM プラットフォーム上でこの GPS マルチユニットを使う場合、SORACOM Lagoon を使ってダッシュボードを作れる他、SORACOM FunnelSORACOM Funk を使って他のクラウドサービスへデータを送ることができます。今回は SORACOM Funnel を使って AWS へデータを送っています。

温度データの利用

具体的な構成の説明に入る前に、私たちが温度データを使ってどのような機能を作ったかを説明します。

保冷用バッグの中には生鮮食品が入っているため、GPS マルチユニットの示す温度は低く保たれている必要があります。もし GPS マルチユニットの温度が高くなり始めた場合、その GPS マルチユニットの入った保冷用ボックスを運んでいるドライバーさんにご確認いただこうということになりました。

しかし GPS マルチユニット自体には温度を表示する液晶画面の類や異常を知らせるブザーなどはついていないため、何かしら別の方法でドライバーさんに異常を伝えなければいけません。そこで GPS マルチユニットの温度データを元にドライバーさんへ通知をする機能を作りました。

元々ドライバーさんには、当日の配送ルートの表示や集めるコンテナの指示などを行う目的でモバイルアプリを使っていただいていました。これはドライバーさん専用アプリで、販売者さんの商品を販売するアプリとは別のものです。社内では通称「ドライバーアプリ」と呼ばれています。

もし GPS マルチユニットの温度が高くなり始めた場合、このドライバーアプリ越しにプッシュ通知してお知らせするようにしています。以下のような感じです。

プッシュ通知のスクリーンショットです。内容には「シッパー内の温度が高くなっていませんか?」「タップして続ける」と書かれています。
プッシュ通知

※「シッパー」と書かれているのが保冷用ボックスのことです。

また、先述のように GPS マルチユニット自体を目で見ても温度は分からないため、ドライバーアプリから現在の温度が確認できるようにもしています。

ところでこの機能を実装するためには、それぞれの GPS マルチユニットをどのドライバーさんが持っているのか知らなければいけません。この紐付け登録もドライバーアプリを使って行っています。具体的には、それぞれの GPS マルチユニットに二次元コードを貼り付け、ドライバーさんにモバイルアプリで読み取ってもらうことで登録しています。

更にドライバーさん向けだけでなく、弊社の社内オペレーター向けにも温度データが見えるようにもしています。具体的には、Grafana を使ったダッシュボードで温度の時系列変化が確認できるようにしたり、GPS マルチユニットの温度が高い状態を維持した場合に Slack へ通知が来るようにしたりもしています。

構成

以上のような機能を作るため、サーバーサイドでは以下のような構成を採用しました。

各々のサービスを示すアイコンが矢印で結ばれています。まず GPS マルチユニット SORACOM Edition から SORACOM Funnel に矢印が伸びており、SORACOM Funnel から Amazon Kinesis Data Firehose に伸びています。そこから AWS Lambda に伸びており、AWS Lambda からは 2 本の矢印がそれぞれ Amazon CloudWatch と Amazon DynamoDB に伸びています。
構成図

まず、GPS マルチユニットから SORACOM へ送られてきたデータを SORACOM FunnelAmazon Kinesis Data Firehose に転送します。続いてそのデータを AWS Lambda を使って整形しつつ Amazon CloudWatchAmazon DynamoDB に流します。図からは省略していますがモバイルアプリからは API アプリを介して DynamoDB にある温度データにアクセスできるようになっています。なお、データの行き先が CloudWatch と DynamoDB の 2 つになっているのは開発事情の都合です。

構成自体はシンプルで、全体の実装にもそこまで長い時間はかかりませんでした。たとえば AWS Lambda の部分は AWS CDK を使って 1 日かからずに作れています。

なお、自分が不慣れだったこともあり、上記の構成に落ち着くまでには少し時間がかかりました。たとえば論点をひとつ取り上げると、SORACOM Funnel と SORACOM Funk のどちらを使うかというものがありました。

SORACOM Funnel と SORACOM Funk はどちらもクラウド間を結ぶ「アダプタ」であるという意味で似たサービスです。結果的に Lambda を実行するのであれば、SORACOM から別のクラウドへデータを運ぶ仕組みである SORACOM Funnel ではなく、SORACOM から別のクラウドの関数を実行する仕組みである SORACOM Funk を利用する選択肢もありました。しかし今回は SORACOM Funnel を採用しています。

これは、SORACOM Funnel の方がそれぞれの温度データのタイムスタンプをより正確に得られそうであったためです。GPS マルチユニット SORACOM Edition はデータ取得時の時刻を送らないため何かしらの時刻を使ってタイムスタンプを計算する必要があるのですが、SORACOM Funk を使った場合タイムスタンプの誤差が大きくなりうるという問題がありました。

というのも、今回の実装時点の SORACOM Funk は、GPS マルチユニットからのデータを受信した瞬間のタイムスタンプを保持していませんでした。このため SORACOM Funk を使った場合に温度データのタイムスタンプを得るには「SORACOM Funk 経由で実行された Lambda の内部で現在時刻を取得して温度データのタイムスタンプとする」ような実装が必要でした。しかし Lambda の実行が失敗してリトライされる可能性があることを考えると誤差が許容できないと判断しました。SORACOM Funnel ではデータを受信した瞬間のタイムスタンプが保持されており、この問題が起こりませんでした。

ただし SORACOM Funnel にも、GPS マルチユニットがデータを取得するタイミングと Funnel がデータを受信するタイミングにズレがあるという別の問題はあります。

この問題については、今回は温度について秒単位でのリアルタイム性は要求されておらず、またデータ取得時刻とデータ受信時刻のズレは充分小さいらしいことが分かったため、許容することにしています。実際私たちが気にしているのは「温度の低い状態が維持されているか」であり、特定時刻の温度を正確に知りたい訳ではないため、これで充分と判断しました。

このようにいくつかの論点を考えた上で今回の構成に至りました。そして実際今のところサービスが成長しドライバーさんが増えていく中でもこの構成のまま運用を進められており、ベターな選択であったと考えています。

最後に

以上のように、この記事では、GPS マルチユニット SORACOM Edition を使って生鮮食品の温度を遠隔監視している話を紹介しました。

ところで、実際にこの仕組みを使って温度を計測してみると、たとえば車がトンネルに入ると少しのあいだ温度が上手く取得できないことがあるなどの問題が分かっています。現状は安全側に倒してデータ取得失敗時も通知を行い、人力で判断している状態です。このような状況をどうしていくか、ハードウェアとソフトウェアの両面から改善を進めています。

このようにクックパッドマートでは実世界にまつわる様々な課題にハードからもソフトからも切り込み、スピード感のある開発を続けています。弊社ではただいま絶賛エンジニア募集中ですので、ぜひ採用情報をご覧くださいませ。

cookpad-mart-careers.studio.site

info.cookpad.com

※SORACOM は、株式会社ソラコムの登録商標または商標です。

JSON Schema をクックパッドマートの商品登録画面に導入した話

主にバックエンドのエンジニアとしてクックパッドマートの開発に携わっている塩出( @solt9029 )です。

美味しい食材をユーザにお届けするサービスであるクックパッドマートでは、日々街の販売店や地域の生産者が商品の登録を行っています。
商品を登録する際、販売者は消費期限をはじめとする様々な品質保証の情報を正確に入力する必要があります。
しかし、商品の種類や状態に応じて記載するべき品質保証の情報は異なるため、全項目が羅列されるフォームでは正確な入力が困難であり、販売者および商品の審査を行う社内の運用メンバに対して大きな負担をかけていました。 そこで、 JSON Schema を利用して複雑なフォームの出し分けを自動で制御し、またバックエンド側でのバリデーションも行うことが出来る仕組みを導入しました。
その結果、商品の種類や状態を選択するだけで、適切な品質保証の情報が自動的に入力され、必要な項目のフォームのみが表示されるようになり、販売者および商品の審査を行う社内の運用メンバの負担を大きく減らすことが出来ました。

f:id:solt9029:20210405174433g:plain:w250
JSON Schema を導入した商品登録画面

f:id:solt9029:20210406100722p:plain:w460
JSON Schema による商品の種類ごとのフォームの比較

背景・目的

クックパッドマートは、弊社が力を入れて取り組んでいる新規事業の1つです。生鮮食品を中心として扱っているECプラットフォームで、街の販売店や地域の生産者が、販売者としてクックパッドマートに参加しています。コンビニエンスストア・ドラッグストア・駅・マンションなどの様々な場所に、ユーザの受け取り場所として専用の冷蔵庫が設置されています。ユーザはアプリから注文を行い、冷蔵庫から生鮮食品を受け取ることができます。

クックパッドマートでは、販売者が商品の登録や日々の出荷作業などを行うための機能を提供する販売者向け管理画面を開発しています。
販売者向け管理画面を通じて商品登録をする際に、商品名や写真、価格だけでなく、消費期限や解凍品かどうかなどの、品質保証や食品表示に関わる情報も入力する必要があります。
一方で、それらの情報は商品の種類や状態に応じて入力するべきものが異なり、全項目が羅列されるフォームでは正しい項目を入力することが難しい状態でした。

例えば、じゃがいもをそのまま販売する場合、消費期限を入力する必要はなく、「お早めにお召し上がりください」といった文言を特記事項として記載する必要があります。
また、製品として販売されているドレッシングのように、商品自体に消費期限や賞味期限が元から記載されている場合、消費期限ではなく保証消費期限として、出荷日から最低限品質が保証される日数を入力する必要があります。この場合、販売者は保証消費期限よりも長い日数の消費期限や賞味期限が記載された商品を出荷する必要があります。
その他には、鮮魚や魚介加工品などの商品を販売するときには、生食用なのか・養殖なのか・解凍品なのか、といった項目を明示する必要があります。
このように、商品登録をする際には、商品の種類や状態に応じてそれぞれ異なった種類のデータを入力する必要がありますが、全項目が羅列されるフォームから人手でどの情報を入力するべきかを都度判断するのはとても困難です。そのため、商品の種類や状態に応じて、適切なフォームが出し分けされる仕組みが求められていました。

また、フロントエンド側でフォームの出し分け制御がされるだけでは、不正なデータの登録を完全に防ぐことはできません。社内の運用メンバが商品の販売開始前に商品審査を行っているものの、商品審査の負担やミスを避けるために、バックエンド側でバリデーションされた上で商品が登録されている状態が望ましいです。

複雑なフォームの出し分けのみであれば、 JavaScript でオレオレ実装をすることも考えましたが、バックエンド側のバリデーションまで考慮すると、共通した Schema が存在している状態が望ましいと考えました。そこで、複雑なフォームの出し分けおよびバリデーションをすることが可能な仕組みとして、 JSON Schema を導入することにしました。

実装

JSON Schema とは

JSON Schema とは、その名の通り JSON の構造を定義したものです。 OpenAPI で利用されている記述方法として知っている方も多いかもしれません。百聞は一見にしかずということで、JSON Schema とそれに対応する JSON の簡単なサンプルをご紹介します。

{
  title: "お料理レシピ",
  type: "object",
  properties: {
    id: { title: "ID", type: "integer" },
    title: { title: "タイトル", type: "string" },
    content: { title: "作り方", type: "string" },
    public: { title: "公開中", type: "boolean" }
  },
  required: ["id", "title", "content", "public"]
}
{
  id: 100,
  title: "カルボナーラの作り方",
  content: "ベーコンと玉ねぎを食べやすい大きさに切ります。〜(以下略)",
  public: true
}

このように型や必須項目など、 JSON の構造を定義することができます。他にも、本記事で扱う dependencies や oneOf などといった、複雑な構造を定義するときに便利な方法が豊富に用意されています。より詳細な仕様については、 Understanding JSON Schema をご参照ください。

JSON Schema の定義

商品登録時の JSON Schema を定義するにあたって、商品の種類ごとに必要なフォームの出し分けができるような構造を考える必要がありました。詳細な説明は省きますが、代表的な商品の種類を一部抜粋してご紹介します。

  • 根菜類(玉ねぎ、人参、じゃがいも)
    • 品質保証の種類:品質保証に関する特記事項 → テキストを入力するフォームが必要(デフォルトでは「お早めにお召し上がりください。」と入力される)
  • 鶏肉
    • 品質保証の種類:消費期限 → 日数を入力するフォームが必要
    • 解凍表示を入力するフォームが必要
  • 魚介加工品
    • 生食表示(生食用 / 加熱用)を入力するフォームが必要
    • 養殖表示(養殖 / 天然)を入力するフォームが必要
    • 解凍表示を入力するフォームが必要
    • 解凍品を選択した場合
      • 品質保証の種類:消費期限 → 日数を入力するフォームが必要
    • 非解凍品を選択した場合
      • 品質保証の種類:保証消費期限(配送日から最低限品質が保証される期間) → 日数を入力するフォームが必要

特に魚介加工品が一番複雑に見えると思います。このように、ある特定の値に応じてフォームの出し分けをする必要がある場合には、 JSON Schema の definitions・dependencies・oneOf などを利用します。魚介加工品の要件を JSON Schema として表現したときに、最終的には下記のようになりました。それなりに複雑な JSON にはなりますが、自前で出し分けを自動で制御するロジックやバリデーションをゼロから実装するよりも、遥かに簡単に記述することができました。

{
  required: ["raw", "thawed", "farmed"],
  properties: {
    category_id: { const: "魚介加工品カテゴリのID" },
    thawed: { "$ref" => "#/definitions/thawed" },
    farmed: { "$ref" => "#/definitions/farmed" },
    raw: { "$ref" => "#/definitions/raw" },
  },
  dependencies: {
    thawed: {
      oneOf: [
        {
          properties: {
            thawed: { const: true }, # 解凍品だった場合、消費期限
            quality_guarantee: { "$ref" => "#/definitions/quality_guarantee/definitions/expiration" },
          },
        },
        {
          properties: {
            thawed: { const: false }, # 非解凍品だった場合、保証消費期限
            quality_guarantee: { "$ref" => "#/definitions/quality_guarantee/definitions/guarantee_expiration" },
          },
        },
      ],
    },
  },
}

ライブラリ選定

クックパッドマートの販売者向け管理画面について、フロントエンドは Rails の View の仕組みを用いて HTML が返される仕組みとなっています。また、動的な処理などを追加する際には TypeScript / React を用いている状態です。そのため、 React で JSON Schema に基づいたフォームの出し分けを自動で制御するライブラリとして、 react-jsonschema-form を利用することとしました。
また、バックエンドについては Rails で開発が行われているため、 Ruby 製で JSON Schema に基づくバリデーションをすることができるライブラリが必要でした。そのため、 json_schemer を選定することにしました。

json_schemer

json_schemer はとても簡単に導入することができました。下記のように検証したい JSON を渡してあげることでバリデーションをすることができます。

JSONSchemer.schema(json_schema).valid?(json_to_be_validated)

react-jsonschema-form

react-jsonschema-form が JSON Schema の定義に沿ってフォームの出し分けを自動で制御してくれるため、自分で実装する必要のある箇所は主に見た目に関する部分でした。具体的には uiSchema と、 Widget や FieldTemplate と呼ばれる React Component です。

FieldTemplate や Widget は JSON Schema のそれぞれの入力フォームを描画する際に利用される Component です。JSON Schema および後述する uiSchema で渡される値を Props として受け取り、その情報を元に描画を行います。実装例は下記の通りです。

export const FieldTemplate = (props: FieldTemplateProps) => {
  const { label, required, children, rawDescription, rawHelp } = props;

  return (
    <Card>
      <Card.Header>
        <div>
          {required ? (
              <Badge variant="primary">必須</Badge>
          ) : (
              <Badge variant="secondary">任意</Badge>
          )}
          {label}
        </div>
      </Card.Header>
      <Card.Body>
        {rawDescription && <Card.Text>{rawDescription}</Card.Text>}
        {children} {/* この部分で Widget の描画が行われる */}
        {rawHelp && <small>{rawHelp}</small>}
      </Card.Body>
    </Card>
  );
};
export const RadioWidget = (props) => (
  <div className="field-radio-group">
    {props.options.enumOptions.map((option, i) => {
      return (
        <div key={i}>
          <label>
            <input
              disabled={props.disabled}
              type="radio"
              name={props.options.name}
              value={option.value}
              onChange={() => {
                props.onChange(option.value);
              }}
            />
            <span>{option.label}</span>
          </label>
        </div>
      );
    })}
  </div>
);

uiSchema について、下記は養殖か天然かを入力するフォームの定義例です。その入力フォームを描画するときに使用したい Widget の指定や、説明文の付与などの指定を行うことができます。uiSchema で指定された値は Widget の Props として渡されます。

{
  farmed: {
    'ui:disabled': isDisabled,
    'ui:name': 'item[farmed]',
    'ui:widget': RadioWidget,
    'ui:help': '養殖か天然を必ず選択してください。',
  },
  // ...
}

JSON Schema を導入した結果

これまでは新規登録された商品の内、約10%の割合で品質保証の項目の入力不備がありましたが、JSON Schema を導入したことによって、商品の種類や状態を選ぶだけで品質保証の種類が自動的に選択されるようになったため、品質保証の項目に関する入力不備はゼロになりました。商品登録時の正確性や体験を改善し、商品審査の運用負担を大きく減らすことができました。
Schema に基づいた実装を行っているため、今後新しく要件が増えたとしても JSON Schema の定義を更新するのみで解決し、フロントエンド側のフォームの出し分け制御ロジック・バックエンド側のバリデーションを容易に追加・更新することが可能な状態になりました。

最後に

クックパッドマートでは事業成長のためにスピードを高めて開発に取り組んでおり、様々な技術に触れる機会も多くとても楽しい環境です。弊社では絶賛エンジニア募集中なので、興味を持って頂けた方はぜひ採用情報をご覧ください。

cookpad-mart-careers.studio.site

info.cookpad.com

/* */ @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;*/ /*}*/