应使用中间件 wrapper 统一拦截请求响应,用 io.TeeReader/io.MultiWriter 捕获流、httptest.NewRecorder 记录响应、X-Request-ID 透传链路 ID,context 存字符串防泄漏,限制响应体类型与长度(如10KB)并校验 StatusCode。

用 http.Handler 包一层,别动 http.ServeMux 默认逻辑
直接改 http.DefaultServeMux 或在每个路由 handler 里重复写日志逻辑,后期维护成本高、容易漏。统一入口拦截最稳——写个中间件 wrapper,套在所有 handler 外层就行。
关键点是:必须用 io.TeeReader 和 io.MultiWriter 分别捕获请求体和响应体,不能简单 bytes.Buffer 读完再塞回去,否则 body 流被消耗后下游 handler 拿不到数据。
-
request.Body需要先io.Copy到 buffer,再用bytes.NewReader构造新 body 替换原值 - 响应体捕获要用
httptest.NewRecorder()或自定义ResponseWriter实现,记录Write和WriteHeader调用 - 链路 ID 建议从
X-Request-IDheader 读,不存在时用uuid.New().String()生成,写回 response header 保证透传
context.WithValue 传链路 ID,但别塞结构体或指针
很多人把整个 log.Logger 或带字段的 struct 塞进 context,结果内存泄漏、GC 压力大、甚至 panic。链路 ID 就该是字符串,轻量、可比较、无副作用。
更麻烦的是:如果用了 Gin/echo 等框架,它们的 context 已经封装过一层,context.WithValue 后要确保下游 handler 真的用的是你塞的那个 context,而不是框架自己 new 的。
立即学习“go语言免费学习笔记(深入)”;
使用 uber-go/dig 在 Golang 中实现依赖注入——基于反射容器,提供 Provide/Invoke、dig.In/dig.Out 参数与结果对象,支持命名依赖等
- 统一在中间件里调用
ctx = context.WithValue(r.Context(), "req_id", reqID) - 后续 handler 中用
reqID := ctx.Value("req_id").(string)取(注意类型断言,建议加判断避免 panic) - 别用
map[string]interface{}当 key,用私有类型如type ctxKey string防止 key 冲突
响应体太大时别全量记录,加长度和类型限制
用户上传 100MB 文件,或者接口返回巨量 JSON,日志一记就 OOM 或打爆磁盘。不是所有响应体都值得记,得有策略。
常见错误是没判断 Content-Type 就硬 decode,遇到 application/octet-stream 或压缩流直接 panic;或者没限制长度,io.ReadAll 卡死连接。
- 只记录
Content-Type包含json、text、xml的响应体 - 用
io.LimitReader(respBody, 10240)限长 10KB,超长部分用"[TRUNCATED]"标记 - 记录前检查
resp.StatusCode ,错误响应体才更值得查,成功响应体可跳过
日志格式里别拼接字符串,用结构化字段输出
用 fmt.Printf("[%s] %s %s %d %s\n", reqID, r.Method, r.URL.Path, status, body) 看着快,但后续想按 req_id 查整条链路?得靠正则硬扒,慢还容易错。
真正排查时,你要的是:同一 req_id 下,所有日志能被 ES 或 Loki 聚合,请求、中间件、DB 查询、响应全部串起来。
- 用
log.Printf不够,上zap或zerolog,写日志时明确传req_id、method、path、status、duration_ms等字段 - 请求体和响应体作为独立字段(如
req_body、resp_body),不要混在 message 里 - 日志级别按需设:
Info记常规请求,Warn记 4xx/5xx,Error记中间件 panic
最难的不是记下什么,而是决定不记什么——哪些 body 要截断、哪些 header 要脱敏(比如 Authorization)、链路 ID 在跨服务调用时怎么透传而不被覆盖。这些不写死在中间件里,光靠文档没人会看。

















