使用 Java Compiler API 编译 Java 代码

1、简介

在 Java 开发中,编译是防止语法错误、类型不匹配和其他可能导致项目失败的问题的第一道防线。传统的工作流程依赖于手动编译,而现代应用程序则需要动态编译检查。例如:

  • 实时验证学生提交的代码的教育平台
  • 在部署前对生成的代码片段进行编译的 CI/CD 流水线
  • 动态编译用户自定义逻辑的低代码工具
  • 热代码重载系统可即时重载开发人员所做的更改
  • 创建 Java 插件

Java Compiler API 允许在 Java 应用程序中以编程式编译代码,从而实现上述应用场景。像 LeetCode 或 Codecademy 等平台可即时验证用户提交的代码。当用户点击 “运行” 时,后台会使用编译器 API 等工具编译代码段、检查错误并在沙盒环境中执行。程序化编译为这一即时反馈循环提供了动力。

本文将带你了解 Java Compiler 这一强大的工具。

2、Java Compiler API 概览

Java Compiler API 位于 javax.tools 包内,提供对 Java 编译器的编程式访问。该 API 对于需要在运行时验证或执行代码的动态编译任务至关重要。

Compiler API 的主要组件包括:

  • JavaCompiler:启动编译任务的主编译器实例
  • JavaFileObject:代表 Java 源文件或类文件,可以是内存文件,也可以是基于文件的文件
  • StandardJavaFileManager:编译过程中管理输入和输出文件
  • DiagnosticCollector:捕获错误和警告等编译诊断信息

这些组件协同工作,可在 Java 应用程序中实现灵活高效的动态编译。

3、实现编译

JDK 环境默认提供 Compiler API,无需任何外部依赖。

现在,来看看如何编译内存中的 Java 代码。

3.1、在内存中创建源码

首先在内存中创建一个 Java 源码类:


// 继承自 SimpleJavaFileObject
public class InMemoryJavaFile extends SimpleJavaFileObject {
    private final String code;

    protected InMemoryJavaFile(String name, String code) {
        super(URI.create("string:///" + name.replace('.', '/') + Kind.SOURCE.extension),
              Kind.SOURCE);
        this.code = code;
    }

    @Override
    public CharSequence getCharContent(boolean ignoreEncodingErrors) {
        return code;
    }
}

该类将 Java 代码表示为内存中的 JavaFileObject,使我们能够直接将源码传递给编译器,而无需物理文件。

3.2、Compile API 的用法

创建一个工具方法来编译 Java 代码并捕获诊断信息:

private boolean compile(Iterable<? extends JavaFileObject> compilationUnits) {
    DiagnosticCollector<JavaFileObject> diagnostics = new DiagnosticCollector<>();

    JavaCompiler.CompilationTask task = compiler.getTask(
        null, 
        standardFileManager, 
        diagnostics, 
        null, 
        null, 
        compilationUnits
    );

    boolean success = task.call();

    for (Diagnostic<? extends JavaFileObject> diagnostic : diagnostics.getDiagnostics()) {
        System.out.println(diagnostic.getMessage(null));
    }

    return success;
}

compile() 方法通过 Compiler API 处理 Java 源码编译。

首先使用 DiagnosticCollector 捕捉编译信息。

compiler.getTask() 调用接受六个参数:writer 为 null(默认为 System.err)、用于处理源文件的标准文件管理器、编译消息诊断收集器、编译器选项为 null(使用默认值而非自定义选项)、注解处理类为 null(不需要处理特定类型),以及所提供的包含要编译源文件的编译单元。执行 task.call() 后,该方法会记录所有诊断信息,并返回一个表示编译成功的布尔值。

3.3、编译字符串形式的代码

封装一个方法,直接从字符串编译 Java 代码:

public boolean compileFromString(String className, String sourceCode) {
    JavaFileObject sourceObject = new InMemoryJavaFile(className, sourceCode);
    return compile(Collections.singletonList(sourceObject));
}

如上,根据传递的 Java 代码创建 InMemoryJavaFile 类的实例,并将其封装在一个 List 中,以传递给 compile() 方法。

3.4、测试编译器

使用有效和无效代码段来进行测试:

@Test
void givenSimpleHelloWorldClass_whenCompiledFromString_thenCompilationSucceeds() {
    String className = "HelloWorld";
    String sourceCode = "public class HelloWorld {\n" +
        "    public static void main(String[] args) {\n" +
        "        System.out.println(\"Hello, World!\");\n" +
        "    }\n" +
        "}";
    
    boolean result = compilerUtil.compileFromString(className, sourceCode);
    assertTrue(result, "Compilation should succeed");
    
    // 检查是否创建了 class 文件
    Path classFile = compilerUtil.getOutputDirectory().resolve(className + ".class");
    assertTrue(Files.exists(classFile), "Class file should be created");
}

如上,该测试确认编译器是否处理并编译了有效的 Java 源码,并在预期输出目录中生成了可执行类文件。

接着,测试语法错误的代码:

@Test
void givenClassWithSyntaxError_whenCompiledFromString_thenCompilationFails() {
    String className = "ErrorClass";
    String sourceCode = "public class ErrorClass {\n" +
        "    public static void main(String[] args) {\n" +
        "        System.out.println(\"This has an error\")\n" +
        "    }\n" +
        "}";
    
    boolean result = compilerUtil.compileFromString(className, sourceCode);
    assertFalse(result, "Compilation should fail due to syntax error");
    
    Path classFile = compilerUtil.getOutputDirectory().resolve(className + ".class");
    assertFalse(Files.exists(classFile), "No class file should be created for failed compilation");
}

由于编译失败,因此不会创建 .class 文件。

4、总结

本文介绍了 Java Compiler API 及其用法,还介绍了如何编译内存中的源码、捕获诊断信息以及动态执行编译。

利用 Compiler API,我们可以:

  • 在 CI/CD 管道中实现编译工作流程自动化。
  • 在应用中动态验证和执行用户定义的代码。
  • 获取详细诊断信息,改进调试和错误处理。

无论是构建自动分级系统、插件系统还是动态 Java 执行工具,Java Compiler API 都能提供强大而灵活的解决方案。


Ref:https://www.baeldung.com/java-compilation-compiler-api