☰
Python的jsonpath库使用方法实例
2026/10/10 13:38:59 网站建设 项目流程

前言


处理嵌套很深的 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 结构变了,你会不知道要改哪些地方。


常见坑点



  1. 把某一家库的语法当成 JSONPath 标准。


❌ 从网上抄来[?(...)]的过滤器写法,换到另一个库就不认。 ✅ 先区分"RFC 9535 标准语法"与"某库的扩展语法",迁移时逐条核对带过滤器的表达式。



  1. 路径写错却以为是数据问题。


❌ 表达式里键名拼错,拿到空列表后去怀疑数据没抓到。 ✅ JSONPath 匹配不到时通常返回空结果而不报错,拿到空列表要先核对键名与层级。



  1. 用索引链代替 JSONPath 后又抱怨它脆。


❌ 继续写data["a"]["b"][0]["c"],一旦中间缺失就KeyError。 ✅ 嵌套取值用 JSONPath 或加防御性的.get()链,让缺失变成可处理的结果。



  1. 在不可信输入上使用支持脚本求值的实现。


❌ 让外部传入的查询字符串进入能执行任意表达式的老式实现。 ✅ 选择只实现标准语法的实现;标准明确把过滤表达式设计成无副作用、可静态分析,正是为此。



  1. 忽略..的代价。


❌ 在大文档上频繁使用$..name递归下降。 ✅ 后代段要遍历整棵子树,路径越深代价越大;能用确定路径就不要用递归下降。



  1. 混用点记法与括号记法处理特殊键名。


❌ 键名里带点或空格时仍写$.a.b,被当成两级路径。 ✅ 特殊键名用括号记法$['a.b']。



  1. 把 JSONPath 当成校验工具。


❌ 用路径能取到值就认为数据结构完全正确。 ✅ 定位与校验是两件事;取到值之后还要自己检查类型、范围与必需字段。


总结




维度结论



标准RFC 9535 于 2024 年 2 月发布,之前各实现互不兼容

核心语法$根、.名字、[n]索引、[*]通配、[a:b]切片、[?filter]过滤、..后代

未命中行为通常返回空结果而非报错

扩展语法length()等聚合、脚本求值属非标准,各库不同

安全不可信输入不要用支持脚本求值的实现

第三方库 API本环境无法核实,请以各库官方文档为准



用 JSONPath 的正确心态是:它让"取数"这件事变得可声明、可配置、可容错,但代价是错误会变得安静。所以配套的做法是——表达式集中管理、对取到的值单独做校验、以及在文档里明确标注自己用的是哪一版语法。




需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询