提示:本节最终代码参考:feature/s12

在实现一个最简单的 Web 服务器之后,接下来,还需要实现一些核心的功能,例如:各类中间件、跨域、优雅关停。本节课,我们就一起实现这些功能。

Gin Web 框架如何添加中间件(Middleware)?

在 Web 开发中,我们要实现很多功能,例如:认证、授权、限流、熔断、设置请求/返回 Header(例如:请求 ID)、跨域等,这些都需要通过 Web 中间件的方式来实现,可以说中间件是 Web 框架或者 Web 服务非常核心的一个功能,一个中大型的 Web 应用基本都需要用到。

那么什么是 Web 中间件呢?简单来说,Web 中间件是 HTTP / RPC 请求必经的一个中间层,该中间层可以统一处理所有的请求,你可以根据需要开发不同功能的中间层。例如:你可以在中间层给所有的请求/返回头中添加 X-Request-ID 头,用以标识唯一一次请求,方便追踪、排障。

基本上所有的 Web 框架都具有中间件的能力,不同框架可能叫法不同,例如有叫 Filter、Middleware 的,但实现的都是类似的机制。中间件常用在权限验证、日志记录、数据过滤等场景中。

Gin 也具有强大的中间件能力,Gin 的中间件是基于洋葱模型的,如下图所示:

10.基础功能:Web 服务如何添加中间件、跨域、优雅关停功能? - 图1

上图中,有 2 个中间件:Middleware A、Middleware B。HTTP 请求,从开始到结束经历的路径为:Middleware A -> Middleware B -> 主体函数 -> Middleware B -> Middleware A。执行顺序类似于栈。

从上图可以知道,Gin 中间件,其实可以起到请求前置拦截和后置拦截的功能:

  • 请求前置拦截: Web 请求到达我们定义的 HTTP 请求处理方法之前,拦截请求并进行相应处理;

  • 请求后置拦截: 在处理完成请求并响应客户端时,拦截响应并进行相应的处理。

Gin 中添加中间件的方法如下:

  1. package main
  2. import "github.com/gin-gonic/gin"
  3. func main() {
  4. // 创建一个不带任何中间件的路由
  5. r := gin.New()
  6. // 全局中间件
  7. // Logger 中间件将日志写到 gin.DefaultWriter,即使设置了 GIN_MODE=release
  8. // 默认设置 gin.DefaultWriter = os.Stdout
  9. r.Use(gin.Logger())
  10. // Recovery 中间件,从任何 panic 恢复,并返回一个 500 错误
  11. r.Use(gin.Recovery())
  12. // 对于每一个路由,如果有需要,可以添加多个中间件
  13. r.GET("/benchmark", MyBenchLogger(), benchEndpoint)
  14. // 授权组
  15. // authorized := r.Group("/users", AuthRequired())
  16. // 也可以这样
  17. authorized := r.Group("/users")
  18. // 在这个示例中,我们使用了一个自定义的中间件 AuthRequired(),该中间件只作用于 authorized 组
  19. authorized.Use(AuthRequired())
  20. {
  21. authorized.POST("/login", loginEndpoint)
  22. authorized.POST("/submit", submitEndpoint)
  23. authorized.POST("/read", readEndpoint)
  24. // 嵌套组
  25. testing := authorized.Group("testing")
  26. testing.GET( "/analytics" , analyticsEndpoint)
  27. }
  28. // 监听并服务于0.0.0.0:8080
  29. r.Run(":8080")
  30. }

上述代码中,我们通过 r.Use()r.Get()r.Group() 方法分别设置了多个 Gin 中间件,Gin 提供不同的方法,在不同的位置设置中间件,不同位置的中间件作用于不同的路由:

  1. 全局中间件:全局中间件设置之后对全局的路由都起作用。

我们可以通过默认路由来设置全局中间件,例如:r.Use()。可以根据需要设置 1 个或者多个:

  1. router := gin.New()
  2. //一次设置多个中间件
  3. router.Use(Logger(), Recovery())
  4. //一次设置一个中间件
  5. router.Use(gin.Logger())
  6. router.Use(gin.Recovery())
  1. 路由组中间件:路由组中间件仅对该路由组下面的路由起作用。

可以通过以下 2 种方式来设置路由组中间件:

  • 在声明路由组的同时设置路由组中间件,例如:
  1. authorized := r.Group("/users", AuthRequired())
  • 先声明路由组然后再通过 Use 方法进行设置,例如:
  1. authorized := r.Group("/users")
  2. authorized.Use(AuthRequired())
  1. 单个路由中间件:单个路由中间件仅对一个路由起作用。

可以通过以下 2 种方式来设置单个路由中间件:

  • 单个路由设置单个中间件,例如:
  1. authorized.POST("/login", loginEndpoint)
  • 单个路由设置多个中间件,例如:
  1. r.GET("/benchmark", MyBenchLogger(), benchEndpoint)

上述代码段中,各个中间件作用的路由如下:

  • r.Use(gin.Logger())r.Use(gin.Recovery()) 将中间件添加在全局路由上,也就是说,所有请求路径以 / 开头的请求都会被 gin.Logger()gin.Recovery() 中间件按顺序请求;

  • AuthRequired() 中间件被添加在了 authorized 路由分组中,也就是所有请求路径以 /users 开头的请求,都会被 AuthRequired() 中间件处理;

  • analyticsEndpoint 中间件被添加在了 testing 路由分组中,也就是所有请求路径以 /users/testing 开头的请求,都会被 analyticsEndpoint 中间件处理;

  • loginEndpoint 中间件只作用在 POST /users/login 方法。

Gin 社区也有很多好用的中间件可供我们使用,如果有需要,你可以查看:gin-gonic/contrib

这里需要注意的是,因为中间件会附加在每个请求的链路上,所以如果中间件性能不好,或者不稳定,影响的是所有 API 接口。所以,在开发中间件的时候,需要确保中间件的稳定性和性能,并且建议只添加真正需要的中间件。

Gin Web 中间件实现

上面,我介绍了如何添加中间件,这里我再来介绍下,如何开发 Gin 中间件。

在实际应用开发中,经常会有这么一种场景:一个用户执行某次操作失败,这时候找你定位、修复问题。然后会提供给你一些基本的请求信息,供你定位。

定位问题的时候,绝大部分情况下,都需要查找日志,发现问题。但是,用户给你的信息,可能不足以查找到问题,也可能查找到的日志,并不是他需要定位的那次请求,这个时候该怎么办呢?

当前最好的方法,是在每次请求中都注入一个 RequestID,并且在每条日志中,输出该 RequestID。这样用户只需要提供给你一个唯一的 RequestID,你就能够定位到跟这次请求相匹配的所有日志,加快问题修复速度和准度。所以,这时候我们需要实现以下部分功能:

  1. 在请求中注入 RequestID;

  2. 在日志中打印 RequestID。

给请求添加 X-Request-ID

要在请求中注入 RequestID,根据 HTTP 请求/返回头的格式,我们需要一个 Header Key。为了便于大家理解哪个 Header Key 指代的是 RequestID,我们采用当前用的最多的 Header Key 命名:X-Request-ID

X-Request-ID 可以是任何全局唯一的值,实际开发中,通常使用 32 位的 UUID,例如:b55696f0-dac9-47d3-a3ed-00db20685295

你可以使用 github.com/google/uuid 包提供的 New 方法来生成。当然,社区有很多包可以生成 UUID,当前我喜欢使用 github.com/google/uuid 来生成,因为该包使用起来很简单。

那么, 我现在有了 UUID,如何开发一个 Gin 中间件,将 X-Request-ID 注入到请求头中呢?这里,我们先来看下 r.Use 方法的入参格式:

  1. Use(middleware ...HandlerFunc) IRoutes

Use 方法的入参是一个 Gin 中间件,从 Use 的入参我们知道,Gin 中间件,其实是一个 type HandlerFunc func(*Context) 类型的函数。所以,我们只需要返回一个 gin.HandlerFunc 类型的 function 即可。也可以这么来实现:

  1. package middleware
  2. import (
  3. "github.com/gin-gonic/gin"
  4. "github.com/google/uuid"
  5. )
  6. func RequestID() gin.HandlerFunc {
  7. return func(c *gin.Context) {
  8. requestID := c.Request.Header.Get("X-Request-ID")
  9. if requestID == "" {
  10. requestID = uuid.New().String()
  11. }
  12. // 将 RequestID 保存在 gin.Context 中,方便后边程序使用
  13. c.Set("X-Request-ID", requestID)
  14. // 将 RequestID 保存在 HTTP 返回头中,Header 的键为 `X-Request-ID`
  15. c.Writer.Header().Set("X-Request-ID", requestID)
  16. }
  17. }

上述代码创建了一个 32 位的 UUID,并分别设置在 *gin.Context 和 HTTP 返回头中。

上面,我们分析过,我们也需要在日志中打印这个 X-Request-ID,为了方便从 *gin.Context 中获取 X-Request-ID,我们将其 Key 名字保存在一个共享包中 known 中,见:known.go#L10

所以,最后我们开发的 RequestID 中间件代码如下:

  1. // RequestID 是一个 Gin 中间件,用来在每一个 HTTP 请求的 context, response 中注入 `X-Request-ID` 键值对.
  2. func RequestID() gin.HandlerFunc {
  3. return func(c *gin.Context) {
  4. // 检查请求头中是否有 `X-Request-ID`,如果有则复用,没有则新建
  5. requestID := c.Request.Header.Get(known.XRequestIDKey)
  6. if requestID == "" {
  7. requestID = uuid.New().String()
  8. }
  9. // 将 RequestID 保存在 gin.Context 中,方便后边程序使用
  10. c.Set(known.XRequestIDKey, requestID)
  11. // 将 RequestID 保存在 HTTP 返回头中,Header 的键为 `X-Request-ID`
  12. c.Writer.Header().Set(known.XRequestIDKey, requestID)
  13. c.Next()
  14. }
  15. }

这里,你可能发现,上述代码中有一个 c.Next() 调用。Gin 为中间件功能提供了 2 个核心方法,在开发中间件时会经常被用到:

  • c.Next():在中间件中调用 Next() 方法,Next() 方法之前的代码会在到达请求方法前执行,Next() 方法之后的代码则在请求方法处理后执行,例如:
  1. func MyMiddleware(c *gin.Context){
  2. //请求前
  3. c.Next()
  4. //请求后
  5. }
  • c.Abort():在中间件中调用 Abort() 方法,会直接终止请求。

提示:RequestID() 中间件中的 c.Next() 之后,并没有处理逻辑,这里只起到示例作用。

在日志中打印 X-Request-ID

那么如何在日志中,输出每一次请求的 X-Request-ID 呢?这时候,我们就需要定制化日志包,这也是我们为什么要基于开源日志包定制化开发项目独有日志包的一个原因。

这里,我们介绍下 miniblog 的实现方式,这种方式也是业界最普遍的实现方式,你也可以理解为最佳实践。实现思路如下:在日志包中添加 C(ctx context.Context) *zapLogger 方法,C 方法中尝试从 ctx 中获取期望的 Key,如果值不为空,则调用 *zap.LoggerWith 方法,将 X-Request-ID 添加到日志输出中。实现代码如下:

  1. // C 解析传入的 context,尝试提取关注的键值,并添加到 zap.Logger 结构化日志中.
  2. func C(ctx context.Context) *zapLogger {
  3. return std.C(ctx)
  4. }
  5. func (l *zapLogger) C(ctx context.Context) *zapLogger {
  6. lc := l.clone()
  7. if requestID := ctx.Value(known.XRequestIDKey); requestID != nil {
  8. lc.z = lc.z.With(zap.Any(known.XRequestIDKey, requestID))
  9. }
  10. return lc
  11. }
  12. // clone 深度拷贝 zapLogger.
  13. func (l *zapLogger) clone() *zapLogger {
  14. lc := *l
  15. return &lc
  16. }

这里需要注意,因为 log 包被多个请求并发调用,为了防止 X-Request-ID 污染,针对每一个请求,我们都深拷贝一个 *zapLogger 对象,然后再添加 X-Request-ID

添加 RequestID 中间件

在我们开发完了 RequestID 中间件之后,还需要通过 Gin 框架提供的 Use 方法加载这个中间件。为了使所有的请求都具有 X-Request-ID,我们将 RequestID 设置为全局中间。加载方法用伪代码展示如下:

  1. import mw "github.com/marmotedu/miniblog/internal/pkg/middleware"
  2. g := gin.New()
  3. mws := []gin.HandlerFunc{mw.RequestID()}
  4. g.Use(mws...)

miniblog 最终加载 RequestID 中间件的代码实现见:internal/miniblog/miniblog.go#L88

测试 X-Request-ID 输出

这里,我们编写代码来测试日志输出中是否被正确添加了 X-Request-ID 字段。我们改造 /healthz 路由方法,当 /healthz 被调用时,打印一条调用日志:

  1. // 注册 /healthz handler.
  2. g.GET("/healthz", func(c *gin.Context) {
  3. log.C(c).Infow("Healthz function called")
  4. c.JSON(http.StatusOK, gin.H{"status": "ok"})
  5. })

编译启动服务并测试:

  1. $ make
  2. $ _output/miniblog -c configs/miniblog.yaml

打开另外一个 Linux 终端,请求 /healthz

  1. $ curl http://127.0.0.1:8080/healthz
  2. {"status":"ok"}

查看 miniblog 服务输出,发现 X-Request-ID 字段被成功添加到了日志输出中:

  1. $ _output/miniblog -c configs/miniblog.yaml
  2. 2022/11/30 16:00:26 maxprocs: Leaving GOMAXPROCS=16: CPU quota undefined
  3. [GIN-debug] [WARNING] Running in "debug" mode. Switch to "release" mode in production.
  4. - using env: export GIN_MODE=release
  5. - using code: gin.SetMode(gin.ReleaseMode)
  6. [GIN-debug] GET /healthz --> github.com/marmotedu/miniblog/internal/miniblog.run.func2 (6 handlers)
  7. 2022-11-30 16:00:26.662 info miniblog/miniblog.go:114 Start to listening the incoming requests on http address {"addr": ":8080"}
  8. 2022-11-30 16:01:24.960 info miniblog/miniblog.go:104 Healthz function called { "X-Request-ID" : "abcba2c3-0099-42e1-95cd-893c88a31a6c" }

开发完成后的代码见:feature/s11

跨域功能实现

在前后端分离的架构中,因为前后端域名不一致,会浏触发览器的同源策略限制,导致请求失败。所以,后端经常需要处理来自前端的跨域请求。这里,我来介绍下后端服务如何实现跨域功能。

为什么会出现跨域

先来看下,为什么会出现跨域。原因如下:

  • 出于浏览器的同源策略限制。同源策略(Sameoriginpolicy)是一种约定,它是浏览器最核心也最基本的安全功能,如果缺少了同源策略,则浏览器的正常功能可能都会受到影响。可以说 Web 是构建在同源策略基础之上的,浏览器只是针对同源策略的一种实现。同源策略会阻止一个域的 javascript 脚本和另外一个域的内容进行交互;

  • 所谓同源(即指在同一个域)就是两个页面具有相同的协议(protocol),主机(host)和端口号(port);

  • 非同源限制:

    • 无法读取非同源网页的 Cookie、LocalStorage 和 IndexedDB;

    • 无法接触非同源网页的 DOM;

    • 无法向非同源地址发送 AJAX 请求。

简单来说就是:当一个资源去访问另一个不同源资源时,就会发出跨域请求。如果此时另一个资源不允许其进行跨域资源访问,那么访问的那个资源就会遇到跨域问题。

使用跨域资源共享(CORS)来跨域

解决跨域问题的方法有多种,例如:CORS、Nginx 反向代理、JSONP 跨域等。在 Go 后端服务开发中,通常可以使用 CORS 来解决跨域问题。

CORS 是一个 W3C 标准,全称是”跨域资源共享”(Cross-origin resource sharing)。它允许浏览器向跨域服务器,发出 AJAX 请求,从而克服了 AJAX 只能同源使用的限制。例如:当一个请求 URL 的协议、域名、端口三者之间任意一个与当前页面的 URL 不同即为跨域。案例如下:

当前页面 URL 被请求页面 URL 是否跨域 原因
www.example.com/ www.example.com/index.html 同源(协议、域名、端口号相同)
www.example.com/ www.example.com/index.html 跨域 协议不同(HTTP / HTTPS)
www.example.com/ www.baidu.com/ 跨域 主域名不同(example / baidu)
www.example.com/ blog.example.com/ 跨域 子域名不同(www / blog)
www.example.com:8080/ www.example.com:7001/ 跨域 端口号不同(8080 / 7001)

在使用 CORS 的时候,HTTP 请求会被划分为两类:简单请求和复杂请求,而这两种请求的区别主要在于是否会触发 CORS 预检请求。简单请求和复杂请求具体定义如下:

  • 简单请求: 请求方法是 GETHEAD 或者 POST,并且 HTTP 请求头中只有 Accept/Accept-Language/Content-Language/Last-Event-ID/Content-Type 6 种类型,且 Content-Type 只能是 application/x-www-form-urlencoded, multipart/form-data 或着 text / plain 中的一个值。简单请求会在发送时自动在 HTTP 请求头加上 Origin 字段,来标明当前是哪个源(协议 + 域名 + 端口),服务端来决定是否放行。

  • 复杂请求: 如果一个请求不是简单请求,则为复杂请求。

CORS 需要浏览器和服务器同时支持。目前,所有浏览器都支持该功能。浏览器一旦发现 AJAX 请求跨源,就会自动添加一些附加的头信息,复杂请求还会多出一次附加的预检请求,但用户不会有感觉。因此,实现 CORS 通信的关键是服务器。只要服务器实现了 CORS 接口,就可以跨源通信(在 Header 中设置:Access-Control-Allow-Origin

简单请求的 CORS 跨域处理

对于简单请求,浏览器直接发出 CORS 请求。具体来说,就是在头信息之中,增加一个 Origin 字段:

  1. origin: https://wetv.vip

服务器需要处理这个头部,并填充返回头 Access-Control-Allow-Origin

  1. access-control-allow-origin: https://wetv.vip

此头部也可填写为 *,表示接受任意域名的请求。如果不返回这个头部,浏览器会抛出跨域错误。(注:服务器端不会报错)

复杂请求的 CORS 跨域处理

复杂请求的 CORS 请求,会在正式通信之前,增加一次 HTTP 查询请求,称为”预检”请求(preflight)。”预检”请求用的请求方法是OPTIONS,表示这个请求是用来询问请求能否安全送出的。

当后端收到预检请求后,可以设置跨域相关 Header 以完成跨域请求。支持的 Header 具体如下表所示:

返回头 说明
Access-Control-Allow-Origin 必选,设置允许访问的域名
Access-Control-Allow-Methods 必选,逗号分隔的字符串,表明服务器支持的所有跨域请求的方法
Access-Control-Allow-Headers 逗号分隔的字符串,表明服务器支持的所有头信息字段,不限于浏览器在”预检”中请求的字段。如果浏览器请求包括 Access-Control-Request-Headers 字段,则此字段是必选的
Access-Control-Allow-Credentials 可选,布尔值,默认是false,表示不允许发送 Cookie
Access-Control-Max-Age 指定本次预检请求的有效期,单位为秒。可以避免频繁的预检请求

预检通过后,浏览器就正常发起请求和响应,流程和简单请求一致。

miniblog 跨域功能实现

因为 miniblog 的请求都是复杂请求,所以这里只需要处理复杂请求的跨域即可。跨域相关的 HTTP Header 也是通过中间件来实现的,miniblog 的实现见 Cors 中间件,代码如下:

  1. func Cors(c *gin.Context) {
  2. if c.Request.Method != "OPTIONS" {
  3. c.Next()
  4. } else {
  5. c.Header("Access-Control-Allow-Origin", "*")
  6. c.Header("Access-Control-Allow-Methods", "GET,POST,PUT,PATCH,DELETE,OPTIONS")
  7. c.Header("Access-Control-Allow-Headers", "authorization, origin, content-type, accept")
  8. c.Header("Allow", "HEAD,GET,POST,PUT,PATCH,DELETE,OPTIONS")
  9. c.Header("Content-Type", "application/json")
  10. c.AbortWithStatus(200)
  11. }
  12. }

上述代码实现以下效果:

  • 如果 HTTP 请求不是 OPTIONS 跨域请求,则继续处理 HTTP 请求;

  • 如果 HTTP 请求时 OPTIONS 跨域请求,则设置跨域 Header,并返回。

开发完 Cors 跨域中间件之后,还需要添加该中间件。我们在 run() 函数中,通过 g.Use 来加载 Cors 中间件,代码如下:

  1. // run 函数是实际的业务代码入口函数.
  2. func run() error {
  3. // ......
  4. mws := []gin.HandlerFunc{gin.Recovery(), mw.NoCache, mw.Cors, mw.Secure, mw.RequestID()}
  5. g.Use(mws...)
  6. // ......
  7. return nil
  8. }

上述代码中,我们不仅添加了 Cors 中间件,还加载了 gin.Recovery()NoCacheSecureRequestID。这些中间件分别用来完成不同的功能。

至于 NoCacheSecure 实现的功能,这里留给你自己去调研,欢迎在评论区留言分享。

添加优雅关停功能

先来说下,为什么要添加优雅关停能力。在应用程序的生命周期中,新功能发布、Bug 修复、配置变更等,都需要重启服务。在服务进程停止的时候,可能需要做一些处理工作,例如:

  1. 正在执行的 HTTP 请求,要等待请求执行完并返回,否则该请求会报错,并产生一些脏数据;

  2. 异步处理任务,也需要将缓存中的数据处理完成,否则可能会造成一些数据丢失或者不一致;

  3. 关闭数据库连接,否则数据库连接池会保存一个无用的连接,造成宝贵的连接资源浪费。

所以,给应用程序实现优雅关停功能,可以大大提高系统的健壮性。

实现的最佳思路,是在服务进程停止前,能够等待这些任务处理完成再退出进程。那么我们是如何停止服务进程的呢?实际上,我们通过给应用发送标准的系统信号来终止服务进程,例如最常见的 3 种终止进程的方式如下:

  • CTRL + C 实际是发送 SIGINT 信号;

  • kill <pid> 命令向指定的进程发送 SIGTERM 信号;

  • kill -9 <pid> 命令向指定进程发送 SIGKILL 信号,SIGKILL 信号既不能被应用程序捕获,也不能被阻塞或忽略。

提示:我们日常关闭服务时,尽量少用或不用 kill -9 命令,因为这样,应用进程可能无法执行优雅关停逻辑。

Go 程序优雅关停能力实现

如果我们能够捕获 SIGINTSIGTERM 信号,并执行一些关停逻辑,就能够实现优雅关停能力。实际上,当前 Go 应用的优雅关停能力都是基于这个思路来实现的。

Go 提供 os/signal 包可以用来监听并反馈收到的信号。所以,我们基于以上思路,使用 os/signal 即可实现优雅关停功能,实现代码如下:

  1. // 启动一些非阻塞任务,例如:HTTP / gRPC 服务,异步任务处理等逻辑
  2. // 创建一个 os.Signal 类型的 channel 用来接受信号
  3. quit := make(chan os.Signal, 1)
  4. // kill (no param) default send syscall.SIGTERM
  5. // kill -2 is syscall.SIGINT
  6. // kill -9 is syscall.SIGKILL but can't be caught, so don't need to add it
  7. signal.Notify(quit, syscall.SIGINT, syscall.SIGTERM)
  8. <-quit
  9. // 执行一些清理工作
  10. // 程序自然退出

流程如下:

  1. 启动 HTTP /gRPC 服务或者其他异步任务等,以非阻塞方式启动,如果服务本身是阻塞方式,我们可以放在 goroutine 中启动;

  2. 创建 os.Signal 类型的 channel,用来捕获应用程序关停信号;

  3. 调用 signal.Notify 函数设置需要捕获的信号,需要设置为 syscall.SIGINT, syscall.SIGTERM 2 种信号;

  4. 调用 <-quit 阻塞主程序;

  5. 之后,如果系统收到 SIGINTSIGTERM 信号,就会往 quit channel 中写入一条 os.Signal 类型的数据;

  6. quit 读取到数据,解除阻塞状态,执行后续的清理工作。清理工作执行完成后,正常终止进程。清理工作根据业务逻辑可以执行不同的清理函数,例如:可以通过 net/http 包提供的 Shutdown 方法,关停 HTTP 服务。

提示:还可以通过一些 Go 包,例如:fvbock/endless 来实现优雅关停,但我更推荐上面的方式,简单,并不需要引入一个新包。

miniblog 根据以上优雅关停功能实现思路,实现了优雅关停逻辑,代码如下:

  1. // 创建 HTTP Server 实例
  2. httpsrv := &http.Server{Addr: viper.GetString("addr"), Handler: g}
  3. // 运行 HTTP 服务器
  4. // 打印一条日志,用来提示 HTTP 服务已经起来,方便排障
  5. log.Infow("Start to listening the incoming requests on http address", "addr", viper.GetString("addr"))
  6. go func() {
  7. if err := httpsrv.ListenAndServe(); err != nil && !errors.Is(err, http.ErrServerClosed) {
  8. log.Fatalw(err.Error())
  9. }
  10. }()
  11. // 等待中断信号优雅地关闭服务器(10 秒超时)。
  12. quit := make(chan os.Signal, 1)
  13. // kill 默认会发送 syscall.SIGTERM 信号
  14. // kill -2 发送 syscall.SIGINT 信号,我们常用的 CTRL + C 就是触发系统 SIGINT 信号
  15. // kill -9 发送 syscall.SIGKILL 信号,但是不能被捕获,所以不需要添加它
  16. signal.Notify(quit, syscall.SIGINT, syscall.SIGTERM) // 此处不会阻塞
  17. <-quit // 阻塞在此,当接收到上述两种信号时才会往下执行
  18. log.Infow("Shutting down server ...")
  19. // 创建 ctx 用于通知服务器 goroutine, 它有 10 秒时间完成当前正在处理的请求
  20. ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
  21. defer cancel()
  22. // 10 秒内优雅关闭服务(将未处理完的请求处理完再关闭服务),超过 10 秒就超时退出
  23. if err := httpsrv.Shutdown(ctx); err != nil {
  24. log.Errorw("Insecure Server forced to shutdown", "err", err)
  25. return err
  26. }
  27. log.Infow("Server exiting")
  28. return nil

quit 收到 SIGINTSIGTERM 信号后,结束阻塞,并调用 Shutdown 方法关停 HTTP 服务。

Shutdown 方法工作流程为:首先关闭所有开启的监听器,然后关闭所有闲置连接,最后等待活跃的连接均闲置了才终止服务。

若传入的 ctx 在服务完成终止前已超时,则 Shutdown 方法返回 context 的错误,否则返回任何由关闭服务监听器所引起的错误。

Shutdown 方法被调用时,ServeListenAndServeListenAndServeTLS 方法会立刻返回 ErrServerClosed 错误。

ErrServerClosed 错误,我们视为服务关闭时的正常报错行为,所以如果 ListenAndServe 返回的是该错误,我们并不打印错误信息,例如:

  1. if err := httpsrv.ListenAndServe(); err != nil && !errors.Is(err, http.ErrServerClosed) {
  2. log.Fatalw(err.Error())
  3. }

测试优雅关停功能

miniblog 优雅关停的完整实现见:feature/s12。编译启动、并测试服务:

  1. $ make
  2. $ _output/miniblog -c configs/miniblog.yaml

打开另外一个终端,执行以下命令:

  1. $ kill `pgrep miniblog`

观察,miniblog 服务日志输出:

  1. 2022-12-01 00:45:05.312 info miniblog/miniblog.go:128 Shutting down server ...
  2. 2022-12-01 00:45:05.312 info miniblog/miniblog.go:139 Server exiting

可以看到,程序成功执行完 HTTP 优雅关停方法 Shutdown后退出。

小结

在 Web 开发中,我们经常需要很多通用处理功能,这些功能最佳的实现方式是通过 Gin 中间来实现,Gin 框架提供了简单、强大的中间件能力。

另外,为了提高应用的健壮性,我们还需要实现优雅关停功能,实现的一般思路是捕获系统关停信号,进行清理处理,并退出进程。