ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

Lombok原理揭秘:Java编译器插件与AST修改机制

Lombok原理揭秘:Java编译器插件与AST修改机制

简介:本资源深入剖析Lombok在Java开发中的编译期代码生成机制,面向中高级Java开发者、注解处理器学习者及IDE插件原理研究者,解决样板代码冗余、编译时增强实现逻辑不透明等实际痛点。压缩包共40个文件,含9个核心Java源码(涵盖自定义注解与处理器实现)、10个对应class字节码、5个编译过程日志文件(.lst),以及pom.xml构建配置、iml项目元数据、camunda流程定义(bpmn/dmn)等辅助模块,整体仅88KB,轻量聚焦原理验证。已有666人学习下载。读者可直接运行并调试完整的Lombok注解处理器链路,观察ASM字节码注入全过程,对比源码与生成字节码差异,并复现IDE插件预览逻辑;目录结构清晰分层(allens-annotation、allens-library、allens-camunda),便于理解Lombok模块化设计与第三方集成方式。

1. Lombok 不是魔法,是编译器插件:它在 javac 还没生成 class 文件前就偷偷改了你的 AST

你写完@Data,IDE 里 Ctrl+Click 能跳转到“生成的 getter/setter”,但反编译.class文件却真有这些方法——这说明什么?Lombok 的代码根本没在源码里,却实实在在跑在 JVM 上。这不是 IDE 欺骗你,而是它在javac编译流程的早期阶段(Annotation Processing阶段之后、Generate Bytecode阶段之前)直接修改了抽象语法树(AST)。我第一次在某跨平台系统里排查一个NullPointerException时,发现堆栈里报错行号和源码对不上,最后追到lombok.javac.JavacNode才明白:Lombok 已经把你的private String name;节点连同自动生成的getName()方法一起塞进了 AST 节点链表里。这个项目就是一份可运行、可调试、可断点的 Lombok 核心实现源码包,它不依赖任何 IDE 插件,纯 Java 编译器插件形态,完整复现了@Getter、@Setter、@ToString、@EqualsAndHashCode、@Data等最常用注解的 AST 注入逻辑。适合想搞懂「Java 注解到底能干多大事」的中级开发者,也适合需要定制字段序列化规则、或对接自研 ORM 框架的后端同学——因为你看得见每一步 AST 修改,改起来比写@Builder的@Singular还直白。


2. 编译器插件结构拆解:从javac的Plugin入口到JavacTreeMaker的节点注入

Lombok 的本质不是运行时库,而是一个被javac主动加载的编译器插件。它不靠反射、不靠字节码增强(如 ASM/CGLIB),而是在 AST 构建完成但尚未生成字节码时,用TreeMaker实例往语法树里“塞”新节点。整个流程分三步走:注册插件 → 扫描注解 → 修改 AST。下面逐层拆开这个黑匣子。

2.1 插件注册机制:javac是如何认出 Lombok 的?

javac启动时会扫描 classpath 下所有META-INF/services/com.sun.source.util.Plugin文件。Lombok 的lombok-core.jar里就包含这个文件,内容只有一行:

lombok.javac.apt.LombokProcessor

这个类继承自com.sun.source.util.Plugin,并重写了getSupportedOptions()和init()方法。关键在init():

@Override public void init(JavacTask task, String... args) { JavacTrees trees = JavacTrees.instance(task); Context context = ((JavacTaskImpl) task).getContext(); // 注册注解处理器 AnnotationProcessor processor = new AnnotationProcessor(context, trees); processor.init(task); }

提示:JavacTaskImpl是com.sun.tools.javac.api.JavacTaskImpl,属于 JDK 内部 API,所以 Lombok 必须用-Xbootclasspath/p:或--add-exports启动参数才能访问。这也是为什么某些 JDK 17+ 环境下要加--add-exports jdk.compiler/com.sun.tools.javac.api=ALL-UNNAMED。

2.2 注解扫描与上下文绑定:JavacNode是怎么封装 AST 节点的?

Lombok 不直接操作com.sun.tools.javac.tree.JCTree,而是用JavacNode做了一层封装。它把原始JCClassDecl、JCVariableDecl、JCMethodDecl等节点包装成带父子关系、作用域、注解缓存的树形结构:

public class JavacNode { private final JCTree tree; private final JavacNode parent; private final List<JavacNode> children = new ArrayList<>(); private final Map<Class<? extends Annotation>, AnnotationValues<?>> annotations = new HashMap<>(); public JavacNode(JCTree tree, JavacNode parent) { this.tree = tree; this.parent = parent; if (parent != null) parent.addChild(this); } }

当你写@Getter private String name;,Lombok 在遍历JCVariableDecl节点时,会调用node.getAst().findAnnotation(Getter.class)获取注解实例,并缓存进annotationsMap。后续所有@Getter逻辑都基于这个JavacNode实例展开,而不是反复解析 AST。

2.3 AST 修改核心:JavacTreeMaker如何安全插入 getter 方法?

生成getName()的关键不在字符串拼接,而在构造合法的JCMethodDecl并插入到类声明的defs列表中。看HandleGetter类的核心逻辑:

public void generateGetterForField(JavacNode fieldNode, JavacNode classNode, JavacTreeMaker maker) { JCVariableDecl field = (JCVariableDecl) fieldNode.get(); // 原始字段节点 Name methodName = fieldNode.toName("get" + capitalize(field.name.toString())); // 构造方法体:return this.name; JCExpression bodyExpr = maker.Select(maker.Ident(names._this), field.name); JCStatement returnStmt = maker.Return(bodyExpr); // 构造方法签名:public String getName() JCModifiers mods = maker.Modifiers(Flags.PUBLIC); JCExpression returnType = field.vartype; // 复用字段类型 List<JCTypeParameter> typeParams = List.nil(); List<JCVariableDecl> params = List.nil(); List<JCExpression> thrown = List.nil(); JCMethodDecl method = maker.MethodDef(mods, methodName, returnType, typeParams, params, thrown, maker.Block(0L, List.of(returnStmt)), null); // 插入到类定义的 defs 列表末尾(保持语义顺序) JCClassDecl clazz = (JCClassDecl) classNode.get(); clazz.defs = clazz.defs.append(method); }

这段代码的精妙在于:maker.MethodDef()返回的是JCMethodDecl,它和JCClassDecl、JCVariableDecl属于同一 AST 层级,javac后续字节码生成器会原样处理它。你不需要关心 JVM 指令,只要 AST 合法,.class就一定有对应方法。


3. 核心注解实现原理:@Data不是组合技,而是 AST 节点的批量调度器

@Data看似是@Getter + @Setter + @ToString + @EqualsAndHashCode + @RequiredArgsConstructor的简单叠加,但实际执行时存在强依赖顺序和冲突规避逻辑。比如@ToString需要等所有字段节点被@Getter/@Setter处理完再扫描;@EqualsAndHashCode又必须避开static和transient字段。Lombok 把这些规则编码在HandleData的调度流程里,而不是靠注解处理器的执行顺序。

3.1@Data的五阶段调度:从字段扫描到方法注入

HandleData的handle方法不是一股脑生成所有方法,而是按Phase分五步走:

Phase触发动作关键约束
PHASE_1_FIND_FIELDS收集所有非 static/transient 字段为后续@EqualsAndHashCode做准备
PHASE_2_HANDLE_GETTER_SETTER对每个字段调用HandleGetter.handle()和HandleSetter.handle()若字段已显式声明 getter,则跳过
PHASE_3_HANDLE_TOSTRING扫描PHASE_1收集的字段列表,生成toString()方法体支持@ToString.Exclude过滤
PHASE_4_HANDLE_EQUALS_HASHCODE基于PHASE_1字段生成equals()和hashCode()自动忽略@EqualsAndHashCode.Exclude字段
PHASE_5_HANDLE_CONSTRUCTOR生成全参构造器(仅含@NonNull字段)若已有显式构造器则跳过

这个调度模型保证了:即使你在字段上同时写了@ToString.Exclude和@Getter,@ToString阶段仍能正确跳过该字段,而@Getter依然生效。

3.2@EqualsAndHashCode的字段决策逻辑:AST 层面的语义分析

@EqualsAndHashCode的难点不在生成代码,而在判断哪些字段该参与比较。Lombok 不靠反射读取运行时字段,而是在 AST 层做静态分析:

private List<JCVariableDecl> getFieldsForEquality(JavacNode typeNode) { List<JCVariableDecl> candidates = new ArrayList<>(); for (JavacNode child : typeNode.down()) { if (child.get() instanceof JCVariableDecl) { JCVariableDecl field = (JCVariableDecl) child.get(); // 跳过 static / transient / synthetic 字段 if ((field.mods.flags & (Flags.STATIC | Flags.TRANSIENT)) != 0) continue; if (field.name.startsWith("$")) continue; // 跳过编译器生成字段 // 检查 @EqualsAndHashCode.Exclude 注解 if (child.hasAnnotation(EqualsAndHashCode.Exclude.class)) continue; // 检查 @EqualsAndHashCode.Include 注解(显式包含) if (child.hasAnnotation(EqualsAndHashCode.Include.class)) { candidates.add(field); continue; } // 默认包含:非 static/transient 且无 Exclude 注解 candidates.add(field); } } return candidates; }

注意field.name.startsWith("$")这一行——这是 Lombok 对javac生成的合成字段(如枚举的$VALUES)的硬编码过滤。这种细节只有读源码才能看到,文档里从不提。

3.3@Builder的嵌套类生成:AST 中的类中类构造术

@Builder是 Lombok 最复杂的注解之一,它要生成一个内部静态类Builder,再生成builder()静态工厂方法,还要处理@Singular的集合构建逻辑。其 AST 操作分三层:

  1. 顶层类注入:在原类X的defs里追加static class Builder { ... }
  2. Builder 类体填充:为每个字段生成private T fieldName;和public Builder fieldName(T value) { this.fieldName = value; return this; }
  3. build() 方法注入:构造new X(this.field1, this.field2, ...)调用

关键点在于Builder类的JCClassDecl必须设置Flags.STATIC,且其字段和方法的mods.flags要显式带上Flags.PUBLIC。否则javac会报illegal combination of modifiers错误。


4. 避坑指南:Lombok 源码调试中最常翻车的 4 个场景

Lombok 源码不是拿来“看”的,是拿来断点调试的。但javac是 JDK 内部组件,调试环境稍有偏差就会卡死或跳不到断点。以下是我在某图像处理 Demo 项目中踩过的血泪经验,每一条都配真实日志和修复命令。

4.1 现象:断点打在HandleGetter.handle()里,但javac启动后完全不触发

原因:lombok-core.jar没被javac加载为插件,而是被当成了普通依赖。javac只加载META-INF/services/com.sun.source.util.Plugin在 classpath 根路径下的 jar。
解决:确保lombok-core.jar在javac启动时的-cp参数最前面,且不被 Maven Shade 插件打包进 fat-jar。调试命令示例:

# ✅ 正确:lombok-core.jar 单独放在 -cp 开头 javac -cp "lombok-core.jar:src/main/java" -processor lombok.javac.apt.LombokProcessor src/main/java/com/example/Person.java # ❌ 错误:lombok-core 被打进 target/classes 下,javac 找不到 services 文件 javac -cp "target/classes" src/main/java/com/example/Person.java

4.2 现象:JavacNode的parent为 null,导致node.up()报 NPE

原因:JavacNode构造时传入的parent是null,常见于处理JCCompilationUnit(编译单元)节点时未手动设置父节点。
解决:在LombokProcessor的process()方法中,显式为根节点设置parent:

// 在 process() 方法内 JCCompilationUnit unit = (JCCompilationUnit) trees.getPath(elt).getCompilationUnit(); JavacNode root = new JavacNode(unit, null); // 此时 parent 为 null // ✅ 补充:手动设置 root 的 parent 为自身(约定) root.setParent(root);

4.3 现象:生成的toString()方法里字段值全是null,但运行时对象字段有值

原因:@ToString的callSuper = true时,Lombok 会尝试调用super.toString(),但如果父类没有无参构造器,javac会在生成toString()时插入super()调用,而该调用可能失败。
解决:检查父类构造器。若父类只有带参构造器,必须在@ToString上显式关闭callSuper:

@ToString(callSuper = false) // ✅ 强制不调 super.toString() public class Child extends Parent { private String name; }

4.4 现象:@Builder生成的build()方法编译报错cannot find symbol: constructor X(...)

原因:build()方法体里生成的new X(...)调用,参数列表和目标类构造器签名不匹配。常见于字段类型是泛型(如List<String>)但构造器参数是原始类型(List)。
解决:在HandleBuilder的generateBuildMethod()中,强制使用字段的vartype(带泛型)而非擦除后类型:

// ❌ 错误:用 toString() 得到 "List",丢失泛型 String paramType = field.vartype.toString(); // 可能是 "List" // ✅ 正确:用 TreeMaker 重新构造带泛型的类型节点 JCExpression genericType = maker.TypeApply( maker.Ident(names.fromString("List")), List.of(maker.Ident(names.fromString("String"))) );

5. 二次开发实战:给 Lombok 加一个@Loggable注解,自动生成 SLF4J 日志字段

现在我们来动手改源码:给 Lombok 加一个新注解@Loggable,效果是——在任意类上加@Loggable,就自动注入private static final Logger log = LoggerFactory.getLogger(X.class);字段。这不是玩具功能,某高校实验室的微服务日志规范就强制要求每个类必须有log字段,人工加太容易漏。

5.1 第一步:定义注解与配置类

在lombok-core/src/main/java/lombok/下新建Loggable.java:

package lombok; import java.lang.annotation.ElementType; import java.lang.annotation.Retention; import java.lang.annotation.RetentionPolicy; import java.lang.annotation.Target; @Target(ElementType.TYPE) @Retention(RetentionPolicy.SOURCE) public @interface Loggable { /** 日志器类型,默认 SLF4J */ Class<?> value() default org.slf4j.Logger.class; /** 是否启用 debug 级别日志 */ boolean debug() default false; }

注意:@Retention(RetentionPolicy.SOURCE)是必须的,因为 Lombok 只在编译期处理,运行时不需要该注解。

5.2 第二步:编写注解处理器HandleLoggable

新建lombok-core/src/main/java/lombok/javac/handlers/HandleLoggable.java:

package lombok.javac.handlers; import com.sun.tools.javac.tree.JCTree.*; import lombok.core.AnnotationValues; import lombok.core.HandlerPriority; import lombok.javac.JavacNode; import lombok.javac.JavacTreeMaker; import lombok.javac.handlers.JavacHandlerUtil; import static lombok.javac.handlers.JavacHandlerUtil.*; @HandlerPriority(1001) // 比 @Data 优先级高,确保先处理 public class HandleLoggable extends JavacAnnotationHandler<Loggable> { @Override public void handle(AnnotationValues<Loggable> annotation, JCAnnotation ast, JavacNode annotationNode) { JavacNode typeNode = annotationNode.up(); if (!typeNode.get() instanceof JCClassDecl) return; JavacTreeMaker maker = annotationNode.getTreeMaker(); JCClassDecl type = (JCClassDecl) typeNode.get(); // 构造 Logger 字段:private static final Logger log = LoggerFactory.getLogger(X.class); Name loggerName = typeNode.toName("log"); JCExpression loggerType = maker.Ident(typeNode.toName("Logger")); // 构造初始化表达式:LoggerFactory.getLogger(X.class) JCExpression factoryCall = maker.Apply( List.nil(), maker.Select(maker.Ident(typeNode.toName("LoggerFactory")), typeNode.toName("getLogger")), List.of(maker.TypeApply( maker.Ident(typeNode.toName("Class")), List.of(maker.Ident(type.name)) )) ); // 构造字段声明 JCModifiers mods = maker.Modifiers(Flags.PRIVATE | Flags.STATIC | Flags.FINAL); JCVariableDecl logField = maker.VarDef(mods, loggerName, loggerType, factoryCall); // 插入到类定义开头(保证 static 字段在 static 块前) type.defs = type.defs.prepend(logField); } }

5.3 第三步:注册处理器并更新lombok-class-map

  1. 在lombok-core/src/main/resources/META-INF/lombok-class-map末尾添加:
lombok.Loggable=lombok.javac.handlers.HandleLoggable
  1. 在lombok-core/src/main/resources/META-INF/services/com.sun.source.util.Plugin里确认LombokProcessor已注册(通常已有)。

5.4 第四步:编译并验证

用以下命令编译 Lombok 源码(需 JDK 11+):

cd lombok-core mvn clean compile # 打包成 jar mvn package -DskipTests

然后写测试类:

@Loggable public class UserService { public void createUser() { log.info("Creating user..."); // ✅ 编译通过,log 字段已存在 } }

编译时加上-Xlint:all查看是否注入成功:

javac -cp "lombok-core/target/lombok-core-1.18.32.jar:slf4j-api-2.0.9.jar" \ -processor lombok.javac.apt.LombokProcessor \ UserService.java

反编译UserService.class,你会看到:

public class UserService { private static final Logger log = LoggerFactory.getLogger(UserService.class); // ... }

从那以后我每次给团队加新注解,都强制走一遍「定义注解 → 写 Handler → 更新 class-map → mvn compile → javac 测试」闭环。不跑通不提交,因为 Lombok 的 AST 修改一旦出错,不是编译失败,而是生成错误字节码——那种 bug 要花三天才能定位到JavacTreeMaker的一个Ident没设对。希望帮到你。

本文还有配套的精品资源,点击获取

RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进