1. 程式人生 > >Java位元組碼操縱技術

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的程式的正常執行流程是這樣的:

  1. 在某個主類上執行Java,這個類會由一個類載入器進行載入;
  2. 呼叫該類的main方法,它會呼叫預先定義好的處理過程;
  3. 列印“交易完成”。

在引入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");