JSON 推断 Schema 的局限:少量样本能告诉你什么、告诉不了什么

· 约 6 分钟 JSON 转 Schema

JSON Schema 推断工具能在几秒钟里把一段样本 JSON 转成 schema,但直接用推断结果做线上校验几乎一定会出问题——required 太严、null 语义不对、enum 漏推或乱推、嵌套层级失控。这篇讲清自动推断”能告诉你什么、告诉不了什么”,以及人工补刀该聚焦在哪里。

推断工具能做的事

给一段样本 JSON:

{
  "id": 12345,
  "name": "Alice",
  "email": "alice@example.com",
  "tags": ["vip", "early"],
  "address": null
}

工具能自动得出:

  • 字段类型——id 是 integer、nameemail 是 string、tags 是 array of string
  • 字段全集——schema 的 properties 列出所有字段
  • 数组元素类型——遍历样本里的元素求并集
  • 简单格式——日期字符串自动加 format: "date-time"、邮箱加 format: "email"

这些都是机械可推断的事实——只要样本足够多,结论就准。

推断工具做不到的事

下面这些必须依赖业务知识

决策为什么不能纯推断
required样本是观察值,“出现过”≠“必有”
null 语义null 是值还是”代表缺失”,看上下文
enum 候选候选有限还是无界,看业务
数字范围样本里 1-100 ≠ 全集 1-100
字符串约束pattern、长度等都来自业务规则
嵌套深度何处该拆成 $ref 看复用情况

下面逐项讲清楚怎么补刀。

required:从”出现过”到”必有”

默认推断策略:单个样本里所有字段都进 required。

问题

// 单个样本里 metadata 字段恰好有
{ "id": 1, "name": "x", "metadata": { ... } }

// schema 推断成
{ "required": ["id", "name", "metadata"] }

// 真实数据里 50% 没有 metadata → 校验大量失败

正确做法

  1. 多样本求交集——给工具喂 5-10 个真实样本,required 取所有样本都出现的字段
  2. 业务知识补刀——id created_at 必有;note metadata 偏 ad-hoc 不放 required
  3. API 文档对照——服务端代码里看哪些字段是必返的
  4. 保守为先——schema 偏严会大量误报;schema 偏宽只是少抓一些 bad case,可补救

最佳工作流

推断 → 人工删掉看着可选的 → 用 schema 跑 1000 条生产数据 →
  统计 "required missing" 错误 → 把这些字段从 required 移出

null:是值还是缺失代理

JSON 的 null 在不同语义下应该写不同 schema:

语义 1:null 表示”明确没有这个值”

{ "parent_id": null }  // 这是根节点

字段必有,值可以是 null

{
  "type": "object",
  "properties": {
    "parent_id": { "type": ["string", "null"] }
  },
  "required": ["parent_id"]
}

语义 2:null 表示”字段未填”

应该让字段缺席,而不是值为 null:

{
  "type": "object",
  "properties": {
    "note": { "type": "string" }
  }
  // note 不在 required 里
}

工具默认:看到 null 一律推成 ["原类型", "null"]——保守但未必符合业务。

JSON Schema 三种写法

写法适用
"type": ["string", "null"]JSON Schema Draft 6+,OpenAPI 3.1
"type": "string", "nullable": trueOpenAPI 3.0 专用
"anyOf": [{"type": "string"}, {"type": "null"}]通用但冗长

OpenAPI 3.1 已统一用类型联合,新项目直接用 ["string", "null"]

enum:候选有限还是无界

经验阈值:候选 ≤ 10 + 在样本中重复出现 = 适合 enum。

判断维度

  • 业务上是状态机吗?(status: active / inactive / deleted)
  • 候选会随业务增长吗?(color 不会爆,user_id 会爆)
  • 样本里同候选重复多次吗?(重复表示候选稳定)

工具默认不主动推 enum——风险太大。错的代价(“city: 北京”被推成 enum,所有非北京数据失败)远大于对的收益。

手动加 enum 的时机

// ✅ 适合 enum
{ "status": "active" }
{ "status": "inactive" }
{ "status": "deleted" }

// schema:
{ "enum": ["active", "inactive", "deleted"] }
// ❌ 不适合 enum——候选太多 / 会增长
{ "city": "北京" }
{ "city": "上海" }

enum 留扩展空间:业务后续会新增候选时,考虑 oneOf + 留 string 兜底,或直接用 pattern

数字:integer 还是 number?范围是多少?

两步判断

整数性

  • 全部样本都是整数 ≠ 字段总是整数
  • 业务语义优先:“age” 必整数;“temperature” 可小数
  • 浮点精度问题——金额绝不用 float,用 integer 存”分”或 string 存 decimal
// ❌ 危险
{ "amount": 19.99 }  // float 精度可能算错

// ✅ 推荐
{ "amount_cents": 1999 }  // integer
{ "amount": "19.99" }      // string with decimal

范围

  • 业务规则——age 0-150;latitude -90 到 90
  • 留余量——max_users: 10000 不要写当前最大值,留 10 倍空间
  • multipleOf: 0.01 限制小数位(金额用)
  • JS Number 只能精确表示 ±2^53——超过用字符串
{
  "type": "integer",
  "minimum": 0,
  "maximum": 150
}

{
  "type": "number",
  "minimum": -90,
  "maximum": 90
}

字符串:除了类型还要约束什么

样本告诉你”是字符串”,schema 还要表达:

约束用途
minLength / maxLength长度限制(防 DoS)
pattern正则(手机号、车牌、特定 ID 格式)
format标准化格式(email、uri、uuid、date-time)
contentEncodingbase64 等编码
enum有限候选

工具默认:能识别 email、uri、uuid 等常见 format,不会自动加 length 和 pattern

手动补刀

  • 用户输入字段加 maxLength——防 1MB 输入炸 DB
  • 业务 ID 加 pattern——^ORD-\d{12}$
  • 标准格式加 format——校验时大多数库会启用

嵌套:什么时候该拆成 $ref

默认推荐:2-3 层;过深需要拆。

判断

  • 同一子结构在多处出现 → 抽成 $defs/Address,多处引用
  • 业务上是独立实体(User、Order)→ 自然是独立 schema
  • 数组元素结构复杂 → 把 items 抽出来独立命名
{
  "$defs": {
    "Address": {
      "type": "object",
      "properties": {
        "street": { "type": "string" },
        "city": { "type": "string" }
      }
    }
  },
  "properties": {
    "billing_address":  { "$ref": "#/$defs/Address" },
    "shipping_address": { "$ref": "#/$defs/Address" }
  }
}

远程 $ref 慎用——"$ref": "https://..." 会拖慢校验且依赖网络可达性。跨服务复用时复制到本地 + 标注来源 URL。

数组:元素结构如何并集

数组里元素结构不一致时,工具一般有两种策略:

// 样本
[
  { "type": "user", "name": "Alice" },
  { "type": "order", "amount": 100 }
]

策略 A:取并集(宽松)

{
  "items": {
    "type": "object",
    "properties": {
      "type": { "type": "string" },
      "name": { "type": "string" },
      "amount": { "type": "number" }
    }
  }
}

问题:name 和 amount 都成可选,校验等于没校验。

策略 B:oneOf(精确)

{
  "items": {
    "oneOf": [
      { "$ref": "#/$defs/User" },
      { "$ref": "#/$defs/Order" }
    ]
  }
}

实务:异构数组用 oneOf——校验时强制每个元素必须严格匹配某个子 schema。

推断后的人工补刀清单

按顺序过一遍:

  1. required:删掉看着可选的字段,用 1000 条生产 sample 反向校验
  2. null 字段:每个都问”是值还是代表缺失”,分别处理
  3. enum 候选:扫一遍候选有限的字段,加 enum
  4. 数字范围:业务上有边界的全部加 minimum/maximum
  5. 字符串约束:用户输入加 maxLength;业务 ID 加 pattern;标准格式加 format
  6. 嵌套深度:被多处引用的子结构抽 $ref
  7. 异构数组:oneOf 而不是简单 items
  8. 未来扩展:留 buffer——maximum 留余量、enum 考虑后续新增

最后:schema 不是孤立资产

JSON Schema 应该和 TypeScript 类型、OpenAPI 文档联动维护

  • 选定一种 Source of Truth(OpenAPI / TS / JSON Schema)
  • 其他都是自动生成产物
  • CI 跑生成 + 检查产物已提交
  • 生成代码加注释 // AUTO-GENERATED — do not edit

互转工具速查

方向工具
JSON Schema → TSjson-schema-to-typescript
TS → JSON Schemats-json-schema-generator / typia / zod-to-json-schema
OpenAPI → TSopenapi-typescript
运行时校验Ajv / zod / valibot

推断工具是起点,不是终点。把”机械事实”用工具一次推完,把”业务约束”用人工补一遍——schema 才能既准又稳。

❓ 常见问题

required 是不是把所有出现过的字段都列上就行?

不行——会把"恰好这次有"误判成"必须有"问题来源:(1) 工具默认策略是"出现过 = required"——单个样本里所有字段都进 required;(2) 但生产数据里这个字段可能 50% 时候缺;(3) 拿 schema 校验真实流量时大量假阳性失败。正确策略:(1) 多样本求交集——给工具喂 5-10 个真实样本,required 取所有样本里都出现的字段交集;(2) 业务知识补刀——id created_at 一定 required,metadata note 这种 ad-hoc 字段保守不放 required;(3) API 文档对照——服务端代码里看哪些字段在响应中是必返的;(4) 可选字段宁少勿多——schema 偏严是 false positive 灾难,schema 偏宽只是 false negative,可补救。实务:(1) 推断后人工二次过 required,把"看起来可选"的字段挪出去;(2) 第二轮校验:用 schema 跑生产 sample 1000 条,统计哪些字段触发"required missing"——这些就是要从 required 拿掉的。

字段值是 null,schema 怎么推?

null 是有效值还是"字段缺失代理",工具分不清两种常见语义:(1) null 表示"明确没有这个值"——比如 parent_id: null 表示根节点;schema 应该写 "type": ["string", "null"];(2) null 表示"字段没填" —— 应该用 field 不存在表达(required: false),而不是值为 null;schema 写 "type": "string" + 不在 required 里。工具默认行为:(1) 看到 null 大多数推断为 ["原类型", "null"]——保守做法;(2) 但你可能想要的是后者——字段不应该出现而非 nullJSON Schema 的两种写法:(1) 类型联合"type": ["string", "null"]——值可以是字符串或 null;(2) nullable 属性(OpenAPI 3.0):"type": "string", "nullable": true——同语义;(3) anyOf"anyOf": [{"type": "string"}, {"type": "null"}]——更灵活但更冗长。实务:(1) 推断后逐个 null 字段确认语义;(2) 业务上"应有值"的字段——把 null 从 type 里去掉,确保上游不传 null;(3) 业务上"可没值"的字段——保留 null 类型联合;(4) OpenAPI 3.1 已统一用 ["string","null"] 不再用 nullable

几个候选值才算 enum?字段是 "active" "inactive" 算吗?

经验阈值:候选 ≤ 10 个 + 在样本中重复出现 = 适合 enum判断标准:(1) 候选有限且固定——状态机字段(status)、性别、协议类型等;(2) 业务明确语义——"active" "inactive" "deleted" 是状态机;"red" "green" "blue" 是色彩枚举;(3) 样本里重复出现——10 条样本里出现 2 个不同候选,每个都重复几次——大概率 enum;(4) 不是无界字符串——用户名、商品名、URL 不是 enum,候选无限。工具默认行为:(1) 大多数工具不主动推断 enum——风险太高,错的代价比对的收益大;(2) 部分工具支持"候选数 < N + 重复率 > X"才推断;(3) 可以选"激进 enum 推断"模式手动开。误判后果:(1) 过激 enum——"city: 北京"被推断成 enum,schema 上线后所有非北京数据全部失败;(2) 缺少 enum——schema 太宽,文档生成时无法列出候选值。实务:(1) 自动推断默认关闭 enum;(2) 推断完成后人工巡查"候选有限"的字段,补 enum;(3) 保留扩展空间——加 enum 时考虑业务后续是否会新增候选,必要时改用 pattern 或 const + anyOf 组合。

数字字段怎么推?integer 还是 number?要不要加 minimum / maximum?

先确认整数性,再确认范围,最后确认精度工具默认行为:(1) 看样本值——全是整数推断 integer,有小数推断 number;(2) 小心边界——所有样本恰好都是整数 ≠ 该字段总是整数(可能业务上允许小数但样本没出现);(3) 没有 minimum/maximum——除非样本明显有上下界范围。正确策略:(1) 业务语义优先——"age" 是 integer 且 ≥ 0;"temperature" 是 number 且需要范围;"latitude" -90 到 90、"longitude" -180 到 180;(2) 整数性来自约束不来自样本——schema 写 "type": "integer" 才是真"必须整数";(3) 金额字段特别注意——通常用 integer 存"分"或 number 用 decimal 处理,避免 float 精度问题。JSON Schema 数字字段:(1) multipleOf: 0.01 限制小数位;(2) minimum / maximum + exclusiveMinimum / exclusiveMaximum;(3) format: "int32" / "int64" 在 OpenAPI 是参考值不是强约束;(4) JS 的 Number 只能精确表示 ±2^53——超过用字符串。实务:(1) 推断完逐个数字字段问"业务上能小于 0 吗 / 能小数吗 / 上限多大";(2) 加约束时留余量——预算字段 maximum: 10000000 不要写死成现在最大值。

嵌套对象、数组要不要推断 schema?深度多少合适?

默认推荐 2-3 层,过深需手动拆分为什么不能无限深推:(1) JSON 实际可以嵌套很深(像 GraphQL 响应),但 schema 太深难维护;(2) 深层结构通常该拆成"引用"——$ref 指向独立定义;(3) 单个 schema 文件超过 500 行就难以阅读和审查。工具默认行为:(1) 推断 2-3 层即可;(2) 数组里所有元素取并集——元素结构差异大时 schema 用 oneOf 而非简单 items;(3) 过深嵌套通常自动停止递归。手动拆分时机:(1) 被多处引用的子结构 —— 抽成独立 $defs / definitions 引用;(2) 业务上是独立实体 —— UserOrder 等自然是独立 schema;(3) 数组元素结构复杂 —— 抽 items 为独立定义。$ref 引用语法:(1) "$ref": "#/$defs/User" 同文件引用;(2) "$ref": "user.schema.json" 跨文件;(3) "$ref": "https://example.com/schemas/user" 远程引用——慎用,会拖慢校验且依赖网络实务:(1) 顶层 schema 保持薄,复杂内容下放到 $defs;(2) 数组元素抽出来单独命名——可读性大幅提升;(3) 跨服务复用 schema 时优先复制本地 + 标注来源 URL,不要运行时拉远程。

推断的 schema 怎么和 OpenAPI / TypeScript 类型保持同步?

一种 SoT,多端生成——避免手动维护多份典型架构:(1) 以 OpenAPI 为 SoT ——后端用 OpenAPI 定义接口(含 schema),前后端代码从 OpenAPI 自动生成;(2) 以 TypeScript 为 SoT ——TS 类型为主,用 ts-json-schema-generator / typia 反向生成 JSON Schema;(3) 以 JSON Schema 为 SoT——JSON Schema 文件最权威,前后端各自从中生成代码。互转工具:(1) JSON Schema → TSjson-schema-to-typescript;(2) TS → JSON Schemats-json-schema-generatortypiazod-to-json-schema;(3) OpenAPI → 两端代码openapi-typescript(前端 TS 类型)、各语言 codegen;(4) 运行时 schema → 校验代码:Ajv、Joi、zod。陷阱:(1) 多次反向转换会失真——TS 的 union 转 schema 再转回 TS 可能不等于原 TS;(2) JSON Schema 比 TS 表达力更强——patternminimum 等约束在 TS 类型里没有;(3) OpenAPI 3.0 vs 3.1 schema 子集差异——3.0 用 nullable、3.1 用 ["type","null"],转换工具可能不一致。实务:(1) 选定一种 SoT,其他都是生成产物;(2) CI 跑生成 + 检查生成产物已提交,避免源和产物不同步;(3) 生成代码不要手改——加注释 // AUTO-GENERATED

打开 JSON 转 Schema 样本 JSON 推断 Schema · required 策略 · 示例内联