天天看點

非入侵的全鍊路監控系統,是怎麼采集到代碼執行時的耗時的?

作者:小傅哥

作者:小傅哥

部落格:https://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 是直接使用指令來控制位元組碼。

三、實作方案

非入侵的全鍊路監控系統,是怎麼采集到代碼執行時的耗時的?

位元組碼增強實作方案

按照圖中我們使用 javaAgent 的 primain 方法,使用 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();

    }

    關于 ASM 的使用可以通過文檔學習;asm.itstack.org

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蟲洞棧 | 精神小夥!
           

五、總結

  • 綜上使用了 JavaAgent 結合 ASM 對監控方法做了位元組碼增強,可以在方法執行的時候輸出我們需要的資訊。而這些資訊的價值就是可以很好的讓我們做一些程式的全鍊路監控以及工程品質驗證。
  • 目前還是處于案例工程階段,後續會不斷突破一些技術難點,并完善服務品質監控工程,SQM。也歡迎有此愛好的小夥伴加入開源建設。也許這能讓你除了平時的 CRUD 技術外,擴充一項更加進階的領域。
  • 如果你對位元組碼插樁感興趣,并還沒有入門,可以通過我的部落格;bugstack.cn 中,架構師專題->調用鍊路監控,學習。

繼續閱讀