一个很常见的场景是对方法进行拦截,比如计算方法的执行时间或者判断是否有执行该方法的权限。常用的拦截框架有AOP和aspectj,这两种拦截器使用不同的原理。AOP使用动态代理Bean来实施拦截,而aspectj使用扫描将字节码写入class文件。在这里我想要实现的是一种比较优雅的拦截方式:使用注解静态拦截,但是并不使用扫描。比如我们想要实现一个注解Lock。
预期的代码应该是这样:
@Lock(name = "mylock") public static void test() { System.out.println("just a test"); }
当给某个方法加上注解如Lock时,我们会让这个方法在执行前调用某个锁线程的方法,而在方法结束的时候则调用解锁方法。这样这个test()方法就在某种程度上变成了线程同步的方法。这个锁同时还可以有一个名字,其他方法采用Lock注解时只有锁的名字一样才会与test方法线程同步。
我们的Lock注解定义如下:
@Retention(RetentionPolicy.RUNTIME) @Target({ ElementType.TYPE, ElementType.METHOD }) public @interface Lock { final long DEFAULT_ELAPSE_TIME = 3L; String name(); long time() default DEFAULT_ELAPSE_TIME; }
注解中有Retention,Target等配置,这些属于注解的基本知识,这里不多罗嗦。我们在注解中定义了一个name作为锁的名称,以及time作为锁的最大有效时间。
注解的处理器
我们在Java代码中,见过各种各样的注解,比如@Override,或者在Spring中常见的@Autowire等注解。这些注解使我们可以把非业务的代码分离出去,让我们的代码更加整洁和易读。在Java中,注解的使用原理一般有两种:使用处理器在编译期处理和在运行时使用反射进行动态处理。我们先来看一个使用反射进行注解处理的例子:
public class Model { @Lock(name = "my lock") public static void run() { System.out.println("running!!!!!"); } public static void main(String args[]) throws NoSuchMethodException, SecurityException { Class clazz = Model.class; Method method = clazz.getMethod("run",new Class[]{}); Lock lock = method.getAnnotation(Lock.class); System.out.println(lock.name() + ", " + lock.time()); } }
上面的代码非常简单,我们也会看到程序输出正确打印了“my lock, 3”。运用反射可以很方便的处理注解以及很多其他任务。事实上在Spring中,采用动态代理的方式完成的aop就是用的反射。由于在Spring中,几乎所有的类都使用Spring的bean的形式进行加载,亦即每个类都会生成一个单例,因此使用动态代理可以很好的完成拦截的任务。在Spring中,要完成我们这里的锁的任务,甚至不需要在代码中有任何修改,连注解都不需要。但是Spring的aop只能完成对非静态方法的拦截,这限制了它的使用场景,况且我们不一定就是使用Spring的加载方式进行类的加载。
当然,我们可以在程序启动时进行一个扫描,将所有类中包含指定注解的类的class文件改写,然后我们真正加载到的会是一个全新的类,Aspectj正是这么做的。但是我们不想使用扫描,因为这样感觉效率会比较低,而且不够高大上。同时使用类似aspectj的框架限制了我们的自定义注解的种类,我们所有需要拦截的方法所标注的注解都是一样,如@aspect,这也是我们不想要的。
这时候我们在Java 6中,找到了可以在编译期间处理注解的方法:java.annotation.processing.*(在Java 5以及之前,也是可以处理的,但会更加复杂,参见:
http://www.infoq.com/cn/articles/cf-java-annotation)。
你可以使用下面这篇文章来对使用注解处理器有个大致的了解:
http://hannesdorfmann.com/annotation-processing/annotationprocessing101/
以及相应的中文翻译版:
http://www.race604.com/annotation-processing/
能在编译期间做一些事情,甚至修改java和class文件,这很神奇也很酷,有点像C++的模板元编程。我们试图让事情尽量的简单,我们先看一下我们的LockProcessor第一个版本的代码,它的主要功能是获取被@Lock注解的方法的详细信息,包括所在类名,方法的参数和返回值类型等:
@Override public boolean process(Set annotations, RoundEnvironment roundEnv) { for (Element element : roundEnv.getElementsAnnotatedWith(Lock.class)) { Element classElement = element.getEnclosingElement(); String name = null; String time = null; List anns = element .getAnnotationMirrors(); for (AnnotationMirror mirror : anns) { if (mirror.getAnnotationType().asElement().equals(lockElement)) { Map values = mirror .getElementValues(); name = (String) getAnnotationValue(values, "name"); time = (String) getAnnotationValue(values, "time"); if (time == null) { time = DEFAULT_DURATION; } } } processingEnv.getMessager().printMessage( Diagnostic.Kind.WARNING, classElement.toString() + "#" + element.toString() + "#" + element.getKind() + "#" + element.asType().toString() + "#" + name + "#" + NumberUtils.toLong(time)); } return true; } private Object getAnnotationValue( Map values, String annotationFiledName) { for (Entry entry : values .entrySet()) { if (entry.getKey().getSimpleName() .contentEquals(annotationFiledName)) { return entry.getValue().getValue(); } } return null; }
简要介绍一下这个Processor类,首先类的顶部的@AutoService注解表示会自动生成META-INF/services/javax.annotation.processing.Processor首先类的顶部的@AutoService注解表示会自动生成META-INF/services/javax.annotation.processing.Processor文件。这个文件如果不能自动生成,则手动生成也可以,但是路径和文件名都是必须严格匹配的。@SupportedAnnotationTypes 注解表示该Processor支持的注解类型,只有在这里标明的注解才会被Processor的process方法处理到,多个注解类型使用大括号分开标识。@SupportedSourceVersion 表示该Processor最大支持的Java版本,当标明为Java 7时,表示Java 7及以下的版本的代码会被支持。
在该Processor的成员变量中,我们定义了一个lockElement,其类型为TypeElement。TypeElement类型用来表示一个类或者接口的信息。在init方法中,我们将lockElement设置为Lock注解的信息,在后面的处理中,用来比较我们获得的注解是否为Lock注解。虽然我们的Processor只支持Lock注解,但是包含Lock注解的元素却可能同时包含其他注解,因此存储一个Lock注解的TypeElement是必要的。
在继续往下讨论process方法的细节前,或许你对注解处理器工作的原理有些好奇。我们可以从Java编译过程一窥究竟,事实上在明白了Java的编译过程后,你会对process方法有更深的理解。在[4]介绍中,Java的编译过程分为三个阶段:抽象语法树生成,注解处理和生成字节码。抽象语法树自然就是扫描Java代码,记录所有类型、变量、方法、注解等,存储在树状结构中,这在编译原理中非常常见。而后,Java编译器开始根据 WE-INF目录下的processor文件进入相应的注解处理器代码,如下图的中间步骤。
注意注解处理结束后,如果生成了新的Java文件或者class文件,则需要重新回到第一步,解析与输入,然后再进行注解处理,如此往复,直到没有新的文件生成。从中我们也可以看出,编译器并不是看到一个注解就去寻址指定的注解处理器,而是在所有代码都扫描完后才会去做。
在process方法中,有两个循环,外层的循环遍历所有带有Lock注解的元素,这个元素可能是类,包,方法,变量,这取决于注解的定义中所支持的种类。当然在编写注解处理器时应该很清楚注解会用在哪些地方。内层循环获取带有Lock注解的元素上的Lock注解的信息。其中用到了一些Java的API,应该很容易明白。这里取注解的默认值的方法不是很优雅,我没有找到其他好方法,如果你知道烦请告知我。在外层循环的末尾,使用基类AbstractProcessor的成员变量processingEnv打印了一些信息,包括被注解元素所在类的完整类名,以及被注解方法的完整签名和注解的值。这些信息会被当作编译信息打印出来,有了这些信息我们可以做一些操作,比如写一个Java或者Class文件。注意process方法的返回值,当为true时,表示对于该注解不需要额外的处理器处理,为false时,则表示需要。
现在我们已经可以在编译期间获得注解以及处理注解的所有信息,我们应该怎样实现拦截呢?动态代理是行不通的,那么修改类的字节码呢?看起来是不行的,至少暂时是不行的,因为注解处理的时间是比生成原始class文件早的,我们需要寻找其他方法,但是注解处理器所得到的注解信息是有用的,这些信息让我们不需要去扫描代码寻找注解,我们应该把这些信息存在某个临时文件里,后面或许有用。
熟悉Java类加载过程的人应该知道,Java程序启动时,并不是所有类都会加载,只有当这个类被用到时,才会被加载。因此我们或许可以在程序启动的时候去修改类的class文件,让它添加我们想要的功能。也许你会问,修改class文件的方式,行得通吗?代码已经编译好了,这样能行吗?当然行得通,aspectj就是这么做的。那么aspectj是怎么做的呢,它用的是asm框架。
asm框架
关于asm框架的介绍很多,网上也有很多教程,但是其源代码反而不是那么容易读,因为注释太少了。下面的链接是asm官方的解释文档,看完该文档你就应该能够明白asm里面每一个类、方法、变量以及常量的含义。如果没有,说明你看的不够仔细^_^。
http://download.forge.objectweb.org/asm/asm4-guide.pdf
简单来说,asm就是一个用来操作字节码的框架,它提供了很多封装好的访问class文件,提取类型,方法和变量的方法。用它会比较容易的修改class文件。在进行实际的操作前,我们先来了解一下class文件的内容。以上面的Model类为例,我们把main函数里的代码换为调用run方法:
public class Model { public static void run() { System.out.println("running!!!!!"); } public static void main(String args[]) { run(); } }
这个类足够简单了吧,我们用javac命令来编译一下该文件,看看Model.class文件的内容:
// Compiled from Model.java (version 1.7 : 51.0, super bit) public class com.dewmobile.test.Model { // Method descriptor #9 ()V // Stack: 1, Locals: 1 public Model(); 0 aload_0 [this] 1 invokespecial java.lang.Object() [1] 4 return Line numbers: [pc: 0, line: 9] // Method descriptor #9 ()V // Stack: 2, Locals: 0 public static void run(); 0 getstatic java.lang.System.out : java.io.PrintStream [2] 3 ldc[3] 5 invokevirtual java.io.PrintStream.println(java.lang.String) : void [4] 8 return Line numbers: [pc: 0, line: 12] [pc: 8, line: 13] // Method descriptor #14 ([Ljava/lang/String;)V // Stack: 0, Locals: 1 public static void main(java.lang.String[] arg0); 0 invokestatic com.dewmobile.test.Model.run() : void [5] 3 return Line numbers: [pc: 0, line: 17] [pc: 3, line: 18] }
找到run方法的定义,里面只执行了一句打印”running!!!!!”的代码,在class文件也能看到相应的调用,稍微多了一些步骤:
public static void run(); 0 getstatic java.lang.System.out : java.io.PrintStream [2] 3 ldc[3] 5 invokevirtual java.io.PrintStream.println(java.lang.String) : void [4] 8 return
第一句获取静态对象out,其类型为PrintStream;第二句将参数字符串放到栈里面;第三句调用方法println,第四句返回。我们如果熟悉class文件结构,完全可以用文本编辑器直接修改class,但是会比较麻烦且容易出错。而asm框架就提供了相应的修改class的方法。我们的目标是在run方法的开头和结尾各执行一个语句,这要用到asm中的ClassReader、ClassWriter、ClassAdaptor以及MethodAdaptor。ClassReader用来读取一个class文件,生成一个对象加载到内存中。ClassWriter用来将操作完的class对象转为byte数组,这样我们就可以使用文件IO库的方法生成一个新的class文件。连接ClassReader和ClassWriter的是ClassAdaptor,ClassWriter的构造方法包含一个ClassAdaptor的参数,而ClassReader有一个accept方法来接受一个ClassAdaptor对象。因此真正对class进行操作的是ClassAdaptor,而对于我们要修改某个方法而言,MethodAdaptor也是需要的。详细的资料在上面提到的官方文档里有解释,寓教于练,我们直接看看需要的代码。下面这段代码是一个继承了ClassAdaptor的LockAdaptor类:
public class LockAdaptor extends ClassAdapter { private String methodName; private String lockName; private long lockDuration; public LockAdaptor(ClassVisitor cv) { super(cv); } public LockAdaptor(ClassVisitor cv, String methodName, String lockName, long lockDuration) { super(cv); this.methodName = methodName; this.lockName = lockName; this.lockDuration = lockDuration; } public MethodVisitor visitMethod(final int access, final String name, final String desc, final String signature, final String[] exceptions) { MethodVisitor mv = cv.visitMethod(access, name, desc, signature, exceptions); MethodVisitor wrappedMv = mv; if (mv != null && name.equals(methodName)) { wrappedMv = new LockMethodAdaptor(mv, lockName, lockDuration); } return wrappedMv; } }
上面的类包含了三个成员变量:要锁住的方法的名称、锁的名称和锁的最大持续时间。注意如果使用注解实现锁,那么锁的名称是必须的,因为我们希望不同方法采用该注解时,不会被其他方法影响。ClassAdaptor中有一个visitMethod方法,这个方法的参数分别的含义是:
-
- access:访问方法的可见性,public、private或者protected等,参见OpCodes类里面以ACC_打头的所有字段;
- name:访问方法的名称;
- desc:方法的描述符,包含方法的参数和返回值,但是信息不是很详细;
- signature:方法的签名,如果方法参数、返回值和异常声明都是基本类型的话,则为null;
- exceptions:方法的异常声明。
为了测试desc和signature的差异,我们给run方法加上参数:List
access:9, name:run, desc:(Ljava/util/List;)V, signature:(Ljava/util/List;)V, exceptions:null
如上所示,如果我们想完全匹配一个方法的话,需要比较方法名称和方法签名,当然在我们的测试阶段,我就只判断了方法的名称是否一致。在if判断为true后,我们把原来的MethodAdaptor换为我们的LockMethodAdaptor,我们来看LockMethodAdaptor的代码:
public class LockMethodAdaptor extends MethodAdapter { private String lockName; private long lockDuration; public LockMethodAdaptor(MethodVisitor mv) { super(mv); } public LockMethodAdaptor(MethodVisitor mv, String lockName, long lockDuration) { super(mv); this.lockName = lockName; this.lockDuration = lockDuration; } @Override public void visitCode() { mv.visitLdcInsn(lockName); mv.visitLdcInsn(lockDuration); mv.visitMethodInsn(Opcodes.INVOKESTATIC, Type.getInternalName(Util.class), "lock", "(Ljava/lang/String;J)V"); } @Override public void visitInsn(int opcode) { if (opcode == Opcodes.RETURN) { mv.visitLdcInsn(lockName); mv.visitMethodInsn(Opcodes.INVOKESTATIC, Type.getInternalName(Util.class), "unlock", "(Ljava/lang/String;)V"); } mv.visitInsn(opcode); } }
同样的LockMethodAdaptor有两个成员变量:锁的名称和锁的时间。然后覆盖了visitCode和visitInsn两个方法。visitCode方法用来在目标方法的起始位置加入指令,我们先将两个参数压入栈,然后调用Util.lock方法。visitInsn用来在方法的中间加入指令,我们只需要在方法返回之前再次调用解锁,注意这里仅仅判断opcode是否为RETURN是不够的,还需要将OpCodes里定义的所有其他RETURN类型一起匹配才行。然后也是参数压栈,调用方法。
接下来我们来看看AsmGenerator的实现:
public class AsmGenerator { public AsmGenerator() { } public void generateLockedMethod(String className, String methodName, String lockName, long lockDuration) { ClassReader cr; try { cr = new ClassReader(className); ClassWriter cw = new ClassWriter(ClassWriter.COMPUTE_MAXS); ClassAdapter classAdapter = new LockAdaptor(cw, methodName, lockName, lockDuration); cr.accept(classAdapter, ClassReader.SKIP_DEBUG); byte[] data = cw.toByteArray(); File file = new File("src/main/java/" + className.replace('.', '/') + ".class"); FileOutputStream fout = new FileOutputStream(file); fout.write(data); fout.close(); } catch (IOException e) { e.printStackTrace(); } } }
AsmGenerator首先读取指定的类,然后新建一个ClassWriter和ClassAdaptor,调用ClassReader的accept方法将指定的类输入给ClassAdaptor和ClassWriter。最后将ClassWriter生成的byte数组写入文件。
最后是我们调用asm的地方以及lock方法所在的类:
public class App { public static void main(String[] args) { AsmGenerator generator = new AsmGenerator(); generator.generateLockedMethod("Model", "run", "hahaha", 3000L); } }
public class Util { public static void lock(String name, long duration) { System.out.println("lock " + name + "#" + duration); } public static void unlock(String name) { System.out.println("unlock " + name); } }
我们来看修改后的Model.class文件:
// (version 1.7 : 51.0, super bit) public class com.dewmobile.test.Model { // Method descriptor #6 ()V // Stack: 1, Locals: 1 public Model(); 0 aload_0 [this] 1 invokespecial java.lang.Object() [8] 4 return // Method descriptor #6 ()V // Stack: 3, Locals: 0 public static void run(); 0 ldc[11] 2 ldc2_w [12] 5 invokestatic Util.lock(java.lang.String, long) : void [19] 8 getstatic java.lang.System.out : java.io.PrintStream [25] 11 ldc [27] 13 invokevirtual java.io.PrintStream.println(java.lang.String) : void [33] 16 ldc [11] 18 invokestatic Util.unlock(java.lang.String) : void [36] 21 return // Method descriptor #38 ([Ljava/lang/String;)V // Stack: 0, Locals: 1 public static void main(java.lang.String[] arg0); 0 invokestatic Model.run() : void [40] 3 return }
已经正确加入了lock和unlock。注意如果你在eclipse中运行Model类,你不会得到加锁的运行结果,因为eclipse没有使用我们生成的Model.class。
我们已经有了如何记录需要拦截的方法的类的LockProcessor,也有了将指定方法的字节码修改的途径,那么问题来了,我们在编译阶段获取的需要拦截的方法的信息需要在一个合适的时机去调用asm的修改类字节码的方法。解决方法可以有多种,比如可以在程序启动的时候进行修改,因为熟悉ClassLoader的人应该会了解,Java程序在启动时并不会加载所有的类,而是等到需要加载的时候才会去读取class文件,不过这对于将程序打成jar包后再运行可能会麻烦一些。在这种情况下,我们可以再编译后运行一个额外的java程序,来将需要修改的class文件修改完成。这样就解决了程序打包的问题。
好的,现在我们的目标似乎已经完成了。故事结束了,王子和公主幸福的生活在一起。不过我在写这篇文章的时候有了一个额外的发现:lombok。
lombok
lombok是一个在编译期间修改Java代码的框架,它的官方地址在:https://projectlombok.org/。
lombok提供了一些注解用来简化冗长的Java代码,比如在某个字段上添加@Getter注解,则会自动生成该字段的get方法。通过一些资料的说明,可以看到lombok利用的正是注解处理器可以再编译期间运行一些代码的原理。不过上面已经说过,注解处理器可以获取当前注解的元素,却无法直接修改其代码,因为注解处理器处理的时候,代码还没有生成。那么lombok是怎么实现的呢?[3]这篇文章值得好好阅读,在这篇文章里,作者解释了lombok是如何工作的,以及一些实用的用来创建我们的自定义注解的例子。不错,如果你看了这篇文章就会知道,lombok修改的是我们Java代码的抽象语法树(AST)。修改了AST之后,需要再从Java编译的第一步走起,然后再注解处理,最后生成class文件。这是一种类似于黑客的行为。
由于javac编译器和eclipse编译器虽然输出几乎是一样的,但是内部实现方式却差异很大,因此lombok针对每种编译器进行了适配。比如lombok提供的类JavacAnnotationHandler主要针对的是javac编译器,在[3]中有详细的实例。最难的部分在于如何适配,在此贴出[3]中的部分代码,看看有多复杂:
private JCMethodDecl createHelloWorld(JavacNode type) { TreeMaker treeMaker = type.getTreeMaker(); JCModifiers modifiers = treeMaker.Modifiers(Modifier.PUBLIC); List methodGenericTypes = List.nil(); JCExpression methodType = treeMaker.TypeIdent(TypeTags.VOID); Name methodName = type.toName("helloWorld"); List methodParameters = List.nil(); List methodThrows = List.nil(); JCExpression printlnMethod = JavacHandlerUtil.chainDots(treeMaker, type, "System", "out", "println"); List printlnArgs = List.of(treeMaker.Literal("hello world")); JCMethodInvocation printlnInvocation = treeMaker.Apply(List.nil(), printlnMethod, printlnArgs); JCBlock methodBody = treeMaker.Block(0, List.of(treeMaker.Exec(printlnInvocation))); JCExpression defaultValue = null; return treeMaker.MethodDef( modifiers, methodName, methodType, methodGenericTypes, methodParameters, methodThrows, methodBody, defaultValue ); }
不知你看的怎么样,反正我已经看的头晕了。如果想要使用最简洁的方法来通过注解实现拦截,看起来非lombok不可了。如果你有兴趣,可以去了解一下。使用lombok现有的注解不难,难的是利用它来开发新的注解。
参考资料
[1]http://www.javatronic.fr/articles/2014/10/08/how_does_annotation_processing_work_in_java.htmlJava注解处理器的工作原理
[2] http://www.ibm.com/developerworks/library/j-lombok/ lombok的介绍
[3] http://notatube.blogspot.com/2010/12/project-lombok-creating-custom.html lombok的原理,需要
[4] http://openjdk.java.net/groups/compiler/doc/compilation-overview/ Java编译过程