Deser:重新思考 Rust 序列化
摘要
文章介绍了 Deser,这是一个新的 Rust 序列化库,通过重新思考其架构来解决 Serde 的局限性。它强调了 Serde 设计中的问题,并将 Deser 作为受 miniserde 启发的一种替代方案呈现。
<p><a href="https://lobste.rs/s/hxmtyv/deser_rethinking_rust_serialization">评论</a></p>
查看缓存全文
缓存时间: 2026/09/29 22:05
# Deser:重新思考 Rust 序列化
来源:https://lucumr.pocoo.org/2026/9/29/deser/
发布于 2026 年 9 月 29 日
Serde (https://serde.rs/) 是一个出色的 Rust 序列化库,多年来它是我高效使用 Rust 的重要原因。然而,早在我还在 Sentry 工作时,就已对它的某些局限性感到相当沮丧。但真正替换 Serde 并不容易,因为它在生态系统中举足轻重。此外,要想在不做出一些潜在痛苦妥协的情况下真正做得更好,也相当困难。以下是三个 Serde 边缘案例,展示了其特性之间不佳的交互或意外的限制:
### 一个作为映射的数字
一个内部标记的枚举,在启用 `serde_json` 的 `arbitrary_precision` 特性时:
```rust
#[derive(Deserialize)]
#[serde(tag = "type")]
enum Shape {
Circle { radius: f64 },
}
serde_json::from_str(r#"{"type": "Circle", "radius": 1.5}"#)
// 错误:无效类型:映射,期望 f64
```
Serde 的数据模型没有任意精度数字的位置,因此 `serde_json` 使用带内信令,通过一个具有魔力键的映射来实现。枚举必须缓冲字段,直到看到标签,而缓冲区并不知道这个魔力键。由于 Cargo 特性是统一的,依赖图中任何 crate 开启此特性都足够了。
### 扁平化破坏整数键
```rust
#[derive(Deserialize)]
struct Stats {
scores: HashMap<u32, u32>,
}
#[derive(Deserialize)]
struct Report {
name: String,
#[serde(flatten)]
stats: Stats,
}
serde_json::from_str(r#"{"name": "x", "scores": {"42": 23}}"#)
// 错误:无效类型:字符串 "42",期望 u32,位于第 1 行第 35 列
```
`Stats` 单独解析 `{"scores": {"42": 23}}` 完全没问题。JSON 键始终是字符串,只有当类型需要时,`serde_json` 才会将它们转换为整数。然而,一旦 `flatten` 缓冲了值,`"42"` 就只是一个字符串了。错误也指向文档的末尾,而不是键。
### 适配器无法组合
```rust
fn from_hex<'de, D: Deserializer<'de>>(d: D) -> Result<u32, D::Error> { ... }
#[derive(Deserialize)]
struct Theme {
#[serde(deserialize_with = "from_hex")]
primary: u32,
#[serde(deserialize_with = "from_hex")]
accent: Option<u32>,
}
// 错误[E0308]:`?` 运算符类型不兼容
// |
// | #[serde(deserialize_with = "from_hex")]
// | ^^^^^^^^^^ 期望 `Option<u32>`,找到 `u32`
// |
// 帮助:尝试用 `Some` 包裹表达式
// |
// | #[serde(deserialize_with = Some("from_hex"))]
// | +++++ +++++
```
函数不能作为类型参数传递,因此无法将 `from_hex` 应用于 `Option`、`Vec` 或映射的内部。你需要为每个包装器编写另一个函数,而一旦有了 `from_opt_hex`,除非你还记得添加 `#[serde(default)]`,否则该字段就不再是可选的了。
这些都不是在 Serde 中容易修复的 bug。它们源于 Serde 的设计,而该设计受到 Serde 稳定性保证的保护。
早在 2022 年,我开始了一个名为 Deser (https://github.com/mitsuhiko/deser) 的实验。它是一个 Rust 序列化库,借鉴了 Serde (https://serde.rs/) 的用户体验,并将其置于受 miniserde (https://github.com/dtolnay/miniserde) 启发的完全不同架构之上。我从未真正完成它,它闲置了几年。我重新拾起了它,现在它达到了我认为值得一看的阶段。即使只是为了启发他人,看看他们是否想探索这个领域。
## 名称与理念
名称是 Serde 将其两半交换。Deser 是 Serde,但顺序相反。
在 Serde 中,类型驱动反序列化过程:一个 `Deserialize` 实现向反序列化器请求它期望的值类型,格式回调到一个访问者。每个嵌套值都通过递归处理,这使得 Serde 反序列化本质上会随着每一级嵌套而增长栈空间。
另一方面,Deser 反转了这个过程:格式告知下一个值的类型,并将事件推入一个接收器。当接收器遇到嵌套值的开头时,它不会调用它,而是将新的接收器交回给驱动程序,驱动程序将所有状态保持在堆上(实际上是在一个 arena 中)。在返回时,发射器返回其嵌套值,而不是递归进入它们。
这也意味着 Deser 无法支持像 protobuf 这样非自描述的格式。它们实际上被有意完全排除在设计之外。换句话说:如果你想“修复” Serde,就需要做出一些其他妥协。
Deser 想法的大部分原因可以追溯到 Sentry Relay (https://github.com/getsentry/relay),它处理大量的不可信 JSON。多年来,我在 Sentry 工作时反复遇到相同的问题集,其中许多问题并非 Serde 中的真正 bug,而是其设计的后果。Serde 的稳定性保证意味着,如果不破坏每个格式和每个手写实现,许多问题都无法修复。
这些问题大多源于三个决策:
1. **所有格式使用一套 trait**。Serde 同时服务于自描述格式(JSON、YAML、TOML……)和需要预先知道类型的格式(postcard、bincode、protobuf……)。这非常有用,但也意味着某些特性只适用于某些格式,而且你是在运行时才发现的。在 Serde 的情况下,它还有一些奇怪的曲折,例如派生的结构体在 JSON 中会悄悄地接受一个数组来代替对象。
2. **固定的数据模型在缓冲时会丢失信息**。内部标记枚举、未标记枚举和 `flatten` 需要在知道如何处理之前缓冲值。缓冲区无法保存格式所知的所有信息,错误会丢失其位置,生态系统的扩展依赖带内信令来表达任意精度数字等事物。
3. **在调用栈上递归**。每一级嵌套都使用栈空间。格式通过递归限制来防范这一点,但当你通过一个没有此限制的代码路径(写入、动态值)时,深度嵌套的数据可能会导致你的进程崩溃。这也意味着反序列化无法在等待更多输入时暂停。
许多相关的 Serde 问题已开放多年,我之前写过关于滥用 Serde (https://lucumr.pocoo.org/2021/11/14/abusing-serde/) 的文章。多年来,人们尝试了不同的角度。一些人走向极简,丢弃了大部分特性以实现快速编译和无递归。dtolnay 自己的 miniserde (https://github.com/dtolnay/miniserde) 是最好的例子,deser 的 trait 设计最初就是模仿它的。其他最近的尝试转向运行时反射,或者一个新的专注于二进制格式的数据模型。
如果你想了解 Serde 设计的所有已收集挑战,我在这里维护了一个详尽的列表 (https://github.com/mitsuhiko/deser/blob/main/SERDE.md)。
## 取代 Serde
首先,我认为不太可能取代 Serde。孤儿规则 (https://smallcultfollowing.com/babysteps/blog/2022/04/17/coherence-and-crate-level-where-clauses/) 使 Serde 在生态系统中根深蒂固。
但有些事情在 crate 作者的控制范围内。以 Deser 为例,那就是完整性。Deser 今天实现了所有重要的自描述格式,包括 YAML、JSON、TOML、CBOR、JSON5 等,也包括 XML 和 plist 以真正缩小差距。特别是 XML,Serde 拒绝支持它,这一点很明显(下文详述)。至少,格式支持不应成为不使用 Deser 的理由。
第二个问题通常是,真正解决 Serde 的问题会在编译时间和/或运行时性能上付出显著代价。Deser 也不例外。虽然 Deser 的编译时间比 Serde 略好,但二进制膨胀要糟糕得多,运行时性能则好坏参半。从数字上看大致相当,但根据格式结构的不同,你会因一些权衡而遭受显著损失。话虽如此,它现在至少在原则上是一个可以直接替换的方案,其权衡可能对用户来说效果良好。
## Deser 的设计
Deser 在表面层并不试图与 Serde 有显著差异。对于大多数用途,你派生 `Serialize` 和 `Deserialize`,然后开始使用你选择的格式实现 crate。大多数属性非常相似,尽管它们接受 Rust 表达式而不是字符串。
```rust
use deser::{Serialize, Deserialize};
#[derive(Debug, Serialize, Deserialize)]
#[deser(rename_all = "camelCase")]
pub struct Account {
id: u64,
account_holder: String,
#[deser(default)]
is_deactivated: bool,
}
let account: Account = deser_json::from_str(json)?;
```
如果你自己实现序列化器或反序列化器,设计上的差异会更加明显。反序列化一个类型会创建一个*接收器*,它接收解析器直接发出的事件,而不是递归地相互调用的访问者。*序列化产生发射器*,发射器分发值。嵌套的接收器和发射器被交回给驱动程序,驱动程序将它们保持在堆上。这个完全借鉴自 miniserde 的设计带来了一些有趣的结果:
- **无栈溢出**。你可以任意嵌套结构而不会出现问题。对于不可信输入,你可以用一层来设置限制,选择你舒适的数字,这与你的栈空间无关。
- **可挂起**。因为状态存在于驱动程序中,反序列化可以在输入到达时逐步进行。它也是 `Send` 的,因此可以在等待 I/O 时在线程之间移动,这使得它与 tokio 一起使用时更友好。如果你愿意,JSON、CBOR 和 MessagePack 等格式可以作为流来解析。
- **可扩展的数据模型**。核心数据模型很小,由原子、映射和序列组成。对于其他所有东西,有扩展值(`DateTime`、`Uuid` 等),它们也都带有一个用于不理解它们的格式的后备方案。与 Serde 不同,这意味着它不依赖带内信令的对象和魔力键来偷运值。
- **无损缓冲**。当值必须被缓冲时(例如,因为内部标记枚举的标签是最后出现的),Deser 记录事件以及格式所知的所有信息。协议特定的扩展类型或错误位置都会保留。
- **层**是一个中间件系统,位于格式和你的类型之间,可以跟踪路径、强制安全限制、重命名键或编辑值,而无需触及特定代码路径。
- **原生扁平化**,根本不进行缓冲。
除此之外,还有许多我只是想要的东西:
- 允许枚举标签为任何类型,而不仅仅是字符串
- 可组合的适配器(`as = Option<_>`)
- 将验证作为适配器启用
- 派生属性是真正的 Rust 表达式而不是字符串
- 字节作为数据模型中的核心功能
- 默认拒绝重复键,并提供指向问题的错误
这是一个小型配置类型,展示了一些这些特性的组合:
```rust
use deser::adapters::DisplayFromStr;
use deser::de::Recording;
use deser::{Deserialize, Serialize};
use deser_encoding::Hex;
use deser_validate::{Check, NonEmpty, Range};
use ipnet::IpNet;
#[derive(Debug, Serialize, Deserialize)]
pub struct Config {
// 至少一个 256 位密钥,每个写成十六进制
#[deser(as = Check<NonEmpty>)]
secret_keys: Vec<[u8; 32]>,
// `IpNet` 对 deser 一无所知,但有 `FromStr` 和 `Display`
#[deser(as = Option<DisplayFromStr>)]
allowed_networks: Option<Vec<IpNet>>,
listeners: Vec<Listener>,
}
#[derive(Debug, Serialize, Deserialize)]
#[deser(tag = "type", rename_all = "snake_case")]
pub enum Listener {
Unix { path: PathBuf },
Tcp {
host: IpAddr,
#[deser(as = Check<Range<1, 65535>>)]
port: u16,
},
// 该版本不知道的类型会被保留并写回
#[deser(other)]
Other(#[deser(tag)] String, Recording),
}
```
适配器是类型,因此 `Hex` 可以放在 `Vec` 内部,`DisplayFromStr` 可以放在 `Vec` 内部,而 `Vec` 又可以放在 `Option` 内部。验证器也是适配器,因此 `Check<NonEmpty>` 解码键然后检查至少有一个。兜底变体保留标签以及所有其他内容的记录,以防有人想稍后处理它。
错误是我非常关心的事情,所以当值错误时,会发生以下情况:
```toml
secret_keys = ["9f86d081884c7d659a2feaa0c55ad015a3bf4f1b2b0b822cd15d6c15b0f00a08"]
allowed_networks = ["10.0.0.0/8", "fd00::/8"]
[[listeners]]
type = "unix"
path = "/run/app.sock"
[[listeners]]
host = "127.0.0.1"
port = 0
type = "tcp"
[[listeners]]
type = "quic"
host = "::1"
alpn = ["h3"]
```
```rust
let config: Config = deser_toml::Deserializer::from_str(input)
.deserialize_with(|driver| driver.push_layer(PathLayer::new()))?;
```
请注意,这里内部标记枚举的标签是最后出现的,这意味着在知道标签之前必须缓冲值。在 Serde 中这很棘手,如果我们使用一些技巧来添加它,我们会丢失位置信息。然而,对于 Deser,启用路径层后,Deser 能告诉你问题在结构中的哪个位置:
```
意外:无效值:必须在 1 到 65535 之间,位于第 10 行第 8 列(路径:listeners[1].port)
```
## Deser 元数据
Deser 真正希望可扩展,而 XML 是 Deser 和 Serde 之间差异的一个更极端的例子。这是一个混合了 Dublin Core 作者信息的 Atom 条目:
```rust
use chrono::{DateTime, Utc};
use deser::Deserialize;
use deser_value::Value;
use deser_xml::DeserializerConfig;
deser_xml::namespace!(
atom = "http://www.w3.org/2005/Atom",
dc = "http://purl.org/dc/elements/1.1/",
);
#[derive(Debug, Deserialize)]
struct Entry {
#[deser(rename = atom!("title"))]
title: String,
#[deser(rename = dc!("creator"))]
creators: Vec<String>,
#[deser(rename = atom!("updated"))]
updated: DateTime<Utc>,
}
// 我们理解的条目,其他一切都原样保留
#[derive(Debug, Deserialize)]
#[deser(untagged)]
enum Item {
Entry(Entry),
Other(Value),
}
let item: Item = DeserializerConfig::new()
.resolve_namespaces(true)
.from_str(
r#"<entry xmlns:dc="http://purl.org/dc/elements/1.1/">
<title>Deser</title>
<dc:creator>John</dc:creator>
<updated>2026-09-29T21:00:00Z</updated>
<dc:creator>Jane</dc:creator>
</entry>"#,
)?;
```
XML 使用命名空间,这意味着名称需要通过其命名空间匹配,而不是通过文档恰好使用的前缀。这里文档使用 `d:` 而类型使用 `dc!`。`atom!("title")` 只是字符串 `{http://www.w3.org/2005/Atom}title`,这之所以可行,是因为属性是表达式。两个作者被收集到一个 `Vec` 中,即使它们之间有另一个元素,并且 `updated` 的文本直接进入 `chrono` 日期时间。因为枚举是未标记的,所以在选择变体之前必须缓冲条目,而 deser 的缓冲区保留了两个作者。因此结果是一个包含 John 和 Jane 的 `Entry`。
quick-xml,Serde 最流行的 XML crate,丢弃了前缀并完全忽略了命名空间,因此来自其他命名空间的 `<title>` 会作为条目的标题被愉快地接受。然而,分离列表部分情况更糟。一个普通的 `Entry` 会因为 `creator` 字段重复而失败,除非你启用 `overlapped-lists` 特性(记住,这是一个任何 crate 都可能设置的全局附加标志)。该特性使 quick-xml 读取到元素末尾并缓冲之间的所有内容,除非你设置限制,否则没有限制。但该特性只有在 quick-xml 直接连接到结构体且没有发生缓冲时才有帮助。将结构体包装在未标记枚举中,Serde 会缓冲条目本身。从该缓冲区读取时,`Entry` 再次看到 `creator` 并再次失败。后备方案是一个映射,它只保留最后一个 `creator`,并且没有错误。无论是否使用该特性,你都会得到这个:
```
Other({"creator": {"$text": "Jane"}, "title": {"$text": "Deser"}, ...})
```
注意 John 消失了。
像 TOML 日期时间这样的格式特定扩展类型是另一个案例。TOML 原生支持它们,Serde 的数据模型不支持,因此 `toml` crate 会将它们作为字符串传递。
相似文章
@debasishg:我关于Rust底层系统设计系列的第一部分现已发布 - 这部分涵盖:• 如何根据谁接触什么来布局共享的Rust结构体…
关于Rust底层系统设计系列的第一部分介绍了缓存感知的数据布局技术,包括字段分区以避免伪共享,重点涉及多线程结构体和128字节规则,并以SPSC环形缓冲区为例。
用 Rust 重写
本文评估了2026年的‘Rewrite It In Rust’运动,讨论了现实世界中的性能提升、诸如新错误和平台支持等挑战,并提倡增量重写而非完全重写。
并发服务器:第7部分 - Rust
本文是关于并发服务器系列文章的一部分,介绍了如何使用Rust实现并发网络服务器,涵盖了顺序、线程和事件驱动方法,并提供了代码示例。
未定大小值的类型转换
本文探讨了Rust中对未定大小值进行类型转换的挑战,与Go语言的接口动态类型进行比较,指出了Rust类型系统在处理非定大小类型方面的限制。
Rust中Pretty Printer实现的新设计
一篇博客文章,探讨了Rust中Pretty Printer实现的新设计,解决了将函数式编程研究适应到没有垃圾回收的系统语言中的挑战,并比较了现有的方法,比如'pretty' crate和Oppen风格的Pretty Printer。