JSON Schema 复用:$ref、$defs 与 allOf/oneOf 组合
结论:$ref 解决复用,allOf/oneOf 解决组合,两者叠在一起最容易出事。本文按解析顺序讲清 $id、$defs、条件分支、循环引用与打包,并给出可复现的调试路径。

结论:先用 $defs + $ref 把公共结构抽成一份,再考虑 allOf/oneOf 组合;分支可能同时成立时用 anyOf 或加 const 判别字段,只有确实要求「恰好其一」才用 oneOf。
复制粘贴省下十分钟,代价是三个月后两份 schema 悄悄分叉,而且没人记得哪一份才是对的。
一、复用分三层,先想清楚在哪一层
字段一多,第一反应是把所有约束塞进一个文件。这在上线前是最快的做法,但文件超过两百行之后,改一个字段要在三处保持一致,漏掉一处就会得到「本地通过、线上 500」的效果。复用的本质是让同一个事实只写一次,其余地方只引用。
JSON Schema 提供了三层工具,各自适用的范围不同:
| 层次 | 载体 | 生效范围 | 典型场景 |
|---|---|---|---|
| 子结构 | $defs + $ref |
单个 schema 文件 | 地址、金额、分页参数、错误结构 |
| 文件 | 独立 schema 文件 + $id |
多个文件、同一服务 | 用户、订单、商品等独立实体 |
| 契约库 | schema 仓库 + 打包产物 | 多个服务、跨语言 | OpenAPI 组件、对外发布的接口规范 |
绝大多数项目在第一层就够了。只有当两个服务真的需要共享同一份契约、或者 schema 要作为产物发布给其他语言使用时,拆文件才有收益。过早拆文件会让 $id 与相对路径变复杂,调试成本往往超过省下的重复。
判断标准很简单:同一个结构如果只在两处出现,复制一次尚可接受;如果会出现在第三处,就应该抽成 $defs;如果它需要被另一种语言或另一个仓库读取,才升级成独立文件。
二、$id 决定身份,$ref 决定跳转
$id 不是注释,它声明这份 schema 的基准地址(base URI)。$ref 的值是一个 URI 引用:先相对当前基准地址解析成绝对地址,再取 # 后面的片段定位到具体节点。基准地址一旦错位,后面所有相对引用都会指错地方,但报错通常只说「找不到引用」,不会告诉你错在哪个域名。
{
"$schema": "https://json-schema.org/draft/2020-12/schema",
"$id": "https://bbdrug.com/schemas/order.json",
"$defs": {
"address": {
"$anchor": "address",
"type": "object",
"required": ["city"],
"properties": {
"city": { "type": "string", "minLength": 1 },
"detail": { "type": "string" }
}
}
},
"type": "object",
"required": ["shipTo"],
"properties": {
"shipTo": { "$ref": "#/$defs/address" },
"billTo": { "$ref": "#address" }
}
}
shipTo 用 JSON Pointer,billTo 用锚点,两者指向同一个节点。片段写法与解析结果对照如下:
| 写法 | 指向 | 适用版本 | 注意 |
|---|---|---|---|
# |
当前 schema 根节点 | 全部 | 递归结构常用 |
#/$defs/address |
本文档 $defs 下的节点 |
2019-09 起 | 最直观,推荐 |
#address |
具名锚点 | 2020-12 用 $anchor |
draft-07 写作 $id: "#address" |
common.json |
相对基准地址的文件 | 全部 | 必须先注册或打包 |
https://example.com/x.json |
绝对地址 | 全部 | 生产环境避免运行时拉取 |
有一个隐蔽的坑:如果 $defs 里的子 schema 自己写了 $id,它会成为新的基准地址,从此处开始的相对引用全部以它为准。把从别处复制来的 schema 贴进文件时,残留的 $id 会让相对引用指向原域名,而校验器只在真正解析时才发现找不到目标。
三、$defs 只是收纳盒,不参与校验
$defs 是一个普通容器,里面的内容不会自动生效。只有被 $ref 指过去的那一段才参与校验,其余定义哪怕写得再严格,也只是躺在文件里。把 required 写在 $defs 顶层却没人引用,数据依然能通过——这不是校验器有 bug,而是规范如此。
{
"$defs": {
"money": {
"type": "string",
"pattern": "^[0-9]+\\.[0-9]{2}$",
"description": "两位小数的金额字符串,避免浮点误差"
},
"page": { "type": "integer", "minimum": 1 }
},
"properties": {
"total": { "$ref": "#/$defs/money" },
"page": { "$ref": "#/$defs/page" }
}
}
几个约定值得记住:
$defs的键名没有语义,只用来说明用途,address、Address、addr都合法,但团队内应统一风格;- 2019-09 之前叫
definitions。两者都能被$ref指向,但在严格模式下definitions可能被当作未知关键字告警; $defs里可以放任何 schema,包括只含allOf的组合片段,也可以放带$id的独立子 schema;- 不要指望
$defs做「模板继承」。它只负责收纳,语义仍由引用处的上下文决定。
四、allOf、anyOf、oneOf 的语义差异
三个关键字都与「多个分支」有关,但判定条件完全不同。数据命中两个分支时,allOf 与 anyOf 都算通过,oneOf 直接失败,这是最容易被误判为「schema 写错了」的地方。
| 关键字 | 至少命中 | 至多命中 | 命中两个分支 | 错误可读性 | 典型用途 |
|---|---|---|---|---|---|
allOf |
全部 | 不限 | 通过 | 好,逐条约束 | 组合多个约束片段 |
anyOf |
1 | 不限 | 通过 | 差,分支错误成堆 | 「邮箱或手机号任选其一」 |
oneOf |
1 | 1 | 失败 | 差 | 带判别字段的联合类型 |
not |
0 | 0 | 失败 | 最差 | 排除少数取值,极少使用 |
{
"allOf": [
{ "$ref": "#/$defs/auditable" },
{ "$ref": "#/$defs/softDeletable" },
{ "required": ["email"] }
],
"unevaluatedProperties": false
}
allOf 常被误以为会「合并」对象定义,但它只是要求所有分支都成立。每个分支独立评估,一个分支里的 additionalProperties: false 只认识本分支声明的键,会把另一个分支声明的字段判成多余。2020-12 的解法是把未知字段策略统一提到顶层,用 unevaluatedProperties: false 在所有分支评估完之后只判断一次。
anyOf 适合「多种合法形态」而不是「多种互斥形态」。如果两个分支可以同时成立,用 oneOf 就会得到 must match exactly one schema in oneOf 这种对用户毫无意义的报错。
五、if/then/else 与 discriminator:把条件写成可判定结构
条件约束比 oneOf 更容易给出好错误,因为判定路径是显式的:if 成立就走 then,否则走 else。
{
"type": "object",
"required": ["kind"],
"properties": { "kind": { "enum": ["text", "image"] } },
"if": {
"properties": { "kind": { "const": "image" } },
"required": ["kind"]
},
"then": { "required": ["url", "width", "height"] },
"else": { "required": ["content"] }
}
三个细节决定了它是否按预期工作:
if里的properties不会因为键缺失而失败,必须配required才能表达「kind 必须存在且等于 image」;if本身不产生校验错误,失败只意味着走else,所以不要指望它拦住非法数据;- 单独写
then或else没有任何作用,它们必须与if同级出现。
当联合类型的分支变多,条件链会迅速退化成难以维护的嵌套。更稳妥的写法是在每个分支里锁定一个判别字段,让分支天然互斥:
{
"oneOf": [
{
"properties": { "kind": { "const": "card" } },
"required": ["kind", "cardNumber"]
},
{
"properties": { "kind": { "const": "account" } },
"required": ["kind", "accountId"]
}
]
}
discriminator 是 OpenAPI 3.x 的扩展,不是 JSON Schema 标准关键字。Ajv 在开启 discriminator: true 选项后支持它,但映射表写在 mapping 里,跨工具链不一定通用。如果 schema 要同时给多个校验器使用,优先选 const 判别字段的写法,它没有任何扩展依赖。
六、循环引用:树、链表与互相引用的文件
递归结构不需要特殊语法,$ref 指回自身即可。校验器不会在编译阶段无限展开引用,它按数据的实际深度递归,所以一份几十行的 schema 就能描述任意层级的树。
{
"$schema": "https://json-schema.org/draft/2020-12/schema",
"$id": "https://bbdrug.com/schemas/tree-node.json",
"$defs": {
"node": {
"type": "object",
"required": ["name"],
"properties": {
"name": { "type": "string", "minLength": 1 },
"children": {
"type": "array",
"items": { "$ref": "#/$defs/node" }
}
}
}
},
"$ref": "#/$defs/node"
}
递归有三条边界要提前想清楚:
- 数据深度:schema 不限制层数,但校验与序列化都按深度消耗栈。对外接口应加业务层限制(例如最大 8 层),否则恶意构造的深链会让服务端栈溢出;
- 互相引用:A 文件引用 B、B 又引用 A 是合法结构,但两份 schema 都必须先注册,否则解析顺序会决定成败;
- 可空分支:递归字段如果允许
null,记得写成"type": ["object", "null"]或用oneOf包裹,$ref不会自动带上可空语义。
七、跨文件引用与打包:别在线上拉 schema
跨文件引用在开发环境往往「能跑」,因为校验器会按 $id 去网络取文件;上线后网络策略一变,整条校验链就断了。生产环境的原则是所有被引用的 schema 都必须在进程内注册,或者提前打包成一个文件。
import Ajv2020 from 'ajv/dist/2020';
import addressSchema from './address.json' with { type: 'json' };
import orderSchema from './order.json' with { type: 'json' };
const ajv = new Ajv2020({ allErrors: true, strict: true });
// 先注册被引用的文件,键必须是它的 $id,否则相对引用解析不到
ajv.addSchema(addressSchema, 'https://bbdrug.com/schemas/address.json');
// 再编译入口 schema,compile 只做一次
export const validateOrder = ajv.compile(orderSchema);
如果 order.json 的 $id 是 https://bbdrug.com/schemas/order.json,里面的 "$ref": "address.json" 会解析成 https://bbdrug.com/schemas/address.json,正好命中上面注册的键。只要键与 $id 有一个字符不一致,就会得到 can't resolve reference。
三种落地方式的取舍:
| 方式 | 做法 | 优点 | 代价 |
|---|---|---|---|
| 运行时注册 | addSchema 逐个注册 |
无构建步骤,文件可独立维护 | 注册顺序与键必须手工保证 |
| 构建期打包 | 用引用解析工具合并成单文件 | 运行时零依赖,发布简单 | 产物变大,diff 不直观 |
| 异步加载 | loadSchema + compileAsync |
适合 schema 频繁变化 | 引入网络与缓存问题,不适合关键路径 |
对前端项目,构建期打包更稳;对服务端,注册法足够,但要有测试覆盖「所有引用都能解析」。无论哪种方式,都应在 CI 里跑一遍 compile,让引用错误在合并前暴露,而不是等到线上第一条请求。
八、调试顺序:先解析,再编译,最后看分支
$ref 相关问题几乎都能按三个阶段定位,顺序不能颠倒:
| 现象 | 常见原因 | 处理 |
|---|---|---|
can't resolve reference ... from id ... |
$id 拼错、相对路径基准不对、目标未注册 |
打印 $id 与解析后的绝对地址,再核对注册键 |
schema with key or id already exists |
同一 $id 被注册两次,或重复 compile |
注册集中到模块初始化阶段,只执行一次 |
strict mode: unknown keyword: "definitions" |
混用了 draft-07 写法 | 改成 $defs,不要关严格模式 |
数据同时命中两个 oneOf 分支 |
分支约束重叠,缺少判别字段 | 每个分支加 const,或改用 anyOf |
$defs 里的约束完全不生效 |
没有被任何 $ref 指向 |
检查引用路径,$defs 不会自动校验 |
调试时优先看 schemaPath,它能告诉你失败发生在哪个分支的哪条关键字,比 message 可靠得多。把 compile 结果缓存到模块顶层,避免在请求里重复编译;schema 改动后用测试用例覆盖「应当通过」与「应当失败」两侧,只测通过路径等于没测。更完整的校验器选型与错误信息设计,见JSON Schema 从入门到工程化,把 schema 接进类型生成的流程见Schema 与 TypeScript 类型双向同步。
九、常见问题
$ref 能和 type、description 写在同一层吗
2020-12 允许 $ref 与兄弟关键字共存,兄弟关键字会正常参与校验;draft-07 里兄弟关键字会被忽略,只有 $ref 生效。同一份 schema 跨版本使用时,这是行为差异最大的地方,迁移前务必确认目标校验器支持的草案版本。
$defs 和 definitions 该用哪个
新项目用 $defs(2019-09 引入)。definitions 在 2020-12 里不是关键字,$ref: "#/definitions/foo" 仍能通过 JSON Pointer 解析成功,但严格模式会提示未知关键字。混用两套写法,等于给未来的自己埋一个只在 CI 才炸的雷。
为什么 oneOf 明明数据合法却报错
因为数据同时满足了两个分支。oneOf 要求恰好一个,而分支里只约束了部分字段时,另一分支的字段缺省也会被视为满足。给每个分支加一个 const 判别字段,让分支互斥,是最省事的修法。
循环引用会不会让校验器死循环
不会。校验器不会在编译期展开引用,而是按数据的实际结构递归。真正需要担心的是数据本身过深导致栈溢出,以及跨文件互相引用时解析顺序出错。给递归字段加业务层的深度上限,比在 schema 里想办法更有效。
生产环境能不能直接用远程 $ref
不建议。远程引用把校验的可用性绑在网络上,对方域名变更、证书过期、内网隔离都会让校验整体失败。把被引用的 schema 打包进产物或启动时注册,才是可预期的做法。
如何判断该用 $defs 还是拆成独立文件?
同一结构只在两处出现时复制尚可接受;出现在第三处就该抽成 $defs;需要被另一种语言或另一个仓库读取时,才升级成带独立 $id 的文件。
为什么 $defs 里的约束完全不生效?
$defs 只是收纳盒,没有被任何 $ref 指向的节点不参与校验。把 required 写在 $defs 顶层却没人引用,数据依然能通过,这不是校验器的 bug。
是否应该在 allOf 的分支里写 additionalProperties: false?
不应该。每个分支独立评估,一个分支会把另一个分支声明的字段判成多余。2020-12 的做法是把未知字段策略提到顶层,用 unevaluatedProperties: false 在所有分支评估后只判断一次。