第 21 章 序列化与数据交换
这一章帮你把「内存里的对象」和「能在网络上飞的字节」之间来回转换,搞清楚 json、pickle、dataclass、pydantic 各自的边界,别再因为序列化踩出线上事故。
老哥,你写 Go 的时候,json.Marshal(u) 一把梭,struct 上加个 json:"user_id" 的 tag,类型、字段名、omitempty 都门儿清,跨服务丢出去对方也能稳稳解出来。到了 Python,你是不是也试过 json.dumps(obj) 然后原地报错:Object of type Order is not JSON serializable?你先想一个问题:Python 对象五花八门——datetime、Decimal、dataclass、自定义类——json 模块凭什么知道怎么把它们变成字符串?它其实只知道五种东西:dict、list、str、int/float、bool/None。超出这五种,它就撂挑子。
墨叔:对,序列化在 Python 里不是「一把梭」,而是「分层选择」。今天咱把这条链路捋顺,从最常见的 json 讲起,再到危险的 pickle,最后落到 dataclass/pydantic 与 dict/json 的互转,以及跨服务那点兼容的破事。
一、json 模块:dict 是桥梁(怎么用)
json 模块的哲学很朴素:它只认内置类型,不认你的业务对象。所以「对象 → JSON」的标准路径永远是「对象 → dict → JSON 字符串」。
import json
from dataclasses import dataclass, asdict
@dataclass
class Profile:
user_id: str
nickname: str
age: int
p = Profile("U-1", "墨叔", 38)
# dataclass -> dict
payload = asdict(p)
# dict -> json 字符串
text = json.dumps(payload, ensure_ascii=False)
print(text) # {"user_id": "U-1", "nickname": "墨叔", "age": 38}
# json 字符串 -> dict
back = json.loads(text)
print(back["nickname"])两个常用参数得记住:ensure_ascii=False 让你输出的中文不被转成 \u墨叔 这种乱码(对外接口默认 False 更友好);indent=2 用于日志/调试时美化输出。json.loads 永远返回 dict(或嵌套 list),不会自动还原成你的 dataclass——这点跟 Go 的 json.Unmarshal 直接填进 struct 不一样,Python 这边「反序列化回来是个裸 dict」,要变回对象得自己再包一层。
那非内置类型怎么办?比如 datetime、Decimal?json 模块给你留了 default= 钩子,让你自定义「遇到不认识的类型怎么转」:
import json
from datetime import datetime, timezone
def json_default(obj):
if isinstance(obj, datetime):
return obj.isoformat()
raise TypeError(f"不支持的类型: {type(obj)}")
now = datetime.now(timezone.utc)
text = json.dumps({"ts": now}, default=json_default, ensure_ascii=False)
print(text) # {"ts": "2026-09-17T12:00:00.123456+00:00"}注意 default 只管「序列化出去」,反序列化时没有对称的自动钩子——你得知道那个字段本是 datetime,自己 datetime.fromisoformat(back["ts"]) 还原。这是 Python 序列化里反复出现的主题:出去容易、回来要手动。
二、什么场景该用 pickle(以及为什么大多不该用)
pickle 是 Python 的「对象深拷贝/持久化」机制,它能把几乎任何对象(包括函数、类实例、闭包)原样序列化。听起来比 json 强?墨叔先泼冷水:pickle 是 Python 项目里最常见的「隐形炸弹」之一,能不用就不用。
import pickle
data = {"model": [1, 2, 3], "meta": {"v": 2}}
blob = pickle.dumps(data) # 序列化成字节
restored = pickle.loads(blob) # 还原,类型完整保留老哥你品品它和 json 的根本区别:json 是跨语言、纯文本、人能读的数据交换格式;pickle 是 Python 私有、二进制、人看不懂的对象快照。前者是「数据契约」,后者是「内存照片」。
为什么 pickle 危险?三点:
- 安全黑洞:
pickle.loads反序列化时会执行对象里携带的代码,攻击者只要给你一个精心构造的 pickle 字节流,就能在你的进程里执行任意命令。所以铁律:绝对不要 unpickle 任何来自不可信来源的数据,哪怕是你自己的 MQ、Redis 缓存,只要链路上有被篡改的可能,就别用 pickle 存。 - 版本脆弱:pickle 依赖「类定义还在、模块路径没变」。你改了类名、挪了文件、升了 Python 版本,老 pickle 可能解不开或解成错的。Go 的 json 解不出来顶多字段对不上,pickle 解错是「静默变成另一个对象」。
- 跨语言不可能:Go 服务收到 pickle 字节流,一脸懵——它只认 json/protobuf。
那 pickle 什么时候值得用?墨叔给两条窄场景:同一进程/同一机器内的临时缓存(比如把训练好的本地模型对象存盘,下次直接 pickle.load 起来,不跨信任边界);或者进程间用 multiprocessing 传递复杂对象(它底层就用 pickle)。除此之外,跨服务、跨进程、对外接口,一律 json/protobuf。
对照 Go:Go 没有 pickle 这种「能序列化一切对象」的东西,它的 encoding/gob 类似但同样偏内部用途,且类型 stricter。Go 生态默认 json/protobuf 做跨边界交换——这反而逼着你想清楚「数据契约」,而不是糊里糊涂拍个内存快照出去。Python 给了你 pickle 这种便利,代价就是你要自己守住边界。
三、dataclass / pydantic 与 dict/json 互转(收益与代价)
dataclass 转 dict 用 asdict(递归转),转回 dataclass 呢?标准库没给现成 fromdict,得自己写,或者用 dataclasses.fields 反射:
from dataclasses import dataclass, asdict, fields
@dataclass
class Point:
x: int
y: int
def fromdict(cls, data: dict):
# 仅按字段名匹配,不做类型转换
return cls(**{f.name: data[f.name] for f in fields(cls) if f.name in data})
p = fromdict(Point, {"x": 1, "y": 2})这里暴露了 dataclass 的软肋:它管「结构」,不管「校验与转换」。你从外部 JSON 拿到 {"x": "1"}(字符串),fromdict 会直接把字符串塞进 x,类型注解 int 不会拦你。要在边界上做严格校验,就得请出 pydantic(教程第 12 章专门讲过配置,这里讲序列化视角)。
from pydantic import BaseModel
from datetime import datetime
class Event(BaseModel):
event_id: str
ts: datetime
payload: dict
raw = {
"event_id": "E-1",
"ts": "2026-09-17T12:00:00+00:00", # 字符串
"payload": {"k": "v"},
}
ev = Event(**raw) # pydantic 自动把字符串转成 datetime
print(ev.ts.year) # 2026,已经是 datetime 对象
out = ev.model_dump() # 转回 dict
print(out["ts"]) # 仍是 datetime 对象
out_json = ev.model_dump(mode="json") # 转成 JSON 友好类型(datetime->str)pydantic 的价值在「边界」:外部进来脏数据,它帮你强制转换类型 + 校验 + 给出清晰报错;出去时 model_dump(mode="json") 把 datetime/Decimal 等转成 JSON 原生类型。这几乎就是 Go 里你手写 json.Unmarshal + 字段校验的集大成版,而且错误信息对人更友好。代价是引入一个依赖、且运行时有一层转换开销——内部高性能路径、确定类型安全的代码,用 dataclass 更轻。
所以墨叔给你的选型尺:
- 内部、类型可信、追求轻量 → dataclass(
asdict出去,fromdict回来)。 - 边界、外部输入、要严格校验转换 → pydantic。
- 纯数据交换、不还原对象 → 直接 dict + json。
- 跨语言契约 → json/protobuf,绝不用 pickle。
四、贴近项目的例子:跨服务数据交换的兼容坑
假设你维护一个订单服务,要把订单事件发给下游的「积分服务」。你定义了这样的模型,并通过消息队列发 JSON:
import json
from dataclasses import dataclass, asdict, field
from datetime import datetime, timezone
from typing import Optional
@dataclass(frozen=True, slots=True)
class OrderCreated:
order_id: str
user_id: str
amount_cents: int
currency: str = "CNY"
created_at: datetime = field(default_factory=lambda: datetime.now(timezone.utc))
coupon: Optional[str] = None # 新版本加的字段
def to_json(self) -> str:
d = asdict(self)
# 手动把 datetime 转成 ISO 字符串,否则 json.dumps 会炸
d["created_at"] = self.created_at.isoformat()
return json.dumps(d, ensure_ascii=False)这里埋着几个真实的「跨服务坑」,墨叔给你点破:
坑一:datetime 不转就炸。 asdict 把 datetime 原样留下,json.dumps 不认识它,直接 TypeError。所以出 JSON 前必须自己把时间转成 ISO 字符串(或时间戳)。这也是为什么 pydantic 的 model_dump(mode="json") 省心——它替你转了。
坑二:向后兼容(加字段)。 你给 OrderCreated 加了 coupon 字段。老版本的积分服务收到新消息,JSON 里多了一个它不认识的 coupon 键——只要它用的是「只取自己认识的字段」的宽松解析(Go 的 json.Unmarshal 默认就是忽略未知字段),就不受影响,平滑升级。但反过来,如果你删了一个老字段,老消费者取不到就报错。所以墨叔的经验:加字段安全、删字段危险,协议演进优先加、慎删,必要时靠版本号(v2/)隔离。
坑三:字段名对不齐。 Go 服务发出来的 JSON 用驼峰 orderId,你的 Python 端想用下划线 order_id。单纯 json.loads 后是 data["orderId"],和你的 dataclass 字段名对不上,fromdict 会漏掉。三种解法:让 Python 端 fromdict 里加映射;或约定统一命名;或上 pydantic 用 alias(model_config = ConfigDict(populate_by_name=True) + Field(alias="orderId"))。Go 那边靠 struct tag 名正言顺地映射,Python 这边得自己补一层,这是两套哲学的差异。
坑四:数字精度。 金额别用 float!JSON 里 9.9 经过浮点可能变成 9.899999999999999。跨服务传钱务必用「分」的整数(amount_cents)或字符串。这跟你 Go 里用 int64 分、或 decimal 字符串是一个道理。
老哥,你琢磨一下:序列化从来不只是「把对象变字符串」,它本质是两个系统之间关于「数据长什么样」的契约。Go 用强类型 struct + tag 把契约焊死在代码里;Python 默认更松,所以你得更自觉地用 dataclass/pydantic 把契约显式化、用 json 字符串当通用语言、用「加字段不删字段」守兼容。两边殊途同归——都是为了让数据在飞过网线后,还能被正确、安全地还原。
五、让序列化更顺手的两种进阶写法
前面用 default= 函数处理 datetime,但当对象类型一多,一个个 isinstance 判断会很难看。更工程化的做法是写一个 JSONEncoder 子类,把「不认识的类型怎么转」集中收敛:
import json
from datetime import datetime, date
from decimal import Decimal
from enum import Enum
class ApiEncoder(json.JSONEncoder):
def default(self, obj):
if isinstance(obj, (datetime, date)):
return obj.isoformat()
if isinstance(obj, Decimal):
return float(obj) # 或 str(obj) 保精度
if isinstance(obj, Enum):
return obj.value
return super().default(obj)
text = json.dumps(
{"ts": datetime.now(), "price": Decimal("9.9"), "status": OrderStatus.PAID},
cls=ApiEncoder, ensure_ascii=False,
)cls=ApiEncoder 让所有 dumps 调用统一走这套规则,比到处传 default= 函数好维护得多。代价是:Encoder 必须覆盖你项目里所有「非 JSON 原生」的类型,漏一个就当场 TypeError。所以墨叔建议把它放进公共库、持续补全,别散落在各业务文件里各写各的。
反序列化那侧的对称问题要讲清:json 出来的是字符串,怎么知道哪个字段本是 datetime?光靠 json.loads 拿不回类型。两条路:其一用 pydantic,它按字段注解自动 fromisoformat 还原;其二手写 object_hook,在 json.loads(..., object_hook=...) 里按字典结构猜类型。但 object_hook 只能基于「长什么样」猜,远不如 pydantic 的显式模型可靠。所以墨叔的结论和上一章一致:边界反序列化优先 pydantic。
还有个性能提示:标准库 json 够用但偏慢。高吞吐接口(比如每秒上万条消息的网关、批量导出服务)可以换 orjson(第三方、Rust 实现,快数倍,还直接支持 datetime/numpy 序列化)。不过它返回 bytes 而非 str,调用处要按需 .decode() 或配合响应体直接发。这是典型的 trade-off:标准库零依赖稳、第三方换性能——按你接口的瓶颈选,别提前优化,先测再换。
六、版本兼容的实操清单
把上面那些坑落成可执行的纪律,墨叔给你一张检查单,写跨服务数据交换前过一遍:
- 时间统一 UTC:发出去前
isoformat()(带+00:00),别发本地时间字符串;消费方fromisoformat还原。数据库列用TIMESTAMP WITH TIME ZONE或存 Unix 秒。 - 金额用整数分或字符串:绝不用 float 传钱,
9.9经 JSON 可能变成9.899999999999999。 - 加字段、慎删字段:协议演进优先新增可选字段;删字段前先确认所有消费者已升级,必要时走
v2隔离。 - 字段名对齐:跨语言对接(Go 驼峰 vs Python 下划线)用显式映射或 pydantic
alias,别靠约定「应该一样」。 - 未知字段容忍:消费者解析要「忽略不认识的键」,Go 的
json.Unmarshal默认如此,Python 端别用严格模式。 - 版本号随身:消息体带
schema_version,消费方可按版本分支,显式兼容。 - 别用 pickle 跨边界:内部缓存若用 pickle,加 HMAC 防篡改;对外一律 json/protobuf。
这张单子每一条背后都是一次真实事故。墨叔见过最贵的 bug 就是「A 服务发本地时间、B 服务当 UTC 存」,导致对账系统时差八小时、金额全错。序列化不是技术细节,是系统间的信任契约。
最后提醒一点:别为了「优雅」过度设计。如果一个接口只在你自己的两个 Python 服务之间传数据、且字段简单,直接 dataclass + asdict + json.dumps 就够,没必要引入 pydantic 或上 protobuf。工具是按需取的——边界复杂、外部不可信、要长期演进,才值得堆更重的武器。过早把简单事复杂化,反而拖慢迭代、增加维护负担。墨叔一直说:高级工程师的标志不是「用了多炫的特性」,而是「在正确的地方用恰好够的工具」。
【思考题】
- 为什么墨叔说「pickle 是隐形炸弹」?你在什么场景下会(谨慎地)用它,又在什么场景下绝对碰都不碰?延伸到:如果你们团队用 Redis 缓存对象,用 pickle 还是 json,你会怎么选,为什么?
- dataclass 的
asdict出去很容易,但从 JSON 回来到 dataclass 却要自己写fromdict,而且不校验类型。如果你要做一个「接收外部 API 数据并转成内部模型」的模块,你会选 dataclass 还是 pydantic?讲清楚收益和代价。 - 跨服务协议演进时,为什么说「加字段安全、删字段危险」?结合 Go 的
json.Unmarshal默认忽略未知字段的行为,设计一个向后兼容的版本升级策略。
【参考答案】
-
pickle 的危险在于反序列化即执行:它序列化对象时连同其类的引用和状态一起存,loads 时会在你的进程里重建对象、可能执行
__reduce__等钩子里的代码,于是攻击者构造一个恶意 pickle 字节流就能 RCE。所以任何不可信来源(外部请求、第三方 MQ、公网 Redis)绝不 unpickle。我能谨慎使用它的场景仅限:单机、同进程、同信任域内的临时持久化,比如把训练好的 scikit-learn 模型对象存到本地磁盘、下次直接 load;或 multiprocessing 跨进程传复杂对象(它底层就用 pickle)。关于 Redis 缓存:如果缓存的是「自己写进去、自己读出来、链路可信」的本地对象,且追求零转换开销,pickle 可用但要加校验(比如 HMAC 防篡改)。更稳妥、跨语言友好、可调试的做法是 json——牺牲一点性能,换来「人能看、Go/其他服务能读、不被 RCE」三条安全红利。缓存通常对序列化格式不敏感,墨叔默认选 json(或 msgpack 折中),把 pickle 留给真正的内部对象快照。 -
我会选 pydantic。理由是:外部 API 数据是「不可信边界」,你需要的是「强制类型转换 + 字段校验 + 清晰报错」三件套,而这正是 pydantic 的强项。
Event(**raw)能把"2026-09-17T..."字符串自动转成 datetime,能把缺字段、类型不符的情况用ValidationError明确抛出,定位极快。dataclass + 手写fromdict的问题是:类型注解不强制、fromdict不做转换,脏数据(字符串塞进 int 字段)会一路带毒进业务层,等到某个计算炸了才回头查,排查成本高。代价方面,pydantic 有运行时开销、且多一个依赖;但在「边界」这个少量但关键的位置,这点代价换来的健壮性完全值得。内部、类型已确信的代码路径上我仍用 dataclass 保持轻量——一句话:边界用 pydantic 守门,内部用 dataclass 省心。 -
「加字段安全」是因为健壮的 JSON 解析器(Go 的
json.Unmarshal、Python 的json.loads再手动取字段)默认忽略不认识的键。旧消费者收到带新字段的消息,直接跳过未知键,功能不受影响,实现平滑前向兼容。「删字段危险」则相反:旧消费者代码里还引用着那个字段,新消息里没了,旧服务取不到就报错或拿到零值,引发线上故障。升级策略:(1) 优先采用「加字段、标注 deprecated、保留老字段若干版本后再删」的渐进式演进;(2) 重大不兼容变更走版本隔离,比如 topic 从order.created.v1切到order.created.v2,双写过渡期让双方都能消费,确认新消费者全量后再下线老格式;(3) 在消息体里带schema_version字段,消费者据此分支处理,做到显式兼容。这跟 Go 后端做 API 版本化的思路一致:用「向后兼容的增量变更 + 必要时的版本号隔离」把破坏性变更关进笼子。