Serde 与序列化生态

Serde 序列化生态全解:derive 与常用属性(rename/skip/default/flatten/tag)、格式选型(JSON/bincode/MessagePack/CBOR/protobuf)体积与速度对比、serialize_with 自定义与 Visitor、零拷贝借用反序列化(#[serde(borrow)] 与 Cow)、性能优化与常见坑。

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相对体积相对速度可读性典型场景
JSONserde_json1.0×1.0×高API、配置文件
bincodebincode~0.5×3~5×无进程内/同构节点通信
MessagePackrmp-serde~0.6×2~3×低跨语言、日志
CBORciborium~0.6×2×低IoT、CBOR 标准场景
TOMLtoml1.2×0.5×高配置(Cargo.toml 风格)
Protobufprost~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 命令行工具开发 。

继续阅读

探索更多技术文章

浏览归档,发现更多关于系统设计、工具链和工程实践的内容。

全部文章 返回首页

「rust」更多文章

  1. 性能剖析与优化
  2. 测试与基准:criterion 与 proptest
  3. 并发原语与无锁编程