跳到主要内容

JEP 458:启动多文件源代码程序

QWen Max 中英对照 JEP 458: Launch Multi-File Source-Code Programs

总结

增强 java 应用程序启动器,使其能够运行以多份 Java 源代码文件形式提供的程序。这将使从小型程序过渡到大型程序的过程更加平滑,使开发人员能够选择是否以及何时费心去配置构建工具。

非目标

  • 通过 "shebang" 机制启动多文件源代码程序并不是目标。只有单文件源代码程序可以通过该机制启动。

  • 在源代码程序中简化对外部库依赖的使用也不是目标。这可能是未来 JEP 的主题。

动机

Java 编程语言非常擅长编写由大型团队在多年间开发和维护的大型复杂应用程序。然而,即便是大型程序也是从小规模起步的。在早期阶段,开发者会进行试验和探索,并不关心可交付的产物;项目的结构可能还不存在,一旦出现,也会频繁更改。快速迭代和彻底变革是当务之急。近年来,JDK 中新增了多项功能以协助进行试验和探索,包括 JShell(一个用于尝试代码片段的交互式 shell)和 一个简单的 Web 服务器(用于 Web 应用的快速原型设计)。

在 JDK 11 中,JEP 330 增强了 java 应用启动器,使其能够直接运行 .java 源文件,而无需显式的编译步骤。例如,假设文件 Prog.java 声明了两个类:

class Prog {
public static void main(String[] args) { Helper.run(); }
}

class Helper {
static void run() { System.out.println("Hello!"); }
}

然后运行

$ java Prog.java

会在内存中编译两个类,并执行该文件中声明的第一个类的 main 方法。

这种运行程序的低门槛方法有一个主要限制:程序的所有源代码都必须放在一个单一的 .java 文件中。要处理多个 .java 文件,开发者必须回到显式编译源文件的方式。对于有经验的开发者来说,这通常意味着要为构建工具创建一个项目配置,但在试图让想法和实验顺畅流动时,从无定形的摸索转向正式的项目结构是令人烦恼的。对于初学者来说,从一个 .java 文件过渡到两个或更多文件需要一个更加显著的阶段变化:他们必须暂停对语言的学习,转而学习如何操作 javac,或者学习使用第三方构建工具,又或者学会依赖 IDE 的魔法功能。

如果开发者能够推迟项目设置阶段,直到他们对项目的形态有了更多的了解,甚至在快速编写并丢弃原型时完全避免设置,那就更好了。一些简单的程序可能永远以源代码的形式存在。这促使对 java 启动器进行增强,使其能够运行那些超出单个 .java 文件的程序,而无需强制进行显式的编译步骤。传统的编辑/构建/运行周期将简化为编辑/运行。开发者可以自行决定何时设置构建流程,而不是因为工具的限制被迫这样做。

描述

我们增强了 java 启动器的源文件模式,使其能够运行作为多个 Java 源代码文件提供的程序。

例如,假设一个目录包含两个文件 Prog.javaHelper.java,每个文件声明了一个类:

// Prog.java
class Prog {
public static void main(String[] args) { Helper.run(); }
}

// Helper.java
class Helper {
static void run() { System.out.println("Hello!"); }
}

运行 java Prog.java 会在内存中编译 Prog 类并调用其 main 方法。由于该类中的代码引用了 Helper 类,启动器会在文件系统中找到 Helper.java 文件并在内存中编译其类。如果 Helper 类中的代码引用了其他类,例如 HelperAux,那么启动器会找到 HelperAux.java 并也编译它。

当不同 .java 文件中的类相互引用时,java 启动器不保证 .java 文件编译的任何特定顺序或时机。例如,启动器可能会在 Prog.java 之前编译 Helper.java。某些代码可能在程序开始执行之前就被编译,而其他代码则可能在运行时被延迟编译。(编译和执行源文件程序的过程在下方有详细描述。)

只有被程序引用的类所对应的 .java 文件才会被编译。这使得开发者可以放心地使用新版本的代码,而不用担心旧版本会被意外编译。例如,假设该目录中还包含一个 OldProg.java 文件,其中旧版本的 Prog 类期望 Helper 类有一个名为 go 而非 run 的方法。当运行 Prog.java 时,存在 OldProg.java 及其潜在错误并不重要。

可以在一个 .java 文件中声明多个类,并且它们会被一起编译。在同一个 .java 文件中共同声明的类优先于在其他 .java 文件中声明的类。例如,假设上面的 Prog.java 文件被扩展以声明一个名为 Helper 的类,尽管在 Helper.java 中已经声明了一个同名的类。当 Prog.java 中的代码引用 Helper 时,会使用在 Prog.java 中共同声明的类;启动器不会搜索 Helper.java 文件。

禁止源代码程序中的类重复。也就是说,不允许在同一个 .java 文件中或者在构成程序的不同 .java 文件中声明两个同名的类。假设在进行一些编辑之后,Prog.javaHelper.java 最终如下所示,其中类 Aux 被意外地在这两个文件中都进行了声明:

// Prog.java
class Prog {
public static void main(String[] args) { Helper.run(); Aux.cleanup(); }
}
class Aux {
static void cleanup() { ... }
}

// Helper.java
class Helper {
static void run() { ... }
}
class Aux {
static void cleanup() { ... }
}

运行 java Prog.java 会编译 Prog.java 中的 ProgAux 类,调用 Progmain 方法,然后 —— 由于 main 引用了 Helper —— 找到 Helper.java 并编译其中的 HelperAux 类。Helper.java 中的 Aux 重复声明是不被允许的,因此程序停止并且启动器报告错误。

java 启动器的源文件模式是通过传递一个 .java 文件的名称来触发的。如果提供了额外的文件名,它们会成为其 main 方法的参数。例如,java Prog.java Helper.java 会导致包含字符串 "Helper.java" 的数组作为参数传递给 Prog 类的 main 方法。

使用预编译类

依赖类路径或模块路径上的库的程序也可以从源文件启动。例如,假设一个目录包含两个小程序和一个辅助类,以及一些库 JAR 文件:

Prog1.java
Prog2.java
Helper.java
library1.jar
library2.jar

通过将 --class-path '*' 传递给 java 启动器,您可以快速运行这些程序:

$ java --class-path '*' Prog1.java
$ java --class-path '*' Prog2.java

此处,--class-path 选项的 '*' 参数会将目录中的所有 JAR 文件放入类路径中;星号被引号括起来以避免被 shell 展开。

随着你不断进行实验,你可能会发现将 JAR 文件放在单独的 libs 目录中更为方便,在这种情况下,使用 --class-path 'libs/*' 将使它们可用。随着项目的成型,你可以稍后开始考虑生成一个打包的可交付成果,可能需要借助构建工具的帮助。

启动器如何查找源文件

java 启动器要求多文件源代码程序的源文件按照 常规目录层次结构 进行排列,其中目录结构遵循包结构,从根据以下描述计算出的根目录开始。这意味着:

  • 根目录中的源文件必须声明在未命名的包中,且
  • 根目录下 foo/bar 目录中的源文件必须声明在名为 foo.bar 的包中。

例如,假设一个目录包含 Prog.java,它在未命名的包中声明了类,还有一个子目录 pkg,其中 Helper.java 在包 pkg 中声明了类 Helper

// Prog.java
class Prog {
public static void main(String[] args) { pkg.Helper.run(); }
}

// pkg/Helper.java
package pkg;
class Helper {
static void run() { System.out.println("Hello!"); }
}

运行 java Prog.java 会导致在 pkg 子目录中找到 Helper.java 并在内存中编译,从而生成类 Prog 的代码所需的 pkg.Helper 类。

如果 Prog.java 在命名的包中声明了类,或者 Helper.java 在除 pkg 以外的包中声明了类,那么执行 java Prog.java 将会失败。

java 启动器会根据初始 .java 文件的包名和文件系统位置计算源代码树的 。对于 java Prog.java,初始文件是 Prog.java,它声明了一个属于未命名包的类,因此源代码树的根是包含 Prog.java 的目录。另一方面,如果 Prog.java 声明了一个属于名为 a.b.c 的包中的类,则它必须放置在层次结构中对应的目录下:

dir/
a/
b/
c/
Prog.java

它还必须通过运行 java dir/a/b/c/Prog.java 来启动。在这种情况下,源树的根是 dir

如果 Prog.java 声明其包为 b.c,那么源代码树的根目录将是 dir/a;如果声明了包 c,那么根目录将是 dir/a/b,并且如果没有声明包,则根目录将是 dir/a/b/c。如果 Prog.java 声明了某些其他与文件系统中文件路径后缀不对应的包(例如 p),程序将无法启动。

一个较小但不兼容的变更

如果在上述示例中,Prog.java 声明了位于不同命名包中的类,那么执行 java a/b/c/Prog.java 将会失败。这是 java 启动器的源文件模式行为的一个变化。

在以前的版本中,启动器的源文件模式对 .java 文件中声明的包(如果有的话)要求较为宽松:只要在 a/b/c 中找到 Prog.java,执行 java a/b/c/Prog.java 就会成功,而不管文件中是否有任何 package 声明。一个 .java 文件声明了命名包中的类但却没有位于层次结构中相应目录的情况并不常见,因此这项变更的影响可能有限。如果包名不重要,那么修复方法就是从文件中移除 package 声明。

模块化源代码程序

在迄今为止展示的示例中,从 .java 文件编译而来的类都位于未命名模块中。然而,如果源树的根目录包含一个 module-info.java 文件,那么该程序被视为是模块化的,并且源树中从 .java 文件编译而来的类将位于 module-info.java 中声明的命名模块中。

可以像这样运行使用当前目录中模块化库的程序:

$ java -p . pkg/Prog1.java
$ java -p . pkg/Prog2.java

或者,如果模块化的 JAR 文件位于 libs 目录中,那么使用 -p libs 将使它们可用。

启动时的语义和操作

从 JDK 11 开始,启动器的源文件模式的工作方式就如同

java <other options> --class-path <path> <.java file>

等价于

javac <other options> -d <memory> --class-path <path> <.java file>
java <other options> --class-path <memory>:<path> <first class in .java file>

具备启动多文件源代码程序的能力后,源文件模式现在的工作方式就像

java <other options> --class-path <path> <.java file>

等价于

javac <other options> -d <memory> --class-path <path> --source-path <root> <.java file>
java <other options> --class-path <memory>:<path> <launch class of .java file>

其中 <root> 是源树的计算根目录,如前文定义,而 <launch class of .java file>.java 文件的 启动类如下定义。(使用 --source-path 会向 javac 表明,在初始的 .java 文件中提到的类可能引用了源树中其他 .java 文件中声明的类。优先选择与 .java 文件同位置的类,而非其他 .java 文件中的类;例如,调用 javac --source-path dir dir/Prog.java 不会编译 Helper.java,如果 Prog.java 声明了类 Helper 的话。)

java 启动器在源文件模式下运行时(例如,java Prog.java),它会采取以下步骤:

  1. 如果文件以 "shebang" 行开头,即以 #! 开头的行,则传递给编译器的源路径为空,这样就不会编译其他源文件。进入步骤 4。

  2. 计算作为源树根目录的目录。

  3. 确定源代码程序的模块。如果在根目录中存在 module-info.java 文件,则其模块声明将用于定义一个命名模块,该模块将包含从源树中的 .java 文件编译的所有类。如果 module-info.java 不存在,则从 .java 文件编译的所有类都将位于未命名模块中。

  4. 编译初始 .java 文件中的所有类,以及可能声明了初始文件中代码引用的类的其他 .java 文件,并将生成的 class 文件存储在内存缓存中。

  5. 确定初始 .java 文件的 启动类。如果初始文件中的第一个顶级类声明了一个标准的 main 方法(public static void main(String[])JEP 463 中定义的其他标准 main 入口点),那么该类就是启动类。否则,如果初始文件中的另一个顶级类声明了一个标准的 main 方法并且与文件同名,则该类为启动类。否则,不存在启动类,启动器将报告错误并停止。

  6. 使用自定义类加载器从内存缓存中加载启动类,然后调用该类的标准 main 方法。

步骤 5 中选择启动类的过程保留了与 JEP 330 的兼容性,并确保当源程序从一个文件扩展到多个文件时,使用相同的 main 方法。它还确保“shebang”文件继续有效,因为在这样的文件中声明的类名可能与文件名不匹配。最后,它尽可能地保持与使用 javac 编译的程序启动体验一致,这样当源程序扩展到需要显式运行 javac 并执行 class 文件的程度时,可以使用相同的启动类。

当步骤 6 中的自定义类加载器被调用来加载一个类时——无论是启动类还是运行程序时需要加载的任何其他类——加载器会执行一次搜索,该搜索模拟了 javac-Xprefer:source 选项在编译时的顺序。特别是,如果一个类既存在于源代码树中(在 .java 文件中声明)又存在于类路径中(在 .class 文件中),则优先选择源代码树中的类。加载器针对名为 C 的类的搜索算法如下:

  1. 如果在内存缓存中找到了 C 的类文件,那么加载器会将缓存的类文件定义到 JVM 中,至此 C 的加载完成。

  2. 否则,加载器会委托给应用类加载器,搜索由源代码程序所在模块所读取的命名模块导出的、并且位于模块路径上或 JDK 运行时镜像中的 C 的类文件。(未命名模块——源代码程序可能驻留的地方——会读取 JDK 运行时镜像中的默认模块集合。)如果找到,则由应用类加载器完成 C 的加载。

  3. 否则,加载器会在与该类的包相对应的目录下,查找名称与类名(或者如果是成员类,则是其外围类的名称)匹配的 .java 文件,即 C.java。如果找到,.java 文件中声明的所有类都会被编译。如果编译成功,则生成的类文件会被存储在内存缓存中,加载器使用缓存的类文件将类 C 定义到 JVM,并完成 C 的加载。如果编译失败,启动器会报告错误并以非零退出状态终止。

    在编译 C.java 时,启动器可能会选择提前编译其他声明了被 C.java 引用的类的 .java 文件,并将生成的类文件存储在内存缓存中。此选择基于启发式算法,这些算法可能在不同版本的 JDK 之间发生变化。

  4. 否则,如果源代码程序驻留在未命名模块中,加载器会委托给应用类加载器,在类路径上搜索 C 的类文件。如果找到,则由应用类加载器完成 C 的加载。

  5. 否则,找不到名为 C 的类,加载器抛出 ClassNotFoundException

从类路径或模块路径加载的类不能引用从 .java 文件在内存中编译的类。也就是说,当遇到预编译类中的类引用时,永远不会查阅源代码树。

编译时与启动时编译的差异

使用 javac 时,Java 编译器在源路径上编译代码的方式,与使用 java 启动器在源文件模式下编译代码的方式之间存在一些主要差异。

  • 在源文件模式下,.java 文件中的类声明可以按需在程序执行期间逐步编译,而不是在执行开始前一次性全部编译。这意味着如果发生编译错误,启动器将在程序已经开始执行后终止。这种行为与通过 javac 显式编译的原型设计不同,但在由源文件模式支持的快速编辑/运行周期中非常有效。

  • 通过反射访问的类与直接访问的类以相同的方式加载。例如,如果程序调用 Class.forName("pkg.Helper"),启动器的自定义类加载器将尝试加载包 pkg 中的类 Helper,这可能会导致 pkg/Helper.java 的编译。同样,如果通过 Package::getAnnotations 查询某个包的注解,则源代码树中适当位置的 package-info.java 文件(如果存在)将会在内存中被编译并加载。

  • 注解处理被禁用,类似于向 javac 传递 --proc:none 参数时的行为。

  • 无法运行 .java 文件跨越多个模块的源代码程序。

最后两个限制可能会在将来移除。

替代方案

  • 我们可以将源代码程序限制为单个文件,并继续要求对多文件程序进行单独的编译步骤。虽然这并不会给开发者带来显著更多的工作量,但现实情况是,许多 Java 开发者已经对直接使用 javac 感到陌生,更倾向于在需要编译为 class 文件时依赖构建工具。相比于使用 javac,使用 java 命令显得不那么令人生畏。

  • 我们可以让 javac 更易于使用,为编译完整的源代码树提供便捷的默认设置。然而,需要为生成的 class 文件设置目录,否则它们会污染源代码树,这对快速原型设计来说是一个障碍。开发者常常即使在试验阶段也会将他们的 .java 文件置于版本控制之下,因此需要配置他们的版本控制仓库以排除由 javac 生成的 class 文件。