作者:小傅哥
博客:https://bugstack.cn
Wiki:https://github.com/fuzhengwei/CodeGuide/wiki
沉澱、分享、成長,讓自己和他人都能有所收穫
一、前言
你開發的系統是裸奔
的嗎?深夜被老闆 Diss
一套系統是否穩定運行,取決於它的運行健康度,而這包括;調用量
、可用率
、響應時長
以及服務器性能等各項指標的一個綜合值。並且在系統出現異常問題時,可以抓取整個業務方法執行鏈路並輸出;當時的入參、出參、異常信息等等。當然還包括一些JVM、Redis、Mysql的各項性能指標,以用於快速定位並解決問題。
那麼要做到這樣的事情有什麼監控方案呢,這裏面的做法比較多。比如;
- 最簡單粗暴的可能就是硬編碼在方法中,收取執行耗時以及出入參和異常信息。但這樣的成本實在太大,而且有一些不可預估的風險。
- 可以選擇切面方式做一套統一監控的組件,相對來說還是好一些的。但也需要硬編碼,同時維護成本不低。
- 市面上對於這樣的監控其實是有整套的非入侵監控方案的,比如;
Google Dapper
、Zipkin
等都可以實現,他們都是基於探針技術非入侵的採用字節碼增強的方式進行監控。
好,那麼這樣非入侵的探針方式是怎麼實現的呢?如何去做方法的字節碼增強
?
在字節碼增強方面有三個框架;ASM
、Javassist
、ByteCode
,各有優缺點按需選擇。這在我們之前的字節碼編程文章裏也有所提到。
本文主要講解關於 ASM
方式的字節碼增強,接下來的案例會逐步講解一個給方法添加 TryCatch
塊,用於採集異常信息以及正常的出參結果的流程。
一步步向你展示通過指令碼來改寫你的方法!
二、系統環境
- jdk1.8.0
- asm-commons 6.2.1
三、技術目標
通過 ASM
字節碼增強技術,使用指令碼將方法修改爲我們想要的效果。這部分原本需要使用 JavaAgent
技術,在工程啓動加載時候進行修改字節碼。這裏爲了將關於字節碼核心內容展示出來,通過加載類名稱獲取字節碼進行修改。
這是修改之前的方法
public Integer strToNumber(String str) {
return Integer.parseInt(str);
}
這是修改之後的方法
public Integer strToNumber(String str) {
try {
Integer var2 = Integer.parseInt(str);
MethodTest.point("org.itstack.test.MethodTest$Test.strToNumber", var2);
return var2;
} catch (Exception var3) {
MethodTest.point("org.itstack.test.MethodTest$Test.strToNumber", var3);
throw var3;
}
}
從修改前到修改後,可以看到。有如下幾點修改;
- 返回值賦值給新的參數,並做了輸出
- 把方法包裹在一個
TryCatch
中,並將異常也做了輸出
好!如果你有很敏銳的嗅覺,或者很多小問號。那麼你是否會想到如果使用到你自己的業務中,是不是就可以做一套非入侵的監控系統了? 之後升職加薪
四、實現過程
字節碼增強的過程乍一看還是比較麻煩的,如果你沒有閱讀過JVM虛擬機規範等相關書籍,確實很不好理解。但是也就是這部分不那麼容易理解的知識,纔是你後續價值的體現。
接下來我會一步步的帶着你通過字節碼增強的方式,來實現我們的監控需求。最終的完整的代碼,可以通過關注公衆號:bugstack蟲洞棧
回覆源碼
獲取(ASM字節碼編程)。
1. 搭建字節碼框架
/**
* 字節碼增強獲取新的字節碼
*/
private byte[] getBytes(String className) throws IOException {
ClassReader cr = new ClassReader(className);
ClassWriter cw = new ClassWriter(cr, ClassWriter.COMPUTE_MAXS);
cr.accept(new ClassVisitor(ASM5, cw) {
public MethodVisitor visitMethod(int access, String name, String descriptor, String signature, String[] exceptions) {
// 方法過濾
if (!"strToNumber".equals(name))
return super.visitMethod(access, name, descriptor, signature, exceptions);
MethodVisitor mv = super.visitMethod(access, name, descriptor, signature, exceptions);
return new AdviceAdapter(ASM5, mv, access, name, descriptor) {
// 方法進入時修改字節碼
protected void onMethodEnter() {}
// 訪問局部變量和操作數棧
public void visitMaxs(int maxStack, int maxLocals) {}
// 方法退出時修改字節碼
protected void onMethodExit(int opcode) {}
};
}
}, ClassReader.EXPAND_FRAMES);
return cw.toByteArray();
}
以上這段代碼就是 ASM
用於處理字節碼增強的模版代碼塊。首先他會分別創建 ClassReader
、ClassWriter
,用於對類的加載和寫入,這裏的加載方式在構造方法中也提供的比較豐富。可以通過類名、字節碼或者流的方式進行處理。
接下來是對方法的訪問 MethodVisitor
,基本所有使用 ASM
技術的監控系統,都會在這裏來實現字節碼的注入。這裏面目前用到了三個方法的,如下;
onMethodEnter
方法進入時設置一些基本內容,比如當前納秒用於後續監控方法的執行耗時。還有就是一些Try
塊的開始。visitMaxs
這個是在方法結束前,用於添加Catch
塊。到這也就可以將整個方法進行包裹起來了。onMethodExit
最後是這個方法退出時,用於RETURN
之前,可以注入結尾的字節碼加強,比如調用外部方法輸出監控信息。
基本上所有的 ASM
字節碼增強操作,都離不開這三個方法。下面我就一步步來用指令將方法改造。
2. 獲取方法返回值
這是一個被測試的方法;
public Integer strToNumber(String str) {
return Integer.parseInt(str);
}
編寫指令
這個 onMethodExit
方法就是我們上面提到的字節碼編寫框架中的內容,在裏面添加具體的字節碼指令。
@Override
protected void onMethodExit(int opcode) {
if ((IRETURN <= opcode && opcode <= RETURN) || opcode == ATHROW) {
int nextLocal = this.nextLocal;
mv.visitVarInsn(ASTORE, nextLocal); // 將棧頂引用類型值保存到局部變量indexbyte中。
mv.visitVarInsn(ALOAD, nextLocal); // 從局部變量indexbyte中裝載引用類型值入棧。
}
}
- this.nextLocal,獲取局部變量的索引值。這個值就讓局部變量最後的值,也就是存放
ARETURN
的值(ARETURN,是返回對象類型,如果是返回int
則需要使用 IRETURN)。 - ASTORE,將棧頂引用類型值保存到局部變量indexbyte中。這裏就是把返回的結果,保存到局部變量。在你頭腦中可以想象這有兩塊區域,一個是局部變量、一個是操作數棧。他們不斷的進行壓棧和操作。
- ALOAD,從局部變量indexbyte中裝載引用類型值入棧。現在再將這個值放到操作數棧用,用於一會輸出使用。
被初次增強後的方法;
public Integer strToNumber(String str) {
Integer var2 = Integer.parseInt(str);
return var2;
}
- 首先可以看到,原本的返回值被賦值到一個參數上,之後再由
return
將參數返回。這樣也就可以讓我們拿到了方法出參var2
進行輸出操作。
3. 輸出方法返回值
在上面我們已經將返回內容賦值給參數,那麼在 return
之前,我們就可以在添加一個方法來輸出方法信息和出參了。
定義輸出結果方法;
public static void point(String methodName, Object response) {
System.out.println("系統監控 :: [方法名稱:" + methodName + " 輸出信息:" + JSON.toJSONString(response) + "]\r\n");
}
接下來我們使用字節碼增強的方式來調用這個靜態方法。
@Override
protected void onMethodExit(int opcode) {
if ((IRETURN <= opcode && opcode <= RETURN) || opcode == ATHROW) {
...
mv.visitLdcInsn(className + "." + name); // 類名.方法名
mv.visitVarInsn(ALOAD, nextLocal);
mv.visitMethodInsn(INVOKESTATIC, Type.getInternalName(MethodTest.class), "point", "(Ljava/lang/String;Ljava/lang/Object;)V", false);
}
}
- mv.visitLdcInsn(className + “.” + name);,常量池中的常量值(int, float, string reference, object reference)入棧。也就是我們把類名和方法名,寫到常量池中。
- mv.visitVarInsn(ALOAD, nextLocal);,將上面我們提到的返回值加載到操作數棧。
- mv.visitMethodInsn(INVOKESTATIC, Type.getInternalName(MethodTest.class), “point”, “(Ljava/lang/String;Ljava/lang/Object;)V”, false);,調用靜態方法。
INVOKESTATIC
是調用指令,後面是方法的地址、方法名、方法描述。 (Ljava/lang/String;Ljava/lang/Object;)V
,表示String
和Object
類型的入參,V
是返回空。整體看也就是我們的方法;void point(String methodName, Object response)
再次被增強後的方法;
public Integer strToNumber(String str) {
Integer var2 = Integer.parseInt(str);
point("org.itstack.test.MethodTest.strToNumber", var2);
return var2;
}
- 在字節碼增強後的方法,每次調用這個方法都會輸出方法的名稱和出參結果。可能還有一個問題就是,如果拋異常了,那麼就監控不到了!
4. 給方法加上TryCatch
如果需要抓住方法的異常信息並輸出,那麼就需要給原有的方法包上一層 TryCatch
捕獲異常。接下來我們開始完成這樣的指令碼操作。
添加 TryCatch
開始
private Label from = new Label(),
to = new Label(),
target = new Label();
@Override
protected void onMethodEnter() {
//標誌:try塊開始位置
visitLabel(from);
visitTryCatchBlock(from,
to,
target,
"java/lang/Exception");
}
- 在
onMethodEnter()
中,加入TryCatch
開始塊,在部分在ASM
中固定的模式,按照需求添加即可。
添加 TryCatch
結尾
@Override
public void visitMaxs(int maxStack, int maxLocals) {
//標誌:try塊結束
mv.visitLabel(to);
//標誌:catch塊開始位置
mv.visitLabel(target);
mv.visitFrame(Opcodes.F_SAME1, 0, null, 1, new Object[]{"java/lang/Exception"});
// 異常信息保存到局部變量
int local = newLocal(Type.LONG_TYPE);
mv.visitVarInsn(ASTORE, local);
// 拋出異常
mv.visitVarInsn(ALOAD, local);
mv.visitInsn(ATHROW);
super.visitMaxs(maxStack, maxLocals);
}
- 在
visitMaxs
方法中完成TryCatch
的結尾,包住異常請拋出。 mv.visitFrame(Opcodes.F_SAME1, 0, null, 1, new Object[]{"java/lang/Exception"});
,在指定方法操作數棧中將TryCatch
處理完成。這裏面的幾個參數也可以動態拼裝;局部變量、參數、棧、異常。ASTORE
,將異常信息保存到局部變量,並使用指定ALOAD
放到操作數棧,用於拋出。ATHROW
,最後是拋出異常的指令,也就是throw var
;
這次增強後的方法;
public Integer strToNumber(String str) {
try {
Integer var2 = Integer.parseInt(str);
point("org.itstack.test.MethodTest.strToNumber", var2);
return var2;
} catch (Exception var3) {
throw var3;
}
}
- 這時離我們要的內容越來越近了,整個方法被包裝到一個
TryCatch
中,並按照需要輸出我們的信息。接下來就需要將異常信息,打印出來。
5. 輸出異常信息
在我們使用 ASM
字節碼增強後,已經可以將方法拓展的非常的適合於監控了。接下來我們定義一個靜態方法,用於輸出異常信息;
定義輸出異常方法;
public static void point(String methodName, Throwable throwable) {
System.out.println("系統監控 :: [方法名稱:" + methodName + " 異常信息:" + throwable.getMessage() + "]\r\n");
}
接下來的事情就很簡單了,只需要在拋出異常的指令中,把調用外部方法的內容集成進去就可以了。
@Override
public void visitMaxs(int maxStack, int maxLocals) {
...
// 輸出信息
mv.visitLdcInsn(className + "." + name); // 類名.方法名
mv.visitVarInsn(ALOAD, local);
mv.visitMethodInsn(INVOKESTATIC, Type.getInternalName(MethodTest.class), "point", "(Ljava/lang/String;Ljava/lang/Throwable;)V", false);
...
}
- 這一部分主要體現將異常信息進行輸出,通過字節碼指令來實現調用外部方法。
mv.visitLdcInsn
,加載常量。也就是類名和方法名。ALOAD
,將異常信息加載到操作數棧用,用於輸出。INVOKESTATIC
,調用靜態方法。調用方法除了這個指令外還有;invokespecial
、invokevirtual
、invokeinterface
。
現在再看字節碼增強後的方法;
public Integer strToNumber(String str) {
try {
Integer var2 = Integer.parseInt(str);
point("org.itstack.test.MethodTest.strToNumber", (Object)var2);
return var2;
} catch (Exception var3) {
point("org.itstack.test.MethodTest.strToNumber", (Throwable)var3);
throw var3;
}
}
好!到這我們已經將這個方法徹底的通過字節碼改造完成,可以非常方便的監控異常信息。對用外部輸出的方法,後續可以通過 MQ
等機制推送出去,用於圖表展示監控信息。
五、測試驗證
這是一個字符串轉換成數字類型的方法,我們通過調用傳輸不同的參數進行驗證。比如;數字類型字符串和非數字類型字符串。
另外這裏是我們通過字節碼增強的方式進行改造方法,改造後這個方法反饋給我們的仍然是字節碼,所以需要使用到 ClassLoader
進行加載到執行。
測試方法;
public static void main(String[] args) throws Exception {
// 方法字節碼增強
byte[] bytes = new MethodTest().getBytes(MethodTest.class.getName());
// 輸出方法新字節碼
outputClazz(bytes, MethodTest.class.getSimpleName());
// 測試方法
Class<?> clazz = new MethodTest().defineClass("org.itstack.test.MethodTest", bytes, 0, bytes.length);
Method queryUserInfo = clazz.getMethod("strToNumber", String.class);
// 正確入參;測試驗證結果輸出
Object obj01 = queryUserInfo.invoke(clazz.newInstance(), "123");
System.out.println("01 測試結果:" + obj01);
// 異常入參;測試驗證打印異常信息
Object obj02 = queryUserInfo.invoke(clazz.newInstance(), "abc");
System.out.println("02 測試結果:" + obj02);
}
輸出結果;
ASM字節碼增強後類輸出路徑:/User/itstack/git/github.com/WormholePistachio/SQM/target/test-classes/MethodTestSQM.class
系統監控 :: [方法名稱:org.itstack.test.MethodTest.strToNumber 輸出信息:123]
01 測試結果:123
系統監控 :: [方法名稱:org.itstack.test.MethodTest.strToNumber 異常信息:For input string: "abc"]
Process finished with exit code 1
六、總結
- 通過字節碼指令控制代碼的編寫注入,是不是很酷?完成功能的同時,逐步也解了
JVM虛擬機
。至少不向以前那樣只是去硬背一些理論,而是徹底的實踐了。不要感覺這很難,嗯! - 在逐步的瞭解字節碼編程後,你會在很多的場景領域中建設出高級的玩法。甚至去翻看源碼也能更加容易閱讀理解,並把這技巧複用給自己其他系統。
- 比如我們常用的非入侵的監控系統,全鏈路監控,以及一些反射框架中,其實都用到了
ASM
,只是還沒有注意到而已。最終多學習一些延申拓展的知識,關於這些技巧可以閱讀JVM虛擬機規範
,也可以閱讀ASM文檔;asm.itstack.org
七、彩蛋
最近將個人原創代碼庫資源整理出一份 wiki
文檔,同時逐步將各類案例彙總集中,方便獲取。
本代碼庫是作者小傅哥多年從事一線互聯網Java開發的學習歷程技術彙總,旨在爲大家提供一個清晰詳細的學習教程,側重點更傾向編寫Java核心內容。如果本倉庫能爲您提供幫助,請給予支持(關注、點贊、分享,給個Star ✨)!
鏈接:https://github.com/fuzhengwei/CodeGuide/wiki