读完这篇,你能从零写出一套可复用的 Go HTTP 中间件链,并把日志、鉴权、panic 恢复、超时按正确顺序接入请求链路。
Go 标准库没有"中间件"这个类型,但 net/http 的 Handler 接口本身就是为它设计的:一个中间件就是"接收一个 Handler、返回一个 Handler"的函数。理解这一句,后面的东西都是它的推论。
第一步:认清中间件的签名
type Middleware func(http.Handler) http.Handler
一个最小实现:
func Hello(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
// 请求进入时做的事
next.ServeHTTP(w, r)
// 响应返回后做的事
})
}
http.HandlerFunc 是适配器,把普通函数转成 Handler,所以不用自己定义结构体。
第二步:写第一个中间件——请求日志
func Logging(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 %v", r.Method, r.URL.Path, r.RemoteAddr, time.Since(start))
})
}
这里有个先天缺陷:拿不到状态码。要拿到它,就得包装 ResponseWriter:
type statusWriter struct {
http.ResponseWriter
status int
}
func (w *statusWriter) WriteHeader(code int) {
w.status = code
w.ResponseWriter.WriteHeader(code)
}
func (w *statusWriter) Write(b []byte) (int, error) {
if w.status == 0 {
w.status = http.StatusOK
}
return w.ResponseWriter.Write(b)
}
用法是 sw := &statusWriter{ResponseWriter: w, status: 200},再把 sw 传下去。
注意:包装器会丢掉原 ResponseWriter 上的可选接口,比如 http.Flusher、http.Hijacker(SSE 和 WebSocket 升级都依赖它们)。需要时在包装器上补一个 Flush() 方法转发过去,否则流式响应会卡住。
第三步:把中间件串成链
func Chain(h http.Handler, mws ...Middleware) http.Handler {
for i := len(mws) - 1; i >= 0; i-- {
h = mws[i](h)
}
return h
}
mux := http.NewServeMux()
mux.HandleFunc("/api/ping", ping)
handler := Chain(mux, Recover, Logging, Auth)
http.ListenAndServe(":8080", handler)
必须倒序套。mws[0] 是最外层,请求最先进入它。
链上执行顺序是"洋葱模型":
- 进入方向:Recover → Logging → Auth → 业务
- 返回方向:业务 → Auth → Logging → Recover
注意:两个顺序上的坑。一是 Recover 必须放最外层,否则它内层中间件里的 panic 它接不住,整个进程会挂。二是 Auth 放在 Logging 之后,未登录的 401 请求也能记进日志;如果你想过滤掉这些噪音,就把 Auth 挪到 Logging 前面。
第四步:用 context 在链路里传数据
中间件之间想共享数据(比如 Auth 解析出的用户 ID),走 context:
type ctxKey string
const userIDKey ctxKey = "userID"
func Auth(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
token := r.Header.Get("Authorization")
uid, err := parseToken(token)
if err != nil {
http.Error(w, "unauthorized", http.StatusUnauthorized)
return // 关键:不再调用 next
}
ctx := context.WithValue(r.Context(), userIDKey, uid)
next.ServeHTTP(w, r.WithContext(ctx))
})
}
业务里取值:
uid, ok := r.Context().Value(userIDKey).(int64)
注意:key 一定要用自定义类型(如 type ctxKey string),别直接拿字符串当 key,否则第三方包可能撞名覆盖。另外 context.Value 存的是 any,断言必须带 ok 判断,直接强转会 panic。
再强调一句:拦截请求时 return 前千万别调 next,不然鉴权形同虚设——这是新手最常见的漏洞写法。
第五步:加上 panic 恢复和超时
func Recover(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
defer func() {
if err := recover(); err != nil {
log.Printf("panic: %v\n%s", err, debug.Stack())
http.Error(w, "internal error", http.StatusInternalServerError)
}
}()
next.ServeHTTP(w, r)
})
}
func Timeout(d time.Duration) Middleware {
return func(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
ctx, cancel := context.WithTimeout(r.Context(), d)
defer cancel()
next.ServeHTTP(w, r.WithContext(ctx))
})
}
}
Timeout 只是给 context 加了截止时间,它不会真的中断正在执行的 handler。业务代码里得自己检查 ctx.Done() 或者把 ctx 传给数据库、下游 HTTP 调用,超时才真正生效。
第六步:几个容易翻车的细节
- 读取了
r.Body(比如签名校验中间件)后必须还原:r.Body = io.NopCloser(bytes.NewReader(buf)),否则后面的 handler 读到空 body。
WriteHeader 只能调用一次,重复调用会被忽略并打警告。业务里先写 body 再写 header 也是无效的。
- 中间件里不要直接
http.Error 之后再调 next,响应会被写两遍,客户端拿到拼接的脏数据。
- 中间件函数应该是无状态的,需要配置就走闭包返回
Middleware,别用包级变量存请求相关数据(并发下会串)。
小结
- 中间件 =
func(http.Handler) http.Handler,靠 HandlerFunc 适配;
- 组合时倒序套,Recover 放最外层,Auth 的位置决定日志里有没有 401 噪音;
- 拿状态码要包装
ResponseWriter,同时留意 Flusher/Hijacker 接口丢失;
- 跨中间件传数据用
context.WithValue,key 用自定义类型,断言带 ok;
- 拦截请求后立即
return,不调 next;
context 超时只是信号,业务侧不检查 ctx.Done() 就不会真的中断。