查询指南

JSONPath:选择器、过滤器,以及各家实现互不兼容的部分

JSONPath 选择器、切片与过滤表达式的实例参考,2024 年 RFC 9535 标准化了什么,主流库至今仍有哪些差异,以及什么时候该改用 jq 或 JSON Pointer。

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 负责的是不做变换的搜索与过滤。

继续了解相关检查与工具