楼层: 首页/ 软件技术/ Java 基础/ 注解处理器:在编译期就把代码写出来
17

注解处理器:在编译期就把代码写出来

Annotation Processing · APT · kapt / KSP

你写 @Data 就白得一堆 getter;写一个接口,MapStruct 就帮你生成实现类;写 @Inject,Dagger 就把依赖关系连好——这些代码你从来没写过,但它们确实存在于最终产物里。干这件事的技术就是注解处理器(APT)。这一章带你手写一个能跑的处理器,并讲清 Java/Kotlin 世界里 APT、kapt、KSP 三条路线的关系,以及 JDK 23 那个让 Lombok 集体"静默失效"的变更。

注解处理器是什么:编译期生成代码的另一条路

是什么:注解处理器是 javac 在编译过程中回调的一类插件。它实现 javax.annotation.processing.Processor(通常继承 AbstractProcessor),能读到源码里的类型和注解信息,也能写出新的 .java 源文件让编译器继续编译。

为什么值得学:它和"运行时反射"是解决同一类问题的两条路,而 APT 在三个维度上更优:

注解处理器 vs 运行时反射:同一个需求,两种代价完全不同的实现
维度运行时反射注解处理器(编译期生成)
运行时开销每次都要查注解、做反射调用(Method.invoke 有额外开销,虽然 JIT 会优化一部分)。零开销——生成的代码和你手写的一样,就是普通的方法调用。
错误暴露时机写错了要等运行到那一行才发现,通常是线上。编译期就报错,CI 直接红,根本进不了仓库。
类型安全字符串类名、Map 传参,编译器帮不了你。生成的是真正的 Java 代码,编译器全程检查。
调试体验反射调用栈又深又难读。生成的代码就在你的构建目录里,可以直接下断点单步。
代价代码写法自由,不需要额外配置。构建变慢(多跑一遍编译);生成代码有"魔法感",用得不当会很难维护。

怎么用:一个处理器的最小组成只有三部分:① 一个注解、② 一个 Processor 实现类、③ 一个注册文件(告诉 javac 去哪找这个处理器)。下一节我们把它完整写出来。

手写一个能跑的注解处理器

是什么:我们要实现的功能很简单:给一个类标注 @GenerateInfo("xxx"),处理器自动生成一个 类名Info.java,里面带上类名和注解里的字符串。麻雀虽小,但 APT 的全部关键机制都在里面。

① 定义注解:注意 RetentionPolicy.SOURCE

// src/com/example/apt/GenerateInfo.java
package com.example.apt;

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

/**
 * SOURCE 策略:注解只存在于源码,不进 class 文件。
 * 既然代码在编译期就生成好了,运行时没必要再带着这个注解。
 */
@Retention(RetentionPolicy.SOURCE)
@Target(ElementType.TYPE)
public @interface GenerateInfo {
    String value();
}

② 处理器实现:读注解 → 用 Filer 生成源码

// src/com/example/apt/InfoProcessor.java
package com.example.apt;

import javax.annotation.processing.AbstractProcessor;
import javax.annotation.processing.Filer;
import javax.annotation.processing.RoundEnvironment;
import javax.annotation.processing.SupportedAnnotationTypes;
import javax.annotation.processing.SupportedSourceVersion;
import javax.lang.model.SourceVersion;
import javax.lang.model.element.Element;
import javax.lang.model.element.TypeElement;
import javax.tools.Diagnostic;
import java.io.IOException;
import java.io.Writer;
import java.util.Set;

// 声明"我处理哪个注解"和"我支持到哪个源码版本"(后者不写会有编译警告)
@SupportedAnnotationTypes("com.example.apt.GenerateInfo")
@SupportedSourceVersion(SourceVersion.RELEASE_21)
public class InfoProcessor extends AbstractProcessor {

    @Override
    public boolean process(Set<? extends TypeElement> annotations,
                              RoundEnvironment roundEnv) {
        // 取出所有被 @GenerateInfo 标注的元素(这里限定只能标在类上,所以就是类)
        for (Element e : roundEnv.getElementsAnnotatedWith(GenerateInfo.class)) {
            TypeElement type = (TypeElement) e;
            GenerateInfo ann = type.getAnnotation(GenerateInfo.class);

            String pkg = processingEnv.getElementUtils()
                    .getPackageOf(type).getQualifiedName().toString();
            String simple = type.getSimpleName().toString();
            String genName = simple + "Info";

            // 用 Filer 创建源文件;第二个参数是"由谁引发的生成",用于报错定位
            Filer filer = processingEnv.getFiler();
            try (Writer w = filer
                    .createSourceFile(pkg + "." + genName, type)
                    .openWriter()) {
                w.write("package " + pkg + ";\n\n");
                w.write("/** 由 InfoProcessor 自动生成,请勿手工修改。 */\n");
                w.write("public final class " + genName + " {\n");
                w.write("    public static final String NAME = \"" + simple + "\";\n");
                w.write("    public static final String VALUE = \"" + ann.value() + "\";\n");
                w.write("    private " + genName + "() {}\n");
                w.write("}\n");
            } catch (IOException ex) {
                // 关键:处理器里"报错"要用 Messager,它会把错误挂到对应的源码元素上
                processingEnv.getMessager().printMessage(
                        Diagnostic.Kind.ERROR,
                        "生成 " + genName + " 失败:" + ex.getMessage(), type);
            }
        }
        return true;   // 认领这些注解:别的处理器就不用再看它们了
    }
}

③ 注册处理器:在 META-INF/services 里写一行

# 文件路径(必须是这个全名,javac 按 Java 的 SPI 规则来查找)
# src/META-INF/services/javax.annotation.processing.Processor

# 文件内容就一行:处理器的全限定类名
com.example.apt.InfoProcessor

# 进阶:项目里处理器多了之后,这个文件要手工维护很烦,
# 可以用 Google 的 @AutoService(Processor.class) 让它自动生成这一行。

④ 使用它:App.java 里直接用还没生成的 AppInfo

// src/com/example/apt/App.java
package com.example.apt;

@GenerateInfo("hello apt")
public class App {
    public static void main(String[] args) {
        // AppInfo 此刻还不存在,是处理器在编译过程中"变"出来的
        System.out.println(AppInfo.NAME + " -> " + AppInfo.VALUE);
    }
}

⑤ 编译与运行:注意"先编处理器、再用处理器编业务代码"这个顺序

# 第 1 步:把"注解 + 处理器"编出来,编它的时候不需要跑处理器(-proc:none)
javac -d processor-out -proc:none \
      src/com/example/apt/GenerateInfo.java \
      src/com/example/apt/InfoProcessor.java
cp -r src/META-INF processor-out/

# 第 2 步:用刚才的处理器编译业务代码
#   -processorpath  处理器自己的 jar/目录(不要混进业务类路径)
#   -s gen          生成出来的 .java 源码放哪(想看生成结果就靠它)
javac -d app-out -s gen -processorpath processor-out src/com/example/apt/App.java

# 第 3 步:看看它到底生成了什么 -- 这一步非常重要,别把生成代码当黑盒
cat gen/com/example/apt/AppInfo.java
#   package com.example.apt;
#   /** 由 InfoProcessor 自动生成,请勿手工修改。 */
#   public final class AppInfo {
#       public static final String NAME = "App";
#       public static final String VALUE = "hello apt";
#       private AppInfo() {}
#   }

# 第 4 步:运行
java -cp app-out com.example.apt.App
# 输出:App -> hello apt

# ── 三个排查用的开关 ──
javac -XprintRounds ...        # 打印每一轮处理了哪些注解(看懂"轮次"必用)
javac -XprintProcessorInfo ... # 打印是哪个处理器在处理哪个注解
javac -proc:only ...           # 只跑处理器、不编译业务代码
坑:处理器里用 System.out.println 调试,然后以为没跑

处理器的执行时机在编译期,而且 javac 有可能会把输出吞掉(IDE 里尤其明显)。想看调试信息,正确工具是 processingEnv.getMessager():

messager.printMessage(Diagnostic.Kind.NOTE, "正在处理 " + type.getSimpleName(), type); —— 它会把信息挂在对应的源码位置上输出,配合 -XprintProcessorInfo 一起用,定位问题比 println 靠谱得多。另外要报错就用 Diagnostic.Kind.ERROR,它会直接让编译失败并指出出错的那一行源码——这正是注解处理器最值钱的地方:把错误提前到编译期。

处理轮次与 Element API:处理器的"世界观"

是什么:注解处理不是"一轮跑完",而是循环多轮:第一轮处理原始源码 → 如果生成了新源文件 → 新文件进入第二轮 → 以此类推,直到某一轮没有新文件产生,最后再调用一次 process,此时 roundEnv.processingOver() 返回 true。

为什么必须理解轮次:因为一个非常常见的 bug 就在这里:你在第一轮里想"收集所有被标注的类,然后统一生成一个汇总类"——但第一轮时你只看到了原始源码里的那些类,由其他处理器生成的类还没出现。正确做法是:每轮都收集到自己的集合里,等到 processingOver() 为真时的最后一轮再生成汇总文件。

两个核心 API 家族:一个看"轮次",一个看"元素"
API用途
roundEnv.getElementsAnnotatedWith(A.class)取本轮里所有被注解 A 标注的元素。
roundEnv.processingOver()是否为最后一轮——生成"汇总类"必须等到这一轮。
roundEnv.errorRaised()本轮之前是否已经报过错,出错时通常就别再生成代码了。
processingEnv.getElementUtils()工具集:查包名、取类型、判断子类型关系等。
processingEnv.getFiler()创建新的源文件、class 文件或资源文件。
processingEnv.getMessager()报错/警告/提示信息(处理器里唯一的输出通道,别用 println)。
PackageElement / TypeElement / ExecutableElement / VariableElement元素家族的四个成员,分别代表包 / 类或接口 / 方法或构造器 / 字段或参数。你写的普通 Java 代码要"读"注解,靠的就是它们。
element.getEnclosedElements() / getSimpleName() / getAnnotation() / asType()元素上的常用方法:递归取成员、取名字、读注解、取类型。
坑:process 的返回值随便写、在处理器里改 AST

两个都很隐蔽的坑:① 返回 true 表示"我认领了这些注解"——如果返回 true,其他处理器就再也看不到这些注解了。所以:确定自己完整处理了才返回 true;只是"顺便看一眼"就返回 false。② 不要在处理器里试图修改已有的类。标准的 APT API 只支持"读元素 + 生成新文件",不能改别人的源码。Lombok 之所以能做到"给类加 getter",是因为它绕过了标准 API,直接改编译器的内部语法树——代价是它强依赖 com.sun.tools.javac.* 这些内部类,在 JDK 16+ 的强封装环境下需要它自己做兼容处理。这就是"每次升级 JDK,Lombok 都要跟着升级"的根本原因,也是为什么很多团队宁愿用 MapStruct 这种"生成新类、不改旧类"的工具。

JDK 23 那个变更:为什么处理器会"静默失效"

是什么:从 2006 年引入注解处理开始,javac 一直默认会把类路径上发现的处理器全都跑一遍。从 JDK 23 起,这个默认行为被改掉了:如果没有显式提供 -processor、--processor-path 或 --processor-module-path 中的任何一个,注解处理就不会执行(需要显式写 -proc:full 才能恢复旧行为)。JDK 21 和 22 只是先打印一条提醒,作为过渡。

为什么这是一件大事:因为大量项目其实一直"依赖巧合"在工作——classpath 里恰好有 Lombok 的 jar,于是处理器被隐式发现并执行了。升级到 JDK 23 后,这种"巧合"消失,报错却是"找不到符号"这种完全指不到根因的形式(因为生成的代码不再产生,而你的源码里引用了它们)。

症状与解法

# ── 症状:一堆"找不到符号",但代码明明没动过 ──
# App.java:12: 错误: 找不到符号
#     System.out.println(AppInfo.NAME);
#                              ^
#   符号:   变量 AppInfo
# ── 而且不会提示"注解处理器没跑",这才是最坑的地方 ──

# ── 用 -Xlint:processing 也看不到处理器的信息,正好说明"根本没进注解处理流程" ──
javac -Xlint:processing -cp lombok.jar -d out App.java

# ── 解法一:显式指定处理器路径(推荐,规范做法) ──
javac -processorpath libs/lombok.jar -d out src/com/example/App.java

# ── 解法二:显式声明"我要完整的注解处理"(快速恢复旧行为) ──
javac -proc:full -cp libs/lombok.jar -d out src/com/example/App.java
#   注意:-proc:full 已被向后移植到 JDK 8u / 11u / 17u,老项目可以提前加上做适配

# ── 想彻底关掉注解处理(比如编译一个明确不需要处理器的模块): ──
javac -proc:none -d out src/com/example/App.java
不同工具链的正确配置:官方动机是安全,不是"折腾你"
工具怎么做
裸 javac-processorpath 指定处理器;或 -proc:full 恢复旧默认。
Gradle用 annotationProcessor("...") 声明——Gradle 本来就是显式传参,所以不受这次变更影响(这也是它一直推荐这么写的原因)。
Maven在 maven-compiler-plugin 里用 <annotationProcessorPaths> 列出处理器;Maven Compiler Plugin 4.x 还引入了 processor 这种专门的依赖类型。不要依赖把处理器塞进 <dependencies> 的老做法。
IntelliJ IDEASettings → Build, Execution, Deployment → Compiler → Annotation Processors,勾选 Enable annotation processing。注意:这只会让 IDEA 自己的编译生效,命令行/CI 上照样会失败——所以别把这个当解决方案,要去改构建脚本。

官方为什么要这么改:因为"类路径上任何一个 jar 只要在 META-INF/services 里注册了处理器,就可以在编译期执行任意代码"——在依赖动辄几百个 jar 的今天,这等于给供应链攻击开了一扇门。改成显式声明之后,构建行为变得可审计、可预测,这本身是件好事。

APT / kapt / KSP:三条路线的取舍

是什么:Java 的注解处理机制(APT)在 Kotlin 时代遇到了麻烦——Kotlin 编译器不是 javac,它不认 javax.annotation.processing 那套。于是社区先后出现了两条路:kapt(把 Kotlin 代码变成 Java 存根,再交给 javac 跑 APT)和 KSP(Kotlin 自己的符号处理 API)。

三条路线对比:新项目直接用 KSP,老项目继续 kapt 也不用焦虑
APT(javac 原生)kaptKSP
面向语言JavaKotlin(兼容旧的 Java 处理器)Kotlin 原生
原理javac 直接回调处理器,读 Element。先由 Kotlin 编译器生成 Java 存根,再让 javac 在上面跑一遍注解处理,最后把结果映射回 Kotlin。KSP 处理器直接读 Kotlin 的符号表,不再绕道 Java 存根。
速度基准。慢:多了"生成存根 + 再跑一遍完整 javac"这两步开销。明显更快(编译任务越重、模块越大,差距越明显),且对增量编译的支持更好。
API 体验Element/Mirror API,学习成本较高。和 APT 一样(就是 APT 的 API)。API 更贴近 Kotlin 的符号模型,写起来更简单。
现状与选择Java 项目的唯一选择。仍可用、仍在维护,但官方推荐新项目迁移。新项目首选。Room、Moshi 等主流库都提供了 KSP 版本。

在构建脚本里怎么声明(Gradle Kotlin DSL)

// ── 纯 Java 项目:就是普通的 annotationProcessor 配置 ──
dependencies {
    compileOnly("org.projectlombok:lombok:1.18.34")        // 注解本身:编译期需要
    annotationProcessor("org.projectlombok:lombok:1.18.34") // 处理器:只在编译期跑
}

// ── Kotlin 项目用 kapt(老方式) ──
// plugins { kotlin("kapt") }
// dependencies { kapt("com.example:my-processor:1.0") }

// ── Kotlin 项目用 KSP(推荐) ──
// plugins { id("com.google.devtools.ksp") version "<Kotlin 版本>-<KSP 版本>" }
//   KSP 的版本号跟 Kotlin 版本绑定,形如 2.1.0-1.0.29,升级 Kotlin 时要一起升
// dependencies { ksp("com.example:my-processor:1.0") }
//   KSP 生成的代码默认在 build/generated/ksp/... 需要的话把它加进源码集:
// kotlin.sourceSets.main { kotlin.srcDir("build/generated/ksp/main/kotlin") }
坑:把处理器当普通依赖引进运行时类路径

如果你写 implementation("org.projectlombok:lombok"),处理器就会被塞进打包产物和运行时类路径。三个后果:① 产物体积无端变大;② 有些处理器在运行时被意外触发(更糟的是隐式发现被 JDK 23 禁掉后,行为在不同环境不一致);③ 和 annotationProcessor 重复声明时,可能触发"重复生成/重复处理"的问题。正确姿势永远是一对:API/注解用 compileOnly,处理器用 annotationProcessor(Kotlin 用 kapt 或 ksp)。还有一个常被忽略的细节:处理器与注解库的版本必须一致(Lombok 尤其明显:旧版 Lombok 配新版 JDK 常常直接崩),升级 JDK 时别忘了同步升处理器。

生态全景:这些你天天用的库,背后都是注解处理器

认识它们,能让你在"要不要引一个新注解库"时判断得更准
工具生成什么说明
Lombokgetter/setter/构造器…最特殊的一个:它不生成新类,而是直接改 AST(依赖 javac 内部 API)。省代码效果最好,但绑定编译器版本,且 IDE 必须装插件。
MapStruct对象映射的实现类标准 APT 的典范:你写接口 + @Mapper,它生成实现。纯编译期、零反射、可直接调试,是 DTO 转换的主流方案。
Dagger / Hilt依赖注入的工厂类把"如何在编译期确定依赖图"做到极致,错误在编译期就报出来(漏绑定、循环依赖)。
AutoValue / Immutables不可变值对象生成 equals/hashCode/toString/builder,避免手写这些易错代码。
AutoServiceMETA-INF/services 文件写 Java SPI 时免去手工维护服务文件(写注解处理器时也常用它来注册自己)。
Room(KSP)DAO 实现、SQL 校验Android 数据库框架,在编译期校验 SQL 语句——写错 SQL 直接编译失败,这是 APT 相比运行时 ORM 的最大优势之一。
QueryDSL类型安全的查询对象让 JPA 查询从"拼字符串"变成"写带类型检查的代码"。

判什么时候该自己写一个注解处理器

值得写:① 团队里有一件事重复写了几十遍且模式固定(样板 DTO 转换、注册表、路由表);② 你希望这类错误在编译期就拦住(比如"每个 Handler 必须实现某个方法");③ 你想在编译期校验注解用法(比如"@Scheduled 的 cron 表达式必须合法")。

不值得写:① 只为了少写几行 getter(用 Lombok 就好);② 逻辑复杂到生成的代码"没人能一眼看懂"——注解处理器最贵的成本不是写它,而是后面五年里所有人维护它的心智负担;③ 团队里没有人熟悉编译期 API。

一条底线:生成的代码必须能被看到、能被调试。把生成目录(build/generated/...)加进 IDE 的源码视图、别把它加进 .gitignore 之外的东西——如果团队里没人知道"这些代码是谁生成的、怎么改",那这个处理器就是技术债。

记
本章小结

① 注解处理器 = 编译期的代码生成器:读注解(Element API)→ 生成新源码(Filer)→ 编译器接着编。三段式:注解 + Processor + META-INF/services 注册文件。

② 记住两个关键机制:处理是多轮循环的(要生成"汇总类"必须等到 processingOver());process() 返回 true 表示认领注解,别人就看不到了。报错用 Messager,别用 println。

③ JDK 23 起 javac 不再自动扫描类路径上的处理器(要 -processorpath 或 -proc:full),这是"升 JDK 后一堆找不到符号"的头号原因;Kotlin 世界新项目用 KSP,Java 项目用标准的 annotationProcessor 配置。