Skip to content

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 文件声明了两个类:

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

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

然后运行

shell
$ java Prog.java

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

这种低仪式感的程序运行方式有一个主要限制:程序的所有源代码必须放在单个 .java 文件中。为了处理多个 .java 文件,开发人员必须返回显式编译源文件。对于经验丰富的开发人员来说,这通常涉及为构建工具创建项目配置,但从无形的修补到正式项目结构的转变,在尝试使想法和实验顺畅流动时会很烦人。对于初学者开发人员来说,从单个 .java 文件到两个或多个文件的过渡需要更加鲜明的阶段变化:他们必须暂停对语言的学习,学习如何操作 javac,或学习第三方构建工具,或学习依赖 IDE 的神奇功能。

如果开发人员能够推迟项目设置阶段,直到他们更了解项目的形状,或者甚至在快速黑客攻击后丢弃原型时完全避免设置,那将更好。一些简单的程序可能永远以源代码形式存在。这促使我们增强 java 启动器,使其能够运行已扩展到单个 .java 文件之外的程序,但不强制要求显式的编译步骤。传统的编辑 / 构建 / 运行周期简化为编辑 / 运行。开发人员可以自己决定何时设置构建过程,而不是被工具的限制所迫。

描述

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

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

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 在两个文件中都被意外声明了:

java
// 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 方法,然后——由于 mainHelper 的引用——找到 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

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

shell
$ 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

java
// 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 并在内存中编译它,从而生成类 pkg.Helper,该类是类 Prog 中的代码所需要的。

如果 Prog.java 在命名包中声明了类,或者 Helper.javapkg 以外的包中声明了类,那么 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 文件中声明的包(如果有的话)是宽容的:只要 Prog.java 存在于 a/b/c 中,无论文件中是否有 package 声明,java a/b/c/Prog.java 都会成功执行。.java 文件在声明的命名包中声明类而该文件不位于层次结构中相应目录中的情况是不常见的,因此此更改的影响可能有限。如果包名不重要,则解决方法是从文件中删除 package 声明。

模块化源代码程序

到目前为止的示例中,从 .java 文件编译的类都位于未命名模块中。但是,如果源代码树的根目录包含 module-info.java 文件,则该程序被视为模块化,并且从源代码树中的 .java 文件编译的类位于 module-info.java 中声明的命名模块中。

在当前目录中利用模块化库的程序可以按如下方式运行:

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

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

运行时语义和操作

自 JDK 11 起,启动器的源文件模式工作原理相当于

shell
java <其他选> --class-path <> <.java>

非正式地等价于

shell
javac <其他选> -d <> --class-path <> <.java>
java  <其他选> --class-path <>:<> <.java 文件中的第一个>

由于能够启动多文件源代码程序,现在源文件模式的工作原理相当于

shell
java <其他选> --class-path <> <.java>

非正式地等价于

shell
javac <其他选> -d <> --class-path <> --source-path <根目> <.java>
java <其他选> --class-path <>:<> <.java 文件的启动>

其中,<根目录> 是源代码树的计算根目录(如前面 定义 所述),<.java 文件的启动类>.java 文件的 启动类(如下 定义 所述)。(使用 --source-path 指示 javac,初始 .java 文件中提到的类可能引用源代码树中其他 .java 文件声明的类。与位于其他 .java 文件中的类相比,位于同一 .java 文件中的类具有优先权;例如,如果 Prog.java 声明了类 Helper,则调用 javac --source-path dir dir/Prog.java 将不会编译 Helper.java。)

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. 否则,加载器将委托给应用程序类加载器,以搜索由源代码程序模块读取的命名模块导出的 C 的类文件,同时该类文件也位于模块路径或在 JDK 运行时映像中。(源代码程序可能驻留的无名模块读取 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,并且在需要编译到类文件时更喜欢依赖构建工具。使用 java 命令比使用 javac 更不令人生畏。

  • 我们可以使 javac 更容易使用,为编译完整的源代码树提供方便的默认值。然而,需要为生成的类文件设置一个目录,否则它们会污染源代码树,这是快速原型设计的障碍。开发人员即使在摸索阶段也经常将 .java 文件置于版本控制之下,因此他们需要设置其版本控制存储库以排除 javac 生成的类文件。