こんには、@g0eです。
今日はPLAID AdventCalendar 2019 15日目としてJSON Schemaの紹介をしたいと思います。
(ちなみに業務ではまだ使ったことないです)
JSON Schemaとは?
json-schema.orgによると以下のようになっていて、annotateの訳が悩ましいですが、jsonドキュメントのフォーマット定義の規格みたいなものだと自分は理解しています。
JSON Schema is a vocabulary that allows you to annotate and validate JSON documents.
何が便利なの?
- 色々な言語で実装されているので、
- フロントエンドとサーバサイドなど、言語をまたいで同じ処理を実装できる1
- 複数言語で実装があるので、一度身につけると開発環境に依らず活用できる機会がある
- 色々な用途がある
- データのvalidation
- API仕様の標準化
- スキーマの自動生成(コードから、データから)
- スキーマからの自動生成(入力フォームとか、テストデータとか)
- その他色々
記事を書くために改めて調べたのですが、本当に用途広いですね…。
以下の例ではvalidation用途を念頭におきつつ、最後に入力フォームの自動生成の例も書いています。
注意が必要なところ
- 実装によって対応状況が異なる
- draftのどこまで対応しているとか、この表現は非対応とかライブラリによって異なるので最初に調査しましょう
- 書き方を気をつけないと可読性が落ちる
- フィールドの内容によって条件分岐的な書き方も出来るのですが、注意して書かないと結構読むの辛くなります
- 定義をまとめて記載しておいて、それを別の箇所から参照するような書き方もできるので、可読性や再利用性を担保する場合は意識した方が良さそうです
書いてみる
実際に書いてみた方が早いので、ここではゲームのキャラクタデータを想定して、少しずつJSON Schemaを書いてみようと思います。
簡単なプロパティ定義
まず最初に、文字列のname
プロパティと数値型のlevel
プロパティを持つobject
型のJSON定義は以下のような感じで書けます。
{
"type": "object", // オブジェクト型
"properties": {
"name": {
"type": "string" // 文字列型
},
"level": {
"type": "number" // 数値型
}
}
}
// OK
{"name": "taro", "level": 1}
{"name": "taro"}
{"name": "taro", "hoge": 12345}
// NG
{"name": 123, "level": "lv1"}
必須・不要プロパティの定義を追加
上記の例だと、プロパティがない場合や、不要なプロパティがある場合もOKになっちゃうので、additionalProperties
とrequired
を追加してみます。
{
"type": "object",
"required": ["name", "level"], // 必須プロパティとして定義
"additionalProperties": false, // 未定義プロパティは許可しない
"properties": {
"name": {
"type": "string"
},
"level": {
"type": "number"
}
}
}
// OK
{"name": "taro", "level": 1}
// NG
{"name": 123, "level": "lv1"}
{"name": "taro"}
{"name": "taro", "hoge": 12345}
プロパティ値の範囲を追加
次にプロパティの値にもう少し制限を追加してみましょう
{
"type": "object",
"required": ["name", "level"],
"additionalProperties": false,
"properties": {
"name": {
"type": "string",
"pattern": "[a-zA-Z0-9]+", // 英数字のみ許可(正規表現)
"minLength": 3, // 最小文字長
"maxLength": 16 // 最大文字長
},
"level": {
"type": "number",
"minimum": 1, // 最小値
"maximum": 100 // 最大値
}
}
}
// OK
{"name": "taro", "level": 1}
// NG
{"name": "太郎", "level": 1}
{"name": "taro", "level": 999}
配列やネスト構造を追加
配列やObject型を更にネストさせることもできます。
今度はskills
プロパティを追加してみます。
{
"type": "object",
"required": ["name", "level"],
"additionalProperties": false,
"properties": {
"name": {
"type": "string",
"pattern": "[a-zA-Z-]+",
"minLength": 3,
"maxLength": 16
},
"level": {
"type": "number",
"minimum": 1,
"maximum": 100
},
"skills": {
"type": "array", // 配列型
"minItems": 0, // 配列長の最小値
"maxItems": 3, // 配列長の最大値
"items": {
"type": "object", // 配列の要素としてObject型を指定
"required": ["skill_name", "skill_level"],
"additionalProperties": false,
"properties": {
"skill_name": {
"type": "string"
},
"skill_level": {
"type": "number"
}
}
}
}
}
}
// OK
{
"name": "taro",
"level": 1,
"skills": []
}
{
"name": "taro",
"level": 1,
"skills": [
{"skill_name": "critical-attack", "skill_level": 3},
{"skill_name": "power-charge", "skill_level": 1}
]
}
// NG
{
"name": "taro",
"level": 1,
"skills": [
{"skill_name": "critical-attack", "skill_level": "n/a"},
{"skill_name": "power-charge"}
]
}
条件分岐を追加
あまり複雑な条件の指定は難しいですが、簡単な条件分岐っぽいものは書けます。
今度はjob
を追加して、装備できるweapon
の条件を分岐してみます。
(長いので先ほど追加したskills
)は省略します。
{
"type": "object",
"required": ["name", "level", "job"],
// "additionalProperties": true, // weapon を条件付きで持つのでコメントアウト(厳密にはやや悩ましい…)
"properties": {
"name": {
"type": "string",
"pattern": "[a-zA-Z-]+",
"minLength": 3,
"maxLength": 16
},
"level": {
"type": "number",
"minimum": 1,
"maximum": 100
},
"job": {
"type": "string",
"enum": ["novice", "soldier", "wizard"] // どれかに一致する必要がある
}
},
"dependencies": {
"job": {
"oneOf": [ // この中の条件のどれかに一致するという条件
{
"properties": {
"job": {
"enum": ["novice"] // jobで "novice" を選択した場合
}
"weapon": { // weapon は持てない
"type": "null"
},
}
},
{
"properties": {
"job": {
"enum": ["soldier"] // jobで "soldier" を選択した場合
},
"weapon": { // weapon として "short-sword" か "sword" のどちらかを持てる
"type": "string",
"enum": ["short-sword", "sword"]
}
}
},
{
"properties": {
"job": {
"enum": ["wizard"]
},
"weapon": { // weapon として "magic-wand" か "bow" のどちらかを持てる
"type": "string",
"enum": ["magic-wand", "bow"]
}
}
}
]
}
}
}
// OK
{ "name": "taro", "level": 1, "job": "novice" } // noviceはweaponを持てない
{ "name": "taro", "level": 1, "job": null } // 同上
{ "name": "taro", "level": 1, "job": "soldier", "weapon": "sword" } // soldierならswordを持てる
{ "name": "taro", "level": 1, "job": "wizard" } // requiredにはしていないのでweaponなしでもOK
// NG
{ "name": "taro", "level": 1, "job": "novice", "weapon": "sword" } // noviceはweaponを持てない
{ "name": "taro", "level": 1, "job": "wizard", "weapon": "sword" } // wizardもweaponを持てない
定義の参照を使って整理
先ほど追加したskills
を、weapon
と同様にjob
で分岐させてみます。
ただし、定義を二箇所に書いたり、似たような定義がバラバラになるのは可読性が下がるので
definitions
に定義してそれを参照する形にしてみます。
{
"type": "object",
"required": ["name", "level", "job"],
"properties": {
"name": {
"type": "string",
"pattern": "[a-zA-Z-]+",
"minLength": 3,
"maxLength": 16
},
"level": {
"type": "number",
"minimum": 1,
"maximum": 100
},
"job": {
"type": "string",
"enum": ["novice", "soldier", "wizard"]
}
},
"dependencies": {
"job": {
"oneOf": [
{
"properties": {
"job": {
"enum": ["novice"]
},
"weapon": {
"type": "null"
},
"skills": {
"type": "null"
}
}
},
{
"properties": {
"job": {
"enum": ["soldier"]
},
"weapon": {
"$ref": "#/definitions/soldier_weapon" // 下記定義を参照
},
"skills": {
"$ref": "#/definitions/skills" // 同上
}
}
},
{
"properties": {
"job": {
"enum": ["wizard"]
},
"weapon": {
"$ref": "#/definitions/wizard_weapon" // 下記定義を参照
},
"skills": {
"$ref": "#/definitions/skills" // 同上
}
}
}
]
}
},
"definitions": { // ここの定義を参照して使う
"soldier_weapon": {
"type": "string",
"enum": ["short-sword", "sword"]
},
"wizard_weapon": {
"type": "string",
"enum": ["magic-wand", "bow"]
},
"skills": {
"type": "array",
"minItems": 0,
"maxItems": 3,
"items": {
"type": "object",
"required": ["skill_name", "skill_level"],
"additionalProperties": false,
"properties": {
"skill_name": {
"type": "string"
},
"skill_level": {
"type": "number"
}
}
}
}
}
}
// OK
{ "name": "taro", "level": 1, "job": "novice" }
{ "name": "taro", "level": 1, "job": "novice", "weapon": null, "skills": null }
{ "name": "taro", "level": 1, "job": "soldier", "weapon": "sword", "skills": [] }
{ "name": "taro", "level": 1, "job": "soldier", "weapon": "sword", "skills": [{ "skill_name": "critical-attack", "skill_level": 1 }] }
// NG
{ "name": "taro", "level": 1, "job": "novice", "skills": [] }
フォームにしてみる
細かい部分は加筆・修正していますが、これまでで作ったJSON Schemaを元にreact-jsonschema-formを使ってフォームを作ること、こんな感じになります。
Jobの部分を切り替えると、そこに依存するWeaponやSkillsの選択肢も動的に変化しているので、ポチポチしてみてください。
フォームの自動生成とまでなると未対応だったり、対応が難しい表現もあったりしますが(上記のライブラリだとallOf
とかanyOf
とか)、
条件分岐付の動的なフォーム生成がJSON一つでここまで出来るのであれば、プロトタイピング用途など検討の価値があるのではないでしょうか?
さいごに
今回記事を書くにあたって改めてJSON Schemaについて調べたのですが、利用用途(エコシステム)の広さと、
着実に進歩を遂げている点(前に見た時DRAFT6とかだったような…)に驚かされました。
十分な魅力をお伝えできたかわからないですが、使える機会があったら是非使ってみてください。
-
プレイドだとサーバサイドもNode.jsなので、場合によっては共通コードで動かすことも可能ですが ↩