JSON Schema 验证:在 Node、Python 和浏览器里校验 JSON(2026)
速答:JSON Schema 是 JSON 数据的契约。你声明字段类型、必填键和约束,校验器据此判断任意一份 JSON 文档是否符合契约。Node 端用 Ajv 拿到最快的校验速度,Python 端用 jsonschema 库写跨语言可移植的 schema,浏览器端打包 Ajv 就能给表单和配置做即时反馈。2026 年新项目首选 Draft 2020-12。
接下来是最小可运行示例、三种运行时的端到端模式,以及那些会让「校验通过但生产环境拒绝数据」的真实陷阱。
JSON Schema 的边界
一句话定义
JSON Schema 本身就是一份 JSON 文档,用来描述其他 JSON 文档的形状。校验器读取 schema 与数据,确认两者是否一致,并返回失败的路径。
最小可用示例:
{
"$schema": "https://json-schema.org/draft/2020-12/schema",
"type": "object",
"properties": { "name": { "type": "string" } },
"required": ["name"]
}
{"name": "Alice"} 通过。{"age": 30} 失败(缺少 name)。{"name": 42} 失败(name 不是字符串)。心智模型就这么简单。
JSON Schema 与 JSON 语法校验
两个常被混淆的不同问题。
| 维度 | JSON 语法检查 | JSON Schema 校验 |
|---|---|---|
| 检查什么 | 这是合法的 JSON 文档吗? | 这份 JSON 是否符合契约? |
| 能捕获的问题 | 缺逗号、单引号、注释 | 类型错误、缺必填字段、值越界 |
| 工具 | JSON.parse()、JSON 格式化工具 | Ajv、jsonschema(Python)、fastjsonschema |
| 何时使用 | 解析前的第一步 | 解析后、业务逻辑前 |
实际工作里两步都得做:先用 JSON 格式化工具 美化负载确认能解析,再用 schema 校验是否符合契约。
JSON Schema 与 JSONPath、JSON Patch、jq、TypeScript
这五种工具占据相邻的问题空间。决策矩阵如下:
| 工具 | 解决什么问题 | 何时选用 |
|---|---|---|
| JSON Schema | 这份 JSON 是否符合预期结构? | 校验 API 输入、配置文件、表单负载 |
| JSONPath | 怎么从 JSON 里查询某个值? | 提取嵌套字段、批量读取 |
| JSON Patch(RFC 6902) | 怎么描述 A 到 B 的差异? | 协作编辑、增量同步 |
| jq | 怎么在命令行处理 JSON? | Shell 脚本、日志管道、CI 检查 |
| TypeScript 类型 | 我的代码是否正确使用了这种形状? | 单一代码库内部的编译期保障 |
最关键的分界线是这一条:JSON Schema 在运行时校验未知数据,TypeScript 在编译时校验已知代码。第三方 webhook 推过来的 JSON、用户粘贴的 JSON,TypeScript 帮不上忙,那正是 JSON Schema 的用武之地。Zod 与 Pydantic 介于两者之间,既给编译期类型也跑运行时校验,下文展开。
JSON Schema 与 OpenAPI
一个常见误解:OpenAPI 替代了 JSON Schema。其实没有。OpenAPI 内部正是用 JSON Schema 来描述请求体和响应体,再在外面叠上路径、参数、安全方案和服务器 URL。schema 是数据形状契约,OpenAPI 是把它包起来的 API 契约。
| 维度 | JSON Schema | OpenAPI |
|---|---|---|
| 范围 | 单份 JSON 文档的形状 | 整个 HTTP API 的形状 |
| 依赖 | 无(schema 是自包含的 JSON) | 引入 JSON Schema 来描述请求/响应体 |
| 版本对应 | Draft 7 / Draft 2019-09 / Draft 2020-12 | OpenAPI 3.0 用 Draft 4 子集;OpenAPI 3.1 原生使用 Draft 2020-12 |
| 典型用途 | 配置文件、消息封装、表单校验、单负载契约 | REST API 设计、SDK 生成、Mock 服务、契约测试 |
| 代码生成 | 有限(少数 quicktype 类工具) | 生态成熟(openapi-generator、oapi-codegen、厂商 SDK) |
| 契约管理 | 一份 schema 对应一种形状,无路由 | 路径、操作、鉴权流、版本化端点都集中在一份文档里 |
当你关心的产物只是一份单独的文档时,直接用 JSON Schema:webhook 负载、配置文件、队列消息、表单。没有 HTTP 表面要描述,OpenAPI 只是多余开销。
当你要发布一个 HTTP API,并希望一份文档同时驱动文档站、SDK 生成、Mock 服务和契约测试时,选 OpenAPI。先把 schema 作为独立的 JSON Schema 文件放在 schemas/ 目录,再在 OpenAPI 文档里通过 $ref 引用。这样 schema 在 API 之外仍可复用。
版本对应这件事最容易让团队踩坑。OpenAPI 3.0 用的是 Draft 4 子集,所以你不能在 3.0 文档里使用 prefixItems、unevaluatedProperties 这类 Draft 2020-12 关键字,生成器会静默忽略它们。OpenAPI 3.1 是 Draft 2020-12 的超集,凡是 2020-12 合法的写法在 3.1 里都合法。如果可以选,目标对准 OpenAPI 3.1,所有 schema 一律按 Draft 2020-12 来写。
你的第一份 JSON Schema(5 分钟)
必备的关键字
下面这些就能覆盖 80% 的场景:
{
"type": "object",
"properties": {
"id": { "type": "integer", "minimum": 1 },
"email": { "type": "string", "format": "email" },
"age": { "type": "integer", "minimum": 0, "maximum": 150 },
"tags": { "type": "array", "items": { "type": "string" }, "minItems": 1 },
"role": { "enum": ["admin", "editor", "viewer"] },
"metadata": { "type": "object", "additionalProperties": true }
},
"required": ["id", "email"],
"additionalProperties": false
}
词汇表:
type:string、number、integer、boolean、null、array、objectproperties和required:声明字段并标记哪些必须存在enum与const:限定为固定集合或单个字面量minimum、maximum、multipleOf:数值边界minLength、maxLength、pattern:字符串长度与正则minItems、maxItems、uniqueItems:数组形状additionalProperties: false:拒绝未声明的键。输入契约一律开启
按使用场景看 JSON Schema 示例
上面这些关键字会在不同场景下以不同组合出现。这里给出几种有代表性的形状:
API 请求体——一个接受邮箱和密码的注册端点:
{
"type": "object",
"properties": {
"email": { "type": "string", "format": "email" },
"password": { "type": "string", "minLength": 8, "maxLength": 128 }
},
"required": ["email", "password"],
"additionalProperties": false
}
配置文件——把日志级别锁定在固定集合里的 logger 配置:
{
"type": "object",
"properties": {
"level": { "enum": ["debug", "info", "warn", "error"] },
"output": { "type": "string", "default": "stdout" }
},
"required": ["level"],
"additionalProperties": false
}
带条件规则的表单负载——当 accountType 是 "business" 时,taxId 变为必填:
{
"type": "object",
"properties": {
"accountType": { "enum": ["personal", "business"] },
"taxId": { "type": "string" }
},
"if": { "properties": { "accountType": { "const": "business" } } },
"then": { "required": ["taxId"] }
}
CSV 行的 JSON 记录——导出订单表的一行:
{
"type": "object",
"properties": {
"orderId": { "type": "string", "pattern": "^ORD-[0-9]{6}$" },
"orderedOn": { "type": "string", "format": "date" },
"totalUsd": { "type": "number", "minimum": 0 }
},
"required": ["orderId", "orderedOn", "totalUsd"]
}
Webhook 事件封装——oneOf 按 type 字面量分发,让每种事件变体拥有自己的负载形状:
{
"oneOf": [
{ "properties": { "type": { "const": "order.created" }, "data": { "$ref": "#/$defs/order" } } },
{ "properties": { "type": { "const": "order.refunded" }, "data": { "$ref": "#/$defs/refund" } } }
]
}
这五个示例覆盖了团队在实践中要写的绝大多数 schema。挑最接近的那一个复制过来,把字段名换掉,关键字词汇表本身保持不变。
不装任何东西也能校验
把 schema 和负载粘到 ajv.js.org 或 jsonschemavalidator.net 的在线 playground,立刻得到结论。如果 JSON 本身看起来可疑,先丢进 JSON 格式化工具 走一遍。
在 Node.js 里用 Ajv 校验
安装与 12 行示例
Ajv 在第一次 compile 时把 schema 编译成一个优化过的函数,之后反复复用。
npm install ajv
import Ajv from "ajv";
const ajv = new Ajv();
const schema = {
type: "object",
properties: {
name: { type: "string" },
age: { type: "integer", minimum: 0 }
},
required: ["name"]
};
const validate = ajv.compile(schema);
const data = { name: "Alice", age: 30 };
if (!validate(data)) console.log(validate.errors);
else console.log("OK");
切换到 Draft 2020-12
为了向后兼容,默认的 Ajv 构造函数仍然锁定在 Draft 7。要使用 2020-12,得显式选用:
import Ajv2020 from "ajv/dist/2020";
const ajv = new Ajv2020({ strict: true, allErrors: true });
这样 prefixItems、unevaluatedProperties 和 $dynamicRef 才会启用。各自的作用见后文 Draft 2020-12 那一节。
启用 format 校验
这条让无数开发者翻车,是 Ajv 最常见的怪点:默认情况下 format: "email" 什么都不做。规范把 format 当作建议性,所以你必须手动注册 format 模块。
npm install ajv-formats
import addFormats from "ajv-formats";
addFormats(ajv); // 现在 "format": "email" 才真正生效
跳过这一步,{"email": "not-an-email"} 也能通过一份要求 format: "email" 的 schema。生产环境一律装上 ajv-formats。
在 Express 里实战
每条路由一个校验器,启动时编译完成:
import express from "express";
import Ajv2020 from "ajv/dist/2020";
import addFormats from "ajv-formats";
const ajv = new Ajv2020({ allErrors: true });
addFormats(ajv);
const validateUser = ajv.compile({
type: "object",
properties: {
email: { type: "string", format: "email" },
age: { type: "integer", minimum: 13 }
},
required: ["email"],
additionalProperties: false
});
const app = express();
app.use(express.json());
app.post("/users", (req, res) => {
if (!validateUser(req.body)) {
return res.status(400).json({ errors: validateUser.errors });
}
// ... 业务逻辑
res.status(201).json({ ok: true });
});
最昂贵的单一错误:在请求处理函数里调用 ajv.compile(schema)。请在模块作用域里编译一次,复用返回的函数。每次请求都重新编译,吞吐量会下降 50 倍以上。
在 Python 里用 jsonschema 校验
安装与基础用法
pip install jsonschema
from jsonschema import validate, ValidationError
schema = {
"type": "object",
"properties": {
"name": {"type": "string"},
"age": {"type": "integer", "minimum": 0}
},
"required": ["name"]
}
try:
validate(instance={"name": "Alice", "age": 30}, schema=schema)
print("OK")
except ValidationError as e:
print("FAIL:", e.message, "at", list(e.absolute_path))
用 Draft202012Validator 收集所有错误
validate() 遇到第一个错误就抛出。要一次性列出所有问题(适合表单返回),改用 iter_errors:
from jsonschema import Draft202012Validator
validator = Draft202012Validator(schema)
errors = sorted(validator.iter_errors(instance), key=lambda e: e.path)
for err in errors:
print(f" - {'/'.join(map(str, err.absolute_path))}: {err.message}")
这样用户能一次性把所有问题改完,不必反复来回。
jsonschema 与 Pydantic 怎么选
两个都很强的 Python 库,解决两种不同的问题。
| 维度 | jsonschema | Pydantic v2 |
|---|---|---|
| Schema 形态 | 一个 JSON 字典(schema 是数据) | 一个带类型注解的 Python 类 |
| 性能 | 解释执行,比 Pydantic 慢 10–100 倍 | Rust 内核,目前最快 |
| 跨语言可移植 | 是(同一 schema 在 JS、Go、Rust 都能跑) | 否(仅 Python) |
| FastAPI 与原生模型集成 | 需要手动转换 | 内建 |
Draft 2020-12 关键字($dynamicRef 等) | 完整支持 | 部分支持 |
生产环境验证过的经验法则:跨语言契约(OpenAPI、公开 API、webhook)用 jsonschema,内部 Python 服务用 Pydantic。两者并用也常见:网关层用 jsonschema 强制契约,应用层用 Pydantic 做类型化的业务逻辑。schema 是可移植的产物,跟你喂给 Ajv 的那份完全一样。
在浏览器里校验
客户端为什么也要校验
按重要性排序,三个理由。第一是 UX,用户输入时即时反馈,比来回服务器更顺。第二是带宽,明显错误根本不出浏览器。第三是安全卫生,可以减少打到后端的垃圾流量,但不替代服务端校验。
千万别只信任客户端校验。服务端必须再校验一次。
在浏览器里打包 Ajv
npm install ajv ajv-formats
import Ajv2020 from "ajv/dist/2020";
import addFormats from "ajv-formats";
const ajv = new Ajv2020({ allErrors: true });
addFormats(ajv);
export const validateForm = ajv.compile({
type: "object",
properties: {
email: { type: "string", format: "email" },
password: { type: "string", minLength: 8 }
},
required: ["email", "password"]
});
打包体积大约多出 30 KB(gzip 后),不算小,但远未到灾难级。希望前后端共用一份 schema 定义的团队,会愿意付这个成本。
更轻的选择:Zod 和 Valibot
如果你不需要 JSON Schema 生态,并且本身就在 TypeScript 里,TS 原生校验器能给你更小的包体和更紧的类型推断:
import { z } from "zod";
const UserSchema = z.object({
email: z.string().email(),
password: z.string().min(8)
});
const result = UserSchema.safeParse(data);
if (!result.success) console.log(result.error.issues);
Valibot 体积大约 3 KB(gzip 后),API 类似,对包体最敏感时它最合适。但要注意,这两个库都不产出 JSON Schema。如果你需要一份能跟后端、第三方客户端或 OpenAPI 生成器共享的单一来源,请坚持用 Ajv。如果整条链路都是你自家的 TypeScript,Zod 和 Valibot 更顺手。
Draft 2020-12 带来什么
用 prefixItems 校验元组
Draft 7 通过 items: [] 加 additionalItems 来表达元组。Draft 2020-12 把它们干净拆开:
{
"type": "array",
"prefixItems": [
{ "type": "string" },
{ "type": "number" }
],
"items": false
}
["x", 42] 通过,["x", 42, "extra"] 失败。schema 读起来跟它实际做的事情完全一致。
用 unevaluatedProperties 处理组合 schema
每个用 allOf 或 oneOf 的团队都踩过这个微妙的坑:additionalProperties: false 只检查它出现的那一层。allOf 内部的兄弟子 schema 想声明什么属性都行。2020-12 的解法是 unevaluatedProperties: false:
{
"allOf": [
{ "$ref": "#/$defs/base" }
],
"unevaluatedProperties": false
}
它会拒绝所有未被任何分支评估过的属性,这才是大多数开发者本来期待 additionalProperties: false 该有的行为。
用 $dynamicRef 描述递归 schema
在 Draft 7 里写过递归树 schema 的人,应该都熟悉那种扭来扭去的痛苦。$dynamicRef 加 $dynamicAnchor 把它清理干净:
{
"$dynamicAnchor": "node",
"type": "object",
"properties": {
"value": { "type": "string" },
"children": { "type": "array", "items": { "$dynamicRef": "#node" } }
}
}
递归声明清晰,子孙 schema 也可覆写,无需重写 $id。
Draft 7 还是 2020-12
新项目、现代工具链选 Draft 2020-12。构建或消费 OpenAPI 3.1 时它是原生方言。还在跟 OpenAPI 3.0 或更老的服务打交道,就用 Draft 4(OpenAPI 3.0 用的是 Draft 4 子集,切勿混合方言)。需要广泛的校验器兼容(Postman、老旧 CI 工具)时,Draft 7 仍是最稳妥的交换格式。
如今所有现代校验器(Ajv、Python jsonschema、jsonschema-rs、Java 的 networknt/json-schema-validator)都已支持 2020-12。
真实世界的实战模式
API 输入校验
上面那段 Express 中间件就是生产级形态。再补两条实践:把所有 schema 集中在仓库根目录的 schemas/ 文件夹,并在 CI 里加一步 ajv test(或 Python 等价命令),让 schema 自身也用 JSON Schema 元 schema 校验一次。
配置文件
Visual Studio Code 内置了 SchemaStore 集成,package.json、tsconfig.json 这类几十种文件都能自动补全和内联校验。在你自己的配置里加一个 $schema 字段,编辑器用户也能享受同样的待遇。
CI 测试夹具
测试夹具会腐烂。有人改了模型,夹具还停留在旧形状,测试照样通过,因为断言压根没碰那个改过的字段。在断言之前加一道 schema 检查,就能拦住它:
import { glob } from "glob";
const files = await glob("__tests__/fixtures/*.json");
for (const f of files) {
const data = JSON.parse(await fs.readFile(f, "utf8"));
if (!validate(data)) throw new Error(`${f}: ${ajv.errorsText(validate.errors)}`);
}
schema 检查报警之后,下一步通常是看结构差异。把夹具丢进 JSON 对比工具,跟一份新鲜的生产样本对照,看看哪里漂移了。如果 timestamps 和 IDs 把 diff 刷屏,按 JSON Diff 中忽略 timestamps 与 IDs 的快照路径忽略模式 把信号从噪音里分离出来。
Webhook 负载(Stripe、GitHub)
第三方 webhook 是最值得用 JSON Schema 守护的地方之一。webhook 是契约,服务方可能修改契约,你想第一时间知道。Stripe 和 GitHub 都发布 OpenAPI 描述,可以从中抽出 JSON Schema。校验入站事件,破坏性升级会立刻在监控里亮灯,而不是悄悄污染状态。
Schema 驱动的表单校验
React Hook Form 提供了 @hookform/resolvers/ajv 适配器,Vue 的 VeeValidate 也有等价的 Ajv 插件。两者都能用同一份 JSON Schema 驱动表单渲染、错误提示和提交校验。schema 是单一来源,UI 自动继承它的规则。
友好的错误信息
默认信息为什么粗糙
开箱即用时,Ajv 会产出诸如 #/properties/email format must match "email" 这样的错误。给工程师调试 400 还行,给填结账表单的用户看就是天书。
用 ajv-errors 自定义信息
npm install ajv-errors
import ajvErrors from "ajv-errors";
ajvErrors(ajv);
const schema = {
type: "object",
properties: { email: { type: "string", format: "email" } },
required: ["email"],
errorMessage: {
properties: { email: "请输入有效的邮箱地址" },
required: { email: "邮箱不能为空" }
}
};
errorMessage 关键字写在 schema 里,校验规则和面向用户的文案就能一起迁移。
用 ajv-i18n 翻译错误信息
ajv-i18n 提供 30 多种语言对默认信息的翻译。启动时加一行,校验器就能讲西班牙语、法语、日语,或你支持的任意语种。当你的 errorMessage 没覆盖到所有约束时,它是不错的兜底。
把 schema 路径映射到表单字段
每个 Ajv 错误都带一个 instancePath,形如 /users/0/email。多数表单库期望的是点号路径,比如 users[0].email。一行就能搞定:
const fieldPath = error.instancePath.replace(/^\//, "").replace(/\//g, ".");
Python 的 jsonschema 里对应的是 error.absolute_path,用 . 拼接即可达到同样效果。
五个能通过校验却让生产崩溃的陷阱
1. format 默认是建议性的
不装 ajv-formats、也不调用 addFormats(ajv),每个 format 关键字都形同虚设。{"format": "email"} 会接受 "not-an-email"。生产环境一律装 format 包。
2. additionalProperties 默认是 true
不写 additionalProperties: false,schema 会接受所有未声明的字段。客户端可以塞额外字段绕过校验。把 additionalProperties: false 设为输入契约的默认,需要时再有意识地放宽。
3. additionalProperties 不能组合
在 allOf、oneOf、anyOf 里,additionalProperties: false 只检查它自己那一层的属性。兄弟子 schema 全能溜过去。Draft 2020-12 的解法是 unevaluatedProperties: false。
4. 远程 $ref 是生产隐患
$ref: "https://example.com/schema.json" 会让 Ajv 在第一次编译时通过网络拉取。这意味着延迟、远端卡死时的 DoS 风险,以及 MITM 攻击面。把所有 $ref 目标内联,或者在构建期从磁盘加载。
5. 生成的 schema 会跟真实数据漂移
quicktype、typescript-json-schema 这类工具能从已有类型生成 schema,但产出通常过于宽松:每个字段都可选、additionalProperties 大开。把生成的 schema 当作草稿,手动收紧;并在 CI 里用真实生产样本互相校验(数据 vs schema、schema vs 数据),让漂移尽早暴露。
性能:数字与经验法则
- Ajv(Node.js)。编译过的校验器单次校验远低于 1 微秒,目前 JS 阵营里最快。
jsonschema(Python)。解释执行,比 Pydantic 慢 10–100 倍。被这一点拖累时换上fastjsonschema,它会生成 Python 代码,性能逼近 Ajv。- Rust 与 Go。
jsonschema-rs和xeipuuv/gojsonschema在网关层比 Ajv 再快 2–5 倍。 - 最大的单一收益是预编译。模块加载时调用一次
ajv.compile(schema),每个请求复用返回的校验器。每次请求重新编译会让吞吐下降 50 倍以上。
常见问题
用大白话讲,什么是 JSON Schema 验证?
JSON Schema 验证就是检查一份 JSON 文档是否遵守某个契约。契约(即 schema)本身也是 JSON,声明类型、必填字段和约束。校验器读取 schema 与数据,报告「通过」,或者返回失败的路径与原因。
怎么在线把 JSON 跟 schema 校验?
把 schema 和数据粘到 ajv.js.org 的 playground 或 jsonschemavalidator.net,立刻有结论。如果 JSON 看起来格式有问题,先在 JSON 格式化工具 里整理一下。两者都在浏览器里运行,不会上传数据。
2026 年最快的 JSON Schema 校验器是哪个?
在 Node 里,预编译的 Ajv 单次校验低于 1 微秒。在 Python 里,fastjsonschema 通过生成代码达到 Ajv 级吞吐。网关层 jsonschema-rs(Rust)和 gojsonschema(Go)比 Ajv 再快 2–5 倍。无论选哪个,都要预编译一次然后复用。
JSON Schema 与 TypeScript 类型有什么区别?
TypeScript 在编译期检查你写的代码。JSON Schema 在运行期检查未知 JSON。HTTP 响应、文件、用户粘贴里飘来的 JSON,TypeScript 看不见,那正是 JSON Schema 要解决的。
Draft 2020-12 还是 Draft 7?
2026 年的新项目选 Draft 2020-12。prefixItems、unevaluatedProperties 和 $dynamicRef 都解决了真实问题。OpenAPI 3.1 原生使用 2020-12。只有为了兼容 Postman 或老旧服务才留在 Draft 7。OpenAPI 3.0 用的是 Draft 4 子集,切勿混合方言。
怎么从已有 JSON 生成 JSON Schema?
三条路:把样本粘到 quicktype.io 或 jsonschema.net;命令行运行 npx genson-js 或 pip install genson && genson sample.json;或者手写。自动生成的 schema 太宽松(每个字段可选、additionalProperties: true),当作契约前一定要收紧。
JSON Schema 能取代 OpenAPI 吗?
不能。OpenAPI 内部用 JSON Schema 描述请求和响应体,再叠上路径、安全方案、参数和服务器 URL。两者是组合关系:写好 schema、在 OpenAPI 文档里引用它们,就得到完整的 API 契约。
JSON Schema 跟 JSONPath 或 jq 是一回事吗?
不同问题。JSON Schema 校验结构(「这份 JSON 是否符合契约?」)。JSONPath 与 jq 提取值(「Running 阶段的所有 pod 名称」)。校验用 schema,查询用 JSONPath 或 jq。
为什么我的 Ajv 校验通过了,生产却拒绝数据?
三个元凶能解释几乎所有情况:忘了加 ajv-formats,于是 format: "email" 从未真的校验;漏写 additionalProperties: false,让客户端的额外字段溜了进去;在 allOf 或 oneOf 里使用 additionalProperties: false,结果发现它不能组合。换成 unevaluatedProperties: false。
能为终端用户定制 JSON Schema 错误信息吗?
可以。在 Node 里安装 ajv-errors,把 errorMessage 嵌入 schema;用 ajv-i18n 获得 30 多种语言的翻译。在 Python 里,jsonschema 会把完整的校验上下文挂在每个错误对象上,你可以把错误类型加路径映射到设计系统对应的文案。