《Go 语言编程入门》13.3 中间件与访问日志

日志、恢复、请求 ID 这些横切关注点不该塞进每个处理器。本节用「Handler 包 Handler」的中间件模式把它们抽出来,讲清 Middleware 类型与链式组合、如何包装 ResponseWriter 记录状态码与字节数、用 context 传递请求 ID、用 defer/recover 兜住 panic,并接上 slog 输出结构化访问日志。

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 装进来,再覆写两个方法。关键细节有两个:

  1. Write 里若 status 还是 0,说明处理器直接写了 body 没调 WriteHeader,此时真实状态码是默认的 200。
  2. 嵌入会「继承」其余方法,但如果下游需要 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 与驱动 。

继续阅读

探索更多技术文章

浏览归档,发现更多关于系统设计、工具链和工程实践的内容。

全部文章 返回首页

「golang」更多文章

  1. 《Go 语言编程实战》目录
  2. 《Go 语言编程实战》18.3 上线、观测与迭代
  3. 《Go 语言编程实战》18.2 故障演练