约 9 分钟读完 · 更新于 2026-10-21 · 文中每条都能在下面的配套工具里动手验证
JSON Schema 校验漏掉坏数据,绝大多数不是约束写少了,而是写错了位置或选错了关键字。四个高频误用几乎覆盖了八成事故:把 required 写进属性内部、用 nullable 表达可空、用 oneOf 表达「其中之一」、以为 format 会真的校验邮箱。这些错误有一个共同特征——校验器不会报错,schema 本身是合法的,只是它约束的东西和作者以为的不一样。所以排查思路应该反过来:先拿一份明确非法的数据喂进去,看它是否被拒;如果通过了,问题一定在这四个关键字上。下面的表把每个关键字的作用和易错点对齐,可以当作速查。还有一个更隐蔽的前提问题:schema 只能校验它看得见的东西。如果请求体在校验之前被某层中间件做了类型转换,比如把字符串 "1" 转成数字 1,那么针对字符串写的约束永远不会触发,日志里全是「通过」,数据库里存的却是错的类型。
| 关键字 | 作用 | 易错点 |
|---|---|---|
| type | 限定数据类型,可以写成数组表示多选一 | 写 "nullable": true 完全无效,那是 OpenAPI 3.0 的字段 |
| required | 对象级关键字,声明哪些属性必须出现,值为字符串数组 | 写进属性内部变成 required: true,校验器按未知关键字忽略 |
| additionalProperties | 控制 properties 之外的字段是否允许 | 设为 false 后每加一个字段都要同步改 schema 和客户端 |
| $ref / $defs | 复用子 schema | 2020-12 用 $defs,draft-07 用 definitions,混用会解析不到目标 |
| oneOf / anyOf / allOf | 组合多个子 schema | oneOf 要求恰好命中一个,两个都匹配反而校验失败 |
| format | 语义提示,如 email、date-time、uuid | 多数实现默认只当注解,ajv v8 需要 ajv-formats 才真正校验 |
| items / prefixItems | 数组元素约束 | draft-07 的 items 是统一 schema,2020-12 拆成 items 加 prefixItems |
{
"$schema": "https://json-schema.org/draft/2020-12/schema",
"$id": "https://example.com/schemas/user.json",
"type": "object",
"required": ["id", "email"],
"properties": {
"id": { "type": "integer", "minimum": 1 },
"email": { "type": "string", "format": "email", "maxLength": 254 },
"nickname": { "type": ["string", "null"], "maxLength": 32 },
"roles": {
"type": "array",
"items": { "$ref": "#/$defs/role" },
"uniqueItems": true,
"default": []
}
},
"additionalProperties": false,
"$defs": {
"role": { "type": "string", "enum": ["admin", "editor", "viewer"] }
}
}type 的值既可以是一个字符串,也可以是字符串数组,后者表示「满足其中任意一个即可」,所以可空字符串写成 "type": ["string", "null"]。JSON Schema 规范里从来没有 nullable 这个关键字,它来自 OpenAPI 3.0 的扩展,校验器遇到会当作未知关键字跳过——除非你显式配置了禁止未知关键字(例如 ajv 的 strict 模式),否则不会有任何提示。另一个高频误解是 integer 与 number 的边界:number 接受整数和小数,integer 只接受数学意义上的整数,而 1.0 在 JSON Schema 里算整数,因为判断依据是数值本身而不是字面写法。用 number 校验金额时,还要配合 multipleOf 或自己限定小数位,否则 0.1 加 0.2 那类精度问题会一路带进业务逻辑。反向的坑同样存在:把 type 写成 ["integer", "string"] 之后,minimum、multipleOf 这类数值关键字只对数字分支生效,字符串分支直接绕过它们,于是「允许字符串」在实际效果上变成了「允许任意字符串」。多类型字段要么补上 pattern 和 minLength,要么干脆拆成两个字段。
{
"type": "object",
"properties": {
"email": {
"type": "string",
"required": true,
"nullable": true
}
}
}
// required 在这里是未知关键字,nullable 同样
// 结果:{} 和 {"email": null} 都能通过校验{
"type": "object",
"required": ["email"],
"properties": {
"email": { "type": ["string", "null"], "minLength": 3 }
}
}
// required 在对象层级,值是属性名数组
// 注意:required 只要求键存在,null 值仍然合法,要拒绝得靠 typeadditionalProperties: false 能挡住多余字段,对内部接口很有价值——它把「客户端悄悄多传了一个参数」这种问题从静默忽略变成显式报错,也让拼写错误(例如 emial)暴露出来。代价是每次加字段都要改三处:schema、服务端 DTO、客户端类型,任何一处漏了就是线上 400。对外公开的 API 建议不要设 false,或者先设成只记录不拒绝,否则老客户端一旦带上新字段就会被打回。另一个容易混淆的关键字是 unevaluatedProperties(2019-09 引入):additionalProperties 只看当前层 properties 和 patternProperties,而 unevaluatedProperties 会跨 allOf、anyOf、if/then 的分支去看哪些属性已经被校验过,组合场景下只有它能挡住漏网的字段。
复用子 schema 的关键字在 2020-12 叫 $defs,在 draft-07 叫 definitions,两者语义相同但解析路径不同:#/$defs/address 只在 2020-12 文档里有效,draft-07 文档里必须写 #/definitions/address。$ref 的值是 URI 引用,写 #/$defs/x 是文档内绝对指针;写 ./common.json#/$defs/x 是相对引用,解析时的基准 URI 由最近的 $id 决定,没有 $id 就是文档自身位置——这就是「本地能解析、打包后 404」的常见原因。组合关键字的选择更容易出错:allOf 要求全部满足,用来拼接约束;anyOf 至少满足一个,用来表达宽松的可选形态;oneOf 要求恰好命中一个,两个分支同时匹配反而失败。当分支之间没有真正互斥时(比如两个分支都只约束 amount),一条合法数据会同时命中两边,oneOf 直接判它非法。正确做法是给每个分支加 const 或 enum 的判别字段。实践上还有一条容易踩的:draft-07 里 $ref 会忽略同级的其他关键字,2020-12 才允许 $ref 与 title、description 并存。把 description 写在 $ref 旁边,旧文档里会静默丢失,文档信息就这样没了。组合关键字还有性能面:oneOf 的每个分支都会被完整求值,深递归 schema 在大数组上会明显变慢,能用 if/then 表达的判别逻辑不必用 oneOf 硬凑。
把校验放对位置,比把 schema 写全更重要。第一道在边界:请求一进来就校验,不要拖到写库之前;第二道在数据源:从数据库读出的历史数据也按同一份 schema 检查一次,因为约束是后来加的,老数据往往并不合规。错误信息必须带 JSON Pointer 路径,例如 /items/3/price,否则前端只能把整个表单标红。schema 本身要进版本控制,和接口文档一起改,别留在某个人的本地文件里。最后,给校验器准备一组「已知非法」的用例,每次放宽约束都跑一遍——前面那四个关键字的错误,基本都是在放宽约束的那一刻引入的。
不能,它来自 OpenAPI 3.0,会被当作未知关键字忽略。可空字段写成 type 数组加 null。
因为 required 是对象级关键字,值必须是字符串数组;写在属性内部只是一个未定义的关键字,规范要求实现忽略未知关键字,而不是报错。
需要互斥分支(如按 kind 区分支付方式)用 oneOf,并用 const 保证判别字段唯一;只要求「满足其中之一即可」用 anyOf。分支重叠时 oneOf 会拒绝合法数据。
多数校验器把 format 当注解。ajv v8 需要额外注册 ajv-formats,其它实现也要显式开启断言模式,否则 format 不产生任何校验。