通過位元組碼插樁監控方法採集執行時入參出參和異常資訊 | 位元組碼程式設計,Javassist篇四

小傅哥發表於2020-05-13


作者:小傅哥
部落格: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

相關文章