8、给Gin服务器添加中间件

Web中间件介绍

中间件(Middleware) 是位于应用程序请求-响应处理循环中的一个特殊函数。它可以在请求到达业务逻辑处理之前修改/处理请求,或是在响应返回给客户端之前修改/处理响应。中间件根据使用方又可分为客户端中间件和服务端中间件,两者在实现原理和使用方式上是一致的。

中间件的核心作用是对请求或响应进行预处理、后处理或监控。它允许在请求和响应被发送或接收之前或之后插入自定义逻辑,从而实现多种功能,例如认证、授权、日志记录、性能监控、错误处理、请求验证、跨域支持、限流等。以下是核心使用场景的详细说明:

  • 认证和授权:使用中间件可以实现认证和授权逻辑。在中间件中,可以验证请求者的身份、权限等信息,并根据情况决定是否允许请求继续进行;
  • 日志记录:中间件可以用于记录请求和响应的详细信息,从而实现日志记录和监控。可以记录请求的内容、调用的方法、响应的结果等,以便于调试和分析;
  • 错误处理:在中间件中可以捕获和处理 9RPC 调用过程中可能发生的错误,以提供更友好的错误信息或进行恢复操作;
  • 性能监视:使用中间件可以监视 Web 调用的性能指标,如调用时间、响应时间等,从而实现性能监控和优化。

image

中间上图中,有两个中间件:中间件 A 和中间件 B。一个Web请求从开始到结束时的执行流程为:中间件 A->中间件B->处理器函数->中间件 B->中间件 A,其执行顺序类似于栈结构

Web 中间件的作用实际上是实现对请求的前置拦截和对响应的后置拦截功能:

  • 请求前置拦截:在 Web 请求到达定义的处理器函数之前,对请求进行拦截并执行相应的处理
  • 请求后置拦截:在完成请求的处理并相应客户端后,拦截响应并进行相应的处理

需要注意的是,中间件会附加到每个请求的链路上,因此如果中间件性能较差或不稳定,将会影响所有 API 接口。因此,在开发中间件时,应确保其稳定性和性能,同时建议仅添加必要的中间件。

Gin中间件介绍

Gin 框架也支持 Web 中间件,在 Gin 框架中,Web 中间件就叫中间件。本节课就来详细介绍如何实现并添加Gin 中间件。

Gin 支持三种中间件使用方式:

  • 全局中间件:全局中间件会作用于所有的路由。它们通常用于处理通用功能,比如请求日志记录、跨域设置、错误恢复,
  • 路由组中间件:路由组中间件仅对指定的路由组生效,适用于将某些逻辑限定在同一组相关的路由中。例如:所有/api 路径下的路由可能都需要一套特定的身份验证中间件;
  • 单个路由中间件:单个路由中间件仅对一个路由起作用。有时某个路由需要执行独立的中间件逻辑,这种情况下,可以将中间件绑定到单个路由上。
package main

import (
    "log"
    "net/http"

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

// 定义一个通用中间件:打印请求路径
func LogMiddleware() gin.HandlerFunc {
    return func(c *gin.Context) {
        log.Printf("Request path: %s\n", c.Request.URL.Path)
        // 继续处理后续的中间件或路由
        c.Next()
    }
}

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

    // 使用全局中间件:所有路由都会经过该中间件
    // r.Use(gin.Logger(), gin.Recovery()) 同时设置多个 Gin 中间件
    r.Use(LogMiddleware())

    // 定义普通路由
    r.GET("/", func(c *gin.Context) {
        c.JSON(http.StatusOK, gin.H{"message": "Home"})
    })

    // 定义一个路由组,并为组添加中间件
    apiGroup := r.Group("/api", LogMiddleware())
    {
        apiGroup.GET("/hello", func(c *gin.Context) {
            c.JSON(http.StatusOK, gin.H{"message": "Hello, API"})
        })
        apiGroup.GET("/world", func(c *gin.Context) {
            c.JSON(http.StatusOK, gin.H{"message": "World, API"})
        })
    }

    // 为单个路由添加中间件
    r.GET("/secure", LogMiddleware(), func(c *gin.Context) {
        c.JSON(http.StatusOK, gin.H{"message": "This is a secure route"})
    })

    // 启动HTTP服务
    r.Run(":8080") // 监听在8080端口
}

。上述代码中,通过r.Use()r.Group()r.Get() 方法分别设置了多个 Gin 中间件。在设置 Gin 中间件时,可以根据需要同时设置一个或者多个,例如 r.Use(gin.Logger(),gin.Recovery()),同时设置了两个 Gin 中间件。

LogMiddleware 中间件中,c.Next() 方法之前的代码将在请求到达处理器函数之前执行,而 c.Next() 方法之后的代码将在请求经过处理器函数处理之后执行。另外,在开发 Gin 中间件时, c.Abort() 方法也经常被开发者使用,该方法会直接终止请求的执行。

Fastgo添加中间件

给fastgo添加中间件分为2步:

1、开发gin中间件

2、加载Gin中间件

开发 Gin 中间件

这里,我们先开发 3 个常用的 Gin 中间件:

  • NoCache: 通过设置一些 Header,禁止客户端缓存 HTTP 请求的返回结果;
  • Cors:用来设置 options 请求的返回头,然后退出中间件链,并结束请求(浏览器跨域设置);。
  • RequestlD:用来在每一个 HTTP 请求的 context,response 中注入X-request-id键值对。

Gin中间件其实就是一个fun(c *gin.Context)类型的函数。在函数中可以从c中解析请求,执行通用的处理逻辑、设置返回参数等。

NoCache及Cors中间件代码如下internal/pkg/middleware/header.go:

package middleware

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

// NoCache 是一个 Gin 中间件,用来禁止客户端缓存 HTTP 请求的返回结果.
func NoCache(c *gin.Context) {
	c.Header("Cache-Control", "no-cache, no-store, max-age=0, must-revalidate, value")
	c.Header("Expires", "Thu, 01 Jan 1970 00:00:00 GMT")
	c.Header("Last-Modified", time.Now().UTC().Format(http.TimeFormat))
	c.Next()
}

// Cors 是一个 Gin 中间件,用来设置 options 请求的返回头,然后退出中间件链,并结束请求(浏览器跨域设置).
func Cors(c *gin.Context) {
	if c.Request.Method != "OPTIONS" {
		c.Next()
	} else {
		c.Header("Access-Control-Allow-Origin", "*")
		c.Header("Access-Control-Allow-Methods", "GET,POST,PUT,PATCH,DELETE,OPTIONS")
		c.Header("Access-Control-Allow-Headers", "authorization, origin, content-type, accept")
		c.Header("Allow", "HEAD,GET,POST,PUT,PATCH,DELETE,OPTIONS")
		c.Header("Content-Type", "application/json")
		c.AbortWithStatus(200)
	}
}

RequestId中间件位于internal/pkg/middleware/requestid.go文件

package middleware

import (
	"github.com/gin-gonic/gin"
	"github.com/google/uuid"
	"gitlab.com/onexstack/fastgo/internal/pkg/contextx"
	"gitlab.com/onexstack/fastgo/internal/pkg/known"
)

// RequestID 是一个 Gin 中间件,用来在每一个 HTTP 请求的 context, response 中注入 `x-request-id` 键值对.
func RequestID() gin.HandlerFunc {
	return func(c *gin.Context) {
		// 从请求头中获取 `x-request-id`,如果不存在则生成新的 UUID
		requestID := c.Request.Header.Get(known.XRequestID)

		if requestID == "" {
			requestID = uuid.New().String()
		}

		// 将 RequestID 保存到 context.Context 中,以便后续程序使用
		ctx := contextx.WithRequestID(c.Request.Context(), requestID)
		c.Request = c.Request.WithContext(ctx)

		// 将 RequestID 保存到 HTTP 返回头中,Header 的键为 `x-request-id`
		c.Writer.Header().Set(known.XRequestID, requestID)

		// 继续处理请求
		c.Next()
	}
}

RequestID中间件尝试从c中获取x-request-id请求头,如果获取不到则生成一个新的请求ID并设置为请求头x-request-id的值。为了能在代码中方便获取请求ID,例如:可以打印到日志中,方便串联整个请求日志。还通过contextx.WithRequestID调用,将请求ID保存在了自定义上下文contextx中。

中间件最后通过c.Writer.Header().Set(known.XRequestID, requestID) 调用,将请求ID设置到了返回头中,这样可以将请求ID也传给客户端。方便出问题时提供请求ID进行排查。

因为x-request-id 会在多个地方被访问,为了更好管理这种通用的常量,将x-request-id 以常量的形式保存在known中,internal/pkg/known/knwon.go中

package known

const (
	// XRequestID 用来定义上下文中的键,代表请求 ID.
	XRequestID = "x-request-id"
)

为了能方便的从context.Context中获取请求ID,fastgo项目引入了自定义上下文包contextx。contextx提供了便捷的函数,给context.Contgext添加键值对,并从context.Context中根据键获取对应的值。intetrnal/pkg/contextx/contextx.go

package contextx

import "context"

// 定义用于上下文的键.
type (
	// requestIDKey 定义请求 ID 的上下文键.
	requestIDKey struct{}
)

// WithRequestID 将请求 ID 存放到上下文中.
func WithRequestID(ctx context.Context, requestID string) context.Context {
	return context.WithValue(ctx, requestIDKey{}, requestID)
}

// RequestID 从上下文中提取请求 ID.
func RequestID(ctx context.Context) string {
	requestID, _ := ctx.Value(requestIDKey{}).(string)
	return requestID
}

通过定义一个新的类型requestIDKey struct{},可以避免键名冲突,因为类型是唯一的

加载中间件

修改internal/apiserver/server.go

package apiserver

import (
	"errors"
	"fmt"
	"github.com/gin-gonic/gin"
	"gitlab.com/onexstack/fastgo/internal/pkg/middleware"
	genericoptions "gitlab.com/onexstack/fastgo/pkg/options"
	"log/slog"
	"net/http"
)

// Server
// @Description: 定义一个服务器结构体类型
type Server struct {
	cfg *Config
	srv *http.Server
}

//	Config
//	@Description: Config结构体,用于存储应用相关的配置
//
// 不用viper.Get 是因为这种方式能更加清晰知道应用提供了哪些配置项
type Config struct {
	MYSQLOptions *genericoptions.MySQLOption
	Addr         string
}

// NewServer
//
//	@Description: NewServer 根据配置创建服务器
//	@receiver cfg
//	@return *Server
//	@return error
func (cfg *Config) NewServer() (*Server, error) {
	// 创建Gin引擎
	engine := gin.New()
	// gin.Recovery() 中间件,用来捕获任何panic并恢复
	mws := []gin.HandlerFunc{gin.Recovery(), middleware.NoCache, middleware.Cors, middleware.RequestID()}
	engine.Use(mws...)
	// 注册404 handler
	engine.NoRoute(func(c *gin.Context) {
		c.JSON(http.StatusNotFound, gin.H{
			"code":    404,
			"message": "Page not Found",
		})
	})
	// 注册/healthz handler
	engine.GET("/healthz", func(c *gin.Context) {
		c.JSON(http.StatusOK, gin.H{
			"status": "ok",
		})
	})
	// 创建HTTP Server实例
	httpsrv := &http.Server{Addr: cfg.Addr, Handler: engine}

	return &Server{cfg: cfg, srv: httpsrv}, nil
}

// Run
//
//	@Description: Run运行应用
//	@receiver s
//	@return error
func (s *Server) Run() error {
	slog.Info(fmt.Sprintf("Read MYSQL host from config %s", s.cfg.MYSQLOptions.Addr))
	slog.Info("Start to listening the incoming requests on http address", "addr", s.cfg.Addr)
	if err := s.srv.ListenAndServe(); err != nil && !errors.Is(err, http.ErrServerClosed) {
		return err
	}
	return nil
}

编译并运行

(base) dujie@MacBook-Pro fastgo % sh build.sh                                        
gitlab.com/onexstack/fastgo/internal/apiserver
gitlab.com/onexstack/fastgo/cmd/fg-apiserver/app/options
gitlab.com/onexstack/fastgo/cmd/fg-apiserver/app
command-line-arguments
(base) dujie@MacBook-Pro fastgo % ./_output/fg-apiserver -c configs/fg-apiserver.yaml
/Users/dujie/.fastgo/fg-apiserver.yaml
[GIN-debug] [WARNING] Running in "debug" mode. Switch to "release" mode in production.
 - using env:   export GIN_MODE=release
 - using code:  gin.SetMode(gin.ReleaseMode)

[GIN-debug] GET    /healthz                  --> gitlab.com/onexstack/fastgo/internal/apiserver.(*Config).NewServer.func2 (5 handlers)
(base) dujie@MacBook-Pro fastgo % curl -v  http://127.0.0.1:6666/healthz
*   Trying 127.0.0.1:6666...
* Connected to 127.0.0.1 (127.0.0.1) port 6666
> GET /healthz HTTP/1.1
> Host: 127.0.0.1:6666
> User-Agent: curl/8.9.1
> Accept: */*
> 
* Request completely sent off
< HTTP/1.1 200 OK
< Cache-Control: no-cache, no-store, max-age=0, must-revalidate, value
< Content-Type: application/json; charset=utf-8
< Expires: Thu, 01 Jan 1970 00:00:00 GMT
< Last-Modified: Wed, 02 Apr 2025 06:49:48 GMT
< X-Request-Id: c1fba2dc-d43a-4c72-ae2f-debcb3914353
< Date: Wed, 02 Apr 2025 06:49:48 GMT
< Content-Length: 15
< 
* Connection #0 to host 127.0.0.1 left intact
{"status":"ok"}%        

可以看到,请求返回中,成功返回了请求ID:X-Request-Id ,及NoCache中间件设置的返回头