動態非侵入攔截
什么叫無侵入攔截?
在JAVA中要攔截一個方法調用,有多種方式,最容易也是最流行的就是動態代理。
動態代理方式實現起來簡單,你只要提供一個接口和攔截處理的handler并在invoke中提供要拉截的方法調用時的附件操作,
然后所有對需要攔截的方法所在的對象都由代理來生成就可以在運行時動態地實現對方法調進行攔截。
事實上動態代理模式從描述上也看出了它的無奈。
1. 所有需要攔截的方法所在的類必須要實現一個接口供代理來"制造"這個類的實例。
2. 必須改變原有實現的調用方式。即原來instance.m();的調用必須全部改成proxy.m();
3. 當需要對原來沒有實現接口的類增加進行攔截的時候必須先強制實現接口再重新使用代理的方式生成對象。
4.對方法調用時的內部環境無法感知。
這個動態有些免強,其實代碼一旦生成根本無法再動態。而要實現這個攔截方式,原有的類設計方式被強制修改(必須提供接口),
類使用方式也被強制修改(必須從代理類生成),這是一種侵入式的實現。簡單說要實現這個功能你必須要在你的代碼中嵌入你的攔
截實現。
如果我們采用字節碼生成器來進行攔截實現,我們就可以以非侵入方式來攔截。這種方式的實現對應用透明。程序員根本
不必考慮在業務邏輯實現時如何提供方法調用的攔截。一切都由JVM在loadClass的時候偷偷地將你的class文件替換成可以
進過包裝的class來進行攔截。這種方式的好處是不影響類的設計和實現,并且攔截的功能非常強大,可以獲取方法調用時的
本地變量,異常棧等內部信息。
雖然字節碼生成器的實現方式也是在運行時進行動態方法攔截,但我這里要說的動態非侵入攔截并不是指運行時攔截這種動態。
如果我們僅僅是實現一個經過對原有class文件的替換過的class,在JVM啟動時使用ClassLoader進行redefine來實現攔截,
這同樣要進行侵入,要么要修改System.ClassLoader來自動redefine一個class,要么就象代理模式一樣來控制每個類的調用方式。
而且,如果我們對某一個類中的方法進行攔截,一旦JVM啟動,就要在整個過程中進行都進行攔截。
我要說的動態是指在JVM啟動后正常的時候JVM執行的是原始的class,在我需要的時候JVM能動態執行進過字節碼生成器包裝過的class.
然后在我進行調試,診斷等操作后JVM又能即時執行原有的class,就象沒有發生任何攔截一樣。我這里的用詞不是很準,
JVM執行class是說JVM在運行時鏈接的class對象,然后JIT編譯器根據這個class生成本地碼來執行。
上面說清楚我們要達到的目的,下面就來談具體的實現。
首先是字節碼生成器,在沒有字節碼生器以前,我們要動態生成一個內存中的class,我們只能進行動態編譯。
(http://blog.csdn.net/axman/archive/2004/11/04/167002.aspx)
但字節碼生成器提供了在內存中動態構造class的方式。目前主流的字節碼生成器有ASM,BCEL,SERP。功能基本相同,
但ASM實現非常短小精悍,性能最強。是本人最喜歡的一款字節碼生成器,如果你喜歡其它的字節碼生成器,不影響本文的說明。
本文不是介紹ASM的文檔,所以不會詳細介紹ASM的相關內容。但基于要說明的問題,提供一個很小的例子:
Coder實現了一個業務邏輯類:
1. package org.axman.test;
2.
3. public class TestClass {
4. public void test(){
5. System.out.println("I'm TestClass.test() .");
6. }
7. public void test1(){
8. System.out.println("I'm TestClass.test1() .");
9. }
10. }
這是一個非常普通的業務邏輯,對,我們就要它普通,對于Coder來說,他的實現要以一切正常的方式來運行。
當這個類作為一個項目的實現之一被正常運行后,在運行時我想要看到test或test1被調用時的情況,我們就要實現
它的攔截手段:
1. private static byte[] getWrappedClass(String className,String[] methods){
2. try{
3. String path = className.replace('.', '/') + ".class";
4. ClassReader reader = new ClassReader(ClassLoader.getSystemResourceAsStream(path));
5. ClassWriter writer = new ClassWriter(ClassWriter.COMPUTE_MAXS);
6. ClassAdapter classAdapter = new MyClassAdapter(writer,methods);
7. reader.accept(classAdapter, ClassReader.EXPAND_FRAMES);
8. return writer.toByteArray();
9. }catch(Exception e){
10. System.out.println(">>>>>>");
11. e.printStackTrace(System.out);
12. }
13. return null;
14. }
這個方法是產生經過包裝的class。其中的MyClassAdapter:
1. class MyClassAdapter extends ClassAdapter{
2. private String[] methods;
3. public MyClassAdapter(ClassVisitor cv,String[] methods) {
4. super(cv);
5. this.methods = methods;
6. }
7. public MethodVisitor visitMethod(final int access, final String name, final String desc, final String signature, final String[] exceptions){
8.
9. MethodVisitor mv = cv.visitMethod(access, name, desc, signature, exceptions);
10. if (mv == null || (access & (Opcodes.ACC_ABSTRACT | Opcodes.ACC_NATIVE)) > 0){
11. return mv;
12. }
13. else{
14. for(int i=0;i<methods.length;i++){
15. if(name.equals(methods[i]))
16. return new MyAdviceAdapter(mv, access, name, desc, signature, exceptions);
17. }
18. }
19. return mv;
20. }
21. }
非常簡單,就是在生成新的方法時如果是在指定的methods中就調用MyAdviceAdapter來包裝,否則返回原來的方法.
MyAdviceAdapter也是一個回調接口,是在生成某方法時把onMethodEnter和onMethodExit方法中的指令插入到原來的方法前后再生成
包裝后的字節碼.注意這是注入到生成的class文件中:
1. @SuppressWarnings("unused")
2. class MyAdviceAdapter extends AdviceAdapter {
3.
4. private String name;
5. private int access;
6. private String className;
7. protected MyAdviceAdapter(MethodVisitor mv, int access, String name,
8. String desc,String signature, String[] exceptions) {
9. super(mv, access, name, desc);
10. this.name = name;
11. this.access = access;
12. }
13. protected void onMethodEnter(){
14. this.mv.visitFieldInsn(GETSTATIC, "Ljava/lang/System;", "out", "Ljava/io/PrintStream;");
15. this.mv.visitLdcInsn("before



.");
16. this.mv.visitMethodInsn(INVOKEVIRTUAL, "Ljava/io/PrintStream;", "println", "(Ljava/lang/String;)V");
17. }
18. protected void onMethodExit(int opcode){
19. this.mv.visitFieldInsn(GETSTATIC, "Ljava/lang/System;", "out", "Ljava/io/PrintStream;");
20. this.mv.visitLdcInsn("after %d



.");
21. this.mv.visitMethodInsn(INVOKEVIRTUAL, "Ljava/io/PrintStream;", "println", "(Ljava/lang/String;)V");
22. }
23. }
利用字節碼生成器提供的功能我們還可以獲取方法棧中的本地變量,異常棧等,這是代理方式不能做到的。詳細的功能請看ASM文檔。特別是在方法拋出異常時,為了幫助分析,我們最需要的能恢復現場,所以在攔截器中導出方法運行時的參數,方法內的本地變量等“當時信息”具有非常的意義。
當我們獲取到經過包裝的class的byte[]后,我們如何讓JVM動態執行新的class?
JAVA5以后JVM提供了一個javaagent接口,就是在執行Mail方法前會預執行premain方法。這個方法簽名是:
public static void premain(String agentArgs, Instrumentation inst);
其中的Instrumentation的實例inst就可以redefine一個原來的Class
當我們的項目中的MyBusiness在被main方法調用前,inst可以將原來的class替換成包裝后的class:
1. public static void premain(String agentArgs, Instrumentation inst) {
2. try{
3. byte[] buf = getWrappedClass("org.axman.test.TestClass",new String[]{"test"});
4. Class<?> clazz = ClassLoader.getSystemClassLoader().loadClass("org.axman.test.TestClass");
5. ClassDefinition[] definitions = new ClassDefinition[] { new ClassDefinition(clazz, buf) };
6. inst.redefineClasses(definitions);
7.
8. }catch(Exception e){e.printStackTrace();}
9. }
在將應用打包的時候在MANIFEST.MF文件中加上:
Premain-Class: 包含primain方法的類,最好是和main放在一起。
Can-Redefine-Classes: true
Boot-Class-Path: 打包后的jar文件如agent.jar
這樣對于開發人員而言這個攔截過程是完全透明的。我們只需要啟動時加上
java -javaagent:agent.jar選項就可以在應用完全不感知的情況下攔截應用中的方法
但是,這仍然不能做到動態,因為JVM啟動后,所有原來對MyBusiness的business調用會一直被替換為包裝后的代碼。
所以我們不能直接在premain中redefine,而是將inst傳給一個線程:
# public static void premain(String agentArgs, final Instrumentation inst) {
# new Redefiner(inst).start();
# }
1. class Redefiner extends Thread{
2. private final Instrumentation inst;
3. public Redefiner(Instrumentation inst){
4. this.inst = inst;
5. this.setDaemon(true);
6. }
7.
8. public void run(){
9. //這里應該啟用ServerSocket來獲取從控制臺登錄的命令參數。
10. //但測試的例子為了簡單僅定時從某指定的文件中獲取。
11. HashMap<String,byte[]> map = new HashMap<String,byte[]>();
12. long prevLastModified = 0L;
13. String lastCMD = "";
14. while(true){
15. try{
16. Thread.sleep(1000);
17. File f = new File("d:/a.txt");
18. long lm = f.lastModified();
19. if(prevLastModified == lm) continue;
20. prevLastModified = lm;
21. BufferedReader br = new BufferedReader(new FileReader(f));
22. String line = br.readLine();//從文件中讀取命令
23. br.close();
24. String[] cols = line.split(":");
25. if(cols.length < 3) continue;
26. String CMD = cols[0];
27. if(CMD.equals(lastCMD)) continue;
28. lastCMD = CMD;
29. String className = cols[1];
30. String[] methods = cols[2].split(",");
31. if(CMD.equals("STOP")) break;//退出,應該加權限驗證
32. if(CMD.equals("DEBUG")){
33. if(!map.containsKey(className)){
34. map.put(className,getOriginClass(className));//緩存原始的class
35. }
36. byte[] buf = getWrappedClass(className,methods);
37. //包裝后的class是否要緩存自己看著辦。緩存需要空間,不緩存每次生存需要運算和臨時空間,自己根據調用頻度來決定。
38. Class<?> clazz = ClassLoader.getSystemClassLoader().loadClass(className);
39. ClassDefinition[] definitions = new ClassDefinition[] { new ClassDefinition(clazz, buf) };
40. inst.redefineClasses(definitions);
41. System.out.println("redefine to debug
..");
42. }
43. else if(CMD.equals("RESET")){
44. byte[] buf = map.get(className);
45. if(buf == null) continue;
46. Class<?> clazz = ClassLoader.getSystemClassLoader().loadClass(className);
47. ClassDefinition[] definitions = new ClassDefinition[] { new ClassDefinition(clazz, buf) };
48. inst.redefineClasses(definitions);
49. System.out.println("redefine to reset
..");
50. }
51. else;
52.
53. }catch(Exception e){}
54. }
55. }
56. private static byte[] getWrappedClass(String className,String[] methods){
57. try{
58. String path = className.replace('.', '/') + ".class";
59. ClassReader reader = new ClassReader(ClassLoader.getSystemResourceAsStream(path));
60. ClassWriter writer = new ClassWriter(ClassWriter.COMPUTE_MAXS);
61. ClassAdapter classAdapter = new MyClassAdapter(writer,methods);
62. reader.accept(classAdapter, ClassReader.EXPAND_FRAMES);
63. return writer.toByteArray();
64. }catch(Exception e){
65. System.out.println(">>>>>>");
66. e.printStackTrace(System.out);
67. }
68. return null;
69. }
70. private static byte[] getOriginClass(String className){
71. try{
72. String path = className.replace('.', '/') + ".class";
73. ClassReader reader = new ClassReader(ClassLoader.getSystemResourceAsStream(path));
74. return reader.b;
75. }catch(Exception e){
76. e.printStackTrace(System.out);
77. }
78. return null;
79. }
80. }
OK,在JVM正常啟動后,你只要在那個用來通訊的文件中加上className和methods就可以在你需要的時候redefineClasses,在你不需要的時候恢復原始的class。比如一開如先在a.txt中寫入: XXX:YYY:ZZZ
這樣的命令那么守護線程什么也不做,而主線程會打印
"I'm TestClass.test() ."
"I'm TestClass.test1() ."
然后將a.txt內容改成: DEBUG:org.axman.test.TestClass:test
就會在"I'm TestClass.test() ."前后打印出before和after的注入信息。這時沒有redefine test1方法。
再將a.txt的內容改成:DEBUG:org.axman.test.TestClass:test,test1就會看到
"I'm TestClass.test() ."和"I'm TestClass.test1() ."的前后都打印了注入的信息。然后再修改成
RESET:org.axman.test.TestClass:test,test1,又恢復了默認的打印信息。完全按我們的控制來進行方法調用的
攔截。
這才是真正的“動態無侵入攔截”。當然要記得一個真正的實現不要用文件來通訊。