Java 注解处理器(Annotation Processor)是 JSR 269 提供的一套标准 API,让你在 javac 编译源码时就能读取注解、生成新代码甚至拦截非法写法。掌握它,你就能像 Lombok 一样让编译器替你写样板代码。本文从零手写一个 Builder 生成器,再剖开 Lombok 改写 AST 的底层原理,并附一份排雷清单。
一、什么是 Java 注解处理器
普通注解只是”标记”,运行时靠反射读取;而 Java 注解处理器工作在更早的编译期。编译器每轮(round)把带注解的元素交给你的 AbstractProcessor.process(),你可以用 Filer 写出新的 .java 源文件,用 Messager 输出警告或报错,从而把大量重复劳动提前到编译阶段解决。
为什么要在编译期做?运行时反射虽然灵活,但错误只能等到上线才暴露,且每次启动都有反射开销。Java 注解处理器把问题挡在打包之前,生成的代码仍是普通 Java,调试和阅读都没有黑盒。很多明星库底层都是它:MapStruct 做 Bean 映射、AutoValue 做不可变值类型、Dagger 做编译期依赖注入。理解这套机制,你就明白这些”魔法”并不神秘。
二、动手写一个最小处理器:自动生成 Builder
Builder 模式能避免”叠加构造器”带来的可读性灾难,但手写每个字段的 setter 同样枯燥。用注解处理器生成,业务类只留一个注解,编译后多出一个同包下的 Builder 类,调用方零感知——这正是很多代码生成工具的核心思路。
2.1 定义注解
package demo.gen;
import java.lang.annotation.*;
@Target(ElementType.TYPE)
@Retention(RetentionPolicy.SOURCE) // 只留在源码,编译后不需要
public @interface BuilderGen {
}
2.2 实现 Processor
package demo.gen;
import javax.annotation.processing.*;
import javax.lang.model.SourceVersion;
import javax.lang.model.element.*;
import javax.tools.JavaFileObject;
import java.io.Writer;
import java.util.Set;
@SupportedAnnotationTypes("demo.gen.BuilderGen")
@SupportedSourceVersion(SourceVersion.RELEASE_21)
public class BuilderProcessor extends AbstractProcessor {
@Override
public boolean process(Set<? extends TypeElement> annotations,
RoundEnvironment roundEnv) {
for (Element e : roundEnv.getElementsAnnotatedWith(BuilderGen.class)) {
TypeElement type = (TypeElement) e;
String pkg = processingEnv.getElementUtils()
.getPackageOf(type).getQualifiedName().toString();
String name = type.getSimpleName().toString();
try {
JavaFileObject file = processingEnv.getFiler()
.createSourceFile(pkg + "." + name + "Builder");
try (Writer w = file.openWriter()) {
w.write("package " + pkg + ";\n");
w.write("public class " + name + "Builder {\n");
w.write(" public static " + name + "Builder create() {\n");
w.write(" return new " + name + "Builder();\n");
w.write(" }\n");
w.write("}\n");
}
} catch (Exception ex) {
throw new RuntimeException(ex);
}
}
return true; // 声明我已处理该注解
}
}
2.3 注册与构建配置
在标准 SPI 文件里登记全限定类名,编译器就会自动加载它:
# src/main/resources/META-INF/services/javax.annotation.processing.Processor
demo.gen.BuilderProcessor
// build.gradle(与注解同模块时)
plugins { id 'java' }
repositories { mavenCentral() }
// Gradle 会按 META-INF/services 自动发现并运行处理器
compileJava {
options.annotationProcessorPath = configurations.compileClasspath
}
三、编译期到底发生了什么
编译器会反复执行”轮次”:第一轮处理源码里的注解并生成新文件,新文件进入下一轮继续被处理,直到没有新文件产生。init() 注入 ProcessingEnvironment,process() 每轮调用,roundEnv.processingOver() 为 true 时表示最后一轮。注意:处理器不能直接修改正在编译的源文件,只能新增源文件或报错——这正是 Lombok 要”走后门”的原因。
除了 Filer 和 Messager,处理器还能拿到 Elements 和 Types 两个工具:前者按包、类、方法、字段的层级遍历源码结构,后者做类型比较与赋值判定。一个常见陷阱是 process() 可能在同一轮被多次调用,务必用 processingOver() 判断收尾,避免重复写文件抛 IOException。另外,生成的源文件也会被后续轮次扫描,若它又带上了同一注解就会无限循环——所以本例给生成类起了一个不含 @BuilderGen 的名字。
四、实战:用注解处理器做编译期校验
@SupportedAnnotationTypes("demo.gen.NonNull")
public class NonNullValidator extends AbstractProcessor {
@Override
public boolean process(Set<? extends TypeElement> a, RoundEnvironment env) {
for (Element f : env.getElementsAnnotatedWith(NonNull.class)) {
processingEnv.getMessager().printMessage(
Diagnostic.Kind.NOTE,
"发现非空字段: " + f.getSimpleName(), f);
}
return true;
}
}
想做真正的”非空赋值检查”需要数据流分析,标准 API 力不从心——这也是 Java 21 Record 与 Checker Framework 这类方案存在的价值。处理器更适合”生成代码 + 轻量契约校验”。严格来说,标准处理器拿不到”某字段被赋值前是否为 null”这种跨语句语义,只能基于声明做检查,例如”标了 @NonNull 的字段必须有初始值”。要做真正的空安全,需要基于类型注解的数据流分析,或干脆在测试里覆盖。把”生成”和”校验”两件事分开,是处理器设计的好习惯。
五、Lombok 是怎么做到的:原理剖析
Lombok 同样基于 JSR 269 接入编译,但它不止于”生成新文件”:它直接拿到编译器内部的 AST(com.sun.tools.javac 的抽象语法树),在字节码生成前往当前编译单元注入 getter/setter/构造器/equals 等节点。所以 @Data 不会产出第二个 .java,而是让原有类”凭空”多出方法。
// 你写的
@Data
public class User {
private Long id;
private String name;
}
// 编译器看到的(经 Lombok 改写 AST 后)
public class User {
private Long id; private String name;
public Long getId() { return id; }
public void setName(String name) { this.name = name; }
// equals / hashCode / toString 同此 ...
}
需要排查时,delombok 会把改写后的完整 Java 文件吐出来,让你看清 Lombok 究竟注入了什么;这也是为什么团队引入 Lombok 时,建议把 delombok 输出纳入 code review 样例,避免”看不见的代码”悄悄改变行为。
| 维度 | 标准注解处理器 | Lombok |
|---|---|---|
| 入口 | AbstractProcessor.process | 同样基于 JSR 269,但改写 AST |
| 产物 | 生成新的 .java 源文件 | 直接修改编译单元,无新文件 |
| 能力边界 | 只能新增源码 / 报错提示 | 可增删方法、字段、构造器 |
| 兼容性 | 标准 API,跨编译器稳定 | 依赖 javac 内部实现,升级有风险 |
| 调试 | 生成文件可见 | 需 delombok 才能看到生成代码 |
六、常见坑与排雷清单
| 坑 | 现象 | 排雷 |
|---|---|---|
| 处理器不生效 | 注解像没存在 | 检查 META-INF/services 路径与类名是否拼对 |
| 无限轮次 | 编译卡死 | 生成的新类别再被同一注解标中 |
| 模块系统报错 | Java 9+ 非法反射 | 在 module-info 中显式 opens 包给 jdk.compiler |
| Lombok 升级炸 | 编译突然失败 | 固定版本,升级前跑全量测试 |
| 多处理器打架 | MapStruct 不生成 | 控制 annotationProcessor 顺序或隔离 |
七、什么时候用、什么时候别用
适合用注解处理器:DTO/Builder/序列化、注解驱动的脚手架、团队契约的编译期校验(可接 GitHub Actions 在 CI 强制跑一遍)。简单不可变数据优先用 Java 21 Record 替代手写 DTO,重型并发逻辑参考 结构化并发,日志统一见 Java 日志体系。给团队的具体建议:基础设施团队可把通用脚手架(统一响应体、领域事件注册)做成处理器,让业务方只写注解;但凡涉及”改动业务类行为”的需求,先评估是否拉低可读性,必要时配合可观测方案统一埋点。Lombok 在业务代码里收益明显,但在会被第三方依赖的公共 SDK 中最好收敛,避免把 javac 内部耦合扩散出去。
八、小结
Java 注解处理器是编译期元编程的标准入口,擅长”生成代码 + 轻量校验”;Lombok 则是同一套机制上更激进的 AST 改写。理解二者边界,你既能自己造轮子,也能在”要不要用 Lombok”上做出清醒取舍:能标准处理器解决的,不碰内部 API;能 Record 表达的,不堆注解。当你下次看到 @Data、@Builder 时,心里应该清楚——它们不是运行时魔法,而是编译期对 AST 的一场精准手术。




