JEP 330: Launch Single-File Source-Code Programs | 启动单文件源代码程序
摘要
增强 java
启动器以运行作为单个 Java 源代码文件提供的程序,包括通过 "shebang" 文件 和相关技术在脚本内部的使用。
非目标
本 JEP 的目标不是改变 Java 语言规范(JLS)或 javac 以适应 shebang 文件。同样,本 JEP 的目标也不是将 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
。命令行参数文件(@-files) 可以按标准方式使用。VM 或要调用的程序的参数长列表可以放在文件中,通过在文件名前加上
@
字符在命令行上指定这些文件。
在源文件模式下,编译过程如下:
任何与编译环境相关的命令行选项都会被考虑在内。
不会查找和编译其他源文件,就好像源路径被设置为空值一样。
禁用注解处理,就好像
-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
会被解释为名为 java
的类,该类位于名为 HelloWorld
的包中,但现在如果存在这样的文件,则优先解析为名为 HelloWorld.java
的文件。鉴于这样的类名和包名都违反了几乎普遍遵循的命名约定,并且鉴于此类类不太可能出现在类路径上,并且不太可能有一个同名的文件,所以这种歧义应该是可以接受的。
实现
源文件模式需要 jdk.compiler
模块的存在。当为文件 Foo.java
请求源文件模式时,启动器会表现得像命令行被转换成如下形式:
java [VM参数] \
-m jdk.compiler/<源文件启动器实现类> \
Foo.java [程序参数]
源文件启动器实现类通过编程方式调用编译器,将源文件编译成内存中的表示形式。然后,源文件启动器实现类创建一个类加载器来从该内存表示形式中加载编译后的类,并调用源文件中找到的第一个顶层类的标准 main(String[])
方法。
源文件启动器实现类可以访问任何相关的命令行选项,例如定义类路径、模块路径和模块图的选项,并将这些选项传递给编译器以配置编译环境。
如果调用的类抛出异常,该异常将以正常方式传递回启动器进行处理。然而,在传递给异常的堆栈跟踪中,执行该类之前的初始堆栈帧将被移除。这样做的目的是,如果该类是直接由启动器本身执行的话,对异常的处理方式是相似的。初始堆栈帧将在任何对堆栈的直接访问中可见,包括(例如)Thread.dumpStack()
。
用于加载编译后类的类加载器本身使用特定于实现的协议来处理任何引用类加载器定义资源的 URL。获取此类 URL 的唯一方法是通过使用如 getResource
或 getResources
等方法;不支持从字符串创建此类 URL。
"Shebang" 文件
当手头的任务需要一个小型实用程序时,单文件程序也很常见。在这种情境下,能够在 Unix 派生系统(如 macOS 和 Linux)上直接使用 "#!" 机制从源代码运行程序是非常可取的。这是操作系统提供的一种机制,它允许将单文件程序(如脚本或源代码)放置在任何方便命名的可执行文件中,该文件的第一行以 #!
开头,并指定一个程序的名称来“执行”文件的内容。这样的文件被称为“shebang 文件”。
能够使用这种机制来执行 Java 程序是非常可取的。
要调用使用源文件模式的 Java 启动器的 shebang 文件,必须以类似以下内容开头:
#!/path/to/java --source 版本号
例如,我们可以将“Hello World”程序的源代码放入名为 hello
的文件中,并在文件的第一行添加 #!/path/to/java --source 10
,然后将文件标记为可执行。然后,如果该文件位于当前目录中,我们可以使用以下命令执行它:
$ ./hello
或者,如果该文件位于用户 PATH 中的某个目录中,我们可以使用以下命令执行它:
$ hello
该命令的任何参数都会传递给被执行的类的 main
方法。例如,如果我们将计算阶乘的程序的源代码放入一个名为 factorial
的 shebang 文件中,我们可以使用类似以下的命令来执行它:
$ factorial 6
在以下情况下,必须在 shebang 文件中使用 --source
选项:
- shebang 文件的名称不符合 Java 源文件的标准命名约定。
- 希望在 shebang 文件的第一行指定额外的 VM 选项。在这种情况下,
--source
选项应在可执行文件名称之后首先指定。 - 希望指定文件中源代码所使用的 Java 语言版本。
启动器也可以通过像这样的命令显式地调用 shebang 文件,可能带有额外的选项:
$ java -Dtrace=true --source 10 factorial 3
Java 启动器的源文件模式为 shebang 文件做了两项适应:
当启动器读取源文件时,如果文件不是 Java 源文件(即文件名不以
.java
结尾)且第一行以#!
开头,则在确定要传递给编译器的源代码时,将忽略该行内容直到但不包括第一个换行符。该行后面的文件内容必须包含有效的CompilationUnit
,如 Java Language Specification 的相应版本中的 §7.3 所定义,这取决于--source
选项(如果存在)中给出的平台版本,或者如果不存在--source
选项,则使用运行程序的平台版本。第一行末尾的换行符将被保留,以便在 shebang 文件中任何编译器错误消息中的行号都是有意义的。
一些操作系统将可执行文件名称后的第一行文本作为单个参数传递给可执行文件。考虑到这一点,如果启动器遇到以
--source
开头且包含空格的选项,它会在被启动器进一步分析之前被拆分成一系列由空格分隔的单词。这允许在第一行上放置额外的参数,尽管一些操作系统可能会对行的总长度施加限制。不支持使用引号来保留此类值中的空格。
支持此功能不需要对 JLS(Java Language Specification,Java 语言规范)进行任何更改。
在 shebang 文件中,前两个字节必须是 0x23 0x21
,即 #!
的两个字符 ASCII 编码。所有后续字节都使用当前有效的默认平台字符编码进行读取。
当希望使用操作系统的 shebang 机制来执行文件时,才需要第一行以 #!
开头。如果像上面给出的 HelloWorld.java
和 Factorial.java
示例那样明确使用 Java 启动器来运行源文件中的代码,则不需要任何特殊的第一行。实际上,不允许使用 shebang 机制来执行遵循 Java 源文件标准命名约定的文件。
替代方案
目前的现状已经工作了 20 多年,我们可以继续这样做。
除了使用 #!
,我们还可以配置支持 shebang 文件的系统来使用不同的前缀,比如 //!
。这样的前缀会被 javac
视为单行注释,并且不需要特殊处理来忽略它。然而,在 macOS 和 Linux 等操作系统上引入新的“魔法数字”需要对这些系统进行手动或自动更新,这超出了本 JEP(Java Enhancement Proposal,Java 增强提案)的范围。
除了使用 shebang 机制,我们还可以编写一个包含 Java 源代码的 shell 脚本,这些 Java 源代码作为 here document(此处文档)传递给 Java 源文件启动器。虽然这最终比 shebang 机制更灵活,但在简单的情况下,它的开销也更大。
我们可以创建一个源文件启动器,但除了 java
之外,给它取一个其他的名字,比如 jrun
。鉴于启动器已经拥有多种执行模式,这可能会被视为一种不必要的差异。
我们可以将“一次性运行”的任务委托给 jshell
工具。虽然这在一开始看起来很明显,但这在 jshell
的设计中是明确排除的目标。jshell
工具被设计为一个交互式 shell,许多设计决策都是为了提供更好的交互式体验。如果额外增加作为批处理运行器的约束,将会削弱其交互式体验。
我们也可以使用 jrunscript
工具。然而,这个工具提供与运行时环境交互的功能有限,并且没有解决提供一个简单的 Java 使用入门的需求。