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.java
和Helper.java
,其中每个文件声明一个类:
class Prog { public static void main(String[] args) { Helper.run(); }}
// Helper.javaclass 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.java
和Helper.java
最终如下所示,并且两个文件中都意外声明了Aux
类:
class Prog { public static void main(String[] args) { Helper.run(); Aux.cleanup(); }}class Aux { static void cleanup() { ... }}
// Helper.javaclass Helper { static void run() { ... }}class Aux { static void cleanup() { ... }}
运行java Prog.java
会编译Prog.java
中的Prog
和Aux
类,调用Prog
的main
方法,然后——由于main
引用了Helper
——找到Helper.java
并编译其Helper
和Aux
类。Helper.java
中不允许重复声明Aux
,因此Helper.java
停止,启动器会报告错误。
java
启动器的源文件模式通过传递单个.java
文件的名称来触发。如果传入了其他文件名,这些文件名将成为其main
方法的参数。例如,java Prog.java Helper.java
会生成一个包含字符串"Helper.java"
的数组,并将其作为参数传递给Prog
类的main
方法。
使用预编译类
Section titled “使用预编译类”依赖于类路径或模块路径上的库的程序也可以从源文件启动。例如,假设一个目录包含两个小程序、一个辅助类以及一些库JAR文件:
Prog1.javaProg2.javaHelper.javalibrary1.jarlibrary2.jar
您可以通过将--class-path '*'
传递给java
启动器来快速运行这些程序:
$ java --class-path '*' Prog1.java$ java --class-path '*' Prog2.java
这里,--class-path
选项的'*'
参数将目录中的所有JAR文件放在类路径上;星号被引起来以避免被shell扩展。
随着你不断尝试,你可能会发现将JAR文件放在单独的libs
目录中更方便,在这种情况下--class-path 'libs/*'
即可访问它们。你可以考虑在项目成型后,再考虑打包成可交付成果,或许可以借助构建工具。
启动器如何查找源文件
Section titled “启动器如何查找源文件”java
启动器要求将多文件源代码程序的源文件按通常的目录层次结构排列,其中目录结构遵循包结构,并从如下所述计算的根目录开始。这意味着:
- 根目录中的源文件必须声明未命名包中的类,并且
- 根目录下的目录
foo/bar
中的源文件必须声明名为foo.bar
的包中的类。
例如,假设一个目录包含Prog.java
(它声明了未命名包中的类)和一个子目录pkg
,其中Helper.java
声明了包pkg
中的类Helper
:
class Prog { public static void main(String[] args) { pkg.Helper.run(); }}
// pkg/Helper.javapackage 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
),而该包与文件系统中文件路径的后缀不对应,则程序将启动失败。
很小但不兼容的变化
Section titled “很小但不兼容的变化”如果在上面的例子中,Prog.java
声明了其他包中的类,那么java a/b/c/Prog.java
将会失败。这是java
启动器源文件模式行为的改变。
在之前的版本中,启动器的源文件模式对于在指定位置的.java
文件中声明哪个软件包(如果有)较为宽容:只要在a/b/c
中找到Prog.java
,那么java a/b/c/Prog.java
就会成功,无论文件中是否存在任何package
声明。.java
文件声明某个命名软件包中的类,而该文件不在层次结构中的相应目录中,这种情况并不常见,因此此更改的影响可能有限。如果软件包名称不重要,则解决方法是从文件中删除 package
声明。
模块化源代码程序
Section titled “模块化源代码程序”到目前为止的示例中,从.java
文件编译出来的类都位于未命名的模块中。但是,如果源代码树的根目录包含module-info.java
文件,则该程序将被视为模块化程序,并且从源代码树中的.java
文件编译出来的类将位于module-info.java
中声明的命名模块中。
使用当前目录中的模块化库的程序可以像这样运行:
$ java -p . pkg/Prog1.java$ java -p . pkg/Prog2.java
或者,如果模块化JAR文件位于libs
目录中,那么-p libs
将使它们可用。
启动时语义和操作
Section titled “启动时语义和操作”从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
文件中的类;例如,如果Prog.java
声明了Helper
类,则调用javac --source-path dir dir/Prog.java
将无法编译Helper.java
。)
当java
启动器以源文件模式运行时(例如,java Prog.java
),它采取以下步骤:
- 如果文件以“shebang”行(即以
#!
开头的行)开头,则传递给编译器的源路径为空,因此不会编译其他源文件。请继续执行步骤4。 - 计算源树的根目录。
- 确定源代码程序的模块。如果根目录中存在
module-info.java
文件,则其模块声明将用于定义一个命名模块,该模块将包含从源代码树中的.java
文件编译的所有类。如果module-info.java
不存在,则从.java
文件编译的所有类都将驻留在未命名的模块中。 - 编译初始
.java
文件中的所有类,以及可能声明初始文件中的代码引用的类的其他.java
文件,并将生成的class
文件存储在内存缓存中。 - 确定初始
.java
文件的启动类。如果初始文件中的第一个顶级类声明了一个标准main
方法(public static void main(String[])
或JEP 463中定义的其他标准main
入口点),则该类为启动类。否则,如果初始文件中的另一个顶级类声明了一个标准main
方法且与该文件同名,则该类为启动类。否则,不存在启动类,启动器将报告错误并停止。 - 使用自定义类加载器从内存缓存中加载启动类,然后调用该类的标准
main
方法。
步骤5中用于选择启动类的过程保留了与JEP 330的兼容性,并确保当源程序从一个文件增长到多个文件时,使用相同的main
方法。它还确保“shebang”文件继续工作,因为此类文件中声明的类名可能与文件名不匹配。最后,它保持了与使用javac
编译的程序启动体验尽可能接近的体验,以便当源程序增长到需要显式运行javac
并执行class
文件的程度时,可以使用相同的启动类。
当在步骤6中调用自定义类加载器来加载类(启动类或程序运行时需要加载的任何其他类)时,加载器会执行一次搜索,该搜索顺序与编译时javac
的-Xprefer:source
选项的顺序相同。具体来说,如果某个类同时存在于源代码树(在.java
文件中声明)和类路径(在.class
文件中)中,则优先选择源代码树中的类。加载器对名为C
类的搜索算法如下:
- 如果在内存缓存中找到
C
的类文件,则加载器将缓存的类文件定义到JVM,并且C
的加载完成。 - 否则,加载器委托应用程序类加载器搜索由命名模块导出的
C
类文件,该模块由源代码程序的模块读取,并且位于模块路径或JDK运行时映像中。(源代码程序可能驻留在未命名的模块中,它会读取JDK运行时映像中的一组默认模块。)如果找到,应用程序类加载器将完成C
的加载。 - 否则,加载器会搜索与该类(如果请求的类是成员类,则搜索包含该类的类)名称匹配的
.java
文件,即C.java
,该文件位于与该类对应的包目录中。如果找到,则编译.java
文件中声明的所有类。如果编译成功,则生成的类文件将存储在内存缓存中,加载器使用缓存的类文件将类C
定义到JVM,C
的加载完成。如果编译失败,则启动器报告错误并以非零退出状态终止。 - 否则,如果源代码程序位于未命名模块中,加载器将委托应用程序类加载器在类路径中搜索
C
的类文件。如果找到,则由应用程序类加载器完成C
的加载。 - 否则,将找不到名为
C
的类,并且加载器将抛出ClassNotFoundException
。
从类路径或模块路径加载的类无法引用从.java
文件在内存中编译的类。也就是说,当遇到预编译类中的类引用时,永远不会查询源代码树。
编译时和启动时编译的区别
Section titled “编译时和启动时编译的区别”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
生成的类文件。