我为 HOCON 写的合规测试套件在参考实现中发现 bug
My conformance suite for HOCON found bugs in its reference implementation
开发者用 Zig 手写 HOCON 解析器 zig-hocon,并构建了含 445 个用例的合规测试套件,将规范逐句转为配置与期望值。lightbend/config 1.4.9 在规范模式下通过 90%、Java 模式下 99%,pyhocon 0.3.63 分别为 70% 和 66%,zig-hocon 为 69% 和 70%。
我在生产环境中使用 HOCON 已有多年。我喜欢这种格式,因为它可读性强,并且允许配置文件组合,因此可以实现简单的多环境配置、共享配置文件覆盖,从而最大限度地减少配置差异。作为一名 Python 开发者,我进行配置管理的终极组合是 Pydantic + pyhocon。
我喜欢尝试新事物,所以今年六月我的目光落在了 Zig 上。我想回归编程的根源,看看现代的“C”是什么感觉。读完语言参考后,我需要一个项目,而我知道 HOCON 的 Java 和 Python 实现并不完全一致,这给了我一个想法:“Zig 有原生 C 接口,那为什么不用 Zig 写 HOCON 并通过 C 垫片暴露出来呢?这样每种语言都可以共享同一个实现。太神奇了!”
我选了一个多么简单的项目,对吧?
编写分词器、抽象语法树、配置和解析是最美妙的部分。但就在第一个实现工作之后,问题来了:我真的兼容吗?幸运的是,有一份书面规范和整个互联网的示例。好吧,事实证明规范和参考实现并不总是一致。下面是一个包含错误的基本配置:
hosts = [db1, db2] db3
规范说,在拼接中,数组紧挨着字符串是无效的。lightbend/config 1.4.9,即参考实现,也就是规范为之编写的那一个,会给你这个:
{"hosts":["db1","db2"]}
没有错误,没有警告。db3 不见了。反过来写,db3 [db1, db2],你就会得到承诺的错误。
所以在进一步开发解析器之前,我写下了 HOCON 应该做什么。这变成了一套一致性测试套件:445 个用例,每个用例都是规范中的一句话转化成的配置文件以及它必须产生的值。lightbend/config 1.4.9 通过了其中 90%,pyhocon 通过了 70%。我的 Zig 解析器 zig-hocon 通过了 69%,从现在起它就是针对这套测试套件构建的。
方法
规范是散文,所以我把它变成了数据。每一句包含必须、是或无效的句子都变成了最小的配置文件来测试它,外加一个 JSON 附属文件,其中包含它必须产生的值:
suite/string-value-concatenation/010-object-then-scalar-drops-the-scalar.conf
suite/string-value-concatenation/010-object-then-scalar-drops-the-scalar.json
{
"spec": "HOCON.md#string-value-concatenation",
"rule": "string-value-concatenation.12",
"why": "it is invalid for arrays or objects to appear in a string value concatenation; typesafe/config accepts this one silently and discards the trailing x, losing data with no diagnostic, while erroring on the same pair in the other order",
"error": "ObjectInStringConcatenation",
"java": "lenient",
"java_expect": {"a": {"b": 1}},
"java_since": {"version": "main", "by": "lightbend/config#862"}
}
期望值是通过一个小的预言机脚本从 lightbend/config 1.4.9 播种的,该脚本从标准输入接收配置并打印 JSON。然后每一行都对照它所代表的句子进行阅读。两者一致的地方,没问题。不一致的地方,由规范决定 expect,并且该行还记录了 Java 实际做了什么(java_expect 或 java_error)。规范是参考,Java 是现实的预言机。
所以每个实现有两个分数。规范模式:它是否按照 HOCON 所说的去做。Java 模式:它是否按照 lightbend/config 所做的去做,如果你只需要生产配置继续按照 Java 作为标准来解析,这就是标准。
| 445 个用例 | 规范模式 | Java 模式 |
|---|---|---|
| lightbend/config 1.4.9 | 90% (399) | 99% (442) |
| pyhocon 0.3.63 | 70% (312) | 66% (295) |
| zig-hocon | 69% (307) | 70% (310) |
Java 在自己的模式中漏掉了三行,因为那些是开放性问题:有一行我无法从规范文本中决定,被标记为待审查,既不算通过也不算失败。
这套测试套件尚未完成,也没有假装完成。它为其清单中 287 条规则中的 272 条提供了用例,规范中每个规范性句子对应一条规则,而 COVERAGE.md 列出了它没有覆盖的 15 条。具体来说,一些关于包含、来自环境变量的列表、作为数组的数字键的部分。这是到目前为止我独自能做到的最好程度。
这些都不是 HOCON 特有的问题。任何规范以散文形式描述、而真相却由某个实现决定的格式,都存在同样的缺口:JSON 有 JSONTestSuite,TOML 有 toml-test,YAML 有 yaml-test-suite。而我没在其他地方见过的是第二列:每一行中规范与参考实现产生分歧的地方都会标明是哪一方(diverges、unsupported 或 lenient),这样你就可以针对任意一方进行评分。
完全披露:解析器本身(分词器、解析器、值图、转换以及公共 API)是手写的,学习 Zig 才是重点。我用 Claude 来处理它周围的工具:oracle 包装器、一致性运行器和报告生成器,以及起草部分一致性用例。期望值从不来自模型。它们来自运行 lightbend/config,然后对照规范文本手工检查。
规范与 Java 产生分歧的 43 行大约分为十几个类别,其中一半是刻意的宽容而非 bug。其中三个,按让我惊讶的程度递增排列。
三个发现
消失的尾随文本
就是开头的那个例子。一个对象或数组后面跟同一行上的文本:
server = { port = 8080 } debug
1.4.9 返回 {"server":{"port":8080}}。规范说这是一个错误。pyhocon 在这一点上做对了,会拒绝。在漫长的一天结束时,忘记一个逗号或闭合括号后多出一个词,正是你会犯的错,而参考实现却悄悄把它吃掉了。
这个已经修复:lightbend/config#862 已合并并会拒绝它。但尚未发布。
这个让我坐了下来。它看起来像是一个边缘情况,但它是常见的分层模式:一个共享文件如果环境变量已设置就从环境中取值(${?DB_PORT}),而包含它的文件则持有默认值。两个文件:
# child.conf
x.y = ${?DB_PORT}
a = ${x.y}
# main.conf
common { include "child.conf" }
x.y = 0
zzz = ${?common.x}
规范对此有一条规则:被包含文件中的替换首先相对于该文件被包含的位置进行查找(common.x.y),如果那里没有,则在原始路径查找(x.y)。DB_PORT 未在环境中设置,所以 ${?DB_PORT} 未定义,common.x.y 永远不存在,查找回退,common.a 就是 0。
# lightbend/config 1.4.9:
UnresolvedSubstitution: child.conf: 2: Could not resolve substitution to a value: ${common.x.y}
现在把 zzz 重命名为 o24bbd。其他什么都不变。
{"common":{"a":0,"x":{}},"o24bbd":{},"x":{"y":0}}
它解析成功了。lightbend/config 中的根对象是一个 HashMap,其键出现的顺序就是它们被解析的顺序。o24bbd 不是玩笑,而是一个被选中以在该 map 中排在 a 之前的名字。一个名为 first 的键会像 zzz 一样失败。你在文件中其他地方恰好拥有的键决定了你的配置能否加载。
pyhocon 在这一点上做对了。修复是 lightbend/config#863,仍然开放。
被覆盖的值仍会被求值
w = [${does-not-exist}]
r = { x = 1 }
w = ${r}
在 HOCON 中,同一键的后续值会替换先前的值,除非两者都是对象,在这种情况下它们会合并。列表从不合并。所以 w 中的列表在 w = ${r} 出现的那一刻就死了,规范说它绝不能被求值。答案是 {"w":{"x":1},"r":{"x":1}}。
1.4.9 仍然会对这个死列表求值,并在 ${does-not-exist} 上失败。添加第四行 zzz = ${w.x},它仍然失败。把那行改名为 o24bbd = ${w.x},它就能解析。与故事 2 同样的病症:解析顺序泄漏到了结果中。
pyhocon 在这一点上也做对了。它以不同的方式解析替换,针对一棵半构建的树,而正是同样的方法导致它在某些隐藏替换输入上永远挂起。Java 的修复是 #865,开放中。
在构建这套测试套件的过程中,我向上游发送了几个修复。四个已经合并(#860、#861、#862、#864);两个仍然开放(#863、#865)。
为什么实现会出现偏差
这都不是疏忽。规范是伴随着某一个实现写出来的,而多年来那个实现就是测试套件。凡是文字描述含糊的地方,代码说了算,而其他每一个实现(pyhocon、Rust 的那些 crate、Go 和 .NET 的那些)读了文字描述,碰到边界情况,又各自做了决定。没有一套共享的、可运行的用例,这些决定就永远不会被拿来比较。它们只会越积越多。
故事 2 和故事 3 是类似的机制。HOCON 会针对一棵合并后的树来解析替换,规范描述这棵树,而实现以某种顺序遍历它。当这个顺序没有被固定下来时,结果就取决于它。从文字描述里没人能看出这一点。只有当两个实现运行同一个文件却打印出不同结果时,你才会看到它。
用这套测试套件检验你的解析器
这套测试套件是数据,不是代码。运行器就是遍历目录加一次 JSON 比较,它需要 POSIX shell 和 python3。要测试你自己的实现,写一个适配器:接收一个路径,打印紧凑 JSON,当你拒绝该输入时以非零状态退出。对 pyhocon 来说这就是全部:
#!/usr/bin/env python3
import json, sys
from pyhocon import ConfigFactory
print(json.dumps(ConfigFactory.parse_file(sys.argv[1])))
pyhocon 在少数几个用例上会无限循环,所以要做完整运行,就用 timeout 把它包起来,就像 oracle.sh 那样。
conformance/run.sh --text -- ./my-adapter.sh # spec mode, human-readable
conformance/run.sh --text --java -- ./my-adapter.sh # what lightbend/config does
--text 会打印一份可读的报告,包含每个失败行的规则、期望值以及你的解析器返回了什么。没有它,你会得到一个可以提交到 conformance/reports/ 的 JSON 结果,你的实现也会出现在表格里。对于任何编译型语言,适配器就是一行 exec(example.sh)。
如果你觉得某一行是错的,那你很可能就是我要找的人。带着用例路径和你理解不同的那句规范,开一个 issue。一个对规范理解有误的用例,对我来说比一个通过的分数更有价值,因为每一个用它测试的实现都会继承这个错误。
一个实现,接受测试套件的检验
这套测试套件告诉你各个实现是否一致。它并不能让它们一致。那部分是 Zig 最初的想法:一个核心,通过 C ABI 暴露出来,这样任何能调用 C 的语言都能绑定到同一份代码。那么“每种语言里的 HOCON 都一样”就不再是每个移植版本都必须遵守的承诺。zig-hocon 还没到那一步:测试套件通过率 69%,不支持 include,没有 C 垫片。但从现在起它是针对这套测试套件来构建的,所以测试套件越好,每一个用它测试的解析器就越好,包括我的。
结论
配置应该很无聊!
我认为配置应该是磁盘上可组合的数据:include、覆盖,以及偶尔的替换。类型和校验属于读取它的程序:Pydantic、Serde 或 Zig struct。HOCON 把组合这部分做得很好,而且它是许多项目的配置格式。如果你想在配置本身里拥有类型、约束、循环和函数——当你在多种语言之间共享配置文件时你会很欣赏这一点——那么 Pkl 是诚实的选择。
至于其他一切,HOCON 只需要在任何地方都表示同样的含义。这套测试套件说明了那是什么。来告诉我它哪里错了。
来源:Hacker News · pubs.miloskozak.cz