13.3 中间件与访问日志
写到这里,TaskAPI 的每个处理器都干净利落。但你马上会遇到一批「每个请求都要做,却和业务无关」的事:记一条访问日志、给请求发一个 ID、崩溃了别让整个进程挂掉。把这些代码复制进五个处理器,是新手最容易犯的错。本节用 Go 的方式把它们抽出来。
本节把 TaskAPI 推进到:用中间件统一处理请求 ID、panic 恢复与结构化访问日志,把横切关注点从业务处理器里剥离,让每个 handler 只关心自己那件事。
13.3.1 中间件就是「包一层」
在别的语言里,中间件往往是一套框架提供的钩子。在 Go 里它简单得近乎无聊:一个中间件就是一个接收 http.Handler、返回 http.Handler 的函数。
func myMiddleware(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
// 请求前:做点事
next.ServeHTTP(w, r)
// 请求后:做点事
})
}
next 是「被包裹的下一环」。你在调用 next.ServeHTTP 之前写的代码在请求进入业务逻辑前执行,之后写的在响应返回后执行。就这么一个签名,能表达洋葱模型里的任意一层。
为什么它能工作?因为 13.1 已经说明:ServeMux 是 http.Handler,HandlerFunc 也是。既然大家都是 Handler,就能互相包裹、任意嵌套。
13.3.2 用类型别名统一签名
每次写 func(next http.Handler) http.Handler 太长。定义一个有名字的类型,可读性立刻提升:
type Middleware func(http.Handler) http.Handler
有了它,一条链可以这样组装:
func chain(h http.Handler, mws ...Middleware) http.Handler {
for i := len(mws) - 1; i >= 0; i-- {
h = mws[i](h)
}
return h
}
注意循环是从后往前的。假设调用 chain(mux, A, B, C),你希望的执行顺序是 A → B → C → mux。倒序遍历保证最后包上去的是 A,而 A 在最外层,最先执行。如果写成正序,执行顺序会反过来,这是个非常隐蔽的 bug。
13.3.3 记录状态码与响应字节数
访问日志要写「状态码 200、返回 27 字节」,但 http.ResponseWriter 本身不告诉你这些。办法是包一层,在写的时候顺手记下来:
type statusRecorder struct {
http.ResponseWriter
status int
bytes int
}
func (r *statusRecorder) WriteHeader(code int) {
r.status = code
r.ResponseWriter.WriteHeader(code)
}
func (r *statusRecorder) Write(b []byte) (int, error) {
if r.status == 0 {
r.status = http.StatusOK // 没显式写状态码时,默认 200
}
n, err := r.ResponseWriter.Write(b)
r.bytes += n
return n, err
}
这里用了结构体嵌入(第 4 章)把原 ResponseWriter 装进来,再覆写两个方法。关键细节有两个:
Write里若status还是 0,说明处理器直接写了 body 没调WriteHeader,此时真实状态码是默认的 200。- 嵌入会「继承」其余方法,但如果下游需要
http.Flusher(SSE、流式响应),你得再实现Flush()转发给内层,否则类型断言会失败。
用 httptest 实测这个 recorder,rec.Code 会如实反映写下的状态码。
13.3.4 请求 ID:跨中间件传递数据
给每个请求发一个 ID,方便日志串联。ID 本身是中间件产生的,但下游处理器也想读到它。跨层传值正是 context 的用武之地(第 12.2 节):
type ctxKey string
const reqIDKey ctxKey = "reqID"
func requestID(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
id := r.Header.Get("X-Request-Id")
if id == "" {
b := make([]byte, 8)
_, _ = rand.Read(b)
id = hex.EncodeToString(b)
}
w.Header().Set("X-Request-Id", id)
ctx := context.WithValue(r.Context(), reqIDKey, id)
next.ServeHTTP(w, r.WithContext(ctx))
})
}
三个要点:用自定义 key 类型(ctxKey)而不是字符串,避免和其他包的 key 撞车;r.WithContext(ctx) 返回一个带着新 context 的请求副本,必须把返回值往下传;同时把 ID 写回响应头,客户端能对账。
下游处理器取:
id, _ := r.Context().Value(reqIDKey).(string)
13.3.5 panic 恢复:别让一个请求打垮整个服务
HTTP 服务器对每个连接有独立的 goroutine,一个 handler panic 会终止该 goroutine 并断开连接,但不会拖垮整个进程。不过客户端只会看到连接被重置,日志里也未必有上下文。用 defer/recover 兜住并回一个 500:
func recoverMW(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
defer func() {
if rec := recover(); rec != nil {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusInternalServerError)
_ = json.NewEncoder(w).Encode(map[string]string{"error": "internal error"})
}
}()
next.ServeHTTP(w, r)
})
}
实测确认:一个故意 panic("boom") 的处理器经过它之后,客户端收到的是 500,而不是连接重置。生产环境这里还应该把 rec 和堆栈记进日志,本节从简。
13.3.6 访问日志:接上 slog
把 recorder 和日志合起来,就是访问日志中间件:
func accessLog(logger *slog.Logger) Middleware {
return func(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
start := time.Now()
rec := &statusRecorder{ResponseWriter: w}
next.ServeHTTP(rec, r)
logger.Info("http",
"method", r.Method,
"path", r.URL.Path,
"status", rec.status,
"bytes", rec.bytes,
"dur", time.Since(start).Round(time.Microsecond).String(),
"req_id", r.Context().Value(reqIDKey),
)
})
}
}
实测输出(slog 的 TextHandler):
level=INFO msg=http method=POST path=/tasks status=201 bytes=27 dur=3.184ms req_id=b5f56b67ac9c6a23
一条日志把方法、路径、状态码、字节数、耗时、请求 ID 全带上了,且是结构化的——第 16.1 节会展开 slog 的 handler 与级别。注意日志在 next.ServeHTTP 之后打印,所以能拿到真实的 rec.status 与耗时。
13.3.7 顺序决定行为
中间件的顺序不是随意的,它直接决定行为。TaskAPI 采用的顺序是:
handler := chain(mux, requestID, recoverMW, accessLog(logger))
从外到内是 requestID → recoverMW → accessLog → mux。为什么这么排:
| 顺序 | 原因 |
|---|---|
requestID 最外 | 保证后面所有环节都能拿到 ID |
recoverMW 在日志之外 | 内层任何 panic 都被接住并回 500,避免连接被重置 |
accessLog 最内 | 紧贴业务,耗时统计最准 |
实测一个有趣的细节:当 mux 里的处理器 panic 时,accessLog 的日志不会打印——因为 panic 直接向上抛,跳过了它 next.ServeHTTP 之后的代码,被更外层的 recoverMW 接住。这恰好说明「recover 在日志之外」的选择是对的:如果顺序反过来,panic 会被内层接住并正常返回,外层日志反而能记下这条 500。
装配完整示例:
mux := http.NewServeMux()
mux.HandleFunc("POST /tasks", handleCreate)
mux.HandleFunc("GET /panic", handlePanic)
logger := slog.New(slog.NewTextHandler(os.Stdout, &slog.HandlerOptions{Level: slog.LevelInfo}))
h := chain(mux, requestID, recoverMW, accessLog(logger))
srv := &http.Server{
Addr: ":8080",
Handler: h,
ReadHeaderTimeout: 5 * time.Second,
}
13.3.8 用 httptest 测中间件
中间件本身也是 http.Handler,所以能像普通处理器一样测。测 requestID 是否写进了响应头:
rec := httptest.NewRecorder()
req := httptest.NewRequest("GET", "/tasks", nil)
h.ServeHTTP(rec, req)
if rec.Header().Get("X-Request-Id") == "" {
t.Fatal("missing request id header")
}
实测这套断言全部通过。想验证「状态码记录准确」,就构造一个只调 WriteHeader(418) 的处理器,套上 accessLog,断言日志里的 status=418。第 8 章的表驱动测试在这里同样适用:把 {方法, 路径, 期望状态码} 做成切片,循环断言 rec.Code。
13.3.9 小结
- 中间件 =
func(http.Handler) http.Handler,用Middleware类型别名统一签名。 chain必须倒序包裹,才能得到正序执行。- 记录状态码要包一层
ResponseWriter;注意默认 200 与Flusher转发。 - 跨层传值用
context,key 用自定义类型,r.WithContext的返回值必须往下传。 defer/recover兜 panic,回 500 而不是让连接被重置。- 顺序即行为:ID 最外、recover 次之、日志最内。
到这里,TaskAPI 的 HTTP 层完整了:路由、编解码、中间件。但它还活在一个内存 map 里——进程一重启,任务全丢。下一章我们接上数据库,让数据真正持久化。
阅读导航:上一节:13.2 请求解析、JSON 与响应 · 下一节:14.1 database/sql 与驱动 。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。