Save 37% off PRO during our Black Friday Sale! »

JSON Schema で複雑な仕様の入力フォームの実装に立ち向かった話

3f62dc4b2c8447c38d74267b871e7d37?s=47 solt9029
October 22, 2021

JSON Schema で複雑な仕様の入力フォームの実装に立ち向かった話

Ruby on Rails を用いたシステム上で入力フォームを実現する際、Rails が提供しているフォームヘルパーを利用した実装や、React や Vue によるコンポーネントの自前での実装が一般的に行われます。

ここで、職業で学生を選択した場合は学校名と学年、会社員を選択した場合は役職と年収を入力する...といった、条件分岐が大量に生まれる入力フォームを想像しましょう。
一般的な実装手法では、あるフォームの入力値が他のフォームに影響を与えるような、複雑で動的な入力フォームの実現をするために、大量の if 文を書く必要があります。
また、ユーザから送信された入力値の正しさをバリデーションするために、バックエンド側に同様の if 文を大量に書く必要が出てきます。

そこで私は、複雑な仕様の入力フォームの実装のための JSON Schema 活用方法および事例について紹介します。入力フォームの仕様を JSON Schema で定義すれば、大量の if 文を用いることなくフロントエンド側の入力フォームを自動生成することができます。また、その JSON Schema をバックエンド側のバリデーションのロジックにも使い回すことができるため、シンプルでメンテナンス性の高い実装を実現することができます。

3f62dc4b2c8447c38d74267b871e7d37?s=128

solt9029

October 22, 2021
Tweet

Transcript

  1. +40/4DIFNBͰ ෳࡶͳ࢓༷ͷೖྗϑΥʔϜͷ࣮૷ʹ ཱͪ޲͔ͬͨ࿩ ΫοΫύουגࣜձࣾ Ԙग़ ݚ࢙ 1

  2. 2 Ԙग़ ݚ࢙ ৯Ԙग़ݱ !TPMU ΫοΫύουגࣜձࣾ ങ෺ϓϩμΫτ։ൃ෦ ॴଐ ීஈ͸ 3BJMT΍

    3FBDUΛॻ͘ झຯ͸ϓϩτλΠϐϯάʢ࠷ۙ͸͓ֆඳ͖΋޷͖ʣ ҿञ TVEPΛ๷ࢭ͢ΔγεςϜ
  3. 3 +40/4DIFNBΛ࢖࣮ͬͯݱͨ͠΋ͷ

  4. 4 ࢲ͕։ൃʹܞΘ͍ͬͯΔϓϩμΫτ ੜ઱৯඼Λத৺ͱͨ͠&$ϓϥοτϑΥʔϜ

  5. 5 ൢചऀ޲͚؅ཧը໘ • ঎඼৘ใͷొ࿥ • Ӧۀ೔ͷ؅ཧ • ग़ՙ࡞ۀͷ֬ೝ • ঎඼ϨϏϡʔͷฦ৴

  6. 6 ൢചऀ޲͚؅ཧը໘ • ঎඼৘ใͷొ࿥ • Ӧۀ೔ͷ؅ཧ • ग़ՙ࡞ۀͷ֬ೝ • ঎඼ϨϏϡʔͷฦ৴

  7. 7 ঎඼৘ใͷొ࿥ • ͓ૣΊʹ͓ঌ্͕͠Γͩ͘ ͍͞ • ফඅظݶ·Ͱͷ೔਺ • ղౚ඼͔Ͳ͏͔ •

    ੜ৯༻͔Ճ೤༻͔ • ཆ৩͔ఱવ͔ • ফඅظݶ·Ͱͷ೔਺ • ղౚ඼͔Ͳ͏͔ ೖྗ͢΂͖඼࣭อূ΍৯඼දࣔʹؔΘΔ৘ใ͸঎඼ͷछྨ͝ͱʹҟͳΔ
  8. 8 ౰࣌ɺશ߲໨Λཏྻ͢ΔΑ͏ͳϑΥʔϜΛఏڙ͍ͯͨ͠😨 • ೖྗϛε͕සൃ͠ɺ঎඼ొ࿥ʹख͕͔͔ؒΔ • ೑ͳͷʹʮ͓ૣΊʹ͓ঌ্͕͠Γ͍ͩ͘͞ʯΛબ୒ͯ͠͠·͏ • ڕͳͷʹཆ৩͔ఱવ͔ೖྗ͠๨Εͯ͠·͏ ϑϩϯτΤϯυଆͰ঎඼ͷछྨʹ΋ͱͮ͘ϑΥʔϜͷग़͠෼͚ •

    ࣾ಺ͷӡ༻ϝϯόʹΑΔ঎඼৹ࠪͷෛ୲͕૿͑Δ όοΫΤϯυଆͰෆਖ਼ͳσʔλΛআͨ͘ΊͷόϦσʔγϣϯ
  9. 9 • ೖྗϛε͕සൃ͠ɺ঎඼ొ࿥ʹख͕͔͔ؒΔ • ೑ͳͷʹʮ͓ૣΊʹ͓ঌ্͕͠Γ͍ͩ͘͞ʯΛબ୒ͯ͠͠·͏ • ڕͳͷʹཆ৩͔ఱવ͔ೖྗ͠๨Εͯ͠·͏ ϑϩϯτΤϯυଆͰ঎඼ͷछྨʹ΋ͱͮ͘ϑΥʔϜͷग़͠෼͚ • ࣾ಺ͷӡ༻ϝϯόʹΑΔ঎඼৹ࠪͷෛ୲͕૿͑Δ

    όοΫΤϯυଆͰෆਖ਼ͳσʔλΛআͨ͘ΊͷόϦσʔγϣϯ ౰࣌ɺશ߲໨Λཏྻ͢ΔΑ͏ͳϑΥʔϜΛఏڙ͍ͯͨ͠😨
  10. 10 Ͳ͏΍࣮ͬͯ૷͢Δʁ def validate! if category == "魚" if quality_guarantee_type

    != "消費期限" raise Error.new("魚なのに消費期限が…") end if farmed == nil raise Error.new("魚なのに養殖かどうか…") end # if ... elsif category == "肉" # if ... elsif category == "根もの" # if ... end end • ࠓޙɺ঎඼ͷछྨΛ௥Ճͨ͘͠ͳͬͨͱ͖ ʹϝϯςφϯε͕େม • ϑϩϯτΤϯυͱόοΫΤϯυͰಉ༷ͷ JGจΛେྔʹॻ͘ඞཁ͕͋Δ • ͳΜ͔Πέͯͳͦ͞͏ʂ 👎 ՝୊ JG จΛେྔʹ࢖ͬͯΈΔ
  11. 11 Ͳ͏΍࣮ͬͯ૷͢Δʁ +40/4DIFNBΛར༻͢Δ { required: ["raw", "thawed", "farmed"], properties: {

    category_id: { const: "魚のID" }, thawed: { "$ref" => "#/definitions/thawed" }, farmed: { "$ref" => "#/definitions/farmed" }, raw: { "$ref" => "#/definitions/raw" }, }, dependencies: { thawed: { oneOf: [ # 中略 ], }, }, } • ࠓޙɺ঎඼ͷछྨΛ௥Ճͨ͘͠ͳͬͨͱ͖ ͷϝϯςφϯεੑ͕ߴ͍ • ϑϩϯτΤϯυͱόοΫΤϯυͷϩδοΫ ͕ڞ௨ͷεΩʔϚʹΑΓ׬੒͢Δ 👍 ϝϦοτ
  12. 12 +40/4DIFNBͱ͸ { id: 100, name: "カルボナーラの作り方", content: "ベーコンと玉ねぎを食べやすい大き さに切ります。〜(以下略)",

    public: true } +40/4DIFNBͱ͸ +40/ͷߏ଄Λఆٛͨ͠΋ͷ ྉཧϨγϐͷ +40/ͷߏ଄Λఆٛͯ͠ΈΑ͏ +40/ͷྫ
  13. 13 +40/4DIFNBͱ͸ { id: 100, name: "カルボナーラの作り方", content: "ベーコンと玉ねぎを食べやすい大き さに切ります。〜(以下略)",

    public: true } +40/4DIFNBͱ͸ +40/ͷߏ଄Λఆٛͨ͠΋ͷ ྉཧϨγϐͷ +40/ͷߏ଄Λఆٛͯ͠ΈΑ͏ { title: "お料理レシピ", type: "object", properties: { id: { title: "ID", type: "integer" }, name: { title: "名前", type: "string" }, content: { title: "作り方", type: "string" }, public: { title: "公開中", type: "boolean" } }, required: ["id", "name", "content", "public"] } +40/ͷྫ +40/4DIFNBͷྫ
  14. 14 +40/4DIFNBͱ͸ { id: 100, name: "カルボナーラの作り方", content: "ベーコンと玉ねぎを食べやすい大き さに切ります。〜(以下略)",

    public: true } +40/4DIFNBͱ͸ +40/ͷߏ଄Λఆٛͨ͠΋ͷ ྉཧϨγϐͷ +40/ͷߏ଄Λఆٛͯ͠ΈΑ͏ { title: "お料理レシピ", type: "object", properties: { id: { title: "ID", type: "integer" }, name: { title: "名前", type: "string" }, content: { title: "作り方", type: "string" }, public: { title: "公開中", type: "boolean" } }, required: ["id", "name", "content", "public"] } +40/ͷྫ +40/4DIFNBͷྫ ܕͳͲ੍໿͕ఆٛͰ͖Δ
  15. 15 ඼࣭อূͷೖྗϑΥʔϜΛ +40/4DIFNBͰॻ͍ͯΈΔ { title: "商品", type: "object", required: ["category_id"],

    properties: { category_id: { title: "カテゴリ", type: "number", enum: [1, 2, 3], enumNames: ["根もの", "鶏肉", "魚介加工品"] } }, dependencies: { category_id: { oneOf: [ { properties: { category_id: { const: 1 }, /** 根もの独自の入力項目 **/ }}, { properties: { category_id: { const: 2 }, /** 鶏肉独自の入力項目 **/ }}, { properties: { category_id: { const: 3 }, /** 魚介加工品独自の入力項目 **/ }}, ] }, }, }
  16. 16 { title: "商品", type: "object", required: ["category_id"], properties: {

    category_id: { title: "カテゴリ", type: "number", enum: [1, 2, 3], enumNames: ["根もの", "鶏肉", "魚介加工品"] } }, dependencies: { category_id: { oneOf: [ { properties: { category_id: { const: 1 }, /** 根もの独自の入力項目 **/ }}, { properties: { category_id: { const: 2 }, /** 鶏肉独自の入力項目 **/ }}, { properties: { category_id: { const: 3 }, /** 魚介加工品独自の入力項目 **/ }}, ] }, }, } EFQFOEFODJFTΧςΰϦ*%͕ઃఆ͞Ε͍ͯΔͱ͖ʜ ඼࣭อূͷೖྗϑΥʔϜΛ +40/4DIFNBͰॻ͍ͯΈΔ
  17. 17 { title: "商品", type: "object", required: ["category_id"], properties: {

    category_id: { title: "カテゴリ", type: "number", enum: [1, 2, 3], enumNames: ["根もの", "鶏肉", "魚介加工品"] } }, dependencies: { category_id: { oneOf: [ { properties: { category_id: { const: 1 }, /** 根もの独自の入力項目 **/ }}, { properties: { category_id: { const: 2 }, /** 鶏肉独自の入力項目 **/ }}, { properties: { category_id: { const: 3 }, /** 魚介加工品独自の入力項目 **/ }}, ] }, }, } EFQFOEFODJFTΧςΰϦ*%͕ઃఆ͞Ε͍ͯΔͱ͖ʜ POF0GͲΕ͔ʹҰக͢Δඞཁ͕͋Δ ඼࣭อূͷೖྗϑΥʔϜΛ +40/4DIFNBͰॻ͍ͯΈΔ
  18. 18 +40/4DIFNBʹ͍ͭͯΑΓৄ͘͠஌Γ͍ͨํ΁ +40/4DIFNBͰ͸͍͔ͭ͘όʔδϣϯ͕͋Γɺ ৽͍͠ %SBGUͰ͸ *G5IFO&MTFͱ͍͏ॻ͖ํ΋αϙʔτ͞Ε͍ͯ·͢ ͦͷଞʹ΋ NVMUJQMF0G΍ QBUUFSO ͳͲܕ΍੍໿ͷදݱํ๏͕͋ͬͨΓ

    EFGJOJUJPOTͱ͍ͬͨศརͳߏจ΋ଘࡏ͠·͢ ৄࡉ͸ 6OEFSTUBOEJOH+40/4DIFNB ͕ࢀߟʹͳΔͱࢥ͍·͢
  19. 19 +40/4DIFNBपΓͷϥΠϒϥϦબఆ ΫοΫύουϚʔτͷൢചऀ޲͚؅ཧը໘ͷٕज़ελοΫ • ϑϩϯτΤϯυɿ 3BJMTͷ 7JFXͱ 3FBDU5ZQF4DSJQU • όοΫΤϯυɿ

    3VCZPO3BJMT
  20. 20 +40/4DIFNBपΓͷϥΠϒϥϦબఆ ΫοΫύουϚʔτͷൢചऀ޲͚؅ཧը໘ͷٕज़ελοΫ • ϑϩϯτΤϯυɿ 3BJMTͷ 7JFXͱ 3FBDU5ZQF4DSJQU • όοΫΤϯυɿ

    3VCZPO3BJMT બఆͨ͠ϥΠϒϥϦ • ϑϩϯτΤϯυɿ SKTGUFBNSFBDUKTPOTDIFNBGPSN • όοΫΤϯυɿ EBWJTINDDMVSHKTPO@TDIFNFS
  21. 21 KTPO@TDIFNFS JSONSchemer.schema(json_schema).valid?(json_to_be_validated) +40/4DIFNBΛར༻ͯ͠όϦσʔγϣϯΛ͢ΔͨΊͷ 3VCZ੡ϥΠϒϥϦ ͨͬͨ͜Ε͚ͩͰ +40/4DIFNBʹ΋ͱ͍ͮͨόϦσʔγϣϯ͕Ͱ͖Δʂ

  22. 22 SFBDUKTPOTDIFNBGPSN import Form from 'react-jsonschema-form'; export function App() {

    return <Form schema={jsonSchema} /> } +40/4DIFNBΛར༻ͯ͠ೖྗϑΥʔϜΛࣗಈੜ੒͢Δ 3FBDUϥΠϒϥϦ +40/4DIFNBΛ 1SPQTͱͯ͠౉͚ͩ͢ͰɺೖྗϑΥʔϜ͕׬੒͢Δ POF0GͰઃఆͨ͠ೖྗϑΥʔϜͷग़͠෼͚΋ಈతʹ΍ͬͯ͘ΕΔʂ
  23. 23 σβΠϯ͸ࣗ෼Ͱ࣮૷͢Δ • SFBDUKTPOTDIFNBGPSN͸ .BUFSJBM6*΍ #PPUTUSBQͳͲΛඪ४Ͱαϙʔτ͍ͯ͠Δ • ཁૉͷΧελϚΠζʹ͸ 8JEHFU΍ 'JFMEͳͲͷ࢓૊ΈΛར༻͢Δ

  24. 24 σβΠϯ͸ࣗ෼Ͱ࣮૷͢Δ • SFBDUKTPOTDIFNBGPSN͸ .BUFSJBM6*΍ #PPUTUSBQͳͲΛඪ४Ͱαϙʔτ͍ͯ͠Δ • ཁૉͷΧελϚΠζʹ͸ 8JEHFU΍ 'JFMEͳͲͷ࢓૊ΈΛར༻͢Δ

    8JEHFUJOQVUཁૉ΍ TFMFDUཁૉࣗମ
  25. 25 σβΠϯ͸ࣗ෼Ͱ࣮૷͢Δ • SFBDUKTPOTDIFNBGPSN͸ .BUFSJBM6*΍ #PPUTUSBQͳͲΛඪ४Ͱαϙʔτ͍ͯ͠Δ • ཁૉͷΧελϚΠζʹ͸ 8JEHFU΍ 'JFMEͳͲͷ࢓૊ΈΛར༻͢Δ

    8JEHFUJOQVUཁૉ΍ TFMFDUཁૉࣗମ 'JFME8JEHFUΛϥοϓ͢Δ $PNQPOFOU λΠτϧɾઆ໌ɾ஫هͳͲΛؚΊͨ಺༰
  26. 26 8JEHFUͱ 'JFMEͷαϯϓϧίʔυ 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> ); export const FieldTemplate = (props) => { return ( <Card> <Card.Header> <div> {props.required ? ( <Badge variant="primary">必須</Badge> ) : ( <Badge variant="secondary">任意</Badge> )} {props.label} </div> </Card.Header> <Card.Body> {props.rawDescription && <Card.Text>{props.rawDescription}</Card.Text>} {props.children} {/* この部分で Widget の描画が⾏ われる */} {props.rawHelp && <small>{props.rawHelp}</small>} </Card.Body> </Card> ); };
  27. 27 VJ4DIFNB export function App() { const uiSchema = {

    farmed: { 'ui:disabled': false, 'ui:name': 'item[farmed]’, 'ui:widget': RadioWidget, 'ui:help': '養殖か天然を必ず選択してください。’, }, // ... }; return ( <Form schema={jsonSchema} uiSchema={uiSchema} fieldTemplate={FieldTemplate} /> ); } +40/ 4DIFNB͸σʔλߏ଄ɾ੍໿ͷఆٛΛߦͳ͍ͬͯΔͨΊɺ ͦͷσʔλΛͲ͏͍͏෩ʹදࣔ͢Δ͔ʁͷఆٛͷ੹຿Λ VJ4DIFNB ͕୲͍ͬͯΔ
  28. 28 +40/4DIFNBͰೖྗϑΥʔϜͷग़͠෼͚Λ࣮૷ͨ݁͠Ռ ඼࣭อূʹؔ͢Δೖྗෆඋ཰   ঎඼ͷछྨΛબͿ͚ͩͰ඼࣭อূͷछྨ͕ࣗಈબ୒͞ΕΔ Α͏ʹͳͬͨͨΊɺೖྗෆඋ͸΄΅θϩʹͳΓ·ͨ͠ ։ൃϝϯςφϯεͷ༰қੑ ঎඼ͷछྨͷ௥Ճ΍ཁ͕݅૿͑ͨͱͯ͠΋ɺ+40/4DIFNBͷఆٛΛߋ৽͢Δ͚ͩͰղܾ͠ɺ ೖྗϑΥʔϜͷग़͠෼੍͚ޚͱόοΫΤϯυͷόϦσʔγϣϯΛ༰қʹ௥Ճɾߋ৽Մೳͳঢ়ଶ

  29. 29 Ԙग़ ݚ࢙ ৯Ԙग़ݱ !TPMU