GO语言web框架Gin之完全指南 GO语言web框架Gin之完全指南

作为一款企业级生产力的web框架,gin的优势是显而易见的,高性能,轻量级,易用的api,以及众多的使用者,都为这个框架注入了可靠的因素。截止目前为止,github上面已经有了 35,994 star. 一个开源框架,关注数越多,就会越可靠,因为大家会在使用当中不断地对它进行改进。

下面放几个链接方便进行查看:

几个流行的go框架进行比较

go几大web框架比较 这个主页对几大web框架进行了一些比较,主要是统计了github star last commit time 等等信息,可以作为一个参考。

几大优势

  • 速度快: 高性能,无反射代码,低内存消耗
  • 中间件(拦截器): 可以更优雅的实现请求链路上下文的控制,比如日志,身份验证等等
  • Crash保活: 当一个请求挂掉之后,并不影响服务器的稳定运行
  • 数据验证
  • 分组的API管理: 当需要给特定请求加验证,一些请求又不需要的时候,可以很方便的实现
  • 错误管理
  • 简单易用而丰富的类型支持: Json, Xml, Html 等等

简单的使用

引入项目

现在有方便的go mod支持,引入变得非常简单,直接在需要使用的代码文件处 import "github.com/gin-gonic/gin" 即可

gin的HelloWorld

package main

import "github.com/gin-gonic/gin"

func main() { r := gin.Default() r.GET("/ping", func(c *gin.Context) { c.JSON(200, gin.H{ "message": "pong", }) }) r.Run() // listen and serve on 0.0.0.0:8080 } 

使用如上代码,便可轻松启动一个监听所有请求,端口为8080(默认) 的服务了。可以尝试用 curl 进行测试:

$ curl localhost:8080/ping            
output: {"message":"pong"}

如果想监听在其它端口,可以进行修改 r.Run("0.0.0.0:9000")

Get 请求以及参数获取

现在要发起一个请求: curl 'localhost:8080/send?a=1&b=2',现在来看看我们如何通过 *gin.Context 拿到传参呢,这里我们省去一些代码

g.GET("/send", func(ctx *gin.Context) {
    ctx.JSON(200, gin.H{ "a": ctx.Query("a") "b": ctx.Query("b"), }) }) // output: {"a":"1","b":"2"} 

我们把拿到的参数又返回给了客户端

假如前端此时需要传一个数组到服务器,通过GET方式,这时候该怎么办呢,此时有三个办法

  • 客户端 curl 'localhost:8080/send?a=1&a=2' 传递同样的 key, web 框架会当做数组处理
g.GET("/send", func(ctx *gin.Context) {
    ctx.JSON(200, gin.H{ "a": ctx.QueryArray("a"), }) }) // output: {"a":["1","2"]} 
  • 客户端 curl 'localhost:8080/send?a=["1", "2"]' 这里是使用json字符串的形式传递数组,注意这里面包含了 url 不允许直接传输的字符,比如 [ ] 和 " 等,需要进行url编码, 可以在 UrlEncode编码/UrlDecode解码 - 站长工具 这里进行转换一下,转换后的结果如下:
    curl 'localhost:8080/send?a=%5b%221%22%2c+%222%22%5d', gin 相关代码如下:
g.GET("/send", func(ctx *gin.Context) {
    out := []string{} err := json.Unmarshal([]byte(ctx.Query("a")), &out) if err != nil { ctx.JSON(200, gin.H{ "error": err.Error(), }) return } ctx.JSON(200, gin.H{ "a": out, }) }) // output: {"a":["1","2"]} 
  • 第三种就是传递参数的时候,传递一个字符串,每个元素之间用,号等分割一下,在服务端取到该字符串之后,再利用strings.Split()函数分割成数组即可,这里就不例举代码了。

NOTE: 如果 query 取的key不存在,会得到什么呢?答案是空字符串,或者你也可以使用
func (c *Context) GetQuery(key string) (string, bool) 这个方法,可以返回一个 bool 用来判断是否存在

路径参数Path该如何获取

curl 'localhost:8080/send/1?b=2'

g.GET("/send/:id", func(ctx *gin.Context) {
    ctx.JSON(200, gin.H{ "id": ctx.Param("id"), "b": ctx.Query("b"), }) }) // output: {"b":"2","id":"1"} 

同样,如果获取不到则为空字符串,如果路径参数忘了传,则url匹配不上,就会报404

Post 请求及其参数获取

众所周知,post请求,传输的数据是会在body里面的,在gin里面是怎么获取的呢
curl -XPOST 'localhost:8080/send?a=1' -d "b=2&c=3", 这里也带上了 query parameter

g.POST("/send", func(ctx *gin.Context) {
    ctx.JSON(200, gin.H{ "a": ctx.Query("a"), "b": ctx.PostForm("b"), "c": ctx.PostForm("c"), }) }) // output: {"a":"1","b":"2","c":"3"} 

同样,如果参数不存在,也是获取到空字符串

模型绑定

gin提供了模型绑定,方便参数的规范化,简单来说,模型绑定就是把参数解析出来,放在你定义好的结构体里面。模型绑定的好处如下

  • 规范化数据
  • 能够将string数据解析为你希望的类型,比如 uint32
  • 能够使用参数验证器

最常使用的模型绑定方法

gin 对模型绑定出错的处理分了两个大类

  • Bind*方法,以及MustBindWith方法 出错会将返回code置为400
  • ShouldBind* 方法,出错不会设置返回code,可以自己控制返回的code,一般来说,直接调 ShouldBind方法就行了,它会自动判断 Content-Type 选择相应的绑定

Query Param 绑定

请求为 curl 'localhost:8080/send?a=haha&b=123', go代码如下

g.GET("/send", func(ctx *gin.Context) {
    type Param struct { A string `form:"a" binding:"required"` B int `form:"b" binding:"required"` } param := new(Param) if err := ctx.ShouldBind(param); err != nil { ctx.JSON(400, gin.H{ "err": err.Error(), }) return } ctx.JSON(200, gin.H{ "Content-Type": ctx.ContentType(), "a": param.A, "b": param.B, }) }) // output: {"Content-Type":"","a":"haha","b":123} 

如果什么都不传,因为设置了 binding:"required" 这个tag,于是在绑定最后验证时候,会报错

Query 与 Form Param 同时绑定

请求为 curl -XPOST 'localhost:8080/send?a=haha' -d "b=2&c=3", go代码如下

g.POST("/send", func(ctx *gin.Context) {
    type Param struct { A string `form:"a" binding:"required"` B int `form:"b" binding:"required"` C int `form:"c" binding:"required"` } param := new(Param) if err := ctx.ShouldBind(param); err != nil { ctx.JSON(400, gin.H{ "err": err.Error(), }) return } ctx.JSON(200, gin.H{ "a": param.A, "b": param.B, "c": param.C, }) }) // output: {"a":"haha","b":2,"c":3} 

可以看到,Query 和 Form 参数都是用的 form 这个tag

Path 路径参数绑定

上面看到了,Query 和 Form 是可以绑定到一个结构体当中,但是路径参数就只能单独进行绑定了(如果不需要使用参数验证,则直接用 ctx.Param(key)方法即可),需要单独绑定到一个结构体当中, 使用ctx.ShouldBindUri() 这个方法进行绑定。

请求为 curl 'localhost:8080/send/haha', go代码如下

g.GET("/send/:name", func(ctx *gin.Context) {
        type Param struct { A string `uri:"name" binding:"required"` } param := new(Param) if err := ctx.ShouldBindUri(param); err != nil { ctx.JSON(200, gin.H{ "err": err.Error(), }) return } ctx.JSON(200, gin.H{ "a": param.A, }) }) 

如果觉得绑定到2个结构体很麻烦,可以自己实现 Binding 接口,然后使用自己实现的Bind方法即可

模型绑定方法总结

强制绑定

  • func (c *Context) MustBindWith(obj interface{}, b binding.Binding) error 通用的强制绑定方法,出错则置返回code为400,一般不直接用此方法
  • func (c *Context) Bind(obj interface{}) error 调用 MustBindWith 自动根据请求类型来判断绑定
  • func (c *Context) BindHeader(obj interface{}) error 调用 MustBindWith 绑定请求头,tag使用header
  • func (c *Context) BindJSON(obj interface{}) error 调用 MustBindWith 绑定json,tag使用json
  • func (c *Context) BindQuery(obj interface{}) error 调用 MustBindWith 绑定 Query Param,tag使用form
  • func (c *Context) BindUri(obj interface{}) error 调用 MustBindWith 绑定Path路径参数,tag使用uri
  • func (c *Context) BindXML(obj interface{}) error 调用 MustBindWith 绑定xml,tag使用xml
  • func (c *Context) BindYAML(obj interface{}) error 调用 MustBindWith 绑定yaml,tag使用yaml

非强制绑定

  • func (c *Context) ShouldBindWith(obj interface{}, b binding.Binding) error 通用的绑定方法
  • func (c *Context) ShouldBind(obj interface{}) error 调用 ShouldBindWith 自动根据请求类型判断绑定
  • 其余方法这里不列出,都是在上述方法基础上加 Should 并且都是调用 ShouldBindWith, 下面说两个不一样的

这里说一个需要注意的问题,如果是数据存储于 Body 里面的,gin是封装的标准库的http,而 Body 是io.ReadCloser 类型的,只能读取一次,之后就关闭,内容只允许读一次,也就是说,上述的 Bind 凡是读 Body 的,都不能再读第二次,这个可以用其他办法解决,这里暂且只说一个,那就是
func (c *Context) ShouldBindBodyWith(obj interface{}, bb binding.BindingBody) (err error) 方法,这个方法允许调用多次,因为它将内容暂时存在了 gin.Context 当中,比如绑定json如下代码所示:
ctx.ShouldBindBodyWith(&objA, binding.JSON)

还有个注意点就是,绑定的结构体,如果包含有子结构体,对于 form 传参来说,是不会有什么影响的,比如 a=1&b=2&c=3 , a b c 可以分别在不同的结构体中,可以是结构体指针也可以是结构体,具体可以参考 这里

服务器返回

这里总结下服务器返回的方法,不过调用完成之后,记得return
func (c *Context) String(code int, format string, values …interface{}) 返回类型为 string
func (c *Context) JSON(code *int*, obj interface{}) 这个用得最多的,返回 json

还有许多方法,这里不一一列举,可以参考 gin 源码学习

中间件(或叫拦截器)

中间件是在请求前后做一些事情,比如验证登录,打印日志等等工作,可以将接口逻辑划分开来,与业务代码分离,下面看看中间件是怎么使用的

中间件函数的定义其实和普通请求接口的定义是一样的,都是 type HandlerFunc func(*Context),中间件分为以下三类作用域

  • 全局中间件
  • group中间件
  • 单个接口级别的中间件

中间件的作用顺序是,定义在前面的先生效,也就是定义在前面的会先调用,而且可以定义多个中间件

全局中间件: 对所有请求接口都有效
group中间件: 对该组的接口有效
单个接口级别中间件: 只对该接口有效

现在介绍几个gin自带的全局中间件,还记得初始化gin的时候,调用的哪个方法吗,就是 gin.Default(),下面看看它的源码

func Default() *Engine { debugPrintWARNINGDefault() engine := New() engine.Use(Logger(), Recovery()) return engine } 

可以看到,也是调用 New() 这个函数构造了 Engine 对象,并且初始化了 2 个中间件,一个用于日志打印,另一个用于崩溃恢复,这2个都是全局中间件

gin主页的README有段代码,清晰的解释了这三种中间件的定义

func main() {
	// 使用New()初始化 r := gin.New() // 全局中间件: 日志打印 r.Use(gin.Logger()) // 全局中间件: r.Use(gin.Recovery()) // 单个接口的中间件 r.GET("/benchmark", MyBenchLogger(), benchEndpoint) authorized := r.Group("/") // 分组中间件: 只对该组接口有效 authorized.Use(AuthRequired()) { authorized.POST("/login", loginEndpoint) authorized.POST("/submit", submitEndpoint) authorized.POST("/read", readEndpoint) testing := authorized.Group("testing") testing.GET("/analytics", analyticsEndpoint) } r.Run(":8080") } 

中间件彼此形成一条链条,对于每个请求来说,它的调用关系如下图:

  • 在中间件内部调用 ctx.Next() 即是调用链条的下一级方法,比如,在全局中间件里调用 Next,则表示调用 group中间件函数,这就可以使用切面编程思想,把链条下一级函数看做一个切面,然后在前后做一些事情,比如计算接口的调用时间等。
  • 如果不显示调用Next,则该中间件函数执行完之后,会执行链条的下一级函数
  • 如果想要中断链条,则调用ctx.Abort() 函数,调用之后,会正常执行完当前中间件函数,但是不会再执行链条下一级了,而是准备返回接口。

举个例子

一般来说,定义一个中间件,都遵循下面这种风格,YourFunc() HandlerFunc 返回这个处理函数的方式,当然你也可以直接定义一个 HandlerFunc 也是可以的。

现在要实现一个功能,能够计算某个请求的耗时,使用中间件来完成,代码如下

timeCalc := func() gin.HandlerFunc { return func(ctx *gin.Context) { if ctx.Query("a") == "" { ctx.Abort() // 终止调用链条 ctx.JSON(http.StatusBadRequest, gin.H{ "message": "a参数有问题,请检查参数", }) return } start := time.Now() // Next 在这里相当于 接口函数,在Next之前则在接口函数之前执行 fmt.Println("Next之前") ctx.Next() fmt.Println("Next之后") cost := time.Since(start) // Next 之后,则相当于在接口函数之后执行,形成了一个切面 fmt.Printf("用时 %d 微秒\n", cost.Microseconds()) } } g.GET("/send", timeCalc(), func(ctx *gin.Context) { fmt.Println("进入接口函数") ctx.JSON(http.StatusOK, gin.H{ "a": ctx.Query("a"), }) }) // 服务端输出: // Next之前 // 进入接口函数 // Next之后 // 用时 231 微秒 

NOTE: 如果需要在接口链条的某一处,开辟一个gorutine进行处理,如果需要用到 gin.Context 的话,需要调用 ctx.Copy() 函数进行一份拷贝,然后在开辟的gorutine当中使用该拷贝

Gin一些开源中间件 这里可以找到一些比较实用的中间件,可以自己探索下

MODE

目前Gin有三种模式: debug release test 三种,可以通过设置 GIN_MODE 这个环境变量来控制
比如现在需要将这个web应用发布到正式环境,那么需要将生产机器上的gin的环境变量设置为 release: export GIN_MODE= release

在debug模式下,会在开头多一些打印

单元测试

下面这个例子可以参考一下

package main

import (
    "encoding/json"
    "io/ioutil"
    "net/http" "strings" "testing" "github.com/stretchr/testify/assert" ) func do(t *testing.T, req *http.Request) ([]byte, error) { resp, err := http.DefaultClient.Do(req) if err != nil { return nil, err } body, err := ioutil.ReadAll(resp.Body) if err != nil { return nil, err } assert.Equal(t, 200, resp.StatusCode) return body, nil } // curl 'localhost:8080/send?a=1&b=2' func TestGet(t *testing.T) { req, _ := http.NewRequest("GET", "http://localhost:8080/send?a=1&b=2", nil) body, err := do(t, req) assert.NoError(t, err) assert.Equal(t, "{\"a\":\"1\",\"b\":\"2\"}\n", string(body)) } // curl -XPOST 'localhost:8080/send' -H 'Content-Type: application/json' -d '{"a":1,"b":2,"c":3}' func TestPost(t *testing.T) { req, _ := http.NewRequest("POST", "http://localhost:8080/send", strings.NewReader(`{"a":1,"b":2,"c":3}`)) req.Header.Set("Content-Type", "application/json") // 传json记得修改 body, err := do(t, req) assert.NoError(t, err) type Resp struct { A int `json:"a"` B int `json:"b"` C int `json:"c"` } resp := new(Resp) assert.NoError(t, json.Unmarshal(body, resp)) assert.Equal(t, &Resp{ A: 1, B: 2, C: 3, }, resp) } 

当你的代码嵌套比较多,并且不易于在单元测试当中去启动这个服务的时候,可以使用这个方法,单元测试就相当于开了一个http client,去请求已启动的服务,这时候需要先启动项目的服务,才能调用单元测试哦。

下面介绍一个独立的,也是gin源码经常使用的这种测试方法,可以独立运行,不依赖于已启动的服务

func TestIndependent0(t *testing.T) {
    w := httptest.NewRecorder() // 用于返回的数据 ctx, _ := gin.CreateTestContext(w) // 模拟返回数据 ctx.JSON(http.StatusOK, gin.H{ "a": 1, }) assert.Equal(t, "{\"a\":1}\n", string(w.Body.Bytes())) } func TestIndependent1(t *testing.T) { w := httptest.NewRecorder() _, router := gin.CreateTestContext(w) router.GET("/send", func(c *gin.Context) { c.JSON(http.StatusOK, gin.H{ "a": 1, }) }) router.ServeHTTP(w, httptest.NewRequest("GET", "http://localhost:8080/send", nil)) t.Log(string(w.Body.Bytes())) // output: {"a":1} }

参数验证

我们知道,一个请求完全依赖前端的参数验证是不够的,需要前后端一起配合,才能万无一失,下面介绍一下,在Gin框架里面,怎么做接口参数验证的呢

gin 目前是使用 go-playground/validator 这个框架,截止目前,默认是使用 v10 版本;具体用法可以看看 validator package · go.dev 文档说明哦

下面以一个单元测试,简单说明下如何在tag里验证前端传递过来的数据

简单的例子

func TestValidation(t *testing.T) {
    ctx, _ := gin.CreateTestContext(httptest.NewRecorder())

    testCase := []struct { msg string // 本测试用例的说明 jsonStr string // 输入的参数 haveErr bool // 是否有 error bindStruct interface{} // 被绑定的结构体 errMsg string // 如果有错,错误信息 }{ { msg: "数据正确: ", jsonStr: `{"a":1}`, haveErr: false, bindStruct: &struct { A int `json:"a" binding:"required"` }{}, }, { msg: "数据错误: 缺少required的参数", jsonStr: `{"b":1}`, haveErr: true, bindStruct: &struct { A int `json:"a" binding:"required"` }{}, errMsg: "Key: 'A' Error:Field validation for 'A' failed on the 'required' tag", }, { msg: "数据正确: 参数是数字并且范围 1 <= a <= 10", jsonStr: `{"a":1}`, haveErr: false, bindStruct: &struct { A int `json:"a" binding:"required,max=10,min=1"` }{}, }, { msg: "数据错误: 参数数字不在范围之内", jsonStr: `{"a":1}`, haveErr: true, bindStruct: &struct { A int `json:"a" binding:"required,max=10,min=2"` }{}, errMsg: "Key: 'A' Error:Field validation for ‘A’ failed on the ‘min’ tag", }, { msg: "数据正确: 不等于列举的参数", jsonStr: `{"a":1}`, haveErr: false, bindStruct: &struct { A int `json:"a" binding:"required,ne=10"` }{}, }, { msg: "数据错误: 不能等于列举的参数", jsonStr: `{"a":1}`, haveErr: true, bindStruct: &struct { A int `json:"a" binding:"required,ne=1,ne=2"` // ne 表示不等于 }{}, errMsg: "Key: 'A' Error:Field validation for 'A' failed on the 'ne' tag", }, { msg: "数据正确: 需要大于10", jsonStr: `{"a":11}`, haveErr: false, bindStruct: &struct { A int `json:"a" binding:"required,gt=10"` }{}, }, // 总结: eq 等于,ne 不等于,gt 大于,gte 大于等于,lt 小于,lte 小于等于 { msg: "参数正确: 长度为5的字符串", jsonStr: `{"a":"hello"}`, haveErr: false, bindStruct: &struct { A string `json:"a" binding:"required,len=5"` // 需要参数的字符串长度为5 }{}, }, { msg: "参数正确: 为列举的字符串之一", jsonStr: `{"a":"hello"}`, haveErr: false, bindStruct: &struct { A string `json:"a" binding:"required,oneof=hello world"` // 需要参数是列举的其中之一,oneof 也可用于数字 }{}, }, { msg: "参数正确: 参数为email格式", jsonStr: `{"a":"[email protected]"}`, haveErr: false, bindStruct: &struct { A string `json:"a" binding:"required,email"` }{}, }, { msg: "参数错误: 参数不能等于0", jsonStr: `{"a":0}`, haveErr: true, bindStruct: &struct { A int `json:"a" binding:"gt=0|lt=0"` }{}, errMsg: "Key: 'A' Error:Field validation for 'A' failed on the 'gt=0|lt=0' tag", }, // 详情参考: https://pkg.go.dev/github.com/go-playground/validator/v10?tab=doc } for _, c := range testCase { ctx.Request = httptest.NewRequest("POST", "/", strings.NewReader(c.jsonStr)) if c.haveErr { err := ctx.ShouldBindJSON(c.bindStruct) assert.Error(t, err) assert.Equal(t, c.errMsg, err.Error()) } else { assert.NoError(t, ctx.ShouldBindJSON(c.bindStruct)) } } } // 测试 form 的情况 // time_format 这个tag 只能在 form tag 下能用 func TestValidationForm(t *testing.T) { ctx, _ := gin.CreateTestContext(httptest.NewRecorder()) testCase := []struct { msg string // 本测试用例的说明 formStr string // 输入的参数 haveErr bool // 是否有 error bindStruct interface{} // 被绑定的结构体 errMsg string // 如果有错,错误信息 }{ { msg: "数据正确: 时间格式", formStr: `a=2010-01-01`, haveErr: false, bindStruct: &struct { A time.Time `form:"a" binding:"required" time_format:"2006-01-02"` }{}, }, } for _, c := range testCase { ctx.Request = httptest.NewRequest("POST", "/", bytes.NewBufferString(c.formStr)) ctx.Request.Header.Add("Content-Type", binding.MIMEPOSTForm) // 这个很关键 if c.haveErr { err := ctx.ShouldBind(c.bindStruct) assert.Error(t, err) assert.Equal(t, c.errMsg, err.Error()) } else { assert.NoError(t, ctx.ShouldBind(c.bindStruct)) } } } 

简单解释一下,还记得上一篇文章讲的单元测试吗,这里只需要使用到 gin.Context 对象,所以忽略掉 gin.CreateTestContext()返回的第二个参数,但是需要将输入参数放进 gin.Context,也就是把 Request 对象设置进去 ,接下来才能使用 Bind 相关的方法哦。

其中 binding: 代替框架文档中的 validate,因为gin单独给验证设置了tag名称,可以参考gin源码 binding/default_validator.go

func (v *defaultValidator) lazyinit() { v.once.Do(func() { v.validate = validator.New() v.validate.SetTagName("binding") // 这里改为了 binding }) } 

上面的单元测试已经把基本的验证语法都列出来了,剩余的可以根据自身需求查询文档进行的配置

日志

使用gin默认的日志

首先来看看,初始化gin的时候,使用了 gin.Deatult() 方法,上一篇文章讲过,此时默认使用了2个全局中间件,其中一个就是日志相关的 Logger() 函数,返回了日志处理的中间件

这个函数是这样定义的

func Logger() HandlerFunc { return LoggerWithConfig(LoggerConfig{}) } 

继续跟源码,看来真正处理的就是 LoggerWithConfig() 函数了,下面列出部分关键源码

func LoggerWithConfig(conf LoggerConfig) HandlerFunc { formatter := conf.Formatter if formatter == nil { formatter = defaultLogFormatter } out := conf.Output if out == nil { out = DefaultWriter } notlogged := conf.SkipPaths isTerm := true if w, ok := out.(*os.File); !ok || os.Getenv("TERM") == "dumb" || (!isatty.IsTerminal(w.Fd()) && !isatty.IsCygwinTerminal(w.Fd())) { isTerm = false } var skip map[string]struct{} if length := len(notlogged); length > 0 { skip = make(map[string]struct{}, length) for _, path := range notlogged { skip[path] = struct{}{} } } return func(c *Context) { // Start timer start := time.Now() path := c.Request.URL.Path raw := c.Request.URL.RawQuery // Process request c.Next() // Log only when path is not being skipped if _, ok := skip[path]; !ok { // 中间省略这一大块是在处理打印的逻辑 // …… fmt.Fprint(out, formatter(param)) // 最后是通过 重定向到 out 进行输出 } } } 

稍微解释下,函数入口传参是 LoggerConfig 这个定义如下:

type LoggerConfig struct {
    Formatter LogFormatter
    Output io.Writer
    SkipPaths []string
}

而调用 Default() 初始化gin时候,这个结构体是一个空结构体,在 LoggerWithConfig 函数中,如果这个结构体内容为空,会为它设置一些默认值
默认日志输出是到 stdout 的,默认打印格式是由 defaultLogFormatter 这个函数变量控制的,如果想要改变日志输出,比如同时输出到文件stdout,可以在调用 Default() 之前,设置 DefaultWriter 这个变量;但是如果需要修改日志格式,则不能调用 Default() 了,可以调用 New() 初始化gin之后,使用 LoggerWithConfig() 函数,将自己定义的 LoggerConfig 传入。

使用第三方的日志

默认gin只会打印到 stdout,我们如果使用第三方的日志,则不需要管gin本身的输出,因为它不会输出到文件,正常使用第三方的日志工具即可。由于第三方的日志工具,我们需要实现一下 gin 本身打印接口(比如接口时间,接口名称,path等等信息)的功能,所以往往需要再定义一个中间件去打印。

logrus

GitHub主页

logrus 是一个比较优秀的日志框架,下面这个例子简单的使用它来记录下日志

func main() {
    g := gin.Default()
    gin.DisableConsoleColor()

    testLogrus(g)

    if err := g.Run(); err != nil { panic(err) } } func testLogrus(g *gin.Engine) { log := logrus.New() file, err := os.Create("mylog.txt") if err != nil { fmt.Println("err:", err.Error()) os.Exit(0) } log.SetOutput(io.MultiWriter(os.Stdout, file)) logMid := func() gin.HandlerFunc { return func(ctx *gin.Context) { var data string if ctx.Request.Method == http.MethodPost { // 如果是post请求,则读取body body, err := ctx.GetRawData() // body 只能读一次,读出来之后需要重置下 Body if err != nil { log.Fatal(err) } ctx.Request.Body = ioutil.NopCloser(bytes.NewBuffer(body)) // 重置body data = string(body) } start := time.Now() ctx.Next() cost := time.Since(start) log.Infof("方法: %s, URL: %s, CODE: %d, 用时: %dus, body数据: %s", ctx.Request.Method, ctx.Request.URL, ctx.Writer.Status(), cost.Microseconds(), data) } } g.Use(logMid()) // curl 'localhost:8080/send' g.GET("/send", func(ctx *gin.Context) { ctx.JSON(200, gin.H{"msg": "ok"}) }) // curl -XPOST 'localhost:8080/send' -d 'a=1' g.POST("/send", func(ctx *gin.Context) { ctx.JSON(200, gin.H{"a": ctx.PostForm("a")}) }) } 

zap

zap文档
zap同样是比较优秀的日志框架,是由uber公司主导开发的,这里就不单独举例子了,可与参考下 zap中间件 的实现

 
标签:  httpgoweb
 

作为一款企业级生产力的web框架,gin的优势是显而易见的,高性能,轻量级,易用的api,以及众多的使用者,都为这个框架注入了可靠的因素。截止目前为止,github上面已经有了 35,994 star. 一个开源框架,关注数越多,就会越可靠,因为大家会在使用当中不断地对它进行改进。

下面放几个链接方便进行查看:

几个流行的go框架进行比较

go几大web框架比较 这个主页对几大web框架进行了一些比较,主要是统计了github star last commit time 等等信息,可以作为一个参考。

几大优势

  • 速度快: 高性能,无反射代码,低内存消耗
  • 中间件(拦截器): 可以更优雅的实现请求链路上下文的控制,比如日志,身份验证等等
  • Crash保活: 当一个请求挂掉之后,并不影响服务器的稳定运行
  • 数据验证
  • 分组的API管理: 当需要给特定请求加验证,一些请求又不需要的时候,可以很方便的实现
  • 错误管理
  • 简单易用而丰富的类型支持: Json, Xml, Html 等等

简单的使用

引入项目

现在有方便的go mod支持,引入变得非常简单,直接在需要使用的代码文件处 import "github.com/gin-gonic/gin" 即可

gin的HelloWorld

package main

import "github.com/gin-gonic/gin"

func main() { r := gin.Default() r.GET("/ping", func(c *gin.Context) { c.JSON(200, gin.H{ "message": "pong", }) }) r.Run() // listen and serve on 0.0.0.0:8080 } 

使用如上代码,便可轻松启动一个监听所有请求,端口为8080(默认) 的服务了。可以尝试用 curl 进行测试:

$ curl localhost:8080/ping            
output: {"message":"pong"}

如果想监听在其它端口,可以进行修改 r.Run("0.0.0.0:9000")

Get 请求以及参数获取

现在要发起一个请求: curl 'localhost:8080/send?a=1&b=2',现在来看看我们如何通过 *gin.Context 拿到传参呢,这里我们省去一些代码

g.GET("/send", func(ctx *gin.Context) {
    ctx.JSON(200, gin.H{ "a": ctx.Query("a") "b": ctx.Query("b"), }) }) // output: {"a":"1","b":"2"} 

我们把拿到的参数又返回给了客户端

假如前端此时需要传一个数组到服务器,通过GET方式,这时候该怎么办呢,此时有三个办法

  • 客户端 curl 'localhost:8080/send?a=1&a=2' 传递同样的 key, web 框架会当做数组处理
g.GET("/send", func(ctx *gin.Context) {
    ctx.JSON(200, gin.H{ "a": ctx.QueryArray("a"), }) }) // output: {"a":["1","2"]} 
  • 客户端 curl 'localhost:8080/send?a=["1", "2"]' 这里是使用json字符串的形式传递数组,注意这里面包含了 url 不允许直接传输的字符,比如 [ ] 和 " 等,需要进行url编码, 可以在 UrlEncode编码/UrlDecode解码 - 站长工具 这里进行转换一下,转换后的结果如下:
    curl 'localhost:8080/send?a=%5b%221%22%2c+%222%22%5d', gin 相关代码如下:
g.GET("/send", func(ctx *gin.Context) {
    out := []string{} err := json.Unmarshal([]byte(ctx.Query("a")), &out) if err != nil { ctx.JSON(200, gin.H{ "error": err.Error(), }) return } ctx.JSON(200, gin.H{ "a": out, }) }) // output: {"a":["1","2"]} 
  • 第三种就是传递参数的时候,传递一个字符串,每个元素之间用,号等分割一下,在服务端取到该字符串之后,再利用strings.Split()函数分割成数组即可,这里就不例举代码了。

NOTE: 如果 query 取的key不存在,会得到什么呢?答案是空字符串,或者你也可以使用
func (c *Context) GetQuery(key string) (string, bool) 这个方法,可以返回一个 bool 用来判断是否存在

路径参数Path该如何获取

curl 'localhost:8080/send/1?b=2'

g.GET("/send/:id", func(ctx *gin.Context) {
    ctx.JSON(200, gin.H{ "id": ctx.Param("id"), "b": ctx.Query("b"), }) }) // output: {"b":"2","id":"1"} 

同样,如果获取不到则为空字符串,如果路径参数忘了传,则url匹配不上,就会报404

Post 请求及其参数获取

众所周知,post请求,传输的数据是会在body里面的,在gin里面是怎么获取的呢
curl -XPOST 'localhost:8080/send?a=1' -d "b=2&c=3", 这里也带上了 query parameter

g.POST("/send", func(ctx *gin.Context) {
    ctx.JSON(200, gin.H{ "a": ctx.Query("a"), "b": ctx.PostForm("b"), "c": ctx.PostForm("c"), }) }) // output: {"a":"1","b":"2","c":"3"} 

同样,如果参数不存在,也是获取到空字符串

模型绑定

gin提供了模型绑定,方便参数的规范化,简单来说,模型绑定就是把参数解析出来,放在你定义好的结构体里面。模型绑定的好处如下

  • 规范化数据
  • 能够将string数据解析为你希望的类型,比如 uint32
  • 能够使用参数验证器

最常使用的模型绑定方法

gin 对模型绑定出错的处理分了两个大类

  • Bind*方法,以及MustBindWith方法 出错会将返回code置为400
  • ShouldBind* 方法,出错不会设置返回code,可以自己控制返回的code,一般来说,直接调 ShouldBind方法就行了,它会自动判断 Content-Type 选择相应的绑定

Query Param 绑定

请求为 curl 'localhost:8080/send?a=haha&b=123', go代码如下

g.GET("/send", func(ctx *gin.Context) {
    type Param struct { A string `form:"a" binding:"required"` B int `form:"b" binding:"required"` } param := new(Param) if err := ctx.ShouldBind(param); err != nil { ctx.JSON(400, gin.H{ "err": err.Error(), }) return } ctx.JSON(200, gin.H{ "Content-Type": ctx.ContentType(), "a": param.A, "b": param.B, }) }) // output: {"Content-Type":"","a":"haha","b":123} 

如果什么都不传,因为设置了 binding:"required" 这个tag,于是在绑定最后验证时候,会报错

Query 与 Form Param 同时绑定

请求为 curl -XPOST 'localhost:8080/send?a=haha' -d "b=2&c=3", go代码如下

g.POST("/send", func(ctx *gin.Context) {
    type Param struct { A string `form:"a" binding:"required"` B int `form:"b" binding:"required"` C int `form:"c" binding:"required"` } param := new(Param) if err := ctx.ShouldBind(param); err != nil { ctx.JSON(400, gin.H{ "err": err.Error(), }) return } ctx.JSON(200, gin.H{ "a": param.A, "b": param.B, "c": param.C, }) }) // output: {"a":"haha","b":2,"c":3} 

可以看到,Query 和 Form 参数都是用的 form 这个tag

Path 路径参数绑定

上面看到了,Query 和 Form 是可以绑定到一个结构体当中,但是路径参数就只能单独进行绑定了(如果不需要使用参数验证,则直接用 ctx.Param(key)方法即可),需要单独绑定到一个结构体当中, 使用ctx.ShouldBindUri() 这个方法进行绑定。

请求为 curl 'localhost:8080/send/haha', go代码如下

g.GET("/send/:name", func(ctx *gin.Context) {
        type Param struct { A string `uri:"name" binding:"required"` } param := new(Param) if err := ctx.ShouldBindUri(param); err != nil { ctx.JSON(200, gin.H{ "err": err.Error(), }) return } ctx.JSON(200, gin.H{ "a": param.A, }) }) 

如果觉得绑定到2个结构体很麻烦,可以自己实现 Binding 接口,然后使用自己实现的Bind方法即可

模型绑定方法总结

强制绑定

  • func (c *Context) MustBindWith(obj interface{}, b binding.Binding) error 通用的强制绑定方法,出错则置返回code为400,一般不直接用此方法
  • func (c *Context) Bind(obj interface{}) error 调用 MustBindWith 自动根据请求类型来判断绑定
  • func (c *Context) BindHeader(obj interface{}) error 调用 MustBindWith 绑定请求头,tag使用header
  • func (c *Context) BindJSON(obj interface{}) error 调用 MustBindWith 绑定json,tag使用json
  • func (c *Context) BindQuery(obj interface{}) error 调用 MustBindWith 绑定 Query Param,tag使用form
  • func (c *Context) BindUri(obj interface{}) error 调用 MustBindWith 绑定Path路径参数,tag使用uri
  • func (c *Context) BindXML(obj interface{}) error 调用 MustBindWith 绑定xml,tag使用xml
  • func (c *Context) BindYAML(obj interface{}) error 调用 MustBindWith 绑定yaml,tag使用yaml

非强制绑定

  • func (c *Context) ShouldBindWith(obj interface{}, b binding.Binding) error 通用的绑定方法
  • func (c *Context) ShouldBind(obj interface{}) error 调用 ShouldBindWith 自动根据请求类型判断绑定
  • 其余方法这里不列出,都是在上述方法基础上加 Should 并且都是调用 ShouldBindWith, 下面说两个不一样的

这里说一个需要注意的问题,如果是数据存储于 Body 里面的,gin是封装的标准库的http,而 Body 是io.ReadCloser 类型的,只能读取一次,之后就关闭,内容只允许读一次,也就是说,上述的 Bind 凡是读 Body 的,都不能再读第二次,这个可以用其他办法解决,这里暂且只说一个,那就是
func (c *Context) ShouldBindBodyWith(obj interface{}, bb binding.BindingBody) (err error) 方法,这个方法允许调用多次,因为它将内容暂时存在了 gin.Context 当中,比如绑定json如下代码所示:
ctx.ShouldBindBodyWith(&objA, binding.JSON)

还有个注意点就是,绑定的结构体,如果包含有子结构体,对于 form 传参来说,是不会有什么影响的,比如 a=1&b=2&c=3 , a b c 可以分别在不同的结构体中,可以是结构体指针也可以是结构体,具体可以参考 这里

服务器返回

这里总结下服务器返回的方法,不过调用完成之后,记得return
func (c *Context) String(code int, format string, values …interface{}) 返回类型为 string
func (c *Context) JSON(code *int*, obj interface{}) 这个用得最多的,返回 json

还有许多方法,这里不一一列举,可以参考 gin 源码学习

中间件(或叫拦截器)

中间件是在请求前后做一些事情,比如验证登录,打印日志等等工作,可以将接口逻辑划分开来,与业务代码分离,下面看看中间件是怎么使用的

中间件函数的定义其实和普通请求接口的定义是一样的,都是 type HandlerFunc func(*Context),中间件分为以下三类作用域

  • 全局中间件
  • group中间件
  • 单个接口级别的中间件

中间件的作用顺序是,定义在前面的先生效,也就是定义在前面的会先调用,而且可以定义多个中间件

全局中间件: 对所有请求接口都有效
group中间件: 对该组的接口有效
单个接口级别中间件: 只对该接口有效

现在介绍几个gin自带的全局中间件,还记得初始化gin的时候,调用的哪个方法吗,就是 gin.Default(),下面看看它的源码

func Default() *Engine { debugPrintWARNINGDefault() engine := New() engine.Use(Logger(), Recovery()) return engine } 

可以看到,也是调用 New() 这个函数构造了 Engine 对象,并且初始化了 2 个中间件,一个用于日志打印,另一个用于崩溃恢复,这2个都是全局中间件

gin主页的README有段代码,清晰的解释了这三种中间件的定义

func main() {
	// 使用New()初始化 r := gin.New() // 全局中间件: 日志打印 r.Use(gin.Logger()) // 全局中间件: r.Use(gin.Recovery()) // 单个接口的中间件 r.GET("/benchmark", MyBenchLogger(), benchEndpoint) authorized := r.Group("/") // 分组中间件: 只对该组接口有效 authorized.Use(AuthRequired()) { authorized.POST("/login", loginEndpoint) authorized.POST("/submit", submitEndpoint) authorized.POST("/read", readEndpoint) testing := authorized.Group("testing") testing.GET("/analytics", analyticsEndpoint) } r.Run(":8080") } 

中间件彼此形成一条链条,对于每个请求来说,它的调用关系如下图:

  • 在中间件内部调用 ctx.Next() 即是调用链条的下一级方法,比如,在全局中间件里调用 Next,则表示调用 group中间件函数,这就可以使用切面编程思想,把链条下一级函数看做一个切面,然后在前后做一些事情,比如计算接口的调用时间等。
  • 如果不显示调用Next,则该中间件函数执行完之后,会执行链条的下一级函数
  • 如果想要中断链条,则调用ctx.Abort() 函数,调用之后,会正常执行完当前中间件函数,但是不会再执行链条下一级了,而是准备返回接口。

举个例子

一般来说,定义一个中间件,都遵循下面这种风格,YourFunc() HandlerFunc 返回这个处理函数的方式,当然你也可以直接定义一个 HandlerFunc 也是可以的。

现在要实现一个功能,能够计算某个请求的耗时,使用中间件来完成,代码如下

timeCalc := func() gin.HandlerFunc { return func(ctx *gin.Context) { if ctx.Query("a") == "" { ctx.Abort() // 终止调用链条 ctx.JSON(http.StatusBadRequest, gin.H{ "message": "a参数有问题,请检查参数", }) return } start := time.Now() // Next 在这里相当于 接口函数,在Next之前则在接口函数之前执行 fmt.Println("Next之前") ctx.Next() fmt.Println("Next之后") cost := time.Since(start) // Next 之后,则相当于在接口函数之后执行,形成了一个切面 fmt.Printf("用时 %d 微秒\n", cost.Microseconds()) } } g.GET("/send", timeCalc(), func(ctx *gin.Context) { fmt.Println("进入接口函数") ctx.JSON(http.StatusOK, gin.H{ "a": ctx.Query("a"), }) }) // 服务端输出: // Next之前 // 进入接口函数 // Next之后 // 用时 231 微秒 

NOTE: 如果需要在接口链条的某一处,开辟一个gorutine进行处理,如果需要用到 gin.Context 的话,需要调用 ctx.Copy() 函数进行一份拷贝,然后在开辟的gorutine当中使用该拷贝

Gin一些开源中间件 这里可以找到一些比较实用的中间件,可以自己探索下

MODE

目前Gin有三种模式: debug release test 三种,可以通过设置 GIN_MODE 这个环境变量来控制
比如现在需要将这个web应用发布到正式环境,那么需要将生产机器上的gin的环境变量设置为 release: export GIN_MODE= release

在debug模式下,会在开头多一些打印

单元测试

下面这个例子可以参考一下

package main

import (
    "encoding/json"
    "io/ioutil"
    "net/http" "strings" "testing" "github.com/stretchr/testify/assert" ) func do(t *testing.T, req *http.Request) ([]byte, error) { resp, err := http.DefaultClient.Do(req) if err != nil { return nil, err } body, err := ioutil.ReadAll(resp.Body) if err != nil { return nil, err } assert.Equal(t, 200, resp.StatusCode) return body, nil } // curl 'localhost:8080/send?a=1&b=2' func TestGet(t *testing.T) { req, _ := http.NewRequest("GET", "http://localhost:8080/send?a=1&b=2", nil) body, err := do(t, req) assert.NoError(t, err) assert.Equal(t, "{\"a\":\"1\",\"b\":\"2\"}\n", string(body)) } // curl -XPOST 'localhost:8080/send' -H 'Content-Type: application/json' -d '{"a":1,"b":2,"c":3}' func TestPost(t *testing.T) { req, _ := http.NewRequest("POST", "http://localhost:8080/send", strings.NewReader(`{"a":1,"b":2,"c":3}`)) req.Header.Set("Content-Type", "application/json") // 传json记得修改 body, err := do(t, req) assert.NoError(t, err) type Resp struct { A int `json:"a"` B int `json:"b"` C int `json:"c"` } resp := new(Resp) assert.NoError(t, json.Unmarshal(body, resp)) assert.Equal(t, &Resp{ A: 1, B: 2, C: 3, }, resp) } 

当你的代码嵌套比较多,并且不易于在单元测试当中去启动这个服务的时候,可以使用这个方法,单元测试就相当于开了一个http client,去请求已启动的服务,这时候需要先启动项目的服务,才能调用单元测试哦。

下面介绍一个独立的,也是gin源码经常使用的这种测试方法,可以独立运行,不依赖于已启动的服务

func TestIndependent0(t *testing.T) {
    w := httptest.NewRecorder() // 用于返回的数据 ctx, _ := gin.CreateTestContext(w) // 模拟返回数据 ctx.JSON(http.StatusOK, gin.H{ "a": 1, }) assert.Equal(t, "{\"a\":1}\n", string(w.Body.Bytes())) } func TestIndependent1(t *testing.T) { w := httptest.NewRecorder() _, router := gin.CreateTestContext(w) router.GET("/send", func(c *gin.Context) { c.JSON(http.StatusOK, gin.H{ "a": 1, }) }) router.ServeHTTP(w, httptest.NewRequest("GET", "http://localhost:8080/send", nil)) t.Log(string(w.Body.Bytes())) // output: {"a":1} }

参数验证

我们知道,一个请求完全依赖前端的参数验证是不够的,需要前后端一起配合,才能万无一失,下面介绍一下,在Gin框架里面,怎么做接口参数验证的呢

gin 目前是使用 go-playground/validator 这个框架,截止目前,默认是使用 v10 版本;具体用法可以看看 validator package · go.dev 文档说明哦

下面以一个单元测试,简单说明下如何在tag里验证前端传递过来的数据

简单的例子

func TestValidation(t *testing.T) {
    ctx, _ := gin.CreateTestContext(httptest.NewRecorder())

    testCase := []struct { msg string // 本测试用例的说明 jsonStr string // 输入的参数 haveErr bool // 是否有 error bindStruct interface{} // 被绑定的结构体 errMsg string // 如果有错,错误信息 }{ { msg: "数据正确: ", jsonStr: `{"a":1}`, haveErr: false, bindStruct: &struct { A int `json:"a" binding:"required"` }{}, }, { msg: "数据错误: 缺少required的参数", jsonStr: `{"b":1}`, haveErr: true, bindStruct: &struct { A int `json:"a" binding:"required"` }{}, errMsg: "Key: 'A' Error:Field validation for 'A' failed on the 'required' tag", }, { msg: "数据正确: 参数是数字并且范围 1 <= a <= 10", jsonStr: `{"a":1}`, haveErr: false, bindStruct: &struct { A int `json:"a" binding:"required,max=10,min=1"` }{}, }, { msg: "数据错误: 参数数字不在范围之内", jsonStr: `{"a":1}`, haveErr: true, bindStruct: &struct { A int `json:"a" binding:"required,max=10,min=2"` }{}, errMsg: "Key: 'A' Error:Field validation for ‘A’ failed on the ‘min’ tag", }, { msg: "数据正确: 不等于列举的参数", jsonStr: `{"a":1}`, haveErr: false, bindStruct: &struct { A int `json:"a" binding:"required,ne=10"` }{}, }, { msg: "数据错误: 不能等于列举的参数", jsonStr: `{"a":1}`, haveErr: true, bindStruct: &struct { A int `json:"a" binding:"required,ne=1,ne=2"` // ne 表示不等于 }{}, errMsg: "Key: 'A' Error:Field validation for 'A' failed on the 'ne' tag", }, { msg: "数据正确: 需要大于10", jsonStr: `{"a":11}`, haveErr: false, bindStruct: &struct { A int `json:"a" binding:"required,gt=10"` }{}, }, // 总结: eq 等于,ne 不等于,gt 大于,gte 大于等于,lt 小于,lte 小于等于 { msg: "参数正确: 长度为5的字符串", jsonStr: `{"a":"hello"}`, haveErr: false, bindStruct: &struct { A string `json:"a" binding:"required,len=5"` // 需要参数的字符串长度为5 }{}, }, { msg: "参数正确: 为列举的字符串之一", jsonStr: `{"a":"hello"}`, haveErr: false, bindStruct: &struct { A string `json:"a" binding:"required,oneof=hello world"` // 需要参数是列举的其中之一,oneof 也可用于数字 }{}, }, { msg: "参数正确: 参数为email格式", jsonStr: `{"a":"[email protected]"}`, haveErr: false, bindStruct: &struct { A string `json:"a" binding:"required,email"` }{}, }, { msg: "参数错误: 参数不能等于0", jsonStr: `{"a":0}`, haveErr: true, bindStruct: &struct { A int `json:"a" binding:"gt=0|lt=0"` }{}, errMsg: "Key: 'A' Error:Field validation for 'A' failed on the 'gt=0|lt=0' tag", }, // 详情参考: https://pkg.go.dev/github.com/go-playground/validator/v10?tab=doc } for _, c := range testCase { ctx.Request = httptest.NewRequest("POST", "/", strings.NewReader(c.jsonStr)) if c.haveErr { err := ctx.ShouldBindJSON(c.bindStruct) assert.Error(t, err) assert.Equal(t, c.errMsg, err.Error()) } else { assert.NoError(t, ctx.ShouldBindJSON(c.bindStruct)) } } } // 测试 form 的情况 // time_format 这个tag 只能在 form tag 下能用 func TestValidationForm(t *testing.T) { ctx, _ := gin.CreateTestContext(httptest.NewRecorder()) testCase := []struct { msg string // 本测试用例的说明 formStr string // 输入的参数 haveErr bool // 是否有 error bindStruct interface{} // 被绑定的结构体 errMsg string // 如果有错,错误信息 }{ { msg: "数据正确: 时间格式", formStr: `a=2010-01-01`, haveErr: false, bindStruct: &struct { A time.Time `form:"a" binding:"required" time_format:"2006-01-02"` }{}, }, } for _, c := range testCase { ctx.Request = httptest.NewRequest("POST", "/", bytes.NewBufferString(c.formStr)) ctx.Request.Header.Add("Content-Type", binding.MIMEPOSTForm) // 这个很关键 if c.haveErr { err := ctx.ShouldBind(c.bindStruct) assert.Error(t, err) assert.Equal(t, c.errMsg, err.Error()) } else { assert.NoError(t, ctx.ShouldBind(c.bindStruct)) } } } 

简单解释一下,还记得上一篇文章讲的单元测试吗,这里只需要使用到 gin.Context 对象,所以忽略掉 gin.CreateTestContext()返回的第二个参数,但是需要将输入参数放进 gin.Context,也就是把 Request 对象设置进去 ,接下来才能使用 Bind 相关的方法哦。

其中 binding: 代替框架文档中的 validate,因为gin单独给验证设置了tag名称,可以参考gin源码 binding/default_validator.go

func (v *defaultValidator) lazyinit() { v.once.Do(func() { v.validate = validator.New() v.validate.SetTagName("binding") // 这里改为了 binding }) } 

上面的单元测试已经把基本的验证语法都列出来了,剩余的可以根据自身需求查询文档进行的配置

日志

使用gin默认的日志

首先来看看,初始化gin的时候,使用了 gin.Deatult() 方法,上一篇文章讲过,此时默认使用了2个全局中间件,其中一个就是日志相关的 Logger() 函数,返回了日志处理的中间件

这个函数是这样定义的

func Logger() HandlerFunc { return LoggerWithConfig(LoggerConfig{}) } 

继续跟源码,看来真正处理的就是 LoggerWithConfig() 函数了,下面列出部分关键源码

func LoggerWithConfig(conf LoggerConfig) HandlerFunc { formatter := conf.Formatter if formatter == nil { formatter = defaultLogFormatter } out := conf.Output if out == nil { out = DefaultWriter } notlogged := conf.SkipPaths isTerm := true if w, ok := out.(*os.File); !ok || os.Getenv("TERM") == "dumb" || (!isatty.IsTerminal(w.Fd()) && !isatty.IsCygwinTerminal(w.Fd())) { isTerm = false } var skip map[string]struct{} if length := len(notlogged); length > 0 { skip = make(map[string]struct{}, length) for _, path := range notlogged { skip[path] = struct{}{} } } return func(c *Context) { // Start timer start := time.Now() path := c.Request.URL.Path raw := c.Request.URL.RawQuery // Process request c.Next() // Log only when path is not being skipped if _, ok := skip[path]; !ok { // 中间省略这一大块是在处理打印的逻辑 // …… fmt.Fprint(out, formatter(param)) // 最后是通过 重定向到 out 进行输出 } } } 

稍微解释下,函数入口传参是 LoggerConfig 这个定义如下:

type LoggerConfig struct {
    Formatter LogFormatter
    Output io.Writer
    SkipPaths []string
}

而调用 Default() 初始化gin时候,这个结构体是一个空结构体,在 LoggerWithConfig 函数中,如果这个结构体内容为空,会为它设置一些默认值
默认日志输出是到 stdout 的,默认打印格式是由 defaultLogFormatter 这个函数变量控制的,如果想要改变日志输出,比如同时输出到文件stdout,可以在调用 Default() 之前,设置 DefaultWriter 这个变量;但是如果需要修改日志格式,则不能调用 Default() 了,可以调用 New() 初始化gin之后,使用 LoggerWithConfig() 函数,将自己定义的 LoggerConfig 传入。

使用第三方的日志

默认gin只会打印到 stdout,我们如果使用第三方的日志,则不需要管gin本身的输出,因为它不会输出到文件,正常使用第三方的日志工具即可。由于第三方的日志工具,我们需要实现一下 gin 本身打印接口(比如接口时间,接口名称,path等等信息)的功能,所以往往需要再定义一个中间件去打印。

logrus

GitHub主页

logrus 是一个比较优秀的日志框架,下面这个例子简单的使用它来记录下日志

func main() {
    g := gin.Default()
    gin.DisableConsoleColor()

    testLogrus(g)

    if err := g.Run(); err != nil { panic(err) } } func testLogrus(g *gin.Engine) { log := logrus.New() file, err := os.Create("mylog.txt") if err != nil { fmt.Println("err:", err.Error()) os.Exit(0) } log.SetOutput(io.MultiWriter(os.Stdout, file)) logMid := func() gin.HandlerFunc { return func(ctx *gin.Context) { var data string if ctx.Request.Method == http.MethodPost { // 如果是post请求,则读取body body, err := ctx.GetRawData() // body 只能读一次,读出来之后需要重置下 Body if err != nil { log.Fatal(err) } ctx.Request.Body = ioutil.NopCloser(bytes.NewBuffer(body)) // 重置body data = string(body) } start := time.Now() ctx.Next() cost := time.Since(start) log.Infof("方法: %s, URL: %s, CODE: %d, 用时: %dus, body数据: %s", ctx.Request.Method, ctx.Request.URL, ctx.Writer.Status(), cost.Microseconds(), data) } } g.Use(logMid()) // curl 'localhost:8080/send' g.GET("/send", func(ctx *gin.Context) { ctx.JSON(200, gin.H{"msg": "ok"}) }) // curl -XPOST 'localhost:8080/send' -d 'a=1' g.POST("/send", func(ctx *gin.Context) { ctx.JSON(200, gin.H{"a": ctx.PostForm("a")}) }) } 

zap

zap文档
zap同样是比较优秀的日志框架,是由uber公司主导开发的,这里就不单独举例子了,可与参考下 zap中间件 的实现

 
标签:  httpgoweb
 

参数验证

我们知道,一个请求完全依赖前端的参数验证是不够的,需要前后端一起配合,才能万无一失,下面介绍一下,在Gin框架里面,怎么做接口参数验证的呢

gin 目前是使用 go-playground/validator 这个框架,截止目前,默认是使用 v10 版本;具体用法可以看看 validator package · go.dev 文档说明哦

下面以一个单元测试,简单说明下如何在tag里验证前端传递过来的数据

简单的例子

func TestValidation(t *testing.T) {
    ctx, _ := gin.CreateTestContext(httptest.NewRecorder())

    testCase := []struct { msg string // 本测试用例的说明 jsonStr string // 输入的参数 haveErr bool // 是否有 error bindStruct interface{} // 被绑定的结构体 errMsg string // 如果有错,错误信息 }{ { msg: "数据正确: ", jsonStr: `{"a":1}`, haveErr: false, bindStruct: &struct { A int `json:"a" binding:"required"` }{}, }, { msg: "数据错误: 缺少required的参数", jsonStr: `{"b":1}`, haveErr: true, bindStruct: &struct { A int `json:"a" binding:"required"` }{}, errMsg: "Key: 'A' Error:Field validation for 'A' failed on the 'required' tag", }, { msg: "数据正确: 参数是数字并且范围 1 <= a <= 10", jsonStr: `{"a":1}`, haveErr: false, bindStruct: &struct { A int `json:"a" binding:"required,max=10,min=1"` }{}, }, { msg: "数据错误: 参数数字不在范围之内", jsonStr: `{"a":1}`, haveErr: true, bindStruct: &struct { A int `json:"a" binding:"required,max=10,min=2"` }{}, errMsg: "Key: 'A' Error:Field validation for ‘A’ failed on the ‘min’ tag", }, { msg: "数据正确: 不等于列举的参数", jsonStr: `{"a":1}`, haveErr: false, bindStruct: &struct { A int `json:"a" binding:"required,ne=10"` }{}, }, { msg: "数据错误: 不能等于列举的参数", jsonStr: `{"a":1}`, haveErr: true, bindStruct: &struct { A int `json:"a" binding:"required,ne=1,ne=2"` // ne 表示不等于 }{}, errMsg: "Key: 'A' Error:Field validation for 'A' failed on the 'ne' tag", }, { msg: "数据正确: 需要大于10", jsonStr: `{"a":11}`, haveErr: false, bindStruct: &struct { A int `json:"a" binding:"required,gt=10"` }{}, }, // 总结: eq 等于,ne 不等于,gt 大于,gte 大于等于,lt 小于,lte 小于等于 { msg: "参数正确: 长度为5的字符串", jsonStr: `{"a":"hello"}`, haveErr: false, bindStruct: &struct { A string `json:"a" binding:"required,len=5"` // 需要参数的字符串长度为5 }{}, }, { msg: "参数正确: 为列举的字符串之一", jsonStr: `{"a":"hello"}`, haveErr: false, bindStruct: &struct { A string `json:"a" binding:"required,oneof=hello world"` // 需要参数是列举的其中之一,oneof 也可用于数字 }{}, }, { msg: "参数正确: 参数为email格式", jsonStr: `{"a":"[email protected]"}`, haveErr: false, bindStruct: &struct { A string `json:"a" binding:"required,email"` }{}, }, { msg: "参数错误: 参数不能等于0", jsonStr: `{"a":0}`, haveErr: true, bindStruct: &struct { A int `json:"a" binding:"gt=0|lt=0"` }{}, errMsg: "Key: 'A' Error:Field validation for 'A' failed on the 'gt=0|lt=0' tag", }, // 详情参考: https://pkg.go.dev/github.com/go-playground/validator/v10?tab=doc } for _, c := range testCase { ctx.Request = httptest.NewRequest("POST", "/", strings.NewReader(c.jsonStr)) if c.haveErr { err := ctx.ShouldBindJSON(c.bindStruct) assert.Error(t, err) assert.Equal(t, c.errMsg, err.Error()) } else { assert.NoError(t, ctx.ShouldBindJSON(c.bindStruct)) } } } // 测试 form 的情况 // time_format 这个tag 只能在 form tag 下能用 func TestValidationForm(t *testing.T) { ctx, _ := gin.CreateTestContext(httptest.NewRecorder()) testCase := []struct { msg string // 本测试用例的说明 formStr string // 输入的参数 haveErr bool // 是否有 error bindStruct interface{} // 被绑定的结构体 errMsg string // 如果有错,错误信息 }{ { msg: "数据正确: 时间格式", formStr: `a=2010-01-01`, haveErr: false, bindStruct: &struct { A time.Time `form:"a" binding:"required" time_format:"2006-01-02"` }{}, }, } for _, c := range testCase { ctx.Request = httptest.NewRequest("POST", "/", bytes.NewBufferString(c.formStr)) ctx.Request.Header.Add("Content-Type", binding.MIMEPOSTForm) // 这个很关键 if c.haveErr { err := ctx.ShouldBind(c.bindStruct) assert.Error(t, err) assert.Equal(t, c.errMsg, err.Error()) } else { assert.NoError(t, ctx.ShouldBind(c.bindStruct)) } } } 

简单解释一下,还记得上一篇文章讲的单元测试吗,这里只需要使用到 gin.Context 对象,所以忽略掉 gin.CreateTestContext()返回的第二个参数,但是需要将输入参数放进 gin.Context,也就是把 Request 对象设置进去 ,接下来才能使用 Bind 相关的方法哦。

其中 binding: 代替框架文档中的 validate,因为gin单独给验证设置了tag名称,可以参考gin源码 binding/default_validator.go

func (v *defaultValidator) lazyinit() { v.once.Do(func() { v.validate = validator.New() v.validate.SetTagName("binding") // 这里改为了 binding }) } 

上面的单元测试已经把基本的验证语法都列出来了,剩余的可以根据自身需求查询文档进行的配置

日志

使用gin默认的日志

首先来看看,初始化gin的时候,使用了 gin.Deatult() 方法,上一篇文章讲过,此时默认使用了2个全局中间件,其中一个就是日志相关的 Logger() 函数,返回了日志处理的中间件

这个函数是这样定义的

func Logger() HandlerFunc { return LoggerWithConfig(LoggerConfig{}) } 

继续跟源码,看来真正处理的就是 LoggerWithConfig() 函数了,下面列出部分关键源码

func LoggerWithConfig(conf LoggerConfig) HandlerFunc { formatter := conf.Formatter if formatter == nil { formatter = defaultLogFormatter } out := conf.Output if out == nil { out = DefaultWriter } notlogged := conf.SkipPaths isTerm := true if w, ok := out.(*os.File); !ok || os.Getenv("TERM") == "dumb" || (!isatty.IsTerminal(w.Fd()) && !isatty.IsCygwinTerminal(w.Fd())) { isTerm = false } var skip map[string]struct{} if length := len(notlogged); length > 0 { skip = make(map[string]struct{}, length) for _, path := range notlogged { skip[path] = struct{}{} } } return func(c *Context) { // Start timer start := time.Now() path := c.Request.URL.Path raw := c.Request.URL.RawQuery // Process request c.Next() // Log only when path is not being skipped if _, ok := skip[path]; !ok { // 中间省略这一大块是在处理打印的逻辑 // …… fmt.Fprint(out, formatter(param)) // 最后是通过 重定向到 out 进行输出 } } } 

稍微解释下,函数入口传参是 LoggerConfig 这个定义如下:

type LoggerConfig struct {
    Formatter LogFormatter
    Output io.Writer
    SkipPaths []string
}

而调用 Default() 初始化gin时候,这个结构体是一个空结构体,在 LoggerWithConfig 函数中,如果这个结构体内容为空,会为它设置一些默认值
默认日志输出是到 stdout 的,默认打印格式是由 defaultLogFormatter 这个函数变量控制的,如果想要改变日志输出,比如同时输出到文件stdout,可以在调用 Default() 之前,设置 DefaultWriter 这个变量;但是如果需要修改日志格式,则不能调用 Default() 了,可以调用 New() 初始化gin之后,使用 LoggerWithConfig() 函数,将自己定义的 LoggerConfig 传入。

使用第三方的日志

默认gin只会打印到 stdout,我们如果使用第三方的日志,则不需要管gin本身的输出,因为它不会输出到文件,正常使用第三方的日志工具即可。由于第三方的日志工具,我们需要实现一下 gin 本身打印接口(比如接口时间,接口名称,path等等信息)的功能,所以往往需要再定义一个中间件去打印。

logrus

GitHub主页

logrus 是一个比较优秀的日志框架,下面这个例子简单的使用它来记录下日志

func main() {
    g := gin.Default()
    gin.DisableConsoleColor()

    testLogrus(g)

    if err := g.Run(); err != nil { panic(err) } } func testLogrus(g *gin.Engine) { log := logrus.New() file, err := os.Create("mylog.txt") if err != nil { fmt.Println("err:", err.Error()) os.Exit(0) } log.SetOutput(io.MultiWriter(os.Stdout, file)) logMid := func() gin.HandlerFunc { return func(ctx *gin.Context) { var data string if ctx.Request.Method == http.MethodPost { // 如果是post请求,则读取body body, err := ctx.GetRawData() // body 只能读一次,读出来之后需要重置下 Body if err != nil { log.Fatal(err) } ctx.Request.Body = ioutil.NopCloser(bytes.NewBuffer(body)) // 重置body data = string(body) } start := time.Now() ctx.Next() cost := time.Since(start) log.Infof("方法: %s, URL: %s, CODE: %d, 用时: %dus, body数据: %s", ctx.Request.Method, ctx.Request.URL, ctx.Writer.Status(), cost.Microseconds(), data) } } g.Use(logMid()) // curl 'localhost:8080/send' g.GET("/send", func(ctx *gin.Context) { ctx.JSON(200, gin.H{"msg": "ok"}) }) // curl -XPOST 'localhost:8080/send' -d 'a=1' g.POST("/send", func(ctx *gin.Context) { ctx.JSON(200, gin.H{"a": ctx.PostForm("a")}) }) } 

zap

zap文档
zap同样是比较优秀的日志框架,是由uber公司主导开发的,这里就不单独举例子了,可与参考下 zap中间件 的实现

猜你喜欢

转载自www.cnblogs.com/xinxihua/p/12553183.html