日志脱敏双链路统一优化:从两套枚举到单一规则源,兼顾安全与可维护性

一、背景:为什么要做日志脱敏统一改造

在金融、政企类系统中,日志脱敏是数据安全合规的基础要求。业务系统的日志通常包含两类敏感数据载体:

  1. Java 实体对象:接口出参、业务DTO等,通常以JSON形式打印到日志
  2. 接口报文:对外交互的XML格式报文,以字符串形式直接打印

很多项目会为两类场景分别实现脱敏逻辑,很容易出现规则分散、定义重复、逻辑不一致的问题。本文就基于一套典型的Jackson+Logback脱敏实现,讲解如何将两套独立的脱敏枚举体系收敛为统一规则源,同时优化异常处理,提升安全性与可维护性。

二、原有实现的核心痛点

原有方案同时支持「Jackson序列化实体脱敏」和「XML正则报文脱敏」两条链路,但采用了两套独立的枚举体系:

  • EnumUtils.DesensitizeType:仅作为类型标记,脱敏逻辑以switch硬编码散落在序列化器中
  • DesensitizeRuleEnum:内置脱敏函数,专供XML报文脱敏使用

存在的核心问题

  1. 维护成本高:新增一种脱敏类型,需要同时修改两处枚举、两处逻辑实现,容易遗漏
  2. 逻辑一致性差:两条链路的判空、trim、脱敏算法可能存在差异,同一字段在不同日志形态下脱敏结果不一致
  3. 异常处理粗糙:序列化异常、反射异常存在静默失败或敏感数据泄露风险
  4. 扩展性弱:后续新增响应脱敏、参数校验等场景,需要重复定义规则

三、优化核心思路

收敛为一套「类型+算法一体化」的枚举,作为全链路唯一的脱敏规则载体。

  • 注解层直接绑定统一枚举,消除中间标记类型
  • 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 序列化失败时,原代码直接返回原始对象,导致脱敏完全失效,敏感数据明文输出到日志,违反数据安全的底线原则。

优化方案:

  1. 捕获异常后,打印错误日志,记录异常类型与类名,便于线上排查
  2. 返回固定安全占位符[数据序列化异常,已隐藏],绝对不输出原始敏感数据
  3. 保证异常场景下日志系统正常运行,不阻塞业务主流程

优化点2:反射查找注解异常 ------ 从「静默失败」到「可观测」

原问题: 反射获取字段注解时,异常被完全吞掉,没有任何日志输出。如果注解不生效,开发人员无法快速定位是反射失败、字段名错误还是父类层级问题,排查成本极高。

优化方案:

  1. 增加debug级别日志,记录失败的类名、字段名与异常栈
  2. 不中断主流程,继续递归查找父类,保证功能可用性
  3. 生产环境可关闭debug日志,不影响性能;排查问题时开启即可快速定位根因

六、改造收益与最佳实践

改造收益

  1. 规则唯一,维护成本降低50% :新增脱敏类型仅需在DesensitizeRuleEnum中添加一行,所有链路自动生效
  2. 行为一致:判空、trim、脱敏算法全链路统一,避免同一数据在不同日志形态下脱敏结果不一致
  3. 安全性提升:异常场景兜底保护,杜绝序列化失败导致的敏感数据泄露风险
  4. 扩展性增强:后续新增接口响应脱敏、参数校验等场景,可直接复用该枚举体系,无需重复建设

最佳实践建议

  1. 包名配置化 :将BASE_PACKAGE、占位符文本等配置到配置文件,支持多环境灵活调整
  2. 增加全局开关:通过配置项控制脱敏总开关,开发环境可关闭以提升调试效率
  3. 单元测试覆盖:针对每种脱敏规则、异常场景编写单元测试,保障迭代不回归
  4. XML报文识别优化:硬编码截取交易码的方式兼容性差,建议改用正则提取报文头中的交易类型

七、总结

日志脱敏看似是个小功能,但在复杂业务系统中很容易演变成多套实现各自为政的局面。通过「统一规则枚举 + 策略模式 + 异常兜底」的改造,既解决了维护性与一致性问题,又补齐了安全短板,是投入产出比很高的架构优化。

相关推荐
Dovis(誓平步青云)9 小时前
启元 T1 Pro 人机相声搭档:语音-触摸双通道的启元机器人节奏引擎设计
开发语言·前端·javascript·microsoft·交互
宝桥南山11 小时前
.NET 11 - 尝试一下Blazor中的新特性SupplyParameterFromTempData
microsoft·微软·c#·asp.net·.net·.netcore
Liangwei Lin1 天前
Exchange Server 2019 部署手册
microsoft
ms365copilot2 天前
Microsoft 365 Copilot跨工作资料整理
microsoft·copilot
中二骚年2 天前
Windows 安全中心 / 防火墙故障,calc和Microsoft Store,终端(管理员)无法正常打开。 原因 + 修复方案
windows·安全·microsoft
kk_net88993 天前
微软常用运行库合集免费下载安装教程(附运行库安装包)微软运行库安装图文教程
其他·microsoft
GlobalInfo3 天前
2026年全球机器人用超平执行器市场研究报告:市场规模达7.51亿美元,人形机器人商业化与轻量化运动控制需求推动行业增长
大数据·人工智能·数码相机·microsoft·机器人
编码者卢布3 天前
【Azure Container Apps】为什么通过 Diagnostic Settings 发送到 Log Analytics 中的日志会延迟几分钟?
microsoft·azure
0x00073 天前
微软intelligent-terminal:Windows 的官方内置Agent Cli
microsoft