python 的一个最吸引人的特点就是语言本身提供了大量简单易用的官方库,这其中就包括 json 包和对 json 数据的支持。我们日常开发中,用到的最主要的两个方法就是
import json
json_data = json.loads(...)
json_str = json.dumps(...)
以及类似如下的调用
print(json_data['key1']['key2']['key3'])
上面这些简单的调用就足够应对至少 90% 的日常开发工作,我曾经也天真地认为这些已经足够了。不过事情真的是这样吗?
1. 起因与痛点
我们的一个视频下载服务的任务是下载某视频网站中的视频,其大致处理流程为:
该目标网站将不同清晰度的视频地址进行 BASE64
编码后,将其与其他信息一起组织成了一份 JSON 数据,直接放在了视频页面 HTML 文件的 JavaScript 源码中。
图 1 中标记为橙色的部分是页面解析流程,也是最核心的逻辑。其详细步骤为:
爬虫服务大多数对于目标网站的改版是敏感的,所以当发现该服务无法下载该网站视频时,基本可以断定是原来的网站改版了。
查看了日志中的错误信息,如下
json.decoder.JSONDecodeError: Expecting value: line 1 column 825 (char 824)
根据错误栈信息找到错误点,发现报错的位置是图 2 的 Step 2 处。于是打印了 json.loads(...)
函数入参的 820-830 的字符(全部 json 数据太长了很难肉眼发现问题)。发现打印出
ey":undefi
原来,目标网站 HTML 中的 JSON 数据不知道什么时候新出现了 undefined
!
当然这个问题我最终解决了,为了行文顺畅具体怎么解决的我后面再详细介绍。
在解决了该问题后,图 2 的 Step 3 又出了问题,数据在 JSON 中的位置由原来的 json_data['a1']['b']['c']
变成了 json_data['a2']['e']['f']['b']['c']
!
综合上面遇到的这些问题,我们可以看出
python 自带 json 解析方法的痛点:
json.loads
无法处理非标准格式的 JSON 数据解析- 直接通过多个 key 查找 JSON 数据中的结果,程序将变得脆弱
2. 非标准格式的 JSON 数据解析 - demjson
针对非标准格式的 JSON 数据,开源库 demjson
可以帮你解决全部问题。话不多说上代码:
>>> import demjson
>>> a = '{"a": undefined, "b": 1e10, "c": NaN, "c": Infinity}' # 特殊字符
>>> demjson.decode(a)
{'a': demjson.undefined, 'b': 10000000000, 'c': inf}
>>> b = '{a: "hello", b: null}' # 注意 a、b 没有双引号
>>> demjson.decode(b)
{'a': 'hello', 'b': None}
直接解决了遇到的第一个痛点:非标准格式的 JSON 数据解析~
3. 写出更加健壮的 JSON 解析方法 - jsonpath & python-jsonpath-rw
针对原站 JSON 数据格式 (层级) 变动导致解析程序失效的问题,我们需要理解这样一件事
核心数据不变性: 核心的 JSON 数据格式和字段名称很难发生变动
这个虽然是我的一个假设,但基于以下三点原因,我认为是合理的:
- 核心的数据往往有很多内、外业务方使用,对其修改会导致很大的风险(程序员仔细思考过风险,不会这么干)
- 根据开闭原则,一名有经验的开发不会修改已经存在的数据格式和字段名称,而只会进行添加(程序员虽然没有仔细思考,但是根据自己的经验和常识,不会这么干)
- 改动现有代码和数据格式在开发届并不流行,因为出力无功(程序员没有思考也没有经验,但根据自己的产出和绩效,不会这么干)
当然上面的前提是这个产品比较成熟稳定且有一定用户基数,否则发生什么状况都不足为奇!
3.1 jsonpath
那这和 “写出更加健壮的 JSON 解析方法” 有什么关系呢?由于 “核心的 JSON 数据格式和字段名称很难变动”,所以在我们获取数据的时候,是不是可以不通过 JSON 数据的根,一点点 “走” 到目标;而是通过最终字段名称的匹配,直接“跳”到目标数据呢?
jsonpath 提供了针对 JSON 数据“跳”着查询的方法。类似的方法还有 xpath 对于 xml 的查询。如果通过走的方法获得图 3 中的 target 数值,一个伪代码查询语句是 a.b.c.d.e
, 而 jsonpath 的查询语句可以直接是 $..e
(含义是查找任意名称为 e 的子 json 节点)。
越是大型和复杂的 JSON 数据,jsonpath 的优势就越明显。同时基于上面关于核心数据不变性的论断,这种“跳”着获取数据的方法不但高效、易写,而且健壮性更好。
关于 jsonpath 的详细语法已经超过了本文的讨论范畴,网上也有很多资料,这里就不详细介绍了。
3.2 python-jsonpath-rw
python 使用 jsonpath 的第三方库有很多,这里我推荐 python-jsonpath-rw,主要是因为这个库的 github star 较多,说明比较被认可。
简单的示例如下:
>>> from jsonpath_rw import parse
>>> test_data = {'a': {'b': {'c': {'d': {'e': 'target'}}}}}
>>> jsonpath_expr = parse('$..d') # 查找所有名称为 d 的子 json 节点
>>> found = jsonpath_expr.find(test_data)
>>> print(found.pop().value)
{'e': 'target'}
总结
本文针对实际开发中遇到的 python 语言 json 数据解析的困难,介绍了 demjson 库、jsonpath 以及 python-jsonpath-rw 库,提出了“核心数据不变性” 的论断,希望可以给大家以启迪。
关于 json 解析程序的健壮性问题,除了 jsonpath 的方案,实际还可以根据一份标准输出值,来动态生成解析路径。不过该方案涉及到历史数据的存储、旧规则失效判定等细节问题,而这些内容和 python 官方 json 解析工具的局限性有些远,所以就没有介绍。希望以后有机会可以和大家分享这一主题。
参考与资源
- 第三方库: demjson
- 第三方库: python-jsonpath-rw
- StackOverflow: How to convert raw javascript object to python dictionary?
- 使用JsonPath解析json数据
- JSON 格式化工具
- JSONPath Online Evaluator