写了几百个Gin的接口之后,我发现很多同学对参数校验的理解停留在“给struct打几个binding标签就完事”的层面。说实话,这不算错,但真的不够。咱们日常写接口,十个报错里至少有三四个是参数问题,validator/v10这个库表面上是做结构体校验的,但它设计得非常深——自定义校验器、跨字段校验、错误翻译、甚至一些奇技淫巧,能帮你把接口层的第一道防线扎得非常扎实。
这篇文章就聊聊validator/v10在Gin里的深度玩法。我会从它底层的跑法说起,讲清楚标签背后是怎么工作的,然后一步步拆解自定义校验器、结构体级校验、错误信息定制这些实战高频点,最后附上一些我实际踩过的坑和总结出来的项目级规范。内容是按Gin + validator/v10的标配组合写的,用的是Gin的binding标签体系,适合用过Gin但还没深入研究过校验机制的读者,也适合正在写接口想把手上的校验逻辑整理清楚的开发者。
1. validator/v10在Gin里到底是怎么跑起来的
1.1 Gin的binding机制与默认校验器
Gin处理请求的时候有个核心的binding机制,把请求体(JSON、Query、Form等)绑定到struct上。这里的关键在于,Gin没有自己实现校验器,而是用了go-playground/validator这个生态,并把它集成到binding包里。
每个binding类型都有对应的结构体,比如jsonBinding、queryBinding、formBinding。执行bind的时候,Gin会调用bindData方法,默认走DefaultValidator这个全局单例。DefaultValidator内部维护了一个validator.Validate实例,通过ValidateStruct来做结构体校验。Gin官网的文档其实没太展开讲,但源码里写得清清楚楚——你打上的binding:"required"这些tag,本质是validator/v10在解析和执行的。
所以“binding标签”和“validator标签”是一回事。binding前缀是Gin的约定,实际注册进validator的还是那些规则名。这一点对理解后面的自定义校验器很重要:你注册校验规则时,是在binding.Validator.Engine()这个validator实例上注册的,而不是在Gin本身注册。
1.2 一个请求从入门到校验通过的全流程
一条带校验的请求链路大概是这样的:
- 请求进来,走路由,到handler。
- handler里调用
c.ShouldBindJSON(&req)或者c.ShouldBind(&req)。 - Gin根据Content-Type选择对应的binding实现。
- binding先用
json.Decoder或url.Values解析数据到结构体。 - 解析完成后,Gin调用
ValidateStruct做校验。 - 校验通过,handler继续执行;校验不通过,返回error,你的逻辑里再处理这个error。
这里面有个细节很多人忽略:校验是绑定之后自动触发的。如果你调了ShouldBindJSON,即参数匹配不上,比如json字段名对不上,Gin会返回一个json.UnmarshalTypeError之类的错误,这种错误是“绑定错误”,和“校验错误”是两类不同的错误。写全局错误处理的时候,要区分开这俩。
还有个细节是binding和validation的触发顺序。Gin是先绑定再校验,绑定成功但值不满足校验规则时才走校验逻辑。如果绑定阶段就失败了(比如类型不对、JSON格式错误),validator根本不会被调用。
r.POST("/user", func(c *gin.Context) { var req CreateUserRequest if err := c.ShouldBindJSON(&req); err != nil { c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) return } c.JSON(http.StatusOK, gin.H{"data": req}) })这种写法是基础,但错误处理太粗糙。后面我会专门讲怎么把错误信息做成前端友好的格式。
2. 基础校验标签的背后逻辑:先搞懂校验规则怎么设计
2.1 常用标签速查与踩坑对照
validator/v10内置了几十个校验器,核心的老牌标签有这么几个:
| 标签 | 作用 | 典型示例 | 备注 |
|---|---|---|---|
required | 字段必须有值 | binding:"required" | 对字符串和切片,空值也会报错 |
omitempty | 字段为空则跳过校验 | binding:"omitempty,email" | 不是校验规则,是“条件跳过”指令 |
min/max | 对数字、字符串、切片做长度或大小下限/上限 | binding:"min=3,max=20" | 字符串按rune数算 |
len | 要求长度严格相等 | binding:"len=11" | 手机号这类场景 |
gt/gte/lt/lte | 数值比较:大于、大于等于、小于、小于等于 | binding:"gte=0,lte=150" | 注意和min的区别 |
email | 校验邮箱格式 | binding:"email" | 格式校验,不保证能收到信 |
oneof | 值必须在给定枚举里 | binding:"oneof=admin user guest" | 字符串校验利器 |
numeric | 只允许数字字符 | binding:"numeric" | 对字符串类型生效 |
url | 校验URL格式 | binding:"url" | 能过格式,但不检查可访问性 |
uuid | 校验UUID格式 | binding:"uuid" | 有uuid4等变体 |
这些标签别死记,知道每个的适用类型最重要。比如min,对字符串表示最小长度,对数值表示最小值,对切片表示最少元素个数。同一个标签在不同类型上的含义是分派的,这点很容易踩坑。
一个很典型的坑是min和gte。min=0用在int字段上表示最小值要大于等于0,这没问题;但如果用在string上,它表示的是字符串长度要大于等于0。原意和效果差了十万八千里。
还有个更常见的坑是required和omitempty同时写。binding:"required,omitempty"看起来人畜无害,实际上是语义冲突的——omitempty会让validator在字段为零值时跳过后续校验,而required又要求字段不能为空,这俩组合的最终行为是:空值时omitempty触发跳过,整个校验直接放行。这往往不是你想要的。实际项目里,见过不少同事把omitempty当“可以为空但不为空时校验”来用,然后写上required,这个bug隐藏得很深。
2.2 required和omitempty的正确姿势
要正确理解这两个标签,得回到validator的设计理念:默认情况下,所有打上校验标签的字段都必须通过校验,否则接口报错。你打一个binding:"email",没打required,前端不传这个字段时,JSON里不存在这个key,字段是空字符串,但如果校验器仍然去执行email规则,空字符串会直接报格式错误。
这就是为什么“非必填但要校验格式”的场景必须加omitempty:
type UpdateUserRequest struct { Email string `json:"email" binding:"omitempty,email"` Nickname string `json:"nickname" binding:"omitempty,min=2,max=20"` }这里omitempty是说“如果这个字段没传(零值),就跳过校验;如果传了,就按后面的规则来”。这是partial update场景最常用的写法。
需要特别注意的是,omitempty只认零值。对于string是空字符串,对于int是0,对于指针是nil,对于slice是nil或空。如果你要区分“用户传了0”和“用户没传这个字段”,光靠omitempty区分不了。这种情况建议改用指针类型:
type UpdateConfigRequest struct { Timeout int `json:"timeout" binding:"omitempty,min=10"` }前端传{"timeout": 0}时,omitempty会认为这是空值,直接跳过,后端拿到的也是0。但传0可能是合法的“关闭超时限制”的意思。改用*int:
type UpdateConfigRequest struct { Timeout *int `json:"timeout" binding:"omitempty,min=10"` }这样传0和传null、不传,三种情况在Go里都能区分。指针配合omitempty,是处理“更新接口”参数语义的标准解法。
3. 注册自定义校验器:处理业务规则的最强利器
3.1 为什么要自定义校验器
内置标签覆盖的是通用规则,但真实业务里充满了“不太通用”的规则。比如:
- 用户名不能是保留字(admin、root、test)。
- 订单状态必须处于“已创建、已支付、已发货、已取消”四者之一。
- 时间参数必须是当天之后的日期。
- 用户ID在某个正则规则匹配下必须是特定前缀。
你当然可以在handler里手写if else判断,但这样做有几个问题:校验逻辑散落在各个handler,没法复用;handler里塞满业务判断,可读性下降;一旦规则变化,改起来容易漏。
正确思路是注册自定义校验器,让校验逻辑和struct定义在一起,保持handler干净。validator是对反射的封装,它允许你通过RegisterValidation注入自己的函数。
话虽如此,也要说句公道话:自定义校验器不是每次都必须用。一次性、只在某个接口出现的简单判断,直接在handler里写也没问题。但如果同一个规则被三五个接口复用,或者规则本身很复杂(比如依赖多个字段),那就应该提升为自定义校验器。
3.2 通过RegisterValidation扩展校验规则
自定义校验器的核心是一个validator.Func类型的函数,签名是:
func(fl validator.FieldLevel) boolFieldLevel提供了获取当前字段值、父结构体、参数(把binding:"mytag=xxx"里的参数取出来)的能力。
最简单的自定义校验器,比如校验用户名不能是保留字:
import ( "github.com/gin-gonic/gin" "github.com/gin-gonic/gin/binding" "github.com/go-playground/validator/v10" ) var reservedUsernames = map[string]bool{ "admin": true, "root": true, "test": true, } func validateUsername(fl validator.FieldLevel) bool { username := fl.Field().String() return !reservedUsernames[username] } func init() { if v, ok := binding.Validator.Engine().(*validator.Validate); ok { _ = v.RegisterValidation("reserved_username", validateUsername) } }然后就可以在结构体里直接用了:
type CreateUserRequest struct { Username string `json:"username" binding:"required,min=3,max=20,reserved_username"` }这样设计的好处是规则集中管理,而且校验器是全局单例,init时注册一次即可,运行期零成本。
这里要敲黑板:binding.Validator.Engine()返回的是接口,必须断言成*validator.Validate才能注册自定义规则。有些同学直接自己validator.New()一个实例注册了,又绑定到Gin的别的地方,结果发现标签不生效,就是因为注册错了对象。Gin在binding包里维护的是全局默认validator实例,你要扩展的必须是这个实例。
3.3 FieldLevel的高级玩法:访问参数与其他字段
自定义校验器不只是“看一下这个字段值”,它还能拿参数和上下文。FieldLevel.Param()能拿到标签里的参数,这样就能写一个“参数化”的校验器。
举个实际例子,校验字符串长度必须介于param1和param2之间:
func validateLengthRange(fl validator.FieldLevel) bool { field := fl.Field() if field.Kind() != reflect.String { return false } parts := strings.Split(fl.Param(), "-") if len(parts) != 2 { return false } minLen, _ := strconv.Atoi(parts[0]) maxLen, _ := strconv.Atoi(parts[1]) l := len([]rune(field.String())) return l >= minLen && l <= maxLen }注册时标签名是自己定的,比如叫len_range,然后结构体里这么写:
type ArticleRequest struct { Title string `json:"title" binding:"len_range=5-60"` }再进一步,FieldLevel还能通过fl.Parent()拿到父结构体,从而访问同级的其他字段。这在“两个字段关联校验”的场景里特别有用,典型的例子是优惠券规则里“最高抵扣金额不能超过订单金额”。
type CouponRequest struct { OrderAmount float64 `json:"order_amount" binding:"required,gt=0"` MaxDeduct float64 `json:"max_deduct" binding:"required,gt=0"` }这时候用跨字段校验更合适。validator本身提供了eqfield、nefield、gtfield等内置的跨字段标签,但自定义场景更多。FieldLevel.Parent()拿到的父结构体是reflect.Value,需要Interface()再断言成具体类型,或者用FieldByName取字段值。
实际项目里,我建议把复杂跨字段规则写成自定义校验器,这样错误信息才可控。内置的eqfield等标签报错信息比较生硬,翻译过来也不够友好,后面讲错误定制的时候会展开说。
4. 跨字段与结构体级校验:密码二次确认这类需求的正解
4.1 使用eqfield、nefield做字段间比较
最典型的跨字段校验就是注册接口里的密码二次确认。
type RegisterRequest struct { Password string `json:"password" binding:"required,min=8,max=32"` ConfirmPassword string `json:"confirm_password" binding:"required,eqfield=Password"` }eqfield=Password的意思是“本字段必须等于同结构体内名为Password的字段”。注意这里标签值是Go字段名,不是json字段名。这个坑我见人踩过好多次:json里写的是confirm_password,然后eqfield=confirm_password,运行时报错“找不到字段”。
跨字段校验还有这些:
| 标签 | 含义 | 示例 |
|---|---|---|
eqfield | 等于某字段 | binding:"eqfield=Password" |
nefield | 不等于某字段 | binding:"nefield=Password" |
gtfield | 大于某字段 | binding:"gtfield=StartTime" |
gtefield | 大于等于某字段 | binding:"gtefield=MinPrice" |
ltfield | 小于某字段 | binding:"ltfield=Price" |
ltefield | 小于等于某字段 | binding:"ltefield=MaxPrice" |
这些标签本质上是对FieldLevel.Parent()做反射查字段再比较,所以要求字段可比。类型不同会直接报错。
注意一个隐藏条件:eqfield等比较用的是Go类型语义,所以参与比较的两个字段类型得一致,或者类型兼容。int和int64不匹配,会包一个validator内部的panic或error,这一点要注意。
4.2 dive标签处理结构体切片和嵌套结构体
接口设计里,批量创建是非常常见的需求。比如一次提交多个人:
type BatchCreateRequest struct { Users []UserInfo `json:"users" binding:"required,min=1,dive"` } type UserInfo struct { Name string `json:"name" binding:"required,min=2,max=20"` Age int `json:"age" binding:"gte=0,lte=200"` }这里dive的语义是“进入切片/数组的每个元素继续校验”。没有dive时,validator只会看Users这个变量本身,而不会管内部每个元素的字段是否满足约束。
dive有两个关键点:
第一,dive可以叠加在required后面,但顺序有讲究。binding:"required,min=1,dive"和binding:"required,dive,min=1"语义完全不同。前者先校验切片非空且至少1个元素,然后进入元素;后者先校验切片非空,然后进入每个元素,再对每个元素校验min=1(对切片里的每一层再取长度)。实际开发中,对[]string校验元素长度时,写法是:
type TagsRequest struct { Tags []string `json:"tags" binding:"required,dive,min=1,max=10"` }这里dive后面的min=1和max=10作用在切片元素上,也就是每个tag字符串长度在1到10之间。而如果顺序写反,比如binding:"required,min=1,dive,max=10",你就同时校验了切片本身的长度和每个元素的长度,是不是你想要的,取决于需求,但这个差异非常隐晦。
第二,嵌套struct的dive会递归校验。切片元素本身是struct时,dive之后的字段会继续按该struct的标签校验。如果有更深的嵌套,比如切片里的struct又有切片,那就需要连写多个dive。
type SchoolRequest struct { Classes []struct { Students []string `json:"students" binding:"required,dive,required"` } `json:"classes" binding:"required,dive"` }这种多级嵌套的标签链是validator最容易被误解的地方。我的建议是,遇到这种结构首先在本地用测试用例跑一圈,别靠脑子推断。
4.3 在结构体方法上使用RegisterStructValidation
有些校验规则更“整体性”,比如一个时间段必须开始早于结束。这种规则不属于任何一个字段,而是关于结构体本身。这时候应该用RegisterStructValidation注册结构体级校验函数。
结构体级校验函数的签名和字段校验不一样,它接收validator.StructLevel:
type DateRangeRequest struct { Start string `json:"start" binding:"required"` End string `json:"end" binding:"required"` } func validateDateRange(sl validator.StructLevel) { req := sl.Current().Interface().(DateRangeRequest) if req.Start >= req.End { sl.ReportError(req.Start, "start", "start", "date_range", "") } }ReportError的参数里,第一个是field的值,第二个是Go字段名,第三个是json标签名(用于错误消息展示),第四个是错误类型名,第五个是参数。这样做的好处是,把“两个字段满足某种关系”这种结构级规则显式声明出来,而不是散落在handler里。
注册方式:
if v, ok := binding.Validator.Engine().(*validator.Validate); ok { _ = v.RegisterStructValidation(validateDateRange, DateRangeRequest{}) }注册时传的是空struct实例,validator用这个实例的type做关联。
我亲测下来,结构体级校验适合两类场景:一是字段间有强关联性(比如起止时间、金额区间),二是需要读取多个字段做决策后才能判断是否合法。它的优势在于,错误信息可以通过ReportError控制在某个字段名下,前端拿到的是“哪个字段错了”,而不是整条请求说不清道不明的错误。
5. 错误信息定制:让接口报错说人话
5.1 用translator实现对中英文错误信息的无缝切换
validator/v10的错误信息默认是英文的,格式类似Key: 'CreateUserRequest.Username' Error:Field validation for 'Username' failed on the 'required' tag。这种信息不光是用户看不懂,前端同学看着也头疼。Gin的官方示例里用了一个中文翻译器,可以全局注册:
import ( zhLoc "github.com/go-playground/locales/zh" ut "github.com/go-playground/universal-translator" "github.com/go-playground/validator/v10/translations/zh" ) func initTranslator() ut.Translator { zh := zhLoc.New() uni := ut.New(zh, zh) trans, _ := uni.GetTranslator("zh") if v, ok := binding.Validator.Engine().(*validator.Validate); ok { _ = zh.RegisterDefaultTranslations(v, trans) } return trans }注意locales和translations/zh是两个不同的包,前者是语言地区数据,后者是把validator内置错误信息翻译成中文的注册函数。忘了注册后者,翻译器不会自动生效。
项目里如果要做多语言,逻辑是:根据请求头里的Accept-Language选翻译器,Gin的c.GetHeader("Accept-Language")可以拿到语言偏好,然后动态选择注册英文或中文翻译器。
这样做有一个好处是,错误信息会变成“Username为必填字段”这种格式。比默认英文友好得多。
5.2 基于ValidationErrors的结构化错误响应
让前端能精确拿到每个字段的错位信息,最好的方案不是把翻译后的字符串拼成大块,而是输出结构化JSON。validator的err.(validator.ValidationErrors)是一个切片,里面每个FieldError都有丰富的字段:
func handleErr(c *gin.Context, err error) { if err == nil { return } var errors []map[string]string if validationErrors, ok := err.(validator.ValidationErrors); ok { for _, e := range validationErrors { errors = append(errors, map[string]string{ "field": e.Field(), "tag": e.Tag(), "value": fmt.Sprintf("%v", e.Value()), "msg": e.Translate(trans), }) } } else { errors = append(errors, map[string]string{ "field": "request", "tag": "invalid", "msg": err.Error(), }) } c.JSON(http.StatusBadRequest, gin.H{"code": 400, "errors": errors}) }这段代码里有个关键分叉:err.(validator.ValidationErrors)断言成功说明是校验错误,失败说明是绑定错误(比如JSON格式不对、字段类型不匹配)。这两种错误要做不同的处理,不能一概而论。
前端拿到这种结构,就能在表单里精确标红每个字段,而不是在alert里显示一大段英文。
多说一句:e.Field()返回的是Go字段名,不是json字段名。如果要返回给前端的字段名和json一致,需要在注册validator时调用RegisterTagNameFunc。这个函数可以自定义“显示名”映射规则:
v.RegisterTagNameFunc(func(fld reflect.StructField) string { name := strings.SplitN(fld.Tag.Get("json"), ",", 2)[0] if name == "-" { return "" } return name })注册后,翻译错误信息里的字段名会自动换成json名。这样前端拿到的字段就能直接对应表单的name属性。
5.3 注册自定义标签的错误翻译
自定义校验器注册后,validator并不认识它对应的错误文案,翻译器也不会自动处理。如果你用e.Translate(trans),自定义标签返回的还是一句默认英文。要解决这个问题,需要注册自定义标签的翻译函数。
比如前面写的reserved_username,注册翻译可以这样做:
_ = v.RegisterTranslation("reserved_username", trans, func(ut ut.Translator) error { return ut.Add("reserved_username", "{0}不能为保留用户名", true) }, func(ut ut.Translator, fe validator.FieldError) string { t, _ := ut.T("reserved_username", fe.Field()) return t })Add方法的参数是键名、中文模板和是否覆盖。模板里的{0}会被替换成字段名。
这个功能非常重要,不然自定义校验器的错误信息会非常突兀。我记得有一次上线,自定义的validateEnumType校验器没有注册翻译,前端收到的错误是EnumType must be a valid value,用户完全看不懂,后来补注册了翻译,才恢复正常。
6. 实战项目中的校验规范与避坑总结
6.1 Gin + GORM + go-redis项目中校验层的位置
在完整的Web项目里,参数校验是接口处理链路上的第一道关卡,它负责把脏数据挡在业务逻辑之外。我参与过的Gin + GORM + go-redis项目,通常的分层是这样的:
router -> handler -> validate(拦截参数) -> service -> dao(MySQL/Redis)handler里拿到请求先绑定和校验,脏数据直接返回,不进service。这样service层可以安全地假设入参合法,MySQL、Redis那边的代码能写得简洁很多。
有些团队会写一个全局的ResponseHandler,在handler里抽公共逻辑。对于校验,我的做法是封装一个BindAndValidate辅助函数,统一处理绑定和校验:
func BindAndValidate(c *gin.Context, req interface{}) bool { if err := c.ShouldBind(req); err != nil { handleErr(c, err) return false } return true }然后在每个handler里写:
if !BindAndValidate(c, &req) { return }这样的好处是绑定和校验的流程集中管理,减少重复代码,也让每个handler的入口看起来非常清晰。缺点是多了一层封装,新同学可能不知道内部发生了什么,需要在项目文档里说明。
6.2 微型单测保护你的校验规则
自定义校验器写完之后,一定要写单测。因为validator的运行机制依赖反射和标签解析,写错了可能运行期才炸,而且错误信息不太直观。
一个简单的单测思路:
func TestValidateUsername(t *testing.T) { validate := validator.New() _ = validate.RegisterValidation("reserved_username", validateUsername) type Request struct { Username string `json:"username" binding:"reserved_username"` } req := Request{Username: "admin"} err := validate.Struct(req) if err == nil { t.Error("expected admin to be rejected") } }这里用独立的validator.New()实例测试,不依赖Gin环境,跑起来更快。同时也能验证你写的校验函数逻辑本身是否正确。
我自己的经验是,校验器最容易翻车的点不是普通输入,而是边界输入:空字符串、超长字符串、包含特殊符号、不同字符编码。单测用例里把这些都覆盖上,上线后能少处理很多issue。
6.3 我复盘出的validator/v10高频坑位清单
写这么长的文章,最后把我见过的坑整理成表。所有问题都是真实踩过或帮同事排过的,价值很高。
| 坑位 | 具体表现 | 解法 |
|---|---|---|
required,omitempty同用 | 空值被跳过,required形同虚设 | 二选一,想“可选但格式正确”只写omitempty |
min用错类型 | 字符串长度vs数值大小混淆 | 先明确类型,再选min还是gte |
eqfield写成json名字 | 运行期找不到字段,panic或无效 | 标签里写Go字段名 |
| 自定义校验器注册错实例 | gin的binding标签不生效 | 断言binding.Validator.Engine()再注册 |
dive顺序错误 | 校验的是切片本身而不是元素 | 记住dive之后是元素规则 |
未调用RegisterTagNameFunc | 错误消息返回Go字段名 | 注册json名映射 |
| 自定义校验器不注册翻译 | 错误信息仍然是英文模板 | 调用RegisterTranslation |
| 指针和非指针字段区分不清 | 传0和没传被omitempty混淆 | 用*int等指针类型 |
| 类型不匹配的绑定错误 | validator断言失败,走了错误分支 | 在错误处理里区分ValidationErrors和别的错误 |
最后一条我想多说两句。ValidationErrors断言失败的情况,最常见的场景是前端传了个"age": "abc",JSON反序列化阶段就失败了,这时代理返回的其实是json.UnmarshalTypeError的包装。如果你在错误处理里只处理了ValidationErrors,其他错误直接拼字符串返回,前端的报错格式就不统一。所以我在项目里统一把绑定错误也翻译成结构化JSON,只是tag固定为invalid_request,这样前端适配成本最低。
6.4 项目里的校验规范建议
经过几个项目的沉淀,我总结了一套目前用下来比较顺的规范:
第一,所有DTO(数据传输对象)统一放在dto包下,结构体定义和校验标签不散落在handler文件里。这样想了解接口入参,看DTO一目了然。尤其是Gin + GORM的项目,DTO和Model分开,Model不写校验标签,校验全在DTO层,避免Model被校验逻辑污染。
第二,自定义校验器统一在validator或pkg/validator包中初始化,init函数只做注册,不做业务逻辑。这样后续要复用可以在多个项目里直接拷包。
第三,错误处理封装成独立函数,不要在每个handler里重复写JSON响应。统一错误响应格式,减少前端联调成本。
第四,校验规则有变化时,先看有没有单测。没有单测的校验器,改起来谁都不敢动。这个不是规范,是血泪教训。
其实validator/v10在Gin里能玩的深度远不止这些,像alias标签、validate.New()的option配置、结构化错误配合errors.As、针对国际化做整套消息映射,都是很实用的进阶方向。但把上面这些基础盘扎实,日常项目的接口参数校验已经游刃有余了。我自己的习惯是每写一个新的校验规则,都顺手在测试目录里加个用例,时间长了这就是你的“校验工具库”,以后新接口的校验规则基本都是老规则的组合,写起来很快。
这套玩法在我手头的Gin + GORM + go-redis实战项目里已经沉淀了很久,目前线上接口的校验错误基本能做到“前端一键定位、用户看得明白、后端不脏不乱”。如果你也正在折腾Gin的参数校验,建议直接照着这篇里的顺序动手实操一遍,尤其建议把自定义校验器、dive、翻译注册这三块都跑通,你会发现Gin的校验能力比想象中厚实得多。