GO語言web框架Gin之完全指南(二)


這篇主要講解自定義日志數據驗證

參數驗證

我們知道,一個請求完全依賴前端的參數驗證是不夠的,需要前后端一起配合,才能萬無一失,下面介紹一下,在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":"hello@gmail.com"}`,
            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中間件 的實現


免責聲明!

本站轉載的文章為個人學習借鑒使用,本站對版權不負任何法律責任。如果侵犯了您的隱私權益,請聯系本站郵箱yoyou2525@163.com刪除。



 
粵ICP備18138465號   © 2018-2025 CODEPRJ.COM