字節碼編程,Javassist篇四《通過字節碼插樁監控方法採集運行時入參出參和異常信息》


作者:小傅哥
博客:https://bugstack.cn

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

一、前言

字節碼編程插樁這種技術常與 Javaagent 技術結合用在系統的非入侵監控中,這樣就可以替代在方法中進行硬編碼操作。比如,你需要監控一個方法,包括;方法信息、執行耗時、出入參數、執行鏈路以及異常等。那麼就非常適合使用這樣的技術手段進行處理。

爲了能讓這部分最核心的內容體現出來,本文會只使用 Javassist 技術對一段方法字節碼進行插樁操作,最終輸出這段方法的執行信息,如下;

方法 - 測試方法用於後續進行字節碼增強操作

public Integer strToInt(String str01, String str02) {
    return Integer.parseInt(str01);
}

監控 - 對一段方法進行字節碼增強後,輸出監控信息

監控 - Begin
方法:org.itstack.demo.javassist.ApiTest.strToInt
入參:["str01","str02"] 入參[類型]["java.lang.String","java.lang.String"] 入數[]["1","2"]
出參:java.lang.Integer 出參[]1
耗時:59(s)
監控 - End

有了這樣的監控方案,基本我們可以輸出方法執行過程中的全部信息。再通過後期的完善將監控信息展示到界面,實時報警。既提升了系統的監控質量,也方便了研發排查並定位問題。

好!那麼接下來我們開始一步步使用 javassist 進行字節碼插樁,已達到我們的監控效果。

二、開發環境

  1. JDK 1.8.0
  2. javassist 3.12.1.GA
  3. 本章涉及源碼在:itstack-demo-bytecode-1-04,可以關注公衆號bugstack蟲洞棧,回覆源碼下載獲取。你會獲得一個下載鏈接列表,打開后里面的第17個「因爲我有好多開源代碼」,記得給個Star

三、技術實現

1. 獲取方法基礎信息

1.1 獲取類

ClassPool pool = ClassPool.getDefault();
// 獲取類
CtClass ctClass = pool.get(org.itstack.demo.javassist.ApiTest.class.getName());
ctClass.replaceClassName("ApiTest", "ApiTest02");
String clazzName = ctClass.getName();

通過類名獲取類的信息,同時這裏可以把類名進行替換。它也包括類裏面一些其他獲取屬性的操作,比如;ctClass.getSimpleName()ctClass.getAnnotations() 等。

1.2 獲取方法

CtMethod ctMethod = ctClass.getDeclaredMethod("strToInt");
String methodName = ctMethod.getName();

通過 getDeclaredMethod 獲取方法的 CtMethod 的內容。之後就可以獲取方法的名稱等信息。

1.3 方法信息

MethodInfo methodInfo = ctMethod.getMethodInfo();

MethodInfo 中包括了方法的信息;名稱、類型等內容。

1.4 方法類型

boolean isStatic = (methodInfo.getAccessFlags() & AccessFlag.STATIC) != 0;

通過 methodInfo.getAccessFlags() 獲取方法的標識,之後通過 與運算AccessFlag.STATIC,判斷方法是否爲靜態方法。因爲靜態方法會影響後續的參數名稱獲取,靜態方法第一個參數是 this ,需要排除。

1.5 方法:入參信息{名稱和類型}

CodeAttribute codeAttribute = methodInfo.getCodeAttribute();
LocalVariableAttribute attr = (LocalVariableAttribute) codeAttribute.getAttribute(LocalVariableAttribute.tag);
CtClass[] parameterTypes = ctMethod.getParameterTypes();
  • LocalVariableAttribute,獲取方法的入參的名稱。
  • parameterTypes,獲取方法入參的類型。

1.6 方法;出參信息

CtClass returnType = ctMethod.getReturnType();
String returnTypeName = returnType.getName();

對於方法的出參信息,只需要獲取出參類型。

1.7 輸出所有獲取的信息

System.out.println("類名:" + clazzName);
System.out.println("方法:" + methodName);
System.out.println("類型:" + (isStatic ? "靜態方法" : "非靜態方法"));
System.out.println("描述:" + methodInfo.getDescriptor());
System.out.println("入參[名稱]:" + attr.variableName(1) + "," + attr.variableName(2));
System.out.println("入參[類型]:" + parameterTypes[0].getName() + "," + parameterTypes[1].getName());
System.out.println("出參[類型]:" + returnTypeName);

輸出結果

類名:org.itstack.demo.javassist.ApiTest
方法:strToInt
類型:非靜態方法
描述:(Ljava/lang/String;Ljava/lang/String;)Ljava/lang/Integer;
入參[名稱]:str01,str02
入參[類型]:java.lang.String,java.lang.String
出參[類型]:java.lang.Integer

以上,所輸出信息,都在爲監控方法在做準備。從上面可以記錄方法的基本描述以及入參個數等。尤其是入參個數,因爲在後續還需要使用 $1,來獲取沒有給入參的值。

2. 方法字節碼插樁

一段需會被字節碼插樁改變的原始方法;

public class ApiTest {

    public Integer strToInt(String str01, String str02) {
        return Integer.parseInt(str01);
    }

}

2.1 先給基礎屬性打標

在監控的適合,不可能每一次調用都把所有方法信息彙總輸出出來。這樣做不只是性能問題,而是這些都是固定不變的信息,沒有必要讓每一次方法執行都輸出。

好!那麼在方法編譯時候,給每一個方法都生成一個唯一ID,用ID關聯上方法的固定信息。也就可以把監控數據通過ID傳遞到外面。

// 方法:生成方法唯一標識ID
int idx = Monitor.generateMethodId(clazzName, methodName, parameterNameList, parameterTypeList, returnTypeName);

生成ID的過程

public static final int MAX_NUM = 1024 * 32;
private final static AtomicInteger index = new AtomicInteger(0);
private final static AtomicReferenceArray<MethodDescription> methodTagArr = new AtomicReferenceArray<>(MAX_NUM);   

public static int generateMethodId(String clazzName, String methodName, List<String> parameterNameList, List<String> parameterTypeList, String returnType) {
    MethodDescription methodDescription = new MethodDescription();
    methodDescription.setClazzName(clazzName);
    methodDescription.setMethodName(methodName);
    methodDescription.setParameterNameList(parameterNameList);
    methodDescription.setParameterTypeList(parameterTypeList);
    methodDescription.setReturnType(returnType); 

    int methodId = index.getAndIncrement();
    if (methodId > MAX_NUM) return -1;
    methodTagArr.set(methodId, methodDescription);
    return methodId;
}

2.2 字節碼插樁添加進入方法時間

// 定義屬性
ctMethod.addLocalVariable("startNanos", CtClass.longType);
// 方法前加強
ctMethod.insertBefore("{ startNanos = System.nanoTime(); }");
  • 定義一個 long 類型的屬性,startNanos。並通過 insertBefore 插入到方法內容的開始處。

最終 class 類方法

public class ApiTest {     

    public Integer strToInt(String str01, String str02) {
        long startNanos = System.nanoTime();
        return Integer.parseInt(str01);
    }
}
  • 此時已經有了一個方法的開始時間,有了開始時間在加上後續的結尾時間。就可以很方便的統計一個方法的執行耗時。

2.3 字節碼插樁添加入參輸出

// 定義屬性
ctMethod.addLocalVariable("parameterValues", pool.get(Object[].class.getName()));
// 方法前加強
ctMethod.insertBefore("{ parameterValues = new Object[]{" + parameters.toString() + "}; }");
  • 這裏定義一個數組類型的屬性,Object[],用於記錄入參信息。

最終 class 類方法

public Integer strToInt(String str01, String str02) {
    Object[] var10000 = new Object[]{str01, str02};
    long startNanos = System.nanoTime();
    return Integer.parseInt(str01);
}
  • 兩個參數可以通過一條 insertBefore 進行插入,這裏是爲了更加清晰的向你展示字節碼插樁的過程。現在我們就有了進入方法的時間和參數集合,方便後續輸出。

2.4 定義監控方法

因爲我們需要將監控信息,輸出給外部。那麼我們這裏會定義一個靜態方法,讓字節碼增強後的方法去調用,輸出監控信息。

public static void point(final int methodId, final long startNanos, Object[] parameterValues, Object returnValues) {
    MethodDescription method = methodTagArr.get(methodId);
    System.out.println("監控 - Begin");
    System.out.println("方法:" + method.getClazzName() + "." + method.getMethodName());
    System.out.println("入參:" + JSON.toJSONString(method.getParameterNameList()) + " 入參[類型]:" + JSON.toJSONString(method.getParameterTypeList()) + " 入數[值]:" + JSON.toJSONString(parameterValues));
    System.out.println("出參:" + method.getReturnType() + " 出參[值]:" + JSON.toJSONString(returnValues));
    System.out.println("耗時:" + (System.nanoTime() - startNanos) / 1000000 + "(s)");
    System.out.println("監控 - End\r\n");
}     

public static void point(final int methodId, Throwable throwable) {
    MethodDescription method = methodTagArr.get(methodId);
    System.out.println("監控 - Begin");
    System.out.println("方法:" + method.getClazzName() + "." + method.getMethodName());
    System.out.println("異常:" + throwable.getMessage());
    System.out.println("監控 - End\r\n");
}
  • 這裏一共有兩個方法,一個用於記錄正常情況下的監控信息。另外一個用於記錄異常時候的信息。如果是實際的業務場景中,就可以通過這樣的方法使用 MQ 將監控信息發送給服務端記錄起來並做展示。

2.5 字節碼插樁調用監控方法

// 方法後加強
ctMethod.insertAfter("{ org.itstack.demo.javassist.Monitor.point(" + idx + ", startNanos, parameterValues, $_);}", false); // 如果返回類型非對象類型,$_ 需要進行類型轉換
  • 這裏通過靜態方法將監控參數傳遞給外部;idxstartNanosparameterValues$_出參值

最終 class 類方法

public Integer strToInt(String str01, String str02) {
    Object[] parameterValues = new Object[]{str01, str02};
    long startNanos = System.nanoTime();
    Integer var7 = Integer.parseInt(str01);
    Monitor.point(0, startNanos, parameterValues, var7);
    return var7;
}
  • 現在已經可以將基本的監控信息傳遞給外部。對於一個普通的監控,如果不需要追蹤鏈路,基本已經可以滿足需求了。

2.6 字節碼插樁給方法添加TryCatch

以上插樁內容,如果只是正常調用還是沒問題的。但是如果方法拋出異常,那麼這個時候就不能做到收集監控信息了。所以還需要給方法添加上 TryCatch

// 方法;添加TryCatch
ctMethod.addCatch("{ org.itstack.demo.javassist.Monitor.point(" + idx + ", $e); throw $e; }", ClassPool.getDefault().get("java.lang.Exception"));   // 添加異常捕獲
  • 這裏通過 addCatch 將方法包裝在 TryCatch 裏面。
  • 再通過在 catch 中調用外部方法,將異常信息輸出。
  • 同時有一個點需要注意,$e,用於獲取拋出異常的內容。

最終 class 類方法

public Integer strToInt(String str01, String str02) {
    try {
        Object[] parameterValues = new Object[]{str01, str02};
        long startNanos = System.nanoTime();
        Integer var7 = Integer.parseInt(str01);
        Monitor.point(0, startNanos, parameterValues, var7);
        return var7;
    } catch (Exception var9) {
        Monitor.point(0, var9);
        throw var9;
    }
}
  • 那麼現在就可以非常完整的收錄方法執行的信息,包括它的正常執行以及異常情況。

四、測試結果

接下來就是執行我們的調用測試被修改後的方法字節碼。通過不同的入參,來驗證監控結果;

// 測試調用
byte[] bytes = ctClass.toBytecode();
Class<?> clazzNew = new GenerateClazzMethod().defineClass("org.itstack.demo.javassist.ApiTest", bytes, 0, bytes.length);          

// 反射獲取 main 方法
Method method = clazzNew.getMethod("strToInt", String.class, String.class);
Object obj_01 = method.invoke(clazzNew.newInstance(), "1", "2");
System.out.println("正確入參:" + obj_01);             

Object obj_02 = method.invoke(clazzNew.newInstance(), "a", "b");
System.out.println("異常入參:" + obj_02);
  • 這裏首先會使用 ClassLoader 加載字節碼,之後生成新的類。
  • 接下來通過獲取方法並傳入正確和錯誤的入參。

測試結果

監控 - Begin
方法:org.itstack.demo.javassist.ApiTest.strToInt
入參:["str01","str02"] 入參[類型]["java.lang.String","java.lang.String"] 入數[]["1","2"]
出參:java.lang.Integer 出參[]1
耗時:63(s)
監控 - End

正確入參:1   

監控 - Begin
方法:org.itstack.demo.javassist.ApiTest.strToInt
異常:For input string: "a"
監控 - End
  • 截至到這我們已經將監控中最核心之一展示出來了,也就是監控方法的全部信息。後續就是需要將這樣的監控信息填充到統一監控中心,進行做展示相關的計算操作。

五、總結

  • 基於 Javassist 字節碼操作框架可以非常方便的去進行字節碼增強,也不需要考慮純字節碼編程下的指令碼控制。但如果考慮性能以及更加細緻的改變,還是需要使用到 ASM

  • 這裏包括一些字節碼操作的知識點,如下;

    • methodInfo.getDescriptor(),可以輸出方法描述信息。(Ljava/lang/String;Ljava/lang/String;)Ljava/lang/Integer;,其實就是方法的出入參和返回值。
    • $1 $2 ... 用於獲取不同位置的參數。$$ 可以獲取全部入參,但是不太適合用在數值傳遞中。
    • 獲取方法的入參需要判斷方法的類型,靜態類型的方法還包含了 this 參數。AccessFlag.STATIC
    • addCatch 最開始執行就包裹原有方法內的內容,最後執行就包括所有內容。它依賴於順序操作,其他的方法也是這樣;insertBeforeinsertAfter
發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章