Files
cs-note/hzh/GIN/3-middleware.md
T
2026-05-24 11:42:38 +08:00

398 lines
11 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
---
tags: [后端, Go, Gin, 中间件, 架构]
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`:
```go
type HandlerFunc func(*Context)
```
它接收一个 `*gin.Context`,执行三个阶段:
1. **前置处理** — 读取请求、校验、记录日志等
2. **调用 `c.Next()`** — 把控制权交给下一个 handler
3. **后置处理** — 修改响应、收集指标等
```go
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 中间件可以在三个层级注册,形成**作用域嵌套**:
```mermaid
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
```go
r := gin.Default()
// 全局中间件 — 所有路由都经过
r.Use(globalMiddleware())
// 分组中间件 — 仅该分组下的路由经过
v1 := r.Group("/api/v1", v1Middleware())
{
// 路由中间件 — 仅此路由经过
v1.GET("/users", routeMiddleware(), listUsers)
v1.GET("/posts", listPosts) // 不经过 routeMiddleware
}
```
**执行链路示意(先入后出):**
```mermaid
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
```
```go
// 验证执行顺序
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` 切片:
```go
// 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` 的来源是 **全局 + 分组 + 路由** 中间件的拼接:
```mermaid
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
```
注册顺序就是执行顺序:
```go
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` 预检请求,否则浏览器会拦截。
```go
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")` 获取。
```go
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()` 前后分别记录请求开始和响应状态,便于排查问题。
```go
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),便于在分布式日志中追踪请求链路。如果客户端已传入则复用。
```go
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**。
**错误写法:**
```go
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()` 创建独立副本**
```go
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. 跳过中间件
有时不想让特定路由经过某些中间件,有两种方式:
**方式一:将中间件注册到子分组而非全局**
```go
r := gin.Default()
// 只挂载到 /api 分组
api := r.Group("/api", rateLimitMiddleware())
{
api.GET("/public", publicHandler) // 经过 rateLimit
api.GET("/private", privateHandler) // 经过 rateLimit
}
r.GET("/health", healthHandler) // 不经过 rateLimit
```
**方式二:中间件内部判断跳过**
```go
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]] — 认证/授权中间件实战