ASM位元組碼程式設計 | JavaAgent+ASM位元組碼插樁採集方法名稱以及入參和出參結果並記錄方法耗時

小傅哥發表於2020-04-07

ASM位元組碼程式設計 | JavaAgent+ASM位元組碼插樁採集方法名稱以及入參和出參結果並記錄方法耗時

作者:小傅哥
部落格:bugstack.cn
沉澱、分享、成長,讓自己和他人都能有所收穫!

一、前言

在我們實際的業務開發到上線的過程中,中間都會經過測試。那麼怎麼來保證測試質量呢?比如;提交了多少程式碼、提交了多少方法、有單元測試嗎、影響了那些流程鏈路、有沒有夾帶上線。

大部分時候這些問題的彙總都是人為的方式進行提供,以依賴相信研發為主。剩下的就需要依賴有經驗的測試進行白盒驗證。所以即使是這樣測試也會在上線後發生很多未知的問題,畢竟流程太長,影響面太廣。很難用一個人去照顧到所有流程。

所以,我很希望使用技術手段來解決這一問題,通過服務質量監控來在研發提測後,自動報告相關資料,例如;研發程式碼涉及流程鏈路展示、每個鏈路測試次數、通過次數、失敗次數、當時的出入參資訊以及對應的程式碼塊在當前提測分支修改記錄等各項資訊。最終測試在執行驗證時候,分配驗證渠道掃描到所有分支節點,可以清晰的看到全鏈路的影響。那麼,這樣的測試才是可以保證系統的整體質量的。

好!接下來到後續一段時間,我會不斷的去完善和開發這些功能。也歡迎你的加入!

二、技術目標

技術行為都是為目標服務的,也就是實現務產品功能

而我們這個文章的目標是需要使用固定的技術棧 JavaAgent + ASM,來抓取方法執行時候的資訊,包括:類名稱、方法名稱、入參資訊和入參值、出參資訊和出參值以及當前方法的耗時。

JavaAgent,是一種探針技術可以通過 premain 方法,在類載入的過程中給指定的方法進行位元組碼增強。其實你的每一個類最終都是位元組碼指令的執行,而這種增強後的方法就可以輸出我們想要的資訊。這就相當於你硬編碼時候輸出了一些方法的耗時,日誌等資訊。

ASM,是一個 Java 位元組碼操控框架。它能被用來動態生成類或者增強既有類的功能。ASM 可以直接產生二進位制 class 檔案,也可以在類被載入入 Java 虛擬機器之前動態改變類行為。Java class 被儲存在嚴格格式定義的 .class 檔案裡,這些類檔案擁有足夠的後設資料來解析類中的所有元素:類名稱、方法、屬性以及 Java 位元組碼(指令)。ASM 從類檔案中讀入資訊後,能夠改變類行為,分析類資訊,甚至能夠根據使用者要求生成新類。說白了asm是直接通過位元組碼來修改class檔案。另外除了 asm 可以操作位元組碼,還有javassist和Byte-code等,他們比 asm 要簡單,但是執行效率還是 asm 高。因為 asm 是直接使用指令來控制位元組碼。

三、實現方案

位元組碼增強實現方案

按照圖中我們使用 javaAgentprimain 方法,使用 asm 進行位元組碼增強,以便於輸出我們的監控資訊。最終在我們把位元組碼增強後,程式所執行的就是我們的新的方法位元組碼,從而也就可以獲取到我們需要的資訊。那麼,接下來我們開始一步步上線這些功能。

關於實現方案中的所有原始碼,可以通過關注公眾號:bugstack蟲洞棧,回覆原始碼下載進行獲取

1. 定義測試方法

public class ApiTest {

    public static void main(String[] args) throws InterruptedException {
        ApiTest apiTest = new ApiTest();
        String res01 = apiTest.queryUserInfo(111, 17);
        System.out.println("測試結果:" + res01 + "\r\n");;
    }

    public String queryUserInfo(int uId, int age) throws InterruptedException {
        return "你好,bugstack蟲洞棧 | 精神小夥!";
    }

}
複製程式碼
  • 這裡我們定義了一個查詢使用者資訊的測試方法,後續不斷將這個方法進行位元組碼增強。

2. 監控類入口

PreMain.java & 入口方法

public class PreMain {

    //JVM 首先嚐試在代理類上呼叫以下方法
    public static void premain(String agentArgs, Instrumentation inst) {
        inst.addTransformer(new ProfilingTransformer());
    }

    //如果代理類沒有實現上面的方法,那麼 JVM 將嘗試呼叫該方法
    public static void premain(String agentArgs) {
    }

}
複製程式碼

MANIFEST.MF & 配置

Manifest-Version: 1.0
Premain-Class: org.itstack.sqm.asm.PreMain
Can-Redefine-Classes: true
複製程式碼
  • 以上是固定的基礎模板程式碼,所有的 JavaAgent 程式都需要從這裡開始。

3. 位元組碼方法處理


public class ProfilingTransformer implements ClassFileTransformer {

    @Override
    public byte[] transform(ClassLoader loader, String className, Class<?> classBeingRedefined, ProtectionDomain protectionDomain, byte[] classfileBuffer) throws IllegalClassFormatException {
        try {

        	// 排除一些不需要處理的方法
            if (ProfilingFilter.isNotNeedInject(className)) {
                return classfileBuffer;
            }

            return getBytes(loader, className, classfileBuffer);;
        } catch (Throwable e) {
            System.out.println(e.getMessage());
        }
        return classfileBuffer;
    }

    ...

}

複製程式碼
  • 這裡主要通過傳入進行的類載入器、類名、位元組碼等,負責位元組碼的增強操作。而這裡會使用 ASM 方式進行處理,如下;

private byte[] getBytes(ClassLoader loader, String className, byte[] classfileBuffer) {
      ClassReader cr = new ClassReader(classfileBuffer);
      ClassWriter cw = new ClassWriter(cr, ClassWriter.COMPUTE_MAXS);
      ClassVisitor cv = new ProfilingClassAdapter(cw, className);
      cr.accept(cv, ClassReader.EXPAND_FRAMES);
      return cw.toByteArray();
  }

複製程式碼

4. 位元組碼方法解析

位元組碼方法解析

  • 當程式啟動載入的時候,每個類的每一個方法都會被監控到。類的名稱、方法的名稱、方法入參出參的描述等,都可以在這裡獲取。
  • 為了可以在後續監控處理不至於每一次都去傳參(方法資訊)浪費消耗效能,一般這裡都會給每個方法生產一個全域性防重的 id ,通過這個 id 就可以查詢到對應的方法。
  • 另外從這裡可以看到的方法的入參和出參被描述成一段指定的碼,(II)Ljava/lang/String; ,為了我們後續對引數進行解析,那麼需要將這段字串進行拆解。

4.1 解析方法入參和出參

asm 文件中說明過關於位元組碼結構和方法的資訊,I;int、Ljava/lang/String;String,所以我們可以分析出這個方法的是兩個 int 型別的入參和一個 String 型別的出參。也就是;String queryUserInfo(int uId, int age)

那麼這個方法的入參除了這麼簡單的,還會很複雜的,比如:(Ljava/lang/String;Ljava/lang/Object;Ljava/lang/String;IJ[I[[Ljava/lang/Object;Lorg/itstack/test/Req;)Ljava/lang/String; 對於這樣的字串內容需要使用到正規表示式進行解析。

正則解析方法描述

@Test
public void test_desc() {
    String desc = "(Ljava/lang/String;Ljava/lang/Object;Ljava/lang/String;IJ[I[[Ljava/lang/Object;Lorg/itstack/test/Req;)Ljava/lang/String;";

    Matcher m = Pattern.compile("(L.*?;|\\[{0,2}L.*?;|[ZCBSIFJD]|\\[{0,2}[ZCBSIFJD]{1})").matcher(desc.substring(0, desc.lastIndexOf(')') + 1));

    while (m.find()) {
        String block = m.group(1);
        System.out.println(block);
    }

}
複製程式碼

測試結果

Ljava/lang/String;
Ljava/lang/Object;
Ljava/lang/String;
I
J
[I
[[Ljava/lang/Object;
Lorg/itstack/test/Req;

Process finished with exit code 0
複製程式碼
  • 可以看到我們將所有的引數型別已經解析出來,因為只有通過這樣的解析我們才能去處理方法中入參。這主要是8個基本型別需要進行型別轉換為物件,填充到陣列中,方便我們輸出結果。

4.2 提取類和方法生產標識ID

接下來我們將解析的方法資訊包括入參、出參結果生產方法的標識ID,這個ID是一個全域性唯一的,每一個方法都有一個固定的標識。如下;

methodId = ProfilingAspect.generateMethodId(new MethodTag(fullClassName, simpleClassName, methodName, desc, parameterTypeList, desc.substring(desc.lastIndexOf(')') + 1)));

public static int generateMethodId(MethodTag tag) {
    int methodId = index.getAndIncrement();
    if (methodId > MAX_NUM) return -1;
    methodTagArr.set(methodId, tag);
    return methodId;
}
複製程式碼
  • 這是一個原子性使用者自增的ID,AtomicInteger,同時也提供了一個對應的集合;AtomicReferenceArray<MethodTag>
  • 當我們每新增一個方法就會使用這個工具生產一個對應的ID,同時存放到集合中,並返回。這個生成的過程是一次性的,所以也不會影響執行時候的耗時。

5. 位元組碼增強「方法進入」

ProfilingMethodVisitor extends AdviceAdapter 中,可以重寫方法 onMethodEnter 。也就是當方法進入時候設定開始時間和收集入參到陣列中。而收集入參的過程相對會複雜一些,需要使用位元組碼指令建立資料,之後把每一個入參在使用位元組碼載入到陣列中。這個過程有點像我們寫程式碼,定義陣列設定引數。

5.1 在方法裡設定開始時間

這段程式碼我們需要使用位元組碼指令插樁到方法的開始處

long var3 = System.nanoTime();
複製程式碼

位元組碼插樁處理

mv.visitMethodInsn(INVOKESTATIC, "java/lang/System", "nanoTime", "()J", false);
startTimeIdentifier = newLocal(Type.LONG_TYPE);
mv.visitVarInsn(LSTORE, startTimeIdentifier);	
複製程式碼
位元組碼 描述
INVOKESTATIC 呼叫靜態方法
LSTORE 將棧頂long型別值儲存到區域性變數indexbyte中

5.2 初始化入參裝填陣列

使用位元組碼的方式去初始化一個引數數量的陣列

Object[] var6 = new Object[](x);
複製程式碼

通過位元組碼的方式進行建立陣列

if (parameterCount >= 4) {
    mv.visitVarInsn(BIPUSH, parameterCount);//初始化陣列長度
} else {
    switch (parameterCount) {
        case 1:
            mv.visitInsn(ICONST_1);
            break;
        case 2:
            mv.visitInsn(ICONST_2);
            break;
        case 3:
            mv.visitInsn(ICONST_3);
            break;
        default:
            mv.visitInsn(ICONST_0);
    }
}
mv.visitTypeInsn(ANEWARRAY, Type.getDescriptor(Object.class));
複製程式碼
位元組碼 描述
BIPUSH valuebyte值帶符號擴充套件成int值入棧
ANEWARRAY 建立引用型別的陣列

這裡有一個陣列大小的判斷,如果小於4會使用 ICONST 初始化長度。

5.3 給陣列賦值

給陣列賦值相當於如下效果,只不過需要經過一些位元組碼的方式進行處理

Object[] var6 = new Object[]{var1, var2};
複製程式碼

通過位元組碼的方式進行初始化

 // 給陣列賦引數值
for (int i = 0; i < parameterCount; i++) {
    mv.visitInsn(DUP);
    mv.visitVarInsn(BIPUSH, i);
    String type = parameterTypeList.get(i);
	if ("Z".equals(type)) {
	    mv.visitVarInsn(ILOAD, ++cursor);  //獲取對應的引數
	    mv.visitMethodInsn(INVOKESTATIC, "java/lang/Boolean", "valueOf", "(Z)Ljava/lang/Boolean;", false);
	} else if ("C".equals(type)) {
	    mv.visitVarInsn(ILOAD, ++cursor);  //獲取對應的引數
	    mv.visitMethodInsn(INVOKESTATIC, "java/lang/Character", "valueOf", "(C)Ljava/lang/Character;", false);
	} else if ("B".equals(type)) {
	    mv.visitVarInsn(ILOAD, ++cursor);  //獲取對應的引數
	    mv.visitMethodInsn(INVOKESTATIC, "java/lang/Byte", "valueOf", "(B)Ljava/lang/Byte;", false);
	} else if ("S".equals(type)) {
	    mv.visitVarInsn(ILOAD, ++cursor);  //獲取對應的引數
	    mv.visitMethodInsn(INVOKESTATIC, "java/lang/Short", "valueOf", "(S)Ljava/lang/Short;", false);
	} else if ("I".equals(type)) {
	    mv.visitVarInsn(ILOAD, ++cursor);  //獲取對應的引數
	    mv.visitMethodInsn(INVOKESTATIC, "java/lang/Integer", "valueOf", "(I)Ljava/lang/Integer;", false);
	} else if ("F".equals(type)) {
	    mv.visitVarInsn(FLOAD, ++cursor);  //獲取對應的引數
	    mv.visitMethodInsn(INVOKESTATIC, "java/lang/Float", "valueOf", "(F)Ljava/lang/Float;", false);
	} else if ("J".equals(type)) {
	    mv.visitVarInsn(LLOAD, ++cursor);  //獲取對應的引數
	    mv.visitMethodInsn(INVOKESTATIC, "java/lang/Long", "valueOf", "(J)Ljava/lang/Long;", false);
	} else if ("D".equals(type)) {
	    cursor += 2;
	    mv.visitVarInsn(DLOAD, cursor);  //獲取對應的引數
	    mv.visitMethodInsn(INVOKESTATIC, "java/lang/Double", "valueOf", "(D)Ljava/lang/Double;", false);
	} else {
	    ++cursor;
	    mv.visitVarInsn(ALOAD, cursor);  //獲取對應的引數
	}
	mv.visitInsn(AASTORE);

	mv.visitVarInsn(ASTORE, parameterIdentifier);
}
複製程式碼

這裡在賦值的過程中,包括了對基本型別的轉換,否則是不能放入到的 Object 陣列中的。因為它們 int long ... 都不是物件型別

位元組碼 描述
ILOAD 從區域性變數indexbyte中裝載int型別值入棧
INVOKESTATIC 呼叫靜態方法
AASTORE 將棧頂引用型別值儲存到指定引用型別陣列的指定項

到這為止,我們就已經將引數初始化到陣列中了,後面就可以將引數通過方法傳遞出去。

6. 位元組碼增強「方法退出」

在方法結束後這裡還提供給我們一個退出的方法 onMethodExit ,我們可以通過這個方法的重寫,使用位元組碼獲取出參並一起輸出到外部。

6.1 獲取 return 出參值

通過位元組碼的方式,實現下面出參賦值給一個屬性,並最終把值給 return

Object var7 = "你好,bugstack蟲洞棧 | 精神小夥!";
ProfilingAspect.point(var3, 0, var6, var7);
return uId;
複製程式碼

通過位元組碼方式進行處理

switch (opcode) {
    case RETURN:
        break;
    case ARETURN:
        mv.visitVarInsn(ASTORE, ++localCount); // 6
        mv.visitVarInsn(ALOAD, localCount);    // 6
        break;
}
複製程式碼

6.2 最終將方法資訊輸出給外部

mv.visitVarInsn(LLOAD, startTimeIdentifier);
mv.visitLdcInsn(methodId);
if (parameterTypeList.isEmpty()) {
    mv.visitMethodInsn(INVOKESTATIC, Type.getInternalName(ProfilingAspect.class), "point", "(JI)V", false);
} else {
    mv.visitVarInsn(ALOAD, parameterIdentifier);  // 5
    mv.visitVarInsn(ALOAD, localCount);           // 6
    mv.visitMethodInsn(INVOKESTATIC, Type.getInternalName(ProfilingAspect.class), "point", "(JI[Ljava/lang/Object;Ljava/lang/Object;)V", false);
}
複製程式碼
  • LLOAD ,從區域性變數indexbyte中裝載long型別值入棧。這裡載入的就是方法的啟動時間。

  • LDC , 常量池中的常量值(int, float, string reference, object reference)入棧。這裡是載入方法ID;methodId

  • ALOAD ,parameterIdentifier ,從區域性變數indexbyte中裝載引用型別值入棧。此時載入引數陣列資訊。

  • ALOAD ,localCount ,載入的是返回值資訊,也就是 return 的結果。

  • INVOKESTATIC ,最後就是呼叫靜態方法輸出結果資訊,這個靜態方法是我們已經預設好的,如下;

    public static void point(final long startNanos, final int methodId, Object[] requests, Object response) {
        MethodTag method = methodTagArr.get(methodId);
        System.out.println("監控 - Begin");
        System.out.println("類名:" + method.getFullClassName());
        System.out.println("方法:" + method.getMethodName());
        System.out.println("入參型別:" + JSON.toJSONString(method.getParameterTypeList()));
        System.out.println("入數[值]:" + JSON.toJSONString(requests));
        System.out.println("出參型別:" + method.getReturnParameterType());
        System.out.println("出參[值]:" + JSON.toJSONString(response));
        System.out.println("耗時:" + (System.nanoTime() - startNanos) / 1000000 + "(s)");
        System.out.println("監控 - End\r\n");
    }
    複製程式碼

四、測試驗證

1. 需要測試的方法

public class ApiTest {

    public static void main(String[] args) throws InterruptedException {
        ApiTest apiTest = new ApiTest();
        String res01 = apiTest.queryUserInfo(111, 17);
        System.out.println("測試結果:" + res01 + "\r\n");;
    }

    public String queryUserInfo(int uId, int age) throws InterruptedException {
        return "你好,bugstack蟲洞棧 | 精神小夥!";
    }

}
複製程式碼

2. 配置javaagent

-javaagent:/Users/xiaofuge/itstack/git/github.com/SQM/target/SQM-1.0-SNAPSHOT.jar
複製程式碼
  • IDEA 執行時候配置到 VM options 中,jar包地址按照自己的路徑進行配置。

3. 被位元組碼增強後的方法

public String queryUserInfo(int var1, int var2) throws InterruptedException {
    long var3 = System.nanoTime();
    Object[] var6 = new Object[]{var1, var2};
    Object var7 = "你好,bugstack蟲洞棧 | 精神小夥!";
    ProfilingAspect.point(var3, 0, var6, var7);
    return var7;
}
複製程式碼
  • 通過編譯後的方法可以看到,方法的執行資訊全部通過靜態方法輸出到外部。這樣就可以很方便的監控一個方法的執行資訊。

4. 輸出結果

ASM類輸出路徑:/Users/xiaofuge/itstack/git/github.com/SQM/target/test-classes/org/itstack/test/ApiTest$1SQM.class
監控 - Begin
類名:org.itstack.test.ApiTest
方法:queryUserInfo
入參型別:["I","I"]
入數[值]:[111,17]
出參型別:Ljava/lang/String;
出參[值]:"你好,bugstack蟲洞棧 | 精神小夥!"
耗時:95(s)
監控 - End

測試結果:你好,bugstack蟲洞棧 | 精神小夥!
複製程式碼

五、總結

ASM位元組碼程式設計 | JavaAgent+ASM位元組碼插樁採集方法名稱以及入參和出參結果並記錄方法耗時

相關文章