調研位元組碼插樁技術,用於系統監控設計和實現

小傅哥發表於2021-07-08

⚠️ 本文為掘金社群首發簽約文章,未獲授權禁止轉載

作者:小傅哥
部落格:bugstack.cn

沉澱、分享、成長,讓自己和他人都能有所收穫!?

一、來自深夜的電話!

咋滴,你那上線的系統是裸奔呢?

週末熟睡的深夜,突然接到老闆電話☎的催促。“趕緊看微信、看微信,咋系統出問題了,我們都不知道,還得使用者反饋才知道的!!!”深夜爬起來,開啟電腦連上 VPN ,打著哈欠、睜開朦朧的眼睛,查查系統日誌,原來是系統掛了,趕緊重啟恢復!

雖然重啟恢復了系統,也重置了老闆扭曲的表情。但系統是怎麼掛的呢,因為沒有一個監控系統,也不知道是流量太大導致,還是因為程式問題引起,通過一片片的日誌,也僅能粗略估計出一些打著好像的標籤給老闆彙報。不過老闆也不傻,聊來聊去,讓把所有的系統執行狀況都監控出來。

雙手拖著睏倦的腦袋,一時半會也想不出什麼好方法,難道在每個方法上都硬編碼上執行耗時計算。之後把資訊在統一收集起來,展示到一個監控頁面呢,監控頁面使用阿帕奇的 echarts,別說要是這樣顯示了,還真能挺好看還好用。

  • 但這麼硬編碼也不叫玩意呀,這不把我們部門搬磚的碼農累岔氣呀!再說了,這麼幹他們肯定瞧不起我。啥架構師,要監控系統,還得硬編碼,傻了不是!!!
  • 這麼一想整的沒法睡覺,得找找資料,明天給老闆彙報!

其實一套線上系統是否穩定執行,取決於它的執行健康度,而這包括;呼叫量、可用率、影響時長以及伺服器效能等各項指標的一個綜合值。並且在系統出現異常問題時,可以抓取整個業務方法執行鏈路並輸出;當時的入參、出參、異常資訊等等。當然還包括一些JVM、Redis、Mysql的各項效能指標,以用於快速定位並解決問題。

那麼要做到這樣的事情有什麼處理方案呢,其實做法還是比較多的,比如;

  1. 最簡單粗暴的就是硬編碼在方法中,收取執行耗時以及出入參和異常資訊。但這樣的編碼成本實在太大,而且硬編碼完還需要大量回歸測試,可能給系統帶來一定的風險。萬一誰手抖給複製貼上錯了呢!
  2. 可以選擇切面方式做一套統一監控的元件,相對來說還是好一些的。但也需要硬編碼,比如寫入註解,同時維護成本也不低。
  3. 其實市面上對於這樣的監控其實是有整套的非入侵監控方案的,比如;Google Dapper、Zipkin等都可以實現監控系統需求,他們都是基於探針技術非入侵的採用位元組碼增強的方式採集系統執行資訊進行分析和監控執行狀態。

好,那麼本文就來帶著大家來嘗試下幾種不同方式,監控系統執行狀態的實現思路。

二、準備工作

本文會基於 AOP、位元組碼框架(ASMJavassistByte-Buddy),分別實現不同的監控實現程式碼。整個工程結構如下:

MonitorDesign
├── cn-bugstack-middleware-aop
├── cn-bugstack-middleware-asm
├── cn-bugstack-middleware-bytebuddy
├── cn-bugstack-middleware-javassist
├── cn-bugstack-middleware-test
└── pom.xml
複製程式碼
  • 原始碼地址:github.com/fuzhengwei/…
  • 簡單介紹:aop、asm、bytebuddy、javassist,分別是四種不同的實現方案。test 是一個基於 SpringBoot 的簡單測試工程。
  • 技術使用:SpringBoot、asm、byte-buddy、javassist

cn-bugstack-middleware-test

@RestController
public class UserController {

    private Logger logger = LoggerFactory.getLogger(UserController.class);

    /**
     * 測試:http://localhost:8081/api/queryUserInfo?userId=aaa
     */
    @RequestMapping(path = "/api/queryUserInfo", method = RequestMethod.GET)
    public UserInfo queryUserInfo(@RequestParam String userId) {
        logger.info("查詢使用者資訊,userId:{}", userId);
        return new UserInfo("蟲蟲:" + userId, 19, "天津市東麗區萬科賞溪苑14-0000");
    }

}
複製程式碼
  • 接下來的各類監控程式碼實現,都會以監控 UserController#queryUserInfo 的方法執行資訊為主,看看各類技術都是怎麼操作的。

三、使用 AOP 做個切面監控

1. 工程結構

cn-bugstack-middleware-aop
└── src
    ├── main
    │   └── java
    │       ├── cn.bugstack.middleware.monitor
    │       │   ├── annotation
    │       │   │   └── DoMonitor.java
    │       │   ├── config
    │       │   │   └── MonitorAutoConfigure.java
    │       │   └── DoJoinPoint.java
    │       └── resources
    │           └── META-INF 
    │               └── spring.factories
    └── test
        └── java
            └── cn.bugstack.middleware.monitor.test
                └── ApiTest.java
複製程式碼

基於 AOP 實現的監控系統,核心邏輯的以上工程並不複雜,其核心點在於對切面的理解和運用,以及一些配置項需要按照 SpringBoot 中的實現方式進行開發。

  • DoMonitor,是一個自定義註解。它作用就是在需要使用到的方法監控介面上,新增此註解並配置必要的資訊。
  • MonitorAutoConfigure,配置下是可以對 SpringBoot yml 檔案的使用,可以處理一些 Bean 的初始化操作。
  • DoJoinPoint,是整個中介軟體的核心部分,它負責對所有新增自定義註解的方法進行攔截和邏輯處理。

2. 定義監控註解

cn.bugstack.middleware.monitor.annotation.DoMonitor

@Retention(RetentionPolicy.RUNTIME)
@Target(ElementType.METHOD)
public @interface DoMonitor {

   String key() default "";
   String desc() default "";

}
複製程式碼
  • @Retention(RetentionPolicy.RUNTIME),Annotations are to be recorded in the class file by the compiler and retained by the VM at run time, so they may be read reflectively.
  • @Retention 是註解的註解,也稱作元註解。這個註解裡面有一個入參資訊 RetentionPolicy.RUNTIME 在它的註釋中有這樣一段描述:Annotations are to be recorded in the class file by the compiler and retained by the VM at run time, so they may be read reflectively. 其實說的就是加了這個註解,它的資訊會被帶到JVM執行時,當你在呼叫方法時可以通過反射拿到註解資訊。除此之外,RetentionPolicy 還有兩個屬性 SOURCECLASS,其實這三個列舉正式對應了Java程式碼的載入和執行順序,Java原始碼檔案 -> .class檔案 -> 記憶體位元組碼。並且後者範圍大於前者,所以一般情況下只需要使用 RetentionPolicy.RUNTIME 即可。
  • @Target 也是元註解起到標記作用,它的註解名稱就是它的含義,目標,也就是我們這個自定義註解 DoWhiteList 要放在類、介面還是方法上。在 JDK1.8 中 ElementType 一共提供了10中目標列舉,TYPE、FIELD、METHOD、PARAMETER、CONSTRUCTOR、LOCAL_VARIABLE、ANNOTATION_TYPE、PACKAGE、TYPE_PARAMETER、TYPE_USE,可以參考自己的自定義註解作用域進行設定
  • 自定義註解 @DoMonitor 提供了監控的 key 和 desc描述,這個主要記錄你監控方法的為唯一值配置和對監控方法的文字描述。

3. 定義切面攔截

cn.bugstack.middleware.monitor.DoJoinPoint

@Aspect
public class DoJoinPoint {

    @Pointcut("@annotation(cn.bugstack.middleware.monitor.annotation.DoMonitor)")
    public void aopPoint() {
    }

    @Around("aopPoint() && @annotation(doMonitor)")
    public Object doRouter(ProceedingJoinPoint jp, DoMonitor doMonitor) throws Throwable {
        long start = System.currentTimeMillis();
        Method method = getMethod(jp);
        try {
            return jp.proceed();
        } finally {
            System.out.println("監控 - Begin By AOP");
            System.out.println("監控索引:" + doMonitor.key());
            System.out.println("監控描述:" + doMonitor.desc());
            System.out.println("方法名稱:" + method.getName());
            System.out.println("方法耗時:" + (System.currentTimeMillis() - start) + "ms");
            System.out.println("監控 - End\r\n");
        }
    }

    private Method getMethod(JoinPoint jp) throws NoSuchMethodException {
        Signature sig = jp.getSignature();
        MethodSignature methodSignature = (MethodSignature) sig;
        return jp.getTarget().getClass().getMethod(methodSignature.getName(), methodSignature.getParameterTypes());
    }

}
複製程式碼
  • 使用註解 @Aspect,定義切面類。這是一個非常常用的切面定義方式。
  • @Pointcut("@annotation(cn.bugstack.middleware.monitor.annotation.DoMonitor)"),定義切點。在 Pointcut 中提供了很多的切點尋找方式,有指定方法名稱的、有範圍篩選表示式的,也有我們現在通過自定義註解方式的。一般在中介軟體開發中,自定義註解方式使用的比較多,因為它可以更加靈活的運用到各個業務系統中。
  • @Around("aopPoint() && @annotation(doMonitor)"),可以理解為是對方法增強的織入動作,有了這個註解的效果就是在你呼叫已經加了自定義註解 @DoMonitor 的方法時,會先進入到此切點增強的方法。那麼這個時候就你可以做一些對方法的操作動作了,比如我們要做一些方法監控和日誌列印等。
  • 最後在 doRouter 方法體中獲取把方法執行 jp.proceed(); 使用 try finally 包裝起來,並列印相關的監控資訊。這些監控資訊的獲取最後都是可以通過非同步訊息的方式傳送給服務端,再由伺服器進行處理監控資料和處理展示到監控頁面。

4. 初始化切面類

cn.bugstack.middleware.monitor.config.MonitorAutoConfigure

@Configuration
public class MonitorAutoConfigure {

    @Bean
    @ConditionalOnMissingBean
    public DoJoinPoint point(){
        return new DoJoinPoint();
    }

}
複製程式碼
  • @Configuration,可以算作是一個元件註解,在 SpringBoot 啟動時可以進行載入建立出 Bean 檔案。因為 @Configuration 註解有一個 @Component 註解
  • MonitorAutoConfigure 可以處理自定義在 yml 中的配置資訊,也可以用於初始化 Bean 物件,比如在這裡我們例項化了 DoJoinPoint 切面物件。

5. 執行測試

5.1 引入 POM 配置

<!-- 監控方式:AOP -->
<dependency>
    <groupId>cn.bugstack.middleware</groupId>
    <artifactId>cn-bugstack-middleware-aop</artifactId>
    <version>1.0-SNAPSHOT</version>
</dependency>
複製程式碼

5.2 方法上配置監控註冊

@DoMonitor(key = "cn.bugstack.middleware.UserController.queryUserInfo", desc = "查詢使用者資訊")
@RequestMapping(path = "/api/queryUserInfo", method = RequestMethod.GET)
public UserInfo queryUserInfo(@RequestParam String userId) {
    logger.info("查詢使用者資訊,userId:{}", userId);
    return new UserInfo("蟲蟲:" + userId, 19, "天津市東麗區萬科賞溪苑14-0000");
}
複製程式碼
  • 在通過 POM 引入自己的開發的元件後,就可以通過自定義的註解,攔截方法獲取監控資訊。

5.3 測試結果

2021-07-04 23:21:10.710  INFO 19376 --- [nio-8081-exec-1] c.b.m.test.interfaces.UserController     : 查詢使用者資訊,userId:aaa
監控 - Begin By AOP
監控索引:cn.bugstack.middleware.UserController.queryUserInfo
監控描述:查詢使用者資訊
方法名稱:queryUserInfo
方法耗時:6ms
監控 - End
複製程式碼
  • 通過啟動 SpringBoot 程式,在網頁中開啟 URL 地址:http://localhost:8081/api/queryUserInfo?userId=aaa,可以看到已經可以把監控資訊列印到控制檯了。
  • 此種通過自定義註解的配置方式,能解決一定的硬編碼工作,但如果在方法上大量的新增註解,也是需要一定的開發工作的。

接下來我們開始介紹關於使用位元組碼插樁非入侵的方式進行系統監控,關於位元組碼插樁常用的有三個元件,包括:ASM、Javassit、Byte-Buddy,接下來我們分別介紹它們是如何使用的。

四、ASM

ASM 是一個 Java 位元組碼操控框架。它能被用來動態生成類或者增強既有類的功能。ASM 可以直接產生二進位制 class 檔案,也可以在類被載入入 Java 虛擬機器之前動態改變類行為。Java class 被儲存在嚴格格式定義的 .class 檔案裡,這些類檔案擁有足夠的後設資料來解析類中的所有元素:類名稱、方法、屬性以及 Java 位元組碼(指令)。ASM 從類檔案中讀入資訊後,能夠改變類行為,分析類資訊,甚至能夠根據使用者要求生成新類。

1. 先來個測試

cn.bugstack.middleware.monitor.test.ApiTest

private static byte[] generate() {
    ClassWriter classWriter = new ClassWriter(0);
    // 定義物件頭;版本號、修飾符、全類名、簽名、父類、實現的介面
    classWriter.visit(Opcodes.V1_7, Opcodes.ACC_PUBLIC, "cn/bugstack/demo/asm/AsmHelloWorld", null, "java/lang/Object", null);
    // 新增方法;修飾符、方法名、描述符、簽名、異常
    MethodVisitor methodVisitor = classWriter.visitMethod(Opcodes.ACC_PUBLIC + Opcodes.ACC_STATIC, "main", "([Ljava/lang/String;)V", null, null);
    // 執行指令;獲取靜態屬性
    methodVisitor.visitFieldInsn(Opcodes.GETSTATIC, "java/lang/System", "out", "Ljava/io/PrintStream;");
    // 載入常量 load constant
    methodVisitor.visitLdcInsn("Hello World ASM!");
    // 呼叫方法
    methodVisitor.visitMethodInsn(Opcodes.INVOKEVIRTUAL, "java/io/PrintStream", "println", "(Ljava/lang/String;)V", false);
    // 返回
    methodVisitor.visitInsn(Opcodes.RETURN);
    // 設定運算元棧的深度和區域性變數的大小
    methodVisitor.visitMaxs(2, 1);
    // 方法結束
    methodVisitor.visitEnd();
    // 類完成
    classWriter.visitEnd();
    // 生成位元組陣列
    return classWriter.toByteArray();
}
複製程式碼
  • 以上這段程式碼就是基於 ASM 編寫的 HelloWorld,整個過程包括:定義一個類的生成 ClassWriter、設定版本、修飾符、全類名、簽名、父類、實現的介面,其實也就是那句;public class HelloWorld

  • 型別描述符:

    Java 型別型別描述符
    booleanZ
    charC
    byteB
    shortS
    intI
    floatF
    longJ
    doubleD
    ObjectLjava/lang/Object;
    int[][I
    Object[][][[Ljava/lang/Object;
  • 方法描述符:

    原始檔中的方法宣告方法描述符
    void m(int i, float f)(IF)V
    int m(Object o)(Ljava/lang/Object;)I
    int[] m(int i, String s)(ILjava/lang/String;)[I
    Object m(int[] i)([I)Ljava/lang/Object;
  • 執行指令;獲取靜態屬性。主要是獲得 System.out

  • 載入常量 load constant,輸出我們的HelloWorld methodVisitor.visitLdcInsn("Hello World");

  • 最後是呼叫輸出方法並設定空返回,同時在結尾要設定運算元棧的深度和區域性變數的大小。

  • 這樣輸出一個 HelloWorld 是不還是蠻有意思的,雖然你可能覺得這編碼起來實在太難了吧,也非常難理解。不過你可以安裝一個 ASM 在 IDEA 中的外掛 ASM Bytecode Outline,能更加方便的檢視一個普通的程式碼在使用 ASM 的方式該如何處理。

  • 另外以上這段程式碼的測試結果,主要是生成一個 class 檔案和輸出 Hello World ASM! 結果。

2. 監控設計工程結構

cn-bugstack-middleware-asm
└── src
    ├── main
    │   ├── java
    │   │   └── cn.bugstack.middleware.monitor
    │   │       ├── config
    │   │       │   ├── MethodInfo.java
    │   │       │   └── ProfilingFilter.java
    │   │       ├── probe
    │   │       │   ├── ProfilingAspect.java
    │   │       │   ├── ProfilingClassAdapter.java
    │   │       │   ├── ProfilingMethodVisitor.java
    │   │       │   └── ProfilingTransformer.java
    │   │       └── PreMain.java
    │   └── resources	
    │       └── META_INF
    │           └── MANIFEST.MF
    └── test
        └── java
            └── cn.bugstack.middleware.monitor.test
                └── ApiTest.java
複製程式碼

以上工程結構是使用 ASM 框架給系統方法做增強操作,也就是相當於通過框架完成硬編碼寫入方法前後的監控資訊。不過這個過程轉移到了 Java 程式啟動時在 Javaagent#premain 進行處理。

  • MethodInfo 是方法的定義,主要是描述類名、方法名、描述、入參、出參資訊。
  • ProfilingFilter 是監控的配置資訊,主要是過濾一些不需要位元組碼增強操作的方法,比如main、hashCode、javax/等
  • ProfilingAspect、ProfilingClassAdapter、ProfilingMethodVisitor、ProfilingTransformer,這四個類主要是完成位元組碼插裝操作和輸出監控結果的類。
  • PreMain 提供了 Javaagent 的入口,JVM 首先嚐試在代理類上呼叫 premain 方法。
  • MANIFEST.MF 是配置資訊,主要是找到 Premain-Class Premain-Class: cn.bugstack.middleware.monitor.PreMain

3. 監控類入口

cn.bugstack.middleware.monitor.PreMain

public class PreMain {

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

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

}
複製程式碼
  • 這個是 Javaagent 技術的固定入口方法類,同時還需要把這個類的路徑配置到 MANIFEST.MF 中。

4. 位元組碼方法處理

cn.bugstack.middleware.monitor.probe.ProfilingTransformer

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;
    }

    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 核心類 ClassReader、ClassWriter、ClassVisitor,處理傳入進行的類載入器、類名、位元組碼等,負責位元組碼的增強操作。
  • 此處主要是關於 ASM 的操作類,ClassReader、ClassWriter、ClassVisitor,關於位元組碼程式設計的文章:ASM、Javassist、Byte-bu 系列文章

5.位元組碼方法解析

cn.bugstack.middleware.monitor.probe.ProfilingMethodVisitor

public class ProfilingMethodVisitor extends AdviceAdapter {

    private List<String> parameterTypeList = new ArrayList<>();
    private int parameterTypeCount = 0;     // 引數個數
    private int startTimeIdentifier;        // 啟動時間標記
    private int parameterIdentifier;        // 入參內容標記
    private int methodId = -1;              // 方法全域性唯一標記
    private int currentLocal = 0;           // 當前區域性變數值
    private final boolean isStaticMethod;   // true;靜態方法,false;非靜態方法
    private final String className;

    protected ProfilingMethodVisitor(int access, String methodName, String desc, MethodVisitor mv, String className, String fullClassName, String simpleClassName) {
        super(ASM5, mv, access, methodName, desc);
        this.className = className;
        // 判斷是否為靜態方法,非靜態方法中區域性變數第一個值是this,靜態方法是第一個入參引數
        isStaticMethod = 0 != (access & ACC_STATIC);
        //(String var1,Object var2,String var3,int var4,long var5,int[] var6,Object[][] var7,Req var8)=="(Ljava/lang/String;Ljava/lang/Object;Ljava/lang/String;IJ[I[[Ljava/lang/Object;Lorg/itstack/test/Req;)V"
        Matcher matcher = Pattern.compile("(L.*?;|\\[{0,2}L.*?;|[ZCBSIFJD]|\\[{0,2}[ZCBSIFJD]{1})").matcher(desc.substring(0, desc.lastIndexOf(')') + 1));
        while (matcher.find()) {
            parameterTypeList.add(matcher.group(1));
        }
        parameterTypeCount = parameterTypeList.size();
        methodId = ProfilingAspect.generateMethodId(new MethodInfo(fullClassName, simpleClassName, methodName, desc, parameterTypeList, desc.substring(desc.lastIndexOf(')') + 1)));
    }     

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

6. 執行測試

6.1 配置 VM 引數 Javaagent

-javaagent:E:\itstack\git\github.com\MonitorDesign\cn-bugstack-middleware-asm\target\cn-bugstack-middleware-asm.jar
複製程式碼
  • IDEA 執行時候配置到 VM options 中,jar包地址按照自己的路徑進行配置。

6.2 測試結果

監控 - Begin By ASM
方法:cn.bugstack.middleware.test.interfaces.UserController$$EnhancerBySpringCGLIB$$8f5a18ca.queryUserInfo
入參:null 入參型別:["Ljava/lang/String;"] 入數[值]:["aaa"]
出參:Lcn/bugstack/middleware/test/interfaces/dto/UserInfo; 出參[值]:{"address":"天津市東麗區萬科賞溪苑14-0000","age":19,"code":"0000","info":"success","name":"蟲蟲:aaa"}
耗時:54(s)
監控 - End
複製程式碼
  • 從執行測試結果可以看到,在使用 ASM 監控後,就不需要硬編碼也不需要 AOP 的方式在程式碼中操作了。同時還可以監控到更完整的方法執行資訊,包括入參型別、入參值和出參資訊、出參值。
  • 但可能大家會發現 ASM 操作起來還是挺麻煩的,尤其是一些很複雜的編碼邏輯中,可能會遇到各種各樣問題,因此接下來我們還會介紹一些基於 ASM 開發的元件,這些元件也可以實現同樣的功能。

五、Javassist

Javassist是一個開源的分析、編輯和建立Java位元組碼的類庫。是由東京工業大學的數學和電腦科學系的 Shigeru Chiba (千葉 滋)所建立的。它已加入了開放原始碼JBoss 應用伺服器專案,通過使用Javassist對位元組碼操作為JBoss實現動態"AOP"框架。

1. 先來個測試

cn.bugstack.middleware.monitor.test.ApiTest

public class ApiTest {

    public static void main(String[] args) throws Exception {
        ClassPool pool = ClassPool.getDefault();

        CtClass ctClass = pool.makeClass("cn.bugstack.middleware.javassist.MathUtil");

        // 屬性欄位
        CtField ctField = new CtField(CtClass.doubleType, "π", ctClass);
        ctField.setModifiers(Modifier.PRIVATE + Modifier.STATIC + Modifier.FINAL);
        ctClass.addField(ctField, "3.14");

        // 方法:求圓面積
        CtMethod calculateCircularArea = new CtMethod(CtClass.doubleType, "calculateCircularArea", new CtClass[]{CtClass.doubleType}, ctClass);
        calculateCircularArea.setModifiers(Modifier.PUBLIC);
        calculateCircularArea.setBody("{return π * $1 * $1;}");
        ctClass.addMethod(calculateCircularArea);

        // 方法;兩數之和
        CtMethod sumOfTwoNumbers = new CtMethod(pool.get(Double.class.getName()), "sumOfTwoNumbers", new CtClass[]{CtClass.doubleType, CtClass.doubleType}, ctClass);
        sumOfTwoNumbers.setModifiers(Modifier.PUBLIC);
        sumOfTwoNumbers.setBody("{return Double.valueOf($1 + $2);}");
        ctClass.addMethod(sumOfTwoNumbers);
        // 輸出類的內容
        ctClass.writeFile();

        // 測試呼叫
        Class clazz = ctClass.toClass();
        Object obj = clazz.newInstance();

        Method method_calculateCircularArea = clazz.getDeclaredMethod("calculateCircularArea", double.class);
        Object obj_01 = method_calculateCircularArea.invoke(obj, 1.23);
        System.out.println("圓面積:" + obj_01);

        Method method_sumOfTwoNumbers = clazz.getDeclaredMethod("sumOfTwoNumbers", double.class, double.class);
        Object obj_02 = method_sumOfTwoNumbers.invoke(obj, 1, 2);
        System.out.println("兩數和:" + obj_02);
    }

}
複製程式碼
  • 這是一個使用 Javassist 生成的求圓面積和抽象的類和方法並執行結果的過程,可以看到 Javassist 主要是 ClassPool、CtClass、CtField、CtMethod 等方法的使用。
  • 測試結果主要包括會生成一個指定路徑下的類 cn.bugstack.middleware.javassist.MathUtil,同時還會在控制檯輸出結果。

生成的類

public class MathUtil {
  private static final double π = 3.14D;

  public double calculateCircularArea(double var1) {
      return 3.14D * var1 * var1;
  }

  public Double sumOfTwoNumbers(double var1, double var3) {
      return var1 + var3;
  }

  public MathUtil() {
  }
}
複製程式碼

測試結果

圓面積:4.750506
兩數和:3.0

Process finished with exit code 0
複製程式碼

2. 監控設計工程結構

cn-bugstack-middleware-javassist
└── src
    ├── main
    │   ├── java
    │   │   └── cn.bugstack.middleware.monitor
    │   │       ├── config
    │   │       │   └── MethodDescription.java
    │   │       ├── probe
    │   │       │   ├── Monitor.java
    │   │       │   └── MyMonitorTransformer.java
    │   │       └── PreMain.java
    │   └── resources
    │       └── META_INF
    │           └── MANIFEST.MF
    └── test
        └── java
            └── cn.bugstack.middleware.monitor.test
                └── ApiTest.java
複製程式碼
  • 整個使用 javassist 實現的監控框架來看,與 ASM 的結構非常相似,但大部分操作位元組碼的工作都交給了 javassist 框架來處理,所以整個程式碼結構看上去更簡單了。

3. 監控方法插樁

cn.bugstack.middleware.monitor.probe.MyMonitorTransformer

public class MyMonitorTransformer implements ClassFileTransformer {

    private static final Set<String> classNameSet = new HashSet<>();

    static {
        classNameSet.add("cn.bugstack.middleware.test.interfaces.UserController");
    }

    @Override
    public byte[] transform(ClassLoader loader, String className, Class<?> classBeingRedefined, ProtectionDomain protectionDomain, byte[] classfileBuffer) {
        try {
            String currentClassName = className.replaceAll("/", ".");
            if (!classNameSet.contains(currentClassName)) { // 提升classNameSet中含有的類
                return null;
            }

            // 獲取類
            CtClass ctClass = ClassPool.getDefault().get(currentClassName);
            String clazzName = ctClass.getName();

            // 獲取方法
            CtMethod ctMethod = ctClass.getDeclaredMethod("queryUserInfo");
            String methodName = ctMethod.getName();

            // 方法資訊:methodInfo.getDescriptor();
            MethodInfo methodInfo = ctMethod.getMethodInfo();

            // 方法:入參資訊
            CodeAttribute codeAttribute = methodInfo.getCodeAttribute();
            LocalVariableAttribute attr = (LocalVariableAttribute) codeAttribute.getAttribute(LocalVariableAttribute.tag);
            CtClass[] parameterTypes = ctMethod.getParameterTypes();

            boolean isStatic = (methodInfo.getAccessFlags() & AccessFlag.STATIC) != 0;  // 判斷是否為靜態方法
            int parameterSize = isStatic ? attr.tableLength() : attr.tableLength() - 1; // 靜態型別取值
            List<String> parameterNameList = new ArrayList<>(parameterSize);            // 入參名稱
            List<String> parameterTypeList = new ArrayList<>(parameterSize);            // 入參型別
            StringBuilder parameters = new StringBuilder();                             // 引數組裝;$1、$2...,$$可以獲取全部,但是不能放到陣列初始化

            for (int i = 0; i < parameterSize; i++) {
                parameterNameList.add(attr.variableName(i + (isStatic ? 0 : 1))); // 靜態型別去掉第一個this引數
                parameterTypeList.add(parameterTypes[i].getName());
                if (i + 1 == parameterSize) {
                    parameters.append("$").append(i + 1);
                } else {
                    parameters.append("$").append(i + 1).append(",");
                }
            }

            // 方法:出參資訊
            CtClass returnType = ctMethod.getReturnType();
            String returnTypeName = returnType.getName();

            // 方法:生成方法唯一標識ID
            int idx = Monitor.generateMethodId(clazzName, methodName, parameterNameList, parameterTypeList, returnTypeName);

            // 定義屬性
            ctMethod.addLocalVariable("startNanos", CtClass.longType);
            ctMethod.addLocalVariable("parameterValues", ClassPool.getDefault().get(Object[].class.getName()));

            // 方法前加強
            ctMethod.insertBefore("{ startNanos = System.nanoTime(); parameterValues = new Object[]{" + parameters.toString() + "}; }");

            // 方法後加強
            ctMethod.insertAfter("{ cn.bugstack.middleware.monitor.probe.Monitor.point(" + idx + ", startNanos, parameterValues, $_);}", false); // 如果返回型別非物件型別,$_ 需要進行型別轉換

            // 方法;新增TryCatch
            ctMethod.addCatch("{ cn.bugstack.middleware.monitor.probe.Monitor.point(" + idx + ", $e); throw $e; }", ClassPool.getDefault().get("java.lang.Exception"));   // 新增異常捕獲

            return ctClass.toBytecode();
        } catch (Exception e) {
            e.printStackTrace();
        }
        return null;
    }

}
複製程式碼
  • 與 ASM 實現相比,整體的監控方法都是類似的,所以這裡只展示下不同的地方。
  • 通過 Javassist 的操作,主要是實現一個 ClassFileTransformer 介面的 transform 方法,在這個方法中獲取位元組碼並進行相應的處理。
  • 處理過程包括:獲取類、獲取方法、獲取入參資訊、獲取出參資訊、給方法生成唯一ID、之後開始進行方法的前後增強操作,這個增強也就是在方法塊中新增監控程式碼。
  • 最後返回位元組碼資訊 return ctClass.toBytecode(); 現在你新加入的位元組碼就已經可以被程式載入處理了。

4. 執行測試

4.1 配置 VM 引數 Javaagent

-javaagent:E:\itstack\git\github.com\MonitorDesign\cn-bugstack-middleware-javassist\target\cn-bugstack-middleware-javassist.jar
複製程式碼
  • IDEA 執行時候配置到 VM options 中,jar包地址按照自己的路徑進行配置。

4.2 測試結果

監控 -  Begin By Javassist
方法:cn.bugstack.middleware.test.interfaces.UserController$$EnhancerBySpringCGLIB$$8f5a18ca.queryUserInfo
入參:null 入參型別:["Ljava/lang/String;"] 入數[值]:["aaa"]
出參:Lcn/bugstack/middleware/test/interfaces/dto/UserInfo; 出參[值]:{"address":"天津市東麗區萬科賞溪苑14-0000","age":19,"code":"0000","info":"success","name":"蟲蟲:aaa"}
耗時:46(s)
監控 - End
複製程式碼
  • 從測試結果來看與 ASM 做位元組碼插樁的效果是一樣,都可以做到監控系統執行資訊。但是這樣的框架會使開發流程更簡單,也更容易控制。

六、Byte-Buddy

2015年10月,Byte Buddy被 Oracle 授予了 Duke's Choice大獎。該獎項對Byte Buddy的“ Java技術方面的巨大創新 ”表示讚賞。我們為獲得此獎項感到非常榮幸,並感謝所有幫助Byte Buddy取得成功的使用者以及其他所有人。我們真的很感激!

Byte Buddy 是一個程式碼生成和操作庫,用於在 Java 應用程式執行時建立和修改 Java 類,而無需編譯器的幫助。除了 Java 類庫附帶的程式碼生成實用程式外,Byte Buddy 還允許建立任意類,並且不限於實現用於建立執行時代理的介面。此外,Byte Buddy 提供了一種方便的 API,可以使用 Java 代理或在構建過程中手動更改類。

  • 無需理解位元組碼指令,即可使用簡單的 API 就能很容易操作位元組碼,控制類和方法。
  • 已支援Java 11,庫輕量,僅取決於Java位元組程式碼解析器庫ASM的訪問者API,它本身不需要任何其他依賴項。
  • 比起JDK動態代理、cglib、Javassist,Byte Buddy在效能上具有一定的優勢。

1. 先來個測試

cn.bugstack.middleware.monitor.test.ApiTest

public class ApiTest {

    public static void main(String[] args) throws IllegalAccessException, InstantiationException {
        String helloWorld = new ByteBuddy()
                .subclass(Object.class)
                .method(named("toString"))
                .intercept(FixedValue.value("Hello World!"))
                .make()
                .load(ApiTest.class.getClassLoader())
                .getLoaded()
                .newInstance()
                .toString();

        System.out.println(helloWorld);
    }

}
複製程式碼
  • 這是一個使用 ByteBuddy 語法生成的 "Hello World!" 案例,他的執行結果就是一行,Hello World!,整個程式碼塊核心功能就是通過 method(named("toString")),找到 toString 方法,再通過攔截 intercept,設定此方法的返回值。FixedValue.value("Hello World!")。到這裡其實一個基本的方法就通過 Byte-buddy ,最後載入、初始化和呼叫輸出。

測試結果

Hello World!

Process finished with exit code 0
複製程式碼

2. 監控設計工程結構

cn-bugstack-middleware-bytebuddy
└── src
    ├── main
    │   ├── java
    │   │   └── cn.bugstack.middleware.monitor
    │   │       ├── MonitorMethod
    │   │       └── PreMain.java
    │   └── resources
    │       └── META_INF
    │           └── MANIFEST.MF
    └── test
        └── java
            └── cn.bugstack.middleware.monitor.test
                └── ApiTest.java
複製程式碼
  • 這是我個人最喜歡的一個框架,因為它操作的方便性,可以像使用普通的業務程式碼一樣使用位元組碼增強的操作。從現在的工程結構你能看得出來,程式碼類數量越來越少了。

3. 監控方法插樁

cn.bugstack.middleware.monitor.MonitorMethod

public class MonitorMethod {

    @RuntimeType
    public static Object intercept(@Origin Method method, @SuperCall Callable<?> callable, @AllArguments Object[] args) throws Exception {
        long start = System.currentTimeMillis();
        Object resObj = null;
        try {
            resObj = callable.call();
            return resObj;
        } finally {
            System.out.println("監控 - Begin By Byte-buddy");
            System.out.println("方法名稱:" + method.getName());
            System.out.println("入參個數:" + method.getParameterCount());
            for (int i = 0; i < method.getParameterCount(); i++) {
                System.out.println("入參 Idx:" + (i + 1) + " 型別:" + method.getParameterTypes()[i].getTypeName() + " 內容:" + args[i]);
            }
            System.out.println("出參型別:" + method.getReturnType().getName());
            System.out.println("出參結果:" + resObj);
            System.out.println("方法耗時:" + (System.currentTimeMillis() - start) + "ms");
            System.out.println("監控 - End\r\n");
        }
    }

}
複製程式碼
  • @Origin,用於攔截原有方法,這樣就可以獲取到方法中的相關資訊。
  • 這一部分的資訊相對來說比較全,尤其也獲取到了引數的個數和型別,這樣就可以在後續的處理引數時進行迴圈輸出。

常用註解說明

除了以上為了獲取方法的執行資訊使用到的註解外,Byte Buddy 還提供了很多其他的註解。如下;

註解說明
@Argument繫結單個引數
@AllArguments繫結所有引數的陣列
@This當前被攔截的、動態生成的那個物件
@Super當前被攔截的、動態生成的那個物件的父類物件
@Origin可以繫結到以下型別的引數:Method 被呼叫的原始方法 Constructor 被呼叫的原始構造器 Class 當前動態建立的類 MethodHandle MethodType String 動態類的toString()的返回值 int 動態方法的修飾符
@DefaultCall呼叫預設方法而非super的方法
@SuperCall用於呼叫父類版本的方法
@Super注入父型別物件,可以是介面,從而呼叫它的任何方法
@RuntimeType可以用在返回值、引數上,提示ByteBuddy禁用嚴格的型別檢查
@Empty注入引數的型別的預設值
@StubValue注入一個存根值。對於返回引用、void的方法,注入null;對於返回原始型別的方法,注入0
@FieldValue注入被攔截物件的一個欄位的值
@Morph類似於@SuperCall,但是允許指定呼叫引數

常用核心API

  1. ByteBuddy

    • 流式API方式的入口類
    • 提供Subclassing/Redefining/Rebasing方式改寫位元組碼
    • 所有的操作依賴DynamicType.Builder進行,建立不可變的物件
  2. ElementMatchers(ElementMatcher)

    • 提供一系列的元素匹配的工具類(named/any/nameEndsWith等等)
    • ElementMatcher(提供對型別、方法、欄位、註解進行matches的方式,類似於Predicate)
    • Junction對多個ElementMatcher進行了and/or操作
  3. DynamicType

    (動態型別,所有位元組碼操作的開始,非常值得關注)

    • Unloaded(動態建立的位元組碼還未載入進入到虛擬機器,需要類載入器進行載入)
    • Loaded(已載入到jvm中後,解析出Class表示)
    • Default(DynamicType的預設實現,完成相關實際操作)
  4. `Implementation

    (用於提供動態方法的實現)

    • FixedValue(方法呼叫返回固定值)
    • MethodDelegation(方法呼叫委託,支援兩種方式: Class的static方法呼叫、object的instance method方法呼叫)
  5. Builder

    (用於建立DynamicType,相關介面以及實現後續待詳解)

    • MethodDefinition
    • FieldDefinition
    • AbstractBase

4. 配置入口方法

cn.bugstack.middleware.monitor.PreMain

public class PreMain {

    //JVM 首先嚐試在代理類上呼叫以下方法
    public static void premain(String agentArgs, Instrumentation inst) {
        AgentBuilder.Transformer transformer = (builder, typeDescription, classLoader, javaModule) -> {
            return builder
                    .method(ElementMatchers.named("queryUserInfo")) // 攔截任意方法
                    .intercept(MethodDelegation.to(MonitorMethod.class)); // 委託
        };

        new AgentBuilder
                .Default()
                .type(ElementMatchers.nameStartsWith(agentArgs))  // 指定需要攔截的類 "cn.bugstack.demo.test"
                .transform(transformer)
                .installOn(inst);
    }

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

}
複製程式碼
  • premain 方法中主要是對實現的 MonitorMethod 進行委託使用,同時還在 method 設定了攔截的方法,這個攔截方法還可以到類路徑等。

5. 執行測試

5.1 配置 VM 引數 Javaagent

-javaagent:E:\itstack\git\github.com\MonitorDesign\cn-bugstack-middleware-bytebuddy\target\cn-bugstack-middleware-bytebuddy.jar
複製程式碼
  • IDEA 執行時候配置到 VM options 中,jar包地址按照自己的路徑進行配置。

5.2 測試結果

監控 - Begin By Byte-buddy
方法名稱:queryUserInfo
入參個數:1
入參 Idx:1 型別:java.lang.String 內容:aaa
出參型別:cn.bugstack.middleware.test.interfaces.dto.UserInfo
出參結果:cn.bugstack.middleware.test.interfaces.dto.@214b199c
方法耗時:1ms
監控 - End
複製程式碼
  • Byte-buddy 是我們整個測試過程的幾個位元組碼框架中,操作起來最簡單,最方便的,也非常容易擴容資訊。整個過程就像最初使用 AOP 一樣簡單,但卻滿足了非入侵的監控需求。
  • 所以在使用位元組碼框架的時候,可以考慮選擇使用 Byte-buddy 這個非常好用的位元組碼框架。

七、總結

  • ASM 這種位元組碼程式設計的應用是非常廣的,但可能確實平時看不到的,因為他都是與其他框架結合一起作為支撐服務使用。像這樣的技術還有很多,比如 javassit、Cglib、jacoco等等。
  • 在一些全鏈路監控中的元件中 Javassist 的使用非常多,它即可使用編碼的方式操作位元組碼增強,也可以像 ASM 那樣進行處理。
  • Byte-buddy 是一個非常方便的框架,目前使用也越來越廣泛,並且上手使用的學習難度也是幾個框架中最低的。除了本章節的案例使用介紹外,還可以通過官網:https://bytebuddy.net,去了解更多關於 Byte Buddy 的內容。
  • 本章節所有的原始碼已經上傳到GitHub:github.com/fuzhengwei/…

相關文章