使用Java Tree API操作AST

44 阅读8分钟

前言

在上一篇文章中,我们使用 JavaParser 学习了 AST 的基本操作。但你是否想过,这些修改为什么只停留在"纸上谈兵",无法真正影响编译结果?

现在,我们将从"外围观察"进入"编译器核心",探索 Java 编译器内部的 Javac Tree API。这是 Lombok、MapStruct 等工具真正使用的技术,能够在编译过程中直接修改 AST,实现真正的代码生成。

Javac Tree API 是什么?

编译器内部的 AST

维度Javac Tree APIJavaParser
运行时机编译期(在 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"的一部分。我们需要:

  1. --add-exports:让编译器能"看到"这些包(编译时访问)

  2. --add-opens:让注解处理器能在运行时反射访问(深度访问)

  3. 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 个