基于MyBatis插件与注解实现声明式数据脱敏的完整方案

发布时间:2026/8/7 2:26:28
基于MyBatis插件与注解实现声明式数据脱敏的完整方案
1. 项目概述当数据安全遇上MyBatis的优雅解法最近在做一个金融后台项目对接的业务方对数据安全的要求近乎苛刻。每次联调对方都会反复确认“这个手机号在日志里能看到全量吗”“身份证号在数据库里是明文存储的吗” 这类问题。我们当然做了应用层的加解密但总有“漏网之鱼”——比如MyBatis打印的SQL日志里面可能就躺着完整的敏感数据再比如某些内部运营查询页面开发同学图方便直接用了全量字段的实体类一不小心就把敏感信息带到了前端。全表加解密性能损耗太大且对模糊查询不友好。在每一个查询方法里手动处理代码会变得臃肿不堪且容易遗漏。这时候我想到了MyBatis的插件机制。它就像潜伏在SQL执行生命周期里的“特工”我们可以在数据“进”写入和“出”查询的关键节点进行拦截和改造。结合自定义注解我们可以用一种非常声明式、无侵入的方式给需要脱敏的字段打上标记让插件自动完成脱敏逻辑。这不仅能满足安全审计要求还能极大地提升开发效率和代码的可维护性。今天我就来详细拆解一下如何从零开始打造一个属于自己的MyBatis数据脱敏插件。2. 核心设计思路与架构拆解2.1 为什么选择插件注解的方案在决定技术方案前我们评估过几种常见的脱敏方式。第一种是在Setter/Getter方法里处理这需要修改所有实体类侵入性强且无法覆盖SQL日志场景。第二种是使用AOP拦截Service层方法这能解决一部分问题但粒度较粗且对MyBatis内部执行的SQL结果集处理起来比较麻烦。第三种就是在数据库层面使用视图或函数但这将业务逻辑下沉到了数据库不利于维护和扩展。MyBatis插件Interceptor方案的优势在于它直接作用于MyBatis的核心执行流程。无论是查询结果映射ResultSetHandler、参数设置ParameterHandler还是SQL语句本身StatementHandler我们都能介入。这意味着我们可以在结果集映射时脱敏拦截查询结果根据注解将敏感字段内容替换为脱敏后的值如138****1234。在参数设置时加密拦截插入/更新操作的参数根据注解将明文加密后再存入数据库。在记录SQL日志时脱敏拦截执行的SQL语句将其中包含的敏感参数值替换为占位符或脱敏值避免日志泄露。而注解则是驱动这个插件的“配置元数据”。通过在实体类的字段上添加类似DataMasking(type MaskType.PHONE)的注解我们就清晰地声明了“这个字段是手机号需要按手机号规则脱敏”。插件读取这些注解然后执行相应的逻辑。这种声明式的方式将脱敏规则与业务代码完全解耦。2.2 插件执行的生命周期与拦截点选择MyBatis插件通过实现Interceptor接口并指定要拦截的方法签名来工作。MyBatis有四大核心对象可以拦截Executor执行器控制整个SQL执行流程包含update、query、commit等方法。StatementHandler数据库会话处理器负责编译SQL、设置参数、执行SQL。ParameterHandler参数处理器用于设置预编译SQL语句PreparedStatement的参数。ResultSetHandler结果集处理器负责将查询结果ResultSet映射成Java对象。对于数据脱敏我们的主要战场在ResultSetHandler和ParameterHandler。ResultSetHandler我们拦截其handleResultSets方法。在这个方法内部MyBatis已经通过JDBC拿到了数据库返回的原始ResultSet并正准备将其转换为我们在Mapper中指定的结果对象无论是单个实体、List还是Map。此时拦截我们可以遍历这些即将返回给调用方的对象根据字段上的注解修改其值实现查询结果的自动脱敏。ParameterHandler我们拦截其setParameters方法。当执行一条INSERT或UPDATE语句时MyBatis会调用此方法将Java对象中的属性值设置到SQL的占位符?上。此时拦截我们可以检查参数对象通常是实体对象的字段注解如果发现需要加密存储的字段就将其明文值加密成密文再用密文去设置SQL参数。这样就实现了落库加密。一个更常见且实用的场景是仅对**出库查询**的数据进行脱敏而入库数据保持明文或由其他加密服务负责。这样既能满足前端展示和日志安全的需求又避免了加解密对数据库查询功能如模糊查询的复杂影响。因此本方案将重点放在ResultSetHandler的拦截上。2.3 注解的设计定义脱敏的规则注解是灵魂它定义了“对谁”以及“如何”脱敏。一个健壮的脱敏注解至少需要包含以下元素/** * 数据脱敏注解 */ Documented Target(ElementType.FIELD) // 作用于字段上 Retention(RetentionPolicy.RUNTIME) // 运行时保留插件需要通过反射读取 public interface DataMasking { /** * 脱敏类型 */ MaskType type() default MaskType.CUSTOM; /** * 前置保留长度 (仅对部分类型有效) */ int prefixKeep() default 0; /** * 后置保留长度 (仅对部分类型有效) */ int suffixKeep() default 0; /** * 掩码字符 (仅对CUSTOM或部分类型有效) */ char maskChar() default *; /** * 自定义脱敏逻辑的Spring Bean名称 (当type为CUSTOM时使用) */ String customProcessor() default ; }同时我们需要一个枚举MaskType来定义常见的脱敏模式public enum MaskType { /** 中文名 */ CHINESE_NAME, /** 身份证号 */ ID_CARD, /** 手机号 */ PHONE, /** 邮箱 */ EMAIL, /** 银行卡号 */ BANK_CARD, /** 自定义 */ CUSTOM; }这样在实体类中我们就可以非常清晰地使用public class User { private Long id; DataMasking(type MaskType.CHINESE_NAME) private String name; DataMasking(type MaskType.PHONE) private String mobile; DataMasking(type MaskType.ID_CARD, prefixKeep 6, suffixKeep 4) private String idCard; // ... 其他字段 }3. 核心组件实现详解3.1 脱敏策略工厂与具体策略实现有了注解定义“规则”我们需要具体的“工人”来执行脱敏。设计一个策略工厂模式是非常合适的。首先定义一个脱敏策略接口public interface MaskingStrategy { /** * 脱敏处理 * param source 原始字符串 * param annotation 字段上的DataMasking注解实例 * return 脱敏后的字符串 */ String mask(String source, DataMasking annotation); }然后为每一种MaskType实现具体的策略。例如手机号的脱敏策略Component(phoneMaskingStrategy) // 注册为Spring Bean public class PhoneMaskingStrategy implements MaskingStrategy { private static final Pattern PHONE_PATTERN Pattern.compile(^(\\d{3})\\d{4}(\\d{4})$); Override public String mask(String source, DataMasking annotation) { if (source null || source.length() ! 11) { return source; // 非标准手机号原样返回或按自定义规则处理 } // 将 13800138000 转换为 138****8000 return source.replaceAll((\\d{3})\\d{4}(\\d{4}), $1****$2); } }再比如一个通用的保留前后缀的脱敏策略可以被身份证、银行卡等类型复用Component(commonMaskingStrategy) public class CommonMaskingStrategy implements MaskingStrategy { Override public String mask(String source, DataMasking annotation) { if (source null || source.length() 0) { return source; } int prefixKeep annotation.prefixKeep(); int suffixKeep annotation.suffixKeep(); int totalKeep prefixKeep suffixKeep; int length source.length(); // 如果要保留的长度超过或等于原长返回原值 if (totalKeep length) { return source; } // 构建掩码部分 StringBuilder maskedPart new StringBuilder(); int maskLength length - totalKeep; for (int i 0; i maskLength; i) { maskedPart.append(annotation.maskChar()); } // 拼接前段保留 掩码 后段保留 return source.substring(0, prefixKeep) maskedPart.toString() source.substring(length - suffixKeep); } }最后创建一个策略工厂根据注解类型返回对应的策略BeanComponent public class MaskingStrategyFactory { Autowired private MapString, MaskingStrategy strategyMap; // Spring会自动将所有MaskingStrategy实现注入为Mapkey为Bean name public MaskingStrategy getStrategy(DataMasking annotation) { MaskType type annotation.type(); String beanName; switch (type) { case PHONE: beanName phoneMaskingStrategy; break; case ID_CARD: case BANK_CARD: beanName commonMaskingStrategy; // 身份证和银行卡使用通用策略 break; case CUSTOM: beanName annotation.customProcessor(); // 自定义策略从注解中读取Bean名 break; // ... 其他类型 default: beanName commonMaskingStrategy; } MaskingStrategy strategy strategyMap.get(beanName); if (strategy null) { throw new IllegalArgumentException(未找到对应的脱敏策略: beanName); } return strategy; } }实操心得将策略注册为Spring Bean并通过Map自动装配比用if-else或switch直接new对象的方式要优雅和灵活得多。未来新增一种脱敏类型你只需要新增一个实现MaskingStrategy的Bean即可工厂类几乎不用修改符合开闭原则。3.2 MyBatis插件的核心拦截逻辑这是插件最核心的部分。我们将实现一个ResultSetHandler的拦截器。Intercepts({ Signature(type ResultSetHandler.class, // 拦截的目标类 method handleResultSets, // 拦截的方法 args {Statement.class}) // 方法参数类型 }) Component public class DataMaskingInterceptor implements Interceptor { Autowired private MaskingStrategyFactory strategyFactory; Override public Object intercept(Invocation invocation) throws Throwable { // 1. 先执行原方法获取MyBatis处理后的原始结果 Object result invocation.proceed(); // 2. 如果结果为空直接返回 if (result null) { return null; } // 3. 处理结果集 return processResult(result); } private Object processResult(Object result) { // 3.1 处理结果为单个对象的情况 if (result instanceof List) { List? list (List?) result; if (!list.isEmpty()) { // 获取列表第一个元素的Class假设列表元素类型一致 Class? clazz list.get(0).getClass(); // 只有我们标记了MaskingData的实体类才进行处理 if (clazz.isAnnotationPresent(MaskingData.class)) { for (Object item : list) { maskObject(item); } } } return list; } else { // 3.2 处理结果为单个对象的情况 Class? clazz result.getClass(); if (clazz.isAnnotationPresent(MaskingData.class)) { maskObject(result); } return result; } } private void maskObject(Object object) { Class? clazz object.getClass(); // 遍历所有字段 for (Field field : clazz.getDeclaredFields()) { // 检查字段是否被DataMasking注解标记 DataMasking annotation field.getAnnotation(DataMasking.class); if (annotation ! null field.getType().equals(String.class)) { // 获取策略并执行脱敏 MaskingStrategy strategy strategyFactory.getStrategy(annotation); try { field.setAccessible(true); // 设置可访问因为字段通常是private的 String originalValue (String) field.get(object); if (originalValue ! null) { String maskedValue strategy.mask(originalValue, annotation); field.set(object, maskedValue); // 将脱敏后的值设回字段 } } catch (IllegalAccessException e) { throw new RuntimeException(处理字段[ field.getName() ]脱敏时发生反射异常, e); } } } } Override public Object plugin(Object target) { return Plugin.wrap(target, this); } Override public void setProperties(Properties properties) { // 可以从mybatis配置中读取属性此处暂不需要 } }这里引入了一个额外的类级注解MaskingData。为什么需要它这是为了性能优化。如果不加这个注解插件会对所有查询返回的对象进行反射和字段遍历开销巨大。通过这个注解我们明确告诉插件“只有标记了MaskingData的实体类才需要检查其字段进行脱敏。” 这相当于一个总开关。Target(ElementType.TYPE) Retention(RetentionPolicy.RUNTIME) Documented public interface MaskingData { }然后在需要脱敏的实体类上加上它MaskingData public class User { // ... 带有DataMasking注解的字段 }注意事项使用反射修改字段值存在一定的性能损耗并且要求字段不能是final的。在生产环境中对于海量数据查询需要评估此开销。一种优化思路是在插件层面增加一个开关配置或者通过ThreadLocal传递一个“本次查询是否需要脱敏”的上下文避免无谓的反射调用。3.3 插件在Spring Boot中的配置与注册在Spring Boot项目中MyBatis插件可以通过自动配置或手动配置的方式注册。最简单的方式是确保你的插件类DataMaskingInterceptor本身被Spring容器管理使用了Component注解并且MyBatis的配置类中将其添加到拦截器链。Configuration public class MyBatisConfig { Autowired private DataMaskingInterceptor dataMaskingInterceptor; Bean public ConfigurationCustomizer mybatisConfigurationCustomizer() { return configuration - { // 将我们的拦截器添加到MyBatis的拦截器链中 configuration.addInterceptor(dataMaskingInterceptor); }; } }这样当应用启动时我们的脱敏插件就会自动生效。4. 高级特性与生产级考量4.1 支持Map、JSON字段与嵌套对象上面的基础版本处理了简单的实体对象和List。但在实际项目中结果类型可能更加复杂返回MapString, Object常见于动态查询或统计场景。实体类中包含嵌套对象例如User对象里有一个Account对象而Account里也有需要脱敏的字段。字段值是JSON字符串数据库中存储的JSON其中某个属性是手机号需要在反序列化后脱敏。对于Map类型我们可以在processResult方法中增加分支判断。处理逻辑类似遍历Map的Value如果Value是String类型且对应的Key符合某种规则或我们需要维护一个Key-脱敏规则的映射则进行脱敏。但更通用的做法是将Map也视为一个“对象”为其定义一个虚拟的“类级注解”映射。对于嵌套对象我们需要递归地处理。修改maskObject方法private void maskObject(Object object) { if (object null) { return; } Class? clazz object.getClass(); // 只有标记了MaskingData的类才递归处理其字段 if (!clazz.isAnnotationPresent(MaskingData.class)) { return; } for (Field field : clazz.getDeclaredFields()) { field.setAccessible(true); try { Object fieldValue field.get(object); if (fieldValue null) { continue; } // 情况1字段是String类型且有DataMasking注解 DataMasking annotation field.getAnnotation(DataMasking.class); if (annotation ! null fieldValue instanceof String) { MaskingStrategy strategy strategyFactory.getStrategy(annotation); String maskedValue strategy.mask((String) fieldValue, annotation); field.set(object, maskedValue); } // 情况2字段是对象或集合递归处理 else if (fieldValue instanceof Collection) { for (Object item : (Collection?) fieldValue) { maskObject(item); // 递归处理集合中的每个元素 } } else { // 递归处理嵌套对象 maskObject(fieldValue); } } catch (IllegalAccessException e) { // 记录日志继续处理其他字段 log.warn(处理字段[{}]时发生反射异常, field.getName(), e); } } }踩坑提醒递归处理需要特别注意循环引用的问题。例如User对象里有一个ListOrder而Order对象里又引用了User对象。不加控制地递归会导致栈溢出。解决方案是使用一个ThreadLocalSetObject来记录当前调用链中已经处理过的对象引用如果再次遇到则跳过。对于JSON字段处理起来更复杂。一种思路是在脱敏插件之后再增加一个Jackson的序列化/反序列化定制器。例如定义一个Jackson的JsonSerializer专门用于处理带有DataMasking注解的字段。这样无论数据来自数据库还是其他来源在序列化成JSON响应给前端时都会自动脱敏。这需要将脱敏逻辑从MyBatis插件中部分抽离形成更通用的工具。4.2 性能优化与开关控制全量反射对性能的影响不容忽视。除了使用MaskingData进行粗粒度过滤还可以进行以下优化缓存反射信息每次处理一个对象类型时都通过clazz.getDeclaredFields()和遍历查找注解开销很大。我们可以使用一个ConcurrentHashMapClass?, ListMaskingFieldInfo来缓存每个需要脱敏的类及其需要处理的字段信息字段对象、注解、对应的策略。这样每个类只在第一次被处理时进行反射解析。private static class MaskingFieldInfo { final Field field; final DataMasking annotation; final MaskingStrategy strategy; // 甚至可以缓存策略实例 MaskingFieldInfo(Field field, DataMasking annotation, MaskingStrategy strategy) { this.field field; this.annotation annotation; this.strategy strategy; this.field.setAccessible(true); // 在这里提前设置accessible } }全局/请求级开关在某些内部接口或导出数据时我们可能需要获取原始数据。可以通过以下方式实现开关基于注解在Mapper方法上添加一个注解如SkipMasking插件在执行时通过Invocation对象获取到当前执行的Mapper方法检查该方法是否有此注解有则跳过脱敏。基于ThreadLocal在业务代码中通过一个工具类设置ThreadLocalBoolean插件在执行时检查这个上下文变量。例如public class MaskingContext { private static final ThreadLocalBoolean SKIP_MASKING ThreadLocal.withInitial(() - false); public static void skipMaskingForCurrentThread() { SKIP_MASKING.set(true); } public static void resetMaskingForCurrentThread() { SKIP_MASKING.remove(); } public static boolean isSkipMasking() { return SKIP_MASKING.get(); } }在插件intercept方法开头检查if (MaskingContext.isSkipMasking()) { return invocation.proceed(); }。务必注意一定要在finally块中或在后续的过滤器中清理ThreadLocal否则会导致内存泄漏和状态污染。4.3 与MyBatis-Plus等框架的兼容性如果你的项目使用了MyBatis-PlusMP需要注意插件的加载顺序和MP自身插件如分页插件PaginationInnerInterceptor的兼容性。MP的插件也是通过MyBatis的拦截器机制实现的。在配置时需要确保插件被添加到InterceptorChain的顺序符合预期。通常数据脱敏插件应该在结果集处理的最外层即等其他所有插件如分页插件都处理完后再对最终的结果进行脱敏。在Spring Boot中通过Order注解或显式地在ConfigurationCustomizer中控制添加顺序可能不直观。更可靠的方式是在MP的配置类中直接组装拦截器链Configuration public class MybatisPlusConfig { Bean public MybatisPlusInterceptor mybatisPlusInterceptor(DataMaskingInterceptor dataMaskingInterceptor) { MybatisPlusInterceptor interceptor new MybatisPlusInterceptor(); // 1. 先添加其他MP插件比如分页插件 interceptor.addInnerInterceptor(new PaginationInnerInterceptor(DbType.MYSQL)); // ... 其他MP插件 // 2. 注意MP的Interceptor是一个包装器我们的插件需要单独添加 // 通常我们需要获取SqlSessionFactory然后通过其Configuration添加 // 但这里更推荐在下面的方法中配置 return interceptor; } Bean public ConfigurationCustomizer configurationCustomizer(DataMaskingInterceptor dataMaskingInterceptor) { return configuration - { // 确保在MP插件之后添加我们的脱敏插件 // 因为脱敏作用于最终结果顺序靠后更安全 configuration.addInterceptor(dataMaskingInterceptor); }; } }重要提示务必进行充分的集成测试。编写一个单元测试模拟MP分页查询断言返回的分页对象Page中的记录数据是否被正确脱敏。有时MP会对结果进行包装你需要定位到真正的数据列表所在的位置。5. 测试、部署与问题排查5.1 编写单元测试与集成测试一个健壮的组件离不开测试。我们需要为脱敏策略和插件核心逻辑编写单元测试并为整个数据访问层编写集成测试。策略单元测试示例SpringBootTest class PhoneMaskingStrategyTest { Autowired private PhoneMaskingStrategy strategy; Test void testMask_StandardPhone() { DataMasking annotation mock(DataMasking.class); when(annotation.type()).thenReturn(MaskType.PHONE); String result strategy.mask(13800138000, annotation); assertEquals(138****8000, result); } Test void testMask_InvalidPhone() { DataMasking annotation mock(DataMasking.class); when(annotation.type()).thenReturn(MaskType.PHONE); // 测试非11位手机号 assertEquals(12345, strategy.mask(12345, annotation)); // 测试null assertNull(strategy.mask(null, annotation)); } }插件集成测试示例使用H2内存数据库SpringBootTest Transactional // 测试后回滚数据 class DataMaskingInterceptorTest { Autowired private UserMapper userMapper; Test void testQueryWithMasking() { // 1. 插入一条原始数据 User user new User(); user.setName(张三); user.setMobile(13800138000); user.setIdCard(110101199001011234); userMapper.insert(user); // 2. 查询该数据 User queriedUser userMapper.selectById(user.getId()); // 3. 断言脱敏生效 assertNotNull(queriedUser); assertEquals(138****8000, queriedUser.getMobile()); // 手机号脱敏 assertEquals(110101********1234, queriedUser.getIdCard()); // 身份证保留前6后4 assertEquals(张三, queriedUser.getName()); // 姓名未加注解应保持不变 } Test void testQueryListWithMasking() { // 插入多条数据... ListUser userList userMapper.selectList(null); assertFalse(userList.isEmpty()); for (User u : userList) { assertTrue(u.getMobile().contains(****)); // 所有手机号都应被脱敏 } } }5.2 常见问题排查清单在实际使用中你可能会遇到以下问题问题现象可能原因排查步骤与解决方案脱敏完全没生效1. 插件未注册成功。2. 实体类未加MaskingData注解。3. 字段类型不是String。1. 检查Spring Boot启动日志确认DataMaskingInterceptorBean已创建并查看MyBatis配置日志。2. 确认查询返回的实体类上有MaskingData注解。3. 确认被DataMasking注解标记的字段是String类型。部分字段脱敏部分未脱敏1. 字段名拼写错误或注解放置位置不对应放在字段上而非getter方法。2. 使用了Lombok等框架编译后注解位置可能变化。1. 使用IDE或反射API检查运行时字段上的注解信息。2. 如果使用Lombok确保DataMasking注解在生成的Getter/Setter上依然有效可能需要使用GetterSetter在字段上或将注解放在正确的Getter方法上如果插件设计为检查方法注解。建议本插件统一针对字段设计。脱敏后数据变null脱敏策略中对null或空字符串处理不当。检查你的MaskingStrategy实现确保在输入为null或空字符串时直接原样返回而不是返回null或抛出异常。性能明显下降1. 未使用MaskingData过滤导致所有查询对象都被反射扫描。2. 嵌套对象层次太深递归处理开销大。3. 缓存未生效。1. 确保所有需要脱敏的实体类都标记了MaskingData。2. 评估是否真的需要深度递归脱敏或许可以调整数据结构。3. 检查反射信息缓存逻辑是否正确确保同一种类只解析一次。与MyBatis-Plus分页插件冲突插件执行顺序问题脱敏可能发生在分页插件计算总数之前导致分页数据错乱。调整插件注册顺序确保脱敏插件在MP分页插件之后执行。可以通过调试查看InterceptorChain中插件的顺序或在MP配置中显式控制。最稳妥的方式是脱敏插件只处理ResultSetHandler而MP分页插件主要作用于Executor两者通常不冲突但需测试验证。日志中SQL参数仍显示明文插件只拦截了ResultSetHandler未拦截ParameterHandler或SQL语句日志本身。如果需要对日志中的参数进行脱敏需要额外拦截ParameterHandler的setParameters方法或者使用一个专门的StatementHandler插件来重写SQL日志输出逻辑。这是一个更高级的需求实现时要注意性能。5.3 生产环境部署建议灰度发布首次上线时可以先在非核心业务或查询量较小的服务上启用观察日志和监控确认无性能问题和功能异常后再全量推广。监控与告警在插件的关键方法如intercept入口处增加监控点记录处理耗时、处理的对象数量。如果平均耗时超过阈值如5ms触发告警以便及时优化。配置化将脱敏规则如手机号保留前3后4提取到配置中心如Apollo、Nacos而不是硬编码在策略类中。这样可以在不重启服务的情况下动态调整脱敏规则。文档与规范在团队内部明确脱敏注解的使用规范。例如规定所有DTOData Transfer Object中只要包含敏感信息字段就必须使用对应的脱敏注解。可以将此作为代码审查Code Review的一项必查项。这个自制的MyBatis数据脱敏插件从最初的简单想法到考虑各种边界情况和生产优化是一个不断打磨的过程。它不仅仅是一个工具更是一种将安全诉求无缝融入开发流程的实践。看到团队成员在新建实体类时能自然而然地加上DataMasking注解而不是到处写substring和replace就知道这个轮子造得值了。