1. 程式人生 > 程式設計 >深入開源框架底層之ASM

深入開源框架底層之ASM

什麼是 ASM ?

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

為什麼要動態生成 Java 類?

想象一下,如果開源框架要求你新增各種Java類來實現諸如log、cache、transaction等功能,我想這個開源框架你肯定不會用吧。動態生成類可以減少對你程式碼的侵入,提高使用者的效率。

為什麼選擇ASM?

最直接的改造 Java 類的方法莫過於直接改寫 class 檔案。Java 規範詳細說明瞭 class 檔案的格式,直接編輯位元組碼確實可以改變 Java 類的行為。直到今天,還有一些 Java 高手們使用最原始的工具,如 UltraEdit 這樣的編輯器對 class 檔案動手術。是的,這是最直接的方法,但是要求使用者對 Java class 檔案的格式了熟於心:小心地推算出想改造的函式相對檔案首部的偏移量,同時重新計算 class 檔案的校驗碼以通過 Java 虛擬機器器的安全機制。

可以發現,直接操作class檔案是比較麻煩的,就跟為什麼我們都選擇使用框架一樣,框架遮蔽了底層的複雜性。ASM就是操作class的一把利器。

使用 ASM 程式設計

ASM提供了兩種API:

  1. CoreAPI(ClassVisitor 、MethodVisitor等)
  2. TreeAPI(ClassNode,MethodNode等)

區別是CoreAPI基於事件模型,定義了Class中各個元素的Visitor,不需要載入整個Class到記憶體中。而TreeAPI以Tree結構將Class整個結構讀取到記憶體中。從使用角度來說TreeAPI更為簡單。

以下示例採用的是CoreAPI方式。

新增Maven:

<dependency>
    <groupId>org.ow2.asm</groupId>
    <artifactId>asm</artifactId>
    <version>5.0.4</version>
</dependency>
複製程式碼

使用的相對比較穩定,使用比較多的版本5.0.4。

首先說明一下,修改Class有多種方式,例如直接修改當前Class,或者生成Class的子類,從而達到增強的效果。

下面的示例就是通過生成指定Class的子類,從而達到增強的效果,好處是對原有Class無侵入,並且可以實現多型的效果。

首先定義一個我們要增強的類:

package com.zjz;

import java.util.Random;

/**
 * @author zhaojz created at 2019-08-22 10:49
 */
public class Student {
    public String name;

    public void studying() throws InterruptedException {
        System.out.println(this.name+"正在學習...");
        Thread.sleep(new Random().nextInt(5000));
    }
}

複製程式碼

接下來首先定義一個ClassReader:

ClassReader classReader = new ClassReader("com.zjz.Student");
複製程式碼

然後再定義一個ClassWriter:

 ClassWriter classWriter = new ClassWriter(classReader,ClassWriter.COMPUTE_MAXS);
複製程式碼

ClassWriter.COMPUTE_MAXS 表示自動計算區域性變數和運算元棧大小。更多其它選項可參考:asm.ow2.io

接下來開始正式訪問Class:

//通過ClassVisitor訪問Class(匿名類的方式,可以自行定義為一個獨立的類)
//ASM5為JVM位元組碼指令操作碼
ClassVisitor classVisitor = new ClassVisitor(Opcodes.ASM5,classWriter) {
	//宣告一個全域性變數,表示增強後生成的子類的父類
   String enhancedSuperName;
   @Override
   public void visit(int version,int access,String name,String signature,String superName,String[] interfaces) {
   //拼接需要生成的子類的類名:Student$EnhancedByASM
   String enhancedName = name+"$EnhancedByASM";
   //將Student設定為父類
   enhancedSuperName = name;
   super.visit(version,access,enhancedName,signature,enhancedSuperName,interfaces);
   }

    @Override
    public FieldVisitor visitField(int access,String desc,Object value) {
    //這裡是演示欄位訪問
    System.out.println("Field:" + name);
    return super.visitField(access,name,desc,value);
    }

    @Override
    public MethodVisitor visitMethod(int access,String[] exceptions) {
    System.out.println("Method:" + name);
    MethodVisitor mv = super.visitMethod(access,exceptions);
    MethodVisitor wrappedMv = mv;
    //判斷當前讀取的方法
    if (name.equals("studying")) {
    //如果是studying方法,則包裝一個方法的Visitor
    wrappedMv = new StudentStudyingMethodVisitor(Opcodes.ASM5,mv);
    }else if(name.equals("<init>")){
    //如果是構造方法,處理子類中父類的建構函式呼叫
    wrappedMv = new StudentEnhancedConstructorMethodVisitor(Opcodes.ASM5,mv,enhancedSuperName);
    }
    return wrappedMv;
    }
};
複製程式碼

接下來重點看看MethodVisitor:

//Studying方法的Visitor
static class StudentStudyingMethodVisitor extends MethodVisitor{

    public StudentStudyingMethodVisitor(int i,MethodVisitor methodVisitor) {
    	super(i,methodVisitor);
    }

	//MethodVisitor 中定義了不同的visitXXX()方法,代表的不同的訪問階段。
	//visitCode表示剛剛進入方法。
    @Override
    public void visitCode() {
    	//新增一行System.currentTimeMillis()呼叫
        visitMethodInsn(Opcodes.INVOKESTATIC,"java/lang/System","currentTimeMillis","()J",false);
        //並且將其儲存在區域性變量表內位置為1的地方
        visitVarInsn(Opcodes.LSTORE,1);
        //上面兩個的作用就是在Studying方法的第一行新增 long start = System.currentTimeMillis()
    }

	//visitInsn 表示訪問進入了方法內部
    @Override
    public void visitInsn(int opcode) {
    	//通過opcode可以得知當前訪問到了哪一步,如果是>=Opcodes.IRETURN && opcode <= Opcodes.RETURN 表明方法即將退出
        if ((opcode >= Opcodes.IRETURN && opcode <= Opcodes.RETURN)){
        	//載入區域性變量表中位置為1的資料,也就是start的資料,並傳入給下面的方法
            visitVarInsn(Opcodes.LLOAD,1);
            //然後呼叫自定義的一個工具方法,用來輸出耗時
            visitMethodInsn(Opcodes.INVOKESTATIC,"com/zjz/Before","end","(J)V",false);
        }
        super.visitInsn(opcode);
    }


}

static class StudentEnhancedConstructorMethodVisitor extends MethodVisitor{
	//定義一個全域性變數記錄父類名稱
    private String superClassName;
    public StudentEnhancedConstructorMethodVisitor(int i,MethodVisitor methodVisitor,String superClassName) {
        super(i,methodVisitor);
        this.superClassName = superClassName;
    }

    @Override
    public void visitMethodInsn(int opcode,String owner,boolean b) {
    	//當開始初始化建構函式時,先訪問父類建構函式,類似原始碼中的super()
        if (opcode==Opcodes.INVOKESPECIAL && name.equals("<init>")){
        	owner = superClassName;
        }
        super.visitMethodInsn(opcode,owner,b);
    }
}
複製程式碼

此時ClassVisitor還沒有資料的輸入,只定義了資料的輸出 new ClassVisitor(Opcodes.ASM5,classWriter),所以還需要:

classReader.accept(classVisitor,ClassReader.SKIP_DEBUG);
複製程式碼

到此就完成了Class的讀取,訪問修改,輸出的過程。

細心的觀眾就會發現了,輸出到哪裡了?怎麼樣訪問新生成的類呢?所以我們需要定義一個ClassLoader來載入我們生成的Class:

 static class StudentClassLoader extends ClassLoader{
        public Class defineClassFromClassFile(String className,byte[] classFile) throws ClassFormatError{
            return defineClass(className,classFile,classFile.length);
        }
    }
複製程式碼

然後通過ClassWriter獲取新生成的類的位元組陣列,並載入到JVM中:

 byte[] data = classWriter.toByteArray();
 Class subStudent = classLoader.defineClassFromClassFile("com.zjz.Student$EnhancedByASM",data);
複製程式碼

到此就完成了一個class的生成,上面的程式碼完成的是一個很簡單的事情:記錄學習時間。

總結一下:

ASM CoreAPI 核心的三個東西就是ClassReader、Visitor、ClassWriter,通過責任鏈模式將其連結起來。

Visitor通過訪問者模式進行方法、欄位等等屬性的訪問,如果需要修改一個方法和欄位,只需要將其原本的Visitor給Wrap一下即可。

關於如何進行程式碼的hook需要理解JVM相關位元組碼指令,以及ASM的相關OpCode。

ASM Bytecode Outline 2017

但是那麼多指令、OpCode、符號怎麼記得住呢?比如上面程式碼中的:

visitMethodInsn(Opcodes.INVOKESTATIC,false);
visitVarInsn(Opcodes.LSTORE,1);
複製程式碼

Opcodes.INVOKESTATIC 、Opcodes.LSTORE、()J,是不是看著就暈?其實除了熟能生巧外,還可以使用工具。

如果你使用的是IDEA,那麼可以安裝上ASM Bytecode Outline 2017外掛。然後在原始檔上右鍵選擇Show Bytecode Outline,你將會看到如下檢視:

image.png

切換的ASMified檢視,你會看到跟我們上面寫的一樣的程式碼,直接Copy過來使用即可。

檢視示例完整原始碼:asm_demo

參考資料:

asm.ow2.io/

www.ibm.com/developerwo…

juejin.im/post/5b549b…