Python 标准库 Day 2:数据序列化与配置管理
1. 问题引入
假设你正在搭一个个人博客网站,目录大概长这样:
my-blog/
├── config.toml # 数据库 URL、端口、API key
├── data/
│ ├── posts.json # 所有文章的元数据
│ └── visits.csv # 访问日志(从 nginx 导出)
├── cache/
│ └── rendered.pkl # 渲染好的 HTML 缓存
└── app.py
启动时你要做这几件事:
- 从
config.toml读出端口号和数据库连接字符串 - 把
posts.json加载成字典列表,方便按时间排序 - 把
visits.csv解析出来,统计每篇文章的 PV - 把渲染好的页面缓存起来,下次启动直接用
这四件事用的就是今天的五个库。每种格式都有擅长的场景,错配就是 bug 来源——比如有人把用户上传的数据 pickle.load,被 RCE 了;有人把中文 CSV 用默认编码写出来,Excel 打开全是乱码。
今天就把这五个库搞清楚,以及它们各自的边界。
2. 主题讲解
2.1 什么是序列化(Serialization)
先抓住核心概念:序列化 = 把内存里的对象,变成可以存盘或传输的字节/字符串;反序列化 = 反过来。
为什么需要它?三个原因:
- 持久化:内存是临时的,断电就没了,要存到磁盘
- 传输:网络只能传字节流,不能直接传 Python 对象
- 跨语言:你的 Python 程序要和 JavaScript 前端、Go 微服务对话,需要一种公共语言
不同的格式在这几个维度上做了不同取舍:
| 格式 | 人类可读 | 跨语言 | 类型丰富 | 安全 | 典型用途 |
|---|---|---|---|---|---|
| JSON | 是 | 是 | 只有基础类型 | 是 | API、配置、数据交换 |
| CSV | 是 | 是 | 全是字符串 | 是 | 表格、报表 |
| INI | 是 | 有限 | 否 | 是 | 老式配置 |
| Pickle | 否 | 否 Python 专属 | 几乎任何对象 | 危险 | 缓存、模型存储 |
| TOML | 是 | 是 | 比 JSON 多 | 是 | 现代 Python 配置 |
记住这张表,后面挑库的时候就不会乱。
2.2 json —— 互联网的通用语言
JSON 来自 JavaScript(JavaScript Object Notation),但今天它是事实上的跨语言数据交换标准。Python 标准库里的 json 模块只有四个核心 API:
json.dumps(obj) # Python 对象 → JSON 字符串
json.loads(s) # JSON 字符串 → Python 对象
json.dump(obj, f) # Python 对象 → 写入文件
json.load(f) # 文件 → Python 对象
记忆法:带 s 的处理 string,不带的处理 file。
类型映射(这个一定要熟):
Python JSON
─────────────────────────────
dict → object
list, tuple → array
str → string
int, float → number
True/False → true/false
None → null
注意 tuple 会被反序列化成 list——这是 JSON 不支持元组的副作用,第一次踩到会很懵。
常见误区
误区一:以为 JSON 支持所有 Python 类型
import json
from datetime import datetime
data = {"created_at": datetime.now()}
json.dumps(data)
datetime、set、bytes、Decimal、自定义类——这些 JSON 全都不认。解决办法是传 default= 参数告诉它怎么处理:
def to_serializable(obj):
if isinstance(obj, datetime):
return obj.isoformat()
raise TypeError(f"{type(obj)} not serializable")
json.dumps(data, default=to_serializable)
误区二:dict 的 key 必须是字符串
json.dumps({1: "a"}) # OK,但会变成 {"1": "a"}
json.dumps({(1,2): "a"}) # TypeError
JSON 规范规定 object 的 key 必须是字符串,所以即使你的 Python dict 用 int 当 key,序列化后也会被转成字符串。反序列化回来时是字符串而不是 int,这是经典的双向不一致问题。
误区三:中文乱码
json.dumps({"name": "张三"})
默认会把非 ASCII 转成 \uXXXX。要保留中文:
json.dumps({"name": "张三"}, ensure_ascii=False)
写文件时还要记得指定 encoding="utf-8",否则 Windows 默认 GBK 会再坑你一次。
2.3 csv —— 看似简单实则陷阱密布
CSV 是最古老的数据格式之一(1972 年就有了),看起来就是「逗号分隔值」嘛,但在工程实践里坑很多:
- 字段里包含逗号怎么办?→ 用引号包起来
- 字段里包含引号怎么办?→ 引号转义成两个引号
- 字段里包含换行怎么办?→ 也用引号包起来,但很多解析器处理不好
- 用 tab 还是逗号?→ 这叫 dialect(方言)
- 编码问题?→ 中文 CSV 在 Excel 里默认是 GBK,在 Mac 上又是另一回事
csv 模块就是来处理这些麻烦的。最好用的是 DictReader 和 DictWriter,把每行当字典处理:
import csv
with open("visits.csv", encoding="utf-8", newline="") as f:
reader = csv.DictReader(f)
for row in reader:
print(row["url"], row["timestamp"])
三个必须知道的坑
坑一:永远要写 newline=""
这是 Python 文档明确规定的。如果不写,在 Windows 上会出现空行,因为 csv 模块自己处理换行符,OS 又来帮一次倒忙,结果就是 \r\r\n。
坑二:编码问题要预判
- 从 Excel 另存为的 CSV 在 Windows 通常是
gbk或utf-8-sig(带 BOM 的 UTF-8) - Linux 工具产出的一般是纯
utf-8 - Mac Excel 又有自己的脾气
读不确定来源的 CSV 时,可以先用 utf-8-sig(它能兼容带 BOM 和不带 BOM 两种情况),不行再试 gbk。
坑三:所有字段都是字符串
row = {"age": "25", "score": "98.5"} # 注意这都是 str
age = int(row["age"]) # 要自己转
CSV 没有类型概念。这是它的简单也是它的局限。
2.4 configparser —— 老派的 INI 配置
INI 文件长这样:
[database]
host = localhost
port = 5432
user = admin
[logging]
level = INFO
file = /var/log/app.log
读取:
import configparser
config = configparser.ConfigParser()
config.read("app.ini")
print(config["database"]["host"]) # 'localhost'
print(config.getint("database", "port")) # 5432,注意要用 getint
关键认知
所有值默认都是字符串,要数字得用 getint()、getfloat()、getboolean()。这一点和 CSV 一样。
getboolean 接受的真值串:yes/no、true/false、on/off、1/0,不区分大小写。这是它比手写 value == "true" 强的地方。
支持插值(interpolation),可以引用其他值:
[paths]
home = /home/alice
data = ${home}/data
为什么它在被淘汰
INI 格式的根本缺陷:没有嵌套,没有数组,没有类型。要表达 servers = ["a", "b", "c"] 怎么办?只能写成 servers = a,b,c 然后自己 split——这已经是格式之外的约定了。
所以 Python 社区在 2016 年的 PEP 518 选了 TOML 作为新标准,2022 年 Python 3.11 把 TOML 解析器搬进了标准库(tomllib)。
configparser 还会用在哪?
- 维护老项目(Django 早期、Flask 一些扩展、
setup.cfg) - 系统级配置(
/etc/下的 ini 文件) - 给非程序员看的配置(INI 比 TOML 更直白)
新项目,优先 TOML。
2.5 pickle —— 强大但危险的二进制序列化
pickle 是 Python 专属的二进制序列化格式,几乎任何 Python 对象都能 pickle:函数引用、类实例、嵌套结构、numpy 数组……
import pickle
data = {"users": [1, 2, 3], "model": some_complex_object}
with open("cache.pkl", "wb") as f: # 注意是 wb 二进制写
pickle.dump(data, f)
with open("cache.pkl", "rb") as f:
data = pickle.load(f)
安全警告(重点)
永远不要 pickle.load 来源不可信的数据。
为什么?因为 pickle 的反序列化过程允许执行任意代码。一个恶意构造的 pickle 文件,load 的瞬间就能在你的服务器上 rm -rf /。
import pickle, os
class Exploit:
def __reduce__(self):
return (os.system, ('echo PWNED',))
payload = pickle.dumps(Exploit())
pickle.loads(payload) # 会执行 echo PWNED
判断标准:如果数据来自网络、用户上传、第三方 API,不要用 pickle,改用 JSON。
pickle 的合理用途
- 本地缓存(你自己生成、自己读)
- 多进程间通信(
multiprocessing内部就是用 pickle) - 机器学习模型存储(sklearn、PyTorch 早期都用 pickle,PyTorch 现在推荐
torch.save,本质也是 pickle) functools.cache持久化的辅助
vs JSON 速记
| pickle | json | |
|---|---|---|
| 可读 | 否 | 是 |
| 跨语言 | 否 | 是 |
| 支持类型 | 几乎全部 | 只有基础类型 |
| 安全 | 否 | 是 |
| 速度 | 通常更快 | 较慢 |
| 适用场景 | 本地缓存 | 数据交换、配置 |
2.6 tomllib —— Python 3.11+ 的配置新标准
TOML(Tom's Obvious, Minimal Language)是 GitHub 创始人 Tom Preston-Werner 在 2013 年设计的,目标是「比 INI 更强、比 YAML 更简单、比 JSON 更适合人写」。
title = "我的博客"
port = 8000
debug = true
[database]
host = "localhost"
port = 5432
users = ["alice", "bob"]
[server.tls]
cert = "/etc/ssl/cert.pem"
读取(注意:必须用二进制模式打开):
import tomllib # Python 3.11+
with open("config.toml", "rb") as f:
config = tomllib.load(f)
print(config["database"]["host"]) # 'localhost'
print(config["database"]["users"]) # ['alice', 'bob'] ← 真正的列表
关键认知
tomllib 只能读不能写。这是 Python 标准库的有意设计——保持最小职责。要写 TOML 文件,需要第三方库:
tomli-w:纯写入,简单tomlkit:保留注释、格式,编辑器友好
如果你的项目目标是「读配置」(绝大多数场景),tomllib 够用了。
为什么打开要用 "rb"? TOML 规范规定文件必须是 UTF-8。tomllib 自己处理解码,所以不允许你用文本模式打开(不然双重解码会出问题)。这个设计其实是从社区库 tomli 继承来的。
为什么 TOML 赢了
2016 年的 PEP 518 选 TOML 作为 pyproject.toml 的格式时,候选包括 YAML、JSON、INI。最终选 TOML 的理由:
- 比 YAML 简单:YAML 规范有 80 多页,缩进敏感、布尔陷阱(
no会被解析成False,挪威国家代码NO翻车过) - 比 JSON 适合人写:JSON 不能写注释,多一个逗号就报错
- 比 INI 强:原生支持嵌套、列表、日期时间等类型
今天你看到的 pyproject.toml、mypy 配置、black 配置、ruff 配置都用 TOML,这是 Python 生态的事实标准了。
3. Maybe Useful 旁注
旁注 1:JSON 为什么不支持注释? Douglas Crockford(JSON 发明人)有意拿掉的。他的原话大意是「我看到有人用注释来传递解析指令,破坏了互操作性,所以我把它拿掉了」。这就是为什么大家被迫发明了 JSON5、JSONC 这些扩展。
旁注 2:CSV 的「方言」是真实存在的csv 模块里有 csv.list_dialects(),能看到内置的 excel、excel-tab、unix。你也能注册自己的方言。这个抽象在你需要兼容多个 ERP 系统导出文件时特别有用。
旁注 3:pickle 的协议版本 pickle 有 0~5 共 6 个协议版本。Python 3.8 默认是 5,向前兼容旧版本但不能向后。跨 Python 版本传 pickle 时要显式 protocol=,不然新版本写的、旧版本读不出来。
旁注 4:面试常考——JSON 和 pickle 的区别 不要只答「一个文本一个二进制」。重点要答到:安全性(pickle 危险)、跨语言(pickle 不行)、类型丰富度(pickle 强)、适用场景(JSON 用于交换,pickle 用于本地缓存)。
旁注 5:YAML 不在标准库里 经常有人问「Python 怎么读 YAML」,答案是装 PyYAML。标准库不收 YAML 是因为它规范太复杂、安全问题多(yaml.load 也有代码执行风险,要用 yaml.safe_load)。这也反向印证了 TOML 的设计选择。
旁注 6:调试 JSON 的神器 命令行里 python -m json.tool input.json 可以格式化 JSON。配合 jq(不是 Python 的,但很常用)做 JSON 流处理,是数据工程师必备技能。
旁注 7:csv.field_size_limit 默认 CSV 单字段长度有上限(131072)。处理某些超大字段(比如把 base64 图片塞进 CSV 的奇葩场景)时会报 _csv.Error: field larger than field limit。解决:csv.field_size_limit(sys.maxsize)。这个坑很冷门但一旦遇到就抓狂。
4. 代码实践
我们围绕开头的博客场景写一个完整的 demo:读 TOML 配置 → 加载 JSON 文章 → 解析 CSV 访问日志 → 统计 PV → 输出 JSON 报表 → 缓存到 pickle。
这一段代码体现了今天五个库的协作。
"""
blog_stats.py - 博客访问统计工具
功能:合并文章元数据 + 访问日志,输出每篇文章的 PV 统计
"""
import json
import csv
import pickle
import tomllib
from datetime import datetime
from pathlib import Path
from collections import Counter
def load_config(path: Path) -> dict:
with open(path, "rb") as f: # 注意 "rb"
return tomllib.load(f)
def load_posts(path: Path) -> list[dict]:
with open(path, encoding="utf-8") as f:
return json.load(f)
def count_visits(path: Path) -> Counter:
counter = Counter()
with open(path, encoding="utf-8-sig", newline="") as f:
reader = csv.DictReader(f)
for row in reader:
counter[row["url"]] += 1 # 累加每个 URL 的访问次数
return counter
def build_report(posts: list[dict], visits: Counter) -> dict:
return {
"generated_at": datetime.now().isoformat(), # datetime 不能直接 JSON 化,转成字符串
"total_visits": sum(visits.values()),
"posts": [
{
"title": p["title"],
"url": p["url"],
"pv": visits.get(p["url"], 0),
}
for p in posts
],
}
def save_outputs(report: dict, json_path: Path, cache_path: Path):
# 5.1 确保父目录存在
json_path.parent.mkdir(parents=True, exist_ok=True)
cache_path.parent.mkdir(parents=True, exist_ok=True)
# JSON:人类可读,可以给前端
with open(json_path, "w", encoding="utf-8") as f:
json.dump(report, f, ensure_ascii=False, indent=2)
# pickle:自己下次启动时秒速加载
with open(cache_path, "wb") as f:
pickle.dump(report, f)
def main():
config = load_config(Path("config.toml"))
posts = load_posts(Path(config["paths"]["posts"]))
visits = count_visits(Path(config["paths"]["visits"]))
report = build_report(posts, visits)
save_outputs(
report,
Path(config["paths"]["report"]),
Path(config["paths"]["cache"]),
)
print(f"报表生成完毕,共统计 {report['total_visits']} 次访问")
if __name__ == "__main__":
main()
配套的几个数据文件(你可以自己创建测试):
[paths]
posts = "data/posts.json"
visits = "data/visits.csv"
report = "output/report.json"
cache = "cache/report.pkl"
[
{"title": "Python 入门", "url": "/python-intro"},
{"title": "标准库精讲", "url": "/stdlib"}
]
url,timestamp,ip
/python-intro,2026-04-28T10:00,1.1.1.1
/stdlib,2026-04-28T10:05,2.2.2.2
/python-intro,2026-04-28T10:10,3.3.3.3
这段代码值得注意的点
Path来自 Day 1 的pathlib——配置里读出来是字符串,立刻包装成Path,后续操作就跨平台了utf-8-sig读 CSV——兼容带 BOM 和不带 BOM 两种情况,是个稳妥的默认datetime.now().isoformat()——把 datetime 转成 ISO 8601 字符串,JSON 才能吃下去ensure_ascii=False, indent=2——中文不转义,缩进 2 格,生成的 JSON 文件能直接 review- JSON 给外部,pickle 给自己——这是最常见的双输出模式:JSON 用来对接前端/API,pickle 用来下次启动秒速加载
补充说明:open() 的 mode 参数有默认值"r"(文本读取模式),所以 open(path, encoding="utf-8") 等价于 open(path, mode="r", encoding="utf-8")。而 tomllib.load() 和 pickle.load() 要求二进制文件对象,所以必须显式写 "rb"。
open 的 "w" 模式只会创建文件本身,不会创建中间目录。如果 output/ 或 cache/ 不存在,open(..., "w") 会抛 FileNotFoundError。解决方案:
json_path.parent.mkdir(parents=True, exist_ok=True)
cache_path.parent.mkdir(parents=True, exist_ok=True)
两个参数的记忆口诀:「P 给我建全(parents),E 给我闭嘴(exist_ok)」。
"w" 模式打开文件会立刻清空原文件,即使后面写入失败。健壮的做法是「写临时文件 + 原子重命名」:
import os
import tempfile
def atomic_write_json(path: Path, data: dict):
path.parent.mkdir(parents=True, exist_ok=True)
with tempfile.NamedTemporaryFile(
mode="w", encoding="utf-8",
dir=path.parent, delete=False,
suffix=".tmp"
) as tmp:
json.dump(data, tmp, ensure_ascii=False, indent=2)
tmp_path = tmp.name
os.replace(tmp_path, path) # 原子替换
os.replace() 在 POSIX 和 Windows 上都是原子操作。这个模式叫 write-rename pattern,Git、SQLite、各种数据库的写盘都用类似套路。
5. 练习题
理论题
- 你有一个字典
{"items": {1, 2, 3}},能直接json.dumps吗?为什么?怎么解决?
回答
不能。
{1, 2, 3} 是 Python 的 set(集合),而 JSON 规范里没有 set 这种数据类型。JSON 只支持:object、array、string、number、boolean、null。所以 json 模块不知道该把 set 序列化成什么。
实际报错:
>>> json.dumps({"items": {1, 2, 3}})
TypeError: Object of type set is not JSON serializable
怎么解决(三种思路):
方案 A:先转成 list(最简单,最常用)
data = {"items": {1, 2, 3}}
data["items"] = list(data["items"])
json.dumps(data)
方案 B:用 default= 参数(适合数据结构里很多地方都有 set)
def to_serializable(obj):
if isinstance(obj, set):
return list(obj)
raise TypeError(f"{type(obj)} not serializable")
json.dumps({"items": {1, 2, 3}}, default=to_serializable)
方案 C:default=list(一行简洁版,但只对可迭代对象有效)
json.dumps({"items": {1, 2, 3}}, default=list)
注意一个隐藏问题:set 转 list 后顺序不固定。如果需要稳定顺序,先 sorted():
data["items"] = sorted(data["items"])
- 解释为什么
pickle.load不安全,并举一个你在工作中可能会接触到 pickle 的场景。
回答
pickle 反序列化的过程会执行任意代码。pickle 的本质不是「读数据」,而是「按照 pickle 字节流里的指令,重建对象」——而这些指令包括「调用某个函数」。攻击者可以构造一个恶意的 pickle 文件,让 pickle.load 在你机器上执行任意 Python 代码。
判断标准(这一条最重要):如果数据来源不是你自己 100% 控制的,永远不要 pickle.load。包括:网络下载的、用户上传的、第三方 API 返回的、邮件附件的。
工作中可能接触 pickle 的场景:
- 本地缓存:把昂贵的计算结果 pickle 到磁盘,下次直接读
- 机器学习模型:sklearn 的
joblib.dump底层就是 pickle;PyTorch 的torch.save也是 - 多进程通信:Python
multiprocessing模块在进程间传数据,底层用 pickle - 任务队列:Celery 默认用 pickle 序列化任务参数(注意:Celery 后来推荐换成 JSON,原因就是安全)
- Redis/Memcached 缓存复杂对象
以上场景的共同点是「数据是自己产生、自己消费」,所以可信。
tomllib.load(open("config.toml"))这行代码会报错,为什么?正确写法是什么?
回答
会报错:
TypeError: File must be opened in binary mode, e.g. use `open('foo.toml', 'rb')`
原因:tomllib.load() 要求接收二进制文件对象("rb" 模式打开),不接受文本模式。
为什么这么设计:TOML 规范明确规定文件必须是 UTF-8。tomllib 想自己负责解码,不想让用户的文本模式干扰。
正确写法:
with open("config.toml", "rb") as f:
config = tomllib.load(f)
- 写 CSV 时为什么必须
newline=""?不写会发生什么?
回答
不写会在 Windows 上出现多余的空行,导致 CSV 文件损坏。
原因(两层换行符处理):
csv模块自己处理换行。它在写入时会输出\r\n作为行分隔符- 文本模式的
open也会处理换行。Windows 上,文本模式会把你写入的\n自动转成\r\n
两层叠加:csv 模块写 \r\n → 文本模式再转 → \r\r\n,变成两个换行。
newline="" 告诉 open:「别管换行,原样输出」。
configparser读出来的port = 5432,类型是什么?怎么拿到 int?
回答
类型是 str——字符串 "5432",不是整数。
怎么拿 int(推荐方法):
port = config.getint("database", "port")
configparser 提供了一组类型转换方法:getint()、getfloat()、getboolean()。getboolean 尤其有用,因为它认得 "yes"、"on"、"1" 这些常见写法。
代码实操题
题目 A:配置迁移工具 写一个脚本,把一个 INI 配置文件转换成等价的 TOML 配置文件。
题目 B:CSV 健康检查器 写一个工具,扫描一个 CSV 文件并输出报告:行数、列数、每列有几个空值、每列推断的数据类型。结果输出成 JSON。
题目 C:安全的缓存装饰器 写一个 @disk_cache(path) 装饰器,把函数的返回值用 pickle 缓存到磁盘。
思考题
你的博客访问量上来了,每天访问日志(CSV)有 1GB。原来的脚本一次性 csv.DictReader 全读进内存,OOM 了。你会怎么改造它?涉及到哪些权衡?如果同时要保证「断点续传」,你会用什么格式记录进度状态?是 JSON、pickle、还是另一种?为什么?
6. 当天总结
今天学了什么:
五个序列化/配置库,分别对应五种场景:
json—— 跨语言交换,API 必备csv—— 表格数据,注意编码和 newlineconfigparser—— 老派配置,新项目慎选pickle—— Python 专属缓存,永不接受外部输入tomllib—— 现代 Python 配置新标准,只读
最重要的概念:
- "该用什么格式"是设计问题——根据「谁来读」「跨不跨语言」「是否要给人看」来选
- pickle 的安全边界——这是最容易在工作中出严重事故的点
- JSON 的类型映射——
tuple → list、int key → str key、datetime不支持 - CSV 的两个魔法字符串:
newline=""和encoding="utf-8-sig"
需要反复练习:
- JSON 处理自定义类型(
default=和object_hook=) - CSV 在不同编码、不同方言下的读写
- pickle 安全意识——看到陌生的
.pkl文件,要警惕
和明天的衔接:
明天 Day 3 讲 datetime / time / zoneinfo。你可能注意到今天 demo 里用了 datetime.now().isoformat()——这个 ISO 字符串格式、时区问题、JSON 里时间该怎么存,都是明天要展开的。