应使用包装 http.ResponseWriter 的自定义中间件捕获状态码、响应字节数和耗时,而非直接用 log.Printf 或 fmt.Printf;需重写 WriteHeader 和 Write 方法,初始化 statusCode 为 0 并在 Write 中兜底设 200,累加 written 字节数,配合 time.Since 计算耗时。

Go HTTP 中间件里怎么记录请求日志
直接用 log.Printf 或 fmt.Printf 打印请求信息是最常见的错误起点——它不区分并发请求,时间戳不准,无法关联响应状态,还容易在 panic 时丢失日志。真正可用的日志中间件必须捕获 http.ResponseWriter 的写入行为,才能拿到状态码和响应体大小。
核心做法是包装 http.ResponseWriter,重写 WriteHeader 和 Write 方法,在写入前/后记录关键字段:
-
Request.Method、Request.URL.Path、Request.RemoteAddr - 实际写入的
statusCode(不能只看ResponseWriter.WriteHeader调用参数,因为可能被后续覆盖) - 响应耗时(用
time.Since()计算,起始点必须在 handler 执行前) - 响应字节数(需自己累加
Write返回的n)
为什么不能直接用 net/http 的 ResponseWriter
http.ResponseWriter 是个接口,标准实现(如 response 结构体)未导出,无法直接读取状态码或已写入字节数。调用 w.WriteHeader(200) 后若 handler panic,真实返回的可能是 500,但你记录的还是 200。
典型陷阱:
- 在
defer里读w.Header().Get("Content-Length")—— 这个 header 可能根本没设,或者设了但被底层忽略 - 只监听
WriteHeader调用 —— 多数 handler 不显式调用它,依赖第一次Write自动触发 200 - 用
io.MultiWriter包裹ResponseWriter—— 它不拦截WriteHeader,状态码照样拿不到
一个轻量但可靠的日志中间件实现
不需要引入第三方日志库,用标准 log + 自定义 wrapper 就够用。重点在于:状态码必须从 WriteHeader 的入参捕获,并在 Write 第一次调用时“锁定”;响应体大小必须在每次 Write 后累加。
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: 0,
written: 0,
}
next.ServeHTTP(lrw, r)
log.Printf("[%s] %s %s %d %d %v",
r.Method,
r.URL.Path,
r.RemoteAddr,
lrw.statusCode,
lrw.written,
time.Since(start),
)
})
}
注意:lrw.statusCode 初始化为 0,靠 Write 里兜底设为 200,是因为很多 handler 根本不调 WriteHeader;lrw.written 必须在 Write 返回后更新,否则并发写入会错乱。
生产环境要补哪些关键细节
上面的版本能跑通,但上线前至少得加三件事:
- 用
log.SetOutput把日志输出到文件(比如os.OpenFile("access.log", os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0644)),别让它混进 stderr - 加请求 ID:用
r.Context().Value()或解析X-Request-IDheader,否则多个请求日志串在一起没法追踪 - 过滤敏感路径:对
/health、/metrics这类探针接口跳过日志,避免刷屏和泄露内部结构
最常被忽略的是日志格式一致性——如果后续要接入 ELK 或 Loki,建议直接输出 JSON,而不是靠正则解析空格分隔的字符串。但那是另一层需求了。
golang免费学习笔记(深入):立即使用
在学习笔记中,你将探索golang的核心概念和高级技巧!











