Serde 元编程
第 16 章 #[serde(with)]、remote、flatten:三个"扩展口"的实现机理
16.1 Serde 的三个救命属性
Serde 的属性系统里有三个特别复杂的——with、remote、flatten。它们不是普通的"重命名"、"跳过"这种简单行为——它们每个都是 Serde 为了应对现实世界的复杂性专门设计的扩展口。
#[serde(with = "mod::path")]:让单个字段使用自定义的序列化/反序列化函数。#[serde(remote = "path::ToType")]:为不属于你 crate 的类型生成 Serialize/Deserialize。#[serde(flatten)]:把字段的 struct 内容"展平"到父级 struct。
这三个属性在现实项目里不可或缺:
with让你为第三方类型(chrono::DateTime、uuid::Uuid)定制序列化格式。remote让你给没有实现 Serde 的第三方库类型加 Serialize/Deserialize 支持——无需修改它们的源代码。flatten让你应对"嵌套 struct 但 JSON 扁平"的现实——比如 API 响应里公共字段(status、code)和特定字段混在一起。
但它们的实现也比普通属性复杂一个量级。每一个都触发 serde_derive 里独特的代码生成路径、生成临时 struct、包装类型转换——是"模板引擎"逻辑上的巅峰。
本章拆解这三个属性的实现机理——不是"用户怎么用"(官方文档已经讲得清楚),而是"它们生成的代码长什么样、为什么这么设计"。
本专栏基于 serde 1.0.228。
16.2 #[serde(with = "module")] 的实现
最简单的例子:给 chrono::DateTime<Utc> 用自定义格式序列化。
用户代码:
use chrono::{DateTime, Utc};
use serde::{Serialize, Deserialize};
mod iso_format {
use chrono::{DateTime, Utc};
use serde::{Deserialize, Deserializer, Serializer};
pub fn serialize<S: Serializer>(t: &DateTime<Utc>, s: S) -> Result<S::Ok, S::Error> {
s.serialize_str(&t.to_rfc3339())
}
pub fn deserialize<'de, D: Deserializer<'de>>(d: D) -> Result<DateTime<Utc>, D::Error> {
let s = String::deserialize(d)?;
DateTime::parse_from_rfc3339(&s)
.map(|dt| dt.with_timezone(&Utc))
.map_err(serde::de::Error::custom)
}
}
#[derive(Serialize, Deserialize)]
struct Event {
#[serde(with = "iso_format")]
timestamp: DateTime<Utc>,
}
#[serde(with = "iso_format")] 告诉 serde_derive:"这个字段不用默认的 Serialize/Deserialize;改为调用 iso_format::serialize 和 iso_format::deserialize"。
挑战:serde_derive 生成的主流程里,字段的序列化是 state.serialize_field("name", &self.field)——第二个参数必须是实现了 Serialize 的值。iso_format::serialize 是一个函数,不是类型。
Serde 的解法:生成一个临时 wrapper struct——让它持有字段引用,并为它实现 Serialize(内部调用用户函数)。
生成代码(第 12 章讲过,这里完整呈现):
// 字段位置生成
SerializeStruct::serialize_field(
&mut state,
"timestamp",
&{
// 定义一个临时 struct
struct __SerializeWith<'__a> {
values: (&'__a DateTime<Utc>,),
phantom: PhantomData<Event>,
}
impl<'__a> Serialize for __SerializeWith<'__a> {
fn serialize<S: Serializer>(&self, s: S) -> Result<S::Ok, S::Error> {
iso_format::serialize(self.values.0, s) // 调用用户函数
}
}
__SerializeWith {
values: (&self.timestamp,),
phantom: PhantomData,
}
},
)?;
临时 struct __SerializeWith:
- 只持有对字段的引用(
&'__a DateTime<Utc>)——不 own 数据,零拷贝。 - 实现 Serialize,内部转发到用户函数。
- 用
PhantomData<Event>把容器类型(含它的泛型参数)挂进 wrapper——这样容器的 where 子句能原样搬到 wrapper 上而不会出现"未使用的类型参数"。
使用时:把这个临时实例的引用传给 serialize_field。serialize_field 调用 __SerializeWith::serialize,它又调用 iso_format::serialize(&self.timestamp, s)——函数被包装成了 trait 实现。
这是 Rust 里把"函数"变成"trait impl" 的经典手法——用临时 struct 作为 adapter。
Deserialize 侧类似(serde_derive/src/de.rs:664-708 的 wrap_deserialize_with,de/struct_.rs:254-258 调用它):
// visit_map 的分支里
__Field::__field0 => {
if __field0.is_some() { ... }
// 临时 wrapper:value 存字段值,phantom/lifetime 只是类型标记
#[doc(hidden)]
struct __DeserializeWith<'de> {
value: DateTime<Utc>,
phantom: PhantomData<Event>,
lifetime: PhantomData<&'de ()>,
}
#[automatically_derived]
impl<'de> Deserialize<'de> for __DeserializeWith<'de> {
fn deserialize<D: Deserializer<'de>>(d: D) -> Result<Self, D::Error> {
Ok(__DeserializeWith {
value: iso_format::deserialize(d)?, // 调用用户函数
phantom: PhantomData,
lifetime: PhantomData,
})
}
}
__field0 = Some(match __map.next_value::<__DeserializeWith>() {
Ok(__wrapper) => __wrapper.value,
Err(__err) => return Err(__err),
});
}
和序列化侧对称:wrapper 实现的是普通的 Deserialize(不是 DeserializeSeed),next_value::<__DeserializeWith>() 拿到 wrapper 后取出 .value 就是字段值。用户函数在 Deserialize::deserialize 里被调用。
serde_derive 的实现:Serialize 侧是 wrap_serialize_with(serde_derive/src/ser.rs:1207;wrap_serialize_field_with / wrap_serialize_variant_with 是它的两个入口,见 serde_derive/src/ser.rs:1173-1205),Deserialize 侧是 wrap_deserialize_with(serde_derive/src/de.rs:664)。生成临时 struct 的 quote! 模板是核心。
16.2.1 #[doc(hidden)] 和 #[automatically_derived] 双 attribute 标注
打开 ser.rs:1241-1248、临时 struct 生成时有两个 attribute:
quote!(&{
#[doc(hidden)]
struct __SerializeWith #wrapper_impl_generics #where_clause {
values: (#(&'__a #field_tys, )*),
phantom: _serde::#private::PhantomData<#this_type #ty_generics>,
}
#[automatically_derived]
impl #wrapper_impl_generics _serde::Serialize for __SerializeWith ...
两个 attribute 各有用意:
① #[doc(hidden)]——声明"这个 struct 是实现细节、不进文档"。要说清楚的是:这里的 #[doc(hidden)] 是冗余的防御,不是唯一把它藏起来的机制。quote!(&{ ... })(ser.rs:1241)把 __SerializeWith 生成在一个块表达式里——它是 fn serialize 函数体内的局部 item,rustdoc 本来就只收录从 crate 根可达的模块级 item,压根不会列它;何况整份 derive 产物还被 dummy::wrap_in_const 再包了一层 #[doc(hidden)] const _: () = { ... }(serde_derive/src/dummy.rs:15-30)。serde_derive 对所有生成的辅助 item 一律打这个标记,图的是生成代码风格一致、以及万一将来这段模板被挪到模块层级也不会漏。
② #[automatically_derived]——告诉工具链 "这个 impl 是宏自动生成的"。Rust Reference 对它的定义是 "It has no direct effect, but it may be used by tools and diagnostic lints to detect these automatically generated implementations"——它本身不改变语义,只是给工具一个标记。实际吃这个标记的地方之一是覆盖率插桩:rustc 从 rust-lang/rust#120185("coverage: Don't instrument #[automatically_derived] functions")起,不再给带此标记的 impl 里的函数插桩,派生实现不会以"未覆盖"的形式污染覆盖率报告。若干 clippy lint 也据此跳过生成代码。
这种"告诉工具链该忽略什么"的标注在 proc-macro 里很值钱——它们不改变程序行为,只改变工具看待这段代码的方式。serde_derive 的做法是"能标就标":辅助 struct 打 #[doc(hidden)]、生成的 impl 打 #[automatically_derived]、整份产物塞进匿名 const,三层叠加把生成代码从文档、lint、覆盖率报告里一起摘干净。
16.2.2 quote_spanned! 把错误位置精确指到 #[serde(with = "...")]
打开 serde_derive/src/ser.rs:1237,wrap_serialize_with 里有一段看似平平的代码:
// If #serialize_with returns wrong type, error will be reported on here.
// We attach span of the path to this piece so error will be reported
// on the #[serde(with = "...")]
// ^^^^^
let wrapper_serialize = quote_spanned! {serialize_with.span()=>
#serialize_with(#(#self_var.values.#field_access, )* #serializer_var)
};
quote_spanned! 而不是 quote!——这是 serde_derive 对错误信息精度的极致追求。
普通 quote! 生成的 token 都挂在 Span::call_site() 上——如果用户写的 iso_format::serialize 类型签名错了、编译器报错会指向 derive 宏调用的那一行(整个 struct 定义)——用户一看到"struct 定义这里有个奇怪的错误、找不到原因"。
quote_spanned! {serialize_with.span()=> ...——让生成的 token 挂在用户属性里 "mod::serialize" 那个字面量的 span 上。编译器报错时会精确指向 #[serde(with = "...")] 里的那段引号——用户一眼看到"我的自定义 serialize 函数签名错了"。
这是 Rust 宏作者对用户体验的极致打磨——花几行代码让错误信息从"毫无头绪"变成"一眼定位"。同样的技巧在 tokio 的 #[tokio::main](tokio-macros)、thiserror 等 proc-macro crate 里反复出现——高质量宏的标志之一就是错误定位的精准。
对比 C++ 模板错误的经典噩梦(一页页的 template instantiation chain)——Rust 宏通过 quote_spanned! 把错误体验从"考古学作业"变成"精确指针"。这是生态质量的基础。
16.3 with 支持单独的 ser/de 函数
#[serde(with = "module")] 要求 module 同时有 serialize 和 deserialize 函数。有时你只需要一个方向:
#[serde(serialize_with = "iso_format::serialize")] // 只序列化
#[serde(deserialize_with = "iso_format::deserialize")] // 只反序列化
实现上 serde_derive 把它们分别处理——serialize_with 和 deserialize_with 各自生成自己的 wrapper。with = "mod" 是 serialize_with = "mod::serialize" + deserialize_with = "mod::deserialize" 的语法糖。
// serde_derive/src/internals/attr.rs:830-845(变体级,在 Variant::from_ast 里;
// 字段级的一份同样的在 attr.rs:1124-1139,在 Field::from_ast 里)
} else if meta.path == WITH {
// #[serde(with = "...")]
if let Some(path) = parse_lit_into_expr_path(cx, WITH, &meta)? {
let mut ser_path = path.clone();
ser_path
.path
.segments
.push(Ident::new("serialize", ser_path.span()).into());
serialize_with.set(&meta.path, ser_path);
let mut de_path = path;
de_path
.path
.segments
.push(Ident::new("deserialize", de_path.span()).into());
deserialize_with.set(&meta.path, de_path);
}
}
短短几行代码把 with = "mod" 展开成两个独立属性——后续代码生成逻辑不用区分。
16.3.1 bound::with_lifetime_bound 注入 '__a 生命周期
看 ser.rs:1216-1220:
let wrapper_generics = if field_exprs.is_empty() {
params.generics.clone()
} else {
bound::with_lifetime_bound(¶ms.generics, "'__a")
};
__SerializeWith struct 里持有字段引用 values: (&'__a Field, ...)——需要一个生命周期参数 '__a。这个生命周期不在用户的 struct 定义里——必须由 derive 宏额外注入。
bound::with_lifetime_bound(serde_derive/src/bound.rs:354-385)干的事就是把用户 struct 的泛型参数(<T, U>)扩充成 <'__a, T: '__a, U: '__a>——在最前面加一个 lifetime,并给每个已有的类型参数和生命周期参数追加 : '__a 约束(引用 &'__a T 要求 T 至少活过 '__a)。where 子句靠 ..generics.clone() 原样带过来——用户 struct 有 where T: Clone、__SerializeWith 也有。
为什么要这么折腾?——因为 Rust 里任何涉及引用的类型都需要 lifetime 标注。如果 __SerializeWith 的 lifetime 不匹配外层 struct 的字段生命周期——编译失败。
with_lifetime_bound 负责的把用户的 generic 参数和 derive 引入的 lifetime 正确合并——用户写 struct Foo<T: Clone> { x: T }、生成的 __SerializeWith<'__a, T: Clone + '__a> 保留原约束、同时多一个 lifetime 和一个 outlives 约束。
field_exprs.is_empty() 的分支——如果没有字段(比如标了 serialize_with 的 unit 变体,经 wrap_serialize_variant_with 进来)、不需要 lifetime、不走 bound 处理。这种按需注入的精细是 serde_derive 生成代码尽量简洁的体现——不该加的属性就不加。
16.3.2 attr.rs 的 WITH 属性展开:单行到两行的重写
serde_derive/src/internals/attr.rs 里处理 WITH 属性的代码约 15 行、做了一个看似简单但意义重大的转换——把 with = "mod" 展开成等价的 serialize_with = "mod::serialize" + deserialize_with = "mod::deserialize"。
为什么这个展开重要?
① 简化下游代码——后续所有代码生成路径只需要处理 serialize_with 和 deserialize_with——不需要"同时处理 with"这个额外的分支。等价于在属性解析层做 normalization、让下游逻辑少一种可能性。
② 语法糖 vs 正交特性——with 是语法糖(绝大多数场景用它就够)、serialize_with/deserialize_with 是正交的底层特性(少数场景只需要一个方向)。糖在属性解析层 desugar 成基础特性、后续代码只处理基础特性——简化了代码逻辑的同时保留了表达力。
这种 "在尽量早的阶段把高层概念展开为低层原语" 的模式是编译器前端的经典设计——Rust 编译器本身把 for x in iter 展开成 loop { match iter.next() { ... } }、expr? 展开成对 Try::branch(expr) 的 match。serde_derive 作为宏也遵循同样模式——内部只处理 canonical form、用户的多种表面语法都在入口处归一化。
代价——debug 某些 with 产生的错误时、用户看到的错误消息可能提到 serialize_with / deserialize_with——略微破坏"我只用了 with"的心智模型。但这是可接受的 trade-off——换来了整个 derive 路径的简单性。
16.4 remote 模式:给外部类型加 Serialize
Rust 的 orphan rule 规定:一个 trait 的实现,必须要么 trait 在当前 crate、要么类型在当前 crate。你不能给 std::time::Duration(不在你 crate)实现 serde::Serialize(trait 也不在你 crate)。这是 Rust 的基础安全规则。
但用户有真实需求——第三方 crate 里没实现 Serde 的类型也得能序列化。Serde 的 remote 模式是绕过 orphan rule 的巧妙 workaround。下面沿用第 12 章的做法借 Duration 演示——要说明的是,serde 本身已经为 Duration 实现了 Serialize(serde_core/src/ser/impls.rs:685),反序列化一侧同样有内置实现(serde_core/src/de/impls.rs:2129),真实项目里不需要这样包;这里只是借一个人人熟悉的类型看 remote 的机制:
// 第一版:直接按字段名描述——这一版编译不过,见下文"坑"
#[derive(Serialize, Deserialize)]
#[serde(remote = "std::time::Duration")]
struct DurationDef {
secs: u64,
nanos: u32,
}
用户定义 DurationDef——结构和 Duration 相同。#[serde(remote = "std::time::Duration")] 告诉 serde:"用 DurationDef 作为 schema,但为 std::time::Duration 生成代码"。
生成的代码不是 trait impl,而是挂在代理 struct 上的关联函数(serde_derive/src/ser.rs:29-44、serde_derive/src/de.rs:43-58;因为不能为外部类型 impl trait):
impl DurationDef {
// 可见性跟随代理 struct 本身(#vis)
fn serialize<S: Serializer>(__self: &Duration, serializer: S) -> Result<S::Ok, S::Error> {
// 和普通 Serialize 生成相同逻辑,但用 __self 代替 self
let mut state = serializer.serialize_struct("Duration", 2)?;
state.serialize_field("secs", &__self.as_secs())?;
state.serialize_field("nanos", &__self.subsec_nanos())?;
state.end()
}
fn deserialize<'de, D: Deserializer<'de>>(d: D) -> Result<Duration, D::Error> {
// 类似 Deserialize 生成,但返回 Duration 而不是 Self
...
}
}
使用:通过 #[serde(with = "DurationDef")] 配合:
#[derive(Serialize, Deserialize)]
struct MyConfig {
#[serde(with = "DurationDef")]
timeout: std::time::Duration,
}
DurationDef::serialize 和 ::deserialize 恰好符合 with = "" 的要求(函数路径,签名匹配)——所以 MyConfig 的 timeout 字段被 DurationDef 接管。
circular workaround:remote 定义关联函数,with 调用这些关联函数。两个属性配合,给不属于你的类型加上了"虚拟的 Serialize"。
serde_derive 里 remote 模式的特殊处理(serde_derive/src/ser.rs 和 de.rs):
- 检测
cont.attrs.remote()为Some时,不生成impl Serialize for X,而是impl X { fn serialize(__self: &Remote, ...) ... }。 - 所有用到
self.field的地方换成__self.field(因为函数的第一个参数不是&self)。 - 生成的 ident 名仍然是用户代理 struct(DurationDef),但操作的是远端类型(Duration)。
实际生成代码(对 DurationDef):
impl DurationDef {
fn serialize<__S>( // DurationDef 没写 pub,所以这里也不是 pub
__self: &std::time::Duration, // ← 远端类型
__serializer: __S,
) -> Result<__S::Ok, __S::Error>
where __S: Serializer,
{
// 原本 self.secs 变成 __self.secs
// 但这里 Duration 没有 secs 字段!需要 getter
...
}
}
坑:std::time::Duration 的 secs / nanos 字段是私有的——第一版直接按字段名读,实测对 secs、nanos 各报一个 E0616(字段私有)。它的公共 API 是 as_secs() 和 subsec_nanos()。怎么把 DurationDef 的字段映射到 Duration 的 getter 方法?用 #[serde(getter = "fn")];反序列化方向同样不能按字段名构造私有字段,要再提供一个 From<DurationDef> for Duration:
use serde::{Deserialize, Serialize};
use std::time::Duration;
#[derive(Serialize, Deserialize)]
#[serde(remote = "std::time::Duration")]
struct DurationDef {
#[serde(getter = "Duration::as_secs")]
secs: u64,
#[serde(getter = "Duration::subsec_nanos")]
nanos: u32,
}
// 有 getter 时,生成的 deserialize 先构造 DurationDef,再 Into 成 Duration
impl From<DurationDef> for Duration {
fn from(def: DurationDef) -> Duration {
Duration::new(def.secs, def.nanos)
}
}
(这段加上前面的 MyConfig,在 serde 1.0.228 + serde_json 1.0.149 下实编实跑:MyConfig { timeout: Duration::new(3, 5) } 序列化为 {"timeout":{"secs":3,"nanos":5}},再反序列化回 3.000000005s。)
反序列化侧的 Into 转换见 serde_derive/src/de/struct_.rs:394-400:只要有字段带 getter,生成代码就把 DurationDef { .. } 包进 Into::<Duration>::into(..)。
序列化侧,生成代码用 getter 读取值(ser.rs get_member 的 (true, Some(getter)) 分支):
// __self: &Duration,getter 直接拿它当参数;constrain::<T> 把返回值钉死在字段声明的类型上
SerializeStruct::serialize_field(&mut state, "secs",
_serde::__private228::ser::constrain::<u64>(&Duration::as_secs(__self)))?;
SerializeStruct::serialize_field(&mut state, "nanos",
_serde::__private228::ser::constrain::<u32>(&Duration::subsec_nanos(__self)))?;
这是 remote + getter 的完整套路——用户只需要写 "定义结构和 getter",serde 生成所有胶水代码。
16.4.1 impl DurationDef——不是 impl Serialize for Duration
回看 remote 模式生成的代码结构:
impl DurationDef { // ← 关键:impl 的是代理 struct、不是远端类型
fn serialize<__S>(__self: &Duration, __serializer: __S) -> ... { ... }
fn deserialize<'de, __D>(__deserializer: __D) -> Result<Duration, __D::Error> { ... }
}
为什么 impl 要挂在 DurationDef 上、而不是 impl Serialize for Duration?
回答在 Rust orphan rule——一个 trait impl 必须满足:
- trait 在当前 crate
- 或类型在当前 crate
Duration 在 std、Serialize 在 serde——两个都不在用户 crate——impl Serialize for Duration 被 orphan rule 拒绝。
serde 的解法:不生成 trait impl、而是生成"恰好和 Serialize trait 方法签名一样的关联函数"(fn serialize(__self, serializer) -> Result<...>)——inherent impl 没有 orphan rule 限制(DurationDef 是本地类型)、可以挂在 DurationDef 下面。
然后 #[serde(with = "DurationDef")] 的 wrapper 机制(§16.2)不依赖 trait impl、只依赖函数签名匹配——于是这些关联函数被当成 serialize/deserialize 工作。
这就是 remote 能绕过 orphan rule 的巧思——用关联函数 + with 的名义调用链代替 trait impl。完全合法、完全 idiomatic。
16.4.2 Parameters::self_var 的双模 ident
serde_derive/src/ser.rs:104-121 里 Parameters 结构有一个字段:
let self_var = if is_remote {
Ident::new("__self", Span::call_site())
} else {
Ident::new("self", Span::call_site())
};
普通 derive 里、字段访问是 self.field——但 remote 模式下不是 impl Serialize for X { fn serialize(&self, ...) }、而是 impl Proxy { fn serialize(__self: &RealType, ...) }——没有 &self。
为了让所有代码生成路径(fields 遍历、getter 调用、rename 处理等)共用同一个模板、ser.rs 在 Parameters 里记录 self_var 是 self 还是 __self。下游每一处 quote!(#self_var.field) 自动根据模式渲染成 self.field 或 __self.field。
这就是把 "是否 remote" 的分叉在 Parameters 层一次性决定、后续几千行代码不用重复判断 if is_remote。
和第 7 章 §7.12、第 10 章 §10.6 讲的 Ctxt 错误累积器是同一个思想——把上下文状态集中管理、让生成代码的各个局部不用各自处理上下文。这种集中管理 + 局部透明的设计让 serde_derive 的代码密度极高——短短几千行支撑了极复杂的 derive 语义。
用户几乎看不见这个机制——但它是 remote 模式能和普通模式共享绝大部分代码的根本原因。没有 self_var 抽象、每个代码生成函数都要写两份(一份 self、一份 __self)——维护地狱。
16.4.3 quote_spanned! 和 this_type 的精确 remote 错误定位
wrap_serialize_with 里还有 this_type 的使用(ser.rs:1213):
let this_type = ¶ms.this_type;
this_type 在普通模式是用户 struct 名(Event)、remote 模式是 remote 类型完整路径(std::time::Duration)(this.rs 的 this_type)——PhantomData<Event> 变 PhantomData<std::time::Duration>。
这和 self_var 是同一个思路:"当前类型是谁"这个分叉也在 Parameters 里一次算好,wrapper struct、PhantomData、函数签名里所有需要"当前类型"的位置都从 params.this_type 取,不用各处再判断 is_remote。remote 模式下真正被序列化的值是 Duration,wrapper 的类型标记也就跟着指向 Duration。
16.4.4 getter 属性的编译期校验:unreachable!("getter is only allowed for remote impls")
ser.rs:1281-1303 有一段 match:
match (params.is_remote, field.attrs.getter()) {
(false, None) => { /* 普通:&self.field */ }
(true, None) => { /* remote 无 getter:constrain::<Ty>(&__self.field) */ }
(true, Some(getter)) => { /* remote + getter:constrain::<Ty>(&getter(__self)) */ }
(false, Some(_)) => {
unreachable!("getter is only allowed for remote impls");
}
}
unreachable! 而不是运行时错误——表达 "这个组合不可能到达"。因为 check.rs 会在更早的阶段拦住 "非 remote struct 上用 getter"——到 codegen 阶段时、(false, Some(_)) 组合不应该存在。
如果 check.rs 有 bug 漏了这个检查、unreachable! 会在 codegen 阶段 panic——panic 发生在宏展开期间,对用户表现为编译失败,bug 当场暴露、而不是 silent 生成错误代码。这是两层防御——先在 check 拦、拦不住就 unreachable 兜底。
这种 "不可能的分支显式 unreachable" 是 Rust 防御性编程的惯用——相比 _ => panic!("impossible") 的通用写法、unreachable!("specific message") 更精确——让未来的 bug 更容易定位。
16.5 flatten:最复杂的属性
#[serde(flatten)] 让一个字段的结构"摊平"到父级:
#[derive(Serialize, Deserialize)]
struct Response<T> {
status: String,
code: u16,
#[serde(flatten)]
data: T,
}
#[derive(Serialize, Deserialize)]
struct UserData {
id: u64,
name: String,
}
// 序列化 Response<UserData> { status: "ok", code: 200, data: UserData { id: 1, name: "alice" } }
// 结果: {"status": "ok", "code": 200, "id": 1, "name": "alice"}
// ^^^^^^^^^^^^^^^^^^^^ 来自 data,被展平
这比前面的属性复杂得多——因为"展平" 意味着父级字段和子级字段在同一个 JSON 对象里,反序列化时不知道哪些字段属于父级哪些属于子级。
序列化相对简单:
// Response 的序列化(简化)
let mut state = serializer.serialize_map(None)?; // 使用 map 而不是 struct
SerializeMap::serialize_entry(&mut state, "status", &self.status)?;
SerializeMap::serialize_entry(&mut state, "code", &self.code)?;
// flatten 字段:把它当作 map 的扩展
self.data.serialize(FlatMapSerializer(&mut state))?; // 特殊 serializer
SerializeMap::end(state)
FlatMapSerializer 是一个特殊 Serializer——它不写 {、},只接收 serialize_key/serialize_value 调用并转发到外层 map。效果是 data 里的字段被添加到 Response 的 map 里。
FlatMapSerializer 怎么"只支持 struct/map"
打开 serde/src/private/ser.rs:1003,FlatMapSerializer 的实现把 Serializer trait 的大部分方法变成错误分支:
// 节选,省略了 #[cfg] 和其余方法
pub struct FlatMapSerializer<'a, M: 'a>(pub &'a mut M);
impl<'a, M> FlatMapSerializer<'a, M>
where M: SerializeMap + 'a,
{
fn bad_type(what: Unsupported) -> M::Error {
ser::Error::custom(format_args!(
"can only flatten structs and maps (got {})",
what
))
}
}
#[cfg_attr(not(no_diagnostic_namespace), diagnostic::do_not_recommend)]
impl<'a, M> Serializer for FlatMapSerializer<'a, M>
where M: SerializeMap + 'a,
{
type Ok = ();
type Error = M::Error;
type SerializeSeq = Impossible<Self::Ok, M::Error>; // ← 禁止
type SerializeTuple = Impossible<Self::Ok, M::Error>; // ← 禁止
type SerializeTupleStruct = Impossible<Self::Ok, M::Error>;// ← 禁止
type SerializeMap = FlatMapSerializeMap<'a, M>; // ✓ 允许
type SerializeStruct = FlatMapSerializeStruct<'a, M>; // ✓ 允许
type SerializeTupleVariant = FlatMapSerializeTupleVariantAsMapValue<'a, M>;
type SerializeStructVariant = FlatMapSerializeStructVariantAsMapValue<'a, M>;
fn serialize_bool(self, _: bool) -> Result<Self::Ok, Self::Error> {
Err(Self::bad_type(Unsupported::Boolean))
}
fn serialize_i8(self, _: i8) -> Result<Self::Ok, Self::Error> {
Err(Self::bad_type(Unsupported::Integer))
}
// ...(对标量类型都返回 bad_type)
fn serialize_seq(self, _: Option<usize>) -> Result<Self::SerializeSeq, Self::Error> {
Err(Self::bad_type(Unsupported::Sequence))
}
// ...(serialize_tuple / serialize_tuple_struct 同理)
}
三层精心设计:
1. Impossible 关联类型封死不支持的组合——SerializeSeq/Tuple/TupleStruct 都设成 Impossible。trait 要求 serialize_seq 等方法的返回类型里有这个关联类型,而 Impossible 根本构造不出来,所以这三个方法只能走 Err 分支——"把序列 flatten 进 map"不是在类型层被拒绝,而是在运行时返回 bad_type(Unsupported::Sequence) 错误(实测 flatten 一个 Vec<u8> 字段,serde_json::to_string 返回 can only flatten structs and maps (got a sequence))。这正是第 3 章讲的 Impossible 用法,这里一口气用了 3 次。
2. 运行时错误消息统一走 bad_type——对 bool/int/float 等标量 flatten 时的错误都走 "can only flatten structs and maps (got a boolean)" 这种精确描述(实测输出)。Unsupported enum 把类型分成 Boolean/Integer/Float/... 几类、错误文字参数化——用户看到的错误一看就知道"我把一个整数标了 flatten、这不支持"。
3. #[cfg_attr(not(no_diagnostic_namespace), diagnostic::do_not_recommend)] 行(serde/src/private/ser.rs:1019)——这是 #[diagnostic] 命名空间下的诊断指令,告诉编译器当用户代码类型错误时不要推荐 FlatMapSerializer 这个 impl 作为修复建议。#[diagnostic] 命名空间 Rust 1.78 稳定(serde/build.rs:53-56 据此决定要不要打 no_diagnostic_namespace cfg),do_not_recommend 本身 1.85 才生效、更旧的编译器会忽略这个未知属性。FlatMapSerializer 是 Serde 内部的 plumbing 类型、用户误用#[serde(flatten)] 时看到编译器说"考虑 impl Serializer for FlatMapSerializer" 就完全没用。
支持的四种关联类型 SerializeMap/Struct/TupleVariant/StructVariant 各自是一个独立的 wrapper struct——每种"允许 flatten 的 Data Model 原语"配一个专用序列化器。这种 "关联类型栅栏" 的架构让每条允许的路径都有专用的状态对象、不允许的路径连状态对象都拿不到(只能返回错误)——是 Rust 类型系统表达能力在 Serde 内部的巅峰使用之一。
反序列化是真正的挑战:
Response 期望的字段里有 status、code,但也应该吸纳 data 类型里的字段(id、name)。serde 不能在编译期知道 T 有哪些字段(T 是泛型)。
解决方案:把不认识的 key 连同 value 缓存成 Content,已知字段照常直接反序列化(de/struct_.rs:225-282、337-362)。
// Response 的 visit_map(按 struct_.rs 生成代码精简)
fn visit_map<A: MapAccess<'de>>(self, mut map: A) -> Result<Response<T>, A::Error> {
let mut status: Option<String> = None;
let mut code: Option<u16> = None;
// 未知 key 连同 value 缓存成 (Content, Content);被 flatten 字段消费掉的条目置 None
let mut collect: Vec<Option<(Content, Content)>> = Vec::new();
while let Some(key) = map.next_key::<__Field>()? {
match key {
__Field::status => status = Some(map.next_value()?), // 已知字段:直接反序列化
__Field::code => code = Some(map.next_value()?),
// 有 flatten 时字段标识符多一个 __other(Content) 变体、把不认识的 key 原样存下来
__Field::__other(name) => {
collect.push(Some((name, map.next_value_seed(ContentVisitor::new())?)));
}
}
}
// 已知字段处理完后,flatten 字段从缓存里"捡走"属于它的 (key, value)
let data: T = Deserialize::deserialize(FlatMapDeserializer(&mut collect, PhantomData))?;
// 若容器还标了 deny_unknown_fields:此时 collect 里剩下的 Some(..) 才是真正的未知字段
Ok(Response { status: status.unwrap(), code: code.unwrap(), data })
}
三步:
- 已知字段(
status、code)不经过 Content、和普通 struct 一样直接next_value - 不认识的 key 和它的 value 缓存成
(Content, Content)——只有这部分输入被完整缓存 - flatten 字段用
FlatMapDeserializer从缓存里消费:它把Vec<Option<(Content, Content)>>伪装成一个 map 源(serde/src/private/de.rs:3184)。flatten 成 struct 时,被认领的条目置成None(flat_map_take_entry,serde/src/private/de.rs:3430-3447);flatten 成 map 时(FlatMapAccess)只借用、不置空
这就是 #[serde(flatten)] 比普通 struct 多出来的开销——flatten 字段的那部分输入要先缓存成 Content、再从 Content 二次反序列化,多一层分配和遍历。具体代价取决于 flatten 字段的深度和数量,性能敏感的场景应该用实测数据评估。
和 deny_unknown_fields 的关系:编译期不拦,但官方文档明确写着这个属性"不支持与 flatten 组合,无论标在外层 struct 还是被 flatten 的字段上"。源码里只做了一半:de/struct_.rs:347-361 的 collected_deny_unknown_fields 在 flatten 字段消费完缓存之后再检查 __collect 里剩下的 Some 条目——剩下的才报 unknown field。实测(serde 1.0.228 + serde_json 1.0.149)三种情况各不相同:外层标 deny_unknown_fields、flatten 一个 struct,多余的 zzz 会报 unknown field zzz,符合预期;标在被 flatten 的内层 struct 上则完全不生效,zzz 被静默接受;外层标 deny_unknown_fields、flatten 一个 HashMap,因为 map 只借用不置空,任何额外的键都会报 unknown field——flatten 出来的 map 等于白设。所以别把这两个属性搭在一起用。check.rs 对 flatten 只拒绝两种情况:tuple struct 和 newtype struct 上的 flatten(serde_derive/src/internals/check.rs:117-136;check_flatten 在 100-115 对 struct 字段和 enum 每个变体的字段逐个调用它)。
16.5.1 has_non_skipped_flatten 的编译期切换
ser.rs:305-308 里有一段关键的 dispatch:
let has_non_skipped_flatten = fields
.iter()
.any(|field| field.attrs.flatten() && !field.attrs.skip_serializing());
if has_non_skipped_flatten {
// 走 serialize_map 路径
} else {
// 走 serialize_struct 路径
}
任何一个非跳过的 flatten 字段都让整个 struct 走 serialize_map——即使其他字段都是普通字段。
为什么?——因为 serialize_struct 要求事先给出字段数(serialize_struct(name, N) 里的 N 由宏按它看得见的字段算出来,遇到 skip_serializing_if 时会变成形如 false as usize + 1 + if 条件 { 0 } else { 1 } 的运行时表达式,见 ser.rs:348-358)——但 flatten 字段会展开成几个键取决于 T,宏看不到。serialize_map 不要求已知数量(可以 serialize_map(None))——天然支持"未知数量字段"。
代价是 serialize_map 的 binary 格式可能比 struct 略微不同(比如 MessagePack 的 map 和 struct 编码不一样)——用户一旦加 flatten、就要接受格式层可能的变化。绝大多数场景 JSON / YAML 等自描述格式里两者完全等价——但 binary 格式用户要注意。
!field.attrs.skip_serializing() 的判断也精确——被 skip 的 flatten 字段不触发 serialize_map 切换(因为它不会被发送、对格式没影响)。这种对 skip_serializing + flatten 组合的精细处理又一次体现 serde_derive 对属性组合的完整覆盖。
16.5.2 FlatMapDeserializer 的两遍解析性能代价
§16.5 讲过 flatten 的 Deserialize 需要 "缓存到 Content → 重组剩余字段为 Map" 两阶段——值得量化这个代价。
Content 缓存的开销:
- 堆分配——Content 是一个
enum Content<'de> { String(..), U64(u64), ... Map(Vec<(Content, Content)>) }——复杂输入会产生大量 Vec 分配 - 拷贝——Content 有
Str(&'de str)/Bytes(&'de [u8])借用变体,但只有格式交出借用切片(visit_borrowed_str)时才用得上;有转义、或输入源是io::Read时就得String/Vec<u8>拷贝
flatten 的相对开销取决于被缓存的那部分输入占整体多大比例、嵌套多深。对 Web API 这类被网络 IO 主导的场景通常无感——但对批量反序列化(处理海量 JSON)或延迟敏感的 RPC 就要实测评估。
正确的使用场景:
- ✓ HTTP API 响应(外部 latency 淹没 flatten 开销)
- ✓ 配置文件(一次启动、不敏感)
- ✓ 日志分析(反序列化不是热路径)
- ✗ 内部 RPC 消息(每毫秒都重要)
- ✗ 流处理里的热路径
如果非要在热路径用 flatten——先看嵌套是不是只有一层 newtype 包装(那 #[serde(transparent)] 零开销就够);否则手写 Deserialize(完全控制)。
16.5.3 Content enum 的 variant 覆盖——所有 data model 类型
Content 是 serde 私有模块里的一个 enum、作为 flatten 和 untagged 的中间缓存——它必须能承载 data model 的所有可能值:
// serde_core/src/private/content.rs:10(serde 1.0.220 起随 serde_core 拆分;serde 侧经 private::de 再导出)
pub enum Content<'de> {
Bool(bool),
U8(u8), U16(u16), U32(u32), U64(u64),
I8(i8), I16(i16), I32(i32), I64(i64),
F32(f32), F64(f64),
Char(char),
String(String),
Str(&'de str),
ByteBuf(Vec<u8>),
Bytes(&'de [u8]),
None,
Some(Box<Content<'de>>),
Unit,
Newtype(Box<Content<'de>>),
Seq(Vec<Content<'de>>),
Map(Vec<(Content<'de>, Content<'de>)>),
}
22 个 variant——覆盖 data model(第 2 章)里除 i128/u128 之外的全部标量;复合类型折叠成 Seq/Map/Newtype/Some,struct、tuple、enum 的"名字"在缓存时被丢掉。这让 Content 能从任何 Deserializer 里"取出整个输入"、然后再 feed 给下游 Deserializer。
关键设计:
① String vs Str——owned vs borrowed——同理 ByteBuf vs Bytes。两个都有、让 Content 能保留借用信息——如果原输入允许零拷贝、Content 保留借用;如果必须 own(比如字符串有转义需要分配)、Content 用 owned。这保留了最大灵活性。
② Some(Box<Content<'de>>)——为了防止 Content 类型大小递归膨胀(Option::Some 嵌套 Content 会让 size 无限)、用 Box 拆开。这是 Rust enum 里自引用类型的标准做法。
③ 不包含 trait object——Content 是完全 closed enum、所有值都能 pattern match。这让 ContentDeserializer 能对每一种做精确分发。
Content 的内存开销——每个 Content 值都是一个带 tag 的 enum、Seq/Map/Some/Newtype 又各自持有堆分配,输入嵌套多深就有多少层 Vec/Box。这就是 flatten / untagged 慢的根源——不只是遍历两次、还有这棵树的分配。
16.6 flatten + untagged enum 的组合绝招
实际工程里最有用也最棘手的组合:
#[derive(Deserialize)]
#[serde(untagged)]
enum Response<T> {
Ok { status: String, data: T },
Err { status: String, message: String },
}
直接这样写,公共字段 status 要在每个变体里重复一遍。更好的做法是把公共字段提到外层、用 flatten 接上:
#[derive(Deserialize)]
struct Wrapper<T> {
status: String,
#[serde(flatten)]
variant: Variant<T>,
}
#[derive(Deserialize)]
#[serde(untagged)]
enum Variant<T> {
Ok { data: T },
Err { message: String },
}
语义:JSON {"status": "...", "data": ...} 走 Ok 分支;{"status": "...", "message": "..."} 走 Err 分支。flatten 让 status 在 Wrapper 层处理,剩下的字段传给 Variant 让它做 untagged 匹配。
生成代码是本章讨论的两个机制(flatten 的 FlatMap + untagged 的多次 try)合体。性能开销大(缓存 + 尝试),但表达力强。
16.6.1 untagged enum 的 "尝试每个 variant" 代价
untagged enum 的反序列化策略是 对每个 variant 依次 try——第一个成功的胜出:
// 按 de/enum_untagged.rs:45-58 精简
fn deserialize<'de, D>(d: D) -> Result<Self, D::Error> {
let content = ContentVisitor::new().deserialize(d)?; // ← 缓存整个输入
// 每个 variant 都用 ContentRefDeserializer 借用同一份 content,不 clone
let d = ContentRefDeserializer::<D::Error>::new(&content);
// 尝试 variant 1
if let Ok(v) = /* Ok 变体的 visitor */.deserialize(d) {
return Ok(Self::Ok(v));
}
// 尝试 variant 2
if let Ok(v) = /* Err 变体的 visitor */.deserialize(d) {
return Ok(Self::Err(v));
}
// 所有都失败
Err(D::Error::custom("data did not match any variant of untagged enum Variant"))
}
性能问题叠加:
- 缓存 Content——untagged 要把整个输入缓存成堆上的 Content 树(flatten 只缓存不认识的那部分字段)
- 对每个 variant 尝试——N 个 variant 的 worst case 是 N 次完整解析
如果 enum 有 5 个 variant、输入是第 5 个——需要 4 次失败尝试 + 1 次成功——每次都是对同一份 Content 树的一遍遍历。
加上 flatten 的 combo——两种二次解析叠加——开销随 variant 数和嵌套深度一起涨。
这也是 Rust 社区常见的 "尽量用 tagged enum" 建议的原因——#[serde(tag = "type")] 让解析器看一眼 tag 字段就能精确分派、零回退。
untagged + flatten 是表达力的巅峰、性能的底谷——了解这个 trade-off、才能在真实场景里正确选型。
16.7 属性之间的协作与冲突
前面三个属性不是孤立的——它们经常组合使用:
#[serde(remote = "..", default)]:remote + default,处理远端类型的缺失字段#[serde(flatten)]+rename_all:容器的 rename_all 只作用于它自己的字段;被 flatten 进来的字段名由内层类型自己的属性决定(FlatMapDeserializer 原样转交 key),两套规则互不影响#[serde(with = "..")]+#[serde(default = "..")]:自定义序列化 + 默认值
哪些组合非法(都在 check.rs 里有对应的错误消息):
- getter 只能出现在带
remote的 struct 上,enum 里不允许(check_getter) - flatten 不能用在 tuple struct / newtype struct 的字段上(
check_flatten_field) - 代理 struct 自己带泛型参数时,
remote = "..."路径不能再带具体泛型实参(check_remote_generic:"remove generic parameters from this path") - transparent 不能和 from / try_from / into 同用、也不能用在 enum 和 unit struct 上(
check_transparent) - from 和 try_from 互斥(
check_from_and_try_from)
check.rs 的 477 行代码大部分在检查这些组合规则。Serde 的工程细致在这里体现得淋漓尽致——每一个非法组合都有明确错误信息、指向用户代码。
16.7.1 check.rs 的 477 行非法组合检查
serde_derive/src/internals/check.rs 是最长的内部模块之一——477 行代码、全部在做属性组合合法性检查。典型模式:
// check.rs:8-19
pub fn check(cx: &Ctxt, cont: &mut Container, derive: Derive) {
check_default_on_tuple(cx, cont);
check_remote_generic(cx, cont);
check_getter(cx, cont);
check_flatten(cx, cont);
check_identifier(cx, cont);
check_variant_skip_attrs(cx, cont);
check_internal_tag_field_name_conflict(cx, cont);
check_adjacent_tag_conflict(cx, cont);
check_transparent(cx, cont, derive);
check_from_and_try_from(cx, cont);
}
// check.rs:117-136
fn check_flatten_field(cx: &Ctxt, style: Style, field: &Field) {
if !field.attrs.flatten() {
return;
}
match style {
Style::Tuple => {
cx.error_spanned_by(
field.original,
"#[serde(flatten)] cannot be used on tuple structs",
);
}
Style::Newtype => {
cx.error_spanned_by(
field.original,
"#[serde(flatten)] cannot be used on newtype structs",
);
}
_ => {}
}
}
每一条规则都有:
- 检查条件(
if !field.attrs.flatten() { return; }+match style) - 精确的错误消息(告诉用户哪两个属性冲突)
- 错误指向(通过
cx.error_spanned_by指到用户写的具体属性)
这 477 行代码的价值不是"功能"、是"防护"——拦住用户写出语义上可能工作、但实际行为和期望不符的代码。比如在 tuple struct 的字段上写 flatten——生成代码没有字段名可以当 map key、错误会在离用户代码很远的地方冒出来;check.rs 直接一句 "#[serde(flatten)] cannot be used on tuple structs" 指着那个字段。又比如 getter 出现在非 remote struct 上——不拦的话会一路走到 get_member 的 unreachable!(§16.4.4)。check.rs 在编译期拦下这些陷阱、把错误指回用户写的属性。
这种 "宁可多报错、不让用户 silent bug" 的态度是高质量 Rust 库的共同特征——生态高质量的代价就是边界上的精确防御。
16.8 为什么这三个属性复杂
对比普通属性(rename、skip),这三个为什么代码量膨胀?
rename 只改一个字符串字面量——"name" 变成 "full_name"。代码生成差一行。
with 要生成临时 struct + impl + PhantomData 包装——每用一次就多 10 行代码。
remote 改变整个 impl 块形状——不是 impl Serialize for T,而是 impl X { fn serialize(__self: &T, ...) }。这影响每一处 self 引用。
flatten 改变反序列化流程——未知字段要先缓存、再二次解析。整个 visit_map 逻辑多出一条缓存/消费路径。
它们复杂的根本原因:它们不是"调整"行为,而是改变架构。普通属性在"模板填空"阶段工作;这三个在"选择模板"阶段工作——每种都有自己的独立模板。
从工程角度看:这三个属性是 serde 的"扩展点",让它在 derive 宏基础上支持了原本只有手写 Serialize 才能做的事。代价是代码复杂度。但用户端非常简洁——一个属性标注搞定。这种"把复杂度留给库实现者、把简洁留给用户"的哲学贯穿整个 Serde。
16.8.1 serde_derive 各模块的分工——架构图
serde_derive 约 9000 行代码(find serde_derive/src -name '*.rs' | xargs wc -l 在本专栏钉定的 commit fa7da4a 上实测 8969 行;v1.0.228 tag 为 8956 行,差异来自 tag 之后 31 个提交对 ser.rs、de.rs、bound.rs、attr.rs、ast.rs 等 9 个文件的小改)、分布在 28 个文件——每个模块职责明确:
| 模块 | 职责 | 代码量(wc -l 实测) |
|---|---|---|
lib.rs |
入口 + Serialize/Deserialize derive 宏导出 | 127 行 |
ser.rs |
Serialize impl 的代码生成 | 1369 行 |
de.rs + de/ 子目录(struct_ / tuple / enum_* / identifier 等 9 个文件) |
Deserialize impl 的代码生成 | 976 + 2383 行 |
serde_derive/src/internals/attr.rs |
解析 #[serde(...)] 属性 | 1818 行 |
serde_derive/src/internals/case.rs |
rename_all 大小写转换 | 200 行 |
serde_derive/src/internals/check.rs |
属性组合合法性 | 477 行 |
serde_derive/src/internals/ctxt.rs |
错误累积(第 7 章 §7.12、第 10 章 §10.6 讲过) | 67 行 |
bound.rs |
自动 where 约束推导 | 425 行 |
dummy.rs |
匿名 const 包装 | 31 行 |
pretend.rs |
dead_code 抑制(第 9 章讲过) | 188 行 |
这个模块划分有几个 takeaway:
① de.rs 加 de/ 子目录共 3359 行、是 ser.rs 的两倍多——反序列化本质上比序列化难(需要处理乱序字段、缺失字段、格式错误等)、代码复杂度翻倍。这是 Deserialize trait 比 Serialize trait 难理解的根本原因。
② attr.rs 1818 行——属性解析的细节极其繁琐。每一个 #[serde(xxx)] 都要做 syntax 合法性检查、位置检查(某些属性只能在字段上、某些只能在容器上)、meta 值格式验证。这 1800 多行是 serde 能支持 symbol.rs 里登记的 35 个属性名(容器 / 变体 / 字段三个层级各自取用)的基础。
③ check.rs 477 行——不生成任何代码、纯做验证。但这 477 行把用户可能误用的组合拦住——"零代价的正确性保证" 之所以可能、靠的是这种"独立 validation 阶段" 的架构。
④ bound.rs 425 行——把 struct Foo<T> { x: T } 自动推导为 impl<T> Serialize for Foo<T> where T: Serialize(约束追加在 where 子句里)——让用户不用手写 where 约束。这种自动推导对大多数场景够用——复杂场景用户用 #[serde(bound = "...")] 手动指定。
把这些模块组合起来、就是 serde_derive 能既简洁又精确的架构基础——每个模块只做一件事、合作覆盖完整功能。
16.9 实用模式:serde_with 生态
由于原生 #[serde(with = "mod")] 需要用户写完整的 serialize/deserialize 函数,社区出现了 serde_with crate——提供大量常用转换的预置函数:
use serde::{Deserialize, Serialize};
use serde_with::{serde_as, DisplayFromStr, DurationSeconds};
use std::time::Duration;
#[serde_as]
#[derive(Serialize, Deserialize)]
struct Config {
#[serde_as(as = "DurationSeconds<u64>")]
timeout: Duration,
#[serde_as(as = "DisplayFromStr")]
version: semver::Version,
}
DurationSeconds<u64> 是 serde_with 预置的 "Duration 序列化为秒数" 转换;DisplayFromStr 是 "用 Display/FromStr 代替 Serialize/Deserialize"。这些都是 with 的具体应用。
《Rust 编译器与运行时揭秘》专栏第 6 章"单态化"讨论过泛型类型的单态化——DurationSeconds<u64> 和 DurationSeconds<f64> 是两个不同的具体类型,在编译期分别生成代码。serde_with 的设计用足了这一点。
16.9.1 serde_as 和 #[serde(with)] 的语法对比
serde_with crate 引入了一个全新的属性系统 #[serde_as] 和 #[serde_as(as = "...")]——对比传统的 #[serde(with = "...")]:
| 维度 | #[serde(with)] |
#[serde_as] |
|---|---|---|
| 目标类型 | 单个字段 | 字段 + 嵌套(Vec<T> / HashMap<K, V>) |
| 复合性 | 不能嵌套 | Vec<DisplayFromStr> 给 Vec 每个元素 apply |
| 粒度 | 整字段 | 字段里的子结构 |
| 宏 | proc-macro | proc-macro 加一层 #[serde_as] 预处理 |
具体对比:
// serde(with) 风格——对整个 Vec<Duration> 处理
#[derive(Serialize)]
struct A {
#[serde(with = "vec_of_duration_secs")]
durations: Vec<Duration>,
}
// 需要用户手写 vec_of_duration_secs 模块、逐个处理
// serde_with 风格——直接对 Vec<Duration> 的每个元素 apply
#[serde_as]
#[derive(Serialize)]
struct A {
#[serde_as(as = "Vec<DurationSeconds<u64>>")]
durations: Vec<Duration>,
}
// 不需要手写任何模块、serde_with 预置了 DurationSeconds
serde_as 的设计思路——把 with 需要手写的通用模式预置成类型、用户通过类型组合来表达转换。这比每次手写 mod 方便得多。
实现原理——#[serde_as] 是另一个 proc-macro、在 #[derive] 之前先跑、把 #[serde_as(as = "T")] 展开成 #[serde(with = "...")]。两个 macro 分层协作——serde_with 是 serde_derive 的一层语法糖、不需要改 serde_derive 本身。
这是 Rust 宏生态的模块化特性——多个 macro 可以串联、层层转换——serde_with 在 serde_derive 上面加一层 abstract、给用户更好的 ergonomics。这也是库能在不改 core 的前提下扩展能力的典型。
16.10 和本系列其他专栏的关联
三个属性的使用模式在其他 Rust 项目里能看到:
- 《Tokio 源码深度解析》专栏第 17 章"Runtime 可观测性"里的运行时指标有不少是
Duration(如worker_total_busy_duration)——导出成 JSON 时要为它选格式:serde 自带的{secs, nanos},或 serde_with 的 DurationSeconds。 - 《MCP 协议设计与实现》专栏第 3 章讲的 JSON-RPC 消息,用 Rust 实现时 result 对象经常需要
#[serde(flatten)]把公共字段(_meta)和方法特定字段合在同一层。 - 《LangChain 设计与实现》专栏 里 Agent 的 tool schema 若用 Rust 实现,经常用
#[serde(rename)]+#[serde(with)]组合——schema 字段名用 camelCase(API 约定),类型用 Rust 原生时间/UUID 等。
本章讨论的三个属性是真实 Rust 后端项目的"标配工具"——无论你用 Serde 做 API 定义、配置加载、还是 RPC 协议,这三个属性都会反复出现。
16.10.1 MCP 协议 result 对象里的 flatten 实例
第 14 章 §14.11 提过《MCP 协议设计与实现》专栏第 3 章的消息类型——如果用 Rust 实现一个 MCP server、result 对象的 flatten 用法几乎必然出现。JSON-RPC 2.0 规定 params 和 result 都是嵌套成员({"jsonrpc": "2.0", "id": 1, "method": "tools/list", "params": {...}}),所以请求信封本身不需要 flatten;需要 flatten 的是 result:MCP 的每种 result 都在方法特定字段旁边放一个可选的公共字段 _meta,两者在同一层:
{"tools": [...], "nextCursor": "...", "_meta": {...}}
#[derive(Serialize, Deserialize)]
struct McpResult<R> {
#[serde(rename = "_meta", default, skip_serializing_if = "Option::is_none")]
meta: Option<Meta>, // 所有 result 共有
#[serde(flatten)]
body: R, // ← ListToolsResult { tools, next_cursor } 等方法特定字段被展平
}
如果不 flatten、就得写 {"body": {"tools": ...}} ——不符合 MCP schema。flatten 让 Rust 端的 McpResult<ListToolsResult> 天然产生符合 schema 的 JSON。
但这里有坑——§16.5.2 讲过 flatten 有缓存 + 二次解析的开销。MCP 的请求通常由用户交互驱动、频率不高、这点开销完全可以接受。但同样的模式到 RPC gateway 场景(每秒千级请求)——flatten 的开销就不可忽视。
正确的 mental model 是:flatten 是 "语义表达力" vs "性能" 的 trade-off——MCP 这种低频协议 flatten 很合适、gRPC internal service 这种高频场景手写 Deserialize 更好。
16.10.2 LangChain / LangGraph 里的 #[serde(with)] 模式
LangChain 的 Python 代码建立在 pydantic 上,pydantic 有 @field_validator 和 @field_serializer 装饰器——对应 Rust 的 #[serde(with)]。如果用 Rust 重构 LangChain 的 Message 类型——一定会大量用 with:
#[derive(Serialize, Deserialize)]
struct AIMessage {
#[serde(with = "chrono_rfc3339")]
timestamp: DateTime<Utc>,
#[serde(with = "serialize_model_response")]
tool_calls: Vec<ToolCall>,
content: String,
}
LangChain 的 Python 世界通过 pydantic validators 处理同样问题——"给字段自定义 serialize/validate 逻辑"。Rust 的 with 属性是语法不同、本质相同的解决方案——都是"把领域特定的格式化逻辑和通用字段描述解耦"。
这种跨语言的同构模式在整个 serialization 领域反复出现——Java 的 Jackson 用 @JsonSerialize(using = ...)、Go 的 json 包用 MarshalJSON/UnmarshalJSON 方法、Rust 用 #[serde(with)]。表面语法不同、底层概念完全一致——因为真实世界的序列化需求是跨语言的。
16.11 本章小结
三个高阶属性各有其独特的实现机理:
#[serde(with = "mod")] 用临时 wrapper struct 把自由函数包装成 Serialize 实现:
struct __SerializeWith<'a> { values: (&'a T,), phantom: PhantomData<Parent> }
impl Serialize for __SerializeWith<'_> { fn serialize(...) { user_fn(...) } }
#[serde(remote = "path::T")] 用代理类型上的关联函数替代 trait 实现绕过 orphan rule:
impl DurationDef { fn serialize(__self: &Duration, s: S) { ... } }
// 配合 #[serde(with = "DurationDef")] 使用
#[serde(flatten)] 用Content 缓存 + FlatMapDeserializer 实现字段吸纳:
- 父级已知字段照常直接反序列化
- 不认识的 key/value 缓存成
(Content, Content) - flatten 字段通过 FlatMapDeserializer 从缓存里消费属于它的条目
三个属性是 Serde 从"derive 宏"变成"完整序列化生态"的关键扩展点。它们允许用户做 derive 宏原本做不到的事——定制格式、远端类型、扁平化——代价是代码生成复杂度和运行时性能。
至此第 5 部分(高阶主题)完结。你已经理解了 Serde 生命周期与借用(第 15 章)、三个扩展口(本章)。接下来最后两章进入生态——第 17 章看 serde_json 作为真实格式实现如何接入 Data Model;第 18 章总结 Serde 的设计哲学,提炼可迁移到其他工程的模式。
16.12 三个属性在真实项目里的使用模式分布
从日常读到的 Rust 项目看(经验观察、不是统计):
#[serde(with = "...")] 最普遍。最常见的 with 对象:
- chrono / time crate 的时间类型(RFC3339、unix timestamp 等格式)
- uuid 的序列化(hex 字符串 vs 二进制)
- Duration 的 secs / ms / hex 表示
- 自定义 ID 类型(如
OrderId(u64)要以字符串形式传输以保留精度) - 枚举的自定义编码(lowercase、numeric code 等)
#[serde(flatten)] 次之。主要场景:
- HTTP API 响应的通用字段(status、code、request_id)
- JSON-RPC 的 method + params
- GraphQL 的 data + errors + extensions
- 配置文件的 common + specific 拆分
#[serde(remote = "...")] 最少用。因为:
- 现代 Rust 类型几乎都有
serdefeature(作为可选 feature)——不需要 remote 包装 - 本章拿
std::time::Duration做 remote 示例只是教学用(官方文档 serde.rs 的 remote 示例用的是虚构的other_crate::Duration)——serde 自己一直带着Duration/SystemTime的 impl(serde_core/src/ser/impls.rs:685),直接 derive 就能用 serde_with覆盖了多数"给外部类型加序列化"的场景
这个分布反映了一个趋势——用户的需求从 "给我工具让我自己扩展" 变成 "直接给我预置方案"——serde_with 的崛起和 remote 的衰落是同一个故事。
但读 derive 源码依然有价值——理解 remote 的实现能让你在遇到特殊情况(比如 C ABI 类型需要 serde)时知道怎么自定义。基础原理不会过时。
16.13 本章收束——serde_derive 作为宏工程的典范
回看本章讨论的一切——quote_spanned! 的精确错误定位、Parameters::self_var 的双模抽象、has_non_skipped_flatten 的编译期切换、check.rs 的 477 行组合验证、FlatMapSerializer 的 Impossible 关联类型禁用——每一个细节都在回答同一个问题:如何让一个 proc-macro 既功能强大、又用户友好、又错误信息精确、又性能可观?
serde_derive 的答案是把复杂度分层:
- 第一层:属性解析(attr.rs)——把用户写的
#[serde(...)]转成内部规范形态 - 第二层:合法性检查(check.rs)——拦住非法组合、给出精确错误
- 第三层:代码生成(ser.rs / de.rs)——按规范形态生成优化后的代码
- 第四层:错误定位(
quote_spanned!遍布)——让生成代码的错误指回用户源
每层都只做一件事——attr 不写代码生成、gen 不做检查、check 不解析属性。这种严格分层让近 9000 行的 serde_derive 是可维护的——哪里有 bug 很快定位、哪里要加新特性改哪一层清楚。
对比某些 proc-macro 把所有逻辑糅在一个 impl_derive() 函数里的设计——随着特性增加必然变成泥潭。serde_derive 的分层架构值得 proc-macro 作者借鉴。
把本章这些 derive 源码细节记下来、下次你自己写 proc-macro 时就知道怎么组织——这是读源码比看文档更有价值的原因。文档告诉你"做什么"、源码告诉你"怎么做得好"。
16.14 跨专栏呼应——serde_derive 的几个模式在其他专栏里的对应
与《React 19 内核探秘》专栏第 6 章 Commit 的呼应——Parameters::self_var 的 "双模共享模板" 和 React Commit 的 "双缓冲 current/finishedWork flip"——都是在状态机里用一个变量切换两种语义、让下游代码无感。
与《Hyper 与 Tower:工业级 HTTP 栈》专栏第 14 章的呼应——serde 的 attr::Default 三态(None / Default / Path)和 hyper 的 Dur(Default / Configured 两个变体,各自携带 Option<Duration>,再配合 Time::Empty 区分是否装了 timer;hyper-1.9.0/src/common/time.rs:21-24)——都体现对 "用户显式设置 vs 框架默认" 的精确区分。
与《vLLM 推理内核深度解析》专栏第 8 章的呼应——serde 的 Content enum 缓存输入 vs vllm 的 InputBatch 持久化——都是 "在昂贵的输入处理处加一层缓存、下游消费者零感知"。
与《LangGraph 设计与实现》专栏第 14 章 Runtime 的呼应——serde 的 #[serde(flatten)] 实现中的"父级提取已知字段 → 剩余传给子级"模式和 LangGraph Runtime 的"context_schema 注入 + 子节点按需读"同构——都是把"父子作用域"编码到数据结构。
与《Vite 设计与实现》专栏第 15 章的呼应——serde_derive 的 quote_spanned! 错误定位 vs Vite 的 debug?.(...) 2s 诊断——都是"让开发者能在错误时刻立刻定位问题"的工程投入——不是锦上添花、是生产可用的必备。
16.15 属性是 Serde 的"可变形"能力
with/remote/flatten 三个属性分别对应 Serde 在三个维度上的变形能力:
with——字段级变形(改变单字段的序列化方式)remote——类型级变形(给外部类型补上序列化能力)flatten——结构级变形(改变字段在 JSON 里的嵌套关系)
三者合起来、覆盖了绝大多数"我的类型不能直接用 derive、需要定制"场景。加上 serde_with 的预置抽象、剩下真正需要手写 Serialize/Deserialize 的属于极端情况。
对比横向生态——Python pydantic 灵活但运行时开销大、Java Jackson 功能多但语法臃肿、Go 的 encoding/json 简单但表达力有限——Rust serde 在保持类型安全和零成本的同时覆盖了绝大多数业务场景。
动手实验
- 用 cargo expand 观察 with:写一个用
#[serde(with = "my_mod")]的字段,用 cargo expand 看生成的 __SerializeWith struct。对照本章 16.2 节。 - 实现一个 remote:给
std::time::Duration或std::net::IpAddr写 remote 代理 struct,验证它能工作。 - flatten 性能实测:同一份结构,一次用 flatten,一次不用。用 criterion 对比反序列化 10MB JSON 的时间差异。
- 读
serde_with:这个 crate 是with属性的"标准库",读它的 README 和最常用的 10 个 serialize_as 类型,看它们如何实现。
延伸阅读
- Serde "Custom serialization":自定义序列化的官方说明,
with指向的函数就按这里的签名来写。 - serde_with:serde 生态最重要的辅助 crate,基于
with提供几十种预置转换。 - Serde "Deserialize for custom map type":为自定义 map 类型手写 Deserialize(Visitor + MapAccess)的完整示例。
- 《Rust 编译器与运行时揭秘》专栏第 6 章 "单态化":理解
DurationSeconds<u64>vsDurationSeconds<f64>如何编译成两份代码。 - 《MCP 协议设计与实现》专栏第 3 章:看 flatten 在真实协议里的应用。