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!"); }
}
2
3
4
5
6
7
然后运行
$ java Prog.java
将在内存中编译这两个类,并执行该文件中声明的第一个类的 main
方法。
这种低仪式感的程序运行方式有一个主要限制:程序的所有源代码必须放在单个 .java
文件中。为了处理多个 .java
文件,开发人员必须返回显式编译源文件。对于经验丰富的开发人员来说,这通常涉及为构建工具创建项目配置,但从无形的修补到正式项目结构的转变,在尝试使想法和实验顺畅流动时会很烦人。对于初学者开发人员来说,从单个 .java
文件到两个或多个文件的过渡需要更加鲜明的阶段变化:他们必须暂停对语言的学习,学习如何操作 javac
,或学习第三方构建工具,或学习依赖 IDE 的神奇功能。
如果开发人员能够推迟项目设置阶段,直到他们更了解项目的形状,或者甚至在快速黑客攻击后丢弃原型时完全避免设置,那将更好。一些简单的程序可能永远以源代码形式存在。这促使我们增强 java
启动器,使其能够运行已扩展到单个 .java
文件之外的程序,但不强制要求显式的编译步骤。传统的编辑 / 构建 / 运行周期简化为编辑 / 运行。开发人员可以自己决定何时设置构建过程,而不是被工具的限制所迫。
描述
我们增强了 java
启动器的源文件模式,使其能够运行以多个 Java 源代码文件提供的程序。
例如,假设一个目录包含两个文件,Prog.java
和 Helper.java
,其中每个文件声明了一个类:
// Prog.java
class Prog {
public static void main(String[] args) { Helper.run(); }
}
// Helper.java
class Helper {
static void run() { System.out.println("Hello!"); }
}
2
3
4
5
6
7
8
9
运行 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.java
和 Helper.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() { ... }
}
2
3
4
5
6
7
8
9
10
11
12
13
14
15
运行 java Prog.java
会编译 Prog.java
中的 Prog
和 Aux
类,调用 Prog
的 main
方法,然后——由于 main
对 Helper
的引用——找到 Helper.java
并编译其 Helper
和 Aux
类。由于 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
2
3
4
5
您可以通过向 java
启动器传递 --class-path '*'
来快速运行这些程序:
$ java --class-path '*' Prog1.java
$ java --class-path '*' Prog2.java
2
在这里,--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!"); }
}
2
3
4
5
6
7
8
9
10
运行 java Prog.java
会导致在 pkg
子目录中找到 Helper.java
并在内存中编译它,从而生成类 pkg.Helper
,该类是类 Prog
中的代码所需要的。
如果 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
2
3
4
5
它还必须通过运行 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
中声明的命名模块中。
在当前目录中利用模块化库的程序可以按如下方式运行:
$ java -p . pkg/Prog1.java
$ java -p . pkg/Prog2.java
2
或者,如果模块化 JAR 文件位于 libs
目录中,则 -p libs
将使它们可用。
运行时语义和操作
自 JDK 11 起,启动器的源文件模式工作原理相当于
java <其他选项> --class-path <路径> <.java 文件>
非正式地等价于
javac <其他选项> -d <内存> --class-path <路径> <.java 文件>
java <其他选项> --class-path <内存>:<路径> <.java 文件中的第一个类>
2
由于能够启动多文件源代码程序,现在源文件模式的工作原理相当于
java <其他选项> --class-path <路径> <.java 文件>
非正式地等价于
javac <其他选项> -d <内存> --class-path <路径> --source-path <根目录> <.java 文件>
java <其他选项> --class-path <内存>:<路径> <.java 文件的启动类>
2
其中,<根目录>
是源代码树的计算根目录(如前面 定义 所述),<.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
),它将执行以下步骤:
如果文件以 "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.java
时,启动器可能会选择急切地编译C.java
引用的其他.java
文件,并将生成的类文件存储在内存缓存中。此选择基于启发式方法,该方法可能会随 JDK 版本的发布而变化。否则,如果源代码程序驻留在无名模块中,则加载器将委托给应用程序类加载器,在类路径上搜索
C
的类文件。如果找到,则由应用程序类加载器完成C
的加载。否则,找不到名为
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
生成的类文件。