作為一款企業級生產力的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置為400ShouldBind*方法,出錯不會設置返回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使用headerfunc (c *Context) BindJSON(obj interface{}) error調用MustBindWith綁定json,tag使用jsonfunc (c *Context) BindQuery(obj interface{}) error調用MustBindWith綁定 Query Param,tag使用formfunc (c *Context) BindUri(obj interface{}) error調用MustBindWith綁定Path路徑參數,tag使用urifunc (c *Context) BindXML(obj interface{}) error調用MustBindWith綁定xml,tag使用xmlfunc (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{}) 返回類型為 stringfunc (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":"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 := 