WR Blog 加载中...
返回文章
GoValidator参数校验API

Go Validator 实战:结构体校验、自定义规则与错误翻译

Go Validator 实战:结构体校验、自定义规则与错误翻译封面

Go 基础体系 · 第 80/113 篇。示例统一基于 Go 1.26.4;核心片段可能省略 package 与 import,完整程序可直接按文中结构运行。

Go Validator 实战:结构体校验、自定义规则与错误翻译

本文以 Go 1.26.4 和稳定版 github.com/go-playground/validator/v10 v10.27.0 为基准。Validator 根据结构体 tag、类型元数据和注册规则检查内存中的值,适合 required、长度、范围、格式、集合元素和简单跨字段关系。用户名是否唯一、当前用户是否有权修改、订单能否转态等依赖外部状态的判断属于 service/领域层,并由数据库约束兜底。

校验不是“越多 tag 越安全”。可靠边界需要先限制 HTTP body 和 JSON 结构,再执行 DTO 校验,最后做业务授权与事务约束;每层返回稳定错误码,不把 Go 结构体名、英文 tag 或内部字段直接暴露给客户端。

1. 校验在请求流水线中的位置

推荐顺序是:限制 body 字节数、解析 Content-Type、严格 JSON 解码、确认只有一个 JSON 值、执行结构校验、映射错误、调用业务服务。认证通常在读取大 body 前完成,授权则可能需要已经解析出的资源 ID。

HTTP limits -> decode DTO -> validator.Struct -> field errors
            -> service rules -> database unique/check/foreign key

Validator 只能看到传入的 Go 值。它不知道 JSON 是否出现过重复键,不知道 body 是否超限,也不知道另一个并发请求即将插入相同邮箱。把它当作第一道快速反馈,而不是最终数据完整性证明。

2. 固定版本并创建长期实例

安装时固定主版本路径和补丁版本:

go get github.com/go-playground/validator/v10@v10.27.0
go mod tidy
go list -m github.com/go-playground/validator/v10

实例会缓存已经解析的结构体类型信息,应在启动阶段创建、注册全部规则后长期复用。不要每请求 validator.New(),也不要处理流量时修改注册表。WithRequiredStructEnabled 让非指针嵌套结构体的 required 采用 v11 预期语义,新增项目应显式打开并用测试固定。

validate := validator.New(validator.WithRequiredStructEnabled())
validate.RegisterTagNameFunc(func(field reflect.StructField) string {
	name := strings.SplitN(field.Tag.Get("json"), ",", 2)[0]
	if name == "-" {
		return ""
	}
	return name
})

构造过程可能因错误别名或无效注册返回 error,启动函数应包装并返回。Validator 的注册方法通常不是并发热更新 API;完成构造后再发布给 Handler。

3. Tag 解析和元数据缓存机制

第一次校验某个 reflect.Type 时,Validator 反射字段、解析 validate tag,把规则链、参数、命名和嵌套信息缓存下来。之后同类型实例复用缓存,因此稳态开销远低于每次重新解析,但值读取和具体规则仍会执行。

规则用逗号串联,通常是 AND;omitempty 在字段为空时跳过后续规则;竖线表达 OR。规则顺序会影响首先报告哪个错误和花费多少,应把便宜、基础的条件放前面。

type CreateArticle struct {
	Title    string   `json:"title" validate:"required,min=2,max=80"`
	Category string   `json:"category" validate:"required,oneof=go database ops"`
	Tags     []string `json:"tags" validate:"max=10,dive,required,max=24"`
	URL      string   `json:"url" validate:"omitempty,http_url"`
}

Tag 是代码的一部分,不是运行时配置语言。无法解析或引用不存在字段可能 panic,必须在测试和启动自检中覆盖所有 DTO,不能让第一条生产请求触发。

4. required、零值与类型语义

required 判断类型的默认值:字符串非空、指针/接口/map/slice 非 nil,数值在相应配置下要符合非零语义。业务上允许数字 0 时不要写 required,而应根据范围写 gte=0。空 slice 与 nil slice 都可能需要不同协议语义,Validator 不替 API 设计者决定。

type PriceInput struct {
	Currency string `json:"currency" validate:"required,iso4217"`
	Cents    int64  `json:"cents" validate:"gte=0,lte=100000000"`
	Note     *string `json:"note" validate:"omitempty,max=200"`
}

指针可区分“字段缺失/null”和具体零值,但 JSON 的缺失与显式 null 都会得到 nil;若协议必须区分三态,需要自定义 Optional 类型或先解析 token。不要仅为通过 required 把所有字段改成指针,这会把 nil 处理扩散到业务层。

6. slice、map 与 dive

dive 把后续规则应用到集合元素;map 可用 keys ... endkeys 校验键。先限制集合长度,再 dive,避免攻击者用大量元素放大 CPU 和错误响应。

type Batch struct {
	IDs   []string          `json:"ids" validate:"required,max=100,dive,uuid4"`
	Attrs map[string]string `json:"attrs" validate:"max=20,dive,keys,printascii,max=32,endkeys,max=128"`
}

嵌套路径会出现在 StructNamespace/Namespace 中,例如 Batch.IDs[3]。客户端字段路径应转换为 ids[3],并限制返回错误数;一次返回十万条错误既浪费资源,也不帮助用户。map 遍历顺序不稳定,如响应要求确定顺序,应在映射后排序。

元素是结构体或指针时,dive 会继续按其 tag 校验。nil 元素是否允许要显式写规则,不能依赖偶然默认值。

7. 跨字段和结构体级校验

eqfieldgtefield 等比较同一结构体字段;eqcsfield 等可跨嵌套层级。它们适合密码确认、开始结束范围等纯内存关系。复杂条件使用 struct-level validation,比把逻辑压进难读 tag 更清楚。

type Window struct {
	Start time.Time `json:"start" validate:"required"`
	End   time.Time `json:"end" validate:"required,gtfield=Start"`
}

func validatePublish(sl validator.StructLevel) {
	input := sl.Current().Interface().(PublishInput)
	if input.Immediate && input.PublishAt != nil {
		sl.ReportError(input.PublishAt, "publish_at", "PublishAt", "excluded_if", "immediate")
	}
}

注册时具体类型必须匹配,并在测试覆盖指针和值形式。结构体级函数保持纯计算,不查数据库、不发 HTTP、不打印日志。它没有请求总预算、重试和事务语义,把 I/O 放进去会让校验延迟不可控。

8. 自定义字段规则与 ctx

自定义规则适合项目稳定格式,例如文章 slug。注册名是公共 tag 契约,函数应无副作用、可并发调用、对任意输入不 panic。

var slugPattern = regexp.MustCompile(`^[a-z0-9]+(?:-[a-z0-9]+)*$`)

func validSlug(field validator.FieldLevel) bool {
	value, ok := field.Field().Interface().(string)
	return ok && len(value) <= 80 && slugPattern.MatchString(value)
}

if err := validate.RegisterValidation("article_slug", validSlug); err != nil {
	return fmt.Errorf("register article_slug validation: %w", err)
}

库也提供 RegisterValidationCtxStructCtx,规则能观察取消。但“支持 context”不代表适合查询外部服务;校验器缓存、错误类型和事务竞态仍无法解决。Context 规则可用于请求范围的纯策略或可取消 CPU 工作,真正业务检查应是显式 service 方法:CheckSlugAvailable(ctx, slug)

正则在包级预编译是确定性计算;不要根据请求动态编译。复杂正则要用长输入 Benchmark,避免灾难性回溯类风险;Go regexp 使用 RE2 语义,不会传统回溯爆炸,但输入长度和规则数量仍影响 CPU。

9. ValidationErrors 的安全解包

Struct 返回 nil、validator.ValidationErrorsInvalidValidationError 等 error。不要直接 err.(validator.ValidationErrors),错误类型或错误输入变化会 panic;使用 errors.As

func fieldErrors(err error) ([]validator.FieldError, error) {
	var validationErrors validator.ValidationErrors
	if errors.As(err, &validationErrors) {
		result := make([]validator.FieldError, len(validationErrors))
		for i := range validationErrors {
			result[i] = validationErrors[i]
		}
		return result, nil
	}
	return nil, fmt.Errorf("validate input: %w", err)
}

FieldError 提供 FieldStructFieldTagActualTagParamKindValue 和 namespace。响应不要回显 Value,它可能是密码;不要把 Param 原样作为可执行文本。别名规则下 Tag 可能是别名而 ActualTag 是具体规则,错误映射应明确依赖哪一个。

10. 稳定的 API 错误契约

客户端应依赖稳定 code 和 JSON 字段路径,而非英文 tag 文本。服务端维护允许公开的 tag 映射,未知规则返回通用 invalid,绝不把结构体内部名暴露出去。

{
  "code": "invalid_request",
  "fields": [
    {"path":"title","code":"required","message":"标题不能为空"},
    {"path":"tags[2]","code":"max_length","message":"标签最长 24 个字符"}
  ]
}

HTTP 通常返回 400 或 422,团队选定后保持一致。JSON 语法错误、未知字段、类型错误和 validator 规则错误应有可区分 code。错误列表按 path/code 排序并设置上限,日志只记录请求 ID、规则 code 和字段路径,不记录原始秘密。

11. TagNameFunc 与嵌套字段路径

RegisterTagNameFunc 可让 Field() 使用 JSON 名称,但 json:"-"、匿名嵌套、omitempty 和空 tag 都需处理。JSON 名只是叶子字段,完整路径仍要解析 namespace;直接对字符串做全局替换很脆弱。

更稳妥的方法是在应用启动时基于 DTO 类型构建 Go 字段到 JSON 字段的映射,或使用库提供的命名结果并专门测试嵌套 slice/map。内部 StructNamespace 保留在诊断测试,对外只发布经过白名单转换的 path。

validate.RegisterTagNameFunc(func(field reflect.StructField) string {
	tag := field.Tag.Get("json")
	name, _, _ := strings.Cut(tag, ",")
	if name == "-" {
		return ""
	}
	if name == "" {
		return field.Name
	}
	return name
})

重构 Go 字段名时,显式 JSON tag 保持协议稳定;校验测试应防止字段路径因重构意外变化。

13. PATCH 的缺失、null 与零值

创建 DTO 和更新 DTO 不应共用同一组 required。PATCH 至少有三态:字段缺失表示不改,显式 null 表示清空或拒绝,具体值包含零值。简单场景用指针只能表达缺失/null 合并后的两态;严格三态可定义 Optional[T] 记录 SetNullValue

type PatchArticle struct {
	Title  *string `json:"title" validate:"omitempty,min=2,max=80"`
	Pinned *bool   `json:"pinned"`
}

func (p PatchArticle) Apply(article *Article) {
	if p.Title != nil {
		article.Title = *p.Title
	}
	if p.Pinned != nil {
		article.Pinned = *p.Pinned
	}
}

omitempty 对 nil 跳过后续规则,但 Title: ptr("") 仍会触发 min,正是常见 PATCH 所需。Apply 前仍要授权字段级变更;不能因为 DTO 没暴露 role 就认为 Mass Assignment 已彻底解决,领域方法也要控制可修改属性。

14. 唯一性、竞态与数据库兜底

“先 SELECT 邮箱不存在,再 INSERT”存在检查到使用之间的竞态。Validator 自定义规则即使查询数据库,也无法保证下一瞬间仍唯一。正确做法是可选的友好预检查加数据库唯一索引,插入冲突映射为稳定业务错误。

CREATE UNIQUE INDEX users_email_normalized_uq
ON users (lower(email));

规范化必须与索引表达式一致;租户内唯一则索引包含 tenant_id。权限、余额、库存和状态迁移同样应在事务条件更新或约束中完成。Validator 提供快速、确定的输入反馈,数据库提供并发事实,二者职责不能互换。

外部检查必须接收 context,设置超时并定义失败策略。安全授权依赖不可用时通常 fail closed;可选推荐标签服务失败则可降级跳过。错误在最终处理边界记录一次。

15. 并发、性能和内存边界

完成全部注册后的 Validate 可并发复用。不要在请求期间调用 RegisterValidationRegisterAlias 或改变 TagNameFunc。自定义函数共享 map、regexp 或 translator 时也要满足并发安全;首选不可变数据。

性能先从限制输入和错误数量开始。大量反射通常不是 API 最大瓶颈,JSON、数据库和网络更常占主导。使用 Benchmark 分别覆盖成功、首字段失败、深层集合和自定义规则,并报告分配:

go test ./...
go test -race ./...
go test -bench=Validate -benchmem ./...
go vet ./...

不要用 sync.Pool 缓存请求 DTO,除非 profile 证明必要且每次彻底清零;残留字段可能造成越权或数据泄漏。类型缓存由库管理,业务只需复用实例。

16. 测试与故障诊断

规则共享逻辑时采用表驱动子测试,断言公开 path/code,而非完整英文错误。每个自定义规则测试边界、空值、Unicode、超长值和错误类型。对 DTO 写一个启动扫描测试,确保所有 tag 可解析,避免生产首次请求 panic。

func TestCreateArticle(t *testing.T) {
	tests := []struct {
		name     string
		give     CreateArticle
		wantTags []string
	}{
		{name: "valid", give: CreateArticle{Title: "Go context", Category: "go"}},
		{name: "missing title", give: CreateArticle{Category: "go"}, wantTags: []string{"required"}},
	}
	for _, tt := range tests {
		t.Run(tt.name, func(t *testing.T) {
			err := validate.Struct(tt.give)
			got := collectTags(err)
			if diff := cmp.Diff(tt.wantTags, got); diff != "" {
				t.Errorf("tags mismatch (-want +got):\n%s", diff)
			}
		})
	}
}

线上出现“规则未执行”时检查是否忘记 validate tag、字段是否未导出、omitempty 是否提前跳过、是否校验了错误 DTO 类型。出现 panic 时检查无效 tag、错误字段名、错误类型断言和自定义函数。出现延迟尖峰时检查集合大小、动态正则、翻译注册和被误塞进规则的 I/O。

17. 可运行的校验与错误映射

下面程序创建一次 Validator,注册 JSON 字段名和 slug 规则,把错误转换成有界、排序、无原始值的协议结果。映射层只认识允许公开的规则。

package input

import (
	"errors"
	"fmt"
	"reflect"
	"regexp"
	"sort"
	"strings"

	"github.com/go-playground/validator/v10"
)

var slugPattern = regexp.MustCompile(`^[a-z0-9]+(?:-[a-z0-9]+)*$`)

type CreateArticle struct {
	Title string   `json:"title" validate:"required,min=2,max=80"`
	Slug  string   `json:"slug" validate:"required,article_slug"`
	Tags  []string `json:"tags" validate:"max=5,dive,required,max=24"`
}

type Violation struct {
	Path string `json:"path"`
	Code string `json:"code"`
}

func NewValidator() (*validator.Validate, error) {
	validate := validator.New(validator.WithRequiredStructEnabled())
	validate.RegisterTagNameFunc(jsonName)
	if err := validate.RegisterValidation("article_slug", func(field validator.FieldLevel) bool {
		value := field.Field().String()
		return len(value) <= 80 && slugPattern.MatchString(value)
	}); err != nil {
		return nil, fmt.Errorf("register article_slug validation: %w", err)
	}
	return validate, nil
}

func ValidateArticle(validate *validator.Validate, input CreateArticle) ([]Violation, error) {
	err := validate.Struct(input)
	if err == nil {
		return nil, nil
	}
	var fieldErrors validator.ValidationErrors
	if !errors.As(err, &fieldErrors) {
		return nil, fmt.Errorf("validate article: %w", err)
	}

	violations := make([]Violation, 0, min(len(fieldErrors), 20))
	for _, fieldErr := range fieldErrors {
		if len(violations) == cap(violations) {
			break
		}
		violations = append(violations, Violation{
			Path: fieldErr.Namespace(),
			Code: publicCode(fieldErr.Tag()),
		})
	}
	sort.Slice(violations, func(i, j int) bool {
		return violations[i].Path < violations[j].Path
	})
	return violations, nil
}

func jsonName(field reflect.StructField) string {
	name, _, _ := strings.Cut(field.Tag.Get("json"), ",")
	if name == "-" {
		return ""
	}
	if name == "" {
		return field.Name
	}
	return name
}

func publicCode(tag string) string {
	switch tag {
	case "required":
		return "required"
	case "min", "max":
		return "invalid_length"
	case "article_slug":
		return "invalid_slug"
	default:
		return "invalid"
	}
}

生产版还应移除 namespace 根类型名、转换嵌套 JSON 路径,并按受支持语言把 code 翻译成展示文本;客户端仍只依赖 code。Validator 最合适的角色是并发安全、可测试的纯结构规则引擎;规则一旦需要数据库、网络、主体或事务,就应成为显式业务操作。


系列导航与关联阅读

官方资料

本文依据 Go 官方规范、标准库文档和 Go 官方博客重新梳理;正文与示例由 WR BLOG 编写。

评论

0 条讨论
0/1000
还没有评论,来聊聊你的看法
WR Blog 加载中...
返回文章
GoValidator参数校验API

Go Validator 实战:结构体校验、自定义规则与错误翻译

Go Validator 实战:结构体校验、自定义规则与错误翻译封面

Go 基础体系 · 第 80/113 篇。示例统一基于 Go 1.26.4;核心片段可能省略 package 与 import,完整程序可直接按文中结构运行。

Go Validator 实战:结构体校验、自定义规则与错误翻译

本文以 Go 1.26.4 和稳定版 github.com/go-playground/validator/v10 v10.27.0 为基准。Validator 根据结构体 tag、类型元数据和注册规则检查内存中的值,适合 required、长度、范围、格式、集合元素和简单跨字段关系。用户名是否唯一、当前用户是否有权修改、订单能否转态等依赖外部状态的判断属于 service/领域层,并由数据库约束兜底。

校验不是“越多 tag 越安全”。可靠边界需要先限制 HTTP body 和 JSON 结构,再执行 DTO 校验,最后做业务授权与事务约束;每层返回稳定错误码,不把 Go 结构体名、英文 tag 或内部字段直接暴露给客户端。

1. 校验在请求流水线中的位置

推荐顺序是:限制 body 字节数、解析 Content-Type、严格 JSON 解码、确认只有一个 JSON 值、执行结构校验、映射错误、调用业务服务。认证通常在读取大 body 前完成,授权则可能需要已经解析出的资源 ID。

HTTP limits -> decode DTO -> validator.Struct -> field errors
            -> service rules -> database unique/check/foreign key

Validator 只能看到传入的 Go 值。它不知道 JSON 是否出现过重复键,不知道 body 是否超限,也不知道另一个并发请求即将插入相同邮箱。把它当作第一道快速反馈,而不是最终数据完整性证明。

2. 固定版本并创建长期实例

安装时固定主版本路径和补丁版本:

go get github.com/go-playground/validator/v10@v10.27.0
go mod tidy
go list -m github.com/go-playground/validator/v10

实例会缓存已经解析的结构体类型信息,应在启动阶段创建、注册全部规则后长期复用。不要每请求 validator.New(),也不要处理流量时修改注册表。WithRequiredStructEnabled 让非指针嵌套结构体的 required 采用 v11 预期语义,新增项目应显式打开并用测试固定。

validate := validator.New(validator.WithRequiredStructEnabled())
validate.RegisterTagNameFunc(func(field reflect.StructField) string {
	name := strings.SplitN(field.Tag.Get("json"), ",", 2)[0]
	if name == "-" {
		return ""
	}
	return name
})

构造过程可能因错误别名或无效注册返回 error,启动函数应包装并返回。Validator 的注册方法通常不是并发热更新 API;完成构造后再发布给 Handler。

3. Tag 解析和元数据缓存机制

第一次校验某个 reflect.Type 时,Validator 反射字段、解析 validate tag,把规则链、参数、命名和嵌套信息缓存下来。之后同类型实例复用缓存,因此稳态开销远低于每次重新解析,但值读取和具体规则仍会执行。

规则用逗号串联,通常是 AND;omitempty 在字段为空时跳过后续规则;竖线表达 OR。规则顺序会影响首先报告哪个错误和花费多少,应把便宜、基础的条件放前面。

type CreateArticle struct {
	Title    string   `json:"title" validate:"required,min=2,max=80"`
	Category string   `json:"category" validate:"required,oneof=go database ops"`
	Tags     []string `json:"tags" validate:"max=10,dive,required,max=24"`
	URL      string   `json:"url" validate:"omitempty,http_url"`
}

Tag 是代码的一部分,不是运行时配置语言。无法解析或引用不存在字段可能 panic,必须在测试和启动自检中覆盖所有 DTO,不能让第一条生产请求触发。

4. required、零值与类型语义

required 判断类型的默认值:字符串非空、指针/接口/map/slice 非 nil,数值在相应配置下要符合非零语义。业务上允许数字 0 时不要写 required,而应根据范围写 gte=0。空 slice 与 nil slice 都可能需要不同协议语义,Validator 不替 API 设计者决定。

type PriceInput struct {
	Currency string `json:"currency" validate:"required,iso4217"`
	Cents    int64  `json:"cents" validate:"gte=0,lte=100000000"`
	Note     *string `json:"note" validate:"omitempty,max=200"`
}

指针可区分“字段缺失/null”和具体零值,但 JSON 的缺失与显式 null 都会得到 nil;若协议必须区分三态,需要自定义 Optional 类型或先解析 token。不要仅为通过 required 把所有字段改成指针,这会把 nil 处理扩散到业务层。

6. slice、map 与 dive

dive 把后续规则应用到集合元素;map 可用 keys ... endkeys 校验键。先限制集合长度,再 dive,避免攻击者用大量元素放大 CPU 和错误响应。

type Batch struct {
	IDs   []string          `json:"ids" validate:"required,max=100,dive,uuid4"`
	Attrs map[string]string `json:"attrs" validate:"max=20,dive,keys,printascii,max=32,endkeys,max=128"`
}

嵌套路径会出现在 StructNamespace/Namespace 中,例如 Batch.IDs[3]。客户端字段路径应转换为 ids[3],并限制返回错误数;一次返回十万条错误既浪费资源,也不帮助用户。map 遍历顺序不稳定,如响应要求确定顺序,应在映射后排序。

元素是结构体或指针时,dive 会继续按其 tag 校验。nil 元素是否允许要显式写规则,不能依赖偶然默认值。

7. 跨字段和结构体级校验

eqfieldgtefield 等比较同一结构体字段;eqcsfield 等可跨嵌套层级。它们适合密码确认、开始结束范围等纯内存关系。复杂条件使用 struct-level validation,比把逻辑压进难读 tag 更清楚。

type Window struct {
	Start time.Time `json:"start" validate:"required"`
	End   time.Time `json:"end" validate:"required,gtfield=Start"`
}

func validatePublish(sl validator.StructLevel) {
	input := sl.Current().Interface().(PublishInput)
	if input.Immediate && input.PublishAt != nil {
		sl.ReportError(input.PublishAt, "publish_at", "PublishAt", "excluded_if", "immediate")
	}
}

注册时具体类型必须匹配,并在测试覆盖指针和值形式。结构体级函数保持纯计算,不查数据库、不发 HTTP、不打印日志。它没有请求总预算、重试和事务语义,把 I/O 放进去会让校验延迟不可控。

8. 自定义字段规则与 ctx

自定义规则适合项目稳定格式,例如文章 slug。注册名是公共 tag 契约,函数应无副作用、可并发调用、对任意输入不 panic。

var slugPattern = regexp.MustCompile(`^[a-z0-9]+(?:-[a-z0-9]+)*$`)

func validSlug(field validator.FieldLevel) bool {
	value, ok := field.Field().Interface().(string)
	return ok && len(value) <= 80 && slugPattern.MatchString(value)
}

if err := validate.RegisterValidation("article_slug", validSlug); err != nil {
	return fmt.Errorf("register article_slug validation: %w", err)
}

库也提供 RegisterValidationCtxStructCtx,规则能观察取消。但“支持 context”不代表适合查询外部服务;校验器缓存、错误类型和事务竞态仍无法解决。Context 规则可用于请求范围的纯策略或可取消 CPU 工作,真正业务检查应是显式 service 方法:CheckSlugAvailable(ctx, slug)

正则在包级预编译是确定性计算;不要根据请求动态编译。复杂正则要用长输入 Benchmark,避免灾难性回溯类风险;Go regexp 使用 RE2 语义,不会传统回溯爆炸,但输入长度和规则数量仍影响 CPU。

9. ValidationErrors 的安全解包

Struct 返回 nil、validator.ValidationErrorsInvalidValidationError 等 error。不要直接 err.(validator.ValidationErrors),错误类型或错误输入变化会 panic;使用 errors.As

func fieldErrors(err error) ([]validator.FieldError, error) {
	var validationErrors validator.ValidationErrors
	if errors.As(err, &validationErrors) {
		result := make([]validator.FieldError, len(validationErrors))
		for i := range validationErrors {
			result[i] = validationErrors[i]
		}
		return result, nil
	}
	return nil, fmt.Errorf("validate input: %w", err)
}

FieldError 提供 FieldStructFieldTagActualTagParamKindValue 和 namespace。响应不要回显 Value,它可能是密码;不要把 Param 原样作为可执行文本。别名规则下 Tag 可能是别名而 ActualTag 是具体规则,错误映射应明确依赖哪一个。

10. 稳定的 API 错误契约

客户端应依赖稳定 code 和 JSON 字段路径,而非英文 tag 文本。服务端维护允许公开的 tag 映射,未知规则返回通用 invalid,绝不把结构体内部名暴露出去。

{
  "code": "invalid_request",
  "fields": [
    {"path":"title","code":"required","message":"标题不能为空"},
    {"path":"tags[2]","code":"max_length","message":"标签最长 24 个字符"}
  ]
}

HTTP 通常返回 400 或 422,团队选定后保持一致。JSON 语法错误、未知字段、类型错误和 validator 规则错误应有可区分 code。错误列表按 path/code 排序并设置上限,日志只记录请求 ID、规则 code 和字段路径,不记录原始秘密。

11. TagNameFunc 与嵌套字段路径

RegisterTagNameFunc 可让 Field() 使用 JSON 名称,但 json:"-"、匿名嵌套、omitempty 和空 tag 都需处理。JSON 名只是叶子字段,完整路径仍要解析 namespace;直接对字符串做全局替换很脆弱。

更稳妥的方法是在应用启动时基于 DTO 类型构建 Go 字段到 JSON 字段的映射,或使用库提供的命名结果并专门测试嵌套 slice/map。内部 StructNamespace 保留在诊断测试,对外只发布经过白名单转换的 path。

validate.RegisterTagNameFunc(func(field reflect.StructField) string {
	tag := field.Tag.Get("json")
	name, _, _ := strings.Cut(tag, ",")
	if name == "-" {
		return ""
	}
	if name == "" {
		return field.Name
	}
	return name
})

重构 Go 字段名时,显式 JSON tag 保持协议稳定;校验测试应防止字段路径因重构意外变化。

13. PATCH 的缺失、null 与零值

创建 DTO 和更新 DTO 不应共用同一组 required。PATCH 至少有三态:字段缺失表示不改,显式 null 表示清空或拒绝,具体值包含零值。简单场景用指针只能表达缺失/null 合并后的两态;严格三态可定义 Optional[T] 记录 SetNullValue

type PatchArticle struct {
	Title  *string `json:"title" validate:"omitempty,min=2,max=80"`
	Pinned *bool   `json:"pinned"`
}

func (p PatchArticle) Apply(article *Article) {
	if p.Title != nil {
		article.Title = *p.Title
	}
	if p.Pinned != nil {
		article.Pinned = *p.Pinned
	}
}

omitempty 对 nil 跳过后续规则,但 Title: ptr("") 仍会触发 min,正是常见 PATCH 所需。Apply 前仍要授权字段级变更;不能因为 DTO 没暴露 role 就认为 Mass Assignment 已彻底解决,领域方法也要控制可修改属性。

14. 唯一性、竞态与数据库兜底

“先 SELECT 邮箱不存在,再 INSERT”存在检查到使用之间的竞态。Validator 自定义规则即使查询数据库,也无法保证下一瞬间仍唯一。正确做法是可选的友好预检查加数据库唯一索引,插入冲突映射为稳定业务错误。

CREATE UNIQUE INDEX users_email_normalized_uq
ON users (lower(email));

规范化必须与索引表达式一致;租户内唯一则索引包含 tenant_id。权限、余额、库存和状态迁移同样应在事务条件更新或约束中完成。Validator 提供快速、确定的输入反馈,数据库提供并发事实,二者职责不能互换。

外部检查必须接收 context,设置超时并定义失败策略。安全授权依赖不可用时通常 fail closed;可选推荐标签服务失败则可降级跳过。错误在最终处理边界记录一次。

15. 并发、性能和内存边界

完成全部注册后的 Validate 可并发复用。不要在请求期间调用 RegisterValidationRegisterAlias 或改变 TagNameFunc。自定义函数共享 map、regexp 或 translator 时也要满足并发安全;首选不可变数据。

性能先从限制输入和错误数量开始。大量反射通常不是 API 最大瓶颈,JSON、数据库和网络更常占主导。使用 Benchmark 分别覆盖成功、首字段失败、深层集合和自定义规则,并报告分配:

go test ./...
go test -race ./...
go test -bench=Validate -benchmem ./...
go vet ./...

不要用 sync.Pool 缓存请求 DTO,除非 profile 证明必要且每次彻底清零;残留字段可能造成越权或数据泄漏。类型缓存由库管理,业务只需复用实例。

16. 测试与故障诊断

规则共享逻辑时采用表驱动子测试,断言公开 path/code,而非完整英文错误。每个自定义规则测试边界、空值、Unicode、超长值和错误类型。对 DTO 写一个启动扫描测试,确保所有 tag 可解析,避免生产首次请求 panic。

func TestCreateArticle(t *testing.T) {
	tests := []struct {
		name     string
		give     CreateArticle
		wantTags []string
	}{
		{name: "valid", give: CreateArticle{Title: "Go context", Category: "go"}},
		{name: "missing title", give: CreateArticle{Category: "go"}, wantTags: []string{"required"}},
	}
	for _, tt := range tests {
		t.Run(tt.name, func(t *testing.T) {
			err := validate.Struct(tt.give)
			got := collectTags(err)
			if diff := cmp.Diff(tt.wantTags, got); diff != "" {
				t.Errorf("tags mismatch (-want +got):\n%s", diff)
			}
		})
	}
}

线上出现“规则未执行”时检查是否忘记 validate tag、字段是否未导出、omitempty 是否提前跳过、是否校验了错误 DTO 类型。出现 panic 时检查无效 tag、错误字段名、错误类型断言和自定义函数。出现延迟尖峰时检查集合大小、动态正则、翻译注册和被误塞进规则的 I/O。

17. 可运行的校验与错误映射

下面程序创建一次 Validator,注册 JSON 字段名和 slug 规则,把错误转换成有界、排序、无原始值的协议结果。映射层只认识允许公开的规则。

package input

import (
	"errors"
	"fmt"
	"reflect"
	"regexp"
	"sort"
	"strings"

	"github.com/go-playground/validator/v10"
)

var slugPattern = regexp.MustCompile(`^[a-z0-9]+(?:-[a-z0-9]+)*$`)

type CreateArticle struct {
	Title string   `json:"title" validate:"required,min=2,max=80"`
	Slug  string   `json:"slug" validate:"required,article_slug"`
	Tags  []string `json:"tags" validate:"max=5,dive,required,max=24"`
}

type Violation struct {
	Path string `json:"path"`
	Code string `json:"code"`
}

func NewValidator() (*validator.Validate, error) {
	validate := validator.New(validator.WithRequiredStructEnabled())
	validate.RegisterTagNameFunc(jsonName)
	if err := validate.RegisterValidation("article_slug", func(field validator.FieldLevel) bool {
		value := field.Field().String()
		return len(value) <= 80 && slugPattern.MatchString(value)
	}); err != nil {
		return nil, fmt.Errorf("register article_slug validation: %w", err)
	}
	return validate, nil
}

func ValidateArticle(validate *validator.Validate, input CreateArticle) ([]Violation, error) {
	err := validate.Struct(input)
	if err == nil {
		return nil, nil
	}
	var fieldErrors validator.ValidationErrors
	if !errors.As(err, &fieldErrors) {
		return nil, fmt.Errorf("validate article: %w", err)
	}

	violations := make([]Violation, 0, min(len(fieldErrors), 20))
	for _, fieldErr := range fieldErrors {
		if len(violations) == cap(violations) {
			break
		}
		violations = append(violations, Violation{
			Path: fieldErr.Namespace(),
			Code: publicCode(fieldErr.Tag()),
		})
	}
	sort.Slice(violations, func(i, j int) bool {
		return violations[i].Path < violations[j].Path
	})
	return violations, nil
}

func jsonName(field reflect.StructField) string {
	name, _, _ := strings.Cut(field.Tag.Get("json"), ",")
	if name == "-" {
		return ""
	}
	if name == "" {
		return field.Name
	}
	return name
}

func publicCode(tag string) string {
	switch tag {
	case "required":
		return "required"
	case "min", "max":
		return "invalid_length"
	case "article_slug":
		return "invalid_slug"
	default:
		return "invalid"
	}
}

生产版还应移除 namespace 根类型名、转换嵌套 JSON 路径,并按受支持语言把 code 翻译成展示文本;客户端仍只依赖 code。Validator 最合适的角色是并发安全、可测试的纯结构规则引擎;规则一旦需要数据库、网络、主体或事务,就应成为显式业务操作。


系列导航与关联阅读

官方资料

本文依据 Go 官方规范、标准库文档和 Go 官方博客重新梳理;正文与示例由 WR BLOG 编写。

评论

0 条讨论
0/1000
还没有评论,来聊聊你的看法
)\n\nfunc validSlug(field validator.FieldLevel) bool {\n\tvalue, ok := field.Field().Interface().(string)\n\treturn ok && len(value) \u003c= 80 && slugPattern.MatchString(value)\n}\n\nif err := validate.RegisterValidation(\"article_slug\", validSlug); err != nil {\n\treturn fmt.Errorf(\"register article_slug validation: %w\", err)\n}\n```\n\n库也提供 `RegisterValidationCtx` 和 `StructCtx`,规则能观察取消。但“支持 context”不代表适合查询外部服务;校验器缓存、错误类型和事务竞态仍无法解决。Context 规则可用于请求范围的纯策略或可取消 CPU 工作,真正业务检查应是显式 service 方法:`CheckSlugAvailable(ctx, slug)`。\n\n正则在包级预编译是确定性计算;不要根据请求动态编译。复杂正则要用长输入 Benchmark,避免灾难性回溯类风险;Go regexp 使用 RE2 语义,不会传统回溯爆炸,但输入长度和规则数量仍影响 CPU。\n\n## 9. ValidationErrors 的安全解包\n\n`Struct` 返回 nil、`validator.ValidationErrors` 或 `InvalidValidationError` 等 error。不要直接 `err.(validator.ValidationErrors)`,错误类型或错误输入变化会 panic;使用 `errors.As`。\n\n```go\nfunc fieldErrors(err error) ([]validator.FieldError, error) {\n\tvar validationErrors validator.ValidationErrors\n\tif errors.As(err, &validationErrors) {\n\t\tresult := make([]validator.FieldError, len(validationErrors))\n\t\tfor i := range validationErrors {\n\t\t\tresult[i] = validationErrors[i]\n\t\t}\n\t\treturn result, nil\n\t}\n\treturn nil, fmt.Errorf(\"validate input: %w\", err)\n}\n```\n\n`FieldError` 提供 `Field`、`StructField`、`Tag`、`ActualTag`、`Param`、`Kind`、`Value` 和 namespace。响应不要回显 `Value`,它可能是密码;不要把 `Param` 原样作为可执行文本。别名规则下 `Tag` 可能是别名而 `ActualTag` 是具体规则,错误映射应明确依赖哪一个。\n\n## 10. 稳定的 API 错误契约\n\n客户端应依赖稳定 code 和 JSON 字段路径,而非英文 tag 文本。服务端维护允许公开的 tag 映射,未知规则返回通用 `invalid`,绝不把结构体内部名暴露出去。\n\n```json\n{\n \"code\": \"invalid_request\",\n \"fields\": [\n {\"path\":\"title\",\"code\":\"required\",\"message\":\"标题不能为空\"},\n {\"path\":\"tags[2]\",\"code\":\"max_length\",\"message\":\"标签最长 24 个字符\"}\n ]\n}\n```\n\nHTTP 通常返回 400 或 422,团队选定后保持一致。JSON 语法错误、未知字段、类型错误和 validator 规则错误应有可区分 code。错误列表按 path/code 排序并设置上限,日志只记录请求 ID、规则 code 和字段路径,不记录原始秘密。\n\n## 11. TagNameFunc 与嵌套字段路径\n\n`RegisterTagNameFunc` 可让 `Field()` 使用 JSON 名称,但 `json:\"-\"`、匿名嵌套、omitempty 和空 tag 都需处理。JSON 名只是叶子字段,完整路径仍要解析 namespace;直接对字符串做全局替换很脆弱。\n\n更稳妥的方法是在应用启动时基于 DTO 类型构建 Go 字段到 JSON 字段的映射,或使用库提供的命名结果并专门测试嵌套 slice/map。内部 `StructNamespace` 保留在诊断测试,对外只发布经过白名单转换的 path。\n\n```go\nvalidate.RegisterTagNameFunc(func(field reflect.StructField) string {\n\ttag := field.Tag.Get(\"json\")\n\tname, _, _ := strings.Cut(tag, \",\")\n\tif name == \"-\" {\n\t\treturn \"\"\n\t}\n\tif name == \"\" {\n\t\treturn field.Name\n\t}\n\treturn name\n})\n```\n\n重构 Go 字段名时,显式 JSON tag 保持协议稳定;校验测试应防止字段路径因重构意外变化。\n\n## 13. PATCH 的缺失、null 与零值\n\n创建 DTO 和更新 DTO 不应共用同一组 required。PATCH 至少有三态:字段缺失表示不改,显式 null 表示清空或拒绝,具体值包含零值。简单场景用指针只能表达缺失/null 合并后的两态;严格三态可定义 `Optional[T]` 记录 `Set`、`Null` 和 `Value`。\n\n```go\ntype PatchArticle struct {\n\tTitle *string `json:\"title\" validate:\"omitempty,min=2,max=80\"`\n\tPinned *bool `json:\"pinned\"`\n}\n\nfunc (p PatchArticle) Apply(article *Article) {\n\tif p.Title != nil {\n\t\tarticle.Title = *p.Title\n\t}\n\tif p.Pinned != nil {\n\t\tarticle.Pinned = *p.Pinned\n\t}\n}\n```\n\n`omitempty` 对 nil 跳过后续规则,但 `Title: ptr(\"\")` 仍会触发 min,正是常见 PATCH 所需。Apply 前仍要授权字段级变更;不能因为 DTO 没暴露 `role` 就认为 Mass Assignment 已彻底解决,领域方法也要控制可修改属性。\n\n## 14. 唯一性、竞态与数据库兜底\n\n“先 SELECT 邮箱不存在,再 INSERT”存在检查到使用之间的竞态。Validator 自定义规则即使查询数据库,也无法保证下一瞬间仍唯一。正确做法是可选的友好预检查加数据库唯一索引,插入冲突映射为稳定业务错误。\n\n```sql\nCREATE UNIQUE INDEX users_email_normalized_uq\nON users (lower(email));\n```\n\n规范化必须与索引表达式一致;租户内唯一则索引包含 tenant_id。权限、余额、库存和状态迁移同样应在事务条件更新或约束中完成。Validator 提供快速、确定的输入反馈,数据库提供并发事实,二者职责不能互换。\n\n外部检查必须接收 context,设置超时并定义失败策略。安全授权依赖不可用时通常 fail closed;可选推荐标签服务失败则可降级跳过。错误在最终处理边界记录一次。\n\n## 15. 并发、性能和内存边界\n\n完成全部注册后的 `Validate` 可并发复用。不要在请求期间调用 `RegisterValidation`、`RegisterAlias` 或改变 TagNameFunc。自定义函数共享 map、regexp 或 translator 时也要满足并发安全;首选不可变数据。\n\n性能先从限制输入和错误数量开始。大量反射通常不是 API 最大瓶颈,JSON、数据库和网络更常占主导。使用 Benchmark 分别覆盖成功、首字段失败、深层集合和自定义规则,并报告分配:\n\n```bash\ngo test ./...\ngo test -race ./...\ngo test -bench=Validate -benchmem ./...\ngo vet ./...\n```\n\n不要用 `sync.Pool` 缓存请求 DTO,除非 profile 证明必要且每次彻底清零;残留字段可能造成越权或数据泄漏。类型缓存由库管理,业务只需复用实例。\n\n## 16. 测试与故障诊断\n\n规则共享逻辑时采用表驱动子测试,断言公开 path/code,而非完整英文错误。每个自定义规则测试边界、空值、Unicode、超长值和错误类型。对 DTO 写一个启动扫描测试,确保所有 tag 可解析,避免生产首次请求 panic。\n\n```go\nfunc TestCreateArticle(t *testing.T) {\n\ttests := []struct {\n\t\tname string\n\t\tgive CreateArticle\n\t\twantTags []string\n\t}{\n\t\t{name: \"valid\", give: CreateArticle{Title: \"Go context\", Category: \"go\"}},\n\t\t{name: \"missing title\", give: CreateArticle{Category: \"go\"}, wantTags: []string{\"required\"}},\n\t}\n\tfor _, tt := range tests {\n\t\tt.Run(tt.name, func(t *testing.T) {\n\t\t\terr := validate.Struct(tt.give)\n\t\t\tgot := collectTags(err)\n\t\t\tif diff := cmp.Diff(tt.wantTags, got); diff != \"\" {\n\t\t\t\tt.Errorf(\"tags mismatch (-want +got):\\n%s\", diff)\n\t\t\t}\n\t\t})\n\t}\n}\n```\n\n线上出现“规则未执行”时检查是否忘记 `validate` tag、字段是否未导出、`omitempty` 是否提前跳过、是否校验了错误 DTO 类型。出现 panic 时检查无效 tag、错误字段名、错误类型断言和自定义函数。出现延迟尖峰时检查集合大小、动态正则、翻译注册和被误塞进规则的 I/O。\n\n## 17. 可运行的校验与错误映射\n\n下面程序创建一次 Validator,注册 JSON 字段名和 slug 规则,把错误转换成有界、排序、无原始值的协议结果。映射层只认识允许公开的规则。\n\n```go\npackage input\n\nimport (\n\t\"errors\"\n\t\"fmt\"\n\t\"reflect\"\n\t\"regexp\"\n\t\"sort\"\n\t\"strings\"\n\n\t\"github.com/go-playground/validator/v10\"\n)\n\nvar slugPattern = regexp.MustCompile(`^[a-z0-9]+(?:-[a-z0-9]+)* Go Validator 实战:结构体校验、自定义规则与错误翻译 - WR Blog
WR Blog 加载中...
返回文章
GoValidator参数校验API

Go Validator 实战:结构体校验、自定义规则与错误翻译

Go Validator 实战:结构体校验、自定义规则与错误翻译封面

Go 基础体系 · 第 80/113 篇。示例统一基于 Go 1.26.4;核心片段可能省略 package 与 import,完整程序可直接按文中结构运行。

Go Validator 实战:结构体校验、自定义规则与错误翻译

本文以 Go 1.26.4 和稳定版 github.com/go-playground/validator/v10 v10.27.0 为基准。Validator 根据结构体 tag、类型元数据和注册规则检查内存中的值,适合 required、长度、范围、格式、集合元素和简单跨字段关系。用户名是否唯一、当前用户是否有权修改、订单能否转态等依赖外部状态的判断属于 service/领域层,并由数据库约束兜底。

校验不是“越多 tag 越安全”。可靠边界需要先限制 HTTP body 和 JSON 结构,再执行 DTO 校验,最后做业务授权与事务约束;每层返回稳定错误码,不把 Go 结构体名、英文 tag 或内部字段直接暴露给客户端。

1. 校验在请求流水线中的位置

推荐顺序是:限制 body 字节数、解析 Content-Type、严格 JSON 解码、确认只有一个 JSON 值、执行结构校验、映射错误、调用业务服务。认证通常在读取大 body 前完成,授权则可能需要已经解析出的资源 ID。

HTTP limits -> decode DTO -> validator.Struct -> field errors
            -> service rules -> database unique/check/foreign key

Validator 只能看到传入的 Go 值。它不知道 JSON 是否出现过重复键,不知道 body 是否超限,也不知道另一个并发请求即将插入相同邮箱。把它当作第一道快速反馈,而不是最终数据完整性证明。

2. 固定版本并创建长期实例

安装时固定主版本路径和补丁版本:

go get github.com/go-playground/validator/v10@v10.27.0
go mod tidy
go list -m github.com/go-playground/validator/v10

实例会缓存已经解析的结构体类型信息,应在启动阶段创建、注册全部规则后长期复用。不要每请求 validator.New(),也不要处理流量时修改注册表。WithRequiredStructEnabled 让非指针嵌套结构体的 required 采用 v11 预期语义,新增项目应显式打开并用测试固定。

validate := validator.New(validator.WithRequiredStructEnabled())
validate.RegisterTagNameFunc(func(field reflect.StructField) string {
	name := strings.SplitN(field.Tag.Get("json"), ",", 2)[0]
	if name == "-" {
		return ""
	}
	return name
})

构造过程可能因错误别名或无效注册返回 error,启动函数应包装并返回。Validator 的注册方法通常不是并发热更新 API;完成构造后再发布给 Handler。

3. Tag 解析和元数据缓存机制

第一次校验某个 reflect.Type 时,Validator 反射字段、解析 validate tag,把规则链、参数、命名和嵌套信息缓存下来。之后同类型实例复用缓存,因此稳态开销远低于每次重新解析,但值读取和具体规则仍会执行。

规则用逗号串联,通常是 AND;omitempty 在字段为空时跳过后续规则;竖线表达 OR。规则顺序会影响首先报告哪个错误和花费多少,应把便宜、基础的条件放前面。

type CreateArticle struct {
	Title    string   `json:"title" validate:"required,min=2,max=80"`
	Category string   `json:"category" validate:"required,oneof=go database ops"`
	Tags     []string `json:"tags" validate:"max=10,dive,required,max=24"`
	URL      string   `json:"url" validate:"omitempty,http_url"`
}

Tag 是代码的一部分,不是运行时配置语言。无法解析或引用不存在字段可能 panic,必须在测试和启动自检中覆盖所有 DTO,不能让第一条生产请求触发。

4. required、零值与类型语义

required 判断类型的默认值:字符串非空、指针/接口/map/slice 非 nil,数值在相应配置下要符合非零语义。业务上允许数字 0 时不要写 required,而应根据范围写 gte=0。空 slice 与 nil slice 都可能需要不同协议语义,Validator 不替 API 设计者决定。

type PriceInput struct {
	Currency string `json:"currency" validate:"required,iso4217"`
	Cents    int64  `json:"cents" validate:"gte=0,lte=100000000"`
	Note     *string `json:"note" validate:"omitempty,max=200"`
}

指针可区分“字段缺失/null”和具体零值,但 JSON 的缺失与显式 null 都会得到 nil;若协议必须区分三态,需要自定义 Optional 类型或先解析 token。不要仅为通过 required 把所有字段改成指针,这会把 nil 处理扩散到业务层。

6. slice、map 与 dive

dive 把后续规则应用到集合元素;map 可用 keys ... endkeys 校验键。先限制集合长度,再 dive,避免攻击者用大量元素放大 CPU 和错误响应。

type Batch struct {
	IDs   []string          `json:"ids" validate:"required,max=100,dive,uuid4"`
	Attrs map[string]string `json:"attrs" validate:"max=20,dive,keys,printascii,max=32,endkeys,max=128"`
}

嵌套路径会出现在 StructNamespace/Namespace 中,例如 Batch.IDs[3]。客户端字段路径应转换为 ids[3],并限制返回错误数;一次返回十万条错误既浪费资源,也不帮助用户。map 遍历顺序不稳定,如响应要求确定顺序,应在映射后排序。

元素是结构体或指针时,dive 会继续按其 tag 校验。nil 元素是否允许要显式写规则,不能依赖偶然默认值。

7. 跨字段和结构体级校验

eqfieldgtefield 等比较同一结构体字段;eqcsfield 等可跨嵌套层级。它们适合密码确认、开始结束范围等纯内存关系。复杂条件使用 struct-level validation,比把逻辑压进难读 tag 更清楚。

type Window struct {
	Start time.Time `json:"start" validate:"required"`
	End   time.Time `json:"end" validate:"required,gtfield=Start"`
}

func validatePublish(sl validator.StructLevel) {
	input := sl.Current().Interface().(PublishInput)
	if input.Immediate && input.PublishAt != nil {
		sl.ReportError(input.PublishAt, "publish_at", "PublishAt", "excluded_if", "immediate")
	}
}

注册时具体类型必须匹配,并在测试覆盖指针和值形式。结构体级函数保持纯计算,不查数据库、不发 HTTP、不打印日志。它没有请求总预算、重试和事务语义,把 I/O 放进去会让校验延迟不可控。

8. 自定义字段规则与 ctx

自定义规则适合项目稳定格式,例如文章 slug。注册名是公共 tag 契约,函数应无副作用、可并发调用、对任意输入不 panic。

var slugPattern = regexp.MustCompile(`^[a-z0-9]+(?:-[a-z0-9]+)*$`)

func validSlug(field validator.FieldLevel) bool {
	value, ok := field.Field().Interface().(string)
	return ok && len(value) <= 80 && slugPattern.MatchString(value)
}

if err := validate.RegisterValidation("article_slug", validSlug); err != nil {
	return fmt.Errorf("register article_slug validation: %w", err)
}

库也提供 RegisterValidationCtxStructCtx,规则能观察取消。但“支持 context”不代表适合查询外部服务;校验器缓存、错误类型和事务竞态仍无法解决。Context 规则可用于请求范围的纯策略或可取消 CPU 工作,真正业务检查应是显式 service 方法:CheckSlugAvailable(ctx, slug)

正则在包级预编译是确定性计算;不要根据请求动态编译。复杂正则要用长输入 Benchmark,避免灾难性回溯类风险;Go regexp 使用 RE2 语义,不会传统回溯爆炸,但输入长度和规则数量仍影响 CPU。

9. ValidationErrors 的安全解包

Struct 返回 nil、validator.ValidationErrorsInvalidValidationError 等 error。不要直接 err.(validator.ValidationErrors),错误类型或错误输入变化会 panic;使用 errors.As

func fieldErrors(err error) ([]validator.FieldError, error) {
	var validationErrors validator.ValidationErrors
	if errors.As(err, &validationErrors) {
		result := make([]validator.FieldError, len(validationErrors))
		for i := range validationErrors {
			result[i] = validationErrors[i]
		}
		return result, nil
	}
	return nil, fmt.Errorf("validate input: %w", err)
}

FieldError 提供 FieldStructFieldTagActualTagParamKindValue 和 namespace。响应不要回显 Value,它可能是密码;不要把 Param 原样作为可执行文本。别名规则下 Tag 可能是别名而 ActualTag 是具体规则,错误映射应明确依赖哪一个。

10. 稳定的 API 错误契约

客户端应依赖稳定 code 和 JSON 字段路径,而非英文 tag 文本。服务端维护允许公开的 tag 映射,未知规则返回通用 invalid,绝不把结构体内部名暴露出去。

{
  "code": "invalid_request",
  "fields": [
    {"path":"title","code":"required","message":"标题不能为空"},
    {"path":"tags[2]","code":"max_length","message":"标签最长 24 个字符"}
  ]
}

HTTP 通常返回 400 或 422,团队选定后保持一致。JSON 语法错误、未知字段、类型错误和 validator 规则错误应有可区分 code。错误列表按 path/code 排序并设置上限,日志只记录请求 ID、规则 code 和字段路径,不记录原始秘密。

11. TagNameFunc 与嵌套字段路径

RegisterTagNameFunc 可让 Field() 使用 JSON 名称,但 json:"-"、匿名嵌套、omitempty 和空 tag 都需处理。JSON 名只是叶子字段,完整路径仍要解析 namespace;直接对字符串做全局替换很脆弱。

更稳妥的方法是在应用启动时基于 DTO 类型构建 Go 字段到 JSON 字段的映射,或使用库提供的命名结果并专门测试嵌套 slice/map。内部 StructNamespace 保留在诊断测试,对外只发布经过白名单转换的 path。

validate.RegisterTagNameFunc(func(field reflect.StructField) string {
	tag := field.Tag.Get("json")
	name, _, _ := strings.Cut(tag, ",")
	if name == "-" {
		return ""
	}
	if name == "" {
		return field.Name
	}
	return name
})

重构 Go 字段名时,显式 JSON tag 保持协议稳定;校验测试应防止字段路径因重构意外变化。

13. PATCH 的缺失、null 与零值

创建 DTO 和更新 DTO 不应共用同一组 required。PATCH 至少有三态:字段缺失表示不改,显式 null 表示清空或拒绝,具体值包含零值。简单场景用指针只能表达缺失/null 合并后的两态;严格三态可定义 Optional[T] 记录 SetNullValue

type PatchArticle struct {
	Title  *string `json:"title" validate:"omitempty,min=2,max=80"`
	Pinned *bool   `json:"pinned"`
}

func (p PatchArticle) Apply(article *Article) {
	if p.Title != nil {
		article.Title = *p.Title
	}
	if p.Pinned != nil {
		article.Pinned = *p.Pinned
	}
}

omitempty 对 nil 跳过后续规则,但 Title: ptr("") 仍会触发 min,正是常见 PATCH 所需。Apply 前仍要授权字段级变更;不能因为 DTO 没暴露 role 就认为 Mass Assignment 已彻底解决,领域方法也要控制可修改属性。

14. 唯一性、竞态与数据库兜底

“先 SELECT 邮箱不存在,再 INSERT”存在检查到使用之间的竞态。Validator 自定义规则即使查询数据库,也无法保证下一瞬间仍唯一。正确做法是可选的友好预检查加数据库唯一索引,插入冲突映射为稳定业务错误。

CREATE UNIQUE INDEX users_email_normalized_uq
ON users (lower(email));

规范化必须与索引表达式一致;租户内唯一则索引包含 tenant_id。权限、余额、库存和状态迁移同样应在事务条件更新或约束中完成。Validator 提供快速、确定的输入反馈,数据库提供并发事实,二者职责不能互换。

外部检查必须接收 context,设置超时并定义失败策略。安全授权依赖不可用时通常 fail closed;可选推荐标签服务失败则可降级跳过。错误在最终处理边界记录一次。

15. 并发、性能和内存边界

完成全部注册后的 Validate 可并发复用。不要在请求期间调用 RegisterValidationRegisterAlias 或改变 TagNameFunc。自定义函数共享 map、regexp 或 translator 时也要满足并发安全;首选不可变数据。

性能先从限制输入和错误数量开始。大量反射通常不是 API 最大瓶颈,JSON、数据库和网络更常占主导。使用 Benchmark 分别覆盖成功、首字段失败、深层集合和自定义规则,并报告分配:

go test ./...
go test -race ./...
go test -bench=Validate -benchmem ./...
go vet ./...

不要用 sync.Pool 缓存请求 DTO,除非 profile 证明必要且每次彻底清零;残留字段可能造成越权或数据泄漏。类型缓存由库管理,业务只需复用实例。

16. 测试与故障诊断

规则共享逻辑时采用表驱动子测试,断言公开 path/code,而非完整英文错误。每个自定义规则测试边界、空值、Unicode、超长值和错误类型。对 DTO 写一个启动扫描测试,确保所有 tag 可解析,避免生产首次请求 panic。

func TestCreateArticle(t *testing.T) {
	tests := []struct {
		name     string
		give     CreateArticle
		wantTags []string
	}{
		{name: "valid", give: CreateArticle{Title: "Go context", Category: "go"}},
		{name: "missing title", give: CreateArticle{Category: "go"}, wantTags: []string{"required"}},
	}
	for _, tt := range tests {
		t.Run(tt.name, func(t *testing.T) {
			err := validate.Struct(tt.give)
			got := collectTags(err)
			if diff := cmp.Diff(tt.wantTags, got); diff != "" {
				t.Errorf("tags mismatch (-want +got):\n%s", diff)
			}
		})
	}
}

线上出现“规则未执行”时检查是否忘记 validate tag、字段是否未导出、omitempty 是否提前跳过、是否校验了错误 DTO 类型。出现 panic 时检查无效 tag、错误字段名、错误类型断言和自定义函数。出现延迟尖峰时检查集合大小、动态正则、翻译注册和被误塞进规则的 I/O。

17. 可运行的校验与错误映射

下面程序创建一次 Validator,注册 JSON 字段名和 slug 规则,把错误转换成有界、排序、无原始值的协议结果。映射层只认识允许公开的规则。

package input

import (
	"errors"
	"fmt"
	"reflect"
	"regexp"
	"sort"
	"strings"

	"github.com/go-playground/validator/v10"
)

var slugPattern = regexp.MustCompile(`^[a-z0-9]+(?:-[a-z0-9]+)*$`)

type CreateArticle struct {
	Title string   `json:"title" validate:"required,min=2,max=80"`
	Slug  string   `json:"slug" validate:"required,article_slug"`
	Tags  []string `json:"tags" validate:"max=5,dive,required,max=24"`
}

type Violation struct {
	Path string `json:"path"`
	Code string `json:"code"`
}

func NewValidator() (*validator.Validate, error) {
	validate := validator.New(validator.WithRequiredStructEnabled())
	validate.RegisterTagNameFunc(jsonName)
	if err := validate.RegisterValidation("article_slug", func(field validator.FieldLevel) bool {
		value := field.Field().String()
		return len(value) <= 80 && slugPattern.MatchString(value)
	}); err != nil {
		return nil, fmt.Errorf("register article_slug validation: %w", err)
	}
	return validate, nil
}

func ValidateArticle(validate *validator.Validate, input CreateArticle) ([]Violation, error) {
	err := validate.Struct(input)
	if err == nil {
		return nil, nil
	}
	var fieldErrors validator.ValidationErrors
	if !errors.As(err, &fieldErrors) {
		return nil, fmt.Errorf("validate article: %w", err)
	}

	violations := make([]Violation, 0, min(len(fieldErrors), 20))
	for _, fieldErr := range fieldErrors {
		if len(violations) == cap(violations) {
			break
		}
		violations = append(violations, Violation{
			Path: fieldErr.Namespace(),
			Code: publicCode(fieldErr.Tag()),
		})
	}
	sort.Slice(violations, func(i, j int) bool {
		return violations[i].Path < violations[j].Path
	})
	return violations, nil
}

func jsonName(field reflect.StructField) string {
	name, _, _ := strings.Cut(field.Tag.Get("json"), ",")
	if name == "-" {
		return ""
	}
	if name == "" {
		return field.Name
	}
	return name
}

func publicCode(tag string) string {
	switch tag {
	case "required":
		return "required"
	case "min", "max":
		return "invalid_length"
	case "article_slug":
		return "invalid_slug"
	default:
		return "invalid"
	}
}

生产版还应移除 namespace 根类型名、转换嵌套 JSON 路径,并按受支持语言把 code 翻译成展示文本;客户端仍只依赖 code。Validator 最合适的角色是并发安全、可测试的纯结构规则引擎;规则一旦需要数据库、网络、主体或事务,就应成为显式业务操作。


系列导航与关联阅读

官方资料

本文依据 Go 官方规范、标准库文档和 Go 官方博客重新梳理;正文与示例由 WR BLOG 编写。

评论

0 条讨论
0/1000
还没有评论,来聊聊你的看法
)\n\ntype CreateArticle struct {\n\tTitle string `json:\"title\" validate:\"required,min=2,max=80\"`\n\tSlug string `json:\"slug\" validate:\"required,article_slug\"`\n\tTags []string `json:\"tags\" validate:\"max=5,dive,required,max=24\"`\n}\n\ntype Violation struct {\n\tPath string `json:\"path\"`\n\tCode string `json:\"code\"`\n}\n\nfunc NewValidator() (*validator.Validate, error) {\n\tvalidate := validator.New(validator.WithRequiredStructEnabled())\n\tvalidate.RegisterTagNameFunc(jsonName)\n\tif err := validate.RegisterValidation(\"article_slug\", func(field validator.FieldLevel) bool {\n\t\tvalue := field.Field().String()\n\t\treturn len(value) \u003c= 80 && slugPattern.MatchString(value)\n\t}); err != nil {\n\t\treturn nil, fmt.Errorf(\"register article_slug validation: %w\", err)\n\t}\n\treturn validate, nil\n}\n\nfunc ValidateArticle(validate *validator.Validate, input CreateArticle) ([]Violation, error) {\n\terr := validate.Struct(input)\n\tif err == nil {\n\t\treturn nil, nil\n\t}\n\tvar fieldErrors validator.ValidationErrors\n\tif !errors.As(err, &fieldErrors) {\n\t\treturn nil, fmt.Errorf(\"validate article: %w\", err)\n\t}\n\n\tviolations := make([]Violation, 0, min(len(fieldErrors), 20))\n\tfor _, fieldErr := range fieldErrors {\n\t\tif len(violations) == cap(violations) {\n\t\t\tbreak\n\t\t}\n\t\tviolations = append(violations, Violation{\n\t\t\tPath: fieldErr.Namespace(),\n\t\t\tCode: publicCode(fieldErr.Tag()),\n\t\t})\n\t}\n\tsort.Slice(violations, func(i, j int) bool {\n\t\treturn violations[i].Path \u003c violations[j].Path\n\t})\n\treturn violations, nil\n}\n\nfunc jsonName(field reflect.StructField) string {\n\tname, _, _ := strings.Cut(field.Tag.Get(\"json\"), \",\")\n\tif name == \"-\" {\n\t\treturn \"\"\n\t}\n\tif name == \"\" {\n\t\treturn field.Name\n\t}\n\treturn name\n}\n\nfunc publicCode(tag string) string {\n\tswitch tag {\n\tcase \"required\":\n\t\treturn \"required\"\n\tcase \"min\", \"max\":\n\t\treturn \"invalid_length\"\n\tcase \"article_slug\":\n\t\treturn \"invalid_slug\"\n\tdefault:\n\t\treturn \"invalid\"\n\t}\n}\n```\n\n生产版还应移除 namespace 根类型名、转换嵌套 JSON 路径,并按受支持语言把 code 翻译成展示文本;客户端仍只依赖 code。Validator 最合适的角色是并发安全、可测试的纯结构规则引擎;规则一旦需要数据库、网络、主体或事务,就应成为显式业务操作。\n\n---\n\n## 系列导航与关联阅读\n\n- 系列入口:[Go 完整技术体系学习路线:从语法、并发到框架、中间件与 AI](https://wrblog.cn/articles/2b61d3c4-fff1-55ee-99f3-d43c40e72e21)\n- 上一篇:[Go Zap 与 Zerolog:高性能结构化日志、字段和采样](https://wrblog.cn/articles/c8b2e61d-d6d6-5c2f-a05d-b4d699e00064)\n- 下一篇:[Go Resty HTTP 客户端:请求封装、重试、认证与可观测性](https://wrblog.cn/articles/2139fd7a-96f4-5031-af93-9b855d0703e4)\n- 延伸:[Go Gin 完整入门:路由、参数绑定、中间件与优雅关闭](https://wrblog.cn/articles/7357c148-0ca0-5f0e-8a3f-096229af7902)\n- 延伸:[Go Echo 实战:路由分组、Binder、Middleware 与错误处理](https://wrblog.cn/articles/4616e6ec-8230-5159-b048-384a422acde1)\n- 延伸:[Go 错误处理:包装、errors.Is/As、panic 与 recover 边界](https://wrblog.cn/articles/71754e75-78f4-53d5-ac1e-ab0d26f4e7e6)\n- 延伸:[Go OpenAPI 与 Swagger:契约优先、代码生成和接口文档治理](https://wrblog.cn/articles/aac69a1a-4fdf-533e-bd17-6a1172f72d6c)\n\n## 官方资料\n\n- [go-playground/validator package](https://pkg.go.dev/github.com/go-playground/validator/v10)\n- [validator repository](https://github.com/go-playground/validator)\n\n> 本文依据 Go 官方规范、标准库文档和 Go 官方博客重新梳理;正文与示例由 WR BLOG 编写。\n","tags":["Go","Validator","参数校验","API"],"likeCount":0,"commentCount":0,"createdByUserId":"10000000000","createdByDisplayName":"小郝","createdByAvatar":"/public/profile/10000000000/avatar/2026/08/04/db02b81c-42f2-441b-8a80-61370cdbb581.webp","publishTime":"2026-08-31 10:18:44","updateTime":"2026-09-01 13:19:19"}},"status":200,"locale":"zh-CN","theme":"light"}