做后端这几年,我越来越觉得序列化层是事故高发区。比如 Rust 生态里最常用的 Serde,大家习惯性写几个 derive 就以为万事大吉,结果线上被各种脏数据反复折腾:年龄传成字符串、必填字段偷偷缺了、枚举拼错一个大小写……这些问题听起来小,但一旦流入核心链路,数据完整性直接崩塌。这篇文章我想围绕 Serde 的错误处理与验证,聊聊如何用类型安全把这类问题挡在边界之外。适合正在用 Serde 做接口层、解析层,又被 JSON 脏数据折磨过的 Rust 开发者,看完可以直接把思路搬到自己的 DTO 上。
不过我得先说清楚:这里不是讲#[derive(Serialize, Deserialize)]这种基础用法,而是讲当你遇到“错误处理”和“数据校验”这两个需求时,Serde 能帮你做到哪一步、哪些事其实不该交给 Serde 干。类型安全不是玄学,数据完整性也不是一句口号,它们最后都要落到一处处代码选择上。
1. 序列化边界:类型安全与数据完整性为什么是一回事
1.1 一个让我印象深刻的线上事故
先讲个场景。有一次我在某个开放平台项目里,上游系统回调一个支付结果,body 里带amount字段。文档里写的是数字,某天对方灰度改造后,部分请求把amount变成了字符串"12.50"。我们的服务端用的是 Serde 直接反序列化到f64,按道理类型不匹配会报错,可问题是这条链路上套了一层serde_json::Value,先转成通用类型,再手工as_f64()取值。数值串在as_f64()里拿不到,代码又非常“聪明”地在拿不到时用默认值 0.0 兜底。结果就是:十几笔支付回调被当成 0 元处理,账目对不上,最后靠手工对账才捞回来。
这个事故不是 Serde 的问题,是我在序列化层丢掉了类型安全。serde_json::Value这种中间表示很灵活,但它把结构信息抹掉了;等你再从Value里捞数据时,老代码几乎全是 unwrap 和默认值。只要上游一抽风,脏数据就长驱直入。后来我把这块改成直接从 JSON 反序列化到强类型结构体,同样场景直接返回invalid type: string "12.50", expected f64,错误来得又快又清晰。类型安全的价值,往往就在这种“脏数据进不来”的时候体现出来。
1.2 类型安全、数据完整性与验证的分工
很多人把“类型安全”和“数据验证”混在一起说,我建议拆开理解。类型安全解决的是“形状对不对”:字段在不在、类型对不对、枚举值是否存在。数据完整性指的是“最终落库/计算的数值是否符合业务约束”:年龄不能小于 0、邮箱必须带@、交易金额不能是负数。这两者中间夹着一层验证,也就是业务规则的检查。
Serde 最适合负责的是类型安全。它能把 JSON 的结构、字段、基础类型映射成 Rust 的类型系统,凡是形状不对的输入,在边界处直接拒绝。而业务验证呢?你可以放在反序列化之前、之中或之后,各有取舍。我的习惯是:能用类型表达的就用类型表达,比如非空字符串、合法邮箱、受限数值,做成 newtype,让非法状态在编译期就无法表示;剩下依赖多个字段关系的规则(比如start_time < end_time),再放到专门的validate()里做。
这里有个容易犯的认知错误:以为只要#[derive(Deserialize)]成功了,数据就一定合法。不是的,类型安全只保证了“形状合法”,“值合法”还得靠验证。反过来也一样,如果你先反序列化到Value再手工校验,形状合法性也丢了大半,属于两头不靠。正确的姿势是先保住类型安全,再在合适的位置补业务验证。
1.3 为什么验证要尽量靠近边界
验证放得越晚,脏数据扩散得越远。比如从 HTTP body 进来的数据,不验证就进业务层,业务层再传给仓储层,最后库表里躺着一堆非法值。到时候排查成本翻倍,还会污染下游统计。
我见过不少项目在 Service 方法开头写一堆if判断,虽然也起到了验证作用,但每个调用方都可能跳过,而且错误信息和字段名之间没有统一映射。更稳的做法是把验证收敛到 DTO 的构建阶段。这样只要拿到的 DTO 是合法的,后续所有代码都可以默认数据是干净的,不需要到处重复防御。这也是下一节要讲的 Serde 错误处理机制能帮上忙的前提:验证发生在解析时,错误自然携带反序列化上下文,排查起来比业务层返回一个Err("invalid")舒服得多。
2. Serde 错误处理:先把机制摸清楚,再谈自定义
2.1 错误从哪来:Deserialize、反序列化器与 D::Error
要玩转 Serde 的验证,先理解它错误处理的最小链路。每个类型实现Deserialize时都要求一个fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>,这里的D::Error是反序列化器的错误类型。以 JSON 为例,serde_json::Deserializer的Error就是serde_json::Error。因此你在自定义Deserialize实现里抛错,最终抛出的也是反序列化器能理解的那种错误。
常见错误来源有三种。第一种是语法错误,比如 JSON 本身不合法、括号没闭合;第二种是形状错误,比如字段类型不对、缺少必填字段;第三种是你自己通过D::Error::custom(...)塞进去的业务校验错误。前两种由反序列化器天然产生,第三种是你在解析过程中主动报告。
理解这个链路有个实际好处:不要试图在Deserialize里返回你的应用层错误类型。trait 签名里写死了D::Error,所以自定义校验只能“借用”反序列化器错误通道。应用层要做的是捕获serde_json::Error或serde_path_to_error::Error,再统一映射成自己的 API 错误。很多人想一步到位在 DTO 里返回自定义错误结构,结果发现 trait 不答应,反而绕了远路。
2.2 自定义错误消息的正确姿势与边界
自定义校验最常用的就是serde::de::Error::custom。比如我给邮箱类型写反序列化:
use serde::Deserialize; #[derive(Debug, Clone, PartialEq, Eq, serde::Serialize)] pub struct Email(String); impl<'de> Deserialize<'de> for Email { fn deserialize<D>(deserializer: D) -> Result<Self, D::Error> where D: serde::Deserializer<'de>, { let raw = String::deserialize(deserializer)?; let trimmed = raw.trim().to_string(); if trimmed.contains('@') && trimmed.len() <= 320 && !trimmed.starts_with('@') && !trimmed.ends_with('@') { Ok(Email(trimmed)) } else { Err(serde::de::Error::custom("invalid email address")) } } }这段代码展示了三个要点。第一,先让String的Deserialize去处理底层类型,报错会带上类型信息;第二,校验完再做转换,比如 trim 掉首尾空格;第三,失败时用custom生成一条人话错误。注意错误消息最好是不带字段名的纯描述,字段路径留给外层工具去拼,这样组件可以复用,也不会把结构体内部的命名泄漏给调用方。
边界在于:custom只能产生字符串消息,没法携带结构化错误码。如果业务上需要把“字段 A 失败”和“字段 B 失败”区分成不同错误码,建议在外面用serde_path_to_error拿到路径后自行映射,而不是把所有逻辑都塞进校验消息里。另外一个坑是别在custom里拼接巨大对象或敏感信息,错误消息会进日志,控制好打印内容。
2.3 让报错带上字段路径:serde_path_to_error 实测
原生serde_json::Error的缺陷是:虽然带line和column,但不告诉你“哪个字段出了问题”。结构体嵌套三层以后,错误信息根本定位不到根因。这个问题靠serde_path_to_error这个 crate 解决。用法很简单:
use serde_path_to_error::deserialize; let raw = r#"{ "name": "alice", "email": "not-an-email" }"#; let mut de = serde_json::Deserializer::from_str(raw); let result: Result<CreateUserRequest, _> = deserialize(&mut de); if let Err(err) = result { println!("path = {}", err.path()); println!("detail = {}", err.inner()); }err.path()会输出类似email或address.city这样的字段路径,err.inner()是原始错误。如果自定义的Email校验失败,你得到的最终错误就是email: invalid email address。这个能力在排障和生产日志里特别值钱。我在开发内部接口时,基本都会在解析层统一包一层serde_path_to_error,让所有反序列化错误天然带路径,避免日志里出现十几个长得差不多的custom error。
3. 验证应该做在哪一层:从可维护性角度看
3.1 三种常见方案:derive 后校验、Value 中转、边界内校验
先说三种我都用过的方案,再给结论。
方案一,直接#[derive(Deserialize)]一个普通 DTO,解析成功后调用validate()方法。优点是代码直观,规则集中在一个函数里;缺点是你不能保证每个调用方都记得调用validate(),而且validate()返回的错误未必和解析错误格式统一。
方案二,先反序列化到serde_json::Value,再手动取值校验。这个方案我强烈不建议在业务主链路上用。它会丢掉编译期的字段检查,字段名打错只能运行时发现,而且as_u64()、as_f64()这类方法会在类型不符时返回None,极易引发默认值兜底,前面的支付事故就是这么来的。
方案三,利用 newtype 和自定义Deserialize,在反序列化过程中完成校验。这也是我目前的默认方案。它的核心思路是让非法状态“不可表示”:Email只要存在,就必然通过了邮箱校验;Age只要存在,数值就必然落在业务区间里。后续代码不需要重复防御,因为构造出这些类型的唯一途径就自带校验。
如果规则依赖多个字段之间的关系,比如时间区间、金额方向,我会在 DTO 上额外提供一个validate()方法,并把这个方法放在一个类型构造之后必须调用的流程里,比如TryFrom的实现。这样“形状校验”和“跨字段业务校验”分离,职责清楚。
3.2 newtype 模式:把规则编译进类型里
newtype 模式的价值在于把约束变成类型的一部分。用一个非空字符串类型:
#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize)] pub struct NonEmptyString(String); impl<'de> Deserialize<'de> for NonEmptyString { fn deserialize<D>(deserializer: D) -> Result<Self, D::Error> where D: serde::Deserializer<'de>, { let s = String::deserialize(deserializer)?; let t = s.trim().to_string(); if t.is_empty() { Err(serde::de::Error::custom("value must not be empty")) } else { Ok(NonEmptyString(t)) } } }类似的数值约束也不难:
#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize)] pub struct Age(u8); impl<'de> Deserialize<'de> for Age { fn deserialize<D>(deserializer: D) -> Result<Self, D::Error> where D: serde::Deserializer<'de>, { let n = u8::deserialize(deserializer)?; if n < 13 { Err(serde::de::Error::custom("age must be at least 13")) } else { Ok(Age(n)) } } }这里有一个容易被忽略的细节:u8::deserialize本身已经帮你挡掉了负数、小数和超大数。JSON 里传-1会报类型错误,根本轮不到你的if判断。所以 newtype 校验处理的是“值域内但不合法”的部分,比如age < 13。如果你用的是u64、i64等原始类型,Serde 的数字溢出检查也会自动触发,这点比动态语言里的解析要省心得多。
不过 newtype 也有代价:你得多写很多样板代码,而且不同约束的组合需要不同的类型。比如“非空字符串”和“合法邮箱”是两种类型,需要的时候就得各写一份。如果嫌麻烦,可以用serde_with里的serde_as+DisplayFromStr等组合简化一部分场景,再不够就写一个小宏。每写一个 newtype,都是在加一道保险,考虑一下投入产出比。
3.3 脏数据清洗:serde_with 与中间表示的取舍
有些场景不是“拒绝脏数据”,而是“正常化脏数据”。比如上游把数字3序列化成字符串"3",统一协议成本很高时,可以在解析层用serde_with的serde_as自动转换:
use serde_with::serde_as; #[serde_as] #[derive(serde::Deserialize, Debug)] struct RetryConfig { #[serde_as(as = "serde_with::DisplayFromStr")] max_retries: u32, }这样"3"和3都能解析成u32。这种手段适合用在已知且稳定的上游脏格式上,不建议乱用,因为一旦放得太宽,类型安全的“拒绝”功能就被削弱了。还有一个常见需求是把空字符串当作None:
#[derive(serde::Deserialize, Debug)] struct PatchProfile { #[serde(default, deserialize_with = "empty_string_as_none")] nickname: Option<String>, }这类自定义deserialize_with函数网上有很多模板,核心逻辑都是:先反序列化成Option<String>,再手动把空串改成None。我把它们当成“受控的宽容”,只对明确知道要兼容的字段启用,而不是对整个结构体无差别放开。
中间表示serde_json::Value也不是完全没用。在配置热更新、动态扩展字段这类场景里,Value的灵活性反而有价值。关键是你拿到Value后要尽快转换成强类型模型,不要让它离开边界层。换句话说,Value只做临时容器,不能做业务层的数据载体。
4. 实战:一个注册 DTO 从“能跑”改到“扛得住脏数据”
4.1 第一版:快速上线,隐患也快速上线
假设做一个用户注册接口,JSON body 长这样:
{ "name": "alice", "age": 20, "email": "alice@example.com", "tags": ["rust", "backend"] }第一版很多人会写成这样:
#[derive(Debug, serde::Deserialize)] struct CreateUserRequest { name: String, age: u8, email: String, tags: Vec<String>, }解析一眼看过去没问题,但问题立即可见:name可以是""或全空格;age可以是0,哪怕业务要求至少 13 岁;email可以是"not-an-email";tags里可以塞进空字符串。这些值都通过了类型检查,因为它们“形状”对:都是字符串和数字。这就是典型的有类型安全却没有数据完整性。
更麻烦的是,如果你只在 handler 里写一次if校验,第二个 handler 再来一个复制粘贴的if,早晚会漏。我有段时间被这种重复校验搞得头大,后来痛下决心把所有字段级规则收敛到类型里。
4.2 第二版:newtype + 自定义 Deserialize 让非法状态不可表示
第二版把三个核心字段换成带约束的 newtype:
#[derive(Debug, serde::Deserialize)] #[serde(deny_unknown_fields)] struct CreateUserRequest { name: NonEmptyString, age: Age, email: Email, #[serde(default)] tags: Vec<Tag>, }NonEmptyString、Age、Email就是 3.2 节里那种类型。Tag也可以做成非空 short string。这时再解析同样的脏数据,错误信息会直接告诉你:value must not be empty、age must be at least 13、invalid email address。配合serde_path_to_error,谁传的哪个字段出了问题也一目了然。
这里我加粗强调两个属性。第一是#[serde(deny_unknown_fields)]:内部接口建议开启,它能及时发现协议字段笔误,比如把email写成emial时直接报错。第二是#[serde(default)]:tags允许缺省,但一旦传了,里面的每一项仍要经过Tag的校验。千万不要在字段级放松校验,否则又会出现tags: [""]这种情况。
4.3 第三版:统一错误响应、兼容策略与可观测性
第二版解决了“非法状态不可表示”,但错误如何返回给调用方、如何记录日志,还需要统一。我通常会在最外层把反序列化错误映射成应用层错误:
use serde::Deserialize; #[derive(Debug, thiserror::Error)] enum ApiError { #[error("bad request body: {0}")] BadBody(String), } fn parse_request(raw: &str) -> Result<CreateUserRequest, ApiError> { let mut de = serde_json::Deserializer::from_str(raw); serde_path_to_error::deserialize::<_, CreateUserRequest>(&mut de) .map_err(|err| ApiError::BadBody(err.to_string())) }这样 handler 只需要调parse_request,错误消息里已经包含了字段路径和具体失败原因。日志里可以直接打印email: invalid email address at line 1 column 20,定位问题非常快。
兼容策略要单独说。deny_unknown_fields对内部服务是好东西,对外部开放平台却可能变成升级障碍。第三方调用方可能会不知不觉多传字段,你一旦开启,对方老版本 SDK 就会被拒绝。所以外部接口我会先评估协议变更频率,再决定开不开启。另一个常用兼容手段是#[serde(alias = "oldName")],可以在过渡期同时接受新旧字段名,但别名别长期留着,等旧版本下线后要清掉,防止接口语义越来越乱。
5. 测试是数据完整性的最后一道锁
5.1 往返测试:序列化与反序列化必须对称
类型定义改完,不能只靠“看起来对”,要用测试锁住行为。最基础的测试是往返测试:把一个合法对象序列化成 JSON,再反序列化回来,保证字段没丢、值没变。
#[cfg(test)] mod tests { use super::*; #[test] fn round_trip_keeps_all_fields() { let req = CreateUserRequest { name: NonEmptyString("alice".to_string()), age: Age(20), email: Email("alice@example.com".to_string()), tags: vec![Tag("rust".to_string())], }; let json = serde_json::to_string(&req).unwrap(); let back: CreateUserRequest = serde_json::from_str(&json).unwrap(); assert_eq!(req.name.0, back.name.0); assert_eq!(req.age, back.age); assert_eq!(req.email, back.email); assert_eq!(req.tags.len(), back.tags.len()); } }往返测试常见的问题是默认值干扰:tags没传时反序列化成空 vec,但如果序列化时又把它输出成空数组,来回走一趟没问题;可一旦你给字段加了skip_serializing_if = "Vec::is_empty",空 vec 序列化时被丢弃,再反序列化时因为default又变成空 vec,依然没问题。真正怕的是default函数的默认值和skip_serializing_if的判空条件不一致,导致非预期行为,所以往返测试里要特意覆盖“缺省字段”的情况。
5.2 把脏数据变成测试用例:表驱动测试
类型安全最爽的时刻,就是你写下一批非法 JSON,然后盯着测试全部通过。我习惯用表驱动测试,一行一个脏数据,用例既当测试又当文档:
#[test] fn rejects_dirty_inputs() { let cases = vec![ ( r#"{ "name": "", "age": 20, "email": "a@b.com" }"#, "must not be empty", ), ( r#"{ "name": "alice", "age": 7, "email": "a@b.com" }"#, "must be at least 13", ), ( r#"{ "name": "alice", "age": 20, "email": "not-an-email" }"#, "invalid email address", ), ( r#"{ "name": "alice", "age": 20, "email": "a@b.com", "extra": 1 }"#, "unknown field", ), ]; for (input, expected_msg) in cases { let err = serde_json::from_str::<CreateUserRequest>(input).unwrap_err(); assert!( err.to_string().contains(expected_msg), "input = {input}, error = {err}" ); } }这里有个推荐做法:错误断言不要匹配完整句子,只匹配关键字。因为 Serde 的底层错误消息可能随版本微调,匹配关键字稳健得多。另外,每个非法用例都要写明意图,让人一眼看出测的是哪个规则。等字段规则变多,这种表会越来越大,我有时还会把用例生成到外部 JSON 文件里,让非开发同事也能帮忙补充脏数据样本。
5.3 属性测试与模糊测试:对付“想不到的输入”
手工用例覆盖的是你能想到的脏数据,还有大量你没想到的情况,交给属性测试和模糊测试更合适。用proptest写一个简单属性:只要是能成功解析成Email的输入,解析后的值必须满足邮箱不变量。
proptest::proptest! { #[test] fn parsed_email_always_satisfies_invariant(s in ".*") { let json = serde_json::to_string(&s).unwrap(); if let Ok(email) = serde_json::from_str::<Email>(&json) { let raw = email.0; assert!(raw.contains('@')); assert!(raw.len() <= 320); } } }这个测试比你手写一百个非法邮箱覆盖面都广。虽然大部分随机字符串会被Email::deserialize拒绝,但只要有一个漏网的,它解析出来的值必然违反不变量,测试就会炸。对于更极端的输入,也可以上cargo fuzz,把字节流喂给解析函数。字节流往往是脏数据的最终形态,做网关、做存储引擎的同学可以重点考虑。普通业务接口先用表驱动测试 +proptest就够用了,没必要一开始就上 fuzz。
6. 踩坑记录:我在这上面交过的学费
6.1 高频错误速查表
把我在项目里被问得最多的反序列化错误整理成一张表,遇到可以对号入座:
| 报错特征 | 常见原因 | 处理建议 |
|---|---|---|
invalid type: string "12", expected u8 | 上游把数字序列化成了字符串 | 修协议,或用serde_with适配 |
missing field \name`` | 必填字段没传 | 判断业务语义,补字段或给default |
unknown field \emial`` | 开了deny_unknown_fields后字段拼写错误 | 修字段拼写;外部接口考虑兼容 |
unknown variant \Pending`, expected one of ...` | 枚举字符串不匹配 | 检查rename_all和枚举命名 |
data did not match any variant of untagged enum | untagged枚举所有候选都失败 | 改用 tag 式枚举,错误定位更准 |
invalid value: integer \300`, expected ...` | 数值超出目标类型范围 | 用更大整数类型或自定义校验 |
email: invalid email address | 自定义校验失败 | 看路径定位具体字段 |
这张表解决的是“报错能看懂”的问题。只要错误可读,大部分脏数据问题几秒钟就能定位。
6.2 几个容易忽略的配置细节
#[serde(rename_all = "camelCase")]大家都懂,但和alias一起用的时候容易绕晕。我把规则总结成一句话:rename决定序列化和反序列化用哪个名字,alias只影响反序列化,不影响序列化。所以过渡期想让旧的user_name能进来、输出还是userName,就用#[serde(alias = "user_name")],而不是rename。
Option<T>和T的语义差别也常被忽略。Option<String>在 JSON 里传null会得到None,缺字段也会得到None,两者无法区分。如果你的业务需要区分“没传”和“传了 null”,比如 PATCH 接口要分别表达“不更新”和“置空”,那Option<String>不够用,得借助serde_with的双层Option方案,或者自定义Deserialize去检查字段是否存在。不少人在这一点上栽过跟头,我自己的经验是:对外 PATCH 场景一定先想清楚语义,再定类型。
还有一个很隐蔽的问题:#[serde(flatten)]能把内联字段拍平,但错误信息有时会变得很诡异,而且字段冲突时的行为不是直觉上的报错,而是静默覆盖或忽略,取决于实现细节。我在配置解析里用过几次flatten,后来都因为排障成本高而撤了。能不用尽量不用,除非你非常清楚 Serde 对flatten的错误处理约定。
6.3 最后想说的几句心得
回头看我处理过的这些“脏数据”事故,绝大多数不是 Serde 不够强,而是我在边界处的设计太随意。把规则塞进类型里,短期看多写了不少 newtype 和自定义Deserialize,长期看是在给整个系统减负:业务代码不需要到处防御,日志里不需要对着报错猜字段,测试可以靠一列脏数据持续回归。
我个人现在做接口层时有一个固定顺序:先定义字段级约束类型,再写结构体和属性配置,接着补serde_path_to_error包裹的解析入口,最后用表驱动测试和proptest把规则钉死。这套流程不复杂,但它把“类型安全”和“数据完整性”真正落到了代码里。新项目我基本不做第二选择,老项目只要重构到这块,也会按同样的思路一步一步收敛。
如果你现在手头正好有一个被脏数据反复坑过的解析层,我建议先从最痛的字段开始,单独抽成 newtype,配一个非法用例测试。不用一次改完,改一处,测试跟一处,数据完整性就会肉眼可见地好起来。Serde 能做的远比 derive 多,真正拉开差距的,是你在边界处愿意花多少心思。