中间件与请求上下文
中间件与请求上下文 · 阅读后完成下方实验与检查题,再用「标记完成」记录进度。
学习目标
这课给服务装上横切能力:用中间件统一加请求 ID 和访问日志,理解包装顺序对执行顺序的影响,学会把请求级元数据放进 context,也划清「什么不该放进 context」的红线。
问题场景
出了问题查日志,一百条请求混在一起分不清谁是谁;想统计每个接口耗时,只能在每个 handler 里重复写计时代码。这两件事和业务无关却处处需要——正是中间件的用武之地:一层包装,全站生效。
核心解释
中间件就是「接收 Handler 返回 Handler」的函数:
type requestIDKey struct{} // 自定义 key 类型,杜绝撞车
func withRequestID(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
id := r.Header.Get("X-Request-Id")
if id == "" {
id = strconv.FormatInt(time.Now().UnixNano(), 36)
}
ctx := context.WithValue(r.Context(), requestIDKey{}, id)
w.Header().Set("X-Request-Id", id)
next.ServeHTTP(w, r.WithContext(ctx))
})
}
func requestID(ctx context.Context) string {
v, _ := ctx.Value(requestIDKey{}).(string)
return v
}
func withLogging(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
start := time.Now()
next.ServeHTTP(w, r)
log.Printf("req=%s %s %s took=%s", requestID(r.Context()), r.Method, r.URL.Path, time.Since(start))
})
}
func main() {
// ...
handler := withLogging(withRequestID(newMux(store)))
log.Fatal(http.ListenAndServe(":8080", handler))
}
包装与执行顺序相反:withLogging(withRequestID(mux)) 里 logging 在外层,请求进来先过 logging 的前半段,再进 requestID,最后到 mux;返回时按反方向出来。所以这个顺序里,logging 打日志拿到的 context 已经带着请求 ID(内层先执行了 WithValue)。把两个中间件对调,日志里 ID 恒为空——顺序 bug 不报错,只是悄悄失效。
context 传递请求级元数据的规矩:key 定义成自有类型(这里是空结构体 requestIDKey),避免和别人的 key 撞车;取值带类型断言和零值兜底,context 里没有也不崩。
红线同样要立:业务参数不放 context。书签 id 在路径里,过滤标签在查询串里,它们是接口契约的一部分,塞进 context 会让函数签名看不出依赖,测试也得先造 context。context 里只放三类东西:取消信号、截止时间、跨层传递的请求级元数据(请求 ID、登录态)。
示例拆解
go run .
curl -i http://127.0.0.1:8080/bookmarks/1
# 响应头: X-Request-Id: m1abc23(服务端生成)
# 终端日志: req=m1abc23 GET /bookmarks/1 took=128.5µs
curl -i -H "X-Request-Id: my-trace-001" http://127.0.0.1:8080/bookmarks/1
# 响应头透传 my-trace-001,日志同 ID
透传的价值在联调:前端把它自己的 trace id 带过来,服务端日志、响应头、以后的数据库慢查询日志全用同一个 ID,一条请求全链路可追。动手把两个中间件顺序对调再跑一次,观察日志里 req= 变空,这就是顺序敏感的实证。
动手任务
- 加 withRecovery 中间件:defer recover() 捕获 handler 的 panic,记日志并返回 500,不把堆栈给客户端。
- 升级耗时日志:记录 r.UserAgent() 和响应状态码(用一个包装 ResponseWriter 的小结构体截获 WriteHeader)。
- 对调 withLogging 与 withRequestID 的顺序,确认日志 ID 为空,再改回来。
常见错误
- 中间件包装顺序随手写:日志拿不到请求 ID、recovery 包在 logging 外导致 panic 时序不对,先画洋葱图再定顺序。
- context key 用 string 类型:跨包容易撞 key,取错值还不报错,key 一律自定义类型。
- 中间件里读了请求体却不塞回去:后续 handler 读到的 body 已被消费,要么重新赋值 r.Body,要么别在中间件碰 body。
- 把每请求的数据库连接放 context 传递:生命周期管理失控,连接归存储层管,context 只传信号和元数据。
检查与复盘
完成下方检查题。自查清单:
- 每个中间件都是 func(http.Handler) http.Handler,可独立测试
- 能画出当前中间件栈的洋葱图,说清进入与返回顺序
- context 只放取消/截止/请求元数据,业务参数走参数
参考资料
- 标准库 net/http.Handler —— 中间件操作的底层接口
- 标准库 context —— WithValue 的 key 约定官方说明
- 标准库 log —— 本课日志的基础实现,G14 升级为 slog
读完这一课了?
标记完成后,工作台会安排接下来的学习。