JSON 工具箱

JSON Schema 从入门到工程化

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

进阶约 14 分钟发布于 2026-10-30更新于 2026-10-30
JSON Schema 从入门到工程化封面

结论:先固定 $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。

三个高频误区:

  1. required 只要求键存在,{"name": ""} 是合法的;要拒绝空串得加 minLength: 1;
  2. additionalProperties: false 会放大兼容问题:客户端多传一个字段就整体失败,接口演进必须同步升级所有调用方;
  3. 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 判别字段。

参考资料

相关文章

Schema 与 TypeScript 类型双向同步

类型和 schema 各管一段:一个在编译期,一个在运行时。问题不是选哪个,而是如何让两者自动保持一致。

进阶约 11 分钟更新于 2026-10-30

JSON API 数据契约设计要点

六件事一旦上线就很难改:响应包裹、错误格式、分页形态、ID 与时间的表示、null 与缺省的语义、版本演进。

进阶约 13 分钟更新于 2026-10-30

additionalProperties 不关,Schema 校验等于没校验

校验通过不代表数据干净。Pydantic 默认忽略未知字段,Zod 默认剥离,Ajv 默认放行——模型编造的参数、拼错的键都会静默穿过。本文用实测说明静默放行的三种形态,并给出关闭对象、控制强转、逐层收口的完整写法。

进阶约 8 分钟更新于 2026-11-02