2022 年 7 月 23 日,星期六 ·11分钟阅读

修复 JSON Schema 输出

我有一个问题:当我阅读 GitHub 问题时,它们偶尔会引起我的共鸣,我会痴迷于它们,直到它们被解决。对有些人来说这可能听起来不像个问题,但是当这个解决方案导致 JSON Schema 实现开发者在三年内提出基本的设计问题时……是的,这是一个问题。

这正是 2019-09 草案发布后发生的事情。对于规范的这个版本,我们发布了第一个官方输出格式。它实际上是多种格式,应该满足多种需求。

  • 虽然大多数人想知道错误是什么,但有些人只想要一个通过/失败的结果,所以我们创建了 flag 格式。
  • 在那些想要更多关于实际失败内容细节的人中,有些人更喜欢一个扁平列表,而另一些人认为与模式匹配的层次结构会更好。因此,我们为列表人员创建了 basic
  • 最后,在那些想要层次结构的人中,有些人想要一个压缩版本(它变成了 detailed),而另一些人想要完全实现的层次结构 (verbose)。

旁白有些人想要一个模仿实例数据的层次结构,但我们无法弄清楚如何以一种现实的方式做到这一点,所以我们只是把它掩盖起来,然后继续前进。

向规范添加要求

当时,我还没有对规范做出任何重大贡献,但我一直积极参与做出方向性决策,所以我想尝试一下作者身份。这并不是说我没有对规范贡献任何文字;只是没有像整个章节那样重要。

所以,我花了几周时间根据来自数周(数月?)讨论的冗长 GitHub 问题编写了新的输出要求。

天啊,我认为我拥有了一切!我定义了属性、整体结构、验证示例,并且我用我们都喜欢的那个美妙的规范风格写下了所有内容。

我甚至在规范发布之前就在我的库 Manatee.Json 中实现了它,只是为了确保它能正常工作。

但我错过了一点:注释。我的意思是,我考虑过它们,并为它们提供了要求。但我没有提供一个从生成注释的通过实例中获得结果的示例。我想,从技术上来说,我是有的,但它被埋藏了,嵌套在 verbose 示例的内部,而这个示例碰巧太大了,所以我决定把它放在自己的文件中,与规范文档分开。(是的,就像有人会读那个一样。)

接下来几年的亮点将是,我会收到来自其他实现者的众多问题,他们对输出感到困惑,主要围绕注释的表示方式。我对此类问题的普遍回答也不好:“它们就像错误一样。”我认为这是一项微不足道的练习。

幸运的是,我们将整个输出列为“SHOULD”要求,因此实现不需要这样做。这样做的目的是,我们处于定义它的早期阶段,我们不想给实现带来太多负担,因为我们知道我们可能会在未来的版本中进行调整。

尝尝自己的药

直到我决定弃用 Manatee.Json 来构建 JsonSchema.Net,我才意识到为什么每个人都在问问题。必须重新实现输出才让我大开眼界。

哇。我遗漏了很多东西!

知道我最初的意图对我帮助很大,但我无法想象在没有编写它的时候,尝试实现我所写的内容会是什么样子。

所以,我开始记笔记。

更新时间

2020-12 草案已经发布了一年多,我决定需要对输出做点什么。我制造了这个烂摊子,我觉得我有责任把它清理干净。(现在它实际上是我的工作了!😁)我整理了所有笔记,并发布了一个 关于我认为可以对格式进行的改进的大量开场讨论评论

每个人都同意的第一件事是隔离一些输出单元属性的用途并重命名它们。这些属性具有不同的用途,但是 命名事物很难,所以当然,这些属性的名称可以更好。经过一番来回、提出的替代方案和改进,这获得了快速且简单的 PR,该 PR 已经合并,所以这是完成的一件事。

  • keywordLocation ➡️ evaluationPath
  • absoluteSchemaLocation(主要可选)➡️ schemaLocation(必需)
  • errors/annotations ➡️ details

您可以阅读讨论以了解其他建议的更改,但我想要重点关注其中一个。在讨论的某个时刻,我顿悟了。

为什么输出被设计为从各个关键字捕获错误和注释,而不是从子模式捕获错误和注释,而实际上是子模式最终收集错误和注释并提供最终结果?

现状

要了解我的意思,让我们看一下现有的输出。我们将从一个简单的示例开始,为了简洁起见,我们只介绍 basic 或列表形式。

模式
{ "$schema": "https://json-schema.fullstack.org.cn/draft/2020-12/schema", "$id": "example-schema", "type": "object", "title": "foo object schema", "properties": { "foo": { "title": "foo's title", "description": "foo's description", "type": "string", "pattern": "^foo ", "minLength": 10, } }, "required": [ "foo" ], "additionalProperties": false}
// instance (passing){ "foo": "foo isn't a real word"}

如您所见,此模式定义了一个 JSON 值必须是一个具有单个字符串值属性的对象,foo,并且实例符合这些要求。此外,模式还定义了几个注解。

2019-09 / 2020-12 规范将要求此评估的以下输出

data
{ "valid": true, "keywordLocation": "", "instanceLocation": "", "annotations": [ { "valid": true, "keywordLocation": "/title", "instanceLocation": "", "annotation": "foo object schema" }, { "valid": true, "keywordLocation": "/properties", "instanceLocation": "", "annotation": [ "foo" ] }, { "valid": true, "keywordLocation": "/properties/foo/title", "instanceLocation": "/foo", "annotation": "foo's title" }, { "valid": true, "keywordLocation": "/properties/foo/description", "instanceLocation": "/foo", "annotation": "foo's description" } ]}

那有什么问题呢?

  • 注解被渲染为完整的节点。这会导致很多不必要的或重复信息。这在层次结构格式中更加明显,在层次结构格式中,所有内容都按位置分组,使得重复的位置属性变得冗余。
  • 所有节点都带有 valid 属性,这使得很难区分注解的结果和验证的结果。
  • 顶层节点有一个复数的 annotations 属性,它包含一个节点数组,而内部节点则分别有一个单数的 annotation 属性,它包含注解值。这只是令人困惑。

这只是一个简单的例子。您可以看到,随着模式的大小和复杂性的增加,这种情况会变得更加复杂。

一定有更好的方法

有:根据子模式而不是关键字来报告输出。

在上面的例子中,这意味着我们将得到两个节点:一个用于根模式,另一个用于 foo 属性子模式。(还要注意上面提到的属性名称的更改。)

data
{ "valid": true, "evaluationPath": "", "instanceLocation": "", "details": [ { "valid": true, "evaluationPath": "/properties/foo", "instanceLocation": "/foo" } ]}

确实看起来简单多了。但是注释呢?好吧,我们可以将它们分组在一个新的属性中。而且,由于我们知道任何关键字只会产生单个注释值,我们可以使用对象通过使用关键字作为属性名来报告这些注释。

data
{ "valid": true, "evaluationPath": "", "instanceLocation": "", "annotations": { "title": "foo object schema", "properties": [ "foo" ] }, "details": [ { "valid": true, "evaluationPath": "/properties/foo", "instanceLocation": "/foo", "annotations": { "title": "foo's title", "description": "foo's description" } } ]}

或者,对于 basic 格式,它应该是一个列表,根模式的结果可以像下面这样移动到根输出节点中。无论如何,这是提议的想法。在 PR 上发表评论,告诉我们你更喜欢哪种方式。我会在帖子的剩余部分使用这种格式,因为它目前是提议的格式。

data
{ "valid": true, "details": [ { "valid": true, "evaluationPath": "", "instanceLocation": "", "annotations": { "title": "foo object schema", "properties": [ "foo" ] } }, { "valid": true, "evaluationPath": "/properties/foo", "instanceLocation": "/foo", "annotations": { "title": "foo's title", "description": "foo's description" } } ]}

最后一点是,子模式的绝对 URI 现在是必需的,所以让我们添加它。

注意 所有这些示例(旧的和新的)都是由我的实现生成的,它使用 https://json-everything/base 作为默认基本 URI。我已经在我的实验分支上实现了这个新的输出,您可以查看这些更改对我的库套件的影响 这里

data
{ "valid": true, "details": [ { "valid": true, "evaluationPath": "", "schemaLocation": "https://json-everything/example-schema#", "instanceLocation": "", "annotations": { "title": "foo object schema", "properties": [ "foo" ] } }, { "valid": true, "evaluationPath": "/properties/foo", "schemaLocation": "https://json-everything/example-schema#/properties/foo", "instanceLocation": "/foo", "annotations": { "title": "foo's title", "description": "foo's description" } } ]}

就是这样!我们之前的所有信息都以一个更加简洁的包形式存在。此外,所有相关的注释都分组在一起,这提高了可读性。

这如何影响错误

我想从注释开始,因为这是我在之前的迭代中所缺少的。现在,让我们看看如何报告几个失败的实例。有一个有趣的细微差别,并不立即显而易见,我不得不进行一些仔细检查以确保它是正确的。

我们的第一个失败实例

data
{ "baz": 42}

这将失败,因为

  • foo 是必需的,但缺失
  • baz 不允许

当前的错误输出与当前注释输出有相同的问题

data
{ "valid": false, "keywordLocation": "#", "instanceLocation": "#", "errors": [ { "valid": false, "keywordLocation": "#/required", "instanceLocation": "#", "error": "Required properties [\"foo\"] were not present" }, { "valid": false, "keywordLocation": "#/additionalProperties", "instanceLocation": "#/baz", "error": "All values fail against the false schema" } ]}

注意,即使所有错误实际上都是由根模式引起的,它们也是从子位置报告的。这似乎不对。

让我们看看新的输出

data
{ "valid": false, "details": [ { "valid": false, "evaluationPath": "", "schemaLocation": "https://json-everything/example-schema#", "instanceLocation": "", "errors": { "required": "Required properties [\"foo\"] were not present" } }, { "valid": false, "evaluationPath": "/additionalProperties", "schemaLocation": "https://json-everything/example-schema#/additionalProperties", "instanceLocation": "/baz", "errors": { "": "All values fail against the false schema" } } ]}

再次,我们看到错误作为单个 errors 属性存在,它在子模式级别报告。

此外,我提到的细微差别出现了: falseadditionalProperties 下被报告为一个单独的子模式(因为它在技术上 *是* 一个子模式),并且错误被报告为一个空字符串关键字。但是,查看评估路径,我们仍然发现我们是在关键字级别报告。这是细微之处:我们实际上是在子模式级别报告;只是子模式碰巧位于关键字处。让我们看看另一个失败的实例以更好地了解这一点。

data
{ "foo": "baz"}
data
{ "valid": false, "details": [ { "valid": false, "evaluationPath": "/properties/foo", "schemaLocation": "https://json-everything/example-schema#/properties/foo", "instanceLocation": "/foo", "errors": { "pattern": "The string value was not a match for the indicated regular expression", "minLength": "Value is not longer than or equal to 10 characters" } } ]}

这里,您可以看到评估路径表明我们位于 /properties/foo 所在的子模式。将其与前面的示例进行比较,在前面的示例中,我们在 false 位置评估子模式 /additionalProperties,您可以看到它们之间的相似之处。

总结

这就是我想更新输出的方式以及背后的原因。如果您对此有任何想法,请在 讨论 中或 PR 上的评论中告知我们。

再次,如果您想了解在我的实现中进行此更改的影响,请查看 此 PR。所有这些更改都是由更新输出驱动的,但我认为其中大部分与我的架构有关,即使没有实现新的输出,也可以对库进行一些更改。但是,简而言之,净减少了 343 行代码!

封面照片由 Daria NepriakhinaUnsplash 上提供 😁