自动生成的 JSON Schema 为什么总在拒绝真实数据
结论:把示例 JSON 丢进生成器,得到的 schema 往往一上线就拦掉正常数据。本文用五条同形状记录做对照实测,说明「样本越全反而越糟」的原因,并给出多条采样、语义补全、错误定位的完整落地流程。

结论:先人工把必填与可选边界收敛——一次喂 5~20 条多形态记录、关掉「全部必填」——再生成 schema,并手工补 format、enum 与 additionalProperties;只贴一条全字段样本必然过严。
生成器只看得见你贴进去的那一条记录。它把「这一条里有 email」读成了「每条都必须有 email」,于是你越贴越全,它要求越多。
一、生成器做的是结构映射,不是理解业务
把一段 JSON 交给生成器,它做的事只有一件:走一遍值的结构,把每个节点翻译成 schema 关键字。整个映射表是固定的:
| 样本里的值 | 推断出的类型 | 说明 |
|---|---|---|
"hello" |
{"type": "string"} |
不区分邮箱、日期、UUID |
42 |
{"type": "number"} |
整数不会推断成 integer |
3.14 |
{"type": "number"} |
同上 |
true |
{"type": "boolean"} |
— |
null |
{"type": "null"} |
只认 null,不认「可空」 |
[1, "a"] |
{"type": "array", "items": {"type": ["number", "string"]}} |
元素类型取并集 |
{"a": 1} |
{"type": "object", "properties": {...}, "required": [...]} |
required 是这里的关键 |
问题全在最后一行。required 不是从类型推出来的,而是从「这个键在这份样本里存在」推出来的。样本是一条记录时,这句话等价于「每个键都必须有」。
而真实接口返回的数据,几乎总有一半字段是可选的。这就是冲突的起点。
二、五条同形状记录,五种接受率
先看实测。下面五条记录,任何人一眼都会说「这是同一种形状」:
[
{
"id": 1,
"name": "Ada",
"email": "ada@example.com",
"tags": ["a"],
"score": 10
},
{ "id": 2, "name": "Bob", "tags": [], "score": 7 },
{
"id": 3,
"name": "Cy",
"email": "cy@example.com",
"tags": ["a", "b"],
"score": 8.5
},
{ "id": 4, "name": "Dee", "email": null, "tags": ["c"], "score": 9 },
{
"id": 5,
"name": "Eve",
"email": "eve@example.com",
"tags": ["d"],
"score": 6,
"admin": true
}
]
email 在 5 条里出现 3 次,admin 只出现 1 次,第 2 条的 tags 是空数组。
现在逐条单独生成 schema,再拿生成结果去校验全部 5 条(校验器为 ajv 8):
| 用哪条生成 | 键数 | 生成的 required |
接受 5 条中的 |
|---|---|---|---|
| 第 1 条 | 5 | id, name, email, tags, score |
3 条 |
| 第 2 条 | 4 | id, name, tags, score |
5 条 |
| 第 3 条 | 5 | id, name, email, tags, score |
3 条 |
| 第 4 条 | 5 | id, name, email, tags, score |
1 条 |
| 第 5 条 | 6 | id, name, email, tags, score, admin |
1 条 |
五份 schema 没有一份是对的,但唯一一份能接受全部数据的,来自第 2 条——那条缺了 email 的记录。它赢的原因很朴素:没出现的键不会被写进 required。
第 4 条更典型:它的 email 值是 null,生成器就写死成 {"type": "null"},于是另外 4 条带字符串邮箱的记录全被拒。
三、为什么「贴最全的那条」是最糟的选择
直觉是「贴字段最全的样本,schema 才完整」。实测正好相反,而且有个第三方做过更大规模的对照。
absurdtools 用 3000 个随机「数据家族」、每个家族 8 条记录、可选字段各约一半出现,测了不同键数的样本生成出的 schema 能接受多少数据:
| 样本键数 | 能接受该家族数据比例 |
|---|---|
| 2 | 100.0% |
| 3 | 56.8% |
| 4 | 34.8% |
| 5 | 23.4% |
| 6 | 17.7% |
| 7 | 14.9% |
结论不是「略有下降」,而是完全反转:最简样本在 95% 的家族里给出了最宽松的 schema,最全样本只有 0.1% 做到。
原因在于 required 的语义是「所有」,而可选性只能通过「缺失」被观察到。你贴一条全字段记录,等于告诉生成器「这些键一个都不能少」;你贴一条缺字段记录,反而给了它「这个键可以不出现」的证据。
一句话记住:样本的价值不在完整,在于覆盖了哪些缺失。
到底采多少条才够
同一批实验还测了收敛速度:按「一个键只有至今每条都有才保持必填」的规则逐条累加,required 收敛到真实必填集合的中位记录数是 3 条,6 条能覆盖约九成家族,12 条覆盖全部。
所以日常采样 5~10 条是性价比最高的区间,硬凑二十条以上收益已经很小。
但有个前提:这些记录必须来自不同的真实情形。5 条由同一条模板复制出来的记录,从第 2 条起就不再提供任何新信息——它们的字段集合完全一样,required 一条都收不窄。
四、正解:一次喂多条,让缺失自己说话
既然单条采样必然过严,正确做法就是让生成器同时看多条记录,用「每条都有才算必填」的规则收敛 required。
本站的生成器支持直接贴数组,并且有一个「全部必填」开关。关掉之后,它按出现次数判定:只有每条记录都带的键,才写进 required。
还是那 5 条记录,这一次把它们组成数组一起推断,再取 items 作为元素契约:
| 输入的记录数 | 推断出的 required |
校验全部 5 条 |
|---|---|---|
| 1 条 | id, name, email, tags, score |
3 条通过 |
| 2 条 | id, name, tags, score |
4 条通过 |
| 3 条 | id, name, tags, score |
4 条通过 |
| 4 条 | id, name, tags, score |
5 条通过 |
| 5 条 | id, name, tags, score |
5 条通过 |
第 4 条记录进来时 required 就收敛了,之后不再变化。email 和 admin 被正确地放进 properties、不放进 required。而且因为第 4 条的 email 是 null,多条推断把它合成了 {"type": ["string", "null"]},字符串和 null 都能过——这是单条采样做不到的。
这与 absurdtools 测到的「中位 3 条记录后 required 收敛」是同一量级。
不过多条推断也有它自己的盲点:如果这几条记录里的 email 恰好全是 null(比如都取自没填资料的账号),推断结果就会写成 {"type": "null"},带字符串的邮箱照样被拒。同理,tags 若全是空数组,元素类型就无从推断。
所以采样还有一条硬要求:每个字段的每种形态都至少出现一次。字符串、数字、null、空数组各来一条,别让某一类形态整体缺席。
具体操作:
- 从日志、Mock 或真实请求里捞 5~20 条返回,务必包含异常行(空字段、null、空数组那条);
- 组造成 JSON 数组,贴进生成器;
- 关掉「全部必填」,草案选 2020-12;
- 生成结果是
type: array加一个items,取items那一段作为单条记录的契约; - 立刻用这份 schema 校验原始样本,必须 100% 通过——有一条过不了,说明采样还不够,回去补记录。
第 5 步是最省事的自检。生成器如果连喂给它的数据都容不下,那就是采样方式错了,不用往下走。
五、生成器补不上的四件事,只能手写
即使喂了多条记录,下面这些仍然推不出来,必须人工补:
| 缺失项 | 实测表现 | 后果 | 补法 |
|---|---|---|---|
| 字符串格式 | "a@b.com" 只生成 {"type": "string"} |
校验 "不是邮箱" 通过 |
加 "format": "email" 并开启格式断言 |
| 范围与枚举 | 看不出 score 只能是 0–100,也看不出 status 只有三种值 |
负数、超大值、乱填的枚举全部通过 | 加 minimum / maximum / enum |
| 未知字段策略 | 不生成 additionalProperties |
拼错的键被静默接受,比报错更危险 | 配置文件类加 "additionalProperties": false |
| 可空与空集合语义 | 单条采样遇到 null 就锁死成 {"type": "null"};空数组生成 "items": {} |
正常值被拒,或数组元素完全不受约束 | 改成 ["string", "null"],空数组补真实元素 |
第三行值得单独说:additionalProperties 的默认值是放行。这意味着把 timeout 写成 timeuot 的配置文件,校验会通过,然后在运行时静默失效。对配置类 schema,这是必须显式关掉的一项。
format 也有个坑:从 draft 2019-09 起,format 属于注解词汇,默认只做注释不强制校验。加了 format: "email" 却还能通过非法值,八成是校验器没开 validateFormats 或对应的格式插件,不是 schema 写错了。
顺带一句草案选择:schema 若要进 OpenAPI 3.1,必须用 draft 2020-12,因为 3.1 直接内嵌这一版;老工具链(Swagger 2.0 生态、部分 $ref 实现)仍以 draft-07 为主。两者在 $ref 同级关键字和数组相关关键字上的行为不同,同一套 schema 别混着用。
六、没有文档的第三方接口,用响应样本反推契约
接第三方回调、对接不给自己写文档的合作方、维护十年前的老系统,都会遇到「数据在手、规范没有」。这时生成器不只是省事,它是拿到契约最快的路径。
做法与前面一致,但多两个必须:
一、跨时间、按状态采样。 第三方字段经常随业务状态变化——促销期多出 discount,退款单多出 refundAt,错误响应里干脆没有 data。只抓一次,等于只看到一个状态。
成功、失败、空结果各来几条一起推,error 和 data 往往互斥出现,多条推断会把两者都放进 properties 而不放进 required,正好符合实际。
二、把 schema 存进仓库,接一个每日校验。 拿当天真实响应去校验,字段悄悄消失或悄悄改类型时,你会比用户先知道。
// 每天跑一次:契约漂移告警
const drifted = records.filter((record) => !validate(record));
if (drifted.length > 0) {
console.warn(
`${drifted.length} 条响应与契约不符,首条差异:`,
validate.errors,
);
}
这套做法的价值不在「省了手写」,而在把口头约定变成了可执行的告警。第三方改字段从来不提前通知你,但一定会在你的日志里出现。
有个边界要划清:反推出来的 schema 描述的是「对方现在返回什么」,不是「对方承诺返回什么」。前者用来监控,后者才该写进合同或接口文档。
七、补全之后,让校验错误指回具体字段
schema 能用了,接下来是报错可读性。ajv 的错误对象有五个字段,真正有用的是前四个:
| 字段 | 含义 | 示例(id: 0 违反 minimum: 1) |
|---|---|---|
instancePath |
出错值在数据里的 JSON Pointer | "/id" |
schemaPath |
触发的规则在 schema 里的位置 | "#/properties/id/minimum" |
keyword |
失败的约束名 | "minimum" |
params |
该约束的机器可读细节 | { "comparison": ">=", "limit": 1 } |
message |
英文散文,跨版本会变,不要解析它 | "must be >= 1" |
有一个不对称最容易踩:required 失败时,instancePath 指向的是父对象(通常是空字符串,即根),不是缺失的那个字段。缺失的键名在 params.missingProperty 里。同理,additionalProperties 失败时,多出来的键名在 params.additionalProperty。
所以把错误映射成「字段 → 消息」时,必须先处理这两个特例:
import Ajv from "ajv";
const ajv = new Ajv({ allErrors: true });
const validate = ajv.compile(schema);
/** 把 ajv 错误数组转成 { 字段路径: [消息] },供表单高亮 */
function groupByField(errors) {
const grouped = {};
for (const error of errors) {
// required 与 additionalProperties 的错位字段在 params 里,不在 instancePath 上
const field =
error.keyword === "required"
? error.params.missingProperty
: error.keyword === "additionalProperties"
? error.params.additionalProperty
: error.instancePath.replace(/^\//u, "").split("/").join(".") ||
"(root)";
(grouped[field] ??= []).push(error.message);
}
return grouped;
}
if (!validate(data)) console.log(groupByField(validate.errors));
allErrors: true 不能省。默认配置下 ajv 碰到第一个错误就停,用户改完一个再提交、再报下一个,来回三趟才填对一张表。如果还要用 ajv-errors 做自定义文案,少了这个开关,插件会静默只替换部分消息。
八、一条能复用的落地流程
把上面几步串成固定动作,以后每次接新接口照着走:
- 采样:抓 5~20 条真实返回,专门挑有空字段、null、空数组、多字段的那几条;
- 推断:数组一起贴进生成器,关「全部必填」,取
items; - 补语义:
format、enum、minimum/maximum、minItems、additionalProperties,一项一项加; - 自检:原样本必须 100% 通过,否则回第 1 步补样本;
- 反证:每个约束都准备一条「应该失败」的数据,确认它真的失败——只测通过路径,等于没测;
- 入库:schema 进版本库,失败样本留成测试用例,接口改字段时先跑测试。
第 5 步的反向测试最容易漏。required 加了一个键却没验证过缺失时会不会报错,等于赌校验器理解一致。
import { describe, expect, it } from "vitest";
describe("user schema", () => {
it("接受完整记录", () => {
expect(validate({ id: 1, name: "Ada", tags: [], score: 10 })).toBe(true);
});
it("拒绝缺少 name 的记录", () => {
expect(validate({ id: 2, tags: [], score: 7 })).toBe(false);
// 同时断言失败原因,避免「因为别的字段错了才失败」
expect(
validate.errors?.some((e) => e.params?.missingProperty === "name"),
).toBe(true);
});
});
最后一句断言很关键。只断言 false,可能在 schema 改坏之后依然绿——它失败的原因可能早就换了一个。
九、常见问题
生成器出的 schema 能直接上生产吗
不能,它只是结构的快照。至少要补格式、范围、枚举和未知字段策略,再用原始样本和反向样本各跑一遍。生成器解决的是「手敲 properties 太烦」,不是「业务规则由它决定」。
写 schema 的关键字体系、$ref 拆分和组合关键字,见JSON Schema 从入门到工程化。
为什么我贴了字段最全的一条,反而拒绝更多数据
因为 required 是按「样本里出现过」收集的。全字段样本把每个可选键都变成了必填项,而真实数据里这些键大多不在。改贴多条记录,或者贴一条故意缺字段的记录,都比贴最全的更接近真相。
整数会被推断成 integer 吗
本站的生成器不会,整数和浮点都输出 number,所以 {"price": 5} 生成的 schema 能接受 5.99。这省掉了「样本恰好是整数、结果上线后拒绝带小数的价格」这类事故。部分第三方工具按值推断 integer,遇到金额字段要特别小心。
数字精度与类型边界的细节,见JSON 六种数据类型与数字精度陷阱。
加了 format 却没生效
draft 2019-09 之后 format 默认只做注解。要么在校验器里显式打开格式断言(ajv 需装 ajv-formats),要么直接用 pattern 硬约束。别假设加了就会拦。
required 报错,但数据里明明有这个字段
先看 instancePath。required 报错指向的是父对象,说明缺失的键不在这一层——常见原因是嵌套层级错了(/profile/name 不等于根上的 name)、大小写不同(userId 与 userID),或者上游序列化时把 undefined 整个丢掉了。
排查前先确认文档本身是合法 JSON,注释、尾逗号这类问题会在解析阶段就失败,轮不到 schema。JSON 解析格式化能直接指出第几行出错。
如何确定采样条数与 JSON Schema 草稿版本?
日常采样 5~10 条性价比最高,required 的中位收敛点是 3 条,12 条可覆盖全部家族;每条记录都要来自不同情形,并让每个字段的每种形态至少出现一次。草案固定 2020-12,因为要进 OpenAPI 3.1 时只认这一版。
为什么 additionalProperties 默认放行反而更危险?
默认值不生成该关键字,拼错的键会被静默接受,校验通过后在运行时失效,比直接报错更难排查。对配置类 schema 必须显式写 "additionalProperties": false,接口类则要先评估兼容成本。
是否应该用反推出来的 schema 做硬性拦截?
不应该。反推的 schema 描述的是对方「现在」返回什么,不是「承诺」返回什么,只适合做监控与漂移告警。要写进合同或接口文档的,必须是对方承诺的契约。