gin无法用单一中间件统一脱敏请求和响应日志,必须分离处理:请求体脱敏在c.next()前完成并重置r.body,响应体脱敏需包装c.writer在c.next()后操作,且两者逻辑完全隔离。

Gin 里没法靠一个中间件统一脱敏请求和响应日志,必须拆开处理:请求体脱敏在 c.Next() 前,响应体脱敏靠包装 c.Writer 在 c.Next() 后,两者逻辑完全隔离。
为什么不能只写一个中间件统一脱敏
因为请求体和响应体的读取时机、方式、生命周期完全不同:
-
r.Body是单次可读流,io.ReadAll(r.Body)后后续 handler(比如c.ShouldBindJSON())就拿不到数据了,必须重设r.Body = bytes.NewReader(buf)并同步更新r.ContentLength和Content-Lengthheader - 响应体默认不缓存,
c.Writer一旦调用WriteHeader()或首次Write(),数据就直发 TCP,中间件里根本拿不到原始 JSON 字节 - 中间件无结构体语义,
password字段可能叫pwd、auth_token、userSecret,仅靠字符串匹配会误杀或漏杀
请求体脱敏必须在 c.Next() 前完成
否则绑定逻辑已消费 body,你再读就是空的。关键点不是“能不能读”,而是“读完怎么还回去”:
- 开头加
r.Body = http.MaxBytesReader(c.Writer, r.Body, 1024*1024)防 OOM,上限按业务定(如 1MB) - 跳过 GET 请求:
if c.Request.Method == "GET" { goto skipBody },避免读空 body 报错 - 对
application/json:用json.Unmarshal解析成map[string]interface{},递归遍历 key,命中敏感键(如"password"、"token"、"idCard")且 value 类型为string时替换为"***" - 对
application/x-www-form-urlencoded或multipart/form-data:优先调c.Request.ParseForm(),再遍历c.Request.PostForm,别手动解析 - 脱敏后要同步更新
c.Request.URL.RawQuery(query 参数)和c.Request.Header(如Authorization),否则日志里看到的是未脱敏原始值
响应体脱敏必须包装 c.Writer
不能等 c.Next() 结束再去读 c.Writer——它没暴露缓冲区。正确做法是提前替换:
- 定义结构体实现
http.ResponseWriter接口,内嵌原c.Writer,并持有一个bytes.Buffer - 重写
Write([]byte):把数据先写进 buffer,不直接透传;重写WriteHeader(int):缓存状态码 - 在
c.Next()后检查c.Request.Header.Get("Content-Type")是否含"application/json",是则从 buffer 取出字节,json.Unmarshal→ 脱敏 →json.Marshal→ 再调原Write() - 非 JSON 类型(如
text/html、image/png)直接透传,避免破坏静态资源或文件下载 - 注意:脱敏后的响应体长度可能变化,需重置
Content-Lengthheader
结构体字段级脱敏比中间件更可控
中间件脱敏容易漏掉嵌套结构、破坏格式、干扰流式响应;而字段级脱敏发生在序列化源头,天然幂等、可测试、不依赖执行顺序:
- 给敏感字段加自定义 tag,例如
Phone string `json:"phone" redact:"mask"` - 让结构体实现
MarshalJSON()方法,内部用type Alias User防止递归,再对指定字段调用MaskPhone()等函数 - 若用
zap记录结构体,必须实现zapcore.ObjectMarshaler接口,且字段名必须导出(Password string,不能是password string) - 若用
zerolog,得靠InterfaceHook+ 封装SanitizeMap()深度递归处理map[string]interface{},但要注意只匹配键名,不碰值内容
最常被忽略的一点:脱敏不是“不打印”,而是“在数据离开服务前就改掉”。query、header、body、response、日志、配置加载——每个出口都要单独控制,没有银弹,也没有全局钩子能一劳永逸。











