资讯动态

深入Lombok源码:从零实现注解处理器与AST改写

发布时间:2026/10/16 7:36:34 来源:尧图企业网站定制
简介这份资源围绕 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.xmllombok-core打包时要注意META-INF/services/javax.annotation.processing.Processor这个文件必须存在内容是处理器的全限定类名。少了它javac根本不会加载你的处理器编译能过但方法不会生成——这就是我开头踩的那个坑。2.2 注解定义从 MyGetter 开始先定义一个最简单的注解只用来生成 getterpackage 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 groupIdorg.apache.maven.plugins/groupId artifactIdmaven-compiler-plugin/artifactId version3.11.0/version configuration source8/source target8/target annotationProcessorPaths path groupIdcom.example/groupId artifactIdlombok-core/artifactId version1.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.treeALL-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.MySetterProcessorjavac启动时会扫描 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-exportscompilerArgs arg--add-exports/arg argjdk.compiler/com.sun.tools.javac.treeALL-UNNAMED/arg arg--add-exports/arg argjdk.compiler/com.sun.tools.javac.utilALL-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()是追加操作如果处理器被多轮触发或者同一个类被多个处理器处理就会重复追加。解决在处理器里用一个SetString记录已处理的类名处理前先判断。或者在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(lazytrue)、Builder(toBuildertrue)都是这个套路读参数、走不同分支、生成不同节点。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 一个容易忽略的细节泛型字段的类型处理如果字段是ListString这种泛型类型field.asType()返回的是DeclaredType直接传给treeMaker.MethodDef的返回类型参数会丢失泛型信息生成List getNames()而不是ListString getNames()。正确做法是用treeMaker.Type(field.asType())把TypeMirror转成JCTree这样泛型参数会被保留。// 错误直接传 TypeMirror泛型丢失 // treeMaker.MethodDef(..., field.asType(), ...) // 正确先转成 JCTree JCTree returnType treeMaker.Type(field.asType());这个坑我在写第一个处理器时踩过生成的代码编译能过但调用方拿到的是裸类型赋值给ListString变量时报警告。从那以后我每次处理字段类型都强制走一遍treeMaker.Type()转换再传给MethodDef。希望帮到你。本文还有配套的精品资源点击获取

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价 →
↑