ARTICLE DETAIL

资讯详情

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

Python保存JSON文件全指南:序列化、中文编码与原子写入

Python保存JSON文件全指南:序列化、中文编码与原子写入 1. 保存JSON三个阶段最容易翻车的两个瞬间先说个真实场景你写好了爬虫提取了一堆数据准备落盘成JSON代码也就三五行import json data {name: 张三, score: 92, tags: [python, json]} with open(result.json, w) as f: json.dump(data, f)运行之后控制台一声不吭你以为成了。用编辑器打开result.json要么是挤成一行的乱码要么是满屏的\u5f20\u4e09要么文件路径根本不对、数据写到了别的地方。如果保存的是记录型业务数据等到真正要读回来的时候才发现字段丢了、格式坏了那才叫欲哭无泪。这就是最典型的保存JSON看起来简单实操起来全是细节的问题。保存JSON文件至少横跨三个环节Python对象到JSON文本的序列化、文件系统的写入与覆盖、编码与格式整理。任何一个环节出问题最终文件都不符合预期。我见过很多开发者在这上面卡半天不是不会写json.dump而是不知道它背后每个参数在干嘛、文件层和编码层各自有哪些坑。这篇文章就把保存JSON文件这件事彻底拆开讲清楚。核心围绕json标准库展开覆盖序列化原理、核心API参数、中文编码、路径工程化、异常处理、实际业务场景和实践避坑清单。适合刚入门Python的小白也适合被JSON编码坑过、想弄明白根因的进阶用户。2. Python对象与JSON类型之间的映射边界哪些能存哪些会被拒2.1 标准类型映射表先建立一个基本认知JSON不是Python的原生对象它是一种独立于语言的文本交换格式。Python的json模块做的事情就是翻译——把Python对象翻译成JSON文本。既然是翻译就必然存在哪些词能翻译、哪些词不能翻译的边界。Python标准库给出的映射关系如下Python类型JSON类型示例dictobject{name: json}list / tuplearray[1, 2, 3]strstringhelloint / floatnumber42/3.14boolbooleantrue/falseNonenullnull需要注意几个容易踩的区域tuple本身不是JSON里的类型但它会被序列化为数组int和float在JSON里都叫number但读取回来时Python会区分整型和浮点型True、False、None会被转成JSON的true、false、null首字母大小写不同这是新手最容易忽略的点——如果你手动写JSON文本写成了True或None标准JSON解析器会报错。2.2 会被拒收的类型JSON最让人头疼的就是那些超出边界的类型。以下这些在默认情况下直接序列化就会抛TypeErrorset集合无序结构JSON数组要求有序默认不支持。datetime、date、timeJSON没有时间类型标准库不知道如何表达。bytes/bytearray二进制数据不能直接进入JSON文本。Decimal虽然本质是数字但标准库默认不认。自定义类实例除非你告诉它怎么转。这里有个很直观的类比把JSON想象成一份快递单JSON官方规定了固定字段string、number、array、object、boolean、null你手里的Python对象是包裹里的物品。如果物品超过了快递单能填写的范围快递公司就不接单。json.dump这位收件员看到不认识的类型宁可拒收也不乱写。2.3 为什么默认解释不了这些类型根本原因是JSON规范刻意保持简洁它只定义了一套最小的数据交换格式。而Python的对象模型复杂得多比如set的数学性质无序、去重在JSON里找不到语义对应datetime在不同时区、不同格式下表达方式太多规范层面无法指定唯一方案。所以标准库的策略很明确宁缺毋滥让用户自己决定扩展规则。这就引出了一个重要的实操结论在调用json.dump之前先确认你的数据结构里没有非标准类型的漏网之鱼。我通常会在保存前做一次类型自检把数据结构里可能出现的特殊类型提前处理成字符串或标准类型而不是等TypeError出现后再去改。后面第6章我会专门讲自定义序列化的方案。3. 核心API拆解json.dump、json.dumps与那一堆让人绕晕的参数3.1 三个核心方法的区别Python的json模块里有三个高频序列化方法很多人经常搞混方法返回典型用途json.dump(obj, file)无返回值直接写入文件对象把对象写入文件一步到位json.dumps(obj)返回字符串先转成字符串再自行处理如写入、拼接、传输json.load(file)从文件解析读取JSON文件为Python对象json.loads(str)从字符串解析解析JSON字符串为Python对象dump和dumps的差别就是s英文里dump是动词倾倒dumps是名词复数记忆上可以直接记成带s的返回字符串。实际开发中如果你要保存到文件用json.dump最直接如果你需要日志输出、组装复合字符串、或者传给网络接口先用json.dumps转字符串更灵活。3.2 核心参数逐一说明json.dump和json.dumps的参数几乎一致这里以json.dump为例import json data { name: HoRain云, version: 1.0, features: [json, python, save], options: { pretty: True, compact: False } } # 基础写入 with open(config.json, w, encodingutf-8) as f: json.dump(data, f)ensure_ascii默认值是True意思是把所有非ASCII字符比如中文转成\uXXXX形式。这是乱码的头号来源。设成False才会保留中文原样with open(config.json, w, encodingutf-8) as f: json.dump(data, f, ensure_asciiFalse)indent默认是None输出会挤成一行。传入数字表示缩进空格数例如indent2输出结构会变得可读。注意indent传None和传0的行为不同传0会输出换行但不缩进传None是完全不换行。实际项目中配置类文件常用indent2或indent4而日志和存储用紧凑格式。separators控制分隔符。默认是(, , : )也就是逗号加空格、冒号加空格。要压缩体积时传入(,, :)去掉所有多余空格with open(data.min.json, w, encodingutf-8) as f: json.dump(data, f, ensure_asciiFalse, separators(,, :))sort_keys设为True会按键名排序。这个参数的好处是让输出稳定尤其是用版本管理工具对比JSON文件变化时键顺序稳定才能看出真正的diff。skipkeys默认False。如果字典里的键不是基本类型str、int、float、bool、None会抛TypeError。设成True会跳过这些键而不是报错——但很容易让数据静默丢失一般不建议为了省事开启。3.3 参数组合的实际效果我做一个完整的对比示例方便你直观看到这些参数的作用import json data {b: 2, a: [1, 2, {nested: True}], c: 中文} # 形式一默认一行ASCII转义 print(json.dumps(data, ensure_asciiTrue)) # 输出{b: 2, a: [1, 2, {nested: true}], c: \u4e2d\u6587} # 形式二人类可读中文保留 print(json.dumps(data, ensure_asciiFalse, indent2)) # 输出结构化多行中文原样显示 # 形式三压缩中文保留 print(json.dumps(data, ensure_asciiFalse, separators(,, :))) # 输出{b:2,a:[1,2,{nested:true}],c:中文}这三种形式分别适合不同场景形式一用于跨系统安全传输形式二用于人读的配置文件形式三用于存储和网络传输省空间、省带宽。理解了这三个参数JSON保存的表面功夫就基本够了。4. 中文乱码问题的完整解法ensure_ascii、utf-8与记事本的恩怨4.1 乱码的两个源头中文乱码是JSON保存中被问得最多的问题实际上根源只有两个很多人搞混了。第一个源头是ensure_asciiTrue导致的转义。此时文件内容虽然合法但人眼看到的是\u5f20\u4e09这样的转义序列。第二次用Python读取时json.load会自动还原成中文所以程序层面没有问题纯属人不可读。解决办法很简单ensure_asciiFalse。第二个源头是文件编码问题。即使ensure_asciiFalse如果你用open()写文件时没有指定编码或者指定了错误的编码写入的字节序列就不是UTF-8编码的中文。Windows平台尤其容易踩这个坑因为open()默认编码随系统区域设置走通常是gbk。这就导致两个不同的中文字节序列互相认不出对方。所以正确的保留中文写法必须同时满足两个条件with open(data.json, w, encodingutf-8) as f: json.dump(data, f, ensure_asciiFalse)只用ensure_asciiFalse而不指定encodingutf-8在Windows上依然可能写出GBK编码的文件只指定encodingutf-8而不加ensure_asciiFalse文件里照样是转义序列。这两个条件缺一不可是中文JSON保存的基本功。4.2 记事本打开UTF-8文件乱码的特殊情况还有一个非常诡异的场景文件确实是UTF-8编码、也保留了中文原样但用Windows记事本打开还是乱码。原因在于记事本默认用ANSI本机编码去猜文件除非文件带有UTF-8的BOMByte Order Mark字节顺序标记头。解决办法也很直接写文件时用utf-8-sig编码代替utf-8Python会自动在文件开头写入BOMwith open(data.json, w, encodingutf-8-sig) as f: json.dump(data, f, ensure_asciiFalse)这样用记事本打开就不会乱码。但要注意BOM会导致部分严格JSON解析器报错比如某些后端服务、Java的JsonParser会认为第一个字符不是合法JSON起始字符。从通用性角度我更推荐UTF-8无BOM除非你的下游明确要求用记事本打开看。如果确实要兼顾可以写程序时统一用utf-8只是自己换个编辑器查看。4.3 什么时候该保留 \uXXXX 转义虽然人不可读很讨厌但转义并非没有价值。\uXXXX形式的内容是纯ASCII文本在很多旧系统中传输更稳定比如某些消息队列、日志系统、或者把JSON嵌入到别的文本格式里时纯ASCII形式不会因为编码转换而损坏。另外\u转义也能避免一些比较字符时的问题——同样的中文UTF-8编码字节不同但转义后是固定的\uXXXX序列字节完全相同。所以我通常的建议是给人看的文件用ensure_asciiFalse给机器和跨系统传输的文件可以保留默认。这不是技术对错问题而是业务场景取舍。5. 从能保存到稳保存路径、覆盖写、原子化与并发场景5.1 路径问题别再用字符串拼接很多初学写路径喜欢拼字符串path data/ result .json这不是不能跑但一旦目录带中文、带空格、带Windows分隔符问题就来了。现代Python项目我建议直接用pathlibfrom pathlib import Path data_dir Path(data) # 等价于 os.makedirs(data_dir, exist_okTrue) data_dir.mkdir(parentsTrue, exist_okTrue) file_path data_dir / result.json with open(file_path, w, encodingutf-8) as f: json.dump(data, f, ensure_asciiFalse, indent2)mkdir(parentsTrue, exist_okTrue)这行写得很频繁它的作用是如果父目录不存在就逐级创建如果目录已存在也不会报错。没有这一步直接写文件到不存在的目录会抛FileNotFoundError。5.2 覆盖写入与数据保护open(path, w)是直接清空原文件再写。流程上很常见但风险是如果保存过程中程序崩溃原文件已经清空了新数据却还没写完数据就丢了。针对重要数据我常用的保护手段有几种。第一种最简单写之前改名备份file_path Path(data.json) if file_path.exists(): file_path.replace(file_path.with_suffix(.json.bak))第二种更稳用临时文件加os.replace原子替换。原理是先写到一个临时文件写完再一次性替换原文件。这样即使中途崩溃原文件始终是完好的import os import json from pathlib import Path target Path(data.json) tmp target.with_suffix(.json.tmp) with open(tmp, w, encodingutf-8) as f: json.dump(data, f, ensure_asciiFalse, indent2) # os.replace 是原子操作要么替换成功要么不替换 os.replace(tmp, target)这个模式在写配置文件、状态文件、缓存文件时特别有用。尤其是进程可能随时被中断的场景比如被kill -9原子替换能把文件写到一半的概率降到接近零。5.3 只追加不覆盖的特殊需求如果你要保存的是日志流、追加记录就不能用w模式了它会清空已有内容。此时有两种做法一种是把每个记录作为独立的JSON行写入也就是常说的JSONLJSON Lines格式with open(records.jsonl, a, encodingutf-8) as f: for record in records: f.write(json.dumps(record, ensure_asciiFalse) \n)这种格式的好处是支持追加、支持逐行读取、文件损坏时最多丢最后一行。另一种做法是每次读旧数据、合并、再整体写回这种适合数据量不大但结构是完整JSON对象的场景。用哪种取决于数据量和写入频率量小用整体写回量大用JSONL逐条追加。5.4 并发写入时的大坑如果你在写多线程或异步程序最可怕的不是报错而是看起来没报错但数据坏了。多个线程同时open(w)一个文件后写的内容会覆盖前面的多个线程同时追加写同一行可能互相交错。解决思路有几种加threading.Lock保护写入代码段适合单进程多线程。每个线程写不同文件最后合并。用multiprocessing.Manager的Queue汇总到单写者线程。这一块做后端服务、定时任务落盘的人尤其要注意。你没法保证每次只落一个任务但要保证每次写文件的命令是串行执行的。6. 序列化失败了怎么办异常根因、自定义Encoder与往返自检6.1 最常见的TypeError根因保存JSON时报错九成是TypeError: Object of type XXX is not JSON serializable。看到这个报错先别慌它只是告诉你数据结构里有标准JSON不认识的类型。常见的几个元凶按频率排datetime对象通常在日志、交易记录里出现。set通常在去重统计后想保存结果时出现。bytes通常在爬虫抓取二进制内容后随手放进字典里出现。numpy的int64、float32、ndarray在使用pandas/numpy时出现注意这不是Python内置类型。定位方法也很直接在json.dump前打印一下type()检查可疑字段或者用二分法逐个字段试。但更好的做法是写一个通用的安全转换函数。6.2 自定义 JSONEncoder 方案一种经典做法是写一个CustomEncoder继承json.JSONEncoder在default方法里定义遇到未知类型怎么转import json from datetime import datetime from decimal import Decimal class CustomEncoder(json.JSONEncoder): def default(self, obj): if isinstance(obj, datetime): return obj.isoformat() if isinstance(obj, Decimal): return float(obj) if isinstance(obj, bytes): return obj.decode(utf-8, errorsignore) if isinstance(obj, set): return list(obj) return super().default(obj) data { time: datetime.now(), amount: Decimal(19.99), tags: {a, b}, raw: bhello } with open(output.json, w, encodingutf-8) as f: json.dump(data, f, ensure_asciiFalse, clsCustomEncoder)这个方案最吸引人的地方是一次定义到处复用。你在多个项目里都可以把这个CustomEncoder抽成一个小工具模块以后遇到datetime、Decimal、set直接带上这个类问题全消。6.3 用 default 参数做快速兜底如果你不想写完整的Encoder类json.dump还有个default参数可以直接传一个函数def default_convert(o): if isinstance(o, datetime): return o.isoformat() if isinstance(o, set): return list(o) raise TypeError(f无法序列化类型: {type(o)}) with open(output.json, w, encodingutf-8) as f: json.dump(data, f, ensure_asciiFalse, defaultdefault_convert)区别在于cls更结构化适合作为工具类复用default更轻量适合脚本里快速处理。两者都不要抛TypeError之外的异常否则异常信息会变得很难懂。6.4 保存前的往返自检习惯我个人的一个习惯是保存JSON后立刻json.load验证一遍确认数据能原样读回来。这比只检查文件是否存在靠谱得多。import json # 保存 with open(data.json, w, encodingutf-8) as f: json.dump(data, f, ensure_asciiFalse, indent2) # 往返自检 with open(data.json, r, encodingutf-8) as f: loaded json.load(f) assert loaded data, 数据往返不一致 print(往返校验通过)这个习惯帮我抓出过很多次问题字段值从True变true、数字从Decimal变float、顺序调整、类型变化等。有时候写入能成功但读回来已经不是原来的语义。做一次往返自检能在开发阶段就暴露问题。7. 三个真实业务场景接口缓存、配置文件回写、数据导出7.1 场景一接口数据缓存减少重复请求爬虫或调用第三方接口时JSON保存最常见的用途就是缓存。思路是请求前检查本地缓存文件有没有、在不在有效期内有就直接读没有再请求并保存。import json import time from pathlib import Path cache_file Path(api_cache.json) cache_timeout 3600 # 1小时 def get_data_with_cache(cache_file: Path, fetch_func, timeout: int): # 1. 尝试读缓存 if cache_file.exists(): with open(cache_file, r, encodingutf-8) as f: cache_data json.load(f) if time.time() - cache_data.get(timestamp, 0) timeout: return cache_data[data] # 2. 请求新数据 fresh_data fetch_func() # 3. 保存并带上时间戳 payload {timestamp: time.time(), data: fresh_data} tmp cache_file.with_suffix(.json.tmp) with open(tmp, w, encodingutf-8) as f: json.dump(payload, f, ensure_asciiFalse) tmp.replace(cache_file) return fresh_data注意这里用了时间戳 临时文件替换两个技巧都是为了减少缓存文件损坏和多写覆盖的风险。缓存文件不像日志那样可丢坏了就得重新请求接口代价更大。7.2 场景二程序配置文件的读取与回写配置文件的JSON保存有一个特殊点用户可能修改过配置再回写时不能把人家的手动修改全冲掉。所以标准的做法是默认值 用户覆盖策略import json from pathlib import Path DEFAULT_CONFIG { host: 127.0.0.1, port: 8080, debug: False } config_path Path(config.json) def load_config(): if config_path.exists(): with open(config_path, r, encodingutf-8) as f: user_cfg json.load(f) # 合并以默认值为基础用户值覆盖 merged {**DEFAULT_CONFIG, **user_cfg} return merged # 不存在则直接创建默认配置 with open(config_path, w, encodingutf-8) as f: json.dump(DEFAULT_CONFIG, f, ensure_asciiFalse, indent2) return DEFAULT_CONFIG这里有个合并技巧{**default, **user}的顺序很重要后者会覆盖前者相同的键。这样用户加的新键保留没改的键用默认值覆盖了默认值的行为符合直觉。关于配置文件还要提醒一点JSON格式本身不支持注释即使你手工往配置文件里写//或#json.load也会直接报错。如果你需要注释就别用JSON改用YAML或TOML格式这是格式能力边界不是代码技巧能绕过去的。7.3 场景三数据导出给前端或其他系统有时候是导出数据给下游系统做展示或进一步处理。这时格式、编码的严谨性比人可读性更重要import json records [ {id: 1, title: JSON教程, tags: [python, json], active: True}, {id: 2, title: 数据导出, tags: [export], active: False} ] # 导出成带缩进但保持中文原样的文件 with open(export.json, w, encodingutf-8) as f: json.dump(records, f, ensure_asciiFalse, indent2) # 导出成压缩的单行文件给接口用 with open(export.min.json, w, encodingutf-8) as f: json.dump(records, f, ensure_asciiFalse, separators(,, :))给前端做展示我一般同时导出两版一版人类可读方便调试一版压缩版给生产环境用。注意导出的JSON必须是纯数组或纯对象不要在文件外层包不必要的字符串前缀。8. 一份踩坑清单与我的三条保命习惯8.1 高频问题对照表把上面所有内容浓缩成一张排查表。以后保存JSON出问题先对着这张表查症状根因快速解法打开的JSON全是\uXXXXensure_ascii未设为Falsejson.dump(..., ensure_asciiFalse)Windows下中文乱码open()缺encodingutf-8open(path, w, encodingutf-8)记事本打开UTF-8文件乱码缺BOM改用encodingutf-8-sig路径不存在报错目录没创建Path.mkdir(parentsTrue, exist_okTrue)保存成功但读回报错文件被写坏或编码不一致做json.load往返自检序列化报TypeError含set/datetime/bytes等自定义Encoder或default函数多线程写入数据损坏并发写同一文件加锁或写临时文件后原子替换文件被覆盖丢失w模式先清空先备份再用os.replace原子替换8.2 踩过的几个典型坑再啰嗦两句第一ensure_asciiFalse且open不指定encoding在Linux上大概率没事因为Linux默认UTF-8但换到Windows必炸。这个坑只在你迁移运行环境时才爆发最气人的是我在自己电脑上明明没问题——对环境差异而已。第二json.dump不报错不代表数据就正确。如果你把datetime经过默认参数处理转换成字符串再读回来就不是原来的对象了是字符串。做往返自检是唯一能提前发现这种语义变化的方式。第三压缩格式虽然省空间但人眼排查时完全没法看。我建议开发调试阶段永远indent2上线或存储阶段再压缩。别为了压缩而压缩先想想这个文件之后还要不要被人读。第四不要把JSON当成数据库用。数据量一旦上到几千上万行整体写入、整体读取的性能会很难看频繁追加更是噩梦。那时候该换SQLite就换SQLiteJSON保存适合的是配置、缓存、中间交换、轻量落盘而不是主存储。8.3 最后分享三条保命习惯我做了这么多年项目保存JSON相关的坑踩了不少最后真正沉淀下来的习惯只有三条。第一条是在多处复用的保存代码里统一封装成一个函数比如save_json(path, data, prettyTrue)内部固定写encodingutf-8和ensure_asciiFalse并且默认开启往返自检。这样全项目只维护一处逻辑再也不会漏参数。第二条是重要文件永不直接覆盖。哪怕是刚写完就马上读的文件我也习惯走临时文件 os.replace流程因为一旦程序崩溃或磁盘写满你就知道这两行代码值多少钱了。第三条是每次保存前先想一个问题这个JSON之后谁会读是用Python读还是用Java/JavaScript还是用记事本打开需求不同参数组合就不同给程序读可以压缩给人看得缩进给其他语言系统读要考虑转义和编码。想清楚这一点JSON保存这件事才算真的过关。JSON保存本质上不是一行json.dump完事的问题它牵扯到序列化边界、编码规则、文件系统的写入方式以及实际业务的容错需求。把这些点一个个理顺你写JSON就不会再靠试了。
返回列表