JSON 工具箱

查询与校验常见问题:JSONPath 与 Schema

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

进阶约 12 分钟发布于 2026-10-30更新于 2026-10-30
查询与校验常见问题封面

结论:按条件找节点用 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

两个容易踩的迁移点:

  1. 元组写法变了:draft-07 里 items 是数组表示元组,2020-12 改成 prefixItems;
  2. $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 字段类型不对。

两个实践建议:

  1. 一次返回全部错误,不要遇到第一个就抛异常——前端一次改完比来回试效率高;
  2. 把 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 多数校验器默认不校验,别依赖