ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

Python JSON处理全攻略:从解析到流式处理,避开四大经典坑

Python JSON处理全攻略:从解析到流式处理,避开四大经典坑 最近处理一个数据迁移的小任务运营给了我一整张几十MB的JSON文件里面是好几层嵌套的用户行为记录。我用Python打开处理结果第一版脚本直接翻车——不是语法错而是json.loads读出来的对象和我想象中完全不一样。类似的问题我几乎每年都会在社区里看到三五次有人把JSON字符串直接当dict下标用有人导出文件全是\u开头的中文有人在接口返回里死活取不到想要的字段。所以我打算把平时处理json的整套工作流原原本本记下来从Python标准库json模块的四个核心函数json.load、json.loads、json.dump、json.dumps讲起一直延伸到文件读写、接口对接、嵌套数据提取、大文件流式处理最后再把高频翻车点集中复盘一遍。这篇文章适合刚入门Python、需要对接接口或者处理配置文件的同学看也适合已经写过一阵子但总在小细节上卡壳的人。1. 把JSON当字典用之前先认清三件事1.1 JSON和Python字典长得像但不是一回事JSON的全称是JavaScript Object Notation最初是给JavaScript用的数据交换格式。因为设计得足够简单后来几乎所有语言都把它当成通用的数据交换标准。它的语法确实和Python字典很像但本质上JSON只是一个字符串一种文本格式。Python字典是运行在内存里的对象两者之间隔着一道解析的步骤。这个差异看起来简单实际坑了很多人。最常见的翻车现场从文件里读了一段内容打印出来是{name: 张三}于是直接写data[name]结果报TypeError: string indices must be integers, not str。原因很简单你手里拿到的还是字符串不是字典。JSON类型和Python类型的对应关系大概是这样的JSON类型Python类型说明objectdict键值对结构arraylist有序数组stringstr字符串numberint / float整数或浮点数true / falsebool注意小写开头nullNone空值记住这张表后面很多报错都能一眼定位。1.2 为什么用标准库json而不是正则硬抠我见过有人用正则表达式从JSON文本里提取字段理由是std json库我还要查参数不如正则熟。这个想法值得商榷。JSON的嵌套结构、转义字符、中文字符编码组合起来正则写起来不仅长还非常容易漏边界。比如字符串里包含引号、逗号、花括号时纯正则基本是灾难。Python标准库里的json模块是C语言实现的核心逻辑性能好、解析稳定更重要的是它帮你处理了所有嵌套和转义细节。调用方式也极其简单就四个函数json.load从文件对象读取JSONjson.loads从字符串读取JSONjson.dump把Python对象写入文件json.dumps把Python对象转成JSON字符串load和loads的区别dump和dumps的区别就是一个多了一个ss代表string。搞懂这四个函数日常80%的场景就能覆盖了。1.3 先确认数据的身份再动手处理任何JSON数据之前我建议你先做一步确认当前数据到底是什么类型。别急着写业务逻辑先跑一段诊断代码两分钟不到能省下后面大量的排查时间。import json from pathlib import Path source Path(data.json).read_text(encodingutf-8) print(type(source)) # class str说明是字符串 print(source[:200]) # 看一眼文本开头 data json.loads(source) print(type(data)) # class list 或 class dict如果数据是二进制bytes记得先decode成字符串比如source.decode(utf-8)。养成这个习惯之后你会发现很多莫名其妙的报错其实都出在最开始这一步上——你以为在操作字典实际上在操作字符串。2. 文件场景json.load和json.dump的正确打开方式2.1 读取JSON文件的标准姿势和编码细节读取JSON文件最标准的写法是用上下文管理器配合json.loadimport json with open(config.json, r, encodingutf-8) as f: data json.load(f)很多初学者会漏掉encodingutf-8。Windows环境的默认编码经常是GBK如果JSON文件里有中文直接open(config.json, r)读取很可能抛UnicodeDecodeError。加了encodingutf-8可以规避一部分问题。但这里还有一个更隐蔽的坑文件如果带了BOM头Byte Order Mark常见于Windows记事本另存为UTF-8的文件即便指定utf-8也可能在解析第一个键时报错错误信息大概是JSONDecodeError: Unexpected UTF-8 BOM。解决办法是把编码参数改成utf-8-sigwith open(config.json, r, encodingutf-8-sig) as f: data json.load(f)utf-8-sig会自动吃掉开头的BOM标记实际上对不带BOM的文件也完全兼容。所以如果你不确定文件来源直接用utf-8-sig是最稳的。读取阶段另外一个高频报错是json.JSONDecodeError。常见的触发原因有JSON文件末尾多了个逗号、文件里有//注释、文件内容被截断。JSON标准本身不允许注释和尾逗号所以能用Python标准json库直接解析的文件必须是严格合规的JSON。拿到的文件不合规优先和源头沟通修正格式别自己造解析器。2.2 写出文件时几个容易忽略的参数写出JSON文件我通常是这个模板import json config { version: 2.1, timeout: 30, retry: True } with open(config.json, w, encodingutf-8) as f: json.dump(config, f, ensure_asciiFalse, indent2)ensure_asciiFalse是最关键的参数之一。不加的话输出的JSON文件里中文会变成\u4e2d\u6587这种转义序列虽然语义上没错但肉眼检查、命令行grep都很痛苦而且和很多下游工具交接也不方便。加上之后文件里直接显示中文可读性好很多。indent2用来控制缩进生成的文件更接近给人看的配置而不是给机器压的包。如果你要传输给前端或者存日志希望体积更小可以反过来设置separators(,, :)去掉多余空格把文件压到最小。还有个细节容易被忽略直接对一个正在运行中的配置文件执行json.dump覆盖写入万一程序中途崩溃文件会损坏。稳妥的做法是先写临时文件再原子替换import json import os tmp_path config.json.tmp target_path config.json with open(tmp_path, w, encodingutf-8) as f: json.dump(config, f, ensure_asciiFalse, indent2) os.replace(tmp_path, target_path)os.replace在大多数系统上是原子操作要么成功替换要么保留原文件不会出现写到一半文件截断的情况。这个习惯在写自动化脚本时尤其有用。3. 接口场景loads与dumps才是爬虫和API对接的主战场3.1 从response.text到Python对象的完整链路做爬虫或者对接第三方API时你拿到的响应本质上是一段JSON文本需要转成Python对象才能横向处理。很多人用requests库的response.json()直接转换这个方法确实方便但遇到编码不正确的接口就会出问题最常见的症状是解析纯ASCII内容成功一旦接口返回中文就抛异常。我更推荐显式地处理import json import requests resp requests.get(https://api.example.com/recent, timeout10) resp.raise_for_status() text resp.content.decode(utf-8) data json.loads(text)先拿resp.content拿到底层bytes再用decode(utf-8)自己控制解码最后交给json.loads解析。这样做虽然多两行代码但把网络传输编码和JSON解析两个环节彻底分开了哪一步出问题都容易定位。有的接口返回的JSON最外层不是对象而是数组也就是[{...}, {...}]这样的结构。这种情况下json.loads得到的直接就是一个list可以立刻遍历。不要下意识以为JSON一定以花括号开头以中括号开头的JSON同样合法。3.2 构造请求参数时dumps的微妙之处很多接口要求请求体是JSON字符串尤其是POST接口。requests库设计得比较好requests.post(url, jsonpayload)会帮你把Python dict序列化成JSON字符串并设置Content-Type: application/json。但有的场景下你还是得手动json.dumps接口签名要求对JSON字符串做摘要校验业务需要把JSON字符串作为另一个字段嵌套进JSON里某些网关只认字符串形式的body手动序列化请求体时有个坑是序列化结果不稳定。同一个dict键顺序不同生成的JSON字符串就不同。如果你的接口需要计算签名这个差异会直接导致签名校验失败。解决办法是固定键顺序import json import hashlib payload {timestamp: 1710000000, app_id: demo, amount: 100} canonical json.dumps(payload, sort_keysTrue, separators(,, :), ensure_asciiFalse) sign hashlib.md5(canonical.encode(utf-8)).hexdigest()sort_keysTrue保证所有键按字母序排列separators(,, :)去掉多余空格确保同样的业务数据任何时候序列化结果都完全一致。这是做接口签名的常规操作能省掉大量联调时的为什么签名老不对类问题。3.3 接口返回结构复杂时先当一次结构侦探联调新接口我最先做的事情不是写业务逻辑而是把返回的JSON结构完整看一遍。但如果直接在控制台打印整个response几十层嵌套能刷好几个屏幕还容易把终端搞得乱七八糟。聪明的做法是写一个小函数只打印结构骨架不打印具体值def show_structure(obj, depth0, max_depth6): if depth max_depth: return if isinstance(obj, dict): for k, v in obj.items(): print( * depth f{k}: {type(v).__name__}) show_structure(v, depth 1, max_depth) elif isinstance(obj, list) and obj: print( * depth f[0]: {type(obj[0]).__name__}) show_structure(obj[0], depth 1, max_depth) else: print( * depth f{type(obj).__name__})这个函数输出类似code: str data: dict total: int list: list [0]: dict order_id: str amount: float一眼就能看出数据长什么样字段在哪个层级类型对不对。我把这个函数当成通用调试工具放在自己的util模块里每次对接新接口都先用它打一个结构图比翻接口文档还快。4. 嵌套再深也不怕复杂JSON的数据定位与提取4.1 链式取值为什么容易连环爆炸多层嵌套的数据结构取值时最直觉的写法是data[results][0][detail][price]。这在数据格式稳定的时候没问题可一旦中间某个环节缺失就会连环报错键不存在抛KeyError索引越界抛IndexError预期是dict结果拿到的是list抛TypeError大接口的数据偶尔缺字段太常见了。为了不被这种问题中断我习惯写一个安全取值函数把路径作为参数传入def safe_get(data, path, defaultNone): cur data for key in path: if isinstance(cur, dict) and isinstance(key, str): cur cur.get(key) elif isinstance(cur, list) and isinstance(key, int) and 0 key len(cur): cur cur[key] else: return default if cur is None: return default return cur调用方式price safe_get(data, [results, 0, detail, price], 0)路径里混了字典键和列表索引都可以处理。默认值按业务场景传0、空字符串还是空列表自己把握。这个函数帮我处理了大量脏数据属于性价比非常高的工具代码。4.2 不知道字段藏在哪一层写个搜索工具接口文档写得不全或者数据是第三方导出的你根本不知道某个字段藏在嵌套结构的第几层。这时候与其层层打印碰运气不如直接写一个递归搜索函数def find_all_keys(obj, target, path): results [] if isinstance(obj, dict): for k, v in obj.items(): new_path f{path}.{k} if path else str(k) if k target: results.append((new_path, v)) results.extend(find_all_keys(v, target, new_path)) elif isinstance(obj, list): for i, item in enumerate(obj): results.extend(find_all_keys(item, target, f{path}[{i}])) return results比如数据结构里藏着user_id但你不知道位置直接for path, value in find_all_keys(data, user_id): print(path, value)它会告诉你order.user_id、log[2].user_id这些完整路径和对应值。有时候一个key会在多个地方出现这个函数能全部找出来比看文档还直观。4.3 把JSON数组当成小型数据库筛选统计接口返回的json数组通常是一组相同结构的对象比如订单列表、用户列表。对这种数据Python列表推导式是最好的筛选工具orders json.loads(resp.text)[data][list] paid_orders [o for o in orders if o.get(status) 2] high_value [o for o in paid_orders if o.get(amount, 0) 1000]条件统计也很顺手success_count sum(1 for item in orders if item.get(success)) total_amount sum(item.get(amount, 0) for item in orders)再复杂一点按字段分组统计用dict手动分组就够了不需要引入pandasgrouped {} for item in orders: status item.get(status) grouped.setdefault(status, []).append(item)这类操作在我的日常里非常高频。接口数据拿到手先筛再算逻辑清晰性能也足够。如果数据量大到几十万条以上再考虑引入pandas或数据库小规模数据用列表推导式反而更轻便。4.4 进阶工具JSONPath快速提取如果嵌套层级太深你只想提取满足某个路径模式的一批值可以上JSONPath语法类似XPath。安装扩展库后可以直接用pip install jsonpathfrom jsonpath import jsonpath # 提取所有商品的价格 prices jsonpath(data, $..price) # 提取前两个订单的ID ids jsonpath(data, $.orders[0:2].order_id)返回结果是list匹配不到返回False要注意判断。JSONPath适合数据格式相对稳定、但路径很深的场景比手写多层循环简洁得多。也有命令行工具jq可以做类似事情处理本地文件很方便我偶尔用来快速预览不过项目代码里还是优先用Python脚本可维护性更好。5. 大JSON文件与流式处理别一口气吃成胖子5.1 JSON Lines格式逐行读取才是正道在做数据交换时经常遇到一种特殊格式文件后缀是.jsonl或者.ndjson一行一个独立的JSON对象而不是一个巨大的数组包住所有对象。这种设计的好处是每一行都能独立解析读多少处理多少不需要加载整个文件。处理这类文件正确的方式是逐行读取def read_json_lines(path): with open(path, r, encodingutf-8) as f: for line in f: line line.strip() if not line: continue yield json.loads(line)这是一个生成器每次yield一个对象。调用方逐个处理不需要把全部对象同时放内存里records read_json_lines(logs.jsonl) for record in records: process(record)如果你把整个文件直接json.load(f)最外层不是数组就会报错是数组也会全部载入内存。面对几个GB的日志文件轻则卡顿重则直接OOM。5.2 标准JSON大数组怎么处理ijson流式解析但现实是很多系统导出的还是标准JSON大数组整个文件就是一个[...]几百MB甚至几个GB。这时候json.load会把整个数组一次性解析进内存Python对象的内存占用往往比文件体积还大几倍机器稍微吃紧就扛不住。推荐用ijson做流式解析它像拉水管一样一点一点往外吐数据不会把你的内存撑爆pip install ijsonimport ijson with open(huge.json, rb) as f: for item in ijson.items(f, records.item): process(item)records.item表示外层数组字段名为records逐个取出其中的item对象。文件格式如果是顶层就是数组可以写成item。ijson的解析速度虽然比标准json模块慢一些但省下的内存非常可观几GB的文件也能处理。5.3 处理大数据的通用心态能流则流别全装我自己的经验是处理任何大数据量的JSON先问自己我真的需要全部数据同时存在内存里吗。大部分业务场景都是按条处理——清洗、转换、汇总、落库根本不需要把全部数据集合在内存里。如果是需要全局排序或者全量去重那是另一回事要考虑外排或数据库方案。如果只是逐条处理生成器加流式解析就是最优雅的解法。写成一个生成器既可以用for循环遍历又能配合itertools.islice只取前N条测试灵活度非常高。6. 我在JSON处理上踩过的四个经典坑6.1 中文全部变成\uXXXX当年第一次用json.dump写配置打开文件看到满屏\u4e2d\u6587时整个人都懵了。后来才明白默认json模块在序列化时会把非ASCII字符全部转义保证生成的JSON文本百分之百是纯ASCII。这在跨语言传输时是好设计但人读起来就是灾难。解决办法前面已经提过加ensure_asciiFalse。需要注意如果文件是给别人消费的尤其是老旧的JavaScript或某些数据库工具可能反而期望\u转义这时候就别关。看具体场景来不要养成永远加或永远不加的坏习惯。6.2 datetime和自定义对象序列化失败给接口传参数payload里有个datetime对象json.dumps直接抛TypeError: Object of type datetime is not JSON serializable。这个报错很多人遇到过解决办法是用default参数让json模块遇到不认识的对象时调自定义函数from datetime import datetime def default_convert(obj): if isinstance(obj, datetime): return obj.strftime(%Y-%m-%d %H:%M:%S) if hasattr(obj, isoformat): return obj.isoformat() raise TypeError(f无法序列化类型 {type(obj)}) data json.dumps(payload, defaultdefault_convert, ensure_asciiFalse)这个default钩子类似兜底翻译官遇到所有未知类型都会来问它。比到处手动转字符串省事得多。6.3 布尔值大小写和null空值JSON和Python在布尔值写法上不同JSON里是true、falsePython里是True、False。如果自己拼JSON字符串拼成Python的写法对方的解析器直接不认。用json.dumps来生成就不会犯这种错。再说nulljson.loads(null)的结果是None这是正常的。真正容易踩的是接口返回字段值为null而你的代码写成if data[field]:明明有值的情况可能没问题但值为空字符串或null时都走了False分支。该用is not None判断的时候就老老实实用is not None别图省事靠真值判断。6.4 重复键被静默覆盖JSON规范里其实不鼓励重复键但实际数据里可能会出现比如手写的配置文件里同一个key写了两次。默认json.loads会静默保留最后一个值前面的直接丢。如果你怀疑数据里有重复键想把它排查出来可以用object_pairs_hook钩子def check_dup_keys(pairs): d {} for k, v in pairs: if k in d: print(f发现重复键: {k}值将从 {d[k]!r} 覆盖为 {v!r}) d[k] v return d data json.loads(text, object_pairs_hookcheck_dup_keys)这个钩子会在每个dict对象构建前把所有键值对列表暴露给你加一点打印或告警逻辑重复键就无所遁形了。数据是从不可信的来源来时这个检查特别有用。还有一个坑和浮点数精度有关。JSON里的数字可能超过JavaScript安全整数范围Python int本身不怕大整数但如果你把很大的数转给浏览器或者某些只支持64位整数的下游系统精度可能丢。处理金融类数据时优先用字符串保留原始表示别用float接否则分秒之间钱就差一分两分都可能。最后分享一个我的个人习惯拿到新接口的数据后我在写任何业务代码前都会先保存一份原始JSON原文再保存一份结构图。原文留着回溯问题结构图留着写字段路径。真正处理JSON时最耗时间的往往不是写代码而是猜数据结构。有了结构图写代码就跟填空题一样简单。这套工作流我用下来翻车概率低了很多。
返回列表