0%

注解处理器

注解(annotation)是Java5引入的,用来为类、方法、字段、参数等Java结构提供额外信息的机制。

Java核心类库中的@Override注解是被用来声明某个实例方法重写了父类的同名参数类型的方法。

1
2
3
4
5
6
package java.lang;

@Target(ElementType.METHOD)
@Retention(RetentionPolicy.SOURCE)
public @interface Override {
}

@Override注解本身被另外两个元注解(即作用在注解上的注解)所标注。
@Target用来限定目标注解所能标注的Java结构。(这里@Override便只能被用来标注方法。)

@Retention则用来限定当前注解生命周期。注解共有三种不同的声明周期:SOURCE、CLASS、RUNTIME,分别表示注解只出现在源代码中、只出现在源代码和字节码中、以及出现在源代码和字节码及运行过程中。(这里@Override仅对Java编译器有用。它会为Java编译器引入一条新的编译规则:如果所标注的方法不是Java语言中的重写方法,那么编译器会报错。而当编译完成时,它的使命也就结束了。)

Java的注解机制允许开发人员自定义注解。这些自定义注解同样可以为Java编译器添加编译规则。不过这种功能需要由开发人员提供,并且以插件的形式接入Java编译器中,这些插件称之为注解处理器(annotation processor)。

除了引入新的编译规则之外,注解处理器还可以用于修改已有的Java源文件(不推荐),或者生成新的Java源文件。

注解处理器的原理

Java编译器的工作流程

Java源代码的编译过程可分为三个步骤:

  1. 将源文件解析为抽象语法树;
  2. 调用已注册的注解处理器;
  3. 生成字节码。

如果在第2步调用注解处理器过程中生成了新的源文件,那么编译器将重复第1、2步,解析并且处理新生成的源文件。每次重复称之为一轮(Round)。

第一轮解析、处理的是输入至编译器中的已有源文件。如果注解处理器生成了新的源文件,则开始第二轮、第三轮,解析并且处理这些新生成的源文件。当注解处理器不再生成新的源文件,编译进入最后一轮,并最终进入生成字节码的第3步。

1
2
3
4
5
6
7
public interface Processor {
void init(ProcessingEnvironment processingEnv);
Set<String> getSupportedAnnotationTypes();
SourceVersion getSupportedSourceVersion();
boolean process(Set<? extends TypeElement> annotations, RoundEnvironment roundEnv);
...
}

所有的注解处理器类都需要实现接口Processor。该接口主要有四个重要方法。

其中init方法用来存放注解处理器的初始化代码(不用构造器是因为在Java编译器中,注解处理器的实例是通过反射API生成的。因为使用反射API,每个注解处理器类都需要定义一个无参数构造器。)。通常来说,当编写注解处理器时,不声明任何构造器,并依赖于Java编译器,为之插入一个无参数构造器。而具体的初始化代码,则放入init方法之中。

getSupportedAnnotationTypes方法将返回注解处理器所支持的注解类型,这些注解类型只需用字符串形式表示即可。

getSupportedSourceVersion方法将返回该处理器所支持的Java版本,通常,这个版本需要与Java编译器版本保持一致。

process方法是最为关键的注解处理方法。

JDK提供了一个实现Processor接口的抽象类AbstractProcessor。该抽象类实现了init、getSupportedAnnotationTypes和getSupportedSourceVersion方法。

它的子类可以通过@SupportedAnnotationTypes和@SupportedSourceVersion注解来声明所支持的注解类型以及Java版本。


process方法接收两个参数,分别代表该注解处理器所能处理的注解类型,以及囊括当前轮生成的抽象语法树的RoundEnvironment。(该处理器针对的注解仅有@CheckGetter一个,而且并不会读取注解中的值,因此这里使用
roundEnv.getElementsAnnotatedWith(CheckGetter.class)来获取所有被@CheckGetter注解的类以及字段)

process方法涉及各种不同类型的Element,分别指代Java程序中的各个结构:
PackageElement指代包名,TypeElement指代类或者接口,VariableElement指代字段、局部变量、enum常量等,ExecutableElement指代方法或者构造器。

1
2
3
4
5
6
7
8
9
10
package foo;     // PackageElement

class Foo { // TypeElement
int a; // VariableElement
static int b; // VariableElement
Foo () {} // ExecutableElement
void setA ( // ExecutableElement
int newA // VariableElement
) {}
}

在将该注解处理器编译成class文件后,便可以将其注册为Java编译器的插件,并用来处理其他源代码。
注册的方式主要有两种:

  • 第一种是直接使用javac命令的-processor参数。

    $ javac -cp /CLASSPATH/TO/CheckGetterProcessor -processor bar.CheckGetterProcessor Foo.java

  • 第二种是将注解处理器编译生成的class文件压缩如jar包中,并在jar包的配置文件中记录该注解处理器的包名及类名。(bar.CheckGetterProcessor)

    (具体路径及配置文件名为META-INF/services/javax.annotation.processing.Processor

    当启动Java编译器时,它会寻找classpath路径上的jar包是否包含上述配置文件,并自动注册其中记录的注解处理器。

    $ javac -cp /PATH/TO/CheckGetterProcessor.jar Foo.java

    此外还可以在IDE中配置注解处理器。

利用注解处理器生成源代码

注解处理器可以用来修改已有源代码2或者生成源代码3。

注解处理器并不能真正的修改已有源代码2。这里的修改指的是修改由Java源代码生成的抽象语法树,在其中修改已有树节点或者插入新的树节点,从而使生成的字节码发生变化。(对抽象语法树的修改涉及了Java编译器的内部API,这部分很可能随着版本变更而失效,不推荐)

Project Lombok项目自定义了一系列注解,并根据注解的内容来修改已有的源代码。它提供了@Getter和@Setter注解,能够为程序自动添加getter以及setter方法。

用注解处理器来生成源代码比较常用。压力测试jcstress,以及JMH(Java Microbenchmark Harness)工具,都是依赖这种方式来生成测试代码的。

1
2
3
4
5
6
7
8
package foo;
import java.lang.annotation.*;

@Target(ElementType.METHOD)
@Retention(RetentionPolicy.SOURCE)
public @interface Adapt {
Class<?> value();
}

自定义注解Adapt,将接受一个Class类型的参数value(如果注解类仅包含一个名为value的参数时,那么在使用注解时,可以省略value=)。

1
2
3
4
5
6
7
8
9
10
11
//Bar.java
package test;
import java.util.function.IntBinaryOperator;
import foo.Adapt;

public class Bar {
@Adapt(IntBinaryOperator.class)
public static int add(int a, int b) {
return a+b;
}
}

实现处理@Adapt注解的处理器。该处理器将生成一个新的源文件,实现参数value所指定的接口,并且调用至被该注解所标注的方法之中。(该注解处理器没有处理所编译的代码包名为空的情况)

注解处理器实现中,将读取注解中的值,因此将使用process方法的第一个参数,并通过它获得被标注方法对应的@Adapt注解中的value值。(value值属于Class类型。在编译过程中,被编译代码中的Class常量未必被加载进Java编译器所在的虚拟机中。因此,需要通过process方法的第一个参数,获得value所指向的接口的抽象语法树,并据此生成源代码。)

生成源代码的方式非常容易理解。可以通过Filter.createSourceFile方法获得一个类似于文件的概念,并通过PrintWriter将 具体的内容一一写入即可。

当将注解处理器作为插件接入Java编译器时,编译前面的test/Bar.java将生成下述代码,并且触发新一轮的编译。

1
2
3
4
5
6
7
8
9
package test;
import java.util.function.IntBinaryoperator;

public class Bar_addAdapter implements IntBinaryOperator {
@Override
public int applyAsInt(int arg0, int arg1) {
return Bar.add(arg0, arg1);
}
}

总结

注解处理器主要有三个用途:

  • 一是定义编译规则,并检查被编译的源文件。
  • 二是修改已有源代码。
  • 三是生成新的源代码。

    第二种涉及了Java编译器的内部API,因此并不推荐。第三种较为常见,是OpenJDK工具jcstress,以及JMH生成测试代码的方式。

Java源代码的编译过程可分为三个步骤,分别为:

  • 解析源文件生成抽象语法树;
  • 调用已注册的注解处理器;
  • 生成字节码。

    如果在第2步中,注解处理器生成了新的源代码,那么Java编译器将重复第1、2步,直至不再生成新的源代码。