验证逻辑不应写在 handler 中,因其会导致重复、难测试、不可复用,且业务规则变更时需多处修改;应抽离为纯数据函数,基于 struct tag 实现轻量、可复用、易测试的独立验证组件。

验证逻辑为什么不能直接写在 handler 里
因为会重复、难测试、无法复用,且一旦业务规则变化(比如邮箱校验加了新域名白名单),所有用到的地方都要改。更麻烦的是,单元测试得模拟整个 HTTP 请求上下文,而验证本身只是纯数据判断。
把验证抽成独立组件,核心目标就一个:Validate 函数只接收结构体或字段值,返回 error 或 ValidationErrors,不依赖 http.Request、context.Context 等运行时环境。
用 struct tag + 自定义 validator 是最轻量的抽象方式
Go 原生没有内置验证器,但 reflect + struct tag 足够支撑大部分场景。关键不是“造轮子”,而是控制抽象粒度——别一上来就封装成带注册表、中间件、钩子的框架。
-
validate:"required,email"这类 tag 是事实标准,主流库(如go-playground/validator)都认它,迁移成本低 - 自定义校验函数必须是无副作用的纯函数,例如:
func isPhone(fl validator.FieldLevel) bool { ... },不能查数据库、不能发 HTTP 请求 - 避免在 tag 里塞复杂表达式(如
validate:"gt=10,lt={{.MaxAllowed}}"),这会让验证逻辑泄漏到配置中,难以调试和覆盖测试 - 嵌套结构体默认不递归验证,需显式加
validate:"dive",否则User.Profile.Name的required不生效
validator 实例该全局复用还是 per-request 创建
应该全局复用单例 *validator.Validate 实例。它本身是线程安全的,内部缓存了 struct 字段解析结果;每次新建实例反而触发重复反射解析,实测 QPS 下降 15%+(尤其对高频接口)。
使用 uber-go/dig 在 Golang 中实现依赖注入——基于反射容器,提供 Provide/Invoke、dig.In/dig.Out 参数与结果对象,支持命名依赖等
立即学习“go语言免费学习笔记(深入)”;
但要注意:自定义校验函数如果用了闭包捕获 request-scoped 数据(比如当前租户 ID),就必须通过 SetStructLevelValidator 或字段级 RegisterValidation 动态注入,而不是在全局 validator 初始化时硬编码。
- 全局初始化示例:
v := validator.New() v.RegisterValidation("tenant_email", tenantEmailFunc) // tenantEmailFunc 是无状态函数 - 若需传入租户信息,应改用字段级校验参数,例如:
validate:"tenant_email=123",然后在tenantEmailFunc中解析 tag 参数 - 不要在 validator 上挂载任何 map、slice 类型的可变状态,否则并发下 panic
错误处理要区分 validation error 和业务 error
validator 返回的 error 是结构化的 validator.ValidationErrors,它实现了 error 接口但本质是 slice。直接 return err 给前端会导致 JSON 序列化成空对象 {},前端收不到具体字段错误。
- 必须显式转换:用
err.(validator.ValidationErrors)类型断言,再遍历生成map[string]string格式(如{"email": "email format invalid"}) - 不要用
err.Error()拼接字符串返回,它把所有错误混成一行,前端无法按字段高亮 - 如果验证失败后还要执行 DB 查询等操作,别用
if err != nil { return }粗暴退出,先收集错误,再统一响应,避免部分验证通过后产生脏数据

















