在实际开发中,我们可能需要对每个请求/返回做一些特定的操作,比如记录请求的 log 信息,在返回中插入一个 Header,对部分接口进行鉴权,这些都需要一个统一的入口,逻辑如下:
中间件
这个功能可以通过引入 middleware 中间件来解决。Go 的 net/http 设计的一大特点是特别容易构建中间件。apiserver 所使用的 gin 框架也提供了类似的中间件。

在 gin 中,可以通过如下方法使用 middleware:

g := gin.New()
g.Use(middleware.AuthMiddleware())

1. 自定义中间件

使用

r := gin.New()

替代

// Default With the Logger and Recovery middleware already attached
r := gin.Default()

代码示例一:

func main() {
	// Creates a router without any middleware by default
	r := gin.New()

	// Global middleware
	// Logger middleware will write the logs to gin.DefaultWriter even if you set with GIN_MODE=release.
	// By default gin.DefaultWriter = os.Stdout
	r.Use(gin.Logger())

	// Recovery middleware recovers from any panics and writes a 500 if there was one.
	r.Use(gin.Recovery())

	// Per route middleware, you can add as many as you desire.
	r.GET("/benchmark", MyBenchLogger(), benchEndpoint)

	// Authorization group
	// authorized := r.Group("/", AuthRequired())
	// exactly the same as:
	authorized := r.Group("/")
	// per group middleware! in this case we use the custom created
	// AuthRequired() middleware just in the "authorized" group.
	authorized.Use(AuthRequired())
	{
		authorized.POST("/login", loginEndpoint)
		authorized.POST("/submit", submitEndpoint)
		authorized.POST("/read", readEndpoint)

		// nested group
		testing := authorized.Group("testing")
		testing.GET("/analytics", analyticsEndpoint)
	}

	// Listen and serve on 0.0.0.0:8080
	r.Run(":8080")
}

在 Gin 框架中,系统自带了异常恢复(Recovery)中间件,这个中间件在处理程序出现异常时会在异常链中的任意位置恢复程序, 并打印堆栈的错误信息。

Recovery() 中间件一方面可以捕获处理程序中异常,并展示异常堆栈信息,便于开发人员进行 Bug 与异常的追踪调试与修改,另一方面也保障了 Web 服务器的稳定,不会因为某些处理程序的异常而导致 Web 服务停止服务,这在实际工程开发中是非常必须的一个中间件。

代码示例二:

为 Gin 框架定义一个中间件比较简单,只需要返回 gin.HandlerFunc 类型,且中间件有调用这个函数类型的 c.Next() 方法(以便能传递 Handler 的顺序调用),中间件返回的 gin.HandlerFunc 就是 func(c *gin.Context),这和路由中路径对应的处理程序即 func(c *gin.Context) 一致,所以前面把它们的组合称为处理程序集。

func Logger() gin.HandlerFunc {
	return func(c *gin.Context) {
		t := time.Now()

		// Set example variable
		c.Set("example", "12345")

		// before request

		c.Next()

		// after request
		latency := time.Since(t)
		log.Print(latency)

		// access the status we are sending
		status := c.Writer.Status()
		log.Println(status)
	}
}
// 下面程序使用了上面的自定义中间件,
func main() {
	r := gin.New()
	r.Use(Logger())

	r.GET("/test", func(c *gin.Context) {
		example := c.MustGet("example").(string)

		// it would print: "12345"
		log.Println(example)
	})

	// Listen and serve on 0.0.0.0:8080
	r.Run(":8080")
}

输出打印

2021/11/30 20:24:23 12345
2021/11/30 20:24:23 58.673µs
2021/11/30 20:24:23 200

代码示例三:
下面定义了一个简单的中间件,在控制台会显示程序运行该中间件的开始时间,以及运行其余 Handler 所耗费的时间。

    func LoggerMiddle() gin.HandlerFunc {
        return func(c *gin.Context) {
            fmt.Println("LoggerMiddle Strat: ", time.Now())

            // 转到下一个 Handler
            c.Next()

            // Handler 已经处理完,继续本中间件的处理
            // 显示 Handler 响应的状态码以及内容长度
            fmt.Println(c.Writer.Status(), ":", c.Writer.Size())
            fmt.Println("LoggerMiddle End: ", time.Since(t))

        }
    }

下面程序使用了上面的自定义中间件,和系统自带的如日志中间件在使用方法上没有差异。

    func main() {
        router := gin.New()

        // 加入自定义中间件
        router.Use(LoggerMiddle())

        router.GET("/ping", func(c *gin.Context) {
            c.String(200, "pong")
        })

        // 监听并启动服务
        router.Run(":8080")
    }

程序运行在 Debug 模式时,通过浏览器访问 http://localhost:8080/ping,控制台输出如下:

    [GIN-debug] GET /ping --> main.main.func1 (2 handlers)
    [GIN-debug] Listening and serving HTTP on :8080
    LoggerMiddle Strat: 2019-07-12 11:10:58.2797509 +0800 CST m=+2.801509801
    200 : 4
    LoggerMiddle End: 39.894ms

可以看到自定义的中间件 LoggerMiddle 正常运行,记录了该中间件运行开始时间,Handler 响应的状态码和响应内容的长度,以及该中间件运行总耗费时长。

2. 中间件分类

在 Gin 框架中,按照中间件作用的范围,可以分为三类中间件:全局中间件、组中间件、作用于单个处理程序的中间件。

2.1 全局中间件

全局中间件顾名思义,在所有的处理程序中都会生效,如下面代码中通过 Use() 方法加入的日志中间件:

// 新建一个没有任何默认中间件的路由
router := gin.New()

// 加入全局中间件日志中间件
router.Use(gin.Logger())

上面加入的日志中间件就是全局中间件,它会在每一个 HTTP 请求中生效。程序中注册的处理程序,其 URI 对应的路由信息都会包括这些中间件。

程序运行在 Debug 模式时,通过浏览器访问 http://localhost:8080/login,控制台输出如下:

[GIN-debug] GET    /login                    --> main.Login (2 handlers)
[GIN-debug] Listening and serving HTTP on :8080
[GIN] 2019/07/11 - 20:07:56 | 200 |            0s |           ::1 | GET      /login

可以看到处理程序 Login 实际上运行了两个 Handler,里面就含有日志中间件,并且日志中间件记录了该访问的日志。

2.2 组中间件

可以通过 Group() 方法直接加入中间件。如下代码所示:

router := gin.New()
router.Use(gin.Recovery())

// 简单的组路由 v1,直接加入日志中间件
v1 := router.Group("/v1", gin.Logger())
{
    v1.GET("/login", Login)
}
router.Run(":8080")

也可以通过 Use() 方法在设置组路由后加入中间件。如下代码所示:

router := gin.New()
router.Use(gin.Recovery())

// 简单的组路由 v2
v2 := router.Group("/v2")
// 使用Use()方法加入日志中间件
v2.Use(gin.Logger())
{
    v2.GET("/login", Login)
}
router.Run(":8080")

组中间件只在本组路由的注册处理程序中生效,不影响到在其他组路由注册的处理程序。

程序运行在 Debug 模式时,通过浏览器访问 http://localhost:8080/v2/login,控制台输出如下:

[GIN-debug] GET /v1/login --> main.Login (3 handlers)
[GIN-debug] GET /v2/login --> main.Login (3 handlers)
[GIN-debug] Listening and serving HTTP on :8080
[GIN] 2019/07/11 - 22:40:26 | 200 | 964µs | ::1 | GET /v2/login

可以看到日志中间件在 v2 组路由中的处理程序中生效,且 v2 组路由的处理程序 Login 实际运行了三个 Handler,即全局中间件,日志中间件和路由处理程序这三个 Handler。

2.3 单个请求的中间件

可以直接在单个处理程序上加入中间件。在 HTTP 请求方法中,如 GET() 方法的定义:

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

参数 handlers 可以填写多个,一般中间件写在前面而真正的处理程序写在最后。如下代码所示:

func main(){
    router := gin.New()
    router.Use(gin.Recovery())

    // 为单个处理程序添加任意数量的中间件。
    router.GET("/login",gin.Logger(), Login)

    router.Run(":8080")
}

通过控制台输出的信息可以看到 URI 路径 /login 实际上运行了三个 Handler,里面就含有日志中间件,异常恢复中间以及自身的处理程序这三个 Handler。

这三种中间件只是作用范围的区分,在功能上没有任何区别。比如身份验证可以作为中间件形式,选择性加在某些分组或者某些处理程序上。

3. 限流中间件

在 Web 服务中,有时会出现意料之外的突发流量,尤其是中小站点资源有限,如有突发事件就会出现服务器扛不住流量的冲击,但为了保证服务的可用性,在某些情况下可采用限流的方式来保证服务可用。 Gin 框架官方对此推荐了一款中间件:

go get github.com/aviddiviner/gin-limit
    import (
        "time"

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

    func main() {
        router := gin.Default()

        router.Use(limit.MaxAllowed(1))

        router.GET("/test", func(c *gin.Context) {
            time.Sleep(10 * time.Second)
            c.String(200, "test")
        })

        router.Run(":8080")
    }

程序运行在 Debug 模式时,通过浏览器访问 http://localhost:8080/test,如果并发访问的数量超过程序预定的值(这里为 1),如果超过阈值 1 的访问数量限制其处理程序将会被阻塞,直到前面处理程序完成处理。

上面程序通过延时处理,可以模拟多个请求发生,打开浏览器,新开两个 Tab 窗口,访问 http://localhost:8080/test,由于有延时存在,可清楚观察到只有前面的处理程序完成了才会继续运行后面第二个访问的处理程序。

4. 使用 BasicAuth 中间件

// simulate some private data
var secrets = gin.H{
	"foo":    gin.H{"email": "foo@bar.com", "phone": "123433"},
	"austin": gin.H{"email": "austin@example.com", "phone": "666"},
	"lena":   gin.H{"email": "lena@guapa.com", "phone": "523443"},
}

func main() {
	r := gin.Default()

	// Group using gin.BasicAuth() middleware
	// gin.Accounts is a shortcut for map[string]string
	authorized := r.Group("/admin", gin.BasicAuth(gin.Accounts{
		"foo":    "bar",
		"austin": "1234",
		"lena":   "hello2",
		"manu":   "4321",
	}))

	// /admin/secrets endpoint
	// hit "localhost:8080/admin/secrets
	authorized.GET("/secrets", func(c *gin.Context) {
		// get user, it was set by the BasicAuth middleware
		user := c.MustGet(gin.AuthUserKey).(string)
		if secret, ok := secrets[user]; ok {
			c.JSON(http.StatusOK, gin.H{"user": user, "secret": secret})
		} else {
			c.JSON(http.StatusOK, gin.H{"user": user, "secret": "NO SECRET :("})
		}
	})

	// Listen and serve on 0.0.0.0:8080
	r.Run(":8080")
}

程序运行在 Debug 模式时,通过浏览器访问 http://localhost:8080/secrets,控制台输出如下:

    [GIN-debug] GET /secrets --> main.main.func1 (4 handlers)
    [GIN-debug] Listening and serving HTTP on :8080
    [GIN] 2019/07/12 - 12:27:11 | 401 | 0s | ::1 | GET /secrets
    [GIN] 2019/07/12 - 12:28:06 | 200 |      1.0002ms |      ::1 | GET      /secrets

从控制台输出可以看到路径 /secrets 对应有四个处理程序,说明认证中间件、日志中间件和异常恢复中间件在里面生效了,再加上路径本身的处理程序即:func(c *gin.Context)。

浏览器会弹出一个提示框,输入用户名和密码。用户名输入 foo,密码输入 bar。回车后就能看到一个关于 foo 的信息的 JSON 串了。

5. 中间件内使用 goroutines

当在一个中间件或处理程序内启动新的 Goroutines时,你不应该使用里面的原始上下文,你必须使用一个只读的副本。

func main() {
	r := gin.Default()

	r.GET("/long_async", func(c *gin.Context) {
		// create copy to be used inside the goroutine
		cCp := c.Copy()
		go func() {
			// simulate a long task with time.Sleep(). 5 seconds
			time.Sleep(5 * time.Second)

			// note that you are using the copied context "cCp", IMPORTANT
			log.Println("Done! in path " + cCp.Request.URL.Path)
		}()
	})

	r.GET("/long_sync", func(c *gin.Context) {
		// simulate a long task with time.Sleep(). 5 seconds
		time.Sleep(5 * time.Second)

		// since we are NOT using a goroutine, we do not have to copy the context
		log.Println("Done! in path " + c.Request.URL.Path)
	})

	// Listen and serve on 0.0.0.0:8080
	r.Run(":8080")
}

6. 其它中间件

Gin 自带中间件远不止上面介绍的几种,比如 GZIP 等中间件,可以访问 https://github.com/gin-gonic/contrib 自行了解。在这里还可以了解到最新支持 Gin 框架的第三方中间件。例如:

  • RestGate:REST API 端点的安全身份验证
  • gin-jwt:用于 Gin 框架的 JWT 中间件
  • gin-sessions:基于 MongoDB 和 MySQL 的会话中间件
  • gin-location:用于公开服务器主机名和方案的中间件
  • gin-nice-recovery:异常错误恢复中间件,让您构建更好的用户体验
  • gin-limit:限制同时请求,可以帮助解决高流量负载
  • gin-oauth2:用于处理 OAuth2
  • gin-template:简单易用的 Gin 框架 HTML/模板
  • gin-redis-ip-limiter:基于 IP 地址的请求限制器
  • gin-access-limit:通过指定允许的源 CIDR 表示法来访问控制中间件
  • gin-session:Gin 的会话中间件
  • gin-stats:轻量级且有用的请求指标中间件
  • gin-session-middleware:一个高效,安全且易于使用的 Go 会话库
  • ginception:漂亮的异常页面
  • gin-inspector:用于调查 HTTP 请求的 Gin 中间件

7. API 身份验证

在典型业务场景中,为了区分用户和安全保密,必须对 API 请求进行鉴权, 但是不能要求每一个请求都进行登录操作。合理做法是,在第一次登录之后产生一个有一定有效期的 token,并将其存储于浏览器的 Cookie 或 LocalStorage 之中,之后的请求都携带该 token ,请求到达服务器端后,服务器端用该 token 对请求进行鉴权。在第一次登录之后,服务器会将这个 token 用文件、数据库或缓存服务器等方法存下来,用于之后请求中的比对。或者,更简单的方法是,直接用密钥对用户信息和时间戳进行签名对称加密,这样就可以省下额外的存储,也可以减少每一次请求时对数据库的查询压力。这种方式,在业界已经有一种标准的实现方式,该方式被称为 JSON Web Token。

token 的意思是“令牌”,里面包含了用于认证的信息。这里的 token 是指 JSON Web Token(JWT)。

JWT 认证流程
认证流程

  1. 客户端使用用户名和密码请求登录
  2. 服务端收到请求后会去验证用户名和密码,如果用户名和密码跟数据库记录不一致则验证失败,如果一致则验证通过,服务端会签发一个 Token 返回给客户端
  3. 客户端收到请求后会将 Token 缓存起来,比如放在浏览器 Cookie 中或者本地存储中,之后每次请求都会携带该 Token
  4. 服务端收到请求后会验证请求中携带的 Token,验证通过则进行业务逻辑处理并成功返回数据

在 JWT 中,Token 有三部分组成,中间用 . 隔开,并使用 Base64 编码:

  • header
  • payload
  • signature
    如下是 JWT 中的一个 Token 示例:
eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpYXQiOjE1MjgwMTY5MjIsImlkIjowLCJuYmYiOjE1MjgwMTY5MjIsInVzZXJuYW1lIjoiYWRtaW4ifQ.LjxrK9DuAwAzUD8-9v43NzWBN7HXsSLfebw92DKd1JQ

header 介绍

JWT Token 的 header 中,包含两部分信息:

  • Token 的类型
  • Token 所使用的加密算法

例如:

{
  "typ": "JWT",
  "alg": "HS256"
}

该例说明 Token 类型是 JWT,加密算法是 HS256(alg 算法可以有多种)。

Payload 载荷介绍

Payload 中携带 Token 的具体内容,里面有一些标准的字段,当然你也可以添加额外的字段,来表达更丰富的信息,可以用这些信息来做更丰富的处理,比如记录请求用户名,标准字段有:

  • iss:JWT Token 的签发者
  • sub:主题
  • exp:JWT Token 过期时间
  • aud:接收 JWT Token 的一方
  • iat:JWT Token 签发时间
  • nbf:JWT Token 生效时间
  • jti:JWT Token ID

本例中的 payload 内容为:

{
 "id": 2,
 "username": "kong",
 "nbf": 1527931805,
 "iat": 1527931805
}

Signature 签名介绍

Signature 是 Token 的签名部分,通过如下方式生成:

  • 用 Base64 对 header.payload 进行编码
  • 用 Secret 对编码后的内容进行加密,加密后的内容即为 Signature

Secret 相当于一个密码,存储在服务端,一般通过配置文件来配置 Secret 的值,本例中是配置在 conf/config.yaml 配置文件中:

jwt_secret: Rtg8BPKNEf2mB4mgvKONGPZZQSaJWNLijxR42qRgq0iBb5

最后生成的 Token 像这样:

eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpYXQiOjE1MjgwMTY5MjIsImlkIjowLCJuYmYiOjE1MjgwMTY5MjIsInVzZXJuYW1lIjoiYWRtaW4ifQ.LjxrK9DuAwAzUD8-9v43NzWBN7HXsSLfebw92DKd1JQ

签名后服务端会返回生成的 Token,客户端下次请求会携带该 Token,服务端收到 Token 后会解析出 header.payload,然后用相同的加密算法和密码对 header.payload 再进行一次加密,并对比加密后的 Token 和收到的 Token 是否相同,如果相同则验证通过,不相同则返回 HTTP 401 Unauthorized 的错误。

详情请参考:
基于 Token 的身份验证

本小节来自 《基于 Go 语言构建企业级的 RESTful API 服务》

更多推荐