寫了個追蹤業務異常的工具,歡迎體驗!

基於java的註解處理器寫了個自動植入業務追蹤日誌的工具,目前snapshot版本已上線,歡迎體驗和提出意見,感謝! 

原文檔(建議直接閱讀這個):LogTrace使用指南

建議的版本號:

jdk:8、9、10、11

gradle:7+

Part1: 解決的問題

本產品嚐試解決以下場景的問題:假設現在有一塊依賴了很多上下游服務的代碼,且上下游的返回決定了它的邏輯走向,其中彎彎繞繞的if-else一大堆,除了沒寫註釋外,還沒有打印任何日誌,舉個例子:


public Student complexScene(String... args) {
    if(args == null || args.length == 0) {
        return null;
    }

    int aResult = aService.getA(args[0]); //假設getA底層是通過數據庫拿到的結果
    if(aResult > 5){
        return null;
    }

    List bResults = bService.getBs(args[0]); //假設getBs是通過一個rpc服務拿到的結果
    if(bResults != null && bResults.size() > 0){
        Student student = new Student();
        student.setAge(aResult);
        student.setName(bResults.get(0));
        return student;
    }
    return null;
}

這段代碼中有3種邏輯走向會返回null,假如現在這塊邏輯在生產環境突然返回了不符合預期的結果(比如應該返回student,卻返回了null),需要排查問題,你會怎麼做?

 

你可能會想到利用可觀測系統(即監控+日誌+鏈路追蹤系統)進行一系列分析,最終得出結論,但這隻適用於上下游服務異常的情況(IO錯誤),像上面這種情況各方調用都是正常的,僅僅是返回了不符合預期的結果而已,在這種場景下可觀測系統就顯得力不從心了。

排查這類問題,最簡單的方式就是給每個影響邏輯走向的地方打上追蹤日誌:


public Student complexScene(String... args) {
    if(args == null || args.length == 0) {
        log.debug("args == null or length == 0 is true! args={}", args); //邏輯追蹤日誌
        return null;
    }

    int aResult = aService.getA(args[0]);
    if(aResult > 5){
        log.debug("aResult > 5 is true! aResult={}", aResult); //邏輯追蹤日誌
        return null;
    }

    List bResults = bService.getBs(args[0]);
    log.debug("bResults={}", bResults); //邏輯追蹤日誌
    if(bResults != null && bResults.size() > 0){
        Student student = new Student();
        student.setAge(aResult);
        student.setName(bResults.get(0));
        return student;
    }
    return null;
}

這樣就可以通過日誌系統分析出邏輯走向。

這只是個簡單的例子,在實際開發中往往有巨複雜的邏輯,最典型的就是網關接口,內部可能聚合了高達十幾個rpc服務的返回值,中間產生的條件判斷邏輯更是數不勝數, 像這種場景一旦返回了不符合預期的結果,如果沒有追蹤日誌排查起來將會極其痛苦。 

雖然通過追蹤日誌很容易排查出問題所在,但打印這些日誌是麻煩的,你要考慮在哪裏打,輸出哪些數據,格式應該怎樣,如何避免打印無意義的日誌。
LogTrace就是用來解決這些問題的,它會自動解析語法樹,在影響邏輯走向的地方植入風格統一的業務追蹤日誌,下面來看看它具體的用法。

Part2: 導包 

將下面的jar包導入到你的項目中 

⚠️ 注意:
Slf4j和logback是必須的,如果你項目中已經引入了,就不用再引了
除了logback,引入其他Slf4j標準實現也可以,如log4j

gradle:


compileOnly 'io.github.exceting:log-trace:0.0.1-SNAPSHOT'
annotationProcessor 'io.github.exceting:log-trace:0.0.1-SNAPSHOT'

maven:


<dependencies>
    <dependency>
        <groupId>org.slf4j</groupId>
        <artifactId>slf4j-api</artifactId>
        <version>1.7.7</version>
    </dependency>
    <dependency>
    <groupId>ch.qos.logback</groupId>
        <artifactId>logback-classic</artifactId>
        <version>1.2.9</version>
    </dependency>
    <dependency>
        <groupId>io.github.exceting</groupId>
        <artifactId>log-trace</artifactId>
        <version>0.0.1-SNAPSHOT</version>
    </dependency>
</dependencies>

<build>
    <plugins>
        <plugin>
            <groupId>org.apache.maven.plugins</groupId>
            <artifactId>maven-compiler-plugin</artifactId>
            <configuration>
                <source>1.8</source>
                <target>1.8</target>
                <annotationProcessors>
                    <annotationProcessor>io.github.exceting.cicada.tools.logtrace.LogTraceProcessor</annotationProcessor>
                </annotationProcessors>
            </configuration>
        </plugin>
    </plugins>
</build>

現在的包是snapshot版本(正式版需要進行更多的測試case後才能發佈),所以要把sonatype的snapshot倉庫依賴加進來:

gradle:


//將snapshot倉庫加到repositories裏
maven { url "https://s01.oss.sonatype.org/content/repositories/snapshots/" }

maven:

<!-- 將snapshot倉庫加到<repositories>裏-->
<repository>
    <id>snapshots</id>
    <name>sonatype snapshot</name>
    <url>https://s01.oss.sonatype.org/content/repositories/snapshots/</url>
</repository>

Part3: 快速開始

確定jar包和倉庫已經配好後開始快速使用,首先在測試類上加@Slf4jCheck註解

@Slf4j註解

 然後在需要被追蹤的方法上加@MethodLog註解,運行效果如圖: 

@MethodLog註解

Part4: 格式&基本原理

通過LogTrace植入的追蹤日誌統一格式如下: 

日誌格式

LogTrace的工作原理與lombok一致,都是在編譯期解析語法樹,通過對應的註解增強原有代碼,即在編譯期修改源代碼的方式實現, 參考這裏 ,它是對java源代碼的增強,除此之外還有增強字節碼的技術,如asm和javassist。

Part5: 註解&用法

@Slf4jCheck 

每個需要打追蹤日誌的類上都應該加上這個註解,加上此註解後,類內會自動創建一個Slf4j的Logger對象,作用等同於lombok的@Slf4j且兼容lombok。
它有一個屬性:

  • isOpen:用來控制是否輸出追蹤日誌,默認爲空(輸出),支持定製AtomicBoolean開關,靈活控制是否輸出日誌,對全局方法生效,開關這塊內容會放到自定義開關小節詳細介紹,這裏不再贅述。

@MethodLog 

除了要在類上加@Slf4jCheck,還要在每個需要植入追蹤日誌的方法上加上@MethodLog註解,程序運行起來後,只會給加了此註解的方法植入追蹤日誌。
它有6個屬性: 

  • isOpen:默認爲空,作用跟@Slf4jCheck裏的isOpen一樣,但優先級更高,僅對當前方法生效。
  • traceLevel:默認爲Level.DEBUG,可通過此項定製追蹤日誌的級別。
  • exceptionLog:是否打印方法異常信息,爲true時開啓,默認false,它的增強效果如下:
    • 
      // 編譯前
      @MethodLog(exceptionLog = true)
      void methodTest() {
          // 方法體省略...
      }
      
      // ⬇⬇
      
      // 編譯後被LogTrace增強後的代碼
      void methodTest() {
          try {
              // 方法體省略...
          } catch(Exception e) { // try-catch
              log.error("LOG_TRACE >>>>>> OUTPUT: [METHOD: methodTest][TRY][LINE: 5] Error! Data: ", e); // 輸出錯誤日誌(注:異常日誌的級別強制爲error)
              throw e;
          }
      }
      
  • noThrow:需要和exceptionLog搭配使用,當它的值爲true時,則只catch異常,不拋出異常,默認false,它的增強效果如下:
    • 
      // 編譯前
      @MethodLog(exceptionLog = true, noThrow = true)
      void methodTest() {
          // 方法體省略...
      }
      
      // ⬇⬇
      
      // 編譯後被LogTrace增強後的代碼
      void methodTest() {
        try {
          // 方法體省略...
        } catch(Exception e) { //僅輸出日誌,不再throw異常
          log.error("LOG_TRACE >>>>>> OUTPUT: [METHOD: methodTest][TRY][LINE: 5] Error! Data: ", e);
        }
      }
      
  • dur:是否打印方法耗時?爲true時開啓,默認false,開啓後的增強邏輯如下:
    • 
      // 編譯前
      @methodTest(dur = true)
      void methodTest() {
        // 方法體省略...
      }
      
      // ⬇⬇
      
      // 編譯後被LogTrace增強後的代碼
      void methodTest() {
        // 植入的計數變量會加個UUID後綴,防止局部變量衝突
        long start_${UUID} = System.nanoTime();
        try{
          // 方法體省略...
        } finally { //打印出本方法執行耗時
          log.debug("LOG_TRACE >>>>>> OUTPUT: [METHOD: methodTest][TRY][LINE: 5] Finished! Data: duration = {}", (System.nanoTime()-start_${UUID})/1000000L);
        }
      }
      
  • onlyVar:是否只打印變量追蹤日誌?默認false,爲false時,那些加了@MethodLog的方法,會在所有影響邏輯走勢的地方都加上追蹤日誌(即方法內任意地方的任意if、if-else,switch-case語句),增強效果如下:
    • 
      // 編譯前
      @methodTest(dur = true)
      void methodTest() {
        // 方法體省略...
      }
      
      // ⬇⬇
      
      // 編譯後被LogTrace增強後的代碼
      void methodTest() {
        // 植入的計數變量會加個UUID後綴,防止局部變量衝突
        long start_${UUID} = System.nanoTime();
        try{
          // 方法體省略...
        } finally { //打印出本方法執行耗時
          log.debug("LOG_TRACE >>>>>> OUTPUT: [METHOD: methodTest][TRY][LINE: 5] Finished! Data: duration = {}", (System.nanoTime()-start_${UUID})/1000000L);
        }
      }
      

      如果onlyVar爲true,這些日誌將不再打印,這時就只會打印方法體中被@VarLog標註的局部變量日誌(@VarLog後面會介紹)。
      如果你認爲不需要那麼詳細的追蹤日誌,可以利用此項放棄這些日誌。

@VarLog

對於方法體中局部變量的追蹤,如果你要對方法體中某個局部變量感興趣,可以在其聲明的位置打上這個註解,之後這個變量的值會被追蹤,增強過程如下:


// 編譯前
@MethodLog
void methodTest() {
    @VarLog //利用@VarLog追蹤局部變量a
    int a = getA(); //假設getA是調用另一個RPC服務來拿a的值
    a=5;
}

// ⬇⬇

// 編譯後被LogTrace增強後的代碼
void methodTest() {
    int a = getA(); //⬇追蹤後會打印a的值
    log.debug("LOG_TRACE >>>>>> OUTPUT: [METHOD: methodTest][VARIABLE][LINE: 28]  Data: a = {}", new Object[]{Integer.valueOf(a)});
    a = 5; //⬇之後局部變量在程序中的任意位置被重新賦值,都會將其新值打印出來
    log.debug("LOG_TRACE >>>>>> OUTPUT: [METHOD: methodTest][VARIABLE][LINE: 31]  Data: a = {}", new Object[]{Integer.valueOf(a)});
}
除此之外,這個註解還包含一個dur屬性,默認值爲false,當設置爲true時,會打印獲取這個變量所消耗的時間。

對於複雜場景,你可以利用這個註解靈活的追蹤任意變量,記錄變量被賦予的所有值。

@Ban 

所有追蹤日誌在打印時,會無腦打印方法的入參,如果你不需要某個參數被打印,就給它加上這個註解:


// 編譯前
@MethodLog
void methodTest(int a, @Ban int b, int c) { //禁止打印參數b
    if(a == 1){
        //業務代碼省略...
    }
}

// ⬇⬇

// 編譯後被LogTrace增強後的代碼
void methodTest(int a, int b, int c) {
    Object final_c = c;
    Object final_a = a;
    if (a == 1) { //⬇在植入的追蹤日誌中,打印入參時,只打印a和c,被@Ban修飾的b則不打印
        log.debug("LOG_TRACE >>>>>> OUTPUT: [METHOD: methodTest][IF][LINE: 30] The condition: (a == 1) is true! Data: a = {}, c = {}", new Object[]{final_a, final_c});
    }
}

Part6: 自定義日誌開關

LogTrace提供非常靈活的開關定製方式,在@Slf4jCheck@MethodLog裏面通過isOpen屬性控制日誌是否輸出,默認輸出,@MethodLog優先級更高。

定製開關: 在任意類裏定義一個開關,這個開關必須是static、final的AtomicBoolean對象:


public static final AtomicBoolean isOpen = new AtomicBoolean(true)
利用全限定名#開關名的格式給isOpen屬性賦值:

@Slf4jCheck(isOpen = "io.cicada.mock.tools.config.Test#isOpen")
@MethodLog(isOpen = "io.cicada.mock.tools.config.Test#isOpen")
剩下的事情就很簡單了,你可以寫個定時任務定時從配置系統中獲取具體的開關值,來刷新這個對象的值(注意刷新的時候只刷值,不要改開關的引用指針!),從而利用配置系統靈活控制日誌的輸出:

@Component
public class OffOnTest {
    
    // 開關
    public static final AtomicBoolean isOpen = new AtomicBoolean(false);
    
    ScheduledExecutorService refreshTask = ThreadPools.newScheduledThreadPool("RefreshSwitch", 1);
    @PostConstruct
    private void init() {
        refreshTask.scheduleWithFixedDelay(() -> {
            // 定時刷新開關值,具體從哪裏獲取開關狀態,取決於你自己的需求,最典型的就是拉取遠程配置系統裏的值,這樣你只需要更新配置系統裏的配置,就能控制追蹤日誌是否打印
            isOpen.set(當前開關值);
        }, 5000, 5000, TimeUnit.MILLISECONDS);
    }
}
一些C端服務流量較高,如果擔心日誌的上報對性能有影響,可以通過開關來控制是否輸出追蹤日誌。

Part7: lombok可以讓它更好的工作

利用lombok生成對象的toString方法,將對象整個打印出來: 

日誌格式

 Part8: 常見問題

 使用maven運行時可能出現類型轉換錯誤異常:


java: java.lang.ClassCastException: class com.sun.proxy.$Proxy15 cannot be cast to class com.sun.tools.javac.processing.JavacProcessingEnvironment (com.sun.proxy.$Proxy15 is in unnamed module of loader java.net.URLClassLoader @59690aa4; com.sun.tools.javac.processing.JavacProcessingEnvironment is in module jdk.compiler of loader 'app')
解決:點開IDEA的settings選項,在彈出窗口找到如下位置:

日誌格式

 將-Djps.track.ap.dependencies填入上圖指定位置即可解決。

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章