兄弟们,今天咱们来聊个老生常谈但又总让人头疼的话题 —— 数据脱敏。先跟大家唠唠我之前踩过的坑啊:去年做一个用户中心项目,产品经理拍着桌子说 “用户手机号、身份证号必须脱敏!日志里不能有明文,接口返回也不能漏!”。我当时一拍胸脯 “小意思,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
请求参数:
预期效果: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 踩过坑,不妨试试这个方案,绝对能让你少写很多代码,少踩很多坑。