基于javac实现的编译时注解

2,239 阅读6分钟

很多同学都知道jdk中有一个很重要的jar : tools.jar,但是 很少有人知道这个包里面究竟有哪些好玩的东西。

javac入口及编译过程

在使用javac命令去编译源文件时,实际上是去执行com.sun.tools.javac.Main#main方法。而真正执行编译动作的,正是com.sun.tools.javac.main.JavaCompiler类。

javac的编译过程大致分如下几个阶段:

  • 解析与填充符号表处理过程。
  • 插入式注解处理的注解处理过程。
  • 分析与字节码生成过程。

上面几个过程画成图的话,就是下面这张(来自openjdk):

对应到代码中,就是上面提到的JavaCompiler类中的complie方法。

  /**
     * Main method: compile a list of files, return all compiled classes
     * ...
     */
    public void compile(Collection<JavaFileObject> sourceFileObjects,
                        Collection<String> classnames,
                        Iterable<? extends Processor> processors,
                        Collection<String> addModules)
    {
	...
    	   //准备过程:初始化插入式注解处理器
            initProcessAnnotations(processors, sourceFileObjects, classnames);
	...
            // These method calls must be chained to avoid memory leaks
            processAnnotations(		//过程2:执行注解处理
                enterTrees(			//过程1.2:输入到符号表
                        stopIfError(CompileState.PARSE,
                                initModules(stopIfError(CompileState.PARSE, 
                                parseFiles(sourceFileObjects))))	//过程1.1:词法分析、语法分析
                ),
                classnames
            );
	...
            switch (compilePolicy) {
	...
            case BY_TODO:	//过程3:分析及字节码生成
                while (!todo.isEmpty())
                    generate(	//过程3.4:生成字节码
                    desugar(	//过程3.3:解语法糖
                    flow(		//过程3.2:数据流分析
                    attribute(	//过程3.1:标注
                    todo.remove()))));
                break;
        ...
            }
        ...
    }

今天我们关注的,正是javac中的注解处理器。

插入式注解处理器(JSR-269)

在jdk5时,java提供了对注解的支持,但当时,这些注解与普通的java代码相同,只能在运行时发挥作用。

而在jdk6中实现了JSR-269规范,提供了插入式注解处理器的标准API在编译期间对注解进行处理。所以,他们更像是编译器插件,让我们可以读取、修改、添加抽象语法树中的任意元素。

如果这些插件在处理注解注解期间对语法树进行了修改,编译器将回到解析及填充符号表的过程重新处理,直到所有的插入式注解处理器都没有再对语法树进行修改为止。每一次循环称为一个Round。

AbstractProcessor

注解处理抽象类:javax.annotation.processing.AbstractProcessor

如果要实现一个注解处理器,就必须要继承AbstractProcessor类,其中的process()方法,就是javac编译器在执行注解处理器时要调用的过程。

    public abstract boolean process(Set<? extends TypeElement> annotations,
                                    RoundEnvironment roundEnv);

该方法的第一个参数:annotations,为此注解处理器所要处理的注解集合;第二个参数roundEnv,就是当前这个Round中的语法树节点。

具体的语法树节点可以参看枚举类:javax.lang.model.element.ElementKind,包括了java代码中最常用的元素。

  • PACKAGE
  • CLASS
  • LOCAL_VARIABLE
  • FIELD
  • ...

另外,在init方法中,传入了实例变量processingEnv,他代表了注解处理器框架提供的上下文环境,在创建代码,输出信息,获取工具类是都需要用到该实例变量。

那么,如何让代码在编译时执行到我们自己的注解处理器呢?

请看javac -help

$ javac -help
用法: javac <options> <source files>
其中, 可能的选项包括:
...
  -processor <class1>[,<class2>,<class3>...] 要运行的注释处理程序的名称; 绕过默认的搜索进程
  -processorpath <路径>        指定查找注释处理程序的位置
...

当然,不用每次编译的时候都辛苦带上这个参数,我们可以使用maven-compiler-plugin插件:

	<plugin>
                <artifactId>maven-compiler-plugin</artifactId>
                <executions>
                    <execution>
                        <id>default-compile</id>
                        <phase>compile</phase>
                        <goals>
                            <goal>compile</goal>
                        </goals>
                        <configuration>
                            <source>1.8</source>
                            <target>1.8</target>
                            <annotationProcessors>
				<annotationProcessor>xxx.xxx.xxx.xxx</annotationProcessor>
                            </annotationProcessors>
                        </configuration>
                    </execution>
                </executions>
            </plugin>

(com.google.auto.service.autoService的方式本文不再涉及,感兴趣的自行搜索)

具体实现

预期效果

使用编译时注解,实现在方法进入和方法退出时新增日志打印入参,出参功能。

预期效果入下图所示,左侧为java源码,右侧为编译后的class文件。

其中,红色箭头执行代码行即为编译时注解处理器生成代码。

使用方式

  1. 基于slf4j,在目标类中定义一个成员属性,创建出org.slf4j.Logger实例,属性名为:logger。
  2. 在需要新增打印日志的方法在加上自定义注解@AroundSlf4j

实现思路

这里只放出关键思路及一些主要的代码。

  1. 自定义一个注解@AroundSlf4j

  2. 在注解处理器中,获取到所有被该注解标注的元素,并过滤出其中类型为METHOD的元素。

  3. 找到该元素的“属主”,遍历其成员变量,找到类型为org.slf4j.Logger,且名字为logger的符号引用。

  4. 获取当前处理的METHOD元素名,及所在类名,以及其参数列表,拼接成日志打印格式。

  5. 生成调用logger.info方法JCTree节点,将其加入至METHOD节点列表中。

  6. 递归遍历当前方法所有执行路径,找出所有类型为RETURN的节点。

    6.1 根据RETURN语句的形式,创建出对应的调用logger.info方法JCTree节点。

    6.2 将节点加入至RETURN节点前一个位置。

  7. 结束

定义注解&注解处理器

注意,该注解仅存在于SOURCE级别,再往后放也没什么用。

@Target({ElementType.TYPE,ElementType.METHOD})
@Retention(RetentionPolicy.SOURCE)
public @interface AroundSlf4j {
}
@SupportedAnnotationTypes("AroundSlf4j")
@SupportedSourceVersion(SourceVersion.RELEASE_8)
public class AroundSlf4jProcessor extends AbstractProcessor {
...
}

获取目标METHOD元素

在注解处理器的process方法中,可以得到所有被@AroundSlf4j注解标记的JCTree节点。这里需要再过滤掉owner为interface类型的元素。

Set<? extends Element> elementsAnnotatedWith = roundEnv.getElementsAnnotatedWith(AroundSlf4j.class);
    elementsAnnotatedWith.forEach(ele->{
       if(ele.getKind() == ElementKind.METHOD && !((Symbol.MethodSymbol) ele).owner.isInterface()){
            	...
       }

获取类型指定类型、名称的成员属性符号引用

很简单,就是遍历该METHOD节点属主的全部成员属性,根据名字/类型比较。

 private Symbol.VarSymbol getAvailableFieldInMethod(JCTree.JCMethodDecl jcMethodDecl,Class target,String name){
        Scope members = jcMethodDecl.sym.owner.members();
        Iterator<Symbol> iterator = members.getElements().iterator();
        while (iterator.hasNext()){
            Symbol next = iterator.next();
            if(ElementKind.FIELD.equals(next.getKind()) && next.getQualifiedName().toString().equals(name)
                && next.type.tsym.getQualifiedName().toString().equals(target.getName())){

                return (Symbol.VarSymbol) next;
            }
        }
        return null;
    }

针对目标节点应用增强Visitor

我这里创建了一个名为AroundSlf4jMethodVisitor的增量类,继承自com.sun.tools.javac.tree.TreeTranslator

因为需要在该增强类中生成logger.info调用。所以,刚才找到的logger成员属性当然要传递进去咯。

 tree.accept(new AroundSlf4jMethodVisitor(treeMaker,names,logger));

生成打印入参调用语句并加入

根据方法名和类名拼装日志打印内容比较简单,这里就不说了。

直接看怎么生成方法调用:

使用AroundSlf4jProcessor中传入的工具类:treeMaker

        JCTree.JCExpressionStatement beforeState = treeMaker.Exec(treeMaker.Apply(
                List.nil(),
                //调用方法
                treeMaker.Select(treeMaker.Ident(logger.name),names.fromString("info")),
                //入参
                loggerArgs.toList()
                )
        );

然后,需要把刚才生成的语句加入到原方法节树中。

因为打印入参应该在方法的第一条代码中。所以,使用prepend方法加入到节点头。

jcMethodDecl.body.stats = jcMethodDecl.body.stats.prepend(beforeState);

递归遍历方法执行路径,找出所有RETURN语句

对于java代码中的语句类型,我这里只继续递归了代码块BLOCK,if语句IF,以及FOR_LOOP三种类型,已经可以覆盖大多数执行分支了。

 private void walkReturnExpression(List<JCTree.JCStatement> statement){
        for(int i = 0 ;i< statement.size();i++){
            JCTree.JCStatement jcStatement = statement.get(i);
            if(jcStatement == null){
                continue;
            }
            switch (jcStatement.getKind()){
                case BLOCK:
                    walkReturnExpression(((JCTree.JCBlock)jcStatement).stats);
                    break;

                case IF:
                    ((JCTree.JCIf)jcStatement).getThenStatement().accept(new AroundSlf4jBlockVisitor(treeMaker,names,logger));
                    JCTree.JCStatement current = ((JCTree.JCIf)jcStatement).getElseStatement();
                    walkReturnExpression(List.of(current));
                    break;
                case FOR_LOOP:
                    JCTree.JCBlock body = (JCTree.JCBlock) ((JCTree.JCForLoop) jcStatement).body;
                    walkReturnExpression(body.stats);
                    break;

                default:
                    System.out.println(jcStatement);
            }
        }
    }

后面,再给RETURN前加入打印日志调用。

        jcMethodDecl.body.stats.stream().filter( c-> Tree.Kind.RETURN == c.getKind() ).findFirst().ifPresent( r->{
            StatementHelper statementHelper = new StatementHelper(treeMaker,names);
            JCTree.JCExpressionStatement endLogging = statementHelper.createEndLoggingStatementByReturn(logger, (JCTree.JCReturn) r);
            jcMethodDecl.body.stats = SunListUtils.prependBeforeItem(jcMethodDecl.body.stats.iterator(),endLogging,r);
        });

因为打印日志调用因在return语句前。所以,他应该是倒数第二条代码。

我这里写了一个工具方法:在List指定元素前新增元素SunListUtils.prependBeforeItem

参考资料

  • 《深入理解java虚拟机》
  • openJDK源码