跳转到内容

JEP 458:启动多个源文件的程序

原文:https://openjdk.org/jeps/458
翻译:张欢

增强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文件的编译顺序或时间。例如,启动器可能会先编译Helper.java,然后再编译Prog.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,因此Helper.java停止,启动器会报告错误。

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.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文件中声明哪个软件包(如果有)较为宽容:只要在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-pathjavac指示,初始.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的加载完成。如果编译失败,则启动器报告错误并以非零退出状态终止。
  4. 否则,如果源代码程序位于未命名模块中,加载器将委托应用程序类加载器在类路径中搜索C的类文件。如果找到,则由应用程序类加载器完成C的加载。
  5. 否则,将找不到名为C的类,并且加载器将抛出ClassNotFoundException

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

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

  • 在源文件模式下,.java文件中的类声明可以在程序执行过程中按需增量编译,而不是在程序开始执行前一次性全部编译。这意味着,如果发生编译错误,启动器将在程序开始执行后终止。此行为与通过javac进行显式编译的原型设计不同,但它在源文件模式支持的快速编辑/运行周期中有效运行。
  • 通过反射访问的类的加载方式与直接访问的类相同。例如,如果程序调用Class.forName("pkg.Helper"),启动器的自定义类加载器将尝试加载包pkg中的Helper类,这可能会导致编译pkg/Helper.java。同样,如果通过Package::getAnnotations查询包的注释,则源码树中位置合适的package-info.java文件(如有)将在内存中进行编译并加载。
  • 注解处理被禁用,类似于将--proc:none传递给javac时。
  • 无法运行.java文件跨越多个模块的源代码程序。

最后两个限制将来可能会被取消。

  • 我们可以将源代码程序限制为单文件程序,并继续要求对多文件程序进行单独的编译步骤。虽然这不会给开发人员带来太多工作量,但现实情况是,许多Java开发人员已经不熟悉直接使用javac,在需要编译为类文件时,他们更倾向于依赖构建工具。使用java命令比使用javac更轻松。
  • 我们可以让javac更易于使用,并提供方便的默认设置来编译完整的源代码树。然而,需要为生成的类文件设置一个目录,否则它们会污染源代码树,这会对快速原型开发造成阻碍。开发人员通常即使在修改阶段也会将他们的.java文件置于版本控制之下,因此需要设置版本控制存储库以排除javac生成的类文件。