使用 Java Compiler API 编译 Java 代码

1、简介

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

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

Java Compiler API 允许在 Java 应用程序中以编程式编译代码,从而实现上述应用场景。像 LeetCodeCodecademy 等平台可即时验证用户提交的代码。当用户点击 “运行” 时,后台会使用编译器 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() 调用接受六个参数:writernull(默认为 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