java使用AbstractProcessor、编译时注解和JCTree实现自动修改class文件并实现Debug自己的Processor和编译后的代码

    xiaoxiao2022-07-05  177

    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的话可以看下我之前的一篇博文简单了解下https://blog.csdn.net/dap769815768/article/details/90370417。

    接下来要实现的功能是这样的,实现一个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来解决问题,如果确定适合又必须单步调试,那么就先按照我说的笨方法来解决吧。

    至于其他的没有说到的部分,有兴趣的话可以自行在网上找些资料研究一下。后续针对这块,我还会深入探究。

     

    最新回复(0)