Java 基础体系 · 第 55/100 篇。示例统一以 Java 25 LTS 为语言和 JVM 基线;框架示例使用与其兼容的现代稳定版本。

Java 注解处理器:编译期模型、代码生成、增量构建和调试

Java 注解处理器(Annotation Processor)是在编译期读取注解和程序结构,并可生成新的源代码、类文件或资源文件的工具。它不是运行时反射,也不是编译器插件:处理器通过 Java SE 提供的 javax.annotation.processing API 与编译器交互,通常由 javac 或构建工具启动。

本文以 Java 25 LTS 为范围,重点解释四个相互连接的机制:

  1. 编译器如何把源代码暴露为编译期模型;
  2. 注解处理器如何按轮次读取模型并生成代码;
  3. 生成代码如何进入后续编译流程;
  4. 增量构建为何需要额外的依赖声明,以及如何调试失败的处理过程。

一、注解处理器解决什么问题

假设项目中存在如下接口:

public interface UserService {
    User findById(long id);
}

工程可能希望自动生成:

public final class UserServiceFactory {
    public static UserService create() {
        return new UserServiceImpl();
    }
}

如果这个生成过程放在运行时,程序需要反射、扫描类路径或读取配置;如果放在编译期,则可以让编译器直接检查生成代码的类型正确性。

注解处理器通常用于:

  • 根据接口生成实现类;
  • 根据数据类生成 Builder、序列化器或映射器;
  • 根据接口生成注册表;
  • 根据注解生成元数据资源;
  • 在编译期检查约束,例如字段必须满足某种类型;
  • 生成减少重复代码的适配层。

它的边界也很明确:

  • 它不能修改已经存在的源文件;
  • 它通常不能改变当前类的语法结构;
  • 它生成的代码必须遵守 Java 语法和类型规则;
  • 它只能观察编译器提供的模型,而不是把源文件当普通文本随意改写。

因此,注解处理器的典型形式不是“修改 UserService”,而是“读取 UserService,再生成另一个类型”。


二、三个必须区分的概念

1. 注解本身

注解是 Java 语言中的声明式元数据。例如:

package demo;

import java.lang.annotation.ElementType;
import java.lang.annotation.Retention;
import java.lang.annotation.RetentionPolicy;
import java.lang.annotation.Target;

@Retention(RetentionPolicy.SOURCE)
@Target(ElementType.TYPE)
public @interface GenerateHello {
}

这里有两个重要属性:

  • @Target(ElementType.TYPE):该注解只能用于类、接口、枚举或记录等类型声明;
  • @Retention(RetentionPolicy.SOURCE):注解只需要在源代码编译期间存在,编译后的 .class 文件不必保留它。

SOURCE 并不表示处理器看不到它。处理器正是在源代码处理阶段读取它。若注解需要被运行时反射读取,则应使用 RUNTIME;若只需要保留在 .class 中供其他编译阶段读取,则可以使用 CLASS

2. 注解处理器

处理器是实现了 javax.annotation.processing.Processor 的类。工程中通常继承:

javax.annotation.processing.AbstractProcessor

处理器接收:

  • 当前编译环境;
  • 当前轮次中的注解和程序元素;
  • 编译器提供的类型、包、成员和注解信息;
  • 输出源文件、类文件和资源文件的能力。

3. 编译器

javac 负责解析源代码、执行注解处理、编译源代码和处理器生成的源代码。注解处理器本身不等于编译器,也不直接负责把 Java 源代码编译成字节码。

基本关系是:

Java 源文件
    │
    ▼
javac 解析
    │
    ▼
编译期模型 ──► 注解处理器
    │              │
    │              └── 生成源文件、类文件或资源
    ▼
javac 继续编译生成的源文件
    │
    ▼
.class 文件

三、编译期模型:处理器看到的不是反射对象

注解处理器最容易被误解的地方,是把它当成“编译期反射”。实际上,处理器使用的是编译器模型 API,主要包括:

  • Element:程序元素;
  • TypeMirror:类型镜像;
  • TypeElement:类或接口等类型声明;
  • ExecutableElement:方法或构造器声明;
  • VariableElement:字段、参数或局部变量;
  • Elements:名称、包、注解和文档等操作;
  • Types:类型比较、继承关系和类型转换等操作。

1. Element 表示声明

例如:

public class User {
    private final String name;

    public String name() {
        return name;
    }
}

其中可能包含:

User 类              TypeElement
name 字段            VariableElement
name() 方法          ExecutableElement
String 类型          TypeMirror
demo 包              PackageElement

Element 表示的是“源程序中的声明”,而不是运行时对象。

因此,下面的思路是错误的:

Class<?> type = ...;
type.getDeclaredMethods();

处理器不应依赖被处理项目中的类已经能够被当前处理器类加载器加载。正确做法是使用:

TypeElement typeElement = ...;
typeElement.getEnclosedElements();

2. TypeMirror 不是 Class<?>

处理器中应使用 TypeMirror 表示类型:

TypeMirror type = variableElement.asType();

判断两个类型是否相等,应使用:

processingEnv.getTypeUtils().isSameType(type1, type2)

而不是比较字符串,也不是尝试转换成 Class<?>

例如,以下两种写法的语义不同:

type.toString().equals("java.lang.String")

这只是基于格式化名称的比较;而:

types.isSameType(type1, type2)

才是编译器类型系统意义上的比较。

类型模型还需要处理:

  • 泛型类型;
  • 通配符;
  • 类型变量;
  • 数组;
  • 原始类型;
  • ERROR 类型;
  • 尚未解析完整的类型。

如果源码存在类型错误,处理器可能仍会看到某些模型元素,但类型可能是 TypeKind.ERROR。因此处理器不应假定每个 TypeMirror 都已经是完整、可加载的普通类。

3. ElementsTypes 的职责不同

Elements elements = processingEnv.getElementUtils();
Types types = processingEnv.getTypeUtils();

Elements 处理声明层面的信息,例如:

PackageElement packageElement =
        elements.getPackageOf(typeElement);

String qualifiedName =
        typeElement.getQualifiedName().toString();

Types 处理类型关系,例如:

boolean subtype =
        types.isSubtype(candidateType, targetType);

可以把两者简单理解为:

Elements:这个声明叫什么、属于哪个包、有哪些成员
Types:两个类型是否相同、是否可赋值、是否存在继承关系

四、处理器的轮次模型

注解处理不是一次调用 process() 就结束,而是一个由编译器驱动的多轮过程。

设第 i 轮的输入源元素为 Rᵢ,处理器在该轮生成的新文件为 Gᵢ。编译器执行过程可以抽象为:

R0processG0R_0 \xrightarrow{process} G_0

R1=G0processG1R_1 = G_0 \xrightarrow{process} G_1

R2=G1processG2R_2 = G_1 \xrightarrow{process} G_2

当某一轮不再生成新文件时,编译器进入最终轮:

RnprocessOver=trueR_n \xrightarrow{processOver=true} \varnothing

这里:

  • Rᵢ 是该轮可供处理器观察的根元素;
  • Gᵢ 是处理器通过 Filer 产生的新文件;
  • processOver() 返回 true 表示当前是最终轮;
  • 生成文件不会在产生它的同一轮中再次作为输入处理,而会在后续轮次出现。

处理器方法的基本结构如下:

@Override
public boolean process(
        Set<? extends TypeElement> annotations,
        RoundEnvironment roundEnv) {

    if (roundEnv.processingOver()) {
        return false;
    }

    for (Element element
            : roundEnv.getElementsAnnotatedWith(GenerateHello.class)) {
        // 读取元素并生成代码
    }

    return true;
}

processingOver()errorRaised()

最终轮可以通过以下方法判断:

roundEnv.processingOver()

如果之前某轮通过 Messager 报告了编译错误,可以检查:

roundEnv.errorRaised()

典型用途是:只有在所有普通处理结束后,才生成汇总文件;如果前面已有错误,则不再生成可能误导开发者的汇总结果。

process() 的返回值

返回值的含义经常被误解:

return true;

表示当前处理器“声明自己处理了这些注解类型”。它不是“停止所有其他处理器”,也不是“处理成功”的通用布尔值。

返回:

return false;

表示这些注解仍可由其他处理器处理。

如果两个处理器都要观察同一个注解,前一个处理器不应因为完成了自己的工作,就错误地阻止其他处理器获得机会。


五、一个可运行的最小处理器

下面的示例根据:

@GenerateHello
public class User {
}

生成:

package demo;

public final class Generated_User {
    public static String message() {
        return "Hello from User";
    }
}

1. 注解定义

src-processor/demo/GenerateHello.java

package demo;

import java.lang.annotation.ElementType;
import java.lang.annotation.Retention;
import java.lang.annotation.RetentionPolicy;
import java.lang.annotation.Target;

@Retention(RetentionPolicy.SOURCE)
@Target(ElementType.TYPE)
public @interface GenerateHello {
}

2. 处理器实现

src-processor/demo/HelloProcessor.java

package demo;

import java.io.IOException;
import java.io.Writer;
import java.util.Set;

import javax.annotation.processing.AbstractProcessor;
import javax.annotation.processing.Filer;
import javax.annotation.processing.Messager;
import javax.annotation.processing.ProcessingEnvironment;
import javax.annotation.processing.Processor;
import javax.annotation.processing.RoundEnvironment;
import javax.lang.model.SourceVersion;
import javax.lang.model.element.Element;
import javax.lang.model.element.ElementKind;
import javax.lang.model.element.Modifier;
import javax.lang.model.element.PackageElement;
import javax.lang.model.element.TypeElement;
import javax.tools.Diagnostic;
import javax.tools.JavaFileObject;

import javax.annotation.processing.SupportedAnnotationTypes;
import javax.annotation.processing.SupportedSourceVersion;

@SupportedAnnotationTypes("demo.GenerateHello")
@SupportedSourceVersion(SourceVersion.RELEASE_25)
public final class HelloProcessor extends AbstractProcessor {

    private Messager messager;
    private Filer filer;

    @Override
    public synchronized void init(ProcessingEnvironment environment) {
        super.init(environment);
        this.messager = environment.getMessager();
        this.filer = environment.getFiler();
    }

    @Override
    public boolean process(
            Set<? extends TypeElement> annotations,
            RoundEnvironment roundEnvironment) {

        if (roundEnvironment.processingOver()) {
            return false;
        }

        if (roundEnvironment.errorRaised()) {
            return true;
        }

        for (Element element
                : roundEnvironment.getElementsAnnotatedWith(GenerateHello.class)) {

            if (element.getKind() != ElementKind.CLASS) {
                messager.printMessage(
                        Diagnostic.Kind.ERROR,
                        "@GenerateHello 只能用于普通类",
                        element);
                continue;
            }

            TypeElement type = (TypeElement) element;

            if (type.getModifiers().contains(Modifier.PRIVATE)) {
                messager.printMessage(
                        Diagnostic.Kind.ERROR,
                        "@GenerateHello 不能用于 private 类",
                        type);
                continue;
            }

            PackageElement packageElement =
                    processingEnv.getElementUtils().getPackageOf(type);

            String packageName =
                    packageElement.getQualifiedName().toString();

            String originalName =
                    type.getSimpleName().toString();

            String generatedName =
                    "Generated_" + originalName;

            String qualifiedName = packageName.isEmpty()
                    ? generatedName
                    : packageName + "." + generatedName;

            try {
                JavaFileObject sourceFile =
                        filer.createSourceFile(qualifiedName, type);

                try (Writer writer = sourceFile.openWriter()) {
                    if (!packageName.isEmpty()) {
                        writer.write("package ");
                        writer.write(packageName);
                        writer.write(";\n\n");
                    }

                    writer.write("public final class ");
                    writer.write(generatedName);
                    writer.write(" {\n");
                    writer.write("    private ");
                    writer.write(generatedName);
                    writer.write("() {}\n\n");
                    writer.write("    public static String message() {\n");
                    writer.write("        return \"Hello from ");
                    writer.write(originalName);
                    writer.write("\";\n");
                    writer.write("    }\n");
                    writer.write("}\n");
                }
            } catch (IOException exception) {
                messager.printMessage(
                        Diagnostic.Kind.ERROR,
                        "生成 " + qualifiedName + " 失败:"
                                + exception.getMessage(),
                        type);
            }
        }

        return true;
    }
}

这个处理器包含了几个重要约束:

  • @SupportedAnnotationTypes 声明它关注的注解;
  • @SupportedSourceVersion 声明它支持的 Java 源代码版本;
  • createSourceFile() 创建新的源文件;
  • 第二个参数 type 是该生成文件的 originating element;
  • 失败时通过 Messager 把错误关联到原始元素;
  • 处理器没有尝试读取或加载 User.class

SourceVersion.RELEASE_25 表示处理器声明支持 Java 25 的语言源版本。处理器本身是否能在更高版本上运行,还取决于具体 Java 运行环境和处理器代码使用的 API;声明版本不是对未来版本的永久兼容承诺。

3. 注册处理器

创建文件:

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

内容只有一行:

demo.HelloProcessor

这是 Java 的服务提供者发现机制。编译器可以从处理器路径中读取这个文件,找到处理器实现。

也可以通过 javac-processor 参数显式指定处理器,但服务文件更适合打包成可复用的处理器 JAR。

4. 编译处理器

以下命令要求使用 JDK 25,并且当前目录包含 src-processor

mkdir -p build/processor-classes

javac --release 25 \
  -d build/processor-classes \
  src-processor/demo/GenerateHello.java \
  src-processor/demo/HelloProcessor.java

复制服务注册文件:

mkdir -p build/processor-classes/META-INF/services

cp src-processor/META-INF/services/javax.annotation.processing.Processor \
   build/processor-classes/META-INF/services/

打包:

jar --create \
  --file build/hello-processor.jar \
  -C build/processor-classes .

处理器 JAR 应放在 -processorpath 上,而被处理源码需要通过 -classpath 找到注解类型:

mkdir -p build/classes build/generated

javac --release 25 \
  -cp build/hello-processor.jar \
  -processorpath build/hello-processor.jar \
  -s build/generated \
  -d build/classes \
  src-input/demo/User.java

输入文件 src-input/demo/User.java

package demo;

@GenerateHello
public class User {
}

预期结果:

build/generated/demo/Generated_User.java
build/classes/demo/User.class
build/classes/demo/Generated_User.class

-s build/generated 指定生成源代码的输出目录。javac 会把生成的源文件加入后续处理轮次,并最终把它编译成 .class 文件。

查看生成结果:

cat build/generated/demo/Generated_User.java

可以再编译一个使用者:

src-main/demo/Main.java

package demo;

public class Main {
    public static void main(String[] args) {
        System.out.println(Generated_User.message());
    }
}

编译并运行:

javac --release 25 \
  -cp build/classes \
  -d build/classes \
  src-main/demo/Main.java

java -cp build/classes demo.Main

预期输出:

Hello from User

这里的因果链是:

User.java
  └─ @GenerateHello
       └─ HelloProcessor.process()
            └─ Generated_User.java
                 └─ javac 后续轮次编译
                      └─ Generated_User.class

六、Filer:代码生成的边界和约束

处理器通过 Filer 创建输出:

JavaFileObject source =
        processingEnv.getFiler()
                     .createSourceFile("demo.Generated_User", originatingElement);

还可以创建类文件和资源:

processingEnv.getFiler()
    .createClassFile("demo.Generated_User", originatingElement);

processingEnv.getFiler()
    .createResource(
        StandardLocation.CLASS_OUTPUT,
        "demo",
        "registry.txt",
        originatingElement);

1. 不能覆盖已有源文件

如果处理器试图重复创建同名文件,通常会得到:

javax.annotation.processing.FilerException:
Attempt to recreate a file for type ...

这可能由以下原因造成:

  • 同一轮内重复处理同一个元素;
  • 多个处理器生成了相同的全限定名;
  • 编译输出目录未清理,构建工具错误地复用了过期生成结果;
  • 处理器在多个轮次中没有记录已经生成的类型。

处理器通常应保证生成名称稳定,并避免在后续轮次重新生成同一文件。对于同一个编译任务,处理器可以在内存中记录已生成的全限定名:

private final Set<String> generatedNames = new HashSet<>();

但这只能解决当前处理器实例内的重复问题,不能替代正确的输出目录管理。

2. 生成文件必须是合法的 Java 文件

处理器生成的字符串最终仍由 Java 编译器解析。如果生成代码中存在语法错误,诊断通常指向生成文件,而不是原始注解位置。

因此生成器应:

  • 对 Java 标识符进行合法性检查;
  • 正确转义字符串、字符和注释;
  • 不直接把用户输入拼接进 Java 代码;
  • 对包名和类型名使用模型 API 获取,而不是通过文本猜测。

例如,生成字符串字面量时,用户输入的反斜杠和双引号必须转义;否则处理器自身执行成功,生成的 Java 文件仍可能无法编译。

3. originating element 的作用

下面的调用:

filer.createSourceFile(qualifiedName, type);

type 作为生成文件的来源元素。它的作用包括:

  • 帮助编译器和构建工具建立输入到输出的关联;
  • 让错误或构建诊断保留更准确的来源关系;
  • 为支持增量编译的构建工具提供依赖信息。

如果生成文件实际上依赖多个输入元素,可以传入多个来源元素:

filer.createSourceFile(
        qualifiedName,
        typeElement,
        methodElement,
        fieldElement);

这不是普通注释,而是构建依赖图的重要信号。


七、处理器发现:类路径、处理器路径和模块路径

编译一个使用注解的项目时,至少存在两类依赖:

被处理源码需要:
    注解类型

编译器需要:
    注解处理器实现

传统 javac 命令中通常分别对应:

-cp             被处理源码的类路径
-processorpath  注解处理器及其依赖的路径

例如:

javac \
  -cp application-api.jar \
  -processorpath processor.jar:processor-dependency.jar \
  ...

把处理器放在普通 -cp 上有时也能被 javac 发现,但这会混淆“编译期工具依赖”和“应用运行时依赖”。更严重的是,某些构建工具会因此把处理器放入运行时类路径,增加依赖泄漏和安全风险。

在模块化项目中,处理器模块通常需要:

module demo.processor {
    requires java.compiler;

    provides javax.annotation.processing.Processor
        with demo.HelloProcessor;
}

java.compiler 模块提供注解处理 API 和语言模型 API。消费者模块通常不需要在运行时依赖处理器模块;处理器只在编译期运行。

模块路径下的具体参数和构建工具配置依赖于使用的是 --processor-module-path 还是传统 --processorpath。这属于 javac 和构建工具的配置问题,不是 Java 语言本身对项目布局的唯一规定。


八、编译期模型中的常见陷阱

1. 不要用 Class.forName

错误做法:

Class<?> clazz = Class.forName(typeElement.getQualifiedName().toString());

原因是被处理类型可能:

  • 尚未生成 .class 文件;
  • 依赖当前编译任务中的其他源文件;
  • 不在处理器的类加载器中;
  • 含有处理器无法安全加载的静态初始化逻辑。

应使用:

TypeMirror mirror = typeElement.asType();
Types types = processingEnv.getTypeUtils();

2. 不要把 getEnclosedElements() 当成“所有成员”

它只表示当前声明直接包含的元素。例如,父类继承来的方法不会自动出现在某个类的 getEnclosedElements() 中。

如果需要检查继承关系,应使用:

types.directSupertypes(type);

或沿着父类型递归遍历,并明确处理:

  • 接口;
  • 泛型替换;
  • 方法重载;
  • 可见性;
  • 桥接方法不会以运行时反射的方式直接呈现。

3. 不要用字符串判断类型关系

错误示例:

if (field.asType().toString().equals("java.util.List<java.lang.String>")) {
    ...
}

格式化类型字符串可能受泛型写法、嵌套类型和编译器实现影响。对于类型关系,应尽量使用 Types API。

需要检查注解参数时,可以使用:

processingEnv.getElementUtils()
    .getElementValuesWithDefaults(annotationMirror);

这样可以同时得到显式传入的值和默认值,而不必把注解值当作普通字符串解析。


九、增量构建:为什么“能生成”还不够

增量构建不是 Java 注解处理 API 自动保证的能力,而是构建工具在处理器基础上建立的优化。

完整编译通常近似为:

所有源码 + 所有处理器
    └─ 重新处理
         └─ 重新生成
              └─ 重新编译

增量编译希望把它缩小为:

被修改的源码
    └─ 受影响的处理器输入
         └─ 受影响的生成文件
              └─ 受影响的编译单元

要做到这一点,构建工具必须知道:

  1. 哪些输入元素导致了某个输出;
  2. 某个处理器是否只依赖单个输入;
  3. 某个处理器是否需要查看全部输入;
  4. 输出文件在输入删除或修改后如何清理。

1. 隔离型处理器

隔离型(isolating)处理器满足近似条件:

oi=f(xi)o_i = f(x_i)

其中:

  • xᵢ 是一个输入元素;
  • oᵢ 是由该元素产生的输出;
  • f 不依赖其他输入元素的存在或内容。

例如:

@GenerateDto User
    └─ Generated_UserDto.java

如果只修改 User,理论上只需重新生成 Generated_UserDto.java

隔离型处理器不能偷偷读取整个项目,然后生成依赖全局信息的文件。例如下面的行为不是隔离的:

扫描所有 @Entity
    └─ 生成一个包含全部实体的 EntityRegistry.java

即使处理器表面上对每个实体分别调用 createSourceFile(),生成结果仍然依赖全体输入。

2. 聚合型处理器

聚合型(aggregating)处理器的输出依赖多个或全部输入:

o=f(x1,x2,,xn)o = f(x_1, x_2, \ldots, x_n)

例如:

全部 @Route
    └─ Routes.java

任何一个路由增加、删除或修改,都可能改变 Routes.java。构建工具不能只重建单个路由对应的输出,因为这种输出根本不是单输入函数。

3. originating element 不能制造虚假的隔离性

下面的代码虽然传入了一个 originating element:

filer.createSourceFile("demo.Routes", oneRouteElement);

但如果 Routes.java 实际上还读取了其他所有路由,那么它仍然是聚合型输出。错误标记会导致增量构建漏编译,表现为:

  • 全量构建正常;
  • 修改某个输入后,增量构建仍使用旧生成文件;
  • CI 的干净构建与本地增量构建结果不同;
  • 删除一个注解后,旧的生成类残留在输出目录中。

originating element 是依赖证据,不是性能注解。它必须反映真实依赖关系。

4. 构建工具声明是工具特定的

不同构建工具对增量注解处理的声明方式不同,版本之间也可能变化。例如,Gradle 对隔离型和聚合型处理器有自己的识别和声明机制;Maven 编译插件则通过插件配置和编译器行为参与增量编译。它们都不是 Java SE 规范的一部分。

因此,处理器设计应先回答:

这个输出实际依赖哪些输入?

然后再按照目标构建工具和版本的文档声明类型。不能为了获得增量构建,就把聚合型处理器标成隔离型。

5. 增量构建的删除路径

增量构建不仅要处理“文件变了”,还要处理“输入被删除”:

删除 @GenerateHello User

正确结果不仅是停止生成新文件,还应该删除旧的:

Generated_User.java
Generated_User.class

这依赖构建工具掌握输出与输入的关系。若处理器把文件直接写到源码目录或项目外部目录,构建工具可能无法追踪它,导致旧代码残留。

因此生成文件应始终通过 Filer 输出到构建工具管理的位置,而不是:

Files.writeString(Path.of("src/main/java/..."), content);

后者会绕过编译器和构建工具的输出模型。


十、聚合处理器中的最终轮

某些处理器需要等所有输入都可见后,才能生成一个汇总文件。此时不能在普通轮次中看到一个元素就立即生成汇总结果,因为后续轮次或其他源文件可能还会提供更多输入。

可以采用如下结构:

private final Set<String> routeNames = new TreeSet<>();

@Override
public boolean process(
        Set<? extends TypeElement> annotations,
        RoundEnvironment roundEnvironment) {

    if (!roundEnvironment.processingOver()) {
        for (Element element : roundEnvironment
                .getElementsAnnotatedWith(Route.class)) {
            routeNames.add(element.toString());
        }
        return true;
    }

    if (roundEnvironment.errorRaised()) {
        return true;
    }

    generateRegistry(routeNames);
    return true;
}

这里的状态变化是:

普通轮次
    └─ 收集 routeNames
最终轮次 processingOver=true
    └─ 如果之前无错误,则生成 Registry

但这类处理器通常是聚合型的,因为 Registry 依赖集合中的所有路由。它的增量代价也更高:任意一个路由变化,都可能导致整个注册表重新生成。

如果生成的汇总内容只是运行时注册所需的数据,也可以考虑生成每个输入各自的片段,再由运行时或后续工具合并。但这改变了系统设计,不能仅靠处理器标记解决。


十一、编译期错误处理

处理器发现用户代码不满足约束时,应使用 Messager 报告错误:

processingEnv.getMessager().printMessage(
        Diagnostic.Kind.ERROR,
        "字段必须是非空的 String",
        fieldElement);

这样 javac 可以把错误关联到源代码位置。典型输出类似:

User.java:5: error: 字段必须是非空的 String
    private int id;
            ^

错误处理有三个层次:

1. 用户输入错误

例如注解放在错误的元素上:

@GenerateHello
private class User {}

处理器应报告 ERROR,并关联到具体元素。

2. 生成逻辑错误

例如名称冲突:

demo.Generated_User 已经被其他输出占用

可以报告错误,也可以捕获 FilerException 后报告更具体的信息。

3. 处理器自身异常

不应依赖直接抛出异常作为用户诊断机制:

throw new IllegalStateException("bad input");

这通常只能得到“处理器崩溃”或堆栈信息,用户很难知道源代码应如何修改。应尽可能将可预期的输入问题转换为 MessagerERROR

如果处理器抛出未处理异常,编译通常会失败,但这属于处理器故障路径,而不是良好的编译诊断。


十二、调试注解处理器

1. 先确认处理器是否被发现

使用 -XprintProcessorInfo

javac --release 25 \
  -XprintProcessorInfo \
  -cp build/hello-processor.jar \
  -processorpath build/hello-processor.jar \
  -d build/classes \
  src-input/demo/User.java

该选项用于打印处理器与注解的处理关系。如果没有任何处理器信息,优先检查:

  • 服务文件路径是否是 META-INF/services/javax.annotation.processing.Processor
  • 文件内容是否为处理器的全限定类名;
  • 处理器 JAR 是否真的位于 -processorpath
  • @SupportedAnnotationTypes 是否写错;
  • 源代码是否真正使用了该注解。

2. 查看轮次

使用:

javac --release 25 \
  -XprintRounds \
  -XprintProcessorInfo \
  -cp build/hello-processor.jar \
  -processorpath build/hello-processor.jar \
  -s build/generated \
  -d build/classes \
  src-input/demo/User.java

典型的轮次逻辑是:

第 1 轮:看到 User.java 和 @GenerateHello
第 1 轮结束:生成 Generated_User.java
第 2 轮:看到 Generated_User.java
最终轮:processingOver() 为 true

如果处理器反复生成不同文件,或者每轮都尝试生成同名文件,应检查是否存在:

  • 没有判断 processingOver()
  • 没有记录已生成名称;
  • 生成代码自身又带有会触发处理器的注解;
  • 处理器对生成文件和原始文件重复执行了同一逻辑。

3. 只运行处理器,不生成类文件

-proc:only 只执行注解处理,不进行普通类文件编译:

javac --release 25 \
  -proc:only \
  -cp build/hello-processor.jar \
  -processorpath build/hello-processor.jar \
  -s build/generated \
  src-input/demo/User.java

这适合回答一个具体问题:

处理器有没有生成预期源文件?

如果 build/generated/demo/Generated_User.java 正确生成,但完整编译失败,问题通常在生成代码语法、类型依赖或类路径,而不在处理器发现过程。

4. 保留生成源并直接查看

构建工具往往会把生成源放到临时目录。调试时应显式指定生成目录,例如:

-s build/generated

然后检查:

find build/generated -type f -print

需要区分三个输出位置:

生成源目录       -s
类文件目录       -d
资源输出目录     Filer 的 StandardLocation

生成源存在不代表它已经成功编译;类文件存在也不代表生成源目录中的内容是当前构建生成的,尤其是在增量构建或手工复制文件时。

5. 在处理器中输出日志

临时调试可以使用:

processingEnv.getMessager().printMessage(
        Diagnostic.Kind.NOTE,
        "processing " + element);

不过构建工具可能隐藏 NOTE 级别信息。更稳定的做法是让处理器支持一个显式选项:

@SupportedOptions("demo.processor.debug")

然后通过:

-Ademo.processor.debug=true

传入。处理器读取:

boolean debug =
        "true".equalsIgnoreCase(
                processingEnv.getOptions().get("demo.processor.debug"));

选项名称必须由处理器声明,否则某些编译器或构建工具可能报告未识别选项。


十三、常见失败表现与定位顺序

症状一:注解存在,但没有生成文件

按以下顺序检查:

  1. 注解处理器 JAR 是否包含服务注册文件;
  2. -processorpath 是否指向正确 JAR;
  3. @SupportedAnnotationTypes 是否与注解全限定名一致;
  4. 是否使用了 -proc:none
  5. 注解是否出现在处理器检查的元素类型上;
  6. 是否提前因为 errorRaised() 或其他条件跳过;
  7. 构建工具是否禁用了注解处理。

-proc:none 会禁止注解处理;-proc:only 则只执行处理器。两者结果完全不同。

症状二:生成文件存在,但使用它的源码仍然找不到类型

检查:

  • 生成源是否被放在正确的包中;
  • 文件名是否与 public 顶层类型一致;
  • 生成源是否真的被加入当前编译任务;
  • 生成类是否由于语法错误没有产生 .class
  • 使用者是否在另一个编译任务中,而该任务的 -classpath 没有包含生成类输出目录。

特别是多模块项目中,处理器生成的类通常属于“生产源码模块”的输出,其他模块必须依赖该模块的编译产物,而不是依赖处理器 JAR。

症状三:全量构建成功,增量构建失败

重点检查:

  • 生成文件是否通过 Filer 创建;
  • originating elements 是否完整;
  • 聚合处理器是否错误声明为隔离型;
  • 删除输入时,旧输出是否被清理;
  • 是否把时间、机器路径、随机值写入生成文件;
  • 处理器是否读取了未声明的外部文件或环境变量;
  • 是否依赖了整个类路径,却只声明了单个源元素。

这是典型的“处理器输出函数不稳定”问题。若输出实际是:

o=f(x,classpath,env,filesystem,time)o = f(x, classpath, env, filesystem, time)

但构建工具按照:

o=f(x)o = f(x)

进行缓存或增量判断,就会出现结果不一致。


十四、代码生成的稳定性要求

代码生成不仅要“能编译”,还应具有可预测的输出。

1. 名称必须确定

同一个输入在不同编译中应生成相同的全限定名:

demo.User
    └─ demo.Generated_User

不要使用:

  • 随机 UUID;
  • 当前时间;
  • 进程 ID;
  • 临时目录名;
  • 未排序的哈希表遍历顺序。

2. 聚合输出应排序

如果生成一个包含多个类型的注册表,应先排序:

List<String> names = new ArrayList<>(collectedNames);
Collections.sort(names);

否则同一组输入可能因为遍历顺序不同,生成不同文件,导致无意义的重新编译和版本控制噪声。

3. 生成代码应具备可读诊断性

生成文件应保留稳定的格式,并可以包含生成标记:

// Generated by demo.HelloProcessor. Do not edit.

这不能阻止用户修改文件,但能明确告诉用户:该文件会在下一次构建中被覆盖或重新创建。

4. 不要把生成输出放回源码目录

将生成文件写入 src/main/java 会造成:

  • 源码目录被构建污染;
  • 旧生成文件难以删除;
  • Git 状态出现未预期变化;
  • 增量构建无法准确追踪输出;
  • 并行构建可能互相覆盖。

Filer 和构建工具的生成源目录正是为了解决这些问题。


十五、并发、状态和处理器实例

Java 注解处理 API 由编译器调用。处理器不应假定:

  • process() 每次都在新的对象上调用;
  • 不同编译器的调用顺序完全一致;
  • 可以无限期保留上一次编译的状态;
  • 多个构建任务之间共享一个处理器实例。

通常,一个编译任务会创建并初始化处理器实例,然后执行多个轮次。处理器可以在实例字段中保存当前编译任务的收集状态,例如:

private final Map<String, String> discovered = new TreeMap<>();

但不应把状态写入静态字段,因为同一个 JVM 中可能连续运行多个编译任务,静态状态会造成交叉污染:

private static final Set<String> GLOBAL = new HashSet<>();

处理器还应避免修改共享外部文件。构建工具可能并行执行多个模块,外部文件写入会产生竞争条件,并破坏可复现性。

如果处理器自身使用线程池,必须额外保证:

  • FilerMessager 和模型访问的线程安全边界;
  • 同一个文件不会被并发创建;
  • 所有任务在 process() 返回前正确结束;
  • 异常不会被后台线程吞掉。

对于普通处理器,最安全的模型是:在 process() 调用线程中顺序读取模型并生成输出。


十六、规范保证、编译器行为和工程约定

需要区分三类事实。

Java 语言和标准 API 的保证

Java 语言规范定义注解语法、声明位置和类型规则;Java SE 的注解处理 API 定义处理器接口、轮次环境、元素模型和文件创建接口。

javac 的实现行为

javac 提供:

-XprintRounds
-XprintProcessorInfo
-proc:none
-proc:only
-s
-Akey=value

这些是 javac 的命令行能力。其他 Java 编译器不一定提供完全相同的诊断选项。

构建工具的工程约定

以下内容不属于 Java SE 规范:

  • 隔离型和聚合型处理器的增量分类;
  • 处理器缓存;
  • 生成输出的清理策略;
  • 编译避免和 ABI 分析;
  • 处理器是否默认启用;
  • 处理器路径的具体 DSL 写法。

因此,处理器可以依赖标准模型 API,但增量能力必须遵循目标构建工具的约定。


十七、模块化项目中的额外注意事项

如果处理器使用 Java 模块,应至少关注:

module demo.processor {
    requires java.compiler;

    provides javax.annotation.processing.Processor
        with demo.HelloProcessor;
}

处理器实现需要访问:

  • javax.annotation.processing
  • javax.lang.model
  • javax.tools

这些 API 位于 java.compiler 模块中。

应用模块与处理器模块的关系通常是:

应用源码模块 ──编译期使用──► 处理器模块
应用运行时   ──不必依赖──► 处理器模块

如果把处理器及其实现依赖打入应用运行时,可能导致:

  • 运行时类路径膨胀;
  • 处理器依赖被错误暴露;
  • 处理器中的初始化逻辑在运行时意外加载;
  • 供应链风险扩大。

处理器本质上是构建工具执行的代码,应像编译插件一样单独管理和审计。


十八、安全边界

启用注解处理器意味着:构建过程会执行来自依赖的 Java 代码。处理器理论上可以:

  • 读取项目文件;
  • 访问环境变量;
  • 访问网络;
  • 创建或删除文件;
  • 执行外部进程;
  • 生成任意源码或资源。

Java SE 的注解处理 API 并不会自动限制这些行为。因此:

  • 不要从不可信来源随意引入处理器;
  • 处理器依赖应锁定版本并进行供应链审计;
  • 构建环境应限制不必要的网络和文件系统权限;
  • 处理器生成的代码应进入代码审查或至少进入构建产物检查;
  • 生产构建和本地构建应使用一致的处理器版本。

这与运行时依赖不同:运行时依赖通常在应用启动时执行,处理器则在开发者编译和 CI 构建时执行。


十九、何时适合使用注解处理器

注解处理器适合满足以下条件的任务:

输入是 Java 声明或注解
输出可以表示为新的 Java 类型、类文件或资源
错误最好在编译期暴露
生成结果应纳入普通编译和依赖分析

它不适合直接解决:

  • 修改已有源文件;
  • 依赖运行时对象状态;
  • 需要动态加载和运行时发现的问题;
  • 必须观察完整字节码控制流的问题;
  • 需要对任意语言进行复杂源代码重写的问题。

如果需求是检查已有 .class 的字节码结构,字节码分析工具更合适;如果需求是运行时插件发现,ServiceLoader 或模块服务机制更直接;如果需求是修改源码,则应使用专门的源码重写工具,而不是绕过 Filer


二十、从一个处理器检查结果反推构建问题

可以用下面的最小验证路径隔离问题:

第一步:处理器 JAR 能否被发现?
    └─ -XprintProcessorInfo

第二步:是否进入预期轮次?
    └─ -XprintRounds

第三步:是否生成了源文件?
    └─ -proc:only + -s

第四步:生成源是否语法正确?
    └─ 直接查看 build/generated

第五步:生成源是否生成 .class?
    └─ 检查 -d 输出目录

第六步:其他编译任务是否看到了 .class?
    └─ 检查 classpath/module-path

第七步:只有增量构建失败?
    └─ 检查 originating element 和处理器增量分类

这种顺序把问题分成了五个不同层次:

发现问题
→ 轮次问题
→ 生成问题
→ 普通 Java 编译问题
→ 构建工具增量问题

如果不区分这些层次,常见结果是修改处理器逻辑去修复一个实际由类路径配置导致的问题,或者修改构建缓存去掩盖生成文件重复创建的问题。


结语

Java 注解处理器的核心不是“根据注解拼接字符串”,而是一个由编译器驱动的模型处理过程:

源代码声明
    └─ Element / TypeMirror 模型
         └─ 多轮 process()
              └─ Filer 输出
                   └─ javac 后续轮次
                        └─ 普通类型检查和编译

正确的处理器需要同时满足三个条件:

  1. 使用 ElementTypeMirrorElementsTypes 正确理解编译期模型;
  2. 通过 Filer 生成稳定、唯一、可编译且可追踪的输出;
  3. 根据真实依赖关系处理增量构建,而不是用错误的隔离声明换取表面性能。

调试时,应先确认处理器是否被发现,再检查轮次、生成源、类文件和构建工具依赖图。只有把这些阶段分开,才能准确判断问题究竟发生在 Java 模型、处理器逻辑、生成代码,还是增量构建系统。


系列导航与关联阅读

官方资料

本文依据 Java、Spring 与相关项目官方文档重新梳理;正文、示例与生产清单由 WR BLOG 编写。