Serde 元编程

第 1 章 为什么需要 Serde:M×N 问题与 M+N 解法

作者 杨艺韬 · 7,376 字 · 发布于 · 更新于

1.1 一个被所有人忽视的工程灾难

在 2016 年以前,Rust 社区的数据序列化是一场灾难。

想象你在写一个后端服务:从 HTTP 请求里读 JSON、把配置文件写成 TOML 或 YAML、用 MessagePack 和另一个微服务通信、把热数据写进 Bincode 格式的缓存文件。你定义了 30 个业务数据结构——User、Order、Product、Config、Event……然后你遇到了第一个问题:

每一个数据结构,都要为每一种格式写一份序列化代码。

30 个结构体 × 5 种格式 = 150 份几乎重复的胶水代码。这还没完。当业务变化、User 结构多了一个字段,你得去 5 个地方同步修改。任何一处漏改,就是一个运行时 bug。

这个问题有个正式的名字,叫 M×N 问题——M 种数据结构、N 种格式,实现复杂度是 M 乘以 N。2016 年以前 Rust 生态的主流方案是 rustc_serialize(从编译器内部拆出来的官方 crate)。它的 Encoder/Decoder 已经有 emit_struct、emit_seq、read_struct_field 这样一层通用接口,算是用中间层避开了 M×N,但有几个致命缺陷:Decoder 要求按声明顺序逐个读字段(read_struct_field 的文档写明 "fields must be read in order"),JSON 这类键序任意的格式只能先把整个输入解析成一棵 Json 树再从树里取值;#[derive(RustcEncodable)] 依赖编译器内置支持、无法由库自己扩展;错误处理粗糙(Decoder::error 只接受一个 &str);read_str 返回的是 String,没有零拷贝反序列化的能力。社区开始意识到,如果 Rust 想成为一门严肃的系统编程语言,必须先解决序列化这个底层问题。

2017 年 4 月 20 日,由 Erick Tryzelaar 于 2014 年创建、后来交由 David Tolnay 维护的 Serde 发布了 1.0(仓库里 v1.0.0 标签指向 2017-04-20 的 Release 1.0.0 提交 d7ccef0c;最早的提交是 2014-05-16 由 Erick Tryzelaar 推送的 initial commit,serde/Cargo.toml:4 的 authors 至今同时列着两人)。九年后的今天,Serde 是 crates.io 下载量最高的几个 crate 之一(总下载量排在前 20 名,和 syn、quote 等过程宏基础设施同处第一梯队;2026 年 9 月查询时排第 19,实时排名可在 crates.io 首页的 "Most Downloaded" 查证),几乎所有 Rust 项目都直接或间接依赖它。它定义了 Rust 生态如何处理序列化,并且在过程中意外地成为 Rust 过程宏系统最重要的案例。

本章要回答一个简单的问题:Serde 是怎么把 M×N 变成 M+N 的? 这个看似朴素的"数学公式转换"背后,藏着 Rust 零成本抽象最精彩的一次实战。

1.2 M×N 灾难的真实样貌

让我们先把"M×N 问题"具象化。假设没有 Serde,你需要为 User 结构体支持 JSON 和 Bincode 两种格式:

// 没有 Serde 的朴素实现——仅为说明问题,不是真实 API
struct User {
    id: u64,
    name: String,
    email: String,
}

// JSON 编码
impl User {
    fn to_json(&self) -> String {
        format!(
            r#"{{"id":{},"name":"{}","email":"{}"}}"#,
            self.id, self.name, self.email
        )
    }

    fn from_json(s: &str) -> Result<User, String> {
        // ... 手写 JSON parser,解析三个字段
        todo!()
    }
}

// Bincode 编码(假设格式:u64 + u32长度+字节 + u32长度+字节)
impl User {
    fn to_bincode(&self) -> Vec<u8> {
        let mut out = Vec::new();
        out.extend_from_slice(&self.id.to_le_bytes());
        out.extend_from_slice(&(self.name.len() as u32).to_le_bytes());
        out.extend_from_slice(self.name.as_bytes());
        out.extend_from_slice(&(self.email.len() as u32).to_le_bytes());
        out.extend_from_slice(self.email.as_bytes());
        out
    }

    fn from_bincode(bytes: &[u8]) -> Result<User, String> {
        // ... 手写二进制 parser
        todo!()
    }
}

这段代码有三个问题:

第一,大量重复。 to_json、to_bincode、from_json、from_bincode 四个方法做的事情本质上一样——把 User 的三个字段按某种规则写出去,或按某种规则读回来。规则变了,但"读三个字段"的动作没变。每次加字段、改字段名,四个方法都要修改。

第二,难以扩展。 如果要加 YAML 支持,你得再写两个方法 to_yaml 和 from_yaml。如果有 30 个结构体要支持 5 种格式,那就是 30 × 5 × 2 = 300 个方法。

第三,类型不安全。 from_json 返回 Result<User, String>,错误类型是 String——你失去了所有结构化的错误信息。调用者无法区分"字段缺失"和"类型不匹配"。

这就是 M×N 灾难。用一张图看得更清楚:

graph LR
    subgraph 数据结构 M 个
        U[User]
        O[Order]
        P[Product]
        D[...]
    end
    subgraph 格式 N 种
        J[JSON]
        Y[YAML]
        B[Bincode]
        M[MessagePack]
        T[TOML]
    end
    U --> J
    U --> Y
    U --> B
    U --> M
    U --> T
    O --> J
    O --> Y
    O --> B
    O --> M
    O --> T
    P --> J
    P --> Y
    P --> B
    P --> M
    P --> T
    D --> J
    D --> Y
    D --> B
    D --> M
    D --> T
    style U fill:#ffe0e0
    style O fill:#ffe0e0
    style P fill:#ffe0e0
    style D fill:#ffe0e0
    style J fill:#e0f0ff
    style Y fill:#e0f0ff
    style B fill:#e0f0ff
    style M fill:#e0f0ff
    style T fill:#e0f0ff

每一条连线代表一份实现代码。M 个结构体连向 N 种格式,一共 M×N 条线。当 M=30、N=5,总线数是 150——而且每加一种格式,要新画 30 条线;每加一个结构体,要新画 5 条线。

1.3 其他语言怎么解决这个问题

M×N 问题不是 Rust 独有的。所有支持多种序列化格式的语言都遇到过。让我们看一下其他主流语言的解法,才能理解 Serde 为什么选择了一条和它们都不同的道路。

Java / Python / Go:反射

Java 的 Jackson、Python 的 pickle、Go 的 encoding/json 都使用同一个思路:反射(reflection)。

反射的核心能力是——在运行时,程序可以"看到"自己定义的类/结构体有哪些字段、每个字段是什么类型、甚至读写私有字段。有了这个能力,一个 JSON 序列化器只需要实现一次:

// Go 风格伪代码
func ToJSON(obj any) string {
    t := reflect.TypeOf(obj)      // 运行时拿到类型信息
    v := reflect.ValueOf(obj)
    var out strings.Builder
    out.WriteString("{")
    for i := 0; i < t.NumField(); i++ {
        field := t.Field(i)       // 每个字段的元信息
        value := v.Field(i)       // 每个字段的实际值
        out.WriteString(fmt.Sprintf("\"%s\":%v,", field.Name, value))
    }
    out.WriteString("}")
    return out.String()
}

这段代码不需要知道 User 长什么样。它在运行时查询类型信息,动态遍历字段。一份代码通吃所有结构体——M×N 被反射降成了 N(每种格式写一份反射代码即可)。

这个方案看起来完美,但对 Rust 不可接受,原因有三:

第一,运行时开销。 反射必须查询类型元数据、做动态分发、遍历字段描述表。具体开销依赖语言实现、对象形状和格式库,不应脱离基准测试给出固定百分比。Rust 的核心承诺是"零成本抽象"——不能为了方便把每一次序列化都变成运行时类型查询。

第二,Rust 不支持反射。 Rust 编译器不生成可在运行时枚举字段的类型元数据。std::any::Any 提供有限的类型 ID 查询能力,但不能枚举字段。想要真正的反射,必须靠编译期代码生成——这又回到了 serde 的路线。

第三,运行时反射抹杀类型检查。 用 Go 的 encoding/json 时,如果结构体字段的 JSON 标签拼写错了,编译器不会提醒你;而且 Unmarshal 默认会静默忽略对不上的键(官方文档:"By default, object keys which don't have a corresponding struct field are ignored"),字段只是保持零值,连运行时错误都没有。Rust 的类型系统是它最大的武器,不能丢。

C++:手写 + 代码生成器

C++ 社区分两派。一派是 Boost.Serialization,让用户为每种类型手写一个以 Archive 为模板参数的 serialize 函数(成员函数模板或非侵入式的自由函数模板)——同一份函数可以喂给文本、二进制、XML 等各种 archive(官方文档:"specification of serialization is exactly the same for all archives"),格式一侧已经解耦,但每个类型的 serialize 仍要逐字段手写。另一派是 Protobuf、Thrift、Cap'n Proto,让用户先写一个独立的 .proto/.thrift 文件定义 schema,然后由外部代码生成器(如 protobuf 的 protoc)生成 C++ 代码。

代码生成路线的优点是性能极致、schema 可跨语言共享。缺点是:

  • schema 和类型系统割裂。你得在两个地方维护同样的信息:一份 .proto、一份 C++ 类。虽然生成器能从 .proto 生成 C++ 代码,但反过来不行——已有的 C++ 类型不能"自动变成" Protobuf 消息。
  • 构建流程复杂。.proto 编译要在 C++ 编译之前跑,构建系统(CMake、Bazel)要专门配置。
  • 格式绑定死。每个 .proto 绑定到 protobuf 体系:官方只额外提供了一套标准 JSON 映射(ProtoJSON),想换成 MessagePack、TOML 这类体系外的格式?重写一遍。

动态语言的启示

注意 Java/Python/Go 的反射方案有一个很诱人的隐含优点:它把"序列化器"和"数据结构"完全解耦。写 User 的人不用为序列化操心,写序列化器的人也不用为 User 操心。这是一种漂亮的抽象——一种可扩展性。

Serde 的设计者想要保留这个解耦,同时避开运行时反射的性能代价和类型不安全问题。答案是:把反射搬到编译期。

1.4 Serde 的核心洞察:用中间层击碎 M×N

Serde 的解法从一个数学观察开始:M×N 问题之所以是 M×N,是因为每个数据结构直接连到每种格式。如果插入一个中间层,让所有数据结构只连到中间层,所有格式也只连到中间层,那么总线数就变成 M+N。

graph LR
    subgraph 数据结构 M 个
        U[User]
        O[Order]
        P[Product]
        D[...]
    end
    DM[Serde Data Model<br/>29 种类型]
    subgraph 格式 N 种
        J[JSON]
        Y[YAML]
        B[Bincode]
        M[MessagePack]
        T[TOML]
    end
    U --> DM
    O --> DM
    P --> DM
    D --> DM
    DM --> J
    DM --> Y
    DM --> B
    DM --> M
    DM --> T
    style U fill:#ffe0e0
    style O fill:#ffe0e0
    style P fill:#ffe0e0
    style D fill:#ffe0e0
    style DM fill:#fff0a0
    style J fill:#e0f0ff
    style Y fill:#e0f0ff
    style B fill:#e0f0ff
    style M fill:#e0f0ff
    style T fill:#e0f0ff

这个中间层叫 Data Model——Serde 定义的一套"通用数据表示"。"29 种"这个数字不是约数,是 Serializer trait 文档里白纸黑字写的:"a way to categorize every Rust data structure into one of 29 possible types. Each method of the Serializer trait corresponds to one of the types of the data model."(serde_core/src/ser/mod.rs:274-277;Deserializer 侧同一段清单在 serde_core/src/de/mod.rs:843)。这 29 种按源码里的分组是:14 种原语(bool、i8/i16/i32/i64/i128、u8/u16/u32/u64/u128、f32/f64、char),加上 string、byte array、option、unit、unit_struct、unit_variant、newtype_struct、newtype_variant、seq、tuple、tuple_struct、tuple_variant、map、struct、struct_variant 这 15 种——14 + 15 = 29,一个不多一个不少(清单见 serde_core/src/ser/mod.rs:282-332)。

所有数据结构都只需要"告诉 Data Model 自己长什么样":User 告诉 Data Model "我是一个 struct,有 3 个字段,分别是 u64、String、String"。这通过 Serialize trait 实现。

所有格式只需要"接受 Data Model 的描述、按自己的规则写出去":JSON 收到 "struct 有 3 个字段" 的描述,就写 {"field1":..,"field2":..,"field3":..};Bincode 收到同样的描述,就按二进制布局写。这通过 Serializer trait 实现。

关键洞察:Serialize 和 Serializer 解耦了。User 实现 Serialize 时,不需要知道最终是 JSON 还是 Bincode;JSON 格式实现 Serializer 时,不需要知道数据来自 User 还是 Order。

有了这个中间层:

  • 新加一个数据结构(比如 Event):只要为它实现 Serialize 和 Deserialize,所有格式自动支持。这就是 #[derive(Serialize, Deserialize)] 的威力。
  • 新加一个格式(比如 CBOR):只要实现 Serializer 和 Deserializer,所有现有的数据结构自动支持。这就是 serde_cbor、rmp-serde、postcard 等格式 crate 的工作原理。

M+N 达成。

但魔鬼在细节里。这套抽象要真正 work,有三个必须回答的问题:

  1. Data Model 的 29 种类型够不够? 如果某个数据结构表达不出来,整个抽象崩溃。
  2. 抽象会不会带来性能损失? 中间层看起来多了一层开销,Rust 的零成本承诺还作数吗?
  3. 谁来为每个 User 结构体写 Serialize 实现? 如果要用户手写,M×N 只是变成了 M×(N=1),并没有真正解决问题。

下面三节分别回答这三个问题——它们对应本专栏后续的三条主线。

1.5 Data Model:29 种原语的取舍

Data Model 的设计本身就是一门艺术。太少的原语无法表达复杂数据;太多的原语让格式实现复杂化。Serde 最终选了 29 种。

设计意图:29 种原语的选取不是凭感觉,而是对"真实世界数据形态"的归纳。Serde 覆盖了:5 种有符号整数(i8..i128)+ 5 种无符号整数(u8..u128)+ 2 种浮点 + bool/char + 2 种字符串(str/bytes)+ option/unit + 2 种"命名但无数据"的形态(unit_struct、unit_variant)+ 2 种 newtype 包装(newtype_struct、newtype_variant)+ 4 种序列类(seq、tuple、tuple_struct、tuple_variant)+ 3 种映射类(map、struct、struct_variant),合计 29 种。每一种都对应 Rust 类型系统中一个常见场景。

比如为什么要区分 seq(同类型元素变长序列)和 tuple(异构类型定长)?因为 JSON 数组和 Rust 的 Vec<T> 对应 seq,而 (i32, String, bool) 这种元组的长度在编译期就确定——Bincode(1.3.3)对 seq 先写一个长度前缀,对 tuple 则直接省掉,因为反序列化的一方不看数据也知道长度。格式可以利用"我知道长度提前"这个信息做优化。

再比如为什么 struct 和 map 分开?看起来它们都是"key-value 集合"。区别是 struct 的 key 在编译期已知(字段名),map 的 key 在运行时才确定。Bincode 利用这个区别,对 struct 不写字段名(反正编译期双方都知道),省下大量字节;而对 map 就必须写 key。

Data Model 的完整设计是第 2 章的主题。这里只说结论:29 种原语足以表达 Rust 类型系统中几乎所有可序列化的形态,且每种原语都给了格式实现者利用特定信息优化的空间。

1.6 零成本:trait 与单态化的合谋

抽象有没有代价?让我们看一段简化的 Serde 调用:

let user = User { id: 1, name: "alice".into(), email: "a@x.com".into() };
let json = serde_json::to_string(&user).unwrap();

to_string 内部会:

  1. 创建一个 serde_json::Serializer
  2. 调用 user.serialize(&mut serializer)
  3. user.serialize 内部会调用 serializer.serialize_struct("User", 3)?
  4. 然后依次调用 serialize_field("id", &self.id)?、serialize_field("name", &self.name)? 等

步骤 3、4 都是 trait 方法调用。trait 方法调用有两种实现:

  • 动态分发(dynamic dispatch):&dyn Serializer,运行时查虚表。有开销。
  • 静态分发(static dispatch):<S: Serializer>,编译期单态化。零开销。

Serde 选择了静态分发(Serializer 声明为 pub trait Serializer: Sized,见 serde_core/src/ser/mod.rs:355,dyn Serializer 根本构造不出来)。看 Serialize trait 的真实定义:

// serde_core/src/ser/mod.rs:234(略去 trait 声明与方法签名之间的文档注释)
pub trait Serialize {
    fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
    where
        S: Serializer;
}

serialize 方法的 serializer 参数是泛型参数 S: Serializer,不是 &dyn Serializer。这意味着当你写 user.serialize(json_serializer) 时,编译器会为 User + serde_json::Serializer 这个具体组合生成一份专门的代码(单态化),把所有 trait 方法调用静态化成直接函数调用,然后内联、常量传播、死代码消除一通优化——最终产物和你手写的 user.to_json() 几乎一模一样。

设计意图:这里有一个关键权衡。静态分发零开销,但每个 (数据结构, 格式) 组合都会生成一份单态化代码,二进制膨胀。Serde 选了零开销、容忍膨胀——对系统编程来说这是正确的取舍。想要动态分发的用户可以用 erased-serde 这个 crate,它把 Serde 包装成 trait 对象,代价是每次调用多一层虚表分发、失去内联机会。

单态化 + 内联的组合会在第 18 章"静态 > 动态"一节再作总结。想亲眼看到证据,可以按第 3 章动手实验的提示用 cargo asm 查看 serde_json::to_string 的输出:没有虚表、没有间接调用。

1.7 derive 宏:让用户不再手写

M+N 的最后一个拼图是——谁来为 30 个业务结构体写 Serialize 实现?

如果要用户手写:

impl Serialize for User {
    fn serialize<S: Serializer>(&self, serializer: S) -> Result<S::Ok, S::Error> {
        let mut state = serializer.serialize_struct("User", 3)?;
        state.serialize_field("id", &self.id)?;
        state.serialize_field("name", &self.name)?;
        state.serialize_field("email", &self.email)?;
        state.end()
    }
}

这段代码的每一行都是机械的——它完全由 User 的字段结构决定。我们可以让编译器自动生成它。这就是过程宏的用武之地:

#[derive(Serialize)]
struct User {
    id: u64,
    name: String,
    email: String,
}

一行 #[derive(Serialize)] 告诉编译器:"请扫描 User 的字段,为我生成上面那段代码"。

具体怎么生成?Serde 提供了一个独立 crate serde_derive,它注册了一个过程宏:

// serde_derive/src/lib.rs:113
#[proc_macro_derive(Serialize, attributes(serde))]
pub fn derive_serialize(input: TokenStream) -> TokenStream {
    let mut input = parse_macro_input!(input as DeriveInput);
    ser::expand_derive_serialize(&mut input)
        .unwrap_or_else(syn::Error::into_compile_error)
        .into()
}

当编译器看到 #[derive(Serialize)],它把 User 的定义(完整的 TokenStream)传给 derive_serialize 函数,函数返回新的 TokenStream——也就是 impl Serialize for User { ... } 那段代码。编译器把这段代码插入当前 crate,继续编译。

整个过程发生在编译期。 运行时没有任何 "derive" 的痕迹——只有普通的 trait 实现代码。这就是为什么 Serde 能既有"反射级别的便利"又保持"零开销"。

第 5-9 章会完整讲过程宏工具链;第 10-13 章会拆解 serde_derive 的实现细节。这里只需要记住一个事实:Serde 用过程宏把"手写 Serialize 实现"这件事自动化了,完成了 M+N 解法的最后一块拼图。

1.8 三个基石:Data Model、trait、过程宏

把前三节串起来,Serde 的架构可以画成这张图:

graph TB
    subgraph 编译期
        A["#[derive(Serialize, Deserialize)]"]
        B[serde_derive 过程宏]
        C[生成 impl Serialize/Deserialize]
        A --> B --> C
    end
    subgraph 运行时
        D["User::serialize&lt;S&gt;(&self, s: S)"]
        E[Data Model 调用序列]
        F["serde_json::Serializer"]
        G[JSON 字节流]
        D --> |"serialize_struct, serialize_field..."| E
        E --> F
        F --> G
    end
    C -.生成.-> D
    style A fill:#fff0a0
    style B fill:#fff0a0
    style C fill:#fff0a0
    style D fill:#ffe0e0
    style E fill:#f0f0ff
    style F fill:#e0f0ff
    style G fill:#e0f0ff

三个基石:

  1. Data Model(第 2-4 章):29 种原语定义了"所有可序列化数据的通用表示"。Serialize 和 Serializer trait 通过这套表示通信。
  2. 过程宏(第 5-14 章):serde_derive 在编译期读取用户定义的数据结构,自动生成 Serialize/Deserialize 实现。用户只写一行 #[derive(...)]。
  3. 零成本抽象(贯穿全专栏,第 18 章总结):trait 泛型 + 单态化 + 内联让整套抽象在编译后基本消解,不留运行时分发的开销。

这三者缺一不可:

  • 没有 Data Model,Serialize 没有统一接口,M×N 回归。
  • 没有过程宏,用户要手写 M 份实现,等于 M×(N=1),收益减半。
  • 没有零成本抽象,中间层的性能代价会让用户抛弃 Serde 转向手写。

1.9 Serde 和它的竞争者们

Serde 不是 Rust 里唯一的序列化方案。理解竞争格局能帮你判断什么时候用 Serde、什么时候不用。

Prost / protobuf-rust:如果你必须用 Protobuf 格式(比如跨语言 RPC),Prost 是比 serde-protobuf 更好的选择——它基于 .proto 文件生成 Rust 类型,类型和 schema 一一对应。但它绑死 Protobuf,换格式重来一遍。

rkyv:零拷贝反序列化专用库。把二进制数据直接映射为 Rust 结构体,完全跳过"解析"步骤。据 rkyv README 推荐的社区测评 rust_serialization_benchmark,针对大 payload 的反序列化性能显著高于 Serde + Bincode(具体倍数视场景波动极大,读者应以自己场景实测为准),代价是格式固定、不兼容任何其他语言。适合 Rust-only 的高性能缓存。

手写 nom/winnow:对于协议实现(HTTP/2 帧、Postgres wire protocol),Serde 不合适——这些协议有复杂的控制流,不是简单的 struct-to-bytes。nom 是 Rust 最流行的 parser combinator 库,适合这类场景。

Serde 的统治领域:应用层数据交换——JSON API、配置文件、缓存、消息队列、数据库里的 JSON 列。在这些场景里,Serde 是事实标准,少有竞争者。

一个实用判断:如果你的数据可以用 struct + enum + Vec + HashMap 表达,并且你可能换格式,用 Serde。如果不满足(协议解析、极致零拷贝、单一格式锁定),考虑专用方案。

1.10 M+N 真实成本:把"中间层"放到秤上称一称

§1.4 给出 Serde 的核心公式 M+N——本章前半反复说"消除 M×N 灾难"——但这个中间层本身值多少行代码?实测把 §1.8 提到的"三个基石"对应到具体 crate——

基石 crate 真实行数
Data Model + trait serde_core (含 ser/ + de/) 11139(ser/ 3441 + de/ 7698,ch04 §4.11 实测)
过程宏(用户侧自动生成) serde_derive 8969(ch10 §10.2 实测)
工具链:TokenStream(未计入 syn、quote) proc-macro2 6029(ch06 §6.12 实测 v1.0.107)
三者合计 — 26137 行

这就是 "M+N" 的真实物理重量——#[derive(Serialize, Deserialize)] 这一行用户代码的背后、26137 行基础设施在支撑。但拿一组假设的规模估算一下"如果不用 Serde"的代价(下面的 M、N 和每对行数都是示意用的假设值,不是实测)——

  • M = 50 个用户类型、N = 8 种格式(JSON/YAML/TOML/MessagePack/CBOR/Bincode/Postcard/Avro)
  • 每对 (T, F) 平均 手写 80 行(包含 schema、解析、错误处理)
  • M×N 路线:50 × 8 × 80 = 32000 行用户代码
  • M+N 路线:50 × 30 行(类型定义连同 derive 注解,假设值)+ 8 × 1 个格式 crate 引入 = 大约 1500 行用户代码 + 26137 行 Serde 基础设施(写一次、所有用户共享)

对单个用户:基础设施由整个生态共享、不用自己维护,在这组假设下换来约 20 倍的用户代码减少(1500 vs 32000)。

对整个生态:26137 行只写一次,节省下来的是全 Rust 生态所有用户的 M×N 工程量——按 crates.io 上数以万计直接依赖 serde 的 crate 估算、节省的总代码量远超这 26137 行本身。

这条计算让 §1.4 "M+N 击碎 M×N" 从口号变成账本——任何质疑"中间层值不值得"的人都能拿这组数字算 ROI。这也从侧面说明了为什么 Serde 从 2017 年 1.0 一路到现在九年保持核心 API 不变(§3.11)——整个生态都架在这层基础设施上,breaking change 的代价要由所有下游一起承担。

1.11 三个 crate 把 M+N 固化成源码边界

M+N 不是一个口号,它在源码里被切成三条边界:

边界 源码锚点 负责的事 不负责的事
serde / serde_core serde/src/lib.rs:252-254 重导出 Serialize、Serializer、Deserialize、Deserializer 定义 Data Model 和 trait 协议 不知道 JSON、Bincode、YAML 的字节格式
serde_derive serde_derive/src/lib.rs:113-127 注册 Serialize / Deserialize 两个 derive 入口 读取用户类型,生成 trait impl 不解析 JSON,不执行用户业务逻辑
serde_json serde_json/src/lib.rs:396-406 暴露 from_str、to_string、Value 等格式 API 把 Serde Data Model 映射到 JSON 字节和 Value 树 不关心用户结构体字段如何被宏展开
flowchart LR
  User[用户类型] --> Derive[serde_derive 生成 impl]
  Derive --> Trait[serde_core 协议层]
  Trait --> Json[serde_json 格式实现]
  Trait --> Other[其他格式 crate]

这个分工解释了为什么 Serde 能长期稳定。serde/src/lib.rs:268-279 只是把 derive 宏作为可选 feature 重导出,核心 trait 本身不依赖过程宏;serde_derive/src/lib.rs:115 把输入先交给 parse_macro_input!(input as DeriveInput),说明宏入口只处理 TokenStream 到 AST 的转换;serde_json/src/lib.rs:400-404 暴露 to_string / to_writer / Serializer,说明格式 crate 的公共面是"把任何 Serialize 类型写成 JSON",而不是"认识每一个用户类型"。

工程上这带来三条直接后果。

第一,格式 crate 可以独立演化。serde_json 可以优化字符串转义、浮点格式化、错误定位,不需要修改 serde_derive。第 17 章会看到 serde_json/src/ser.rs 里 Serializer 和 Formatter 分层,正是这个边界的格式侧落地。

第二,宏生成代码可以保持格式无关。第 12、13 章展开 cargo expand 时,生成代码调用的是 serialize_struct、deserialize_struct、serialize_field、MapAccess::next_value 这些 trait 方法,而不是 write('{') 或 parse_string()。这意味着同一份 derive 结果可以喂给 JSON、MessagePack、CBOR。

第三,用户依赖可以按需裁剪。只写手工 impl 的库可以依赖 serde 而不开启 derive;需要 JSON 才引入 serde_json;需要宏才启用 serde = { features = ["derive"] }。这条裁剪线写在 serde/Cargo.toml:38-42:default = ["std"],derive = ["serde_derive"] 是单独一项,serde_derive 依赖本身标着 optional = true(serde/Cargo.toml:19)——即默认不编译过程宏。M+N 的真正价值不只是减少代码量,而是让"类型协议、代码生成、格式实现"三者可以分别测试、分别发布、分别优化。

还有一个经常被忽略的收益:错误隔离。用户类型写错属性,错误来自 serde_derive;JSON 输入不合法,错误来自 serde_json;某个格式不支持 map 的非字符串 key,错误来自格式 crate。边界清楚,排查路径就短。反过来,如果把反射、格式解析、类型规则塞进一个运行时大框架,用户看到的往往只是"serialization failed",很难判断是类型声明、格式语法还是协议约束出了问题。Serde 的 M+N 不是把复杂度消灭,而是把复杂度放到可定位的层里。

因此,本专栏后面读源码时会始终沿着这三层走:先看 trait 协议,再看 derive 如何生成协议调用,最后看格式 crate 如何兑现协议约束。

1.12 本章小结与预告

本章的核心是一个公式:

Serde = Data Model(中间层) + trait(抽象边界) + 过程宏(代码生成)

这三样东西把序列化的工程复杂度从 M×N 降到 M+N。每一部分都不是 Serde 发明的——中间层是由来已久的分层思想,trait 泛型是 Rust 原生特性,过程宏是编译器提供的能力。Serde 的天才之处在于把它们拼在一起,做到了反射方案和外部代码生成方案都难以兼得的三件事:运行时零开销 + 编译期类型安全 + 用户零负担。

后续章节的组织如下:

  • 第 2 章 深入 Data Model,列出全部 29 种原语,解释每一种的设计意图。
  • 第 3、4 章 拆解 Serialize/Serializer/Deserialize/Deserializer 四个核心 trait 的 API 设计。为什么 Serializer trait 内有 30 个 serialize_* 方法(ch03 §3.3 实测:23 个一次性 + 7 个状态机入口)、再加 7 个 SerializeXxx 子 trait 共 9 个 serialize_* 方法(ch03 §3.11.1)= 整个 serde_core/src/ser/mod.rs 里 39 个 serialize_*(去掉带默认实现的、补上 7 个 end,从零写一个 Serializer 实际要填 43 个函数体,ch03 §3.11.1 有账)?为什么 Deserializer 有 31 个 deserialize_* 方法、Visitor 有 27 个 visit_*?Visitor 模式从哪冒出来的?
  • 第 5-9 章 从零开始建立过程宏知识体系。TokenStream 是什么、syn 如何解析、quote 如何生成、如何写第一个可工作的 derive 宏。
  • 第 10-14 章 读 serde_derive 源码。你会看到本章描述的理论在真实代码里如何落地。
  • 第 15-16 章 高阶主题——借用反序列化(为什么 &'de str 能零拷贝?)、#[serde(with)] 和 remote 的实现。
  • 第 17 章 serde_json 源码:一个格式 crate 是如何把自己接入 Serde Data Model 的。
  • 第 18 章 总结 Serde 的设计哲学,并提炼出你可以用在自己项目里的模式。

动手实验

完成以下实验能帮你建立第 1 章的直觉:

  1. 安装 cargo-expand:cargo install cargo-expand。这是阅读本专栏最重要的工具。
  2. 最小复现:新建一个 crate,cargo new --lib serde-demo。在 Cargo.toml 加上 serde = { version = "1", features = ["derive"] } 和 serde_json = "1"。
  3. 观察 derive 展开:在 src/lib.rs 写:
    use serde::Serialize;
    
    #[derive(Serialize)]
    pub struct User {
        pub id: u64,
        pub name: String,
    }
    然后运行 cargo expand。你会看到 #[derive(Serialize)] 展开成几十行代码——一个包在 const _: () = { ... } 里的完整 impl Serialize for User 块(第 12 章 §12.14 有一份 3 字段结构体的实测展开输出可以对照,具体行数随字段数、属性和 rustfmt 版本浮动,以你自己跑出来的为准)。把它抄下来,这就是你的第一份 Serde 源码研究样本。
  4. 思考题:如果把 u64 改成 Vec<u64>,展开的代码会变成什么样?为什么?(提示:展开代码对每个字段只调用 serialize_field,不关心字段类型;Vec<u64> 靠它自己的 Serialize 实现被序列化成 seq。)

延伸阅读

  • Serde 官方文档站:数据模型、属性与格式实现的一手说明。
  • Rust RFC 1681: Macros 1.1:#[proc_macro_derive] 的来历,serde_derive 正是这个 RFC 的头号动机用例。
  • erased-serde:trait 对象版本的 Serde,理解静态分发的取舍。
  • 《Rust 编译器与运行时揭秘》专栏第 6 章"单态化"、第 7 章"Trait 静态分发":本章"零成本"部分的编译器视角补充。