Java架构师交流群:793825326
java版本:jdk1.8
IDE:idea2019
先说怎么用,现在我们想写一个注解HelloWorld,让所有使用了这个注解的类,在编译的时候都打印“Hello World!”,注意,是在编译的时候,不是运行的时候。那么该怎么做呢,这就要用到AbstractProcessor这个东西了。
1.先创建一个maven项目abstractprocessor,在这个项目下创建两个子module,processor和compiletest,如图:
2.在processor里面添加一个类MyProcessor:
import com.google.auto.service.AutoService;
import javax.annotation.processing.*;
import javax.lang.model.SourceVersion;
import javax.lang.model.element.TypeElement;
import javax.tools.Diagnostic;
import java.util.Set;
@SupportedAnnotationTypes("HelloWorld")
@SupportedSourceVersion(SourceVersion.RELEASE_7)
@AutoService(Processor.class)
public class MyProcessor extends AbstractProcessor {
@Override
public boolean process(Set<? extends TypeElement> annotations, RoundEnvironment roundEnv) {
processingEnv.getMessager().printMessage(Diagnostic.Kind.NOTE, "Hello World!");
return false;
}
}
其中AutoService是Google开发用来生成META-INF/services/javax.annotation.processing.Processor文件的,有了它,我们就不用自己手动添加这个文件了。要使用它,需要在pom里面添加依赖:
<dependency>
<groupId>com.google.auto.service</groupId>
<artifactId>auto-service</artifactId>
<version>1.0-rc5</version>
</dependency>
3.然后写一个注解HelloWorld:
import java.lang.annotation.Retention;
import java.lang.annotation.RetentionPolicy;
@Retention(RetentionPolicy.SOURCE)
public @interface HelloWorld {
}
至此,processor项目目录如下:
4.使用maven生成一下,这样,我们的HelloWorld注解就实现了,现在就是如何使用它了,很简单,像使用普通的注解一样。在compiletest这个项目里面添加引用:
<dependency>
<groupId>AbstractProcessorDemo</groupId>
<artifactId>processor</artifactId>
<version>1.0-SNAPSHOT</version>
</dependency>
5.在compiletest的启动类上添加注解HelloWorld:
@HelloWorld
public class app {
public static void main(String[] args) {
System.out.println("HelloWorld");
}
}
6.点击生成compiletest,不需要运行起来,就可以看到编译信息里面打印出了Hello World!:
这里需要注意一点,HelloWorld注解所在的项目要和使用它的项目分开,就像我们在这里的例子的做法一样。这里面我们可以利用这个功能,在一个项目编译期间打印出一些编译信息。
前面我们实现了在编译期间打印信息,现在我们实现在编译期间,修改源码,让你看到的源码,和最终生成的class文件里面的代码不一样。就像之前我们说过的一个插件lombok的做法一样,不知道lombok的话可以看下我之前的一篇博文简单了解下lombok插件的使用和原理分析_dap769815768的博客-CSDN博客_lombok插件原理。
接下来要实现的功能是这样的,实现一个HelloWorld注解,所有的加了这个注解的方法内部,在编译时都会自动加一句代码:
System.out.println("Hello, world!!!");
下面我讲一下具体实现方法:
1.在一个单独的Module(processor)里面创建一个注解HelloWorld:
@Target({ElementType.METHOD})
@Retention(RetentionPolicy.SOURCE)
public @interface HelloWorld {
}
2.创建一个MyProcessor继承自AbstractProcessor:
import com.google.auto.service.AutoService;
import com.sun.tools.javac.model.JavacElements;
import com.sun.tools.javac.processing.JavacProcessingEnvironment;
import com.sun.tools.javac.tree.JCTree;
import com.sun.tools.javac.tree.TreeMaker;
import com.sun.tools.javac.util.Context;
import com.sun.tools.javac.util.List;
import javax.annotation.processing.*;
import javax.lang.model.SourceVersion;
import javax.lang.model.element.Element;
import javax.lang.model.element.TypeElement;
import java.util.Set;
@SupportedAnnotationTypes("HelloWorld")
@SupportedSourceVersion(SourceVersion.RELEASE_7)
@AutoService(Processor.class)
public class MyProcessor extends AbstractProcessor {
@Override
public boolean process(Set<? extends TypeElement> annotations, RoundEnvironment roundEnv) {
final Context context = ((JavacProcessingEnvironment) processingEnv).getContext();
final JavacElements elementUtils = (JavacElements) processingEnv.getElementUtils();
final TreeMaker treeMaker = TreeMaker.instance(context);
Set<? extends Element> elements = roundEnv.getRootElements();
for (Element element : roundEnv.getElementsAnnotatedWith(HelloWorld.class)) {
JCTree.JCMethodDecl jcMethodDecl = (JCTree.JCMethodDecl) elementUtils.getTree(element);
treeMaker.pos = jcMethodDecl.pos;
jcMethodDecl.body = treeMaker.Block(0, List.of(
treeMaker.Exec(
treeMaker.Apply(
List.<JCTree.JCExpression>nil(),
treeMaker.Select(
treeMaker.Select(
treeMaker.Ident(
elementUtils.getName("System")
),
elementUtils.getName("out")
),
elementUtils.getName("println")
),
List.<JCTree.JCExpression>of(
treeMaker.Literal("Hello, world!!!")
)
)
),
jcMethodDecl.body
));
}
return false;
}
}
这样,我们的HelloWorld注解插件就写好了,由于是maven项目,这里面引用了com.sun.tools的东西,所以,需要在maven的pom文件里面加上:
<dependency>
<groupId>com.sun</groupId>
<artifactId>tools</artifactId>
<version>1.8</version>
<scope>system</scope>
<systemPath>${java.home}/../lib/tools.jar</systemPath>
</dependency>
这样,在使用maven打包的时候,才不会报错。该module的目录如下:
3.使用maven打包好,写一个测试项目compiletest测试一下是否达到了预期,在pom里面添加引用:
<dependency>
<groupId>AbstractProcessorDemo</groupId>
<artifactId>processor</artifactId>
<version>1.0-SNAPSHOT</version>
</dependency>
4.在main方法上加上HelloWorld注解:
public class app {
@HelloWorld
public static void main(String[] args) {
}
}
最终的目录结构:
理论来讲,如果我们写的代码没问题,这个main方法编译后应该在方法体内多一句代码:System.out.println("Hello, world!!!");
5.使用maven打包一下测试项目,然后用反编译工具看一下结果,这里我用的反编译工具是jd-gui,打开后看到的源码如下:
运行一下测试程序,看到控制台打印结果:
也就是说,我们成功修改了编译后的字节码文件。下面,针对所涉及的技术点,具体讲解一下。
这里涉及到了javac API的东西,这块的东西文档不是很多,网上的资料也有限,所以我个人也不是很熟悉,这里只针对例子中用到的东西,简单解释一下。重点看一下MyProcessor这个类:
1.首先MyProcessor继承自AbstractProcessor这个抽象类,并且重写了process,所有的处理操作,基本都是在这个方法里面。另外这个类的两个注解
@SupportedAnnotationTypes("HelloWorld")
@SupportedSourceVersion(SourceVersion.RELEASE_7)
分别表示了这个类支持的注解类型和支持的版本。
2.在process方法里面,有一个参数roundEnv,根据这个参数,可以获取到相关的语法树的对象,比如这段代码
Context context = ((JavacProcessingEnvironment) processingEnv).getContext();
JavacElements elementUtils = (JavacElements) processingEnv.getElementUtils();
TreeMaker treeMaker = TreeMaker.instance(context);
3.然后遍历所有的注解了HelloWorld的元素,获取到元素里面的方法
JCTree.JCMethodDecl jcMethodDecl = (JCTree.JCMethodDecl) elementUtils.getTree(element);
4.jcMethodDecl.body即为方法体,利用treemaker的Block方法获取到一个新方法体,将原来的替换掉。就达到了修改方法体的目的了。这里的Block方法有两个参数,重点要关注的是第二个参数,也就是具体的方法体内容。它是一个List类型的参数,List里面每一个元素就代表一个语句块,比如,例子中有两块语句,第一块是我们织入的代码块:System.out.println("Hello, world!!!");用treeMaker.Exec()来实现,第二块是原来的代码块:jcMethodDecl.body。这块的代码是用户原本的代码,我们直接放进来就行。这个List是有顺序的,谁的顺序在前,谁最终生成的代码块就在前,比如这里我们织入的代码在原来的代码块之前,所以最终生成System.out.println("Hello, world!!!");语句就在该方法的第一行位置。
5.重点关注的应该是treeMaker.Exec()这个方法,这个方法帮助我们最终生成了System.out.println("Hello, world!!!");这条语句,它的参数是treeMaker.Apply这个方法的返回结果,这个方法的第二个参数,也就是最终实现了输出System.out.println("Hello, world!!!")的东西。
6.我们知道System.out.println的完整写法是java.lang.System.out.println,如果我们想输出这个完整写法该怎么写呢,参考如下代码:
@SupportedAnnotationTypes("HelloWorld")
@SupportedSourceVersion(SourceVersion.RELEASE_7)
@AutoService(Processor.class)
public class MyProcessor extends AbstractProcessor {
@Override
public boolean process(Set<? extends TypeElement> annotations, RoundEnvironment roundEnv) {
Context context = ((JavacProcessingEnvironment) processingEnv).getContext();
JavacElements elementUtils = (JavacElements) processingEnv.getElementUtils();
TreeMaker treeMaker = TreeMaker.instance(context);
for (Element element : roundEnv.getElementsAnnotatedWith(HelloWorld.class)) {
JCTree.JCMethodDecl jcMethodDecl = (JCTree.JCMethodDecl) elementUtils.getTree(element);
treeMaker.pos = jcMethodDecl.pos;
jcMethodDecl.body = treeMaker.Block(0, List.of(
treeMaker.Exec(
treeMaker.Apply(
List.<JCTree.JCExpression>nil(),
treeMaker.Select(
treeMaker.Select(
treeMaker.Ident(
elementUtils.getName("System")
),
elementUtils.getName("out")
),
elementUtils.getName("println")
),
List.<JCTree.JCExpression>of(
treeMaker.Literal("Hello, world!!!")
)
)
),
jcMethodDecl.body,
treeMaker.Exec(
treeMaker.Apply(
List.<JCTree.JCExpression>nil(),
treeMaker.Select(
treeMaker.Select(
treeMaker.Select(
treeMaker.Select(
treeMaker.Ident(elementUtils.getName("java")),
elementUtils.getName("lang")
),
elementUtils.getName("System")
),
elementUtils.getName("out")
),
elementUtils.getName("println")
),
List.<JCTree.JCExpression>of(
treeMaker.Literal("Hello, world!!!")
)
)
)
));
}
return false;
}
}
这里我在原本的方法体前后各插入了一句打印"Hello, world!!!"的代码,区别就是一个用完整写法,一个是不完整的,它最终生成的代码如下:
其源码为:
这里之所以最终生成的两句完全一样,是因为编译器编译之后会把java.lang给省略掉,这个是编译器优化所致。比对这两个写法的区别你会发现,这里面用到了两个方法,一个是treeMaker.Select(生成具体的方法),一个是treeMaker.Literal(方法的参数)。
7.treeMaker.Select里面套了很多层,对比两种写法的区别,你也能明白,这是为了写出多级方法的做法,多级方法的第一级以treeMaker.Ident开始,然后一层套一层,直到整个方法结束。
8.process方法最终返回的是false,这个返回值涉及到编译器的处理逻辑,返回true表示已经处理,返回false表示未被处理,也就是说后续还会有操作。这里用true合理,还是false合理,由于我目前对于便编译器的编译逻辑还不清楚,所以目前还没法给出确切的答案,当然,在此例子中,true和false看起来都没什么问题。
那么现在还剩下一个问题,如何debug,这里面的debug包括debug自己的processor和debug编译后的代码。
1.debug自己的processor方法,这里讲的方法是基于idea18的方法
a)idea右上角,Edit Configurations
b)点击左上角的+号,选择Remote
c)随便起一个名字,比如ProcessorDebug,如图,点击确定。
d)进入Terminal界面,输入mvnDebug clean install,回车,不出意外的话,会出现如图的提示
f)process里面打上断点,然后点击debug按钮,便命中了断点
2.Debug编译后的代码,使用maven,点击install之后,在target文件里面找到编译好的class文件app.class。
这里由于我之前创建的项目没有包,所以我改了一下,把包加了进去,和之前例子里面有些不同。打开这个app.class,看到的文件如下,这个是增强后的代码:
现在你只是能够看到增强后的代码,并没有办法调试它,虽然你可以在代码上打断点,但是这个断点并没有什么用处。如何debug它,我目前还不知道有什么方法,能想到的替代方法就是把这部分代码先拿出来替换掉源码,利用源码debug,debug通了,再还原回去。
换句话说,使用自动代码修改源码,大多数场景下,我们实现的都是很简单的代码,比如lombok给我们增强的代码都是很简单的,是没有必要debug的,如果你真的碰见需要debug的场景,那么你首先要考虑的是这个场景是否适合用AbstractProcessor来解决问题,如果确定适合又必须单步调试,那么就先按照我说的笨方法来解决吧。
至于其他的没有说到的部分,有兴趣的话可以自行在网上找些资料研究一下。后续针对这块,我还会深入探究。
补充
有很多人跟我反馈无法按照我的步骤实现代码织入,这里我把示例代码放上:GitHub - DaiAnpeng/AbstractProcessorDemo
我后期可能还会继续完善这个项目,争取对javac api做一次封装,让代码织入相对容易些,如果您有时间精力,欢迎您在我的基础上补充,在该项目上重新创建一个module就可以了。
我分析了一下无法成功织入代码的原因,大概可能是因为以下几个原因:
1.services文件没有正常生成,这个是关键,在打包的时候,看一下这个文件有没有正常生成
在我提供的示例代码中,生成的内容如下:
2.SupportedAnnotationTypes没有写完整的包名,我讲解的时候,项目里面没有使用包名,所以代码里面没有写包名,但是你创建的项目可能存在包名,这个时候你就需要写全了,参考我后来提供的示例代码: