抛弃AOP!SpringBoot + YAML 零侵入数据脱敏神操作!

兄弟们,今天咱们来聊个老生常谈但又总让人头疼的话题 —— 数据脱敏。先跟大家唠唠我之前踩过的坑啊:去年做一个用户中心项目,产品经理拍着桌子说 “用户手机号、身份证号必须脱敏!日志里不能有明文,接口返回也不能漏!”。我当时一拍胸脯 “小意思,AOP 搞定!”,结果呢?

写了个@SensitiveField注解,又搞了个切面拦截 Controller 返回值,用反射遍历字段处理。一开始挺顺利,直到遇到嵌套对象 —— 比如User里套了个Address,Address里有个contactPhone要脱敏,我那切面直接懵了,递归反射写了三层才搞定;后来又遇到集合,List<User>得循环处理每个元素,代码越改越乱,最后切面里全是 if-else,跟个迷宫似的。

更坑的是上线后,运维说 “你这接口响应慢了 100ms”,查了半天发现是反射次数太多,尤其是高并发的时候,CPU 占用直接上去了。当时我就想:就不能有个不用写切面、不用改业务代码,甚至连实体类都不用动的脱敏方案吗?

还真让我找到了!今天就给大家分享这个 “偷懒神器”——SpringBoot + YAML 零侵入数据脱敏方案。不用 AOP,不用加注解,改改配置文件就能搞定,新手看一遍也能上手,看完你绝对想收藏!

一、先搞懂:为啥要做数据脱敏?别等踩坑才后悔

在讲方案之前,先跟没接触过脱敏的兄弟补补课 —— 别觉得脱敏是 “多此一举”,等出了问题你就知道有多重要了。

举个真实案例:前两年某电商平台,开发在日志里打印了用户的银行卡号(明文),结果被黑客通过日志漏洞爬走了,最后不仅赔了用户钱,还被监管罚了几百万。你说这亏不亏?

咱们日常开发里,需要脱敏的场景主要有 3 个:

接口返回:给前端返回用户信息时,手机号不能是13800138000,得是1388000;身份证号不能是110101199001011234,得是110101****1234日志打印:不管是业务日志还是异常日志,只要有敏感信息,必须脱敏,不然日志文件就是 “定时炸弹”数据库存储:这个得区分情况 —— 像手机号、邮箱可以存明文(但响应和日志要脱敏),但银行卡号、身份证号这种高敏感信息,数据库里最好存加密后的结果,脱敏只负责 “前端展示”

简单说:脱敏的核心是 “该看的人能看,不该看的人看不到”,既保证用户信息安全,又不影响业务正常运行。

之前用 AOP 做脱敏,虽然能实现功能,但有 3 个致命问题:

侵入性强:得给实体类加注解,改业务代码,万一后续要改脱敏规则,牵一发动全身代码复杂:处理嵌套对象、集合、基本类型,反射逻辑写得头晕,还容易出 bug性能拉胯:反射次数多,高并发场景下接口响应变慢,CPU 占用飙升

而今天要讲的方案,完美解决这 3 个问题 ——零侵入、配置化、轻量级,咱们一步步来拆解。

二、核心原理:SpringBoot 自带的 “响应拦截神器”,比 AOP 更轻

很多兄弟不知道,SpringMVC 里有个叫ResponseBodyAdvice的接口,它能在 “响应体返回给前端之前” 拦截处理,相当于给响应加了个 “过滤器”。

咱们之前用 AOP,还得自己写切面、定义切点(比如拦截所有@RestController的方法),而ResponseBodyAdvice是 Spring 官方提供的扩展点,不用处理复杂的切面表达式,也不用考虑拦截顺序,比 AOP 更简单、更轻量。

举个通俗的例子:如果把接口响应比作 “快递”,ResponseBodyAdvice就是 “快递分拣员”,在快递送到用户(前端)手里之前,先检查一下里面有没有 “敏感物品”(敏感字段),有就按规则 “包装一下”(脱敏),再送出去。

整个方案的核心逻辑就是:

用ResponseBodyAdvice拦截所有接口响应从 YAML 配置里读取 “哪些接口、哪些字段需要脱敏”对响应体里的敏感字段按规则处理把处理后的响应体返回给前端

全程不用改业务代码,不用加注解,所有规则都在 YAML 里配置,这就是 “零侵入” 的关键!

三、实战步骤:从 0 到 1 实现,复制代码就能用

咱们先定个目标:实现两个接口的脱敏需求

接口 1:GET /api/user/get,返回用户信息,需要脱敏phone(手机号)和idCard(身份证号)接口 2:POST /api/order/list,返回订单列表,需要脱敏bankCard(银行卡号)和user.phone(嵌套字段)

环境准备:JDK 1.8+,SpringBoot 2.7.x(其他版本也能用,差别不大)

第一步:引入依赖,就两个,不多加

首先创建一个 SpringBoot 项目,然后在pom.xml里加两个依赖:

spring-boot-starter-web:必备,不用多说hutool-all:国产工具包,里面有很多现成的脱敏方法,省得咱们自己写正则
复制
<dependencies> <!-- SpringBoot Web核心依赖 --> <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-web</artifactId> </dependency> <!-- Hutool工具包:简化脱敏、字符串处理 --> <dependency> <groupId>cn.hutool</groupId> <artifactId>hutool-all</artifactId> <version>5.8.20</version> <!-- 用最新版就行 --> </dependency> <!-- 可选:如果用Lombok,加这个,能少写getter/setter --> <dependency> <groupId>org.projectlombok</groupId> <artifactId>lombok</artifactId> <optional>true</optional> </dependency> </dependencies>1.2.3.4.5.6.7.8.9.10.11.12.13.14.15.16.17.18.19.

Hutool 不是必须的,如果你不想引入第三方包,自己写正则也能实现脱敏,后面会讲怎么自定义。

第二步:写 YAML 配置,脱敏规则全在这里定

最关键的一步来了!咱们在application.yml里配置脱敏规则,不用改任何 Java 代码。

先看配置结构,我都加了注释,一看就懂:

复制
# 应用基础配置 spring: application: name: sensitive-demo # 多环境配置:开发环境可以关闭脱敏,方便调试 profiles: active: dev # 脱敏核心配置:dev环境(开发) --- spring: config: activate: on-profile: dev # 开发环境关闭脱敏,方便调试接口,看明文数据 sensitive: enabled: false # 脱敏核心配置:prod环境(生产) --- spring: config: activate: on-profile: prod sensitive: enabled: true # 生产环境开启脱敏 # 接口脱敏映射:按接口配置需要脱敏的字段 mappings: # 第一个接口:获取用户信息 - path: /api/user/get method: GET # 请求方法:GET/POST/PUT/DELETE,不区分大小写 fields: # 需要脱敏的字段 - name: phone # 字段名:对应响应体里的phone字段 rule: mobile # 脱敏规则:mobile(手机号) - name: idCard # 字段名:身份证号 rule: idCard # 脱敏规则:idCard(身份证号) # 第二个接口:获取订单列表 - path: /api/order/list method: POST fields: - name: bankCard # 字段名:银行卡号 rule: bankCard # 脱敏规则:bankCard(银行卡号) - name: user.phone # 嵌套字段:order里的user对象的phone字段 rule: mobile # 可以继续加更多接口... # 自定义脱敏规则:如果Hutool的规则不够用,自己加 custom-rules: # 比如自定义邮箱脱敏规则:zhangsan@163.com → zh****@163.com - name: email regex: "([a-zA-Z0-9_]{2})[a-zA-Z0-9_]*@([a-zA-Z0-9.]+)" # 正则表达式 replacement: "$1****@$2" # 替换规则:$1是第一个分组,$2是第二个分组1.2.3.4.5.6.7.8.9.10.11.12.13.14.15.16.17.18.19.20.21.22.23.24.25.26.27.28.29.30.31.32.33.34.35.36.37.38.39.40.41.42.43.44.45.46.47.48.49.

这里有几个关键点要说明:

多环境区分:开发环境(dev)关闭脱敏,方便调试;生产环境(prod)开启,保证安全。不用每次改代码,切换环境就行。接口映射:每个mapping对应一个接口,path是接口路径,method是请求方法,fields是需要脱敏的字段。嵌套字段:支持user.phone这种嵌套字段,不管嵌套多少层,用 “.” 分隔就行。脱敏规则:内置了mobile、idCard、bankCard三种规则(后面会讲怎么实现),还支持自定义规则(比如上面的email)。

第三步:把 YAML 配置映射成 Java 对象

SpringBoot 不能直接读取 YAML 里的复杂结构(比如mappings列表),所以咱们要写个配置类,把 YAML 配置映射成 Java 对象,方便后续使用。

用@ConfigurationProperties注解就能实现,代码很简单:

复制
import lombok.Data; import org.springframework.boot.context.properties.ConfigurationProperties; import org.springframework.stereotype.Component; import java.util.List; /** * 脱敏配置类:把YAML里的sensitive配置映射成Java对象 */ @Component @ConfigurationProperties(prefix = "sensitive") // 对应YAML里的sensitive节点 @Data // Lombok注解,省得写getter/setter public class SensitiveProperties { /** * 是否开启脱敏功能:true=开启,false=关闭 */ private boolean enabled = false; /** * 接口脱敏映射列表 */ private List<SensitiveMapping> mappings; /** * 自定义脱敏规则列表 */ private List<CustomRule> customRules; /** * 单个接口的脱敏配置 */ @Data public static class SensitiveMapping { /** * 接口路径:比如/api/user/get */ private String path; /** * 请求方法:GET/POST/PUT/DELETE,不区分大小写 */ private String method; /** * 该接口需要脱敏的字段列表 */ private List<SensitiveField> fields; } /** * 单个字段的脱敏配置 */ @Data public static class SensitiveField { /** * 字段名:支持嵌套字段,比如user.phone */ private String name; /** * 脱敏规则:比如mobile、idCard、bankCard,或自定义规则名 */ private String rule; } /** * 自定义脱敏规则 */ @Data public static class CustomRule { /** * 规则名:比如email,在fields.rule里引用 */ private String name; /** * 正则表达式:用来匹配敏感字段 */ private String regex; /** * 替换规则:比如$1****@$2 */ private String replacement; } }1.2.3.4.5.6.7.8.9.10.11.12.13.14.15.16.17.18.19.20.21.22.23.24.25.26.27.28.29.30.31.32.33.34.35.36.37.38.39.40.41.42.43.44.45.46.47.48.49.50.51.52.53.54.55.56.57.58.59.60.61.62.63.64.65.66.67.68.69.70.71.72.73.74.

这里用了 Lombok 的@Data注解,如果你没加 Lombok 依赖,自己写 getter 和 setter 就行,功能一样。

第四步:实现脱敏工具类,规则全在这里

接下来写个脱敏工具类,负责实现具体的脱敏逻辑 —— 包括内置规则(手机号、身份证号、银行卡号)和自定义规则(从 YAML 里读)。

咱们用 Hutool 的DesensitizedUtil来实现内置规则,省得自己写正则,效率更高:

复制
import cn.hutool.core.util.DesensitizedUtil; import cn.hutool.core.util.StrUtil; import org.springframework.stereotype.Component; import javax.annotation.Resource; import java.util.HashMap; import java.util.Map; import java.util.regex.Pattern; /** * 脱敏工具类:实现各种脱敏规则 */ @Component public class SensitiveUtil { /** * 自定义脱敏规则缓存:key=规则名,value=正则Pattern */ private final Map<String, Pattern> customRulePatterns = new HashMap<>(); /** * 自定义脱敏替换规则缓存:key=规则名,value=替换字符串 */ private final Map<String, String> customRuleReplacements = new HashMap<>(); @Resource private SensitiveProperties sensitiveProperties; /** * 初始化:把YAML里的自定义规则加载到缓存 */ public void init() { if (sensitiveProperties.getCustomRules() == null) { return; } // 遍历自定义规则,编译正则表达式并缓存 for (SensitiveProperties.CustomRule customRule : sensitiveProperties.getCustomRules()) { customRulePatterns.put( customRule.getName(), Pattern.compile(customRule.getRegex()) ); customRuleReplacements.put( customRule.getName(), customRule.getReplacement() ); } } /** * 核心方法:根据规则脱敏字符串 * @param value 原始字符串(比如手机号13800138000) * @param rule 脱敏规则(比如mobile) * @return 脱敏后的字符串(比如138****8000) */ public String desensitize(String value, String rule) { // 1. 空值直接返回,避免空指针 if (StrUtil.isBlank(value)) { return value; } // 2. 处理内置规则 switch (rule.toLowerCase()) { case "mobile": // 手机号脱敏:138****8000 return DesensitizedUtil.mobilePhone(value); case "idcard": // 身份证号脱敏:110101********1234 return DesensitizedUtil.idCardNum(value, 6, 4); case "bankcard": // 银行卡号脱敏:6222*******1234 return DesensitizedUtil.bankCard(value); default: // 3. 处理自定义规则 return handleCustomRule(value, rule); } } /** * 处理自定义脱敏规则 */ private String handleCustomRule(String value, String rule) { // 检查自定义规则是否存在 if (!customRulePatterns.containsKey(rule) || !customRuleReplacements.containsKey(rule)) { return value; // 规则不存在,返回原始值,避免报错 } // 用自定义的正则和替换规则处理 Pattern pattern = customRulePatterns.get(rule); String replacement = customRuleReplacements.get(rule); return pattern.matcher(value).replaceAll(replacement); } }1.2.3.4.5.6.7.8.9.10.11.12.13.14.15.16.17.18.19.20.21.22.23.24.25.26.27.28.29.30.31.32.33.34.35.36.37.38.39.40.41.42.43.44.45.46.47.48.49.50.51.52.53.54.55.56.57.58.59.60.61.62.63.64.65.66.67.68.69.70.71.72.73.74.75.76.77.78.

这里有两个关键点:

初始化方法:把 YAML 里的自定义规则编译成Pattern缓存起来,避免每次脱敏都重新编译正则(正则编译很耗时,缓存能提高性能)。内置规则:直接用 Hutool 的DesensitizedUtil,里面还有很多其他规则(比如邮箱、密码),如果需要可以自己加。自定义规则:通过正则匹配和替换实现,灵活度很高,不管是邮箱、地址还是其他敏感字段,都能搞定。

第五步:实现核心拦截器,用 ResponseBodyAdvice

这是整个方案的 “灵魂”—— 用ResponseBodyAdvice拦截响应体,然后调用上面的工具类进行脱敏。

不用写 AOP 切面,不用定义切点,只要实现ResponseBodyAdvice接口就行,代码比 AOP 简单多了:

复制
import cn.hutool.core.util.StrUtil; import org.springframework.core.MethodParameter; import org.springframework.http.MediaType; import org.springframework.http.converter.HttpMessageConverter; import org.springframework.http.server.ServerHttpRequest; import org.springframework.http.server.ServerHttpResponse; import org.springframework.web.bind.annotation.ControllerAdvice; import org.springframework.web.servlet.mvc.method.annotation.ResponseBodyAdvice; import javax.annotation.Resource; import javax.annotation.PostConstruct; import java.lang.reflect.Field; import java.util.List; import java.util.Map; import java.util.concurrent.ConcurrentHashMap; /** * 响应体脱敏拦截器:用ResponseBodyAdvice实现零侵入脱敏 */ @ControllerAdvice // 全局拦截所有@Controller的响应 public class SensitiveResponseBodyAdvice implements ResponseBodyAdvice<Object> { @Resource private SensitiveProperties sensitiveProperties; @Resource private SensitiveUtil sensitiveUtil; /** * 接口脱敏配置缓存:key=path#method(比如/api/user/get#GET),value=该接口的敏感字段列表 */ private final Map<String, List<SensitiveProperties.SensitiveField>> sensitiveFieldCache = new ConcurrentHashMap<>(); /** * 初始化:1. 初始化脱敏工具类 2. 缓存接口脱敏配置 */ @PostConstruct public void init() { // 1. 初始化脱敏工具类(加载自定义规则) sensitiveUtil.init(); // 2. 缓存接口脱敏配置,避免每次请求都遍历mappings if (sensitiveProperties.getMappings() == null) { return; } for (SensitiveProperties.SensitiveMapping mapping : sensitiveProperties.getMappings()) { // 生成key:path#method(统一转小写,避免大小写问题) String key = mapping.getPath().toLowerCase() + "#" + mapping.getMethod().toLowerCase(); sensitiveFieldCache.put(key, mapping.getFields()); } } /** * 第一步:判断当前请求是否需要脱敏 * @param returnType 方法返回类型 * @param converterType 消息转换器类型 * @return true=需要脱敏,false=不需要 */ @Override public boolean supports(MethodParameter returnType, Class<? extends HttpMessageConverter<?>> converterType) { // 1. 如果脱敏功能关闭,直接返回false if (!sensitiveProperties.isEnabled()) { return false; } // 2. 只处理JSON响应(大部分项目都是JSON,XML可以自己加) return MediaType.APPLICATION_JSON.equalsTypeAndSubtype(MediaType.valueOf(converterType.getSimpleName())); } /** * 第二步:对响应体进行脱敏处理 * @param body 原始响应体 * @param returnType 方法返回类型 * @param selectedContentType 响应类型 * @param selectedConverterType 消息转换器类型 * @param request 请求对象 * @param response 响应对象 * @return 脱敏后的响应体 */ @Override public Object beforeBodyWrite(Object body, MethodParameter returnType, MediaType selectedContentType, Class<? extends HttpMessageConverter<?>> selectedConverterType, ServerHttpRequest request, ServerHttpResponse response) { // 1. 获取当前请求的path和method String path = request.getURI().getPath().toLowerCase(); String method = request.getMethod().name().toLowerCase(); String cacheKey = path + "#" + method; // 2. 从缓存获取当前接口的敏感字段列表 List<SensitiveProperties.SensitiveField> sensitiveFields = sensitiveFieldCache.get(cacheKey); if (sensitiveFields == null || sensitiveFields.isEmpty()) { return body; // 没有需要脱敏的字段,直接返回原始响应体 } // 3. 对响应体进行脱敏处理 processSensitiveField(body, sensitiveFields); // 4. 返回脱敏后的响应体 return body; } /** * 核心方法:处理响应体里的敏感字段 * @param body 响应体对象(可能是单个对象、集合、Map等) * @param sensitiveFields 敏感字段列表 */ private void processSensitiveField(Object body, List<SensitiveProperties.SensitiveField> sensitiveFields) { if (body == null) { return; } // 1. 如果是集合(List、Set等),遍历每个元素处理 if (body instanceof List<?>) { List<?> list = (List<?>) body; for (Object item : list) { processSensitiveField(item, sensitiveFields); // 递归处理每个元素 } return; } // 2. 如果是Map(比如接口返回Map<String, Object>),遍历每个entry处理 if (body instanceof Map<?, ?>) { Map<?, ?> map = (Map<?, ?>) body; for (Map.Entry<?, ?> entry : map.entrySet()) { Object value = entry.getValue(); if (value == null) { continue; } // 检查当前key是否是敏感字段 String key = entry.getKey().toString(); for (SensitiveProperties.SensitiveField sensitiveField : sensitiveFields) { // 如果是简单字段(不是嵌套字段),直接脱敏 if (StrUtil.equals(key, sensitiveField.getName())) { String desensitizedValue = sensitiveUtil.desensitize(value.toString(), sensitiveField.getRule()); entry.setValue(desensitizedValue); break; } } // 递归处理Map里的嵌套对象 processSensitiveField(value, sensitiveFields); } return; } // 3. 如果是普通Java对象(比如User、Order),用反射处理字段 Class<?> clazz = body.getClass(); // 遍历所有敏感字段,处理每个字段 for (SensitiveProperties.SensitiveField sensitiveField : sensitiveFields) { try { // 处理字段:支持嵌套字段(比如user.phone) Object fieldValue = getNestedFieldValue(body, sensitiveField.getName()); if (fieldValue == null) { continue; } // 脱敏处理:把字段值转成字符串,脱敏后再设回去 String desensitizedValue = sensitiveUtil.desensitize(fieldValue.toString(), sensitiveField.getRule()); setNestedFieldValue(body, sensitiveField.getName(), desensitizedValue, fieldValue.getClass()); } catch (Exception e) { // 遇到异常不抛出,避免影响接口正常返回(脱敏是辅助功能,不能让它搞崩主流程) e.printStackTrace(); } } } /** * 获取嵌套字段的值:比如从Order对象里获取user.phone的值 * @param obj 目标对象(比如Order) * @param fieldName 嵌套字段名(比如user.phone) * @return 字段值(比如13800138000) */ private Object getNestedFieldValue(Object obj, String fieldName) throws Exception { if (StrUtil.isBlank(fieldName)) { return null; } // 分割字段名:比如"user.phone" → ["user", "phone"] String[] fieldNames = fieldName.split("\\."); Object currentObj = obj; for (String name : fieldNames) { if (currentObj == null) { return null; } // 获取当前对象的字段(包括私有字段) Field field = getDeclaredField(currentObj.getClass(), name); if (field == null) { return null; } field.setAccessible(true); // 突破私有字段访问限制 currentObj = field.get(currentObj); // 获取字段值,作为下一层的对象 } return currentObj; } /** * 设置嵌套字段的值:比如给Order对象的user.phone设置脱敏后的值 * @param obj 目标对象(比如Order) * @param fieldName 嵌套字段名(比如user.phone) * @param value 脱敏后的值(比如138****8000) * @param fieldType 字段类型(比如String) */ private void setNestedFieldValue(Object obj, String fieldName, String value, Class<?> fieldType) throws Exception { if (StrUtil.isBlank(fieldName)) { return; } // 分割字段名:比如"user.phone" → ["user", "phone"] String[] fieldNames = fieldName.split("\\."); Object currentObj = obj; // 遍历到倒数第二个字段(比如"user") for (int i = 0; i < fieldNames.length - 1; i++) { String name = fieldNames[i]; Field field = getDeclaredField(currentObj.getClass(), name); if (field == null) { return; } field.setAccessible(true); currentObj = field.get(currentObj); // 获取下一层对象(比如user对象) if (currentObj == null) { return; } } // 处理最后一个字段(比如"phone") String lastFieldName = fieldNames[fieldNames.length - 1]; Field lastField = getDeclaredField(currentObj.getClass(), lastFieldName); if (lastField == null) { return; } lastField.setAccessible(true); // 把脱敏后的字符串转成字段对应的类型(比如Long类型的phone) Object fieldValue = convertValue(value, fieldType); lastField.set(currentObj, fieldValue); // 设置脱敏后的值 } /** * 获取类的字段(包括父类的字段) * @param clazz 目标类 * @param fieldName 字段名 * @return 字段对象 */ private Field getDeclaredField(Class<?> clazz, String fieldName) { // 遍历当前类和父类,直到找到字段或到Object类 while (clazz != null && clazz != Object.class) { try { return clazz.getDeclaredField(fieldName); } catch (NoSuchFieldException e) { clazz = clazz.getSuperclass(); // 没找到,找父类 } } return null; // 没找到字段 } /** * 把字符串转成目标类型(比如String → Long) * @param value 字符串值 * @param targetType 目标类型 * @return 转换后的值 */ private Object convertValue(String value, Class<?> targetType) { if (targetType == String.class) { return value; } if (targetType == Integer.class || targetType == int.class) { return Integer.parseInt(value); } if (targetType == Long.class || targetType == long.class) { return Long.parseLong(value); } // 其他类型可以自己加,比如Double、Boolean等 return value; } }1.2.3.4.5.6.7.8.9.10.11.12.13.14.15.16.17.18.19.20.21.22.23.24.25.26.27.28.29.30.31.32.33.34.35.36.37.38.39.40.41.42.43.44.45.46.47.48.49.50.51.52.53.54.55.56.57.58.59.60.61.62.63.64.65.66.67.68.69.70.71.72.73.74.75.76.77.78.79.80.81.82.83.84.85.86.87.88.89.90.91.92.93.94.95.96.97.98.99.100.101.102.103.104.105.106.107.108.109.110.111.112.113.114.115.116.117.118.119.120.121.122.123.124.125.126.127.128.129.130.131.132.133.134.135.136.137.138.139.140.141.142.143.144.145.146.147.148.149.150.151.152.153.154.155.156.157.158.159.160.161.162.163.164.165.166.167.168.169.170.171.172.173.174.175.176.177.178.179.180.181.182.183.184.185.186.187.188.189.190.191.192.193.194.195.196.197.198.199.200.201.202.203.204.205.206.207.208.209.210.211.212.213.214.215.216.217.218.219.220.221.222.223.224.225.226.227.228.229.230.231.232.233.234.235.236.237.238.239.240.241.242.243.244.245.246.247.248.

这段代码虽然长,但逻辑很清晰,我分几个部分给大家解释:

1. 初始化和缓存用@PostConstruct注解在项目启动时初始化:加载自定义脱敏规则,把接口脱敏配置缓存到sensitiveFieldCache里(key 是path#method)。缓存的目的是避免每次请求都遍历mappings列表,提高性能,尤其是接口多的时候。2. supports 方法:判断是否需要脱敏首先检查脱敏功能是否开启(sensitive.enabled),关闭的话直接返回false。只处理 JSON 响应(大部分项目都是 JSON),如果需要处理 XML,可以自己加判断。3. beforeBodyWrite 方法:拦截响应体获取当前请求的path和method,生成缓存 key,从缓存里拿敏感字段列表。如果没有敏感字段,直接返回原始响应体;有就调用processSensitiveField方法处理。4. processSensitiveField 方法:核心处理逻辑处理集合:如果响应体是List,遍历每个元素递归处理。处理 Map:如果响应体是Map,遍历每个 entry,检查 key 是否是敏感字段,是就脱敏,然后递归处理 value 里的嵌套对象。处理普通对象:用反射处理,支持嵌套字段(比如user.phone),这里是整个方法的重点。5. 嵌套字段处理getNestedFieldValue:通过 “.” 分割字段名,逐层获取对象的字段值,比如从Order里获取user,再从user里获取phone。setNestedFieldValue:和上面相反,逐层找到最后一个字段,把脱敏后的值设回去。getDeclaredField:获取类的字段,包括父类的字段,解决私有字段访问问题。

第六步:写测试代码,验证效果

咱们写几个测试类,看看脱敏效果到底怎么样。

1. 定义实体类

先写User、Order、Result三个实体类(Result是接口统一返回格式):

复制
// User.java import lombok.Data; @Data public class User { private Long id; private String name; private String phone; // 需要脱敏的字段 private String idCard; // 需要脱敏的字段 } // Order.java import lombok.Data; @Data public class Order { private Long id; private String orderNo; private String bankCard; // 需要脱敏的字段 private User user; // 嵌套对象,里面的phone需要脱敏 } // Result.java:接口统一返回格式 import lombok.Data; @Data public class Result<T> { private Integer code; // 状态码:200=成功,500=失败 private String msg; // 提示信息 private T data; // 响应数据 // 成功返回 public static <T> Result<T> success(T data) { Result<T> result = new Result<>(); result.setCode(200); result.setMsg("success"); result.setData(data); return result; } // 失败返回 public static <T> Result<T> fail(String msg) { Result<T> result = new Result<>(); result.setCode(500); result.setMsg(msg); return result; } }1.2.3.4.5.6.7.8.9.10.11.12.13.14.15.16.17.18.19.20.21.22.23.24.25.26.27.28.29.30.31.32.33.34.35.36.37.38.39.40.41.
2. 写测试 Controller

然后写UserController和OrderController,提供两个测试接口:

复制
// UserController.java import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.RequestParam; import org.springframework.web.bind.annotation.RestController; @RestController @RequestMapping("/api/user") public class UserController { /** * 测试接口1:获取用户信息 */ @GetMapping("/get") public Result<User> getUser(@RequestParam Long id) { // 模拟从数据库查询用户信息 User user = new User(); user.setId(id); user.setName("张三"); user.setPhone("13800138000"); // 明文手机号 user.setIdCard("110101199001011234"); // 明文身份证号 return Result.success(user); } } // OrderController.java import org.springframework.web.bind.annotation.PostMapping; import org.springframework.web.bind.annotation.RequestBody; import org.springframework.web.bind.annotation.RequestMapping; import org.springframework.web.bind.annotation.RestController; import java.util.ArrayList; import java.util.List; @RestController @RequestMapping("/api/order") public class OrderController { /** * 测试接口2:获取订单列表 */ @PostMapping("/list") public Result<List<Order>> getOrderList(@RequestBody OrderQuery query) { // 模拟从数据库查询订单列表 List<Order> orderList = new ArrayList<>(); Order order1 = new Order(); order1.setId(1L); order1.setOrderNo("20240520001"); order1.setBankCard("6222021234567890123"); // 明文银行卡号 User user1 = new User(); user1.setId(1L); user1.setName("李四"); user1.setPhone("13900139000"); // 嵌套对象的明文手机号 order1.setUser(user1); orderList.add(order1); return Result.success(orderList); } // 订单查询参数 public static class OrderQuery { private Long userId; // getter/setter public Long getUserId() { return userId; } public void setUserId(Long userId) { this.userId = userId; } } }1.2.3.4.5.6.7.8.9.10.11.12.13.14.15.16.17.18.19.20.21.22.23.24.25.26.27.28.29.30.31.32.33.34.35.36.37.38.39.40.41.42.43.44.45.46.47.48.49.50.51.52.53.54.55.56.57.58.59.60.61.62.
3. 启动项目,测试效果

把application.yml里的spring.profiles.active改成prod(开启脱敏),然后启动项目。

用 Postman 或浏览器测试接口:

测试接口 1:GET /api/user/get?id=1

预期效果:phone脱敏成1388000,idCard脱敏成110101****1234

实际返回结果:

复制
{ "code": 200, "msg": "success", "data": { "id": 1, "name": "张三", "phone": "138****8000", // 成功脱敏 "idCard": "110101********1234" // 成功脱敏 } }1.2.3.4.5.6.7.8.9.10.
测试接口 2:POST /api/order/list

请求参数:

复制
{ "userId": 1 }1.2.3.

预期效果:bankCard脱敏成622202**123,user.phone脱敏成139**9000实际返回结果:

复制
{ "code": 200, "msg": "success", "data": [ { "id": 1, "orderNo": "20240520001", "bankCard": "622202********123", // 成功脱敏 "user": { "id": 1, "name": "李四", "phone": "139****9000", // 嵌套字段成功脱敏 "idCard": null } } ] }1.2.3.4.5.6.7.8.9.10.11.12.13.14.15.16.17.

完美!和预期效果一致,而且咱们没改任何业务代码,只改了 YAML 配置,真正实现了 “零侵入”。

四、进阶优化:让方案更实用,应对复杂场景

上面的方案已经能满足大部分场景了,但在实际项目中,还有一些细节需要优化,咱们再补充几个进阶功能。

1. 支持动态配置:不用重启服务,实时更新脱敏规则

上面的方案有个问题:如果要新增一个脱敏接口,得改 YAML 配置,然后重启服务,很麻烦。

解决办法:用配置中心(比如 Nacos、Apollo)存储脱敏规则,实现动态更新。

以 Nacos 为例,步骤如下:

在 Nacos 里创建一个配置文件(比如sensitive-demo-prod.yaml),把 YAML 里的sensitive节点配置放进去。在项目里引入 Nacos 配置依赖:
复制
<dependency> <groupId>com.alibaba.cloud</groupId> <artifactId>spring-cloud-starter-alibaba-nacos-config</artifactId> <version>2.2.9.RELEASE</version> </dependency>1.2.3.4.5.
在bootstrap.yml里配置 Nacos 地址:
复制
spring: cloud: nacos: config: server-addr: 127.0.0.1:8848 # Nacos地址 file-extension: yaml # 配置文件格式 group: DEFAULT_GROUP # 配置分组 application: name: sensitive-demo # 服务名,对应Nacos里的配置文件名前缀 profiles: active: prod # 环境1.2.3.4.5.6.7.8.9.10.11.
在SensitiveProperties里加@RefreshScope注解,支持配置动态刷新:
复制
@Component @ConfigurationProperties(prefix = "sensitive") @Data @RefreshScope // 关键:开启配置动态刷新 public class SensitiveProperties { // ... 原有代码不变 }1.2.3.4.5.6.7.
在SensitiveResponseBodyAdvice里监听配置刷新事件,重新初始化缓存:
复制
import org.springframework.cloud.context.environment.EnvironmentChangeEvent; import org.springframework.context.ApplicationListener; import org.springframework.stereotype.Component; @Component publicclass SensitiveConfigRefreshListener implements ApplicationListener<EnvironmentChangeEvent> { @Resource private SensitiveResponseBodyAdvice sensitiveResponseBodyAdvice; @Override public void onApplicationEvent(EnvironmentChangeEvent event) { // 如果脱敏配置发生变化,重新初始化缓存 if (event.getKeys().stream().anyMatch(key -> key.startsWith("sensitive."))) { sensitiveResponseBodyAdvice.init(); } } }1.2.3.4.5.6.7.8.9.10.11.12.13.14.15.16.17.18.

这样一来,以后要新增或修改脱敏规则,直接在 Nacos 里改配置,不用重启服务,配置会自动刷新,非常方便!

2. 日志脱敏:避免日志里出现明文敏感信息

前面咱们解决了接口响应脱敏,但日志里如果打印了敏感信息,还是会有风险。比如:

复制
log.info("用户登录成功,手机号:{}", user.getPhone()); // 日志里会出现明文手机号1.

解决办法:用 Logback 的自定义转换器,对日志里的敏感字段进行脱敏。步骤如下:

写一个日志脱敏转换器:
复制
import ch.qos.logback.classic.pattern.ClassicConverter; import ch.qos.logback.classic.spi.ILoggingEvent; import org.springframework.stereotype.Component; import javax.annotation.Resource; /** * Logback日志脱敏转换器 */ @Component publicclass LogSensitiveConverter extends ClassicConverter { @Resource private SensitiveUtil sensitiveUtil; @Override publicString convert(ILoggingEvent event) { String message = event.getMessage(); if (message == null) { return""; } // 对日志里的手机号、身份证号、银行卡号进行脱敏 message = sensitiveUtil.desensitize(message, "mobile"); message = sensitiveUtil.desensitize(message, "idCard"); message = sensitiveUtil.desensitize(message, "bankCard"); return message; } }1.2.3.4.5.6.7.8.9.10.11.12.13.14.15.16.17.18.19.20.21.22.23.24.25.26.27.28.29.30.
在logback-spring.xml里配置转换器:
复制
<configuration> <!-- 配置脱敏转换器 --> <conversionRule conversionWord="sensitive" converterClass="com.example.sensitivedemo.config.LogSensitiveConverter" /> <!-- 控制台输出 --> <appender name="CONSOLE" class="ch.qos.logback.core.ConsoleAppender"> <encoder> <!-- 使用sensitive转换器脱敏日志 --> <pattern>%d{yyyy-MM-dd HH:mm:ss} [%thread] %-5level %logger{36} - %sensitive%n</pattern> </encoder> </appender> <!-- 全局日志级别 --> <root level="INFO"> <appender-ref ref="CONSOLE" /> </root> </configuration>1.2.3.4.5.6.7.8.9.10.11.12.13.14.15.16.17.

这样一来,日志里的敏感信息会自动脱敏,比如:

复制
2024-05-20 15:30:00 [http-nio-8080-exec-1] INFO com.example.sensitivedemo.controller.UserController - 用户登录成功,手机号:138****80001.

3. 性能优化:减少反射次数,提高接口响应速度

前面咱们提到,反射会影响性能,尤其是高并发场景。咱们可以通过缓存反射获取的字段信息,减少反射次数。

修改SensitiveResponseBodyAdvice里的processSensitiveField方法,增加字段缓存:

复制
/** * 字段缓存:key=类名#字段名,value=字段对象 */ private final Map<String, Field> fieldCache = new ConcurrentHashMap<>(); /** * 获取类的字段(包括父类的字段),并缓存 */ private Field getDeclaredField(Class<?> clazz, String fieldName) { String cacheKey = clazz.getName() + "#" + fieldName; // 先从缓存里拿 if (fieldCache.containsKey(cacheKey)) { return fieldCache.get(cacheKey); } // 缓存里没有,遍历类和父类找字段 Class<?> currentClazz = clazz; while (currentClazz != null && currentClazz != Object.class) { try { Field field = currentClazz.getDeclaredField(fieldName); field.setAccessible(true); // 突破私有字段访问限制 fieldCache.put(cacheKey, field); // 缓存字段 return field; } catch (NoSuchFieldException e) { currentClazz = currentClazz.getSuperclass(); // 找父类 } } returnnull; }1.2.3.4.5.6.7.8.9.10.11.12.13.14.15.16.17.18.19.20.21.22.23.24.25.26.27.28.29.30.

这样一来,同一个类的同一个字段,只会反射一次,后续都从缓存里拿,性能会提升很多。

五、对比 AOP:这个方案到底好在哪里?

最后咱们来总结一下,这个 SpringBoot + YAML 方案,和传统的 AOP 方案相比,优势到底在哪里:

对比维度

传统 AOP 方案

SpringBoot + YAML 方案

侵入性

强:需要加注解、改业务代码、写切面

零侵入:不用改业务代码,只改配置文件

代码复杂度

高:处理嵌套对象、集合要写复杂逻辑

低:基于 ResponseBodyAdvice,逻辑清晰

配置灵活性

差:改规则要改代码,重启服务

好:配置化,支持动态更新(配 Nacos)

性能

一般:反射次数多,切面拦截有开销

好:字段缓存,减少反射,轻量级拦截

维护成本

高:代码耦合度高,后续修改牵一发动全身

低:配置集中管理,新增接口只加配置

简单说:用 AOP 做脱敏,就像 “给每个房间装一扇门”,每个门都要单独设计、安装;而用这个方案,就像 “装一个智能门禁系统”,统一配置,所有房间都能用,还能随时改规则。

六、总结

兄弟们,看到这里,相信你已经明白这个零侵入脱敏方案的好处了。不用写复杂的 AOP 切面,不用改业务代码,只改改 YAML 配置,就能实现接口响应脱敏,还支持动态配置、日志脱敏、性能优化,新手也能快速上手。

如果你现在正在做数据脱敏相关的需求,或者之前用 AOP 踩过坑,不妨试试这个方案,绝对能让你少写很多代码,少踩很多坑。

阅读剩余
THE END