Go HTTP 中间件

Go HTTP 中间件以 func(http.Handler) http.Handler 组合请求处理链,把日志、认证、CORS、限流、panic 恢复等横切逻辑从业务 Handler 中剥离。

#type / concept #status / growing #tech / dev #tech / dev / backend #resource / go

[!info] 关联笔记

Go HTTP 中间件

这个概念为什么会出现

一个 HTTP Handler 若同时负责:

  • 解析 JWT 并拒绝未登录请求
  • 打访问日志与耗时
  • 写 CORS 头、处理 OPTIONS
  • recover panic
  • 真正的业务读写

则会出现:复制粘贴爆炸、测试困难、顺序无法统一调整、安全策略与业务代码缠在一起。

中间件解决的是横切关注点(cross-cutting concerns):在「请求进入业务」之前和「响应离开」之后,用同一套可组合的包装器插入逻辑。Go 标准库没有名为 Middleware 的类型,但社区与生产代码几乎统一收敛到:

func(http.Handler) http.Handler

这与 go-nethttpHandler 接口天然契合,也是理解 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

结合场景再看三个关注点

  1. 形状固定:func(http.Handler) http.Handler
    与路由库无关,Gin/chi 只是同一思想的语法糖。

  2. 返回值常用 http.HandlerFunc
    把闭包适配成 Handler 接口。

  3. 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)
	// ...
}

约定:

  1. key 使用未导出自定义类型,避免包间碰撞。
  2. 只放横切元数据;领域依赖优先构造注入,而不是塞进 context。
  3. 必须用 r.WithContext,否则下游仍看到旧 ctx。

详见 go-context

包装 ResponseWriter

若后置逻辑需要状态码或字节数,标准 ResponseWriterWriteHeader 后不会自动暴露状态,常见做法是嵌入包装类型:

type statusWriter struct {
	http.ResponseWriter
	code int
}

func (w *statusWriter) WriteHeader(code int) {
	w.code = code
	w.ResponseWriter.WriteHeader(code)
}

注意:实现 FlushHijackPush 等可选接口时,需按需转发,否则 SSE、WebSocket 可能静默失败。见 go-sse-proxy-pattern

路由级 vs 全局

挂载方式适用
ListenAndServe(addr, chain(mux, ...))全局:日志、Recovery、请求 ID
包一层子 mux / 分组/api 要 Auth
单个 Handle 再包极少数路由特例

go-routing-patterns 中的分组(如 chi Route + Use)本质仍是「子树根 Handler 外包中间件」。

常见中间件清单

类型职责要点
Recoverydefer 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
Gingin.HandlerFunc,链式 c.Next()
Echoecho.MiddlewareFunc

框架中间件不与标准库签名通用。选型框架即选型其中间件生态;理解标准库模型后,迁移时只需改「如何取 Writer/Request/Next」。

设计动机

  1. 关注点分离:业务 Handler 只做协议 ↔ 用例编排入口。
  2. 可组合顺序:安全、可观测、协议策略可独立演进。
  3. 可测试:对假 next 断言是否调用、是否改写 header/ctx。
  4. 与 net/http 同构:不引入新运行时概念,只是函数组合。

边界情况与反直觉行为

  1. 写了响应又调用 next
    可能导致重复 WriteHeader 或混乱 body。短路必须 return
  2. 在中间件里启动 goroutine 却使用请求 ctx 却不管理生命周期
    请求结束后 ctx 取消,后台任务被误杀或泄漏,需明确策略。
  3. 包装 Writer 丢掉 Flusher
    SSE/分块传输无法 Flush
  4. 全局可变状态当「请求用户」
    竞态;应用 ctx 或显式参数。
  5. 中间件顺序反了
    未 Recovery 的 panic、未 Auth 就记敏感业务日志等。
  6. 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 关联。

工程实践

  1. 定义统一类型 type Middleware func(http.Handler) http.HandlerChain
  2. Recovery 最外,Logging 记录真实状态码(包装 Writer)
  3. ctx 传递 request id,日志字段一致。
  4. 认证中间件与授权中间件分离:认证解决「你是谁」,授权解决「能不能」。
  5. 单测httptest.NewRecorder + 可断言的 next(例如记录是否调用)。
  6. 流式接口:跳过 gzip、谨慎包装 Writer,保留 http.Flusher
  7. 超时:优先让下游尊重 r.Context();全局再配合 ReadHeaderTimeout 等 server 超时(go-nethttp)。
  8. 分层:中间件不写 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 落地认证。

自测题

概念题

  1. 为什么 Chain 常常从后往前应用中间件切片?
  2. 中间件不调用 next.ServeHTTP 的合法场景有哪些?
  3. context.WithValue 的 key 为何推荐未导出类型?

代码推理题

h := recovery(auth(logging(mux)))

请求进入时,loggingauth 谁的前置逻辑先执行?若 auth 短路,logging 的后置耗时日志还会跑吗?

工程思考题

SSE 接口与 JSON API 共用全局 gzip 中间件,可能出现什么问题?如何分层挂载?

参考答案

展开
  1. 使切片中靠前的中间件成为外层,符合「从左到右阅读执行顺序」的直觉。
  2. 认证失败、限流、CORS 预检、维护模式等有意终止链路。
  3. 避免不同包用同名字符串 key 互相覆盖或误读。
    代码:recovery(auth(logging(mux))) 进入顺序为 recovery → auth → logging → mux。authlogging 外侧:短路时 整段 logging(含后置耗时)都不会执行
    工程:gzip 与 SSE 冲突(缓冲/编码);SSE 路由组跳过压缩或单独 mux。

延伸阅读与资料来源

资料类型支撑
Package net/http标准库Handler、ServeMux、Server
Package net/http/httptest标准库中间件单测
Package context标准库WithValue / 取消
Go Blog — context博客请求范围值与取消
Code Review Comments — ContextsWikictx 惯例

笔记元信息

  • 建议文件名:go-http-middleware.md
  • 所属阶段:阶段七(Web 后端)
  • 本篇状态:已深化
  • 建议下一篇:Go 路由模式
创建于 2026/6/25 更新于 2026/7/15