YAOTU INSIGHTS

Rust 参数校验实践:用 validator 告别手写 if,优雅实现声明式校验

Rust 参数校验实践:用 validator 告别手写 if,优雅实现声明式校验
做 Rust 后端的时间久了我对参数校验这件事越来越敏感。头几个项目我是纯手写 if 判断字段一多整个函数就变得又臭又长后来换上 validator 库才把这块的维护成本降下来。这篇就聊聊我实际用 validator 的经验包括它解决什么问题、日常怎么用、和 Web 框架怎么配合以及我踩过的几个坑。无论你是刚入门 Rust 写点小接口还是已经在生产环境维护服务这份内容应该都能给你一些实在的参考。1. 为什么最终选 validator 而不是手写校验先说我自己的经历。之前做一个用户中心的注册接口要校验用户名、邮箱、密码、手机号、邀请码还有一堆可选字段。一开始我图省事直接在 handler 里堆 ifasync fn register(payload: CreateUserRequest) - Result(), ApiError { if payload.username.len() 3 || payload.username.len() 20 { return Err(ApiError::bad_request(用户名长度必须在3到20之间)); } if payload.email.is_empty() || !payload.email.contains() { return Err(ApiError::bad_request(邮箱格式不正确)); } if payload.password.len() 8 { return Err(ApiError::bad_request(密码至少8位)); } // 手机号、邀请码、年龄、性别……越写越多 Ok(()) }1.1 手写校验为什么撑不过第二个接口上面的代码看起来还行但那是只有一个接口的时候。第二个接口要校验用户资料更新第三个接口要校验订单提交每个都来这么一堆 if问题马上就来了。第一是可读性差。业务逻辑和校验逻辑混在一起后来review代码的人根本分不清哪些分支是参数错误、哪些是真实业务异常。第二是规则没法复用。用户名长度校验在注册接口写一遍在修改资料接口又写一遍哪天产品经理说用户名最长30个字符你得把所有接口都翻出来改一遍漏改一个就是线上事故。第三是错误处理非常随意。手写 if 的时候每个开发者返回的错误码、错误消息风格都不同前端对接起来能疯掉。所以我的结论是参数校验这种东西就应该和业务代码彻底分离用声明式的方式写在数据结构上而不是每次用命令式逻辑重复实现一遍。1.2 同类工具一瞥validator 生态定位与选型对比Rust 生态里做校验的库不算多但也不能说没有。我当时认真对比了几个方向手写校验最灵活但最累、validator 库derive 宏 字段属性声明式、garde 这类后起之秀、以及完全自己实现TryFrom做校验。简单列个对比方案声明式程度嵌套支持自定义规则生态成熟度学习成本手写 if 判断低很差很灵活无低validator高好好成熟actix/axum 生态常见低garde高好好上升期但文档和坑相对少低手动实现 TryFrom中中很灵活无高我选 validator 的核心原因有三个。第一它基于Validatetrait derive(Validate)属性直接写在结构体字段旁边数据长什么样、规则是什么样一目了然。第二它的嵌套校验和自定义函数支持得不错能覆盖大部分业务场景。第三它在 actix-web、axum 这些 Web 框架的例子里出现频率非常高遇到问题搜一下基本都能找到答案社区踩坑记录足够多。garde 我也简单试过设计上更激进一些但团队项目里求稳validator 更适合。2. derive 宏的日常用法与字段级校验规则聊完选型直接上干货。validator 最核心的用法就是给结构体加#[derive(Validate)]然后为每个字段挂上不同的校验属性。2.1 注册接口的字段规则可以直接抄走下面这个结构体基本覆盖了我日常最常用的几种规则use validator::{Validate, ValidationError}; #[derive(Debug, Validate)] struct CreateUserRequest { #[validate(length(min 3, max 20, message 用户名长度必须在3到20个字符之间))] username: String, #[validate(email(message 邮箱格式不正确))] email: String, #[validate(length(min 8, max 64, message 密码长度必须在8到64个字符之间))] password: String, #[validate(regex(path PHONE_RE, message 手机号格式不正确))] phone: String, #[validate(custom(function validate_invite_code))] invite_code: OptionString, } lazy_static! { static ref PHONE_RE: regex::Regex regex::Regex::new(r^1\d{10}$).unwrap(); } fn validate_invite_code(code: str) - Result(), ValidationError { if code ABCD1234 || code WELCOME2024 { return Ok(()); } let mut err ValidationError::new(invalid_invite_code); err.message Some(邀请码不存在或已过期.into()); Err(err) }然后在接口里直接调用let req CreateUserRequest { /* ... */ }; match req.validate() { Ok(()) { /* 继续业务逻辑 */ } Err(errors) { /* 统一处理错误 */ } }这里有几个细节我要单独说明。length(min 3, max 20)对 String 和集合类型都适用限制的是字符数而不是字节数。对中文用户名String::len()返回的是字节长度但 validator 的 length 规则用的是.chars().count()这点实测下来对中文更友好。regex(path PHONE_RE)里的path指向一个lazy_static或once_cell的全局正则。为什么不直接写成字符串因为regex::Regex的编译是有开销的如果每次校验都现场编译高并发下性能会很难看。全局复用是正确做法。OptionString字段在值为None时derive 宏生成的代码会直接跳过校验。也就是说invite_code不传就通过传了才检查。这个行为非常重要后面讲坑的时候还要展开。自定义校验函数的签名是fn(str) - Result(), ValidationError但如果字段本身是OptionString签名的参数类型会变成OptionString。我当时写第一个自定义函数就栽在这上面编译器报了半天错才反应过来。2.2 容易用错的属性细节与报错消息定制validator 内置了不少字段级规则像email、url、ip、phone、credit_card这些都有。但有几个属性容易踩坑。第一是range和length的区别。range(min 18, max 60)用于数值类型或实现了PartialOrd的类型比如整数、浮点、日期时间length用于集合和字符串。我当时把range(min 3, max 20)用在 String 字段上编译过了但运行行为完全不是预期折腾了一会儿才意识到应该用length。第二是must_match属性它用来校验两个字段是否相等比如注册接口的密码和确认密码。很多新手不知道有这个还会自己写自定义函数。#[derive(Validate)] struct RegisterRequest { #[validate(length(min 8))] password: String, #[validate(must_match(other password, message 两次输入的密码不一致))] confirm_password: String, }第三是错误消息定制。默认情况下校验失败返回的ValidationError里 code 是规则名比如length、emailmessage 是英文。我建议每个字段都写上message直接改成前端能展示的中文文案。否则前端拿到的就是Validation error: length这种东西产品经理看到要炸。第四是嵌套属性可以叠加。一个字段可以同时挂多个规则比如手机号既要查格式又要查号段就写两个属性#[validate(regex(path PHONE_RE, message 手机号格式不正确))] #[validate(custom(function validate_phone_segment))] phone: String,所有规则都会执行每一条失败都会被收集到ValidationErrors里不会因为第一个失败就中断。这也是 validator 做得比较好的地方。3. 和 serde 及各 Web 框架的集成细节validator 本身不关心数据从哪来实际项目中它几乎总是和 serde 的Deserialize配合使用。JSON 请求体反序列化成结构体然后立刻调用validate()。这个链路很自然但中间有几个细节容易出问题。3.1 字段改名后错误信息的名字别跟着改serde的#[serde(rename ...)]属性是前端接口联调里的常客。比如前端传userNameRust 结构体里叫user_name。问题来了validator 收集错误时field_errors()返回的字段名是 Rust 里的字段名user_name而不是前端认识的userName。#[derive(Debug, Validate, serde::Deserialize)] struct CreateUserRequest { #[serde(rename userName)] #[validate(length(min 3, max 20, message 用户名长度必须在3到20个字符之间))] user_name: String, }这个请求体前端正常提交但如果用户名长度不合法validator 返回的错误结构里字段名是user_name前端代码里写的是userName结果就是前端根本找不到这个错误字段。我在项目里专门维护了一张字段映射表把 validator 暴露的 Rust 字段名映射回接口层约定的 JSON 字段名再做统一出口返回。这个经验建议你们项目也提前做别等前端来提 bug。3.2 在 axum 里把校验做成正式的提取器在 axum 里最省事的做法是在 handler 里直接调validate()。但项目接口多了你会发现每个 handler 都要写一遍 if let Err(errors) payload.validate() 然后转错误响应非常重复。我后来封装了一个ValidatedJson提取器专门干这个活use axum::{ extract::{FromRequest, Request}, http::StatusCode, response::{IntoResponse, Response}, Json, }; use serde::de::DeserializeOwned; use validator::Validate; pub struct ValidatedJsonT(pub T); implT, S FromRequestS for ValidatedJsonT where T: DeserializeOwned Validate, S: Send Sync, { type Rejection (StatusCode, Jsonserde_json::Value); async fn from_request(req: Request, state: S) - ResultSelf, Self::Rejection { let Json(payload) Json::T::from_request(req, state) .await .map_err(|_| { ( StatusCode::BAD_REQUEST, Json(serde_json::json!({ error: 请求体不是合法的 JSON })), ) })?; if let Err(errors) payload.validate() { return Err(( StatusCode::BAD_REQUEST, Json(serde_json::json!({ errors: errors.to_string() })), )); } Ok(ValidatedJson(payload)) } }handler 就能写成async fn create_user(ValidatedJson(req): ValidatedJsonCreateUserRequest) - impl IntoResponse { // 到这里req 已经是校验通过的数据了 (StatusCode::CREATED, Json(req)) }这样校验逻辑和业务逻辑彻底分离handler 里不会出现一堆validate()样板代码。需要注意 axum 的不同版本对FromRequest的签名要求略有区别我这是基于 axum 0.7 写的升级版本时记得对照官方文档调整。actix-web 的思路类似可以写一个 extractor 包装Json也可以在 data 层面做统一校验。核心模式都一样反序列化之后立刻校验校验失败立刻返回不要把脏数据带进业务层。4. 嵌套结构、集合遍历与自定义校验函数单一结构体的字段校验只是入门。实际业务里更麻烦的是嵌套结构体和集合内的校验以及跨字段之间的依赖校验。4.1 嵌套结构体验证用 nest 把子结构规则内聚用户资料里通常会有地址、联系方式这些子结构。如果每个子结构都单独在业务代码里手动校验那又回到了 if 地狱。validator 提供了#[validate(nested)]支持在字段上直接触发子结构体的validate()#[derive(Debug, Validate)] struct Address { #[validate(length(min 5, max 128, message 街道地址长度不合法))] street: String, #[validate(length(min 1, max 64, message 城市名称不能为空))] city: String, } #[derive(Debug, Validate)] struct UserProfile { #[validate(nested)] address: Address, }这样UserProfile调用validate()时会自动递归校验内部的Address。错误信息会被包成嵌套结构address.street这样的层级关系一目了然日志里排查问题非常方便。4.2 集合遍历与 skip_on_empty 的边界语义#[validate(nested)]同样适用于集合字段。如果字段是VecTag加#[validate(nested)]后遍历校验每个元素任何一个元素校验失败都会收集进来。这里有个和Option相关的语义容易混淆。我单独强调一下OptionT字段默认行为值为None时跳过校验值为Some(v)时才校验。skip_on_empty true则更激进空字符串、空集合、None都会跳过校验。两者区别在于Some()这种情况。单纯Option字段遇到Some()会继续校验空字符串可能就违反了length(min 1)但如果加了skip_on_empty空字符串也会被放过。很多事故就出在Optionskip_on_empty的组合上。产品本来想表达的是可选字段但填了就必须合法结果配置成不管填没填都放过那校验等于形同虚设。我在下一章还会讲一个真实案例。4.3 跨字段依赖校验手动实现 Validate trait字段级规则解决的是单个字段是否合法但业务里经常有字段组合是否合法的诉求。最典型的例子预定期接口end_time必须晚于start_time。这种依赖关系没法用 derive 属性写在单个字段上因为每个字段校验函数只能拿到自己那一个值。我的做法是手动为结构体实现Validatetrait。derive 宏不写了直接用impluse validator::{Validate, ValidationError, ValidationErrors}; #[derive(Debug)] struct Booking { start_time: i64, end_time: i64, user_id: i64, } impl Validate for Booking { fn validate(self) - Result(), ValidationErrors { let mut errors ValidationErrors::new(); if self.end_time self.start_time { let mut err ValidationError::new(invalid_time_range); err.message Some(结束时间必须晚于开始时间.into()); errors.add(end_time, err); } if self.user_id 0 { let mut err ValidationError::new(invalid_user_id); err.message Some(用户ID非法.into()); errors.add(user_id, err); } if errors.is_empty() { Ok(()) } else { Err(errors) } } }手动实现的好处是可以在一个方法里拿到结构体的全部字段跨字段校验、依赖外部服务比如查数据库判断用户是否存在都能做。外部校验要注意一点不要在validate()里做太重的 IO 操作否则一个接口的耗时就被校验拖垮了。查库这种活我一般放在业务服务里做不在 validator 层碰。另外derive 和手动实现也可以混着来。复杂的结构体可以用#[validate(custom(function ...))] derive 保留字段级规则然后在自定义函数里只做跨字段逻辑。具体用哪种看场景顺不顺手。5. 从一次线上问题看 validator 常见的坑前面把基本用法和进阶玩法都聊了最后分享一个我真实遇到的线上问题。这个问题的排查过程能帮大家理解 validator 的各种行为边界。5.1 事故还原空字符串是怎么绕过校验的某天运营反馈用户可以把昵称改成空字符串数据库里也真的出现了一批空昵称。我们结构体的定义大概是这样的#[derive(Debug, Validate, serde::Deserialize)] struct UpdateProfileRequest { #[validate(length(min 1, max 30, message 昵称长度必须在1到30个字符之间))] #[validate(skip_on_empty true)] nickname: OptionString, }我当时的本意是nickname是可选字段前端不传没问题传了就需要是 1 到 30 个字符的合法昵称。所以加OptionString是合理的但我又画蛇添足地加了skip_on_empty。问题就在这前端没有传昵称时请求体里可能压根没有这个字段所以值是Nonederive 宏遇到None本来就跳过校验了。而当用户把昵称输入框清空前端选择传空字符串时值变成Some()。此时length(min 1)应该拦下空字符串可skip_on_empty检测到字符串为空再次跳过了校验。两条规则加在一起等于对空字符串完全放行。排查的时候我一度以为是 validator 版本 bug后来把两个属性拆开单测才搞清楚这是一个语义组合问题。修复方案很简单去掉skip_on_empty只保留OptionString。这样没传和传了空串被彻底区分开后者会被正常拦截。这次事故的教训非常值钱不要在一个字段上同时使用语义重叠的跳过规则。Option本身就有None跳过语义skip_on_empty又把空值跳过这件事重复做了一遍叠加起来就把合法值也放过了。5.2 自定义校验函数 panic 导致 500 的排查链路第二件让我印象深刻的事是自定义校验函数里发生了 panic接口直接返回 500。那段代码大概是这样的fn validate_user_tag(tag: str) - Result(), ValidationError { let config get_tag_config().unwrap(); // 这里炸了 if !config.valid_tags.contains(tag) { return Err(ValidationError::new(invalid_tag)); } Ok(()) }get_tag_config()内部因为缓存没初始化返回了Noneunwrap直接 panic。validator 的validate()调用链没有捕获 panic于是整个请求 500线上日志只留下一句thread tokio-runtime-worker panicked。定位过程说难也难说简单也简单。我先在本地复现构造同一个请求发现 handler 的 error log 里根本没有业务错误反而有 panic 堆栈。再顺藤摸瓜找到自定义校验函数确认是内部 unwrap 导致。这个坑给我的经验是自定义校验函数要当作生产代码来写不能带任何 unwrap/expect。校验函数本身也可能出错出错的时候应该把 panic 转化为校验失败而不是让进程崩溃。上面那段代码正确写法是把配置读取变成优雅的错误处理读不到配置就返回ValidationError::new(internal_config_error)至少前端拿到的是 400而不是 500。5.3 错误信息的结构化输出让前端少点抱怨最后一个经验是关于错误输出的。validator 的ValidationErrors默认的Display输出是人类可读的文本但前端接口对接更喜欢结构化 JSON而不是一长串字符串。我们后来在项目里统一封装了一个转换函数把ValidationErrors转成serde_json::Valuefn format_validation_errors(errors: ValidationErrors) - serde_json::Value { let mut result serde_json::Map::new(); for (field, errors_vec) in errors.field_errors() { let messages: Vecstr errors_vec .iter() .filter_map(|e| e.message.as_deref()) .collect(); result.insert(field.to_string(), serde_json::json!(messages)); } serde_json::Value::Object(result) }配合前面说的字段映射表最终返回给前端的结构就是{ userName: [用户名长度必须在3到20个字符之间] }这种格式。前端可以直接把错误 message 怼到对应表单项下面不需要再解析字符串。这里要注意ValidationError的 message 字段类型在不同小版本里可能不完全一致有的是OptionCowstatic, str有的是固定字符串。如果遇到类型不匹配按你锁定的版本来一次字段匹配就行整体思路不变。以上这些坑有的是我自己折腾半天才琢磨明白的有的是队友填了坑我在旁边围观记下来的。vali dator 不是那种特别难上手的库但它和 serde、框架的联动细节以及各种属性组合的边界语义确实值得多花时间吃透。用熟了之后你会发现参数校验这件事可以被安排得非常省心而省下来的时间就留给更值得操心的业务逻辑吧。