Java自定义注解之编译期注解

Java自定义注解之编译时注解

java 自定义注解分为两种, 运行时注解和编译期注解。本篇文章主要讲的是编译期注解。先介绍下注解处理原理,JVM编译处理流程如下:

  1. 在命令行上指定的所有源文件被读取,解析为语法树,然后将所有外部可见定义输入到编译器的符号表中。
  2. 调用所有适当的注释处理器。如果任何注释处理器生成任何新的源或类文件,则重新启动编译,直到不创建新文件。
  3. 最后,解析器创建的语法树被分析并转换成类文件。在分析过程中,可能会发现对其他类的引用。编译器将检查这些类的源和类路径; 如果在源路径上找到这些文件,那么这些文件也将被编译,尽管它们不会被注释处理。

javac-flow

本篇文章引用源码默认为Java 8

处理流程详细介绍

Parse and Enter 阶段

源文件被scanner处理成unicode字符流,parser 读取字符流,使用TreeMaker构建语法树,语法树的节点都是JCTree的子类(这些子类都实现了com.sun.source.Tree的子接口)构建的。我们先看下包com.sun.source.tree

javac-tree

显然图中可以看出com.sun.source.tree包中的内容是java程序的语法节点。

com.sun.source.Tree 包位于 JAVA_HOME/lib/tools.jar 中,如需使用,要把tools.jar 加到项目的classpath中。com.sun.tools.javac.Main.compile() 是 javac的主入口。

每个生成的语法树都被传递给Enter(Enter 继承了JCTree及其子类的访问者类 visitor)。

            graph TD
            state(Enter.main)-->state0(Enter.classEnter)
state0(Enter.classEnter)-->|Enter.uncompleted|state1(MemberEnter1)
state1(MemberEnter1)-->|MemberEnter.halfcompleted|state2(MemberEnter2)
state2(MemberEnter2)-->|To Do|state3(Attribute and Generate)
          
  1. 在第一阶段,对所有类递归下降遍历tree及其成员类的tree。每个类都有一个 MemberEnter 对象作为处理结果返回。
  2. 将所有类中出现的符号输入到类自身的符号表中,所有类符号、类的参数类型符号(泛型参数类型)、超类符号和继承的接口类型符号等都存储到一个未处理的列表中。
  3. 将这个未处理的列表中所有的类都解析到各自的类符号列表中,这个操作是在MemberEnter.complete()中完成(默认构造器也是在这里完成的)。

代码关键流程如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
// 程序调试入口
public class App
{
public static void main( String[] args )
{
String[] optionsAndSources = {
"/test/Apple.java" // 此处应是绝对路径
};
com.sun.tools.javac.Main.compile(optionsAndSources,new PrintWriter(System.out));
}
}

// com.sun.tools.javac.main.JavaCompiler

public void compile(List<JavaFileObject> var1, List<String> var2, Iterable<? extends Processor> var3) {
// ...
// 1. 初始化编译期注解处理器
this.initProcessAnnotations(var3);
// 2. 执行注解处理
this.delegateCompiler = this.processAnnotations(
this.enterTrees( // 1.2 输入到符号表
this.stopIfError(CompileState.PARSE,
this.parseFiles(var1))) // 1.1 词法分析,语法分析
, var2);
this.delegateCompiler.compile2(); // 分析和字节码生成
// ...
}

private void compile2() {
// ...
case BY_TODO:
while(true) {
if (this.todo.isEmpty()) {
break label44;
}
this.generate( // 3.4 生成字节码
this.desugar( // 3.3 解语法糖
this.flow( // 3.2 流分析
this.attribute( // 3.1标注
(Env)this.todo.remove()))));
}
// ...
}

词法分析主要借助com.sun.tools.javac.parser包,语法分析主要借助了com.sun.tools.javac.tree包。代码调用栈如下:

1
2
3
4
5
JavaCompiler.parseFiles
-> JavaCompiler.parse(JavaFileObject var1)
-> JavaCompiler.parse(JavaFileObject var1, CharSequence var2)
-> JavacParser.parseCompilationUnit()
-> com.sun.tools.javac.tree.TreeMaker

注解处理

这一部分主要由com.sun.tools.javac.processing.JavacProcessingEnvironment处理。注释处理是编译之前的一个初步步骤。该初步步骤包括一系列轮次,每个轮次用于解析和输入源文件,然后确定并调用任何适当的注释处理器。在初始回合之后,如果任何被调用的注释处理器生成任何需要作为最终编译的一部分的新源文件或类文件,则将执行后续轮次。最后,当完成所有必要的轮次后,将执行实际编译。

实际上在解析了要编译的文件且确定其包含的注解后,才知道是否需要执行注解处理。因此,为了避免在没有执行注释处理的情况下不必要地解析和输入源文件, JavacProcessingEnvironment与概念模型有细微的差异,同时仍然满足在实际编译之前整个注释处理发生的概念要求。

分析生成

一旦指定的所有文件都已被解析并输入到编译器的符号表中,并且在结束了所有的注释处理,JavaCompiler继续分析语法树以生成相应的类文件。此阶段包括类型检查、控制流分析、泛型的类型擦除、去除语法糖、字节码生成等操作。

  • com.sun.tools.javac.comp.Attr:检查语义的合理性并进行逻辑判断,类型是否匹配,是否初始化,泛型是否可推导,字符串常量合并
  • com.sun.tools.javac.comp.Check:协助attr,变量类型是否正确
  • com.sun.tools.javac.comp.Resolve:协助attr,变量方法类的访问是否合法,是否是静态变量
  • com.sun.tools.javac.comp.ConstFold:协助attr,常量折叠
  • com.sun.tools.javac.comp.Infer:协助attr,推导泛型
  • com.sun.tools.javac.comp.Flow: 数据流分析和替换等价源代码的分析(即上面的进一步语义分析)
  • com.sun.tools.javac.comp.TransTypes: 涉及泛型类型的代码将转换为没有泛型类型的代码
  • com.sun.tools.javac.comp.Lower: 处理语法糖”,Lower通过替换等效的,更简单的树来重写语法树以消除特定类型的子树
  • com.sun.tools.javac.jvm.Gen:遍历语法树生成最终的java字节码
  • com.sun.tools.javac.jvm.Items:辅助gen,这个类表示任何可寻址的操作项,这些操作项都可以作为一个单位出现在操作栈上
  • com.sun.tools.javac.jvm.Code:辅助gen,存储生成的字节码,并提供一些能够影射操作码的方法

一旦类被写为类文件,其大部分语法树和生成的字节码将不再需要。为了节省内存,将清除对树的这些部分和符号的引用,以允许垃圾收集器恢复内存。

功能实现

既然是自定义注解,那么首先是定义一个注解:

1
2
3
4
@Target(ElementType.TYPE)
@Retention(RetentionPolicy.CLASS) // compile time annotation
public @interface ClassInfoPrinter {
}

@Retention(RetentionPolicy.CLASS)是必须的,这样才能在编译期获取注解信息。

接下来我们实现自定义的注解处理器:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
// 注解处理器支持的注解
@SupportedAnnotationTypes(
"info.victorchu.demos.annotation.ClassInfoPrinter")
@SupportedSourceVersion(SourceVersion.RELEASE_8)
public class ClassInfoPrinterProcessor extends AbstractProcessor {
@Override
public boolean process(Set<? extends TypeElement> annotations, RoundEnvironment roundEnv) {
// annotations 是注解处理器要处理的所有注解
// 此处只有 @ClassInfoPrinter
for (TypeElement annotation: annotations) {
for ( Element element : roundEnv.getElementsAnnotatedWith(annotation) ) {
processingEnv.getMessager().printMessage(Diagnostic.Kind.NOTE, "found @ClassInfoPrinter at " + element);
}
}
return false;
}
}

如何应用注解

annotation maven demos

annotation 可以和maven 结合使用:

1
2
3
4
5
6
7
8
9
10
11
12
<build>
<plugins>
<plugin>
<artifactId>maven-compiler-plugin</artifactId>
<configuration>
<annotationProcessors>
<annotationProcessor>info.victorchu.demos.processor.ClassInfoPrinterProcessor</annotationProcessor>
</annotationProcessors>
</configuration>
</plugin>
</plugins>
</build>

如果不使用maven,也可以通过java 提供的方式使用注解处理器。

  • 处理器本身和注解必须已经在单独的编译中作为类进行编译,并出现在类路径中,因此,应该做的第一件事是:
1
2
javac -proc:none info/victorchu/demos/processor/ClassInfoPrinterProcessor.java
javac -proc:none info/victorchu/demos/processor/ClassInfoPrinter.java

-proc:none 选项的意思是停用所有注解处理器,可选

  • 然后,使用-processor 指定刚编译的注解处理器类,对源文件进行编译:
1
javac -processor info.victorchu.demos.processor.ClassInfoPrinterProcessor Person.java

要一次性指定多个注解处理器,可以用逗号分隔它们的类名,如下所示:

1
javac -processor package1.Processor1,package2.Processor2 SourceFile.java

如果不想在编译时指定,可以集合java 的SPI 机制,在META-INF/services/javax.annotation.processing.Processor文件中加上想要使用的注解处理器类名。

应用场景

依赖 JSR-269 开发的典型的第三方库有,代码自动生成的 Lombok 和 Google Auto,代码检查的 Checker 和 Google Error Prone,编译阶段完成依赖注入的 Google Dagger 2 等。

实际应用中,由于字节码的版本问题,创建新文件会比修改java 语法树的方式更安全一些。

抽象注解处理器

JDK 提供了易于扩展的抽象注解处理器:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
// javax.annotation.processing.AbstractProcessor
public abstract class AbstractProcessor implements Processor {
// 上下文环境
protected ProcessingEnvironment processingEnv;
private boolean initialized = false;

protected AbstractProcessor() {}
// 处理器支持的选项,可以通过SupportedOptions注解来快速设置
public Set<String> getSupportedOptions() {
SupportedOptions so = this.getClass().getAnnotation(SupportedOptions.class);
if (so == null)
return Collections.emptySet();
else
return arrayToSet(so.value());
}
// 处理器支持的注解类型,可以通过 SupportedAnnotationTypes注解来快速设置
public Set<String> getSupportedAnnotationTypes() {
SupportedAnnotationTypes sat = this.getClass().getAnnotation(SupportedAnnotationTypes.class);
if (sat == null) {
if (isInitialized())
processingEnv.getMessager().printMessage(Diagnostic.Kind.WARNING,
"No SupportedAnnotationTypes annotation " +
"found on " + this.getClass().getName() +
", returning an empty set.");
return Collections.emptySet();
}
else
return arrayToSet(sat.value());
}
// 处理器支持的源码版本,可以通过注解SupportedSourceVersion快速设置,默认1.6
public SourceVersion getSupportedSourceVersion() {
SupportedSourceVersion ssv = this.getClass().getAnnotation(SupportedSourceVersion.class);
SourceVersion sv = null;
if (ssv == null) {
sv = SourceVersion.RELEASE_6;
if (isInitialized())
processingEnv.getMessager().printMessage(Diagnostic.Kind.WARNING,
"No SupportedSourceVersion annotation " +
"found on " + this.getClass().getName() +
", returning " + sv + ".");
} else
sv = ssv.value();
return sv;
}
// 处理器生命周期方法:初始化。ProcessingEnvironment提供了很多工具接口实现
public synchronized void init(ProcessingEnvironment processingEnv) {
if (initialized)
throw new IllegalStateException("Cannot call init more than once.");
Objects.requireNonNull(processingEnv, "Tool provided null ProcessingEnvironment");

this.processingEnv = processingEnv;
initialized = true;
}
// 处理器业务逻辑,需子类实现
public abstract boolean process(Set<? extends TypeElement> annotations,
RoundEnvironment roundEnv);
// ... ...
}

要实现我们自己的注解处理器,需要继承AbstractProcessor,并实现process方法。

注解处理上下文 ProcessingEnvironment

在实现process方法前,我们先了解下JDK 提供的工具类。ProcessingEnvironment 提供了编写新文件,报告错误信息和提供一些实用工具。Hotspot的实现类是com.sun.tools.javac.processing包中的JavacProcessingEnvironment。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
public interface ProcessingEnvironment {
/**
* 返回传递给注解处理器的特定选项。返回的结构是key-value对
* 如果option没有对应的值,那么value是 null。
* 不同的工具接收选项的方法不同,命令行可以通过在字符串前面加上"-A",来表示注解处理器的选项.
*/
Map<String,String> getOptions();

/**
* 返回用于报告信息的messager
*/
Messager getMessager();

/**
* 返回用于创建资源文件的filer
*/
Filer getFiler();

/**
* 返回element工具集合
*/
Elements getElementUtils();

/**
* 返回type工具集合
*/
Types getTypeUtils();

/**
* 返回源码版本,filer 创建的文件必须符合
*/
SourceVersion getSourceVersion();

/**
* 返回当前的语言.若无,为null
*/
Locale getLocale();
}

filer

filter是个接口,hotspot的实现类是com.sun.tools.javac.processing包中的JavacFiler。该接口支持注解处理器创建新文件。通过这种方式创建的文件会被实现类感知,并且能够更好地管理这些文件。一共有3种文件类型:源文件,类文件和辅助资源文件。注意。这样创建的文件(源文件,类文件)在写入流(write或outputStream) 被关闭后,才会被后面的注解处理器处理。辅助资源文件不会参见之后的注解处理。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
public interface Filer {
/* 创建一个新的源文件并返回一个对象以允许对其进行写入.可以创建类型的源文件或包.
* 文件的名称和路径(相对于源文件的根输出位置)基于要在该文件中声明的项目的名称以及该项目的指定模块(如果有)。
* 如果在单个文件(即,单个编译单元)中声明了多个类型,则文件名应与主体顶级类型的名称(例如,公共类型)相对应。
* 可以创建源文件来保存有关软件包的信息,包括软件包批注。要为命名包创建源文件,请将name参数作为包的名称,后跟".package-info";。要为未命名的软件包创建源文件,请使用"package-info"。
* 可选模块名称以类型名称或程序包名称为前缀,并使用"/"字符分隔。例如,要为a.B 模块中的类型 foo创建源文件,请使用name参数"foo/a.B"。
* 如果没有给出明确的模块前缀,并且环境中支持模块,则将推断出合适的模块。如果无法推断出合适的模块,则抛出错误FilerException。
*/
JavaFileObject createSourceFile(CharSequence name,
Element... originatingElements) throws IOException;
/*和createSourceFile 类似,但是该方法创建的是一个新的二进制类文件。
*/
JavaFileObject createClassFile(CharSequence name,
Element... originatingElements) throws IOException;
/*
* 创建一个新的辅助资源文件进行写入,并为其返回文件对象。
* 该文件可以与新创建的源文件,新创建的二进制文件或其他受支持的位置一起放置,条件是 CLASS_OUTPUT或SOURCE_OUTPUT必须得到支持。
* 可以相对于某个模块和/或程序包(如源文件和类文件)来命名资源,并以相对路径名命名。在广义上说,新文件的全名是串联的location, moduleAndPkg和relativeName。
* 如果moduleAndPkg包含“ /”字符,则“/”字符之前的前缀是模块名称,而“/”字符之后的后缀是程序包名称。软件包后缀可能为空。
* 如果moduleAndPkg 不包含“/字符,整个参数将解释为程序包名称。
*/
FileObject createResource(JavaFileManager.Location location,
CharSequence pkg,
CharSequence relativeName,
Element... originatingElements) throws IOException;
/* 返回用于读取现有资源的对象
*/
FileObject getResource(JavaFileManager.Location location,
CharSequence pkg,
CharSequence relativeName) throws IOException;
}

element 和type涉及JDK对Java 编程语言的建模,将在后面的blog中详细介绍。


参考资料:

  • jsr-269, 注解处理器
  • compilation overview
-------------本文结束感谢您的阅读-------------
坚持分享,您的支持将鼓励我继续创作!
0%