Gin框架学习笔记

Restful方式

RESTful的开发规范中,请求参数传递可以范围两大类,一类是放在header中,另外一类是放在body中。其中,放在body中的参数信息又可以分为放在url中,query中,以及请求body中。

url

放在url中的参数,可以通过:id的形式注册到url

1
2
3
4
group := r.Group("/goods")
{
group.GET("/:name", GetGoods)
}

获取的方式则是通过

1
2
n := c.Param("name")
get, ok := c.Params.Get("name")

通配符,获取资源时,会将url后续的地址全部都获取到,因此很少会使用到,一般在映射目录时会用到。

1
2
3
4
5
group.GET("/*name", GetGoods)

// 获取资源时,会将url后续的地址全部都获取到,
n get: /123/123123123123/done
name get: /123/123123123123/done

批量获取,通过ShouldBindUri实现参数绑定,批量获取参数,这个方式还可以配合validate一起进行参数约束。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
type Goods struct {
ID uint `uri:"id"`
Name string `uri:"name"`
}

func GetGoods(c *gin.Context) {
var g Goods
err := c.ShouldBindUri(&g)
if err != nil {
return
log.Println(err)
}
log.Printf("%+v", g)
}

query

获取query的参数:query的参数一般会通过GET请求和POST请求发送,通常作为检索条件或者约束条件。

获取方式使用Query

1
2
3
4
5
func ListGoods(c *gin.Context) {
name := c.Query("name")
id := c.DefaultQuery("id", "abc") // 默认值,没有获取到id时,id值为abc
log.Printf("get name: %s,id: %s", name, id)
}

body

获取body的参数:body的参数一般用在postputpatch请求中,用于创建、更新资源。

获取方式使用PostForm

1
2
3
4
5
func ListGoods(c *gin.Context) {
name := c.PostForm("name")
id := c.DefaultPostForm("id", "abc") // 默认值
log.Printf("get name: %s,id: %s", name, id)
}

输出

一般情况下,返回的格式是json

1
2
3
4
5
6
7
c.JSON(http.StatusOK, gin.H{
"key": "value",
})
c.JSON(http.StatusOK, Goods{
ID: 1,
Name: "mitaka",
})

也可以返回protobuf格式

1
2
3
4
5
6
7
8
syntax = "proto3";

option go_package = "gostudy/gin;main";

message Goods {
int32 id =1 ;
string name = 2;
}

生成代码

1
2
3
protoc --proto_path=. \
--go_out=. --go_opt=paths=source_relative \
goods.proto

使用protobuf返回

1
2
3
4
c.ProtoBuf(http.StatusOK, &Goods{ // 需要注意,这里需要传入指针
Id: 1,
Name: "mitaka",
})

注册GET请求,通过浏览器访问,是一个下载的文件,内容是一个二进制文件,可以通过Protobuf反序列化获取内容

1
mitaka%

表单验证

将请求体绑定到结构体中,需要使用模型绑定,目前支持JSONXMLYAML和标准表单值(foo=bar&boo=baz)的绑定

具体验证方式,是通过binding的tag实现,具体可以查看 gin的参数校验器validator

Gin针对参数验证,提供两套绑定方法:

  • Must bind

    • Methods:Bind BindJSON BindXML BindQuery BindYAML
    • Behavior:这些方法底层使用MustBindWith,如果存在绑定错误,则将被一下指令终止c.AbortWithError(400, err).SetType(ErrorTypeBind),状态码会被设置为400, 并且 Content-Type 被设置为 text/plain; charset=utf-8。如果您在此之后尝试设置响应状态码,Gin会输出日志 [GIN-debug] [WARNING] Headers were already written. Wanted to override status code 400 with 422
  • Should bind

    • Methods:ShouldBindShouldBindJSONShouldBindXMLShouldBindQueryShouldBindYAML

    • Behavior:这些方法属于 ShouldBindWith 的具体调用。

      Should bind 如果发生绑定错误,Gin 会返回错误并由开发者处理错误和请求。

使用 Bind 方法时,Gin 会尝试根据 Content-Type 推断如何绑定(例如传递过来的数据是json还是xml还是form表单)。如果你明确知道要绑定什么,可以使用 MustBindWithShouldBindWith

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
type User struct {
Name string `json:"name" binding:"required"`
Password string `json:"password" binding:"required"`
}

func Login(c *gin.Context) {
var u User
err := c.ShouldBind(&u) // 当密码为空,返回200,返回值为报错
//err := c.Bind(&u) // 当密码为空,返回400,返回值为报错
if err != nil {
c.JSON(http.StatusOK, err.Error())
return
}
c.JSON(http.StatusOK, u)
}

传递过程使用Content-Typeapplication/json,在ShouldBind时以json格式绑定。

报错改为中文:国际化

中间件

例如gin.Default()中使用的Logger()Recovery(),都是返回一个HandlerFunc

1
type HandlerFunc func(*Context)

类似于设计模式中的装饰器模式或者说责任链模式

仿照写一个日志中间件

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
func LoggerHandler() gin.HandlerFunc {
return func(c *gin.Context) {
t := time.Now()
c.Next() // 调用下一个函数
log.Printf("spend %s", time.Since(t))
}
}

r := gin.Default()
//r.Use(LoggerHandler()) // 在后续注册的请求接口中都会用到这个中间件
group := r.Group("/login")
group.Use(LoggerHandler()) // 只有在group中才会使用中间件
{
group.POST("", Login)
}
r.Run(":8080")

再仿照写一个验证token的中间件

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
func TokenRequired() gin.HandlerFunc {
return func(c *gin.Context) {
var token string
for k, v := range c.Request.Header {
// 这里要大写,X-Token
if k == "X-Token" {
token = v[0]
}
}
// 也可以使用这种方式
// token := c.Request.Header.Get("x-token")
if token != "mitaka" {
c.JSON(http.StatusUnauthorized, gin.H{
"msg": "未登录",
})
}
c.Next()
}
}

实际在请求的时候,可以看到结果变成了这样,

1
2
3
4
5
6
{
"msg": "未登录"
}{
"name": "mitaka1",
"password": "123"
}

这是由于return并没有终止c.Next的调用,而是需要通过c.Abort()终止

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
func TokenRequired() gin.HandlerFunc {
return func(c *gin.Context) {
var token string
for k, v := range c.Request.Header {
if k == "X-Token" {
token = v[0]
}
}
if token != "mitaka" {
c.JSON(http.StatusUnauthorized, gin.H{
"msg": "未登录",
})
c.Abort() // 通过Abort终止执行
}
c.Next()
}
}

在源码中可以看到

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
// Use adds middleware to the group, see example code in GitHub.
// Use是将HandlerFunc append到一个slice中
func (group *RouterGroup) Use(middleware ...HandlerFunc) IRoutes {
group.Handlers = append(group.Handlers, middleware...)
return group.returnObj()
}

// HandlersChain defines a HandlerFunc slice.
type HandlersChain []HandlerFunc

// HandlersChain 是一个HandlerFunc的切片
// RouterGroup is used internally to configure router, a RouterGroup is associated with
// a prefix and an array of handlers (middleware).
type RouterGroup struct {
Handlers HandlersChain
basePath string
engine *Engine
root bool
}

// 在路由注册时,可以看到是将handlers(也就是响应请求的函数)放到这个切片的后面
// 这里的handlers是处理请求的函数
func (group *RouterGroup) POST(relativePath string, handlers ...HandlerFunc) IRoutes {
return group.handle(http.MethodPost, relativePath, handlers)
}

func (group *RouterGroup) handle(httpMethod, relativePath string, handlers HandlersChain) IRoutes {
absolutePath := group.calculateAbsolutePath(relativePath)
// handlers重新组合
handlers = group.combineHandlers(handlers)
group.engine.addRoute(httpMethod, absolutePath, handlers)
return group.returnObj()
}

// 返回的 handlers HandlersChain 是一个slice
func (group *RouterGroup) combineHandlers(handlers HandlersChain) HandlersChain {
// 新的长度
finalSize := len(group.Handlers) + len(handlers)
assert1(finalSize < int(abortIndex), "too many handlers")
// 初始化长度
mergedHandlers := make(HandlersChain, finalSize)
// 将中间件拷贝到新的slice中
copy(mergedHandlers, group.Handlers)
// 将处理请求的函数放在末尾
copy(mergedHandlers[len(group.Handlers):], handlers)
return mergedHandlers
}

在使用next时的源代码

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
// ServeHTTP conforms to the http.Handler interface.
func (engine *Engine) ServeHTTP(w http.ResponseWriter, req *http.Request) {
c := engine.pool.Get().(*Context)
c.writermem.reset(w)
c.Request = req
c.reset()

engine.handleHTTPRequest(c)

engine.pool.Put(c)
}

func (engine *Engine) handleHTTPRequest(c *Context) {
...
for i, tl := 0, len(t); i < tl; i++ {
if t[i].method != httpMethod {
continue
}
root := t[i].root
// Find route in tree
value := root.getValue(rPath, c.params, c.skippedNodes, unescape)
if value.params != nil {
c.Params = *value.params
}
if value.handlers != nil {
c.handlers = value.handlers
c.fullPath = value.fullPath
// 调用handler函数链
c.Next()
c.writermem.WriteHeaderNow()
return
}
...
}

// 通过index这个角标,处理HandlerFunc的切片
// 也就是先处理第一个midware,再处理第二个midware...再处理函数
func (c *Context) Next() {
c.index++
for c.index < int8(len(c.handlers)) {
// 通过index,使用下一个handler
c.handlers[c.index](c)
c.index++
}
}

也就是当使用return时,是结束当前函数,index会往下走一位,也就是使用下一个midware处理请求。因此需要通过c.Abort(),直接将index移动到最后。

gin的源码解释了设计原理和路由树:

全网最详细的gin源码解析

gin框架源码解析

总结:核心是路由存储树,学好算法,数据结构才是关键

image-20220928154522575

模板

http请求通过传递html文件,让前端渲染出静态页面,通过html中的语法实现内容替换和渲染,就可以通过模板实现

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
func main() {
r := gin.Default()

// 加载文件,需要注意目录
r.LoadHTMLFiles("./gin/gin2/goods.html")
// 或者加载目录中的所有文件
// r.LoadHTMLGlob("./gin/gin2/*")

group := r.Group("/goods")
{
group.GET("", Goods)
}
r.Run(":8080")
}

func Goods(c *gin.Context) {
// 指定文件名
c.HTML(http.StatusOK, "goods.html", gin.H{
// 将关键字进行替换
"title": "mitaka",
})
}

html文件为

1
2
3
4
5
6
7
8
9
10
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title>Title</title>
</head>
<body>
{{ .title }}
</body>
</html>

返回的结果如下

1
2
3
4
5
6
7
8
9
10
11
12
13
<!DOCTYPE html>
<html lang="en">

<head>
<meta charset="UTF-8">
<title>Title</title>
</head>

<body>
mitaka
</body>

</html>

其中 {{ .title }} 被替换成 mitaka

那么当一个全局目录,不同的目录中有相同的文件名,此时如何实现区分?

1
2
3
4
5
6
7
gin2
├── gin.go
└── html
├── goods
│   └── index.html
└── user
└── index.html
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
func main() {
r := gin.Default()

r.LoadHTMLGlob("./gin/gin2/html/*/*")

r.GET("/goods", Goods)
r.GET("/user", Users)
r.Run(":8080")
}

func Users(c *gin.Context) {
// 如果使用这种方式 "user/index.html" ,会出现报错 html/template: "user/index.html" is undefined
c.HTML(http.StatusOK, "index.html", gin.H{
"title": "mitaka",
})
}

func Goods(c *gin.Context) {
c.HTML(http.StatusOK, "index.html", gin.H{
"title": "mitaka",
})
}

此时,访问 goods 和 user 都会访问到同一个 index.html

可以通过define实现定义和区分

1
2
3
4
5
6
7
8
9
10
11
12
{{ define "goods/index.html" }}
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title>Title</title>
</head>
<body>
goods : {{ .title }}
</body>
</html>
{{ end }}
1
2
3
4
5
func Goods(c *gin.Context) {
c.HTML(http.StatusOK, "goods/index.html", gin.H{
"title": "mitaka",
})
}

当目录结构中有目录也有文件

1
2
3
4
5
6
7
8
gin/gin2
├── gin.go
└── html
├── all.html
├── goods
│   └── index.html
└── user
└── index.html

此时运行服务可以看到

1
2
3
4
5
[GIN-debug] Loaded HTML Templates (4): 
-
- index.html
- goods/index.html
- user/index.html

并没有加载 all.html 文件

这是由于 r.LoadHTMLGlob("./gin/gin2/html/*/*") 是加载的目录下的文件,因此需要将目录结构改为

1
2
3
4
5
6
7
8
9
gin/gin2
├── gin.go
└── html
├── all
│   └── all.html
├── goods
│   └── index.html
└── user
└── index.html

静态资源

除了 html文件需要加载,还需要加载其他的静态资源,例如 css,此时则需要指定静态资源的路径

1
2
//        路径匹配前缀   匹配之后寻找的路径
r.Static("/static/", "./gin/gin2/static/")
1
<link href="/static/style.css" rel="stylesheet">

当指定引入css文件路径是 /static/style.css,则会匹配 /static/,将路径指向 ./gin/gin2/static/

除了css文件,其他的js文件等,都是相同的处理逻辑。

优雅的退出

虽然正常情况下,可以直接 os.Exit(0)退出,但是这种方式直接中断服务,会导致有些还没有完成的请求直接退出,因此需要有一个过程,让请求全部处理完,也就是优雅的退出。

在微服务框架中,可以通过优雅退出的过程,通知注册中心,让流量不再转发到当前节点。

官方文档:优雅地重启或停止

优雅的退出需要能捕获到终止信号

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
func main() {
r := gin.Default()
r.POST("", hello)
srv := &http.Server{
Addr: ":8080",
Handler: r,
}

go func() {
// 服务连接
if err := srv.ListenAndServe(); err != nil && err != http.ErrServerClosed {
log.Fatalf("listen: %s\n", err)
}
}()

// 等待中断信号以优雅地关闭服务器(设置 5 秒的超时时间)
quit := make(chan os.Signal)
signal.Notify(quit, os.Interrupt, os.Kill)
<-quit
log.Println("Shutdown Server ...")

ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
if err := srv.Shutdown(ctx); err != nil {
log.Fatal("Server Shutdown:", err)
}
log.Println("Server exiting")
}

JSON序列化的处理

当序列化成JSON之后,需要将字段进行特殊处理,例如,需要将时间转换成日期格式

1
2
3
4
5
6
7
8
9
10
11
12
13
14
type User struct {
Name string `json:"name"`
Birthday time.Time `json:"birthday"`
}

func main() {
u := User{
Name: "mitaka",
Birthday: time.Now(),
}
marshal, _ := json.Marshal(u)
fmt.Println(string(marshal))
// {"name":"mitaka","birthday":"2022-09-27T21:22:36.260545+08:00"}
}

此时,如果需要将Birthday反序列化成日期格式,而如果每次都需要特殊处理一下,那么代码会非常繁琐,这里可以使用Marshaler方法

1
2
3
4
5
6
7
8
9
10
type Birthday time.Time

func (b Birthday) MarshalJSON() ([]byte, error) {
return []byte(fmt.Sprintf(`"%s"`, (time.Time)(b).Format("2006-01-02"))), nil
}

type User struct {
Name string `json:"name"`
Birthday Birthday `json:"birthday"`
}

其中 fmt.Sprintf("%s")需要注意,要给返回的Format加引号, 否则会出现报错 json: error calling MarshalJSON for type main.Birthday: invalid character '-' after top-level value

跨域

当在前后端分离的系统中,跨域的问题比较常见。

跨域指的是:浏览器不能执行其他网站的脚本,从一个域名的网页去请求另一个域名的资源时,域名、端口、协议任一不同,都是跨域。跨域是由浏览器的同源策略造成的,是浏览器施加的安全限制。a页面想获取b页面资源,如果a、b页面的协议、域名、端口、子域名不同,所进行的访问行动都是跨域的。

例如:a页面想获取b页面资源,如果a、b页面的协议、域名、端口、子域名不同,所进行的访问行动都是跨域的,而浏览器为了安全问题一般都限制了跨域访问,也就是不允许跨域请求资源。注意:跨域限制访问,其实是浏览器的限制。理解这一点很重要。

跨域的解决方案有很多,前后端都可以解决,例如通过nginx反向代理可以解决,在前端也可以通过proxy解决。

跨源资源共享(CORS)

解决方案,放行复杂请求,允许浏览器访问资源

1
2
3
4
5
6
7
8
9
10
11
12
13
14
func Cors() gin.HandlerFunc {
return func(c *gin.Context) {
method := c.Request.Method

c.Header("Access-Control-Allow-Origin", "*")
c.Header("Access-Control-Allow-Methods", "POST, GET, OPTIONS")
c.Header("Access-Control-Allow-Headers", "X-PINGOTHER, Content-Type")
c.Header("Access-Control-Max-Age", "86400")

if method == "OPTIONS" {
c.AbortWithStatus(http.StatusNoContent)
}
}
}

验证码

验证码可有通过多种方式,图片数字、语音、图片文字

Go进阶37:重构我的base64Captcha图形验证码项目

实例代码

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
package main

import (
"net/http"

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

// 使用默认的内存store,在分布式环境下,需要使用redis
var store = base64Captcha.DefaultMemStore

func GetCaptcha(c *gin.Context) {
var driver base64Captcha.Driver
switch c.Query("captcha") {
case "audio":
driver = base64Captcha.DefaultDriverAudio
case "digit":
driver = base64Captcha.DefaultDriverDigit
// 这四种没有默认,需要手动创建
//case "chinese":
//case "language":
//case "math":
//case "string":
default:
driver = base64Captcha.DefaultDriverDigit
}

cp := base64Captcha.NewCaptcha(driver, store)
id, s, err := cp.Generate()
if err != nil {
c.JSON(http.StatusInternalServerError, err)
return
}

c.JSON(http.StatusOK, gin.H{
"captcha": s,
"captchaID": id,
})
}

在登录接口验证

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
func Login(c *gin.Context) {
var u User
err := c.ShouldBind(&u) // 当密码为空,返回200,返回值为空
if err != nil {
c.JSON(http.StatusOK, err.Error())
return
}

// true 代表只能使用一次,如果输入错误,需要重新获取图片
if !store.Verify(u.CaptchaID, u.Captcha, true) {
c.JSON(http.StatusBadRequest, gin.H{
"msg": "验证码错误",
})
return
}

if u.Name == "mitaka" && u.Password == "123" {
token, err := tokenGen.GeneraToken(1, 10, 2*time.Hour)
if err != nil {
c.JSON(http.StatusInternalServerError, err.Error())
return
}
c.JSON(http.StatusOK, gin.H{
"token": token,
})
}
return
}

短信验证码

通过阿里云的sdk调用即可

国内文本短信

短信发送并查询示例

需要:

  • 个人账户的aksk
  • money