☰
深入Lombok源码:从零实现注解处理器与AST改写
2026/10/16 7:36:26 网站建设 项目流程

简介:这份资源围绕 Lombok 的实现原理展开,面向希望深入理解注解处理器与字节码增强机制的 Java 开发者,尤其是想弄清 @Data、@Getter 等注解为何能在编译期自动生成代码的中高级工程师。压缩包共 40 个文件,约 88KB,包含 9 个 java 源码、10 个 class 字节码、5 个 lst 与 4 个 xml 配置,另有 iml、bpmn、dmn、jar、properties、processor 等文件,覆盖注解定义、处理器实现与工程配置多个层面。资源从注解处理器、预处理器元数据、字节码操作到 IDE 集成与自定义处理器逐层拆解,并涉及 final 字段、第三方库兼容性等使用限制,便于对照源码理解编译期代码注入的完整链路。目前已有 665 人学习下载,适合作为研究 Lombok 底层机制与自研注解处理器的实践参考。

1. 从一次编译报错说起:为什么我要把 Lombok 的源码翻出来

有次在项目里加了个@Data,本地跑得好好的,CI 上却报cannot find symbol getXxx()。同事说“清一下缓存”,我清了,没用;又有人说“IDE 插件没装”,装了,还是没用。最后发现是注解处理器在编译期没被正确触发——那一刻我才意识到,我天天用的 Lombok,其实是个我完全不了解的黑匣子。它到底在编译的哪个阶段动手?改的是 AST 还是字节码?为什么 IDE 里能看到方法、javac却看不到?带着这些问题,我把它的实现原理拆了一遍,顺手写了一份能跑的最小复现工程。这份代码资源适合两类人:一类是天天用 Lombok 但从没想过它怎么工作的业务开发,另一类是准备自己写注解处理器、想找个完整参照的工程师。下面从它怎么跑起来讲起,一路讲到怎么改出一个自己的注解。

2. 环境准备与最小复现工程:让注解处理器真正跑起来

2.1 依赖与 JDK 版本的选择

Lombok 的实现依赖javax.annotation.processing和com.sun.source.tree这两套 API,前者是标准注解处理接口,后者是编译器内部 AST 接口。这意味着它对 JDK 版本比较敏感,尤其是 JDK 9 引入模块化之后,com.sun.source.*的可见性发生了变化。我一般会固定用 JDK 8 或 JDK 11 来跑这份代码,JDK 17 以上需要额外加--add-exports参数,否则编译期会直接抛IllegalAccessError。

工程结构上,我把它拆成两个模块:一个是lombok-core,放注解定义和处理器;另一个是demo-app,放使用注解的业务类。这样拆的好处是,处理器和调用方隔离,能清楚看到“编译期注入”到底发生在哪一步。

# 目录结构 lombok-demo/ ├── lombok-core/ # 注解 + 处理器实现 │ ├── src/main/java/ │ │ ├── annotation/ # @MyGetter @MySetter 等注解 │ │ └── processor/ # 注解处理器 │ └── pom.xml ├── demo-app/ # 使用注解的业务代码 │ ├── src/main/java/ │ └── pom.xml └── pom.xml

lombok-core打包时要注意,META-INF/services/javax.annotation.processing.Processor这个文件必须存在,内容是处理器的全限定类名。少了它,javac根本不会加载你的处理器,编译能过但方法不会生成——这就是我开头踩的那个坑。

2.2 注解定义:从 @MyGetter 开始

先定义一个最简单的注解,只用来生成 getter:

package annotation; import java.lang.annotation.ElementType; import java.lang.annotation.Retention; import java.lang.annotation.RetentionPolicy; import java.lang.annotation.Target; // SOURCE 级别即可,编译后注解本身不需要保留 @Retention(RetentionPolicy.SOURCE) @Target(ElementType.TYPE) public @interface MyGetter { // 预留:可以配置是否生成 fluent 风格 boolean fluent() default false; }

这里@Retention选SOURCE而不是CLASS或RUNTIME,是因为 Lombok 类注解只在编译期起作用,运行时不需要反射读取。选SOURCE能减少最终 class 文件的体积,也避免有人误以为能在运行时通过反射拿到它。@Target(TYPE)表示它只能标在类上,和 Lombok 的@Getter行为一致。

2.3 处理器骨架:AbstractProcessor 的四个关键方法

注解处理器的入口是继承AbstractProcessor,核心要覆写四个方法:

package processor; import javax.annotation.processing.*; import javax.lang.model.SourceVersion; import javax.lang.model.element.TypeElement; import java.util.Set; @SupportedAnnotationTypes("annotation.MyGetter") @SupportedSourceVersion(SourceVersion.RELEASE_8) public class MyGetterProcessor extends AbstractProcessor { private Messager messager; @Override public synchronized void init(ProcessingEnvironment env) { super.init(env); // Messager 用来在编译期打印日志,比 System.out 更规范 this.messager = env.getMessager(); } @Override public boolean process(Set<? extends TypeElement> annotations, RoundEnvironment roundEnv) { // 第一轮才处理,后续轮次 roundEnv 可能为空 if (roundEnv.processingOver()) { return false; } messager.printMessage(javax.tools.Diagnostic.Kind.NOTE, "MyGetterProcessor 被触发,注解数量=" + annotations.size()); // 具体生成逻辑放到下一节 return true; } }

init方法里拿到的ProcessingEnvironment是后面所有操作的入口,getFiler()用来写新文件,getElementUtils()用来做类型判断,getMessager()用来报错。process方法会被调用多轮,直到没有新的源文件产生为止,所以第一行判断processingOver()很关键,否则容易在最后一轮空跑甚至重复生成。

@SupportedAnnotationTypes和@SupportedSourceVersion这两个注解是给编译器看的,告诉它这个处理器关心哪些注解、支持到哪个 JDK 版本。如果漏了@SupportedAnnotationTypes,处理器会被加载但process方法永远收到空集合。

2.4 用 Maven 把处理器挂到编译流程

处理器写完后,要让demo-app在编译时自动调用它。有两种方式:一种是直接把lombok-core作为demo-app的依赖,另一种是用annotationProcessorPaths显式声明。我推荐后者,因为依赖和处理器职责分离,不会污染运行时 classpath。

<!-- demo-app/pom.xml 片段 --> <plugin> <groupId>org.apache.maven.plugins</groupId> <artifactId>maven-compiler-plugin</artifactId> <version>3.11.0</version> <configuration> <source>8</source> <target>8</target> <annotationProcessorPaths> <path> <groupId>com.example</groupId> <artifactId>lombok-core</artifactId> <version>1.0-SNAPSHOT</version> </path> </annotationProcessorPaths> </configuration> </plugin>

配好之后执行mvn clean compile,如果控制台能看到MyGetterProcessor 被触发这行 NOTE 日志,说明处理器已经成功挂载。这一步是整个工程的地基,跑不通后面全是空谈。

3. 核心模块拆解:AST 改写与代码生成的两条路线

3.1 路线一:用 Filer 生成新文件

最直观的做法是拿到被注解的类,读取它的字段,然后拼一个带 getter 的新类写出去。Filer的用法如下:

// 在 process 方法内 for (Element element : roundEnv.getElementsAnnotatedWith(MyGetter.class)) { TypeElement typeElement = (TypeElement) element; String className = typeElement.getSimpleName() + "Generated"; String packageName = processingEnv.getElementUtils() .getPackageOf(typeElement).getQualifiedName().toString(); // 创建源文件,注意 createSourceFile 的第三个参数是 originating element JavaFileObject file = processingEnv.getFiler() .createSourceFile(packageName + "." + className, typeElement); try (Writer writer = file.openWriter()) { writer.write("package " + packageName + ";\n"); writer.write("public class " + className + " {\n"); // 遍历字段,为每个字段生成 getter for (Element field : ElementFilter.fieldsIn(typeElement.getEnclosedElements())) { String fieldName = field.getSimpleName().toString(); String cap = Character.toUpperCase(fieldName.charAt(0)) + fieldName.substring(1); writer.write(" public " + field.asType() + " get" + cap + "() {\n"); writer.write(" return this." + fieldName + ";\n"); } writer.write("}\n"); } }

createSourceFile的第三个参数originating element不能省,它决定了增量编译时这个文件跟谁绑定。ElementFilter.fieldsIn是javax.lang.model.util提供的工具方法,比手动遍历getEnclosedElements()再判断ElementKind.FIELD更安全,能自动过滤掉方法和构造器。

这条路线的问题是:生成的是新类,不是往原类里加方法。业务代码得改成new UserGenerated(user)这种形式,和 Lombok 的体验差很远。所以 Lombok 没走这条路。

3.2 路线二:直接改 AST,往原类里插方法

Lombok 真正的做法是拿到JavacTrees和TreeMaker,在编译器的抽象语法树上直接插入方法节点。核心代码大致长这样:

// 通过反射拿到编译期的 Context 和 Trees 实例 Context context = ((JavacProcessingEnvironment) processingEnv).getContext(); JavacTrees trees = JavacTrees.instance(context); TreeMaker treeMaker = TreeMaker.instance(context); Names names = Names.instance(context); // 定位到被注解的类对应的 AST 节点 JCTree.JCClassDecl classDecl = (JCTree.JCClassDecl) trees.getTree(typeElement); // 构造一个 getter 方法节点 JCTree.JCMethodDecl getter = treeMaker.MethodDef( treeMaker.Modifiers(Flags.PUBLIC), // 修饰符 names.fromString("get" + cap), // 方法名 fieldType, // 返回类型 List.nil(), // 泛型参数 List.nil(), // 方法参数 List.nil(), // 异常声明 treeMaker.Block(0, List.of( // 方法体 treeMaker.Return(treeMaker.Select( treeMaker.Ident(names.fromString("this")), names.fromString(fieldName))))), null); // 把方法节点塞进类的成员列表 classDecl.defs = classDecl.defs.append(getter);

这段代码是 Lombok 实现里最“黑魔法”的部分。JavacTrees.getTree()拿到的是编译器正在处理的 AST 节点,TreeMaker负责构造新的语法树片段,Names负责字符串到编译器内部符号的转换。三者配合,就能在编译期把方法“凭空”塞进类里。

treeMaker.MethodDef的参数顺序要记牢:修饰符、方法名、返回类型、泛型参数、参数列表、异常、方法体、默认值。任何一个传错,编译期就会抛AssertionError或者生成畸形字节码。treeMaker.Block(0, ...)里的0是 block 的 flags,通常传 0 即可。

注意:com.sun.source.tree和com.sun.tools.javac.*属于 JDK 内部 API,不同版本之间签名可能变化。JDK 8 到 JDK 11 这段相对稳定,JDK 17 之后需要加--add-exports jdk.compiler/com.sun.tools.javac.tree=ALL-UNNAMED之类的参数才能编译通过。

3.3 为什么 Lombok 要改 AST 而不是生成新类

对比两条路线,差异在三个点上。第一是调用方体验:改 AST 后,user.getName()直接可用,不需要引入任何新类;生成新类则要求业务代码显式引用。第二是增量编译:AST 改写发生在当前编译单元内,不产生新文件,增量编译的依赖图不会变复杂;生成新文件则要处理文件与源文件的绑定关系。第三是IDE 兼容:改 AST 的方案需要 IDE 插件配合做“影子”解析,否则 IDE 看不到生成的方法;生成新文件的方案 IDE 天然能识别,但用起来别扭。

Lombok 选了体验最好但实现最复杂的那条路,代价就是它必须为每个主流 IDE 写插件,还得跟着 JDK 版本不断适配。这也是为什么 Lombok 升级经常和 JDK 升级绑在一起。

3.4 处理器注册与多轮处理机制

一个完整的 Lombok 类注解处理器,注册方式是在META-INF/services/javax.annotation.processing.Processor里写全限定类名:

processor.MyGetterProcessor processor.MySetterProcessor

javac启动时会扫描 classpath 下所有 jar 里的这个文件,把列出的处理器全部加载。多个处理器之间按@SupportedAnnotationTypes声明的注解匹配,互不干扰。

多轮处理的机制是:第一轮process处理原始源文件,如果生成了新源文件,编译器会再跑一轮处理新文件,直到没有新文件产生。Lombok 的处理器通常在第一轮就把所有 AST 改完,后续轮次直接返回。如果处理器里写了return false,表示“这个注解我没处理完,交给下一个处理器”,写return true表示“我处理了,别人不用管”。这个返回值搞反,会导致注解被重复处理或者完全没人处理。

4. 避坑与排查:注解处理器不生效的几种典型情况

4.1 现象:编译通过但方法不存在

现象:mvn compile成功,但调用user.getName()时报cannot find symbol。

原因:处理器没被加载。最常见的是META-INF/services文件路径写错,或者文件内容有拼写错误。Maven 打包时如果没把src/main/resources下的这个文件打进 jar,也会导致加载失败。

解决:解压lombok-core.jar,确认META-INF/services/javax.annotation.processing.Processor存在且内容正确。用mvn dependency:build-classpath确认处理器 jar 在编译期 classpath 上。

4.2 现象:处理器被调用但报 ClassNotFoundException

现象:编译日志里出现ClassNotFoundException: com.sun.tools.javac.tree.TreeMaker。

原因:JDK 9 之后模块化,com.sun.tools.javac.*默认不导出给 unnamed module。

解决:在maven-compiler-plugin的compilerArgs里加--add-exports:

<compilerArgs> <arg>--add-exports</arg> <arg>jdk.compiler/com.sun.tools.javac.tree=ALL-UNNAMED</arg> <arg>--add-exports</arg> <arg>jdk.compiler/com.sun.tools.javac.util=ALL-UNNAMED</arg> </compilerArgs>

具体要导出哪些包,看报错里缺哪个类,缺什么补什么。

4.3 现象:IDE 里报红但命令行编译正常

现象:IDEA 里user.getName()标红,但mvn compile能过。

原因:IDE 用自己的编译器前端做代码分析,不走javac的注解处理流程,除非装了对应的 IDE 插件。

解决:要么装 Lombok 官方插件,要么在 IDE 设置里开启 annotation processing。自己写的处理器没有官方插件,只能靠 IDE 的“注解处理器”配置项手动开启,或者接受 IDE 报红、以命令行编译结果为准。

4.4 现象:增量编译时方法时有时无

现象:改一个字段后重新编译,有时 getter 在,有时不在。

原因:增量编译只重新编译改动的文件,如果处理器依赖的 AST 信息来自未改动的文件,可能拿到过期数据。

解决:在处理器里对roundEnv做完整遍历,不要缓存上一轮的元素。实在不行就mvn clean compile,虽然慢但结果稳定。生产环境构建脚本里加clean是常见做法。

4.5 现象:生成的代码顺序错乱或重复

现象:同一个类里出现两个getName(),或者方法顺序和字段顺序对不上。

原因:classDecl.defs.append()是追加操作,如果处理器被多轮触发,或者同一个类被多个处理器处理,就会重复追加。

解决:在处理器里用一个Set<String>记录已处理的类名,处理前先判断。或者在process开头判断roundEnv.processingOver(),最后一轮直接返回。

5. 二次开发:把 @MyGetter 改造成支持 fluent 和链式调用

5.1 从注解参数到 AST 分支

前面定义的@MyGetter里留了个fluent参数,现在把它用起来。改造点在处理器里:读取注解的fluent值,决定生成getXxx()还是xxx()。

MyGetter annotation = typeElement.getAnnotation(MyGetter.class); boolean fluent = annotation != null && annotation.fluent(); String methodName = fluent ? fieldName // fluent 风格:直接字段名 : "get" + cap; // 标准风格:getXxx JCTree.JCMethodDecl getter = treeMaker.MethodDef( treeMaker.Modifiers(Flags.PUBLIC), names.fromString(methodName), fieldType, List.nil(), List.nil(), List.nil(), treeMaker.Block(0, List.of( treeMaker.Return(treeMaker.Select( treeMaker.Ident(names.fromString("this")), names.fromString(fieldName))))), null);

关键变化只有methodName这一处,但背后体现的是注解处理器的一个通用模式:注解参数决定 AST 的构造分支。Lombok 的@Getter(lazy=true)、@Builder(toBuilder=true)都是这个套路,读参数、走不同分支、生成不同节点。

5.2 加一个 @MySetter 并处理返回值

Setter 比 Getter 多一个参数,方法体是赋值语句而不是 return。构造方法节点时,参数列表要传一个JCVariableDecl:

// 构造方法参数:字段类型 + 字段名 JCTree.JCVariableDecl param = treeMaker.VarDef( treeMaker.Modifiers(Flags.PARAMETER), names.fromString(fieldName), fieldType, null); // 方法体:this.field = field; JCTree.JCStatement assign = treeMaker.Exec( treeMaker.Assign( treeMaker.Select( treeMaker.Ident(names.fromString("this")), names.fromString(fieldName)), treeMaker.Ident(names.fromString(fieldName)))); JCTree.JCMethodDecl setter = treeMaker.MethodDef( treeMaker.Modifiers(Flags.PUBLIC), names.fromString("set" + cap), treeMaker.TypeIdent(TypeTag.VOID), // 返回 void List.nil(), List.of(param), // 一个参数 List.nil(), treeMaker.Block(0, List.of(assign)), null);

treeMaker.TypeIdent(TypeTag.VOID)是构造 void 返回类型的标准写法,不能直接传null,否则生成的字节码里返回类型会缺失。treeMaker.Exec用来把表达式包装成语句,赋值、方法调用这类没有返回值的操作都要经过它。

5.3 验证改造结果:写一个测试类跑一遍

改造完处理器后,在demo-app里写一个测试类:

@MyGetter(fluent = true) @MySetter public class User { private String name; private int age; }

编译后反编译User.class,应该能看到name()、age()、setName(String)、setAge(int)四个方法。用javap -p User.class查看,如果方法签名和预期一致,说明 AST 改写成功。

我一般还会加一个运行时测试,用反射遍历User.class.getDeclaredMethods(),断言方法名集合等于预期集合。这样每次改处理器,跑一遍测试就知道有没有破坏已有功能。

5.4 一个容易忽略的细节:泛型字段的类型处理

如果字段是List<String>这种泛型类型,field.asType()返回的是DeclaredType,直接传给treeMaker.MethodDef的返回类型参数会丢失泛型信息,生成List getNames()而不是List<String> getNames()。正确做法是用treeMaker.Type(field.asType())把TypeMirror转成JCTree,这样泛型参数会被保留。

// 错误:直接传 TypeMirror,泛型丢失 // treeMaker.MethodDef(..., field.asType(), ...) // 正确:先转成 JCTree JCTree returnType = treeMaker.Type(field.asType());

这个坑我在写第一个处理器时踩过,生成的代码编译能过,但调用方拿到的是裸类型,赋值给List<String>变量时报警告。从那以后我每次处理字段类型,都强制走一遍treeMaker.Type()转换,再传给MethodDef。希望帮到你。

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

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询