拋棄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>
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是第二個分組
這里有幾個關鍵點要說明:
- 多環境區分:開發環境(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;
}
}
這里用了 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);
}
}
這里有兩個關鍵點:
- 初始化方法:把 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. 初始化和緩存
- 用@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;
}
}
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;
}
}
}
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" // 成功脫敏
}
}
測試接口 2:POST /api/order/list
請求參數:
{
"userId": 1
}
預期效果: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
}
}
]
}
完美!和預期效果一致,而且咱們沒改任何業務代碼,只改了 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>
- 在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 # 環境
- 在SensitiveProperties里加@RefreshScope注解,支持配置動態刷新:
@Component
@ConfigurationProperties(prefix = "sensitive")
@Data
@RefreshScope // 關鍵:開啟配置動態刷新
public class SensitiveProperties {
// ... 原有代碼不變
}
- 在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();
}
}
}
這樣一來,以后要新增或修改脫敏規則,直接在 Nacos 里改配置,不用重啟服務,配置會自動刷新,非常方便!
2. 日志脫敏:避免日志里出現明文敏感信息
前面咱們解決了接口響應脫敏,但日志里如果打印了敏感信息,還是會有風險。比如:
log.info("用戶登錄成功,手機號:{}", user.getPhone()); // 日志里會出現明文手機號
解決辦法:用 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;
}
}
- 在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>
這樣一來,日志里的敏感信息會自動脫敏,比如:
2024-05-20 15:30:00 [http-nio-8080-exec-1] INFO com.example.sensitivedemo.controller.UserController - 用戶登錄成功,手機號:138****8000
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;
}
這樣一來,同一個類的同一個字段,只會反射一次,后續都從緩存里拿,性能會提升很多。
五、對比 AOP:這個方案到底好在哪里?
最后咱們來總結一下,這個 SpringBoot + YAML 方案,和傳統的 AOP 方案相比,優勢到底在哪里:
對比維度 | 傳統 AOP 方案 | SpringBoot + YAML 方案 |
侵入性 | 強:需要加注解、改業務代碼、寫切面 | 零侵入:不用改業務代碼,只改配置文件 |
代碼復雜度 | 高:處理嵌套對象、集合要寫復雜邏輯 | 低:基于 ResponseBodyAdvice,邏輯清晰 |
配置靈活性 | 差:改規則要改代碼,重啟服務 | 好:配置化,支持動態更新(配 Nacos) |
性能 | 一般:反射次數多,切面攔截有開銷 | 好:字段緩存,減少反射,輕量級攔截 |
維護成本 | 高:代碼耦合度高,后續修改牽一發動全身 | 低:配置集中管理,新增接口只加配置 |
簡單說:用 AOP 做脫敏,就像 “給每個房間裝一扇門”,每個門都要單獨設計、安裝;而用這個方案,就像 “裝一個智能門禁系統”,統一配置,所有房間都能用,還能隨時改規則。
六、總結
兄弟們,看到這里,相信你已經明白這個零侵入脫敏方案的好處了。不用寫復雜的 AOP 切面,不用改業務代碼,只改改 YAML 配置,就能實現接口響應脫敏,還支持動態配置、日志脫敏、性能優化,新手也能快速上手。
如果你現在正在做數據脫敏相關的需求,或者之前用 AOP 踩過坑,不妨試試這個方案,絕對能讓你少寫很多代碼,少踩很多坑。