Java9-秘籍-全-
Java9 秘籍(全)
原文:
zh.annas-archive.org/md5/d055ebded731bca7f9d2723b96bb9e97译者:飞龙
前言
本烹饪书提供了简单直接的 Java 9 代码中的各种软件开发示例,提供逐步资源和节省时间的方法,帮助您高效地解决数据问题。从 Java 的安装开始,每个配方都解决一个特定问题,讨论解释解决方案,并深入探讨其工作原理。我们涵盖了核心编程语言的主要概念以及构建各种软件的常见任务。您将通过配方学习新功能,使您的应用程序模块化、安全且快速。
本书涵盖内容
第一章,安装和 Java 9 预览,帮助您设置运行 Java 程序的开发环境,并简要概述 Java 9 的新特性和工具。
第二章,快速掌握面向对象编程 - 类和接口,涵盖了面向对象编程原则和设计解决方案,包括内部类、继承、组合、接口、枚举以及 Java 9 对 Javadocs 的更改。
第三章,模块化编程,介绍了 jigsaw 作为 Java 生态系统的一个主要特性和巨大飞跃。本章演示了如何使用工具,如 jdeps 和 jlink,创建简单的模块化应用程序,以及相关的模块化 JAR 等工件,最后是如何将您的 Java 9 之前的应用程序模块化。
第四章,迈向函数式编程,介绍了一种名为函数式编程的编程范式及其在 Java 9 中的应用。涵盖的主题包括函数式接口、lambda 表达式和 lambda 友好的 API。
第五章,流操作和管道,展示了如何利用流和在一个集合上链式执行多个操作以创建管道,使用工厂方法创建集合对象,创建和操作流,以及在流上创建操作管道,包括并行计算。
第六章,数据库编程,涵盖了 Java 应用程序和数据库之间基本和常用的交互,从连接数据库和执行 CRUD 操作到创建事务、存储过程以及处理大型对象。
第七章,并发和多线程编程,介绍了将并发引入的不同方法以及一些最佳实践,如同步和不可变性。我们还将讨论使用 Java 提供的结构实现一些常用模式,例如分而治之和使用发布-订阅模式。
第八章,操作系统进程的更好管理,详细阐述了围绕进程 API 的新 API 增强功能。
第九章,使用 JavaFX 进行 GUI 编程,展示了如何开始创建 JavaFX 应用程序,将 CSS 样式引入您的应用程序,使用 FXML 以声明性方式构建 GUI,并使用 JavaFX 的图形、媒体和浏览器组件。
第十章,使用 Spring Boot 创建 RESTful Web 服务,处理使用 Spring Boot 创建简单的 RESTful Web 服务,将它们部署到 Heroku,并最终将基于 Spring Boot 的 RESTful Web 服务应用程序进行容器化。
第十一章,网络,向您展示如何使用不同的 HTTP 客户端 API 库,即 Java 9 中作为孵化模块提供的 API,Apache HTTP 客户端和 Unirest HTTP 客户端 API。
第十二章,内存管理和调试,探讨了管理 Java 应用程序的内存,包括介绍 Java 9 中使用的垃圾回收算法和一些新特性,这些特性有助于高级应用程序诊断。我们还将展示如何使用新的 try-with-resources 构造和新的堆栈跟踪 API 来管理资源。
第十三章,使用 JShell 的读取-评估-打印循环(REPL),向您展示如何使用作为 JDK 一部分提供的新 REPL 工具和 JShell 进行工作。
第十四章,使用 Oracle Nashorn 进行脚本编写,展示了如何使用 Oracle Nashorn JavaScript 引擎在 JavaScript 和 Java 之间进行互操作,以及如何使用 jjs 命令行工具运行 JavaScript。它还探讨了 Oracle Nashorn 对新的 ECMAScript 6 的支持。
第十五章,测试,解释了在 API 与其他组件集成之前如何进行单元测试,包括使用一些虚拟数据来模拟依赖项和模拟依赖项。我们还将向您展示如何编写固定数据来填充测试数据,然后通过集成不同的 API 并对其进行测试来测试您的应用程序行为。
您需要这本书的内容
要运行代码示例,您需要至少 2GB RAM 的计算机,10GB 的可用磁盘空间,以及 Windows 或 Linux 操作系统。以下软件/库是必需的:
-
JDK 9(适用于所有章节)
-
PostgreSQL 9.4 数据库(适用于第六章,数据库编程)
-
Junit 4.12(适用于第十五章,测试)
-
Mockito 2.7.13(适用于第十五章,测试)
-
Maven 3.5.0(适用于第三章,模块化编程)
-
MySQL 5.7.19 数据库(适用于第十章,使用 Spring Boot 创建 RESTful Web 服务)
-
Heroku CLI(适用于第十章,使用 Spring Boot 创建 RESTful Web 服务)
-
Docker(用于第十章,使用 Spring Boot 的 RESTful Web 服务)
这本书面向谁
这本书是为希望使他们的应用程序快速、安全、可扩展的初级到高级 Java 程序员而编写的。
部分
在这本书中,你会发现一些经常出现的标题(准备就绪、如何操作…、工作原理…、更多内容…,以及相关内容)。为了清楚地说明如何完成食谱,我们使用以下部分如下:
准备就绪
这一部分告诉你可以在食谱中期待什么,并描述如何设置任何软件或任何为食谱所需的初步设置。
如何操作…
这一部分包含遵循食谱所需的步骤。
工作原理…
这一部分通常包含对上一部分发生情况的详细解释。
更多内容…
这一部分包含有关食谱的附加信息,以便使读者对食谱有更多的了解。
相关内容
这一部分提供了对其他有用信息的链接,这些信息对食谱很有帮助。
惯例
在这本书中,你会发现许多文本样式,用于区分不同类型的信息。以下是一些这些样式的示例及其含义的解释。文本中的代码单词、数据库表名、文件夹名、文件名、文件扩展名、路径名、虚拟 URL、用户输入和 Twitter 昵称如下所示:“运行时,它产生的值与Vehicle类的对象相同。”代码块设置为以下格式:
module newfeatures{
requires jdk.incubator.httpclient;
}
任何命令行输入或输出都按照以下方式编写:
$> vim ~/.bash_aliases
新术语和重要词汇以粗体显示。你在屏幕上看到的单词,例如在菜单或对话框中,在文本中如下所示:“右键单击我的电脑,然后单击属性。”
警告或重要注意事项如下所示。
小贴士和技巧如下所示。
读者反馈
我们欢迎读者的反馈。告诉我们你对这本书的看法——你喜欢什么或不喜欢什么。读者反馈对我们很重要,因为它帮助我们开发出你真正能从中获得最大收益的标题。要发送一般反馈,请简单地发送电子邮件至feedback@packtpub.com,并在邮件主题中提及书籍的标题。如果你在某个主题上具有专业知识,并且你对撰写或为书籍做出贡献感兴趣,请参阅我们的作者指南www.packtpub.com/authors。
客户支持
现在你已经是 Packt 图书的骄傲拥有者,我们有一些事情可以帮助你从购买中获得最大收益。
下载示例代码
您可以从www.packtpub.com上的账户下载本书的示例代码文件。如果您在其他地方购买了这本书,您可以访问www.packtpub.com/support并注册,以便将文件直接通过电子邮件发送给您。您可以通过以下步骤下载代码文件:
-
使用您的电子邮件地址和密码登录或注册我们的网站。
-
将鼠标指针悬停在顶部的“支持”选项卡上。
-
点击“代码下载与勘误表”。
-
在搜索框中输入书籍的名称。
-
选择您想要下载代码文件的书籍。
-
从下拉菜单中选择您购买此书的来源。
-
点击“代码下载”。
您还可以通过点击 Packt Publishing 网站上书籍网页上的“代码文件”按钮来下载代码文件。您可以通过在搜索框中输入书籍的名称来访问此页面。请注意,您需要登录到您的 Packt 账户。文件下载后,请确保您使用最新版本的 WinRAR / 7-Zip 对文件夹进行解压缩或提取:
-
Windows 上的 WinRAR / 7-Zip
-
Mac 上的 Zipeg / iZip / UnRarX
-
Linux 上的 7-Zip / PeaZip
该书的代码包也托管在 GitHub 上,网址为github.com/PacktPublishing/Java-9-Cookbook。我们还有其他来自我们丰富图书和视频目录的代码包,可在github.com/PacktPublishing/找到。请查看它们!
勘误
尽管我们已经尽一切努力确保我们内容的准确性,但错误仍然可能发生。如果您在我们的某本书中发现了错误——可能是文本或代码中的错误——如果您能向我们报告,我们将不胜感激。通过这样做,您可以节省其他读者的挫败感,并帮助我们改进本书的后续版本。如果您发现任何勘误,请通过访问www.packtpub.com/submit-errata,选择您的书籍,点击“勘误提交表单”链接,并输入您的勘误详情来报告它们。一旦您的勘误得到验证,您的提交将被接受,勘误将被上传到我们的网站或添加到该标题的勘误部分下的现有勘误列表中。要查看之前提交的勘误,请转到www.packtpub.com/books/content/support,并在搜索字段中输入书籍的名称。所需信息将出现在勘误部分下。
侵权
在互联网上侵犯版权材料是一个跨所有媒体持续存在的问题。在 Packt,我们非常重视我们版权和许可证的保护。如果您在互联网上发现任何形式的我们作品的非法副本,请立即提供位置地址或网站名称,以便我们可以寻求补救措施。请通过copyright@packtpub.com与我们联系,并提供疑似侵权材料的链接。我们感谢您在保护我们作者和我们为您提供有价值内容的能力方面的帮助。
问题和建议
如果您在这本书的任何方面遇到问题,您可以联系我们的questions@packtpub.com,我们将尽力解决问题。
第一章:安装和 Java 9 的预览
在本章中,我们将介绍以下内容:
-
在 Windows 上安装 JDK 9 并设置 PATH 变量
-
在 Linux(Ubuntu,x64)上安装 JDK 9 并配置 PATH 变量
-
编译和运行 Java 应用程序
-
Java 9 的新特性
-
使用 JDK 9 中的新工具
-
比较 JDK 8 与 JDK 9
简介
学习任何编程语言的第一步是设置实验学习环境。与这一理念保持一致,在本章中,我们将向您展示如何设置您的开发环境,然后运行一个简单的模块化应用程序来测试我们的安装。之后,我们将向您介绍 JDK 9 中的新特性和工具。然后,我们将以 JDK 8 和 JDK 9 安装的比较来结束本章。
在 Windows 上安装 JDK 9 并设置 PATH 变量
在这个菜谱中,我们将探讨在 Windows 上安装 JDK 以及如何设置 PATH 变量,以便可以从命令行中的任何位置访问 Java 可执行文件(如 javac、java 和 jar 等)。
如何做到这一点...
-
访问
jdk9.java.net/download/并接受早期采用者许可协议,它看起来像这样:![]()
-
接受许可后,您将看到一个基于操作系统和架构(32/64 位)的可用 JDK 打包网格,如下所示:
![]()
-
点击下载适用于您的 Windows 平台的相应 JDK 可执行文件(
.exe)。 -
运行 JDK 可执行文件(
.exe)并遵循屏幕上的说明来安装 JDK 到您的系统上。 -
如果您在安装过程中选择了所有默认设置,您将发现在 64 位系统上 JDK 安装在
C:/Program Files/Java,在 32 位系统上安装在C:/Program Files (x86)/Java。
现在我们已经完成了 JDK 的安装,让我们看看如何设置 PATH 变量。
JDK 提供的工具,包括 javac、java、jconsole 和 jlink 等,都位于 JDK 安装目录的 bin 目录中。您可以从命令提示符运行这些工具的两种方式:
- 导航到工具安装的目录,然后按照以下方式运行它们:
cd "C:\Program Files\Java\jdk-9\bin"
javac -version
- 将路径导出到目录,以便工具可以从命令提示符中的任何目录访问。为了实现这一点,我们必须将 JDK 工具的路径添加到
PATH环境变量中。命令提示符将在PATH环境变量中声明的所有位置中搜索相关工具。
让我们看看如何将 JDK bin 目录添加到 PATH 变量中:
- 右键单击“我的电脑”,然后单击“属性”。您将看到系统信息。搜索“高级系统设置”并单击它以获取以下截图所示的窗口:

-
点击“环境变量”以查看系统中定义的变量。您将看到已经定义了相当多的环境变量,如下面的截图所示(系统之间的变量会有所不同;在下面的截图中,有一些预定义的变量和几个我添加的变量):
在系统变量下定义的变量对所有系统用户都可用,而在 sanaulla的用户变量下定义的变量仅对用户sanaulla可用。 -
在
<your username>的用户变量下点击“新建”,添加一个名为JAVA_HOME的新变量,其值为 JDK 9 安装的位置。例如,对于 64 位系统将是C:/Program Files/Java/jdk-9,对于 32 位系统将是C:/Program Files (x86)/Java/jdk-9:

-
下一步是将
PATH环境变量更新为 JDK 安装(在JAVA_HOME环境变量中定义)的 bin 目录位置。如果您已经在列表中看到了PATH变量,则需要选择该变量并点击“编辑”。如果未看到PATH变量,则点击“新建”。 -
上一步中的任何操作都会弹出一个窗口,如下面的截图所示(在 Windows 10 上):

以下图像显示了其他 Windows 版本:

-
您可以点击第一张图中的“新建”并插入值,
%JAVA_HOME%/bin,或者通过添加; %JAVA_HOME%/bin来追加变量值字段中的值。在 Windows 中,分号 (;) 用于分隔给定变量名的多个值。 -
设置值后,打开命令提示符,然后运行
javac -version,您应该能看到输出为javac 9-ea。如果您看不到它,那么这意味着您的 JDK 安装的 bin 目录尚未正确添加到PATH变量中。
在 Linux (Ubuntu, x64) 上安装 JDK 9 并配置 PATH 变量
在本教程中,我们将探讨如何在 Linux (Ubuntu, x64) 上安装 JDK,以及如何配置 PATH 变量,以便在终端的任何位置使用 JDK 工具(如 javac、java、jar 等)。
如何操作...
-
按照教程 在 Windows 上安装 JDK 9 并设置 PATH 变量 的步骤 1 和 2,到达下载页面。
-
从下载页面复制适用于 Linux x64 平台的 JDK 下载链接 (
tar.gz)。 -
使用
$> wget <copied link>下载 JDK,例如,$> wget http://download.java.net/java/jdk9/archive/180/binaries/jdk-9+180_linux-x64_bin.tar.gz。 -
下载完成后,您应该有相关的 JDK 可用,例如
jdk-9+180_linux-x64_bin.tar.gz。您可以使用$> tar -tf jdk-9+180_linux-x64_bin.tar.gz列出内容。您甚至可以将它管道到more以分页显示输出:$> tar -tf jdk-9+180_linux-x64_bin.tar.gz | more。 -
使用
$> tar -xvzf jdk-9+180_linux-x64_bin.tar.gz -C /usr/lib通过在/usr/lib下提取tar.gz文件的内容。这将提取内容到一个目录,/usr/lib/jdk-9。然后,您可以使用$> ls /usr/lib/jdk-9列出 JDK 9 的内容。 -
通过编辑您 Linux 主目录下的
.bash_aliases文件来更新JAVA_HOME和PATH变量:
$> vim ~/.bash_aliases
export JAVA_HOME=/usr/lib/jdk-9
export PATH=$PATH:$JAVA_HOME/bin
源.bashrc文件以应用新别名:
$> source ~/.bashrc
$> echo $JAVA_HOME
/usr/lib/jdk-9
$>javac -version
javac 9
$> java -version
java version "9"
Java(TM) SE Runtime Environment (build 9+180)
Java HotSpot(TM) 64-Bit Server VM (build 9+180, mixed mode)
本书中的所有示例都是在 Linux(Ubuntu,x64)上针对安装的 JDK 运行的,除非我们特别指出这些是在 Windows 上运行的。我们已尝试为这两个平台提供运行脚本。
JavaFX 的菜谱在 Windows 上完全执行。
编译和运行 Java 应用程序
在这个菜谱中,我们将编写一个非常简单的模块化Hello world程序来测试我们的 JDK 安装。这个简单的示例以 XML 格式打印Hello world;毕竟,这是网络服务的世界。
准备工作
您应该已经安装了 JDK,并且将PATH变量更新为指向 JDK 安装位置。
如何做到...
- 让我们定义具有相关属性和注释的模型对象,这些注释将被序列化为 XML:
@XmlRootElement
@XmlAccessorType(XmlAccessType.FIELD)
class Messages{
@XmlElement
public final String message = "Hello World in XML";
}
在前面的代码中,@XmlRootElement用于定义根标签,@XmlAccessorType用于定义标签名称和标签值的源类型,@XmlElement用于标识成为 XML 中标签名称和标签值的源:
- 现在,让我们使用 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());
}
}
- 现在,我们将创建一个名为
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 安装。
前面文件的目录结构如下:

- 让我们现在编译并运行代码。从
hellowordxml目录中,创建一个新的目录来放置您的编译后的类文件:
mkdir -p mods/com.packt
将源代码HelloWorldXml.java和module-info.java编译到mods/com.packt目录中:
javac -d mods/com.packt/ src/com.packt/module-info.java
src/com.packt/com/packt/HelloWorldXml.java
- 使用
java --module-path mods -m com.packt/com.packt.HelloWorldXml运行编译后的代码。您将看到以下输出:
<messages><message>Hello World in XML</message></messages>
如果您无法理解java或javac命令传递的选项,请不要担心。您将在第三章《模块化编程》中了解它们。
Java 9 的新特性
Java 9 的发布是 Java 生态系统中的一个里程碑。在 Project Jigsaw 下开发的备受期待的模块化框架将成为这个 Java SE 版本的一部分。另一个主要特性是 JShell 工具,这是一个 Java 的 REPL 工具。除此之外,还有其他重要的 API 更改和 JVM 级别的更改,以提高 JVM 的性能和调试性。在一篇博客文章(blogs.oracle.com/java/jdk-9-categories)中,Yolande Poirier 将 JDK 9 特性分类如下:
-
背后
-
新功能
-
专用
-
新标准
-
清理工作
-
已消失
同一篇博客文章将前面的分类总结成以下图像:

在这个菜谱中,我们将讨论 JDK 9 的一些重要特性,并在可能的情况下,也会展示该特性的一个小代码片段。JDK 中的每个新特性都是通过JDK 增强提案(也称为JEP)引入的。有关 JDK 9 中不同 JEP 的更多信息以及 JDK 9 的发布计划,可以在官方项目页面上找到:
openjdk.java.net/projects/jdk9/
如何做到这一点...
我们挑选了一些我们认为很棒且值得了解的特性。在接下来的几个部分中,我们将简要介绍这些特性。
JEP 102 -- Process API 更新
Java 的 Process API 相当原始,仅支持启动新进程、重定向进程的输出和错误流。在这个版本中,Process API 的更新使得以下功能成为可能:
-
获取当前 JVM 进程的 PID 以及由 JVM 产生的任何其他进程的 PID
-
列出系统中运行的过程以获取诸如 PID、名称和资源使用情况等信息
-
管理进程树
-
管理子进程
让我们看看一个示例代码,该代码打印当前 PID 以及当前进程信息:
//NewFeatures.java
public class NewFeatures{
public static void main(String [] args) {
ProcessHandle currentProcess = ProcessHandle.current();
System.out.println("PID: " + currentProcess.getPid());
ProcessHandle.Info currentProcessInfo = currentProcess.info();
System.out.println("Info: " + currentProcessInfo);
}
}
JEP 110 -- HTTP/2 客户端
这个特性被包含在孵化器模块中。这意味着该特性预计在后续版本中会有所变化,甚至可能被完全删除。因此,我们建议您以实验性方式使用它。
Java 的 HTTP API 一直是最原始的。开发者经常求助于使用第三方库,例如 Apache HTTP、RESTlet、Jersey 等。除此之外,Java 的 HTTP API 早于 HTTP/1.1 规范,并且是同步的,难以维护。这些限制要求添加一个新的 API。新的 HTTP 客户端 API 提供了以下功能:
-
一个简单且简洁的 API 来处理大多数 HTTP 请求
-
支持 HTTP/2 规范
-
更好的性能
-
更好的安全性
-
一些额外的增强
让我们看看一个示例代码,使用新的 API 来进行 HTTP GET 请求。以下是在文件 module-info.java 中定义的模块定义:
//module-info.java
module newfeatures{
requires jdk.incubator.httpclient;
}
以下代码使用 HTTP 客户端 API,它是 jdk.incubator.httpclient 模块的一部分:
import jdk.incubator.http.*;
import java.net.URI;
public class Http2Feature{
public static void main(String[] args) throws Exception{
HttpClient client = HttpClient.newBuilder().build();
HttpRequest request = HttpRequest
.newBuilder(new URI("http://httpbin.org/get"))
.GET()
.version(HttpClient.Version.HTTP_1_1)
.build();
HttpResponse<String> response = client.send(request,
HttpResponse.BodyHandler.asString());
System.out.println("Status code: " + response.statusCode());
System.out.println("Response Body: " + response.body());
}
}
JEP 213 -- 磨削项目硬币
在 Java SE 7 中,下划线 _ 被引入作为数字的一部分,通过在数字之间引入 _ 可以方便地写出大数字。这有助于提高数字的可读性,例如:
Integer large_Number = 123_123_123;
System.out.println(large_Number);
在 Java SE 8 中,变量名中使用 _,如前所述,会导致警告,但在 Java SE 9 中,这种使用会导致错误,这意味着变量名不能再包含 _。
这个 JEP 的另一个变化部分是支持接口中的私有方法。Java 从没有方法实现的接口开始。然后,Java SE 8 引入了默认方法,允许接口具有具有实现的方法,称为默认方法。因此,任何实现此接口的类都可以选择不覆盖默认方法,并使用接口中提供的方法实现。
Java SE 9 正在引入私有方法,其中接口中的默认方法可以通过将公共代码重构为私有方法来相互共享代码。
另一个有用的特性是允许在 try-with-resources 中使用实际上是最终的变量。截至 Java SE 8,我们需要在 try-with-resources 块内声明一个变量,如下所示:
try(Connection conn = getConnection()){}catch(Exception ex){}.
然而,在 Java SE 9 中,我们可以做以下事情:
try(conn){}catch(Exception ex){}
在这里,conn 是实际上是最终的;也就是说,它已经在之前声明和定义,并且在程序执行过程中永远不会重新分配。
JEP 222: jshell -- Java shell(读取-评估-打印循环)
你一定见过像 Ruby、Scala、Groovy、Clojure 等语言,它们都附带了一个工具,通常被称为 REPL(读取-评估-打印-循环)。这个 REPL 工具在尝试语言特性时非常实用。例如,在 Scala 中,我们可以编写一个简单的 Hello World 程序,如下所示:scala> println("Hello World");
JShell REPL 的一些优点如下:
-
帮助语言学习者快速尝试语言特性
-
帮助经验丰富的开发者在使用主代码库之前快速原型设计和实验
-
Java 开发者现在可以吹嘘拥有一个 REPL
让我们快速启动我们的命令提示符/终端,并运行 JShell 命令,如下面的图像所示:

我们可以做更多的事情,但我们将把那留到第十三章,使用 JShell 的读取-评估-打印循环(REPL)。
JEP 238 -- 多版本 JAR 文件
目前,JAR 文件可以包含只能运行在它们编译的 Java 版本上的类。为了利用 Java 平台在较新版本上的新功能,库开发者必须发布他们库的新版本。很快,开发者将维护库的多个版本,这可能会成为一个噩梦。为了克服这一限制,多版本 JAR 文件的新特性允许开发者构建包含不同 Java 版本类文件的不同版本的 JAR 文件。以下示例使这一点更加清晰。
下面是当前 JAR 文件的示例:
jar root
- A.class
- B.class
- C.class
下面是多版本 JAR 文件的外观:
jar root
- A.class
- B.class
- C.class
- META-INF
- versions
- 9
- A.class
- 10
- B.class
在前面的示例中,JAR 文件支持两个 Java 版本的类文件--9 和 10。因此,当较早的 JAR 文件在 Java 9 上执行时,versions - 9 文件夹下的 A.class 会被选中执行。在不支持多版本 JAR 文件的平台上,版本目录下的类永远不会被使用。所以,如果你在 Java 8 上运行多版本 JAR 文件,它就像运行一个简单的 JAR 文件一样。
JEP 266 -- 更多的并发更新
在这次更新中,引入了一个新的类 java.util.concurrent.Flow,它包含嵌套接口,支持实现发布-订阅框架。发布-订阅框架允许开发者通过设置生产数据的发布者和通过订阅消费数据的订阅者来构建可以异步消费实时数据流组件,订阅管理这些订阅者。以下是新增加的四个接口:
-
java.util.concurrent.Flow.Publisher -
java.util.concurrent.Flow.Subscriber -
java.util.concurrent.Flow.Subscription -
java.util.concurrent.Flow.Processor(它同时充当Publisher和Subscriber)。
项目 Jigsaw
本项目的主要目标是引入模块化的概念;在 Java 中支持创建模块,然后将这些应用到 JDK 中;也就是说,模块化 JDK。模块化的一些好处如下:
-
更强的封装性:模块只能访问那些已公开供使用的模块部分。因此,除非包在模块信息文件中明确导出,否则包中的公共类不是公共的。这种封装性不能通过反射(除非模块是开放模块或模块中的特定包已被公开)来破坏。
-
清晰的依赖关系:模块必须通过
requires子句声明它们将使用哪些其他模块。 -
将模块组合起来创建更小的运行时环境,这可以轻松地扩展到更小的计算设备。
-
通过消除运行时错误来提高应用程序的可靠性。例如,你可能经历过应用程序在运行时由于缺少类而失败,导致
ClassNotFoundException。
有各种 JEP,它们是这个项目的一部分,如下所示:
-
JEP 200 - 模块化 JDK:这将 Java 平台模块系统应用于模块化 JDK,使其成为一组可以在编译时、构建时或运行时组合的模块。
-
JEP 201 - 模块化源代码:这将 JDK 源代码模块化,并增强构建工具以编译模块。
-
JEP 220 - 模块化运行时图像:这重构了 JDK 和 JRE 运行时图像以适应模块并提高性能、安全性和可维护性。
-
JEP 260 - 封装大多数内部 API:这允许许多内部 API 可以直接访问或通过反射访问。访问那些注定要改变的内部 API 风险很大。为了防止其使用,它们被封装到模块中,并且只有那些广泛使用的内部 API 才被提供,直到合适的 API 取代它们。
-
JEP 261 - 模块系统:这通过更改 Java 编程语言、JVM 和其他标准 API 来实现 Java 模块系统规范。这包括引入一个名为模块的新构造,
{ },以及其支持的键词,如requires、exports、opens和uses。 -
JEP 282: jlink,Java 链接器:这允许将模块及其依赖项打包到更小的运行时中。
更多关于 Project Jigsaw 的详细信息可以在 Project Jigsaw 主页上找到 (openjdk.java.net/projects/jigsaw/).
还有更多...
列出了许多对开发者来说很重要的功能,我们考虑将它们分组在一起以供您参考:
-
增强 Javadoc 工具以生成 HTML 5 输出,并且生成的 Javadoc 应该支持对类和其他元素的本地搜索。
-
将 G1 作为默认的垃圾回收器,并移除 Java 8 中已弃用的 GC 组合。G1 是一个新的垃圾回收器(自 Java SE 7 以来一直存在),它专注于减少垃圾回收活动的暂停时间。这些暂停时间对于延迟敏感的应用程序至关重要,因此,这些应用程序正在转向采用新的垃圾回收器。
-
将
String的内部表示从使用字符数组改为使用字节数组。在字符数组中,每个数组元素是 2 个字节,观察到大多数字符串使用 1 个字节。这导致了浪费的分配。新的表示也将引入一个标志来指示使用的编码类型。 -
新的堆栈跟踪 API 支持导航堆栈跟踪,这将有助于做更多不仅仅是打印堆栈跟踪的事情。
-
允许图像 I/O 插件支持 TIFF 图像格式。
在 JDK 9 中使用新工具
JDK 9 中引入了一些新的命令行工具来支持新特性。我们将为您快速概述这些工具,并在后面的章节中用各自的配方进行解释。
准备工作
你应该已经安装了 JDK 9,并且将 JDK 安装目录下的 bin 目录路径添加到 PATH 环境变量中。此外,你需要尝试过配方中解释的 HelloWorldXml,即 编译和运行 Java 应用程序。
如何操作...
我们将探讨一些新引入的有趣命令行工具。
jdeprscan
此工具用于扫描给定 JAR 文件、类路径或源目录中弃用的 API 的使用情况。假设我们有一个简单的类,它使用了 java.awt.List 类中弃用的方法 addItem,如下所示:
import java.awt.List;
public class Test{
public static void main(String[] args){
List list = new List();
list.addItem("Hello");
}
}
编译前面的类,然后使用 jdeprscan,如下所示:
C:Program FilesJavajdk-9bin>jdeprscan.exe -cp . Test
你会注意到这个工具打印出 class Test uses method java/awt/List addItem (Ljava/lang/String;)V deprecated,这正是我们预期的。
jdeps
该工具通过指定 .class 文件、目录或 JAR 的路径来分析你的代码库,列出应用程序的包依赖关系,并列出包含该包的 JDK 模块。这有助于识别应用程序所依赖的 JDK 模块,并且是迁移到模块化应用程序的第一步。
我们可以在之前编写的 HelloWorldXml 示例上运行此工具,看看 jdeps 提供了什么:
$> jdeps mods/com.packt/
com.packt -> java.base
com.packt -> java.xml.bind
com.packt -> java.io java.base
com.packt -> java.lang java.base
com.packt -> javax.xml.bind java.xml.bind
com.packt -> javax.xml.bind.annotation java.xml.bind
jlink
此工具用于选择模块并创建包含所选模块的较小运行时镜像。例如,我们可以通过添加在 HelloWorldXml 示例中创建的 com.packt 模块来创建运行时镜像:
$> jlink --module-path mods/:$JAVA_HOME/jmods/ --add-modules com.packt --output img
查看文件夹 img 的内容,我们应该发现它包含 bin、conf、include 和 lib 目录。我们将在第三章 模块化编程 中学习更多关于 jlink 的内容。
jmod
JMOD 是一种新的模块打包格式。这种格式允许包含原生代码、配置文件和其他不适合放入 JAR 文件中的数据。JDK 模块已被打包为 JMOD 文件。
jmod 命令行工具允许 create、list、describe 和 hash JMOD 文件:
-
create:这个用于创建一个新的jmod文件 -
list:这个用于列出jmod文件的内容 -
describe:这个用于描述模块详情 -
hash:这个用于记录绑定模块的哈希值
JShell
此工具已在标题为 JEP 222: jshell - Java shell(读取-评估-打印循环) 的部分中简要介绍。
比较 JDK 8 和 JDK 9
由于在 Project Jigsaw 项目下对 JDK 应用了模块化系统,因此您的系统中安装的 JDK 目录结构发生了一些变化。除此之外,还进行了一些更改以修复 JDK 安装结构,这些更改可以追溯到 Java 1.2 时代。JDK 团队认为这是一个修复 JDK 目录结构问题的绝佳机会。
准备工作
要查看 JDK 9 目录结构中的差异,您需要安装一个 JDK 9 之前的版本。我们选择使用 JDK 8 与 JDK 9 进行比较。因此,在继续之前,请先安装 JDK 8。
如何操作...
- 我们对以下所示的两个 JDK 安装目录进行了并排比较:

-
以下是我们从先前的比较中得出的观察结果:
-
jre目录已被完全删除,并已被jmods和conf替换。jmods目录包含 JDK 模块的运行时镜像,conf目录包含配置和属性文件,这些文件之前位于jre目录下。 -
jrebin和jrelib的内容已移动到 JDK 安装目录的 lib 和 bin 目录中。
-
参考以下内容
参考本章以下配方:
- 在 JDK 9 中使用新工具
第二章:面向对象编程快速入门 - 类和接口
本章将涵盖以下内容:
-
使用类实现面向对象设计
-
使用内部类
-
使用继承和组合来使设计可扩展
-
面向接口编码
-
创建具有默认和静态方法的接口
-
创建具有私有方法的接口
-
使用枚举表示常量实体
-
使用@Deprecated 注解弃用 API
-
在 Javadocs 中使用 HTML5
简介
本章为您快速介绍了面向对象编程的组件,并涵盖了 Java 8 和 Java 9 中这些组件的新增强功能。我们还将尝试在适用的情况下涵盖一些好的面向对象设计(OOD)实践。
在整个食谱中,我们将使用新的增强功能(Java 8 和 Java 9 中引入),定义并使用具体的代码示例演示面向对象设计(OOD)的概念,并展示新的代码文档化能力。
一个人可以在书籍和互联网上花费许多小时阅读关于面向对象设计(OOD)的文章和实用建议。其中一些活动对某些人可能有益。但根据我们的经验,掌握面向对象设计的最快方式是在自己的代码中尽早尝试其原则。这正是本章的目的:让您有机会看到并使用面向对象设计(OOD)原则,以便正式定义能立即理解。
一段好代码的主要标准之一是其表达意图的清晰度。一个有动机且清晰的设计有助于实现这一点。代码是由计算机运行的,但它是由人类维护和扩展的。记住这一点将确保您编写的代码的长期有效性,也许还能得到一些感谢和赞赏的提及。
在本章中,您将学习如何使用五个基本的面向对象设计(OOD)概念:
-
对象/类 - 将数据和过程放在一起
-
封装 - 隐藏数据或过程
-
继承 - 扩展另一个类的数据或过程
-
接口 - 延迟类型实现的编码
-
多态性 - 当使用父类引用来引用子类对象时,使用基类类型为其所有扩展
这些概念将在本章提供的代码片段中定义和演示。如果您在互联网上搜索,可能会注意到许多其他概念和它们的补充都可以从我们刚才讨论的五个要点中推导出来。
虽然以下文本不需要对面向对象设计(OOD)有先前的知识,但有一些使用 Java 编写代码的经验将是有益的。代码示例完全有效,且与 Java 9 兼容。为了更好地理解,我们建议您尝试运行所提供的示例。
我们还鼓励您根据您在团队经验中的需求,将本章中的提示和建议适应到您的实际需求中。考虑与您的同事分享您新获得的知识,并讨论如何将描述的原则应用到您的领域,以及您当前的项目中。
使用类实现面向对象设计
在这个菜谱中,你将了解前两个面向对象设计(OOD)概念:对象/类和封装。
准备工作
术语对象通常指的是将数据和处理程序(可以应用于这些数据)结合在一起。数据和处理程序都不是必需的,但其中之一是必需的,通常两者都是始终存在的。数据被称为对象属性,而处理程序被称为方法。属性捕获对象的状态。方法描述对象的行为。每个对象都有一个类型,该类型由其类定义(见下文)。一个对象也被说成是类的实例。
术语类是一组定义,这些定义将存在于其每个实例中--基于此类创建的对象。
封装是指隐藏那些不应该被其他对象访问的对象属性和方法。
封装是通过在属性和方法声明中使用 Java 关键字private或protected来实现的。
如何做到这一点...
- 创建一个具有
horsePower属性、设置此属性值的setHorsePower()方法以及基于车辆启动以来经过的时间、车辆重量和发动机功率计算车辆速度的getSpeedMph()方法的Engine类:
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;
v = v*timeSec*32.17/weightPounds;
return Math.round(Math.sqrt(v)*0.68);
}
}
- 创建
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);
}
}
- 创建将使用这些类的应用程序:
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类的默认构造函数并使用 Java 关键字new(在堆上为新创建的对象分配内存)不带参数地创建了一个engine对象。
第二个对象,即vehicle,是通过使用Vehicle类的显式定义的构造函数创建的,该构造函数有两个参数。构造函数的第二个参数是一个engine对象,它携带了horsePower属性,其值通过setHorsePower()方法设置为246。
它还包含一个getSpeedMph()方法,可以被任何有权访问engine对象的另一个对象调用,就像在Vehicle类的getSpeedMph()方法中所做的那样。
值得注意的是,Vehicle类的getSpeedMph()方法依赖于分配给engine属性值的存续。Vehicle类的对象将速度计算委托给Engine类的对象。如果后者未设置(例如,在Vehicle()构造函数中传递null),则在运行时将得到NullPointerException。为了避免这种情况,我们可以在Vehicle()构造函数中放置一个检查这个值是否存在:
if(engine == null){
throw new RuntimeException("Engine" + " is required parameter.");
}
或者,我们可以在Vehicle类的getSpeedMph()方法中放置一个检查:
if(getEngine() == null){
throw new RuntimeException("Engine value is required.");
}
这样,我们避免了NullPointerException的不确定性,并确切地告诉用户问题的来源。
如你所注意到的,getSpeedMph() 方法可以从 Engine 类中移除,并在 Vehicle 类中完全实现:
public 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);
}
要做到这一点,我们需要向 Engine 类添加一个 public 方法 getHorsePower(),以便使其可用于 Vehicle 类中的 getSpeedMph() 方法。现在,我们保留 Engine 类中的 getSpeedMph() 方法。
这是你需要做出的设计决策之一。如果你认为 Engine 类的对象将被传递给不同类的对象(而不仅仅是 Vehicle),那么你需要在 Engine 类中保留 getSpeedMph() 方法。否则,如果你认为 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;
}
}
现在我们可以通过将 Vehicle 类替换为 Car 类来更改我们的测试代码:
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 中,一个类也可以实现多个接口,此类对象的类型也会是每个实现接口的类型。我们将在后续的菜谱中讨论这一点。
通常有几种方式来设计具有相同功能的应用程序。这完全取决于你项目的需求和发展团队采用的风格。但在任何情况下,设计清晰都将帮助你传达意图。良好的设计有助于提高代码的质量和寿命。
参见
参考本章中的以下菜谱:
- 使用继承和组合来使设计可扩展
使用内部类
在这个菜谱中,你将了解三种类型的内部类:
-
内部类:这是在另一个(封装)类内部定义的类。其从外部封装类访问的权限由
public、protected和private关键字控制。它可以访问封装类的私有成员,封装类可以访问其内部类的私有成员。 -
方法局部内部类:这是在方法内部定义的类。其作用域限制在方法内部。
-
匿名内部类:这是在对象实例化期间定义的匿名类。
准备工作
当一个类只被一个其他类使用时,设计者可能会决定没有必要使此类公开。例如,假设Engine类只被Vehicle类使用。
如何做到这一点...
- 将
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;
v = v * timeSec * 32.174 / getWeightPounds();
return Math.round(Math.sqrt(v) * 0.68);
}
}
}
-
注意,
Vehicle类的getSpeedMph()方法可以访问Engine类(尽管它被声明为private),甚至可以访问Engine类的私有getSpeedMph()方法。内部类也可以访问封装类的所有私有元素。这就是为什么Engine类的getSpeedMph()方法可以访问封装类Vehicle的私有getWeightPounds()方法。 -
更仔细地看看内部类
Engine的使用。只有getSpeedMph()方法使用了它。如果设计者认为将来也会是这样,那么将其作为方法局部内部类(内部类的第二种类型)是合理的:
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;
v = v * timeSec * 32.174 / getWeightPounds();
return Math.round(Math.sqrt(v) * 0.68);
}
}
Engine engine = new Engine(this.horsePower);
return engine.getSpeedMph(timeSec);
}
}
封装——隐藏对象的状态和行为——有助于避免由于意外更改或覆盖而导致的意外副作用。这使得行为更加可预测,更容易理解。这就是为什么一个好的设计只暴露必须从外部访问的功能。通常,这是最初创建类的动机所在的主要功能。
它是如何工作的...
无论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()方法的实现。或者,我们可以更改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");
}
如果我们运行这个程序,结果将如下所示:

我们通过只留下一个方法(返回硬编码值的getSpeedMph()方法)来覆盖Vehicle类的实现。我们也可以覆盖其他方法或添加新方法,但为了演示目的,我们将保持简单。
根据定义,匿名内部类必须是一个表达式,它是语句的一部分,并以分号结束(就像任何语句一样)。该表达式由以下部分组成:
-
new运算符 -
实现的接口名称(后跟表示默认构造函数的括号
())或扩展类的构造函数(后者是我们的情况,扩展类是Vehicle) -
带有方法声明(匿名内部类体中不允许有语句)的类体
与任何内部类一样,匿名内部类可以访问封装类的任何成员,并可以捕获其变量的值。为了能够这样做,这些变量必须声明为final。否则,它们会隐式地成为final,这意味着它们的值不能被更改(如果你尝试更改这样的值,一个好的现代 IDE 会警告你违反了这个约束)。
使用这些特性,我们可以修改我们的示例代码,并为新实现的getSpeedMph()方法提供更多输入数据,而无需将它们作为方法参数传递:
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;
v = v * timeSec * 32.174 / vehicleWeightPounds;
return Math.round(Math.sqrt(v) * 0.68);
}
};
System.out.println("Vehicle speed (" + timeSec + " sec) = " +
vehicle.getSpeedMph(timeSec) + " mph");
}
注意,变量timeSec、engineHorsePower和vehicleWeightPounds可以通过内部类的getSpeedMph()方法访问,但不能被修改。如果我们运行这段代码,结果将与之前相同:

在只有一个抽象方法(称为函数式接口)的接口情况下,有一种特殊的匿名内部类,称为lambda 表达式,它允许你使用更短的表示法,但提供了接口实现。我们将在下一章讨论函数式接口和 lambda 表达式。
更多内容...
内部类是非静态嵌套类。Java 还允许你创建一个静态嵌套类,当内部类不需要访问封装类的非公共属性和方法时可以使用。以下是一个示例(关键字static被添加到Engine类中):
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;
v = v * timeSec * 32.174 / this.weightPounds;
return Math.round(Math.sqrt(v) * 0.68);
}
}
}
因为静态类不能访问非静态成员(封装类Vehicle的getWeightPounds()方法),我们被迫在构造Engine类时传递重量值(并且我们移除了不再需要的getWeightPounds()方法)。
参见
参考本章中的以下菜谱:
- 走向函数式编程
使用继承和组合来使设计可扩展
在这个菜谱中,你将了解两个重要的面向对象设计(OOD)概念,即继承和多态,这些概念已经在之前的菜谱示例中提到并使用过。
准备工作
继承是某个类扩展(可选地覆盖)另一个类的属性和/或方法的能力。被扩展的类被称为基类、超类或父类。新扩展的类被称为子类或子类。
多态是使用基类作为其子类对象引用的类型的能力。
为了展示这两个概念的力量,让我们创建代表汽车和卡车的类,每个类都有重量、发动机功率和最大负载下能到达的速度(作为时间的函数)。此外,在这种情况下,汽车将由乘客数量来表征,而卡车的重要特征将是其载货量。
如何做到这一点...
- 看看
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 类作为基类中。
- 创建一个名为
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;
}
}
- 创建另一个名为
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 没有参数的隐式或显式构造函数(因为我们选择只使用带参数的显式构造函数),我们必须在每个子类的构造函数的第一行调用基类构造函数 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 的对象。这是通过多态实现的,根据多态,一个对象具有其继承线上的每个类的类型(包括所有接口,我们将在稍后讨论)。
如前例所示,需要将此类引用转换为子类类型,以便调用仅在子类中存在的方法。
如果我们运行前面的示例,结果将如下所示:

我们看到汽车和卡车都计算出了相同的速度,即 117.0 mph,这并不令人惊讶——因为使用了相同的重量和发动机功率来计算每个的速度。但是,直观上,我们感觉一辆重载的卡车不应该能够达到与汽车相同的速度。为了验证这一点,我们需要在 getSpeedMph() 方法中包含汽车(包括乘客和他们的行李)和卡车(包括货物)的总重量。一种方法是在每个子类中覆盖基类 Vehicle 的 getSpeedMph() 方法。
现在,向 Car 类添加 horsePower 和 weightPounds 属性以及以下方法(我们假设一个乘客加上行李平均重 250 磅):
public double getSpeedMph(double timeSec) {
int weight = this.weightPounds + this.passengersCount * 250;
double v = 2.0 * this.horsePower * 746;
v = v * timeSec * 32.174 / weight;
return Math.round(Math.sqrt(v) * 0.68);
}
还要向 Truck 类添加 horsePower 和 weightPounds 属性以及以下方法:
public double getSpeedMph(double timeSec) {
int weight = this.weightPounds + this.payload;
double v = 2.0 * this.horsePower * 746;
v = v * timeSec * 32.174 / weight;
return Math.round(Math.sqrt(v) * 0.68);
}
这两个添加的结果(如果我们运行相同的测试类)将如下所示:

这些结果确实证实了我们的直觉:一辆满载的汽车或卡车不会达到与空载相同的速度。
子类中的新方法覆盖了基类 Vehicle 的 getSpeedMph() 方法,尽管我们通过基类引用来访问它:
Vehicle vehicle = new Car(4, vehicleWeightPounds, engineHorsePower);
System.out.println("Car speed (" + timeSec + " sec) = " +
vehicle.getSpeedMph(timeSec) + " mph");
覆盖的方法是动态绑定的,这意味着方法调用的上下文由实际引用的对象类型决定。由于在我们的例子中,引用vehicle指向子类Car的对象,因此vehicle.getSpeedMph()构造调用的是子类的方法,而不是基类的方法。
在这两个新方法中存在明显的代码冗余,我们可以通过在基类中创建一个方法来重构,即Vehicle:
protected double getSpeedMph(double timeSec, int weightPounds) {
double v = 2.0 * this.horsePower * 746;
v = v * timeSec * 32.174 / weightPounds;
return Math.round(Math.sqrt(v) * 0.68);
}
由于这个方法只被子类使用,它可以被声明为受保护的(因此,只能由子类访问)。
现在子类Car的getSpeedMph()方法将如下所示:
public double getSpeedMph(double timeSec) {
int weightPounds = this.weightPounds + this.passengersCount * 250;
return getSpeedMph(timeSec, weightPounds);
}
这就是它在Truck子类中的样子:
public double getSpeedMph(double timeSec) {
int weightPounds = this.weightPounds + this.payload;
return getSpeedMph(timeSec, weightPounds);
}
现在我们需要通过添加强制类型转换来修改测试类。否则,由于基类Vehicle中没有getSpeedMph(int 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;
v = v * timeSec * 32.174 / 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()方法中实现的。如果我们需要使用不同的速度模型(例如,它包括更多的输入参数,并且更适应某些驾驶条件),我们需要更改Vehicle类或创建一个新的子类来覆盖该方法。当我们需要实验数十个甚至数百个不同的模型时,这种方法变得不可行。
此外,在现实生活中,基于机器学习和其他高级技术的建模变得如此复杂和专业,以至于汽车加速的建模通常由一个不同的团队来完成,而不是构建车辆的团队。
为了避免车辆构建者和速度模型开发者之间子类过多和代码合并冲突,我们可以通过组合来创建一个更可扩展的设计。
组合是实现必要功能的一种 OOD 原则,使用的是不属于继承层次结构的类的行为。
我们可以在SpeedModel类的getSpeedMph()方法中封装速度计算:
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;
v = v * timeSec * 32.174 / 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层次结构中的任何类。这就是组合设计原则如何允许你更改Vehicle类及其子类的行为,而无需更改它们的实现。
在下一个菜谱中,我们将展示 OOD 概念接口如何解锁组合和多态的更多功能,使设计更简单,甚至更具表现力。
参见
参考本章中的以下菜谱:
-
面向接口的编码
-
使用枚举来表示常量实体
面向接口的编码
在这个菜谱中,你将了解 OOD 概念中的最后一个,称为接口,并进一步练习组合和多态的使用,以及内部类和继承。
准备工作
在这种情况下,接口是一种引用类型,它定义了实现该接口的类可以期望看到的方法签名。它是客户端可访问的功能的公共面孔,因此通常被称为应用程序程序接口(API)。它支持多态和组合,从而促进了更灵活和可扩展的设计。
接口是隐式抽象的,这意味着它不能被实例化(不能仅基于接口创建对象)。它只用于包含抽象方法(没有方法体)。现在,自从 Java 8 以来,可以向接口添加默认和私有方法--我们将在接下来的菜谱中讨论这一功能。
每个接口可以扩展多个其他接口,并且类似于类继承,继承扩展接口的所有方法。
如何做...
- 创建描述 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();
}
-
使用工厂,这些是生成实现特定接口的对象的类。工厂是创建对象模式的实现,无需指定创建的对象的确切类——只需指定接口,而不是调用构造函数。当对象实例的创建需要复杂的过程和/或大量的代码重复时,这特别有帮助。
在我们的情况下,有一个
FactoryVehicle类来为Vehicle、Car和Truck接口创建对象,以及一个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");
}
- 注意代码行为是相同的:

然而,设计要灵活得多。
工作原理...
我们已经看到了SpeedModel类的一个可能的实现。这里是在FactorySpeedModel类内部实现它的另一种方法:
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){...}
}
private class SpeedModelDry implements SpeedModel{
public double getSpeedMph(double timeSec, int weightPounds,
int horsePower){...}
}
}
为了简洁,我们用注释(作为伪代码)和...符号代替了代码。
如您所见,工厂类可以隐藏许多不同的私有和静态嵌套类,每个类都包含特定驾驶条件下的专用模型。每个模型都会带来不同的结果。
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);
}
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;
}
}
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; }
}
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类可能看起来像这样:
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;
}
}
这个类实现了两个接口——Car和Truck——并将车辆的总重量、载货和乘客及其行李传递给基类构造函数。
我们还可以向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 首次引入的两个新功能:接口中的默认和静态方法。
准备中
默认方法允许你在不更改实现该接口的类的情况下向接口添加新功能。这个方法被称为默认,因为它在类没有实现方法时提供功能。然而,如果类实现了它,接口的默认实现就会被忽略,并被类实现覆盖。
接口中的静态方法可以提供与类中的静态方法相同的功能。与类静态方法(可以在没有类实例化的情况下调用)一样,接口静态方法也可以通过在前面添加接口名称来调用。
静态接口方法不能被任何类覆盖,包括实现该接口的类,它也不能隐藏任何类的静态方法,包括实现该接口的类。
到目前为止,我们已经创建了一个令人惊叹的软件,它可以计算车辆的行驶速度。如果这个程序变得流行(正如它应该的那样),它就可以被那些喜欢使用重量单位公制系统的读者使用。为了满足这种需求——在我们计算速度的软件变得流行之后——我们决定向Truck接口添加更多方法;然而,我们不想破坏由其他公司创建的FactoryVehicle的现有实现。
默认接口方法就是为了这种情况而引入的。使用它们,我们可以在不需要与FactoryVehicle的开发协调的情况下发布接口的新版本。
如何做到这一点...
- 通过添加
getPayloadKg()方法来增强Truck接口,该方法返回以千克为单位的车辆载重。你可以通过向FactoryVehicle内部实现Truck接口的TruckImpl类添加新的默认方法来实现这一点,而不必强制改变该类:
public interface Truck extends Vehicle {
int getPayloadPounds();
default int getPayloadKg(){
return (int) Math.round(0.454 * getPayloadPounds());
}
}
注意新方法getPayloadKg()是如何像后者在接口内部实现一样使用现有的getPayloadPounds()方法的,尽管实际上它是FactoryVehicle内部的一个类实现的。这种魔法发生在运行时,当这个方法动态绑定到实现该接口的类的实例时。
我们不能使getPayloadKg()方法成为静态的,因为它将无法访问非静态的getPayloadPounds()方法,我们必须使用default关键字,因为只有接口的默认或静态方法可以有主体。
- 编写使用新方法的演示代码:
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());
}
- 运行前面的程序并查看输出:

-
注意,这个新方法即使在没有更改实现它的类的情况下也能工作。
-
当你决定改进
FactoryVehicle类的实现时,你可以通过添加相应的方法来实现,例如:
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; }
}
我们实现了return -2的版本,以便使使用哪个实现变得明显。
- 运行相同的演示程序。结果将如下所示:

如您所见,TruckImpl类中的方法已覆盖了Truck接口中的默认实现。
- 在不改变
FactoryVehicle实现的情况下,增强Truck接口以能够以千克为单位输入载重。但我们还希望Truck实现保持不可变,并且我们不希望添加 setter 方法。在这些限制下,我们唯一的办法是在Truck接口中添加convertKgToPounds(),并且它必须是static的,因为我们将在实现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 中引入的新功能:私有接口方法,它有两种类型,即静态和非静态。
准备工作
接口中的私有方法是在 Java 9 中引入的。它们允许您使接口方法(具有正文)仅对同一接口中的其他方法(具有正文)可访问。
接口中的私有方法在任何地方都不能被覆盖--既不能被任何接口的方法覆盖,也不能被任何类中的方法覆盖。它的唯一目的是包含两个或多个方法(无论是私有还是公共)之间的共同功能,这些方法在同一个接口中具有正文。它也可以仅由一个方法使用,以便使代码更容易理解。
私有接口方法必须有一个实现。同一接口中未使用其他方法的私有接口方法没有意义。
静态私有接口方法可以被同一接口的非静态和静态方法访问。非静态私有接口方法只能被同一接口的静态方法访问。
如何操作...
- 还要添加
getWeightKg()实现。由于接口中没有getWeightPounds()方法,方法签名应包括一个输入参数:
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);
}
}
- 使用私有接口方法删除冗余代码:
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) 方法接受输入参数,方法名可能会误导,因为(与 getPayloadKg() 相比)该方法不控制数据来源。输入值可以代表任何东西。意识到这一点后,我们决定接口最好没有它,但只有在将 convertPoundsToKg() 方法公开之后。由于它不需要访问对象元素,它也可以是静态的:
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));
}
结果没有变化:

参见
参考本章以下食谱:
- 创建具有默认和静态方法的接口
使用枚举表示常量实体
在这个食谱中,你将了解允许你创建具有预定义值的自定义类型的特殊数据类型 enum。
准备工作
让我们快速看一下几个例子。这里是最简单的 enum 类型:
public enum RoadCondition {
DRY, WET, SNOW
}
假设我们运行以下循环:
for(RoadCondition v: RoadCondition.values()){
System.out.println(v);
}
这将产生以下结果:

enum 类型隐式扩展 java.util.Enum(因此你不能扩展你自定义的类型 RoadCondition,例如)并自动获得其方法。在前面的代码中,我们已经看到了一个(最有用的)方法,values(),它返回 enum 元素的数组。
另一个有用的 enum 方法是 valueOf(String),它返回具有指定名称(作为 String 传入)的指定 enum 类型的常量。
每个元素的方法包括有用的方法,例如 equals()、name() 和 ordinal()。现在假设我们运行以下循环:
for(RoadCondition v: RoadCondition.values()){
System.out.println(v.ordinal());
System.out.println(v.name());
System.out.println(RoadCondition.SNOW.equals(v));
}
我们将得到以下结果:

如你所猜,ordinal() 返回值在 enum 声明中的位置,从零开始。
其他 enum 功能包括以下内容:
-
enum类体可以包含构造函数、方法、变量和常量 -
当
enum第一次被访问时,构造函数会自动调用 -
静态常量可以通过类名访问
-
非静态常量可以通过任何元素访问
Java 包含几个预定义的 enum 类型,包括 DayOfWeek 和 Month。
为了让它看起来更像现实生活中的 enum,让我们使用牵引系数作为元素值。这样的系数反映了道路和轮胎之间的牵引程度。值越高,牵引力越好。这个值可以用于改进车辆速度计算。例如,SpeedModel 应该能够接收 RoadCondition.WET 并从中提取牵引系数 0.2。
如何做到这一点...
- 在
SpeedModel接口中定义RoadCondition枚举(因为它属于SpeedModelAPI):
public interface SpeedModel {
double getSpeedMph(double timeSec, int weightPounds,
int horsePower);
enum RoadCondition {
DRY(1.0), WET(0.2), SNOW(0.04);
private double traction;
RoadCondition(double traction){ this.traction = traction;}
public double getTraction(){ return this.traction; }
}
}
在接口中,无需在 enum RoadCondition 前添加 public 关键字,因为默认情况下它就是 public(除非使用 private 关键字代替)。
- 运行以下循环:
for(RoadCondition v: RoadCondition.values()){
System.out.println(v + " => " + v.getTraction());
}
- 你将得到以下结果:

- 将
enum TireCondition也添加到iSpeedModel接口:
enum TireCondition {
NEW(1.0), WORN(0.2);
private double traction;
TireCondition(double traction){ this.traction = traction; }
public double getTraction(){return this.traction;}
}
- 为
DrivingCondition添加enum:
enum DrivingCondition {
ROAD_CONDITION, TIRE_CONDITION
}
它是如何工作的...
在有了三个 enum 类型之后,我们可以计算给定驾驶条件所有可能组合的车辆速度:
public static void main(String... arg) {
double timeSec = 10.0;
String[] roadConditions = { RoadCondition.WET.toString(),
RoadCondition.SNOW.toString() };
String[] tireConditions = { TireCondition.NEW.toString(),
TireCondition.WORN.toString() };
for(String rc: roadConditions){
for(String tc: tireConditions){
Properties drivingCond = new Properties();
drivingCond.put(DrivingCondition
.ROAD_CONDITION.toString(), rc);
drivingCond.put(DrivingCondition
.TIRE_CONDITION.toString(), tc);
SpeedModel speedModel = FactorySpeedModel
.generateSpeedModel(drivingCond);
Car car = FactoryVehicle.buildCar(4, 4000, 246);
car.setSpeedModel(speedModel);
System.out.println("Car speed (" + timeSec + " sec) = " +
car.getSpeedMph(timeSec) + " mph");
}
}
}
结果将如下所示:

这就是 enum 如何允许你定义 API 输入并使输入数据验证成为不必要的。
还有更多...
温度越高,道路越快干燥,牵引系数也越高。为了考虑这一点,我们可以在 enum RoadCondition 中添加一个 temperature 属性,并覆盖 WET 元素的 getTraction() 方法,例如:
enum RoadCondition {
public int temperature;
DRY(1.0),
WET(0.2) {
public double getTraction(){
RoadCondition return temperature > 60 ? 0.4 : 0.2
}
},
SNOW(0.04);
private double traction;
RoadCondition(double traction){this.traction = traction; }
public double getTraction(){ return this.traction; }
}
现在,我们可以在速度计算之前在 RoadCondition 上设置 temperature 属性,并得到湿路条件下的不同速度值。在调用速度计算之前添加此行:
RoadCondition.temperature = 63;
如果你这样做,结果将如下所示:

使用 @Deprecated 注解来弃用 API
在这个菜谱中,你将了解 API 元素的弃用以及 Java 9 中 @Deprecated 注解的增强。
准备工作
@Deprecated 注解首次在 Java 5 中引入,而 Javadoc 标签 @deprecated 在 Java 中更早被引入。该注解的存在会强制编译器生成一个警告,该警告可以通过注解来抑制:
@SuppressWarnings("deprecation")
自从 Java 9 以来,注解可以有一个或两个方法,即 since() 和 forRemoval():
@Deprecated(since = "2.1", forRemoval = true)
since() 方法允许设置 API 版本(作为 String)。它描述了特定类或方法被弃用的 API 版本。如果没有指定,since() 方法的默认值是 ""(空 String)。
forRemoval() 方法描述了移除标记元素的意图(如果为 true)或不是(如果为 false)。如果没有设置,默认值是 false。如果 forRemoval() 方法存在且值为 true 以抑制警告,则需要指定以下内容:
@SuppressWarnings("removal")
如何操作...
- 再次看看
Car接口:
public interface Truck extends Vehicle {
int getPayloadPounds();
}
- 代替
getPayloadKg()方法,引入一个更通用的方法和enum以支持重量单位的公制系统:
int getPayload(WeigthUnit weightUnit);
enum WeigthUnit { Pound, Kilogram }
这样的增强使得你在未来有更多的灵活性。
- 弃用
getPayloadPounds()方法并添加带有解释的 Javadoc:
/**
* Returns the payload of the truck.
*
* @return the payload of the truck in the specified units
* of weight measurements
* @deprecated As of API 2.1, to avoid adding methods
* for each possible weight unit,
* use {@link #getPayload(WeigthUnit weightUnit)} instead.
*/
@Deprecated(since = "2.1", forRemoval = true)
int getPayloadPounds();
@Deprecated 注解中的每个方法都是可选的。
它是如何工作的...
如果我们编译前面的代码,在其实现的每个地方都会有一个警告此方法已被弃用并标记为删除。
如果forRemoval()方法不存在或设置为false,警告信息只会说已被弃用。
为了避免警告,我们需要避免使用已弃用的方法或如前所述抑制警告。
更多内容...
在 Java 9 中,Javadoc 标签@deprecated和@Deprecated注解都是必需的。只有其中一个存在被视为错误。
在 Javadocs 中使用 HTML5
在这个菜谱中,你将了解在 Java 9 中 Javadoc 注释中 HTML5 标签的使用。
准备工作
互联网上有许多资源描述了 HTML5 标签。自从 Java 9 以来,可以在 Javadoc 注释中使用其中任何一个。
HTML5 提供了更好的浏览器兼容性。它也比其前身 HTML4 更易于移动设备使用。但为了利用 HTML5,必须在生成 Javadoc 时指定-html5参数。否则,将只继续支持 HTM4 风格的注释。
如何操作...
这里是用于 Javadoc 注释的 HTML5 标签的示例:
/**
<h2>Returns the weight of the car.</h2>
<article>
<h3>If life would be often that easy</h3>
<p>
Do you include unit of measurement into the method name or not?
</p>
<p>
The new signature demonstrates extensible design of an interface.
</p>
</article>
<aside>
<p> A few other examples could be found
<a href="http://www.nicksamoylov.com/cat/programming/">here</a>.
</p>
</aside>
* @param weightUnit - an element of the enum Car.WeightUnit
* @return the weight of the car in the specified units of weight
*/
int getMaxWeight(WeigthUnit weightUnit);
如果你正在使用 IntelliJ IDEA,请转到工具 | 生成 JavaDoc... 并在其他命令行参数字段中设置-html5值,然后点击确定。如果没有 IDE,请使用以下命令:
javadoc [options] [packagenames] [sourcefiles] [@files]
考虑以下示例:

这里,src是包含com子文件夹中源代码的文件夹,而api是当前目录中将要存储 Javadoc 的文件夹。com.cookbook.oop是你想要生成 Javadoc 的包。
生成的 Javadoc 将看起来像这样:

上述描述是从<h2>标签内的文本中提取的,但它的字体与其他行没有区别。完整的描述将如下所示:

你可以看到标签<h2>和<h3>中的文本是如何突出显示的,以及here链接是如何被突出显示并可以点击的。
第三章:模块化编程
在本章中,我们将介绍以下食谱:
-
使用 jdeps 在 Java 应用程序中查找依赖项
-
创建一个简单的模块化应用程序
-
创建模块化 JAR 文件
-
在预 JDK 9 应用程序中使用模块 JAR 文件
-
自底向上的迁移
-
自顶向下的迁移
-
使用服务在消费者模块和提供者模块之间创建松耦合
-
使用 jlink 创建自定义模块化运行时映像
-
为旧平台版本编译
-
创建多版本 JAR 文件
-
使用 Maven 开发模块化应用程序
简介
模块化编程使人们能够将代码组织成独立的、凝聚的模块,这些模块可以组合在一起以实现所需的功能。这允许创建以下代码:
-
更具凝聚力,因为模块是针对特定目的构建的,所以驻留在那里的代码往往是为了满足那个特定目的。
-
封装的,因为模块只能与那些由其他模块提供的 API 交互。
-
可靠的,因为可发现性是基于模块而不是基于单个类型。这意味着如果某个模块不存在,那么依赖模块将无法执行,直到它被依赖模块发现。这有助于防止运行时错误。
-
松耦合。如果你使用服务接口,那么模块接口和服务接口实现可以松耦合。
因此,在设计和组织代码时的思维过程现在将涉及识别模块、代码和配置文件,这些文件将进入模块,以及代码在模块内组织的包。之后,我们必须决定模块的公共 API,从而使它们可供依赖模块使用。
说到 Java 平台模块系统的开发,它正由Java 规范请求(JSR)376(www.jcp.org/en/jsr/detail?id=376)管理。JSR 提到模块系统的需求是为了解决以下基本问题:
-
可靠的配置:开发者长期以来一直遭受脆弱、易出错的类路径机制来配置程序组件。类路径不能表达组件之间的关系,因此如果必要的组件缺失,那么它将不会被发现,直到尝试使用它。类路径还允许从不同的组件中加载同一包中的类,导致不可预测的行为和难以诊断的错误。所提出的规范将允许一个组件声明它依赖于其他组件,就像其他组件依赖于它一样。
-
强封装:Java 编程语言和 JVM 的访问控制机制没有提供任何方法来阻止一个组件防止其他组件访问其内部包。所提出的规范将允许一个组件声明其可以被其他组件访问的包以及那些不可以被访问的包。
JSR 还进一步列出了解决上述问题所带来的优势,如下所示:
-
可扩展的平台:Java SE 平台的大小不断增加,使得在小型设备上使用变得越来越困难,尽管许多此类设备能够运行 SE 类 JVM。Java SE 8(JSR 337)中引入的紧凑配置有助于解决这个问题,但它们远不够灵活。拟议的规范将允许 Java SE 平台及其实现被分解成一组组件,开发者可以将这些组件组装成自定义配置,其中只包含应用程序实际需要的功能。
-
更高的平台完整性:对 Java SE 平台实现内部 API 的随意使用既是安全风险也是维护负担。拟议的规范提供的强大封装将允许实现 Java SE 平台的组件防止对其内部 API 的访问。
-
改进的性能:当知道一个类只能引用几个其他特定组件中的类,而不是运行时加载的任何类时,许多提前时间、整个程序优化技术可以更有效。当应用程序的组件可以与实现 Java SE 平台的组件一起优化时,性能尤其得到提升。
在本章中,我们将探讨一些重要的食谱,这些食谱将帮助您开始模块化编程。
使用 jdeps 在 Java 应用程序中查找依赖项
将您的应用程序模块化的第一步是确定其依赖项。在 JDK 8 中引入了一个名为 jdeps 的静态分析工具,以使开发者能够找到其应用程序的依赖项。命令支持多个选项,这使开发者能够检查对 JDK 内部 API 的依赖,显示包级别的依赖,显示类级别的依赖,以及过滤依赖,等等。
在本食谱中,我们将探讨如何通过探索其功能和使用它支持的多个命令行选项来利用 jdeps 工具。
准备工作
我们需要一个示例应用程序,我们可以运行 jdeps 命令来找到其依赖项。因此,我们想到了创建一个非常简单的应用程序,该应用程序使用 Jackson API 从 REST API 消费 JSON:jsonplaceholder.typicode.com/users。
在示例代码中,我们还添加了对已弃用的 JDK 内部 API 的调用,该 API 被称为 sun.reflect.Reflection.getCallerClass()。这样,我们可以看到 jdeps 如何帮助找到对 JDK 内部 API 的依赖。
以下步骤将帮助您设置本食谱的先决条件:
-
你可以从位置
chp3/1_json-jackson-sample获取示例的完整代码。我们针对 Java 9 构建了此代码,并且它可以很好地编译。因此,您不需要安装除 Java 9 以外的任何东西来编译它。 -
一旦你有代码,就可以使用以下命令编译它:
#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
你将看到一个关于使用内部 API 的警告,你可以安全地忽略它。我们添加这个是为了演示 jdeps 的能力。现在,你应该已经在 classes 目录中有编译好的类文件。
- 你可以使用以下命令运行示例程序:
#On Linux:
java -cp lib/*:classes/ com.packt.Sample
#On Windows:
java -cp lib\*;classes com.packt.Sample
-
我们在
chp3/1_json-jackson-sample提供了run.bat和run.sh脚本。您也可以使用这些脚本进行编译和运行。 -
让我们也为这个示例创建一个 JAR 文件,这样我们就可以在 JAR 文件上运行
jdeps:
jar cvfm sample.jar manifest.mf -C classes .
在当前目录下创建了一个 sample.jar 文件。您也可以通过以下命令运行 JAR:java -jar sample.jar。
如何做到这一点...
- 使用
jdeps的最简单方法是如下所示:
jdeps -cp classes/:lib/* classes/com/packt/Sample.class
前面的命令等同于以下命令:
jdeps -verbose:package -cp classes/:lib/*
classes/com/packt/Sample.class
前面代码的输出如下:

在前面的命令中,我们使用 jdeps 列出类文件 Sample.class 在包级别的依赖关系。我们必须为 jdeps 提供搜索正在分析的代码依赖关系的路径。这可以通过设置 jdeps 命令的 -classpath、-cp 或 --class-path 选项来完成。
-verbose:package 选项列出包级别的依赖关系。
- 让我们列出类级别的依赖关系:
jdeps -verbose:class -cp classes/:lib/* classes/com/packt/Sample.class
前面命令的输出如下:

在这种情况下,我们使用了 -verbose:class 选项来列出类级别的依赖关系,这就是为什么你可以看到 com.packt.Sample 类依赖于 com.packt.model.Company、java.lang.Exception、com.fasterxml.jackson.core.type.TypeReference 等原因。
- 让我们获取依赖关系的摘要:
jdeps -summary -cp classes/:lib/* classes/com/packt/Sample.class
输出如下:

- 让我们检查对 JDK 内部 API 的依赖性:
jdeps -jdkinternals -cp classes/:lib/*
classes/com/packt/Sample.class
前面命令的输出如下:

StackWalker API 是 Java 9 中引入的用于遍历调用栈的新 API。这是 sun.reflect.Reflection.getCallerClass() 方法的替代品。我们将在第十二章,内存管理和调试中讨论这个 API。
- 让我们在 JAR 文件
sample.jar上运行jdeps:
jdeps -s -cp lib/* sample.jar
我们得到的结果如下:

使用 jdeps 检查 sample.jar 后得到的信息非常有用。它清楚地说明了我们的 JAR 文件的依赖关系,当我们尝试将此应用程序迁移到模块化应用程序时非常有用。
- 让我们查找是否有对给定包名的依赖关系:
jdeps -p java.util sample.jar
输出如下:

-p选项用于查找给定包名的依赖。因此,我们得知我们的代码依赖于java.util包。让我们尝试使用其他包名:
jdeps -p java.util.concurrent sample.jar
没有输出,这意味着我们的代码不依赖于java.util.concurrent包。
- 我们只想为我们的代码运行依赖性检查。是的,这是可能的。假设我们运行
jdeps -cp lib/* sample.jar,你会看到甚至库 JAR 文件也被分析了。我们不想这样,对吧?让我们只包含com.packt包的类:
jdeps -include 'com.packt.*' -cp lib/* sample.jar
输出如下:

- 让我们检查我们的代码是否依赖于特定的包:
jdeps -p 'com.packt.model' sample.jar
输出如下:

- 我们可以使用
jdeps来分析 JDK 模块。让我们选择java.httpclient模块进行分析:
jdeps -m java.httpclient
下面是输出结果:

我们也可以使用--require选项来查找给定模块是否依赖于另一个模块,如下所示:
jdeps --require java.logging -m java.sql
下面是输出结果:

在前面的命令中,我们试图找出java.sql模块是否依赖于java.logging模块。我们得到的输出是java.sql模块及其使用java.logging模块代码的包的依赖摘要。
它是如何工作的...
jdeps命令是一个静态类依赖分析器,用于分析应用程序及其库的静态依赖。默认情况下,jdeps命令显示输入文件的包级别依赖,这些可以是.class文件、目录或 JAR 文件。这是可配置的,可以更改以显示类级别依赖。有多个选项可用于过滤依赖并指定要分析的类文件。我们已经看到了-cp命令行选项的常规使用。此选项用于提供搜索分析代码依赖的位置。
我们已经分析了类文件、JAR 文件和 JDK 模块,我们还尝试了jdeps命令的不同选项。有几个选项,如-e、-regex、--regex、-f、--filter和-include,它们接受正则表达式(regex)。理解jdeps命令的输出是很重要的。对于每个正在分析的类/JAR 文件,有两部分信息:
- 分析文件的依赖摘要(JAR 或类文件)。这包括左侧的类或 JAR 文件名称和右侧的依赖实体名称。依赖实体可以是目录、JAR 文件或 JDK 模块,如下所示:
Sample.class -> classes
Sample.class -> lib/jackson-core-2.8.4.jar
Sample.class -> lib/jackson-databind-2.8.4.jar
Sample.class -> java.base
Sample.class -> jdk.unsupported
- 在包或类级别(根据命令行选项)上,对分析文件内容的更详细依赖信息(取决于命令行选项)。这由三列组成:第 1 列包含包/类的名称,第 2 列包含依赖包的名称,第 3 列包含找到依赖项的模块/JAR 的名称。一个示例输出如下:
com.packt -> com.fasterxml.jackson.core.type jackson-core-2.8.4.jar
com.packt -> com.fasterxml.jackson.databind jackson-databind-
2.8.4.jar
com.packt -> com.packt.model sample.jar
更多内容...
我们已经看到了jdeps命令的许多选项。还有一些与过滤依赖项和过滤要分析的类相关的选项。除此之外,还有一些与模块路径相关的选项。
以下是可以尝试的选项:
-
-e,-regex,--regex:这些查找与给定模式匹配的依赖项。 -
-f,-filter:这些排除与给定模式匹配的依赖项。 -
-filter:none:这允许不通过filter:package或filter:archive应用过滤。 -
-filter:package:这排除同一包内的依赖项。这是默认选项。例如,如果我们向jdeps sample.jar添加了-filter:none,它将打印出包对自己的依赖项。 -
-filter:archive:这排除同一存档内的依赖项。 -
-filter:module:这排除同一模块中的依赖项。 -
-P,-profile:这用于显示包的配置文件,无论是 compact1、compact2、compact3 还是 Full JRE。 -
-R,-recursive:这些递归遍历所有运行时依赖项;它们与-filter:none选项等价。
创建一个简单的模块化应用程序
你可能正在想这个模块化是什么意思,以及如何在 Java 中创建一个模块化应用程序。在这个菜谱中,我们将通过一个简单的示例来帮助你解开在 Java 中创建模块化应用程序的神秘面纱。我们的目标是向你展示如何创建一个模块化应用程序;因此,我们选择了一个简单的例子,以便专注于我们的目标。
我们的例子是一个简单的先进计算器,它可以检查一个数字是否为素数,计算素数的和,检查一个数字是否为偶数,并计算偶数和奇数的和。
准备工作
我们将把我们的应用程序分为两个模块:
-
math.util模块,它包含执行数学计算的 API -
calculator模块,它启动一个高级计算器
如何做到这一点...
- 让我们在
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 );
}
- 下一步是实现
sumOfFirstNPrimes(Integer count)API:
public static Integer sumOfFirstNPrimes(Integer count){
return IntStream.iterate(1,i -> i+1)
.filter(j -> isPrime(j))
.limit(count).sum();
}
- 让我们编写一个函数来检查数字是否为偶数:
public static Boolean isEven(Integer number){
return number % 2 == 0;
}
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 并将这些操作提取到一个方法中,如下所示:
private static 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类的使用
如果你确实在疑惑,那么你不必担心,因为我们在第四章 功能化 和第五章 流操作和管道 中将涵盖这些内容。Chapter 4, 功能化 和 Chapter 5, 流操作和管道。
到目前为止,我们已经看到了一些数学计算的 API。这些 API 是我们 com.packt.math.MathUtil 类的一部分。这个类的完整代码可以在代码库中找到,位置是 chp3/2_simple-modular-math-util/math.util/com/packt/math,这是为这本书下载的代码库。
让我们将这个小实用类模块化为一个名为 math.util 的模块。以下是我们创建模块时使用的某些约定:
-
将与模块相关的所有代码放置在一个名为
math.util的目录下,并将其视为我们的模块根目录。 -
在根文件夹中,放置一个名为
module-info.java的文件。 -
然后,我们将包和代码文件放置在根目录下。
module-info.java 包含什么内容?
-
模块的名字
-
它导出的包,即,使其可供其他模块使用
-
它依赖的模块
-
它使用的服务
-
它提供的实现服务
如第一章 Chapter 1 安装和 Java 9 漫游 中所述,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 的模块。
这个模块的代码可以在位置 chp3/2_simple-modular-math-util/math.util 找到。
现在,让我们创建另一个名为 calculator 的模块,它使用 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 类的完整代码可以在 chp3/2_simple-modular-math-util/calculator/com/packt/calculator/Calculator.java 找到。
让我们以创建 math.util 模块相同的方式创建我们的 calculator 模块的模块定义:
module calculator{
requires math.util;
}
在前面的模块定义中,我们提到 calculator 模块通过使用关键字 required 依赖于 math.util 模块。
该模块的代码可以在 chp3/2_simple-modular-math-util/calculator 找到。
现在让我们编译代码:
javac -d mods --module-source-path . $(find . -name "*.java")
必须在 chp3/2_simple-modular-math-util 目录下执行前面的命令。
此外,您应该在 mods 目录中拥有来自两个模块的编译代码,即 math.util 和 calculator。这难道不是很简单吗?只需一个命令,包括模块之间的依赖关系,编译器就全部处理了。我们不需要像 ant 这样的构建工具来管理模块的编译。
--module-source-path 命令是 javac 的新命令行选项,用于指定我们的模块源代码的位置。
现在,让我们执行前面的代码:
java --module-path mods -m calculator/com.packt.calculator.Calculator
--module-path 命令,类似于 --classpath,是 java 的新命令行选项,用于指定编译模块的位置。
执行前面的命令后,您将看到计算器正在运行:

恭喜!有了这个,我们有一个简单的模块化应用程序正在运行。
我们提供了在 Windows 和 Linux 平台上测试代码的脚本。请使用 run.bat 用于 Windows 和 run.sh 用于 Linux。
它是如何工作的...
现在您已经通过了这个例子,我们将看看如何将其推广,以便我们可以在所有模块中应用相同的模式。我们遵循了特定的约定来创建模块:
|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} }
下面是语法说明:
-
{注释}: 这是指任何形式的注释@注释(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}可以是 transitive、static 或两者都是。传递性意味着任何依赖于给定模块的模块也隐式地依赖于给定模块所要求的模块。静态意味着模块依赖在编译时是强制性的,但在运行时是可选的。一些例子是requires math.util、requires 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: 这用于声明一个服务接口并提供至少一个实现。服务接口可以在当前模块中声明,也可以在任何其他依赖模块中声明。然而,服务实现必须在同一模块中提供;否则,将发生编译时错误。
我们将在我们的配方中更详细地查看 uses 和 provides 子句,使用服务在消费者模块和提供者模块之间创建松耦合。
所有模块的模块源可以一次使用 --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 9 的预览,其中我们尝试运行另一个模块化应用程序。
创建模块化 JAR 文件
将模块编译成类是好的,但并不适合共享二进制文件和部署。JAR 文件是更好的共享和部署格式。我们可以将编译好的模块打包成 JAR 文件,其中包含顶层module-info.class的 JAR 文件被称为模块化 JAR 文件。在本配方中,我们将探讨如何创建模块化 JAR 文件,同时也会探讨如何执行由多个模块化 JAR 文件组成的应用程序。
准备工作
我们在配方中看到了并创建了一个简单的模块化应用程序,创建一个更简单的模块化应用程序。为了构建模块化 JAR 文件,我们将利用在chp3/3_modular_jar可用的示例代码。这个示例代码包含两个模块:math.util和calculator。我们将为这两个模块创建模块化 JAR 文件。
如何操作...
- 编译代码并将编译后的类放置在目录中,例如
mods:
javac -d mods --module-source-path . $(find . -name *.java)
- 为
math.util模块构建一个模块化 JAR 文件:
jar --create --file=mlib/math.util@1.0.jar --module-version 1.0
-C mods/math.util .
不要忘记在前面代码的末尾加上点(.)。
- 为
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 文件。
- 现在我们已经在
mlib目录中有两个 JAR 文件:math.util@1.0.jar和calculator@1.0.jar。这些 JAR 文件被称为模块化 JAR 文件。如果您想运行示例,可以使用以下命令:
java -p mlib -m calculator
Java 9 中引入了 JAR 命令的新命令行选项,称为-d或--describe-module。这会打印出模块化 JAR 文件包含的模块信息:
jar -d --file=mlib/calculator@1.0.jar
jar -d对calculator@1.0.jar的输出如下:
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
jar -d对math.util@1.0.jar的输出如下:
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
您必须按照列出的顺序运行脚本。
在 JDK 9 之前的应用程序中使用模块化 JAR 文件
如果我们的模块化 JAR 可以与 JDK 9 之前的应用程序一起运行,那将非常神奇。这样,我们就无需再为 JDK 9 之前的应用程序编写另一个版本的 API。好消息是,我们可以像使用普通 JAR 一样使用我们的模块化 JAR,也就是说,根目录下没有 module-info.class 的 JAR。我们将在本食谱中看到如何做到这一点。
准备工作
对于这个食谱,我们需要一个模块化 JAR 和一个非模块化应用程序。我们的模块化代码可以在 chp3/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 .
我们还在 chp3/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 类借用代码。
您可以在 chp3/4_modular_jar_with_pre_java9/calculator 目录下的 com.packt.calculator 包中找到 NonModularCalculator 类的定义。由于它不是模块化的,它不需要 module-info.java 文件。此应用程序使用我们的模块化 JAR math.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 与非模块化应用程序一起使用。太神奇了,对吧!
我们在 chp3/4_modular_jar_with_pre_java9 提供了以下脚本,用于在 Windows 平台上运行代码:
-
compile-calculator.bat -
run.bat
参见
我们建议您尝试以下食谱:
-
创建一个简单的模块化应用程序
-
创建一个模块化 JAR
自下而上的迁移
现在,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 文件名,因此与未命名模块不同。或者,可以通过在 JAR 清单文件中提供Automatic-Module-Name来为这些自动模块提供名称。这些自动模块导出其包含的所有包,并且依赖于所有自动和命名的(用户/JDK)模块。
根据这个解释,模块可以分为以下几类:
-
未命名模块:位于类路径上但不在模块路径上的代码被放置在未命名模块中
-
命名模块:所有与名称相关联的模块——这可以是用户定义的模块和 JDK 模块。
-
自动模块:所有由 JVM 根据模块路径中存在的 jar 文件隐式创建的模块
-
隐式模块:隐式创建的模块。它们与自动模块相同
-
显式模块:所有由用户或 JDK 显式创建的模块。
但未命名模块和自动模块是开始迁移的良好第一步。所以让我们开始吧!
准备工作
我们需要一个非模块化应用程序,我们最终将对其进行模块化。我们已创建了一个简单的应用程序,其源代码位于位置chp3/6_bottom_up_migration_before。这个简单应用程序有 3 个部分:
-
一个包含我们最喜欢的数学 API 的数学实用程序库,即:素数检查器、偶数检查器、素数之和、偶数之和和奇数之和。此代码位于位置
chp3/6_bottom_up_migration_before/math_util。 -
一个包含计算简单利息和复利 API 的银行实用程序库。此代码位于位置
chp3/6_bottom_up_migration_before/banking_util。 -
我们的计算器应用程序,它帮助我们进行数学和银行计算。为了使它更有趣,我们将结果输出为 JSON,为此,我们将使用 Jackson JSON API。此代码位于位置
chp3/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 JAR 位于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-*.sh和run.sh在 Linux 上打包和运行应用程序。
如何操作...
将您的应用程序模块化的第一步是理解其依赖图。让我们为我们的应用程序创建一个依赖图。为此,我们使用jdeps工具。如果您想知道jdeps工具是什么,请立即停止并阅读配方:使用 jdeps 在 Java 应用程序中查找依赖关系。好的,那么让我们运行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.xml、java.sql、java.base和java.logging已经进行了模块化。让我们选择模块化banking.util.jar。
本食谱的所有代码都可在位置chp3/6_bottom_up_migration_after找到。
模块化banking.util.jar
-
将
BankUtil.java从chp3/6_bottom_up_migration_before/banking_util/src/com/packt/banking复制到位置chp3/6_bottom_up_migration_after/src/banking.util/com/packt/banking。请注意以下两点:-
我们已将文件夹从
banking_util重命名为banking.util。这是为了遵循将模块相关代码放置在以模块名称命名的文件夹下的约定。 -
我们将包直接放置在
banking.util文件夹下,而不是在src文件夹下,这再次是为了遵循约定。我们还将把所有模块放置在src文件夹下。
-
-
在
chp3/6_bottom_up_migration_after/src/banking.util下创建模块定义文件module-info.java,内容如下:
module banking.util{
exports com.packt.banking;
}
- 在
6_bottom_up_migration_after文件夹内,通过运行以下命令编译模块的 Java 代码:
javac -d mods --module-source-path src $(find src -name *.java)
-
你会看到模块
banking.util中的 Java 代码被编译到 mods 目录中。 -
让我们为这个模块创建一个模块化的 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-PATH、ALL-DEFAULT、ALL-SYSTEM)来包含模块。我们使用了ALL-MODULE-PATH来添加位于我们的模块路径上的模块。
--module-path选项告诉 Java 运行时我们的模块位置。
你会看到我们的计算器仍然正常运行。尝试进行简单利息计算和复利计算,以检查是否找到了BankUtil类。因此,我们的依赖关系图现在看起来如下:

模块化math.util.jar
-
将
MathUtil.java从chp3/6_bottom_up_migration_before/math_util/src/com/packt/math复制到位置chp3/6_bottom_up_migration_after/src/math.util/com/packt/math。 -
在
chp3/6_bottom_up_migration_after/src/math.util下创建模块定义文件module-info.java,内容如下:
module math.util{
exports com.packt.math;
}
- 在
6_bottom_up_migration_after文件夹内,通过运行以下命令编译模块的 Java 代码:
javac -d mods --module-source-path src $(find src -name *.java)
-
你会看到模块
math.util和banking.util中的 Java 代码被编译到了mods目录。 -
让我们为这个模块创建一个模块化 JAR:
jar --create --file=mlib/math.util.jar -C mods/math.util .
如果你想知道什么是模块化 JAR,请随意阅读本章中的配方,创建一个模块化 JAR。
- 现在我们已经模块化了
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 文件夹下的 mods 文件夹,使用以下命令:
$ pwd
/root/java9-samples/chp3/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 标识符规则。
如果 6_bottom_up_migration_after 下不存在,创建一个新的 mlib 目录。
现在我们再次使用命令运行我们的计算器程序:
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。按照以下步骤进行模块化:
-
将
chp3/6_bottom_up_migration_before/calculator/src下的com文件夹复制到chp3/6_bottom_up_migration_after/src/calculator位置。 -
在
chp3/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;
}
- 在文件夹
6_bottom_up_migration_after内部,通过运行以下命令编译模块的 Java 代码:
javac -d mods --module-path mlib:mods --module-source-path src $(find src -name *.java)
-
您将看到我们所有模块的 Java 代码都被编译到了
mods目录中。请注意,您应该已经将自动模块(即与 jackson 相关的 JAR 文件)放置在mlib目录中。 -
让我们为这个模块创建一个模块化 JAR,并说明哪个是
main类:
jar --create --file=mlib/calculator.jar --main-
class=com.packt.calculator.Calculator -C mods/calculator .
- 现在,我们为我们的计算器模块创建了一个模块化 JAR,这是我们的主模块,因为它包含了
main类。有了这个,我们也已经模块化了我们的完整应用程序。让我们从文件夹6_bottom_up_migration_after中运行以下命令:
java -p mlib:mods -m calculator
因此,我们已经看到了如何使用自下而上的迁移方法对非模块化应用程序进行模块化。最终的依赖图看起来大致如下:

这个模块化应用程序的最终代码可以在以下位置找到:chp3/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 文件创建。
准备工作
我们将使用之前在自下而上迁移菜谱中介绍的计算器示例。请继续复制来自以下位置的代码:chp3/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-*.bat和run.bat来在 Windows 上打包和运行代码。以及package-*.sh和run.sh来在 Linux 上打包和运行代码。
如何做到这一点...
我们将在chp3/7_top_down_migration_after目录下对应用程序进行模块化。在chp3/7_top_down_migration_after下创建两个目录src和mlib。
模块化计算器:
- 在我们没有对计算器的所有依赖项进行模块化之前,我们无法对计算器进行模块化。但有时对依赖项进行模块化可能更容易,有时则不然,尤其是在依赖项来自第三方的情况下。在这种情况下,我们使用自动模块。我们将非模块化 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.bat和copy-non-mod-jar.sh,以便轻松复制 JAR 文件。
让我们看看我们复制到mlib中的所有内容:
$ ls mlib
banking.util.jar jackson.annotations.jar jackson.core.jar
jackson.databind.jar math.util.jar
只有在您已经编译并将chp3/7_top_down_migration_before/banking_util和chp3/7_top_down_migration_before/math_util目录中的代码打包成 JAR 文件后,banking.util.jar和math.util.jar才会存在。我们之前在准备就绪部分已经这样做过了。
-
在
src目录下创建一个新的文件夹calculator。这个文件夹将包含calculator模块的代码。 -
在
chp3/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;
}
-
将
chp3/7_top_down_migration_before/calculator/src/com目录及其所有代码复制到chp3/7_top_down_migration_after/src/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
src\calculator\module-info.java
src\calculator\com\packt\calculator\Calculator.java
src\calculator\com\packt\calculator\commands\*.java
- 为
calculator模块创建模块化 JAR 文件:
jar --create --file=mlib/calculator.jar --main-
class=com.packt.calculator.Calculator -C mods/calculator/ .
- 运行
calculator模块:
java --module-path mlib -m calculator
我们将看到我们的计算器正在正确执行。您可以尝试不同的操作来验证是否所有操作都正确执行。
模块化banking.util:
由于这不依赖于其他非模块化代码,我们可以通过遵循以下步骤直接将其转换为模块:
-
在
src目录下创建一个新的文件夹banking.util。这个文件夹将包含banking.util模块的代码。 -
在
chp3/7_top_down_migration_after/src/banking.util目录下创建module-info.java文件,其中包含以下内容**:
module banking.util{
exports com.packt.banking;
}
-
将
chp3/7_top_down_migration_before/banking_util/src/com目录及其所有代码复制到chp3/7_top_down_migration_after/src/banking.util。 -
编译模块:
#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
src\banking.util\module-info.java
src\banking.util\com\packt\banking\BankUtil.java
- 为
banking.util模块创建模块化 JAR 文件。这将替换mlib中已存在的非模块化banking.util.jar:
jar --create --file=mlib/banking.util.jar -C mods/banking.util/ .
- 运行
calculator模块以测试是否已成功创建banking.util模块化 JAR 文件:
java --module-path mlib -m calculator
- 你应该看到计算器正在执行。尝试不同的操作以确保没有找不到类的错误。
模块化math.util
-
在
src下创建一个新的文件夹math.util。这将包含math.util模块的代码。 -
在
chp3/7_top_down_migration_after/src/math.util目录下创建module-info.java,其中包含以下内容**:
module math.util{
exports com.packt.math;
}
-
将
chp3/7_top_down_migration_before/math_util/src/com目录及其下的所有代码复制到chp3/7_top_down_migration_after/src/math.util。 -
编译模块:
#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
src\math.util\module-info.java
src\math.util\com\packt\math\MathUtil.java
- 为
banking.util模块创建模块化 JAR。这将替换mlib中已经存在的非模块化banking.util.jar:
jar --create --file=mlib/math.util.jar -C mods/math.util/ .
- 运行
calculator模块以测试是否已成功创建math.util模块的模块化 JAR。
java --module-path mlib -m calculator
- 你应该看到计算器正在执行。尝试不同的操作以确保没有找不到类的错误。
通过这种方式,我们已经完全模块化了应用程序,除了我们已将其转换为自动模块的 Jackson 库。
我们更倾向于自上而下的迁移方法。这是因为我们不必同时处理类路径和模块路径。我们可以将所有内容都变成自动模块,然后在我们继续将非模块化 JAR 迁移到模块化 JAR 时使用模块路径。
使用服务在消费者模块和提供者模块之间创建松耦合
通常,在我们的应用程序中,我们有一些接口和这些接口的多个实现。然后在运行时根据某些条件,我们使用某些特定的实现。这个原则被称为依赖倒置。这个原则被依赖注入框架如 Spring 用来创建具体实现的实例并将它们(或注入)到抽象接口的引用中。
Java 长期以来(自 Java 6 以来)通过java.util.ServiceLoader类支持服务提供者加载功能。使用服务加载器,你可以有一个服务提供者接口(SPI)和该 SPI 的多个实现,这些实现简单地称为服务提供者。这些服务提供者位于类路径中,并在运行时加载。当这些服务提供者位于模块内,并且我们不再依赖于类路径扫描来加载服务提供者时,我们需要一个机制来告诉我们的模块关于服务提供者和它所提供的服务 SPI。在这个菜谱中,我们将通过一个简单的示例来查看这个机制。
准备中
对于这个菜谱,我们不需要设置任何特定内容。在这个菜谱中,我们将使用一个简单的示例。我们有一个支持 CRUD 操作的BookService抽象类。现在,这些 CRUD 操作可以在 SQL 数据库、MongoDB 或文件系统上工作等等。这种灵活性可以通过使用服务提供者接口和ServiceLoader类来加载所需的服务提供者实现来提供。
如何操作...
在这个菜谱中,我们有四个模块:
-
book.service: 这是一个包含我们的服务提供者接口的模块,即服务 -
mongodb.book.service: 这是服务提供者模块之一 -
sqldb.book.service: 这是另一个服务提供者模块 -
book.manage: 这是一个服务消费者模块
以下步骤演示了如何使用ServiceLoader实现松耦合:
-
在
chp3/8_services/src目录下创建一个名为book.service的文件夹。我们所有关于book.service模块的代码都将位于这个文件夹下。 -
在新包
com.packt.model下创建一个新类Book。这是我们包含以下属性的模型类:
public String id;
public String title;
public String author;
- 在新包
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加载了所有的服务提供者,我们选择了一个服务提供者。你也可以有条件地返回服务提供者,但这完全取决于需求。
- 另一个重要的部分是实际的服务提供者接口。其责任是返回适当的服务实现实例。在我们的方案中,
com.packt.spi包中的BookServiceProvider是一个服务提供者接口。
public interface BookServiceProvider{
public BookService getBookService();
}
- 接下来是主要部分,即模块定义。我们在
chp3/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发现的服务的接口。
-
现在让我们创建一个名为
mongodb.book.service的服务提供者模块。这将为我们BookService和book.service模块中的BookServiceProvider接口提供实现。我们的想法是,这个服务提供者将使用 MongoDB 数据存储实现 CRUD 操作。 -
在
chp3/8_services/src目录下创建一个名为mongodb.book.service的文件夹。 -
在
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);
}
- 在
com.packt.mongodb包中创建一个名为MongoDbBookServiceProvider的类,它实现了BookServiceProvider接口。这是我们服务发现类。基本上,它返回一个相关的BookService实现实例。它覆盖了BookServiceProvider接口中的方法如下:
@Override
public BookService getBookService(){
return new MongoDbBookService();
}
- 模块定义非常有趣。我们必须在模块定义中声明这个模块是
BookServiceProvider接口的服务提供者,并且可以如下进行:
module mongodb.book.service{
requires book.service;
provides com.packt.spi.BookServiceProvider
with com.packt.mongodb.MongoDbBookServiceProvider;
}
使用 provides .. with .. 语句来指定服务接口和其中一个服务提供者。
-
现在让我们创建一个名为
book.manage的服务消费者模块。 -
在
chp3/8_services/src下创建一个新的文件夹book.manage,它将包含模块的代码。 -
在
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");
}
}
- 现在让我们使用以下命令编译并运行我们的主模块:
$ 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 实现。
- 使用一个服务提供者时看起来很简单。让我们继续添加另一个模块
sqldb.book.service,其模块定义如下:
module sqldb.book.service{
requires book.service;
provides com.packt.spi.BookServiceProvider
with com.packt.sqldb.SqlDbBookServiceProvider;
}
com.packt.sqldb包中的SqlDbBookServiceProvider类是实现BookServiceProvider接口的一个实现,如下所示:
@Override
public BookService getBookService(){
return new SqlDbBookService();
}
-
CRUD 操作的实现由
com.packt.sqldb.service包中的SqlDbBookService类完成。 -
让我们编译并运行主模块,这次使用两个服务提供者:
$ 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 开发工具包,也称为 JDK - 支持 Java 应用程序的开发和执行。
除了这个之外,Java 8 中还引入了 3 个紧凑配置文件,目的是为了提供具有更小内存占用量的运行时,以便在嵌入式和较小的设备上运行。

前面的图像显示了不同的配置文件及其支持的功能。
Java 9 中引入了一个名为 jLink 的新工具,它使得创建模块化运行时镜像成为可能。这些运行时镜像实际上是一组模块及其依赖项的集合。有一个名为 JEP 220 的 Java 增强提案,它规定了该运行时镜像的结构。
在这个食谱中,我们将使用 jLink 创建一个运行时镜像,该镜像包含我们的模块 math.util、banking.util 和 calculator 以及 Jackson 自动模块。
准备工作
在《创建一个简单的模块化应用程序》的食谱中,我们创建了一个简单的模块化应用程序,它由以下模块组成:
-
math.util -
calculator- 包含主类
我们将重用同一组模块和代码来演示 jLink 工具的使用。为了方便读者,代码可以在以下位置找到:chp3/9_jlink_modular_run_time_image。
如何做到这一点...
- 让我们编译这些模块:
$ javac -d mods --module-path mlib --module-source-path src $(find
src - name *.java)
- 让我们为所有模块创建模块化 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/ .
- 让我们使用
jlink创建包含以下模块的运行时镜像:calculator、math.util及其依赖项:
$ jlink --module-path mlib:$JAVA_HOME/jmods --add-modules
calculator,math.util --output image --launcher
launch=calculator/com.packt.calculator.Calculator
运行时镜像在 --output 命令行选项指定的位置创建。
- 在名为 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.class,jLink 会报错。
为旧平台版本编译
我们在某个时候使用了 -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 src\demo\module-info.java src\demo\com\packt\CollectionsDemo.java
$ java --module-path mods -m demo/com.packt.CollectionsDemo
我们得到的输出是:
key1, value1
key2, value3
key3, value3
现在让我们编译它以在 Java 8 上运行,然后运行它。
如何做到这一点...
- 由于较老的 Java 版本,即 Java 8 及之前版本不支持模块,我们不得不在较老版本编译时去除
module-info.java。所以我们所做的是在编译过程中不包含module-info.java。因此,我们使用以下代码进行编译:
$ javac --release 8 -d mods src\demo\com\packt\CollectionsDemo.java
你可以看到我们正在使用 --release 选项针对 Java 8,并且没有编译 module-info.java。
- 让我们创建一个 JAR 文件,因为与复制所有类文件相比,它更容易传输 java 构建文件:
$jar --create --file mlib/demo.jar --main-class
com.packt.CollectionsDemo -C mods/ .
- 让我们在 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
- 让我们在 Java 8 上运行这个 JAR:
$ "%JAVA8_HOME%"\bin\java -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%"\bin\java -jar mlib\demo.jar
key1, value1
key2, value3
key3, value3
如果我们在 Java 9 构建时没有使用 -release 选项会怎样?让我们也试试这个。
- 不使用
--release选项进行编译,并将生成的类文件创建成 JAR:
$ javac -d mods src\demo\com\packt\CollectionsDemo.java
$ jar --create --file mlib/demo.jar --main-class
com.packt.CollectionsDemo -C mods/ .
- 让我们在 Java 9 上运行这个 JAR:
$ java -jar mlib/demo.jar
key1, value1
key2, value3
key3, value3
按预期工作
- 让我们在 Java 8 上运行这个 JAR:
$ "%JAVA8_HOME%"\bin\java -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 mlib\demo.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 选项用于定位 bootclasspath。ct.sym 文件是一个 ZIP 文件,包含与目标平台版本对应的简化后的类文件(直接引用自 openjdk.java.net/jeps/247)。
创建多版本 JAR
在 Java 9 之前,库的开发者很难在不发布新库版本的情况下采用语言中引入的新功能。但在 Java 9 中,多版本 JAR 提供了这样的功能,您可以将某些类文件捆绑在一起,以便在使用更高版本的 Java 时运行。
在这个菜谱中,我们将向您展示如何创建这样的多版本 JAR。
如何操作...
- 为 Java 8 平台创建所需的 Java 代码。我们将在
src\8\com\packt目录中添加两个类CollectionUtil.java和FactoryDemo.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"));
}
}
- 我们希望使用 Java 9 中引入的
Collection工厂方法。所以我们可以创建src下的另一个子目录来放置我们的 Java 9 相关代码:src\9\com\packt,我们将添加另一个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);
}
}
- 上述代码使用了 Java 9 集合工厂方法。使用以下命令编译源代码:
javac -d mods --release 8 src\8\com\packt\*.java
javac -d mods9 --release 9 src\9\com\packt\*.java
记下用于编译不同 Java 版本代码的 --release 选项。
- 现在让我们创建多版本 JAR:
jar --create --file mr.jar --main-class=com.packt.FactoryDemo
-C mods . --release 9 -C mods9 .
在创建 JAR 文件时,我们也提到当在 Java 9 上运行时,要使用 Java 9 特定的代码。
- 我们将在 Java 9 上运行
mr.jar:
java -jar mr.jar
[element1, element2, element3]
Using factory methods
[element2, element3, element1]
- 我们将在 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%"\bin\java -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%"\bin\java -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 模块。那么,让我们开始吧。
如何做到这一点...
- 创建一个文件夹来包含所有模块。我们将其命名为
12_services_using_maven,其文件夹结构如下:
12_services_using_maven
|---book-manage
|---book-service
|---mongodb-book-service
|---sqldb-book-service
|---pom.xml
- 父项目的
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>
- 让我们为
book-serviceMaven 模块创建结构:
book-service
|---pom.xml
|---src
|---main
|---book.service
|---module-info.java
|---com
|---packt
|---model
|---Book.java
|---service
|---BookService.java
|---spi
|---BookServiceProvider.java
book-serviceMaven 模块的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>
module-info.java的内容是:
module book.service{
exports com.packt.model;
exports com.packt.service;
exports com.packt.spi;
uses com.packt.spi.BookServiceProvider;
}
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;
}
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();
}
}
BookServiceProvider.java的内容是:
public interface BookServiceProvider{
public BookService getBookService();
}
按照类似的思路,我们定义了其他三个 Maven 模块,分别是 mongodb-book-service、sqldb-book-service 和 book-manager。这段代码可以在位置 chp3/12_services_using_maven 找到。
我们可以使用以下命令编译类和构建所需的 JAR 文件:
mvn clean install
我们提供了 run-with-mongo.* 来使用 mongodb-book-service 作为服务提供者实现,以及 run-with-sqldb.* 来使用 sqldb-book-service 作为服务提供者实现。
这个菜谱的完整代码可以在 chp3/12_services_using_maven 找到。
第四章:走向函数式编程
本章介绍了名为函数式编程的编程范式及其在 Java 9 中的适用性。我们将涵盖以下食谱:
-
理解和创建函数式接口
-
理解 lambda 表达式
-
使用方法引用
-
创建和调用 lambda 友好的 API
-
在您的程序中利用 lambda 表达式
简介
函数式编程——将某些功能作为对象处理,并将其作为参数或方法的返回值的能力——是许多编程语言中存在的一个特性。它避免了对象状态的改变和可变数据。函数的结果仅取决于输入数据,无论它被调用多少次。这种风格使结果更具可预测性,这是函数式编程最具吸引力的方面。
它在 Java 中的引入也允许您通过将并行性的责任从客户端代码转移到库中来提高 Java 8 的并行编程能力。在此之前,为了处理 Java 集合的元素,客户端代码必须从集合中获取迭代器并组织集合的处理。
在 Java 8 中,添加了新的(默认)方法,这些方法接受一个函数(函数式接口的实现)作为参数,并将其应用于集合的每个元素。因此,组织并行处理的责任在库。一个例子是每个 Iterable 接口都有的 forEach(Consumer) 方法,其中 Consumer 是一个函数式接口。另一个例子是每个 Collection 接口都有的 removeIf(Predicate) 方法,其中 Predicate 也是一个函数式接口。然后我们有 sort(Comparator) 和 replaceAll(UnaryOperator) 方法,这些方法在 List 和其他几个方法中可用,例如 Map 的 compute() 方法。
java.util.function 包提供了四十三种函数式接口。每个接口都只包含一个抽象方法。Lambda 表达式利用单抽象方法限制,显著简化了此类接口的实现。
没有函数式编程,Java 中传递某些功能作为参数的唯一方法是通过编写一个实现接口的类,创建其对象,然后将其作为参数传递。但即使是最低程度的参与方式——使用匿名类——也需要编写过多的代码。使用函数式接口和 lambda 表达式可以使代码更短、更清晰、更具表现力。
在本章中,我们将定义和解释这些新的 Java 特性——功能性接口和 lambda 表达式——并在代码示例中展示它们的适用性。将这些新特性引入 Java 使得函数成为语言的第一等公民。但对于尚未接触过函数式编程的人来说,利用它们的强大功能需要一种新的思考方式和代码组织方式。
展示这些特性并分享使用它们的最佳实践是本章的目的。
理解和创建功能性接口
在这个菜谱中,你将了解自 Java 8 以来支持的功能性接口。
准备工作
任何只有一个抽象方法的接口被称为功能性接口。为了帮助避免运行时错误,Java 8 中引入了 @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。然而,这只有在 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() 方法。比如说,我们给 Car 接口添加 @FunctionalInterface 注解:
@FunctionalInterface
public interface Car extends Vehicle {
int getPassengersCount();
}
如果我们这样做,编译器将生成以下错误:

使用 @FunctionalInterface 注解不仅有助于在编译时捕获错误,而且还能确保设计意图的可靠传达。它帮助你或其他程序员记住,这个接口不能有超过一个抽象方法,这在代码已经存在且依赖于这种假设的情况下尤为重要。
由于同样的原因,Java 8 中的 Runnable 和 Callable 接口(它们在 Java 的早期版本中就已经存在)被标注为 @FunctionalInterface,以使这种区分明确,并提醒用户或试图向它们添加另一个抽象方法的人:
@FunctionalInterface
interface Runnable { void run(); }
@FunctionalInterface
interface Callable<V> { V call() throws Exception; }
在创建你打算用作方法参数的自定义功能接口之前,首先考虑使用java.util.function包中提供的四十三种功能接口之一。其中大多数是以下四个接口的特化:Function、Consumer、Supplier和Predicate。
如何做到这一点……
你可以遵循以下步骤来熟悉功能接口:
- 看一下功能接口
Function:
@FunctionalInterface
public interface Function<T,R>
这里是其 Javadoc,接受一个类型为 T 的参数并产生类型为 R 的结果。功能方法是 apply(Object)。你可以使用匿名类来实现这个接口:
Function<Integer, Double> ourFunc = new
Function<Integer, Double>(){
public Double apply(Integer i){
return i * 10.0;
}
};
它的唯一方法,即apply(),接受类型为Integer(或原始的int,它将被自动装箱)的值作为参数,然后乘以10,并返回类型为Double(或解箱为原始的double)的值,因此我们可以编写以下代码:
System.out.println(ourFunc.apply(1));
结果将如下所示:

在下一个菜谱中,我们将介绍 lambda 表达式,并展示其使用如何使实现更加简洁。但到目前为止,我们仍将继续使用匿名类。
- 看一下功能接口
Consumer(名称有助于记住该接口的方法接受一个值但不返回任何内容——它只消费):
public interface Consumer<T>
Accepts a single input argument of type T and returns no result.
The functional method is accept(Object).
该接口的实现可以如下所示:
Consumer<String> ourConsumer = new Consumer<String>() {
public void accept(String s) {
System.out.println("The " + s + " is consumed.");
}
};
accepts()方法接收类型为String的参数值并打印它。比如说,我们编写以下代码:
ourConsumer.accept("Hello!");
结果将如下所示:

- 看一下功能接口
Supplier(名称有助于记住该接口的方法不接受任何值但返回某些内容——仅提供):
public interface Supplier<T>
Represents a supplier of results of type T.
The functional method is get().
这意味着该接口的唯一方法是get(),它没有输入参数并返回类型为T的值。基于此,我们可以创建一个函数:
Supplier<String> ourSupplier = new Supplier<String>() {
public String get() {
String res = "Success";
//Do something and return result – Success or Error.
return res;
}
};
(get()方法执行某些操作然后返回类型为String的值,因此我们可以编写以下代码:
System.out.println(ourSupplier.get());
结果将如下所示:

- 看一下功能接口
Predicate(名称有助于记住该接口的方法返回一个布尔值——断言某事):
@FunctionalInterface
public interface Predicate<T>
它的 JavaDoc 声明:表示一个类型为 T 的单参数谓词(布尔值函数)。功能方法是 test(Object)。这意味着该接口的唯一方法是test(Object),它接受一个类型为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;
}
};
它的test()方法接受一个Double类型的参数并返回一个boolean类型的值,因此我们可以编写以下代码:
System.out.println(ourPredicate.test(10.0) ?
"10 is smaller" : "10 is bigger");
结果将如下所示:

- 看看
java.util.function包中的其他 39 个功能接口。注意,它们是我们已经讨论过的四个接口的变体。这些变体是为了以下原因而创建的:
-
-
通过显式使用
int、double或long原始类型来避免自动装箱和拆箱,以获得更好的性能 -
对于接受两个输入参数
-
对于简短的表示法
-
以下功能接口只是 39 个接口列表中的几个例子。
功能接口 IntFunction<R>(其唯一抽象方法是 apply(int))接受一个 int 原始类型,并返回类型为 R 的值。它提供了一种简短的表示法(参数类型不使用泛型)并避免了自动装箱(通过将 int 原始类型定义为参数)。以下是一个例子:
IntFunction<String> function = new IntFunction<String>() {
public String apply(int i) {
return String.valueOf(i * 10);
}
};
功能接口 BiFunction<T,U,R>(apply(T,U) 方法)接受类型为 T 和 U 的两个参数,并返回类型为 R 的值。以下是一个例子:
BiFunction<String, Integer, Double> function =
new BiFunction<String, Integer, Double >() {
public Double apply(String s, Integer i) {
return (s.length() * 10d)/i;
}
};
功能接口 BinaryOperator<T>(apply(T,T) 方法)接受两个类型为 T 的参数,并返回类型为 T 的值。它通过避免重复三次相同的类型来提供简短的表示法。以下是一个例子:
BinaryOperator<Integer> function =
new BinaryOperator<Integer>(){
public Integer apply(Integer i, Integer j) {
return i >= j ? i : j;
}
};
功能接口 IntBinaryOperator(applyAsInt(int,int) 方法)接受两个类型为 int 的参数,并返回类型为 int 的值。以下是一个例子:
IntBinaryOperator function = new IntBinaryOperator(){
public int apply(int i, int j) {
return i >= j ? i : j;
}
};
我们将在下面的菜谱中看到此类特殊化的使用示例。
它是如何工作的...
你可以创建并传递任何功能接口的实现,就像传递任何对象一样。例如,让我们编写一个创建 Function<Integer, Double> 的方法:
Function<Integer, Double> createMultiplyBy10(){
Function<Integer, Double> ourFunc = new Function<Integer, Double>(){
public Double apply(Integer i){ return i * 10.0; }
};
return ourFunc;
}
更好的是,我们可以创建一个使用以下内容的泛型方法:
Function<Integer, Double> createMultiplyBy(double num){
Function<Integer, Double> ourFunc = new Function<Integer, Double>(){
public Double apply(Integer i){ return i * num; }
};
return ourFunc;
}
现在,我们可以写出这个:
Function<Integer, Double> multiplyBy10 = createMultiplyBy(10d);
System.out.println(multiplyBy10.apply(1));
Function<Integer, Double> multiplyBy30 = createMultiplyBy(30d);
System.out.println(multiplyBy30.apply(1));
结果将如下所示:

类似地,我们可以有创建 Function<Double, Double> 函数、Consumer<String> 函数和 Supplier<String> 函数的工厂方法:
Function<Double, Double> createSubtract(double num){
Function<Double, Double> ourFunc = new Function<Double, Double>(){
public Double apply(Double dbl){ return dbl - num; }
};
return ourFunc;
}
public static Consumer<String> createTalker(String value){
Consumer<String> consumer = new Consumer<String>() {
public void accept(String s) {
System.out.println(s + value);
}
};
return consumer;
}
public static Supplier<String> createResultSupplier(){
Supplier<String> supplier = new Supplier<String>() {
public String get() {
String res = "Success";
//Do something and return Success or Error.
return res;
}
};
return supplier;
}
让我们使用前面的函数:
Function<Double,Double> subtract7 = createSubtract(7.0);
System.out.println(subtract7.apply(10.0));
Consumer<String> sayHappyToSee = createTalker("Happy to see
you again!");
sayHappyToSee.accept("Hello!");
Supplier<String> successOrFailure = createResultSupplier();
System.out.println(successOrFailure.get());
结果将如下所示:

我们还可以有创建不同版本的 Predicate<Double> 函数的工厂方法:
Predicate<Double> createIsSmallerThan(double limit){
Predicate<Double> pred = new Predicate<Double>() {
public boolean test(Double num) {
System.out.println("Test if " + num + " is smaller than "
+ limit);
return num < limit;
}
};
return pred;
}
Predicate<Double> createIsBiggerThan(double limit){
Predicate<Double> pred = new Predicate<Double>() {
public boolean test(Double num) {
System.out.println("Test if " + num + " is bigger than "
+ limit);
return num > limit;
}
};
return pred;
}
让我们按照以下方法使用前面的方法:
Predicate<Double> isSmallerThan20 = createIsSmallerThan(20d);
System.out.println(isSmallerThan20.test(10d));
Predicate<Double> isBiggerThan18 = createIsBiggerThan(18d);
System.out.println(isBiggerThan18.test(10d));
当我们使用它们时,我们得到以下结果:

如果需要,具有更复杂逻辑的函数可以由几个已存在的函数组合而成:
Supplier<String> applyCompareAndSay(int i,
Function<Integer, Double> func,
Predicate<Double> isSmaller){
Supplier<String> supplier = new Supplier<String>() {
public String get() {
double v = func.apply(i);
return isSmaller.test(v)? v + " is smaller" : v + " is bigger";
}
};
return supplier;
}
我们可以通过将 multiplyBy10、multiplyBy30 和 isSmallerThan20 函数传递给之前创建的工厂方法来创建它:
Supplier<String> compare1By10And20 =
applyCompareAndSay(1, multiplyBy10, isSmallerThan20);
System.out.println(compare1By10And20.get());
Supplier<String> compare1By30And20 =
applyCompareAndSay(1, multiplyBy30, isSmallerThan20);
System.out.println(compare1By30And20.get());
如果我们运行前面的代码,你会得到以下结果:

第一行和第三行来自 isSmallerThan20 函数,而第二行和第四行来自 compare1By10And20 和 compare1By30And20 函数,相应地。
正如你所见,函数式接口的引入通过允许传递函数作为参数来增强了 Java。现在,应用程序开发者可以专注于函数(业务流程)的实现,而不用担心将其应用到集合的每个元素。
还有更多...
java.util.function包中的许多函数式接口都有默认方法,这些方法不仅增强了它们的功能,还允许你链式调用函数,并将一个函数的结果作为另一个函数的输入参数。例如,我们可以使用Function接口的默认方法andThen(Function after):
Supplier<String> compare1By30Less7To20 =
applyCompareAndSay(1, multiplyBy30.andThen(subtract7),
isSmallerThan20);
System.out.println(compare1By30Less7To20.get());
Supplier<String> compare1By30Less7TwiceTo20 =
applyCompareAndSay(1, multiplyBy30.andThen(subtract7)
.andThen(subtract7),isSmallerThan20);
System.out.println(compare1By30Less7TwiceTo20.get());
after函数应用于这个函数的结果,因此,after函数的输入类型必须与这个函数的结果类型相同或为其基类型。这段代码的结果如下:

我们可以使用Function接口的另一个默认方法compose(Function before)来达到相同的结果,该方法首先应用before函数,然后再应用这个函数。自然地,在这种情况下,我们需要交换multiplyBy30函数和subtract7函数的位置:
Supplier<String> compare1By30Less7To20 =
applyCompareAndSay(1, subtract7.compose(multiplyBy30),
isSmallerThan20);
System.out.println(compare1By30Less7To20.get());
Supplier<String> compare1By30Less7TwiceTo20 =
applyCompareAndSay(1, subtract7.compose(multiplyBy30)
.andThen(subtract7), isSmallerThan20);
System.out.println(compare1By30Less7TwiceTo20.get());
结果如下所示:

Consumer接口也有andThen(Consumer after)方法,因此我们可以使用之前创建的sayHappyToSee函数来创建一个对话:
Consumer<String> askHowAreYou = createTalker("How are you?");
sayHappyToSee.andThen(askHowAreYou).accept("Hello!");
结果将如下所示:

Supplier接口没有默认方法,而Predicate接口有一个isEqual(Object targetRef)静态方法和三个默认方法:and(Predicate other)、negate()和or(Predicate other)。我们将演示and(Predicate other)方法的用法。
我们可以创建一个谓词,使用已经创建的函数isSmallerThan20和isBiggerThan18来检查输入值是否介于这两个值之间。但在做这个之前,我们需要通过向签名中添加另一个名为message的参数来重载applyCompareAndSay()工厂方法:
Supplier<String> applyCompareAndSay(int i,
Function<Integer, Double> func,
Predicate<Double> compare, String message) {
Supplier<String> supplier = new Supplier<String>() {
public String get() {
double v = func.apply(i);
return (compare.test(v)? v + " is " : v + " is not ") + message;
}
};
return supplier;
}
现在,我们可以写出以下内容:
Supplier<String> compare1By30Less7TwiceTo18And20 =
applyCompareAndSay(1, multiplyBy30.andThen(subtract7)
.andThen(subtract7),
isSmallerThan20.and(isBiggerThan18),
"between 18 and 20");
System.out.println(compare1By30Less7TwiceTo18And20.get());
我们得到以下结果:

如果这段代码看起来有点过度设计且复杂,这是真的。我们这样做是为了演示目的。好消息是 lambda 表达式(在下一道菜谱中介绍)允许你以更直接、更清晰的方式达到相同的结果。
在结束这道菜谱之前,我们想提到,java.util.function包中的函数式接口有其他有用的默认方法。其中最突出的是identity()方法,它返回一个总是返回其输入参数的函数:
Function<Integer, Integer> id = Function.identity();
System.out.println("Function.identity.apply(4) => " + id.apply(4));
我们将得到以下输出:

当某些过程需要你提供一个特定函数,但你又不想这个函数修改结果时,identity() 方法非常有用。
其他默认方法大多与转换、装箱和拆箱以及提取两个参数的最小和最大值有关。我们鼓励你遍历 java.util.function 包中所有函数式接口的 API,并感受其可能性。
参见
参考本章以下食谱:
-
理解 lambda 表达式
-
使用方法引用
理解 lambda 表达式
在这个食谱中,你将了解自 Java 8 以来支持的 lambda 表达式。
准备工作
在前面的食谱(使用匿名类实现函数式接口)中的例子看起来很庞大,感觉过于冗长。首先,我们没有必要重复接口名称,因为我们已经将其声明为对象引用的类型。其次,在函数式接口(只有一个抽象方法)的情况下,没有必要指定要实现的方法名称。编译器和 Java 运行时无论如何都能弄清楚。我们需要的只是提供新的功能。这就是 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));
Consumer<String> consumer = new Consumer<String>() {
public void accept(String s) {
System.out.println("The " + s + " is consumed.");
}
};
consumer.accept("Hello!");
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());
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");
- 用 lambda 表达式重写它:
Function<Integer, Double> ourFunc = i -> i * 10.0;
System.out.println(ourFunc.apply(1));
Consumer<String> consumer =
s -> System.out.println("The " + s + " is consumed.");
consumer.accept("Hello!");
Supplier<String> supplier = () -> {
String res = "Success";
//Do something and return result – Success or Error.
return res;
};
System.out.println(supplier.get());
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");
- 运行它,你会得到相同的结果:

它是如何工作的...
lambda 表达式的语法包括参数列表、箭头符号 (->) 和主体。参数列表可以是空的 (()), 没有括号(如果只有一个参数,如我们的示例所示),或者是一个用括号包围的逗号分隔的参数列表。主体可以是一个单独的表达式(如我们前面的代码所示)或一个语句块。以下是一个例子:
BiFunction<Integer, String, Double> demo =
(x,y) -> x * 10d + Double.parseDouble(y);
System.out.println(demo.apply(1, "100"));
//The above is the equivalent to the statement block:
demo = (x,y) -> {
//You can add here any code you need
double v = 10d;
return x * v + Double.parseDouble(y);
};
System.out.println(demo.apply(1, "100"));
本例的结果如下:

只有在语句块的情况下才需要大括号。在单行 lambda 表达式中,无论函数是否返回值,大括号都是可选的。
让我们用 lambda 表达式重写我们之前写的代码:
Function<Integer, Double> multiplyBy10 = i -> i * 10.0;
System.out.println("1 * 10.0 => "+multiplyBy10.apply(1));
Function<Integer, Double> multiplyBy30 = i -> i * 30.0;
System.out.println("1 * 30.0 => "+multiplyBy30.apply(1));
Function<Double,Double> subtract7 = x -> x - 7.0;
System.out.println("10.0 - 7.0 =>"+subtract7.apply(10.0));
Consumer<String> sayHappyToSee =
s -> System.out.println(s + " Happy to see you again!");
sayHappyToSee.accept("Hello!");
Predicate<Double> isSmallerThan20 = x -> x < 20d;
System.out.println("10.0 is smaller than 20.0 => " +
isSmallerThan20.test(10d));
Predicate<Double> isBiggerThan18 = x -> x > 18d;
System.out.println("10.0 is smaller than 18.0 => " +
isBiggerThan18.test(10d));
如果我们运行这个,我们会得到以下结果:

如你所见,结果完全相同,但代码更简单,只捕捉了本质。
工厂方法也可以用 lambda 表达式重写和简化:
Supplier<String> applyCompareAndSay(int i,
Function<Integer, Double> func,
Predicate<Double> compare, String message){
return () -> {
double v = func.apply(i);
return (compare.test(v) ? v + " is " : v + " is not ") + message;
};
}
我们不再重复实现 Supplier<String> 接口的名称,因为它已经在方法签名中指定为返回类型。我们也不再指定实现 test() 方法的名称,因为它必须是 Supplier 接口中唯一必须实现的方法。编写这样紧凑高效的代码成为可能,是因为 lambda 表达式和函数式接口的结合。
与匿名类一样,在 lambda 表达式外部创建并在内部使用的变量成为实际上是 final 的,不能被修改。你可以编写以下代码:
double v = 10d;
multiplyBy10 = i -> i * v;
然而,你无法在 lambda 表达式外部更改变量v的值:
double v = 10d;
v = 30d; //Causes compiler error
multiplyBy10 = i -> i * v;
你也无法在表达式中更改它:
double v = 10d;
multiplyBy10 = i -> {
v = 30d; //Causes compiler error
return i * v;
};
这种限制的原因是,一个函数可以在不同的上下文中(例如不同的线程)为不同的参数传递和执行,尝试同步这些上下文将违背函数分布式评估的原始想法。
匿名类和 lambda 表达式之间一个主要的不同之处在于this关键字的解释。在匿名类内部,它指的是匿名类的实例。在 lambda 表达式内部,this指的是包围表达式的类的实例。以下是一个演示代码示例:
public static void main(String arg[]) {
Demo d = new Demo();
d.method();
}
public static 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<String>() {
private String prop = "ConsumerProperty";
public void accept(String s) {
System.out.println("Anonymous accept(" + s + "): this.prop="
+ this.prop);
}
};
consumer.accept(this.prop);
}
此代码的输出如下:

lambda 表达式不是一个内部类,不能通过this来引用。根据 Java 规范,这种做法通过将this引用视为来自周围上下文,允许你拥有更多的实现灵活性。
还有更多...
看看演示代码如何变得更加简单和复杂度更低。我们可以在传递参数的同时动态创建函数:
Supplier<String> compare1By30Less7TwiceTo18And20 =
applyCompareAndSay(1, x -> x * 30.0 - 7.0 - 7.0,
x -> x < 20 && x > 18, "betwen 18 and 20");
System.out.println("Compare (1 * 30 - 7 - 7) and the range"
+ " 18 to 20 => "
+ compare1By30Less7TwiceTo18And20Lambda.get());
然而,结果并没有改变:

这就是 lambda 表达式与函数式接口结合的强大和美丽之处。
参见
参考本章中的以下菜谱:
-
理解和创建函数式接口
-
使用方法引用
此外,请参阅第五章,流操作和管道
使用方法引用
在这个菜谱中,你将学习如何使用方法引用,构造器引用只是其中的一种情况。
准备工作
在以下情况下,当一行 lambda 表达式仅由对现有方法的引用组成(在别处实现)时,可以使用方法引用进一步简化表示法。引用方法可以是静态的或非静态的(后者可以绑定到特定的对象或不行)或带有或不带有参数的构造器。
方法引用的语法是Location::methodName,其中Location表示methodName方法可以在哪里(在哪个对象或类)找到。两个冒号(::)作为位置和方法名之间的分隔符。如果在指定位置有多个具有相同名称的方法(由于方法重载),则通过 lambda 表达式实现的函数式接口的抽象方法的签名来识别引用方法。
如何做到这一点...
使用方法引用很简单,可以通过每个案例的几个示例来轻松说明:
- 首先,我们有静态方法引用。如果一个
Food类有一个名为String getFavorite()的静态方法,那么 lambda 表达式可能看起来像这样:
Supplier<String> supplier = Food::getFavorite;
如果Foo类还有一个名为String getFavorite(int num)的方法,使用它的 lambda 表达式可能看起来完全一样:
Function<Integer, String> func = Food::getFavorite;
差异在于这个 lambda 表达式实现的接口。它允许编译器和 Java 运行时识别要使用的方法。让我们看看代码。以下是Food类:
public class Food{
public static String getFavorite(){ return "Donut!"; }
public static String getFavorite(int num){
return num > 1 ? String.valueOf(num)
+ " donuts!" : "Donut!";
}
}
我们可以使用它的静态方法作为函数式接口的实现:
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));
结果将会如下:

- 其次,我们有构造函数的方法引用。
假设Food类没有显式的构造函数,或者有一个不带参数的构造函数。最接近这种签名的函数式接口是Supplier<Food>,因为它也不接受任何参数。让我们向我们的Food类添加以下内容:
private String name;
public Food(){ this.name = "Donut"; };
public String sayFavorite(){
return this.name + (this.name.toLowerCase()
.contains("donut") ? "? Yes!" : "? D'oh!");
}
然后,我们可以写出以下内容:
Supplier<Food> constrFood = Food::new;
Food food = constrFood.get();
System.out.println("new Food().sayFavorite() => "
+ food.sayFavorite());
如果我们这样写,我们会得到以下输出:

之前非静态方法引用绑定到了Food类的特定实例。我们稍后会回到它,并讨论非绑定非静态方法引用。但现在,我们将向Food类添加另一个带有一个参数的构造函数:
public Food(String name){
this.name = name;
}
一旦我们这样做,我们将通过方法引用来表示它:
Function<String, Food> constrFood1 = Food::new;
food = constrFood1.apply("Donuts");
System.out.println("new Food(Donuts).sayFavorite() => "
+ food.sayFavorite());
food = constrFood1.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> constrFood2 = Food::new;
food = constrFood2.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);}
我们可以使用它来处理不同类型:
Func<Integer, Double, String, Food> constr3 = Food::new;
Food food = constr3.apply(1, 2d, "Food");
这个自定义接口的名称及其唯一方法的名称可以是我们喜欢的任何名称:
@FunctionalInterface
interface FourParamFunction<T1,T2,T3,R>{
R construct(T1 t1, T2 t2, T3 t3);
}
Func<Integer, Double, String, Food> constr3 = Food::new;
Food food = constr3.construct(1, 2d, "Food");
- 第三,我们有绑定和非绑定非静态方法。
我们用于sayFavorite()方法的引用方法需要(绑定到)类实例。这意味着在函数创建之后,我们无法更改函数中使用的类实例。为了演示这一点,让我们创建三个Food类的实例和三个捕获sayFavorite()方法功能的Supplier<String>接口的实例:
Food food1 = new Food();
Food food2 = new Food("Carrot");
Food food3 = new Food("Carrot", "Broccoli");
Supplier<String> supplier1 = food1::sayFavorite;
Supplier<String> supplier2 = food2::sayFavorite;
Supplier<String> supplier3 = food3::sayFavorite;
System.out.println("new Food()=>supplier1.get() => " +
supplier1.get());
System.out.println("new Food(Carrot)=>supplier2.get() => "
+ supplier2.get());
System.out.println("new Food(Carrot,Broccoli)" +
"=>supplier3.get() => " + supplier3.get());
如您所见,在创建供应商之后,我们只能调用其上的get()方法,并且不能更改绑定到其上的实例(Food类的实例)(get()方法指的是food1、food2或food3对象的方法)。结果如下:

相比之下,我们可以使用Function<Food, String>接口的实例创建一个未绑定的主引用(注意,方法位置被指定为名为Food的类名):
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));
这就是为什么这个方法引用被称为未绑定的。
最后,我们可以通过添加sayFavorite(String name)方法(与我们对静态方法getFavorite()所做的方式相同)来重载sayFavorite()方法:
public String sayFavorite(String name){
this.name = this.name + " and " + name;
return sayFavorite();
}
通过这种方式,我们可以证明编译器和 Java 运行时仍然可以通过指定的功能接口签名理解我们的意图并调用正确的方法:
Function<String, String> func1 = food1::sayFavorite;
Function<String, String> func2 = food2::sayFavorite;
Function<String, String> func3 = food3::sayFavorite;
System.out.println("new Food().sayFavorite(Carrot) => "
+ func1.apply("Carrot"));
System.out.println("new Food(Carrot).sayFavorite(Broccoli) => "
+ func2.apply("Broccoli"));
System.out.println("new Food(Carrot,Broccoli)" +
".sayFavorite(Donuts) => " +
func3.apply("Donuts"));
结果如下:

还有更多...
在实践中,经常使用一些简单但有用的 lambda 表达式和方法引用:
Function<String, Integer> strLength = String::length;
System.out.println(strLength.apply("3"));
Function<String, Integer> parseInt = Integer::parseInt;
System.out.println(parseInt.apply("3"));
Consumer<String> consumer = System.out::println;
consumer.accept("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 友好的 API
在这个菜谱中,你将学习如何创建 lambda 友好的 API 以及最佳实践。
准备工作
当一个新的 API 的想法首次出现时,它通常看起来干净且聚焦良好。即使是第一个实现的版本也往往保持相同的品质。但随后,“一次性”和其他与主要用例的小偏差变得紧迫,随着用例种类的增加,API 开始增长(并且变得越来越复杂,更难使用)。生活并不总是符合我们对它的愿景。这就是为什么任何 API 设计者在某个时候都会面临如何使 API 既通用又灵活的问题。过于通用的 API 在特定业务领域难以理解,而非常灵活的 API 会使实现更加复杂,更难测试、维护和使用。
将接口用作参数可以促进灵活性,但需要编写实现它们的新代码。功能接口和 lambda 表达式允许通过几乎不需要管道的方式捕获功能,从而将此类代码的范围减少到最小。这种实现的粒度可以细到或粗到所需,无需创建新类、它们的对象工厂和其他传统基础设施。
然而,过度追求灵活性是有可能的,过度使用新功能的力量,以至于它通过使 API 难以理解和使用几乎不可能来违背初衷。本食谱的目的是警告某些陷阱并分享适合 lambda 表达式的 API 设计的最佳实践。
适合 lambda 表达式使用的 API 设计最佳实践包括以下内容:
-
优先使用
java.util.function包中的接口 -
避免通过函数式接口的类型来过度加载方法
-
使用
@FunctionalInterface注解来定义自定义函数式接口 -
将函数式接口作为参数考虑,而不是创建几个只在功能步骤上有所不同的方法
它是如何工作的...
我们在理解和创建函数式接口的食谱中讨论了所列出的前两种实践。使用java.util.function包中的接口的优势基于两个事实。首先,任何标准化都促进了更好的理解和 API 使用的便利性。其次,它促进了代码编写的最小化。
为了说明这些考虑,让我们尝试创建一个 API,并将其命名为GrandApi——一个将在GrandApiImpl类中实现的接口。
让我们在新的 API 中添加一个方法,允许客户端传递一个计算某个值的函数。这种安排允许客户端根据需要自定义 API 的行为。这种设计被称为委托模式。它有助于对象组合,这在第二章中讨论过,快速掌握面向对象 - 类和接口。
首先,我们演示传统的面向对象方法,并引入一个具有计算方法Calculator接口:
public interface Calculator {
double calculateSomething();
}
public class CalcImpl implements Calculator{
private int par1;
private double par2;
public CalcImpl(int par1, double par2){
this.par1 = par1;
this.par2 = par2;
}
public double calculateSomething(){
return par1 * par2;
}
}
因此,我们新接口的第一个方法可以有如下方法:
public interface GrandApi{
double doSomething(Calculator calc, String str, int i);
}
该方法的实现可能如下所示:
double doSomething(Calculator calc, String str, int i){
return calc.calculateSomething() * i + str.length();
}
我们现在可以创建一个实现Calculator接口的CalucaltorImpl类,并将CalculatorImpl对象的实例传递给doSomething()方法:
GrandApi api = new GrandImpl();
Calculator calc = new CalcImpl(20, 10d);
double res = api.doSomething(calc, "abc", 2);
System.out.println(res);
结果将如下所示:

如果客户端想要使用另一个Calculator实现,这种方法将需要你创建一个新的类或使用匿名类:
GrandApi api = new GrandImpl();
double res = api.doSomething(new Calculator() {
public double calculateSomething() {
return 20 * 10d;
}
}, "abc", 2);
System.out.println(res);
在 Java 8 之前,客户端有两个可用的选项。如果客户端被迫使用某个Calculator实现(例如,由第三方开发),则不能使用匿名类;因此,只剩下一个选项。
在 Java 8 中,客户端可以利用只有一个抽象方法的Calculator接口(因此,它是一个函数式接口)。比如说,客户端被迫使用第三方实现:
public static class AnyImpl{
public double doIt(){ return 1d; }
public double doSomethingElse(){ return 100d; }
}
客户端可以编写以下代码:
GrandApi api = new GrandImpl();
AnyImpl anyImpl = new AnyImpl();
double res = api.doSomething(anyImpl::doIt, "abc", 2);
System.out.println(res);
如果他们这样做,将会得到以下结果:

编译器和 Java 运行时主要根据输入参数的数量以及返回值的是否存在来匹配功能接口。
如果使用第三方实现不是强制性的,客户端可以使用 lambda 表达式,例如以下内容:
double res = api.doSomething(() -> 20 * 10d, "abc", 2);
System.out.println(res);
他们还可以使用以下方法:
int i = 20;
double d = 10.0;
double res = api.doSomething(() -> i * d, "abc", 2);
System.out.println(res);
在这两种情况下,他们都会得到以下结果:

然而,所有这些只有在Calculator接口只有一个抽象方法的情况下才可能。因此,如果我们能的话,最好给它添加@FunctionalInterface注解。这是因为一旦Calculator获得另一个抽象方法,带有 lambda 表达式的客户端代码就会崩溃。
然而,如果我们使用java.util.function包中的一个标准功能接口,我们可以避免创建一个自定义接口。在这种情况下,匹配的接口将是Supplier<Double>,我们可以将我们的第一个 API 方法更改为以下内容:
public interface GrandApi{
double doSomething(Supplier<Double> supp, String str, int i);
}
现在,我们确信带有 lambda 表达式的客户端代码永远不会崩溃,并且客户端代码将会更短:
GrandApi api = new GrandImpl();
Supplier<Double> supp = () -> 20 * 10d;
double res = api.doSomething(supp, "abc", 2);
System.out.println(res);
它也可以是这样的:
GrandApi api = new GrandImpl();
double res = api.doSomething(() -> 20 * 10d, "abc", 2);
System.out.println(res);
在任何情况下,结果都将相同:

如果客户端被迫使用现有的实现,代码可以如下所示:
GrandApi api = new GrandImpl();
AnyImpl anyImpl = new AnyImpl();
double res = api.doSomething(anyImpl::doIt, "abc", 2);
System.out.println(res);
对于AnyImpl类,它仍然会产生相同的结果:

这就是为什么使用标准功能接口非常推荐的原因,因为它允许更多的灵活性,并且减少了客户端代码的编写。然而,一个人应该小心不要仅仅通过功能接口的类型来过度加载方法。问题在于,通过功能接口参数识别方法的算法通常没有太多可以工作的,特别是如果方法调用只包含内联 lambda 表达式。算法检查输入参数的数量(arity)以及返回值的是否存在(void)。但这可能还不够,API 用户可能会遇到严重的调试问题。让我们看一个例子,并将这两个方法添加到我们的 API 中:
double doSomething2(Function<Integer,Integer> function, double num);
void doSomething2(Consumer<String> consumer, double num);
对于人类眼睛来说,这些方法具有非常不同的签名。只要传递给功能接口的实例是明确指定的,它们就会被正确解析:
GrandApi api = new GrandImpl();
Consumer<String> consumer = System.out::println;
api.doSomething2(consumer, 2d);
功能接口的类型也可以通过类型转换来指定:
GrandApi api = new GrandImpl();
api.doSomething2((Consumer<String>)System.out::println,2d);
现在考虑不指定传入功能接口类型的代码:
GrandApi api = new GrandImpl();
api.doSomething2(System.out::println, 2d);
它甚至无法编译,并给出以下错误信息:

错误的原因是,这两个接口(Function<Integer, Integer>和Consumer<String>)具有相同数量的输入参数,而不同的返回类型(Integer和void)显然不足以解决这种情况下的方法重载。因此,不要使用不同的函数式接口来重载方法,而应使用不同的方法名。例如,看看以下内容:
double doSomethingWithFunction(Function<Integer, Integer> function,
double num);
void doSomethingWIthConsumer(Consumer<String> consumer, double num);
还有更多...
以下是为构建 lambda 表达式最佳实践:
-
保持无状态
-
避免使用块语句
-
使用方法引用
-
依赖有效的最终状态
-
不要绕过有效的最终状态
-
避免指定参数类型
-
避免为单个参数使用括号
-
避免使用大括号和返回语句
API 设计者也应牢记这些指南,因为 API 客户端可能会使用它们。
保持 lambda 表达式无状态意味着函数评估的结果必须仅依赖于输入参数,无论表达式评估多少次或使用了哪些参数。例如,这将是一个容易出错的代码:
GrandApi api = new GrandImpl();
int[] arr = new int[1];
arr[0] = 1;
double res = api.doSomething(() -> 20 * 10d + arr[0]++, "abc", 2);
System.out.println(res);
res = api.doSomething(() -> 20 * 10d + arr[0]++, "abc", 2);
System.out.println(res);
res = api.doSomething(() -> 20 * 10d + arr[0]++, "abc", 2);
System.out.println(res);
这是因为它每次都会产生不同的结果:

此示例还涵盖了依赖有效的最终状态而不是绕过它的建议。通过在先前的示例中指定final int[] arr,给人一种代码无懈可击的错觉,而实际上它隐藏了缺陷。
其他 lambda 表达式最佳实践有助于使代码更清晰,更好地表达其主要逻辑,否则可能会在冗长的代码和众多符号中丢失。只需比较以下行。这是第一行:
double res = api.doSomething2((Integer i) -> { return i * 10; }, 2d);
这是第二行:
double res = api.doSomething2(i -> i * 10, 2d);
第二行更加简洁明了,尤其是在块语句中有多个参数和复杂逻辑的情况下。我们将在下一配方中看到块语句的示例以及如何避免它们。任何现代编辑器都有助于移除不必要的符号。
另请参阅
请参考本章中的以下配方:
- 在你的程序中利用 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();
}
这里的VehicleType、RoadCondition和TireCondition是我们已经在上一章中构建的enum类型:
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 点为例)的交通数据。每次调用工厂都会产生不同的结果,而交通单元列表描述了指定地点的统计正确数据(包括最可能的天气条件)。
我们还将更改FactoryVehicle和FactorySpeedModel的接口,以便它们可以根据TrafficUnit接口构建Vehicle和SpeedModel。生成的示例代码如下:
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 = 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类型的元素。
现在我们已经准备好使用之前菜谱中讨论的 Java 新特性来改进我们的速度计算应用程序。
如何做到这一点...
按照以下步骤学习如何使用 lambda 表达式:
- 让我们开始构建一个 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.month = month;
this.dayOfWeek = dayOfWeek;
this.hour = hour;
this.country = country;
this.city = city;
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);
}
}
}
- 现在,让我们编写使用此接口的示例代码:
Traffic api = new TrafficImpl(Month.APRIL, DayOfWeek.FRIDAY,
17, "USA", "Denver", "Main103S");
double timeSec = 10.0;
int trafficUnitsNumber = 10;
api.speedAfterStart(timeSec, trafficUnitsNumber);
我们得到以下类似的结果:

如前所述,由于我们使用的是真实数据,相同的代码每次并不产生完全相同的结果。不应该期望看到与前面的截图中的速度值相同,而应该看到非常相似的结果。
- 让我们使用 lambda 表达式。前面的 API 相当有限。例如,它不允许你在不更改
FactorySpeedModel的情况下测试不同的速度计算公式。同时,SpeedModel接口只有一个名为getSpeedMph()的抽象方法:
public interface SpeedModel {
double getSpeedMph(double timeSec, int weightPounds,
int horsePower);
}
这使其成为一个功能接口,我们可以利用这一事实,并添加另一个方法到我们的 API 中,该方法能够接受SpeedModel实现作为 lambda 表达式:
public interface Traffic {
void speedAfterStart(double timeSec, int trafficUnitsNumber,
SpeedModel speedModel);
}
然而,问题在于牵引力值并不是作为参数传递给getSpeedMph()方法的,因此我们无法在将其传递给我们的 API 方法之前将其实现为一个函数。更仔细地看看速度计算:
double weightPower =
2.0 * horsePower * 746 * 32.174/weightPounds;
double speed = Math.round(Math.sqrt(timeSec * weightPower)
* 0.68 * traction);
当你这样做时,你会注意到牵引力作为速度值的简单乘数,因此我们可以在速度计算之后应用它(并避免调用FactorySpeedModel):
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 = Math.round(speed * tu.getTraction());
printResult(tu, timeSec, speed);
}
}
此更改允许 API 用户将SpeedModel作为函数传递:
Traffic api = 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 Math.round(Math.sqrt(t * weightPower) * 0.68);
};
api.speedAfterStart(timeSec, trafficUnitsNumber, speedModel);
- 此代码的结果与
FactorySpeedModel生成的SpeedModel相同。但现在 API 用户可以提出他们自己的速度计算函数。例如,他们可以编写以下内容:
Vehicle vehicle = FactoryVehicle.build(trafficUnit);
SpeedModel speedModel = (t, wp, hp) -> {
return -1.0;
};
vehicle.setSpeedModel(speedModel);
printResult(trafficUnit, timeSec, vehicle.getSpeedMph(timeSec));
结果将如下所示:

-
将
SpeedModel接口注释为@FunctionalInterface,这样任何试图向其中添加另一个方法的人都会得到忠告,并且在没有移除此注释并意识到破坏已实现此功能接口的现有客户端代码的风险的情况下,将无法添加另一个抽象方法。 -
通过添加各种标准,将所有可能的流量切割成各个部分,来改进 API。
例如,API 用户可能只想分析汽车、卡车、发动机功率大于 300 马力的汽车、发动机功率大于 400 马力的卡车等等。完成此任务的传统方法可能是创建如下方法:
void speedAfterStartCarEngine(double timeSec,
int trafficUnitsNumber, int horsePower);
void speedAfterStartCarTruckOnly(double timeSec,
int trafficUnitsNumber);
void speedAfterStartCarTruckEngine(double timeSec,
int trafficUnitsNumber, int carHorsePower,
int truckHorsePower);
相反,我们只需向现有方法添加标准功能接口,并让 API 用户决定提取哪部分流量:
void speedAfterStart(double timeSec, int trafficUnitsNumber,
SpeedModel speedModel,
Predicate<TrafficUnit> limitTraffic);
实现看起来可能如下所示:
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 = Math.round(speed * tu.getTraction());
printResult(tu, timeSec, speed);
}
}
}
API 用户可以像以下这样调用它:
Predicate<TrafficUnit> limitTraffic = tu ->
(tu.getHorsePower() < 250 && tu.getVehicleType()
== VehicleType.CAR) || (tu.getHorsePower() < 400
&& tu.getVehicleType()==VehicleType.TRUCK);
api.speedAfterStart(timeSec, trafficUnitsNumber,
speedModel, limitTraffic);
现在的结果仅限于发动机功率小于 250 马力的汽车和发动机功率小于 400 马力的卡车:

实际上,API 用户现在可以应用任何适用于TrafficUnit对象中值的限制标准。例如,用户可以编写以下内容:
Predicate<TrafficUnit> limitTraffic2 =
tu -> tu.getTemperature() > 65
&& tu.getTireCondition() == TireCondition.NEW
&& tu.getRoadCondition() == RoadCondition.WET;
或者,他们可以编写任何其他来自TrafficUnit的值限制的组合。如果用户决定移除限制并分析所有交通,此代码也将执行此操作:
api.speedAfterStart(timeSec, trafficUnitsNumber,
speedModel, tu -> true);
- 允许将计算出的速度值包含在标准中。一种方法是将实现更改如下:
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 = Math.round(speed * tu.getTraction());
if(limitSpeed.test(tu, speed)){
printResult(tu, timeSec, speed);
}
}
}
因此,API 将看起来像这样:
void speedAfterStart(double timeSec, int trafficUnitsNumber,
SpeedModel speedModel,
BiPredicate<TrafficUnit, Double> limitSpeed);
客户端代码可能如下所示:
BiPredicate<TrafficUnit, Double> limitSpeed = (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);
api.speedAfterStart(timeSec, trafficUnitsNumber,
speedModel, limitSpeed);
此示例通过超出不同驾驶条件下不同缓冲区的速度来限制交通。如果需要,它可以完全忽略速度,并以与先前谓词相同的方式限制交通。这种实现的唯一缺点是它稍微不太高效,因为谓词是在速度计算之后应用的。这意味着速度计算将针对每个生成的交通单元进行,而不是像先前实现中那样限制在有限的数量。如果这是一个问题,你可以在 API 中保留所有不同的签名:
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 用户提供输出格式的选择。目前,它实现为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 api = new TrafficImpl(Month.APRIL, DayOfWeek.FRIDAY, 17,
"USA", "Denver", "Main103S");
double timeSec = 10.0;
int trafficUnitsNumber = 10;
BiConsumer<TrafficUnit, Double> output = (tm, sp) ->
System.out.println("Road " + tm.getRoadCondition() + ", tires "
+ tm.getTireCondition() + ": "
+ tm.getVehicleType().getType() + " speedMph ("
+ timeSec + " sec)=" + sp + " mph");
api.speedAfterStart(timeSec, trafficUnitsNumber, speedModel, output);
注意,我们不是将timeSec值作为函数参数之一,而是从函数的封闭作用域中获取。我们可以这样做,因为它在整个计算过程中保持不变(并且可以有效地视为 final)。以同样的方式,我们可以将任何其他对象添加到output函数中——例如,一个文件名或另一个输出设备——从而将所有与输出相关的决策留给 API 用户。为了适应这个新函数,API 实现改为以下:
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 = Math.round(speed * tu.getTraction());
printResult.accept(tu, speed);
}
}
我们花了很长时间才达到这个点,即函数式编程的力量开始闪耀并证明学习它的努力是值得的。然而,结合下一章中描述的响应式流,这个 Java 新增功能带来了更多的力量。在下一章中,这种增强的动机变得更加明显和完全得到认可。
参见
参考第五章 Stream Operations and Pipelines,流操作和管道
第五章:流操作和管道
在最新的 Java 版本(8 和 9)中,通过利用 lambda 表达式引入了流和内部迭代,集合 API 得到了重大改进。本章将向您展示如何利用流在集合上链式执行多个操作以创建管道。此外,我们还想向您展示这些操作如何并行执行。我们将涵盖以下食谱:
-
使用新的工厂方法创建集合对象
-
创建和操作流
-
在流上创建操作管道
-
流的并行计算
简介
在上一章中描述和演示的 lambda 表达式是在 Java 8 中引入的。与函数式接口一起,它们为 Java 添加了函数式编程能力,允许将行为(函数)作为参数传递给针对数据处理性能优化的库。这样,应用程序程序员可以专注于开发系统的业务方面,将性能方面留给专家——库的作者。
这样的库的一个例子是java.util.stream包,这个包将是本章的重点。这个包允许你以声明性的方式展示可以随后应用于数据的程序,也可以并行执行;这些程序以流的形式呈现,是Stream接口的对象。为了更好地从传统集合过渡到流,java.util.Collection接口中添加了两个默认方法(stream()和parallelStream()),同时还在Stream接口中添加了新的流生成工厂方法。
这种方法利用了在前面章节中讨论的复合能力的优势。与其他设计原则——封装、接口和多态性一起,它促进了高度可扩展和灵活的设计,而 lambda 表达式允许你以简洁的方式实现它。
现在,当大规模数据处理的需求和操作的微调变得普遍时,这些新特性加强了 Java 在现代编程语言选择中的地位。
使用新的工厂方法创建集合对象
在这个食谱中,我们将回顾传统的创建集合的方法,并介绍 Java 9 带来的新工厂方法,即List.of()、Set.of()、Map.of()和Map.ofEntries()。
准备工作
在 Java 9 之前,有几种创建集合的方法。以下是最流行的创建List的方法:
List<String> list = new ArrayList<>();
list.add("This ");
list.add("is ");
list.add("built ");
list.add("by ");
list.add("list.add()");
//Let us print the created list:
list.forEach(System.out::print);
注意到在 Java 8 中添加到Iterable接口的默认方法forEach(Consumer)的使用。
如果我们运行前面的代码,我们会得到以下结果:

做这件事的更简单的方法是从一个数组开始:
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() ");
//Now print the created set:
set.forEach(System.out::print);
或者,我们可以写出以下内容:
new HashSet<>(Arrays.asList("This ", "is ", "created ", "by ",
"new HashSet(Arrays.asList()) "))
.forEach(System.out::print);
下面是最后两个示例结果的说明:

注意,与List不同,Set中元素的顺序不被保留。它取决于哈希码实现,并且可能因计算机而异。但在同一台计算机上的运行之间顺序保持不变(请注意这个事实,因为我们稍后会回到它)。
相同的结构,即元素顺序,也适用于Map。这就是我们在 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() ");
//Print the results:
map.entrySet().forEach(System.out::print);
上述代码的输出如下:

那些经常需要创建集合的人会欣赏 JDK 增强提案 269(JEP 269)——集合的便利工厂方法,该提案指出Java 经常因其冗长而受到批评,其目标是在集合接口上提供静态工厂方法,以创建紧凑、不可修改的集合实例。
作为对批评和建议的回应,Java 9 为三个接口中的每一个引入了十二个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)
还为Set和Map添加了十二个类似的静态方法。十个具有固定元素数量的重载工厂方法被优化以提高性能,并且如 JEP 269 所述,这些方法避免了由 varargs 调用产生的数组分配、初始化和垃圾回收开销。
相同示例的代码现在变得更加紧凑:
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接口的十二个静态工厂方法中的一个与其他of()方法不同:
static <K,V> Map<K,V> ofEntries(Map.Entry<? extends K,
? extends 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()。
如何做到这一点...
你可能已经注意到了Set.of()、Map.of()和Map.ofEntries()方法不保留其元素的顺序。这与 Java 9 之前的Set和Map实例不同;现在元素的顺序在运行之间会发生变化,即使在同一台计算机上也是如此(但在同一运行期间不会变化,无论集合被迭代多少次)。这是一个旨在帮助程序员避免依赖特定顺序的故意特性,因为当代码在另一台计算机上运行时,顺序可能会发生变化。
新的List、Set和Map接口的of()静态方法生成的集合的另一个特性是这些集合是不可变的。这是什么意思?考虑以下代码:
List<String> list = List.of("This ", "is ", "immutable");
list.add("Is it?");
这意味着前面的代码在运行时会抛出java.lang.UnsupportedOperationException异常,而下面的代码也会抛出相同的异常:
List<Integer> list = List.of(1,2,3,4,5);
list.set(2, 9);
此外,由新的of()静态方法生成的集合不允许包含 null 元素,下面的代码在运行时也会抛出java.lang.NullPointerException异常:
List<String> list = List.of("This ", "is ", "not ", "created ", null);
还有更多...
非空值和不可变性保证在 lambda 和流引入后不久就被添加,这并非偶然。正如你将在后续食谱中看到的,函数式编程和流管道鼓励一种流畅的编码风格(使用方法链,就像我们在本食谱示例中使用forEach()方法时做的那样)。这种流畅风格提供了更紧凑和可读的代码,非空保证通过消除检查null值的需要来支持它。
不可变特性反过来又很好地与 lambda 表达式在外部上下文中使用的变量的实际最终概念相一致。例如,可变集合允许你绕过这种限制,以下代码:
List<Integer> list = Arrays.asList(1,2,3,4,5);
list.set(2, 0);
list.forEach(System.out::print);
list.forEach(i -> {
int j = list.get(2);
list.set(2, j + 1);
});
System.out.println();
list.forEach(System.out::print);
此代码生成以下输出:

这意味着有意或无意地在 lambda 表达式中引入状态,并导致同一函数在不同上下文中有不同的输出。这在并行处理中尤其危险,因为无法预测每个可能上下文的状态。这就是为什么集合的不可变性是一个有用的补充,它有助于使代码更加健壮和可靠。
参考以下内容
参考本章以下食谱:
-
创建和操作流
-
在流上创建操作管道
创建和操作流
在这个食谱中,我们将描述如何创建流以及可以应用于流输出的元素的运算。
准备工作
创建流有许多方法。自从 Java 8 以来,Collection接口有了stream()方法,该方法返回一个顺序流,该集合作为其源,以及parallelStream()方法,该方法返回一个可能并行流,该集合作为其源。这意味着包括Set和List在内的所有子接口也有这些方法。此外,Arrays类还增加了八个重载的stream()方法,可以从相应的数组或子集创建不同类型的流。
Stream接口有of()、generate()和iterate()方法。专门的接口IntStream、DoubleStream和LongStream也有类似的方法,而IntStream还有range()和rangeClosed()方法;两者都返回IntStream。
JDK 中有许多产生流的函数,例如Files.list()、Files.lines()、Files.find()、BufferedReader.lines()等。
流创建后,可以对其元素应用各种操作。流本身不存储数据。它更像是根据需要从源(并提供或输出给操作)获取它们。由于许多中间操作也可以返回流,因此操作可以形成一个使用流畅式风格的管道。这些操作被称为中间操作。中间操作的例子包括filter()(仅选择符合标准的元素)、map()(根据函数转换元素)、distinct()(删除重复项)、limit()(将流限制为指定的元素数量)、sorted()(将未排序的流转换为排序后的流),以及Stream接口返回Stream的其他方法(除了我们刚才提到的创建流的方法)。
管道以终端操作结束。流元素的加工实际上只有在执行终端操作时才开始。然后,所有中间操作(如果有的话)开始处理,一旦终端操作执行完毕,流就会关闭,并且无法重新打开。终端操作的例子有forEach()、findFirst()、reduce()、collect()、sum()、max()以及Stream接口的其他不返回Stream的方法。终端操作返回一个结果或产生副作用。
所有的Stream方法都支持并行处理,这在处理大量数据的多核计算机上特别有帮助。所有的 Java Stream API 接口和类都在java.util.stream包中。
在本食谱中,我们将主要演示由stream()方法及其类似方法创建的顺序流。并行流的处理与顺序流没有太大区别。只需注意处理管道不要使用可能在不同处理环境中变化的上下文状态。我们将在本章后面的另一个食谱中讨论并行处理。
如何实现...
在本食谱的这一部分,我们将介绍流创建的方法。正如引言中提到的,每个实现了Set接口或List接口的类都有stream()方法和parallelStream()方法,这两个方法返回Stream接口的实例。目前,我们将只查看由stream()方法创建的顺序流,稍后再回到并行流。
- 考虑以下流创建的示例:
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()以开始新的一行。
- 运行前面的示例并查看结果:

注意,List保留了元素的顺序,而Set元素的顺序在每次运行时都会改变。这有助于揭示基于某些顺序的缺陷,当这个顺序无法保证时。
- 查看一下
Arrays类的 Javadoc。它有八个重载的stream()方法:
static DoubleStream stream(double[] array)
static DoubleStream stream(double[] array, int startInclusive,
int endExclusive)
static IntStream stream(int[] array)
static IntStream stream(int[] array, int startInclusive,
int endExclusive)
static LongStream stream(long[] array)
static LongStream stream(long[] array, int startInclusive,
int endExclusive)
static <T> Stream<T> stream(T[] array)
static <T> Stream<T> stream(T[] array, int startInclusive,
int endExclusive)
- 编写最后两个方法的用法示例:
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);
- 运行它并查看结果:

注意,只有前两个元素(索引为0和1)被选中包含在流中,正如前面的第二个示例所意图的。
- 现在打开
Stream接口的 Javadoc,查看工厂方法of()、generate()和iterate():
static <T> Stream<T> of(T t) //Returns Stream of one
static <T> Stream<T> ofNullable(T t)//Returns Stream of one
// element, if non-null, otherwise returns an empty Stream
static <T> Stream<T> of(T... values)
static <T> Stream<T> generate(Supplier<? extends T> s)
static <T> Stream<T> iterate(T seed, UnaryOperator<T> f)
static <T> Stream<T> iterate(T seed,
Predicate<? super T> hasNext, UnaryOperator<T> next)
前两个方法很简单,所以我们跳过它们的演示,从第三个方法of()开始。它可以接受一个数组或逗号分隔的元素。
- 按如下方式编写示例:
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);
- 运行它并观察输出:

- 按如下方式编写
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);
我们不得不对前两个示例生成的流的尺寸进行限制。否则,它们将是无限的。第三个示例接受一个谓词,它提供了迭代何时停止的标准。
- 运行示例并观察结果:

- 现在为专门的接口
IntStream、DoubleStream和LongStream编写示例。它们的实现通过避免装箱和拆箱的开销,在处理原始数据时优化了性能。此外,IntStream接口还有两个额外的流生成方法,即range()和rangeClosed()。两者都返回IntStream:
System.out.print("IntStream.range(0,10): ");
IntStream.range(0, 9).forEach(System.out::print);
System.out.println();
System.out.print("IntStream.rangeClosed(0,10): ");
IntStream.rangeClosed(0, 9).forEach(System.out::print);
- 运行它们并查看输出:

如你所猜,range()方法通过增量步长1生成整数序列,从左参数开始但不包括右参数。而rangeClosed()方法生成一个类似的序列,包括右参数。
- 现在让我们看看
Files.list(Path dir)方法的示例,它返回目录中所有条目的Stream<Path>:
System.out.println("Files.list(dir): ");
Path dir = FileSystems.getDefault()
.getPath("src/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 块中关闭流,结果不会改变。
- 运行示例并观察输出:

明确关闭流的需求可能会让人困惑,因为Stream接口扩展了AutoCloseable接口,因此人们会预期流会自动关闭。但实际上并非如此。以下是Stream接口的 Javadoc 对这一点的说明:流有一个BaseStream.close()方法,并实现了AutoCloseable接口。大多数流实例在使用后实际上不需要关闭,因为它们由集合、数组或生成函数支持,这些不需要特殊资源管理。通常,只有源为 I/O 通道的流,例如由Files.lines(Path)返回的流,才需要关闭。这意味着程序员必须知道流的来源,并确保在 API 要求时关闭它。
- 请看
Files.lines()方法的使用示例:
System.out.println("Files.lines().limit(3): ");
String file = "src/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(); }
目的是读取指定文件的头三行,并打印带有三个空格缩进的非空行。
- 运行它并查看结果:

请看ind()方法的使用示例:
static Stream<Path> find(Path start, int maxDepth,
BiPredicate<Path, BasicFileAttributes> matcher,
FileVisitOption... options)
- 与前一个案例类似,由该方法生成的流也必须显式关闭。
find()方法遍历以给定起始文件为根的文件树和请求的深度,并返回匹配谓词(包括文件属性)的文件路径。现在请编写以下代码:
Path dir = FileSystems.getDefault()
.getPath("src/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(); }
- 运行它,你会得到以下输出:

如果需要,可以将FileVisitorOption.FOLLOW_LINKS作为Files.find()的最后一个参数包含在内,如果我们需要执行会跟随所有遇到的符号链接的搜索。
使用BufferedReader.lines()方法(该方法返回从文件中读取的Stream<String>行)的要求略有不同。根据 Javadoc,在终端流操作执行期间,不得对读取器进行操作。否则,终端流操作的结果是未定义的。
JDK 中还有许多其他方法可以生成流。但它们更专业化,我们不会在这里演示它们,因为空间有限。
它是如何工作的...
在前面的示例中,我们已经展示了几个流操作——Stream接口的方法。我们最常使用forEach(),几次使用了limit()。第一个是一个终端操作,第二个是一个中间操作。现在让我们看看Stream接口的其他方法。以下是中间操作,返回Stream的方法,可以以流畅的方式连接:
Stream<T> peek(Consumer<T> action)
Stream<T> distinct() //Returns stream of distinct elements
Stream<T> skip(long n) //Discards the first n elements
Stream<T> limit(long max) //Discards elements after max
Stream<T> filter(Predicate<T> predicate)
Stream<T> dropWhile(Predicate<T> predicate)
Stream<T> takeWhile(Predicate<T> predicate)
Stream<R> map(Function<T, R> mapper)
IntStream mapToInt(ToIntFunction<T> mapper)
LongStream mapToLong(ToLongFunction<T> mapper)
DoubleStream mapToDouble(ToDoubleFunction<T> mapper)
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)
static Stream<T> concat(Stream<T> a, Stream<T> b)
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("\nsum = " + sum);
结果如下:

在第二个中间操作组中,前三个是自解释的。filter() 方法是最常用的操作之一。它做其名字暗示的事情;它丢弃了匹配传递给方法的 Predicate 函数作为标准的元素。我们在上一段代码的示例中看到了它的用法。dropWhile() 方法在满足标准的情况下丢弃元素(然后允许流中的其余元素流向下一个操作)。takeWhile() 方法做相反的事情;它在满足标准的情况下允许元素流动(然后丢弃其余元素)。以下是一个示例:
System.out.println("Files.lines().dropWhile().takeWhile(): ");
String file = "src/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(); }
这段代码读取存储此代码的文件。它丢弃了所有不包含 dropWhile().takeWhile() 子串的第一行,然后允许所有行流动,直到找到 } catch 子串。请注意,我们必须将这个字符串拆分为 "} catch" + "h",这样标准就不会对这一行返回 true。结果是以下内容:

map() 操作组也很简单。这种操作通过将传入的函数应用于每个流元素来转换流中的每个元素。我们已经看到了 mapToInt() 方法使用的一个例子。以下是 map() 操作的另一个例子:
Stream.of( "That ", "is ", "a ", "Stream.of(literals)" )
.map(s -> s.contains("i")).forEach(System.out::println);
在这个例子中,我们将 String 字面量转换为 boolean。结果如下:

下一个中间操作组,称为 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() 是一个返回其输入参数的函数(因为我们不需要转换输入流,只需将它们原样传递给结果流)。结果如下:

最后一个中间操作组由 sorted() 方法组成,这些方法按自然顺序(无参数)或指定顺序(根据传入的 Comparator)对流元素进行排序。这是一个有状态的操作(以及 distinct()、limit() 和 skip()),在并行处理的情况下会产生非确定性的结果;然而,这将是后续菜谱的主题。
现在让我们看看终端操作(我们也通过移除 ? super T 和 ? extends R 简化了它们的签名):
long count() //Returns count of elements
Optional<T> max(Comparator<T> comparator) //Max according
// to Comparator
Optional<T> min(Comparator<T> comparator) //Min according
// to Comparator
Optional<T> findAny() //Returns any or empty Optional
Optional<T> findFirst() //Returns the first element
// or empty Optional
boolean allMatch(Predicate<T> predicate) //All elements
// match Predicate?
boolean anyMatch(Predicate<T> predicate) //Any element
// match Predicate?
boolean noneMatch(Predicate<T> predicate) //No element
// match Predicate?
void forEach(Consumer<T> action) //Apply action to each el
void forEachOrdered(Consumer<T> action)
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)
R collect(Collector<T,A,R> collector)
R collect(Supplier<R> supplier, BiConsumer<R,T> accumulator,
BiConsumer<R,R> combiner)
Object[] toArray()
A[] toArray(IntFunction<A[]> generator)
前两组是自解释的,但我们需要说几句关于 Optional 的话。Javadoc 这样定义它:一个可能包含或不包含非空值的容器对象。如果存在值,isPresent() 返回 true,get() 返回该值。它允许你避免 NullPointerException 或检查 null(这会破坏代码的一行风格)。出于同样的原因,它有自己的方法: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): 如果存在值,则返回该值,或者抛出由异常供应函数产生的异常。
注意,当可能返回 null 时,使用 Optional 作为返回值。以下是一个使用示例。我们重新实现了使用返回 Optional 的 reduce() 操作的流连接代码:
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() 则保证。以下是一个同时使用 Optional 和 forEach() 的示例:
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()。这三个重载方法在处理所有流元素后返回一个单一值。最简单的例子是找到流元素的总和(如果它们是数字),或者最大、最小值等。但也可以为任何类型的对象流构建更复杂的结果。
第一种方法,即 reduce(BinaryOperator<T> accumulator),返回 Optional,因为计算结果的责任在于提供的累加器函数。而且,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 是第二个元素。结果如下:

为了避免使用Optional的额外步骤,第二个方法在流为空的情况下返回作为第一个参数identity的类型T(它是Stream<T>元素的类型)的值。此参数必须符合以下要求(来自 Javadoc):对于所有 t,accumulator.apply(identity, t)等于 t。在我们的情况下,它必须是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()方法相同。第三个方法使用BiFunction<U,T,U>函数将类型T的值转换为类型U的值。然后,结果(类型为R)通过与类型T相同的逻辑进行处理,就像上一个方法一样。以下是一个例子:
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));
这是结果:

下一个中间操作组,称为collect(),由两个方法组成。第一个方法接受Collector作为参数。它比第二个方法更受欢迎,因为它得到了Collectors类的支持,该类提供了Collector接口的多种实现。我们鼓励您查阅Collectors类的 Javadoc,看看它提供了什么。让我们讨论一些这个方法的例子。首先,我们将创建一个小的demo类:
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() 收集器来生成 List、Set 或 Map。
最后一个终端操作组包括两个 toArray() 方法。其中一个返回 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 中说:“生成器函数接收一个整数,它是所需数组的尺寸,并生成所需尺寸的数组。”这意味着在最后一个例子中,toArray(String[]::new) 构造函数的方法引用是 toArray(size -> new String[size]) 的简短版本。
还有更多...
java.util.stream 包还提供了专门接口,即 IntStream、DoubleStream 和 LongStream,它们针对处理相应原始类型值的流进行了优化。在执行减少操作时使用它们非常方便。例如,它们有 max()、min()、average()、sum() 以及许多其他可以直接作为中间和终端操作调用的简化(针对性能调优)方法。
参见
参考本章中的以下食谱:
-
在流上创建操作管道
-
在流上执行并行计算
在流上创建操作管道
在这个食谱中,你将学习如何从 Stream 操作构建管道。
准备中
在上一章中,当我们创建一个适合 lambda 表达式的 API 时,我们得到了以下 API 方法:
public interface Traffic {
void speedAfterStart(double timeSec,
int trafficUnitsNumber, SpeedModel speedModel,
BiPredicate<TrafficUnit, Double> limitTraffic,
BiConsumer<TrafficUnit, Double> printResult);
}
在 speedAfterStart() 方法内部生成了指定的 TrafficUnit 实例。它们被 limitTrafficAndSpeed 函数限制,并按照 speedAfterStart() 方法内部的 speedModel 函数进行处理。结果在设备上以及由 printResults 函数指定的格式中显示。
这是一个非常灵活的设计,允许你通过修改传递给 API 的函数进行相当广泛的实验。然而,在现实中,尤其是在数据分析的早期阶段,拥有一个 API 会带来开销。如果提供的灵活性不足,就需要对其进行更改,构建和部署新版本。这需要时间,而且实现缺乏透明度。
公平地说,封装实现细节是 API 设计要求之一。但是,它与需要封装为产品供用户群体使用的过程相结合时效果良好。在研究阶段,情况发生了根本性的变化。当开发并测试新的算法,以及需要处理大量数据时,其自身带来的挑战,使得整个开发系统的透明度成为一项基本要求。没有它,今天在大数据分析领域中的许多成功将是不可能的。
流和流操作管道解决了透明度问题,并最小化了编写基础设施代码的开销。
如何做到这一点...
让我们回顾一下用户是如何调用 lambda 友好的 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所取代。这意味着我们需要携带原始元素并在过程中添加新值。为了实现这一点,我们需要一个容器——一种交通单元包装器。让我们创建一个:
private static 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()));
代码看起来有点冗长,特别是Vehicle和SpeedModel设置。我们可以通过将它们移动到TrafficUnitWrapper类来隐藏这些管道细节:
private static 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()));
如果没有必要让速度计算的公式易于修改,我们可以通过将setSpeed()方法更改为calcSpeed()将这个公式也移动到TrafficUnitWrapper类中:
public 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]);
}
它使用了两个私有类,如下所示:
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());
}
}
它也使用了私有方法:
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() 方法创建,其底层实现都使用了在 Java 7 中引入的相同 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() 方法始终生成由 is 和 Stream.of(literals) 组成的相同列表,而 forEach() 有时会错过 is 或 Stream.of(literals)。
如果可能,首先尝试使用由 Collectors 类构建的收集器,并在并行计算期间避免使用共享资源。
总体来说,使用无状态函数是并行流管道的最佳选择。如果有疑问,测试你的代码,最重要的是,多次运行相同的测试以检查结果是否稳定。
参见
参考第七章 Concurrent and Multithreaded Programming,了解更多关于 java.util.stream 包中类使用示例。
第六章:数据库编程
本章涵盖了 Java 应用程序和数据库(DB)之间基本和常用的交互,从连接到数据库和执行 CRUD 操作,到创建事务、存储过程和与 大型对象 (LOBs) 一起工作。我们将涵盖以下食谱:
-
使用 JDBC 连接到数据库
-
设置用于数据库交互所需的表格
-
执行 CRUD 操作
-
使用预处理语句
-
使用事务
-
处理大型对象
-
执行存储过程
简介
很难想象一个复杂的软件应用程序不会使用某种类型的数据存储。一种结构化和可访问的数据存储称为数据库。这就是为什么任何现代语言实现都包括一个框架,允许你访问数据库并在其中 创建、读取、更新和删除 (CRUD) 数据。在 Java 中,提供这种访问的是 Java 数据库连接 (JDBC) API,它根据 Javadoc 的描述,可以访问 几乎所有数据源,从关系数据库到电子表格和平面文件。
组成 JDBC API 的 java.sql 和 javax.sql 包包含在 Java 平台标准版 (Java SE) 中。java.sql 包提供了 访问和处理存储在数据源中(通常是关系数据库)的数据的 API。 javax.sql 包提供了服务器端数据源访问和处理的 API。具体来说,它提供了 DataSource 接口以建立与数据库的连接、连接和语句池、分布式事务和行集。我们将在本章的食谱中更详细地讨论这些功能。
然而,要实际将 DataSource 连接到物理数据库,还需要一个数据库特定的驱动程序(由数据库供应商提供,例如 MySQL、Oracle、PostgreSQL 或 SQL 服务器数据库等)。这些驱动程序可能用 Java 编写,也可能是 Java 和 Java Native Interface (JNI) 本地方法的混合。此驱动程序实现了 JDBC API。
与数据库一起工作涉及八个步骤:
-
按照供应商的说明安装数据库。
-
将数据库特定驱动程序的
.jar依赖添加到应用程序中。 -
创建用户、数据库和数据库模式:表、视图、存储过程等。
-
从应用程序连接到数据库。
-
构建一个 SQL 语句。
-
执行 SQL 语句。
-
使用执行结果。
-
关闭数据库连接和其他资源。
步骤 1-3 在应用程序运行之前,在数据库设置阶段只执行一次。
步骤 4-8 根据需要由应用程序重复执行。
步骤 5-7 可以使用相同的数据库连接重复多次。
使用 JDBC 连接到数据库
在本食谱中,你将学习如何连接到数据库。
如何做到这一点...
-
选择你想要工作的数据库。有好的商业数据库和好的开源数据库。我们唯一要假设的是你选择的数据库支持 结构化查询语言(SQL),这是一种标准化的语言,允许你在数据库上执行 CRUD 操作。在我们的食谱中,我们将使用标准 SQL 并避免特定于特定数据库类型的构造和过程。
-
如果数据库尚未安装,请按照供应商的说明进行安装。然后,下载数据库驱动程序。最流行的是类型 4 和 5,用 Java 编写。它们非常高效,并通过套接字连接与数据库服务器通信。如果包含此类驱动程序的
.jar文件放置在类路径上,它将自动加载。类型 4 和 5 驱动程序是数据库特定的,因为它们使用数据库本机协议来访问数据库。我们将假设你正在使用此类类型的驱动程序。
如果你的应用程序需要访问多种类型的数据库,那么你需要一个类型 3 的驱动程序。这样的驱动程序可以通过一个中间件应用程序服务器与不同的数据库进行通信。
类型 1 和 2 的驱动程序仅在没有其他数据库驱动程序类型可用时使用。
-
将下载的
.jar文件(包含驱动程序)添加到你的应用程序的类路径中。现在你可以创建一个数据库,并通过你的应用程序访问它。 -
你的数据库可能有一个控制台、一个图形用户界面(GUI)或某种其他与它交互的方式。阅读说明,首先创建一个用户,即
cook,然后创建一个数据库,即cookbook。
例如,以下是为 PostgreSQL 执行此操作的命令:
CREATE USER cook SUPERUSER;
CREATE DATABASE cookbook OWNER cook;
我们为我们的用户选择了 SUPERUSER 角色;然而,一个良好的安全实践是将如此强大的角色分配给管理员,并创建另一个特定于应用程序的用户,该用户可以管理数据但不能更改数据库结构。创建另一个名为模式(schema)的逻辑层是一个好习惯,它可以有自己的用户和权限集合。这样,同一个数据库中的多个模式可以隔离,每个用户(其中之一是你的应用程序)将只能访问特定的模式。
- 此外,在企业级应用中,常见的做法是为数据库模式创建同义词,这样没有任何应用程序可以直接访问原始结构。你也可以为每个用户创建密码,但,再次强调,对于本书的目的,这并不是必需的。因此,我们将这项工作留给数据库管理员,由他们根据每个企业的特定工作条件建立规则和指南。
现在我们将我们的应用程序连接到数据库。在我们的演示中,我们将使用,正如你可能已经猜到的,开源(免费)的 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 为真/假,autoReconnect 为真/假,connectTimeout 以秒为单位等)传递给创建连接的方法。传递的属性键对于所有主要数据库类型都是相同的,但其中一些是数据库特定的。
作为替代,如果我们只想传递用户名和密码,我们可以使用第三个重载版本,即 DriverManager.getConnection(String url, String user, String password)。值得一提的是,保持密码加密是一个好的实践。我们不会展示如何做这件事,但网上有很多指南。
此外,getConnection() 方法会抛出 SQLException,因此我们需要将其包裹在 try...catch 块中。
为了隐藏所有这些和其他管道,将连接建立代码放在一个方法中是一个好主意:
private static 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.PGSimpleDataSource 和 org.postgresql.ds.PGPoolingDataSource。我们可以使用它们来代替 DriverManager。以下是 PGSimpleDataSource 的使用示例:
private static 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 的使用示例:
private static 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 连接时可用的一些功能。
无论你选择哪种版本的 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.AutoCloseable 或 java.io.Closeable 接口的任何对象一起使用。
参见
参考本章中的以下配方:
- 设置用于数据库交互所需的表
设置用于数据库交互所需的表
在这个配方中,你将学习如何创建、更改和删除表以及其他构成数据库模式的逻辑数据库结构。
准备工作
标准的 SQL 表创建语句看起来是这样的:
CREATE TABLE table_name (
column1_name data_type(size),
column2_name data_type(size),
column3_name data_type(size),
....
);
在这里,table_name 和 column_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
);
在这里,我们没有设置 VARCHAR 类型列的大小,因此允许这些列存储任何长度的值。在这种情况下,integer 类型允许你存储从 -2147483648 到 +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_type、road_condition 和 tire_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
);
现在必须使用 enums 表中相应记录的主键来填充 vehicle_type、road_condition 和 tire_condition 列。这样,我们可以确保我们的交通分析代码能够将这些列中的值与代码中 enum 类型的值相匹配。
还有更多...
enums 表不应有重复的类型-值组合,因为这可能会导致混淆,尤其是在填充 traffic_unit 表的代码在 enums 表中查找必要的 id 时。查询将返回两个值,那么选择哪一个呢?为了避免重复,我们可以在 enums 表中添加一个唯一约束:
ALTER TABLE enums ADD CONSTRAINT enums_unique_type_value
UNIQUE (type, value);
现在如果我们尝试添加一个重复项,数据库将不允许这样做。
数据库表创建的另一个重要考虑因素是是否需要添加索引。索引是一种数据结构,它有助于加速表中的数据搜索,而无需检查每个表记录。它可以包括一个或多个表的列。例如,主键的索引是自动创建的。如果你查看我们已创建的表的描述,你会看到以下内容:
Indexes: "traffic_unit_pkey" PRIMARY KEY, btree (id)
如果我们认为(并通过实验证明)它将有助于应用程序性能,我们也可以自己添加索引。在 traffic_unit 的例子中,我们发现我们的代码经常通过 vehicle_type 和 passengers_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 的命名约定。如果你使用不同的数据库,我们建议你查找其命名约定并遵循它,以便你的命名与自动创建的名称相一致。
参考以下内容
参考本章中的以下食谱:
-
执行 CRUD 操作
-
处理大型对象
执行 CRUD 操作
在这个食谱中,你将学习如何在数据库中填充、读取、更改和删除数据。
准备工作
我们已经看到了创建(填充)数据库中数据的 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;
这也是在所有列都必须按顺序选择时所做的:
SELECT * 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构造可以与逻辑运算符AND和OR结合,并用括号(和)分组。
数据可以通过UPDATE语句进行更改:
UPDATE table_name SET column1=value1,column2=value2,...
WHERE-clause;
或者,它也可以通过DELETE语句删除:
DELETE FROM table_name WHERE-clause;
没有使用WHERE子句,UPDATE或DELETE语句将影响表中的所有记录。
如何做到这一点...
我们已经看到了一个INSERT语句。这里是一个其他类型语句的例子:

前面的SELECT语句需要检索表中所有行的所有列。如果行数超过了屏幕上的行数,数据库控制台将只显示第一屏,你需要输入一个命令(特定于数据库)来显示下一屏:

这个SELECT语句有一个WHERE子句,要求你只显示那些type列中的值为vehicle且value列中的值不为crewcab的行。它还要求你显示value列中的值为new的行:

前面的截图捕获了三个语句。第一个是一个UPDATE语句,要求你将value列中的值更改为NEW,但只在没有value列中的值为NEW的行中更改(显然,值是区分大小写的)。第二个语句要求你删除所有value列中没有NEW值的行。第三个语句是SELECT,我们刚刚讨论过。
值得注意的是,如果我们不能删除traffic_unit表中的这些记录,因为它们作为外键被引用,我们就无法删除enums表中的记录。只有删除traffic_unit表中的相应记录后,我们才能这样做。但是,现在,也就是说,为了演示目的,我们保持traffic_unit表为空。
要在代码中执行任何 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(); }
对于Statement对象,使用try-with-resources构造也是良好的实践。Connection对象的关闭会自动关闭Statement对象。然而,当你显式关闭Statement对象时,清理会立即发生,而不是等待必要的检查和操作通过框架的各个层传播。
execute() 方法是三种可以执行语句的方法中最通用的一个。其他两种包括 executeQuery()(仅用于 SELECT 语句)和 executeUpdate()(用于 UPDATE、DELETE、CREATE 或 ALTER 语句)。正如您在我们的示例中所看到的,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 语句,因为程序员通常必须以针对执行 SQL 语句特定的方式进行数据提取。相比之下,executeUpdate() 的调用可以封装在一个通用方法中:
private static 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关键字来去除所有重复的值 -
添加
ORDER BY关键字将结果按指定顺序呈现 -
关键字
LIKE允许您将搜索模式设置为WHERE子句 -
搜索模式可以使用多个通配符:
%,_,[字符列表],[^字符列表]或[!字符列表] -
可以使用
IN关键字列举匹配的值 -
SELECT语句可以使用JOIN子句包含多个表 -
SELECT * INTO table_2 from table_1创建table_2表并从table_1表复制数据 -
当删除一个表的全部行时,
TRUNCATE更快且使用的资源更少
ResultSet 接口还有许多其他有用的方法。以下是一些方法如何被用来编写通用的代码,该代码将遍历返回的结果并使用元数据来打印出列名和返回的值:
private static 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") 方法,结果将如下所示:

参见
参考本章以下菜谱:
-
使用预编译语句
-
使用事务
-
处理大型对象
-
执行存储过程
使用预编译语句
在这个菜谱中,你将学习如何使用预编译语句:这是一种可以存储在数据库中并使用不同输入值高效执行的语句模板。
准备工作
PreparedStatement 对象——Statement 的子接口——可以预先编译并存储在数据库中,然后用于针对不同输入值多次高效执行 SQL 语句。类似于通过 createStatement() 方法创建的 Statement 对象,它可以通过同一 Connection 对象的 prepareStatement() 方法创建。
还有一种声明的第三种版本,它创建了一个名为 prepareCall() 的方法,该方法反过来创建用于执行数据库存储过程的 CallableStatement 对象,但我们将在此后的单独菜谱中讨论这一点。
用于生成 Statement 的相同 SQL 语句也可以用于生成 PreparedStatement。实际上,考虑为任何多次调用的 SQL 语句使用 PreparedStatement 是一个好主意,因为它比 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 TABLE、ALTER TABLE 等)会自动提交,并且无法从 Java 代码中进行控制。
默认情况下,CRUD 操作事务也被设置为自动提交。这意味着由 SQL 语句引入的每个数据更改都会在 SQL 语句执行完成后立即应用到数据库中。所有前面的示例都使用了这种默认行为。
要改变这一点,必须使用 Connection 对象的 setAutoCommit() 方法。如果设置为 false,即 setAutoCommit(false),则数据更改将不会在调用 Connection 对象上的 commit() 方法之前应用到数据库中。如果调用 rollback() 方法,则自事务开始以来或自上次调用 commit() 以来所做的所有数据更改将被丢弃。
显式的程序性事务管理可以提高性能,但在调用一次且不常调用的短原子操作的情况下,这是微不足道的。当多个操作引入必须应用的变化时,接管事务控制变得至关重要。它允许您将数据库更改分组为原子单元,从而避免意外违反数据完整性。
如何做到这一点...
首先,让我们向 traverseRS() 方法添加一个输出:
private static 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 = "insert 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(); }
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 语句失败了,所以没有可以回滚的内容。
现在让我们使用数据库控制台创建一个test表:

我们将使用这个表格来记录插入到enums表中的记录的车辆类型:
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");
在注释掉conn.rollback()之后,结果如下:

带有truck的行没有在enums表中插入,而是添加到了test表中,尽管我们的意图是记录在enums中插入的所有车辆,并且只在test表中记录它们。这就是回滚的有用之处可以体现出来的时候。如果我们取消注释conn.rollback(),结果将如下:

还有更多...
事务的一个重要属性是事务隔离级别。它定义了数据库用户之间的边界。例如,其他用户在提交之前能看到你的数据库更改吗?隔离级别越高(最高是可序列化),在并发访问相同记录的情况下,事务完成所需的时间就越长。隔离级别越不严格(最不严格的是读取未提交),数据就越脏;这是因为其他用户可以获取你最终不会提交的值。
通常,使用默认级别就足够了,尽管它可能因数据库而异,通常是TRANSACTION_READ_COMMITTED。JDBC 允许你使用名为getTransactionIsolation()的Connection方法获取当前事务隔离级别,而setTransactionIsolation()方法允许你根据需要设置任何其他级别。
在关于哪些更改需要提交以及哪些需要回滚的复杂决策逻辑中,可以使用两个Connection方法来创建和删除保存点。setSavepoint(String savepointName)方法创建一个新的保存点并返回一个Savepoint对象,该对象可以稍后用于使用rollback (Savepoint savepoint)方法回滚到这个点之前的所有更改。可以通过调用releaseSavepoint(Savepoint savepoint)来删除保存点。
最复杂的数据库事务类型是分布式事务。有时它们被称为全局事务、XA 事务或JTA 事务(后者是一个 Java API,由两个 Java 包组成,即javax.transaction和javax.transaction.xa)。它们允许你创建和执行跨越两个不同数据库的操作的事务。提供分布式事务的详细概述超出了本书的范围。
处理大型对象
在这个菜谱中,你将学习如何存储和检索一个可以是三种类型之一的 LOB:二进制大对象(BLOB)、字符大对象(CLOB)和国家字符大对象(NCLOB)。
准备工作
在数据库内部对 LOB 对象的实际处理是供应商特定的,但 JDBC API 通过将三种 LOB 类型表示为接口来隐藏这些实现细节:java.sql.Blob、java.sql.Clob和java.sql.NClob。
Blob通常用于存储图像或其他非字母数字数据。在通往数据库的路上,一张图像可以被转换成字节流并使用INSERT INTO语句进行存储。Blob接口允许你找到对象长度并将其转换为 Java 可以处理的字节数组,例如用于显示图像。
Clob允许你存储字符数据。NClob以支持国际化的方式存储 Unicode 字符数据。它扩展了Clob接口并提供了相同的方法。这两个接口都允许你找到 LOB 的长度和值内的子字符串。
ResultSet、CallableStatement(我们将在下一道菜谱中讨论)和PreparedStatement接口中的方法允许应用程序以各种方式存储和访问存储的值:其中一些通过相应对象的 setter 和 getter 方法,而其他则作为bytes[]或作为二进制、字符或 ASCII 流。
如何做到这一点...
每个数据库都有其特定的存储 LOB 的方式。在 PostgreSQL 的情况下,Blob通常映射到OID或BYTEA数据类型,而Clob和NClob则映射到TEXT类型。因此,让我们编写一个新的方法,允许我们以编程方式创建表:
private static void execute(String sql){
try (Connection conn = getDbConnection()) {
try (PreparedStatement st = conn.prepareStatement(sql)) {
st.execute();
}
} catch (Exception ex) {
ex.printStackTrace();
}
}
这与executeUpdate()不同,因为它调用PreparedStatement的execute()方法而不是executeUpdate()。原则上,我们可以用execute()代替executeUpdate()在任何地方使用,但在我们的executeUpdate()实现中,我们期望有一个返回值(count),在创建表的情况下没有返回,因此我们编写了这个新方法。现在我们可以创建三个表:
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 接口PreparedStatement和ResultSet,你会注意到对象的 setter 和 getter--get/setBlob()、get/setClob()、get/setNClob()、get/setBytes()--在内存中以及使用InputStream和Reader的方法(get/setBinaryStream()、get/setAsciiStream()或get/setCharacterStream())。流式方法的优点是它们在数据库和源之间移动数据,而不需要在内存中存储整个 LOB。
然而,对象的 setter 和 getter 更接近我们的心,因为它们用于面向对象的编码。所以我们将从它们开始,使用不太大的对象进行演示。我们期望代码能够正常工作:
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/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/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示例,我们得到以下结果:

我们也可以尝试通过 getter 从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 时,我们首先会使用流式方法,因为直接从源到数据库或相反方向的流式传输比 setter 和 getter 消耗的内存要少得多(它们必须首先在内存中加载 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/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/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/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/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/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 的二进制数据,但处理如此大的值需要大量的内存。
大对象(Large Object)方法更适合存储非常大的值,但它也有自己的局限性。具体来说,删除包含大对象引用的行并不会删除大对象。删除大对象是一个需要单独执行的操作。大对象也存在一些安全问题,因为任何连接到数据库的人都可以查看和/或修改任何大对象,即使他们没有权限查看/更新包含大对象引用的行。
在决定将 LOB 存储在数据库中时,必须记住,数据库越大,维护它就越困难。访问速度——选择数据库作为存储设施的主要优势——也会减慢,而且无法为 LOB 类型创建索引以改进搜索。此外,除了少数 CLOB 情况外,不能在WHERE子句中使用 LOB 列,也不能在INSERT或UPDATE语句的多个行中使用 LOB 列。
因此,在考虑为 LOB 选择数据库之前,应该始终考虑是否将文件名、关键词和一些其他内容属性存储在数据库中就足够解决问题。
执行存储过程
在这个菜谱中,你将学习如何从 Java 程序中执行数据库存储过程。
准备工作
有时,现实生活中的 Java 程序员会遇到需要在多个表中操作和/或选择数据的需要,并因此提出一系列复杂的 SQL 语句。在一个场景中,数据库管理员查看建议的流程,并对其进行改进和优化,以至于在 Java 中实现它变得不可能或至少不切实际。这就是将开发的一组 SQL 语句封装成存储过程,编译并存储在数据库中,然后通过 JDBC 接口调用的时刻。或者,在命运的另一个转折点,Java 程序员可能会遇到需要在程序中包含对现有存储过程的调用的需要。为了完成这个任务,可以使用CallableStatement接口(它扩展了PreparedStatement接口),尽管一些数据库允许你使用Statement或PreparedStatement接口中的任何一个来调用存储过程。
CallableStatement 可以有三种类型的参数:IN 用于输入值,OUT 用于结果,以及 IN OUT 用于输入或输出值。OUT 参数必须通过 CallableStatement 的 registerOutParameter() 方法进行注册。IN 参数的设置方式与 PreparedStatement 的参数相同。
请记住,从 Java 程序中程序化执行存储过程是标准化程度最低的领域之一。例如,PostgreSQL 不直接支持存储过程,但它们可以作为函数调用,这些函数已经通过将 OUT 参数解释为返回值而进行了修改。另一方面,Oracle 允许函数也有 OUT 参数。
因此,以下数据库函数和存储过程之间的区别只能作为一般指南,而不是正式定义:
-
函数有一个返回值,但它不允许
OUT参数(除了某些数据库),并且可以在 SQL 语句中使用。 -
存储过程没有返回值(除了某些数据库);它允许
OUT参数(对于大多数数据库)并且可以使用 JDBC 接口的CallableStatement执行。
这就是为什么阅读数据库文档以学习如何执行存储过程,与之前菜谱中讨论的,处理 LOBs 一样重要。
因为存储过程是在数据库服务器上编译和存储的,所以 CallableStatement 的 execute() 方法对于相同的 SQL 语句比 Statement 或 PreparedStatement 的相应方法性能更好。这就是为什么很多 Java 代码有时会被一个或多个包含甚至业务逻辑的存储过程所取代的原因之一。做出这种决定的另一个原因是,人们可以以最熟悉的方式实现解决方案。因此,对于所有情况和情况,没有正确答案,我们将避免提出具体建议,除了重复关于测试价值和编写代码清晰度的熟悉箴言。
如何实现...
如前所述,我们将继续使用 PostgreSQL 数据库进行演示。在编写自定义 SQL 语句、函数和存储过程之前,应该首先查看现有函数的列表。通常,它们提供了丰富的功能。
这里是一个调用 replace(string text, from text, to text) 函数的例子,该函数查找 string text 中的所有 from text 子串,并将它们替换为 to 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");
我们使用一个我们已熟悉的方法:
private static 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),因此我们可以通过我们的方法遍历它:
private static 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()");
结果如下:

注意,函数的名称不区分大小写。我们保持驼峰式命名只是为了提高可读性。
现在让我们创建并调用一个带有两个输入参数的存储过程(函数):
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 表中添加两行,然后查询它并创建一个存储过程(函数),该函数计算表中的行数并返回结果(注意返回值的 bigint 值和 OUT 参数 Types.BIGINT 的匹配类型):
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()");
新创建的存储过程被执行了三次,然后被删除。结果如下:

一个具有一个输入参数(类型为int)并返回ResultSet的存储过程示例将如下所示(注意返回类型定义为setof texts,其中texts是表名称):
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)");
结果将如下所示:

值得分析两次调用存储过程的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 接口而不是通过 CallableStatement 接口调用返回数据集的函数。
尽管存在这种限制,但有一种方法可以绕过。相同的数据库文档描述了如何检索一个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参数) -
为了访问
refcursor功能(PL/pgSQL 是 PostgreSQL 数据库的可加载过程语言),语言设置为plpgsql -
要遍历
ResultSet,我们编写了一个新方法,如下所示:
private 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(", ")));
}
}
因此,我们的老朋友现在可以被重构为这样:
private static 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 函数
-
具有校对的函数
第七章:并发和多线程编程
并发编程一直是一项艰巨的任务。它可能听起来很简单,但它却是许多难以解决的问题的来源。在本章中,我们将向您展示不同的并发实现方式以及一些最佳实践,例如不可变性,这将有助于创建更好的并发应用程序。我们还将讨论使用 Java 提供的构造实现一些常用模式,例如分而治之和发布-订阅。我们将涵盖以下食谱:
-
使用并发的基本元素——线程
-
不同的同步方法
-
不可变性作为实现并发的手段
-
使用并发集合
-
使用执行器服务执行异步任务
-
使用 fork/join 实现分而治之
-
使用流实现发布-订阅模式
简介
并发——并行执行多个程序的能力——随着大数据分析进入现代应用的主流,变得越来越重要。拥有 CPU 或一个 CPU 中的多个核心有助于提高吞吐量,但数据量的增长速度将始终超过硬件的进步。此外,即使在多 CPU 系统中,仍然需要结构化代码并考虑资源共享,以充分利用可用的计算能力。
在前面的章节中,我们展示了如何使用具有函数式接口的 lambda 表达式和并行流将并发处理变成每个 Java 程序员工具箱的一部分。如果需要的话,人们可以很容易地利用这一功能,几乎不需要任何指导。
在本章中,我们将描述一些其他——旧(在 Java 9 之前存在)和新——Java 特性和 API,它们允许对并发有更多的控制。高级并发 Java API 自 Java 5 以来一直存在。JDK 增强提案(JEP)266,“更多并发更新”,在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(),以便让线程忙碌一段时间。参考以下代码:
private static int doSomething(int i){
IntStream.range(i, 99999).asDoubleStream().map(Math::sqrt).average();
return i;
}
如您所见,此方法生成另一个范围在i和99999之间的整数流,然后将流转换为双流,计算流中每个元素的平方根,并最终计算流的平均平均值。我们丢弃结果并返回传入的参数(作为一个便利,允许我们在线程的流管道中保持流畅的样式,最终打印出每个元素)。使用这个新类,我们现在可以演示三个线程的并发执行:
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);
第一个线程生成整数1、2和3,第二个线程生成整数11、12和13,第三个线程(主线程)生成21、22和23。
如前所述,我们可以通过创建和使用一个可以实现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_PRIORITY和Thread.MAX_PRIORITY之间设置值来程序化地设置优先级。值越小,线程被允许运行的时间越长(这意味着它有更高的优先级)。如果没有设置,优先级值默认为Thread.NORM_PRIORITY。线程的状态可以有以下之一:
-
NEW: 当一个线程尚未启动 -
RUNNABLE: 当一个线程正在执行 -
BLOCKED: 当一个线程被阻塞并正在等待监视器锁时 -
WAITING: 当一个线程无限期地等待另一个线程执行特定操作时 -
TIMED_WAITING: 当一个线程等待另一个线程执行操作,最长等待指定的时间 -
TERMINATED: 当一个线程已退出
我们将在我们的某个菜谱中讨论BLOCKED和WAITING状态。
sleep()方法可以用来暂停线程执行指定的时间(以毫秒为单位)。互补的方法interrupt()向线程发送InterruptedException,可以用来唤醒睡眠的线程。让我们在代码中实现这一点,并创建一个新的类:
private static 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);
上述程序生成一系列整数:21, 22, ..., 28。在生成每个整数之后,主线程中断thr1线程,并让它生成下一个结果,然后通过getCurrentResult()方法访问并分析。如果当前结果是偶数,过滤器允许生成的数字流被打印出来。如果不是,则跳过。以下是一个可能的结果:

如果在不同的计算机上运行程序,结果可能会有所不同,但您应该明白:这样,一个线程可以控制另一个线程的输出。
还有更多...
还有两种其他重要方法允许线程协作。首先是join()方法,它允许当前线程等待直到另一个线程终止。join()的重载版本接受定义线程等待多长时间才能做其他事情的参数。
setDaemon()方法在所有非守护线程终止后自动终止线程。通常,它用于后台和支持进程。
参见
参考本章中的以下食谱:
-
不同的同步方法
-
不可变性作为实现并发的手段
-
使用并发集合
-
使用执行器服务执行异步任务
-
使用 fork/join 实现分而治之
-
使用流实现发布-订阅模式
不同的同步方法
在本食谱中,您将了解 Java 中管理对公共资源并发访问最流行和基本的方法:一个synchronized方法和一个synchronized块。
准备工作
两个或更多线程修改相同的值,而其他线程正在读取它,这是并发访问问题最一般的描述。更微妙的问题包括线程干扰和内存一致性错误,两者都会在看似良性的代码片段中产生意外的结果。我们将演示这些情况以及避免它们的方法。
初看之下,这似乎非常简单:只需允许一次只有一个线程修改/访问资源,就是这样。但如果访问需要很长时间,那么它就会创建一个瓶颈,可能会消除多个线程并行工作的优势。或者,如果一个线程在等待访问另一个资源时阻塞了对一个资源的访问,而第二个线程在等待访问第一个资源时阻塞了对第二个资源的访问,那么它就会创建一个称为死锁的问题。这些都是程序员在处理多个线程时必须应对的可能挑战的两个非常简单的例子。
如何做到这一点...
首先,我们将检查由并发引起的问题。让我们创建一个具有calculate()方法的Calculator类:
class Calculator{
private double prop;
public double calculate(int i){
this.prop = 2.0 * i;
DoubleStream.generate(new Random()::nextDouble)
.limit(10);
return Math.sqrt(this.prop);
}
}
此方法将输入值分配给一个属性,然后计算其平方根。我们还插入了一段生成 10 个值的代码。我们这样做是为了让方法忙碌一段时间。否则,一切完成得太快,几乎没有并发发生的可能性。此外,我们希望返回值始终明显相同,所以我们没有通过计算复杂化它,而是通过无关的活动使方法忙碌。现在我们将在以下代码中使用它:
Calculator c = new Calculator();
Thread thr1 = new Thread(() -> System.out.println(IntStream.range(1, 4)
.peek(x ->DoubleStream.generate(new Random()::nextDouble)
.limit(10)).mapToDouble(c::calculate).sum()));
thr1.start();
Thread thr2 = new Thread(() -> System.out.println(IntStream.range(1, 4)
.mapToDouble(c::calculate).sum()));
thr2.start();
即使对于一个新手来说,很明显,两个线程访问同一个对象有很大可能会相互干扰。正如你所看到的,Random接口实现打印出相同的三个数字之和,即 1、2 和 3,在Calculator对象的calculate()方法处理每个数字之后。在calculate()内部,每个数字都乘以 2,然后通过开方提取过程。这个操作非常简单,我们甚至可以提前手动计算出结果。结果将是5.863703305156273。再次,为了使第一个线程运行得慢一些,以便给并发更好的机会,我们在peek()操作符中添加了 10 个双精度生成代码。如果你在自己的电脑上运行这些示例而没有看到并发效果,尝试增加双精度数字的数量,例如将10替换为100。
现在运行代码。这里是一个可能的结果:

一个线程得到了正确的结果,而另一个没有。显然,在设置prop属性的值和使用它来返回calculate()方法的结果之间,另一个线程设法将其(较小的)值分配给了prop。这是线程干扰的情况。
有两种方法可以保护代码免受此类问题的影响:使用同步方法或同步块;这些有助于包含始终作为原子操作执行且不受其他线程干扰的代码行。
创建一个同步方法既简单又直接:
class Calculator{
private double prop;
synchronized public double calculate(int i){
this.prop = 2.0 * i;
DoubleStream.generate(new Random()::nextDouble).limit(10);
return Math.sqrt(this.prop);
}
}
我们只是在方法定义前添加了synchronized关键字。现在,无论生成的双精度流有多大,我们程序的结果始终如下:

这是因为另一个线程无法进入synchronized方法,直到当前线程(已经进入方法的那个线程)退出它。如果该方法执行时间较长,这种方法可能会导致性能下降,因此许多线程可能会被阻塞,等待它们的轮次使用该方法。在这种情况下,可以使用synchronized块来包装几行代码,以执行原子操作:
private static class Calculator{
private double prop;
public double calculate(int i){
synchronized (this) {
this.prop = 2.0 * i;
DoubleStream.generate(new Random()::nextDouble).limit(10);
return Math.sqrt(this.prop);
}
}
或者,你也可以这样做:
private static class Calculator{
private double prop;
public double calculate(int i){
DoubleStream.generate(new Random()::nextDouble).limit(10);
synchronized (this) {
this.prop = 2.0 * i;
return Math.sqrt(this.prop);
}
}
我们可以这样做是因为通过研究代码,我们发现我们可以重新排列它,使得同步部分将小得多,因此有更少的可能性成为瓶颈。
一个synchronized块会锁定一个对象,任何对象都一样。例如,它可能是一个专用的对象:
private static class Calculator{
private double prop;
private Object calculateLock = new Object();
public double calculate(int i){
DoubleStream.generate(new Random()::nextDouble).limit(10);
synchronized (calculateLock) {
this.prop = 2.0 * i;
return Math.sqrt(this.prop);
}
}
}
专用锁的优势在于你可以确信这样的锁只会用于访问特定的块。否则,当前对象(this)可能会被用来控制对另一个块的访问;在一个庞大的类中,你可能不会在编写代码时注意到这一点。锁也可以从类中获取,这甚至更容易用于无关的共享目的。
我们做所有这些例子只是为了演示同步方法。如果它们是真正的代码,我们就会让每个线程创建自己的Calculator对象:
Thread thr1 = new Thread(() -> System.out.println(IntStream.range(1, 4)
.peek(x ->DoubleStream.generate(new Random()::nextDouble)
.limit(10))
.mapToDouble(x -> {
Calculator c = new Calculator();
return c.calculate(x);
}).sum()));
thr1.start();
Thread thr2 = new Thread(() -> System.out.println(IntStream.range(1, 4)
.mapToDouble(x -> {
Calculator c = new Calculator();
return c.calculate(x);
}).sum()));
thr2.start();
这将与使 lambda 表达式独立于它们创建的上下文的一般思想一致。这是因为在一个多线程环境中,在执行期间,没有人知道上下文会是什么样子。每次创建新对象的开销是可以忽略不计的,除非需要处理大量数据,并且测试确保对象创建的开销是可察觉的。将calculate()方法(和属性)设为静态(这很有吸引力,因为它避免了对象创建并保留了流畅的风格)并不能消除并发问题,因为属性的一个共享(这次是在类级别)值仍然会保留。
在多线程环境中,内存一致性错误可能具有多种形式和原因。它们在java.util.concurrent包的 Javadoc 中得到了很好的讨论。在这里,我们只提及由可见性不足引起的最常见情况。当一个线程更改属性值时,另一个线程可能不会立即看到这个变化,而且你不能为原始类型使用synchronized关键字。在这种情况下,考虑为这样的属性使用volatile关键字;它保证其在不同线程之间的读写可见性。
还有更多...
java.util.concurrent.locks包中组装了不同类型和不同行为的锁,以满足不同的需求。
java.util.concurrent.atomic 包为单变量上的无锁、线程安全编程提供了支持。
以下类也提供了同步支持:
-
Semaphore: 这限制了可以访问某些资源的线程数量 -
CountDownLatch: 这允许一个或多个线程等待其他线程中正在执行的操作完成 -
CyclicBarrier: 这允许一组线程等待其他线程到达一个共同的屏障点 -
Phaser: 这提供了一种更灵活的屏障形式,可用于在多个线程之间控制分阶段计算 -
Exchanger: 这允许两个线程在 rendezvous 点交换对象,并在多个管道设计中非常有用
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 是同步的,但在多线程处理中,完全有可能其他代码会向列表中添加更多元素(那么预期的最后一个元素就不会反映现实)或者移除一个元素(那么代码会因 IndexOutOfBoundException 而失败)。
这里描述的是一些最常见的并发问题。这些问题并不容易解决。这就是为什么越来越多的开发者现在采取更激进的策略并不令人惊讶。他们避免管理状态。相反,他们更愿意编写非阻塞代码,以在一系列无状态操作中对异步和并行处理数据进行处理。我们在关于流管道的章节中看到了类似的代码。这似乎表明 Java 和许多现代语言和计算机系统正在向这个方向发展。
参见
参考本章中的以下菜谱:
-
不可变性作为实现并发的手段
-
使用并发集合
-
使用执行器服务执行异步任务
-
使用 fork/join 实现分而治之
-
使用流程实现发布-订阅模式
不可变性作为实现并发的手段
在这个菜谱中,你将学习如何使用不可变性来对抗由并发引起的问题。
准备工作
并发问题最常发生在不同的线程修改和从同一共享资源读取数据时。减少修改操作的数量可以降低并发问题的风险。这就是不可变性——只读值条件——介入的阶段。
对象不可变性意味着在对象创建后没有改变其状态的手段。它不保证线程安全,但有助于显著提高线程安全并提供足够的保护,以防止许多实际应用中的并发问题。
创建一个新对象而不是重用现有对象(通过 setter 和 getter 更改其状态)通常被认为是一种昂贵的做法。但是,随着现代计算机的强大,必须进行大量的对象创建,以便在性能上产生任何显著的影响。即使如此,程序员通常更愿意以牺牲一些性能为代价来获得更可靠的结果。
如何做到这一点...
这里是一个非常基本的类,它产生可变对象:
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;
}
}
要使其不可变,我们需要移除 setter 并添加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类有使各种集合不可变的方法。这意味着集合本身的修改变为只读,而不是集合成员。
参见
参考本章中的以下食谱:
-
使用并发集合
-
使用执行器服务执行异步任务
-
使用 fork/join 实现分而治之
-
使用流来实现发布-订阅模式
使用并发集合
在这个菜谱中,你将了解java.util.concurrent包中的线程安全集合。
准备中
如果你对集合应用了Collections.synchronizeXYZ()方法之一,则该集合可以同步;在这里,我们使用 XYZ 作为占位符,代表Set、List、Map或Collections类中的几种集合类型(参见Collections类的 API)。我们已经提到,同步应用于集合本身,而不是其迭代器或集合成员。
这样的同步集合也被称为包装器,因为所有功能仍然由传递给Collections.synchronizeXYZ()方法的集合提供,所以包装器只为它们提供线程安全的访问。通过在原始集合上获取锁也可以达到相同的效果。显然,这种同步在多线程环境中会带来性能开销,导致每个线程都要等待其轮到访问集合。
java.util.concurrent包提供了一个针对性能实现的线程安全集合的优秀应用。
如何做到这一点...
java.util.concurrent包中的每个并发集合都实现了java.util包中的四个接口之一(或扩展,如果它是接口):List、Set、Map或Queue:
List接口只有一个实现:CopyOnWriteArrayList类。根据该类的 Javadoc,*所有可变操作(添加、设置等)都是通过创建底层数组的副本来实现的....“快照”风格的迭代器方法使用迭代器创建时的数组状态引用。这个数组在迭代器的整个生命周期中都不会改变,因此干扰是不可能的,迭代器保证不会抛出ConcurrentModificationException。迭代器不会反映自创建以来列表的添加、删除或更改。不支持迭代器本身的元素更改操作(删除、设置和添加)。这些方法会抛出UnsupportedOperationException。为了演示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 所警告的:“迭代器本身的元素更改操作(remove、set 和 add)是不支持的。这些方法会抛出 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,这可以作为一个原子操作来完成,因此不需要同步此代码块:if-not-present-then-add。
- 现在,让我们回顾实现
Set接口的java.util.concurrent包中的并发集合。这里有三种这样的实现:ConcurrentHashMap.KeySetView、CopyOnWriteArraySet和ConcurrentSkipListSet。
第一个只是一个 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()不保证原子执行。例如,如果与addAll()操作并发,forEach()操作可能只能观察到部分添加的元素(如 Javadoc 中所述)。
-
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);
}
对 TreeSet 和 ConcurrentSkipListSet 运行此操作:
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 的迭代器是弱一致的,这意味着以下(根据 Javadoc):
-
-
它们可能与其他操作并发进行
-
它们永远不会抛出
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版本在处理并发方面表现更好。
- 现在我们转向
Map接口,它在java.util.concurrent包中有两个实现:ConcurrentHashMap和ConcurrentSkipListMap。
ConcurrentHashMap类支持完全的检索并发性和高并发更新(来自 Javadoc)。它是java.util.HashMap的线程安全版本,在这方面与java.util.Hashtable类似。实际上,ConcurrentHashMap类满足与java.util.Hashtable相同的函数规范,尽管其实现细节在同步方面有所不同(来自 Javadoc)。
与java.util.HashMap和java.util.Hashtable不同,ConcurrentHashMap根据 JavaDoc,支持一系列顺序和并行批量操作,与大多数 Stream 方法不同,即使是在其他线程并发更新映射的情况下,也可以安全地、通常是有意义地应用:
-
-
forEach(): 这个方法对每个元素执行给定的操作 -
search(): 这个方法返回将给定函数应用于每个元素后的第一个非空结果 -
reduce(): 这个方法累积每个元素(有五种重载版本)
-
这些批量操作接受一个parallelismThreshold参数,允许在映射大小达到指定的阈值之前延迟并行化。自然地,当阈值设置为Long.MAX_VALUE时,将没有任何并行化。
类 API 中还有许多其他方法,因此请参阅其 Javadoc 以获取概述。
与java.util.HashMap(类似于java.util.Hashtable)不同,ConcurrentHashMap和ConcurrentSkipListMap都不允许使用 null 作为键或值。
Map的第二个实现——ConcurrentSkipListSet类——基于我们之前提到的ConcurrentSkipListMap类,因此我们刚才描述的ConcurrentSkipListSet类的所有限制也适用于ConcurrentSkipListMap类。ConcurrentSkipListSet类实际上是一个线程安全的java.util.TreeMap版本。SkipList是一种排序数据结构,允许并发快速搜索。所有元素都是根据其键的自然排序顺序排序的。我们在ConcurrentSkipListSet类中演示的NavigableSet功能也存在于ConcurrentSkipListMap类中。对于类 API 中的许多其他方法,请参阅其 Javadoc。
现在,让我们演示 java.util.HashMap、ConcurrentHashMap 和 ConcurrentSkipListMap 类在响应并发行为方面的差异。首先,我们将编写生成测试 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 的第一个键的输出:

我们还得到了 ConcurrentHashMap 和 ConcurrentSkipListMap 的输出,包括新添加的键。以下是 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 实现允许并发执行上述任何一种删除方式。
所有 Queue 接口的并发实现也表现出类似的行为:LinkedTransferQueue、LinkedBlockingQueue、LinkedBlockingDequeue、ArrayBlockingQueue、PriorityBlockingQueue、DelayQueue、SynchronousQueue、ConcurrentLinkedQueue 和 ConcurrentLinkedDequeue,所有这些都在 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,ConcurrentSkipListSet和ConcurrentSkipListMap类*提供预期的平均对数(n)时间成本,用于包含、添加和删除操作及其变体。升序视图及其迭代器比降序视图更快。当需要快速迭代特定顺序的元素并默认排序时,请使用它们。
当并发需求非常严格,并且需要在写操作上允许加锁但不需要锁定元素时,请使用ConcurrentHashMap。
ConcurrentLinkedQueque和ConcurrentLinkedDeque是当许多线程共享对公共集合的访问时的一个合适选择。ConcurrentLinkedQueque采用一个高效的非阻塞算法。
当自然顺序可接受且需要快速向队列尾部添加元素和从队列头部快速删除元素时,PriorityBlockingQueue是一个更好的选择。阻塞意味着在检索元素时队列等待变为非空,在存储元素时等待队列中有空间可用。
ArrayBlockingQueue、LinkedBlockingQueue和LinkedBlockingDeque具有固定大小(有界)。其他队列是无界的。
使用这些和类似的特点及建议作为指导方针,但在实现功能前后进行全面的测试和性能测量。
参见
参考本章以下菜谱:
-
使用执行器服务执行异步任务
-
使用分治法实现 fork/join
-
使用流实现发布-订阅模式
使用执行器服务执行异步任务
在这个菜谱中,你将学习如何使用ExecutorService来实现可控的线程执行。
准备工作
在之前的配方中,我们展示了如何直接使用 Thread 类创建和执行线程。对于少量运行并快速产生可预测结果的线程来说,这是一个可接受的机制。对于具有较长时间运行线程、复杂逻辑(可能使它们存活时间不可预测地长)以及线程数量不可预测地增长的大规模应用程序,简单的创建和运行直到退出的方法可能会导致 OutOfMemory 错误或需要复杂的自定义线程状态维护和管理系统。对于此类情况,ExecutorService 和 java.util.concurrent 包中的相关类提供了一种现成的解决方案,可以减轻程序员编写和维护大量基础设施代码的需要。
执行器框架的基础是一个 Executor 接口,它只有一个 void execute(Runnable command) 方法,该方法将在未来的某个时间执行给定的命令。
其子接口 ExecutorService 添加了允许你管理执行器的方法:
-
invokeAny()、invokeAll()、awaitTermination()方法以及submit()允许你定义线程的执行方式以及它们是否预期返回某些值。 -
shutdown()和shutdownNow()方法允许你关闭执行器。 -
isShutdown()和isTerminated()方法提供了执行器状态。
可以使用 java.util.concurrent.Executors 类的静态工厂方法创建 ExecutorService 对象:
-
newSingleThreadExecutor()- 这创建了一个使用单个工作线程并操作无界队列的Executor方法。它有一个带有ThreadFactory参数的重载版本。 -
newCachedThreadPool()- 这将创建一个线程池,根据需要创建新线程,但在可用时重用先前构建的线程。它有一个带有ThreadFactory参数的重载版本。 -
newFixedThreadPool(int nThreads)- 这将创建一个线程池,它重用固定数量的线程,这些线程在一个共享的无界队列上操作。它有一个带有ThreadFactory参数的重载版本。
ThreadFactory 实现允许你覆盖创建新线程的过程,使应用程序能够使用特殊的线程子类、优先级等。其使用示例超出了本书的范围。
如何做到这一点...
- 你需要记住
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.");
}
您可以通过多种方式将工作者(Runnable 或 Callable 函数式接口的实现)传递给 ExecutorService 以进行执行,我们将在稍后看到。在这个例子中,我们执行了两个线程:一个使用 execute() 方法,另一个使用 submit() 方法。两种方法都接受 Runnable 或 Callable,但在这个例子中我们只使用了 Runnable。submit() 方法返回 Future,它表示异步计算的结果。
shutdown() 方法启动先前提交任务的有序关闭,并阻止接受任何新任务。此方法不会等待任务完成执行。awaitTermination() 方法会这样做。但是,在 shutdownDelaySec 之后,它停止阻塞,代码流程进入 finally 块,其中 isTerminated() 方法在所有任务在关闭后完成时返回 true。在这个例子中,我们执行了两个不同的语句中的两个任务,但请注意,ExecutorService 的其他方法接受任务集合。
在这种情况下,当服务正在关闭时,我们遍历 Future 对象的集合。我们调用每个任务,如果它尚未完成,则取消它,可能还会执行在取消任务之前必须执行的其他操作。等待时间(shutdownDelaySec 的值)必须为每个应用程序和可能的运行任务进行测试。
最后,shutdownNow() 方法表示:尝试停止所有正在执行的任务,停止等待任务的执行,并返回等待执行的任务列表(根据 Javadoc)。
- 现在我们可以收集和评估结果。在实际应用中,我们通常不希望频繁地关闭服务。我们只是检查任务的状况,并收集从
isDone()方法返回 true 的那些任务的结果。在上面的代码示例中,我们只是展示了当我们确实停止服务时,我们如何确保以一种受控的方式停止,而不留下任何失控的进程。如果我们运行这个代码示例,我们将得到以下结果:

- 将前面的代码泛化,创建一个关闭服务和已返回
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.");
}
}
- 通过让
Runnable(使用 lambda 表达式)睡眠一段时间(模拟要执行的有用工作)来增强示例:
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(定义工作线程将睡眠多长时间,表示模拟的过程正在执行其工作)。
- 为不同的
ExecutorService实现和shutdownDelaySec、threadSleepSec的值运行新代码:
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);
这就是输出可能的样子(它可能因操作系统控制的事件的确切时间而略有不同):

- 分析结果。在第一个例子中,我们没有发现任何惊喜,因为以下行:
execService.awaitTermination(shutdownDelaySec,
TimeUnit.SECONDS);
它阻塞了三秒钟,而每个工作者只需工作一秒钟。所以对于单线程执行器来说,这已经足够每个工作者完成其工作了。
让我们让服务只等待一秒钟:

当你这样做时,你会发现没有任何任务会被完成。在这种情况下,工作者One被中断(见输出的最后一行),而任务Two被取消。
让我们让服务等待三秒钟:

现在,我们看到工作者One能够完成其任务,而工作者Two被中断了。
由newCachedThreadPool()或newFixedThreadPool()生成的ExecutorService接口在一个核心计算机上表现相似。唯一的显著区别是,如果shutdownDelaySec值等于threadSleepSec值,那么它们都允许你完成线程:

这是使用newCachedThreadPool()的结果。在一个核心计算机上,使用newFixedThreadPool()的示例输出看起来完全一样。
- 当你需要对任务有更多控制时,使用
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()是一个阻塞方法。这就是为什么我们使用一个允许我们设置delaySec超时的get()方法版本。否则,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);
其输出将如下所示:

可能值得提醒的是,三个工作者分别以 1 秒、2 秒和 3 秒的睡眠时间创建,而服务关闭前的等待时间是 1 秒。这就是为什么所有工作者都被取消的原因。
现在如果我们把等待时间设置为六秒,单线程执行器的输出将如下所示:

自然地,如果我们再次增加等待时间,所有工作者都将能够完成他们的任务。
由newCachedThreadPool()或newFixedThreadPool()生成的ExecutorService接口,即使在单核计算机上也能表现得更好:

如您所见,所有线程即使在等待了三秒钟之后也能完成任务。
作为一种替代方案,你可以在服务关闭期间而不是设置超时,可能设置在invokeAll()方法的重载版本上:
List<Future<T>> invokeAll(Collection<? extends Callable<T>> tasks,
long timeout, TimeUnit unit)
invokeAll()方法的一个特定行为常常被忽视,并给初次用户带来惊喜:它只有在所有任务都完成(无论是正常完成还是通过抛出异常)之后才返回。请阅读 Javadoc 并实验,直到你认识到这种行为对你的应用程序是可接受的。
相比之下,invokeAny()方法只阻塞到至少有一个任务成功完成(没有抛出异常),如果有任何任务的话。在正常或异常返回时,未完成的任务将被取消(根据 Javadoc)。以下是一个实现此功能的代码示例:
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。
可以使用 java.util.concurrent.Executors 类的静态工厂方法创建 ScheduledExecutorService 对象:
-
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 实现分而治之
在这个食谱中,你将学习如何使用 fork/join 框架来实现分而治之计算模式。
准备工作
如前一个食谱中提到的,ForkJoinPool 类是 ExecutorService 接口的一个实现,它使用工作窃取算法管理工作者的执行--ForkJoinTask 进程。它利用可用的多个处理器,并在可以递归分解为更小任务的任务上表现最佳,这也就是所谓的 分而治之 策略。
池中的每个线程都有一个专用的双端队列(deque),用于存储任务,线程在完成当前任务后立即从队列头部获取下一个任务。当另一个线程完成其队列中的所有任务时,它可以从另一个非空队列的尾部窃取一个任务。
与任何ExecutorService实现一样,fork/join 框架将任务分配给线程池中的工作线程。这个框架的独特之处在于它使用了一个工作窃取算法。当工作线程耗尽任务时,可以从仍然忙碌的其他线程那里窃取任务。
这样的设计平衡了负载,并允许高效地使用资源。
为了演示目的,我们将使用在第三章中创建的 API,模块化编程:TrafficUnit、SpeedModel和Vehicle接口以及TrafficUnitWrapper、FactoryTraffic、FactoryVehicle和FactorySpeedModel类。我们还将依赖第三章中描述的流和流管道,模块化编程。
为了刷新你的记忆,以下是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类的两个子类(RecursiveAction或RecursiveTask<T>)之一的子类中。你可以扩展RecursiveAction(并实现void compute()方法)或RecursiveTask<T>(并实现T compute()方法)。正如你可能已经注意到的,你可以选择扩展RecursiveAction类来处理不返回任何值的任务,当你需要任务返回值时扩展RecursiveTask<T>。在我们的演示中,我们将使用后者,因为它稍微复杂一些。
假设我们想要计算在特定日期和时间以及驾驶条件下(所有这些参数都由DateLocation对象定义)某个地点的交通平均速度。其他参数如下:
-
timeSec:车辆在停止在交通灯后有机会加速的秒数 -
trafficUnitsNumber:用于平均速度计算的车辆数量
自然地,包含在计算中的车辆越多,预测就越好。但随着这个数字的增加,计算的数量也会增加。这产生了将车辆数量分解成更小组并与其他组并行计算每个组平均速度的需要。然而,存在一个计算的最小数量,不值得在两个线程之间分割。以下是 Javadoc 对此的说明:作为一个非常粗略的经验法则,一个任务应该执行超过 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);
}
注意 trafficUnitsNumber 和 threshold 参数的值。这对于分析结果将非常重要。
完成此任务的另一种方法是使用 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()方法(通过传递基于新生成的SpeedModel对象,该对象基于发出的TrafficUnit对象)。然后我们计算速度,获取流中所有速度的平均值,并将结果作为double从Optional对象(average()操作的返回类型)中获取。然后我们打印出结果,并四舍五入以获得更易读的格式。
同样,也可以使用传统的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数量除以二(在处理大集合的平均值时,我们不担心可能丢失一个单位),并创建两个任务。
以下——实际的任务执行代码——可以以几种不同的方式编写。以下是第一个可能的解决方案,这是我们首先想到的,也是我们熟悉的:
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 个单位(车辆)上计算平均速度,首先被分成几组,直到一组(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);
结果将如下所示:

你可以看到,在这种情况下,计算方法(第二个任务)被递归调用多次,直到通过元素数量达到阈值,然后将其结果与第一个任务的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。Publisher 流式传输数据(发布),而 Subscriber 监听数据(订阅)。
Flow.Publisher<T> 接口是一个函数式接口。它只有一个抽象方法:
void subscribe(Flow.Subscriber<? super T> subscriber)
根据 Javadoc,此方法如果可能的话会添加给定的 Flow.Subscriber
Flow.Subscriber<T> 接口有四个方法;其中一些刚才已经提到了:
-
void onSubscribe(Flow.Subscription subscription)在调用给定Subscription的任何其他Subscriber方法之前被调用 -
void onError(Throwable throwable)在Publisher或Subscription遇到不可恢复的错误后调用,之后不再通过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>接口超出了本书的范围。
如何做到这一点...
为了节省一些时间和空间,我们不必创建自己的Flow.Publisher<T>接口实现,我们可以使用java.util.concurrent包中的SubmissionPublisher<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,该方法通过异步调用其 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块,并在关闭服务之前等待 1 秒。这个等待期足以让项目被生成并传递给订阅者。我们还确认每个生成的项目都被发送到每个订阅者。每当每个订阅者的onSubscribe()方法被调用时,都会生成三个null值。
有理由预期在未来的 Java 版本中,将增加更多对反应式(异步和非阻塞)功能的支持。
第八章:更好地管理操作系统进程
在这一章中,我们将涵盖以下菜谱:
-
生成新进程
-
将进程输出和错误流重定向到文件
-
更改子进程的工作目录
-
为子进程设置环境变量
-
运行 shell 脚本
-
获取当前 JVM 的进程信息
-
获取生成进程的进程信息
-
管理生成的进程
-
列举系统中的活动进程
-
使用管道连接多个进程
-
管理子进程
简介
你有多少次最终编写了生成新进程的代码?不经常。然而,会有一些需要编写此类代码的情况。在这种情况下,你会求助于使用第三方 API,例如 Apache Commons Exec (commons.apache.org/proper/commons-exec/) 等。为什么会这样?Java API 不够用吗?不,它不够用,至少直到 Java 9。现在,随着 Java 9 的推出,我们为进程 API 添加了许多新特性。
在 Java 7 之前,重定向输入、输出和错误流并不简单。Java 7 引入了一些新的 API,允许将输入、输出和错误重定向到其他进程(管道)、文件或标准输入/输出。然后在 Java 8 中,又引入了一些新的 API。在 Java 9 中,为以下领域引入了新的 API:
-
获取进程信息,例如进程 ID(PID)、启动进程的用户、进程运行的时间等
-
列举系统中运行的进程
-
通过在进程层次结构中向上导航来管理子进程并获取进程树访问权限
在这一章中,我们将探讨一些菜谱,这些菜谱将帮助您探索进程 API 中的新特性,您还将了解从 Runtime.getRuntime().exec() 时代引入的变化。你们都知道使用那个是犯罪。
所有这些菜谱只能在 Linux 平台上执行,因为我们将在从 Java 代码中生成新进程时使用 Linux 特定的命令。在 Linux 上执行 run.sh 脚本有两种方式:
-
sh run.sh -
chmod +x run.sh && ./run.sh
生成新进程
在这个菜谱中,我们将看到如何使用 ProcessBuilder 生成新进程。我们还将看到如何利用输入、输出和错误流。这应该是一个非常简单且常见的菜谱。然而,引入这个菜谱的目的是使这一章更加完整,而不仅仅是关注 Java 9 的特性。
准备工作
Linux 中有一个名为 free 的命令,它显示系统空闲和使用的 RAM 量。它接受一个选项 -m,以兆字节为单位显示输出。因此,只需运行 free -m 就会得到以下输出:

我们将在 Java 程序内部运行前面的代码。
如何做到...
- 通过提供所需的命令及其选项来创建
ProcessBuilder的实例:
ProcessBuilder pBuilder = new ProcessBuilder("free", "-m");
指定命令和选项的另一种方法是:
pBuilder.command("free", "-m");
- 设置进程构建器的输入和输出流以及其他属性,如执行目录和环境变量。之后,在
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()方法来终止该过程。
- 使用以下命令编译和运行代码:
$ javac -d mods --module-source-path src $(find src -name *.java)
$ java -p mods -m process/com.packt.process.NewProcessDemo
- 我们得到的结果如下:

这个菜谱的代码可以在chp8/1_spawn_new_process中找到。
它是如何工作的...
有两种方法让ProcessBuilder知道要运行哪个命令:
-
在创建
ProcessBuilder对象时,通过将命令及其选项传递给构造函数。 -
通过将命令及其选项作为参数传递给
ProcessBuilder对象的command()方法。
在启动进程之前,我们可以做以下操作:
-
我们可以使用
directory()方法更改执行目录 -
我们可以将输入流、输出流和错误流重定向到文件或另一个进程
-
我们可以为子进程提供所需的环境变量
我们将在本章的相应菜谱中看到所有这些活动。
当调用start()方法时,会启动一个新的进程,调用者会以Process类实例的形式获得对这个子进程的引用。使用这个Process对象,我们可以做很多事情,例如以下内容:
-
获取关于进程的信息,包括其 PID
-
获取输出和错误流
-
检查过程的完成情况
-
终止进程
-
将任务与进程完成后要执行的操作关联起来
-
检查由该进程产生的子进程
-
如果存在,找到该过程的所有父过程
在我们的菜谱中,我们waitFor 1 秒或等待进程完成(以先到者为准)。如果进程已经完成,则waitFor返回true;否则,返回false。如果进程没有完成,我们可以通过在Process对象上调用destroyForcibly()方法来终止进程。
将进程的输出和错误流重定向到文件
在这个菜谱中,我们将了解如何处理从 Java 代码中产生的过程的输出和错误流。我们将把产生的过程的输出或错误写入文件。
准备工作
在这个菜谱中,我们将使用iostat命令。这个命令用于报告不同设备和分区的 CPU 和 I/O 统计信息。让我们运行这个命令并看看它报告了什么:
$ iostat
在某些 Linux 发行版中,例如 Ubuntu,iostat 不是默认安装的。你可以通过运行 sudo apt-get install sysstat 来安装这个实用工具。
上述命令的输出如下:

如何操作...
- 通过指定要执行的命令创建一个新的
ProcessBuilder对象:
ProcessBuilder pb = new ProcessBuilder("iostat");
- 将输出和错误流分别重定向到文件的输出和错误:
pb.redirectError(new File("error"))
.redirectOutput(new File("output"));
- 启动进程,并等待其完成:
Process p = pb.start();
int exitValue = p.waitFor();
- 读取输出文件的内容:
Files.lines(Paths.get("output"))
.forEach(l -> System.out.println(l));
- 读取错误文件的内容。这仅在命令中有错误时创建:
Files.lines(Paths.get("error"))
.forEach(l -> System.out.println(l));
第 4 步和第 5 步仅供参考。这与 ProcessBuilder 或生成的进程无关。使用这两行代码,我们可以检查进程写入输出和错误文件的内容。
完整的代码可以在 chp8/2_redirect_to_file 找到。
- 使用以下命令编译代码:
$ javac -d mods --module-source-path src $(find src -name *.java)
- 使用以下命令运行代码:
$ 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 命令支持多个选项。这需要你自己去探索。
如何操作...
- 创建一个新的
ProcessBuilder对象:
ProcessBuilder pb = new ProcessBuilder();
- 将命令设置为
tree,并将输出和错误设置为与当前 Java 进程相同:
pb.command("tree").inheritIO();
- 将目录设置为任何你想要的目录。我将其设置为根文件夹:
pb.directory(new File("/root"));
- 启动进程并等待其退出:
Process p = pb.start();
int exitValue = p.waitFor();
- 使用以下命令编译和运行:
$ javac -d mods --module-source-path src $(find src -name *.java)
$ java -p mods -m process/com.packt.process.ChangeWorkDirectoryDemo
- 输出将是
ProcessBuilder对象的directory()方法中指定的目录的递归内容,以树形格式打印。
完整代码可以在 chp8/2_redirect_to_file 找到。
它是如何工作的...
directory() 方法接受 Process 的工作目录路径。路径指定为一个 File 实例。
设置子进程的环境变量
环境变量就像我们在编程语言中使用的任何其他变量一样。它们有一个名称并持有一些值,这些值可以变化。这些变量被 Linux/Windows 命令或 shell/batch 脚本用来执行不同的操作。这些被称为环境变量,因为它们存在于正在执行的过程/命令/脚本的运行环境中。通常,进程会从父进程继承环境变量。
在不同的操作系统中,它们以不同的方式访问。在 Windows 中,它们以 %ENVIRONMENT_VARIABLE_NAME% 的形式访问,而在基于 Unix 的操作系统中,它们以 $ENVIRONMENT_VARIABLE_NAME 的形式访问。
在基于 Unix 的系统中,你可以使用 printenv 命令来打印进程可用的所有环境变量,而在基于 Windows 的系统中,你可以使用 SET 命令。
在这个菜谱中,我们将传递一些环境变量给我们的子进程,并使用 printenv 命令来打印所有可用的环境变量。
如何操作...
- 创建一个
ProcessBuilder实例:
ProcessBuilder pb = new ProcessBuilder();
- 将命令设置为
printenv,并将输出和错误流设置为与当前 Java 进程相同:
pb.command("printenv").inheritIO();
- 提供环境变量,
COOKBOOK_VAR1的值为First variable,COOKBOOK_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");
- 启动进程并等待其完成:
Process p = pb.start();
int exitValue = p.waitFor();
本菜谱的完整代码可以在 chp8/4_environment_variables 找到。
- 使用以下命令编译和运行代码:
$ 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 类型有bash、csh、ksh等。bash shell 是最常用的 shell。
在这个配方中,我们将编写一个简单的 shell 脚本,然后使用ProcessBuilder和Process对象从 Java 代码中调用它。
准备就绪
首先,让我们编写我们的 shell 脚本。此脚本执行以下操作:
-
打印环境变量
MY_VARIABLE的值。 -
执行
tree命令。 -
执行
iostat命令。
让我们创建一个名为script.sh的 shell 脚本文件,其中包含以下命令:
echo $MY_VARIABLE;
echo "Running tree command";
tree;
echo "Running iostat command"
iostat;
您可以将script.sh放置在您的家目录中,即/home/<username>。现在让我们看看如何从 Java 中执行它。
如何操作...
- 创建一个新的
ProcessBuilder实例:
ProcessBuilder pb = new ProcessBuilder();
- 将执行目录设置为指向 shell 脚本文件的目录:
pb.directory(new File("/root"));
注意,在创建File对象时传递的上述路径将取决于您放置脚本script.sh的位置。在我们的案例中,我们将其放置在/root。您可能已经将脚本复制到/home/yourname,因此File对象将被创建为new File("/home/yourname")。
- 设置一个将被 shell 脚本使用的环境变量:
Map<String, String> environment = pb.environment();
environment.put("MY_VARIABLE", "From your parent Java process");
- 设置要执行的命令以及传递给命令的参数。同时,设置进程的输出和错误流与当前 Java 进程相同:
pb.command("/bin/bash", "script.sh").inheritIO();
- 启动进程,并等待其完全执行:
Process p = pb.start();
int exitValue = p.waitFor();
您可以从chp8/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 脚本。
如果您没有执行步骤 1,那么您将不得不使用 shell 脚本文件的绝对路径。然而,在我们的配方中,我们已经执行了步骤 1,因此我们只需使用 shell 脚本名称来对/bin/bash命令进行操作。
步骤 2 基本上是您想要执行 shell 脚本的方式。这样做的方法是将 shell 脚本传递给解释器,解释器将解释并执行脚本。这就是以下代码行所做的事情:
pb.command("/bin/bash", "script.sh")
获取当前 JVM 的进程信息
运行中的进程有一组与之关联的属性,例如以下内容:
-
PID:这是进程的唯一标识
-
所有者:这是启动进程的用户名称
-
命令:这是在进程下运行的命令
-
CPU 时间:这表示进程活跃的时间
-
启动时间:这表示进程启动的时间
这些是我们通常感兴趣的几个属性。也许,我们也会对 CPU 使用率或内存使用率感兴趣。现在,在 Java 9 之前,从 Java 内部获取这些信息是不可能的。然而,在 Java 9 中,引入了一套新的 API,使我们能够获取关于进程的基本信息。
在这个菜谱中,我们将看到如何获取当前 Java 进程的进程信息,即执行你代码的进程。
如何做到这一点...
- 创建一个简单的类并使用
ProcessHandle.current()来获取当前 Java 进程的ProcessHandle:
ProcessHandle handle = ProcessHandle.current();
- 我们添加了一些代码,这将增加代码的运行时间:
for ( int i = 0 ; i < 100; i++){
Thread.sleep(1000);
}
- 在
ProcessHandle实例上使用info()方法来获取ProcessHandle.Info实例:
ProcessHandle.Info info = handle.info();
- 使用
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");
- 使用
ProcessHandle的pid()方法来获取当前 Java 进程的进程 ID:
System.out.println("PID: " + handle.pid());
- 我们还将使用代码即将结束的时间来打印结束时间。这将给我们一个关于进程执行时间的概念:
Instant end = Instant.now();
System.out.println("End: " + end);
你可以从chp8/6_current_process_info获取完整的代码。
使用以下命令编译和运行代码:
$ javac -d mods --module-source-path src $(find src -name *.java)
$ java -p mods -m process/com.packt.process.CurrentProcessInfoDemo
你看到的输出将类似于以下内容:

程序完成执行将需要一些时间。
需要注意的一个观察结果是,即使程序运行了大约 2 分钟,总的 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 接口的实现实例,我们可以利用它来获取进程信息,如配方代码所示。
ProcessHandle 和 ProcessHandle.Info 是接口。JDK 提供者,即 Oracle JDK 或 Open JDK,将为这些接口提供实现。Oracle JDK 有一个名为 ProcessHandleImpl 的类,它实现了 ProcessHandle,并在 ProcessHandleImpl 内部还有一个名为 Info 的内部类,它实现了 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,它用于在秒内暂停执行一段时间。
如何操作...
- 从 Java 代码中生成一个新的进程,该进程运行
sleep命令:
ProcessBuilder pBuilder = new ProcessBuilder("sleep", "20");
Process p = pBuilder.inheritIO().start();
- 获取此生成的进程的
ProcessHandle实例:
ProcessHandle handle = p.toHandle();
- 等待生成的进程完成执行:
int exitValue = p.waitFor();
- 使用
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());
您可以从 chp8/7_spawned_process_info 获取完整的代码。
使用以下命令编译和运行代码:
$ javac -d mods --module-source-path src $(find src -name *.java)
$ java -p mods -m process/com.packt.process.SpawnedProcessInfoDemo
或者,在 chp8\7_spawned_process_info 中有一个名为 run.sh 的脚本,您可以从任何基于 Unix 的系统作为 /bin/bash run.sh 运行它。
您看到的输出将类似于以下内容:

管理生成的进程
有几种方法,例如destroy()、destroyForcibly()(在 Java 8 中添加)、isAlive()(在 Java 8 中添加)和supportsNormalTermination()(在 Java 9 中添加),可以用来控制生成的进程。这些方法在Process对象以及ProcessHandle对象上都是可用的。在这里,控制只是检查进程是否处于活动状态,如果是,则销毁进程。
在这个菜谱中,我们将生成一个长时间运行的进程,并执行以下操作:
-
检查其活动性
-
检查它是否可以正常停止,也就是说,根据平台,进程可以通过仅使用销毁或使用强制销毁来停止
-
停止进程
如何操作...
- 从 Java 代码中生成一个新的进程,运行
sleep命令,例如 1 分钟,即 60 秒:
ProcessBuilder pBuilder = new ProcessBuilder("sleep", "60");
Process p = pBuilder.inheritIO().start();
- 等待,比如说,10 秒:
p.waitFor(10, TimeUnit.SECONDS);
- 检查进程是否处于活动状态:
boolean isAlive = p.isAlive();
System.out.println("Process alive? " + isAlive);
- 检查进程是否可以正常停止:
boolean normalTermination = p.supportsNormalTermination();
System.out.println("Normal Termination? " + normalTermination);
- 停止进程并检查其活动性:
p.destroy();
isAlive = p.isAlive();
System.out.println("Process alive? " + isAlive);
您可以从chp8\8_manage_spawned_process获取完整的代码。
我们提供了一个名为run.sh的实用脚本,您可以使用它来编译和运行代码:sh run.sh。
我们得到的输出如下:

如果我们在 Windows 上运行程序,supportsNormalTermination()返回false,但在 Unix 上supportsNormalTermination()返回true(如前面的输出所示)。
列举系统中的活动进程
在 Windows 中,您打开 Windows 任务管理器来查看当前活动的进程,而在 Linux 中,您使用带有各种选项的ps命令来查看进程以及其他详细信息,如用户、耗时、命令等。
在 Java 9 中,添加了一个新的 API,称为ProcessHandle,用于处理进程的控制和获取信息。API 的一个方法是allProcesses(),它返回当前进程可见的所有进程的快照。在这个菜谱中,我们将查看该方法的工作方式以及我们可以从 API 中提取哪些信息。
如何操作...
- 使用
ProcessHandle接口上的allProcesses()方法获取当前活动进程的流:
Stream<ProcessHandle> liveProcesses =
ProcessHandle.allProcesses();
- 使用
forEach()遍历流,并传递一个 lambda 表达式来打印可用的详细信息:
liveProcesses.forEach(ph -> {
ProcessHandle.Info phInfo = ph.info();
System.out.println(phInfo.command().orElse("") +" " +
phInfo.user().orElse(""));
});
您可以从chp8/9_enumerate_all_processes获取完整的代码。
我们提供了一个名为run.sh的实用脚本,您可以使用它来编译和运行代码:sh run.sh。
我们得到的输出如下:

在前面的输出中,我们打印了进程的命令名称以及用户。我们只展示了输出的一小部分。
使用管道连接多个进程
在 Unix 中,使用|符号将一组命令连接起来是常见的,以创建一系列活动的管道,其中命令的输入是前一个命令的输出。这样,我们可以处理输入以获得所需的输出。
一个常见的场景是当你想在日志文件中搜索某些内容或某些模式,或者在日志文件中查找某些文本的出现。在这种情况下,你可以创建一个管道,通过一系列命令(如cat、grep、wc -l等)传递所需的日志文件数据。
在这个菜谱中,我们将使用来自 UCI 机器学习仓库的 Iris 数据集(可在archive.ics.uci.edu/ml/datasets/Iris找到)来创建一个管道,其中我们将计算每种类型花的出现次数。
准备中...
我们已经下载了 Iris 花数据集,可以在本书代码下载的chp8/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选项时,它返回每个唯一值在列表中出现的次数
如何做...
- 创建一个
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)
);
- 使用
ProcessBuilder的startPipeline()方法,并将ProcessBuilder对象列表传递给它以启动管道。它将返回一个Process对象列表,每个对象代表列表中的一个ProcessBuilder对象:
List<Process> processes = ProcessBuilder.startPipeline(pipeline);
- 获取列表中的最后一个进程并
waitFor它完成:
int exitValue = processes.get(processes.size() - 1).waitFor();
你可以从chp8/10_connecting_process_pipe获取完整的代码。
我们提供了一个名为run.sh的实用脚本,你可以使用它来编译和运行代码:sh run.sh。
我们得到的结果如下:

它是如何工作的...
startPipeline()方法为列表中的每个ProcessBuilder对象启动一个Process。除了第一个和最后一个进程外,它通过使用ProcessBuilder.Redirect.PIPE将一个进程的输出重定向到另一个进程的输入。如果你为任何中间进程提供了除ProcessBuilder.Redirect.PIPE之外的其他redirectOutput,则会抛出错误,类似于以下内容:
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 脚本,我们将在菜谱中使用它。这个脚本可以在chp8/11_managing_sub_process/script.sh找到:
echo "Running tree command";
tree;
sleep 60;
echo "Running iostat command";
iostat;
在前面的脚本中,我们正在运行tree和iostat命令,它们之间有 1 分钟的睡眠时间。如果你想知道这些命令,请参阅本章的运行 shell 脚本菜谱。当从 bash shell 中执行睡眠命令时,每次调用都会创建一个新的子进程。
我们将创建,比如说,10 个ProcessBuilder实例来运行前面的 shell 脚本并将它们同时启动。
如何做到这一点...
- 我们将创建 10 个
ProcessBuilder实例来运行我们的 shell 脚本(位于chp8/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();
}
- 获取当前进程的句柄:
ProcessHandle currentProcess = ProcessHandle.current();
- 使用当前进程通过
children()API 获取其子进程,并对每个子进程迭代以打印其信息。一旦我们有一个ProcessHandle实例,我们就可以做很多事情,比如销毁进程、获取进程信息等等:
System.out.println("Obtaining children");
currentProcess.children().forEach(pHandle -> {
System.out.println(pHandle.info());
});
- 使用当前进程通过
descendants()API 获取所有其后代子进程,并对每个子进程迭代以打印它们的信息:
currentProcess.descendants().forEach(pHandle -> {
System.out.println(pHandle.info());
});
您可以从chp8/11_managing_sub_process获取完整的代码。
我们提供了一个名为run.sh的实用脚本,您可以使用它来编译和运行代码:sh run.sh。
我们得到的结果如下:

它是如何工作的...
children()和descendants() API 为每个进程返回Stream的ProcessHandler,这些进程是当前进程的直接子进程或后代。使用ProcessHandler的实例,我们可以执行以下操作:
-
获取进程信息。
-
检查进程的状态。
-
停止进程。
第九章:使用 JavaFX 进行 GUI 编程
在本章中,我们将介绍以下菜谱:
-
使用 JavaFX 控件创建 GUI
-
使用 FXML 标记创建 GUI
-
使用 CSS 在 JavaFX 中设置元素样式
-
创建柱状图
-
创建饼图
-
创建折线图
-
创建面积图
-
创建气泡图
-
创建散点图
-
在应用程序中嵌入 HTML
-
在应用程序中嵌入媒体
-
为控件添加效果
-
使用新的 TIFF I/O API 读取 TIFF 图像
简介
GUI 编程自 JDK 1.0 以来就在 Java 中,通过名为抽象窗口工具包(AWT)的 API 实现。这在当时是一个了不起的事情,但它也有自己的局限性,以下是一些例子:
-
它具有有限的组件集
-
由于 AWT 使用原生组件,因此无法创建自定义的可重用组件
-
组件的外观和感觉无法控制,并且它们采用了宿主操作系统的外观和感觉
然后,在 Java 1.2 中,引入了一个新的 GUI 开发 API,称为Swing,它通过提供以下功能来弥补 AWT 的不足:
-
更丰富的组件库。
-
支持创建自定义组件。
-
本地外观和感觉以及支持插入不同的外观和感觉。一些 Java 外观和感觉主题包括 Nimbus、Metal、Motif 以及系统默认值。
许多使用 Swing 的桌面应用程序已经被构建,其中许多仍在使用。然而,随着时间的推移,技术必须发展;否则,它最终会过时,很少使用。2008 年,Adobe 的 Flex 开始引起关注。它是一个用于构建丰富互联网应用程序(RIAs)的框架。桌面应用程序始终是丰富的基于组件的 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 就没有回头路了。
在本章中,我们将完全关注 JavaFX 周围的食谱。我们将尝试涵盖尽可能多的食谱,以给您所有人提供良好的 JavaFX 使用体验。
使用 JavaFX 控件创建 GUI
在这个食谱中,我们将查看如何使用 JavaFX 控件创建一个简单的 GUI 应用程序。我们将构建一个应用程序,通过提供您的出生日期来帮助您计算年龄。可选地,您甚至可以输入您的姓名,应用程序将问候您并显示您的年龄。这是一个相当简单的例子,试图展示您如何通过使用布局、组件和事件处理来创建 GUI。
准备工作
您已安装的 JDK 包含 JavaFX 模块,因此无需进行任何操作即可开始使用 JavaFX。包含 JavaFX 类的各种模块如下:
-
javafx.base -
javafx.controls -
javafx.fxml -
javafx.graphics -
javafx.media -
javafx.swing -
javafx.web
在我们的食谱中,我们将根据需要从前面列表中使用几个模块。
如何操作...
- 创建一个继承自
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)方法中编写。
- 让我们创建一个容器布局,以正确对齐我们将要添加的组件。在这种情况下,我们将使用
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的对齐方式、行与列之间的水平和垂直间距以及网格中每个单元格的填充。
- 创建一个新的标签,用于显示我们应用程序的名称,特别是
Age calculator,并将其添加到我们在前面步骤中创建的gridPane中:
Text appTitle = new Text("Age calculator");
appTitle.setFont(Font.font("Arial", FontWeight.NORMAL, 15));
gridPane.add(appTitle, 0, 0, 2, 1);
- 创建一个标签和一个文本输入框的组合,用于接受用户的姓名。然后将这两个组件添加到
gridPane中:
Label nameLbl = new Label("Name");
TextField nameField = new TextField();
gridPane.add(nameLbl, 0, 1);
gridPane.add(nameField, 1, 1);
- 创建一个标签和一个日期选择器的组合,用于接受用户的出生日期:
Label dobLbl = new Label("Date of birth");
gridPane.add(dobLbl, 0, 2);
DatePicker dateOfBirthPicker = new DatePicker();
gridPane.add(dateOfBirthPicker, 1, 2);
- 创建一个按钮,用户将使用它来触发年龄计算,并将其添加到
gridPane中:
Button ageCalculator = new Button("Calculate");
gridPane.add(ageCalculator, 1, 3);
- 创建一个组件来保存计算出的年龄结果:
Text resultTxt = new Text();
resultTxt.setFont(Font.font("Arial", FontWeight.NORMAL, 15));
gridPane.add(resultTxt, 0, 5, 2, 1);
- 现在,我们需要将一个动作绑定到第 6 步中创建的按钮。这个动作将获取在姓名字段中输入的姓名和在日期选择器字段中输入的出生日期。如果提供了出生日期,则使用 Java 时间 API 计算现在和出生日期之间的时间段。如果提供了姓名,则在结果前添加问候语,
Hello, <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());
}
});
- 通过提供我们在第 2 步中创建的
gridPane对象和场景的宽度和高度来创建Scene类的实例:
Scene scene = new Scene(gridPane, 300, 250);
Scene实例持有 UI 组件的图,称为场景图。
- 我们已经看到
start()方法为我们提供了一个Stage对象的引用。Stage对象是 JavaFX 中的顶级容器,类似于 Swing 使用的 AWT。我们将Scene对象设置到Stage对象,并使用其show()方法来渲染 UI:
stage.setTitle("Age calculator");
stage.setScene(scene);
stage.show();
- 现在,我们需要从主方法启动这个 JavaFX UI。我们使用
Application类的launch(String[] args)方法来启动 JavaFX UI:
public static void main(String[] args) {
Application.launch(args);
}
完整的代码可以在chp9/1_create_javafx_gui中找到。
我们在chp9/1_create_javafx_gui中提供了两个脚本,run.bat和run.sh。run.bat脚本将用于在 Windows 上运行应用程序,而run.sh将用于在 Linux 上运行应用程序。
使用run.bat或run.sh运行应用程序,你将看到以下截图所示的 GUI:

输入姓名和出生日期,然后点击Calculate来查看年龄:

它是如何工作的...
在深入其他细节之前,让我们先简要概述一下 JavaFX 架构。我们已从 JavaFX 文档中提取了以下描述架构堆栈的图像(docs.oracle.com/javase/8/javafx/get-started-tutorial/jfx-architecture.htm#JFXST788):

让我们从堆栈的顶部开始:
-
JavaFX API 和场景图:这是应用程序的起点,我们将大部分精力集中在这一部分。这提供了不同组件、布局和其他实用工具的 API,以促进基于 JavaFX 的 UI 开发。场景图持有应用程序的视觉元素。
-
棱镜、量子工具包以及蓝色中的其他组件:这些组件管理 UI 的渲染,并在底层操作系统和 JavaFX 之间提供桥梁。这一层在图形硬件无法提供丰富 UI 和 3D 元素的硬件加速渲染的情况下提供软件渲染。
-
玻璃窗口工具包:这是窗口工具包,就像 Swing 使用的 AWT。
-
媒体引擎:这支持 JavaFX 中的媒体。
-
网络引擎:这支持网络组件,允许完全渲染 HTML。
-
JDK API 和 JVM:这些与 Java API 集成,并将代码编译成字节码以在 JVM 上运行。
让我们回到解释食谱。javafx.application.Application 类是启动 JavaFX 应用程序的入口点。它具有以下方法,这些方法映射到应用程序的生命周期(按调用顺序):
-
init():此方法在javafx.application.Application实例化后立即调用。可以在应用程序开始之前重写此方法以进行一些初始化。默认情况下,此方法不执行任何操作。 -
start(javafx.stage.Stage):此方法在init()之后立即调用,并且在系统完成运行应用程序所需的初始化之后调用。此方法传递一个javafx.stage.Stage实例,这是组件渲染的主要阶段。可以创建其他javafx.stage.Stage对象,但应用程序提供的是主要阶段。 -
stop():当应用程序应该停止时调用此方法。可以执行必要的退出相关操作。
阶段是一个顶级 JavaFX 容器。作为 start() 方法参数传递的主阶段是由平台创建的,并且应用程序可以在需要时创建其他 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 的入口点,我们还向该类添加了一个主方法,使其成为我们应用程序的入口点。
布局构造函数决定了你的组件如何布局。JavaFX 支持多种布局,如下所示:
-
javafx.scene.layout.HBox和javafx.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.Font对象可以设置javafx.scene.text.Text组件的字体,如下所示: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()方法分别从nameField和dateOfBirthPicker获取值。DatePicker返回所选日期作为一个java.time.LocalDate实例。这是 Java 8 中添加的新日期时间 API 之一。它表示一个日期,即年、月和日,没有任何时区相关信息。然后我们使用java.time.Period类来找到当前日期和所选日期之间的持续时间,如下所示:
LocalDate now = LocalDate.now();
Period period = Period.between(dob, now);
Period以年、月和日为单位的日期持续时间表示,例如,3 年、2 个月和 3 天。这正是我们试图从这一行代码中提取的内容:String.format("Your age is %d years %d months %d days", 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。
准备工作
如果你已经能够实现前面的食谱,那么就没有太多需要做的。如果你是直接来到这个食谱的,也没有太多需要做的。JavaFX API 与 OpenJDK 一起提供,你必须已经在你系统上安装了它。
我们将开发一个简单的年龄计算器应用程序。此应用程序将要求用户输入姓名(可选)和出生日期,并从给定的出生日期计算年龄,并将其显示给用户。
如何实现...
-
所有 FXML 文件都应该以
.fxml扩展名结尾。让我们在src/gui/com/packt位置创建一个空的fxml_age_calc_gui.xml文件。在后续步骤中,我们将使用 JavaFX 组件的 XML 标签更新此文件: -
创建一个
GridPane布局,它将按行和列的网格形式持有所有组件。我们还将使用vgap和hgap属性提供行与列之间所需的空间。此外,我们还将为我们的根组件GridPane提供对将添加所需事件处理的 Java 类的引用。这个 Java 类将类似于 UI 的控制器:
<GridPane alignment="CENTER" hgap="10.0" vgap="10.0"
fx:controller="com.packt.FxmlController">
</GridPane>
- 我们将通过在
GridPane中定义一个带有Insets的padding标签来为网格中的每个单元格提供填充:
<padding>
<Insets bottom="25.0" left="25.0" right="25.0" top="25.0" />
</padding>
- 接下来是添加一个显示应用程序标题的
Text标签:Age Calculator。我们在style属性中提供所需样式信息,并使用GridPane.columnIndex和GridPane.rowIndex属性在GridPane中放置Text组件。可以使用GridPane.columnSpan和GridPane.rowSpan属性提供单元格占用信息:
<Text style="-fx-font: NORMAL 15 Arial;" text="Age calculator"
GridPane.columnIndex="0" GridPane.rowIndex="0"
GridPane.columnSpan="2" GridPane.rowSpan="1">
</Text>
- 然后我们添加用于接受名称的
Label和TextField组件。注意在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>
- 我们添加了用于接受出生日期的
Label和DatePicker组件:
<Label text="Date of Birth" GridPane.columnIndex="0"
GridPane.rowIndex="2">
</Label>
<DatePicker fx:id="dateOfBirthPicker" GridPane.columnIndex="1"
GridPane.rowIndex="2">
</DatePicker>
- 然后,我们添加一个
Button对象,并将其onAction属性设置为处理此按钮点击事件的 Java 控制器中的方法名称:
<Button onAction="#calculateAge" text="Calculate"
GridPane.columnIndex="1" GridPane.rowIndex="3">
</Button>
- 最后我们添加一个
Text组件来显示计算出的年龄:
<Text fx:id="resultTxt" style="-fx-font: NORMAL 15 Arial;"
GridPane.columnIndex="0" GridPane.rowIndex="5"
GridPane.columnSpan="2" GridPane.rowSpan="1"
</Text>
- 下一步是实现与前面步骤中创建的基于 XML 的 UI 组件直接相关的 Java 类。创建一个名为
FxmlController的类。这将包含与 FXML UI 相关的代码;也就是说,它将包含对在 FXML 动作处理程序中创建的组件的引用:
public class FxmlController {
//to implement in next few steps
}
- 我们需要
nameField、dateOfBirthPicker和resultText组件的引用。我们使用前两个来获取输入的名称和出生日期,第三个来显示年龄计算的结果:
@FXML private Text resultTxt;
@FXML private DatePicker dateOfBirthPicker;
@FXML private TextField nameField;
- 下一步是实现
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());
}
}
-
在步骤 10 和 11 中,我们都使用了注解
@FXML。这个注解表示类或成员对基于 FXML 的 UI 是可访问的: -
接下来,我们将创建另一个 Java 类,名为
FxmlGuiDemo,它负责渲染基于 FXML 的 UI,并且也将是启动应用程序的入口点:
public class FxmlGuiDemo extends Application{
//code to launch the UI + provide main() method
}
- 现在,我们需要通过重写
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();
}
- 最后,我们提供了
main()方法的实现:
public static void main(String[] args) {
Application.launch(args);
}
完整的代码可以在指定位置找到,chp9/2_fxml_gui。
我们在chp9/2_fxml_gui中提供了两个运行脚本,run.bat和run.sh。run.bat脚本将用于在 Windows 上运行应用程序,而run.sh将用于在 Linux 上运行应用程序。
使用run.bat或run.sh运行应用程序,你将看到以下截图所示的 GUI:

输入姓名和出生日期,然后点击Calculate按钮查看年龄:

它是如何工作的...
没有 XSD 定义 FXML 文档的架构。因此,为了知道要使用的标签,它们遵循一个简单的命名约定。组件的 Java 类名也是 XML 标签的名称。例如,javafx.scene.layout.GridPane布局的 XML 标签是<GridPane>,javafx.scene.control.TextField的标签是<TextField>,而javafx.scene.control.DatePicker的标签是<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 文件。通过反射创建相应 Java 类的 XML 标签实例,并将 XML 标签的属性值填充到 Java 类的相应属性中。
由于我们的 FXML 的根是javafx.scene.layout.GridPane,它扩展了javafx.scene.layout.Pane,因此我们可以将FXMLLoader.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属性值的开头添加了#。
参见
参考本章的以下食谱:
- 使用 JavaFX 控件创建 GUI
使用 CSS 在 JavaFX 中设置元素样式
来自网页开发背景的人将能够欣赏层叠样式表(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只会接受有效的颜色值。
另一种方法是定义这些 name:value 对的组在不同的文件中,具有.css扩展名。让我们称这个 name:value 对的组为 CSS 属性。我们可以将这些 CSS 属性与不同的选择器关联起来,即选择器用于选择 HTML 页面上的元素以应用 CSS 属性。提供选择器有三种不同的方式:
-
通过直接给出 HTML 元素的名称,即它是一个锚点标签(
<a>)、按钮还是输入。在这种情况下,CSS 属性应用于页面上的所有 HTML 元素类型。 -
通过使用 HTML 元素的
id属性。假设我们有一个id="btn1"的按钮,那么我们可以定义一个选择器#btn1,针对这个选择器提供 CSS 属性。看看下面的例子:
#btn1 { background-color: blue; }
- 通过使用 HTML 元素的
class属性。假设我们有一个class="blue-btn"的按钮,那么我们可以定义一个选择器.blue-btn,针对这个选择器提供 CSS 属性。看看下面的例子:
.blue-btn { background-color: blue; }
使用不同的 CSS 文件的优势在于,我们可以独立地发展网页的外观,而不会紧密耦合到元素的位置。此外,这也鼓励在不同页面之间重复使用 CSS 属性,从而在整个页面上提供统一的样式。
当我们将类似的方法应用于 JavaFX 时,我们可以利用我们的网页设计师已经拥有的 CSS 知识来为 JavaFX 组件构建 CSS,这有助于比使用 Java API 更容易地样式化组件。当这种 CSS 与 FXML 混合时,它就成为了网页开发者所熟知的领域。
在这个菜谱中,我们将查看如何使用外部 CSS 文件来样式化几个 JavaFX 组件。
准备工作
在定义 JavaFX 组件的 CSS 属性时存在一个小的差异。所有属性都必须以-fx-为前缀,即background-color变为-fx-background-color。在 JavaFX 世界中,选择器,即#id和.class-name 仍然保持不变。我们甚至可以为 JavaFX 组件提供多个类,从而将这些 CSS 属性应用到组件上。
我在这个菜谱中使用的 CSS 是基于一个流行的 CSS 框架 Bootstrap(getbootstrap.com/css/)。
如何做到这一点...
- 让我们创建一个
GridPane,它将以行和列的形式持有组件:
GridPane gridPane = new GridPane();
gridPane.setAlignment(Pos.CENTER);
gridPane.setHgap(10);
gridPane.setVgap(10);
gridPane.setPadding(new Insets(25, 25, 25, 25));
- 首先,我们将创建一个按钮并添加两个类,
btn和btn-primary。在下一步中,我们将定义这些选择器并使用所需的 CSS 属性:
Button primaryBtn = new Button("Primary");
primaryBtn.getStyleClass().add("btn");
primaryBtn.getStyleClass().add("btn-primary");
gridPane.add(primaryBtn, 0, 1);
- 现在,让我们为类
btn和btn-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;
}
- 让我们创建另一个具有不同 CSS 类的按钮:
Button successBtn = new Button("Sucess");
successBtn.getStyleClass().add("btn");
successBtn.getStyleClass().add("btn-success");
gridPane.add(successBtn, 1, 1);
- 现在我们定义
.btn-success选择器的 CSS 属性如下:
.btn-success {
-fx-text-fill: #fff;
-fx-background-color: #5cb85c;
-fx-border-color: #4cae4c;
}
- 让我们创建另一个具有不同 CSS 类的按钮:
Button dangerBtn = new Button("Danger");
dangerBtn.getStyleClass().add("btn");
dangerBtn.getStyleClass().add("btn-danger");
gridPane.add(dangerBtn, 2, 1);
- 我们将为选择器
.btn-danger定义 CSS 属性:
.btn-danger {
-fx-text-fill: #fff;
-fx-background-color: #d9534f;
-fx-border-color: #d43f3a;
}
- 现在,让我们添加一些具有不同选择器的标签,即
badge和badge-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);
- 之前选择器的 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;
}
- 让我们添加一个具有
big-input类的TextField:
TextField bigTextField = new TextField();
bigTextField.getStyleClass().add("big-input");
gridPane.add(bigTextField, 0, 3, 3, 1);
- 我们定义 CSS 属性,以便文本框的内容大小大且颜色为红色:
.big-input{
-fx-text-fill: red;
-fx-font-size: 18px;
-fx-font-style: italic;
-fx-font-weight: bold;
}
- 让我们添加一些单选按钮:
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);
- 我们定义 CSS 属性,以便单选按钮的标签大小大且颜色为绿色:
.big-radio{
-fx-text-fill: green;
-fx-font-size: 18px;
-fx-font-weight: bold;
-fx-background-color: yellow;
-fx-padding: 5;
}
- 最后,我们将
javafx.scene.layout.GridPane添加到场景图中,并在javafx.stage.Stage上渲染场景图。我们还需要将stylesheet.css与Scene关联起来:
Scene scene = new Scene(gridPane, 600, 500);
scene.getStylesheets().add("com/packt/stylesheet.css");
stage.setTitle("Age calculator");
stage.setScene(scene);
stage.show();
- 添加一个
main()方法以启动 GUI:
public static void main(String[] args) {
Application.launch(args);
}
完整的代码可以在位置chp9/3_css_javafx找到。
我们在chp9/3_css_javafx目录下提供了两个运行脚本,run.bat和run.sh。run.bat用于在 Windows 上运行应用程序,而run.sh用于在 Linux 上运行应用程序。
使用run.bat或run.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 属性。然后,这些属性将应用于具有相同类名的所有组件。
让我们从我们的配方中挑选一个组件,看看我们是如何对相同的组件进行样式的。考虑以下具有两个类btn和btn-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-radius、border、font-size、font-weight、text-align、background-color和border-color,都以前缀-fx-开头。
重要的部分是如何将样式表与 Scene 组件关联起来。代码行scene.getStylesheets().add("com/packt/stylesheet.css");将样式表与 Scene 组件关联起来。由于getStylesheets()返回一个字符串列表,我们可以向其中添加多个字符串,这意味着我们可以将多个样式表关联到一个 Scene 上。
getStylesheets()的文档说明了以下内容:
URL 是具有以下形式的分层 URI:[scheme:][//authority][path]。如果 URL 没有[scheme:]组件,则 URL 被认为是仅有的[path]组件。任何[path]前面的'/'字符都将被忽略,并且[path]被视为相对于应用程序 classpath 根部的路径。
在我们的配方中,我们只使用path组件,因此它会在 classpath 中查找文件。这就是为什么我们将样式表添加到与场景相同的包中。这是一种使其在 classpath 上可用的更简单的方法。
创建柱状图
数据以表格形式表示时很难理解,但以图表形式图形化表示时,对眼睛来说更舒适,更容易理解。我们已经看到了许多用于 Web 应用的图表库。然而,在桌面应用程序方面,这种支持却缺乏。Swing 没有创建图表的原生支持,我们不得不依赖于第三方应用程序,如JFreeChart(www.jfree.org/jfreechart/)。但是,使用 JavaFX,我们有创建图表的原生支持,我们将向您展示如何使用 JavaFX 图表组件以图表形式表示数据。
JavaFX 支持以下图表类型:
-
柱状图
-
折线图
-
饼图
-
散点图
-
面积图
-
气泡图
在接下来的几个菜谱中,我们将介绍每种图表类型的构建。将每种图表类型单独作为一个菜谱,将有助于我们以更简单的方式解释菜谱,并有助于更好地理解。
这个菜谱将全部关于柱状图。一个示例柱状图看起来像这样:

柱状图可以为x轴上的每个值有一个或多个柱子(如前面的图像所示)。多个柱子有助于我们比较x轴上每个值的多个值点。
准备工作
我们将利用学生表现机器学习仓库中的一部分数据(archive.ics.uci.edu/ml/datasets/Student+Performance)。该数据集包括学生在数学和葡萄牙语两门课程的表现,以及他们的社会背景信息,例如父母的职业和教育,以及其他信息。数据集中有很多属性,但我们将选择以下内容:
-
学生的性别
-
学生的年龄
-
父亲的教育程度
-
父亲的职业
-
母亲的教育程度
-
母亲的职业
-
学生是否参加了课外班
-
第一学期成绩
-
第二学期成绩
-
最终成绩
正如我们之前提到的,数据中捕获了大量的属性,但我们应该能够处理几个重要的属性,这些属性将帮助我们绘制一些有用的图表。因此,我们已经从机器学习仓库中可用的数据集中提取了信息,并将其保存到一个单独的文件中,该文件位于书籍代码下载的chp9/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,其代码可以在 chp9/101_student_data_processor 找到。因此,如果您想修改那里的任何代码,可以通过运行 build-jar.bat 或 build-jar.sh 文件来重新构建 JAR。这将创建一个模块化的 JAR,名为 student.processor.jar,位于 mlib 目录中。然后,您必须用此模块化 JAR 替换此食谱中 mlib 目录中现有的 JAR,即 chp9/4_bar_charts/mlib。
我们建议您从 chp9/101_student_data_processor 中可用的源代码构建 student.processor 模块化 JAR。我们提供了 build-jar.bat 和 build-jar.sh 脚本来帮助您构建 JAR。您只需运行与您的平台相关的脚本。然后,将 101_student_data_processor/mlib 中的构建好的 JAR 复制到 4_bar_charts/mlib。
这样,我们可以在所有图表的食谱中重用此模块。
如何做到这一点...
- 首先,创建
GridPane并配置它以放置我们将要创建的图表:
GridPane gridPane = new GridPane();
gridPane.setAlignment(Pos.CENTER);
gridPane.setHgap(10);
gridPane.setVgap(10);
gridPane.setPadding(new Insets(25, 25, 25, 25));
- 使用来自
student.processor模块的StudentDataProcessor类来解析学生文件并将数据加载到Student的List中:
StudentDataProcessor sdp = new StudentDataProcessor();
List<Student> students = sdp.loadStudent();
- 原始数据,即
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() 计算他们成绩的统计数据。
- 柱状图的数据以
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;
}
- 我们将创建两个柱状图:一个表示母亲教育程度的平均成绩,另一个表示父亲教育程度的平均成绩。为此,我们将创建一个方法,该方法将接受
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;
}
- 通过
gridPane创建表示母亲教育程度的平均成绩的BarChart并将其添加到gridPane:
BarChart<String, Number> avgGradeByMotherEdu =
getAvgGradeByEducationBarChart(
students,
Student::getMotherEducation
);
avgGradeByMotherEdu.setTitle("Average grade by Mother's
Education");
gridPane.add(avgGradeByMotherEdu, 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);
- 使用
gridPane创建场景图并将其设置为Stage:
Scene scene = new Scene(gridPane, 800, 600);
stage.setTitle("Bar Charts");
stage.setScene(scene);
stage.show();
完整代码可以在chp9/4_bar_charts中找到。
我们在chp9/4_bar_charts目录下提供了两个运行脚本,run.bat和run.sh。run.bat脚本将用于在 Windows 上运行应用程序,而run.sh将用于在 Linux 上运行应用程序。
使用run.bat或run.sh运行应用程序,你将看到以下 GUI:

工作原理...
让我们首先看看创建BarChart需要什么。BarChart是一个基于两个轴的图表,数据绘制在两个轴上,即x轴(水平轴)和y轴(垂直轴)。其他基于两个轴的图表还包括面积图、气泡图和折线图。
在 JavaFX 中,支持两种类型的轴:
-
javafx.scene.chart.CategoryAxis:这个支持轴上的字符串值 -
javafx.scene.chart.NumberAxis:这个支持轴上的数值
在我们的配方中,我们创建了以CategoryAxis作为x轴的BarChart,其中我们绘制了教育数据,以及以NumberAxis作为y轴的BarChart,其中我们绘制了成绩,如下所示:
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表示。Data是XYChart中的一个嵌套类,代表一个基于两个轴的图表的单个数据项。一个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 实例所需的对象)是配方的重要部分;理解它们将为您提供一个更清晰的配方图景。
参见
参考本章以下配方:
-
创建面积图
-
创建气泡图
-
创建折线图
-
创建散点图
创建饼图
如其名所示,饼图是带有切片(要么连接要么分离)的圆形图表,其中每个切片及其大小表示切片所代表的项目的量级。饼图用于比较不同类别、类别、产品等的量级。这是一个示例饼图的样子:

准备工作
我们将使用与我们在“创建条形图”配方中讨论的相同的学生数据(从机器学习存储库中获取并在我们端进行处理),为此,我们创建了一个模块 student.processor,该模块将读取学生数据并提供给我们一个 Student 对象的列表。该模块的源代码可以在 chp9/101_student_data_processor 中找到。我们已提供 student.processor 模块的模块化 jar 文件,位于本配方代码的 chp9/5_pie_charts/mlib。
我们建议您从 chp9/101_student_data_processor 中可用的源代码构建 student.processor 模块化 jar。我们已提供 build-jar.bat 和 build-jar.sh 脚本来帮助您构建 jar。您只需运行与您的平台相关的脚本。然后,将 101_student_data_processor/mlib 中的 jar 文件复制到 4_bar_charts/mlib。
如何做...
- 让我们先创建和配置
GridPane以容纳我们的饼图:
GridPane gridPane = new GridPane();
gridPane.setAlignment(Pos.CENTER);
gridPane.setHgap(10);
gridPane.setVgap(10);
gridPane.setPadding(new Insets(25, 25, 25, 25));
- 创建
StudentDataProcessor实例(来自student.processor模块)并使用它来加载List中的Student:
StudentDataProcessor sdp = new StudentDataProcessor();
List<Student> students = sdp.loadStudent();
- 现在,我们需要根据学生的母亲和父亲的职业来获取学生的数量。我们将编写一个方法,该方法将接受一个学生列表和一个分类器,即返回学生需要按其分组的价值的函数。该方法返回一个
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;
}
- 我们将调用前面的方法两次 - 一次以母亲的职业作为分类器,另一次以父亲的职业作为分类器。然后我们将返回的
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);
- 接下来是使用
gridPane创建场景图并将其添加到Stage中:
Scene scene = new Scene(gridPane, 800, 600);
stage.setTitle("Pie Charts");
stage.setScene(scene);
stage.show();
- 可以通过调用
Application.launch方法从主方法启动 UI:
public static void main(String[] args) {
Application.launch(args);
}
完整的代码可以在 chp9/5_pie_charts 中找到。
我们在 chp9/5_pie_charts 下提供了两个运行脚本,run.bat 和 run.sh。run.bat 脚本将用于在 Windows 上运行应用程序,而 run.sh 将用于在 Linux 上运行应用程序。
使用 run.bat 或 run.sh 运行应用程序,你将看到以下图形用户界面:

它是如何工作的...
在这个菜谱中执行所有工作的最重要的方法是 getStudentCountByOccupation()。它执行以下操作:
- 按专业分组学生数量。这可以通过使用新流式 API(作为 Java 8 的一部分添加)的单行代码来完成:
Map<String, Long> occupationBreakUp =
students.stream().collect(
Collectors.groupingBy(
classifier,
Collectors.counting()
)
);
- 构建
PieChart所需的数据。PieChart实例的数据是ObservableList的PieChart.Data。我们首先使用前一步中获得的Map创建PieChart.Data的ArrayList。然后,我们使用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::getMotherJob 和 Student::getFatherJob。这两个方法引用在 Student 列表的不同实例上调用 getMotherJob 和 getFatherJob 方法。
一旦我们获得 PieChart 实例,我们将它们添加到 GridPane 中,然后使用 GridPane 构建场景图。场景图必须与 Stage 关联,以便在屏幕上渲染。
主方法通过调用 Application.launch(args); 方法启动 UI。
创建折线图
折线图是一种双轴图表,类似于条形图;与条形图不同,数据使用点在 X-Y 平面上绘制,并且点被连接起来以描绘数据的变化。折线图用于了解某个变量的表现,并且当结合多个变量时,即通过使用多个系列,我们可以看到每个变量与其他变量相比的表现。
准备工作
在这个菜谱中,我们将利用过去三年原油和布伦特原油价格的变化。这些数据可以在以下位置找到,chp9/6_line_charts/src/gui/com/packt/crude-oil 和 chp9/6_line_charts/src/gui/com/packt/brent-oil。
如何做...
- 我们将创建一个 普通 Java 对象(POJO)来表示给定月份和年份的油价:
public class OilPrice{
public String period;
public Double value;
}
- 接下来是编写
getOilData(String oilType)方法,该方法将读取给定文件中的数据并构建List<OilPrice>:
private List<OilPrice> getOilData(String oilType)
throws IOException{
Scanner reader = new Scanner(getClass()
.getModule()
.getResourceAsStream("com/packt/"+oilType)
);
List<OilPrice> data = new LinkedList<>();
while(reader.hasNext()){
String line = reader.nextLine();
String[] elements = line.split("t");
OilPrice op = new OilPrice();
op.period = elements[0];
op.value = Double.parseDouble(elements[1]);
data.add(op);
}
Collections.reverse(data);
return data;
}
- 接下来,我们将编写一个方法,该方法将接受系列名称和要填充到系列中的数据:
private XYChart.Series<String,Number> getSeries(
String seriesName, List<OilPrice> data
) throws IOException{
XYChart.Series<String,Number> series = new XYChart.Series<>();
series.setName(seriesName);
data.forEach(d -> {
series.getData().add(new XYChart.Data<String, Number>(
d.period, d.value
));
});
return series;
}
- 创建一个空的
start(Stage stage)方法,我们将在接下来的几个步骤中重写它:
@Override
public void start(Stage stage) throws IOException {
//code to be added here from the next few steps
}
- 创建并配置
GridPane:
GridPane gridPane = new GridPane();
gridPane.setAlignment(Pos.CENTER);
gridPane.setHgap(10);
gridPane.setVgap(10);
gridPane.setPadding(new Insets(25, 25, 25, 25));
- 创建
CategoryAxis作为 x 轴和NumberAxis作为 y 轴,并相应地标记它们:
final CategoryAxis xAxis = new CategoryAxis();
final NumberAxis yAxis = new NumberAxis();
xAxis.setLabel("Month");
yAxis.setLabel("Price");
- 使用前面步骤中创建的轴初始化一个
LineChart实例:
final LineChart<String,Number> lineChart =
new LineChart<>(xAxis,yAxis);
- 将原油数据加载到
List<OilPrice>:
List<OilPrice> crudeOil = getOilData("crude-oil");
- 将布伦特原油数据加载到
List<OilPrice>:
List<OilPrice> brentOil = getOilData("brent-oil");
- 创建一个系列,每个系列分别用于原油和布伦特原油,并将其添加到
lineChart:
lineChart.getData().add(getSeries("Crude Oil", crudeOil));
lineChart.getData().add(getSeries("Brent Oil", brentOil));
- 将
LineChart添加到gridPane:
gridPane.add(lineChart, 1, 1);
- 使用
GridPane作为根节点创建场景图,并设置所需的大小:
Scene scene = new Scene(gridPane, 800, 600);
- 为传递给
start(Stage stage)方法的Stage对象设置属性,并将前面步骤中创建的场景图关联起来:
stage.setTitle("Line Charts");
stage.setScene(scene);
stage.show();
- 通过从
main方法调用javafx.application.Application.launch()方法来启动 UI:
public static void main(String[] args) {
Application.launch(args);
}
完整代码可以在 chp9/6_line_charts 中找到。
我们在 chp9/6_line_charts 下提供了两个运行脚本,run.bat 用于在 Windows 上运行应用程序,run.sh 用于在 Linux 上运行应用程序。
使用 run.bat 或 run.sh 运行应用程序,你将看到以下 GUI:

它是如何工作的...
如同任何其他双轴图表一样,折线图有 x 轴和 y 轴。这些轴可以是字符串类型或数值类型。字符串值由 javafx.scene.chart.CategoryAxis 表示,数值值由 javafx.scene.chart.NumberAxis 表示。
通过将 x 轴和 y 轴对象作为参数传递给其构造函数来创建一个新的 LineChart:
final CategoryAxis xAxis = new CategoryAxis();
final NumberAxis yAxis = new NumberAxis();
xAxis.setLabel("Category");
yAxis.setLabel("Price");
final LineChart<String,Number> lineChart = new LineChart<>(xAxis,yAxis);
LineChart 的数据以 XYChart.Series 实例的形式提供。因此,如果 LineChart 在 x 轴上使用 String,在 y 轴上使用 Number,那么我们创建一个 XYChart.Series<String, Number> 实例,如下所示:
XYChart.Series<String,Number> series = new XYChart.Series<>();
series.setName("Series 1");
XYChart.Series 包含 XYChart.Data 类型的数据,因此 XYChart.Series<String, Number> 将包含 XYChart.Data<String, Number> 类型的数据。让我们向前面步骤中创建的系列添加一些数据:
series.getData().add(new XYChart.Data<String, Number>("Cat 1", 10));
series.getData().add(new XYChart.Data<String, Number>("Cat 2", 20));
series.getData().add(new XYChart.Data<String, Number>("Cat 3", 30));
series.getData().add(new XYChart.Data<String, Number>("Cat 4", 40));
然后将系列添加到 lineChart 的数据中:
lineChart.getData().add(series);
我们可以在此基础上创建另一个系列:
XYChart.Series<String, Number> series2 = new XYChart.Series<>();
series2.setName("Series 2");
series2.getData().add(new XYChart.Data<String, Number>("Cat 1", 40));
series2.getData().add(new XYChart.Data<String, Number>("Cat 2", 30));
series2.getData().add(new XYChart.Data<String, Number>("Cat 3", 20));
series2.getData().add(new XYChart.Data<String, Number>("Cat 4", 10));
lineChart.getData().add(series2);
创建的图表看起来如下:

参见
参考本章以下食谱:
-
创建柱状图
-
创建面积图
-
创建气泡图
-
创建散点图
创建面积图
面积图与折线图类似,唯一的区别是绘制线与轴之间的区域被着色,不同的系列用不同的颜色着色。在本食谱中,我们将探讨如何创建面积图。
准备工作
我们将利用之前食谱(创建折线图)中的原油和布伦特原油价格变动数据来绘制面积图。
如何做到这一点...
- 我们将创建一个 POJO 来表示给定月份和年份的油价:
public class OilPrice{
public String period;
public Double value;
}
- 接下来,我们将编写一个方法,
getOilData(String oilType),它将读取给定文件中的数据并构建List<OilPrice>:
private List<OilPrice> getOilData(String oilType)
throws IOException{
Scanner reader = new Scanner(getClass()
.getModule()
.getResourceAsStream("com/packt/"+oilType)
);
List<OilPrice> data = new LinkedList<>();
while(reader.hasNext()){
String line = reader.nextLine();
String[] elements = line.split("t");
OilPrice op = new OilPrice();
op.period = elements[0];
op.value = Double.parseDouble(elements[1]);
data.add(op);
}
Collections.reverse(data);
return data;
}
- 接下来,我们将编写一个方法,该方法将接受序列的名称和要填充在序列中的数据:
private XYChart.Series<String,Number> getSeries(
String seriesName, List<OilPrice> data
) throws IOException{
XYChart.Series<String,Number> series = new XYChart.Series<>();
series.setName(seriesName);
data.forEach(d -> {
series.getData().add(new XYChart.Data<String, Number>(
d.period, d.value
));
});
return series;
}
- 创建一个空的
start(Stage stage)方法,我们将在接下来的几个步骤中重写它:
@Override
public void start(Stage stage) throws IOException {
//code to be added here from the next few steps
}
- 创建并配置
GridPane:
GridPane gridPane = new GridPane();
gridPane.setAlignment(Pos.CENTER);
gridPane.setHgap(10);
gridPane.setVgap(10);
gridPane.setPadding(new Insets(25, 25, 25, 25));
- 创建
CategoryAxis作为x轴,NumberAxis作为y轴,并相应地标注它们:
final CategoryAxis xAxis = new CategoryAxis();
final NumberAxis yAxis = new NumberAxis();
xAxis.setLabel("Month");
yAxis.setLabel("Price");
- 使用前一步骤中创建的坐标轴初始化
AreaChart:
final AreaChart<String,Number> areaChart =
new AreaChart<>(xAxis,yAxis);
- 将原油数据加载到
List<OilPrice>中:
List<OilPrice> crudeOil = getOilData("crude-oil");
- 将布伦特原油数据加载到
List<OilPrice>中:
List<OilPrice> brentOil = getOilData("brent-oil");
- 为原油和布伦特原油分别创建一个序列,并将其添加到
AreaChart中:
areaChart.getData().add(getSeries("Crude Oil", crudeOil));
areaChart.getData().add(getSeries("Brent Oil", brentOil));
- 将
AreaChart添加到GridPane中:
gridPane.add(areaChart, 1, 1);
- 使用
GridPane作为根节点创建场景图并设置所需的大小:
Scene scene = new Scene(gridPane, 800, 600);
- 设置传递给
start(Stage stage)方法的Stage对象的属性,并将前一步骤中创建的场景图关联起来:
stage.setTitle("Area Charts");
stage.setScene(scene);
stage.show();
- 通过从
main方法中调用javafx.application.Application.launch()方法来启动 UI:
public static void main(String[] args) {
Application.launch(args);
}
完整代码可在chp9/7_area_charts中找到。
在chp9/7_area_charts目录下提供了两个运行脚本,run.bat和run.sh。run.bat脚本用于在 Windows 上运行应用程序,而run.sh脚本用于在 Linux 上运行应用程序。
使用run.bat或run.sh运行应用程序,您将看到以下 GUI:

它是如何工作的...
面积图与折线图类似,所以我们强烈建议阅读菜谱,创建折线图。面积图由两个坐标轴组成,数据分别绘制在x轴和y轴上。要绘制的数据以XYChart.Series实例的形式提供。图表的坐标轴可以是javafx.scene.chart.CategoryAxis或javafx.scene.chart.NumberAxis。
XYChart.Series包含封装在XYChart.Data实例中的数据。
参见
参考本章以下菜谱:
-
创建柱状图
-
创建面积图
-
创建气泡图
-
创建饼图
-
创建散点图
创建气泡图
气泡图也是一个双轴图表,数据具有第三个维度,即气泡的半径。气泡图仅支持NumberAxis,因此x轴和y轴上只能有数字。
在这个菜谱中,我们将创建一个简单的气泡图。
准备工作
我们提供了一天中不同时间段的样本商店访问数据以及该小时的销售额信息。此样本数据文件可在位置chp9/8_bubble_charts/src/gui/com/packt/store找到。数据文件中的每一行由三个部分组成:
-
白天的小时
-
访问次数
-
总销售额
如何操作...
- 让我们创建一个方法,从文件中读取数据到
StoreVisit对象中:
private List<StoreVisit> getData() throws IOException{
Scanner reader = new Scanner(getClass()
.getModule()
.getResourceAsStream("com/packt/store")
);
List<StoreVisit> data = new LinkedList<>();
while(reader.hasNext()){
String line = reader.nextLine();
String[] elements = line.split(",");
StoreVisit sv = new StoreVisit(elements);
data.add(sv);
}
return data;
}
- 我们还需要一天中任何时段的最大销售额。因此,让我们创建一个方法,该方法接受
List<StoreVisit>并返回最大销售额。我们将使用这个最大销售额来确定图表中气泡的半径:
private Integer getMaxSale(List<StoreVisit> data){
return data.stream()
.mapToInt(StoreVisit::getSales)
.max()
.getAsInt();
}
- 创建并配置一个
GridPane对象,我们将在此处放置图表:
GridPane gridPane = new GridPane();
gridPane.setAlignment(Pos.CENTER);
gridPane.setHgap(10);
gridPane.setVgap(10);
gridPane.setPadding(new Insets(25, 25, 25, 25));
- 为x轴和y轴创建两个
NumberAxis对象并给它们相应的名称:
final NumberAxis xAxis = new NumberAxis();
final NumberAxis yAxis = new NumberAxis();
xAxis.setLabel("Hour");
yAxis.setLabel("Visits");
- 使用这两个轴创建一个
BubbleChart实例:
final BubbleChart<Number,Number> bubbleChart =
new BubbleChart<>(xAxis,yAxis);
- 从文件中读取的商店访问数据创建一个
XYChart.Series:
List<StoreVisit> data = getData();
Integer maxSale = getMaxSale(data);
XYChart.Series<Number,Number> series =
new XYChart.Series<>();
series.setName("Store Visits");
data.forEach(sv -> {
series.getData().add(
new XYChart.Data<Number, Number>(
sv.hour, sv.visits, (sv.sales/(maxSale * 1d)) * 2
)
);
});
- 使用前面步骤中创建的序列填充
bubbleChart并将其添加到gridPane:
bubbleChart.getData().add(series);
gridPane.add(bubbleChart, 1, 1);
- 通过使用
gridPane创建场景图并设置到start(Stage stage)方法传入的Stage对象中来渲染图表:
Scene scene = new Scene(gridPane, 600, 400);
stage.setTitle("Bubble Charts");
stage.setScene(scene);
stage.show();
完整的代码可以在chp9/8_bubble_charts中找到。
我们在chp9/8_bubble_charts目录下提供了两个运行脚本,run.bat和run.sh。run.bat脚本用于在 Windows 上运行应用程序,而run.sh脚本用于在 Linux 上运行应用程序。
使用run.bat或run.sh运行应用程序,您将看到以下 GUI:

它是如何工作的...
气泡图是另一种双轴图,就像区域图、折线图和柱状图一样。唯一的区别在于XYChart.Data对象在其构造函数中接受第三个参数,该参数确定气泡的半径。一般思路是,气泡半径越大,该数据点的冲击力/贡献就越大。所以,在我们的例子中,我们使用了销售额和最大销售额来确定半径,使用公式(sales/(maxSale * 1d)) * 2,这意味着我们将销售额缩放到 2 的比例。
其余的细节与我们所见的其他双轴图完全相同,即柱状图、折线图和区域图。因此,我们不会深入探讨其细节,并且强烈建议您访问那些食谱。
参见
参考本章以下食谱:
-
创建柱状图
-
创建区域图
-
创建折线图
-
创建散点图
创建散点图
散点图是另一种类型的双轴图,其中数据以点的集合形式呈现。图表中的每个序列都由不同的形状表示。这些点不像折线图那样相连。此类图表有助于我们通过查看数据点的密度来识别数据的大致分布。在本食谱中,我们将查看如何创建一个简单的散点图。
准备工作
我收集了 2016 年 10 月 26 日在印度和新西兰之间举行的一日国际板球比赛的统计数据(www.espncricinfo.com/series/1030193/scorecard/1030221)。收集的数据是新西兰和印度回合中钩子落下时的得分和进行中的球数。这些数据可以在文件chp9/9_scatter_charts/src/gui/com/packt/wickets中找到。数据看起来如下:
NZ,0.2,0
NZ,20.3,120
NZ,30.6,158
NZ,40.5,204
在前面的示例中,每行数据有三个部分:
-
团队
-
钩子落下的进行中的球数
-
钩子落下的团队得分
我们将此数据绘制在散点图上,并了解每个队伍回合中板球是如何失掉的。
对于那些想知道这项被称为板球的游戏是什么的人来说,我们建议你花几分钟时间在这里阅读有关它的内容:en.wikipedia.org/wiki/Cricket。
如何实现...
- 让我们首先创建一个方法来从文件中读取板球失球数据:
private Map<String, List<FallOfWicket>> getFallOfWickets()
throws IOException{
Scanner reader = new Scanner(getClass()
.getModule()
.getResourceAsStream("com/packt/wickets")
);
Map<String, List<FallOfWicket>> data = new HashMap<>();
while(reader.hasNext()){
String line = reader.nextLine();
String[] elements = line.split(",");
String country = elements[0];
if ( !data.containsKey(country)){
data.put(country, new ArrayList<FallOfWicket>());
}
data.get(country).add(new FallOfWicket(elements));
}
return data;
}
- 散点图也是一个 X-Y 图;我们将使用
XYChart.Series来创建图表的数据。让我们编写一个方法来使用从文件解析的数据创建XYChart.Series<Number,Number>的实例:
private XYChart.Series<Number, Number> getSeries(
List<FallOfWicket> data, String seriesName
){
XYChart.Series<Number,Number> series = new XYChart.Series<>();
series.setName(seriesName);
data.forEach(s -> {
series.getData().add(
new XYChart.Data<Number, Number>(s.over, s.score)
);
});
return series;
}
- 现在我们来构建 UI(所有这些代码都放在
start(Stage stage)方法中),首先创建一个GridPane实例并对其进行配置:
GridPane gridPane = new GridPane();
gridPane.setAlignment(Pos.CENTER);
gridPane.setHgap(10);
gridPane.setVgap(10);
gridPane.setPadding(new Insets(25, 25, 25, 25));
- 使用第一步中创建的方法从文件加载数据:
Map<String, List<FallOfWicket>> fow = getFallOfWickets();
- 创建所需的x和y轴并将它们添加到
ScatterChart:
final NumberAxis xAxis = new NumberAxis();
final NumberAxis yAxis = new NumberAxis();
xAxis.setLabel("Age");
yAxis.setLabel("Marks");
final ScatterChart<Number,Number> scatterChart =
new ScatterChart<>(xAxis,yAxis);
- 为每个队伍的回合创建
XYChart.Series并将其添加到ScatterChart:
scatterChart.getData().add(getSeries(fow.get("NZ"), "NZ"));
scatterChart.getData().add(getSeries(fow.get("IND"), "IND"));
- 将
ScatterChart添加到gridPane并创建一个新的Scene图,以gridPane为根:
gridPane.add(scatterChart, 1, 1);
Scene scene = new Scene(gridPane, 600, 400);
- 将场景图设置为要渲染在显示上的
Stage实例:
stage.setTitle("Bubble Charts");
stage.setScene(scene);
stage.show();
- 从
main方法启动 UI,如下所示:
public static void main(String[] args) {
Application.launch(args);
}
你可以在位置找到完整的代码,chp9/9_scatter_charts。
我们在chp9/9_scatter_charts目录下提供了两个运行脚本,run.bat和run.sh。run.bat脚本将用于在 Windows 上运行应用程序,而run.sh将用于在 Linux 上运行应用程序。
使用run.bat或run.sh运行应用程序,你将看到以下 GUI:

它是如何工作的...
散点图的工作方式与柱状图或线图类似。它是一个双轴图,数据点绘制在两个轴上。轴是通过使用javafx.scene.chart.CategoryAxis或javafx.scene.chart.NumberAxis创建的,具体取决于数据是字符串还是数值。
要绘制的数据以XYChart.Series<X,Y>的形式提供,其中X和Y可以是String或任何扩展Number的类型,并且它包含以XYChart.Data对象列表形式的数据,如下所示:
XYChart.Series<Number,Number> series = new XYChart.Series<>();
series.getData().add(new XYChart.Data<Number, Number>(xValue, yValue));
在散点图中,数据以点的形式绘制,每个系列都有特定的颜色和形状,这些点之间没有连接,与线图或面积图不同。这可以在食谱中使用的示例中看到。
参见
参考本章以下食谱:
-
创建柱状图
-
创建面积图
-
创建线图
-
创建饼图
-
创建气泡图
在应用程序中嵌入 HTML
JavaFX 通过 javafx.scene.web 包中定义的类提供对管理网页的支持。它支持通过接受网页 URL 或接受网页内容来加载网页。它还管理网页的文档模型,应用相关的 CSS,并运行相关的 JavaScript 代码。它还扩展了对 JavaScript 和 Java 代码之间双向通信的支持。
在这个配方中,我们将构建一个非常原始和简单的网页浏览器,它支持以下功能:
-
在访问过的页面历史记录中导航
-
重新加载当前页面
-
一个用于接受 URL 的地址栏
-
一个用于加载输入 URL 的按钮
-
显示网页
-
显示网页加载状态
准备就绪
我们需要互联网连接来测试页面加载。所以,请确保你已经连接到互联网。除此之外,没有其他特定要求来使用这个配方。
如何实现...
- 让我们首先创建一个具有空方法的类,它将代表启动应用程序以及 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) 方法中。
- 让我们创建一个
javafx.scene.web.WebView组件,它将渲染我们的网页。这具有所需的javafx.scene.web.WebEngine实例,该实例管理网页的加载:
WebView webView = new WebView();
- 获取由
webView使用的javafx.scene.web.WebEngine实例。我们将使用这个javafx.scene.web.WebEngine实例来浏览历史记录并加载其他网页。然后,默认情况下,我们将加载以下 URL,www.google.com:
WebEngine webEngine = webView.getEngine();
webEngine.load("http://www.google.com/");
- 现在,让我们创建一个
javafx.scene.control.TextField组件,它将充当我们的浏览器地址栏:
TextField webAddress = new TextField("http://www.google.com/");
- 我们希望根据完全加载的网页的标题和 URL 来更改浏览器和地址栏中的网页标题。这可以通过监听从
javafx.scene.web.WebEngine实例获取的javafx.concurrent.Worker的stateProperty的变化来实现:
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());
}
}
}
);
- 让我们创建一个
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);
}
});
- 让我们创建一个
javafx.scene.control.Button实例,当点击时,将转到历史记录中的上一个网页。为此,我们将在动作处理程序中执行 JavaScript 代码,history.back():
Button prevButton = new Button("Prev");
prevButton.setOnAction(e -> {
webEngine.executeScript("history.back()");
});
- 让我们创建一个
javafx.scene.control.Button实例,当点击时,将转到由javafx.scene.web.WebEngine实例维护的历史记录中的下一个条目。为此,我们将使用javafx.scene.web.WebHistoryAPI:
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);
}
});
- 接下来是重新加载当前页面的按钮。同样,我们将使用
javafx.scene.web.WebEngine来重新加载当前页面:
Button reloadButton = new Button("Refresh");
reloadButton.setOnAction(e -> {
webEngine.reload();
});
- 现在,我们需要将迄今为止创建的所有组件(即
prevButton、nextButton、reloadButton、webAddress和goButton)分组,以便它们彼此水平对齐。为了实现这一点,我们将使用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
);
- 我们想知道网页是否正在加载以及是否已加载完成。让我们创建一个
javafx.scene.layout.Label字段来更新状态,如果网页已加载。然后,我们将监听javafx.concurrent.Worker实例的workDoneProperty的更新,我们可以从javafx.scene.web.WebEngine实例中获取它:
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");
}
}
});
- 让整个地址栏(包括其导航按钮)、
webView和websiteLoadingStatus垂直对齐:
VBox root = new VBox();
root.getChildren().addAll(
addressBar, webView, websiteLoadingStatus
);
- 使用前一步骤中创建的
VBox实例作为根,创建一个新的Scene对象:
Scene scene = new Scene(root);
- 我们希望
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();
完整的代码可以在位置 chp9/10_embed_html 中找到。
我们在 chp9/10_embed_html 目录下提供了两个运行脚本,run.bat 和 run.sh。run.bat 脚本用于在 Windows 上运行应用程序,而 run.sh 脚本用于在 Linux 上运行应用程序。
使用 run.bat 或 run.sh 运行应用程序,您将看到以下 GUI:

它是如何工作的...
与网页相关的 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 实例来异步加载网页。然后,我们将注册这些工作实例属性的变化监听器以跟踪网页加载的进度。在这个配方中,我们使用了两个这样的属性,即 stateProperty 和 workDoneProperty。前者跟踪工作状态的变化,后者跟踪已完成的工作百分比。
工作者可以经过以下状态(如 javafx.concurrent.Worker.State 枚举中列出):
-
CANCELLED -
FAILED -
READY -
RUNNING -
SCHEDULED -
SUCCEEDED
在我们的配方中,我们只检查SUCCEEDED,但你也可以增强它以检查FAILED。这将帮助我们报告无效的 URL,甚至从事件对象中获取消息并显示给用户。
我们通过在*Property()上使用addListener()方法来添加监听器以跟踪属性的变化,其中*可以是state、workDone或任何其他已公开为属性的 worker 属性:
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 支持创建网络浏览器的基本方法。你可以增强它以支持以下功能:
-
更好的错误处理和用户消息,即通过跟踪工作线程的状态变化来显示网页地址是否有效。
-
多个标签页
-
收藏夹
-
在本地存储浏览器的状态,以便下次运行时加载所有书签和历史记录
在应用程序中嵌入媒体
JavaFX 提供了一个组件javafx.scene.media.MediaView,用于查看视频和收听音频。这个组件由媒体引擎javafx.scene.media.MediaPlayer支持,该引擎加载并管理媒体的播放。
在这个配方中,我们将查看如何通过使用媒体引擎上的方法来播放示例视频并控制其播放。
准备工作
我们将使用位于chp9/11_embed_audio_video/sample_video1.mp4的示例视频。
如何做到这一点...
- 让我们首先创建一个具有空方法的类,它将代表启动应用程序以及 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
}
}
- 为位于
chp9/11_embed_audio_video/sample_video1.mp4的视频创建一个javafx.scene.media.Media对象:
File file = new File("sample_video1.mp4");
Media media = new Media(file.toURI().toString());
- 使用上一步创建的
javafx.scene.media.Media对象创建一个新的媒体引擎javafx.scene.media.MediaPlayer:
MediaPlayer mediaPlayer = new MediaPlayer(media);
- 让我们通过在
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);
}
});
- 现在让我们使用上一步创建的媒体引擎来创建一个媒体查看器:
MediaView mediaView = new MediaView(mediaPlayer);
- 我们将限制媒体查看器的宽度和高度:
mediaView.setFitWidth(350);
mediaView.setFitHeight(350);
- 接下来,我们创建三个按钮来暂停视频播放、恢复播放和停止播放。我们将使用
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();
});
- 使用
javafx.scene.layout.HBox水平对齐所有这些按钮:
HBox controlsBox = new HBox(10);
controlsBox.getChildren().addAll(pauseB, playB, stopB);
- 使用
javafx.scene.layout.VBox垂直对齐媒体查看器和按钮栏:
VBox vbox = new VBox();
vbox.getChildren().addAll(mediaView, controlsBox);
- 使用
VBox对象作为根创建一个新的场景图,并将其设置到舞台对象:
Scene scene = new Scene(vbox);
stage.setScene(scene);
// Name and display the Stage.
stage.setTitle("Media Demo");
- 在显示上渲染舞台:
stage.setWidth(400);
stage.setHeight(400);
stage.show();
完整的代码可以在chp9/11_embed_audio_video找到。
我们在chp9/11_embed_audio_video下提供了两个运行脚本,run.bat和run.sh。run.bat脚本将用于在 Windows 上运行应用程序,而run.sh将用于在 Linux 上运行应用程序。
使用run.bat或run.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;
}
在这个菜谱中,我们有一个示例视频在chp9/11_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);
通过javafx.scene.media.MediaView类管理 UI 上的媒体渲染,它背后是一个javafx.scene.media.MediaPlayer对象:
MediaView mediaView = new MediaView(mediaPlayer);
我们可以通过使用setFitWidth()和setFitHeight()方法来设置查看器的高度和宽度。
更多...
我们在 JavaFX 中提供了一个基本的媒体支持演示。还有很多可以探索的。您可以添加音量控制选项、向前或向后查找选项、播放音频和音频均衡器。
向控件添加效果
以受控的方式添加效果会给用户界面带来良好的外观。有多种效果,如模糊、阴影、反射、光晕等。JavaFX 在javafx.scene.effects包下提供了一套类,可以用来添加效果以增强应用程序的外观。此包在javafx.graphics模块中可用。
在这个菜谱中,我们将查看几个效果:模糊、阴影和反射。
如何做到这一点...
- 让我们首先创建一个具有空方法的类,它将代表启动应用程序以及 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
}
}
- 后续代码将在
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));
- 创建用于应用模糊效果的矩形:
Rectangle r1 = new Rectangle(100,25, Color.BLUE);
Rectangle r2 = new Rectangle(100,25, Color.RED);
Rectangle r3 = new Rectangle(100,25, Color.ORANGE);
- 将
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));
- 将矩形添加到
gridPane:
gridPane.add(r1,1,1);
gridPane.add(r2,2,1);
gridPane.add(r3,3,1);
- 创建三个圆形,用于应用阴影:
Circle c1 = new Circle(20, Color.BLUE);
Circle c2 = new Circle(20, Color.RED);
Circle c3 = new Circle(20, Color.GREEN);
- 将
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));
- 将这些圆形添加到
gridPane:
gridPane.add(c1,1,2);
gridPane.add(c2,2,2);
gridPane.add(c3,3,2);
- 在我们将应用反射效果的简单文本
Reflection Sample上创建一个文本:
Text t = new Text("Reflection Sample");
t.setFont(Font.font("Arial", FontWeight.BOLD, 20));
t.setFill(Color.BLUE);
- 创建一个
javafx.scene.effect.Reflection效果并将其添加到文本中:
Reflection reflection = new Reflection();
reflection.setFraction(0.8);
t.setEffect(reflection);
- 将文本组件添加到
gridPane:
gridPane.add(t, 1, 3, 3, 1);
- 使用
gridPane作为根节点创建一个场景图:
Scene scene = new Scene(gridPane, 500, 300);
- 将场景图设置到舞台并渲染到显示上:
stage.setScene(scene);
stage.setTitle("Effects Demo");
stage.show();
完整的代码可以在chp9/12_effects_demo中找到。
我们在chp9/12_effects_demo下提供了两个运行脚本,run.bat用于在 Windows 上运行应用程序,run.sh用于在 Linux 上运行应用程序。
使用run.bat或run.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在对象内部添加阴影。每个效果都接受阴影的半径、阴影开始位置的x和y坐标以及阴影的颜色:
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 效果。
我们建议您以我们尝试它们的方式尝试这些效果。
使用新的 TIFF I/O API 读取 TIFF 图像
标记图像文件格式 (TIFF) 是一种常见的图像格式,用于在应用程序之间交换图像。之前,JDK 没有任何读取 TIFF 图像的支持,开发者必须使用 JDK 外部的 Java 图像 API。
在本菜谱中,我们将向您展示如何读取 TIFF 图像文件。
准备工作
我们在位置 chp9/13_tiff_reader/sample.tif 有一个示例 TIFF 图像文件。
如何实现...
- 通过格式名称获取图像读取器,对于 TIFF 图像,格式名称是
tiff
Iterator iterator = ImageIO.getImageReadersByFormatName("tiff");
- 从上一步获取的图像读取器中获取第一个
ImageReader对象:
ImageReader reader = (ImageReader) iterator.next();
- 为
sample.tif图像创建一个FileImageInputStream对象:
try(ImageInputStream is =
new FileImageInputStream(new File("sample.tif"))) {
//image reading code here.
} catch (Exception ex){
//exception handling
}
- 使用获取到的读取器读取图像文件:
reader.setInput(is, false, true);
- 让我们获取一些属性,例如图像数量、宽度和高度,以确认我们已经真正读取了图像:
System.out.println("Number of Images: " +
reader.getNumImages(true));
System.out.println("Height: " + reader.getHeight(0));
System.out.println("Width: " + reader.getWidth(0));
System.out.println(reader.getFormatName());
此代码的完整内容可以在 chp9/13_tiff_reader 文件夹中找到。您可以通过使用 run.bat 或 run.sh 运行示例。
第十章:使用 Spring Boot 的 RESTful Web 服务
在本章中,我们将介绍以下食谱:
-
创建一个简单的 Spring Boot 应用程序
-
与数据库交互
-
创建 RESTful Web 服务
-
为 Spring Boot 创建多个配置文件
-
将 RESTful Web 服务部署到 Heroku
-
使用 Docker 容器化 RESTful Web 服务
简介
近年来,基于微服务架构的推动力得到了广泛的应用,这得益于它提供的简单性和易于维护。许多公司,如 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 的空项目:
- 导航到
start.spring.io/以查看以下截图类似的内容:

-
您可以通过在文本后的下拉菜单中选择适当的选项来选择依赖关系管理和构建工具,Generate a。
-
Spring Boot 支持 Java、Kotlin 和 Groovy。您可以通过更改文本后的下拉菜单来选择语言,with。
-
通过在文本后的下拉菜单中选择 Spring Boot 的版本值,选择 Spring Boot。对于这个食谱,我们将使用 Spring Boot 2 的最新里程碑版 2.0.0 M2。
-
在左侧,在项目元数据下,我们必须提供与 Maven 相关的信息,即组 ID 和工件 ID。我们将组设置为
com.packt,工件设置为boot_demo。 -
在右侧的依赖项下,你可以搜索你想添加的依赖项。对于这个菜谱,我们需要 web 和 Thymeleaf 依赖项。这意味着我们想要创建一个使用 Thymeleaf UI 模板的 Web 应用程序,并且希望所有依赖项,如 Spring MVC、嵌入式 Tomcat 等,都成为应用程序的一部分。
-
然后,点击生成项目按钮以下载空项目。你可以将这个空项目加载到你选择的任何 IDE 中,就像其他 Maven 项目一样。
到目前为止,你将把你的空项目加载到你选择的 IDE 中,并准备好进一步探索。在这个菜谱中,我们将使用 Thymeleaf 模板引擎来定义我们的 Web 页面,并创建一个简单的控制器来渲染 Web 页面。
本菜谱的完整代码可以在位置chp10/1_boot_demo找到。
如何做到这一点...
-
如果你遵循了准备就绪部分中提到的组 ID 和工件 ID 命名,你将有一个包结构
com.packt.boot_demo和一个为你创建的BootDemoApplication.java主类。在tests文件夹下将有一个等效的包结构和BootDemoApplicationTests.java主类。 -
在
com.packt.boot_demo包下创建一个新的类,名为SimpleViewController,代码如下:
@Controller
public class SimpleViewController{
@GetMapping("/message")
public String message(){
return "message";
}
}
- 在位置
src/main/resources/templates下创建一个名为message.html的 Web 页面,代码如下:
<h1>Hello, this is a message from the Controller</h1>
<h2>The time now is [[${#dates.createNow()}]]</h2>
- 从命令提示符导航到项目根目录,并执行命令
mvn spring-boot:run;你会看到应用程序正在启动。一旦初始化完成并开始运行,它将默认在端口8080上运行。导航到http://localhost:8080/message以查看消息。
我们正在使用 Spring Boot 的 Maven 插件,它为我们提供了在开发期间启动应用程序的便捷工具。但对于生产环境,我们将创建一个胖 JAR,即包含所有依赖项的 JAR 文件,并将其作为 Linux 或 Windows 服务部署。我们甚至可以使用java -jar命令运行胖 JAR。
它是如何工作的...
我们不会深入探讨 Spring Boot 或其他 Spring 库的工作原理。但简要来说,Spring Boot 创建了一个在默认端口8080上运行的嵌入式 Tomcat。然后,它将类中带有@SpringBootApplication注解的包和子包中的所有控制器、组件和服务注册到 Spring 框架中。
在我们的菜谱中,位于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 GET、POST、DELETE 和 PUT 方法,有专门的注解映射,分别是 @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 工作台;启动时,你应该能看到以下图像以及其他由该工具提供的图像:

如果您找不到前面的图像中的连接,您可以使用(+)符号添加一个。点击(+)后,您将看到以下对话框。填写它并点击测试连接以获取成功消息:

成功的测试连接将产生以下消息:

双击连接以连接到数据库,您应该在左侧看到数据库列表,右侧有一个空白区域,顶部有菜单和工具栏。从文件菜单中,点击新建查询标签或,作为替代,按Ctrl + T以获取一个新的查询窗口。在这里,我们将编写我们的查询以创建数据库并在该数据库中创建一个表。
从dev.mysql.com/downloads/windows/installer/5.7.html下载的捆绑安装程序仅适用于 Windows。Linux 用户必须分别下载 MySQL 服务器和 MySQL Workbench(与数据库交互的 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 项目,以下是一些选项:

如何做到这一点...
- 创建一个表示人员的模型类
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;
}
- 让我们创建
com.packt.boot_db_demo.PersonMapper来将数据库中的数据映射到我们的模型类Person:
@Mapper
public interface PersonMapper {
}
- 让我们添加一个方法来获取表中的所有行。注意,接下来的几个方法将编写在
PersonMapper接口内部:
@Select("SELECT * FROM person")
public List<Person> getPersons();
- 另一个方法是通过 ID 获取单个人员的详细信息如下:
@Select("SELECT * FROM person WHERE id = #{id}")
public Person getPerson(Integer id);
- 创建表中新行的方法如下:
@Insert("INSERT INTO person(first_name, last_name, place) " +
" VALUES (#{firstName}, #{lastName}, #{place})")
@Options(useGeneratedKeys = true)
public void insert(Person person);
- 更新表中由 ID 标识的现有行的方法如下:
@Update("UPDATE person SET first_name = #{firstName}, last_name =
#{lastName}, "+ "place = #{place} WHERE id = #{id} ")
public void save(Person person);
- 最后,删除表中由 ID 标识的行的方法如下:
@Delete("DELETE FROM person WHERE id = #{id}")
public void delete(Integer id);
- 让我们创建一个
com.packt.boot_db_demo.PersonController类,我们将使用它来编写我们的 Web 端点:
@Controller
@RequestMapping("/persons")
public class PersonContoller {
@Autowired PersonMapper personMapper;
}
- 让我们创建一个端点来列出
person表中的所有条目:
@GetMapping
public String list(ModelMap model){
List<Person> persons = personMapper.getPersons();
model.put("persons", persons);
return "list";
}
- 让我们创建一个端点来在
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";
}
- 让我们在
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/";
}
- 让我们创建一个端点来从
person表中删除一行:
@GetMapping("/{id}/delete")
public String deletePerson(@PathVariable Integer id){
personMapper.delete(id);
return "redirect:/persons";
}
- 最后,我们需要更新
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。
本食谱的完整代码可以在以下位置找到,chp10/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在底层使用的。
然后,我们通过使用@AutoWired将com.packt.boot_db_demo.PersonMapper注入到com.packt.boot_db_demo.PersonController类中,来使用com.packt.boot_db_demo.PersonMapper。@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/下载启动项目:

如何做到这一点...
- 我们将从之前的菜谱中复制
Person类:
public class Person {
private Integer id;
private String firstName;
private String lastName;
private String place;
//required getters and setters
}
-
我们将以不同的方式处理
PersonMapper部分。我们将把所有的 SQL 查询写在映射 XML 文件中,然后从PersonMapper接口中引用它们。我们将映射 XML 放在src/main/resources/mappers文件夹下。我们将mybatis.mapper-locations属性的值设置为classpath*:mappers/*.xml。这样,PersonMapper接口就可以发现与其方法对应的 SQL 查询。 -
首先,让我们创建
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);
}
- 现在,让我们在
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>
- 我们将在
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
- 为我们的 REST API 创建一个空的控制器。这个控制器将使用
@RestController注解,因为其中的所有 API 都将仅处理数据:
@RestController
@RequestMapping("/api/persons")
public class PersonApiController {
@Autowired PersonMapper personMapper;
}
- 让我们添加一个 API 来列出
person表中的所有行:
@GetMapping
public ResponseEntity<List<Person>> getPersons(){
return new ResponseEntity<>(personMapper.getPersons(),
HttpStatus.OK);
}
- 让我们添加一个 API 来获取单个人员的详细信息:
@GetMapping("/{id}")
public ResponseEntity<Person> getPerson(@PathVariable Integer id){
return new ResponseEntity<>(personMapper.getPerson(id),
HttpStatus.OK);
}
- 让我们添加一个 API 来向表中添加新数据:
@PostMapping
public ResponseEntity<Person> newPerson
(@RequestBody Person person){
personMapper.insert(person);
return new ResponseEntity<>(person, HttpStatus.OK);
}
- 让我们添加一个 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);
}
- 让我们添加一个 API 来删除表中的数据:
@DeleteMapping("/{id}")
public ResponseEntity<Void> deletePerson
(@PathVariable Integer id){
personMapper.delete(id);
return new ResponseEntity<>(HttpStatus.OK);
}
您可以在指定位置找到完整的代码,chp10/3_boot_rest_demo。您可以从项目文件夹中使用mvn spring-boot:run启动应用程序。一旦应用程序启动,导航到http://localhost:8080/api/persons以查看人员表中的所有数据。
要测试其他 API,我们将使用 Google Chrome 的 Postman REST 客户端应用。
这就是添加新人员的样子。看看请求体,即 JSON 中指定的人员详细信息:

这是我们如何编辑一个人的详细信息:

这就是删除一个人的样子:

它是如何工作的...
首先,让我们看看PersonMapper接口是如何发现要执行的 SQL 语句的。如果您查看src/main/resources/mappers/PersonMapper.xml,您会发现<mapper>的namespace属性是org.packt.boot_rest_demo.PersonMapper。这是namespace属性值应该是映射接口的完全限定名称的要求,在我们的例子中是org.packt.boot_rest_demo.PersonMapper。
接下来,在<select>、<insert>、<update>和<delete>中定义的各个 SQL 语句的id属性应该与映射接口中的方法名称匹配。例如,PersonMapper接口中的getPersons()方法查找id="getPersons"的 SQL 语句。
现在 MyBatis 库通过读取mybatis.mapper-locations属性的值来发现这个 mapper XML 的位置。
来到控制器部分,我们引入了一个新的注解@RestController。这个特殊的注解除了表示它是一个 Web 控制器外,还表示类中定义的所有方法都返回通过 HTTP 响应体发送的响应;所有 REST API 也是如此。它们只是与数据一起工作。
如同往常一样,你可以通过使用 Maven Spring-Boot 插件mvn spring-boot:run或通过执行 Maven 打包创建的 JAR 文件java -jar my_jar_name.jar来启动你的 Spring Boot 应用程序。
为 Spring Boot 创建多个配置文件
通常,Web 应用程序部署在不同的环境中--首先,它们在开发者的机器上本地运行,然后部署到测试服务器,最后部署到生产服务器。对于每个环境,应用程序都会与位于不同位置的不同组件交互。为此,最佳做法是为每个环境维护不同的配置文件。一种方法是通过创建application.properties文件的不同版本来实现,即存储应用程序级别属性的文件的不同版本。这些 Spring Boot 中的属性文件也可以是 YML 文件,例如application.yml。即使你创建了不同的版本,你也需要一种机制来告诉你的应用程序根据它部署到的环境选择相关版本的文件。
Spring Boot 为这样的特性提供了惊人的支持。它允许你拥有多个配置文件,每个文件代表一个特定的配置文件,然后,你可以根据应用程序部署到的环境启动应用程序。让我们看看它是如何工作的,然后我们将解释它是如何工作的。
准备工作
对于这个食谱,有两种方式来托管 MySQL 数据库的另一个实例:
-
使用像 AWS 这样的云服务提供商,并使用其 Amazon 关系数据库服务(RDS)(
aws.amazon.com/rds/)。他们有一定的免费使用限制。 -
使用像 DigitalOcean(
www.digitalocean.com/)这样的云服务提供商,以每月最低 5 美元的价格购买一个 droplet(即服务器)。然后在上面安装 MySQL 服务器。 -
使用 VirtualBox 在你的机器上安装 Linux,假设我们使用 Windows,或者如果你使用 Linux,则相反。然后在上面安装 MySQL 服务器。
选项从托管数据库服务到服务器,这些服务器可以让你完全访问 root 权限来安装 MySQL 服务器。对于这个食谱,我们做了以下操作:
-
我们从 DigitalOcean 购买了基本 droplet。
-
我们使用
sudo apt-get install mysql-server-5.7安装 MySQL,并为 root 用户设置密码。 -
我们创建了一个名为
springboot的新用户,这样我们就可以使用这个用户从我们的 RESTful Web 服务应用程序连接:
$ mysql -uroot -p
Enter password:
mysql> create user 'springboot'@'%' identified by 'springboot';
-
我们修改了 MySQL 配置文件,以便 MySQL 允许远程连接。这可以通过在
/etc/mysql/mysql.conf.d/mysqld.cnf文件中编辑bind-address属性为服务器的 IP 地址来实现。 -
从 MySQL 工作台,我们通过使用
IP = <Digital Ocean droplet IP>、username = springboot和password = springboot添加了新的 MySQL 连接。
在 Ubuntu OS 中,MySQL 配置文件的位置是/etc/mysql/mysql.conf.d/mysqld.cnf。找出特定于您的操作系统的配置文件位置的一种方法如下:
-
运行
mysql --help -
在输出中,搜索
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 DB 的云实例。让我们看看如何根据应用程序运行的配置来管理两个不同连接的信息。
为此菜谱所需的初始示例应用程序可以在位置chp10/4_boot_multi_profile_incomplete找到。我们将转换此应用程序,使其能够在不同的环境中运行。
如何做到这一点...
-
在
src/main/resources/application.properties文件中,添加一个新的springboot属性,spring.profiles.active = local。 -
在
src/main/resources/位置创建一个新的文件,application-local.properties。 -
将以下属性添加到
application-local.properties中,并从application.properties文件中删除它们:
spring.datasource.url=jdbc:mysql://localhost/sample?useSSL=false
spring.datasource.username=root
spring.datasource.password=mohamed
-
在
src/main/resources/处创建另一个文件,application-cloud.properties。 -
将以下属性添加到
application-cloud.properties中:
spring.datasource.url=jdbc:mysql://<digital_ocean_ip>/sample? useSSL=false
spring.datasource.username=springboot
spring.datasource.password=springboot
完整应用程序的完整代码可以在chp10/4_boot_multi_profile_incomplete.处找到。您可以通过使用mvn spring-boot:run命令来运行应用程序。Spring Boot 从application.properties文件中读取spring.profiles.active属性,并在本地配置下运行应用程序。在浏览器中打开 URL,http://localhost:8080/api/persons,以找到以下数据:
[
{
"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}.properties或application-{profile}.yml文件,位于打包的 JAR 文件之外。 -
打包在 JAR 中的特定配置文件应用程序属性
application-{profile}.properties或application-{profile}.yml文件。 -
定义在打包的 JAR 之外的应用程序属性,
application.properties或application.yml。 -
打包在 JAR 中的应用程序属性,
application.properties或application.yml。 -
作为属性源(带有
@PropertySource注解)的配置类(即带有@Configuration注解的类)。 -
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.properties 或 application-{profile}.properties 文件可以定义在应用程序 JAR 之外。Spring Boot 将在默认位置搜索属性文件,其中一个路径是应用程序运行当前目录的 config 子目录。
Spring Boot 支持的所有应用程序属性的完整列表可以在 docs.spring.io/spring-boot/docs/current/reference/html/common-application-properties.html 找到。除了这些之外,我们还可以创建我们应用程序所需的自己的属性。
完整代码可以在位置 chp10/4_boot_multi_profile_complete 找到。
还有更多...
我们可以使用 Spring Boot 创建一个配置服务器,该服务器将作为所有配置文件中所有应用程序的所有属性的存储库。然后,客户端应用程序可以连接到配置服务器,根据应用程序名称和应用程序配置读取相关属性。
在配置服务器中,可以使用类路径或 GitHub 仓库从文件系统读取应用程序属性。使用 GitHub 仓库的优势在于属性文件可以进行版本控制。配置服务器中的属性文件可以更新,并且可以通过设置消息队列来中继更改,将这些更新推送到客户端应用程序。另一种方法是使用 @RefreshScope 实例,并在需要客户端应用程序拉取配置更改时调用 /refresh API。
在 Heroku 上部署 RESTful 网络服务
平台即服务(PaaS)是云计算模型之一(其他两个是软件即服务(SaaS)和基础设施即服务(IaaS)),其中云计算提供商提供管理的计算平台,包括操作系统、编程语言运行时、数据库以及其他附加功能,如队列、日志管理和警报。他们还提供工具以简化部署,并提供仪表板以监控您的应用程序。
Heroku 是 PaaS(平台即服务)提供商领域的早期参与者之一。它支持以下编程语言:Ruby、Node.js、Java、Python、Clojure、Scala、Go 和 PHP。Heroku 支持多种数据存储,如 MySQL、MongoDB、Redis 和 Elastic search。它提供与日志工具、网络工具、电子邮件服务和监控工具的集成。
Heroku 提供了一个名为 heroku-cli 的命令行工具(cli.heroku.com),可用于创建 Heroku 应用程序、部署、监控、添加资源等。他们提供的网页仪表板功能也由 CLI 支持。它使用 Git 存储应用程序的源代码。因此,当您将应用程序代码推送到 Heroku 的 Git 仓库时,它将根据您使用的构建包触发构建。然后,它将使用默认方式启动应用程序或使用 ProcFile 执行您的应用程序。
在本食谱中,我们将部署我们的基于 Spring Boot 的 RESTful 网络服务到 Heroku。我们将继续使用在前一个食谱“为 Spring Boot 创建多个配置文件”中创建的数据库。
准备工作
在我们开始在 Heroku 上部署我们的示例应用程序之前,我们需要注册一个 Heroku 账户并安装其工具,这将使我们能够从命令行工作。在接下来的章节中,我们将指导您完成注册过程,通过网页用户界面创建一个示例应用程序,以及通过 Heroku 命令行界面(CLI)创建。
设置 Heroku 账户
如果您还没有账户,请访问 www.heroku.com 并注册。如果您已有账户,则可以登录。注册的 URL 是 signup.heroku.com:

登录的 URL 是 id.heroku.com/login:

登录成功后,您将看到一个包含应用程序列表的仪表板,如果您有任何应用程序:

从 UI 创建新应用
点击“新建 | 创建新应用”,并填写以下截图中的详细信息,然后点击“创建应用”:

从 CLI 创建新应用
执行以下步骤从 CLI 创建新应用:
-
从
cli.heroku.com安装 Heroku CLI。 -
安装完成后,Heroku 应该位于您的系统
PATH变量中。 -
打开命令提示符并运行
heroku create。您将看到类似于以下输出的内容:
Creating app... done, glacial-beyond-27911
https://glacial-beyond-27911.herokuapp.com/ |
https://git.heroku.com/glacial-beyond-27911.git
- 应用程序名称是动态生成的,并创建了一个远程 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
将部署到 Heroku 是通过git push到在 Heroku 上创建的远程 Git 仓库完成的。我们将在下一节中看到这一点。
我们在chp10/5_boot_on_heroku有应用程序的源代码。因此,复制此应用程序并继续在 Heroku 上部署。
在运行 Heroku cli 中的任何命令之前,您必须登录到 Heroku 账户。您可以通过运行命令heroku login来登录。
如何操作...
- 运行以下命令创建 Heroku 应用程序:
$ heroku create <app_name> -region us
- 在项目文件夹中初始化 Git 仓库:
$ git init
- 将 Heroku Git 仓库作为远程仓库添加到本地 Git 仓库:
$ heroku git:remote -a <app_name_you_chose>
- 将源代码,即 master 分支,推送到 Heroku Git 仓库:
$ git add .
$ git commit -m "deploying to heroku"
$ git push heroku master
- 当代码推送到 Heroku Git 仓库时,会触发构建。由于我们使用 Maven,它会运行以下命令:
./mvnw -DskipTests clean dependency:list install
-
代码完成构建和部署后,您可以使用
heroku open命令打开应用程序。这将使用浏览器打开应用程序。 -
您可以使用
heroku logs --tail命令监控应用程序的日志。
应用程序成功部署后,运行heroku open命令后,您应该在浏览器中看到正在加载的 URL:

点击“人员”链接将显示以下信息:
[
{
"id":1,
"firstName":"Mohamed",
"lastName":"Sanaulla",
"place":"Bangalore"
},
{
"id":2,
"firstName":"Nick",
"lastName":"Samoylov",
"place":"USA"
}
]
这里有趣的是,我们的应用程序正在 Heroku 上运行,它连接到 DigitalOcean 服务器上的 MySQL 数据库。我们甚至可以与 Heroku 应用程序一起配置数据库并连接到该数据库。在“更多...”部分查看如何操作。
更多...
- 向应用程序添加新的数据库附加组件:
$ heroku addons:create jawsdb:kitefin
这里,addons:create接受附加组件名称和服务计划名称,两者之间用冒号(:)分隔。您可以在elements.heroku.com/addons/jawsdb-maria了解更多关于附加组件和计划的详细信息。此外,所有附加组件详情页面末尾都提供了将附加组件添加到应用程序的 Heroku CLI 命令。
- 打开数据库仪表板以查看连接详情,例如 URL、用户名、密码和数据库名称:
$ heroku addons:open jawsdb
jawsdb仪表板看起来与以下所示类似:

- 您甚至可以从
JAWSDB_URL配置属性中获取 MySQL 连接字符串。您可以使用以下命令列出您应用的配置:
$ heroku config
=== rest-demo-on-cloud Config Vars
JAWSDB_URL: <URL>
- 复制连接详情,在 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');
- 在
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>
您可以从附加仪表板中找到连接相关详情。
-
更新
src/main/resources/application.properties文件,将spring.profiles.active属性的值替换为heroku -
将更改提交并推送到 Heroku 远程仓库:
$ git commit -am"using heroky mysql addon"
$ git push heroku master
- 部署成功后,运行
heroku open命令。然后,一旦页面在浏览器中加载,点击“人员”链接。这次,您将看到一组不同的数据,这是我们输入到我们的 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 等工具确实有助于配置,但设置应用时往往会遇到一些问题,这些问题可能是由于缺少或错误的配置引起的。这导致了另一个概念的出现,即容器化。
在虚拟化的世界中,我们有宿主操作系统和虚拟化软件,即虚拟机管理程序。然后我们最终创建了多个机器,每个机器都有自己的操作系统,应用程序就在这些操作系统上运行。然而,在容器化中,我们不分割服务器的资源。相反,我们有一个带有宿主操作系统的服务器,在其之上,我们有一个容器化层,这是一个软件抽象层。我们将应用程序打包成容器,其中容器只包含运行应用程序所需的足够操作系统功能、应用程序的软件依赖以及应用程序本身。以下图像来自 docs.docker.com/get-started/#container-diagram,最能描绘这一点:

前面的图像展示了虚拟化系统的典型架构。下面的图像展示了容器化系统的典型架构:

容器化的最大优势在于,你可以将应用程序的所有依赖项打包到一个容器镜像中。然后,这个镜像在容器化平台上运行,从而创建一个容器。我们可以在服务器上同时运行多个容器。如果需要添加更多实例,我们只需部署镜像,并且这种部署可以自动化,以支持简单易行的高可扩展性。
Docker 是全球最受欢迎的软件容器化平台。在本教程中,我们将把位于指定位置的示例应用程序打包成 Docker 镜像,并运行 Docker 镜像来启动我们的应用程序。
准备工作
对于本教程,我们将使用运行 Ubuntu 16.04.2 x64 的 Linux 服务器。
- 从
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
- 使用
dpkg软件包管理器安装 Docker 软件包:
$ sudo dpkg -i docker-ce_17.03.2~ce-0~ubuntu-xenial_amd64.deb
软件包的名称将根据你下载的版本而有所不同。
- 安装成功后,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)
要容器化的应用程序位于源代码下载的 chp10/6_boot_with_docker 位置。
如何操作...
- 在应用程序的根目录下创建
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" ]
- 运行以下命令,使用前面步骤中创建的
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
- 你可以使用命令查看已安装的镜像:
$ 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 镜像。这些是为了构建我们的应用程序镜像而下载的,并且列在第一位。
- 现在,我们需要运行镜像以创建一个包含运行应用程序的容器的容器:
docker run -p 8090:8080 -d --name restapp restapp-image
d521b9927cec105d8b69995ef6d917121931c1d1f0b1f4398594bd1f1fcbee55
在 run 命令之后打印出的长字符串是容器的标识符。您可以使用前几个字符来唯一标识容器。或者,您可以使用容器名称,restapp。
- 应用程序已经启动。您可以通过运行以下命令来查看日志:
docker logs restapp
- 您可以使用以下命令查看创建的 Docker 容器:
docker ps
上述命令的输出类似于以下所示:

- 您可以使用以下命令管理容器:
$ docker stop restapp
$ docker start restapp
一旦应用程序运行,打开 http://<hostname>:8090/api/persons。
它是如何工作的...
您通过定义 Dockerfile 来定义容器结构和其内容。Dockerfile 遵循一种结构,其中每一行都是 INSTRUCTION arguments 的形式。存在一组预定义的指令,包括 FROM、RUN、CMD、LABEL、ENV、ADD、COPY 以及其他指令。完整的列表可以在 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 操作系统镜像作为基础镜像,然后与 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 数据库、Couchbase、Ubuntu 以及其他操作系统的镜像。您可以在store.docker.com/上探索这些包。
第十一章:网络通信
在本章中,我们将介绍以下菜谱:
-
发送 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 正在 JEP 110 (openjdk.java.net/jeps/110) 下引入。不幸的是,这个 API 正在被引入为一个孵化模块 (openjdk.java.net/jeps/11)。孵化模块包含非最终 API,这些 API 非常庞大,并且尚未完全成熟,不能包含在 Java SE 中。这是一种 API 的 beta 版本,以便开发者能够更早地使用这些 API。但这里的难点是,这些 API 在 JDK 的新版本中没有向后兼容性支持。这意味着依赖于孵化模块的代码可能会在新版本的 JDK 中中断。这可能是由于孵化模块被提升到 Java SE 或被无声地从孵化模块中删除。
无论如何,了解可能在未来 JDK 版本中出现的 HTTP 客户端 API 将是有益的。除此之外,了解我们现在拥有的替代方案也是有益的。因此,在本章中,我们将介绍一些菜谱,展示如何在 JDK 9 孵化模块中使用 HTTP 客户端 API,然后介绍一些其他 API,这些 API 使用了 Apache HttpClient (hc.apache.org/httpcomponents-client-ga/) API 和 Unirest Java HTTP 库 (unirest.io/java.html)。
发送 HTTP GET 请求
在这个菜谱中,我们将探讨使用 JDK 9 HTTP 客户端 API 向 URL 发送 GET 请求,httpbin.org/get。
如何做到...
- 使用
jdk.incubator.http.HttpClient.Builder构建器创建jdk.incubator.http.HttpClient的实例:
HttpClient client = HttpClient.newBuilder().build();
- 使用
jdk.incubator.http.HttpRequest.Builder构建器创建jdk.incubator.http.HttpRequest的实例。请求的 URL 应该提供为java.net.URI的实例:
HttpRequest request = HttpRequest
.newBuilder(new URI("http://httpbin.org/get"))
.GET()
.version(HttpClient.Version.HTTP_1_1)
.build();
- 使用
jdk.incubator.http.HttpClient的sendAPI 发送 HTTP 请求。此 API 接受jdk.incubator.http.HttpRequest的实例和jdk.incubator.http.HttpResponse.BodyHandler的实现:
HttpResponse<String> response = client.send(request,
HttpResponse.BodyHandler.asString());
- 打印
jdk.incubator.http.HttpResponse的状态码和响应体:
System.out.println("Status code: " + response.statusCode());
System.out.println("Response Body: " + response.body());
此代码的完整内容可以在位置 chp11/1_making_http_get 找到。你可以使用运行脚本 run.bat 或 run.sh 来编译和运行代码:

它是如何工作的...
向 URL 发送 HTTP 请求主要有两个步骤:
-
创建 HTTP 客户端以发起调用。
-
设置目标 URL、必需的 HTTP 头部和 HTTP 方法类型,即
GET、POST或PUT。
Java HTTP 客户端 API 提供了一个构建器类 jdk.incubator.http.HttpClient.Builder,它可以用来构建 jdk.incubator.http.HttpClient 的实例,同时利用构建器 API 来设置 jdk.incubator.http.HttpClient。以下代码片段展示了如何使用默认配置获取 jdk.incubator.http.HttpClient 的实例:
HttpClient client = HttpClient.newHttpClient();
以下代码片段使用构建器进行配置,然后创建 jdk.incubator.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 上下文,我们将在不同的菜谱中查看。
设置目标 URL 只是创建一个 jdk.incbator.http.HttpRequest 的实例,使用其构建器和 API 进行配置。以下代码片段展示了如何创建 jdk.incbator.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();
jdk.incubator.http.HttpClient 对象提供了两个 API 来进行 HTTP 调用:
-
使用
HttpClient#send()方法同步发送 -
使用
HttpClient#sendAsync()方法异步发送
send() 方法接收两个参数:HTTP 请求和 HTTP 响应的处理程序。响应的处理程序由 jdk.incubator.http.HttpResponse.BodyHandler 接口的实现表示。有几个可用的实现,例如 asString(),它将响应体读取为 String,asByteArray(),它将响应体读取为字节数组,等等。我们将使用 asString() 方法,它将响应 Body 返回为字符串:
HttpResponse<String> response = client.send(request,
HttpResponse.BodyHandler.asString());
jdk.incubator.http.HttpResponse 的实例表示来自 HTTP 服务器的响应。它提供了以下 API:
-
获取响应体(
body()) -
HTTP 头部(
headers()) -
初始 HTTP 请求(
request()) -
响应状态码(
statusCode()) -
请求使用的 URL(
uri())
传递给 send() 方法的 HttpResponse.BodyHandler 实现将 HTTP 响应转换为兼容格式,例如 String、byte 数组等。
发送 HTTP POST 请求
在本菜谱中,我们将通过请求体向 HTTP 服务发送一些数据。我们将数据发送到 URL:http://httpbin.org/post。
我们将跳过类的包前缀,因为它假定是 jdk.incubator.http。
如何操作...
- 使用其
HttpClient.Builder构建器创建HttpClient的实例:
HttpClient client = HttpClient.newBuilder().build();
- 创建要传递到请求体的所需数据:
Map<String, String> requestBody =
Map.of("key1", "value1", "key2", "value2");
- 创建一个
HttpRequest对象,请求方法为 POST,并通过提供请求体数据作为String。我们使用 Jackson 的ObjectMapper将请求体Map<String, String>转换为普通的 JSONString,然后使用HttpRequest.BodyProcessor处理String请求体:
ObjectMapper mapper = new ObjectMapper();
HttpRequest request = HttpRequest
.newBuilder(new URI("http://httpbin.org/post"))
.POST(
HttpRequest.BodyProcessor.fromString(
mapper.writeValueAsString(requestBody)
)
)
.version(HttpClient.Version.HTTP_1_1)
.build();
- 使用
send(HttpRequest, HttpRequest.BodyHandler)方法发送请求并获取响应:
HttpResponse<String> response = client.send(request,
HttpResponse.BodyHandler.asString());
- 然后我们打印出服务器发送的响应状态码和响应体:
System.out.println("Status code: " + response.statusCode());
System.out.println("Response Body: " + response.body());
这个完整的代码可以在chp11/2_making_http_post中找到。请确保在chp11/2_making_http_post/mods位置有以下的 Jackson JAR 文件:
-
jackson.databind.jar -
jackson.core.jar -
jackson.annotations.jar
还要注意模块定义文件module-info.java,它位于chp11/2_making_http_post/src/http.client.demo位置。
要了解如何在模块化代码中使用 Jackson JAR 文件,请参考第三章中关于自下而上的迁移和自上而下的迁移的食谱,模块化编程。
提供了run.bat和run.sh脚本来方便代码的编译和执行:

对受保护资源的 HTTP 请求
在这个示例中,我们将探讨如何调用一个受用户凭证保护的网络资源。URL,httpbin.org/basic-auth/user/passwd,已被 HTTP 基本认证保护。基本认证要求提供用户名和密码,并以明文形式提供,然后由 HTTP 资源用来判断用户认证是否成功。
如果你通过浏览器打开链接,httpbin.org/basic-auth/user/passwd,将会提示输入用户名和密码,如下面的截图所示:

使用用户名作为user和密码作为passwd,你将能够通过认证并显示 JSON 响应,如下所示:
{
"authenticated": true,
"user": "user"
}
让我们使用HttpClient API 实现同样的功能。
如何实现...
- 我们需要扩展
java.net.Authenticator类并重写其getPasswordAuthentication()方法。该方法应该返回一个java.net.PasswordAuthentication实例。让我们创建一个类,UsernamePasswordAuthenticator,它扩展了java.net.Authenticator:
public class UsernamePasswordAuthenticator
extends Authenticator{
}
- 我们将在
UsernamePasswordAuthenticator类中创建两个实例变量来存储用户名和密码,并提供一个构造函数来初始化它们:
private String username;
private String password;
public UsernamePasswordAuthenticator(){}
public UsernamePasswordAuthenticator ( String username,
String password){
this.username = username;
this.password = password;
}
- 然后我们重写
getPasswordAuthentication()方法来返回一个初始化了用户名和密码的java.net.PasswordAuthentication实例:
@Override
protected PasswordAuthentication getPasswordAuthentication(){
return new PasswordAuthentication(username,
password.toCharArray());
}
- 然后我们创建一个
UsernamePasswordAuthenticator实例:
String username = "user";
String password = "passwd";
UsernamePasswordAuthenticator authenticator =
new UsernamePasswordAuthenticator(username, password);
- 我们在初始化
HttpClient时提供UsernamePasswordAuthenticator的实例:
HttpClient client = HttpClient.newBuilder()
.authenticator(authenticator)
.build();
- 创建一个对应的
HttpRequest对象来调用受保护的 HTTP 资源,httpbin.org/basic-auth/user/passwd:
HttpRequest request = HttpRequest.newBuilder(new URI(
"http://httpbin.org/basic-auth/user/passwd"
))
.GET()
.version(HttpClient.Version.HTTP_1_1)
.build();
- 我们通过执行请求来获取
HttpResponse,并打印状态码和请求体:
HttpResponse<String> response = client.send(request,
HttpResponse.BodyHandler.asString());
System.out.println("Status code: " + response.statusCode());
System.out.println("Response Body: " + response.body());
完整的代码位于位置,chp11/3_making_http_request_protected_res。您可以通过使用运行脚本run.bat或run.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.Builder API。HttpClient 实例在调用受保护的 HTTP 请求时使用此认证器来获取用户名和密码。
进行异步 HTTP 请求
在这个菜谱中,我们将探讨如何进行异步 GET 请求。在异步请求中,我们不等待响应;相反,我们处理客户端接收到的响应。在 jQuery 中,我们将进行异步请求并提供一个回调来处理响应,而在 Java 的情况下,我们获取java.util.concurrent.CompletableFuture的实例,然后调用thenApply方法来处理响应。让我们看看实际操作。
如何实现...
- 使用其构建器
HttpClient.Builder创建HttpClient的实例:
HttpClient client = HttpClient.newBuilder().build();
- 使用其
HttpRequest.Builder构建器创建HttpRequest的实例,表示 URL 和要使用的相应 HTTP 方法:
HttpRequest request = HttpRequest
.newBuilder(new URI("http://httpbin.org/get"))
.GET()
.version(HttpClient.Version.HTTP_1_1)
.build();
- 使用
sendAsync方法进行异步 HTTP 请求,并保留所获得的CompletableFuture<HttpResponse<String>>对象的引用。我们将使用它来处理响应:
CompletableFuture<HttpResponse<String>> responseFuture =
client.sendAsync(request,
HttpResponse.BodyHandler.asString());
- 我们提供
CompletionStage以便在先前的阶段完成后处理响应。为此,我们使用thenAccept方法,该方法接受一个 lambda 表达式:
CompletableFuture<Void> processedFuture =
responseFuture.thenAccept(response -> {
System.out.println("Status code: " + response.statusCode());
System.out.println("Response Body: " + response.body());
});
- 最后,我们等待 future 完成:
CompletableFuture.allOf(processedFuture).join();
完整的代码可以在位置chp11/4_async_http_request找到。我们提供了run.bat和run.sh脚本以编译和运行菜谱:

使用 Apache HttpClient 进行 HTTP 请求
在这个菜谱中,我们将使用 Apache HttpClient (hc.apache.org/httpcomponents-client-4.5.x/index.html)库来发送简单的 HTTP GET 请求。由于我们使用的是 Java 9,我们希望使用模块路径而不是类路径。因此,我们需要模块化 Apache HttpClient 库。实现这一目标的一种方法是通过使用自动模块的概念。让我们看看如何在准备就绪部分设置菜谱的依赖关系。
准备就绪
所需的所有 JAR 文件都已经存在于chp11\5_apache_http_demo\mods位置:

当这些 JAR 文件在模块路径上时,我们可以在module-info.java中声明对这些 JAR 文件的依赖,该文件位于chp11\5_apache_http_demo\src\http.client.demo位置,如下代码片段所示:
module http.client.demo{
requires httpclient;
requires httpcore;
requires commons.logging;
requires commons.codec;
}
如何做到这一点...
- 使用其
org.apache.http.impl.client.HttpClients工厂创建org.http.client.HttpClient的默认实例:
CloseableHttpClient client = HttpClients.createDefault();
- 创建一个
org.apache.http.client.methods.HttpGet实例以及所需的 URL。这代表 HTTP 方法类型和请求的 URL:
HttpGet request = new HttpGet("http://httpbin.org/get");
- 使用
HttpClient实例执行 HTTP 请求以获取CloseableHttpResponse实例:
CloseableHttpResponse response = client.execute(request);
执行 HTTP 请求后返回的CloseableHttpResponse实例可以用来获取响应状态码和其他嵌入在HttpEntity实例中的响应内容。
- 我们使用
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);
这个菜谱的完整代码可以在chp11\5_apache_http_demo位置找到。我们提供了run.bat和run.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;
});
在这种情况下,响应由响应处理器处理,并返回给我们响应体字符串。完整的代码可以在chp11\5_1_apache_http_demo_response_handler位置找到。
使用 Unirest HTTP 客户端库发送 HTTP 请求
在这个菜谱中,我们将使用 Unirest HTTP (unirest.io/java.html) Java 库来访问 HTTP 服务。Unirest Java 是基于 Apache 的 HTTP 客户端库的库,并提供了一个用于发送 HTTP 请求的流畅 API。
准备就绪
由于 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 文件下载到了文件夹中,chp11/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();
代码可以在以下位置找到,chp11\6_unirest_http_demo。
我们提供了 run.bat 和 run.sh 脚本来执行代码。
还有更多...
Unirest Java 库提供了许多更高级的功能,例如异步请求、文件上传和使用代理等。建议您尝试这些库的不同功能。
第十二章:内存管理和调试
本章讨论了管理 Java 应用程序的内存。理解垃圾收集过程对于开发内存效率高的应用程序至关重要。我们将向您介绍 Java 9 中使用的垃圾收集算法。然后,我们将介绍 Java 9 的一些新特性,这些特性有助于高级应用程序诊断。我们还将向您展示如何使用新的“使用资源”构造来管理资源。稍后,我们将向您展示 Java 9 中引入的新堆栈跟踪 API。以下内容将涵盖:
-
理解 G1 垃圾收集器
-
JVM 的统一日志记录
-
使用 JVM 的新诊断命令
-
尝试使用资源以更好地处理资源
-
改进调试的堆栈跟踪
-
一些提高内存使用效率的最佳实践
简介
内存管理是内存分配(用于程序执行)和内存重用(在分配的某些内存不再使用后)的过程。在 Java 中,这个过程是自动发生的,称为垃圾收集(GC)。GC 的有效性影响两个主要的应用程序特性——响应性和吞吐量。
响应性是通过应用程序对请求的响应速度(提供必要的数据)来衡量的。例如,网站返回页面或桌面应用程序对事件的响应速度有多快。自然地,响应时间越低,用户体验就越好,这是许多应用程序设计和实现的目标。
吞吐量表示应用程序在单位时间内可以完成的工作量。例如,一个 Web 应用程序可以服务多少请求或数据库可以支持多少事务。数字越大,应用程序可能产生的价值就越大,它可以容纳的用户数量也越多。
并非每个应用程序都需要具有尽可能小的响应性和最大的吞吐量。一个应用程序可能是一个异步提交并执行其他任务的程序,这不需要太多的用户交互。也可能只有少数潜在的应用程序用户,因此低于平均的吞吐量可能已经足够。然而,有些应用程序对这两个特征中的一个或两个都有很高的要求,并且不能容忍 GC 过程强加的长时间暂停。
GC,另一方面,需要停止任何应用程序的执行以重新评估内存使用情况,并从不再使用的旧数据中释放它。这样的 GC 活动期间被称为“停止世界”。它们越长,GC 完成其工作的速度就越快,应用程序冻结的时间就越长,最终可能会足够大,以至于影响应用程序的响应性和吞吐量。如果出现这种情况,GC 调整和 JVM 优化就变得很重要,并且需要理解 GC 原则及其现代实现。
不幸的是,这一步常常被忽略。为了提高响应速度和/或吞吐量,公司和个人只是增加内存和其他计算能力,从而为原本微小的现有问题提供了成长的空间。除了硬件和软件成本外,扩大后的基础设施还需要更多的人来维护,最终证明建立一个专门致力于维护系统的全新组织是合理的。到那时,问题已经达到几乎无法解决的规模,并依赖于那些创造了它的人,迫使他们在其余的专业生涯中做那些例行——几乎是琐事——的工作。
在本章中,我们将重点关注即将成为 Java 9 默认垃圾回收器的Garbage-First(G1)垃圾回收器。然而,我们也会参考一些其他可用的 GC 实现,以便对比和解释一些使 G1 得以实现的设计决策。此外,它们可能比 G1 更适合某些系统。
内存组织和管理工作是 JVM 开发中非常专业和复杂的领域。本书的目的不是处理此类级别的实现细节。我们的重点是那些可以帮助应用程序开发者通过设置 JVM 运行时的相应参数来调整以适应应用程序需求的 GC 方面。
GC 使用两个内存区域,堆和栈,这说明了任何 GC 实现的主要原理。第一个区域由 JVM 用于分配内存并存储由程序创建的对象。当一个对象使用new关键字创建时,它被分配在堆上,而对其的引用则存储在栈上。栈还存储原始变量和当前方法或线程使用的堆对象的引用。栈以后进先出(LIFO)的方式操作。栈比堆小得多,只有 GC 读取它。
这里有一个稍微简单化,但对我们目的来说足够好的,对任何 GC 主要活动的概述:遍历堆中的对象,并移除那些在栈中没有任何引用的对象。
理解 G1 垃圾回收器
之前的 GC 实现包括Serial GC、Parallel GC和Concurrent Mark-Sweep(CMS)收集器。它们将堆分为三个部分:年轻代、老年代或持久代,以及用于存放大小为标准区域 50%或更大的对象的巨大区域。年轻代包含大多数新创建的对象;这是最活跃的区域,因为大多数对象都是短命的,很快(随着它们的年龄增长)就会成为收集的候选对象。术语年龄指的是对象存活过的收集周期数。年轻代有三个收集周期:一个Eden 空间和两个存活空间,如存活 0(S0)和存活 1(S1)。对象会通过它们移动(根据它们的年龄和其他一些特征),直到最终被丢弃或放入老年代。
老年代包含年龄超过一定限度的对象。这个区域比年轻代大,因此这里的垃圾回收成本更高,发生的频率也没有年轻代那么高。
永久代包含描述应用程序中使用的类和方法的元数据。它还存储字符串、库类和方法。
当 JVM 启动时,堆是空的,然后对象会被推入 Eden。当它填满时,会启动一个次要 GC 过程。它会移除未引用的和循环引用的对象,并将其他对象移动到S0区域。
首先,任何新的对象都会分配到 Eden 空间。两个存活空间最初都是空的。当 Eden 空间填满时,会触发一个次要垃圾回收。引用的对象会被移动到 S0 空间。未引用的对象会被删除。
下一个次要的垃圾回收(GC)过程将引用的对象迁移到S1,并增加那些在之前的次要收集中存活下来的对象的年龄。在所有存活下来的对象(不同年龄)都移动到S1之后,S0 和 Eden 都变为空。
在下一个次要收集中,S0和S1会交换它们的作用。引用的对象从 Eden 移动到S1,从S1移动到S0。
在每个次要收集中,达到一定年龄的对象会被移动到老年代。正如我们之前提到的,老年代最终会被检查(在几个次要收集之后),未引用的对象会被从那里移除,并且内存会被碎片化。这种清理老年代的过程被认为是主要的收集。
永久代在不同的时间由不同的 GC 算法进行清理。
G1 GC 的做法有所不同。它将堆分成大小相等的区域,并为每个区域分配一个相同的角色--Eden、存活或老--但会根据需要动态地改变具有相同角色的区域数量。这使得内存清理过程和内存碎片化更加可预测。
准备工作
序列 GC 在同一周期内清理年轻代和旧代(序列化,因此得名)。在任务期间,它会停止世界。这就是为什么它用于具有一个 CPU 和几百 MB 堆栈大小的非服务器应用程序。
并行 GC 在所有可用核心上并行工作,尽管线程数可以配置。它也会停止世界,仅适用于可以容忍长时间冻结时间的应用程序。
CMS 收集器是为了解决这个非常问题——长时间暂停。它是通过不碎片化旧代和与应用程序执行并行进行一些分析(通常使用 25%的 CPU)来实现的。当旧代达到 68%满时(默认值,但此值可以配置)开始收集旧代。
G1 GC 算法与 CMS 收集器类似。首先,它并发地识别堆中所有引用的对象,并相应地标记它们。然后它首先收集最空旷的区域,从而释放大量空闲空间。这就是为什么它被称为垃圾优先。因为它使用许多小专用区域,它有更好的机会预测清理其中一个所需的时间,并适应用户定义的暂停时间(G1 偶尔可能会超过它,但大多数时候非常接近)。
G1 的主要受益者是那些需要大堆栈(6 GB 或更多)且不能容忍长时间暂停(0.5 秒或更少)的应用程序。如果一个应用程序遇到太多和/或太长的暂停问题,它可以从切换到 CMS 或并行 GC(尤其是旧代并行 GC)到 G1 GC 中受益。如果不是这种情况,切换到 G1 收集器不是使用 JDK 9 的必要条件。
G1 GC 从使用停止世界暂停进行驱逐(将年轻代内部的对象移动到旧代)的年轻代收集开始。当旧代的占用达到一定阈值时,它也会被收集。旧代中一些对象的收集是并行的,一些对象是使用停止世界暂停收集的。步骤包括以下内容:
-
使用停止世界暂停进行的幸存区域(根区域)的初始标记,这些区域可能引用旧代中的对象。
-
在应用程序继续运行的同时,扫描幸存区域以查找对旧代对象的引用,这是并行的。
-
在应用程序继续运行的同时,在整个堆上并发标记活对象。
-
备注步骤完成了使用停止世界暂停进行的活对象标记。
-
清理过程计算活对象的年龄,使用停止世界暂停释放区域,并将它们返回到空闲列表(并发)。
由于大多数对象是短命的,并且通过更频繁地扫描年轻代可以更容易地释放大量内存,因此前面的序列可能会与年轻代撤离混合。
在混合阶段,当 G1 收集年轻代和旧生代中已标记为大部分垃圾的区域,以及巨大分配阶段,当大对象被移动到或从巨大区域撤离时,也存在混合阶段。
在以下情况下会执行完全垃圾收集,使用停止世界暂停:
-
并发失败:如果在标记阶段旧生代已满,则发生这种情况。
-
提升失败:如果在混合阶段旧生代空间不足,则发生这种情况。
-
撤离失败:当收集器无法将对象提升到幸存空间以及旧生代时发生这种情况。
-
巨大分配:当应用程序尝试分配一个非常大的对象时发生这种情况。
如果调整得当,您的应用程序应该避免完全垃圾收集。
为了帮助进行垃圾收集器调优,JVM 文档描述了人体工程学——这个过程是指“通过 JVM 和垃圾收集器调优,例如基于行为的调优,来提高应用程序性能。JVM 为垃圾收集器、堆大小和运行时编译器提供了平台相关的默认选择。这些选择符合不同类型应用程序的需求,同时需要较少的命令行调整。此外,基于行为的调优会动态调整堆的大小以满足应用程序指定的行为”(来自 JVM 文档)。
如何做到这一点...
- 要了解 GC 的工作原理,编写以下程序:
package com.packt.cookbook.ch12_memory;
import java.util.ArrayList;
import java.util.List;
import java.util.stream.IntStream;
public class Chapter12Memory {
public static void main(String... args) {
int max = 99888999;
System.out.println("Chapter12Memory.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)来调整它,以匹配您计算机的配置。
- G1 GC 是 Java 9 的默认收集器,所以如果它对您的应用程序足够好,您就不需要设置任何东西。不过,您可以通过在命令行上提供
-XX:+UseG1GC来显式启用 G1(在包含com.packt.cookbook.ch12_memory.Chapter12Memory类和main()方法的可执行.jar文件所在的同一文件夹中运行):
java -XX:+UseG1GC -cp ./cookbook-1.0.jar
com.packt.cookbook.ch12_memory.Chapter12Memory
注意,我们假设您能够构建一个可执行的 .jar 文件并理解基本的 Java 执行命令。如果不能,请参阅 JVM 文档。
可以通过设置以下选项之一来使用其他可用的垃圾收集器:
-
-
使用
-XX:+UseSerialGC来使用串行收集器。 -
使用
-XX:+UseParallelGC来使用具有并行压缩的并行收集器(这使并行收集器能够并行执行主要收集)。如果没有并行压缩,主要收集将使用单个线程执行,这可能会显著限制可伸缩性。通过-XX:+UseParallelOldGC选项禁用并行压缩。 -
使用
-XX:+UseConcMarkSweepGC来使用 CMS 收集器。
-
- 要查看 GC 的日志消息,设置
-Xlog:gc。您还可以使用 Unix 实用程序time来测量完成任务所需的时间(实用程序发布输出中的最后三行,因此如果您不能或不想这样做,则不需要使用它):
time java -Xlog:gc -cp ./cookbook-1.0.jar
com.packt.cookbook.ch12_memory.Chapter12Memory
- 运行前面的命令,结果可能如下(实际值可能因您的计算机而异):

您可以看到 GC 走过了我们描述的大多数步骤。它从收集年轻代开始。然后,当List<AnObject> list对象(参见前面的代码)变得太大(超过年轻代区域的 50%)时,为其分配的内存位于巨大区域。您还可以看到初始标记步骤以及随后的重标记和其他之前描述的步骤。
每一行都以 JVM 运行的总时间(以秒为单位)开始,并以每个步骤花费的时间(以毫秒为单位)结束。在截图底部,我们看到由time实用程序打印的三个行:
-
-
real表示花费的墙钟时间——自命令运行以来经过的所有时间(应与 JVM 运行时间的第一列对齐) -
user表示进程在用户模式代码(内核外)中所有 CPU 花费的时间;它更大是因为 GC 与应用程序并发工作 -
sys表示 CPU 在进程内核中花费的时间 -
user+sys表示进程使用的 CPU 时间
-
- 设置
-XX:+PrintGCDetails选项(或只需在日志选项-Xlog:gc*中添加一个*)以查看 GC 活动的更多细节(在以下截图,我们只提供了与 GC 步骤 0 相关的日志开头):

现在日志中每个 GC 步骤都有十几个条目,并以记录每个步骤所花费的User、Sys和Real时间(由time实用程序累积的数量)结束。您可以通过添加更多短生命周期的对象(例如)来修改程序,并查看 GC 活动如何变化。
- 使用
-Xlog:gc*=debug选项(以下仅为片段)获取更多信息:

因此,选择您需要多少信息进行分析取决于您。
我们将在本章的下一个配方“JVM 的统一日志”中讨论日志格式和其它日志选项的更多细节。
它是如何工作的...
如我们之前提到的,G1 GC 使用默认的人体工程学值,这可能是大多数应用程序足够好的。以下是其中最重要的列表(<ergo>表示实际值根据环境以人体工程学方式确定):
-
-XX:MaxGCPauseMillis=200保存最大暂停时间的值 -
-XX:GCPauseTimeInterval=<ergo>保存 GC 步骤之间的最大暂停时间(默认未设置,允许 G1 在需要时连续执行垃圾回收) -
-XX:ParallelGCThreads=<ergo>保存在垃圾收集暂停期间用于并行工作的最大线程数(默认情况下,从可用的线程数派生;如果进程可用的 CPU 线程数小于或等于 8,则使用此数量;否则,将大于 8 的线程的八分之五添加到最终的线程数)。 -
-XX:ConcGCThreads=<ergo>保存用于并发工作的最大线程数(默认设置为-XX:ParallelGCThreads除以 4)。 -
-XX:+G1UseAdaptiveIHOP表示初始化堆占用应该是自适应的。 -
-XX:InitiatingHeapOccupancyPercent=45设置前几个收集周期;G1 将使用旧生代的 45% 占用率作为标记开始阈值。 -
-XX:G1HeapRegionSize=<ergo>保存基于初始和最大堆大小的堆区域大小(默认情况下,因为堆包含大约 2048 个堆区域,堆区域的大小可以从 1 MB 到 32 MB 不等,且必须是 2 的幂)。 -
-XX:G1NewSizePercent=5和-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 文档)。
一个可能的原因是,在日志中出现暂停全 GC(分配失败)时性能不佳。这通常发生在短时间内创建了太多对象(并且不能快速收集)或者许多大(巨大)对象不能及时分配的情况下。有几种推荐的处理此情况的方法:
-
在大量巨型对象过多的情况下,尝试通过增加区域大小,使用
-XX:G1HeapRegionSize选项(当前选定的堆区域大小在日志开头打印)来减少它们的数量。 -
增加堆的大小。
-
通过设置
-XX:ConcGCThreads来增加并发标记线程的数量。 -
促进标记开始得更早(利用 G1 基于早期应用程序行为做出决策的事实)。通过修改
-XX:G1ReservePercent来增加自适应 IHOP 计算中使用的缓冲区,或者通过使用-XX:-G1UseAdaptiveIHOP和-XX:InitiatingHeapOccupancyPercent手动设置来禁用自适应的 IHOP 计算。
仅在解决完全 GC 之后,才能开始调整 JVM 以获得更好的响应性和/或吞吐量。JVM 文档确定了以下响应性调整案例:
-
不寻常的系统或实时使用
-
引用处理时间过长
-
仅年轻代收集耗时过长
-
混合收集耗时过长
-
高更新 RS 和扫描 RS 时间
通过减少整体暂停时间和暂停频率,可以获得更好的吞吐量。请参考 JVM 文档以识别和获得缓解问题的建议。
参见
请参考本章中的以下食谱:
-
JVM 统一日志
-
使用 JVM 的新诊断命令
-
一些提高内存使用效率的最佳实践
JVM 统一日志
Java 9 实现了 JEP 158: Unified JVM Logging,该提案要求 为 JVM 的所有组件引入一个共同的日志系统。JVM 的主要组件包括以下内容:
-
类加载器
-
运行时数据区域
-
栈区域
-
方法区
-
堆区域
-
PC 寄存器
-
原生方法栈
-
-
执行引擎
-
解释器
-
JIT 编译器
-
垃圾收集
-
原生方法接口 JNI
-
原生方法库
-
所有这些组件的日志消息现在都可以通过统一日志捕获和分析,统一日志由 -Xlog 选项开启。
新的日志系统的主要特点如下:
-
使用日志级别:
trace、debug、info、warning、error -
消息标签,用于标识 JVM 组件、动作或特定感兴趣的消息
-
三种输出类型:
stdout、stderr和file -
对每行一条消息的限制执行
准备工作
要快速查看所有日志可能性,您可以运行以下命令:
java -Xlog:help
这里是输出:

如您所见,-Xlog 选项的格式定义如下:
-Xlog[:[what][:[output][:[decorators][:output-options]]]]
让我们详细解释这个选项:
-
what是标签和级别的组合,形式为tag1[+tag2...][*][=level][,...]。我们已经在使用-Xlog:gc*=debug选项中的gc标签时展示了此结构的用法。通配符 (*) 表示您想查看所有具有gc标签的消息(可能还有其他标签)。如果没有-Xlog:gc=debug通配符,则表示您只想查看由一个标签(在这种情况下为gc)标记的消息。如果仅使用-Xlog,则日志将显示所有info级别的消息。 -
output设置输出类型(默认为stdout)。 -
decorators指示了日志每行开头将放置的内容(在组件实际日志消息之前)。默认的装饰器包括uptime、level和tags,每个都包含在方括号内。 -
output_options可能包括filecount=file count和/或filesize=file size with optional K, M or G suffix。
总结一下,默认的日志配置如下:
-Xlog:all=info:stdout:uptime,level,tags
如何做到这一点...
让我们运行一些日志设置,如下所示:
- 运行以下命令:
java -Xlog:cpu -cp ./cookbook-1.0.jar
com.packt.cookbook.ch12_memory.Chapter12Memory
没有消息,因为 JVM 不会仅使用 cpu 标签记录消息。该标签与其他标签结合使用。
- 添加一个
*符号并再次运行命令:
java -Xlog:cpu* -cp ./cookbook-1.0.jar
com.packt.cookbook.ch12_memory.Chapter12Memory
结果将如下所示:

如您所见,标签 cpu 仅包含记录垃圾回收任务执行时间的消息。即使我们将日志级别设置为 trace 或 debug(例如 -Xlog:cpu*=debug),也不会显示其他消息。
- 现在运行带有
heap标签的命令:
java -Xlog:heap* -cp ./cookbook-1.0.jar
com.packt.cookbook.ch12_memory.Chapter12Memory
您将只会得到与堆相关的消息:

但让我们更仔细地看看第一行。它以三个装饰器开始--uptime、log 级别和 tags--然后是消息本身,该消息以收集周期号(在这种情况下为 0)和 Eden 区域数量从 24 降至 0(现在数量为 9)的信息开始。这是由于(正如我们在下一行中看到的那样)幸存区域的数量从 0 增长到 3,旧生代(第三行)的数量增长到 18,而巨大区域(23)的数量没有变化。这些都是第一个收集周期中的所有堆相关消息。然后,第二个收集周期开始。
- 再次添加
cpu标签并运行:
java -Xlog:heap*,cpu* -cp ./cookbook-1.0.jar
com.packt.cookbook.ch12_memory.Chapter12Memory
如您所见,cpu 消息显示了每个周期花费的时间:

-
尝试使用两个通过符号
+结合的标签(例如-Xlog:gc+heap)。它只会显示具有两个标签的消息(类似于二进制AND操作)。请注意,通配符不能与+符号一起使用(例如-Xlog:gc*+heap不起作用)。 -
您还可以选择输出类型和装饰器。实际上,装饰器级别似乎不太具有信息性,并且可以通过显式列出所需的装饰器来轻松省略。考虑以下示例:
java -Xlog:heap*,cpu*::uptime,tags -cp ./cookbook-1.0.jar
com.packt.cookbook.ch12_memory.Chapter12Memory
注意如何插入两个冒号(:)以保留输出类型的默认设置。我们也可以显式地显示它:
java -Xlog:heap*,cpu*:stdout:uptime,tags -cp ./cookbook-1.0.jar
com.packt.cookbook.ch12_memory.Chapter12Memory
要删除任何装饰,可以将它们设置为none:
java -Xlog:heap*,cpu*::none -cp ./cookbook-1.0.jar
com.packt.cookbook.ch12_memory.Chapter12Memory
新日志系统最有用的方面是标签选择。它允许更好地分析每个 JVM 组件及其子系统的内存演变,或者找到性能瓶颈,分析每个收集阶段所花费的时间——这两者对于 JVM 和应用调整都至关重要。
参见
请参阅本章的其他食谱:
-
使用 JVM 的新诊断命令
-
改进调试的堆栈跟踪
-
一些更好的内存使用最佳实践
使用 JVM 的新诊断命令
如果您打开 Java 安装的bin文件夹,您可以在其中找到相当多的命令行实用程序,这些实用程序可以用来诊断问题并监控使用Java 运行时环境(JRE)部署的应用程序。它们使用不同的机制来获取它们报告的数据。这些机制是针对虚拟机(VM)实现、操作系统和版本的。通常,只有工具集的一小部分适用于特定时间点的特定问题。
在这个食谱中,我们将关注 Java 9 中引入的新诊断命令,其中一些列在JEP 228:添加更多诊断命令中(实际的命令名称与 JEP 中的略有不同)。新诊断命令作为命令行实用程序jcmd的命令实现。您可以在 Java 安装的同一bin文件夹中找到此实用程序,并且可以通过在命令提示符中键入jcmd来调用它。
如果您输入了它,并且机器上当前没有 Java 进程正在运行,您将只得到一行,如下所示:
87863 jdk.jcmd/sun.tools.jcmd.JCmd
这表明当前只有一个 Java 进程正在运行(jcmd实用程序本身),并且它具有进程标识符(PID)87863(每次运行都会不同)。
让我们运行另一个 Java 程序,例如:
java -cp ./cookbook-1.0.jar com.packt.cookbook.ch12_memory.Chapter12Memory
jcmd的输出将显示(带有不同的 PID)以下内容:
87864 jdk.jcmd/sun.tools.jcmd.JCmd
87785 com.packt.cookbook.ch12_memory.Chapter12Memory
如您所见,如果没有选项输入,jcmd实用程序会报告所有当前运行的 Java 进程的 PID。在获取 PID 之后,您可以使用jcmd从运行该进程的 JVM 请求数据:
jcmd 88749 VM.version
或者,您可以通过引用应用程序的主类来避免使用 PID(并且在不带参数的情况下调用jcmd):
jcmd Chapter12Memory VM.version
您可以阅读 JVM 文档以获取有关jcmd实用程序及其使用方法的更多详细信息。在本食谱中,我们将仅关注 Java 9 附带的新诊断命令。
如何做到这一点...
- 通过运行以下行获取可用的
jcmd命令的完整列表:
jcmd PID/main-class-name help
将 PID/main-class 替换为进程标识符或主类名称。该列表特定于 JVM,因此命令请求从特定进程获取数据。
- 如果可能,请使用 JDK 8 和 JDK 9 编译相同的类,并为每个 JSK 版本运行前面的命令。这样,您可以比较列表并看到 JDK 9 引入了以下新的
jcmd命令:
-
-
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 飞行记录器 -
JVMTI.data_dump:这将打印 Java 虚拟机工具接口数据转储 -
JVMTI.agent_load:这将加载(附加)Java 虚拟机工具接口代理 -
ManagementAgent.status:这将打印远程 JMX 代理的状态 -
Thread.print:这将打印所有带有堆栈跟踪的线程 -
VM.log [option]:这允许在 JVM 启动后运行时设置 JVM 日志配置(我们已在先前的菜谱中描述),(可用性可以通过使用VM.log list查看) -
VM.info:这将打印统一的 JVM 信息(版本和配置),所有线程及其状态(无线程转储和堆转储)的列表,堆摘要,JVM 内部事件(GC、JIT、safepoint 等),带有已加载本地库的内存映射,VM 参数和环境变量,以及操作系统和硬件的详细信息 -
VM.dynlibs:这将打印有关动态库的信息 -
VM.set_flag:这允许设置 JVM 可写(也称为 可管理)标志(有关标志的列表,请参阅 JVM 文档) -
VM.stringtable和VM.symboltable:这些将打印所有 UTF-8 字符串常量 -
VM.class_hierarchy [full-class-name]:这将打印所有已加载的类或仅打印指定的类层次结构 -
VM.classloader_stats:这将打印有关类加载器的信息 -
VM.print_touched_methods:这将打印在运行时被接触的所有方法
-
如您所见,这些新命令属于几个组,分别由前缀编译器、垃圾收集器(GC)、Java 飞行记录器(JFR)、Java 虚拟机工具接口(JVMTI)、管理代理(与远程 JMX 代理相关)、线程和 VM 表示。在这本书中,我们没有足够的空间详细讲解每个命令。我们只会演示一些最实用的命令的使用方法。
它是如何工作的...
要获取jcmd工具的帮助,请运行以下命令:
jcmd -h
这里是命令的结果:

它告诉我们命令也可以从指定在-f之后的文件中读取,并且存在一个PerfCounter.print命令,该命令会打印出进程的所有性能计数器(统计信息)。
让我们运行以下命令:
jcmd Chapter12Memory GC.heap_info
输出可能看起来如下:

它显示了总堆大小以及使用了多少,年轻代区域的大小以及分配了多少个区域,以及Metaspace和class space的参数。
以下命令在寻找失控线程或想了解幕后发生的事情时非常有用:
jcmd Chapter12Memory Thread.print
这里是可能输出的一个片段:

以下命令可能是使用最频繁的,因为它提供了关于硬件、整个 JVM 进程及其组件当前状态的大量信息:
jcmd Chapter12Memory VM.info
它以一个摘要开始,如下所示:

然后是一般的过程描述:

然后是堆的详细信息(这仅仅是其中的一小部分):

然后它会打印编译事件、GC 堆历史、去优化事件、内部异常、事件、动态库、日志选项、环境变量、VM 参数以及运行进程的系统的许多参数。
jcmd命令为 JVM 进程提供了深入的了解,这有助于我们调试和调整进程以获得最佳性能和最优资源使用。
参见
请参考本章的其他食谱:
-
改进调试的堆栈跟踪
-
一些提高内存使用效率的最佳实践
尝试使用资源以更好地处理资源
管理资源很重要。以下是 JDK 7 文档中的一段摘录:
"典型的 Java 应用程序操作多种类型的资源,如文件、流、套接字和数据库连接。这些资源必须非常小心地处理,因为它们在操作过程中会获取系统资源。因此,你需要确保即使在出现错误的情况下它们也能被释放。实际上,不正确的资源管理是生产应用程序中常见的失败原因,常见的陷阱是数据库连接和文件描述符在代码的其他地方发生异常后仍然保持打开状态。这导致当资源耗尽时,应用程序服务器经常需要重新启动,因为操作系统和服务器应用程序通常对资源有一个上限限制。"
语句because they acquire system resources for their operations是关键。这意味着不当处理(未释放)资源可能会耗尽系统操作的能力。这就是为什么在 JDK 7 中引入了try-with-resources语句,并在第六章的数据库编程示例中使用了它:
try (Connection conn = getDbConnection()){
try (Statement st = createStatement(conn)) {
st.execute(sql);
}
} catch (Exception ex) {
ex.printStackTrace();
}
同一语句的另一种变体是将资源的获取都包含在同一个try块中:
try (Connection conn = getDbConnection();
Statement st = createStatement(conn)) {
st.execute(sql);
} catch (Exception ex) {
ex.printStackTrace();
}
作为提醒,我们使用了getDbConnection()和createStatement()方法。以下是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();
}
}
}
}
如您所见,其中大部分只是样板复制粘贴代码。
新的try-with-resources语句(随 Java 9 推出)通过允许将有效 final 变量用作try-with-resources语句中的资源来解决这个问题。
如何做...
- 使用新的try-with-resources语句重写前面的示例:
void execute(Statement st, String sql){
try (st) {
st.execute(sql);
} catch (Exception ex) {
ex.printStackTrace();
}
}
如您所见,它更加简洁和专注,无需反复编写关闭资源的简单代码。不再需要finally和额外的try...catch。
- 尝试编写它,以便在使用后立即关闭连接:
void execute(Connection conn, Statement st, String sql) {
try (conn; st) {
st.execute(sql);
} catch (Exception ex) {
ex.printStackTrace();
}
}
这可能或可能不适合你的应用程序连接处理,但通常,这种能力很有用。
- 尝试不同的组合,例如以下:
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 或有效 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.Thread和java.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()的变体,分别是getInstance(),它们的不同之处在于它们可以接受以下几种选项之一或没有任何选项:
-
StackWalker getInstance(): 这是配置为跳过所有隐藏帧和没有调用者类引用。 -
StackWalker getInstance(StackWalker.Option option): 这个方法创建了一个具有给定选项的实例,指定它可以访问的栈帧信息。 -
StackWalker getInstance(Set<StackWalker.Option> options): 这个方法创建了一个具有给定选项集的实例,指定它可以访问的栈帧信息。如果给定的集合为空,则实例配置与StackWalker getInstance()实例完全相同。 -
StackWalker getInstance(Set<StackWalker.Option> options, int estimatedDepth): 这个方法创建了一个与前面类似的实例,并接受estimatedDepth参数,该参数指定此实例将遍历的估计栈帧数,以便它可以估计可能需要的缓冲区大小。
以下值之一作为选项传递:
-
StackWalker.Option.RETAIN_CLASS_REFERENCE -
StackWalker.Option.SHOW_HIDDEN_FRAMES -
StackWalker.Option.SHOW_REFLECT_FRAMES
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。
如何实现...
创建几个相互调用的类和方法,以便您可以执行堆栈跟踪处理。
- 创建一个
Clazz01类:
public class Clazz01 {
public void method(){
new Clazz03().method("Do something");
new Clazz02().method();
}
}
- 创建一个
Clazz02类:
public class Clazz02 {
public void method(){
new Clazz03().method(null);
}
}
- 创建一个
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();
}
}
- 编写一个
demo4_StackWalk()方法:
private static void demo4_StackWalk(){
new Clazz01().method();
}
从Chapter12Memory类的main方法中调用此方法:
public class Chapter12Memory {
public static void main(String... args) {
demo4_StackWalk();
}
}
如果我们现在运行Chapter12Memory类,结果将如下所示:

消息Do something从Clazz01传递到Clazz03并打印出来。然后Clazz02将 null 传递给Clazz03,在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());
我们将得到以下结果:

参见
请参考本章的其他示例:
- 一些提高内存使用效率的最佳实践
一些提高内存使用效率的最佳实践
内存管理可能永远都不会成为你的问题,或者它可能成为你生活中挥之不去的永恒故事,亦或是介于两者之间的任何情况。对于大多数程序员来说,这很可能不是一个问题,尤其是在垃圾收集算法不断改进的情况下。G1 垃圾收集器(JVM 9 的默认设置)无疑是朝着正确方向迈出的一步。但你也可能会被叫去(或者自己注意到)应用程序性能下降的问题,那时你才会了解到自己应对挑战的准备程度。
这个方法试图帮助你避免这种情况,或者成功摆脱它。
它是如何工作的...
第一道防线是代码本身。在之前的示例中,我们已经讨论了在不再需要资源时立即释放资源以及使用StackWalker以减少内存使用的必要性。互联网上有许多建议,但它们可能不适用于你的应用程序。你将不得不监控内存消耗并测试你的设计决策,尤其是在你的代码处理大量数据之前,决定在哪里集中注意力。
例如,如果集合很小(不同的集合使用更多或更少的内存),那么集合的选择可能无关紧要。然而,程序员通常使用相同的编码模式,人们可以通过他们的风格来识别代码的作者。这就是为什么在长期来看,找出最有效的结构并经常使用它们是有回报的。然而,尽量让你的代码易于理解;可读性是代码质量的重要方面。
这里有一些关于内存感知编码风格的流行建议:
-
使用延迟初始化并在使用前创建对象,特别是如果这种需求根本不可能实现的话
-
使用
StringBuilder而不是+运算符 -
如果适合你的需求,在使用
HashSet之前使用ArrayList(内存使用量按顺序从ArrayList增加到LinkedList、HashTable、HashMap和HashSet) -
如果无法避免,请避免使用正则表达式并缓存
Pattern引用 -
优先使用原始类型而不是类包装器(使用自动装箱)
-
不要忘记清理缓存并删除不必要的条目
-
注意循环内创建的对象
一旦代码开始执行其预期功能,就立即测试和评估你的代码。你可能需要更改设计或实现的一些细节。这也会影响你未来的决策。任何环境都有许多分析器和诊断工具可用。我们在本章的使用 JVM 的新诊断命令配方中描述了其中之一(jcmd)。
了解你的垃圾回收器是如何工作的(参见配方,理解 G1 垃圾回收器)并且不要忘记使用 JVM 日志记录(在配方JVM 的统一日志记录中描述)。
之后,你可能需要调整 JVM 和垃圾回收器。以下是一些最常用的 JVM 参数(默认情况下,大小以字节为单位指定,但你可以附加字母 k 或 K 来表示千字节,m 或 M 来表示兆字节,g 或 G 来表示吉字节):
-
-Xms size:这设置了堆的初始大小,它必须是 1024 的倍数且大于 1 MB。 -
-Xmx size:这设置了堆的最大大小,它必须是 1024 的倍数且大于 2 MB。默认值是在运行时根据系统配置选择的。对于服务器部署,-Xms size和-Xmx size通常设置为相同的值。实际的内存使用量可能超过你通过-Xmx size设置的量,因为它仅限制 Java 堆大小,而 JVM 还为其他目的分配内存,包括每个线程的栈。 -
-Xmn size:这设置了年轻代(幼崽代)的堆的初始和最大大小。如果年轻代的大小太小,则将执行大量的垃圾回收。如果大小太大,则只执行完全垃圾回收,这可能需要很长时间才能完成。Oracle 建议将年轻代的大小保持在整体堆大小的 25% 到 50% 之间。此参数等同于-XX:NewSize=size。为了有效地进行垃圾回收,-Xmn size应低于-Xmx size。 -
-XX:MaxNewSize=size:这设置了年轻代(幼崽代)的堆的最大大小。默认值是按人体工程学设置的。Oracle 建议在总可用内存之后,第二个最有影响力的因素是保留给年轻代的堆比例。默认情况下,年轻代的最低大小为 1310 MB,最大大小为无限。 -
-XX:NewRatio=ratio: 这设置年轻代和老年代大小之间的比率;默认设置为 2。 -
-Xss size: 这设置线程栈大小,默认值取决于平台:-
Linux/ARM (32-bit): 320 KB
-
Linux/ARM (64-bit): 1024 KB
-
Linux/x64 (64-bit): 1024 KB
-
OS X (64-bit): 1024 KB
-
Oracle Solaris/i386 (32-bit): 320 KB
-
Oracle Solaris/x64 (64-bit): 1024 KB
-
Windows: 默认值取决于虚拟内存
-
每个线程都有一个栈,因此栈大小将限制 JVM 可以拥有的线程数。如果栈大小太小,可能会得到 java.lang.StackOverflowError 异常。然而,将栈大小设置得太大也可能耗尽内存,因为每个线程将分配比它需要的更多内存。
-XX:MaxMetaspaceSize=size: 这设置分配给类元数据的内存上限,默认情况下不受限制。
内存泄漏的明显迹象是旧代增长导致全 GC 运行得更频繁。为了调查,您可以使用将堆内存转储到文件的 JVM 参数:
-
-XX:+HeapDumpOnOutOfMemoryError: 当抛出java.lang.OutOfMemoryError异常时,将 Java 堆保存到当前目录的文件中。您可以使用-XX:HeapDumpPath=path选项显式设置堆转储文件的路径和名称。默认情况下,此选项禁用,当抛出OutOfMemoryError异常时不会转储堆。 -
-XX:HeapDumpPath=path: 这设置写入堆转储的路径和文件名,该转储由堆分析器(hprof)提供,当设置-XX:+HeapDumpOnOutOfMemoryError参数时。默认情况下,文件在当前工作目录中创建,命名为java_pidpid.hprof,其中pid是导致错误的进程标识符。 -
-XX:OnOutOfMemoryError="< cmd args >;< cmd args >": 这设置在首次抛出OutOfMemoryError异常时运行的自定义命令或一系列分号分隔的命令。如果字符串包含空格,则必须用引号括起来。有关命令字符串的示例,请参阅-XX:OnError参数的描述。 -
-XX:+UseGCOverheadLimit: 这启用了一种策略,限制 JVM 在抛出OutOfMemoryError异常之前在 GC 上花费的时间比例。此选项默认启用,如果超过 98% 的总时间用于垃圾收集且少于 2% 的堆被回收,并行 GC 将抛出OutOfMemoryError异常。当堆较小时,此功能可以用来防止应用程序长时间运行而几乎没有进展。要禁用此选项,请指定-XX:-UseGCOverheadLimit。
参见
请参阅本章的其他食谱:
-
理解 G1 垃圾收集器
-
JVM 的统一日志记录
-
使用 JVM 的新诊断命令
-
尝试使用资源以更好地处理资源
-
改进调试的堆栈跟踪
第十三章:使用 JShell 的 Read-Evaluate-Print Loop (REPL)
在本章中,我们将涵盖以下食谱:
-
熟悉 REPL
-
导航 JShell 及其命令
-
评估代码片段
-
JShell 中的面向对象编程
-
保存和恢复 JShell 命令历史
-
使用 JShell Java API
简介
REPL 代表 Read-Evaluate-Print Loop,正如其名,它读取命令行上输入的命令,评估它,打印评估结果,并在任何输入的命令上继续此过程。
所有主要的语言,如 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 代码。
在本章中,我们将探索 JShell 的特性,并编写一些真正令人惊叹的代码,以欣赏 REPL 的力量。我们还将看到如何使用 JShell Java API 创建我们自己的 REPL。
熟悉 REPL
在这个食谱中,我们将查看一些基本操作,帮助我们熟悉 JShell 工具。
准备工作
确保你已经安装了最新的 JDK 9 版本,其中包含 JShell。
如何操作...
-
到目前为止,你应该已经将
%JAVA_HOME%/bin(在 Windows 上)或$JAVA_HOME/bin(在 Linux 上)添加到你的PATH变量中。如果没有,请访问第一章中的食谱,在 Windows 上安装 JDK 9 并设置 PATH 变量 和 在 Linux (Ubuntu, x64) 上安装 JDK 9 并配置 PATH 变量。 -
在你的命令提示符中,输入
jshell并按回车键。 -
你将看到一个消息,然后是一个
jshell>提示符:

- 前缀斜杠(
/),后跟 JShell 支持的命令,帮助您与 JShell 交互。就像我们尝试/help intro来获取以下内容:

- 让我们打印一个
Hello World消息:

- 让我们打印一个定制的
Hello World消息:

- 您可以使用上箭头键和下箭头键在已执行的命令之间导航。
它是如何工作的...
在 jshell 提示符中输入的代码片段被包裹在仅足够执行它们的代码中。因此,变量、方法和类声明被包裹在一个类中,表达式被包裹在一个方法中,而这个方法又反过来被包裹在类中。其他如导入和类定义等事物保持原样,因为它们是顶级实体,即不需要在另一个类中包裹类定义,因为类定义是一个顶级实体,可以独立存在。同样,在 Java 中,导入语句可以独立存在,并且它们出现在类声明之外,因此不需要在类中包裹。
在随后的菜谱中,我们将看到如何定义方法、导入额外的包、定义类等等。
在前面的菜谱中,我们看到了 $1 ==> "Hello World"。如果我们有一些没有与任何变量关联的值,那么 jshell 会给它一个变量名,例如 $1、$2 等等。
在 JShell 中导航及其命令
为了利用一个工具,我们需要熟悉如何使用它、它提供的命令以及我们可以使用的各种快捷键,以便提高生产力。在这个菜谱中,我们将查看我们可以通过不同的方式在 JShell 中导航,以及它提供的不同键盘快捷键,以便在使用时提高生产力。
如何做...
-
通过在命令提示符中输入
jshell来启动 JShell,您将看到一个包含开始指令的欢迎消息。 -
输入
/help intro获取 JShell 的简要介绍:

- 输入
/help获取支持的命令列表:

- 要获取有关命令的更多信息,请输入
/help <command>。例如,要获取有关/edit的信息,请输入/help /edit:

- JShell 中有自动完成支持。这使得 Java 开发者感到宾至如归。您可以使用 Tab 键调用自动完成:

-
您可以使用
/!来执行之前执行过的命令,以及使用/line_number在指定行号重新执行表达式。 -
要通过命令行导航光标,请使用 Ctrl + A 来到达行的开头,以及 Ctrl + E 来到达行的末尾。
评估代码片段
在这个菜谱中,我们将查看执行以下代码片段:
-
导入语句
-
类声明
-
接口声明
-
方法声明
-
字段声明
-
语句
如何操作...
-
打开命令提示符并启动 JShell。
-
默认情况下,JShell 导入了一些库。我们可以通过发出
/imports命令来检查这一点:

-
让我们通过发出
import java.text.SimpleDateFormat命令来导入java.text.SimpleDateForm。这将导入SimpleDateFormat类。 -
让我们声明一个
Employee类。我们将每行发出一个语句,使其成为一个不完整的语句,然后以与任何普通编辑器相同的方式进行操作。以下插图将阐明这一点:
class Employee{
private String empId;
public String getEmpId() {
return empId;
}
public void setEmpId ( String empId ) {
this.empId = empId;
}
}

- 让我们声明一个
Employability接口,该接口定义了一个方法employable(),如下面的代码片段所示:
interface Employability {
public boolean employable();
}
通过jshell创建的上述接口如下所示:

- 让我们声明一个
newEmployee(String empId)方法,该方法使用给定的empId构建一个Employee对象:
public Employee newEmployee(String empId ) {
Employee emp = new Employee();
emp.setEmpId(empId);
return emp;
}
以下是在 JShell 中定义的方法:

- 我们将使用上一步中定义的方法来创建一个声明
Employee变量的语句:
Employee e = newEmployee("1234");
上述语句及其在 JShell 中执行时的输出如下所示。e.get + Tab键生成由 IDEs 支持的自动完成。

还有更多...
我们可以调用一个未定义的方法。请看以下示例:
public void newMethod(){
System.out.println("New Method");
undefinedMethod();
}

然而,在定义被使用的方法之前,不能调用该方法:
public void undefinedMethod(){
System.out.println("Now defined");
}

我们只能在定义了undefinedMethod()之后调用newMethod()。
JShell 中的面向对象编程
在这个食谱中,我们将使用预定义的 Java 类定义文件并将它们导入 JShell。然后,我们将在 JShell 中玩转这些类。
如何操作...
-
我们将在本食谱中使用位于代码下载中
chp13/4_oo_programming位置的类定义文件。 -
有三个类定义文件:
Engine.java、Dimensions.java和Car.java。 -
导航到包含这三个类定义文件的目录。
-
/open命令允许从文件中加载代码。 -
我们将加载
Engine类的定义并创建一个Engine对象。

- 接下来,我们将加载
Dimensions类的定义并创建一个Dimensions对象:

- 最后,我们将加载
Car类的定义并创建一个Car对象:

保存和恢复 JShell 命令历史
我们将尝试在jshell中运行一些代码片段,作为向初学者解释 Java 编程的手段。此外,记录执行过的代码片段对于学习语言的人来说将是有用的。
在这个菜谱中,我们将执行一些代码片段并将它们保存到文件中。然后我们将从保存的文件中加载代码片段。
如何做到这一点...
- 让我们执行一系列代码片段,如下所示:
"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 );
}

-
使用
/save history命令将执行过的代码片段保存到名为history的文件中。 -
使用
/exit退出 shell,然后根据操作系统使用dir或ls列出目录中的文件。列表中会有一个history文件。 -
打开
jshell并使用/list检查执行过的代码片段的历史记录。您将看到没有执行过的代码片段。 -
使用
/open history加载history文件,然后使用/list检查执行过的代码片段的历史记录。您将看到所有之前执行并添加到历史记录中的代码片段:

使用 JShell Java API
JDK 9 提供了用于创建工具(如用于评估 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运行的传统方式。
如何做到这一点...
- 我们的这个模块将依赖于
jdk.jshell模块。因此,模块定义将如下所示:
module jshell{
requires jdk.jshell;
}
- 让我们使用
jdk.jshell.JShell类的create()方法或jdk.jshell.JShell.Builder中的构建器 API 来创建一个实例:
JShell myShell = JShell.create();
- 让我们使用
java.util.Scanner从System.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
}
}
- 我们将使用
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());
});
- 评估完成后,我们将使用
jdk.jshell.JShell.snippets()方法打印处理过的代码片段,该方法将返回处理过的Snippet的Stream。
System.out.println("Snippets processed: ");
myShell.snippets().forEach(s -> {
String msg = String.format("%s -> %s", s.kind(), s.source());
System.out.println(msg);
});
- 同样,我们可以按如下方式打印活动方法和变量:
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()));
- 在应用程序退出之前,我们通过调用其
close()方法来关闭JShell实例:
myShell.close();
此菜谱的代码可以在以下位置找到,chp13/6_jshell_api。您可以通过使用同一目录中可用的run.bat或run.sh脚本运行示例。示例执行和输出如下所示:

它是如何工作的...
API 中的核心类是jdk.jshell.JShell类。这个类是评估状态引擎,其状态在每次评估片段时都会被修改。正如我们之前看到的,片段是通过eval(String snippet)方法进行评估的。我们甚至可以使用drop(Snippet snippet)方法丢弃之前评估的片段。这两个方法都会导致jdk.jshell.JShell维护的内部状态发生变化。
传递给JShell评估引擎的代码片段被分类如下:
-
错误: 语法不正确的输入
-
表达式: 可能或可能不产生某些输出的输入
-
导入: 一个导入语句
-
方法: 方法声明
-
语句: 一个语句
-
类型声明: 一个类型,即类/接口声明
-
变量声明: 一个变量声明
所有这些类别都被捕获在jdk.jshell.Snippet.Kind枚举中。
我们还看到了不同的 API 来获取评估的片段、创建的方法、变量声明和其他特定片段类型的执行。每种片段类型都由扩展jdk.jshell.Snippet类的类支持。
第十四章:使用 Oracle Nashorn 进行脚本编写
在本章中,我们将涵盖以下菜谱:
-
使用 jjs 命令行工具
-
嵌入 Oracle Nashorn 引擎
-
从 Oracle Nashorn 调用 Java
-
使用 Oracle Nashorn 实现的 ECMAScript 6 功能
简介
Oracle Nashorn 是为 Java 平台开发的 JavaScript 引擎。这是在 Java 8 中引入的。在 Nashorn 之前,Java 平台的 JavaScript 引擎基于 Mozilla Rhino JavaScript 引擎。Oracle Nashorn 引擎利用 Java 8 中引入的 invokedynamic 支持以获得更好的运行时性能,并且也提供了对 ECMAScript 规范的更好遵守。
Oracle Nashorn 支持使用 jjs 工具以独立模式执行 JavaScript 代码,以及使用其嵌入的脚本引擎在 Java 中嵌入。在本章中,我们将探讨从 Java 执行 JavaScript 代码以及从 Java 调用 JavaScript 函数,反之亦然,包括从 JavaScript 访问 Java 类型。我们还将探讨使用命令行工具 jjs 执行 JavaScript 代码。
在本章的其余部分,我们将使用术语 ES6 来指代 ECMAScript 6。
使用 jjs 命令行工具
jjs 命令行工具支持执行 JavaScript 代码文件以及交互式执行 JavaScript 代码片段,如其他 JavaScript shell(如 node.js)所支持的那样。它使用 Oracle Nashorn,这是一个为 JVM 提供支持的下一代 JavaScript 引擎。除了 JavaScript 代码外,jjs 还支持执行 shell 命令,从而允许我们使用 JavaScript 创建 shell 脚本实用程序。
在这个菜谱中,我们将探讨通过 jjs 执行 JavaScript 代码文件,以及代码片段的交互式执行。
准备工作
首先,通过执行命令 jjs -version 验证 jjs 工具是否可用。这将打印版本为 nashorn 9-ea 并将其输入到 shell 中,如下面的图像所示:

我们甚至可以使用 jjs -fv 获取更具体的版本信息,它打印的版本为 nashorn full version 9-ea+169。
本菜谱中使用的 JavaScript 代码文件位于位置 chp14/1_jjs_demo。
如何操作...
-
让我们使用
jjs执行脚本,$ jjs hellojjs.js,它给出以下输出:Hello via JJS using Nashorn。 -
现在我们尝试使用 ECMAScript 6 的
Set、Map和模板字符串功能。模板字符串支持使用占位符构建String,占位符由${variable}标识,完整的String被嵌入在java ``中。我们使用jjs --language=es6 using_map_set_demo.js命令运行此脚本。默认情况下,jjs以es5模式运行,我们通过提供此选项来启用它以在es6模式下运行,如下面的截图所示:

- 现在,让我们交互式地使用
jjs工具。在命令提示符中运行$ jjs --language=es6以启动外壳并执行一些 JavaScript 代码片段,如下所示:
var numbers = [1,2,3,4,5];
var twiceTheValue = numbers.map(v => v * 2 ) ;
print(twiceTheValue);
var name = "Sanaulla";
print(`Hello Mr. ${name}`);
const pi = 3.14
pi = 4
for ( var n of twiceTheValue ) {
print(n);
}
屏幕上将会打印以下内容:

还有更多...
可以使用 -scripting 命令在 jjs 中启用 shell 脚本模式。因此,可以在 JavaScript 代码中嵌入 Shell/Batch 命令,如下所示:
var files = $EXEC("dir").split("\n");
for( let file of files){
print(file);
}
如果你使用 ES5 作为 jjs 的语言,则可以将 $EXEC("dir") 替换为 `dir`。但在 ES6 中,使用 java `` 来表示模板字符串。前面的脚本可以使用 jjs 执行,如下所示:
$ jjs -scripting=true --language=es6 embedded_shell_command.js
embedded_shell_command.js hellojjs.js using_map_set_demo.js
还有两个可用的变量,分别是 $ARG 和 $ENV,可以用来访问传递给脚本的参数和相应的环境变量。
嵌入 Oracle Nashorn 引擎
在这个菜谱中,我们将查看在 Java 代码中嵌入 Nashorn JavaScript 引擎并执行不同的 JavaScript 代码片段、函数和 JavaScript 源文件。
准备工作
应该已经安装了 JDK 9,因为我们将在 Nashorn 引擎中使用一些 ES6 JavaScript 语言特性。
如何做...
- 首先,我们获取一个启用 ES6 语言特性的
ScriptEngine实例:
NashornScriptEngineFactory factory =
new NashornScriptEngineFactory();
ScriptEngine engine = factory.getScriptEngine("--language=es6");
- 让我们定义一个 JavaScript 函数来计算两个数字的和:
engine.eval("function sum(a, b) { return a + b; }");
- 让我们调用上一步定义的函数:
System.out.println(engine.eval("sum(1, 2);"));
- 然后,我们将查看模板字符串支持:
engine.eval("let name = 'Sanaulla'");
System.out.println(engine.eval("print(`Hello Mr. ${name}`)"));
- 我们将使用 ES6 中的新
Set构造和新的for循环来打印Set元素:
engine.eval("var s = new Set();
s.add(1).add(2).add(3).add(4).add(5).add(6);");
System.out.println("Set elements");
engine.eval("for (let e of s) { print(e); }");
- 最后,我们将查看如何加载 JavaScript 源文件并执行其中定义的方法:
engine.eval(new FileReader("src/embedded.nashorn/com/packt
/embeddable.js"));
int difference = (int)engine.eval("difference(1, 2);");
System.out.println("Difference between 1, 2 is: " + difference);
此代码的完整内容可以在以下位置找到,chp14/2_embedded_nashorn。
执行示例后的输出将如下所示:
3
Hello Mr. Sanaulla
null
Set elements
1
2
3
4
5
6
Difference between 1, 2 is: -1
从 Oracle Nashorn 调用 Java
在这个菜谱中,我们将查看从 JavaScript 代码中调用 Java API,包括使用 Java 类型,以及处理包和类导入。结合 Java API 的广泛性和 Oracle Nashorn JavaScript 引擎利用的 JavaScript 的动态性具有更大的潜力。我们将查看创建一个纯 JavaScript 代码,该代码使用 Java API,并使用 jjs 工具来执行它。
我们还将查看纯 JavaScript 创建基于 Swing 的应用程序。
如何做...
- 让我们使用
Arrays.asListAPI 创建一个数字List:
var numbers = java.util.Arrays.asList(12,4,5,67,34,567,32);
- 现在,计算列表中的最大数:
var max = java.util.Collections.max(numbers);
- 我们可以使用 JavaScript 的
print()方法打印max,并且可以使用模板字符串:
print(`Max of ${numbers} is ${max}`);
- 让我们运行使用
jjs创建的脚本:
jjs --language=es6 java_from_javascript.js
- 现在,让我们导入
java.util包:
var javaUtils = new JavaImporter(java.util);
- 让我们使用导入的包来打印今天的日期:
with(javaUtils){
var date = new Date();
print(`Todays date is ${date}`);
}
- 使用
Java.typeAPI 为 Java 类型创建一个别名:
var jSet = Java.type('java.util.HashSet');
- 使用别名创建一个集合,添加一些元素,并打印它:
var mySet = new jSet();
mySet.add(1);
mySet.add(4);
print(`My set is ${mySet}`);
我们得到以下输出:
Max of [12, 4, 5, 67, 34, 567, 32] is 567
Todays date is Wed May 24 2017 00:43:35 GMT+0300 (AST)
My set is [1, 4]
此脚本文件的代码可以在以下位置找到,chp14/3_java_from_nashorn/java_from_javascript.js。
它是如何工作的...
可以通过使用它们的完全限定名从 JavaScript 代码中访问 Java 类型及其 API,就像我们在上一节中创建数字列表时使用 java.util.Arrays.asList() 和使用 java.util.Collections.max() 查找最大值时所看到的那样。
如果我们想省略包名和类名一起指定,我们可以使用 JavaImporter 来导入包,并使用 with 子句来包装代码,这样就可以在内部使用导入包中的类,如下所示:
var javaUtils = new JavaImporter(java.util);
with(javaUtils){
var date = new Date();
print(`Todays date is ${date}`);
}
我们看到的另一个特性是通过使用 Java.type(<fully qualified class name>) 为 Java 类型创建类型别名,就像在以下示例中所做的那样:
var jSet = Java.type('java.util.HashSet');
如果您使用别名创建对象,类型必须是实现类:
var mySet = new jSet();
还有更多...
让我们创建一个脚本,创建一个简单的 Swing GUI,包含一个按钮和一个按钮的事件处理器。我们还将探讨如何利用导入和通过匿名内部类方法实现接口。
首先,我们将创建一个新的 JavaImporter 对象,包含所需的 Java 包:
var javaGui = new JavaImporter(javax.swing, java.awt, java.awt.event);
我们使用 with(obj){} 子句来包装所有使用所需导入的语句:
with(javaGui){
//other statements
}
接下来,我们创建 JButton 并提供 ActionListener 来监听其点击事件:
var button = new JButton("My Button");
button.addActionListener(new ActionListener({
actionPerformed: function(e){
print("Button clicked");
}
}));
然后,我们创建 JFrame 来渲染包含其组件的 GUI:
var frame = new JFrame("GUI Demo");
frame.setDefaultCloseOperation(JFrame.EXIT_ON_CLOSE);
frame.getContentPane().add(button, BorderLayout.CENTER);
frame.pack();
frame.setVisible(true);
这个完整的代码可以在 chp14/3_java_from_nashorn/gui_from_javascript.js 中找到。让我们使用 jjs 运行这个脚本:
jjs --language=es6 java_from_javascript.js
我们可以看到一个小型 GUI,如下面的截图所示:

要退出程序,我们必须使用 Ctrl + C 来停止进程,因为 setDefaultCloseOperation 在 Java 运行时才会生效。另一个选项是覆盖 JFrame 的 close 操作来退出程序。
使用 Oracle Nashorn 实现的 ES6 特性
在这个菜谱中,我们将探讨一些在 Oracle Nashorn JavaScript 引擎中实现的 ES6 特性。为此,我们将创建一个 JavaScript 文件并使用 jjs 来执行它。请记住使用 jjs 在 ES6 模式下,可以通过传递 --language=es6 命令行选项来启用。
如何做到这一点...
- 模板字符串是带有变量占位符的字符串,从而允许创建动态文本。这些字符串必须嵌入在符号
java ``内:
var name = "Sanaulla";
print(`My name is ${name}`);
- JavaScript 中的任何变量都具有全局作用域。ES6 引入了块作用域,可以使用
let关键字声明。现在可以使用const关键字定义常量,如下面的代码片段所示:
const pi = 3.14;
var language = "Java";
function hello(){
let name = "Mohamed";
language = "Javascript";
print(`From hello(). Hello ${name}`);
print(`From hello(). Language is ${language}`);
}
print(`Before hello(). Language is ${language}`);
hello();
print(`After hello(). Language is ${language}`);
print(`After hello(). Hello ${name}`);
//pi = 4.5;
//above will be error because pi is defined as a constant
- 让我们看看新的迭代构造
for ... of:
let numbers = [2,4,6,8,10,12];
for ( const number of numbers ){
print(number);
}
- 让我们看看如何使用新的
Set和Map类来创建集合和映射:
var set = new Set();
set.add("elem 1").add("elem 2").add("elem 3").add("elem 1");
print(`Set ${set} has ${set.size} elements`);
var map = new Map();
map.set(1, "elem 1");
map.set(2, "elem 2");
map.set(3, "elem 3");
print(`Map has 1? ${map.has(1)}`);
- 让我们看看箭头函数。这些与从 Java 8 开始的 lambda 表达式概念类似。箭头函数的形式为
(parameters) => {function body}:
numbers = [1,2,3,4,5,6,7,8,9,10];
var evenNumbers = numbers.filter(n => n % 2 == 0);
print(`Even numbers: ${evenNumbers}`);
该代码的完整版本可以在文件中找到,chp14/4_es6_features/es6_features.js。使用jjs --language=es6 es6_features.js命令执行完整脚本后的输出如下:

第十五章:测试
本章展示了如何在 API 与其他组件集成之前对其进行单元测试。有时,我们需要用一些虚拟数据来模拟依赖,这可以通过模拟依赖来实现。我们将向您展示如何使用模拟库来完成这项工作。我们还将向您展示如何编写固定值来填充测试数据,然后如何通过集成不同的 API 并一起测试它们来测试应用程序的行为。我们将涵盖以下食谱:
-
使用 JUnit 进行 API 的单元测试
-
通过模拟依赖进行单元测试
-
使用固定值来填充测试数据
-
行为测试
简介
经过良好测试的代码能让开发者感到安心。如果你觉得为正在开发的新方法编写测试过于繁琐,那么你通常第一次就做不对。这是因为你无论如何都需要测试你的方法。在应用程序的上下文中进行测试只需要时间来设置(尤其是如果你试图测试所有可能的输入和条件)。然后,如果方法发生变化,你需要重新设置。而且你是手动做的。你可以在开发新方法的同时创建一个自动化的测试来避免这种情况(当然,我们假设代码不是太复杂;设置器和获取器不计入)。这将在长期内为你节省时间。
为什么有时我们会觉得有额外的负担?可能是因为我们在心理上没有做好准备。当我们思考添加新功能需要多长时间时,我们常常忘记包括编写测试所需的时间。当我们向经理提供估算时忘记这一点会更糟。我们常常回避给出更高的估算,因为我们不想显得不够知识渊博或技能不足。无论什么原因,这种情况都会发生。只有经过多年的经验,我们才学会在我们的估算中包含测试,并赢得足够的尊重和影响力,能够公开宣称正确做事需要前期投入更多时间,但长期来看可以节省更多时间。此外,正确做事可以带来更好的结果质量,压力更小,这意味着整体生活质量更高。
如果你仍然不确信,请记下你阅读这篇文档的日期,并每年检查一次,直到这些建议对你来说显而易见。然后,请与他人分享你的经验。这就是人类进步的方式,通过将知识从一代传到下一代。
然而,如果你想要学习如何编写有助于你生成高质量 Java 代码的测试,那么这一章就是为你准备的。从方法论上讲,这也适用于其他语言和职业。这一章主要面向 Java 开发者,并假设测试代码的作者。另一个假设是测试发生在代码编写的早期阶段,这样在测试过程中发现的代码弱点可以立即修复。尽早编写自动化测试是最佳时机,还有两个其他原因:
-
你可以轻松地重构你的代码,使其更容易进行测试
-
它通过消除猜测来节省你的时间,这反过来又使得你的开发过程更加高效
另一个编写测试(或增强现有测试)的好时机是在生产中发现缺陷时。如果你重新创建问题并在失败的测试中展示它,然后展示在新版本的代码中问题消失(并且测试不再失败),这将有助于你调查根本原因。
使用 JUnit 对 API 进行单元测试
根据维基百科,*2013 年在 GitHub 上托管在 GitHub 上的 10,000 个 Java 项目中进行的调查发现,JUnit(与 slf4j-api 并列),是最常包含的外部库。每个库都被 30.7%的项目使用。JUnit 是一个测试框架——xUnit 家族中单元测试框架之一,起源于 SUnit。它在编译时链接为一个 JAR 文件,并驻留在org.junit包中(自 JUnit 4 以来)。
这里是维基百科上另一篇文章的摘录,在面向对象编程中,一个单元通常是一个完整的接口,如一个类,但也可以是一个单独的方法。我们发现最后一部分——一个作为单独方法的单元——在实践中最有用。它将成为本章食谱示例的基础。
准备工作
在撰写本文时,JUnit 的最新稳定版本是 4.12,可以通过在pom.xml项目级别添加以下 Maven 依赖项来使用:
<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类(这正是我们在本书第二章,快速掌握面向对象编程 - 类和接口中讨论的代码):
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文件夹(注意以test开头的新的文件夹结构,它与main文件夹结构并行创建)并在其中创建一个名为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 类。测试获取器可能价值不大,但我们仍然可以这样做,确保传递给构造函数的值由相应的获取器返回。构造函数中的异常也属于必须测试的功能,以及 getSpeedMph() 方法。还有一个 Engine 类的实例,它有 getHorsePower() 方法。它能否返回 null?我们也应该在 Engine 类中查看:
public class Engine {
private int horsePower;
public int getHorsePower() {
return horsePower;
}
public void setHorsePower(int horsePower) {
this.horsePower = horsePower;
}
}
这个类中要测试的行为不多,它不能返回 null。但是返回一个负值是肯定的可能性,这反过来又可能对 getSpeedMph() 方法的 Math.sqrt() 函数造成问题。我们应该确保马力值永远不会是负数吗?这取决于该方法的使用限制和输入数据的来源。
类似的考虑也适用于 Vehicle 类的 weightPounds 属性的值。它可能会因为 getSpeedMph() 方法中的除零错误而引发 ArithmeticException,从而停止应用程序。
然而,在实践中,发动机马力值和车辆重量为负数或接近零的可能性很小,所以我们将假设这一点,并且不会将这些检查添加到代码中。
这样的分析是每个开发者的日常工作和背景思考,这是正确方向的第一步。第二步是将所有这些思考和疑问捕捉到单元测试中,并验证假设。
让我们回到我们创建的测试类,并对其进行增强。你可能已经注意到了,@Test 注解将某个方法标记为测试方法。这意味着每次你发出运行测试的命令时,IDE 或 Maven 都会运行它。你可以按任何你喜欢的名称命名该方法,但最佳实践建议表明你正在测试哪个方法(在这种情况下是 Vehicle 类的方法)。因此,格式通常看起来像 test<methodname><scenario>,其中 scenario 表示特定的测试用例:一条成功路径、一个失败,或者你想要测试的其他条件。在我们的例子中,我们没有使用后缀来表示我们将要测试的是运行成功的主要功能(没有任何错误或边缘情况)。稍后,我们将展示测试其他场景的方法示例。
在这样的测试方法中,你可以调用你正在测试的应用程序方法,提供数据,并断言结果。你可以创建自己的断言(比较实际结果与预期结果的方法),或者你可以使用 JUnit 提供的断言。要实现后者,只需添加 static 导入:
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);
这个结果将是这样的:

这是不正确的,因为5是实际结果,而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 马力的引擎和 4,000 磅的重量,那么在 10 秒内,其速度可以达到 117 英里/小时。由于速度是double类型,我们将使用以下断言:
void assertEquals(String message, double expected,
double actual, double delta)
在这里,delta 是允许的精度(我们决定 1%已经足够好)。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;
然后,编写以下测试:
@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) {}
}
这个测试也运行成功。为了确保测试正确工作,我们暂时将其分配以下内容:
Engine engine = new Engine();
然后,我们观察输出:

这样,我们获得了一种信心,即我们没有编写出总是正确的代码,无论代码如何变化。
如您所见,编写这些测试的最佳方式是在编写应用程序代码的过程中,这样您可以在代码复杂性增长的同时测试代码。否则,特别是在更复杂的代码中,在所有代码编写完成后,您可能会在调试时遇到问题。
还有许多其他注释和 JUnit 特性可能对您有所帮助,因此请参阅 JUnit 文档以深入了解所有框架功能。
参见
参考本章中的以下食谱:
-
通过模拟依赖进行单元测试
-
使用固定值填充测试数据
-
行为测试
通过模拟依赖进行单元测试
编写单元测试需要单元隔离。如果一个方法使用了来自不同对象的几个其他方法,就需要限制测试的深度,以便每个层可以作为一个单元单独测试。这时,对低层进行模拟的需求就凸显出来了。
模拟不仅可以垂直进行,也可以水平进行:在同一级别,但已经与底层功能隔离。如果一个方法很长且复杂,您可能需要将其分解成几个更小的方法,这样您就可以在模拟其他方法的同时只测试其中一个。这是与代码开发一起进行单元测试的另一个优点;在代码变得固定之前,更容易重新设计代码以提高可测试性。
准备工作
模拟其他方法和类是直接的。按照第二章,快速掌握面向对象 - 类和接口中描述的方法进行编码,这会容易得多,尽管有一些模拟框架允许您模拟不实现任何接口的类(我们将在本食谱的下一节中看到此类框架使用的示例)。此外,使用对象和方法工厂可以帮助您创建特定于测试的工厂实现,这样它们就可以生成具有返回预期硬编码值的方法的对象。
例如,在第四章,功能化编程中,我们介绍了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对象,而是可以使用 mocking 框架之一来 mock 它。在这种情况下,我们使用 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()方法时返回一个固定值。我们甚至可以进一步创建一个用于我们想要测试的方法的 mock 对象:
Vehicle vehicleMock = Mockito.mock(Vehicle.class);
Mockito.when(vehicleMock.getSpeedMph(10)).thenReturn(30d);
double speed = vehicleMock.getSpeedMph(10);
System.out.println(speed);
因此,它总是返回相同的值:

然而,这会违背测试的目的。
在测试流式处理的管道方法时,你可以使用另一种技术。假设我们需要测试TrafficDensity1类中的trafficByLane()方法(我们还将有TrafficDensity2和TrafficDensity3):
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对象(为了获取牵引力值)。我们需要修改它,以便在不依赖TrafficUnit的情况下提取SpeedModel,因为我们现在将在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; }
};
}
不清楚TrafficUnit对象中的数据是如何导致不同的速度值的。此外,我们还需要添加各种测试数据——针对不同车辆类型和其他参数——而这需要编写和维护大量的代码。
这意味着我们需要重新审视trafficByLane()方法的设计。为了对方法正确工作的信心,我们需要单独测试方法内部计算的每一步,这样每个测试都只需要少量输入数据,并允许你对预期的结果有清晰的理解。
它是如何工作的...
如果你仔细查看trafficByLane()方法,你会注意到问题是由计算的地点引起的——在私有类TrafficUnitWrapper内部。我们可以将其从那里移出,并为TrafficDensity类创建一个新的方法:
double calcSpeed(double timeSec) {
double speed = this.vehicle.getSpeedMph(timeSec);
return Math.round(speed * this.trafficUnit.getTraction());
}
然后,我们可以将其签名更改为这样:
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()方法设置为受保护的,并假设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);
}
还有更多...
这种经验让我们意识到,使用内部私有类可能会使功能在隔离状态下不可测试。让我们尝试去除private类CountByLane。这导致我们到达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;
}
此更改使我们能够扩展测试中的类:
private 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);
}
}
参见
参考本章以下食谱:
-
使用固定值来填充测试数据
-
行为测试
使用固定值来填充测试数据
在更复杂的应用程序(例如使用数据库的应用程序)中,通常需要在每次测试之前设置相同的数据,并在每次测试运行后清理。某些数据部分需要在每次测试方法之前设置,并在之后清理。你还需要在运行测试类之前配置另一个设置,并在之后清理。
如何做到这一点...
为了实现这一点,你可以编写一个设置方法,并在其前面写一个@Before注解。相应的清理方法由@After注解标识。类似的类级别方法由@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");
}
}
如果你现在运行测试,你会得到以下结果:

这样的方法被称为固定值,它们必须是公开的,并且类级别的设置/清理固定值必须是静态的。尽管即将到来的 JUnit 5 计划取消这些限制。
它是如何工作的...
这种用法的典型例子是在第一个测试方法运行之前创建必要的表,并在测试类的最后一个方法完成后删除它们。设置/清理方法也可以用来创建/关闭数据库连接,除非你的代码在 try-with-resources 构造中这样做(请参阅第十二章,内存管理和调试)。
这里是固定值使用的一个示例(有关如何设置数据库以运行它的说明,请参阅第六章,数据库编程)。让我们假设我们需要测试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(){
...
}
}
我们想确保这个方法始终更新text表中的所有记录为提供的值。我们的第一个测试是更新所有现有记录:
@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);
}
这意味着表格必须在测试数据库中存在,并且应该在其中有一个记录。
我们的第二次测试确保即使有多个记录,并且每个记录包含不同的值,所有记录都会被更新:
@Test
public void updateAllTextRecordsTo2(){
System.out.println("updateAllTextRecordsTo2() is called");
String testString = "Whatever";
System.out.println(" Update all records to Unexpected");
dbRelatedMethods.updateAllTextRecordsTo("Unexpected");
executeUpdate("insert into text(id, text) values(2, ?)",
"Text 01");
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");
}
此外,由于我们可以为所有测试使用相同的对象,让我们也在类级别上创建它(作为测试类的属性):
private DbRelatedMethods dbRelatedMethods = new DbRelatedMethods();
如果我们现在运行test类的所有测试,输出将如下所示:

打印的消息允许你追踪所有方法调用的顺序,并查看它们是否按预期执行。
参见
参考本章以下配方:
- 行为测试
行为测试
如果你已经阅读了所有章节并查看过代码示例,你可能已经注意到现在,我们已经讨论并构建了典型分布式应用所需的所有组件。现在是时候将所有组件组合起来,看看它们是否按预期协作。这个过程被称为集成。
在做这件事的时候,我们将仔细评估应用程序是否按照要求运行。在功能需求以可执行形式呈现的情况下(例如使用 Cucumber 框架),我们可以运行它们并检查是否所有检查都通过。许多软件公司遵循行为驱动开发过程,并在代码编写早期进行测试,有时甚至在大量代码编写之前(当然,这些测试会失败,但一旦预期的功能实现,它们就会成功)。如前所述,早期测试对于编写专注、清晰且易于测试的代码非常有帮助。
然而,即使没有严格遵循测试驱动过程,集成阶段自然也包括某种形式的行为测试。在这个配方中,我们将看到几个可能的途径和与这个相关的具体示例。
准备工作
你可能已经注意到,在这本书的过程中,我们构建了几个类,它们组成一个分析并模拟交通的应用程序。为了方便起见,我们将它们全部包含在com.packt.cookbook.ch15_testing包中。你已经熟悉(从第 2、4、5 和 7 章)api文件夹中的五个接口:Car、SpeedModel、TrafficUnit、Truck和Vehicle。它们的实现封装在具有相同名称的文件夹中称为工厂的类中(在第 2、4、5 和 7 章中使用):FactorySpeedModel、FactoryTraffic和FactoryVehicle。这些工厂为AverageSpeed类的功能提供了输入(第七章,并发和多线程编程)和TrafficDensity(基于第五章,流操作和管道,但在本章中创建和讨论)——我们演示应用程序的核心类。它们产生最初推动这个特定应用程序发展的值。
应用程序的主要功能很简单。对于给定的车道数量和每条车道的速度限制,AverageSpeed计算(估计)每条车道的实际速度(假设所有驾驶员都表现理性,根据他们的速度选择车道),而TrafficDensity计算 10 秒后每条车道上的车辆数量(假设所有车辆在红灯后同时启动)。这些计算基于在特定位置和一年中的特定时间收集的numberOfTrafficUnits车辆的数据。这并不意味着所有一千辆车都在同一时间竞赛。这 1,000 个测量点在过去 50 年中收集了大约 20 辆车在指定小时(这意味着平均每三分钟一辆车)在指定交叉口行驶的数据。
应用程序的整体基础设施由process文件夹中的类支持:Dispatcher、Processor和Subscription(我们在第七章,并发和多线程编程)中讨论了它们的功能并进行了演示)。这些类允许分布式处理。Dispatcher类使用Subscription类将处理请求发送到池中的Processors群体。每个Processor类根据请求执行任务(使用AverageSpeed和TrafficDensity类)并将结果存储在数据库中(使用utils文件夹中的DbUtil类,基于第六章[41632f15-3abe-4f59-8ce9-009aacfbe1cf.xhtml],数据库编程中讨论的功能)。
我们已经将这些类作为单元进行了测试。现在我们将它们集成在一起,以整体测试应用程序的正确行为。
这些要求只是为了演示目的。目标是有一个有良好动机(类似于真实数据)的东西,同时足够简单,无需特殊知识即可理解交通分析和建模。
如何操作...
有几个集成级别。我们需要集成应用程序的类和子系统,并将我们的应用程序与外部系统(由第三方开发和维护的交通数据源)集成。以下是一个类级别集成的示例(请参阅Chapter15Testing类中的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(", ")));
在这个例子中,我们将两个主要类,即AverageSpeed和TrafficDensity,与工厂和它们的 API 接口实现进行了集成。
结果如下:

注意,结果在不同的运行中略有不同。这是因为FactoryTraffic产生的数据从一次请求到另一次请求是变化的。但在这个阶段,我们只需确保一切都能协同工作,并产生一些或多或少看起来准确的结果。我们已经通过单元测试了代码,并对它正在执行预期操作有一定的信心。我们将在实际集成测试过程中,而不是在集成过程中验证结果。
在完成类级别的集成后,看看子系统是如何协同工作的(请参阅Chapter15Testing类中的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();
}
}
该开关可以作为类中的一个布尔属性(如前述代码所示)或项目配置属性来实现。我们省略了连接到特定真实交通数据源的细节,因为这与本书的目的无关。
在这个阶段,主要关注点必须是性能,以及在外部真实数据源和我们的应用程序之间保持流畅的数据流。在确保一切正常工作并产生满意性能的结果(看起来很真实)之后,我们才能转向集成测试(对实际结果的断言)。
它是如何工作的...
对于测试,我们需要设置预期的值,然后我们可以将它们与由处理真实数据的应用程序产生的(实际)值进行比较。但是,真实数据在每次运行中都会略有变化,尝试预测结果值要么使测试变得脆弱,要么迫使引入巨大的误差范围,这可能会有效地抵消测试的目的。
我们甚至无法模拟生成数据(如我们在单元测试中所做的那样),因为我们处于集成阶段,必须使用真实数据。
一种可能的解决方案是将传入的真实数据和我们的应用程序产生的结果存储在数据库中。然后,领域专家可以遍历每条记录并断言结果是否符合预期。
为了实现这一点,我们在TrafficDensity类中引入了一个布尔开关,因此它记录了每个计算单元的输入:
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表的新方法(设计用于存储来自FactoryTraffic的TrafficUnits)和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类以及DbUtil内部的TrafficUnitImpl类:
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 如何决定分配负载。在特定情况下,提供的代码可能不会工作。在这种情况下,尝试增加请求的交通单元数量。这将确保有更多的空间用于负载分配逻辑。




在系统变量下定义的变量对所有系统用户都可用,而在
浙公网安备 33010602011771号