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

结论:先用确定的路径定位、再叠加过滤条件,表达式按 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负下标是否支持;- 结果是「值数组」还是「
{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(),换实现前先跑官方一致性测试用例。