注解处理器:在编译期就把代码写出来
你写 @Data 就白得一堆 getter;写一个接口,MapStruct 就帮你生成实现类;写 @Inject,Dagger 就把依赖关系连好——这些代码你从来没写过,但它们确实存在于最终产物里。干这件事的技术就是注解处理器(APT)。这一章带你手写一个能跑的处理器,并讲清 Java/Kotlin 世界里 APT、kapt、KSP 三条路线的关系,以及 JDK 23 那个让 Lombok 集体"静默失效"的变更。
注解处理器是什么:编译期生成代码的另一条路
是什么:注解处理器是 javac 在编译过程中回调的一类插件。它实现 javax.annotation.processing.Processor(通常继承 AbstractProcessor),能读到源码里的类型和注解信息,也能写出新的 .java 源文件让编译器继续编译。
为什么值得学:它和"运行时反射"是解决同一类问题的两条路,而 APT 在三个维度上更优:
| 维度 | 运行时反射 | 注解处理器(编译期生成) |
|---|---|---|
| 运行时开销 | 每次都要查注解、做反射调用(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 ... # 只跑处理器、不编译业务代码
处理器的执行时机在编译期,而且 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 | 用途 |
|---|---|
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 IDEA | Settings → 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)。
| APT(javac 原生) | kapt | KSP | |
|---|---|---|---|
| 面向语言 | Java | Kotlin(兼容旧的 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 时别忘了同步升处理器。
生态全景:这些你天天用的库,背后都是注解处理器
| 工具 | 生成什么 | 说明 |
|---|---|---|
| Lombok | getter/setter/构造器… | 最特殊的一个:它不生成新类,而是直接改 AST(依赖 javac 内部 API)。省代码效果最好,但绑定编译器版本,且 IDE 必须装插件。 |
| MapStruct | 对象映射的实现类 | 标准 APT 的典范:你写接口 + @Mapper,它生成实现。纯编译期、零反射、可直接调试,是 DTO 转换的主流方案。 |
| Dagger / Hilt | 依赖注入的工厂类 | 把"如何在编译期确定依赖图"做到极致,错误在编译期就报出来(漏绑定、循环依赖)。 |
| AutoValue / Immutables | 不可变值对象 | 生成 equals/hashCode/toString/builder,避免手写这些易错代码。 |
| AutoService | META-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 配置。