结构化日志中间件必须返回func(http.Handler) http.Handler类型,且需用http.HandlerFunc包装逻辑、显式调用next.ServeHTTP(w, r)放行请求;须用zap实现JSON格式输出,复用logger实例,并在所有路由前通过r.Use()注册。

结构化日志中间件必须返回 http.Handler,不能只写日志逻辑
Chi 框架本身不关心日志格式,但要求中间件函数签名严格为 func(http.Handler) http.Handler。很多人直接照搬 log.Printf 示例,结果注册后日志照打、但请求卡死或 panic——根本原因是没返回 handler,或者返回了错误类型(比如 func(http.ResponseWriter, *http.Request))。Chi 的 r.Use() 只接受符合该签名的函数。
- 正确结构:外层接收配置(如
*zap.Logger),中层返回func(http.Handler) http.Handler,内层用http.HandlerFunc包裹实际逻辑 - 错误示范:
func Logger() { ... }(无参数无返回)、func Logger(w http.ResponseWriter, r *http.Request)(类型不匹配) - Chi 路由器调用链依赖
next.ServeHTTP(w, r)显式放行;漏掉这句,请求就停在中间件里,永远不进业务 handler
用 zap 替代 log.Printf 实现结构化输出
默认 log 包输出是纯文本,无法被 ELK 或 Loki 有效解析。换成 zap 后,字段自动转为 JSON 键值对,比如 "method":"GET"、"status":200。注意:必须提前初始化 logger 实例传入中间件,不能在闭包里每次 new 一个。
示例代码:
func ZapLogger(logger *zap.Logger) func(http.Handler) http.Handler {
return func(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
start := time.Now()
// 记录请求开始
logger.Info("request started",
zap.String("method", r.Method),
zap.String("path", r.URL.Path),
zap.String("ip", r.RemoteAddr),
)
// 包装 ResponseWriter 以捕获状态码和字节数
rw := &responseWriter{ResponseWriter: w, statusCode: http.StatusOK}
next.ServeHTTP(rw, r)
// 记录响应完成
logger.Info("request completed",
zap.String("method", r.Method),
zap.String("path", r.URL.Path),
zap.Int("status", rw.statusCode),
zap.Int64("bytes", rw.bytesWritten),
zap.Duration("duration", time.Since(start)),
)
})
}
}
关键点:
使用 uber-go/dig 在 Golang 中实现依赖注入——基于反射容器,提供 Provide/Invoke、dig.In/dig.Out 参数与结果对象,支持命名依赖等
-
responseWriter是个自定义 wrapper,需实现WriteHeader方法并记录statusCode,否则拿不到真实状态码 - 不要在
next.ServeHTTP前调用w.WriteHeader,否则后续 write 会 panic:http: multiple response.WriteHeader calls -
zap实例应复用,避免并发创建导致内存泄漏或性能抖动
在 Chi 中注册时别漏掉 r.Use() 调用顺序
Chi 的中间件执行顺序是「先注册,先执行」,也就是洋葱模型最外层。如果你把日志中间件放在 r.Use() 最后,它反而会成为最内层——意味着其他中间件(如鉴权、recover)出错 panic 时,日志可能根本来不及打出。
- 推荐顺序:
r.Use(middleware.Recoverer)→r.Use(ZapLogger(zapLogger))→r.Use(middleware.Throttle)→ 其他业务中间件 - 错误写法:
r.Use(ZapLogger(zapLogger)); r.Get("/api", handler)—— 这样日志只覆盖/api,不覆盖其他路由;必须在所有r.Get/r.Post之前调用r.Use - 若需按路径区分日志级别(如
/health用 DEBUG,/api用 INFO),得用chi.Route分组 + 单独Use,不能靠一个全局中间件硬过滤
容易被忽略的坑:r.Body 不可重复读,影响日志里的请求体采样
想在结构化日志里打印请求体(如 JSON payload),直接 ioutil.ReadAll(r.Body) 会导致下游 handler 读不到 body,接口报空数据。这不是 Chi 特有,而是 net/http 标准行为。
- 安全做法:用
io.TeeReader或httputil.DumpRequest(仅调试用,性能差) - 生产建议:只对特定路径(如
/debug/log)或开关开启 body 日志,且限制最大长度(io.LimitReader(r.Body, 1024)) - 更健壮方案:结合
context.WithValue在前置中间件缓存解析后的结构体(如json.Unmarshal后的 map),日志中间件只打键名,不重读原始 body
Chi 的中间件机制本身很轻量,但结构化日志真正落地时,90% 的问题出在 response 状态捕获不准、body 读取冲突、或 logger 实例生命周期管理混乱——这些细节比选型更重要。
golang免费学习笔记(深入):立即使用
在学习笔记中,你将探索golang的核心概念和高级技巧!










