JEP 330:启动单文件源代码程序
概述
增强 java 启动器,以运行作为单个 Java 源代码文件提供的程序,包括通过 "shebang" 文件 和相关技术在脚本内部使用。
非目标
修改《Java 语言规范》(JLS)或 javac 以适应 shebang 文件并不是本提案的目标。同样,将 Java 语言发展为一种通用脚本语言也并非本提案的目标。
此 JEP 的目标并非更改 Java 语言规范以适应编写小程序的更简单方法,例如消除对标准 public static void main(String[] args) 方法的需求。然而,预期对 Java 语言的任何此类更改都将能够与此功能结合使用。
动机
单文件程序——即整个程序都在一个源文件中——在学习 Java 的早期阶段以及编写小型实用程序时非常常见。在这种情况下,必须先编译程序才能运行它纯粹是一种形式。此外,单文件程序可能会声明多个类,因此会编译成多个 class 文件,这为实现“运行这个程序”的简单目标增加了打包的开销。理想的情况是能够使用 java 启动器直接从源代码运行程序:
java HelloWorld.java
描述
从 JDK 10 开始,java 启动器以三种模式运行:启动类文件、启动 JAR 文件的主类或启动模块的主类。这里我们添加了一种新的第四种模式:启动在源文件中声明的类。
源文件模式是通过考虑命令行中的两项内容来确定的:
- 命令行中既不是选项也不属于选项一部分的第一个项目。(换句话说,之前是类名的项目。)
- 如果存在的话,
--source版本 选项。
如果“类名”标识了一个现有文件,且该文件带有 .java 扩展名,则会选择源文件模式,并对该文件进行编译和运行。可以使用 --source 选项来指定源代码的版本。
如果文件没有 .java 扩展名,则必须使用 --source 选项来强制启用源文件模式。这种情况适用于源文件是需要执行的“脚本”,并且源文件的名称不遵循 Java 源文件的常规命名约定的情况。(请参见下面的 “shebang” 文件。)
当使用 --enable-preview 选项时,还必须使用 --source 选项来指定源代码的源版本。(参见 JEP 12。)
在源文件模式下,其效果就好像源文件被编译到内存中,并且执行源文件中的第一个类。例如,如果一个名为 HelloWorld.java 的文件包含一个名为 hello.World 的类,那么以下命令
java HelloWorld.java
等价于
javac -d <memory> HelloWorld.java
java -cp <memory> hello.World
原始命令行中源文件名之后的任何参数都会在编译后的类执行时传递给它。例如,如果一个名为 Factorial.java 的文件包含一个名为 Factorial 的类,用于计算其参数的阶乘,那么以下命令
java Factorial.java 3 4 5
等价于
javac -d <memory> Factorial.java
java -cp <memory> Factorial 3 4 5
在源文件模式下,任何额外的命令行选项都会按以下方式处理:
-
启动器会扫描源文件之前指定的选项,查找与编译源文件相关的任何选项。这包括:
--class-path、--module-path、--add-exports、--add-modules、--limit-modules、--patch-module、--upgrade-module-path以及这些选项的任何变体形式。它还包括新的--enable-preview选项,该选项在 JEP 12 中有描述。 -
没有提供将任何附加选项传递给编译器的机制,例如
-processor或-Werror。 -
可以按照标准方式使用命令行参数文件(@-文件)。虚拟机或被调用程序的长参数列表可以放在文件中,并通过在命令行中用
@字符前缀文件名来指定。
在源文件模式下,编译过程如下:
-
任何与编译环境相关的命令行选项都会被考虑在内。
-
不会查找或编译其他源文件,就像源路径被设置为一个空值一样。
-
注解处理被禁用,就像
-proc:none生效一样。 -
如果通过
--source选项指定了某个 版本,则该值将作为隐式--release选项的参数用于编译。这同时设置了编译器接受的源代码版本以及源文件中代码可能使用的系统 API。 -
源文件将在一个未命名模块的上下文中进行编译。
-
源文件应包含一个或多个顶层类,其中第一个类将被视为要执行的类。
-
编译器不会强制执行 JLS §7.6 末尾定义的可选限制,即命名包中的类型应该存在于以其类型名称加上
.java扩展名组成的文件中。 -
如果源文件包含错误,则适当的错误信息会被写入标准错误流,并且启动器将以非零退出码退出。
在源文件模式下,执行过程如下:
-
要执行的类是源文件中找到的第一个顶级类。它必须包含标准
public static void main(String[])方法的声明。 -
编译后的类由一个自定义类加载器加载,该加载器委托给应用程序类加载器。(这意味着出现在应用程序类路径上的类不能引用源文件中声明的任何类。)
-
编译后的类在未命名模块的上下文中执行,并且如同
--add-modules=ALL-DEFAULT生效一样(此外还可以在命令行中指定其他--add-module选项。) -
命令行中文件名之后出现的任何参数都会以显而易见的方式传递给标准的
main方法。 -
如果应用程序类路径上存在一个与要执行的类同名的类,则会报错。
请注意,当使用像 java HelloWorld.java 这样的简单命令行时,存在一个潜在的小歧义。以前,HelloWorld.java 会被解释为名为 HelloWorld 的包中的一个名为 java 的类,但现在如果有这样一个文件存在,则解析为名为 HelloWorld.java 的文件。鉴于这样的类名和包名都违反了几乎普遍遵循的命名约定,并且考虑到这样的类位于类路径上而同名文件又在当前目录中的可能性很小,这似乎是一个可以接受的折衷方案。
实现
源文件模式需要 jdk.compiler 模块的存在。当请求对文件 Foo.java 使用源文件模式时,启动器的行为就像命令行被转换为:
java [VM args] \
-m jdk.compiler/<source-launcher-implementation-class> \
Foo.java [program args]
源启动器实现类以编程方式调用编译器,该编译器将源代码编译为内存中的表示形式。然后,源启动器实现类创建一个类加载器,以从该内存表示形式中加载已编译的类,并调用在源文件中找到的第一个顶级类的标准 main(String[]) 方法。
源启动器实现类可以访问任何相关的命令行选项,例如用于定义类路径、模块路径和模块图的选项,并将这些选项传递给编译器以配置编译环境。
如果被调用的类抛出异常,该异常会以常规方式传递回调用器进行处理。然而,导致该类执行的初始堆栈帧会从异常的堆栈跟踪中移除。这样做的目的是使异常的处理方式类似于该类由启动器直接执行时的处理方式。在任何对堆栈的直接访问中,包括(例如)Thread.dumpStack(),初始堆栈帧都将可见。
用于加载已编译类的类加载器本身使用一种特定于实现的协议来处理引用由类加载器定义的资源的任何 URL。获取此类 URL 的唯一方法是使用 getResource 或 getResources 等方法;不支持通过字符串创建任何此类 URL。
“Shebang”文件
当手头的任务需要一个小的实用程序时,单文件程序也很常见。在这种情况下,最好能够使用类 Unix 系统(例如 macOS 和 Linux)上的 "#!" 机制直接从源代码运行程序。这是操作系统提供的一种机制,允许将单文件程序(例如脚本或源代码)放入任何方便命名的可执行文件中,该文件的第一行以 #! 开头,并指定用于“执行”文件内容的程序名称。这样的文件被称为“shebang 文件”。
能够使用此机制执行 Java 程序是可取的。
用于调用 Java 启动器(使用源文件模式)的 shebang 文件必须以类似以下内容开头:
#!/path/to/java --source version
例如,我们可以将 “Hello World” 程序的源代码放入一个名为 hello 的文件中,在文件的第一行写上 #!/path/to/java --source 10,然后将该文件标记为可执行文件。接着,如果该文件位于当前目录下,我们可以通过以下方式执行它:
$ ./hello
或者,如果文件位于用户 PATH 中的目录中,我们可以通过以下方式执行它:
$ hello
传递给命令的任何参数都会被传递到所执行类的 main 方法中。例如,如果我们将用于计算阶乘的程序源代码放入一个名为 factorial 的 shebang 文件中,则可以使用如下命令执行它:
$ factorial 6
在以下情况下,必须在 shebang 文件中使用 --source 选项:
- Shebang 文件的名称不遵循 Java 源文件的标准命名约定。
- 希望在 shebang 文件的第一行指定额外的虚拟机选项。在这种情况下,
--source选项应该在可执行文件名称之后首先指定。 - 希望指定文件中源代码使用的 Java 语言版本。
Shebang 文件也可以通过启动器显式调用,或许还可以附带额外的选项,例如使用如下命令:
$ java -Dtrace=true --source 10 factorial 3
Java 启动器的源文件模式为 shebang 文件提供了两项支持:
-
当启动器读取源文件时,如果该文件不是 Java 源文件(即,文件名不以
.java结尾),并且如果第一行以#!开头,则在确定要传递给编译器的源代码时,将忽略从该行开头到第一个换行符之前(但不包括换行符)的内容。文件中出现在第一行之后的内容必须构成一个有效的CompilationUnit,其定义见 Java 语言规范 中与--source选项指定的平台版本相对应的章节 §7.3;如果未提供--source选项,则使用运行程序所用的平台版本。第一行末尾的换行符会被保留,以便任何编译器错误消息中的行号在 shebang 文件中具有意义。
-
某些操作系统会将可执行文件名称后的第一行文本作为单个参数传递给可执行文件。考虑到这一点,如果启动器遇到以
--source开头且包含空格的选项,它会将该选项按空格分隔拆分为一系列单词,然后再由启动器进一步分析。这允许在第一行添加额外的参数,尽管某些操作系统可能会对整行的长度施加限制。不支持使用引号来保留此类值中的空格。
支持此特性不需要对 JLS 进行更改。
在 shebang 文件中,前两个字节必须是 0x23 0x21,即 #! 的两个字符的 ASCII 编码。所有后续字节都会按照当前生效的默认平台字符编码进行读取。
只有在希望使用操作系统的 shebang 机制来执行文件时,才需要以 #! 开头的第一行。当明确使用 Java 启动器来运行源文件中的代码时(如上面给出的 HelloWorld.java 和 Factorial.java 示例),不需要任何特殊的首行。事实上,不允许使用 shebang 机制来执行遵循 Java 源文件标准命名约定的文件。
替代方案
现状已经持续了 20 多年;我们可以继续这样。
与其使用 #!,还可以配置支持 shebang 文件的系统以使用其他前缀,例如 //!。这样的前缀会被 javac 视为单行注释,并且不需要任何特殊处理来忽略它。然而,在 macOS 和 Linux 等操作系统上引入一个新的 幻数 需要对这些系统进行手动或自动更新,这超出了此 JEP 的范围。
除了使用 shebang 机制外,还可以编写一个包含 Java 源代码的 shell 脚本,作为here document传递给 Java 源代码启动器。尽管这种方法比 shebang 机制更加灵活,但在简单场景下,它也比使用 shebang 带来更多的开销。
我们可以创建一个源代码启动器,但将其命名为其他名称,而不是 java,例如 jrun。鉴于该启动器已经具备的执行模式数量,这很可能会被视为一种多余的差异。
我们可以将“一次性运行”的任务委托给 jshell 工具。虽然这起初似乎显而易见,但在 jshell 的设计中这是一个明确的非目标。jshell 工具被设计为一个交互式 shell,许多设计决策都倾向于提供更好的交互体验。如果让它承担作为批处理运行器的额外约束,将会削弱交互体验。
我们也可以使用 jrunscript 工具。但是,此工具提供与运行时环境交互的功能有限,并且不能满足提供使用 Java 的简单介绍的需求。