关于Annotation注解的理解

  在编Java程序的时候,我们经常会碰到annotation。比如:@Override 我们在子类继承父类的时候,会经常用到这个annotation。它告诉编译器这个方法是override父类的方法的。@WebServlet("/myservlet") 在进行Web开发的时候,我们用这个annotation表示这个类是一个servlet。Web容器会识别这个annotation,在运行的时候调用它。很多人说annotation是注释,初看起来有一点像,它对程序的编写和编译似乎没有什么影响,只是给人看的一个对程序的附注。从这点上,确实有一点像注释。不过,它跟注释不同的是,它会影响程序的运行。比如,上面提到的@Override,如果没有override父类的方法,编译器会给出错误提示;再比如,上面的@WebServlet,如果没有这个注解,程序是运行不起来的。

  由此看来,annotation不是注释,注释是给人看的,并不影响程序的编译和运行时候的行为。annotation其实不是给人看的,那么它是给谁看的呢?它被设计出来,用于给另外的程序看的,比如编译器,比如框架,比如Web容器。

  这些外在的程序通过某种方式查看到这些annotation后,就可以采取相应的行为。具体解释一下。

  假如我们要做一个Web容器,类似于Tomcat这种的。它的一个基本功能就是加载servlet。按照JavaEE的规范,容器需要管理servlet的生命周期,第一件事情就是要识别哪些类是servlet。那么,容器启动的时候,可以扫描全部类,找到包含@WebServlet注解的,识别它们,然后加载它们。那么,这个@WebServlet注解就是在运行时起作用的,Java里面把它的作用范围规定为RUNTIME。再看@Override,这个是给编译器看的,编译程序读用户程序的源代码,识别出有override注解的方法,就去检查上层父类相应方法。这个@Override注解就是在编译的时候起作用的,编译之后,就不存在了。Java里面把它的作用范围规定为SOURCE。类似的注解还有@Test,程序员写好了程序,想交给测试框架去测试自己写的方法,就可以用这个注解。测试框架会读取源代码,识别出有@Test注解的方法,就可以进行测试了。

  接下来,我们自己动手做一个注解看看效果加深理解。我们想做的例子是一个运行时框架加载别的客户类,并运行其中的初始化方法。作为框架,我们可以提供一个@InitMethod注解给客户程序员。客户类代码如下:

 public class InitDemo {

    @InitMethod
    public void init(){
        System.out.println("init ...");
    }
}

客户类程序员在init()方法上标注了@InitMethod注解,声明这就是本类的初始化方法。框架程序识别它,并调用它。 

接下来我们看怎么提供这个注解的实现。代码如下:

@Target(ElementType.METHOD)
@Retention(RetentionPolicy.RUNTIME)
public @interface InitMethod {
}

第一次看到这个注解的实现的时候,人们都会大吃一惊,觉得很像是在定义一个 interface。的确很像,Java5之后,提供了这样的手段,让人定义注解。上面就声明了有一个叫InitMethod的注解,它是修饰方法的,在运行时可见。问题来了,上面这一段并不是真正的实现,只是一个定义或者声明。那真正的实现怎么做呢?这些当然要定义者来提供实现的。我们作为框架程序的作者,有责任实现它,代码如下:

public class InitProcessor {
    public static void main(String[] args) throws ClassNotFoundException, IllegalAccessException,IllegalArgumentException,InvocationTargetException, InstantiationException {
        Class<?> clazz = Class.forName("InitDemo");
        Method[] methods = clazz.getMethods();
        if(methods!=null){
            for(Method method:methods){
                boolean isInitMethod = method.isAnnotationPresent(InitMethod.class);
                if(isInitMethod){
                    method.invoke(clazz.newInstance(), null);
                }
            }
        }
    }
}

  稍微解释一下上面的代码。

  为了从客户类InitDemo里面读出annotation信息,需要用到reflection机制。先通过Class.forName()加载类拿到Class信息;然后通过getMethods()拿到所有public的方法(包含从上层父类继承下来的公共方法);接下来是重点: method.isAnnotationPresent(InitMethod.class),这一行判断一个方法是否标记为InitMethod;如果是,则创建一个对象并调用。这样在框架中实现了对类的初始化方法进行调用。运行上面的程序,就能看到确实调用了初始化方法。我们的annotation工作了。Annotation基本的使用就是这样的,一点也不神秘。

  下面介绍更多的一些特性。Annotation的基本定义如下:

@Target(ElementType.xxxxxx)
@Retention(RetentionPolicy.xxxxxx)
[Access Specifier] @interface <AnnotationName> {         
   DataType <Method Name>() [default value];
}

  Annotation里面的Method其实是客户程序在使用该annotation的时候的参数定义,如@WebServlet(urlPatterns=”/abc”,loadOnStartup=1),其中urlPatterns和loadOnStartup就是参数,定义的时候用类似于方法定义的方式。如:

@Target(ElementType.TYPE)
@Retention(RetentionPolicy.RUNTIME)
public @interface WebServlet {         
String urlPatterns() default "/";  
int loadOnStartup() default 0; 
}

 这种定义方式也有点让人费解,对使用者有些费解。不过从实现者的角度,倒是正常。因为实现注解的程序员是这么写的,代码如下:

Annotation annotation=clazz.getAnnotation(WebServlet.class);
WebServlet ws = (WebServlet)annotation;
System.out.println(ws.loadOnStartup());

这一下子就可以看出来了,实现者确实是当成方法调用的。

@Target指定该注解针对的地方,有几种ElementType:

    ElementType.TYPE,         - 类、接口 
    ElementType.FIELD,         - 字段
    ElementType.METHOD,        - 方法
    ElementType.PARAMETER,      - 参数
    ElementType.CONSTRUCTOR,     - 构造方法
    ElementType.LOCAL_VARIABLE,   - 局部变量
    ElementType.ANNOTATION_TYPE,   - 注解
    ElementType.PACKAGE       - 包

@Retention指定注解的保留域,有三种RetentionPolicy:

  RetentionPolicy.SOURCE,            Annotation信息仅存在于源代码级别,由编译器处理,处理完之后就没有保留了
  RetentionPolicy.CLASS,             Annotation信息保留于类对应的.class文件中
  RetentionPolicy.RUNTIME            Annotation信息保留于class文件中,并且可由JVM读入,运行时使用

  SOURCE选项常用于代码自动生成。RetentionPolicy里的CLASS选项让人不好理解。它的含义是说在编译的CLASS文件中记录了这个注解,但是JVM获取不到。那这有什么用处?这确实是一种很少见的应用场景,比如一些直接通过bytecode方式运行的程序,如ASM,它直接读CLASS字节码,并不通过JVM去装载class,这个情况下就需要用到这个选项。不过有的编译器会把CLASS处理成RUNTIME,通过Reflection一样可见,因此有人宣称两者一样的,事实上这不符合Java官方文档说明。我们普通应用程序的开发工作中,CLASS选项用得极少,一般情况下,就用RUNTIME选项。

 讲解了这些之后,我们可以把上面的例子完整写下来。

InitMethod.java

 

import java.lang.annotation.Retention;
import java.lang.annotation.Target;
import java.lang.annotation.ElementType;
import java.lang.annotation.RetentionPolicy;

@Target(ElementType.METHOD)
@Retention(RetentionPolicy.RUNTIME)
public @interface InitMethod {
    String flag() default "1";

InitDemo.java

public class InitDemo {
    @InitMethod(flag="1")
    public void init(){
        System.out.println("init ...");
    }
}

 

InitProcessor.java

import java.lang.annotation.Annotation;
import java.lang.reflect.InvocationTargetException;
import java.lang.reflect.Method;

public class InitProcessor{
    public static void main(String[] args) throws ClassNotFoundException, IllegalAccessException, IllegalArgumentException, InvocationTargetException, InstantiationException {
        Class<?> clazz = Class.forName("InitDemo");
        Method[] methods = clazz.getMethods();
        if(methods!=null){
            for(Method method:methods){
                boolean isInit = method.isAnnotationPresent(InitMethod.class);
                if(isInit){
                    method.invoke(clazz.newInstance(), null);
                    Annotation annotation=method.getAnnotation(InitMethod.class);
                    InitMethod im = (InitMethod)annotation;
                    System.out.println(im.flag());
                }
            }
        }
    }
}

  InitProcessor相当于一个框架,对客户程序进行管理并自动调用初始化方法。而站在客户化程序员的角度,就是要遵守某种协议规范,写的程序就能按照预想的正确运行起来,简单易用,自己少写很多代码。不过“一物有二柄”,凡事都有一个优缺点。这么写程序,对客户程序员来讲,获得简单的同时,却丢失了全局观。很多程序员都不知道这些程序究竟是怎么运行起来的,有点失控的感觉。这也是学习框架过程中最经常的困惑。当代编程的范式,已经全部转换成基于框架的编程了。大部分人都面临着知其然而不知其所以然的问题。这种情况下,D.I.Y.,自动动手实现某种机制就显得格外重要。

  为了加深对annotation的了解,我们再试着编写一个自动生成代码的例子。我们想做这么一件事情,模仿JUnit,应用程序员写了一个类,我们自动生成测试类。这个就可以使用到作用于SOURCE级别的annotation来实现。我们先提供一个@UnitTest注解给客户程序员使用。这个注解的作用就是自动生成一个测试类,把客户程序里面的方法都调用一次。

同样的,我们先定义注解,代码如下(UnitTest.java):

import java.lang.annotation.Retention;
import java.lang.annotation.Target;
import java.lang.annotation.ElementType;
import java.lang.annotation.RetentionPolicy;

@Target(ElementType.TYPE)
@Retention(RetentionPolicy.SOURCE)
public @interface UnitTest {
  String prefix() default "Test";
}

 

这个定义没有什么特别的,跟上述讲解的不同在于使用了RetentionPolicy.SOURCE,表示这个注解作用于源代码层面,在编译时使用。

使用层面就更加简单了,代码如下(Tool.java):

@UnitTest
public class Tool {
    public void check(){
    System.out.println("check");
    }
}

 

根据规定,对这类注解的实现类要继承AbstractProcessor抽象类,这个抽象类是由javax里面给出的,所以需要先import javax.annotation.processing.AbstractProcessor;

在类里面,主要是要override 一个方法:process(),好,我们直接看代码(UnitTestProcessor.java):

 

import java.io.IOException;
import java.io.Writer;
import java.util.Set;

import javax.annotation.processing.AbstractProcessor;
import javax.annotation.processing.RoundEnvironment;
import javax.annotation.processing.SupportedAnnotationTypes;
import javax.annotation.processing.SupportedSourceVersion;
import javax.lang.model.element.Element;
import javax.lang.model.element.TypeElement;
import javax.lang.model.type.ExecutableType;
import javax.tools.JavaFileObject;
import javax.lang.model.SourceVersion;

@SupportedAnnotationTypes({"UnitTest"})
@SupportedSourceVersion(SourceVersion.RELEASE_8)
public class UnitTestProcessor extends AbstractProcessor{
    @Override
    public boolean process(Set<? extends TypeElement> annotations, RoundEnvironment roundEnv) {
        for(Element clz: roundEnv.getElementsAnnotatedWith(UnitTest.class)){
          UnitTest ut = clz.getAnnotation(UnitTest.class);

          String newFileStr = "public class "+ut.prefix() + clz.getSimpleName()+" {\n\n";

          //add constructor
          newFileStr += "public " + ut.prefix() + clz.getSimpleName() + "() {\n";
          newFileStr+="}\n\n"; //end of constructor

          //add main()
          newFileStr += "public static void main(String[] args) {\n";

          //new instance
          newFileStr += clz.getSimpleName() + " clz = new "+clz.getSimpleName()+"();\n";

          //add test method
          for(Element testmethod : clz.getEnclosedElements()){ 
            if(!testmethod.getSimpleName().toString().equals("<init>") && testmethod.asType() instanceof ExecutableType){ //add test method
              newFileStr += "clz."+testmethod.getSimpleName()+"();\n";
            }
          }

          newFileStr+="}\n"; //end of main()
          newFileStr+="}\n"; //end of class

          try {
            JavaFileObject jfo = processingEnv.getFiler().createSourceFile(ut.prefix() + clz.getSimpleName(), clz);
            Writer writer = jfo.openWriter();
            writer.append(newFileStr);
            writer.flush();
            writer.close();
          } catch (IOException ex) {
          }
        }

        return true;
      }

    }

 

  把这个程序解释一下:

  Process()带两个参数:the set of annotations that is being processed in this round, and a RoundEnv reference that contains information about the current processing round.一个是要处理的annotation集合,一个是环境参数。原理上process()的主体是对集合中的每一个annotation进行处理。处理之后新生成的一些程序或许还会包含有同样的annotation,就需要递归处理,所以有一个round的概念,需要一轮一轮处理完毕。不过,我们这个是教学,只是为了演示,我们就简化处理。通过roundEnv.getElementsAnnotatedWith(UnitTest.class),我们拿到包含有UnitTest注解的全部类,因为@UnitTest是作用于类之上的。通过clz.getAnnotation(UnitTest.class),我们可以把类上的注解拿到,然后从注解中获取信息。我们的任务比较简单,只是要生成一个测试类,所以我们用newFileStr拼字符串,写这个文件。先写类定义:newFileStr += "public class "+ut.prefix() + clz.getSimpleName()+" {\n\n"如果类的名字叫Tool,那么我们自动生成的测试类就叫TestTool.然后定义构造函数newFileStr += "public " + ut.prefix() + clz.getSimpleName() + "() {\n"再写main(),作为测试类的入口      newFileStr += "public static void main(String[] args) {\n"main()里面的结构,就是新建对象,然后调用方法      

newFileStr += clz.getSimpleName() + " clz = new "+clz.getSimpleName()+"();\n";检查类里面的每一个element,挑出普通方法,进行调用

for(Element testmethod : clz.getEnclosedElements()){ 
   if(!testmethod.getSimpleName().toString().equals("<init>") && testmethod.asType() instanceof ExecutableType){ //add test method
      newFileStr += "clz."+testmethod.getSimpleName()+"();\n";
   }
}

程序片段中,clz.getEnclosedElements()用来找出类里面包含的elements,包括了构造函数,方法,属性定义等等。我们简化处理,只是派出了构造函数。

程序字符串准备好之后,就直接写文件:

JavaFileObject jfo = processingEnv.getFiler().createSourceFile(ut.prefix() + clz.getSimpleName(), clz);
Writer writer = jfo.openWriter();
writer.append(newFileStr);

 

注:

自定义annotation不困难。不过在build的时候要费一点功夫。因为这个是影响编译的行为,所以要向编译器javac声明:

先编译好UnitTestProcessor,然后

javac -processor UnitTestProcessor Tool.java

 

如果是IDE环境,就要进行配置,如在eclipse中,要在Project Properties ->Java Compiler->Annotation Processing中启用annotation processing,并在Factory Path中把上述编译好的处理器Jar包登记进来,并选择run这个jar包中包含的哪个processor.

因此,我们就要先打一个jar包。新建一个处理器工程,包含UnitTest.java, UnitTestProcessor.java, 还要准备一个meta文件,在工程的resources目录下建一个文件:META-INF/services/javax.annotation.processing.Processor,文件中写上处理器的名字:UnitTestProcessor。

这个独立的jar包的作用就是为客户程序员自动生成测试类代码,这是一个有用的工具包。

有了这个jar包之后,在客户工程里面引入即可。

Build项目的时候,自动生成了测试类,代码如下(TestTool.java):

 

public class TestTool {
public TestTool() {
}
public static void main(String[] args) {
Tool clz = new Tool();
clz.check();
}
}

  大功告成。到此,我们就能看到,用了Annotation技术,我们能做很多非平凡的工作。自己写工具,写框架,都会有一些头绪。

  作为学习者,最先了解的是Annotation的概念,学习使用现成的annotation,这是第一步;接下来就要自己写RUNTIME类型的annotation,实现一些框架的效果;进一步就是自己写SOURCE类型的annotation,提供各种源代码级别的工具。学习的进路,就这么一步步深入下去。掌握了后,就有拨开丛林,见到本尊的愉悦,一种获得知识的愉悦感。耳边总是传来“不要重新造轮子”的声音,但是,对于学习者,就应该重新造轮子。只有在学习重新造轮子的过程中,我们才能更加深刻理解技术概念。

posted @ 2020-04-01 16:25  码农的进击  阅读(767)  评论(0编辑  收藏  举报