源码视图技术:集 APT 和 AST 所长
背景
Java 编译期代码增强有两条主流路线:APT 和 AST。
APT(注解处理器)是编译器原生支持的扩展机制,在编译期读取注解信息,生成新的源码文件。典型代表是 MapStruct、Dagger 等代码生成工具。AST 修改则更进一步——直接操作编译器内存中的抽象语法树,改变已有类的结构。典型代表是 Lombok,开发者只需在字段上标注 @Getter、@Setter 等注解,编译后的 .class 文件中就自动包含生成的方法。
APT 可以生成源码文件(可观察、可调试),但无法生成同名类(不能修改已有类);AST 可以修改已有类(突破同名类限制),但修改结果不可观察、不可调试。如果取长补短,集 APT 和 AST 所长,即用 AST 修改类,再将修改后的 AST 输出为源码文件——就可以同时获得两者的优势:既能修改已有类,增强结果又可观察、可调试。
这就是源码视图技术(Source View Technology)的核心思想。在同一个项目中,开发者编写的原始类和 AST 增强后生成的同名视图类共存,编译时视图类替换原始类,最终产物只包含增强后的版本。由于视图类是真实的源码文件,开发者可以直接打开、阅读、调试。
源码视图技术通过 source-view 项目的设计来实现,需要解决两个问题:
- 同名类的运行——在同一个 Java 项目中,让原始类和视图类共存,编译时用视图类替换原始类
- 编译器插件统一处理——在各注解处理器通过 AST 技术增强类之后,由编译器插件统一将增强后的 AST 输出为视图类源码文件,生成位置靠近
main模块,方便阅读和调试
APT 的优点
APT 是 Java 编译器原生支持的扩展机制,注解处理器在编译期运行,可以读取源码中的注解信息,生成新的源码文件。它的优点是:
- 标准化——实现
Processor接口即可,无需 Hack 编译器 - 可组合——多个注解处理器可以在同一编译过程中协同工作
- 生成代码可见——通过
FilerAPI 生成的源码文件位于build/generated/目录下,IDE 配置源码路径后可直接跳转查看
APT 的缺点
APT 有一个根本性的限制:无法生成与源码完全限定名相同的类。Java 编译器明确规定,同一个编译单元中不允许存在两个完全限定名相同的类。APT 生成的源码与原始源码参与同一轮编译,因此永远无法生成一个与原始类完全限定名相同的类。这意味着 APT 无法真正"修改"一个类,只能生成新的类(如 Builder、Factory 等)。
AST 的优点
Lombok 等工具绕过了 APT 的限制,直接修改编译器内存中的 AST——为字段注入方法、为类注入构造方法等。AST 修改的优势是:
- 可以修改已有类——直接在内存中的 AST 上添加方法,突破了 APT 无法生成同名类的限制
- 零侵入——开发者编写的类保持简洁,所有增强都在编译期完成
AST 的缺点
AST 修改的代价是:增强后的类是隐蔽的,无法调试。
- 不可观察——修改后的内容只存在于编译期的内存中,不会输出为源码文件。开发者看到的始终是原始类,看不到生成的方法。Lombok 为各主流 IDE 编写了专用插件,让 IDE "假装"看到了生成的方法——这本质上是对 IDE 的 Hack。
- 不可调试——增强后的代码不存在于任何源码文件中,开发者无法在生成的方法上设置断点,无法单步调试。
- 不可审查——如果需要知道最终编译的类到底长什么样,只能通过反编译
.class文件来查看。
同名类的运行
源码视图技术首先要解决的是:如何让类和视图类在同一项目中共存,并确保编译时视图类替换类。
项目结构
初始方案不依赖注解处理器,视图类由开发者手动编写:
source-view/
├── build.gradle.kts
├── settings.gradle.kts
└── src/├── main/java/com/example/ # 开发者编写的类│ ├── App.java # 应用入口│ ├── Student.java # 只有字段 + 注解声明│ ├── Getter.java # @Getter 注解│ └── Setter.java # @Setter 注解└── generated/java/com/example/ # 视图类└── Student.java # 含完整 getter/setter 实现
src/main/java 是开发者编写的类,src/generated/java 是视图类,拥有相同的完全限定名,编译时视图类替换类。
类与视图类
Student.java——开发者只需要写字段声明和注解,无需手写 getter/setter:
package com.example;public class Student {@Getter@Setterprivate String name;public Student() {}public Student(String name) {this.name = name;}
}
视图类 Student.java——同一个完全限定名 com.example.Student,包含完整的方法实现,初始方案中由开发者手动编写:
package com.example;public class Student {private String name;public Student() {}public Student(String name) {this.name = name;}public String getName() {return this.name;}public void setName(String name) {this.name = name;}
}
视图类是一个普通的 Java 类,可以用 IDE 正常编辑、调试、重构。
应用入口——App.java 直接调用 setName() 和 getName(),就像这些方法本来就在 Student 上一样:
package com.example;public class App {public static void main(String[] args) {Student student = new Student();student.setName("Tom");System.out.println(student.getName());}
}
构建逻辑的演进
源码视图技术的核心挑战在于构建逻辑:如何让类和视图类在同一项目中共存,并确保编译时视图类替换类?这一节从朴素方案出发,分析其问题,逐步演进到最终方案。
朴素方案:main 依赖 generated
最直观的做法是将 src/main/java 和 src/generated/java 分别作为 main 和 generated 两个 SourceSet,让 main 依赖 generated 的编译产出:
sourceSets {create("generated") {java { srcDir("src/generated/java") }}
}dependencies {"implementation"(sourceSets["generated"].output)
}tasks.named<JavaCompile>("compileJava") {dependsOn("compileGeneratedJava")exclude { }
}tasks.named<Jar>("jar") {from(sourceSets["main"].output)from(sourceSets["generated"].output)
}
编译流程为三步:先编译 generated,再排除 main 中的同名类后编译 main,最后合并两个模块的产出打包。这个方案在没有注解处理器的情况下可以工作,但存在两个根本性问题。
问题一:注解处理器引入循环依赖
当前视图类是手动编写的,所以朴素方案尚可运行。但一旦引入注解处理器——由它读取 main 模块中的 @Getter、@Setter 注解,自动生成视图类源码到 src/generated/java——循环依赖就不可避免:
main 模块 ──依赖──▶ generated 模块▲ ││ │└── 注解处理器读取注解 ─┘
构建工具在编译一个模块前,会先编译其依赖的模块。因此编译 main 前必须先编译 generated,但 generated 的源码需要注解处理器从 main 中读取注解才能生成——main 依赖 generated,generated 又依赖 main,构建工具无法确定编译顺序。
问题二:IDE 编译错误提示
无论是否引入注解处理器,IDE 都会报错。IDE 解析 main 模块时,看到的 Student 类只有字段声明,没有 getName()/setName() 方法。因此 App.java 中的 student.getName() 会被 IDE 标记为编译错误,尽管构建时这些调用是合法的(因为构建时 main 的 classpath 包含了 generated 的视图类)。
最终方案:解除 main 对 generated 的依赖
两个问题的根源相同:main 依赖 generated。解除这个依赖,循环依赖自然消除。但问题是,解除依赖后 App.java 如何解析到 Student 的方法?答案是:不依赖 generated 的编译产出,而是将 generated 的源码直接纳入 main 的编译范围。
sourceSets {create("generated") {java { srcDir("src/generated/java") }resources { srcDir("src/generated/resources") }}
}tasks.named<JavaCompile>("compileJava") {val generatedJavaDir = file("src/generated/java")val generatedRelativePaths = generatedJavaDir.walkTopDown().filter { it.isFile && it.name.endsWith(".java") }.map { it.relativeTo(generatedJavaDir).path.replace('\\', '/') }.toSet()val mainJavaDir = file("src/main/java")exclude { element ->val file = element.filefile.absolutePath.startsWith(mainJavaDir.absolutePath) &&generatedRelativePaths.contains(file.relativeTo(mainJavaDir).path.replace('\\', '/'))}
}tasks.named<JavaCompile>("compileGeneratedJava") {enabled = false
}tasks.named<Jar>("jar") {from(sourceSets["main"].output)
}
关键设计:
main不依赖generated——没有dependencies块,彻底消除循环依赖的可能。generated源码纳入main编译——通过srcDir("src/generated/java")将视图类的源码加入main的编译源码目录,main一次性编译所有源码,App.java自然能解析到Student的方法。- 只排除
src/main/java中的同名类——exclude通过判断文件是否位于src/main/java目录下,确保只排除开发者编写的重复类,视图类的版本正常参与编译。 - 禁用
compileGeneratedJava——generated不再单独编译,不会产生build/classes/java/generated/目录,所有.class文件统一输出到build/classes/java/main/。 - Jar 只从
main打包——最终产物只包含main模块的输出,不存在两个同名类的冲突。
编译流程从朴素方案的三步简化为一步:
┌──────────────────────────────────────────────────────┐
│ compileJava │
│ │
│ 源码范围: │
│ src/main/java ──排除同名──▶ App.java │
│ │ Getter.java │
│ │ Setter.java │
│ │ (Student.java 被排除) │
│ │ │
│ src/generated/java ────────▶ Student.java │
│ │
│ 编译产出: │
│ build/classes/java/main/ │
│ ├── App.class │
│ ├── Getter.class │
│ ├── Setter.class │
│ └── Student.class (来自视图类) │
└──────────────────────────────────────────────────────┘
IDE 编译错误提示的解决
上述方案解决了构建层面的问题,但 IDE 的编译错误提示仍然存在——IDE 解析 src/main/java 中的 Student 时,看到的是开发者编写的类,没有 getter/setter 方法。这需要通过编写 IDE 插件来解决:插件识别类中的 @Getter、@Setter 等注解,抑制对应方法调用的编译错误提示,让 IDE 知道这些方法在编译时会被视图类提供。
编译器插件统一处理
同名类的运行解决了"视图类如何替换类"的问题,但视图类仍需开发者手动编写。如果视图类能由注解处理器自动生成,开发体验将进一步提升。这就需要引入编译器插件——统一在各种注解处理器使用 AST 技术将类增强后,将增强后的 AST 输出为视图类源码,使增强后的类从"隐蔽的内存状态"变为"可观察的源码文件"。
设计理念
源码视图技术通过编译器插件(Compiler Plugin)来实现 AST 增强结果的源码输出。编译器插件是 javac 提供的一种扩展机制,通过 -Xplugin 参数加载,可以在编译的各个阶段插入自定义逻辑。
设计思路:
- 注解处理器负责修改 AST——这是现有 AST 类增强技术已经做的事,无需改变。
LombokProcessor读取@Getter/@Setter注解,通过LombokTreeTranslator在内存中为字段生成 getter/setter 方法。 - 编译器插件负责输出源码——这是源码视图技术新增的能力。
GeneratedSourcePlugin作为编译器插件,监听编译事件,在注解处理完成后将修改后的 AST 输出为视图类源码到src/generated/java目录。 - 源码输出在 main 模块附近——视图类源码生成在
src/generated/java目录下,与src/main/java并列,而非隐藏在build/目录中。开发者可以直接在 IDE 中打开、阅读、调试这些文件。
项目结构
引入编译器插件后,项目结构新增 core 模块,包含注解定义、注解处理器和编译器插件:
source-view/
├── build.gradle.kts # 根项目构建逻辑
├── settings.gradle.kts
├── core/ # 注解处理器模块
│ ├── build.gradle.kts
│ └── src/main/java/mock/lombok/
│ ├── annotation/
│ │ ├── Getter.java # @Getter 注解
│ │ └── Setter.java # @Setter 注解
│ └── processor/
│ ├── LombokProcessor.java # 注解处理器:读取注解,修改 AST
│ ├── LombokTreeTranslator.java # AST 转换器:为字段生成 getter/setter
│ └── GeneratedSourcePlugin.java # 编译器插件:将增强后的 AST 输出为视图类源码
└── src/├── main/java/com/example/ # 开发者编写的类│ ├── App.java # 应用入口│ └── Student.java # 只有字段 + 注解声明└── generated/java/com/example/ # 视图类(由编译器插件自动生成)├── App.java└── Student.java # 含完整 getter/setter 实现
core 模块包含注解定义和注解处理器,是源码视图技术与编译器插件协作的核心。视图类不再需要手动编写,由编译器插件自动生成。
构建逻辑的升级
引入注解处理器后,同名类的运行中的简单构建方案面临循环依赖问题(见"问题一:注解处理器引入循环依赖")。解决方案是将编译分为两个阶段:
- 注解处理阶段(
processAnnotation任务):使用-proc:only仅运行注解处理器,配合-Xplugin运行编译器插件,将增强后的 AST 输出为视图类源码到src/generated/java - 编译阶段(
compileJava任务):使用-proc:none跳过注解处理,将generated源码纳入main编译范围,排除开发者编写的同名类
sourceSets {create("generated") {java { srcDir("src/generated/java") }resources { srcDir("src/generated/resources") }}
}dependencies {implementation(project(":core"))annotationProcessor(project(":core"))
}tasks.register<JavaCompile>("processAnnotation") {options.encoding = "UTF-8"options.compilerArgs.add("-proc:only")options.compilerArgs.add("-nowarn")options.compilerArgs.add("-Xplugin:mock.lombok.processor.GeneratedSourcePlugin " +"-mainJavaPath=${sourceSets.main.get().java.sourceDirectories.asPath} " +"-generatedJavaPath=${sourceSets["generated"].java.sourceDirectories.asPath}")source = sourceSets.main.get().javaoptions.sourcepath = sourceSets.main.get().javaclasspath = tasks.compileJava.get().classpathoptions.annotationProcessorPath = tasks.compileJava.get().options.annotationProcessorPathdestinationDirectory.set(sourceSets.main.get().java.outputDir)
}tasks.compileJava {dependsOn("processAnnotation")options.compilerArgs.add("-proc:none")options.encoding = "UTF-8"doFirst {val generatedSrcDirs = sourceSets["generated"].java.sourceDirectoriesval mainSrcDirs = sourceSets.main.get().java.sourceDirectoriesval generatedSrcDirPaths = generatedSrcDirs.asFileTree.filter { it.isFile && it.name.endsWith(".java") }.map { it.toRelativeString(generatedSrcDirs.singleFile) }val filterMainSrcDirs = mainSrcDirs.asFileTree.filter {it.isFile && it.name.endsWith(".java")&& !generatedSrcDirPaths.contains(it.toRelativeString(mainSrcDirs.singleFile))}source = (generatedSrcDirs + filterMainSrcDirs).asFileTreeoptions.sourcepath = generatedSrcDirs + filterMainSrcDirs}
}tasks.named<JavaCompile>("compileGeneratedJava") {enabled = false
}tasks.named<Jar>("jar") {from(sourceSets["main"].output)
}tasks.named<Delete>("clean") {delete(fileTree(sourceSets["generated"].java.sourceDirectories.asPath))
}
关键变化:
- 两阶段编译——
processAnnotation只运行注解处理器和编译器插件,不编译代码;compileJava只编译代码,不运行注解处理器。两者职责分离,互不干扰。 - 引入
core模块依赖——implementation(project(":core"))提供注解定义,annotationProcessor(project(":core"))提供注解处理器。 clean清理生成目录——删除src/generated/java下的自动生成文件,确保下次构建时注解处理器重新生成。
编译流程从同名类运行方案的一步变为两步:
┌─────────────────────────────────────────────────────────────┐
│ 阶段一:processAnnotation │
│ │
│ 读取类中的注解 │
│ LombokProcessor 修改 AST(添加 getter/setter) │
│ GeneratedSourcePlugin 将增强后的 AST 输出为视图类源码 │
│ │
│ -proc:only -Xplugin:GeneratedSourcePlugin │
└─────────────────────────────────────────────────────────────┘│▼
┌─────────────────────────────────────────────────────────────┐
│ 阶段二:compileJava │
│ │
│ 源码范围: │
│ src/main/java ──排除同名──▶ App.java │
│ │ (Student.java 被排除) │
│ │
│ src/generated/java ────────▶ Student.java (视图类) │
│ App.java │
│ │
│ 编译产出: │
│ build/classes/java/main/ │
│ ├── App.class │
│ └── Student.class (来自视图类) │
│ │
│ -proc:none │
└─────────────────────────────────────────────────────────────┘
源码实现
引入编译器插件后,开发者编写的 Student.java 使用 core 模块中的注解:
package com.example;import mock.lombok.annotation.Getter;
import mock.lombok.annotation.Setter;public class Student {@Getter@Setterprivate String name;public Student() {}public Student(String name) {this.name = name;}
}
视图类 Student.java 由编译器插件自动生成,包含完整的 getter/setter 实现:
package com.example;import mock.lombok.annotation.Getter;
import mock.lombok.annotation.Setter;public class Student {@Getter()@Setter()private String name;public Student() {}public Student(String name) {this.name = name;}public String getName() {return this.name;}public void setName(final String name) {this.name = name;}
}
注解处理器:LombokProcessor
LombokProcessor 是一个标准的注解处理器,读取类中的注解,通过 LombokTreeTranslator 修改 AST:
@AutoService(Processor.class)
@SupportedAnnotationTypes("*")
@SupportedSourceVersion(SourceVersion.RELEASE_8)
public class LombokProcessor extends AbstractProcessing {private Trees trees;private TreeMaker treeMaker;private Names names;@Overridepublic synchronized void init(ProcessingEnvironment processingEnv) {super.init(processingEnv);this.trees = Trees.instance(processingEnv);Context context = ((JavacProcessingEnvironment) processingEnv).getContext();this.treeMaker = TreeMaker.instance(context);this.names = Names.instance(context);}@Overridepublic boolean process(Set<? extends TypeElement> annotations, RoundEnvironment roundEnv) {if (!roundEnv.processingOver()) {for (Element element : roundEnv.getRootElements()) {if (element.getKind().isClass()) {JCTree tree = (JCTree) trees.getTree(element);tree.accept(new LombokTreeTranslator(treeMaker, names));}}}return false;}
}
AST 转换器:LombokTreeTranslator
LombokTreeTranslator 继承自 TreeTranslator,遍历 AST 中的字段定义,为标注了 @Getter/@Setter 的字段生成对应方法:
public class LombokTreeTranslator extends TreeTranslator {private TreeMaker treeMaker;private Names names;private List<JCTree> getters = List.nil();private List<JCTree> setters = List.nil();@Overridepublic void visitClassDef(JCClassDecl jcClassDecl) {getters = List.nil();setters = List.nil();super.visitClassDef(jcClassDecl);if (!getters.isEmpty()) {jcClassDecl.defs = jcClassDecl.defs.appendList(this.getters);}if (!setters.isEmpty()) {jcClassDecl.defs = jcClassDecl.defs.appendList(this.setters);}this.result = jcClassDecl;}@Overridepublic void visitVarDef(JCVariableDecl jcVariableDecl) {super.visitVarDef(jcVariableDecl);JCModifiers modifiers = jcVariableDecl.getModifiers();List<JCAnnotation> annotations = modifiers.getAnnotations();if (annotations == null || annotations.size() <= 0) {return;}for (JCAnnotation annotation : annotations) {String annotationType = annotation.type.toString();if ("mock.lombok.annotation.Getter".equals(annotationType)|| "Getter".equals(annotationType)) {JCMethodDecl getterMethod = createGetterMethod(jcVariableDecl);this.getters = this.getters.append(getterMethod);}if ("mock.lombok.annotation.Setter".equals(annotationType)|| "Setter".equals(annotationType)) {JCMethodDecl setterMethod = createSetterMethod(jcVariableDecl);this.setters = this.setters.append(setterMethod);}}}}
编译器插件:GeneratedSourcePlugin
GeneratedSourcePlugin 是源码视图技术的关键组件。它作为 javac 的编译器插件,通过 TaskListener 监听编译事件,在注解处理完成后将修改后的 AST 输出为视图类源码:
@AutoService(Plugin.class)
public class GeneratedSourcePlugin implements Plugin {private final Map<JavaFileObject, JCTree.JCCompilationUnit> fileMap = new LinkedHashMap<>();@Overridepublic String getName() {return getClass().getName();}@Overridepublic void init(JavacTask task, String... args) {if (ObjectUtils.isEmpty(args)) {return;}Map<String, String> paramMap = parseArgs(args);String generatedJavaPath = paramMap.get("generatedJavaPath");String mainJavaPath = paramMap.get("mainJavaPath");Trees trees = Trees.instance(task);task.addTaskListener(new TaskListener() {@Overridepublic void started(TaskEvent taskEvent) {}@Overridepublic void finished(TaskEvent taskEvent) {if (taskEvent.getKind() == TaskEvent.Kind.ENTER) {JavaFileObject sourceFile = taskEvent.getSourceFile();CompilationUnitTree compilationUnitTree = taskEvent.getCompilationUnit();if (sourceFile != null && compilationUnitTree instanceof JCTree.JCCompilationUnit) {fileMap.putIfAbsent(sourceFile, (JCTree.JCCompilationUnit) compilationUnitTree);}}else if (taskEvent.getKind() == TaskEvent.Kind.ANNOTATION_PROCESSING) {for (Map.Entry<JavaFileObject, JCTree.JCCompilationUnit> entry : fileMap.entrySet()) {JavaFileObject sourceFile = entry.getKey();String path = new File(sourceFile.toUri()).getPath();if (!path.startsWith(mainJavaPath)) {throw new IllegalArgumentException(path + " not start with mainJavaPath");}String relativePath = path.substring(mainJavaPath.length());String newPath = generatedJavaPath + relativePath;generateSource(newPath, entry.getValue());}}}});}private void generateSource(String filePath, JCTree.JCCompilationUnit unit) {File directory = new File(filePath).getParentFile();if (!directory.exists() && !directory.mkdirs()) {throw new IllegalArgumentException("Failed to create directory: " + directory);}try (PrintWriter writer = new PrintWriter(new FileWriter(filePath))) {Pretty pretty = new Pretty(writer, true);pretty.printExpr(unit);} catch (IOException e) {throw new IllegalArgumentException("generate source failed", e);}}
}
工作流程:
- ENTER 阶段:收集
main目录下所有源码文件与其 AST 的映射关系 - ANNOTATION_PROCESSING 阶段:注解处理器已完成 AST 修改,此时将修改后的 AST 通过
Pretty打印器输出为视图类源码到generated目录
编译器插件通过 -Xplugin 参数加载,在构建脚本中配置:
options.compilerArgs.add("-Xplugin:mock.lombok.processor.GeneratedSourcePlugin " +"-mainJavaPath=${sourceSets.main.get().java.sourceDirectories.asPath} " +"-generatedJavaPath=${sourceSets["generated"].java.sourceDirectories.asPath}")
-mainJavaPath 指定开发者编写的类的源码目录,-generatedJavaPath 指定视图类的输出目录。插件只处理 mainJavaPath 下的源码文件,将增强后的版本输出到 generatedJavaPath 对应的相对路径下。
注解定义
@Getter 和 @Setter 位于 core 模块中,保留策略为 SOURCE,只在类中起标记作用,运行时不存在:
@Target(ElementType.FIELD)
@Retention(RetentionPolicy.SOURCE)
public @interface Getter {}
@Target(ElementType.FIELD)
@Retention(RetentionPolicy.SOURCE)
public @interface Setter {}
协作流程
注解处理器、编译器插件、构建逻辑三者协作,完成从类到视图类的完整流程:
LombokProcessor读取@Getter/@Setter注解,通过LombokTreeTranslator在内存中修改 AST(添加 getter/setter 方法)GeneratedSourcePlugin监听编译事件,在注解处理完成后将增强后的 AST 输出为视图类源码到src/generated/java- 构建逻辑将视图类源码纳入编译,排除开发者编写的同名类,完成替换
运行结果
$ ./gradlew clean run> Task :run
TomBUILD SUCCESSFUL in 4s
App.java 成功调用了 Student.setName() 和 Student.getName()——这些方法只存在于视图类中,而开发者编写的 Student 在编译时已被自动排除。
源码视图技术集 APT 和 AST 所长:APT 生成源码文件的能力使增强结果可观察、可调试,AST 修改已有类的能力突破了同名类的限制。通过编译器插件,将注解处理器使用 AST 技术增强后的类输出为视图类源码;通过构建逻辑,让视图类在编译时替换开发者编写的同名类。注解处理器负责读取注解和修改 AST,编译器插件负责将增强后的 AST 落地为视图类源码,构建逻辑负责同名类替换——三者协作,让类增强技术变得透明、可观察、可调试。