默认的 http.servemux 不记录请求日志,因其仅负责路由分发,无内置日志能力;完整日志需通过外层中间件拦截整个请求生命周期,捕获状态码、字节数、耗时等关键字段。

为什么默认的 http.ServeMux 不记录请求日志
Go 标准库的 http.ServeMux 本身不带日志能力,它只负责路由分发。你看到的 log.Printf("received request") 这类写法如果直接塞进 handler,容易漏掉 panic、超时、提前返回等路径,导致日志不完整或丢失状态码。
真正可控的日志必须在请求生命周期最外层拦截——也就是用中间件包装所有 handler,确保无论是否 panic、是否重定向、是否提前 return,都能捕获开始时间、结束时间、状态码、字节数等关键字段。
用 http.Handler 实现可复用的日志中间件
核心是实现一个接收 http.Handler 并返回新 http.Handler 的函数。注意不要直接包装 http.HandlerFunc,否则会丢失对底层 http.Handler 接口的兼容性(比如第三方 router 如 gorilla/mux 返回的是自定义 Handler)。
- 用
time.Now()记录起始时间,defer 中计算耗时 - 用
io.TeeReader或自定义ResponseWriter拦截响应体和状态码(推荐后者) - 避免在日志中打印完整
r.Body,容易阻塞或泄露敏感数据;如需 body 内容,应限制长度并做脱敏 - 状态码必须从自定义
ResponseWriter的WriteHeader和Write方法中捕获,不能依赖defer里读rw.Status—— 因为有些 handler 可能根本不调用WriteHeader
type loggingResponseWriter struct {
http.ResponseWriter
statusCode int
written int
}
func (lrw *loggingResponseWriter) WriteHeader(code int) {
lrw.statusCode = code
lrw.ResponseWriter.WriteHeader(code)
}
func (lrw *loggingResponseWriter) Write(b []byte) (int, error) {
if lrw.statusCode == 0 {
lrw.statusCode = http.StatusOK
}
n, err := lrw.ResponseWriter.Write(b)
lrw.written += n
return n, err
}
func LoggingMiddleware(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
start := time.Now()
lrw := &loggingResponseWriter{
ResponseWriter: w,
statusCode: http.StatusOK,
}
next.ServeHTTP(lrw, r)
log.Printf("[%s] %s %s %d %d %v",
r.Method,
r.URL.Path,
r.Proto,
lrw.statusCode,
lrw.written,
time.Since(start),
)
})
}
生产环境要注意的三个坑
本地调试时日志看着没问题,一上生产就出问题,常见于:
立即学习“go语言免费学习笔记(深入)”;
-
log.Printf默认输出到os.Stderr,Kubernetes 或 systemd 下可能被截断或丢弃;应显式配置log.SetOutput到文件或结构化 logger(如zap) - 高并发下大量字符串拼接 +
log.Printf会成为性能瓶颈;建议用fmt.Sprintf预格式化或直接用zap.Stringer类型避免分配 - 未过滤健康检查路径(如
/healthz),导致日志刷屏;应在中间件开头加白名单/黑名单判断:if strings.HasPrefix(r.URL.Path, "/healthz") { next.ServeHTTP(w, r); return }
要不要集成结构化日志(如 zap)
纯文本日志查问题效率低,尤其要按 status、path、latency 做聚合分析时。用 zap 替换 log 几乎零成本:
- 把
log.Printf(...)改成logger.Info("http request", zap.String("method", r.Method), ...) - 注意
zap.Duration要传time.Since(start),别传start.Sub(...)错方向 - 如果用了
gin或echo,它们自带日志中间件,但默认不记录 body size 和精确耗时;仍建议自己包一层或 patch 其ResponseWriter
真正麻烦的不是怎么记日志,而是日志字段是否统一、能否被日志采集系统(如 filebeat + ELK)正确解析。路径、方法、状态码、耗时、字节数这五个字段缺一不可,且命名最好跟 OpenTelemetry HTTP 规范对齐。










