Skip to content

Gin

官方文檔:Gin Web Framework (gin-gonic.com)

倉庫地址:gin-gonic/gin: Gin is a HTTP web framework written in Go (Golang)

官方示例:gin-gonic/examples: A repository to host examples and tutorials for Gin. (github.com)

介紹

Gin 是一個用 Go (Golang) 編寫的 Web 框架。 它具有類似 martini 的 API,性能要好得多,多虧了 httprouter,速度提高了 40 倍。 如果您需要性能和良好的生產力,您一定會喜歡 Gin。Gin 相比於 Iris 和 Beego 而言,更傾向於輕量化的框架,只負責 Web 部分,追求極致的路由性能,功能或許沒那麼全,勝在輕量易拓展,這也是它的優點。因此,在所有的 Web 框架中,Gin 是最容易上手和學習的。

特性

  • 快速:基於 Radix 樹的路由,小內存佔用。沒有反射。可預測的 API 性能。
  • 支持中間件:傳入的 HTTP 請求可以由一系列中間件和最終操作來處理。 例如:Logger,Authorization,GZIP,最終操作 DB。
  • Crash 處理:Gin 可以 catch 一個發生在 HTTP 請求中的 panic 並 recover 它。這樣,你的服務器將始終可用。
  • JSON 驗證:Gin 可以解析並驗證請求的 JSON,例如檢查所需值的存在。
  • 路由組:更好地組織路由。是否需要授權,不同的 API 版本…… 此外,這些組可以無限制地嵌套而不會降低性能。
  • 錯誤管理:Gin 提供了一種方便的方法來收集 HTTP 請求期間發生的所有錯誤。最終,中間件可以將它們寫入日志文件,數據庫並通過網絡發送。
  • 內置渲染:Gin 為 JSON,XML 和 HTML 渲染提供了易於使用的 API。
  • 可擴展性:新建一個中間件非常簡單

安裝

截止目前2022/11/22,gin 支持的 go 最低版本為1.16,建議使用go mod來管理項目依賴。

powershell
go get -u github.com/gin-gonic/gin

導入

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

快速開始

go
package main

import (
   "github.com/gin-gonic/gin"
   "net/http"
)

func main() {
   engine := gin.Default() //創建gin引擎
   engine.GET("/ping", func(context *gin.Context) {
      context.JSON(http.StatusOK, gin.H{
         "message": "pong",
      })
   })
   engine.Run() //開啟服務器,默認監聽localhost:8080
}

請求 URL

http
GET localhost:8080/ping

返回

http
HTTP/1.1 200 OK
Content-Type: application/json; charset=utf-8
Date: Tue, 22 Nov 2022 08:47:11 GMT
Content-Length: 18

{
  "message": "pong"
}
Response file saved.
> 2022-11-22T164711.200.json

文檔

其實Gin官方文檔裡面並沒有多少教程,大多數只是一些介紹和基本使用和一些例子,但是gin-gonic/ 組織下,有一個gin-gonic/examples倉庫,這是一個由社區共同維護的gin示例倉庫。都是全英文,更新時間並不是特別頻繁,筆者也是從這裡慢慢學習的gin框架。

示例倉庫地址:gin-gonic/examples: A repository to host examples and tutorials for Gin. (github.com)

TIP

開始之前建議可以閱讀一下HttpRouter:HttpRouter

參數解析

gin中的參數解析總共支持三種方式:路由參數URL參數表單參數,下面逐一講解並結合代碼示例,比較簡單易懂。

路由參數

路由參數其實是封裝了HttpRouter的參數解析功能,使用方法基本上與HttpRouter一致。

go
package main

import (
   "github.com/gin-gonic/gin"
   "log"
   "net/http"
)

func main() {
   e := gin.Default()
   e.GET("/findUser/:username/:userid", FindUser)
   e.GET("/downloadFile/*filepath", UserPage)

   log.Fatalln(e.Run(":8080"))
}

// 命名參數示例
func FindUser(c *gin.Context) {
   username := c.Param("username")
   userid := c.Param("userid")
   c.String(http.StatusOK, "username is %s\n userid is %s", username, userid)
}

// 路徑參數示例
func UserPage(c *gin.Context) {
   filepath := c.Param("filepath")
   c.String(http.StatusOK, "filepath is  %s", filepath)
}

示例一

bash
curl --location --request GET '127.0.0.1:8080/findUser/jack/001'
username is jack
 userid is 001

示例二

bash
curl --location --request GET '127.0.0.1:8080/downloadFile/img/fruit.png'
filepath is  /img/fruit.png

URL 參數

傳統的 URL 參數,格式就是/url?key=val&key1=val1&key2=val2

go
package main

import (
   "github.com/gin-gonic/gin"
   "log"
   "net/http"
)

func main() {
   e := gin.Default()
   e.GET("/findUser", FindUser)
   log.Fatalln(e.Run(":8080"))
}

func FindUser(c *gin.Context) {
   username := c.DefaultQuery("username", "defaultUser")
   userid := c.Query("userid")
   c.String(http.StatusOK, "username is %s\nuserid is %s", username, userid)
}

示例一

bash
curl --location --request GET '127.0.0.1:8080/findUser?username=jack&userid=001'
username is jack
userid is 001

示例二

bash
curl --location --request GET '127.0.0.1:8080/findUser'
username is defaultUser
userid is

表單參數

表單的內容類型一般有application/jsonapplication/x-www-form-urlencodedapplication/xmlmultipart/form-data

go
package main

import (
  "github.com/gin-gonic/gin"
  "net/http"
)

func main() {
  e := gin.Default()
  e.POST("/register", RegisterUser)
  e.POST("/update", UpdateUser)
  e.Run(":8080")
}

func RegisterUser(c *gin.Context) {
  username := c.PostForm("username")
  password := c.PostForm("password")
  c.String(http.StatusOK, "successfully registered,your username is [%s],password is [%s]", username, password)
}

func UpdateUser(c *gin.Context) {
  var form map[string]string
  c.ShouldBind(&form)
  c.String(http.StatusOK, "successfully update,your username is [%s],password is [%s]", form["username"], form["password"])
}

示例一:使用form-data

bash
curl --location --request POST '127.0.0.1:8080/register' \
--form 'username="jack"' \
--form 'password="123456"'
successfully registered,your username is [jack],password is [123456]

PostForm方法默認解析application/x-www-form-urlencodedmultipart/form-data類型的表單。

示例二:使用json

bash
curl --location --request POST '127.0.0.1:8080/update' \
--header 'Content-Type: application/json' \
--data-raw '{
    "username":"username",
    "password":"123456"
}'
successfully update,your username is [username],password is [123456]

數據解析

在大多數情況下,我們都會使用結構體來承載數據,而不是直接解析參數。在gin中,用於數據綁定的方法主要是Bind()ShouldBind(),兩者的區別在於前者內部也是直接調用的ShouldBind(),當然返回err時,會直接進行 400 響應,後者則不會。如果想要更加靈活的進行錯誤處理,建議選擇後者。這兩個函數會自動根據請求的content-type來進行推斷用什麼方式解析。

go
func (c *Context) MustBindWith(obj any, b binding.Binding) error {
    // 調用了ShouldBindWith()
  if err := c.ShouldBindWith(obj, b); err != nil {
    c.AbortWithError(http.StatusBadRequest, err).SetType(ErrorTypeBind) // 直接響應400 badrequest
    return err
  }
  return nil
}

如果想要自行選擇可以使用BindWith()ShouldBindWith(),例如

go
c.MustBindWith(obj, binding.JSON) //json
c.MustBindWith(obj, binding.XML) //xml

gin 支持的綁定類型有如下幾種實現:

go
var (
   JSON          = jsonBinding{}
   XML           = xmlBinding{}
   Form          = formBinding{}
   Query         = queryBinding{}
   FormPost      = formPostBinding{}
   FormMultipart = formMultipartBinding{}
   ProtoBuf      = protobufBinding{}
   MsgPack       = msgpackBinding{}
   YAML          = yamlBinding{}
   Uri           = uriBinding{}
   Header        = headerBinding{}
   TOML          = tomlBinding{}
)

示例

go
package main

import (
  "fmt"
  "github.com/gin-gonic/gin"
  "net/http"
)

type LoginUser struct {
  Username string `binding:"required" json:"username" form:"username" uri:"username"`
  Password string `binding:"required" json:"password" form:"password" uri:"password"`
}

func main() {
  e := gin.Default()
  e.POST("/loginWithJSON", Login)
  e.POST("/loginWithForm", Login)
  e.GET("/loginWithQuery/:username/:password", Login)
  e.Run(":8080")
}

func Login(c *gin.Context) {
  var login LoginUser
    // 使用ShouldBind來讓gin自動推斷
  if c.ShouldBind(&login) == nil && login.Password != "" && login.Username != "" {
    c.String(http.StatusOK, "login successfully !")
  } else {
    c.String(http.StatusBadRequest, "login failed !")
  }
  fmt.Println(login)
}

Json 數據綁定

bash
curl --location --request POST '127.0.0.1:8080/loginWithJSON' \
--header 'Content-Type: application/json' \
--data-raw '{
    "username":"root",
    "password":"root"
}'
login successfully !

表單數據綁定

go
curl --location --request POST '127.0.0.1:8080/loginWithForm' \
--form 'username="root"' \
--form 'password="root"'
login successfully !

URL 數據綁定

go
curl --location --request GET '127.0.0.1:8080/loginWithQuery/root/root'
login failed !

到了這裡就會發生錯誤了,因為這裡輸出的content-type是空字符串,無法推斷到底是要如何進行數據解析。所以當使用 URL 參數時,我們應該手動指定解析方式,例如:

go
if err := c.ShouldBindUri(&login); err == nil && login.Password != "" && login.Username != "" {
   c.String(http.StatusOK, "login successfully !")
} else {
   fmt.Println(err)
   c.String(http.StatusBadRequest, "login failed !")
}

多次綁定

一般方法都是通過調用 c.Request.Body 方法綁定數據,但不能多次調用這個方法,例如c.ShouldBind,不可重用,如果想要多次綁定的話,可以使用

c.ShouldBindBodyWith

go
func SomeHandler(c *gin.Context) {
  objA := formA{}
  objB := formB{}
  // 讀取 c.Request.Body 並將結果存入上下文。
  if errA := c.ShouldBindBodyWith(&objA, binding.JSON); errA == nil {
    c.String(http.StatusOK, `the body should be formA`)
  // 這時, 復用存儲在上下文中的 body。
  }
  if errB := c.ShouldBindBodyWith(&objB, binding.JSON); errB == nil {
    c.String(http.StatusOK, `the body should be formB JSON`)
  // 可以接受其他格式
  }
  if errB2 := c.ShouldBindBodyWith(&objB, binding.XML); errB2 == nil {
    c.String(http.StatusOK, `the body should be formB XML`)
  }
}

TIP

c.ShouldBindBodyWith 會在綁定之前將 body 存儲到上下文中。 這會對性能造成輕微影響,如果調用一次就能完成綁定的話,那就不要用這個方法。只有某些格式需要此功能,如 JSON, XML, MsgPack, ProtoBuf。 對於其他格式, 如 Query, Form, FormPost, FormMultipart 可以多次調用c.ShouldBind() 而不會造成任何性能損失 。

數據校驗

gin內置的校驗工具其實是github.com/go-playground/validator/v10,使用方法也幾乎沒有什麼差別,Validator

簡單示例

go
type LoginUser struct {
   Username string `binding:"required"  json:"username" form:"username" uri:"username"`
   Password string `binding:"required" json:"password" form:"password" uri:"password"`
}

func main() {
   e := gin.Default()
   e.POST("/register", Register)
   log.Fatalln(e.Run(":8080"))
}

func Register(ctx *gin.Context) {
   newUser := &LoginUser{}
   if err := ctx.ShouldBind(newUser); err == nil {
      ctx.String(http.StatusOK, "user%+v", *newUser)
   } else {
      ctx.String(http.StatusBadRequest, "invalid user,%v", err)
   }
}

測試

curl --location --request POST 'http://localhost:8080/register' \
--header 'Content-Type: application/json' \
--data-raw '{
    "username":"jack1"

}'

輸出

invalid user,Key: 'LoginUser.Password' Error:Field validation for 'Password' failed on the 'required' tag

TIP

需要注意的一點是,gin 中 validator 的校驗 tag 是binding,而單獨使用validator的的校驗 tag 是validator

數據響應

數據響應是接口處理中最後一步要做的事情,後端將所有數據處理完成後,通過 HTTP 協議返回給調用者,gin 對於數據響應提供了豐富的內置支持,用法簡潔明了,上手十分容易。

簡單示例

go
func Hello(c *gin.Context) {
    // 返回純字符串格式的數據,http.StatusOK代表著200狀態碼,數據為"Hello world !"
  c.String(http.StatusOK, "Hello world !")
}

HTML 渲染

TIP

文件加載的時候,默認根路徑是項目路徑,也就是go.mod文件所在的路徑,下面例子中的index.html即位於根路徑下的index.html,不過一般情況下這些模板文件都不會放在根路徑,而是會存放在靜態資源文件夾中

go
func main() {
   e := gin.Default()
    // 加載HTML文件,也可以使用Engine.LoadHTMLGlob()
   e.LoadHTMLFiles("index.html")
   e.GET("/", Index)
   log.Fatalln(e.Run(":8080"))
}

func Index(c *gin.Context) {
   c.HTML(http.StatusOK, "index.html", gin.H{})
}

測試

curl --location --request GET 'http://localhost:8080/'

返回

html
<!DOCTYPE html>
<html lang="en">
  <head>
    <meta charset="UTF-8" />
    <title>GinLearn</title>
  </head>

  <body>
    <h1>Hello World!</h1>
    <h1>This is a HTML Template Render Example</h1>
  </body>
</html>

快速響應

前面經常用到context.String()方法來進行數據響應,這是最原始的響應方法,直接返回一個字符串,gin中其實還內置了許多了快速響應的方法例如:

go
// 使用Render寫入響應頭,並進行數據渲染
func (c *Context) Render(code int, r render.Render)

// 渲染一個HTML模板,name是html路徑,obj是內容
func (c *Context) HTML(code int, name string, obj any)

// 以美化了的縮進JSON字符串進行數據渲染,通常不建議使用這個方法,因為會造成更多的傳輸消耗。
func (c *Context) IndentedJSON(code int, obj any)

// 安全的JSON,可以防止JSON劫持,詳情了解:https://www.cnblogs.com/xusion/articles/3107788.html
func (c *Context) SecureJSON(code int, obj any)

// JSONP方式進行渲染
func (c *Context) JSONP(code int, obj any)

// JSON方式進行渲染
func (c *Context) JSON(code int, obj any)

// JSON方式進行渲染,會將unicode碼轉換為ASCII碼
func (c *Context) AsciiJSON(code int, obj any)

// JSON方式進行渲染,不會對HTML特殊字符串進行轉義
func (c *Context) PureJSON(code int, obj any)

// XML方式進行渲染
func (c *Context) XML(code int, obj any)

// YML方式進行渲染
func (c *Context) YAML(code int, obj any)

// TOML方式進行渲染
func (c *Context) TOML(code int, obj interface{})

// ProtoBuf方式進行渲染
func (c *Context) ProtoBuf(code int, obj any)

// String方式進行渲染
func (c *Context) String(code int, format string, values ...any)

// 重定向到特定的位置
func (c *Context) Redirect(code int, location string)

// 將data寫入響應流中
func (c *Context) Data(code int, contentType string, data []byte)

// 通過reader讀取流並寫入響應流中
func (c *Context) DataFromReader(code int, contentLength int64, contentType string, reader io.Reader, extraHeaders map[string]string)

// 高效的將文件寫入響應流
func (c *Context) File(filepath string)

// 以一種高效的方式將fs中的文件流寫入響應流
func (c *Context) FileFromFS(filepath string, fs http.FileSystem)

// 以一種高效的方式將fs中的文件流寫入響應流,並且在客戶端會以指定的文件名進行下載
func (c *Context) FileAttachment(filepath, filename string)

// 將服務端推送流寫入響應流中
func (c *Context) SSEvent(name string, message any)

// 發送一個流響應並返回一個布爾值,以此來判斷客戶端是否在流中間斷開
func (c *Context) Stream(step func(w io.Writer) bool) bool

對於大多數應用而言,用的最多的還是context.JSON,其他的相對而言要少一些,這裡就不舉例子演示了,因為都比較簡單易懂,差不多都是直接調用的事情。

異步處理

在 gin 中,異步處理需要結合 goroutine 使用,使用起來十分簡單。

go
// copy返回一個當前Context的副本以便在當前Context作用范圍外安全的使用,可以用於傳遞給一個goroutine
func (c *Context) Copy() *Context
go
func main() {
  e := gin.Default()
  e.GET("/hello", Hello)
  log.Fatalln(e.Run(":8080"))
}

func Hello(c *gin.Context) {
  ctx := c.Copy()
  go func() {
    // 子協程應該使用Context的副本,不應該使用原始Context
    log.Println("異步處理函數: ", ctx.HandlerNames())
  }()
  log.Println("接口處理函數: ", c.HandlerNames())
  c.String(http.StatusOK, "hello")
}

測試

go
curl --location --request GET 'http://localhost:8080/hello'

輸出

go
2022/12/21 13:33:47 異步處理函數:  []
2022/12/21 13:33:47 接口處理函數:  [github.com/gin-gonic/gin.LoggerWithConfig.func1 github.com/gin-gonic/gin.CustomRecoveryWithWriter.func1 main.Hello]
[GIN] 2022/12/21 - 13:33:47 | 200 |     11.1927ms |             ::1 | GET      "/hello"

可以看到兩者輸出不同,副本在復制時,為了安全考慮,刪掉了許多元素的值。

文件傳輸

文件傳輸是 Web 應用的一個不可或缺的功能,gin 對於此的支持也是封裝的十分簡單,但其實本質上和用原生的net/http的流程都差不多。流程都是從請求體中讀取文件流,然後再保存到本地。

單文件上傳

go
func main() {
  e := gin.Default()
  e.POST("/upload", uploadFile)
  log.Fatalln(e.Run(":8080"))
}

func uploadFile(ctx *gin.Context) {
  // 獲取文件
  file, err := ctx.FormFile("file")
  if err != nil {
    ctx.String(http.StatusBadRequest, "%+v", err)
    return
  }
  // 保存在本地
  err = ctx.SaveUploadedFile(file, "./"+file.Filename)
  if err != nil {
    ctx.String(http.StatusBadRequest, "%+v", err)
    return
  }
  // 返回結果
  ctx.String(http.StatusOK, "upload %s size:%d byte successfully!", file.Filename, file.Size)
}

測試

curl --location --request POST 'http://localhost:8080/upload' \
--form 'file=@"/C:/Users/user/Pictures/Camera Roll/a.jpg"'

結果

upload a.jpg size:1424 byte successfully!

TIP

一般情況下,上傳文件的Method都會指定用POST,一些公司可能會傾向於使用PUT,前者是簡單 HTTP 請求,後者是復雜 HTTP 請求,具體區別不作贅述,如果使用後者的話,尤其是前後端分離的項目時,需要進行相應的跨域處理,而 Gin 默認的配置是不支持跨域的跨域配置

多文件上傳

go
func main() {
   e := gin.Default()
   e.POST("/upload", uploadFile)
   e.POST("/uploadFiles", uploadFiles)
   log.Fatalln(e.Run(":8080"))
}

func uploadFiles(ctx *gin.Context) {
  // 獲取gin解析好的multipart表單
  form, _ := ctx.MultipartForm()
  // 根據鍵值取得對應的文件列表
  files := form.File["files"]
  // 遍歷文件列表,保存到本地
  for _, file := range files {
    err := ctx.SaveUploadedFile(file, "./"+file.Filename)
    if err != nil {
      ctx.String(http.StatusBadRequest, "upload failed")
      return
    }
  }
  // 返回結果
  ctx.String(http.StatusOK, "upload %d files successfully!", len(files))
}

測試

curl --location --request POST 'http://localhost:8080/uploadFiles' \
--form 'files=@"/C:/Users/Stranger/Pictures/Camera Roll/a.jpg"' \
--form 'files=@"/C:/Users/Stranger/Pictures/Camera Roll/123.jpg"' \
--form 'files=@"/C:/Users/Stranger/Pictures/Camera Roll/girl.jpg"'

輸出

upload 3 files successfully!

文件下載

關於文件下載的部分 Gin 對於原有標准庫的 API 再一次封裝,使得文件下載異常簡單。

go
func main() {
  e := gin.Default()
  e.POST("/upload", uploadFile)
  e.POST("/uploadFiles", uploadFiles)
  e.GET("/download/:filename", download)
  log.Fatalln(e.Run(":8080"))
}

func download(ctx *gin.Context) {
    // 獲取文件名
  filename := ctx.Param("filename")
    // 返回對應文件
  ctx.FileAttachment(filename, filename)
}

測試

curl --location --request GET 'http://localhost:8080/download/a.jpg'

結果

Content-Disposition: attachment; filename="a.jpg"
Date: Wed, 21 Dec 2022 08:04:17 GMT
Last-Modified: Wed, 21 Dec 2022 07:50:44 GMT

是不是覺得簡單過頭了,不妨不用框架的方法,自行編寫一遍過程

go
func download(ctx *gin.Context) {
   // 獲取參數
   filename := ctx.Param("filename")

   // 請求響應對象和請求對象
   response, request := ctx.Writer, ctx.Request
   // 寫入響應頭
   // response.Header().Set("Content-Type", "application/octet-stream") 以二進制流傳輸文件
   response.Header().Set("Content-Disposition", `attachment; filename*=UTF-8''`+url.QueryEscape(filename)) // 對文件名進行安全轉義
   response.Header().Set("Content-Transfer-Encoding", "binary")                                            // 傳輸編碼
   http.ServeFile(response, request, filename)
}

其實net/http也已經封裝的足夠好了

TIP

可以通過Engine.MaxMultipartMemory來設置文件傳輸的最大內存,默認為32 << 20 // 32 MB

路由管理

路由管理是一個系統中非常重要的部分,需要確保每一個請求都能被正確的映射到對應的函數上。

路由組

創建一個路由組是將接口分類,不同類別的接口對應不同的功能,也更易於管理。

go
func Hello(c *gin.Context) {

}

func Login(c *gin.Context) {

}

func Update(c *gin.Context) {

}

func Delete(c *gin.Context) {

}

假設我們有以上四個接口,暫時不管其內部實現,HelloLogin是一組,UpdateDelete是一組。

go
func (group *RouterGroup) Group(relativePath string, handlers ...HandlerFunc) *RouterGroup

在創建分組的時候,我們也可以給分組的根路由注冊處理器,不過大多數時候並不會這麼做。

go
func main() {
  e := gin.Default()
  v1 := e.Group("v1")
  {
    v1.GET("/hello", Hello)
    v1.GET("/login", Login)
  }
  v2 := e.Group("v2")
  {
    v2.POST("/update", Update)
    v2.DELETE("/delete", Delete)
  }
}

我們將其分成了v1v2兩個分組,其中的花括號{}僅僅只是為了規范,表名花括號內注冊的處理器是屬於同一個路由分組,在功能上沒有任何作用。同樣的,gin 也支持嵌套分組,方法與上例一致,這裡就不再演示。

404 路由

gin 中的Engine結構體提供了一個方法NoRoute,來設置當訪問的 URL 不存在時如何處理,開發者可以將邏輯寫入此方法中,以便路由未找到時自動調用,默認會返回 404 狀態碼

go
func (engine *Engine) NoRoute(handlers ...HandlerFunc)

我們拿上個例子舉例

go
func main() {
   e := gin.Default()
   v1 := e.Group("v1")
   {
      v1.GET("/hello", Hello)
      v1.GET("/login", Login)
   }
   v2 := e.Group("v2")
   {
      v2.POST("/update", Update)
      v2.DELETE("/delete", Delete)
   }
   // 注冊處理器
   e.NoRoute(func(context *gin.Context) { // 這裡只是演示,不要在生產環境中直接返回HTML代碼
      context.String(http.StatusNotFound, "<h1>404 Page Not Found</h1>")
   })
   log.Fatalln(e.Run(":8080"))
}

隨便發一個請求

curl --location --request GET 'http://localhost:8080/'
<h1>404 Page Not Found</h1>

405 路由

Http 狀態碼中,405 代表著當前請求的方法類型是不允許的,gin 中提供了如下方法

go
func (engine *Engine) NoMethod(handlers ...HandlerFunc)

來注冊一個處理器,以便在發生時自動調用,前提是設置Engine.HandleMethodNotAllowed = true

go
func main() {
   e := gin.Default()
   // 需要將其設置為true
   e.HandleMethodNotAllowed = true
   v1 := e.Group("/v1")
   {
      v1.GET("/hello", Hello)
      v1.GET("/login", Login)
   }
   v2 := e.Group("/v2")
   {
      v2.POST("/update", Update)
      v2.DELETE("/delete", Delete)
   }
   e.NoRoute(func(context *gin.Context) {
      context.String(http.StatusNotFound, "<h1>404 Page Not Found</h1>")
   })
   // 注冊處理器
   e.NoMethod(func(context *gin.Context) {
      context.String(http.StatusMethodNotAllowed, "method not allowed")
   })
   log.Fatalln(e.Run(":8080"))
}

配置好後,gin 默認的 header 是不支持OPTION請求的,測試一下

curl --location --request OPTIONS 'http://localhost:8080/v2/delete'
method not allowed

至此配置成功

重定向

gin 中的重定向十分簡單,調用gin.Context.Redirect()方法即可。

go
func main() {
  e := gin.Default()
  e.GET("/", Index)
  e.GET("/hello", Hello)
  log.Fatalln(e.Run(":8080"))
}

func Index(c *gin.Context) {
  c.Redirect(http.StatusMovedPermanently, "/hello")
}

func Hello(c *gin.Context) {
  c.String(http.StatusOK, "hello")
}

測試

curl --location --request GET 'http://localhost:8080/'

輸出

hello

中間件

gin 十分輕便靈活,拓展性非常高,對於中間件的支持也非常友好。在 Gin 中,所有的接口請求都要經過中間件,通過中間件,開發者可以自定義實現很多功能和邏輯,gin 雖然本身自帶的功能很少,但是由第三方社區開發的 gin 拓展中間件十分豐富。

中間件本質上其實還是一個接口處理器

go
// HandlerFunc defines the handler used by gin middleware as return value.
type HandlerFunc func(*Context)

從某種意義上來說,每一個請求對應的處理器也是中間件,只不過是作用范圍非常小的局部中間件。

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

查看 gin 的源代碼,Default函數中,返回的默認Engine就使用兩個默認中間件Logger()Recovery(),如果不想使用默認的中間件也可以使用gin.New()來代替。

全局中間件

全局中間件即作用范圍為全局,整個系統所有的請求都會經過此中間件。

go
func GlobalMiddleware() gin.HandlerFunc {
   return func(ctx *gin.Context) {
      fmt.Println("全局中間件被執行...")
   }
}

先創建一個閉包函數來創建中間件,再通過Engine.Use()來注冊全局中間件。

go
func main() {
   e := gin.Default()
   // 注冊全局中間件
   e.Use(GlobalMiddleware())
   v1 := e.Group("/v1")
   {
      v1.GET("/hello", Hello)
      v1.GET("/login", Login)
   }
   v2 := e.Group("/v2")
   {
      v2.POST("/update", Update)
      v2.DELETE("/delete", Delete)
   }
   log.Fatalln(e.Run(":8080"))
}

測試

curl --location --request GET 'http://localhost:8080/v1/hello'

輸出

[GIN-debug] Listening and serving HTTP on :8080
全局中間件被執行...
[GIN] 2022/12/21 - 11:57:52 | 200 |       538.9µs |             ::1 | GET      "/v1/hello"

局部中間件

局部中間件即作用范圍為局部,系統中局部的請求會經過此中間件。局部中間件可以注冊到單個路由上,不過更多時候是注冊到路由組上。

go
func main() {
   e := gin.Default()
   // 注冊全局中間件
   e.Use(GlobalMiddleware())
   // 注冊路由組局部中間件
   v1 := e.Group("/v1", LocalMiddleware())
   {
      v1.GET("/hello", Hello)
      v1.GET("/login", Login)
   }
   v2 := e.Group("/v2")
   {
      // 注冊單個路由局部中間件
      v2.POST("/update", LocalMiddleware(), Update)
      v2.DELETE("/delete", Delete)
   }
   log.Fatalln(e.Run(":8080"))
}

測試

curl --location --request POST 'http://localhost:8080/v2/update'

輸出

全局中間件被執行...
局部中間件被執行
[GIN] 2022/12/21 - 12:05:03 | 200 |       999.9µs |             ::1 | POST     "/v2/update"

中間件原理

Gin 中間的使用和自定義非常容易,其內部的原理也比較簡單,為了後續的學習,需要簡單的了解下內部原理。Gin 中的中間件其實用到了責任鏈模式,Context中維護著一個HandlersChain,本質上是一個[]HandlerFunc,和一個index,其數據類型為int8。在Engine.handlerHTTPRequest(c *Context)方法中,有一段代碼表明了調用過程:gin 在路由樹中找到了對應的路由後,便調用了Next()方法。

go
if value.handlers != nil {
   // 將調用鏈賦值給Context
   c.handlers = value.handlers
   c.fullPath = value.fullPath
   // 調用中間件
   c.Next()
   c.writermem.WriteHeaderNow()
   return
}

Next()的調用才是關鍵,Next()會遍歷路由的handlers中的HandlerFunc 並執行,此時可以看到index的作用就是記錄中間件的調用位置。其中,給對應路由注冊的接口函數也在handlers內,這也就是為什麼前面會說接口也是一個中間件。

go
func (c *Context) Next() {
   // 一進來就+1是為了避免陷入遞歸死循環,默認值是-1
   c.index++
   for c.index < int8(len(c.handlers)) {
      // 執行HandlerFunc
      c.handlers[c.index](c)
      // 執行完畢,index+1
      c.index++
   }
}

修改一下Hello()的邏輯,來驗證是否果真如此

go
func Hello(c *gin.Context) {
   fmt.Println(c.HandlerNames())
}

輸出結果為

[github.com/gin-gonic/gin.LoggerWithConfig.func1 github.com/gin-gonic/gin.CustomRecoveryWithWriter.func1 main.GlobalMiddleware.func1 main.LocalMiddleware.func1 main.Hello]

可以看到中間件調用鏈的順序為:Logger -> Recovery -> GlobalMiddleware -> LocalMiddleWare -> Hello,調用鏈的最後一個元素才是真正要執行的接口函數,前面的都是中間件。

TIP

在注冊局部路由時,有如下一個斷言

go
finalSize := len(group.Handlers) + len(handlers) //中間件總數
assert1(finalSize < int(abortIndex), "too many handlers")

其中abortIndex int8 = math.MaxInt8 >> 1值為 63,即使用系統時路由注冊數量不要超過 63 個。

計時器中間件

在知曉了上述的中間件原理後,就可以編寫一個簡單的請求時間統計中間件。

go
func TimeMiddleware() gin.HandlerFunc {
   return func(context *gin.Context) {
      // 記錄開始時間
      start := time.Now()
      // 執行後續調用鏈
      context.Next()
      // 計算時間間隔
      duration := time.Since(start)
      // 輸出納秒,以便觀測結果
      fmt.Println("請求用時: ", duration.Nanoseconds())
   }
}

func main() {
  e := gin.Default()
  // 注冊全局中間件,計時中間件
  e.Use(GlobalMiddleware(), TimeMiddleware())
  // 注冊路由組局部中間件
  v1 := e.Group("/v1", LocalMiddleware())
  {
    v1.GET("/hello", Hello)
    v1.GET("/login", Login)
  }
  v2 := e.Group("/v2")
  {
    // 注冊單個路由局部中間件
    v2.POST("/update", LocalMiddleware(), Update)
    v2.DELETE("/delete", Delete)
  }
  log.Fatalln(e.Run(":8080"))
}

測試

curl --location --request GET 'http://localhost:8080/v1/hello'

輸出

請求用時:  517600

一個簡單的計時器中間件就已經編寫完畢了,後續可以憑借自己的摸索編寫一些功能更實用的中間件。

服務配置

光是使用默認的配置是遠遠不夠的,大多數情況下都需求修改很多的服務配置才能達到需求。

Http 配置

可以通過net/http創建 Server 來配置,Gin 本身也支持像原生 API 一樣使用 Gin。

go
func main() {
   router := gin.Default()
   server := &http.Server{
      Addr:           ":8080",
      Handler:        router,
      ReadTimeout:    10 * time.Second,
      WriteTimeout:   10 * time.Second,
      MaxHeaderBytes: 1 << 20,
   }
   log.Fatal(server.ListenAndServe())
}

靜態資源配置

靜態資源在以往基本上是服務端不可或缺的一部分,盡管在現在使用佔比正在逐漸減少,但仍舊有大量的系統還是使用單體架構的情況。

Gin 提供了三個方法來加載靜態資源

go
// 加載某一靜態文件夾
func (group *RouterGroup) Static(relativePath, root string) IRoutes

// 加載某一個fs
func (group *RouterGroup) StaticFS(relativePath string, fs http.FileSystem) IRoutes

// 加載某一個靜態文件
func (group *RouterGroup) StaticFile(relativePath, filepath string) IRoutes

TIP

relativePath 是映射到網頁 URL 上的相對路徑,root 是文件在項目中的實際路徑

假設項目的目錄如下

root
|
|-- static
|  |
|  |-- a.jpg
|  |
|  |-- favicon.ico
|
|-- view
  |
  |-- html
go
func main() {
   router := gin.Default()
   // 加載靜態文件目錄
   router.Static("/static", "./static")
   // 加載靜態文件目錄
   router.StaticFS("/view", http.Dir("view"))
   // 加載靜態文件
   router.StaticFile("/favicon", "./static/favicon.ico")

   router.Run(":8080")
}

跨域配置

Gin 本身是沒有對於跨域配置做出任何處理,需要自行編寫中間件來進行實現相應的需求,其實難度也不大,稍微熟悉 HTTP 協議的人一般都能寫出來,邏輯基本上都是那一套。

go
func CorsMiddle() gin.HandlerFunc {
   return func(c *gin.Context) {
      method := c.Request.Method
      origin := c.Request.Header.Get("Origin")
      if origin != "" {
         // 生產環境中的服務端通常都不會填 *,應當填寫指定域名
         c.Header("Access-Control-Allow-Origin", origin)
         // 允許使用的HTTP METHOD
         c.Header("Access-Control-Allow-Methods", "POST, GET, OPTIONS, PUT, DELETE, UPDATE")
         // 允許使用的請求頭
         c.Header("Access-Control-Allow-Headers", "Origin, X-Requested-With, Content-Type, Accept, Authorization")
         // 允許客戶端訪問的響應頭
         c.Header("Access-Control-Expose-Headers", "Content-Length, Access-Control-Allow-Origin, Access-Control-Allow-Headers, Cache-Control, Content-Language, Content-Type")
         // 是否需要攜帶認證信息 Credentials 可以是 cookies、authorization headers 或 TLS client certificates
         // 設置為true時,Access-Control-Allow-Origin不能為 *
         c.Header("Access-Control-Allow-Credentials", "true")
      }
      // 放行OPTION請求,但不執行後續方法
      if method == "OPTIONS" {
         c.AbortWithStatus(http.StatusNoContent)
      }
      // 放行
      c.Next()
   }
}

將中間件注冊為全局中間件即可

會話控制

在目前的時代中,流行的三種 Web 會話控制總共有三種,cookiesessionJWT

ookie 中的信息是以鍵值對的形式儲存在瀏覽器中,而且在瀏覽器中可以直接看到數據

優點:

  • 結構簡單
  • 數據持久

缺點:

  • 大小受限

  • 明文存儲

  • 容易受到 CSRF 攻擊

go
import (
    "fmt"

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

func main() {

    router := gin.Default()

    router.GET("/cookie", func(c *gin.Context) {

         // 獲取對應的cookie
        cookie, err := c.Cookie("gin_cookie")

        if err != nil {
            cookie = "NotSet"
            // 設置cookie 參數:key,val,存在時間,目錄,域名,是否允許他人通過js訪問cookie,僅http
            c.SetCookie("gin_cookie", "test", 3600, "/", "localhost", false, true)
        }

        fmt.Printf("Cookie value: %s \n", cookie)
    })

    router.Run()
}

單純的 cookie 在五六年前用的比較多,不過作者一般很少使用單純的 cookie 來做會話控制,這樣做確實不太安全。

Session

session 存儲在服務器中,然後發送一個 cookie 存儲在瀏覽器中,cookie 中存儲的是 session_id,之後每次請求服務器通過 session_id 可以獲取對應的 session 信息

優點:

  • 存儲在服務端,增加安全性,便於管理

缺點:

  • 存儲在服務端,增大服務器開銷,降低性能
  • 基於 cookie 識別,不安全
  • 認證信息在分布式情況下不同步

Session 與 Cookie 是不分家的,每次要用到 Session,默認就是要用到 Cookie 了。Gin 默認是不支持 Session 的,因為 Cookie 是 Http 協議裡面的內容,但 Session 不是,不過有第三方中間件支持,安裝依賴即可,倉庫地址:gin-contrib/sessions: Gin middleware for session management (github.com)

go get github.com/gin-contrib/sessions

支持 cookie,Redis,MongoDB,GORM,PostgreSQL

go
func main() {
   r := gin.Default()
   // 創建基於Cookie的存儲引擎
   store := cookie.NewStore([]byte("secret"))
   // 設置Session中間件,mysession即session名稱,也是cookie的名稱
   r.Use(sessions.Sessions("mysession", store))
   r.GET("/incr", func(c *gin.Context) {
      // 初始化session
      session := sessions.Default(c)
      var count int
      // 獲取值
      v := session.Get("count")
      if v == nil {
         count = 0
      } else {
         count = v.(int)
         count++
      }
      // 設置
      session.Set("count", count)
      // 保存
      session.Save()
      c.JSON(200, gin.H{"count": count})
   })
   r.Run(":8000")
}

一般不推薦通過 Cookie 存儲 Sesison,推薦使用 Redis,其他例子還請自行去官方倉庫了解。

JWT

優點:

  • 基於 JSON,多語言通用
  • 可以存儲非敏感信息
  • 佔用很小,便於傳輸
  • 服務端無需存儲,利於分布式拓展

缺點:

  • Token 刷新問題
  • 一旦簽發則無法主動控制

自從前端革命以來,前端程序員不再只是一個「寫頁面的」,前後端分離的趨勢愈演愈烈,JWT 是最適合前後端分離和分布式系統來做會話控制的,具有很大的天然優勢。考慮到 JWT 已經完全脫離 Gin 的內容,且沒有任何中間件支持,因為 JWT 本身就是不局限於任何框架任何語言,在這裡就不作細致的講解,可以前往另一篇文檔:JWT

日志管理

Gin 默認使用的日志中間件采用的是os.Stdout,只有最基本的功能,畢竟 Gin 只專注於 Web 服務,大多數情況下應該使用更加成熟的日志框架,不過這並不在本章的討論范圍內,而且 Gin 的拓展性很高,可以很輕易的整合其他框架,這裡只討論其自帶的日志服務。

控制台顏色

go
gin.DisableConsoleColor() // 關閉控制台日志顏色

除了在開發的時候,大多數時候都不建議開啟此項

日志寫入文件

go
func main() {
  e := gin.Default()
    // 關掉控制台顏色
  gin.DisableConsoleColor()
    // 創建兩個日志文件
  log1, _ := os.Create("info1.log")
  log2, _ := os.Create("info2.log")
    // 同時記錄進兩個日志文件
  gin.DefaultWriter = io.MultiWriter(log1, log2)
  e.GET("/hello", Hello)
  log.Fatalln(e.Run(":8080"))
}

gin 自帶的日志支持寫入多個文件,但內容是相同的,使用起來不太方便,並且不會將請求日志寫入文件中。

go
func main() {
  router := gin.New()
  // LoggerWithFormatter 中間件會寫入日志到 gin.DefaultWriter
  // 默認 gin.DefaultWriter = os.Stdout
  router.Use(gin.LoggerWithFormatter(func(param gin.LogFormatterParams) string {
        //TODO 寫入對應文件的邏輯
        ......
    // 輸出自定義格式
    return fmt.Sprintf("%s - [%s] \"%s %s %s %d %s \"%s\" %s\"\n",
        param.ClientIP,
        param.TimeStamp.Format(time.RFC1123),
        param.Method,
        param.Path,
        param.Request.Proto,
        param.StatusCode,
        param.Latency,
        param.Request.UserAgent(),
        param.ErrorMessage,
    )
  }))
  router.Use(gin.Recovery())
  router.GET("/ping", func(c *gin.Context) {
    c.String(200, "pong")
  })
  router.Run(":8080")
}

通過自定義中間件,可以實現日志寫入文件中

路由調試日志格式

這裡修改的只是啟動時輸出路由信息的的日志

go
func main() {
   e := gin.Default()
   gin.SetMode(gin.DebugMode)
   gin.DebugPrintRouteFunc = func(httpMethod, absolutePath, handlerName string, nuHandlers int) {
      if gin.IsDebugging() {
         log.Printf("路由 %v %v %v %v\n", httpMethod, absolutePath, handlerName, nuHandlers)
      }
   }
   e.GET("/hello", Hello)
   log.Fatalln(e.Run(":8080"))
}

輸出

2022/12/21 17:19:13 路由 GET /hello main.Hello 3

結語:Gin 算是 Go 語言 Web 框架中最易學習的一種,因為 Gin 真正做到了職責最小化,只是單純的負責 Web 服務,其他的認證邏輯,數據緩存等等功能都交給開發者自行完成,相比於那些大而全的框架,輕量簡潔的 Gin 對於初學者而言更適合也更應該去學習,因為 Gin 並沒有強制使用某一種規范,項目該如何構建,采用什麼結構都需要自行斟酌,對於初學者而言更能鍛煉能力。

Golang學習網由www.golangdev.cn整理維護