RUST:序列化与反序列化 serde + derive 详解

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 类型没变,也应提升协议版本。

版本迁移应集中在明确路径中:

  1. 外部格式产生 wire 类型;
  2. 迁移函数识别版本并转换历史语义;
  3. 业务校验产生领域类型;
  4. 内部代码只处理当前语义。

这样失败可以准确分成格式错误、版本不支持和业务约束不满足。数据库、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 协议,再通过显式迁移和校验进入领域类型。

参考资料

相关推荐
铁皮饭盒1 小时前
CPU就行, 老照片变清晰, 本地部署582mb腾讯的模型, 本地部署, 过节了给长辈们变个小魔术
前端·javascript·后端
H.莓飛1 小时前
【Linux】自主 Shell
linux·运维·服务器·c语言·开发语言·visual studio
熊猫钓鱼>_>1 小时前
Kotlin Multiplatform for OpenHarmony 实战:kotlinx-coroutines 结构化并发适配
开发语言·kotlin·agent·harmonyos·openharmony·适配·华为云码道
七夜zippoe1 小时前
Spring Boot 接通义千问:从拿 Key 到第一个对话接口跑通(保姆级)
java·spring boot·后端
SimonKing1 小时前
别再花钱调 OCR 接口了!这个国产开源工具,免费还能离线跑
java·后端·程序员
Nebula_g1 小时前
SpringBoot企业级实战项目—云禾外卖—SpringBoot常用注解解析
java·网络·数据库·spring boot·后端·spring·javase
沫璃染墨2 小时前
《Qt从零入门系列(十四):QThread多线程详解——从线程创建到客户端阻塞处理》
开发语言·c++·笔记·qt·ui·交互·线程
hixiong1232 小时前
C# 使用 ZXingCpp.Angle 进行条码、二维码识别
开发语言·c#·扫码
云和数据.ChenGuang2 小时前
AI 工程师相关就业岗位(细分,偏工程落地,由底层到上层)
服务器·开发语言·人工智能·python