精品一区二区三区在线成人,欧美精产国品一二三区,Ji大巴进入女人66h,亚洲春色在线视频

拋棄AOP!SpringBoot + YAML 零侵入數據脫敏神操作!

數據庫 其他數據庫
不用寫復雜的 AOP 切面,不用改業務代碼,只改改 YAML 配置,就能實現接口響應脫敏,還支持動態配置、日志脫敏、性能優化,新手也能快速上手。

兄弟們,今天咱們來聊個老生常談但又總讓人頭疼的話題 —— 數據脫敏。先跟大家嘮嘮我之前踩過的坑啊:去年做一個用戶中心項目,產品經理拍著桌子說 “用戶手機號、身份證號必須脫敏!日志里不能有明文,接口返回也不能漏!”。我當時一拍胸脯 “小意思,AOP 搞定!”,結果呢?

寫了個@SensitiveField注解,又搞了個切面攔截 Controller 返回值,用反射遍歷字段處理。一開始挺順利,直到遇到嵌套對象 —— 比如User里套了個Address,Address里有個contactPhone要脫敏,我那切面直接懵了,遞歸反射寫了三層才搞定;后來又遇到集合,List<User>得循環處理每個元素,代碼越改越亂,最后切面里全是 if-else,跟個迷宮似的。

更坑的是上線后,運維說 “你這接口響應慢了 100ms”,查了半天發現是反射次數太多,尤其是高并發的時候,CPU 占用直接上去了。當時我就想:就不能有個不用寫切面、不用改業務代碼,甚至連實體類都不用動的脫敏方案嗎?

還真讓我找到了!今天就給大家分享這個 “偷懶神器”——SpringBoot + YAML 零侵入數據脫敏方案。不用 AOP,不用加注解,改改配置文件就能搞定,新手看一遍也能上手,看完你絕對想收藏!

一、先搞懂:為啥要做數據脫敏?別等踩坑才后悔

在講方案之前,先跟沒接觸過脫敏的兄弟補補課 —— 別覺得脫敏是 “多此一舉”,等出了問題你就知道有多重要了。

舉個真實案例:前兩年某電商平臺,開發在日志里打印了用戶的銀行卡號(明文),結果被黑客通過日志漏洞爬走了,最后不僅賠了用戶錢,還被監管罰了幾百萬。你說這虧不虧?

咱們日常開發里,需要脫敏的場景主要有 3 個:

  1. 接口返回:給前端返回用戶信息時,手機號不能是13800138000,得是1388000;身份證號不能是110101199001011234,得是110101****1234
  2. 日志打印:不管是業務日志還是異常日志,只要有敏感信息,必須脫敏,不然日志文件就是 “定時炸彈”
  3. 數據庫存儲:這個得區分情況 —— 像手機號、郵箱可以存明文(但響應和日志要脫敏),但銀行卡號、身份證號這種高敏感信息,數據庫里最好存加密后的結果,脫敏只負責 “前端展示”

簡單說:脫敏的核心是 “該看的人能看,不該看的人看不到”,既保證用戶信息安全,又不影響業務正常運行。

之前用 AOP 做脫敏,雖然能實現功能,但有 3 個致命問題:

  • 侵入性強:得給實體類加注解,改業務代碼,萬一后續要改脫敏規則,牽一發動全身
  • 代碼復雜:處理嵌套對象、集合、基本類型,反射邏輯寫得頭暈,還容易出 bug
  • 性能拉胯:反射次數多,高并發場景下接口響應變慢,CPU 占用飆升

而今天要講的方案,完美解決這 3 個問題 ——零侵入、配置化、輕量級,咱們一步步來拆解。

二、核心原理:SpringBoot 自帶的 “響應攔截神器”,比 AOP 更輕

很多兄弟不知道,SpringMVC 里有個叫ResponseBodyAdvice的接口,它能在 “響應體返回給前端之前” 攔截處理,相當于給響應加了個 “過濾器”。

咱們之前用 AOP,還得自己寫切面、定義切點(比如攔截所有@RestController的方法),而ResponseBodyAdvice是 Spring 官方提供的擴展點,不用處理復雜的切面表達式,也不用考慮攔截順序,比 AOP 更簡單、更輕量。

舉個通俗的例子:如果把接口響應比作 “快遞”,ResponseBodyAdvice就是 “快遞分揀員”,在快遞送到用戶(前端)手里之前,先檢查一下里面有沒有 “敏感物品”(敏感字段),有就按規則 “包裝一下”(脫敏),再送出去。

整個方案的核心邏輯就是:

  1. 用ResponseBodyAdvice攔截所有接口響應
  2. 從 YAML 配置里讀取 “哪些接口、哪些字段需要脫敏”
  3. 對響應體里的敏感字段按規則處理
  4. 把處理后的響應體返回給前端

全程不用改業務代碼,不用加注解,所有規則都在 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是第二個分組

這里有幾個關鍵點要說明:

  1. 多環境區分:開發環境(dev)關閉脫敏,方便調試;生產環境(prod)開啟,保證安全。不用每次改代碼,切換環境就行。
  2. 接口映射:每個mapping對應一個接口,path是接口路徑,method是請求方法,fields是需要脫敏的字段。
  3. 嵌套字段:支持user.phone這種嵌套字段,不管嵌套多少層,用 “.” 分隔就行。
  4. 脫敏規則:內置了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);
    }
}

這里有兩個關鍵點:

  1. 初始化方法:把 YAML 里的自定義規則編譯成Pattern緩存起來,避免每次脫敏都重新編譯正則(正則編譯很耗時,緩存能提高性能)。
  2. 內置規則:直接用 Hutool 的DesensitizedUtil,里面還有很多其他規則(比如郵箱、密碼),如果需要可以自己加。
  3. 自定義規則:通過正則匹配和替換實現,靈活度很高,不管是郵箱、地址還是其他敏感字段,都能搞定。

第五步:實現核心攔截器,用 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 踩過坑,不妨試試這個方案,絕對能讓你少寫很多代碼,少踩很多坑。

責任編輯:武曉燕 來源: 石杉的架構筆記
相關推薦

2021-02-03 09:34:28

潮數

2021-08-02 18:23:01

Spring隱私數據

2024-02-05 13:39:00

隱私數據脫敏

2023-10-09 07:37:01

2024-09-02 00:27:51

SpringAOP自定義

2025-06-18 02:12:00

2019-03-07 15:45:30

SQL字符串腳本語言

2009-12-09 16:47:08

Linux操作系統

2018-01-26 07:53:46

數據脫敏數據安全信息安全

2024-02-21 15:30:56

2017-02-05 17:27:43

2020-04-10 10:36:20

網絡通信框架

2020-03-13 14:05:14

SpringBoot+數據源Java

2021-10-22 06:53:45

脫敏處理數據

2022-05-16 08:50:23

數據脫加密器

2025-03-10 00:13:00

數據庫脫敏日志脫敏出脫敏

2023-07-08 00:12:26

框架結構組件

2025-03-11 08:34:22

2022-12-14 09:51:04

Twitter開源

2018-01-02 17:42:44

數據脫敏數據安全數據泄露
點贊
收藏

51CTO技術棧公眾號

主站蜘蛛池模板: 南木林县| 商都县| 沐川县| 西和县| 阿城市| 孝感市| 怀安县| 中卫市| 锦屏县| 博罗县| 陵川县| 南投市| 龙门县| 永嘉县| 永仁县| 呼伦贝尔市| 吉安市| 琼中| 南召县| 镇康县| 无锡市| 武山县| 新泰市| 广河县| 乌拉特后旗| 尼勒克县| 阿拉善左旗| 灯塔市| 莫力| 孝义市| 响水县| 台东市| 马鞍山市| 宣城市| 个旧市| 兖州市| 华池县| 汤阴县| 会泽县| 万年县| 晴隆县|