1. 程式人生 > >[譯]深入位元組碼操作:使用ASM和Javassist建立稽核日誌

[譯]深入位元組碼操作:使用ASM和Javassist建立稽核日誌

深入位元組碼操作:使用ASM和Javassist建立稽核日誌

在堆疊中使用Spring和Hibernate,您的應用程式的位元組碼可能會在執行時被增強或處理。 位元組碼是Java虛擬機器(JVM)的指令集,所有在JVM上執行的語言都必須最終編譯為位元組碼。 操作位元組碼原因如下:

  • 程式分析:
    • 查詢應用bug
    • 檢查程式碼複雜性
    • 查詢特定註解的類
  • 類生成:
    • 使用代理從資料庫中懶惰載入資料
  • 安全性
    • 特定API限制訪問許可權
    • 程式碼混淆
  • 無Java原始碼類轉換
    • 程式碼分析
    • 程式碼優化
  • 最後,新增日誌

有幾種可用於操作位元組碼的工具,從非常低階的工具(如需要位元組碼級別工作的ASM)到諸如AspectJ等高階框架(允許編寫純Java)。

本博文,我將演示分別使用Javassist和ASM實現一種審計日誌的方法。

審計日誌例子

假定我沒有如下程式碼:

public class BankTransactions {
    public static void main(String[] args) {
    BankTransactions bank = new BankTransactions();
    for (int i = 0; i < 100; i++) {
        String accountId = "account" + i;
        bank.login("password"
, accountId, "Ashley"); bank.unimportantProcessing(accountId); bank.withdraw(accountId, Double.valueOf(i)); } System.out.println("Transactions completed"); } }

我們要記錄重要的操作以及關鍵資訊以確定操作。 以上,我將確定登入退出的重要動作。 對於登入,重要資訊將是帳戶ID和使用者。 對於退出,重要資訊將是帳戶ID和撤回的金額。 記錄重要操作的一種方法是將日誌記錄語句新增到每個重要的方法,但這將是乏味的。 相反,我們可以為重要的方法添加註釋,然後使用工具來注入日誌記錄。 在這種情況下,該工具將是一個位元組碼操作框架。

@ImportantLog(fields = { "1", "2" })
public void login(String password, String accountId, String userName) {
    // login logic
}
@ImportantLog(fields = { "0", "1" })
public void withdraw(String accountId, Double moneyToRemove) {
    // transaction logic
}

@ImportantLog註釋表示我們要在每次呼叫該方法時記錄一條訊息,而@ImportantLog註釋中的fields引數表示應記錄的每個引數的索引位置。 例如,對於登入,我們要記錄第1位和第2位的輸入引數。它們是accountIduserName。 我們不會記錄第0位的密碼引數。

使用位元組碼和註釋來執行日誌記錄有兩個主要優點:

  1. 日誌記錄與業務邏輯分離,這有助於保持程式碼清潔和簡單。
  2. 在不修改原始碼的情況下,輕鬆刪除稽核日誌記錄。

在哪裡實際修改位元組碼?

我們可以使用1.5中引入的核心Java功能來操縱位元組碼。 此功能稱為Java代理
要了解Java代理,讓我們來看一下典型的Java處理流程。

使用包含我們的main方法的類作為輸入引數執行命令java。 這將啟動Java執行時環境,使用classloader來載入輸入類,並呼叫該類的main方法。 在我們具體的例子中,呼叫了BankTransactions的main方法,這將導致一些處理髮生,並列印“完成交易”。

現在來看一下使用Java代理的Java程序。

命令java執行兩個輸入引數。第一個是JVM引數-javaagent,指向代理jar。第二個是包含我們主要方法的類。javaagent標誌告訴JVM首先載入代理。 代理的主類必須在代理jar的清單中指定。 一旦類被載入,類的premain方法被呼叫。 這個premain方法充當代理的安裝鉤子。 它允許代理註冊一個類變換器。 當類變換器在JVM中註冊時,該變換器將在類載入到JVM前接收每個類的位元組。 這為類變換器提供了根據需要修改類的位元組的機會。 一旦類變換器修改了位元組,它將修改的位元組返回給JVM。 這些位元組接著由JVM驗證和載入。

在我們具體的例子中,當BankTransaction載入時,位元組將首先進入類變換器進行潛在的修改。修改後的位元組將被返回並載入到JVM中。 載入完之後,呼叫類中的main方法,進行一些處理,並列印“事務完成”。

讓我們來看看程式碼。 下面我有代理的premain方法:

public class JavassistAgent {
    public static void premain(String agentArgs, Instrumentation inst) {
    System.out.println("Starting the agent");
    inst.addTransformer(new ImportantLogClassTransformer());
    }
}

premain方法打印出一個訊息,然後註冊一個類變換器。 類變換器必須實現方法轉換,載入到JVM中的每個類都會呼叫它。它以該類的位元組陣列作為方法的輸入,然後返回修改後的位元組陣列。如果類變換器決定不修改特定類的位元組,則可以返回null。

public class ImportantLogClassTransformer implements ClassFileTransformer {
    public byte[] transform(ClassLoader loader, String className,
    Class classBeingRedefined, ProtectionDomain protectionDomain,
    byte[] classfileBuffer) throws IllegalClassFormatException {
    // manipulate the bytes here
        return modified bytes;
    }
}

現在我們知道在哪裡修改一個類的位元組,接著需要知道如何修改位元組。

如何使用Javassist修改位元組碼?

Javassist是一個具有高階和低階API的位元組碼操作框架。我將重點關注高階的面向物件的API,首先從Javassist中的物件的解釋開始。接下來,我將實現稽核日誌應用程式的實際程式碼。

Javassist使用CtClass物件來表示一個類。 這些CtClass物件可以從ClassPool獲得,用於修改Classes。ClassPool是一個基於HashMap實現的CtClass物件容器,其中鍵是類名稱,值是表示該類的CtClass物件。預設的ClassPool使用與底層JVM相同的類路徑。因此,在某些情況下,可能需要向ClassPool新增類路徑或類位元組。

類似於包含欄位,方法和建構函式的Java類,CtClass物件包含CtFieldsCtConstructorsCtMethods。所有這些物件都可以修改。我將重點關注方法操作,因為稽核日誌應用程式需要這種行為。

以下是修改方法的幾種方法:

上圖顯示了Javassist的主要優點之一。實際上不必寫位元組碼。而是編寫Java程式碼。一個複雜的情況是Java程式碼必須在引號內。

現在我們瞭解了Javassist的基本構建塊,現在來看看應用程式的實際程式碼。 類變換器的變換方法需要執行以下步驟:

  1. 將位元組陣列轉換為CtClass物件
  2. 檢查CtClass中每個帶註解@ImportantLog的方法
  3. 如果方法中存在@ImportantLog註釋,那麼:
    • 獲取方法重要引數索引
    • 函式開始增加日誌語句

使用Javassist編寫Java程式碼時,請注意以下問題:

  • JVM在包之間使用斜槓,而Javassist使用點。
  • 當插入多行Java程式碼時,程式碼需要在括號內。
  • 當使用12等引用方法引數值時,知道0this1。
  • 註釋擁有可見和不可見的籤。 不可見的註釋在執行時無法獲取。

實際的Java程式碼如下:

public class ImportantLogClassTransformer implements ClassFileTransformer {
  private static final String METHOD_ANNOTATION = 
      "com.example.spring2gx.mains.ImportantLog";
  private static final String ANNOTATION_ARRAY = "fields";
  private ClassPool pool;

  public ImportantLogClassTransformer() {
    pool = ClassPool.getDefault();
  }

  public byte[] transform(ClassLoader loader, String className,
    Class classBeingRedefined, ProtectionDomain protectionDomain,
    byte[] classfileBuffer) throws IllegalClassFormatException {
    try {
      pool.insertClassPath(new ByteArrayClassPath(className,
        classfileBuffer));
      CtClass cclass = pool.get(className.replaceAll("/", "."));
    if (!cclass.isFrozen()) {
      for (CtMethod currentMethod : cclass.getDeclaredMethods()) {
        Annotation annotation = getAnnotation(currentMethod);
        if (annotation != null) {
          List parameterIndexes = getParamIndexes(annotation);
        currentMethod.insertBefore(createJavaString(
        currentMethod, className, parameterIndexes));
        }
      }
      return cclass.toBytecode();
    }
      } catch (Exception e) {
    e.printStackTrace();
      }
      return null;
    }

  private Annotation getAnnotation(CtMethod method) {
    MethodInfo mInfo = method.getMethodInfo();
    // the attribute we are looking for is a runtime invisible attribute
    // use Retention(RetentionPolicy.RUNTIME) on the annotation to make it
    // visible at runtime
    AnnotationsAttribute attInfo = (AnnotationsAttribute) mInfo
      .getAttribute(AnnotationsAttribute.invisibleTag);
    if (attInfo != null) {
      // this is the type name meaning use dots instead of slashes
      return attInfo.getAnnotation(METHOD_ANNOTATION);
    }
    return null;
  }

  private List getParamIndexes(Annotation annotation) {
    ArrayMemberValue fields = (ArrayMemberValue) annotation
      .getMemberValue(ANNOTATION_ARRAY);
    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();
  }

  private String createJavaString(CtMethod currentMethod, String className,
    List indexParameters) {
    StringBuilder sb = new StringBuilder();
    sb.append("{StringBuilder sb = new StringBuilder");
    sb.append("(\"A call was made to method '\");");
    sb.append("sb.append(\"");
    sb.append(currentMethod.getName());
    sb.append("\");sb.append(\"' on class '\");");
    sb.append("sb.append(\"");
    sb.append(className);
    sb.append("\");sb.append(\"'.\");");
    sb.append("sb.append(\"\\n    Important params:\");");
    for (String index : indexParameters) {
      try {
    // add one because 0 is "this" for instance variable
    // if were a static method 0 would not be anything
    int localVar = Integer.parseInt(index) + 1;
    sb.append("sb.append(\"\\n        Index \");");
    sb.append("sb.append(\"");
    sb.append(index);
    sb.append("\");sb.append(\" value: \");");
    sb.append("sb.append($" + localVar + ");");
      } catch (NumberFormatException e) {
    e.printStackTrace();
      }
    }
    sb.append("System.out.println(sb.toString());}");
    return sb.toString();
  }
}

完成了!我們可以執行應用程式,並將日誌記錄輸出到“System.out”。

積極的一面是寫入的程式碼量非常小,而且實際上不需要使用Javassist編寫位元組碼。 最大的缺點是用引號編寫Java程式碼可能會變得乏味。幸運的是,其他一些位元組碼操作框架更快。我們來看看其中一個更快的框架。

如何使用ASM修改位元組?

ASM是一個位元組碼操作框架,使用較小的記憶體佔用並且速度相對較快。我認為ASM是位元組碼操作的行業標準,即使是Javassist也在使用ASM。ASM提供基於物件和事件的庫,但在這裡我將重點介紹基於事件的模型。

要理解ASM,我將從ASM自己的文件的一個Java類圖(下圖)開始。它表明Java類由幾個部分組成,包括一個超類,介面,註釋,欄位和方法。在ASM基於事件的模型中,所有這些類元件都可以被認為是事件。

可以在ClassVisitor上找到ASM的類事件。為了“看到”這些事件,必須建立一個classVisitor來覆蓋您想要檢視的所需元件。

除了類訪問者,我們需要一些東西來解析類並生成事件。為此,ASM提供了一個名為ClassReader的物件。reader解析課程併產生事件。類被解析後,需要ClassWriter來消耗事件,將它們轉換成一個類位元組陣列。在下圖中,我們BankTransactions類的位元組傳遞給ClassReader,該位元組將位元組傳送到ClassWriter,該ClassWriter會輸出生成的BankTransaction。當沒有ClassVisitor存在時,輸入BankTransactions位元組應基本上匹配其輸出位元組。

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

ClassReader得到類的位元組,ClassWriter從類讀取器獲取。ClassReader的accept呼叫解析該類。接下來,我們從ClassWriter訪問生成的位元組。

現在我們想修改BankTransaction位元組。首先,我們需要連結在ClassVisitor中。 此ClassVisitor將覆蓋諸如visitFieldvisitMethod之類的方法來接收關於該特定類元件的通知。

以下是上圖的程式碼實現。 類訪問者LogMethodClassVisitor已新增。請注意,可以新增多個類訪問者。

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’。

public class LogMethodClassVisitor extends ClassVisitor {
    private String className;

    public LogMethodClassVisitor(ClassVisitor cv, String pClassName) {
    super(Opcodes.ASM5, cv);
    className = pClassName;
    }

    @Override
    public MethodVisitor visitMethod(int access, String name, String desc,
        String signature, String[] exceptions) {
    //put logic in here
    }
}

請注意,visitMethod返回一個MethodVisitor。 就像一個類有多個元件,一個方法也有很多的元件,當解析該方法時,它可以被認為是事件。

MethodVisitor在方法上提供事件。對於稽核日誌應用,我們要檢查帶註釋的方法上。基於註釋,我們可能需要修改方法中的實際程式碼。為了進行這些修改,我們需要在一個methodVisitor連結,如下所示。

@Override
public MethodVisitor visitMethod(int access, String name, String desc, 
    String signature, String[] exceptions) {                               
    MethodVisitor mv = super.visitMethod(access, name, desc, signature,
            exceptions);
    return new PrintMessageMethodVisitor(mv, name, className);
}

這個PrintMessageMethodVisitor將需要覆蓋visitAnnotationvisitCode。請注意,visitAnnotation返回一個AnnotationVisitor。就像類和方法具有元件一樣,還有一個註釋的多個元件。AnnotationVisitor允許我們訪問註釋的所有部分。

下面我簡要介紹了visitAnnotation和visitCode的步驟。

public class PrintMessageMethodVisitor extends MethodVisitor {
  @Override
  public AnnotationVisitor visitAnnotation(String desc, boolean visible) {
    // 1. check method for annotation @ImportantLog
    // 2. if annotation present, then get important method param indexes
  }

  @Override
  public void visitCode() {
    // 3. if annotation present, add logging to beginning of the method
  } 
}

當使用ASM編寫Java程式碼時,請注意以下問題:

  • 在事件模型中,類或方法的事件將始終以特定順序發生。 例如,帶註解的方法將始終在實際程式碼之前訪問。
  • 當使用12等引用方法引數值時,知道0this1。

實際Java程式碼如下:

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, Object value) {
        if ("fields".equals(name)) {
          return new AnnotationVisitor(Opscodes.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);
}                                                                                                                       
public void visitCode() {
  if (isAnnotationPresent) {
    // create string builder
    mv.visitFieldInsn(Opcodes.GETSTATIC, "java/lang/System", 
        "out","Ljava/io/PrintStream;");
    mv.visitTypeInsn(Opcodes.NEW, "java/lang/StringBuilder");
    mv.visitInsn(Opcodes.DUP);
    // add everything to the string builder
    mv.visitLdcInsn("A call was made to method \"");
    mv.visitMethodInsn(Opcodes.INVOKESPECIAL,
        "java/lang/StringBuilder", "",
        "(Ljava/lang/String;)V", false);
    mv.visitLdcInsn(methodName);
    mv.visitMethodInsn(Opcodes.INVOKEVIRTUAL,
        "java/lang/StringBuilder", "append",
        "(Ljava/lang/String;)Ljava/lang/StringBuilder;", false);
. . .

以上可以看出Javassist和ASM之間的主要區別之一。使用ASM,必須在修改方法時在位元組碼級別編寫程式碼,這意味著需要很好地瞭解JVM的工作原理。需要在給定的時刻確切知道堆疊和區域性變數的內容。 在位元組碼級別的編寫方面,在功能和優化方面提高了門檻,這意味著開發人員需要較長的時間熟悉ASM開發。

家庭作業

現在你已經看到如何使用ASM和Javassist的一個場景,我鼓勵你嘗試一個位元組碼操作框架。位元組碼操作不僅可以讓您更好地瞭解JVM,而且還有無數的應用程式。一旦開始,你會發現天空的極限。