天天看點

通過位元組碼插樁,監控方法采集運作時,入參出參和異常資訊

作者:小傅哥

作者:小傅哥

部落格: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); // 如果傳回類型非對象類型,$_ 需要進行類型轉換
           
  • 這裡通過靜态方法将監控參數傳遞給外部;idx、startNanos、parameterValues、$_出參值

最終 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 最開始執行就包裹原有方法内的内容,最後執行就包括所有内容。它依賴于順序操作,其他的方法也是這樣;insertBefore、insertAfter。

繼續閱讀