G07第 1 / 1 课时未开始内容版本 1.0.0 · 核验于 2026-10-26

中间件与请求上下文

中间件与请求上下文 · 阅读后完成下方实验与检查题,再用「标记完成」记录进度。

学习目标

这课给服务装上横切能力:用中间件统一加请求 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= 变空,这就是顺序敏感的实证。

动手任务

  1. 加 withRecovery 中间件:defer recover() 捕获 handler 的 panic,记日志并返回 500,不把堆栈给客户端。
  2. 升级耗时日志:记录 r.UserAgent() 和响应状态码(用一个包装 ResponseWriter 的小结构体截获 WriteHeader)。
  3. 对调 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 只放取消/截止/请求元数据,业务参数走参数

参考资料

检查题共 1 题,提交后立即显示解析
答案由服务端判定;答错的题会自动进入复习计划。
Q1读代码中等

阅读代码(Go 1.22+,net/http 中间件链)。客户端请求 GET /ping,控制台输出顺序是什么?响应头里有 X-Stage 吗?

package main

import (
	"fmt"
	"net/http"
)

func logging(next http.Handler) http.Handler {
	return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
		fmt.Println("log-in")
		next.ServeHTTP(w, r)
		fmt.Println("log-out")
	})
}

func auth(next http.Handler) http.Handler {
	return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
		fmt.Println("auth")
		w.Header().Set("X-Stage", "auth")
		next.ServeHTTP(w, r)
	})
}

func main() {
	mux := http.NewServeMux()
	mux.HandleFunc("/ping", func(w http.ResponseWriter, r *http.Request) {
		fmt.Println("handler")
		w.Write([]byte("pong"))
	})
	http.ListenAndServe(":8080", logging(auth(mux)))
}

读完这一课了?

标记完成后,工作台会安排接下来的学习。

Command Palette

Search for a command to run...