json.marshaler 接口是 go 字段级脱敏最稳入口,应避免正则替换或中间件读 response body;正确做法是结构体用指针接收器实现 marshaljson,配合别名类型防递归,并通过闭包捕获权限开关。

json.Marshaler 接口是 Go 里做字段级脱敏最稳、最可控的入口,别在 JSON 字符串上正则替换,也别指望中间件读 response body——那不是脱敏,是碰运气。
用 json.Marshaler 控制序列化输出,而不是改字符串
常见错误现象:json.Marshal 后对返回的 []byte 做 strings.ReplaceAll 或正则替换,结果把 URL 里的 "phone"、错误信息里的数字、甚至 JSON 的引号和转义都搞乱了;更糟的是嵌套结构体或 nil 指针直接 panic。
正确做法是让结构体自己决定怎么被序列化:
- 必须用指针接收器:
func (u *User) MarshalJSON() ([]byte, error),否则无法处理u.Profile.Phone这类嵌套字段或nil指针 - 防无限递归:内部定义别名类型
type Alias User,再用(*Alias)(u)转换,避免调用自身MarshalJSON - 脱敏值别设空字符串:如果字段带
json:",omitempty",脱敏后变空就会被整个丢掉;改用"*** **** ***"或"[REDACTED]" - 权限判断不能塞进方法签名:
json.Marshal不传context.Context,得靠闭包捕获开关变量,比如redactEnabled := isRedactEnabled(ctx)再在闭包里用
给敏感字段定义自定义类型 + UnmarshalJSON
适用于反序列化阶段就要脱敏的场景,比如从 HTTP 请求 Body 解析用户数据时,还没进业务逻辑,字段就该是掩码后的。
例如手机号字段不直接用 string,而是:
type Phone string
<p>func (p <em>Phone) UnmarshalJSON(data []byte) error {
var s string
if err := json.Unmarshal(data, &s); err != nil {
return err
}
if matched, _ := phoneRegex.MatchString(s); matched {
</em>p = Phone(s[:3] + "***<em>" + s[7:])
} else {
</em>p = Phone(s)
}
return nil
}
</p>
注意点:
- 接收器必须是指针:
func (p *Phone),Go 1.20+ 对非指针接收者调用UnmarshalJSON会静默失败 - 要显式处理
null:如果前端传"phone": null,json.Unmarshal(data, &s)会报错,得先判断bytes.Equal(data, []byte("null"))再跳过 - 别在
UnmarshalJSON里查 DB 或调外部服务——它在解码路径上,延迟和错误都会卡住整个请求
HTTP 响应脱敏必须包装 http.ResponseWriter,不能靠中间件后读
gin/echo 等框架默认不缓存响应体。c.Next() 执行完,WriteHeader 一发,字节就推到 TCP 连接了,中间件根本拿不到原始 JSON。
错误做法:c.Writer.Body.String()(多数框架没这个字段)、io.TeeReader 拦 request body(response 是单向写流,拦不住)。
正确做法:在 handler 执行前替换 c.Writer,实现自己的 ResponseWriter:
- 重写
Write([]byte)方法,只对Content-Type: application/json做处理 - 用
json.Decoder.Token()流式解析,匹配 key 路径(如user.phone),遇到目标字段就skip()并注入掩码,避免全量解码 - 别对所有 JSON 都解析——加个 header 开关,比如
X-Redact: true,只在需要时开 - 非 JSON 类型(
text/html、application/octet-stream)直通,不碰
别在日志里漏掉未脱敏字段
zap、zerolog 这些库不会识别敏感语义。你传 user 结构体进去,它就原样打;fmt.Printf("%+v", user) 更是裸奔。
关键不是“有没有脱敏逻辑”,而是“脱敏是否发生在日志构造之前”:
- 不要在
log.Info().Interface("user", u).Send()里传原始结构体,而要先调u.Redact()或redactUser(u) - panic 日志里打印的结构体,也要走同一套脱敏函数——尤其
recover()捕获后手动打日志时容易忽略 - SQL 日志、HTTP 错误响应里的
error字段、甚至第三方 SDK 的 debug 输出,都要单独检查是否含敏感字段 - 如果用了
log.With().Stack(),确保栈帧里没打印含 token/password 的局部变量
真正难的不是写一个 DesensitizePhone 函数,而是确保所有出口——JSON 响应、日志、panic、RPC 返回、甚至调试 pprof 的 trace——都经过同一套可控、可测、不破坏类型契约的脱敏路径。漏掉任意一个,前面写的都白搭。
golang免费学习笔记(深入):立即使用
在学习笔记中,你将探索golang的核心概念和高级技巧!











