27 深度解析Java注解处理器:编译期代码生成的魔法

图片[1]-27 深度解析Java注解处理器:编译期代码生成的魔法-速优课

深度解析Java注解处理器:编译期代码生成的魔法

本文导读

注解(Annotation)是Java 5引入的重要特性,几乎每个Java开发者都用过。但你是否知道,注解不仅仅是标记,还可以在编译期改变代码的生成过程?

注解处理器(Annotation Processor)就是这样一种强大的工具,它允许你在编译期读取注解信息,并进行各种处理——从代码检查到代码生成,无所不能。Lombok、MapStruct、Dagger等知名框架的背后,都有注解处理器的身影。

本文将带你深入理解Java注解处理器的原理与实践,你将学到:

  • 注解的三种生命周期及其区别
  • Java编译器的工作流程与注解处理阶段
  • 注解处理器的核心API与开发步骤
  • 如何通过注解处理器生成源代码
  • 注解处理器的注册与使用方式

一、注解基础:从@Override说起

注解(Annotation)是Java 5引入的,用来为类、方法、字段、参数等Java结构提供额外信息的机制。

我们先从最熟悉的@Override注解开始,它被用来声明某个实例方法重写了父类的同名同参数类型的方法。

package java.lang;
​
@Target(ElementType.METHOD)
@Retention(RetentionPolicy.SOURCE)
public @interface Override {
}

@Override注解本身被另外两个元注解(即作用在注解上的注解)所标注:

  • @Target:用来限定目标注解所能标注的Java结构。这里@Override便只能被用来标注方法。
  • @Retention:用来限定当前注解的生命周期。

1.1 注解的三种生命周期

注解共有三种不同的生命周期:

生命周期说明
SOURCE注解只出现在源代码中,编译时会被擦除
CLASS注解出现在源代码和字节码中,但运行时无法通过反射获取
RUNTIME注解出现在源代码、字节码和运行过程中,可以通过反射读取

@Override便只能出现在源代码中。一旦标注了@Override的方法所在的源代码被编译为字节码,该注解便会被擦除。

我们不难猜到,@Override仅对Java编译器有用。事实上,它会为Java编译器引入了一条新的编译规则,即如果所标注的方法不是Java语言中的重写方法,那么编译器会报错。而当编译完成时,它的使命也就结束了。

1.2 自定义注解与注解处理器

我们知道,Java的注解机制允许开发人员自定义注解。这些自定义注解同样可以为Java编译器添加编译规则。不过,这种功能需要由开发人员提供,并且以插件的形式接入Java编译器中,这些插件我们称之为注解处理器(Annotation Processor)

注解处理器的主要用途有三个:

  1. 定义编译规则:检查被编译的源文件是否符合规范
  2. 修改已有源代码:通过修改抽象语法树来改变编译结果(不推荐)
  3. 生成新的源代码:在编译期自动生成新的Java源文件

下面,我们将通过几个案例来详细阐述注解处理器的这些功能,以及它背后的原理。


二、注解处理器的工作原理

在介绍注解处理器之前,我们先来了解一下Java编译器的工作流程。

图片[2]-27 深度解析Java注解处理器:编译期代码生成的魔法-速优课

如上图所示,Java源代码的编译过程可分为三个步骤:

  1. 解析源文件为抽象语法树(AST)
  2. 调用已注册的注解处理器
  3. 生成字节码

2.1 轮次(Round)机制

如果在第2步调用注解处理器过程中生成了新的源文件,那么编译器将重复第1、2步,解析并且处理新生成的源文件。每次重复我们称之为一轮(Round)

也就是说:

  • 第一轮:解析、处理的是输入至编译器中的已有源文件
  • 如果注解处理器生成了新的源文件,则开始第二轮、第三轮,解析并且处理这些新生成的源文件
  • 当注解处理器不再生成新的源文件时,编译进入最后一轮,并最终进入生成字节码的第3步

这种轮次机制使得注解处理器可以链式工作——一个处理器生成的代码可以被另一个处理器继续处理。


三、开发第一个注解处理器

下面我们通过一个实际的例子来学习如何开发注解处理器。我们将定义一个@CheckGetter注解,并实现一个对应的处理器,用来检查被标注的类中的每个字段是否都有对应的getter方法。

3.1 定义注解

首先,我们定义@CheckGetter注解:

package foo;
​
import java.lang.annotation.*;
​
@Target({ ElementType.TYPE, ElementType.FIELD })
@Retention(RetentionPolicy.SOURCE)
public @interface CheckGetter {
}

在上面这段代码中,我们定义了一个注解@CheckGetter。它既可以用来标注类,也可以用来标注字段。此外,它和@Override相同,其生命周期被限定在源代码中。

3.2 Processor接口

所有的注解处理器类都需要实现接口Processor。该接口主要有四个重要方法:

public interface Processor {
​
    void init(ProcessingEnvironment processingEnv);
    
    Set<String> getSupportedAnnotationTypes();
    
    SourceVersion getSupportedSourceVersion();
    
    boolean process(Set<? extends TypeElement> annotations, RoundEnvironment roundEnv);
    
    ...
}

各方法的作用如下:

方法作用
init存放注解处理器的初始化代码。之所以不用构造器,是因为注解处理器的实例是通过反射API生成的,需要无参构造器
getSupportedAnnotationTypes返回注解处理器所支持的注解类型,用字符串形式表示
getSupportedSourceVersion返回该处理器所支持的Java版本,通常需要与编译器版本保持一致
process最核心的注解处理方法,所有处理逻辑都在这里

3.3 AbstractProcessor抽象类

JDK提供了一个实现Processor接口的抽象类AbstractProcessor。该抽象类实现了initgetSupportedAnnotationTypesgetSupportedSourceVersion方法。

它的子类可以通过@SupportedAnnotationTypes@SupportedSourceVersion注解来声明所支持的注解类型以及Java版本。

3.4 CheckGetterProcessor实现

下面是@CheckGetter注解处理器的完整实现:

package bar;
​
import java.util.Set;
​
import javax.annotation.processing.*;
import javax.lang.model.SourceVersion;
import javax.lang.model.element.*;
import javax.lang.model.util.ElementFilter;
import javax.tools.Diagnostic.Kind;
​
import foo.CheckGetter;
​
@SupportedAnnotationTypes("foo.CheckGetter")
@SupportedSourceVersion(SourceVersion.RELEASE_10)
public class CheckGetterProcessor extends AbstractProcessor {
​
    @Override
    public boolean process(Set<? extends TypeElement> annotations, RoundEnvironment roundEnv) {
        // TODO: 处理被@CheckGetter标注的字段
        for (TypeElement annotatedClass : ElementFilter.typesIn(roundEnv.getElementsAnnotatedWith(CheckGetter.class))) {
            for (VariableElement field : ElementFilter.fieldsIn(annotatedClass.getEnclosedElements())) {
                if (!containsGetter(annotatedClass, field.getSimpleName().toString())) {
                    processingEnv.getMessager().printMessage(Kind.ERROR,
                        String.format("getter not found for '%s.%s'.", annotatedClass.getSimpleName(), field.getSimpleName()));
                }
            }
        }
        return true;
    }
​
    private static boolean containsGetter(TypeElement typeElement, String name) {
        String getter = "get" + name.substring(0, 1).toUpperCase() + name.substring(1).toLowerCase();
        for (ExecutableElement executableElement : ElementFilter.methodsIn(typeElement.getEnclosedElements())) {
            if (!executableElement.getModifiers().contains(Modifier.STATIC)
                && executableElement.getSimpleName().toString().equals(getter)
                && executableElement.getParameters().isEmpty()) {
                return true;
            }
        }
        return false;
    }
}

3.5 核心概念解析

该注解处理器仅重写了process方法。这个方法将接收两个参数:

  • annotations:该注解处理器所能处理的注解类型
  • roundEnv:囊括当前轮生成的抽象语法树的环境

由于该处理器针对的注解仅有@CheckGetter一个,而且我们并不会读取注解中的值,因此第一个参数并不重要。在代码中,我们直接使用:

roundEnv.getElementsAnnotatedWith(CheckGetter.class)

来获取所有被@CheckGetter注解的类(以及字段)。

process方法涉及各种不同类型的Element,分别指代Java程序中的各个结构:

Element类型指代的Java结构
TypeElement类或者接口
VariableElement字段、局部变量、enum常量等
ExecutableElement方法或者构造器
PackageElement

它们之间也有从属关系,例如:

package foo;     // PackageElement
​
class Foo {      // TypeElement
  int a;           // VariableElement
  static int b;    // VariableElement
  Foo () {}        // ExecutableElement
  void setA (      // ExecutableElement
    int newA         // VariableElement
  ) {}
}

我们可以通过TypeElement.getEnclosedElements方法,获得类中的字段、构造器以及方法。我们也可以通过ExecutableElement.getParameters方法,获得方法的参数。


四、注解处理器的注册方式

在将该注解处理器编译成class文件后,我们便可以将其注册为Java编译器的插件,并用来处理其他源代码。注册的方法主要有两种。

4.1 方式一:命令行参数

直接使用javac命令的-processor参数:

$ javac -cp /CLASSPATH/TO/CheckGetterProcessor -processor bar.CheckGetterProcessor Foo.java
error: Class 'Foo' is annotated as @CheckGetter, but field 'a' is without getter
1 error

4.2 方式二:自动发现(SPI机制)

将注解处理器编译生成的class文件压缩入jar包中,并在jar包的配置文件中记录该注解处理器的包名及类名,即bar.CheckGetterProcessor

配置文件的具体路径为:

META-INF/services/javax.annotation.processing.Processor

当启动Java编译器时,它会寻找classpath路径上的jar包是否包含上述配置文件,并自动注册其中记录的注解处理器。

$ javac -cp /PATH/TO/CheckGetterProcessor.jar Foo.java
error: Class 'Foo' is annotated as @CheckGetter, but field 'a' is without getter
1 error

此外,我们还可以在IDE中配置注解处理器。


五、利用注解处理器生成源代码

前面提到,注解处理器可以用来修改已有源代码或者生成源代码。

5.1 修改源代码 vs 生成源代码

确切地说,注解处理器并不能真正地修改已有源代码。这里指的是修改由Java源代码生成的抽象语法树,在其中修改已有树节点或者插入新的树节点,从而使生成的字节码发生变化。

对抽象语法树的修改涉及了Java编译器的内部API,这部分很可能随着版本变更而失效。因此,并不推荐这种修改方式。

如果你感兴趣的话,可以参考Project Lombok。这个项目自定义了一系列注解,并根据注解的内容来修改已有的源代码。例如它提供了@Getter@Setter注解,能够为程序自动添加getter以及setter方法。

用注解处理器来生成源代码则比较常用。我们以前介绍过的压力测试工具jcstress,以及JMH工具,都是依赖这种方式来生成测试代码的。

5.2 实战:生成适配器类

下面我们来看一个更复杂的例子:通过注解处理器生成源代码。

首先,定义一个@Adapt注解:

package foo;

import java.lang.annotation.*;

@Target(ElementType.METHOD)
@Retention(RetentionPolicy.SOURCE)
public @interface Adapt {
    Class<?> value();
}

这个注解将接收一个Class类型的参数value(如果注解类仅包含一个名为value的参数时,那么在使用注解时,我们可以省略value=)。

使用方式如下:

// Bar.java
package test;
import java.util.function.IntBinaryOperator;
import foo.Adapt;

public class Bar {
    @Adapt(IntBinaryOperator.class)
    public static int add(int a, int b) {
        return a + b;
    }
}

接下来,我们来实现一个处理@Adapt注解的处理器。该处理器将生成一个新的源文件,实现参数value所指定的接口,并且调用至被该注解所标注的方法之中。

以下是完整的处理器实现:

package bar;

import java.io.*;
import java.util.Set;

import javax.annotation.processing.*;
import javax.lang.model.SourceVersion;
import javax.lang.model.element.*;
import javax.lang.model.type.TypeMirror;
import javax.lang.model.util.ElementFilter;
import javax.tools.JavaFileObject;

import javax.tools.Diagnostic.Kind;

@SupportedAnnotationTypes("foo.Adapt")
@SupportedSourceVersion(SourceVersion.RELEASE_10)
public class AdaptProcessor extends AbstractProcessor {

    @Override
    public boolean process(Set<? extends TypeElement> annotations, RoundEnvironment roundEnv) {
        for (TypeElement annotation : annotations) {
            if (!"foo.Adapt".equals(annotation.getQualifiedName().toString())) {
                continue;
            }

            ExecutableElement targetAsKey = getExecutable(annotation, "value");

            for (ExecutableElement annotatedMethod : ElementFilter.methodsIn(roundEnv.getElementsAnnotatedWith(annotation))) {
                if (!annotatedMethod.getModifiers().contains(Modifier.PUBLIC)) {
                    processingEnv.getMessager().printMessage(Kind.ERROR, "@Adapt on non-public method");
                    continue;
                }
                if (!annotatedMethod.getModifiers().contains(Modifier.STATIC)) {
                    // TODO: 支持非静态方法
                    continue;
                }

                TypeElement targetInterface = getAnnotationValueAsTypeElement(annotatedMethod, annotation, targetAsKey);
                if (targetInterface.getKind() != ElementKind.INTERFACE) {
                    processingEnv.getMessager().printMessage(Kind.ERROR, "@Adapt with non-interface input");
                    continue;
                }

                TypeElement enclosingType = getTopLevelEnclosingType(annotatedMethod);
                createAdapter(enclosingType, annotatedMethod, targetInterface);
            }
        }
        return true;
    }

    private void createAdapter(TypeElement enclosingClass, ExecutableElement annotatedMethod,
            TypeElement targetInterface) {
        PackageElement packageElement = (PackageElement) enclosingClass.getEnclosingElement();
        String packageName = packageElement.getQualifiedName().toString();
        String className = enclosingClass.getSimpleName().toString();
        String methodName = annotatedMethod.getSimpleName().toString();
        String adapterName = className + "_" + methodName + "Adapter";

        ExecutableElement overriddenMethod = getFirstNonDefaultExecutable(targetInterface);

        try {
            Filer filer = processingEnv.getFiler();
            JavaFileObject sourceFile = filer.createSourceFile(packageName + "." + adapterName, new Element[0]);

            try (PrintWriter out = new PrintWriter(sourceFile.openWriter())) {
                out.println("package " + packageName + ";");
                out.println("import " + targetInterface.getQualifiedName() + ";");
                out.println();
                out.println("public class " + adapterName + " implements " + targetInterface.getSimpleName() + " {");
                out.println("  @Override");
                out.println("  public " + overriddenMethod.getReturnType() + " " + overriddenMethod.getSimpleName()
                    + formatParameter(overriddenMethod, true) + " {");
                out.println("    return " + className + "." + methodName + formatParameter(overriddenMethod, false) + ";");
                out.println("  }");
                out.println("}");
            }
        } catch (IOException e) {
            throw new RuntimeException(e);
        }
    }

    private ExecutableElement getExecutable(TypeElement annotation, String methodName) {
        for (ExecutableElement method : ElementFilter.methodsIn(annotation.getEnclosedElements())) {
            if (methodName.equals(method.getSimpleName().toString())) {
                return method;
            }
        }
        processingEnv.getMessager().printMessage(Kind.ERROR, "Incompatible @Adapt.");
        return null;
    }

    private ExecutableElement getFirstNonDefaultExecutable(TypeElement annotation) {
        for (ExecutableElement method : ElementFilter.methodsIn(annotation.getEnclosedElements())) {
            if (!method.isDefault()) {
                return method;
            }
        }
        processingEnv.getMessager().printMessage(Kind.ERROR,
            "Target interface should declare at least one non-default method.");
        return null;
    }

    private TypeElement getAnnotationValueAsTypeElement(ExecutableElement annotatedMethod, TypeElement annotation,
            ExecutableElement annotationFunction) {
        TypeMirror annotationType = annotation.asType();

        for (AnnotationMirror annotationMirror : annotatedMethod.getAnnotationMirrors()) {
            if (processingEnv.getTypeUtils().isSameType(annotationMirror.getAnnotationType(), annotationType)) {
                AnnotationValue value = annotationMirror.getElementValues().get(annotationFunction);
                if (value == null) {
                    processingEnv.getMessager().printMessage(Kind.ERROR, "Unknown @Adapt target");
                    continue;
                }
                TypeMirror targetInterfaceTypeMirror = (TypeMirror) value.getValue();
                return (TypeElement) processingEnv.getTypeUtils().asElement(targetInterfaceTypeMirror);
            }
        }
        processingEnv.getMessager().printMessage(Kind.ERROR, "@Adapt should contain target()");
        return null;
    }

    private TypeElement getTopLevelEnclosingType(ExecutableElement annotatedMethod) {
        TypeElement enclosingType = null;
        Element enclosing = annotatedMethod.getEnclosingElement();

        while (enclosing != null) {
            if (enclosing.getKind() == ElementKind.CLASS) {
                enclosingType = (TypeElement) enclosing;
            } else if (enclosing.getKind() == ElementKind.PACKAGE) {
                break;
            }
            enclosing = enclosing.getEnclosingElement();
        }
        return enclosingType;
    }

    private String formatParameter(ExecutableElement method, boolean includeType) {
        StringBuilder builder = new StringBuilder();
        builder.append('(');
        String separator = "";

        for (VariableElement parameter : method.getParameters()) {
            builder.append(separator);
            if (includeType) {
                builder.append(parameter.asType());
                builder.append(' ');
            }
            builder.append(parameter.getSimpleName());
            separator = ", ";
        }
        builder.append(')');
        return builder.toString();
    }
}

5.3 代码生成的核心要点

在这个注解处理器实现中,我们将读取注解中的值,因此使用了process方法的第一个参数,并通过它获得被标注方法对应的@Adapt注解中的value值。

之所以采用这种”麻烦”的方式,是因为value值属于Class类型。在编译过程中,被编译代码中的Class常量未必被加载进Java编译器所在的虚拟机中。因此,我们需要通过process方法的第一个参数,获得value所指向的接口的抽象语法树,并据此生成源代码。

生成源代码的方式实际上非常容易理解。我们可以通过Filer.createSourceFile方法获得一个类似于文件的概念,并通过PrintWriter将具体的内容一一写入即可。

当将该注解处理器作为插件接入Java编译器时,编译前面的test/Bar.java将生成下述代码,并且触发新一轮的编译:

package test;
import java.util.function.IntBinaryOperator;

public class Bar_addAdapter implements IntBinaryOperator {
    @Override
    public int applyAsInt(int arg0, int arg1) {
        return Bar.add(arg0, arg1);
    }
}

总结与思考

本文深入讲解了Java注解处理器的原理与实践,让我们来总结一下核心要点:

1. 注解的生命周期

  • SOURCE:仅在源代码中存在,编译时擦除(如@Override
  • CLASS:存在于源代码和字节码中,运行时无法反射获取
  • RUNTIME:全程存在,可以通过反射读取

2. Java编译的三个步骤

  1. 解析源文件为抽象语法树(AST)
  2. 调用已注册的注解处理器
  3. 生成字节码

注解处理器运行在第2步,并且支持多轮(Round)处理,直到不再生成新的源文件为止。

3. 注解处理器的核心API

  • Processor接口:所有注解处理器的顶层接口
  • AbstractProcessor:提供了默认实现的抽象类
  • Element及其子类:代表Java程序中的各种结构(类、方法、字段等)
  • ProcessingEnvironment:处理器运行环境,提供Messager、Filer等工具
  • RoundEnvironment:当前轮次的语法树环境

4. 注解处理器的三种用途

  • 代码检查:在编译期检查代码规范,如检查getter方法是否存在
  • 修改AST:修改抽象语法树(使用编译器内部API,不推荐)
  • 生成代码:在编译期生成新的源代码,这是最常见的用法

5. 注册方式

  • 命令行参数:-processor
  • SPI机制:META-INF/services/javax.annotation.processing.Processor

实践建议

  • 注解处理器是一项非常强大的技术,可以极大地减少样板代码
  • Lombok、MapStruct、Dagger、AutoValue等知名框架都基于此技术
  • 学习注解处理器有助于理解很多框架的底层原理
  • 如果需要开发注解处理器,优先使用生成源代码的方式,避免修改AST

思考题:请实现本文案例CheckGetterProcessor中的TODO项,处理由@CheckGetter注解的字段(而不仅仅是注解在类上的情况)。也就是说,当字段被@CheckGetter标注时,检查该字段是否有对应的getter方法。

© 版权声明
THE END
喜欢就支持一下吧
点赞13
相关推荐
评论 抢沙发

请登录后发表评论

    请登录后查看评论内容

温馨提示:
1、本内容转载于网络,版权归原作者所有!
2、本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。
3、本内容若侵犯到你的版权利益,请联系我们,会尽快给予删除处理!