用 JSON Schema 做严谨校验:告别“幽灵字段”和脏数据

JsonTool
订阅
充电
|

很多朋友把“能被 JSON.parse 成对象”当成“数据没问题”。但是一到线上:一个小小的 null、一个多余字段、一个写错格式的时间串,就能产生严重的问题。JSON 校验做的除了能不能解析,更重要的是要验证是“结构对不对、值合不合理、能不能放心往下游传”。

下面,我尽量把落地要点、常见坑和多语言方案一次说清,配一套可复制的脚手架。

1. 目标边界:校验到底在查什么

语法:是不是合法 JSON。对应 JSON.parse / Jackson / json.loads。标准见 RFC 8259 与 ECMA-404(两个标准都在定义 JSON 的语法,彼此兼容)。(IETF Datatracker, Ecma International)

结构/类型/约束:对象里有哪些字段、类型是什么、取值范围/正则/枚举、数组长度、是否允许未知字段。推荐用 JSON Schema(最新版规范见 json-schema.org)。(JSON Schema)

业务规则:跨字段/跨对象的规则,比如“有 discount 就必须有 couponCodediscount <= price”。这一层通常要在代码里自定义校验器(Schema 里也能写一部分,比如 if/then/elsedependentRequired)。(JSON Schema)

语法过关 ≠ 有效数据。校验的职责是把“输入不确定性”挡在系统边界之外。

2. 先有一个能跑的 Schema

以一个订单片段为例(可直接拷贝到你的项目里):

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
{
"$schema": "https://json-schema.org/draft/2020-12/schema",
"$id": "https://example.com/schemas/order.json",
"type": "object",
"required": ["id", "items", "price", "createdAt"],
"additionalProperties": false,
"properties": {
"id": { "type": "string", "minLength": 16 },
"items": {
"type": "array",
"minItems": 1,
"items": {
"type": "object",
"required": ["sku", "qty"],
"properties": {
"sku": { "type": "string" },
"qty": { "type": "integer", "minimum": 1 }
},
"additionalProperties": false
}
},
"price": { "type": "number", "minimum": 0 },
"discount": { "type": "number", "minimum": 0 },
"couponCode": { "type": "string" },
"createdAt": { "type": "string", "format": "date-time" }
},
"allOf": [
{ "if": { "required": ["discount"] },
"then": { "required": ["couponCode"] } },
{ "if": { "properties": { "discount": { "const": 0 } } },
"then": { "not": { "required": ["couponCode"] } } }
]
}

几点说明:

  • additionalProperties: false 限制不允许未知字段;如果你要向前兼容,先放开,再逐步收紧。
  • format: "date-time"RFC 3339 格式;不少校验库默认不强制校验 format,要显式开启(后文给具体库的开关)。(JSON Schema)

3. 多语言落地脚手架

3.1 Node.js(Ajv)

Ajv 支持到 2020-12/2019-09 草案,速度很快、生态成熟。(ajv.js.org, GitHub)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
npm i ajv ajv-formats
import Ajv from "ajv";
import addFormats from "ajv-formats";
import schema from "./order.schema.json" assert { type: "json" };

const ajv = new Ajv({
allErrors: true, // 一次性报全错,便于前端展示
strict: true, // 严格模式,避免歧义
removeAdditional: false, // 不要自动删除未知字段,先让问题显形
coerceTypes: false // 不要把 "123" 强转成数字,避免脏数据混进来
});
addFormats(ajv, { mode: "fast" }); // 开启 date-time 等 format 校验

const validate = ajv.compile(schema);

export function assertValidOrder(data) {
const ok = validate(data);
if (!ok) {
const msg = ajv.errorsText(validate.errors, { separator: "\n" });
const details = validate.errors?.map(e => ({
path: e.instancePath || "/",
keyword: e.keyword,
message: e.message
}));
const err = new Error(msg);
err.details = details;
throw err;
}
return data;
}

3.2 Java(networknt json-schema-validator)

轻量、无框,常配合 Jackson 使用;支持到 2020-12。建议缓存 JsonSchema 实例。(GitHub)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
<!-- Maven -->
<dependency>
<groupId>com.networknt</groupId>
<artifactId>json-schema-validator</artifactId>
<version>1.5.8</version>
</dependency>

ObjectMapper mapper = new ObjectMapper();
JsonSchemaFactory factory = JsonSchemaFactory.getInstance(SpecVersion.VersionFlag.V202012);
JsonSchema schema = factory.getSchema(
YourClass.class.getResourceAsStream("/schemas/order.json"));

public void assertValidOrder(JsonNode node) {
Set<ValidationMessage> errors = schema.validate(node);
if (!errors.isEmpty()) {
// 组装友好的错误格式
throw new IllegalArgumentException(errors.toString());
}
}

小提示(Java):金额用 BigDecimal;Jackson 里可以 DeserializationFeature.USE_BIG_DECIMAL_FOR_FLOATS,避免精度丢失。

文档/JavaDoc:(doc.networknt.com, javadoc.io)

3.3 Python(jsonschema / Pydantic)

  • 直接用 jsonschema:与规范同源,API 简单。(jsonschema)
  • 做模型映射/业务逻辑时用 Pydantic v2:类型注解即约束,校验快,配 model_validate_json 很顺手。(docs.pydantic.dev)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
# jsonschema 版
from jsonschema import validate, Draft202012Validator, ValidationError
import json

schema = json.load(open("order.schema.json"))
Draft202012Validator.check_schema(schema)

def assert_valid_order(data: dict):
try:
validate(instance=data, schema=schema, cls=Draft202012Validator)
except ValidationError as e:
raise ValueError(f"{list(e.path)}: {e.message}") from e
# Pydantic v2 版(更偏“模型校验”,不等价于 JSON Schema)
from typing import List, Optional
from pydantic import BaseModel, Field, ValidationError, field_validator

class Item(BaseModel):
sku: str
qty: int = Field(ge=1)

class Order(BaseModel):
id: str = Field(min_length=16)
items: List[Item]
price: float = Field(ge=0)
discount: Optional[float] = Field(default=None, ge=0)
couponCode: Optional[str] = None

@field_validator("couponCode")
def coupon_required_if_discount(cls, v, info):
d = info.data.get("discount")
if d is not None and d > 0 and not v:
raise ValueError("couponCode required when discount > 0")
if d == 0 and v:
raise ValueError("couponCode must be absent when discount == 0")
return v

def assert_valid_order_json(json_str: str):
try:
return Order.model_validate_json(json_str)
except ValidationError as e:
raise ValueError(e) from e

4. 错误信息与用户体验:别只丢 “Bad Request”

好错误应该至少包含:path(出错位置)、expected(期待什么)、actual(实际是什么)、hint(修正建议)。
比如:

1
2
3
4
5
6
7
8
{
"code": "VALIDATION_FAILED",
"errors": [
{"path": "/items/0/qty", "expected": "integer >= 1", "actual": 0, "hint": "若是删除请移除此项"},
{"path": "/createdAt", "expected": "RFC3339 date-time", "actual": "2024/06/01 12:00"}
],
"requestId": "a1b2c3"
}

前端/工具里则把 path 聚合展示,配“定位到字段”的按钮,体验会好很多。


5. 常见坑

  1. 评论与尾逗号:JSON 不允许注释和尾逗号,别把 JSONC 当 JSON。标准里写得很清楚。(IETF Datatracker, Ecma International)
  2. format 没生效:不少库默认不校验 date-timeformat,像 Ajv 需要 ajv-formats 显式开启。(ajv.js.org)
  3. additionalProperties 忘关:上线后发现一堆“幽灵字段”随请求飘进来,导致下游落库失败/缓存 miss。
  4. oneOf 多匹配oneOf 里两个分支都能匹配会报错;要么加判别字段(discriminator),要么用更严格的约束。(JSON Schema)
  5. 精度问题:金额类别用十进制;JS 端不要把金额当 number 参与计算,上下游统一单位与类型。
  6. 深层嵌套/超大 JSON:大于几十 MB 的 JSON 建议流式校验(Jackson streaming、Node Transform 流),别一次全载入内存。
  7. Schema 与代码漂移:Schema 更新没同步到服务,最终“校验通过但代码崩”。解决:契约测试 + CI 校验

6. 性能与大文件:怎么才能减少 CPU 负载

  • 编译 & 复用:像 Ajv 会把 Schema 编译成函数;Java 也要把 JsonSchema 缓存起来,别每次解析。(GitHub)
  • 流式处理:NDJSON(每行一个 JSON)可以一行一行读+校验;超大数组考虑分块校验。
  • 采样:低风险场景先按比例采样校验,观察错误率与类型,再决定是否全量拦截。
  • 指标:至少打两类指标:校验耗时失败率(按 keyword/path 维度分桶)。

7. 版本化与演进:老版本兼容处理

  • Schema 版本号$id 带版本,或路径分版本 /schemas/order/2025-08-01.json。(JSON Schema)
  • 加字段:先非必填上线一版(提供默认值/后端兜底)→ 观察 → 再改成必填。
  • 删字段:先在 Schema 里标记弃用(文档+日志告警)→ 一段时间后再 additionalProperties: false 收口。
  • 向前/向后兼容策略:入口严格、出口宽松(对外响应更宽容,有助于灰度)。

8. 一个落地小清单

  • 所有入口(API/Webhook/消息)统一加 Schema 校验
  • additionalProperties 有明确态度(要么关掉要么白名单)
  • format 显式开启并约定时区/格式
  • 错误返回统一结构,包含 path/expected/actual/hint
  • CI:Schema 自检 + 契约测试(示例负样本 & 正样本)
  • 观测:失败率、关键字分布、Top N 出错路径
  • Schema 版本化 & 变更公告

9. 延伸:Schema ≠ 业务全部

别把业务校验全塞进 Schema。像“库存是否足够”“用户是否有权限”这类需要上下文的校验,应该放在服务逻辑里,并和 Schema 校验分层。

10. 参考与进一步阅读