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