RUST:序列化与反序列化 serde + derive 详解
求个关注谢谢
把一段 JSON 读进结构体只需要一行代码。真正影响项目可靠性的,是这一行前后的约定:旧字段怎样兼容,未知字段是否拒绝,反序列化后的值能否直接进入业务层,借用的数据可以保存多久,协议升级时谁负责迁移。
Serde 解决 Rust 数据类型与数据格式之间的通用转换。它不负责打开文件,不限定 JSON 或 TOML,也不判断"优先级 9 是否符合业务规则"。本文使用 Rust 1.94.0、edition 2024、serde 1.0.229、serde_json 1.0.151、serde_test 1.0.177 和 thiserror 2.0.21。
toml
serde = { version = "=1.0.229", features = ["derive"] }
一、Serde 连接数据结构和数据格式
text
Rust 值
└─ Serialize
└─ Serializer(具体格式实现)
└─ 文本或字节
文本或字节
└─ Deserializer(具体格式实现)
└─ Deserialize<'de>
└─ Rust 值
Serialize 由数据类型实现,它通过 Serializer 的结构体、序列、枚举、数字和字符串等接口描述自身。Deserialize<'de> 从 Deserializer 提供的数据构造 Rust 值。JSON、TOML 等 crate 实现格式端;结构体和枚举实现数据端。
#[derive(Serialize, Deserialize)] 是编译期过程宏。它生成 trait 实现,不在运行时反射字段。类型没有实现相应 trait 时,代码会在编译期失败。
完整案例
rust
//! 本例验证 Serde 的数据结构 trait 与具体数据格式彼此分离。
//! 正常路径把同一个结构体序列化为 JSON;测试直接观察格式无关的 token。
use serde::{Deserialize, Serialize};
/// 具名字段结构体会通过 Serializer 的"结构体"接口输出字段名和值。
#[derive(Debug, PartialEq, Serialize, Deserialize)]
struct Task {
id: u64,
title: String,
}
/// newtype 在 Rust 中是独立类型,Serde 数据模型也保留它的类型名称。
#[derive(Debug, PartialEq, Serialize, Deserialize)]
struct TaskId(u64);
/// tuple struct 使用位置而不是字段名表达成员。
#[derive(Debug, PartialEq, Serialize, Deserialize)]
struct Point(i32, i32);
/// unit struct 没有需要携带的字段。
#[derive(Debug, PartialEq, Serialize, Deserialize)]
struct End;
/// `main` 只选择 JSON 作为一种可见格式;derive 出来的 trait 实现没有绑定 JSON。
fn main() -> Result<(), serde_json::Error> {
let task = Task {
id: 7,
title: "整理测试".to_owned(),
};
// `to_string` 借用 task,所以序列化后仍可继续使用原值。
let json = serde_json::to_string(&task)?;
println!("JSON: {json}");
// `from_str` 返回新的 Task;其中 String 拥有解析所得文本。
let decoded: Task = serde_json::from_str(&json)?;
println!("恢复: id={}, title={}", decoded.id, decoded.title);
println!(
"其他形状: newtype={}, tuple={}, unit={}",
serde_json::to_string(&TaskId(9))?,
serde_json::to_string(&Point(2, 3))?,
serde_json::to_string(&End)?
);
Ok(())
}
Task 传给 to_string 时只被借用,原值仍可使用。from_str 创建新的 Task,其中的 String 拥有解析结果。运行输出是:
text
JSON: {"id":7,"title":"整理测试"}
恢复: id=7, title=整理测试
其他形状: newtype=9, tuple=[2,3], unit=null
最后一行只是 JSON 对这些 Rust 形状的表示。serde_test 能直接检查 Serde token,适合验证不绑定某一种文本格式的数据模型。
二、结构体属性就是协议规则
rename 改一个外部名称,rename_all 批量转换名称。alias 只放宽输入,重新输出仍使用当前名称,因此适合"读旧写新"。
default 只处理字段缺失。字段明确为 null 时,Deserializer 仍会把 null 转成目标类型;普通 String 不能接收。字段存在但类型错误时,default 也不会掩盖错误。
skip_serializing_if 根据值决定是否输出。skip_serializing 和 skip_deserializing 只影响一个方向。秘密字段即使跳过序列化,也仍可能被 Debug 或日志输出,必须分别审查。
deny_unknown_fields 能尽早发现拼错字段,适合工具参数和启动配置。允许第三方扩展的事件协议则可能需要扩展区。严格和宽松都必须是明确的协议选择。
rust
//! 本例集中验证常用结构体属性。
//! 正常路径读取旧字段名并补默认值;失败注入分别传入未知字段、null 和错误类型。
use serde::{Deserialize, Serialize};
fn default_priority() -> u8 {
3
}
fn is_default_priority(value: &u8) -> bool {
*value == default_priority()
}
/// 输入严格拒绝拼错的字段;旧字段名 `task_name` 只作为反序列化别名。
#[derive(Debug, PartialEq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase", deny_unknown_fields)]
struct TaskDto {
id: u64,
#[serde(alias = "task_name")]
name: String,
#[serde(
default = "default_priority",
skip_serializing_if = "is_default_priority"
)]
priority: u8,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
labels: Vec<String>,
#[serde(skip_serializing, default)]
access_token: String,
}
/// transparent 让包装类型在线格式中直接表现为内部字符串。
#[derive(Debug, PartialEq, Serialize, Deserialize)]
#[serde(transparent)]
struct RequestId(String);
fn main() -> Result<(), serde_json::Error> {
let old_input = r#"{"id":7,"task_name":"build","accessToken":"secret"}"#;
let task: TaskDto = serde_json::from_str(old_input)?;
println!("读取: name={}, priority={}", task.name, task.priority);
let output = serde_json::to_string(&task)?;
println!("输出: {output}");
println!(
"transparent: {}",
serde_json::to_string(&RequestId("req-9".into()))?
);
for input in [
r#"{"id":7,"name":"build","typo":1}"#,
r#"{"id":7,"name":null}"#,
r#"{"id":7,"name":"build","priority":"high"}"#,
] {
let error = serde_json::from_str::<TaskDto>(input).expect_err("故意失败输入应被拒绝");
println!("拒绝: {error}");
}
Ok(())
}
本机运行会分别报告未知字段、null 和类型错误。默认优先级不会输出,旧名称 task_name 只在输入端有效:
text
读取: name=build, priority=3
输出: {"id":7,"name":"build"}
transparent: "req-9"
拒绝: unknown field typo
拒绝: invalid type: null, expected a string
拒绝: invalid type: string "high", expected u8
transparent 让单字段包装类型在线格式中直接表现为内部值。Rust 侧仍有独立的 RequestId 类型,外部无需增加一层对象。
三、枚举的四种表示
默认的 externally tagged 把 variant 名放在最外层,适用范围最广。internally tagged 用 tag 把标签放进对象,便于人读,但不能容纳 tuple variant。adjacently tagged 用独立的 tag 与 content 字段,能稳定表达更多 variant 形状。
untagged 没有判别字段。Serde 按声明顺序尝试,第一种成功的类型胜出。形状重叠时,调整顺序可能改变含义;variant 多时还会重复解析并给出较弱错误。它适合少量历史兼容输入,不适合持续扩大的命令协议。
rust
//! 本例用同一个业务含义比较四种枚举表示。
//! 故意失败路径展示内部标签的未知变体;测试说明 untagged 按声明顺序尝试。
use serde::{Deserialize, Serialize};
#[derive(Debug, PartialEq, Serialize, Deserialize)]
enum External {
Start { id: u64 },
Stop,
}
#[derive(Debug, PartialEq, Serialize, Deserialize)]
#[serde(tag = "kind", rename_all = "snake_case")]
enum Internal {
Start {
id: u64,
},
Stop,
#[serde(other)]
Unknown,
}
#[derive(Debug, PartialEq, Serialize, Deserialize)]
#[serde(tag = "kind", content = "data", rename_all = "snake_case")]
enum Adjacent {
Start { id: u64 },
Message(String),
}
#[derive(Debug, PartialEq, Serialize, Deserialize)]
#[serde(untagged)]
enum LooseNumber {
Integer(u64),
Text(String),
}
fn main() -> Result<(), serde_json::Error> {
println!(
"external: {}",
serde_json::to_string(&External::Start { id: 7 })?
);
println!(
"internal: {}",
serde_json::to_string(&Internal::Start { id: 7 })?
);
println!(
"adjacent: {}",
serde_json::to_string(&Adjacent::Message("ready".into()))?
);
println!(
"untagged: {:?}",
serde_json::from_str::<LooseNumber>(r#""42""#)?
);
let unknown: Internal = serde_json::from_str(r#"{"kind":"pause"}"#)?;
println!("未知内部标签: {unknown:?}");
Ok(())
}
#[serde(other)] 可接住未知标签,但只适用于 internally tagged 或 adjacently tagged 的 unit variant。事件消费者可以选择忽略未知事件;命令执行入口若不知道含义,应拒绝执行。
text
external: {"Start":{"id":7}}
internal: {"kind":"start","id":7}
adjacent: {"kind":"message","data":"ready"}
untagged: Text("42")
未知内部标签: Unknown
四、借用、生命周期与 DeserializeOwned
Deserialize<'de> 中的生命周期表示结果最多可以从输入借用多久。输入是仍然存活的字符串或字节切片时,某些字段可以直接引用输入,减少分配;返回值不能超过输入寿命。
DeserializeOwned 要求结果不借用反序列化器的数据。文件 reader、网络流或会复用内部缓冲的接口通常需要它,因为函数返回后临时缓冲可能销毁。
即使从字符串解析,也不是所有内容都能借用。带转义的 JSON 字符串必须先解码,Cow<'de, str> 会变成 Owned。借用是一种条件满足时的优化,不是业务正确性保证。
rust
//! 本例验证反序列化结果何时能借用输入,何时必须拥有数据。
//! `BorrowedTask` 不能活得比 JSON 字符串更久;reader 入口返回 `OwnedTask`。
use std::{borrow::Cow, io::Cursor};
use serde::{Deserialize, Serialize, de::DeserializeOwned};
#[derive(Debug, Deserialize)]
struct BorrowedTask<'a> {
id: u64,
#[serde(borrow)]
title: Cow<'a, str>,
}
#[derive(Debug, Deserialize, Serialize)]
struct OwnedTask {
id: u64,
title: String,
}
/// 该泛型函数可从任意借用期的字符串读取 T;T 可以借用 `input`。
fn parse_borrowed<'de, T>(input: &'de str) -> Result<T, serde_json::Error>
where
T: Deserialize<'de>,
{
serde_json::from_str(input)
}
/// reader 内部缓冲不会交给返回值长期保存,因此结果要求 `DeserializeOwned`。
fn parse_reader<T>(bytes: &[u8]) -> Result<T, serde_json::Error>
where
T: DeserializeOwned,
{
serde_json::from_reader(Cursor::new(bytes))
}
fn main() -> Result<(), serde_json::Error> {
let input = r#"{"id":7,"title":"borrowed text"}"#;
let borrowed: BorrowedTask<'_> = parse_borrowed(input)?;
println!(
"借用解析: id={}, borrowed={}",
borrowed.id,
matches!(borrowed.title, Cow::Borrowed(_))
);
let owned: OwnedTask = parse_reader(input.as_bytes())?;
println!("reader 解析: id={}, title={}", owned.id, owned.title);
Ok(())
}
T: Deserialize<'de> 允许 T 借用调用者输入;T: DeserializeOwned 适合返回独立值。Deserialize<'static> 通常是错误约束,它不是"让结果活得更久",而是要求借用永久输入。
text
借用解析: id=7, borrowed=true
reader 解析: id=7, title=borrowed text
derive 会自动推导泛型 bound。只有自动结果过严或不正确时才使用 #[serde(bound = "...")],否则手写 bound 只会增加维护负担。
五、flatten 与显式迁移
flatten 可以把公共字段提升到外层,也可以把未知字段收进 Map。后一种方式适合明确允许扩展的元数据。它不能与 deny_unknown_fields 组合:一个要求接收未知字段,另一个要求拒绝,两条规则相冲突。
alias 和 default 解决表示兼容;字段含义、单位或支持版本变化必须进入显式迁移函数。反序列化成功只证明输入形状可读,不证明业务值可执行。
rust
//! 本例演示 flatten 的两个常见用途,以及旧版线格式到当前领域类型的显式迁移。
//! 扩展字段会被收集;不支持的协议版本和非法名称是故意失败路径。
use std::collections::BTreeMap;
use serde::{Deserialize, Serialize};
use serde_json::Value;
use thiserror::Error;
#[derive(Debug, PartialEq, Serialize, Deserialize)]
struct CommonFields {
request_id: String,
}
#[derive(Debug, PartialEq, Serialize, Deserialize)]
struct ExtensibleRequest {
#[serde(flatten)]
common: CommonFields,
action: String,
#[serde(flatten)]
extensions: BTreeMap<String, Value>,
}
fn legacy_version() -> u16 {
1
}
#[derive(Debug, Deserialize)]
struct WireTask {
#[serde(default = "legacy_version")]
version: u16,
#[serde(alias = "task_name")]
name: String,
}
#[derive(Debug, PartialEq)]
struct Task {
name: String,
migrated_from: u16,
}
#[derive(Debug, Error, PartialEq)]
enum MigrationError {
#[error("不支持协议版本 {0}")]
UnsupportedVersion(u16),
#[error("任务名称去除首尾空白后不能为空")]
EmptyName,
}
fn migrate(wire: WireTask) -> Result<Task, MigrationError> {
if !matches!(wire.version, 1 | 2) {
return Err(MigrationError::UnsupportedVersion(wire.version));
}
let name = wire.name.trim().to_owned();
if name.is_empty() {
return Err(MigrationError::EmptyName);
}
Ok(Task {
name,
migrated_from: wire.version,
})
}
fn main() -> Result<(), Box<dyn std::error::Error>> {
let request: ExtensibleRequest = serde_json::from_str(
r#"{"request_id":"r-1","action":"run","trace_id":"t-9","dry_run":true}"#,
)?;
println!(
"扩展字段: {:?}",
request.extensions.keys().collect::<Vec<_>>()
);
let old: WireTask = serde_json::from_str(r#"{"task_name":" build "}"#)?;
println!("迁移结果: {:?}", migrate(old)?);
let future: WireTask = serde_json::from_str(r#"{"version":9,"name":"build"}"#)?;
println!("拒绝未来版本: {}", migrate(future).unwrap_err());
Ok(())
}
WireTask 是输入模型,Task 是领域模型。migrate 按值接收 wire,取得字符串所有权;成功时把整理后的值移入新对象,失败时不生成领域任务。
text
扩展字段: ["dry_run", "trace_id"]
迁移结果: Task { name: "build", migrated_from: 1 }
拒绝未来版本: 不支持协议版本 9
六、自定义字段适配器和 Visitor
普通字段优先使用 derive。线格式和领域类型确实不一致时,再用 with、serialize_with 或 deserialize_with。适配器应先读一个明确的中间类型,再做格式与范围检查,错误通过 de::Error::custom 返回。
rust
//! 本例用字段适配器为 TaskId 定义稳定线格式,并用 Visitor 兼容两种旧输入。
//! 错误前缀、零 ID 和非数字文本都是故意失败路径。
use std::fmt;
use serde::{Deserialize, Deserializer, Serialize, Serializer, de};
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
struct TaskId(u64);
mod task_id_format {
use super::*;
pub fn serialize<S>(value: &TaskId, serializer: S) -> Result<S::Ok, S::Error>
where
S: Serializer,
{
serializer.serialize_str(&format!("task-{}", value.0))
}
pub fn deserialize<'de, D>(deserializer: D) -> Result<TaskId, D::Error>
where
D: Deserializer<'de>,
{
let text = String::deserialize(deserializer)?;
let digits = text
.strip_prefix("task-")
.ok_or_else(|| de::Error::custom("任务 ID 必须以 task- 开头"))?;
let value = digits
.parse::<u64>()
.map_err(|_| de::Error::custom("task- 后面必须是无符号整数"))?;
if value == 0 {
return Err(de::Error::custom("任务 ID 必须大于 0"));
}
Ok(TaskId(value))
}
}
#[derive(Debug, PartialEq, Serialize, Deserialize)]
struct TaskRef {
#[serde(with = "task_id_format")]
id: TaskId,
}
#[derive(Debug, PartialEq)]
struct StringOrU64(u64);
struct StringOrU64Visitor;
impl<'de> de::Visitor<'de> for StringOrU64Visitor {
type Value = StringOrU64;
fn expecting(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
formatter.write_str("无符号整数或只包含数字的字符串")
}
fn visit_u64<E>(self, value: u64) -> Result<Self::Value, E> {
Ok(StringOrU64(value))
}
fn visit_str<E>(self, value: &str) -> Result<Self::Value, E>
where
E: de::Error,
{
value.parse::<u64>().map(StringOrU64).map_err(E::custom)
}
}
impl<'de> Deserialize<'de> for StringOrU64 {
fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
where
D: Deserializer<'de>,
{
deserializer.deserialize_any(StringOrU64Visitor)
}
}
fn main() -> Result<(), serde_json::Error> {
let json = serde_json::to_string(&TaskRef { id: TaskId(42) })?;
println!("编码: {json}");
println!("解码: {:?}", serde_json::from_str::<TaskRef>(&json)?);
for input in [r#"{"id":"job-42"}"#, r#"{"id":"task-0"}"#] {
println!(
"拒绝: {}",
serde_json::from_str::<TaskRef>(input).unwrap_err()
);
}
println!(
"兼容旧数字字符串: {:?}",
serde_json::from_str::<StringOrU64>(r#""17""#)?
);
Ok(())
}
TaskId(42) 的 Rust 表示与 task-42 的线格式分开。手写 Visitor 则明确列出能接收的输入种类。示例使用 deserialize_any,这要求格式能够先识别值的种类;某些非自描述二进制格式并不支持。因此 Visitor 是边界工具,不是 derive 的常规替代品。
text
编码: {"id":"task-42"}
解码: TaskRef { id: TaskId(42) }
拒绝: 任务 ID 必须以 task- 开头
拒绝: 任务 ID 必须大于 0
兼容旧数字字符串: StringOrU64(17)
remote derive 可为不能直接修改的外部类型声明镜像定义,但要持续维护字段对应。简单 newtype 或字段适配器通常更容易看懂。
七、完整案例:版本化任务请求
可靠的输入边界最好分两步:
text
外部格式
↓
TaskRequestWire
- 接受历史别名
- 补表示层默认值
- 拒绝未知字段
↓ normalize_wire
TaskRequest
- 版本已支持
- ID、名称和数值已校验
rust
//! 版本化任务请求协议。
//! `TaskRequestWire` 只表示可接受的线格式;`normalize_wire` 迁移版本并产生已校验的领域值。
use serde::{Deserialize, Serialize};
use thiserror::Error;
fn legacy_version() -> u16 {
1
}
fn default_priority() -> Priority {
Priority(3)
}
/// 优先级在线上是数字。私有字段阻止业务代码随意构造,但反序列化仍可能得到越界值,
/// 因而必须在 `normalize_wire` 中校验。
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(transparent)]
pub struct Priority(u8);
impl Priority {
pub fn get(self) -> u8 {
self.0
}
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct CreateTask {
#[serde(default, skip_serializing_if = "Option::is_none")]
pub details: Option<String>,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(tag = "kind", content = "data", rename_all = "snake_case")]
pub enum TaskAction {
Create(CreateTask),
Cancel { task_id: String },
Status { task_id: String },
}
/// 线格式保留兼容属性。成功反序列化后还不能直接执行业务操作。
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct TaskRequestWire {
#[serde(default = "legacy_version")]
pub version: u16,
pub request_id: String,
#[serde(alias = "task_name")]
pub name: String,
#[serde(default = "default_priority")]
pub priority: Priority,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub labels: Vec<String>,
pub action: TaskAction,
}
/// 领域类型中的字符串已经去除无意义空白,版本也已迁移到当前语义。
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct TaskRequest {
pub request_id: String,
pub name: String,
pub priority: u8,
pub labels: Vec<String>,
pub action: TaskAction,
}
#[derive(Debug, Error, PartialEq, Eq)]
pub enum ContractError {
#[error("不支持协议版本 {version}")]
UnsupportedVersion { version: u16 },
#[error("request_id 必须以 req- 开头,后面至少有一个字符")]
InvalidRequestId,
#[error("任务名称去除首尾空白后必须为 1 到 120 个字符")]
InvalidName,
#[error("优先级必须在 1 到 5 之间")]
InvalidPriority,
#[error("任务 ID 必须采用 task-<正整数> 格式")]
InvalidTaskId,
}
/// 把可兼容读取的 wire 值转成当前领域值。
///
/// 参数按值传入,因此函数取得其中 String 和 Vec 的所有权,无需复制整个请求。
/// 任何校验失败都会返回结构化 `ContractError`,调用者不得执行 action。
pub fn normalize_wire(wire: TaskRequestWire) -> Result<TaskRequest, ContractError> {
if !matches!(wire.version, 1 | 2) {
return Err(ContractError::UnsupportedVersion {
version: wire.version,
});
}
if !valid_prefixed_number(&wire.request_id, "req-") {
return Err(ContractError::InvalidRequestId);
}
let name = wire.name.trim().to_owned();
if name.is_empty() || name.chars().count() > 120 {
return Err(ContractError::InvalidName);
}
if !(1..=5).contains(&wire.priority.0) {
return Err(ContractError::InvalidPriority);
}
if let TaskAction::Cancel { task_id } | TaskAction::Status { task_id } = &wire.action
&& !valid_prefixed_number(task_id, "task-")
{
return Err(ContractError::InvalidTaskId);
}
// 版本 1 的标签历史上不区分大小写;迁移时统一为小写。
let mut labels = wire.labels;
if wire.version == 1 {
for label in &mut labels {
*label = label.trim().to_lowercase();
}
} else {
for label in &mut labels {
*label = label.trim().to_owned();
}
}
labels.retain(|label| !label.is_empty());
Ok(TaskRequest {
request_id: wire.request_id,
name,
priority: wire.priority.0,
labels,
action: wire.action,
})
}
fn valid_prefixed_number(value: &str, prefix: &str) -> bool {
value
.strip_prefix(prefix)
.is_some_and(|digits| !digits.is_empty() && digits.parse::<u64>().is_ok())
}
演示入口:
rust
//! 演示版本化任务协议的正常路径和业务失败路径。
use versioned_task_contract::{TaskRequestWire, normalize_wire};
fn main() -> Result<(), serde_json::Error> {
let legacy = r#"
{
"request_id": "req-7",
"task_name": " build release ",
"labels": [" CI ", "Rust"],
"action": { "kind": "create", "data": { "details": "nightly" } }
}
"#;
let wire: TaskRequestWire = serde_json::from_str(legacy)?;
println!(
"正常: {:#?}",
normalize_wire(wire).expect("示例输入应通过业务校验")
);
let future = r#"
{
"version": 9,
"request_id": "req-8",
"name": "build",
"action": { "kind": "status", "data": { "task_id": "task-3" } }
}
"#;
let wire: TaskRequestWire = serde_json::from_str(future)?;
println!("拒绝: {}", normalize_wire(wire).unwrap_err());
Ok(())
}
版本 1 的标签迁移为小写,版本 2 保留大小写但去除空白。版本分支是显式的,不通过字段是否存在猜测历史版本。Handler、任务执行器和存储层只接收 TaskRequest,不用再次猜测输入是否已检查。
text
正常: TaskRequest {
request_id: "req-7",
name: "build release",
priority: 3,
labels: [
"ci",
"rust",
],
action: Create(
CreateTask {
details: Some(
"nightly",
),
},
),
}
拒绝: 不支持协议版本 9
+## 八、执行过程、所有权与兼容性再深入一步
1. Serde 不要求先建立通用中间树
直接调用 serde_json::to_string(&task) 时,derive 生成的 Serialize::serialize 会调用 JSON Serializer 的结构体接口:声明字段数,逐个交付字段名和值,然后结束结构体。它不必先把 Task 变成 serde_json::Value。
反序列化同理。Deserializer 读取 token,derive 生成的 Visitor 根据字段名填充结构体,检查重复字段、缺失字段和类型错误。直接解析强类型通常比"先 Value、再 from_value"少一层中间表示,错误也更接近输入边界。
数据类型与格式虽然解耦,组合能力仍受格式约束。例如 Serde 的 map 键可以是多种类型,JSON object 的键却只能是字符串。实现了 Serialize 只表示类型能向通用 Serializer 描述自己,不保证任意具体值都能被每一种格式表达。
2. 序列化与反序列化的所有权方向不同
序列化通常借用 &self。Serializer 读取字段,不取得业务值所有权,同一个值可以先写文件,再写网络或交给测试 Serializer。反序列化必须创建新值:String、Vec 等字段拥有解析结果,借用字段则把生命周期绑定到输入。
这也是输入 DTO 经常按值传给迁移函数的原因。解析阶段创建 wire 值,迁移阶段取得它的所有权,把能复用的 String 和 Vec 直接移动到领域值。输出 DTO 则可以借用领域对象,避免为了序列化再复制一份业务数据。
3. 输入属性和输出属性要分别审查
字段可能只用于输入,也可能只用于输出。skip_serializing 让字段不出现在输出,但反序列化时仍然需要它,除非另有 default。skip_deserializing 不接收外部值,因此必须有办法构造字段。skip 同时影响两个方向,只适合真正的内部状态。
alias 通常比"输入和输出使用两套批量命名规则"更适合迁移。外部仍能发送旧名称,服务端只输出当前名称,协议不会继续制造新的旧格式。
默认值也要区分 Rust 默认和协议默认。Vec 的空值经常合理,usize 的 0 对 workers 却可能表示程序永远不工作。业务默认应使用有名称的函数,使代码审查者能看到来源。
4. 缺失、null 与 Option 不是同一件事
Option<T> 不会把所有错误变成 None。字段缺失可以得到 None;JSON 明确提供 null 也可以得到 None;字段存在但内部类型错误仍然是 Err。
PATCH 接口有时必须区分三种状态:字段没出现时保留旧值,字段为 null 时清空,字段有值时替换。单层 Option 不足以表达三态,可以使用 Option<Option<T>> 或专门枚举。TOML 没有 null,同一个 patch 类型跨 JSON 与 TOML 使用时必须重新检查语义。
5. 借用为什么经常止步于边界层
HTTP handler 可以从请求体缓冲借用字符串;值一旦进入 Tokio 任务、channel、缓存或长期存储,输入缓冲通常不再活得足够久,最终仍需 String。是否借用应取决于数据存活范围:
- 临时检查大输入并立即返回:借用可能节省分配;
- 任务进入队列,晚于请求结束执行:使用拥有所有权的数据;
- 公共库同步分析调用者字符串:可以返回带生命周期的视图;
- 领域对象要持久化或缓存:不要依赖原始输入继续存活。
借用类型还会延长整块输入的生命。即使只保存一个很短的 &str,原始大缓冲也不能释放。内存收益要观察整个生命周期,而不是只看解析时有没有复制。
6. 兼容性变化要分类处理
新增带默认值的字段通常能读取旧输入,但旧消费者是否能读取新输出,取决于它是否拒绝未知字段。字段改名可在迁移期保留 alias;删除字段前要确认旧生产者是否仍会发送。字段含义变化最危险,即使 Rust 类型没变,也应提升协议版本。
版本迁移应集中在明确路径中:
- 外部格式产生 wire 类型;
- 迁移函数识别版本并转换历史语义;
- 业务校验产生领域类型;
- 内部代码只处理当前语义。
这样失败可以准确分成格式错误、版本不支持和业务约束不满足。数据库、handler 和任务执行器不需要各自保存一份兼容逻辑。
7. 自定义适配器不应访问业务资源
适配器适合固定前缀、时间文本和数字编码等线格式约束。依赖数据库、当前用户或另一个字段的规则不应塞进 deserialize_with:Deserializer 没有这些上下文,错误也难以分类。
跨字段约束如 start_at < end_at 应在 wire 构造后检查。序列化适配器还应与反序列化器成对测试:合法值能 round-trip,非法前缀、边界数字和错误类型分别失败。
8. 私有字段不等于反序列化后一定合法
最终案例的 Priority 内部字段是私有的,普通业务代码不能随意构造 Priority(99)。derive 生成的 Deserialize 仍能从线格式构造内部 u8,所以 normalize 必须检查 1 到 5。
如果范围永远与类型绑定,可以为 Priority 手写 Deserialize,使非法值根本无法存在。如果不同协议版本允许不同范围,让 wire 暂时保存原值、再按版本校验更灵活。两种做法的关键都是不要在类型已经进入业务层后继续假设。
9. Err 会清理内存,不会撤销外部副作用
normalize_wire 按值取得 wire。中途返回 Err 时,尚未移动的字段和临时 Vec、String 会按 RAII 释放,不会向调用者交付半个领域对象。调用者可以放心用 ? 传播错误。
但如果校验函数已经写文件、发请求或提交数据库,返回 Err 不会回滚这些动作。规范化与业务校验最好保持纯函数,副作用放在完整领域值形成之后。
九、测试应该证明什么
不同层次需要不同证据:
- 用
serde_test验证类型调用的通用 Serde 数据模型; - 用具体格式测试字段名、枚举标签、null 和数字能力;
- 用失败测试验证未知字段、错误类型、越界值与未来版本;
- 用 round-trip 证明当前实现能读取自己的输出;
- 直接匹配领域错误变体,不依赖完整英文解析消息;
- 单独检查秘密不会出现在 Serialize、Debug 和日志输出。
Round-trip 不能证明旧消费者能读取新输出,也不能证明另一种语言使用相同数字范围。跨版本协议仍需固定 fixture 和契约测试。
+## 十、常见错误
把 Deserialize 当成业务校验
u8 只保证 0 到 255,不能保证业务范围 1 到 5。PathBuf 只保存路径数据,不能证明路径存在、是目录或允许访问。表示层和业务层要分别验证。
所有输入都静默忽略未知字段
拼错重试字段后回退默认值,可能让程序以错误策略运行。严格输入应该尽早失败;需要扩展时应提供明确扩展字段。
用 untagged 承担复杂版本演进
variant 增多后会出现顺序依赖和含糊错误。稳定命令协议更适合显式 tag 与版本号。
为了少一次分配传播复杂生命周期
边界层借用确实有价值,但进入队列、缓存和异步任务前转成拥有所有权的数据通常更稳妥。不要把生命周期复杂度传播到整个业务模型。
先序列化秘密再做文本替换
秘密应从 DTO、Serialize、Debug 和日志字段多个入口排除。事后字符串替换无法可靠覆盖嵌套结构。
十一、Serde 与具体格式的边界
Serde 提供 trait、derive、属性和通用数据模型。serde_json 负责 JSON 文本、动态 Value、流式读取和 JSON 错误;toml 负责 TOML 表、日期时间、配置语法和 span。格式能力并不完全相同:TOML 没有 null,JSON 没有原生日期时间类型。
derive 减少机械代码,不替开发者决定兼容策略、业务约束和安全边界。稳定做法是先定义 wire 协议,再通过显式迁移和校验进入领域类型。