动态非侵入拦截
什么叫无侵入拦截?
在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,又恢复了默认的打印信息。完全按我们的控制来进行方法调用的
拦截。
这才是真正的“动态无侵入拦截”。当然要记得一个真正的实现不要用文件来通讯。