学习目标
学完本章你应该能够:
- 说清为什么
zap.Hooks做不了脱敏,以及"自定义zapcore.Core“才是正确入口。 - 用装饰器模式包装原生 Core,在日志写入前拦截并改写所有 Field。
- 设计"Key 名匹配 + 值正则匹配"双重脱敏规则,覆盖显式敏感字段与隐式敏感值。
- 讲清脱敏实现里的并发安全与性能要点,避免高并发下数据竞争。
- 判断何时需要"深度脱敏”,并权衡反射带来的性能损耗是否可接受。
前置知识:
- Go 基础、接口与装饰器模式
- Uber Zap 日志库的基本用法(
zapcore.Core、zap.Field) - 一点正则(
regexp)与反射(reflect)常识
本章你会动手做的事:
- 写一个
DesensitizeCore,把zap.String("mobile", "13800138000")自动变成138****8000。 - 故意"手贱"直接改原 Field 切片,在并发下跑出数据竞争,再改回"先拷贝再改",体会区别。
- 在
LogConfig里加一个Desensitize开关,验证脱敏可一键关闭/开启。
敏感字段自动脱敏(Zap 自定义 Core 方案)
类比:日志脱敏就像快递面单上的手机号打码——原本
13800138000完整印在单子上,谁捡到都能看到;脱敏后变成138****8000,人能认出是"谁的号段"、却拿不到完整号码。生产日志里满屏的手机号、身份证、token,就是一张张没打码的面单,合规和安全的底线要求我们必须在"打印"这最后一刻遮住它们。
1、方案选型与核心原理
Zap 原生 zap.Hooks 只能获取日志元信息(级别、消息、调用栈),无法拦截和修改结构化 Fields,因此生产级脱敏必须通过自定义 zapcore.Core 实现。
核心思路:
- 用装饰器模式包装原生 Core,在日志真正写入前拦截所有 Field
- 采用Key 名匹配 + 值正则匹配双重脱敏规则,覆盖显式敏感字段和隐式敏感值
- 默认仅处理顶层 String 字段,避免递归反射损耗性能;深度脱敏作为可选项
- 先拷贝 Field 切片再修改,彻底避免并发写冲突
脱敏发生在日志"真正落盘"之前,整条链路如下:业务调用 logger.Info(...) 产生的 Entry 与 Fields 先经过我们包装的 DesensitizeCore,改写完敏感值后再交给底层真正的 Core 写出。
flowchart LR
A["业务代码
logger.Info(msg, fields)"] --> B["DesensitizeCore.Write"]
B --> C{"脱敏开关?
enable=true"}
C -->|是| D["doDesensitize
改写敏感 Field"]
C -->|否| E[原样透传]
D --> F["底层 Core 写出
文件/控制台"]
E --> F2、预定义脱敏规则与工具函数
先统一脱敏规则、预编译正则,保证全公司格式一致,同时最大化性能。
import (
"regexp"
"strings"
"go.uber.org/zap/zapcore"
)
// 预编译正则:全局只编译一次,避免性能损耗
var (
// 手机号正则(11位、1开头)
mobileRegex = regexp.MustCompile(`^1[3-9]\d{9}$`)
// 18位身份证号
idCardRegex = regexp.MustCompile(`^\d{17}[\dXx]$`)
// 银行卡号(16-19位数字)
bankCardRegex = regexp.MustCompile(`^\d{16,19}$`)
// 邮箱正则
emailRegex = regexp.MustCompile(`^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}$`)
)
// 敏感Key集合:O(1)匹配,命中即全脱敏
var sensitiveKeyMap = map[string]struct{}{
"password": {},
"pwd": {},
"token": {},
"access_token": {},
"secret": {},
"secret_key": {},
"authorization":{},
"cookie": {},
"mobile": {},
"phone": {},
"id_card": {},
"idcard": {},
"bank_card": {},
"email": {},
"id_no": {},
}
// 脱敏处理函数
func desensitizeValue(key, value string) string {
keyLower := strings.ToLower(key)
// 1. Key命中敏感集合:按类型脱敏
if _, ok := sensitiveKeyMap[keyLower]; ok {
switch {
case mobileRegex.MatchString(value):
return value[:3] + "****" + value[7:]
case idCardRegex.MatchString(value):
return value[:6] + "********" + value[14:]
case bankCardRegex.MatchString(value):
return value[:4] + "********" + value[len(value)-4:]
case emailRegex.MatchString(value):
parts := strings.Split(value, "@")
if len(parts) == 2 {
prefix := parts[0]
if len(prefix) <= 1 {
return "*@" + parts[1]
}
return prefix[:1] + "****@" + parts[1]
}
}
// 密码、Token等非结构化敏感值:全掩码
return "***"
}
// 2. Key未命中:值正则兜底匹配(防止漏脱敏)
switch {
case mobileRegex.MatchString(value):
return value[:3] + "****" + value[7:]
case idCardRegex.MatchString(value):
return value[:6] + "********" + value[14:]
}
return value
}
⚠️ 新手必踩的坑:
value[:7]这类切片在值太短时直接panic。上面的正则已经保证了手机号 11 位、身份证 18 位,所以value[7:]是安全的;但如果你要脱敏的字段长度不确定,务必先判断len(value),否则线上一个超短字符串就能把日志协程打挂。
3、自定义脱敏 Core 完整实现
脱敏 Core 通过装饰器模式套在原生 Core 外面。它复用 zapcore.Core 接口,所以底层完全无感知。判定逻辑是"Key 命中优先,值正则兜底",流程如下:
flowchart TD
A[拿到 Field] --> B{是 String 类型?}
B -->|否| Z[原样保留]
B -->|是| C{Key 在敏感集合?}
C -->|是| D[按类型脱敏
手机/身份证/银行卡/邮箱/全掩码]
C -->|否| E{值匹配手机/身份证正则?}
E -->|是| F[对值兜底脱敏]
E -->|否| Z
D --> G[返回脱敏后 Field]
F --> G// DesensitizeCore 脱敏Core装饰器
type DesensitizeCore struct {
zapcore.Core
enable bool // 脱敏开关
}
// With 继承字段时保持包装
func (c *DesensitizeCore) With(fields []zapcore.Field) zapcore.Core {
if !c.enable {
return c.Core.With(fields)
}
// 先脱敏再传给底层Core
desensitizedFields := c.doDesensitize(fields)
return &DesensitizeCore{
Core: c.Core.With(desensitizedFields),
enable: c.enable,
}
}
// Check 委托给底层Core判断
func (c *DesensitizeCore) Check(entry zapcore.Entry, ce *zapcore.CheckedEntry) *zapcore.CheckedEntry {
if c.Enabled(entry.Level) {
return ce.AddCore(entry, c)
}
return ce
}
// Write 核心:写入前执行脱敏
func (c *DesensitizeCore) Write(entry zapcore.Entry, fields []zapcore.Field) error {
if !c.enable {
return c.Core.Write(entry, fields)
}
// 必须拷贝切片,禁止修改原切片,避免并发数据竞争
desensitizedFields := c.doDesensitize(fields)
return c.Core.Write(entry, desensitizedFields)
}
// 执行脱敏:仅处理String类型顶层字段,兼顾性能与合规
func (c *DesensitizeCore) doDesensitize(fields []zapcore.Field) []zapcore.Field {
if len(fields) == 0 {
return fields
}
// 拷贝切片,不污染原数据
newFields := make([]zapcore.Field, len(fields))
copy(newFields, fields)
for i := range newFields {
field := &newFields[i]
// 仅处理String类型字段,跳过数字、布尔等
if field.Type == zapcore.StringType {
field.String = desensitizeValue(field.Key, field.String)
}
}
return newFields
}
4、集成到现有日志构建链路
在原 buildMultiCores 函数中,对每个 Core 统一包装一层脱敏装饰器,通过配置开关控制。
// LogConfig 新增脱敏开关
type LogConfig struct {
// ... 原有字段保持不变
Desensitize bool // 是否开启敏感字段脱敏
}
func buildMultiCores(cfg *LogConfig, encoder zapcore.Encoder, atomicLevel zap.AtomicLevel) ([]zapcore.Core, error) {
var cores []zapcore.Core
// 1. 主日志Core(原有逻辑不变)
mainSyncer, _ := buildRotateSyncer(...)
mainLevelEnabler := zap.LevelEnablerFunc(func(l zapcore.Level) bool {
return atomicLevel.Enabled(l) && l < zap.ErrorLevel
})
mainCore := zapcore.NewCore(encoder, mainSyncer, mainLevelEnabler)
samplingMainCore := zapcore.NewSamplerWithOptions(mainCore, time.Second, 100, 100)
// 包装脱敏Core
if cfg.Desensitize {
samplingMainCore = &DesensitizeCore{Core: samplingMainCore, enable: true}
}
cores = append(cores, samplingMainCore)
// 2. 错误日志Core(原有逻辑不变)
errorSyncer, _ := buildRotateSyncer(...)
errorLevelEnabler := zap.LevelEnablerFunc(func(l zapcore.Level) bool {
return l >= zap.ErrorLevel
})
errorCore := zapcore.NewCore(encoder, errorSyncer, errorLevelEnabler)
// 错误日志同样脱敏
if cfg.Desensitize {
errorCore = &DesensitizeCore{Core: errorCore, enable: true}
}
cores = append(cores, errorCore)
// 3. 控制台Core
if cfg.Console {
consoleCore := zapcore.NewCore(encoder, zapcore.AddSync(os.Stdout), atomicLevel)
if cfg.Desensitize {
consoleCore = &DesensitizeCore{Core: consoleCore, enable: true}
}
cores = append(cores, consoleCore)
}
return cores, nil
}
5、进阶:结构化对象深度脱敏
如果需要对 zap.Any("user", user) 传入的结构体 / Map 做深度递归脱敏,可基于反射扩展 doDesensitize 方法,但需注意:
- 反射会带来 3~5 倍性能损耗,高 QPS 服务不推荐开启
- 建议仅对明确标注
log:"desensitize"tag 的字段做脱敏 - 更优实践:业务层在打印前手动处理敏感字段,日志层仅做兜底校验
6、脱敏核心注意事项
- 并发安全:绝对禁止直接修改传入的 Field 切片,必须拷贝后再修改,否则高并发下会出现数据竞争、日志乱码。
- 性能优先:正则预编译、Key 用 Map 查找、仅处理 String 字段,三者缺一不可,避免脱敏成为性能瓶颈。
- 白名单机制:
trace_id、service、span_id等排障核心字段禁止加入脱敏规则,必须保障链路可追踪。 - 规则统一:全公司统一敏感 Key 集合与脱敏格式,避免日志平台检索、告警规则失效。
⚠️ 新手必踩的坑:直接改原切片破坏并发安全。
Write/With收到的fields切片可能被多处复用,若原地修改field.String,高并发下另一个正在写同一条日志的协程会读到被你改掉的值,甚至触发 data race 检测直接崩溃。一定要make+copy出一份新的再改。
自测题与动手练习
自测题(合上书能答出来,才算懂):
- 为什么
zap.Hooks做不了字段级脱敏?自定义zapcore.Core相比 Hook 强在哪? - “Key 名匹配 + 值正则匹配"双重规则分别解决什么问题?只保留一种会有什么漏脱敏的隐患?
doDesensitize里为什么要make+copy出新的切片,而不是直接改原fields?- 为什么默认只处理顶层 String 字段?深度脱敏(反射递归结构体)的代价是什么?
- 为什么
trace_id、span_id这类字段不能进脱敏规则?把这俩脱敏了会有什么后果?
动手练习(建议真做一遍):
- 把
DesensitizeCore接进你的 Zap logger,打印zap.String("mobile", "13800138000")和zap.String("token", "abc.def.ghi"),确认前者变138****8000、后者变***。 - 写一个并发测试(
go test -race),在Write里直接改原切片(去掉copy),观察-race是否报数据竞争;再改回拷贝写法,确认竞争消失。 - 给
LogConfig加Desensitize开关,分别用true/false启动,对比同一行日志的输出差异,验证脱敏可一键开关。
本章小结
- 入口选对是关键:Zap 字段级脱敏只能走自定义
zapcore.Core,zap.Hooks拿不到 Field,做不了。 - 装饰器模式无痛接入:把
DesensitizeCore套在原生 Core 外,底层完全无感知;用enable开关即可一键开关。 - 双重规则兜底:Key 名匹配覆盖"显式敏感字段”,值正则匹配兜底"隐式敏感值",两者配合避免漏脱敏。
- 并发与性能是底线:必须拷贝切片再改、正则预编译、Key 用 Map、只处理顶层 String,脱敏不能成为瓶颈也不能引发 data race。
- 深度脱敏按需开启,反射有 3~5 倍损耗;更优做法是在业务层先处理敏感字段,日志层只做兜底。