本文探讨 Gin 的一些用法和源码。
1. Quickstart 1.1 Gin 是什么 一个用 Go (Golang) 编写的 HTTP Web 框架。
1.2 安装 1 go get -u github.com/gin-gonic/gin
1.3 hello word 1 2 3 4 5 6 7 8 9 10 11 12 13 package mainimport "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(":8099" ) }
r.GET
第一个参数是 路由地址,第二个参数是处理函数。1 2 3 4 func (group *RouterGroup) GET(relativePath string , handlers ...HandlerFunc) IRoutes { return group.handle(http.MethodGet, relativePath, handlers) }
1.4 Default 和 New 初始化路由器的区别 1 2 3 router := gin.New() router := gin.Default()
使用 Default 方法,会在 New 的基础上,增加两个中间件:Logger、Recovery。
1 2 3 4 5 6 7 func Default () *Engine { debugPrintWARNINGDefault() engine := New() engine.Use(Logger(), Recovery()) return engine }
Logger、Recovery 作用分别为:
1 2 [GIN] 2022/06/17 - 18:03:53 | 404 | 646ns | 127.0.0.1 | GET "/" [GIN] 2022/06/17 - 18:04:02 | 200 | 1.556255ms | 127.0.0.1 | GET "/ping"
Recovery:如果程序中出现 panic,没加入 Recovery ,服务器会直接无响应;加入 Recovery,服务器会返回 HTTP 500 错误。 2.路由 2.1 路由分组 对于 URL 中的重复部分,可以使用 Group
进行路由分组。
例如:商品的增删改查,可以使用 “/goods” 进行分组。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 func router () { r := gin.Default() r.GET("/goods/list" , goodsList) r.POST("/goods/add" , createGoods) r.GET("/goods/3" , goodsDetail) r.Run() }func routerWithGroup () { r := gin.Default() routerGroup := r.Group("/goods" ) { routerGroup.GET("/list" , goodsList) routerGroup.POST("/add" , createGoods) routerGroup.GET("/3" , goodsDetail) } r.Run() }func goodsDetail (context *gin.Context) {}func createGoods (context *gin.Context) {}func goodsList (context *gin.Context) {}
例如:不同版本的 API,使用 “/v1” 和 “v2” 进行分组。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 func routerWithGroup () { r := gin.Default() v1 := r.Group("/v1" ) { v1.GET("/login" , login) v1.POST("/submit" , submit) v1.GET("/read" , read) } v2 := r.Group("/v2" ) { v2.GET("/login" , login2) v2.POST("/submit" , submit2) v2.GET("/read" , read2) } r.Run() }
2.2 获取 URL 参数 路由中使用 :id
来匹配参数,使用 *path
来匹配路径。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 func main () { r:= gin.Default() goodsGroup := r.Group("/goods" ) { goodsGroup.GET("/:id" , goodsDetail) goodsGroup.GET("/:id/:action" , goodsAction) goodsGroup.GET("/file/*path" , goodsFilePath) } r.Run() }func goodsDetail (context *gin.Context) { id := context.Param("id" ) context.JSON(http.StatusOK, gin.H{ "id" : id, }) }func goodsAction (context *gin.Context) { id := context.Param("id" ) action := context.Param("action" ) context.JSON(http.StatusOK, gin.H{ "id" : id, "action" : action, }) }func goodsFilePath (context *gin.Context) { path := context.Param("path" ) context.JSON(http.StatusOK, gin.H{ "path" : path, }) }
注意匹配冲突,举个例子:
1 2 3 goodsGroup.GET("/list" , goodsList) goodsGroup.GET("/:id" , goodsDetail)
2.3 URL 参数的表单验证 先对结构体变量用 tag 标记,形成约束条件。将 URL 中获取的参数,用 context.ShouldBindUri
进行验证该约束条件。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 type Person struct { Id int `uri:"id" binding:"required"` Name string `uri:"name" binding:"required"` }func main () { r:= gin.Default() r.GET("/:id/:name" , func (context *gin.Context) { var p Person if err := context.ShouldBindUri(&p); err != nil { context.Status(http.StatusBadRequest) return } fmt.Println(p) context.JSON(http.StatusOK, gin.H{ "id" : p.Id, "name" : p.Name, }) }) r.Run() }
context.ShouldBindUri
的函数实现:从 context 中获取参数,进行验证后,赋值给 obj。
1 2 3 4 5 6 7 8 func (c *Context) ShouldBindUri(obj any) error { m := make (map [string ][]string ) for _, v := range c.Params { m[v.Key] = []string {v.Value} } return binding.Uri.BindUri(m, obj) }
2.4 获取 GET/POST 参数 GET 参数。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 func main () { r:= gin.Default() r.GET("/person" , person) r.Run() }func person (context *gin.Context) { id := context.Query("id" ) name := context.DefaultQuery("name" , "anonymous" ) context.JSON(http.StatusOK, gin.H{ "id" : id, "name" : name, }) }
POST 参数。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 func main () { r:= gin.Default() r.POST("/person" , person) r.Run() }func person (context *gin.Context) { id := context.PostForm("id" ) name := context.DefaultPostForm("name" , "anonymous" ) context.JSON(http.StatusOK, gin.H{ "id" : id, "name" : name, }) }
GET 和 POST 混合参数。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 func main () { r:= gin.Default() r.POST("/person" , person) r.Run() }func person (context *gin.Context) { id := context.Query("id" ) name := context.DefaultPostForm("name" , "anonymous" ) context.JSON(http.StatusOK, gin.H{ "id" : id, "name" : name, }) }
2.5 返回 JSON 和 ProtoBuf 除了使用 gin.H
返回 JSON,还可以返回整个 struct 作为 JSON。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 func main () { r:= gin.Default() r.GET("/JSON" , returnJSON) r.Run() }func returnJSON (context *gin.Context) { var person struct { Name string `json:"name"` Age int `json:"age"` } person.Name = "h" person.Age = 10 context.JSON(http.StatusOK, person) }
先定义 proto 文件,在生成处理 protobuf 的 bg.go 文件,其中包含 struct 的定义。初始化该 struct,并使用 context.ProtoBuf
返回即可。
注意:此处需传递实例化的 struct 地址。
为什么 context.ProtoBuf
需要传地址,而 context.JSON
不需要?
context.JSON
需要接收一个结构体,并将其处理为字符串即可。context.ProtoBuf
需要接收一个结构体指针,用这个指针去调用 protoreflect.ProtoMessage
方法。(bp.go 中,该结构体实现了 ProtoMessage 接口。)最终转化为 protobuf 格式。1 2 3 4 5 6 7 8 9 10 11 12 13 14 func main () { r:= gin.Default() r.GET("/Protobuf" , returnProtobuf) r.Run() }func returnProtobuf (context *gin.Context) { course := []string {"go" ,"rpc" , "gin" } p := proto.Person{ Name: "hh" , Course: course, } context.ProtoBuf(http.StatusOK, &p) }
2.6 使用 validator 进行表单验证 gin 使用 go-playground/validator 验证参数。gin 将其封装成两套绑定方法。
Must bind
Methods:Bind
、BindJSON
、BindXML
、BindQuery
、BindYAML
Behavior:这些方法底层使用 MustBindWith
,如果验证错误,请求会被 c.AbortWithError(http.StatusBadRequest, err).SetType(ErrorTypeBind)
中止请求,响应状态码会设置为 400。(如果想自定义处理错误和请求响应,应使用 ShouldBind) 1 2 3 4 5 6 7 8 9 10 func (c *Context) MustBindWith(obj any, b binding.Binding) error { if err := c.ShouldBindWith(obj, b); err != nil { c.AbortWithError(http.StatusBadRequest, err).SetType(ErrorTypeBind) return err } return nil }
Should bind
Methods:ShouldBind
、ShouldBindJSON`、
ShouldBindXML、``ShouldBindQuery
、``ShouldBindYAML` Behavior:这些方法底层使用 ShouldBindWith
,如果验证错误,则返回错误,由开发人员自定义处理错误和请求响应。 当调用 Bind
、ShouldBind
两种方法时,Gin 会根据求的 Method 和 Content-Type 推断使用哪种绑定器,然后使用 ShouldBindWith(obj any, b binding.Binding)
或 BindWith(obj any, b binding.Binding)
。具体如何调用及推断的逻辑如下:
1 2 3 4 5 6 7 8 9 10 11 12 13 func (c *Context) ShouldBind(obj any) error { b := binding.Default(c.Request.Method, c.ContentType()) return c.ShouldBindWith(obj, b) }
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 const ( MIMEJSON = "application/json" func Default (method, contentType string ) Binding { if method == http.MethodGet { return Form } switch contentType { case MIMEJSON: return JSON case MIMEXML, MIMEXML2:
举个例子: 对注册的表单进行验证。
SignUpForm 使用 tag 配置约束条件。 使用 context.ShouldBind(&signUpForm)
进行表单验证。 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 import ( "fmt" "net/http" "github.com/gin-gonic/gin" )type SignUpForm struct { Username string `json:"username" form:"username" binding:"required,max=10"` Password string `json:"password" form:"password" binding:"required,min=6"` RePassword string `json:"repassword" form:"repassword" binding:"required,eqfield=Password"` Age int `json:"age" form:"age" binding:"required"` Email string `json:"email" form:"email" binding:"required,email"` }func main () { engine := gin.Default() engine.POST("SignUp" , Login) engine.Run(":8083" ) }func Login (context *gin.Context) { var signUpForm SignUpForm if err := context.ShouldBind(&signUpForm); err != nil { context.JSON(http.StatusBadRequest, gin.H{ "error" : err.Error(), }) return } context.JSON(http.StatusOK, gin.H{ "msg" : "signup success" , "user" : signUpForm.Username, }) }
当有不符合条件的访问时,会返回 400 及错误原因。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 curl -X "POST" "http://127.0.0.1:8083/SignUp" \ -H 'Content-Type: application/json; charset=utf-8' \ -d $'{ "username": "Tom", "password": "123456", "repassword": "12345", # 重复密码不一致 "age": 18, "email": "123@123.com" }' HTTP/1.1 400 Bad Request Content-Length: 108 Content-Type: text/plain; charset=utf-8 Connection: close {"error" :"Key: 'SignUpForm.RePassword' Error:Field validation for 'RePassword' failed on the 'eqfield' tag" }
2.7 为 validator 配置翻译器 初始化语言环境 传入 语言环境,初始化通用翻译器 从通用翻译器中拿出中文翻译器 绑定中文验证器和翻译器 将验证器返回的 Error 转换为 validator.ValidationErrors
,这样 Error 就有了 Translate 方法 使用 Translate 方法 具体流程参照代码阅读。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 import ( "fmt" "net/http" "github.com/gin-gonic/gin" "github.com/gin-gonic/gin/binding" "github.com/go-playground/locales/en" "github.com/go-playground/locales/zh" ut "github.com/go-playground/universal-translator" "github.com/go-playground/validator/v10" en2 "github.com/go-playground/validator/v10/translations/en" zh2 "github.com/go-playground/validator/v10/translations/zh" )type SignUpForm struct { Username string `json:"username" form:"username" binding:"required,max=10"` Password string `json:"password" form:"password" binding:"required,min=6"` RePassword string `json:"repassword" form:"repassword" binding:"required,eqfield=Password"` Age int `json:"age" form:"age" binding:"required"` Email string `json:"email" form:"email" binding:"required,email"` }var translator ut.Translator func main () { engine := gin.Default() err := InitTrans("zh" ) if err != nil { panic (err) } engine.POST("SignUp" , Login) engine.Run(":8083" ) }func InitTrans (locale string ) (err error ) { translatorZh := zh.New() translatorEn := en.New() uni := ut.New(translatorZh, translatorEn, translatorZh) var found bool translator, found = uni.GetTranslator(locale) if !found { return fmt.Errorf("uni.GetTranslator(%s)" , locale) } validate := binding.Validator.Engine().(*validator.Validate) switch locale { case "en" : err = en2.RegisterDefaultTranslations(validate, translator) case "zh" : err = zh2.RegisterDefaultTranslations(validate, translator) default : err = en2.RegisterDefaultTranslations(validate, translator) } return err }func Login (context *gin.Context) { var signUpForm SignUpForm if err := context.Bind(&signUpForm); err != nil { errors := err.(validator.ValidationErrors) context.JSON(http.StatusOK, gin.H{ "error" : errors.Translate(translator), }) return } context.JSON(http.StatusOK, gin.H{ "msg" : "signup success" , "user" : signUpForm.Username, }) }
当有不符合条件的访问时,错误原因被翻译成中文。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 curl -X "POST" "http://127.0.0.1:8083/SignUp" \ -H 'Content-Type: application/json; charset=utf-8' \ -d $'{ "username": "Tom", "password": "123456", "repassword": "12345", # 重复密码不一致 "age": 18, "email": "123.com" # 邮箱格式错误 }' HTTP/1.1 400 Bad Request Content-Length: 108 Content-Type: text/plain; charset=utf-8 Connection: close { "error" :{ "SignUpForm.Email" :"Email必须是一个有效的邮箱" , "SignUpForm.RePassword" :"RePassword必须等于Password" } }
2.8 更改验证错误字段格式 上述错误原因虽然是中文的,但是字段格式不是 json 格式的。期望返回的是这样的格式:
1 2 3 4 5 6 { "error" :{ "email" :"email必须是一个有效的邮箱" , "repassword" :"repassword必须等于password" } }
主要需要做两件事:
验证时,字段使用 tag 配置中的 json 格式。
在拿到 gin 的验证器时,注册一个获取 Tag Name 的方法
1 2 3 4 5 6 7 8 9 validate := binding.Validator.Engine().(*validator.Validate) validate.RegisterTagNameFunc(func (field reflect.StructField) string { name := strings.SplitN(field.Tag.Get("json" ), "," , 2 )[0 ] if name == "-" { return " " } return name })
去掉 struct 名称。
errors.Translate(translator)
返回的是 ValidationErrorsTranslations
类型,其实就是 map[string]string
类型。我们要进行字符串操作的就是这个 map 的 key。
1 2 3 4 5 6 7 8 if err := context.Bind(&signUpForm); err != nil { errors := err.(validator.ValidationErrors) context.JSON(http.StatusOK, gin.H{ "error" : removeTopStruct(errors.Translate(translator)), }) return }
如何处理?找到点的位置,进行切片操作。
1 2 3 4 5 6 7 func removeTopStruct (fields map [string ]string ) map [string ]string { rsp := map [string ]string {} for field, err := range fields { rsp[field[strings.Index(field, "." )+1 :]] = err } return rsp }
3.中间件 3.1 自定义中间件 中间件可以避免代码的侵入性。gin 的中间件使用很简便,只需提供一个 gin.HandlerFunc 类型的函数即可。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 func timing () gin.HandlerFunc { return func (c *gin.Context) { t := time.Now() c.Set("number" , "1233" ) c.Next() end := time.Since(t) fmt.Println("Duration: " , end) fmt.Println("Writer status: " , c.Writer.Status()) } }func main () { engine := gin.New() engine.Use(gin.Logger(), gin.Recovery(), timing()) engine.GET("/index" , index) engine.Run(":8083" ) }func index (c *gin.Context) { if value, exists := c.Get("number" ); exists { time.Sleep(time.Second) c.JSON(http.StatusOK, gin.H{ "number" : value, }) } }
3.2 中间件队列原理
如图,RouterGroup 中维护了一个队列 Handlers,并将中间件及请求处理函数依次加入其中。处理请求时,依次执行队列中的函数,并使用 index 指向正在执行的函数。
c.Next()
:表示 index ++,开始执行队列中后面的函数。c.Abort()
:表示中止队列函数的执行。所以在中间件中,使用 return 并不能中止队列函数的执行,只能结束当前函数,index 还是会向后移动,并执行后面的函数。 我们来具体看下源码。
RouterGroup 中,维护一个函数数组 Handlers。这里的 HandlersChain 类型就是 func(*Context)
。
1 2 3 4 5 6 7 type RouterGroup struct { Handlers HandlersChain basePath string engine *Engine root bool }
1 2 3 type HandlerFunc func (*Context) type HandlersChain []HandlerFunc
当需要加入中间件时,gin.Use
-> RouterGroup.Use
,将中间件加入队列。
1 2 3 4 5 func (group *RouterGroup) Use(middleware ...HandlerFunc) IRoutes { group.Handlers = append (group.Handlers, middleware...) return group.returnObj() }
当需要加入请求处理函数时,gin.GET
-> RouterGroup.handle
-> RouterGroup.combineHandlers
,创建一个新的队列,将原有的中间件和请求处理函数拷贝到其中。
1 2 3 4 5 6 7 8 9 10 11 func (group *RouterGroup) combineHandlers(handlers HandlersChain) HandlersChain { finalSize := len (group.Handlers) + len (handlers) assert1(finalSize < int (abortIndex), "too many handlers" ) mergedHandlers := make (HandlersChain, finalSize) copy (mergedHandlers, group.Handlers) copy (mergedHandlers[len (group.Handlers):], handlers) return mergedHandlers }
4.其他 4.1 模版功能 https://pkg.go.dev/html/template
处理请求时,gin 返回模版文件+变量数据。
(1)最小实践
1 2 3 4 . ├── main.go └── templates └── index.html
1 2 3 4 5 6 7 8 9 10 11 12 13 14 func main () { router := gin.Default() router.LoadHTMLFiles("templates/index.html" ) router.GET("/index" , func (context *gin.Context) { context.HTML(http.StatusOK, "index.html" , gin.H{ "title" : "haha" , }) }) router.Run(":8080" ) }
1 2 3 4 5 6 7 8 9 10 11 12 <!DOCTYPE html > <html lang ="en" > <head > <meta charset ="utf-8" > <title > Hello World</title > </head > <body > <h1 > {{ .title }}</h1 > </body > </html >
注意相对路径问题:
上述代码中,使用 "templates/index.html"
相对路径,直接在 IDE 中使用 go run 无法获取到相当路径,需要在当前路径下 go build 出可执行文件,再执行。 原因:go run 执行时,所在的目录是临时目录。 1 2 3 4 5 dir, _ := filepath.Abs(filepath.Dir(os.Args[0 ])) fmt.Println("dir: " , dir)
(2)同时加载多个 HTML
文件过多,可以使用 LoadHTMLGlob
加载整个目录。
1 2 3 4 5 . ├── main.go └── templates ├── goods.html └── index.html
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 func main () { router := gin.Default() router.LoadHTMLGlob("templates/*" ) router.GET("/index" , func (context *gin.Context) { context.HTML(http.StatusOK, "index.html" , gin.H{ "title" : "haha" , }) }) router.GET("/goods" , func (context *gin.Context) { context.HTML(http.StatusOK, "goods.html" , gin.H{ "title" : "goods" , }) }) router.Run(":8080" ) }
(3)处理文件名冲突问题
1 2 3 4 5 6 7 8 9 . ├── main.go └── templates ├── goods │ └── list.html ├── index │ └── index.html └── user └── list.html
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 func main () { router := gin.Default() router.LoadHTMLGlob("templates/**/*" ) router.GET("/index" , func (context *gin.Context) { context.HTML(http.StatusOK, "index/index.html" , gin.H{ "title" : "haha" , }) }) router.GET("/user/list" , func (context *gin.Context) { context.HTML(http.StatusOK, "user/list.html" , gin.H{ "title" : "users" , }) }) router.GET("/goods/list" , func (context *gin.Context) { context.HTML(http.StatusOK, "goods/list.html" , gin.H{ "title" : "goods" , }) }) router.Run(":8080" ) }
goods/list.html
1 2 3 4 5 6 7 8 9 10 11 12 {{define "goods/list.html" }} <!DOCTYPE html > <html lang ="en" > <head > <meta charset ="UTF-8" > <title > goods</title > </head > <body > <h1 > {{ .title }}</h1 > </body > </html > {{end}}
index/index.html
1 2 3 4 5 6 7 8 9 10 11 12 {{define "index/index.html" }} <!DOCTYPE html > <html lang ="en" > <head > <meta charset ="utf-8" > <title > Hello World</title > </head > <body > <h1 > {{ .title }}</h1 > </body > </html > {{end}}
user/list.html
1 2 3 4 5 6 7 8 9 10 11 12 {{define "user/list.html" }} <!DOCTYPE html > <html lang ="en" > <head > <meta charset ="utf-8" > <title > Hello World</title > </head > <body > <h1 > {{ .title }}</h1 > </body > </html > {{end}}
其中有几个问题需要说明一下:
4.2 静态文件处理 html 中需要引用大量的静态文件,使用 router.Static("/static", "./static")
指明以 /static 开头的链接,均在 ./static 目录下找。
1 2 3 4 5 6 7 . ├── main.go ├── static │ └── css │ └── index.css └── templates └── index.html
1 2 3 4 5 6 7 8 9 10 11 12 13 func main () { router := gin.Default() router.LoadHTMLGlob("templates/*" ) router.Static("/static" , "./static" ) router.GET("/index" , func (context *gin.Context) { context.HTML(http.StatusOK, "index.html" , gin.H{ "title" : "haha" , }) }) router.Run(":8080" ) }
1 2 3 4 5 6 7 8 9 10 11 12 <!DOCTYPE html > <link rel ="stylesheet" href ="/static/css/index.css" > <html lang ="en" > <head > <meta charset ="utf-8" > <title > Hello World</title > </head > <body > <h1 > {{ .title }}</h1 > </body > </html >
1 2 3 4 *{ background-color : burlywood; }
4.3 gin 的优雅退出 https://gin-gonic.com/docs/examples/graceful-restart-or-stop/
优雅退出的作用:当关闭程序时,应该做一些后续处理。比如:将没保存的数据做保存、保存日志、向注册中心告知当前服务已下线等等。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 import ( "fmt" "net/http" "os" "os/signal" "syscall" "github.com/gin-gonic/gin" )func main () { router := gin.Default() router.GET("/" , func (c *gin.Context) { c.JSON(http.StatusOK, gin.H{ "message" : "Hello World!" , }) }) go func () { router.Run(":8080" ) }() quit := make (chan os.Signal) signal.Notify(quit, syscall.SIGINT, syscall.SIGTERM) <-quit fmt.Println("Closing server..." ) fmt.Println("Unregistering service..." ) }