Go Gin框架请求自动验证和数据绑定,看完这篇就会用了

Golang
550
0
0
2023-03-19
标签   Golang进阶

之前做项目基本上公司是用 gRPC 和 echo 这两个框架的组合,后来 Gin 框架在Go圈越来越流行,陆续我在公司接触到的项目也开始有人用 Gin 框架开发了。

因为我也是偶尔开发,像Gin框架里边参数的模型验证和绑定这些没有系统去学习,都是粘贴一下其他人的代码,改成我要的参数和模型,这里说的模型就是保存请求数据的 Struct。慢慢我发现每个人写的风格都不一样,有直接一个个接收参数再赋值到模型的,有用Gin自带的binding库的。还有Bind、ShouldBind一大堆方法到底该用哪个呢,觉得有点懵。

最近花时间整理了下这方面的知识,算是有了比较清晰的认识了,在这里也分享给大家。文章内容挺长,几乎没啥废话全是代码例子,建议收藏起来,后面开发项目的时候拿来参考。

什么是 Gin Binding

Gin 框架自带的 binding 库是一个非常好用的反序列化库,支持把请求体里 JSON、XML、FormData格式的数据和 URL上的路径参数、查询字符串、HTTP Headers 绑定到 Go 的 Struct 指针上,并且还把 go-playground/validator 库整合了进来,提供参数验证功能。

binding 库能支持这么多样格式的请求数据绑定,是因为提供了很多种绑定器,这些绑定器统一都遵守下面这个 interface的约定

type Binding interface {
 Name() string
 Bind(*http.Request, interface{}) error
}

打开项目工程,通过 GoLand DIE,可以看到 Gin 直接提供了 10 种绑定器实现。

img

图片

可以看到Gin对formDataheaderJSONYAMLprotobuf这些都提供了绑定器。

比如发送一个POST请求,请求体中常用到的数据交换格式是 JSON 或者 Form表单这两种。针对这两种请求的交换格式 Gin 框架 binding 库中提供了 JSON 绑定器和 FormData的绑定器,用来把请求体里的数据解析出来绑定到结构体指针对象上。

我们看一下他们的实现

// JOSN 绑定器
type jsonBinding struct{}
func (jsonBinding) Name() string {
 return "json"
}

func (jsonBinding) Bind(req *http.Request, obj interface{}) error {
 if req == nil || req.Body == nil {
  return fmt.Errorf("invalid request")
 }
 return decodeJSON(req.Body, obj)
}

// FormData 绑定器
type formPostBinding struct{}
func (formPostBinding) Name() string {
 return "form-urlencoded"
}

func (formPostBinding) Bind(req *http.Request, obj interface{}) error {
 if err := req.ParseForm(); err != nil {
  return err
 }
 if err := mapForm(obj, req.PostForm); err != nil {
  return err
 }
 return validate(obj)
}

把请求里的数据按照约定格式结束出来绑定到结构体指针对象上的逻辑就是在每个绑定器里的 Bind 方法里实现的,上面代码里 jsonBinding 这个绑定器的逻辑是解析JSON数据绑定到对象上,而formPostBinding 这个绑定器则是把请求体里的FormData绑定到对象上。

这里顺便说一下,因为还在更新设计模式系列的文章,像这里这样把解析请求数据绑定到对象的任务定义成一类算法族,把每个解析绑定算法封装成不同的绑定器,让客户端可以按照统一的方式使用各种绑定器,这种情况应该使用策略模式进行设计

策略模式中需要引入一个上下文,作为客户端和具体策略的中间层,用抽象接口去跟具体策略交流,达到客户端能用统一方式使用不同算法的效果。如果大家对策略模式有些模糊的话,可以关注公众号等后面更新的设计模式文章。这里只需要知道要想客户端用统一的方式使用绑定器,需要引入一个上下文,这个上下文就是 Gin 框架的 Context 来充当的

Gin 框架Context提供的Bind、ShouldBindWith、 BindJSON、之类的方法让我们能用统一的方式来使用各种绑定器。绑定器的要想把请求数据绑定到结构体指针上,还需要在结构体字段上声明对应的 Tag 才行,下面举一些常见的各种请求使用绑定器绑定数据的例子。

使用 Gin 的模型绑定

绑定 POST 请求体里的JSON数据

type queryBody struct {
 Name string `json:"name"`
 Age int `json:"age"`
 Sex int `json:"sex"`
}

func bindBody(context *gin.Context){
 var q queryBody
 err:= context.ShouldBindJSON(&q)
 if err != nil {
  context.JSON(http.StatusBadRequest,gin.H{
   "result":err.Error(),
  })
  return
 }
 context.JSON(http.StatusOK,gin.H{
  "result":"绑定成功",
  "body": q,
 })
}
// 路由
srv.POST("/binding/body",bindBody)
// 请求示例
// curl -X POST -d '{"name":"laoshi","age":18,"sex": 1}' <url>

绑定URL路径的位置参数

type queryUri struct {
 Id int `uri:"id"`
 Name string `uri:"name"`
}

func bindUri(context *gin.Context){
 var q queryUri
 err:= context.ShouldBindUri(&q)
 if err != nil {
  context.JSON(http.StatusBadRequest,gin.H{
   "result":err.Error(),
  })
  return
 }
 context.JSON(http.StatusOK,gin.H{
  "result":"绑定成功",
  "uri": q,
 })
}
// 路由
srv.GET("/binding/:id/:name",bindUri)
//请求示例
// curl -XGET https://xxx.com/binding/100/XiaoWang

绑定URL查询字符串

type queryParameter struct {
 Year int `form:"year"`
 Month int `form:"month"`
}

func bindQuery(context *gin.Context){
 var q queryParameter
 err:= context.ShouldBindQuery(&q)
 if err != nil {
  context.JSON(http.StatusBadRequest,gin.H{
   "result":err.Error(),
  })
  return
 }
 context.JSON(http.StatusOK,gin.H{
  "result":"绑定成功",
  "query": q,
 })
}
// 路由
srv.GET("/binding/query",bindQuery)
// 请求示例
// curl -XGET https://xxx.com/binding/query?year=2022&month=10

绑定HTTP Header

type queryHeader struct {
 Token string `header:"token"`
 Platform string `header:"platform"`
}

func bindHeader(context *gin.Context){
 var q queryHeader
 err := context.ShouldBindHeader(&q)
 if err != nil {
  context.JSON(http.StatusBadRequest,gin.H{
   "result":err.Error(),
  })
  return
 }
 context.JSON(http.StatusOK,gin.H{
  "result":"绑定成功",
  "header": q,
 })
}
// 路由
srv.GET("/binding/header",bindHeader)
// 请求示例
// curl -H "token: a1b2c3" -H "platform: 5" \
// -XGET https://xxx.com/

绑定FormData

Gin 没有单独的 ShouldBindForm 这样的方法,如果是要把请求里的FormData 绑定到自定义结构体的指针,可以使用shouldBind方法,这个方法支持根据 Header 里的 "Content-Type" 绑定各种格式的请求数据。

type InfoParam struct {
    A string `form:"a" json:"a"`
    B int    `form:"b" json:"b"`
}

func Results(c *gin.Context) {
    var info InfoParam
    // If `GET`, only `Form` binding engine (`query`) used.
    // If `POST`, first checks the `content-type` for `JSON` or `XML`, then uses `Form` (`form-data`).
    // See more at https://github.com/gin-gonic/gin/blob/master/binding/binding.go#L48
    if err := c.ShouldBind(&info); err != nil {
        c.JSON(400, gin.H{ "error": err.Error() })
        return
    }

    c.JSON(200, gin.H{ "data": info.A })
}

Bind 和 ShouldBind

Gin 的 Context 为请求数据绑定提供了两大类方法:在命名上以 Bind 为前缀和以 ShouldBind 区分。这两大类方法在行为上有些差异。

  • Bind 类的绑定方法,在绑定数据失败的时候,Gin 框架会直接返回 HTTP 400 Bad Request 错误,其中 Bind 方法会自动根据请求 Header 中的 Content-Type 判断要使用哪种绑定器解析绑定数据,而BindJSON、BindXML 类的方法则是直接使用对应的绑定器。
  • ShouldBind 类的绑定方法,在绑定数据失败的时候,会返回 error ,交给程序自己去处理错误。同样ShouldBind、ShouldBindJSON 这些方法的区别是前者会自动根据Header头确定使用什么绑定器,如果团队内开发规范里约定了请求 Content-Type 都是 JSON 的话,直接选用后者更为合理。
  • 无论是Bind 还是 ShouldBind 类的绑定方法,都只能读取一次请求体进行绑定,如果多次读取请求体字节流的需求的话,可以使用 ShouldBindBodyWith 方法,该方法会把请求体字节流拷贝一份放在 Gin 的 Context对象里。

如果看Gin 提供的绑定方法这块源码的话,你会发现所有绑定方法都是基于 ShouldBindWith这个基础方法实现的。

func (c *Context) ShouldBindWith(obj interface{}, b binding.Binding) error {
 return b.Bind(c.Request, obj)
}

只不过 Bind 类的绑定方法,在拿到错误后会直包装成 HTTP 错误进行返回。

func (c *Context) Bind(obj interface{}) error {
  // 判断HTTP请求的Content-Type
 b := binding.Default(c.Request.Method, c.ContentType())
 return c.MustBindWith(obj, b)
}

func (c *Context) MustBindWith(obj interface{}, b binding.Binding) error {
 if err := c.ShouldBindWith(obj, b); err != nil {
  c.AbortWithError(http.StatusBadRequest, err).SetType(ErrorTypeBind) // nolint: errcheck
  return err
 }
 return nil
}

所以在实际开发中使用 Gin 的请求参数绑定的时候,建议使用 Should 类的绑定方法。上面Bind方法的源码中我们可以看到判断 HTTP 请求的 Content-Type 的方法,而像ShouldBindJSON 这样带格式名后缀的方法会省略这一步,直接指定相应的绑定器类型进行操作

func (c *Context) ShouldBindJSON(obj interface{}) error {
 return c.ShouldBindWith(obj, binding.JSON)
}

Gin 的 binding 库看起来功能非常强大,各种方式的请求参数都能绑定到结构体指针上,不过都用绑定器解析请求参数的,如果接口只有一个简单的参数,也得定义结构体类型才行,所以针对这种情况 Gin 也提供了不用绑定器获取请求数据的方法。

不用绑定怎么获取请求数据?

当参数比较简单,不需要结构体来进行封装时候,此时还需采用gin.Context上的其他方法来获取请求参数值,下面列举一下不用绑定,直接获取请求参数值的方法。以下五个方法差不多涵盖了各种请求参数的接收方法,放在这里,供大家以后使用时参考。

context.Param 获取URL路径参数

// 此规则能够匹配/user/john这种格式,但不能匹配/user/ 或 /user这种格式
router.GET("/user/:name", func(c *gin.Context) {
  name := c.Param("name")
  c.String(http.StatusOK, "Hello %s", name)
})

context.Query 获取URL参数

// 匹配的url格式:  /welcome?firstname=Jane&lastname=Doe
router.GET("/welcome", func(c *gin.Context) {
  firstname := c.DefaultQuery("firstname", "Guest")
  lastname := c.Query("lastname") // 是 c.Request.URL.Query().Get("lastname") 的简写

  c.String(http.StatusOK, "Hello %s %s", firstname, lastname)
})

context.PostForm 获取Form表单里的字段

POST 请求里如果用Form表单上传了一两个参数,嫌创建请求类型麻烦,可以通过gin context 的PostForm 方法获取表单里的字段;

 router.POST("/form_post", func(c *gin.Context) {
  message := c.PostForm("message")
  nick := c.DefaultPostForm("nick", "anonymous") // 此方法可以设置默认值

  c.JSON(200, gin.H{
   "status":  "posted",
   "message": message,
   "nick":    nick,
  })
 })

如果Form 表单里字段很多,还是推荐用绑定,把参数数据绑定到结构体指针中。

context.FormFile 获取上传文件

 // 给表单限制上传大小 (默认 32 MiB)
 // router.MaxMultipartMemory = 8 << 20  // 8 MiB
 router.POST("/upload", func(c *gin.Context) {
  // 单文件
  file, _ := c.FormFile("file")
  log.Println(file.Filename)

  // 上传文件到指定的路径
  // c.SaveUploadedFile(file, dst)

  c.String(http.StatusOK, fmt.Sprintf("'%s' uploaded!", file.Filename))
 })

queryMap 和 PostFormMap

如果同个参数有多个值,可以使用queryMap和PostFormMap 分别对应URL查询字符串上和Form表单里单个参数的多个值

func getInputArray() {
  router := gin.Default()

  router.POST("/post_input_array", func(c *gin.Context) {
    ids := c.QueryMap("ids")
    names := c.PostFormMap("names")

    fmt.Printf("ids: %v; names: %v", ids, names)

    c.JSON(http.StatusOK, gin.H{
      "ids":   ids,
      "names": names,
    })
  })

编写自定义绑定器

如果 Gin 框架默认提供的绑定器还满足不了我们的需求,我们还可以通过编写自定义绑定器的方式实现需求,相信绝大多数人没有这个需求,不过为了让内容闭环我们还是花一点时间说一下。

文章开头说过所有绑定器都实现了 Binding 接口:

type Binding interface {
 Name() string
 Bind(*http.Request, interface{}) error
}

现在我们要实现一个可以解析 Toml这种格式数据的绑定器,在 Bind 方法里通过 go-toml 库解析请求体里的数据完成绑定即可。

type Toml struct {
}
// 返回绑定器的名称
func (t Toml) Name() string {
   return "toml"
}

// 解析请求,绑定数据到对象
func (t Toml) Bind(request *http.Request, i interface{}) error {
  // 使用 go-toml 包
   tD:= toml.NewDecoder(request.Body)
   return tD.Decode(i)
}

使用时可以自己再封装一个 BingTOML、ShouldBindTOML 这类的方法,不过感觉没太大不要,直接用 ShouldBindWith 方法就行。

engine.POST("/Toml", func(context *gin.Context) {
   uri:= URI{}

   if err:=context.ShouldBindWith(&uri, Toml{});err!=nil{
      context.AbortWithError(http.StatusBadRequest,err)
      return
   }
   context.JSON(200,uri)
})

到这里使用 Gin 框架开发项目时通过它提供的 binding 库完成请求参数数据绑定的各种用法以及使用演示差不多就跟大家通说了一遍,下次开发时用到了数据绑定就可以直接参考这里给出的例子啦。

binding 除了能完成请求数据到结构体类型指针的绑定 — 专业名词叫模型绑定,在进行模型绑定时,binding 库还顺带能对每个要绑定参数的进行验证,下面我们进入到这部分的内容。

参数验证

Gin 的 binding 库在数据绑定过程中提供的参数验证功能,在其内部其实是依赖 go-playgound/validator 库实现的,validator 是一个非常强大的验证库,提供了各种验证功能,这篇文章我们先把平常用 binding 库怎么做参数验证给大家说一下,提供一些示例供大家学习和开发的时候参考,后面的文章再深入地详细介绍 validator 库。

参数必填验证

用 binding 库进行参数验证,需要在要绑定数据的模型的 Struct Tag 中,使用binding标签进行各种验证规则的说明。最基础的验证就是要求参数必填,如果不使用验证器的话,我们大概率是要在程序里写一堆类似下面的 if 判断。

if name == "" {
  return errors.New("name is empty")
}

参数必填这个基础判断,我们使用 binding 验证功能,可以在声明绑定参数的结构体模型的时候,对于必填参数对应的字段,在其 binding 标签里用require进行声明:

type queryBody struct {
  Name string `json:"name" binding:"require"`
 Age int `json:"age"`
 Sex int `json:"sex"`
}

这样在后续使用 ShouldBindJSON这类方法进行解析请求、绑定数据到模型的时候,对于声明了 require 的字段,会强制验证对应参数是不是为空。

func bindBody(context *gin.Context){
 var q queryBody
 err:= context.ShouldBindJSON(&q)
  ......
}

手机号、邮箱地址、地区码验证

现在市面上各种软件,在注册时或者功能需要总是要求用户提交手机号、邮箱地址、国家地区码之类的数据,那么我们在开发时就经常需要对这类数据进行验证,通常的做法是我们会自己在项目里维护一个工具类,通过正则表达式之类的手段对这些输入项进行验证。

binding 库在这方面也有考虑,看下下面这个模型结构体的声明:

type Body struct {
   FirstName string `json:"firstName" binding:"required"`
   LastName string `json:"lastName" binding:"required"`
   Email string `json:"email" binding:"required,email"`
   Phone string `json:"phone" binding:"required,e164"`
   CountryCode string `json:"countryCode" binding:"required,iso3166_1_alpha2"`
}

在结构体字段的 Tag 中除了上面已经学过的require,在 Email、Phone 和 CountryCode 字段的 Tag 中,增加了其他几个验证规则。

  • email: 使用通用正则表达式验证电子邮件。
  • e164: 使用国际 E.164 标准验证电话。
  • iso3166_1_alpha2: 使用 ISO-3166-1 两字母标准验证国家代码。

我们可以使用下面的JSON样本,自己写程序验证一下 binding 的这几个验证规则。

{
   "firstName": "John",
   "lastName": "Mark",
   "email": "jmark@example.com",
   "phone": "+11234567890",
   "countryCode": "US"
}

国内的手机号是+86开头,不确定能验证所有国内的手机号,毕竟这几年还有虚拟电信运营商,阿里、京东什么的都能发手机号,如果你们公司有成型的手机号验证规则,可以封装个自定义验证规则,注册到binding的验证器中,注册验证规则这部分内容后面讲。

字符串输入验证

对于字符串参数,除了验证参数是否为空外,我们在写代码的时候经常还会按照系统的业务对一些字符串进行验证,比如手机类产品的SKU,在SKU码中都会包含MB关键字,产品编码都以PC关键字前缀开头等等。

对于这种更复杂的字符串参数验证,binding 也提供了可以直接用的验证规则。比如我们刚才的场景,验证产品码和SKU码的时候,可以在声明的模型结构体中加上这几个标签。

type MobileBody struct {
   ProductCode string `json:"productCode" binding:"required,startswith=PC,len=10"`
  SkuCode string `json:"skuCode" binding:"required,contains=MB,len=12"`
}

下面是几个经常会用到的字符串验证规则:

Tag

Description

Usage Example

uppercase

只允许包含大些字母

binding:"uppercase"

lowercase

只允许包含大些字母

binding:"lowercase"

contains

包含指定的子串

binding:"contains=key"

alphanum

只允许包含英文字母和数字

binding:"alphanum"

alpha

只允许包含英文字母

binding:"alpha"

endswith

字符串以指定子串结尾

binding:"endswith=."

startwith

字符串以指定子串开始

binding:"startswith=PC"

字段组合验证和比较

binding 的验证器提供了几个标签用于跨字段比较和字段内比较。跨字段比较即将特定字段与另一个字段的值进行比较,字段内比较说的是字段值与硬编码值进行比较。

看下面这个例子

type Body struct {
   Width int `json:"width" binding:"required,gte=1,lte=100,gtfield=Height"`
   Height int `json:"height" binding:"required,gte=1,lte=100"`
}

这个模型声明中,对 WidthHeight 会分别进行这项约束:

  • Width: 必填,1 <= Width <= 100,Width 大于 Height 字段的值。
  • Height: 必填,1<= Height <= 100。

验证时间是否有效

请求里存放时间的字段也是我们每次验证参数的老大难,一般都是偷懒就验证个不为空就行了,要验证是否是有效时间还得用time.Time 库进行解析,不过使用 binding 库参数的时候,这部分工作就可以交给 binding 库来做了。

binding 库提供了一个time_format 标签,通过它我们可以自由指定参数里时间的格式,从而完成时间验证。

type Body struct {
   StartDate time.Time `form:"start_date" binding:"required,ltefield=EndDate" time_format:"2006-01-02"`
   EndDate time.Time `form:"end_date" binding:"required" time_format:"2006-01-02"`
}

上面这个验证规则指定了:

  • StratDate:必填,小于EndDate字段的值,参数中的格式为:"2006-01-02" 即 "yyy-mm-dd" 的形式

time_format标签和binding标签可以组合使用,上面例子中的格式为:"2006-01-02" ,如果时间参数为"yyy-mm-dd hh:mm:ss" 格式的,把标签的值指定成"2006-01-02 15:04:05"就行,跟 Go 时间对象的Format函数用的模版一样。

自定义验证

有时候官方提供的验证器并不能满足我们的所有需求, Gin 的binding库也支持我们注册自定义验证器,其实这个功能是 binding 使用的 validator 库提供的,下面我们先用例子看一下怎么注册自定义验证器,关于 validator 的详细内容,放到后面的文章再介绍。

官方的验证器里提供了一个oneof验证

type ReqBody struct {
   Color string `json:"name" uri:"name" binding:"oneof=red blue pink"`
}

上面使用这个 oneof 验证的规则是:只能是列举出的标签值red blue pink值其中一个,这些值必须是数值或字符串,每个值以空格分隔。

现在假设我们要自定义一个验证叫做notoneof,验证规则是:字段的值不能是指定值中的任一个,与oneof验证的规则恰恰相反。

给 Gin 注册这个自定义验证,可以这么写,先上代码,下面再解释原理。

func main() {
  route := gin.Default()
  ...
  // 获取验证引擎,并类型转换成*validator.Validate
  if v, ok := binding.Validator.Engine().(*validator.Validate); ok {
     // 注册notoneof的验证函数
     v.RegisterValidation("notoneof", func(fl validator.FieldLevel) bool {
       // split values using ` `. eg. notoneof=bob rob job
       // 用空格分割ontoneof的值 比如:notoneof=red blue pink
        match:=strings.Split(fl.Param()," ")
       // 把用反射获取的字段值由reflect.Value 转为 string
        value:=fl.Field().String()
        for _,s:=range match {
           // 判断字段值是否等于notoneof指定的那些值
           if s==value {
              return false
           }
        }
        return true
     })
  }
  ...
  route.Run(":8080")
}

上面这个自定义验证的实现可以分成下面几步:

  • 获取Gin binding 使用的验证器引擎:binding.Validator.Engine().(*validator.Validate)
  • 接着使用验证引擎的 RegisterValidation 方法注册notoneof验证,以及对应的验证函数。
  • 通过 validator.FieldLevel 可以获得反射的结构体以及验证里的所有信息和帮助函数
  • FieldLevel.Param()获取为当前验证设置的所有参数(结构体标签在notoneof中指定的值)
  • FieldLevel.Field()获取当前验证的结构体字段的反射值,这样就可以进一步转化字段值

具体这个notoneof验证函数的实现逻辑,看上面代码里的注释吧。

注册自定义验证这部分的内容,相当于是 validator 库相关的知识,除了注册自定义验证外,我们在搭建框架的时候还需要自定义验证器的错误返回格式、把错误信息根据语言翻译成中文等等,这部分内容其实跟使用哪个Web框架关系不大,都是 validator 库的功能,所以我们放到后面详细学习 validator 库的文章里再说。

总结

今天把使用 Gin 框架开发项目时,经常会用到的请求数据的模型绑定和验证统一梳理了一下,基本上没什么废话都是代码。除了模型绑定和验证,我们还把Gin 简单获取单个参数的方式也梳理了一下,建议大家收藏好,开发项目的时候可以直接拿来参考,这样就省的从项目里粘来粘去了。

- END -