Java位元組碼操縱技術
大家可能已經非常熟悉下面的處理流程:將一個“.java”檔案輸入到Java編譯器中(可能會使用javac,也可能像ANT、Maven或Gradle這樣的構建工具),編譯器對其進行分析,最終生成一個或多個“.class”檔案。
圖1:什麼是Java位元組碼?
如果從命令列中執行構建,並啟用verbose的話,我們能夠看到解析檔案直到生成“.class”檔案這一過程的輸出。
javac -verbose src/com/example/spring2gx/BankTransactions.java
所生成的“.class”檔案包含了位元組碼,本質上來講它就是Java虛擬機器(Java virtual machine,JVM)所使用的指令,當程式執行時,它會由Java執行時類載入器進行載入。
在本文中,我們將會研究Java位元組碼以及如何對其進行操縱,並探討人們為何想要這樣做。
位元組碼操縱框架
最為流行的位元組碼操縱框架包括:
本文將會主要關注Javassist和ASM。
我們為什麼應該關注位元組碼操縱呢?
很多常用的Java庫,如Spring和Hibernate,以及大多數的JVM語言甚至我們的IDE,都用到了位元組碼操縱框架。另外,它也確實非常有趣,所以這是一項很有價值的技術,掌握它之後,我們就能完成一些靠其他技術很難實現或無法完成的任務。一旦學會之後,我們的發揮空間將是無限的!
一個很重要的使用場景就是程式分析。例如,流行的bug定位工具FindBugs在底層就使用了ASM來分析位元組碼並定位bug。有一些軟體商店會有一定的程式碼複雜性規則,比如方法中if/else語句的最大數量以及方法的最大長度。靜態分析工具會分析我們的位元組碼來確定程式碼的複雜性。
另外一個常見的使用場景就是類生成功能。例如,ORM框架一般都會基於我們的類定義使用代理的機制。或者,在考慮實現應用的安全性時,可能會提供一種語法來新增授權的註解。在這樣的場景下,都能很好地運用位元組碼操縱技術。
像Scala、Groovy和Grails這樣的JVM語言都使用了位元組碼操縱框架。
考慮這樣一種場景,我們需要轉換庫中的類,這些類我們並沒有原始碼,這樣的任務通常會由Java profiler來執行。例如,在New Relic,採用了位元組碼instrumentation技術實現了對方法執行的計時。
藉助位元組碼操縱,我們可以優化或混淆程式碼,甚至可以引入一些功能,比如為應用新增重要的日誌。本文將會關注一個日誌樣例,這個樣例提供使用這些位元組碼操縱框架的基本工具。
我們的樣例
Sue負責一家銀行的ATM程式設計,她有了一項新的需求:針對一些指定的重要操作,在日誌中新增關鍵的資料。
如下是一個簡化的銀行交易類,它允許使用者通過使用者名稱和密碼進行登入、進行一些處理、提取一些錢,然後列印“交易完成”。這裡的重要操作就是登入和提款。
public void login(String password, String accountId, String userName) {
// 登入邏輯
}
public void withdraw(String accountId, Double moneyToRemove) {
// 交易邏輯
}
為了簡化編碼,Sue會為這些方法呼叫建立一個@ImportantLog註解,這個註解所包含的輸入引數代表了希望記錄的方法引數索引。藉助這一點,她就可以為login和withdraw方法添加註解了。
/**
* 方法註解,用於識別
* 重要的方法,這些方法的呼叫需要進行日誌記錄。
*/
public @interface ImportantLog {
/**
* 需要進行日誌記錄的方法引數索引。
* 例如,如果有名為
* hello(int paramA, int paramB, int paramC)的方法,我們
* 希望以日誌的形式記錄paramA和paramC的值,那麼fields
* 應該是["0","2"]。如果我們只想記錄
* paramB的值,那麼fields將會是["1"]。
*/
String[] fields();
}
對於login方法,Sue希望記錄賬戶Id和使用者名稱,那麼她的fields應該設定為“1”和“2”(她不希望將密碼展現出來!)。對於withdraw方法,她的fields應該設定為“0”和“1”,因為她希望輸出前兩個域:賬戶ID以及要提取的金額,其審計日誌理想情況下應該包含如下的內容:
要實現該功能,Sue將會使用Java agent技術。Java agent是在JDK 1.5中引入的,它允許我們在處於執行狀態的JVM中,修改組成類的位元組,在這個過程中,並不需要這些類的原始碼。
在沒有agent的時候,Sue的程式的正常執行流程是這樣的:
- 在某個主類上執行Java,這個類會由一個類載入器進行載入;
- 呼叫該類的main方法,它會呼叫預先定義好的處理過程;
- 列印“交易完成”。
在引入Java agent之後,會發生幾件額外的事情——但是,在此之前,我們先看一下建立agent都需要些什麼。agent必須要包含一個類,這個類要具有一個名為premain的方法。這個類必須要打包為JAR檔案,這個包中還需要包含一個正確的manifest檔案,在manifest檔案中要有一個名為Premain-Class的條目。在啟動的時候,必須要設定一個啟動項,指向該JAR檔案的路徑,這樣的話,JVM才能知道這個agent:
java -javaagent:/to/agent.jar com/example/spring2gx/BankTransactions
在premain方法中,我們可以註冊一個Transformer,它會在每個類載入的時候,捕獲它的位元組,進行所需的修改,然後返回修改後的位元組。在Sue的樣例中,Transformer會捕獲BankTransaction,在這裡她會作出修改並返回修改後的位元組,這也就是類載入器所載入的位元組,main方法將會執行原有的功能,除此之外還會增加Sue所需的日誌增強。
當agent類載入後,它的premain方法會在應用程式的main方法之前被呼叫。
圖2:使用Java agent的過程。
我們最好來看一個樣例。
Agent類不需要實現任何介面,但是它必須要包含一個premain方法,如下所示:
Transformer類包含了一個transform方法,它的簽名會接受ClassLoader、類名、要重定義的類所對應的Class物件、定義許可權的ProtectionDomain以及這個類的原始位元組。如果從transform方法中返回null的話,將會告訴執行時環境我們並沒有對這個類進行變更。
如果要修改類的位元組的話,我們需要在transform中提供位元組碼操縱的邏輯並返回修改後的位元組。
Javassist
Javassist(“Java Programming Assistant”的縮寫形式)是JBoss的子專案,包含了高層級的基於物件的API,同時也包含了低層級更接近位元組碼的API。基於物件的API社群更為活躍,這也是本文所關注的焦點。讀者可以參考 Javassist站點 以獲取完整的使用指南。
在Javassist中,進行類表述的基本單元是CtClass(即“編譯時的類”,compile time class)。組成程式的這些類會儲存在一個ClassPool中,它本質上就是CtClass例項的一個容器。
ClassPool的實現使用了一個HashMap,其中key是類的名稱,而value是對應的CtClass物件。
正常的Java類都會包含域、構造器以及方法。在CtClass中,分別與之對應的是CtField、CtConstructor和CtMethod。要定位某個CtClass,我們可以根據名稱從ClassPool中獲取,然後通過CtClass得到任意的方法,並做出我們的修改。
圖3
CtMethod中包含了相關方法的程式碼行。我們可以藉助insertBefore命令在方法開始的地方插入程式碼。Javassist非常棒的一點在於我們所編寫的是純Java,只不過需要提醒一下:Java程式碼必須要以引用字串的形式來實現。但是,大多數的人都會同意這種方式要比處理位元組碼好得多!(儘管如此,如果你碰巧喜歡直接處理位元組碼的話,那麼可以關注本文ASM相關的內容。)JVM包含了一個位元組碼驗證器(verifier),以防止出現不合法的位元組碼。如果在你的Javassist程式碼中,所使用的Java是非法的,那麼在執行時位元組碼驗證器會拒絕它。
與insertBefore類似,還有一個名為insertAfter的方法,藉助它我們可以在相關方法的結尾處插入程式碼。我們還可以使用insertAt方法,從而在相關方法的中間插入程式碼,或者使用addCatch新增catch語句。
現在,讓我們開啟IDE,然後編碼實現這個日誌特性,首先從Agent(包含premain)和ClassTransformer開始。
package com.example.spring2gx.agent;
public class Agent {
public static void premain(String args, Instrumentation inst) {
System.out.println("Starting the agent");
inst.addTransformer(new ImportantLogClassTransformer());
}
}
package com.example.spring2gx.agent;
import java.lang.instrument.ClassFileTransformer;
import java.lang.instrument.IllegalClassFormatException;
import java.security.ProtectionDomain;
public class ImportantLogClassTransformer
implements ClassFileTransformer {
public byte[] transform(ClassLoader loader, String className,
Class classBeingRedefined, ProtectionDomain protectionDomain,
byte[] classfileBuffer) throws IllegalClassFormatException {
// 在這裡操縱位元組
return modified_bytes;
}
為了新增審計日誌,首先要實現transform,將類的位元組轉換為CtClass物件。然後,我們可以迭代它的方法,並捕獲其中帶有@ImportantLogin註解的方法,獲取要進行日誌記錄的輸入引數索引,並將相關的程式碼插入到方法開頭的位置上。
public byte[] transform(ClassLoader loader, String className,
Class classBeingRedefined,
ProtectionDomain protectionDomain,
byte[] cfbuffer) throws IllegalClassFormatException {
// 將位元組陣列轉換為CtClass物件
pool.insertClassPath(new ByteArrayClassPath(className,classfileBuffer));
// 將路徑斜線轉換為點號
CtClass cclass = pool.get(className.replaceAll("/", "."));
if (!cclass.isFrozen()) {
// 檢查CtClass的每個方法是否有@ImportantLog註解
for (CtMethod currentMethod : cclass.getDeclaredMethods()) {
// 查詢@ImportantLog註解
Annotation annotation = getAnnotation(currentMethod);
if (annotation != null) {
// 如果方法上有@ImportantLog註解的話,那麼
// 獲得重要方法的引數索引
List parameterIndexes = getParamIndexes(annotation);
// 在方法的開頭位置新增日誌語句
currentMethod.insertBefore(
createJavaString(currentMethod, className, parameterIndexes));
}
}
return cclass.toBytecode();
}
return null;
}
Javassist註解可以宣告為“不可見的(invisible)”和“可見的(visible)”。不可見的註解只會在類載入和編譯期可見,它們在宣告時需要將RententionPolicy.CLASS引數傳遞到註解中。可見註解(RententionPolicy.RUNTIME)在執行期會載入,並且是可見的。對於本例來說,我們只在編譯期需要這些屬性,因此將其設為不可見的。
getAnnotation方法會掃描@ImportantLog註解,如果找不到註解的話,將會返回null。
private Annotation getAnnotation(CtMethod method) {
MethodInfo methodInfo = method.getMethodInfo();
AnnotationsAttribute attInfo = (AnnotationsAttribute) methodInfo
.getAttribute(AnnotationsAttribute.invisibleTag);
if (attInfo != null) {
return attInfo.getAnnotation("com.example.spring.mains.ImportantLog");
}
return null;
}
在得到註解之後,我們就可以檢索引數索引了。通過使用Javassist的ArrayMemberValue,會以字串陣列的形式返回成員field的值,然後我們就可以對其進行遍歷,從而獲取在註解中所嵌入的field索引。
private List getParamIndexes(Annotation annotation) {
ArrayMemberValue fields =
(ArrayMemberValue) annotation.getMemberValue(“fields”);
if (fields != null) {
MemberValue[] values = (MemberValue[]) fields.getValue();
List parameterIndexes = new ArrayList();
for (MemberValue val : values) {
parameterIndexes.add(((StringMemberValue) val).getValue());
}
return parameterIndexes;
}
return Collections.emptyList();
}
最後,我們可以藉助createJavaString在某個位置插入日誌語句。
1 private String createJavaString(CtMethod currentMethod,
2 String className, List indexParameters) {
3 StringBuilder sb = new StringBuilder();
4 sb.append("{StringBuilder sb = new StringBuilder");
5 sb.append("(\"A call was made to method '\");");
6 sb.append("sb.append(\"");
7 sb.append(currentMethod.getName());
8 sb.append("\");sb.append(\"' on class '\");");
9 sb.append("sb.append(\"");
10 sb.append(className);
11 sb.append("\");sb.append(\"'.\");");
12 sb.append("sb.append(\"\\n Important params:\");");
13
14 for (String index : indexParameters) {
15 try {
16 int localVar = Integer.parseInt(index) + 1;
17 sb.append("sb.append(\"\\n Index: \");");
18 sb.append("sb.append(\"");
19 sb.append(index);
20 sb.append("\");sb.append(\" value: \");");
21 sb.append("sb.append($" + localVar + ");");
22 } catch (NumberFormatException e) {
23 e.printStackTrace();
24 }
25 }
26 sb.append("System.out.println(sb.toString());}");
27 return sb.toString();
28 }
29 }
在我們的實現中,建立了一個StringBuilder,它首先拼接了一些報頭資訊,緊接著是方法名和類名。需要注意的一件事情是,如果我們插入多行Java語句的話,需要將其用大括號括起來(參見第4行和第26行)。
(如果只有一條語句的話,那沒有必要使用括號。)
前文基本上已經全部涵蓋了使用Javassist新增審計日誌的程式碼。回顧一下,它的優勢在於:
- 因為它使用我們所熟悉的Java語法,所以不需要學習位元組碼;
- 沒有太多的編碼工作要做;
- Javassist已經有了很棒的文件。
它的不足之處在於:
- 不使用位元組碼會限制它的功能;
- Javassist會比其他的位元組碼操縱框架更慢一些。
ASM
ASM最初是一個博士研究專案,在2002年開源。它的更新非常活躍,從5.x版本開始支援Java 8。ASM包含了一個基於事件的庫和一個基於物件的庫,分別類似於SAX和DOM XML解析器。
一個Java類是由很多元件組成的,包括超類、介面、屬性、域和方法。在使用ASM時,我們可以將其均視為事件。我們會提供一個ClassVisitor實現,通過它來解析類,當解析器遇到每個元件時,ClassVisitor上對應的“visitor”事件處理器方法會被呼叫(始終按照上述的順序)。
package com.sun.xml.internal.ws.org.objectweb.asm;
public interface ClassVisitor {
void visit(int version, int access, String name, String signature,
String superName, String[] interfaces);
void visitSource(String source, String debug);
void visitOuterClass(String owner, String name, String desc);
AnnotationVisitor visitAnnotation(String desc, boolean visible);
void visitAttribute(Attribute attr);
void visitInnerClass(String name, String outerName,
String innerName, int access);
FieldVisitor visitField(int access, String name, String desc,
String signature, Object value);
MethodVisitor visitMethod(int access, String name, String desc,
String signature, String[] exceptions);
void visitEnd();
}
接下來,我們將Sue的BankTransaction(在本文的開頭中進行了定義)傳遞到一個ClassReader中進行解析,以便對這種處理方式有個直觀的感覺。
同樣,我們需要從Agent premain開始:
import java.lang.instrument.Instrumentation;
public class Agent {
public static void premain(String args, Instrumentation inst) {
System.out.println("Starting the agent");
inst.addTransformer(new ImportantLogClassTransformer());
}
}
然後,將輸出的位元組傳遞給不進行任何操作(no-op)的ClassWriter,將解析得到的位元組全部寫回到位元組陣列中,得到一個重新生成的BankTransaction,它的行為與原始類的行為是完全一致的。
圖4
import jdk.internal.org.objectweb.asm.ClassReader;
import jdk.internal.org.objectweb.asm.ClassWriter;
import jdk.internal.org.objectweb.asm.ClassVisitor;
import java.lang.instrument.ClassFileTransformer;
import java.lang.instrument.IllegalClassFormatException;
import java.security.ProtectionDomain;
public class ImportantLogClassTransformer implements ClassFileTransformer {
public byte[] transform(ClassLoader loader, String className,
Class classBeingRedefined, ProtectionDomain protectionDomain,
byte[] classfileBuffer) throws IllegalClassFormatException {
ClassReader cr = new ClassReader(classfileBuffer);
ClassWriter cw = new ClassWriter(cr, ClassWriter.COMPUTE_FRAMES);
cr.accept(cw, 0);
return cw.toByteArray();
}
}
現在,我們修改一下ClassWriter,讓它做一些更有用的事情,這需要新增一個ClassVisitor(名為LogMethodClassVisitor)來呼叫我們的事件處理方法,如visitField或visitMethod,在解析時遇到相關元件時就會呼叫這些方法。
圖5
public byte[] transform(ClassLoader loader, String className,
Class classBeingRedefined, ProtectionDomain protectionDomain,
byte[] classfileBuffer) throws IllegalClassFormatException {
ClassReader cr = new ClassReader(classfileBuffer);
ClassWriter cw = new ClassWriter(cr, ClassWriter.COMPUTE_FRAMES);
ClassVisitor cv = new LogMethodClassVisitor(cw, className);
cr.accept(cv, 0);
return cw.toByteArray();
}
對於日誌記錄的需求,我們想要檢查每個方法是否具有標示註解並新增特定的日誌。我們只需要重寫ClassVisitor的visitMethod方法,讓它返回一個MethodVisitor,從而提供我們自己的實現。就像類是由多個元件組成的一樣,方法也是由多個元件組成的,對應著方法屬性、註解以及編譯後的程式碼。ASM的MethodVisitor提供了一種鉤子(hook)機制,以便訪問方法中的每個操作碼(opcode),這樣我們就能以很細的粒度來進行修改。
public class LogMethodClassVisitor extends ClassVisitor {
private String className;
public LogMethodIfAnnotationVisitor(ClassVisitor cv, String className) {
super(Opcodes.ASM5, cv);
this.className = className;
}
@Override
public MethodVisitor visitMethod(int access, String name, String desc,
String signature, String[] exceptions) {
// 將我們的邏輯放在這裡
}
}
同樣的,事件處理器會始終按照預先定義的相同順序來呼叫,所以在實際 訪問(visit) 程式碼的時候,我們就已經得知了方法的所有屬性和註解。(順便說一下,我們還可以將多個MethodVisitor連結在一起,就像我們可以連結多個ClassVisitor例項一樣。)所以,在visitMethod方法中,我們將會通過PrintMessageMethodVisitor新增鉤子,過載visitAnnotation方法來獲取註解並插入任意所需的日誌程式碼。
我們的PrintMessageMethodVisitor過載了兩個方法。首先是visitAnnotation,所以可以檢查方法的@ImportantLog註解。如果存在這個註解的話,我們需要提取field屬性上的索引。當visitCode執行的時候,是否存在註解已經確定了,所以我們就可以新增特定的日誌了。visitAnnotation的程式碼以鉤子的形式新增到了AnnotationVisitor中,它能夠暴露@ImportantLog 註解上的field引數。
public AnnotationVisitor visitAnnotation(String desc, boolean visible) {
if ("Lcom/example/spring2gx/mains/ImportantLog;".equals(desc)) {
isAnnotationPresent = true;
return new AnnotationVisitor(Opcodes.ASM5,
super.visitAnnotation(desc, visible)) {
public AnnotationVisitor visitArray(String name) {
if (“fields”.equals(name)){
return new AnnotationVisitor(Opcodes.ASM5,
super.visitArray(name)) {
public void visit(String name, Object value) {
parameterIndexes.add((String) value);
super.visit(name, value);
}
};
}else{
return super.visitArray(name);
}
}
};
}
return super.visitAnnotation(desc, visible);
}
現在,我們來看一下visitCode方法。首先,它必須要檢查AnnotationVisitor是否有註解存在的標記。如果有的話,那麼新增我們自己的位元組碼。
public void visitCode() {
if (isAnnotationPresent) {
// 建立string builder
mv.visitFieldInsn(Opcodes.GETSTATIC, "java/lang/System", "out",
"Ljava/io/PrintStream;");
mv.visitTypeInsn(Opcodes.NEW, "java/lang/StringBuilder");