Go HTTP 中间件
Go HTTP 中间件以 func(http.Handler) http.Handler 组合请求处理链,把日志、认证、CORS、限流、panic 恢复等横切逻辑从业务 Handler 中剥离。
[!info] 关联笔记
Go HTTP 中间件
这个概念为什么会出现
一个 HTTP Handler 若同时负责:
- 解析 JWT 并拒绝未登录请求
- 打访问日志与耗时
- 写 CORS 头、处理 OPTIONS
recoverpanic- 真正的业务读写
则会出现:复制粘贴爆炸、测试困难、顺序无法统一调整、安全策略与业务代码缠在一起。
中间件解决的是横切关注点(cross-cutting concerns):在「请求进入业务」之前和「响应离开」之后,用同一套可组合的包装器插入逻辑。Go 标准库没有名为 Middleware 的类型,但社区与生产代码几乎统一收敛到:
func(http.Handler) http.Handler
这与 go-nethttp 的 Handler 接口天然契合,也是理解 Gin/chi/Echo 中间件的底层模型。
[!abstract] 一句话理解 中间件是「吃掉一个 Handler、吐出新 Handler」的装饰器:可在调用
next前后插入逻辑,也可不调用next以短路请求;多个中间件反向嵌套形成洋葱模型。
最小可运行示例
先把示例放进业务场景,再看代码:
场景:给 /hello 统一打访问耗时日志
业务 handler 只想写 "ok",不想在每个接口里复制:
- 记开始时间
- 调真正业务
- 打
METHOD path duration
中间件就是装饰器:吃掉一个 http.Handler,吐出“外面多包一层”的新 Handler。
多个中间件用 chain 从后往前包,切片里写在前面的成为更外层(洋葱模型)。
本例为保持可运行会
ListenAndServe;本地可curl localhost:8080/hello观察日志。
若只想看组合关系,把main里 listen 换成对handler.ServeHTTP的单测调用即可。
package main
import (
"log"
"net/http"
"time"
)
// Middleware:社区统一形状——包装 Handler。
type Middleware func(http.Handler) http.Handler
// withAccessLog 模拟“访问日志”横切逻辑。
// 业务意图:不改 hello 本身,也能量到每个请求的耗时。
func withAccessLog(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
start := time.Now()
// 先交给内层(业务或下一个中间件)
next.ServeHTTP(w, r)
// 返回路径上再打日志(洋葱的“后置”半圈)
log.Printf("%s %s %s", r.Method, r.URL.Path, time.Since(start))
})
}
// hello 纯业务:与日志、鉴权无关。
func hello(w http.ResponseWriter, r *http.Request) {
_, _ = w.Write([]byte("ok"))
}
// chain:按“外→内”书写顺序组装。
// 实现上从后往前包,使 ms[0] 成为最外层。
func chain(h http.Handler, ms ...Middleware) http.Handler {
for i := len(ms) - 1; i >= 0; i-- {
h = ms[i](h)
}
return h
}
func main() {
mux := http.NewServeMux()
// Go 1.22+ 方法+路径形态;旧版本可改成 mux.HandleFunc("/hello", hello)
mux.HandleFunc("GET /hello", hello)
// 书写:logging 在前 → 它是最外层
handler := chain(mux, withAccessLog)
log.Fatal(http.ListenAndServe(":8080", handler))
}
建议运行:
go run .
# 另开终端:
curl -s localhost:8080/hello
期望:HTTP 响应 ok;服务端日志类似 GET /hello 123.4µs。
结合场景再看三个关注点
-
形状固定:
func(http.Handler) http.Handler
与路由库无关,Gin/chi 只是同一思想的语法糖。 -
返回值常用
http.HandlerFunc
把闭包适配成Handler接口。 -
chain从后往前包
切片书写顺序 = 从外到内的请求经过顺序。
核心概念与准确模型
Handler 与 HandlerFunc
type Handler interface {
ServeHTTP(ResponseWriter, *Request)
}
type HandlerFunc func(ResponseWriter, *Request)
func (f HandlerFunc) ServeHTTP(w ResponseWriter, r *Request) {
f(w, r)
}
中间件不依赖具体路由库,只依赖这两个抽象。任何实现了 ServeHTTP 的类型都可被包装。
洋葱模型(顺序)
请求 → Recovery → Auth → Logging → mux/handler
响应 ← Recovery ← Auth ← Logging ← mux/handler
对应代码:
// 书写:外层在最外
handler := recovery(auth(logging(mux)))
// 或 chain(mux, logging, auth, recovery)
// 若 chain 反向遍历,则 logging 最内、recovery 最外
| 阶段 | 谁先跑 |
|---|---|
| 请求进入(前置) | 最外层中间件 |
| 到达业务 | 最内层 / mux |
| 响应返回(后置) | 先内后外 |
顺序是行为的一部分:例如 Recovery 通常应在最外,才能接住内层 panic;Auth 失败应在写业务日志的「成功路径」之前短路,具体策略按产品要求调整。
短路(不调用 next)
func requireAPIKey(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.Header.Get("X-API-Key") == "" {
http.Error(w, "missing api key", http.StatusUnauthorized)
return // 不调用 next
}
next.ServeHTTP(w, r)
})
}
短路用于:认证失败、限流拒绝、CORS 预检直接 204、维护模式等。忘记 return 会导致「已写错误响应仍进入业务」。
通过 Context 向下传值
中间件解析出的主体、request id、trace 等,适合放入 context(克制使用):
type ctxKey int
const keyUser ctxKey = 1
func withUser(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
u, err := userFromRequest(r)
if err != nil {
http.Error(w, "unauthorized", http.StatusUnauthorized)
return
}
ctx := context.WithValue(r.Context(), keyUser, u)
next.ServeHTTP(w, r.WithContext(ctx))
})
}
func handler(w http.ResponseWriter, r *http.Request) {
u, _ := r.Context().Value(keyUser).(*User)
// ...
}
约定:
- key 使用未导出自定义类型,避免包间碰撞。
- 只放横切元数据;领域依赖优先构造注入,而不是塞进 context。
- 必须用
r.WithContext,否则下游仍看到旧 ctx。
详见 go-context。
包装 ResponseWriter
若后置逻辑需要状态码或字节数,标准 ResponseWriter 在 WriteHeader 后不会自动暴露状态,常见做法是嵌入包装类型:
type statusWriter struct {
http.ResponseWriter
code int
}
func (w *statusWriter) WriteHeader(code int) {
w.code = code
w.ResponseWriter.WriteHeader(code)
}
注意:实现 Flush、Hijack、Push 等可选接口时,需按需转发,否则 SSE、WebSocket 可能静默失败。见 go-sse-proxy-pattern。
路由级 vs 全局
| 挂载方式 | 适用 |
|---|---|
ListenAndServe(addr, chain(mux, ...)) | 全局:日志、Recovery、请求 ID |
| 包一层子 mux / 分组 | 仅 /api 要 Auth |
单个 Handle 再包 | 极少数路由特例 |
go-routing-patterns 中的分组(如 chi Route + Use)本质仍是「子树根 Handler 外包中间件」。
常见中间件清单
| 类型 | 职责要点 |
|---|---|
| Recovery | defer recover,记录栈,返回 500,避免进程被单请求打挂 |
| Logging / Access log | 方法、路径、状态、耗时、request id |
| Auth / JWT | 校验凭证,注入主体,失败短路 |
| CORS | 预检与响应头;生产勿默认 * + 带 cookie |
| Request ID | 生成或透传 X-Request-ID |
| Timeout | 基于 ctx 截止时间;注意与 http.TimeoutHandler 差异 |
| Rate limit | 按 IP/用户限流,见 go-rate-limiting |
| Compress | 注意与流式响应、SSE 的冲突 |
与框架中间件的关系
| 生态 | 形态 |
|---|---|
| 标准库 / chi | 接近 func(http.Handler) http.Handler |
| Gin | gin.HandlerFunc,链式 c.Next() |
| Echo | echo.MiddlewareFunc |
框架中间件不与标准库签名通用。选型框架即选型其中间件生态;理解标准库模型后,迁移时只需改「如何取 Writer/Request/Next」。
设计动机
- 关注点分离:业务 Handler 只做协议 ↔ 用例编排入口。
- 可组合顺序:安全、可观测、协议策略可独立演进。
- 可测试:对假
next断言是否调用、是否改写 header/ctx。 - 与 net/http 同构:不引入新运行时概念,只是函数组合。
边界情况与反直觉行为
- 写了响应又调用 next
可能导致重复WriteHeader或混乱 body。短路必须return。 - 在中间件里启动 goroutine 却使用请求 ctx 却不管理生命周期
请求结束后 ctx 取消,后台任务被误杀或泄漏,需明确策略。 - 包装 Writer 丢掉 Flusher
SSE/分块传输无法Flush。 - 全局可变状态当「请求用户」
竞态;应用 ctx 或显式参数。 - 中间件顺序反了
未 Recovery 的 panic、未 Auth 就记敏感业务日志等。 - 对
http.Server的 Handler 与 mux 内部 Handler 混用多层重复包装
日志打两遍、Auth 跑两遍;约定「全局一层 + 路由组一层」。
常见误区
[!warning] 常见误区:把中间件当成「切面魔法」 错误:期望不调用也能自动织入任意函数。
正确:它只是 Handler 装饰器;业务函数若不是 Handler,需在边界适配。
[!warning] 常见误区:Auth 只藏在中间件,Handler 假定用户一定存在 错误:直接
r.Context().Value(key).(*User)无检查。
正确:类型断言失败当 401/500;单测覆盖「无用户 ctx」路径。
[!warning] 常见误区:CORS 中间件复制粘贴
Allow-Origin: *上生产 错误:与凭证 cookie、敏感 API 组合不当。
正确:按环境配置白名单,预检与实际请求头一致。
[!warning] 常见误区:Recovery 只
recover不打栈 / 不统一错误体 错误:吞 panic 或把内部错误细节返回公网。
正确:结构化日志 + 对外通用 500;链路用 request id 关联。
工程实践
- 定义统一类型
type Middleware func(http.Handler) http.Handler与Chain。 - Recovery 最外,Logging 记录真实状态码(包装 Writer)。
- ctx 传递 request id,日志字段一致。
- 认证中间件与授权中间件分离:认证解决「你是谁」,授权解决「能不能」。
- 单测:
httptest.NewRecorder+ 可断言的next(例如记录是否调用)。 - 流式接口:跳过 gzip、谨慎包装 Writer,保留
http.Flusher。 - 超时:优先让下游尊重
r.Context();全局再配合ReadHeaderTimeout等 server 超时(go-nethttp)。 - 分层:中间件不写 SQL;业务规则进 Service,见 go-http-handler-service-repository。
测试草图
func TestRequireAPIKey_Unauthorized(t *testing.T) {
called := false
next := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
called = true
})
req := httptest.NewRequest(http.MethodGet, "/", nil)
rr := httptest.NewRecorder()
requireAPIKey(next).ServeHTTP(rr, req)
if called || rr.Code != http.StatusUnauthorized {
t.Fatalf("code=%d called=%v", rr.Code, called)
}
}
可验证实验
实验 1:洋葱顺序
写三个中间件,在 next 前后各打印标签,观察请求进入与返回的字符串顺序。
实验 2:短路
鉴权中间件在缺 token 时不调用 next;用计数器确认业务 Handler 未执行,且状态码为 401。
实验 3:Panic 恢复
内层 panic("boom"),最外层 Recovery 应返回 500 且进程不退出。
实验 4:Context 传值
Auth 注入 user,业务 Handler 读出;缺少 key 时行为明确(401/500),避免裸断言崩溃。
实验 5:状态码包装
包装 ResponseWriter 记录最终状态码,使访问日志在 404/500 时字段正确。
本节总结
- 本质:
func(http.Handler) http.Handler的洋葱组合。 - 能力:前置/后置逻辑、短路、ctx 传值、包装 Writer。
- 顺序:外层先置后后置;Recovery 通常最外。
- 纪律:key 未导出、不重复写响应、流式保留 Flusher、可单测。
- 下一步:go-routing-patterns 分组挂载;go-auth-and-jwt 落地认证。
自测题
概念题
- 为什么
Chain常常从后往前应用中间件切片? - 中间件不调用
next.ServeHTTP的合法场景有哪些? context.WithValue的 key 为何推荐未导出类型?
代码推理题
h := recovery(auth(logging(mux)))
请求进入时,logging 与 auth 谁的前置逻辑先执行?若 auth 短路,logging 的后置耗时日志还会跑吗?
工程思考题
SSE 接口与 JSON API 共用全局 gzip 中间件,可能出现什么问题?如何分层挂载?
参考答案
展开
- 使切片中靠前的中间件成为外层,符合「从左到右阅读执行顺序」的直觉。
- 认证失败、限流、CORS 预检、维护模式等有意终止链路。
- 避免不同包用同名字符串 key 互相覆盖或误读。
代码:recovery(auth(logging(mux)))进入顺序为 recovery → auth → logging → mux。auth在logging外侧:短路时 整段 logging(含后置耗时)都不会执行。
工程:gzip 与 SSE 冲突(缓冲/编码);SSE 路由组跳过压缩或单独 mux。
延伸阅读与资料来源
| 资料 | 类型 | 支撑 |
|---|---|---|
| Package net/http | 标准库 | Handler、ServeMux、Server |
| Package net/http/httptest | 标准库 | 中间件单测 |
| Package context | 标准库 | WithValue / 取消 |
| Go Blog — context | 博客 | 请求范围值与取消 |
| Code Review Comments — Contexts | Wiki | ctx 惯例 |
笔记元信息
- 建议文件名:
go-http-middleware.md - 所属阶段:阶段七(Web 后端)
- 本篇状态:已深化
- 建议下一篇:Go 路由模式