← [ BACK_TO_NOTES ]

Go HTTP 中间件

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

#concept #growing #dev #backend #go

[!info] related notes

Go HTTP 中间件

这个概念为什么会出现

一个 HTTP Handler 若同时负责:

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

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

中间件解决的是横切关注点(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。

结合场景再看三个关注点

  1. 形状固定:func(http.Handler) http.Handler
    这是标准库与 chi 常用的形状;Gin、Echo 有自己的调用链、短路与错误处理语义,不能直接互换。

  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)))

// 等价于本篇的反向遍历实现:
// handler := chain(mux, recovery, auth, logging)
// recovery 最外、logging 最内
阶段谁先跑
请求进入(前置)最外层中间件
到达业务最内层 / mux
响应返回(后置)先内后外

顺序是行为的一部分:Recovery 只能捕获同一 goroutine 内层调用的 panic。上例 Auth 短路时 Logging 不会执行;若访问日志也要覆盖拒绝请求及 Recovery 写出的错误,可采用 Logging → Recovery → Auth → Handler。普通后置语句在 panic 时不会执行,需要时使用 defer;但 defer 本身不保证已看到外层 Recovery 的最终状态。

短路(不调用 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)
	})
}

本例只检查 header 非空,任意非空值都能通过,不是完整的 API Key 认证;实际认证必须校验凭证。短路用于认证失败、限流拒绝、通过策略检查后的 CORS 预检、维护模式等。忘记 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

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

type statusWriter struct {
	http.ResponseWriter
	code int
}

func (w *statusWriter) WriteHeader(code int) {
	if w.code != 0 {
		return // 最终状态已提交,后续 WriteHeader 不会覆盖它
	}
	w.ResponseWriter.WriteHeader(code)
	if code >= 200 || code == http.StatusSwitchingProtocols {
		w.code = code // 其他 1xx 是临时响应,不是最终状态
	}
}

func (w *statusWriter) Write(p []byte) (int, error) {
	if w.code == 0 {
		w.code = http.StatusOK // 底层 Write 会隐式提交 200
	}
	return w.ResponseWriter.Write(p)
}

上例只演示普通、非流式 HTTP 响应的状态记录;正常返回且完全未写时按 200 记日志。它不统计字节数,也不保留 Flusher、Hijacker、Pusher 等可选接口,不能直接用于 SSE/WebSocket。生产包装需按底层能力转发,并跟踪 Flush 触发的隐式响应;Unwrap() 可供 http.ResponseController 查找底层,但不会自动保留直接类型断言的接口,也可能绕过状态记录。见 go-sse-proxy-pattern。

路由级 vs 全局

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

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

常见中间件清单

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

框架中间件不与标准库签名通用。迁移时还需核对 Next/Abort、错误返回、响应提交以及 Context 生命周期,不能只替换字段名称。

设计动机

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

边界情况与反直觉行为

  1. 写了响应又调用 next
    可能导致重复 WriteHeader 或混乱 body。短路必须 return。
  2. 在中间件里启动 goroutine 却使用请求 ctx 却不管理生命周期
    请求结束后 ctx 取消;后台任务若观察取消可能提前退出,若不观察则可能继续执行或泄漏。取消不会强杀 goroutine,需明确任务归属与等待策略。
  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,已提交则按连接/流协议处理。不要把部分成功 body 后追加的错误当作完整 500;http.ErrAbortHandler 应保留其终止语义。

工程实践

  1. 定义统一类型 type Middleware func(http.Handler) http.Handler 与 Chain。
  2. 按覆盖范围排顺序:例如 Logging → Recovery → Auth;日志包装须处理隐式 200、重复 WriteHeader 和 1xx,且状态码不证明客户端已收到完整响应。
  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;再测试先写部分响应后 panic,确认不能改写已提交状态。net/http 本身会恢复 ServeHTTP 调用中的 panic 并关闭连接或重置 HTTP/2 流,但不会自动提供业务 JSON 500。另起 goroutine 的 panic 不在该恢复范围内。

实验 4:Context 传值

Auth 注入 user,业务 Handler 读出;缺少 key 时行为明确(401/500),避免裸断言崩溃。

实验 5:状态码包装

包装 ResponseWriter 记录最终状态码,使访问日志在 404/500 时字段正确。

本节总结

  • 本质:func(http.Handler) http.Handler 的洋葱组合。
  • 能力:前置/后置逻辑、短路、ctx 传值、包装 Writer。
  • 顺序:外层前置先执行、正常返回时后置后执行;短路、panic 与响应提交都会改变可观测结果。
  • 纪律:key 未导出、不重复写响应、流式保留 Flusher、可单测。
  • 下一步:go-routing-patterns 分组挂载;go-auth-and-jwt 落地认证。

自测题

概念题

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

代码推理题

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

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

工程思考题

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

参考答案

展开
  1. 使切片中靠前的中间件成为外层,符合「从左到右阅读执行顺序」的直觉。
  2. 认证失败、限流、CORS 预检、维护模式等有意终止链路。
  3. 避免不同包用同名字符串 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 — ContextsWikictx 惯例

笔记元信息

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

键盘导航

j / k / ↑ / ↓
选择对象
Enter / o
打开选择
/ 或 Ctrl+K
全站搜索
i / f / 1–4
筛选当前列表
g → h / l / p / t / a
首页 / 笔记 / 项目 / 工具 / About
r → 1–9
打开所选对象的关联
u
返回上一页
Esc
关闭浮层 / 取消命令;输入框内清空文本