基於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: 快速開始
@Slf4jCheck
註解然後在需要被追蹤的方法上加@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)});
}
對於複雜場景,你可以利用這個註解靈活的追蹤任意變量,記錄變量被賦予的所有值。
@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);
}
}
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')
將-Djps.track.ap.dependencies
填入上圖指定位置即可解決。