发布于
JSONPath 是「把这份文档里所有作者给我」这句话最短的表达方式,不用写循环。在长达十八年的时间里它没有规范,只有 2007 年的一篇博客和十几个各自略有出入的库实现。RFC 9535 在 2024 年 2 月终结了这个局面。本文讲语法、在真实文档上的实际结果,以及你手上的库可能仍然与标准不一致的那些地方。
JSONPath 是什么,不是什么
JSONPath 是一种只读的选择语言。一条表达式接受一份 JSON 文档,返回一个节点列表:从文档中取出的零个或多个值,顺序是有定义的。它不做变换、不做聚合、不构造新结构。这个限制是优点 —— 它意味着从配置文件里读进来的表达式是安全的,也正因如此,JSONPath 出现在 Kubernetes 的 `kubectl -o jsonpath`、日志管道、API 网关和测试断言里。
这套设计由 Stefan Goessner 于 2007 年发布,参照 XPath 而来;此后每一个实现都是照着那篇文章、再加上自己对空白处的理解写出来的。空白处相当多:文章没有规定对象上的后代搜索行为、过滤器的语义,以及结果的确切类型。RFC 9535《JSONPath: Query Expressions for JSON》在 2024 年把这些补齐了。今天要写新表达式,就按 RFC 写,然后拿你真正会跑的那个库去验证。
结构上最重要的一个事实是:表达式分两类。单值查询 —— 只用名字和索引选择器,不含通配符、后代、切片或过滤器 —— 最多匹配一个节点。其余都是非确定的,可能匹配任意数量的节点,包括零个。有些库对单值查询返回裸值、对非确定查询返回列表,这意味着调用方代码必须知道你写的是哪一类。RFC 的行为是始终返回节点列表。
| 语法 | 含义 | 示例 |
|---|---|---|
| `$` | 文档的根节点 | `$` |
| `@` | 当前节点,只在过滤器内部有效 | `[email protected]` |
| `.name` | 按名字取子节点,点号写法 | `$.store.bicycle` |
| `['name']` | 按名字取子节点,方括号写法;键里含空格或点号时必须用它 | `$['store']['book']` |
| `['a','b']` | 多个名字的并集 | `$.store['book','bicycle']` |
| `*` | 通配符:对象的每个成员或数组的每个元素 | `$.store.book[*]` |
| `..` | 后代搜索:当前节点及其下方的所有节点 | `$..author` |
| `[0]` | 按下标取数组元素,从 0 开始 | `$.store.book[0]` |
| `[-1]` | 从末尾倒数的下标 | `$.store.book[-1]` |
| `[0,2]` | 多个下标的并集 | `$.store.book[0,2]` |
| `[start:end:step]` | 切片;`end` 不包含在内,三部分都可省略 | `$.store.book[0:2]` |
| `?expr` | 过滤器:保留表达式为真的成员 | `$.store.book[[email protected]]` |
| `length()`、`count()`、`match()`、`search()`、`value()` | RFC 9535 定义的五个函数 | `?length(@.title) > 15` |
用来查询的示例文档
下面所有例子都跑在这份文档上 —— 来自最初那篇 JSONPath 文章的书店样例,多数库的测试套件里也用它,所以你可以直接拿本文的结果和自己的实现对照。
注意其中刻意保留的不规则:四本书里只有两本有 `isbn`,而 `bicycle` 是一个对象而不是数组。真实文档就长这样,而这种不规则正是过滤器存在的意义。
{
"store": {
"book": [
{ "category": "reference", "author": "Nigel Rees",
"title": "Sayings of the Century", "price": 8.95 },
{ "category": "fiction", "author": "Evelyn Waugh",
"title": "Sword of Honour", "price": 12.99 },
{ "category": "fiction", "author": "Herman Melville",
"title": "Moby Dick", "isbn": "0-553-21311-3", "price": 8.99 },
{ "category": "fiction", "author": "J. R. R. Tolkien",
"title": "The Lord of the Rings", "isbn": "0-395-19395-8", "price": 22.99 }
],
"bicycle": { "color": "red", "price": 19.95 }
}
}选择器与切片的真实结果
后代搜索是大家最先用、也最不了解的操作符。`$..price` 会找出文档中任何位置的每一个 `price`,在这里就是四个书价加上自行车的价格。四个书价按数组顺序产出,因为数组顺序是有定义的。而自行车价格相对于它们的位置,取决于实现访问 `store` 对象成员的顺序 —— 而对象成员顺序被 RFC 明确列为不保证。如果你需要确定的顺序,就不要依赖跨对象的后代搜索。
切片沿用 Python 的约定:`[start:end:step]`,`end` 不含在内,负数从末尾倒数。`[0:2]` 取前两个元素,`[-2:]` 取最后两个,`[::2]` 取每隔一个。步长为负会反转结果。越界的切片不是错误,只是产出更少的节点,这让切片可以安全地用在长度未知的数组上。
$.store.book[*].author
-> ["Nigel Rees", "Evelyn Waugh", "Herman Melville", "J. R. R. Tolkien"]
$..author 同样的四个值,用后代搜索找到
-> ["Nigel Rees", "Evelyn Waugh", "Herman Melville", "J. R. R. Tolkien"]
$..price
-> [8.95, 12.99, 8.99, 22.99, 19.95]
四个书价按数组顺序排列。19.95(自行车)是否排在最后,
取决于对象成员的遍历顺序,而这一点没有保证。
$.store.book[2].title -> ["Moby Dick"]
$.store.book[-1].title -> ["The Lord of the Rings"]
$.store.book[0:2].title -> ["Sayings of the Century", "Sword of Honour"]
$.store.book[0,2].price -> [8.95, 8.99]
$.store['book','bicycle'] -> [book 数组, bicycle 对象]
$.store.book[5] -> [] 越界返回空,不是错误价值主要在过滤器上
过滤选择器会对它所作用的数组或对象的每个成员求值,保留表达式为真的那些。在过滤器内部,`@` 指代当前被测试的成员。RFC 的语法是 `?` 后面跟一个逻辑表达式;括号只是分组,所以多数库使用的旧写法 `?(...)` 依然合法,而且对大多数人来说读起来更清楚。
比较运算符是 `==`、`!=`、`<`、`<=`、`>`、`>=`,逻辑运算符是 `&&`、`||`、`!`。字符串字面量单引号双引号都可以。不同类型之间的比较结果直接是 false 而不是报错 —— 当某些记录缺字段时,这正是你想要的行为。
最有用的形式反而不带任何运算符。过滤器内部的一条裸查询就是存在性测试:`[email protected]` 保留所有含 `isbn` 成员的项,不管它的值是什么。这是你在文档里找出不规则记录的方式,通常也是面对陌生载荷时第一条值得跑的查询。
RFC 9535 还定义了五个函数。`length()` 返回字符串、数组或对象的长度。`count()` 返回一条查询匹配到的节点数量。`match()` 和 `search()` 应用 I-Regexp 模式,分别是整体匹配和部分匹配。`value()` 把单节点结果转换成值以便参与比较。各库对这些函数的支持仍然参差不齐 —— 它们是规范里最新的部分 —— 依赖之前先确认。
$.store.book[[email protected] < 10].title
-> ["Sayings of the Century", "Moby Dick"] 8.95 和 8.99
$.store.book[[email protected]].title 存在性测试
-> ["Moby Dick", "The Lord of the Rings"]
$.store.book[[email protected]].title 取反的存在性测试
-> ["Sayings of the Century", "Sword of Honour"]
$.store.book[[email protected] == 'fiction' && @.price < 20].author
-> ["Evelyn Waugh", "Herman Melville"] 托尔金那本是 22.99
$.store.book[?length(@.title) > 15].title RFC 9535 函数
-> ["Sayings of the Century", "The Lord of the Rings"]
分别是 22 和 21 个字符。"Sword of Honour" 正好 15,所以被排除。
$..[[email protected] > 15] 任何带 price 的节点
-> [托尔金那本书, bicycle 对象]
$.store.book[[email protected] > 100]
-> [] 无匹配返回空节点列表,
不是错误各家实现至今仍不一致的地方
咬得最狠的差异和结果形状有关。Jayway 对确定路径直接返回值、对非确定路径返回列表,于是 `$.store.bicycle.color` 给你一个字符串,而 `$..color` 给你一个只有一个元素的列表。有的库在无匹配时返回 `null`,有的返回空列表,这会改变调用方判断「不存在」的写法。请一次性确定你的代码如何区分「匹配到了 null」和「什么都没匹配到」,因为这两者确实不同,而好几个库把它们混为一谈。
第二类是扩展。`jsonpath-plus` 增加了父节点操作符 `^`、属性名操作符 `~`,以及 `@string()` 这类类型选择器。它们确实好用,也完全不可移植 —— 用了它们的表达式换个地方就跑不了。Goessner 的原版还允许 `$..book[(@.length-1)]` 这种由宿主语言求值的脚本表达式。所有注重安全的实现都已经把它删掉了,RFC 里也没有;看到含这类写法的表达式,应当视为危险信号。
实用建议:把表达式控制在交集之内。名字与索引选择器、通配符、后代搜索、切片,以及使用普通比较的过滤器,基本到哪都能跑。函数、名字并集、负下标和各种扩展,则是你开始依赖某个特定库的起点。无论你写什么,上线之前都要拿一份包含边界情况的文档测一遍 —— 在配置文件里,一个无声的空结果看起来和一条正常工作的查询一模一样。
| 方面 | 老实现的常见行为 | RFC 9535 |
|---|---|---|
| 过滤器语法 | `?(@.price < 10)`,括号是必需的 | `[email protected] < 10`;括号只用于分组 |
| 无匹配 | `null`、空列表,或抛异常 | 始终是空节点列表 |
| 确定路径的结果 | 常常是裸值 | 始终是节点列表 |
| 负下标 `[-1]` | 经常不支持 | 支持 |
| 名字并集 `['a','b']` | 各不相同 | 支持 |
| `..` 中对象成员的顺序 | 实践中是插入顺序 | 明确不保证 |
| 脚本表达式 `[(...)]` | Goessner 原版允许 | 已移除;永远不要实现 |
| 正则表达式 | 部分库提供 `=~` 运算符 | `match()` 和 `search()`,使用 I-Regexp |
| 父节点 / 属性名访问 | `jsonpath-plus` 的 `^` 和 `~` | 规范中没有 |
JSONPath、jq 与 JSON Pointer
JSON Pointer(RFC 6901)用字面路径精确定位一个位置:`/store/book/0/title`。它没有通配符、没有搜索、没有过滤器,也不可能产生歧义。正因如此,它是 JSON Patch、JSON Schema 错误报告和 OpenAPI `$ref` 内部的寻址机制。如果你确切知道东西在哪,需要的是一个稳定、可被引用的地址,那就用 pointer,而不是路径表达式。
jq 是一门完整的 JSON 编程语言。它有管道、变量、自定义函数、归约,而且关键在于 —— 它会构造新的输出。`.store.book[] | select(.price < 10) | {t: .title}` 先筛选再重塑。JSONPath 什么都重塑不了。如果你的任务以「……然后按 category 分组并对价格求和」结尾,你要的是 jq,或者一门真正的编程语言。
JSONPath 的位置在两者之间:只搜索和过滤、不做变换,语法短到可以塞进一个 YAML 字段或命令行参数,而且安全到可以接受来自不可信配置的输入 —— 因为它什么都执行不了。这是一个确实有用的生态位,也是 `kubectl`、许多 API 网关和大多数契约测试工具选择它的原因。
| 任务 | 用什么 | 示例 |
|---|---|---|
| 稳定地定位一个已知位置 | JSON Pointer | `/store/book/0/title` |
| 在任意位置找出某个键的所有值 | JSONPath | `$..price` |
| 按条件筛选记录 | JSONPath | `$.store.book[[email protected] < 10]` |
| 把输出重塑成新结构 | jq | `.store.book[] | {t: .title}` |
| 聚合、分组或求和 | jq | `[.store.book[].price] | add` |
| 接受来自配置文件的表达式 | JSONPath | 没有执行面 |
| 描述对文档的一次修改 | JSON Patch,其内部使用 JSON Pointer | `{"op":"replace","path":"/store/bicycle/color"}` |
要点回顾
- 新表达式按 RFC 9535 来写,然后拿你真正会运行的那个库验证一遍,因为所有广泛部署的实现都早于这份标准。
- 把过滤器里的裸查询当作存在性测试 —— `$.store.book[[email protected]]` —— 作为面对陌生文档时的第一条查询,它能告诉你哪些记录是不规则的。
- 永远不要依赖跨对象成员的后代搜索所返回的结果顺序,规范明确不保证它;数组顺序才是有保证的。
- 一次性确定代码如何区分「匹配到 null」和「完全没有匹配」,因为各库分别会返回 `null`、空列表或抛异常。
- 需要一个稳定地址时用 JSON Pointer,需要重塑或聚合时用 jq;JSONPath 负责的是不做变换的搜索与过滤。