开始前,会这些就够了
- 知道 properties、required 与
additionalProperties的作用 - 先读 多个输入分支怎样匹配
- Python 3.10+;需要 jsonschema 4.26.0
资料工具原来只接受 document_id。现在想复用基础规则,再增加必填 language,于是把旧规则放入 allOf,在外面声明语言。结果:带语言会报多余字段,不带语言又报缺字段。参数没有写错,两份规则却合在一起互相冲突了。
本篇使用真实 jsonschema 4.26.0,逐格执行六份合同与八个固定输入。实验不查询真实资料,也不调用模型;它只观察应用侧验证器怎样解释这些 Schema。
把例子带到自己的电脑上
下载完整练习包
需要 Python 3.10 或更新版本;实测 Windows / Python 3.14.0,依赖 jsonschema==4.26.0;Python 3.10 仅做语法兼容检查。首次创建隔离环境并安装固定版本依赖需要联网;之后运行示例不访问网络。下载 ZIP 后选择“全部提取”,进入能看到 README.md 的文件夹。
下载 ZIP · 6 个文件包里有什么
- demo.py:完整可运行源码
- README.md:运行步骤与练习
- observed.stdout.txt:本次实际输出
- verification.json:源码和运行验证记录
- SHA256SUMS.txt:文件摘要
- requirements.txt:固定版本依赖
在这个文件夹打开 PowerShell,逐行运行
python --version
python -m venv .venv
.\.venv\Scripts\python.exe -m pip install -r requirements.txt
.\.venv\Scripts\python.exe -B demo.py解压后,在含 demo.py 的目录打开终端。命令分别回车执行。真实 jsonschema 4.26.0 的六份本地组合合同、8输入×6合同及40个对象的有限枚举;未执行工具、远程引用或模型API。
找不到命令或文件时,先按 README 检查环境和所在目录。文中标出的阅读时间只估算文章篇幅,安装环境和动手练习可按自己的节奏进行。
allOf 扩展字段,为什么仍然被基础规则拒绝?
正在准备实验控件…
固定回放 CPython 3.14.0、jsonschema 4.26.0 在 Draft 2020-12 下实际执行的 48 组结果。网页只切换这 6 份规则与 8 个输入,没有校验任意 JSON,也没有执行资料查询;下方 Python 下载版可在本地复验。
先明确基础规则已经关上了哪扇门
基础合同要求输入是对象,document_id 必须存在、是字符串且非空。如果同时写 additionalProperties=false,这份合同只允许它自己声明的 document_id 出现。language 在基础规则看来就是未声明字段。
扩展合同在外层声明 language 只能为 zh 或 en,并要求它存在;allOf 中继续放入那份已关闭的基础合同。对于 document_id=D7、language=zh 的输入,语言约束通过,基础合同却拒绝 language。
删掉 language 也不能解决:外层 required 又会失败。固定案例表把新形状和旧形状都保留下来,避免只看某一种失败就要求模型来回增删同一个字段。
allOf 是同时满足,不是把对象规则拼成一张表
allOf 把同一份输入分别交给所有子规则,全部成立才通过。它不会把外层的 properties 名单写回内层,也不会撤销内层已经规定的 additionalProperties=false。子规则接收到的还是完整原始对象,不是先被其他规则过滤后的剩余字段。
因此,扩展者不能靠“外层已经声明了 language”说服关闭的基础规则。这个问题与上一篇 oneOf 的匹配数量不同:oneOf 检查恰好一支成立;这里要求全部成立,而每支对字段的认识有自己的作用范围。
程序保留失败关键字的位置。合法新请求在 closed_base 中,拒绝来自 allOf 的第 0 支里的 additionalProperties。路径指向的是这份基础合同,不是模型给了非法语言。
只把关闭开关搬到外层,仍可能关错范围
第二份合同先去掉基础规则的 additionalProperties,再在外层设 additionalProperties=false。但外层 properties 只列 language,于是 document_id 又变成外层眼中的额外字段。单纯移动一个开关还不够。
第三份合同在外层同时声明 language 与 document_id,再关闭额外字段。document_id 在外层的约束写成空规则 {},只是让这个名字进入外层名单;类型、非空与必填要求仍由 allOf 中的基础规则执行,没有因为空规则就被撤销。
这份 redeclared 合同接受合法新请求,同时拒绝数字编号、空编号、未知语言、缺字段和 debug 等额外字段。重声明字段名称是一种明确的结构安排;字段较多时要注意维护同步,但本例没有测量维护成本。
开放基础规则,在组合完成的外层检查遗漏字段
第四份合同也使用开放的基础规则,但外层不重复 document_id 名称,而是写 unevaluatedProperties=false。这个关键字会依据适用规则产生的属性注解,识别前面成功评估过的字段,再处理尚未覆盖的字段。
这里 document_id 由 allOf 中的基础规则检查,language 由外层 properties 检查;合法输入的两栏都得到覆盖。额外的 debug 没有得到允许,于是被拒绝。它与上一种重声明方案在本篇声明的有限输入域中给出相同结果。
unevaluatedProperties 从较新的草案开始提供。本篇明确使用 Draft202012Validator;不能把关键字复制到任意旧草案或模型服务后,就假定那里也支持同样行为。校验仍不负责删除字段或修改输入。
外层的新关键字,不能救回已经失败的基础规则
第五份合同保留关闭的基础规则,再添加外层 unevaluatedProperties=false。合法新请求仍然失败:内部 additionalProperties 的拒绝没有消失。外层规则能够增加条件,不会把内层的 False 改写成 True。
第六份 open_everywhere 则让基础规则与外层都不关闭额外字段。它接受合法新请求,也接受多了 debug 的请求。这说明“终于通过了”可能只是把原本想保留的禁止条件取消了,验收还必须包含应该拒绝的输入。
失败子规则还可能影响哪些字段注解可供外层使用,所以一个错误输入有时会出现不止一种错误关键词。程序记录路径与关键词,不把英文错误文案或错误条数当成跨版本稳定的业务接口。
运行八行六列,再核对四十个有限对象
先在独立虚拟环境安装 requirements.txt 中的 jsonschema==4.26.0,再运行 demo.py。2026-10-23 已用 Windows、Python 3.14.0 与该版本真实执行,181 项检查通过;直接复用了本轮已安装的隔离环境,没有修改全局依赖。
主表是八个输入、六份合同,共 48 个接受或拒绝结果。除此之外,代码枚举编号缺失、空串、合法串或数字,语言缺失、zh、en、fr 或数字,再组合是否有 debug,共 40 个对象;独立字段表预期分别核对两份修正版与开放版。
report.json 保存完整 Schema、每格布尔结果、错误关键词和路径。代码还检查输入与规则未被修改。有限枚举只能证明这些声明的组合,不能证明任意嵌套 Schema、引用或全部输入空间都已覆盖。
展开收起代码 · 小实验
长行可在代码区域内左右滚动,按 Tab 可把焦点移入代码区。
"""Real jsonschema composition checks for a fictional read-only tool."""
from copy import deepcopy
from importlib.metadata import version
from itertools import product
import json
from pathlib import Path
from jsonschema import Draft202012Validator
EXPECTED_VERSION = '4.26.0'
DIALECT = 'https://json-schema.org/draft/2020-12/schema'
checks = []
def check(name, condition):
if not condition:
raise AssertionError(name)
checks.append(name)
def schemas():
base_open = {'type': 'object', 'properties': {'document_id': {'type': 'string', 'minLength': 1}},
'required': ['document_id']}
base_closed = {**deepcopy(base_open), 'additionalProperties': False}
extension = {'$schema': DIALECT, 'type': 'object',
'properties': {'language': {'enum': ['zh', 'en']}}, 'required': ['language']}
result = {}
result['closed_base'] = {**deepcopy(extension), 'allOf': [deepcopy(base_closed)]}
result['outer_only'] = {**deepcopy(extension), 'allOf': [deepcopy(base_open)],
'additionalProperties': False}
result['redeclared'] = {**deepcopy(extension), 'allOf': [deepcopy(base_open)],
'properties': {**deepcopy(extension['properties']), 'document_id': {}},
'additionalProperties': False}
result['unevaluated'] = {**deepcopy(extension), 'allOf': [deepcopy(base_open)],
'unevaluatedProperties': False}
result['closed_plus_unevaluated'] = {**deepcopy(result['closed_base']), 'unevaluatedProperties': False}
result['open_everywhere'] = {**deepcopy(extension), 'allOf': [deepcopy(base_open)]}
return result
def make_validator(schema):
Draft202012Validator.check_schema(schema)
return Draft202012Validator(schema)
def error_records(validator, payload):
records = [{'keyword': str(error.validator), 'instancePath': list(error.absolute_path),
'schemaPath': list(error.absolute_schema_path)} for error in validator.iter_errors(payload)]
return sorted(records, key=lambda row: json.dumps(row, sort_keys=True))
def main():
installed = version('jsonschema')
check('pinned jsonschema version', installed == EXPECTED_VERSION)
contracts = schemas()
original_contracts = deepcopy(contracts)
validators = {name: make_validator(schema) for name, schema in contracts.items()}
names = list(validators)
cases = [
('valid', {'document_id': 'D7', 'language': 'zh'}, [False, False, True, True, False, True]),
('old_id_only', {'document_id': 'D7'}, [False] * 6),
('only_language', {'language': 'zh'}, [False] * 6),
('bad_id_type', {'document_id': 7, 'language': 'zh'}, [False] * 6),
('unknown_language', {'document_id': 'D7', 'language': 'fr'}, [False] * 6),
('extra_field', {'document_id': 'D7', 'language': 'zh', 'debug': True}, [False, False, False, False, False, True]),
('null', None, [False] * 6),
('empty_id', {'document_id': '', 'language': 'zh'}, [False] * 6),
]
print('jsonschema=' + installed)
print('columns=' + ','.join(names))
report = []
for label, payload, expected in cases:
before = deepcopy(payload)
results = [validator.is_valid(payload) for validator in validators.values()]
for name, got, want in zip(names, results, expected):
check(label + ' / ' + name, got is want)
check(label + ' input unchanged', payload == before)
report.append({'case': label, 'payload': payload, 'accepted': dict(zip(names, results)),
'errors': {name: error_records(validator, payload) for name, validator in validators.items()}})
print(label + ': ' + ','.join('T' if valid else 'F' for valid in results))
valid_payload = {'document_id': 'D7', 'language': 'zh'}
closed_errors = error_records(validators['closed_base'], valid_payload)
outer_errors = error_records(validators['outer_only'], valid_payload)
check('closed base rejects at its own keyword', any(
e['keyword'] == 'additionalProperties' and e['schemaPath'] == ['allOf', 0, 'additionalProperties']
for e in closed_errors))
check('outer-only closure rejects at root', any(
e['keyword'] == 'additionalProperties' and e['schemaPath'] == ['additionalProperties']
for e in outer_errors))
check('outer unevaluated does not override failing inner closure', any(
e['keyword'] == 'additionalProperties' and e['schemaPath'] == ['allOf', 0, 'additionalProperties']
for e in error_records(validators['closed_plus_unevaluated'], valid_payload)))
# Independent truth table over a small, declared input domain.
missing = object()
count = 0
for document_id, language, extra in product([missing, '', 'D7', 7], [missing, 'zh', 'en', 'fr', 7], [False, True]):
payload = {}
if document_id is not missing:
payload['document_id'] = document_id
if language is not missing:
payload['language'] = language
if extra:
payload['debug'] = True
fields_valid = type(document_id) is str and bool(document_id) and language in ('zh', 'en')
expected_closed = bool(fields_valid and not extra)
check('finite redeclared oracle ' + str(count), validators['redeclared'].is_valid(payload) == expected_closed)
check('finite unevaluated oracle ' + str(count), validators['unevaluated'].is_valid(payload) == expected_closed)
check('finite open oracle ' + str(count), validators['open_everywhere'].is_valid(payload) == bool(fields_valid))
count += 1
check('schema definitions unchanged', contracts == original_contracts)
print(f'finite_objects={count}; repaired_contracts_match_oracle=True')
print('outer_unevaluated_does_not_override_closed_base=True')
print(f'PASS checks={len(checks)}')
Path('report.json').write_text(json.dumps({
'jsonschema': installed, 'scope': 'fixed local schemas for a fictional read-only lookup; no actual lookup',
'schemas': contracts, 'columns': names, 'cases': report, 'finite_objects': count,
'checks': len(checks), 'passed': checks,
}, ensure_ascii=False, indent=2) + '\n', encoding='utf-8')
if __name__ == '__main__':
main()
怎样运行:在独立练习文件夹中,依次执行下载区的命令。2026-10-23 使用 Python 3.10 或更新版本;实测 Windows / Python 3.14.0,依赖 jsonschema==4.26.0;Python 3.10 仅做语法兼容检查 复跑这份源码并核对完整输出。真实 jsonschema 4.26.0 的六份本地组合合同、8输入×6合同及40个对象的有限枚举;未执行工具、远程引用或模型API。
运行后对照结果
jsonschema=4.26.0
columns=closed_base,outer_only,redeclared,unevaluated,closed_plus_unevaluated,open_everywhere
valid: F,F,T,T,F,T
old_id_only: F,F,F,F,F,F
only_language: F,F,F,F,F,F
bad_id_type: F,F,F,F,F,F
unknown_language: F,F,F,F,F,F
extra_field: F,F,F,F,F,T
null: F,F,F,F,F,F
empty_id: F,F,F,F,F,F
finite_objects=40; repaired_contracts_match_oracle=True
outer_unevaluated_does_not_override_closed_base=True
PASS checks=181
把合同组合与真实工具执行分开验收
组合前,明确基础片段是否计划被复用,以及哪一层负责最终限制字段。若基础规则本来就是完整且封闭的合同,应重新设计扩展方式,不能指望调用方的 allOf 自动把它变成可继承的对象类型。
Schema 通过只说明输入满足当前规则。资料存在、用户可读、语言支持与查询成功仍是后续业务条件。本篇不访问远程 Schema,不执行资料查询,也不检验任何模型产品原生工具接口支持哪些关键字。
移植时一起保存草案、依赖版本、完整输入和预期矩阵。增加字段后,既运行应该通过的例子,也运行额外字段、缺字段与错误类型的反例,才能发现“放宽到什么程度”和“拒绝从哪一层发出”。
记住这三点
allOf要求同一输入满足所有子规则,不合并字段声明,也不覆写内部拒绝。additionalProperties只看同一 Schema 对象中的字段声明;移动它时要重新核对作用范围。- 开放基础规则后,可在外层重声明字段再关闭,或用支持草案中的
unevaluatedProperties统一检查遗漏字段。
别把例子用过头
- 把
allOf当作面向对象继承或字段合并器。 - 只把
additionalProperties移到外层,却没有让外层认识基础字段。 - 保留内层关闭,还期待外层
unevaluatedProperties覆盖内部失败。 - 为了让合法输入通过而完全开放字段,却没有检查反例。
自己试一小步
给资料工具再增加一种可选输出选项,怎样保持复用同时拒绝意外字段?
- 先画出基础片段与完整工具合同的职责,独立列出哪些字段必填、可选或禁止。
- 分别实现外层重声明与
unevaluatedProperties两种方案,运行前写预期表。 - 加入缺字段、错误类型、非法枚举和额外字段,逐格保存结果与错误路径。
- 再构造一个仍保留关闭基础规则的错误版本,解释拒绝位置为什么改变。
做到这些,就算完成
- 声明使用的草案与库版本,未把结果推断成所有模型接口都支持。
- 两份修正版符合自己先写的字段表,允许和拒绝样例都覆盖。
- 输入未被校验过程修改,结果没有包装成真实工具执行成功。
查原始资料
想确认一个细节,先看这份资料对应的部分,不必从头读完所有文档。
- JSON Schema:扩展关闭的对象规则 (新标签页)↗
additionalProperties的同一子规则范围、字段重声明,以及unevaluatedProperties的外层闭合方式。 - JSON Schema 2020-12 Core:组合规则与未评估属性 (新标签页)↗
allOf对同一实例逐支求合取,additionalProperties的同对象作用范围,以及unevaluatedProperties对适用注解的依赖。