我们为什么需要这个特性

如果我们回想一下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)并想实践这种语言,应该怎么办呢?#21487;?#36848;过程中的两个步骤实践起来似乎还是有点难度。

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

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

更多关于 **Jshell 10+** 的新信息,请查看视?#21040;?#31243;“ Hands-on Java 10 Programming with JShell ”。

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

该 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!” 示例开始。

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

如果还没有准备好,请先创建本文顶部列出的 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. 运行源文件中声明的类

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

是否进入源文件模?#25509;?#21629;令行上的如下两项来决定:

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

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

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

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

当源文件是要执行的“脚本?#20445;?#25110;者源文件的名称不遵循 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 文件。敏锐的读者可能还注意到了,我们是如何在要执行的文件名之后将参数传递给代码的。这意味着在命令行上文件名之后出现的任何参数都会以这种?#20801;?#30340;方式传递给标准的 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 的?#38382;?#21551;动 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

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

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

它是否适用于多个类?

答案是肯定的。

让我们测试一?#20255;?#21547;两个类的示例代码,以演示该特性可以适用于多个类。该代码的功能是检验给定的字符串是否为回文。回文可以是一个单词、短语、数字或其他字符序列,但它们从两个方向读取时,都能得到相同的字符序列,例如“redivider?#34987;頡?strong>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?#20445;?/p>

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

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

允许使用模块吗?

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

这使得代码可以使用不同的模块,而无需使用 module-info.java ?#20801;?#22768;明依赖项。

让我们来看一些使用 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 ?#25945;?#27169;块系?#24120;?strong>JPMS)的信息,请查看视?#21040;?#31243;“ Getting Started with Clean Code Java SE 9 ”。

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

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

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

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

在这个通用定义中,我们可以?#39057;?#20986;脚本语言的一个简单定义;脚本语言是一种编程语言,它使用高级构造器?#30475;?#35299;释并执行一个命令。

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

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

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

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

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

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

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

我们已经?#25945;止?#20102;第一种方法了,所以现在是时候看一下第二种方法,这是一个可以打开许多可能性大门的特性。

Shebang 文件:以 shell 脚本的?#38382;?#36816;行 Java

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

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

因此,当希望使用操作系统的 Shebang 机制执行文件时,文件的第一行需要以#! 开始。这意味着,当?#20801;?#20351;用 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 ,以及这些选项的任何变体?#38382;健?#23427;还可以包括 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 行(第一行)。因此,启动程序也可以?#20801;?#22320;调用 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
.
?#25193;ぉ?Greater.java
?#25193;ぉ?HelloUniverse
│   ?#25193;ぉ?java.class
│   └── java.java
?#25193;ぉ?HelloUniverse.java
?#25193;ぉ?PalindromeChecker.java
?#25193;ぉ?UsersHttpClient.java
?#25193;ぉ?dirlist
└── greater

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

	
mohamed_taman:code$ java HelloUniverse.java

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

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

总结

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

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

参考资源

作者介绍

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

余下全文(1/3)

本文最初发表在www.infoq.cn,文章内容属作者个人观点,不代表本站立场。

分享这篇文章:

请关注我们:

发表评论

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