11 KiB
tags, create time
| tags | create time | |||||
|---|---|---|---|---|---|---|
|
2026-04-27 12:51 |
中间件完整机制
概述
本文档系统梳理 Gin 中间件的完整机制:从类型签名、三级作用域嵌套、执行顺序,到常见实战场景(CORS、JWT 认证、结构化日志等),以及最容易踩坑的 c.Copy() 异步 Goroutine 问题。
思考题:Gin 的中间件和 Go 标准库 net/http 的 func(http.Handler) http.Handler 模式有什么本质区别?Gin 的方案更简单还是更灵活?详见 GIN/3-middleware/gin-vs-std。
正文
1. 中间件的本质
中间件的类型签名就是 gin.HandlerFunc:
type HandlerFunc func(*Context)
它接收一个 *gin.Context,执行三个阶段:
- 前置处理 — 读取请求、校验、记录日志等
- 调用
c.Next()— 把控制权交给下一个 handler - 后置处理 — 修改响应、收集指标等
func logger() gin.HandlerFunc {
return func(c *gin.Context) {
start := time.Now() // 前置:记录开始时间
c.Next() // ← 必须调用,把控制权交给下一个
// 后置:请求结束后的处理
latency := time.Since(start)
log.Printf("[%d] %s %s — %v", c.Writer.Status(), c.Request.Method, c.Request.URL.Path, latency)
}
}
2. 三级作用域
Gin 中间件可以在三个层级注册,形成作用域嵌套:
graph TD
Engine["Engine 级<br/>全局中间件"] --> v1["RouterGroup /api/v1<br/>分组中间件"]
Engine --> v2["RouterGroup /api/v2<br/>分组中间件"]
v1 --> u1["GET /users<br/>路由中间件"]
v1 --> p1["GET /posts<br/>路由中间件"]
v2 --> u2["GET /users<br/>路由中间件"]
执行顺序: 全局 → 分组 → 路由 → handler
r := gin.Default()
// 全局中间件 — 所有路由都经过
r.Use(globalMiddleware())
// 分组中间件 — 仅该分组下的路由经过
v1 := r.Group("/api/v1", v1Middleware())
{
// 路由中间件 — 仅此路由经过
v1.GET("/users", routeMiddleware(), listUsers)
v1.GET("/posts", listPosts) // 不经过 routeMiddleware
}
执行链路示意(先入后出):
flowchart LR
subgraph 前置处理["◀ 前置处理(按注册顺序)"]
A1["全局中间件"]
A2["v1 分组中间件"]
A3["路由中间件"]
end
subgraph 后置处理["▶ 后置处理(按逆序)"]
B3["路由中间件"]
B2["v1 分组中间件"]
B1["全局中间件"]
end
A1 --> A2 --> A3 --> H["handler: listUsers"] --> B3 --> B2 --> B1
style A1 fill:#90EE90
style A2 fill:#90EE90
style A3 fill:#90EE90
style B1 fill:#FFB6C1
style B2 fill:#FFB6C1
style B3 fill:#FFB6C1
style H fill:#FFD700
// 验证执行顺序
r := gin.Default()
r.Use(func(c *gin.Context) {
fmt.Println("1-before")
c.Next()
fmt.Println("1-after")
})
r.Use(func(c *gin.Context) {
fmt.Println("2-before")
c.Next()
fmt.Println("2-after")
})
r.GET("/test", func(c *gin.Context) {
fmt.Println("handler")
})
// 输出:
// 1-before
// 2-before
// handler
// 2-after
// 1-after
思考题:如果中间件 A 中调用了 c.Abort()(不调用 c.Next()),中间件 B 和 handler 还会执行吗?那 A 中 c.Abort() 之后的代码还会执行吗?详见 middleware-abort。
3. 中间件链的构成
当你调用 c.Next() 时,内部执行的是 c.handlers 切片:
// gin/context.go
func (c *Context) Next() {
c.index++
for ; c.index < int8(len(c.handlers)); c.index++ {
c.handlers[c.index](c)
}
}
c.handlers 的来源是 全局 + 分组 + 路由 中间件的拼接:
flowchart LR
subgraph handlers["c.handlers 拼接结果"]
H1["Logger"]
H2["Recovery"]
H3["V1Middleware"]
H4["RouteMiddleware"]
H5["listUsers"]
end
subgraph sources["来源层级"]
S1["全局<br/>Logger, Recovery"]
S2["v1 分组<br/>V1Middleware"]
S3["路由<br/>RouteMiddleware"]
S4["handler<br/>listUsers"]
end
S1 --> H1
S1 --> H2
S2 --> H3
S3 --> H4
S4 --> H5
style handlers fill:#F0F0F0
style H5 fill:#FFD700
注册顺序就是执行顺序:
r := gin.Default() // Logger, Recovery
v1 := r.Group("/api/v1", authMiddleware()) // Logger, Recovery, authMiddleware
{
v1.GET("/users", corsMiddleware(), listUsers) // Logger, Recovery, authMiddleware, corsMiddleware, listUsers
}
4. 常用中间件实现
CORS 中间件
允许跨域请求。关键点:必须单独处理 OPTIONS 预检请求,否则浏览器会拦截。
func cors() gin.HandlerFunc {
return func(c *gin.Context) {
origin := c.Request.Header.Get("Origin")
if origin != "" {
c.Header("Access-Control-Allow-Origin", origin)
c.Header("Access-Control-Allow-Methods", "GET,POST,PUT,DELETE,PATCH,OPTIONS")
c.Header("Access-Control-Allow-Headers", "Origin,Content-Type,Authorization,X-Token")
c.Header("Access-Control-Max-Age", "86400")
c.Header("Access-Control-Allow-Credentials", "true")
}
// 处理 OPTIONS 预检请求
if c.Request.Method == "OPTIONS" {
c.AbortWithStatus(http.StatusNoContent)
return
}
c.Next()
}
}
认证中间件(JWT)
从请求头提取 JWT token,校验成功后将用户信息存入 Context,后续 handler 可通过 c.GetString("userID") 获取。
func jwtAuth() gin.HandlerFunc {
return func(c *gin.Context) {
token := c.GetHeader("Authorization")
if token == "" {
c.JSON(http.StatusUnauthorized, gin.H{"code": 401, "message": "missing token"})
c.Abort()
return
}
claims, err := parseJWT(token)
if err != nil {
c.JSON(http.StatusUnauthorized, gin.H{"code": 401, "message": "invalid token"})
c.Abort()
return
}
// 把用户信息存入 Context,后续 handler 可直接获取
c.Set("userID", claims.UserID)
c.Set("role", claims.Role)
c.Next()
}
}
请求日志(结构化)
使用 zap/logrus 等结构化日志库,在 c.Next() 前后分别记录请求开始和响应状态,便于排查问题。
func structuredLogger() gin.HandlerFunc {
return func(c *gin.Context) {
start := time.Now()
path := c.Request.URL.Path
query := c.Request.URL.RawQuery
c.Next()
latency := time.Since(start)
status := c.Writer.Status()
log.WithFields(log.Fields{
"method": c.Request.Method,
"path": path,
"query": query,
"status": status,
"latency_ms": latency.Milliseconds(),
"client_ip": c.ClientIP(),
}).Info("request")
}
}
请求 ID 中间件
为每个请求生成唯一的追踪 ID(RequestID),便于在分布式日志中追踪请求链路。如果客户端已传入则复用。
const requestIDKey = "X-Request-ID"
func requestID() gin.HandlerFunc {
return func(c *gin.Context) {
id := c.GetHeader(requestIDKey)
if id == "" {
id = uuid.New().String()
}
c.Set(requestIDKey, id)
c.Header(requestIDKey, id)
c.Next()
}
}
提问: JWT 中间件中认证失败时,写入 Context 的
userID会不会被后续 handler 读到?为什么?详见 GIN/3-middleware/jwt-auth-qa。
5. 中间件中启动 Goroutine 的陷阱
这是 Gin 中间件最常见的坑:在中间件中启动 goroutine 后,goroutine 可能引用已释放的 Context。
错误写法:
func asyncProcessor() gin.HandlerFunc {
return func(c *gin.Context) {
c.Next()
// 危险!c.Next() 返回后,Context 可能已经被回收
// 但 goroutine 还在运行,可能会 panic
go func() {
log.Printf("处理完成: %s", c.Request.URL.Path) // ← c 可能已被 pool 回收
}()
}
}
正确写法:用 c.Copy() 创建独立副本
func safeAsyncProcessor() gin.HandlerFunc {
return func(c *gin.Context) {
c.Next()
// c.Copy() 创建请求副本,包含独立的 Request 和 Context
// 注意:copy 中的 Writer 是无效的,只能读 Request 和 Context keys
copy := c.Copy()
go func() {
// 安全的异步处理,只读取数据,不能写入响应
log.Printf("异步处理完成: %s", copy.Request.URL.Path)
userID := copy.GetString("userID")
log.Printf("用户 %s 的请求已异步处理", userID)
}()
}
}
c.Copy() 的限制:
| 可以复制的 | 不可以复制的 |
|---|---|
c.Request(原始请求) |
c.Writer(ResponseWriter 无法复制) |
c.Keys(已设置的键值对) |
c.Errors(错误链不能写) |
c.Params(路径参数) |
不能调用 c.JSON() 等写响应的方法 |
c.ClientIP() |
不能修改请求体 |
核心规则:
c.Copy()出的 goroutine 只能读不能写。如果需要在异步中写数据,用数据库/队列等持久化方式,不要依赖 Context。
6. 跳过中间件
有时不想让特定路由经过某些中间件,有两种方式:
方式一:将中间件注册到子分组而非全局
r := gin.Default()
// 只挂载到 /api 分组
api := r.Group("/api", rateLimitMiddleware())
{
api.GET("/public", publicHandler) // 经过 rateLimit
api.GET("/private", privateHandler) // 经过 rateLimit
}
r.GET("/health", healthHandler) // 不经过 rateLimit
方式二:中间件内部判断跳过
func logging() gin.HandlerFunc {
return func(c *gin.Context) {
// 跳过健康检查端点
if c.Request.URL.Path == "/health" {
c.Next()
return
}
// 正常日志逻辑
start := time.Now()
c.Next()
log.Printf("[%d] %s", c.Writer.Status(), time.Since(start))
}
}
7. 中间件的常见应用场景
| 场景 | 方案 |
|---|---|
| 跨域处理 | CORS 中间件 |
| 身份认证 | JWT / Session 中间件 |
| 权限控制 | RBAC 中间件 |
| 请求限流 | Token Bucket / 滑动窗口中间件 |
| 请求日志 | 结构化日志中间件 |
| 请求追踪 | RequestID 中间件 |
| 异常恢复 | gin.Recovery() |
| 缓存 | 响应缓存中间件 |
| 数据预处理 | 数据注入中间件(如把数据库对象注入 Context) |
思考题:如果需要在多个分组之间共享中间件(比如 api/v1 和 api/v2 都需要 CORS),是把 CORS 注册到全局好,还是注册到各自分组好?为什么?详见 cors-registration-scope。
关联笔记
- GIN/3-middleware/jwt-auth-qa — JWT 中间件认证失败后 Context 数据安全性
- cors-registration-scope — 跨分组共享中间件的作用域选择
- middleware-abort —
c.Abort()终止机制与常见陷阱 - GIN/gin-architecture — 中间件链的底层执行机制
- GIN/context-lifecycle —
c.Copy()的深拷贝原理 - GIN/session-auth — 认证/授权中间件实战