Serde 是 Rust 事实上的序列化标准,它的巧妙之处在于把「数据模型」和「数据格式」彻底解耦:Serialize/Deserialize 只描述类型长什么样,JSON、bincode、MessagePack 各自实现一套 Serializer。加一种格式不用改任何业务结构体。
代价是抽象层带来性能开销,以及一堆属性组合起来的语义容易记混。本文梳理 derive 的常用属性、四种主流格式的取舍、自定义序列化的正确姿势,以及零拷贝反序列化能省下多少分配。
derive 与常用属性
use serde::{Serialize, Deserialize};
#[derive(Serialize, Deserialize)]
#[serde(rename_all = "camelCase", deny_unknown_fields)]
struct Config {
#[serde(rename = "listenAddr")]
listen: String,
#[serde(default = "default_port")]
port: u16,
#[serde(skip_serializing_if = "Option::is_none")]
tls: Option<TlsConfig>,
#[serde(alias = "old_name")] // 反序列化时兼容旧字段名
timeout_ms: u64,
}
fn default_port() -> u16 { 8080 }
| 属性 | 作用 |
|---|---|
rename / rename_all | 字段名映射,camelCase/snake_case/SCREAMING_SNAKE_CASE |
default / default = "path" | 缺字段时用默认值,而非报错 |
skip / skip_serializing_if | 跳过字段或条件跳过(常用于 Option) |
alias | 反序列化接受多个字段名,做向后兼容 |
flatten | 内联嵌套结构的字段 |
deny_unknown_fields | 拒绝多余字段,配置解析时推荐 |
borrow | 借用输入数据,实现零拷贝 |
flatten 的代价
flatten 用起来优雅,但它会强制走「先收集到中间 map 再分发」的路径,无法再用借用式反序列化,且明显更慢:
#[derive(Deserialize)]
struct Request {
id: String,
#[serde(flatten)]
extra: HashMap<String, serde_json::Value>, // 灵活但慢
}
热路径上的结构体慎用 flatten;能用显式字段就别用它。
枚举的三种表示
// 1. 外部标签(默认):{"Point": {"x":1,"y":2}}
#[derive(Serialize, Deserialize)]
enum Shape { Point { x: i32, y: i32 }, Circle(f64) }
// 2. 内部标签:{"type":"Point","x":1,"y":2}
#[serde(tag = "type")]
// 3. 无标签:靠尝试每个变体匹配,最慢
#[serde(untagged)]
untagged 需要把输入反序列化多次来试探变体,性能最差,只在真的需要「JSON 形状不固定」时用。
格式选型
| 格式 | crate | 相对体积 | 相对速度 | 可读性 | 典型场景 |
|---|---|---|---|---|---|
| JSON | serde_json | 1.0× | 1.0× | 高 | API、配置文件 |
| bincode | bincode | ~0.5× | 3~5× | 无 | 进程内/同构节点通信 |
| MessagePack | rmp-serde | ~0.6× | 2~3× | 低 | 跨语言、日志 |
| CBOR | ciborium | ~0.6× | 2× | 低 | IoT、CBOR 标准场景 |
| TOML | toml | 1.2× | 0.5× | 高 | 配置(Cargo.toml 风格) |
| Protobuf | prost | ~0.4× | 3×+ | 无 | 跨语言强契约、gRPC |
[dependencies]
serde = { version = "1", features = ["derive"] }
serde_json = "1"
bincode = "1.3"
rmp-serde = "1.3"
bincode 2.x 的 API 与 1.x 不兼容,升级前务必确认依赖链里的版本。bincode 不适合长期存储:没有 schema 演进机制,结构体加字段就会读不出旧数据。
选型的实际判据是「对端是谁」:对端是浏览器或人类,选 JSON;对端是自家同构服务,选 bincode;对端是异构语言且要强契约,选 Protobuf。
自定义序列化
serialize_with / deserialize_with
use serde::{Deserialize, Serialize, Serializer, Deserializer};
#[derive(Serialize, Deserialize)]
struct Event {
#[serde(with = "ts_seconds")]
at: chrono::DateTime<chrono::Utc>,
}
mod ts_seconds {
use super::*;
use chrono::{DateTime, TimeZone, Utc};
pub fn serialize<S: Serializer>(dt: &DateTime<Utc>, s: S) -> Result<S::Ok, S::Error> {
s.serialize_i64(dt.timestamp())
}
pub fn deserialize<'de, D: Deserializer<'de>>(d: D) -> Result<DateTime<Utc>, D::Error> {
let secs = i64::deserialize(d)?;
Ok(Utc.timestamp_opt(secs, 0).single().ok_or_else(||
serde::de::Error::custom("invalid timestamp"))?)
}
}
实现 Visitor
要控制反序列化的输入形态(例如接受「数字或字符串」),需要手写 Visitor:
use serde::de::{self, Visitor};
struct StringOrInt(i64);
impl<'de> Deserialize<'de> for StringOrInt {
fn deserialize<D: Deserializer<'de>>(d: D) -> Result<Self, D::Error> {
struct V;
impl<'de> Visitor<'de> for V {
type Value = StringOrInt;
fn expecting(&self, f: &mut std::fmt::Formatter) -> std::fmt::Result {
f.write_str("an integer or a string containing an integer")
}
fn visit_i64<E: de::Error>(self, v: i64) -> Result<Self::Value, E> {
Ok(StringOrInt(v))
}
fn visit_str<E: de::Error>(self, v: &str) -> Result<Self::Value, E> {
v.parse().map(StringOrInt).map_err(de::Error::custom)
}
}
d.deserialize_any(V)
}
}
deserialize_any 对自描述格式(JSON)可用,但对 bincode 这类非自描述格式会报错——这是「同一结构体适配所有格式」的边界。
零拷贝反序列化
JSON 里的字符串默认会被分配成 String。若只需要借用,可以完全不分配:
use serde::Deserialize;
#[derive(Deserialize)]
struct LogLine<'a> {
#[serde(borrow)]
level: &'a str,
#[serde(borrow)]
msg: &'a str,
ts: u64,
}
// from_slice 借用输入缓冲区,生命周期与 input 绑定
let input = std::fs::read("app.log")?;
let line: LogLine = serde_json::from_slice(&input)?;
from_slice 配合 &'a str 字段,解析过程零分配。前提是输入缓冲区在结构体存活期间一直有效——所以不能用 from_reader,它内部的临时缓冲会被回收。
需要「借用优先、必要时拥有」时用 Cow<'a, str>:
use std::borrow::Cow;
#[derive(Deserialize)]
struct Record<'a> {
#[serde(borrow)]
name: Cow<'a, str>, // 无转义时借用,有转义时自动分配
}
转义字符(\n、\uXXXX)会强制分配,所以「零拷贝」只对不含转义的输入成立。
性能优化与常见坑
输出用 to_writer
// ❌ to_string 先分配一个大 String,再写出去
let s = serde_json::to_string(&payload)?;
writeln!(conn, "{}", s)?;
// ✅ 直接写,省一次分配
serde_json::to_writer(&mut writer, &payload)?;
响应体大时,to_writer + 带缓冲的 writer(BufWriter)能省掉整块内存的分配与拷贝。
避免 Value 中转
// ❌ 先解析成 Value 再转结构体,等于解析两遍
let v: serde_json::Value = serde_json::from_str(&body)?;
let req: Request = serde_json::from_value(v)?;
// ✅ 一次解析到位
let req: Request = serde_json::from_str(&body)?;
serde_json::Value 的每个节点都是枚举 + 堆分配,只在确实需要动态结构(如 flatten 的 extra)时才用。
常见坑清单
| 现象 | 原因 | 对策 |
|---|---|---|
missing field | 缺字段且无 default | 加 #[serde(default)] |
| 数字精度丢失 | JSON number 走 f64 | 用 serde_json::Number 或 arbitrary_precision |
flatten 后反序列化报错 | 非自描述格式不支持 | 换 JSON,或去掉 flatten |
枚举 untagged 很慢 | 逐变体试探 | 改用内部标签 tag |
| 大结构体慢 | 字段多导致多次 visitor 调用 | 拆小结构体,或换 bincode |
| 递归结构栈溢出 | 无深度限制 | 用 serde_json::Deserializer::disable_recursion_limit 慎用 |
递归与深度限制
解析不可信输入时,深度限制是安全边界:
let mut de = serde_json::Deserializer::from_str(&body);
de.disable_recursion_limit(); // 关闭前务必自己做深度校验
默认 serde_json 有 128 层递归上限,这是防栈溢出的保护,不要轻易关闭。
小结
Serialize/Deserialize把数据模型与格式解耦,加格式不改结构体。- 属性里最影响性能的是
flatten与untagged:热路径尽量避开。 - 格式选型看对端:人类/浏览器用 JSON,同构服务用 bincode,跨语言强契约用 Protobuf;bincode 不适合长期存储。
- 自定义序列化优先
with = "module",输入形态复杂再手写Visitor。 - 零拷贝要靠
from_slice+#[serde(borrow)],from_reader无法借用。 - 输出侧用
to_writer而非to_string,输入侧避免Value中转。
网络服务的请求响应编解码、gRPC 的 protobuf 消息定义都与这套生态直接相关,可参考 Rust 网络编程 ;Web 框架里的提取器(extractor)本质就是 serde 的封装,见 Rust Web 框架 ;CLI 工具的配置文件解析同样依赖它,见 Rust 命令行工具开发 。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。