GO~如何自定义github.com/go-playground/validator/v10字段验证错误信息

2025 年 9 月 7 日 星期日(已编辑)
/
3

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)
            }
        })
    }
}

总结

通过本文介绍的方法,我们实现了:

  1. 友好的错误信息:将技术性的验证错误转换为用户友好的提示
  2. 支持嵌套结构:可以验证复杂的嵌套对象和数组
  3. 灵活的自定义:通过 msg tag 可以完全自定义错误信息
  4. 可扩展性强:易于添加新的验证规则和错误信息模板
  5. 与框架集成:与 Echo 框架无缝集成,使用简单

这套方案在生产环境中经过验证,可以大幅提升 API 的用户体验。你可以根据项目需求进一步扩展和优化。

参考资源


使用社交账号登录

  • Loading...
  • Loading...
  • Loading...
  • Loading...
  • Loading...