前言
处理嵌套很深的 JSON 时,一层层写data["a"]["b"][0]["c"]会非常脆:少一层就KeyError,多一层就TypeError,中间任何一个键缺失都会让整行代码炸掉。JSONPath 就是为了解决这个问题而生的——它是一门查询语言,用一条字符串表达式描述"我要取值的位置",把"路径描述"和"取值的防御逻辑"分开。
一个重要的时间点:JSONPath 在 2024 年 2 月有了正式标准 RFC 9535(标题是 JSONPath: Query Expressions for JSON)。在此之前,各家实现各写各的,语法细节互相不一致,这正是"照着网上例子写却跑不通"的根源。
关于 Python 里的实现:jsonpath-ng、jsonpath等第三方库都能用,但本文不写它们的具体 import 路径、类名与方法名——本机没有安装这些库,官方文档站点也无法从本环境访问,写出来无法核实。因此本文的路线是:先讲 RFC 9535 定义的通用语法,再用标准库实现一个最小可用的子集。各第三方库的具体 API 与扩展语法,请以其官方文档为准。
一、JSONPath 的基本词法
按 RFC 9535,一条 JSONPath 查询以根标识符$开头,后面跟若干个"段(segment)",每段说明"往下走一步"。段有两类写法:
点记法:$.store.book,用.连接名字。
括号记法:$['store']['book'],用方括号加引号。带特殊字符的键名(含空格、含点)必须用这种写法。
选择器(selector)有以下几种:名字选择器、通配符*、索引选择器(可为负,-1表示最后一个)、切片选择器[start:end:step]、过滤选择器[?过滤条件]。其中@代表"当前节点",过滤条件就写在@上。
后代段..是最有特色的一个:它表示"递归下降",匹配任意深度的后代。RFC 里给了几条简写规则:..name等价于..['name'],..*等价于..[*]。
| 表达式 | 含义 |
|---|
$.store.book[*].author | 商店里所有书的作者 |
$..author | 文档中所有位置的 author(任意深度) |
$.store.* | 商店下的所有成员值 |
$.store..price | 商店下任意深度的 price |
$..book[2] | 第三本书(下标从 0 开始) |
$..book[-1] | 最后一本书 |
$..book[0,1] | 前两本书(联合) |
$..book[:2] | 前两本书(切片写法) |
$..book[?@.isbn] | 所有带 isbn 的书 |
$..book[?@.price<10] | 所有价格低于 10 的书 |
$..* | 所有成员值与数组元素 |
有两个反直觉的细节要注意:$..book[2].publisher在示例文档里返回空结果,因为第三本书根本没有 publisher 这个字段——查询一条不存在的路径在 JSONPath 里通常返回空列表,而不是报错。这既是它比索引链安全的地方,也是它可能静默出错的地方(写错了路径名,它不会提醒你)。
二、为什么各家实现会不一致
RFC 9535 之前,JSONPath 只有原始提案,没有标准。于是各库在几个点上分化:
- 过滤表达式的写法。有的用
[?(...)]包一层括号,有的用[?...]。 - 脚本表达式。老实现允许在过滤器里写近乎任意的脚本,这是巨大的安全风险(相当于允许查询字符串执行代码)。RFC 明确设计了无副作用、可静态分析的表达式,就是为了堵住这个口子。
- 扩展函数。
length()、sum()这类聚合函数属于非标准扩展,各库的支持与命名完全不同。 - 结果格式。有的库返回"值"的列表,有的返回带路径信息的匹配对象,有的返回迭代器。
所以:换库就等于换语法。迁移时要逐条核对表达式,尤其是带过滤器的那几条。
选库时要留一个安全心眼:如果 JSONPath 表达式来自不可信输入(用户自定义查询、配置下发),一定要用只支持标准语法的实现,不要用支持任意脚本求值的老实现。一条精心构造的表达式有可能变成代码执行入口。
三、标准库实现一个最小子集
既然第三方库的接口无法核实,不如自己用标准库写一个只支持核心语法的求值器。它支持$、点记法与括号记法、通配符*、索引(含负数)、切片、以及..后代段——覆盖了绝大多数实际用法。
# 适用于 Python 3.8+
import re
SEG_RE = re.compile(r"""
\.\.\* # ..* 后代通配
| \.\.(?P<desc_name>[A-Za-z_][\w-]*) # ..name 后代名字
| \.\.(?P<desc_bracket>\[[^\]]*\]) # ..[ ... ] 后代括号段
| \.(?P<name>[A-Za-z_][\w-]*) # .name
| \.(?P<star>\*) # .*
| (?P<bracket>\[[^\]]*\]) # [ ... ]
""", re.X)
def _children(node):
"""节点的直接子值:字典取 value,列表取元素。"""
if isinstance(node, dict):
return list(node.values())
if isinstance(node, list):
return list(node)
return []
def _descendants(node):
"""产出节点自身及其所有后代(深度优先)。"""
yield node
for child in _children(node):
yield from _descendants(child)
def _apply_bracket(node, expr):
"""处理方括号里的内容:通配符、带引号的名字、切片、索引。"""
expr = expr.strip()
if expr == "*":
return _children(node)
if expr.startswith("'") and expr.endswith("'"):
key = expr[1:-1]
return [node[key]] if isinstance(node, dict) and key in node else []
if ":" in expr:
start, _, rest = expr.partition(":")
end, _, step = rest.partition(":")
if not isinstance(node, list):
return []
s = int(start) if start else None
e = int(end) if end else None
st = int(step) if step else None
return list(node[slice(s, e, st)])
index = int(expr)
if isinstance(node, list) and -len(node) <= index < len(node):
return [node[index]]
return []
def jsonpath(data, expr):
"""求值:返回所有匹配到的值组成的列表。"""
if not expr.startswith("$"):
raise ValueError("表达式必须以 $ 开头")
current = [data]
pos = 1
while pos < len(expr):
match = SEG_RE.match(expr, pos)
if not match:
raise ValueError(f"无法解析的片段: {expr[pos:]!r}")
pos = match.end()
nxt = []
if match.group(0) == "..*":
for node in current:
for d in _descendants(node):
nxt.extend(_children(d)) # 所有后代节点本身
elif match.group("desc_name"):
name = match.group("desc_name")
for node in current:
for d in _descendants(node):
if isinstance(d, dict) and name in d:
nxt.append(d[name])
elif match.group("desc_bracket"):
inner = match.group("desc_bracket")[1:-1]
for node in current:
for d in _descendants(node):
nxt.extend(_apply_bracket(d, inner))
elif match.group("name"):
name = match.group("name")
for node in current:
if isinstance(node, dict) and name in node:
nxt.append(node[name])
elif match.group("star"):
for node in current:
nxt.extend(_children(node))
else:
inner = match.group("bracket")[1:-1]
for node in current:
nxt.extend(_apply_bracket(node, inner))
current = nxt
return current
if __name__ == "__main__":
doc = {
"store": {
"book": [
{"title": "A", "price": 8.95},
{"title": "B", "price": 12.99},
{"title": "C"},
]
}
}
print(jsonpath(doc, "$.store.book[0].title")) # ['A']
print(jsonpath(doc, "$.store.book[-1].title")) # ['C']
print(jsonpath(doc, "$.store.book[:2].price")) # [8.95, 12.99]
print(jsonpath(doc, "$..price")) # [8.95, 12.99]
print(jsonpath(doc, "$..['title']")) # ['A', 'B', 'C']这段代码有几个设计取舍值得说明:
匹配不到就返回空列表。_apply_bracket与名字选择器都在键不存在时安静地跳过,而不是抛KeyError。这正是 JSONPath 相比索引链的价值——路径写错时不会崩,但也不会告诉你写错了。
..name用生成器做递归。_descendants是递归生成器,用yield from深度优先遍历整棵树。每次调用都新建一个生成器,不存在被耗尽的问题。
..*的语义是对每个后代取其子值。所以它覆盖面是"除根节点以外的所有节点",这正是 RFC 里那句"所有成员值与数组元素"的意思。
切片复用 Python 的切片语义。node[slice(s, e, st)]直接借用列表切片,所以负数与省略端点的行为和 Python 一致,不必自己实现。
没实现的语法要明确说出来。过滤表达式[?...]、联合[0,1]、带引号键名内部的转义,这个实现都不支持。写文档时要老实标注,避免别人误用。
四、什么时候该用 JSONPath,什么时候不该
适合用:结构嵌套深、只关心其中少数几个字段;同一份结构要反复用不同路径取数;路径本身要作为配置项下发。
不适合用:只需要取顶层一个键(直接data["key"]更清楚);需要严格的类型校验(JSONPath 只负责定位,不负责校验);表达式来自不可信输入又用了支持脚本的老实现(安全风险)。
还有一个使用习惯建议:把 JSONPath 表达式集中管理。写在散落的几十处代码里,一旦上游 JSON 结构变了,你会不知道要改哪些地方。
常见坑点
- 把某一家库的语法当成 JSONPath 标准。
❌ 从网上抄来[?(...)]的过滤器写法,换到另一个库就不认。 ✅ 先区分"RFC 9535 标准语法"与"某库的扩展语法",迁移时逐条核对带过滤器的表达式。
- 路径写错却以为是数据问题。
❌ 表达式里键名拼错,拿到空列表后去怀疑数据没抓到。 ✅ JSONPath 匹配不到时通常返回空结果而不报错,拿到空列表要先核对键名与层级。
- 用索引链代替 JSONPath 后又抱怨它脆。
❌ 继续写data["a"]["b"][0]["c"],一旦中间缺失就KeyError。 ✅ 嵌套取值用 JSONPath 或加防御性的.get()链,让缺失变成可处理的结果。
- 在不可信输入上使用支持脚本求值的实现。
❌ 让外部传入的查询字符串进入能执行任意表达式的老式实现。 ✅ 选择只实现标准语法的实现;标准明确把过滤表达式设计成无副作用、可静态分析,正是为此。
- 忽略
..的代价。
❌ 在大文档上频繁使用$..name递归下降。 ✅ 后代段要遍历整棵子树,路径越深代价越大;能用确定路径就不要用递归下降。
- 混用点记法与括号记法处理特殊键名。
❌ 键名里带点或空格时仍写$.a.b,被当成两级路径。 ✅ 特殊键名用括号记法$['a.b']。
- 把 JSONPath 当成校验工具。
❌ 用路径能取到值就认为数据结构完全正确。 ✅ 定位与校验是两件事;取到值之后还要自己检查类型、范围与必需字段。
总结
| 维度 | 结论 |
|---|
| 标准 | RFC 9535 于 2024 年 2 月发布,之前各实现互不兼容 |
| 核心语法 | $根、.名字、[n]索引、[*]通配、[a:b]切片、[?filter]过滤、..后代 |
| 未命中行为 | 通常返回空结果而非报错 |
| 扩展语法 | length()等聚合、脚本求值属非标准,各库不同 |
| 安全 | 不可信输入不要用支持脚本求值的实现 |
| 第三方库 API | 本环境无法核实,请以各库官方文档为准 |
用 JSONPath 的正确心态是:它让"取数"这件事变得可声明、可配置、可容错,但代价是错误会变得安静。所以配套的做法是——表达式集中管理、对取到的值单独做校验、以及在文档里明确标注自己用的是哪一版语法。