深入字节码操作:使用ASM和Javassist创建审核日志
深入字節碼操作:使用ASM和Javassist創建審核日志
原文鏈接:https://blog.newrelic.com/2014/09/29/diving-bytecode-manipulation-creating-audit-log-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");} }- 1
- 2
- 3
- 4
- 5
- 6
- 7
- 8
- 9
- 10
- 11
- 12
- 1
- 2
- 3
- 4
- 5
- 6
- 7
- 8
- 9
- 10
- 11
- 12
我們要記錄重要的操作以及關鍵信息以確定操作。 以上,我將確定登錄退出的重要動作。 對于登錄,重要信息將是帳戶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 }- 1
- 2
- 3
- 4
- 5
- 6
- 7
- 8
- 1
- 2
- 3
- 4
- 5
- 6
- 7
- 8
@ImportantLog注釋表示我們要在每次調用該方法時記錄一條消息,而@ImportantLog注釋中的fields參數表示應記錄的每個參數的索引位置。 例如,對于登錄,我們要記錄第1位和第2位的輸入參數。它們是accountId和userName。 我們不會記錄第0位的密碼參數。
使用字節碼和注釋來執行日志記錄有兩個主要優點:
在哪里實際修改字節碼?
我們可以使用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());} }- 1
- 2
- 3
- 4
- 5
- 6
- 1
- 2
- 3
- 4
- 5
- 6
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 herereturn modified bytes;} }- 1
- 2
- 3
- 4
- 5
- 6
- 7
- 8
- 1
- 2
- 3
- 4
- 5
- 6
- 7
- 8
現在我們知道在哪里修改一個類的字節,接著需要知道如何修改字節。
如何使用Javassist修改字節碼?
Javassist是一個具有高級和低級API的字節碼操作框架。我將重點關注高級的面向對象的API,首先從Javassist中的對象的解釋開始。接下來,我將實現審核日志應用程序的實際代碼。
Javassist使用CtClass對象來表示一個類。 這些CtClass對象可以從ClassPool獲得,用于修改Classes。ClassPool是一個基于HashMap實現的CtClass對象容器,其中鍵是類名稱,值是表示該類的CtClass對象。默認的ClassPool使用與底層JVM相同的類路徑。因此,在某些情況下,可能需要向ClassPool添加類路徑或類字節。
類似于包含字段,方法和構造函數的Java類,CtClass對象包含CtFields,CtConstructors和CtMethods。所有這些對象都可以修改。我將重點關注方法操作,因為審核日志應用程序需要這種行為。
以下是修改方法的幾種方法:
上圖顯示了Javassist的主要優點之一。實際上不必寫字節碼。而是編寫Java代碼。一個復雜的情況是Java代碼必須在引號內。
現在我們了解了Javassist的基本構建塊,現在來看看應用程序的實際代碼。 類變換器的變換方法需要執行以下步驟:
- 獲取方法重要參數索引
- 函數開始增加日志語句
使用Javassist編寫Java代碼時,請注意以下問題:
- JVM在包之間使用斜杠,而Javassist使用點。
- 當插入多行Java代碼時,代碼需要在括號內。
- 當使用1,2等引用方法參數值時,知道0被保留給“this”。這意味著您方法的第一個參數的值為1。
- 注釋擁有可見和不可見的簽。 不可見的注釋在運行時無法獲取。
實際的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 runtimeAnnotationsAttribute attInfo = (AnnotationsAttribute) mInfo.getAttribute(AnnotationsAttribute.invisibleTag);if (attInfo != null) {// this is the type name meaning use dots instead of slashesreturn 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 anythingint 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();} }- 1
- 2
- 3
- 4
- 5
- 6
- 7
- 8
- 9
- 10
- 11
- 12
- 13
- 14
- 15
- 16
- 17
- 18
- 19
- 20
- 21
- 22
- 23
- 24
- 25
- 26
- 27
- 28
- 29
- 30
- 31
- 32
- 33
- 34
- 35
- 36
- 37
- 38
- 39
- 40
- 41
- 42
- 43
- 44
- 45
- 46
- 47
- 48
- 49
- 50
- 51
- 52
- 53
- 54
- 55
- 56
- 57
- 58
- 59
- 60
- 61
- 62
- 63
- 64
- 65
- 66
- 67
- 68
- 69
- 70
- 71
- 72
- 73
- 74
- 75
- 76
- 77
- 78
- 79
- 80
- 81
- 82
- 83
- 84
- 85
- 86
- 87
- 88
- 89
- 90
- 91
- 92
- 1
- 2
- 3
- 4
- 5
- 6
- 7
- 8
- 9
- 10
- 11
- 12
- 13
- 14
- 15
- 16
- 17
- 18
- 19
- 20
- 21
- 22
- 23
- 24
- 25
- 26
- 27
- 28
- 29
- 30
- 31
- 32
- 33
- 34
- 35
- 36
- 37
- 38
- 39
- 40
- 41
- 42
- 43
- 44
- 45
- 46
- 47
- 48
- 49
- 50
- 51
- 52
- 53
- 54
- 55
- 56
- 57
- 58
- 59
- 60
- 61
- 62
- 63
- 64
- 65
- 66
- 67
- 68
- 69
- 70
- 71
- 72
- 73
- 74
- 75
- 76
- 77
- 78
- 79
- 80
- 81
- 82
- 83
- 84
- 85
- 86
- 87
- 88
- 89
- 90
- 91
- 92
完成了!我們可以運行應用程序,并將日志記錄輸出到“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(); }
- 1
- 2
- 3
- 4
- 5
- 6
- 7
- 8
- 9
- 1
- 2
- 3
- 4
- 5
- 6
- 7
- 8
- 9
ClassReader得到類的字節,ClassWriter從類讀取器獲取。ClassReader的accept調用解析該類。接下來,我們從ClassWriter訪問生成的字節。
現在我們想修改BankTransaction字節。首先,我們需要鏈接在ClassVisitor中。 此ClassVisitor將覆蓋諸如visitField或visitMethod之類的方法來接收關于該特定類組件的通知。
以下是上圖的代碼實現。 類訪問者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(); }- 1
- 2
- 3
- 4
- 5
- 6
- 7
- 8
- 9
- 10
- 1
- 2
- 3
- 4
- 5
- 6
- 7
- 8
- 9
- 10
對于審核日志應用,我們需要檢查類中的每個方法。這意味著ClassVisitor只需要覆蓋’visitMethod’。
public class LogMethodClassVisitor extends ClassVisitor {private String className;public LogMethodClassVisitor(ClassVisitor cv, String pClassName) {super(Opcodes.ASM5, cv);className = pClassName;}@Overridepublic MethodVisitor visitMethod(int access, String name, String desc,String signature, String[] exceptions) {//put logic in here} }- 1
- 2
- 3
- 4
- 5
- 6
- 7
- 8
- 9
- 10
- 11
- 12
- 13
- 14
- 1
- 2
- 3
- 4
- 5
- 6
- 7
- 8
- 9
- 10
- 11
- 12
- 13
- 14
請注意,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); }- 1
- 2
- 3
- 4
- 5
- 6
- 7
- 1
- 2
- 3
- 4
- 5
- 6
- 7
這個PrintMessageMethodVisitor將需要覆蓋visitAnnotation和visitCode。請注意,visitAnnotation返回一個AnnotationVisitor。就像類和方法具有組件一樣,還有一個注釋的多個組件。AnnotationVisitor允許我們訪問注釋的所有部分。
下面我簡要介紹了visitAnnotation和visitCode的步驟。
public class PrintMessageMethodVisitor extends MethodVisitor {@Overridepublic AnnotationVisitor visitAnnotation(String desc, boolean visible) {// 1. check method for annotation @ImportantLog// 2. if annotation present, then get important method param indexes}@Overridepublic void visitCode() {// 3. if annotation present, add logging to beginning of the method} }- 1
- 2
- 3
- 4
- 5
- 6
- 7
- 8
- 9
- 10
- 11
- 12
- 1
- 2
- 3
- 4
- 5
- 6
- 7
- 8
- 9
- 10
- 11
- 12
當使用ASM編寫Java代碼時,請注意以下問題:
- 在事件模型中,類或方法的事件將始終以特定順序發生。 例如,帶注解的方法將始終在實際代碼之前訪問。
- 當使用1,2等引用方法參數值時,知道0被保留用于“this”。這意味著您方法的第一個參數的值為1。
實際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 buildermv.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 buildermv.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); . . .- 1
- 2
- 3
- 4
- 5
- 6
- 7
- 8
- 9
- 10
- 11
- 12
- 13
- 14
- 15
- 16
- 17
- 18
- 19
- 20
- 21
- 22
- 23
- 24
- 25
- 26
- 27
- 28
- 29
- 30
- 31
- 32
- 33
- 34
- 35
- 36
- 37
- 38
- 39
- 1
- 2
- 3
- 4
- 5
- 6
- 7
- 8
- 9
- 10
- 11
- 12
- 13
- 14
- 15
- 16
- 17
- 18
- 19
- 20
- 21
- 22
- 23
- 24
- 25
- 26
- 27
- 28
- 29
- 30
- 31
- 32
- 33
- 34
- 35
- 36
- 37
- 38
- 39
以上可以看出Javassist和ASM之間的主要區別之一。使用ASM,必須在修改方法時在字節碼級別編寫代碼,這意味著需要很好地了解JVM的工作原理。需要在給定的時刻確切知道堆棧和局部變量的內容。 在字節碼級別的編寫方面,在功能和優化方面提高了門檻,這意味著開發人員需要較長的時間熟悉ASM開發。
家庭作業
現在你已經看到如何使用ASM和Javassist的一個場景,我鼓勵你嘗試一個字節碼操作框架。字節碼操作不僅可以讓您更好地了解JVM,而且還有無數的應用程序。一旦開始,你會發現天空的極限。
from:?http://blog.csdn.net/lihenair/article/details/69948918
《新程序員》:云原生和全面數字化實踐50位技術專家共同創作,文字、視頻、音頻交互閱讀總結
以上是生活随笔為你收集整理的深入字节码操作:使用ASM和Javassist创建审核日志的全部內容,希望文章能夠幫你解決所遇到的問題。
- 上一篇: LinkedList和ArrayList
- 下一篇: Serializable java序列化