Go 企业级工程能力实战(9):14 个中间件如何串成一条请求流水线

一、开篇:一个请求的"入境审查"

想象你正在通过国际机场的入境流程:

复制代码
第 1 道:检疫(测体温) ------ 体温异常直接遣返
第 2 道:边检(查护照) ------ 护照过期直接遣返  
第 3 道:海关(查行李) ------ 违禁品当场没收
第 4 道:放行 ------ 欢迎来到这座城市
在你离开机场时,每道关卡还要在你的入境记录上盖个章

Gin 中间件的工作原理和这个入境流程一模一样。一个 HTTP 请求到达后,不是直接跳到业务处理函数,而是要经过一层层的"关卡"。每个关卡可以做三件事:

  1. 放行前(入境检查):是否允许通过?
  2. 放行c.Next()):让下一道关卡继续处理
  3. 放行后(出境记录):请求处理完毕,记录日志

这就是著名的"俄罗斯套娃"模型------每个中间件包裹着下一个中间件,业务逻辑在最内层。

user-service 项目中,14 个中间件按照严格的顺序串成了一条请求流水线。这个顺序不是随便排的------排错了会导致安全问题、性能问题和可观测性盲区。


二、概念铺垫:中间件的三个核心概念

2.1 c.Next() vs c.Abort()

这是 Gin 中间件最核心的两个操作:

go 复制代码
// Next:我检查过了,没问题,传给下一个中间件
c.Next()

// Abort:这里有问题,后面的中间件和业务处理函数都不要执行了
c.Abort()

用机场入境比喻:

  • c.Next() = "体温正常,请到边检窗口"
  • c.Abort() = "体温 39 度,直接拉去隔离区,后面的边检、海关都不用去了"

注意:c.Abort() 不会阻止当前函数剩余代码的执行 。如果你调了 c.Abort() 但忘记 return,后面的代码会继续跑。所以正确的模式是:

go 复制代码
if failed {
    c.Abort()
    return  // 别忘了这个
}

service/middleware.go:39-58AuthRequired 中:

go 复制代码
if auth == "" || !strings.HasPrefix(auth, "Bearer ") {
    s.returnError(c, constant.ERROR_AUTH_FAIL, "authorization required")
    c.Abort()
    return  // ← 必须 return,否则会走到 c.Next()
}

2.2 中间件的执行顺序为什么重要?

Gin 中间件执行有一个反直觉的规则:前半段按注册顺序,后半段按注册逆序

复制代码
注册顺序:A → B → C → handler

执行时间线:
  A(前半段) → B(前半段) → C(前半段) → handler
                                      ↓
  A(后半段) ← B(后半段) ← C(后半段) ← 返回

关键:A 的后半段是最后一个执行的!

想象一个"俄罗斯套娃"------你打开最大的娃娃(A),再打开中间的那一个(B),再打开最小的(C),里面是业务逻辑。然后你开始往回装:先装最小的(C 后半段),再装中间的(B 后半段),最后装最大的(A 后半段)。

这个规则决定了中间件的排列顺序。举个反例:如果你在最前面 放一个"记录请求响应时间"的中间件,但在最后面 才放"解析 JWT Token"的中间件。那么被 JWT 中间件拒绝的请求(走了 c.Abort()),计时中间件的后半段仍然会执行。被 JWT 拒绝的请求同样被计入"平均响应时间",污染监控数据。

所以顺序原则是:

复制代码
先保安全(Recovery、Security Headers)
 → 再做协议处理(Gzip、CORS、Content-Type)
 → 再建上下文(RequestID、Tracing、Metrics)
 → 再做业务校验(Auth、Rate Limit、AuditLog)

2.3 如何在中间件中注入依赖?

Gin 的 gin.Context 提供了一个 key-value 存储(c.Set() / c.Get()),中间件可以把数据放进去,后续处理函数拿出来。比如:

go 复制代码
// AuthRequired 中间件
c.Set("user_id", claims.UserID)

// 业务处理函数
uid := c.GetInt("user_id")

但更优雅的方式是通过闭包注入依赖 。看 service/Routes.go:24

go 复制代码
r.Use(s.LoggerMiddleware())

注意 s.LoggerMiddleware()*Service 的方法,不是包级函数。它返回一个闭包,闭包里引用了 s(Service 实例)。这样中间件就能访问到 Service 的 logger、dao 等依赖。


三、循序渐进:14 个中间件的完整走读

让我们从 service/Routes.go:14-27 出发,一次穿越 14 道关卡:

go 复制代码
func NewRouter(s *Service) *gin.Engine {
    r := gin.New()
    r.Use(gin.Recovery())           //  1. 全局恢复
    r.Use(gzip.Gzip(...))           //  2. 响应压缩
    r.Use(RequestID())              //  3. 请求 ID
    r.Use(CORS())                   //  4. 跨域处理
    r.Use(SecurityHeaders())        //  5. 安全响应头
    r.Use(RequireContentType())     //  6. 内容类型校验
    r.Use(TracingMiddleware(...))    //  7. 链路追踪
    r.Use(MetricsMiddleware())      //  8. Prometheus 指标
    r.Use(s.LoggerMiddleware())     //  9. 请求日志
    r.Use(MaxBodySize())            // 10. 请求体大小限制
    r.Use(RequestTimeout(...))      // 11. 请求超时控制
    r.Use(s.AuditLog())             // 12. 审计日志
    // ... 路由注册
    // 路由级别还有一个 s.AuthRequired() 和 RateLimitMiddleware

第 1 关:Recovery(最后的防线)

gin.Recovery() 是 Gin 内置的全局 panic 恢复机制。放在最外层意味着:无论内层哪个中间件或业务函数 panic,它都能兜底

如果 Recovery 放在内层,外层中间件 panic 是无法被捕获的。所以它必须是第一层。

第 2 关:Gzip(响应体压缩)

gzip.Gzip(gzip.DefaultCompression) 自动把 JSON 响应体压缩后返回给客户端。一个 100KB 的 JSON 响应经 Gzip 后只有约 20KB,特别是对于 /nearby-users 这种返回列表的接口。

为什么 gzip 要放在最前面?

这个问题最容易让人困惑:注册最靠前的代码,不是应该最先生效吗?确实是------但 Gin 中间件的执行是"分两半"的:c.Next() 之前是前半段,c.Next() 之后是后半段。

复制代码
注册顺序(从外到内):
  gzip → RequestID → CORS → ... → handler

实际执行顺序(前→后→反转):
  gzip 前半段(包裹 Writer)
    → RequestID 前半段(生成 UUID)
      → CORS 前半段(设置跨域头)
        → ... handler 写响应体 ...
      ← CORS 后半段
    ← RequestID 后半段
  ← gzip 后半段 ← 🎯 此时响应体已写完,是唯 一正确的压缩时机!

两条关键规律:

  1. 前半段按注册顺序执行 ------早注册的先跑 c.Next() 前的代码。gzip 第一个注册,所以它最先执行前半段,先把 ResponseWriter 包一层,让后续所有中间件和 handler 写入响应体时,都被这个包装器拦截下来暂存。
  2. 后半段按注册逆序执行------早注册的后半段反而最后执行。gzip 的后半段在所有中间件和 handler 都写完响应体后才执行,此时完整的响应内容已经在缓冲区里了,正是压缩的最好时机。

如果 gzip 放在最后面注册会怎样?

复制代码
注册顺序(错误):
  RequestID → CORS → ... → gzip → handler

实际执行:
  RequestID 前半段
    → CORS 前半段
      → gzip 前半段(包裹 Writer ← 太晚了!前面已经直接写原始 Writer 了)
        → handler 写响应体
      ← gzip 后半段 ← 只拦到了 handler 的输出,前面中间件写的 Header 没拦到
    ← CORS 后半段
  ← RequestID 后半段

gzip 放在后面时,它前面的中间件(如 SecurityHeaders)已经调用 c.Header(...) 往原始的 Writer 写了响应头。gzip 后注册时包裹 Writer 为时已晚------前面的中间件已经绕过了这个包装器。这会导致压缩后响应头丢失或格式错乱。

总结:gzip 放在第二个位置(紧跟 Recovery),是因为它需要:

  • 前半段最先执行 → 最早包裹 ResponseWriter,拦截后续所有写入
  • 后半段最后执行 → 拿到完整响应体,一次性压缩

第 3 关:RequestID(给每个请求一个身份证)

service/middleware.go:27-37

go 复制代码
func RequestID() gin.HandlerFunc {
    return func(c *gin.Context) {
        id := c.GetHeader("X-Request-ID")
        if id == "" {
            id = uuid.NewString()
        }
        c.Set(string(reqIDKey), id)
        c.Header("X-Request-ID", id)
        c.Next()
    }
}
  • 如果客户端传了 X-Request-ID,就用客户端的(方便前端排查)
  • 如果没有,自动生成一个 UUID
  • 通过 HTTP 响应头返回给客户端,后续查日志时可以直接搜这个 ID

第 4 关:CORS(跨域资源共享)

service/middleware.go:61-91

go 复制代码
func CORS() gin.HandlerFunc {
    origins := "*"
    if v := config.Get("config.cors.allowedOrigins"); v != nil {
        if s, ok := v.(string); ok && s != "" {
            origins = s
        }
    }
    return func(c *gin.Context) {
        // 设置 Access-Control-Allow-Origin
        // 处理 OPTIONS 预检请求
    }
}

设计要点:

  • 支持从配置文件读取 allowedOrigins,可以是 * 或逗号分隔的域名列表
  • OPTIONS 请求(预检)直接返回 204,不走业务逻辑
  • 放在 RequestID 之后,保证 OPTIONS 的返回也带了 RequestID

第 5 关:SecurityHeaders(HTTP 安全响应头)

service/middleware.go:117-129

go 复制代码
func SecurityHeaders() gin.HandlerFunc {
    return func(c *gin.Context) {
        c.Header("X-Content-Type-Options", "nosniff")
        c.Header("X-Frame-Options", "DENY")
        c.Header("X-XSS-Protection", "1; mode=block")
        c.Header("Referrer-Policy", "no-referrer")
        c.Header("Content-Security-Policy", "default-src 'none'")
        if c.Request.TLS != nil {
            c.Header("Strict-Transport-Security", "max-age=31536000; includeSubDomains")
        }
        c.Next()
    }
}

每个头的作用:

作用 防止的攻击
X-Content-Type-Options: nosniff 禁止浏览器 MIME 嗅探 MIME 混淆攻击
X-Frame-Options: DENY 禁止页面被 iframe 嵌入 点击劫持
X-XSS-Protection: 1; mode=block 启用浏览器 XSS 过滤器 反射型 XSS
Referrer-Policy: no-referrer 不发送 Referer 头 信息泄漏
CSP: default-src 'none' 禁止加载任何外部资源 XSS 深度防御
Strict-Transport-Security 强制 HTTPS(仅 TLS 连接时) SSL 剥离攻击

注意 :HSTS 只在 c.Request.TLS != nil 时设置。如果你在 HTTP 连接上返回 HSTS,浏览器会记住并要求后续所有请求用 HTTPS------但你的 HTTP 端口可能根本不支持 TLS,这会导致无法访问。

第 6 关:RequireContentType(只接受 JSON)

service/middleware.go:145-158

go 复制代码
func RequireContentType() gin.HandlerFunc {
    return func(c *gin.Context) {
        if c.Request.Method == "POST" || c.Request.Method == "PUT" {
            if c.GetHeader("Content-Type") != "application/json" {
                c.AbortWithStatusJSON(http.StatusUnsupportedMediaType, gin.H{
                    "code": constant.ERROR_PARAM_ERR,
                    "msg":  "Content-Type must be application/json",
                })
                return
            }
        }
        c.Next()
    }
}

只对 POST/PUT 请求校验(GET 没有 Body)。校验失败返回 415 Unsupported Media Type。这个中间件避免了 JSON 解析器收到 form-data 时的糟糕错误信息。

第 7 关:TracingMiddleware(链路追踪)

OpenTelemetry 的追踪中间件,为每个请求创建 Span,并将 trace_id 注入 context。放在 Logger 和 Metrics 之前,这样日志和指标中也带 trace 信息。

第 8 关:MetricsMiddleware(Prometheus 指标)

记录 HTTP 请求的计数器(按 method、path、status 分组)和延迟直方图。放在 Tracing 之后,指标上能关联 trace 采样。

第 9 关:LoggerMiddleware(请求日志)

service/ResponseHandler.go:52-68

go 复制代码
func (s *Service) LoggerMiddleware() gin.HandlerFunc {
    return func(c *gin.Context) {
        reqID := getReqID(c.Request.Context())
        // 记录请求开始
        logWithTrace(s, ctx, "[%s] request begin: Method: %v, request url: %s", ...)
        // 脱敏后记录请求体
        body, _ := c.GetRawData()
        logWithTrace(s, ctx, "[%s] request body: %s", reqID, sanitizeBody(body))
        // 把 body 重新放回去(GetRawData 会消耗 Reader)
        c.Request.Body = io.NopCloser(bytes.NewBuffer(body))
        startTime := time.Now()
        c.Next()  // ← 执行业务逻辑
        // 记录耗时
        logWithTrace(s, ctx, "[%s] exec end: api: %v, execute time: %vms", ...)
    }
}

这里有三个"魔鬼细节":

(1)body 的"读后归还"

c.GetRawData() 读取请求体后,原始的 io.Reader 被消耗了。如果不归还,后面的 JSON 解析会失败。io.NopCloser(bytes.NewBuffer(body)) 重新构造了一个 Reader。

(2)脱敏日志

sanitizeBody()service/ResponseHandler.go:26-41

go 复制代码
var sensitiveFields = map[string]bool{
    "password": true, "token": true, "secret": true, ...
}

日志里看到的 JSON 是 {"password":"***", "name":"张三"}------敏感字段被替换为 ***,防止密码泄漏到日志系统。

(3)性能计时

c.Next() 前后各打一条日志,计算 time.Since(startTime)。这在不引入 APM 的情况下,也能快速定位慢接口。

第 10 关:MaxBodySize(请求体限制)

service/middleware.go:95-106

go 复制代码
const maxBodySize = 1 << 20 // 1MB

func MaxBodySize() gin.HandlerFunc {
    return func(c *gin.Context) {
        if c.Request.ContentLength > maxBodySize {
            c.AbortWithStatusJSON(http.StatusRequestEntityTooLarge, ...)
            return
        }
        c.Next()
    }
}

放在 Logger 之后是因为:被拦截的请求也要记日志,方便排查攻击行为。

只用 ContentLength 判断而非读完整个 body------防止攻击者发送一个 100MB 的上传请求,直接占满服务器内存。ContentLength 是 HTTP 头,读取零成本。

第 11 关:RequestTimeout(请求超时)

service/middleware.go:108-115

go 复制代码
func RequestTimeout(timeout time.Duration) gin.HandlerFunc {
    return func(c *gin.Context) {
        ctx, cancel := context.WithTimeout(c.Request.Context(), timeout)
        defer cancel()
        c.Request = c.Request.WithContext(ctx)
        c.Next()
    }
}

context.WithTimeout 注入请求上下文。30 秒后 context 自动取消,所有依赖 context 的数据库查询和外部调用都会收到 context.Canceledcontext.DeadlineExceeded

注意位置:它在 Logger 之后但在业务逻辑之前。被超时取消的请求也会被 Logger 记录(duration 显示约 30000ms)。

第 12 关:AuditLog(审计日志)

service/middleware.go:185-209

go 复制代码
func (s *Service) AuditLog() gin.HandlerFunc {
    return func(c *gin.Context) {
        c.Next() // ← 先等业务逻辑完成
        // 后记录审计信息
        s.Logger.Infof("[audit] request_id=%s user_id=%s method=%s path=%s status=%d", ...)
    }
}

注意:它的 c.Next() 放在最前面!也就是说,它不拦截请求 ,而是在请求处理完成后记录审计信息。审计日志包含了 user_idmethodpathstatus------后续可以统计"谁在什么时间访问了什么接口,结果是什么"。

审计日志放在最后,因为只有业务处理完了才知道 status(HTTP 状态码)。

第 13 关:RateLimitMiddleware(路由级限流)

service/middleware.go:131-143

go 复制代码
func RateLimitMiddleware(rl ratelimit.Limiter) gin.HandlerFunc {
    return func(c *gin.Context) {
        ip := c.ClientIP()
        if !rl.Allow(ip) {
            c.AbortWithStatusJSON(http.StatusTooManyRequests, ...)
            return
        }
        c.Next()
    }
}

注意它不是全局中间件,而是在特定路由上手动挂载 。看 service/Routes.go:37

go 复制代码
v1.POST("/auth/login", RateLimitMiddleware(s.RateLimiter), s.Login)

只在登录接口上加了限流。为什么?因为登录是暴力破解的重灾区。其他接口(如查看好友列表)不需要基于 IP 限流------它们有 JWT 身份验证,可以对 UID 限流。

第 14 关:AuthRequired(路由组级认证)

service/middleware.go:39-58 ,应用在 service/Routes.go:42

go 复制代码
auth := v1.Group("")
auth.Use(s.AuthRequired())
{
    auth.GET("/user/:uid", s.GetUser)
    // ...
}

所有 auth 组下的路由都需要 Bearer Token 验证。验证通过后:

  • user_idrole 注入 gin.Context
  • 调用 heartbeatUser(claims.UserID) 刷新在线状态

认证和限流的顺序:限流在路由上、认证在路由组上。所以请求先过限流再过认证。这是合理的------先防暴力破解,再验证身份。


四、代码实战:如何编写一个自定义中间件

我们通过三个例子掌握技巧。

模式 1:纯函数中间件(RequestID)

无依赖,只做上下文注入:

go 复制代码
func RequestID() gin.HandlerFunc {
    return func(c *gin.Context) {
        id := c.GetHeader("X-Request-ID")
        if id == "" {
            id = uuid.NewString()
        }
        c.Set(string(reqIDKey), id)
        c.Header("X-Request-ID", id)
        c.Next()
    }
}

模式 2:闭包注入中间件(LoggerMiddleware)

需要访问外部依赖(如 Service 的 Logger):

go 复制代码
func (s *Service) LoggerMiddleware() gin.HandlerFunc {
    return func(c *gin.Context) {
        // 闭包引用了 s,可以访问 s.Logger
        s.Logger.Infof("...")
        c.Next()
    }
}

模式 3:参数化中间件(RateLimitMiddleware)

通过参数注入依赖:

go 复制代码
func RateLimitMiddleware(rl ratelimit.Limiter) gin.HandlerFunc {
    return func(c *gin.Context) {
        if !rl.Allow(c.ClientIP()) {
            // ...
        }
        c.Next()
    }
}

模式 4:请求后处理中间件(AuditLog)

先放行,后处理:

go 复制代码
func (s *Service) AuditLog() gin.HandlerFunc {
    return func(c *gin.Context) {
        c.Next()  // 先执行业务逻辑
        // 业务完成后记录审计信息
        s.Logger.Infof("[audit] status=%d", c.Writer.Status())
    }
}

4.5 中间件中的常见陷阱与避坑指南

写了这么多中间件,踩过的坑远比成功的经验多。以下是三个最容易出错的场景。

陷阱 1:在错误处理的中间件之后设置 Content-Type

假设你把一个返回 JSON 的错误中间件放在最外层,但它之前没有设置 Content-Type: application/json。当它调用 c.AbortWithStatusJSON() 时,Gin 会设置 Content-Type。但如果这个中间件前面的中间件已经写了响应体的一部分(比如 SecurityHeaders 写了响应头),Content-Type 可能已经被设置成 text/plain,JSON 响应返回时前端解析失败。

所以 SecurityHeadersservice/middleware.go:117-129只设置安全相关的响应头 ,从不设置 Content-Type。Content-Type 交给 Gin 的 c.JSON() 自动设置,放在业务处理的最后一步。

陷阱 2:c.Next() 后修改响应状态码

AuditLog 中间件(service/middleware.go:185-209),它在 c.Next() 之后读取 c.Writer.Status()。但如果有人在 c.Next() 返回后调用 c.Status(http.StatusOK),之前设置的 404 就被覆盖了。Gin 的 c.WriterResponseWriter 的包装,WriteHeader 只能调一次。重复调用不会报错但也不会生效------这容易产生隐蔽的 bug。

所以规则是:只在 c.Next() 之前设置状态码,之后只读取状态码,永远不要修改。

陷阱 3:中间件顺序导致的性能雪崩

假设你把 MaxBodySize(10MB 限制)放在 Logger 之前。Logger 的 c.GetRawData() 会把整个 Body 读到内存里------如果一个攻击者发送 9.9MB 的 JSON 请求,每个请求都完整读进内存再打日志,内存瞬间耗尽。

正确的顺序应该是:MaxBodySizeLogger 之后吗?不,看看 service/Routes.go:25-26

go 复制代码
r.Use(s.LoggerMiddleware())   // 9
r.Use(MaxBodySize())          // 10

MaxBodySize 确实在 Logger 之后。但它只检查 ContentLength(HTTP 头的值,不读 Body),所以不会造成内存问题。但如果 Logger 先读了 Body(c.GetRawData()),Body 已被消耗,后续中间件和业务处理函数就拿不到请求体了。

这就引出了 Logger 中间件的"读后归还"设计(service/ResponseHandler.go:61):

go 复制代码
c.Request.Body = io.NopCloser(bytes.NewBuffer(body))

这个操作本身有内存开销------10MB 的请求体会在内存里多存一份拷贝。如果先检查 ContentLength > maxBodySize 再决定是否读取 Body,就能避免这个问题。但 user-service 的 maxBodySize 是 1MB(service/middleware.go:93),所以影响可控。

陷阱 4:并发安全与 gin.Context

gin.Context 的设计是每个请求一个实例 ,所以中间件里直接读写 c.Set() / c.Get() 不需要加锁。但如果你的中间件把 Context 传给另一个 goroutine(比如异步日志),就需要小心:

  • 请求结束后 Gin 会回收 Context,异步 goroutine 读它可能拿到脏数据
  • 正确做法是在中间件里提取需要的数据(如 userID),拷贝一份再传给 goroutine

user-service 的所有中间件都是同步的,没有异步 goroutine 操作 Context,避免了这个问题。

4.6 TracingMiddleware 和 MetricsMiddleware 的实现细节

两个中间件在 service/Routes.go:22-23 中紧挨着放置,它们的工作机制值得了解。

TracingMiddleware 在每个请求开始时从请求头提取或创建 trace context,注入到 c.Request.Context()。OpenTelemetry 的 Gin 中间件会自动:

  1. 从上游请求的 traceparent 头提取 trace ID
  2. 创建新的 Span(如果上游没有的话)
  3. 将 Span 注入 context,后续的数据库查询、Redis 命令都通过 context 传递 trace 信息

MetricsMiddleware 记录 HTTP 级别的指标------请求计数(按 method、path、status 分组)和请求延迟(histogram)。它利用了 c.Next() 的前后模式:

  • c.Next() 之前:记录 startTime
  • c.Next() 之后:计算 duration = time.Since(startTime),递增对应 label 的计数器

这两个中间件的顺序是 Tracing 在前、Metrics 在后。为什么?因为 Metrics 本身也可能被 Tracing 采样(如果你想知道"记录 Metrics 本身花了多长时间")。反过来放置虽然也能工作,但 Metrics 的 Span 会出现在 Tracing 的瀑布图里,不太有意义。


五、总结

14 个中间件,像是请求在进入服务核心之前的 14 道安检。它们的顺序是精心编排的:

复制代码
Recovery(兜底)
  → Gzip(压缩)
    → RequestID(身份证)
      → CORS + SecurityHeaders(安全边界)
        → ContentType(输入校验)
          → Tracing + Metrics(可观测性)
            → Logger(日志)
              → MaxBodySize(资源保护)
                → Timeout(超时控制)
                  → [路由层] RateLimit + Auth(业务安全)
                    → AuditLog(审计)

这条流水线的设计哲学是:安全在前,可观测性在中,审计在后。被拒绝的请求也要被记录,被记录的请求一定能被追踪。

写中间件不难,难的是设计它们之间的顺序和协作。正如一条机场的安检线,单拆出来每道工序都很简单,但把它们串成一条线,让 1000 万人每天高效通过而不发生事故------这才是工程。


完整代码

本文所有示例代码来自开源项目 user-service,一个基于 Go + Gin + GORM 构建的企业级用户管理与社交关系 REST API 微服务。

项目地址:https://github.com/binbin3828/user

本系列 14 篇完整目录:

① 从面条代码到三层架构 ② API 安全洋葱模型 ③ 配置管理与密钥保护 ④ 单元测试 ⑤ 可观测性

⑥ 部署进化 ⑦ 好友请求状态机 ⑧ Redis 实战 ⑨ 中间件链 ⑩ Geohash

⑪ API 响应设计 ⑫ 优雅关闭 ⑬ GORM 避坑 ⑭ Makefile