查询与校验常见问题:JSONPath 与 Schema
结论:路径查询该用 JSONPath 还是 Pointer、Patch 与 Merge Patch 怎么取舍、Schema 能拦住哪些错误、draft 版本怎么选,一次讲透。

结论:按条件找节点用 JSONPath,精确定位与增删改用 JSON Pointer 或 JSON Patch,合并配置用 Merge Patch;新项目的 Schema 固定用 2020-12,并打开 allErrors 一次拿到全部校验错误。
查询是「找到它」,校验是「确认它对」。两件事各有一套标准,别混着用。
一、路径查询
JSONPath 和 JSON Pointer 该怎么选?
看你是「按条件找」还是「按位置取」。
| 维度 | JSONPath | JSON Pointer |
|---|---|---|
| 标准 | RFC 9535(2024) | RFC 6901(2013) |
| 表达式 | $.users[?(@.age > 18)].id |
/users/0/id |
| 通配与递归 | 支持 *、.. |
不支持 |
| 条件过滤 | 支持 | 不支持 |
| 返回结果 | 节点集(可能多个) | 单个位置 |
| 表达能力 | 强,但实现差异历史包袱重 | 弱,但完全确定 |
| 典型用途 | 日志提取、调试、数据探查 | JSON Patch、配置定位、错误路径 |
选择规则很直接:
- 需要「取所有满足条件的节点」→ JSONPath;
- 需要「精确定位某一个位置并做增删改」→ JSON Pointer;
- 两者配合:用 JSONPath 找到节点,用 Pointer 描述它的位置。
不要在接口协议里用 JSONPath 做寻址。JSONPath 的返回是集合,集合为空、单元素、多元素三种情况的 处理分支会让协议变复杂;Pointer 的语义是「要么指向一个存在的位置,要么不存在」,更适合做契约。
$.. 和 $.* 有什么区别?
$.. 是递归下降,会遍历所有层级;$.* 只是当前层的所有直接子节点。
{
"a": { "id": 1, "child": { "id": 2 } },
"b": { "id": 3 }
}
$.*→{id:1, child:{...}}和{id:3}两个对象;$..id→1、2、3三个值;$.*.id→1、3,不含嵌套的2。
递归下降很实用但代价高:$.. 会遍历整棵树,在大文档上是 O(n),而且容易把预期外的深层字段一起捞出来。
能用具体层级就别用 ..。
过滤器里能比较字符串、能正则匹配吗?
能比较字符串,正则要看实现。
标准写法(RFC 9535):
$.items[?(@.status == "active")]
$.items[?(@.price > 10 && @.stock > 0)]
$.items[?(@.name == $.target)] // 与另一个节点比较
字符串比较用双引号(单引号在 RFC 9535 里也允许,但部分老实现只认双引号)。
数字比较按数值语义,"10" > 9 这种字符串与数字混比在标准里是未定义行为,别依赖。
正则方面,RFC 9535 定义了 match() 与 search() 两个函数,但各实现的函数支持程度参差不齐。
稳妥做法是:JSONPath 只负责粗筛,正则匹配放到取回结果后的代码里做。
JSON Patch 和 Merge Patch 选哪个?
两者都是描述「怎么改」,但表达能力和语义完全不同。
| 维度 | JSON Patch (RFC 6902) | Merge Patch (RFC 7386) |
|---|---|---|
| 形式 | 操作数组 [{"op":"add",...}] |
一个局部对象 |
| 支持操作 | add / remove / replace / move / copy / test | 只有合并(删除靠 null) |
| 能操作数组元素 | 能,按下标 | 不能,数组整体替换 |
| 原子性 | 有 test 可做乐观锁 |
无 |
| 文档大小 | 大,但精确 | 小,但表达受限 |
| 适合 | 精细增删改、API 局部更新 | 配置合并、表单部分提交 |
判断口径:
- 要改数组里第 3 个元素的某个字段 → JSON Patch;
- 要把若干配置项合进现有配置 → Merge Patch;
- 需要「如果原值不是 X 就失败」的并发控制 → JSON Patch 的
test。
注意 Merge Patch 的 null 语义有陷阱:null 表示「删除该键」,而不是「把值设为 null」。
想设成 null 在 Merge Patch 里做不到。
二、Schema 校验
JSON Schema 能解决什么问题?
它把「数据长什么样」从口头约定变成可机器校验的契约。具体能拦住的错误:
- 必填字段缺失(
required); - 类型不符,例如该是数字却传了字符串(
type); - 取值范围越界(
minimum/maximum/minLength); - 枚举值不在允许集合(
enum); - 数组元素类型不统一、数量越界(
items/minItems); - 嵌套结构缺字段(
properties递归校验); - 多余字段(
additionalProperties: false)。
它不能解决的:业务规则(「结束时间必须晚于开始时间」)、跨字段条件(部分能用 if/then 表达,
但可读性差)、数据库唯一性、权限判断。
所以 Schema 是「数据形状」的守门员,不是业务逻辑的替代品。
draft-07 和 2020-12 该用哪个?
新项目用 2020-12,存量系统看校验器支持情况。
| 版本 | 状态 | 关键变化 |
|---|---|---|
| draft-04 | 过时 | 大量老库仍默认它 |
| draft-07 | 事实标准 | 支持最广,生态最全 |
| 2019-09 | 过渡版 | 引入 $defs、unevaluatedProperties |
| 2020-12 | 当前推荐 | items 拆成 prefixItems + items,支持 $dynamicRef |
两个容易踩的迁移点:
- 元组写法变了:draft-07 里
items是数组表示元组,2020-12 改成prefixItems; $ref与兄弟关键字:draft-07 里$ref存在时兄弟关键字被忽略,2020-12 允许共存。
如果校验器只支持 draft-07,硬写 2020-12 的语法会被静默忽略——最危险的情况是「校验通过」但其实压根没校验。
Schema 怎么生成 TypeScript 类型?
两条路:类型从 Schema 推导,或者 Schema 从类型生成。
从 Schema 到类型(后端定义契约、前端消费):
import type { FromSchema } from 'json-schema-to-ts';
const userSchema = {
type: 'object',
properties: {
id: { type: 'string' },
age: { type: 'integer' },
},
required: ['id'],
additionalProperties: false,
} as const;
type User = FromSchema<typeof userSchema>;
// { id: string; age?: number }
关键是 as const:不加的话 type: 'string' 会被推断成 string 而不是字面量 'string',推导失效。
从类型到 Schema(前端已有类型、需要运行时校验):
import { z } from 'zod';
import { zodToJsonSchema } from 'zod-to-json-schema';
const userSchema = z.object({ id: z.string(), age: z.number().int().optional() });
const jsonSchema = zodToJsonSchema(userSchema);
这条路的优势是「一份定义同时得到类型与运行时校验」,代价是引入了 DSL,
且生成的 Schema 可能带实现细节(比如 $ref 结构不美观)。
校验失败怎么定位到具体字段?
看校验器返回的 instancePath(2020-12)或 dataPath(draft-07),它是指向错误位置的 JSON Pointer。
[
{
"instancePath": "/items/2/price",
"schemaPath": "#/properties/items/items/properties/price/type",
"keyword": "type",
"message": "must be number"
}
]
instancePath: "/items/2/price" 直接告诉你:items 数组第 3 个元素的 price 字段类型不对。
两个实践建议:
- 一次返回全部错误,不要遇到第一个就抛异常——前端一次改完比来回试效率高;
- 把 Pointer 转成人话再给终端用户看,
/items/2/price对开发有用,对用户没用。
allErrors: true 是多数校验器的开关(Ajv 需要显式打开),默认往往是快速失败模式。
additionalProperties: false 要不要开?
内部契约建议开,公开 API 谨慎开。
开启后,任何未在 properties 里声明的字段都会导致校验失败。好处是能抓住拼写错误
(userNmae)和意外透传;坏处是上游加字段就会打破下游,破坏「向后兼容」。
折中方案:
| 场景 | 建议 |
|---|---|
| 内部服务之间 | 开。字段变更走版本管理 |
| 对外公开 API | 关。允许上游新增字段 |
| 消费第三方数据 | 关。你控制不了对方 |
| 配置校验 | 开。配置里的错拼应该立刻报错 |
怎么校验数组元素唯一?
两种语义,别搞混:
{ "uniqueItems": true }
这是整个元素去重,要求数组里没有完全相同的元素(深度相等)。
{
"type": "array",
"items": { "type": "object", "properties": { "id": { "type": "string" } } },
"uniqueItems": true
}
上面这段只能保证对象整体不重复,两个 {"id":"a","name":"x"} 与 {"id":"a","name":"y"} 不算重复。
要按某个字段去重,Schema 本身表达不了,得在业务层校验:
const ids = items.map((item) => item.id);
if (new Set(ids).size !== ids.length) {
throw new Error('id 重复');
}
format 会真的校验邮箱和日期吗?
不一定。format 在规范里默认是「注解」,不是「断言」,校验器可以只收集不校验。
- Ajv 需要单独装
ajv-formats并配置,否则format: "email"形同虚设; - 某些校验器把
format当作断言默认开启,行为不一致; - 即便开启,
email的正则也很宽松,a@b就能通过。
结论:format 不要当作强校验。邮箱、URL、日期的严格校验放到业务代码里做,
Schema 里保留 format 作为文档说明是有价值的,但别指望它拦住脏数据。
三、速查
| 问题 | 结论 |
|---|---|
| 按条件找节点 | JSONPath |
| 精确寻址、做 Patch | JSON Pointer |
| 改数组元素 | JSON Patch,Merge Patch 做不到 |
| 合并配置 | Merge Patch |
| 新项目 Schema 版本 | 2020-12,注意 prefixItems |
| Schema 生成 TS 类型 | json-schema-to-ts + as const |
| 定位校验错误 | 读 instancePath,开 allErrors |
| 按字段去重 | Schema 做不到,业务层用 Set |
format: email |
多数校验器默认不校验,别依赖 |