ASM字節碼編程 | 用字節碼增強技術給所有方法加上TryCatch捕獲異常並輸出


作者:小傅哥
博客:https://bugstack.cn
Wiki:https://github.com/fuzhengwei/CodeGuide/wiki

沉澱、分享、成長,讓自己和他人都能有所收穫

一、前言

深夜Diss,一級愛慕

你開發的系統是裸奔的嗎?深夜被老闆 Diss

一套系統是否穩定運行,取決於它的運行健康度,而這包括;調用量可用率響應時長以及服務器性能等各項指標的一個綜合值。並且在系統出現異常問題時,可以抓取整個業務方法執行鏈路並輸出;當時的入參、出參、異常信息等等。當然還包括一些JVM、Redis、Mysql的各項性能指標,以用於快速定位並解決問題。

那麼要做到這樣的事情有什麼監控方案呢,這裏面的做法比較多。比如;

  1. 最簡單粗暴的可能就是硬編碼在方法中,收取執行耗時以及出入參和異常信息。但這樣的成本實在太大,而且有一些不可預估的風險。
  2. 可以選擇切面方式做一套統一監控的組件,相對來說還是好一些的。但也需要硬編碼,同時維護成本不低。
  3. 市面上對於這樣的監控其實是有整套的非入侵監控方案的,比如;Google DapperZipkin等都可以實現,他們都是基於探針技術非入侵的採用字節碼增強的方式進行監控。

,那麼這樣非入侵的探針方式是怎麼實現的呢?如何去做方法的字節碼增強

在字節碼增強方面有三個框架;ASMJavassistByteCode,各有優缺點按需選擇。這在我們之前的字節碼編程文章裏也有所提到。

本文主要講解關於 ASM 方式的字節碼增強,接下來的案例會逐步講解一個給方法添加 TryCatch 塊,用於採集異常信息以及正常的出參結果的流程。

一步步向你展示通過指令碼來改寫你的方法!

二、系統環境

  1. jdk1.8.0
  2. 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;
    }
}

從修改前到修改後,可以看到。有如下幾點修改;

  1. 返回值賦值給新的參數,並做了輸出
  2. 把方法包裹在一個 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 用於處理字節碼增強的模版代碼塊。首先他會分別創建 ClassReaderClassWriter,用於對類的加載和寫入,這裏的加載方式在構造方法中也提供的比較豐富。可以通過類名、字節碼或者流的方式進行處理。

接下來是對方法的訪問 MethodVisitor ,基本所有使用 ASM 技術的監控系統,都會在這裏來實現字節碼的注入。這裏面目前用到了三個方法的,如下;

  1. onMethodEnter 方法進入時設置一些基本內容,比如當前納秒用於後續監控方法的執行耗時。還有就是一些 Try 塊的開始。
  2. visitMaxs 這個是在方法結束前,用於添加 Catch 塊。到這也就可以將整個方法進行包裹起來了。
  3. 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中裝載引用類型值入棧。
    }
}
  1. this.nextLocal,獲取局部變量的索引值。這個值就讓局部變量最後的值,也就是存放 ARETURN 的值(ARETURN,是返回對象類型,如果是返回 int 則需要使用 IRETURN)。
  2. ASTORE,將棧頂引用類型值保存到局部變量indexbyte中。這裏就是把返回的結果,保存到局部變量。在你頭腦中可以想象這有兩塊區域,一個是局部變量、一個是操作數棧。他們不斷的進行壓棧和操作
  3. 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);
    }
}
  1. mv.visitLdcInsn(className + “.” + name);,常量池中的常量值(int, float, string reference, object reference)入棧。也就是我們把類名和方法名,寫到常量池中。
  2. mv.visitVarInsn(ALOAD, nextLocal);,將上面我們提到的返回值加載到操作數棧。
  3. mv.visitMethodInsn(INVOKESTATIC, Type.getInternalName(MethodTest.class), “point”, “(Ljava/lang/String;Ljava/lang/Object;)V”, false);,調用靜態方法。INVOKESTATIC 是調用指令,後面是方法的地址、方法名、方法描述。
  4. (Ljava/lang/String;Ljava/lang/Object;)V,表示 StringObject 類型的入參,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,調用靜態方法。調用方法除了這個指令外還有;invokespecialinvokevirtualinvokeinterface

現在再看字節碼增強後的方法;

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

ASM字節碼增強,演示效果

六、總結

  • 通過字節碼指令控制代碼的編寫注入,是不是很酷?完成功能的同時,逐步也解了 JVM虛擬機 。至少不向以前那樣只是去硬背一些理論,而是徹底的實踐了。不要感覺這很難,嗯!
  • 在逐步的瞭解字節碼編程後,你會在很多的場景領域中建設出高級的玩法。甚至去翻看源碼也能更加容易閱讀理解,並把這技巧複用給自己其他系統。
  • 比如我們常用的非入侵的監控系統,全鏈路監控,以及一些反射框架中,其實都用到了 ASM,只是還沒有注意到而已。最終多學習一些延申拓展的知識,關於這些技巧可以閱讀 JVM虛擬機規範,也可以閱讀ASM文檔;asm.itstack.org

七、彩蛋

最近將個人原創代碼庫資源整理出一份 wiki 文檔,同時逐步將各類案例彙總集中,方便獲取。

本代碼庫是作者小傅哥多年從事一線互聯網Java開發的學習歷程技術彙總,旨在爲大家提供一個清晰詳細的學習教程,側重點更傾向編寫Java核心內容。如果本倉庫能爲您提供幫助,請給予支持(關注、點贊、分享,給個Star ✨)!

鏈接https://github.com/fuzhengwei/CodeGuide/wiki

CodeGuide Wiki,程序員編碼指南

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