博客 / 詳情

返回

巧用異步監聽切面,提高系統性能

使用異步監聽切面,提高系統性能

💡 作者:古渡藍按

💡個人微信公眾號:微信公眾號(深入淺出談java)
感覺本篇對你有幫助可以關注一下,會不定期更新知識和麪試資料、技巧!!!

摘要: 在構建高併發、高性能的現代Web應用時,如何優雅地記錄系統日誌、監控API調用而不影響核心業務邏輯的執行效率,是一個至關重要的課題。本文將深入探討一種結合面向切面編程(AOP)、異步處理和事件驅動模型的強大模式——異步監聽切面。我們將剖析其實現原理,演示如何在Spring Boot環境中運用此模式進行API調用日誌記錄,並分析其對系統性能帶來的顯著益處。


本文是對之前作者寫的進行補充。之前文章地址:https://www.cnblogs.com/blbl-blog/p/17944006


1. 引言

在複雜的軟件系統中,諸如日誌記錄、安全校驗、事務管理等功能往往是橫切關注點(Cross-Cutting Concerns),它們散佈於應用的核心業務邏輯之中。傳統的硬編碼方式不僅使得代碼耦合度高、難以維護,更嚴重的是,這些輔助性操作(尤其是I/O密集型操作,如數據庫寫入、網絡調用)若在主線程同步執行,會直接阻塞業務流程,成為系統性能的瓶頸。

Spring Framework 提供的面向切面編程(AOP)功能,為我們提供了分離關注點、模塊化橫切邏輯的有效途徑。然而,僅僅將邏輯抽取到切面中還不夠,對於耗時的操作,我們還需要進一步解耦其執行時機。此時,“異步”便成了破局的關鍵。

本文將以一個具體的場景——記錄API調用日誌為例,詳細介紹如何設計並實現一個基於“異步監聽切面”的解決方案,從而在不犧牲核心業務性能的前提下,高效地收集系統運行時信息。


2. 核心思想:AOP + 異步 + 事件驅動

我們的目標是:當一個被監控的API被調用時,主業務邏輯能夠快速響應,而日誌記錄等輔助任務則在後台悄然完成。

  • AOP(面向切面編程): 用於在不侵入原有代碼的情況下,攔截特定的方法調用(如Controller中的方法)。通過定義切入點(Pointcut)和通知(Advice),我們可以精確地知道何時何地需要觸發日誌記錄行為。
  • 事件驅動(Event-Driven): 當AOP攔截到目標方法調用後,不直接執行耗時的日誌保存操作,而是發佈一個“API調用完成”的事件。這種方式將觸發動作(方法調用)與處理邏輯(日誌保存)解耦。
  • 異步處理(Asynchronous Processing): 專門的監聽器(Listener)訂閲上述事件。這些監聽器被標記為 @Async,意味着它們將在獨立的線程池中執行,徹底釋放主線程,讓其專注於業務邏輯的處理。

這種組合模式的優勢在於:它既利用了AOP的非侵入性和靈活性來定位需要監控的點,又通過事件驅動實現了鬆耦合,最終依靠異步處理保證了主線程的高效運行。


3.代碼實現

3.1 環境依賴

<dependency>
   <groupId>org.springframework.boot</groupId>
   <artifactId>spring-boot-starter-aop</artifactId>
</dependency>

日誌實體類,這個可根據直接要求修改

@Data
@TableName("sys_log")
public class SysLog implements Serializable { // 實現 Serializable 是一個好的實踐

    private static final long serialVersionUID = 1L;

    /** ID */
    // 指定主鍵,並使用數據庫自增策略

    private String id;

    /** 日誌類型 */
    @TableField("log_type")
    private String logType;

    /** 創建用户編碼 */
    @TableField("create_user_code")
    private String createUserCode;

    /** 創建用户名稱 */
    @TableField("create_user_name")
    private String createUserName;

    /** 創建時間 */
    @TableField("create_date")
    private LocalDateTime createDate;

    /** 請求URI */
    @TableField("request_uri")
    private String requestUri;

    /** 請求方式 */
    @TableField("request_method")
    private String requestMethod;

    /** 請求參數 */
    @TableField("request_params")
    private String requestParams;

    /** 響應參數 */
    @TableField("response_params")
    private String responseParams;

    /** 請求IP */
    @TableField("request_ip")
    private String requestIp;

    /** 請求服務器地址 */
    @TableField("server_address")
    private String serverAddress;

    /** 是否異常 ('0': 正常, '1': 異常) */
    @TableField("is_exception")
    private String isException;

    /** 異常信息 */
    @TableField("exception_info")
    private String exceptionInfo;

    /** 開始時間 */
    @TableField("start_time")
    private LocalDateTime startTime;

    /** 結束時間 */
    @TableField("end_time")
    private LocalDateTime endTime;

    /** 執行時間 (毫秒) */
    @TableField("execute_time")
    private Integer executeTime;

    /** 用户代理 */
    @TableField("user_agent")
    private String userAgent;

    /** 操作系統 */
    @TableField("device_name")
    private String deviceName;

    /** 瀏覽器名稱 */
    @TableField("browser_name")
    private String browserName;
}


3.2 在主程序開啓異步

在 主應用類或配置類上啓用了異步支持 @EnableAsync

@EnableAsync // <-- 啓用異步方法執行
@SpringBootApplication
public class Application {

    public static void main(String[] args) {
        SpringApplication.run(BomCompareApplication.class, args);
    }

}

3.3 編寫切面

切面負責攔截目標方法調用,並在方法執行前後收集所需信息,最後發佈事件;如果想監聽自己定義的註解,例如:@Pointcut(value = "@annotation(com.xncoding.aop.aspect.UserAccess)") ,具體可以參考之前的文章:https://www.cnblogs.com/blbl-blog/p/17944006

@Slf4j
@Aspect
@Component
public class LogAspect {



    @Autowired
    private AsyncLogService asyncLogService; // 注入異步服務


    // 這個目前是對從 com.xncoding.aop.controller.* 下的都進行切入,如果想對上面的自定義註解進行切入,只需改成相對應的路徑
    // 例如:@Pointcut(value = "@annotation(com.xncoding.aop.aspect.UserAccess)")

    @Pointcut("execution(public * com.example.bomcompare.controller.*.*(..))")
    public void webLog(){}

    //環繞通知,環繞增強,相當於MethodInterceptor
    @Around("webLog()")
    public Object arround(ProceedingJoinPoint pjp) throws Throwable{
        System.out.println("方法環繞start.....");

        // --- 1. 初始化日誌信息 ---
        String requestId = UUID.randomUUID().toString(); // 生成唯一請求ID
        LocalDateTime startTime = LocalDateTime.now();
        long startTimeMillis = System.currentTimeMillis();

        String url = "N/A";
        String method = "N/A";
        String ip = "N/A";
        String userAgent = "N/A"; // 新增獲取 User-Agent

        String className = pjp.getSignature().getDeclaringTypeName();
        String methodName = pjp.getSignature().getName();
        String params = Arrays.toString(pjp.getArgs()); // 注意:可能包含敏感信息
        try {
            ServletRequestAttributes attributes = (ServletRequestAttributes) RequestContextHolder.getRequestAttributes();
            if (attributes == null) {
                log.warn("Not in a web request context, skipping request log.");
            }

            HttpServletRequest request = attributes.getRequest();
            url = request.getRequestURL().toString();
            method = request.getMethod();
            ip = getClientIpAddress(request);
            userAgent = request.getHeader("User-Agent"); // 獲取 User-Agent

            // --- 3. 記錄開始日誌 (可選,用於調試) ---
            log.info("Captured Request [ID: {}]: URL={}, Method={}, IP={}, Class.Method={}.{}, Args={}",
                    requestId, url, method, ip, className, methodName, params);
        } catch (Exception e) {
            log.warn("Could not extract HTTP request details in aspect.", e);
        }

        // --- 4. 執行目標方法並捕獲結果/異常 ---
        Object result = null;
        String errorMessage = null;
        Exception caughtException = null; // 用於存儲捕獲到的異常對象

        try {
            result = pjp.proceed(); // 執行被攔截的方法
        } catch (Exception ex) { // 捕獲所有檢查和非檢查異常
            caughtException = ex;
            errorMessage = ex.getClass().getSimpleName() + ": " + ex.getMessage(); // 記錄簡化的錯誤信息
            // 重要:必須重新拋出異常,否則原調用方無法感知到異常的發生
            throw ex;
        } finally {
            // --- 5. 計算執行時間和準備最終日誌 ---
            long endTimeMillis = System.currentTimeMillis();
            long executionTime = endTimeMillis - startTimeMillis;
            LocalDateTime endTime = LocalDateTime.now();

            String resultStr = "N/A";
            if (result != null) {
                // 注意:直接 toString() 大對象可能導致性能問題或日誌過大
                // 可以考慮限制長度或根據類型特殊處理
                resultStr = result.toString();
            }

            // 將所有收集到的信息傳遞給異步服務
            asyncLogService.saveRequestLog(
                    requestId,
                    url,
                    method,
                    ip,
                    className,
                    methodName,
                    params,
                    resultStr,
                    errorMessage,
                    executionTime,
                    startTime,
                    endTime,
                    userAgent // 傳遞 User-Agent
            );

            // 返回目標方法的原始結果
            return result;
       }
    }

    // --- 輔助方法:獲取客户端真實IP ---
    private String getClientIpAddress(HttpServletRequest request) {
        String xfHeader = request.getHeader("X-Forwarded-For");
        if (xfHeader != null && !xfHeader.isEmpty() && !"unknown".equalsIgnoreCase(xfHeader)) {
            // X-Forwarded-For 可能包含多個IP,取第一個(通常是客户端)
            return xfHeader.split(",")[0].trim();
        }
        String xriHeader = request.getHeader("X-Real-IP");
        if (xriHeader != null && !xriHeader.isEmpty() && !"unknown".equalsIgnoreCase(xriHeader)) {
            return xriHeader;
        }
        return request.getRemoteAddr();
    }
}

3.4 處理日誌和保存邏輯

日誌按照自己需求保存到數據庫或者寫成文檔等操作

@Slf4j
@Service // 讓 Spring 管理這個 Service
public class AsyncLogService {

    @Autowired
    SysLogMapper sysLogMapper;



    /**
     * 異步保存 API 調用日誌到數據庫。
     *
     * @param requestId       唯一請求ID
     * @param url             請求URL
     * @param method          HTTP方法 (GET, POST等)
     * @param ip              客户端IP地址
     * @param className       被調用的類名
     * @param methodName      被調用的方法名
     * @param params          方法參數 (字符串形式)
     * @param result          方法返回結果 (字符串形式)
     * @param errorMessage    錯誤信息 (如果沒有則為null)
     * @param executionTime   方法執行時間 (毫秒)
     * @param startTime       請求開始時間
     * @param endTime         請求結束時間
     * @param userAgent       用户代理字符串
     */
    @Async // <-- 標記為異步方法
    public void saveRequestLog(String requestId, String url, String method, String ip,
                               String className, String methodName, String params,
                               String result, String errorMessage, long executionTime,
                               LocalDateTime startTime, LocalDateTime endTime, String userAgent) {
        try {

            SysLog logEntry = new SysLog();
            logEntry.setId(requestId); // 設置日誌類型
            logEntry.setLogType("API_CALL"); // 設置日誌類型


            logEntry.setCreateUserCode("ANONYMOUS"); // 默認匿名用户或系統用户
            logEntry.setCreateUserName("Anonymous");

            logEntry.setCreateDate(LocalDateTime.now()); // 創建時間通常為當前時間
            logEntry.setRequestUri(url);
            logEntry.setRequestMethod(method);
            logEntry.setRequestParams(params); // 注意敏感信息處理
            logEntry.setResponseParams(result); // 注意大對象或敏感信息處理
            logEntry.setRequestIp(ip);

            // 獲取服務器地址
            try {
                logEntry.setServerAddress(InetAddress.getLocalHost().getHostAddress());
            } catch (Exception e) {
                log.warn("Could not determine server address", e);
                logEntry.setServerAddress("UNKNOWN");
            }

            // 設置異常狀態和信息
            if (errorMessage != null && !errorMessage.isEmpty()) {
                logEntry.setIsException("1"); // 有異常
                logEntry.setExceptionInfo(errorMessage);
            } else {
                logEntry.setIsException("0"); // 無異常
                logEntry.setExceptionInfo(null); // 清空異常信息
            }

            logEntry.setStartTime(startTime);
            logEntry.setEndTime(endTime);
            logEntry.setExecuteTime((int) executionTime); // 轉換為 Integer

            // 設置 User-Agent 和解析的設備/瀏覽器信息 (簡單示例,可用 UserAgentUtils 等庫加強)
            logEntry.setUserAgent(userAgent);
            // --- 簡單解析 User-Agent 示例 (可選) ---
            if (userAgent != null) {
                if (userAgent.toLowerCase().contains("windows")) {
                    logEntry.setDeviceName("Windows");
                } else if (userAgent.toLowerCase().contains("mac")) {
                    logEntry.setDeviceName("Mac");
                } else if (userAgent.toLowerCase().contains("linux")) {
                    logEntry.setDeviceName("Linux");
                } else {
                    logEntry.setDeviceName("Other");
                }

                if (userAgent.toLowerCase().contains("chrome")) {
                    logEntry.setBrowserName("Chrome");
                } else if (userAgent.toLowerCase().contains("firefox")) {
                    logEntry.setBrowserName("Firefox");
                } else if (userAgent.toLowerCase().contains("safari")) {
                    logEntry.setBrowserName("Safari");
                } else {
                    logEntry.setBrowserName("Other");
                }
            }
            // --- /簡單解析 User-Agent 示例 ---

            // 2. 調用 MyBatis-Plus Mapper 保存到數據庫
            sysLogMapper.insert(logEntry); // insert 方法由 BaseMapper 提供

            log.debug("[ASYNC DB SAVE] Successfully saved log entry for request ID: {}", requestId);

        } catch (Exception e) {
            // 異步方法內的異常通常不會直接影響主線程,
            // 但需要捕獲並記錄,防止線程因未捕獲異常而終止
            // 並且要確保不會因為日誌記錄失敗導致系統問題
            log.error("Error occurred while saving API call log asynchronously for request ID: {}", requestId, e);
            // 根據需求,可以選擇在這裏重試、發送告警等
        }
    }
}

4. 性能優勢分析

相較於在主線程中直接執行日誌記錄(如同步調用 sysLogMapper.insert()),異步監聽切面模式帶來了顯著的性能提升:

  1. 降低主線程阻塞: 主業務邏輯在執行完 proceed() 後立即返回(或拋出異常),後續的日誌持久化操作交由後台線程處理,主線程得以迅速釋放,可以繼續處理下一個請求。
  2. 提高吞吐量: 由於主線程不再等待耗時的 I/O 操作,系統的整體請求處理能力(QPS)得到增強。
  3. 增強系統穩定性: 即使日誌記錄過程出現延遲或暫時性故障(如數據庫連接池滿),也不會直接影響 API 的響應時間和可用性。異步監聽器內部的異常被捕獲,避免了因輔助功能失敗而導致整個請求失敗。
  4. 更好的資源隔離: 日誌記錄任務在獨立的線程池中運行,可以對其進行獨立的資源管理和調優(如調整線程池大小),而不會干擾核心業務線程池。
  5. 代碼清晰度與可維護性: AOP 將日誌記錄邏輯從業務代碼中剝離,事件驅動使得組件間關係更加鬆散,易於擴展新的監聽器(如發送郵件通知、更新統計指標等)。

5. 注意事項與最佳實踐

  • 合理配置線程池: @EnableAsync 默認使用的 SimpleAsyncTaskExecutor 會在每次調用時創建新線程,生產環境務必自定義 TaskExecutor Bean 來複用線程,避免資源耗盡。
  • 處理監聽器異常: 異步監聽器內部的異常必須被捕獲並妥善處理,否則可能導致線程意外終止,影響日誌記錄的可靠性。
  • 日誌信息脱敏: 在記錄 paramsresult 時,需注意過濾敏感信息(如密碼、身份證號等)。
  • 批量處理優化: 對於高頻次的寫入場景,可以在監聽器內部引入隊列和批處理機制,進一步減少數據庫交互次數。
  • 監控與告警: 監控異步監聽器的執行情況(如處理延遲、失敗率)是保障系統穩定性的關鍵。

6. 結論

通過巧妙地融合 AOP、事件驅動和異步處理,我們構建了一套高效、低耦合的系統監控與日誌記錄方案。該方案不僅提升了系統的響應速度和吞吐量,還增強了架構的健壯性和可維護性。這是一種值得推廣的設計模式,尤其適用於那些對性能有較高要求且需要記錄大量運行時信息的應用場景。掌握並靈活運用此類技術,是每一位致力於打造高性能Java應用開發者的重要技能。

user avatar
0 位用戶收藏了這個故事!

發佈 評論

Some HTML is okay.