JSON 工具箱

JSONPath 查询语言实战

结论:2024 年 JSONPath 终于有了正式标准 RFC 9535。这一篇讲清语法、过滤器语义、函数,以及各实现之间的历史差异。

进阶约 12 分钟发布于 2026-10-30更新于 2026-10-30
JSONPath 查询语言实战封面

结论:先用确定的路径定位、再叠加过滤条件,表达式按 RFC 9535 写;只需要取固定字段就用属性访问或 JSON Pointer,需要聚合就换 jq 或代码。

$.store.book[?(@.price < 10)].title 这种写法你大概见过,但它成为国际标准是 2024 年的事。

一、从事实标准到正式标准

JSONPath 由 Stefan Gössner 在 2007 年提出,长期只有一篇博客作为「规范」,各语言实现各自发挥:

  • 过滤器要不要加括号(?(@.a) vs ?@.a);
  • 数组下标 [-1] 是否支持;
  • 结果返回「值」还是「值与路径」;
  • 脚本表达式(?(@.price < $.max))是否允许。

2024 年 2 月发布的 RFC 9535 把这些分歧收敛成一套语法与语义,并给出了官方一致性测试套件。 新项目应当按 RFC 9535 来写,老项目迁移时要先确认实现版本。

二、语法速查

语法 含义 示例
$ 根节点 $.store
@ 过滤器内的当前节点 $[?@.price < 10]
.name 子节点(名字需匹配 [A-Za-z_][A-Za-z0-9_]*) $.store.book
['name'] 子节点(任意名字,含中文、含点) $['store']['书']
[0] 数组下标,负数从尾部数 $.book[0]、$.book[-1]
[0,2] 多个下标/多个名字 $.book[0,2]
[1:3] 切片,含头不含尾 $.book[1:3]
[::2] 带步长切片 $.book[::2]
[*] 通配所有子节点 $.book[*].title
..name 递归下降,任意深度查找 $..price
..[*] 递归取全部节点 $..*
[?<expr>] 过滤选择器 $.book[?@.price < 10]

一个必须记住的语义:JSONPath 返回的是节点集(nodelist),不是单个值。 即使只匹配一个节点,结果也是长度为一的列表。

三、用一份样例把语法跑一遍

{
  "store": {
    "book": [
      { "title": "JSON 权威指南", "price": 8.95, "tags": ["json", "spec"] },
      { "title": "数据交换格式史", "price": 12.99, "tags": ["history"] },
      { "title": "解析器实现原理", "price": 8.99, "tags": ["parser", "json"] }
    ],
    "bicycle": { "color": "red", "price": 19.95 }
  },
  "expensive": 10
}
表达式 结果
$.store.bicycle.color ["red"]
$..price 4 个价格(含 bicycle)
$.store.book[*].title 3 个标题
$.store.book[?@.price < 10].title 第 1、3 本的标题
$.store.book[?@.tags] 所有带 tags 的书(存在性判断)
$.store.book[-1].title 最后一本的标题
$..book[0,1] 前两本
$.store.book[?@.price < $.expensive] 语法上依赖实现,RFC 9535 不允许跨根引用

最后一行是历史坑:很多实现支持在过滤器里写 $.expensive 做跨节点比较,RFC 9535 明确禁止—— 过滤器只能访问当前节点 @。需要比较全局阈值时,把阈值作为参数传给查询函数。

四、过滤器能写什么

RFC 9535 的过滤表达式支持四类构造:

逻辑:  &&  ||  !          例:?@.price < 10 && @.tags
比较:  ==  !=  <  <=  >  >=
存在性:单独写一个路径     例:?@.tags(判断存在)
函数:  length() count() match() search() value()

几个实用例子:

// 价格在 8 到 10 之间
$.store.book[?@.price >= 8 && @.price <= 10]

// 标题包含「JSON」(正则匹配,第二个参数是 I-Regexp)
$.store.book[?match(@.title, ".*JSON.*")]

// 标签数量大于 1
$.store.book[?length(@.tags) > 1]

// 取第一个匹配值而不是列表(value() 用于把单节点集转成值)
$.store.book[?@.price < 9].title.value()

match() 是全串匹配、search() 是子串搜索,底层的正则方言是 I-Regexp(RFC 9485 的子集), 不支持回溯引用与环视——这既是限制,也顺手挡掉了正则 DoS。

五、实现差异:迁移前必查

实现 语言 RFC 9535 支持 备注
jsonpath-plus JavaScript 大部分兼容 支持扩展函数与脚本表达式,能力更强但非严格标准
jsonpath(dchester) JavaScript 部分 老牌库,?() 语法,结果结构不同
ojg / gjson Go ojg 支持较好 gjson 是另一套路径语法,不是 JSONPath
jsonpath-ng Python 部分 支持扩展,写法偏宽松
Jayway JsonPath Java 部分 生态广,历史行为与 RFC 有出入

迁移检查三项:

  1. 过滤器括号是否必需;
  2. -1 负下标是否支持;
  3. 结果是「值数组」还是「{path, value} 对象数组」。

第三项最容易踩:前端按 result[0] 取值,换库后拿到的是对象,页面直接空白。

六、性能与安全

性能

  • JSONPath 本质是一次遍历,复杂度是 O(节点数),无法利用索引;
  • .. 递归下降最贵,能先 $.store.book 定位再过滤,就不要写 $..book;
  • 表达式应先编译再复用(多数库提供 compile 或缓存),避免每次查询都重新解析;
  • 十万节点以上的文档,考虑先用 JSON Pointer 精确取值,或改用 jq 在服务端预处理。

安全

  • 用户输入的表达式必须限制长度与嵌套层数;
  • 不要让 JSONPath 承担权限控制——它只能读,无法表达「哪些字段不可见」;
  • match() 用的正则来自使用者时要限制复杂度,虽比 PCRE 安全,仍建议设长度上限。

七、什么时候不该用 JSONPath

  • 只需要取一个固定字段 → 直接 data.store.bicycle.color;
  • 需要在修改后写回 → 用 JSON Pointer 或补丁,JSONPath 不回写;
  • 需要聚合计算(求和、分组)→ 用 jq、SQL 或代码,JSONPath 只做选择;
  • 文档超大且只需要少数字段 → 用流式解析器,别先整棵读进内存。

八、检查清单

  • [ ] 表达式遵循 RFC 9535,不依赖跨根引用;
  • [ ] 明确了结果是节点集,取单值用 [0] 或 value();
  • [ ] 表达式预编译并复用;
  • [ ] 用户可控的表达式有长度与复杂度限制;
  • [ ] 换实现时跑一遍官方一致性测试用例。

常见问题

如何判断该用 JSON Pointer 还是 JSONPath?

只需要取一个固定字段时,直接写属性访问或用 JSON Pointer,不必引入表达式;需要过滤、通配或递归下降时再用 JSONPath。JSONPath 只负责选择、不回写,修改后写回要走 JSON Pointer 或补丁。

为什么过滤器里不能写 $.expensive?

RFC 9535 明确禁止跨根引用,过滤器只能访问当前节点 @。需要比较全局阈值时,把阈值作为参数传给查询函数;老实现虽然支持这种写法,但换库或迁移版本后会失效。

是否可以直接用 result[0] 取值?

可以,但前提是确认当前实现返回的是值数组。部分库返回 {path, value} 对象数组,换库后 result[0] 拿到的是对象,页面会直接空白。取单值统一走 [0] 或 value(),换实现前先跑官方一致性测试用例。