作者:小傅哥
部落格: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
進行位元組碼插樁,已達到我們的監控效果。
二、開發環境
- JDK 1.8.0
- javassist 3.12.1.GA
- 本章涉及原始碼在:
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
。
-