JSON Schema 从入门到工程化
结论:关键字很多,真正天天用的只有十几个。把 type/properties/required/$ref 用对,再处理好错误信息,就能覆盖八成场景。

结论:先固定 $schema 为 2020-12,再用 $defs + $ref 抽公共结构;只有需要「恰好满足其一」时才用 oneOf,其余场景优先判别式或 anyOf。
Schema 不是文档的附属品,而是可以被执行、被测试、被生成的资产。
一、先搞清版本
| 版本 | $schema 值 |
状态 | 备注 |
|---|---|---|---|
| draft-04 | http://json-schema.org/draft-04/schema# |
已过时 | 老项目与部分 OpenAPI 2.0 工具 |
| draft-07 | http://json-schema.org/draft-07/schema# |
广泛使用 | OpenAPI 3.0 的事实基础 |
| 2019-09 | https://json-schema.org/draft/2019-09/schema |
可用 | 引入 $defs、注解词汇 |
| 2020-12 | https://json-schema.org/draft/2020-12/schema |
当前推荐 | 引入 prefixItems,调整 items 语义 |
新项目用 2020-12。要注意 items 在 2020-12 里只管「剩余元素」,元组式声明改成了 prefixItems,
这是从 draft-07 迁移时最常见的坑。
二、最小可用 schema
{
"$schema": "https://json-schema.org/draft/2020-12/schema",
"$id": "https://bbdrug.com/schemas/user.json",
"type": "object",
"required": ["id", "name"],
"properties": {
"id": { "type": "string", "minLength": 1 },
"name": { "type": "string", "minLength": 1, "maxLength": 64 },
"age": { "type": "integer", "minimum": 0, "maximum": 150 },
"tags": { "type": "array", "items": { "type": "string" }, "uniqueItems": true }
},
"additionalProperties": false
}
这份 schema 覆盖了最常见的四件事:类型约束、必填、范围限制、数组元素约束。
三、关键字地图
3.1 通用
| 关键字 | 作用 | 注意 |
|---|---|---|
type |
类型,可为数组 | "type": ["string", "null"] 表示可空 |
enum |
枚举值 | 值必须唯一 |
const |
单值约束 | 适合版本号这类固定值 |
$ref |
引用其他 schema | 引用整个 schema,不与被引用对象的兄弟关键字合并 |
3.2 数字
multipleOf、maximum、exclusiveMaximum、minimum、exclusiveMinimum。
注意 draft-04 里 exclusiveMaximum 是布尔值(配合 maximum 使用),draft-07 之后才变成独立数值关键字——
这让很多「从旧文档抄来的 schema」在严格模式下报错。
3.3 字符串
minLength、maxLength、pattern(ECMA-262 正则)、format(注解)。
minLength 计算的是 Unicode 码点,不是 UTF-16 单元,也不是字节。前端用 string.length 校验会和它不一致。
3.4 数组
| 关键字 | 作用 |
|---|---|
prefixItems |
2020-12 的元组式约束,按下标逐个匹配 |
items |
剩余元素的统一约束(2020-12) |
contains + minContains/maxContains |
至少/至多包含多少个匹配项 |
uniqueItems |
元素互不相同(深比较,大数组慎用) |
minItems / maxItems |
长度约束 |
3.5 对象
properties、required、patternProperties、additionalProperties、propertyNames、
minProperties、maxProperties、dependentRequired、dependentSchemas。
三个高频误区:
required只要求键存在,{"name": ""}是合法的;要拒绝空串得加minLength: 1;additionalProperties: false会放大兼容问题:客户端多传一个字段就整体失败,接口演进必须同步升级所有调用方;properties不限制未声明的键,默认是放行的。
3.6 逻辑组合
{
"oneOf": [
{ "type": "object", "required": ["cardNumber"], "properties": { "cardNumber": { "type": "string" } } },
{ "type": "object", "required": ["accountId"], "properties": { "accountId": { "type": "string" } } }
]
}
| 关键字 | 语义 | 典型错误 |
|---|---|---|
allOf |
全部满足 | 多个分支对同一字段给出冲突约束 |
anyOf |
至少一个满足 | 错误信息难以定位到具体分支 |
oneOf |
恰好一个满足 | 数据同时命中两个分支时报错,但看起来「明明是对的」 |
not |
不满足 | 极少用,错误信息最不友好 |
oneOf 是最容易写出「明明合法却校验失败」的关键字。若分支约束有重叠(比如都允许额外字段),
一条数据会命中多个分支,oneOf 直接判失败。此时应改用 anyOf,或在分支里用 const 做判别字段。
{
"type": "object",
"required": ["kind"],
"properties": { "kind": { "enum": ["text", "image"] } },
"if": { "properties": { "kind": { "const": "image" } } },
"then": { "required": ["url", "width", "height"] },
"else": { "required": ["content"] }
}
判别式(discriminator)写法比 oneOf 更易读,也更容易给出好错误。
四、拆分与复用
{
"$schema": "https://json-schema.org/draft/2020-12/schema",
"$id": "https://bbdrug.com/schemas/order.json",
"$defs": {
"money": { "type": "string", "pattern": "^-?\\d+\\.\\d{2}$" },
"address": {
"type": "object",
"required": ["city"],
"properties": { "city": { "type": "string" }, "detail": { "type": "string" } }
}
},
"type": "object",
"required": ["total", "shipTo"],
"properties": {
"total": { "$ref": "#/$defs/money" },
"shipTo": { "$ref": "#/$defs/address" }
}
}
$defs是 2019-09 之后的写法,draft-07 用definitions;- 跨文件引用用相对
$id,但生产环境不要依赖运行时拉取远程 schema,应打包成静态文件; - 引用后不能在同级追加约束(
$ref在有兄弟关键字时行为随版本变化),需要组合时用allOf。
五、用 Ajv 做运行时校验
import Ajv2020 from 'ajv/dist/2020';
import addFormats from 'ajv-formats';
import orderSchema from './order.json' with { type: 'json' };
const ajv = new Ajv2020({
allErrors: true,
strict: true,
allowUnionTypes: true,
});
addFormats(ajv);
const validateOrder = ajv.compile(orderSchema);
export function assertOrder(input: unknown): void {
if (validateOrder(input)) {
return;
}
const detail = (validateOrder.errors ?? []).map((errorItem) => ({
path: errorItem.instancePath || '/',
keyword: errorItem.keyword,
message: errorItem.message,
params: errorItem.params,
}));
throw new Error(`订单数据校验失败:${JSON.stringify(detail)}`);
}
要点:
compile只做一次,放到模块顶层;每次请求重新编译会把 10ms 的校验拖成几百毫秒;strict: true能提前发现拼错的关键字(比如把minLength写成minlength),值得开;ajv-formats才会让format: "email"、format: "date-time"真正生效;- 2020-12 需要
ajv/dist/2020入口,别用默认导出。
六、错误信息怎么设计
校验器给的是「哪个关键字失败」,用户要的是「哪个输入框错了」。建议做一层映射:
const FIELD_LABEL: Record<string, string> = {
'/name': '姓名',
'/age': '年龄',
'/tags': '标签',
};
/**
* 把 Ajv 错误转换成可直接展示的中文提示。
* @param errors Ajv 错误列表
* @returns 展示用提示数组
*/
function toMessages(errors: Array<{ instancePath: string; keyword: string; params: unknown }>): string[] {
return errors.map((errorItem) => {
const label = FIELD_LABEL[errorItem.instancePath] ?? errorItem.instancePath;
if (errorItem.keyword === 'required') {
const missing = (errorItem.params as { missingProperty: string }).missingProperty;
return `${label}缺少必填字段 ${missing}`;
}
if (errorItem.keyword === 'minLength') {
return `${label}长度不足`;
}
return `${label}校验失败(${errorItem.keyword})`;
});
}
接口层则保留机器可读结构,让前端自己决定展示方式:
{
"type": "https://bbdrug.com/problems/validation",
"title": "请求数据校验失败",
"status": 422,
"errors": [
{ "path": "/items/0/price", "keyword": "pattern", "message": "必须是两位小数的金额字符串" }
]
}
七、性能与陷阱
- 校验成本与 schema 复杂度、数据规模成正比;
oneOf多分支会导致组合爆炸,几千条数据时非常明显; uniqueItems对数组做 O(n²) 深比较,大数组应改成业务侧去重;pattern里避免灾难性回溯写法((a+)+$),这是 ReDoS 的经典入口;additionalProperties: false与allOf一起用时,子 schema 声明的字段会被父级判为「额外」,需要合并成单个 schema 或用unevaluatedProperties。
八、检查清单
- [ ] 固定使用 2020-12,并在文件顶部写
$schema; - [ ] 用
$defs+$ref抽公共结构,不复制粘贴; - [ ] 判别式优先于
oneOf; - [ ] 校验器
compile结果复用,并开启严格模式; - [ ]
format是否强制校验在文档里写清楚; - [ ] 错误响应同时提供路径、关键字与面向用户的提示;
- [ ] schema 进 CI:用 JSON Schema Test Suite 的用例回归。
常见问题
如何选择 JSON Schema 草稿版本?
新项目固定 2020-12,并在文件顶部写明 $schema。如果 schema 要进 OpenAPI 3.1,也必须是 2020-12,因为 3.1 直接内嵌这一版;只有仍在维护老工具链的项目才继续用 draft-07。两者在 items 与 $ref 同级关键字上的行为不同,同一套 schema 别混着用。
为什么 additionalProperties: false 会放大兼容问题?
它把未声明的键判为错误,客户端多传一个字段就整体失败。接口演进时所有调用方必须同步升级,否则旧客户端会被新 schema 拒绝。配置文件类 schema 应显式关闭额外字段,公开接口则要权衡兼容成本。
是否应该用 oneOf 还是 anyOf?
只有要求数据恰好命中一个分支时才用 oneOf。若分支约束可能同时成立,一条数据会命中多个分支,oneOf 直接判失败;此时改用 anyOf,或在分支里加 const 判别字段。