为接口做参数校验

2.5 为接口做参数校验

接下来我们将正式进行编码,在进行对应的业务模块开发时,第一步要考虑到的问题的就是如何进行入参校验,我们需要将整个项目,甚至整个团队的组件给定下来,形成一个通用规范,在今天本章节将核心介绍这一块,并完成标签模块的接口的入参校验。

2.5.1 validator 介绍

在本项目中我们将使用开源项目 go-playground/validator 作为我们的本项目的基础库,它是一个基于标签来对结构体和字段进行值验证的一个验证器。

那么,我们要单独引入这个库吗,其实不然,因为我们使用的 gin 框架,其内部的模型绑定和验证默认使用的是 go-playground/validator 来进行参数绑定和校验,使用起来非常方便。

在项目根目录下执行命令,进行安装:

$ go get -u github.com/go-playground/validator/v10

2.5.2 业务接口校验

接下来我们将正式开始对接口的入参进行校验规则的编写,也就是将校验规则写在对应的结构体的字段标签上,常见的标签含义如下:

标签 含义
required 必填
gt 大于
gte 大于等于
lt 小于
lte 小于等于
min 最小值
max 最大值
oneof 参数集内的其中之一
len 长度要求与 len 给定的一致

2.5.2.1 标签接口

我们回到项目的 internal/service 目录下的 tag.go 文件,针对入参校验增加绑定/验证结构体,在路由方法前写入如下代码:

type CountTagRequest struct {
	Name  string `form:"name" binding:"max=100"`
	State uint8 `form:"state,default=1" binding:"oneof=0 1"`
}

type TagListRequest struct {
	Name  string `form:"name" binding:"max=100"`
	State uint8  `form:"state,default=1" binding:"oneof=0 1"`
}

type CreateTagRequest struct {
	Name      string `form:"name" binding:"required,min=3,max=100"`
	CreatedBy string `form:"created_by" binding:"required,min=3,max=100"`
	State     uint8  `form:"state,default=1" binding:"oneof=0 1"`
}

type UpdateTagRequest struct {
	ID         uint32 `form:"id" binding:"required,gte=1"`
	Name       string `form:"name" binding:"min=3,max=100"`
	State      uint8  `form:"state" binding:"required,oneof=0 1"`
	ModifiedBy string `form:"modified_by" binding:"required,min=3,max=100"`
}

type DeleteTagRequest struct {
	ID uint32 `form:"id" binding:"required,gte=1"`
}

在上述代码中,我们主要针对业务接口中定义的的增删改查和统计行为进行了 Request 结构体编写,而在结构体中,应用到了两个 tag 标签,分别是 form 和 binding,它们分别代表着表单的映射字段名和入参校验的规则内容,其主要功能是实现参数绑定和参数检验。

2.5.2.2 文章接口

接下来到项目的 internal/service 目录下的 article.go 文件,针对入参校验增加绑定/验证结构体。这块与标签模块的验证规则差不多,主要是必填,长度最小、最大的限制,以及要求参数值必须在某个集合内的其中之一,因此不再赘述。

2.5.3 国际化处理

2.5.3.1 编写中间件

go-playground/validator 默认的错误信息是英文,但我们的错误信息不一定是用的英文,有可能要简体中文,做国际化的又有其它的需求,这可怎么办,在通用需求的情况下,有没有简单又省事的办法解决呢?

如果是简单的国际化需求,我们可以通过中间件配合语言包的方式去实现这个功能,接下来我们在项目的 internal/middleware 目录下新建 translations.go 文件,用于编写针对 validator 的语言包翻译的相关功能,新增如下代码:

import (
	"github.com/gin-gonic/gin"
	"github.com/gin-gonic/gin/binding"
	"github.com/go-playground/locales/en"
	"github.com/go-playground/locales/zh"
	"github.com/go-playground/locales/zh_Hant_TW"
	"github.com/go-playground/universal-translator"
	validator "github.com/go-playground/validator/v10"
	en_translations "github.com/go-playground/validator/v10/translations/en"
	zh_translations "github.com/go-playground/validator/v10/translations/zh"
)

func Translations() gin.HandlerFunc {
	return func(c *gin.Context) {
		uni := ut.New(en.New(), zh.New(), zh_Hant_TW.New())
		locale := c.GetHeader("locale")
		trans, _ := uni.GetTranslator(locale)
		v, ok := binding.Validator.Engine().(*validator.Validate)
		if ok {
			switch locale {
			case "zh":
				_ = zh_translations.RegisterDefaultTranslations(v, trans)
				break
			case "en":
				_ = en_translations.RegisterDefaultTranslations(v, trans)
				break
			default:
				_ = zh_translations.RegisterDefaultTranslations(v, trans)
				break
			}
			c.Set("trans", trans)
		}

		c.Next()
	}
}

在自定义中间件 Translations 中,我们针对 i18n 利用了第三方开源库去实现这块功能,分别如下:

  • go-playground/locales:多语言包,是从 CLDR 项目(Unicode 通用语言环境数据存储库)生成的一组多语言环境,主要在 i18n 软件包中使用,该库是与 universal-translator 配套使用的。
  • go-playground/universal-translator:通用翻译器,是一个使用 CLDR 数据 + 复数规则的 Go 语言 i18n 转换器。
  • go-playground/validator/v10/translations:validator 的翻译器。

而在识别当前请求的语言类别上,我们通过 GetHeader 方法去获取约定的 header 参数 locale,用于判别当前请求的语言类别是 en 又或是 zh,如果有其它语言环境要求,也可以继续引入其它语言类别,因为 go-playground/locales 基本上都支持。

在后续的注册步骤,我们调用 RegisterDefaultTranslations 方法将验证器和对应语言类型的 Translator 注册进来,实现验证器的多语言支持。同时将 Translator 存储到全局上下文中,便于后续翻译时的使用。

2.5.3.2 注册中间件

回到项目的 internal/routers 目录下的 router.go 文件,新增中间件 Translations 的注册,新增代码如下:

func NewRouter() *gin.Engine {
	r := gin.New()
	r.Use(gin.Logger())
	r.Use(gin.Recovery())
	r.Use(middleware.Translations())
	...
}

至此,我们就完成了在项目中的自定义验证器注册、验证器初始化、错误提示多语言的功能支持了。

2.5.4 接口校验

我们在项目下的 pkg/app 目录新建 form.go 文件,写入如下代码:

import (
	...
	ut "github.com/go-playground/universal-translator"
	val "github.com/go-playground/validator/v10"
)

type ValidError struct {
	Key     string
	Message string
}

type ValidErrors []*ValidError

func (v *ValidError) Error() string {
	return v.Message
}

func (v ValidErrors) Error() string {
	return strings.Join(v.Errors(), ",")
}

func (v ValidErrors) Errors() []string {
	var errs []string
	for _, err := range v {
		errs = append(errs, err.Error())
	}

	return errs
}

func BindAndValid(c *gin.Context, v interface{}) (bool, ValidErrors) {
	var errs ValidErrors
	err := c.ShouldBind(v)
	if err != nil {
		v := c.Value("trans")
		trans, _ := v.(ut.Translator)
		verrs, ok := err.(val.ValidationErrors)
		if !ok {
			return false, errs
		}

		for key, value := range verrs.Translate(trans) {
			errs = append(errs, &ValidError{
				Key:     key,
				Message: value,
			})
		}

		return false, errs
	}

	return true, nil
}

在上述代码中,我们主要是针对入参校验的方法进行了二次封装,在 BindAndValid 方法中,通过 ShouldBind 进行参数绑定和入参校验,当发生错误后,再通过上一步在中间件 Translations 设置的 Translator 来对错误消息体进行具体的翻译行为。

另外我们声明了 ValidError 相关的结构体和类型,对这块不熟悉的读者可能会疑惑为什么要实现其对应的 Error 方法呢,我们简单来看看标准库中 errors 的相关代码,如下:

func New(text string) error {
	return &errorString{text}
}

type errorString struct {
	s string
}

func (e *errorString) Error() string {
	return e.s
}

标准库 errors 的 New 方法实现非常简单,errorString 是一个结构体,内含一个 s 字符串,也只有一个 Error 方法,就可以认定为 error 类型,这是为什么呢?这一切的关键都在于 error 接口的定义,如下:

type error interface {
	Error() string
}

在 Go 语言中,如果一个类型实现了某个 interface 中的所有方法,那么编译器就会认为该类型实现了此 interface,它们是”一样“的。

2.5.5 验证

我们回到项目的 internal/routers/api/v1 下的 tag.go 文件,修改获取多个标签的 List 接口,用于验证 validator 是否正常,修改代码如下:

func (t Tag) List(c *gin.Context) {
	param := struct {
		Name  string `form:"name" binding:"max=100"`
		State uint8  `form:"state,default=1" binding:"oneof=0 1"`
	}{}
	response := app.NewResponse(c)
	valid, errs := app.BindAndValid(c, &param)
	if !valid {
		global.Logger.Errorf("app.BindAndValid errs: %v", errs)
		response.ToErrorResponse(errcode.InvalidParams.WithDetails(errs.Errors()...))
		return
	}

	response.ToResponse(gin.H{})
	return
}

在命令行中利用 CURL 请求该接口,查看验证结果,如下:

$ curl -X GET http://127.0.0.1:8000/api/v1/tags\?state\=6
{"code":10000001,"details":["State 必须是[0 1]中的一个"],"msg":"入参错误"}

另外你还需要注意到 TagListRequest 的校验规则里其实并没有 required,因此它的校验规则应该是有才校验,没有该入参的话,是默认无校验的,也就是没有 state 参数,也应该可以正常请求,如下:

$ curl -X GET http://127.0.0.1:8000/api/v1/tags          
{}

在 Response 中我们调用的是 gin.H 作为返回结果集,因此该输出结果正确。

2.5.6 小结

在本章节中,我们介绍了在 gin 框架中如何通过 validator 来进行参数校验,而在一些定制化场景中,我们常常需要自定义验证器,这个时候我们可以通过实现 binding.Validator 接口的方式,来替换其自身的 validator::

// binding/binding.go
type StructValidator interface {
	ValidateStruct(interface{}) error
	Engine() interface{}
}

func setupValidator() error {
	// 将你所自定义的 validator 写入
	binding.Validator = global.Validator
	return nil
}

也就是说如果你有定制化需求,也完全可以自己实现一个验证器,效仿我们前面的模式,就可以完全替代 gin 框架原本的 validator 使用了。

而在章节的后半段,我们对业务接口进行了入参校验规则的编写,并且针对错误提示的多语言化问题(也可以理解为一个简单的国际化需求),通过中间件和多语言包的方式进行了实现,在未来如果你有更细致的国际化需求,也可以进一步的拓展。



本图书由 煎鱼 ©2020 版权所有,所有文章采用知识署名-非商业性使用-禁止演绎 4.0 国际进行许可。