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.fullstack.org.cn/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.fullstack.org.cn/draft/2020-12/schema", "$id": "schema:peripheral", "type": "object", "properties": { "name": true }, "required": [ "name" ]}

但是现在,任何具有 name 属性的 JSON 对象都被验证为外设。虽然不太准确,但我们可以接受。这给了我们第一个让步。

对基类建模的模式无法验证实例是否代表该类的派生类。

对派生类的建模非常直接:我们对派生类定义的内容进行建模,并添加一个指向基模式的 $ref

数据
{ "$schema": "https://json-schema.fullstack.org.cn/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.fullstack.org.cn/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。检查额外属性只能对继承树的叶子进行。

此外,我们使用 unevaluatedProperties 而不是 additionalProperties,因为我们需要它能够“看到” $ref 内部,以识别 name 是否被评估为基模式的一部分。使用 additionalPropertiesname 会被拒绝。

这看起来很简单,我们只需要做出一个(而且很容易)的让步。

添加递归引用

如果我们的外设本身可以连接其他外设呢?比如,一个 USB 集线器。

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

让我们尝试在模式中对其进行建模

模式
{ "$schema": "https://json-schema.fullstack.org.cn/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.fullstack.org.cn/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.fullstack.org.cn/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 添加项目,因此只能在开发时支持我已知的设备。在大多数情况下,这不是问题。但是,如果我计划将它发布到一个供其他人使用的包中,它将不支持他们创建的设备。(我确实有一种解决方法,但效果不好,因此不会在这里分享。)这为我们带来了第二个让步。

如果我们需要引用基类,我们只能支持我们提前知道的派生。

意外的收益

为了确定某个 JSON 是 MouseKeyboard 还是 UsbHub,有人可能会持有这三个模式中的所有模式,并依次验证它们以确定接收了哪一个模式。但是,我们对引用问题解决方案的解决方法实际上为我们提供了更好的选择。

我们知道 schema:known-peripherals 可以验证任何已知的外围设备(因为我们设计了它来做到这一点),但是如果我们使用更详细的输出格式,它实际上可以告诉我们获得了哪种外围设备。

首先,我们通过查看其子输出节点以查找 valid: true 来识别哪个 oneOf 子模式通过了验证。我们知道那将是一个 $ref 模式(因为它是一个只包含 $ref 模式的 oneOf),这意味着该 $ref 模式的子输出节点将表示外围设备模式的输出,其中包含外围设备模式的 $id URI。

因此,在一次验证过程中,我们可以知道它是否是一种受支持的任何类型的外围设备,并且我们可以辨别它是什么类型。一石二鸟。

那么,JSON Schema 中是否支持继承?

不。

是的,如果我们认可

对基类建模的模式无法验证实例是否代表该类的派生类。

如果我们需要引用基类,我们只能支持我们提前知道的派生。

我认为这些对大多数人来说是可以接受的,但我相信有人会不可避免地遇到这种方法无法奏效的情况。

这是我迄今为止看到的对继承建模的最佳方法,并且我确信 JSON Schema 无法在没有一些新功能的情况下做到 100% 正确。

如果您对如何支持多态性有任何其他想法,或者您认为多态性被高估了,JSON Schema 不需要支持它,请加入我们 IDL 词汇表存储库中的对话

封面图片来自Gerd AltmannPixabay上的作品。