JSON 工具箱

自动生成的 JSON Schema 为什么总在拒绝真实数据

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

进阶约 14 分钟发布于 2026-10-30更新于 2026-10-30
由 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、空数组各来一条,别让某一类形态整体缺席。

具体操作:

  1. 从日志、Mock 或真实请求里捞 5~20 条返回,务必包含异常行(空字段、null、空数组那条);
  2. 组造成 JSON 数组,贴进生成器;
  3. 关掉「全部必填」,草案选 2020-12;
  4. 生成结果是 type: array 加一个 items,取 items 那一段作为单条记录的契约;
  5. 立刻用这份 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 做自定义文案,少了这个开关,插件会静默只替换部分消息。

八、一条能复用的落地流程

把上面几步串成固定动作,以后每次接新接口照着走:

  1. 采样:抓 5~20 条真实返回,专门挑有空字段、null、空数组、多字段的那几条;
  2. 推断:数组一起贴进生成器,关「全部必填」,取 items;
  3. 补语义:format、enum、minimum/maximum、minItems、additionalProperties,一项一项加;
  4. 自检:原样本必须 100% 通过,否则回第 1 步补样本;
  5. 反证:每个约束都准备一条「应该失败」的数据,确认它真的失败——只测通过路径,等于没测;
  6. 入库: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 描述的是对方「现在」返回什么,不是「承诺」返回什么,只适合做监控与漂移告警。要写进合同或接口文档的,必须是对方承诺的契约。