GO~如何自定义github.com/go-playground/validator/v10字段验证错误信息
前言
在 Go Web 开发中,数据验证是一个必不可少的环节。github.com/go-playground/validator/v10 是 Go 生态中最流行的验证库之一,但其默认的错误信息对用户并不友好。本文将详细介绍如何自定义验证错误信息,让你的 API 返回更加友好、易懂的错误提示。
问题场景
使用原生 validator 时,错误信息通常是这样的:
Key: 'User.Email' Error:Field validation for 'Email' failed on the 'required' tag
Key: 'User.Age' Error:Field validation for 'Age' failed on the 'min' tag
这样的错误信息对开发者友好,但对最终用户来说可读性很差。我们希望的错误信息是:
email is required; age must be at least 18
完整解决方案
1. 自定义 Validator 结构
首先,我们创建一个自定义的 CustomValidator 结构体,包装 validator 实例:
package server
import (
"errors"
"fmt"
"reflect"
"strings"
"github.com/go-playground/validator/v10"
"github.com/labstack/echo/v4"
)
type CustomValidator struct {
Validator *validator.Validate
}
2. 实现核心验证逻辑
核心是实现 Validate 方法,该方法会被 Echo 框架自动调用:
func (cv *CustomValidator) Validate(req interface{}) error {
if err := cv.Validator.Struct(req); err != nil {
// 获取结构体类型信息
dataType := reflect.TypeOf(req)
if dataType.Kind() == reflect.Ptr {
dataType = dataType.Elem()
}
var errMsg []string
// 遍历所有验证错误
for _, fieldErr := range err.(validator.ValidationErrors) {
// 查找字段(支持嵌套和数组)
structField, found := cv.findNestedField(dataType, fieldErr)
if !found {
return fmt.Errorf("field %s not found", fieldErr.StructField())
}
// 解析 validate tag 的详细信息
tagDetails := parseValidateTag(structField)
// 生成友好的错误信息
em := getValidationMessage(structField, fieldErr.Tag(), tagDetails)
errMsg = append(errMsg, em)
}
if len(errMsg) != 0 {
return errors.New(strings.Join(errMsg, "; "))
}
return err
}
return nil
}
3. 支持嵌套和数组字段
在实际项目中,我们经常会遇到嵌套结构体和数组字段的验证。为了支持这些场景,需要实现递归查找字段的功能:
func (cv *CustomValidator) findNestedField(rootType reflect.Type, fieldErr validator.FieldError) (reflect.StructField, bool) {
// 首先尝试直接查找
if structField, found := rootType.FieldByName(fieldErr.StructField()); found {
return structField, true
}
// 如果没找到,解析命名空间路径
namespace := fieldErr.Namespace()
structField := fieldErr.StructField()
// 解析命名空间以找到嵌套路径
// 例如: "Product.ProductTags[0].TagName" -> ["Product", "ProductTags", "TagName"]
pathParts := cv.parseNamespacePath(namespace, structField)
if len(pathParts) <= 1 {
return reflect.StructField{}, false
}
// 通过嵌套结构导航
return cv.findFieldByPath(rootType, pathParts[1:]) // 跳过根结构体名称
}
func (cv *CustomValidator) findFieldByPath(rootType reflect.Type, pathParts []string) (reflect.StructField, bool) {
currentType := rootType
for i, part := range pathParts {
field, found := currentType.FieldByName(part)
if !found {
return reflect.StructField{}, false
}
// 如果这是最后一部分,返回字段
if i == len(pathParts)-1 {
return field, true
}
// 导航到下一层级
fieldType := field.Type
// 处理指针类型
if fieldType.Kind() == reflect.Ptr {
fieldType = fieldType.Elem()
}
// 处理切片/数组类型
if fieldType.Kind() == reflect.Slice || fieldType.Kind() == reflect.Array {
fieldType = fieldType.Elem()
if fieldType.Kind() == reflect.Ptr {
fieldType = fieldType.Elem()
}
}
// 必须是结构体才能继续
if fieldType.Kind() != reflect.Struct {
return reflect.StructField{}, false
}
currentType = fieldType
}
return reflect.StructField{}, false
}
4. 解析命名空间路径
处理带数组索引的命名空间(如 Product.Tags[0].Name):
func (cv *CustomValidator) parseNamespacePath(namespace, structField string) []string {
// 移除数组索引,如 [0], [1] 等
cleanNamespace := cv.removeArrayIndices(namespace)
// 通过点号分割路径组件
parts := strings.Split(cleanNamespace, ".")
// 确保 structField 包含在路径中
if len(parts) > 0 && parts[len(parts)-1] != structField {
for i, part := range parts {
if part == structField {
return parts[:i+1]
}
}
parts = append(parts, structField)
}
return parts
}
func (cv *CustomValidator) removeArrayIndices(namespace string) string {
result := ""
inBracket := false
for _, char := range namespace {
if char == '[' {
inBracket = true
} else if char == ']' {
inBracket = false
} else if !inBracket {
result += string(char)
}
}
return result
}
5. 解析 Validate Tag
解析 validate tag 中的参数信息:
func parseValidateTag(field reflect.StructField) map[string]string {
tag := field.Tag.Get("validate")
if tag == "" {
return nil
}
result := make(map[string]string)
parts := strings.Split(tag, ",")
for _, part := range parts {
if strings.Contains(part, "=") {
// 处理带参数的规则,如 min=10
pair := strings.SplitN(part, "=", 2)
if len(pair) == 2 {
result[pair[0]] = pair[1]
}
} else {
// 处理不带参数的规则,如 required
result[part] = ""
}
}
return result
}
6. 生成友好的错误信息
核心方法:根据不同的验证规则生成对应的友好提示:
func getValidationMessage(field reflect.StructField, tag string, tagDetails map[string]string) string {
// 优先使用自定义的 msg tag
if msg := field.Tag.Get("msg"); msg != "" {
return msg
}
// 获取字段名(优先使用 json tag)
fieldName := field.Tag.Get("json")
if fieldName == "" {
fieldName = field.Name
}
// 处理 json tag 中的选项(如 omitempty)
if strings.Contains(fieldName, ",") {
fields := strings.Split(fieldName, ",")
fieldName = fields[0]
}
// 获取规则参数
param := tagDetails[tag]
// 根据不同的验证规则返回友好的错误信息
switch tag {
case "required":
return fmt.Sprintf("%s is required", fieldName)
case "min":
if param != "" {
return fmt.Sprintf("%s must be at least %s", fieldName, param)
}
return fmt.Sprintf("%s value too small", fieldName)
case "max":
if param != "" {
return fmt.Sprintf("%s must be at most %s", fieldName, param)
}
return fmt.Sprintf("%s value too large", fieldName)
case "oneof":
if param != "" {
return fmt.Sprintf("%s must be one of: %s", fieldName, strings.ReplaceAll(param, " ", ", "))
}
return fmt.Sprintf("%s has invalid value", fieldName)
case "email":
return fmt.Sprintf("%s must be a valid email address", fieldName)
case "url":
return fmt.Sprintf("%s must be a valid URL", fieldName)
case "len":
if param != "" {
return fmt.Sprintf("%s must be exactly %s characters", fieldName, param)
}
return fmt.Sprintf("%s has invalid length", fieldName)
case "eq":
if param != "" {
return fmt.Sprintf("%s must be equal to %s", fieldName, param)
}
return fmt.Sprintf("%s has invalid value", fieldName)
case "ne":
if param != "" {
return fmt.Sprintf("%s must not be equal to %s", fieldName, param)
}
return fmt.Sprintf("%s has invalid value", fieldName)
case "gt":
if param != "" {
return fmt.Sprintf("%s must be greater than %s", fieldName, param)
}
return fmt.Sprintf("%s value too small", fieldName)
case "gte":
if param != "" {
return fmt.Sprintf("%s must be greater than or equal to %s", fieldName, param)
}
return fmt.Sprintf("%s value too small", fieldName)
case "lt":
if param != "" {
return fmt.Sprintf("%s must be less than %s", fieldName, param)
}
return fmt.Sprintf("%s value too large", fieldName)
case "lte":
if param != "" {
return fmt.Sprintf("%s must be less than or equal to %s", fieldName, param)
}
return fmt.Sprintf("%s value too large", fieldName)
default:
return fmt.Sprintf("%s validation failed for rule: %s", fieldName, tag)
}
}
7. 在 Echo 中注册自定义 Validator
func NewHttpServer(c *config.Config) *echo.Echo {
e := echo.New()
// 注册自定义 Validator
e.Validator = &CustomValidator{
Validator: validator.New(),
}
// 其他中间件配置...
e.Use(middleware.CORS())
e.Use(middleware.Logger())
return e
}
实际使用示例
定义请求结构体
type CreateUserRequest struct {
Username string `json:"username" validate:"required,min=3,max=20"`
Email string `json:"email" validate:"required,email"`
Age int `json:"age" validate:"required,min=18,max=120"`
Role string `json:"role" validate:"required,oneof=admin user guest"`
Website string `json:"website" validate:"omitempty,url"`
}
// 使用自定义 msg tag
type CreateProductRequest struct {
Name string `json:"name" validate:"required,min=3" msg:"Product name is required and must be at least 3 characters"`
Price float64 `json:"price" validate:"required,gt=0" msg:"Price must be greater than 0"`
}
// 嵌套结构体
type Product struct {
Name string `json:"name" validate:"required,min=3"`
Description string `json:"description" validate:"required"`
ProductTags []ProductTag `json:"product_tags" validate:"required,dive"`
}
type ProductTag struct {
TagName string `json:"tag_name" validate:"required,min=2"`
TagType string `json:"tag_type" validate:"required,oneof=category brand"`
}
在 Handler 中使用
func CreateUser(c echo.Context) error {
req := new(CreateUserRequest)
// Bind 请求数据
if err := c.Bind(req); err != nil {
return c.JSON(http.StatusBadRequest, map[string]string{
"error": "Invalid request format",
})
}
// 自动调用自定义的 Validate 方法
if err := c.Validate(req); err != nil {
return c.JSON(http.StatusBadRequest, map[string]string{
"error": err.Error(),
})
}
// 业务逻辑...
return c.JSON(http.StatusOK, map[string]string{
"message": "User created successfully",
})
}
错误信息示例
输入:
{
"username": "ab",
"email": "invalid-email",
"age": 15,
"role": "superuser"
}
输出(原生 validator):
Key: 'CreateUserRequest.Username' Error:Field validation for 'Username' failed on the 'min' tag
Key: 'CreateUserRequest.Email' Error:Field validation for 'Email' failed on the 'email' tag
Key: 'CreateUserRequest.Age' Error:Field validation for 'Age' failed on the 'min' tag
Key: 'CreateUserRequest.Role' Error:Field validation for 'Role' failed on the 'oneof' tag
输出(自定义 validator):
{
"error": "username must be at least 3; email must be a valid email address; age must be at least 18; role must be one of: admin, user, guest"
}
进阶技巧
1. 支持国际化(i18n)
你可以根据请求的语言返回不同的错误信息:
func getValidationMessage(field reflect.StructField, tag string, tagDetails map[string]string, lang string) string {
fieldName := field.Tag.Get("json")
if fieldName == "" {
fieldName = field.Name
}
param := tagDetails[tag]
if lang == "zh" {
switch tag {
case "required":
return fmt.Sprintf("%s 是必填项", fieldName)
case "min":
return fmt.Sprintf("%s 最小值为 %s", fieldName, param)
case "email":
return fmt.Sprintf("%s 必须是有效的邮箱地址", fieldName)
// ... 其他规则
}
}
// 默认返回英文
// ...
}
2. 自定义验证规则
func NewHttpServer(c *config.Config) *echo.Echo {
e := echo.New()
v := validator.New()
// 注册自定义验证规则
v.RegisterValidation("username", func(fl validator.FieldLevel) bool {
username := fl.Field().String()
// 用户名只能包含字母、数字和下划线
matched, _ := regexp.MatchString("^[a-zA-Z0-9_]+$", username)
return matched
})
e.Validator = &CustomValidator{Validator: v}
return e
}
// 在 getValidationMessage 中添加对应的错误信息
case "username":
return fmt.Sprintf("%s can only contain letters, numbers and underscores", fieldName)
3. 使用字段别名
func NewHttpServer(c *config.Config) *echo.Echo {
e := echo.New()
v := validator.New()
// 注册函数以使用 json tag 作为字段名
v.RegisterTagNameFunc(func(fld reflect.StructField) string {
name := strings.SplitN(fld.Tag.Get("json"), ",", 2)[0]
if name == "-" {
return ""
}
return name
})
e.Validator = &CustomValidator{Validator: v}
return e
}
性能优化
1. 缓存反射结果
对于高频调用的场景,可以缓存反射的结果:
type CustomValidator struct {
Validator *validator.Validate
fieldCache sync.Map // 缓存字段查找结果
}
func (cv *CustomValidator) findNestedFieldCached(rootType reflect.Type, fieldErr validator.FieldError) (reflect.StructField, bool) {
cacheKey := fmt.Sprintf("%s.%s", rootType.String(), fieldErr.Namespace())
if cached, ok := cv.fieldCache.Load(cacheKey); ok {
return cached.(reflect.StructField), true
}
field, found := cv.findNestedField(rootType, fieldErr)
if found {
cv.fieldCache.Store(cacheKey, field)
}
return field, found
}
2. 复用 Validator 实例
确保 validator.Validate 实例在整个应用生命周期中复用,避免重复创建。
测试示例
package server
import (
"testing"
"github.com/go-playground/validator/v10"
"github.com/stretchr/testify/assert"
)
func TestCustomValidator_Validate(t *testing.T) {
cv := &CustomValidator{
Validator: validator.New(),
}
type TestStruct struct {
Name string `json:"name" validate:"required,min=3"`
Email string `json:"email" validate:"required,email"`
Age int `json:"age" validate:"required,min=18"`
}
tests := []struct {
name string
input TestStruct
wantErr bool
errMsg string
}{
{
name: "valid input",
input: TestStruct{
Name: "John Doe",
Email: "john@example.com",
Age: 25,
},
wantErr: false,
},
{
name: "missing required fields",
input: TestStruct{
Name: "",
Email: "",
Age: 0,
},
wantErr: true,
errMsg: "name is required",
},
{
name: "invalid email",
input: TestStruct{
Name: "John",
Email: "invalid-email",
Age: 25,
},
wantErr: true,
errMsg: "email must be a valid email address",
},
{
name: "age below minimum",
input: TestStruct{
Name: "John",
Email: "john@example.com",
Age: 15,
},
wantErr: true,
errMsg: "age must be at least 18",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
err := cv.Validate(tt.input)
if tt.wantErr {
assert.Error(t, err)
assert.Contains(t, err.Error(), tt.errMsg)
} else {
assert.NoError(t, err)
}
})
}
}
总结
通过本文介绍的方法,我们实现了:
- ✅ 友好的错误信息:将技术性的验证错误转换为用户友好的提示
- ✅ 支持嵌套结构:可以验证复杂的嵌套对象和数组
- ✅ 灵活的自定义:通过
msgtag 可以完全自定义错误信息 - ✅ 可扩展性强:易于添加新的验证规则和错误信息模板
- ✅ 与框架集成:与 Echo 框架无缝集成,使用简单
这套方案在生产环境中经过验证,可以大幅提升 API 的用户体验。你可以根据项目需求进一步扩展和优化。