Java 11 : 无需编译即可运行单文件程序

我们为什么需要这个特性

如果我们回想一下JavaSE 11(JDK 11)之前的日子,假设我们有一个 HelloUniverse.java 源文件,它包含一个类定义和一个静态的 main 方法,该方法打印一行文本到终端中,代码如下所示:

	
public class HelloUniverse{
      public static void main(String[] args) { 
            System.out.println("Hello InfoQ Universe");
      }
}

正常情况下,如果要运行这个类,首先,需要使用 Java 编译器(javac)来编译它,编译后将生成一个 HelloUniverse.class 文件:

	
mohamed_taman$ javac HelloUniverse.java

然后,需要使用一条 java 虚拟机(解释器)命令来运行生成的字节码类文件:

	
mohamed_taman$ java HelloUniverse
Hello InfoQ Universe

它将启动 JVM、加载类并执行代码。

但是,如果我们想快速测试一段代码,或者我们刚开始学习 Java(这里的关键词是 Java)并想实践这种语言,应该怎么办呢?上述过程中的两个步骤实践起来似乎还是有点难度。

在 Java SE 11 中,我们可以在无需任何中间编译的情况下,直接启动单个源代码文件。

这一特性对于那些想尝试简单程序的 Java 新手来说特别有用;当我们将这个特性与 jshell 结合起来使用时,我们将会得到一个很棒的初学者学习工具集。

更多关于 **Jshell 10+** 的新信息,请查看视频教程“ Hands-on Java 10 Programming with JShell ”。

专业人员也可以利用这些工具来探索新的语言变化或尝试未知的 API。在我看来,当我们可以自动化地执行很多任务时,比如,将 Java 程序编写为脚本,然后在操作系统 shell 中执行这些脚本,它将会产生更强大的功能。这种组合不仅为我们提供了 shell 脚本的灵活性,同时也提供了 Java 语言的强大功能。我们将在本文的第二部分更详细地探讨这个问题。

该 Java 11 特性的伟大之处在于,它使我们可以无需任何编译即可直接运行 Java 单文件源代码。现在让我们深入地了解它的更多细节和其他有趣的相关主题。

我们需要遵循什么

如果想要运行本文中提供的所有演示示例,我们需要使用 Java 的最新版本。它应该是 Java 11 或更高版本。当前的功能版本是Java SE 开发工具包 12.0.1(最终版本可以从该链接获得,只需接受许可并单击与操作系统相匹配的链接即可)。如果想要了解更多的新特性,最新的 JDK 13 early access是最近更新的,可以从这个链接下载。

我们还应该注意到,现在也可以从 Oracle 和其他供应商(如 AdoptOpenJDK )处获取 OpenJDK 版本。

在本文中,我们使用纯文本编辑器而不是 Java IDE,因为我们想要避免任何 IDE 魔力,并在终端中直接使用 Java 命令行。

使用 Java 运行.java 文件

JEP 330 启动单文件源代码程序Launch Single-File Source-Code Programs),是 JDK11 发行版本中引入的新特性之一。该特性允许我们直接使用 Java 解释器来执行 Java 源代码文件。源代码在内存中编译,然后由解释器执行,而不需要在磁盘上生成.class 文件了。

但是,该特性仅限于保存在单个源文件中的代码。不能在同一个运行编译中添加其他源文件。

为了满足这个限制,所有的类都必须在同一个文件中定义,不过它对文件中类的数量没有限制,并且类既可声明为公共类,也可以不是,因为只要它们在同一个源文件中就没关系。

源文件中声明的第一个类将被提取出来作为主类,我们应该将 main 方法放在第一个类中。所以类的顺序很重要。

第一个示例

现在,让我们以学习新东西时的一贯做法开始我们的学习吧,是的,你没有猜错,以一个最简单的“Hello Universe!” 示例开始。

我们将集中精力通过尝试不同的示例来演示如何使用该特性,以便你了解如何在日常编码中使用该特性。

如果还没有准备好,请先创建本文顶部列出的 HelloUniverse.java 文件,编译它,并运行生成的字节码类文件。

现在,我希望你删除编译生成的类文件;你马上就会明白为什么:

	
mohamed_taman$ rm HelloUniverse.class

现在,如果不编译,只使用 Java 解释器运行该类,操作如下:

	
mohamed_taman$ java HelloUniverse.java
Hello InfoQ Universe

我们会看到它运行了,并返回和之前编译时相同的结果。

对于 java HelloUniverse.java 来说,我们传入的是源代码而不是字节码类文件,这就意味着,它在内部编译源代码,然后运行编译后的代码,最后将消息输出到控制台。

所以,它仍然需要进行一个编译过程,如果有编译错误,我们仍然会收到一个错误通知。此外,我们还可以检查目录结构,会发现并未生成字节码类文件;这是一个内存编译过程

现在,让我们看看这个魔法是如何发生的。

Java 解释器如何运行 HelloUniverse 程序

在 JDK 10 中,Java 启动程序会以如下三种模式运行:

  1. 运行字节码类文件
  2. 运行 JAR 文件中的 main 类
  3. 运行模块中的 main 类

现在,在 Java 11 中,又添加了一个新的第四模式:

  1. 运行源文件中声明的类

在源文件模式下,运行效果就像是,将源文件编译到内存中,并执行可以在源文件中找到的第一个类。

是否进入源文件模式由命令行上的如下两项来决定:

  1. 在命令行中既不是选项也不是选项一部分的第一项。
  2. 如果存在选项的话,它将是–source选项。

对于第一种情况,Java 命令将查看命令行上的第一项,它既不是选项也不是选项的一部分。如果它有一个以.java 结尾的文件名,那么它将会被当作是一个要编译和运行的 Java 源文件。我们也可以在源文件名之前为 Java 命令提供选项。比如,如果我们希望在源文件中通过设置类路径来使用外部依赖项时。

对于第二种情况,选择源文件模式,并将第一个非选项命令行项视为要编译和运行的源文件。

如果文件没有.java 扩展名,则必须使用–source 选项来强制执行源文件模式。

当源文件是要执行的“脚本”,或者源文件的名称不遵循 Java 源文件的常规命名约定时,–source 选项是必要的。

–source 选项还可用于指定源代码的语言版本。稍后我会详细讨论。

我们可以传递命令行参数吗?

让我们丰富下“Hello Universe”程序,为访问 InfoQ Universe 的任何人创建一个个性化的问候:

	
public class HelloUniverse2{
    public static void main(String[] args){
        if ( args == null || args.length< 1 ){
System.err.println("Name required");
System.exit(1);
        }
  var name = args[0];
  System.out.printf("Hello, %s to InfoQ Universe!! %n", name);
    }
}

我们将代码保存在一个名为 Greater.java 的文件中。请注意,该文件的命名违反了 Java 编程规范,它的名称和公共类的名称不匹配。

运行如下代码,看看将会发生什么:

	
mohamed_taman$ java Greater.java "Mo. Taman"
Hello, Mo. Taman to InfoQ universe!!

我们可以看到的,类名是否与文件名匹配并不重要;它是在内存中编译的,并且没有生成 .class 文件。敏锐的读者可能还注意到了,我们是如何在要执行的文件名之后将参数传递给代码的。这意味着在命令行上文件名之后出现的任何参数都会以这种显式的方式传递给标准的 main 方法。

使用–source 选项指定代码文件的语言版本

有两种使用 –source 选项的场景:

  1. 指定代码文件的语言版本
  2. 强制 Java 运行时进入源文件执行模式

在第一种情况下,当我们缺省代码语言版本时,则假定它是当前的 JDK 版本。在第二种情况下,我们可以对除 .java 之外的扩展名文件进行编译并立即运行。

我们先研究一下第二个场景,将 Greater.java 重命名为没有任何扩展名的 greater,然后使用相同的方法,尝试再次执行它:

	
mohamed_taman$ java greater "Mo. Taman"
Error: Could not find or load main class greater
Caused by: java.lang.ClassNotFoundException: greater

正如我们所看到的那样,在没有 .java 扩展名的情况下,Java 命令解释器将以模式 1 的形式启动 Java 程序,它会根据参数中提供的文件名寻找编译后的字节码类。为了防止这种情况的发生,我们需要使用 –source 选项来强制指定源文件模式:

	
mohamed_taman$ java --source 11 greater "Mo. Taman"
Hello, Mo. Taman to InfoQ universe!!

现在,让我们回到第一个场景。Greater.java 类与 JDK 10 兼容的,因为它包含 var 关键字,但与 JDK 9 不兼容。将源版本更改为 10,看看会发生什么:

	
mohamed_taman$ java --source 10 Greater.java "Mo. Taman"
Hello Mo. Taman to InfoQ universe!!

现在再次运行前面的命令,但传递到 –source 选项的是 JDK 9 而不是 JDK 10:

	
mohamed_taman$ java --source 9 Greater.java "Mo. Taman"
Greater.java:8: warning: as of release 10, 'var' is a restricted local variable type and cannot be used for type declarations or as the element type of an array
var name = args[0];
            ^
Greater.java:8: error: cannot find symbol
var name = args[0];
        ^
  symbol:   class var
  location: class HelloWorld
1 error
1 warning
error: compilation failed

请注意错误消息的形式,编译器警告说,在 JDK 10 中 var 会成为一个受限制的类型名,但是由于当前是 Java 语言 9 版本,所以编译仍会继续进行。但是,由于在源文件中找不到名为 var 的类型,所以编译失败。

很简单,对吧?现在让我们看看如何使用多个类。

它是否适用于多个类?

答案是肯定的。

让我们测试一段包含两个类的示例代码,以演示该特性可以适用于多个类。该代码的功能是检验给定的字符串是否为回文。回文可以是一个单词、短语、数字或其他字符序列,但它们从两个方向读取时,都能得到相同的字符序列,例如“redivider”或“reviver”。

如下是保存在名为 PalindromeChecker.java 文件中的代码:

	
import static java.lang.System.*;
public class PalindromeChecker {
      public static void main(String[] args) {
            
            if ( args == null || args.length< 1 ){
                err.println("String is required!!");
                exit(1);
            }
            out.printf("The string {%s} is a Palindrome!! %b %n",
                  args[0],
                  StringUtils
                        .isPalindrome(args[0]));            
      }
}
public class StringUtils {
      public static Boolean isPalindrome(String word) {
      return (new StringBuilder(word))
            .reverse()
            .toString()
            .equalsIgnoreCase(word);
      }
}

现在,我们运行一下这个文件:

	
mohamed_taman:code$ java PalindromeChecker.java RediVidEr
The string {RediVidEr} is a Palindrome!! True

使用“RaceCar”代替“RediVidEr”后,再运行一次:

	
mohamed_taman:code$ java PalindromeChecker.java RaceCar
The string {RaceCar} is a Palindrome!! True

最后,再使用“Taman”来代替“RaceCar”:

	
mohamed_taman:code$ java PalindromeChecker.java Taman
The string {Taman} is a Palindrome!! false

正如我们看到的那样,我们可以在单个源文件中添加任意多个的公共类。唯一的要点是,main 方法应该在源文件的第一个类中定义。解释器(Java 命令)将使用第一个类作为入口,在内存中编译代码并启动程序。

允许使用模块吗?

是的,完全允许使用模块。内存中编译的代码作为未命名模块的一部分运行,该未命名模块带有 –add-modules=ALL-DEFAULT 选项,该选项允许访问 JDK 附带的所有模块。

这使得代码可以使用不同的模块,而无需使用 module-info.java 显式声明依赖项。

让我们来看一些使用 JDK11 附带的新的 HTTP 客户端 API 进行 HTTP 调用的代码。注意,这些 API 是在 Java SE 9 中作为孵化器特性引入的,但是现在它们已经逐步发展成为 java.net.http 模块中的完整特性。

在本示例中,我们将通过 GET 方法调用一个简单的 REST API 来获取一些用户信息。我们将调用一个公共端点服务 https://reqres.in/api/users?page=2 。示例代码位于名 UsersHttpClient.java 的文件中:

	
import static java.lang.System.*;
import java.net.http.*;
import java.net.http.HttpResponse.BodyHandlers;
import java.net.*;
import java.io.IOException;
 
public class UsersHttpClient{
    public static void main(String[] args) throws Exception{
var client = HttpClient.newBuilder().build(); 
var request = HttpRequest.newBuilder()
.GET()
.uri(URI.create("https://reqres.in/api/users?page=2"))
.build();
 
var response = client.send(request, BodyHandlers.ofString());
out.printf("Response code is: %d %n",response.statusCode());
out.printf("The response body is:%n %s %n", response.body());     
    }
}

运行程序,将产生如下的输出结果:

	
mohamed_taman:code$ java UsersHttpClient.java
Response code is: 200
The response body is:
{"page":2,"per_page":3,"total":12,"total_pages":4,"data":[{"id":4,"first_name":"Eve","last_name":"Holt","avatar":"https://s3.amazonaws.com/uifaces/faces/twitter/marcoramires/128.jpg"},{"id":5,"first_name":"Charles","last_name":"Morris","avatar":"https://s3.amazonaws.com/uifaces/faces/twitter/stephenmoon/128.jpg"},{"id":6,"first_name":"Tracey","last_name":"Ramos","avatar":"https://s3.amazonaws.com/uifaces/faces/twitter/bigmancho/128.jpg"}]}

这允许我们快速测试不同模块提供的新功能,而无需创建自己的模块。

更多关于新的 Java 平台模块系统(JPMS)的信息,请查看视频教程“ Getting Started with Clean Code Java SE 9 ”。

为什么脚本对 Java 来说很重要?

首先,让我们回顾一下脚本是什么,以便于理解为什么在 Java 编程语言中使用脚本如此重要。

我们可以给脚本作如下的定义:

脚本是为特定的运行时环境编写的程序,它可以自动执行任务或命令,这些任务或命令也可以由操作人员逐个执行。

在这个通用定义中,我们可以推导出脚本语言的一个简单定义;脚本语言是一种编程语言,它使用高级构造器每次解释并执行一个命令。

脚本语言是一种编程语言,它在文件中使用一系列命令。通常,脚本语言是解释语言(而不是编译语言),并且倾向于过程式编程风格(尽管一些脚本语言也有面向对象的特性)。

一般来说,脚本语言比更结构化的编译语言(如 Java、C 和 C++)更容易学习,也能更快地进行代码编写。服务端的脚本语言有 Perl、PHP 和 Python 等,客户端的脚本语言有 JavaScript。

长期以来,Java 被归类成一种结构良好的、强类型的编译语言,经 JVM 解释运行于任何计算机体系结构上。然而,对于 Java 的一个抱怨是,与普通脚本语言相比,它的学习及原型开发速度不够快。

然而,现在,Java 已经成为一门历经 24 年的语言,全世界大约有 940 万的开发人员在使用它。为了让年轻一代的程序员更容易地学习 Java,并在不需要编译和 IDE 的情况下尝试其特性和 API,Java 最近发布了一些特性。从 Java SE 9 开始,添加了一个支持交互式编程的JShell (REPL) 工具集,其目的就是使 Java 更易于编程和学习。

现在,使用 JDK 11,Java 逐步成为一种支持脚本的编程语言,因为我们可以简单地通过调用 Java 命令来运行代码了!

在 Java 11 中,有两种基本的脚本编写方法:

  1. 直接使用 java 命令工具。
  2. 使用 *nix 命令行脚本,它类似于 bash 脚本

我们已经探讨过了第一种方法了,所以现在是时候看一下第二种方法,这是一个可以打开许多可能性大门的特性。

Shebang 文件:以 shell 脚本的形式运行 Java

如前所述,Java SE 11 引入了对脚本的支持,包括支持传统的 *nix,即所谓的 Shebang 文件。无需修改JLSJava Language Specification,Java 语言规范)就可以支持该特性。

在一般的 Shebang 文件中,前两个字节必须是 0x230x21 ,这是 “#!” 两个字符的 ASCII 编码。然后,才能有效地使用默认平台字符编码读取文件所有后续字节。

因此,当希望使用操作系统的 Shebang 机制执行文件时,文件的第一行需要以#! 开始。这意味着,当显式使用 Java 启动程序运行源文件代码时,无需任何特殊的第一行,比如上面的 HelloUniverse.java 示例。

让我们在 macOS Mojave 10.14.5 的终端中运行下一个示例。但是首先,我们需要列出一些创建 Shebang 文件时,应该遵循的重要规则:

  • 不要混合使用 Java 代码与操作系统的 shell 脚本语言。
  • 如果需要包含VM(虚拟机)选项,则必须将 –source 指定为 Shebang 文件可执行的文件名后面的第一个选项。这些选项包括:–class-path、–module-path、–add-exports、–add-modules、–limit-modules、–patch-module、upgrade-module-path ,以及这些选项的任何变体形式。它还可以包括 JEP 12 引入的新的–enable-preview 选项。
  • 必须为文件中的源代码指定 Java 语言版本。
  • Shebang 字符(#!)必须在文件的第一行,它应该是这样的:
	
#!/path/to/java --source <version>
  • 不允许使用Shebang 机制来执行遵循标准命名约定(以 .java 结尾的文件)的 Java 源文件。
  • 最后,必须使用以下命令将文件标记为可执行文件:
	
chmod +x <Filename>.<Extension>.

在我们的示例中,我们创建一个 Shebang 文件(script utility program),它将列出作为参数传递的目录内容。如果没有传递任何参数,则默认列出当前目录。

	
#!/usr/bin/java --source 11
import java.nio.file.*;
import static java.lang.System.*;
 
public class DirectoryLister {
      public static void main(String[] args) throws Exception {
            vardirName = ".";
 
            if ( args == null || args.length< 1 ){
err.println("Will list the current directory");
            } else {
                  dirName = args[0];
            }
 
            Files
            .walk(Paths.get(dirName))
            .forEach(out::println);       
      }
}

将此代码保存在一个名为 dirlist 文件中,它不带任何扩展名,然后将其标记为可执行文件:

	
mohamed_taman:code$ chmod +x dirlist

按以下方式运行:

	
mohamed_taman:code$ ./dirlist
Will list the current directory
.
./PalindromeChecker.java
./greater
./UsersHttpClient.java
./HelloWorld.java
./Greater.java
./dirlist

通过传递父目录,按照如下命令再次运行程序 ,并检查它输出。

	
mohamed_taman:code$ ./dirlist ../

注意:在计算源代码时,解释器会忽略 Shebang 行(第一行)。因此,启动程序也可以显式地调用 Shebang 文件,可能需要使用如下附加选项:

	
$ java -Dtrace=true --source 11 dirlist

另外,值得注意的是,如果脚本文件在当前目录中,还可以按以下方式执行:

	
$ ./dirlist

或者,如果脚本在用户路径的目录中,也可以这样执行:

	
$ dirlist

最后,我们通过展示一些使用该特性时需要注意的用法和技巧来结束本文。

用法和技巧

  1. 可以传递给 javac 的一些选项可能不会被 Java 工具所传递 (或识别),比如, -processor 和 -Werror 选项。
  2. 如果类路径中同时存在.class 和.java 文件,启动程序将强制使用字节码类文件。
	
mohamed_taman:code$ javac HelloUniverse.java
mohamed_taman:code$ java HelloUniverse.java
error: class found on application class path: HelloUniverse

请记住类和包存在命名冲突的可能性。请看如下的目录结构:

	
mohamed_taman:code$ tree
.
├── Greater.java
├── HelloUniverse
│   ├── java.class
│   └── java.java
├── HelloUniverse.java
├── PalindromeChecker.java
├── UsersHttpClient.java
├── dirlist
└── greater

注意:HelloUniverse 包下的两个 java.java 文件和当前目录中的 HelloUniverse.java 文件。当我们试图运行如下命令时,会发生什么呢?

	
mohamed_taman:code$ java HelloUniverse.java

运行哪个文件,第一个还是第二个?Java 启动程序不再引用 HelloUniverse 包中的类文件。相反,它将通过源代码模式加载并运行 HelloUniverse.java 文件,以便运行当前目录中的文件。

我喜欢使用 Shebang 特性,因为它为利用 Java 语言的强大功能来创造脚本自动化完成大量工作提供了可能性。

总结

从 Java SE 11 开始,在这款编程语言的历史上,首次可以在无需编译的情况下,直接运行包含 Java 代码的脚本。Java 11 源文件执行特性使得使用 Java 编写脚本并直接使用 *inx 命令行执行脚本成为可能。

今天就开始尝试使用这个新特性吧,祝大家编程愉快。如果喜欢这篇文章,请将它分享给更多的极客。

参考资源

作者介绍

Mohamed Taman 是 @DevTech d.o.o 的高级企业架构师、Java 冠军、甲骨文开拓大使、Java SE.next() 和 JakartaEE.next() 的采纳者、JCP 成员。他曾是 JCP 执行委员会成员、JSR 354、363、373 专家组成员、EGJUG 领导者、甲骨文埃及架构师俱乐部董事会成员。他主讲 Java,热爱移动、大数据、云、区块链、DevOps。他是国际讲师,是“JavaFX essentials”、“Getting Started with Clean Code, Java SE 9”、“Hands-On Java 10 Programming with JShell” 等书和视频的作者。还出了一本新书“Secrets of a Java Champions”。他还赢得过 2014、2015 年杜克选择奖项和 JCP 杰出参与者 2013 年奖项。

本文文字及图片出自 InfoQ

余下全文(1/3)
分享这篇文章:

请关注我们:

发表回复

您的电子邮箱地址不会被公开。 必填项已用 * 标注