一起养成写作习惯!这是我参与「掘金日新计划 · 4 月更文挑战」的第1天,点击查看活动详情
1. 简介
注解把元数据与源代码文件结合在一起,它们可以用于描述由源代码无法表达的信息。
注解的使用并不是必须的,但是它减轻了样板代码、提高了代码可读性,这些优势在无注解编程中是无法体验到, Spring
、Hibernate
等流行框架在注解出现后都提供了相应的支持,而程序员的主流习惯也由原有的XML配置替换成基于注解配置。
使用注解需要明确三个问题: WHEN (何时生效)、WHERE(生效的位置)、HOW(如何生效)
-
WHEN:注解可作用于三个阶段,由注解定义中使用
@Retention
指定-
RetentionPolicy.RUNTIME
:该类注解作用在运行期,可通过反射机制获取注解信息 -
RetentionPolicy.SOURCE
:该类注解作用在编译期,可通过编译器 API 访问注解信息 -
RetentionPolicy.CLASS
:一般用于虚拟机加载字节码阶段,官方文档对于该注解是否作用于运行期比较隐晦,经实验无法通过反射机制获取注解信息Annotations are to be recorded in the class file by the compiler but need not be retained by the VM at run time.
-
-
WHERE: 注解可作用于类、接口、变量等上
-
HOW:被注解的元素与普通的元素在语法上来说无区别,注解需要配合对应的处理器使用
本文主要介绍编译期注解的使用。
2. 可插拔注解处理 API
在编译阶段处理注解,可以额外生成一些新的文件,如源文件、其它类型文件等。在生成完外部文件后,会连同其他源文件一同编译。目前的流行工具Lombok
、MapStruct
工具提供的注解都作用在编译阶段。
在JDK5
引入注解时,通过APT
工具在编译期处理注解,到JDK6
时引入JSR-269:Pluggable Annotation Processing API
(可插拔注解处理 API
)代替 APT
工具,而APT
工具在JDK7
时被废弃。
可插拔注解处理API
集成在编译阶段:
如上图,编译器(示例采用javac
编译器,下同)执行编译时,经过词法分析、填充符号表后对所有的注解选择合适的注解处理器。
局限性
可插拔注解处理 API
存在一定局限性,只能生成新文件而不能对已有的源码进行修改。
但是我们可以发现一些库提供的注解处理器却能实现类似修改已有的源文件的功能,如Lombok
的Getter
、Setter
、构造器等。这种修改已有文件超过了可插拔注解处理 API
提供的注解处理能力,它们是通过注解处理器作为引导机制将自身包含到编译过程中来调用编译器内部的 API
修改编译过程中产生的AST(Abstract Syntax Tree,抽象语法树)
。
3. 相关接口
3.1. 注解定义
注解定义需要将Retention
设置为RetentionPolicy.SOURCE
, 声明该注解可保留在源码期。
经编译器编译后,该注解会被丢弃,所以无法在运行期使用反射机制访问该类型的注解。
@Retention(RetentionPolicy.SOURCE)
@Target(ElementType.TYPE)
public @interface Setter {
}
3.2. 处理器接口
注解处理器接口为javax.annotation.processing.Processor
,自定义注解处理器需要实现该接口,Java
同时提供了一个抽象类javax.annotation.processing.AbstractProcessor
,该抽象类实现了Processor
接口部分方法,一般自定义处理器继承该抽象类即可。
AbstracProcessor
中提供了处理器执行上下文环境ProcessingEnvironment
,该上下文环境中定义了2个重要的接口:Filer
、Messager
。
其中Filer
用于访问本地文件,如读取源文件、生成新文件都需要依赖该接口;Messager
用于输出编译过程的日志消息,虽然通过System.out.println
也可以输出日志到控制台,但是Messager
消息输出包含了源文件中的信息,各IDE
对该类消息都提供了很好的支持。
// 创建一个写源文件的流
JavaFileObject mapperFile = processingEnv.getFiler().createSourceFile(mapperSimpleClassName);
PrintWriter out = new PrintWriter(mapperFile.openWriter())
// 输出编译错误信息,会终止当前编译过程
processingEnv.getMessager().printMessage(Diagnostic.Kind.ERROR, "@Mapper 注解的属性 "+ name +" 必须包含getter方法", element);
编译失败时,IDEA
自动定位到导致错误的位置:
继承抽象类AbstractProcessor
, 需要实现唯一的方法process
, 该方法签名如下:
public boolean process(Set<? extends TypeElement> annotations, RoundEnvironment roundEnv)
方法中包含2个参数:
-
Set<? extends TypeElement> annotations
: 本轮中需要处理的注解集合 -
RoundEnvironment roundEnv
: 包含有关当前处理轮次的环境信息如果注解处理器运行
process()
方法产生了新的.java
源文件,javac
会重新运行一轮注解处理器,直到没有新文件的产生。每调用一次process()
方法,就称为一个Round
。
4. 注解处理器
根据可插拔注解处理 API
的局限性,分以下两种情况讨论编译期的处理器:
- 基础使用:生成新文件
- 调用编译器
API
:增强已有功能
4.1. 基础使用
假设实现类似于Mapstruct
库将Entity
自动转换为DTO
的注解能力。
4.1.1. 定义注解
@Target(ElementType.FIELD)
@Retention(RetentionPolicy.SOURCE)
public @interface Mapper {
/**
* 指定dto属性名称
* @return
*/
String target() default "";
}
定义@Mapper
注解作用在域上,如果DTO
的域名与Entity
的域名不一致时,则通过 target
指定新的名称。
4.1.2. 使用方式
public class User {
@Mapper
private String id;
@Mapper(target = "userName")
private String name;
public String getId() {
return this.id;
}
public String getName() {
return this.name;
}
public void setId(String id) {
this.id = id;
}
public void setName(String name) {
this.name = name;
}
}
4.1.3. 注解处理器
处理器代码片段如下:
@SupportedSourceVersion(SourceVersion.RELEASE_8)
@SupportedAnnotationTypes("com.song.annotation.Mapper")
public class MapperProcessor extends AbstractProcessor {
@Override public boolean process(Set<? extends TypeElement> annotations, RoundEnvironment roundEnv) {
// 编译时,输出日志消息,该类消息输出可以被IDE支持,定位具体位置
Messager messager = processingEnv.getMessager();
// 找到被Mapper注解的元素
Set<? extends Element> annotatedElements = roundEnv.getElementsAnnotatedWith(Mapper.class);
messager.printMessage(Diagnostic.Kind.NOTE, "开始处理 @Mapper");
Map<String, List<Element>> class2EleMap = new HashMap<>();
for (Element element : annotatedElements) {
// 元素名称
String name = element.getSimpleName().toString();
// 获取当前元素的封闭元素,即父元素
// 父元素即为 class
Element classElement = element.getEnclosingElement();
TypeElement classTypeElement = (TypeElement)classElement;
// 全限定名称
String className = classTypeElement.getQualifiedName().toString();
// 获取当前元素的子元素
// 子元素包含当前class下定义的元素,如构造器、属性、方法等
List<? extends Element> enclosedElements = classElement.getEnclosedElements();
// 查找当前属性的getter
String getterMethod = getterMethodName(name);
// 是否包含getter方法
boolean hasContainGetterMethod = enclosedElements.stream().anyMatch(e -> e.getSimpleName().toString().equals(getterMethod));
if (!hasContainGetterMethod) {
messager.printMessage(Diagnostic.Kind.ERROR, "@Mapper 注解的属性 "+ name +" 必须包含getter方法", element);
}
List<Element> elements = class2EleMap.computeIfAbsent(className, k -> new ArrayList<Element>());
elements.add(element);
}
try {
writeMapperFile(class2EleMap);
} catch (IOException e) {
e.printStackTrace();
}
return true;
}
/**
* 生成DTO源代码文件
* @param class2EleMap
* @throws IOException
*/
private void writeMapperFile(Map<String, List<Element>> class2EleMap) throws IOException {
for (Map.Entry<String, List<Element>> entry : class2EleMap.entrySet()) {
String className = entry.getKey();
List<Element> elements = entry.getValue();
String packageName = null;
int lastDot = className.lastIndexOf('.');
if (lastDot > 0) {
packageName = className.substring(0, lastDot);
}
String simpleClassName = className.substring(lastDot + 1);
String mapperClassName = className + "DTO";
String mapperSimpleClassName = mapperClassName.substring(lastDot + 1);
JavaFileObject mapperFile = processingEnv.getFiler().createSourceFile(mapperSimpleClassName);
// 写源文件
try (PrintWriter out = new PrintWriter(mapperFile.openWriter())) {
if (packageName != null) {
out.print("package ");
out.print(packageName);
out.println(";");
out.println();
}
out.print(" public class ");
out.print(mapperSimpleClassName);
out.println(" {");
out.println();
// 省略部分实现
createSetterGetterMethod(elements, out, false);
createSetterGetterMethod(elements, out, true);
out.println("}");
}
}
}
// 省略部分实现
}
代码说明:
- 处理器上的注解
@SupportedAnnotationTypes
: 当前处理的注解,需要注意编译期的处理器未被虚拟机加载,获取不到.class
信息,只能通过全限定名字符串指定。@SupportedSourceVersion
: 当前处理器支持的 Java 版本, 只有符合要求时,才会被编译器识别。
- 在编译期
Element
用来表示类、方法、域等元素,是所有元素的基础接口element.getEnclosingElement()
获取当前元素的上级元素,示例中获取的域所在的类信息classElement.getEnclosedElements()
获取当前元素的下级元素,示例中在类上获取当前类下的直接元素,如域、方法等
writeMapperFile()
方法用来通过当前的元素信息,拼接目标源码字符串并写入到源文件中
4.1.4. 注册处理器
注册注解处理器有3种方式:
- 在命令行使用
Javac
命令时添加注解处理器编译参数
通过参数指定所需要使用的处理器,多个处理器之间使用,
分隔。
javac -processor package1.Processor1,package2.Processor2 Target.java
需要注意的是,在编译目标代码之前需要先编译依赖的注解及处理器代码。
如示例中的编译步骤:
// 当前环境:运行在Windows环境下,注解、处理器、验证代码在同一目录下
// 0. 创建类路径,设置为 D:\test
process> md D:\test
// 1. 编译注解@Mapper
process> javac -encoding UTF-8 -d D:\test Mapper.java
// 2. 编译处理器MapperProcessor
process> javac -encoding UTF-8 -classpath D:\test -d D:\test MapperProcessor.java
// 3. 编译目标源文件User
process>javac -encoding UTF-8 -classpath D:\test -d D:\test -processor com.song.annotation.MapperProcessor User.java
上述代码中第3步为核心步骤,依赖于第1、2步编译后的结果,所以需要增加-classpath
参数指定当前编译的类路径,找到相关依赖。同时指定-processor
找到注解相关的处理器。
编译后产生的文件:
编译后生成一个源文件及对应的类文件。
在编译过程中该注解处理器会进行3轮(round),即调用3次BuilderProcessor
类的process()
方法。第1轮,从User.java
中查找搜索注解,找到@Mapper
注解,通过处理器生成UserDTO.java
文件。第2轮,从UserDTO.java
中搜索注解,该过程未产生新文件。第3轮,无新文件可以搜索,退出处理器。
- 使用
Maven
编译
使用Maven
编译目标源文件,也需要对依赖的注解及注解处理器提前编译,需要将注解、注解处理器与目标源文件分成多个模块以实现更加细粒度的编译控制。
假设将注解、注解处理器分为annotation-define
模块,目标源文件分为annotation-use
模块。 在annotation-use
模块的pom
文件中添加编译配置,如下:
<build>
<plugins>
<plugin>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-compiler-plugin</artifactId>
<version>3.5.1</version>
<configuration>
<source>1.8</source>
<target>1.8</target>
<encoding>UTF-8</encoding>
<generatedSourcesDirectory>
${project.build.directory}/generated-sources/
</generatedSourcesDirectory>
<annotationProcessors>
<annotationProcessor>
com.song.annotation.MapperProcessor
</annotationProcessor>
</annotationProcessors>
</configuration>
</plugin>
</plugins>
</build>
其中generatedSourcesDirectory
指定生成目标文件存放的目录。annotationProcessors
指定当前编译所使用的注解处理器。
当前示例环境为一个父级模块聚合2个子模块:annotation-processing
(注解定义及注解处理器)、annotation-user
(定义使用注解的模块)。
编译后生成的文件:
- 在使用注解的模块配置处理器
通过在模块META-INF/services/javax.annotation.processing.Processor
文件中指定处理器,编译时将读取该文件中列举的处理器。处理器需要写出全限定类名。如下:
com.song.annotation.MapperProcessor
otherpackage1.Processor1
示例配置如下:
编译效果与Maven
配置效果一致。
4.1.5. 小技巧
- 每个处理器都需要在
javax.annotation.processing.Processor
增加一行配置,在实际开发过程中,容易因疏漏导致配置与实际处理器不一致。
Google
提供了一个工具库auto-service可以自动生成处理器对应的元文件信息,省去手动配置的烦恼。
在注解处理器上增加@AutoService(Processor.class)
注解。
@SupportedAnnotationTypes("com.song.annotation.Mapper")
@SupportedSourceVersion(SourceVersion.RELEASE_8)
@AutoService(Processor.class)
public class MapperProcessor extends AbstractProcessor {
}
编译时,会自动生成META-INF/services/javax.annotation.processing.Processor
文件并将当前处理器信息写入到该文件中。
- 生成源代码时,需要手动每行代码调用
out.print
输出,工作量较大且易出错,可采用javapoet
库,链式命名式调用。
MethodSpec main = MethodSpec.methodBuilder("main")
.addModifiers(Modifier.PUBLIC, Modifier.STATIC)
.returns(void.class)
.addParameter(String[].class, "args")
.addStatement("$T.out.println($S)", System.class, "Hello, JavaPoet!")
.build();
TypeSpec helloWorld = TypeSpec.classBuilder("HelloWorld")
.addModifiers(Modifier.PUBLIC, Modifier.FINAL)
.addMethod(main)
.build();
JavaFile javaFile = JavaFile.builder("com.example.helloworld", helloWorld)
.build();
javaFile.writeTo(System.out);
4.2. 调用编译器API
4.2.1. 背景知识
该部分涉及编译器部分知识,需要对Java
语言规范及编译过程有一定了解。
Java
语言规范中定义了各种结构的文法,JDK
中的tool.jar
工具提供了规范中文法的操作实现。
-
编译步骤
javac
编译器 将.java
源代码转化为.class
字节码过程会涉及以下几个步骤:图4-4.编译步骤
-
(1)
Scan
:进行词法分析,由Scanner
读取原文件中字符,根据Java
关键字、符号或自定义符号结构化输出Token
流 -
(2)
Parse
:由Parser
读取Token
流进行语法分析,根据Java
语言规范分析Token
序列建立Token
之间的关系,生成抽象语法树 -
(3)
Enter
:将所有类及其依赖的类符号输入到符号表中 -
(4)
Annotation Processing
:该阶段进行注解处理 -
(5)
Analyse
:检测语义合法性,并简化语法,生成标注语法树。泛型擦除、去除语法糖、变量合法赋值都在该阶段进行 -
(6)
Generate
:按照JVM规范
,生成Java
字节码
从源文件生成最终
.class
文件需要经过以上6个步骤,处理注解时可访问前面步骤的产物,如Parse
阶段生成的抽象语法树,处理器通过修改语法树来增强已有功能。 -
-
编译相关接口
-
JCTree
抽象语法树中的每个节点都是
JCTree
的一个实例,每个节点的实现类直接或间接继承了JCTree
,且都是JCTree
的内部类,阅读JCTree
源码可查看所有语法树节点实现。JCTree
中关键实现类:(1)
JCStatement
:语句节点抽象类,如 CLASS、 IF 、SWITCH 、TRY-CATCH等语句节点实现类都继承该抽象类JCClassDecl
:类定义节点实现类JCVariableDecl
:变量定义节点实现类,包含成员变量和局部变量JCReturn
:返回语句实现类JCBlock
:块定义实现类,每一对花括号"{}"范围内的都是一个JCBlock
对象(例外:类的花括号范围为JCClassDecl
)
(2)
JCMethodDecl
:方法定义节点实现类(3)
JCExpression
:表达式节点抽象类,一个复杂表达式由基本表达式和运算符构成,如二元运算、赋值等实现类都继承该抽象类JCIdent
:标识符节点实现类JCFieldAccess
:域访问节点实现类JCAssign
:赋值节点
(4)
JCModifiers
:修饰符节点实现类,如public
、private
等。 -
TreeMaker
TreeMarker
工厂提供创建语法树的所有操作,语法树节点构造文法参考Java语言规范
。(1)
treeMaker.Ident
:构建指定域名的标识符节点(2)
treeMaker.Select
:创建域访问表达式节点(3)
treeMaker.Return
:创建retrun
语句(4)
treeMaker.Block
:构建块节点(5)
treeMaker.Modifiers
:创建修饰符节点(6)
treeMaker.MethodDef
:创建方法节点 -
节点访问:访问者模式
javac
编译器使用访问者模式将数据结构和数据操作解耦。用户可通过重写访问者的方法实现对语法树的操作。我们知道JCTree
为所有节点的基类,且JCTree
中定义了接受访问者的抽象方法。public abstract void accept(JCTree.Visitor var1);
这样我们就可以通过注入访问者对各类节点进行操作。
-
4.2.2. 定义注解
假设需要实现类似Lombok
的@Getter
功能,自动为域生成getter
方法。
@Retention(RetentionPolicy.SOURCE)
@Target(ElementType.TYPE)
public @interface Setter {
}
定义@Getter
注解作用在类上。
4.2.3. 使用方式
@Setter
public class Animal {
private String name;
private String action;
}
在Animal
类上使用@Setter
注解,对每个域生成Setter
方法。
4.2.4. 注解处理器
JDK8
版本及之前编译器工具定义在rt.jar
中,该包默认在jdk
中,使用编译器 API 需要将该工具库加入到类路径中。
引入依赖
<dependency>
<groupId>com.sun</groupId>
<artifactId>jar</artifactId>
<version>1.0</version>
<scope>system</scope>
<systemPath>${java.home}/../lib/tools.jar</systemPath>
</dependency>
需要注意的是,在JDK9
时引入模块化概念,移除了rt.jar
工具包。
代码片段
setter
方法节点树如下:
处理器需要在编译过程中构造一个方法节点,并将该节点挂在原类的节点树上,这样就实现了对源码的增强。
@SupportedAnnotationTypes("com.song.annotation.Setter")
@SupportedSourceVersion(SourceVersion.RELEASE_8)
@AutoService(Processor.class)
public class SetterProcessor extends AbstractProcessor {
/**
* 语法树
*/
private JavacTrees trees;
/**
* 操作树工厂
*/
private TreeMaker treeMaker;
/**
* 标识符存储表示,如java关键字、标识符、用户自定义标识符(类名、方法名)
*/
private Names names;
@Override public synchronized void init(ProcessingEnvironment processingEnv) {
super.init(processingEnv);
this.trees = JavacTrees.instance(processingEnv);
Context context = ((JavacProcessingEnvironment)processingEnv).getContext();
this.treeMaker = TreeMaker.instance(context);
this.names = Names.instance(context);
}
@Override public boolean process(Set<? extends TypeElement> annotations, RoundEnvironment roundEnv) {
Messager messager = processingEnv.getMessager();
messager.printMessage(Diagnostic.Kind.NOTE, "开始新一轮注解处理");
// 找出 @Setter注解
Set<? extends Element> setterElements = roundEnv.getElementsAnnotatedWith(Setter.class);
// 创建访问者
TreeTranslator treeTranslator = createSetterVisitor();
for(Element element : setterElements){
JCTree jcTree = trees.getTree(element);
// 注入访问者操作
jcTree.accept(treeTranslator);
}
return true;
}
/**
* 创建用于@Setter注解处理的访问者
* <pre>
* TreeTranslator: Visitor实现类,提供对各类节点的基础实现,自定义操作只需要覆盖直接类型节点的方法即可
* </pre>
* @return 访问者
*/
private TreeTranslator createSetterVisitor() {
TreeTranslator treeTranslator = new TreeTranslator() {
// @Setter注解定义在类上,所以只需要重写类定义语法节点操作
@Override public void visitClassDef(JCTree.JCClassDecl jcClassDecl) {
// 当前类定义中的下级节点
for (JCTree tree : jcClassDecl.defs) {
// 过滤成员变量
if (!Tree.Kind.VARIABLE.equals(tree.getKind())) {
continue;
}
JCTree.JCVariableDecl jcVariableDecl = (JCTree.JCVariableDecl)tree;
// 创建setter方法树节点
JCTree.JCMethodDecl setterMethod = createSetterMethodTree(jcVariableDecl);
// 将setter节点挂到当前语法树上
jcClassDecl.defs = jcClassDecl.defs.prepend(setterMethod);
}
super.visitClassDef(jcClassDecl);
}
};
return treeTranslator;
}
/**
* 创建setter方法定义语法树节点
*
* <pre>
* 构建setter方法定义节点:
* public void setName(String name) {
* this.name = name;
* }
* </pre>
* @return
*/
private JCTree.JCMethodDecl createSetterMethodTree(JCTree.JCVariableDecl jcVariableDecl) {
// 语句
ListBuffer<JCTree.JCStatement> statements = new ListBuffer<>();
// 访问域名:thi.name
JCTree.JCIdent thiIdent = treeMaker.Ident(names.fromString("this"));
JCTree.JCFieldAccess jcFieldAccess = treeMaker.Select(thiIdent, jcVariableDecl.getName());
// 赋值 this.name = name
JCTree.JCIdent varIdent = treeMaker.Ident(jcVariableDecl.getName());
JCTree.JCAssign assign = treeMaker.Assign(jcFieldAccess, varIdent);
// 将表达式转化为语句
JCTree.JCExpressionStatement statement = treeMaker.Exec(assign);
statements.append(statement);
// 创建方法块
JCTree.JCBlock body = treeMaker.Block(0, statements.toList());
return treeMaker.MethodDef(treeMaker.Modifiers(Flags.PUBLIC),
setterMethodName(jcVariableDecl.getName().toString()), treeMaker.TypeIdent(TypeTag.VOID), List.<JCTree.JCTypeParameter>nil(),
List.<JCTree.JCVariableDecl>nil(), List.<JCTree.JCExpression>nil(),body, null
);
}
// 省略部分实现
}
代码说明:
-
相比较与基础使用,该处理器重写了父类中的
init
方法。 该方法的作用主要进行一些初始化操作,根据上下文调用编译器API
实例化语法树、操作树工厂、符号存储等。 -
createSetterVisitor()
方法创建一个访问者TreeTranslator
(Visitor
的实现类),对语法树的所有操作定义在该访问者内。- 基础使用中通过直接输出源码字符串的方式生成新文件, 调用编译器
API
中通过操作各个元素对应的节点来实现功能增强。 - 注解作用在类上,所以只需要重写
TreeTranslator
访问者的visitClassDef
类定义方法即可 - 在节点上调用
accept()
方法,将当前定义的访问者注入到节点中
- 基础使用中通过直接输出源码字符串的方式生成新文件, 调用编译器
-
目标是给前类中所有域生成
getter
方法,一般操作步骤为:-
(1)确定目标节点类型,当前为方法节点,即
JCMethodDecl
-
(2)对比
Java语言规范
中关于当前节点的文法定义,或treeMaker
中节点构造方法(treeMaker.MethodDef
) -
(3)最后递归从步骤(1)开始对每个依赖的节点类型进行构造,当前依赖节点类型
-
编译效果
注册处理器的方式与基础使用注册方式一致。
如上图,Animal
编译后生成了2个Setter
方法,效果符合预期。
4.2.5. 问题
IDE
自身会对源码进行索引并建立自身的语法树,自定义注解处理器修改语法树后,IDE
无法感知(示例中,在IDEA
中编译后依然无法调用生成的Setter
方法),Lombok
库会同时提供IDE
插件来解决这个问题。
5. 总结
编译期注解通过生成外部文件可简化测试、日志、事务等模板代码。
运行期注解和编译期注解使用场景不同,但相比于运行期注解,编译期注解处理可避免反射机制带来的性能损失。Mapstruct
提供在Entity
与DTO
之间的转换比较于BeanUtils.copyProperties
性能会好很多。如同时可在运行期和编译期处理的注解,应当将注解定义在编译期。
官方对于编译期注解定位用于生成额外的文件,可插拔注解处理API
虽然存在一定局限性,但是调用编译器API
注解处理方式场景也较少,尤其大多数IDE
并不支持这种操作,需要编写相应的插件,所以这类注解处理主要用来编写基础库。
参考
- OpenJDK Compilation Overview
- The Java® Language Specification
- 《 Java 核心技术第10版 》