Java11-秘籍-全-

Java11 秘籍(全)

原文:zh.annas-archive.org/md5/2bf50d1e2a61626a8f3de4e5aae60b76

译者:飞龙

协议:CC BY-NC-SA 4.0

前言

本食谱书提供了一系列软件开发示例,这些示例通过简单直接的代码进行了说明,提供了逐步资源和节省时间的方法,帮助您高效解决数据问题。从安装 Java 开始,每个食谱都解决了一个特定的问题,并附有解决方案的讨论,以及解释其工作原理的见解。我们涵盖了关于核心编程语言的主要概念,以及构建各种软件所涉及的常见任务。您将按照食谱了解最新 Java 11 版本的新功能,使您的应用程序模块化、安全和快速。

本书的受众包括初学者、有中级经验的程序员,甚至专家;所有人都能够访问这些食谱,这些食谱演示了 Java 11 发布的最新功能。

预期读者包括初学者、有中级经验的程序员,甚至专家;所有人都能够访问这些食谱,这些食谱演示了 Java 11 发布的最新功能。

为了充分利用本书

为了充分利用本书,需要一些 Java 知识和运行 Java 程序的能力。此外,最好安装和配置了您喜爱的编辑器或 IDE 以供在食谱中使用。因为本书本质上是一本食谱集,每个食谱都是基于具体示例的,如果读者不执行提供的示例,将会失去本书的好处。读者如果在他们的 IDE 中复制每个提供的示例,执行它,并将其结果与书中显示的结果进行比较,将会从本书中获得更多。

下载示例代码文件

您可以从www.packt.com的帐户中下载本书的示例代码文件。如果您在其他地方购买了本书,您可以访问www.packt.com/support并注册,以便文件直接发送到您的邮箱。

您可以按照以下步骤下载代码文件:

  1. www.packt.com登录或注册。

  2. 选择“支持”选项卡。

  3. 单击“代码下载和勘误”。

  4. 在搜索框中输入书名,然后按照屏幕上的说明操作。

下载文件后,请确保使用以下最新版本解压或提取文件夹:

  • WinRAR/7-Zip for Windows

  • Zipeg/iZip/UnRarX for Mac

  • 7-Zip/PeaZip for Linux

本书的代码包也托管在 GitHub 上,网址为github.com/PacktPublishing/Java-11-Cookbook-Second-Edition。我们还有其他代码包,来自我们丰富的图书和视频目录,可在github.com/PacktPublishing/上获得。去看看吧!

使用的约定

本书中使用了许多文本约定。

CodeInText:表示文本中的代码词、数据库表名、文件夹名、文件名、文件扩展名、路径名、虚拟 URL、用户输入和 Twitter 句柄。这是一个例子:"使用ProcessHandle接口上的allProcesses()方法获取当前活动进程的流"

代码块设置如下:

public class Thing {
  private int someInt;
  public Thing(int i) { this.someInt = i; }
  public int getSomeInt() { return someInt; }
  public String getSomeStr() { 
    return Integer.toString(someInt); }
} 

当我们希望引起您对代码块的特定部分的注意时,相关行或项目以粗体设置:

Object[] os = Stream.of(1,2,3).toArray();
 Arrays.stream(os).forEach(System.out::print);
 System.out.println();
 String[] sts = Stream.of(1,2,3)
                      .map(i -> i.toString())
                      .toArray(String[]::new);
 Arrays.stream(sts).forEach(System.out::print);

任何命令行输入或输出都写成如下形式:

jshell> ZoneId.getAvailableZoneIds().stream().count()
$16 ==> 599

粗体:表示一个新术语、一个重要词或屏幕上看到的词。例如,菜单或对话框中的单词会在文本中以这种方式出现。这是一个例子:"右键单击“我的电脑”,然后单击“属性”。您会看到

您的系统信息。"

警告或重要说明看起来像这样。

提示和技巧看起来像这样。

部分

本书中,您会发现一些经常出现的标题(准备工作如何做它是如何工作的还有更多另请参阅)。为了清晰地说明如何完成一个食谱,使用以下部分:

准备工作

本节告诉您在食谱中可以期待什么,并描述了如何设置食谱所需的任何软件或初步设置。

如何做...

本节包含了遵循食谱所需的步骤。

工作原理...

本节通常包括对前一节发生的事情的详细解释。

还有更多...

本节包括有关食谱的其他信息,以使您对食谱更加了解。

另请参阅

本节提供了有关食谱的其他有用信息的链接。

联系我们

我们始终欢迎读者的反馈。

一般反馈: 如果您对本书的任何方面有疑问,请在邮件主题中提及书名,并发送电子邮件至customercare@packtpub.com

勘误: 尽管我们已经尽一切努力确保内容的准确性,但错误确实会发生。如果您在本书中发现错误,我们将不胜感激您向我们报告。请访问www.packt.com/submit-errata,选择您的书,点击勘误提交表格链接,并输入详细信息。

盗版: 如果您在互联网上发现我们作品的任何形式的非法副本,我们将不胜感激您向我们提供位置地址或网站名称。请通过copyright@packtpub.com与我们联系,并提供材料链接。

如果您有兴趣成为作者: 如果您在某个专题上有专业知识,并且有兴趣撰写或为一本书做出贡献,请访问authors.packtpub.com

评论

请留下评论。一旦您阅读并使用了本书,为什么不在购买书籍的网站上留下评论呢?潜在读者可以看到并使用您的客观意见来做出购买决定,我们在 Packt 可以了解您对我们产品的看法,我们的作者可以看到您对他们书籍的反馈。谢谢!

有关 Packt 的更多信息,请访问packt.com

第一章:安装和预览 Java 11

在本章中,我们将介绍以下内容:

  • 在 Windows 上安装 JDK 18.9 并设置 PATH 变量

  • 在 Linux(Ubuntu,x64)上安装 JDK 18.9 并配置 PATH 变量

  • 编译和运行 Java 应用程序

  • JDK 18.9 的新功能

  • 使用应用程序类数据共享

介绍

学习编程语言的每一个探索都始于设置环境以进行学习实验。为了与这一理念保持一致,在本章中,我们将向您展示如何设置开发环境,然后运行一个简单的模块化应用程序来测试我们的安装。之后,我们将向您介绍 JDK 18.9 中的新功能和工具。然后,我们将比较 JDK 9、18.3 和 18.9。我们将以 JDK 18.3 中引入的允许应用程序类数据共享的新功能结束本章。

在 Windows 上安装 JDK 18.9 并设置 PATH 变量

在本教程中,我们将介绍如何在 Windows 上安装 JDK 以及如何设置PATH变量,以便能够在命令行中的任何位置访问 Java 可执行文件(如javacjavajar)。

如何做...

  1. 访问jdk.java.net/11/并接受早期采用者许可协议,它看起来像这样:

  1. 接受许可协议后,您将获得一个基于操作系统和架构(32/64 位)的可用 JDK 捆绑包的网格。单击下载适用于您的 Windows 平台的相关 JDK 可执行文件(.exe)。

  2. 运行 JDK 可执行文件(.exe)并按照屏幕上的说明在系统上安装 JDK。

  3. 如果您在安装过程中选择了所有默认设置,您将在 64 位的C:/Program Files/Java和 32 位的C:/Program Files (x86)/Java找到安装的 JDK。

既然我们已经安装了 JDK,让我们看看如何设置PATH变量。

JDK 提供的工具,即javacjavajconsolejlink,都位于 JDK 安装的 bin 目录中。您可以通过两种方式从命令提示符中运行这些工具:

  1. 导航到安装工具的目录并运行它们,如下所示:
 cd "C:\Program Files\Java\jdk-11\bin"
      javac -version
  1. 导出路径到目录,以便在命令提示符中的任何目录中都可以使用工具。为了实现这一点,我们必须将 JDK 工具的路径添加到PATH环境变量中。命令提示符将在PATH环境变量中声明的所有位置中搜索相关工具。

让我们看看如何将 JDK 的 bin 目录添加到PATH变量中:

  1. 右键单击“我的电脑”,然后单击“属性”。您将看到系统信息。搜索“高级系统设置”,单击它以获得一个窗口,如下面的屏幕截图所示:

  1. 单击“环境变量”以查看系统中定义的变量。您会看到已经定义了相当多的环境变量,如下面的屏幕截图所示(变量将在不同系统之间有所不同;在下面的屏幕截图中,有一些预定义的变量和一些我添加的变量):

在“系统变量”下定义的变量可供系统的所有用户使用,而在“用户变量”下定义的变量仅供特定用户使用。

  1. 一个新变量,名为JAVA_HOME,其值为 JDK 9 安装的位置。例如,它将是C:\Program Files\Java\jdk-11(64 位)或C:\Program Files (x86)\Java\jdk-11(32 位):

  1. 使用 JDK 安装的 bin 目录的位置(在JAVA_HOME环境变量中定义)更新PATH环境变量。如果您已经在列表中看到了PATH变量定义,那么您需要选择该变量并点击编辑。如果没有看到PATH变量,则点击新建。

  2. 在上一步中的任何操作都会弹出一个窗口,如下面的屏幕截图所示(在 Windows 10 上):

下面的屏幕截图显示了其他 Windows 版本:

  1. 您可以在第一个屏幕截图中单击新建并插入%JAVA_HOME%\bin值,或者通过在变量值字段中添加; %JAVA_HOME%\bin来追加值。在 Windows 中,分号(;)用于分隔给定变量名的多个值。

  2. 设置完值后,打开命令提示符并运行javac -version。您应该能够看到javac 11-ea作为输出。如果您没有看到它,这意味着您的 JDK 安装的 bin 目录没有正确添加到PATH变量中。

在 Linux(Ubuntu,x64)上安装 JDK 18.9 并配置 PATH 变量

在这个示例中,我们将看看如何在 Linux(Ubuntu,x64)上安装 JDK,并如何配置PATH变量以使终端中的 JDK 工具(如javacjavajar)可以从任何位置使用。

如何做...

  1. 按照在 Windows 上安装 JDK 18.9 并设置 PATH 变量的步骤 1 和 2 来到达下载页面。

  2. 从下载页面上复制 Linux x64 平台的 JDK 的下载链接(tar.gz)。

  3. 使用$> wget <copied link>下载 JDK,例如,$> wget https://download.java.net/java/early_access/jdk11/26/BCL/jdk-11-ea+26_linux-x64_bin.tar.gz

  4. 下载完成后,您应该有相关的 JDK 可用,例如,jdk-11-ea+26_linux-x64_bin.tar.gz。您可以使用$> tar -tf jdk-11-ea+26_linux-x64_bin.tar.gz列出内容。您甚至可以使用$> tar -tf jdk-11-ea+26_linux-x64_bin.tar.gz | more将其传输到more来分页输出。

  5. 使用$> tar -xvzf jdk-11-ea+26_linux-x64_bin.tar.gz -C /usr/lib/usr/lib下提取tar.gz文件的内容。这将把内容提取到一个目录/usr/lib/jdk-11中。然后,您可以使用$> ls /usr/lib/jdk-11列出 JDK 11 的内容。

  6. 通过编辑 Linux 主目录中的.bash_aliases文件来更新JAVA_HOMEPATH变量:

 $> vim ~/.bash_aliases
      export JAVA_HOME=/usr/lib/jdk-11
      export PATH=$PATH:$JAVA_HOME/bin

.bashrc文件以应用新的别名:

 $> source ~/.bashrc
      $> echo $JAVA_HOME
      /usr/lib/jdk-11
      $>javac -version
      javac 11-ea
      $> java -version
      java version "11-ea" 2018-09-25
 Java(TM) SE Runtime Environment 18.9 (build 11-ea+22)
 Java HotSpot(TM) 64-Bit Server VM 18.9 (build 11-ea+22, mixed 
      mode)

本书中的所有示例都是在 Linux(Ubuntu,x64)上安装的 JDK 上运行的,除非我们特别提到这些是在 Windows 上运行的地方。我们已经尝试为两个平台提供运行脚本。

编译和运行 Java 应用程序

在这个示例中,我们将编写一个非常简单的模块化Hello world程序来测试我们的 JDK 安装。这个简单的示例在 XML 中打印Hello world;毕竟,这是 Web 服务的世界。

准备工作

您应该已经安装了 JDK 并更新了PATH变量以指向 JDK 安装位置。

如何做...

  1. 让我们使用相关属性和注释定义模型对象,这些属性和注释将被序列化为 XML:
        @XmlRootElement
        @XmlAccessorType(XmlAccessType.FIELD) 
        class Messages{     
          @XmlElement 
          public final String message = "Hello World in XML"; 
        }

在上面的代码中,@XmlRootElement用于定义根标签,@XmlAccessorType用于定义标签名称和标签值的来源类型,@XmlElement用于标识成为 XML 中标签名称和标签值的来源。

  1. 让我们使用 JAXB 将Message类的一个实例序列化为 XML:
public class HelloWorldXml{
  public static void main(String[] args) throws JAXBException{
    JAXBContext jaxb = JAXBContext.newInstance(Messages.class);
    Marshaller marshaller = jaxb.createMarshaller();
    marshaller.setProperty(Marshaller.JAXB_FRAGMENT,Boolean.TRUE);
    StringWriter writer = new StringWriter();
    marshaller.marshal(new Messages(), writer);
    System.out.println(writer.toString());
  } 
}
  1. 我们现在将创建一个名为com.packt的模块。要创建一个模块,我们需要创建一个名为module-info.java的文件,其中包含模块定义。模块定义包含模块的依赖关系和模块向其他模块导出的包:
    module com.packt{
      //depends on the java.xml.bind module
      requires java.xml.bind;
      //need this for Messages class to be available to java.xml.bind
      exports  com.packt to java.xml.bind;
    }

我们将在第三章中详细解释模块化。但这个例子只是为了让您体验模块化编程并测试您的 JDK 安装。

具有上述文件的目录结构如下:

  1. 让我们编译并运行代码。从hellowordxml目录中,创建一个新目录,用于放置编译后的类文件:
      mkdir -p mods/com.packt

将源代码HelloWorldXml.javamodule-info.java编译成mods/com.packt目录:

 javac -d mods/com.packt/ src/com.packt/module-info.java
      src/com.packt/com/packt/HelloWorldXml.java
  1. 使用java --module-path mods -m com.packt/com.packt.HelloWorldXml运行编译后的代码。您将看到以下输出:
<messages><message>Hello World in XML</message></messages>

如果您无法理解javajavac命令中传递的选项,请不要担心。您将在第三章中了解它们,模块化编程

Java 11 中有什么新功能?

Java 9 的发布是 Java 生态系统的一个里程碑。在项目 Jigsaw 下开发的模块化框架成为了 Java SE 发布的一部分。另一个主要功能是 JShell 工具,这是一个用于 Java 的 REPL 工具。Java 9 引入的许多其他新功能在发布说明中列出:www.oracle.com/technetwork/java/javase/9all-relnotes-3704433.html

在本教程中,我们将列举并讨论 JDK 18.3 和 18.9(Java 10 和 11)引入的一些新功能。

准备就绪

Java 10 发布(JDK 18.3)开始了一个为期六个月的发布周期——每年三月和九月——以及一个新的发布编号系统。它还引入了许多新功能,其中最重要的(对于应用程序开发人员)是以下内容:

  • 允许使用保留的var类型声明变量的本地变量类型推断(见第十五章,使用 Java 10 和 Java 11 进行编码的新方法

  • G1 垃圾收集器的并行完整垃圾收集,改善了最坏情况下的延迟

  • 一个新的方法Optional.orElseThrow(),现在是现有get()方法的首选替代方法

  • 用于创建不可修改集合的新 API:java.util包的List.copyOf()Set.copyOf()Map.copyOf()方法,以及java.util.stream.Collectors类的新方法:toUnmodifiableList()toUnmodifiableSet()toUnmodifiableMap()(参见第五章,流和管道

  • 一组默认的根证书颁发机构,使 OpenJDK 构建更受开发人员欢迎

  • 新的 Javadoc 命令行选项--add-stylesheet支持在生成的文档中使用多个样式表

  • 扩展现有的类数据共享功能,允许将应用程序类放置在共享存档中,从而提高启动时间并减少占用空间(参见使用应用程序类数据共享教程)

  • 一种实验性的即时编译器 Graal,可以在 Linux/x64 平台上使用

  • 一个干净的垃圾收集器(GC)接口,使得可以更简单地向 HotSpot 添加新的 GC,而不会干扰当前的代码库,并且更容易地从 JDK 构建中排除 GC

  • 使 HotSpot 能够在用户指定的替代内存设备上分配对象堆,例如 NVDIMM 内存模块

  • 线程本地握手,用于在执行全局 VM 安全点的情况下在线程上执行回调

  • Docker 意识:JVM 将知道它是否在 Linux 系统上的 Docker 容器中运行,并且可以提取容器特定的配置信息,而不是查询操作系统

  • 三个新的 JVM 选项,为 Docker 容器用户提供对系统内存的更大控制

在发布说明中查看 Java 10 的新功能的完整列表:www.oracle.com/technetwork/java/javase/10-relnote-issues-4108729.html

我们将在下一节更详细地讨论 JDK 18.9 的新功能。

如何做到...

我们挑选了一些我们认为对应用程序开发人员最重要和有用的功能。

JEP 318 – Epsilon

Epsilon 是一种所谓的无操作垃圾收集器,基本上什么都不做。它的用例包括性能测试、内存压力和虚拟机接口。它还可以用于短暂的作业或不消耗太多内存且不需要垃圾收集的作业。

我们在第十一章的内存管理和调试中的食谱理解 Epsilon,一种低开销的垃圾收集器中更详细地讨论了此功能。

JEP 321 – HTTP 客户端(标准)

JDK 18.9 标准化了 JDK 9 中引入并在 JDK 10 中更新的孵化 HTTP API 客户端。基于CompleteableFuture,它支持非阻塞请求和响应。新的实现是异步的,并提供了更好的可追踪的数据流。

第十章的网络中,通过几个食谱更详细地解释了此功能。

JEP 323 – 用于 Lambda 参数的本地变量语法

lambda 参数的本地变量语法与 Java 11 中引入的保留var类型的本地变量声明具有相同的语法。有关更多详细信息,请参阅第十五章的使用 lambda 参数的本地变量语法食谱。

JEP 333 – ZGC

Z 垃圾收集器ZGC)是一种实验性的低延迟垃圾收集器。其暂停时间不应超过 10 毫秒,与使用 G1 收集器相比,应用吞吐量不应降低超过 15%。ZGC 还为未来的功能和优化奠定了基础。Linux/x64 将是第一个获得 ZGC 支持的平台。

新 API

标准 Java API 有几个新增内容:

  • Character.toString(int codePoint): 返回表示由提供的 Unicode 代码点指定的字符的String对象:
var s = Character.toString(50);
System.out.println(s);  //prints: 2

  • CharSequence.compare(CharSequence s1, CharSequence s2): 按字典顺序比较两个CharSequence实例。返回有序列表中第二个参数的位置与第一个参数位置的差异:
var i = CharSequence.compare("a", "b");
System.out.println(i);   //prints: -1

i = CharSequence.compare("b", "a");
System.out.println(i);   //prints: 1

i = CharSequence.compare("this", "that");
System.out.println(i);   //prints: 8

i = CharSequence.compare("that", "this");
System.out.println(i);   //prints: -8

  • String类的repeat(int count)方法:返回由count次重复组成的String值:
String s1 = "a";
String s2 = s1.repeat(3); //prints: aaa
System.out.println(s2);

String s3 = "bar".repeat(3);
System.out.println(s3); //prints: barbarbar

  • String类的isBlank()方法:如果String值为空或仅包含空格,则返回true,否则返回false。在我们的示例中,我们将其与isEmpty()方法进行了对比,后者仅在length()为零时返回true
String s1 = "a";
System.out.println(s1.isBlank());  //false
System.out.println(s1.isEmpty());  //false

String s2 = "";
System.out.println(s2.isBlank());  //true
System.out.println(s2.isEmpty());  //true

String s3 = "  ";
System.out.println(s3.isBlank());  //true
System.out.println(s3.isEmpty());  //false
  • String类的lines()方法:返回一个Stream对象,该对象从源String值中提取行,行之间由行终止符\n\r\r\n分隔:
String s = "l1 \nl2 \rl3 \r\nl4 ";
s.lines().forEach(System.out::print); //prints: l1 l2 l3 l4 

  • String类的三个方法,用于从源String值中移除前导空格、尾随空格或两者:
String s = " a b ";
System.out.println("'" + s.strip() + "'");        // 'a b'
System.out.println("'" + s.stripLeading() + "'"); // 'a b '
System.out.println("'" + s.stripTrailing() + "'");// ' a b'

  • 两个构造java.nio.file.Path对象的Path.of()方法:
Path filePath = Path.of("a", "b", "c.txt");
System.out.println(filePath);     //prints: a/b/c.txt

try {
    filePath = Path.of(new URI("file:/a/b/c.txt"));
    System.out.println(filePath);  //prints: /a/b/c.txt
} catch (URISyntaxException e) {
    e.printStackTrace();
}
  • java.util.regex.Pattern类的asMatchPredicate()方法,它创建了一个java.util.function.Predicate函数接口的对象,然后允许我们测试String值是否与编译后的模式匹配。在下面的示例中,我们测试String值是否以a字符开头并以b字符结尾:
Pattern pattern = Pattern.compile("^a.*z$");
Predicate<String> predicate = pattern.asMatchPredicate();
System.out.println(predicate.test("abbbbz")); // true
System.out.println(predicate.test("babbbz")); // false
System.out.println(predicate.test("abbbbx")); // false

还有更多...

JDK 18.9 中引入了相当多的其他更改:

  • 移除了 Java EE 和 CORBA 模块

  • JavaFX 已从 Java 标准库中分离并移除

  • util.jar中的 Pack200 和 Unpack200 工具以及 Pack200 API 已被弃用

  • Nashorn JavaScript 引擎以及 JJS 工具已被弃用,并打算在将来删除它们

  • Java 类文件格式被扩展以支持新的常量池形式CONSTANT_Dynamic

  • Aarch64 内在函数得到改进,为 Aarch64 处理器实现了java.lang.Math sin、cos 和 log 函数的新内在函数 JEP 309—动态类文件常量

  • Flight Recorder 为故障排除 Java 应用程序和 HotSpot JVM 提供了低开销的数据收集框架

  • Java 启动器现在可以运行作为 Java 源代码单个文件提供的程序,因此这些程序可以直接从源代码运行

  • 低开销的堆分析,提供了一种对 Java 堆分配进行采样的方式,可以通过 JVM 工具接口访问

  • 传输层安全性TLS)1.3 增加了安全性并提高了性能

  • java.lang.Characterjava.lang.Stringjava.awt.font.NumericShaperjava.text.Bidi,java.text.BreakIteratorjava.text.Normalizer类中支持 Unicode 版本 10.0

阅读 Java 11(JDK 18.9)发行说明以获取更多详细信息和其他更改。

使用应用程序类数据共享

这个功能自 Java 5 以来就存在。它在 Java 9 中作为商业功能得到了扩展,不仅允许引导类,还允许将应用程序类放置在 JVM 共享的存档中。在 Java 10 中,这个功能成为了 open JDK 的一部分。它减少了启动时间,并且当同一台机器上运行多个 JVM 并部署相同的应用程序时,减少了内存消耗。

做好准备

从共享存档加载类的优势有两个原因:

  • 存档中存储的类是经过预处理的,这意味着 JVM 内存映射也存储在存档中。这减少了 JVM 实例启动时类加载的开销。

  • 内存区域甚至可以在同一台计算机上运行的 JVM 实例之间共享,这通过消除每个实例中复制相同信息的需要来减少总体内存消耗。

新的 JVM 功能允许我们创建一个要共享的类列表,然后使用这个列表创建一个共享存档,并使用共享存档快速加载存档类到内存中。

如何做…

  1. 默认情况下,JVM 可以使用随 JDK 提供的类列表创建一个存档。例如,运行以下命令:
java -Xshare:dump

它将创建一个名为classes.jsa的共享存档文件。在 Linux 系统上,该文件放置在以下文件夹中:

/Library/Java/JavaVirtualMachines/jdk-11.jdk/Contents/Home/lib/server

在 Windows 系统上,它放置在以下文件夹中:

C:\Program Files\Java\jdk-11\bin\server

如果这个文件夹只能被系统管理员访问,以管理员身份运行命令。

请注意,并非所有类都可以共享。例如,位于类路径上的目录中的.class文件和由自定义类加载器加载的类不能添加到共享存档中。

  1. 告诉 JVM 使用默认的共享存档,使用以下命令:
java -Xshare:on -jar app.jar

上述命令将存档的内容映射到固定地址。当所需的地址空间不可用时,这种内存映射操作有时会失败。如果在使用-Xshare:on选项时发生这种情况,JVM 将以错误退出。或者,可以使用-Xshare:auto选项,它只是禁用该功能,并且如果由于任何原因无法使用共享存档,则从类路径加载类。

  1. 创建加载的应用程序类列表的最简单方法是使用以下命令:
java -XX:+UseAppCDS -XX:DumpLoadedClassList=classes.txt -jar app.jar

上述命令记录了classes.txt文件中加载的所有类。如果要使应用程序加载更快,请在应用程序启动后立即停止 JVM。如果需要更快地加载某些类,但这些类不会在应用程序启动时自动加载,请确保执行需要这些类的用例。

  1. 或者,您可以手动编辑classes.txt文件,并添加/删除任何需要放入共享存档的类。首次自动创建此文件并查看格式。这是一个简单的文本文件,每行列出一个类。

  2. 创建列表后,使用以下命令生成共享存档:

java -XX:+UseAppCDS -Xshare:dump -XX:SharedClassListFile=classes.txt -XX:SharedArchiveFile=app-shared.jsa --class-path app.jar

请注意,共享存档文件的名称不是classes.jsa,因此不会覆盖默认共享存档。

  1. 通过执行以下命令使用创建的存档:
java -XX:+UseAppCDS -Xshare:on -XX:SharedArchiveFile=app-shared.jsa -jar app.jar

同样,您可以使用-Xshare:auto选项,以避免 JVM 意外退出。

共享存档的使用效果取决于其中的类数量和应用程序的其他细节。因此,我们建议您在承诺使用特定类列表之前进行实验和测试各种配置。

第二章:OOP 的快速通道-类和接口

在本章中,我们将涵盖以下示例:

  • 实现面向对象设计OOD

  • 使用内部类

  • 使用继承和聚合

  • 编码到一个接口

  • 创建具有默认和静态方法的接口

  • 创建具有私有方法的接口

  • 使用Optional更好地处理空值

  • 使用实用类Objects

本章的示例不需要对 OOD 有任何先前的了解。但是,在 Java 中编写代码的一些经验将是有益的。本章中的代码示例完全可用,并与 Java 11 兼容。为了更好地理解,我们建议您尝试运行所呈现的示例。

我们还鼓励您根据您团队的经验,将本章中的提示和建议调整到您的需求中。考虑与同事分享您新获得的知识,并讨论所描述的原则如何应用到您的领域和当前项目中。

介绍

本章为您快速介绍了面向对象编程OOP)的概念,并涵盖了自 Java 8 以来引入的一些增强功能。我们还将尝试在适用的地方涵盖一些良好的 OOD 实践,并使用具体的代码示例加以演示。

一个人可以花费很多时间阅读关于 OOD 的文章和实用建议,无论是在书籍上还是在互联网上。对一些人来说,这样做可能是有益的。但是根据我们的经验,掌握 OOD 的最快方法是在自己的代码中尝试其原则。这正是本章的目标——让您有机会看到和使用 OOD 原则,以便立即理解其正式定义。

良好编写代码的主要标准之一是意图的清晰。良好的动机和清晰的设计有助于实现这一点。代码由计算机运行,但由人类维护——阅读和修改。牢记这一点将确保您的代码的长期性,甚至可能会得到一些后来处理它的人的感激和赞赏。

在本章中,您将学习如何使用五个基本的 OOP 概念:

  • 对象/类:将数据和方法放在一起

  • 封装:隐藏数据和/或方法

  • 继承:扩展另一个类的数据和/或方法

  • 接口:隐藏实现和为一种类型编码

  • 多态:使用指向子类对象的基类类型引用

如果您在互联网上搜索,您可能会注意到许多其他概念和对它们的补充,以及所有 OOD 原则,都可以从前面列出的五个概念中推导出来。这意味着对它们的扎实理解是设计面向对象系统的先决条件。

实现面向对象设计(OOD)

在这个示例中,您将学习前两个 OOP 概念——对象/类和封装。这些概念是 OOD 的基础。

准备就绪

术语对象通常指的是将数据和可以应用于这些数据的过程耦合在一起的实体。数据和过程都不是必需的,但其中一个是——通常情况下,两者都是——总是存在的。数据称为对象字段(或属性),而过程称为方法。字段值描述了对象的状态。方法描述了对象的行为。每个对象都有一个类型,由其类——用于对象创建的模板——定义。对象也被称为类的实例。

是字段和方法的定义集合,这些字段和方法将存在于基于该类创建的每个实例中。

封装是隐藏那些不应该被其他对象访问的字段和方法。

封装是通过在字段和方法的声明中使用publicprotectedprivate Java 关键字,称为访问修饰符来实现的。当未指定访问修饰符时,还有一种默认级别的封装。

如何做...

  1. 创建一个带有horsePower字段的Engine类。添加setHorsePower(int horsePower)方法,用于设置该字段的值,以及getSpeedMph(double timeSec, int weightPounds)方法,用于根据车辆开始移动以来经过的时间、车辆重量和发动机功率计算车辆的速度:
public class Engine { 
  private int horsePower; 
  public void setHorsePower(int horsePower) { 
     this.horsePower = horsePower; 
  } 
  public double getSpeedMph(double timeSec, int weightPounds){ 
    double v = 2.0 * this.horsePower * 746 * timeSec * 
                                       32.17 / weightPounds; 
    return Math.round(Math.sqrt(v) * 0.68); 
 } 
}
  1. 创建Vehicle类:
      public class Vehicle { 
          private int weightPounds; 
          private Engine engine; 
          public Vehicle(int weightPounds, Engine engine) { 
            this.weightPounds = weightPounds; 
            this.engine = engine; 
          } 
          public double getSpeedMph(double timeSec){ 
            return this.engine.getSpeedMph(timeSec, weightPounds); 
         } 
     } 
  1. 创建将使用前述类的应用程序:
public static void main(String... arg) { 
   double timeSec = 10.0; 
   int horsePower = 246; 
   int vehicleWeight = 4000;  
   Engine engine = new Engine(); 
   engine.setHorsePower(horsePower); 
   Vehicle vehicle = new Vehicle(vehicleWeight, engine); 
   System.out.println("Vehicle speed (" + timeSec + " sec)=" 
                   + vehicle.getSpeedMph(timeSec) + " mph"); 
 } 

正如你所看到的,engine对象是通过调用Engine类的默认构造函数而创建的,该构造函数没有参数,并且使用new Java 关键字在堆上为新创建的对象分配内存。

第二个对象vehicle是使用Vehicle类的显式定义的带有两个参数的构造函数创建的。构造函数的第二个参数是engine对象,它携带了horsePower值,使用setHorsePower(int horsePower)方法设置为246

engine对象包含getSpeedMph(double timeSec, int weightPounds)方法,可以被任何对象调用(因为它是public),就像在Vehicle类的getSpeedMph(double timeSec)方法中所做的那样。

它是如何工作的...

前述应用程序产生以下输出:

值得注意的是,Vehicle类的getSpeedMph(double timeSec)方法依赖于为engine字段分配的值的存在。这样,Vehicle类的对象委托速度计算给Engine类的对象。如果后者未设置(例如在Vehicle()构造函数中传递了null),将在运行时抛出NullPointerException,如果应用程序未处理,将被 JVM 捕获并强制其退出。为了避免这种情况,我们可以在Vehicle()构造函数中放置一个检查,检查engine字段值的存在:

if(engine == null){ 
   throw new RuntimeException("Engine" + " is required parameter."); 
}   

或者,我们可以在Vehicle类的getSpeedMph(double timeSec)方法中放置一个检查:

if(getEngine() == null){ 
  throw new RuntimeException("Engine value is required."); 
} 

这样,我们避免了NullPointerException的歧义,并告诉用户问题的确切来源。

正如你可能已经注意到的,getSpeedMph(double timeSec, int weightPounds)方法可以从Engine类中移除,并且可以完全在Vehicle类中实现:

public double getSpeedMph(double timeSec){
  double v =  2.0 * this.engine.getHorsePower() * 746 * 
                                timeSec * 32.17 / this.weightPounds;
  return Math.round(Math.sqrt(v) * 0.68);
}

为此,我们需要在Engine类中添加getHorsePower()公共方法,以便使其可以被Vehicle类的getSpeedMph(double timeSec)方法使用。目前,我们将getSpeedMph(double timeSec, int weightPounds)方法留在Engine类中。

这是你需要做出的设计决策之一。如果你认为Engine类的对象将被传递并被不同类的对象使用(不仅仅是Vehicle),那么你需要在Engine类中保留getSpeedMph(double timeSec, int weightPounds)方法。否则,如果你认为只有Vehicle类将负责速度计算(这是有道理的,因为这是车辆的速度,而不是发动机的速度),你应该在Vehicle类中实现这个方法。

还有更多...

Java 提供了扩展类的能力,并允许子类访问基类的所有非私有字段和方法。例如,你可以决定每个可以被询问其速度的对象都属于从Vehicle类派生的子类。在这种情况下,Car类可能如下所示:

public class Car extends Vehicle {
  private int passengersCount;
  public Car(int passengersCount, int weightPounds, Engine engine){
    super(weightPounds, engine);
    this.passengersCount = passengersCount;
  }
  public int getPassengersCount() {
    return this.passengersCount;
  }
}

现在,我们可以通过用Car类的对象替换Vehicle类对象来更改我们的测试代码:

public static void main(String... arg) { 
  double timeSec = 10.0; 
  int horsePower = 246; 
  int vehicleWeight = 4000; 
  Engine engine = new Engine(); 
  engine.setHorsePower(horsePower); 
  Vehicle vehicle = new Car(4, vehicleWeight, engine); 
  System.out.println("Car speed (" + timeSec + " sec) = " + 
                             vehicle.getSpeedMph(timeSec) + " mph"); 
} 

当执行前面的代码时,它产生与Vehicle类对象相同的值:

由于多态性,对Car类对象的引用可以赋给其基类Vehicle的引用。Car类对象有两种类型——它自己的类型Car和基类Vehicle的类型。

在 Java 中,一个类也可以实现多个接口,这样类的对象也会有每个实现接口的类型。我们将在随后的配方中讨论这一点。

使用内部类

在这个配方中,您将了解三种内部类的类型:

  • 内部类:这是一个在另一个(封闭)类内部定义的类。它的可访问性由publicprotectedprivate访问修饰符调节。内部类可以访问封闭类的私有成员,封闭类也可以访问其内部类的私有成员,但是无法从封闭类外部访问私有内部类或非私有内部类的私有成员。

  • 方法局部内部类:这是一个在方法内部定义的类。它的可访问性受限于方法内部。

  • 匿名内部类:这是一个没有声明名称的类,在对象实例化时基于接口或扩展类定义。

准备就绪

当一个类只被一个其他类使用时,设计者可能会决定不需要将这样的类设为公共类。例如,假设Engine类只被Vehicle类使用。

如何做...

  1. Engine类创建为Vehicle类的内部类:
        public class Vehicle {
          private int weightPounds;
          private Engine engine;
          public Vehicle(int weightPounds, int horsePower) {
            this.weightPounds = weightPounds;
            this.engine = new Engine(horsePower);
          }
          public double getSpeedMph(double timeSec){
            return this.engine.getSpeedMph(timeSec);
          }
          private int getWeightPounds(){ return weightPounds; }
          private class Engine {
            private int horsePower;
            private Engine(int horsePower) {
              this.horsePower = horsePower;
            }
            private double getSpeedMph(double timeSec){
              double v = 2.0 * this.horsePower * 746 * 
                         timeSec * 32.17 / getWeightPounds();
              return Math.round(Math.sqrt(v) * 0.68);
            }
          }
        }
  1. 请注意,Vehicle类的getSpeedMph(double timeSec)方法可以访问Engine类,即使它被声明为private。它甚至可以访问Engine类的getSpeedMph(double timeSec)私有方法。内部类也可以访问封闭类的所有私有元素。这就是为什么Engine类的getSpeedMph(double timeSec)方法可以访问封闭Vehicle类的私有getWeightPounds()方法。

  2. 更仔细地看一下内部Engine类的用法。只使用了Engine类的getSpeedMph(double timeSec)方法。如果设计者认为将来也会是这种情况,他们可能会合理地决定将Engine类设为方法局部内部类,这是内部类的第二种类型:

        public class Vehicle {
          private int weightPounds;
          private int horsePower;
          public Vehicle(int weightPounds, int horsePower) {
            this.weightPounds = weightPounds;
            this.horsePower = horsePower;
          }
          private int getWeightPounds() { return weightPounds; }
          public double getSpeedMph(double timeSec){
            class Engine {
              private int horsePower;
              private Engine(int horsePower) {
                this.horsePower = horsePower;
              }
              private double getSpeedMph(double timeSec){
                double v = 2.0 * this.horsePower * 746 * 
                          timeSec * 32.17 / getWeightPounds();
                return Math.round(Math.sqrt(v) * 0.68);
              }
            }
            Engine engine = new Engine(this.horsePower);
            return engine.getSpeedMph(timeSec);
          }
        }

在前面的代码示例中,根本没有必要有一个Engine类。速度计算公式可以直接使用,而不需要Engine类的介入。但也有一些情况下可能不那么容易做到。例如,方法局部内部类可能需要扩展其他类以继承其功能,或者创建的Engine对象可能需要经过一些转换,因此需要创建。其他考虑可能需要方法局部内部类。

无论如何,将不需要从封闭类外部访问的所有功能设为不可访问是一个好的做法。封装——隐藏对象的状态和行为——有助于避免意外更改或覆盖对象行为导致的意外副作用。这使得结果更加可预测。这就是为什么一个好的设计只暴露必须从外部访问的功能。通常是封闭类的功能首先促使类的创建,而不是内部类或其他实现细节。

它是如何工作的...

无论Engine类是作为内部类还是方法局部内部类实现的,测试代码看起来都是一样的:

public static void main(String arg[]) {
  double timeSec = 10.0;
  int engineHorsePower = 246;
  int vehicleWeightPounds = 4000;
  Vehicle vehicle = 
          new Vehicle(vehicleWeightPounds, engineHorsePower);
  System.out.println("Vehicle speed (" + timeSec + " sec) = " 
                    + vehicle.getSpeedMph(timeSec) + " mph");
}

如果我们运行前面的程序,我们会得到相同的输出:

现在,假设我们需要测试getSpeedMph()方法的不同实现:

public double getSpeedMph(double timeSec){ return -1.0d; }

如果这个速度计算公式对你来说没有意义,那么你是正确的,它确实没有意义。我们这样做是为了使结果可预测,并且与先前实现的结果不同。

有许多方法可以引入这个新的实现。例如,我们可以改变Engine类中getSpeedMph(double timeSec)方法的代码。或者,我们可以改变Vehicle类中相同方法的实现。

在这个示例中,我们将使用第三种内部类,称为匿名内部类。当你想尽可能少地编写新代码,或者你想通过临时覆盖旧代码来快速测试新行为时,这种方法特别方便。匿名类的使用将如下所示:

public static void main(String... arg) {
  double timeSec = 10.0;
  int engineHorsePower = 246;
  int vehicleWeightPounds = 4000;
  Vehicle vehicle = 
    new Vehicle(vehicleWeightPounds, engineHorsePower) {
        public double getSpeedMph(double timeSec){ 
           return -1.0d;
        }
    };
  System.out.println("Vehicle speed (" + timeSec + " sec) = "
                    + vehicle.getSpeedMph(timeSec) + " mph");
}

如果我们运行这个程序,结果将是这样的:

正如你所看到的,匿名类实现已经覆盖了Vehicle类的实现。新的匿名类中只有一个方法——getSpeedMph()方法,它返回了硬编码的值。但我们也可以覆盖Vehicle类的其他方法或者添加新的方法。我们只是想为了演示目的保持示例简单。

根据定义,匿名内部类必须是语句的一部分,该语句以分号结束(与任何语句一样)。这样的表达式由以下部分组成:

  • new操作符

  • 实现的接口或扩展类的名称后跟括号(),表示默认构造函数或扩展类的构造函数(后者是我们的情况,扩展类是Vehicle

  • 类体与方法

像任何内部类一样,匿名内部类可以访问外部类的任何成员,但有一个注意事项——要被内部匿名类使用,外部类的字段必须要么声明为final,要么隐式地变为final,这意味着它们的值不能被改变。一个好的现代 IDE 会在你试图改变这样的值时警告你违反了这个约束。

使用这些特性,我们可以修改我们的示例代码,并为新实现的getSpeedMph(double timeSec)方法提供更多的输入数据,而无需将它们作为方法参数传递:

public static void main(String... arg) {
  double timeSec = 10.0;
  int engineHorsePower = 246;
  int vehicleWeightPounds = 4000;
  Vehicle vehicle = 
    new Vehicle(vehicleWeightPounds, engineHorsePower){
      public double getSpeedMph(double timeSec){
        double v = 2.0 * engineHorsePower * 746 * 
             timeSec * 32.17 / vehicleWeightPounds;
        return Math.round(Math.sqrt(v) * 0.68);
      }
    };
  System.out.println("Vehicle speed (" + timeSec + " sec) = " 
                    + vehicle.getSpeedMph(timeSec) + " mph");
}

请注意,timeSecengineHorsePowervehicleWeightPounds变量可以被内部类的getSpeedMph(double timeSec)方法访问,但不能被修改。如果我们运行上述代码,结果将与之前一样:

在只有一个抽象方法的接口(称为函数式接口)的情况下,可以使用另一种构造,称为lambda 表达式,而不是匿名内部类。它提供了更简洁的表示。我们将在第四章 进入函数式中讨论函数式接口和 lambda 表达式。

还有更多...

内部类是一个非静态嵌套类。Java 还允许我们创建一个静态嵌套类,当内部类不需要访问外部类的非静态字段和方法时可以使用。下面是一个示例(Engine类中添加了static关键字):

public class Vehicle {
  private Engine engine;
  public Vehicle(int weightPounds, int horsePower) {
    this.engine = new Engine(horsePower, weightPounds)
  }
  public double getSpeedMph(double timeSec){
    return this.engine.getSpeedMph(timeSec);
  }
  private static class Engine {
    private int horsePower;
    private int weightPounds;
    private Engine(int horsePower, int weightPounds) {
      this.horsePower = horsePower;
    }
    private double getSpeedMph(double timeSec){
      double v = 2.0 * this.horsePower * 746 * 
                       timeSec * 32.17 / this.weightPounds;
      return Math.round(Math.sqrt(v) * 0.68);
    }
  }
}

由于静态类无法访问非静态成员,我们被迫在构造Engine类时传递重量值,并且我们移除了getWeightPounds()方法,因为它不再需要了。

使用继承和聚合

在这个示例中,你将学习更多关于两个重要的面向对象编程概念,继承和多态,这些概念已经在前面的示例中提到并被使用。结合聚合,这些概念使设计更具可扩展性。

准备就绪

继承是一个类获取另一个类的非私有字段和方法的能力。

扩展的类称为基类、超类或父类。类的新扩展称为子类或子类。

多态性是使用基类类型引用其子类对象的能力。

为了演示继承和多态的威力,让我们创建代表汽车和卡车的类,每个类都有它可以达到的重量、发动机功率和速度(作为时间函数)的最大载荷。此外,这种情况下的汽车将以乘客数量为特征,而卡车的重要特征将是其有效载荷。

如何做...

  1. 看看Vehicle类:
        public class Vehicle {
          private int weightPounds, horsePower;
          public Vehicle(int weightPounds, int horsePower) {
            this.weightPounds = weightPounds;
            this.horsePower = horsePower;
          }
          public double getSpeedMph(double timeSec){
            double v = 2.0 * this.horsePower * 746 * 
                     timeSec * 32.17 / this.weightPounds;
            return Math.round(Math.sqrt(v) * 0.68);
          }
        }

Vehicle类中实现的功能不特定于汽车或卡车,因此将这个类用作CarTruck类的基类是有意义的,这样每个类都可以将这个功能作为自己的功能。

  1. 创建Car类:
        public class Car extends Vehicle {
          private int passengersCount;
          public Car(int passengersCount, int weightPounds, 
                                             int horsepower){
            super(weightPounds, horsePower);
            this.passengersCount = passengersCount;
          }
          public int getPassengersCount() { 
            return this.passengersCount; 
          }
        }
  1. 创建Truck类:
         public class Truck extends Vehicle {
           private int payload;
           public Truck(int payloadPounds, int weightPounds, 
                                              int horsePower){
             super(weightPounds, horsePower);
             this.payload = payloadPounds;
           }
           public int getPayload() { 
             return this.payload; 
           }
         }

由于Vehicle基类既没有隐式构造函数也没有没有参数的显式构造函数(因为我们选择只使用带参数的显式构造函数),所以我们必须在Vehicle类的每个子类的构造函数的第一行调用基类构造函数super()

它是如何工作的...

让我们编写一个测试程序:

public static void main(String... arg) {
  double timeSec = 10.0;
  int engineHorsePower = 246;
  int vehicleWeightPounds = 4000;
  Vehicle vehicle = new Car(4, vehicleWeightPounds, engineHorsePower);
  System.out.println("Passengers count=" + 
                                 ((Car)vehicle).getPassengersCount());
  System.out.println("Car speed (" + timeSec + " sec) = " + 
                               vehicle.getSpeedMph(timeSec) + " mph");
  vehicle = new Truck(3300, vehicleWeightPounds, engineHorsePower);
  System.out.println("Payload=" + 
                           ((Truck)vehicle).getPayload() + " pounds");
  System.out.println("Truck speed (" + timeSec + " sec) = " + 
                               vehicle.getSpeedMph(timeSec) + " mph");
}

注意,Vehicle类型的vehicle引用指向Car子类的对象,稍后指向Truck子类的对象。这是由多态性实现的,根据多态性,对象具有其继承线中每个类的类型,包括所有接口。

如果需要调用仅存在于子类中的方法,必须将这样的引用转换为子类类型,就像在前面的示例中所做的那样。

前面代码的结果如下:

我们不应该对看到相同的速度计算结果感到惊讶,因为相同的重量和发动机功率用于计算每个车辆的速度。但是,直观上,我们感觉到一个装载重的卡车不应该能够在相同的时间内达到与汽车相同的速度。为了验证这一点,我们需要在速度的计算中包括汽车的总重量(乘客和行李)和卡车的总重量(有效载荷)。一种方法是在每个子类中覆盖Vehicle基类的getSpeedMph(double timeSec)方法。

我们可以在Car类中添加getSpeedMph(double timeSec)方法,它将覆盖基类中具有相同签名的方法。这个方法将使用特定于汽车的重量计算:

public double getSpeedMph(double timeSec) {
  int weight = this.weightPounds + this.passengersCount * 250;
  double v = 2.0 * this.horsePower * 746 * timeSec * 32.17 / weight;
  return Math.round(Math.sqrt(v) * 0.68);
}

在前面的代码中,我们假设一个带行李的乘客平均重量为250磅。

类似地,我们可以在Truck类中添加getSpeedMph(double timeSec)方法:

public double getSpeedMph(double timeSec) {
  int weight = this.weightPounds + this.payload;
  double v = 2.0 * this.horsePower * 746 * timeSec * 32.17 / weight;
  return Math.round(Math.sqrt(v) * 0.68);
}

对这些修改的结果(如果我们运行相同的测试类)将如下:

结果证实了我们的直觉——装载完全的汽车或卡车的速度不会达到空车的速度。

子类中的新方法覆盖了Vehicle基类的getSpeedMph(double timeSec),尽管我们是通过基类引用来访问它的:

Vehicle vehicle =  new Car(4, vehicleWeightPounds, engineHorsePower);
System.out.println("Car speed (" + timeSec + " sec) = " + 
                              vehicle.getSpeedMph(timeSec) + " mph");

覆盖的方法是动态绑定的,这意味着方法调用的上下文是由实际对象的类型决定的。因此,在我们的示例中,引用vehicle指向Car子类的对象,vehicle.getSpeedMph(double timeSec)调用子类的方法,而不是基类的方法。

在这两个新方法中存在明显的代码冗余,我们可以通过在Vehicle基类中创建一个方法,然后在每个子类中使用它来重构。

protected double getSpeedMph(double timeSec, int weightPounds) {
  double v = 2.0 * this.horsePower * 746 * 
                              timeSec * 32.17 / weightPounds;
  return Math.round(Math.sqrt(v) * 0.68);
}

由于这个方法只被子类使用,它可以是protected,因此只能被子类访问。

现在,我们可以更改Car类中的getSpeedMph(double timeSec)方法,如下所示:

public double getSpeedMph(double timeSec) {
  int weightPounds = this.weightPounds + this.passengersCount * 250;
  return getSpeedMph(timeSec, weightPounds);
}

在上述代码中,调用getSpeedMph(timeSec, weightPounds)方法时不需要使用super关键字,因为这样的签名方法只存在于Vehicle基类中,对此没有任何歧义。

Truck类的getSpeedMph(double timeSec)方法中也可以进行类似的更改:

public double getSpeedMph(double timeSec) {
  int weightPounds = this.weightPounds + this.payload;
  return getSpeedMph(timeSec, weightPounds);
}

现在,我们需要通过添加转换来修改测试类,否则会出现运行时错误,因为Vehicle基类中不存在getSpeedMph(double timeSec)方法:

public static void main(String... arg) {
    double timeSec = 10.0;
    int engineHorsePower = 246;
    int vehicleWeightPounds = 4000;
    Vehicle vehicle = new Car(4, vehicleWeightPounds, 
    engineHorsePower);
    System.out.println("Passengers count=" + 
    ((Car)vehicle).getPassengersCount());
    System.out.println("Car speed (" + timeSec + " sec) = " +
                       ((Car)vehicle).getSpeedMph(timeSec) + " mph");
    vehicle = new Truck(3300, vehicleWeightPounds, engineHorsePower);
    System.out.println("Payload=" + 
                          ((Truck)vehicle).getPayload() + " pounds");
    System.out.println("Truck speed (" + timeSec + " sec) = " + 
                     ((Truck)vehicle).getSpeedMph(timeSec) + " mph");
  }
}

正如您所期望的那样,测试类产生相同的值:

为了简化测试代码,我们可以放弃转换,改为写以下内容:

public static void main(String... arg) {
  double timeSec = 10.0;
  int engineHorsePower = 246;
  int vehicleWeightPounds = 4000;
  Car car = new Car(4, vehicleWeightPounds, engineHorsePower);
  System.out.println("Passengers count=" + car.getPassengersCount());
  System.out.println("Car speed (" + timeSec + " sec) = " + 
                                  car.getSpeedMph(timeSec) + " mph");
  Truck truck = 
              new Truck(3300, vehicleWeightPounds, engineHorsePower);
  System.out.println("Payload=" + truck.getPayload() + " pounds");
  System.out.println("Truck speed (" + timeSec + " sec) = " + 
                                truck.getSpeedMph(timeSec) + " mph");
}

此代码产生的速度值保持不变。

然而,有一种更简单的方法可以实现相同的效果。我们可以将getMaxWeightPounds()方法添加到基类和每个子类中。现在Car类将如下所示:

public class Car extends Vehicle {
  private int passengersCount, weightPounds;
  public Car(int passengersCount, int weightPounds, int horsePower){
    super(weightPounds, horsePower);
    this.passengersCount = passengersCount;
    this.weightPounds = weightPounds;
  }
  public int getPassengersCount() { 
    return this.passengersCount;
  }
  public int getMaxWeightPounds() {
    return this.weightPounds + this.passengersCount * 250;
  }
}

现在Truck类的新版本如下所示:

public class Truck extends Vehicle {
  private int payload, weightPounds;
  public Truck(int payloadPounds, int weightPounds, int horsePower) {
    super(weightPounds, horsePower);
    this.payload = payloadPounds;
    this.weightPounds = weightPounds;
  }
  public int getPayload() { return this.payload; }
  public int getMaxWeightPounds() {
    return this.weightPounds + this.payload;
  }
}

我们还需要在基类中添加getMaxWeightPounds()方法,以便用于速度计算:

public abstract class Vehicle {
  private int weightPounds, horsePower;
  public Vehicle(int weightPounds, int horsePower) {
    this.weightPounds = weightPounds;
    this.horsePower = horsePower;
  }
  public abstract int getMaxWeightPounds();
  public double getSpeedMph(double timeSec){
    double v = 2.0 * this.horsePower * 746 * 
                             timeSec * 32.17 / getMaxWeightPounds();
    return Math.round(Math.sqrt(v) * 0.68);
  }
}

Vehicle类添加一个抽象方法getMaxWeightPounds()会使该类成为抽象类。这有一个积极的副作用——它强制在每个子类中实现getMaxWeightPounds()方法。否则,子类将无法实例化,必须声明为抽象类。

测试类保持不变,产生相同的结果:

但是,说实话,我们这样做只是为了演示使用抽象方法和类的一种可能方式。事实上,一个更简单的解决方案是将最大重量作为参数传递给Vehicle基类的构造函数。结果类将如下所示:

public class Car extends Vehicle {
  private int passengersCount;
  public Car(int passengersCount, int weightPounds, int horsepower){
    super(weightPounds + passengersCount * 250, horsePower);
    this.passengersCount = passengersCount;
  }
  public int getPassengersCount() { 
    return this.passengersCount; }
}

我们将乘客的重量添加到我们传递给超类构造函数的值中;这是这个子类中唯一的变化。Truck类中也有类似的变化:

public class Truck extends Vehicle {
  private int payload;
  public Truck(int payloadPounds, int weightPounds, int horsePower) {
    super(weightPounds + payloadPounds, horsePower);
    this.payload = payloadPounds;
  }
  public int getPayload() { return this.payload; }
}

Vehicle基类与原始的一样:

public class Vehicle {
  private int weightPounds, horsePower;
  public Vehicle(int weightPounds, int horsePower) {
    this.weightPounds = weightPounds;
    this.horsePower = horsePower;
  }
  public double getSpeedMph(double timeSec){
    double v = 2.0 * this.horsePower * 746;
    v = v * timeSec * 32.174 / this.weightPounds;
    return Math.round(Math.sqrt(v) * 0.68);
  }
}

测试类不会改变,并且产生相同的结果:

这个最后的版本——将最大重量传递给基类的构造函数——现在将成为进一步代码演示的起点。

聚合使设计更具可扩展性

在上面的例子中,速度模型是在Vehicle类的getSpeedMph(double timeSec)方法中实现的。如果我们需要使用不同的速度模型(例如包含更多输入参数并且更适合特定驾驶条件),我们需要更改Vehicle类或创建一个新的子类来覆盖该方法。在需要尝试几十甚至数百种不同模型的情况下,这种方法变得不可行。

此外,在现实生活中,基于机器学习和其他先进技术的建模变得如此复杂和专业化,以至于汽车加速的建模通常是由不同的团队完成的,而不是组装车辆模型的团队。

为了避免子类的激增和车辆构建者与速度模型开发者之间的代码合并冲突,我们可以使用聚合创建一个更具可扩展性的设计。

聚合是一种面向对象设计原则,用于使用不属于继承层次结构的类的行为来实现必要的功能。该行为可以独立于聚合功能存在。

我们可以将速度计算封装在SpeedModel类的getSpeedMph(double timeSec)方法中:

public class SpeedModel{
  private Properties conditions;
  public SpeedModel(Properties drivingConditions){
    this.drivingConditions = drivingConditions;
  }
  public double getSpeedMph(double timeSec, int weightPounds,
                                               int horsePower){
    String road = 
         drivingConditions.getProperty("roadCondition","Dry");
    String tire = 
         drivingConditions.getProperty("tireCondition","New");
    double v = 2.0 * horsePower * 746 * timeSec * 
                                         32.17 / weightPounds;
    return Math.round(Math.sqrt(v)*0.68)-road.equals("Dry")? 2 : 5) 
                                       -(tire.equals("New")? 0 : 5);
  }
}

可以创建这个类的对象,然后将其设置为Vehicle类字段的值:

public class Vehicle {
   private SpeedModel speedModel;       
   private int weightPounds, horsePower;
   public Vehicle(int weightPounds, int horsePower) {
      this.weightPounds = weightPounds;
      this.horsePower = horsePower;
   }
   public void setSpeedModel(SpeedModel speedModel){
      this.speedModel = speedModel;
   }
   public double getSpeedMph(double timeSec){
      return this.speedModel.getSpeedMph(timeSec,
                       this.weightPounds, this.horsePower);
   }
}

测试类的更改如下:

public static void main(String... arg) {
  double timeSec = 10.0;
  int horsePower = 246;
  int vehicleWeight = 4000;
  Properties drivingConditions = new Properties();
  drivingConditions.put("roadCondition", "Wet");
  drivingConditions.put("tireCondition", "New");
  SpeedModel speedModel = new SpeedModel(drivingConditions);
  Car car = new Car(4, vehicleWeight, horsePower);
  car.setSpeedModel(speedModel);
  System.out.println("Car speed (" + timeSec + " sec) = " + 
                         car.getSpeedMph(timeSec) + " mph");
}

上述代码的结果如下:

我们将速度计算功能隔离到一个单独的类中,现在可以修改或扩展它,而不需要改变Vehicle继承层次结构中的任何类。这就是聚合设计原则允许您在不改变实现的情况下改变行为的方式。

在下一个示例中,我们将向您展示接口的面向对象编程概念如何释放出更多聚合和多态的力量,使设计更简单,甚至更具表现力。

编码到接口

在这个示例中,您将学习面向对象编程概念中的最后一个接口,并进一步练习聚合和多态的使用,以及内部类和继承。

准备工作

接口定义了一个类中可以期望看到的方法的签名。它是对客户端可访问的功能的公共界面,因此通常被称为应用程序编程接口API)。它支持多态和聚合,并促进更灵活和可扩展的设计。

接口是隐式抽象的,这意味着它不能被实例化。不能仅基于接口创建对象,而不实现它。它仅用于包含抽象方法(无方法体)。但自 Java 8 以来,可以向接口添加默认和私有方法,这是我们将在以下示例中讨论的功能。

每个接口可以扩展多个其他接口,并且类继承类似,继承所有扩展接口的默认和抽象方法。静态成员不能被继承,因为它们属于特定接口。

如何做...

  1. 创建描述 API 的接口:
public interface SpeedModel {
   double getSpeedMph(double timeSec, int weightPounds, 
                                            int horsePower);
 }
 public interface Vehicle {
   void setSpeedModel(SpeedModel speedModel);
   double getSpeedMph(double timeSec);
 }
 public interface Car extends Vehicle {
   int getPassengersCount();
 }
 public interface Truck extends Vehicle {
   int getPayloadPounds();
 }
  1. 使用工厂,这些工厂是生成实现特定接口的对象的类。工厂隐藏了实现的细节,因此客户端只处理接口。当实例创建需要复杂的过程和/或大量代码重复时,这是特别有帮助的。在我们的情况下,有一个FactoryVehicle类是有意义的,它创建实现VehicleCarTruck接口的类的对象。我们还将创建FactorySpeedModel类,它生成实现SpeedModel接口的类的对象。这样的 API 允许我们编写以下代码:
public static void main(String... arg) {
   double timeSec = 10.0;
   int horsePower = 246;
   int vehicleWeight = 4000;
   Properties drivingConditions = new Properties();
   drivingConditions.put("roadCondition", "Wet");
   drivingConditions.put("tireCondition", "New");
   SpeedModel speedModel  = FactorySpeedModel.
                 generateSpeedModel(drivingConditions);
   Car car = FactoryVehicle.
                buildCar(4, vehicleWeight, horsePower);
   car.setSpeedModel(speedModel);
   System.out.println("Car speed (" + timeSec + " sec) = " 
                      + car.getSpeedMph(timeSec) + " mph");
}
  1. 请注意,代码行为与先前示例中的相同:

然而,设计更具可扩展性。

它是如何工作的...

我们已经看到了SpeedModel接口的一个可能的实现。这是另一种通过在FactorySpeedModel类内聚SpeedModel类型对象来实现的方法:

public class FactorySpeedModel {
  public static SpeedModel generateSpeedModel(
  Properties drivingConditions){
    //if drivingConditions includes "roadCondition"="Wet"
    return new SpeedModelWet(...);
    //if drivingConditions includes "roadCondition"="Dry"
    return new SpeedModelDry(...);
  }
  private class SpeedModelWet implements SpeedModel{
    public double getSpeedMph(double timeSec, int weightPounds, 
                                                     int horsePower){
       //method code goes here
    }
  }
  private class SpeedModelDry implements SpeedModel{
    public double getSpeedMph(double timeSec, int weightPounds, 
                                                     int horsePower){
      //method code goes here
    }
  }
}

我们将注释作为伪代码,并使用...符号代替实际代码,以便简洁。

如您所见,工厂类可能隐藏许多不同的私有类,每个类都包含特定驾驶条件的专门模型。每个模型产生不同的结果。

FactoryVehicle类的实现可能如下所示:

public class FactoryVehicle {
  public static Car buildCar(int passengersCount, 
                               int weightPounds, int horsePower){
    return new CarImpl(passengersCount, weightPounds,horsePower);
  }
  public static Truck buildTruck(int payloadPounds, 
                               int weightPounds, int horsePower){
    return new TruckImpl(payloadPounds, weightPounds,horsePower);
  }
}

CarImpl私有嵌套类在FactoryVehicle类内部可能如下所示:

  private static class CarImpl extends VehicleImpl implements Car {
    private int passengersCount;
    private CarImpl(int passengersCount, int weightPounds,
                                                 int horsePower){
      super(weightPounds + passengersCount * 250, horsePower);
      this.passengersCount = passengersCount;
    }
    public int getPassengersCount() { 
      return this.passengersCount;
    }
  }

同样,TruckImpl类可以是FactoryImpl类的私有嵌套类:

  private static class TruckImpl extends VehicleImpl implements Truck {
    private int payloadPounds;
    private TruckImpl(int payloadPounds, int weightPounds, 
                                                     int horsePower){
      super(weightPounds+payloadPounds, horsePower);
      this.payloadPounds = payloadPounds;
    }
    public int getPayloadPounds(){ return payloadPounds; }
  }

我们也可以将VehicleImpl类作为FactoryVehicle类的私有内部类放置,这样CarImplTruckImpl类就可以访问它,但是FactoryVehicle之外的任何其他类都不能访问它:

  private static abstract class VehicleImpl implements Vehicle {
    private SpeedModel speedModel;
    private int weightPounds, horsePower;
    private VehicleImpl(int weightPounds, int horsePower){
      this.weightPounds = weightPounds;
      this.horsePower = horsePower;
    }
    public void setSpeedModel(SpeedModel speedModel){ 
      this.speedModel = speedModel; 
    }
    public double getSpeedMph(double timeSec){
      return this.speedModel.getSpeedMph(timeSec, weightPounds, 
                                                    horsePower);
    }
  }

如您所见,接口描述了如何调用对象行为,而工厂可以为不同的请求生成不同的实现,而不改变客户端应用程序的代码。

还有更多...

让我们尝试建模一个乘员舱——一个具有多个乘客座位的卡车,它结合了汽车和卡车的特性。Java 不允许多重继承。这是另一个接口发挥作用的案例。

CrewCab类可能如下所示:

public class CrewCab extends VehicleImpl implements Car, Truck {
  private int payloadPounds;
  private int passengersCount;
  private CrewCabImpl(int passengersCount, int payloadPounds,
                             int weightPounds, int horsePower) {
     super(weightPounds + payloadPounds + passengersCount * 250, 
                                                     horsePower);
     this.payloadPounds = payloadPounds;
     this. passengersCount = passengersCount;
  }
  public int getPayloadPounds(){ return payloadPounds; }
  public int getPassengersCount() { 
     return this.passengersCount;
  }
}

这个类同时实现了CarTruck接口,并将车辆、货物和乘客及其行李的总重量传递给基类构造函数。

我们还可以向FactoryVehicle添加以下方法:

public static Vehicle buildCrewCab(int passengersCount, 
                      int payload, int weightPounds, int horsePower){
  return new CrewCabImpl(passengersCount, payload, 
                                           weightPounds, horsePower);
}

CrewCab对象的双重性质可以在以下测试中得到证明:

public static void main(String... arg) {
  double timeSec = 10.0;
  int horsePower = 246;
  int vehicleWeight = 4000;
  Properties drivingConditions = new Properties();
  drivingConditions.put("roadCondition", "Wet");
  drivingConditions.put("tireCondition", "New");
  SpeedModel speedModel = 
      FactorySpeedModel.generateSpeedModel(drivingConditions);
  Vehicle vehicle = FactoryVehicle.
             buildCrewCab(4, 3300, vehicleWeight, horsePower);
  vehicle.setSpeedModel(speedModel);
  System.out.println("Payload = " +
            ((Truck)vehicle).getPayloadPounds()) + " pounds");
  System.out.println("Passengers count = " + 
                         ((Car)vehicle).getPassengersCount());
  System.out.println("Crew cab speed (" + timeSec + " sec) = "  
                     + vehicle.getSpeedMph(timeSec) + " mph");
}

正如您所看到的,我们可以将CrewCub类的对象转换为它实现的每个接口。如果我们运行这个程序,结果将如下所示:

创建具有默认和静态方法的接口

在这个示例中,您将了解到 Java 8 中首次引入的两个新功能——接口中的默认和静态方法。

准备就绪

接口中的默认方法允许我们添加一个新的方法签名,而不需要改变在添加新方法签名之前已经实现了该接口的类。该方法被称为default,因为它在该方法未被类实现时提供功能。然而,如果类实现了它,接口的默认实现将被忽略并被类实现覆盖。

接口中的静态方法可以提供与类中的静态方法相同的功能。类似于类静态方法可以在不实例化类的情况下调用一样,接口静态方法也可以使用点运算符应用于接口来调用,SomeInterface.someStaticMethod()

接口的静态方法不能被实现该接口的类覆盖,也不能隐藏任何类的静态方法,包括实现该接口的类。

例如,让我们为我们已经在示例中使用的系统添加一些功能。到目前为止,我们已经创建了一个了不起的软件,可以计算车辆的速度。如果系统变得受欢迎(应该是这样),我们希望它对更喜欢使用公制单位的读者更友好,而不是我们在速度计算中使用的英里和磅。在我们的速度计算软件变得受欢迎之后,为了满足这样的需求,我们决定向CarTruck接口添加更多方法,但我们不想破坏现有的实现。

默认接口方法正是为这种情况而引入的。使用它,我们可以发布CarTruck接口的新版本,而无需协调与现有实现的相应修改,即CarImplTruckImplFactoryVehicle类。

如何做...

例如,我们将更改Truck接口。Car接口可以以类似的方式修改:

  1. 通过添加一个新的默认方法来增强Truck接口,该方法返回卡车的载重量(以千克为单位)。您可以在不强制更改实现Truck接口的TruckImpl类的情况下完成这一点——通过向Truck接口添加一个新的默认方法:
      public interface Truck extends Vehicle {
          int getPayloadPounds();
          default int getPayloadKg(){
            return (int) Math.round(0.454 * getPayloadPounds());
          }
      }

注意新的getPayloadKg()方法如何使用现有的getPayloadPounds()方法,就好像后者也是在接口内实现的一样,尽管实际上它是在实现Truck接口的类中实现的。魔术发生在运行时,当这个方法动态绑定到实现该接口的类的实例时。

我们无法将getPayloadKg()方法设为静态,因为它无法访问非静态的getPayloadPounds()方法,我们必须使用default关键字,因为只有接口的默认或静态方法才能有方法体。

  1. 编写使用新方法的客户端代码:
      public static void main(String... arg) {
         Truck truck = FactoryVehicle.buildTruck(3300, 4000, 246);
         System.out.println("Payload in pounds: " + 
                                        truck.getPayloadPounds());
         System.out.println("Payload in kg: " + 
                                            truck.getPayloadKg());
      }
  1. 运行上述程序并检查输出:

  1. 请注意,即使不改变实现它的类,新方法也可以工作。

  2. 当您决定改进TruckImpl类的实现时,您可以通过添加相应的方法来实现,例如:

       class TruckImpl extends VehicleImpl implements Truck {
          private int payloadPounds;
          private TruckImpl(int payloadPounds, int weightPounds,
                                                int horsePower) {
            super(weightPounds + payloadPounds, horsePower);
            this.payloadPounds = payloadPounds;
          }
          public int getPayloadPounds(){ return payloadPounds; }
          public int getPayloadKg(){ return -2; }
       }

我们已经实现了getPyloadKg()方法,使其为return -2,以便明确使用了哪种实现。

  1. 运行相同的演示程序。结果将如下所示:

正如您所看到的,这次在TruckImpl类中使用了方法实现。它已覆盖了Truck接口中的默认实现。

  1. 增强Truck接口的功能,使其能够以千克为单位输入有效载荷,而不改变FactoryVehicleTruck接口的实现。此外,我们不希望添加一个 setter 方法。在所有这些限制下,我们唯一的选择是在Truck接口中添加convertKgToPounds(int kgs)方法,并且它必须是静态的,因为我们将在实现Truck接口的对象构造之前使用它:
      public interface Truck extends Vehicle {
          int getPayloadPounds();
          default int getPayloadKg(){
            return (int) Math.round(0.454 * getPayloadPounds());
          }
          static int convertKgToPounds(int kgs){
            return (int) Math.round(2.205 * kgs);
          }
       }

工作原理...

现在,喜欢使用公制单位的人可以利用新的方法:

public static void main(String... arg) {
  int horsePower = 246;
  int payload = Truck.convertKgToPounds(1500);
  int vehicleWeight = Truck.convertKgToPounds(1800);
  Truck truck = FactoryVehicle.
           buildTruck(payload, vehicleWeight, horsePower);
  System.out.println("Payload in pounds: " + 
                                truck.getPayloadPounds());
  int kg = truck.getPayloadKg();
  System.out.println("Payload converted to kg: " + kg);
  System.out.println("Payload converted back to pounds: " + 
                              Truck.convertKgToPounds(kg));
}

结果将如下所示:

1,502 的值接近原始的 1,500,而 3,308 接近 3,312。差异是由转换过程中的近似误差引起的。

创建具有私有方法的接口

在本教程中,您将了解 Java 9 中引入的新功能——私有接口方法,它有两种类型:静态和非静态。

准备就绪

私有接口方法必须有实现(具有代码的主体)。同一接口的其他方法未使用的私有接口方法是没有意义的。私有方法的目的是包含在同一接口中具有主体的两个或多个方法之间的常见功能,或者将代码部分隔离在单独的方法中,以获得更好的结构和可读性。私有接口方法不能被覆盖,既不能被任何其他接口的方法覆盖,也不能被实现接口的类中的方法覆盖。

非静态私有接口方法只能被同一接口的非静态方法访问。静态私有接口方法可以被同一接口的非静态和静态方法访问。

如何做...

  1. 添加getWeightKg(int pounds)方法的实现:
     public interface Truck extends Vehicle {
         int getPayloadPounds();
         default int getPayloadKg(){
            return (int) Math.round(0.454 * getPayloadPounds());
         }
         static int convertKgToPounds(int kilograms){
            return (int) Math.round(2.205 * kilograms);
         }
         default int getWeightKg(int pounds){
            return (int) Math.round(0.454 * pounds);
         }
     }
  1. 通过使用私有接口方法删除冗余代码:
    public interface Truck extends Vehicle {
        int getPayloadPounds();
        default int getPayloadKg(int pounds){
            return convertPoundsToKg(pounds);
        }
        static int convertKgToPounds(int kilograms){
            return (int) Math.round(2.205 * kilograms);
        }
        default int getWeightKg(int pounds){
            return convertPoundsToKg(pounds);
        }
        private int convertPoundsToKg(int pounds){
            return (int) Math.round(0.454 * pounds);
        }
    }

工作原理...

以下代码演示了新的添加内容:

public static void main(String... arg) {
  int horsePower = 246;
  int payload = Truck.convertKgToPounds(1500);
  int vehicleWeight = Truck.convertKgToPounds(1800);
  Truck truck = 
      FactoryVehicle.buildTruck(payload, vehicleWeight, horsePower);
  System.out.println("Weight in pounds: " + vehicleWeight);
  int kg = truck.getWeightKg(vehicleWeight);
  System.out.println("Weight converted to kg: " + kg);
  System.out.println("Weight converted back to pounds: " + 
                                       Truck.convertKgToPounds(kg));
}

测试结果不会改变:

还有更多...

由于getWeightKg(int pounds)方法接受输入参数,方法名称可能会误导,因为它没有捕捉输入参数的重量单位。我们可以尝试将其命名为getWeightKgFromPounds(int pounds),但这并不会使方法功能更清晰。在意识到这一点后,我们决定将convertPoundsToKg(int pounds)方法设为公共方法,并完全删除getWeightKg(int pounds)方法。由于convertPoundsToKg(int pounds)方法不需要访问对象字段,它也可以是静态的:

public interface Truck extends Vehicle {
  int getPayloadPounds();
  default int getPayloadKg(int pounds){
    return convertPoundsToKg(pounds);
  }
  static int convertKgToPounds(int kilograms){
    return (int) Math.round(2.205 * kilograms);
  }
  static int convertPoundsToKg(int pounds){
    return (int) Math.round(0.454 * pounds);
  }
}

仍然可以将英镑转换为千克,而且由于两种转换方法都是静态的,我们不需要创建实现Truck接口的类的实例来进行转换:

public static void main(String... arg) {
  int payload = Truck.convertKgToPounds(1500);
  int vehicleWeight = Truck.convertKgToPounds(1800);
  System.out.println("Weight in pounds: " + vehicleWeight);
  int kg = Truck.convertPoundsToKg(vehicleWeight);
  System.out.println("Weight converted to kg: " + kg);
  System.out.println("Weight converted back to pounds: " + 
                                     Truck.convertKgToPounds(kg));
}

结果不会改变:

使用 Optional 更好地处理空值

在本教程中,您将学习如何使用java.util.Optional类来表示可选值,而不是使用null引用。它是在 Java 8 中引入的,并在 Java 9 中进一步增强,增加了三种方法:or()ifPresentOrElse()stream()。我们将演示它们全部。

准备就绪

Optional类是一个围绕值的包装器,可以是null或任何类型的值。它旨在帮助避免可怕的NullPointerException。但是,到目前为止,引入Optional只在流和函数式编程领域有所帮助。

创建Optional类的愿景是在Optional对象上调用isPresent()方法,然后仅在isPresent()方法返回true时应用get()方法(获取包含的值)。不幸的是,当无法保证Optional对象本身的引用不是null时,需要检查它以避免NullPointerException。如果是这样,那么使用Optional的价值就会减少,因为即使写入更少的代码,我们也可以检查值本身是否为null并避免包装在Optional中?让我们编写代码来说明我们所说的。

假设我们想编写一个方法来检查彩票结果,如果你和朋友一起买的彩票中奖了,计算你的 50%份额。传统的做法是:

void checkResultInt(int lotteryPrize){
    if(lotteryPrize <= 0){
        System.out.println("We've lost again...");
    } else {
        System.out.println("We've won! Your half is " + 
                     Math.round(((double)lotteryPrize)/2) + "!");
    }
}

但是,为了演示如何使用Optional,我们将假设结果是Integer类型。然后,我们还需要检查null,如果我们不能确定传入的值不可能是null

void checkResultInt(Integer lotteryPrize){
    if(lotteryPrize == null || lotteryPrize <= 0){
        System.out.println("We've lost again...");
    } else {
        System.out.println("We've won! Your half is " + 
                    Math.round(((double)lotteryPrize)/2) + "!");
    }
}

使用Optional类并不能帮助避免对null的检查。它甚至需要添加额外的检查isPresent(),以避免在获取值时出现NullPointerException

void checkResultOpt(Optional<Integer> lotteryPrize){
    if(lotteryPrize == null || !lotteryPrize.isPresent() 
                                        || lotteryPrize.get() <= 0){
        System.out.println("We lost again...");
    } else {
        System.out.println("We've won! Your half is " + 
                   Math.round(((double)lotteryPrize.get())/2) + "!");
    }
}

显然,先前使用Optional并没有帮助改进代码或使编码更容易。在 Lambda 表达式和流管道中使用Optional具有更大的潜力,因为Optional对象提供了可以通过点运算符调用的方法,并且可以插入到流畅式处理代码中。

如何做...

  1. 使用已经演示过的任何方法创建一个Optional对象,如下所示:
Optional<Integer> prize1 = Optional.empty();
System.out.println(prize1.isPresent()); //prints: false
System.out.println(prize1);   //prints: Optional.empty

Optional<Integer> prize2 = Optional.of(1000000);
System.out.println(prize2.isPresent()); //prints: true
System.out.println(prize2);  //prints: Optional[1000000]

//Optional<Integer> prize = Optional.of(null); 
                                  //NullPointerException

Optional<Integer> prize3 = Optional.ofNullable(null);
System.out.println(prize3.isPresent());  //prints: false
System.out.println(prize3);     //prints: Optional.empty

请注意,可以使用ofNullable()方法将null值包装在Optional对象中。

  1. 可以使用equals()方法比较两个Optional对象,该方法通过值进行比较:
Optional<Integer> prize1 = Optional.empty();
System.out.println(prize1.equals(prize1)); //prints: true

Optional<Integer> prize2 = Optional.of(1000000);
System.out.println(prize1.equals(prize2)); //prints: false

Optional<Integer> prize3 = Optional.ofNullable(null);
System.out.println(prize1.equals(prize3)); //prints: true

Optional<Integer> prize4 = Optional.of(1000000);
System.out.println(prize2.equals(prize4)); //prints: true
System.out.println(prize2 == prize4); //prints: false

Optional<Integer> prize5 = Optional.of(10);
System.out.println(prize2.equals(prize5)); //prints: false

Optional<String> congrats1 = Optional.empty();
System.out.println(prize1.equals(congrats1));//prints: true

Optional<String> congrats2 = Optional.of("Happy for you!");
System.out.println(prize1.equals(congrats2));//prints: false

请注意,空的Optional对象等于包装null值的对象(上述代码中的prize1prize3对象)。上述代码中的prize2prize4对象相等,因为它们包装相同的值,尽管它们是不同的对象,引用不匹配(prize2 != prize4)。还要注意,包装不同类型的空对象是相等的(prize1.equals(congrats1)),这意味着Optional类的equals()方法不比较值类型。

  1. 使用Optional类的or(Suppier<Optional<T>> supplier)方法可靠地从Optional对象中返回非空值。如果对象为空并包含null,则它将返回由提供的Supplier函数生成的Optional对象中的另一个值。

例如,如果Optional<Integer> lotteryPrize对象可能包含null值,则以下结构将在遇到null值时每次返回零:

       int prize = lotteryPrize.or(() -> Optional.of(0)).get();

  1. 使用ifPresent(Consumer<T> consumer)方法来忽略null值,并使用提供的Consumer<T>函数处理非空值。例如,这是processIfPresent(Optional<Integer>)方法,它处理Optional<Integer> lotteryPrize对象:
void processIfPresent(Optional<Integer> lotteryPrize){
   lotteryPrize.ifPresent(prize -> {
      if(prize <= 0){
          System.out.println("We've lost again...");
      } else {
          System.out.println("We've won! Your half is " + 
                    Math.round(((double)prize)/2) + "!");
     }
});

我们可以通过创建checkResultAndShare(int prize)方法简化上述代码:

void checkResultAndShare(int prize){
    if(prize <= 0){
        System.out.println("We've lost again...");
    } else {
        System.out.println("We've won! Your half is " + 
                   Math.round(((double)prize)/2) + "!");
    }
}

现在,processIfPresent()方法看起来简单得多:

void processIfPresent(Optional<Integer> lotteryPrize){
    lotteryPrize.ifPresent(prize -> checkResultAndShare(prize));
}
  1. 如果您不想忽略null值并且也要处理它,可以使用ifPresentOrElse(Consumer<T> consumer, Runnable processEmpty)方法将Consumer<T>函数应用于非空值,并使用Runnable函数接口来处理null值:
void processIfPresentOrElse(Optional<Integer> lotteryPrize){
   Consumer<Integer> weWon = 
                       prize -> checkResultAndShare(prize);
   Runnable weLost = 
           () -> System.out.println("We've lost again...");
   lotteryPrize.ifPresentOrElse(weWon, weLost);
}

正如您所见,我们已经重用了刚刚创建的checkResultAndShare(int prize)方法。

  1. 使用orElseGet(Supplier<T> supplier)方法允许我们用由提供的Supplier<T>函数产生的值来替换Optional对象中的空值或null值:
void processOrGet(Optional<Integer> lotteryPrize){
   int prize = lotteryPrize.orElseGet(() -> 42);
   lotteryPrize.ifPresentOrElse(p -> checkResultAndShare(p),
      () -> System.out.println("Better " + prize 
                                     + " than nothing..."));
 }
  1. 如果需要在Optional对象为空或包含null值的情况下抛出异常,请使用orElseThrow()方法:
void processOrThrow(Optional<Integer> lotteryPrize){
   int prize = lotteryPrize.orElseThrow();
   checkResultAndShare(prize);
}

orElseThrow()方法的重载版本允许我们指定异常和当Optional对象中包含的值为null时要抛出的消息:

void processOrThrow(Optional<Integer> lotteryPrize){
    int prize = lotteryPrize.orElseThrow(() -> 
           new RuntimeException("We've lost again..."));
    checkResultAndShare(prize);
}
  1. 使用filter()map()flatMap()方法来处理流中的Optional对象:
void useFilter(List<Optional<Integer>> list){
   list.stream().filter(opt -> opt.isPresent())
         .forEach(opt -> checkResultAndShare(opt.get()));
}
void useMap(List<Optional<Integer>> list){
   list.stream().map(opt -> opt.or(() -> Optional.of(0)))
         .forEach(opt -> checkResultAndShare(opt.get()));
}
void useFlatMap(List<Optional<Integer>> list){
   list.stream().flatMap(opt -> 
           List.of(opt.or(()->Optional.of(0))).stream())
        .forEach(opt -> checkResultAndShare(opt.get()));
}

在前面的代码中,useFilter()方法只处理那些非空数值的流元素。useMap()方法处理所有流元素,但是用没有值的Optional对象或者用包装了null值的Optional对象替换它们。最后一个方法使用了flatMap(),它需要从提供的函数返回一个流。在这方面,我们的示例是相当无用的,因为我们传递给flatMap()参数的函数产生了一个对象的流,所以在这里使用map()(就像前面的useMap()方法中一样)是一个更好的解决方案。我们只是为了演示flatMap()方法如何插入到流管道中才这样做的。

它是如何工作的...

以下代码演示了所描述的Optional类的功能。useFlatMap()方法接受一个Optional对象列表,创建一个流,并处理每个发出的元素:

void useFlatMap(List<Optional<Integer>> list){
    Function<Optional<Integer>, 
      Stream<Optional<Integer>>> tryUntilWin = opt -> {
        List<Optional<Integer>> opts = new ArrayList<>();
        if(opt.isPresent()){
            opts.add(opt);
        } else {
            int prize = 0;
            while(prize == 0){
                double d = Math.random() - 0.8;
                prize = d > 0 ? (int)(1000000 * d) : 0;
                opts.add(Optional.of(prize));
            }
        }
        return opts.stream();
    };
    list.stream().flatMap(tryUntilWin)
        .forEach(opt -> checkResultAndShare(opt.get()));
}

原始列表的每个元素首先作为输入进入flatMap()方法,然后作为tryUntilWin函数的输入。这个函数首先检查Optional对象的值是否存在。如果是,Optional对象将作为流的单个元素发出,并由checkResultAndShare()方法处理。但是如果tryUntilWin函数确定Optional对象中没有值或者值为null,它会在-0.80.2之间生成一个随机双精度数。如果值为负数,就会向结果列表中添加一个值为零的Optional对象,并生成一个新的随机数。但如果生成的数是正数,它将用于奖金值的计算,并添加到包装在Optional对象中的结果列表中。Optional对象的结果列表然后作为流返回,并且流的每个元素都由checkResultAndShare()方法处理。

现在,让我们对以下列表运行前面的方法:

List<Optional<Integer>> list = List.of(Optional.empty(), 
                                       Optional.ofNullable(null), 
                                       Optional.of(100000));
useFlatMap(list);

结果将如下所示:

如您所见,当第一个列表元素Optional.empty()被处理时,tryUntilWin函数在第三次尝试中成功获得了一个正的奖金值。第二个Optional.ofNullable(null)对象导致了两次尝试,直到tryUntilWin函数成功。最后一个对象成功通过,并奖励您和您的朋友各 50000。

还有更多...

Optional类的对象不可序列化,因此不能用作对象的字段。这是Optional类的设计者打算在无状态过程中使用的另一个指示。

它使流处理管道更加简洁和表达,专注于实际值而不是检查流中是否有空元素。

使用实用类 Objects

在这个配方中,您将学习java.util.Objects实用类如何允许更好地处理与对象比较、计算哈希值和检查null相关的功能。这是早就该有的功能,因为程序员们一遍又一遍地编写相同的代码来检查对象是否为null

准备工作

Objects类只有 17 种方法,全部都是静态的。为了更好地概述,我们将它们组织成了七个组:

  • compare(): 使用提供的Comparator比较两个对象的方法

  • toString(): 将Object转换为String值的两种方法

  • checkIndex(): 三种允许我们检查集合或数组的索引和长度是否兼容的方法

  • requireNonNull(): 如果提供的对象为null,则五种方法抛出异常

  • hash(), hashCode(): 计算单个对象或对象数组的哈希值的两种方法

  • isNull()nonNull(): 包装obj == nullobj != null表达式的两种方法

  • equals(), deepEquals(): 比较两个可以为 null 或数组的对象的两种方法

我们将按照前述顺序编写使用这些方法的代码。

如何做...

  1. int compare(T a, T b, Comparator<T> c)方法使用提供的比较器来比较两个对象:
  • 当对象相等时返回 0

  • 当第一个对象小于第二个对象时返回负数

  • 否则返回正数

int compare(T a, T b, Comparator<T> c)方法的非零返回值取决于实现。对于String,根据它们的排序位置定义较小和较大(较小的放在有序列表的前面),返回值是第一个和第二个参数在列表中的位置之间的差异,根据提供的比较器排序:

int res =
      Objects.compare("a", "c", Comparator.naturalOrder());
System.out.println(res);       //prints: -2
res = Objects.compare("a", "a", Comparator.naturalOrder());
System.out.println(res);       //prints: 0
res = Objects.compare("c", "a", Comparator.naturalOrder());
System.out.println(res);       //prints: 2
res = Objects.compare("c", "a", Comparator.reverseOrder());
System.out.println(res);       //prints: -2

另一方面,Integer值在值不相等时返回-11

res = Objects.compare(3, 5, Comparator.naturalOrder());
System.out.println(res);       //prints: -1
res = Objects.compare(3, 3, Comparator.naturalOrder());
System.out.println(res);       //prints: 0
res = Objects.compare(5, 3, Comparator.naturalOrder());
System.out.println(res);       //prints: 1
res = Objects.compare(5, 3, Comparator.reverseOrder());
System.out.println(res);       //prints: -1
res = Objects.compare("5", "3", Comparator.reverseOrder());
System.out.println(res);       //prints: -2

请注意,在前面代码块的最后一行中,当我们将数字作为String文字进行比较时,结果会发生变化。

当两个对象都为null时,compare()方法将认为它们相等:

res = Objects.compare(null,null,Comparator.naturalOrder());
System.out.println(res);       //prints: 0

但是当其中一个对象为 null 时,会抛出NullPointerException

//Objects.compare(null, "c", Comparator.naturalOrder());   
//Objects.compare("a", null, Comparator.naturalOrder());   

如果需要将对象与 null 进行比较,最好使用org.apache.commons.lang3.ObjectUtils.compare(T o1, T o2)

  1. null时,toString(Object obj)方法很有用:
  • String toString(Object obj): 当第一个参数不为null时,返回调用toString()的结果,当第一个参数值为null时,返回null

  • String toString(Object obj, String nullDefault): 当第一个参数不为null时,返回调用toString()的结果,当第一个参数值为null时,返回第二个参数值nullDefault

toString(Object obj)方法的使用很简单:

System.out.println(Objects.toString("a")); //prints: a
System.out.println(Objects.toString(null)); //prints: null
System.out.println(Objects.toString("a", "b")); //prints: a
System.out.println(Objects.toString(null, "b"));//prints: b
  1. checkIndex()重载方法检查集合或数组的索引和长度是否兼容:
  • int checkIndex(int index, int length): 如果提供的index大于length - 1,则抛出IndexOutOfBoundsException,例如:
List<Integer> list = List.of(1, 2);
try {
   Objects.checkIndex(3, list.size());
} catch (IndexOutOfBoundsException ex){
   System.out.println(ex.getMessage()); 
       //prints: Index 3 out-of-bounds for length 2
}
    • int checkFromIndexSize(int fromIndex, int size, int length): 如果提供的index + size大于length - 1,则抛出IndexOutOfBoundsException,例如:
List<Integer> list = List.of(1, 2);
try {
   Objects.checkFromIndexSize(1, 3, list.size());
} catch (IndexOutOfBoundsException ex){
   System.out.println(ex.getMessage());
//prints:Range [1, 1 + 3) out-of-bounds for length 2
}
    • int checkFromToIndex(int fromIndex, int toIndex, int length): 如果提供的fromIndex大于toIndex,或者toIndex大于length - 1,则抛出IndexOutOfBoundsException,例如:
List<Integer> list = List.of(1, 2);
try {
   Objects.checkFromToIndex(1, 3, list.size());
} catch (IndexOutOfBoundsException ex){
   System.out.println(ex.getMessage()); 
   //prints:Range [1, 3) out-of-bounds for length 2
}
  1. requireNonNull()组的五种方法检查第一个参数obj的值。如果值为null,它们要么抛出NullPointerException,要么返回提供的默认值:
  • T requireNonNull(T obj): 如果参数为null,则抛出没有消息的NullPointerException,例如:
String obj = null;
try {
  Objects.requireNonNull(obj);
} catch (NullPointerException ex){
  System.out.println(ex.getMessage());//prints: null
}
    • T requireNonNull(T obj, String message): 如果第一个参数为null,则抛出带有提供的消息的NullPointerException,例如:
String obj = null;
try {
  Objects.requireNonNull(obj,  
                          "Parameter 'obj' is null");
} catch (NullPointerException ex){
  System.out.println(ex.getMessage()); 
                 //prints: Parameter 'obj' is null
}
    • T requireNonNull(T obj, Supplier<String> messageSupplier): 如果第一个参数为null,则返回由提供的函数生成的消息,如果生成的消息或函数本身为null,则抛出NullPointerException,例如:
String obj = null;
Supplier<String> supplier = () -> "Message";
try {
  Objects.requireNonNull(obj, supplier);
} catch (NullPointerException ex){
  System.out.println(ex.getMessage()); 
                         //prints: Message
}
    • T requireNonNullElse(T obj, T defaultObj): 返回第一个参数(如果它不是 null),第二个参数(如果它不是 null),抛出NullPointerException(如果两个参数都是null),例如:
String object = null;
System.out.println(Objects
          .requireNonNullElse(obj, "Default value")); 
                          //prints: Default value
    • T requireNonNullElseGet(T obj, Supplier<T> supplier): 返回第一个参数(如果它不是 null),由提供的供应商函数产生的对象(如果它不是 null 且supplier.get()不是 null),抛出NullPointerException(如果两个参数都是null或第一个参数和 supplier.get()都是null),例如:
Integer obj = null;
Supplier<Integer> supplier = () -> 42;
try {
    System.out.println(Objects
              .requireNonNullElseGet(obj, supplier));
} catch (NullPointerException ex){
    System.out.println(ex.getMessage()); //prints: 42
} 
  1. hash()hashCode()方法通常用于覆盖默认的hashCode()实现:
  • int hashCode(Object value): 为单个对象计算哈希值,例如:
System.out.println(Objects.hashCode(null)); 
                                       //prints: 0
System.out.println(Objects.hashCode("abc")); 
                                   //prints: 96354 
  • int hash(Object... values): 为对象数组计算哈希值,例如:
System.out.println(Objects.hash(null));  //prints: 0
System.out.println(Objects.hash("abc"));  
                                     //prints: 96385
String[] arr = {"abc"};
System.out.println(Objects.hash(arr));
                                     //prints: 96385
Object[] objs = {"a", 42, "c"};
System.out.println(Objects.hash(objs));  
                                    //prints: 124409
System.out.println(Objects.hash("a", 42, "c")); 
                                    //prints: 124409

请注意,hashCode(Object value)方法返回一个不同的哈希值(96354),而Objects.hash(Object... values)方法返回(96385),尽管它们为相同的单个对象计算哈希值。

  1. isNull()nonNull()方法只是布尔表达式的包装器:
  • boolean isNull(Object obj): 返回与obj == null相同的值,例如:
String obj = null;
System.out.println(obj == null);     //prints: true
System.out.println(Objects.isNull(obj));
                                     //prints: true
obj = "";
System.out.println(obj == null);    //prints: false
System.out.println(Objects.isNull(obj));  
                                    //prints: false
    • boolean nonNull(Object obj): 返回与obj != null相同的值,例如:
String obj = null;
System.out.println(obj != null);    //prints: false
System.out.println(Objects.nonNull(obj)); 
                                    //prints: false
obj = "";
System.out.println(obj != null);     //prints: true
System.out.println(Objects.nonNull(obj));  
                                     //prints: true
  1. equals()deepEquals()方法允许我们通过它们的状态来比较两个对象:
  • boolean equals(Object a, Object b): 使用equals(Object)方法比较两个对象,并处理它们中的一个或两个为null的情况,例如:
String o1 = "o";
String o2 = "o";
System.out.println(Objects.equals(o1, o2));       
                                   //prints: true
System.out.println(Objects.equals(null, null));   
                                   //prints: true
Integer[] ints1 = {1,2,3};
Integer[] ints2 = {1,2,3};
System.out.println(Objects.equals(ints1, ints2)); 
                                  //prints: false

在上面的例子中,Objects.equals(ints1, ints2)返回false,因为数组不能覆盖Object类的equals()方法,而是通过引用而不是值进行比较。

    • boolean deepEquals(Object a, Object b): 比较两个数组的元素值,例如:
String o1 = "o";
String o2 = "o";
System.out.println(Objects.deepEquals(o1, o2));    
                                   //prints: true
System.out.println(Objects.deepEquals(null, null));
                                   //prints: true
Integer[] ints1 = {1,2,3};
Integer[] ints2 = {1,2,3};
System.out.println(Objects.deepEquals(ints1,ints2));
                                      //prints: true
Integer[][] iints1 = {{1,2,3},{1,2,3}};
Integer[][] iints2 = {{1,2,3},{1,2,3}};
System.out.println(Objects.
         deepEquals(iints1, iints2)); //prints: true  

正如你所看到的,deepEquals()方法在数组的相应值相等时返回true。但是如果数组有不同的值或相同值的不同顺序,该方法将返回false

Integer[][] iints1 = {{1,2,3},{1,2,3}};
Integer[][] iints2 = {{1,2,3},{1,3,2}};
System.out.println(Objects.
      deepEquals(iints1, iints2)); //prints: false

它是如何工作的...

Arrays.equals(Object a, Object b)Arrays.deepEquals(Object a, Object b)方法的行为与Objects.equals(Object a, Object b)Objects.deepEquals(Object a, Object b)方法相同:

Integer[] ints1 = {1,2,3};
Integer[] ints2 = {1,2,3};
System.out.println(Arrays.equals(ints1, ints2));         
                                            //prints: true
System.out.println(Arrays.deepEquals(ints1, ints2));     
                                            //prints: true
System.out.println(Arrays.equals(iints1, iints2));       
                                            //prints: false
System.out.println(Arrays.deepEquals(iints1, iints2));   
                                            //prints: true

实际上,Arrays.equals(Object a, Object b)Arrays.deepEquals(Object a, Object b)方法在Objects.equals(Object a, Object b)Objects.deepEquals(Object a, Object b)方法的实现中被使用。

总之,如果你想要比较两个对象ab的字段值,那么:

  • 如果它们不是数组且a不是null,则使用a.equals(Object b)

  • 如果它们不是数组且每个或两个对象都可以是null,则使用Objects.equals(Object a, Object b)

  • 如果两者都可以是数组且每个或两者都可以是null,则使用Objects.deepEquals(Object a, Object b)

Objects.deepEquals(Object a, Object b)方法似乎是最安全的,但这并不意味着你必须总是使用它。大多数情况下,你会知道比较的对象是否可以是null或者可以是数组,因此你也可以安全地使用其他方法。

第三章:模块化编程

在本章中,我们将涵盖以下技巧:

  • 使用 jdeps 在 Java 应用程序中查找依赖关系

  • 创建一个简单的模块化应用程序

  • 创建一个模块化 JAR

  • 在使用 Pre-Project Jigsaw JDK 应用程序中使用模块 JAR

  • 自下而上的迁移

  • 自上而下的迁移

  • 使用服务来创建消费者和提供者模块之间的松耦合

  • 使用 jlink 创建自定义模块化运行时映像

  • 为旧平台版本编译

  • 创建多版本 JAR

  • 使用 Maven 开发模块化应用程序

  • 使您的库对模块路径友好

  • 如何为反射打开一个模块

介绍

模块化编程使我们能够将代码组织成独立的、内聚的模块,这些模块可以组合在一起以实现所需的功能。这使我们能够创建代码:

  • 更加内聚,因为模块是为特定目的构建的,所以驻留在那里的代码往往倾向于迎合特定目的。

  • 封装,因为模块只能与其他模块提供的 API 进行交互。

  • 可靠,因为可发现性是基于模块而不是基于个别类型的。这意味着如果一个模块不存在,依赖的模块在被依赖的模块发现之前无法执行。这有助于防止运行时错误。

  • 松耦合。如果使用服务接口,模块接口和服务接口实现可以松散耦合。

因此,在设计和组织代码的思考过程中,现在将涉及识别模块、代码和配置文件进入模块以及代码在模块内部组织的包。之后,我们必须决定模块的公共 API,从而使它们可供依赖模块使用。

关于Java 平台模块系统的开发,它由Java 规范请求(JSR)376(www.jcp.org/en/jsr/detail?id=376)进行管理。JSR 提到,模块系统应该解决以下基本问题:

  • 可靠的配置:提供了一个替代类路径的方式来声明组件之间的依赖关系,使开发人员可以防止他们的应用程序由于类路径中缺少依赖关系而在运行时出现意外。

  • 强封装:提供更严格的访问控制,使组件中的私有内容真正私有,即使通过反射也无法访问,并允许开发人员有选择地公开组件中的部分内容供其他组件使用。

JSR 列出了解决上述问题所带来的优势:

  • 可扩展的平台:JSR 376 中的规范将允许通过使用新平台创建的不同组件/模块来正确地利用 JSR 337 中引入的不同配置文件,从而创建配置文件。这个模块化平台还将允许其他开发人员打包 Java 平台的不同组件,以创建自定义运行时,从而为他们提供创建仅适合他们使用的运行时的选项。

  • 更大的平台完整性:强封装将阻止有意或意外地使用 Java 内部 API,从而提供更大的平台完整性。

  • 性能提升:由于组件之间的明确依赖关系,现在更容易根据它们在 Java SE 平台内部和外部交互的组件来优化各个组件。

在本章中,我们将介绍一些重要的技巧,帮助您开始模块化编程。

使用 jdeps 在 Java 应用程序中查找依赖关系

模块化应用程序的第一步是识别其依赖关系。JDK 8 引入了一个名为jdeps的静态分析工具,使开发人员能够找到其应用程序的依赖关系。命令中支持多个选项,使开发人员能够检查 JDK 内部 API 的依赖关系,显示包级别的依赖关系,显示类级别的依赖关系,并过滤依赖关系等。

在这个示例中,我们将探讨如何使用jdeps工具来探索其功能,并使用它支持的多个命令行选项。

准备就绪

我们需要一个示例应用程序,可以针对jdeps命令运行以查找其依赖关系。因此,我们考虑创建一个非常简单的应用程序,使用 Jackson API 来消耗来自 REST API 的 JSON:jsonplaceholder.typicode.com/users

在示例代码中,我们还添加了对已弃用的 JDK 内部 APIsun.reflect.Reflection.getCallerClass()的调用。这样,我们可以看到jdeps如何帮助找到 JDK 内部 API 的依赖关系。

以下步骤将帮助您设置此示例的先决条件:

  1. 您可以从Chapter03/1_json-jackson-sample获取示例的完整代码。我们已经针对 Java 9 构建了这段代码,也使用了 Java 8,并且编译成功。因此,您只需要安装 Java 9 来进行编译。如果您尝试使用 JDK 11 进行编译,由于已弃用的内部 API 不再可用,您将遇到错误。

  2. 一旦您有了代码,就使用以下命令进行编译:

 # On Linux
 javac -cp 'lib/*' -d classes 
            -sourcepath src $(find src -name *.java)

 # On Windows
 javac -cp lib*;classes 
            -d classes src/com/packt/model/*.java
                       src/com/packt/*.java

注意:如果您的javac指向 JDK 11,您可以声明环境变量,如JAVA8_HOMEJAVA9_HOME,分别指向您的 JDK 8 和 JDK9 安装。这样,您可以使用以下命令进行编译:

# On Linux
"$JAVA8_HOME"/bin/javac -cp 'lib/*' 
   -d classes -sourcepath src $(find src -name *.java)

# On Windows
"%JAVA8_HOME%"\bin\javac -cp lib\*;classes 
       -d classes src\com\packt\*.java 
                 src\com\packt\model\*.java

您将看到有关使用内部 API 的警告,您可以放心地忽略。我们添加了这个目的是为了演示jdeps的功能。现在,您应该在 classes 目录中有已编译的类文件。

  1. 您可以创建一个可执行的 JAR 文件,并通过以下命令运行示例程序:
 # On Linux:
        jar cvfm sample.jar manifest.mf -C classes .
        "$JAVA8_HOME"/bin/java -jar sample.jar
 # On Windows:
        jar cvfm sample.jar manifest.mf -C classes .
 "%JAVA8_HOME%"\bin\java -jar sample.jar
  1. 我们在Chapter03/1_json-jackson-sample中提供了run.batrun.sh脚本。您也可以使用这些脚本进行编译和运行。

如果您使用run.batrun.sh或上述命令创建 JAR,则会在当前目录中创建一个sample.jar文件。如果 JAR 尚未创建,您可以使用build-jar.batbuild.-jar.sh脚本来编译和构建 JAR。

因此,我们有一个非模块化的示例应用程序,我们将使用jdeps来分析其依赖关系,以及可能依赖的模块名称。

如何做...

  1. 使用jdeps的最简单方法如下:
# On Linux
jdeps -cp classes/:lib/* classes/com/packt/Sample.class

# On Windows
jdeps -cp "classes/;lib/*" classes/com/packt/Sample.class 

上述命令等同于以下命令:

# On Linux jdeps -verbose:package -cp classes/:lib/*
      classes/com/packt/Sample.class

# On Windows
jdeps -verbose:package -cp "classes/;lib/*" classes/com/packt/Sample.class 

上述代码的输出如下:

在上述命令中,我们使用jdeps来列出包级别的类文件Sample.class的依赖关系。我们必须提供jdeps要搜索的代码依赖的路径。这可以通过设置jdeps命令的-classpath-cp--class-path选项来实现。

-verbose:package选项列出了包级别的依赖关系。

  1. 让我们列出类级别的依赖关系:
# On Linux      
jdeps -verbose:class -cp classes/:lib/* 
classes/com/packt/Sample.class

# On Windows
jdeps -verbose:class -cp "classes/;lib/*" classes/com/packt/Sample.class 

上述命令的输出如下:

在这种情况下,我们利用-verbose:class选项来列出类级别的依赖关系,这就是为什么你可以看到com.packt.Sample类依赖于com.packt.model.Companyjava.lang.Exceptioncom.fasterxml.jackson.core.type.TypeReference等等。

  1. 让我们得到依赖关系的摘要:
# On Linux
jdeps -summary -cp classes/:lib/* 
                   classes/com/packt/Sample.class

# On Windows
jdeps -summary -cp "classes/;lib/*" 
                    classes/com/packt/Sample.class 

输出如下:

  1. 让我们检查对 JDK 内部 API 的依赖:
# On Linux
jdeps -jdkinternals -cp classes/:lib/*
 classes/com/packt/Sample.class

# On Windows
jdeps -jdkinternals -cp "classes/;lib/*"   
                         classes/com/packt/Sample.class 

以下是上述命令的输出:

StackWalker API 是 Java 9 中引入的用于遍历调用堆栈的新 API。这是sun.reflect.Reflection.getCallerClass()方法的替代品。我们将在第十一章中讨论此 API,即内存管理和调试

  1. 让我们对 JAR 文件sample.jar运行jdeps
# On Linux and Windows
      jdeps -s -cp lib/* sample.jar

我们得到的输出如下:

通过使用jdepssample.jar进行调查获得的信息非常有用。它清楚地说明了我们的 JAR 文件的依赖关系,在我们尝试将该应用程序迁移到模块化应用程序时非常有用。

  1. 让我们找出是否有任何依赖于给定包名称的依赖项:
# On Linux and Windows
      jdeps -p java.util sample.jar

输出如下:

-p选项用于查找对给定包名称的依赖关系。因此,我们知道我们的代码依赖于java.util包。让我们尝试另一个包名称:

 jdeps -p java.util.concurrent sample.jar

没有输出,这意味着我们的代码不依赖于java.util.concurrent包。

  1. 我们只想对我们的代码运行依赖性检查。是的,这是可能的。假设我们运行jdeps -cp lib/* sample.jar;你会看到甚至库 JAR 也被分析了。我们不想要那样,对吧?让我们只包括com.packt包的类:
# On Linux 
jdeps -include 'com.packt.*' -cp lib/* sample.jar

# On Windows
jdeps -include "com.packt.*" -cp lib/* sample.jar 

输出如下:

  1. 让我们检查我们的代码是否依赖于特定包:
# On Linux
jdeps -p 'com.packt.model' sample.jar

# On Windows
jdeps -p "com.packt.model" sample.jar 

输出如下:

  1. 我们可以使用jdeps来分析 JDK 模块。让我们选择java.httpclient模块进行分析:
 jdeps -m java.xml

以下是输出:

我们还可以通过使用--require选项找出一个给定模块是否依赖于另一个模块,如下所示:

# On Linux and Windows
      jdeps --require java.logging -m java.sql

以下是输出:

在上述命令中,我们试图找出java.sql模块是否依赖于java.logging模块。我们得到的输出是java.sql模块的依赖摘要和java.sql模块中使用java.logging模块代码的包。

它是如何工作的...

jdeps命令是一个静态类依赖性分析器,用于分析应用程序及其库的静态依赖关系。jdeps命令默认显示输入文件(可以是.class文件、目录或 JAR 文件)的包级别依赖关系。这是可配置的,并且可以更改为显示类级别的依赖关系。有多个选项可用于过滤依赖关系并指定要分析的类文件。我们已经看到了-cp命令行选项的常规用法。此选项用于提供要搜索分析代码依赖关系的位置。

我们已经分析了类文件、JAR 文件和 JDK 模块,并尝试了jdeps命令的不同选项。有一些选项,如-e-regex--regex-f--filter-include,可以接受正则表达式(regex)。了解jdeps命令的输出是很重要的。对于每个被分析的类/JAR 文件,都有两部分信息:

  1. 分析文件(JAR 或类文件)的依赖摘要。左侧是类或 JAR 文件的名称,右侧是依赖实体的名称。依赖实体可以是目录、JAR 文件或 JDK 模块,如下所示:
 Sample.class -> classes
      Sample.class -> lib/jackson-core-2.9.6.jar
      Sample.class -> lib/jackson-databind-2.9.6.jar
      Sample.class -> java.base
      Sample.class -> jdk.unsupported
  1. 在包或类级别(取决于命令行选项)分析文件内容的更详细的依赖信息。这由三列组成——第 1 列包含包/类的名称,第 2 列包含依赖包的名称,第 3 列包含找到依赖项的模块/JAR 的名称。示例输出如下:
 com.packt  -> com.fasterxml.jackson.core.type  
                    jackson-core-2.9.6.jar
      com.packt  -> com.fasterxml.jackson.databind   
                    jackson-databind-2.9.6.jar
      com.packt  -> com.packt.model  sample.jar

还有更多...

我们已经看到了jdeps命令的许多选项。还有一些与过滤依赖项和过滤要分析的类相关的选项。除此之外,还有一些处理模块路径的选项。

以下是可以尝试的选项:

  • -e-regex--regex:这些找到与给定模式匹配的依赖项。

  • -f-filter:这些排除与给定模式匹配的依赖项。

  • -filter:none:这允许不应用filter:packagefilter:archive的过滤。

  • -filter:package:这排除了同一包内的依赖项。这是默认选项。例如,如果我们向jdeps sample.jar添加-filter:none,它将打印包对自身的依赖项。

  • -filter:archive:这排除了同一存档内的依赖项。

  • -filter:module:这排除了同一模块中的依赖项。

  • -P-profile:用于显示包的配置文件,无论它是在 compact1、compact2、compact3 还是完整的 JRE 中。

  • -R-recursive:这些递归遍历所有运行时依赖项;它们等同于-filter:none选项。

创建一个简单的模块化应用程序

您一定想知道模块化是什么,以及如何在 Java 中创建模块化应用程序。在这个示例中,我们将尝试通过一个简单的示例来澄清在 Java 中创建模块化应用程序的困惑。我们的目标是向您展示如何创建一个模块化应用程序;因此,我们选择了一个简单的示例,以便专注于我们的目标。

我们的示例是一个简单的高级计算器,用于检查数字是否为质数,计算质数的和,检查数字是否为偶数,并计算偶数和奇数的和。

准备就绪

我们将应用程序分为两个模块:

  • 包含用于执行数学计算的 API 的math.util模块

  • 启动高级计算器的calculator模块

如何做...

  1. 让我们在com.packt.math.MathUtil类中实现 API,从isPrime(Integer number)API 开始:
        public static Boolean isPrime(Integer number){
          if ( number == 1 ) { return false; }
          return IntStream.range(2,num).noneMatch(i -> num % i == 0 );
        }
  1. 实现sumOfFirstNPrimes(Integer count)API:
        public static Integer sumOfFirstNPrimes(Integer count){
          return IntStream.iterate(1,i -> i+1)
                          .filter(j -> isPrime(j))
                          .limit(count).sum();
        }
  1. 让我们编写一个函数来检查数字是否为偶数:
        public static Boolean isEven(Integer number){
          return number % 2 == 0;
        }
  1. isEven的否定告诉我们数字是否为奇数。我们可以有函数来找到前N个偶数和前N个奇数的和,如下所示:
        public static Integer sumOfFirstNEvens(Integer count){
          return IntStream.iterate(1,i -> i+1)
                          .filter(j -> isEven(j))
                          .limit(count).sum();
        }

        public static Integer sumOfFirstNOdds(Integer count){
          return IntStream.iterate(1,i -> i+1)
                          .filter(j -> !isEven(j))
                          .limit(count).sum();
        }

我们可以看到在前面的 API 中重复了以下操作:

  • 1开始的无限序列

  • 根据某些条件过滤数字

  • 将数字流限制为给定数量

  • 找到因此获得的数字的和

根据我们的观察,我们可以重构前面的 API,并将这些操作提取到一个方法中,如下所示:

Integer computeFirstNSum(Integer count,
                                 IntPredicate filter){
  return IntStream.iterate(1,i -> i+1)
                  .filter(filter)
                  .limit(count).sum();
 }

在这里,count是我们需要找到和的数字的限制,filter是选择数字进行求和的条件。

让我们根据刚刚进行的重构重写 API:

public static Integer sumOfFirstNPrimes(Integer count){
  return computeFirstNSum(count, (i -> isPrime(i)));
}

public static Integer sumOfFirstNEvens(Integer count){
  return computeFirstNSum(count, (i -> isEven(i)));
}

public static Integer sumOfFirstNOdds(Integer count){
  return computeFirstNSum(count, (i -> !isEven(i)));
}

您一定想知道以下内容:

  • IntStream类和相关方法的链接

  • 代码库中使用->

  • IntPredicate类的使用

如果您确实想知道,那么您无需担心,因为我们将在第四章 进入功能和第五章 流和管道中涵盖这些内容。

到目前为止,我们已经看到了一些围绕数学计算的 API。这些 API 是我们的com.packt.math.MathUtil类的一部分。该类的完整代码可以在为本书下载的代码库中的Chapter03/2_simple-modular-math-util/math.util/com/packt/math中找到。

让我们将这个小型实用程序类作为名为math.util的模块的一部分。以下是我们用来创建模块的一些约定:

  1. 将与模块相关的所有代码放在名为math.util的目录下,并将其视为我们的模块根目录。

  2. 在根文件夹中,插入一个名为module-info.java的文件。

  3. 将包和代码文件放在根目录下。

module-info.java包含什么?以下内容:

  • 模块的名称

  • 它导出的包,即它提供给其他模块使用的包。

  • 它依赖的模块

  • 它使用的服务

  • 它提供实现的服务

如第一章中所述,安装和 Java 11 的预览,JDK 捆绑了许多模块,即现有的 Java SDK 已经模块化!其中一个模块是名为java.base的模块。所有用户定义的模块都隐式依赖于(或需要)java.base模块(可以将每个类都隐式扩展Object类)。

我们的math.util模块不依赖于任何其他模块(当然,除了java.base模块)。但是,它使其 API 可用于其他模块(如果不是这样,那么该模块的存在就是有问题的)。让我们继续并将此语句放入代码中:

module math.util{
  exports com.packt.math;
}

我们告诉 Java 编译器和运行时,我们的math.util模块正在导出com.packt.math包中的代码给任何依赖于math.util的模块。

该模块的代码可以在Chapter03/2_simple-modular-math-util/math.util中找到。

现在,让我们创建另一个使用math.util模块的计算器模块。该模块有一个Calculator类,其工作是接受用户选择要执行的数学运算,然后输入执行操作所需的输入。用户可以从五种可用的数学运算中进行选择:

  • 素数检查

  • 偶数检查

  • N个素数的和

  • N个偶数的和

  • N个奇数的和

让我们在代码中看看这个:

private static Integer acceptChoice(Scanner reader){
  System.out.println("************Advanced Calculator************");
  System.out.println("1\. Prime Number check");
  System.out.println("2\. Even Number check");
  System.out.println("3\. Sum of N Primes");
  System.out.println("4\. Sum of N Evens");
  System.out.println("5\. Sum of N Odds");
  System.out.println("6\. Exit");
  System.out.println("Enter the number to choose operation");
  return reader.nextInt();
}

然后,对于每个选择,我们接受所需的输入并调用相应的MathUtil API,如下所示:

switch(choice){
  case 1:
    System.out.println("Enter the number");
    Integer number = reader.nextInt();
    if (MathUtil.isPrime(number)){
      System.out.println("The number " + number +" is prime");
    }else{
      System.out.println("The number " + number +" is not prime");
    }
  break;
  case 2:
    System.out.println("Enter the number");
    Integer number = reader.nextInt();
    if (MathUtil.isEven(number)){
      System.out.println("The number " + number +" is even");
    }
  break;
  case 3:
    System.out.println("How many primes?");
    Integer count = reader.nextInt();
    System.out.println(String.format("Sum of %d primes is %d", 
          count, MathUtil.sumOfFirstNPrimes(count)));
  break;
  case 4:
    System.out.println("How many evens?");
    Integer count = reader.nextInt();
    System.out.println(String.format("Sum of %d evens is %d", 
          count, MathUtil.sumOfFirstNEvens(count)));
  break;
  case 5: 
    System.out.println("How many odds?");
    Integer count = reader.nextInt();
    System.out.println(String.format("Sum of %d odds is %d", 
          count, MathUtil.sumOfFirstNOdds(count)));
  break;
}

Calculator类的完整代码可以在Chapter03/2_simple-modular-math-util/calculator/com/packt/calculator/Calculator.java中找到。

让我们以与math.util模块相同的方式创建我们的calculator模块的模块定义:

module calculator{
  requires math.util;
}

在前面的模块定义中,我们提到calculator模块使用required关键字依赖于math.util模块。

该模块的代码可以在Chapter03/2_simple-modular-math-util/calculator中找到。

让我们编译代码:

javac -d mods --module-source-path . $(find . -name "*.java")

上述命令必须从Chapter03/2_simple-modular-math-util执行。

此外,您应该在mods目录中跨两个模块math.utilcalculator的编译代码。编译器负责处理所有内容,包括模块之间的依赖关系。我们不需要构建工具(如ant)来管理模块的编译。

--module-source-path命令是javac的新命令行选项,指定我们模块源代码的位置。

让我们执行上述代码:

java --module-path mods -m calculator/com.packt.calculator.Calculator

--module-path命令,类似于--classpath,是java的新命令行选项,指定编译模块的位置。

在运行上述命令之后,您将看到计算器在运行中:

恭喜!有了这个,我们有一个简单的模块化应用程序正在运行。

我们已经提供了脚本来测试在 Windows 和 Linux 平台上的代码。 请在 Windows 上使用run.bat,在 Linux 上使用run.sh

它是如何工作的...

现在您已经通过了示例,我们将看看如何将其概括,以便我们可以在所有模块中应用相同的模式。 我们遵循了特定的约定来创建模块:

|application_root_directory
|--module1_root
|----module-info.java
|----com
|------packt
|--------sample
|----------MyClass.java
|--module2_root
|----module-info.java
|----com
|------packt
|--------test
|----------MyAnotherClass.java

我们将模块特定的代码放在其文件夹中,并在文件夹的根目录下放置一个相应的module-info.java文件。 这样,代码就组织得很好。

让我们看看module-info.java可以包含什么。 从 Java 语言规范(cr.openjdk.java.net/~mr/jigsaw/spec/lang-vm.html)中,模块声明的形式如下:

{Annotation} [open] module ModuleName { {ModuleStatement} }

这是语法,解释如下:

  • {Annotation}:这是任何形式为@Annotation(2)的注释。

  • open:这个关键字是可选的。 开放模块通过反射在运行时使其所有组件可访问。 但是,在编译时和运行时,只有明确导出的组件才可访问。

  • module:这是用于声明模块的关键字。

  • ModuleName:这是模块的名称,是一个有效的 Java 标识符,标识符名称之间可以使用允许的点(.)-类似于math.util

  • {ModuleStatement}:这是模块定义中允许的语句的集合。 让我们接下来扩展这个。

模块语句的形式如下:

ModuleStatement:
  requires {RequiresModifier} ModuleName ;
  exports PackageName [to ModuleName {, ModuleName}] ;
  opens PackageName [to ModuleName {, ModuleName}] ;
  uses TypeName ;
  provides TypeName with TypeName {, TypeName} ;

模块语句在这里被解码:

  • requires:这用于声明对模块的依赖关系。 {RequiresModifier}可以是transitivestatic,或两者兼有。 Transitive 表示任何依赖于给定模块的模块也隐式地依赖于给定模块传递地。 Static 表示模块依赖在编译时是强制性的,但在运行时是可选的。 一些示例是requires math.utilrequires transitive math.util,和requires static math.util

  • exports:这用于使给定的包对依赖模块可访问。 可选地,我们可以通过指定模块名称来强制包的可访问性到特定模块,例如exports com.package.math to claculator

  • opens:这用于打开特定包。 我们之前看到可以通过在模块声明中使用open关键字来打开模块。 但这可能不够严格。 因此,我们可以使用opens关键字在运行时打开特定包以进行反射访问-opens com.packt.math

  • uses:这用于声明对通过java.util.ServiceLoader可访问的服务接口的依赖关系。 服务接口可以在当前模块中或在当前模块依赖的任何模块中。

  • provides:这用于声明服务接口并提供至少一个实现。 服务接口可以在当前模块中声明,也可以在任何其他依赖模块中声明。 但是,服务实现必须在同一模块中提供; 否则,将发生编译时错误。

我们将更详细地查看usesprovides子句,使用服务来创建消费者和提供者模块之间的松耦合的示例中。

所有模块的模块源可以使用--module-source-path命令行选项一次性编译。 这样,所有模块将被编译并放置在由-d选项提供的目录下的相应目录中。 例如,javac -d mods --module-source-path . $(find . -name "*.java")将当前目录中的代码编译成一个mods目录。

运行代码同样简单。我们使用命令行选项--module-path指定我们所有模块编译后的路径。然后,我们使用命令行选项-m指定模块名称以及完全限定的主类名称,例如,java --module-path mods -m calculator/com.packt.calculator.Calculator

另请参阅

在第一章的编译和运行 Java 应用程序中,安装和预览 Java 11

创建一个模块化 JAR

将模块编译成类是不错的,但不适合共享二进制文件和部署。JAR 是更好的共享和部署格式。我们可以将编译后的模块打包成 JAR,并且包含module-info.class的 JAR 文件被称为模块化 JAR。在这个示例中,我们将看看如何创建模块化 JAR,并且还将看看如何执行由多个模块化 JAR 组成的应用程序。

准备工作

我们已经在创建一个更简单的模块化应用程序中看到并创建了一个简单的模块化应用程序。为了构建一个模块化 JAR,我们将使用Chapter03/3_modular_jar中提供的示例代码。这个示例代码包含两个模块:math.utilcalculator。我们将为这两个模块创建模块化 JAR 文件。

如何做...

  1. 编译代码并将编译后的类放入一个目录,比如mods
 javac -d mods --module-source-path . $(find . -name *.java)
  1. math.util模块构建一个模块化 JAR:
      jar --create --file=mlib/math.util@1.0.jar --module-version 1.0
      -C mods/math.util .

不要忘记在上述代码的末尾加上点(.)。

  1. calculator模块构建一个模块化 JAR,指定主类以使 JAR 可执行:
 jar --create --file=mlib/calculator@1.0.jar --module-version 1.0 
      --main-class com.packt.calculator.Calculator -C mods/calculator .

上述命令中的关键部分是--main-class选项。这使我们能够在执行时不提供主类信息来执行 JAR 文件。

  1. 现在,我们在mlib目录中有两个 JAR 文件:math.util@1.0.jarcalculator@1.0.jar。这些 JAR 文件被称为模块化 JAR 文件。如果您想要运行示例,可以使用以下命令:
 java -p mlib -m calculator
  1. 在 Java 9 中引入了 JAR 命令的一个新的命令行选项,称为-d--describe-module。这会打印模块化 JAR 包含的模块信息:
jar -d --file=mlib/calculator@1.0.jar

calculator@1.0.jarjar -d输出如下:

calculator@1.0
  requires mandated java.base
  requires math.util
  conceals com.packt.calculator
  main-class com.packt.calculator.Calculator

jar -d --file=mlib/math.util@1.0.jar

math.util@1.0.jarjar -d输出如下:

math.util@1.0
  requires mandated java.base
  exports com.packt.math

我们已经提供了以下脚本来在 Windows 上尝试示例代码:

  • compile-math.bat

  • compile-calculator.bat

  • jar-math.bat

  • jar-calculator.bat

  • run.bat

我们已经提供了以下脚本来在 Linux 上尝试示例代码:

  • compile.sh

  • jar-math.sh

  • jar-calculator.sh

  • run.sh

您必须按照它们列出的顺序运行脚本。

在 Project Jigsaw JDK 应用程序之前使用模块 JAR

如果我们的模块化 JAR 可以在 Project Jigsaw JDK 应用程序之前运行,那将是很棒的。这样,我们就不需要为 JDK 9 之前的应用程序编写另一个版本的 API。好消息是,我们可以像使用普通 JAR 一样使用我们的模块化 JAR,也就是说,没有module-info.class的 JAR。我们将在这个示例中看到如何做到这一点。

准备工作

对于这个示例,我们将需要一个模块化 JAR 和一个非模块化应用程序。我们的模块化代码可以在Chapter03/4_modular_jar_with_pre_java9/math.util中找到(这是我们在创建一个简单的模块化应用程序中创建的相同的math.util模块)。让我们使用以下命令编译这个模块化代码并创建一个模块化 JAR:

javac -d classes --module-source-path . $(find math.util -name *.java)
mkdir mlib
jar --create --file mlib/math.util.jar -C classes/math.util .

我们还在Chapter03/4_modular_jar_with_pre_java9中提供了一个jar-math.bat脚本,可以用于在 Windows 上创建模块化 JAR。我们有我们的模块化 JAR。让我们使用jar命令的-d选项来验证它:

jar -d --file mlib/math.util@1.0.jar
math.util@1.0
  requires mandated java.base
  exports com.packt.math

如何做...

现在,让我们创建一个非模块化的简单应用程序。我们的应用程序将包含一个名为NonModularCalculator的类,它从创建一个简单的模块化应用程序中的Calculator类中借用其代码。

您可以在Chapter03/4_modular_jar_with_pre_java9/calculator目录下的com.packt.calculator包中找到NonModularCalculator类的定义。由于它是非模块化的,所以不需要module-info.java文件。该应用程序利用我们的模块化 JARmath.util.jar来执行一些数学计算。

此时,您应该拥有以下内容:

  • 一个名为math.util@1.0.jar的模块化 JAR

  • 一个由NonModularCalculator包组成的非模块化应用程序

现在,我们需要编译我们的NonModularCalculator类:

javac -d classes/ --source-path calculator $(find calculator -name *.java)

运行上一个命令后,您将看到一系列错误,指出com.packt.math包不存在,找不到MathUtil符号等等。您已经猜到了;我们没有为编译器提供我们的模块化 JAR 的位置。让我们使用--class-path选项添加模块化jar的位置:

javac --class-path mlib/* -d classes/ --source-path calculator $(find calculator -name *.java)

现在,我们已成功编译了依赖于模块化 JAR 的非模块化代码。让我们运行编译后的代码:

java -cp classes:mlib/* com.packt.calculator.NonModularCalculator

恭喜!您已成功地将您的模块化 JAR 用于非模块化应用程序。很棒,对吧?

我们在Chapter03/4_modular_jar_with_pre_java9提供了以下脚本来在 Windows 平台上运行代码:

  • compile-calculator.bat

  • run.bat

自下而上的迁移

现在 Java 9 已经发布,备受期待的模块化功能现在可以被开发人员采用。在某个时候,您将参与将您的应用程序迁移到 Java 9,并因此尝试将其模块化。这种涉及第三方库和重新思考代码结构的重大变化需要适当的规划和实施。Java 团队提出了两种迁移方法:

  • 自下而上的迁移

  • 自上而下的迁移

在学习自下而上的迁移之前,了解无名模块和自动模块是很重要的。假设您正在访问一个在任何模块中都不可用的类型;在这种情况下,模块系统将在类路径上搜索该类型,如果找到,该类型将成为无名模块的一部分。这类似于我们编写的不属于任何包的类,但 Java 会将它们添加到无名包中,以简化新类的创建。

因此,无名模块是一个没有名称的通用模块,其中包含所有那些不属于任何模块但在类路径中找到的类型。无名模块可以访问所有命名模块(用户定义的模块)和内置模块(Java 平台模块)的所有导出类型。另一方面,命名模块(用户定义的模块)将无法访问无名模块中的类型。换句话说,命名模块无法声明对无名模块的依赖关系。如果您想声明依赖关系,该怎么办?无名模块没有名称!

有了无名模块的概念,您可以将您的 Java 8 应用程序保持原样,并在 Java 9 上运行它(除了任何已弃用的内部 API,这些 API 在 Java 9 中可能不可用于用户代码)。

如果您尝试过使用 jdeps 在 Java 应用程序中查找依赖项的示例,您可能已经看到了这一点,在那个示例中,我们有一个非模块化的应用程序,并且能够在 Java 9 上运行它。然而,在 Java 9 上按原样运行将违背引入模块化系统的初衷。

如果一个包在命名模块和无名模块中都有定义,那么命名模块中的包将优先于无名模块中的包。这有助于防止当它们来自命名模块和无名模块时的包冲突。

自动模块是 JVM 自动创建的模块。当我们将打包在 JAR 中的类引入模块路径而不是类路径时,将创建这些模块。该模块的名称将从 JAR 的名称中派生,因此与未命名模块不同。或者,可以通过在 JAR 清单文件中对Automatic-Module-Name提供模块名称来为这些自动模块提供名称。这些自动模块导出其中的所有包,并且还依赖于所有自动和命名(用户/JDK)模块。

根据这个解释,模块可以分为以下几类:

  • 未命名模块:在类路径上可用但在模块路径上不可用的代码放置在未命名模块中。

  • 命名模块:所有具有与之关联的名称的模块 - 这些可以是用户定义的模块和 JDK 模块。

  • 自动模块:所有由 JVM 根据模块路径中存在的 JAR 文件隐式创建的模块。

  • 隐式模块:隐式创建的模块。它们与自动模块相同。

  • 显式模块:所有由用户或 JDK 显式创建的模块。

但未命名模块和自动模块是开始迁移的良好第一步。所以,让我们开始吧!

准备工作

我们需要一个非模块化的应用程序,最终我们将对其进行模块化。我们已经创建了一个简单的应用程序,其源代码位于Chapter03/6_bottom_up_migration_before。这个简单的应用程序有三个部分:

  • 一个包含我们最喜爱的数学 API 的数学实用程序库:素数检查器,偶数检查器,素数之和,偶数之和和奇数之和。其代码位于Chapter03/6_bottom_up_migration_before/math_util

  • 一个银行实用程序库,其中包含用于计算简单利息和复利的 API。其代码位于Chapter03/6_bottom_up_migration_before/banking_util

  • 我们的计算器应用程序帮助我们进行数学和银行业务计算。为了使其更有趣,我们将以 JSON 格式输出结果,为此我们将使用 Jackson JSON API。其代码位于Chapter03/6_bottom_up_migration_before/calculator

在您复制或下载了代码之后,我们将编译和构建相应的 JAR。因此,请使用以下命令来编译和构建 JAR:

#Compiling math util

javac -d math_util/out/classes/ -sourcepath math_util/src $(find math_util/src -name *.java)
jar --create --file=math_util/out/math.util.jar 
-C math_util/out/classes/ .

#Compiling banking util

javac -d banking_util/out/classes/ -sourcepath banking_util/src $(find banking_util/src -name *.java)
jar --create --file=banking_util/out/banking.util.jar 
-C banking_util/out/classes/ .

#Compiling calculator

javac -cp calculator/lib/*:math_util/out/math.util.jar:banking_util/out/banking.util.jar -d calculator/out/classes/ -sourcepath calculator/src $(find calculator/src -name *.java)

让我们也为此创建一个 JAR(我们将使用该 JAR 来构建依赖关系图,但不用于运行应用程序):

jar --create --file=calculator/out/calculator.jar -C calculator/out/classes/ .

请注意,我们的 Jackson JARs 位于 calculator/lib 中,所以您不需要担心下载它们。让我们使用以下命令运行我们的计算器:

java -cp calculator/out/classes:calculator/lib/*:math_util/out/math.util.jar:banking_util/out/banking.util.jar com.packt.calculator.Calculator

您将看到一个菜单询问操作的选择,然后您可以尝试不同的操作。现在,让我们对这个应用程序进行模块化!

我们提供了package-*.bat和 run.bat 来在 Windows 上打包和运行应用程序。您可以使用package-*.shrun.sh在 Linux 上打包和运行应用程序。

如何做...

模块化应用程序的第一步是了解其依赖关系图。让我们为我们的应用程序创建一个依赖关系图。为此,我们使用jdeps工具。如果您想知道jdeps工具是什么,请立即阅读在 Java 应用程序中使用 jdeps 查找依赖关系。好的,让我们运行jdeps工具:

jdeps -summary -R -cp calculator/lib/*:math_util/out/*:banking_util/out/* calculator/out/calculator.jar

我们要求jdeps给我们calculator.jar的依赖关系摘要,然后对calculator.jar的每个依赖项进行递归处理。我们得到的输出如下:

banking.util.jar -> java.base
calculator.jar -> banking_util/out/banking.util.jar
calculator.jar -> calculator/lib/jackson-databind-2.8.4.jar
calculator.jar -> java.base
calculator.jar -> math_util/out/math.util.jar
jackson-annotations-2.8.4.jar -> java.base
jackson-core-2.8.4.jar -> java.base
jackson-databind-2.8.4.jar -> calculator/lib/jackson-annotations-2.8.4.jar
jackson-databind-2.8.4.jar -> calculator/lib/jackson-core-2.8.4.jar
jackson-databind-2.8.4.jar -> java.base
jackson-databind-2.8.4.jar -> java.logging
jackson-databind-2.8.4.jar -> java.sql
jackson-databind-2.8.4.jar -> java.xml
math.util.jar -> java.base

前面的输出很难理解,可以用图表形式表示如下:

在自下而上的迁移中,我们首先将叶节点模块化。在我们的图中,java.xmljava.sqljava.basejava.logging叶节点已经被模块化。让我们将banking.util.jar模块化。

本节的所有代码都可以在Chapter03/6_bottom_up_migration_after中找到。

模块化 banking.util.jar

  1. BankUtil.javaChapter03/6_bottom_up_migration_before/banking_util/src/com/packt/banking复制到Chapter03/6_bottom_up_migration_after/src/banking.util/com/packt/banking。有两件事需要注意:
  • 我们已经将文件夹从banking_util改名为banking.util。这是为了遵循将与模块相关的代码放在带有模块名称的文件夹下的惯例。

  • 我们将包直接放在banking.util文件夹下,而不是放在src下。同样,这是为了遵循惯例。我们将把所有的模块放在src文件夹下。

  1. Chapter03/6_bottom_up_migration_after/src/banking.util下创建模块定义文件module-info.java,内容如下:
        module banking.util{   
          exports com.packt.banking;
        }
  1. 6_bottom_up_migration_after文件夹中,通过运行以下命令编译模块的 Java 代码:
 javac -d mods --module-source-path src 
      $(find src -name *.java)
  1. 你会看到banking.util模块中的 Java 代码被编译到了 mods 目录中。

  2. 让我们为这个模块创建一个模块化的 JAR:

 jar --create --file=mlib/banking.util.jar -C mods/banking.util .

如果你想知道什么是模块化的 JAR,请随意阅读本章节中的创建模块化 JAR部分。

现在我们已经将banking.util.jar模块化了,让我们在准备工作部分之前使用这个模块化的jar来替代之前使用的非模块化 JAR。你应该从6_bottom_up_migration_before文件夹中执行以下操作,因为我们还没有完全将应用程序模块化:

java --add-modules ALL-MODULE-PATH --module-path ../6_bottom_up_migration_after/mods/banking.util -cp calculator/out/classes:calculator/lib/*:math_util/out/math.util.jar com.packt.calculator.Calculator

--add-modules选项告诉 Java 运行时要包含模块,可以通过模块名称或预定义常量,即ALL-MODULE-PATHALL-DEFAULTALL-SYSTEM来实现。我们使用了ALL-MODULE-PATH来添加模块,该模块可在我们的模块路径上使用。

--module-path选项告诉 Java 运行时我们模块的位置。

你会看到我们的计算器正常运行。尝试简单利息计算,复利计算,以检查BankUtil类是否被找到。因此,我们的依赖图现在如下所示:

模块化 math.util.jar

  1. MathUtil.javaChapter03/6_bottom_up_migration_before/math_util/src/com/packt/math复制到Chapter03/6_bottom_up_migration_after/src/math.util/com/packt/math

  2. Chapter03/6_bottom_up_migration_after/src/math.util下创建模块定义文件module-info.java,内容如下:

        module math.util{
          exports com.packt.math;
        }
  1. 6_bottom_up_migration_after文件夹中,通过运行以下命令编译模块的 Java 代码:
 javac -d mods --module-source-path src $(find src -name *.java)
  1. 你会看到math.utilbanking.util模块中的 Java 代码被编译到了mods目录中。

  2. 让我们为这个模块创建一个模块化的 JAR:

 jar --create --file=mlib/math.util.jar -C mods/math.util .

如果你想知道什么是模块化的jar,请随意阅读本章节中的创建模块化 JAR部分。

  1. 现在我们已经将math.util.jar模块化了,让我们在准备工作部分之前使用这个模块化的jar来替代非模块化的jar。你应该从6_bottom_up_migration_before文件夹中执行以下操作,因为我们还没有完全将应用程序模块化:
 java --add-modules ALL-MODULE-PATH --module-path
      ../6_bottom_up_migration_after/mods/banking.util:
      ../6_bottom_up_migration_after/mods/math.util 
      -cp calculator/out/classes:calculator/lib/*
      com.packt.calculator.Calculator

我们的应用程序运行正常,并且依赖图如下所示:

我们无法将calculator.jar模块化,因为它依赖于另一个非模块化的代码jackson-databind,而我们无法将jackson-databind模块化,因为它不是我们维护的。这意味着我们无法为我们的应用程序实现 100%的模块化。我们在本教程开始时向您介绍了未命名模块。我们类路径中的所有非模块化代码都被分组在未命名模块中,这意味着所有与 jackson 相关的代码仍然可以保留在未命名模块中,我们可以尝试将calculator.jar模块化。但是我们无法这样做,因为calculator.jar不能声明对jackson-databind-2.8.4.jar的依赖(因为它是一个未命名模块,命名模块不能声明对未命名模块的依赖)。

解决这个问题的一种方法是将与 jackson 相关的代码作为自动模块。我们可以通过移动与 jackson 相关的 jar 来实现这一点:

  • jackson-databind-2.8.4.jar

  • jackson-annotations-2.8.4.jar

  • jackson-core-2.8.4.jar

我们将使用以下命令将它们移动到6_bottom_up_migration_after文件夹下:

$ pwd 
/root/java9-samples/Chapter03/6_bottom_up_migration_after
$ cp ../6_bottom_up_migration_before/calculator/lib/*.jar mlib/
$ mv mlib/jackson-annotations-2.8.4.jar mods/jackson.annotations.jar
$ mv mlib/jackson-core-2.8.4.jar mods/jackson.core.jar
$ mv mlib/jackson-databind-2.8.4.jar mods/jackson.databind.jar

重命名 JAR 的原因是模块的名称必须是有效的标识符(不能仅为数字,不能包含-和其他规则),用.分隔。由于名称是从 JAR 文件的名称派生的,我们必须将 JAR 文件重命名以符合 Java 标识符规则。

如果不存在,创建一个新的mlib目录,在6_bottom_up_migration_after下。

现在,让我们再次运行我们的计算器程序,使用以下命令:

java --add-modules ALL-MODULE-PATH --module-path ../6_bottom_up_migration_after/mods:../6_bottom_up_migration_after/mlib -cp calculator/out/classes com.packt.calculator.Calculator

应用程序将像往常一样运行。您会注意到我们的-cp选项值正在变小,因为所有依赖库都已经作为模块移动到了模块路径中。依赖关系图现在看起来像这样:

模块化 calculator.jar

迁移的最后一步是将calculator.jar模块化。按照以下步骤进行模块化:

  1. Chapter03/6_bottom_up_migration_before/calculator/src中的com文件夹复制到Chapter03/6_bottom_up_migration_after/src/calculator

  2. Chapter03/6_bottom_up_migration_after/src/calculator下创建模块定义文件module-info.java,定义如下:

        module calculator{ 
          requires math.util; 
          requires banking.util; 
          requires jackson.databind; 
          requires jackson.core; 
          requires jackson.annotations; 
        }
  1. 6_bottom_up_migration_after文件夹中,通过运行以下命令编译模块的 Java 代码:
 javac -d mods --module-path mlib:mods --module-source-path src $(find src -name *.java)
  1. 您将看到我们所有模块中的 Java 代码都编译到了 mods 目录中。请注意,您应该已经将自动模块(即与 jackson 相关的 JAR)放置在mlib目录中。

  2. 让我们为这个模块创建一个模块化的 JAR,并指定哪个是main类:

 jar --create --file=mlib/calculator.jar --main-
      class=com.packt.calculator.Calculator -C mods/calculator .
  1. 现在,我们有了我们的计算器模块的模块化 JAR,这是我们的主要模块,因为它包含了main类。通过这样做,我们还模块化了我们的完整应用程序。让我们从文件夹6_bottom_up_migration_after运行以下命令:
 java -p mlib:mods -m calculator

因此,我们已经看到了如何使用自下而上的迁移方法将非模块化的应用程序模块化。最终的依赖关系图看起来像这样:

这个模块化应用程序的最终代码可以在Chapter03/6_bottom_up_migration_after中找到。

我们本可以在同一目录6_bottom_up_migration_before中进行修改,即在同一目录中对代码进行模块化。但我们更喜欢在不同的目录6_bottom_up_migration_after中单独进行,以保持代码整洁,不干扰现有的代码库。

它是如何工作的...

未命名模块的概念帮助我们在 Java 9 上运行我们的非模块化应用程序。在迁移过程中,模块路径和类路径的使用帮助我们运行部分模块化的应用程序。我们从模块化那些不依赖于任何非模块化代码的代码库开始,而我们无法模块化的任何代码库,我们将其转换为自动模块,从而使我们能够模块化依赖于这样一个代码库的代码。最终,我们得到了一个完全模块化的应用程序。

自上而下的迁移

迁移的另一种技术是自上而下的迁移。在这种方法中,我们从 JAR 的依赖图中的根 JAR 开始。

JAR 表示一个代码库。我们假设代码库以 JAR 的形式可用,因此我们得到的依赖图具有节点,这些节点是 JAR。

将依赖图的根模块化意味着该根模块依赖的所有其他 JAR 都必须是模块化的。否则,这个模块化根模块就无法声明对未命名模块的依赖。让我们考虑一下我们在底向上迁移食谱中介绍的非模块化应用程序的例子。依赖图看起来像这样:

我们在自上而下的迁移中广泛使用自动模块。自动模块是由 JVM 隐式创建的模块。这些模块是基于模块路径中可用的非模块化 JAR 创建的。

准备工作

我们将使用我们在前一篇食谱自下而上的迁移中介绍的计算器示例。继续从Chapter03/7_top_down_migration_before复制非模块化代码。如果您希望运行它并查看它是否正常工作,请使用以下命令:

$ javac -d math_util/out/classes/ -sourcepath math_util/src $(find math_util/src -name *.java)

$ jar --create --file=math_util/out/math.util.jar 
-C math_util/out/classes/ .

$ javac -d banking_util/out/classes/ -sourcepath banking_util/src $(find banking_util/src -name *.java)

$ jar --create --file=banking_util/out/banking.util.jar 
-C banking_util/out/classes/ .

$ javac -cp calculator/lib/*:math_util/out/math.util.jar:banking_util/out/banking.util.jar -d calculator/out/classes/ -sourcepath calculator/src $(find calculator/src -name *.java)

$ java -cp calculator/out/classes:calculator/lib/*:math_util/out/math.util.jar:banking_util/out/banking.util.jar com.packt.calculator.Calculator

我们提供了package-*.batrun.bat来在 Windows 上打包和运行代码,并在 Linux 上使用package-*.shrun.sh来打包和运行代码。

如何做...

我们将对Chapter03/7_top_down_migration_after目录下的应用程序进行模块化。在Chapter03/7_top_down_migration_after下创建两个目录,srcmlib

模块化计算器

  1. 在我们模块化所有依赖项之前,我们无法模块化计算器。但是,在某些情况下,模块化其依赖项可能更容易,而在其他情况下可能不那么容易,特别是在依赖项来自第三方的情况下。在这种情况下,我们使用自动模块。我们将非模块化的 JAR 复制到mlib文件夹中,并确保 JAR 的名称采用<identifier>(.<identifier>)*的形式,其中<identifier>是有效的 Java 标识符。
 $ cp ../7_top_down_migration_before/calculator/lib/jackson-
      annotations-
 2.8.4.jar mlib/jackson.annotations.jar 

 $ cp ../7_top_down_migration_before/calculator/lib/jackson-core-
      2.8.4.jar
 mlib/jackson.core.jar 

 $ cp ../7_top_down_migration_before/calculator/lib/jackson-
      databind-
 2.8.4.jar mlib/jackson.databind.jar 

 $ cp 
      ../7_top_down_migration_before/banking_util/out/banking.util.jar 
      mlib/ 

 $ cp ../7_top_down_migration_before/math_util/out/math.util.jar 
      mlib/

我们已经提供了copy-non-mod-jar.batcopy-non-mod-jar.sh脚本,以便轻松复制 jar 包。

让我们看看我们复制到mlib中的内容:

     $ ls mlib
      banking.util.jar  jackson.annotations.jar  jackson.core.jar 
 jackson.databind.jar  math.util.jar

banking.util.jarmath.util.jar只有在您已经在Chapter03/7_top_down_migration_before/banking_utilChapter03/7_top_down_migration_before/math_util目录中编译和打包了代码时才会存在。我们在准备工作部分中已经做过这个。我们在准备工作部分中已经做过这个。

  1. src下创建一个新的calculator文件夹。这将包含calculator模块的代码。

  2. Chapter03/7_top_down_migration_after/src/calculator目录下创建module-info.java,其中包含以下内容

        module calculator{ 
          requires math.util; 
          requires banking.util; 
          requires jackson.databind; 
          requires jackson.core; 
          requires jackson.annotations; 
        }
  1. Chapter03/7_top_down_migration_before/calculator/src/com目录及其下的所有代码复制到Chapter03/7_top_down_migration_after/src/calculator

  2. 编译 calculator 模块:

 #On Linux
 javac -d mods --module-path mlib --module-source-path src $(find
      src -name *.java)

 #On Windows
 javac -d mods --module-path mlib --module-source-path src 
      srccalculatormodule-info.java 
      srccalculatorcompacktcalculatorCalculator.java 
      srccalculatorcompacktcalculatorcommands*.java
  1. calculator模块创建模块化 JAR:
 jar --create --file=mlib/calculator.jar --main-
      class=com.packt.calculator.Calculator -C mods/calculator/ .
  1. 运行calculator模块:
 java --module-path mlib -m calculator

我们将看到我们的计算器是否正确执行。您可以尝试不同的操作来验证它们是否都正确执行。

模块化 banking.util

由于这不依赖于其他非模块化代码,我们可以通过以下步骤直接将其转换为模块:

  1. src下创建一个新的banking.util文件夹。这将包含banking.util模块的代码。

  2. Chapter03/7_top_down_migration_after/src/banking.util目录下创建module-info.java,其中包含以下内容:

        module banking.util{
          exports com.packt.banking; 
        }
  1. Chapter03/7_top_down_migration_before/banking_util/src/com目录及其下所有代码复制到Chapter03/7_top_down_migration_after/src/banking.util

  2. 编译模块:

 #On Linux
 javac -d mods --module-path mlib --module-source-path src $(find 
      src -name *.java)

 #On Windows
 javac -d mods --module-path mlib --module-source-path src 
      srcbanking.utilmodule-info.java 
      srcbanking.utilcompacktbankingBankUtil.java
  1. banking.util模块创建一个模块化的 JAR。这将替换mlib中已经存在的非模块化banking.util.jar
 jar --create --file=mlib/banking.util.jar -C mods/banking.util/ .
  1. 运行calculator模块,测试banking.util模块化 JAR 是否已成功创建:
 java --module-path mlib -m calculator
  1. 您应该看到计算器被执行。尝试不同的操作,以确保没有“找不到类”的问题。

模块化 math.util

  1. src下创建一个新的math.util文件夹。这将包含math.util模块的代码。

  2. Chapter03/7_top_down_migration_after/src/math.util目录下创建module-info.java,其中包含以下内容:

        module math.util{ 
          exports com.packt.math; 
        }
  1. Chapter03/7_top_down_migration_before/math_util/src/com目录及其下所有代码复制到Chapter03/7_top_down_migration_after/src/math.util

  2. 编译模块:

 #On Linux
 javac -d mods --module-path mlib --module-source-path src $(find 
      src -name *.java)

 #On Windows
 javac -d mods --module-path mlib --module-source-path src 
      srcmath.utilmodule-info.java 
      srcmath.utilcompacktmathMathUtil.java
  1. banking.util模块创建一个模块化的 JAR。这将替换mlib中已经存在的非模块化banking.util.jar
 jar --create --file=mlib/math.util.jar -C mods/math.util/ .
  1. 运行calculator模块,测试math.util模块化 JAR 是否已成功创建。
 java --module-path mlib -m calculator
  1. 您应该看到计算器被执行。尝试不同的操作,以确保没有找不到类的问题。

有了这个,我们已经完全模块化了应用程序,除了 Jackson 库,我们已经将其转换为自动模块。

我们更喜欢自上而下的迁移方法。这是因为我们不必同时处理类路径和模块路径。我们可以将所有内容都转换为自动模块,然后在将非模块化的 JAR 迁移到模块化 JAR 时使用模块路径。

使用服务来创建消费者和提供者模块之间的松耦合

通常,在我们的应用程序中,我们有一些接口和这些接口的多个实现。然后,在运行时,根据某些条件,我们使用特定的实现。这个原则叫做依赖反转。依赖注入框架(如 Spring)使用这个原则来创建具体实现的对象,并将其分配(或注入)到抽象接口类型的引用中。

很长一段时间以来,Java(自 Java 6 以来)一直支持通过java.util.ServiceLoader类进行服务提供者加载。使用 Service Loader,您可以有一个服务提供者接口(SPI)和 SPI 的多个实现,简称服务提供者。这些服务提供者位于类路径中,并在运行时加载。当这些服务提供者位于模块中时,由于我们不再依赖于类路径扫描来加载服务提供者,我们需要一种机制来告诉我们的模块有关服务提供者和服务提供者接口的机制,以及它提供实现的服务提供者。在这个配方中,我们将通过一个简单的例子来看一下这种机制。

准备工作

对于这个配方,我们没有特定的设置。在这个配方中,我们将举一个简单的例子。我们有一个BookService抽象类,支持 CRUD 操作。现在,这些 CRUD 操作可以在 SQL DB、MongoDB、文件系统等上工作。通过使用服务提供者接口和ServiceLoader类来加载所需的服务提供者实现,可以提供这种灵活性。

如何做...

我们在这个配方中有四个模块:

  • book.service:这是包含我们服务提供者接口的模块,也就是服务

  • mongodb.book.service:这是其中一个服务提供者模块

  • sqldb.book.service:这是另一个服务提供者模块

  • book.manage:这是服务消费者模块

以下步骤演示了如何利用ServiceLoader实现松耦合:

  1. Chapter03/8_services/src目录下创建一个名为book.service的文件夹。我们的book.service模块的所有代码将放在这个文件夹中。

  2. 创建一个新的包com.packt.model,并在新包下创建一个名为Book的新类。这是我们的模型类,包含以下属性:

        public String id; 
        public String title; 
        public String author;
  1. 创建一个新的包com.packt.service,并在新包下创建一个名为BookService的新类。这是我们的主要服务接口,服务提供者将为此服务提供实现。除了 CRUD 操作的抽象方法之外,值得一提的是getInstance()方法。该方法使用ServiceLoader类加载任何一个服务提供者(具体来说是最后一个),然后使用该服务提供者获取BookService的实现。让我们看一下以下代码:
        public static BookService getInstance(){ 
          ServiceLoader<BookServiceProvider> sl = 
                 ServiceLoader.load(BookServiceProvider.class);
          Iterator<BookServiceProvider> iter = sl.iterator(); 
          if (!iter.hasNext()) 
            throw new RuntimeException("No service providers found!");

          BookServiceProvider provider = null; 
          while(iter.hasNext()){ 
            provider = iter.next(); 
            System.out.println(provider.getClass()); 
          } 
          return provider.getBookService(); 
        }

第一个while循环只是为了演示ServiceLoader加载所有服务提供者,然后我们选择其中一个服务提供者。您也可以有条件地返回服务提供者,但这完全取决于要求。

  1. 另一个重要部分是实际的服务提供者接口。其责任是返回服务实现的适当实例。在我们的示例中,com.packt.spi包中的BookServiceProvider是一个服务提供者接口:
        public interface BookServiceProvider{ 
          public BookService getBookService(); 
        }
  1. 我们在Chapter03/8_services/src/book.service目录下创建module-info.java,其中包含以下内容:
        module book.service{ 
          exports com.packt.model; 
          exports com.packt.service; 
          exports com.packt.spi; 
          uses com.packt.spi.BookServiceProvider; 
        }

在前面的模块定义中,uses语句指定了模块使用ServiceLoader发现的服务接口。

  1. 现在让我们创建一个名为mongodb.book.service的服务提供者模块。这将在book.service模块中为BookServiceBookServiceProvider接口提供实现。我们的想法是,这个服务提供者将使用 MongoDB 数据存储实现 CRUD 操作。

  2. Chapter03/8_services/src目录下创建一个mongodb.book.service文件夹。

  3. com.packt.mongodb.service包中创建一个MongoDbBookService类,它继承了BookService抽象类,并提供了我们的抽象 CRUD 操作方法的实现:

        public void create(Book book){ 
          System.out.println("Mongodb Create book ... " + book.title); 
        } 

        public Book read(String id){ 
          System.out.println("Mongodb Reading book ... " + id); 
          return new Book(id, "Title", "Author"); 
        } 

        public void update(Book book){ 
          System.out.println("Mongodb Updating book ... " + 
              book.title); 
        }

        public void delete(String id){ 
          System.out.println("Mongodb Deleting ... " + id); 
        }
  1. com.packt.mongodb包中创建一个MongoDbBookServiceProvider类,它实现了BookServiceProvider接口。这是我们的服务发现类。基本上,它返回BookService实现的相关实例。它重写了BookServiceProvider接口中的方法,如下所示:
        @Override 
        public BookService getBookService(){ 
          return new MongoDbBookService(); 
        }
  1. 模块定义非常有趣。我们必须在模块定义中声明该模块是BookServiceProvider接口的服务提供者,可以这样做:
        module mongodb.book.service{ 
          requires book.service; 
          provides com.packt.spi.BookServiceProvider 
                   with com.packt.mongodb.MongoDbBookServiceProvider; 
        }

provides .. with ..语句用于指定服务接口和其中一个服务提供者。

  1. 现在让我们创建一个名为book.manage的服务使用者模块。

  2. Chapter03/8_services/src下创建一个新的book.manage文件夹,其中将包含模块的代码。

  3. com.packt.manage包中创建一个名为BookManager的新类。这个类的主要目的是获取BookService的实例,然后执行其 CRUD 操作。由ServiceLoader加载的服务提供者决定返回的实例。BookManager类大致如下:

        public class BookManager{ 
          public static void main(String[] args){ 
            BookService service = BookService.getInstance();
            System.out.println(service.getClass()); 
            Book book = new Book("1", "Title", "Author"); 
            service.create(book); 
            service.read("1"); 
            service.update(book); 
            service.delete("1"); 
          }
        }
  1. 通过以下命令编译和运行我们的主模块:
 $ javac -d mods --module-source-path src 
      $(find src -name *.java) 
 $ java --module-path mods -m 
      book.manage/com.packt.manage.BookManager 
 class com.packt.mongodb.MongoDbBookServiceProvider
 class com.packt.mongodb.service.MongoDbBookService
 Mongodb Create book ... Title
 Mongodb Reading book ... 1
 Mongodb Updating book ... Title
 Mongodb Deleting ... 1

在前面的输出中,第一行列出了可用的服务提供者,第二行列出了我们正在使用的BookService实现。

  1. 有了一个服务提供者,看起来很简单。让我们继续添加另一个模块sqldb.book.service,其模块定义如下:
        module sqldb.book.service{ 
          requires book.service; 
          provides com.packt.spi.BookServiceProvider 
                   with com.packt.sqldb.SqlDbBookServiceProvider; 
        }
  1. com.packt.sqldb包中的SqlDbBookServiceProvider类是BookServiceProvider接口的实现,如下所示:
        @Override 
        public BookService getBookService(){     
          return new SqlDbBookService(); 
        }
  1. CRUD 操作的实现由com.packt.sqldb.service包中的SqlDbBookService类完成。

  2. 让我们编译并运行主模块,这次使用两个服务提供程序:

 $ javac -d mods --module-source-path src 
      $(find src -name *.java) 
 $ java --module-path mods -m  
      book.manage/com.packt.manage.BookManager 
 class com.packt.sqldb.SqlDbBookServiceProvider
 class com.packt.mongodb.MongoDbBookServiceProvider
 class com.packt.mongodb.service.MongoDbBookService
 Mongodb Create book ... Title
 Mongodb Reading book ... 1
 Mongodb Updating book ... Title
 Mongodb Deleting ... 1

前两行打印出可用服务提供程序的类名,第三行打印出我们正在使用哪个BookService实现。

使用 jlink 创建自定义模块化运行时镜像

Java 有两种版本:

  • 仅 Java 运行时,也称为 JRE:支持 Java 应用程序的执行

  • 带有 Java 运行时的 Java 开发工具包,也称为 JDK:支持 Java 应用程序的开发和执行

除此之外,Java 8 引入了三个紧凑配置文件,旨在提供较小的占地面积的运行时,以便在嵌入式和较小的设备上运行,如下所示:

前面的图像显示了不同的配置文件和它们支持的功能。

Java 9 引入了一个名为jlink的新工具,它使得可以创建模块化运行时镜像。这些运行时镜像实际上是一组模块及其依赖项的集合。有一个名为 JEP 220 的 Java 增强提案,规定了这个运行时镜像的结构。

在这个示例中,我们将使用jlink创建一个运行时镜像,其中包括我们的math.utilbanking.utilcalculator模块,以及 Jackson 自动模块。

准备工作

创建一个简单的模块化应用程序的示例中,我们创建了一个简单的模块化应用程序,包括以下模块:

  • math.util

  • calculator:包括主类

我们将重用相同的模块和代码来演示jlink工具的使用。为了方便我们的读者,代码可以在Chapter03/9_jlink_modular_run_time_image中找到。

如何做...

  1. 让我们编译这些模块:
 $ javac -d mods --module-path mlib --module-source-path 
        src $(find src - name *.java)
  1. 让我们为所有模块创建模块化 JAR:
     $ jar --create --file mlib/math.util.jar -C mods/math.util . 

 $ jar --create --file=mlib/calculator.jar --main-
 class=com.packt.calculator.Calculator -C mods/calculator/ .
  1. 让我们使用jlink创建一个运行时镜像,其中包括calculatormath.util模块及其依赖项:
 $ jlink --module-path mlib:$JAVA_HOME/jmods --add-modules 
 calculator,math.util --output image --launcher 
 launch=calculator/com.packt.calculator.Calculator

运行时镜像在指定位置使用--output命令行选项创建。

  1. 在 image 目录下创建的运行时镜像包含bin目录等其他目录。这个bin目录包含一个名为calculator的 shell 脚本。这可以用来启动我们的应用程序。
    $ ./image/bin/launch 

 ************Advanced Calculator************
 1\. Prime Number check
 2\. Even Number check
 3\. Sum of N Primes
 4\. Sum of N Evens
 5\. Sum of N Odds
 6\. Exit
 Enter the number to choose operation

我们无法创建包含自动模块的模块的运行时镜像。如果 JAR 文件不是模块化的,或者没有module-info.classjlink会报错。

为旧平台版本编译

在某些时候,我们使用-source-target选项来创建 Java 构建。-source选项用于指示编译器接受的 Java 语言版本,-target选项用于指示类文件支持的版本。通常,我们忘记使用-source选项,默认情况下,javac会针对最新可用的 Java 版本进行编译。由于这个原因,有可能使用了新的 API,结果在目标版本上构建不会按预期运行。

为了克服提供两个不同命令行选项的混淆,Java 9 引入了一个新的命令行选项--release。这充当了-source-target-bootclasspath选项的替代。-bootclasspath用于提供给定版本的引导类文件的位置N

准备工作

我们创建了一个简单的模块,名为 demo,其中包含一个非常简单的名为CollectionsDemo的类,该类只是将一些值放入地图并对其进行迭代,如下所示:

public class CollectionsDemo{
  public static void main(String[] args){
    Map<String, String> map = new HashMap<>();
    map.put("key1", "value1");
    map.put("key2", "value3");
    map.put("key3", "value3");
    map.forEach((k,v) -> System.out.println(k + ", " + v));
  }
}

让我们编译并运行它以查看其输出:

$ javac -d mods --module-source-path src srcdemomodule-info.java srcdemocompacktCollectionsDemo.java
$ java --module-path mods -m demo/com.packt.CollectionsDemo

我们得到的输出如下:

key1, value1
key2, value3
key3, value3

现在让我们编译它以在 Java 8 上运行,然后在 Java 8 上运行它。

如何做...

  1. 由于较旧版本的 Java,即 Java 8 及之前,不支持模块,因此如果我们在较旧版本上进行编译,就必须摆脱module-info.java。这就是为什么我们在编译过程中没有包括module-info.java。我们使用以下代码进行编译:
 $ javac --release 8 -d mods srcdemocompacktCollectionsDemo.java

您可以看到我们使用了--release选项,针对 Java 8,而不是编译module-info.java

  1. 让我们创建一个 JAR 文件,因为这样可以更容易地传输 Java 构建,而不是复制所有类文件:
 $jar --create --file mlib/demo.jar --main-class 
      com.packt.CollectionsDemo -C mods/ .
  1. 让我们在 Java 9 上运行上述 JAR:
 $ java -version
 java version "9"
 Java(TM) SE Runtime Environment (build 9+179)
 Java HotSpot(TM) 64-Bit Server VM (build 9+179, mixed mode)

 $ java -jar mlib/demo.jar
 key1, value1
 key2, value3
 key3, value3
  1. 让我们在 Java 8 上运行这个 JAR:
 $ "%JAVA8_HOME%"binjava -version 
 java version "1.8.0_121"
 Java(TM) SE Runtime Environment (build 1.8.0_121-b13)
 Java HotSpot(TM) 64-Bit Server VM (build 25.121-b13, mixed mode)

 $ "%JAVA8_HOME%"binjava -jar mlibdemo.jar
 key1, value1
 key2, value3
 key3, value3

如果我们在构建 Java 9 时没有使用-release选项会怎样?我们也试试这个:

  1. 编译时不使用--release选项,并将生成的类文件创建为 JAR:
 $ javac -d mods srcdemocompacktCollectionsDemo.java 
 $ jar --create --file mlib/demo.jar --main-class 
      com.packt.CollectionsDemo -C mods/ .
  1. 让我们在 Java 9 上运行这个 JAR:
 $ java -jar mlib/demo.jar 
 key1, value1
 key2, value3
 key3, value3

它按预期工作。

  1. 让我们在 Java 8 上运行这个 JAR:
 $ "%JAVA8_HOME%"binjava -version
 java version "1.8.0_121"
 Java(TM) SE Runtime Environment (build 1.8.0_121-b13)
 Java HotSpot(TM) 64-Bit Server VM (build 25.121-b13, mixed mode)

输出如下:

$ java -jar mlibdemo.jar

Exception in thread "main" java.lang.UnsupportedClassVersionError:

com/packt/CollectionsDemo has been compiled by a more recent version of the Java Runtime (class file version 53.0), this version of the Java Runtime only recognizes class file versions up to 52.0

它清楚地说明了类文件版本不匹配。因为它是为 Java 9(版本 53.0)编译的,所以在 Java 8(版本 52.0)上无法运行。

它的工作原理...

编译到较旧版本所需的数据存储在$JDK_ROOT/lib/ct.sym文件中。此信息被--release选项用于定位bootclasspathct.sym文件是一个 ZIP 文件,其中包含与目标平台版本的类文件对应的剥离类文件(直接从openjdk.java.net/jeps/247复制)。

创建多版本 JAR

在 Java 9 之前,对于库的开发人员来说,要采用语言中引入的新功能而不发布新的库版本是很困难的。但是在 Java 9 中,多版本 JAR 提供了这样一个功能,可以在使用更高版本的 Java 时捆绑某些类文件以运行。

在这个示例中,我们将向您展示如何创建这样一个多版本 JAR。

如何做到...

  1. 为 Java 8 平台创建所需的 Java 代码。我们将在src8compackt目录中添加两个类,CollectionUtil.javaFactoryDemo.java
        public class CollectionUtil{
          public static List<String> list(String ... args){
            System.out.println("Using Arrays.asList");
            return Arrays.asList(args);
          }

          public static Set<String> set(String ... args){
            System.out.println("Using Arrays.asList and set.addAll");
            Set<String> set = new HashSet<>();
            set.addAll(list(args));
            return set;
          }
        }

        public class FactoryDemo{
          public static void main(String[] args){
            System.out.println(CollectionUtil.list("element1", 
                       "element2", "element3"));
            System.out.println(CollectionUtil.set("element1", 
                       "element2", "element3"));
          }
        }
  1. 我们希望使用在 Java 9 中引入的Collection工厂方法。因此,我们将在src下创建另一个子目录,将我们的与 Java 9 相关的代码放在其中:src9compackt。在这里,我们将添加另一个CollectionUtil类:
        public class CollectionUtil{
          public static List<String> list(String ... args){
            System.out.println("Using factory methods");
            return List.of(args);
          }
          public static Set<String> set(String ... args){
            System.out.println("Using factory methods");
            return Set.of(args);
          }
        }
  1. 上述代码使用了 Java 9 集合工厂方法。使用以下命令编译源代码:
 javac -d mods --release 8 src8compackt*.java
      javac -d mods9 --release 9 src9compackt*.java

注意使用--release选项为不同的 Java 版本编译代码。

  1. 现在让我们创建多版本 JAR:
 jar --create --file mr.jar --main-class=com.packt.FactoryDemo 
      -C mods . --release 9 -C mods9 .

在创建 JAR 时,我们还提到,当在 Java 9 上运行时,我们使用了 Java 9 特定的代码。

  1. 我们将在 Java 9 上运行mr.jar
 java -jar mr.jar
 [element1, element2, element3]
 Using factory methods
 [element2, element3, element1]
  1. 我们将在 Java 8 上运行mr.jar
      #Linux
 $ /usr/lib/jdk1.8.0_144/bin/java -version
 java version "1.8.0_144"
 Java(TM) SE Runtime Environment (build 1.8.0_144-b01)
 Java HotSpot(TM) 64-Bit Server VM (build 25.144-b01, mixed mode)
 $ /usr/lib/jdk1.8.0_144/bin/java -jar mr.jar
 Using Arrays.asList
 [element1, element2, element3]
 Using Arrays.asList and set.addAll
 Using Arrays.asList
 [element1, element2, element3]

 #Windows
 $ "%JAVA8_HOME%"binjava -version 
 java version "1.8.0_121"
 Java(TM) SE Runtime Environment (build 1.8.0_121-b13)
 Java HotSpot(TM) 64-Bit Server VM (build 25.121-b13, mixed mode)
 $ "%JAVA8_HOME%"binjava -jar mr.jar
 Using Arrays.asList
 [element1, element2, element3]
 Using Arrays.asList and set.addAll
 Using Arrays.asList
 [element1, element2, element3]

它的工作原理...

让我们看看mr.jar中内容的布局:

jar -tvf mr.jar

JAR 的内容如下:

在上述布局中,我们有META-INF/versions/9,其中包含 Java 9 特定的代码。另一个重要的事情是注意META-INF/MANIFEST.MF文件的内容。让我们提取 JAR 并查看其内容:

jar -xvf mr.jar

$ cat META-INF/MANIFEST.MF
Manifest-Version: 1.0
Created-By: 9 (Oracle Corporation)
Main-Class: com.packt.FactoryDemo
Multi-Release: true

新的Multi-Release清单属性用于指示 JAR 是否是多版本 JAR。

使用 Maven 开发模块化应用程序

在这个示例中,我们将使用 Maven,Java 生态系统中最流行的构建工具,开发一个简单的模块化应用程序。我们将在本章的服务示例中介绍的想法。

准备工作

我们的示例中有以下模块:

  • book.manage:这是与数据源交互的主模块

  • book.service:这是包含服务提供者接口的模块

  • mongodb.book.service:这是为服务提供者接口提供实现的模块

  • sqldb.book.service:这是为服务提供者接口提供另一个实现的模块

在本示例中,我们将创建一个 maven 项目,并将之前的 JDK 模块作为 maven 模块包含进来。让我们开始吧。

如何做...

  1. 创建一个包含所有模块的文件夹。我们称之为12_services_using_maven,具有以下文件夹结构:
      12_services_using_maven
 |---book-manage
 |---book-service
 |---mongodb-book-service
 |---sqldb-book-service
 |---pom.xml
  1. 父级的pom.xml如下:
        <?xml version="1.0" encoding="UTF-8"?>
        <project 

         xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 
         http://maven.apache.org/xsd/maven-4.0.0.xsd">
          <modelVersion>4.0.0</modelVersion>
          <groupId>com.packt</groupId>
          <artifactId>services_using_maven</artifactId>
          <version>1.0</version>
          <packaging>pom</packaging>
          <modules>
            <module>book-service</module>
            <module>mongodb-book-service</module>
            <module>sqldb-book-service</module>
            <module>book-manage</module>
          </modules>
          <build>
            <plugins>
              <plugin>
                <groupId>org.apache.maven.plugins</groupId>
                <artifactId>maven-compiler-plugin</artifactId>
                <version>3.6.1</version>
                <configuration>
                  <source>9</source>
                  <target>9</target>
                  <showWarnings>true</showWarnings>
                  <showDeprecation>true</showDeprecation>
                </configuration>
              </plugin>
            </plugins>
          </build>
        </project>
  1. 让我们按照以下方式创建book-service Maven 模块的结构:
 book-service
 |---pom.xml
 |---src
 |---main
 |---book.service
 |---module-info.java
 |---com
 |---packt
 |---model
 |---Book.java
 |---service
 |---BookService.java
 |---spi
 |---BookServiceProvider.java
  1. book-service Maven 模块的pom.xml内容如下:
        <?xml version="1.0" encoding="UTF-8"?>
        <project 

        xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 
        http://maven.apache.org/xsd/maven-4.0.0.xsd">
          <modelVersion>4.0.0</modelVersion>
          <parent>
            <groupId>com.packt</groupId>
            <artifactId>services_using_maven</artifactId>
            <version>1.0</version>
          </parent>
          <artifactId>book-service</artifactId>
          <version>1.0</version>
          <build>
            <sourceDirectory>src/main/book.service</sourceDirectory>
          </build>
        </project>
  1. 这是module-info.java
        module book.service{
          exports com.packt.model;
          exports com.packt.service;
          exports com.packt.spi;
          uses com.packt.spi.BookServiceProvider;
       }
  1. 这是Book.java
       public class Book{
         public Book(String id, String title, String author){
           this.id = id;
           this.title = title
           this.author = author;
         }
         public String id;
         public String title;
         public String author;
       }
  1. 这是BookService.java
 public abstract class BookService{
 public abstract void create(Book book); 
 public abstract Book read(String id); 
 public abstract void update(Book book); 
 public abstract void delete(String id);
 public static BookService getInstance(){ 
   ServiceLoader<BookServiceProvider> sl =     
        ServiceLoader.load(BookServiceProvider.class);          
   Iterator<BookServiceProvider> iter = sl.iterator();        
   if (!iter.hasNext())
      throw new RuntimeException("No service providers found!");              
   BookServiceProvider provider = null;        
   while(iter.hasNext()){
       provider = iter.next();
       System.out.println(provider.getClass());        
   }        
   return provider.getBookService(); 
   }
 }
  1. 这是BookServiceProvider.java
        public interface BookServiceProvider{
          public BookService getBookService();
        }

同样,我们定义了另外三个 Maven 模块,mongodb-book-servicesqldb-book-servicebook-manager。此代码可以在Chapter03/12_services_using_maven找到。

我们可以使用以下命令编译类并构建所需的 JAR 文件:

mvn clean install

我们提供了run-with-mongo.*来使用mongodb-book-service作为服务提供者实现,以及run-with-sqldb.*来使用sqldb-book-service作为服务提供者实现。

这个示例的完整代码可以在Chapter03/12_services_using_maven找到。

使您的库模块路径友好

要使应用程序完全模块化,它应该自身模块化以及其依赖项。现在,使第三方模块化不在应用程序开发人员的手中。一种方法是将第三方jar包含在模块路径中,并使用jar的名称作为模块的名称来声明依赖关系。在这种情况下,jar将成为自动模块。这是可以的,但通常jar的名称不符合模块名称的规范。在这种情况下,我们可以利用 JDK 9 中添加的另一种支持,其中可以在jarMANIFEST.mf文件中定义jar的名称,库使用者可以声明对定义名称的依赖关系。这样,将来,库开发人员可以将他们的库模块化,同时仍然使用相同的模块名称。

在这个示例中,我们将向您展示如何为从非模块化jar创建的自动模块提供名称。首先,我们将向您展示如何使用 maven 实现这一点,然后在更多内容部分中,我们将看到如何在不使用任何构建工具的情况下创建一个 JAR。

准备工作

您至少需要 JDK 9 来运行这个示例,但我们将在 Maven 构建插件中使用 JDK 11。您还需要安装 Maven 才能使用它。您可以在互联网上搜索 Maven 的安装过程。

如何做...

  1. 使用 Maven 生成一个空项目:
mvn archetype:generate -DgroupId=com.packt.banking -DartifactId=13_automatic_module -DarchetypeArtifactId=maven-archetype-quickstart -DinteractiveMode=false
  1. 通过复制以下依赖项,更新位于13_automatic_module目录中的pom.xml文件中的依赖项:
<dependencies>
  <dependency>
    <groupId>junit</groupId>
    <artifactId>junit</artifactId>
    <version>4.12</version>
    <scope>test</scope>
  </dependency>
  <dependency>
    <groupId>org.assertj</groupId>
    <artifactId>assertj-core</artifactId>
    <version>3.10.0</version>
    <scope>test</scope>
  </dependency>
</dependencies>
  1. 我们需要配置maven-compiler-plugin以便能够编译 JDK 11。因此,我们将在<dependencies></dependencies>之后添加以下插件配置:
<build>
  <plugins>
    <plugin>
      <groupId>org.apache.maven.plugins</groupId>
      <artifactId>maven-compiler-plugin</artifactId>
      <version>3.6.1</version>
      <configuration>
        <source>11</source>
        <target>11</target>
        <showWarnings>true</showWarnings>
        <showDeprecation>true</showDeprecation>
      </configuration>
    </plugin>
  </plugins>
</build>
  1. 配置maven-jar-plugin,通过在新的<Automatic-Module-Name>标签中提供名称来提供自动模块名称,如下所示:
<plugin>
  <groupId>org.apache.maven.plugins</groupId>
  <artifactId>maven-jar-plugin</artifactId>
  <configuration>
    <archive>
      <manifestEntries>
        <Automatic-Module-Name>com.packt.banking</Automatic-Module-
         Name>
      </manifestEntries>
    </archive>
  </configuration>
</plugin>
  1. 我们将在com.packt.banking.Banking类中添加一个用于计算简单利息的 API,如下所示:
public class Banking {
  public static Double simpleInterest(Double principal, 
                            Double rateOfInterest, Integer years){
    Objects.requireNonNull(principal, "Principal cannot be null");
    Objects.requireNonNull(rateOfInterest,  
                               "Rate of interest cannot be null");
    Objects.requireNonNull(years, "Years cannot be null");
    return ( principal * rateOfInterest * years ) / 100;
  }
}
  1. 我们还添加了一个测试,您可以在本章下载的代码中的Chapter03\13_automatic_module\src\test\java\com\packt\banking找到。让我们运行mvn package命令来构建一个 JAR。如果一切顺利,您将看到以下内容:

  1. 您可以使用任何压缩实用程序,例如 7z,来查看 JAR 的内容,特别是Manifest.MF文件,其内容如下:
Manifest-Version: 1.0
Archiver-Version: Plexus Archiver
Created-By: Apache Maven 3.3.9
Built-By: sanaulla
Build-Jdk: 11-ea
Automatic-Module-Name: com.packt.banking

这些步骤的代码可以在Chapter03\13_automatic_module找到。

它是如何工作的...

到目前为止,我们已经创建了一个具有自动模块名称的 Java 库 JAR。现在,让我们看看如何在模块化应用程序中将这个非模块化 JAR 用作自动模块。这个示例的完整代码可以在Chapter03\13_using_automatic_module找到。

让我们将在How to do it...部分创建的jar文件复制到13_automatic_module\target\13_automatic_module-1.0.jar中,然后放入13_using_automatic_module\mods文件夹中。这样我们即可让即将创建的模块化应用程序使用随jar一起提供的com.packt.banking模块。

复制 jar 文件后,我们需要为我们的模块创建模块定义,并在module-info.java中声明其依赖项,放置在13_using_automatic_module\src\banking.demo中:

module banking.demo{
    requires com.packt.banking;
}

接下来是创建main com.packt.demo.BankingDemo类,它将使用银行工具。创建路径为13_using_automatic_module\src\banking.demo\com\packt\demo,如下所示:

package com.packt.demo;
import com.packt.banking.Banking;
public class BankingDemo{
  public static void main(String[] args) {
    Double principal = 1000.0;
    Double rateOfInterest = 10.0;
    Integer years = 2;
    Double simpleInterest = Banking.simpleInterest(principal, 
                                      rateOfInterest, years);
        System.out.println("The simple interest is: " + 
                                             simpleInterest);
    }
}

我们可以通过使用从13_using_automatic_module执行的以下命令来编译前面的代码:

javac -d mods -p mods --module-source-path src src\banking.demo\*.java src\banking.demo\com\packt\demo\*.java

然后通过使用从相同位置执行的以下命令来运行前面的代码:

java --module-path mods -m banking.demo/com.packt.demo.BankingDemo

您将看到以下输出:

The simple interest is: 200.0

注意:您可以使用run.batrun.sh脚本来编译和运行代码。

因此,通过这样做,我们有:

  • 创建一个非模块化的 JAR,并自动命名模块。

  • 通过使用其自动模块名称声明对其的依赖,将非模块化的 JAR 用作自动模块。

您还将看到,我们已完全删除了对类路径的使用,而仅使用模块路径;这是我们迈向完全模块化应用程序的第一步。

还有更多...

我们将向您展示如何创建您的银行实用程序的 JAR,以及自动模块名称(如果您不使用 Maven)。此代码可以在Chapter03\13_automatic_module_no_maven中找到。我们仍将把Banking .java复制到13_automatic_module_no_maven\src\com\packt\banking目录中。

接下来,我们需要定义一个包含以下自动模块名称的manifest.mf清单文件:

Automatic-Module-Name: com.packt.banking

我们可以通过从Chapter03\13_automatic_module_no_maven发出以下命令来编译前面的类:

javac -d classes src/com/packt/banking/*.java

然后通过从相同位置发出以下命令来构建一个jar

jar cvfm banking-1.0.jar manifest.mf -C classes .

我们还提供了用于创建您的jar的脚本。您可以使用build-jar.batbuild-jar.sh来编译和创建jar。现在,您可以将banking-1.0.jar复制到Chapter03\13_using_automatic_module\mods并替换13_automati_module-1.0.jar。然后,使用run.batrun.sh脚本在Chapter03\13_using_automatic_module中运行代码,具体取决于您的平台。您仍将看到与上一节相同的输出。

如何为反射打开模块

模块系统引入了严格的封装,如果类没有明确允许反射,则其私有成员不能通过反射访问。大多数库,如 hibernate 和 Jackson,依赖于反射来实现其目的。模块系统提供的严格封装将立即破坏这些库在新的 JDK 9 及更高版本上的运行。

为了支持这样重要的库,Java 团队决定引入功能,模块开发人员可以声明一些包或完整包,以便通过反射进行检查。在本教程中,我们将看看如何确切地实现这一点。

准备工作

您需要安装 JDK 9 或更高版本。在本教程中,我们将使用 Jackson API,其jar文件可以在本书的代码下载的Chapter03/14_open_module_for_rflxn/mods中找到。这些jar文件很重要,因为我们将使用 Jackson API 从 Java 对象创建 JSON 字符串。这些 Jackson API 将被用作自动模块。

如何做...

  1. 14_open_module_for_rflxn/src/demo/com/packt/demo中创建一个Person类,定义如下:
package com.packt.demo;

import java.time.LocalDate;

public class Person{
    public Person(String firstName, String lastName, 
        LocalDate dob, String placeOfBirth){
        this.firstName = firstName;
        this.lastName = lastName;
        this.dob = dob;
        this.placeOfBirth = placeOfBirth;
    }
    public final String firstName;
    public final String lastName;
    public final LocalDate dob;
    public final String placeOfBirth;
}
  1. 创建一个OpenModuleDemo类,该类创建一个Person类的实例,并使用com.fasterxml.jackson.databind.ObjectMapper将其序列化为 JSON。新日期时间 API 的序列化需要对ObjectMapper实例进行一些配置更改,这也在静态初始化块中完成,如下所示:
package com.packt.demo;

import java.time.LocalDate;

import com.fasterxml.jackson.databind.ObjectMapper;
import com.fasterxml.jackson.databind.SerializationFeature;
import com.fasterxml.jackson.datatype.jsr310.JavaTimeModule;

public class OpenModuleDemo{
    final static ObjectMapper MAPPER = new ObjectMapper();
    static{
        MAPPER.registerModule(new JavaTimeModule());
        MAPPER.configure(SerializationFeature.
                         WRITE_DATES_AS_TIMESTAMPS, false);
    }
    public static void main(String[] args) 
        throws Exception {
        Person p = new Person("Mohamed", "Sanaulla", 
            LocalDate.now().minusYears(30), "India");
        String json = MAPPER.writeValueAsString(p);
        System.out.println("The Json for Person is: ");
        System.out.println(json);
    }
}
  1. 14_open_module_for_rflxn/src/demo中创建module-info.java,它声明了模块的名称、其依赖关系,以及另一个有趣的东西叫做opensopens是允许外部库进行反射的解决方案,如下所示:
module demo{
    requires com.fasterxml.jackson.annotation;
    requires com.fasterxml.jackson.core;
    requires com.fasterxml.jackson.databind;
    requires com.fasterxml.jackson.datatype.jsr310;
    opens com.packt.demo;
}

它是如何工作的...

有两种方法可以打开模块以供反射检查:

  • 在模块级别上声明开放:
open module demo { }
  • 在单个包级别上声明开放:
module demo { 
    opens com.packt.demo;
}

后者比前者更加严格(即只能使一个包对反射可用)。还有另一种方法可以实现这一点,那就是将特定包导出到正确的 Jackson 包,如下所示:

module demo{
   exports com.packt.demo to <relevant Jackson package here>
}

第四章:进行函数式编程

本章介绍了一种称为函数式编程的编程范式,以及它在 Java 11 中的适用性。我们将涵盖以下内容:

  • 使用标准功能接口

  • 创建函数式接口

  • 理解 lambda 表达式

  • 使用 lambda 表达式

  • 使用方法引用

  • 在程序中利用 lambda 表达式

介绍

函数式编程是将某个功能作为对象对待,并将其作为方法的参数或返回值传递的能力。这个特性存在于许多编程语言中,Java 在 Java 8 发布时获得了这个特性。

它避免了创建类、对象和管理对象状态。函数的结果仅取决于输入数据,无论调用多少次。这种风格使结果更可预测,这是函数式编程最吸引人的方面。

它也让我们能够通过将并行性的责任从客户端代码转移到库中,来改进 Java 中的并行编程能力。在此之前,为了处理 Java 集合的元素,客户端代码必须从集合中获取迭代器并组织集合的处理。

Java 集合的一些默认方法接受一个函数(函数式接口的实现)作为参数,然后将其应用于集合的每个元素。因此,库的责任是组织处理。一个例子是在每个 Iterable 接口中都可用的 forEach(Consumer)方法,其中 Consumer 是一个函数式接口。另一个例子是在每个 Collection 接口中都可用的 removeIf(Predicate)方法,其中 Predicate 也是一个函数式接口。此外,List 接口中添加了 sort(Comparator)和 replaceAll(UnaryOperator)方法,Map 中添加了 compute()方法。

Lambda 表达式利用函数式接口,并显著简化了它们的实现,使代码更短、更清晰、更具表现力。

在本章中,我们将讨论函数式编程的优势,定义和解释函数式接口和 lambda 表达式,并在代码示例中演示所有相关功能。

使函数成为语言的一等公民为 Java 增加了更多的功能。但利用这种语言能力需要——对于尚未接触函数式编程的人来说——一种新的思维方式和代码组织方式。

解释这一新特性并分享使用它的最佳实践是本章的目的。

使用标准功能接口

在这个示例中,您将学习什么是函数式接口,以及为什么它被添加到 Java 中,以及 JDK 8 中附带的标准 Java 库中的 43 个可用的函数式接口。

没有函数式接口,将功能传递到方法的唯一方法是通过编写一个类,创建其对象,然后将其作为参数传递。但即使是最不涉及的样式——使用匿名类——也需要编写太多的代码。使用函数式接口有助于避免所有这些。

准备工作

任何具有一个且仅有一个抽象方法的接口都被称为函数式接口。为了避免运行时错误,可以在接口前面添加@FunctionalInterface 注解。它告诉编译器意图,因此编译器可以检查该接口中是否实际上有一个抽象方法,包括从其他接口继承的方法。

在前几章的演示代码中,我们已经有了一个函数式接口的示例,即使我们没有将其注释为函数式接口。

public interface SpeedModel {
  double getSpeedMph(double timeSec, int weightPounds, int horsePower);
  enum DrivingCondition {
    ROAD_CONDITION,
    TIRE_CONDITION
  }
  enum RoadCondition {
    //...
  }
  enum TireCondition {
    //...
  }
}

enum类型的存在或任何实现的(默认或静态)方法并不会使其成为非功能接口。只有抽象(未实现)方法才算。因此,这也是一个功能接口的例子:

public interface Vehicle {
  void setSpeedModel(SpeedModel speedModel);
  default double getSpeedMph(double timeSec){ return -1; };
  default int getWeightPounds(){ return -1; }
  default int getWeightKg(){ 
    return convertPoundsToKg(getWeightPounds());
  }
  private int convertPoundsToKg(int pounds){
    return (int) Math.round(0.454 * pounds);
  }
  static int convertKgToPounds(int kilograms){
    return (int) Math.round(2.205 * kilograms);
  }
}

回顾您在第二章中已经学到的关于接口的默认方法,getWeightPounds()方法在被getWeightKg()调用或直接调用时将返回-1,使用实现Vehicle接口的类的对象。但是,只有在类中未实现getWeightPounds()方法时才会如此。否则,将使用类的实现并返回不同的值。

除了默认和静态接口方法,功能接口还可以包括java.lang.Object基类的任何和所有抽象方法。在 Java 中,每个对象都提供了java.lang.Object方法的默认实现,因此编译器和 Java 运行时会忽略这样的抽象方法。

例如,这也是一个功能接口:

public interface SpeedModel {
  double getSpeedMph(double timeSec, int weightPounds, int horsePower);
  boolean equals(Object obj);
  String toString();
}

以下虽然不是功能接口:

public interface Car extends Vehicle {
   int getPassengersCount();
}

这是因为Car接口有两个抽象方法——它自己的getPassengersCount()方法和从Vehicle接口继承的setSpeedModel(SpeedModel speedModel)方法。

我们可以尝试将@FunctionalInterface注解添加到Car接口中:

@FunctionalInterface 
public interface Car extends Vehicle {
   int getPassengersCount();
}

如果我们这样做,编译器将生成以下错误:

使用@FunctionalInterface注解不仅有助于在编译时捕获错误,而且还确保了程序员之间设计意图的可靠沟通。它可以帮助您或其他程序员记住该接口不能有多个抽象方法,这在已经存在依赖于这种假设的一些代码时尤其重要。

出于同样的原因,RunnableCallable接口(它们自 Java 早期版本以来就存在)在 Java 8 中被注释为@FunctionalInterface,以明确区分:

@FunctionalInterface
interface Runnable { void run(); }

@FunctionalInterface
interface Callable<V> { V call() throws Exception; }

如何做…

在创建自己的功能接口之前,首先考虑使用java.util.function包中提供的 43 个功能接口中的一个。它们中的大多数是FunctionConsumerSupplierPredicate接口的特殊化。

以下是您可以遵循的步骤,以熟悉功能接口:

  1. 看看Function<T,R>功能接口:
        @FunctionalInterface
        public interface Function<T,R>

<T,R>泛型中可以看出,该接口的唯一方法接受T类型的参数并返回R类型的值。根据 JavaDoc,该接口具有R apply(T t)方法。我们可以使用匿名类创建该接口的实现:

      Function<Integer, Double> ourFunc = 
          new Function<Integer, Double>() {
              public Double apply(Integer i){
                  return i * 10.0;
              }
          };

我们的实现中的R apply(T t)方法接受Integer类型的值(或将自动装箱的int原始类型),将其乘以10,并返回Double类型的值,以便我们可以如下使用我们的新函数:

        System.out.println(ourFunc.apply(1));  //prints: 10

在下面的理解 lambda 表达式的示例中,我们将介绍 lambda 表达式,并向您展示它的使用方式如何使实现变得更短。但现在,我们将继续使用匿名类。

  1. 看看Consumer<T>功能接口。名称帮助我们记住该接口的方法接受一个值,但不返回任何东西——它只消耗。它的唯一方法是void accept(T)。该接口的实现可以如下所示:
        Consumer<String> ourConsumer = new Consumer<String>() {
          public void accept(String s) {
            System.out.println("The " + s + " is consumed.");
          }
        };

我们的实现中的void accept(T t)方法接收String类型的值并打印它。例如,我们可以这样使用它:

          ourConsumer.accept("Hello!");  
                        //prints: The Hello! is consumed.
  1. 看看Supplier<T>功能接口。名称帮助您记住该接口的方法不接受任何值,但确实返回一些东西——只提供。它的唯一方法是T get()。基于此,我们可以创建一个函数:
        Supplier<String> ourSupplier = new Supplier<String>() {
          public String get() {
            String res = "Success";
            //Do something and return result—Success or Error.
            return res;
          }
        };

我们的实现中的T get()方法执行某些操作,然后返回String类型的值,因此我们可以编写以下内容:

        System.out.println(ourSupplier.get());   //prints: Success
  1. 看一下Predicate<T>函数接口。名称有助于记住该接口的方法返回一个布尔值——它预测某些东西。它的唯一方法是boolean test(T t),这意味着我们可以创建以下函数:
        Predicate<Double> ourPredicate = new Predicate<Double>() {
          public boolean test(Double num) {
            System.out.println("Test if " + num + 
                               " is smaller than 20");
            return num < 20;
          }
        };

我们的实现的boolean test(T t)方法接受Double类型的值作为参数,并返回boolean类型的值,因此我们可以这样使用它:

        System.out.println(ourPredicate.test(10.0) ? 
                           "10 is smaller" : "10 is bigger");

这将产生以下结果:

  1. 查看java.util.function包中的其他 39 个函数接口。请注意,它们是我们已经讨论过的四个接口的变体。这些变体是为以下原因而创建的:
    • 为了通过显式使用intdoublelong原始类型来避免自动装箱和拆箱而获得更好的性能
  • 接受两个输入参数

  • 更短的表示法

以下函数接口只是 39 个接口列表中的几个示例。

IntFunction<R>函数接口具有R apply(int i)抽象方法。它提供了更短的表示法(不带参数类型的泛型)并避免了自动装箱(通过将int原始类型定义为参数)。以下是其用法示例:

        IntFunction<String> iFunc = new IntFunction<String>() {
          public String apply(int i) {
            return String.valueOf(i * 10);
          }
        };
        System.out.println(iFunc.apply(1));    //prints: 10

BiFunction<T,U,R>函数接口具有抽象方法R apply(T,U)。以下是其实现示例:

        BiFunction<String, Integer, Double> biFunc = 
                         new BiFunction<String, Integer, Double >() {
           public Double apply(String s, Integer i) {
             return (s.length() * 10d) / i;
           }
        };
        System.out.println(biFunc.apply("abc", 2)); //prints: 15.0

BinaryOperator<T>函数接口具有一个抽象方法T apply(T,T)。它通过避免重复三次相同类型提供了更短的表示法。以下是其用法示例:

       BinaryOperator<Integer> function = new BinaryOperator<Integer>(){
           public Integer apply(Integer i, Integer j) {
             return i >= j ? i : j;
           }
       };
        System.out.println(binfunc.apply(1, 2));     //prints: 2

IntBinaryOperator函数接口具有int applyAsInt(int,int)抽象方法。我们可以使用它来复制前面示例中的相同功能:

        IntBinaryOperator intBiFunc = new IntBinaryOperator(){
            public int applyAsInt(int i, int j) {
                return i >= j ? i : j;
            }
        };
        System.out.println(intBiFunc.applyAsInt(1, 2)); //prints: 2

接下来的食谱将提供更多此类专业化用法示例。

它的工作原理...

我们可以仅使用函数来组成整个方法:

void calculate(Supplier<Integer> source, 
  Function<Integer, Double> process, Predicate<Double> condition,
              Consumer<Double> success, Consumer<Double> failure){
    int i = source.get();
    double res = process.apply(i);
    if(condition.test(res)){
        success.accept(res);
    } else {
        failure.accept(res);
    }
}

前面的代码从源中获取值,处理它,然后根据提供的函数决定结果是否成功。现在,让我们创建这些函数并调用该方法。我们决定源参数如下:

Supplier<Integer> source = new Supplier<Integer>() {
    public Integer get() {
        Integer res = 42;
        //Do something and return result value
        return res;
    }
};

在实际代码中,此函数可以从数据库或任何其他数据源中提取数据。我们保持简单——使用硬编码的返回值——以获得可预测的结果。

处理函数和谓词将保持与以前相同的方式:

Function<Integer, Double> process = new Function<Integer, Double>(){
    public Double apply(Integer i){
        return i * 10.0;
    }
};
Predicate<Double> condition = new Predicate<Double>() {
    public boolean test(Double num) {
        System.out.println("Test if " + num + 
                                    " is smaller than " + 20);
        return num < 20;
    }
}; 

消费者几乎相同,只是在打印结果之前有不同的前缀:

Consumer<Double> success = new Consumer<Double>() {
    public void accept(Double d) {
        System.out.println("Success: " + d);
    }
};
Consumer<Double> failure = new Consumer<Double>() {
    public void accept(Double d) {
        System.out.println("Failure: " + d);
    }
};

现在我们可以调用 calculate 方法,如下所示:

calculate(source, process, condition, success, failure);

结果将如下所示:

Test if 420.0 is smaller than 20.0
Failure: 420.0

如果我们需要快速测试源值和谓词条件的各种组合,我们可以创建testSourceAndCondition(int src, int limit)方法,如下所示:

void testSourceAndCondition(int src, double condition) {
    Supplier<Integer> source = new Supplier<Integer>() {
        public Integer get() {
            Integer res = src;
            //Do something and return result value
            return res;
        }
    };
    Function<Integer, Double> process = 
      new Function<Integer, Double>() {
         public Double apply(Integer i){
            return i * 10.0;
         }
      };
    Predicate<Double> condition = new Predicate<Double>() {
        public boolean test(Double num) {
            System.out.println("Test if " + num + 
                                " is smaller than " + limit);
            return num < limit;
        }
    };
    Consumer<Double> success = new Consumer<Double>() {
        public void accept(Double d) {
            System.out.println("Success: " + d);
        }
    };
    Consumer<Double> failure = new Consumer<Double>() {
        public void accept(Double d) {
            System.out.println("Failure: " + d);
        }
    };
    calculate(source, process, cond, success, failure);
}

注意我们如何将src值传递给source供应商,将limit值传递给condition谓词。现在,我们可以运行testSourceAndCondition(int src, int limit)方法,使用不同的输入值寻找src值和limit值的组合,以获得成功:

testSourceAndCondition(10, 20);
testSourceAndCondition(1, 20);
testSourceAndCondition(10, 200);

结果将如下所示:

Test if 100.0 is smaller than 20.0
Failure: 100.0
Test if 10.0 is smaller than 20.0
Success: 10.0
Test if 100.0 is smaller than 200.0
Success: 100.0

还有更多...

java.util.function包中的许多函数接口都具有默认方法,不仅增强了它们的功能,还允许您将函数链接在一起,并将一个函数的结果作为输入参数传递给另一个函数。例如,我们可以使用Function<T,V> andThen(Function<R,V> after)接口的默认方法:

Function<Integer, Double> before = new Function<Integer, Double>(){
    public Double apply(Integer i){
        return i * 10.0;
    }
};
Function<Double, Double> after = new Function<Double, Double>(){
    public Double apply(Double d){
        return d + 10.0;
    }
};
Function<Integer, Double> process = before.andThen(after);

如您所见,我们的process函数现在是我们原始函数(将源值乘以 10.0)和一个新函数after的组合,该函数将 10.0 添加到第一个函数的结果中。如果我们调用testSourceAndCondition(int source, int condition)方法,如testSourceAndCondition(42, 20),结果将如下所示:

Test if 430.0 is smaller than 20
Failure: 430.0

Supplier<T>接口没有允许我们链接多个函数的方法,但Predicate<T>接口有and(Predicate<T> other)or(Predicate<T> other)默认方法,允许我们构造更复杂的布尔表达式。Consumer<T>接口也有andThen(Consumer<T> after)默认方法。

注意after函数的输入值类型必须与before函数的结果类型匹配:

Function<T,R> before = ...
Function<R,V> after = ...
Function<T,V> result = before.andThen(after);

生成的函数接受T类型的值并产生V类型的值。

实现相同结果的另一种方法是使用Function<V,R> compose(Function<V,T> before)默认方法:

Function<Integer, Double> process = after.compose(before);

要使用andThen()compose()中的哪种方法取决于哪个函数可用于调用聚合方法。然后,一个被认为是基础,而另一个是参数。

如果这种编码看起来有点过度设计和复杂,那是因为确实如此。我们只是为了演示目的而这样做的。好消息是,下一个示例中介绍的 lambda 表达式可以让我们以更简洁和更清晰的方式实现相同的结果。

java.util.function包的函数接口还有其他有用的默认方法。其中一个突出的是identity()方法,它返回一个始终返回其输入参数的函数:

Function<Integer, Integer> id = Function.identity();
System.out.println(id.apply(4));  //prints: 4

identity()方法在需要提供某个函数但不希望该函数修改结果时非常有用。

其他默认方法大多与转换、装箱、拆箱以及提取两个参数的最小值和最大值有关。我们鼓励您浏览java.util.function的所有函数接口的 API,并了解可能性。

创建函数接口

在本示例中,您将学习如何在java.util.function包中没有满足要求的标准接口时创建和使用自定义函数接口。

准备工作

创建函数接口很容易。只需确保接口中只有一个抽象方法,包括从其他接口继承的方法:

@FunctionalInterface
interface A{
    void m1();
}

@FunctionalInterface
interface B extends A{
    default void m2(){};
}

//@FunctionalInterface
interface C extends B{
    void m3();
}

在前面的示例中,接口C不是函数接口,因为它有两个抽象方法-m1(),从接口A继承,以及它自己的方法m3()

我们已经看到了SpeedModel函数接口:

@FunctionalInterface
public interface SpeedModel {
  double getSpeedMph(double timeSec, int weightPounds, int horsePower);
}

我们已经对其进行了注释以表达意图,并在SpeedModel接口中添加另一个抽象方法时得到警告。为了简化,我们已将enum类从中删除。此接口用于Vehicle接口:

public interface Vehicle {
    void setSpeedModel(SpeedModel speedModel);
    double getSpeedMph(double timeSec);
}

Vehicle实现需要它的原因是SpeedModel是计算速度功能的来源:

public class VehicleImpl implements Vehicle {
    private SpeedModel speedModel;
    private int weightPounds, hoursePower;
    public VehicleImpl(int weightPounds, int hoursePower){
        this.weightPounds = weightPounds;
        this.hoursePower = hoursePower;
    }
    public void setSpeedModel(SpeedModel speedModel){
        this.speedModel = speedModel;
    }
    public double getSpeedMph(double timeSec){
        return this.speedModel.getSpeedMph(timeSec, 
                                 this.weightPounds, this.hoursePower);
    };
}

正如我们在第二章中提到的OOP 快速通道-类和接口,这种设计被称为聚合。这是组合所需行为的首选方式,因为它允许更灵活性。

使用函数接口,这种设计变得更加灵活。为了演示,让我们实现我们的自定义接口SpeedModel

如何做...

传统的方法是创建一个实现SpeedModel接口的类:

public class SpeedModelImpl implements SpeedModel {
   public double getSpeedMph(double timeSec, 
                       int weightPounds, int horsePower){
      double v = 2.0 * horsePower * 746 * 
                       timeSec * 32.17 / weightPounds;
      return (double) Math.round(Math.sqrt(v) * 0.68);
   }
}

然后,我们可以按以下方式使用此实现:

Vehicle vehicle = new VehicleImpl(3000, 200);
SpeedModel speedModel = new SpeedModelImpl();
vehicle.setSpeedModel(speedModel);
System.out.println(vehicle.getSpeedMph(10.)); //prints: 122.0

要更改速度计算的方式,我们需要更改SpeedModelImpl类。

或者,利用SpeedModel是一个接口的事实,我们可以更快地引入更改,甚至避免首先拥有SpeedModelImpl类:

Vehicle vehicle = new VehicleImpl(3000, 200);
SpeedModel speedModel = new SpeedModel(){
   public double getSpeedMph(double timeSec, 
                       int weightPounds, int horsePower){
      double v = 2.0 * horsePower * 746 * 
                       timeSec * 32.17 / weightPounds;
      return (double) Math.round(Math.sqrt(v) * 0.68);
   }
};
vehicle.setSpeedModel(speedModel);
System.out.println(vehicle.getSpeedMph(10.)); //prints: 122.0

然而,前面的实现没有利用接口是功能性的优势。如果我们注释掉注解,我们可以向SpeedModel接口添加另一个方法:

//@FunctionalInterface
public interface SpeedModel {
    double getSpeedMph(double timeSec, 
                    int weightPounds, int horsePower);
    void m1();
}
Vehicle vehicle = new VehicleImpl(3000, 200);
SpeedModel speedModel = new SpeedModel(){
   public double getSpeedMph(double timeSec, 
                     int weightPounds, int horsePower){
      double v = 2.0 * horsePower * 746 * 
                       timeSec * 32.17 / weightPounds;
      return (double) Math.round(Math.sqrt(v) * 0.68);
   }
   public void m1(){}
   public void m2(){}
};
vehicle.setSpeedModel(speedModel);
System.out.println(vehicle.getSpeedMph(10.)); //prints: 122.0

从前面的代码中可以看出,不仅SpeedModel接口有另一个抽象方法m1(),而且匿名类还有另一个未在SpeedModel接口中列出的方法m2()。因此,匿名类不需要接口是功能性的。但是 lambda 表达式需要。

它是如何工作的...

使用 lambda 表达式,我们可以将前面的代码重写如下:

Vehicle vehicle = new VehicleImpl(3000, 200);
SpeedModel speedModel =  (t, wp, hp) -> {
    double v = 2.0 * hp * 746 * t * 32.17 / wp;
    return (double) Math.round(Math.sqrt(v) * 0.68);
};
vehicle.setSpeedModel(speedModel);
System.out.println(vehicle.getSpeedMph(10.)); //prints: 122.0

我们将在下一个示例中讨论 lambda 表达式的格式。现在,我们只想指出功能接口对于前面的实现非常重要。正如您所看到的,只指定了接口的名称,没有任何方法名称。这是可能的,因为功能接口只有一个必须实现的方法,这就是 JVM 如何找出并在幕后生成功能接口实现的方式。

还有更多...

可以定义一个类似于标准功能接口的通用自定义功能接口。例如,我们可以创建以下自定义功能接口:

@FunctionalInterface
interface Func<T1,T2,T3,R>{ 
   R apply(T1 t1, T2 t2, T3 t3);
}

它允许三个输入参数,这正是我们计算速度所需要的:

Func<Double, Integer, Integer, Double> speedModel = (t, wp, hp) -> {
    double v = 2.0 * hp * 746 * t * 32.17 / wp;
    return (double) Math.round(Math.sqrt(v) * 0.68);
};

使用这个函数而不是SpeedModel接口,我们可以将Vehicle接口及其实现更改如下:

interface Vehicle {
   void setSpeedModel(Func<Double, Integer, Integer, 
                                         Double> speedModel);
   double getSpeedMph(double timeSec);
}
class VehicleImpl  implements Vehicle {
   private Func<Double, Integer, Integer, Double> speedModel;
   private int weightPounds, hoursePower;
   public VehicleImpl(int weightPounds, int hoursePower){
       this.weightPounds = weightPounds;
       this.hoursePower = hoursePower;
   }
   public void setSpeedModel(Func<Double, Integer, 
                               Integer, Double> speedModel){
       this.speedModel = speedModel;
   }
   public double getSpeedMph(double timeSec){
       return this.speedModel.apply(timeSec, 
                             weightPounds, hoursePower);
   };
}

前面的代码产生了与SpeedModel接口相同的结果。

自定义接口的名称和其唯一方法的名称可以是我们喜欢的任何东西。例如:

@FunctionalInterface
interface FourParamFunction<T1,T2,T3,R>{
     R caclulate(T1 t1, T2 t2, T3 t3);
}

既然我们无论如何都要创建一个新接口,使用SpeedModel名称和getSpeedMph()方法名称可能是更好的解决方案,因为这样可以使代码更易读。但是在某些情况下,通用自定义功能接口是更好的选择。在这种情况下,您可以使用前面的定义,并根据需要进行增强。

理解 lambda 表达式

我们已经多次提到 lambda 表达式,并指出它们在 Java 中的使用证明了在java.util.function包中引入功能接口的必要性。lambda 表达式允许我们通过删除匿名类的所有样板代码来简化函数实现,只留下最少必要的信息。我们还解释了这种简化是可能的,因为功能接口只有一个抽象方法,所以编译器和 JVM 将提供的功能与方法签名进行匹配,并在幕后生成功能接口实现。

现在,是时候定义 lambda 表达式语法并查看 lambda 表达式的可能形式范围了,在我们开始使用它们使我们的代码比使用匿名类时更短更易读之前。

准备工作

在 20 世纪 30 年代,数学家阿隆佐·邱奇在研究数学基础时引入了 lambda 演算——一种通用的计算模型,可以用来模拟任何图灵机。那个时候,图灵机还没有被创建。只有后来,当艾伦·图灵发明了他的a-机器(自动机),也称为通用图灵机时,他和邱奇联手提出了一个邱奇-图灵论题,表明 lambda 演算和图灵机具有非常相似的能力。

Church 使用希腊字母lambda来描述匿名函数,它成为了编程语言理论领域的非官方符号。第一个利用 lambda 演算形式的编程语言是 Lisp。Java 在 2014 年发布 Java 8 时添加了函数式编程能力。

lambda 表达式是一个允许我们省略修饰符、返回类型和参数类型的匿名方法。这使得它非常紧凑。lambda 表达式的语法包括参数列表、箭头标记(->)和主体。参数列表可以为空(只有括号,()),没有括号(如果只有一个参数),或者由括号括起来的逗号分隔的参数列表。主体可以是一个没有括号的单个表达式,也可以是由括号括起来的语句块。

如何做到...

让我们看一些例子。以下 lambda 表达式没有输入参数,总是返回33

() -> 33;

以下 lambda 表达式接受一个整数类型的参数,将其增加 1,并返回结果:

i -> i++;

以下 lambda 表达式接受两个参数并返回它们的和:

(a, b) -> a + b;

以下 lambda 表达式接受两个参数,比较它们,并返回boolean结果:

(a, b) -> a == b;

最后一个 lambda 表达式接受两个参数,计算并打印结果:

(a, b) -> { 
     double c = a +  Math.sqrt(b); 
     System.out.println("Result: " + c);
}

正如你所看到的,lambda 表达式可以包含任意大小的代码块,类似于任何方法。前面的例子没有返回任何值。这里是另一个返回String值的代码块的例子:

(a, b) -> { 
     double c = a +  Math.sqrt(b); 
     return c > 10.0 ? "Success" : "Failure";
}

它是如何工作的...

让我们再次看看最后一个例子。如果在functional接口A中定义了一个String m1(double x, double y)方法,并且有一个接受A类型对象的m2(A a)方法,我们可以这样调用它:

A a = (a, b) -> { 
     double c = a +  Math.sqrt(b); 
     return c > 10.0 ? "Success" : "Failure";
}
m2(a);

前面的代码意味着传入的对象有以下m1()方法的实现:

public String m1(double x, double y){
     double c = a +  Math.sqrt(b); 
     return c > 10.0 ? "Success" : "Failure";
}

m2(A a)A对象作为参数告诉我们,m2(A a)的代码可能使用了A接口的至少一个方法(A接口中也可能有默认或静态方法)。但是,一般来说,不能保证方法使用了传入的对象,因为程序员可能决定停止使用它,但保持签名不变,以避免破坏客户端代码,例如。

然而,客户端必须传入实现A接口的对象到方法中,这意味着它的唯一抽象方法必须被实现。这就是 lambda 表达式所做的事情。它使用最少的代码定义抽象方法的功能——输入参数列表和方法实现的代码块。这就是编译器和 JVM 生成实现所需的一切。

编写这样紧凑和高效的代码成为可能,是因为 lambda 表达式和函数接口的结合。

还有更多...

与匿名类一样,外部创建但在 lambda 表达式内部使用的变量实际上是最终的,不能被修改。你可以写下以下代码:

double v = 10d;
Function<Integer, Double> multiplyBy10 = i -> i * v;

然而,你不能在 lambda 表达式外部改变v变量的值:

double v = 10d;
v = 30d; //Causes compiler error
Function<Integer, Double> multiplyBy10 = i -> i * v;

你也不能在表达式内部改变它:

double v = 10d;
Function<Integer, Double> multiplyBy10 = i -> {
  v = 30d; //Causes compiler error
  return i * v;
};

这种限制的原因是一个函数可以在不同的上下文(例如不同的线程)中传递和执行不同的参数,试图同步这些上下文会破坏函数的分布式评估的原始想法。

另一个值得一提的 lambda 表达式特性是它对this关键字的解释,这与匿名类的解释有很大不同。在匿名类内部,this指的是匿名类的实例,但在 lambda 表达式内部,this指的是包围表达式的类的实例。让我们来演示一下,假设我们有以下类:

class Demo{
    private String prop = "DemoProperty";
    public void method(){
        Consumer<String> consumer = s -> {
            System.out.println("Lambda accept(" + s 
                                      + "): this.prop=" + this.prop);
        };
        consumer.accept(this.prop);
        consumer = new Consumer<>() {
            private String prop = "ConsumerProperty";
            public void accept(String s) {
                System.out.println("Anonymous accept(" + s 
                                      + "): this.prop=" + this.prop);
            }
        };
        consumer.accept(this.prop);
    }
}

正如你所看到的,在method()代码中,Consumer函数接口被实现了两次——使用 lambda 表达式和使用匿名类。让我们在以下代码中调用这个方法:

  Demo d = new Demo();
  d.method();

输出将如下所示:

Lambda 表达式不是内部类,也不能被this引用。Lambda 表达式没有字段或属性。它是无状态的。这就是为什么在 lambda 表达式中,this关键字指的是周围的上下文。这也是 lambda 表达式要求周围上下文中的所有变量必须是 final 或有效 final 的另一个原因。

使用 lambda 表达式

在这个示例中,你将学习如何在实践中使用 lambda 表达式。

准备工作

创建和使用 lambda 表达式实际上比编写方法简单得多。只需要列出输入参数(如果有的话),以及执行所需操作的代码。

让我们重新审视本章第一个示例中标准功能接口的实现,并使用 lambda 表达式重写它们。以下是我们使用匿名类实现了四个主要功能接口的方式:

Function<Integer, Double> ourFunc = new Function<Integer, Double>(){
    public Double apply(Integer i){
        return i * 10.0;
    }
};
System.out.println(ourFunc.apply(1));       //prints: 10.0
Consumer<String> consumer = new Consumer<String>() {
    public void accept(String s) {
        System.out.println("The " + s + " is consumed.");
    }
};
consumer.accept("Hello!"); //prints: The Hello! is consumed.
Supplier<String> supplier = new Supplier<String>() {
    public String get() {
        String res = "Success";
        //Do something and return result—Success or Error.
        return res;
    }
};
System.out.println(supplier.get());      //prints: Success
Predicate<Double> pred = new Predicate<Double>() {
    public boolean test(Double num) {
       System.out.println("Test if " + num + " is smaller than 20");
       return num < 20;
    }
};
System.out.println(pred.test(10.0)? "10 is smaller":"10 is bigger");
                           //prints: Test if 10.0 is smaller than 20
                           //        10 is smaller

以下是使用 lambda 表达式的样子:

Function<Integer, Double> ourFunc = i -> i * 10.0;
System.out.println(ourFunc.apply(1)); //prints: 10.0

Consumer<String> consumer = 
            s -> System.out.println("The " + s + " is consumed.");
consumer.accept("Hello!");       //prints: The Hello! is consumed.

Supplier<String> supplier = () - > {
        String res = "Success";
        //Do something and return result—Success or Error.
        return res;
    };
System.out.println(supplier.get());  //prints: Success

Predicate<Double> pred = num -> {
   System.out.println("Test if " + num + " is smaller than 20");
   return num < 20;
};
System.out.println(pred.test(10.0)? "10 is smaller":"10 is bigger");
                          //prints: Test if 10.0 is smaller than 20
                          //        10 is smaller

我们提供的专门功能接口示例如下:

IntFunction<String> ifunc = new IntFunction<String>() {
    public String apply(int i) {
        return String.valueOf(i * 10);
    }
};
System.out.println(ifunc.apply(1));   //prints: 10
BiFunction<String, Integer, Double> bifunc =
        new BiFunction<String, Integer, Double >() {
            public Double apply(String s, Integer i) {
                return (s.length() * 10d) / i;
            }
        };

System.out.println(bifunc.apply("abc",2));     //prints: 15.0
BinaryOperator<Integer> binfunc = new BinaryOperator<Integer>(){
    public Integer apply(Integer i, Integer j) {
        return i >= j ? i : j;
    }
};
System.out.println(binfunc.apply(1,2));  //prints: 2
IntBinaryOperator intBiFunc = new IntBinaryOperator(){
    public int applyAsInt(int i, int j) {
        return i >= j ? i : j;
    }
};
System.out.println(intBiFunc.applyAsInt(1,2)); //prints: 2

以下是使用 lambda 表达式的样子:

IntFunction<String> ifunc = i -> String.valueOf(i * 10);
System.out.println(ifunc.apply(1));             //prints: 10

BiFunction<String, Integer, Double> bifunc = 
                            (s,i) -> (s.length() * 10d) / i;
System.out.println(bifunc.apply("abc",2));      //prints: 15.0

BinaryOperator<Integer> binfunc = (i,j) -> i >= j ? i : j;
System.out.println(binfunc.apply(1,2));         //prints: 2

IntBinaryOperator intBiFunc = (i,j) -> i >= j ? i : j;
System.out.println(intBiFunc.applyAsInt(1,2));  //prints: 2

正如你所看到的,代码更简洁,更易读。

如何做...

那些有一些传统代码编写经验的人,在开始进行函数式编程时,将函数等同于方法。他们首先尝试创建函数,因为这是我们以前编写传统代码的方式——通过创建方法。然而,在函数式编程中,方法继续提供代码结构,而函数则是它的良好和有用的补充。因此,在函数式编程中,首先创建方法,然后再定义函数。让我们来演示一下。

以下是代码编写的基本步骤。首先,我们确定可以作为方法实现的精心设计的代码块。然后,在我们知道新方法将要做什么之后,我们可以将其功能的一些部分转换为函数:

  1. 创建calculate()方法:
void calculate(){
    int i = 42;        //get a number from some source
    double res = 42.0; //process the above number 
    if(res < 42){ //check the result using some criteria
        //do something
    } else {
        //do something else
    }
}

上述伪代码概述了calculate()方法的功能。它可以以传统方式实现——通过使用方法,如下所示:

int getInput(){
   int result;
   //getting value for result variable here
   return result;
}
double process(int i){
    double result;
    //process input i and assign value to result variable
}
boolean checkResult(double res){
    boolean result = false;
    //use some criteria to validate res value
    //and assign value to result
    return result;
}
void processSuccess(double res){
     //do something with res value
}
void processFailure(double res){
     //do something else with res value
}
void calculate(){
    int i = getInput();
    double res = process(i); 
    if(checkResult(res)){     
        processSuccess(res);
    } else {
        processFailure(res);
    }
}

但是其中一些方法可能非常小,因此代码变得分散,使用这么多额外的间接会使代码变得不太可读。这个缺点在方法来自实现calculate()方法的类外部的情况下尤为明显:

void calculate(){
    SomeClass1 sc1 = new SomeClass1();
    int i = sc1.getInput();
    SomeClass2 sc2 = new SomeClass2();
    double res = sc2.process(i); 
    SomeClass3 sc3 = new SomeClass3();
    SomeClass4 sc4 = new SomeClass4();
    if(sc3.checkResult(res)){     
        sc4.processSuccess(res);
    } else {
        sc4.processFailure(res);
    }
}

正如你所看到的,在每个外部方法都很小的情况下,管道代码的数量可能大大超过它所支持的负载。此外,上述实现在类之间创建了许多紧密的依赖关系。

  1. 让我们看看如何使用函数来实现相同的功能。优势在于函数可以尽可能小,但是管道代码永远不会超过负载,因为没有管道代码。使用函数的另一个原因是,当我们需要灵活地在算法研究目的上更改功能的部分时。如果这些功能部分需要来自类外部,我们不需要为了将方法传递给calculate()而构建其他类。我们可以将它们作为函数传递:
void calculate(Supplier<Integer> souc e, Function<Integer,
             Double> process, Predicate<Double> condition,
      Consumer<Double> success, Consumer<Double> failure){
    int i = source.get();
    double res = process.apply(i);
    if(condition.test(res)){
        success.accept(res);
    } else {
        failure.accept(res);
    }
} 
  1. 以下是函数可能的样子:
Supplier<Integer> source = () -> 4;
Function<Integer, Double> before = i -> i * 10.0;
Function<Double, Double> after = d -> d + 10.0;
Function<Integer, Double> process = before.andThen(after);
Predicate<Double> condition = num -> num < 100;
Consumer<Double> success = 
                  d -> System.out.println("Success: "+ d);
Consumer<Double> failure = 
                  d -> System.out.println("Failure: "+ d);
calculate(source, process, condition, success, failure);

上述代码的结果将如下:

Success: 50.0

它是如何工作的...

Lambda 表达式就像一个普通的方法,除了当你考虑单独测试每个函数时。如何做呢?

有两种方法来解决这个问题。首先,由于函数通常很小,通常不需要单独测试它们,它们在使用它们的代码测试时间接测试。其次,如果您仍然认为函数必须进行测试,总是可以将其包装在返回函数的方法中,这样您就可以像测试其他方法一样测试该方法。以下是如何做的一个例子:

public class Demo {
  Supplier<Integer> source(){ return () -> 4;}
  Function<Double, Double> after(){ return d -> d + 10.0; }
  Function<Integer, Double> before(){return i -> i * 10.0; }
  Function<Integer, Double> process(){return before().andThen(after());}
  Predicate<Double> condition(){ return num -> num < 100.; }
  Consumer<Double> success(){ 
     return d -> System.out.println("Failure: " + d); }
  Consumer<Double> failure(){ 
     return d-> System.out.println("Failure: " + d); }
  void calculate(Supplier<Integer> souce, Function<Integer,
              Double> process, Predicate<Double> condition,
       Consumer<Double> success, Consumer<Double> failure){
    int i = source.get();
    double res = process.apply(i);
    if(condition.test(res)){
        success.accept(res);
    } else {
        failure.accept(res);
    }
}
void someOtherMethod() {
   calculate(source(), process(), 
                       condition(), success(), failure());
}

现在我们可以编写函数单元测试如下:

public class DemoTest {

    @Test
    public void source() {
        int i = new Demo().source().get();
        assertEquals(4, i);
    }
    @Test
    public void after() {
        double d = new Demo().after().apply(1.);
        assertEquals(11., d, 0.01);
    }
    @Test
    public void before() {
        double d = new Demo().before().apply(10);
        assertEquals(100., d, 0.01);
    }
    @Test
    public void process() {
        double d = new Demo().process().apply(1);
        assertEquals(20., d, 0.01);
    }
    @Test
    public void condition() {
        boolean b = new Demo().condition().test(10.);
        assertTrue(b);
    }
}

通常,lambda 表达式(以及一般的函数)用于为通用功能添加业务逻辑,从而实现特定功能。一个很好的例子是流操作,我们将在第五章《流和管道》中讨论。库的作者已经创建了它们以便能够并行工作,这需要很多专业知识。现在库的用户可以通过传递 lambda 表达式(函数)来专门定制操作,从而提供应用程序的业务逻辑。

还有更多...

由于,正如我们已经提到的,函数通常是简单的一行代码,当作为参数传递时通常会内联,例如:

Consumer<Double> success = d -> System.out.println("Success: " + d);
Consumer<Double> failure = d-> System.out.println("Failure: " + d);
calculate(() -> 4, i -> i * 10.0 + 10, n -> n < 100, success, failure);

但是,不要过分推动,因为这样的内联可能会降低代码的可读性。

使用方法引用

在这个示例中,您将学习如何使用方法引用,构造函数引用是其中的一种情况。

准备工作

当一行 lambda 表达式只包含对其他地方实现的现有方法的引用时,可以进一步简化 lambda 表示法,使用方法引用。

方法引用的语法是Location::methodName,其中Location表示methodName方法所在的位置(对象或类)。两个冒号(::)作为位置和方法名之间的分隔符。如果在指定的位置有多个同名方法(因为方法重载),则引用方法由 lambda 表达式实现的函数接口的抽象方法的签名来确定。

如何做...

方法引用的确切格式取决于所引用的方法是静态的还是非静态的。方法引用也可以是绑定的未绑定的,或者更正式地说,方法引用可以有绑定的接收者未绑定的接收者。接收者是用于调用方法的对象或类。它接收调用。它可以绑定到特定的上下文或不绑定。我们将在演示过程中解释这意味着什么。

方法引用也可以引用带参数或不带参数的构造函数。

请注意,方法引用仅适用于表达式只包含一个方法调用而没有其他内容的情况。例如,方法引用可以应用于() -> SomeClass.getCount() lambda 表达式。它看起来像SomeClass::getCount。但是表达式() -> 5 + SomeClass.getCount()不能用方法引用替换,因为这个表达式中有比方法调用更多的操作。

静态未绑定方法引用

为了演示静态方法引用,我们将使用Food类和两个静态方法:

class Food{
    public static String getFavorite(){ return "Donut!"; }
    public static String getFavorite(int num){
        return num > 1 ? String.valueOf(num) + " donuts!" : "Donut!";
    }
}

由于第一个方法String getFavorite()不接受任何输入参数并返回一个值,它可以作为一个函数接口Supplier<T>来实现。实现调用String getFavorite()静态方法的 lambda 表达式如下:

Supplier<String> supplier = () -> Food.getFavorite();

使用方法引用,前面的行变成了以下内容:

Supplier<String> supplier = Food::getFavorite;

正如您所看到的,前面的格式定义了方法的位置(作为Food类),方法的名称和返回类型的值(作为String)。函数接口的名称表示没有输入参数,因此编译器和 JVM 可以在Food类的方法中识别该方法。

静态方法引用是未绑定的,因为没有对象用于调用该方法。在静态方法的情况下,类是调用接收器,而不是对象。

第二个静态方法String getFavorite(int num)接受一个参数并返回一个值。这意味着我们可以使用Function<T,R>函数接口来实现仅调用此方法的函数:

Function<Integer, String> func = i -> Food.getFavorite(i); 

但是当使用方法引用时,它会变成与前面示例完全相同的形式:

Function<Integer, String> func = Food::getFavorite; 

区别在于指定的函数接口。它允许编译器和 Java 运行时识别要使用的方法:方法名为getFavorite(),接受Integer值,并返回String值。Food类中只有一个这样的方法。实际上,甚至不需要查看方法返回的值,因为仅通过返回值无法重载方法。方法的签名——名称和参数类型列表——足以标识方法。

我们可以按以下方式使用实现的函数:

Supplier<String> supplier = Food::getFavorite;
System.out.println("supplier.get() => " + supplier.get());

Function<Integer, String> func = Food::getFavorite;
System.out.println("func.getFavorite(1) => " + func.apply(1));
System.out.println("func.getFavorite(2) => " + func.apply(2));

如果运行上述代码,结果将如下所示:

非静态绑定方法引用

为了演示非静态绑定方法引用,让我们通过添加name字段、两个构造函数和两个String sayFavorite()方法来增强Food类:

class Food{
     private String name;
     public Food(){ this.name = "Donut"; }
     public Food(String name){ this.name = name; }
     public static String getFavorite(){ return "Donut!"; }
     public static String getFavorite(int num){
         return num > 1 ? String.valueOf(num) + " donuts!" : "Donut!";
     }
     public String sayFavorite(){
         return this.name + (this.name.toLowerCase()
                             .contains("donut")?"? Yes!" : "? D'oh!");
     }
     public String sayFavorite(String name){
         this.name = this.name + " and " + name;
         return sayFavorite();
     }
}

现在,让我们创建Food类的三个实例:

Food food1 = new Food();
Food food2 = new Food("Carrot");
Food food3 = new Food("Carrot and Broccoli");

上述是上下文——我们将要创建的 lambda 表达式周围的代码。我们使用前面上下文的局部变量来实现三个不同的供应商:

Supplier<String> supplier1 = () -> food1.sayFavorite();
Supplier<String> supplier2 = () -> food2.sayFavorite();
Supplier<String> supplier3 = () -> food3.sayFavorite();

我们使用Supplier<T>,因为String sayFavorite()方法不需要任何参数,只产生(提供)String值。使用方法引用,我们可以将前面的 lambda 表达式重写如下:

Supplier<String> supplier1 = food1::sayFavorite;
Supplier<String> supplier2 = food2::sayFavorite;
Supplier<String> supplier3 = food3::sayFavorite;

方法sayFavorite()属于在特定上下文中创建的对象。换句话说,这个对象(调用接收器)绑定到特定的上下文,这就是为什么这样的方法引用被称为绑定方法引用绑定接收器方法引用

我们可以将新创建的函数作为任何其他对象传递,并在需要的任何地方使用它们,例如:

System.out.println("new Food().sayFavorite() => " + supplier1.get());
System.out.println("new Food(Carrot).sayFavorite() => " 
                                                  + supplier2.get());
System.out.println("new Food(Carrot,Broccoli).sayFavorite() => " 
                                                  + supplier3.get());

结果将如下所示:

请注意,接收器仍然绑定到上下文,因此其状态可能会改变并影响输出。这就是绑定的区别的重要性。使用这样的引用时,必须小心不要在其原始上下文中更改接收器的状态。否则,可能会导致不可预测的结果。在并行处理时,同一函数可以在不同的上下文中使用,这一考虑尤为重要。

让我们看看使用第二个非静态方法String sayFavorite(String name)的绑定方法引用的另一个案例。首先,我们使用相同的Food类的对象创建了一个UnaryOperator<T>函数接口的实现,这与前面的示例中使用的相同:

UnaryOperator<String> op1 = s -> food1.sayFavorite(s);
UnaryOperator<String> op2 = s -> food2.sayFavorite(s);
UnaryOperator<String> op3 = s -> food3.sayFavorite(s);

我们使用UnaryOperator<T>函数接口的原因是,String sayFavorite(String name)方法接受一个参数并产生相同类型的值。这就是它们名称中带有Operator的函数接口的目的——支持输入值和结果类型相同的情况。

方法引用允许我们将 lambda 表达式更改如下:

UnaryOperator<String> op1 = food1::sayFavorite;
UnaryOperator<String> op2 = food2::sayFavorite;
UnaryOperator<String> op3 = food3::sayFavorite;

现在我们可以在代码的任何地方使用前面的函数(操作符),例如:

System.out.println("new Food()
       .sayFavorite(Carrot) => " + op1.apply("Carrot"));
System.out.println("new Food(Carrot)
   .sayFavorite(Broccoli) => " + op2.apply("Broccoli"));
System.out.println("new Food(Carrot, Broccoli)
       .sayFavorite(Donuts) => " + op3.apply("Donuts"));

上述代码的结果如下:

非静态未绑定方法引用

为了演示对String sayFavorite()方法的非绑定方法引用,我们将使用Function<T,R>函数接口,因为我们希望使用Food类的对象(调用接收器)作为参数,并返回一个String值:

Function<Food, String> func = f -> f.sayFavorite();

方法引用允许我们将前面的 lambda 表达式重写为以下形式:

Function<Food, String> func = Food::sayFavorite;

使用在前面的例子中创建的Food类的相同对象,我们在以下代码中使用新创建的函数,例如:

System.out.println("new Food()
              .sayFavorite() => " + func.apply(food1));
System.out.println("new Food(Carrot)
              .sayFavorite() => " + func.apply(food2));
System.out.println("new Food(Carrot, Broccoli)
              .sayFavorite() => " + func.apply(food3));

正如您所看到的,参数(调用接收对象)仅来自当前上下文,就像任何参数一样。无论函数传递到哪里,它都不携带上下文。它的接收器不绑定到用于函数创建的上下文。这就是为什么这个方法引用被称为未绑定的原因。

前面代码的输出如下:

为了演示未绑定方法引用的另一个案例,我们将使用第二个方法String sayFavorite(String name),并使用一直使用的Food对象。我们要实现的功能接口这次叫做BiFunction<T,U,R>

BiFunction<Food, String, String> func = (f,s) -> f.sayFavorite(s);

我们选择这个功能接口的原因是它接受两个参数——这正是我们在这种情况下需要的——以便将接收对象和String值作为参数。前面 lambda 表达式的方法引用版本如下所示:

BiFunction<Food, String, String> func = Food::sayFavorite;

我们可以通过编写以下代码来使用前面的函数,例如:

System.out.println("new Food()
  .sayFavorite(Carrot) => " + func.apply(food1, "Carrot"));
System.out.println("new Food(Carrot)
  .sayFavorite(Broccoli) => " 
                         + func2.apply(food2, "Broccoli"));
System.out.println("new Food(Carrot,Broccoli)
  .sayFavorite(Donuts) => " + func2.apply(food3,"Donuts"));

结果如下:

构造函数方法引用

使用构造函数的方法引用与静态方法引用非常相似,因为它使用类作为调用接收器,而不是对象(它尚未被创建)。这是实现Supplier<T>接口的 lambda 表达式:

Supplier<Food> foodSupplier = () -> new Food();

以下是它的方法引用版本:

Supplier<Food> foodSupplier = Food::new;
System.out.println("new Food()
  .sayFavorite() => " + foodSupplier.get().sayFavorite());

如果我们运行前面的代码,我们会得到以下输出:

现在,让我们向Food类添加另一个构造函数:

public Food(String name){ 
     this.name = name; 
} 

一旦我们这样做,我们可以通过方法引用来表示前面的构造函数:

Function<String, Food> createFood = Food::new;
Food food = createFood.apply("Donuts");
System.out.println("new Food(Donuts).sayFavorite() => " 
                                   + food.sayFavorite());
food = createFood.apply("Carrot");
System.out.println("new Food(Carrot).sayFavorite() => " 
                                   + food.sayFavorite());

以下是前面代码的输出:

同样地,我们可以添加一个带有两个参数的构造函数:

public Food(String name, String anotherName) {
     this.name = name + " and " + anotherName;
}

一旦我们这样做,我们可以通过BiFunction<String, String>来表示它:

BiFunction<String, String, Food> createFood = Food::new;
Food food = createFood.apply("Donuts", "Carrots");
System.out.println("new Food(Donuts, Carrot)
        .sayFavorite() => " + food.sayFavorite());
food = constrFood2.apply("Carrot", "Broccoli");
System.out.println("new Food(Carrot, Broccoli)
          .sayFavorite() => " food.sayFavorite());

前面的代码的结果如下:

为了表示接受多于两个参数的构造函数,我们可以创建一个自定义的功能接口,带有任意数量的参数。例如,我们可以使用前面一篇文章中讨论的以下自定义功能接口:

        @FunctionalInterface
        interface Func<T1,T2,T3,R>{ R apply(T1 t1, T2 t2, T3 t3);}

假设我们需要使用AClass类:

class AClass{
    public AClass(int i, double d, String s){ }
    public String get(int i, double d){ return ""; }
    public String get(int i, double d, String s){ return ""; }
}

我们可以通过使用方法引用来编写以下代码:

Func<Integer, Double, String, AClass> func1 = AClass::new;
AClass obj = func1.apply(1, 2d, "abc");

Func<Integer, Double, String, String> func2 = obj::get;    //bound
String res1 = func2.apply(42, 42., "42");

Func<AClass, Integer, Double, String> func3 = AClass::get; //unbound
String res21 = func3.apply(obj, 42, 42.);

func1 function that allows us to create an object of class AClass. The func2 function applies to the resulting object obj the method String get(int i, double d) using the bound method reference because its call receiver (object obj) comes from a particular context (bound to it). By contrast, the func3 function is implemented as an unbound method reference because it gets its call receiver (class AClass) not from a context. 

还有更多...

有几个简单但非常有用的方法引用,因为它得到了通常在实践中使用的调用接收器:

Function<String, Integer> strLength = String::length;
System.out.println(strLength.apply("3"));  //prints: 1

Function<String, Integer> parseInt = Integer::parseInt;
System.out.println(parseInt.apply("3"));    //prints: 3

Consumer<String> consumer = System.out::println;
consumer.accept("Hello!");             //prints: Hello!

还有一些用于处理数组和列表的有用方法:

Function<Integer, String[]> createArray = String[]::new;
String[] arr = createArray.apply(3);
System.out.println("Array length=" + arr.length); 

int i = 0;
for(String s: arr){ arr[i++] = String.valueOf(i); }
Function<String[], List<String>> toList = Arrays::<String>asList;
List<String> l = toList.apply(arr);
System.out.println("List size=" + l.size());
for(String s: l){ System.out.println(s); }

以下是前面代码的结果:

让我们由你来分析前面的 lambda 表达式是如何创建和使用的。

利用 lambda 表达式在您的程序中

在这个示例中,您将学习如何将 lambda 表达式应用到您的代码中。我们将回到演示应用程序,并通过引入 lambda 表达式来修改它。

准备工作

配备了功能接口、lambda 表达式和友好的 lambda API 设计最佳实践,我们可以通过使其设计更加灵活和用户友好来大大改进我们的速度计算应用程序。让我们尽可能接近真实问题的背景,而不要使它过于复杂。

无人驾驶汽车如今成为新闻头条,有充分的理由相信它将在相当长的一段时间内保持这种状态。在这个领域的任务之一是基于真实数据对城市区域的交通流进行分析和建模。这样的数据已经存在很多,并且将继续在未来被收集。假设我们可以通过日期、时间和地理位置访问这样的数据库。还假设来自该数据库的交通数据以单位形式存在,每个单位捕捉有关一个车辆和驾驶条件的详细信息:

public interface TrafficUnit {
  VehicleType getVehicleType();
  int getHorsePower();
  int getWeightPounds();
  int getPayloadPounds();
  int getPassengersCount();
  double getSpeedLimitMph();
  double getTraction();
  RoadCondition getRoadCondition();
  TireCondition getTireCondition();
  int getTemperature();
} 

enum类型——VehicleTypeRoadConditionTireCondition——已经在第二章中构建,OOP 快速通道-类和接口

enum VehicleType { 
  CAR("Car"), TRUCK("Truck"), CAB_CREW("CabCrew");
  private String type;
  VehicleType(String type){ this.type = type; }
  public String getType(){ return this.type;}
}
enum RoadCondition {
  DRY(1.0), 
  WET(0.2) { public double getTraction() { 
    return temperature > 60 ? 0.4 : 0.2; } }, 
  SNOW(0.04);
  public static int temperature;
  private double traction;
  RoadCondition(double traction){ this.traction = traction; }
  public double getTraction(){return this.traction;}
}
enum TireCondition {
  NEW(1.0), WORN(0.2);
  private double traction;
  TireCondition(double traction){ this.traction = traction; }
  public double getTraction(){ return this.traction;}
}

访问交通数据的接口可能如下所示:

TrafficUnit getOneUnit(Month month, DayOfWeek dayOfWeek, 
                       int hour, String country, String city, 
                       String trafficLight);
List<TrafficUnit> generateTraffic(int trafficUnitsNumber, 
                  Month month, DayOfWeek dayOfWeek, int hour,
                  String country, String city, String trafficLight);

以下是访问前述方法的示例:

TrafficUnit trafficUnit = FactoryTraffic.getOneUnit(Month.APRIL, 
               DayOfWeek.FRIDAY, 17, "USA", "Denver", "Main103S");

数字17是一天中的小时(下午 5 点),Main1035是交通灯的标识。

对第二个方法的调用返回多个结果:

List<TrafficUnit> trafficUnits = 
    FactoryTrafficModel.generateTraffic(20, Month.APRIL, 
        DayOfWeek.FRIDAY, 17, "USA", "Denver", "Main103S");

第一个参数20是请求的交通单位数。

如您所见,这样的交通工厂提供了关于特定时间(在我们的示例中为下午 5 点至 6 点之间)特定地点的交通数据。每次调用工厂都会产生不同的结果,而交通单位列表描述了统计上正确的数据(包括指定位置的最可能天气条件)。

我们还将更改FactoryVehicleFactorySpeedModel的接口,以便它们可以基于TrafficUnit接口构建VehicleSpeedModel。结果演示代码如下:

double timeSec = 10.0;
TrafficUnit trafficUnit = FactoryTraffic.getOneUnit(Month.APRIL, 
              DayOfWeek.FRIDAY, 17, "USA", "Denver", "Main103S");
Vehicle vehicle = FactoryVehicle.build(trafficUnit);
SpeedModel speedModel =  
               FactorySpeedModel.generateSpeedModel(trafficUnit);
vehicle.setSpeedModel(speedModel);
printResult(trafficUnit, timeSec, vehicle.getSpeedMph(timeSec));

printResult()方法包含以下代码:

void printResult(TrafficUnit tu, double timeSec, double speedMph){
   System.out.println("Road " + tu.getRoadCondition()
                 + ", tires " + tu.getTireCondition() + ": " 
                              + tu.getVehicleType().getType() 
                              + " speedMph (" + timeSec + " sec)=" 
                                              + speedMph + " mph");
}

此代码的输出可能如下所示:

由于我们现在使用“真实”数据,因此该程序的每次运行都会产生不同的结果,这取决于数据的统计特性。在某个地点,汽车或干燥天气可能更常出现在该日期和时间,而在另一个地点,卡车或雪更典型。

在这次运行中,交通单位带来了湿地面、新轮胎和Truck,具有这样的发动机功率和负载,以至于在 10 秒内能够达到 22 英里/小时的速度。我们用来计算速度的公式(在SpeedModel对象内部)对您来说是熟悉的:

double weightPower = 2.0 * horsePower * 746 * 32.174 / weightPounds;
double speed = (double) Math.round(Math.sqrt(timeSec * weightPower) 
                                                 * 0.68 * traction);

这里,traction值来自TrafficUnit。在实现TrafficUnit接口的类中,getTraction()方法如下所示:

public double getTraction() {
  double rt = getRoadCondition().getTraction();
  double tt = getTireCondition().getTraction();
  return rt * tt;
}

getRoadCondition()getTireCondition()方法返回我们刚刚描述的相应enum类型的元素。

现在我们准备使用前面讨论的 lambda 表达式来改进我们的速度计算应用程序。

如何做…

按照以下步骤学习如何使用 lambda 表达式:

  1. 让我们开始构建一个 API。我们将其称为Traffic。如果不使用函数接口,它可能如下所示:
public interface Traffic {
   void speedAfterStart(double timeSec, int trafficUnitsNumber);
}  

其实现可能如下所示:

public class TrafficImpl implements Traffic {
   private int hour;
   private Month month;
   private DayOfWeek dayOfWeek;
   private String country, city, trafficLight;
   public TrafficImpl(Month month, DayOfWeek dayOfWeek, int hour, 
                String country, String city, String trafficLight){
      this.hour = hour;
      this.city = city;
      this.month = month;
      this.country = country;
      this.dayOfWeek = dayOfWeek;
      this.trafficLight = trafficLight;
   }
   public void speedAfterStart(double timeSec, 
                                      int trafficUnitsNumber){
      List<TrafficUnit> trafficUnits = 
        FactoryTraffic.generateTraffic(trafficUnitsNumber, 
          month, dayOfWeek, hour, country, city, trafficLight);
      for(TrafficUnit tu: trafficUnits){
         Vehicle vehicle = FactoryVehicle.build(tu);
         SpeedModel speedModel = 
                      FactorySpeedModel.generateSpeedModel(tu);
         vehicle.setSpeedModel(speedModel);
         double speed = vehicle.getSpeedMph(timeSec);
         printResult(tu, timeSec, speed);
      }
   }
}
  1. 让我们编写使用Traffic接口的示例代码:
Traffic traffic = new TrafficImpl(Month.APRIL, 
  DayOfWeek.FRIDAY, 17, "USA", "Denver", "Main103S");
double timeSec = 10.0;
int trafficUnitsNumber = 10;
traffic.speedAfterStart(timeSec, trafficUnitsNumber); 

我们得到类似以下的结果:

如前所述,由于我们使用真实数据,因此相同的代码不会每次产生完全相同的结果。不应该期望看到前面截图中的速度值,而是看到非常相似的结果。

  1. 让我们使用 lambda 表达式。前面的 API 相当有限。例如,它不允许您在不更改FactorySpeedModel的情况下测试不同的速度计算公式。同时,SpeedModel接口只有一个名为getSpeedMph()的抽象方法(这使它成为函数接口):
public interface SpeedModel {
  double getSpeedMph(double timeSec, 
           int weightPounds, int horsePower);
}

我们可以利用SpeedModel是函数接口的特性,并向Traffic接口添加另一个方法,该方法能够接受SpeedModel实现作为 lambda 表达式:

public interface Traffic {
  void speedAfterStart(double timeSec, 
                       int trafficUnitsNumber);
  void speedAfterStart(double timeSec, 
    int trafficUnitsNumber, SpeedModel speedModel);
}

不过问题在于traction值不作为getSpeedMph()方法的参数传递,因此我们无法将其作为一个函数传递到speedAfterStart()方法中。仔细查看FactorySpeedModel.generateSpeedModel(TrafficUnit trafficUnit)的速度计算:

double getSpeedMph(double timeSec, int weightPounds, 
                                           int horsePower) {
    double traction = trafficUnit.getTraction();
    double v = 2.0 * horsePower * 746 * timeSec * 
                                    32.174 / weightPounds;
    return Math.round(Math.sqrt(v) * 0.68 * traction);
}

正如你所看到的,traction值是计算出的speed值的乘数,这是对交通单位的唯一依赖。我们可以从速度模型中移除traction,并在使用速度模型计算速度后应用traction。这意味着我们可以改变TrafficImpl类的speedAfterStart()的实现,如下所示:

public void speedAfterStart(double timeSec, 
           int trafficUnitsNumber, SpeedModel speedModel) {
   List<TrafficUnit> trafficUnits = 
     FactoryTraffic.generateTraffic(trafficUnitsNumber, 
       month, dayOfWeek, hour, country, city, trafficLight);
   for(TrafficUnit tu: trafficUnits){
       Vehicle vehicle = FactoryVehicle.build(tu);
       vehicle.setSpeedModel(speedModel);
       double speed = vehicle.getSpeedMph(timeSec);
       speed = (double) Math.round(speed * tu.getTraction());
       printResult(tu, timeSec, speed);
   }
}

这个改变允许Traffic API 的用户将SpeedModel作为一个函数传递:

Traffic traffic = new TrafficImpl(Month.APRIL, 
     DayOfWeek.FRIDAY, 17, "USA", "Denver", "Main103S");
double timeSec = 10.0;
int trafficUnitsNumber = 10;
SpeedModel speedModel = (t, wp, hp) -> {
   double weightPower = 2.0 * hp * 746 * 32.174 / wp;
   return (double) Math
              .round(Math.sqrt(t * weightPower) * 0.68);
};
traffic.speedAfterStart(timeSec, trafficUnitsNumber, 
                                            speedModel);
  1. 上述代码的结果与通过FactorySpeedModel生成SpeedModel时相同。但现在 API 用户可以自己想出自己的速度计算函数。

  2. 我们可以将SpeedModel接口注释为@FunctionalInterface,这样所有试图向其添加另一个方法的人都会得到警告,并且不能在删除此注释并意识到破坏已经实现此功能接口的现有客户端代码的风险的情况下添加另一个抽象方法。

  3. 我们可以通过添加各种标准来丰富 API,将所有可能的交通划分为不同的片段。

例如,API 用户可能只想分析汽车、卡车、引擎大于 300 马力的汽车,或引擎大于 400 马力的卡车。传统的方法是创建这样的方法:

void speedAfterStartCarEngine(double timeSec, 
              int trafficUnitsNumber, int horsePower);
void speedAfterStartCarTruckOnly(double timeSec, 
                              int trafficUnitsNumber);
void speedAfterStartEngine(double timeSec, 
         int trafficUnitsNumber, int carHorsePower, 
                                 int truckHorsePower);

相反,我们可以将标准的函数接口添加到Traffic接口的现有speedAfterStart()方法中,并让 API 用户决定要提取哪一部分交通:

void speedAfterStart(double timeSec, int trafficUnitsNumber,
  SpeedModel speedModel, Predicate<TrafficUnit> limitTraffic);

TrafficImpl类中speedAfterStart()方法的实现将如下更改:

public void speedAfterStart(double timeSec, 
          int trafficUnitsNumber, SpeedModel speedModel, 
                    Predicate<TrafficUnit> limitTraffic) {
  List<TrafficUnit> trafficUnits = 
    FactoryTraffic.generateTraffic(trafficUnitsNumber, 
    month, dayOfWeek, hour, country, city, trafficLight);
  for(TrafficUnit tu: trafficUnits){
      if(limitTraffic.test(tu){
         Vehicle vehicle = FactoryVehicle.build(tu);
         vehicle.setSpeedModel(speedModel);
         double speed = vehicle.getSpeedMph(timeSec);
         speed = (double) Math.round(speed * 
                                   tu.getTraction());
         printResult(tu, timeSec, speed);
      }
    }
}

然后,Traffic API 用户可以按以下方式定义他们需要的交通情况:

Predicate<TrafficUnit> limit = tu ->
  (tu.getHorsePower() < 250 
      && tu.getVehicleType() == VehicleType.CAR) || 
  (tu.getHorsePower() < 400 
      && tu.getVehicleType() == VehicleType.TRUCK);
traffic.speedAfterStart(timeSec, 
            trafficUnitsNumber, speedModel, limit);

结果现在被限制为引擎小于 250 hp的汽车和引擎小于 400 hp的卡车:

事实上,Traffic API 用户现在可以应用任何限制交通的标准,只要它们适用于TrafficUnit对象中的值。例如,用户可以写以下内容:

Predicate<TrafficUnit> limitTraffic = 
 tu -> tu.getTemperature() > 65 
 && tu.getTireCondition() == TireCondition.NEW 
 && tu.getRoadCondition() == RoadCondition.WET;

或者,他们可以写任何其他限制TrafficUnit值的组合。如果用户决定移除限制并分析所有交通情况,这段代码也可以做到:

traffic.speedAfterStart(timeSec, trafficUnitsNumber, 
                              speedModel, tu -> true);
  1. 如果需要通过速度选择交通单位,我们可以在速度计算后应用谓词标准(请注意,我们用BiPredicate替换了Predicate,因为我们现在需要使用两个参数):
public void speedAfterStart(double timeSec,  
           int trafficUnitsNumber, SpeedModel speedModel,
             BiPredicate<TrafficUnit, Double> limitSpeed){
   List<TrafficUnit> trafficUnits = 
     FactoryTraffic.generateTraffic(trafficUnitsNumber, 
     month, dayOfWeek, hour, country, city, trafficLight);
   for(TrafficUnit tu: trafficUnits){
      Vehicle vehicle = FactoryVehicle.build(tu);
      vehicle.setSpeedModel(speedModel);
      double speed = vehicle.getSpeedMph(timeSec);
      speed = (double) Math.round(speed*tu.getTraction());
      if(limitSpeed.test(tu, speed)){
           printResult(tu, timeSec, speed);
      }
   }
}

Traffic API 用户现在可以编写以下代码:

BiPredicate<TrafficUnit, Double> limit = (tu, sp) ->
   (sp > (tu.getSpeedLimitMph() + 8.0) && 
          tu.getRoadCondition() == RoadCondition.DRY) || 
   (sp > (tu.getSpeedLimitMph() + 5.0) && 
          tu.getRoadCondition() == RoadCondition.WET) || 
    (sp > (tu.getSpeedLimitMph() + 0.0) && 
           tu.getRoadCondition() == RoadCondition.SNOW);
traffic.speedAfterStart(timeSec, 
                 trafficUnitsNumber, speedModel, limit);

上面的谓词选择超过一定数量的速度限制的交通单位(对于不同的驾驶条件是不同的)。如果需要,它可以完全忽略速度,并且以与之前的谓词完全相同的方式限制交通。这种实现的唯一缺点是它略微不那么高效,因为谓词是在速度计算之后应用的。这意味着速度计算将针对每个生成的交通单位进行,而不是像之前的实现中那样限制数量。如果这是一个问题,你可以留下我们在本文中讨论过的所有不同签名:

public interface Traffic {
   void speedAfterStart(double timeSec, int trafficUnitsNumber);
   void speedAfterStart(double timeSec, int trafficUnitsNumber,
                                         SpeedModel speedModel);
   void speedAfterStart(double timeSec, 
            int trafficUnitsNumber, SpeedModel speedModel, 
                           Predicate<TrafficUnit> limitTraffic);
   void speedAfterStart(double timeSec, 
             int trafficUnitsNumber, SpeedModel speedModel,
                  BiPredicate<TrafficUnit,Double> limitTraffic);
}

这样,API 用户可以决定使用哪种方法,更灵活或更高效,并决定默认的速度计算实现是否可接受。

还有更多...

到目前为止,我们还没有给 API 用户选择输出格式的选择。目前,它是作为printResult()方法实现的:

void printResult(TrafficUnit tu, double timeSec, double speedMph) {
  System.out.println("Road " + tu.getRoadCondition() +
                  ", tires " + tu.getTireCondition() + ": " 
                     + tu.getVehicleType().getType() + " speedMph (" 
                     + timeSec + " sec)=" + speedMph + " mph");
}

为了使其更加灵活,我们可以向我们的 API 添加另一个参数:

Traffic traffic = new TrafficImpl(Month.APRIL, DayOfWeek.FRIDAY, 17,
                                        "USA", "Denver", "Main103S");
double timeSec = 10.0;
int trafficUnitsNumber = 10;
BiConsumer<TrafficUnit, Double> output = (tu, sp) ->
  System.out.println("Road " + tu.getRoadCondition() + 
                  ", tires " + tu.getTireCondition() + ": " 
                     + tu.getVehicleType().getType() + " speedMph (" 
                     + timeSec + " sec)=" + sp + " mph");
traffic.speedAfterStart(timeSec, trafficUnitsNumber, speedModel, output);

注意我们取timeSec值不是作为函数参数之一,而是从函数的封闭范围中取得。我们之所以能够这样做,是因为它在整个计算过程中保持不变(并且可以被视为最终值)。同样地,我们可以向output函数添加任何其他对象,比如文件名或另一个输出设备,从而将所有与输出相关的决策留给 API 用户。为了适应这个新函数,API 的实现发生了变化,如下所示:

public void speedAfterStart(double timeSec, int trafficUnitsNumber,
        SpeedModel speedModel, BiConsumer<TrafficUnit, Double> output) {
  List<TrafficUnit> trafficUnits = 
     FactoryTraffic.generateTraffic(trafficUnitsNumber, month, 
                      dayOfWeek, hour, country, city, trafficLight);
  for(TrafficUnit tu: trafficUnits){
     Vehicle vehicle = FactoryVehicle.build(tu);
     vehicle.setSpeedModel(speedModel);
     double speed = vehicle.getSpeedMph(timeSec);
     speed = (double) Math.round(speed * tu.getTraction());
     output.accept(tu, speed);
  }
}

我们花了一些时间才达到这一点——函数式编程的威力开始显现并证明了学习它的努力是值得的。然而,当用于处理流时,如下一章所述,lambda 表达式会产生更大的威力。

第五章:流和管道

在 Java 8 和 9 中,通过引入流和利用 lambda 表达式进行内部迭代,集合 API 得到了重大改进。在 Java 10(JDK 18.3)中,添加了新方法List.copyOfSet.copyOfMap.copyOf,允许我们从现有实例创建新的不可变集合。此外,在java.util.stream包的Collectors类中添加了新方法toUnmodifiableListtoUnmodifiableSettoUnmodifiableMap,允许将Stream的元素收集到不可变集合中。本章将向您展示如何使用流并链接多个操作来创建管道。此外,读者将学习如何并行进行这些操作。示例包括以下内容:

  • 使用of()copyOf()工厂方法创建不可变集合

  • 创建和操作流

  • 使用数字流进行算术运算

  • 通过生成集合来完成流

  • 通过生成映射来完成流

  • 通过对流元素进行分组来完成流

  • 创建流操作管道

  • 并行处理流

介绍

在 Java 8 中引入的 lambda 表达式在上一章中有所描述和演示。它们与函数接口一起,为 Java 增加了函数式编程能力,允许将行为(函数)作为参数传递给专为数据处理性能优化的库。这样,应用程序员可以专注于开发系统的业务方面,将性能方面留给专家-库的作者。

这样的库的一个例子是java.util.stream包,它将成为本章的重点。该包允许您以声明性的方式呈现随后可以应用于数据的过程,也可以并行进行;这些过程被呈现为流,是Stream接口的对象。为了更好地从传统集合过渡到流,java.util.Collection接口添加了两个默认方法(stream()parallelStream()),并向Stream接口添加了新的流生成工厂方法。

这种方法利用了聚合的强大功能,如第二章中所讨论的那样,OOP 快速通道-类和接口。结合其他设计原则-封装、接口和多态性-它促进了高度可扩展和灵活的设计,而 lambda 表达式允许您以简洁和简洁的方式实现它。

如今,随着机器学习对大规模数据处理和操作的需求变得普遍,这些新功能加强了 Java 在少数现代编程语言中的地位。

使用of()copyOf()工厂方法创建不可变集合

在这个示例中,我们将重新审视创建集合的传统方法,并将它们与 Java 9 中引入的List.of()Set.of()Map.of()Map.ofEntries()工厂方法,以及 Java 10 中引入的List.copyOf()Set.copyOf()Map.copyOf()方法进行比较。

准备工作

在 Java 9 之前,有几种创建集合的方式。以下是创建List最流行的方式:

List<String> list = new ArrayList<>();
list.add("This ");
list.add("is ");
list.add("built ");
list.add("by ");
list.add("list.add()");
list.forEach(System.out::print);

如果我们运行上述代码,将得到以下结果:

创建List集合的更简洁方式是通过使用数组开始:

Arrays.asList("This ", "is ", "created ", "by ", 
              "Arrays.asList()").forEach(System.out::print);

结果如下:

创建Set集合的方式类似:

Set<String> set = new HashSet<>();
set.add("This ");
set.add("is ");
set.add("built ");
set.add("by ");
set.add("set.add() ");
set.forEach(System.out::print);

或者,我们可以通过使用数组来构建Set

new HashSet<>(Arrays.asList("This ", "is ", "created ", "by ", 
                            "new HashSet(Arrays.asList()) "))
                            .forEach(System.out::print);

以下是最后两个示例的结果的示例:

请注意,与List不同,Set中元素的顺序不是固定的。它取决于哈希码的实现,并且可能因计算机而异。但是在同一台计算机上的多次运行中,顺序保持不变。请注意这一点,因为我们稍后会回到这个问题。

这是在 Java 9 之前创建Map的方法:

Map<Integer, String> map = new HashMap<>();
map.put(1, "This ");
map.put(2, "is ");
map.put(3, "built ");
map.put(4, "by ");
map.put(5, "map.put() ");
map.entrySet().forEach(System.out::print);

前面代码的输出如下:

尽管前面的输出保留了元素的顺序,但对于Map来说并不是保证的,因为它是基于在Set中收集的键。

那些经常以这种方式创建集合的人赞赏 JDK 增强提案 269 集合的便利工厂方法(JEP 269)的声明,

"Java 经常因其冗长而受到批评",它的目标是"在集合接口上提供静态工厂方法,用于创建紧凑的、不可修改的集合实例。"

作为对批评和提案的回应,Java 9 为 3 个接口——ListSetMap引入了 12 个of()静态工厂方法。以下是List的工厂方法:

static <E> List<E> of()  //Returns list with zero elements
static <E> List<E> of(E e1) //Returns list with one element
static <E> List<E> of(E e1, E e2)  //etc
static <E> List<E> of(E e1, E e2, E e3)
static <E> List<E> of(E e1, E e2, E e3, E e4)
static <E> List<E> of(E e1, E e2, E e3, E e4, E e5)
static <E> List<E> of(E e1, E e2, E e3, E e4, E e5, E e6)
static <E> List<E> of(E e1, E e2, E e3, E e4, E e5, E e6, E e7)
static <E> List<E> of(E e1, E e2, E e3, E e4, E e5, 
                                        E e6, E e7, E e8)
static <E> List<E> of(E e1, E e2, E e3, E e4, E e5, 
                                  E e6, E e7, E e8, E e9)
static <E> List<E> of(E e1, E e2, E e3, E e4, E e5, 
                            E e6, E e7, E e8, E e9, E e10)
static <E> List<E> of(E... elements)

具有固定数量元素的 10 个重载工厂方法被优化为性能,并且正如 JEP 269 所述(openjdk.java.net/jeps/269),这些方法

"避免了由 varargs 调用引起的数组分配、初始化和垃圾回收开销。**"

使用of()工厂方法使代码更加紧凑:

List.of("This ", "is ", "created ", "by ", "List.of()")
                                            .forEach(System.out::print);
System.out.println();
Set.of("This ", "is ", "created ", "by ", "Set.of() ")
                                            .forEach(System.out::print);
System.out.println();
Map.of(1, "This ", 2, "is ", 3, "built ", 4, "by ", 5,"Map.of() ")
                                 .entrySet().forEach(System.out::print);

System.out.println()语句被添加以在结果之间插入换行:

Map接口中的 12 个静态工厂方法之一与其他of()方法不同:

Map<K,V> ofEntries(Map.Entry<K,V>... entries)

以下是其用法示例:

Map.ofEntries(
  entry(1, "This "),
  entry(2, "is "),
  entry(3, "built "),
  entry(4, "by "),
  entry(5, "Map.ofEntries() ")
).entrySet().forEach(System.out::print);

它产生了以下输出:

并且没有Map.of()工厂方法用于无限数量的元素。当创建一个包含超过 10 个元素的映射时,必须使用Map.ofEntries()

在 Java 10 中,引入了List.copyOf()Set.copyOf()Map.copyOf()方法。它们允许我们将任何集合转换为相应类型的不可变集合。

如何做到...

正如我们已经提到的,Set.of()Map.of()Map.ofEntries()方法不保留集合元素的顺序。这与之前(Java 9 之前)的SetMap实例在同一台计算机上运行时保持相同顺序的行为不同。Set.of()Map.of()Map.ofEntries()方法会在同一台计算机上的多次运行中改变元素的顺序。顺序只在同一次运行中保持不变,无论集合被迭代多少次。在同一台计算机上从一次运行到另一次运行改变元素的顺序有助于程序员避免对特定顺序的不必要依赖。

ListSetMap接口的of()静态方法生成的集合的另一个特性是它们是不可变的。这是什么意思?考虑以下代码:

List<String> list = List.of("This ", "is ", "immutable");
list.add("Is it?");     //throws UnsupportedOperationException
list.set(1, "is not "); //throws UnsupportedOperationException

如您所见,任何尝试向使用List.of()方法创建的集合中添加新元素或修改现有元素都会导致java.lang.UnsupportedOperationException运行时异常。

此外,List.of()方法不接受null元素,因此以下代码会抛出java.lang.NullPointerException运行时异常:

List<String> list = List.of("This ", "is ", "not ", "created ", null);

Set.of()Map.of()创建的集合与前面描述的List.of()方法的行为相同:

Set<String> set = Set.of("a", "b", "c");
//set.remove("b");  //UnsupportedOperationException
//set.add("e");     //UnsupportedOperationException
//set = Set.of("a", "b", "c", null); //NullPointerException

Map<Integer, String> map = Map.of(1, "one", 2, "two", 3, "three");
//map.remove(2);                    //UnsupportedOperationException
//map.put(5, "five ");              //UnsupportedOperationException
//map = Map.of(1, "one", 2, "two", 3, null); //NullPointerException
//map = Map.ofEntries(entry(1, "one"), null); //NullPointerException

List.copyOf()Set.copyOf()Map.copyOf()方法提供了另一种基于另一个集合创建不可变集合的方法:

List<Integer> list = Arrays.asList(1,2,3);
list = List.copyOf(list);
//list.set(1, 0);     //UnsupportedOperationException
//list.remove(1);     //UnsupportedOperationException

Set<Integer> setInt = Set.copyOf(list);
//setInt.add(42);       //UnsupportedOperationException
//setInt.remove(3);  //UnsupportedOperationException

Set<String> set = new HashSet<>(Arrays.asList("a","b","c"));
set = Set.copyOf(set);
//set.add("d");     //UnsupportedOperationException
//set.remove("b");  //UnsupportedOperationException

Map<Integer, String> map = new HashMap<>();
map.put(1, "one ");
map.put(2, "two ");
map = Map.copyOf(map);
//map.remove(2);          //UnsupportedOperationException
//map.put(3, "three ");    //UnsupportedOperationException

请注意,输入参数可以是任何具有相同类型元素或扩展传入集合元素类型的类型的集合:

class A{}
class B extends A{}

List<A> listA = Arrays.asList(new B(), new B());
Set<A> setA = new HashSet<>(listA);

List<B> listB = Arrays.asList(new B(), new B());
setA = new HashSet<>(listB);

//List<B> listB = Arrays.asList(new A(), new A()); //compiler error
//Set<B> setB = new HashSet<>(listA);              //compiler error

还有更多...

在 lambda 表达式和流引入后不久,非空值和不可变性被强制执行并非偶然。正如您将在后续的示例中看到的,函数式编程和流管道鼓励一种流畅的编码风格(使用方法链式编程,以及在本示例中使用forEach()方法)。流畅的风格提供了更紧凑和可读的代码。消除了对null值的检查有助于保持这种方式——紧凑且专注于主要的处理过程。

不可变性特性与 lambda 表达式使用的变量的effectively final概念相吻合。例如,可变集合允许我们绕过这个限制:

List<Integer> list = Arrays.asList(1,2,3,4,5);
list.set(2, 0);
list.forEach(System.out::print);  //prints: 12045

list.forEach(i -> {
  int j = list.get(2);
  list.set(2, j + 1);
});
System.out.println();
list.forEach(System.out::print);   //prints: 12545

在上述代码中,第二个forEach()操作使用的 lambda 表达式在原始列表的第三个(索引为 2)元素中保持状态。这可能会有意或无意地在 lambda 表达式中引入状态,并导致在不同上下文中同一函数的不同结果。这在并行处理中尤其危险,因为无法预测每个可能上下文的状态。这就是为什么集合的不可变性是一个有用的补充,使代码更健壮和可靠。

创建和操作流

在本示例中,我们将描述如何创建流以及如何对流发出的元素应用操作。讨论和示例适用于任何类型的流,包括专门的数值流:IntStreamLongStreamDoubleStream。数值流特有的行为没有呈现,因为它在下一个示例中描述,即使用数值流进行算术操作

准备就绪

有许多创建流的方法:

  • stream()parallelStream()方法属于java.util.Collection接口——这意味着所有的子接口,包括SetList,也有这些方法

  • java.util.Arrays类的两个重载的stream()方法,将数组和子数组转换为流

  • java.util.stream.Stream接口的of()generate()iterate()方法

  • java.nio.file.Files类的Stream<Path> list()Stream<String> lines()Stream<Path> find()方法

  • java.io.BufferedReader类的Stream<String> lines()方法

创建流后,可以对其元素应用各种方法(称为操作)。流本身不存储数据。相反,它根据需要从源获取数据(并将其提供或发出给操作)。操作可以使用流畅的风格形成管道,因为许多中间操作也可以返回流。这些操作称为中间操作。中间操作的示例包括以下内容:

  • map(): 根据函数转换元素

  • flatMap(): 根据函数将每个元素转换为流

  • filter(): 选择符合条件的元素

  • limit(): 将流限制为指定数量的元素

  • sorted(): 将无序流转换为有序流

  • distinct(): 移除重复项

  • Stream接口的其他返回Stream的方法

管道以终端操作结束。实际上,只有在执行终端操作时,流元素的处理才会开始。然后,所有中间操作(如果存在)开始处理,流关闭并且在终端操作完成执行之前不能重新打开。终端操作的示例包括:

  • forEach()

  • findFirst()

  • reduce()

  • collect()

  • Stream接口的其他不返回Stream的方法

终端操作返回结果或产生副作用,但它们不返回Stream对象。

所有的Stream操作都支持并行处理,在多核计算机上处理大量数据时尤其有帮助。所有 Java Stream API 接口和类都在java.util.stream包中。

在本示例中,我们将演示顺序流。并行流处理并没有太大的不同。只需注意处理管道不使用在不同处理环境中可能变化的上下文状态。我们将在本章后面的另一个示例中讨论并行处理。

如何做到...

在本节中,我们将介绍创建流的方法。实现Set接口或List接口的每个类都有stream()方法和parallelStream()方法,它们返回Stream接口的实例:

  1. 考虑以下流创建的示例:
List.of("This", "is", "created", "by", "List.of().stream()")
                            .stream().forEach(System.out::print);
System.out.println();
Set.of("This", "is", "created", "by", "Set.of().stream()")
                            .stream().forEach(System.out::print);
System.out.println();
Map.of(1, "This ", 2, "is ", 3, "built ", 4, "by ", 5,
                             "Map.of().entrySet().stream()")
                 .entrySet().stream().forEach(System.out::print);

我们使用了流畅的风格使代码更加简洁,并插入了System.out.println()以便在输出中开始新的一行。

  1. 运行上述示例,你应该会看到以下结果:

注意,List保留了元素的顺序,而Set元素的顺序在每次运行时都会改变。后者有助于发现基于对特定顺序的依赖而未能保证顺序时的缺陷。

  1. 查看Arrays类的 Javadoc。它有两个重载的静态stream()方法:
Stream<T> stream(T[] array)
Stream<T> stream(T[] array, int startInclusive, int endExclusive)
  1. 写出最后两种方法的用法示例:
String[] array = {"That ", "is ", "an ", "Arrays.stream(array)"};
Arrays.stream(array).forEach(System.out::print);
System.out.println();
String[] array1 = { "That ", "is ", "an ", 
                                    "Arrays.stream(array,0,2)" };
Arrays.stream(array1, 0, 2).forEach(System.out::print);
  1. 运行它并查看结果:

在第二个例子中,注意只有第一个和第二个元素,即索引为01的元素被选中并包含在流中,这正是预期的结果。

  1. 打开Stream接口的 Javadoc 并查看of()generate()iterate()静态工厂方法:
Stream<T> of(T t)          //Stream of one element
Stream<T> ofNullable(T t)  //Stream of one element
       // if not null. Otherwise, returns an empty Stream
Stream<T> of(T... values)
Stream<T> generate(Supplier<T> s)
Stream<T> iterate(T seed, UnaryOperator<T> f)
Stream<T> iterate(T seed, Predicate<T> hasNext, 
                           UnaryOperator<T> next)

前两种方法很简单,所以我们跳过它们的演示,直接从第三种方法of()开始。它可以接受数组或逗号分隔的元素。

  1. 将示例写成如下形式:
String[] array = { "That ", "is ", "a ", "Stream.of(array)" };
Stream.of(array).forEach(System.out::print); 
System.out.println();
Stream.of( "That ", "is ", "a ", "Stream.of(literals)" )
                                  .forEach(System.out::print);
  1. 运行它并观察输出:

  1. 按照以下方式写出generate()iterate()方法的用法示例:
Stream.generate(() -> "generated ")
                           .limit(3).forEach(System.out::print);
System.out.println();
System.out.print("Stream.iterate().limit(10): ");
Stream.iterate(0, i -> i + 1)
                          .limit(10).forEach(System.out::print);
System.out.println();
System.out.print("Stream.iterate(Predicate < 10): ");
Stream.iterate(0, i -> i < 10, i -> i + 1)
                                    .forEach(System.out::print);

我们必须对前两个示例生成的流的大小进行限制,否则它们将是无限的。第三个示例接受一个提供迭代何时停止的条件的谓词。

  1. 运行示例并观察结果:

  1. 让我们看一下Files.list(Path dir)方法的示例,它返回目录中所有条目的Stream<Path>
System.out.println("Files.list(dir): ");
Path dir = FileSystems.getDefault()
  .getPath("src/main/java/com/packt/cookbook/ch05_streams/");
try(Stream<Path> stream = Files.list(dir)) {
      stream.forEach(System.out::println);
} catch (Exception ex){ 
      ex.printStackTrace(); 
}

以下内容来自 JDK API:

"必须在 try-with-resources 语句或类似的控制结构中使用此方法,以确保在流操作完成后及时关闭流的打开目录。"

这就是我们所做的;我们使用了 try-with-resources 语句。或者,我们可以使用 try-catch-finally 结构,在 finally 块中关闭流,结果不会改变。

  1. 运行上述示例并观察输出:

并非所有流都必须显式关闭,尽管Stream接口扩展了AutoCloseable,人们可能期望所有流都必须使用 try-with-resources 语句自动关闭。但事实并非如此。Stream接口的 Javadoc(docs.oracle.com/javase/8/docs/api/java/util/stream/Stream.html)中说道:

“流具有BaseStream.close()方法并实现AutoCloseable。大多数流实例实际上在使用后不需要关闭,因为它们由集合、数组或生成函数支持,不需要特殊的资源管理。通常,只有其源是 I/O 通道的流,例如Files.lines(Path)返回的流,才需要关闭。”

这意味着程序员必须知道流的来源,因此请确保如果源的 API 要求关闭流,则关闭流。

  1. 写一个Files.lines()方法的使用示例:
  System.out.println("Files.lines().limit(3): ");
  String file = "src/main/java/com/packt/cookbook/" +
                              "ch05_streams/Chapter05Streams.java";
  try(Stream<String> stream=Files.lines(Paths.get(file)).limit(3)){ 
       stream.forEach(l -> { 
            if( l.length() > 0 ) {
                System.out.println("   " + l); 
            }
       });
  } catch (Exception ex){ 
      ex.printStackTrace(); 
  }

前面的例子的目的是读取指定文件的前三行,并打印缩进三个空格的非空行。

  1. 运行上面的例子并查看结果:

  1. 编写使用Files.find()方法的代码:
Stream<Path> find(Path start, int maxDepth, BiPredicate<Path, 
    BasicFileAttributes> matcher, FileVisitOption... options)
  1. 与前面的情况类似,Files.find()方法生成的流也必须显式关闭。Files.find()方法遍历以给定起始文件为根的文件树,并返回与谓词匹配的文件的路径(包括文件属性)。写下以下代码:
Path dir = FileSystems.getDefault()
 .getPath("src/main/java/com/packt/cookbook/ch05_streams/");
BiPredicate<Path, BasicFileAttributes> select = 
   (p, b) -> p.getFileName().toString().contains("Factory");
try(Stream<Path> stream = Files.find(f, 2, select)){
        stream.map(path -> path.getFileName())
                              .forEach(System.out::println);
} catch (Exception ex){ 
   ex.printStackTrace(); 
}
  1. 运行上面的例子,你会得到以下输出:

如果需要,FileVisitorOption.FOLLOW_LINKS可以作为Files.find()方法的最后一个参数包含,如果我们需要执行一个会遵循它可能遇到的所有符号链接的搜索。

  1. 使用BufferedReader.lines()方法的要求有点不同,它返回从文件中读取的行的Stream<String>。根据 Javadoc(docs.oracle.com/javase/8/docs/api/java/io/BufferedReader.html),

“在终端流操作执行期间,不能对读取器进行操作。否则,终端流操作的结果是未定义的。”

JDK 中有许多其他生成流的方法。但它们更加专业化,由于空间不足,我们将不在这里演示它们。

它是如何工作的...

在前面的例子中,我们已经演示了几个流操作,即Stream接口的方法。我们最常使用forEach(),有时使用limit()。第一个是终端操作,第二个是中间操作。现在让我们看看Stream接口的其他方法。

以下是中间操作,即返回Stream并可以以流畅的方式连接的方法:

//1
Stream<T> peek(Consumer<T> action)
//2
Stream<T> distinct()       //Returns stream of distinct elements
Stream<T> skip(long n)     //Discards the first n elements 
Stream<T> limit(long n)    //Allows the first n elements to be processed 
Stream<T> filter(Predicate<T> predicate)
Stream<T> dropWhile(Predicate<T> predicate) 
Stream<T> takeWhile(Predicate<T> predicate)
//3 
Stream<R> map(Function<T, R> mapper)
IntStream mapToInt(ToIntFunction<T> mapper)
LongStream mapToLong(ToLongFunction<T> mapper)
DoubleStream mapToDouble(ToDoubleFunction<T> mapper)
//4
Stream<R> flatMap(Function<T, Stream<R>> mapper)
IntStream flatMapToInt(Function<T, IntStream> mapper)
LongStream flatMapToLong(Function<T, LongStream> mapper)
DoubleStream flatMapToDouble(Function<T, DoubleStream> mapper)
//5
static Stream<T> concat(Stream<T> a, Stream<T> b) 
//6
Stream<T> sorted()
Stream<T> sorted(Comparator<T> comparator)

前面方法的签名通常包括"? super T"作为输入参数和"? extends R"作为结果(请参阅 Javadoc 以获取正式定义)。我们通过删除这些标记来简化它们,以便更好地概述这些方法的多样性和共性。为了弥补这一点,我们想简要回顾相关泛型标记的含义,因为它们在 Stream API 中被广泛使用,可能会引起混淆。

让我们看看flatMap()方法的正式定义,因为它包含了所有这些内容:

<R> Stream<R> flatMap(Function<? super T,
                      ? extends Stream<? extends R>> mapper)

方法前面的<R>符号表示给编译器它是一个通用方法(具有自己的类型参数)。没有它,编译器将寻找R类型的定义。T类型没有列在方法前面,因为它包含在Stream<T>接口定义中(查看接口声明的页面顶部)。? super T表示T类型或其超类在此处允许。? extends R表示R类型或其子类在此处允许。? extends Stream<...>也是一样的:Stream类型或其子类在此处允许。

现在,让我们回到我们(简化的)中间操作列表。我们根据相似性将它们分成了几个组:

  • 第一组中只包含一个peek()方法,它允许您对每个流元素应用Consumer函数,而不影响元素,因为Consumer函数不返回任何内容。它通常用于调试:
       int sum = Stream.of( 1,2,3,4,5,6,7,8,9 )
                       .filter(i -> i % 2 != 0)
                       .peek(i -> System.out.print(i))
                       .mapToInt(Integer::intValue)
                       .sum();
       System.out.println("sum = " + sum);

如果您执行上述代码,结果将如下所示:

  • 在上面列出的第二组中间操作中,前三个——distinct()skip()limit()——都是不言自明的。filter(Predicate p)方法是最常用的方法之一。它做的就是它的名字所暗示的——从流中删除不符合作为Predicate函数传递的标准的元素。我们在前面的代码片段中看到了它的使用示例:只有奇数才能通过过滤器。dropWhile()方法丢弃元素,只要标准得到满足(然后允许流的其余元素流向下一个操作)。takeWhile()方法则相反——只要标准得到满足(然后丢弃其余的元素)。以下是这些操作的使用示例:
System.out.println("Files.lines().dropWhile().takeWhile():");
String file = "src/main/java/com/packt/cookbook/" + 
                        "ch05_streams/Chapter05Streams.java";
try(Stream<String> stream = Files.lines(Paths.get(file))){
    stream.dropWhile(l -> 
                  !l.contains("dropWhile().takeWhile()"))
        .takeWhile(l -> !l.contains("} catc" + "h"))
        .forEach(System.out::println);
} catch (Exception ex){ 
    ex.printStackTrace(); 
}   

此代码读取存储上述代码的文件。我们希望它首先打印"Files.lines().dropWhile().takeWhile():",然后打印除最后三行之外的所有前面的行。因此,上述代码丢弃文件中不包含dropWhile().takeWhile()子字符串的所有第一行,然后允许所有行流动,直到找到"} catch子字符串为止。

请注意,我们必须写"} catc" + "h"而不是"} catch"。否则,代码会找到contains(" catch"),并且不会继续执行。

上述代码的结果如下:

  • map()操作组也非常简单。这样的操作通过将作为参数传递的函数应用于流的每个元素来转换流的每个元素。我们已经看到了mapToInt()方法的使用示例。以下是map()操作的另一个示例:
Stream.of( "That ", "is ", "a ", "Stream.of(literals)" )
              .map(s -> s.contains("i"))
              .forEach(System.out::println);

在这个例子中,我们将String文字转换为boolean。结果如下:

  • 下一组中间操作称为flatMap(),提供更复杂的处理。flatMap()操作将传入的函数(返回流)应用于每个元素,以便操作可以生成由从每个元素提取的流组成的流。以下是flatMap()的使用示例:
Stream.of( "That ", "is ", "a ", "Stream.of(literals)" )
     .filter(s -> s.contains("Th"))
     .flatMap(s -> Pattern.compile("(?!^)").splitAsStream(s))
     .forEach(System.out::print);

上述代码从流元素中仅选择包含Th的文字,并将它们转换为字符流,然后由forEach()打印出来。其结果如下:

  • concat()方法从两个输入流创建一个流,以便第一个流的所有元素后跟第二个流的所有元素。以下是此功能的示例:
Stream.concat(Stream.of(4,5,6), Stream.of(1,2,3))
                                  .forEach(System.out::print);

结果如下:

如果有两个以上的流连接,可以编写如下内容:

Stream.of(Stream.of(4,5,6), Stream.of(1,2,3), Stream.of(7,8,9))
 .flatMap(Function.identity())
 .forEach(System.out::print);

结果如下:

请注意,在上述代码中,Function.identity()是一个返回其输入参数的函数。我们使用它是因为我们不需要转换输入流,而只是将它们原样传递给生成的流。如果不使用这个flatMap()操作,流将由Stream对象组成,而不是它们的元素,输出将显示java.util.stream.ReferencePipeline$Head@548b7f67java.util.stream.ReferencePipeline$Head@7ac7a4e4 java.util.stream.ReferencePipeline$Head@6d78f375

  • 中间操作的最后一组由sorted()方法组成,该方法按自然顺序(如果它们是Comparable类型)或根据传入的Comparator对象对流元素进行排序。它是一个有状态的操作(以及distinct()limit()skip()),在并行处理的情况下会产生非确定性结果(这是下面在并行中处理流主题的食谱)。

现在,让我们看看终端操作(我们通过删除? super T? extends R来简化它们的签名):

//1
long count()                     //Returns total count of elements
//2
Optional<T> max(Comparator<T> c) //Returns max according to Comparator
Optional<T> min(Comparator<T> c) //Returns min according to Comparator
//3
Optional<T> findAny()    //Returns any or empty Optional
Optional<T> findFirst()  //Returns the first element or empty Optional 
//4
boolean allMatch(Predicate<T> p)   //All elements match Predicate?
boolean anyMatch(Predicate<T> p)   //Any element matches Predicate?
boolean noneMatch(Predicate<T> p)  //No element matches Predicate?
//5
void forEach(Consumer<T> action)   //Apply action to each element 
void forEachOrdered(Consumer<T> action) 
//6
Optional<T> reduce(BinaryOperator<T> accumulator) 
T reduce(T identity, BinaryOperator<T> accumulator) 
U reduce(U identity, BiFunction<U,T,U> accumulator, 
                                          BinaryOperator<U> combiner) 
//7
R collect(Collector<T,A,R> collector) 
R collect(Supplier<R> supplier, BiConsumer<R,T> accumulator, 
                                            BiConsumer<R,R> combiner) 
//8
Object[] toArray() 
A[] toArray(IntFunction<A[]> generator)

前四组操作都不言自明,但是我们需要对Optional说几句话。Javadoc(docs.oracle.com/javase/8/docs/api/java/util/Optional.html)将其定义为,

“可能包含非空值的容器对象。如果存在值,则isPresent()返回trueget()返回该值。”

它允许您避免NullPointerException或检查null(无论如何,您都必须调用isPresent())。它有自己的方法——map()filter()flatMap()。此外,Optional还有一些包含isPresent()检查的方法:

  • ifPresent(Consumer<T> action): 如果存在值,则执行该操作,否则不执行任何操作

  • ifPresentOrElse(Consumer<T> action, Runnable emptyAction): 如果存在值,则执行提供的操作,否则执行提供的基于空的操作

  • or(Supplier<Optional<T>> supplier): 如果存在值,则返回描述该值的Optional类,否则返回由提供的函数产生的Optional

  • orElse(T other): 如果存在值,则返回该值,否则返回提供的other对象

  • orElseGet(Supplier<T> supplier): 如果存在值,则返回该值,否则返回由提供的函数产生的结果

  • orElseThrow(Supplier<X> exceptionSupplier): 如果存在值,则返回该值,否则抛出由提供的函数产生的异常

请注意,Optional在可能返回null的情况下用作返回值。以下是其用法示例。我们使用reduce()操作重新实现了流连接代码,该操作返回Optional

    Stream.of(Stream.of(4,5,6), Stream.of(1,2,3), Stream.of(7,8,9))
          .reduce(Stream::concat)
          .orElseGet(Stream::empty)
          .forEach(System.out::print);

使用flatMap()方法的结果与以前的实现相同:

下一组终端操作称为forEach()。这些操作保证给定的函数将应用于流的每个元素。但是forEach()对顺序没有任何要求,这可能会改变以获得更好的性能。相比之下,forEachOrdered()保证不仅处理流的所有元素,而且无论流是顺序还是并行,都会按照其源指定的顺序进行处理。以下是几个示例:

Stream.of("3","2","1").parallel().forEach(System.out::print);
System.out.println();
Stream.of("3","2","1").parallel().forEachOrdered(System.out::print);

结果如下:

如您所见,在并行处理的情况下,forEach()不能保证顺序,而forEachOrdered()可以。以下是使用OptionalforEach()的另一个示例:

 Stream.of( "That ", "is ", "a ", null, "Stream.of(literals)" )
       .map(Optional::ofNullable) 
       .filter(Optional::isPresent)
       .map(Optional::get)
       .map(String::toString)
       .forEach(System.out::print);

我们无法使用Optional.of(),而是使用Optional.ofNullable(),因为Optional.of()null上会抛出NullPointerException。在这种情况下,Optional.ofNullable()只会返回空的Optional。结果如下:

现在,让我们谈谈下一组终端操作,称为reduce()。这三个重载方法中的每一个在处理所有流元素后返回单个值。最简单的例子包括找到流元素的和(如果它们是数字),或者最大值、最小值等。但是对于任何类型的对象流,也可以构造更复杂的结果。

第一个方法Optional<T> reduce(BinaryOperator<T> accumulator)返回Optional<T>对象,因为由提供的累加器函数负责计算结果,JDK 实现的作者无法保证它总是包含非空值:

 int sum = Stream.of(1,2,3).reduce((p,e) -> p + e).orElse(0);
 System.out.println("Stream.of(1,2,3).reduce(acc): " +sum);

传入的函数接收相同函数之前执行的结果(作为第一个参数p)和流的下一个元素(作为第二个参数e)。对于第一个元素,p获得其值,而e是第二个元素。您可以按如下方式打印p的值:

int sum = Stream.of(1,2,3)
        .reduce((p,e) -> {
            System.out.println(p);   //prints: 1 3
            return p + e;
        })
        .orElse(10);
System.out.println("Stream.of(1,2,3).reduce(acc): " + sum);

前面代码的输出如下:

为了避免使用Optional的额外步骤,第二种方法T reduce(T identity, BinaryOperator<T> accumulator)在流为空的情况下返回作为第一个参数identity的值,类型为T(即Stream<T>的元素类型)。该参数必须符合对于所有t的要求,因为accumulator.apply(identity, t)等于t的要求(来自 Javadoc)。在我们的例子中,它必须为0,以符合0 + e == e。以下是如何使用第二种方法的示例:

int sum = Stream.of(1,2,3).reduce(0, (p,e) -> p + e);
System.out.println("Stream.of(1,2,3).reduce(0, acc): " + sum);

结果与第一个reduce()方法相同。

第三种方法U reduce(U identity, BiFunction<U,T,U> accumulator, BinaryOperator<U> combiner),使用BiFunction<U,T,U>函数将T类型的值转换为U类型的值。BiFunction<U,T,U>用作累加器,使得其应用于前一个元素(T类型)的结果(U类型)成为函数的输入,同时与流的当前元素一起成为函数的输入。以下是一个代码示例:

String sum = Stream.of(1,2,3)
    .reduce("", (p,e) -> p + e.toString(), (x,y) -> x + "," + y);
System.out.println("Stream.of(1,2,3).reduce(,acc,comb): " + sum);

自然地期望看到结果为1,2,3。但实际上我们看到的是:

前面结果的原因是使用了组合器,因为流是顺序的。但现在让流并行化:

String sum = Stream.of(1,2,3).parallel()  
    .reduce("", (p,e) -> p + e.toString(), (x,y) -> x + "," + y);
System.out.println("Stream.of(1,2,3).reduce(,acc,comb): " + sum);

前面的代码执行结果将如下所示:

这意味着组合器仅在并行处理时才会被调用,以组装(合并)并行处理的不同子流的结果。这是我们迄今为止从声明意图提供顺序和并行流相同行为的唯一偏差。但是有许多方法可以在不使用reduce()的第三个版本的情况下实现相同的结果。例如,考虑以下代码:

String sum = Stream.of(1,2,3)
                   .map(i -> i.toString() + ",")
                   .reduce("", (p,e) -> p + e);
System.out.println("Stream.of(1,2,3).map.reduce(,acc): " 
                   + sum.substring(0, sum.length()-1));

它产生与前一个示例相同的结果:

现在让我们将其改为并行流:

String sum = Stream.of(1,2,3).parallel()
                   .map(i -> i.toString() + ",")
                   .reduce("", (p,e) -> p + e);
System.out.println("Stream.of(1,2,3).map.reduce(,acc): " 
                   + sum.substring(0, sum.length()-1));

结果保持不变:1,2,3

下一组中间操作称为collect(),包括两种方法:

R collect(Collector<T,A,R> collector) 
R collect(Supplier<R> supplier, BiConsumer<R,T> accumulator, 
                                            BiConsumer<R,R> combiner) 

第一种接受Collector<T,A,R>作为参数。它比第二种更受欢迎,因为它由Collectors类支持,该类提供了Collector接口的多种实现。我们鼓励您查看Collectors类的 Javadoc 并了解其提供的功能。

让我们讨论一些使用Collectors类的示例。首先,我们将创建一个名为Thing的小型演示类:

public class Thing {
  private int someInt;
  public Thing(int i) { this.someInt = i; }
  public int getSomeInt() { return someInt; }
  public String getSomeStr() { 
    return Integer.toString(someInt); }
} 

现在我们可以用它来演示一些收集器:

double aa = Stream.of(1,2,3).map(Thing::new)
              .collect(Collectors.averagingInt(Thing::getSomeInt));
System.out.println("stream(1,2,3).averagingInt(): " + aa);

String as = Stream.of(1,2,3).map(Thing::new).map(Thing::getSomeStr)
                                 .collect(Collectors.joining(","));
System.out.println("stream(1,2,3).joining(,): " + as);

String ss = Stream.of(1,2,3).map(Thing::new).map(Thing::getSomeStr)
                       .collect(Collectors.joining(",", "[", "]"));
System.out.println("stream(1,2,3).joining(,[,]): " + ss);

结果将如下所示:

连接收集器对于任何曾经不得不编写代码来检查添加的元素是否是第一个、最后一个或删除最后一个字符的程序员来说都是一种乐趣的来源(就像我们在reduce()操作的示例中所做的那样)。joining()方法生成的收集器在幕后执行此操作。程序员唯一需要提供的是分隔符、前缀和后缀。

大多数程序员永远不需要编写自定义收集器。但是如果有需要,可以使用Stream的第二种方法collect(),并提供组成收集器的函数,或者使用两种Collector.of()静态方法之一来生成可以重复使用的收集器。

如果比较reduce()collect()操作,您会注意到reduce()的主要目的是对不可变对象和原始类型进行操作。reduce()的结果通常是一个值,通常(但不一定)与流的元素类型相同。相比之下,collect()产生了一个不同类型的结果,包装在一个可变容器中。collect()的最常见用法是使用相应的Collectors.toList()Collectors.toSet()Collectors.toMap()收集器生成ListSetMap对象。

最后一组终端操作包括两个toArray()方法:

Object[] toArray() 
A[] toArray(IntFunction<A[]> generator)

第一个返回Object[],第二个返回指定类型的数组。让我们看一下它们的使用示例:

 Object[] os = Stream.of(1,2,3).toArray();
 Arrays.stream(os).forEach(System.out::print);
 System.out.println();
 String[] sts = Stream.of(1,2,3)
                      .map(i -> i.toString())
                      .toArray(String[]::new);
 Arrays.stream(sts).forEach(System.out::print);

这些示例的输出如下:

第一个示例非常简单。值得注意的是,我们不能写以下内容:

Stream.of(1,2,3).toArray().forEach(System.out::print);

这是因为toArray()是一个终端操作,流在执行后会自动关闭。这就是为什么我们必须在前面代码示例的第二行中打开一个新的流。

第二个示例——使用重载的A[] toArray(IntFunction<A[]> generator)方法——更加复杂。Javadoc (docs.oracle.com/javase/8/docs/api/java/util/stream/Stream.html)中说,

“生成函数接受一个整数,这个整数是所需数组的大小,并生成所需大小的数组。”

这意味着在最后一个示例中对toArray(String[]::new)构造函数的方法引用是toArray(size -> new String[size])的缩写版本。

使用数字流进行算术运算

除了Stream接口之外,java.util.stream包还提供了专门的接口——IntStreamDoubleStreamLongStream——专门用于处理相应原始类型的流。它们非常方便使用,并且具有数字操作,如max()min()average()sum()

数字接口具有类似于Stream接口的方法,这意味着我们在前面的创建和操作流中讨论的所有内容也适用于数字流。这就是为什么在本节中,我们只会讨论Stream接口中不存在的方法。

准备工作

除了创建和操作流中描述的方法之外,还可以使用以下方法创建数字流:

  • IntStreamLongStream接口的range(int startInclusive, int endInclusive)rangeClosed(int startInclusive, int endInclusive)方法

  • java.util.Arrays类的六个重载的stream()方法,将数组和子数组转换为数字流

特定于数字流的中间操作列表包括以下内容:

  • boxed(): 将原始类型的数字流转换为相应包装类型的流

  • mapToObj(mapper): 使用提供的函数映射器将原始类型的数字流转换为对象流

  • asDoubleStream()LongStream接口:将LongStream转换为DoubleStream

  • asLongStream()asDoubleStream()IntStream接口:将IntStream转换为相应的数字流

特定于数字流的终端算术操作列表包括以下内容:

  • sum(): 计算数字流元素的总和

  • average(): 计算数字流元素的平均值

  • summaryStatistics():创建一个包含有关流元素的各种摘要数据的对象

如何做到...

  1. 尝试使用IntStreamLongStream接口的range(int startInclusive, int endInclusive)rangeClosed(int startInclusive, int endInclusive)方法:
IntStream.range(1,3).forEach(System.out::print); //prints: 12
LongStream.range(1,3).forEach(System.out::print); //prints: 12
IntStream.rangeClosed(1,3).forEach(System.out::print);  // 123
LongStream.rangeClosed(1,3).forEach(System.out::print); // 123

如您所见,range()rangeClosed()方法之间的区别在于第二个参数的排除或包含。这也导致了在两个参数具有相同值的情况下产生以下结果:

IntStream.range(3,3).forEach(System.out::print);
                                                //prints:
LongStream.range(3,3).forEach(System.out::print);      
                                                //prints:
IntStream.rangeClosed(3,3).forEach(System.out::print); 
                                                //prints: 3
LongStream.rangeClosed(3,3).forEach(System.out::print);
                                                //prints: 3

在前面的示例中,range()方法不会发出任何元素,而rangeClosed()方法只会发出一个元素。

请注意,当第一个参数大于第二个参数时,这些方法都不会生成错误。它们只是不发出任何内容,随后的语句也不会产生输出:

IntStream.range(3,1).forEach(System.out::print);        
LongStream.range(3,1).forEach(System.out::print);       
IntStream.rangeClosed(3,1).forEach(System.out::print);  
LongStream.rangeClosed(3,1).forEach(System.out::print); 

  1. 如果您不需要流元素的值是顺序的,可以首先创建一个值的数组,然后使用java.util.Arrays类的六个重载的stream()静态方法之一生成流:
IntStream stream(int[] array)
IntStream stream(int[] array, int startInclusive, 
 int endExclusive)
LongStream stream(long[] array)
LongStream stream(long[] array, int startInclusive, 
                                           int endExclusive)
DoubleStream stream(double[] array)
DoubleStream stream(double[] array, int startInclusive, 
                                           int endExclusive)

以下是Arrays.stream()方法的使用示例:

int[] ai = {2, 3, 1, 5, 4};
Arrays.stream(ai)
      .forEach(System.out::print);  //prints: 23154
Arrays.stream(ai, 1, 3)
      .forEach(System.out::print);  //prints: 31
long[] al = {2, 3, 1, 5, 4};
Arrays.stream(al)
       .forEach(System.out::print);  //prints: 23154
Arrays.stream(al, 1, 3)
       .forEach(System.out::print);  //prints: 31
double[] ad = {2., 3., 1., 5., 4.};
Arrays.stream(ad)
  .forEach(System.out::print);  //prints: 2.03.01.05.04.0
Arrays.stream(ad, 1, 3)
      .forEach(System.out::print);  //prints: 3.01.0

最后两个流水线可以通过使用我们在上一篇文章中讨论的 joining 收集器来改进,以更加人性化的格式打印DoubleStream的元素:

double[] ad = {2., 3., 1., 5., 4.};
String res = Arrays.stream(ad).mapToObj(String::valueOf)
                       .collect(Collectors.joining(" ")); 
System.out.println(res);   //prints: 2.0 3.0 1.0 5.0 4.0
res = Arrays.stream(ad, 1, 3).mapToObj(String::valueOf)
                       .collect(Collectors.joining(" "));  
System.out.println(res);               //prints: 3.0 1.0

由于Collector<CharSequence, ?, String> joining 收集器接受CharSequence作为输入类型,我们必须使用中间操作mapToObj()将数字转换为String

  1. 使用mapToObj(mapper)中间操作将原始类型元素转换为引用类型。我们在第 2 步中看到了它的使用示例。mapper 函数可以简单也可以复杂,以便实现必要的转换。

还有一个专门的操作boxed(),没有参数,可以将原始数值类型的元素转换为相应的包装类型——int值转换为Integer值,long值转换为Long值,double值转换为Double值。例如,我们可以使用它来实现与mapToObj(mapper)操作的最后两个示例相同的结果:

double[] ad = {2., 3., 1., 5., 4.};
String res = Arrays.stream(ad).boxed()
                   .map(Object::toString)
                   .collect(Collectors.joining(" ")); 
System.out.println(res); //prints: 2.0 3.0 1.0 5.0 4.0
res = Arrays.stream(ad, 1, 3).boxed()
                     .map(Object::toString)
                     .collect(Collectors.joining(" ")); 
System.out.println(res); //prints: 3.0 1.0
  1. 还有一些中间操作,可以将数值流的元素从一个原始类型转换为另一个原始类型:IntStream接口中的asLongStream()asDoubleStream(),以及LongStream接口中的asDoubleStream()。让我们看看它们的使用示例:
IntStream.range(1, 3).asLongStream()
              .forEach(System.out::print); //prints: 12
IntStream.range(1, 3).asDoubleStream()
 .forEach(d -> System.out.print(d + " ")); //prints: 1.0 2.0
LongStream.range(1, 3).asDoubleStream()
 .forEach(d -> System.out.print(d + " ")); //prints: 1.0 2.0

您可能已经注意到,这些操作仅适用于扩展原始转换:从int类型到longdouble,以及从longdouble

  1. 特定于数值流的终端算术操作非常简单。以下是IntStreamsum()average()操作的示例:
         int sum = IntStream.empty().sum();
         System.out.println(sum);                   //prints: 0
         sum = IntStream.range(1, 3).sum();
         System.out.println(sum);                   //prints: 3
         double av = IntStream.empty().average().orElse(0);
         System.out.println(av);                   //prints: 0.0
         av = IntStream.range(1, 3).average().orElse(0);
         System.out.println(av);                   //prints: 1.5

如您所见,average()操作返回OptionalDouble。有趣的是考虑为什么作者决定为average()返回OptionalDouble,但对于sum()却没有。这个决定可能是为了将空流映射到空的OptionalDouble,但是当sum()应用于空流时返回0的决定似乎是不一致的。

这些操作对LongStreamDoubleStream的行为方式相同:

        long suml = LongStream.range(1, 3).sum();
        System.out.println(suml);                 //prints: 3
        double avl = LongStream.range(1, 3).average().orElse(0);
        System.out.println(avl);                  //prints: 1.5

        double sumd = DoubleStream.of(1, 2).sum();
        System.out.println(sumd);                 //prints: 3.0
        double avd = DoubleStream.of(1, 2).average().orElse(0);
        System.out.println(avd);                  //prints: 1.5

  1. summaryStatistics()终端操作收集有关流元素的各种摘要数据:
     IntSummaryStatistics iss = 
                    IntStream.empty().summaryStatistics();
     System.out.println(iss);   //count=0, sum=0, 
       //min=2147483647, average=0.000000, max=-2147483648
     iss = IntStream.range(1, 3).summaryStatistics();
     System.out.println(iss);    //count=2, sum=3, min=1, 
                                 //average=1.500000, max=2

     LongSummaryStatistics lss = 
                    LongStream.empty().summaryStatistics();
     System.out.println(lss);  //count=0, sum=0, 
                               //min=9223372036854775807, 
               //average=0.000000, max=-9223372036854775808
     lss = LongStream.range(1, 3).summaryStatistics();
     System.out.println(lss);  //count=2, sum=3, min=1, 
                               //average=1.500000, max=2

     DoubleSummaryStatistics dss = 
                   DoubleStream.empty().summaryStatistics();
     System.out.println(dss);  //count=0, sum=0.000000, 
            //min=Infinity, average=0.000000, max=-Infinity
     dss = DoubleStream.of(1, 2).summaryStatistics();
     System.out.println(dss);  //count=2, sum=3.000000, 
             //min=1.000000, average=1.500000, max=2.000000

添加到前面打印行的注释来自IntSummaryStatisticsLongSummaryStatisticsDoubleSummaryStatistics对象的toString()方法。这些对象的其他方法包括getCount()getSum()getMin()getAverage()getMax(),允许访问收集统计的特定方面。

请注意,在空流的情况下,最小值(最大值)是相应 Java 类型的最小(最大)可能值:

    System.out.println(Integer.MAX_VALUE); // 2147483647
    System.out.println(Integer.MIN_VALUE); //-2147483648
    System.out.println(Long.MAX_VALUE);    // 9223372036854775807
    System.out.println(Long.MIN_VALUE);    //-9223372036854775808
    System.out.println(Double.MAX_VALUE);  //1.7976931348623157E308
    System.out.println(Double.MIN_VALUE);  //4.9E-324

只有DoubleSummaryStatistics显示Infinity-Infinity作为最小和最大值,而不是这里显示的实际数字。根据这些方法的 Javadoc,getMax()返回“记录的最大值,如果任何记录的值为NaN,则返回Double.NaN,如果没有记录值,则返回Double.NEGATIVE_INFINITY”,getMin()返回“记录的最小值,如果任何记录的值为NaN,则返回Double.NaN,如果没有记录值,则返回Double.POSITIVE_INFINITY”。

另外,请注意,与average()终端流操作相比,前述摘要统计的getAverage()方法返回流数值的算术平均值,如果从流中没有发出值,则返回零,而不是Optional对象。

还有更多...

IntSummaryStatisticsLongSummaryStatisticsDoubleSummaryStatistics对象不仅可以通过summaryStatistics()数字流终端操作创建。这样的对象也可以通过应用于任何Stream对象的collect()终端操作来创建,而不仅仅是IntStreamLongStreamDoubleStream

每个摘要统计对象都有accept()combine()方法,允许我们创建一个可以传递到collect()操作并产生相应摘要统计对象的Collector对象。我们将通过创建IntSummaryStatistics对象来演示这种可能性。LongSummaryStatisticsDoubleSummaryStatistics对象可以类似地创建。

IntSummaryStatistics类有以下两种方法:

  • void accept(int value):将新值包含到统计摘要中

  • void combine(IntSummaryStatistics other):将提供的other对象的收集统计信息添加到当前对象中

这些方法允许我们在任何Stream对象上使用R collect(Supplier<R> supplier, BiConsumer<R,? super T> accumulator, BiConsumer<R,R> combiner)操作的重载版本,如下所示:

IntSummaryStatistics iss = Stream.of(3, 1)
        .collect(IntSummaryStatistics::new,
                 IntSummaryStatistics::accept,
                 IntSummaryStatistics::combine
        );
System.out.println(iss);  //count=2, sum=4, min=1, 
                          //average=2.000000, max=3

正如您所看到的,该流不是专门的数字流。它只有与创建的摘要统计对象相同类型的数值元素。尽管如此,我们仍然能够创建一个IntSummaryStatistics类的对象。同样,也可以创建LongSummaryStatisticsDoubleSummaryStatistics类的对象。

请注意,第三个参数combiner仅用于并行流处理——它将并行处理的子流的结果合并起来。为了演示这一点,我们可以将前面的示例更改如下:

IntSummaryStatistics iss = Stream.of(3, 1)
   .collect(IntSummaryStatistics::new,
      IntSummaryStatistics::accept,
      (r, r1) -> {
        System.out.println("Combining...");  //is not printing
        r.combine(r1);
      }
   );
System.out.println(iss); //count=2, sum=4, min=1, 
                          //average=2.000000, max=3

Combining...行没有打印。让我们将流更改为并行流:

IntSummaryStatistics iss = Stream.of(3, 1)
     .parallel()
     .collect(IntSummaryStatistics::new,
         IntSummaryStatistics::accept,
         (r, r1) -> {
             System.out.println("Combining...");  //Now it prints!
             r.combine(r1);
         }
     );
System.out.println(iss); //count=2, sum=4, min=1, 
                          //average=2.000000, max=3

如果现在运行前面的代码,您将看到Combining...行。

收集统计信息的另一种方法是使用Collectors类的以下方法之一创建的Collector对象:

Collector<T, ?, IntSummaryStatistics> 
                   summarizingInt (ToIntFunction<T> mapper)
Collector<T, ?, LongSummaryStatistics> 
                  summarizingLong(ToLongFunction<T> mapper)
Collector<T, ?, DoubleSummaryStatistics> 
              summarizingDouble(ToDoubleFunction<T> mapper)

同样,我们将使用前述方法中的第一个来创建IntSummaryStatistics对象。假设我们有以下Person类:

class Person {
    private int age;
    private String name;
    public Person(int age, String name) {
        this.name = name;
        this.age = age;
    }
    public int getAge() { return this.age; }
    public String getName() { return this.name; }
}

如果有一个Person类对象的流,我们可以按如下方式收集人的年龄(流元素)的统计信息:

IntSummaryStatistics iss = 
   Stream.of(new Person(30, "John"), new Person(20, "Jill"))
         .collect(Collectors.summarizingInt(Person::getAge));
System.out.println(iss);     //count=2, sum=50, min=20, 
                             //average=25.000000, max=30

正如您所看到的,我们只能收集与收集统计信息类型匹配的对象字段的统计信息。流及其元素都不是数字。

在尝试创建自定义的Collector对象之前,查看java.util.stream.Collectors类的 Javadoc,看看它提供了哪些其他功能。

通过生成集合完成流

您将学习并练习如何使用collect()终端操作将流元素重新打包到目标集合结构中。

做好准备

collect()终端操作有两个重载版本,允许我们创建流元素的集合:

  • R collect(Supplier<R> supplier, BiConsumer<R,T> accumulator, BiConsumer<R,R> combiner): 使用传入的函数应用于T类型的流元素产生R结果。提供的供应商和累加器一起工作如下:
                 R result = supplier.get();
                 for (T element : this stream) {
                    accumulator.accept(result, element);
                 }
                 return result;

提供的组合器仅用于并行流的处理。它合并并行处理的子流的结果。

  • R collect(Collector<T, A, R> collector): 使用传入的Collector对象应用于T类型的流元素产生R结果。A类型是Collector的中间累积类型。Collector对象可以使用Collector.of()工厂方法构建,但我们不打算在本教程中讨论它,因为java.util.stream.Collectors类中有许多可用的工厂方法可以满足大部分需求。此外,学会如何使用Collectors类后,您也将能够使用Collector.of()方法。

在本教程中,我们将演示如何使用Collectors类的以下方法:

  • Collector<T, ?, List<T>> toList(): 创建一个Collector对象,将T类型的流元素收集到一个List<T>对象中

  • Collector<T, ?, Set<T>> toSet(): 创建一个Collector对象,将T类型的流元素收集到一个Set<T>对象中

  • Collector<T, ?, C> toCollection(Supplier<C> collectionFactory): 创建一个Collector对象,将T类型的流元素收集到由collectionFactor供应商产生的C类型的Collection

  • Collector<T, ?, List<T>> toUnmodifiableList(): 创建一个Collector对象,将T类型的流元素收集到一个不可变的List<T>对象中

  • Collector<T, ?, Set<T>> toUnmodifiableSet(): 创建一个Collector对象,将T类型的流元素收集到一个不可变的Set<T>对象中

对于我们的演示,我们将使用以下Person类:

class Person {
    private int age;
    private String name;
    public Person(int age, String name) {
        this.age = age;
        this.name = name;
    }
    public int getAge() { return this.age; }
    public String getName() { return this.name; }
    @Override
    public boolean equals(Object o) {
        if (this == o) return true;
        if (!(o instanceof Person)) return false;
        Person person = (Person) o;
        return getAge() == person.getAge() &&
                Objects.equals(getName(), person.getName());
    }
    @Override
    public int hashCode() {
        return Objects.hash(getName(), getAge());
    }
    @Override
    public String toString() {
        return "Person{name:" + this.name + ",age:" + this.age + "}";
    }
}

如何做到这一点...

我们将带您完成一系列实际步骤,演示如何使用前面的方法和类:

  1. 编写使用Stream<T>接口的R collect(Supplier<R> supplier, BiConsumer<R,T> accumulator, BiConsumer<R,R> combiner)操作产生List<T>对象的用法示例:
 List<Person> list = 
    Stream.of(new Person(30, "John"), new Person(20, "Jill"))
    .collect(ArrayList::new,
             List::add,      //same as: (a,p)-> a.add(p),
             List::addAll    //same as: (r, r1)-> r.addAll(r1)
    );
 System.out.println(list);
   //prints: [Person{name:John,age:30}, Person{name:Jill,age:20}]  

在前面的示例中,累加器和组合器的注释演示了如何将这些函数表示为 lambda 表达式,而不仅仅是方法引用。

第一个参数Supplier<R>返回结果的容器。在我们的例子中,我们将其定义为ArrayList<Person>类的构造函数,因为它实现了List<Person>接口,这是我们想要构造的对象类型。

累加器接受当前结果a(在我们的例子中将是List<Person>类型),并将下一个流元素p(在我们的例子中是Person对象)添加到其中。示例的输出显示为最后一行注释。

组合器将并行处理的子流的结果合并。它获取第一个结果r(任何第一个完成处理的子流的结果),并添加另一个结果r1,依此类推。这意味着组合器仅用于并行处理。为了证明这一点,让我们修改前面的代码如下:

  List<Person> list = 
     Stream.of(new Person(30, "John"), new Person(20, "Jill"))
           .collect(ArrayList::new,
                    ArrayList::add,
                    (r, r1)-> {
                        System.out.println("Combining...");
                        r.addAll(r1);
                    }
           );
  System.out.println(list1);  
   //prints: [Person{name:John,age:30}, Person{name:Jill,age:20}]

如果运行前面的示例,您将看不到打印出Combining...行,因为combiner在顺序流处理中未被使用。

现在,让我们将流转换为并行流:

 List<Person> list = 
    Stream.of(new Person(30, "John"), new Person(20, "Jill"))
          .parallel()
          .collect(ArrayList::new,
                   ArrayList::add,
                   (r, r1)-> {
                      System.out.println("Combining...");
                      r.addAll(r1);
                   }
          );
  System.out.println(list1);  
    //prints: [Person{name:John,age:30}, Person{name:Jill,age:20}]

如果运行前面的代码,将显示Combining...行。

只要每个函数的输入和返回类型保持不变,就可以根据需要修改提供的函数。

Set<Person>对象可以以相同的方式创建:

 Set<Person> set = 
   Stream.of(new Person(30, "John"), new Person(20, "Jill"))
         .collect(HashSet::new,
                  Set::add,      //same as: (a,p)-> a.add(p),
                  Set::addAll    //same as: (r, r1)-> r.addAll(r1)
         );
 System.out.println(set);  
   //prints: [Person{name:John,age:30}, Person{name:Jill,age:20}]

创建的ListSet对象可以随时修改:

list.add(new Person(30, "Bob"));
System.out.println(list);  //prints: [Person{name:John,age:30}, 
                           //         Person{name:Jill,age:20}, 
                           //         Person{name:Bob,age:30}]
list.set(1, new Person(15, "Bob"));
System.out.println(list);  //prints: [Person{name:John,age:30}, 
                           //         Person{name:Bob,age:15}, 
                           //         Person{name:Bob,age:30}]
set.add(new Person(30, "Bob"));
System.out.println(set);   //prints: [Person{name:John,age:30}, 
                           //         Person{name:Jill,age:20}, 
                           //         Person{name:Bob,age:30}]

我们已经提到它是为了与不可变集合的行为进行对比,我们很快就会讨论。

  1. 编写使用由Collector<T, ?, List<T>> Collectors.toList()Collector<T, ?, Set<T>> Collectors.toSet()方法创建的收集器的R collect(Collector<T, A, R> collector)操作的Stream<T>接口的用法示例:
       List<Person> list = Stream.of(new Person(30, "John"), 
                                     new Person(20, "Jill"))
                .collect(Collectors.toList());
       System.out.println(list);  //prints: [Person{name:John,age:30}, 
                                  //         Person{name:Jill,age:20}]

       Set<Person> set1 = Stream.of(new Person(30, "John"), 
                                    new Person(20, "Jill"))
                .collect(Collectors.toSet());
       System.out.println(set1); //prints: [Person{name:John,age:30}, 
                                            Person{name:Jill,age:20}]

       Set<Person> set2 = Stream.of(new Person(30, "John"), 
                                    new Person(20, "Jill"), 
                                    new Person(30, "John"))
                .collect(Collectors.toSet());
        System.out.println(set2); //prints: [Person{name:John,age:30}, 
                                             Person{name:Jill,age:20}]
        set2.add(new Person(30, "Bob"));
        System.out.println(set2); //prints: [Person{name:John,age:30}, 
                                             Person{name:Jill,age:20}, 
                                             Person{name:Bob,age:30}]

正如预期的那样,Set不允许由equals()方法实现定义的重复元素。在Person类的情况下,equals()方法比较年龄和姓名,因此这些属性的任何差异都会使两个Person对象不相等。

  1. 编写使用由Collector<T, ?, C> Collectors.toCollection(Supplier<C> collectionFactory)方法创建的收集器的R collect(Collector<T, A, R> collector)操作的Stream<T>接口的用法示例。这个收集器的优点是它不仅可以收集流元素到ListSet中,而且可以收集到实现Collection接口的任何对象中。收集T类型的流元素的目标对象由collectionFactor供应商生成:
LinkedList<Person> list = Stream.of(new Person(30, "John"), 
                                    new Person(20, "Jill"))
        .collect(Collectors.toCollection(LinkedList::new));
System.out.println(list);  //prints: [Person{name:John,age:30}, 
                            //        Person{name:Jill,age:20}]

LinkedHashSet<Person> set = Stream.of(new Person(30, "John"), 
                                      new Person(20, "Jill"))
        .collect(Collectors.toCollection(LinkedHashSet::new));
System.out.println(set);  //prints: [Person{name:John,age:30}, 
                                     Person{name:Jill,age:20}]
  1. 编写使用由Collector<T, ?, List<T>> Collectors.toUnmodifiableList()Collector<T, ?, Set<T>> Collectors.toUnmodifiableSet()方法创建的收集器的R collect(Collector<T, A, R> collector)操作的Stream<T>接口的用法示例:
List<Person> list = Stream.of(new Person(30, "John"), 
                              new Person(20, "Jill"))
        .collect(Collectors.toUnmodifiableList());
System.out.println(list);  //prints: [Person{name:John,age:30}, 
                           //         Person{name:Jill,age:20}]

list.add(new Person(30, "Bob"));  //UnsupportedOperationException
list.set(1, new Person(15, "Bob")); //UnsupportedOperationException
list.remove(new Person(30, "John")); //UnsupportedOperationException

Set<Person> set = Stream.of(new Person(30, "John"), 
                            new Person(20, "Jill"))
        .collect(Collectors.toUnmodifiableSet());
System.out.println(set);  //prints: [Person{name:John,age:30}, 
                          //         Person{name:Jill,age:20}]

set.add(new Person(30, "Bob"));  //UnsupportedOperationException

从前面代码中的注释中可以看出,使用由Collector<T, ?, List<T>> Collectors.toUnmodifiableList()Collector<T, ?, Set<T>> Collectors.toUnmodifiableSet()方法生成的收集器创建的对象是不可变的。当在 lambda 表达式中使用时,这样的对象非常有用,因为这样我们可以保证它们不会被修改,因此相同的表达式即使在不同的上下文中传递和执行,也只会产生依赖于其输入参数的结果,并且不会由于修改它使用的ListSet对象而产生意外的副作用。

例如:

Set<Person> set = Stream.of(new Person(30, "John"), 
                            new Person(20, "Jill"))
        .collect(Collectors.toUnmodifiableSet());

Predicate<Person> filter = p -> set.contains(p);

在前面的例子中创建的过滤器可以在任何地方使用,以选择属于提供的集合的Person对象。

通过生成映射来完成流

您将学习并练习如何使用collect()终端操作将流元素重新打包到目标Map结构中。在讨论收集器时,我们不会包括使用分组的收集器,因为它们将在下一篇中介绍。

准备工作

如前一篇中提到的,collect()终端操作有两个重载版本,允许我们创建流元素的集合:

  • R collect(Supplier<R> supplier, BiConsumer<R,T> accumulator, BiConsumer<R,R> combiner): 使用应用于T类型的流元素的传入函数生成R结果

  • R collect(Collector<T, A, R> collector): 使用应用于T类型的流元素的传入Collector对象生成R结果

这些操作也可以用来创建Map对象,在本篇中,我们将演示如何做到这一点。

支持前述collect()操作的第二个版本,Collectors类提供了四组工厂方法,用于创建Collector对象。第一组包括与前一篇中讨论和演示的将流元素收集到ListSet中的Collector对象非常相似的工厂方法:

  • Collector<T,?,Map<K,U>> toMap(Function<T,K> keyMapper, Function<T,U> valueMapper): 创建一个Collector对象,使用提供的函数(映射器)将T类型的流元素收集到Map<K,U>对象中,这些函数从流元素作为输入参数产生键和值。

  • Collector<T,?,Map<K,U>> toMap(Function<T,K> keyMapper, Function<T,U> valueMapper, BinaryOperator<U> mergeFunction): 创建一个Collector对象,使用提供的函数(映射器)将T类型的流元素收集到一个Map<K,U>对象中,这些函数从流元素中产生一个键和一个值作为输入参数。提供的mergeFunction仅用于并行流处理;它将子流的结果合并为最终结果——Map<K,U>对象。

  • Collector<T,?,M> toMap(Function<T,K> keyMapper, Function<T,U> valueMapper, BinaryOperator<U> mergeFunction, Supplier<M> mapFactory): 创建一个Collector对象,使用提供的函数(映射器)将T类型的流元素收集到一个Map<K,U>对象中,这些函数从流元素中产生一个键和一个值作为输入参数。提供的mergeFunction仅用于并行流处理;它将子流的结果合并为最终结果——Map<K,U>对象。提供的mapFactory供应商创建一个空的Map<K,U>对象,结果将被插入其中。

  • Collector<T,?,Map<K,U>> toUnmodifiableMap(Function<T,K> keyMapper, Function<T,U> valueMapper): 创建一个Collector对象,使用提供的函数(映射器)将T类型的流元素收集到一个不可变Map<K,U>对象中,这些函数从流元素中产生一个键和一个值作为输入参数。

  • Collector<T,?,Map<K,U>> toUnmodifiableMap(Function<T,K> keyMapper, Function<T,U> valueMapper, BinaryOperator<U> mergeFunction): 创建一个Collector对象,使用提供的函数(映射器)将T类型的流元素收集到一个不可变Map<K,U>对象中,这些函数从流元素中产生一个键和一个值作为输入参数。提供的mergeFunction仅用于并行流处理;它将子流的结果合并为最终结果——一个不可变的Map<K,U>对象。

第二组包括三个工厂方法,类似于我们刚刚列出的三个toMap()方法。唯一的区别是,由toConcurrentMap()方法创建的收集器将流元素收集到ConcurrentMap对象中:

  • Collector<T,?,ConcurrentMap<K,U>> toConcurrentMap(Function<T,K> keyMapper, Function<T,U> valueMapper): 创建一个Collector对象,使用提供的函数(映射器)将T类型的流元素收集到一个ConcurrentMap<K,U>对象中,这些函数从流元素中产生一个键和一个值作为输入参数。

  • Collector<T,?,ConcurrentMap<K,U>> toConcurrentMap(Function<T,K> keyMapper, Function<T,U> valueMapper, BinaryOperator<U> mergeFunction): 创建一个Collector对象,使用提供的函数(映射器)将T类型的流元素收集到一个ConcurrentMap<K,U>对象中,这些函数从流元素中产生一个键和一个值作为输入参数。提供的mergeFunction仅用于并行流处理;它将子流的结果合并为最终结果——ConcurrentMap<K,U>对象。

  • Collector<T,?,M> toConcurrentMap(Function<T,K> keyMapper, Function<T,U> valueMapper, BinaryOperator<U> mergeFunction, Supplier<M> mapFactory): 创建一个Collector对象,使用提供的函数(映射器)将T类型的流元素收集到一个ConcurrentMap<K,U>对象中,这些函数从流元素中产生一个键和一个值作为输入参数。提供的mergeFunction仅用于并行流处理;它将子流的结果合并为最终结果——ConcurrentMap<K,U>对象。提供的mapFactory供应商创建一个空的ConcurrentMap<K,U>对象,结果将被插入其中。

对于并行流,需要第二组工厂方法的原因是,合并不同子流的结果是一项昂贵的操作。当结果必须按照遇到的顺序合并到结果Map中时,这种操作尤其繁重——这就是toMap()工厂方法创建的收集器所做的。这些收集器创建多个中间结果,然后通过多次调用收集器的供应商和组合器来合并它们。

当结果合并的顺序不重要时,由toConcurrentMap()方法创建的收集器可以用作较轻量级的,因为它们只调用一次供应商,在共享结果容器中插入元素,并且从不调用组合器。

因此,toMap()toConcurrentMap()收集器之间的区别只在并行流处理期间显现。这就是为什么通常建议对于串行流处理使用toMap()收集器,对于并行流处理使用toConcurrentMap()收集器(如果收集流元素的顺序不重要)。

第三组包括三个groupingBy()工厂方法,我们将在下一个示例中讨论。

第四组包括三个groupingByConcurrent()工厂方法,我们也将在下一个示例中讨论。

对于我们的演示,我们将使用与上一个示例中创建集合时相同的Person类:

class Person {
    private int age;
    private String name;
    public Person(int age, String name) {
        this.age = age;
        this.name = name;
    }
    public int getAge() { return this.age; }
    public String getName() { return this.name; }
    @Override
    public boolean equals(Object o) {
        if (this == o) return true;
        if (!(o instanceof Person)) return false;
        Person person = (Person) o;
        return getAge() == person.getAge() &&
                Objects.equals(getName(), person.getName());
    }
    @Override
    public int hashCode() {
        return Objects.hash(getName(), getAge());
    }
    @Override
    public String toString() {
        return "Person{name:" + this.name + ",age:" + this.age + "}";
    }
}

如何做...

我们将带你走过一系列实际步骤,演示如何使用前面的方法和类:

  1. 使用Stream<T>接口的R collect(Supplier<R> supplier, BiConsumer<R,T> accumulator, BiConsumer<R,R> combiner)操作的用法示例,生成Map对象。创建Map<String, Person>,以人名作为键:
Map<String, Person> map = Stream.of(new Person(30, "John"), 
                                    new Person(20, "Jill"))
        .collect(HashMap::new,
                (m,p) -> m.put(p.getName(), p),
                Map::putAll
        );
System.out.println(map); //prints: {John=Person{name:John,age:30}, 
                         //         Jill=Person{name:Jill,age:20}}

或者,为了避免结果Map中的冗余数据,我们可以使用年龄字段作为Map的值:

Map<String, Integer> map = Stream.of(new Person(30, "John"), 
                                     new Person(20, "Jill"))
        .collect(HashMap::new,
                (m,p) -> m.put(p.getName(), p.getAge()),
                Map::putAll
        );
System.out.println(map);       //prints: {John=30, Jill=20}

组合器仅在并行流中调用,因为它用于组合不同子流处理的结果。为了证明这一点,我们已经用打印消息Combining...的代码块替换了方法引用Map::putAll

Map<String, Integer> map = Stream.of(new Person(30, "John"), 
                                     new Person(20, "Jill"))
      //.parallel()     //conversion to a parallel stream
        .collect(HashMap::new,
                (m,p) -> m.put(p.getName(), p.getAge()),
                (m,m1) -> {
                      System.out.println("Combining...");
                      m.putAll(m1);
                }
        );
System.out.println(map);  //prints: {John=30, Jill=20}

只有在未注释掉转换为并行流时,才会显示Combining...消息。

如果我们添加另一个具有相同名称的Person对象,其中一个将在结果Map中被覆盖:

Map<String, Integer> map = Stream.of(new Person(30, "John"), 
                                     new Person(20, "Jill"),
                                     new Person(15, "John"))
        .collect(HashMap::new,
                (m,p) -> m.put(p.getName(), p.getAge()),
                Map::putAll
        );
System.out.println(map);       //prints: {John=15, Jill=20}

如果这种行为不可取,并且我们需要查看所有重复键的所有值,我们可以将结果Map更改为具有List对象作为值,这样在这个列表中我们可以收集所有具有相同键的值:

BiConsumer<Map<String, List<Integer>>, Person> consumer = 
(m,p) -> {
    List<Integer> list = m.get(p.getName());
    if(list == null) {
        list = new ArrayList<>(); 
        m.put(p.getName(), list);
    }
    list.add(p.getAge());
};
Map<String, List<Integer>> map = 
  Stream.of(new Person(30, "John"), 
            new Person(20, "Jill"), 
            new Person(15, "John"))
        .collect(HashMap::new, consumer, Map::putAll);
System.out.println(map);
                   //prints: {John=[30, 15], Jill=[20]}

正如你所看到的,我们没有将BiConsumer函数内联到collect()操作中作为参数,因为现在它是多行代码,这样阅读起来更容易。

在这种情况下,收集相同键的多个值的另一种方法是创建具有String值的Map,如下所示:

BiConsumer<Map<String, String>, Person> consumer2 = (m,p) -> {
 if(m.keySet().contains(p.getName())) {
   m.put(p.getName(), m.get(p.getName()) + "," + p.getAge());
 } else {
   m.put(p.getName(), String.valueOf(p.getAge()));
 }
};
Map<String, String> map = Stream.of(new Person(30, "John"), 
                                    new Person(20, "Jill"), 
                                    new Person(15, "John"))
        .collect(HashMap::new, consumer, Map::putAll);
System.out.println(map);    //prints: {John=30,15, Jill=20}
  1. 使用Stream<T>接口的R collect(Collector<T, A, R> collector)操作的用法示例,使用Collector<T, ?, Map<K,U>> Collectors.toMap(Function<T,K> keyMapper, Function<T,U> valueMapper)方法创建的收集器:
Map<String, Integer> map = Stream.of(new Person(30, "John"), 
                                     new Person(20, "Jill"))
  .collect(Collectors.toMap(Person::getName, Person::getAge));
System.out.println(map);     //prints: {John=30, Jill=20}

只要没有遇到重复键,前面的解决方案就能正常工作,就像下面的情况一样:

Map<String, Integer> map = Stream.of(new Person(30, "John"), 
                                     new Person(20, "Jill"),
                                     new Person(15, "John"))
.collect(Collectors.toMap(Person::getName, Person::getAge));

前面的代码抛出了IllegalStateException,并显示了Duplicate key John(尝试合并值 30 和 15)的消息,我们无法为重复键添加检查,就像之前做的那样。因此,如果存在重复键的可能性,就必须使用toMap()方法的重载版本。

  1. 使用Stream<T>接口的R collect(Collector<T, A, R> collector)操作的用法示例,使用Collector<T, ?, Map<K,U>> Collectors.toMap(Function<T,K> keyMapper, Function<T,U> valueMapper, BinaryOperator<U> mergeFunction)方法创建的收集器:
Function<Person, List<Integer>> valueMapper = p -> {
    List<Integer> list = new ArrayList<>();
    list.add(p.getAge());
    return list;
};
BinaryOperator<List<Integer>> mergeFunction = (l1, l2) -> {
    l1.addAll(l2);
    return l1;
};
Map<String, List<Integer>> map = 
   Stream.of(new Person(30, "John"), 
             new Person(20, "Jill"), 
             new Person(15, "John"))
         .collect(Collectors.toMap(Person::getName, 
                           valueMapper, mergeFunction));
System.out.println(map); 
                     //prints: {John=[30, 15], Jill=[20]}

这就是mergeFunction的目的——合并重复键的值。我们还可以将重复键的值收集到一个String对象中,而不是List<Integer>

Function<Person, String> valueMapper = 
                        p -> String.valueOf(p.getAge());
BinaryOperator<String> mergeFunction = 
                              (s1, s2) -> s1 + "," + s2;
Map<String, String> map = 
  Stream.of(new Person(30, "John"), 
            new Person(20, "Jill"), 
            new Person(15, "John"))
        .collect(Collectors.toMap(Person::getName, 
                           valueMapper, mergeFunction));
System.out.println(map3);//prints: {John=30,15, Jill=20}
  1. 使用Stream<T>接口的R collect(Collector<T, A, R> collector)操作的用法示例,使用Collector<T, ?, M> Collectors.toMap(Function<T,K> keyMapper, Function<T,U> valueMapper, BinaryOperator<U> mergeFunction, Supplier<M> mapFactory)方法创建的收集器:
Function<Person, String> valueMapper = 
                           p -> String.valueOf(p.getAge());
BinaryOperator<String> mergeFunction = 
                                 (s1, s2) -> s1 + "," + s2;
LinkedHashMap<String, String> map = 
   Stream.of(new Person(30, "John"), 
             new Person(20, "Jill"), 
             new Person(15, "John"))
         .collect(Collectors.toMap(Person::getName, 
           valueMapper, mergeFunction, LinkedHashMap::new));
System.out.println(map3);    //prints: {John=30,15, Jill=20} 

正如你所看到的,这个toMap()方法的版本允许我们指定所需的Map接口实现(在这种情况下是LinkedHashMap类),而不是使用默认的实现。

  1. 使用Stream<T>接口的R collect(Collector<T, A, R> collector)操作的用法示例,使用Collector<T, ?, Map<K,U>> Collectors.toUnmodifiableMap(Function<T,K> keyMapper, Function<T,U> valueMapper)方法创建的收集器:
Map<String, Integer> map = Stream.of(new Person(30, "John"), 
                                      new Person(20, "Jill"))
        .collect(Collectors.toUnmodifiableMap(Person::getName, 
                                              Person::getAge));
System.out.println(map);          //prints: {John=30, Jill=20}

map.put("N", new Person(42, "N")); //UnsupportedOperationExc
map.remove("John");                //UnsupportedOperationExc

Map<String, Integer> map = Stream.of(new Person(30, "John"), 
                                     new Person(20, "Jill"), 
                                     new Person(15, "John"))
  .collect(Collectors.toUnmodifiableMap(Person::getName, 
    Person::getAge)); //IllegalStateExc: Duplicate key John

正如你所看到的,由toUnmpdifiableMap()方法创建的收集器的行为与由Collector<T, ?, Map<K,U>> Collectors.toMap(Function<T,K> keyMapper, Function<T,U> valueMapper)方法创建的收集器相同,只是它生成一个不可变的Map对象。

  1. 使用Stream<T>接口的R collect(Collector<T, A, R> collector)操作的用法示例,使用Collector<T, ?, Map<K,U>> Collectors.toUnmodifiableMap(Function<T,K> keyMapper, Function<T,U> valueMapper, BinaryOperator<U> mergeFunction)方法创建的收集器:
Function<Person, List<Integer>> valueMapper = p -> {
    List<Integer> list = new ArrayList<>();
    list.add(p.getAge());
    return list;
};
BinaryOperator<List<Integer>> mergeFunction = (l1, l2) -> {
    l1.addAll(l2);
    return l1;
};
Map<String, List<Integer>> map = 
    Stream.of(new Person(30, "John"), 
              new Person(20, "Jill"), 
              new Person(15, "John"))
      .collect(Collectors.toUnmodifiableMap(Person::getName, 
                                valueMapper, mergeFunction));
System.out.println(map); //prints: {John=[30, 15], Jill=[20]}

toUnmpdifiableMap()方法创建的收集器的行为与由Collector<T, ?, Map<K,U>> Collectors.toMap(Function<T,K> keyMapper, Function<T,U> valueMapper, BinaryOperator<U> mergeFunction)方法创建的收集器相同,只是它生成一个不可变的Map对象。它的目的是处理重复键的情况。以下是另一种组合重复键值的方法:

Function<Person, String> valueMapper = 
                             p -> String.valueOf(p.getAge());
BinaryOperator<String> mergeFunction = 
                                   (s1, s2) -> s1 + "," + s2;
Map<String, String> map = Stream.of(new Person(30, "John"), 
                                    new Person(20, "Jill"), 
                                    new Person(15, "John"))
    .collect(Collectors.toUnmodifiableMap(Person::getName, 
                                valueMapper, mergeFunction));
System.out.println(map);      //prints: {John=30,15, Jill=20}
  1. 使用Stream<T>接口的R collect(Collector<T, A, R> collector)操作的用法示例,使用Collector<T, ? ,ConcurrentMap<K,U>> Collectors.toConcurrentMap(Function<T,K> keyMapper, Function<T,U> valueMapper)方法创建的收集器:
ConcurrentMap<String, Integer> map = 
                            Stream.of(new Person(30, "John"), 
                                      new Person(20, "Jill"))
        .collect(Collectors.toConcurrentMap(Person::getName, 
                                            Person::getAge));
System.out.println(map);          /prints: {John=30, Jill=20}

map.put("N", new Person(42, "N")); //UnsupportedOperationExc
map.remove("John");                //UnsupportedOperationExc

ConcurrentMap<String, Integer> map = 
                           Stream.of(new Person(30, "John"), 
                                     new Person(20, "Jill"), 
                                     new Person(15, "John"))
  .collect(Collectors.toConcurrentMap(Person::getName, 
    Person::getAge)); //IllegalStateExc: Duplicate key John

正如你所看到的,由toConcurrentMap()方法创建的收集器的行为与由Collector<T, ?, Map<K,U>> Collectors.toMap(Function<T,K> keyMapper, Function<T,U> valueMapper)Collector<T, ?, Map<K,U>> Collectors.toUnmodifiableMap(Function<T,K> keyMapper, Function<T,U> valueMapper)方法创建的收集器相同,只是它生成一个可变的Map对象,并且在流是并行的时候,在子流之间共享结果Map

  1. 使用Stream<T>接口的R collect(Collector<T, A, R> collector)操作的用法示例,使用Collector<T, ?, ConcurrentMap<K,U>> Collectors.toConcurrentMap(Function<T,K> keyMapper, Function<T,U> valueMapper, BinaryOperator<U> mergeFunction)方法创建的收集器:
Function<Person, List<Integer>> valueMapper = p -> {
    List<Integer> list = new ArrayList<>();
    list.add(p.getAge());
    return list;
};
BinaryOperator<List<Integer>> mergeFunction = (l1, l2) -> {
    l1.addAll(l2);
    return l1;
};
ConcurrentMap<String, List<Integer>> map = 
  Stream.of(new Person(30, "John"), 
            new Person(20, "Jill"), 
            new Person(15, "John"))
       .collect(Collectors.toConcurrentMap(Person::getName, 
                              valueMapper, mergeFunction));
System.out.println(map);
                       //prints: {John=[30, 15], Jill=[20]}

正如你所看到的,由toConcurrentMap()方法创建的收集器的行为与由Collector<T, ?, Map<K,U>> Collectors.toMap(Function<T,K> keyMapper, Function<T,U> valueMapper, BinaryOperator<U> mergeFunction)Collector<T, ?, Map<K,U>> Collectors.toUnmodifiableMap(Function<T,K> keyMapper, Function<T,U> valueMapper, BinaryOperator<U> mergeFunction)方法创建的收集器相同,只是它生成一个可变的Map对象,并且在流是并行的时候,在子流之间共享结果Map。以下是另一种组合重复键值的方法:

Function<Person, String> valueMapper = 
                              p -> String.valueOf(p.getAge());
BinaryOperator<String> mergeFunction = 
                                    (s1, s2) -> s1 + "," + s2;
ConcurrentMap<String, String> map = 
                          Stream.of(new Person(30, "John"), 
                                    new Person(20, "Jill"), 
                                    new Person(15, "John"))
    .collect(Collectors.toConcurrentMap(Person::getName, 
                                 valueMapper, mergeFunction));
System.out.println(map);       //prints: {John=30,15, Jill=20}
  1. 使用Stream<T>接口的R collect(Collector<T, A, R> collector)操作的用法示例,使用Collector<T, ?, M> Collectors.toConcurrentMap(Function<T,K> keyMapper, Function<T,U> valueMapper, BinaryOperator<U> mergeFunction, Supplier<M> mapFactory)方法创建的收集器:
ConcurrentSkipListMap<String, String> map = 
                             Stream.of(new Person(30, "John"), 
                                       new Person(20, "Jill"), 
                                       new Person(15, "John"))
   .collect(Collectors.toConcurrentMap(Person::getName, 
     valueMapper, mergeFunction, ConcurrentSkipListMap::new));
System.out.println(map4);      //prints: {Jill=20, John=30,15}

正如您所看到的,这个toConcurrentMap()方法的版本允许我们指定所需的Map接口实现(在本例中是ConcurrentSkipListMap类),而不是使用默认的实现。

toConcurrentMap()方法创建的收集器与Collector<T, ?, Map<K,U>> Collectors.toMap(Function<T,K> keyMapper, Function<T,U> valueMapper, BinaryOperator<U> mergeFunction, Supplier<M> mapFactory)方法创建的收集器行为相同,但在流并行时,它在子流之间共享结果Map

通过使用分组收集器生成地图来完成流

在这个配方中,您将学习并练习如何使用collect()终端操作来按属性对元素进行分组,并使用收集器将结果存储在Map实例中。

准备工作

有两组收集器使用分组功能,类似于 SQL 语句的group by功能,将流数据呈现为Map对象。第一组包括三个重载的groupingBy()工厂方法:

  • Collector<T, ?, Map<K,List<T>>> groupingBy(Function<T,K> classifier): 创建一个Collector对象,使用提供的classifier函数将T类型的流元素收集到Map<K,List<T>>对象中,将当前元素映射到结果地图中的键。

  • Collector<T,?,Map<K,D>> groupingBy(Function<T,K> classifier, Collector<T,A,D> downstream): 创建一个Collector对象,使用提供的classifier函数将T类型的流元素收集到Map<K,D>对象中,将当前元素映射到中间地图Map<K,List<T>>中的键。然后使用downstream收集器将中间地图的值转换为结果地图Map<K,D>的值。

  • Collector<T, ?, M> groupingBy(Function<T,K> classifier, Supplier<M> mapFactory, Collector<T,A,D> downstream): 创建一个Collector对象,使用提供的classifier函数将T类型的流元素收集到M地图对象中,将当前元素映射到Map<K,List<T>>中的键。然后使用downstream收集器将中间地图的值转换为mapFactory供应商提供的类型的结果地图的值。

第二组收集器包括三个groupingByConcurrent()工厂方法,用于在并行流处理期间处理并发。这些收集器接受与前面列出的groupingBy()收集器的相应重载版本相同的参数。唯一的区别是groupingByConcurrent()收集器的返回类型是ConcurrentHashMap类或其子类的实例:

  • Collector<T, ?, ConcurrentMap<K,List<T>>> groupingByConcurrent(Function<T,K> classifier): 创建一个Collector对象,使用提供的classifier函数将T类型的流元素收集到ConcurrentMap<K,List<T>>对象中,将当前元素映射到结果地图中的键。

  • Collector<T, ?, ConcurrentMap<K,D>> groupingByConcurrent(Function<T,K> classifier, Collector<T,A,D> downstream): 创建一个Collector对象,使用提供的classifier函数将T类型的流元素收集到ConcurrentMap<K,D>对象中,将当前元素映射到ConcurrentMap<K,List<T>>中的键。然后使用downstream收集器将中间地图的值转换为结果地图ConcurrentMap<K,D>的值。

  • Collector<T, ?, M> groupingByConcurrent(Function<T,K> classifier, Supplier<M> mapFactory, Collector<T,A,D> downstream): 创建一个Collector对象,使用提供的classifier函数将T类型的流元素收集到M地图对象中,将当前元素映射到ConcurrentMap<K,List<T>>中的键。然后使用downstream收集器将中间地图的值转换为由mapFactory供应商提供的结果地图的值类型。

对于我们的演示,我们将使用在上一个示例中创建地图时使用的相同Person类:

class Person {
    private int age;
    private String name;
    public Person(int age, String name) {
        this.age = age;
        this.name = name;
    }
    public int getAge() { return this.age; }
    public String getName() { return this.name; }
    @Override
    public boolean equals(Object o) {
        if (this == o) return true;
        if (!(o instanceof Person)) return false;
        Person person = (Person) o;
        return getAge() == person.getAge() &&
                Objects.equals(getName(), person.getName());
    }
    @Override
    public int hashCode() {
        return Objects.hash(getName(), getAge());
    }
    @Override
    public String toString() {
        return "Person{name:" + this.name + ",age:" + this.age + "}";
    }
}

我们还将使用Person2类:

class Person2 {
    private int age;
    private String name, city;
    public Person2(int age, String name, String city) {
        this.age = age;
        this.name = name;
        this.city = city;
    }
    public int getAge() { return this.age; }
    public String getName() { return this.name; }
    public String getCity() { return this.city; }
    @Override
    public boolean equals(Object o) {
        if (this == o) return true;
        if (!(o instanceof Person)) return false;
        Person2 person = (Person2) o;
        return getAge() == person.getAge() &&
                Objects.equals(getName(), person.getName()) &&
                Objects.equals(getCity(), person.getCity());
    }
    @Override
    public int hashCode() {
        return Objects.hash(getName(), getAge(), getCity());
    }
    @Override
    public String toString() {
        return "Person{name:" + this.name + ",age:" + this.age  + 
                                       ",city:" + this.city + "}";
    }
}

Person2类不同于Person类,因为它有一个额外的字段——城市。它将用于展示分组功能的强大功能。Person2类的变体Person3类将用于演示如何创建EnumMap对象。Person3类使用enum City作为其city属性的值类型:

enum City{
    Chicago, Denver, Seattle
}

class Person3 {
    private int age;
    private String name;
    private City city;
    public Person3(int age, String name, City city) {
        this.age = age;
        this.name = name;
        this.city = city;
    }
    public int getAge() { return this.age; }
    public String getName() { return this.name; }
    public City getCity() { return this.city; }
    @Override
    public boolean equals(Object o) {
        if (this == o) return true;
        if (!(o instanceof Person)) return false;
        Person3 person = (Person3) o;
        return getAge() == person.getAge() &&
                Objects.equals(getName(), person.getName()) &&
                Objects.equals(getCity(), person.getCity());
    }
    @Override
    public int hashCode() {
        return Objects.hash(getName(), getAge(), getCity());
    }
    @Override
    public String toString() {
        return "Person{name:" + this.name + ",age:" + this.age  + 
                                       ",city:" + this.city + "}";
    }
}

为了使示例更简洁,我们将使用以下方法生成测试流:

Stream<Person> getStreamPerson() {
    return Stream.of(new Person(30, "John"), 
                     new Person(20, "Jill"), 
                     new Person(20, "John"));
}
Stream<Person2> getStreamPerson2(){
    return Stream.of(new Person2(30, "John", "Denver"), 
                     new Person2(30, "John", "Seattle"), 
                     new Person2(20, "Jill", "Seattle"), 
                     new Person2(20, "Jill", "Chicago"), 
                     new Person2(20, "John", "Denver"),
                     new Person2(20, "John", "Chicago"));
}
Stream<Person3> getStreamPerson3(){
    return Stream.of(new Person3(30, "John", City.Denver), 
                     new Person3(30, "John", City.Seattle),
                     new Person3(20, "Jill", City.Seattle), 
                     new Person3(20, "Jill", City.Chicago),
                     new Person3(20, "John", City.Denver),
                     new Person3(20, "John", City.Chicago));
}

如何做...

我们将带您逐步演示如何使用前面的方法和类:

  1. 使用Stream<T>接口的R collect(Collector<T, A, R> collector)操作的用法示例,使用由Collector<T, ?, Map<K,List<T>>> groupingBy(Function<T,K> classifier)方法创建的收集器:
Map<String, List<Person>> map = getStreamPerson()
        .collect(Collectors.groupingBy(Person::getName));
System.out.println(map);  
               //prints: {John=[Person{name:John,age:30}, 
               //               Person{name:John,age:20}], 
               //         Jill=[Person{name:Jill,age:20}]}

这是Collector对象的最简单版本。您只需定义结果地图的键是什么,收集器将把具有相同键值的所有流元素添加到结果地图中与该键关联的元素列表中。

这是另一个例子:

Map<Integer, List<Person>> map = getStreamPerson()
        .collect(Collectors.groupingBy(Person::getAge));
System.out.println(map);  
                //prints: {20=[Person{name:Jill,age:20}, 
                //             Person{name:John,age:20}], 
                //         30=[Person{name:John,age:30}]}

如果流元素必须按属性组合分组,可以创建一个可以包含必要组合的类。这个类的对象将作为复杂键。例如,让我们读取Person2元素的流,并按年龄和姓名对它们进行分组。这意味着需要一个可以携带两个值的类。例如,这是一个这样的类,叫做TwoStrings

class TwoStrings {
    private String one, two;
    public TwoStrings(String one, String two) {
        this.one = one;
        this.two = two;
    }
    public String getOne() { return this.one; }
    public String getTwo() { return this.two; }
    @Override
    public boolean equals(Object o) {
       if (this == o) return true;
       if (!(o instanceof TwoStrings)) return false;
       TwoStrings twoStrings = (TwoStrings) o;
       return Objects.equals(getOne(), twoStrings.getOne()) 
           && Objects.equals(getTwo(), twoStrings.getTwo());
    }
    @Override
    public int hashCode() {
        return Objects.hash(getOne(), getTwo());
    }
    @Override
    public String toString() {
        return "(" + this.one + "," + this.two + ")";
    }
}

我们必须实现equals()hashCode()方法,因为TwoStrings类的对象将被用作键,其值必须对于两个值的每个组合是特定的。现在我们可以这样使用它:

Map<TwoStrings, List<Person2>> map = getStreamPerson2()
  .collect(Collectors.groupingBy(p -> 
            new TwoStrings(String.valueOf(p.getAge()), 
                                        p.getName())));
System.out.println(map);  
//prints: 
//   {(20,Jill)=[Person{name:Jill,age:20,city:Seattle}, 
//               Person{name:Jill,age:20,city:Chicago}], 
//    (20,John)=[Person{name:John,age:20,city:Denver}, 
//               Person{name:John,age:20,city:Chicago}], 
//    (30,John)=[Person{name:John,age:30,city:Denver}, 
//               Person{name:John,age:30,city:Seattle}]}

  1. 使用Stream<T>接口的R collect(Collector<T, A, R> collector)操作的用法示例,使用由Collector<T,?,Map<K,D>> groupingBy(Function<T,K> classifier, Collector<T,A,D> downstream)方法创建的收集器:
Map<String, Set<Person>> map = getStreamPerson()
   .collect(Collectors.groupingBy(Person::getName, 
                                  Collectors.toSet()));
System.out.println(map);  
             //prints: {John=[Person{name:John,age:30}, 
             //               Person{name:John,age:20}], 
             //         Jill=[Person{name:Jill,age:20}]}

如您所见,由Collectors.groupingBy(Person::getName)收集器产生的地图的List<Person>值后来(下游)被Collectors.toSet()收集器更改为集合。

或者,每个List<Person>值可以转换为列表元素的计数,如下所示:

Map<String, Long> map = getStreamPerson()
        .collect(Collectors.groupingBy(Person::getName, 
                                        Collectors.counting()));
System.out.println(map);   //prints: {John=2, Jill=1}

要计算流中相同的Person对象(根据equals()方法相等的对象)的数量,我们可以使用 identity 函数,它被定义为返回不变的输入。例如:

Stream.of("a","b","c")
      .map(s -> Function.identity()
      .apply(s))
      .forEach(System.out::print);  //prints: abc    

使用这个函数,我们可以计算相同人的数量,如下所示:

Map<Person, Long> map = Stream.of(new Person(30, "John"), 
                                  new Person(20, "Jill"), 
                                  new Person(30, "John"))
        .collect(Collectors.groupingBy(Function.identity(), 
                                        Collectors.counting()));
System.out.println(map);  //prints: {Person{name:Jill,age:20}=1, 
                          //         Person{name:John,age:30}=2}

我们还可以计算每个人群中的平均年龄(一个群体被定义为具有相同的结果键值):

Map<String, Double> map = getStreamPerson()
        .collect(Collectors.groupingBy(Person::getName, 
                Collectors.averagingInt(Person::getAge)));
System.out.println(map);  //prints: {John=25.0, Jill=20.0}

要列出具有相同名称的人的年龄的所有值,我们可以使用由Collector<T, ?, R> Collectors.mapping (Function<T,U> mapper, Collector<U,A,R> downstream)方法创建的下游收集器:

Map<String, List<Integer>> map = getStreamPerson()
   .collect(Collectors.groupingBy(Person::getName, 
            Collectors.mapping(Person::getAge, 
                               Collectors.toList())));
System.out.println(map);     
                  //prints: {John=[30, 20], Jill=[20]}

这个解决方案的另一个变化是下面的例子,对于每个年龄,创建一个逗号分隔的名称列表:

Map<Integer, String> map = getStreamPerson()
 .collect(Collectors.groupingBy(Person::getAge, 
             Collectors.mapping(Person::getName, 
                            Collectors.joining(","))));
System.out.println(map);
                     //prints: {20=Jill, John, 30=John}

最后,为了演示另一种技术,我们可以使用嵌套的groupingBy()收集器创建一个包含年龄作为键和人名到他们所在城市的地图:


Map<Integer, Map<String, String>> map = getStreamPerson2()
  .collect(Collectors.groupingBy(Person2::getAge, 
           Collectors.groupingBy(Person2::getName, 
                  Collectors.mapping(Person2::getCity, 
                              Collectors.joining(",")))));
System.out.println(map);  //prints: 
                          //   {20={John=Denver,Chicago, 
                          //        Jill=Seattle,Chicago}, 
                          //   30={John=Denver,Seattle}}

请注意,在前面的例子中我们使用了Person2流。

  1. 写一个使用Stream<T>接口的R collect(Collector<T, A, R> collector)操作的示例,该操作使用Collector<T, ?, M> groupingBy(Function<T,K> classifier, Supplier<M> mapFactory, Collector<T,A,D> downstream)方法创建的收集器:
LinkedHashMap<String, Long> map = getStreamPerson()
        .collect(Collectors.groupingBy(Person::getName, 
                                       LinkedHashMap::new, 
                                       Collectors.counting()));
System.out.println(map);  //prints: {John=2, Jill=1}

前面示例中的代码计算了在Person对象流中遇到每个名称的次数,并将结果放在由mapFactory函数(groupingBy()方法的第二个参数)定义的容器中(在本例中是LinkedHashMap)。

以下示例演示了如何告诉收集器基于enum City作为最终结果的容器使用EnumMap

EnumMap<City, List<Person3>> map = getStreamPerson3()
        .collect(Collectors.groupingBy(Person3::getCity, 
                            () -> new EnumMap<>(City.class), 
                                      Collectors.toList()));
System.out.println(map);  
 //prints: {Chicago=[Person{name:Jill,age:20,city:Chicago},  
 //                  Person{name:John,age:20,city:Chicago}], 
 //          Denver=[Person{name:John,age:30,city:Denver}, 
 //                  Person{name:John,age:20,city:Denver}], 
 //         Seattle=[Person{name:Jill,age:20,city:Seattle}, 
 //                  Person{name:John,age:30,city:Seattle}]}

请注意,在前面的例子中我们使用了Person3流。为了简化结果(避免在同一结果中重复显示城市)并且按年龄(对于每个城市)对人员进行分组,我们可以再次使用嵌套的groupingBy()收集器:

EnumMap<City, Map<Integer, String>> map = getStreamPerson3()
   .collect(Collectors.groupingBy(Person3::getCity, 
                   () -> new EnumMap<>(City.class), 
             Collectors.groupingBy(Person3::getAge, 
             Collectors.mapping(Person3::getName, 
                                Collectors.joining(",")))));
System.out.println(map);  
                       //prints: {Chicago={20=Jill,John}, 
                       //         Denver={20=John, 30=John}, 
                       //         Seattle={20=Jill, 30=John}}
  1. 作为第二组收集器的示例,那些由groupingByConcurrent()方法创建的收集器,所有前面的代码片段(最后两个使用EnumMap的除外)都可以通过将groupingBy()替换为groupingByConcurrent()和将结果的Map替换为ConcurrentMap类或其子类来使用。例如:
ConcurrentMap<String, List<Person>> map1 = 
   getStreamPerson().parallel()
      .collect(Collectors.groupingByConcurrent(Person::getName));
System.out.println(map1);  
                     //prints: {John=[Person{name:John,age:30}, 
                     //               Person{name:John,age:20}], 
                     //         Jill=[Person{name:Jill,age:20}]}

ConcurrentMap<String, Double> map2 = 
   getStreamPerson().parallel()
    .collect(Collectors.groupingByConcurrent(Person::getName,       
                     Collectors.averagingInt(Person::getAge)));
System.out.println(map2);      //prints: {John=25.0, Jill=20.0}

ConcurrentSkipListMap<String, Long> map3 = 
    getStreamPerson().parallel()
       .collect(Collectors.groupingByConcurrent(Person::getName, 
           ConcurrentSkipListMap::new, Collectors.counting()));
System.out.println(map3);        //prints: {Jill=1, John=2}

正如我们之前提到的,groupingByConcurrent()收集器也可以处理顺序流,但它们设计用于处理并行流数据,因此我们已将前面的流转换为并行流。返回的结果是ConcurrentHashMap类型或其子类。

还有更多...

Collectors类还提供了由partitioningBy()方法生成的两个收集器,这些收集器是groupingBy()收集器的专门版本:

  • Collector<T, ?, Map<Boolean,List<T>>> partitioningBy(Predicate<T> predicate): 使用提供的predicate函数将T类型的流元素收集到Map<Boolean,List<T>>对象中,创建一个Collector对象。

  • Collector<T, ?, Map<Boolean,D>> partitioningBy(Predicate<T> predicate, Collector<T,A,D> downstream):创建一个Collector对象,使用提供的predicate函数将T类型的流元素收集到Map<Boolean,D>对象中,将当前元素映射到Map<K,List<T>>中的键。然后使用downstream收集器将中间映射的值转换为结果映射的值,Map<Boolean,D>

让我们来看一些例子。以下是如何使用前面的方法之一将Person流元素收集到两个组中的示例——一个组包含包含字母i的名称,另一个组包含不包含字母i的名称:

Map<Boolean, List<Person>> map = getStreamPerson()
  .collect(Collectors.partitioningBy(p-> p.getName().contains("i")));
System.out.println(map);  //prints: {false=[Person{name:John,age:30}, 
                          //                Person{name:John,age:20}], 
                          //          true=[Person{name:Jill,age:20}]}

为了演示第二种方法的使用,我们可以将在前面示例中创建的地图中的每个List<Person>值转换为列表大小:

Map<Boolean, Long> map = getStreamPerson()
  .collect(Collectors.partitioningBy(p-> p.getName().contains("i"),  
                                           Collectors.counting()));
System.out.println(map);  //prints: {false=2, true=1}

使用groupingBy()方法也可以实现相同的结果:

Map<Boolean, List<Person>> map1 = getStreamPerson()
   .collect(Collectors.groupingBy(p-> p.getName().contains("i")));
System.out.println(map); //prints: {false=[Person{name:John,age:30}, 
                          //               Person{name:John,age:20}], 
                          //         true=[Person{name:Jill,age:20}]}

Map<Boolean, Long> map2 = getStreamPerson()
     .collect(Collectors.groupingBy(p-> p.getName().contains("i"),  
                                          Collectors.counting()));
System.out.println(map2);  //prints: {false=2, true=1}

partitioningBy()方法创建的收集器被认为是groupingBy()方法创建的收集器的一个特殊版本,并且预计允许我们在流元素被分成两组并存储在具有布尔键的地图中时编写更少的代码。但是,正如您从前面的代码中看到的那样,并非总是如此。我们的示例中的partitioningBy()收集器要求我们编写与groupingBy()收集器完全相同数量的代码。

创建流操作管道

在这个示例中,您将学习如何从Stream操作构建管道。

准备工作

在上一章,第四章,函数式编程中,当创建一个 lambda 友好的 API 时,我们最终得到了以下 API 方法:

public interface Traffic {
  void speedAfterStart(double timeSec, 
    int trafficUnitsNumber, SpeedModel speedModel, 
    BiPredicate<TrafficUnit, Double> limitTraffic,     
    BiConsumer<TrafficUnit, Double> printResult);
 }

指定数量的TrafficUnit实例是在speedAfterStart()方法中生成的。它们受到limitTrafficAndSpeed函数的限制,并根据speedModel函数在speedAfterStart()方法中进行处理。结果由printResults函数格式化。

这是一个非常灵活的设计,可以通过修改传递给 API 的函数来进行各种实验。但实际上,在数据分析的早期阶段,创建 API 需要更多的代码编写。这只有在长期内并且设计的灵活性允许我们在零或非常少的代码更改的情况下才能回报。

在研究阶段,情况发生了根本性的变化。当新算法被开发或者需要处理大量数据时,系统的各个层面之间的透明度成为基本要求。没有它,今天在大数据分析方面的许多成功将是不可能的。

流和管道解决了透明度问题,并最小化了编写基础设施代码的开销。

如何做到这一点...

让我们回顾一下用户如何调用 lambda-friendly API:

double timeSec = 10.0;
int trafficUnitsNumber = 10;

SpeedModel speedModel = (t, wp, hp) -> ...;
BiConsumer<TrafficUnit, Double> printResults = (tu, sp) -> ...;
BiPredicate<TrafficUnit, Double> limitSpeed = (tu, sp) -> ...;

Traffic api = new TrafficImpl(Month.APRIL, DayOfWeek.FRIDAY, 17, 
                              "USA", "Denver", "Main103S");
api.speedAfterStart(timeSec, trafficUnitsNumber, speedModel, 
                    limitSpeed, printResults);

正如我们已经注意到的,这样的 API 可能无法涵盖模型可能发展的所有可能方式,但它是一个很好的起点,可以让我们以更透明和灵活的实验方式构建操作流和管道。

现在,让我们来看一下 API 的实现:

double timeSec = 10.0;
int trafficUnitsNumber = 10;

SpeedModel speedModel = (t, wp, hp) -> ...;
BiConsumer<TrafficUnit, Double> printResults = (tu, sp) -> ...;
BiPredicate<TrafficUnit, Double> limitSpeed = (tu, sp) -> ...;
List<TrafficUnit> trafficUnits = FactoryTraffic
     .generateTraffic(trafficUnitsNumber, Month.APRIL, 
                      DayOfWeek.FRIDAY, 17, "USA", "Denver",
                      "Main103S");
for(TrafficUnit tu: trafficUnits){
  Vehicle vehicle = FactoryVehicle.build(tu);
  vehicle.setSpeedModel(speedModel);
  double speed = vehicle.getSpeedMph(timeSec);
  speed = Math.round(speed * tu.getTraction());
    if(limitSpeed.test(tu, speed)){
      printResults.accept(tu, speed);
    }
  }

我们可以将for循环转换为交通单位的流,并直接将相同的函数应用于流的元素。但首先,我们可以要求交通生成系统向我们提供一个Stream,而不是数据的List。这样可以避免将所有数据存储在内存中:

Stream<TrafficUnit> stream = FactoryTraffic
       .getTrafficUnitStream(trafficUnitsNumber, Month.APRIL,
            DayOfWeek.FRIDAY, 17, "USA", "Denver", "Main103S");

现在,我们可以处理无限数量的交通单位,而不需要一次存储超过一个单位的内存。在演示代码中,我们仍然使用List,因此流式处理并不能节省我们的内存。但在真实系统中,例如从各种传感器收集数据的系统中,使用流可以帮助减少或完全避免内存使用方面的问题。

我们还将创建一个便利的方法:

Stream<TrafficUnit>getTrafficUnitStream(int trafficUnitsNumber){
  return FactoryTraffic.getTrafficUnitStream(trafficUnitsNumber,
                       Month.APRIL, DayOfWeek.FRIDAY, 17, "USA", 
                                          "Denver", "Main103S");
}

有了这个,我们可以写下以下内容:

getTrafficUnitStream(trafficUnitsNumber).map(tu -> {
   Vehicle vehicle = FactoryVehicle.build(tu);
   vehicle.setSpeedModel(speedModel);
   return vehicle;
})
.map(v -> {
   double speed = v.getSpeedMph(timeSec);
   return Math.round(speed * tu.getTraction());
})
.filter(s -> limitSpeed.test(tu, s))
.forEach(tuw -> printResults.accept(tu, s));

我们将TrafficUnit映射(转换)为Vehicle,然后将Vehicle映射为speed,然后使用当前的TrafficUnit实例和计算出的speed来限制交通并打印结果。如果您在现代编辑器中有这段代码,您会注意到它无法编译,因为在第一个映射之后,当前的TrafficUnit元素不再可访问——它被Vehicle替换了。这意味着我们需要携带原始元素,并在途中添加新值。为了实现这一点,我们需要一个容器——一种交通单位包装器。让我们创建一个:

class TrafficUnitWrapper {
  private double speed;
  private Vehicle vehicle;
  private TrafficUnit trafficUnit;
  public TrafficUnitWrapper(TrafficUnit trafficUnit){
    this.trafficUnit = trafficUnit;
  }
  public TrafficUnit getTrafficUnit(){ return this.trafficUnit; }
  public Vehicle getVehicle() { return vehicle; }
  public void setVehicle(Vehicle vehicle) { 
    this.vehicle = vehicle; 
  }
  public double getSpeed() { return speed; }
  public void setSpeed(double speed) { this.speed = speed; }
}

现在,我们可以构建一个有效的管道:

getTrafficUnitStream(trafficUnitsNumber)
  .map(TrafficUnitWrapper::new)
  .map(tuw -> {
       Vehicle vehicle = FactoryVehicle.build(tuw.getTrafficUnit());
       vehicle.setSpeedModel(speedModel);
       tuw.setVehicle(vehicle);
       return tuw;
   })
  .map(tuw -> {
       double speed = tuw.getVehicle().getSpeedMph(timeSec);
       speed = Math.round(speed * tuw.getTrafficUnit().getTraction());
       tuw.setSpeed(speed);
       return tuw;
  })
  .filter(tuw -> limitSpeed.test(tuw.getTrafficUnit(),tuw.getSpeed()))
  .forEach(tuw -> printResults.accept(tuw.getTrafficUnit(), 
                                                     tuw.getSpeed()));

代码看起来有点冗长,特别是VehicleSpeedModel的设置。我们可以通过将它们移动到TrafficUntiWrapper类中来隐藏这些细节:

class TrafficUnitWrapper {
  private double speed;
  private Vehicle vehicle;
  private TrafficUnit trafficUnit;
  public TrafficUnitWrapper(TrafficUnit trafficUnit){
    this.trafficUnit = trafficUnit;
    this.vehicle = FactoryVehicle.build(trafficUnit);
  }
  public TrafficUnitWrapper setSpeedModel(SpeedModel speedModel) {
    this.vehicle.setSpeedModel(speedModel);
    return this;
  }
  pubic TrafficUnit getTrafficUnit(){ return this.trafficUnit; }
  public Vehicle getVehicle() { return vehicle; }
  public double getSpeed() { return speed; }
  public TrafficUnitWrapper setSpeed(double speed) { 
    this.speed = speed;
    return this; 
  }
}

请注意,我们从setSpeedModel()setSpeed()方法中返回this。这使我们能够保持流畅的风格。现在,管道看起来更加清晰:

getTrafficUnitStream(trafficUnitsNumber)
  .map(TrafficUnitWrapper::new)
  .map(tuw -> tuw.setSpeedModel(speedModel))
  .map(tuw -> {
       double speed = tuw.getVehicle().getSpeedMph(timeSec);
       speed = Math.round(speed * tuw.getTrafficUnit().getTraction());
       return tuw.setSpeed(speed);
   })
  .filter(tuw -> limitSpeed.test(tuw.getTrafficUnit(),tuw.getSpeed()))
  .forEach(tuw -> printResults.accept(tuw.getTrafficUnit(), 
                                                     tuw.getSpeed()));

如果不需要轻松访问速度计算的公式,我们可以通过将其移动到TrafficUnitWrapper类中,将setSpeed()方法更改为calcSpeed()

TrafficUnitWrapper calcSpeed(double timeSec) {
   double speed = this.vehicle.getSpeedMph(timeSec);
   this.speed = Math.round(speed * this.trafficUnit.getTraction());
   return this;
}

因此,管道变得更加简洁:

getTrafficUnitStream(trafficUnitsNumber)
   .map(TrafficUnitWrapper::new)
   .map(tuw -> tuw.setSpeedModel(speedModel))
   .map(tuw -> tuw.calcSpeed(timeSec))
   .filter(tuw -> limitSpeed.test(tuw.getTrafficUnit(),
                                                  tuw.getSpeed()))
   .forEach(tuw -> printResults.accept(tuw.getTrafficUnit(),
                                                  tuw.getSpeed()));

基于这种技术,我们现在可以创建一个计算交通密度的方法——在多车道道路的每条车道上,根据每条车道的速度限制计算车辆的数量:

Integer[] trafficByLane(Stream<TrafficUnit> stream,
       int trafficUnitsNumber, double timeSec, 
       SpeedModel speedModel, double[] speedLimitByLane) {
   int lanesCount = speedLimitByLane.length;
   Map<Integer, Integer> trafficByLane = stream
     .limit(trafficUnitsNumber)
     .map(TrafficUnitWrapper::new)
     .map(tuw -> tuw.setSpeedModel(speedModel))
     .map(tuw -> tuw.calcSpeed(timeSec))
     .map(speed -> countByLane(lanesCount, 
                               speedLimitByLane, speed))
     .collect(Collectors.groupingBy(CountByLane::getLane, 
           Collectors.summingInt(CountByLane::getCount)));
   for(int i = 1; i <= lanesCount; i++){
      trafficByLane.putIfAbsent(i, 0);
   }
   return trafficByLane.values()
                       .toArray(new Integer[lanesCount]);
}

前面方法使用的私有CountByLane类如下所示:

private class CountByLane {
  int count, lane;
  private CountByLane(int count, int lane){
    this.count = count;
    this.lane = lane;
  }
  public int getLane() { return lane; }
  public int getCount() { return count; }
}

以下是私有TrafficUnitWrapper类的样子:

private static class TrafficUnitWrapper {
  private Vehicle vehicle;
  private TrafficUnit trafficUnit;
  public TrafficUnitWrapper(TrafficUnit trafficUnit){
    this.vehicle = FactoryVehicle.build(trafficUnit);
    this.trafficUnit = trafficUnit;
  }
  public TrafficUnitWrapper setSpeedModel(SpeedModel speedModel) {
    this.vehicle.setSpeedModel(speedModel);
    return this;
  }
  public double calcSpeed(double timeSec) {
    double speed = this.vehicle.getSpeedMph(timeSec);
    return Math.round(speed * this.trafficUnit.getTraction());
  }
}

countByLane()私有方法的代码如下:

private CountByLane countByLane(int lanesNumber, 
                                   double[] speedLimit, double speed){
  for(int i = 1; i <= lanesNumber; i++){
     if(speed <= speedLimit[i - 1]){ 
        return new CountByLane(1, i);
     }
  }
  return new CountByLane(1, lanesNumber);
}

在第十四章中,测试,我们将更详细地讨论TrafficDensity类的这种方法,并重新审视这个实现以便更好地进行单元测试。这就是为什么在代码开发的同时编写单元测试会带来更高的生产力;它消除了之后改变代码的需要。它还会产生更可测试(更高质量)的代码。

还有更多…

管道允许轻松添加另一个过滤器,或者任何其他操作:

Predicate<TrafficUnit> limitTraffic = tu ->
    tu.getVehicleType() == Vehicle.VehicleType.CAR
    || tu.getVehicleType() == Vehicle.VehicleType.TRUCK;

getTrafficUnitStream(trafficUnitsNumber)
   .filter(limitTraffic)
   .map(TrafficUnitWrapper::new)
   .map(tuw -> tuw.setSpeedModel(speedModel))
   .map(tuw -> tuw.calcSpeed(timeSec))
   .filter(tuw -> limitSpeed.test(tuw.getTrafficUnit(), 
                                            tuw.getSpeed()))
   .forEach(tuw -> printResults.accept(tuw.getTrafficUnit(), 
                                           tuw.getSpeed()));

当需要处理多种类型的数据时,这一点尤为重要。值得一提的是,在进行计算之前进行过滤是提高性能的最佳方式,因为它可以避免不必要的计算。

使用流的另一个主要优势是可以在不额外编码的情况下使流程并行化。你只需要将管道的第一行改为getTrafficUnitStream(trafficUnitsNumber).parallel()(假设源不生成并行流,可以通过.isParallel()操作来识别)。我们将在下一个示例中更详细地讨论并行处理。

并行处理流

在之前的示例中,我们演示了一些并行流处理的技术。在这个示例中,我们将更详细地讨论处理,并分享常见问题的最佳实践和解决方案。

做好准备

很诱人的是将所有流都设置为并行,然后不再考虑它。不幸的是,并行并不总是有利的。事实上,它会因为工作线程的协调而产生开销。此外,一些流源是顺序的,一些操作可能共享相同的(同步的)资源。更糟糕的是,在并行处理中使用有状态的操作可能导致不可预测的结果。这并不意味着不能在并行流中使用有状态的操作,但它需要仔细规划和清晰理解在并行处理的子流之间如何共享状态。

如何做…

正如前面的示例中提到的,可以通过集合的parallelStream()方法或应用于流的parallel()方法来创建并行流。相反,可以使用sequential()方法将现有的并行流转换为顺序流。

首先,应该默认使用顺序流,并且只有在必要和可能的情况下才考虑并行流。通常情况下,如果性能不够好并且需要处理大量数据,才会出现这种需求。流源和操作的性质限制了可能性。例如,从文件中读取是顺序的,基于文件的流在并行中表现并不更好。任何阻塞操作也会抵消并行的性能提升。

顺序流和并行流不同的一个领域是顺序。这里有一个例子:

List.of("This ", "is ", "created ", "by ",
         "List.of().stream()").stream().forEach(System.out::print);
System.out.println();
List.of("This ", "is ", "created ", "by ", 
          "List.of().parallelStream()")
                      .parallelStream().forEach(System.out::print);

结果如下:

正如你所看到的,List保留了元素的顺序,但在并行处理的情况下不保持顺序。

创建和操作流的示例中,我们演示了对于reduce()collect()操作,组合器只会在并行流中被调用。因此,在顺序流处理时不需要组合器,但在并行流操作时必须存在。没有组合器,多个工作线程的结果就无法正确聚合。

我们还演示了在并行处理的情况下,sorted()distinct()limit()skip()这些有状态的操作会产生非确定性的结果。

如果顺序很重要,我们已经证明您可以依赖forEachOrdered()操作。它不仅保证处理流的所有元素,而且按照其源指定的顺序进行处理,无论流是顺序的还是并行的。

并行流可以通过parallelStream()方法或parallel()方法创建。一旦创建,它在处理过程中使用ForkJoin框架:原始流被分成段(子流),然后分配给不同的工作线程进行处理,然后所有结果(每个子流处理的结果)被聚合并呈现为原始流处理的最终结果。在只有一个处理器的计算机上,这样的实现没有优势,因为处理器是共享的。但在多核计算机上,工作线程可以由不同的处理器执行。更重要的是,如果一个工作线程变得空闲,它可以从忙碌的工作线程那里偷取一部分工作。然后从所有工作线程收集结果,并为终端操作的完成(即收集操作的组合器变得繁忙时)进行聚合。

一般来说,如果有一个资源在并发访问时不安全,那么在并行流处理期间使用它也是不安全的。考虑这两个例子(ArrayList不被认为是线程安全的):

List<String> wordsWithI = new ArrayList<>();
Stream.of("That ", "is ", "a ", "Stream.of(literals)")
      .parallel()
      .filter(w -> w.contains("i"))
      .forEach(wordsWithI::add);
System.out.println(wordsWithI);
System.out.println();

wordsWithI = Stream.of("That ", "is ", "a ", "Stream.of(literals)" )
                   .parallel()
                   .filter(w -> w.contains("i"))
                   .collect(Collectors.toList());
System.out.println(wordsWithI);

如果运行多次,此代码可能会产生以下结果:

Collectors.toList()方法总是生成相同的列表,其中包括isStream.of(literals),而forEach()偶尔会漏掉isStream.of(literals)

如果可能的话,首先尝试使用Collectors类构造的收集器,并避免在并行计算期间使用共享资源。

总的来说,使用无状态函数是并行流管道的最佳选择。如果有疑问,请测试您的代码,最重要的是多次运行相同的测试,以检查结果是否稳定。

第六章:数据库编程

本章涵盖了 Java 应用程序与数据库DB)之间的基本和常用交互,从连接到数据库和执行 CRUD 操作到创建事务、存储过程和处理大对象LOBs)。我们将涵盖以下内容:

  • 使用 JDBC 连接到数据库

  • 设置所需的用于数据库交互的表。

  • 使用 JDBC 执行 CRUD 操作

  • 使用Hikari 连接池HikariCP

  • 使用预编译语句

  • 使用事务

  • 处理大对象

  • 执行存储过程

  • 使用批处理操作处理大量数据

  • 使用MyBatis进行 CRUD 操作

  • 使用Java 持久性 APIHibernate

介绍

很难想象一个不使用某种结构化和可访问的数据存储(称为数据库)的复杂软件应用程序。这就是为什么任何现代语言实现都包括一个允许您访问数据库并在其中创建、读取、更新和删除CRUD)数据的框架。在 Java 中,Java 数据库连接JDBC)API 提供对任何数据源的访问,从关系数据库到电子表格和平面文件。

基于这种访问,应用程序可以直接在数据库中操作数据,使用数据库语言(例如 SQL),或间接使用对象关系映射(ORM)框架,该框架允许将内存中的对象映射到数据库中的表。Java 持久性 API(JPA)是 Java 的 ORM 规范。当使用 ORM 框架时,对映射的 Java 对象的 CRUD 操作会自动转换为数据库语言。最受欢迎的 ORM 框架列表包括 Apache Cayenne、Apache OpenJPA、EclipseLink、jOOQ、MyBatis 和 Hibernate 等。

组成 JDBC API 的java.sqljavax.sql包包含在Java 平台标准版Java SE)中。java.sql包提供了访问和处理存储在数据源中的数据(通常是关系数据库)的 API。javax.sql包提供了用于服务器端数据源访问和处理的 API。具体来说,它提供了用于与数据库建立连接、连接和语句池、分布式事务和行集的DataSource接口。包含符合 JPA 的接口的javax.persistence包未包含在 Java SE 中,必须将其作为依赖项添加到 Maven 配置文件pom.xml中。特定的 JPA 实现——首选的 ORM 框架——也必须作为 Maven 依赖项包含在内。我们将在本章的示例中讨论 JDBC、JPA 和两个 ORM 框架——Hibernate 和 MyBatis 的使用。

要实际将DataSource连接到物理数据库,还需要一个特定于数据库的驱动程序(由数据库供应商提供,例如 MySQL、Oracle、PostgreSQL 或 SQL Server 数据库)。它可能是用 Java 编写的,也可能是 Java 和Java 本机接口JNI)本机方法的混合体。这个驱动程序实现了 JDBC API。

与数据库的工作涉及八个步骤:

  1. 按照供应商的说明安装数据库。

  2. 向应用程序添加特定于数据库的驱动程序的.jar依赖项。

  3. 创建用户、数据库和数据库模式——表、视图、存储过程等。

  4. 从应用程序连接到数据库。

  5. 直接使用 JDBC 或间接使用 JPA 构建 SQL 语句。

  6. 直接使用 JDBC 执行 SQL 语句或使用 JPA 提交数据更改。

  7. 使用执行结果。

  8. 关闭数据库连接和其他资源。

步骤 1-3 仅在数据库设置阶段执行一次,应用程序运行之前。

步骤 4-8 根据需要由应用程序重复执行。

步骤 5-7 可以重复多次使用相同的数据库连接。

使用 JDBC 连接到数据库

在这个示例中,您将学习如何连接到数据库。

如何做...

  1. 选择您想要使用的数据库。有很好的商业数据库和很好的开源数据库。我们唯一假设的是您选择的数据库支持结构化查询语言SQL),这是一种标准化的语言,允许您在数据库上执行 CRUD 操作。在我们的示例中,我们将使用标准 SQL,并避免特定于特定数据库类型的构造和过程。

  2. 如果数据库尚未安装,请按照供应商的说明进行安装。然后,下载数据库驱动程序。最流行的是类型 4 和 5,用 Java 编写。它们非常高效,并通过套接字连接与数据库服务器通信。如果将带有此类驱动程序的 .jar 文件放置在类路径上,它将自动加载。类型 4 和 5 的驱动程序是特定于数据库的,因为它们使用数据库本机协议来访问数据库。我们将假设您正在使用此类驱动程序。

如果您的应用程序必须访问多种类型的数据库,则需要类型 3 的驱动程序。这样的驱动程序可以通过中间件应用服务器与不同的数据库通信。

仅当没有其他驱动程序类型可用于您的数据库时,才使用类型 1 和 2 的驱动程序。

  1. 将下载的带有驱动程序的 .jar 文件设置在应用程序的类路径上。现在,您的应用程序可以访问数据库。

  2. 您的数据库可能有控制台、图形用户界面或其他与之交互的方式。阅读说明,并首先创建一个用户,即 cook,然后创建一个名为 cookbook 的数据库。

例如,以下是 PostgreSQL 的命令:

 CREATE USER cook SUPERUSER;
 CREATE DATABASE cookbook OWNER cook;

我们为我们的用户选择了 SUPERUSER 角色;然而,一个良好的安全实践是将这样一个强大的角色分配给管理员,并创建另一个专门用于管理数据但不能更改数据库结构的应用程序特定用户。创建另一个逻辑层,称为 模式,可以拥有自己的一组用户和权限,这是一个良好的实践。这样,同一数据库中的几个模式可以被隔离,每个用户(其中一个是你的应用程序)只能访问特定的模式。

  1. 此外,在企业级别,通常的做法是为数据库模式创建同义词,以便没有应用程序可以直接访问原始结构。您甚至可以为每个用户创建一个密码,但是,再次强调,出于本书的目的,这是不需要的。因此,我们将其留给数据库管理员来制定适合每个企业特定工作条件的规则和指南。

现在,我们将应用程序连接到数据库。在以下演示代码中,我们将使用开源的 PostgreSQL 数据库,你可能已经猜到了。

它是如何工作的...

以下是创建到本地 PostgreSQL 数据库的连接的代码片段:

String URL = "jdbc:postgresql://localhost/cookbook";
Properties prop = new Properties( );
//prop.put( "user", "cook" );
//prop.put( "password", "secretPass123" );
Connection conn = DriverManager.getConnection(URL, prop);

注释行显示了如何为连接设置用户和密码。由于在此演示中,我们将数据库保持开放并对任何人都可访问,我们可以使用重载的 DriverManager.getConnection(String url) 方法。然而,我们将展示最常见的实现,允许任何人从属性文件中读取并传递其他有用的值(ssl 为 true/false,autoReconnect 为 true/false,connectTimeout 以秒为单位等)到创建连接的方法。传入属性的许多键对于所有主要数据库类型都是相同的,但其中一些是特定于数据库的。

或者,仅传递用户和密码,我们可以使用第三个重载版本,即 DriverManager.getConnection(String url, String user, String password)。值得一提的是,将密码加密是一个良好的实践。我们不会向您展示如何做到这一点,但在线上有很多指南可用。

此外,getConnection()方法会抛出SQLException,因此我们需要将其包装在try...catch块中。

为了隐藏所有这些管道,最好将建立连接的代码放在一个方法中:

Connection getDbConnection(){
  String url = "jdbc:postgresql://localhost/cookbook";
  try {
    return DriverManager.getConnection(url);
  }
  catch(Exception ex) {
    ex.printStackTrace();
    return null;
  }
}

连接到数据库的另一种方法是使用DataSource接口。它的实现通常包含在与数据库驱动程序相同的.jar文件中。在 PostgreSQL 的情况下,有两个类实现了DataSource接口:org.postgresql.ds.PGSimpleDataSourceorg.postgresql.ds.PGPoolingDataSource。我们可以使用它们来代替DriverManager。以下是使用PGSimpleDataSource的示例:

Connection getDbConnection(){
  PGSimpleDataSource source = new PGSimpleDataSource();
  source.setServerName("localhost");
  source.setDatabaseName("cookbook");
  source.setLoginTimeout(10);
  try {
    return source.getConnection();
  }
  catch(Exception ex) {
    ex.printStackTrace();
    return null;
  }
}

以下是使用PGPoolingDataSource的示例:

Connection getDbConnection(){
  PGPoolingDataSource source = new PGPoolingDataSource();
  source.setServerName("localhost");
  source.setDatabaseName("cookbook");
  source.setInitialConnections(3);
  source.setMaxConnections(10);
  source.setLoginTimeout(10);
  try {
    return source.getConnection();
  }
  catch(Exception ex) {
    ex.printStackTrace();
    return null;
  }
}

getDbConnection()方法的最新版本通常是首选的连接方式,因为它允许您使用连接池和其他一些功能,除了通过DriverManager连接时可用的功能。请注意,自版本42.0.0起,PGPoolingDataSource类已被弃用,而是支持第三方连接池软件。其中一个框架,HikariCP,我们之前提到过,将在使用 Hikari 连接池的示例中进行讨论和演示。

无论您选择哪个版本的getDbConnection()实现,您都可以在所有代码示例中以相同的方式使用它。

还有更多...

关闭连接是一个好习惯,一旦不再需要它,就立即关闭连接。这样做的方法是使用try-with-resources构造,它确保在try...catch块结束时关闭资源:

try (Connection conn = getDbConnection()) {
  // code that uses the connection to access the DB
} 
catch(Exception ex) { 
  ex.printStackTrace();
}

这样的构造可以与实现java.lang.AutoCloseablejava.io.Closeable接口的任何对象一起使用。

设置所需的用于数据库交互的表

在这个示例中,您将学习如何创建、更改和删除表和其他组成数据库模式的逻辑数据库构造。

准备工作

用于创建表的标准 SQL 语句如下:

CREATE TABLE table_name (
  column1_name data_type(size),
  column2_name data_type(size),
  column3_name data_type(size),
  ....
);

在这里,table_namecolumn_name必须是字母数字和唯一的(在模式内)。名称和可能的数据类型的限制是特定于数据库的。例如,Oracle 允许表名有 128 个字符,而在 PostgreSQL 中,表名和列名的最大长度为 63 个字符。数据类型也有所不同,因此请阅读数据库文档。

它是如何工作的...

以下是在 PostgreSQL 中创建traffic_unit表的命令示例:

CREATE TABLE traffic_unit (
  id SERIAL PRIMARY KEY,
  vehicle_type VARCHAR NOT NULL,
  horse_power integer NOT NULL,
  weight_pounds integer NOT NULL,
  payload_pounds integer NOT NULL,
  passengers_count integer NOT NULL,
  speed_limit_mph double precision NOT NULL,
  traction double precision NOT NULL,
  road_condition VARCHAR NOT NULL,
  tire_condition VARCHAR NOT NULL,
  temperature integer NOT NULL
);

size参数是可选的。如果不设置,就像前面的示例代码一样,这意味着该列可以存储任意长度的值。在这种情况下,integer类型允许您存储从Integer.MIN_VALUE(-2147483648)到Integer.MAX_VALUE(+2147483647)的数字。添加了NOT NULL类型,因为默认情况下,该列将是可空的,我们希望确保所有列都将被填充。

我们还将id列标识为PRIMARY KEY,这表示该列(或列的组合)唯一标识记录。例如,如果有一张包含所有国家所有人的信息的表,唯一的组合可能是他们的全名、地址和出生日期。嗯,可以想象在一些家庭中,双胞胎出生并被赋予相同的名字,所以我们说可能。如果这种情况发生的机会很大,我们需要向主键组合中添加另一列,即出生顺序,默认值为1。以下是我们在 PostgreSQL 中可以这样做的方法:

CREATE TABLE person (
  name VARCHAR NOT NULL,
  address VARCHAR NOT NULL,
  dob date NOT NULL,
  order integer DEFAULT 1 NOT NULL,
  PRIMARY KEY (name,address,dob,order)
);

traffic_unit表的情况下,没有任何组合的列可以作为主键。许多汽车在任何组合的列中具有相同的值。但我们需要引用traffic_unit记录,这样我们就可以知道,例如,哪些单位已被选择和处理,哪些没有。这就是为什么我们添加了一个id列,用一个唯一生成的数字填充它,我们希望数据库能够自动生成这个主键。

如果您发出命令\d traffic_unit来显示表描述,您将看到id列分配了函数nextval('traffic_unit_id_seq'::regclass)。此函数按顺序生成数字,从 1 开始。如果您需要不同的行为,可以手动创建序列号生成器。以下是如何做到这一点的示例:

CREATE SEQUENCE traffic_unit_id_seq 
START WITH 1000 INCREMENT BY 1 
NO MINVALUE NO MAXVALUE CACHE 10; 
ALTER TABLE ONLY traffic_unit ALTER COLUMN id SET DEFAULT nextval('traffic_unit_id_seq'::regclass);

这个序列从 1,000 开始,缓存 10 个数字以提高性能,如果需要快速生成数字。

根据前几章中给出的代码示例,vehicle_typeroad_conditiontire_condition的值受enum类型的限制。这就是为什么当traffic_unit表被填充时,我们希望确保只有相应的enum类型的值可以设置在列中。为了实现这一点,我们将创建一个名为enums的查找表,并用我们enum类型的值填充它:

CREATE TABLE enums (
  id integer PRIMARY KEY,
  type VARCHAR NOT NULL,
  value VARCHAR NOT NULL
);

insert into enums (id, type, value) values 
(1, 'vehicle', 'car'),
(2, 'vehicle', 'truck'),
(3, 'vehicle', 'crewcab'),
(4, 'road_condition', 'dry'),
(5, 'road_condition', 'wet'),
(6, 'road_condition', 'snow'),
(7, 'tire_condition', 'new'),
(8, 'tire_condition', 'worn');

PostgreSQL 有一个enum数据类型,但如果可能值的列表不固定并且需要随时间更改,它会产生额外的开销。我们认为我们的应用程序中可能会扩展值的列表。因此,我们决定不使用数据库的enum类型,而是自己创建查找表。

现在,我们可以通过使用它们的 ID 作为外键,从traffic_unit表中引用enums表的值。首先,我们删除表:

drop table traffic_unit;

然后,我们用稍微不同的 SQL 命令重新创建它:

CREATE TABLE traffic_unit (
  id SERIAL PRIMARY KEY,
  vehicle_type integer REFERENCES enums (id),
  horse_power integer NOT NULL,
  weight_pounds integer NOT NULL,
  payload_pounds integer NOT NULL,
  passengers_count integer NOT NULL,
  speed_limit_mph double precision NOT NULL,
  traction double precision NOT NULL,
  road_condition integer REFERENCES enums (id),
  tire_condition integer REFERENCES enums (id),
  temperature integer NOT NULL
);

vehicle_typeroad_conditiontire_condition列现在必须由enums表相应记录的主键值填充。这样,我们可以确保我们的交通分析代码能够将这些列的值与代码中的enum类型的值匹配。

还有更多...

我们还希望确保enums表不包含重复的组合类型和值。为了确保这一点,我们可以向enums表添加唯一约束:

ALTER TABLE enums ADD CONSTRAINT enums_unique_type_value 
UNIQUE (type, value);

现在,如果我们尝试添加重复项,数据库将不允许。

数据库表创建的另一个重要考虑因素是是否需要添加索引。索引是一种数据结构,可以加速在表中进行数据搜索,而无需检查每个表记录。它可以包括一个或多个表的列。例如,主键的索引会自动创建。如果您打开我们已经创建的表的描述,您会看到以下内容:

 Indexes: "traffic_unit_pkey" PRIMARY KEY, btree (id)

如果我们认为(并通过实验已经证明)添加索引将有助于应用程序的性能,我们也可以自己添加索引。在traffic_unit的情况下,我们发现我们的代码经常通过vehicle_typepassengers_count搜索这个表。因此,我们测量了我们的代码在搜索过程中的性能,并将这两列添加到了索引中:

CREATE INDEX idx_traffic_unit_vehicle_type_passengers_count 
ON traffic_unit USING btree (vehicle_type,passengers_count);

然后,我们再次测量性能。如果性能有所改善,我们将保留索引,但在我们的情况下,我们已经删除了它:

drop index idx_traffic_unit_vehicle_type_passengers_count;

索引并没有显著改善性能,可能是因为索引会增加额外的写入和存储空间的开销。

在我们的主键、约束和索引示例中,我们遵循了 PostgreSQL 的命名约定。如果您使用不同的数据库,我们建议您查找其命名约定并遵循,以便您的命名与自动创建的名称保持一致。

使用 JDBC 执行 CRUD 操作

在这个示例中,您将学习如何使用 JDBC 在表中填充、读取、更改和删除数据。

准备工作

我们已经看到了在数据库中创建(填充)数据的 SQL 语句示例:

INSERT INTO table_name (column1,column2,column3,...)
VALUES (value1,value2,value3,...);

我们还看到了需要添加多个表记录的示例:

INSERT INTO table_name (column1,column2,column3,...)
VALUES (value1,value2,value3, ... ), 
       (value21,value22,value23, ...), 
       ( ...                       );

如果列有指定的默认值,则无需在 INSERT INTO 语句中列出它,除非需要插入不同的值。

通过 SELECT 语句从数据库中读取数据:

SELECT column_name,column_name
FROM table_name WHERE some_column=some_value;

以下是 WHERE 子句的一般定义:

WHERE column_name operator value
Operator:
  = Equal
  <> Not equal. In some versions of SQL, !=
  > Greater than
  < Less than
  >= Greater than or equal
  <= Less than or equal
  BETWEEN Between an inclusive range
  LIKE Search for a pattern
  IN To specify multiple possible values for a column

column_name operator value 结构可以与逻辑运算符 ANDOR 结合,并用括号 () 进行分组。

所选的值可以按特定顺序返回:

SELECT * FROM table_name WHERE-clause by some_other_column; 

顺序可以标记为升序(默认)或降序:

SELECT * FROM table_name WHERE-clause by some_other_column desc; 

数据可以使用 UPDATE 语句进行更改:

UPDATE table_name SET column1=value1,column2=value2,... WHERE-clause;

或者,可以使用 DELETE 语句进行删除:

DELETE FROM table_name WHERE-clause;

没有 WHERE 子句,UPDATEDELETE 语句将影响表的所有记录。

如何做...

我们已经看到了 INSERT 语句。这里是其他类型语句的示例:

前面的 SELECT 语句显示了表的所有行的所有列的值。为了限制返回的数据量,可以添加 WHERE 子句:

以下屏幕截图捕获了三个语句:

第一个是 UPDATE 语句,将 value 列中的值更改为 NEW,但仅在 value 列包含值 new 的行中(显然,该值区分大小写)。第二个语句删除所有 value 列中没有值 NEW 的行。第三个语句(SELECT)检索所有行的所有列的值。

值得注意的是,如果 enums 表的记录被 traffic_unit 表(作为外键)引用,我们将无法删除这些记录。只有在删除 traffic_unit 表的相应记录之后,才能删除 enums 表的记录。

要在代码中执行 CRUD 操作之一,首先必须获取 JDBC 连接,然后创建并执行语句:

try (Connection conn = getDbConnection()) {
  try (Statement st = conn.createStatement()) {
    boolean res = st.execute("select id, type, value from enums");
    if (res) {
      ResultSet rs = st.getResultSet();
      while (rs.next()) {
        int id = rs.getInt(1); 
        String type = rs.getString(2);
        String value = rs.getString(3);
        System.out.println("id = " + id + ", type = " 
                           + type + ", value = " + value);
      }
    } else {
      int count = st.getUpdateCount();
      System.out.println("Update count = " + count);
    }
  }
} catch (Exception ex) { ex.printStackTrace(); }

使用 try-with-resources 结构来处理 Statement 对象是一个良好的实践。关闭 Connection 对象将自动关闭 Statement 对象。但是,当您显式关闭 Statement 对象时,清理将立即发生,而不必等待必要的检查和操作传播到框架的各个层。

execute() 方法是可以执行语句的三种方法中最通用的方法。其他两种包括 executeQuery()(仅用于 SELECT 语句)和 executeUpdate()(用于 UPDATEDELETECREATEALTER 语句)。从前面的示例中可以看出,execute() 方法返回 boolean,表示结果是 ResultSet 对象还是仅仅是计数。这意味着 execute() 对于 SELECT 语句起到了 executeQuery() 的作用,对于我们列出的其他语句起到了 executeUpdate() 的作用。

我们可以通过运行前面的代码来演示以下语句序列:

"select id, type, value from enums"
"insert into enums (id, type, value)" + " values(1,'vehicle','car')"
"select id, type, value from enums"
"update enums set value = 'bus' where value = 'car'"
"select id, type, value from enums"
"delete from enums where value = 'bus'"
"select id, type, value from enums"

结果将如下所示:

我们使用了从 ResultSet 中提取值的位置提取,因为这比使用列名(如 rs.getInt("id")rs.getInt("type"))更有效。性能差异非常小,只有在操作发生多次时才变得重要。只有实际的测量和测试才能告诉您这种差异对于您的应用程序是否重要。请记住,按名称获取值提供更好的代码可读性,在应用程序维护期间长期受益。

我们用execute()方法进行演示。在实践中,executeQuery()方法用于SELECT语句:

try (Connection conn = getDbConnection()) {
  try (Statement st = conn.createStatement()) {
    boolean res = st.execute("select id, type, value from enums");
    ResultSet rs = st.getResultSet();
    while (rs.next()) {
        int id = rs.getInt(1); 
        String type = rs.getString(2);
        String value = rs.getString(3);
        System.out.println("id = " + id + ", type = " 
                           + type + ", value = " + value);
    }
  }
} catch (Exception ex) { ex.printStackTrace(); }

如您所见,前面的代码无法作为接收 SQL 语句作为参数的方法进行泛化。提取数据的代码特定于执行的 SQL 语句。相比之下,对executeUpdate()的调用可以包装在通用方法中:

void executeUpdate(String sql){
  try (Connection conn = getDbConnection()) {
    try (Statement st = conn.createStatement()) {
      int count = st.executeUpdate(sql);
      System.out.println("Update count = " + count);
    }
  } catch (Exception ex) { ex.printStackTrace(); }
}

还有更多...

SQL 是一种丰富的语言,我们没有足够的空间来涵盖其所有功能。但我们想列举一些最受欢迎的功能,以便您了解它们的存在,并在需要时查找它们:

  • SELECT语句允许使用DISTINCT关键字,以摆脱所有重复的值

  • 关键字LIKE允许您将搜索模式设置为WHERE子句

  • 搜索模式可以使用多个通配符——%, _[charlist][^charlist][!charlist]

  • 匹配值可以使用IN关键字枚举

  • SELECT语句可以使用JOIN子句包括多个表

  • SELECT * INTO table_2 from table_1创建table_2并从table_1复制数据

  • TRUNCATE在删除表的所有行时更快且使用更少的资源

ResultSet接口中还有许多其他有用的方法。以下是一些方法如何用于编写通用代码,遍历返回的结果并使用元数据打印列名和返回值的示例:

void traverseRS(String sql){
  System.out.println("traverseRS(" + sql + "):");
  try (Connection conn = getDbConnection()) {
    try (Statement st = conn.createStatement()) {
      try(ResultSet rs = st.executeQuery(sql)){
        int cCount = 0;
        Map<Integer, String> cName = new HashMap<>();
        while (rs.next()) {
          if (cCount == 0) {
            ResultSetMetaData rsmd = rs.getMetaData();
            cCount = rsmd.getColumnCount();
            for (int i = 1; i <= cCount; i++) {
              cName.put(i, rsmd.getColumnLabel(i));
            }
          }
          List<String> l = new ArrayList<>();
          for (int i = 1; i <= cCount; i++) {
            l.add(cName.get(i) + " = " + rs.getString(i));
          }
          System.out.println(l.stream()
                              .collect(Collectors.joining(", ")));
        }
      }
    }
  } catch (Exception ex) { ex.printStackTrace(); }
}

我们只使用了一次ResultSetMetaData来收集返回的列名和一行的长度(列数)。然后,我们通过位置从每行中提取值,并创建了相应列名的List<String>元素。为了打印,我们使用了您已经熟悉的东西——程序员的乐趣——连接收集器(我们在上一章中讨论过)。如果我们调用traverseRS("select * from enums")方法,结果将如下所示:

使用 Hikari 连接池(HikariCP)

在本配方中,您将学习如何设置和使用高性能的 HikariCP。

准备就绪

HikariCP 框架是由居住在日本的 Brett Wooldridge 创建的。Hikari在日语中的意思是。它是一个轻量级且相对较小的 API,经过高度优化,并允许通过许多属性进行调整,其中一些属性在其他池中不可用。除了标准用户、密码、最大池大小、各种超时设置和缓存配置属性之外,它还公开了诸如allowPoolSuspensionconnectionInitSqlconnectionTestQuery等属性,甚至包括处理未及时关闭的连接的属性leakDetectionThreshold

要使用最新(在撰写本书时)版本的 Hikari 池,请将以下依赖项添加到项目中:

<dependency>
    <groupId>com.zaxxer</groupId>
    <artifactId>HikariCP</artifactId>
    <version>3.2.0</version>
 </dependency>

出于演示目的,我们将使用本章前一个配方中创建的数据库使用 JDBC 连接到数据库。我们还假设您已经学习了该配方,因此无需重复讨论数据库、JDBC 以及它们如何一起工作的内容。

如何做...

有几种配置 Hikari 连接池的方法。所有这些方法都基于javax.sql.DataSource接口的使用:

  1. 最明显和直接的方法是直接在DataSource对象上设置池属性:
HikariDataSource ds = new HikariDataSource();
ds.setPoolName("cookpool");
ds.setDriverClassName("org.postgresql.Driver");
ds.setJdbcUrl("jdbc:postgresql://localhost/cookbook");
ds.setUsername( "cook");
//ds.setPassword("123Secret");
ds.setMaximumPoolSize(10);
ds.setMinimumIdle(2);
ds.addDataSourceProperty("cachePrepStmts", Boolean.TRUE);
ds.addDataSourceProperty("prepStmtCacheSize", 256);
ds.addDataSourceProperty("prepStmtCacheSqlLimit", 2048);
ds.addDataSourceProperty("useServerPrepStmts", Boolean.TRUE);

我们已经注释掉了密码,因为我们没有为我们的数据库设置密码。在jdbcUrldataSourceClassName属性之间,一次只能使用一个,除非使用一些可能需要同时设置这两个属性的旧驱动程序。此外,请注意当没有专门的 setter 用于特定属性时,我们如何使用通用方法addDataSourceProperty()

要从 PostgreSQL 切换到另一个关系数据库,您只需要更改驱动程序类名和数据库 URL。还有许多其他属性;其中一些是特定于数据库的,但我们不打算深入研究这些细节,因为本配方演示了如何使用 HikariCP。阅读有关特定于数据库的池配置属性的数据库文档,并了解如何使用它们来调整池以获得最佳性能,这在很大程度上也取决于特定应用程序与数据库的交互方式。

  1. 配置 Hikari 池的另一种方法是使用HikariConfig类收集所有属性,然后将HikariConfig对象设置在HikariDataSource构造函数中:
HikariConfig config = new HikariConfig();
config.setPoolName("cookpool");
config.setDriverClassName("org.postgresql.Driver");
config.setJdbcUrl("jdbc:postgresql://localhost/cookbook");
config.setUsername("cook");
//conf.setPassword("123Secret");
config.setMaximumPoolSize(10);
config.setMinimumIdle(2);
config.addDataSourceProperty("cachePrepStmts", true);
config.addDataSourceProperty("prepStmtCacheSize", 256);
config.addDataSourceProperty("prepStmtCacheSqlLimit", 2048);
config.addDataSourceProperty("useServerPrepStmts", true);

HikariDataSource ds = new HikariDataSource(config);

正如您所看到的,我们再次使用了通用方法addDataSourceProperty(),因为在HikariConfig类中也没有专门的设置器来设置这些属性。

  1. HikariConfig对象反过来可以使用java.util.Properties类填充数据:
Properties props = new Properties();
props.setProperty("poolName", "cookpool");
props.setProperty("driverClassName", "org.postgresql.Driver");
props.setProperty("jdbcUrl", "jdbc:postgresql://localhost/cookbook");
props.setProperty("username", "cook");
//props.setProperty("password", "123Secret");
props.setProperty("maximumPoolSize", "10");
props.setProperty("minimumIdle", "2");
props.setProperty("dataSource.cachePrepStmts","true");
props.setProperty("dataSource.prepStmtCacheSize", "256");
props.setProperty("dataSource.prepStmtCacheSqlLimit", "2048");
props.setProperty("dataSource.useServerPrepStmts","true");

HikariConfig config = new HikariConfig(props);
HikariDataSource ds = new HikariDataSource(config);

请注意,对于在HikariConfig类中没有专用设置器的属性,我们使用了前缀dataSource

  1. 为了使配置更容易加载,HikariConfig类具有接受属性文件的构造函数。例如,让我们在resources文件夹中创建一个名为database.properties的文件,其中包含以下内容:
poolName=cookpool
driverClassName=org.postgresql.Driver
jdbcUrl=jdbc:postgresql://localhost/cookbook
username=cook
password=
maximumPoolSize=10
minimumIdle=2
dataSource.cachePrepStmts=true
dataSource.useServerPrepStmts=true
dataSource.prepStmtCacheSize=256
dataSource.prepStmtCacheSqlLimit=2048

请注意,我们再次使用了相同属性的前缀dataSource。现在,我们可以直接将上述文件加载到HikariConfig构造函数中:

ClassLoader loader = getClass().getClassLoader();
File file = 
   new File(loader.getResource("database.properties").getFile());
HikariConfig config = new HikariConfig(file.getAbsolutePath());
HikariDataSource ds = new HikariDataSource(config);

在幕后,正如您可能猜到的那样,它只是加载属性:

public HikariConfig(String propertyFileName) {
    this();
    this.loadProperties(propertyFileName);
}
  1. 或者,我们可以使用HikariConfig默认构造函数中包含的以下功能:
String systemProp = 
       System.getProperty("hikaricp.configurationFile");
if (systemProp != null) {
    this.loadProperties(systemProp);
}

这意味着我们可以设置系统属性如下:

-Dhikaricp.configurationFile=src/main/resources/database.properties

然后,我们可以配置 HikariCP 如下:

HikariConfig config = new HikariConfig();
HikariDataSource ds = new HikariDataSource(config);

池配置的所有前述方法产生相同的结果,因此只取决于风格、约定或个人偏好来决定使用其中的哪一个。

它是如何工作的...

以下方法是使用创建的DataSource对象访问数据库,并从在配方使用 JDBC 连接到数据库中创建的enums表中选择所有值:

void readData(DataSource ds) {
   try(Connection conn = ds.getConnection();
      PreparedStatement pst = 
        conn.prepareStatement("select id, type, value from enums");
      ResultSet rs = pst.executeQuery()){
      while (rs.next()) {
            int id = rs.getInt(1);
            String type = rs.getString(2);
            String value = rs.getString(3);
            System.out.println("id = " + id + ", type = " + 
                                      type + ", value = " + value);
      }
   } catch (SQLException ex){
      ex.printStackTrace();
   }
}

如果我们运行上述代码,结果将如下所示:

还有更多...

您可以在 GitHub 上阅读有关 HikariCP 功能的更多信息(github.com/brettwooldridge/HikariCP)。

使用预处理语句

在本配方中,您将学习如何使用预处理语句——可以存储在数据库中并使用不同输入值高效执行的语句模板。

准备就绪

PreparedStatement对象——Statement的子接口——可以预编译并存储在数据库中,然后用于高效地执行不同输入值的 SQL 语句。与由createStatement()方法创建的Statement对象类似,它可以由相同Connection对象的prepareStatement()方法创建。

用于生成Statement的相同 SQL 语句也可以用于生成PreparedStatement。实际上,考虑使用PrepdaredStatement来替换多次调用的任何 SQL 语句是一个好主意,因为它的性能优于Statement。要做到这一点,我们只需要更改上一节示例代码中的这两行:

try (Statement st = conn.createStatement()) {
  boolean res = st.execute("select * from enums");

我们将这些行更改为以下内容:

try (PreparedStatement st = 
           conn.prepareStatement("select * from enums")) {
  boolean res = st.execute();

如何做...

PreparedStatement的真正用处在于它能够接受参数——替换(按顺序出现)?符号的输入值。以下是一个示例:

traverseRS("select * from enums");
System.out.println();
try (Connection conn = getDbConnection()) {
  String[][] values = {{"1", "vehicle", "car"},
                       {"2", "vehicle", "truck"}};
  String sql = "insert into enums (id, type, value) values(?, ?, ?)");
  try (PreparedStatement st = conn.prepareStatement(sql) {
    for(String[] v: values){
      st.setInt(1, Integer.parseInt(v[0]));
      st.setString(2, v[1]);
      st.setString(3, v[2]);
      int count = st.executeUpdate();
      System.out.println("Update count = " + count);
    }
  }
} catch (Exception ex) { ex.printStackTrace(); }
System.out.println();
traverseRS("select * from enums");

其结果如下:

还有更多...

总是使用准备好的语句进行 CRUD 操作并不是一个坏主意。如果只执行一次,它们可能会比较慢,但您可以测试并查看这是否是您愿意支付的代价。通过系统地使用准备好的语句,您将产生一致的(更易读的)代码,提供更多的安全性(准备好的语句不容易受到 SQL 注入的攻击)。

使用事务

在这个教程中,您将学习什么是数据库事务以及如何在 Java 代码中使用它。

准备工作

事务是一个包括一个或多个改变数据的操作的工作单元。如果成功,所有数据更改都将被提交(应用到数据库)。如果其中一个操作出错,事务将回滚,并且事务中包含的所有更改都不会应用到数据库。

事务属性是在Connection对象上设置的。它们可以在不关闭连接的情况下更改,因此不同的事务可以重用相同的Connection对象。

JDBC 仅允许对 CRUD 操作进行事务控制。表修改(CREATE TABLEALTER TABLE等)会自动提交,并且无法从 Java 代码中进行控制。

默认情况下,CRUD 操作事务被设置为自动提交。这意味着每个由 SQL 语句引入的数据更改都会在该语句的执行完成后立即应用到数据库中。本章中的所有前面的示例都使用了这种默认行为。

要更改此行为,您必须使用Connection对象的setAutoCommit(boolean)方法。如果设置为false,则数据更改将不会应用到数据库,直到在Connection对象上调用commit()方法为止。此外,如果调用rollback()方法,那么自事务开始或上次调用commit()以来的所有数据更改都将被丢弃。

显式的程序化事务管理可以提高性能,但在只调用一次且不太频繁的短时间原子操作的情况下,它并不重要。接管事务控制在多个操作引入必须一起应用或全部不应用的更改时变得至关重要。它允许将数据库更改分组为原子单元,从而避免意外违反数据完整性。

如何做...

首先,让我们向traverseRS()方法添加一个输出:

void traverseRS(String sql){
  System.out.println("traverseRS(" + sql + "):");
  try (Connection conn = getDbConnection()) {
    ...
  }
}

这将帮助您分析当许多不同的 SQL 语句在同一个演示示例中执行时的输出。

现在,让我们运行以下代码,从enums表中读取数据,然后插入一行,然后再次从表中读取所有数据:

traverseRS("select * from enums");
System.out.println();
try (Connection conn = getDbConnection()) {
  conn.setAutoCommit(false);
  String sql = "insert into enums (id, type, value) "
                       + " values(1,'vehicle','car')";
  try (PreparedStatement st = conn.prepareStatement(sql)) {
    System.out.println(sql);
    System.out.println("Update count = " + st.executeUpdate());
  }
  //conn.commit();
} catch (Exception ex) { ex.printStackTrace(); }
System.out.println();
traverseRS("select * from enums");

请注意,我们通过调用conn.setAutoCommit(false)接管了事务控制。结果如下:

正如您所看到的,由于对commit()的调用被注释掉,所以更改没有被应用。当我们取消注释时,结果会改变:

现在,让我们执行两次插入,但在第二次插入中引入一个拼写错误:

traverseRS("select * from enums");
System.out.println();
try (Connection conn = getDbConnection()) {
  conn.setAutoCommit(false);
  String sql = "insert into enums (id, type, value) "
                       + " values(1,'vehicle','car')";
  try (PreparedStatement st = conn.prepareStatement(sql)) {
    System.out.println(sql);
    System.out.println("Update count = " + st.executeUpdate());
  }
  conn.commit();
  sql = "inst into enums (id, type, value) " 
                     + " values(2,'vehicle','truck')";
  try (PreparedStatement st = conn.prepareStatement(sql)) {
    System.out.println(sql);
    System.out.println("Update count = " + st.executeUpdate());
  }
  conn.commit();
} catch (Exception ex) { ex.printStackTrace(); } //get exception here
System.out.println();
traverseRS("select * from enums");

我们得到一个异常堆栈跟踪(我们不显示它以节省空间)和这个消息:

org.postgresql.util.PSQLException: ERROR: syntax error at or near "inst"

尽管第一个插入成功执行:

第二行没有被插入。如果在第一个INSERT INTO语句之后没有conn.commit(),那么第一个插入也不会被应用。这是在许多独立数据更改的情况下进行程序化事务控制的优势——如果一个失败,我们可以跳过它并继续应用其他更改。

现在,让我们尝试插入三行数据,并在第二行中引入一个错误(将字母设置为id值而不是数字):

traverseRS("select * from enums");
System.out.println();
try (Connection conn = getDbConnection()) {
  conn.setAutoCommit(false);
  String[][] values = { {"1", "vehicle", "car"},
                        {"b", "vehicle", "truck"},
                        {"3", "vehicle", "crewcab"} };
  String sql = "insert into enums (id, type, value) " 
                            + " values(?, ?, ?)";
  try (PreparedStatement st = conn.prepareStatement(sql)) {
    for (String[] v: values){
      try {
        System.out.print("id=" + v[0] + ": ");
        st.setInt(1, Integer.parseInt(v[0]));
        st.setString(2, v[1]);
        st.setString(3, v[2]);
        int count = st.executeUpdate();
        conn.commit();
        System.out.println("Update count = "+count);
      } catch(Exception ex){
        //conn.rollback();
        System.out.println(ex.getMessage());
      }
    }
  }
} catch (Exception ex) { ex.printStackTrace(); }
System.out.println();
traverseRS("select * from enums");

我们将每个插入执行放在try...catch块中,并在打印出结果(更新计数或错误消息)之前提交更改。结果如下:

正如你所看到的,第二行没有被插入,尽管conn.rollback()被注释掉了。为什么?这是因为此事务中只包括的 SQL 语句失败了,所以没有什么可以回滚的。

现在,让我们使用数据库控制台创建一个只有一个名为name的列的test表:

在插入enums表中的记录之前,我们将在test表中插入车辆类型:

traverseRS("select * from enums");
System.out.println();
try (Connection conn = getDbConnection()) {
  conn.setAutoCommit(false);
  String[][] values = { {"1", "vehicle", "car"},
                        {"b", "vehicle", "truck"},
                        {"3", "vehicle", "crewcab"} };
  String sql = "insert into enums (id, type, value) " +
                                        " values(?, ?, ?)";
  try (PreparedStatement st = conn.prepareStatement(sql)) {
    for (String[] v: values){
      try(Statement stm = conn.createStatement()) {
        System.out.print("id=" + v[0] + ": ");
        stm.execute("insert into test values('"+ v[2] + "')");
        st.setInt(1, Integer.parseInt(v[0]));
        st.setString(2, v[1]);
        st.setString(3, v[2]);
        int count = st.executeUpdate();
        conn.commit();
        System.out.println("Update count = " + count);
      } catch(Exception ex){
         //conn.rollback();
         System.out.println(ex.getMessage());
      }
    }
  }
} catch (Exception ex) { ex.printStackTrace(); }
System.out.println();
traverseRS("select * from enums");
System.out.println();
traverseRS("select * from test");

正如你所看到的,前面的代码在第二次插入后提交了更改,就像前面的例子一样,对于数组values的第二个元素来说是不成功的。如果注释掉conn.rollback(),结果将如下所示:

enums表中没有插入truck行,但是添加到了test表中。也就是说,演示了回滚的有用性。如果我们取消注释conn.rollback(),结果将如下所示:

这表明conn.rollback()会回滚所有尚未提交的更改。

还有更多...

事务的另一个重要属性是事务隔离级别。它定义了数据库用户之间的边界。例如,在提交之前其他用户能否看到您的数据库更改?隔离级别越高(最高为可串行化),在并发访问相同记录的情况下,事务完成所需的时间就越长。隔离级别越低(最低为读未提交),数据就越脏,这意味着其他用户可以获取您尚未提交的值(也许永远不会提交)。

通常,使用默认级别就足够了,通常是TRANSACTION_READ_COMMITTED,尽管对于不同的数据库可能会有所不同。JDBC 允许您通过在Connection对象上调用getTransactionIsolation()方法来获取当前事务隔离级别。Connection对象的setTransactionIsolation()方法允许您根据需要设置任何隔离级别。

在复杂的决策逻辑中,关于哪些更改需要提交,哪些需要回滚,可以使用另外两个Connection方法来创建和删除保存点setSavepoint(String savepointName)方法创建一个新的保存点并返回一个Savepoint对象,以后可以使用rollback (Savepoint savepoint)方法回滚到此点的所有更改。可以通过调用releaseSavepoint(Savepoint savepoint)来删除保存点。

最复杂的数据库事务类型是分布式事务。它们有时被称为全局事务XA 事务JTA 事务(后者是一个由两个 Java 包组成的 Java API,即javax.transactionjavax.transaction.xa)。它们允许创建和执行跨两个不同数据库操作的事务。提供分布式事务的详细概述超出了本书的范围。

使用大对象

在本教程中,您将学习如何存储和检索可以是三种类型之一的 LOB——二进制大对象BLOB)、字符大对象CLOB)和国家字符大对象NCLOB)。

准备工作

数据库内 LOB 对象的实际处理是特定于供应商的,但是 JDBC API 通过将三种 LOB 类型表示为接口——java.sql.Blobjava.sql.Clobjava.sql.NClob,隐藏了这些实现细节。

Blob通常用于存储图像或其他非字母数字数据。在到达数据库之前,图像可以转换为字节流并使用INSERT INTO语句进行存储。Blob接口允许您查找对象的长度并将其转换为 Java 可以处理的字节数组,以便显示图像等目的。

Clob允许您存储字符数据。NClob以一种支持国际化的方式存储 Unicode 字符数据。它扩展了Clob接口并提供相同的方法。这两个接口都允许您查找 LOB 的长度并获取值内的子字符串。

ResultSetCallableStatement(我们将在下一个示例中讨论),和PreparedStatement接口中的方法允许应用程序以各种方式存储和访问存储的值——有些通过相应对象的设置器和获取器,而其他一些作为bytes[],或作为二进制、字符或 ASCII 流。

如何做...

每个数据库都有其特定的存储 LOB 的方式。在 PostgreSQL 的情况下,Blob通常映射到OIDBYTEA数据类型,而ClobNClob则映射到TEXT类型。为了演示如何做到这一点,让我们创建可以存储每种大对象类型的表。我们将编写一个新的方法,以编程方式创建表:

void execute(String sql){
  try (Connection conn = getDbConnection()) {
    try (PreparedStatement st = conn.prepareStatement(sql)) {
      st.execute();
    }
  } catch (Exception ex) {
    ex.printStackTrace();
  }
}

现在,我们可以创建三个表:

execute("create table images (id integer, image bytea)");
execute("create table lobs (id integer, lob oid)");
execute("create table texts (id integer, text text)");

查看 JDBC 接口PreparedStatementResultSet,您会注意到对象的设置器和获取器——get/setBlob()get/setClob()get/setNClob()get/setBytes()——以及使用InputStreamReader的方法——get/setBinaryStream()get/setAsciiStream(),或get/setCharacterStream()。流方法的重要优势在于它们在数据库和源之间传输数据而无需在内存中存储整个 LOB。

然而,对象的设置器和获取器更符合我们的心意,符合面向对象编程。因此,我们将从它们开始,使用一些不太大的对象进行演示。我们期望以下代码可以正常工作:

try (Connection conn = getDbConnection()) {
  String sql = "insert into images (id, image) values(?, ?)";
  try (PreparedStatement st = conn.prepareStatement(sql)) {
    st.setInt(1, 100);
    File file = 
       new File("src/main/java/com/packt/cookbook/ch06_db/image1.png");
    FileInputStream fis = new FileInputStream(file);
    Blob blob = conn.createBlob();   
    OutputStream out = blob.setBinaryStream(1);
    int i = -1;
    while ((i = fis.read()) != -1) {
      out.write(i);
    }
    st.setBlob(2, blob);
    int count = st.executeUpdate();
    System.out.println("Update count = " + count);
  }
} catch (Exception ex) { ex.printStackTrace(); }

或者,在Clob的情况下,我们编写这段代码:

try (Connection conn = getDbConnection()) {
  String sql = "insert into texts (id, text) values(?, ?)";
  try (PreparedStatement st = conn.prepareStatement(sql)) {
    st.setInt(1, 100);
    File file = new File("src/main/java/com/packt/cookbook/" +
                                    "ch06_db/Chapter06Database.java");
    Reader reader = new FileReader(file);
    st.setClob(2, reader);  
    int count = st.executeUpdate();
    System.out.println("Update count = " + count);
  }
} catch (Exception ex) { ex.printStackTrace(); }

事实证明,并非所有 JDBC API 中可用的方法都由所有数据库的驱动程序实际实现。例如,createBlob()似乎对 Oracle 和 MySQL 都可以正常工作,但在 PostgreSQL 的情况下,我们得到了这个:

对于Clob,我们得到以下结果:

我们也可以尝试通过获取器从ResultSet中检索对象:

String sql = "select image from images";
try (PreparedStatement st = conn.prepareStatement(sql)) {
  st.setInt(1, 100);
  try(ResultSet rs = st.executeQuery()){
    while (rs.next()){
      Blob blob = rs.getBlob(1); 
      System.out.println("blob length = " + blob.length());
    }
  }
}

结果将如下:

显然,仅仅了解 JDBC API 是不够的;您还必须阅读数据库的文档。以下是 PostgreSQL 文档(jdbc.postgresql.org/documentation/80/binary-data.html)对 LOB 处理的说明:

"要使用 BYTEA 数据类型,您应该简单地使用getBytes()setBytes()getBinaryStream()setBinaryStream()方法。

要使用大对象功能,您可以使用由 PostgreSQL JDBC 驱动程序提供的LargeObject类,或者使用getBLOB()setBLOB()方法。"

此外,您必须在 SQL 事务块内访问大对象。您可以通过调用setAutoCommit(false)来启动事务块。

不知道这样的具体情况,找出处理 LOB 的方法将需要很多时间并引起很多挫折。

在处理 LOB 时,流方法更受青睐,因为它们直接从源传输数据到数据库(或反之),并且不像设置器和获取器那样消耗内存(后者必须首先将整个 LOB 加载到内存中)。以下是在 PostgreSQL 数据库中流传Blob的代码:

traverseRS("select * from images");
System.out.println();
try (Connection conn = getDbConnection()) {
  String sql = "insert into images (id, image) values(?, ?)";
  try (PreparedStatement st = conn.prepareStatement(sql)) {
    st.setInt(1, 100);
    File file = 
       new File("src/main/java/com/packt/cookbook/ch06_db/image1.png");
    FileInputStream fis = new FileInputStream(file);
    st.setBinaryStream(2, fis);
    int count = st.executeUpdate();
    System.out.println("Update count = " + count);
  }
  sql = "select image from images where id = ?";
  try (PreparedStatement st = conn.prepareStatement(sql)) {
    st.setInt(1, 100);
    try(ResultSet rs = st.executeQuery()){
      while (rs.next()){
        try(InputStream is = rs.getBinaryStream(1)){
          int i;
          System.out.print("ints = ");
          while ((i = is.read()) != -1) {
            System.out.print(i);
          }
        }
      }
    }
  }
} catch (Exception ex) { ex.printStackTrace(); }
System.out.println();
traverseRS("select * from images");

让我们来看看结果。我们在右侧任意裁剪了截图;否则,它在水平上太长了:

处理检索到的图像的另一种方法是使用byte[]

try (Connection conn = getDbConnection()) {
  String sql =  "insert into images (id, image) values(?, ?)";
  try (PreparedStatement st = conn.prepareStatement(sql)) {
    st.setInt(1, 100);
    File file = 
       new File("src/main/java/com/packt/cookbook/ch06_db/image1.png");
    FileInputStream fis = new FileInputStream(file);
    byte[] bytes = fis.readAllBytes();
    st.setBytes(2, bytes);
    int count = st.executeUpdate();
    System.out.println("Update count = " + count);
  }
  sql = "select image from images where id = ?";
  System.out.println();
  try (PreparedStatement st = conn.prepareStatement(sql)) {
    st.setInt(1, 100);
    try(ResultSet rs = st.executeQuery()){
      while (rs.next()){
        byte[] bytes = rs.getBytes(1);
        System.out.println("bytes = " + bytes);
      }
    }
  }
} catch (Exception ex) { ex.printStackTrace(); }

PostgreSQL 将BYTEA大小限制为 1 GB。较大的二进制对象可以存储为对象标识符OID)数据类型:

traverseRS("select * from lobs");
System.out.println();
try (Connection conn = getDbConnection()) {
  conn.setAutoCommit(false);
  LargeObjectManager lobm = 
        conn.unwrap(org.postgresql.PGConnection.class)
            .getLargeObjectAPI();
  long lob = lobm.createLO(LargeObjectManager.READ 
                           | LargeObjectManager.WRITE);
  LargeObject obj = lobm.open(lob, LargeObjectManager.WRITE);
  File file = 
       new File("src/main/java/com/packt/cookbook/ch06_db/image1.png");
  try (FileInputStream fis = new FileInputStream(file)){
    int size = 2048;
    byte[] bytes = new byte[size];
    int len = 0;
    while ((len = fis.read(bytes, 0, size)) > 0) {
      obj.write(bytes, 0, len);
    }
    obj.close();
    String sql = "insert into lobs (id, lob) values(?, ?)";
    try (PreparedStatement st = conn.prepareStatement(sql)) {
      st.setInt(1, 100);
      st.setLong(2, lob);
      st.executeUpdate();
    }
  }
    conn.commit();
} catch (Exception ex) { ex.printStackTrace(); }
System.out.println();
traverseRS("select * from lobs");

结果将如下所示:

请注意,select语句从lob列返回一个长值。这是因为OID列不像BYTEA那样存储值本身。相反,它存储对存储在数据库中其他位置的对象的引用。这样的安排使得删除具有 OID 类型的行不像这样直截了当:

execute("delete from lobs where id = 100"); 

如果只是这样做,它会使实际对象成为一个继续占用磁盘空间的孤立对象。为了避免这个问题,您必须首先通过执行以下命令unlink LOB:

execute("select lo_unlink((select lob from lobs " + " where id=100))");

只有在此之后,您才能安全地执行delete from lobs where id = 100命令。

如果您忘记首先unlink,或者因为代码错误而意外创建了孤立的 LOB(例如),则可以通过在系统表中查找孤立对象的方式来找到孤立对象。同样,数据库文档应该为您提供如何执行此操作的说明。在 PostgreSQL v.9.3 或更高版本中,您可以通过执行select count(*) from pg_largeobject命令来检查是否有孤立的 LOB。如果返回的计数大于 0,则可以使用以下连接删除所有孤立对象(假设lobs表是唯一一个可能包含对 LOB 的引用的表):

SELECT lo_unlink(pgl.oid) FROM pg_largeobject_metadata pgl
WHERE (NOT EXISTS (SELECT 1 FROM lobs ls" + "WHERE ls.lob = pgl.oid));

这是在数据库中存储 LOB 所需付出的代价。

值得注意的是,尽管BYTEA在删除操作期间不需要这样的复杂性,但它有一种不同类型的开销。根据 PostgreSQL 文档,当接近 1 GB 时,处理这样一个大值将需要大量内存

要读取 LOB 数据,您可以使用以下代码:

try (Connection conn = getDbConnection()) {
  conn.setAutoCommit(false);
  LargeObjectManager lobm =      
          conn.unwrap(org.postgresql.PGConnection.class)
              .getLargeObjectAPI();
  String sql = "select lob from lobs where id = ?";
  try (PreparedStatement st = conn.prepareStatement(sql)) {
    st.setInt(1, 100);
    try(ResultSet rs = st.executeQuery()){
      while (rs.next()){
        long lob = rs.getLong(1);
        LargeObject obj = lobm.open(lob, LargeObjectManager.READ);
        byte[] bytes = new byte[obj.size()];
        obj.read(bytes, 0, obj.size());
        System.out.println("bytes = " + bytes);
        obj.close();
      }
    }
  }
  conn.commit();
} catch (Exception ex) { ex.printStackTrace(); }

或者,如果 LOB 不太大,可以直接从ResultSet对象中获取Blob,使用更简单的代码:

while (rs.next()){
  Blob blob = rs.getBlob(1);
  byte[] bytes = blob.getBytes(1, (int)blob.length());
  System.out.println("bytes = " + bytes);
}

要在 PostgreSQL 中存储Clob,可以使用与前面相同的代码。在从数据库中读取时,可以将字节转换为String数据类型或类似的内容(同样,如果 LOB 不太大):

String str = new String(bytes, Charset.forName("UTF-8"));
System.out.println("bytes = " + str);

然而,在 PostgreSQL 中,Clob可以直接存储为无限大小的数据类型TEXT。这段代码读取了存有这段代码的文件,并将其存储/检索到/从数据库中:

traverseRS("select * from texts");
System.out.println();
try (Connection conn = getDbConnection()) {
  String sql = "insert into texts (id, text) values(?, ?)";
  try (PreparedStatement st = conn.prepareStatement(sql)) {
    st.setInt(1, 100);
    File file = new File("src/main/java/com/packt/cookbook/ch06_db/"
                                          + "Chapter06Database.java");
    try (FileInputStream fis = new FileInputStream(file)) {
      byte[] bytes = fis.readAllBytes();
      st.setString(2, new String(bytes, Charset.forName("UTF-8")));
    }
    int count = st.executeUpdate();
    System.out.println("Update count = " + count);
  }
  sql = "select text from texts where id = ?";
  try (PreparedStatement st = conn.prepareStatement(sql)) {
    st.setInt(1, 100);
    try(ResultSet rs = st.executeQuery()){
      while (rs.next()) {
        String str = rs.getString(1);
        System.out.println(str);
      }
    }
  }
} catch (Exception ex) { ex.printStackTrace(); }

结果将如下所示(我们只显示了输出的前几行):

对于更大的对象,流式处理方法将是更好的(如果不是唯一的)选择:

traverseRS("select * from texts");
System.out.println();
try (Connection conn = getDbConnection()) {
  String sql = "insert into texts (id, text) values(?, ?)";
  try (PreparedStatement st = conn.prepareStatement(sql)) {
    st.setInt(1, 100);
    File file = new File("src/main/java/com/packt/cookbook/ch06_db/"
                                          + "Chapter06Database.java");
    //This is not implemented:
    //st.setCharacterStream(2, reader, file.length()); 
    st.setCharacterStream(2, reader, (int)file.length());

    int count = st.executeUpdate();
    System.out.println("Update count = " + count);
  }
} catch (Exception ex) { ex.printStackTrace(); }
System.out.println();
traverseRS("select * from texts");

请注意,截至撰写本书时,setCharacterStream(int, Reader, long)尚未实现,而setCharacterStream(int, Reader, int)却可以正常工作。

我们还可以从texts表中以字符流的形式读取文件,并将其限制在前 160 个字符:

String sql = "select text from texts where id = ?";
try (PreparedStatement st = conn.prepareStatement(sql)) {
  st.setInt(1, 100);
  try(ResultSet rs = st.executeQuery()){
    while (rs.next()) {
      try(Reader reader = rs.getCharacterStream(1)) {
        char[] chars = new char[160];
        reader.read(chars);
        System.out.println(chars);
      }
    }
  }
}

结果将如下所示:

还有更多...

以下是来自 PostgreSQL 文档的另一个建议(您可以在jdbc.postgresql.org/documentation/80/binary-data.html上访问):

“BYTEA 数据类型不适合存储大量二进制数据。虽然 BYTEA 类型的列最多可以容纳 1 GB 的二进制数据,但处理这样大的值将需要大量内存。

用于存储二进制数据的大对象方法更适合存储非常大的值,但它也有自己的局限性。特别是删除包含大对象引用的行并不会删除大对象。删除大对象是一个需要执行的单独操作。大对象也存在一些安全问题,因为任何连接到数据库的人都可以查看和/或修改任何大对象,即使他们没有权限查看/更新包含大对象引用的行。

在决定将 LOB 存储在数据库中时,您需要记住,数据库越大,维护起来就越困难。选择数据库作为存储设施的主要优势——访问速度也会下降,而且无法为 LOB 类型创建索引以改善搜索。此外,除了一些 CLOB 情况,您不能在WHERE子句中使用 LOB 列,也不能在INSERTUPDATE语句的多行中使用 LOB 列。

因此,在考虑 LOB 的数据库之前,您应该始终考虑在数据库中存储文件名、关键字和其他一些内容属性是否足够解决方案。

执行存储过程

在这个教程中,您将学习如何从 Java 程序中执行数据库存储过程。

做好准备。

偶尔,Java 程序员会遇到需要操作和/或从多个表中选择数据的需求,因此程序员会提出一组复杂的 SQL 语句,这些语句在 Java 中实现起来不切实际,或者强烈怀疑 Java 实现可能无法获得足够的性能。这时,SQL 语句可以被封装成一个存储过程,编译并存储在数据库中,然后通过 JDBC 接口调用。或者,另一种情况是,Java 程序员可能需要将对现有存储过程的调用合并到程序中。为了实现这一点,可以使用CallableStatement接口(它扩展了PreparedStatement接口),尽管一些数据库允许您使用StatementPreparedStatement接口调用存储过程。

CallableStatement可以有三种类型的参数——IN表示输入值,OUT表示结果,IN OUT表示输入或输出值。OUT参数必须由CallableStatementregisterOutParameter()方法注册。IN参数可以像PreparedStatement的参数一样设置。

请记住,从 Java 程序中以编程方式执行存储过程是最不标准化的领域之一。例如,PostgreSQL 不直接支持存储过程,但可以作为函数调用,这些函数已经被修改,将OUT参数解释为返回值。另一方面,Oracle 也允许函数使用OUT参数。

这就是为什么数据库函数和存储过程之间的以下差异只能作为一般指南,而不能作为正式定义:

  • 函数具有返回值,但不允许OUT参数(除了一些数据库),并且可以在 SQL 语句中使用。

  • 存储过程没有返回值(除了一些数据库);它允许OUT参数(对于大多数数据库),并且可以使用 JDBC 接口CallableStatement执行。

这就是为什么阅读数据库文档以了解如何执行存储过程非常重要。

由于存储过程是在数据库服务器上编译和存储的,CallableStatementexecute()方法对于相同的 SQL 语句执行要比StatementPreparedStatement的相应方法性能更好。这是为什么很多 Java 代码有时会被一个或多个存储过程替代的原因之一,甚至包括业务逻辑。但是对于每种情况和问题,都没有一个正确的答案,因此我们将避免提出具体的建议,除了重复熟悉的测试价值和您正在编写的代码的清晰度的口头禅。

如何做到...

与之前的示例一样,我们将继续使用 PostgreSQL 数据库进行演示。在编写自定义 SQL 语句、函数和存储过程之前,您应该先查看已经存在的函数列表。通常,它们提供了丰富的功能。

以下是调用replace(string text, from text, to text)函数的示例,该函数搜索第一个参数(string text)并用第二个参数(from text)匹配的所有子字符串替换为第三个参数(string text)提供的子字符串:

String sql = "{ ? = call replace(?, ?, ? ) }";
try (CallableStatement st = conn.prepareCall(sql)) {
  st.registerOutParameter(1, Types.VARCHAR);
  st.setString(2, "Hello, World! Hello!");
  st.setString(3, "llo");
  st.setString(4, "y");
  st.execute();
  String res = st.getString(1);
  System.out.println(res);
}

结果如下:

我们将把这个函数合并到我们的自定义函数和存储过程中,以展示如何完成这个操作。

存储过程可以没有任何参数,只有IN参数,只有OUT参数,或者两者都有。结果可以是一个或多个值,或者是一个ResultSet对象。以下是在 PostgreSQL 中创建一个没有任何参数的存储过程的示例:

execute("create or replace function createTableTexts() " 
        + " returns void as " 
        + "$$ drop table if exists texts; "
        + "  create table texts (id integer, text text); "
        + "$$ language sql");

在上述代码中,我们使用了我们已经熟悉的execute()方法:

void execute(String sql){
  try (Connection conn = getDbConnection()) {
    try (PreparedStatement st = conn.prepareStatement(sql)) {
      st.execute();
    }
  } catch (Exception ex) {
    ex.printStackTrace();
  }
}

这个存储过程(在 PostgreSQL 中总是一个函数)创建了texts表(如果表已经存在,则先删除)。您可以在数据库文档中找到有关函数创建的 SQL 语法。我们想在这里评论的唯一一件事是,可以使用单引号代替表示函数主体的符号$$。不过,我们更喜欢$$,因为它有助于避免在函数主体中需要包含单引号时进行转义。

在被创建并存储在数据库中之后,该存储过程可以通过CallableStatement来调用:

String sql = "{ call createTableTexts() }";
try (CallableStatement st = conn.prepareCall(sql)) {
  st.execute();
}

或者,可以使用 SQL 语句select createTableTexts()select * from createTableTexts()来调用它。这两个语句都会返回一个ResultSet对象(在createTableTexts()函数的情况下是null),因此我们可以通过我们的方法来遍历它:

void traverseRS(String sql){
  System.out.println("traverseRS(" + sql + "):");
  try (Connection conn = getDbConnection()) {
    try (Statement st = conn.createStatement()) {
      try(ResultSet rs = st.executeQuery(sql)){
        int cCount = 0;
        Map<Integer, String> cName = new HashMap<>();
        while (rs.next()) {
          if (cCount == 0) {
            ResultSetMetaData rsmd = rs.getMetaData();
            cCount = rsmd.getColumnCount();
            for (int i = 1; i <= cCount; i++) {
              cName.put(i, rsmd.getColumnLabel(i));
            }
          }
          List<String> l = new ArrayList<>();
          for (int i = 1; i <= cCount; i++) {
            l.add(cName.get(i) + " = " + rs.getString(i));
          }
          System.out.println(l.stream()
                      .collect(Collectors.joining(", ")));
        }
      }
    }
  } catch (Exception ex) { ex.printStackTrace(); }
}

我们已经在之前的示例中使用过这个方法。

可以使用以下语句删除该函数:

drop function if exists createTableTexts(); 

现在,让我们把所有这些放在一起,用 Java 代码创建一个函数,并以三种不同的方式调用它:

execute("create or replace function createTableTexts() " 
        + "returns void as "
        + "$$ drop table if exists texts; "
        + "  create table texts (id integer, text text); "
        + "$$ language sql");
String sql = "{ call createTableTexts() }";
try (Connection conn = getDbConnection()) {
  try (CallableStatement st = conn.prepareCall(sql)) {
    st.execute();
  }
}
traverseRS("select createTableTexts()");
traverseRS("select * from createTableTexts()");
execute("drop function if exists createTableTexts()");

结果如下:

正如预期的那样,返回的ResultSet对象是null。请注意,函数的名称是不区分大小写的。我们将其保留为驼峰式风格,仅供人类阅读。

现在,让我们创建并调用另一个带有两个输入参数的存储过程(函数):

execute("create or replace function insertText(int,varchar)" 
        + " returns void "
        + " as $$ insert into texts (id, text) "
        + "   values($1, replace($2,'XX','ext'));" 
        + " $$ language sql");
String sql = "{ call insertText(?, ?) }";
try (Connection conn = getDbConnection()) {
  try (CallableStatement st = conn.prepareCall(sql)) {
    st.setInt(1, 1);
    st.setString(2, "TXX 1");
    st.execute();
  }
}
execute("select insertText(2, 'TXX 2')");
traverseRS("select * from texts");
execute("drop function if exists insertText()");

在函数的主体中,输入参数通过它们的位置$1$2来引用。正如之前提到的,我们还使用了内置的replace()函数来操作第二个输入参数的值,然后将其插入到表中。新创建的存储过程被调用了两次:首先通过CallableStatment,然后通过execute()方法,使用不同的输入值。然后,我们使用traverseRS("select * from texts")来查看表中的内容,并删除了新创建的函数以进行清理。我们只是为了演示目的删除了该函数。在实际代码中,一旦创建,函数就会留在那里,并利用它的存在,编译并准备运行。

如果我们运行上述代码,将得到以下结果:

现在让我们向texts表添加两行,然后查看它并创建一个计算表中行数并返回结果的存储过程(函数):

execute("insert into texts (id, text) " 
         + "values(3,'Text 3'),(4,'Text 4')");
traverseRS("select * from texts");
execute("create or replace function countTexts() " 
        + "returns bigint as " 
        + "$$ select count(*) from texts; " 
        + "$$ language sql");
String sql = "{ ? = call countTexts() }";
try (Connection conn = getDbConnection()) {
  try (CallableStatement st = conn.prepareCall(sql)) {
    st.registerOutParameter(1, Types.BIGINT);
    st.execute();
    System.out.println("Result of countTexts() = " + st.getLong(1));
  }
}
traverseRS("select countTexts()");
traverseRS("select * from countTexts()");
execute("drop function if exists countTexts()");

注意返回值的bigint值和OUT参数Types.BIGINT的匹配类型。新创建的存储过程被执行三次,然后被删除。结果如下所示:

现在,让我们看一个具有int类型的输入参数并返回ResultSet对象的存储过程示例:

execute("create or replace function selectText(int) " 
        + "returns setof texts as 
        + "$$ select * from texts where id=$1; " 
        + "$$ language sql");
traverseRS("select selectText(1)");
traverseRS("select * from selectText(1)");
execute("drop function if exists selectText(int)");

注意返回类型定义为setof texts,其中texts是表的名称。如果我们运行上述代码,结果将如下所示:

值得分析的是两次对存储过程的不同调用在ResultSet内容上的差异。没有select *时,它包含存储过程的名称和返回对象(ResultSet类型)。但是有select *时,它返回存储过程中最后一个 SQL 语句的实际ResultSet内容。

自然而然地,问题就是为什么我们不能通过CallableStatement调用这个存储过程,如下所示:

String sql = "{ ? = call selectText(?) }";
try (CallableStatement st = conn.prepareCall(sql)) {
  st.registerOutParameter(1, Types.OTHER);
  st.setInt(2, 1);
  st.execute();
  traverseRS((ResultSet)st.getObject(1));
}

我们尝试了,但没有成功。以下是 PostgreSQL 文档对此的描述:

"返回数据集的函数不应该通过 CallableStatement 接口调用,而应该使用普通的 Statement 或 PreparedStatement 接口。"

不过,有一种方法可以绕过这个限制。同样的数据库文档描述了如何检索refcursor(PostgreSQL 特有的功能)值,然后可以将其转换为ResultSet

execute("create or replace function selectText(int) " 
        + "returns refcursor " +
        + "as $$ declare curs refcursor; " 
        + " begin " 
        + "   open curs for select * from texts where id=$1;" 
        + "     return curs; " 
        + " end; " 
        + "$$ language plpgsql");
String sql = "{ ? = call selectText(?) }";
try (Connection conn = getDbConnection()) {
  conn.setAutoCommit(false);
  try(CallableStatement st = conn.prepareCall(sql)){
    st.registerOutParameter(1, Types.OTHER);
    st.setInt(2, 2);
    st.execute();
    try(ResultSet rs = (ResultSet) st.getObject(1)){
      System.out.println("traverseRS(refcursor()=>rs):");
      traverseRS(rs);
    }
  }
}
traverseRS("select selectText(2)");
traverseRS("select * from selectText(2)");
execute("drop function if exists selectText(int)");

关于前面的代码的一些评论可能会帮助您理解这是如何完成的:

  • 必须关闭自动提交。

  • 函数内部,$1指的是第一个IN参数(不包括OUT参数)。

  • 语言设置为plpgsql以访问refcursor功能(PL/pgSQL 是 PostgreSQL 数据库的可加载过程语言)。

  • 为了遍历ResultSet,我们编写了一个新的方法,如下所示:

        void traverseRS(ResultSet rs) throws Exception {
          int cCount = 0;
          Map<Integer, String> cName = new HashMap<>();
          while (rs.next()) {
            if (cCount == 0) {
              ResultSetMetaData rsmd = rs.getMetaData();
              cCount = rsmd.getColumnCount();
              for (int i = 1; i <= cCount; i++) {
                cName.put(i, rsmd.getColumnLabel(i));
              }
            }
            List<String> l = new ArrayList<>();
            for (int i = 1; i <= cCount; i++) {
              l.add(cName.get(i) + " = " + rs.getString(i));
            }
            System.out.println(l.stream()
                      .collect(Collectors.joining(", ")));
          }
        }

因此,我们的老朋友traverseRS(String sql)方法现在可以重构为以下形式:

void traverseRS(String sql){
  System.out.println("traverseRS(" + sql + "):");
  try (Connection conn = getDbConnection()) {
    try (Statement st = conn.createStatement()) {
      try(ResultSet rs = st.executeQuery(sql)){
        traverseRS(rs);
      }
    }
  } catch (Exception ex) { ex.printStackTrace(); }
}

如果我们运行最后一个示例,结果将如下所示:

您可以看到,不提取对象并将其转换为ResultSet的结果遍历方法在这种情况下不显示正确的数据。

还有更多...

我们介绍了从 Java 代码调用存储过程的最常见情况。本书的范围不允许我们介绍 PostgreSQL 和其他数据库中更复杂和潜在有用的存储过程形式。但是,我们想在这里提到它们,这样你就可以了解其他可能性:

  • 复合类型的函数

  • 带有参数名称的函数

  • 具有可变数量参数的函数

  • 具有参数默认值的函数

  • 函数作为表源

  • 返回表的函数

  • 多态的 SQL 函数

  • 具有排序规则的函数

使用批处理操作处理大量数据

在这个示例中,您将学习如何创建和执行许多 SQL 语句的批处理

准备工作

当需要执行许多 SQL 语句以同时插入、更新或读取数据库记录时,需要批处理。可以通过迭代执行多个 SQL 语句并逐个发送到数据库来执行多个 SQL 语句,但这会产生网络开销,可以通过同时将所有查询发送到数据库来避免。

为了避免这种网络开销,所有的 SQL 语句可以合并成一个String值,每个语句之间用分号分隔,这样它们可以一次性发送到数据库。返回的结果(如果有的话)也会作为每个语句生成的结果集的集合发送回来。这种处理通常被称为批量处理,以区别于仅适用于INSERTUPDATE语句的批处理。批处理允许您使用java.sql.Statementjava.sql.PreparedStatement接口的addBatch()方法来组合许多 SQL 语句。

我们将使用 PostgreSQL 数据库和以下表person来插入、更新和从中读取数据:

create table person (
   name VARCHAR NOT NULL,
   age INTEGER NOT NULL
)

正如您所看到的,表的每个记录都可以包含一个人的两个属性——姓名和年龄。

如何做到这一点...

我们将演示批量处理批处理。为了实现这一点,让我们按照以下步骤进行:

  1. 批量处理的一个示例是使用单个INSERT语句和多个VALUES子句:
INSERT into <table_name> (column1, column2, ...) VALUES 
                         ( value1,  value2, ...),
                         ( value1,  value2, ...),
                          ...
                         ( value1,  value2, ...)

构造这样一个语句的代码如下:

int n = 100000;  //number of records to insert
StringBuilder sb = 
 new StringBuilder("insert into person (name,age) values ");
for(int i = 0; i < n; i++){
   sb.append("(")
     .append("'Name").append(String.valueOf(i)).append("',")
     .append(String.valueOf((int)(Math.random() * 100)))
     .append(")");
   if(i < n - 1) {
        sb.append(",");
   }
}
try(Connection conn = getConnection();
    Statement st = conn.createStatement()){
    st.execute(sb.toString());
} catch (SQLException ex){
    ex.printStackTrace();
}

正如您所看到的,前面的代码构造了一个带有 100,000 个VALUES子句的语句,这意味着它一次性向数据库插入了 100,000 条记录。在我们的实验中,完成这项工作花费了 1,082 毫秒。结果,表person现在包含了 100,000 条记录,姓名从Name0Name99999,年龄是从 1 到 99 的随机数。

这种批量处理方法有两个缺点——它容易受到 SQL 注入攻击,并且可能消耗太多内存。使用PreparedStatement可以解决 SQL 注入问题,但受到绑定变量数量的限制。在 PostgreSQL 的情况下,它不能超过32767。这意味着我们需要将单个PreparedStatement分解为几个更小的PreparedStatement,每个PreparedStatement中的绑定变量不超过32767。顺便说一句,这也可以解决内存消耗问题,因为每个语句现在比大语句小得多。例如,之前的单个语句包括 200,000 个值。

  1. 以下代码通过将单个 SQL 语句分解为多个不超过32766个绑定变量的PreparedStatement对象来解决这两个问题:
int n = 100000, limit = 32766, l = 0;
List<String> queries = new ArrayList<>();
List<Integer> bindVariablesCount = new ArrayList<>();
String insert = "insert into person (name, age) values ";
StringBuilder sb = new StringBuilder(insert);
for(int i = 0; i < n; i++){
    sb.append("(?, ?)");
    l = l + 2;
    if(i == n - 1) {
        queries.add(sb.toString());
        bindVariablesCount.add(l % limit);
    }
    if(l % limit == 0) {
        queries.add(sb.toString());
        bindVariablesCount.add(limit);
        sb = new StringBuilder(insert);
    } else {
        sb.append(",");
    }
}
try(Connection conn = getConnection()) {
    int i = 0, q = 0;
    for(String query: queries){
        try(PreparedStatement pst = conn.prepareStatement(query)) {
            int j = 0;
            while (j < bindVariablesCount.get(q)) {
                pst.setString(++j, "Name" + String.valueOf(i++));
                pst.setInt(++j, (int)(Math.random() * 100));
            }
            pst.executeUpdate();
            q++;
        }
    }
} catch (SQLException ex){
    ex.printStackTrace();
}

前面的代码执行速度与我们之前的示例一样快。完成这项工作花费了 1,175 毫秒。但是我们在安装了数据库的同一台计算机上运行了这段代码,所以没有来自数据库的七次网络开销(这是添加到List queries的查询次数)。但是,正如您所看到的,代码相当复杂。通过使用批处理,可以大大简化它。

  1. 批处理基于addBatch()executeBatch()方法的使用,这两种方法在StatementPreparedStatement接口中都可用。为了演示,我们将使用PreparedStatement,原因有两个——它不容易受到 SQL 注入攻击,并且在多次执行时性能更好(这是PreparedStatement的主要目的——利用对具有不同值的相同语句的多次执行)。
int n = 100000;
String insert = 
           "insert into person (name, age) values (?, ?)";
try (Connection conn = getConnection();
    PreparedStatement pst = conn.prepareStatement(insert)) {
    for (int i = 0; i < n; i++) {
        pst.setString(1, "Name" + String.valueOf(i));
        pst.setInt(2, (int)(Math.random() * 100));
        pst.addBatch();
    }
    pst.executeBatch();
} catch (SQLException ex) {
    ex.printStackTrace();
}

person表中插入 100,000 条记录花费了 2,299 毫秒,几乎是使用单个带有多个VALUES子句的语句(第一个示例)或使用多个PreparedStatement对象(第二个示例)的两倍长。尽管它的执行时间更长,但这段代码明显更简单。它将一批语句一次性发送到数据库,这意味着当数据库与应用程序不在同一台计算机上时,这种实现与之前的实现(需要七次访问数据库)之间的性能差距将更小。

但是这种实现也可以得到改进。

  1. 为了改进批处理,让我们向DataSource对象添加reWriteBatchedInserts属性,并将其设置为true
DataSource createDataSource() {
    HikariDataSource ds = new HikariDataSource();
    ds.setPoolName("cookpool");
    ds.setDriverClassName("org.postgresql.Driver");
    ds.setJdbcUrl("jdbc:postgresql://localhost/cookbook");
    ds.setUsername( "cook");
    //ds.setPassword("123Secret");
    ds.setMaximumPoolSize(2);
    ds.setMinimumIdle(2);
    ds.addDataSourceProperty("reWriteBatchedInserts", 
                                            Boolean.TRUE);
    return ds;
}

现在,如果我们使用连接createDataSource().getConnection()运行相同的批处理代码,插入相同的 10 万条记录所需的时间减少到 750 毫秒,比我们迄今为止测试过的任何实现都要好 25%。而且代码仍然比以前的任何实现都简单得多。

但是内存消耗呢?

  1. 随着批处理的增长,JVM 可能会在某个时候耗尽内存。在这种情况下,批处理可以分成几个批次,每个批次在单独的行程中传递到数据库:
int n = 100000;
int batchSize = 30000;
boolean execute = false;
String insert = 
          "insert into person (name, age) values (?, ?)";
try (Connection conn = getConnection();
    PreparedStatement pst = conn.prepareStatement(insert)) {
    for (int i = 0; i < n; i++) {
        pst.setString(1, "Name" + String.valueOf(i));
        pst.setInt(2, (int)(Math.random() * 100));
        pst.addBatch();
        if((i > 0 && i % batchSize == 0) || 
                                 (i == n - 1 && execute)) {
             pst.executeBatch();
             System.out.print(" " + i); 
                        //prints: 30000 60000 90000 99999
             if(n - 1 - i < batchSize && !execute){
                  execute = true;
             }
        }
    }
    pst.executeBatch();
} catch (SQLException ex) {
    ex.printStackTrace();
}

我们使用变量execute作为标志,指示当最后一个批次小于batchSize值时,我们需要在最后一个语句添加到批次时再次调用executeBatch()。从前面代码的注释中可以看出,executeBatch()被调用了四次,包括在添加最后一个语句时(当i=99999)。在我们的运行中,这段代码的性能与生成多个批次的性能相同,因为我们的数据库位于与应用程序相同的计算机上。否则,每个批次通过网络传递都会增加执行此代码所需的时间。

工作原理...

前一个子部分的最后一个示例(步骤 5)是用于在数据库中插入和更新记录的批处理过程的终极实现。方法executeBatch()返回一个int数组,成功的情况下,表示批处理中每个语句更新了多少行。对于INSERT语句,此值等于-2(负二),这是静态常量Statement.SUCCESS_NO_INFO的值。值为-3(负三),即常量Statement.EXECUTE_FAILED的值,表示语句失败。

如果预期返回的更新行数大于Integer.MAX_VALUE,则使用方法long[] executeLargeBatch()来执行批处理。

没有用于从数据库中读取数据的批处理。要批量读取数据,可以将许多语句作为一个字符串以分号分隔发送到数据库,然后迭代返回的多个结果集。例如,让我们提交SELECT语句,计算年龄值从 1 到 99 的每个记录的数量:

int minAge = 0, maxAge = 0, minCount = n, maxCount = 0;
StringBuilder sb = new StringBuilder();
for (int i = 0; i < 100; i++) {
    sb.append("select count(*) from person where age = ")
                                          .append(i).append(";");
}
try (Connection conn = getConnection();
     PreparedStatement pst = conn.prepareStatement(sb.toString())) {
    boolean hasResult = pst.execute();
    int i = 0;
    while (hasResult){
        try (ResultSet rs = pst.getResultSet()) {
            rs.next();
            int c = rs.getInt(1);
            if(c < minCount) {
                minAge = i;
                minCount = c;
            }
            if(c > maxCount) {
                maxAge = i;
                maxCount = c;
            }
            i++;
            hasResult = pst.getMoreResults();
        }
    }
} catch (SQLException ex) {
    ex.printStackTrace();
}
System.out.println("least popular age=" + minAge + "(" + minCount + 
             "), most popular age=" + maxAge + "(" + maxCount + ")");

在我们的测试运行中,执行上述代码并显示以下消息花费了 2,162 毫秒:

least popular age=14(929), most popular age=10(1080)

还有更多...

将大量数据从 PostgreSQL 数据库移动到数据库和从数据库移动也可以使用COPY命令来完成,该命令将数据复制到文件中。您可以在数据库文档中了解更多信息(www.postgresql.org/docs/current/static/sql-copy.html)。

使用 MyBatis 进行 CRUD 操作

在以前的示例中,使用 JDBC 时,我们需要编写代码,从查询返回的ResultSet对象中提取查询的结果。这种方法的缺点是,您必须编写相当多的样板代码来创建和填充表示数据库中记录的域对象。正如我们在本章的介绍中已经提到的那样,有几个 ORM 框架可以为您执行此操作,并自动创建相应的域对象(或者换句话说,将数据库记录映射到相应的域对象)。当然,每个这样的框架都会减少一些构建 SQL 语句的控制和灵活性。因此,在承诺使用特定的 ORM 框架之前,您需要研究和尝试不同的框架,以找到提供应用程序所需的数据库方面的一切,并且不会产生太多开销的框架。

在这个教程中,您将学习到 SQL 映射工具 MyBatis,与直接使用 JDBC 相比,它简化了数据库编程。

准备工作

MyBatis 是一个轻量级的 ORM 框架,不仅允许将结果映射到 Java 对象,还允许执行任意的 SQL 语句。主要有两种描述映射的方式:

  • 使用 Java 注解

  • 使用 XML 配置

在这个教程中,我们将使用 XML 配置。但是,无论您喜欢哪种风格,您都需要创建一个org.apache.ibatis.session.SqlSessionFactory类型的对象,然后使用它来通过创建一个org.apache.ibatis.session.SqlSession类型的对象来启动 MyBatis 会话:

InputStream inputStream = Resources.getResourceAsStream(configuration);
SqlSessionFactory sqlSessionFactory = 
                     new SqlSessionFactoryBuilder().build(inputStream);
SqlSession session = sqlSessionFactory.openSession();

SqlSessionFactoryBuilder对象有九个重载的build()方法,用于创建SqlSession对象。使用这些方法,您可以定义以下内容:

  • 无论您喜欢自动提交数据库更改还是显式执行它们(我们在示例中使用后者)

  • 无论您是使用配置的数据源(就像我们的例子中)还是使用外部提供的数据库连接

  • 无论您是使用默认的数据库特定事务隔离级别(就像我们的例子中)还是想要设置自己的

  • 您将使用以下哪种ExecutorType值——SIMPLE(默认值,为每次执行语句创建一个新的PreparedStatement)、REUSE(重用PreparedStatements)或BATCH(批处理所有更新语句,并在它们之间执行SELECT时必要时标记它们)

  • 这段代码部署在哪个环境(例如developmenttestproduction),因此将使用相应的配置部分(我们将很快讨论它)

  • 包含数据源配置的Properties对象

SqlSession对象提供了允许您执行在 SQL 映射 XML 文件中定义的SELECTINSERTUPDATEDELETE语句的方法。它还允许您提交或回滚当前事务。

我们用于这个教程的 Maven 依赖如下:

<dependency>
   <groupId>org.mybatis</groupId>
   <artifactId>mybatis</artifactId>
   <version>3.4.6</version>
</dependency>

在撰写本书时,最新的 MyBatis 文档可以在这里找到:www.mybatis.org/mybatis-3/index.html 

如何做…

我们将从使用 PostgreSQL 数据库和Person1类开始进行 CRUD 操作:

public class Person1 {
    private int id;
    private int age;
    private String name;
    public Person1(){}  //Must be present, used by the framework
    public Person1(int age, String name){
        this.age = age;
        this.name = name;
    }
    public int getId() { return id; }
    public void setName(String name) { this.name = name; }
    @Override
    public String toString() {
        return "Person1{id=" + id + ", age=" + age +
                                  ", name='" + name + "'}";
    }
}

我们需要之前的getId()方法来获取一个 ID 值(演示如何通过 ID 查找数据库记录)。setName()方法将用于更新数据库记录,toString()方法将用于显示结果。我们使用名称Person1来区分它与同一类的另一个版本Person2,我们将使用它来演示如何实现类和相应表之间的关系。

可以使用以下 SQL 语句创建匹配的数据库表:

create table person1 (
   id SERIAL PRIMARY KEY,
   age INTEGER NOT NULL,
   name VARCHAR NOT NULL
);

然后执行以下步骤:

  1. 首先创建一个 XML 配置文件。我们将其命名为mb-config1.xml,并将其放在resources文件夹下的mybatis文件夹中。这样,Maven 会将其放在类路径上。另一个选项是将文件放在任何其他文件夹中,并与 Java 代码一起修改pom.xml,告诉 Maven 也将该文件夹中的.xml文件放在类路径上。mb-config1.xml文件的内容如下:
<?xml version="1.0" encoding="UTF-8" ?>
<!DOCTYPE configuration
        PUBLIC "-//mybatis.org//DTD Config 3.0//EN"
        "http://mybatis.org/dtd/mybatis-3-config.dtd">
<configuration>
  <settings>
    <setting name="useGeneratedKeys" value="true"/>
  </settings>
  <typeAliases>
     <typeAlias type="com.packt.cookbook.ch06_db.mybatis.Person1" 
                                                 alias="Person"/>
  </typeAliases>
  <environments default="development">
     <environment id="development">
       <transactionManager type="JDBC"/>
       <dataSource type="POOLED">
          <property name="driver" value="org.postgresql.Driver"/>
          <property name="url" 
                   value="jdbc:postgresql://localhost/cookbook"/>
          <property name="username" value="cook"/>
          <property name="password" value=""/>
        </dataSource>
     </environment>
  </environments>
  <mappers>
      <mapper resource="mybatis/Person1Mapper.xml"/>
  </mappers>
</configuration>

<settings>标签允许全局定义一些行为——延迟加载值、启用/禁用缓存、设置自动映射行为(是否填充嵌套数据)等。我们选择全局设置自动生成的键的用法,因为我们需要插入的对象被填充为在数据库中生成的 ID。

<typeAiases> 标签包含完全限定类名的别名,其工作方式类似于 IMPORT 语句。唯一的区别是别名可以是任何单词,而不仅仅是类名。在声明别名之后,在 MyBatis 的 .xml 文件中的任何其他地方,只能通过此别名引用该类。我们将在不久的将来查看文件 Person1Mapper.xml 的内容时看到如何做到这一点。

<environments> 标签包含不同环境的配置。例如,我们可以为环境 env42(任何字符串都可以)创建一个配置。然后,在创建 SqlSession 对象时,可以将此名称作为方法 SqlSessionFactory.build() 的参数传递,将使用标签 <environment id="env42"></environment> 中包含的配置。它定义要使用的事务管理器和数据源。

TransactionManager 可以是两种类型之一——JDBC,它使用数据源提供的连接来提交、回滚和管理事务的范围,以及 MANAGED,它什么也不做,允许容器管理事务的生命周期——好吧,默认情况下它会关闭连接,但是该行为可以通过设置以下属性进行更改:


<transactionManager type="MANAGED">
    <property name="closeConnection" value="false"/>
</transactionManager>

标签 <mappers> 包含对所有包含映射数据库记录和 Java 对象的 SQL 语句的 .xml 文件的引用,这在我们的情况下是文件 Person1Mapper.xml

  1. 创建 Person1Mapper.xml 文件,并将其放在与 mb-config1.xml 文件相同的文件夹中。此文件可以使用任何您喜欢的名称,但它包含映射数据库记录和类 Person1 对象的所有 SQL 语句,因此我们将其命名为 Person1Mapper.xml 仅仅是为了清晰起见:
<?xml version="1.0" encoding="UTF-8" ?>
<!DOCTYPE mapper
        PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
        "http://mybatis.org/dtd/mybatis-3-mapper.dtd">
<mapper namespace="mybatis.Person1Mapper">
   <insert id="insertPerson" keyProperty="id" keyColumn="id" 
                                         parameterType="Person">
      insert into Person1 (age, name) values(#{age}, #{name})
   </insert>
   <select id="selectPersonById" parameterType="int" 
                                            resultType="Person">
      select * from Person1 where id = #{id}
   </select>
   <select id="selectPersonsByName" parameterType="string" 
                                           resultType="Person">
      select * from Person1 where name = #{name}
   </select>
   <select id="selectPersons" resultType="Person">
      select * from Person1
   </select>
   <update id="updatePersonById" parameterType="Person">
      update Person1 set age = #{age}, name = #{name} 
                                              where id = #{id}
   </update>
   <delete id="deletePersons"> 
      delete from Person1
   </delete>
</mapper>

如您所见,<mapper> 标签具有 namespace 属性,用于解析不同位置相同名称的文件。它可能匹配或不匹配映射器文件的位置。映射器文件的位置在配置文件 mb-config1.xml 中指定为标签 <mapper> 的属性资源(参见上一步)。

标签 <insert><select><update><delete> 的属性在很大程度上是不言自明的。标签 <settings> 中的属性 keyPropertykeyColumnuseGeneratedKeys 被添加以用数据库生成的值填充插入的对象。如果不需要全局使用,可以从配置中的设置中删除属性 useGeneratedKeys,并且只在希望利用某些值的自动生成的插入语句中添加。我们这样做是因为我们想要获取生成的 ID,并在以后的代码中使用它来演示如何通过 ID 检索记录。

<select> 和类似标签的 ID 属性用于调用它们,以及映射器命名空间值。我们将很快向您展示如何做到这一点。构造 #{id} 是指传递的值作为参数,如果该值是原始类型。否则,传递的对象应该具有这样的字段。对象上的 getter 不是必需的。如果存在 getter,则必须符合 JavaBean 方法格式。

对于返回值,默认情况下,列的名称与对象字段或 setter 的名称匹配(必须符合 JavaBean 方法格式)。如果字段(或 setter 名称)和列名不同,可以使用标签 <resultMap> 提供映射。例如,如果表 person 具有列 person_idperson_name,而域对象 Person 具有字段 idname,我们可以创建一个映射:

<resultMap id="personResultMap" type="Person">
    <id property="id" column="person_id" />
    <result property="name" column="person_name"/>
</resultMap>

然后可以使用此 resultMap 来填充域对象 Person,如下所示:

<select id="selectPersonById" parameterType="int" 
                                  resultMap="personResultMap">
   select person_id, person_name from Person where id = #{id}
</select>

或者,也可以使用标准的 select 子句别名:

<select id="selectPersonById" parameterType="int" 
                                          resultType="Person">
   select person_id as "id", person_name as "name" from Person 
                                              where id = #{id}
</select>
  1. 编写代码,向表 person1 中插入记录,然后通过 id 读取此记录:
String resource = "mybatis/mb-config1.xml";
String mapperNamespace = "mybatis.Person1Mapper";
try {
   InputStream inputStream = 
                    Resources.getResourceAsStream(resource);
   SqlSessionFactory sqlSessionFactory = 
          new SqlSessionFactoryBuilder().build(inputStream);
   try(SqlSession session = sqlSessionFactory.openSession()){
       Person1 p = new Person1(10, "John");
       session.insert(mapperNamespace + ".insertPerson", p);
       session.commit();
       p = session.selectOne(mapperNamespace + 
                            ".selectPersonById", p.getId());
        System.out.println("By id " + p.getId() + ": " + p);
    } catch (Exception ex) {
        ex.printStackTrace();
    }
} catch (Exception ex){
    ex.printStackTrace();
}

上述代码将产生一个输出:

By id 1: Person1{id=1, age=10, name='John'}

Resources实用程序有十个重载方法用于读取配置文件。我们已经描述了如何确保 Maven 将配置和 mapper 文件放在类路径上。

SqlSession对象实现了AutoCloseable接口,因此我们可以使用 try-with-resources 块,而不必担心资源泄漏。SqlSession接口提供了许多执行方法,包括重载方法insert()select()selectList()selectMap()selectOne()update()delete(),这些是最常用和直接的方法。我们还使用了insert()selectOne()。后者确保只返回一个结果。否则,它会抛出异常。当用于通过值标识单个记录的列没有唯一约束时,它也会抛出异常。这就是为什么我们在 ID 列上添加了PRIMARY KEY限定符。或者,我们可以只添加唯一约束(将其标记为PRIMARY KEY会隐式执行此操作)。

另一方面,selectList()方法生成一个List对象,即使只返回一个结果。我们现在来演示一下。

  1. 编写代码,从表person1中读取所有记录:
List<Person1> list = session.selectList(mapperNamespace 
                                    + ".selectPersons");
for(Person1 p1: list) {
    System.out.println("All: " + p1);
}

上述代码将产生以下输出:

All: Person1{id=1, age=10, name='John'}
  1. 为了演示更新,让我们将"John"的名字更改为"Bill",然后再次读取person1中的所有记录:
List<Person1> list = session.selectList(mapperNamespace 
                      + ".selectPersonsByName", "John");
for(Person1 p1: list) {
    p1.setName("Bill");
    int c = session.update(mapperNamespace + 
                               ".updatePersonById", p1);
    System.out.println("Updated " + c + " records");
}
session.commit();
list = 
 session.selectList(mapperNamespace + ".selectPersons");
for(Person1 p1: list) {
    System.out.println("All: " + p1);
}

上述代码将产生以下输出:

Updated 1 records
All: Person1{id=1, age=10, name='Bill'}

注意更改是如何提交的:session.commit()。没有这一行,结果是一样的,但更改不会持久化,因为默认情况下事务不会自动提交。可以通过在打开会话时将 autocommit 设置为true来更改它:

      SqlSession session = sqlSessionFactory.openSession(true);
  1. 最后,调用DELETE语句并从表person1中删除所有记录:
int c = session.delete(mapperNamespace + ".deletePersons");
System.out.println("Deleted " + c + " persons");
session.commit();

List<Person1> list = session.selectList(mapperNamespace + 
                                         ".selectPersons");
System.out.println("Total records: " + list.size());

上述代码将产生以下输出:

Deleted 0 persons
Total records: 0
  1. 为了演示 MyBatis 如何支持关系,请创建表family和表person2
create table family (
    id SERIAL PRIMARY KEY,
    name VARCHAR NOT NULL
);
create table person2 (
    id SERIAL PRIMARY KEY,
    age INTEGER NOT NULL,
    name VARCHAR NOT NULL,
    family_id INTEGER references family(id) 
                             ON DELETE CASCADE
);

如您所见,表familyperson2中的记录具有一对多的关系。表person2中的每条记录可能属于一个家庭(指向一个family记录)或不属于。几个人可以属于同一个家庭。我们还添加了ON DELETE CASCADE子句,以便在删除它们所属的家庭时自动删除person2记录。

相应的 Java 类如下所示:

class Family {
    private int id;
    private String name;
    private final List<Person2> members = new ArrayList<>();
    public Family(){}  //Used by the framework
    public Family(String name){ this.name = name; }
    public int getId() { return id; }
    public String getName() { return name; }
    public List<Person2> getMembers(){ return this.members; }
}

如您所见,类Family有一个Person2对象的集合。对于getId()getMembers()方法,我们需要与Person2类建立关系。我们将使用getName()方法来进行演示代码。

Person2类如下所示:

class Person2 {
    private int id;
    private int age;
    private String name;
    private Family family;
    public Person2(){}  //Used by the framework
    public Person2(int age, String name, Family family){
        this.age = age;
        this.name = name;
        this.family = family;
    }
    @Override
    public String toString() {
        return "Person2{id=" + id + ", age=" + age +
                 ", name='" + name + "', family='" +
         (family == null ? "" : family.getName())+ "'}";
    }
}
  1. 创建一个名为mb-config2.xml的新配置文件:
<?xml version="1.0" encoding="UTF-8" ?>
<!DOCTYPE configuration
        PUBLIC "-//mybatis.org//DTD Config 3.0//EN"
        "http://mybatis.org/dtd/mybatis-3-config.dtd">
<configuration>
    <settings>
        <setting name="useGeneratedKeys" value="true"/>
    </settings>
    <typeAliases>
       <typeAlias type="com.packt.cookbook.ch06_db.mybatis.Family" 
                                                    alias="Family"/>
       <typeAlias type="com.packt.cookbook.ch06_db.mybatis.Person2" 
                                                    alias="Person"/>
    </typeAliases>
    <environments default="development">
       <environment id="development">
          <transactionManager type="JDBC"/>
          <dataSource type="POOLED">
             <property name="driver" value="org.postgresql.Driver"/>
             <property name="url" 
                      value="jdbc:postgresql://localhost/cookbook"/>
             <property name="username" value="cook"/>
             <property name="password" value=""/>
          </dataSource>
       </environment>
    </environments>
    <mappers>
        <mapper resource="mybatis/FamilyMapper.xml"/>
        <mapper resource="mybatis/Person2Mapper.xml"/>
    </mappers>
</configuration>

注意,我们现在有两个别名和两个 mapper.xml文件。

  1. Person2Mapper.xml文件的内容比我们之前使用的Person1Mapper.xml文件要小得多:
<?xml version="1.0" encoding="UTF-8" ?>
<!DOCTYPE mapper
        PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
        "http://mybatis.org/dtd/mybatis-3-mapper.dtd">
<mapper namespace="mybatis.Person2Mapper">
    <insert id="insertPerson" keyProperty="id" keyColumn="id"
                                          parameterType="Person">
        insert into Person2 (age, name, family_id) 
                    values(#{age}, #{name}, #{family.id})
    </insert>
    <select id="selectPersonsCount" resultType="int">
        select count(*) from Person2
    </select>
</mapper>

这是因为我们不打算直接更新或管理这些人。我们将通过他们所属的家庭来进行操作。我们添加了一个新的查询,返回person2表中记录的数量。

  1. FamilyMapper.xml文件的内容如下:
<?xml version="1.0" encoding="UTF-8" ?>
<!DOCTYPE mapper
 PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
 "http://mybatis.org/dtd/mybatis-3-mapper.dtd">
<mapper namespace="mybatis.FamilyMapper">
 <insert id="insertFamily" keyProperty="id" keyColumn="id" 
 parameterType="Family">
 insert into Family (name) values(#{name})
 </insert>
 <select id="selectMembersOfFamily" parameterType="int" 
 resultMap="personMap">
 select * from Person2 where family_id = #{id}
 </select>
 <resultMap id="personMap" type="Person">
 <association property="family" column="family_id" 
 select="selectFamilyById"/>
 </resultMap>
 <select id="selectFamilyById" parameterType="int"
 resultType="Family">
 select * from Family where id = #{id}
 </select>
 <select id="selectFamilies" resultMap="familyMap">
 select * from Family
 </select>
 <resultMap id="familyMap" type="Family">
 <collection property="members" column="id" ofType="Person" 
 select="selectMembersOfFamily"/>
 </resultMap>
 <select id="selectFamiliesCount" resultType="int">
 select count(*) from Family
 </select>
 <delete id="deleteFamilies">
 delete from Family
 </delete>

家庭 mapper 更加复杂,因为我们在其中管理关系。首先,看一下查询selectMembersOfFamily。如果您不想在Person2对象的family字段中填充数据,SQL 将会简单得多,如下所示:

    <select id="selectMembersOfFamily" parameterType="int" 
 resultType="Person">
        select * from Person2 where family_id = #{id}
    </select>

但是我们希望在Person2对象中设置相应的Family对象值,因此我们使用了ResultMap personMap,它仅描述了默认情况下无法完成的映射——我们使用<association>标签将family字段与family_id列关联起来,并使用查询selectFamilyById。这个查询不会填充Family对象的members字段,但我们决定在我们的演示中不需要它。

我们在查询selectMembersOfFamily中重用了查询selectFamilies。为了填充Family对象的members字段,我们创建了一个ResultMap familyMap,它使用selectMembersOfFamily来实现。

它是如何工作的...

让我们编写代码来演示对family表的 CRUD 操作。首先,这是如何创建一个family记录并与两个person2记录关联的代码:

String resource = "mybatis/mb-config2.xml";
String familyMapperNamespace = "mybatis.FamilyMapper";
String personMapperNamespace = "mybatis.Person2Mapper";
try {
    InputStream inputStream = Resources.getResourceAsStream(resource);
    SqlSessionFactory sqlSessionFactory =
            new SqlSessionFactoryBuilder().build(inputStream);
    try(SqlSession session = sqlSessionFactory.openSession()){
        Family f = new Family("The Jones");
        session.insert(familyMapperNamespace + ".insertFamily", f);
        System.out.println("Family id=" + f.getId()); //Family id=1

        Person2 p = new Person2(25, "Jill", f);
        session.insert(personMapperNamespace + ".insertPerson", p);
        System.out.println(p); 
          //Person2{id=1, age=25, name='Jill', family='The Jones'}

        p = new Person2(30, "John", f);
        session.insert(personMapperNamespace + ".insertPerson", p);
        System.out.println(p);
          //Person2{id=2, age=30, name='John', family='The Jones'}

        session.commit();
    } catch (Exception ex) {
        ex.printStackTrace();
    }
} catch (Exception ex){
    ex.printStackTrace();
}

现在,我们可以使用以下代码读取创建的记录:

List<Family> fList = 
       session.selectList(familyMapperNamespace + ".selectFamilies");
for (Family f1: fList) {
    System.out.println("Family " + f1.getName() + " has " + 
                               f1.getMembers().size() + " members:");
    for(Person2 p1: f1.getMembers()){
        System.out.println("   " + p1);
    }
}

前面的代码片段产生了以下输出:

Family The Jones has 2 members:
 Person2{id=1, age=25, name='Jill', family='The Jones'}
 Person2{id=2, age=30, name='John', family='The Jones'}

现在,我们可以删除所有family记录,并检查在此之后familyperson2表中是否包含任何记录:

int c = session.delete(familyMapperNamespace + ".deleteFamilies");
System.out.println("Deleted " + c + " families");
session.commit();

c = session.selectOne(familyMapperNamespace + ".selectFamiliesCount");
System.out.println("Total family records: " + c);

c = session.selectOne(personMapperNamespace + ".selectPersonsCount");
System.out.println("Total person records: " + c);

前面的代码片段的输出如下:

Deleted 1 families
Total family records: 0
Total person records: 0

person2表现在也是空的,因为我们在创建表时添加了ON DELETE CASCADE子句。

还有更多...

MyBatis 还提供了构建动态 SQL 的工具,一个 SqlBuilder 类以及许多其他构建和执行任意复杂 SQL 或存储过程的方法。有关详细信息,请阅读www.mybatis.org/mybatis-3中的文档。

使用 Java 持久化 API 和 Hibernate

在本教程中,您将学习如何使用名为Hibernate 对象关系映射ORM)框架的Java 持久化 APIJPA)实现来填充、读取、更改和删除数据库中的数据。

准备工作

JPA 是一种定义 ORM 的规范解决方案。您可以在以下链接找到 JPA 版本 2.2:

http://download.oracle.com/otn-pub/jcp/persistence-2_2-mrel-spec/JavaPersistence.pdf

规范中描述的接口、枚举、注解和异常属于javax.persistence包(javaee.github.io/javaee-spec/javadocs),该包包含在Java 企业版EE)中。 JPA 由几个框架实现,其中最流行的是:

JPA 围绕实体设计——映射到数据库表的 Java bean,使用注解进行映射。或者,可以使用 XML 或两者的组合来定义映射。由 XML 定义的映射优先于注解定义的映射。规范还定义了一种类似 SQL 的查询语言,用于静态和动态数据查询。

大多数 JPA 实现允许使用注解和 XML 定义的映射创建数据库模式。

如何做...

  1. 让我们从将javax.persistence包依赖项添加到 Maven 配置文件pom.xml开始:
<dependency>
    <groupId>javax.persistence</groupId>
    <artifactId>javax.persistence-api</artifactId>
    <version>2.2</version>
</dependency>

我们还不需要任何 JPA 实现。这样,我们可以确保我们的代码不使用任何特定于框架的代码,只使用 JPA 接口。

  1. 创建Person1类:
public class Person1 {
    private int age;
    private String name;
    public Person1(int age, String name){
        this.age = age;
        this.name = name;
    }
    @Override
    public String toString() {
        return "Person1{id=" + id + ", age=" + age +
                          ", name='" + name + "'}";
    }
}

我们不添加 getter、setter 或任何其他方法;这样可以使我们的代码示例简短而简单。根据 JPA 规范,要将此类转换为实体,需要在类声明中添加注解@Entity(需要导入java.persistence.Entity)。这意味着我们希望这个类代表数据库表中的一条记录,表名为person。默认情况下,实体类的名称与表的名称相匹配。但是可以使用注解@Table(name="<another table name>")将类映射到另一个名称的表。类的每个属性也映射到具有相同名称的列,可以使用注解@Column (name="<another column name>")更改默认名称。

此外,实体类必须有一个主键——一个由注解@Id表示的字段。也可以使用注解@IdClass定义结合多个字段的复合键(在我们的示例中未使用)。如果主键在数据库中是自动生成的,可以在该字段前面放置@GeneratedValue注解。

最后,实体类必须有一个无参数的构造函数。有了所有这些注解,实体类Person现在看起来如下:

import javax.persistence.Entity;
import javax.persistence.GeneratedValue;
import javax.persistence.GenerationType;
import javax.persistence.Id;

@Entity
public class Person1 {
    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private int id;
    public int age;
    private String name;
    public Person1(){}
    public Person1(int age, String name){
        this.age = age;
        this.name = name;
    }
}

类和其持久化实例变量都不能声明为 final。这样,实现框架可以扩展实体类并实现所需的功能。

或者,持久化注解可以添加到 getter 和 setter 中,而不是实例字段(如果方法名遵循 Java bean 约定)。但是不允许混合字段和方法注解,可能会导致意想不到的后果。

也可以使用 XML 文件而不是注解来定义 Java 类与数据库表和列之间的映射,但我们打算使用字段级别的注解,以提供最简洁和清晰的方法来表达意图。

  1. 使用以下 SQL 脚本创建名为person1的数据库表:
create table person1 (
   id SERIAL PRIMARY KEY,
   age INTEGER NOT NULL,
   name VARCHAR NOT NULL
);

我们将列id定义为SERIAL,这意味着我们要求数据库在每次向person1表插入新行时自动生成下一个整数值。它与类Person1的属性id的注解相匹配。

  1. 现在,让我们编写一些代码,将一条记录插入到person1表中,然后从中读取所有记录。要创建、更新和删除实体(以及相应表中的记录),需要使用javax.persistence.EntityManager等实体管理器:
EntityManagerFactory emf = 
      Persistence.createEntityManagerFactory("jpa-demo");
EntityManager em = emf.createEntityManager();
try {
    em.getTransaction().begin();
    Person1 p = new Person1(10, "Name10");
    em.persist(p);
    em.getTransaction().commit();

    Query q = em.createQuery("select p from Person1 p");
    List<Person1> pList = q.getResultList();
    for (Person1 p : pList) {
        System.out.println(p);
    }
    System.out.println("Size: " + pList.size());
} catch (Exception ex){
    em.getTransaction().rollback();
} finally {
    em.close();
    emf.close();
} 

正如你所看到的,使用一些配置创建了EntityManagerFactory对象,即jpa-demo。我们很快会谈到它。工厂允许创建一个EntityManager对象,它控制持久化过程:创建、提交和回滚事务,存储一个新的Person1对象(从而在person1表中插入新记录),支持使用Java 持久化查询语言JPQL)读取数据,以及许多其他数据库操作和事务管理过程。

实体管理器关闭后,托管实体处于分离状态。要再次与数据库同步,可以使用EntityManagermerge()方法。

在前面的示例中,我们使用 JPQL 来查询数据库。或者,我们也可以使用 JPA 规范定义的 Criteria API:

CriteriaQuery<Person1> cq = 
       em.getCriteriaBuilder().createQuery(Person1.class);
cq.select(cq.from(Person1.class));
List<Person1> pList = em.createQuery(cq).getResultList();
System.out.println("Size: " + pList.size());

但似乎 JPQL 更简洁,支持那些了解 SQL 的程序员的直觉,所以我们打算使用 JPQL。

  1. resources/META-INF文件夹中的persistence.xml文件中定义持久化配置。标签<persistence-unit>有一个名称属性。我们将属性值设置为jpa-demo,但你可以使用任何其他你喜欢的名称。这个配置指定了 JPA 实现(提供者)、数据库连接属性以及许多其他持久化相关属性,以 XML 格式表示:
<?xml version="1.0" encoding="UTF-8"?>
<persistence xmlns="http://xmlns.jcp.org/xml/ns/persistence" 
    xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
    xsi:schemaLocation="http://xmlns.jcp.org/xml/ns/persistence
    http://xmlns.jcp.org/xml/ns/persistence/persistence_2_1.xsd"
                                                   version="2.1">
   <persistence-unit name="jpa-demo" 
                               transaction-type="RESOURCE_LOCAL">
     <provider>org.hibernate.jpa.HibernatePersistenceProvider</provider>
     <properties>
       <property name="javax.persistence.jdbc.url" 
                   value="jdbc:postgresql://localhost/cookbook"/>
       <property name="javax.persistence.jdbc.driver" 
                                  value="org.postgresql.Driver"/>
       <property name="javax.persistence.jdbc.user" value="cook"/>
       <property name="javax.persistence.jdbc.password" value=""/>
     </properties>
   </persistence-unit>
</persistence>

参考 Oracle 文档(docs.oracle.com/cd/E16439_01/doc.1013/e13981/cfgdepds005.htm)关于persistence.xml文件的配置。对于这个示例,我们使用了 Hibernate ORM,并指定了org.hibernate.jpa.HibernatePersistenceProvider作为提供者。

  1. 最后,我们需要在pom.xml中将 JPA 实现(Hibernate ORM)作为依赖项添加:
<dependency>
    <groupId>org.hibernate</groupId>
    <artifactId>hibernate-core</artifactId>
    <version>5.3.1.Final</version>
    <scope>runtime</scope>
</dependency>
<dependency>
    <groupId>javax.xml.bind</groupId>
    <artifactId>jaxb-api</artifactId>
    <version>2.3.0</version>
</dependency>

正如你可能已经注意到的,我们已经将 Hibernate 依赖项标记为runtime作用域。我们这样做是为了在编写代码时避免使用 Hibernate 特定的功能。我们还添加了jaxb-api依赖项,这是 Hibernate 使用的,但这个库不是 Hibernate 特定的,所以我们没有将它仅在运行时使用。

  1. 为了更好地呈现结果,我们将向Person1类添加以下toString()方法:
@Override
public String toString() {
    return "Person1{id=" + id + ", age=" + age +
                             ", name='" + name + "'}";
}
  1. 现在,我们可以运行我们的 JPA 代码示例并观察输出:
Person1{id=1, age=10, name='Name10'}
Size: 1
Size: 1

前面输出的前两行来自 JPQL 的使用,最后一行来自我们代码示例的 Criteria API 的使用片段。

  1. JPA 还提供了建立类之间关系的规定。一个实体类(以及相应的数据库表)可以与另一个实体类(以及它的表)建立一对一、一对多、多对一和多对多的关系。关系可以是双向的或单向的。该规范定义了双向关系的以下规则:
  • 反向方必须使用注解@OneToOne@OneToMany@ManyToManymappedBy属性引用它的拥有方。

  • 一对多和多对一关系的多方必须拥有这个关系,因此mappedBy属性不能在@ManyToOne注解上指定。

  • 在一对一关系中,拥有方是包含外键的一方。

  • 在多对多关系中,任何一方都可以是拥有方。

在单向关系中,只有一个类引用另一个类。

为了说明这些规则,让我们创建一个名为Family的类,它与类Person2有一对多的关系:

@Entity
public class Family {
    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private int id;
    private String name;
    public Family(){}
    public Family(String name){ this.name = name;}

    @OneToMany(mappedBy = "family")
    private final List<Person2> members = new ArrayList<>();

    public List<Person2> getMembers(){ return this.members; }
    public String getName() { return name; }
}

创建表family的 SQL 脚本非常简单:

create table family (
    id SERIAL PRIMARY KEY,
    name VARCHAR NOT NULL
);

我们还需要将Family字段添加到Person2类中:

@Entity
public class Person2 {
    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private int id;
    private int age;
    private String name;

    @ManyToOne
    private Family family;

    public Person2(){}
    public Person2(int age, String name, Family family){
        this.age = age;
        this.name = name;
        this.family = family;
    }
    @Override
    public String toString() {
      return "Person2{id=" + id + ", age=" + age +
               ", name='" + name + "', family='" +
         (family == null ? "" : family.getName())+ "'}";
    }
}

Person2类是“多”方,因此根据这个规则,它拥有关系,所以表person2必须有一个指向表family记录的外键:

create table person2 (
    id SERIAL PRIMARY KEY,
    age INTEGER NOT NULL,
    name VARCHAR NOT NULL,
    family_id INTEGER references family(id) 
                             ON DELETE CASCADE
);

引用列需要这个列的值是唯一的。这就是为什么我们将表person2的列id标记为PRIMARY KEY。否则,会出现错误ERROR: 42830: there is no unique constraint matching given keys for referenced table

  1. 现在,我们可以使用FamilyPerson2类来创建相应表中的记录,并从这些表中读取:
EntityManagerFactory emf = 
         Persistence.createEntityManagerFactory("jpa-demo");
    EntityManager em = emf.createEntityManager();
    try {
        em.getTransaction().begin();
        Family f = new Family("The Jones");
        em.persist(f);

        Person2 p = new Person2(10, "Name10", f);  
        em.persist(p);                      

        f.getMembers().add(p);
        em.getTransaction().commit();

        Query q = em.createQuery("select f from Family f");
        List<Family> fList = q.getResultList();
        for (Family f1 : fList) {
            System.out.println("Family " + f1.getName() + ": " 
                      + f1.getMembers().size() + " members:");
            for(Person2 p1: f1.getMembers()){
                System.out.println("   " + p1);
            }
        }
        q = em.createQuery("select p from Person2 p");
        List<Person2> pList = q.getResultList();
        for (Person2 p1 : pList) {
            System.out.println(p1);
        }
    } catch (Exception ex){
        ex.printStackTrace();
        em.getTransaction().rollback();
    } finally {
        em.close();
        emf.close();
    }
}

在前面的代码中,我们创建了一个Family类的对象并将其持久化。这样,对象就从数据库中获得了一个id值。然后,我们将它传递给Person2类的构造函数,并在多方建立了关系。然后,我们持久化了Person2对象(这样它也从数据库中获得了一个id),并将其添加到Family对象的members集合中,这样关系的一方也建立了。为了保留数据,事务必须被提交。当事务被提交时,与EntityManager关联的所有实体对象(这些对象被称为处于管理状态)会自动持久化。

它是如何工作的...

如果我们运行上述代码,结果将如下所示:

Family The Jones: 1 members:
    Person2{id=1, age=10, name='Name10', family='The Jones'}
Person2{id=1, age=10, name='Name10', family='The Jones'}

正如你所看到的,这正是我们所期望的——一个Family The Jones类的对象有一个成员——一个Person2类的对象,并且表person2中的记录指向表family中对应的记录。

第七章:并发和多线程编程

并发编程一直是一项困难的任务。它是许多难以解决的问题的根源。在本章中,我们将向您展示不同的整合并发的方式和一些最佳实践,例如不可变性,它有助于创建多线程处理。我们还将讨论一些常用模式的实现,例如分而治之和发布-订阅,使用 Java 提供的构造。我们将涵盖以下内容:

  • 使用并发的基本元素——线程

  • 不同的同步方法

  • 不可变性作为实现并发的手段

  • 使用并发集合

  • 使用执行器服务执行异步任务

  • 使用 fork/join 实现分而治之

  • 使用流来实现发布-订阅模式

介绍

并发——能够并行执行多个过程——随着大数据分析进入现代应用程序的主流,变得越来越重要。拥有 CPU 或一个 CPU 中的多个核心有助于增加吞吐量,但数据量的增长速度总是超过硬件的进步。此外,即使在多 CPU 系统中,仍然需要结构化代码并考虑资源共享,以充分利用可用的计算能力。

在前几章中,我们演示了如何使用函数接口和并行流使 lambda 成为每个 Java 程序员工具包的一部分,从而实现了并发处理。一个人可以很容易地在最少或没有指导的情况下利用这种功能。

在本章中,我们将描述一些其他(Java 9 之前的)和新的 Java 特性和 API,它们允许更多地控制并发。自 Java 5 以来,高级并发 Java API 一直存在。JDK 增强提案(JEP)266,“更多并发更新”(openjdk.java.net/jeps/266),在 Java 9 中引入到java.util.concurrent包中。

“可互操作的发布-订阅框架,对 CompletableFuture API 的增强以及各种其他改进”

但在我们深入了解最新添加的细节之前,让我们回顾一下 Java 中并发编程的基础知识,以及如何使用它们。

Java 有两个执行单元——进程和线程。一个进程通常代表整个 JVM,尽管应用程序可以使用ProcessBuilder创建另一个进程。但由于多进程情况超出了本书的范围,我们将专注于第二个执行单元,即线程,它类似于进程,但与其他线程隔离程度较低,执行所需资源较少。

一个进程可以有许多运行的线程,至少有一个称为线程。线程可以共享资源,包括内存和打开的文件,这可以提高效率。但这也带来了更高的意外相互干扰和甚至阻塞执行的风险。这就需要编程技能和对并发技术的理解。这也是我们将在本章讨论的内容。

使用并发的基本元素——线程

在本章中,我们将研究java.lang.Thread类,并了解它对并发和程序性能的一般作用。

准备工作

Java 应用程序以主线程(不包括支持进程的系统线程)开始。然后它可以创建其他线程,并让它们并行运行,通过时间切片共享同一个核心,或者为每个线程分配一个专用的 CPU。这可以使用实现了Runnable函数接口的java.lang.Thread类来实现,该接口只有一个抽象方法run()

创建新线程有两种方法:创建Thread的子类,或者实现Runnable接口并将实现类的对象传递给Thread构造函数。我们可以通过调用Thread类的start()方法来调用新线程,该方法又调用了实现的run()方法。

然后,我们可以让新线程运行直到完成,或者暂停它然后让它继续。如果需要,我们还可以访问它的属性或中间结果。

如何做到...

首先,我们创建一个名为AThread的类,它扩展了Thread并重写了它的run()方法:

class AThread extends Thread {
  int i1,i2;
  AThread(int i1, int i2){
    this.i1 = i1;
    this.i2 = i2;
  }
  public void run() {
    IntStream.range(i1, i2)
             .peek(Chapter07Concurrency::doSomething)
             .forEach(System.out::println);
  }
}

在这个例子中,我们希望线程在特定范围内生成一个整数流。然后,我们使用peek()操作来调用主类的doSomething()静态方法,以使线程忙碌一段时间。参考以下代码:

int doSomething(int i){
  IntStream.range(i, 100000).asDoubleStream().map(Math::sqrt).average();
  return i;
}

如你所见,doSomething()方法生成了一个范围在i99999之间的整数流;然后将流转换为双精度流,计算每个流元素的平方根,最后计算流元素的平均值。我们丢弃结果并返回传入的参数,这样可以保持流水线的流畅风格,最后打印出每个元素。使用这个新类,我们可以演示三个线程的并发执行,如下所示:

Thread thr1 = new AThread(1, 4);
thr1.start();

Thread thr2 = new AThread(11, 14);
thr2.start();

IntStream.range(21, 24)
         .peek(Chapter07Concurrency::doSomething)
         .forEach(System.out::println);

第一个线程生成整数123,第二个生成整数111213,第三个线程(主线程)生成212223

如前所述,我们可以通过创建和使用一个实现Runnable接口的类来重写相同的程序:

class ARunnable implements Runnable {
  int i1,i2;
  ARunnable(int i1, int i2){
    this.i1 = i1;
    this.i2 = i2;
  }
  public void run() {
    IntStream.range(i1, i2)
             .peek(Chapter07Concurrency::doSomething)
             .forEach(System.out::println);
  }
}

可以像这样运行相同的三个线程:

Thread thr1 = new Thread(new ARunnable(1, 4));
thr1.start();

Thread thr2 = new Thread(new ARunnable(11, 14));
thr2.start();

IntStream.range(21, 24)
         .peek(Chapter07Concurrency::doSomething)
         .forEach(System.out::println);

我们还可以利用Runnable作为一个函数式接口,通过传递 lambda 表达式来避免创建中间类:

Thread thr1 = new Thread(() -> IntStream.range(1, 4)
                  .peek(Chapter07Concurrency::doSomething)
                  .forEach(System.out::println));
thr1.start();

Thread thr2 = new Thread(() -> IntStream.range(11, 14)
                  .peek(Chapter07Concurrency::doSomething)
                  .forEach(System.out::println));
thr2.start();

IntStream.range(21, 24)
         .peek(Chapter07Concurrency::doSomething)
         .forEach(System.out::println);

哪种实现更好取决于你的目标和风格。实现Runnable有一个优势(在某些情况下,是唯一可能的选项),允许实现扩展另一个类。当你想要向现有类添加类似线程的行为时,这是特别有帮助的。甚至可以直接调用run()方法,而不必将对象传递给Thread构造函数。

当只需要run()方法的实现时,使用 lambda 表达式胜过Runnable实现,无论它有多大。如果它太大,可以将其隔离在一个单独的方法中:

public static void main(String arg[]) {
  Thread thr1 = new Thread(() -> runImpl(1, 4));
  thr1.start();

  Thread thr2 = new Thread(() -> runImpl(11, 14));
  thr2.start();

  runImpl(21, 24);
}

private static void runImpl(int i1, int i2){
  IntStream.range(i1, i2)
           .peek(Chapter07Concurrency::doSomething)
           .forEach(System.out::println);
}

很难想出比前述功能更短的实现。

如果运行任何前述版本,将会得到类似以下的输出:

正如你所看到的,这三个线程同时打印出它们的数字,但顺序取决于特定的 JVM 实现和底层操作系统。因此,你可能会得到不同的输出。此外,它也可能在每次运行时发生变化。

Thread类有几个构造函数,允许设置线程的名称和所属的组。将线程分组有助于管理它们,如果有许多线程并行运行。该类还有几个方法,提供有关线程状态和属性的信息,并允许我们控制线程的行为。将这两行添加到前面的示例中:

System.out.println("Id=" + thr1.getId() + ", " + thr1.getName() + ",
                   priority=" + thr1.getPriority() + ",
                   state=" + thr1.getState());
System.out.println("Id=" + thr2.getId() + ", " + thr2.getName() + ",
                   priority=" + thr2.getPriority() + ",
                   state=" + thr2.getState());

前述代码的结果将会是这样的:

接下来,假设你给每个线程添加一个名字:

Thread thr1 = new Thread(() -> runImpl(1, 4), "First Thread");
thr1.start();

Thread thr2 = new Thread(() -> runImpl(11, 14), "Second Thread");
thr2.start();

在这种情况下,输出将显示如下内容:

线程的id是自动生成的,不能更改,但在线程终止后可以重新使用。另一方面,可以为多个线程设置相同的名称。执行优先级可以通过编程方式设置,其值介于Thread.MIN_PRIORITYThread.MAX_PRIORITY之间。值越小,线程被允许运行的时间就越长,这意味着它具有更高的优先级。如果没有设置,优先级值默认为Thread.NORM_PRIORITY

线程的状态可以有以下值之一:

  • NEW:当一个线程尚未启动时

  • RUNNABLE:当一个线程正在执行时

  • BLOCKED:当一个线程被阻塞并且正在等待监视器锁时

  • WAITING:当一个线程无限期地等待另一个线程执行特定操作时

  • TIMED_WAITING:当一个线程等待另一个线程在指定的等待时间内执行操作时

  • TERMINATED:当一个线程已经退出时

sleep()方法可用于暂停线程执行一段指定的时间(以毫秒为单位)。补充的interrupt()方法会向线程发送InterruptedException,可以用来唤醒休眠的线程。让我们在代码中解决这个问题并创建一个新的类:

class BRunnable implements Runnable {
  int i1, result;
  BRunnable(int i1){ this.i1 = i1; }
  public int getCurrentResult(){ return this.result; }
  public void run() {
    for(int i = i1; i < i1 + 6; i++){
      //Do something useful here
      this.result = i;
      try{ Thread.sleep(1000);
      } catch(InterruptedException ex){}
    }
  }
}

前面的代码会产生中间结果,这些结果存储在result属性中。每次产生新的结果时,线程都会暂停(休眠)一秒钟。在这个特定的示例中,仅用于演示目的的代码并没有做任何特别有用的事情。它只是迭代一组值,并将每个值视为结果。在实际的代码中,您将根据系统的当前状态进行一些计算,并将计算出的值分配给result属性。现在让我们使用这个类:

BRunnable r1 = new BRunnable(1);
Thread thr1 = new Thread(r1);
thr1.start();

IntStream.range(21, 29)
         .peek(i -> thr1.interrupt())
         .filter(i ->  {
           int res = r1.getCurrentResult();
           System.out.print(res + " => ");
           return res % 2 == 0;
         })
         .forEach(System.out::println);

thr1 thread and lets it generate the next result, which is then accessed via the getCurrentResult() method. If the current result is an even number, the filter allows the generated number flow to be printed out. If not, it is skipped. Here is a possible result:

输出在不同的计算机上可能看起来不同,但你明白了:这样,一个线程可以控制另一个线程的输出。

还有更多...

还有两个支持线程协作的重要方法。第一个是join()方法,它允许当前线程等待另一个线程终止。join()的重载版本接受定义线程在可以做其他事情之前必须等待多长时间的参数。

setDaemon()方法可用于在所有非守护线程终止后使线程自动终止。通常,守护线程用于后台和支持进程。

不同的同步方法

在这个示例中,您将学习 Java 中管理共享资源的并发访问的两种最流行的方法:synchronized methodsynchronized block

做好准备

两个或更多线程同时修改相同的值,而其他线程读取它,这是并发访问问题的最一般描述。更微妙的问题包括线程干扰和内存一致性错误,这两者都会在看似良性的代码片段中产生意外结果。我们将演示这些情况以及避免它们的方法。

乍一看,这似乎很简单:只允许一个线程一次修改/访问资源,就可以了。但是如果访问需要很长时间,就会创建一个可能消除多个线程并行工作优势的瓶颈。或者,如果一个线程在等待访问另一个资源时阻塞了对一个资源的访问,而第二个线程在等待对第一个资源的访问时阻塞了对第二个资源的访问,就会创建一个称为死锁的问题。这些都是程序员在处理多个线程时可能面临的挑战的两个非常简单的例子。

如何做...

首先,我们将重现由并发修改相同值引起的问题。让我们创建一个Calculator类,其中有calculate()方法:

class Calculator {
   private double prop;
   public double calculate(int i){
      DoubleStream.generate(new Random()::nextDouble).limit(50);
      this.prop = 2.0 * i;
      DoubleStream.generate(new Random()::nextDouble).limit(100);
      return Math.sqrt(this.prop);
   }
}

这种方法将一个输入值分配给一个属性,然后计算其平方根。我们还插入了两行代码,生成了 50 和 100 个值的流。我们这样做是为了让方法忙一段时间。否则,一切都会很快完成,几乎没有并发的机会。我们添加生成 100 个值的代码给另一个线程一个机会,在当前线程计算当前线程刚刚分配的值的平方根之前,为prop字段分配另一个值。

现在我们将在以下代码片段中使用calculate()方法:

Calculator c = new Calculator();
Runnable runnable = () -> System.out.println(IntStream.range(1, 40)
                                   .mapToDouble(c::calculate).sum());
Thread thr1 = new Thread(runnable);
thr1.start();
Thread thr2 = new Thread(runnable);
thr2.start();

前两个线程同时修改同一个Calculator对象的同一个属性。以下是我们从其中一个运行中得到的结果:

231.69407148192175
237.44481627598856

如果您在计算机上运行这些示例,并且看不到并发效果,请尝试通过将slowing行中的100替换为1000来增加生成的双倍数量,例如。当线程的结果不同时,这意味着在设置prop字段的值并在calculate()方法中返回其平方根之间的时间段内,另一个线程设法为prop分配了不同的值。这是线程干扰的情况。

保护代码免受这种问题的两种方法:使用synchronized methodsynchronized block——两者都有助于在没有其他线程干扰的情况下执行代码作为原子操作。

制作synchronized method很容易和直接:

class Calculator{
  private double prop;
 synchronized public double calculate(int i){
     DoubleStream.generate(new Random()::nextDouble).limit(50);
     this.prop = 2.0 * i;
     DoubleStream.generate(new Random()::nextDouble).limit(100);
     return Math.sqrt(this.prop);
  }
}

我们只需在方法定义前面添加synchronized关键字。现在,两个线程的结果将始终相同:

233.75710300331153
233.75710300331153

这是因为另一个线程在当前线程(已经进入方法的线程)退出之前,无法进入同步方法。如果方法执行时间很长,这种方法可能会导致性能下降。在这种情况下,可以使用synchronized block,它不是包装整个方法,而是包装几行代码,使其成为原子操作。在我们的情况下,我们可以将生成 50 个值的slowing代码行移出同步块:

class Calculator{
  private double prop;
  public double calculate(int i){
  DoubleStream.generate(new Random()::nextDouble).limit(50);
 synchronized (this) {
       this.prop = 2.0 * i;
       DoubleStream.generate(new Random()::nextDouble).limit(100);
       return Math.sqrt(this.prop);
    }
  }

这样,同步部分要小得多,因此它更少地成为瓶颈的机会。

synchronized block在对象上获取锁—任何对象,无论如何。在一个庞大的类中,您可能不会注意到当前对象(this)被用作多个块的锁。并且在类上获取的锁更容易出现意外共享。因此,最好使用专用锁:

class Calculator{
  private double prop;
  private Object calculateLock = new Object();
  public double calculate(int i){
    DoubleStream.generate(new Random()::nextDouble).limit(50);
    synchronized (calculateLock) {
       this.prop = 2.0 * i;
       DoubleStream.generate(new Random()::nextDouble).limit(100);
       return Math.sqrt(this.prop);
    }
  }
}

专用锁具有更高的保证级别,该锁将用于仅访问特定块。

我们做了所有这些示例,只是为了演示同步方法。如果它们是真正的代码,我们将让每个线程创建自己的Calculator对象:

    Runnable runnable = () -> {
        Calculator c = new Calculator();
        System.out.println(IntStream.range(1, 40)
                .mapToDouble(c::calculate).sum());
    };
    Thread thr1 = new Thread(runnable);
    thr1.start();
    Thread thr2 = new Thread(runnable);
    thr2.start();

这将符合使 lambda 表达式独立于它们创建的上下文的一般思想。这是因为在多线程环境中,人们永远不知道它们执行期间上下文会是什么样子。每次创建一个新对象的成本是可以忽略不计的,除非必须处理大量数据,并且测试确保对象创建开销是显而易见的。

在多线程环境中,内存一致性错误可能具有许多形式和原因。它们在java.util.concurrent包的 Javadoc 中有很好的讨论。在这里,我们只提到最常见的情况,即由于可见性不足而引起的。当一个线程更改属性值时,另一个可能不会立即看到更改,并且您不能为原始类型使用synchronized关键字。在这种情况下,考虑为属性使用volatile关键字;它保证了不同线程之间的读/写可见性。

还有更多...

不同类型的锁用于不同的需求,并具有不同的行为,这些锁被组装在java.util.concurrent.locks包中。

java.util.concurrent.atomic包提供了对单个变量进行无锁、线程安全编程的支持。

以下类也提供了同步支持:

  • Semaphore:这限制了可以访问资源的线程数量

  • CountDownLatch:这允许一个或多个线程等待,直到其他线程中执行的一组操作完成

  • CyclicBarrier:这允许一组线程等待彼此达到一个共同的屏障点

  • Phaser:这提供了一种更灵活的屏障形式,可用于控制多个线程之间的分阶段计算

  • Exchanger:这允许两个线程在会合点交换对象,并在几个管道设计中非常有用。

Java 中的每个对象都继承了基本对象的wait()notify()notifyAll()方法。这些方法也可以用来控制线程的行为和它们对锁的访问。

Collections类有方法来同步各种集合。然而,这意味着只有对集合的修改才能变得线程安全,而对集合成员的更改则不是。此外,通过其迭代器遍历集合时,也必须进行保护,因为迭代器不是线程安全的。以下是一个正确使用同步映射的 Javadoc 示例:

 Map m = Collections.synchronizedMap(new HashMap());
 ...
 Set s = m.keySet(); // Needn't be in synchronized block
 ...
 synchronized (m) { // Synchronizing on m, not s!
   Iterator i = s.iterator(); //Must be synchronized block
   while (i.hasNext())
   foo(i.next());
 }

作为程序员,你必须意识到以下代码不是线程安全的:

List<String> l = Collections.synchronizedList(new ArrayList<>());
l.add("first");
//... code that adds more elements to the list
int i = l.size();
//... some other code
l.add(i, "last");

这是因为虽然List l是同步的,在多线程处理中,很可能会有其他代码向列表添加更多元素或删除元素。

并发问题并不容易解决。这就是为什么越来越多的开发人员现在采取更激进的方法。他们更喜欢在一组无状态操作中处理数据,而不是管理对象状态。我们在第五章中看到了这种代码的例子,流和管道。看来 Java 和许多现代语言和计算机系统正在朝着这个方向发展。

不可变性作为实现并发的手段

在这个示例中,你将学习如何使用不可变性来解决并发引起的问题。

准备就绪

并发问题最常发生在不同的线程修改和读取同一共享资源的数据时。减少修改操作的数量会减少并发问题的风险。这就是不可变性——只读值的条件——进入舞台的地方。

对象的不可变性意味着在创建对象后无法更改其状态。这并不能保证线程安全,但有助于显著增加线程安全性,并在许多实际应用程序中提供足够的保护,以防止并发问题。

创建一个新对象而不是通过设置器和获取器更改其状态来重用现有对象通常被认为是一种昂贵的方法。但是随着现代计算机的强大,必须大量创建对象才能显著影响性能。即使是这种情况,程序员通常也会选择一些性能下降作为获得可预测结果的代价。

如何做...

下面是一个产生可变对象的类的示例:

class MutableClass{
  private int prop;
  public MutableClass(int prop){
    this.prop = prop;
  }
  public int getProp(){
    return this.prop;
  }
  public void setProp(int prop){
    this.prop = prop;
  }
}

要使其不可变,我们需要删除设置器,并将final关键字添加到其唯一属性和类本身:

final class ImmutableClass{
  final private int prop;
  public ImmutableClass(int prop){
    this.prop = prop;
  }
  public int getProp(){
    return this.prop;
  }
}

向类添加final关键字可以防止其被扩展,因此其方法不能被覆盖。向私有属性添加final并不那么明显。动机有些复杂,与编译器在对象构造期间重新排序字段的方式有关。如果字段声明为final,编译器会将其视为同步。这就是为什么向私有属性添加final是必要的,以使对象完全不可变。

如果类由其他类组成,特别是可变类,挑战就会增加。当这种情况发生时,注入的类可能会带入会影响包含类的代码。此外,通过 getter 引用检索的内部(可变)类可能会被修改并传播更改到包含类内部。关闭这种漏洞的方法是在对象检索的组合期间生成新对象。以下是一个示例:

final class ImmutableClass{
  private final double prop;
  private final MutableClass mutableClass;
  public ImmutableClass(double prop, MutableClass mc){
    this.prop = prop;
    this.mutableClass = new MutableClass(mc.getProp());
  }
  public double getProp(){
    return this.prop;
  }
  public MutableClass getMutableClass(){
    return new MutableClass(mutableClass.getProp());
  }
}

还有更多...

在我们的示例中,我们使用了非常简单的代码。如果任何方法中添加了更多复杂性,特别是带有参数(尤其是当一些参数是对象时),可能会再次出现并发问题:

int getSomething(AnotherMutableClass amc, String whatever){
  //... code is here that generates a value "whatever" 
  amc.setProperty(whatever);
  //...some other code that generates another value "val"
  amc.setAnotherProperty(val);
  return amc.getIntValue();
}

即使此方法属于ImmutableClass,并且不影响ImmutableClass对象的状态,它仍然是线程竞争的主题,并且必须根据需要进行分析和保护。

Collections类具有使各种集合不可修改的方法。这意味着集合本身的修改变为只读,而不是集合成员。

使用并发集合

在本教程中,您将了解java.util.concurrent包的线程安全集合。

准备就绪

如果对集合应用Collections.synchronizeXYZ()方法之一,则可以对集合进行同步;在这里,我们使用 XYZ 作为占位符,表示SetListMap或几种集合类型之一(请参阅Collections类的 API)。我们已经提到,同步适用于集合本身,而不适用于其迭代器或集合成员。

这种同步集合也被称为包装器,因为所有功能仍由作为参数传递给Collections.synchronizeXYZ()方法的集合提供,而包装器仅提供对它们的线程安全访问。通过在原始集合上获取锁也可以实现相同的效果。显然,在多线程环境中,这种同步会产生性能开销,导致每个线程等待轮流访问集合。

java.util.concurrent包提供了性能实现线程安全集合的良好调整的应用程序。

如何做...

java.util.concurrent包的每个并发集合都实现(或扩展,如果它是一个接口)java.util包的四个接口之一:ListSetMapQueue

  1. List接口只有一个实现:CopyOnWriteArrayList类。以下摘自此类的 Javadoc:

“所有改变操作(添加、设置等)都是通过制作基础数组的新副本来实现的...“快照”样式的迭代器方法使用对迭代器创建时数组状态的引用。在迭代器的生命周期内,此数组永远不会更改,因此不可能发生干扰,并且保证迭代器不会抛出ConcurrentModificationException。迭代器不会反映自创建迭代器以来对列表的添加、删除或更改。迭代器本身的元素更改操作(删除、设置和添加)不受支持。这些方法会抛出UnsupportedOperationException。”

  1. 为了演示CopyOnWriteArrayList类的行为,让我们将其与java.util.ArrayList(不是List的线程安全实现)进行比较。以下是在迭代相同列表时向列表添加元素的方法:
        void demoListAdd(List<String> list) {
          System.out.println("list: " + list);
          try {
            for (String e : list) {
              System.out.println(e);
              if (!list.contains("Four")) {
                System.out.println("Calling list.add(Four)...");
                list.add("Four");
              }
            }
          } catch (Exception ex) {
            System.out.println(ex.getClass().getName());
          }
          System.out.println("list: " + list);
        }

考虑以下代码:

        System.out.println("***** ArrayList add():");
        demoListAdd(new ArrayList<>(Arrays
                          .asList("One", "Two", "Three")));

        System.out.println();
        System.out.println("***** CopyOnWriteArrayList add():");
        demoListAdd(new CopyOnWriteArrayList<>(Arrays.asList("One", 
                                         "Two", "Three")));

如果执行此代码,结果将如下所示:

正如您所看到的,当在迭代列表时修改列表时,ArrayList会抛出ConcurrentModificationException(我们为简单起见使用了相同的线程,因为它会导致与另一个线程修改列表的情况一样的效果)。尽管规范并不保证会抛出异常或应用列表修改(如我们的情况),因此程序员不应该基于这种行为来构建应用程序逻辑。

另一方面,CopyOnWriteArrayList类容忍相同的干预;但请注意,它不会将新元素添加到当前列表中,因为迭代器是从底层数组的新副本快照创建的。

现在让我们尝试使用这种方法在遍历列表时并发地删除列表元素:

       void demoListRemove(List<String> list) {
          System.out.println("list: " + list);
          try {
            for (String e : list) {
              System.out.println(e);
              if (list.contains("Two")) {
                System.out.println("Calling list.remove(Two)...");
                list.remove("Two");
              }
            }
          } catch (Exception ex) {
            System.out.println(ex.getClass().getName());
          }
          System.out.println("list: " + list);
       }

考虑以下代码:

        System.out.println("***** ArrayList remove():");
        demoListRemove(new ArrayList<>(Arrays.asList("One", 
                                         "Two", "Three")));
        System.out.println();
        System.out.println("***** CopyOnWriteArrayList remove():");
        demoListRemove(new CopyOnWriteArrayList<>(Arrays
                                .asList("One", "Two", "Three")));

如果我们执行这个,我们将得到以下结果:

行为与前面的例子类似。CopyOnWriteArrayList类允许并发访问列表,但不允许修改当前列表的副本。

我们很久以前就知道ArrayList不是线程安全的,因此我们使用了不同的技术来在遍历列表时删除列表中的元素。这是在 Java 8 发布之前完成的:

        void demoListIterRemove(List<String> list) {
          System.out.println("list: " + list);
          try {
            Iterator iter = list.iterator();
            while (iter.hasNext()) {
              String e = (String) iter.next();
              System.out.println(e);
              if ("Two".equals(e)) {
                System.out.println("Calling iter.remove()...");
                iter.remove();
              }
            }
          } catch (Exception ex) {
              System.out.println(ex.getClass().getName());
          }
          System.out.println("list: " + list);
        }

让我们尝试这样做并运行代码:

        System.out.println("***** ArrayList iter.remove():");
        demoListIterRemove(new ArrayList<>(Arrays
                            .asList("One", "Two", "Three")));
        System.out.println();
        System.out.println("*****" 
                    + " CopyOnWriteArrayList iter.remove():");
        demoListIterRemove(new CopyOnWriteArrayList<>(Arrays
                             .asList("One", "Two", "Three")));

结果如下:

这正是 Javadoc 所警告的(docs.oracle.com/cd/E17802_01/j2se/j2se/1.5.0/jcp/beta2/apidiffs/java/util/concurrent/CopyOnWriteArrayList.html):

"迭代器本身的元素更改操作(删除、设置和添加)不受支持。这些方法会抛出 UnsupportedOperationException 异常。"

当我们将应用程序升级以使其在多线程环境中工作时,我们应该记住这一点——如果我们使用迭代器来删除列表元素,仅仅从ArrayList()更改为CopyOnWriteArrayList是不够的。

自 Java 8 以来,有一种更好的方法可以使用 lambda 从集合中删除元素,应该使用它,因为它将管道细节留给库代码:

        void demoRemoveIf(Collection<String> collection) {
              System.out.println("collection: " + collection);
              System.out.println("Calling list.removeIf(e ->" 
                                      + " Two.equals(e))...");
              collection.removeIf(e -> "Two".equals(e));
              System.out.println("collection: " + collection);
        }

所以让我们这样做:

        System.out.println("***** ArrayList list.removeIf():");
        demoRemoveIf(new ArrayList<>(Arrays
                              .asList("One", "Two", "Three")));
        System.out.println();
        System.out.println("*****" 
                   + " CopyOnWriteArrayList list.removeIf():");
        demoRemoveIf(new CopyOnWriteArrayList<>(Arrays
                              .asList("One", "Two", "Three")));

上述代码的结果如下:

它很简短,并且不会出现任何与集合相关的问题,符合使用流、lambda 和函数接口进行无状态并行计算的一般趋势。

此外,当我们将应用程序升级为使用CopyOnWriteArrayList类后,我们可以利用一种更简单的方法向列表中添加新元素(无需首先检查它是否已存在):

CopyOnWriteArrayList<String> list =  
  new CopyOnWriteArrayList<>(Arrays.asList("Five","Six","Seven"));
list.addIfAbsent("One");

使用CopyOnWriteArrayList,这可以作为原子操作完成,因此不需要同步如果-不存在-则添加代码块。

  1. 让我们回顾一下实现Set接口的java.util.concurrent包的并发集合。有三种这样的实现——ConcurrentHashMap.KeySetViewCopyOnWriteArraySetConcurrentSkipListSet

第一个是ConcurrentHashMap键的视图。它由ConcurrentHashMap支持(可以通过getMap()方法检索)。我们稍后将审查ConcurrentHashMap的行为。

java.util.concurrent包中Set的第二个实现是CopyOnWriteArraySet类。其行为类似于CopyOnWriteArrayList类。实际上,它在底层使用了CopyOnWriteArrayList类的实现。唯一的区别是它不允许集合中有重复元素。

java.util.concurrent包中的第三(也是最后一个)Set实现是ConcurrentSkipListSet;它实现了Set的一个子接口NavigableSet。根据ConcurrentSkipListSet类的 Javadoc,插入、移除和访问操作可以由多个线程安全并发执行。Javadoc 中也描述了一些限制:

    • 它不允许使用null元素。
  • 集合的大小是通过遍历集合动态计算的,因此如果在操作期间修改了此集合,它可能报告不准确的结果。

  • addAll()removeIf()forEach()操作不能保证原子执行。例如,如果forEach()操作与addAll()操作并发进行,可能会“观察到只有一些添加的元素”。

ConcurrentSkipListSet类的实现基于ConcurrentSkipListMap类,我们将很快讨论。为了演示ConcurrentSkipListSet类的行为,让我们将其与java.util.TreeSet类(NavigableSet的非并发实现)进行比较。我们首先移除一个元素:

        void demoNavigableSetRemove(NavigableSet<Integer> set) {
          System.out.println("set: " + set);
          try {
            for (int i : set) {
              System.out.println(i);
              System.out.println("Calling set.remove(2)...");
              set.remove(2);
            }
          } catch (Exception ex) {
            System.out.println(ex.getClass().getName());
          }
          System.out.println("set: " + set);
        }

当然,这段代码并不是很高效;我们多次移除了相同的元素而没有检查其是否存在。我们这样做只是为了演示目的。此外,自 Java 8 以来,相同的removeIf()方法对Set也可以正常工作。但我们想提出新的ConcurrentSkipListSet类的行为,所以让我们执行这段代码:

        System.out.println("***** TreeSet set.remove(2):");
        demoNavigableSetRemove(new TreeSet<>(Arrays
                                     .asList(0, 1, 2, 3)));
        System.out.println();
        System.out.println("*****"
                    + " ConcurrentSkipListSet set.remove(2):");
        demoNavigableSetRemove(new ConcurrentSkipListSet<>(Arrays
                                     .asList(0, 1, 2, 3)));

输出将如下所示:

正如预期的那样,ConcurrentSkipListSet类处理并发性,甚至从当前集合中移除一个元素,这是有帮助的。它还通过迭代器移除元素而不会抛出异常。考虑以下代码:

        void demoNavigableSetIterRemove(NavigableSet<Integer> set){
          System.out.println("set: " + set);
          try {
            Iterator iter = set.iterator();
            while (iter.hasNext()) {
              Integer e = (Integer) iter.next();
              System.out.println(e);
              if (e == 2) {
                System.out.println("Calling iter.remove()...");
                iter.remove();
              }
            }
          } catch (Exception ex) {
            System.out.println(ex.getClass().getName());
          }
          System.out.println("set: " + set);
        }

TreeSetConcurrentSkipListSet运行此操作:

        System.out.println("***** TreeSet iter.remove():");
        demoNavigableSetIterRemove(new TreeSet<>(Arrays
                                           .asList(0, 1, 2, 3)));

        System.out.println();
        System.out.println("*****"
                      + " ConcurrentSkipListSet iter.remove():");
        demoNavigableSetIterRemove(new ConcurrentSkipListSet<>
                                    (Arrays.asList(0, 1, 2, 3)));

我们不会得到任何异常:

这是因为根据 Javadoc,ConcurrentSkipListSet的迭代器是弱一致的,这意味着以下内容:

    • 它们可以与其他操作同时进行
  • 它们永远不会抛出ConcurrentModificationException

  • 它们保证遍历元素时仅在构造时存在一次,并且可能(但不保证)反映构造后的任何修改(来自 Javadoc)

这个“不保证”的部分有些令人失望,但比起CopyOnWriteArrayList抛出异常要好。

Set类添加不像向List类那样有问题,因为Set不允许重复,并且会在内部处理必要的检查:

        void demoNavigableSetAdd(NavigableSet<Integer> set) {
          System.out.println("set: " + set);
          try {
            int m = set.stream().max(Comparator.naturalOrder())
                                .get() + 1;
            for (int i : set) {
              System.out.println(i);
              System.out.println("Calling set.add(" + m + ")");
              set.add(m++);
              if (m > 6) {
                break;
              }
            }
          } catch (Exception ex) {
            System.out.println(ex.getClass().getName());
          }
          System.out.println("set: " + set);
        }

考虑以下代码:

        System.out.println("***** TreeSet set.add():");
        demoNavigableSetAdd(new TreeSet<>(Arrays
                                     .asList(0, 1, 2, 3)));

        System.out.println();
        System.out.println("*****" 
                            + " ConcurrentSkipListSet set.add():");
        demoNavigableSetAdd(new ConcurrentSkipListSet<>(Arrays
                                        .asList(0,1,2,3)));

如果我们运行这个,我们将得到以下结果:

与之前一样,我们观察到并发Set版本处理并发性更好。

  1. 让我们转向Map接口,在java.util.concurrent包中有两个实现:ConcurrentHashMapConcurrentSkipListMap

来自ConcurrentHashMap类的 Javadoc。

“支持检索的完全并发性和更新的高并发性”

它是java.util.HashMap的线程安全版本,并且在这方面类似于java.util.Hashtable。实际上,ConcurrentHashMap类满足与java.util.Hashtable相同的功能规范要求,尽管其实现在“同步细节上有些不同”(来自 Javadoc)。

java.util.HashMapjava.util.Hashtable不同,ConcurrentHashMap支持,根据其 Javadoc(docs.oracle.com/javase/9/docs/api/java/util/concurrent/ConcurrentHashMap.html),

“一组顺序和并行的大容量操作,与大多数 Stream 方法不同,它们被设计为安全地,通常是明智地,即使在被其他线程并发更新的映射中也可以应用”

    • forEach(): 这对每个元素执行给定的操作
  • search(): 这返回将给定函数应用于每个元素的第一个可用的非空结果

  • reduce(): 这累积每个元素(有五个重载版本)

这些大容量操作接受一个parallelismThreshold参数,允许推迟并行化,直到映射大小达到指定的阈值。当阈值设置为Long.MAX_VALUE时,将根本没有并行性。

类 API 中还有许多其他方法,因此请参考其 Javadoc 以获取概述。

java.util.HashMap不同(类似于java.util.Hashtable),ConcurrentHashMapConcurrentSkipListMap都不允许将 null 用作键或值。

第二个Map的实现——ConcurrentSkipListSet类——基于我们之前提到的ConcurrentSkipListMap类,因此我们刚刚描述的ConcurrentSkipListSet类的所有限制也适用于ConcurrentSkipListMap类。ConcurrentSkipListSet类实际上是java.util.TreeMap的线程安全版本。SkipList是一种排序的数据结构,允许并发快速搜索。所有元素都根据它们的键的自然排序顺序进行排序。我们为ConcurrentSkipListSet类演示的NavigableSet功能在ConcurrentSkipListMap类中也存在。有关类 API 中的许多其他方法,请参考其 Javadoc。

现在让我们演示java.util.HashMapConcurrentHashMapConcurrentSkipListMap类在响应并发性方面的行为差异。首先,我们将编写生成测试Map对象的方法:

        Map createhMap() {
          Map<Integer, String> map = new HashMap<>();
          map.put(0, "Zero");
          map.put(1, "One");
          map.put(2, "Two");
          map.put(3, "Three");
          return map;
       }

以下是将元素添加到Map对象的代码:

        void demoMapPut(Map<Integer, String> map) {
          System.out.println("map: " + map);
          try {
            Set<Integer> keys = map.keySet();
            for (int i : keys) {
              System.out.println(i);
              System.out.println("Calling map.put(8, Eight)...");
              map.put(8, "Eight");

              System.out.println("map: " + map);
              System.out.println("Calling map.put(8, Eight)...");
              map.put(8, "Eight");

              System.out.println("map: " + map);
              System.out.println("Calling" 
                                 + " map.putIfAbsent(9, Nine)...");
              map.putIfAbsent(9, "Nine");

              System.out.println("map: " + map);
              System.out.println("Calling" 
                                 + " map.putIfAbsent(9, Nine)...");
              map.putIfAbsent(9, "Nine");

              System.out.println("keys.size(): " + keys.size());
              System.out.println("map: " + map);
            }
          } catch (Exception ex) {
            System.out.println(ex.getClass().getName());
          }
        }

Map的所有三个实现运行此操作:

        System.out.println("***** HashMap map.put():");
        demoMapPut(createhMap());

        System.out.println();
        System.out.println("***** ConcurrentHashMap map.put():");
        demoMapPut(new ConcurrentHashMap(createhMap()));

        System.out.println();
        System.out.println("*****"
                          + " ConcurrentSkipListMap map.put():");
        demoMapPut(new ConcurrentSkipListMap(createhMap()));

如果我们这样做,我们将只为第一个键的HashMap获得输出:

我们还为所有键(包括新添加的键)的ConcurrentHashMapConcurrentSkipListMap获得输出。以下是ConcurrentHashMap输出的最后一部分:

如前所述,不能保证会出现ConcurrentModificationException。现在我们看到,它被抛出的时刻(如果被抛出)是代码发现修改已经发生的时刻。在我们的例子中,它发生在下一个迭代中。另一个值得注意的是,即使我们将集合隔离在一个单独的变量中,当前的键集合也会发生变化:

      Set<Integer> keys = map.keySet();

这提醒我们不要忽视通过它们的引用传播的对象的更改。

为了节省空间和时间,我们将不展示并发删除的代码,只总结结果。如预期的那样,当以任何以下方式之一删除元素时,HashMap会抛出ConcurrentModificationException异常:

        String result = map.remove(2);
        boolean success = map.remove(2, "Two");

可以使用Iterator以以下一种方式之一进行并发删除:

         iter.remove();
         boolean result = map.keySet().remove(2);
         boolean result = map.keySet().removeIf(e -> e == 2);

相比之下,这两个并发Map实现不仅允许使用Iterator进行并发元素删除。

Queue接口的所有并发实现也表现出类似的行为:LinkedTransferQueueLinkedBlockingQueueLinkedBlockingDequeueArrayBlockingQueuePriorityBlockingQueueDelayQueueSynchronousQueueConcurrentLinkedQueueConcurrentLinkedDequeue,都在java.util.concurrent包中。但要演示它们所有将需要一个单独的卷,因此我们将其留给您浏览 Javadoc 并提供ArrayBlockingQueue的示例。队列将由QueueElement类表示:

         class QueueElement {
           private String value;
           public QueueElement(String value){
             this.value = value;
           }
           public String getValue() {
             return value;
           }
         }

队列生产者将如下所示:

        class QueueProducer implements Runnable {
          int intervalMs, consumersCount;
          private BlockingQueue<QueueElement> queue;
          public QueueProducer(int intervalMs, int consumersCount, 
                               BlockingQueue<QueueElement> queue) {
            this.consumersCount = consumersCount;
            this.intervalMs = intervalMs;
            this.queue = queue;
          }
          public void run() {
            List<String> list = 
               List.of("One","Two","Three","Four","Five");
            try {
              for (String e : list) {
                Thread.sleep(intervalMs);
                queue.put(new QueueElement(e));
                System.out.println(e + " produced" );
              }
              for(int i = 0; i < consumersCount; i++){
                queue.put(new QueueElement("Stop"));
              }
            } catch (InterruptedException e) {
              e.printStackTrace();
            }
           }
         }

以下将是队列消费者:

        class QueueConsumer implements Runnable{
          private String name;
          private int intervalMs;
          private BlockingQueue<QueueElement> queue;
          public QueueConsumer(String name, int intervalMs, 
                               BlockingQueue<QueueElement> queue){
             this.intervalMs = intervalMs;
             this.queue = queue;
             this.name = name;
          }
          public void run() {
            try {
              while(true){
                String value = queue.take().getValue();
                if("Stop".equals(value)){
                  break;
                }
                System.out.println(value + " consumed by " + name);
                Thread.sleep(intervalMs);
              }
            } catch(InterruptedException e) {
              e.printStackTrace();
            }
          }
        }

运行以下代码:

        BlockingQueue<QueueElement> queue = 
                      new ArrayBlockingQueue<>(5);
        QueueProducer producer = new QueueProducer(queue);
        QueueConsumer consumer = new QueueConsumer(queue);
        new Thread(producer).start();
        new Thread(consumer).start();

它的结果可能如下所示:

它是如何工作的...

在选择要使用的集合之前,请阅读 Javadoc,看看集合的限制是否适合您的应用程序。

例如,根据 Javadoc,CopyOnWriteArrayList

“通常成本太高,但当遍历操作远远超过变异操作时可能更有效,并且在您不能或不想同步遍历但需要阻止并发线程之间干扰时非常有用。”

当您不需要在不同位置添加新元素并且不需要排序时,请使用它。否则,请使用ConcurrentSkipListSet

根据 Javadoc,ConcurrentSkipListSetConcurrentSkipListMap

“为包含、添加和删除操作及其变体提供预期的平均 log(n)时间成本。升序排序的视图及其迭代器比降序排序的视图及其迭代器更快。”

当您需要按特定顺序快速迭代元素时,请使用它们。

当并发要求非常苛刻并且需要在写操作上允许锁定但不需要锁定元素时,请使用ConcurrentHashMap

当许多线程共享对公共集合的访问时,ConcurrentLinkedQuequeConcurrentLinkedDeque是一个合适的选择。ConcurrentLinkedQueque采用了高效的非阻塞算法。

PriorityBlockingQueue是一个更好的选择,当自然顺序是可以接受的,并且您需要快速向队列尾部添加元素和快速从队列头部删除元素时。阻塞意味着队列在检索元素时等待变得非空,并在存储元素时等待队列中有空间可用。

ArrayBlockingQueueLinkedBlockingQueueLinkedBlockingDeque具有固定大小(它们是有界的)。其他队列是无界的。

将这些类似特性和建议作为指南,但在实现功能之前进行全面测试和性能测量。

使用执行器服务执行异步任务

在本示例中,您将学习如何使用ExecutorService来实现可控的线程执行。

准备就绪

在早期的示例中,我们演示了如何直接使用Thread类创建和执行线程。这是一个适用于少量线程的可接受机制,可以快速运行并产生可预测的结果。对于长时间运行且具有复杂逻辑的大规模应用程序(可能会使它们长时间保持活动状态)和/或线程数量也在不可预测增长的情况下,简单的创建和运行直到退出的方法可能会导致OutOfMemory错误,或者需要复杂的自定义线程状态维护和管理系统。对于这种情况,ExecutorServicejava.util.concurrent包的相关类提供了一个开箱即用的解决方案,解除了程序员编写和维护大量基础设施代码的需要。

在 Executor Framework 的基础上,有一个只有一个void execute(Runnable command)方法的Executor接口,它在将来的某个时间执行给定的命令。

它的子接口ExecutorService添加了一些允许您管理执行器的方法:

  • invokeAny()invokeAll()awaitTermination()方法以及submit()允许您定义线程将如何执行以及它们是否预期返回一些值

  • shutdown()shutdownNow()方法允许您关闭执行器

  • isShutdown()isTerminated()方法提供了执行器的状态

ExecutorService的对象可以使用java.util.concurrent.Executors类的静态工厂方法创建:

  • newSingleThreadExecutor(): 创建一个使用单个工作线程并在无界队列上运行的Executor方法。它有一个带有ThreadFactory作为参数的重载版本。

  • newCachedThreadPool(): 创建一个线程池,根据需要创建新线程,但在可用时重用先前构造的线程。它有一个带有ThreadFactory作为参数的重载版本。

  • newFixedThreadPool(int nThreads): 创建一个线程池,该线程池重用固定数量的线程,这些线程在共享的无界队列上运行。它有一个带有ThreadFactory作为参数的重载版本。

ThreadFactory实现允许您重写创建新线程的过程,使应用程序能够使用特殊的线程子类、优先级等。其使用示例超出了本书的范围。

如何做到这一点...

  1. 您需要记住的Executor接口行为的一个重要方面是,一旦创建,它会一直运行(等待执行新任务),直到 Java 进程停止。因此,如果要释放内存,必须显式停止Executor接口。如果不关闭,被遗忘的执行程序将导致内存泄漏。以下是确保没有执行程序被遗留下来的一种可能方法:
        int shutdownDelaySec = 1;
        ExecutorService execService = 
                       Executors.newSingleThreadExecutor();
        Runnable runnable =  () -> System.out.println("Worker One did
                                                       the job.");
        execService.execute(runnable);
        runnable =   () -> System.out.println("Worker Two did the 
                                               job.");
        Future future = execService.submit(runnable);
        try {
          execService.shutdown();
          execService.awaitTermination(shutdownDelaySec, 
                                       TimeUnit.SECONDS);
        } catch (Exception ex) {
          System.out.println("Caught around" 
                  + " execService.awaitTermination(): " 
                  + ex.getClass().getName());
        } finally {
          if (!execService.isTerminated()) {
            if (future != null && !future.isDone() 
                               && !future.isCancelled()){
              System.out.println("Cancelling the task...");
              future.cancel(true);
            }
          }
          List<Runnable> l = execService.shutdownNow();
          System.out.println(l.size() 
                 + " tasks were waiting to be executed." 
                 + " Service stopped.");
        }

您可以以各种方式将工作程序(RunnableCallable功能接口的实现)传递给ExecutorService进行执行,我们将很快看到。在本例中,我们执行了两个线程:一个使用execute()方法,另一个使用submit()方法。这两种方法都接受RunnableCallable,但在本例中我们只使用了Runnablesubmit()方法返回Future,它表示异步计算的结果。

shutdown()方法启动先前提交的任务的有序关闭,并阻止接受任何新任务。此方法不等待任务完成执行。awaitTermination()方法会等待。但是在shutdownDelaySec之后,它停止阻塞,代码流进入finally块,在该块中,如果所有任务在关闭后都已完成,则isTerminated()方法返回 true。在本例中,我们在两个不同的语句中执行了两个任务。但请注意,ExecutorService的其他方法接受任务集合。

在这种情况下,当服务关闭时,我们遍历Future对象的集合。我们调用每个任务,如果任务尚未完成,则取消它,可能在取消任务之前执行其他必须完成的任务。等待多长时间(shutdownDelaySec的值)必须针对每个应用程序和可能正在运行的任务进行测试。

最后,shutdownNow()方法表示

“尝试停止所有正在执行的任务,停止等待任务的处理,并返回等待执行的任务列表”

(根据 Javadoc)。

  1. 收集和评估结果。在实际应用中,我们通常不希望经常关闭服务。我们只检查任务的状态,并收集那些从isDone()方法返回 true 的任务的结果。在前面的代码示例中,我们只是展示了如何确保当我们停止服务时,我们以一种受控的方式进行,而不会留下任何失控的进程。如果运行该代码示例,我们将得到以下结果:

  1. 概括前面的代码并创建一个关闭服务和返回Future的任务的方法:
        void shutdownAndCancelTask(ExecutorService execService, 
                  int shutdownDelaySec, String name, Future future) {
          try {
            execService.shutdown();
            System.out.println("Waiting for " + shutdownDelaySec 
                         + " sec before shutting down service...");
            execService.awaitTermination(shutdownDelaySec,
                                         TimeUnit.SECONDS);
          } catch (Exception ex) {
            System.out.println("Caught around" 
                        + " execService.awaitTermination():" 
                        + ex.getClass().getName());
         } finally {
           if (!execService.isTerminated()) {
             System.out.println("Terminating remaining tasks...");
             if (future != null && !future.isDone() 
                                && !future.isCancelled()) {
               System.out.println("Cancelling task " 
                                  + name + "...");
               future.cancel(true);
             }
           }
           System.out.println("Calling execService.shutdownNow(" 
                              + name + ")...");
           List<Runnable> l = execService.shutdownNow();
           System.out.println(l.size() + " tasks were waiting" 
                         + " to be executed. Service stopped.");
         }
       }
  1. 通过使用 lambda 表达式使Runnable休眠一段时间(模拟需要完成的有用工作)来增强示例:
        void executeAndSubmit(ExecutorService execService, 
                    int shutdownDelaySec, int threadSleepsSec) {
          System.out.println("shutdownDelaySec = " 
                          + shutdownDelaySec + ", threadSleepsSec = " 
                          + threadSleepsSec);
          Runnable runnable = () -> {
            try {
              Thread.sleep(threadSleepsSec * 1000);
              System.out.println("Worker One did the job.");
            } catch (Exception ex) {
              System.out.println("Caught around One Thread.sleep(): " 
                                 + ex.getClass().getName());
            }
          };
          execService.execute(runnable);
          runnable = () -> {
            try {
              Thread.sleep(threadSleepsSec * 1000);
              System.out.println("Worker Two did the job.");
            } catch (Exception ex) {
              System.out.println("Caught around Two Thread.sleep(): " 
                                 + ex.getClass().getName());
            }
          };
          Future future = execService.submit(runnable);
          shutdownAndCancelTask(execService, shutdownDelaySec, 
                                "Two", future);
        }

注意两个参数,shutdownDelaySec(定义服务在继续关闭自身之前等待多长时间,不允许提交新任务)和threadSleepSec(定义工作者睡眠的时间,表示模拟过程正在工作)。

  1. 运行不同的ExecutorService实现和shutdownDelaySecthreadSleepSec值的新代码:
        System.out.println("Executors.newSingleThreadExecutor():");
        ExecutorService execService = 
                       Executors.newSingleThreadExecutor();
        executeAndSubmit(execService, 3, 1);

        System.out.println();
        System.out.println("Executors.newCachedThreadPool():");
        execService = Executors.newCachedThreadPool();
        executeAndSubmit(execService, 3, 1);

        System.out.println();
        int poolSize = 3;
        System.out.println("Executors.newFixedThreadPool(" 
                                            + poolSize + "):");
        execService = Executors.newFixedThreadPool(poolSize);
        executeAndSubmit(execService, 3, 1);

这是输出的样子(在你的电脑上可能略有不同,取决于操作系统控制的事件的确切时间):

  1. 分析结果。在第一个例子中,我们没有惊喜,因为以下一行:
        execService.awaitTermination(shutdownDelaySec, 
                                     TimeUnit.SECONDS);

它会阻塞三秒,而每个工作者只工作一秒。所以即使是单线程执行器,每个工作者都有足够的时间完成工作。

让服务只等待一秒:

当你这样做时,你会注意到没有一个任务会被完成。在这种情况下,工作者One被中断了(参见输出的最后一行),而任务Two被取消了。

让服务等待三秒:

现在我们看到工作者One能够完成它的任务,而工作者Two被中断了。

newCachedThreadPool()newFixedThreadPool()产生的ExecutorService接口在单核计算机上表现类似。唯一的显著区别是,如果shutdownDelaySec的值等于threadSleepSec的值,那么它们都允许你完成线程:

这是使用newCachedThreadPool()的结果。使用newFixedThreadPool()的例子在单核计算机上看起来完全一样。

  1. 为了更好地控制任务,检查Future对象的返回值,而不仅仅是提交一个任务并希望它按需要完成。ExecutorService接口中还有一个名为submit()的方法,允许你不仅返回一个Future对象,还可以将结果作为第二个参数传递给该方法并包含在返回对象中。让我们看一个例子:
        Future<Integer> future = execService.submit(() -> 
               System.out.println("Worker 42 did the job."), 42);
        int result = future.get();

result的值是42。当你提交了很多工作者(nWorkers)并且需要知道哪一个已经完成时,这个方法会很有帮助:

        Set<Integer> set = new HashSet<>();
        while (set.size() < nWorkers){
          for (Future<Integer> future : futures) {
            if (future.isDone()){
              try {
                String id = future.get(1, TimeUnit.SECONDS);
                if(!set.contains(id)){
                  System.out.println("Task " + id + " is done.");
                  set.add(id);
                }
              } catch (Exception ex) {
                System.out.println("Caught around future.get(): "
                                   + ex.getClass().getName());
              }
            }
          }
        }

好吧,问题在于future.get()是一个阻塞方法。这就是为什么我们使用get()方法的一个版本,允许我们设置delaySec超时。否则,get()会阻塞迭代。

它是如何工作的...

让我们更接近实际代码,创建一个实现Callable并允许你将工作者的结果作为Result类对象返回的类:

class Result {
  private int sleepSec, result;
  private String workerName;
  public Result(String workerName, int sleptSec, int result) {
    this.workerName = workerName;
    this.sleepSec = sleptSec;
    this.result = result;
  }
  public String getWorkerName() { return this.workerName; }
  public int getSleepSec() { return this.sleepSec; }
  public int getResult() { return this.result; }
}

实际的数值结果是由getResult()方法返回的。在这里,我们还包括了工作者的名字以及线程预计睡眠的时间(为了方便和更好地说明输出)。

工作者本身将是CallableWorkerImpl类的一个实例:

class CallableWorkerImpl implements CallableWorker<Result>{
  private int sleepSec;
  private String name;
  public CallableWorkerImpl(String name, int sleepSec) {
    this.name = name;
    this.sleepSec = sleepSec;
  }
  public String getName() { return this.name; }
  public int getSleepSec() { return this.sleepSec; }
  public Result call() {
    try {
      Thread.sleep(sleepSec * 1000);
    } catch (Exception ex) {
      System.out.println("Caught in CallableWorker: " 
                         + ex.getClass().getName());
    }
    return new Result(name, sleepSec, 42);
  }
}

在这里,数字42是一个实际的数值结果,一个工作者在睡觉的时候计算出来的。CallableWorkerImpl类实现了CallableWorker接口:

interface CallableWorker<Result> extends Callable<Result> {
  default String getName() { return "Anonymous"; }
  default int getSleepSec() { return 1; }
}

我们必须将方法设置为默认的并返回一些数据(它们无论如何都会被类实现覆盖)以保持其functional interface状态。否则,我们将无法在 lambda 表达式中使用它。

我们还将创建一个工厂,用于生成工作者列表:

List<CallableWorker<Result>> createListOfCallables(int nSec){
  return List.of(new CallableWorkerImpl("One", nSec),
                 new CallableWorkerImpl("Two", 2 * nSec),
                 new CallableWorkerImpl("Three", 3 * nSec));
}

现在我们可以使用所有这些新的类和方法来演示invokeAll()方法:

void invokeAllCallables(ExecutorService execService, 
        int shutdownDelaySec, List<CallableWorker<Result>> callables) {
  List<Future<Result>> futures = new ArrayList<>();
  try {
    futures = execService.invokeAll(callables, shutdownDelaySec, 
                                    TimeUnit.SECONDS);
  } catch (Exception ex) {
    System.out.println("Caught around execService.invokeAll(): " 
                       + ex.getClass().getName());
  }
  try {
    execService.shutdown();
    System.out.println("Waiting for " + shutdownDelaySec 
                       + " sec before terminating all tasks...");
    execService.awaitTermination(shutdownDelaySec,
                                 TimeUnit.SECONDS);
  } catch (Exception ex) {
    System.out.println("Caught around awaitTermination(): " 
                       + ex.getClass().getName());
  } finally {
    if (!execService.isTerminated()) {
      System.out.println("Terminating remaining tasks...");
      for (Future<Result> future : futures) {
        if (!future.isDone() && !future.isCancelled()) {
          try {
            System.out.println("Cancelling task "
                       + future.get(shutdownDelaySec, 
                               TimeUnit.SECONDS).getWorkerName());
            future.cancel(true);
          } catch (Exception ex) {
            System.out.println("Caught at cancelling task: " 
                               + ex.getClass().getName());
          }
        }
      }
    }
    System.out.println("Calling execService.shutdownNow()...");
    execService.shutdownNow();
  }
  printResults(futures, shutdownDelaySec);
}

printResults()方法输出从工作者那里收到的结果:

void printResults(List<Future<Result>> futures, int timeoutSec) {
  System.out.println("Results from futures:");
  if (futures == null || futures.size() == 0) {
    System.out.println("No results. Futures" 
                       + (futures == null ? " = null" : ".size()=0"));
  } else {
    for (Future<Result> future : futures) {
      try {
        if (future.isCancelled()) {
          System.out.println("Worker is cancelled.");
        } else {
          Result result = future.get(timeoutSec, TimeUnit.SECONDS);
          System.out.println("Worker "+ result.getWorkerName() + 
                             " slept " + result.getSleepSec() + 
                             " sec. Result = " + result.getResult());
        }
      } catch (Exception ex) {
        System.out.println("Caught while getting result: " 
                           + ex.getClass().getName());
      }
    }
  }
}

为了获得结果,我们再次使用了带有超时设置的get()方法的一个版本。运行以下代码:

List<CallableWorker<Result>> callables = createListOfCallables(1);
System.out.println("Executors.newSingleThreadExecutor():");
ExecutorService execService = Executors.newSingleThreadExecutor();
invokeAllCallables(execService, 1, callables);

它的输出将如下:

值得一提的是,三个工作线程的睡眠时间分别为一秒、两秒和三秒,而服务关闭前的等待时间为一秒。这就是为什么所有工作线程都被取消的原因。

现在,如果我们将等待时间设置为六秒,单线程执行程序的输出将如下所示:

当然,如果我们再次增加等待时间,所有工作线程将能够完成它们的任务。

newCachedThreadPool()newFixedThreadPool()产生的ExecutorService接口在单核计算机上表现得更好:

正如您所看到的,即使等待时间为三秒,所有线程也能够完成。

作为替代方案,您可以在服务关闭期间设置超时,也可以在invokeAll()方法的重载版本上设置超时:

List<Future<T>> invokeAll(Collection<? extends Callable<T>> tasks,
                          long timeout, TimeUnit unit)

invokeAll()方法的一个特定方面经常被忽视,这会给初次使用者带来惊喜:它只有在所有任务完成(正常或通过抛出异常)后才返回。阅读 Javadoc 并进行实验,直到您认识到这种行为对您的应用程序是可以接受的。

相比之下,invokeAny()方法只会阻塞,直到至少有一个任务完成

“成功完成(没有抛出异常),如果有的话。在正常或异常返回时,未完成的任务将被取消”

上述引用来自 Javadoc(docs.oracle.com/javase/7/docs/api/java/util/concurrent/ExecutorService.html)。以下是执行此操作的代码示例:

void invokeAnyCallables(ExecutorService execService, 
        int shutdownDelaySec, List<CallableWorker<Result>> callables) {
  Result result = null;
  try {
    result = execService.invokeAny(callables, shutdownDelaySec,                                    TimeUnit.SECONDS);
  } catch (Exception ex) {
    System.out.println("Caught around execService.invokeAny(): " 
                       + ex.getClass().getName());
  }
  shutdownAndCancelTasks(execService, shutdownDelaySec,
                         new ArrayList<>());
  if (result == null) {
    System.out.println("No result from execService.invokeAny()");
  } else {
    System.out.println("Worker " + result.getWorkerName() + 
                       " slept " + result.getSleepSec() + 
                       " sec. Result = " + result.getResult());
  }
}

您可以尝试设置等待时间(shutdownDelaySec)和线程的睡眠时间的不同值,直到您对此方法的行为感到满意为止。正如您所看到的,我们通过传递一个空的Future对象列表来重用了shutdownAndCancelTasks()方法,因为我们这里没有这些对象。

还有更多...

Executors类中还有两个静态工厂方法,用于创建ExecutorService的实例:

  • newWorkStealingPool(): 使用可用处理器的数量作为目标并行级别创建工作窃取线程池。它有一个带有并行级别参数的重载版本。

  • unconfigurableExecutorService(ExecutorService executor): 返回一个对象,该对象将所有已定义的ExecutorService方法委托给给定的执行程序,除了可能使用转换访问的那些方法。

此外,ExecutorService接口的子接口ScheduledExecutorService通过增强 API 的能力来在将来调度线程执行和/或它们的周期性执行。

ScheduledExecutorService的对象可以使用java.util.concurrent.Executors类的静态工厂方法来创建:

  • newSingleThreadScheduledExecutor(): 创建一个可以在给定延迟后调度命令运行或定期执行命令的单线程执行程序。它有一个带有ThreadFactory参数的重载版本。

  • newScheduledThreadPool(int corePoolSize): 创建一个可以在给定延迟后调度命令运行或定期执行命令的线程池。它有一个带有ThreadFactory参数的重载版本。

  • unconfigurableScheduledExecutorService( ScheduledExecutorService executor ): 返回一个对象,该对象将所有已定义的ScheduledExecutorService方法委托给给定的执行程序,但不包括可能使用转换访问的其他方法。

Executors类还有几个重载方法,接受、执行和返回Callable(与Runnable相反,它包含结果)。

java.util.concurrent包还包括实现ExecutorService的类:

  • ThreadPoolExecutor:这个类使用几个池化线程中的一个来执行每个提交的任务,通常使用Executors工厂方法进行配置。

  • ScheduledThreadPoolExecutor:这个类扩展了ThreadPoolExecutor类,并实现了ScheduledExecutorService接口。

  • ForkJoinPool:它使用工作窃取算法管理工作者(ForkJoinTask进程)的执行。我们将在下一个示例中讨论它。

这些类的实例可以通过接受更多参数的类构造函数创建,包括保存结果的队列,以提供更精细的线程池管理。

使用 fork/join 实现分而治之

在这个示例中,您将学习如何使用 fork/join 框架来进行分而治之的计算模式。

准备工作

如前一示例中所述,ForkJoinPool类是ExecutorService接口的实现,使用工作窃取算法管理工作者(ForkJoinTask进程)的执行。如果有多个处理器可用,它会充分利用,并且最适合可以递归地分解为更小任务的任务,这也被称为分而治之策略。

池中的每个线程都有一个专用的双端队列(deque)来存储任务,线程在当前任务完成后立即从队列头部获取下一个任务。当另一个线程执行完其队列中的所有任务时,它可以从另一个线程的非空队列尾部获取任务(窃取)。

与任何ExecutorService实现一样,fork/join 框架将任务分配给线程池中的工作者线程。这个框架是独特的,因为它使用工作窃取算法。运行完任务的工作者线程可以从仍在忙碌的其他线程中窃取任务。

这样的设计可以平衡负载,并有效利用资源。

为了演示目的,我们将使用第三章中创建的 API,模块化编程TrafficUnitSpeedModelVehicle接口以及TrafficUnitWrapperFactoryTrafficFactoryVehicleFactorySpeedModel类。我们还将依赖于第三章中描述的流和流管道,模块化编程

为了提醒您,这是TrafficUnitWrapper类:

class TrafficUnitWrapper {
  private double speed;
  private Vehicle vehicle;
  private TrafficUnit trafficUnit;
  public TrafficUnitWrapper(TrafficUnit trafficUnit){
    this.trafficUnit = trafficUnit;
    this.vehicle = FactoryVehicle.build(trafficUnit);
  }
  public TrafficUnitWrapper setSpeedModel(SpeedModel speedModel) {
    this.vehicle.setSpeedModel(speedModel);
    return this;
  }
  TrafficUnit getTrafficUnit(){ return this.trafficUnit;}
  public double getSpeed() { return speed; }

  public TrafficUnitWrapper calcSpeed(double timeSec) {
    double speed = this.vehicle.getSpeedMph(timeSec);
    this.speed = Math.round(speed * this.trafficUnit.getTraction());
    return this;
  }
}

我们还将稍微修改现有的 API 接口,并通过引入一个新的DateLocation类使其更加紧凑:

class DateLocation {
  private int hour;
  private Month month;
  private DayOfWeek dayOfWeek;
  private String country, city, trafficLight;

  public DateLocation(Month month, DayOfWeek dayOfWeek, 
                      int hour, String country, String city, 
                      String trafficLight) {
    this.hour = hour;
    this.month = month;
    this.dayOfWeek = dayOfWeek;
    this.country = country;
    this.city = city;
    this.trafficLight = trafficLight;
  }
  public int getHour() { return hour; }
  public Month getMonth() { return month; }
  public DayOfWeek getDayOfWeek() { return dayOfWeek; }
  public String getCountry() { return country; }
  public String getCity() { return city; }
  public String getTrafficLight() { return trafficLight;}
}

它还将帮助您隐藏细节,并帮助您看到这个示例的重要方面。

如何做到...

所有计算都封装在ForkJoinTask类的两个子类(RecursiveActionRecursiveTask<T>)的子类中。您可以扩展RecursiveAction(并实现void compute()方法)或RecursiveTask<T>(并实现T compute()方法)。正如您可能已经注意到的,您可以选择扩展RecursiveAction类以处理不返回任何值的任务,并在需要任务返回值时扩展RecursiveTask<T>。在我们的示例中,我们将使用后者,因为它稍微复杂一些。

假设我们想要计算特定位置在特定日期和时间以及驾驶条件下的交通平均速度(所有这些参数由DateLocation属性对象定义)。其他参数如下:

  • timeSec:车辆在交通灯停止后加速的秒数

  • trafficUnitsNumber:包括在平均速度计算中的车辆数量

自然地,包括在计算中的车辆数量越多,预测就越准确。但随着这个数字的增加,计算的数量也会增加。这就需要将车辆数量分成更小的组,并与其他组并行计算每组的平均速度。然而,有一定数量的计算是不值得分配给两个线程的。Javadoc(docs.oracle.com/javase/8/docs/api/java/util/concurrent/ForkJoinTask.html)对此有以下说明:

“作为一个非常粗略的经验法则,一个任务应该执行超过 100 个,少于 10000 个基本计算步骤,并且应该避免无限循环。如果任务太大,那么并行性无法提高吞吐量。如果太小,那么内存和内部任务维护开销可能会压倒处理。”

然而,一如既往,确定不在并行线程之间分割计算的最佳数量应该基于测试。这就是为什么我们建议您将其作为参数传递。我们将称此参数为threshold。请注意,它还用作递归退出的标准。

我们将称我们的类(任务)为AverageSpeed,并扩展RecursiveTask<Double>,因为我们希望将平均速度值作为结果的double类型:

class AverageSpeed extends RecursiveTask<Double> {
  private double timeSec;
  private DateLocation dateLocation;
  private int threshold, trafficUnitsNumber;
  public AverageSpeed(DateLocation dateLocation, 
                      double timeSec, int trafficUnitsNumber, 
                      int threshold) {
    this.timeSec = timeSec;
    this.threshold = threshold;
    this.dateLocation = dateLocation;
    this.trafficUnitsNumber = trafficUnitsNumber;
  }
  protected Double compute() {
    if (trafficUnitsNumber < threshold) {
      //... write the code here that calculates
      //... average speed trafficUnitsNumber vehicles
      return averageSpeed;
    } else{
      int tun = trafficUnitsNumber / 2;
      //write the code that creates two tasks, each
      //for calculating average speed of tun vehicles 
      //then calculates an average of the two results
      double avrgSpeed1 = ...;
      double avrgSpeed2 = ...;
      return (double) Math.round((avrgSpeed1 + avrgSpeed2) / 2);
    }
  }
}

在完成compute()方法的编写之前,让我们编写将执行此任务的代码。有几种方法可以做到这一点。例如,我们可以使用fork()join()

void demo1_ForkJoin_fork_join() {
  AverageSpeed averageSpeed = createTask();
  averageSpeed.fork();  
  double result = averageSpeed.join();
  System.out.println("result = " + result);
}

这种技术为框架提供了名称。根据 Javadoc,fork()方法

“安排异步执行此任务,该任务在池中运行,如果适用,或者如果不在ForkJoinPool()中,则使用ForkJoinPool.commonPool()。”

在我们的情况下,我们还没有使用任何池,因此fork()将默认使用ForkJoinPool.commonPool()。它将任务放入池中的线程的队列中。join()方法在计算完成时返回计算结果。

createTask()方法包含以下内容:

AverageSpeed createTask() {
  DateLocation dateLocation = new DateLocation(Month.APRIL, 
        DayOfWeek.FRIDAY, 17, "USA", "Denver", "Main103S");
  double timeSec = 10d;
  int trafficUnitsNumber = 1001;
  int threshold = 100;
  return new AverageSpeed(dateLocation, timeSec, 
                          trafficUnitsNumber, threshold);
}

注意trafficUnitsNumberthreshold参数的值。这对于分析结果非常重要。

实现这一点的另一种方法是使用execute()submit()方法中的任一种——每种方法都提供相同的功能——用于执行任务。执行的结果可以通过join()方法检索(与前面的示例相同):

void demo2_ForkJoin_execute_join() {
  AverageSpeed averageSpeed = createTask();
  ForkJoinPool commonPool = ForkJoinPool.commonPool();
  commonPool.execute(averageSpeed);
  double result = averageSpeed.join();
  System.out.println("result = " + result);
}

我们将要审查的最后一种方法是invoke(),它相当于调用fork()方法,然后调用join()方法:

void demo3_ForkJoin_invoke() {
  AverageSpeed averageSpeed = createTask();
  ForkJoinPool commonPool = ForkJoinPool.commonPool();
  double result = commonPool.invoke(averageSpeed);
  System.out.println("result = " + result);
}

当然,这是开始分治过程的最流行的方法。

现在让我们回到compute()方法,看看它如何实现。首先,让我们实现if块(计算少于threshold车辆的平均速度)。我们将使用我们在第三章中描述的技术和代码,模块化编程

double speed = 
    FactoryTraffic.getTrafficUnitStream(dateLocation, 
                                                trafficUnitsNumber)
        .map(TrafficUnitWrapper::new)
        .map(tuw -> tuw.setSpeedModel(FactorySpeedModel.
                         generateSpeedModel(tuw.getTrafficUnit())))
        .map(tuw -> tuw.calcSpeed(timeSec))
        .mapToDouble(TrafficUnitWrapper::getSpeed)
        .average()
        .getAsDouble();
System.out.println("speed(" + trafficUnitsNumber + ") = " + speed);
return (double) Math.round(speed);

我们从FactoryTraffic获取了车辆的trafficUnitsNumber。我们为每个发射的元素创建一个TrafficUnitWrapper对象,并在其上调用setSpeedModel()方法(通过传入基于发射的TrafficUnit对象生成的新生成的SpeedModel对象)。然后我们计算速度,得到流中所有速度的平均值,并从Optional对象(average()操作的返回类型)中得到结果作为double。然后我们打印出结果并四舍五入以获得更具有代表性的格式。

也可以使用传统的for循环来实现相同的结果。但是,如前所述,似乎 Java 遵循了更流畅和类似流的风格的总体趋势,旨在处理大量数据。因此,我们建议您习惯于使用它。

在第十四章中,测试,您将看到相同功能的另一个版本,它允许更好地单独测试每个步骤,这再次支持单元测试,以及编写代码,帮助您使代码更具可测试性,并减少以后重写代码的需要。

现在,让我们回顾else块实现的选项。前几行总是相同的:

int tun = trafficUnitsNumber / 2;
System.out.println("tun = " + tun);
AverageSpeed as1 = 
   new AverageSpeed(dateLocation, timeSec, tun, threshold);
AverageSpeed as2 = 
   new AverageSpeed(dateLocation, timeSec, tun, threshold);

我们将trafficUnitsNumber的数字除以 2(我们不担心在大型集合的平均值中可能丢失一个单位),并创建两个任务。

接下来-实际任务执行代码-可以用几种不同的方式编写。这是我们已经熟悉的第一个可能的解决方案,首先想到的:

as1.fork();                //add to the queue
double res1 = as1.join();  //wait until completed
as2.fork();
double res2 = as2.join();
return (double) Math.round((res1 + res2) / 2);

运行以下代码:

demo1_ForkJoin_fork_join();
demo2_ForkJoin_execute_join();
demo3_ForkJoin_invoke();

如果我们这样做,我们将看到相同的输出(但速度值不同)三次:

您可以看到,首先将计算 1,001 个单位(车辆)的平均速度的原始任务分成 2 部分,直到一组的数量(62)降到 100 以下的阈值。然后,计算最后两组的平均速度,并将其与其他组的结果合并。

实现compute()方法的else块的另一种方法可能如下:

as1.fork();                   //add to the queue
double res1 = as2.compute();  //get the result recursively
double res2 = as1.join();     //wait until the queued task ends
return (double) Math.round((res1 + res2) / 2);

结果将如下所示:

您可以看到,在这种情况下,compute()方法(第二个任务的)被递归调用多次,直到达到元素数量的阈值,然后其结果与对第一个任务调用fork()join()方法的结果合并。

如前所述,所有这些复杂性都可以通过调用invoke()方法来替换:

double res1 = as1.invoke();
double res2 = as2.invoke();
return (double) Math.round((res1 + res2) / 2);

它产生的结果类似于对每个任务调用fork()join()产生的结果:

然而,实现compute()方法的else块的更好的方法是:

return ForkJoinTask.invokeAll(List.of(as1, as2))
        .stream()
        .mapToDouble(ForkJoinTask::join)
        .map(Math::round)
        .average()
        .getAsDouble();

如果这对您来说看起来很复杂,只需注意这只是一种类似流的方式来迭代invokeAll()的结果:

<T extends ForkJoinTask> Collection<T> invokeAll(Collection<T> tasks)

还可以迭代对每个返回任务调用join()的结果(并将结果合并为平均值)。优点是我们让框架决定如何优化负载分配。结果如下:

您可以看到它与之前的任何结果都不同,并且可以根据计算机上 CPU 的可用性和负载而改变。

使用流来实现发布-订阅模式

在这个示例中,您将了解 Java 9 中引入的新的发布-订阅功能。

准备好了

除了许多其他功能,Java 9 还在java.util.concurrent.Flow类中引入了这四个接口:

Flow.Publisher<T> - producer of items (messages) of type T
Flow.Subscriber<T> - receiver of messages of type T
Flow.Subscription - links producer and receiver
Flow.Processor<T,R> - acts as both producer and receiver

通过这种方式,Java 步入了响应式编程的世界-使用数据流的异步处理编程。

我们在第三章中讨论了流,模块化编程,并指出它们不是数据结构,因为它们不会在内存中保存数据。流管道在发出元素之前不会执行任何操作。这种模型允许最小的资源分配,并且只在需要时使用资源。应用程序对其所反应的数据的出现做出响应,因此得名。

在发布-订阅模式中,主要的两个角色是Publisher,它流式传输数据(发布),以及Subscriber,它监听数据(订阅)。

Flow.Publisher<T>接口是一个函数式接口。它只有一个抽象方法:

void subscribe(Flow.Subscriber<? super T> subscriber)

根据 Javadoc(docs.oracle.com/javase/10/docs/api/java/util/concurrent/SubmissionPublisher.html),这个方法,

“如果可能,添加给定的Flow.Subscriber<T>。如果已经订阅,或者订阅失败,则使用IllegalStateException调用Flow.Subscriber<T>onError()方法。否则,使用新的Flow.Subscription调用Flow.Subscriber<T>onSubscribe()方法。订阅者可以通过调用此Flow.Subscriptionrequest()方法启用接收项目,并可以通过调用其cancel()方法取消订阅。”

Flow.Subscriber<T>接口有四个方法:

  • void onSubscribe(Flow.Subscription subscription): 在给定Subscription的其他Subscriber方法之前调用

  • void onError(Throwable throwable): 在PublisherSubscription遇到不可恢复的错误后调用,之后Subscription不会再调用其他Subscriber方法

  • void onNext(T item): 调用Subscription的下一个项目

  • void onComplete(): 当已知对于Subscription不会再发生额外的Subscriber方法调用时调用

Flow.Subscription接口有两个方法:

  • void cancel(): 导致Subscriber(最终)停止接收消息

  • void request(long n): 将给定的n数量的项目添加到此订阅的当前未满足的需求中

Flow.Processor<T,R>接口超出了本书的范围。

如何做...

为了节省时间和空间,我们可以使用java.util.concurrent包中的SubmissionPublisher<T>类,而不是创建自己的Flow.Publisher<T>接口的实现。但是,我们将创建自己的Flow.Subscriber<T>接口的实现:

class DemoSubscriber<T> implements Flow.Subscriber<T> {
  private String name;
  private Flow.Subscription subscription;
  public DemoSubscriber(String name){ this.name = name; }
  public void onSubscribe(Flow.Subscription subscription) {
    this.subscription = subscription;
    this.subscription.request(0);
  }
  public void onNext(T item) {
    System.out.println(name + " received: " + item);
    this.subscription.request(1);
  }
  public void onError(Throwable ex){ ex.printStackTrace();}
  public void onComplete() { System.out.println("Completed"); }
}

我们还将实现Flow.Subscription接口:

class DemoSubscription<T> implements Flow.Subscription {
  private final Flow.Subscriber<T> subscriber;
  private final ExecutorService executor;
  private Future<?> future;
  private T item;
  public DemoSubscription(Flow.Subscriber subscriber,
                          ExecutorService executor) {
    this.subscriber = subscriber;
    this.executor = executor;
  }
  public void request(long n) {
    future = executor.submit(() -> {
      this.subscriber.onNext(item );
    });
  }
  public synchronized void cancel() {
    if (future != null && !future.isCancelled()) {
      this.future.cancel(true);
    }
  }
}

正如您所看到的,我们只是遵循了 Javadoc 的建议,并期望当订阅者添加到发布者时,将调用订阅者的onSubscribe()方法。

还有一个要注意的细节是,SubmissionPublisher<T>类具有submit(T item)方法,根据 Javadoc(docs.oracle.com/javase/10/docs/api/java/util/concurrent/SubmissionPublisher.html):

“通过异步调用其onNext()方法将给定项目发布给每个当前订阅者,同时在任何订阅者资源不可用时阻塞不间断地。”

这样,SubmissionPublisher<T>类将项目提交给当前订阅者,直到关闭。这允许项目生成器充当反应式流发布者。

为了演示这一点,让我们使用demoSubscribe()方法创建几个订阅者和订阅:

void demoSubscribe(SubmissionPublisher<Integer> publisher, 
        ExecutorService execService, String subscriberName){
  DemoSubscriber<Integer> subscriber = 
                     new DemoSubscriber<>(subscriberName);
  DemoSubscription subscription = 
            new DemoSubscription(subscriber, execService);
  subscriber.onSubscribe(subscription);
  publisher.subscribe(subscriber);
}

然后在以下代码中使用它们:

ExecutorService execService =  ForkJoinPool.commonPool();
try (SubmissionPublisher<Integer> publisher = 
                            new SubmissionPublisher<>()){
  demoSubscribe(publisher, execService, "One");
  demoSubscribe(publisher, execService, "Two");
  demoSubscribe(publisher, execService, "Three");
  IntStream.range(1, 5).forEach(publisher::submit);
} finally {
  //...make sure that execService is shut down
}

上述代码创建了三个订阅者,连接到具有专用订阅的相同发布者。最后一行生成了一个数字流,1、2、3 和 4,并将每个数字提交给发布者。我们期望每个订阅者都会将生成的每个数字作为onNext()方法的参数。

finally块中,我们包含了您已经熟悉的代码,来自上一个示例:

try {
  execService.shutdown();
  int shutdownDelaySec = 1;
  System.out.println("Waiting for " + shutdownDelaySec 
                           + " sec before shutting down service...");
  execService.awaitTermination(shutdownDelaySec, TimeUnit.SECONDS);
} catch (Exception ex) {
  System.out.println("Caught around execService.awaitTermination(): " 
                                          + ex.getClass().getName());
} finally {
  System.out.println("Calling execService.shutdownNow()...");
  List<Runnable> l = execService.shutdownNow();
  System.out.println(l.size() 
            +" tasks were waiting to be executed. Service stopped.");
}

如果我们运行上述代码,输出可能如下所示:

正如您所看到的,由于异步处理,控制非常快地到达finally块,并在关闭服务之前等待一秒钟。这段等待时间足够生成项目并将其传递给订阅者。我们还确认每个生成的项目都发送给了每个订阅者。每次调用每个订阅者的onSubscribe()方法时,都会生成三个null值。

可以合理地期望,在未来的 Java 版本中,将会为反应式(异步和非阻塞)功能增加更多支持。

第八章:更好地管理操作系统进程

在本章中,我们将介绍以下内容:

  • 生成一个新进程

  • 将进程输出和错误流重定向到文件

  • 更改子进程的工作目录

  • 为子进程设置环境变量

  • 运行 shell 脚本

  • 获取当前 JVM 的进程信息

  • 获取生成的进程的进程信息

  • 管理生成的进程

  • 枚举系统中正在运行的进程

  • 使用管道连接多个进程

  • 管理子进程

介绍

你有多少次编写了生成新进程的代码?不多。然而,可能有一些情况需要编写这样的代码。在这种情况下,您不得不使用第三方 API,如Apache Commons Execcommons.apache.org/proper/commons-exec/)等。为什么会这样?Java API 不够用吗?不,不够用;至少在 Java 9 之前是这样。现在,有了 Java 9 及以上版本,我们在进程 API 中添加了更多功能。

直到 Java 7,重定向输入、输出和错误流并不是一件简单的事。在 Java 7 中,引入了新的 API,允许将输入、输出和错误重定向到其他进程(管道)、文件或标准输入/输出。然后,在 Java 8 中,又引入了一些新的 API。在 Java 9 中,现在有了以下领域的新 API:

  • 获取进程信息,如进程 IDPID)、启动进程的用户、运行时间等

  • 枚举系统中正在运行的进程

  • 通过导航到进程层次结构的上层来管理子进程并访问进程树

在本章中,我们将介绍一些配方,这些配方将帮助您探索进程 API 中的新内容,并了解自Runtime.getRuntime().exec()以来引入的更改。而且你们都知道使用那个是犯罪。

所有这些配方只能在 Linux 平台上执行,因为我们将在 Java 代码中使用特定于 Linux 的命令来生成新进程。在 Linux 上执行脚本run.sh有两种方法:

  • sh run.sh

  • chmod +x run.sh && ./run.sh

那些使用 Windows 10 的人不用担心,因为微软发布了 Windows 子系统用于 Linux,它允许您在 Windows 上运行您喜欢的 Linux 发行版,如 Ubuntu、OpenSuse 等。有关更多详细信息,请查看此链接:docs.microsoft.com/en-in/windows/wsl/install-win10

生成新进程

在这个配方中,我们将看到如何使用ProcessBuilder生成新进程。我们还将看到如何使用输入、输出和错误流。这应该是一个非常简单和常见的配方。然而,引入这个的目的是为了使本章内容更加完整,而不仅仅是关注 Java 9 的特性。

准备工作

Linux 中有一个名为free的命令,它显示系统中空闲的 RAM 量以及被系统使用的量。它接受一个选项-m,以便以兆字节显示输出。因此,只需运行 free -m即可得到以下输出:

我们将在 Java 程序中运行上述代码。

如何做...

按照以下步骤进行:

  1. 通过提供所需的命令和选项来创建ProcessBuilder的实例:
        ProcessBuilder pBuilder = new ProcessBuilder("free", "-m");

指定命令和选项的另一种方法如下:

        pBuilder.command("free", "-m");
  1. 为进程生成器设置输入和输出流以及其他属性,如执行目录和环境变量。然后,在ProcessBuilder实例上调用start()来生成进程并获取对Process对象的引用:
        Process p = pBuilder.inheritIO().start();

inheritIO()函数将生成的子进程的标准 I/O 设置为与当前 Java 进程相同。

  1. 然后,我们等待进程的完成,或者等待一秒钟(以先到者为准),如下面的代码所示:
        if(p.waitFor(1, TimeUnit.SECONDS)){
          System.out.println("process completed successfully");
        }else{
          System.out.println("waiting time elapsed, process did 
                              not complete");   
          System.out.println("destroying process forcibly");
          p.destroyForcibly();
        }

如果在指定的时间内没有完成,我们可以通过调用destroyForcibly()方法来终止进程。

  1. 使用以下命令编译和运行代码:
 $ javac -d mods --module-source-path src
      $(find src -name *.java)
      $ java -p mods -m process/com.packt.process.NewProcessDemo
  1. 我们得到的输出如下:

此示例的代码可以在Chapter08/1_spawn_new_process中找到。

工作原理...

有两种方法可以让ProcessBuilder知道要运行哪个命令:

  • 通过在创建ProcessBuilder对象时将命令及其选项传递给构造函数

  • 通过将命令及其选项作为参数传递给ProcessBuilder对象的command()方法

在生成进程之前,我们可以执行以下操作:

  • 我们可以使用directory()方法更改执行目录。

  • 我们可以将输入流、输出流和错误流重定向到文件或另一个进程。

  • 我们可以为子进程提供所需的环境变量。

我们将在本章的各自示例中看到所有这些活动。

当调用start()方法时,将生成一个新的进程,并且调用者以Process类的实例形式获得对该子进程的引用。使用这个Process对象,我们可以做很多事情,比如以下事情:

  • 获取有关进程的信息,包括其 PID

  • 获取输出和错误流

  • 检查进程的完成情况

  • 销毁进程

  • 将任务与进程完成后要执行的操作关联起来

  • 检查进程生成的子进程

  • 查找进程的父进程(如果存在)

在我们的示例中,我们等待一秒钟,或者等待进程完成(以先到者为准)。如果进程已完成,则waitFor返回true;否则返回false。如果进程没有完成,我们可以通过在Process对象上调用destroyForcibly()方法来终止进程。

将进程输出和错误流重定向到文件

在本示例中,我们将看到如何处理从 Java 代码生成的进程的输出和错误流。我们将把生成的进程产生的输出或错误写入文件。

准备工作

在本示例中,我们将使用iostat命令。此命令用于报告不同设备和分区的 CPU 和 I/O 统计信息。让我们运行该命令并查看它报告了什么:

$ iostat

在某些 Linux 发行版(如 Ubuntu)中,默认情况下未安装iostat。您可以通过运行sudo apt-get install sysstat来安装该实用程序。

上述命令的输出如下:

如何做...

按照以下步骤进行:

  1. 通过指定要执行的命令来创建一个新的ProcessBuilder对象:
        ProcessBuilder pb = new ProcessBuilder("iostat");
  1. 将输出和错误流重定向到文件的输出和错误流中:
        pb.redirectError(new File("error"))
          .redirectOutput(new File("output"));
  1. 启动进程并等待其完成:
        Process p = pb.start();
        int exitValue = p.waitFor();
  1. 读取输出文件的内容:
        Files.lines(Paths.get("output"))
                         .forEach(l -> System.out.println(l));
  1. 读取错误文件的内容。只有在命令出现错误时才会创建此文件:
        Files.lines(Paths.get("error"))
                         .forEach(l -> System.out.println(l));

步骤 4 和 5 是供我们参考的。这与ProcessBuilder或生成的进程无关。使用这两行代码,我们可以检查进程写入输出和错误文件的内容。

完整的代码可以在Chapter08/2_redirect_to_file中找到。

  1. 使用以下命令编译代码:
 $ javac -d mods --module-source-path src $(find src -name 
      *.java)
  1. 使用以下命令运行代码:
 $ java -p mods -m process/com.packt.process.RedirectFileDemo

我们将得到以下输出:

我们可以看到,由于命令成功执行,错误文件中没有任何内容。

还有更多...

您可以向ProcessBuilder提供错误的命令,然后看到错误被写入错误文件,输出文件中没有任何内容。您可以通过更改ProcessBuilder实例创建来实现这一点,如下所示:

ProcessBuilder pb = new ProcessBuilder("iostat", "-Z");

使用前面在如何做...部分中给出的命令进行编译和运行。

您会看到错误文件中报告了一个错误,但输出文件中没有任何内容:

更改子进程的工作目录

通常,您会希望在路径的上下文中执行一个进程,比如列出目录中的文件。为了做到这一点,我们将不得不告诉 ProcessBuilder 在给定位置的上下文中启动进程。我们可以通过使用 directory() 方法来实现这一点。这个方法有两个目的:

  • 当我们不传递任何参数时,它返回执行的当前目录。

  • 当我们传递参数时,它将执行的当前目录设置为传递的值。

在这个示例中,我们将看到如何执行

tree 命令用于递归遍历当前目录中的所有目录,并以树形式打印出来。

准备工作

通常,tree 命令不是预装的,因此您将不得不安装包含该命令的软件包。要在 Ubuntu/Debian 系统上安装,请运行以下命令:

$ sudo apt-get install tree

要在支持 yum 软件包管理器的 Linux 上安装,请运行以下命令:

$ yum install tree

要验证您的安装,只需运行 tree 命令,您应该能够看到当前目录结构的打印。对我来说,它是这样的:

tree 命令支持多个选项。这是供您探索的。

如何做...

按照以下步骤进行:

  1. 创建一个新的 ProcessBuilder 对象:
        ProcessBuilder pb = new ProcessBuilder();
  1. 将命令设置为 tree,并将输出和错误设置为与当前 Java 进程相同的输出和错误:
        pb.command("tree").inheritIO();
  1. 将目录设置为您想要的任何目录。我将其设置为根文件夹:
        pb.directory(new File("/root"));
  1. 启动进程并等待其退出:
        Process p = pb.start();
        int exitValue = p.waitFor();
  1. 使用以下命令进行编译和运行:
$ javac -d mods --module-source-path src $(find src -name *.java)
$ java -p mods -m process/com.packt.process.ChangeWorkDirectoryDemo
  1. 输出将是指定在 ProcessBuilder 对象的 directory() 方法中的目录的递归内容,以树状格式打印出来。

完整的代码可以在 Chapter08/3_change_work_directory 找到。

它是如何工作的...

directory() 方法接受 Process 的工作目录的路径。路径被指定为 File 的实例。

为子进程设置环境变量

环境变量就像我们在编程语言中拥有的任何其他变量一样。它们有一个名称并保存一些值,这些值可以变化。这些被 Linux/Windows 命令或 shell/batch 脚本用来执行不同的操作。它们被称为环境变量,因为它们存在于正在执行的进程/命令/脚本的环境中。通常,进程从父进程继承环境变量。

它们在不同的操作系统中以不同的方式访问。在 Windows 中,它们被访问为 %ENVIRONMENT_VARIABLE_NAME%,在基于 Unix 的操作系统中,它们被访问为 $ENVIRONMENT_VARIABLE_NAME

在基于 Unix 的系统中,您可以使用 printenv 命令打印出进程可用的所有环境变量,在基于 Windows 的系统中,您可以使用 SET 命令。

在这个示例中,我们将向子进程传递一些环境变量,并使用 printenv 命令打印所有可用的环境变量。

如何做...

按照以下步骤进行:

  1. 创建一个 ProcessBuilder 的实例:
        ProcessBuilder pb = new ProcessBuilder();
  1. 将命令设置为 printenv,并将输出和错误流设置为与当前 Java 进程相同的输出和错误:
        pb.command("printenv").inheritIO();
  1. 提供环境变量 COOKBOOK_VAR1 的值为 First variableCOOKBOOK_VAR2 的值为 Second variable,以及 COOKBOOK_VAR3 的值为 Third variable
        Map<String, String> environment = pb.environment();
        environment.put("COOKBOOK_VAR1", "First variable");
        environment.put("COOKBOOK_VAR2", "Second variable");
        environment.put("COOKBOOK_VAR3", "Third variable");

  1. 启动进程并等待其完成:
        Process p = pb.start();
        int exitValue = p.waitFor();

这个示例的完整代码可以在 Chapter08/4_environment_variables 找到。

  1. 使用以下命令编译和运行代码:
 $ javac -d mods --module-source-path src $(find src -name 
      *.java)
      $ java -p mods -m 
       process/com.packt.process.EnvironmentVariableDemo

您得到的输出如下:

您可以看到三个变量打印在其他变量中。

它是如何工作的...

当您在ProcessBuilder的实例上调用environment()方法时,它会复制当前进程的环境变量,将它们填充到HashMap的一个实例中,并将其返回给调用者代码。

加载环境变量的所有工作都是由一个包私有的最终类ProcessEnvironment完成的,它实际上扩展了HashMap

然后我们利用这个映射来填充我们自己的环境变量,但我们不需要将映射设置回ProcessBuilder,因为我们将有一个对映射对象的引用,而不是一个副本。对映射对象所做的任何更改都将反映在ProcessBuilder实例持有的实际映射对象中。

运行 shell 脚本

我们通常会收集在文件中执行操作的一组命令,称为 Unix 世界中的shell 脚本和 Windows 中的批处理文件。这些文件中的命令按顺序执行,除非脚本中有条件块或循环。

这些 shell 脚本由它们执行的 shell 进行评估。可用的不同类型的 shell 包括bashcshksh等。bash shell 是最常用的 shell。

在这个示例中,我们将编写一个简单的 shell 脚本,然后使用ProcessBuilderProcess对象从 Java 代码中调用它。

准备工作

首先,让我们编写我们的 shell 脚本。这个脚本做了以下几件事:

  1. 打印环境变量MY_VARIABLE的值

  2. 执行tree命令

  3. 执行iostat命令

让我们创建一个名为script.sh的 shell 脚本文件,其中包含以下命令:

echo $MY_VARIABLE;
echo "Running tree command";
tree;
echo "Running iostat command"
iostat;

您可以将script.sh放在您的主文件夹中;也就是说,在/home/<username>中。现在让我们看看我们如何从 Java 中执行它。

如何做...

按照以下步骤进行:

  1. 创建ProcessBuilder的一个新实例:
        ProcessBuilder pb = new ProcessBuilder();
  1. 将执行目录设置为指向 shell 脚本文件的目录:
         pb.directory(new File("/root"));

请注意,在创建File对象时传递的先前路径将取决于您放置脚本script.sh的位置。在我们的情况下,我们将它放在/root中。您可能已经将脚本复制到了/home/yourname中,因此File对象将相应地创建为newFile("/home/yourname")

  1. 设置一个将被 shell 脚本使用的环境变量:
    Map<String, String> environment = pb.environment();
    environment.put("MY_VARIABLE", "Set by Java process");
  1. 设置要执行的命令,以及要传递给命令的参数。还要将进程的输出和错误流设置为与当前 Java 进程相同的流:
       pb.command("/bin/bash", "script.sh").inheritIO();
  1. 启动进程并等待它完全执行:
         Process p = pb.start();
         int exitValue = p.waitFor();

您可以从Chapter08/5_running_shell_script获取完整的代码。

您可以使用以下命令编译和运行代码:

$ javac -d mods --module-source-path src $(find src -name *.java)
$ java -p mods -m process/com.packt.process.RunningShellScriptDemo

我们得到的输出如下:

它是如何工作的...

在这个示例中,您必须记下两件事:

  • 将进程的工作目录更改为 shell 脚本的位置。

  • 使用/bin/bash执行 shell 脚本。

如果你没有记下第一步,那么你将不得不使用 shell 脚本文件的绝对路径。然而,在这个示例中,我们做了这个,因此我们只需使用 shell 脚本名称来执行/bin/bash命令。

第 2 步基本上是您希望执行 shell 脚本的方式。要执行此操作的方法是将 shell 脚本传递给解释器,解释器将解释和执行脚本。以下代码行就是这样做的:

pb.command("/bin/bash", "script.sh")

获取当前 JVM 的进程信息

运行中的进程有一组与之关联的属性,例如以下内容:

  • PID:这个唯一标识进程

  • 所有者:这是启动进程的用户的名称

  • 命令:这是在进程下运行的命令

  • CPU 时间:这表示进程已经活动的时间

  • 开始时间:这表示进程启动的时间

这些是我们通常感兴趣的一些属性。也许我们还对 CPU 使用率或内存使用率感兴趣。现在,在 Java 9 之前,从 Java 中获取这些信息是不可能的。然而,在 Java 9 中,引入了一组新的 API,使我们能够获取有关进程的基本信息。

在本示例中,我们将看到如何获取当前 Java 进程的进程信息;也就是说,正在执行您的代码的进程。

如何做...

按照以下步骤进行:

  1. 创建一个简单的类,并使用ProcessHandle.current()来获取当前 Java 进程的ProcessHandle
        ProcessHandle handle = ProcessHandle.current();
  1. 我们添加了一些代码,这将为代码增加一些运行时间:
        for ( int i = 0 ; i < 100; i++){
          Thread.sleep(1000);
        }
  1. ProcessHandle实例上使用info()方法获取ProcessHandle.Info的实例:
        ProcessHandle.Info info = handle.info();
  1. 使用ProcessHandle.Info的实例获取接口提供的所有信息:
        System.out.println("Command line: " + 
                                     info.commandLine().get());
        System.out.println("Command: " + info.command().get());
        System.out.println("Arguments: " + 
                     String.join(" ", info.arguments().get()));
        System.out.println("User: " + info.user().get());
        System.out.println("Start: " + info.startInstant().get());
        System.out.println("Total CPU Duration: " + 
                  info.totalCpuDuration().get().toMillis() +"ms");
  1. 使用ProcessHandlepid()方法获取当前 Java 进程的进程 ID:
        System.out.println("PID: " + handle.pid());
  1. 我们还将打印结束时间,使用代码即将结束时的时间。这将让我们了解进程的执行时间:
        Instant end = Instant.now();
        System.out.println("End: " + end);

您可以从Chapter08/6_current_process_info获取完整的代码。

使用以下命令编译和运行代码:

$ javac -d mods --module-source-path src $(find src -name *.java) 
$ java -p mods -m process/com.packt.process.CurrentProcessInfoDemo

您将看到的输出将类似于这样:

程序执行完成需要一些时间。

需要注意的一点是,即使程序运行了大约两分钟,总 CPU 持续时间也只有 350 毫秒。这是 CPU 繁忙的时间段。

它是如何工作的...

为了给本地进程更多的控制并获取其信息,Java API 中添加了一个名为ProcessHandle的新接口。使用ProcessHandle,您可以控制进程执行并获取有关进程的一些信息。该接口还有一个名为ProcessHandle.Info的内部接口。该接口提供了一些 API 来获取有关进程的信息。

有多种方法可以获取进程的ProcessHandle对象。以下是其中一些方法:

  • ProcessHandle.current(): 用于获取当前 Java 进程的ProcessHandle实例。

  • Process.toHandle(): 用于获取给定Process对象的ProcessHandle

  • ProcessHandle.of(pid): 用于获取由给定 PID 标识的进程的ProcessHandle

在我们的示例中,我们使用第一种方法,即使用ProcessHandle.current()。这使我们可以处理当前的 Java 进程。在ProcessHandle实例上调用info()方法将为我们提供ProcessHandle.Info接口的实现的实例,我们可以利用它来获取进程信息,如示例代码所示。

ProcessHandleProcessHandle.Info都是接口。JDK 提供的 Oracle JDK 或 Open JDK 将为这些接口提供实现。Oracle JDK 有一个名为ProcessHandleImpl的类,它实现了ProcessHandle,还有一个名为InfoProcessHandleImpl内部类,它实现了ProcessHandle.Info接口。因此,每当调用上述方法之一来获取ProcessHandle对象时,都会返回ProcessHandleImpl的实例。

Process类也是如此。它是一个抽象类,Oracle JDK 提供了一个名为ProcessImpl的实现,该实现实现了Process类中的抽象方法。

在本章的所有示例中,对ProcessHandle实例或ProcessHandle对象的任何提及都将指的是ProcessHandleImpl的实例或对象,或者是您正在使用的 JDK 提供的任何其他实现类。

此外,对ProcessHandle.Info的实例或ProcessHandle.Info对象的任何提及都将指的是ProcessHandleImpl.Info的实例或对象,或者是您正在使用的 JDK 提供的任何其他实现类。

获取生成的进程的进程信息

在我们之前的示例中,我们看到了如何获取当前 Java 进程的进程信息。在这个示例中,我们将看看如何获取由 Java 代码生成的进程的进程信息;也就是说,由当前 Java 进程生成的进程。使用的 API 与我们在之前的示例中看到的相同,只是ProcessHandle实例的实现方式不同。

准备工作

在这个示例中,我们将使用 Unix 命令sleep,它用于暂停执行一段时间(以秒为单位)。

如何做...

按照以下步骤进行:

  1. 从 Java 代码中生成一个新的进程,运行sleep命令:
        ProcessBuilder pBuilder = new ProcessBuilder("sleep", "20");
        Process p = pBuilder.inheritIO().start();
  1. 获取此生成的进程的ProcessHandle实例:
        ProcessHandle handle = p.toHandle();
  1. 等待生成的进程完成执行:
        int exitValue = p.waitFor();
  1. 使用ProcessHandle获取ProcessHandle.Info实例,并使用其 API 获取所需信息。或者,我们甚至可以直接使用Process对象通过Process类中的info()方法获取ProcessHandle.Info
        ProcessHandle.Info info = handle.info();
        System.out.println("Command line: " + 
                                     info.commandLine().get());
        System.out.println("Command: " + info.command().get());
        System.out.println("Arguments: " + String.join(" ", 
                                      info.arguments().get()));
        System.out.println("User: " + info.user().get());
        System.out.println("Start: " + info.startInstant().get());
        System.out.println("Total CPU time(ms): " + 
                        info.totalCpuDuration().get().toMillis());
        System.out.println("PID: " + handle.pid());

您可以从Chapter08/7_spawned_process_info获取完整的代码。

使用以下命令编译和运行代码:

$ javac -d mods --module-source-path src $(find src -name *.java)
$ java -p mods -m process/com.packt.process.SpawnedProcessInfoDemo

另外,在Chapter08/7_spawned_process_info中有一个run.sh脚本,您可以在任何基于 Unix 的系统上运行/bin/bash run.sh

您看到的输出将类似于这样:

管理生成的进程

有一些方法,如destroy()destroyForcibly()(在 Java 8 中添加)、isAlive()(在 Java 8 中添加)和supportsNormalTermination()(在 Java 9 中添加),可以用于控制生成的进程。这些方法既可以在Process对象上使用,也可以在ProcessHandle对象上使用。在这里,控制只是检查进程是否存活,如果是,则销毁进程。

在这个示例中,我们将生成一个长时间运行的进程,并执行以下操作:

  • 检查其是否存活

  • 检查它是否可以正常停止;也就是说,根据平台的不同,进程可以通过 destroy 或 force destroy 来停止

  • 停止进程

如何做...

  1. 从 Java 代码中生成一个新的进程,运行sleep命令,比如一分钟或 60 秒:
        ProcessBuilder pBuilder = new ProcessBuilder("sleep", "60");
        Process p = pBuilder.inheritIO().start();

  1. 等待,比如 10 秒:
        p.waitFor(10, TimeUnit.SECONDS);
  1. 检查进程是否存活:
        boolean isAlive = p.isAlive();
        System.out.println("Process alive? " + isAlive);
  1. 检查进程是否可以正常停止:
        boolean normalTermination = p.supportsNormalTermination();
        System.out.println("Normal Termination? " + normalTermination);
  1. 停止进程并检查其是否存活:
        p.destroy();
        isAlive = p.isAlive();
        System.out.println("Process alive? " + isAlive);

您可以从Chapter08/8_manage_spawned_process获取完整的代码。

我们提供了一个名为run.sh的实用脚本,您可以使用它来编译和运行代码——sh run.sh

我们得到的输出如下:

如果我们在 Windows 上运行程序,supportsNormalTermination()返回false,但在 Unix 上,supportsNormalTermination()返回true(如前面的输出中所见)。

枚举系统中的活动进程

在 Windows 中,您可以打开 Windows 任务管理器来查看当前活动的进程,在 Linux 中,您可以使用ps命令及其各种选项来查看进程以及其他详细信息,如用户、时间、命令等。

在 Java 9 中,添加了一个名为ProcessHandle的新 API,用于控制和获取有关进程的信息。API 的一个方法是allProcesses(),它返回当前进程可见的所有进程的快照。在这个示例中,我们将看看这个方法的工作原理以及我们可以从 API 中提取的信息。

如何做...

按照以下步骤进行:

  1. ProcessHandle接口上使用allProcesses()方法,以获取当前活动进程的流:
         Stream<ProcessHandle> liveProcesses = 
                       ProcessHandle.allProcesses();
  1. 使用forEach()迭代流,并传递 lambda 表达式以打印可用的详细信息:
         liveProcesses.forEach(ph -> {
           ProcessHandle.Info phInfo = ph.info();
           System.out.println(phInfo.command().orElse("") +" " + 
                              phInfo.user().orElse(""));
         });

您可以从Chapter08/9_enumerate_all_processes获取完整的代码。

我们提供了一个名为run.sh的实用脚本,您可以使用它来编译和运行代码——sh run.sh

我们得到的输出如下:

在前面的输出中,我们打印了命令名称以及进程的用户。我们展示了输出的一小部分。

使用管道连接多个进程

在 Unix 中,通常使用|符号将一组命令连接在一起,以创建一系列活动的管道,其中命令的输入是前一个命令的输出。这样,我们可以处理输入以获得所需的输出。

一个常见的场景是当您想要在日志文件中搜索某些内容或模式,或者在日志文件中搜索某些文本的出现时。在这种情况下,您可以创建一个管道,通过一系列命令,即catgrepwc -l等,传递所需的日志文件数据。

在这个示例中,我们将使用 UCI 机器学习库中提供的 Iris 数据集(archive.ics.uci.edu/ml/datasets/Iris)创建一个管道,我们将统计每种花的出现次数。

准备工作

我们已经下载了 Iris Flower 数据集(archive.ics.uci.edu/ml/datasets/iris),可以在本书的代码下载中的Chapter08/10_connecting_process_pipe/iris.data中找到。

如果您查看Iris数据,您会看到以下格式的 150 行:

4.7,3.2,1.3,0.2,Iris-setosa

这里,有多个由逗号(,)分隔的属性,属性如下:

  • 花萼长度(厘米)

  • 花萼宽度(厘米)

  • 花瓣长度(厘米)

  • 花瓣宽度(厘米)

  • 类别:

  • Iris setosa

  • Iris versicolour

  • Iris virginica

在这个示例中,我们将找到每个类别中花的总数,即 setosa、versicolour 和 virginica。

我们将使用以下命令的管道(使用基于 Unix 的操作系统):

$ cat iris.data.txt | cut -d',' -f5 | uniq -c

我们得到的输出如下:

50 Iris-setosa
50 Iris-versicolor
50 Iris-virginica
1

末尾的 1 表示文件末尾有一个新行。所以每个类别有 50 朵花。让我们解析上面的 shell 命令管道并理解它们各自的功能:

  • cat:此命令读取作为参数给定的文件。

  • cut:这使用-d选项中给定的字符拆分每一行,并返回由-f选项标识的列中的值。

  • uniq:这从给定的值返回一个唯一列表,当使用-c选项时,它返回列表中每个唯一值的出现次数。

操作步骤

  1. 创建一个ProcessBuilder对象的列表,其中将保存参与我们的管道的ProcessBuilder实例。还将管道中最后一个进程的输出重定向到当前 Java 进程的标准输出:
         List<ProcessBuilder> pipeline = List.of(
           new ProcessBuilder("cat", "iris.data.txt"),
           new ProcessBuilder("cut", "-d", ",", "-f", "5"),
           new ProcessBuilder("uniq", "-c")
               .redirectOutput(ProcessBuilder.Redirect.INHERIT)
         );
  1. 使用ProcessBuilderstartPipeline()方法,并传递ProcessBuilder对象的列表以启动管道。它将返回一个Process对象的列表,每个对象代表列表中的一个ProcessBuilder对象:
  List<Process> processes = ProcessBuilder.startPipeline(pipeline);
  1. 获取列表中的最后一个进程并waitFor它完成:
     int exitValue = processes.get(processes.size() - 1).waitFor();

您可以从Chapter08/10_connecting_process_pipe获取完整的代码。

我们提供了一个名为run.sh的实用脚本,您可以使用它来编译和运行代码——sh run.sh

我们得到的输出如下:

工作原理

startPipeline()方法为列表中的每个ProcessBuilder对象启动一个Process。除了第一个和最后一个进程外,它通过使用ProcessBuilder.Redirect.PIPE将一个进程的输出重定向到另一个进程的输入。如果您为任何中间进程提供了redirectOutput,而不是ProcessBuilder.Redirect.PIPE,那么将会抛出错误;类似于以下内容:

Exception in thread "main" java.lang.IllegalArgumentException: builder redirectOutput() must be PIPE except for the last builder: INHERIT. 

它指出除了最后一个之外的任何构建器都应将其输出重定向到下一个进程。对于redirectInput也是适用的。

管理子进程

当一个进程启动另一个进程时,启动的进程成为启动进程的子进程。启动的进程反过来可以启动另一个进程,这个链条可以继续下去。这导致了一个进程树。通常,我们可能需要处理一个有错误的子进程,可能想要终止该子进程,或者可能想要知道启动的子进程并可能想要获取有关它们的一些信息。

在 Java 9 中,Process类中添加了两个新的 API——children()descendants()children() API 允许您获取当前进程的直接子进程的快照列表,而descendants() API 提供了当前进程递归children()的进程的快照;也就是说,它们在每个子进程上递归地调用children()

在这个配方中,我们将查看children()descendants() API,并看看我们可以从进程的快照中收集到什么信息。

准备就绪

让我们创建一个简单的 shell 脚本,我们将在配方中使用它。此脚本可以在Chapter08/11_managing_sub_process/script.sh中找到:

echo "Running tree command";
tree;
sleep 60;
echo "Running iostat command";
iostat;

在上述脚本中,我们运行了treeiostat命令,中间用一分钟的睡眠时间分隔。如果您想了解这些命令,请参考本章的运行 shell 脚本配方。当从 bash shell 中执行时,睡眠命令每次被调用时都会创建一个新的子进程。

我们将创建,比如说,10 个ProcessBuilder实例来运行上述的 shell 脚本并同时启动它们。

如何做...

  1. 我们将创建 10 个ProcessBuilder实例来运行我们的 shell 脚本(位于Chapter08/11_managing_sub_process/script.sh)。我们不关心它的输出,所以让我们通过将输出重定向到预定义的重定向ProcessHandle.Redirect.DISCARD来丢弃命令的输出:
        for ( int i = 0; i < 10; i++){
          new ProcessBuilder("/bin/bash", "script.sh")
              .redirectOutput(ProcessBuilder.Redirect.DISCARD)
              .start();
        }
  1. 获取当前进程的句柄:
        ProcessHandle currentProcess = ProcessHandle.current();
  1. 使用当前进程通过children() API 获取其子进程,并迭代每个子进程以打印它们的信息。一旦我们有了ProcessHandle的实例,我们可以做多种事情,比如销毁进程,获取其进程信息等。
        System.out.println("Obtaining children");
        currentProcess.children().forEach(pHandle -> {
          System.out.println(pHandle.info());
        });
  1. 使用当前进程通过使用descendants() API 获取所有子进程,然后迭代每个子进程以打印它们的信息:
        currentProcess.descendants().forEach(pHandle -> {
          System.out.println(pHandle.info());
        });

您可以从Chapter08/11_managing_sub_process获取完整的代码。

我们提供了一个名为run.sh的实用脚本,您可以使用它来编译和运行代码——sh run.sh

我们得到的输出如下:

工作原理...

children()descendants() API 返回当前进程的直接子进程或后代进程的ProcessHandlerStream。使用ProcessHandler的实例,我们可以执行以下操作:

  • 获取进程信息

  • 检查进程的状态

  • 停止进程

第九章:使用 Spring Boot 创建 RESTful Web 服务

在本章中,我们将涵盖以下示例:

  • 创建一个简单的 Spring Boot 应用程序

  • 与数据库交互

  • 创建一个 RESTful web 服务

  • 为 Spring Boot 创建多个配置文件

  • 将 RESTful web 服务部署到 Heroku

  • 使用 Docker 将 RESTful web 服务容器化

  • 使用 Micrometer 和 Prometheus 监控 Spring Boot 2 应用程序

介绍

近年来,基于微服务架构的推动已经得到了广泛的采用,这要归功于它在正确的方式下提供的简单性和易于维护性。许多公司,如 Netflix 和 Amazon,已经从单片系统转移到了更专注和轻量级的系统,它们之间通过 RESTful web 服务进行通信。RESTful web 服务的出现及其使用已知的 HTTP 协议创建 web 服务的简单方法,使得应用程序之间的通信比旧的基于 SOAP 的 web 服务更容易。

在本章中,我们将介绍Spring Boot框架,它提供了一种方便的方式来使用 Spring 库创建可投入生产的微服务。使用 Spring Boot,我们将开发一个简单的 RESTful web 服务并将其部署到云端。

创建一个简单的 Spring Boot 应用程序

Spring Boot 有助于轻松创建可投入生产的基于 Spring 的应用程序。它支持几乎所有 Spring 库的工作,而无需显式配置它们。提供了自动配置类,以便轻松集成大多数常用的库、数据库和消息队列。

在本示例中,我们将介绍如何创建一个简单的 Spring Boot 应用程序,其中包含一个在浏览器中打开时打印消息的控制器。

准备工作

Spring Boot 支持 Maven 和 Gradle 作为其构建工具,我们将在我们的示例中使用 Maven。以下 URL,start.spring.io/,提供了一种方便的方式来创建一个带有所需依赖项的空项目。我们将使用它来下载一个空项目。按照以下步骤创建并下载一个基于 Spring Boot 的空项目:

  1. 导航到start.spring.io/,您将看到类似以下截图的内容:

  1. 您可以选择依赖管理和构建工具,通过在Generate a文本后的下拉菜单中选择适当的选项。

  2. Spring Boot 支持 Java、Kotlin 和 Groovy。您可以通过更改with文本后的下拉菜单来选择语言。

  3. 通过在and Spring Boot文本后的下拉菜单中选择其值来选择 Spring Boot 版本。对于本示例,我们将使用 Spring Boot 2 的最新稳定版本,即 2.0.4。

  4. 在左侧的项目元数据下,我们需要提供与 Maven 相关的信息,即组 ID 和 artifact ID。我们将使用 Group 作为com.packt,Artifact 作为boot_demo

  5. 在右侧的依赖项下,您可以搜索要添加的依赖项。对于本示例,我们需要 web 和 Thymeleaf 依赖项。这意味着我们想要创建一个使用 Thymeleaf UI 模板的 web 应用程序,并且希望所有依赖项,如 Spring MVC 和嵌入式 Tomcat,都成为应用程序的一部分。

  6. 单击生成项目按钮以下载空项目。您可以将此空项目加载到您选择的任何 IDE 中,就像加载任何其他 Maven 项目一样。

此时,您将在您选择的任何 IDE 中加载您的空项目,并准备进一步探索。在本示例中,我们将使用 Thymeleaf 模板引擎来定义我们的网页,并创建一个简单的控制器来呈现网页。

此处的完整代码可以在Chapter09/1_boot_demo中找到。

如何做...

  1. 如果您按照“准备就绪”部分提到的组 ID 和工件 ID 命名约定进行了跟随,您将拥有一个包结构com.packt.boot_demo,以及一个BootDemoApplication.java主类已经为您创建。在tests文件夹下将有一个等效的包结构和一个BootDemoApplicationTests.java主类。

  2. com.packt.boot_demo包下创建一个名为SimpleViewController的新类,其中包含以下代码:

        @Controller
        public class SimpleViewController{
          @GetMapping("/message")
          public String message(){
            return "message";
          }  
        }
  1. src/main/resources/templates下创建一个名为message.html的网页,其中包含以下代码:
        <h1>Hello, this is a message from the Controller</h1>
        <h2>The time now is [[${#dates.createNow()}]]</h2>
  1. 从命令提示符中,导航到项目根文件夹,并发出mvn spring-boot:run命令;您将看到应用程序正在启动。一旦完成初始化并启动,它将在默认端口8080上运行。导航到http://localhost:8080/message以查看消息。

我们使用 Spring Boot 的 Maven 插件,它为我们提供了方便的工具来在开发过程中启动应用程序。但是对于生产环境,我们将创建一个 fat JAR,即一个包含所有依赖项的 JAR,并将其部署为 Linux 或 Windows 服务。我们甚至可以使用java -jar命令运行这个 fat JAR。

工作原理

我们不会深入讨论 Spring Boot 或其他 Spring 库的工作原理。Spring Boot 创建了一个嵌入式 Tomcat,运行在默认端口8080上。然后,它注册了所有被@SpringBootApplication注解的类所在包及其子包中可用的控制器、组件和服务。

在我们的示例中,com.packt.boot_demo包中的BootDemoApplication类被注解为@SpringBootApplication。因此,所有被注解为@Controller@Service@Configuration@Component的类都会被 Spring 框架注册为 bean,并由其管理。现在,这些可以通过使用@Autowired注解注入到代码中。

我们可以通过两种方式创建一个 web 控制器:

  • 使用@Controller进行注解

  • 使用@RestController进行注解

在第一种方法中,我们创建了一个既可以提供原始数据又可以提供 HTML 数据(由模板引擎如 Thymeleaf、Freemarker 和 JSP 生成)的控制器。在第二种方法中,控制器支持只能提供 JSON 或 XML 形式的原始数据的端点。在我们的示例中,我们使用了前一种方法,如下所示:

@Controller
public class SimpleViewController{
  @GetMapping("/message")
  public String message(){
    return "message";
  }
}

我们可以用@RequestMapping注解类,比如@RequestMapping("/api")。在这种情况下,控制器中暴露的任何 HTTP 端点都会以/api开头。对于 HTTP 的GETPOSTDELETEPUT方法,有专门的注解映射,分别是@GetMapping@PostMapping@DeleteMapping@PutMapping。我们也可以将我们的控制器类重写如下:

@Controller
@RequestMapping("/message")
public class SimpleViewController{
  @GetMapping
  public String message(){
    return "message";
  }
}

我们可以通过在application.properties文件中提供server.port = 9090来修改端口。这个文件可以在src/main/resources/application.properties中找到。有一整套属性(docs.spring.io/spring-boot/docs/current/reference/html/common-application-properties.html)可以用来自定义和连接不同的组件。

与数据库交互

在这个示例中,我们将看看如何与数据库集成,以创建、读取、修改和删除数据。为此,我们将设置一个带有所需表的 MySQL 数据库。随后,我们将从我们的 Spring Boot 应用程序中更新表中的数据。

我们将使用 Windows 作为这个示例的开发平台。您也可以在 Linux 上执行类似的操作,但首先必须设置您的 MySQL 数据库。

准备就绪

在我们开始将应用程序与数据库集成之前,我们需要在开发机器上本地设置数据库。在接下来的几节中,我们将下载和安装 MySQL 工具,然后创建一个带有一些数据的示例表,我们将在应用程序中使用。

安装 MySQL 工具

首先,从dev.mysql.com/downloads/windows/installer/5.7.html下载 MySQL 安装程序。这个 MySQL 捆绑包只适用于 Windows。按照屏幕上的说明成功安装 MySQL 以及其他工具,如 MySQL Workbench。要确认 MySQL 守护程序(mysqld)正在运行,打开任务管理器,你应该能够看到一个类似以下的进程:

你应该记住你为 root 用户设置的密码。

让我们运行 MySQL Workbench;启动时,你应该能够看到类似以下截图的东西,以及工具提供的其他内容:

如果你在前面的图像中找不到连接,你可以使用(+)号添加一个。当你点击(+)时,你将看到以下对话框。填写它并点击测试连接以获得成功消息:

成功的测试连接将导致以下消息:

双击连接到数据库,你应该能够在左侧看到一个 DB 列表,在右侧看到一个空白区域,在顶部看到一个菜单和工具栏。从文件菜单中,点击新查询选项卡,或按Ctrl + T获得一个新的查询窗口。在这里,我们将编写我们的查询来创建一个数据库,并在该数据库中创建一个表。

dev.mysql.com/downloads/windows/installer/5.7.html下载的捆绑安装程序仅适用于 Windows。Linux 用户必须单独下载 MySQL 服务器和 MySQL Workbench(与 DB 交互的 GUI)。

MySQL 服务器可以从dev.mysql.com/downloads/mysql/下载。

MySQL Workbench 可以从dev.mysql.com/downloads/workbench/下载。

创建一个示例数据库

运行以下 SQL 语句创建数据库:

create database sample;

创建一个人员表

运行以下 SQL 语句使用新创建的数据库并创建一个简单的人员表:

create table person( 
  id int not null auto_increment,    
  first_name varchar(255),    
  last_name varchar(255),
  place varchar(255),    
  primary key(id)
);

填充示例数据

让我们继续在我们刚刚创建的表中插入一些示例数据:

insert into person(first_name, last_name, place) 
values('Raj', 'Singh', 'Bangalore');

insert into person(first_name, last_name, place) 
values('David', 'John', 'Delhi');

现在我们的数据库准备好了,我们将继续从start.spring.io/下载空的 Spring Boot 项目,选项如下:

如何做...

  1. 创建一个模型类com.packt.boot_db_demo.Person,代表一个人。我们将使用 Lombok 注解为我们生成 getter 和 setter:
        @Data
        public class Person{
          private Integer id;
          private String firstName;
          private String lastName;
          private String place;
        }
  1. 创建com.packt.boot_db_demo.PersonMapper将数据库中的数据映射到我们的模型类Person
        @Mapper
        public interface PersonMapper {
        }
  1. 让我们添加一个方法来获取表中的所有行。请注意,接下来的几个方法将写在PersonMapper接口内:
        @Select("SELECT * FROM person")
        public List<Person> getPersons();
  1. 通过 ID 标识的单个人的详细信息的另一种方法如下:
        @Select("SELECT * FROM person WHERE id = #{id}")
        public Person getPerson(Integer id);
  1. 在表中创建新行的方法如下:
        @Insert("INSERT INTO person(first_name, last_name, place) " 
                 + " VALUES (#{firstName}, #{lastName}, #{place})")
        @Options(useGeneratedKeys = true)
        public void insert(Person person);
  1. 更新表中现有行的方法,通过 ID 标识:
  @Update("UPDATE person SET first_name = #{firstName},last_name = 
             #{lastName}, "+ "place = #{place}  WHERE id = #{id} ")
  public void save(Person person);
  1. 从表中删除行的方法,通过 ID 标识:
        @Delete("DELETE FROM person WHERE id = #{id}")
        public void delete(Integer id);
  1. 让我们创建一个com.packt.boot_db_demo.PersonController类,我们将用它来编写我们的 web 端点:
        @Controller
        @RequestMapping("/persons")
        public class PersonContoller {
          @Autowired PersonMapper personMapper;
        }
  1. 让我们创建一个端点来列出person表中的所有条目:
        @GetMapping
        public String list(ModelMap model){
          List<Person> persons = personMapper.getPersons();
          model.put("persons", persons);
          return "list";
        }
  1. 让我们创建一个端点来在person表中添加一个新行:
   @GetMapping("/{id}")
   public String detail(ModelMap model, @PathVariable Integer id){
        System.out.println("Detail id: " + id);
        Person person = personMapper.getPerson(id);
        model.put("person", person);
        return "detail";
   }
  1. 让我们创建一个端点来在person表中添加一个新行或编辑一个现有行:
        @PostMapping("/form")
        public String submitForm(Person person){
          System.out.println("Submiting form person id: " + 
                             person.getId());
          if ( person.getId() != null ){
            personMapper.save(person);
          }else{
            personMapper.insert(person);
          }
          return "redirect:/persons/";
        }  
  1. 让我们创建一个端点来从person表中删除一行:
        @GetMapping("/{id}/delete")
        public String deletePerson(@PathVariable Integer id){
          personMapper.delete(id);
          return "redirect:/persons";
        }
  1. 更新src/main/resources/application.properties文件,提供与我们的数据源(即 MySQL 数据库)相关的配置:
  spring.datasource.driver-class-name=com.mysql.jdbc.Driver
  spring.datasource.url=jdbc:mysql://localhost/sample?useSSL=false
  spring.datasource.username=root
  spring.datasource.password=mohamed
  mybatis.configuration.map-underscore-to-camel-case=true

您可以使用mvn spring-boot:run命令行运行应用程序。该应用程序在默认端口8080上启动。在浏览器中导航到http://localhost:8080/persons

这个食谱的完整代码可以在Chapter09/2_boot_db_demo找到。

访问http://localhost:8080/persons,您会看到以下内容:

点击新建人员,您会得到以下内容:

点击编辑,你会得到以下内容:

工作原理...

首先,com.packt.boot_db_demo.PersonMapper使用org.apache.ibatis.annotations.Mapper注解知道如何执行@Select@Update@Delete注解中提供的查询,并返回相关结果。这一切都由 MyBatis 和 Spring Data 库管理。

你一定想知道如何实现与数据库的连接。Spring Boot 的一个自动配置类DataSourceAutoConfiguration通过使用application.properties文件中定义的spring.datasource.*属性来设置连接,从而为我们提供了javax.sql.DataSource的实例。然后 MyBatis 库使用这个javax.sql.DataSource对象为我们提供了SqlSessionTemplate的实例,这就是我们的PersonMapper在后台使用的。

然后,我们通过@AutoWiredcom.packt.boot_db_demo.PersonMapper注入到com.packt.boot_db_demo.PersonController类中。@AutoWired注解寻找任何 Spring 管理的 bean,这些 bean 要么是确切类型的实例,要么是其实现。查看本章中的创建一个简单的 Spring Boot 应用程序食谱,了解@Controller注解。

凭借极少的配置,我们已经能够快速设置简单的 CRUD 操作。这就是 Spring Boot 为开发人员提供的灵活性和敏捷性!

创建 RESTful Web 服务

在上一个食谱中,我们使用 Web 表单与数据进行交互。在这个食谱中,我们将看到如何使用 RESTful Web 服务与数据进行交互。这些 Web 服务是使用已知的 HTTP 协议及其方法(即 GET、POST 和 PUT)与其他应用程序进行交互的一种方式。数据可以以 XML、JSON 甚至纯文本的形式交换。我们将在我们的食谱中使用 JSON。

因此,我们将创建 RESTful API 来支持检索数据、创建新数据、编辑数据和删除数据。

准备工作

像往常一样,通过选择以下截图中显示的依赖项从start.spring.io/下载起始项目:

如何做...

  1. 从上一个食谱中复制Person类:
        public class Person {
          private Integer id;
          private String firstName;
          private String lastName;
          private String place;
          //required getters and setters
        }
  1. 我们将以不同的方式完成PersonMapper部分。我们将在一个 mapper XML 文件中编写所有的 SQL 查询,然后从PersonMapper接口中引用它们。我们将把 mapper XML 放在src/main/resources/mappers文件夹下。我们将mybatis.mapper-locations属性的值设置为classpath*:mappers/*.xml。这样,PersonMapper接口就可以发现与其方法对应的 SQL 查询。

  2. 创建com.packt.boot_rest_demo.PersonMapper接口:

        @Mapper
        public interface PersonMapper {
          public List<Person> getPersons();
          public Person getPerson(Integer id);
          public void save(Person person);
          public void insert(Person person);
          public void delete(Integer id);
        }
  1. PersonMapper.xml中创建 SQL。确保<mapper>标签的namespace属性与PersonMapper映射接口的完全限定名称相同:
        <!DOCTYPE mapper PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
          "http://mybatis.org/dtd/mybatis-3-mapper.dtd">
        <mapper namespace="com.packt.boot_rest_demo.PersonMapper">
          <select id="getPersons"
           resultType="com.packt.boot_rest_demo.Person">
            SELECT id, first_name firstname, last_name lastname, place
            FROM person
          </select>

          <select id="getPerson"
           resultType="com.packt.boot_rest_demo.Person"
           parameterType="long">
            SELECT id, first_name firstname, last_name lastname, place
            FROM person
            WHERE id = #{id}
          </select>

          <update id="save"
           parameterType="com.packt.boot_rest_demo.Person">
            UPDATE person SET
              first_name = #{firstName},
              last_name = #{lastName},
              place = #{place}
            WHERE id = #{id}
          </update>

          <insert id="insert" 
           parameterType="com.packt.boot_rest_demo.Person"
           useGeneratedKeys="true" keyColumn="id" keyProperty="id">
            INSERT INTO person(first_name, last_name, place)
            VALUES (#{firstName}, #{lastName}, #{place})
          </insert>

          <delete id="delete" parameterType="long">
            DELETE FROM person WHERE id = #{id}
          </delete>
        </mapper>
  1. src/main/resources/application.properties文件中定义应用程序属性:
        spring.datasource.driver-class-name=com.mysql.jdbc.Driver
        spring.datasource.url=jdbc:mysql://localhost/sample?    
        useSSL=false
        spring.datasource.username=root
        spring.datasource.password=mohamed
        mybatis.mapper-locations=classpath*:mappers/*.xml
  1. 为 REST API 创建一个空的控制器。这个控制器将被标记为@RestController注解,因为其中的所有 API 都将专门处理数据:
        @RestController
        @RequestMapping("/api/persons")
        public class PersonApiController {
          @Autowired PersonMapper personMapper;
        }
  1. 添加一个 API 来列出person表中的所有行:
        @GetMapping
        public ResponseEntity<List<Person>> getPersons(){
          return new ResponseEntity<>(personMapper.getPersons(),
                                      HttpStatus.OK);
        }
  1. 添加一个 API 来获取单个人的详细信息:
   @GetMapping("/{id}")
   public ResponseEntity<Person> getPerson(@PathVariable Integer id){
      return new ResponseEntity<>(personMapper.getPerson(id),
                                                     HttpStatus.OK);
   }
  1. 添加一个 API 来向表中添加新数据:
        @PostMapping
        public ResponseEntity<Person> newPerson
                       (@RequestBody Person person){
          personMapper.insert(person);
          return new ResponseEntity<>(person, HttpStatus.OK);
        }
  1. 添加一个 API 来编辑表中的数据:
        @PostMapping("/{id}")
        public ResponseEntity<Person> updatePerson
                       (@RequestBody Person person,
          @PathVariable Integer id){
            person.setId(id);
            personMapper.save(person);
            return new ResponseEntity<>(person, HttpStatus.OK);
          }
  1. 添加一个 API 来删除表中的数据:
        @DeleteMapping("/{id}")
        public ResponseEntity<Void> deletePerson
                       (@PathVariable Integer id){
          personMapper.delete(id);
          return new ResponseEntity<>(HttpStatus.OK);
        }

您可以在Chapter09/3_boot_rest_demo找到完整的代码。您可以通过在项目文件夹中使用mvn spring-boot:run来启动应用程序。应用程序启动后,导航到http://localhost:8080/api/persons以查看 person 表中的所有数据。

为了测试其他 API,我们将使用 Google Chrome 的 Postman REST 客户端应用程序。

这就是添加新人的样子。看一下请求体,也就是在 JSON 中指定的人的详细信息:

这就是我们编辑一个人的详细信息的方式:

这就是删除一个人的样子:

它是如何工作的...

首先,让我们看一下PersonMapper接口是如何发现要执行的 SQL 语句的。如果您查看src/main/resources/mappers/PersonMapper.xml,您会发现<mapper>namespace属性是org.packt.boot_rest_demo.PersonMapper。这是一个要求,即namespace属性的值应该是 mapper 接口的完全限定名称,在我们的例子中是org.packt.boot_rest_demo.PersonMapper

接下来,<select><insert><update><delete>中定义的各个 SQL 语句的id属性应该与 mapper 接口中方法的名称匹配。例如,PersonMapper接口中的getPersons()方法寻找一个id="getPersons"的 SQL 语句。

现在,MyBatis 库通过读取mybatis.mapper-locations属性的值来发现 mapper XML 的位置。

关于控制器,我们引入了一个新的注解@RestController。这个特殊的注解表示,除了它是一个 web 控制器之外,类中定义的所有方法都返回通过 HTTP 响应体发送的响应;所有的 REST API 都是如此。它们只是处理数据。

通常情况下,您可以通过使用 Maven Spring Boot 插件mvn spring-boot:run或者执行 Maven 包创建的 JARjava -jar my_jar_name.jar来启动 Spring Boot 应用程序。

为 Spring Boot 创建多个配置文件

通常,Web 应用程序在不同的环境上部署 - 首先在开发人员的机器上本地运行,然后部署在测试服务器上,最后部署在生产服务器上。对于每个环境,我们希望应用程序与位于不同位置的组件进行交互。这种情况下的最佳方法是为每个环境维护不同的配置文件。其中一种方法是创建不同版本的application.properties文件,即存储应用程序级属性的文件的不同版本。Spring Boot 中的这些属性文件也可以是 YML 文件,比如application.yml。即使您创建了不同的版本,您也需要一种机制来告诉您的应用程序选择与其部署的环境相关的文件的相关版本。

Spring Boot 为这样的功能提供了令人惊叹的支持。它允许您拥有多个配置文件,每个文件代表一个特定的配置文件,然后,您可以根据部署的环境在不同的配置文件中启动应用程序。让我们看看它是如何运作的,然后我们将解释它是如何工作的。

准备工作

对于这个示例,有两种选项来托管另一个实例的 MySQL 数据库:

  1. 使用云服务提供商,比如 AWS,并使用其 Amazon 关系型数据库服务RDS)(aws.amazon.com/rds/)。它们有一定的免费使用限制。

  2. 使用云服务提供商,比如 DigitalOcean (www.digitalocean.com/),以每月 5 美元的价格购买一个 droplet(即服务器)。在其上安装 MySQL 服务器。

  3. 使用 VirtualBox 在您的机器上安装 Linux,假设我们使用 Windows,或者如果您使用 Linux,则反之。在其上安装 MySQL 服务器。

选项非常多,从托管数据库服务到服务器,都可以让您完全控制 MySQL 服务器的安装。对于这个配方,我们做了以下工作:

  1. 我们从 DigitalOcean 购买了一个基本的 droplet。

  2. 我们使用sudo apt-get install mysql-server-5.7安装了 MySQL,并为 root 用户设置了密码。

  3. 我们创建了另一个用户springboot,以便我们可以使用这个用户从我们的 RESTful Web 服务应用程序进行连接:

 $ mysql -uroot -p
 Enter password: 
 mysql> create user 'springboot'@'%' identified by 'springboot';
  1. 我们修改了 MySQL 配置文件,以便 MySQL 允许远程连接。这可以通过编辑/etc/mysql/mysql.conf.d/mysqld.cnf文件中的bind-address属性来完成。

  2. 从 MySQL Workbench 中,我们通过使用IP = <Digital Ocean droplet IP>username = springbootpassword = springboot添加了新的 MySQL 连接。

在 Ubuntu OS 中,MySQL 配置文件的位置是/etc/mysql/mysql.conf.d/mysqld.cnf。找出特定于您的操作系统的配置文件位置的一种方法是执行以下操作:

  1. 运行mysql --help

  2. 在输出中,搜索Default options are read from the following files in the given order:。接下来是 MySQL 配置文件的可能位置。

我们将创建所需的表并填充一些数据。但在此之前,我们将以root身份创建sample数据库,并授予springboot用户对其的所有权限:

mysql -uroot
Enter password: 

mysql> create database sample;

mysql> GRANT ALL ON sample.* TO 'springboot'@'%';
Query OK, 0 rows affected (0.00 sec)

mysql> flush privileges;

现在,让我们以springboot用户的身份连接到数据库,创建所需的表,并填充一些示例数据:

mysql -uspringboot -pspringboot

mysql> use sample
Database changed
mysql> create table person(
-> id int not null auto_increment,
-> first_name varchar(255),
-> last_name varchar(255),
-> place varchar(255),
-> primary key(id)
-> );
Query OK, 0 rows affected (0.02 sec)

mysql> INSERT INTO person(first_name, last_name, place) VALUES('Mohamed', 'Sanaulla', 'Bangalore');
mysql> INSERT INTO person(first_name, last_name, place) VALUES('Nick', 'Samoylov', 'USA');

mysql> SELECT * FROM person;
+----+------------+-----------+-----------+
| id | first_name | last_name | place     |
+----+------------+-----------+-----------+
| 1  | Mohamed    | Sanaulla  | Bangalore |
| 2  | Nick       | Samoylov  | USA       |
+----+------------+-----------+-----------+
2 rows in set (0.00 sec)

现在,我们有了云实例的 MySQL 数据库准备就绪。让我们看看如何根据应用程序运行的配置文件管理两个不同连接的信息。

可以在Chapter09/4_boot_multi_profile_incomplete中找到此配方所需的初始示例应用程序。我们将转换此应用程序,使其在不同的环境中运行。

如何做到...

  1. src/main/resources/application.properties文件中,添加一个新的springboot属性,spring.profiles.active = local

  2. src/main/resources/中创建一个新文件application-local.properties

  3. 将以下属性添加到application-local.properties中,并从application.properties文件中删除它们:

  spring.datasource.url=jdbc:mysql://localhost/sample?useSSL=false
  spring.datasource.username=root
  spring.datasource.password=mohamed
  1. src/main/resources/中创建另一个文件application-cloud.properties

  2. 将以下属性添加到application-cloud.properties中:

      spring.datasource.url=
               jdbc:mysql://<digital_ocean_ip>/sample?useSSL=false
      spring.datasource.username=springboot
      spring.datasource.password=springboot

完整的应用程序代码可以在Chapter09/4_boot_multi_profile_incomplete中找到。您可以使用mvn spring-boot:run命令运行应用程序。Spring Boot 从application.properties文件中读取spring.profiles.active属性,并在本地配置文件中运行应用程序。在浏览器中打开http://localhost:8080/api/personsURL,以查看以下数据:

[ 
  {
    "id": 1,
    "firstName": "David ",
    "lastName": "John",
    "place": "Delhi"
  },
  {
    "id": 2,
    "firstName": "Raj",
    "lastName": "Singh",
    "place": "Bangalore"
  }
]

现在,使用mvn spring-boot:run -Dspring.profiles.active=cloud命令在云配置文件上运行应用程序。然后,在浏览器中打开http://localhost:8080/api/persons,以查看以下数据:

[
  {
    "id": 1,
    "firstName": "Mohamed",
    "lastName": "Sanaulla",
    "place": "Bangalore"
  },
  {
    "id": 2,
    "firstName": "Nick",
    "lastName": "Samoylov",
    "place": "USA"
  }
]

您可以看到相同 API 返回了不同的数据集,并且之前的数据是插入到我们在云上运行的 MySQL 数据库中的。因此,我们已成功地在两个不同的配置文件中运行了应用程序:本地和云端。

工作原理...

Spring Boot 可以以多种方式读取应用程序的配置。以下是一些重要的方式,按其相关性顺序列出(在较早的源中定义的属性会覆盖在后来的源中定义的属性):

  • 从命令行。属性使用-D选项指定,就像我们在云配置文件中启动应用程序时所做的那样,mvn spring-boot:run -Dspring.profiles.active=cloud。或者,如果您使用 JAR,它将是java -Dspring.profiles.active=cloud -jar myappjar.jar

  • 从 Java 系统属性,使用System.getProperties()

  • 操作系统环境变量。

  • 特定配置文件,application-{profile}.propertiesapplication-{profile}.yml文件,打包在 JAR 之外。

  • 特定配置文件,application-{profile}.propertiesapplication-{profile}.yml文件,打包在 JAR 中。

  • 应用程序属性,application.propertiesapplication.yml定义在打包的 JAR 之外。

  • 应用程序属性,application.propertiesapplication.yml打包在 JAR 中。

  • 配置类(即使用@Configuration注释)作为属性源(使用@PropertySource注释)。

  • Spring Boot 的默认属性。

在我们的示例中,我们在application.properties文件中指定了所有通用属性,例如以下属性,并且任何特定配置文件中的特定配置属性都在特定配置文件中指定:

spring.profiles.active=local
spring.datasource.driver-class-name=com.mysql.jdbc.Driver

mybatis.mapper-locations=classpath*:mappers/*.xml
mybatis.configuration.map-underscore-to-camel-case=true  

从上面的列表中,我们可以发现application.propertiesapplication-{profile}.properties文件可以在应用程序 JAR 之外定义。Spring Boot 将搜索属性文件的默认位置,其中一个路径是应用程序正在运行的当前目录的config子目录。

Spring Boot 支持的应用程序属性的完整列表可以在docs.spring.io/spring-boot/docs/current/reference/html/common-application-properties.html找到。除了这些,我们可以创建自己的属性,这些属性将为我们的应用程序所需。

此示例的完整代码可以在Chapter09/4_boot_multi_profile_complete找到。

还有更多...

我们可以使用 Spring Boot 创建一个配置服务器,它将作为所有应用程序在所有配置文件中的所有属性的存储库。然后客户端应用程序可以连接到配置服务器,根据应用程序名称和应用程序配置读取相关属性。

在配置服务器中,可以使用类路径或 GitHub 存储库从文件系统读取应用程序属性。使用 GitHub 存储库的优势是属性文件可以进行版本控制。配置服务器中的属性文件可以更新,并且可以通过设置消息队列将这些更新推送到客户端应用程序的下游。另一种方法是使用@RefreshScope bean,然后在需要客户端应用程序拉取配置更改时调用/refresh API。

将 RESTful web 服务部署到 Heroku

平台即服务PaaS)是云计算模型之一(另外两个是软件即服务SaaS)和基础设施即服务IaaS)),其中云计算提供商提供托管的计算平台,包括操作系统、编程语言运行时、数据库和其他附加组件,如队列、日志管理和警报。他们还为您提供工具来简化部署和监视应用程序的仪表板。

Heroku 是 PaaS 提供商领域中最早的参与者之一。它支持以下编程语言:Ruby、Node.js、Java、Python、Clojure、Scala、Go 和 PHP。Heroku 支持多个数据存储,如 MySQL、MongoDB、Redis 和 Elasticsearch。它提供与日志记录工具、网络实用程序、电子邮件服务和监视工具的集成。

Heroku 提供了一个名为 heroku-cli(cli.heroku.com)的命令行工具,可用于创建 Heroku 应用程序,部署,监视,添加资源等。其 Web 仪表板提供的功能也受到 CLI 的支持。它使用 Git 存储应用程序的源代码。因此,当您将应用程序代码推送到 Heroku 的 Git 存储库时,它会触发一个构建,根据您使用的构建包进行构建。然后,它要么使用默认方式生成应用程序,要么使用ProcFile来执行您的应用程序。

在本示例中,我们将把基于 Spring Boot 的 RESTful Web 服务部署到 Heroku。我们将继续使用我们在上一个示例“为 Spring Boot 创建多个配置文件”中在另一个云提供商上创建的数据库。

准备工作

在继续在 Heroku 上部署我们的示例应用程序之前,我们需要注册 Heroku 帐户并安装其工具,这将使我们能够从命令行工作。在接下来的章节中,我们将指导您完成注册过程,通过 Web UI 创建示例应用程序,以及通过 Heroku 命令行界面(CLI)。

设置 Heroku 账户

访问www.heroku.com并注册账户。如果您已经有账户,可以登录。要注册,请访问signup.heroku.com

要登录,URL 是id.heroku.com/login

成功登录后,您将看到一个仪表板,列出了应用程序的列表,如果有的话:

从 UI 创建新应用

单击 New | Create new app,填写详细信息,然后单击 Create App:

从 CLI 创建新应用程序

执行以下步骤从 CLI 创建一个新应用程序:

  1. cli.heroku.com安装 Heroku CLI。

  2. 安装后,Heroku 应该在系统的PATH变量中。

  3. 打开命令提示符并运行heroku create。您将看到类似以下的输出:

 Creating app... done, glacial-beyond-27911
 https://glacial-beyond-27911.herokuapp.com/ |
 https://git.heroku.com/glacial-beyond-27911.git
  1. 应用程序名称是动态生成的,并创建了一个远程 Git 存储库。您可以通过运行以下命令指定应用程序名称和区域(与 UI 中所做的一样):
 $ heroku create test-app-9812 --region us
 Creating test-app-9812... done, region is us
 https://test-app-9812.herokuapp.com/ |
      https://git.heroku.com/test-app-9812.git

通过git push将部署到 Heroku 的代码推送到远程 Git 存储库。我们将在下一节中看到这一点。

我们在Chapter09/5_boot_on_heroku中有应用程序的源代码。因此,复制此应用程序,然后继续在 Heroku 上部署。

在运行 Heroku 的 cli 中的任何命令之前,您必须登录 Heroku 帐户。您可以通过运行heroku login命令来登录。

如何做...

  1. 运行以下命令创建一个 Heroku 应用程序:
 $ heroku create <app_name> -region us
  1. 在项目文件夹中初始化 Git 存储库:
 $ git init
  1. 将 Heroku Git 存储库添加为本地 Git 存储库的远程存储库:
 $ heroku git:remote -a <app_name_you_chose>
  1. 将源代码,即主分支,推送到 Heroku Git 存储库:
 $ git add .
 $ git commit -m "deploying to heroku"
 $ git push heroku master
  1. 当代码推送到 Heroku Git 存储库时,会触发构建。由于我们使用 Maven,它运行以下命令:
 ./mvnw -DskipTests clean dependency:list install
  1. 代码完成构建和部署后,您可以使用heroku open命令在浏览器中打开应用程序。

  2. 您可以使用heroku logs --tail命令监视应用程序的日志。

应用程序成功部署后,并且在运行heroku open命令后,您应该看到浏览器加载的 URL:

单击Persons链接将显示以下信息:

[
  {
    "id":1,
    "firstName":"Mohamed",
    "lastName":"Sanaulla",
    "place":"Bangalore"
  },
  {
    "id":2,
    "firstName":"Nick",
    "lastName":"Samoylov",
    "place":"USA"
  }
]

有趣的是,我们的应用程序正在 Heroku 上运行,它正在连接到 DigitalOcean 服务器上的 MySQL 数据库。我们甚至可以为 Heroku 应用程序提供数据库并连接到该数据库。在还有更多...部分了解如何执行此操作。

还有更多...

  1. 向应用程序添加新的 DB 附加组件:
 $ heroku addons:create jawsdb:kitefin

在这里,addons:create接受附加组件名称和服务计划名称,两者用冒号(:)分隔。您可以在elements.heroku.com/addons/jawsdb-maria上了解有关附加组件详细信息和计划的更多信息。此外,所有附加组件的附加组件详细信息页面末尾都提供了向应用程序添加附加组件的 Heroku CLI 命令。

  1. 打开 DB 仪表板以查看连接详细信息,如 URL、用户名、密码和数据库名称:
 $ heroku addons:open jawsdb

jawsdb仪表板看起来与以下类似:

  1. 甚至可以从JAWSDB_URL配置属性中获取 MySQL 连接字符串。您可以使用以下命令列出应用程序的配置:
 $ heroku config
 === rest-demo-on-cloud Config Vars
 JAWSDB_URL: <URL>
  1. 复制连接详细信息,在 MySQL Workbench 中创建一个新连接,并连接到此连接。数据库名称也是由附加组件创建的。连接到数据库后运行以下 SQL 语句:
        use x81mhi5jwesjewjg;
        create table person( 
          id int not null auto_increment, 
          first_name varchar(255), 
          last_name varchar(255), 
          place varchar(255), 
          primary key(id)
        );

        INSERT INTO person(first_name, last_name, place) 
        VALUES('Heroku First', 'Heroku Last', 'USA');

        INSERT INTO person(first_name, last_name, place) 
        VALUES('Jaws First', 'Jaws Last', 'UK');
  1. src/main/resources中为 Heroku 配置文件创建一个新的属性文件,名为application-heroku.properties,包含以下属性:
        spring.datasource.url=jdbc:mysql://
        <URL DB>:3306/x81mhi5jwesjewjg?useSSL=false
        spring.datasource.username=zzu08pc38j33h89q
        spring.datasource.password=<DB password>

您可以在附加仪表板中找到与连接相关的详细信息。

  1. 更新src/main/resources/application.properties文件,将spring.profiles.active属性的值替换为heroku

  2. 将更改提交并推送到 Heroku 远程:

 $ git commit -am"using heroky mysql addon"
 $ git push heroku master
  1. 部署成功后,运行heroku open命令。页面在浏览器中加载后,单击Persons链接。这次,您将看到一组不同的数据,这是我们在 Heroku 附加组件中输入的数据:
        [
          {
            "id":1,
            "firstName":"Heroku First",
            "lastName":"Heroku Last",
            "place":"USA"
          },
          {
            "id":2,
            "firstName":"Jaws First",
            "lastName":"Jaws Last",
            "place":"UK"
          }
        ]

通过这种方式,我们已经与在 Heroku 中创建的数据库集成。

使用 Docker 对 RESTful Web 服务进行容器化

我们已经从将应用程序安装在服务器上的时代发展到每个服务器都被虚拟化,然后应用程序安装在这些较小的虚拟机上。通过添加更多虚拟机来解决应用程序的可扩展性问题,使应用程序运行到负载均衡器上。

在虚拟化中,通过在多个虚拟机之间分配计算能力、内存和存储来将大型服务器划分为多个虚拟机。这样,每个虚拟机本身都能够像服务器一样完成所有这些任务,尽管规模较小。通过虚拟化,我们可以明智地利用服务器的计算、内存和存储资源。

然而,虚拟化需要一些设置,即您需要创建虚拟机,安装所需的依赖项,然后运行应用程序。此外,您可能无法 100%确定应用程序是否能够成功运行。失败的原因可能是由于不兼容的操作系统版本,甚至是由于在设置过程中遗漏了一些配置或缺少了一些依赖项。这种设置还会导致水平扩展方面的一些困难,因为在虚拟机的配置和部署应用程序方面需要花费一些时间。

使用 Puppet 和 Chef 等工具确实有助于配置,但是应用程序的设置往往会导致由于缺少或不正确的配置而出现问题。这导致了另一个概念的引入,称为容器化。

在虚拟化世界中,我们有主机操作系统,然后是虚拟化软件,也就是 hypervisor。然后我们会创建多个机器,每台机器都有自己的操作系统,可以在上面部署应用程序。然而,在容器化中,我们不会划分服务器的资源。相反,我们有带有主机操作系统的服务器,然后在其上方有一个容器化层,这是一个软件抽象层。我们将应用程序打包为容器,其中容器只打包了运行应用程序所需的足够操作系统功能、应用程序的软件依赖项,以及应用程序本身。以下图片最好地描述了这一点:docs.docker.com/get-started/#container-diagram

前面的图片说明了典型的虚拟化系统架构。以下图片说明了典型的容器化系统架构:

容器化的最大优势在于将应用程序的所有依赖项捆绑到一个容器映像中。然后在容器化平台上运行此映像,从而创建一个容器。我们可以在服务器上同时运行多个容器。如果需要添加更多实例,我们只需部署映像,这种部署可以自动化以支持高可伸缩性。

Docker 是一种流行的软件容器化平台。在本示例中,我们将把位于Chapter09/6_boot_with_docker位置的示例应用程序打包成 Docker 映像,并运行 Docker 映像以启动我们的应用程序。

准备工作

对于此示例,我们将使用运行 Ubuntu 16.04.2 x64 的 Linux 服务器:

  1. download.docker.com/linux/ubuntu/dists/xenial/pool/stable/amd64/下载最新的.deb文件。对于其他 Linux 发行版,您可以在download.docker.com/linux/找到软件包:
$ wget https://download.docker.com/linux/ubuntu/dists/xenial
 /pool/stable/amd64/docker-ce_17.03.2~ce-0~ubuntu-xenial_amd64.deb
  1. 使用dpkg软件包管理器安装 Docker 软件包:
 $  sudo dpkg -i docker-ce_17.03.2~ce-0~ubuntu-xenial_amd64.deb

包的名称将根据您下载的版本而变化。

  1. 安装成功后,Docker 服务开始运行。您可以使用service命令验证这一点:
 $ service docker status
 docker.service - Docker Application Container Engine
 Loaded: loaded (/lib/systemd/system/docker.service; enabled;
        vendor preset: enabled)
 Active: active (running) since Fri 2017-07-28 13:46:50 UTC;
              2min 3s ago
 Docs: https://docs.docker.com
 Main PID: 22427 (dockerd)

要 docker 化的应用程序位于Chapter09/6_boot_with_docker,在下载本书的源代码时可获得。

如何做...

  1. 在应用程序的根目录创建Dockerfile,内容如下:
 FROM ubuntu:17.10
      FROM openjdk:9-b177-jdk
 VOLUME /tmp
 ADD target/boot_docker-1.0.jar restapp.jar
 ENV JAVA_OPTS="-Dspring.profiles.active=cloud"
      ENTRYPOINT [ "sh", "-c", "java $JAVA_OPTS -jar /restapp.jar" ]
  1. 运行以下命令使用我们在前面步骤中创建的Dockerfile构建 Docker 映像:
 $ docker build --tag restapp-image .

 Sending build context to Docker daemon 18.45 MB
 Step 1/6 : FROM ubuntu:17.10
 ---> c8cdcb3740f8
 Step 2/6 : FROM openjdk:9-b177-jdk
 ---> 38d822ff5025
 Step 3/6 : VOLUME /tmp
 ---> Using cache
 ---> 38367613d375
 Step 4/6 : ADD target/boot_docker-1.0.jar restapp.jar
 ---> Using cache
 ---> 54ad359f53f7
 Step 5/6 : ENV JAVA_OPTS "-Dspring.profiles.active=cloud"
 ---> Using cache
 ---> dfa324259fb1
 Step 6/6 : ENTRYPOINT sh -c java $JAVA_OPTS -jar /restapp.jar
 ---> Using cache
 ---> 6af62bd40afe
 Successfully built 6af62bd40afe
  1. 您可以使用以下命令查看已安装的映像:
 $ docker images

      REPOSITORY     TAG        IMAGE ID     CREATED     SIZE
 restapp-image  latest     6af62bd40afe 4 hours ago 606 MB
 openjdk        9-b177-jdk 38d822ff5025 6 days ago  588 MB
 ubuntu         17.10      c8cdcb3740f8 8 days ago  93.9 MB

您会看到还有 OpenJDK 和 Ubuntu 映像。这些是下载用于构建我们应用程序的映像的。首先列出的是我们的应用程序。

  1. 运行映像以创建包含我们正在运行的应用程序的容器:
 docker run -p 8090:8080 -d --name restapp restapp-image
 d521b9927cec105d8b69995ef6d917121931c1d1f0b1f4398594bd1f1fcbee55

run命令之后打印的长字符串是容器的标识符。您可以使用前几个字符来唯一标识容器。或者,您可以使用容器名称restapp

  1. 应用程序已经启动。您可以通过运行以下命令查看日志:
 docker logs restapp
  1. 您可以使用以下命令查看已创建的 Docker 容器:
 docker ps

前面命令的输出类似于以下内容:

  1. 您可以使用以下命令管理容器:
 $ docker stop restapp
 $ docker start restapp

应用程序运行后,打开http://<hostname>:8090/api/persons

它是如何工作的...

通过定义Dockerfile来定义容器结构和其内容。Dockerfile遵循一种结构,其中每一行都是INSTRUCTION arguments的形式。有一组预定义的指令,即FROMRUNCMDLABELENVADDCOPY。完整的列表可以在docs.docker.com/engine/reference/builder/#from上找到。让我们看看我们定义的Dockerfile

FROM ubuntu:17.10
FROM openjdk:9-b177-jdk
VOLUME /tmp
ADD target/boot_docker-1.0.jar restapp.jar
ENV JAVA_OPTS="-Dspring.profiles.active=cloud"
ENTRYPOINT [ "sh", "-c", "java $JAVA_OPTS -jar /restapp.jar" ]

使用FROM指令的前两行指定了我们的 Docker 镜像的基础镜像。我们使用 Ubuntu OS 镜像作为基础镜像,然后将其与 OpenJDK 9 镜像结合在一起。VOLUME指令用于指定镜像的挂载点。这通常是主机操作系统中的路径。

ADD指令用于将文件从源复制到工作目录下的目标目录。ENV指令用于定义环境变量。

ENTRYPOINT指令用于配置容器以作为可执行文件运行。对于此指令,我们传递一个参数数组,否则我们将直接从命令行执行。在我们的场景中,我们使用 bash shell 来运行java -$JAVA_OPTS -jar <jar name>

一旦我们定义了Dockerfile,我们就指示 Docker 工具使用Dockerfile构建一个镜像。我们还使用--tag选项为镜像提供一个名称。构建我们的应用程序镜像时,它将下载所需的基础镜像,这在我们的情况下是 Ubuntu 和 OpenJDK 镜像。因此,如果列出 Docker 镜像,您将看到基础镜像以及我们的应用程序镜像。

这个 Docker 镜像是一个可重用的实体。如果我们需要更多的应用程序实例,我们可以使用docker run命令生成一个新的容器。当我们运行 Docker 镜像时,有多个选项,其中一个是-p选项,它将容器内的端口映射到主机操作系统。在我们的情况下,我们将 Spring Boot 应用程序的8080端口映射到主机操作系统的8090端口。

现在,要检查我们运行的应用程序的状态,我们可以使用docker logs restapp来检查日志。除此之外,docker工具支持多个命令。强烈建议运行docker help并探索支持的命令。

Docker 是 Docker 容器化平台背后的公司,它创建了一组基础镜像,可以用来创建容器。有用于 MySQL DB、Couchbase、Ubuntu 和其他操作系统的镜像。您可以在store.docker.com/上探索这些软件包。

使用 Micrometer 和 Prometheus 监控 Spring Boot 2 应用程序

监控和收集性能指标是应用程序开发和维护的重要部分。人们对内存使用情况、各个端点的响应时间、CPU 使用情况、机器负载、垃圾收集频率和暂停等指标感兴趣。有不同的方法来启用捕获指标,例如使用 Dropwizard Metrics(metrics.dropwizard.io/4.0.0/)或 Spring Boot 的度量框架。

Spring Boot 2 及更高版本中的代码仪器是使用一个名为 Micrometer 的库(micrometer.io/)来完成的。Micrometer 提供了一个供应商中立的代码仪器,这样您就可以使用任何监控工具,并且 Micrometer 以工具理解的格式提供度量数据。这就像 SLF4J 用于日志记录一样。它是对以供应商中立方式产生输出的度量端点的外观。

Micrometer 支持诸如 Prometheus (prometheus.io/)、Netflix Atlas (github.com/Netflix/atlas)、Datadog (www.datadoghq.com/)以及即将支持的 InfluxDB (www.influxdata.com/)、statsd (github.com/etsy/statsd)和 Graphite (graphiteapp.org/)等工具。使用早期版本的 Spring Boot,如 1.5,的应用程序也可以使用这个新的仪表化库,如还有更多...部分所示。

在本食谱中,我们将使用 Micrometer 来为我们的代码进行仪表化,并将指标发送到 Prometheus。因此,首先,我们将从准备工作部分开始设置 Prometheus。

准备工作

Prometheus (prometheus.io/)是一个监控系统和时间序列数据库,允许我们存储时间序列数据,其中包括应用程序随时间变化的指标,一种简单的可视化指标的方法,或者在不同指标上设置警报。

让我们执行以下步骤在我们的机器上运行 Prometheus(在我们的情况下,我们将在 Windows 上运行。类似的步骤也适用于 Linux):

  1. github.com/prometheus/prometheus/releases/download/v2.3.2/prometheus-2.3.2.windows-amd64.tar.gz下载 Prometheus 分发版。

  2. 在 Windows 上使用 7-Zip (www.7-zip.org/)将其提取到一个我们将称为PROMETHEUS_HOME的位置。

  3. %PROMETHEUS_HOME%添加到您的 PATH 变量(在 Linux 上,它将是$PROMETHEUS_HOME到 PATH 变量)。

  4. 使用prometheus --config "%PROMETHEUS_HOME%/prometheus.yml"命令运行 Prometheus。您将看到以下输出:

  1. 在浏览器中打开http://localhost:9090,以查看 Prometheus 控制台。在空文本框中输入go_gc_duration_seconds,然后单击执行按钮以显示捕获的指标。您可以切换到图形版本的选项卡以可视化数据:

前面的指标是用于 Prometheus 本身的。您可以导航到http://localhost:9090/targets以查找 Promethues 监视的目标,如下所示:

当您在浏览器中打开http://localhost:9090/metrics时,您将看到当前时间点的指标值。没有可视化很难理解。这些指标在随时间收集并使用图表可视化时非常有用。

现在,我们已经启动并运行了 Prometheus。让我们启用 Micrometer 和以 Prometheus 理解的格式发布指标。为此,我们将重用本章中与数据库交互食谱中使用的代码。此食谱位于Chapter09/2_boot_db_demo。因此,我们将只需将相同的代码复制到Chapter09/7_boot_micrometer,然后增强部分以添加对 Micrometer 和 Prometheus 的支持,如下一节所示。

如何做...

  1. 更新pom.xml以包括 Spring boot 执行器和 Micrometer Prometheus 注册表依赖项:
<dependency>
  <groupId>org.springframework.boot</groupId>
  <artifactId>spring-boot-starter-actuator</artifactId>
</dependency>
<dependency>
  <groupId>io.micrometer</groupId>
  <artifactId>micrometer-registry-prometheus</artifactId>
  <version>1.0.6</version>
</dependency>

在 Spring Boot 2 及更高版本中,Micrometer 已配置了执行器,因此我们只需要将执行器作为依赖项添加,然后micrometer-registry-prometheus依赖项会生成一个 Prometheus 理解的指标表示。

  1. 当我们运行应用程序(一种方式是运行mvn spring-boot:run)并打开执行器端点时,默认情况下将是<root_url>/actuator。我们会发现默认情况下有一些执行器端点可用,但 Prometheus 指标端点不是其中的一部分:

  1. 要在执行器中启用 Prometheus 端点,我们需要在src/main/resources/application.properties文件中添加以下属性:
management.endpoints.web.exposure.include=prometheus
  1. 重新启动应用程序并浏览http://localhost:8080/actuator/。现在,您会发现只有 Prometheus 端点可用:

  1. 打开http://localhost:8080/actuator/prometheus以查看 Prometheus 理解的格式中的指标:

  1. 配置 Prometheus 以在特定频率下调用http://localhost:8080/actuator/prometheus,可以进行配置。这可以通过在%PROMETHEUS_HOME%/prometheus.yml配置文件中在scrape_configs属性下更新新作业来完成:
- job_name: 'spring_apps'
    metrics_path: '/actuator/prometheus'
    static_configs:
      - targets: ['localhost:8080']

您会看到,默认情况下,有一个作业来抓取 Prometheus 指标本身。

  1. 重新启动 Prometheus 服务器并访问http://localhost:9090/targets。您将看到一个新的部分spring_apps,其中包含我们添加的目标:

  1. 我们可以通过访问http://localhost:9090/graph,在文本框中输入jvm_memory_max_bytes,然后单击执行来绘制从指标捕获的指标的图表:

因此,我们最终设置了在 Prometheus 中摄取指标并根据指标值创建图表。

它是如何工作的...

Spring Boot 提供了一个名为执行器的库,具有在部署到生产环境时帮助您监视和管理应用程序的功能。这种开箱即用的功能不需要开发人员进行任何设置。因此,您可以在没有任何工作的情况下进行审计、健康检查和指标收集。

如前所述,执行器使用 Micrometer 从代码中进行仪表化和捕获不同的指标,例如:

  • JVM 内存使用情况

  • 连接池信息

  • 应用程序中不同 HTTP 端点的响应时间

  • 不同 HTTP 端点调用的频率

要使应用程序具有这些生产就绪的功能,如果您使用 Maven,需要将以下依赖项添加到您的pom.xml中(Gradle 也有相应的依赖项):

<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-actuator</artifactId>
</dependency>

默认情况下,执行器位于/actuator端点,但可以通过在src/main/resources/application.properties文件中覆盖management.endpoints.web.base-path属性来配置不同的值,如下所示:

management.endpoints.web.base-path=/metrics

默认情况下,除了/shutdown端点之外,所有用于监视和审计应用程序的端点都是默认启用的。默认情况下禁用此端点。此端点用于关闭应用程序。以下是一些可用的端点:

auditevents 公开当前应用程序的审计事件信息
beans 显示应用程序中所有 Spring bean 的完整列表
env 公开 Spring 的ConfigurableEnvironment中的属性
health 显示应用程序健康信息
info 显示任意应用程序信息
metrics 显示当前应用程序的指标信息
mappings 显示所有@RequestMapping路径的汇总列表
prometheus 以 Prometheus 服务器可以抓取的格式公开指标

您会发现这些是非常敏感的端点,需要进行安全保护。好消息是,Spring Boot 执行器与 Spring Security 很好地集成,以保护这些端点。因此,如果 Spring Security 在类路径上,它将默认安全地保护这些端点。

这些端点可以通过 JMX 或通过 Web 访问。默认情况下,并非所有执行器端点都启用了 Web 访问,而是默认情况下启用了使用 JMX 访问。默认情况下,只有以下属性可以通过 Web 访问:

  • health

  • info

这就是我们不得不添加以下配置属性的原因,以便在 Web 上提供 Prometheus 端点,以及健康、信息和指标:

management.endpoints.web.exposure.include=prometheus,health,info,metrics

即使我们启用了 Prometheus,我们也需要在类路径上有micrometer-registry-prometheus库。只有这样,我们才能以 Prometheus 的格式查看指标。因此,我们将以下依赖项添加到我们的 pom 中:

<dependency>
  <groupId>io.micrometer</groupId>
  <artifactId>micrometer-registry-prometheus</artifactId>
  <version>1.0.6</version>
</dependency>

Prometheus 处理的输出格式很简单:它以<property_name value>的形式接收,每个属性都在新的一行中。Spring Boot 执行器不会将指标推送到 Prometheus;相反,我们配置 Prometheus 以在其配置中定义的频率从给定 URL 拉取指标。Prometheus 的默认配置,可在其主目录中找到,如下所示:

# my global config
global:
  scrape_interval: 15s # Set the scrape interval to every 15 seconds. Default is every 1 minute.
  evaluation_interval: 15s # Evaluate rules every 15 seconds. The default is every 1 minute.
  # scrape_timeout is set to the global default (10s).

# Alertmanager configuration
alerting:
  alertmanagers:
  - static_configs:
    - targets:
      # - alertmanager:9093

# Load rules once and periodically evaluate them according to the global 'evaluation_interval'.
rule_files:
  # - "first_rules.yml"
  # - "second_rules.yml"

# A scrape configuration containing exactly one endpoint to scrape:
# Here it's Prometheus itself.
scrape_configs:
  # The job name is added as a label `job=<job_name>` to any timeseries scraped from this config.
  - job_name: 'prometheus'

    # metrics_path defaults to '/metrics'
    # scheme defaults to 'http'.

    static_configs:
    - targets: ['localhost:9090']

因此,它配置了 Prometheus 将获取指标的间隔和评估rule_files下定义的规则的间隔的默认值。Scrape 是从scrape_configs选项下定义的不同目标中拉取指标的活动,而 evaluate 是评估rule_files中定义的不同规则的行为。为了使 Prometheus 能够从我们的 Spring Boot 应用程序中抓取指标,我们通过提供作业名称、相对于应用程序 URL 的指标路径和应用程序的 URL,在scrape_configs下添加了一个新的作业:

- job_name: 'spring_apps'
    metrics_path: '/actuator/prometheus'
    static_configs:
      - targets: ['localhost:8080']

我们还看到了如何从http://localhost:9090/graph查看这些指标的值,以及如何使用 Prometheus 提供的简单图形支持来可视化这些指标。

还有更多

通过配置另一个名为 Alertmanager 的服务(prometheus.io/docs/alerting/alertmanager/),可以在 Prometheus 中启用警报。该服务可用于向电子邮件、寻呼机等发送警报。

Prometheus 中的图形支持是天真的。您可以使用 Grafana(grafana.com/),这是一种领先的开源软件,用于分析时间序列数据,例如存储在 Prometheus 中的数据。通过这种方式,您可以配置 Grafana 从 Prometheus 读取时间序列数据,并构建具有预定义指标的不同类型图表的仪表板。

第十章:网络

在本章中,我们将介绍以下示例:

  • 发出 HTTP GET 请求

  • 发出 HTTP POST 请求

  • 为受保护的资源发出 HTTP 请求

  • 发出异步 HTTP 请求

  • 使用 Apache HttpClient 发出 HTTP 请求

  • 使用 Unirest HTTP 客户端库发出 HTTP 请求

介绍

Java 对与 HTTP 特定功能进行交互的支持非常原始。自 JDK 1.1 以来可用的HttpURLConnection类提供了与具有 HTTP 特定功能的 URL 进行交互的 API。由于此 API 甚至在 HTTP/1.1 之前就存在,它缺乏高级功能,并且使用起来很麻烦。这就是为什么开发人员大多倾向于使用第三方库,如Apache HttpClient、Spring 框架和 HTTP API。

在 JDK 9 中,引入了一个新的 HTTP 客户端 API,作为孵化器模块的一部分,名称为java.net.http,在 JEP 321 (openjdk.java.net/jeps/321)下被提升为标准模块,这是最新的 JDK 11 版本的一部分。

关于孵化器模块的说明:孵化器模块包含非最终 API,这些 API 非常庞大,不够成熟,无法包含在 Java SE 中。这是 API 的一种测试版发布,使开发人员能够更早地使用 API。但问题是,这些 API 在较新版本的 JDK 中没有向后兼容性支持。这意味着依赖于孵化器模块的代码可能会在较新版本的 JDK 中出现问题。这可能是因为孵化器模块被提升为 Java SE 或在孵化器模块中被悄悄删除。

在本章中,我们将介绍如何在 JDK 11 中使用 HTTP 客户端 API,并介绍一些其他 API,这些 API 使用了 Apache HttpClient (hc.apache.org/httpcomponents-client-ga/) API 和 Unirest Java HTTP 库 (unirest.io/java.html)。

发出 HTTP GET 请求

在本示例中,我们将使用 JDK 11 的 HTTP 客户端 API 发出对httpbin.org/getGET请求。

如何做...

  1. 使用其构建器java.net.http.HttpClient.Builder创建java.net.http.HttpClient的实例:
        HttpClient client = HttpClient.newBuilder().build();
  1. 使用其构建器java.net.http.HttpRequest.Builder创建java.net.http.HttpRequest的实例。请求的 URL 应该作为java.net.URI的实例提供:
        HttpRequest request = HttpRequest
                    .newBuilder(new URI("http://httpbin.org/get"))
                    .GET()
                    .version(HttpClient.Version.HTTP_1_1)
                    .build();
  1. 使用java.net.http.HttpClientsend API 发送 HTTP 请求。此 API 需要一个java.net.http.HttpRequest实例和一个java.net.http.HttpResponse.BodyHandler的实现:
        HttpResponse<String> response = client.send(request,
                             HttpResponse.BodyHandlers.ofString());
  1. 打印java.net.http.HttpResponse状态码和响应体:
        System.out.println("Status code: " + response.statusCode());
        System.out.println("Response Body: " + response.body());

此代码的完整代码可以在Chapter10/1_making_http_get中找到。您可以使用运行脚本run.batrun.sh来编译和运行代码:

工作原理...

在向 URL 发出 HTTP 调用时有两个主要步骤:

  • 创建 HTTP 客户端以发起调用

  • 设置目标 URL、所需的 HTTP 标头和 HTTP 方法类型,即GETPOSTPUT

java.net.http.HttpClient with the default configuration:
HttpClient client = HttpClient.newHttpClient();
java.net.http.HttpClient:
HttpClient client = HttpClient
                    .newBuilder()
                    //redirect policy for the client. Default is NEVER
                    .followRedirects(HttpClient.Redirect.ALWAYS) 
                    //HTTP client version. Defabult is HTTP_2
                    .version(HttpClient.Version.HTTP_1_1)
                    //few more APIs for more configuration
                    .build();

在构建器中还有更多的 API,例如用于设置身份验证、代理和提供 SSL 上下文,我们将在不同的示例中进行讨论。

java.net.http.HttpRequest:
HttpRequest request = HttpRequest
                .newBuilder()
                .uri(new URI("http://httpbin.org/get")
                .headers("Header 1", "Value 1", "Header 2", "Value 2")
                .timeout(Duration.ofMinutes(5))
                .version(HttpClient.Version.HTTP_1_1)
                .GET()
                .build();

java.net.http.HttpClient对象提供了两个 API 来发出 HTTP 调用:

  • 您可以使用HttpClient#send()方法同步发送

  • 您可以使用HttpClient#sendAsync()方法异步发送

send()方法接受两个参数:HTTP 请求和 HTTP 响应的处理程序。响应的处理程序由java.net.http.HttpResponse.BodyHandlers接口的实现表示。有一些可用的实现,例如ofString(),它将响应体读取为String,以及ofByteArray(),它将响应体读取为字节数组。我们将使用ofString()方法,它将响应Body作为字符串返回:

HttpResponse<String> response = client.send(request,
                                HttpResponse.BodyHandlers.ofString());

java.net.http.HttpResponse的实例表示来自 HTTP 服务器的响应。它提供以下 API:

  • 获取响应体(body()

  • HTTP 头(headers()

  • 初始 HTTP 请求(request()

  • 响应状态码(statusCode()

  • 用于请求的 URL(uri()

传递给send()方法的HttpResponse.BodyHandlers实现有助于将 HTTP 响应转换为兼容格式,例如Stringbyte数组。

发出 HTTP POST 请求

在本示例中,我们将查看通过请求体将一些数据发布到 HTTP 服务。我们将把数据发布到http://httpbin.org/post的 URL。

我们将跳过类的包前缀,因为假定为java.net.http

如何做...

  1. 使用其HttpClient.Builder构建器创建HttpClient的实例:
        HttpClient client = HttpClient.newBuilder().build();
  1. 创建要传递到请求体中的所需数据:
        Map<String, String> requestBody = 
                    Map.of("key1", "value1", "key2", "value2");
  1. 创建一个HttpRequest对象,请求方法为 POST,并提供请求体数据作为String。我们将使用 Jackson 的ObjectMapper将请求体Map<String, String>转换为纯 JSONString,然后使用HttpRequest.BodyPublishers处理String请求体:
        ObjectMapper mapper = new ObjectMapper();
        HttpRequest request = HttpRequest
                   .newBuilder(new URI("http://httpbin.org/post"))
                   .POST(
          HttpRequest.BodyPublishers.ofString(
            mapper.writeValueAsString(requestBody)
          )
        )
        .version(HttpClient.Version.HTTP_1_1)
        .build();
  1. 使用send(HttpRequest, HttpRequest.BodyHandlers)方法发送请求并获取响应:
        HttpResponse<String> response = client.send(request, 
                             HttpResponse.BodyHandlers.ofString());
  1. 然后我们打印服务器发送的响应状态码和响应体:
        System.out.println("Status code: " + response.statusCode());
        System.out.println("Response Body: " + response.body());

此代码的完整代码可以在Chapter10/2_making_http_post中找到。确保在Chapter10/2_making_http_post/mods中有以下 Jackson JARs:

  • jackson.databind.jar

  • jackson.core.jar

  • jackson.annotations.jar

还要注意Chapter10/2_making_http_post/src/http.client.demo中可用的模块定义module-info.java

要了解此模块化代码中如何使用 Jackson JAR,请参阅第三章中的自下而上迁移自上而下迁移示例,模块化编程

运行脚本run.batrun.sh,用于简化代码的编译和执行:

发出对受保护资源的 HTTP 请求

在本示例中,我们将查看调用已由用户凭据保护的 HTTP 资源。httpbin.org/basic-auth/user/passwd已通过 HTTP 基本身份验证进行了保护。基本身份验证需要提供明文用户名和密码,然后 HTTP 资源使用它来决定用户身份验证是否成功。

如果您在浏览器中打开httpbin.org/basic-auth/user/passwd,它将提示您输入用户名和密码:

将用户名输入为user,密码输入为passwd,您将被验证并显示 JSON 响应:

{
  "authenticated": true,
  "user": "user"
}

让我们使用HttpClient API 实现相同的功能。

如何做...

  1. 我们需要扩展java.net.Authenticator并重写其getPasswordAuthentication()方法。此方法应返回java.net.PasswordAuthentication的实例。让我们创建一个类UsernamePasswordAuthenticator,它扩展java.net.Authenticator
        public class UsernamePasswordAuthenticator 
          extends Authenticator{
        }
  1. 我们将在UsernamePasswordAuthenticator类中创建两个实例变量来存储用户名和密码,并提供一个构造函数来初始化它:
        private String username;
        private String password;

        public UsernamePasswordAuthenticator(){}
        public UsernamePasswordAuthenticator ( String username, 
                                               String password){
          this.username = username;
          this.password = password;
        }
  1. 然后我们将重写getPasswordAuthentication()方法,返回一个用用户名和密码初始化的java.net.PasswordAuthentication的实例:
        @Override
        protected PasswordAuthentication getPasswordAuthentication(){
          return new PasswordAuthentication(username, 
                                            password.toCharArray());
        }
  1. 然后我们将创建一个UsernamePasswordAuthenticator的实例:
        String username = "user";
        String password = "passwd"; 
        UsernamePasswordAuthenticator authenticator = 
                new UsernamePasswordAuthenticator(username, password);
  1. 在初始化HttpClient时,我们提供UsernamePasswordAuthenticator的实例:
        HttpClient client = HttpClient.newBuilder()
                                      .authenticator(authenticator)
                                      .build();
  1. 创建一个对受保护的 HTTP 资源httpbin.org/basic-auth/user/passwdHttpRequest对象:
        HttpRequest request = HttpRequest.newBuilder(new URI(
          "http://httpbin.org/basic-auth/user/passwd"
        ))
        .GET()
        .version(HttpClient.Version.HTTP_1_1)
        .build();
  1. 我们通过执行请求来获取HttpResponse,并打印状态码和请求体:
        HttpResponse<String> response = client.send(request,
        HttpResponse.BodyHandlers.ofString());

        System.out.println("Status code: " + response.statusCode());
        System.out.println("Response Body: " + response.body());

这个配方的完整代码可以在Chapter10/3_making_http_request_protected_res中找到。您可以使用run.batrun.sh脚本来运行代码:

它是如何工作的...

Authenticator对象被网络调用使用来获取认证信息。开发人员通常扩展java.net.Authenticator类,并重写其getPasswordAuthentication()方法。用户名和密码要么从用户输入中读取,要么从配置中读取,并由扩展类用来创建java.net.PasswordAuthentication的实例。

在这个配方中,我们创建了java.net.Authenticator的扩展,如下所示:

public class UsernamePasswordAuthenticator 
  extends Authenticator{
    private String username;
    private String password;

    public UsernamePasswordAuthenticator(){}

    public UsernamePasswordAuthenticator ( String username, 
                                           String password){
        this.username = username;
        this.password = password;
    }

    @Override
    protected PasswordAuthentication getPasswordAuthentication(){
      return new PasswordAuthentication(username, 
                         password.toCharArray());
    }
}

然后将UsernamePasswordAuthenticator的实例提供给HttpClient.BuilderAPI。HttpClient实例利用这个验证器来获取用户名和密码,同时调用受保护的 HTTP 请求。

进行异步 HTTP 请求

在这个配方中,我们将看看如何进行异步的GET请求。在异步请求中,我们不等待响应;相反,我们在客户端接收到响应时处理响应。在 jQuery 中,我们将进行异步请求,并提供一个回调来处理响应,而在 Java 的情况下,我们会得到一个java.util.concurrent.CompletableFuture的实例,然后我们调用thenApply方法来处理响应。让我们看看它是如何工作的。

如何做...

  1. 使用其构建器HttpClient.Builder创建HttpClient的实例:
        HttpClient client = HttpClient.newBuilder().build();
  1. 使用HttpRequest.Builder的构建器创建HttpRequest的实例,表示要使用的 URL 和相应的 HTTP 方法:
        HttpRequest request = HttpRequest
                        .newBuilder(new URI("http://httpbin.org/get"))
                        .GET()
                        .version(HttpClient.Version.HTTP_1_1)
                        .build();
  1. 使用sendAsync方法进行异步 HTTP 请求,并保留我们获取的CompletableFuture<HttpResponse<String>>对象的引用。我们将使用这个对象来处理响应:
        CompletableFuture<HttpResponse<String>> responseFuture = 
                  client.sendAsync(request, 
                         HttpResponse.BodyHandlers.ofString());
  1. 我们提供CompletionStage来处理响应,一旦前一个阶段完成。为此,我们使用thenAccept方法,它接受一个 lambda 表达式:
        CompletableFuture<Void> processedFuture = 
                   responseFuture.thenAccept(response -> {
          System.out.println("Status code: " + response.statusCode());
          System.out.println("Response Body: " + response.body());
        });
  1. 等待未来完成:
        CompletableFuture.allOf(processedFuture).join();

这个配方的完整代码可以在Chapter10/4_async_http_request中找到。我们提供了run.batrun.sh脚本来编译和运行这个配方:

使用 Apache HttpClient 进行 HTTP 请求

在这个配方中,我们将使用 Apache HttpClient (hc.apache.org/httpcomponents-client-4.5.x/index.html)库来进行简单的 HTTP GET请求。由于我们使用的是 Java 9,我们希望使用模块路径而不是类路径。因此,我们需要将 Apache HttpClient 库模块化。实现这一目标的一种方法是使用自动模块的概念。让我们看看如何为这个配方设置依赖关系。

准备就绪

所有必需的 JAR 文件已经存在于Chapter10/5_apache_http_demo/mods中:

一旦这些 JAR 文件在模块路径上,我们可以在module-info.java中声明对这些 JAR 文件的依赖关系,该文件位于Chapter10/5_apache_http_demo/src/http.client.demo中,如下面的代码片段所示:

module http.client.demo{
  requires httpclient;
  requires httpcore;
  requires commons.logging;
  requires commons.codec;
}

如何做...

  1. 使用其org.apache.http.impl.client.HttpClients工厂创建org.http.client.HttpClient的默认实例:
        CloseableHttpClient client = HttpClients.createDefault();
  1. 创建org.apache.http.client.methods.HttpGet的实例以及所需的 URL。这代表了 HTTP 方法类型和请求的 URL:
        HttpGet request = new HttpGet("http://httpbin.org/get");
  1. 使用HttpClient实例执行 HTTP 请求以获取CloseableHttpResponse的实例:
        CloseableHttpResponse response = client.execute(request);

执行 HTTP 请求后返回的CloseableHttpResponse实例可用于获取响应状态码和嵌入在HttpEntity实现实例中的响应内容等详细信息。

  1. 我们使用EntityUtils.toString()来获取嵌入在HttpEntity实现实例中的响应体,并打印状态码和响应体:
        int statusCode = response.getStatusLine().getStatusCode();
        String responseBody = 
                       EntityUtils.toString(response.getEntity());
        System.out.println("Status code: " + statusCode);
        System.out.println("Response Body: " + responseBody);

此示例的完整代码可以在Chapter10/5_apache_http_demo中找到。我们提供了run.batrun.sh来编译和执行示例代码:

还有更多...

在调用HttpClient.execute方法时,我们可以提供自定义的响应处理程序,如下所示:

String responseBody = client.execute(request, response -> {
  int status = response.getStatusLine().getStatusCode();
  HttpEntity entity = response.getEntity();
  return entity != null ? EntityUtils.toString(entity) : null;
});

在这种情况下,响应由响应处理程序处理并返回响应体字符串。完整的代码可以在Chapter10/5_1_apache_http_demo_response_handler中找到。

使用 Unirest HTTP 客户端库进行 HTTP 请求

在本示例中,我们将使用 Unirest HTTP (unirest.io/java.html) Java 库来访问 HTTP 服务。Unirest Java 是一个基于 Apache 的 HTTP 客户端库的库,并提供了一个流畅的 API 来进行 HTTP 请求。

准备工作

由于 Java 库不是模块化的,我们将利用自动模块的概念,如第三章 模块化编程中所解释的。该库的 JAR 文件被放置在应用程序的模块路径上,然后应用程序通过使用 JAR 的名称作为其模块名称来声明对 JAR 的依赖关系。这样,JAR 文件就会自动成为一个模块,因此被称为自动模块。

Java 库的 Maven 依赖如下:

<dependency>
  <groupId>com.mashape.unirest</groupId>
  <artifactId>unirest-java</artifactId>
  <version>1.4.9</version>
</dependency>

由于我们的示例中没有使用 Maven,我们已经将 JAR 文件下载到了Chapter10/6_unirest_http_demo/mods文件夹中。

模块定义如下:

module http.client.demo{
  requires httpasyncclient;
  requires httpclient;
  requires httpmime;
  requires json;
  requires unirest.java;
  requires httpcore;
  requires httpcore.nio;
  requires commons.logging;
  requires commons.codec;
}

操作步骤如下...

Unirest 提供了一个非常流畅的 API 来进行 HTTP 请求。我们可以按如下方式进行GET请求:

HttpResponse<JsonNode> jsonResponse = 
  Unirest.get("http://httpbin.org/get")
         .asJson();

可以从jsonResponse对象中获取响应状态和响应体:

int statusCode = jsonResponse.getStatus();
JsonNode jsonBody = jsonResponse.getBody();

我们可以进行POST请求并传递一些数据,如下所示:

jsonResponse = Unirest.post("http://httpbin.org/post")
                      .field("key1", "val1")
                      .field("key2", "val2")
                      .asJson();

我们可以按如下方式调用受保护的 HTTP 资源:

jsonResponse = Unirest.get("http://httpbin.org/basic-auth/user/passwd")
                      .basicAuth("user", "passwd")
                      .asJson();

该代码可以在Chapter10/6_unirest_http_demo中找到。

我们提供了run.batrun.sh脚本来执行代码。

还有更多...

Unirest Java 库提供了更多高级功能,例如进行异步请求、文件上传和使用代理。建议您尝试该库的不同功能。

第十一章:内存管理和调试

在本章中,我们将涵盖以下内容:

  • 了解 G1 垃圾收集器

  • JVM 的统一日志记录

  • 使用jcmd命令进行 JVM

  • 使用 try-with-resources 来更好地处理资源

  • 为了改进调试,堆栈遍历

  • 使用内存感知的编码风格

  • 更好地使用内存的最佳实践

  • 了解 Epsilon,一种低开销的垃圾收集器

介绍

内存管理是程序执行的内存分配过程,以及一些分配的内存不再使用后的内存重用。在 Java 中,这个过程被称为垃圾收集GC)。GC 的有效性影响两个主要应用特性——响应性和吞吐量。

响应性是指应用程序对请求的快速响应程度。例如,一个网站返回页面的速度,或者桌面应用程序对事件的快速响应。自然地,响应时间越短,用户体验就越好,这是许多应用程序的目标。

吞吐量表示应用程序在单位时间内可以完成的工作量。例如,一个 Web 应用程序可以提供多少请求,或者一个数据库可以支持多少交易。数字越大,应用程序可能产生的价值就越大,可以容纳的用户数量也越多。

并非每个应用程序都需要具有最小的响应性和最大的可实现吞吐量。一个应用程序可能是异步提交并执行其他操作,不需要太多用户交互。可能也只有少数潜在的应用程序用户,因此低于平均水平的吞吐量可能已经足够了。然而,有些应用程序对这些特性中的一个或两个有很高的要求,并且无法容忍 GC 过程带来的长时间暂停。

另一方面,GC 需要偶尔停止任何应用程序执行,重新评估内存使用情况,并释放不再使用的数据。这些 GC 活动期间被称为停止-世界。它们持续的时间越长,GC 完成工作的速度越快,应用程序冻结的时间就越长,最终可能会足够大到影响应用程序的响应性和吞吐量。如果情况如此,GC 调优和 JVM 优化变得重要,并需要理解 GC 原则及其现代实现。

不幸的是,程序员经常忽略了这一步。试图改进响应性和/或吞吐量,他们只是增加了内存和其他计算能力,从而为最初较小的现有问题提供了增长的空间。扩大的基础设施,除了硬件和软件成本外,还需要更多的人来维护,最终证明需要建立一个专门的组织来维护系统。到那时,问题已经达到了几乎无法解决的规模,并且通过迫使他们为其余的职业生涯做例行的——几乎是琐碎的——工作,滋养了那些创造它的人。

在本章中,我们将重点关注Garbage-FirstG1)垃圾收集器,这是自 Java 9 以来的默认收集器。然而,我们也会提到其他几种可用的 GC 实现,以对比和解释一些设计决策,这些决策使 G1 得以诞生。此外,它们可能比 G1 更适合某些应用程序。

内存组织和管理是 JVM 开发中非常专业和复杂的领域。本书不打算在这个层面上解决实现细节。我们的重点是 GC 的那些方面,可以通过设置 JVM 运行时的相应参数,帮助应用程序开发人员调整应用程序的需求。

GC 使用的两个内存区域是堆和栈。第一个由 JVM 用于分配内存和存储程序创建的对象。当使用new关键字创建对象时,它位于堆中,并且对它的引用存储在栈中。栈还存储原始变量和当前方法或线程使用的堆对象的引用。栈以后进先出LIFO)的方式操作。栈比堆小得多。

对于任何 GC 的略微简化但足够好的高层次视图是—遍历堆中的对象并删除那些在堆栈中没有引用的对象。

理解 G1 垃圾收集器

以前的 GC 实现包括串行 GC并行 GC并发标记-清除CMS)收集器。它们将堆分成三个部分—年轻代、老年代或终身代和用于容纳大小为标准区域的 50%或更大的对象的巨大区域。年轻代包含大部分新创建的对象;这是最动态的区域,因为大多数对象的寿命很短,很快(随着它们的年龄)就有资格进行收集。术语年龄指的是对象存活的收集周期数。年轻代有三个收集周期—伊甸空间和两个幸存者空间,如幸存者 0(S0)和幸存者 1(S1)。对象会根据它们的年龄和其他一些特征移动到这些空间中,直到它们最终被丢弃或放入老年代。

老年代包含比一定年龄更老的对象。这个区域比年轻代大,因此这里的垃圾收集更昂贵,发生的频率也不如年轻代频繁。

永久代包含描述应用程序中使用的类和方法的元数据。它还存储字符串、库类和方法。

JVM 启动时,堆是空的,然后对象被推送到伊甸园。当它填满时,一个小的 GC 过程开始。它移除了未引用和循环引用的对象,并将其他对象移动到S0区域。

接下来的小 GC 过程将引用的对象迁移到S1,并增加了在上一次小集合中幸存的对象的年龄。在所有幸存的对象(不同年龄的对象)都移动到S1后,S0和伊甸园都变为空。

在下一次小集合中,S0S1交换它们的角色。引用的对象从伊甸园移动到S1,从S1移动到S0

在每次小集合中,已经达到一定年龄的对象被移动到老年代。正如我们之前提到的,老年代最终会被检查(经过几次小集合后),未引用的对象将从中移除,并且内存将被碎片整理。这种对老年代的清理被认为是一次大集合。

永久代由不同的 GC 算法在不同的时间进行清理。

G1 GC 做法略有不同。它将堆分成相等大小的区域,并为每个区域分配相同的角色—伊甸园、幸存者或老年代—但根据需要动态地改变具有相同角色的区域数量。这使得内存清理过程和内存碎片整理更加可预测。

准备就绪

串行 GC 在同一个周期内清理年轻代和老年代(串行地,因此得名)。在执行任务期间,它会停止世界。这就是为什么它适用于只有一个 CPU 和堆大小为几百 MB 的非服务器应用程序。

并行 GC 在所有可用核心上并行工作,尽管线程数量可以进行配置。它也会停止世界,只适用于可以容忍长时间冻结的应用程序。

CMS 收集器旨在解决长时间暂停的问题。它以不对旧一代进行碎片整理和在应用程序执行期间进行一些分析(通常使用 25%的 CPU)为代价。旧一代的收集在其占用空间达到 68%时开始(默认情况下,但此值可以配置)。

G1 GC 算法类似于 CMS 收集器。首先,它并发地识别堆中的所有引用对象并相应地标记它们。然后,它首先收集最空的区域,从而释放大量的空间。这就是为什么它被称为垃圾优先。因为它使用许多小的专用区域,它有更好的机会来预测清理一个区域所需的时间,并适应用户定义的暂停时间(G1 偶尔可能超出,但大多数情况下非常接近)。

G1 的主要受益者是需要大堆(6GB 或更多)且不能容忍长时间暂停(0.5 秒或更短)的应用程序。如果应用程序遇到太多或太长时间的暂停问题,可以从 CMS 或并行 GC(特别是旧一代的并行 GC)切换到 G1 GC 获益。如果不是这种情况,在使用 JDK 9 或更高版本时,切换到 G1 收集器不是必需的。

G1 GC 从年轻代开始收集,使用停顿世界暂停进行疏散(将年轻代内部和旧一代之间的对象移动)。当旧一代的占用达到一定阈值后,也会进行收集。旧一代中的一些对象是并发收集的,而一些对象是使用停顿世界暂停进行收集的。步骤包括以下内容:

  • 幸存者区域(根区域)的初始标记,可能引用旧一代的对象,使用停顿世界暂停来完成

  • 扫描幸存者区域以查找对旧一代的引用,与此同时应用程序继续运行

  • 在整个堆上并发标记活动对象,同时应用程序继续运行

  • 备注步骤完成了活动对象的标记,使用停顿世界暂停来完成

  • 清理过程计算活动对象的年龄,释放区域(使用停顿世界暂停),并将它们返回到空闲列表(并发进行)

前面的序列可能会与年轻代疏散交错,因为大多数对象的生命周期很短,通过更频繁地扫描年轻代来释放大量内存更容易。

还有一个混合阶段,当 G1 收集已标记为大部分垃圾的年轻代和旧一代的区域时,以及巨大分配,当大对象被移动到或从巨大区域疏散时。

有一些情况下会执行完全 GC,使用停顿世界暂停:

  • 并发失败:如果在标记阶段旧一代占满空间

  • 提升失败:如果在混合阶段旧一代空间不足时发生

  • 疏散失败:当收集器无法将对象提升到幸存者空间和旧一代时发生

  • 巨大分配:当应用程序尝试分配一个非常大的对象时发生

如果正确调整,您的应用程序应该避免完全 GC。

为了帮助 GC 调优,JVM 文档(docs.oracle.com/javase/8/docs/technotes/guides/vm/gctuning/ergonomics.html)描述了人体工程学如下:

“*自适应性是 JVM 和垃圾收集调整的过程,例如基于行为的调整,它提高了应用程序的性能。JVM 为垃圾收集器、堆大小和运行时编译器提供了平台相关的默认选择。这些选择符合不同类型应用程序的需求,同时需要较少的命令行调整。此外,基于行为的调整动态调整堆的大小,以满足应用程序的指定行为。”

如何做...

  1. 要了解 GC 的工作原理,请编写以下程序:
        public class Chapter11Memory {   
           public static void main(String... args) {
              int max = 99_888_999;
              System.out.println("Chapter11Memory.main() for " 
                                      + max + " is running...");
              List<AnObject> list = new ArrayList<>();
              IntStream.range(0, max)
                       .forEach(i -> list.add(new AnObject(i)));
           }

           private static class AnObject {
              private int prop;
              AnObject(int i){ this.prop = i; }
           }
        }

如您所见,它创建了 99,888,999 个对象,并将它们添加到List<AnObject> list集合中。您可以通过减少对象的最大数量(max)来调整它,以匹配您计算机的配置。

  1. 自 Java 9 以来,G1 GC 是默认收集器,因此如果对您的应用程序足够好,您无需设置任何内容。尽管如此,您可以通过在命令行上提供-XX:+UseG1GC来显式启用 G1:
 java -XX:+UseG1GC -cp ./cookbook-1.0.jar 
      com.packt.cookbook.ch11_memory.Chapter11Memory

请注意,我们假设您可以构建一个可执行的.jar文件并理解基本的 Java 执行命令。如果不行,请参考 JVM 文档。

其他可用的 GC 可以通过设置以下选项之一来使用:

    • -XX:+UseSerialGC用于使用串行收集器。
  • -XX:+UseParallelGC用于使用带有并行压缩的并行收集器(这使得并行收集器可以并行执行主要收集)。没有并行压缩,主要收集将使用单个线程执行,这可能会严重限制可伸缩性。通过-XX:+UseParallelOldGC选项禁用并行压缩。

  • -XX:+UseConcMarkSweepGC 用于使用 CMS 收集器。

  1. 要查看 GC 的日志消息,请设置-Xlog:gc。您还可以使用 Unix 实用程序time来测量完成作业所需的时间(该实用程序会发布输出的最后三行,因此如果您无法或不想使用它,则无需使用它):
 time java -Xlog:gc -cp ./cookbook-1.0.jar com.packt.cookbook.ch11_memory.Chapter11Memory
  1. 运行上述命令。输出可能如下所示(实际值可能在您的计算机上有所不同):

如您所见,GC 经历了我们描述的大部分步骤。它从收集年轻代开始。然后,当List<AnObject> list对象(请参阅前面的代码)变得太大(超过年轻代区域的 50%以上)时,为其分配内存到巨大区域。您还可以看到初始标记步骤、随后的重新标记和其他先前描述的步骤。

每行以 JVM 运行的时间(以秒为单位)开头,并以每个步骤花费的时间(以毫秒为单位)结尾。在屏幕截图的底部,我们看到了time实用程序打印的三行:

    • real是花费的挂钟时间量——自命令运行以来经过的所有时间(应与 JVM 正常运行时间值的第一列对齐)
  • user是进程中所有 CPU 在用户模式代码(内核外)中花费的时间量;它更大是因为 GC 与应用程序并发工作。

  • sys 是 CPU 在进程内核中花费的时间量

  • user+sys是进程使用的 CPU 时间量

  1. 设置-XX:+PrintGCDetails选项(或只需在日志选项-Xlog:gc*中添加*)以查看有关 GC 活动的更多详细信息。在以下屏幕截图中,我们仅提供了与 GC 步骤 0 相关的日志的开头:

现在日志中有超过十几个条目,每个 GC 步骤都以记录UserSysReal时间量(由time实用程序累积的时间量)结束。您可以通过添加更多的短寿命对象来修改程序,例如,看看 GC 活动如何改变。

  1. 使用-Xlog:gc*=debug选项获取更多信息。以下仅为输出的一部分:

因此,您可以选择需要多少信息进行分析。

我们将在《JVM 统一日志记录》中讨论日志格式和其他日志选项的更多细节。

工作原理...

正如我们之前提到的,G1 GC 使用默认的人体工程学值,这些值对于大多数应用程序来说可能已经足够好了。以下是最重要的一些值的列表(<ergo>表示实际值是根据环境人体工程学确定的):

  • -XX:MaxGCPauseMillis=200:保持最大暂停时间的值

  • -XX:GCPauseTimeInterval=:保持 GC 步骤之间的最大暂停时间(默认情况下未设置,允许 G1 在需要时连续执行垃圾收集)

  • -XX:ParallelGCThreads=:保持在垃圾收集暂停期间用于并行工作的最大线程数(默认情况下,从可用线程数派生;如果可用于进程的 CPU 线程数小于或等于八,它使用这个数字;否则,它将大于八的五分之八的线程添加到最终线程数中)

  • -XX:ConcGCThreads=:保持用于并发工作的最大线程数(默认设置为-XX:ParallelGCThreads除以四)。

  • -XX:+G1UseAdaptiveIHOP:表示启动堆占用应该是自适应的

  • -XX:InitiatingHeapOccupancyPercent=45:设置了最初的几个收集周期;G1 将使用老年代 45%的占用作为标记开始阈值

  • -XX:G1HeapRegionSize=:根据初始和最大堆大小保持堆区域大小(默认情况下,因为堆包含大约 2048 个堆区域,堆区域的大小可以从 1 到 32 MB 不等,并且必须是 2 的幂)

  • -XX:G1NewSizePercent=5 和-XX:XX:G1MaxNewSizePercent=60:定义了年轻代的总大小,它们作为当前 JVM 堆使用百分比在这两个值之间变化

  • -XX:G1HeapWastePercent=5:保持收集集候选对象中允许的未回收空间的百分比(如果收集集候选对象中的空闲空间低于此值,G1 将停止空间回收)

  • -XX:G1MixedGCCountTarget=8:保持空间回收阶段的预期长度(以收集次数计算)

  • -XX:G1MixedGCLiveThresholdPercent=85:保持老年代区域中存活对象占用的百分比,超过这个百分比的区域将不会在空间回收阶段被收集

一般来说,默认配置下 G1 的目标是“在高吞吐量下提供相对较小、均匀的暂停”(来自 G1 文档)。如果这些默认设置不适合您的应用程序,您可以改变暂停时间(使用-XX:MaxGCPauseMillis)和最大 Java 堆大小(使用-Xmx选项)。但请注意,实际的暂停时间在运行时不会完全匹配,但 G1 会尽力满足目标。

如果您想增加吞吐量,可以减少暂停时间目标或请求更大的堆。要增加响应性,改变暂停时间值。但请注意,限制年轻代大小(使用-Xmn-XX:NewRatio或其他选项)可能会妨碍暂停时间控制,因为“年轻代大小是 G1 允许其满足暂停时间的主要手段”(来自 G1 文档)。

性能不佳的一个可能原因是由于老年代堆占用过高而触发了 Full GC。这种情况可以通过日志中出现Pause Full (Allocation failure)来检测到。通常发生在对象快速创建过多(无法及时回收)或者许多大型(巨大)对象无法及时分配的情况下。有几种推荐的处理这种情况的方法:

  • 在出现过多的巨大对象的情况下,尝试通过增加区域大小,使用-XX:G1HeapRegionSize选项来减少它们的数量(当前选择的堆区域大小在日志开头打印出来)。

  • 增加堆的大小。

  • 通过设置-XX:ConcGCThreads增加并发标记线程的数量。

  • 通过修改-XX:G1ReservePercent增加自适应 IHOP 计算中使用的缓冲区,或者通过-XX:-G1UseAdaptiveIHOP-XX:InitiatingHeapOccupancyPercent手动设置禁用 IHOP 的自适应计算,促进更早的标记开始(利用 G1 基于更早应用行为做出决策的事实)。

只有在解决了完整的 GC 后,才能开始调整 JVM 以获得更好的响应和/或吞吐量。JVM 文档确定了以下情况需要调整响应性:

  • 异常系统或实时使用

  • 引用处理需要太长时间

  • 仅年轻代收集需要太长时间

  • 混合集合需要太长时间

  • 高更新 RS 和扫描 RS 时间

通过减少总暂停时间和暂停频率来实现更好的吞吐量。请参考 JVM 文档以识别和建议减轻问题。

JVM 的统一日志记录

JVM 的主要组件包括以下内容:

  • 类加载器

  • JVM 内存,运行时数据存储在其中;它分为以下几个区域:

  • 堆栈区域

  • 方法区域

  • 堆区域

  • PC 寄存器

  • 本地方法栈

  • 执行引擎,包括以下部分:

  • 解释器

  • JIT 编译器

  • 垃圾收集

  • 本地方法接口 JNI

  • 本地方法库

现在可以使用统一日志记录所有这些组件的日志消息,并通过-Xlog选项打开。

新日志系统的主要特点如下:

  • 日志级别的使用——tracedebuginfowarningerror

  • 标识 JVM 组件、操作或特定感兴趣消息的消息标签

  • 三种输出类型——stdoutstderrfile

  • 强制每行限制一个消息

准备工作

要一目了然地查看所有日志可能性,可以运行以下命令:

java -Xlog:help

以下是输出:

如您所见,-Xlog选项的格式定义如下:

-Xlog[:[what][:[output][:[decorators][:output-options]]]]

让我们详细解释一下这个选项:

  • whattag1[+tag2...][*][=level][,...]形式的标签和级别的组合。我们已经演示了当我们在-Xlog:gc*=debug选项中使用gc标签时,这个结构是如何工作的。通配符(*)表示您想要查看所有具有gc标签的消息(可能是其他标签中的一部分)。-Xlog:gc=debug中缺少通配符表示您只想看到由一个标签(在本例中为gc)标记的消息。如果只使用-Xlog,日志将以info级别显示所有消息。

  • output设置输出类型(默认为stdout)。

  • decorators指示日志每行的开头将放置什么(在实际日志消息来自组件之前)。默认的decoratorsuptimeleveltags,每个都包含在方括号中。

  • output_options可能包括filecount=file count和/或filesize=file size,可选的 K、M 或 G 后缀。

总之,默认的日志配置如下:

-Xlog:all=info:stdout:uptime,level,tags

如何做...

让我们运行一些日志设置:

  1. 运行以下命令:
 java -Xlog:cpu -cp ./cookbook-1.0.jar 
                  com.packt.cookbook.ch11_memory.Chapter11Memory

没有消息是因为 JVM 不仅使用cpu标签记录消息。该标签与其他标签结合使用。

  1. 添加*号并再次运行命令:
 java -Xlog:cpu* -cp ./cookbook-1.0.jar  
                 com.packt.cookbook.ch11_memory.Chapter11Memory

结果如下:

如您所见,cpu标签只会显示垃圾收集执行所需的时间。即使我们将日志级别设置为tracedebug(例如-Xlog:cpu*=debug),也不会显示其他消息。

  1. 使用heap标签运行命令:
 java -Xlog:heap* -cp ./cookbook-1.0.jar 
                 com.packt.cookbook.ch11_memory.Chapter11Memory

您将只收到与堆相关的消息:

但让我们仔细看看第一行。它以三个装饰符开头——uptimelog leveltags——然后是消息本身,它以收集周期编号(在本例中为 0)开头,以及 Eden 区域的数量从 24 下降到 0(现在的数量为 9)的信息。这是因为(正如我们在下一行中看到的那样)幸存者区域的数量从 0 增加到 3,老年代的数量(第三行)增加到 18,而巨大区域的数量(23)没有改变。这些都是第一个收集周期中与堆相关的消息。然后,第二个收集周期开始。

  1. 再次添加cpu标签并运行:
 java -Xlog:heap*,cpu* -cp ./cookbook-1.0.jar 
                   com.packt.cookbook.ch11_memory.Chapter11Memory

如您所见,cpu消息显示了每个周期的持续时间:

  1. 尝试使用通过+符号组合的两个标签(例如-Xlog:gc+heap)。它只会显示具有这两个标签的消息(类似于二进制的AND操作)。请注意,通配符将无法与+符号一起使用(例如,-Xlog:gc*+heap不起作用)。

  2. 您还可以选择输出类型和装饰符。实际上,装饰符级别似乎并不是非常信息丰富,可以通过明确列出仅需要的装饰符来轻松省略。考虑以下示例:

 java -Xlog:heap*,cpu*::uptime,tags -cp ./cookbook-1.0.jar 
                    com.packt.cookbook.ch11_memory.Chapter11Memory

注意如何插入两个冒号(::)以保留输出类型的默认设置。我们也可以明确显示它:

 java -Xlog:heap*,cpu*:stdout:uptime,tags -cp ./cookbook-1.0.jar
                    com.packt.cookbook.ch11_memory.Chapter11Memory

要删除任何装饰,可以将它们设置为none

 java -Xlog:heap*,cpu*::none -cp ./cookbook-1.0.jar
                     com.packt.cookbook.ch11_memory.Chapter11Memory

新日志系统最有用的方面是标签选择。它允许更好地分析每个 JVM 组件及其子系统的内存演变,或者找到性能瓶颈,分析在每个收集阶段花费的时间——这两者对于 JVM 和应用程序调优都至关重要。

使用 JVM 的 jcmd 命令

如果打开 Java 安装的bin文件夹,您可以在那里找到相当多的命令行实用程序,可用于诊断问题并监视使用Java Runtime EnvironmentJRE)部署的应用程序。它们使用不同的机制来获取它们报告的数据。这些机制特定于虚拟机VM)实现、操作系统和版本。通常,这些工具的子集仅适用于特定问题。

在本示例中,我们将重点放在 Java 9 中引入的诊断命令,即命令行实用程序jcmd。如果bin文件夹在路径上,您可以通过在命令行上键入jcmd来调用它。否则,您必须转到bin目录,或者在我们的示例中在jcmd之前加上bin文件夹的完整路径或相对路径(相对于您的命令行窗口的位置)。

如果您输入它,而机器上当前没有运行 Java 进程,您将只收到一行,如下所示:

87863 jdk.jcmd/sun.tools.jcmd.JCmd 

它显示当前只有一个 Java 进程正在运行(jcmd实用程序本身),并且它具有进程标识符PID)为 87863(每次运行时都会有所不同)。

让我们运行一个 Java 程序,例如:

java -cp ./cookbook-1.0.jar 
                   com.packt.cookbook.ch11_memory.Chapter11Memory

jcmd的输出将显示(具有不同 PID)以下内容:

87864 jdk.jcmd/sun.tools.jcmd.JCmd 
87785 com.packt.cookbook.ch11_memory.Chapter11Memory

如您所见,如果没有任何选项输入,jcmd实用程序将报告所有当前运行的 Java 进程的 PID。获取 PID 后,您可以使用jcmd从运行该进程的 JVM 请求数据:

jcmd 88749 VM.version 

或者,您可以避免使用 PID(并且不带参数调用jcmd)通过引用应用程序的主类来引用该进程:

jcmd Chapter11Memory VM.version

您可以阅读 JVM 文档,以获取有关jcmd实用程序及其用法的更多详细信息。

如何做...

jcmd是一个允许我们向指定的 Java 进程发出命令的实用程序:

  1. 通过执行以下行,可以获取特定 Java 进程可用的jcmd命令的完整列表:
 jcmd PID/main-class-name help

PID/main-class的位置,放置进程标识符或主类名称。该列表特定于 JVM,因此每个列出的命令都会从特定进程请求数据。

  1. 在 JDK 8 中,以下jcmd命令是可用的:
JFR.stop
JFR.start
JFR.dump
JFR.check
VM.native_memory
VM.check_commercial_features
VM.unlock_commercial_features
ManagementAgent.stop
ManagementAgent.start_local
ManagementAgent.start
GC.rotate_log
Thread.print
GC.class_stats
GC.class_histogram
GC.heap_dump
GC.run_finalization
GC.run
VM.uptime
VM.flags
VM.system_properties
VM.command_line
VM.version

JDK 9 引入了以下jcmd命令(JDK 18.3 和 JDK 18.9 没有添加新命令):

    • Compiler.queue: 打印排队等待使用 C1 或 C2 编译的方法(分别排队)
  • Compiler.codelist: 打印 n 个(已编译的)方法的完整签名、地址范围和状态(活动、非进入和僵尸),并允许选择打印到stdout、文件、XML 或文本输出

  • Compiler.codecache: 打印代码缓存的内容,即 JIT 编译器存储生成的本机代码以提高性能的地方

  • Compiler.directives_add file: 从文件向指令栈顶部添加编译器指令

  • Compiler.directives_clear: 清除编译器指令栈(仅保留默认指令)

  • Compiler.directives_print: 从顶部到底部打印编译器指令栈上的所有指令

  • Compiler.directives_remove: 从编译器指令栈中移除顶部指令

  • GC.heap_info: 打印当前堆参数和状态

  • GC.finalizer_info: 显示终结器线程的状态,该线程收集具有终结器(即finalize()方法)的对象

  • JFR.configure: 允许我们配置 Java Flight Recorder

  • JVMTI.data_dump: 打印 Java 虚拟机工具接口数据转储

  • JVMTI.agent_load: 加载(附加)Java 虚拟机工具接口代理

  • ManagementAgent.status: 打印远程 JMX 代理的状态

  • Thread.print: 打印所有带有堆栈跟踪的线程

  • VM.log [option]: 允许我们在 JVM 启动后(可以通过使用VM.log list查看可用性)在运行时设置 JVM 日志配置(我们在前面的配方中描述了)

  • VM.info: 打印统一的 JVM 信息(版本和配置)、所有线程及其状态的列表(不包括线程转储和堆转储)、堆摘要、JVM 内部事件(GC、JIT、安全点等)、加载的本机库的内存映射、VM 参数和环境变量,以及操作系统和硬件的详细信息

  • VM.dynlibs: 打印动态库的信息

  • VM.set_flag: 允许我们设置 JVM 的可写(也称为可管理)标志(请参阅 JVM 文档以获取标志列表)

  • VM.stringtableVM.symboltable: 打印所有 UTF-8 字符串常量

  • VM.class_hierarchy [full-class-name]: 打印所有已加载的类或指定类层次结构

  • VM.classloader_stats: 打印有关类加载器的信息

  • VM.print_touched_methods: 打印在运行时已被访问(至少已被读取)的所有方法

正如您所看到的,这些新命令属于几个组,由前缀编译器、垃圾收集器GC)、Java Flight RecorderJFR)、Java 虚拟机工具接口JVMTI)、管理代理(与远程 JMX 代理相关)、线程VM表示。在本书中,我们没有足够的空间来详细介绍每个命令。我们只会演示一些实用命令的用法。

工作原理...

  1. 要获取jcmd实用程序的帮助,请运行以下命令:
jcmd -h 

以下是命令的结果:

它告诉我们,命令也可以从-f之后指定的文件中读取,并且有一个PerfCounter.print命令,它打印进程的所有性能计数器(统计信息)。

  1. 运行以下命令:
jcmd Chapter11Memory GC.heap_info

输出可能看起来像这个屏幕截图:

它显示了总堆大小及其使用量,年轻代中区域的大小和分配的区域数量,以及Metaspaceclass space的参数。

  1. 以下命令在您寻找失控线程或想了解幕后发生了什么时非常有帮助:
jcmd Chapter11Memory Thread.print

以下是可能输出的片段:

  1. 这个命令可能是最常用的,因为它提供了关于硬件、整个 JVM 进程以及其组件当前状态的丰富信息:
jcmd Chapter11Memory VM.info

它以摘要开始,如下所示:

接下来是一般的过程描述:

然后是堆的详细信息(这只是其中的一小部分):

然后打印编译事件、GC 堆历史、去优化事件、内部异常、事件、动态库、日志选项、环境变量、VM 参数以及运行进程的系统的许多参数。

jcmd命令深入了解 JVM 进程,有助于调试和调整进程以获得最佳性能和最佳资源使用。

使用try-with-resources更好地处理资源

管理资源是很重要的。任何资源的错误处理(未释放)——例如保持打开的数据库连接和文件描述符——都可能耗尽系统的操作能力。这就是为什么在 JDK 7 中引入了try-with-resources语句。我们在第六章的示例中使用了它,数据库编程

try (Connection conn = getDbConnection();
Statement st = createStatement(conn)) {
  st.execute(sql);
} catch (Exception ex) {
  ex.printStackTrace();
}

作为提醒,这是getDbConnection()方法:

Connection getDbConnection() {
  PGPoolingDataSource source = new PGPoolingDataSource();
  source.setServerName("localhost");
  source.setDatabaseName("cookbook");
  try {
    return source.getConnection(); 
  } catch(Exception ex) {
    ex.printStackTrace();
    return null;
  }
}

这是createStatement()方法:

Statement createStatement(Connection conn) {
  try {
    return conn.createStatement();
  } catch(Exception ex) {
    ex.printStackTrace();
    return null;
  }
}

这非常有帮助,但在某些情况下,我们仍然需要以旧的方式编写额外的代码,例如,如果有一个接受Statement对象作为参数的execute()方法,并且我们希望在使用后立即释放(关闭)它。在这种情况下,代码将如下所示:

void execute(Statement st, String sql){
  try {
    st.execute(sql);
  } catch (Exception ex) {
    ex.printStackTrace();
  } finally {
    if(st != null) {
      try{
        st.close();
      } catch (Exception ex) {
        ex.printStackTrace();
      }
    }
  }
}

正如您所看到的,其中大部分只是样板复制粘贴代码。

Java 9 引入的新try-with-resources语句通过允许有效地最终变量作为资源来解决了这种情况。

如何做...

  1. 使用新的try-with-resources语句重写前面的示例:
        void execute(Statement st, String sql){
          try (st) {
            st.execute(sql);
          } catch (Exception ex) {
            ex.printStackTrace();
          }
        }

正如您所看到的,它更加简洁和专注,无需反复编写关闭资源的琐碎代码。不再需要finally和额外的try...catch

  1. 如果连接也被传递进来,它也可以放在同一个 try 块中,并在不再需要时立即关闭:
        void execute(Connection conn, Statement st, String sql) {
          try (conn; st) {
            st.execute(sql);
          } catch (Exception ex) {
            ex.printStackTrace();
          }
        }

它可能适合或不适合您应用程序的连接处理,但通常,这种能力是很方便的。

  1. 尝试不同的组合,例如以下:
        Connection conn = getDbConnection();
        Statement st = conn.createStatement();
        try (conn; st) {
          st.execute(sql);
        } catch (Exception ex) {
          ex.printStackTrace();
        }

这种组合也是允许的:

        Connection conn = getDbConnection();
        try (conn; Statement st = conn.createStatement()) {
          st.execute(sql);
        } catch (Exception ex) {
          ex.printStackTrace();
        }

新语句提供了更灵活的编写代码的方式,以满足需求,而无需编写关闭资源的代码行。

唯一的要求如下:

    • try语句中包含的变量必须是 final 或有效最终
  • 资源必须实现AutoCloseable接口,其中只包括一个方法:

        void close() throws Exception;

它是如何工作的...

为了演示新语句的工作原理,让我们创建自己的资源,实现AutoCloseable并以与之前示例中的资源类似的方式使用它们。

这是一个资源:

class MyResource1 implements AutoCloseable {
  public MyResource1(){
    System.out.println("MyResource1 is acquired");
  }
  public void close() throws Exception {
    //Do what has to be done to release this resource
    System.out.println("MyResource1 is closed");
  }
}

这是第二个资源:

class MyResource2 implements AutoCloseable {
  public MyResource2(){
    System.out.println("MyResource2 is acquired");
  }
  public void close() throws Exception {
    //Do what has to be done to release this resource
    System.out.println("MyResource2 is closed");
  }
}

让我们在代码示例中使用它们:

MyResource1 res1 = new MyResource1();
MyResource2 res2 = new MyResource2();
try (res1; res2) {
  System.out.println("res1 and res2 are used");
} catch (Exception ex) {
  ex.printStackTrace();
}

如果我们运行它,结果将如下:

请注意,在try语句中列出的第一个资源最后关闭。让我们只做一个改变,并在try语句中切换引用的顺序:

MyResource1 res1 = new MyResource1();
MyResource2 res2 = new MyResource2();
try (res2; res1) {
  System.out.println("res1 and res2 are used");
} catch (Exception ex) {
  ex.printStackTrace();
}

输出确认了引用关闭的顺序也发生了变化:

按照相反顺序关闭资源的规则解决了资源之间可能存在的最重要的依赖问题,但是由程序员定义关闭资源的顺序(通过在try语句中按正确顺序列出它们)是程序员的责任。幸运的是,大多数标准资源的关闭都由 JVM 优雅地处理,如果资源按照不正确的顺序列出,代码不会中断。但是,按照创建顺序列出它们是一个好主意。

用于改进调试的堆栈遍历

堆栈跟踪在找出问题的根源时非常有帮助。当可能进行自动更正时,需要以编程方式读取它。

自 Java 1.4 以来,可以通过java.lang.Threadjava.lang.Throwable类访问当前堆栈跟踪。您可以在代码的任何方法中添加以下行:

Thread.currentThread().dumpStack();

您还可以添加以下行:

new Throwable().printStackTrace();

它将堆栈跟踪打印到标准输出。或者,自 Java 8 以来,您可以使用以下任一行达到相同的效果:

Arrays.stream(Thread.currentThread().getStackTrace())
      .forEach(System.out::println);

Arrays.stream(new Throwable().getStackTrace())
      .forEach(System.out::println);

或者您可以使用以下任一行提取调用者类的完全限定名称:

System.out.println("This method is called by " + Thread.currentThread()
                                   .getStackTrace()[1].getClassName());

System.out.println("This method is called by " + new Throwable()
                                   .getStackTrace()[0].getClassName());

所有上述解决方案都是可能的,因为java.lang.StackTraceElement类代表堆栈跟踪中的堆栈帧。该类提供其他描述由此堆栈跟踪元素表示的执行点的方法,这允许以编程方式访问堆栈跟踪信息。例如,您可以在程序的任何位置运行此代码片段:

Arrays.stream(Thread.currentThread().getStackTrace())
  .forEach(e -> {
    System.out.println();
    System.out.println("e="+e);
    System.out.println("e.getFileName()="+ e.getFileName());
    System.out.println("e.getMethodName()="+ e.getMethodName());
    System.out.println("e.getLineNumber()="+ e.getLineNumber());
});

或者您可以在程序的任何位置运行以下内容:

Arrays.stream(new Throwable().getStackTrace())
  .forEach(x -> {
    System.out.println();
    System.out.println("x="+x);
    System.out.println("x.getFileName()="+ x.getFileName());
    System.out.println("x.getMethodName()="+ x.getMethodName());
    System.out.println("x.getLineNumber()="+ x.getLineNumber());
});

不幸的是,这些丰富的数据是有代价的。JVM 捕获整个堆栈(除了隐藏的堆栈帧),并且在程序堆栈跟踪的程序化分析嵌入主应用程序流程的情况下,可能会影响应用程序性能。与此同时,您只需要这些数据的一小部分来做出决策。

这就是新的 Java 9 类java.lang.StackWalker以及其嵌套的Option类和StackFrame接口派上用场的地方。

准备就绪

StackWalker类有四个重载的getInstance()静态工厂方法:

  • StackWalker getInstance(): 这是配置为跳过所有隐藏帧的实例,并且不保留调用者类引用。隐藏帧包含 JVM 内部实现特定的信息。不保留调用者类引用意味着在StackWalker对象上调用getCallerClass()方法会抛出UnsupportedOperationException

  • StackWalker getInstance(StackWalker.Option option): 这将创建一个具有给定选项的实例,指定它可以访问的堆栈帧信息。

  • StackWalker getInstance(Set<StackWalker.Option> options): 这将创建一个具有给定选项集的实例,指定它可以访问的堆栈帧信息。如果给定的集合为空,则该实例的配置与StackWalker getInstance()创建的实例完全相同。

  • StackWalker getInstance(Set<StackWalker.Option> options, int estimatedDepth): 这将创建一个与前一个实例类似的实例,并接受estimatedDepth参数,允许我们估计它可能需要的缓冲区大小。

以下是enum StackWalker.Option的值:

  • StackWalker.Option.RETAIN_CLASS_REFERENCE: 配置StackWalker实例以支持getCallerClass()方法,并且StackFrame支持getDeclaringClass()方法

  • StackWalker.Option.SHOW_HIDDEN_FRAMES: 配置StackWalker实例以显示所有反射帧和特定实现帧

  • StackWalker.Option.SHOW_REFLECT_FRAMES: 配置StackWalker实例以显示所有反射帧

StackWalker类还有三个方法:

  • T walk(Function<Stream<StackWalker.StackFrame>, T> function): 这将给定的函数应用于当前线程的StackFrames流,从堆栈顶部遍历帧。顶部帧包含调用此walk()方法的方法。

  • void forEach(Consumer<StackWalker.StackFrame> action): 这对当前线程的StackFrame流的每个元素执行给定的操作,从堆栈的顶部帧开始,这是调用forEach方法的方法。此方法相当于调用walk(s -> { s.forEach(action); return null; })

  • Class<?> getCallerClass(): 这获取调用了调用getCallerClass()方法的方法的Class对象。如果此StackWalker实例未配置RETAIN_CLASS_REFERENCE选项,则此方法会抛出UnsupportedOperationException

如何做...

创建几个类和方法,它们将相互调用,这样您就可以执行堆栈跟踪处理:

  1. 创建一个Clazz01类:
        public class Clazz01 {
          public void method(){
            new Clazz03().method("Do something");
            new Clazz02().method();
          }
        }
  1. 创建一个Clazz02类:
        public class Clazz02 {
          public void method(){
            new Clazz03().method(null);
          }
        }
  1. 创建一个Clazz03类:
        public class Clazz03 {
          public void method(String action){
            if(action != null){
              System.out.println(action);
              return;
            }
            System.out.println("Throw the exception:");
            action.toString();
          }
        }
  1. 编写一个demo4_StackWalk()方法:
        private static void demo4_StackWalk(){
          new Clazz01().method();
        }

Chapter11Memory类的主方法中调用此方法:

        public class Chapter11Memory {
          public static void main(String... args) {
            demo4_StackWalk();
          }
        }

如果我们现在运行Chapter11Memory类,结果将如下所示:

Do something消息从Clazz01传递并在Clazz03中打印出来。然后Clazz02将 null 传递给Clazz03,并在action.toString()行引起的NullPointerException的堆栈跟踪之前打印出Throw the exception消息。

它是如何工作的...

为了更深入地理解这里的概念,让我们修改Clazz03

public class Clazz03 {
  public void method(String action){
    if(action != null){
      System.out.println(action);
      return;
    }
    System.out.println("Print the stack trace:");
    Thread.currentThread().dumpStack();
  }
}

结果将如下所示:

或者,我们可以使用Throwable而不是Thread来获得类似的输出:

new Throwable().printStackTrace();

前一行产生了这个输出:

每个以下两行将产生类似的结果:

Arrays.stream(Thread.currentThread().getStackTrace())
                             .forEach(System.out::println);
Arrays.stream(new Throwable().getStackTrace())
                             .forEach(System.out::println);

自 Java 9 以来,可以使用StackWalker类实现相同的输出。让我们看看如果我们修改Clazz03会发生什么:

public class Clazz03 {
  public void method(String action){
    if(action != null){
      System.out.println(action);
      return;
    }
    StackWalker stackWalker = StackWalker.getInstance();
    stackWalker.forEach(System.out::println);
  }
}

结果如下:

它包含了传统方法产生的所有信息。然而,与在内存中生成和存储完整堆栈跟踪不同,StackWalker类只带来了请求的元素。这已经是一个很大的优点。然而,StackWalker的最大优势是,当我们只需要调用者类名时,而不是获取整个数组并仅使用一个元素,我们现在可以通过以下两行获取所需的信息:

System.out.println("Print the caller class name:");
System.out.println(StackWalker.getInstance(StackWalker
                        .Option.RETAIN_CLASS_REFERENCE)
                        .getCallerClass().getSimpleName());

上述代码片段的结果如下:

使用内存感知编码风格

在编写代码时,程序员有两个主要目标:

  • 实现所需的功能

  • 编写易于阅读和理解的代码

然而,在这样做的同时,他们还必须做出许多其他决定,其中之一是使用与标准库类和方法具有类似功能的类。在这个示例中,我们将带您了解一些考虑因素,以帮助避免浪费内存,并使您的代码风格具有内存感知能力:

  • 注意在循环内创建的对象

  • 使用延迟初始化,在使用之前创建对象,特别是如果有很大的可能性,这种需求根本不会出现

  • 不要忘记清理缓存并删除不必要的条目

  • 使用StringBuilder而不是+运算符

  • 如果符合您的需求,请使用ArrayList,然后再使用HashSet(从ArrayListLinkedListHashTableHashMapHashSet,内存使用量逐渐增加)

如何做...

  1. 注意在循环内创建的对象。

这个建议非常明显。在快速连续创建和丢弃许多对象可能在垃圾收集器重新利用空间之前消耗太多内存。考虑重用对象而不是每次都创建一个新对象。这里有一个例子:

class Calculator {
   public  double calculate(int i) {
       return Math.sqrt(2.0 * i);
   }
}

class SomeOtherClass {
   void reuseObject() {
      Calculator calculator = new Calculator();
      for(int i = 0; i < 100; i++ ){
          double r = calculator.calculate(i);
          //use result r
      }
   }
} 

前面的代码可以通过使calculate()方法静态来改进。另一个解决方案是创建SomeOtherClass类的静态属性Calculator calculator = new Calculator()。但是静态属性在类第一次加载时就会初始化。如果calculator属性没有被使用,那么它的初始化将是不必要的开销。在这种情况下,需要添加延迟初始化。

  1. 使用延迟初始化,在使用之前创建对象,特别是如果有很大的可能性某些请求可能永远不会实现这个需求。

在前面的步骤中,我们谈到了calculator属性的延迟初始化:

class Calculator {
    public  double calculate(int i) {
        return Math.sqrt(2.0 * i);
    }
}

class SomeOtherClass {
     private static Calculator calculator;
     private static Calculator getCalculator(){
        if(this.calculator == null){
            this.calculator = new Calculator();
        }
        return this.calculator;
     }
     void reuseObject() {
        for(int i = 0; i < 100; i++ ){
           double r = getCalculator().calculate(i);
           //use result r
      }
   }
} 

在前面的示例中,Calculator对象是一个单例 - 一旦创建,应用程序中就只存在一个实例。如果我们知道calculator属性总是会被使用,那么就不需要延迟初始化。在 Java 中,我们可以利用静态属性在任何应用程序线程加载类时的第一次初始化。

class SomeOtherClass {
   private static Calculator calculator = new Calculator();
   void reuseObject() {
      for(int i = 0; i < 100; i++ ){
          double r = calculator.calculate(i);
          //use result r
      }
   }
}

但是,如果初始化的对象很可能永远不会被使用,我们又回到了可以在单线程中实现的延迟初始化(使用getCalculator()方法)或者当共享对象是无状态的且其初始化不消耗太多资源时。

在多线程应用程序和复杂对象初始化的情况下,需要采取一些额外措施来避免并发访问冲突,并确保只创建一个实例。例如,考虑以下类:

class ExpensiveInitClass {
    private Object data;
    public ExpensiveInitClass() {
        //code that consumes resources
        //and assignes value to this.data
    }

    public Object getData(){
        return this.data;
    }
}

如果前面的构造函数需要大量时间来完成对象的创建,那么第二个线程有可能在第一个线程完成对象创建之前进入构造函数。为了避免第二个对象的并发创建,我们需要同步初始化过程:

class LazyInitExample {
  public ExpensiveInitClass expensiveInitClass
  public Object getData(){  //can synchrnonize here
    if(this.expensiveInitClass == null){
      synchronized (LazyInitExample.class) {
        if (this.expensiveInitClass == null) {
          this.expensiveInitClass = new ExpensiveInitClass();
        }
      }
    }
    return expensiveInitClass.getData();
  }
}

如您所见,我们可以同步访问getData()方法,但在对象创建后不需要此同步,并且可能在高并发多线程环境中造成瓶颈。同样,我们可以只在同步块内部进行一次空值检查,但在对象初始化后不需要此同步,因此我们用另一个空值检查来减少瓶颈的机会。

  1. 不要忘记清理缓存并删除不必要的条目。

缓存有助于减少访问数据的时间。但缓存会消耗内存,因此有意义的是尽可能保持它小,同时仍然有用。如何做取决于缓存数据使用的模式。例如,如果你知道一旦使用,存储在缓存中的对象不会再次被使用,你可以在应用程序启动时(或根据使用模式定期)将其放入缓存中,并在使用后从缓存中删除:

static HashMap<String, Object> cache = new HashMap<>();
static {
    //populate the cache here
}
public Object getSomeData(String someKey) {
    Object obj = cache.get(someKey);
    cache.remove(someKey);
    return obj;
}

或者,如果您期望每个对象具有很高的可重用性,可以在第一次请求后将其放入缓存中:

static HashMap<String, Object> cache = new HashMap<>();
public Object getSomeData(String someKey) {
    Object obj = cache.get(someKey);
    if(obj == null){
        obj = getDataFromSomeSource();
        cache.put(someKey, obj);
    }
    return obj;
}

前面的情况可能导致缓存无法控制地增长,消耗太多内存,并最终导致OutOfMemoryError条件。为了防止这种情况,您可以实现一个算法,限制缓存的大小 - 达到一定大小后,每次添加新对象时,都会删除一些其他对象(例如,最常用的对象或最少使用的对象)。以下是将缓存大小限制为 10 的示例,通过删除最常使用的缓存对象:

static HashMap<String, Object> cache = new HashMap<>();
static HashMap<String, Integer> count = new HashMap<>();
public static Object getSomeData(String someKey) {
   Object obj = cache.get(someKey);
   if(obj == null){
       obj = getDataFromSomeSource();
       cache.put(someKey, obj);
       count.put(someKey, 1);
       if(cache.size() > 10){
          Map.Entry<String, Integer> max = 
             count.entrySet().stream()
             .max(Map.Entry.comparingByValue(Integer::compareTo))
             .get();
            cache.remove(max.getKey());
            count.remove(max.getKey());
        }
    } else {
        count.put(someKey, count.get(someKey) + 1);
    } 
    return obj;
}

或者,可以使用java.util.WeakHashMap类来实现缓存:

private static WeakHashMap<Integer, Double> cache 
                                     = new WeakHashMap<>();
void weakHashMap() {
    int last = 0;
    int cacheSize = 0;
    for(int i = 0; i < 100_000_000; i++) {
        cache.put(i, Double.valueOf(i));
        cacheSize = cache.size();
        if(cacheSize < last){
            System.out.println("Used memory=" + 
              usedMemoryMB()+" MB, cache="  + cacheSize);
        }
        last = cacheSize;
    }
}

运行上面的示例,您会看到内存使用和缓存大小首先增加,然后下降,然后再次增加,然后再次下降。以下是输出的摘录:

Used memory=1895 MB, cache=2100931
Used memory=189 MB, cache=95658
Used memory=296 MB, cache=271
Used memory=408 MB, cache=153
Used memory=519 MB, cache=350
Used memory=631 MB, cache=129
Used memory=745 MB, cache=2079710
Used memory=750 MB, cache=69590
Used memory=858 MB, cache=213

我们使用的内存使用量计算如下:

long usedMemoryMB() {
   return Math.round(
      Double.valueOf(Runtime.getRuntime().totalMemory() - 
                     Runtime.getRuntime().freeMemory())/1024/1024
   );
}

java.util.WeakHashMap类是一个具有java.lang.ref.WeakReference类型键的 Map 实现。只有通过弱引用引用的对象在垃圾收集器决定需要更多内存时才会被回收。这意味着WeakHashMap对象中的条目将在没有对该键的引用时被移除。当垃圾收集器从内存中移除键时,相应的值也会从地图中移除。

在我们之前的示例中,缓存键都没有在地图之外使用,因此垃圾收集器会自行删除它们。即使我们在地图之外添加对键的显式引用,代码的行为也是相同的:

private static WeakHashMap<Integer, Double> cache 
                                     = new WeakHashMap<>();
void weakHashMap() {
    int last = 0;
    int cacheSize = 0;
    for(int i = 0; i < 100_000_000; i++) {
        Integer iObj = i;
        cache.put(iObj, Double.valueOf(i));
        cacheSize = cache.size();
        if(cacheSize < last){
            System.out.println("Used memory=" + 
              usedMemoryMB()+" MB, cache="  + cacheSize);
        }
        last = cacheSize;
    }
}

这是因为在之前的代码块中显示的iObj引用在每次迭代后都被丢弃并被收集,因此缓存中的相应键也没有外部引用,垃圾收集器也会将其删除。为了证明这一点,让我们再次修改上面的代码:

private static WeakHashMap<Integer, Double> cache 
                                     = new WeakHashMap<>();
void weakHashMap() {
    int last = 0;
    int cacheSize = 0;
    List<Integer> list = new ArrayList<>();
    for(int i = 0; i < 100_000_000; i++) {
        Integer iObj = i;
        cache.put(iObj, Double.valueOf(i));
        list.add(iObj);
        cacheSize = cache.size();
        if(cacheSize < last){
            System.out.println("Used memory=" + 
              usedMemoryMB()+" MB, cache="  + cacheSize);
        }
        last = cacheSize;
    }
}

我们创建了一个列表,并将地图的每个键添加到其中。如果我们运行上述代码,最终会得到OutOfMemoryError,因为缓存的键在地图之外有强引用。我们也可以减弱外部引用:

private static WeakHashMap<Integer, Double> cache 
                                     = new WeakHashMap<>();
void weakHashMap() {
    int last = 0;
    int cacheSize = 0;
    List<WeakReference<Integer>> list = new ArrayList<>();
    for(int i = 0; i < 100_000_000; i++) {
        Integer iObj = i;
        cache.put(iObj, Double.valueOf(i));
 list.add(new WeakReference(iObj));
        cacheSize = cache.size();
        if(cacheSize < last){
            System.out.println("Used memory=" + 
              usedMemoryMB()+" MB, cache="  + cacheSize +
              ", list size=" + list.size());
        }
        last = cacheSize;
    }
}

上面的代码现在运行得好像缓存键没有外部引用一样。使用的内存和缓存大小会增长,然后再次下降。但是列表大小不会下降,因为垃圾收集器不会从列表中删除值。因此,最终应用程序可能会耗尽内存。

然而,无论您限制缓存的大小还是让其无法控制地增长,都可能出现应用程序需要尽可能多的内存的情况。因此,如果有一些对应用程序主要功能不是关键的大对象,有时将它们从内存中移除以使应用程序能够生存并避免出现OutOfMemoryError的情况是有意义的。

如果存在缓存,通常是一个很好的候选对象来释放内存,因此我们可以使用WeakReference类来包装缓存本身:

private static WeakReference<Map<Integer, Double[]>> cache;
void weakReference() {
   Map<Integer, Double[]> map = new HashMap<>();
   cache = new WeakReference<>(map);
   map = null;
   int cacheSize = 0;
   List<Double[]> list = new ArrayList<>();
   for(int i = 0; i < 10_000_000; i++) {
      Double[] d = new Double[1024];
      list.add(d);
      if (cache.get() != null) {
          cache.get().put(i, d);
          cacheSize = cache.get().size();
          System.out.println("Cache="+cacheSize + 
                  ", used memory=" + usedMemoryMB()+" MB");
      } else {
          System.out.println(i +": cache.get()=="+cache.get()); 
          break;
      }
   }
}

在上面的代码中,我们将地图(缓存)包装在WeakReference类中,这意味着我们告诉 JVM 只要没有对它的引用,就可以收集此对象。然后,在每次 for 循环迭代中,我们创建一个new Double[1024]对象并将其保存在列表中。我们这样做是为了更快地使用完所有可用内存。然后我们将相同的对象放入缓存中。当我们运行此代码时,它会迅速得到以下输出:

Cache=4582, used memory=25 MB
4582: cache.get()==null

这意味着垃圾收集器在使用了 25MB 内存后决定收集缓存对象。如果您认为这种方法太过激进,而且您不需要经常更新缓存,您可以将其包装在java.lang.ref.SoftReference类中。如果这样做,缓存只有在所有内存用完时才会被收集——就在即将抛出OutOfMemoryError的边缘。以下是演示它的代码片段:

private static SoftReference<Map<Integer, Double[]>> cache;
void weakReference() {
   Map<Integer, Double[]> map = new HashMap<>();
   cache = new SoftReference<>(map);
   map = null;
   int cacheSize = 0;
   List<Double[]> list = new ArrayList<>();
   for(int i = 0; i < 10_000_000; i++) {
      Double[] d = new Double[1024];
      list.add(d);
      if (cache.get() != null) {
          cache.get().put(i, d);
          cacheSize = cache.get().size();
          System.out.println("Cache="+cacheSize + 
                      ", used memory=" + usedMemoryMB()+" MB");
      } else {
          System.out.println(i +": cache.get()=="+cache.get()); 
          break;
      }
   }
}

如果我们运行它,输出将如下所示:

Cache=1004737, used memory=4096 MB
1004737: cache.get()==null

没错,在我们的测试计算机上,有 4GB 的 RAM,因此只有在几乎用完所有内存时才会删除缓存。

  1. 使用StringBuilder代替+运算符。

您可以在互联网上找到许多这样的建议。也有相当多的声明说这个建议已经过时,因为现代 Java 使用StringBuilder来实现字符串的+运算符。以下是我们实验的结果。首先,我们运行了以下代码:

long um = usedMemoryMB();
String s = "";
for(int i = 1000; i < 10_1000; i++ ){
    s += Integer.toString(i);
    s += " ";
}
System.out.println("Used memory: " 
         + (usedMemoryMB() - um) + " MB");  //prints: 71 MB

usedMemoryMB()的实现:

long usedMemoryMB() {
   return Math.round(
      Double.valueOf(Runtime.getRuntime().totalMemory() - 
                  Runtime.getRuntime().freeMemory())/1024/1024
   );
}

然后我们用StringBuilder来达到同样的目的:

long um = usedMemoryMB();
StringBuilder sb = new StringBuilder();
for(int i = 1000; i < 10_1000; i++ ){
    sb.append(Integer.toString(i)).append(" ");
}
System.out.println("Used memory: " 
         + (usedMemoryMB() - um) + " MB");  //prints: 1 MB

正如你所看到的,使用+运算符消耗了 71MB 的内存,而StringBuilder仅在相同任务中使用了 1MB。我们也测试了StringBuffer。它也消耗了 1MB,但比StringBuilder执行稍慢,因为它是线程安全的,而StringBuilder只能在单线程环境中使用。

所有这些都不适用于长字符串值,该值已被拆分为几个子字符串,以提高可读性。编译器将子字符串收集回一个长值。例如,s1s2字符串占用相同的内存量:

String s1 = "this " +
            "string " +
            "takes " +
            "as much memory as another one";
String s2 = "this string takes as much memory as another one";
  1. 如果需要使用集合,如果符合你的需求,选择ArrayList。从ArrayListLinkedListHashTableHashMapHashSet,内存使用量逐渐增加。

ArrayList对象将其元素存储在Object[]数组中,并使用一个int字段来跟踪列表的大小(除了array.length)。由于这样的设计,如果有可能这个容量不会被充分使用,那么在声明时不建议分配一个大容量的ArrayList。当新元素添加到列表中时,后端数组的容量会以 10 个元素的块递增,这可能是浪费内存的一个可能来源。如果这对应用程序很重要,可以通过调用trimToSize()方法来缩小ArrayList的容量到当前使用的容量。请注意,clear()remove()方法不会影响ArrayList的容量,它们只会改变其大小。

其他集合的开销更大,因为它们提供了更多的服务。LinkedList元素不仅携带对前一个和后一个元素的引用,还携带对数据值的引用。大多数基于哈希的集合实现都专注于更好的性能,这往往是以内存占用为代价的。

如果集合的大小很小,那么选择 Java 集合类可能是无关紧要的。然而,程序员通常使用相同的编码模式,通过其风格可以识别代码的作者。因此,长远来看,找出最有效的构造并经常使用它们是值得的。但是,尽量避免使你的代码难以理解;可读性是代码质量的一个重要方面。

更好地使用内存的最佳实践

内存管理可能永远不会成为你的问题,它可能会成为你每一个清醒的时刻,或者你可能会发现自己处于这两个极端之间。大多数情况下,对于大多数程序员来说,这都不是问题,尤其是随着不断改进的垃圾回收算法。G1 垃圾收集器(JVM 9 中的默认值)绝对是朝着正确方向迈出的一步。但也有可能你会被要求(或者自己注意到)应用程序性能下降的情况,这时你就会了解你有多少能力来应对挑战。

这个示例是为了帮助你避免这种情况或成功摆脱它而做出的尝试。

如何做...

第一道防线是代码本身。在之前的示例中,我们讨论了释放资源的必要性,以及使用StackWalker来消耗更少的内存。互联网上有很多建议,但它们可能不适用于你的应用程序。你需要监控内存消耗并测试你的设计决策,特别是如果你的代码处理大量数据,然后才决定在哪里集中你的注意力。

一旦你的代码开始做它应该做的事情,就测试和分析你的代码。你可能需要改变你的设计或一些实现的细节。这也会影响你未来的决策。任何环境都有许多分析器和诊断工具可用。我们在使用 jcmd 命令进行 JVM示例中描述了其中的一个,jcmd

了解您的垃圾收集器是如何工作的(参见了解 G1 垃圾收集器配方),并且不要忘记使用 JVM 日志记录(在JVM 的统一日志记录配方中描述)。

在那之后,您可能需要调整 JVM 和垃圾收集器。以下是一些经常使用的java命令行参数(默认情况下,大小以字节指定,但您可以附加字母 k 或 K 表示千字节,m 或 M 表示兆字节,g 或 G 表示千兆字节):

  • -Xms size:此选项允许我们设置初始堆大小(必须大于 1 MB 且是 1024 的倍数)。

  • -Xmx size:此选项允许我们设置最大堆大小(必须大于 2 MB 且是 1024 的倍数)。

  • -Xmn size-XX:NewSize=size-XX:MaxNewSize=size的组合:此选项允许我们设置年轻代的初始和最大大小。为了有效的 GC,它必须低于-Xmx size。Oracle 建议将其设置为堆大小的 25%以上但低于 50%。

  • -XX:NewRatio=ratio:此选项允许我们设置年轻代和老年代之间的比率(默认为两个)。

  • -Xss size:此选项允许我们设置线程堆栈大小。不同平台的默认值如下:

  • Linux/ARM(32 位):320 KB

  • Linux/ARM(64 位):1,024 KB

  • Linux/x64(64 位):1,024 KB

  • macOS(64 位):1,024 KB

  • Oracle Solaris/i386(32 位):320 KB

  • Oracle Solaris/x64(64 位):1,024 KB

  • Windows:取决于虚拟内存

  • -XX:MaxMetaspaceSize=size:此选项允许我们设置类元数据区的上限(默认情况下没有限制)。

内存泄漏的明显迹象是老年代的增长导致完整 GC 更频繁地运行。要进行调查,您可以使用将堆内存转储到文件的 JVM 参数:

  • -XX:+HeapDumpOnOutOfMemoryError:允许我们将 JVM 堆内容保存到文件中,但仅当抛出java.lang.OutOfMemoryError异常时。默认情况下,堆转储保存在当前目录中,名称为java_pid<pid>.hprof,其中<pid>是进程 ID。使用-XX:HeapDumpPath=<path>选项来自定义转储文件位置。<path>值必须包括文件名。

  • -XX:OnOutOfMemoryError="<cmd args>;<cmd args>":允许我们提供一组命令(用分号分隔),当抛出OutOfMemoryError异常时将执行这些命令。

  • -XX:+UseGCOverheadLimit:调节 GC 占用时间比例的大小,超过这个比例会抛出OutOfMemoryError异常。例如,并行 GC 将在 GC 占用时间超过 98%且恢复的堆不到 2%时抛出OutOfMemoryError异常。此选项在堆较小时特别有用,因为它可以防止 JVM 在几乎没有进展的情况下运行。默认情况下已启用。要禁用它,请使用-XX:-UseGCOverheadLimit

了解 Epsilon,一种低开销的垃圾收集器

一个流行的 Java 面试问题是,您能强制进行垃圾收集吗? Java 运行时内存管理仍然不受程序员控制,有时会像一个不可预测的小丑一样打断本来表现良好的应用程序,并启动全内存扫描。它通常发生在最糟糕的时候。当您尝试在负载下使用短时间运行来测量应用程序性能时,后来意识到大量时间和资源都花在了垃圾收集过程上,并且在更改代码后,垃圾收集的模式变得与更改代码之前不同,这尤其令人恼火。

在本章中,我们描述了许多编程技巧和解决方案,可以帮助减轻垃圾收集器的压力。然而,它仍然是应用程序性能的独立和不可预测的贡献者(或减少者)。如果垃圾收集器能够更好地受控制,至少在测试目的中,或者可以关闭,那不是很好吗?在 Java 11 中,引入了一个名为 Epsilon 的垃圾收集器,称为无操作垃圾收集器。

乍一看,这看起来很奇怪——一个不收集任何东西的垃圾收集器。但它是可预测的(这是肯定的),因为它什么也不做,这个特性使我们能够在短时间内测试算法,而不用担心不可预测的暂停。此外,还有一整类需要在短时间内尽可能利用所有资源的小型短期应用程序,最好重新启动 JVM 并让负载均衡器执行故障转移,而不是尝试考虑垃圾收集过程中不可预测的 Joker。

它也被设想为一个基准过程,可以让我们估计常规垃圾收集器的开销。

如何做...

要调用无操作垃圾收集器,请使用-XX:+UseEpsilonGC选项。在撰写本文时,它需要一个-XX:+UnlockExperimentalVMOptions选项来访问新功能。

我们将使用以下程序进行演示:

package com.packt.cookbook.ch11_memory;
import java.util.ArrayList;
import java.util.List;
public class Epsilon {
    public static void main(String... args) {
        List<byte[]> list = new ArrayList<>();
        int n = 4 * 1024 * 1024;
        for(int i=0; i < n; i++){
            list.add(new byte[1024]);
            byte[] arr = new byte[1024];
        }
    }
}

正如您所看到的,在这个程序中,我们试图通过在每次迭代中向列表添加 1KB 数组来分配 4GB 的内存。与此同时,我们还在每次迭代中创建一个 1K 数组arr,但不使用对它的引用,因此传统的垃圾收集器可以收集它。

首先,我们将使用默认的垃圾收集器运行前面的程序:

time java -cp cookbook-1.0.jar -Xms4G -Xmx4G -Xlog:gc com.packt.cookbook.ch11_memory.Epsilon

请注意,我们已将 JVM 堆内存限制为 4GB,因为出于演示目的,我们希望程序以OutOfMemoryError退出。我们已经使用time命令包装了调用以捕获三个值:

  • 实际时间:程序运行的时间

  • 用户时间:程序使用 CPU 的时间

  • 系统时间:操作系统为程序工作的时间

我们使用了 JDK 11:

java -version
java version "11-ea" 2018-09-25
Java(TM) SE Runtime Environment 18.9 (build 11-ea+22)
Java HotSpot(TM) 64-Bit Server VM 18.9 (build 11-ea+22, mixed mode)

在您的计算机上,前面的命令的输出可能会有所不同。在我们的测试运行期间,当我们使用指定的java命令参数执行前面的程序时,输出以以下四行开头:

Using G1
GC(0) Pause Young (Normal) (G1 Evacuation Pause) 204M->101M(4096M)
GC(1) Pause Young (Normal) (G1 Evacuation Pause) 279M->191M(4096M)
GC(2) Pause Young (Normal) (G1 Evacuation Pause) 371M->280M(4096M)

正如您所看到的,G1 垃圾收集器是 JDK 11 中的默认值,并且它立即开始收集未引用的arr对象。正如我们所预期的那样,程序在OutOfMemoryError后退出:

GC(50) Pause Full (G1 Evacuation Pause) 4090M->4083M(4096M)
GC(51) Concurrent Cycle 401.931ms
GC(52) To-space exhausted
GC(52) Pause Young (Concurrent Start) (G1 Humongous Allocation)
GC(53) Concurrent Cycle
GC(54) Pause Young (Normal) (G1 Humongous Allocation) 4088M->4088M(4096M)
GC(55) Pause Full (G1 Humongous Allocation) 4088M->4085M(4096M)
GC(56) Pause Full (G1 Humongous Allocation) 4085M->4085M(4096M)
GC(53) Concurrent Cycle 875.061ms
Exception in thread "main" java.lang.OutOfMemoryError: Java heap space
 at java.base/java.util.Arrays.copyOf(Arrays.java:3720)
 at java.base/java.util.Arrays.copyOf(Arrays.java:3689)
 at java.base/java.util.ArrayList.grow(ArrayList.java:237)
 at java.base/java.util.ArrayList.grow(ArrayList.java:242)
 at java.base/java.util.ArrayList.add(ArrayList.java:485)
 at java.base/java.util.ArrayList.add(ArrayList.java:498)
 at com.packt.cookbook.ch11_memory.Epsilon.main(Epsilon.java:12)

时间实用程序产生了以下结果:

real 0m11.549s    //How long the program ran
user 0m35.301s    //How much time the CPU was used by the program
sys 0m19.125s     //How much time the OS worked for the program

我们的计算机是多核的,因此 JVM 能够并行利用多个核心,很可能是用于垃圾收集。这就是为什么用户时间比实际时间长,系统时间也因同样的原因比实际时间长。

现在让我们用以下命令运行相同的程序:

time java -cp cookbook-1.0.jar -XX:+UnlockExperimentalVMOptions -XX:+UseEpsilonGC -Xms4G -Xmx4G -Xlog:gc com.packt.cookbook.ch11_memory.Epsilon

请注意,我们已添加了-XX:+UnlockExperimentalVMOptions -XX:+UseEpsilonGC选项,这需要 Epsilon 垃圾收集器。结果如下:

Non-resizeable heap; start/max: 4096M
Using TLAB allocation; max: 4096K
Elastic TLABs enabled; elasticity: 1.10x
Elastic TLABs decay enabled; decay time: 1000ms
Using Epsilon
Heap: 4096M reserved, 4096M (100.00%) committed, 205M (5.01%) used
Heap: 4096M reserved, 4096M (100.00%) committed, 410M (10.01%) used
Heap: 4096M reserved, 4096M (100.00%) committed, 614M (15.01%) used
Heap: 4096M reserved, 4096M (100.00%) committed, 820M (20.02%) used
Heap: 4096M reserved, 4096M (100.00%) committed, 1025M (25.02%) used
Heap: 4096M reserved, 4096M (100.00%) committed, 1230M (30.03%) used
Heap: 4096M reserved, 4096M (100.00%) committed, 1435M (35.04%) used
Heap: 4096M reserved, 4096M (100.00%) committed, 1640M (40.04%) used
Heap: 4096M reserved, 4096M (100.00%) committed, 1845M (45.05%) used
Heap: 4096M reserved, 4096M (100.00%) committed, 2050M (50.05%) used
Heap: 4096M reserved, 4096M (100.00%) committed, 2255M (55.06%) used
Heap: 4096M reserved, 4096M (100.00%) committed, 2460M (60.06%) used
Heap: 4096M reserved, 4096M (100.00%) committed, 2665M (65.07%) used
Heap: 4096M reserved, 4096M (100.00%) committed, 2870M (70.07%) used
Heap: 4096M reserved, 4096M (100.00%) committed, 3075M (75.08%) used
Heap: 4096M reserved, 4096M (100.00%) committed, 3280M (80.08%) used
Heap: 4096M reserved, 4096M (100.00%) committed, 3485M (85.09%) used
Heap: 4096M reserved, 4096M (100.00%) committed, 3690M (90.09%) used
Heap: 4096M reserved, 4096M (100.00%) committed, 3895M (95.10%) used
Terminating due to java.lang.OutOfMemoryError: Java heap space

正如您所看到的,垃圾收集器甚至没有尝试收集被丢弃的对象。堆空间的使用量稳步增长,直到完全耗尽,并且 JVM 以OutOfMemoryError退出。使用time实用程序允许我们测量三个时间参数:

real 0m4.239s
user 0m1.861s
sys 0m2.132s

自然地,耗尽所有堆内存所需的时间要少得多,用户时间要比实际时间少得多。这就是为什么,正如我们已经提到的那样,无操作的 Epsilon 垃圾收集器对于那些必须尽可能快速但不会消耗所有堆内存或可以随时停止的程序可能是有用的。可能还有其他垃圾收集器不做任何事情可能有帮助的用例。

第十二章:使用 JShell 的读取-评估-打印循环(REPL)

在本章中,我们将涵盖以下内容:

  • 熟悉 REPL

  • 导航 JShell 及其命令

  • 评估代码片段

  • JShell 中的面向对象编程

  • 保存和恢复 JShell 命令历史

  • 使用 JShell Java API

介绍

REPL代表读取-评估-打印循环,正如其名称所示,它读取在命令行上输入的命令,评估它,打印评估结果,并在输入任何命令时继续此过程。

所有主要语言,如 Ruby、Scala、Python、JavaScript 和 Groovy,都有 REPL 工具。Java 一直缺少这个必不可少的 REPL。如果我们要尝试一些示例代码,比如使用SimpleDateFormat解析字符串,我们必须编写一个包含所有仪式的完整程序,包括创建一个类,添加一个主方法,然后是我们想要进行实验的单行代码。然后,我们必须编译和运行代码。这些仪式使得实验和学习语言的特性变得更加困难。

使用 REPL,您只需输入您感兴趣的代码行,并且您将立即得到有关表达式是否在语法上正确并且是否给出所需结果的反馈。REPL 是一个非常强大的工具,特别适合初次接触该语言的人。假设您想展示如何在 Java 中打印Hello World;为此,您必须开始编写类定义,然后是public static void main(String [] args)方法,最后您将解释或尝试解释许多概念,否则对于新手来说将很难理解。

无论如何,从 Java 9 开始,Java 开发人员现在可以停止抱怨缺少 REPL 工具。一个名为 JShell 的新的 REPL 被捆绑到了 JDK 安装中。因此,我们现在可以自豪地将Hello World作为我们的第一个Hello World代码。

在本章中,我们将探索 JShell 的特性,并编写代码,这些代码将真正使我们惊叹并欣赏 REPL 的力量。我们还将看到如何使用 JShell Java API 创建我们自己的 REPL。

熟悉 REPL

在这个配方中,我们将看一些基本操作,以帮助我们熟悉 JShell 工具。

准备工作

确保您安装了最新的 JDK 版本,其中包含 JShell。JShell 从 JDK 9 开始可用。

如何做...

  1. 您应该将%JAVA_HOME%/bin(在 Windows 上)或$JAVA_HOME/bin(在 Linux 上)添加到您的PATH变量中。如果没有,请访问第一章中的在 Windows 上安装 JDK 18.9 并设置 PATH 变量在 Linux(Ubuntu,x64)上安装 JDK 18.9 并配置 PATH 变量这两个配方。

  2. 在命令行上,输入jshell并按Enter

  3. 您将看到一条消息,然后是一个jshell>提示:

  1. 斜杠(/),后跟 JShell 支持的命令,可帮助您与 JShell 进行交互。就像我们尝试/help intro以获得以下内容:

  1. 让我们打印一个Hello World消息:

  1. 让我们打印一个自定义的Hello World消息:

  1. 您可以使用上下箭头键浏览执行的命令。

它是如何工作的...

jshell提示符中输入的代码片段被包装在足够的代码中以执行它们。因此,变量、方法和类声明被包装在一个类中,表达式被包装在一个方法中,该方法又被包装在一个类中。其他东西,如导入和类定义,保持原样,因为它们是顶级实体,即在另一个类定义中包装一个类定义是不需要的,因为类定义是一个可以独立存在的顶级实体。同样,在 Java 中,导入语句可以单独出现,它们出现在类声明之外,因此不需要被包装在一个类中。

在接下来的示例中,我们将看到如何定义一个方法,导入其他包,并定义类。

在前面的示例中,我们看到了$1 ==> "Hello World"。如果我们有一些值没有与之关联的变量,jshell会给它一个变量名,如$1$2

导航 JShell 及其命令

为了利用工具,我们需要熟悉如何使用它,它提供的命令以及我们可以使用的各种快捷键,以提高生产力。在这个示例中,我们将看看我们可以通过 JShell 导航的不同方式,以及它提供的不同键盘快捷键,以便在使用它时提高生产力。

如何做...

  1. 通过在命令行上键入jshell来生成JShell。您将收到一个欢迎消息,其中包含开始的说明。

  2. 键入/help intro以获得关于 JShell 的简要介绍:

  1. 键入/help以获取支持的命令列表:

  1. 要获取有关命令的更多信息,请键入/help <command>。例如,要获取有关/edit的信息,请键入/help /edit

  1. JShell 中有自动补全支持。这使得 Java 开发人员感到宾至如归。您可以使用Tab键来调用自动补全:

  1. 您可以使用/!来执行先前执行的命令,使用/line_number在行号重新执行表达式。

  2. 要通过命令行导航光标,使用Ctrl + A到达行的开头,使用Ctrl + E到达行的结尾。

评估代码片段

在这个示例中,我们将看到执行以下代码片段:

  • 导入语句

  • 类声明

  • 接口声明

  • 方法声明

  • 字段声明

  • 语句

如何做...

  1. 打开命令行并启动 JShell。

  2. 默认情况下,JShell 导入了一些库。我们可以通过发出/imports命令来检查:

  1. 通过发出import java.text.SimpleDateFormat命令来导入java.text.SimpleDateForm。这将导入SimpleDateFormat类。

  2. 让我们声明一个Employee类。我们将每行发出一个语句,以便它是一个不完整的语句,并且我们将以与任何普通编辑器相同的方式进行。下面的插图将澄清这一点:

        class Employee{
          private String empId;
          public String getEmpId() {
            return empId;
          }
          public void setEmpId ( String empId ) {
            this.empId = empId;
          }
        }

您将得到以下输出:

  1. 让我们声明一个Employability接口,它定义了一个名为employable()的方法,如下面的代码片段所示:
        interface Employability { 
          public boolean employable();
        }

通过jshell创建的前面的接口如下截图所示:

  1. 让我们声明一个newEmployee(String empId)方法,它用给定的empId构造一个Employee对象:
        public Employee newEmployee(String empId ) {
          Employee emp = new Employee();
          emp.setEmpId(empId);
          return emp;
        }

JShell 中定义的前面的方法如下所示:

  1. 我们将使用前一步中定义的方法来创建一个声明Employee变量的语句:
        Employee e = newEmployee("1234");
e.get + Tab key generates autocompletion as supported by the IDEs:

还有更多...

我们可以调用一个未定义的方法。看一下下面的例子:

public void newMethod(){
  System.out.println("New  Method");
  undefinedMethod();
}

下面的图片显示了newMethod()调用undefinedMethod()的定义:

但是,在使用方法之前,不能调用该方法:

public void undefinedMethod(){
  System.out.println("Now defined");
}

下面的图片显示了定义undefinedMethod(),然后可以成功调用newMethod()

只有在我们定义了undefinedMethod()之后才能调用newMethod()

JShell 中的面向对象编程

在这个示例中,我们将使用预定义的 Java 类定义文件并将它们导入到 JShell 中。然后,我们将在 JShell 中使用这些类。

如何做...

  1. 我们将在这个示例中使用的类定义文件在本书的代码下载中的Chapter12/4_oo_programming中可用。

  2. 有三个类定义文件:Engine.javaDimensions.javaCar.java

  3. 导航到这三个类定义文件可用的目录。

  4. /open命令允许我们从文件中加载代码。

  5. 加载Engine类定义并创建一个Engine对象:

  1. 加载Dimensions类定义并创建一个Dimensions对象:

  1. 加载Car类定义并创建一个Car对象:

保存和恢复 JShell 命令历史

我们将尝试在jshell中执行一些代码片段,作为向新手解释 Java 编程的手段。此外,记录执行的代码片段的形式对于正在学习语言的人将是有用的。

在这个示例中,我们将执行一些代码片段并将它们保存到一个文件中。然后我们将从保存的文件中加载代码片段。

如何做...

  1. 让我们执行一系列的代码片段,如下所示:
        "Hello World"
        String msg = "Hello, %s. Good Morning"
        System.out.println(String.format(msg, "Friend"))
        int someInt = 10
        boolean someBool = false
        if ( someBool ) {
          System.out.println("True block executed");
        }
        if ( someBool ) {
          System.out.println("True block executed");
        }else{
          System.out.println("False block executed");
        }
        for ( int i = 0; i < 10; i++ ){
          System.out.println("I is : " + i );
        }

您将得到以下输出:

  1. 使用/save history命令将执行的代码片段保存到名为history的文件中。

  2. 使用dirls退出 shell,并列出目录中的文件,具体取决于操作系统。列表中将会有一个history文件。

  3. 打开jshell并使用/list检查执行的代码片段的历史记录。您会看到没有执行任何代码片段。

  4. 使用/open history加载history文件,然后使用/list检查执行的代码片段的历史记录。您将看到所有先前执行的代码片段被执行并添加到历史记录中:

使用 JShell Java API

JDK 11 提供了用于评估 Java 代码片段的工具(如jshell)的 Java API。这个 Java API 存在于jdk.jshell模块中(cr.openjdk.java.net/~rfield/arch/doc/jdk/jshell/package-summary.html)。因此,如果您想在应用程序中使用 API,您需要声明对jdk.jshell模块的依赖。

在这个示例中,我们将使用 JShell JDK API 来评估简单的代码片段,并且您还将看到不同的 API 来获取 JShell 的状态。这个想法不是重新创建 JShell,而是展示如何使用其 JDK API。

在这个示例中,我们将不使用 JShell;相反,我们将按照通常的方式使用javac进行编译,并使用java进行运行。

如何做...

  1. 我们的模块将依赖于jdk.jshell模块。因此,模块定义将如下所示:
        module jshell{
          requires jdk.jshell;
        }
  1. 使用jdk.jshell.JShell类的create()方法或jdk.jshell.JShell.Builder中的构建器 API 创建一个实例:
        JShell myShell = JShell.create();
  1. 使用java.util.ScannerSystem.in中读取代码片段:
        try(Scanner reader = new Scanner(System.in)){
          while(true){
            String snippet = reader.nextLine();
            if ( "EXIT".equals(snippet)){
              break;
            }
          //TODO: Code here for evaluating the snippet using JShell API
          }
        }
  1. 使用jdk.jshell.JShell#eval(String snippet)方法来评估输入。评估将导致jdk.jshell.SnippetEvent的列表,其中包含评估的状态和输出。上述代码片段中的TODO将被以下行替换:
        List<SnippetEvent> events = myShell.eval(snippet);
        events.stream().forEach(se -> {
          System.out.print("Evaluation status: " + se.status());
          System.out.println(" Evaluation result: " + se.value());
        });
  1. 当评估完成时,我们将使用jdk.jshell.JShell.snippets()方法打印处理的代码片段,该方法将返回已处理的SnippetStream
        System.out.println("Snippets processed: ");
        myShell.snippets().forEach(s -> {
          String msg = String.format("%s -> %s", s.kind(), s.source());
          System.out.println(msg);
        });
  1. 类似地,我们可以打印活动方法和变量,如下所示:
        System.out.println("Methods: ");
        myShell.methods().forEach(m -> 
          System.out.println(m.name() + " " + m.signature()));

        System.out.println("Variables: ");
        myShell.variables().forEach(v -> 
          System.out.println(v.typeName() + " " + v.name()));
  1. 在应用程序退出之前,我们通过调用其close()方法关闭JShell实例:
        myShell.close();

此示例的代码可以在Chapter12/6_jshell_api中找到。您可以使用同一目录中提供的run.batrun.sh脚本来运行示例。示例执行和输出如下所示:

工作原理...

eval(String snippet) method. We can even drop the previously-evaluated snippet using the drop(Snippet snippet) method. Both these methods result in a change of the internal state maintained by jdk.jshell.JShell.

传递给JShell评估引擎的代码片段被分类如下:

  • 错误:语法错误的输入

  • 表达式:可能会产生一些输出的输入

  • 导入:导入语句

  • 方法:方法声明

  • 语句:语句

  • 类型声明:类型,即类/接口声明

  • 变量声明:变量声明

所有这些类别都在jdk.jshell.Snippet.Kind枚举中捕获。

jdk.jshell.Snippet class.

第十三章:使用新的日期和时间 API

在本章中,我们将介绍以下内容:

  • 如何构建不依赖于时区的日期和时间实例

  • 如何构建依赖于时区的时间实例

  • 如何创建日期间的基于日期的周期

  • 如何创建基于时间的时间实例之间的周期

  • 如何表示纪元时间

  • 如何操作日期和时间实例

  • 如何比较日期和时间

  • 如何处理不同的日历系统

  • 如何使用DateTimeFormatter格式化日期

介绍

使用java.util.Datejava.util.Calendar对于 Java 开发人员来说是一种痛苦,直到 Stephen Colebourne (www.joda.org/)引入了 Joda-Time (www.joda.org/joda-time/),这是一个用于在 Java 中处理日期和时间的库。Joda-Time 相对于 JDK API 提供了以下优势:

  • 更丰富的 API 用于获取日期组件,如月份的日、星期的日、月份和年份,以及时间组件,如小时、分钟和秒。

  • 轻松操作和比较日期和时间。

  • 可用的既不依赖于时区又依赖于时区的 API。大多数情况下,我们将使用不依赖于时区的 API,这样更容易使用 API。

  • 令人惊叹的 API,可以计算日期和时间之间的持续时间。

  • 日期格式化和持续时间计算默认遵循 ISO 标准。

  • 支持多个日历,如公历、佛历和伊斯兰历。

Joda-Time 启发了 JSR-310 (jcp.org/en/jsr/detail?id=310),将 API 移植到了java.time包下,并作为 Java 8 的一部分发布。由于新的日期/时间 API 基于 ISO 标准,因此可以轻松地在应用程序的不同层之间集成日期/时间库。例如,在 JavaScript 层,我们可以使用 moment.js (momentjs.com/docs/)处理日期和时间,并使用其默认格式化样式(符合 ISO 标准)将数据发送到服务器。在服务器层,我们可以使用新的日期/时间 API 根据需要获取日期和时间实例。因此,我们可以使用标准日期表示在客户端和服务器之间进行交互。

在本章中,我们将探讨利用新的日期/时间 API 的不同方法。

如何处理不依赖于时区的日期和时间实例

在 JSR-310 之前,要为任何时间点或日历中的任何一天创建日期和时间实例并不直观。唯一的方法是使用java.util.Calendar对象设置所需的日期和时间,然后调用getTime()方法获取java.util.Date的实例。这些日期和时间实例也包含时区信息,有时会导致应用程序中的错误。

在新的 API 中,获取日期和时间实例要简单得多,这些日期和时间实例不包含任何与时区相关的信息。在本示例中,我们将向您展示如何使用java.time.LocalDate表示仅日期的实例,使用java.time.LocalTime表示仅时间的实例,以及使用java.time.LocalDateTime表示日期/时间实例。这些日期和时间实例是不依赖于时区的,并表示机器的当前时区中的信息。

准备工作

您需要安装至少 JDK 8 才能使用这些更新的库,本章中的示例使用 Java 10 及更高版本支持的语法。如果您愿意,可以直接在 JShell 中运行这些代码片段。您可以访问第十二章,使用 JShell 进行读取-求值-打印循环(REPL),了解更多关于 JShell 的信息。

如何做…

  1. 使用now()方法可以获取包装在java.time.LocalDate中的当前日期,如下所示:
var date = LocalDate.now();
  1. 我们可以使用通用的get(fieldName)方法或特定的方法,如getDayOfMonth()getDayOfYear()getDayOfWeek()getMonth()getYear()来获取java.time.LocalDate实例的各个字段,如下所示:
var dayOfWeek = date.getDayOfWeek();
var dayOfMonth = date.getDayOfMonth();
var month = date.getMonth();
var year = date.getYear();
  1. 我们可以使用of()方法获取日历中任何日期的java.time.LocalDate实例,如下所示:
var date1 = LocalDate.of(2018, 4, 12);
var date2 = LocalDate.of(2018, Month.APRIL, 12);
date2 = LocalDate.ofYearDay(2018, 102);
date2 = LocalDate.parse("2018-04-12");
  1. java.time.LocalTime类,用于表示任何时间实例,而不考虑日期。可以使用以下方法获取当前时间:
var time = LocalTime.now();
  1. java.time.LocalTime类还带有of()工厂方法,可用于创建表示任何时间的实例。类似地,有方法来获取时间的不同组件,如下所示:
time = LocalTime.of(23, 11, 11, 11);
time = LocalTime.ofSecondOfDay(3600);

var hour = time.getHour();
var minutes = time.getMinute();
var seconds = time.get(ChronoField.SECOND_OF_MINUTE);
  1. java.time.LocalDateTime用于表示包含时间和日期的实体。它由java.time.LocalDatejava.time.LocalTime组成,分别表示日期和时间。可以使用now()和不同版本的of()工厂方法创建其实例,如下所示:
var dateTime1 = LocalDateTime.of(2018, 04, 12, 13, 30, 22);
var dateTime2 = LocalDateTime.of(2018, Month.APRIL, 12, 13, 30, 22);
dateTime2 = LocalDateTime.of(date2, LocalTime.of(13, 30, 22));

它是如何工作的…

java.time包中的以下三个类代表默认时区(系统的时区)中的日期和时间值:

  • java.time.LocalDate: 只包含日期信息

  • java.time.LocalTime: 只包含时间信息

  • java.time.LocalDateTime: 包含日期和时间信息

每个类都由以下字段组成:

  • 日期

  • 小时

  • 分钟

  • 毫秒

所有类都包含now()方法,返回当前的日期和时间值。提供了of()工厂方法来根据它们的字段(如日、月、年、小时和分钟)构建日期和时间实例。java.time.LocalDateTimejava.time.LocalDatejava.time.LocalTime组成,因此可以从java.time.LocalDatejava.time.LocalTime构建java.time.LocalDateTime

从这个示例中学到的重要 API 如下:

  • now(): 这会给出当前日期和时间

  • of(): 这个工厂方法用于构造所需的日期、时间和日期/时间实例

还有更多…

在 Java 9 中,有一个新的 API,datesUntil,它接受结束日期并返回从当前对象的日期到结束日期(但不包括结束日期)的顺序日期流(换句话说,java.time.LocalDate)。使用此 API 将给定月份和年份的所有日期分组到它们各自的星期几,即星期一、星期二、星期三等。

让我们接受月份和年份,并将其分别存储在monthyear变量中。范围的开始将是该月和年的第一天,如下所示:

var startDate = LocalDate.of(year, month, 1);

范围的结束日期将是该月的天数,如下所示:

var endDate = startDate.plusDays(startDate.lengthOfMonth());

我们正在使用lengthOfMonth方法获取该月的天数。然后我们使用datesUntil方法获取java.time.LocalDate的流,然后执行一些流操作:

  • 按星期几对java.time.LocalDate实例进行分组。

  • 将分组的实例收集到java.util.ArrayList中。但在此之前,我们正在应用转换将java.time.LocalDate实例转换为一个简单的月份,这给我们提供了一个表示月份的整数列表。

代码中的前两个操作如下所示:

var dayBuckets = startDate.datesUntil(endDate).collect(

Collectors.groupingBy(date -> date.getDayOfWeek(), 
    Collectors.mapping(LocalDate::getDayOfMonth, 
        Collectors.toList())
));

此代码可以在下载的代码的Chapter13/1_2_print_calendar中找到。

如何构造依赖于时区的时间实例

在上一个示例中,如何构造不依赖于时区的日期和时间实例,我们构造了不包含任何时区信息的日期和时间对象。它们隐式地表示了系统时区中的值;这些类是java.time.LocalDatejava.time.LocalTimejava.time.LocalDateTime

通常我们需要根据某个时区表示时间;在这种情况下,我们将使用java.time.ZonedDateTime,它包含了带有java.time.LocalDateTime的时区信息。时区信息是使用java.time.ZoneIdjava.time.ZoneOffset实例嵌入的。还有两个类,java.time.OffsetTimejava.time.OffsetDateTime,它们也是java.time.LocalTimejava.time.LocalDateTime的特定于时区的变体。

在这个示例中,我们将展示如何使用java.time.ZonedDateTimejava.time.ZoneIdjava.time.ZoneOffsetjava.time.OffsetTimejava.time.OffsetDateTime

准备工作

我们将使用 Java 10 的语法,使用var来声明局部变量和模块。除了 Java 10 及以上版本,没有其他先决条件。

操作步骤

  1. 我们将使用now()工厂方法根据系统的时区获取当前的日期、时间和时区信息,如下所示:
var dateTime = ZonedDateTime.now();
  1. 我们将使用java.time.ZoneId根据任何给定的时区获取当前的日期和时间信息:
var indianTz = ZoneId.of("Asia/Kolkata");
var istDateTime = ZonedDateTime.now(indianTz);
  1. java.time.ZoneOffset也可以用来提供日期和时间的时区信息,如下所示:
var indianTzOffset = ZoneOffset.ofHoursMinutes(5, 30);
istDateTime = ZonedDateTime.now(indianTzOffset);
  1. 我们将使用of()工厂方法构建java.time.ZonedDateTime的一个实例:
ZonedDateTime dateTimeOf = ZonedDateTime.of(2018, 4, 22, 14, 30, 11, 33, indianTz);
  1. 我们甚至可以从java.time.ZonedDateTime中提取java.time.LocalDateTime
var localDateTime = dateTimeOf.toLocalDateTime();

工作原理

首先,让我们看看如何捕获时区信息。它是根据格林威治标准时间(GMT)的小时和分钟数捕获的,也被称为协调世界时(UTC)。例如,印度标准时间(IST),也称为 Asia/Kolkata,比 GMT 提前 5 小时 30 分钟。

Java 提供了java.time.ZoneIdjava.time.ZoneOffset来表示时区信息。java.time.ZoneId根据时区名称捕获时区信息,例如 Asia/Kolkata,US/Pacific 和 US/Mountain。大约有 599 个时区 ID。这是使用以下代码行计算的:

jshell> ZoneId.getAvailableZoneIds().stream().count()
$16 ==> 599

我们将打印 10 个时区 ID:

jshell> ZoneId.getAvailableZoneIds().stream().limit(10).forEach(System.out::println)
Asia/Aden
America/Cuiaba
Etc/GMT+9
Etc/GMT+8
Africa/Nairobi
America/Marigot
Asia/Aqtau
Pacific/Kwajalein
America/El_Salvador
Asia/Pontianak

时区名称,例如 Asia/Kolkata,Africa/Nairobi 和 America/Cuiaba,基于国际分配的数字管理局(IANA)发布的时区数据库。IANA 提供的时区区域名称是 Java 的默认值。

有时时区区域名称也表示为 GMT+02:30 或简单地+02:30,这表示当前时区与 GMT 时区的偏移(提前或落后)。

这个java.time.ZoneId捕获了java.time.zone.ZoneRules,其中包含了获取时区偏移转换和其他信息的规则,比如夏令时。让我们调查一下 US/Pacific 的时区规则:

jshell> ZoneId.of("US/Pacific").getRules().getDaylightSavings(Instant.now())
$31 ==> PT1H

jshell> ZoneId.of("US/Pacific").getRules().getOffset(LocalDateTime.now())
$32 ==> -07:00

jshell> ZoneId.of("US/Pacific").getRules().getStandardOffset(Instant.now())
$33 ==> -08:00

getDaylightSavings()方法返回一个java.time.Duration对象,表示以小时、分钟和秒为单位的一些持续时间。默认的toString()实现返回使用 ISO 8601 基于秒的表示,其中 1 小时 20 分钟 20 秒的持续时间表示为PT1H20M20S。关于这一点将在本章的如何在时间实例之间创建基于时间的期间中进行更多介绍。

我们不会详细介绍它是如何计算的。对于那些想了解更多关于java.time.zone.ZoneRulesjava.time.ZoneId的人,请访问docs.oracle.com/javase/10/docs/api/java/time/zone/ZoneRules.htmldocs.oracle.com/javase/10/docs/api/java/time/ZoneId.html的文档。

java.time.ZoneOffset类以时区领先或落后 GMT 的小时和分钟数来捕获时区信息。让我们使用of*()工厂方法创建java.time.ZoneOffset类的一个实例:

jshell> ZoneOffset.ofHoursMinutes(5,30)
$27 ==> +05:30

java.time.ZoneOffset类继承自java.time.ZoneId并添加了一些新方法。重要的是要记住根据应用程序中要使用的所需时区构造java.time.ZoneOffsetjava.time.ZoneId的正确实例。

现在我们对时区表示有了了解,java.time.ZonedDateTime实际上就是java.time.LocalDateTime加上java.time.ZoneIdjava.time.ZoneOffset。还有两个其他类,java.time.OffsetTimejava.time.OffsetDateTime,分别包装了java.time.LocalTimejava.time.LocalDateTime,以及java.time.ZoneOffset

让我们看看一些构造java.time.ZonedDateTime实例的方法。

第一种方法是使用now()

Signatures:
ZonedDateTime ZonedDateTime.now()
ZonedDateTime ZonedDateTime.now(ZoneId zone)
ZonedDateTime ZonedDateTime.now(Clock clock)

jshell> ZonedDateTime.now()
jshell> ZonedDateTime.now(ZoneId.of("Asia/Kolkata"))
$36 ==> 2018-05-04T21:58:24.453113900+05:30[Asia/Kolkata]
jshell> ZonedDateTime.now(Clock.fixed(Instant.ofEpochSecond(1525452037), ZoneId.of("Asia/Kolkata")))
$54 ==> 2018-05-04T22:10:37+05:30[Asia/Kolkata]

now()的第一种用法使用系统时钟以及系统时区来打印当前日期和时间。now()的第二种用法使用系统时钟,但时区由java.time.ZoneId提供,这种情况下是 Asia/Kolkata。now()的第三种用法使用提供的固定时钟和java.time.ZoneId提供的时区。

使用java.time.Clock类及其静态方法fixed()创建固定时钟,该方法接受java.time.Instantjava.time.ZoneId的实例。java.time.Instant的实例是在纪元后的一些静态秒数后构建的。java.time.Clock用于表示新的日期/时间 API 可以用来确定当前时间的时钟。时钟可以是固定的,就像我们之前看到的那样,然后我们可以创建一个比 Asia/Kolkata 时区的当前系统时间提前一小时的时钟,如下所示:

var hourAheadClock = Clock.offset(Clock.system(ZoneId.of("Asia/Kolkata")), Duration.ofHours(1));

我们可以使用这个新的时钟来构建java.time.LocalDateTimejava.time.ZonedDateTime的实例,如下所示:

jshell> LocalDateTime.now(hourAheadClock)
$64 ==> 2018-05-04T23:29:58.759973700
jshell> ZonedDateTime.now(hourAheadClock)
$65 ==> 2018-05-04T23:30:11.421913800+05:30[Asia/Kolkata]

日期和时间值都基于相同的时区,即 Asia/Kolkata,但正如我们已经了解的那样,java.time.LocalDateTime没有任何时区信息,它基于系统的时区或在这种情况下提供的java.time.Clock的值。另一方面,java.time.ZonedDateTime包含并显示时区信息为[Asia/Kolkata]。

另一种创建java.time.ZonedDateTime实例的方法是使用其of()工厂方法:

Signatures:
ZonedDateTime ZonedDateTime.of(LocalDate date, LocalTime time, ZoneId zone)
ZonedDateTime ZonedDateTime.of(LocalDateTime localDateTime, ZoneId zone)
ZonedDateTime ZonedDateTime.of(int year, int month, int dayOfMonth, int hour, int minute, int second, int nanoOfSecond, ZoneId zone)

jshell> ZonedDateTime.of(LocalDateTime.of(2018, 1, 1, 13, 44, 44), ZoneId.of("Asia/Kolkata"))
$70 ==> 2018-01-01T13:44:44+05:30[Asia/Kolkata]

jshell> ZonedDateTime.of(LocalDate.of(2018,1,1), LocalTime.of(13, 44, 44), ZoneId.of("Asia/Kolkata"))
$71 ==> 2018-01-01T13:44:44+05:30[Asia/Kolkata]

jshell> ZonedDateTime.of(LocalDate.of(2018,1,1), LocalTime.of(13, 44, 44), ZoneId.of("Asia/Kolkata"))
$72 ==> 2018-01-01T13:44:44+05:30[Asia/Kolkata]

jshell> ZonedDateTime.of(2018, 1, 1, 13, 44, 44, 0, ZoneId.of("Asia/Kolkata"))
$73 ==> 2018-01-01T13:44:44+05:30[Asia/Kolkata] 

还有更多...

我们提到了java.time.OffsetTimejava.time.OffsetDateTime类。两者都包含特定于时区的时间值。在我们结束这个教程之前,让我们玩一下这些类。

  • 使用of()工厂方法:
jshell> OffsetTime.of(LocalTime.of(14,12,34), ZoneOffset.ofHoursMinutes(5, 30))
$74 ==> 14:12:34+05:30

jshell> OffsetTime.of(14, 34, 12, 11, ZoneOffset.ofHoursMinutes(5, 30))
$75 ==> 14:34:12.000000011+05:30
  • 使用now()工厂方法:
Signatures:
OffsetTime OffsetTime.now()
OffsetTime OffsetTime.now(ZoneId zone)
OffsetTime OffsetTime.now(Clock clock)

jshell> OffsetTime.now()
$76 ==> 21:49:16.895192800+03:00

jshell> OffsetTime.now(ZoneId.of("Asia/Kolkata"))

jshell> OffsetTime.now(ZoneId.of("Asia/Kolkata"))
$77 ==> 00:21:04.685836900+05:30

jshell> OffsetTime.now(Clock.offset(Clock.systemUTC(), Duration.ofMinutes(330)))
$78 ==> 00:22:00.395463800Z

值得注意的是我们如何构建了一个java.time.Clock实例,它比 UTC 时钟提前了 330 分钟(5 小时 30 分钟)。另一个类java.time.OffsetDateTimejava.time.OffsetTime相同,只是它使用java.time.LocalDateTime。因此,您将向其工厂方法of()传递日期信息,即年、月和日,以及时间信息。

如何在日期实例之间创建基于日期的期间

在过去,我们曾试图测量两个日期实例之间的期间,但由于 Java 8 之前缺乏 API 以及缺乏捕获此信息的适当支持,我们采用了不同的方法。我们记得使用基于 SQL 的方法来处理这样的信息。但从 Java 8 开始,我们有了一个新的类java.time.Period,它可以用来捕获两个日期实例之间的期间,以年、月和日的数量来表示。

此外,该类支持解析基于 ISO 8601 标准的字符串来表示期间。该标准规定任何期间都可以用PnYnMnD的形式表示,其中P是表示期间的固定字符,nY表示年数,nM表示月数,nD表示天数。例如,2 年 4 个月 10 天的期间表示为P2Y4M10D

准备工作

您至少需要 JDK8 来使用java.time.Period,需要 JDK 9 才能使用 JShell,并且至少需要 JDK 10 才能使用本示例中使用的示例。

如何做…

  1. 让我们使用其of()工厂方法创建一个java.time.Period的实例,其签名为Period.of(int years, int months, int days)
jshell> Period.of(2,4,30)
$2 ==> P2Y4M30D
  1. 还有特定变体的of*()方法,即ofDays()ofMonths()ofYears(),也可以使用:
jshell> Period.ofDays(10)
$3 ==> P10D
jshell> Period.ofMonths(4)
$4 ==> P4M
jshell> Period.ofWeeks(3)
$5 ==> P21D
jshell> Period.ofYears(3)
$6 ==> P3Y

请注意,ofWeeks()方法是一个辅助方法,用于根据接受的周数构建java.time.Period

  1. 期间也可以使用期间字符串构造,该字符串通常采用P<x>Y<y>M<z>D的形式,其中xyz分别表示年、月和日的数量:
jshell> Period.parse("P2Y4M23D").getDays()
$8 ==> 23
  1. 我们还可以计算java.time.ChronoLocalDate的两个实例之间的期间(其实现之一是java.time.LocalDate):
jshell> Period.between(LocalDate.now(), LocalDate.of(2018, 8, 23))
$9 ==> P2M2D
jshell> Period.between(LocalDate.now(), LocalDate.of(2018, 2, 23))
$10 ==> P-3M-26D

这些是创建java.time.Period实例的最有用的方法。开始日期是包含的,结束日期是不包含的。

它是如何工作的…

我们利用java.time.Period中的工厂方法来创建其实例。java.time.Period有三个字段分别用于保存年、月和日的值,如下所示:

/**
* The number of years.
*/
private final int years;
/**
* The number of months.
*/
private final int months;
/**
* The number of days.
*/
private final int days;

还有一组有趣的方法,即withDays()withMonths()withYears()。如果它正在尝试更新的字段具有相同的值,则这些方法返回相同的实例;否则,它返回一个具有更新值的新实例,如下所示:

jshell> Period period1 = Period.ofWeeks(2)
period1 ==> P14D

jshell> Period period2 = period1.withDays(15)
period2 ==> P15D

jshell> period1 == period2
$19 ==> false

jshell> Period period3 = period1.withDays(14)
period3 ==> P14D

jshell> period1 == period3
$21 ==> true

还有更多…

我们甚至可以使用java.time.ChronoLocalDate中的until()方法计算两个日期实例之间的java.time.Period

jshell> LocalDate.now().until(LocalDate.of(2018, 2, 23))
$11 ==> P-3M-26D

jshell> LocalDate.now().until(LocalDate.of(2018, 8, 23))
$12 ==> P2M2D

给定java.time.Period的一个实例,我们可以使用它来操作给定的日期实例。有两种可能的方法:

  • 使用期间对象的addTosubtractFrom方法

  • 使用日期对象的plusminus方法

这两种方法都显示在以下代码片段中:

jshell> Period period1 = Period.ofWeeks(2)
period1 ==> P14D

jshell> LocalDate date = LocalDate.now()
date ==> 2018-06-21

jshell> period1.addTo(date)
$24 ==> 2018-07-05

jshell> date.plus(period1)
$25 ==> 2018-07-05

同样,您可以尝试subtractFromminus方法。还有另一组用于操作java.time.Period实例的方法,即以下方法:

  • minusminusDaysminusMonthsminusYears:从期间中减去给定的值。

  • plusplusDaysplusMonthsplusYears:将给定的值添加到期间。

  • negated:返回每个值都取反的新期间。

  • normalized:通过规范化其更高阶字段(如月和日)返回一个新的期间。例如,15 个月被规范化为 1 年和 3 个月。

我们将展示这些方法的操作,首先是minus方法:

jshell> period1.minus(Period.of(1,3,4))
$28 ==> P2Y12M25D

jshell> period1.minusDays(4)
$29 ==> P3Y15M25D

jshell> period1.minusMonths(3)
$30 ==> P3Y12M29D

jshell> period1.minusYears(1)
$31 ==> P2Y15M29D

然后,我们将看到plus方法:

jshell> Period period1 = Period.of(3, 15, 29)
period1 ==> P3Y15M29D

jshell> period1.plus(Period.of(1, 3, 4))
$33 ==> P4Y18M33D

jshell> period1.plusDays(4)
$34 ==> P3Y15M33D

jshell> period1.plusMonths(3)
$35 ==> P3Y18M29D

jshell> period1.plusYears(1)
$36 ==> P4Y15M29D

最后,这里是negated()normalized()方法:

jshell> Period period1 = Period.of(3, 15, 29)
period1 ==> P3Y15M29D

jshell> period1.negated()
$38 ==> P-3Y-15M-29D

jshell> period1
period1 ==> P3Y15M29D

jshell> period1.normalized()
$40 ==> P4Y3M29D

jshell> period1
period1 ==> P3Y15M29D

请注意,在前面的两种情况下,它并没有改变现有的期间,而是返回一个新的实例。

如何创建基于时间的期间实例

在我们之前的示例中,我们创建了一个基于日期的期间,由java.time.Period表示。在这个示例中,我们将看看如何使用java.time.Duration类来以秒和纳秒的方式创建时间实例之间的时间差异。

我们将看看创建java.time.Duration实例的不同方法,操作持续时间实例,并以小时和分钟等不同单位获取持续时间。ISO 8601 标准指定了表示持续时间的可能模式之一为PnYnMnDTnHnMnS,其中以下内容适用:

  • YMD代表日期组件字段,即年、月和日

  • T用于将日期与时间信息分隔开

  • HMS代表时间组件字段,即小时、分钟和秒

java.time.Duration的字符串表示实现基于 ISO 8601。在它是如何工作部分中有更多内容。

准备好了

您至少需要 JDK 8 才能使用java.time.Duration,并且需要 JDK 9 才能使用 JShell。

如何做...

  1. 可以使用of*()工厂方法创建java.time.Duration实例。我们将展示如何使用其中的一些方法,如下所示:
jshell> Duration.of(56, ChronoUnit.MINUTES)
$66 ==> PT56M
jshell> Duration.of(56, ChronoUnit.DAYS)
$67 ==> PT1344H
jshell> Duration.ofSeconds(87)
$68 ==> PT1M27S
jshell> Duration.ofHours(7)
$69 ==> PT7H
  1. 它们也可以通过解析持续时间字符串来创建,如下所示:
jshell> Duration.parse("P12D")
$70 ==> PT288H
jshell> Duration.parse("P12DT7H5M8.009S")
$71 ==> PT295H5M8.009S
jshell> Duration.parse("PT7H5M8.009S")
$72 ==> PT7H5M8.009S
  1. 它们可以通过查找两个支持时间信息的java.time.Temporal实例之间的时间跨度来构建,这些实例支持时间信息(即java.time.LocalDateTime等的实例),如下所示:
jshell> LocalDateTime time1 = LocalDateTime.now()
time1 ==> 2018-06-23T10:51:21.038073800
jshell> LocalDateTime time2 = LocalDateTime.of(2018, 6, 22, 11, 00)
time2 ==> 2018-06-22T11:00
jshell> Duration.between(time1, time2)
$77 ==> PT-23H-51M-21.0380738S
jshell> ZonedDateTime time1 = ZonedDateTime.now()
time1 ==> 2018-06-23T10:56:57.965606200+03:00[Asia/Riyadh]
jshell> ZonedDateTime time2 = ZonedDateTime.of(LocalDateTime.now(), ZoneOffset.ofHoursMinutes(5, 30))
time2 ==> 2018-06-23T10:56:59.878712600+05:30
jshell> Duration.between(time1, time2)
$82 ==> PT-2H-29M-58.0868936S

它是如何工作的...

java.time.Duration所需的数据存储在两个字段中,分别表示秒和纳秒。提供了一些便利方法,以分钟、小时和天为单位获取持续时间,即toMinutes()toHours()toDays()

让我们讨论字符串表示实现。java.time.Duration支持解析 ISO 字符串表示,其中日期部分仅包含天组件,时间部分包含小时、分钟、秒和纳秒。例如,P2DT3M是可接受的,而解析P3M2DT3M将导致java.time.format.DateTimeParseException,因为字符串包含日期部分的月份组件。

java.time.DurationtoString()方法始终返回PTxHyMz.nS形式的字符串,其中x表示小时数,y表示分钟数,z.n表示秒数到纳秒精度。让我们看一些例子:

jshell> Duration.parse("P2DT3M")
$2 ==> PT48H3M

jshell> Duration.parse("P3M2DT3M")
| Exception java.time.format.DateTimeParseException: Text cannot be parsed to a Duration
| at Duration.parse (Duration.java:417)
| at (#3:1)

jshell> Duration.ofHours(4)
$4 ==> PT4H

jshell> Duration.parse("PT3H4M5.6S")
$5 ==> PT3H4M5.6S

jshell> Duration d = Duration.parse("PT3H4M5.6S")
d ==> PT3H4M5.6S

jshell> d.toDays()
$7 ==> 0

jshell> d.toHours()
$9 ==> 3

还有更多...

让我们来看一下提供的操作方法,这些方法允许从特定的时间单位(如天、小时、分钟、秒或纳秒)中添加/减去一个值。每个方法都是不可变的,因此每次都会返回一个新实例,如下所示:

jshell> Duration d = Duration.parse("PT1H5M4S")
d ==> PT1H5M4S

jshell> d.plusDays(3)
$14 ==> PT73H5M4S

jshell> d
d ==> PT1H5M4S

jshell> d.plusDays(3)
$16 ==> PT73H5M4S

jshell> d.plusHours(3)
$17 ==> PT4H5M4S

jshell> d.plusMillis(4)
$18 ==> PT1H5M4.004S

jshell> d.plusMinutes(40)
$19 ==> PT1H45M4S

类似地,您可以尝试minus*()方法,进行减法。然后有一些方法可以操作java.time.LocalDateTimejava.time.ZonedDateTime等的实例。这些方法将持续时间添加/减去日期/时间信息。让我们看一些例子:

jshell> Duration d = Duration.parse("PT1H5M4S")
d ==> PT1H5M4S

jshell> d.addTo(LocalDateTime.now())
$21 ==> 2018-06-25T21:15:53.725373600

jshell> d.addTo(ZonedDateTime.now())
$22 ==> 2018-06-25T21:16:03.396595600+03:00[Asia/Riyadh]

jshell> d.addTo(LocalDate.now())
| Exception java.time.temporal.UnsupportedTemporalTypeException: Unsupported unit: Seconds
| at LocalDate.plus (LocalDate.java:1272)
| at LocalDate.plus (LocalDate.java:139)
| at Duration.addTo (Duration.java:1102)
| at (#23:1)

您可以观察到在前面的示例中,当我们尝试将持续时间添加到仅包含日期信息的实体时,我们得到了一个异常。

如何表示纪元时间

在本教程中,我们将学习如何使用java.time.Instant来表示一个时间点,并将该时间点转换为纪元秒/毫秒。Java 纪元用于指代时间瞬间 1970-01-01 00:00:00Z,java.time.Instant存储了从 Java 纪元开始的秒数。正值表示时间超过了纪元,负值表示时间落后于纪元。它使用 UTC 中的系统时钟来计算当前时间瞬间值。

准备工作

您需要安装支持新日期/时间 API 和 JShell 的 JDK,才能尝试提供的解决方案。

如何做...

  1. 我们将创建一个java.time.Instant实例,并打印出纪元秒,这将给出 Java 纪元后的 UTC 时间:
jshell> Instant.now()
$40 ==> 2018-07-06T07:56:40.651529300Z

jshell> Instant.now().getEpochSecond()
$41 ==> 1530863807
  1. 我们还可以打印出纪元毫秒,这显示了纪元后的毫秒数。这比仅仅秒更精确:
jshell> Instant.now().toEpochMilli()
$42 ==> 1530863845158

它是如何工作的...

java.time.Instant类将时间信息存储在其两个字段中:

  • 秒,类型为long:这存储了从 1970-01-01T00:00:00Z 纪元开始的秒数。

  • 纳秒,类型为int:这存储了纳秒数

当您调用now()方法时,java.time.Instant使用 UTC 中的系统时钟来表示该时间瞬间。然后我们可以使用atZone()atOffset()将其转换为所需的时区,我们将在下一节中看到。

如果您只想表示 UTC 中的操作时间线,那么存储不同事件的时间戳将基于 UTC,并且您可以在需要时将其转换为所需的时区。

还有更多...

我们可以通过添加/减去纳秒、毫秒和秒来操纵java.time.Instant,如下所示:

jshell> Instant.now().plusMillis(1000)
$43 ==> 2018-07-06T07:57:57.092259400Z

jshell> Instant.now().plusNanos(1991999)
$44 ==> 2018-07-06T07:58:06.097966099Z

jshell> Instant.now().plusSeconds(180)
$45 ==> 2018-07-06T08:01:15.824141500Z

同样,您可以尝试minus*()方法。我们还可以使用java.time.Instant方法获取依赖于时区的日期时间,如atOffset()atZone()所示:

jshell> Instant.now().atZone(ZoneId.of("Asia/Kolkata"))
$36 ==> 2018-07-06T13:15:13.820694500+05:30[Asia/Kolkata]

jshell> Instant.now().atOffset(ZoneOffset.ofHoursMinutes(2,30))
$37 ==> 2018-07-06T10:15:19.712039+02:30

如何操纵日期和时间实例

日期和时间类java.time.LocalDatejava.time.LocalTimejava.time.LocalDateTimejava.time.ZonedDateTime提供了从它们的组件中添加和减去值的方法,即天、小时、分钟、秒、周、月、年等。

在这个示例中,我们将看一些可以用来通过添加和减去不同的值来操纵日期和时间实例的方法。

准备就绪

您将需要安装支持新的日期/时间 API 和 JShell 控制台的 JDK。

如何做到这一点...

  1. 让我们操纵java.time.LocalDate
jshell> LocalDate d = LocalDate.now()
d ==> 2018-07-27

jshell> d.plusDays(3)
$5 ==> 2018-07-30

jshell> d.minusYears(4)
$6 ==> 2014-07-27
  1. 让我们操纵日期和时间实例,java.time.LocalDateTime
jshell> LocalDateTime dt = LocalDateTime.now()
dt ==> 2018-07-27T15:27:40.733389700

jshell> dt.plusMinutes(45)
$8 ==> 2018-07-27T16:12:40.733389700

jshell> dt.minusHours(4)
$9 ==> 2018-07-27T11:27:40.733389700
  1. 让我们操纵依赖于时区的日期和时间,java.time.ZonedDateTime
jshell> ZonedDateTime zdt = ZonedDateTime.now()
zdt ==> 2018-07-27T15:28:28.309915200+03:00[Asia/Riyadh]

jshell> zdt.plusDays(4)
$11 ==> 2018-07-31T15:28:28.309915200+03:00[Asia/Riyadh]

jshell> zdt.minusHours(3)
$12 ==> 2018-07-27T12:28:28.309915200+03:00[Asia/Riyadh]

还有更多...

我们刚刚看了一些由plus*()minus*()表示的添加和减去 API。还提供了不同的方法来操纵日期和时间的不同组件,如年、日、月、小时、分钟、秒和纳秒。您可以尝试这些 API 作为练习。

如何比较日期和时间

通常,我们希望将日期和时间实例与其他实例进行比较,以检查它们是在之前、之后还是与其他实例相同。为了实现这一点,JDK 在java.time.LocalDatejava.time.LocalDateTimejava.time.ZonedDateTime类中提供了isBefore()isAfter()isEqual()方法。在这个示例中,我们将看看如何使用这些方法来比较日期和时间实例。

准备就绪

您将需要安装具有新的日期/时间 API 并支持 JShell 的 JDK。

如何做到这一点...

  1. 让我们尝试比较两个java.time.LocalDate实例:
jshell> LocalDate d = LocalDate.now()
d ==> 2018-07-28

jshell> LocalDate d2 = LocalDate.of(2018, 7, 27)
d2 ==> 2018-07-27

jshell> d.isBefore(d2)
$4 ==> false

jshell> d.isAfter(d2)
$5 ==> true

jshell> LocalDate d3 = LocalDate.of(2018, 7, 28)
d3 ==> 2018-07-28

jshell> d.isEqual(d3)
$7 ==> true

jshell> d.isEqual(d2)
$8 ==> false
  1. 我们还可以比较依赖于时区的日期和时间实例:
jshell> ZonedDateTime zdt1 = ZonedDateTime.now();
zdt1 ==> 2018-07-28T14:49:34.778006400+03:00[Asia/Riyadh]

jshell> ZonedDateTime zdt2 = zdt1.plusHours(4)
zdt2 ==> 2018-07-28T18:49:34.778006400+03:00[Asia/Riyadh]

jshell> zdt1.isBefore(zdt2)
$11 ==> true

jshell> zdt1.isAfter(zdt2)
$12 ==> false
jshell> zdt1.isEqual(zdt2)
$13 ==> false

还有更多...

比较可以在java.time.LocalTimejava.time.LocalDateTime上进行。这留给读者去探索。

如何使用不同的日历系统

到目前为止,在我们的示例中,我们使用了 ISO 日历系统,这是世界上遵循的事实标准日历系统。世界上还有其他地区遵循的日历系统,如伊斯兰历、日本历和泰国历。JDK 也为这些日历系统提供了支持。

在这个示例中,我们将看看如何使用两个日历系统:日本和伊斯兰历。

准备就绪

您应该安装支持新的日期/时间 API 和 JShell 工具的 JDK。

如何做到这一点...

  1. 让我们打印 JDK 支持的不同日历系统中的当前日期:
jshell> Chronology.getAvailableChronologies().forEach(chrono -> 
System.out.println(chrono.dateNow()))
2018-07-30
Minguo ROC 107-07-30
Japanese Heisei 30-07-30
ThaiBuddhist BE 2561-07-30
Hijrah-umalqura AH 1439-11-17
  1. 让我们玩弄一下用日本日历系统表示的日期:
jshell> JapaneseDate jd = JapaneseDate.now()
jd ==> Japanese Heisei 30-07-30

jshell> jd.getChronology()
$7 ==> Japanese

jshell> jd.getEra()
$8 ==> Heisei

jshell> jd.lengthOfYear()
$9 ==> 365

jshell> jd.lengthOfMonth()
$10 ==> 31
  1. 日本日历中支持的不同纪元可以使用java.time.chrono.JapeneseEra进行枚举:
jshell> JapaneseEra.values()
$42 ==> JapaneseEra[5] { Meiji, Taisho, Showa, Heisei, NewEra }
  1. 让我们在伊斯兰历中创建一个日期:
jshell> HijrahDate hd = HijrahDate.of(1438, 12, 1)
hd ==> Hijrah-umalqura AH 1438-12-01
  1. 我们甚至可以将 ISO 日期/时间转换为伊斯兰历的日期/时间,如下所示:
jshell> HijrahChronology.INSTANCE.localDateTime(LocalDateTime.now())
$23 ==> Hijrah-umalqura AH 1439-11-17T19:56:52.056465900

jshell> HijrahChronology.INSTANCE.localDateTime(LocalDateTime.now()).toLocalDate()
$24 ==> Hijrah-umalqura AH 1439-11-17

jshell> HijrahChronology.INSTANCE.localDateTime(LocalDateTime.now()).toLocalTime()
$25 ==> 19:57:07.705740500

它是如何工作的...

日历系统由java.time.chrono.Chronology及其实现表示,其中一些是java.time.chrono.IsoChronologyjava.time.chrono.HijrahChronologyjava.time.chrono.JapaneseChronologyjava.time.chrono.IsoChronology是世界上使用的基于 ISO 的事实标准日历系统。每个日历系统中的日期由java.time.chrono.ChronoLocalDate及其实现表示,其中一些是java.time.chrono.HijrahDatejava.time.chrono.JapaneseDate和著名的java.time.LocalDate

要能够在 JShell 中使用这些 API,您需要导入相关的包,如下所示:

jshell> import java.time.*

jshell> import java.time.chrono.*

这适用于所有使用 JShell 的示例。

我们可以直接使用java.time.chrono.ChronoLocalDate的实现,例如java.time.chrono.JapaneseDate,或者使用java.time.chrono.Chronology的实现来获取相关的日期表示,如下所示:

jshell> JapaneseDate jd = JapaneseDate.of(JapaneseEra.SHOWA, 26, 12, 25)
jd ==> Japanese Showa 26-12-25

jshell> JapaneseDate jd = JapaneseDate.now()
jd ==> Japanese Heisei 30-07-30

jshell> JapaneseDate jd = JapaneseChronology.INSTANCE.dateNow()
jd ==> Japanese Heisei 30-07-30

jshell> JapaneseDate jd = JapaneseChronology.INSTANCE.date(LocalDateTime.now())
jd ==> Japanese Heisei 30-07-30

jshell> ThaiBuddhistChronology.INSTANCE.date(LocalDate.now())
$41 ==> ThaiBuddhist BE 2561-07-30

从前面的代码片段中,我们可以看到可以使用其日历系统的date(TemporalAccessor temporal)方法将 ISO 系统日期转换为所需日历系统中的日期。

还有更多…

您可以尝试使用 JDK 支持的其他日历系统,即泰国、佛教和民国(中国)日历系统。还值得探索如何通过编写java.time.chrono.Chronologyjava.time.chrono.ChronoLocalDatejava.time.chrono.Era的实现来创建我们自定义的日历系统。

如何使用 DateTimeFormatter 格式化日期

在使用java.util.Date时,我们使用java.text.SimpleDateFormat将日期格式化为不同的文本表示形式,反之亦然。格式化日期意味着,以不同格式表示给定日期或时间对象,例如以下格式:

  • 2018 年 6 月 23 日

  • 2018 年 8 月 23 日

  • 2018-08-23

  • 2018 年 6 月 23 日上午 11:03:33

这些格式由格式字符串控制,例如以下格式:

  • dd MMM yyyy

  • dd-MM-yyyy

  • yyyy-MM-DD

  • dd MMM yyyy hh:mm:ss

在这个示例中,我们将使用java.time.format.DateTimeFormatter来格式化新日期和时间 API 中的日期和时间实例,并查看最常用的模式字母。

准备工作

您将需要一个具有新的日期/时间 API 和jshell工具的 JDK。

如何做…

  1. 让我们使用内置格式来格式化日期和时间:
jshell> LocalDate ld = LocalDate.now()
ld ==> 2018-08-01

jshell> ld.format(DateTimeFormatter.ISO_DATE)
$47 ==> "2018-08-01"

jshell> LocalDateTime.now().format(DateTimeFormatter.ISO_DATE_TIME)
$49 ==> "2018-08-01T17:24:49.1985601"
  1. 让我们创建一个自定义的日期/时间格式:
jshell> DateTimeFormatter dtf = DateTimeFormatter.ofPattern("dd MMM yyyy hh:mm:ss a")
dtf ==> Value(DayOfMonth,2)' 'Text(MonthOfYear,SHORT)' 'V ... 2)' 'Text(AmPmOfDay,SHORT)
  1. 让我们使用自定义的java.time.format.DateTimeFormatter来格式化当前的日期/时间:
jshell> LocalDateTime ldt = LocalDateTime.now()
ldt ==> 2018-08-01T17:36:22.442159

jshell> ldt.format(dtf)
$56 ==> "01 Aug 2018 05:36:22 PM"

它是如何工作的…

让我们了解最常用的格式字母:

符号 意义 示例
d 一个月中的日期 1,2,3,5
M, MMM, MMMM 一年中的月份 M: 1,2,3,MMM: 六月,七月,八月MMMM: 七月,八月
y, yy y, yyyy: 2017, 2018yy: 18, 19
h 一天中的小时(1-12) 1, 2, 3
k 一天中的小时(0-23) 0, 1, 2, 3
m 分钟 1, 2, 3
s 1, 2, 3
a 一天中的上午/下午 上午,下午
VV 时区 ID 亚洲/加尔各答
ZZ 时区名称 IST, PST, AST
O 时区偏移 GMT+5:30, GMT+3

基于前面的格式字母,让我们格式化java.time.ZonedDateTime

jshell> DateTimeFormatter dtf = DateTimeFormatter.ofPattern("dd MMMM yy h:mm:ss a VV")
dtf ==> Value(DayOfMonth,2)' 'Text(MonthOfYear)' 'Reduced ... mPmOfDay,SHORT)' 'ZoneId()

jshell> ZonedDateTime.now().format(dtf)
$67 ==> "01 August 18 6:26:04 PM Asia/Kolkata"

jshell> DateTimeFormatter dtf = DateTimeFormatter.ofPattern("dd MMMM yy h:mm:ss a zz")
dtf ==> Value(DayOfMonth,2)' 'Text(MonthOfYear)' 'Reduced ... y,SHORT)' 'ZoneText(SHORT)

jshell> ZonedDateTime.now().format(dtf)
$69 ==> "01 August 18 6:26:13 PM IST"

jshell> DateTimeFormatter dtf = DateTimeFormatter.ofPattern("dd MMMM yy h:mm:ss a O")
dtf ==> Value(DayOfMonth,2)' 'Text(MonthOfYear)' 'Reduced ... )' 'LocalizedOffset(SHORT)

jshell> ZonedDateTime.now().format(dtf)
$72 ==> "01 August 18 6:26:27 PM GMT+5:30"

java.time.format.DateTimeFormatter附带了基于 ISO 标准的大量默认格式。当您处理日期操作而没有用户参与时,这些格式应该足够了,也就是说,当日期和时间在应用程序的不同层之间交换时。

但是,为了向最终用户呈现日期和时间信息,我们需要以可读的格式对其进行格式化,为此,我们需要一个自定义的DateTimeFormatter。如果您需要自定义的java.time.format.DateTimeFormatter,有两种创建方式:

  • 使用模式,例如 dd MMMM yyyy 和java.time.format.DateTimeFormatter中的ofPattern()方法

  • 使用java.time.DateTimeFormatterBuilder

使用模式

我们创建一个java.time.format.DateTimeFormatter的实例,如下所示:

jshell> DateTimeFormatter dtf = DateTimeFormatter.ofPattern("dd MMMM yy h:mm:ss a VV")
dtf ==> Value(DayOfMonth,2)' 'Text(MonthOfYear)' 'Reduced ... mPmOfDay,SHORT)' 'ZoneId()

然后我们将格式应用到日期和时间实例:

jshell> ZonedDateTime.now().format(dtf)
$92 ==> "01 August 18 7:25:00 PM Asia/Kolkata"

模式方法也使用DateTimeFormatterBuilder,其中构建器解析给定的格式字符串以构建DateTimeFormatter对象。

使用java.time.format.DateTimeFormatterBuilder

让我们使用DateTimeFormatterBuilder来构建DateTimeFormatter,如下所示:

jshell> DateTimeFormatter dtf = new DateTimeFormatterBuilder().
 ...> appendValue(DAY_OF_MONTH, 2).
 ...> appendLiteral(" ").
 ...> appendText(MONTH_OF_YEAR).
 ...> appendLiteral(" ").
 ...> appendValue(YEAR, 4).
 ...> toFormatter()
dtf ==> Value(DayOfMonth,2)' 'Text(MonthOfYear)' 'Value(Year,4)

jshell> LocalDate.now().format(dtf) E$106 ==> "01 August 2018"

您可以观察到DateTimeFormatter对象由一组指令组成,用于表示日期和时间。这些指令以Value()Text()和分隔符的形式呈现。

第十四章:测试

本章展示了如何测试你的应用程序——如何捕获和自动化测试用例,如何在将 API 与其他组件集成之前对 API 进行单元测试,以及如何集成所有单元。我们将向您介绍行为驱动开发BDD)并展示它如何成为应用程序开发的起点。我们还将演示如何使用 JUnit 框架进行单元测试。有时,在单元测试期间,我们必须使用一些虚拟数据存根依赖项,这可以通过模拟依赖项来完成。我们将向您展示如何使用模拟库来做到这一点。我们还将向您展示如何编写固定装置来填充测试数据,然后如何通过集成不同的 API 并一起测试它们来测试应用程序的行为。我们将涵盖以下内容:

  • 使用 Cucumber 进行行为测试

  • 使用 JUnit 对 API 进行单元测试

  • 单元测试通过模拟依赖关系

  • 使用固定装置来填充测试数据

  • 集成测试

介绍

经过良好测试的代码为开发人员提供了心灵上的安宁。如果你觉得为你正在开发的新方法编写测试太过繁琐,那么通常第一次就做不对。无论如何,你都必须测试你的方法,而在长远来看,设置或编写单元测试比构建和启动应用程序多次要少时间消耗——每次代码更改和每次逻辑通过都要这样做。

我们经常感到时间紧迫的原因之一是我们在估算时间时没有包括编写测试所需的时间。一个原因是我们有时会忘记这样做。另一个原因是我们不愿意给出更高的估计,因为我们不想被认为技能不够。不管原因是什么,这种情况经常发生。只有经过多年的经验,我们才学会在估算中包括测试,并赢得足够的尊重和影响力,能够公开断言正确的做事方式需要更多的时间,但从长远来看节省了更多的时间。此外,正确的做法会导致健壮的代码,减少了很多压力,这意味着整体生活质量更好。

早期测试的另一个优势是在主要代码完成之前发现代码的弱点,这时修复它很容易。如果需要,甚至可以重构代码以提高可测试性。

如果你还不相信,记下你阅读此文的日期,并每年回顾一次,直到这些建议对你来说变得显而易见。然后,请与他人分享你的经验。这就是人类取得进步的方式——通过将知识从一代传递到下一代。

从方法上讲,本章的内容也适用于其他语言和职业,但示例主要是为 Java 开发人员编写的。

使用 Cucumber 进行行为测试

以下是程序员经常提出的三个反复出现的抱怨:

  • 缺乏需求

  • 需求的模糊性

  • 需求一直在变化

有很多建议和流程可以帮助缓解这些问题,但没有一个能够完全消除它们。在我们看来,最成功的是敏捷过程方法与 BDD 相结合,使用 Cucumber 或其他类似框架。短迭代允许快速调整和业务(客户)与程序员之间的协调,而 BDD 与 Cucumber 以 Gherkin 捕获需求,但没有维护大量文档的开销。

Gherkin 中编写的需求必须被分解成特性。每个特性存储在一个扩展名为.feature的文件中,包含一个或多个描述特性不同方面的场景。每个场景由描述用户操作或输入数据以及应用程序对其的响应的步骤组成。

程序员实现必要的应用程序功能,然后使用它来在一个或多个.java文件中实现场景。每个步骤都在一个方法中实现。

在实施后,这些场景将成为一套测试,可以是像单元测试一样细粒度,也可以是像集成测试一样高级,以及介于两者之间的任何形式。这完全取决于谁编写了场景以及应用程序代码的结构。如果场景的作者是业务人员,那么场景往往更高级。但是,如果应用程序的结构使得每个场景(可能有多个输入数据的排列组合)都被实现为一个方法,那么它就可以有效地作为一个单元测试。或者,如果一个场景涉及多个方法甚至子系统,它可以作为一个集成测试,而程序员可以用更细粒度(更像单元测试)的场景来补充它。之后,在代码交付后,所有场景都可以作为回归测试。

您所付出的代价是场景的开销、维护,但回报是捕获需求并确保应用程序确实符合要求的正式系统。话虽如此,需要说明的一点是:捕获 UI 层的场景通常更加棘手,因为 UI 往往更频繁地发生变化,特别是在应用程序开发的初期。然而,一旦 UI 稳定下来,对其的需求也可以使用 Selenium 或类似的框架在 Cucumber 场景中进行捕获。

如何做...

  1. 安装 Cucumber。Cucumber 的安装只是将框架作为 Maven 依赖项添加到项目中。由于我们将添加多个 Cucumber JAR 文件,而且它们都必须是相同版本,因此在pom.xml中添加cucumber.version属性是有意义的。
    <properties>
        <cucumber.version>3.0.2</cucumber.version>
    </properties>

现在我们可以在pom.xml中将 Cucumber 主 JAR 文件添加为依赖项:

<dependency>
    <groupId>io.cucumber</groupId>
    <artifactId>cucumber-java</artifactId>
    <version>${cucumber.version}</version>
    <scope>test</scope>
</dependency>

或者,如果您更喜欢流畅的基于流的编码风格,您可以添加一个不同的 Cucumber 主 JAR 文件:

<dependency>
    <groupId>io.cucumber</groupId>
    <artifactId>cucumber-java8</artifactId>
    <version>${cucumber.version}</version>
    <scope>test</scope>
</dependency>

如果您的项目尚未设置 JUnit 作为依赖项,您可以按照以下步骤添加它以及另一个cucumber-junit JAR 文件:

<dependency>
    <groupId>junit</groupId>
    <artifactId>junit</artifactId>
    <version>4.12</version>
    <scope>test</scope>
</dependency>
<dependency>
    <groupId>io.cucumber</groupId>
    <artifactId>cucumber-junit</artifactId>
    <version>${cucumber.version}</version>
    <scope>test</scope>
</dependency> 

以上是必要的,如果您计划利用 JUnit 断言。请注意,目前为止,Cucumber 不支持 JUnit 5。

或者,您可以使用 TestNG(testng.org)中的断言:

<dependency>
    <groupId>org.testng</groupId>
    <artifactId>testng</artifactId>
    <version>6.14.2</version>
    <scope>test</scope>
</dependency>
<dependency>
    <groupId>io.cucumber</groupId>
    <artifactId>cucumber-testng</artifactId>
    <version>${cucumber.version}</version>
    <scope>test</scope>
</dependency>

如您所见,在这种情况下,您需要添加cucumber-testng JAR 文件,而不是cucumber-junit JAR 文件。TestNG 提供了丰富多样的断言方法,包括深度集合和其他对象比较。

  1. 运行 Cucumber。cucumber-junit JAR 文件还提供了一个@RunWith注解,将一个类指定为测试运行器:
package com.packt.cookbook.ch16_testing;

import cucumber.api.CucumberOptions;
import cucumber.api.junit.Cucumber;
import org.junit.runner.RunWith;

@RunWith(Cucumber.class)
public class RunScenariousTest {
}

执行前述类将执行与运行器所在的相同包中的所有场景。Cucumber 读取每个.feature文件及其中的场景。对于每个场景的每个步骤,它尝试在与运行器和.feature文件相同的包中找到其实现。它按照场景中列出的顺序执行每个已实现的步骤。

  1. 创建一个.feature文件。正如我们已经提到的,一个.feature文件包含一个或多个场景。文件的名称对 Cucumber 没有任何意义。文件的内容以Feature关键字(后面跟着冒号:)开始。接下来的文本描述了功能,并且与文件名类似,对 Cucumber 没有任何意义。功能描述在Scenario关键字(后面跟着冒号:)开始新行时结束。这就是第一个场景描述开始的地方。以下是一个例子:
Feature: Vehicle speed calculation
 The calculations should be made based on the assumption
 that a vehicle starts moving, and driving conditions are 
 always the same.

Scenario: Calculate speed
 This the happy path that demonstrates the main case

当以下关键字之一在新行上开始时,场景描述结束:GivenWhenThenAndBut。每个这些关键字在新行开始时,都表示步骤定义的开始。对于 Cucumber 来说,这样的关键字除了表示步骤定义的开始外,没有其他意义。但对于人类来说,如果场景以Given关键字开始,即描述系统的初始状态的步骤,那么阅读起来更容易。可能会有几个其他步骤(前提条件)跟随;每个步骤都以新行和AndBut关键字开头,例如如下所示:

Given the vehicle has 246 hp engine and weighs 4000 pounds

之后,步骤组描述了动作或事件。为了人类可读性,该组通常以新行的When关键字开头。其他动作或事件随后,每个都以新行和AndBut关键字开头。建议将该组中的步骤数量保持在最小限度,以便每个场景都能够集中精力,例如如下所示:

When the application calculates its speed after 10.0 sec

场景中的最后一组步骤以新行中的Then关键字开始。它们描述了预期的结果。与前两组步骤一样,该组中的每个后续步骤都以新行和AndBut关键字开头,例如如下所示:

Then the result should be 117.0 mph

总结之前,该功能如下:

Feature: Vehicle speed calculation
 The calculations should be made based on the assumption
 that a vehicle starts moving, and driving conditions are
 always the same.

Scenario: Calculate speed
 This the happy path that demonstrates the main case

 Given the vehicle has 246 hp engine and weighs 4000 pounds
 When the application calculates its speed after 10.0 sec
 Then the result should be 117.0 mph

我们将其放在以下文件夹中的src/test/resources/com/packt/cookbook/Chapter14_testing中的CalculateSpeed.feature文件中。

请注意,它必须位于test/resources文件夹中,并且其路径必须与RunScenariosTest测试运行器所属的包名称匹配。

测试运行器像执行任何 JUnit 测试一样,例如使用mvn test命令,或者只需在 JDE 中运行它。执行时,它会查找同一包中的所有.feature文件(Maven 将它们从resources文件夹复制到target/classes文件夹,因此将它们设置在类路径上)。然后按顺序读取每个场景的步骤,并尝试在同一包中找到每个步骤的实现。

正如我们已经提到的,文件的名称对于 Cucumber 来说没有任何意义。它首先寻找.feature扩展名,然后找到第一个步骤,并在同一目录中尝试找到一个类,该类中有一个与步骤相同的注释方法。

为了说明其含义,让我们通过执行测试运行器来运行创建的特性。结果将如下所示:

cucumber.runtime.junit.UndefinedThrowable: 
The step "the vehicle has 246 hp engine and weighs 4000 pounds" 
                                                     is undefined
cucumber.runtime.junit.UndefinedThrowable: 
The step "the application calculates its speed after 10.0 sec" 
                                                     is undefined
cucumber.runtime.junit.UndefinedThrowable: 
The step "the result should be 117.0 mph" is undefined

Undefined scenarios:
com/packt/cookbook/ch16_testing/CalculateSpeed.feature:6 
                                                # Calculate speed
1 Scenarios (1 undefined)
3 Steps (3 undefined)
0m0.081s

You can implement missing steps with the snippets below:

@Given("the vehicle has {int} hp engine and weighs {int} pounds")
public void the_vehicle_has_hp_engine_and_weighs_pounds(Integer 
                                             int1, Integer int2) {
 // Write code here that turns the phrase above 
 // into concrete actions
 throw new PendingException();
}

@When("the application calculates its speed after {double} sec")
public void the_application_calculates_its_speed_after_sec(Double 
                                                         double1) {
 // Write code here that turns the phrase above 
 // into concrete actions
 throw new PendingException();
}

@Then("the result should be {double} mph")
public void the_result_should_be_mph(Double double1) {
 // Write code here that turns the phrase above 
 // into concrete actions
 throw new PendingException();
}

正如您所看到的,Cucumber 不仅告诉我们有多少个undefined特性和场景,它甚至提供了一种可能的实现方式。请注意,Cucumber 允许使用大括号中的类型传递参数。以下是内置类型:intfloatwordstringbigintegerbigdecimalbyteshortlongdoublewordstring之间的区别在于后者允许空格。但 Cucumber 还允许我们定义自定义类型。

  1. 编写并运行步骤定义。Cucumber 术语中的undefined可能会令人困惑,因为我们确实定义了特性和场景。我们只是没有实现它们。因此,Cucumber 消息中的undefined实际上意味着未实现

要开始实现,我们首先在与测试运行器相同的目录中创建一个名为CalculateSpeedSteps的类。类名对于 Cucumber 来说没有意义,所以您可以根据自己的喜好命名它。然后,我们将之前建议的三种方法与注释一起复制并放入该类中:

package com.packt.cookbook.ch16_testing;

import cucumber.api.PendingException;
import cucumber.api.java.en.Given;
import cucumber.api.java.en.Then;
import cucumber.api.java.en.When;

public class Calc {
  @Given("the vehicle has {int} hp engine and weighs {int} pounds")
  public void the_vehicle_has_hp_engine_and_weighs_pounds(Integer 
                                              int1, Integer int2) {
        // Write code here that turns the phrase above 
        // into concrete actions
        throw new PendingException();
  }

  @When("the application calculates its speed after {double} sec")
  public void the_application_calculates_its_speed_after_sec(Double 
                                                         double1) {
        // Write code here that turns the phrase above 
        // into concrete actions
        throw new PendingException();
  }

  @Then("the result should be {double} mph")
    public void the_result_should_be_mph(Double double1) {
        // Write code here that turns the phrase above 
        // into concrete actions
        throw new PendingException();
  }
}

如果我们再次执行测试运行器,输出将如下所示:

cucumber.api.PendingException: TODO: implement me
 at com.packt.cookbook.ch16_testing.CalculateSpeedSteps.the_vehicle
      _has_hp_engine_and_weighs_pounds(CalculateSpeedSteps.java:13)
 at *.the vehicle has 246 hp engine and weighs 4000 pounds(com/packt/cookbook/ch16_testing/CalculateSpeed.feature:9)

Pending scenarios:
com/packt/cookbook/ch16_testing/CalculateSpeed.feature:6 
                                                 # Calculate speed
1 Scenarios (1 pending)
3 Steps (2 skipped, 1 pending)
0m0.055s

cucumber.api.PendingException: TODO: implement me
 at com.packt.cookbook.ch16_testing.CalculateSpeedSteps.the_vehicle       has_hp_engine_and_weighs_pounds(CalculateSpeedSteps.java:13)
 at *.the vehicle has 246 hp engine and weighs 4000 pounds(com/packt/cookbook/ch16_testing/CalculateSpeed.feature:9)

运行器在第一个PendingException处停止执行,因此其他两个步骤被跳过。如果系统地应用 BDD 方法论,那么特性将首先编写——在编写应用程序的任何代码之前。因此,每个特性都会产生前面的结果。

随着应用程序的开发,每个新功能都得到了实现,并且不再失败。

它是如何工作的...

在要求被表达为功能后,应用程序会逐个功能地实现。例如,我们可以从创建Vehicle类开始:

class Vehicle {
    private int wp, hp;
    public Vehicle(int weightPounds, int hp){
        this.wp = weightPounds;
        this.hp = hp;
    }
    protected double getSpeedMpH(double timeSec){
        double v = 2.0 * this.hp * 746 ;
        v = v*timeSec * 32.174 / this.wp;
        return Math.round(Math.sqrt(v) * 0.68);
    }
}

然后,先前显示的第一个功能的步骤可以实现如下:

package com.packt.cookbook.ch16_testing;

import cucumber.api.java.en.Given;
import cucumber.api.java.en.Then;
import cucumber.api.java.en.When;
import static org.junit.Assert.assertEquals;

public class CalculateSpeedSteps {
  private Vehicle vehicle;
  private double speed;

  @Given("the vehicle has {int} hp engine and weighs {int} pounds")
  public void the_vehicle_has_hp_engine_and_weighs_pounds(Integer 
                                                  wp, Integer hp) {
        vehicle = new Vehicle(wp, hp);
  }

  @When("the application calculates its speed after {double} sec")
  public void 
        the_application_calculates_its_speed_after_sec(Double t) {
        speed = vehicle.getSpeedMpH(t);
  }

  @Then("the result should be {double} mph")
  public void the_result_should_be_mph(Double speed) {
        assertEquals(speed, this.speed, 0.0001 * speed);
  }
}

如果我们再次在com.packt.cookbook.ch16_testing包中运行测试运行器,步骤将成功执行。

现在,如果需求发生变化,并且.feature文件相应地进行了修改,除非应用程序代码也进行了更改并符合要求,否则测试将失败。这就是 BDD 的力量。它使要求与代码保持同步。它还允许 Cucumber 测试作为回归测试。如果代码更改违反了要求,测试将失败。

使用 JUnit 对 API 进行单元测试

根据维基百科,GitHub 上托管的项目中超过 30%包括 JUnit,这是一组单元测试框架,统称为 xUnit,起源于 SUnit。它在编译时作为 JAR 链接,并且(自 JUnit 4 以来)驻留在org.junit包中。

在面向对象编程中,一个单元可以是整个类,也可以是一个单独的方法。在实践中,我们发现最有用的是作为一个单独方法的单元。它为本章的示例提供了基础。

准备工作

在撰写本文时,JUnit 的最新稳定版本是 4.12,可以通过将以下 Maven 依赖项添加到pom.xml项目级别来使用:

<dependency>
  <groupId>junit</groupId>
  <artifactId>junit</artifactId>
  <version>4.12</version>
  <scope>test</scope>
</dependency>

之后,您可以编写您的第一个 JUnit 测试。假设您已经在src/main/java/com/packt/cookbook.ch02_oop.a_classes文件夹中创建了Vehicle类(这是我们在第二章中讨论的代码,OOP - 类和接口的快速跟踪):

package com.packt.cookbook.ch02_oop.a_classes;
public class Vehicle {
  private int weightPounds;
  private Engine engine;
  public Vehicle(int weightPounds, Engine engine) {
    this.weightPounds = weightPounds;
    if(engine == null){
      throw new RuntimeException("Engine value is not set.");
    }
    this.engine = engine;
  }
  protected double getSpeedMph(double timeSec){
    double v = 2.0*this.engine.getHorsePower()*746;
    v = v*timeSec*32.174/this.weightPounds;
    return Math.round(Math.sqrt(v)*0.68);
  }
}

现在,您可以创建src/test/java/com/packt/cookbook.ch02_oop.a_classes文件夹,并在其中创建一个名为VehicleTest.java的新文件,其中包含VehicleTest类:

package com.packt.cookbook.ch02_oop.a_classes;
import org.junit.Test;
public class VehicleTest {
  @Test
  public void testGetSpeedMph(){
    System.out.println("Hello!" + " I am your first test method!");
  }
}

使用您喜欢的 IDE 运行它,或者只需使用mvn test命令。您将看到包括以下内容的输出:

恭喜!您已经创建了您的第一个测试类。它还没有测试任何东西,但这是一个重要的设置——这是以正确的方式进行操作所必需的开销。在下一节中,我们将开始实际测试。

如何做...

让我们更仔细地看一下Vehicle类。测试 getter 的价值不大,但我们仍然可以这样做,确保传递给构造函数的值由相应的 getter 返回。构造函数中的异常也属于必须测试的功能,以及getSpeedMph()方法。还有一个Engine类的对象,它具有getHorsePower()方法。它能返回null吗?为了回答这个问题,让我们看一下Engine类:

public class Engine {
  private int horsePower;
  public int getHorsePower() {
    return horsePower;
  }
  public void setHorsePower(int horsePower) {
    this.horsePower = horsePower;
  }
}

getHorsePower()方法不能返回null。如果没有通过setHorsePower()方法显式设置,horsePower字段将默认初始化为零。但是返回负值是一个明显的可能性,这反过来可能会导致getSpeedMph()方法的Math.sqrt()函数出现问题。我们应该确保马力值永远不会是负数吗?这取决于方法的使用限制程度以及输入数据的来源。

VehicleweightPounds字段的值也适用类似的考虑。它可能会在getSpeedMph()方法中由于除以零而导致ArithmeticException而使应用程序停止。

然而,在实践中,发动机马力和车辆重量的值几乎不可能是负数或接近零,因此我们将假设这一点,并不会将这些检查添加到代码中。

这样的分析是每个开发人员的日常例行公事和背景思考,这是朝着正确方向迈出的第一步。第二步是在单元测试中捕获所有这些思考和疑虑,并验证假设。

让我们回到我们创建的测试类。你可能已经注意到,@Test注解使某个方法成为测试方法。这意味着每次你发出运行测试的命令时,它都会被你的 IDE 或 Maven 运行。方法可以以任何你喜欢的方式命名,但最佳实践建议指出你正在测试的方法(在这种情况下是Vehicle类)。因此,格式通常看起来像test<methodname><scenario>,其中scenario表示特定的测试用例:一个成功的路径,一个失败,或者你想测试的其他条件。在第一个示例中,虽然我们没有使用后缀,但为了保持代码简单,我们将展示稍后测试其他场景的方法示例。

在测试中,你可以调用正在测试的应用程序方法,提供数据,并断言结果。你可以创建自己的断言(比较实际结果和预期结果的方法),或者你可以使用 JUnit 提供的断言。要做到后者,只需添加静态导入:

import static org.junit.Assert.assertEquals;

如果你使用现代 IDE,你可以输入import static org.junit.Assert,看看有多少不同的断言可用(或者去 JUnit 的 API 文档中查看)。有十几个或更多的重载方法可用:assertArrayEquals()assertEquals()assertNotEquals()assertNull()assertNotNull()assertSame()assertNotSame()assertFalse()assertTrue()assertThat()fail()。如果你花几分钟阅读这些方法的作用将会很有帮助。你也可以根据它们的名称猜测它们的目的。下面是assertEquals()方法的使用示例:

import org.junit.Test;
import static org.junit.Assert.assertEquals;
public class VehicleTest {
  @Test
  public void testGetSpeedMph(){
    System.out.println("Hello!" + " I am your first test method!");
    assertEquals(4, "Hello".length());
  }
}

我们比较单词Hello的实际长度和预期长度4。我们知道正确的数字应该是5,但我们希望测试失败以演示失败的行为。如果你运行前面的测试,你会得到以下结果:

正如你所看到的,最后一行告诉你出了什么问题:预期值是4,而实际值是5。假设你像这样交换参数的顺序:

assertEquals("Assert Hello length:","Hello".length(), 4);

结果将如下所示:

现在最后一条消息是误导性的。

重要的是要记住,在每个断言方法中,预期值的参数位于(在断言的签名中)实际值之前

写完测试后,你会做其他事情,几个月后,你可能会忘记每个断言实际评估了什么。但有一天测试可能会失败(因为应用程序代码已更改)。你会看到测试方法名称,预期值和实际值,但你必须深入代码以找出哪个断言失败(每个测试方法通常有几个断言)。你可能会被迫添加调试语句并多次运行测试以找出原因。

为了帮助你避免这种额外的挖掘,JUnit 的每个断言都允许你添加描述特定断言的消息。例如,运行测试的这个版本:

public class VehicleTest {
  @Test
  public void testGetSpeedMph(){
    System.out.println("Hello!" + " I am your first test method!");
    assertEquals("Assert Hello length:", 4, "Hello".length());
  }
}

如果你这样做,结果会更容易阅读:

为了完成这个演示,我们将预期值更改为5

assertEquals("Assert Hello length:", 5, "Hello".length());

现在测试结果显示没有失败:

它是如何工作的...

具备了对 JUnit 框架使用的基本理解,我们现在可以为计算具有特定重量和特定马力发动机的车辆速度的主要情况编写一个真正的测试方法。我们首先使用速度计算的公式手动计算预期值。例如,如果车辆的发动机功率为 246 hp,重量为 4,000 磅,那么在 10 秒内,其速度可以达到 117 英里/小时。由于速度是double类型,我们将使用带有一些 delta 的断言。否则,由于double值在计算机中的表示方式,两个double值可能永远不会相等。这是org.junit.Assert类的断言方法:

void assertEquals(String message, double expected, 
                       double actual, double delta)

delta值是允许的精度。test方法的最终实现将如下所示:

@Test
public void testGetSpeedMph(){
  double timeSec = 10.0;
  int engineHorsePower = 246;
  int vehicleWeightPounds = 4000;

  Engine engine = new Engine();
  engine.setHorsePower(engineHorsePower);

  Vehicle vehicle = new Vehicle(vehicleWeightPounds, engine);
  double speed = vehicle.getSpeedMph(timeSec);
  assertEquals("Assert vehicle (" + engineHorsePower 
            + " hp, " + vehicleWeightPounds + " lb) speed in " 
            + timeSec + " sec: ", 117, speed, 0.001 * speed);
}

如您所见,我们已经决定值的千分之一是我们目的的足够精度。如果我们运行前面的测试,输出将如下所示:

为了确保测试有效,我们可以将预期值设置为 119 英里/小时(与实际值相差超过 1%)并再次运行测试。结果将如下所示:

我们将预期值改回 117,并继续编写我们在分析代码时讨论的其他测试用例。

让我们确保在预期时抛出异常。为此,我们添加另一个导入:

import static org.junit.Assert.fail;

然后,我们可以编写测试代码,测试当Vehicle类的构造函数中传递的值为 null 时的情况(因此应该抛出异常):

@Test
public void testGetSpeedMphException(){
  int vehicleWeightPounds = 4000;
  Engine engine = null;
  try {
    Vehicle vehicle = new Vehicle(vehicleWeightPounds, engine);
    fail("Exception was not thrown");
  } catch (RuntimeException ex) {}
}

这个测试成功运行,这意味着Vehicle构造函数抛出了异常,并且代码从未到达过这一行:

    fail("Exception was not thrown");

为了确保测试正确工作,我们临时将非 null 值传递给Vehicle构造函数:

Engine engine = new Engine();

然后,我们观察输出:

通过这种方式,我们可以确保我们的测试按预期工作。或者,我们可以创建另一个测试,当抛出异常时失败:

@Test
public void testGetSpeedMphException(){
  int vehicleWeightPounds = 4000;
  Engine engine = new Engine();
  try {
    Vehicle vehicle = new Vehicle(vehicleWeightPounds, engine);
  } catch (RuntimeException ex) {
    fail("Exception was thrown");
  }
}

编写这样的测试的最佳方式是在编写应用程序代码的过程中,这样您可以随着代码的复杂性增长而测试代码。否则,特别是在更复杂的代码中,您可能在编写所有代码后有问题调试它。

还有一些其他注释和 JUnit 功能对您可能有帮助,因此请参考 JUnit 文档,以更深入地了解所有框架功能。

通过模拟依赖项进行单元测试

编写单元测试需要控制所有输入数据。如果一个方法从其他对象接收其输入,就需要限制测试的深度,以便每个层可以作为一个单元独立测试。这就是模拟较低级别的需求时出现的情况。

模拟不仅可以垂直进行,还可以在同一级别水平进行。如果一个方法很大且复杂,您可能需要考虑将其拆分为几个较小的方法,这样您可以在模拟其他方法的同时仅测试其中一个。这是单元测试代码与其开发一起的另一个优势;在开发的早期阶段更容易重新设计代码以获得更好的可测试性。

准备就绪

模拟其他方法和类很简单。编码到接口(如第二章中描述的快速跟踪到 OOP-类和接口)使得这变得更容易,尽管有一些模拟框架允许您模拟不实现任何接口的类(我们将在本食谱的下一部分看到此类框架使用的示例)。此外,使用对象和方法工厂可以帮助您创建特定于测试的工厂实现,以便它们可以生成具有返回预期硬编码值的方法的对象。

例如,在第四章函数式编程中,我们介绍了FactoryTraffic,它生产了一个或多个TrafficUnit对象。在真实系统中,这个工厂会从某个外部系统中获取数据。使用真实系统作为数据源可能会使代码设置变得复杂。正如你所看到的,为了解决这个问题,我们通过根据与真实系统相似的分布生成数据来模拟数据:汽车比卡车多一点,车辆的重量取决于汽车的类型,乘客数量和有效载荷的重量等。对于这样的模拟,重要的是值的范围(最小值和最大值)应该反映出来自真实系统的值,这样应用程序就可以在可能的真实数据的全部范围内进行测试。

模拟代码的重要约束是它不应该太复杂。否则,它的维护将需要额外的开销,这将要么降低团队的生产力,要么降低测试覆盖率。

如何做...

FactoryTraffic的模拟可能如下所示:

public class FactoryTraffic {
  public static List<TrafficUnit> generateTraffic(int 
    trafficUnitsNumber, Month month, DayOfWeek dayOfWeek, 
    int hour, String country, String city, String trafficLight){
    List<TrafficUnit> tms = new ArrayList();
    for (int i = 0; i < trafficUnitsNumber; i++) {
      TrafficUnit trafficUnit = 
        FactoryTraffic.getOneUnit(month, dayOfWeek,  hour, country, 
                                  city, trafficLight);
        tms.add(trafficUnit);
    }
    return tms;
  }
}

它组装了一个TrafficUnit对象的集合。在真实系统中,这些对象可以从例如某个数据库查询结果的行创建。但在我们的情况下,我们只是硬编码了这些值:

public static TrafficUnit getOneUnit(Month month, 
              DayOfWeek dayOfWeek, int hour, String country, 
              String city, String trafficLight) {
  double r0 = Math.random(); 
  VehicleType vehicleType = r0 < 0.4 ? VehicleType.CAR :
  (r0 > 0.6 ? VehicleType.TRUCK : VehicleType.CAB_CREW);
  double r1 = Math.random();
  double r2 = Math.random();
  double r3 = Math.random();
  return new TrafficModelImpl(vehicleType, gen(4,1),
             gen(3300,1000), gen(246,100), gen(4000,2000),
             (r1 > 0.5 ? RoadCondition.WET : RoadCondition.DRY),    
             (r2 > 0.5 ? TireCondition.WORN : TireCondition.NEW),
             r1 > 0.5 ? ( r3 > 0.5 ? 63 : 50 ) : 63 );
}

如你所见,我们使用随机数生成器来为每个参数选择一个范围内的值。这个范围与真实数据的范围一致。这段代码非常简单,不需要太多的维护,但它提供了与真实数据类似的数据流给应用程序。

你可以使用另一种技术。例如,让我们重新审视VechicleTest类。我们可以使用其中一个模拟框架来模拟而不是创建一个真实的Engine对象。在这种情况下,我们使用 Mockito。以下是它的 Maven 依赖项:

<dependency>
  <groupId>org.mockito</groupId>
  <artifactId>mockito-core</artifactId>
  <version>2.7.13</version>
  <scope>test</scope>
</dependency>

测试方法现在看起来像这样(已更改的两行已突出显示):

@Test
public void testGetSpeedMph(){
  double timeSec = 10.0;
  int engineHorsePower = 246;
  int vehicleWeightPounds = 4000;

 Engine engine = Mockito.mock(Engine.class);
  Mockito.when(engine.getHorsePower()).thenReturn(engineHorsePower);

  Vehicle vehicle =  new Vehicle(vehicleWeightPounds, engine);
  double speed = vehicle.getSpeedMph(timeSec);
  assertEquals("Assert vehicle (" + engineHorsePower 
               + " hp, " + vehicleWeightPounds + " lb) speed in " 
               + timeSec + " sec: ", 117, speed, 0.001 * speed);
}

如你所见,我们指示mock对象在调用getHorsePower()方法时返回一个固定值。我们甚至可以为我们想要测试的方法创建一个模拟对象:

Vehicle vehicleMock = Mockito.mock(Vehicle.class);
Mockito.when(vehicleMock.getSpeedMph(10)).thenReturn(30d);

double speed = vehicleMock.getSpeedMph(10);
System.out.println(speed);

因此,它总是返回相同的值:

然而,这将违背测试的目的,因为我们想测试计算速度的代码,而不是模拟它。

对于测试流的管道方法,还可以使用另一种技术。假设我们需要测试TrafficDensity1类中的trafficByLane()方法(我们也将有TrafficDensity2TrafficDensity3):

public class TrafficDensity1 {
  public Integer[] trafficByLane(Stream<TrafficUnit> stream, 
  int trafficUnitsNumber, double timeSec,
  SpeedModel speedModel, double[] speedLimitByLane) {

    int lanesCount = speedLimitByLane.length;

    Map<Integer, Integer> trafficByLane = stream
      .limit(trafficUnitsNumber)
      .map(TrafficUnitWrapper::new)
      .map(tuw -> tuw.setSpeedModel(speedModel))
      .map(tuw -> tuw.calcSpeed(timeSec))
      .map(speed ->  countByLane(lanesCount, speedLimitByLane, speed))
      .collect(Collectors.groupingBy(CountByLane::getLane, 
               Collectors.summingInt(CountByLane::getCount)));

    for(int i = 1; i <= lanesCount; i++){
      trafficByLane.putIfAbsent(i, 0);
    }
    return trafficByLane.values()
      .toArray(new Integer[lanesCount]);
  }

  private CountByLane countByLane(int lanesCount, 
                 double[] speedLimit, double speed) {
    for(int i = 1; i <= lanesCount; i++){
      if(speed <= speedLimit[i - 1]){
        return new CountByLane(1, i);
      }
    }
    return new CountByLane(1, lanesCount);
  }
}

它使用了两个支持类:

private class CountByLane{
  int count, lane;
  private CountByLane(int count, int lane){
    this.count = count;
    this.lane = lane;
  }
  public int getLane() { return lane; }
  public int getCount() { return count; }
}

它还使用以下内容:

private static class TrafficUnitWrapper {
  private Vehicle vehicle;
  private TrafficUnit trafficUnit;
  public TrafficUnitWrapper(TrafficUnit trafficUnit){
    this.vehicle = FactoryVehicle.build(trafficUnit);
    this.trafficUnit = trafficUnit;
  }
  public TrafficUnitWrapper setSpeedModel(SpeedModel speedModel) {
    this.vehicle.setSpeedModel(speedModel);
    return this;
  }
  public double calcSpeed(double timeSec) {
    double speed = this.vehicle.getSpeedMph(timeSec);
    return Math.round(speed * this.trafficUnit.getTraction());
  }
}

我们在第三章模块化编程中演示了这些支持类的使用,同时讨论了流。现在我们意识到测试这个类可能不容易。

因为SpeedModel对象是trafficByLane()方法的输入参数,我们可以单独测试它的getSpeedMph()方法:

@Test
public void testSpeedModel(){
  double timeSec = 10.0;
  int engineHorsePower = 246;
  int vehicleWeightPounds = 4000;
  double speed = getSpeedModel().getSpeedMph(timeSec,
                 vehicleWeightPounds, engineHorsePower);
  assertEquals("Assert vehicle (" + engineHorsePower 
               + " hp, " + vehicleWeightPounds + " lb) speed in " 
               + timeSec + " sec: ", 117, speed, 0.001 * speed);
}

private SpeedModel getSpeedModel(){
  //FactorySpeedModel possibly
}

参考以下代码:

public class FactorySpeedModel {
  public static SpeedModel generateSpeedModel(TrafficUnit trafficUnit){
    return new SpeedModelImpl(trafficUnit);
  }
  private static class SpeedModelImpl implements SpeedModel{
    private TrafficUnit trafficUnit;
    private SpeedModelImpl(TrafficUnit trafficUnit){
      this.trafficUnit = trafficUnit;
    }
    public double getSpeedMph(double timeSec, 
                              int weightPounds, int horsePower) {
      double traction = trafficUnit.getTraction();
      double v = 2.0 * horsePower * 746 
                 * timeSec * 32.174 / weightPounds;
      return Math.round(Math.sqrt(v) * 0.68 * traction);
    }
  }

如你所见,FactorySpeedModel的当前实现需要TrafficUnit对象以获取牵引值。为了解决这个问题,我们可以修改前面的代码并移除SpeedModelTrafficUnit的依赖。我们可以通过将牵引应用到calcSpeed()方法来实现。FactorySpeedModel的新版本可以看起来像这样:

public class FactorySpeedModel {
  public static SpeedModel generateSpeedModel(TrafficUnit 
                                                   trafficUnit) {
    return new SpeedModelImpl(trafficUnit);
  }
 public static SpeedModel getSpeedModel(){
 return SpeedModelImpl.getSpeedModel();
 }
  private static class SpeedModelImpl implements SpeedModel{
    private TrafficUnit trafficUnit;
    private SpeedModelImpl(TrafficUnit trafficUnit){
      this.trafficUnit = trafficUnit;
    }
    public double getSpeedMph(double timeSec, 
                     int weightPounds, int horsePower) {
      double speed = getSpeedModel()
             .getSpeedMph(timeSec, weightPounds, horsePower);
      return Math.round(speed *trafficUnit.getTraction());
    }
    public static SpeedModel getSpeedModel(){
      return  (t, wp, hp) -> {
        double weightPower = 2.0 * hp * 746 * 32.174 / wp;
        return Math.round(Math.sqrt(t * weightPower) * 0.68);
      };
    }
  }
}

现在可以实现测试方法如下:

@Test
public void testSpeedModel(){
  double timeSec = 10.0;
  int engineHorsePower = 246;
  int vehicleWeightPounds = 4000;
  double speed = FactorySpeedModel.generateSpeedModel()
                 .getSpeedMph(timeSec, vehicleWeightPounds, 
                              engineHorsePower);
  assertEquals("Assert vehicle (" + engineHorsePower 
               + " hp, " + vehicleWeightPounds + " lb) speed in " 
               + timeSec + " sec: ", 117, speed, 0.001 * speed);
}

然而,TrafficUnitWrapper中的calcSpeed()方法仍未经过测试。我们可以将trafficByLane()方法作为一个整体进行测试:

@Test
public void testTrafficByLane() {
  TrafficDensity1 trafficDensity = new TrafficDensity1();
  double timeSec = 10.0;
  int trafficUnitsNumber = 120;
  double[] speedLimitByLane = {30, 50, 65};
  Integer[] expectedCountByLane = {30, 30, 60};
  Integer[] trafficByLane = 
    trafficDensity.trafficByLane(getTrafficUnitStream2(
      trafficUnitsNumber), trafficUnitsNumber, timeSec, 
      FactorySpeedModel.getSpeedModel(),speedLimitByLane);
    assertArrayEquals("Assert count of " 
              + trafficUnitsNumber + " vehicles by " 
              + speedLimitByLane.length +" lanes with speed limit " 
              + Arrays.stream(speedLimitByLane)
                      .mapToObj(Double::toString)
                      .collect(Collectors.joining(", ")),
                      expectedCountByLane, trafficByLane);
}

但这将需要创建一个具有固定数据的TrafficUnit对象流:

TrafficUnit getTrafficUnit(int engineHorsePower, 
                           int vehicleWeightPounds) {
  return new TrafficUnit() {
    @Override
    public Vehicle.VehicleType getVehicleType() {
      return Vehicle.VehicleType.TRUCK;
    }
    @Override
    public int getHorsePower() {return engineHorsePower;}
    @Override
    public int getWeightPounds() { return vehicleWeightPounds; }
    @Override
    public int getPayloadPounds() { return 0; }
    @Override
    public int getPassengersCount() { return 0; }
    @Override
    public double getSpeedLimitMph() { return 55; }
    @Override
    public double getTraction() { return 0.2; }
    @Override
    public SpeedModel.RoadCondition getRoadCondition(){return null;}
    @Override
    public SpeedModel.TireCondition getTireCondition(){return null;}
    @Override
    public int getTemperature() { return 0; }
  };
}

这样的解决方案不能为不同车辆类型和其他参数提供各种测试数据。我们需要重新审视trafficByLane()方法的设计。

它是如何工作的...

如果你仔细观察trafficByLane()方法,你会注意到问题是由于计算的位置——在私有类TrafficUnitWrapper内部。我们可以将其移出,并在TrafficDensity类中创建一个新的calcSpeed()方法:

double calcSpeed(double timeSec) {
  double speed = this.vehicle.getSpeedMph(timeSec);
  return Math.round(speed * this.trafficUnit.getTraction());
}

然后,我们可以改变其签名,并将Vehicle对象和traction系数作为参数包括进去:

double calcSpeed(Vehicle vehicle, double traction, double timeSec){
  double speed = vehicle.getSpeedMph(timeSec);
  return Math.round(speed * traction);
}

让我们还向TrafficUnitWrapper类添加两个方法(您马上就会看到我们为什么需要它们):

public Vehicle getVehicle() { return vehicle; }
public double getTraction() { return trafficUnit.getTraction(); }

前面的更改允许我们重写流管道如下(更改的行用粗体标出):

Map<Integer, Integer> trafficByLane = stream
  .limit(trafficUnitsNumber)
  .map(TrafficUnitWrapper::new)
  .map(tuw -> tuw.setSpeedModel(speedModel))
  .map(tuw -> calcSpeed(tuw.getVehicle(), tuw.getTraction(), timeSec))
  .map(speed -> countByLane(lanesCount, speedLimitByLane, speed))
      .collect(Collectors.groupingBy(CountByLane::getLane, 
            Collectors.summingInt(CountByLane::getCount)));

通过将calcSpeed()方法设置为 protected,并假设Vehicle类在其自己的测试类VehicleTest中进行了测试,我们现在可以编写testCalcSpeed()方法:

@Test
public void testCalcSpeed(){
  double timeSec = 10.0;
  TrafficDensity2 trafficDensity = new TrafficDensity2();

  Vehicle vehicle = Mockito.mock(Vehicle.class);
  Mockito.when(vehicle.getSpeedMph(timeSec)).thenReturn(100d);
  double traction = 0.2;
  double speed = trafficDensity.calcSpeed(vehicle, traction, timeSec);
  assertEquals("Assert speed (traction=" + traction + ") in " 
               + timeSec + " sec: ",20,speed,0.001 *speed);
}

剩下的功能可以通过模拟calcSpeed()方法来测试:

@Test
public void testCountByLane() {
  int[] count ={0};
  double[] speeds = 
                  {1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12};
  TrafficDensity2 trafficDensity = new TrafficDensity2() {
    @Override
    protected double calcSpeed(Vehicle vehicle, 
                     double traction, double timeSec) {
      return speeds[count[0]++];
    }
  };
  double timeSec = 10.0;
  int trafficUnitsNumber = speeds.length;

  double[] speedLimitByLane = {4.5, 8.5, 12.5};
  Integer[] expectedCountByLane = {4, 4, 4};

  Integer[] trafficByLane = trafficDensity.trafficByLane( 
    getTrafficUnitStream(trafficUnitsNumber), 
    trafficUnitsNumber, timeSec, FactorySpeedModel.getSpeedModel(),
    speedLimitByLane );
  assertArrayEquals("Assert count of " + speeds.length 
          + " vehicles by " + speedLimitByLane.length 
          + " lanes with speed limit " 
          + Arrays.stream(speedLimitByLane)
             .mapToObj(Double::toString).collect(Collectors
             .joining(", ")), expectedCountByLane, trafficByLane);
}

还有更多...

这种经验使我们意识到,使用内部私有类可能会使功能在隔离中无法测试。让我们试着摆脱privateCountByLane。这将导致TrafficDensity3类的第三个版本(更改的代码已突出显示):

Integer[] trafficByLane(Stream<TrafficUnit> stream, 
int trafficUnitsNumber, double timeSec,
SpeedModel speedModel, double[] speedLimitByLane) {
  int lanesCount = speedLimitByLane.length;
  Map<Integer, Integer> trafficByLane = new HashMap<>();
  for(int i = 1; i <= lanesCount; i++){
    trafficByLane.put(i, 0);
  }
  stream.limit(trafficUnitsNumber)
    .map(TrafficUnitWrapper::new)
    .map(tuw -> tuw.setSpeedModel(speedModel))
    .map(tuw -> calcSpeed(tuw.getVehicle(), tuw.getTraction(), 
                                                         timeSec))
 .forEach(speed -> trafficByLane.computeIfPresent(
 calcLaneNumber(lanesCount, 
                         speedLimitByLane, speed), (k, v) -> ++v));    return trafficByLane.values().toArray(new Integer[lanesCount]);}
protected int calcLaneNumber(int lanesCount, 
  double[] speedLimitByLane, double speed) {
 for(int i = 1; i <= lanesCount; i++){
 if(speed <= speedLimitByLane[i - 1]){
 return i;
      }
 }
 return lanesCount;
}

这个改变允许我们在我们的测试中扩展这个类:

class TrafficDensityTestCalcLaneNumber extends TrafficDensity3 {
  protected int calcLaneNumber(int lanesCount, 
    double[] speedLimitByLane, double speed){
    return super.calcLaneNumber(lanesCount, 
    speedLimitByLane, speed);
  }
}

它还允许我们单独更改calcLaneNumber()测试方法:

@Test
public void testCalcLaneNumber() {
  double[] speeds = {1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12};
  double[] speedLimitByLane = {4.5, 8.5, 12.5};
  int[] expectedLaneNumber = {1, 1, 1, 1, 2, 2, 2, 2, 3, 3, 3, 3};

  TrafficDensityTestCalcLaneNumber trafficDensity = 
               new TrafficDensityTestCalcLaneNumber();
  for(int i = 0; i < speeds.length; i++){
    int ln = trafficDensity.calcLaneNumber(
               speedLimitByLane.length, 
               speedLimitByLane, speeds[i]);
    assertEquals("Assert lane number of speed " 
                + speeds + " with speed limit " 
                + Arrays.stream(speedLimitByLane)
                        .mapToObj(Double::toString).collect(
                              Collectors.joining(", ")), 
                expectedLaneNumber[i], ln);
  }
}

使用 fixtures 来为测试填充数据

在更复杂的应用程序中(例如使用数据库),通常需要在每个测试之前设置数据,并在测试完成后清理数据。一些数据的部分需要在每个测试方法之前设置和/或在每个测试方法完成后清理。其他数据可能需要在测试类的任何测试方法运行之前设置,并/或在测试类的最后一个测试方法完成后清理。

如何做...

为了实现这一点,您在其前面添加了一个@Before注释,这表示这个方法必须在每个测试方法之前运行。相应的清理方法由@After注释标识。类级别的设置方法由@BeforeClass@AfterClass注释标识,这意味着这些设置方法只会在测试类的任何测试方法执行之前执行一次(@BeforeClass),并在测试类的最后一个测试方法执行之后执行一次(@AfterClass)。这是一个快速演示:

public class DatabaseRelatedTest {
  @BeforeClass
  public static void setupForTheClass(){
    System.out.println("setupForTheClass() is called");
  }
  @AfterClass
  public static void cleanUpAfterTheClass(){
    System.out.println("cleanAfterClass() is called");
  }
  @Before
  public void setupForEachMethod(){
    System.out.println("setupForEachMethod() is called");
  }
  @After
  public void cleanUpAfterEachMethod(){
    System.out.println("cleanAfterEachMethod() is called");
  }
  @Test
  public void testMethodOne(){      
    System.out.println("testMethodOne() is called"); 
  }
  @Test
  public void testMethodTwo(){ 
    System.out.println("testMethodTwo() is called"); 
  }
}

如果现在运行测试,你会得到以下结果:

这种修复测试上下文的方法称为fixtures。请注意,它们必须是公共的,类级别的设置/清理 fixtures 必须是静态的。然而,即将推出的 JUnit 版本 5 计划取消这些限制。

它是如何工作的...

这种用法的典型例子是在第一个测试方法运行之前创建必要的表,并在测试类的最后一个方法完成后删除它们。设置/清理方法也可以用于创建/关闭数据库连接,除非您的代码在 try-with-resources 结构中执行(参见第十一章,内存管理和调试)。

这是 fixtures 的一个使用示例(参见第六章,数据库编程,了解更多关于如何设置数据库运行的内容)。假设我们需要测试DbRelatedMethods类:

class DbRelatedMethods{
  public void updateAllTextRecordsTo(String text){
    executeUpdate("update text set text = ?", text);
  }
  private void executeUpdate(String sql, String text){
    try (Connection conn = getDbConnection();
      PreparedStatement st = conn.prepareStatement(sql)){
        st.setString(1, text);
        st.executeUpdate();
      } catch (Exception ex) {
        ex.printStackTrace();
      }
    }
    private Connection getDbConnection(){
       //...  code that creates DB connection 
    }
}

我们希望确保前一个方法updateAllTextRecordsTo()总是使用提供的值更新text表的所有记录。我们的第一个测试updateAllTextRecordsTo1()是更新一个现有记录:

@Test
public void updateAllTextRecordsTo1(){
  System.out.println("updateAllTextRecordsTo1() is called");
  String testString = "Whatever";
  System.out.println("  Update all records to " + testString);
  dbRelatedMethods.updateAllTextRecordsTo(testString);
  int count = countRecordsWithText(testString);
  assertEquals("Assert number of records with " 
                                  + testString + ": ", 1, count);
  System.out.println("All records are updated to " + testString);
}

这意味着表必须存在于测试数据库中,并且其中应该有一条记录。

我们的第二个测试,updateAllTextRecordsTo2(),确保即使每条记录包含不同的值,也会更新两条记录:

@Test
public void updateAllTextRecordsTo2(){
  System.out.println("updateAllTextRecordsTo2() is called");
  String testString = "Unexpected";
  System.out.println("Update all records to " + testString);
  dbRelatedMethods.updateAllTextRecordsTo(testString);
  executeUpdate("insert into text(id,text) values(2, ?)","Text 01");

  testString = "Whatever";
  System.out.println("Update all records to " + testString);
  dbRelatedMethods.updateAllTextRecordsTo(testString);
  int count = countRecordsWithText(testString);
  assertEquals("Assert number of records with " 
               + testString + ": ", 2, count);
  System.out.println("  " + count + " records are updated to " +
                                                        testString);
}

前面的两个测试都使用了相同的表,即text。因此,在每次测试后无需删除表。这就是为什么我们在类级别创建和删除它的原因:

@BeforeClass
public static void setupForTheClass(){
  System.out.println("setupForTheClass() is called");
  execute("create table text (id integer not null, 
          text character varying not null)");
}
@AfterClass
public static void cleanUpAfterTheClass(){
  System.out.println("cleanAfterClass() is called");
  execute("drop table text");
}

这意味着我们只需要在每个测试之前填充表格,并在每个测试完成后清理它:

@Before
public void setupForEachMethod(){
  System.out.println("setupForEachMethod() is called");
  executeUpdate("insert into text(id, text) values(1,?)", "Text 01");
}
@After
public void cleanUpAfterEachMethod(){
  System.out.println("cleanAfterEachMethod() is called");
  execute("delete from text");
}

此外,由于我们可以为所有测试使用相同的对象dbRelatedMethods,因此让我们也在类级别上创建它(作为测试类的属性),这样它只会被创建一次:

private DbRelatedMethods dbRelatedMethods = new DbRelatedMethods();

如果我们现在运行test类的所有测试,输出将如下所示:

打印的消息可以让您跟踪所有方法调用的顺序,并查看它们是否按预期执行。

集成测试

如果您已经阅读了所有章节并查看了代码示例,您可能已经注意到,到目前为止,我们已经讨论并构建了典型分布式应用程序所需的所有组件。现在是将所有组件放在一起并查看它们是否按预期协作的时候了。这个过程被称为集成

在这个过程中,我们将仔细评估应用程序是否符合要求。在功能需求以可执行形式呈现的情况下(例如使用 Cucumber 框架),我们可以运行它们并检查是否所有检查都通过。许多软件公司遵循行为驱动开发流程,并在很早的时候进行测试,有时甚至在编写大量代码之前(当然,这样的测试会失败,但一旦实现了预期的功能就会成功)。正如前面提到的,早期测试对于编写专注、清晰和易于测试的代码非常有帮助。

然而,即使不严格遵循“先测试”流程,集成阶段自然也包括某种行为测试。在本章中,我们将看到几种可能的方法和与此相关的具体示例。

准备就绪

您可能已经注意到,在本书的过程中,我们构建了几个组成应用程序的类,用于分析和建模交通。为了方便起见,我们已经将它们全部包含在com.packt.cookbook.ch16_testing包中。

从前面的章节中,您已经熟悉了api文件夹中的五个接口——CarSpeedModelTrafficUnitTruckVehicle。它们的实现被封装在同名文件夹中的类中:FactorySpeedModelFactoryTrafficFactoryVehicle。这些工厂为我们的演示应用程序的核心类AverageSpeed(第七章,并发和多线程编程)和TrafficDensity(基于第五章,流和管道,但在本章中创建和讨论)提供输入。它们产生了激发开发这个特定应用程序的值。

应用程序的主要功能很简单。对于每条车道的车道数和速度限制,AverageSpeed计算(估计)每条车道的实际速度(假设所有驾驶员都是理性的,根据他们的速度选择车道),而TrafficDensity计算了 10 秒后每条车道上的车辆数(假设所有车辆在交通灯后同时开始)。这些计算是基于在特定位置和时间收集的numberOfTrafficUnits辆车的数据。这并不意味着所有的 1,000 辆车都在同一时间比赛。这 1,000 个测量点是在 50 年内收集的,用于在指定的交叉口在指定的小时内行驶的大约 20 辆车(平均每三分钟一辆车)。

应用程序的整体基础设施由process文件夹中的类支持:DispatcherProcessorSubscription。我们讨论了它们的功能,并在第七章,并发和多线程编程中进行了演示。这些类允许分发处理。

Dispatcher类向池中的Processors群发请求进行处理,使用Subscription类。每个Processor类根据请求执行任务(使用AverageSpeedTrafficDensity类),并将结果存储在数据库中(使用utils文件夹中的DbUtil类,基于第六章中讨论的功能,数据库编程)。

我们已经将大多数这些类作为单元进行了测试。现在我们将对它们进行集成,并测试整个应用程序的正确行为。

这些要求仅用于演示目的。演示的目标是展示一些有动机的东西(类似真实数据),同时又足够简单,不需要特殊的交通分析和建模知识即可理解。

如何做...

集成有几个级别。我们需要集成应用程序的类和子系统,还需要将我们的应用程序与外部系统集成(由第三方开发和维护的交通数据源)。

这是使用Chapter14Testing类中的demo1_class_level_integration()方法进行类级别集成的示例:

String result = IntStream.rangeClosed(1, 
  speedLimitByLane.length).mapToDouble(i -> {
    AverageSpeed averageSpeed = 
      new AverageSpeed(trafficUnitsNumber, timeSec, 
                       dateLocation, speedLimitByLane, i,100);
    ForkJoinPool commonPool = ForkJoinPool.commonPool();
    return commonPool.invoke(averageSpeed);
}).mapToObj(Double::toString).collect(Collectors.joining(", "));
System.out.println("Average speed = " + result);

TrafficDensity trafficDensity = new TrafficDensity();
Integer[] trafficByLane = 
     trafficDensity.trafficByLane(trafficUnitsNumber,
                    timeSec, dateLocation, speedLimitByLane );
System.out.println("Traffic density = "+Arrays.stream(trafficByLane)
                                .map(Object::toString)
                                .collect(Collectors.joining(", ")));

在这个例子中,我们集成了两个主要类,即AverageSpeedTrafficDensity,并使用它们的接口的工厂和实现。

结果如下:

请注意,结果在每次运行时略有不同。这是因为FactoryTraffic生成的数据在每个请求之间都有所不同。但是,在这个阶段,我们只需要确保一切协同工作,并产生一些看起来更或多或少准确的结果。我们已经通过单元测试了代码,并且对每个单元是否按预期工作有一定的信心。在实际集成测试过程中,而不是在集成过程中,我们将回到结果的验证。

在类级别完成集成后,使用Chapter14Testing类中的demo1_subsystem_level_integration()方法查看子系统如何一起工作:

DbUtil.createResultTable();
Dispatcher.dispatch(trafficUnitsNumber, timeSec, dateLocation, 
                    speedLimitByLane);
try { Thread.sleep(2000L); } 
catch (InterruptedException ex) {}
Arrays.stream(Process.values()).forEach(v -> {
  System.out.println("Result " + v.name() + ": " 
                     + DbUtil.selectResult(v.name()));
});

在这段代码中,我们使用DBUtil创建了一个必要的表,用于保存Processor生成和记录的输入数据和结果。Dispatcher类向Processor类的对象发送请求和输入数据,如下所示:

void dispatch(int trafficUnitsNumber, double timeSec, 
         DateLocation dateLocation, double[] speedLimitByLane) {
  ExecutorService execService =  ForkJoinPool.commonPool();
  try (SubmissionPublisher<Integer> publisher = 
                              new SubmissionPublisher<>()){
    subscribe(publisher, execService,Process.AVERAGE_SPEED, 
              timeSec, dateLocation, speedLimitByLane);
   subscribe(publisher,execService,Process.TRAFFIC_DENSITY, 
             timeSec, dateLocation, speedLimitByLane);
    publisher.submit(trafficUnitsNumber);
  } finally {
    try {
      execService.shutdown();
      execService.awaitTermination(1, TimeUnit.SECONDS);
    } catch (Exception ex) {
      System.out.println(ex.getClass().getName());
    } finally {
      execService.shutdownNow();
    }
  }
}

Subscription类用于发送/接收消息(参考第七章,并发和多线程编程,了解此功能的描述):

void subscribe(SubmissionPublisher<Integer> publisher, 
              ExecutorService execService, Process process, 
              double timeSec, DateLocation dateLocation, 
              double[] speedLimitByLane) {
  Processor<Integer> subscriber =  new Processor<>(process, timeSec, 
                                 dateLocation, speedLimitByLane);
  Subscription subscription = 
                       new Subscription(subscriber, execService);
  subscriber.onSubscribe(subscription);
  publisher.subscribe(subscriber);
}

处理器正在执行它们的工作;我们只需要等待几秒钟(如果您使用的计算机需要更多时间来完成工作,可以调整此时间)然后我们就可以得到结果。我们使用DBUtil从数据库中读取结果:

Process枚举类的名称指向数据库中result表中的相应记录。同样,在这个阶段,我们主要是希望得到任何结果,而不是关注值的正确性。

在基于FactoryTraffic生成的数据的应用程序子系统之间成功集成后,我们可以尝试连接提供真实交通数据的外部系统。在FactoryTraffic中,我们现在将从生成TrafficUnit对象切换到从真实系统获取数据:

public class FactoryTraffic {
  private static boolean switchToRealData = true;
  public static Stream<TrafficUnit> 
  getTrafficUnitStream(DateLocation dl, int trafficUnitsNumber){
    if(switchToRealData){
      return getRealData(dL,  trafficUnitsNumber);
    } else {
      return IntStream.range(0, trafficUnitsNumber)
      .mapToObj(i -> generateOneUnit());
    }
  }

  private static Stream<TrafficUnit> 
  getRealData(DateLocation dl, int trafficUnitsNumber) {
    //connect to the source of the real data 
    // and request the flow or collection of data
    return new ArrayList<TrafficUnit>().stream();
  }
}

该开关可以作为类中的Boolean属性实现(如前面的代码所示),也可以作为项目配置属性。我们不会详细介绍连接到特定真实交通数据源的细节,因为这与本书的目的无关。

在这个阶段,主要关注性能,并在外部真实数据源和我们的应用程序之间实现平稳的数据流。在确保一切正常并且具有令人满意的性能的情况下,我们可以转向集成测试,并断言实际结果。

它是如何工作的...

对于测试,我们需要设置预期值,然后与处理真实数据的应用程序产生的实际值进行比较。但是真实数据在每次运行时都会略有变化,试图预测结果值要么使测试变得脆弱,要么迫使引入巨大的误差范围,这可能会有效地破坏测试的目的。

我们甚至不能模拟生成的数据(就像我们在单元测试中所做的那样),因为我们处于集成阶段,必须使用真实数据。

有一个可能的解决方案是将传入的真实数据和我们应用程序生成的结果存储在数据库中。然后,领域专家可以浏览每条记录,并断言结果是否符合预期。

为了实现这一点,我们在TrafficDensity类中引入了一个boolean开关,这样它就记录了每个计算单元的输入:

public class TrafficDensity {
 public static Connection conn;
 public static boolean recordData = false;
  //... 
  private double calcSpeed(TrafficUnitWrapper tuw, double timeSec){
    double speed = calcSpeed(tuw.getVehicle(),       
    tuw.getTrafficUnit().getTraction(), timeSec);
 if(recordData) {
 DbUtil.recordData(conn, tuw.getTrafficUnit(), speed);
 }
    return speed;
  }
  //...
} 

我们还引入了一个静态属性,以保持所有类实例之间相同的数据库连接。否则,连接池应该很大,因为正如你可能从第七章中所记得的那样,并发和多线程编程,执行任务的工作人员数量随着要执行的工作量的增加而增加。

如果你看看DbUtils,你会看到一个创建data表的新方法,该表设计用于保存来自FactoryTrafficTrafficUnits,以及保存用于数据请求和计算的主要参数的data_common表:请求的交通单位数量,交通数据的日期和地理位置,以秒为单位的时间(速度计算的时间点),以及每条车道的速度限制(其大小定义了我们在建模交通时计划使用多少条车道)。这是我们配置来进行记录的代码:

private static void demo3_prepare_for_integration_testing(){
  DbUtil.createResultTable();
  DbUtil.createDataTables();
  TrafficDensity.recordData = true;
  try(Connection conn = DbUtil.getDbConnection()){
    TrafficDensity.conn = conn;
    Dispatcher.dispatch(trafficUnitsNumber, timeSec, 
                        dateLocation, speedLimitByLane);
  } catch (SQLException ex){
    ex.printStackTrace();
  }
}

记录完成后,我们可以将数据交给领域专家,他可以断言应用程序行为的正确性。

验证的数据现在可以用于集成测试。我们可以在FactoryTrafficUnit中添加另一个开关,并强制它读取记录的数据,而不是不可预测的真实数据:

public class FactoryTraffic {
  public static boolean readDataFromDb = false;
  private static boolean switchToRealData = false;
  public static Stream<TrafficUnit> 
     getTrafficUnitStream(DateLocation dl, int trafficUnitsNumber){
 if(readDataFromDb){
 if(!DbUtil.isEnoughData(trafficUnitsNumber)){
 System.out.println("Not enough data");
        return new ArrayList<TrafficUnit>().stream();
      }
 return readDataFromDb(trafficUnitsNumber);
    }
    //....
}

正如你可能已经注意到的,我们还添加了isEnoughData()方法,用于检查是否有足够的记录数据:

public static boolean isEnoughData(int trafficUnitsNumber){
  try (Connection conn = getDbConnection();
  PreparedStatement st = 
      conn.prepareStatement("select count(*) from data")){
    ResultSet rs = st.executeQuery();
    if(rs.next()){
      int count = rs.getInt(1);
      return count >= trafficUnitsNumber;
    }
  } catch (Exception ex) {
    ex.printStackTrace();
  }
  return false;
}

这将有助于避免在测试更复杂的系统时不必要的调试问题所带来的挫败感。

现在,我们不仅控制输入值,还可以控制预期结果,这些结果可以用来断言应用程序的行为。这两者现在都包含在TrafficUnit对象中。为了能够做到这一点,我们利用了第二章中讨论的新的 Java 接口特性,即接口默认方法:

public interface TrafficUnit {
  VehicleType getVehicleType();
  int getHorsePower();
  int getWeightPounds();
  int getPayloadPounds();
  int getPassengersCount();
  double getSpeedLimitMph();
  double getTraction();
  RoadCondition getRoadCondition();
  TireCondition getTireCondition();
  int getTemperature();
 default double getSpeed(){ return 0.0; }
}

因此,我们可以将结果附加到输入数据。请参阅以下方法:

List<TrafficUnit> selectData(int trafficUnitsNumber){...}

我们可以将结果附加到DbUtil类和TrafficUnitImpl类中的DbUtil中:

class TrafficUnitImpl implements TrafficUnit{
  private int horsePower, weightPounds, payloadPounds, 
              passengersCount, temperature;
  private Vehicle.VehicleType vehicleType;
  private double speedLimitMph, traction, speed;
  private RoadCondition roadCondition;
  private TireCondition tireCondition;
  ...
  public double getSpeed() { return speed; }
}

我们也可以将其附加到DbUtil类中。

前面的更改使我们能够编写集成测试。首先,我们将使用记录的数据测试速度模型:

void demo1_test_speed_model_with_real_data(){
  double timeSec = DbUtil.getTimeSecFromDataCommon();
  FactoryTraffic.readDataFromDb = true;
  TrafficDensity trafficDensity = new TrafficDensity();
  FactoryTraffic.
           getTrafficUnitStream(dateLocation,1000).forEach(tu -> {
    Vehicle vehicle = FactoryVehicle.build(tu);
    vehicle.setSpeedModel(FactorySpeedModel.getSpeedModel());
    double speed = trafficDensity.calcSpeed(vehicle, 
                               tu.getTraction(), timeSec);
    assertEquals("Assert vehicle (" + tu.getHorsePower() 
                 + " hp, " + tu.getWeightPounds() + " lb) speed in " 
                 + timeSec + " sec: ", tu.getSpeed(), speed, 
                 speed * 0.001);
  });
}

可以使用类似的方法来测试AverageSpeed类的速度计算。

然后,我们可以为类级别编写一个集成测试。

private static void demo2_class_level_integration_test() {
  FactoryTraffic.readDataFromDb = true;
  String result = IntStream.rangeClosed(1, 
              speedLimitByLane.length).mapToDouble(i -> {
    AverageSpeed averageSpeed = new AverageSpeed(trafficUnitsNumber, 
               timeSec, dateLocation, speedLimitByLane, i,100);
    ForkJoinPool commonPool = ForkJoinPool.commonPool();
    return commonPool.invoke(averageSpeed);
  }).mapToObj(Double::toString).collect(Collectors.joining(", "));
  String expectedResult = "7.0, 23.0, 41.0";
  String limits = Arrays.stream(speedLimitByLane)
                        .mapToObj(Double::toString)
                        .collect(Collectors.joining(", "));
  assertEquals("Assert average speeds by " 
                + speedLimitByLane.length 
                + " lanes with speed limit " 
                + limits, expectedResult, result);

类似的代码也可以用于对 TrafficDensity 类进行类级别的测试:

TrafficDensity trafficDensity = new TrafficDensity();
String result = Arrays.stream(trafficDensity.
       trafficByLane(trafficUnitsNumber, timeSec, 
                     dateLocation, speedLimitByLane))
       .map(Object::toString)
       .collect(Collectors.joining(", "));
expectedResult = "354, 335, 311";
assertEquals("Assert vehicle count by " + speedLimitByLane.length + 
         " lanes with speed limit " + limits, expectedResult, result);

最后,我们也可以为子系统级别编写集成测试:

void demo3_subsystem_level_integration_test() {
  FactoryTraffic.readDataFromDb = true;
  DbUtil.createResultTable();
  Dispatcher.dispatch(trafficUnitsNumber, 10, dateLocation, 
                      speedLimitByLane);
  try { Thread.sleep(3000l); } 
  catch (InterruptedException ex) {}
  String result = DbUtil.selectResult(Process.AVERAGE_SPEED.name());
  String expectedResult = "7.0, 23.0, 41.0";
  String limits = Arrays.stream(speedLimitByLane)
                        .mapToObj(Double::toString)
                        .collect(Collectors.joining(", "));
  assertEquals("Assert average speeds by " + speedLimitByLane.length 
        + " lanes with speed limit " + limits, expectedResult, result);
  result = DbUtil.selectResult(Process.TRAFFIC_DENSITY.name());
  expectedResult = "354, 335, 311";
  assertEquals("Assert vehicle count by " + speedLimitByLane.length 
        + " lanes with speed limit " + limits, expectedResult, result);
}

所有前面的测试现在都成功运行,并且随时可以用于应用程序的回归测试。

只有当后者具有测试模式时,我们才能创建应用程序与真实交通数据源之间的自动集成测试,从而可以以与使用记录数据相同的方式发送相同的数据流。

所有这些集成测试都是可能的,当处理数据的数量在统计上是显著的时候。这是因为我们无法完全控制工作人员的数量以及 JVM 如何决定分配负载。很可能,在特定情况下,本章中演示的代码可能无法正常工作。在这种情况下,尝试增加请求的流量单位数量。这将确保更多的空间用于负载分配逻辑。

第十五章:使用 Java 10 和 Java 11 进行编码的新方式

在本章中,我们将介绍以下示例:

  • 使用局部变量类型推断

  • 使用 lambda 参数的局部变量语法

介绍

本章为您快速介绍了影响您编码的新功能。许多其他语言,包括 JavaScript,都具有此功能——使用var关键字声明变量(在 Java 中,它实际上是一个保留类型名称,而不是关键字)。它有许多优点,但也存在争议。如果过度使用,特别是对于短的非描述性标识符,可能会使代码变得不太可读,而增加的价值可能会被增加的代码模糊所淹没。

这就是为什么在下面的示例中,我们解释了引入保留var类型的原因。尽量避免在其他情况下使用var

使用局部变量类型推断

在本示例中,您将了解到局部变量类型推断,它是在 Java 10 中引入的,可以在哪里使用以及其限制。

准备工作

局部变量类型推断是编译器使用表达式的正确侧来识别局部变量类型的能力。在 Java 中,使用var标识符声明具有推断类型的局部变量。例如:

var i = 42;       //int
var s = "42";     //String
var list1 = new ArrayList();          //ArrayList of Objects;
var list2 = new ArrayList<String>();  //ArrayList of Strings

前面变量的类型是明确可识别的。我们在注释中捕获了它们的类型。

请注意,var不是关键字,而是一个标识符,具有作为局部变量声明类型的特殊含义。

它确实节省了输入,并使代码不再被重复的代码淹没。让我们看看这个例子:

Map<Integer, List<String>> idToNames = new HashMap<>();
//...
for(Map.Entry<Integer, List<String>> e: idToNames.entrySet()){
    List<String> names = e.getValue();
    //...
}

这是实现这样的循环的唯一方法。但自 Java 10 以来,可以写成如下形式:

var idToNames = new HashMap<Integer, List<String>>();
//...
for(var e: idToNames.entrySet()){
    var names = e.getValue();
    //...
}

正如您所看到的,代码变得更清晰,但使用更具描述性的变量名(如idToNamesnames)是有帮助的。无论如何,这都是有帮助的。但是,如果您不注意变量名,很容易使代码变得难以理解。例如,看看以下代码:

var names = getNames();

看看前一行,你不知道names变量的类型是什么。将其更改为idToNames会更容易猜到。然而,许多程序员不这样做。他们更喜欢使用简短的变量名,并使用 IDE 上下文支持来确定每个变量的类型(在变量名后添加一个点)。但归根结底,这只是一种风格和个人偏好的问题。

另一个潜在的问题来自于新的风格可能违反封装和编码到接口原则,如果不额外注意的话。例如,考虑这个接口及其实现:

interface A {
    void m();
}

static class AImpl implements A {
    public void m(){}
    public void f(){}
}

请注意,AImpl类具有比其实现的接口更多的公共方法。创建AImpl对象的传统方式如下:

A a = new AImpl();
a.m();
//a.f();  //does not compile

这样,我们只暴露接口中存在的方法,而新的风格允许访问所有方法:

var a = new AImpl();
a.m();
a.f();

为了限制仅引用接口的方法,需要添加类型转换,如下所示:

var a = (A) new AImpl();
a.m();
//a.f();  //does not compile

因此,像许多强大的工具一样,新的风格可以使您的代码更易于编写和更易于阅读,或者如果不特别注意,可能会使代码变得不太可读并且更难以调试。

如何做...

您可以以以下方式使用局部变量类型:

  • 使用右侧初始化器:
var i = 1; 
var a = new int[2];
var l = List.of(1, 2); 
var c = "x".getClass(); 
var o = new Object() {}; 
var x = (CharSequence & Comparable<String>) "x";

以下声明和赋值是非法的,不会编译:

var e;                 // no initializer
var g = null;          // null type
var f = { 6 };         // array initializer
var g = (g = 7);       // self reference is not allowed
var b = 2, c = 3.0;    // multiple declarators re not allowed
var d[] = new int[4];  // extra array dimension brackets
var f = () -> "hello"; // lambda requires an explicit target-type

通过扩展,在循环中使用初始化器:

for(var i = 0; i < 10; i++){
    //...
}

我们已经谈到了这个例子:

var idToNames = new HashMap<Integer, List<String>>();
//...
for(var e: idToNames.entrySet()){
    var names = e.getValue();
    //...
}
  • 作为匿名类引用:
interface A {
 void m();
}

var aImpl = new A(){
 @Override
 public void m(){
 //...
 }
};
  • 作为标识符:
var var = 1;
  • 作为方法名:
public void var(int i){
    //...
}

var不能用作类或接口名称。

  • 作为包名:
package com.packt.cookbook.var;

使用 lambda 参数的局部变量语法

在本示例中,您将学习如何使用局部变量语法(在前一个示例中讨论)用于 lambda 参数以及引入此功能的动机。它是在 Java 11 中引入的。

准备工作

直到 Java 11 发布之前,有两种声明参数类型的方式——显式和隐式。以下是显式版本:

BiFunction<Double, Integer, Double> f = (Double x, Integer y) -> x / y;
System.out.println(f.apply(3., 2));    //prints: 1.5

以下是隐式参数类型定义:

BiFunction<Double, Integer, Double> f = (x, y) -> x / y;
System.out.println(f.apply(3., 2));     //prints: 1.5

在上述代码中,编译器通过接口定义来确定参数的类型。

使用 Java 11,引入了另一种参数类型声明的方式——使用var标识符。

如何做...

  1. 在 Java 11 之前的隐式版本的参数声明与以下参数声明完全相同:
BiFunction<Double, Integer, Double> f = (var x, var y) -> x / y;
System.out.println(f.apply(3., 2));       //prints: 1.5

  1. 新的局部变量样式语法允许我们在不明确定义参数类型的情况下添加注释:
import org.jetbrains.annotations.NotNull;
...
BiFunction<Double, Integer, Double> f = 
 (@NotNull var x, @NotNull var y) -> x / y;
System.out.println(f.apply(3., 2));        //prints: 1.5

注释告诉处理代码的工具(例如 IDE)程序员的意图,因此它们可以在编译或执行过程中警告程序员,以防违反声明的意图。例如,我们尝试在 IntelliJ IDEA 中运行以下代码:

BiFunction<Double, Integer, Double> f = (x, y) -> x / y;
System.out.println(f.apply(null, 2));    

运行时出现了NullPointerException。然后我们运行了以下代码(带有注释):

BiFunction<Double, Integer, Double> f4 = 
           (@NotNull var x, @NotNull var y) -> x / y;
Double j = 3.;
Integer i = 2;
System.out.println(f4.apply(j, i)); 

结果如下:

Exception in thread "main" java.lang.IllegalArgumentException: 
Argument for @NotNull parameter 'x' of com/packt/cookbook/ch17_new_way/b_lambdas/Chapter15Var.lambda$main$4 must not be null

lambda 表达式甚至没有被执行。

  1. 在 lambda 参数的情况下,局部变量语法的优势变得清晰起来,如果我们需要在参数是一个名字非常长的类的对象时使用注释。在 Java 11 之前,代码可能如下所示:
BiFunction<SomeReallyLongClassName, 
  AnotherReallyLongClassName, Double> f4 = 
    (@NotNull SomeReallyLongClassName x, 
     @NotNull AnotherReallyLongClassName y) -> x.doSomething(y);

我们不得不明确声明变量的类型,因为我们想要添加注释,而以下的隐式版本甚至无法编译:

BiFunction<SomeReallyLongClassName, 
   AnotherReallyLongClassName, Double> f4 = 
      (@NotNull x, @NotNull y) -> x.doSomething(y);

使用 Java 11,新的语法允许我们使用var标识符进行隐式参数类型推断:

BiFunction<SomeReallyLongClassName, 
   AnotherReallyLongClassName, Double> f4 = 
      (@NotNull var x, @NotNull var y) -> x.doSomething(y);

这就是引入 lambda 参数声明的局部变量语法的优势和动机。

第十六章:使用 JavaFX 进行 GUI 编程

在本章中,我们将涵盖以下配方:

  • 使用 JavaFX 控件创建 GUI

  • 使用 FXML 标记创建 GUI

  • 使用 CSS 为 JavaFX 中的元素设置样式

  • 创建柱状图

  • 创建饼图

  • 在应用程序中嵌入 HTML

  • 在应用程序中嵌入媒体

  • 向控件添加效果

  • 使用机器人 API

介绍

自 JDK 1.0 以来,Java 一直有 GUI 编程,通过名为抽象窗口工具包AWT)的 API。在那个时代,这是一件了不起的事情,但它也有自己的局限性,其中一些如下:

  • 它有一组有限的组件。

  • 由于 AWT 使用本机组件,因此无法创建自定义可重用组件。

  • 组件的外观和感觉无法控制,它们采用了主机操作系统的外观和感觉。

然后,在 Java 1.2 中,引入了一种名为Swing的新 GUI 开发 API,它通过提供以下功能来解决 AWT 的不足:

  • 更丰富的组件库。

  • 支持创建自定义组件。

  • 本地外观和感觉,以及支持插入不同的外观和感觉。一些著名的 Java 外观和感觉主题包括 Nimbus、Metal、Motif 和系统默认。

已经构建了许多使用 Swing 的桌面应用程序,其中许多仍在使用。然而,随着时间的推移,技术必须不断发展;否则,它最终将过时并且很少被使用。2008 年,Adobe 的Flex开始引起关注。这是一个用于构建富互联网应用程序RIA)的框架。桌面应用程序一直是基于丰富组件的 UI,但是 Web 应用程序并不那么令人惊叹。Adobe 推出了一个名为 Flex 的框架,它使 Web 开发人员能够在 Web 上创建丰富、沉浸式的 UI。因此,Web 应用程序不再无聊。

Adobe 还为桌面引入了一个富互联网应用程序运行时环境,称为Adobe AIR,它允许在桌面上运行 Flex 应用程序。这对古老的 Swing API 是一个重大打击。但让我们回到市场:2009 年,Sun Microsystems 推出了一个名为JavaFX的东西。这个框架受 Flex 的启发(使用 XML 定义 UI),并引入了自己的脚本语言称为JavaFX 脚本,它与 JSON 和 JavaScript 有些相似。您可以从 JavaFX 脚本中调用 Java API。引入了一个新的架构,其中有一个新的窗口工具包和一个新的图形引擎。这是一个比 Swing 更好的选择,但它有一个缺点——开发人员必须学习 JavaFX 脚本来开发基于 JavaFX 的应用程序。除了 Sun Microsystems 无法在 JavaFX 和 Java 平台上投入更多投资之外,JavaFX 从未像预期的那样起飞。

Oracle(收购 Sun Microsystems 后)宣布推出了一个新的 JavaFX 版本 2.0,这是对 JavaFX 的整体重写,从而消除了脚本语言,并使 JavaFX 成为 Java 平台内的 API。这使得使用 JavaFX API 类似于使用 Swing API。此外,您可以在 Swing 中嵌入 JavaFX 组件,从而使基于 Swing 的应用程序更加功能强大。从那时起,JavaFX 就再也没有回头看了。

从 JDK 11 开始(无论是 Oracle JDK 还是 OpenJDK 构建),JavaFX 都不再捆绑在一起。OpenJDK 10 构建也不再捆绑 JavaFX。它们必须从 OpenJFX 项目页面(wiki.openjdk.java.net/display/OpenJFX/Main)单独下载。OpenJFX 还推出了一个新的社区网站(openjfx.io/)。

在这一章中,我们将完全专注于围绕 JavaFX 的配方。我们将尽量涵盖尽可能多的配方,以便让您充分体验使用 JavaFX。

使用 JavaFX 控件创建 GUI

在这个示例中,我们将看到如何使用 JavaFX 控件创建一个简单的 GUI 应用程序。我们将构建一个应用程序,可以在您提供出生日期后帮助您计算您的年龄。您还可以输入您的姓名,应用程序将向您问候并显示您的年龄。这是一个相当简单的示例,试图展示如何通过使用布局、组件和事件处理来创建 GUI。

准备工作

以下是 JavaFX 的模块的一部分:

  • javafx.base

  • javafx.controls

  • javafx.fxml

  • javafx.graphics

  • javafx.media

  • javafx.swing

  • javafx.web

如果您使用的是 Oracle JDK 10 和 9,它会随着之前提到的 JavaFX 模块一起安装;也就是说,您可以在JAVA_HOME/jmods目录中找到它们。如果您使用的是 OpenJDK 10 及更高版本和 JDK 11 及更高版本,您需要从gluonhq.com/products/javafx/下载 JavaFX SDK,并将JAVAFX_SDK_PATH/libs位置的 JAR 文件可用于您的modulepath

javac -p "PATH_TO_JAVAFX_SDK_LIB" <other parts of the command line> 

#Windows
java -p "PATH_TO_JAVAFX_SDK_LIB;COMPILED_CODE" <other parts of the command line>
#Linux 
java -p "PATH_TO_JAVAFX_SDK_LIB:COMPILED_CODE" <other parts of the command line>

在我们的示例中,我们将根据需要从上面的列表中使用一些模块。

如何做...

  1. 创建一个扩展javafx.application.Application的类。Application类管理 JavaFX 应用程序的生命周期。Application类有一个抽象方法start(Stage stage),您必须实现这个方法。这将是 JavaFX UI 的起始点:
        public class CreateGuiDemo extends Application{
          public void start(Stage stage){
            //to implement in new steps
          }
        }

该类还可以通过提供一个public static void main(String [] args) {}方法成为应用程序的起始点:

        public class CreateGuiDemo extends Application{
          public void start(Stage stage){
            //to implement in new steps
          }
          public static void main(String[] args){
            //launch the JavaFX application
          }
        }

后续步骤的代码必须在start(Stage stage)方法中编写。

  1. 让我们创建一个容器布局,以正确对齐我们将要添加的组件。在这种情况下,我们将使用javafx.scene.layout.GridPane来以行和列的形式布置组件:
        GridPane gridPane = new GridPane();
        gridPane.setAlignment(Pos.CENTER);
        gridPane.setHgap(10);
        gridPane.setVgap(10);
        gridPane.setPadding(new Insets(25, 25, 25, 25));

除了创建GridPane实例之外,我们还设置了它的布局属性,比如GridPane的对齐方式,行和列之间的水平和垂直间距,以及网格中每个单元格的填充。

  1. 创建一个新的标签,它将显示我们应用程序的名称,具体来说是年龄计算器,并将其添加到我们在前一步中创建的gridPane中:
        Text appTitle = new Text("Age calculator");
        appTitle.setFont(Font.font("Arial", FontWeight.NORMAL, 15));
        gridPane.add(appTitle, 0, 0, 2, 1);
  1. 创建一个标签和文本输入组合,用于接受用户的姓名。然后将这两个组件添加到gridPane中:
        Label nameLbl = new Label("Name");
        TextField nameField = new TextField();
        gridPane.add(nameLbl, 0, 1);
        gridPane.add(nameField, 1, 1);
  1. 创建一个标签和日期选择器组合,用于接受用户的出生日期:
        Label dobLbl = new Label("Date of birth");
        gridPane.add(dobLbl, 0, 2);
        DatePicker dateOfBirthPicker = new DatePicker();
        gridPane.add(dateOfBirthPicker, 1, 2);
  1. 创建一个按钮,用户将用它来触发年龄计算,并将其添加到gridPane中:
        Button ageCalculator = new Button("Calculate");
        gridPane.add(ageCalculator, 1, 3);
  1. 创建一个组件来保存计算出的年龄的结果:
        Text resultTxt = new Text();
        resultTxt.setFont(Font.font("Arial", FontWeight.NORMAL, 15));
        gridPane.add(resultTxt, 0, 5, 2, 1);
  1. 现在我们需要为第 6 步中创建的按钮绑定一个动作。动作将是获取在名称字段中输入的名称和在日期选择器字段中输入的出生日期。如果提供了出生日期,则使用 Java 时间 API 来计算现在和出生日期之间的时间段。如果提供了名称,则在结果前加上一个问候语,你好,<name>
        ageCalculator.setOnAction((event) -> {
          String name = nameField.getText();
          LocalDate dob = dateOfBirthPicker.getValue();
          if ( dob != null ){
            LocalDate now = LocalDate.now();
            Period period = Period.between(dob, now);
            StringBuilder resultBuilder = new StringBuilder();
            if ( name != null && name.length() > 0 ){
              resultBuilder.append("Hello, ")
                         .append(name)
                         .append("n");
            }
            resultBuilder.append(String.format(
              "Your age is %d years %d months %d days",
              period.getYears(), 
              period.getMonths(), 
              period.getDays())
            );
            resultTxt.setText(resultBuilder.toString());
          }
        });
  1. 通过提供我们在第 2 步中创建的gridPane对象和场景的宽度和高度,创建Scene类的一个实例:
        Scene scene = new Scene(gridPane, 300, 250);

Scene的一个实例保存了 UI 组件的图形,这被称为场景图

  1. 我们已经看到start()方法为我们提供了一个Stage对象的引用。Stage对象是 JavaFX 中的顶级容器,类似于 JFrame。我们将Scene对象设置为Stage对象,并使用它的show()方法来渲染 UI:
        stage.setTitle("Age calculator");
        stage.setScene(scene);
        stage.show();
  1. 现在我们需要从主方法启动这个 JavaFX UI。我们使用Application类的launch(String[] args)方法来启动 JavaFX UI:
        public static void main(String[] args) {
          Application.launch(args);
        }

完整的代码可以在Chapter16/1_create_javafx_gui中找到。

我们在Chapter16/1_create_javafx_gui中提供了两个脚本,run.batrun.shrun.bat脚本用于在 Windows 上运行应用程序,run.sh用于在 Linux 上运行应用程序。

使用run.batrun.sh运行应用程序,您将看到 GUI,如下面的屏幕截图所示:

输入姓名和出生日期,然后单击Calculate查看年龄:

工作原理...

在进入其他细节之前,让我们简要概述一下 JavaFX 架构。我们从 JavaFX 文档中获取了以下描述架构堆栈的图表(docs.oracle.com/javase/8/javafx/get-started-tutorial/jfx-architecture.htm#JFXST788):

让我们从堆栈的顶部开始:

  • JavaFX API 和场景图: 这是应用程序的起点,我们大部分关注点将围绕这部分。它提供了不同组件、布局和其他实用程序的 API,以便开发基于 JavaFX 的 UI。场景图保存了应用程序的可视元素。

  • Prism、Quantum Toolkit 和蓝色的其他内容: 这些组件管理 UI 的渲染,并在底层操作系统和 JavaFX 之间提供桥梁。在图形硬件无法提供丰富 UI 和 3D 元素的硬件加速渲染的情况下,此层提供软件渲染。

  • 玻璃窗口工具包: 这是窗口工具包,就像 Swing 使用的 AWT 一样。

  • 媒体引擎: 这支持 JavaFX 中的媒体。

  • Web 引擎: 这支持 Web 组件,允许完整的 HTML 渲染。

  • JDK API 和 JVM: 这些与 Java API 集成,并将代码编译为字节码以在 JVM 上运行。

让我们回到解释这个示例。javafx.application.Application类是启动 JavaFX 应用程序的入口点。它具有以下方法,这些方法映射到应用程序的生命周期(按其调用顺序):

  • init(): 此方法在javafx.application.Application实例化后立即调用。您可以重写此方法,在应用程序启动之前进行一些初始化。默认情况下,此方法不执行任何操作。

  • start(javafx.stage.Stage): 此方法在init()之后立即调用,并在系统完成运行应用程序所需的初始化后调用。此方法传递了一个javafx.stage.Stage实例,这是组件呈现的主要 stage。您可以创建其他javafx.stage.Stage对象,但应用程序提供的是主要 stage。

  • stop(): 当应用程序应该停止时调用此方法。您可以执行必要的退出相关操作。

stage是一个顶级的 JavaFX 容器。作为start()方法的参数传递的主要 stage 是由平台创建的,应用程序可以根据需要创建其他Stage容器。

javafx.application.Application相关的另一个重要方法是launch()方法。有两种变体:

  • launch(Class<? extends Application> appClass, String... args)

  • launch(String... args)

此方法从主方法中调用,应该只调用一次。第一个变体带有扩展javafx.application.Application类的类名以及传递给主方法的参数,第二个变体不带类名,而是应该从扩展javafx.application.Application类的类内部调用。在我们的示例中,我们使用了第二个变体。

我们创建了一个类CreateGuiDemo,扩展了javafx.application.Application。这将是 JavaFX UI 的入口点,我们还向类中添加了一个 main 方法,使其成为我们应用程序的入口点。

布局构造确定了组件的布局方式。JavaFX 支持多种布局,如下所示:

  • javafx.scene.layout.HBoxjavafx.scene.layout.VBox:这些用于水平和垂直对齐组件。

  • javafx.scene.layout.BorderPane:这允许在顶部、右侧、底部、左侧和中心位置放置组件。

  • javafx.scene.layout.FlowPane:此布局允许在流中放置组件,即相邻放置,并在流面板的边界处换行。

  • javafx.scene.layout.GridPane:此布局允许在行和列的网格中放置组件。

  • javafx.scene.layout.StackPane:此布局将组件放置在一个从后到前的堆栈中。

  • javafx.scene.layout.TilePane:此布局将组件放置在统一大小的网格中。

在我们的示例中,我们使用了GridPane并配置了布局,以便实现以下目标:

  • 在中心放置网格(`gridPane.setAlignment(Pos.CENTER);)

  • 将列之间的间隙设置为 10(`gridPane.setHgap(10);)

  • 将行之间的间隙设置为 10(gridPane.setVgap(10);)

  • 在网格的单元格内设置填充(`gridPane.setPadding(new Insets(25, 25, 25, 25));)

javafx.scene.text.Text组件的字体可以使用javafx.scene.text.Font对象来设置,如下所示:appTitle.setFont(Font.font("Arial", FontWeight.NORMAL, 15));

在将组件添加到javafx.scene.layout.GridPane时,我们必须提到列号、行号和列跨度,即组件占据多少列,以及行跨度,即组件在该顺序中占据多少行。列跨度和行跨度是可选的。在我们的示例中,我们将appTitle放在第一行和列中,并占据两列空间和一行空间,如下所示:appTitle.setFont(Font.font("Arial", FontWeight.NORMAL, 15));

在这个示例中的另一个重要部分是为ageCalculator按钮设置事件。我们使用javafx.scene.control.Button类的setOnAction()方法来设置按钮点击时执行的操作。这接受javafx.event.EventHandler<ActionEvent>接口的实现。由于javafx.event.EventHandler是一个功能接口,因此可以以 lambda 表达式的形式编写其实现,如下所示:

ageCalculator.setOnAction((event) -> {
  //event handling code here
});

上述语法看起来类似于 Swing 时期广泛使用的匿名内部类。您可以在第四章“进入函数”中的示例中了解有关功能接口和 lambda 表达式的更多信息。

在我们的事件处理代码中,我们通过使用getText()getValue()方法从nameFielddateOfBirthPicker中获取值。DatePicker将所选日期作为java.time.LocalDate的实例返回。这是 Java 8 中添加的新日期时间 API 之一。它表示一个日期,即年、月和日,没有任何与时区相关的信息。然后我们使用java.time.Period类来找到当前日期和所选日期之间的持续时间,如下所示:

LocalDate now = LocalDate.now();
Period period = Period.between(dob, now);

Period表示基于日期的持续时间,例如三年、两个月和三天。这正是我们试图用这行代码提取的内容:String.format("你的年龄是%d 年%d 月%d 天", period.getYears(), period.getMonths(), period.getDays())

我们已经提到,JavaFX 中的 UI 组件以场景图的形式表示,然后将此场景图呈现到一个称为Stage的容器上。创建场景图的方法是使用javafx.scene.Scene类。我们通过传递场景图的根以及提供场景图将呈现在其中的容器的尺寸来创建javafx.scene.Scene实例。

我们利用提供给start()方法的容器,这只是javafx.stage.Stage的一个实例。为Stage对象设置场景,然后调用其show()方法,使完整的场景图在显示器上呈现:

stage.setScene(scene);
stage.show();

使用 FXML 标记创建 GUI

在我们的第一个示例中,我们使用 Java API 构建了一个 UI。经常发生的情况是,精通 Java 的人可能不是一个好的 UI 设计师;也就是说,他们可能不擅长为他们的应用程序确定最佳的用户体验。在 Web 开发领域,我们有开发人员根据 UX 设计师提供的设计进行前端开发,另一组开发人员则负责后端开发,构建由前端使用的服务。

两个开发人员团队同意一组 API 和一个共同的数据交换模型。前端开发人员使用基于数据交换模型的一些模拟数据,并将 UI 与所需的 API 集成。另一方面,后端开发人员致力于实现 API,以便它们返回协商的数据交换模型中的数据。因此,双方同时使用各自领域的专业知识进行工作。

如果桌面应用程序能够以某种程度上(至少在某种程度上)复制相同的功能将是很棒的。这方面的一大进展是引入了一种基于 XML 的语言,称为FXML。这使得 UI 开发具有声明性方法,开发人员可以独立使用相同的 JavaFX 组件开发 UI,但可用作 XML 标记。JavaFX 组件的不同属性可用作 XML 标记的属性。事件处理程序可以在 Java 代码中声明和定义,然后从 FXML 中引用。

在本示例中,我们将指导您通过使用 FXML 构建 UI,然后将 FXML 与 Java 代码集成,以绑定操作并启动在 FXML 中定义的 UI。

准备工作

由于我们知道自从 Oracle JDK 11 开始和 Open JDK 10 开始,JavaFX 库不再随 JDK 安装一起提供,我们将不得不从gluonhq.com/products/javafx/下载 JavaFX SDK,并使用-p选项将 SDK 的lib文件夹中的 JAR 文件添加到模块路径中,如下所示:

javac -p "PATH_TO_JAVAFX_SDK_LIB" <other parts of the command line> 

#Windows
java -p "PATH_TO_JAVAFX_SDK_LIB;COMPILED_CODE" <other parts of the command line> 
#Linux 
java -p "PATH_TO_JAVAFX_SDK_LIB:COMPILED_CODE" <other parts of the command line> 

我们将开发一个简单的年龄计算器应用程序。该应用程序将要求用户输入姓名(可选)和出生日期,并根据给定的出生日期计算年龄并显示给用户。

如何做...

  1. 所有 FXML 文件应以.fxml扩展名结尾。让我们在src/gui/com/packt位置创建一个空的fxml_age_calc_gui.xml文件。在随后的步骤中,我们将使用 JavaFX 组件的 XML 标签更新此文件。

  2. 创建一个GridPane布局,它将在行和列的网格中容纳所有组件。我们还将使用vgaphgap属性为行和列之间提供所需的间距。此外,我们将为GridPane(我们的根组件)提供对 Java 类的引用,我们将在其中添加所需的事件处理。这个 Java 类将类似于 UI 的控制器:

        <GridPane alignment="CENTER" hgap="10.0" vgap="10.0"

          fx:controller="com.packt.FxmlController">
        </GridPane>
  1. 我们将通过在GridPane中定义Insets标签来为网格中的每个单元格提供填充:
        <padding>
          <Insets bottom="25.0" left="25.0" right="25.0" top="25.0" />
        </padding>
  1. 接下来是添加一个Text标签,用于显示应用程序的标题——年龄计算器。我们在style属性中提供所需的样式信息,并使用GridPane.columnIndexGridPane.rowIndex属性将Text组件放置在GridPane中。可以使用GridPane.columnSpanGridPane.rowSpan属性提供单元格占用信息:
        <Text style="-fx-font: NORMAL 15 Arial;" text="Age calculator"
          GridPane.columnIndex="0" GridPane.rowIndex="0" 
          GridPane.columnSpan="2" GridPane.rowSpan="1">
        </Text>
  1. 然后我们添加LabelTextField组件来接受名称。注意在TextField中使用了fx:id属性。这有助于通过创建与fx:id值相同的字段来绑定 Java 控制器中的这个组件:
        <Label text="Name" GridPane.columnIndex="0" 
          GridPane.rowIndex="1">
        </Label>
        <TextField fx:id="nameField" GridPane.columnIndex="1" 
          GridPane.rowIndex="1">
        </TextField>
  1. 我们添加LabelDatePicker组件来接受出生日期:
        <Label text="Date of Birth" GridPane.columnIndex="0" 
          GridPane.rowIndex="2">
        </Label>
        <DatePicker fx:id="dateOfBirthPicker" GridPane.columnIndex="1" 
          GridPane.rowIndex="2">
        </DatePicker>
  1. 然后,我们添加一个Button对象,并将其onAction属性设置为 Java 控制器中处理此按钮点击事件的方法的名称:
        <Button onAction="#calculateAge" text="Calculate"
          GridPane.columnIndex="1" GridPane.rowIndex="3">
        </Button>
  1. 最后,我们添加一个Text组件来显示计算出的年龄:
        <Text fx:id="resultTxt" style="-fx-font: NORMAL 15 Arial;"
          GridPane.columnIndex="0" GridPane.rowIndex="5"
          GridPane.columnSpan="2" GridPane.rowSpan="1"
        </Text>
  1. 下一步是实现与前面步骤中创建的基于 XML 的 UI 组件相关的 Java 类。创建一个名为FxmlController的类。这将包含与 FXML UI 相关的代码;也就是说,它将包含对在 FXML 中创建的组件的引用,以及对在 FXML 中创建的组件的动作处理程序:
        public class FxmlController {
          //to implement in next few steps
        }
  1. 我们需要引用nameFielddateOfBirthPickerresultText组件。我们使用前两个分别获取输入的名称和出生日期,第三个用于显示年龄计算的结果:
        @FXML private Text resultTxt;
        @FXML private DatePicker dateOfBirthPicker;
        @FXML private TextField nameField;
  1. 下一步是实现calculateAge方法,该方法注册为Calculate按钮的动作事件处理程序。实现与上一个示例中类似。唯一的区别是它是一个方法,而不是上一个示例中的 lambda 表达式:
        @FXML
        public void calculateAge(ActionEvent event){
          String name = nameField.getText();
          LocalDate dob = dateOfBirthPicker.getValue();
          if ( dob != null ){
            LocalDate now = LocalDate.now();
            Period period = Period.between(dob, now);
            StringBuilder resultBuilder = new StringBuilder();
            if ( name != null && name.length() > 0 ){
              resultBuilder.append("Hello, ")
                           .append(name)
                           .append("n");
            }
            resultBuilder.append(String.format(
              "Your age is %d years %d months %d days", 
              period.getYears(), 
              period.getMonths(), 
              period.getDays())
            );
            resultTxt.setText(resultBuilder.toString());
          }
        }
  1. 在步骤 10 和 11 中,我们使用了一个注解@FXML。这个注解表示该类或成员对基于 FXML 的 UI 是可访问的。

  2. 接下来,我们将创建另一个 Java 类FxmlGuiDemo,负责渲染基于 FXML 的 UI,也将是启动应用程序的入口点:

        public class FxmlGuiDemo extends Application{ 
          //code to launch the UI + provide main() method
        }
  1. 现在我们需要通过覆盖javafx.application.Application类的start(Stage stage)方法从 FXML UI 定义创建一个场景图,并在传递的javafx.stage.Stage对象中渲染场景图:
        @Override
        public void start(Stage stage) throws IOException{
          FXMLLoader loader = new FXMLLoader();
          Pane pane = (Pane)loader.load(getClass()
              .getModule()
              .getResourceAsStream("com/packt/fxml_age_calc_gui.fxml")
          );

          Scene scene = new Scene(pane,300, 250);
          stage.setTitle("Age calculator");
          stage.setScene(scene);
          stage.show();
        }
  1. 最后,我们提供main()方法的实现:
        public static void main(String[] args) {
          Application.launch(args);
        }

完整的代码可以在Chapter16/2_fxml_gui位置找到。

我们在Chapter16/2_fxml_gui中提供了两个运行脚本,run.batrun.shrun.bat脚本用于在 Windows 上运行应用程序,run.sh用于在 Linux 上运行应用程序。

使用run.batrun.sh运行应用程序,您将看到如下屏幕截图所示的 GUI:

输入名称和出生日期,然后点击Calculate来查看年龄:

工作原理...

没有定义 FXML 文档模式的 XSD。因此,要知道要使用的标签,它们遵循一个简单的命名约定。组件的 Java 类名也是 XML 标签的名称。例如,javafx.scene.layout.GridPane布局的 XML 标签是<GridPane>javafx.scene.control.TextField的 XML 标签是<TextField>javafx.scene.control.DatePicke的 XML 标签是<DatePicker>

Pane pane = (Pane)loader.load(getClass()
    .getModule()
    .getResourceAsStream("com/packt/fxml_age_calc_gui.fxml")
 );

上面的代码行利用javafx.fxml.FXMLLoader的一个实例来读取 FXML 文件并获取 UI 组件的 Java 表示。FXMLLoader使用基于事件的 SAX 解析器来解析 FXML 文件。通过反射创建 XML 标签的相应 Java 类的实例,并将 XML 标签的属性值填充到 Java 类的相应属性中。

由于我们的 FXML 的根是javafx.scene.layout.GridPane,它扩展了javafx.scene.layout.Pane,我们可以将FXMLoader.load()的返回值转换为javafx.scene.layout.Pane

这个示例中的另一个有趣的地方是FxmlController类。这个类充当了与 FXML 的接口。我们在 FXML 中使用fx:controller属性指示相同的内容到<GridPane>标签。我们可以通过在FxmlController类的成员字段上使用@FXML注解来获取在 FXML 中定义的 UI 组件,就像我们在这个示例中所做的那样:

@FXML private Text resultTxt;
@FXML private DatePicker dateOfBirthPicker;
@FXML private TextField nameField;

成员的名称与 FXML 中的fx:id属性值相同,成员的类型与 FXML 中的标签类型相同。例如,第一个成员绑定到以下内容:

<Text fx:id="resultTxt" style="-fx-font: NORMAL 15 Arial;"
  GridPane.columnIndex="0" GridPane.rowIndex="5" 
  GridPane.columnSpan="2" GridPane.rowSpan="1">
</Text>

在类似的情况下,我们在FxmlController中创建了一个事件处理程序,并用@FXML对其进行了注释,在 FXML 中使用了<Button>onAction属性引用了它。请注意,我们在onAction属性值的方法名称前面添加了#

使用 CSS 来为 JavaFX 中的元素设置样式

来自 Web 开发背景的人将能够欣赏到层叠样式表CSS)的实用性,而对于那些不了解的人,我们将在深入介绍 JavaFX 中的 CSS 应用之前,提供它们的概述和用途。

在网页上看到的元素或组件通常根据网站的主题进行样式设置。这种样式是通过使用一种叫做CSS的语言实现的。CSS 由一组以分号分隔的name:value对组成。当这些name:value对与 HTML 元素关联时,比如<button>,它就会获得所需的样式。

有多种方法将这些name:value对与元素关联起来,最简单的方法是将这些name:value对放在 HTML 元素的 style 属性中。例如,要给按钮设置蓝色背景,我们可以这样做:

<button style="background-color: blue;"></button>

不同样式属性有预定义的名称,并且它们接受特定的一组值;也就是说,属性background-color只能接受有效的颜色值。

另一种方法是在一个扩展名为.css的不同文件中定义这些name:value对的组。让我们称这组name:value对为CSS 属性。我们可以将这些 CSS 属性与不同的选择器关联起来,即选择器用于选择要应用 CSS 属性的 HTML 页面上的元素。有三种不同的提供选择器的方法:

  1. 直接给出 HTML 元素的名称,即是锚标签(<a>)、按钮或输入。在这种情况下,CSS 属性将应用于页面中所有类型的 HTML 元素。

  2. 通过使用 HTML 元素的id属性。假设我们有一个id="btn1"的按钮,那么我们可以定义一个选择器#btn1,针对它提供 CSS 属性。看下面的例子:

        #btn1 { background-color: blue; }
  1. 通过使用 HTML 元素的 class 属性。假设我们有一个class="blue-btn"的按钮,那么我们可以定义一个选择器.blue-btn,针对它提供 CSS 属性。看下面的例子:
        .blue-btn { background-color: blue; }

使用不同的 CSS 文件的优势在于,我们可以独立地改变网页的外观,而不会与元素的位置紧密耦合。此外,这鼓励在不同页面之间重用 CSS 属性,从而使它们在所有页面上具有统一的外观。

当我们将类似的方法应用于 JavaFX 时,我们可以利用我们的 Web 设计人员已经掌握的 CSS 知识来为 JavaFX 组件构建 CSS,这有助于比使用 Java API 更轻松地为组件设置样式。当这种 CSS 与 FXML 混合在一起时,它就成为了 Web 开发人员熟悉的领域。

在这个示例中,我们将使用外部 CSS 文件来为一些 JavaFX 组件设置样式。

准备就绪

由于我们知道从 Oracle JDK 11 开始和 Open JDK 10 开始,JavaFX 库不再随 JDK 安装一起提供,我们需要从gluonhq.com/products/javafx/下载 JavaFX SDK,并使用-p选项将 SDK 的lib文件夹中的 JAR 包包含在模块路径上,如下所示:

javac -p "PATH_TO_JAVAFX_SDK_LIB" <other parts of the command line> 

#Windows
java -p "PATH_TO_JAVAFX_SDK_LIB;COMPILED_CODE" <other parts of the command line> 
#Linux 
java -p "PATH_TO_JAVAFX_SDK_LIB:COMPILED_CODE" <other parts of the command line> 

在定义 JavaFX 组件的 CSS 属性时有一个小区别。所有的属性都必须以-fx-为前缀,也就是说,background-color变成了-fx-background-color。选择器,即#id.class-name在 JavaFX 世界中仍然保持不变。我们甚至可以为 JavaFX 组件提供多个类,从而将所有这些 CSS 属性应用到组件上。

我在这个示例中使用的 CSS 基于一个名为Bootstrap的流行 CSS 框架(getbootstrap.com/css/)。

如何做...

  1. 让我们创建一个GridPane,它将以行和列的网格形式容纳组件:
        GridPane gridPane = new GridPane();
        gridPane.setAlignment(Pos.CENTER);
        gridPane.setHgap(10);
        gridPane.setVgap(10);
        gridPane.setPadding(new Insets(25, 25, 25, 25));
  1. 首先,我们将创建一个按钮,并为其添加两个类,btnbtn-primary。在下一步中,我们将为这些选择器定义所需的 CSS 属性:
        Button primaryBtn = new Button("Primary");
        primaryBtn.getStyleClass().add("btn");
        primaryBtn.getStyleClass().add("btn-primary");
        gridPane.add(primaryBtn, 0, 1);
  1. 现在让我们为类btnbtn-primary提供所需的 CSS 属性。这些类的选择器形式为.<class-name>
        .btn{
          -fx-border-radius: 4px;
          -fx-border: 2px;
          -fx-font-size: 18px;
          -fx-font-weight: normal;
          -fx-text-align: center;
        }
        .btn-primary {
          -fx-text-fill: #fff;
          -fx-background-color: #337ab7;
          -fx-border-color: #2e6da4;
        }
  1. 让我们创建另一个具有不同 CSS 类的按钮:
        Button successBtn = new Button("Sucess");
        successBtn.getStyleClass().add("btn");
        successBtn.getStyleClass().add("btn-success");
        gridPane.add(successBtn, 1, 1);
  1. 现在我们为.btn-success选择器定义 CSS 属性如下:
        .btn-success {
          -fx-text-fill: #fff;
          -fx-background-color: #5cb85c;
          -fx-border-color: #4cae4c;
        }
  1. 让我们创建另一个具有不同 CSS 类的按钮:
        Button dangerBtn = new Button("Danger");
        dangerBtn.getStyleClass().add("btn");
        dangerBtn.getStyleClass().add("btn-danger");
        gridPane.add(dangerBtn, 2, 1);
  1. 我们将为选择器.btn-danger定义 CSS 属性。
        .btn-danger {
          -fx-text-fill: #fff;
          -fx-background-color: #d9534f;
          -fx-border-color: #d43f3a;
        }
  1. 现在,让我们添加一些具有不同选择器的标签,即badgebadge-info
        Label label = new Label("Default Label");
        label.getStyleClass().add("badge");
        gridPane.add(label, 0, 2);

        Label infoLabel = new Label("Info Label");
        infoLabel.getStyleClass().add("badge");
        infoLabel.getStyleClass().add("badge-info");
        gridPane.add(infoLabel, 1, 2);
  1. 前面选择器的 CSS 属性如下:
        .badge{
          -fx-label-padding: 6,7,6,7;
          -fx-font-size: 12px;
          -fx-font-weight: 700;
          -fx-text-fill: #fff;
          -fx-text-alignment: center;
          -fx-background-color: #777;
          -fx-border-radius: 4;
        }

        .badge-info{
          -fx-background-color: #3a87ad;
        }
        .badge-warning {
          -fx-background-color: #f89406;
        }
  1. 让我们添加一个带有big-input类的TextField
        TextField bigTextField = new TextField();
        bigTextField.getStyleClass().add("big-input");
        gridPane.add(bigTextField, 0, 3, 3, 1);
  1. 我们定义 CSS 属性,使得文本框的内容尺寸大且颜色为红色:
        .big-input{
          -fx-text-fill: red;
          -fx-font-size: 18px;
          -fx-font-style: italic;
          -fx-font-weight: bold;
        }
  1. 现在,让我们添加一些单选按钮:
        ToggleGroup group = new ToggleGroup();
        RadioButton bigRadioOne = new RadioButton("First");
        bigRadioOne.getStyleClass().add("big-radio");
        bigRadioOne.setToggleGroup(group);
        bigRadioOne.setSelected(true);
        gridPane.add(bigRadioOne, 0, 4);
        RadioButton bigRadioTwo = new RadioButton("Second");
        bigRadioTwo.setToggleGroup(group);
        bigRadioTwo.getStyleClass().add("big-radio");
        gridPane.add(bigRadioTwo, 1, 4);
  1. 我们定义 CSS 属性,使得单选按钮的标签尺寸大且颜色为绿色:
        .big-radio{
          -fx-text-fill: green;
          -fx-font-size: 18px;
          -fx-font-weight: bold;
          -fx-background-color: yellow;
          -fx-padding: 5;
        }
  1. 最后,我们将javafx.scene.layout.GridPane添加到场景图中,并在javafx.stage.Stage上渲染场景图。我们还需要将stylesheet.cssScene关联起来:
        Scene scene = new Scene(gridPane, 600, 500);
        scene.getStylesheets().add("com/packt/stylesheet.css");
        stage.setTitle("Age calculator");
        stage.setScene(scene);
        stage.show();
  1. 添加一个main()方法来启动 GUI:
        public static void main(String[] args) {
          Application.launch(args);
        }

完整的代码可以在这里找到:Chapter16/3_css_javafx

我们提供了两个运行脚本,run.batrun.sh,在Chapter16/3_css_javafx下。run.bat用于在 Windows 上运行应用程序,run.sh用于在 Linux 上运行应用程序。

使用run.batrun.sh运行应用程序,你会看到以下的 GUI:

它是如何工作的...

在这个示例中,我们利用类名及其对应的 CSS 选择器来关联具有不同样式属性的组件。JavaFX 支持 CSS 属性的子集,不同类型的 JavaFX 组件适用不同的属性。JavaFX CSS 参考指南(docs.oracle.com/javase/8/javafx/api/javafx/scene/doc-files/cssref.html)将帮助您识别支持的 CSS 属性。

所有的场景图节点都是从一个抽象类javax.scene.Node继承的。这个抽象类提供了一个 API,getStyleClass(),返回一个添加到节点或 JavaFX 组件的类名列表(这些都是普通的String)。由于这是一个简单的类名列表,我们甚至可以通过使用getStyleClass().add("new-class-name")来添加更多的类名。

使用类名的优势在于它允许我们通过共同的类名对类似的组件进行分组。这种技术在 Web 开发世界中被广泛使用。假设我在 HTML 页面上有一个按钮列表,我希望在单击每个按钮时执行类似的操作。为了实现这一点,我将为每个按钮分配相同的类,比如my-button,然后使用document.getElementsByClassName('my-button')来获取这些按钮的数组。现在我们可以循环遍历获取的按钮数组并添加所需的操作处理程序。

在为组件分配类之后,我们需要为给定的类名编写 CSS 属性。然后这些属性将应用于所有具有相同类名的组件。

让我们从我们的配方中挑选一个组件,看看我们是如何进行样式化的。考虑以下具有两个类btnbtn-primary的组件:

primaryBtn.getStyleClass().add("btn");
primaryBtn.getStyleClass().add("btn-primary");

我们使用了选择器.btn.btn-primary,并将所有的 CSS 属性分组在这些选择器下,如下所示:

.btn{
  -fx-border-radius: 4px;
  -fx-border: 2px;
  -fx-font-size: 18px;
  -fx-font-weight: normal;
  -fx-text-align: center;
}
.btn-primary {
  -fx-text-fill: #fff;
  -fx-background-color: #337ab7;
  -fx-border-color: #2e6da4;
}

请注意,在 CSS 中,我们有一个color属性,它在 JavaFX 中的等价物是-fx-text-fill。其余的 CSS 属性,即border-radiusborderfont-sizefont-weighttext-alignbackground-colorborder-color,都以-fx-为前缀。

重要的是如何将样式表与Scene组件关联起来。

scene.getStylesheets().add("com/packt/stylesheet.css");这行代码将样式表与场景组件关联起来。由于getStylesheets()返回一个字符串列表,我们可以向其中添加多个字符串,这意味着我们可以将多个样式表关联到一个场景中。

getStylesheets()的文档说明如下:

"URL 是形式为[scheme:][//authority][path]的分层 URI。如果 URL 没有[scheme:]组件,则 URL 被视为仅有[path]组件。[path]的任何前导'/'字符都将被忽略,[path]将被视为相对于应用程序类路径的路径。"

在我们的配方中,我们只使用了path组件,因此它在类路径中查找文件。这就是我们将样式表添加到与场景相同的包中的原因。这是使其在类路径上可用的更简单的方法。

创建条形图

当数据以表格形式表示时,很难理解,但当数据以图表形式图形化表示时,它对眼睛来说是舒适的,易于理解的。我们已经看到了很多用于 Web 应用程序的图表库。然而,在桌面应用程序方面缺乏相同的支持。Swing 没有本地支持创建图表,我们不得不依赖于第三方应用程序,如JFreeChartwww.jfree.org/jfreechart/)。不过,JavaFX 具有创建图表的本地支持,我们将向您展示如何使用 JavaFX 图表组件以图表形式表示数据。

JavaFX 支持以下图表类型:

  • 条形图

  • 折线图

  • 饼图

  • 散点图

  • 区域图

  • 气泡图

在接下来的几个配方中,我们将介绍每种图表类型的构建。将每种图表类型分开成自己的配方将有助于我们以更简单的方式解释配方,并有助于更好地理解。

这个配方将全部关于条形图。一个样本条形图看起来像这样:

条形图可以在x轴上的每个值上具有单个条或多个条(如前面的图表中)。多个条可以帮助我们比较x轴上每个值的多个值点。

准备工作

由于我们知道从 Oracle JDK 11 开始和 Open JDK 10 开始,JavaFX 库不会随 JDK 安装一起提供,因此我们必须从gluonhq.com/products/javafx/下载 JavaFX SDK,并使用-p选项将 SDK 的lib文件夹中的 JAR 包包含在模块路径上,如下所示:

javac -p "PATH_TO_JAVAFX_SDK_LIB" <other parts of the command line> 

#Windows
java -p "PATH_TO_JAVAFX_SDK_LIB;COMPILED_CODE" <other parts of the command line> 
#Linux 
java -p "PATH_TO_JAVAFX_SDK_LIB:COMPILED_CODE" <other parts of the command line>

我们将使用来自学生表现机器学习存储库(archive.ics.uci.edu/ml/datasets/Student+Performance)的数据子集。数据集包括学生在数学和葡萄牙语两门科目中的表现,以及他们的社会背景信息,如父母的职业和教育等其他信息。数据集中有很多属性,但我们将选择以下属性:

  • 学生的性别

  • 学生的年龄

  • 父亲的教育

  • 父亲的职业

  • 母亲的教育

  • 母亲的职业

  • 学生是否参加了额外课程

  • 第一学期成绩

  • 第二学期成绩

  • 最终成绩

正如我们之前提到的,数据中捕获了很多属性,但我们只需要一些重要的属性就可以绘制一些有用的图表。因此,我们已经从机器学习存储库中的数据集中提取了信息,并将其放入了一个单独的文件中,该文件可以在书籍的代码下载中的Chapter16/4_bar_charts/src/gui/com/packt/students中找到。以下是从学生文件中摘录的一部分内容:

"F";18;4;4;"at_home";"teacher";"no";"5";"6";6
"F";17;1;1;"at_home";"other";"no";"5";"5";6
"F";15;1;1;"at_home";"other";"yes";"7";"8";10
"F";15;4;2;"health";"services";"yes";"15";"14";15
"F";16;3;3;"other";"other";"yes";"6";"10";10
"M";16;4;3;"services";"other";"yes";"15";"15";15

条目由分号(;)分隔。已解释每个条目代表的内容。教育信息(字段 3 和 4)是一个数字值,其中每个数字代表教育水平,如下所示:

  • 0:无

  • 1:小学教育(四年级)

  • 2:五到九年级

  • 3:中等教育

  • 4:高等教育

我们已经创建了一个用于处理学生文件的模块。模块名称是student.processor,其代码可以在Chapter16/101_student_data_processor中找到。因此,如果您想更改那里的任何代码,可以通过运行build-jar.batbuild-jar.sh文件来重新构建 JAR。这将在mlib目录中创建一个模块化的 JAR,student.processor.jar。然后,您必须将此模块化 JAR 替换为本配方的mlib目录中的模块化 JAR,即Chapter16/4_bar_charts/mlib

我们建议您从Chapter16/101_student_data_processor中提供的源代码构建student.processor模块化 jar。我们提供了build-jar.batbuild-jar.sh脚本来帮助您构建 JAR。您只需运行与您的平台相关的脚本,然后将构建的 jar 复制到101_student_data_processor/mlib中,然后再复制到4_bar_charts/mlib中。

这样,我们可以在涉及图表的所有配方中重复使用此模块。

操作步骤...

  1. 首先,创建GridPane并配置它以放置我们将要创建的图表:
        GridPane gridPane = new GridPane();
        gridPane.setAlignment(Pos.CENTER);
        gridPane.setHgap(10);
        gridPane.setVgap(10);
        gridPane.setPadding(new Insets(25, 25, 25, 25));
  1. 使用student.processor模块中的StudentDataProcessor类来解析学生文件并将数据加载到StudentList中:
        StudentDataProcessor sdp = new StudentDataProcessor();
        List<Student> students = sdp.loadStudent();
  1. 原始数据,即Student对象的列表,对于绘制图表来说并不有用,因此我们需要通过根据他们母亲和父亲的教育水平对学生进行分组,并计算这些学生的成绩(所有三个学期)的平均值来处理学生的成绩。为此,我们将编写一个简单的方法,接受List<Student>,一个分组函数(即学生需要根据其值进行分组的值),以及一个映射函数(即用于计算平均值的值):
        private Map<ParentEducation, IntSummaryStatistics> summarize(
          List<Student> students,
          Function<Student, ParentEducation> classifier,
          ToIntFunction<Student> mapper
        ){
          Map<ParentEducation, IntSummaryStatistics> statistics =
            students.stream().collect(
              Collectors.groupingBy(
                classifier,
                Collectors.summarizingInt(mapper)
              )
          );
          return statistics;
        }

前面的方法使用了新的基于流的 API。这些 API 非常强大,它们使用Collectors.groupingBy()对学生进行分组,然后使用Collectors.summarizingInt()计算他们的成绩统计信息。

  1. 条形图的数据以XYChart.Series的实例提供。每个系列对于给定的x值会产生一个y值,这是给定x值的一个条形图。我们将有多个系列,一个用于每个学期,即第一学期成绩、第二学期成绩和期末成绩。让我们创建一个方法,该方法接受每个学期成绩的统计数据和seriesName,并返回一个series对象:
        private XYChart.Series<String,Number> getSeries(
            String seriesName,
            Map<ParentEducation, IntSummaryStatistics> statistics
        ){
         XYChart.Series<String,Number> series = new XYChart.Series<>();
          series.setName(seriesName);
          statistics.forEach((k, v) -> {
            series.getData().add(
              new XYChart.Data<String, Number>(
                k.toString(),v.getAverage()
              )
            );
          });
          return series;
        }
  1. 我们将创建两个条形图——一个用于母亲教育的平均成绩,另一个用于父亲教育的平均成绩。为此,我们将创建一个方法,该方法将获取List<Student>和一个分类器,即一个将返回用于对学生进行分组的值的函数。该方法将进行必要的计算并返回一个BarChart对象:
       private BarChart<String, Number> getAvgGradeByEducationBarChart(
          List<Student> students,
          Function<Student, ParentEducation> classifier
        ){
          final CategoryAxis xAxis = new CategoryAxis();
          final NumberAxis yAxis = new NumberAxis();
          final BarChart<String,Number> bc = 
                new BarChart<>(xAxis,yAxis);
          xAxis.setLabel("Education");
          yAxis.setLabel("Grade");
          bc.getData().add(getSeries(
            "G1",
            summarize(students, classifier, Student::getFirstTermGrade)
          ));
          bc.getData().add(getSeries(
            "G2",
           summarize(students, classifier, Student::getSecondTermGrade)
          ));
          bc.getData().add(getSeries(
            "Final",
            summarize(students, classifier, Student::getFinalGrade)
          ));
          return bc;
        }
  1. 为母亲的教育平均成绩创建BarChart,并将其添加到gridPane
        BarChart<String, Number> avgGradeByMotherEdu = 
            getAvgGradeByEducationBarChart(
              students, 
              Student::getMotherEducation
            );
        avgGradeByMotherEdu.setTitle(
            "Average grade by Mother's Education"
        );
        gridPane.add(avgGradeByMotherEdu, 1,1);
  1. 为父亲的教育平均成绩创建BarChart并将其添加到gridPane
        BarChart<String, Number> avgGradeByFatherEdu = 
            getAvgGradeByEducationBarChart(
              students, 
              Student::getFatherEducation
            );
        avgGradeByFatherEdu.setTitle(
            "Average grade by Father's Education");
        gridPane.add(avgGradeByFatherEdu, 2,1);
  1. 使用gridPane创建一个场景图,并将其设置为Stage
        Scene scene = new Scene(gridPane, 800, 600);
        stage.setTitle("Bar Charts");
        stage.setScene(scene);
        stage.show();

完整的代码可以在Chapter16/4_bar_charts中找到。

我们提供了两个运行脚本:run.batrun.sh,位于Chapter16/4_bar_charts下。run.bat脚本将用于在 Windows 上运行应用程序,run.sh将用于在 Linux 上运行应用程序。

使用run.batrun.sh运行应用程序,您将看到以下 GUI:

它是如何工作的...

让我们首先看看创建BarChart需要什么。BarChart是一个基于两个轴的图表,其中数据绘制在两个轴上,即x轴(水平轴)和y轴(垂直轴)。另外两个基于轴的图表是面积图、气泡图和折线图。

在 JavaFX 中,支持两种类型的轴:

  • javafx.scene.chart.CategoryAxis:这支持轴上的字符串值

  • javafx.scene.chart.NumberAxis:这支持轴上的数值

在我们的示例中,我们使用CategoryAxis作为x轴创建了BarChart,在这里我们绘制教育,使用NumberAxis作为y轴,在这里我们绘制等级,如下所示:

final CategoryAxis xAxis = new CategoryAxis();
final NumberAxis yAxis = new NumberAxis();
final BarChart<String,Number> bc = new BarChart<>(xAxis,yAxis);
xAxis.setLabel("Education");
yAxis.setLabel("Grade");

在接下来的几段中,我们将向您展示BarChart的绘制方式。

要在BarChart上绘制的数据应该是一对值,其中每对表示(x, y)值,即x轴上的一个点和y轴上的一个点。这一对值由javafx.scene.chart.XYChart.Data表示。DataXYChart内的一个嵌套类,它表示基于两个轴的图表的单个数据项。XYChart.Data对象可以很简单地创建,如下所示:

XYChart.Data item = new XYChart.Data("Cat1", "12");

这只是一个数据项。一个图表可以有多个数据项,也就是一系列数据项。为了表示一系列数据项,JavaFX 提供了一个名为javafx.scene.chart.XYChart.Series的类。这个XYChart.Series对象是XYChart.Data项的一个命名系列。让我们创建一个简单的系列,如下所示:

XYChart.Series<String,Number> series = new XYChart.Series<>();
series.setName("My series");
series.getData().add(
  new XYChart.Data<String, Number>("Cat1", 12)
);
series.getData().add(
  new XYChart.Data<String, Number>("Cat2", 3)
);
series.getData().add(
  new XYChart.Data<String, Number>("Cat3", 16)
);

BarChart可以有多个数据系列。如果我们为其提供多个系列,那么在x轴上的每个数据点上将有多个条形图。为了演示这是如何工作的,我们将坚持使用一个系列。但是我们示例中的BarChart类使用了多个系列。让我们将系列添加到BarChart,然后将其渲染到屏幕上:

bc.getData().add(series);
Scene scene = new Scene(bc, 800, 600);
stage.setTitle("Bar Charts");
stage.setScene(scene);
stage.show();

这导致了以下图表:

此示例的另一个有趣部分是根据母亲和父亲的教育对学生进行分组,然后计算他们的第一学期、第二学期和期末成绩的平均值。进行分组和平均计算的代码如下:

Map<ParentEducation, IntSummaryStatistics> statistics =
        students.stream().collect(
  Collectors.groupingBy(
    classifier,
    Collectors.summarizingInt(mapper)
  )
);

上述代码执行以下操作:

  • 它从List<Student>创建了一个流。

  • 它使用collect()方法将此流减少到所需的分组。

  • collect()的重载版本之一需要两个参数。第一个是返回学生需要分组的值的函数。第二个参数是一个额外的映射函数,将分组的学生对象映射为所需的格式。在我们的情况下,所需的格式是对任何一个成绩值的学生组获取IntSummaryStatistics

设置条形图的数据和创建所需的对象以填充BarChart实例是本示例的重要部分;了解它们将使您对示例有更清晰的认识。

创建饼图

饼图,顾名思义,是带有切片(连接或分离)的圆形图表,其中每个切片及其大小表示切片代表的项目的大小。饼图用于比较不同类别、产品等的大小。这是一个示例饼图的样子:

准备工作

由于我们知道从 Oracle JDK 11 开始和 Open JDK 10 开始,JavaFX 库不会随 JDK 安装一起提供,因此我们将不得不从gluonhq.com/products/javafx/下载 JavaFX SDK,并使用-p选项将 SDK 的lib文件夹中存在的 JAR 包含在模块路径中,如下所示:

javac -p "PATH_TO_JAVAFX_SDK_LIB" <other parts of the command line> 

#Windows
java -p "PATH_TO_JAVAFX_SDK_LIB;COMPILED_CODE" <other parts of the command line> 
#Linux 
java -p "PATH_TO_JAVAFX_SDK_LIB:COMPILED_CODE" <other parts of the command line>

我们将使用相同的学生数据(从机器学习存储库中获取并在我们这里处理)来创建饼图,这是我们在创建条形图示例中讨论过的。为此,我们创建了一个名为student.processor的模块,它将读取学生数据并为我们提供Student对象的列表。模块的源代码可以在Chapter16/101_student_data_processor找到。我们已经在本示例代码的Chapter16/5_pie_charts/mlib中提供了student.processor模块的模块化 jar。

我们建议您从Chapter16/101_student_data_processor中提供的源代码构建student.processor模块化 jar。我们提供了build-jar.batbuild-jar.sh脚本来帮助您构建 jar。您只需运行与您的平台相关的脚本,然后将构建的 jar 复制到101_student_data_processor/mlib中的4_bar_charts/mlib

如何操作...

  1. 让我们首先创建和配置GridPane来容纳我们的饼图:
        GridPane gridPane = new GridPane();
        gridPane.setAlignment(Pos.CENTER);
        gridPane.setHgap(10);
        gridPane.setVgap(10);
        gridPane.setPadding(new Insets(25, 25, 25, 25));
  1. 创建StudentDataProcessor的实例(来自student.processor模块)并使用它加载StudentList
        StudentDataProcessor sdp = new StudentDataProcessor();
        List<Student> students = sdp.loadStudent();
  1. 现在我们需要按照他们母亲和父亲的职业来统计学生的数量。我们将编写一个方法,该方法将接受学生列表和分类器,即返回学生需要分组的值的函数。该方法返回PieChart的实例:
        private PieChart getStudentCountByOccupation(
            List<Student> students,
            Function<Student, String> classifier
        ){
          Map<String, Long> occupationBreakUp = 
                  students.stream().collect(
            Collectors.groupingBy(
              classifier,
              Collectors.counting()
            )
          );
          List<PieChart.Data> pieChartData = new ArrayList<>();
          occupationBreakUp.forEach((k, v) -> {
            pieChartData.add(new PieChart.Data(k.toString(), v));
          });
          PieChart chart = new PieChart(
            FXCollections.observableList(pieChartData)
          );
          return chart;
        }
  1. 我们将调用前面的方法两次,一次以母亲的职业作为分类器,另一次以父亲的职业作为分类器。然后将返回的PieChart实例添加到gridPane中。这应该在start()方法中完成:
        PieChart motherOccupationBreakUp = 
        getStudentCountByOccupation(
          students, Student::getMotherJob
        );
        motherOccupationBreakUp.setTitle("Mother's Occupation");
        gridPane.add(motherOccupationBreakUp, 1,1);

        PieChart fatherOccupationBreakUp = 
        getStudentCountByOccupation(
          students, Student::getFatherJob
        );
        fatherOccupationBreakUp.setTitle("Father's Occupation");
        gridPane.add(fatherOccupationBreakUp, 2,1);
  1. 下一步是使用gridPane创建场景图并将其添加到Stage
        Scene scene = new Scene(gridPane, 800, 600);
        stage.setTitle("Pie Charts");
        stage.setScene(scene);
        stage.show();
  1. 可以通过调用Application.launch方法从主方法启动 UI:
        public static void main(String[] args) {
          Application.launch(args);
        }

完整的代码可以在Chapter16/5_pie_charts找到。

我们提供了两个运行脚本,run.batrun.sh,位于Chapter16/5_pie_charts下。run.bat脚本用于在 Windows 上运行应用程序,run.sh用于在 Linux 上运行应用程序。

使用run.batrun.sh运行应用程序,您将看到以下 GUI:

工作原理...

在这个示例中,完成所有工作的最重要的方法是getStudentCountByOccupation()。它执行以下操作:

  1. 它按职业对学生人数进行分组。这可以通过使用新的流式 API 的强大功能(作为 Java 8 的一部分添加)来完成一行代码:
        Map<String, Long> occupationBreakUp = 
                    students.stream().collect(
          Collectors.groupingBy(
            classifier,
            Collectors.counting()
          )
        );
  1. 构建PieChart所需的数据。PieChart实例的数据是ObservableListPieChart.Data。我们首先利用前面步骤中获得的Map来创建ArrayListPieChart.Data。然后,我们使用FXCollections.observableList()API 从List<PieChart.Data>中获取ObservableList<PieChart.Data>
        List<PieChart.Data> pieChartData = new ArrayList<>();
        occupationBreakUp.forEach((k, v) -> {
          pieChartData.add(new PieChart.Data(k.toString(), v));
        });
        PieChart chart = new PieChart(
          FXCollections.observableList(pieChartData)
        );

该示例中的另一个重要事项是我们使用的分类器:Student::getMotherJobStudent::getFatherJob。这是两个方法引用,它们在Student列表中的不同实例上调用getMotherJobgetFatherJob方法。

一旦我们获得了PieChart实例,我们将把它们添加到GridPane中,然后使用GridPane构建场景图。场景图必须与Stage相关联,才能在屏幕上呈现。

主方法通过调用Application.launch(args);方法启动 UI。

JavaFX 提供了用于创建不同类型图表的 API,例如以下类型:

  • 区域图

  • 气泡图

  • 折线图

  • 散点图

所有这些图表都是基于xy轴的图表,可以像条形图一样构建。我们提供了一些示例实现来创建这些类型的图表,它们可以在这些位置找到:Chapter16/5_2_area_chartsChapter16/5_3_line_chartsChapter16/5_4_bubble_chartsChapter16/5_5_scatter_charts

在应用程序中嵌入 HTML

JavaFX 提供了通过javafx.scene.web包中定义的类来管理网页的支持。它支持加载网页,可以通过接受网页 URL 或接受网页内容来实现。它还管理网页的文档模型,应用相关的 CSS,并运行相关的 JavaScript 代码。它还扩展了 JavaScript 和 Java 代码之间的双向通信支持。

在这个示例中,我们将构建一个非常原始和简单的支持以下功能的网络浏览器:

  • 浏览访问过的页面的历史记录

  • 重新加载当前页面

  • 用于接受 URL 的地址栏

  • 用于加载输入的 URL 的按钮

  • 显示网页

  • 显示网页加载状态

准备工作

由于我们知道 JavaFX 库从 Oracle JDK 11 开始和 Open JDK 10 开始不再随 JDK 安装而来,我们将不得不从这里gluonhq.com/products/javafx/下载 JavaFX SDK,并使用-p选项将 SDK 的lib文件夹中的 JAR 包包含在模块路径中,如下所示:

javac -p "PATH_TO_JAVAFX_SDK_LIB" <other parts of the command line> 

#Windows
java -p "PATH_TO_JAVAFX_SDK_LIB;COMPILED_CODE" <other parts of the command line> 
#Linux 
java -p "PATH_TO_JAVAFX_SDK_LIB:COMPILED_CODE" <other parts of the command line>

我们需要一个互联网连接来测试页面的加载。因此,请确保您已连接到互联网。除此之外,没有特定的要求来使用这个示例。

如何做...

  1. 让我们首先创建一个空方法的类,它将代表启动应用程序以及 JavaFX UI 的主要应用程序:
        public class BrowserDemo extends Application{
          public static void main(String[] args) {
            Application.launch(args);
          }
          @Override
          public void start(Stage stage) {
            //this will have all the JavaFX related code
          }
        }

在接下来的步骤中,我们将在start(Stage stage)方法中编写所有的代码。

  1. 让我们创建一个javafx.scene.web.WebView组件,它将呈现我们的网页。这个组件有必需的javafx.scene.web.WebEngine实例,用于管理网页的加载:
        WebView webView = new WebView();
  1. 获取webView使用的javafx.scene.web.WebEngine实例。我们将使用这个javafx.scene.web.WebEngine实例来浏览历史记录并加载其他网页。然后,我们将默认加载 URL,www.google.com
        WebEngine webEngine = webView.getEngine();
        webEngine.load("http://www.google.com/");
  1. 现在让我们创建一个javafx.scene.control.TextField组件,它将充当我们浏览器的地址栏:
        TextField webAddress = new
        TextField("http://www.google.com/");
  1. 我们希望根据完全加载的网页的标题和 URL 来更改浏览器和地址栏中的网页标题。这可以通过监听从javafx.scene.web.WebEngine实例获取的javafx.concurrent.WorkerstateProperty的更改来实现:
        webEngine.getLoadWorker().stateProperty().addListener(
          new ChangeListener<State>() {
            public void changed(ObservableValue ov, 
                                State oldState, State newState) {
              if (newState == State.SUCCEEDED) {
                stage.setTitle(webEngine.getTitle());
                webAddress.setText(webEngine.getLocation());
              }
            }
          }
        );
  1. 让我们创建一个javafx.scene.control.Button实例,点击后将加载地址栏中输入的 URL 标识的网页:
        Button goButton = new Button("Go");
        goButton.setOnAction((event) -> {
          String url = webAddress.getText();
          if ( url != null && url.length() > 0){
            webEngine.load(url);
          }
        });
  1. 让我们创建一个javafx.scene.control.Button实例,点击后将转到历史记录中的上一个网页。为此,我们将在操作处理程序内执行 JavaScript 代码history.back()
        Button prevButton = new Button("Prev");
        prevButton.setOnAction(e -> {
          webEngine.executeScript("history.back()");
        });
  1. 让我们创建一个javafx.scene.control.Button实例,点击后将转到javafx.scene.web.WebEngine实例维护的历史记录中的下一个条目。为此,我们将使用javafx.scene.web.WebHistory API:
        Button nextButton = new Button("Next");
        nextButton.setOnAction(e -> {
          WebHistory wh = webEngine.getHistory();
          Integer historySize = wh.getEntries().size();
          Integer currentIndex = wh.getCurrentIndex();
          if ( currentIndex < (historySize - 1)){
            wh.go(1);
          }
        });
  1. 接下来是重新加载当前页面的按钮。再次,我们将使用javafx.scene.web.WebEngine来重新加载当前页面:
        Button reloadButton = new Button("Refresh");
        reloadButton.setOnAction(e -> {
          webEngine.reload();
        });
  1. 现在我们需要将迄今为止创建的所有组件,即prevButtonnextButtonreloadButtonwebAddressgoButton分组,以使它们在水平方向上对齐。为此,我们将使用javafx.scene.layout.HBox,并设置相关间距和填充,使组件看起来间距均匀:
        HBox addressBar = new HBox(10);
        addressBar.setPadding(new Insets(10, 5, 10, 5));
        addressBar.setHgrow(webAddress, Priority.ALWAYS);
        addressBar.getChildren().addAll(
          prevButton, nextButton, reloadButton, webAddress, goButton
        );
  1. 我们想知道网页是否正在加载以及是否已经完成。让我们创建一个javafx.scene.layout.Label字段来更新网页加载状态。然后,我们监听javafx.scene.web.WebEngine实例的workDoneProperty的更新,这可以从javafx.concurrent.Worker实例中获取:
Label websiteLoadingStatus = new Label();
webEngine
  .getLoadWorker()
  .workDoneProperty()
  .addListener(
    new ChangeListener<Number>(){

      public void changed(
        ObservableValue ov, 
        Number oldState, 
        Number newState
      ) {
        if (newState.doubleValue() != 100.0){
          websiteLoadingStatus.setText(
            "Loading " + webAddress.getText());
        }else{
          websiteLoadingStatus.setText("Done");
        }
      }

    }
  );
  1. 让我们垂直对齐整个地址栏(及其导航按钮)、webViewwebsiteLoadingStatus
        VBox root = new VBox();
        root.getChildren().addAll(
          addressBar, webView, websiteLoadingStatus
        );
  1. 使用在前一步中创建的VBox实例作为根创建一个新的Scene对象:
        Scene scene = new Scene(root);
  1. 我们希望javafx.stage.Stage实例占据整个屏幕大小;为此,我们将使用Screen.getPrimary().getVisualBounds()。然后,像往常一样,我们将在舞台上呈现场景图:
        Rectangle2D primaryScreenBounds = 
                    Screen.getPrimary().getVisualBounds();
        stage.setTitle("Web Browser");
        stage.setScene(scene);
        stage.setX(primaryScreenBounds.getMinX());
        stage.setY(primaryScreenBounds.getMinY());
        stage.setWidth(primaryScreenBounds.getWidth());
        stage.setHeight(primaryScreenBounds.getHeight());
        stage.show();

完整的代码可以在Chapter16/6_embed_html位置找到。

Chapter16/6_embed_html下提供了两个运行脚本,run.batrun.shrun.bat脚本用于在 Windows 上运行应用程序,run.sh用于在 Linux 上运行应用程序。

使用run.batrun.sh运行应用程序,您将看到以下 GUI:

它是如何工作的...

与 Web 相关的 API 可在javafx.web模块中使用,因此我们需要在module-info中声明它:

module gui{
  requires javafx.controls;
  requires javafx.web;
  opens com.packt;
}

下面是在处理 JavaFX 中的网页时javafx.scene. web 包中的重要类:

  • WebView:此 UI 组件使用WebEngine来管理加载、渲染和与网页的交互。

  • WebEngine:这是处理加载和管理网页的主要组件。

  • WebHistory:记录当前WebEngine实例中访问的网页。

  • WebEvent:这些是传递给由 JavaScript 事件调用的WebEngine事件处理程序的实例。

在我们的示例中,我们使用了前三个类。

我们不直接创建WebEngine的实例;相反,我们利用WebView来获取由其管理的WebEngine实例的引用。WebEngine实例通过将加载页面的任务提交给javafx.concurrent.Worker实例来异步加载网页。然后,我们在这些 worker 实例属性上注册更改侦听器,以跟踪加载网页的进度。在这个示例中,我们使用了两个这样的属性,即statePropertyworkDoneProperty。前者跟踪 worker 状态的更改,后者跟踪工作完成的百分比。

Worker 可以经历以下状态(如列在javafx.concurrent.Worker.State枚举中):

  • 取消

  • 失败

  • 准备就绪

  • 运行中

  • 已安排

  • 成功

在我们的教程中,我们只检查SUCCEEDED,但您也可以增强它以检查FAILED。这将帮助我们报告无效的 URL,甚至从事件对象中获取消息并显示给用户。

我们添加监听器以跟踪属性变化的方式是使用*Property()上的addListener()方法,其中*可以是stateworkDone或工作器的任何其他属性,这些属性已公开为属性:

webEngine
  .getLoadWorker()
  .stateProperty()
  .addListener( 
    new ChangeListener<State>() {
      public void changed(ObservableValue ov, 
       State oldState, State newState) {
         //event handler code here
       }
    }
);

webEngine
  .getLoadWorker()
  .workDoneProperty()
  .addListener(
    new ChangeListener<Number>(){
      public void changed(ObservableValue ov, 
        Number oldState, Number newState) {
          //event handler code here
      }
   }
);

然后,javafx.scene.web.WebEngine组件还支持以下功能:

  • 重新加载当前页面

  • 获取加载的页面的历史记录

  • 执行 JavaScript 代码

  • 监听 JavaScript 属性,例如显示警报框或确认框

  • 使用getDocument()方法与网页的文档模型进行交互

在这个教程中,我们还介绍了从WebEngine获取的WebHistory的使用。WebHistory存储了给定WebEngine实例加载的网页,这意味着一个WebEngine实例将有一个WebHistory实例。WebHistory支持以下功能:

  • 使用getEntries()方法获取条目列表。这也将为我们获取历史记录中的条目数。这在导航历史记录时是必需的;否则,我们将遇到索引越界异常。

  • 获取currentIndex,即其在getEntries()列表中的索引。

  • 导航到WebHistory的条目列表中的特定条目。这可以通过使用go()方法实现,该方法接受一个偏移量。该偏移量指示要加载的网页相对于当前位置。例如,+1表示下一个条目,-1表示上一个条目。检查边界条件很重要;否则,您将会在0之前,即-1之前,或者超出条目列表大小。

还有更多...

在这个教程中,我们向您展示了使用 JavaFX 提供的支持来创建一个基本的 Web 浏览器的方法。您可以增强它以支持以下功能:

  • 更好的错误处理和用户消息,即通过跟踪工作器的状态变化来显示网址是否有效

  • 多个标签页

  • 书签

  • 将浏览器的状态存储在本地,以便下次运行时加载所有书签和历史记录。

在应用程序中嵌入媒体

JavaFX 提供了一个组件javafx.scene.media.MediaView,用于查看视频和听音频。该组件由一个媒体引擎javafx.scene.media.MediaPlayer支持,该引擎加载和管理媒体的播放。

在这个教程中,我们将学习如何播放一个示例视频,并通过使用媒体引擎上的方法来控制其播放。

准备就绪

由于我们知道 JavaFX 库从 Oracle JDK 11 开始和 Open JDK 10 开始不再随 JDK 安装,我们将不得不从这里gluonhq.com/products/javafx/下载 JavaFX SDK,并使用-p选项将 SDK 的lib文件夹中的 JAR 包包含在模块路径上,如下所示:

javac -p "PATH_TO_JAVAFX_SDK_LIB" <other parts of the command line> 

#Windows
java -p "PATH_TO_JAVAFX_SDK_LIB;COMPILED_CODE" <other parts of the command line> 
#Linux 
java -p "PATH_TO_JAVAFX_SDK_LIB:COMPILED_CODE" <other parts of the command line>

我们将使用位于Chapter16/7_embed_audio_video/sample_video1.mp4的示例视频。

如何做...

  1. 首先,让我们创建一个带有空方法的类,该类将代表启动应用程序以及 JavaFX UI 的主要应用程序:
        public class EmbedAudioVideoDemo extends Application{
          public static void main(String[] args) {
            Application.launch(args);
          }
          @Override
          public void start(Stage stage) {
            //this will have all the JavaFX related code
          }
        }
  1. 为位于Chapter16/7_embed_audio_video/sample_video1.mp4的视频创建一个javafx.scene.media.Media对象:
        File file = new File("sample_video1.mp4");
        Media media = new Media(file.toURI().toString());
  1. 使用上一步创建的javafx.scene.media.Media对象创建一个新的媒体引擎javafx.scene.media.MediaPlayer
        MediaPlayer mediaPlayer = new MediaPlayer(media);
  1. 通过在javafx.scene.media.MediaPlayer对象的statusProperty上注册更改监听器来跟踪媒体播放器的状态:
        mediaPlayer.statusProperty().addListener(
                    new ChangeListener<Status>() {
          public void changed(ObservableValue ov, 
                              Status oldStatus, Status newStatus) {
            System.out.println(oldStatus +"->" + newStatus);
          }
        });
  1. 现在让我们使用上一步创建的媒体引擎来创建一个媒体查看器:
        MediaView mediaView = new MediaView(mediaPlayer);
  1. 我们将限制媒体查看器的宽度和高度:
        mediaView.setFitWidth(350);
        mediaView.setFitHeight(350); 
  1. 接下来,我们创建三个按钮来暂停视频播放、恢复播放和停止播放。我们将使用javafx.scene.media.MediaPlayer类中的相关方法:
        Button pauseB = new Button("Pause");
        pauseB.setOnAction(e -> {
          mediaPlayer.pause();
        });

        Button playB = new Button("Play");
        playB.setOnAction(e -> {
          mediaPlayer.play();
        });

        Button stopB = new Button("Stop");
        stopB.setOnAction(e -> {
          mediaPlayer.stop();
        });
  1. 使用javafx.scene.layout.HBox水平对齐所有这些按钮:
        HBox controlsBox = new HBox(10);
        controlsBox.getChildren().addAll(pauseB, playB, stopB);
  1. 使用javafx.scene.layout.VBox垂直对齐媒体查看器和按钮栏:
        VBox vbox = new VBox();
        vbox.getChildren().addAll(mediaView, controlsBox);
  1. 使用VBox对象创建一个新的场景图,并将其设置为舞台对象:
        Scene scene = new Scene(vbox);
        stage.setScene(scene);
        // Name and display the Stage.
        stage.setTitle("Media Demo");
  1. 在显示器上呈现舞台:
        stage.setWidth(400);
        stage.setHeight(400);
        stage.show();

完整的代码可以在Chapter16/7_embed_audio_video中找到。

我们提供了两个运行脚本,run.batrun.sh,位于Chapter16/7_embed_audio_video下。run.bat脚本将用于在 Windows 上运行应用程序,run.sh将用于在 Linux 上运行应用程序。

使用run.batrun.sh运行应用程序,您将看到以下 GUI:

它是如何工作的...

用于媒体播放的javafx.scene.media包中的重要类如下:

  • Media:表示媒体的来源,即视频或音频。这以 HTTP/HTTPS/FILE 和 JAR URL 的形式接受来源。

  • MediaPlayer:管理媒体的播放。

  • MediaView:这是允许查看媒体的 UI 组件。

还有一些其他类,但我们在本示例中没有涵盖它们。与媒体相关的类位于javafx.media模块中。因此,请不要忘记在此处声明对其的依赖关系:

module gui{
  requires javafx.controls;
  requires javafx.media;
  opens com.packt;
}

在本示例中,我们有一个位于Chapter16/7_embed_audio_video/sample_video1.mp4的示例视频,并且我们使用java.io.File API 来构建File URL 以定位视频:

File file = new File("sample_video1.mp4");
Media media = new Media(file.toURI().toString());

使用javafx.scene.media.MediaPlayer类公开的 API 管理媒体播放。在本示例中,我们使用了其中的一些方法,即play()pause()stop()javafx.scene.media.MediaPlayer类通过使用javafx.scene.media.Media对象进行初始化:

MediaPlayer mediaPlayer = new MediaPlayer(media);

在 UI 上呈现媒体由javafx.scene.media.MediaView类管理,它由javafx.scene.media.MediaPlayer对象支持:

MediaView mediaView = new MediaView(mediaPlayer);

我们可以使用setFitWidth()setFitHeight()方法设置查看器的高度和宽度。

还有更多...

我们在 JavaFX 中提供了媒体支持的基本演示。还有很多可以探索的地方。您可以添加音量控制选项,向前或向后搜索选项,播放音频和音频均衡器。

向控件添加效果

以受控方式添加效果可以使用户界面看起来更好。有多种效果,如模糊、阴影、反射、绽放等。JavaFX 提供了一组类,位于javafx.scene.effects包下,可用于添加效果以增强应用程序的外观。此包在javafx.graphics模块中可用。

在本示例中,我们将查看一些效果——模糊、阴影和反射。

准备就绪

由于我们知道 JavaFX 库从 Oracle JDK 11 开始和 Open JDK 10 开始不再随 JDK 安装一起提供,因此我们必须从此处gluonhq.com/products/javafx/下载 JavaFX SDK,并使用-p选项将 SDK 的lib文件夹中的 JAR 包包含在模块路径上,如下所示:

javac -p "PATH_TO_JAVAFX_SDK_LIB" <other parts of the command line> 

#Windows
java -p "PATH_TO_JAVAFX_SDK_LIB;COMPILED_CODE" <other parts of the command line> 
#Linux 
java -p "PATH_TO_JAVAFX_SDK_LIB:COMPILED_CODE" <other parts of the command line>

如何做...

  1. 让我们首先创建一个带有空方法的类,该类将代表启动应用程序以及 JavaFX UI 的主要应用程序:
        public class EffectsDemo extends Application{
          public static void main(String[] args) {
            Application.launch(args);
          }
          @Override
          public void start(Stage stage) {
            //code added here in next steps
          }
        }
  1. 随后的代码将在start(Stage stage)方法中编写。创建并配置javafx.scene.layout.GridPane
        GridPane gridPane = new GridPane();
        gridPane.setAlignment(Pos.CENTER);
        gridPane.setHgap(10);
        gridPane.setVgap(10);
        gridPane.setPadding(new Insets(25, 25, 25, 25));
  1. 创建矩形,用于应用模糊效果:
        Rectangle r1 = new Rectangle(100,25, Color.BLUE);
        Rectangle r2 = new Rectangle(100,25, Color.RED);
        Rectangle r3 = new Rectangle(100,25, Color.ORANGE);
  1. javafx.scene.effect.BoxBlur添加到Rectangle r1,将javafx.scene.effect.MotionBlur添加到Rectangle r2,将javafx.scene.effect.GaussianBlur添加到Rectangle r3
        r1.setEffect(new BoxBlur(10,10,3));
        r2.setEffect(new MotionBlur(90, 15.0));
        r3.setEffect(new GaussianBlur(15.0));
  1. 将矩形添加到gridPane
        gridPane.add(r1,1,1);
        gridPane.add(r2,2,1);
        gridPane.add(r3,3,1);
  1. 创建三个圆,用于应用阴影:
        Circle c1 = new Circle(20, Color.BLUE);
        Circle c2 = new Circle(20, Color.RED);
        Circle c3 = new Circle(20, Color.GREEN);
  1. javafx.scene.effect.DropShadow添加到c1,将javafx.scene.effect.InnerShadow添加到c2
        c1.setEffect(new DropShadow(0, 4.0, 0, Color.YELLOW));
        c2.setEffect(new InnerShadow(0, 4.0, 4.0, Color.ORANGE));
  1. 将这些圆添加到gridPane
        gridPane.add(c1,1,2);
        gridPane.add(c2,2,2);
        gridPane.add(c3,3,2);
  1. 在我们将应用反射效果的简单文本Reflection Sample上:
        Text t = new Text("Reflection Sample");
        t.setFont(Font.font("Arial", FontWeight.BOLD, 20));
        t.setFill(Color.BLUE);
  1. 创建一个javafx.scene.effect.Reflection效果并将其添加到文本中:
        Reflection reflection = new Reflection();
        reflection.setFraction(0.8);
        t.setEffect(reflection);
  1. 将文本组件添加到gridPane
        gridPane.add(t, 1, 3, 3, 1);
  1. 使用gridPane作为根节点创建一个场景图形:
        Scene scene = new Scene(gridPane, 500, 300);
  1. 将场景图形设置为舞台并在显示器上呈现它:
        stage.setScene(scene);
        stage.setTitle("Effects Demo");
        stage.show();

完整的代码可以在Chapter16/8_effects_demo中找到。

我们提供了两个运行脚本,Chapter16/8_effects_demo下的run.batrun.shrun.bat脚本将用于在 Windows 上运行应用程序,run.sh将用于在 Linux 上运行应用程序。

使用run.batrun.sh运行应用程序,您将看到以下 GUI:

它是如何工作的...

在这个示例中,我们使用了以下效果:

  • javafx.scene.effect.BoxBlur

  • javafx.scene.effect.MotionBlur

  • javafx.scene.effect.GaussianBlur

  • javafx.scene.effect.DropShadow

  • javafx.scene.effect.InnerShadow

  • javafx.scene.effect.Reflection

通过指定模糊效果的宽度和高度以及需要应用效果的次数来创建BoxBlur效果:

BoxBlur boxBlur = new BoxBlur(10,10,3);

通过提供模糊的角度和半径来创建MotionBlur效果。这会产生一种在运动中捕获到的效果:

MotionBlur motionBlur = new MotionBlur(90, 15.0);

通过提供效果的半径来创建GaussianBlur效果,并且该效果使用高斯公式来应用效果:

GaussianBlur gb = new GaussianBlur(15.0);

DropShadow在物体后面添加阴影,而InnerShadow在物体内部添加阴影。每个阴影都有阴影的半径,阴影开始的xy位置,以及阴影的颜色:

DropShadow dropShadow = new DropShadow(0, 4.0, 0, Color.YELLOW);
InnerShadow innerShadow = new InnerShadow(0, 4.0, 4.0, Color.ORANGE);

Reflection是一个非常简单的效果,它添加了物体的反射。我们可以设置原始物体的反射比例:

Reflection reflection = new Reflection();
reflection.setFraction(0.8);

还有更多...

还有很多其他效果:

  • 混合效果,将两个不同的输入与预定义的混合方法混合

  • 波纹效果,使更亮的部分看起来更亮。

  • 发光效果,使物体发光

  • 光照效果模拟了物体上的光源,从而使其呈现出 3D 外观。

我们建议您尝试以与我们尝试的方式相同的方式尝试这些效果。

使用 Robot API

Robot API用于模拟屏幕上的键盘和鼠标操作,这意味着您会指示代码在文本字段中输入一些文本,选择一个选项,然后单击一个按钮。来自 Web UI 测试背景的人可以将其与 Selenium 测试库联系起来。抽象窗口工具包AWT)是 JDK 中的一个较旧的窗口工具包,提供了 Robot API,但在 JavaFX 上使用相同的 API 并不直接,需要一些技巧。JavaFX 窗口工具包称为Glass有自己的 Robot API(openjfx.io/javadoc/11/javafx.graphics/javafx/scene/robot/Robot.html),但这些 API 不是公开的。因此,在 OpenJFX 11 发布的一部分中,为其引入了新的公共 API。

在这个示例中,我们将使用 Robot API 来模拟一些 JavaFX UI 上的操作。

准备就绪

由于我们知道 JavaFX 库从 Oracle JDK 11 开始和 Open JDK 10 开始不再随 JDK 安装一起提供,因此我们必须从这里(gluonhq.com/products/javafx/)下载 JavaFX SDK,并使用-p选项将 SDK 的lib文件夹中的 JAR 包含在模块路径上,如下所示:

javac -p "PATH_TO_JAVAFX_SDK_LIB" <other parts of the command line> 

#Windows
java -p "PATH_TO_JAVAFX_SDK_LIB;COMPILED_CODE" <other parts of the command line> 
#Linux 
java -p "PATH_TO_JAVAFX_SDK_LIB:COMPILED_CODE" <other parts of the command line> 

在这个示例中,我们将创建一个简单的应用程序,接受用户的姓名,并在点击按钮时向用户打印一条消息。整个操作将使用 Robot API 模拟,并且在退出应用程序之前,我们将使用 Robot API 捕获屏幕。

如何做...

  1. 创建一个简单的类RobotApplication,它扩展了javafx.application.Application并设置了测试 Robot API 所需的 UI,还创建了一个javafx.scene.robot.Robot的实例。这个类将被定义为RobotAPIDemo主类的静态内部类:
public static class RobotApplication extends Application{

  @Override
  public void start(Stage stage) throws Exception{
    robot = new Robot();
    GridPane gridPane = new GridPane();
    gridPane.setAlignment(Pos.CENTER);
    gridPane.setHgap(10);
    gridPane.setVgap(10);
    gridPane.setPadding(new Insets(25, 25, 25, 25));

    Text appTitle = new Text("Robot Demo");
    appTitle.setFont(Font.font("Arial", 
        FontWeight.NORMAL, 15));
    gridPane.add(appTitle, 0, 0, 2, 1);

    Label nameLbl = new Label("Name");
    nameField = new TextField();
    gridPane.add(nameLbl, 0, 1);
    gridPane.add(nameField, 1, 1);

    greeting = new Button("Greet");
    gridPane.add(greeting, 1, 2);

    Text resultTxt = new Text();
    resultTxt.setFont(Font.font("Arial", 
        FontWeight.NORMAL, 15));
    gridPane.add(resultTxt, 0, 5, 2, 1);

    greeting.setOnAction((event) -> {

      String name = nameField.getText();
      StringBuilder resultBuilder = new StringBuilder();
      if ( name != null && name.length() > 0 ){
        resultBuilder.append("Hello, ")
            .append(name).append("\n");
      }else{
        resultBuilder.append("Please enter the name");
      }
      resultTxt.setText(resultBuilder.toString());
      btnActionLatch.countDown();
    });

    Scene scene = new Scene(gridPane, 300, 250);

    stage.setTitle("Age calculator");
    stage.setScene(scene);
    stage.setAlwaysOnTop(true);
    stage.addEventHandler(WindowEvent.WINDOW_SHOWN, e -> 
      Platform.runLater(appStartLatch::countDown));
    stage.show();
    appStage = stage;
  }
}
  1. 由于 JavaFX UI 将在不同的 JavaFX 应用程序线程中启动,并且在执行与 UI 交互的命令之前,UI 渲染完全需要一些延迟,我们将使用java.util.concurrent.CountDownLatch来指示不同的事件。为了使用CountDownLatch,我们在RobotAPIDemo类中创建了一个简单的静态辅助方法,定义如下:
public static void waitForOperation(
    CountDownLatch latchToWaitFor, 
    int seconds, String errorMsg) {
  try {
    if (!latchToWaitFor.await(seconds, 
         TimeUnit.SECONDS)) {
      System.out.println(errorMsg);
    }
  } catch (Exception ex) {
    ex.printStackTrace();
  }
}
  1. typeName()方法是一个辅助方法,用于在文本字段中输入人的姓名:
public static void typeName(){
  Platform.runLater(() -> {
    Bounds textBoxBounds = nameField.localToScreen(
      nameField.getBoundsInLocal());
    robot.mouseMove(textBoxBounds.getMinX(), 
      textBoxBounds.getMinY());
    robot.mouseClick(MouseButton.PRIMARY);
    robot.keyType(KeyCode.CAPS);
    robot.keyType(KeyCode.S);
    robot.keyType(KeyCode.CAPS);
    robot.keyType(KeyCode.A);
    robot.keyType(KeyCode.N);
    robot.keyType(KeyCode.A);
    robot.keyType(KeyCode.U);
    robot.keyType(KeyCode.L);
    robot.keyType(KeyCode.L);
    robot.keyType(KeyCode.A);
  });
}
  1. clickButton()方法是一个辅助方法;它点击正确的按钮来触发问候消息的显示:
public static void clickButton(){
  Platform.runLater(() -> {
    //click the button
    Bounds greetBtnBounds = greeting
      .localToScreen(greeting.getBoundsInLocal());

    robot.mouseMove(greetBtnBounds.getCenterX(), 
      greetBtnBounds.getCenterY());
    robot.mouseClick(MouseButton.PRIMARY);
  });
}
  1. captureScreen()方法是一个辅助方法,用于对应用程序进行截屏并将其保存到文件系统:
public static void captureScreen(){
  Platform.runLater(() -> {
    try{

      WritableImage screenCapture = 
        new WritableImage(
          Double.valueOf(appStage.getWidth()).intValue(), 
          Double.valueOf(appStage.getHeight()).intValue()
        );

      robot.getScreenCapture(screenCapture, 
        appStage.getX(), appStage.getY(), 
        appStage.getWidth(), appStage.getHeight());

      BufferedImage screenCaptureBI = 
        SwingFXUtils.fromFXImage(screenCapture, null);
      String timePart = LocalDateTime.now()
        .format(DateTimeFormatter.ofPattern("yyyy-dd-M-m-H-ss"));
      ImageIO.write(screenCaptureBI, "png", 
        new File("screenCapture-" + timePart +".png"));
      Platform.exit();
    }catch(Exception ex){
      ex.printStackTrace();
    }
  });
}
  1. 我们将在main()方法中绑定 UI 的启动和创建的辅助方法,如下所示:
public static void main(String[] args) 
  throws Exception{
  new Thread(() -> Application.launch(
    RobotApplication.class, args)).start();

  waitForOperation(appStartLatch, 10,
    "Timed out waiting for JavaFX Application to Start");
  typeName();
  clickButton();
  waitForOperation(btnActionLatch, 10, 
    "Timed out waiting for Button to complete operation");
  Thread.sleep(1000);
  captureScreen();
}

完整的代码可以在Chapter16/9_robot_api中找到。您可以通过使用run.batrun.sh来运行示例。运行应用程序将启动 UI,执行操作,截取屏幕并退出应用程序。截图将放在启动应用程序的文件夹中,并且将遵循命名约定——screenCapture-yyyy-dd-M-m-H-ss.png。这是一个示例截图:

工作原理...

由于 JavaFX 应用程序在不同的线程中运行,我们需要确保 Robot API 的操作被正确排序,并且只有在完整的 UI 显示后才执行 Robot API 的操作。为了确保这一点,我们使用了java.util.concurrent.CountDownLatch来通信以下事件:

  • 完成 UI 的加载

  • 完成为按钮定义的操作的执行

通过使用CountDownLatch来通信 UI 加载的完成,如下所示:

# Declaration of the latch
static public CountDownLatch appStartLatch = new CountDownLatch(1);

# Using the latch 
stage.addEventHandler(WindowEvent.WINDOW_SHOWN, e ->
                Platform.runLater(appStartLatch::countDown));

当窗口显示时,countDown()方法在Stage事件处理程序中被调用,从而释放锁并触发主方法中以下代码块的执行:

typeName();
clickButton();

然后主线程再次被阻塞,等待btnActionLatch被释放。在按钮问候中的操作完成后,btnActionLatch被释放。一旦btnActionLatch被释放,主线程继续执行以调用captureScreen()方法。

让我们讨论一些我们从javafx.scene.robot.Robot类中使用的方法:

mouseMove():此方法用于将鼠标光标移动到从其xy坐标标识的给定位置。我们使用以下代码行来获取组件的边界:

Bounds textBoxBounds = nameField.localToScreen(nameField.getBoundsInLocal());

组件的边界包括以下内容:

  • 左上角的xy坐标

  • 右下角的xy坐标

  • 组件的宽度和高度

因此,对于我们的 Robot API 用例,我们使用左上角的xy坐标,如下所示:

robot.mouseMove(textBoxBounds.getMinX(), textBoxBounds.getMinY());

mouseClick():此方法用于单击鼠标上的按钮。鼠标按钮由javafx.scene.input.MouseButton枚举中的以下enums标识:

  • PRIMARY:代表鼠标的左键单击

  • SECONDARY:代表鼠标的右键单击

  • MIDDLE:代表鼠标的滚动或中间按钮。

因此,为了能够使用mouseClick(),我们需要移动需要执行单击操作的组件的位置。在我们的情况下,如在typeName()方法的实现中所示,我们使用mouseMove()移动到文本字段的位置,然后调用mouseClick(),如下所示:

robot.mouseMove(textBoxBounds.getMinX(), 
    textBoxBounds.getMinY());
robot.mouseClick(MouseButton.PRIMARY);

keyType(): 该方法用于向接受文本输入的组件中输入字符。要输入的字符由javafx.scene.input.KeyCode枚举中的枚举表示。在我们的typeName()方法实现中,我们输入字符串Sanaulla,如下所示:

robot.keyType(KeyCode.CAPS);
robot.keyType(KeyCode.S);
robot.keyType(KeyCode.CAPS);
robot.keyType(KeyCode.A);
robot.keyType(KeyCode.N);
robot.keyType(KeyCode.A);
robot.keyType(KeyCode.U);
robot.keyType(KeyCode.L);
robot.keyType(KeyCode.L);
robot.keyType(KeyCode.A);

getScreenCapture(): 该方法用于对应用程序进行截屏。捕获截屏的区域由传递给该方法的xy坐标以及宽度和高度信息确定。然后将捕获的图像转换为java.awt.image.BufferedImage并保存到文件系统中,如下面的代码所示:

WritableImage screenCapture = new WritableImage(
    Double.valueOf(appStage.getWidth()).intValue(), 
    Double.valueOf(appStage.getHeight()).intValue()
  );
robot.getScreenCapture(screenCapture, 
  appStage.getX(), appStage.getY(), 
  appStage.getWidth(), appStage.getHeight());

BufferedImage screenCaptureBI = 
  SwingFXUtils.fromFXImage(screenCapture, null);
String timePart = LocalDateTime.now().format(
  DateTimeFormatter.ofPattern("yyyy-dd-M-m-H-ss"));
ImageIO.write(screenCaptureBI, "png", 
  new File("screenCapture-" + timePart +".png"));
posted @ 2025-09-10 15:08  绝不原创的飞龙  阅读(9)  评论(0)    收藏  举报