SpringBoot | 第二十四章:日誌管理之AOP統一日誌

longmanma發表於2021-09-09
前言

上一章節,介紹了目前開發中常見的log4j2logback日誌框架的整合知識。在很多時候,我們在開發一個系統時,不管出於何種考慮,比如是審計要求,或者防抵賴,還是保留操作痕跡的角度,一般都會有個全域性記錄日誌的模組功能。此模組一般上會記錄每個對資料有進行變更的操作記錄,若是在web應用上,還會記錄請求的url,請求的IP,及當前的操作人,操作的方法說明等等。在很多時候,我們需要記錄請求的引數資訊時,通常是利用攔截器過濾器或者AOP等來進行統一攔截。本章節,就主要來說一說如何利用AOP實現統一的web日誌記錄。

一點知識

何為AOP

AOP全稱:Aspect Oriented Programming。是一種面向切面程式設計的,利用預編譯方式和執行期動態代理實現程式功能統一的一種技術。它也是Spring很重要的一部分,和IOC一樣重要。利用AOP可以很好的對業務邏輯的各個部分進行隔離,從而使得業務邏輯各部分之間的耦合度降低,提高程式的可重用性,同時提高了開發的效率。

簡單來說,就是AOP可以在既有的程式基礎上,在無程式碼嵌入前提下完成對相關業務的處理,業務方可以只關注自身業務的邏輯,而無需關係一些和業務無關的事項,比如最常見的日誌事務許可權檢驗效能統計統一異常處理等等。

spring官網給出的AOP介紹如下:

圖片描述

AOP基本概念

關於AOP的相關介紹可點選官網連結檢視:

圖片描述

以下簡單的說明下:

  1. 切面(Aspect):切面是一個關注點的模組化,這個關注點可能是橫切多個物件;

  2. 連線點(Join Point):連線點是指在程式執行過程中某個特定的點,比如某方法呼叫的時候或者處理異常的時候;

  3. 通知(Advice):指在切面的某個特定的連線點上執行的動作。Spring切面可以應用5中通知:
    • 前置通知(Before):在目標方法或者說連線點被呼叫前執行的通知;
    • 後置通知(After):指在某個連線點完成後執行的通知;
    • 返回通知(After-returning):指在某個連線點成功執行之後執行的通知;
    • 異常通知(After-throwing):指在方法丟擲異常後執行的通知;
    • 環繞通知(Around):指包圍一個連線點通知,在被通知的方法呼叫之前和之後執行自定義的方法。
  4. 切點(Pointcut):指匹配連線點的斷言。通知與一個切入點表示式關聯,並在滿足這個切入的連線點上執行,例如:當執行某個特定的名稱的方法。

  5. 引入(Introduction):引入也被稱為內部型別宣告,宣告額外的方法或者某個型別的欄位。

  6. 目標物件(Target Object):目標物件是被一個或者多個切面所通知的物件。

  7. AOP代理(AOP Proxy):AOP代理是指AOP框架建立的對物件,用來實現切面契約(包括通知方法等功能)

  8. 織入(Wearving):指把切面連線到其他應用出程式型別或者物件上,並建立一個被通知的物件。或者說形成代理物件的方法的過程。

以下這張圖,對以上部分概念進行簡單介紹:

圖片描述

代理機制

SpirngAOP的動態代理實現機制有兩種,分別是:JDK動態代理CGLib動態代理。簡單介紹下兩種代理機制。

  • JDK動態代理

JDK動態代理面向介面代理模式,如果被代理目標沒有介面那麼Spring也無能為力,Spring透過java的反射機制生產被代理介面的新的匿名實現類,重寫了其中AOP的增強方法。

  • CGLib動態代理

CGLib是一個強大、高效能的Code生產類庫,可以實現執行期動態擴充套件java類,Spring在執行期間透過 CGlib繼承要被動態代理的類,重寫父類的方法,實現AOP面向切面程式設計。

兩者對比:

  1. JDK動態代理是面向介面,在建立代理實現類時比CGLib要快,建立代理速度快。而且JDK動態代理只能對實現了介面的類生成代理,而不能針對類。

  2. CGLib動態代理是透過位元組碼底層繼承要代理類來實現(如果被代理類被final關鍵字所修飾,那麼抱歉會失敗),在建立代理這一塊沒有JDK動態代理快,但是執行速度比JDK動態代理要快。

至於相關原理,大家自行搜尋下吧,⊙﹏⊙‖∣

切入點指示符簡單介紹

為了能夠靈活定義切入點位置,Spring AOP提供了多種切入點指示符。以下簡單的介紹下。

  • execution:匹配執行方法的連線點

圖片描述

可以從上圖中,看見切入點指示符execution的語法結構為:execution(modifiers-pattern? ret-type-pattern declaring-type-pattern? name-pattern(param-pattern) throws-pattern?)。這也是最常使用的一個指示符了。

  • within:用於匹配指定型別內的方法執行;

  • this:用於匹配當前AOP代理物件型別的執行方法;注意是AOP代理物件的型別匹配,這樣就可能包括引入介面也型別匹配;

  • target:用於匹配當前目標物件型別的執行方法;注意是目標物件的型別匹配,這樣就不包括引入介面也型別匹配;

  • args:用於匹配當前執行的方法傳入的引數為指定型別的執行方法;

  • @within:用於匹配所以持有指定註解型別內的方法;

  • @target:用於匹配當前目標物件型別的執行方法,其中目標物件持有指定的註解;

  • @args:用於匹配當前執行的方法傳入的引數持有指定註解的執行;

  • @annotation:用於匹配當前執行方法持有指定註解的方法;

  • bean:Spring AOP擴充套件的,AspectJ沒有對於指示符,用於匹配特定名稱的Bean物件的執行方法;

  • reference pointcut:表示引用其他命名切入點,只有@ApectJ風格支援,Schema風格不支援。

對於相關的語法和使用,大家可檢視:https://blog.csdn.net/zhengchao1991/article/details/53391244。裡面有較為詳細的介紹。這裡就不多加闡述了。

統一日誌記錄

介紹完相關知識後,我們開始來使用AOP實現統一的日誌記錄功能。本文直接利用@Around環繞模式來實現,同時自定義一個日誌註解類,來個性化記錄日誌資訊。

0.加入Aop依賴。

org.springframework.bootspring-boot-starter-aop

1.編寫自定義日誌註解類Log

/**
 * 日誌註解類
 * @author oKong
 *
 */
@Documented
@Retention(RetentionPolicy.RUNTIME)
@Target({ElementType.METHOD})//只能在方法上使用此註解
public @interface Log {
    /**
     * 日誌描述,這裡使用了@AliasFor 別名。spring提供的
     * @return
     */
    @AliasFor("desc")
    String value() default "";

    /**
     * 日誌描述
     * @return
     */
    @AliasFor("value")
    String desc() default "";

    /**
     * 是否不記錄日誌
     * @return
     */
    boolean ignore() default false;
}

友情提示:熟悉Spring常用註解類的朋友,對@AliasFor應該不陌生。它是Spring提供的一個註解,主要是給註解的屬性起名別的。讓使用註解時,更加的容易理解(比如給value屬性起別名)。一般上是配對別名。由於是Spring框架提供的,所以要使其生效,可以使用AnnotationUtils.synthesizeAnnotation或者AnnotationUtils.getAnnotation方法呼叫獲取註解,以下程式碼中會有個簡單示例。

2.編寫切面類。

/**
 * 日誌切面類
 * @author xiedeshou
 *
 */
//加入@Aspect 申明一個切面
@Aspect
@Component
@Slf4j
public class LogAspect {

    //設定切入點:這裡直接攔截被@RestController註解的類
    @Pointcut("within(@org.springframework.web.bind.annotation.RestController *)")
    public void pointcut() {

    }

    /**
     * 切面方法,記錄日誌
     * @return
     * @throws Throwable 
     */
    @Around("pointcut()")
    public Object doAround(ProceedingJoinPoint joinPoint) throws Throwable {
        long beginTime = System.currentTimeMillis();//1、開始時間 
        //利用RequestContextHolder獲取requst物件
        ServletRequestAttributes requestAttr = (ServletRequestAttributes)RequestContextHolder.currentRequestAttributes();
        String uri = requestAttr.getRequest().getRequestURI();
        log.info("開始計時: {}  URI: {}", new Date(),uri);
        //訪問目標方法的引數 可動態改變引數值
        Object[] args = joinPoint.getArgs();
        //方法名獲取
        String methodName = joinPoint.getSignature().getName();
        log.info("請求方法:{}, 請求引數: {}", methodName, Arrays.toString(args));
        //可能在反向代理請求進來時,獲取的IP存在不正確行 這裡直接摘抄一段來自網上獲取ip的程式碼
        log.info("請求ip:{}", getIpAddr(requestAttr.getRequest()));

        Signature signature = joinPoint.getSignature();
        if(!(signature instanceof MethodSignature)) {
            throw new IllegalArgumentException("暫不支援非方法註解");
        }
        //呼叫實際方法
        Object object = joinPoint.proceed();
        //獲取執行的方法
        MethodSignature methodSign = (MethodSignature) signature;
        Method method = methodSign.getMethod();
        //判斷是否包含了 無需記錄日誌的方法
        Log logAnno = AnnotationUtils.getAnnotation(method, Log.class);
        if(logAnno != null && logAnno.ignore()) {
            return object;
        } 
        log.info("log註解描述:{}", logAnno.desc());
        long endTime = System.currentTimeMillis();
        log.info("結束計時: {},  URI: {},耗時:{}", new Date(),uri,endTime - beginTime);
        //模擬異常
        //System.out.println(1/0);
        return object;
    }

    /**
     * 指定攔截器規則;也可直接使用within(@org.springframework.web.bind.annotation.RestController *)
     * 這樣簡單點 可以通用
     * @param 異常物件
     */
    @AfterThrowing(pointcut="pointcut()",throwing="e")
    public void afterThrowable(Throwable e) {
        log.error("切面發生了異常:", e);
        //這裡可以做個統一異常處理
        //自定義一個異常 包裝後排除
        //throw new AopException("xxx);
    }

    /**
     * 轉至:https://my.oschina.net/u/994081/blog/185982
     */
    public static String getIpAddr(HttpServletRequest request) {
        String ipAddress = null;
        try {
            ipAddress = request.getHeader("x-forwarded-for");
            if (ipAddress == null || ipAddress.length() == 0 || "unknown".equalsIgnoreCase(ipAddress)) {
                ipAddress = request.getHeader("Proxy-Client-IP");
            }
            if (ipAddress == null || ipAddress.length() == 0 || "unknown".equalsIgnoreCase(ipAddress)) {
                ipAddress = request.getHeader("WL-Proxy-Client-IP");
            }
            if (ipAddress == null || ipAddress.length() == 0 || "unknown".equalsIgnoreCase(ipAddress)) {
                ipAddress = request.getRemoteAddr();
                if (ipAddress.equals("127.0.0.1")) {
                    // 根據網路卡取本機配置的IP
                    InetAddress inet = null;
                    try {
                        inet = InetAddress.getLocalHost();
                    } catch (UnknownHostException e) {
                        log.error("獲取ip異常:{}" ,e.getMessage());
                        e.printStackTrace();
                    }
                    ipAddress = inet.getHostAddress();
                }
            }
            // 對於透過多個代理的情況,第一個IP為客戶端真實IP,多個IP按照','分割
            if (ipAddress != null && ipAddress.length() > 15) { // "***.***.***.***".length()
                                                                // = 15
                if (ipAddress.indexOf(",") > 0) {
                    ipAddress = ipAddress.substring(0, ipAddress.indexOf(","));
                }
            }
        } catch (Exception e) {
            ipAddress = "";
        }
        // ipAddress = this.getRequest().getRemoteAddr();

        return ipAddress;
    }    
}

3.啟動類加入註解@EnableAspectJAutoProxy,生效註解。另一說法,預設引入pom依賴就是預設開啟的。無所謂,加了就是了,加上總之是個好習慣,因為不知道後續版本是否會修改預設值呢~

@SpringBootApplication
@EnableAspectJAutoProxy
@Slf4j
public class Chapter24Application {

    public static void main(String[] args) {
        SpringApplication.run(Chapter24Application.class, args);
        log.info("Chapter24啟動!");
    }
}

4.編寫控制層。

/**
 * aop統一異常示例
 * @author xiedeshou
 *
 */
@RestController
public class DemoController {
    /**
     * 簡單方法示例
     * @param hello
     * @return
     */
    @RequestMapping("/aop")
    @Log(value="請求了aopDemo方法")
    public String aopDemo(String hello) {
        return "請求引數為:" + hello;
    }

    /**
     * 不攔截日誌示例
     * @param hello
     * @return
     */
    @RequestMapping("/notaop")
    @Log(ignore=true)
    public String notAopDemo(String hello) {
        return "此方法不記錄日誌,請求引數為:" + hello;
    }
}

友情提示:在編寫了切面類後,若符合切面攔截條件的方法,IDE會進行標識的。

圖片描述

5.啟動應用,訪問api,即可看見控制檯輸出了對應資訊了。

訪問了:/aop,輸出

2018-08-23 22:54:59.003  INFO 12928 --- [nio-8080-exec-3] c.l.l.s.chapter24.config.LogAspect       : 開始計時: Fri Aug 23 22:54:59 CST 2018  URI: /aop
2018-08-23 22:54:59.004  INFO 12928 --- [nio-8080-exec-3] c.l.l.s.chapter24.config.LogAspect       : 請求方法:aopDemo, 請求引數: [oKong]
2018-08-23 22:54:59.005  INFO 12928 --- [nio-8080-exec-3] c.l.l.s.chapter24.config.LogAspect       : 請求ip:192.168.2.107
2018-08-23 22:54:59.005  INFO 12928 --- [nio-8080-exec-3] c.l.l.s.chapter24.config.LogAspect       : log註解描述:請求了aopDemo方法
2018-08-23 22:54:59.005  INFO 12928 --- [nio-8080-exec-3] c.l.l.s.chapter24.config.LogAspect       : 結束計時: Fri Aug 23 22:54:59 CST 2018,  URI: /aop,耗時:2
參考資料
  1. https://blog.csdn.net/zhengchao1991/article/details/53391244
  2. https://blog.csdn.net/wqh8522/article/details/72887209
總結

本文主要是簡單介紹了利用AOP實現統一的web日誌記錄功能。本示例未演示日誌入庫功能,大家可自行實現。在實際開發過程中,一般上都是將日誌儲存進行非同步化後進行入庫處理的,這點需要注意,日誌記錄不能影響正常的方法請求,若是同步的,會本末倒置的。本文只是簡單的使用環繞機制進行講解,大家還可以試試其他的註解進行相應實踐下,大都大同小異,只是要注意下各註解的觸發時機。

最後

目前網際網路上很多大佬都有SpringBoot系列教程,如有雷同,請多多包涵了。本文是作者在電腦前一字一句敲的,每一步都是自己實踐和理解的。若文中有所錯誤之處,還望提出,謝謝。

老生常談
  • 個人QQ:499452441
  • 微信公眾號:lqdevOps

圖片描述

個人部落格:http://blog.lqdev.cn

完整示例:

來自 “ ITPUB部落格 ” ,連結:http://blog.itpub.net/2730/viewspace-2812475/,如需轉載,請註明出處,否則將追究法律責任。

相關文章