前言
在上一篇文章中,我们使用 JavaParser 学习了 AST 的基本操作。但你是否想过,这些修改为什么只停留在"纸上谈兵",无法真正影响编译结果?
现在,我们将从"外围观察"进入"编译器核心",探索 Java 编译器内部的 Javac Tree API。这是 Lombok、MapStruct 等工具真正使用的技术,能够在编译过程中直接修改 AST,实现真正的代码生成。
Javac Tree API 是什么?
编译器内部的 AST
| 维度 | Javac Tree API | JavaParser |
|---|---|---|
| 运行时机 | 编译期(在 javac 编译过程中) | 任何时候(独立于编译) |
| 访问方式 | 注解处理器 | 直接 API 调用 |
| 影响范围 | 可以修改编译结果 | 只能分析,不影响编译 |
| API 稳定性 | 内部 API,可能随 JDK 版本变化 | 公开 API,相对稳定 |
| 学习曲线 | 较陡(需要理解编译器机制) | 较平缓 |
| 使用场景 | 代码生成、编译期增强(如 Lombok) | 代码分析、重构、质量检查 |
核心定位
Javac Tree API 是 Java 编译器内部的 AST 操作接口
- 内置在 JDK 中,无需额外依赖
- 在编译过程中执行,直接影响字节码生成
- 操作编译器内部 AST,不是源码级别
- 内部 API,可能随 JDK 版本变化
前置条件:注解处理器
唯一“合法”入口
Javac Tree API 不是设计给普通应用程序使用的,它只在编译上下文中有意义:
// 这样不行 - 没有编译上下文
public static void main(String[] args) {
TreeMaker treeMaker = ??? // 无法创建,需要编译器的Context
JCTree tree = ??? // 没有关联的编译过程
}
// 这样可行 - 在注解处理器中
@Override
public boolean process(Set<? extends TypeElement> annotations, RoundEnvironment roundEnv) {
// 此时我们有完整的编译上下文
Context context = ((JavacProcessingEnvironment) processingEnv).getContext();
TreeMaker treeMaker = TreeMaker.instance(context); // 现在可以工作了!
}
而下面这段代码,我们需要依赖编译注解器,因为注解处理阶段是编译过程中唯一允许修改AST的阶段,只有在这个阶段才能获取到完整的编译上下文。所以,注解处理器是 Javac 给我们的"后门",让我们能在编译过程中介入并修改 AST。没有这个后门,我们就无法在合适的时机访问编译器的内部结构。
环境准备
引入注解器依赖
<!-- pom.xml -->
<dependencies>
<dependency>
<groupId>com.google.auto.service</groupId>
<artifactId>auto-service</artifactId>
<version>1.1.1</version>
</dependency>
</dependencies>
配置 Maven 编译器插件
由于Javac Tree API是JDK内部的API。我们需要访问 JDK 内部 API(com.sun.tools.javac.*),需要特殊配置来突破 Java 9+ 模块系统的限制。
为什么需要这些配置?
从 Java 9 开始,JDK 引入了模块系统(JPMS),严格限制了对内部 API 的访问。而 Javac Tree API 恰恰是这些"内部 API"的一部分。我们需要:
-
--add-exports:让编译器能"看到"这些包(编译时访问) -
--add-opens:让注解处理器能在运行时反射访问(深度访问) -
fork=true:让编译器在独立 JVM 进程中运行,才能使 -J 参数生效
完整配置
<!-- pom.xml -->
<build>
<plugins>
<plugin>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-compiler-plugin</artifactId>
<version>3.11.0</version>
<configuration>
<source>17</source>
<target>17</target>
<!-- 关键:fork=true 让编译器在独立 JVM 进程中运行 -->
<fork>true</fork>
<compilerArgs>
<!-- 第一步:add-exports 让编译器能"看到"这些包(编译时访问) -->
<arg>--add-exports=jdk.compiler/com.sun.tools.javac.api=ALL-UNNAMED</arg>
<arg>--add-exports=jdk.compiler/com.sun.tools.javac.code=ALL-UNNAMED</arg>
<arg>--add-exports=jdk.compiler/com.sun.tools.javac.comp=ALL-UNNAMED</arg>
<arg>--add-exports=jdk.compiler/com.sun.tools.javac.file=ALL-UNNAMED</arg>
<arg>--add-exports=jdk.compiler/com.sun.tools.javac.main=ALL-UNNAMED</arg>
<arg>--add-exports=jdk.compiler/com.sun.tools.javac.model=ALL-UNNAMED</arg>
<arg>--add-exports=jdk.compiler/com.sun.tools.javac.parser=ALL-UNNAMED</arg>
<arg>--add-exports=jdk.compiler/com.sun.tools.javac.processing=ALL-UNNAMED</arg>
<arg>--add-exports=jdk.compiler/com.sun.tools.javac.tree=ALL-UNNAMED</arg>
<arg>--add-exports=jdk.compiler/com.sun.tools.javac.util=ALL-UNNAMED</arg>
<arg>--add-exports=jdk.compiler/com.sun.tools.javac.jvm=ALL-UNNAMED</arg>
<!-- 第二步:-J add-opens 让注解处理器运行时能反射访问 -->
<arg>-J--add-opens=jdk.compiler/com.sun.tools.javac.api=ALL-UNNAMED</arg>
<arg>-J--add-opens=jdk.compiler/com.sun.tools.javac.code=ALL-UNNAMED</arg>
<arg>-J--add-opens=jdk.compiler/com.sun.tools.javac.comp=ALL-UNNAMED</arg>
<arg>-J--add-opens=jdk.compiler/com.sun.tools.javac.file=ALL-UNNAMED</arg>
<arg>-J--add-opens=jdk.compiler/com.sun.tools.javac.main=ALL-UNNAMED</arg>
<arg>-J--add-opens=jdk.compiler/com.sun.tools.javac.model=ALL-UNNAMED</arg>
<arg>-J--add-opens=jdk.compiler/com.sun.tools.javac.parser=ALL-UNNAMED</arg>
<arg>-J--add-opens=jdk.compiler/com.sun.tools.javac.processing=ALL-UNNAMED</arg>
<arg>-J--add-opens=jdk.compiler/com.sun.tools.javac.tree=ALL-UNNAMED</arg>
<arg>-J--add-opens=jdk.compiler/com.sun.tools.javac.util=ALL-UNNAMED</arg>
<arg>-J--add-opens=jdk.compiler/com.sun.tools.javac.jvm=ALL-UNNAMED</arg>
</compilerArgs>
</configuration>
</plugin>
</plugins>
</build>
配置说明
| 配置项 | 作用 | 为什么需要 |
|---|---|---|
<fork>true</fork> | 在独立 JVM 进程中运行 javac | -J 参数只有在 fork 模式下才生效 |
--add-exports | 导出 JDK 内部包 | 让我们的代码能 import 这些包 |
-J--add-opens | 深度开放包(包括 private 成员) | 注解处理器需要反射访问内部结构 |
日志输出问题
由于配置了 <fork>true</fork>,注解处理器会在独立的 JVM 进程中运行,这会导致:
-
System.out.println()的输出不会显示在控制台 -
Messager.printMessage()的输出可能被 IDE 过滤或隐藏
推荐解决方案:使用文件日志
/**
* 输出日志到文件(使用 UTF-8 编码)
*/
private void logToFile(String message) {
try {
// 使用 OutputStreamWriter 显式指定 UTF-8 编码
java.io.FileOutputStream fos = new java.io.FileOutputStream(
"processor-log.txt", true // true 表示追加模式
);
java.io.OutputStreamWriter osw = new java.io.OutputStreamWriter(
fos,
java.nio.charset.StandardCharsets.UTF_8
);
osw.write(message + "\n");
osw.close();
} catch (Exception e) {
// 如果文件写入失败,忽略(避免影响编译)
}
}
基础注解处理器框架
@AutoService(Processor.class) // 自动注册这个类为注解处理器,编译时会被自动发现
@SupportedAnnotationTypes("*") // 处理所有注解
@SupportedSourceVersion(SourceVersion.RELEASE_17) // 声明这个处理器支持的 Java 版本,这里是 Java 17
public class TreeAPIDemoProcessor extends AbstractProcessor {
private Messager messager; // 用于在编译期间输出消息(错误、警告、信息等)
/**
* 初始化注解处理器,获取必要的工具和环境。
*
* @param processingEnv 提供了与编译器交互的环境,如消息打印、文件操作等。
*/
@Override
public synchronized void init(ProcessingEnvironment processingEnv) {
super.init(processingEnv);
this.messager = processingEnv.getMessager(); // 用来打印编译期消息
messager.printMessage(Diagnostic.Kind.NOTE, "注解处理器已启动");
}
@Override
public boolean process(Set<? extends TypeElement> annotations,
RoundEnvironment roundEnv) {
messager.printMessage(Diagnostic.Kind.NOTE, "开始处理注解");
return false; // 让其他处理器继续处理
}
}
入门实战
构建方法计数器
整体项目结构
method-counter/
├── pom.xml
├── annotations/ # 模块 1:纯注解
│ └── io/github/thirty30ww/
│ └── MethodCounter.java
├── processor/ # 模块 2:注解处理器
│ └── io/github/thirty30ww/
│ └── MethodCounterProcessor.java
└── example/ # 模块 3:使用方法
└── io/github/thirty30ww/
└── TestService.java
定义注解
/**
* 标记需要统计方法的类
* 这是最简单的注解,没有任何参数
*/
@Target(ElementType.TYPE) // 只能用在类上
@Retention(RetentionPolicy.SOURCE) // 源码级别注解
public @interface MethodCounter {
// 没有参数,最简单的注解
}
初始化 Javac Tree API
在注解处理器中初始化 Tree API 核心组件:
@AutoService(Processor.class)
@SupportedAnnotationTypes("io.github.thirty30ww.annotation.MethodCounter")
@SupportedSourceVersion(SourceVersion.RELEASE_17)
public class MethodCounterProcessor extends AbstractProcessor {
private Messager messager; // 用于输出日志和错误信息
private Trees trees; // 用于获取语法树
@Override
public void init(ProcessingEnvironment processingEnv) {
super.init(processingEnv);
this.messager = processingEnv.getMessager();
this.trees = Trees.instance(processingEnv); // 获取 Trees 实例
// 输出到文件
logToFile("========================================");
logToFile("【MethodCounter】方法统计处理器初始化完成 " + new java.util.Date());
logToFile("========================================");
}
}
注意日志输出方式采用上述推荐的输出到文件的方式logToFile
关键点说明:
| 组件 | 作用 | 获取方式 |
|---|---|---|
| Messager | 输出编译期消息(错误、警告等) | processingEnv.getMessager() |
| Trees | 获取元素对应的语法树 | Trees.instance(processingEnv) |
处理注解并获取语法树
@Override
public boolean process(Set<? extends TypeElement> annotations, RoundEnvironment roundEnv) {
// 如果没有注解要处理,直接返回(避免每轮都输出)
if (roundEnv.processingOver()) {
return false;
}
logToFile("【MethodCounter】开始扫描被 @MethodCounter 注解的类...");
// 遍历所有被 @MethodCounter 注解的元素
for (Element element : roundEnv.getElementsAnnotatedWith(MethodCounter.class)) {
// 只处理类元素
if (element.getKind().isClass()) {
logToFile("【MethodCounter】处理类: " + element.getSimpleName());
countMethods(element);
}
}
return true; // 表示注解已处理,不需要其他处理器再处理
}
关键点说明:
-
roundEnv.processingOver():检查是否是最后一轮处理(避免重复输出) -
roundEnv.getElementsAnnotatedWith():获取所有带有 @MethodCounter 注解的元素 -
element.getKind().isClass():确保只处理类元素(不处理接口、枚举等)
遍历 AST 并统计方法
这是核心部分,我们将使用 TreeScanner 遍历语法树:
/**
* 统计类中的方法数量
*/
private void countMethods(Element classElement) {
try {
// 1. 获取类的语法树路径
var treePath = trees.getPath(classElement);
// 2. 获取编译单元(整个文件的语法树)
var compilationUnit = (JCTree.JCCompilationUnit) treePath.getCompilationUnit();
// 3. 创建语法树扫描器来遍历 AST
compilationUnit.accept(new TreeScanner() {
private int totalMethods = 0; // 方法计数器
/**
* 当访问到方法定义时调用
*/
@Override
public void visitMethodDef(JCTree.JCMethodDecl method) {
totalMethods++; // 方法数量+1
// 获取方法信息
String methodName = method.name.toString();
int paramCount = method.params.size();
// 输出方法信息到文件
logToFile("【MethodCounter】 方法: " + methodName + " (参数个数: " + paramCount + ")");
// 继续扫描方法内部(如果有嵌套类或匿名类)
super.visitMethodDef(method);
}
/**
* 当访问到类定义时调用
*/
@Override
public void visitClassDef(JCTree.JCClassDecl classTree) {
// 输出当前分析的类名到文件
logToFile("【MethodCounter】正在分析类: " + classTree.name);
// 先重置计数器(避免嵌套类的影响)
int previousCount = totalMethods;
totalMethods = 0;
// 继续扫描类内部(方法、字段、内部类等)
super.visitClassDef(classTree);
// 输出最终统计结果到文件
logToFile("【MethodCounter】类 " + classTree.name + " 共有 " + totalMethods + " 个方法");
// 恢复之前的计数器(为了外层类的统计)
totalMethods = previousCount;
}
});
} catch (Exception e) {
// 如果出现错误,输出错误信息
messager.printMessage(Diagnostic.Kind.ERROR,
"统计方法时出错: " + e.getMessage());
}
}
核心 API 解析:
| API | 作用 | 返回类型 |
|---|---|---|
·trees.getPath(element) | 获取元素的语法树路径 | TreePath |
treePath.getCompilationUnit() | 获取整个文件的编译单元 | CompilationUnitTree |
compilationUnit.accept(...) | 访问者模式:遍历 AST | - |
new TreeScanner() | 创建语法树扫描器 | 继承 TreeScanner |
visitMethodDef() | 访问方法定义节点 | - |
visitClassDef() | 访问类定义节点 | - |
为什么需要保存和恢复计数器?
int previousCount = totalMethods; // 保存外层类的计数
totalMethods = 0; // 为当前类重置计数器
super.visitClassDef(classTree); // 扫描当前类
logToFile("类 " + classTree.name + " 共有 " + totalMethods + " 个方法");
totalMethods = previousCount; // 恢复外层类的计数
是为了正确处理嵌套类的情况:
@MethodCounter
public class OuterClass {
public void outerMethod() {} // 外层类的方法
class InnerClass {
public void innerMethod() {} // 内层类的方法
}
}
如果不保存和恢复,内层类的方法会被错误地计入外层类。
测试运行
测试类
@MethodCounter
public class TestService {
public void Hello(String name) {
System.out.println("Hello " + name);
}
public void plus(int a, int b) {
System.out.println(a + b);
}
public void HelloWorld() {
System.out.println("Hello World");
}
}
运行编译
mvn clean compile
查看输出日志
打开 method-counter-log.txt 文件:
========================================
【MethodCounter】方法统计处理器初始化完成 Tue Oct 28 09:26:09 CST 2025
========================================
【MethodCounter】开始扫描被 @MethodCounter 注解的类...
【MethodCounter】处理类: TestService
【MethodCounter】正在分析类: TestService
【MethodCounter】 方法: <init> (参数个数: 0)
【MethodCounter】 方法: Hello (参数个数: 1)
【MethodCounter】 方法: plus (参数个数: 2)
【MethodCounter】 方法: HelloWorld (参数个数: 0)
【MethodCounter】类 TestService 共有 4 个方法
注意:
-
<init>是编译器自动生成的构造方法 -
实际编写的方法有 3 个,加上默认构造方法共 4 个