源码视图技术:集 APT 和 AST 所长

📅 2026/7/21 21:55:27 👁️ 阅读次数 📝 编程学习
源码视图技术:集 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 项目的设计来实现,需要解决两个问题:

  1. 同名类的运行——在同一个 Java 项目中,让原始类和视图类共存,编译时用视图类替换原始类
  2. 编译器插件统一处理——在各注解处理器通过 AST 技术增强类之后,由编译器插件统一将增强后的 AST 输出为视图类源码文件,生成位置靠近 main 模块,方便阅读和调试

APT 的优点

APT 是 Java 编译器原生支持的扩展机制,注解处理器在编译期运行,可以读取源码中的注解信息,生成新的源码文件。它的优点是:

  • 标准化——实现 Processor 接口即可,无需 Hack 编译器
  • 可组合——多个注解处理器可以在同一编译过程中协同工作
  • 生成代码可见——通过 Filer API 生成的源码文件位于 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/javasrc/generated/java 分别作为 maingenerated 两个 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 依赖 generatedgenerated 又依赖 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)
}

关键设计:

  1. main 不依赖 generated——没有 dependencies 块,彻底消除循环依赖的可能。
  2. generated 源码纳入 main 编译——通过 srcDir("src/generated/java") 将视图类的源码加入 main 的编译源码目录,main 一次性编译所有源码,App.java 自然能解析到 Student 的方法。
  3. 只排除 src/main/java 中的同名类——exclude 通过判断文件是否位于 src/main/java 目录下,确保只排除开发者编写的重复类,视图类的版本正常参与编译。
  4. 禁用 compileGeneratedJava——generated 不再单独编译,不会产生 build/classes/java/generated/ 目录,所有 .class 文件统一输出到 build/classes/java/main/
  5. 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 参数加载,可以在编译的各个阶段插入自定义逻辑。

设计思路:

  1. 注解处理器负责修改 AST——这是现有 AST 类增强技术已经做的事,无需改变。LombokProcessor 读取 @Getter/@Setter 注解,通过 LombokTreeTranslator 在内存中为字段生成 getter/setter 方法。
  2. 编译器插件负责输出源码——这是源码视图技术新增的能力。GeneratedSourcePlugin 作为编译器插件,监听编译事件,在注解处理完成后将修改后的 AST 输出为视图类源码到 src/generated/java 目录。
  3. 源码输出在 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 模块包含注解定义和注解处理器,是源码视图技术与编译器插件协作的核心。视图类不再需要手动编写,由编译器插件自动生成。

构建逻辑的升级

引入注解处理器后,同名类的运行中的简单构建方案面临循环依赖问题(见"问题一:注解处理器引入循环依赖")。解决方案是将编译分为两个阶段:

  1. 注解处理阶段processAnnotation 任务):使用 -proc:only 仅运行注解处理器,配合 -Xplugin 运行编译器插件,将增强后的 AST 输出为视图类源码到 src/generated/java
  2. 编译阶段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))
}

关键变化:

  1. 两阶段编译——processAnnotation 只运行注解处理器和编译器插件,不编译代码;compileJava 只编译代码,不运行注解处理器。两者职责分离,互不干扰。
  2. 引入 core 模块依赖——implementation(project(":core")) 提供注解定义,annotationProcessor(project(":core")) 提供注解处理器。
  3. 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);}}
}

工作流程:

  1. ENTER 阶段:收集 main 目录下所有源码文件与其 AST 的映射关系
  2. 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 {}

协作流程

注解处理器、编译器插件、构建逻辑三者协作,完成从类到视图类的完整流程:

  1. LombokProcessor 读取 @Getter/@Setter 注解,通过 LombokTreeTranslator 在内存中修改 AST(添加 getter/setter 方法)
  2. GeneratedSourcePlugin 监听编译事件,在注解处理完成后将增强后的 AST 输出为视图类源码到 src/generated/java
  3. 构建逻辑将视图类源码纳入编译,排除开发者编写的同名类,完成替换

运行结果

$ ./gradlew clean run> Task :run
TomBUILD SUCCESSFUL in 4s

App.java 成功调用了 Student.setName()Student.getName()——这些方法只存在于视图类中,而开发者编写的 Student 在编译时已被自动排除。


源码视图技术集 APT 和 AST 所长:APT 生成源码文件的能力使增强结果可观察、可调试,AST 修改已有类的能力突破了同名类的限制。通过编译器插件,将注解处理器使用 AST 技术增强后的类输出为视图类源码;通过构建逻辑,让视图类在编译时替换开发者编写的同名类。注解处理器负责读取注解和修改 AST,编译器插件负责将增强后的 AST 落地为视图类源码,构建逻辑负责同名类替换——三者协作,让类增强技术变得透明、可观察、可调试。