2023年9月13日(水曜日) 7約 分の読み物

JSON Schema を用いた継承のモデリング

おそらく最も多く寄せられる質問は、「JSON Schema で継承階層をどのようにモデル化しますか?」です。そして、その質問に対する最も一般的な答えは「しません」です。

JSON Schema は、設計上そのようなものではありません。制約が増えるほど一致するものが少なくなる減算的なシステムであり、データモデリングは定義が増えるほど一致するものが多くなる加算的な傾向があります。これらのシステムは本質的に互換性がありません。

しかし、いくつかの妥協を受け入れるならば、何とか解決策を見つけることができるかもしれません。

私たちのモデル

始めとして、いくつかのコンピュータ周辺機器をモデル化しようとします。厳密に型付けされた言語では、すべての周辺機器に共通する多くのプロパティ(そして通常は関数)を定義するPeripheral基本クラスを使用してこれをモデル化する場合があります。その後、各デバイスはこの基本クラスのサブクラスになります。

ここでは、基本クラスのプロパティnameのみを定義します。つまり、すべての周辺機器には名前が必要です。

コードサンプルには TypeScript を使用しますが、これらの概念は他の言語にも適用できます。

1abstract class Peripheral {
2  name: string;
3  // ...
4}

これで、この基本クラスを継承することで、他の周辺機器であるMouseKeyboardを定義できます。

1class Mouse extends Peripheral {
2  buttonCount: number;
3  wheelCount: number;
4  trackingType: "ball" | "optical";
5  // ...
6}
7
8class Keyboard extends Peripheral {
9  keyCount: number;
10  mediaButtons: boolean;
11  // ...
12}

これで始めるには十分です。

制約を用いたモデル表現

JSON Schema では、理想的には、これらそれぞれにスキーマが必要です。周辺機器には、次のようなスキーマを試すことができます。

スキーマ
{ "$schema": "https://json-schema.dokyumento.jp/draft/2020-12/schema", "$id": "schema:peripheral", "type": "object", "properties": { "name": true }, "required": [ "name" ], "additionalProperties": false}

スキーマ識別子にはschema: URI を使用しています。これは、これらのスキーマはどこにもアクセスできないためです。これは、JSON Schema の次のバージョンで検討している推奨事項です。このアプローチについてご意見をお聞かせください。

しかし、additionalPropertiesキーワードは問題を引き起こします。「継承された」スキーマ(Mouseのために構築しようとしているもののような)は追加のプロパティを定義できませんが、これは間違いなく必要です。これは全く機能せず、解決策はそれを単に省略することです。

スキーマ
{ "$schema": "https://json-schema.dokyumento.jp/draft/2020-12/schema", "$id": "schema:peripheral", "type": "object", "properties": { "name": true }, "required": [ "name" ]}

しかし、これでnameプロパティを持つ任意のJSONオブジェクトが周辺機器として検証されます。完全に正しいとは言えませんが、これで我慢できます。これが最初の譲歩になります。

基底クラスをモデル化するスキーマは、インスタンスがそのクラスの派生を表していることを検証できません。

派生をモデル化するのは非常に簡単です。派生が定義するものをモデル化し、基底スキーマに$refを追加します。

データ
{ "$schema": "https://json-schema.dokyumento.jp/draft/2020-12/schema", "$id": "schema:mouse", "$ref": "schema:peripheral", "type": "object", "properties": { "buttonCount": { "type": "integer" }, "wheelCount": { "type": "integer" }, "trackingType": { "enum": [ "ball", "optical" ] } }, "required": [ "buttons", "wheels", "tracking" ], "unevaluatedProperties": false}
{ "$schema": "https://json-schema.dokyumento.jp/draft/2020-12/schema", "$id": "schema:keyboard", "$ref": "schema:peripheral", "properties": { "keys": { "type": "integer" }, "mediaButtons": { "type": "boolean" } }, "required": [ "keys", "mediaButtons" ], "unevaluatedProperties": false}

派生スキーマの場合、これらのスキーマから派生するスキーマがないため、unevaluatedPropertiesを使用できます。継承階層が大きく、これらのクラスが他のクラスの基底として機能する場合は、schema:peripheralで行ったように、unevaluatedPropertiesを省略する必要があります。追加のプロパティのチェックは、継承ツリーの葉に対してのみ実行できます。

さらに、$refの「内部を見る」ことができる必要があるため、additionalPropertiesの代わりにunevaluatedPropertiesを使用します。これにより、nameが基底スキーマの一部として評価されたことを識別できます。additionalPropertiesを使用すると、nameは拒否されます。

これは非常に簡単そうで、単一の(そしてかなり簡単な)譲歩をするだけで済みました。

再帰参照の追加

周辺機器の1つに他の周辺機器が接続されている場合はどうでしょうか?例えば、USBハブなど。

1class UsbHub extends Peripheral {
2  connectedDevices: Peripheral[];
3  // ...
4}

スキーマでそれをモデル化してみましょう。

スキーマ
{ "$schema": "https://json-schema.dokyumento.jp/draft/2020-12/schema", "$id": "schema:usbhub", "$ref": "schema:peripheral", "properties": { "connectedDevices": { "type": "array", "items": { "$ref": "schema:peripheral" } } }, "required": [ "connectedDevices" ], "unevaluatedProperties": false}

これは機能しますが、最初に譲歩したことを覚えていますか?このスキーマでは、文字列nameプロパティを持つ任意のアイテムが許可されます。しかし、これはTypeScriptモデルとは一致しません。TypeScriptモデルでは、connectedDevicesは、Peripheralから派生した型のみを保持できるとされています。

これは一部の人にとっては十分かもしれませんが、私の意見では機能しません。connectedDevices配列のアイテムが既知の周辺機器の種類のみであることを確認したいと考えています。そのためには、別のスキーマが必要です。

既知の派生型のみのサポート

問題:既知のデバイスの種類のいずれかを表すJSONを識別するスキーマが必要です。

解決策:既知のすべてのデバイスの種類のスキーマを参照するoneOfを使用してスキーマを定義します。

スキーマ
{ "$schema": "https://json-schema.dokyumento.jp/draft/2020-12/schema", "$id": "schema:known-peripherals", "oneOf": [ { "$ref": "schema:mouse" }, { "$ref": "schema:keyboard" }, { "$ref": "schema:usbhub" } ]}

このスキーマは非常に基本的なものです。「JSONがこれらのデバイスのいずれかに一致する場合、既知の周辺機器である」ということを示しています。

これをschema:usbhubで参照できるようになりました。

スキーマ
{ "$schema": "https://json-schema.dokyumento.jp/draft/2020-12/schema", "$id": "schema:usbhub", "$ref": "schema:peripheral", "properties": { "connectedDevices": { "type": "array", "items": { "$ref": "schema:known-peripherals" } } }, "required": [ "connectedDevices" ], "unevaluatedProperties": false}

これで、USBハブとそれに接続されたデバイスを適切に検証できるようになりました。

問題は、oneOfにアイテムを動的に追加できないため、開発時に認識しているデバイスのみをサポートできることです。ほとんどの場合、これは問題になりません。ただし、これをパッケージとして公開して他の人が使用できるようにする予定がある場合、彼らが作成したデバイスはサポートされません。(このための回避策はありますが、良い方法ではないため、ここでは共有しません。)これが2つ目の譲歩です。

基底クラスへの参照が必要な場合、事前に認識している派生型のみをサポートできます。

予期せぬ利点

JSONがMouseKeyboard、またはUsbHubのいずれであるかを判断するために、これら3つのスキーマすべてを保持し、それぞれを順番に検証して、どれを受信したかを判断することができます。しかし、参照の問題に対する私たちの解決策は、実際により良い選択肢を与えてくれます。

私たちはschema:known-peripheralsが、(そう設計されているため)既知の周辺機器をすべて検証できることを知っていますが、より冗長な出力形式を使用すると、どのような種類の周辺機器を受け取ったかを実際に教えてくれます。

まず、その子出力ノードでvalid: trueを探して、どのoneOfサブスキーマが検証に合格したかを特定します。それが$refスキーマであることがわかります($refスキーマのみを含むoneOfであるため)、つまり、その$refスキーマの子出力ノードは、周辺機器スキーマの出力(周辺機器スキーマの$id URIを含む)を表します。

したがって、単一の検証パスで、それがどのような種類のサポートされている周辺機器であるか、そしてそれがどのような種類であるかを判断できます。一石二鳥です。

では、JSONスキーマで継承は可能ですか?

いいえ。

そしてはい、私たちが大丈夫であれば

基底クラスをモデル化するスキーマは、インスタンスがそのクラスの派生を表していることを検証できません。

基底クラスへの参照が必要な場合、事前に認識している派生型のみをサポートできます。

これはほとんどの人にとって許容範囲内だと思いますが、このアプローチではうまくいかないシナリオに遭遇する人が必ずいることも確信しています。

これは私がこれまで見た中で最高の継承モデリングであり、JSON Schemaが何らかの新しい機能なしに100%正しく実現することはできないとほぼ確信しています。

ポリモーフィズムのサポート方法に関する他のアイデアがある場合、またはポリモーフィズムが過大評価されており、JSON Schemaがそれをサポートする必要がないと思われる場合は、IDL Vocabularyリポジトリの会話にご参加ください。

表紙画像は、Gerd Altmann氏によるPixabayの作品です。