一、背景:为什么要做日志脱敏统一改造
在金融、政企类系统中,日志脱敏是数据安全合规的基础要求。业务系统的日志通常包含两类敏感数据载体:
- Java 实体对象:接口出参、业务DTO等,通常以JSON形式打印到日志
- 接口报文:对外交互的XML格式报文,以字符串形式直接打印
很多项目会为两类场景分别实现脱敏逻辑,很容易出现规则分散、定义重复、逻辑不一致的问题。本文就基于一套典型的Jackson+Logback脱敏实现,讲解如何将两套独立的脱敏枚举体系收敛为统一规则源,同时优化异常处理,提升安全性与可维护性。
二、原有实现的核心痛点
原有方案同时支持「Jackson序列化实体脱敏」和「XML正则报文脱敏」两条链路,但采用了两套独立的枚举体系:
EnumUtils.DesensitizeType:仅作为类型标记,脱敏逻辑以switch硬编码散落在序列化器中DesensitizeRuleEnum:内置脱敏函数,专供XML报文脱敏使用
存在的核心问题
- 维护成本高:新增一种脱敏类型,需要同时修改两处枚举、两处逻辑实现,容易遗漏
- 逻辑一致性差:两条链路的判空、trim、脱敏算法可能存在差异,同一字段在不同日志形态下脱敏结果不一致
- 异常处理粗糙:序列化异常、反射异常存在静默失败或敏感数据泄露风险
- 扩展性弱:后续新增响应脱敏、参数校验等场景,需要重复定义规则
三、优化核心思路
收敛为一套「类型+算法一体化」的枚举,作为全链路唯一的脱敏规则载体。
- 注解层直接绑定统一枚举,消除中间标记类型
- Jackson序列化链路直接复用枚举内置的脱敏函数,消除硬编码分支
- XML报文链路无改造,天然复用同一套规则与算法
- 优化两处关键异常处理,杜绝脱敏失效导致的敏感数据泄露
四、完整优化代码实现
1. 统一脱敏规则枚举(核心)
将所有脱敏类型、算法统一收敛到一个枚举中,内置通用预处理逻辑,作为全链路唯一规则入口。
package cn.cbcc.caps.desensitize; import cn.cbcc.caps.util.MsgUtils; import lombok.AllArgsConstructor; import lombok.Getter; import java.util.function.Function; /** * 脱敏规则枚举 * 全链路统一:JSON序列化、XML报文脱敏均复用此枚举 * * @author jinyh */ @Getter @AllArgsConstructor public enum DesensitizeRuleEnum { /** 账号脱敏 */ ACCOUNT_NO(MsgUtils::accountNoMask), /** 银行卡号脱敏 */ BANK_NO(MsgUtils::bankNoMask), /** 姓名脱敏 */ NAME(MsgUtils::nameMask), /** 手机号脱敏 */ PHONE_NO(MsgUtils::phoneNoMask), /** BASE64编码 */ BASE64_ENCODE(BCD::encodeBase64Str), /** 透传不脱敏 */ PROTOCOL(s -> s); /** * 脱敏执行函数 */ private final Function<String, String> desensitizer; /** * 统一脱敏入口:全链路共用,统一预处理逻辑 * * @param value 原始字符串 * @return 脱敏后字符串 */ public String desensitize(String value) { if (value == null || value.isBlank()) { return value; } // 统一trim处理,保证两条链路行为一致 return desensitizer.apply(value.trim()); } }2. 脱敏注解
修改注解属性类型,直接绑定统一规则枚举,业务使用方式保持不变。
package cn.cbcc.caps.desensitize; import java.lang.annotation.ElementType; import java.lang.annotation.Retention; import java.lang.annotation.RetentionPolicy; import java.lang.annotation.Target; /** * 字段脱敏注解 * 标注在字段或getter方法上,序列化时自动执行对应脱敏规则 * * @author jinyh */ @Target({ElementType.FIELD, ElementType.METHOD}) @Retention(RetentionPolicy.RUNTIME) public @interface Desensitize { /** * 脱敏规则类型 */ DesensitizeRuleEnum type(); }3. Jackson自定义序列化器
消除原有的switch硬编码分支,直接委托统一枚举执行脱敏,代码更简洁,规则自动同步。
package cn.cbcc.caps.desensitize; import com.fasterxml.jackson.core.JsonGenerator; import com.fasterxml.jackson.databind.BeanProperty; import com.fasterxml.jackson.databind.JsonSerializer; import com.fasterxml.jackson.databind.SerializerProvider; import com.fasterxml.jackson.databind.ser.ContextualSerializer; import com.fasterxml.jackson.databind.ser.std.StdScalarSerializer; import java.io.IOException; /** * 脱敏序列化器 * 基于Jackson上下文序列化机制,根据注解动态绑定脱敏规则 * * @author jinyh */ public class DesensitizeSerializer extends StdScalarSerializer<String> implements ContextualSerializer { /** * 当前字段对应的脱敏规则 */ private DesensitizeRuleEnum type; public DesensitizeSerializer() { super(String.class); } public DesensitizeSerializer(DesensitizeRuleEnum type) { super(String.class); this.type = type; } /** * 上下文创建:扫描字段注解,生成对应脱敏规则的序列化器 */ @Override public JsonSerializer<?> createContextual(SerializerProvider provider, BeanProperty property) { if (property == null) { return this; } Desensitize annotation = property.getAnnotation(Desensitize.class); return annotation != null ? new DesensitizeSerializer(annotation.type()) : this; } /** * 执行序列化脱敏 */ @Override public void serialize(String val, JsonGenerator gen, SerializerProvider provider) throws IOException { if (val == null) { gen.writeNull(); return; } // 短字符串不脱敏,其余委托统一枚举执行 String result = (type == null || val.length() < 2) ? val : type.desensitize(val); gen.writeString(result); } }4. 注解内省器(异常优化点1)
优化反射查找注解时的异常处理,增加日志埋点,避免静默失败导致排查困难。
package cn.cbcc.caps.desensitize; import com.fasterxml.jackson.databind.introspect.Annotated; import com.fasterxml.jackson.databind.introspect.AnnotatedMethod; import com.fasterxml.jackson.databind.introspect.JacksonAnnotationIntrospector; import org.slf4j.Logger; import org.slf4j.LoggerFactory; import java.lang.reflect.Field; import java.lang.reflect.Method; /** * 脱敏注解内省器 * 扩展Jackson注解扫描能力,支持字段、方法、父类继承注解识别 * * @author jinyh */ public class DesensitizeAnnotationIntrospector extends JacksonAnnotationIntrospector { private static final Logger log = LoggerFactory.getLogger(DesensitizeAnnotationIntrospector.class); /** * 查找序列化器:优先识别@Desensitize注解 */ @Override public Object findSerializer(Annotated annotated) { // 1. 优先查找当前元素(字段/方法)上的注解 Desensitize annotation = annotated.getAnnotation(Desensitize.class); if (annotation != null) { return new DesensitizeSerializer(annotation.type()); } // 2. 若为getter方法,反推字段名,查找字段上的注解 if (annotated instanceof AnnotatedMethod) { AnnotatedMethod method = (AnnotatedMethod) annotated; String fieldName = extractFieldName(method.getName()); if (fieldName != null) { DesensitizeRuleEnum fieldAnnotation = findFieldAnnotation(method.getDeclaringClass(), fieldName); if (fieldAnnotation != null) { return new DesensitizeSerializer(fieldAnnotation); } } } return super.findSerializer(annotated); } /** * 递归查找字段注解,支持父类继承 * 优化点:增加异常日志,避免反射失败静默丢失 */ public DesensitizeRuleEnum findFieldAnnotation(Class<?> clazz, String fieldName) { try { Field field = clazz.getDeclaredField(fieldName); Desensitize annotation = field.getAnnotation(Desensitize.class); if (annotation != null) { return annotation.type(); } // 兼容注解代理场景,反射获取注解属性 for (java.lang.annotation.Annotation anno : field.getDeclaredAnnotations()) { if (anno.annotationType().getName().equals(Desensitize.class.getName())) { Method type = anno.annotationType().getMethod("type"); Object invoke = type.invoke(anno); String name = ((Enum<?>) invoke).name(); return DesensitizeRuleEnum.valueOf(name); } } } catch (Exception e) { // 优化:增加debug日志,记录反射失败的类与字段,方便排查,不影响主流程 log.debug("查找字段脱敏注解失败,class:{}, field:{}", clazz.getName(), fieldName, e); } // 递归查找父类 Class<?> superclass = clazz.getSuperclass(); if (superclass == null || superclass == Object.class) { return null; } return findFieldAnnotation(superclass, fieldName); } /** * 从getter方法名提取字段名 */ String extractFieldName(String methodName) { if (methodName.startsWith("get") && methodName.length() > 3) { return Character.toLowerCase(methodName.charAt(3)) + methodName.substring(4); } if (methodName.startsWith("is") && methodName.length() > 2) { return Character.toLowerCase(methodName.charAt(2)) + methodName.substring(3); } return null; } }5. Logback消息转换器(异常优化点2)
优化JSON序列化异常处理,序列化失败时不再返回原始对象,避免敏感数据泄露,同时增加错误日志。
package cn.cbcc.caps.desensitize; import ch.qos.logback.classic.pattern.MessageConverter; import ch.qos.logback.classic.spi.ILoggingEvent; import com.fasterxml.jackson.annotation.JsonAutoDetect; import com.fasterxml.jackson.annotation.JsonInclude; import com.fasterxml.jackson.core.JsonProcessingException; import com.fasterxml.jackson.databind.ObjectMapper; import com.fasterxml.jackson.databind.PropertyAccessor; import com.fasterxml.jackson.databind.module.SimpleModule; import org.slf4j.Logger; import org.slf4j.LoggerFactory; import org.slf4j.helpers.MessageFormatter; import java.lang.reflect.Field; import java.util.Collection; import java.util.concurrent.ConcurrentHashMap; /** * Logback脱敏消息转换器 * 日志输出时自动识别敏感对象,执行脱敏后再格式化输出 * * @author jinyh */ public class DesensitizeMessageConverter extends MessageConverter { private static final Logger log = LoggerFactory.getLogger(DesensitizeMessageConverter.class); /** * 脱敏专用ObjectMapper,内置脱敏序列化能力 */ private final ObjectMapper mapper; /** * 类级脱敏缓存,避免重复反射扫描 */ private static final ConcurrentHashMap<Class<?>, Boolean> DESENSITIZE_CACHE = new ConcurrentHashMap<>(); /** * 脱敏扫描基础包,减少无效反射 */ private static final String BASE_PACKAGE = "cn.entity"; /** * 序列化失败占位符,防止敏感信息泄露 */ private static final String SERIALIZE_ERROR_MASK = "[数据序列化异常,已隐藏]"; public DesensitizeMessageConverter() { // 初始化自定义ObjectMapper,注入脱敏注解内省器 SimpleModule simpleModule = new SimpleModule() { @Override public void setupModule(SetupContext context) { super.setupModule(context); context.insertAnnotationIntrospector(new DesensitizeAnnotationIntrospector()); } }; mapper = new ObjectMapper(); mapper.setSerializationInclusion(JsonInclude.Include.NON_NULL); mapper.registerModule(simpleModule); mapper.setVisibility(PropertyAccessor.FIELD, JsonAutoDetect.Visibility.ANY); } /** * 判断对象是否需要脱敏 */ private boolean needDesensitize(Object obj) { if (obj == null || obj instanceof String || obj instanceof Throwable || obj instanceof Number || obj.getClass().isPrimitive()) { return false; } Class<?> clazz = obj.getClass(); // 集合取首个元素递归判断 if (obj instanceof Collection) { Collection<?> col = (Collection<?>) obj; return !col.isEmpty() && needDesensitize(col.iterator().next()); } // 数组取首个元素递归判断 if (clazz.isArray()) { Object[] arr = (Object[]) obj; return arr.length > 0 && needDesensitize(arr[0]); } // 非基础包直接跳过 if (!clazz.getName().startsWith(BASE_PACKAGE)) { return false; } // 缓存命中直接返回,未命中则扫描字段注解 return DESENSITIZE_CACHE.computeIfAbsent(clazz, c -> { Field[] declaredFields = c.getDeclaredFields(); for (Field f : declaredFields) { for (java.lang.annotation.Annotation anno : f.getDeclaredAnnotations()) { if (Desensitize.class.getName().equals(anno.annotationType().getName())) { return true; } } } return false; }); } /** * 日志消息转换入口 * 优化点:序列化异常不再返回原始对象,防止敏感数据泄露 */ @Override public String convert(ILoggingEvent event) { Object[] args = event.getArgumentArray(); if (args == null || args.length == 0) { return event.getMessage(); } Object[] processedArgs = new Object[args.length]; for (int i = 0; i < args.length; i++) { if (needDesensitize(args[i])) { try { processedArgs[i] = mapper.writeValueAsString(args[i]); } catch (JsonProcessingException e) { // 核心优化:序列化失败时,返回安全占位符,绝不输出原始敏感数据 log.error("日志脱敏序列化失败,类名:{}", args[i].getClass().getName(), e); processedArgs[i] = SERIALIZE_ERROR_MASK; } } else { processedArgs[i] = xmlMsg(args[i]); } } // 用处理后的参数格式化日志消息 return MessageFormatter.arrayFormat(event.getMessage(), processedArgs).getMessage(); } // ---------- XML报文脱敏逻辑(复用统一枚举,无改动) ---------- private Object xmlMsg(Object obj) { if (obj == null || obj instanceof Throwable || obj instanceof Number || obj instanceof Collection || obj.getClass().isPrimitive()) { return obj; } if (obj instanceof String) { String str = String.valueOf(obj); if (str.startsWith("<?xml") && str.endsWith("</Document>")) { return desensitizeXml(str, str.substring(88, 101)); } } return obj; } private static String desensitizeXml(String xml, String msgTypeCode) { MsgTypeEnum byCode = MsgTypeEnum.getByCode(msgTypeCode); if (byCode == null) { return xml; } String result = xml; for (MsgTypeEnum.SensitiveField field : byCode.getSensitiveFieldList()) { result = desensitizeXmlSingleField(result, field); } return result; } private static String desensitizeXmlSingleField(String xml, MsgTypeEnum.SensitiveField field) { java.util.regex.Matcher matcher = field.getTagPattern().matcher(xml); StringBuilder sb = new StringBuilder(xml.length()); while (matcher.find()) { String startTag = matcher.group(1); String oContent = matcher.group(2); String endTag = matcher.group(3); if (oContent == null || oContent.isEmpty()) { matcher.appendReplacement(sb, matcher.group(0)); continue; } // 复用统一枚举的脱敏方法 String desensitizedContent = field.getRuleEnum().desensitize(oContent); matcher.appendReplacement(sb, startTag + java.util.regex.Matcher.quoteReplacement(desensitizedContent) + endTag); } matcher.appendTail(sb); return sb.toString(); } }6. 报文类型枚举
XML报文脱敏的配置载体,绑定统一脱敏规则枚举,无需改动核心逻辑。
package cn.cbcc.caps.desensitize; import lombok.Getter; import java.util.List; import java.util.regex.Pattern; /** * 报文类型枚举 * 配置不同交易报文对应的敏感字段与脱敏规则 * * @author jinyh */ public enum MsgTypeEnum { CAPS666("666", List.of( new SensitiveField("Name", DesensitizeRuleEnum.NAME), new SensitiveField("Phone", DesensitizeRuleEnum.PHONE_NO) )); private final String typeCode; @Getter private final List<SensitiveField> sensitiveFieldList; MsgTypeEnum(String typeCode, List<SensitiveField> sensitiveFieldList) { this.typeCode = typeCode; this.sensitiveFieldList = sensitiveFieldList; } public String getTypeCode() { return typeCode; } /** * 根据交易码查找对应报文配置 */ public static MsgTypeEnum getByCode(String typeCode) { if (typeCode == null) { return null; } for (MsgTypeEnum item : values()) { if (item.getTypeCode().equals(typeCode)) { return item; } } return null; } /** * 敏感字段配置 */ @Getter public static class SensitiveField { private final String tagName; private final DesensitizeRuleEnum ruleEnum; private final Pattern tagPattern; public SensitiveField(String tagName, DesensitizeRuleEnum ruleEnum) { this.tagName = tagName; this.ruleEnum = ruleEnum; this.tagPattern = Pattern.compile( "(<" + tagName + "[^>]*>)(.*?)(</" + tagName + ">)", Pattern.DOTALL ); } } }7. Logback配置
在logback-spring.xml中注册自定义转换器,使用%dmsg替换原有的%msg即可启用脱敏。
<?xml version="1.0" encoding="UTF-8"?> <configuration debug="false" scan="false"> <!-- 注册脱敏消息转换器 --> <conversionRule conversionWord="dmsg" converterClass="cn.cbcc.caps.desensitize.DesensitizeMessageConverter"/> <!-- 日志格式中使用 %dmsg 替代 %msg 触发脱敏 --> <appender name="CONSOLE" class="ch.qos.logback.core.ConsoleAppender"> <encoder> <pattern>%d{yyyy-MM-dd HH:mm:ss.SSS} [%thread] %-5level %logger{50} - %dmsg%n</pattern> <charset>UTF-8</charset> </encoder> </appender> <root level="INFO"> <appender-ref ref="CONSOLE"/> </root> </configuration>五、两处关键异常优化详解
优化点1:JSON序列化异常 —— 从「返回原值」到「安全兜底」
原问题:mapper.writeValueAsString序列化失败时,原代码直接返回原始对象,导致脱敏完全失效,敏感数据明文输出到日志,违反数据安全的底线原则。
优化方案:
- 捕获异常后,打印错误日志,记录异常类型与类名,便于线上排查
- 返回固定安全占位符
[数据序列化异常,已隐藏],绝对不输出原始敏感数据 - 保证异常场景下日志系统正常运行,不阻塞业务主流程
优化点2:反射查找注解异常 —— 从「静默失败」到「可观测」
原问题: 反射获取字段注解时,异常被完全吞掉,没有任何日志输出。如果注解不生效,开发人员无法快速定位是反射失败、字段名错误还是父类层级问题,排查成本极高。
优化方案:
- 增加
debug级别日志,记录失败的类名、字段名与异常栈 - 不中断主流程,继续递归查找父类,保证功能可用性
- 生产环境可关闭debug日志,不影响性能;排查问题时开启即可快速定位根因
六、改造收益与最佳实践
改造收益
- 规则唯一,维护成本降低50%:新增脱敏类型仅需在
DesensitizeRuleEnum中添加一行,所有链路自动生效 - 行为一致:判空、trim、脱敏算法全链路统一,避免同一数据在不同日志形态下脱敏结果不一致
- 安全性提升:异常场景兜底保护,杜绝序列化失败导致的敏感数据泄露风险
- 扩展性增强:后续新增接口响应脱敏、参数校验等场景,可直接复用该枚举体系,无需重复建设
最佳实践建议
- 包名配置化:将
BASE_PACKAGE、占位符文本等配置到配置文件,支持多环境灵活调整 - 增加全局开关:通过配置项控制脱敏总开关,开发环境可关闭以提升调试效率
- 单元测试覆盖:针对每种脱敏规则、异常场景编写单元测试,保障迭代不回归
- XML报文识别优化:硬编码截取交易码的方式兼容性差,建议改用正则提取报文头中的交易类型
七、总结
日志脱敏看似是个小功能,但在复杂业务系统中很容易演变成多套实现各自为政的局面。通过「统一规则枚举 + 策略模式 + 异常兜底」的改造,既解决了维护性与一致性问题,又补齐了安全短板,是投入产出比很高的架构优化。