日志中间件必须在c.next()前读取并重置r.body、脱敏,之后获取状态码和耗时;因r.body是单次可读io.readcloser,后读为空,且需同步更新contentlength与content-type头。

直接结论:日志中间件必须在 c.Next() 之后读取状态码和耗时,但原始请求体(r.Body)必须在 c.Next() 之前完成读取与重置——顺序错了就丢数据或打不出日志。
为什么不能在 c.Next() 之后读 r.Body
Go 的 http.Request.Body 是单次可读的 io.ReadCloser。一旦业务 handler(比如 c.ShouldBindJSON())调用过 io.ReadAll(r.Body),再读就是空字节;中间件里后读,拿到的是空 []byte,日志里 body 字段永远为空。
- 常见错误现象:
log.Printf("body: %s", string(body))打印出空字符串,但业务逻辑明明收到了数据 - 根本原因:gin 等框架的 binding、form 解析、中间件链中靠后的 handler 都会提前消费
r.Body - 正确时机:必须在
c.Next()前完成读取 → 脱敏 → 重设r.Body - 安全兜底:开头就套
http.MaxBytesReader,例如r.Body = http.MaxBytesReader(c.Writer, r.Body, 1024*1024),防 OOM
怎么安全地复制并重设 r.Body
不能只用 io.ReadAll,得把副本塞回去,同时同步更新 ContentLength 和 Content-Type 头,否则下游 binding 会失败(比如 c.ShouldBind 报 invalid character 或跳过解析)。
- 先读:用
io.ReadAll(r.Body)得到原始bodyBytes - 再重设:
r.Body = io.NopCloser(bytes.NewReader(bodyBytes)) - 必须补两行:
r.ContentLength = int64(len(bodyBytes))和r.Header.Set("Content-Length", strconv.Itoa(len(bodyBytes))) - GET 请求要跳过:检查
r.Method == "GET",避免对空 body 调用ReadAll(虽不 panic,但浪费) - 表单类请求(
application/x-www-form-urlencoded、multipart/form-data)优先走r.ParseForm()+r.PostForm,比手动解析更稳
脱敏逻辑放哪儿?怎么写才不误杀
脱敏必须在 c.Next() 之前做,且只动 JSON 或表单里的字符串值,不动结构、数字、布尔、空值。硬匹配字段名(如 “password”)极易漏掉 pwd、auth_token 等变体。
- 敏感键列表建议从配置加载,支持正则,例如
^.*token$|^idCard|phone - 对 JSON body:先
json.Unmarshal成map[string]interface{},递归遍历 key,命中敏感键且 value 是string类型时才替换成"***" - 对 query 参数:用
r.URL.Query()获取url.Values,遍历 key/value,脱敏后调用r.URL.RawQuery = q.Encode()同步更新原始串,否则日志里 query 还是明文 - 别碰整个 JSON 字符串:
strings.ReplaceAll(jsonStr, "password", "***")会破坏引号配对或误杀字段值里的单词
耗时和状态码怎么拿才准
耗时不是 time.Since(time.Now()),状态码也不是 c.Writer.Status() 就完事——前者精度漂移,后者在 c.Next() 前调用永远是 0。
- 起始时间必须在进入中间件第一行记:
start := time.Now() - 状态码和响应大小只能在
c.Next()之后获取:status := c.Writer.Status()、size := c.Writer.Size() - 务必用
time.Since(start)算耗时,不要分两次调time.Now(),高并发下可能跨调度周期导致负值 - 注意:
c.Writer是 gin 封装的responseWriterWrapper,它能正确暴露 status 和 size;原生http.ResponseWriter不行,需自己包装
最易被忽略的一点:trace_id 必须用 context.WithValue 注入,且所有 DB 查询、HTTP client 调用都得显式传这个 ctx,否则日志里 trace_id 是空的——而很多人只在中间件里设了,下游代码却还用着 c.Request.Context() 之外的 context。
golang免费学习笔记(深入):立即使用
在学习笔记中,你将探索golang的核心概念和高级技巧!











