Serde 元编程
第 1 章 为什么需要 Serde:M×N 问题与 M+N 解法
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,有三个必须回答的问题:
- Data Model 的 29 种类型够不够? 如果某个数据结构表达不出来,整个抽象崩溃。
- 抽象会不会带来性能损失? 中间层看起来多了一层开销,Rust 的零成本承诺还作数吗?
- 谁来为每个 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 内部会:
- 创建一个
serde_json::Serializer - 调用
user.serialize(&mut serializer) user.serialize内部会调用serializer.serialize_struct("User", 3)?- 然后依次调用
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<S>(&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
三个基石:
- Data Model(第 2-4 章):29 种原语定义了"所有可序列化数据的通用表示"。
Serialize和Serializertrait 通过这套表示通信。 - 过程宏(第 5-14 章):
serde_derive在编译期读取用户定义的数据结构,自动生成Serialize/Deserialize实现。用户只写一行#[derive(...)]。 - 零成本抽象(贯穿全专栏,第 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 设计。为什么Serializertrait 内有 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 章的直觉:
- 安装 cargo-expand:
cargo install cargo-expand。这是阅读本专栏最重要的工具。 - 最小复现:新建一个 crate,
cargo new --lib serde-demo。在Cargo.toml加上serde = { version = "1", features = ["derive"] }和serde_json = "1"。 - 观察 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 源码研究样本。 - 思考题:如果把
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 静态分发":本章"零成本"部分的编译器视角补充。