如果你用 Rust 写过一段时间的业务代码,大概率已经体验过这种场景:拿一个结构体,调用serde_json::to_string把它变成 JSON,或者从前端那边收一个 JSON 字符串,from_str一把梭变成 Rust 结构体。一切顺利的话,你甚至感觉不到 serde 的存在——但一旦字段缺了、类型对不上、枚举的表示方式跟数据格式不匹配,你就会开始好奇,背后到底是哪一层在做转换。再深入一层你会发现,Serialize和Deserialize这两个 trait,不只是“能让结构体变成 JSON”这么简单,它们本身就是 Rust 类型系统能力的集中体现,是类型驱动编程的最佳示范。
这篇文章我想从解决实际问题的角度出发,把这两个 trait 拆开来看:它们是怎么设计的、为什么这么设计、真到了需要手写的时候该怎么写、又有什么坑是文档里不会明说的。内容面向已经会基本使用 serde、但想真正理解它,或者想给自己写的库接入 serde 支持的开发者。
1. Rust 的序列化难题:为什么需要 serde
1.1 序列化的本质:数据格式与内存类型的双向翻译
先把问题定义清楚。序列化和反序列化本质上是一对翻译过程:内存里的 Rust 类型,比如一个User结构体,它有字段、有类型、有嵌套关系;而外部数据格式,比如 JSON、YAML、TOML、MessagePack,它们是另一种语言,只有字符串、数字、布尔、数组、对象这些东西。让这两套东西互相转换,就是序列化的全部工作。
但真正麻烦的地方在于:Rust 类型是编译期确定的,外部数据是运行期才知道的。JSON 里可以出现任何 key 顺序,可能多字段也可能少字段;而 Rust 结构体在编译时就已经固定了字段集合。这两者的“信息密度”天然不对称,所以序列化库必须回答一个问题:在编译期类型和运行时数据之间,谁来充当桥接者?
反射语言的做法是运行时检查——拿到一个对象,摸清它的字段列表,再跟数据格式对齐。但 Rust 没有反射,也刻意不提供这种机制,因为反射会破坏类型安全、增加运行时开销,还会让代码难以静态分析。那 Rust 靠什么?靠 trait 和泛型。类型在编译期就知道自己长什么样,也就有能力“告诉”序列化器自己的结构,这就是Serialize和Deserialize存在的底层逻辑。
1.2 Rust 序列化方案的演进:从手写到特质方案
说句实话,早期的 Rust 序列化体验远没有今天舒服。我记得 Rust 大版本更新之前,用的是官方的rustc-serialize,那套方案也支持derive,但灵活性差不少,而且底层接口设计比较粗糙。后来社区里出现了很多方案,有的基于宏生成代码,有的靠反射模拟,但最终被广泛应用的是 serde:一个由核心 trait、数据格式后端、derive 宏三部分组成的生态。
为什么是 serde 胜出,而不是某个“一站式全家桶”?核心原因是它的架构清晰。serde 本身不跟具体的数据格式绑定——它定义了一组抽象的序列化/反序列化接口,至于这些接口怎么落到 JSON、Bincode、MessagePack,那是serde_json、bincode这些后端的事。这意味着,你只需要让类型实现Serialize/Deserialize,就等于同时支持了所有数据格式。写一次,到处能用。
用生活里的事情来类比:Serialize有点像“自我说明书”,它知道自己的结构是什么、该拆成几个部分;serde 框架负责把这些部分交给不同的“打包员”(JSON 打包员、二进制打包员)。你只需要确保东西能进入流水线,至于是装进纸箱还是木箱,那是打包员的事。这套思路的可扩展性,是 serde 在 Rust 生态里站稳脚跟的根本原因。
1.3 框架层设计:三个关键角色
serde 的整体架构可以拆成三个角色:类型与你写的代码、数据格式后端、以及驱动一切的核心 trait 体系。
- 类型端:每个 Rust 类型实现
Serialize(输出)和Deserialize(输入)。大多数时候不需要手写,#[derive(Serialize, Deserialize)]即可。 - 数据格式端:每种格式实现
Serializer/Deserializertrait。例如serde_json提供serde_json::Serializer,bincode提供自己的序列化器。 - 数据模型层:serde 核心定义了一套中间数据事件(如
SerializeStruct、SerializeMap),类型端产生事件,格式端消费事件,两边互不感知。
这一点很值得品味:类型端和格式端是完全解耦的,它们只通过 serde 核心的 trait 接口通信。这也是为什么第三方库可以比较轻松地为新格式提供 serde 支持——只要实现好序列化器和反序列化器即可。
2. Serialize 与 Deserialize 设计精读
2.1 为什么序列化接口必须是 trait
直接回答标题里“类型系统融合”这层意思:trait 其实是一种编译期约束,任何人都可以为自己定义的类型实现。这跟 Java 里implements Serializable有点像,但底层逻辑完全不同:Java 的序列化靠虚拟机检查,Rust 的 trait 约束是编译期静态检查。
具体好处有三点:
第一,类型安全。如果一个类型没有实现Serialize,你根本没法把它传给serde_json::to_string,编译器直接报错。不存在“运行时才发现序列化失败”的尴尬。第二,零运行时开销。trait 的方法可以静态分发,再加上 monomorphization,序列化的核心路径可以优化到接近手写的水平。第三,开放性。只要满足 trait 的接口约定,任何外部类型都能参与序列化,不需要侵入类型本身的定义。
拿实际例子说:你从第三方 crate 里拿了一个IpAddr,你没法改它的源码,但 serde 可以在自己的代码里为它实现Serialize,或者由那个 crate 自己实现。从类型角度看,trait 就是给“别人的类型”添加“你的能力”的方式。
2.2 Serialize trait 的构成
看源码的话,Serializetrait 的定义非常简单:
pub trait Serialize { fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error> where S: Serializer; }一个方法,接受任意实现了Serializer的对象,返回结果。这里最关键的是泛型S——它不限定具体格式。也就是说,User类型的serialize方法,既可以配合serde_json工作,也可以配合bincode工作,前提是后者实现了Serializer。
但这里有个细节:Serialize的派生实现里,为什么要调用serialize_struct而不是直接逐字段输出?原因是效率。serde 需要给格式端一个“这是一个结构体,有几个字段”的预告,JSON 后端可以据此决定输出{,二进制后端可能直接跳过这个信息。让格式端自行决策,序列化过程就最大化地利用了格式本身的特点。
手写Serialize时,最常见的是这两类方法:
serialize_struct(name, len):声明一个结构体,然后依次输出字段。serialize_map/serialize_seq:输出键值对或序列。
对于枚举类型,更常用的是serialize_unit_variant、serialize_newtype_variant等,这些名字虽然繁琐,但确实覆盖了 Rust 枚举的所有表示形态。想理解 serde 的整个数据模型,其实就是把这些方法跟 Rust 类型系统一一对应起来:结构体对应 struct,元组对应 tuple,枚举对应 variant,Option 对应 option,等等。
2.3 Deserialize trait 与 Visitor 模式
反序列化比序列化难一个量级,核心原因是:序列化时你握着确切类型,顺理成章地输出;反序列化时输入数据是文本或字节流,你必须在运行时把它逐步“翻译”回类型。Rust 没有反射,翻译逻辑必须提前写好,而且要做到把任意格式的输入尽量漂亮地映射回类型。
解决这个问题的通用手法是 Visitor 模式。Deserializetrait 的真实形态比Serialize复杂,先看定义:
pub trait Deserialize<'de>: Sized { fn deserialize<D>(deserializer: D) -> Result<Self, D::Error> where D: Deserializer<'de>; }跟Serialize相比,多了一个生命周期参数'de。这个生命周期并不是摆设,它是 serde 支持零拷贝反序列化的关键支撑——稍后细说。更复杂的是,deserialize方法通常需要一个 visitor 来配合。典型的Deserialize手写实现长这样:
impl<'de> Deserialize<'de> for MyType { fn deserialize<D>(deserializer: D) -> Result<Self, D::Error> where D: Deserializer<'de>, { struct MyTypeVisitor; impl<'de> Visitor<'de> for MyTypeVisitor { type Value = MyType; fn expecting(&self, formatter: &mut fmt::Formatter) -> fmt::Result { formatter.write_str("a MyType value") } fn visit_map<V>(self, mut map: V) -> Result<MyType, V::Error> where V: MapAccess<'de>, { // 逐个字段读取并构造 MyType } } deserializer.deserialize_struct("MyType", &["field1", "field2"], MyTypeVisitor) } }Visitor 模式解决的核心问题是:反序列化的目标类型形态多样,无法用单个方法覆盖所有输入形态。JSON 里的对象,可能对应 Rust 的结构体、HashMap、或元组结构体;JSON 里的数组,也可能对应 Rust 的 Vec、元组、或定长数组。不同输入形态应该调用 visitor 的不同方法——visit_map、visit_seq、visit_str、visit_i64等等。这样一来,格式端只需调用一个统一入口,然后根据输入内容选择调用 visitor 的合适方法;类型端则在自己关心的方法里做真正的工作。
如果你自己实现了几个 Deserialize 的手写版,你会明显感觉到:大部分代码都在处理“输入长这样”的各种可能性,真正构造类型的逻辑反而只占一小段。这就是反序列化的本质——它不是简单的读取,而是从不确定的输入中识别出确定的结构。
2.4 零拷贝反序列化与生命周期推导
刚才提到'de这个生命周期,它其实是 serde 高级特性的基石。具体来说,当数据格式和后端支持借用(borrowing)时,反序列化得到的&str可以直接借用原始输入的缓冲区,而不是为每个字符串重新分配内存。这就是零拷贝反序列化(zero-copy deserialization)。
举个例子,如果你反序列化的是struct User<'a> { name: &'a str },并且使用支持借用的格式后端,那么得到的name就会直接引用原输入字符串中的一段,全程没有堆分配。这在解析大日志、大文件时,性能差别非常明显。
要实现零拷贝,关键在于Deserialize<'de>这个 trait 的生命周期参数:deserialize方法产生的值的生命周期,必须短于或等于输入数据的生命周期。编译器在调用点检查这个约束,从而保证借用安全。这也是我说“serde 是类型系统融合”的原因之一——生命周期安全这种语言级能力,被巧妙地嵌入进序列化框架,这种设计在其它语言里基本见不到。
不过需要提醒的是,零拷贝并非免费午餐。借用&str意味着你必须持有原始输入足够长的时间,这在很多业务场景里很难做到,因为输入归根到底是从网络或磁盘来的,你想把它丢到缓存里、再释放内存,借用就失效了。更常见的选择是直接反序列化为String,牺牲一点性能,换取更大的灵活性。所以实践中要实事求是:能零拷贝的场景就零拷贝,不能就老老实实转成拥有所有权的数据。
3. 从零到一:实战序列化大模型
这部分我想用一个相对完整的例子,一步步展示在真实项目中如何利用 serde 处理常见需求,并解释每一步为什么这么设计。直接上手,不空谈理论。
3.1 基础模型:从 derive 开始
假设我正在做一个模拟的 API 网关系统,需要解析一批来自前端请求的配置数据。最直接的做法是定义一个配置结构体,加上 derive:
use serde::{Serialize, Deserialize}; #[derive(Serialize, Deserialize, Debug, Clone)] #[serde(rename_all = "camelCase")] pub struct GatewayConfig { pub id: u64, pub host: String, pub port: u16, pub enable_ssl: bool, pub upstreams: Vec<String>, }这里的#[serde(rename_all = "camelCase")]是把 Rust 字段的 snake_case 自动映射成 JSON 里的 camelCase。我不喜欢在前端代码里看到下划线,这个属性省去了一堆手工改名。
有了这组 derive 之后,gateway_config就可以直接进入各种序列化场景:
let config = GatewayConfig { id: 1001, host: String::from("10.20.30.40"), port: 8080, enable_ssl: true, upstreams: vec![String::from("up1.local"), String::from("up2.local")], }; let json = serde_json::to_string_pretty(&config)?; let parsed: GatewayConfig = serde_json::from_str(&json)?;to_string_pretty输出是带缩进的 JSON,调试时很友好,线上如果追求性能就用to_string。两个函数都是通过T: Serialize约束来接受任意类型的,调用点不需要知道具体内部实现。
这段代码看似平平无奇,但背后其实发生了几件事:
- derive 宏在编译期检查字段,生成
serialize方法,逐字段调用serializer接口。 serde_json::to_string创建了一个Serializer,把类型事件依次翻译为 JSON 文本。- 反序列化时,JSON 解析器在遇到对象时,调用
Deserialize接口,读取字段并构造结构体。
你觉得写在代码里的只是“几行配置”,但对一个大型项目来说,这些细节直接被编译期的类型系统锁死了:字段拼错了、类型对不上,编译直接亮红灯,运行时的意外减少很多。
3.2 字段映射与下标管理
实际业务里经常遇到服务端返回的字段名不合法,比如user-name这种带横线的 key;或者前端后端命名风格不一致。这时候#[serde(rename)]就能精确控制映射关系:
#[derive(Serialize, Deserialize)] pub struct ApiResponse { #[serde(rename = "status_code")] pub status_code: u32, #[serde(rename = "data")] pub data: serde_json::Value, }如果你有一批字段的命名规则固定,例如外部系统全是大写,你还可以用rename_all = "UPPERCASE"到底,不用逐个手动映射。
这里有一个使用心得:能用rename_all就不手写重复的rename。原因很简单,rename_all是编译期统一处理的,不容易漏字段,而且当你决定切换命名策略时,一处改动全局生效。手写二十个rename也就算了,一百个字段真是维护灾难。
3.3 缺失字段与默认值策略
反序列化最常见的问题之一是“对方发的 JSON 缺字段”。默认行为是直接报错missing field xxx,很多新手一碰到这个就懵了。实际上,你可以非常细粒度地控制缺字段时的行为。
比如,API 返回对象里有一个debug字段,调试时需要,生产环境可能没有;或者新增一个字段,服务端还没完全切流,之前缓存的 JSON 里没有这个 key。你可以这样配置:
#[derive(Serialize, Deserialize)] pub struct ApiMeta { pub version: String, #[serde(default)] pub enable_debug: bool, #[serde(default = "default_retry_times")] pub retry_times: u32, #[serde(skip_serializing_if = "Option::is_none")] pub trace_id: Option<String>, }这里有三类策略:
#[serde(default)]:当字段缺失时使用类型的默认值(bool 默认 false,numeric 默认 0 等)。#[serde(default = "default_retry_times")]:指定一个函数,缺字段时调用它来生成默认值。这样做的好处是可以实现比“零值”更合理的默认策略,比如根据环境动态取默认值。#[serde(skip_serializing_if = "Option::is_none")]:序列化时跳过值为None的字段,让输出的 JSON 更精简。反序列化时缺省按None处理。
配合这两类默认策略,面对外部系统的“残缺数据”时,代码的鲁棒性会明显提升。而且这些策略都是注释性质的声明式配置,读代码时一眼就懂,比在业务逻辑里反复判断字段是否存在要干净太多。
3.4 嵌套结构与枚举的表示方式
真实项目的数据一定是有嵌套的,网关配置里的子模块、响应体里的分页信息,都是内嵌的类型。serde 对嵌套的处理非常自然:只要字段类型实现了对应的 trait,递归处理即可。例如:
#[derive(Serialize, Deserialize)] pub struct PageResult<T> { pub total: u64, pub items: Vec<T>, }这里T本身没有显式实现Serialize,但PageResult<T>的派生实现会自动添加T: Serialize、T: Deserialize的约束。也就是说,只有当T是“可序列化”的,整个PageResult<T>才可序列化。泛型约束跟类型能力绑定得很好,你会得到非常优雅的调用方式:
let page: PageResult<User> = ...; // 只要 User 可序列化,这里就成立枚举面对的问题更复杂:Rust 枚举有单元变体、结构体变体、元组变体等多种形态,JSON 里怎么表示?默认的派生实现,单元变体会序列化为字符串,结构体变体会序列化为{variant_name: {field: value}}这样的嵌套对象,这通常够用。但如果你想对齐某个既有 API 协议,手工控制变体表示就很关键了。
例如,把枚举序列化为字符串加元数据的形态:
#[derive(Serialize, Deserialize)] #[serde(tag = "type", content = "payload")] pub enum Message { Text(String), Image { url: String, width: u32 }, }这样 JSON 就长这样:{"type": "Text", "payload": "hello"}和{"type": "Image", "payload": {"url": "...", "width": 800}}。tag和content的配置,可以把 Rust 枚举变成类似“带类型标记的兼容性协议”,在前后端交互或异构系统对接时非常有价值。
3.5 手写实现序列化:自定义逻辑与外部类型
很多情况下 derive 已经够用,但有三种场景必须手写:
- 类型来自第三方 crate 且对方没实现 serde;
- 数据结构在序列化时要做额外处理(加密、脱敏、类型转换);
- 跟外部 API 的格式要求不完全匹配,需要在跟数据格式之间做自适应。
手写Serialize其实不复杂,核心就是调用 serializer 的相关方法:
impl Serialize for UnserializableType { fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error> where S: Serializer, { let mut state = serializer.serialize_struct("BridgedType", 2)?; state.serialize_field("value", &self.inner_value)?; state.serialize_field("extra", &transform(&self.metadata))?; state.end() } }这段代码做的事情,可以理解成:自己在“翻译层”里插入了一步,把内部的inner_value按你想要的方式展示给数据格式,把metadata经过transform函数加工后再输出。外部调用方完全无感,它只是把BridgedType当成一个普通字段来序列化。
手写Deserialize会繁琐一些,最典型的就是实现 Visitor,刚才在 2.3 里已经展示过骨架。实际开发中,我见过很多人在手写Deserialize时,直接把serde_json::Value当作中间格式转一手:
impl<'de> Deserialize<'de> for WeirdFormat { fn deserialize<D>(deserializer: D) -> Result<Self, D::Error> where D: Deserializer<'de>, { let raw: serde_json::Value = serde_json::Value::deserialize(deserializer)?; // 手动从 raw 中提取字段,做各种判断 Ok(WeirdFormat { /* ... */ }) } }这是一个实用的偷懒方案:先用Value把所有输入兜住,再按自己的逻辑解析。它的代价是性能稍差(必须先完整解析成一棵通用值树),但对一些格式怪异、一次性对接的接口来说,简单粗暴反而好维护。这是典型的“先能跑,再优化”。
4. 性能调优、常见坑与工程实践
这一部分我想专门聊聊工程中真实遇到的问题。网上的入门教程一般到“能跑”就结束了,但真正折磨人的是线上接口报错、性能瓶颈、以及各种边界情况。
4.1 长期维护的项目里,如何保持序列化兼容性
你可以把线上存有 JSON 字段的数据库看作“无法回滚的契约”。一旦某类数据的 JSON 结构发布出去,再想改字段名、删字段、改类型,必须考虑老数据的兼容性。所以我有几个习惯:
- 新增字段时务必设置
#[serde(default)]或用Option,别让老数据解析失败。 - 删除字段时先把字段标记为
#[serde(skip_serializing_if = "Option::is_none")],观察一段时间确认没问题再彻底移除。 - 谨慎避免直接改字段类型,比如从
String改成u64,这类变更很容易直接破坏已有缓存或存储。 - 尽量用
#[serde(rename_all = "camelCase")]保持对外协议稳定,内部字段名随便改,不影响外部契约。
这些经验说白了就是“序列化结构也是一种 API,需要versioning”。Rust 的类型系统能帮你保证字段存在,但保证不了外部系统也遵守你的版本约定。
4.2 编译体积与序列化性能
大家可能听说过#[derive(Serialize)]宏会生成大量代码,尤其是在重泛型、多字段场景下。实际测试下来,serde 的编译时间不小,而且二进制体积也会隐性膨胀。好在大部分做序列化的代码在冷路径上,很少成为真正瓶颈。
如果你的 API 响应体很大、QPS 很高,有几个可以实际优化的点:
第一,优先用二进制格式。serde_json的解析和字符串生成开销比较大,如果服务间通信不需要人类可读,换成bincode或postcard这样的二进制格式,性能和体积通常都会有明显改观。第二,考虑零拷贝。使用&str借用输入上的字段,能省掉大量内存分配。第三,检查默认的序列化是否做了多余工作。例如很多字段本来是u32,但类型里写成了String,导致先解析、再格式化、再序列化,徒增 CPU。
还有一点:serde_json::Value这种通用类型用起来方便,但它的代价是完整的内存分配。如果只是拿它做一层中转,性能损耗会很大,最好直接用具体类型或自定义反序列化。
4.3 必踩的坑:生命周期、循环引用、歧义枚举
我自己踩过的坑,基本可以分为这三类。
生命周期问题。定义一个包含&str的结构体,使用派生反序列化时一切正常,但如果你想收集到某个集合里长期保存,你会发现借用要跟原字符串共存亡。解决办法是改成String,或者搞清楚输入数据的生命周期范围。这种问题编译器通常能识别,但报错信息经常比较绕,可能显示一些'de无法满足约束的神秘信息。
循环引用与递归类型。自引用结构(Node { next: Option<Box<Node>> })可以序列化,但反序列化时其实无法直接还原引用,所以 Rust 里反序列化循环结构基本没有可能做到像图结构那样自然。如果你要序列化图,建议先转成“扁平节点列表 + 索引引用”,再传递整个图。这跟数据库表设计的思路很像。如果写成Node { next: Option<Node> }不包 Box,则不仅放不进堆,还会导致无限递归栈溢出,编译不会提示,运行时直接崩。
枚举的歧义表示。派生实现对枚举的控制比较保守,一旦要跨语言传输,就可能遇到跟预期不符合的结果。比如一个单元变体可能在 JSON 里被表示为"VariantName",但对方可能期望它显示为{"variant": "VariantName"}。解决办法是搞懂#[serde(tag)]、#[serde(untagged)]、#[serde(rename)]之间的搭配,必要时宁可手写Deserialize也不要强行靠 derive 适配所有协议。
性能上的小脏坑。#[serde(default)]会用Default::default(),如果你给Vec写了一个非常昂贵的Default实现(正常情况下不会,但谁知道有没有人搞事情),在大量反序列化时会产生隐性开销。所以有追求的话,你可以在缺失字段的路径上显式构造一个空 Vec。
4.4 测试序列化代码的必备技巧
写序列化相关代码,不只是“编译通过就完事”。为了保证线上稳定,我会反复用“黄金文件测试”来锁定协议。简单来说,就是把期望的 JSON 内容写死在测试里,反序列化后断言每个字段的值。举例:
#[test] fn parse_gateway_config_from_fixture() { let raw = r#"{"id":1001,"host":"10.20.30.40","port":8080,...}"#; let cfg: GatewayConfig = serde_json::from_str(raw).expect("should parse"); assert_eq!(cfg.id, 1001); assert_eq!(cfg.port, 8080); }这种测试的价值在于:未来任何人改了字段名、调整了类型映射、把#[serde(rename_all)]改坏了,测试都会第一时间爆出来。你很可能因为一次鲁莽的“类型重构”引起线上数据解析失败,这种测试就是那道最后防线。
另外,我还会写“往返测试”(round-trip test):先序列化一个已知对象,再反序列化,断言两个对象相等。这样能发现那些不能在两种方向间来回映射的 bug。比如某个字段你自定义了Serialize但忘了一个方向的动作,往返测试很容易暴露出不对称。
4.5 和框架的整合:Web 层与消息队列
最后聊一聊 serde 在实际框架中的集成方式。在 Web 场景里,Serialize控制 HTTP 响应体长什么样,Deserialize控制请求体如何解析。拿一个模拟的 Web 框架来说,路由处理函数常常返回一个实现了Serialize的类型,框架内部调用to_string生成响应。一旦你的响应体对象里有个字段不能序列化,整个请求就会直接 500,所以设计响应体 DTO 时,最好把“内部用、不外传”的字段标记为跳过,以免泄露内部状态。
消息队列跟序列化紧密结合是另一个重点。比如某些队列框架默认用 JSON 做消息编码,那么消费者端的结构体必须有正确的Deserialize实现。而且这里有个经验:消息结构一旦发布,必须严格遵循“只增不减、默认值兼容”的原则,因为不同服务可能同时运行不同版本,老版本消费者解析新消息时缺字段就会直接报错。
整体的思路是:把 serde 当作一种“协议契约”,而不仅仅是工具库。用类型定义协议,用 derive 属性描述映射关系,用测试锁定行为——这套工作流能最大程度发挥 Rust 类型系统和 serde 的结合优势。
5. 写在最后的工程体会
如果非要总结什么,我觉得最重要的不是记住#[serde(default)]怎么用,而是建立起“序列化结构即契约”的意识。Rust 的类型系统给了我们编译期保证:类型对了,基本不会在运行时意外炸掉;而 serde 在这个基础上,又把“我们选择如何呈现一个类型”这个维度也收编进了类型系统。编译期决定的字段映射、默认值、跳过策略,都写进了代码里,变成可以审查、可以测试、可以演化的东西,而不是隐藏在业务代码里的运行时判断。
我之前有一个项目,最初用serde_json::Value接收所有外部请求,代码是省事了,但一旦请求结构变化,排查问题就是在 JSON 字符串和业务逻辑之间来回窥探,非常痛苦。后来花了点时间,把边界处的通用 Value 全部换成了具体 DTO,配上字段映射和默认值,繁琐是繁琐了些,但整个系统的可维护性确实上了一个台阶。这个取舍,我觉得挺值的。