第 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 危险?三点:

  1. 安全黑洞:pickle.loads 反序列化时会执行对象里携带的代码,攻击者只要给你一个精心构造的 pickle 字节流,就能在你的进程里执行任意命令。所以铁律:绝对不要 unpickle 任何来自不可信来源的数据,哪怕是你自己的 MQ、Redis 缓存,只要链路上有被篡改的可能,就别用 pickle 存。
  2. 版本脆弱:pickle 依赖「类定义还在、模块路径没变」。你改了类名、挪了文件、升了 Python 版本,老 pickle 可能解不开或解成错的。Go 的 json 解不出来顶多字段对不上,pickle 解错是「静默变成另一个对象」。
  3. 跨语言不可能: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:标准库零依赖稳、第三方换性能——按你接口的瓶颈选,别提前优化,先测再换。

六、版本兼容的实操清单

把上面那些坑落成可执行的纪律,墨叔给你一张检查单,写跨服务数据交换前过一遍:

  1. 时间统一 UTC:发出去前 isoformat()(带 +00:00),别发本地时间字符串;消费方 fromisoformat 还原。数据库列用 TIMESTAMP WITH TIME ZONE 或存 Unix 秒。
  2. 金额用整数分或字符串:绝不用 float 传钱,9.9 经 JSON 可能变成 9.899999999999999。
  3. 加字段、慎删字段:协议演进优先新增可选字段;删字段前先确认所有消费者已升级,必要时走 v2 隔离。
  4. 字段名对齐:跨语言对接(Go 驼峰 vs Python 下划线)用显式映射或 pydantic alias,别靠约定「应该一样」。
  5. 未知字段容忍:消费者解析要「忽略不认识的键」,Go 的 json.Unmarshal 默认如此,Python 端别用严格模式。
  6. 版本号随身:消息体带 schema_version,消费方可按版本分支,显式兼容。
  7. 别用 pickle 跨边界:内部缓存若用 pickle,加 HMAC 防篡改;对外一律 json/protobuf。

这张单子每一条背后都是一次真实事故。墨叔见过最贵的 bug 就是「A 服务发本地时间、B 服务当 UTC 存」,导致对账系统时差八小时、金额全错。序列化不是技术细节,是系统间的信任契约。

最后提醒一点:别为了「优雅」过度设计。如果一个接口只在你自己的两个 Python 服务之间传数据、且字段简单,直接 dataclass + asdict + json.dumps 就够,没必要引入 pydantic 或上 protobuf。工具是按需取的——边界复杂、外部不可信、要长期演进,才值得堆更重的武器。过早把简单事复杂化,反而拖慢迭代、增加维护负担。墨叔一直说:高级工程师的标志不是「用了多炫的特性」,而是「在正确的地方用恰好够的工具」。

【思考题】

  1. 为什么墨叔说「pickle 是隐形炸弹」?你在什么场景下会(谨慎地)用它,又在什么场景下绝对碰都不碰?延伸到:如果你们团队用 Redis 缓存对象,用 pickle 还是 json,你会怎么选,为什么?
  2. dataclass 的 asdict 出去很容易,但从 JSON 回来到 dataclass 却要自己写 fromdict,而且不校验类型。如果你要做一个「接收外部 API 数据并转成内部模型」的模块,你会选 dataclass 还是 pydantic?讲清楚收益和代价。
  3. 跨服务协议演进时,为什么说「加字段安全、删字段危险」?结合 Go 的 json.Unmarshal 默认忽略未知字段的行为,设计一个向后兼容的版本升级策略。

【参考答案】

  1. pickle 的危险在于反序列化即执行:它序列化对象时连同其类的引用和状态一起存,loads 时会在你的进程里重建对象、可能执行 __reduce__ 等钩子里的代码,于是攻击者构造一个恶意 pickle 字节流就能 RCE。所以任何不可信来源(外部请求、第三方 MQ、公网 Redis)绝不 unpickle。我能谨慎使用它的场景仅限:单机、同进程、同信任域内的临时持久化,比如把训练好的 scikit-learn 模型对象存到本地磁盘、下次直接 load;或 multiprocessing 跨进程传复杂对象(它底层就用 pickle)。关于 Redis 缓存:如果缓存的是「自己写进去、自己读出来、链路可信」的本地对象,且追求零转换开销,pickle 可用但要加校验(比如 HMAC 防篡改)。更稳妥、跨语言友好、可调试的做法是 json——牺牲一点性能,换来「人能看、Go/其他服务能读、不被 RCE」三条安全红利。缓存通常对序列化格式不敏感,墨叔默认选 json(或 msgpack 折中),把 pickle 留给真正的内部对象快照。

  2. 我会选 pydantic。理由是:外部 API 数据是「不可信边界」,你需要的是「强制类型转换 + 字段校验 + 清晰报错」三件套,而这正是 pydantic 的强项。Event(**raw) 能把 "2026-09-17T..." 字符串自动转成 datetime,能把缺字段、类型不符的情况用 ValidationError 明确抛出,定位极快。dataclass + 手写 fromdict 的问题是:类型注解不强制、fromdict 不做转换,脏数据(字符串塞进 int 字段)会一路带毒进业务层,等到某个计算炸了才回头查,排查成本高。代价方面,pydantic 有运行时开销、且多一个依赖;但在「边界」这个少量但关键的位置,这点代价换来的健壮性完全值得。内部、类型已确信的代码路径上我仍用 dataclass 保持轻量——一句话:边界用 pydantic 守门,内部用 dataclass 省心。

  3. 「加字段安全」是因为健壮的 JSON 解析器(Go 的 json.Unmarshal、Python 的 json.loads 再手动取字段)默认忽略不认识的键。旧消费者收到带新字段的消息,直接跳过未知键,功能不受影响,实现平滑前向兼容。「删字段危险」则相反:旧消费者代码里还引用着那个字段,新消息里没了,旧服务取不到就报错或拿到零值,引发线上故障。升级策略:(1) 优先采用「加字段、标注 deprecated、保留老字段若干版本后再删」的渐进式演进;(2) 重大不兼容变更走版本隔离,比如 topic 从 order.created.v1 切到 order.created.v2,双写过渡期让双方都能消费,确认新消费者全量后再下线老格式;(3) 在消息体里带 schema_version 字段,消费者据此分支处理,做到显式兼容。这跟 Go 后端做 API 版本化的思路一致:用「向后兼容的增量变更 + 必要时的版本号隔离」把破坏性变更关进笼子。