Java-研讨会-全-
Java 研讨会(全)
原文:
zh.annas-archive.org/md5/7e98199e364866427216662d687c9e02译者:飞龙
前言
关于
本节简要介绍了本书的覆盖范围,您开始学习所需的技术技能,以及完成所有包含的活动和练习所需的软件要求。
关于本书
您已经知道您想学习 Java,而学习 Java 12 的一个更智能的方法是通过实践来学习。Java 工作坊专注于提高您的实践技能,以便您能够开发在 JVM 中无缝运行的高性能 Java 应用程序,涵盖网络、移动和桌面。您将通过真实示例学习,这些示例将带来实际结果。
在Java 工作坊中,您将采取引人入胜的逐步方法来理解 Java。您不必忍受任何不必要的理论。如果您时间紧迫,您可以每天跳入一个单独的练习,或者花一个周末学习关于响应式编程和单元测试的内容。由您选择。按照自己的方式学习,您将以一种感觉有成就感的方式建立和加强关键技能。
每一本物理印刷版的Java 工作坊都可以解锁访问互动版。视频详细介绍了所有练习和活动,您将始终有一个指导性的解决方案。您还可以通过评估来衡量自己,跟踪进度,并接收免费内容更新。完成学习后,您甚至可以赚取一个可以在线分享和验证的安全凭证。这是一项包含在印刷版中的高级学习体验。要兑换,请遵循 Java 书籍开头的说明。
快速直接,Java 工作坊是 Java 初学者的理想伴侣。您将像软件开发者一样构建和迭代代码,在学习过程中学习。这个过程意味着您会发现您的新技能会坚持下去,作为最佳实践嵌入其中。为未来的几年打下坚实的基础。
关于章节
第一章,入门,涵盖了编写和测试程序的基础,这是构建本书中所有代码的第一步。
第二章,学习基础,涵盖了 Java 语言的基本语法,特别是控制应用程序流程的方法。
第三章,面向对象编程,概述了 OOP,并详细介绍了使 Java 成为流行语言的特点。
第四章,集合、列表和 Java 内置 API,涵盖了流行的 Java 集合框架,该框架用于存储、排序和过滤数据。
第五章,异常,提供了在更概念层面上处理异常的建议,提供了一份任何专业程序员都会遵循的最佳实践列表。
第六章,库、包和模块,介绍了各种打包和捆绑 Java 代码的方法,以及帮助您构建 Java 项目的工具。
第七章,数据库和 JDBC,展示了如何使用 JDBC 从 Java 应用程序中访问关系数据库。
第八章,套接字、文件和流,帮助您处理外部数据存储系统。
第九章,使用 HTTP 进行工作,解释了如何创建连接到特定 Web 服务器并下载数据的程序。
第十章,加密,探讨了将加密应用于您的软件对于保护您或您的客户的数据、业务和完整性至关重要。
第十一章,进程,简要讨论了 Java 中进程的功能和处理方式。
第十二章,正则表达式,解读了正则表达式的含义,并探讨了它在 Java 中的实用性。
第十三章,使用 Lambda 表达式进行函数式编程,讨论了 Java 如何成为一门函数式编程语言,以及如何在 Java 中使用 Lambda 表达式进行模式匹配。
第十四章,递归,探讨了使用递归技术解决的问题。
第十五章,使用流处理数据,解释了您如何使用流以更少的代码行编写更具表达力的程序,以及您如何轻松地对大型列表上的多个操作进行链式操作。
第十六章,断言和其他功能接口,探讨了功能接口的一些有效用例。
第十七章,使用 Java Flow 进行响应式编程,讨论了 Java Flow API 和响应式流规范的优势。
第十八章,单元测试,深入探讨了 JUnit 的测试,JUnit 是 Java 的主要测试框架之一。
惯例
文本中的代码词、数据库表名、文件夹名、文件名、文件扩展名、路径名、虚拟 URL、用户输入和 Twitter 昵称如下所示:“您可以在任何代码块中嵌套if语句,包括在if语句之后的代码块。”。
屏幕上看到的单词,例如在菜单或对话框中,在文本中也以这种方式出现:“点击 创建 新 项目。”
代码块设置如下:
if (i == 5) {
System.out.println("i is 5");
}
i = 0;
新术语和重要词汇如下所示:“这种数据就是我们所说的变量 类型。”
大段代码被截断,GitHub 上相应代码文件的名称被放置在截断代码的顶部。整个代码的永久链接放置在代码片段下方。它应该看起来如下:
Exercise02.java
6 if (distanceToHome > maxDistance) {
7 System.out.println("Distance from the store to your home is");
8 System.out.println(" more than " + maxDistance + "km away.");
9 System.out.println("That is too far for free delivery.");
https://packt.live/32Ca9YS
在开始之前
每一段伟大的旅程都是从一小步开始的。我们即将开始的 Java 之旅也不例外。在我们能够使用 Java 做些令人惊叹的事情之前,我们必须准备好一个高效的环境。在这篇简短的笔记中,我们将看看如何做到这一点。
JRE 的安装
要在您的系统上安装 JRE,请参阅:https://www.java.com/en/download/manual.jsp。
JDK 的安装
要在您的系统上安装 JDK,请参阅:https://www.oracle.com/technetwork/java/javase/downloads/index.html。
IntelliJ IDEA 的安装
虽然课程中所有的代码都能在所有 Java 编译器上运行,但我们已经在我们的系统上使用了 IntelliJ IDEA。练习和活动中的所有说明都针对 IntelliJ 进行定制。要在您的系统上安装 IntelliJ,请访问 jetbrains.com/idea/。
如果您在安装过程中遇到任何问题或有任何疑问,请通过电子邮件发送给我们,邮箱地址为workshops@packt.com。
安装代码包
从 GitHub 下载代码文件,网址为 https://packt.live/2Jgzz6D,并将它们放置在一个名为C:\Code的新文件夹中。请参考这些代码文件以获取完整的代码包。
第一章:1. 入门
概述
在本章中,我们将介绍 Java 的基础知识。你将首先学习如何编写和编译你的第一个 "Hello World!" 程序——这是学习任何新语言的第一个传统步骤。然后,我们将讨论 命令行界面(CLI)和 图形用户界面(GUI)之间的区别,以及两者的相对优势。到本章结束时,你将理解变量背后的基本概念,了解如何在其中存储数据,甚至如何对你的代码进行注释。
简介
在学习如何使用几乎任何编程语言进行编程时,你通常会测试的第一个示例被称为 "hello world"。这是可能的最简单应用程序;目的是将 "hello world" 表达式写入编程环境提供的任何用户界面。执行此程序将向你介绍使用 IntelliJ 编辑器编写代码的基础,利用不同类型的数据打印到用户界面,以及如何对你的代码添加注释。
当你编写第一个程序时,你也会发现 Java 的语法是如何构建的,以及它与 C 或 C++ 等其他语言的关系。理解语法是开始阅读代码的关键。你将学习如何区分命令和函数的开始和结束,如何在代码块之间传递参数,以及如何添加注释,这些注释将有助于你在将来回顾你的软件时。
本章介绍了编写和测试程序的基础,这是构建本书中所有代码的第一步。
编写、编译和执行你的 "Hello World!" 程序
在前言中,你看到了如何安装 IntelliJ 开发环境。虽然你可以用任何文本编辑器编写 Java 代码,但我们认为看到如何使用像上述软件包这样的最先进工具来创建应用程序是很好的。
然而,在一步一步地指导你运行第一个程序之前,我们应该先看看将成为你第一个在 Java 上运行的可执行代码。以下代码列表显示了程序。阅读它,我们稍后会修订每个部分的功能:
public class Main {
public static void main (String[] args) {
System.out.println("Hello World!");
}
}
第一行是我们所说的类定义。Java 中的所有程序都被称作 main。在这个程序中,你可以看到 Main 类包含一个名为 main 的方法,这个方法会将句子 "Hello World!" 打印到系统的默认输出。
包含在类定义(public class Main)中的代码表明,这个类本身是公开的,这意味着它可以从你电脑上运行的其他程序中访问。同样,方法定义(public static void main(String[] args))也是这样。然而,还有一些其他的事情需要我们注意:
-
static表示系统中没有实例化main方法。由于 Java 虚拟机的工作方式,main方法必须是静态的,否则将无法执行它。 -
void表示main方法不会向调用它的任何代码返回任何内容。实际上,方法可以向执行它的代码发送答案,正如我们将在本书后面看到的那样。 -
main是方法的名称。你不能给它赋予不同的名称,因为这个方法是使程序可执行的方法,需要这样命名。 -
String[] args是main方法的参数。参数作为字符串列表传递。换句话说,程序可以从计算机内的其他部分获取参数并用作数据。在main方法的具体情况下,这些是在调用程序时可以在命令行界面(CLI)中输入的字符串。
练习 1:在 Java 中创建你的 Hello World 程序
IntelliJ 为你提供了一个预先制作的“Hello World”模板。模板可以帮助你更快地开始编写代码,因为它们提供了你可能需要的组件来加速开发。模板也可以用于教育目的;当测试“Hello World”时就是这种情况。
对于这个第一个练习,我们从编辑器开始。我们将保留一些默认选项不变。我们稍后会看到如何根据我们的需求个性化一些选项:
-
打开 IntelliJ,你会看到一个窗口提供给你几个选项。点击
创建新项目。它应该是列表中的第一个选项:![图 1.1:在 IntelliJ IDE 中创建新项目]()
图 1.1:在 IntelliJ IDE 中创建新项目
-
应该出现一个新的界面。这里的默认选项是为了创建一个 Java 程序,所以你只需点击
下一步:![图 1.2:创建新的 Java 项目]()
图 1.2:创建新的 Java 项目
-
打勾以从模板创建项目。点击
Java Hello World然后点击下一步:![图 1.3:从模板创建 Java Hello World 项目]()
图 1.3:从模板创建 Java Hello World 项目
-
将项目命名为
chapter01。然后,点击完成:![图 1.4:创建 Hello World 项目]()
图 1.4:创建 Hello World 项目
-
由于我们没有选择存储项目的文件夹(故意为之),IntelliJ 会提供在用户空间内创建默认项目文件夹的可能性。点击
确定:![图 1.5:IntelliJ IDE 中的默认项目文件夹选项]()
图 1.5:IntelliJ IDE 中的默认项目文件夹选项
-
你将看到一个弹出窗口,其中包含有关如何使用该软件的提示。如果你以前从未使用过这种类型的开发环境,那么这是了解每次 IntelliJ 启动时如何工作的好方法。选择你的首选项,然后点击
关闭:![图 1.6:如何使用 IDE 的提示![图片 C13927_01_06.jpg]
图 1.6:如何使用 IDE 的提示
-
IntelliJ 会提醒你有关使用专门用于学习更多关于编程环境信息的特殊标签页的可能性。点击
明白了。 -
编辑器显示一个菜单栏、代码导航栏、项目导航区域以及实际的编辑器,在这里你可以看到我们之前解释过的代码。现在,是时候测试它了。点击
运行按钮(这是代码导航栏右侧的三角形)。![图 1.7:通过点击运行按钮执行程序![图片 C13927_01_07.jpg]
图 1.7:通过点击运行按钮执行程序
-
当程序运行时,IntelliJ 底部的终端窗口展开。在这里,你可以看到软件如何调用 JVM,程序的结果,以及编辑器中的一行显示
Process finished with exit code 0,这意味着没有发生错误。![图 1.8:JVM 显示输出![图片 C13927_01_08.jpg]
图 1.8:JVM 显示输出
注意
由于我们在这个例子中默认选择了所有选项,所以你会看到我们的程序被命名为Main.java。在下一章中,我们将看到如何创建我们自己命名的程序。
基本语法和命名约定
在hello world程序中,当你关注语法时,首先会注意到我们如何将代码分组到由花括号{和}标记的块中。Main类包含main方法。换句话说,main被嵌套在Main中。这就是 Java 中定义类的方式——原则上,它们包含它们将要使用到的所有方法。
Java 语法的另一个方面是大小写很重要。如果一个命令被定义为Print,它不同于另一个名为print的命令,编译器会将它们识别为不同的。大小写属于一种约定,是程序员之间关于 Java 中命名格式的一种不成文规则。你会注意到类被命名为HelloWorld。在 Java 中,约定规定方法、类、变量等应该通过使用大写字母来连接单词,以此作为单词分隔的标记。此外,类的名称应该以大写字母开头。
注意
当你刚开始时,很容易混淆语法,语法是严格的,必须遵守,以便编译器能够运行,以及约定,约定是为了让开发者更好地理解代码应该如何工作。
在一定程度上,Java 编译器不关心空白字符,但有一个关于使用它们的约定,可以使代码更易读。你看到的第一个代码列表(Example01.java)可以重写如下,一旦编译和执行,将产生完全相同的结果:
public class Main {
public static void main(String[] args) {
System.out.println("Hello World!");
}
}
System.out.println("Hello World!") 函数调用将在 CLI 上打印出预期的消息。该命令嵌套在 main(String[] args) 方法定义中,该方法定义又嵌套在 class 定义中。你可以添加更多空白字符,但这不会影响程序的功能。这是 Java 语法的一部分,也是其他编程语言(如 C,C++和 Scala)的一部分。
此外,请注意,“Hello World!” 是一个 String,一种数据类型。下一节将探讨哪些类型的数据可以作为参数发送到 System.out.println() 方法调用。
打印不同数据类型
在 Java 中,定义具有使用不同参数集的能力的方法是很常见的。例如,System.out.println() 方法可以打印出不仅仅是文本的其他类型的数据。例如,你可以尝试打印出一个简单的数字并查看结果。Example03.java 在代码中添加了几行,以展示不同类型的数据:
public class Main {
public static void main(String[] args) {
System.out.println("This is text");
System.out.println('A');
System.out.println(53);
System.out.println(23.08f);
System.out.println(1.97);
System.out.println(true);
}
}
之前的例子将在 CLI 上打印出四行,代表发送给 System.out.println() 方法的不同参数。结果将如下所示:
This is text
A
53
23.08
1.97
true
Process finished with exit code 0
你在这个结果中看到了六种不同的数据类型:一些文本,一个字符,一个整数,两种不同的十进制数,和一个布尔陈述。在 Java 编程语言中,我们分别将这些数据类型定义为 String,char,int,float,double 和 boolean。关于数据类型还有很多东西要学习,但让我们首先介绍一个新主题:变量。这将有助于理解数据类型为什么很重要。
变量和变量类型
变量是赋予计算机内存槽位的可读性名称。每个槽位都可以存储一些数据,例如一个数字,一段文本,一个密码,或室外温度的值。这类数据就是我们所说的变量类型。在我们的编程语言中,变量类型和数据类型的数量一样多。我们使用的数据类型定义了分配给存储数据的内存量。一个字节(由 8 位组成)比一个整数(由 32 位组成)小。一个字符串由多个字符组成,因此比整数大。
byte, int(整数简称),String 和 char(字符简称)是变量类型。为了使用变量,你需要为编译器定义它,以便编译器知道它需要为存储数据分配一些空间。变量定义是通过首先确定其类型,然后是变量的名称,然后你可以选择性地用某个值初始化它。
以下代码列表显示了如何定义几个不同类型的变量:
// a counter
int counter = 0;
// a String
String errMsg = "You should press 'NEXT' to continue";
// a boolean
boolean isConnected = false;
下一个练习将指导你如何修改 Example03.java 中的代码列表,以便打印出变量的值。
练习 2:打印不同类型的数据
在这个练习中,我们将声明不同数据类型的变量并将它们打印为输出。为此,执行以下步骤:
-
打开 IntelliJ。如果你还没有尝试
Example03.java中的代码列表,让我们先使用HelloWorld模板创建一个新的项目:![图 1.9:创建一个新的 Java 项目]()
图 1.9:创建一个新的 Java 项目
-
一旦你到达由开发环境生成的代码步骤,复制所有代码,将其擦除,然后粘贴
Example03.java列表中的代码: -
尝试运行代码,并检查结果是否符合 打印不同数据类型 中解释的内容。
-
首先,声明一个
String类型的变量并初始化它:public class Main { public static void main(String[] args) { String t = "This is text"; System.out.println("This is text"); System.out.println('A'); System.out.println(53); System.out.println(23.08f); System.out.println(1.97); System.out.println(true); } } -
然后,将第一个
System.out.println()命令中的文本替换为变量。由于变量链接到包含字符串的内存块,执行程序将给出相同的结果:public class Main { public static void main(String[] args) { String t = "This is a text"; System.out.println(t); System.out.println('A'); System.out.println(53); System.out.println(23.08f); System.out.println(1.97); System.out.println(true); } } -
继续声明一个
char类型的变量,另一个int类型的变量,一个double类型的变量,最后,一个boolean类型的变量。在打印到 CLI 时,使用变量名而不是值:public class Main { public static void main(String[] args) { String t = "This is a text"; char c = 'A'; int i = 53; float f = 23.08f; double d = 1.97; boolean b = true; System.out.println(t); System.out.println(c); System.out.println(i); System.out.println(f); System.out.println(d); System.out.println(b); } }
通过这个例子,你不仅学习了不同类型的数据以及存储这些数据的变量,还学习了方法如何处理多种数据类型。
注意
注意到当定义 float 类型时,需要在数字后面附加字母 f。这样,Java 就能区分这两种类型的十进制变量。
原始数据类型与引用数据类型
一些数据类型建立在其他类型之上。例如,字符串是由字符序列组成的,所以,从某种意义上说,没有字符就没有字符串。可以说字符比字符串对语言更核心。像字符一样,还有其他用于定义编程语言特性的数据类型。这些对于语言本身构建来说是基本的数据类型,我们称之为基本数据类型。
下表描述了你在 Java 中会发现的一些基本变量类型及其特性:

图 1.10:Java 中的基本类型
八种基本数据类型包括 byte、short、int 和 long)、float 和 double)、以及 char)。练习 2,打印不同类型的数据 展示了如何在我们的程序中使用这些类型中的变量。
注意
字符串不是原始数据类型。我们称之为引用数据类型。一个有助于你记住为什么它被称为“引用”的记忆法是,它不是链接到实际数据,而是链接到数据存储在内存中的位置;因此,它是“一个引用。”本书后面你还将介绍其他引用数据类型。请注意,float和double在处理一些十进制数的用途(如货币)时不够精确。Java 有一个高精度的十进制数据类型称为BigDecimal,但它不是原始类型。
空值(Null)
与原始数据类型有默认值一样,引用数据类型(可以是任何类型的数据)有一个共同的方式来表达它们不包含数据。作为一个引用类型变量的例子,定义为empty的字符串的默认值是null。
然而,空值比这要复杂得多——它还可以用来确定终止。继续以字符串的例子为例,当存储在内存中时,它将是一个以null结尾的字符数组。这样,就可以在字符串内进行迭代,因为有一个共同的方式来表示你已经到达了它的末尾。
在程序执行过程中,可以修改计算机内存的内容。我们通过在代码中使用变量来实现这一点。接下来的代码示例将向您展示如何在程序运行时创建一个空的String类型变量并修改其值:
public class Main {
public static void main(String[] args) {
String t = null;
System.out.println(t);
t = "Joe ...";
System.out.println(t);
t = "went fishing";
System.out.println(t);
}
}
之前的例子展示了如何声明一个空字符串,如何在程序中修改其值,以及程序将如何处理显示空字符串的内容。它实际上会在 CLI 上打印出单词null。请查看程序的完整输出:
null
Joe ...
went fishing
Process finished with exit code 0
程序声明了一个空变量,并通过给它赋新值,用新内容覆盖了变量的内容。
字符和字符串
如同在原始数据类型与引用数据类型中解释的那样,字符串由字符序列组成。一个字符是一个代表字母表中的字母、数字、人类可读的符号(如感叹号)或甚至肉眼看不见的符号(如空白空间、换行符或制表符)的符号。字符串是变量,它引用内存中包含一维字符数组的部分。
Java 允许使用字符的数学组合来创建字符串。让我们以之前打印消息"Joe . . . went fishing"的例子为例。让我们修改它,使其将字符串的不同部分相加,而不是在每一步覆盖变量:
public class Main {
public static void main(String[] args) {
String t = null;
System.out.println(t);
t = t + "Joe . . . ";
System.out.println(t);
t = t + "Joe . . . went fishing";
System.out.println(t);
}
}
这个程序的输出结果将是以下内容:
null
nullJoe ...
nullJoe ... went fishing
Process finished with exit code 0
在这里发生的情况是,程序通过向字符串中追加新部分来使其变长,并打印出字符串。然而,结果是不可预期的(除非你真的想让程序在字符串前打印出null)。
现在是时候看看当你没有正确声明变量时会发生什么了。修改之前的代码列表,并从开发环境中观察结果。
练习 3:声明字符串
修改 Example05.java 中的代码示例,以查看开发环境将如何响应变量声明的无效性。为此,请执行以下步骤:
-
首先,使用
HelloWorld模板创建一个程序,并用Example05.java文件中的列表覆盖所有代码。 -
尝试运行程序。你应该得到本节前面展示的结果。
-
将声明字符串的行修改如下:
String t; -
当执行程序时,你会得到一个错误作为结果:
Error:(4, 28) java: variable t might not have been initialized -
声明字符串为空,即不包含任何字符。你可以通过以下代码行声明字符串:
String t = "";在进行此修改后,程序的结果将如下所示:
Joe ... Joe … went fishing Process finished with exit code 0
进行一些数学运算
你可以说,Example05.java 文件中的代码列表代表了一种 添加 字符串的方法。这种添加字符串的操作称为 连接。同时,还可以使用变量作为方程的一部分执行各种简单和复杂的数学运算。
Java 中的基本数学运算符是加法 (+), 减法 (-), 乘法 (*), 和除法 (/)。这里展示了某些操作是如何执行的示例:
t = a + 5;
b = t * 6.23;
n = g / s - 45;
操作的顺序是正常数学的顺序:先乘除,后加减。如果需要嵌套,可以使用花括号:
h = (4 + t) / 2;
f = j * (e – 5 / 2);
还有其他数学运算符,例如平方根 (sqrt()), 最小值 (min()), 和向上取整 (round())。调用这些更高级的操作需要调用 Java 中的 Math 库中的方法。让我们看看一些示例代码,它将执行一些数学运算以了解这是如何工作的,稍后我们将使用这些方法尝试解决一个简单的三角学方程:
public class Main {
public static void main(String[] args) {
float f = 51.49f;
System.out.println(f);
int i = Math.round(f);
System.out.println(i);
}
}
在前面的例子中,你声明了一个 float 类型的变量并打印它。接下来,你声明了一个 int 类型的变量,并用上一步变量四舍五入的结果初始化它,这样就消除了数字的小数部分。你可以看到 round() 是 Java 的 Math 库的一部分,因此必须这样调用。
Math.round() 和 System.out.println() 分别是调用属于标准 Java 库 Math 和 System 的方法的示例。Java 提供了大量的有用方法,这将使你与软件的交互变得快速而简单。我们将在本书的后面部分探讨它们。
练习 4:解决一个简单的三角学问题
本练习的目标是求解直角三角形的斜边长度,给定其他两边的长度。请注意,计算直角三角形斜边的公式如下:h2 = a2 + b2
![图 1.11:一个直角三角形,边长为 a 和 b,h 为斜边]

图 1.11:一个直角三角形,边长为 a 和 b,h 为斜边
要做到这一点,请执行以下步骤:
-
再次以
HelloWorld模板作为练习的出发点,创建程序,然后通过修改其内容来构建一个新的程序。 -
声明每个问题变量的值。将对应斜边的变量初始化为
0。将所有变量声明为double类型:double a = 3; double b = 4; double h = 0; -
由于
a和b的平方和等于h的平方,将方程重写如下:h = Math.sqrt(a*a + b*b);sqrt()方法用于获取一个数的平方根。 -
添加必要的代码以打印出结果:
System.out.println(h);该程序的预期结果应该是以下内容:
5.0 Process finished with exit code 0 -
编程语言通常提供多种解决问题的方法。在这个特定的情况下,你可以通过使用
Math.pow()方法来解决变量a和b的平方的计算。这将通过参数给出的指数来计算基数的幂:h = Math.sqrt(Math.pow(a,2) + Math.pow(b,2));经过所有修改后的最终程序形式如下:
public class Main { public static void main(String[] args) { double a = 3; double b = 4; double h = 0; h = Math.sqrt(Math.pow(a,2) + Math.pow(b,2)); System.out.println(h); } }
注释有助于你共享代码
到目前为止,你只是编写程序并测试它们。但如果你打算参与一个大型软件项目,你将在其中与其他人合作开发应用程序,你必须与他人共享你的代码。共享代码是当代开发者工作的重要部分,为了共享代码,你必须对代码进行注释,以便他人可以理解你为什么以你代码中的方式解决某些挑战。
在 Java 中,有两种方式来注释代码:内联注释,使用双斜杠//标记;以及更广泛的注释,通常用于大块代码的开头,使用由斜杠和星号组成的开始标签/*和由星号和斜杠组成的结束标签*/。
以下示例展示了如何对上一个练习的结果程序添加注释:
public class Main {
public static void main(String[] args) {
double a = 3; // first side of the triangle
double b = 4; // second side of the triangle
double h = 0; // hypotenuse, init with value 0
// equation to solve the hypotenuse
h = Math.sqrt(Math.pow(a,2) + Math.pow(b,2));
System.out.println(h); // print out the results
}
}
在前面的例子中,我们注释了程序的开头和每一行。目的是突出不同的代码注释方式——内联注释、行前注释、代码开头注释。你会在注释中注意到一些特殊的东西;例如,开头注释包括代码的作者(最终,你也会包括你的联系信息)以及版权声明,让人们知道他们可以多大程度上重新使用你的代码。
注意
代码的版权声明通常取决于特定公司的政策,并且几乎每个项目都有所不同。在添加这些到你的代码时要小心。
命令行界面(CLI)与图形用户界面(GUI)
在这本书中,我们将使用 CLI 作为测试和部署代码的方式。另一方面,我们将使用 IntelliJ 开发环境编写代码,它有一个 图形用户界面(GUI)。我们有意避免编写将使用 GUI 与用户交互的程序。Java 在当前形式下,主要用于作为在服务器上运行的服务,因此生成 GUI 不是使用 Java 的主要目标。
到目前为止,这本书已经邀请你从 IntelliJ 环境中运行代码。接下来的练习将帮助你创建一个完全编译的应用程序并在 CLI 中运行它。
练习 5:从 CLI 运行代码
我们将从创建 HelloWorld 示例开始。我们将编译它,然后从终端窗口中查找它。你必须记住你创建程序所在的文件夹,因为我们将从那里执行它。在这个例子中,我们将文件夹命名为 chapter01。如果你给它起了不同的名字,你将不得不记住在代码中必要的时候使用正确的文件夹名称:
-
点击
构建项目按钮(这是工具栏上的锤子),检查系统没有抛出任何错误。如果有任何错误,窗口底部的控制台将打开,指示可能的错误。 -
接下来,在编辑器内打开终端,你将在环境窗口的底部看到一个按钮。这将显示一个从程序创建位置开始的 CLI。你可以通过输入
ls命令来查看文件夹的内容:usr@localhost:~/IdeaProjects/chapter01$ ls chapter01.iml out src -
将有两个不同的文件夹和一个文件。我们感兴趣的是检查名为
out的文件夹。它是包含我们程序编译版本的文件夹。 -
通过输入
cd out命令导航到该文件夹。这个文件夹包含一个名为production的单个子文件夹——进入它,以及随后的chapter01子文件夹:usr@localhost:~/IdeaProjects/chapter01$ cd out usr@localhost:~/IdeaProjects/chapter01/out$ cd production usr@localhost:~/IdeaProjects/chapter01/out/production$ cd chapter01 usr@localhost:~/IdeaProjects/chapter01/out/production/chapter01$ ls Main.class -
一旦到达正确的文件夹,你将找到一个名为
Main.class的文件。这是你程序的编译版本。要执行它,你需要调用java Main命令。你将直接在 CLI 中看到程序的结果:usr@localhost:~/IdeaProjects/chapter01/out/production/chapter01$ java Main Hello World!
活动 1:获取两个数字的最小值
编写一个程序,该程序将检查作为变量输入的两个数字,并打印出消息 "两个数字的最小值:XX 和 YY 是 ZZ",其中 XX、YY 和 ZZ 分别代表两个变量的值和操作的结果。为此,执行以下步骤:
-
声明 3 个双精度变量:
a、b和m。分别用3、4和0初始化它们。 -
创建一个
String变量r,它应该包含要打印的输出消息。 -
使用
min()方法获取两个数字的最小值并将值存储在 m 中。 -
打印结果。
注意
活动的解决方案可以在第 532 页找到。
摘要
本章向你介绍了 IntelliJ 开发环境的使用,这是本书中将使用的基工具。IntelliJ 的许多功能在其他工具中也很常见,包括菜单中使用的语言和整体编程界面。
你已经看到了 Java 语法的一些基本方面:如何定义类,如何将代码嵌套在花括号内,以及分号如何结束每个命令。注释有助于使代码更易于阅读,无论是与其他可能合作的开发者,还是在你未来回顾代码时。
原始类型提供了一组可能的变量类型,这些类型可以在你的程序中使用,用于携带数据、存储操作结果以及在代码的不同块之间传输信息。
本章中的所有示例都是基于修改我们用作出发点的初始示例构建的:“hello world”——即,在命令行界面打印一个字符串。在后面的章节中,你将学习如何从头创建自己的类,根据你的需求命名它们,并将它们存储在不同的文件夹中。下一章将专门介绍 Java 中控制程序流程的语句。
KAY34
第二章:2. 学习基础知识
概述
在本章中,我们将执行不具有我们迄今为止所看到的典型线性流程的程序。您将首先学习如何使用 if、else、else if 和 switch-case 语句来控制程序的流程。您将练习在 Java 中运行 for、while 和 do-while 循环,以执行重复性任务,以及如何传递命令行参数来修改程序的运行方式。到本章结束时,您将能够实现不可变、静态(全局)变量,以及 Java 的变量类型推断机制。
简介
商业应用程序有许多特殊情况。这些条件可能包括从特定年份开始寻找分配规则的变化,或者根据员工的职位不同而以不同的方式处理不同类型的员工。为了编写这样的特殊情况代码,您将需要条件逻辑。您基本上告诉计算机在满足特定条件时执行一系列操作。
在我们深入探讨高级 Java 主题之前,您需要了解 Java 语法的基础知识。虽然这部分内容可能看起来很简单,但您会发现您需要在您的应用程序中反复使用本章中展示的技术和语法。
如您在第一章 入门中看到的,Java 的语法大量借鉴了 C 和 C++。这同样适用于控制程序流程的条件语句。Java,像大多数计算机语言一样,允许您这样做。本章涵盖了 Java 语言的基本语法,特别是您控制应用程序流程的方法。
本章,以及下一章关于面向对象编程的章节,将使您对 Java 程序的工作方式有良好的实际了解。您将能够承担更高级的 API 和主题。通过这些基本材料,您将准备好过渡到更复杂的代码。
控制程序的流程
想象一下从您的电子钱包中支付账单。只有当您的电子钱包中的信用余额大于或等于账单金额时,您才能进行支付。以下流程图显示了可以实现的简单逻辑:

图 2.1:if-else 语句的代表性流程图
在这里,信用额度决定了程序的执行路径。为了便于此类场景,Java 使用if语句。
使用if语句,当且仅当特定条件为真时,您的应用程序将执行一段代码。在以下代码中,如果happy变量为true,则紧随if语句之后的代码块将执行。如果happy变量不是true,则紧随if语句之后的代码块将不会执行。
boolean happy = true;// initialize a Boolean variable as true
if (happy) //Checks if happy is true
System.out.println("I am happy.");
练习 1:创建基本的 if 语句
在大多数软件行业中,您只负责代码的一个模块,并且您可能已经知道变量中存储的值。在这种情况下,您可以使用if语句和print语句。在这个练习中,使用if语句检查分配给变量的值是true还是false:
-
为本章和其他章节的示例创建一个目录。文件夹命名为
sources。 -
在 IntelliJ 中,从文件菜单中选择
文件->新建->项目。 -
在
新建项目对话框中,选择Java项目。点击下一步。 -
打开复选框以从模板创建项目。点击
命令行应用程序。点击下一步。 -
将项目命名为
chapter02。 -
对于项目位置,点击带有三个点(
…)的按钮,然后选择您之前创建的sources文件夹。 -
删除基本包名,使此条目为空。您将在第六章的库、包和模块中使用 Java 包。
-
点击
完成。IntelliJ 将创建一个名为
chapter02的项目,并在chapter02内部创建一个src文件夹。这是您的 Java 代码将存放的地方。IntelliJ 还会创建一个名为Main的类:public class Main { public static void main(String[] args) { // write your code here } }将名为
Main的类重命名为Exercise01。(我们将在本章创建许多小示例。) -
在文本编辑器窗口中双击单词
Main,然后右键单击它。 -
从上下文菜单中选择
重构|重命名…,输入Exercise01,然后按Enter。您现在将看到以下代码:
public class Exercise01 { public static void main(String[] args) { // write your code here } } -
在
main()方法中,定义两个布尔变量happy和sad:boolean happy = true; boolean sad = false; -
现在,创建两个
if语句,如下所示:if (happy) System.out.println("I am happy."); // Usually put the conditional code into a block. if (sad) { // You will not see this. System.out.println("The variable sad is true."); }最终的代码应类似于以下内容:
public class Exercise01 { public static void main(String[] args) { boolean happy = true; boolean sad = false; if (happy) System.out.println("I am happy."); // Usually put the conditional code into a block. if (sad) { // You will not see this. System.out.println("The variable sad is true."); } } } -
点击位于文本编辑器窗口左侧、指向类名
Exercise01的绿色箭头。选择第一个菜单选项,运行Exercise01.main()。 -
在
运行窗口中,您将看到您的 Java 程序路径,然后是以下输出:I am happy.
我很高兴。这一行来自第一个if语句,因为happy布尔变量为真。
注意,第二个if语句没有执行,因为sad布尔变量为假。
您几乎总是想使用花括号来定义if条件后面的代码块。如果不这样做,您可能会在程序中发现奇怪的错误。例如,在以下代码中,将i变量设置为零的第二条语句将始终执行:
if (i == 5)
System.out.println("i is 5");
i = 0;
与 Python 等语言不同,Java 中缩进不计。以下代码将更清晰地显示实际执行的内容:
if (i == 5) {
System.out.println("i is 5");
}
i = 0;
最后一行总是执行,因为它在花括号关闭后的if语句之外。
比较运算符
除了 Java 的布尔值外,您还可以在条件语句中使用比较。这些比较必须形成一个解析为true或false的布尔表达式。比较运算符允许您通过比较值来构建布尔表达式。Java 的主要比较运算符包括以下内容:

图 2.2:Java 中的比较运算符
比较运算符(如==)对于文本值的工作方式可能不符合您的预期。请参阅本章后面的比较字符串部分,了解如何比较文本值。
注意
单个等号=用于赋值。两个等号==用于比较值。因此,通常您永远不会在布尔表达式中使用=来检查条件。
练习 2:使用 Java 比较运算符
一个在线零售店仅在目的地距离商店 10 公里(km)范围内提供免费送货。根据最近商店位置和家的距离,我们可以使用比较运算符来编写此业务逻辑:
-
在 IntelliJ 的
项目面板中,右键单击名为src的文件夹。 -
从菜单中选择
新建->Java 类。 -
将新类的名称输入为
Exercise02。 -
定义名为
main()的方法:public static void main(String[] args) { } -
在
main()方法内部,定义我们将用于比较的变量:int maxDistance = 10; // km int distanceToHome = 11; -
在变量声明后输入以下
if语句:if (distanceToHome > maxDistance) { System.out.println("Distance from the store to your home is"); System.out.println(" more than " + maxDistance + "km away."); System.out.println("That is too far for free delivery."); } if (distanceToHome <= maxDistance) { System.out.println("Distance from the store to your home is"); System.out.println(" within " + maxDistance + "km away."); System.out.println("You get free delivery!"); }最终代码应类似于以下链接所示:
packt.live/32Ca9YS -
使用左侧的绿色箭头运行
Exercise02程序。在
运行窗口中,您将看到您的 Java 程序路径,然后是以下输出:Distance from the store to your home is more than 10km away. That is too far for free delivery.
嵌套if语句
嵌套意味着在另一个代码结构中嵌入一个结构。您可以在任何代码块中嵌套if语句,包括在if语句之后的代码块。以下是一个嵌套if语句中逻辑如何评估的示例:

图 2.3:嵌套 if-else 语句的代表流程图
练习 3:实现嵌套if语句
在以下练习中,我们将嵌套一个if语句在另一个if语句中,以检查车辆的速度是否超过限速,如果是这样,是否超过可罚款的速度:
-
使用前一个练习中的技术,创建一个名为
Exercise03的新类。 -
将
speed、speedForFine和maxSpeed变量分别声明为75、70和60:public class Exercise03 { public static void main(String[] args) { int speed = 75; int maxSpeed = 60; int speedForFine = 70; } } -
创建一个嵌套
if语句,其中外层if语句检查速度是否大于或等于最大限速,内层循环检查速度是否大于或等于罚款限速:// Nested if statements. if (speed >= maxSpeed) { System.out.println("You're over the speed limit!"); if (speed >= speedForFine) { System.out.println("You are eligible for a fine!"); } -
使用左侧的绿色箭头运行
Exercise03程序。在
运行窗口中,您将看到您的 Java 程序路径,然后是以下输出:You're over the speed limit! You are eligible for a fine!注意
尝试更改代码中的速度值,然后再次运行程序。您将看到不同的速度值会产生不同的输出。
使用 if 和 else 进行双向分支
如果if语句的条件不成立,则执行if语句代码块后面的else语句。您还可以使用else if语句提供额外的测试。
基本语法如下:
if (speed > maxSpeed) {
System.out.println("Your speed is greater than the max. speed limit");
} else if (speed < maxSpeed) {
System.out.println("Your speed is less than the max. speed limit");
} else {
System.out.println("Your speed is equal to the max. speed limit");
}
第三行(在 else 块中)只有在前两行(if 或 else if 代码块)都不为真时才会打印。无论速度的值如何,只有一行会打印。
练习 4:使用 if 和 else 语句
一个公平贸易咖啡烘焙商如果你订购超过 5 公斤的整咖啡豆,将提供 10% 的折扣,如果你订购超过 50 公斤,将提供 15% 的折扣。我们将使用 if、else if 和 else 语句来编写这些业务规则:
-
使用之前练习中的技术,创建一个名为
Exercise04的新类。 -
进入
main方法,并按以下方式声明变量:public static void main(String[] args) { int noDiscount = 0; int mediumDiscount = 10; // Percent int largeDiscount = 15; int mediumThreshold = 5; // Kg int largeThreshold = 50; int purchaseAmount = 40; } -
输入以下
if、else if和else语句:if (purchaseAmount >= largeThreshold) { System.out.println("You get a discount of " + largeDiscount + "%"); } else if (purchaseAmount >= mediumThreshold) { System.out.println("You get a discount of " + mediumDiscount + "%"); } else { // Sorry System.out.println("You get a discount of " + noDiscount + "%"); }注意,我们首先检查最大的阈值。这样做的原因是,大于或等于
largeThreshold的值也将大于或等于mediumThreshold。注意
本练习的完整源代码可以在以下网址找到:
packt.live/33UTu35。 -
使用左侧的绿色箭头运行
Exercise04程序。在 Run 窗口中,你会看到你的 Java 程序的路径,然后是以下输出:
You get a discount of 10%
使用复杂条件
Java 允许你使用逻辑运算符创建复杂的条件语句。逻辑运算符通常只用于布尔值。以下是 Java 中可用的逻辑运算符:
-
AND (&&): 如果a和b都为true,则a&&b将评估为true -
OR (||): 如果a或b,或两者都为true,则a||b将评估为true -
NOT (!): 如果a是false,则!a将评估为true
使用条件运算符在 if 语句中检查多个条件。例如,以下是一个 if 语句,其中两个条件都必须为真,整体 if 语句才会执行:
boolean red = true;
boolean blue = false;
if ((red) && (blue)) {
System.out.println("Both red AND blue are true.");
}
在这种情况下,整体表达式解析为 false,因为 blue 变量是 false,print 语句将不会执行。
注意
总是使用括号来通过将条件分组使你的条件更清晰。
你也可以使用 || 运算符来检查表达式是否为真:
boolean red = true;
boolean blue = false;
if ((red) || (blue)) {
System.out.println("Either red OR blue OR both are true.");
}
在这种情况下,整体表达式解析为 true,因为至少有一部分是 true。因此,print 语句将执行:
boolean blue = false;
if (!blue) {
System.out.println("The variable blue is false");
}
blue 的值初始化为 false。由于我们在 if 语句中检查 blue 变量的非,print 语句将执行。以下练习展示了我们可以如何使用逻辑运算符。
练习 5:使用逻辑运算符创建复杂条件
这个练习展示了之前描述的每个条件运算符的示例。你正在编写一个处理健身追踪器数据的程序。为了完成这个任务,你需要编写一个检查运动期间正常心率的代码。
如果一个人是 30 岁,正常心率应在每分钟 95 次(bpm)到 162 次(bpm)之间。如果一个人是 60 岁,正常心率应在每分钟 80 次到 136 次(bpm)之间。
使用以下步骤来完成:
-
使用前一个练习中的技术,在
main方法中创建一个名为Exercise05的新类并声明变量。public static void main(String[] args) { int age = 30; int bpm = 150; } -
创建一个
if语句来检查 30 岁人的心率:if (age == 30) { if ((bpm >= 95) && (bpm <= 162)) { System.out.println("Heart rate is normal."); } else if (bpm < 95) { System.out.println("Heart rate is very low."); } else { System.out.println("Heart rate is very high."); }我们有嵌套条件来检查 30 岁人的允许范围。
-
创建一个
else if语句来检查 60 岁人的心率:} else if (age == 60) { if ((bpm >= 80) && (bpm <= 136)) { System.out.println("Heart rate is normal."); } else if (bpm < 80) { System.out.println("Heart rate is very low."); } else { System.out.println("Heart rate is very high."); } }我们有嵌套条件来检查 60 岁人的允许范围。
-
使用左侧的绿色箭头运行
Exercise05程序。在
Run窗口中,你会看到你的 Java 程序路径,然后是以下输出:Heart rate is normal. -
将
age改为60并重新运行程序;你的输出应该是以下内容:Heart rate is very high.注意
本练习的完整源代码可以在以下链接找到:
packt.live/2W3YAHs。
在 if 条件中使用算术运算符
你也可以在布尔表达式中使用算术运算符,如Example01.java所示:
public class Example01 {
public static void main(String[] args) {
int x = 2;
int y = 1;
if ((x + y) < 5) {
System.out.println("X added to Y is less than 5.");
}
}
}
在这种情况下,输出将是以下内容:
X added to Y is less than 5
在这里,(x + y)的值被计算,然后结果与5进行比较。因此,由于x加上y的结果是3,小于5,条件为真。因此,执行print语句。现在我们已经看到了if else语句的变体,我们将现在看看如何使用三元运算符来表示if else语句。
三元运算符
Java 允许使用三元(或三部分)运算符?:的简写形式来表示if else语句。这通常用于检查变量是否在允许的最大(或最小)值范围内。
基本格式是:布尔表达式 ? true 块 : false 块,如下所示:
x = (x > max) ? max : x;
JVM 解析(x > max)布尔表达式。如果为真,则表达式返回问号后面的值。在这种情况下,该值将被设置为x变量,因为代码行以赋值开始,x =。如果表达式解析为假,则返回冒号后面的值。
练习 6:使用三元运算符
考虑过山车最小身高要求为 121 厘米(cm)。在这个练习中,我们将使用三元运算符来检查这个条件。执行以下步骤:
-
使用前一个练习中的技术,创建一个名为
Exercise06的新类。 -
声明并赋值给
height和minHeight变量。同时,声明一个字符串变量以打印输出信息:public static void main(String[] args) { int height = 200; int minHeight = 121; String result; -
使用三元运算符检查最小身高要求并设置
result的值:result = (height > minHeight) ? "You are allowed on the ride" : "Sorry you do not meet the height requirements"; System.out.println(result); }因此,如果高度大于
minHeight,将返回第一个语句(You are allowed on the ride)。否则,将返回第二个语句(Sorry you do not meet the height requirements)。你的代码应该类似于以下内容:
public class Exercise06 { public static void main(String[] args) { int height = 200; int minHeight = 121; String result; result = (height > minHeight) ? "You are allowed on the ride" : "Sorry you do not meet the height requirements"; System.out.println(result); } } -
运行
Exercise06程序。在
Run窗口中,你会看到你的 Java 程序的路径,然后是以下输出:You are allowed on the ride
相等可能很棘手
Java 的十进制类型,如 float 和 double(以及对象版本 Float 和 Double),在内存中的存储方式不适用于常规的相等性检查。
当比较十进制值时,你通常需要定义一个表示你认为足够接近的值。例如,如果两个值之间的差异在 .001 以内,那么你可能觉得足够接近,可以认为这两个值相等。
练习 7:比较十进制值
在这个练习中,你将运行一个程序,该程序检查两个双精度值是否足够接近,可以被认为是相等的:
-
使用前一个练习中的技术,创建一个名为
Exercise07的新类。 -
输入以下代码:
public class Exercise07 { public static void main(String[] args) { double a = .6 + .6 + .6 + .6 + .6 + .6; double b = .6 * 6; System.out.println("A is " + a); System.out.println("B is " + b); if (a != b) { System.out.println("A is not equal to B."); } // Check if close enough. if (Math.abs(a - b) < .001) { System.out.println("A is close enough to B."); } } }Math.abs()方法返回输入的绝对值,确保输入是正数。我们将在第六章库、包和模块中学习更多关于
Math包的内容。 -
使用左侧的绿色箭头运行
Exercise07程序。在运行窗口中,你会看到你的 Java 程序的路径,然后是以下输出:
A is 3.6 B is 3.5999999999999996 A is not equal to B. A is close enough to B.注意
a和b由于双精度类型的内部存储而有所不同。注意
关于 Java 表示浮点数的更多信息,请参阅
packt.live/2VZdaQy。
比较字符串
在 Java 中,你不能使用 == 来比较两个字符串。相反,你需要使用 String 类的 equals 方法。这是因为 == 与 String 对象只是检查它们是否是同一个对象。你通常想要检查字符串值是否相等:
String cat = new String("cat");
String dog = new String("dog");
if (cat.equals(dog)) {
System.out.println("Cats and dogs are the same.");
}
在名为 cat 的 String 对象上的 equals 方法,如果传入的 String,dog,与第一个 String 的值相同,则返回 true。在这种情况下,这两个字符串不同。因此,布尔表达式将解析为 false。
你也可以在 Java 中使用字面字符串,用双引号界定这些字符串。以下是一个示例:
if (dog.equals("dog")) {
System.out.println("Dogs are dogs.");
}
此情况比较一个名为 dog 的 String 变量与字面字符串 "dog"。
Example09 展示了如何调用 equals 方法:
Example09.java
15 if (dog.equals(dog)) {
16 System.out.println("Dogs are dogs.");
17 }
18
19 // Using literal strings
20 if (dog.equals("dog")) {
21 System.out.println("Dogs are dogs.");
22 }
23
24 // Can compare using a literal string, too.
25 if ("dog".equals(dog)) {
26 System.out.println("Dogs are dogs.");
https://packt.live/2BtrKGz
你应该得到以下输出:
Cats and dogs are not the same.
Dogs are dogs.
Dogs are dogs.
Dogs are dogs.
使用 switch 语句
switch 语句类似于一系列嵌套的 if-else-if 语句。使用 switch,你可以从一组值中选择。
基本语法如下:
switch(season) {
case 1: message = "Spring";
break;
case 2: message = "Summer";
break;
case 3: message = "Fall";
break;
case 4: message = "Winter";
break;
default: message = "That's not a season";
break;
}
使用 switch 关键字,放置要检查的变量。在这种情况下,我们正在检查一个名为 season 的变量。每个 case 语句代表 switch 变量(季节)的一个可能值。如果 season 的值为 3,则将执行匹配的 case 语句,将 message 变量设置为字符串 Fall。break 语句结束该情况的执行。
default 语句用于捕获任何不符合定义情况的意外值。最佳实践是始终包含一个 default 语句。让我们看看如何在程序中实现这种逻辑。
练习 8:使用 switch
在这个练习中,你将运行一个将数字映射到季节的程序:
-
使用前一个练习中的技术,创建一个名为
Exercise08的新类。 -
在
main()方法中输入并设置以下变量:public static void main(String[] args) { int season = 3; String message; } -
输入以下
switch语句。switch(season) { case 1: message = "Spring"; break; case 2: message = "Summer"; break; case 3: message = "Fall"; break; case 4: message = "Winter"; break; default: message = "That's not a season"; break; } -
并输入一个
println语句以显示结果:System.out.println(message);注意
你可以在此处找到该练习的代码:
packt.live/35WXm58。 -
使用左侧的绿色箭头运行
Exercise08程序。在
Run窗口中,你会看到你的 Java 程序的路径,然后是以下输出:Fall因为
season变量被设置为3,所以 Java 执行了值为3的case,因此在这种情况下,将message变量设置为字符串Fall。注意
没有一条规则可以决定何时使用
switch语句而不是一系列的if-else语句。在许多情况下,你的选择将基于代码的清晰度。此外,switch语句仅限于具有单个值的case,而if语句可以测试更复杂的条件。
通常,你会在特定情况的代码后放置一个 break 语句。你不必这样做。代码将从 case 的开始处继续执行,直到下一个 break 语句。这允许你以类似的方式处理多个条件。
练习 9:允许情况自然过渡
在这个练习中,你将为《金发姑娘和三只熊》中的粥确定温度调整。例如,如果粥太热,你需要降低温度。如果太冷,则提高温度:
-
使用前一个练习中的技术,创建一个名为
Exercise09的新类。 -
在
main()方法中输入并设置以下变量:public static void main(String[] args) { int tempAdjustment = 0; String taste = "way too hot"; } -
接下来,输入以下
switch语句:switch(taste) { case "too cold": tempAdjustment += 1; break; case "way too hot": tempAdjustment -= 1; case "too hot": tempAdjustment -= 1; break; case "just right": // No adjustment default: break; } -
打印出结果:
System.out.println("Adjust temperature: " + tempAdjustment); -
使用左侧的绿色箭头运行
Exercise09程序。在运行窗口中,你会看到你的 Java 程序的路径,然后是以下输出:
Adjust temperature: -2仔细查看
switch语句。如果味道变量的值太冷,则将温度增加 1。如果值太热,则将温度减少 1。但请注意,没有 break 语句,所以代码会继续执行并再次将温度降低 1。这意味着如果粥太热,温度会减少 1。如果非常热,则减少 2。如果粥正好合适,则不需要调整。注意
从 Java 7 开始,您可以在
switch语句中使用字符串。在 Java 7 之前,您不能这样做。
使用 Java 12 增强型 switch 语句
Java 12 提供了 switch 语句的新形式。旨在用于确定变量值的 switch 语句,新的 switch 语法允许您将包含 switch 结果的变量分配给值。
新的语法看起来像这样:
int tempAdjustment = switch(taste) {
case "too cold" -> 1;
case "way too hot" -> -2;
case "too hot" -> -1;
case "just right" -> 0;
default -> 0;
};
这种 switch 语法不使用 break 语句。相反,对于给定的 case,只有 -> 之后的代码块会被执行。然后,该代码块中的值作为 switch 语句的值返回。
我们可以使用新语法重写 Exercise09 示例,如下所示练习所示。
注意
IntelliJ 需要配置以支持 Java 12 switch 语句。
练习 10:使用 Java 12 switch 语句
在这个练习中,我们将使用与上一个练习相同的示例。不过,这次我们将实现 Java 12 提供的新 switch case 语法。在我们开始编写程序之前,您需要修改 IntelliJ 的配置。我们将在练习的初始几个步骤中设置它:
-
从
运行菜单中选择编辑配置。 -
点击
编辑模板。 -
点击
应用程序。 -
将以下内容添加到
VM选项中:--enable-preview -
点击
确定。这将启用 IntelliJ 对 Java 12 增强型 switch 语句的支持。
-
使用前一个练习中的技术,创建一个名为
Exercise10的新类。 -
在
main()方法中输入并设置此变量:public static void main(String[] args) { String taste = "way too hot"; } -
按如下方式定义
switch语句:int tempAdjustment = switch(taste) { case "too cold" -> 1; case "way too hot" -> -2; case "too hot" -> -1; case "just right" -> 0; default -> 0; };注意
switch后的分号。记住,我们是在整个语句中将变量分配给值。 -
然后打印出选定的值:
System.out.println("Adjust temperature: " + tempAdjustment); -
当您运行此示例时,您应该看到与上一个示例相同的输出:
Adjust temperature: -2完整代码如下:
public class Exercise10 { public static void main(String[] args) { String taste = "way too hot"; int tempAdjustment = switch(taste) { case "too cold" -> 1; case "way too hot" -> -2; case "too hot" -> -1; case "just right" -> 0; default -> 0; }; System.out.println("Adjust temperature: " + tempAdjustment); } }
循环和执行重复性任务
在本章中,我们将介绍使用循环执行重复性任务。主要的循环类型如下:
-
for循环 -
while循环 -
do-while循环
for 循环重复执行一定次数的块。当您确定需要多少次迭代时,请使用 for 循环。for 循环的新形式遍历集合中的每个项目。
while 循环在给定条件为真时执行一个块。当条件变为假时,while 循环停止。同样,do-while 循环执行一个块并检查一个条件。如果条件为真,则 do-while 循环运行下一次迭代。
如果你不确定需要多少次迭代,请使用 while 循环。例如,当搜索数据以找到特定元素时,你通常会在找到它时停止。
如果你总是想执行一个块,然后才检查是否需要另一个迭代,请使用 do-while 循环。
使用 for 循环进行循环
for 循环会执行相同块代码给定次数。语法来自 C 语言:
for(set up; boolean expression; how to increment) {
// Execute these statements…
}
在前面的代码中,我们可以看到:
-
每个部分都由分号(
;)分隔。 -
set up部分在整个 for 循环的开始处执行。它只运行一次。 -
在每次迭代(包括第一次)都会检查
boolean expression。只要这个表达式解析为 true,循环就会执行另一个迭代。 -
how to increment部分定义了你想如何增加循环变量。通常,你会在每次迭代时加一。
以下练习将在 Java 中实现一个经典的 for 循环。
练习 11:使用经典 for 循环
这个练习将使用经典的 for 循环语法运行 for 循环四次:
-
使用前一个练习中的技术,创建一个名为
Exercise11的新类。 -
输入一个
main()方法以及以下代码:public static void main(String[] args) { for (int i = 1; i < 5; i++) { System.out.println("Iteration: " + i); } } -
使用绿色箭头左边的绿色箭头运行
Exercise11程序。在
Run窗口中,你会看到你的 Java 程序的路径,然后是以下输出:Iteration: 1 Iteration: 2 Iteration: 3 Iteration: 4
这是程序执行的方式:
-
int i = 1是for循环的设置部分。 -
每次迭代都会检查的布尔表达式是
i < 5。 -
how to increment部分告诉for循环使用++运算符在每个迭代中加一。 -
对于每次迭代,括号内的代码都会执行。它会一直这样执行,直到布尔表达式停止为
true。
除了旧的经典 for 循环之外,Java 还提供了一个增强型 for 循环,用于遍历集合和数组。
我们将在本书的后面部分更详细地介绍数组和集合;目前,你可以将数组想象成存储在单个变量中的相同数据类型值的集合,而集合则是存储在单个变量中的不同数据类型值的集合。
练习 12:使用增强型 for 循环
遍历数组元素意味着增量值始终为 1,起始值始终为 0。这使得 Java 能够减少遍历数组的语法。在这个练习中,你将遍历 letters 数组中的所有项:
-
使用前一个练习中的技术,创建一个名为
Exercise12的新类。 -
输入一个
main()方法:public static void main(String[] args) { } -
输入以下数组:
String[] letters = { "A", "B", "C" };第四章,集合、列表和 Java 内置 API,将更深入地介绍数组语法。目前,我们有一个包含三个
String值的数组,A、B和C。 -
输入一个增强型
for循环:for (String letter : letters) { System.out.println(letter); }注意
for循环的简化语法。在这里,变量 letter 遍历 letters 数组中的每个元素。 -
使用绿色箭头左边的绿色箭头运行
Exercise12程序。在
Run窗口中,你会看到你的 Java 程序的路径,然后是以下输出:A B C
使用 Break 和 Continue 跳出循环
与我们在 switch 示例中看到的 break 语句一样,break 语句会完全跳出循环。将不再发生更多的迭代。
continue 语句会跳出循环的当前迭代。然后 Java 将评估循环表达式以进行下一次迭代。
练习 13:使用 break 和 continue
这个练习展示了如何使用 break 跳出循环,或者使用 continue 跳到下一次迭代:
-
使用上一练习中的技术,创建一个名为
Exercise13的新类。 -
输入一个
main()方法:public static void main(String[] args) { } -
定义一个稍长的
String值数组:String[] letters = { "A", "B", "C", "D" }; -
输入以下 for 循环:
for (String letter : letters) { }这个循环通常会迭代四次,每次迭代一个
letters数组中的字母。但我们将通过下一行代码改变这一点。 -
在循环中添加一个条件:
if (letter.equals("A")) { continue; // Jump to next iteration }使用
continue的意思是,如果当前字母等于A,则跳到下一次迭代。剩余的循环代码将不会执行。 -
接下来,我们将打印出当前字母:
System.out.println(letter);对于所有到达这里的迭代,你将看到当前字母被打印出来。
-
使用
break完成循环:if (letter.equals("C")) { break; // Leave the for loop }如果
letter的值是C,则代码将完全跳出循环。由于我们的字母数组中还有另一个值D,所以我们根本看不到那个值。当letter的值为C时,循环结束。 -
使用左侧的绿色箭头运行
Exercise13程序。在
Run窗口中,你会看到你的 Java 程序的路径,然后是以下输出:B C
Exercise13.java 包含完整的示例:
注意
练习 13 的源代码可以在以下链接找到:packt.live/2MDczAV。
使用 while 循环
在许多情况下,你事先不知道需要多少次迭代。在这种情况下,使用 while 循环而不是 for 循环。
while 循环会重复,只要(或 while)布尔表达式解析为真:
while (boolean expression) {
// Execute these statements…
}
与 for 循环类似,你通常会使用一个变量来计数迭代。尽管如此,你不必这样做。你可以使用任何布尔表达式来控制 while 循环。
练习 14:使用 while 循环
这个练习实现了一个与 Exercise10 类似的循环,它展示了 for 循环:
-
使用上一练习中的技术,创建一个名为
Exercise14的新类。 -
输入一个
main()方法:public static void main(String[] args) { } -
输入以下变量设置和
while循环:int i = 1; while (i < 10) { System.out.println("Odd: " + i); i += 2; }注意这个循环每次递增
i变量两次。这会导致打印出奇数。 -
使用左侧的绿色箭头运行
Exercise14程序。在
Run窗口中,你会看到你的 Java 程序的路径,然后是以下输出:Odd: 1 Odd: 3 Odd: 5 Odd: 7 Odd: 9注意
常见的一个错误是忘记在布尔表达式中使用的变量递增。
使用 do-while 循环
do-while循环提供了对while循环的一种变体。与先检查条件不同,do-while循环在每个迭代后检查条件。这意味着使用do-while循环时,你将始终至少有一个迭代。通常,只有当你确定你想要迭代块在第一次执行,即使条件为假时,你才会使用do-while循环。
do-while循环的一个示例用法是如果你正在向用户提出一系列问题,然后读取用户的响应。你总是想先问第一个问题。
基本格式如下:
do {
// Execute these statements…
} while (boolean expression);
注意布尔表达式后面的分号。
do-while循环先执行迭代块一次,然后检查布尔表达式以确定是否应该运行另一个迭代。
Example17.java展示了do-while循环:
public class Example17 {
public static void main(String[] args) {
int i = 2;
do {
System.out.println("Even: " + i);
i += 2;
} while (i < 10);
}
}
此示例打印出偶数。
注意
你也可以在while和do-while循环中使用break和continue。
处理命令行参数
命令行参数是传递给 Java 程序main()方法的参数。到目前为止的每个示例中,你都看到了main()方法接受一个String值的数组。这些是程序的命令行参数。
命令行参数通过提供一种向程序提供输入的方式证明了它们的有用性。这些输入是启动程序时命令行的一部分,当从终端 shell 窗口运行时。
练习 15:测试命令行参数
此练习展示了如何将命令行参数传递给 Java 程序,并展示了如何在程序内部访问这些参数:
-
使用前一个练习中的技术,创建一个名为
Exercise15的新类。 -
输入以下代码:
public class Exercise15 { public static void main(String[] args) { for (int i = 0; i < args.length; i++) { System.out.println(i + " " + args[i]); } } }此代码使用
for循环遍历所有命令行参数,这些参数由java命令放入名为args的String数组中。每次迭代都会打印出参数的位置(
i)和值(args[i])。请注意,Java 数组从 0 开始计数位置,args.length包含args数组中的值数量。要运行此程序,我们将采取与之前不同的方法。
-
在 IntelliJ 应用程序的底部,点击
Terminal。这将显示一个命令行 shell 窗口。当使用 IntelliJ 进行这些示例时,代码存储在名为
src的文件夹中。 -
在
Terminal窗口中输入以下命令:cd src这将切换到包含示例源代码的文件夹。
-
输入
javac命令来编译 Java 程序:javac Exercise15.java此命令在当前目录中创建一个名为
Exercise15.class的文件。IntelliJ 通常将这些.class文件放入不同的文件夹。 -
现在,使用带有你想要传递的参数的
java命令运行程序:java Exercise15 cat dog wombat在这个命令中,
Exercise15是具有main()方法的 Java 类名,Exercise15。命令行上Exercise15之后的值作为命令行参数传递给Exercise15应用程序。每个参数由一个空格字符分隔,所以我们有三个参数:cat、dog和wombat。 -
你将看到以下输出:
0 cat 1 dog 2 wombat第一个参数位于
args数组的位置0,是cat。位置1的参数是dog,位置2的参数是wombat。注意
运行编译后的 Java 程序的
java命令支持一组命令行参数,例如定义可用的堆内存空间。有关控制 Java 程序执行的命令行参数的详细信息,请参阅 Oracle Java 文档packt.live/2BwqwdJ。
转换命令行参数
命令行参数在 Java 程序中以字符串值的形式出现。然而,在许多情况下,你将希望将这些字符串值转换为数字。
如果你期望一个整数值,你可以使用Integer.parseInt()将一个String转换为int。
如果你期望一个双精度值,你可以使用Double.parseDouble()将一个String转换为double。
练习 16:将字符串转换为整数和双精度浮点数
这个练习提取命令行参数并将它们转换为数字:
-
使用前一个练习中的技术,创建一个名为
Exercise16的新类。 -
输入
main()方法:public class Exercise16 { public static void main(String[] args) { } } -
输入以下代码将第一个参数转换为
int值:if (args.length > 0) { int intValue = Integer.parseInt(args[0]); System.out.println(intValue); }这段代码首先检查是否有命令行参数,然后如果有,将
String值转换为int。 -
输入以下代码将第二个参数转换为
double值:if (args.length > 1) { double doubleValue = Double.parseDouble(args[1]); System.out.println(doubleValue); }这段代码检查是否有第二个命令行参数(从 0 开始计数)并且如果有,将
String转换为double值。 -
输入第一章,入门中介绍的
javac命令来编译 Java 程序:javac Exercise16.java这个命令在当前目录中创建一个名为
Exercise16.class的文件。 -
现在,使用
java命令运行程序:java Exercise16 42 65.8你将看到以下输出:
42 65.8打印出的值已经将字符串值转换为程序内部的数字。这个例子没有尝试捕获错误,所以你必须正确输入输入。
注意
如果传入的字符串不包含数字,
Integer.parseInt()和Double.parseDouble()都将抛出NumberFormatException。有关异常的更多信息,请参阅第五章,异常。
深入了解变量——不可变性
不可变对象不能修改其值。在 Java 术语中,一旦不可变对象被构造,就不能修改该对象。
不可变性可以为 JVM 提供很多优势,因为 JVM 知道不可变对象不能被修改。这可以真正帮助垃圾回收。当编写使用多个线程的程序时,知道一个对象不能被另一个线程修改可以使你的代码更安全。
在 Java 中,String对象是不可变的。虽然你可能觉得你可以将String赋给不同的值,但实际上,当你尝试更改String时,Java 会创建一个新的对象。
比较 final 和不可变
除了不可变对象之外,Java 还提供了final关键字。使用final后,你不能改变对象引用本身。你可以在final对象内部更改数据,但不能更改引用的对象。
将final与不可变对象进行对比。不可变对象不允许对象内部的数据更改。final对象不允许对象指向另一个对象。
使用静态值
静态变量是类中所有实例共有的。这与仅适用于类的一个实例(或对象)的实例变量不同。例如,Integer类的每个实例可以持有不同的int值。但是,在Integer类中,MAX_VALUE和MIN_VALUE是静态变量。这些变量为所有整数的实例定义一次,使它们本质上成为全局变量。
注意
第三章,面向对象编程,深入探讨了类和对象。
静态变量通常用作常量。为了保持它们的常量状态,你通常希望将它们定义为final:
public static final String MULTIPLY = "multiply";
注意
按照惯例,Java 常量的名称全部为大写。
Example20.java定义了一个常量MULTIPLY:
public class Example20 {
public static final String MULTIPLY = "multiply";
public static void main(String[] args) {
System.out.println("The operation is " + MULTIPLY);
}
}
由于MULTIPLY常量是一个 final 值,如果你的代码尝试在设置后更改该值,你将得到编译错误。
使用局部变量类型推断
Java 是一种静态类型语言,这意味着每个变量和每个参数都有一个定义的类型。随着 Java 提供了创建更复杂类型的能力,特别是与集合相关,Java 的变量类型语法变得越来越复杂。为了帮助解决这个问题,Java 10 引入了局部变量类型推断的概念。
使用这种方式,你可以声明一个var类型的变量。只要完全清楚变量的实际类型,Java 编译器就会为你处理这些细节。以下是一个示例:
var s = new String("Hello");
此示例为s变量创建了一个新的String。尽管s是用var关键字声明的,但s实际上是String类型。也就是说,此代码等同于以下代码:
String s = new String("Hello");
仅使用String类型,这并不能节省你多少打字。然而,当你遇到更复杂类型时,你将真正感激var关键字的使用。
注意
第四章,集合、列表和 Java 内置 API,涵盖了集合,在那里你会看到非常复杂的数据类型。
Example21.java展示了局部变量类型推断的实际应用:
public class Example21 {
public static void main(String[] args) {
var s = new String("Hello");
System.out.println("The value is " + s);
var i = Integer.valueOf("42");
System.out.println("The value is " + i);
}
}
当你运行此示例时,你将看到以下输出:
The value is Hello
The value is 42
活动一:输入和比较范围
你被要求编写一个程序,该程序接受患者的血压作为输入,然后确定该血压是否在理想范围内。
血压有两个组成部分,即收缩压和舒张压。
根据 packt.live/2oaVsgs,理想收缩压数值应大于 90 且小于 120。90 以下为低血压。120 以上至 140 为高血压前期,140 以上为高血压。
理想舒张压的范围是 60 到 80。60 以下为低血压。80 以上至 90 以下为高血压前期,90 以上为高血压。

图 2.4:收缩压和舒张压的理想范围
为了进行这项活动,如果任一数值超出理想范围,则报告为非理想血压:
-
编写一个应用程序,接受两个数值,即收缩压和舒张压。将两个输入转换为
int值。 -
在程序开始时检查输入的数量是否正确。如果缺少任何输入,则打印错误信息。在这种情况下退出应用程序。
-
与之前提到的理想值进行比较。输出一条消息,描述输入为低血压、理想血压、高血压前期或高血压。
要打印错误信息,请使用
System.err.println而不是System.out.println。 -
使用各种输入尝试你的程序,以确保其正常工作。
你需要使用 IntelliJ 的终端面板通过命令行输入编译和运行程序。回顾练习 15 和 16 了解如何操作的详细信息。
-
血压通常报告为收缩压/舒张压。
注意
本活动的解决方案可在第 533 页找到。
概述
本章涵盖了大量的 Java 语法——你需要学习这些内容才能处理更高级的主题。你会发现自己在编写的每一个 Java 应用程序中都会用到这些技术。
我们首先通过使用条件语句如 if、else if、else 和 switch 语句来控制程序的流程。然后我们转向不同的循环,这些循环可以用来执行重复性任务。在此之后,我们探讨了如何在运行时使用命令行参数提供值。这是将输入传递到 Java 应用程序的一种方法。本章中的每个示例都创建了一个类,但我们还没有对这些类做太多的事情。
在下一章中,你将学习关于类、方法和面向对象编程的知识,以及如何通过类做更多的事情。
第三章:3. 面向对象编程
概述
在本章中,我们将探讨 Java 实现面向对象编程(OOP)概念的方式。为此,你将首先练习创建和实例化自己的类,以便你可以在以后创建可以处理其中数据的方法。然后我们将带你了解如何编写递归方法,甚至如何覆盖现有方法以使用你自己的方法。到本章结束时,你将完全准备好重载方法的定义,以适应不同场景,并为同一方法或构造函数提供不同的参数,并注释代码以告知编译器必须采取的特定操作。
简介
Java 类是一个模板,用于定义数据类型。类由携带数据和用于在该数据上执行操作的方法的对象组成。类可以是自包含的,可以扩展其他类以添加新功能,或实现其他类的功能。从某种意义上说,类是类别,允许我们定义可以存储在其中的数据类型,以及处理这些数据的方式。
类在运行时告诉编译器如何构建特定的对象。请参考“Java 中处理对象”主题中关于对象解释的内容。
类定义的基本结构如下所示:
class <name> {
fields;
methods;
}
注意
类名应该以大写字母开头,例如TheClass、Animal、WordCount或任何以某种方式表达类主要目的的字符串。如果包含在单独的文件中,包含源代码的文件名应与类名相同:TheClass.java、Animal.java等。
类的解剖结构
类中包含不同的软件组件。以下示例展示了一个包含一些主要组件的类。
Example01.java
1 class Computer {
2 // variables
3 double cpuSpeed; // in GHz
4
5 // constructor
6 Computer() {
7 cpuSpeed = 0;
8 }
9
10 //methods
11 void setCpuSpeed ( double _cpuSpeed ) {
12 cpuSpeed = _cpuSpeed;
13 }
https://packt.live/32w1ffg
本例的输出结果为:
2.5
Process finished with exit code 0
之前的代码列表展示了名为Computer的基本类的定义,该类包括处理类Computer的一个属性(在这种情况下,cpuSpeed)的变量和方法。代码展示了两个不同的类。第一个是定义程序中Computer类型对象的蓝图。第二个,Example01,是在编译后执行的一个,它将创建一个名为myPC的Computer类的实例。
类中还有一个可选的组件,即构造函数,因为 Java 为所有类提供了一个默认构造函数。cpuSpeed变量具有0的值:
// constructor
Computer() {
cpuSpeed = 0;
}
构造函数也可以有参数。类的构造函数可以是这样的:
// constructor
Computer( double _c ) {
cpuSpeed = _c;
}
这样,你可以使用以下方式调用构造函数:
Computer myPC = new Computer( 2.5 );
这也会需要一个参数。此外,类可以有多个构造函数。这一点将在本章后面进行解释。
Java 中处理对象
对象对于类,就像变量对于数据类型一样。虽然类定义了某种数据类型的结构和可能的行为,但对象是包含该数据的计算机内存中的实际可使用部分。创建对象的动作被称为创建类的实例。从某种意义上说,就像制作一个模板的副本,然后通过访问其变量或方法来修改它。让我们看看它是如何工作的:
Computer myPC = new Computer( 2.5 );
myPC 是实际的对象。我们可以说,在口语中,myPC 是 class Computer 类的对象。
类内的不同字段和方法可以通过输入对象名称后跟一个点号和要处理的变量或方法名称来访问。对变量或方法的任何更改或调用都只在该对象的范围内生效。如果你在程序中有相同类的更多对象,每个对象都会有自己的内存部分。以下是如何调用方法的示例:
myPC.setCpuSpeed( 2.5 );
另一方面,如何调用变量的示例如下所示:
myPC.cpuSpeed = 2.5;
由于 Computer 类的定义方式,最后两个代码示例具有完全相同的效果。整个类默认定义为 public,这意味着类中的所有方法、变量和对象都可以通过之前描述的机制被调用。可能有必要防止用户直接与类内的变量交互,并且只允许通过某些方法进行修改。
类内的不同组件可以定义为 public 或 private。前者将使组件可用,如之前所示,而后者将阻碍其他开发人员访问该部分。以下示例显示了如何将 cpuSpeed 变量定义为 private:
Example02.java
1 class Computer {
2 // variables
3 private double cpuSpeed; // in GHz
4
5 // constructor
6 Computer() {
7 cpuSpeed = 0;
8 }
9
10 // methods
11 void setCpuSpeed ( double _cpuSpeed ) {
12 cpuSpeed = _cpuSpeed;
13 }
14
15 double getCpuSpeed () {
16 return cpuSpeed;
17 }
18 }
https://packt.live/2pBgWTS
该代码列表的结果与之前相同:
2.5
Process finished with exit code 0
如果你尝试从 Example02 类直接访问 cpuSpeed 变量,程序将抛出异常。以下示例显示了这种情况。试一试,看看当尝试访问 private 变量时,调试器是如何通知你的:
Example03.java
20 public class Example03 {
21 public static void main(String[] args) {
22 Computer myPC = new Computer();
23 myPC.setCpuSpeed( 2.5 );
24 System.out.println( myPC.cpuSpeed );
25 }
26 }
https://packt.live/2pvLu9Q
该程序的结果是:
Example03.java:23: error: cpuSpeed has private access in Computer
System.out.println( myPC.cpuSpeed );
1 error
Process finished with exit code 1.
编译器显示的错误是在从 java.lang 继承的 Computer 类中
使用 instanceof 检查类的优先级
你可以检查一个对象是否是特定类的实例。这可以方便地进行错误检查,根据其优先级以不同的方式处理数据,等等。以下示例显示了 checkNumber 方法,它可以区分不同类型的变量,并据此打印不同的消息:
public class Example04 {
public static void checkNumber(Number val) {
if( val instanceof Integer )
System.out.println("it is an Integer");
if( val instanceof Double )
System.out.println("it is a Double");
}
public static void main(String[] args) {
int val1 = 3;
double val2 = 2.7;
checkNumber( val1 );
checkNumber( val2 );
}
}
之前示例的结果是:
it is an Integer
it is a Double
Process finished with exit code 0
练习 1:创建 WordTool 类
WordTool是一个类,可以帮助您对一段文本执行一系列操作,包括计算单词数量、查看字母频率以及搜索特定字符串的出现:
-
打开 IntelliJ 并单击
文件|新建|项目菜单选项:![图 3.1:创建新项目![图片 C13927_03_01.jpg]()
图 3.1:创建新项目
-
展开一个新的界面。默认选项是用于创建 Java 程序。您只需点击
下一步:![图 3.2:新建项目对话框![图片 C13927_03_02.jpg]()
图 3.2:新建项目对话框
-
打开复选框以从模板创建项目。选择
命令行应用程序模板。点击下一步:

图 3.3:从模板创建项目
将项目命名为WordTool。点击完成:

图 3.4:添加项目名称
-
默认情况下,模板调用您的基类
Main。让我们将其更改为WordTool。首先,在新项目中导航到Main.java文件;它在列表中显示为main入口:![图 3.5:模板 Java 程序![图片 C13927_03_05.jpg]()
图 3.5:模板 Java 程序
-
右键单击
Main条目,在下拉列表中,选择重构选项。在其中,选择重命名...:![图 3.6:重构 Java 类![图片 C13927_03_06.jpg]()
图 3.6:重构 Java 类
-
弹出一个对话框。在其中写入类的名称,
WordTool。复选框允许您选择哪些代码部分将被重构以适应新类的名称:![图 3.7:在 IntelliJ 中重命名类![图片 C13927_03_07.jpg]()
图 3.7:在 IntelliJ 中重命名类
-
您会看到类现在被命名为
WordTool,文件名为WordTool.java:![图 3.8:WordTool![图片 C13927_03_08.jpg]()
图 3.8:WordTool
-
为类创建构造函数;在这种情况下,它将是空的:
WordTool() {}; -
添加一个方法来计算字符串中的单词数量:
public int wordCount ( String s ) { int count = 0; // variable to count words // if the entry is empty or is null, count is zero // therefore we evaluate it only otherwise if ( !(s == null || s.isEmpty()) ) { // use the split method from the String class to // separate the words having the whitespace as separator String[] w = s.split("\\s+"); count = w.length; } return count; } -
添加一个方法来计算字符串中的字母数量,并添加带空格和不带空格字符的计数能力:
public int symbolCount ( String s, boolean withSpaces ) { int count = 0; // variable to count symbols // if the entry is empty or is null, count is zero // therefore we evaluate it only otherwise if ( !(s == null || s.isEmpty()) ) { if (withSpaces) { // with whitespaces return the full length count = s.length(); } else { // without whitespaces, eliminate whitespaces // and get the length on the fly count = s.replace(" ", "").length(); } } return count; } -
在
main类中,创建一个WordTool类的对象,并添加一个包含您选择的文本的String变量:WordTool wt = new WordTool(); String text = "The river carried the memories from her childhood."; -
在
main方法中添加代码以打印出WordTool执行的计算:System.out.println( "Analyzing the text: \n" + text ); System.out.println( "Total words: " + wt.wordCount(text) ); System.out.println( "Total symbols (w. spaces): " + wt.symbolCount(text, true) ); System.out.println( "Total symbols (wo. spaces): " + wt.symbolCount(text, false) ); -
运行程序;结果应该如下所示:
Analyzing the text: The river carried the memories from her childhood. Total words: 8 Total symbols (w. spaces): 50 Total symbols (wo. spaces): 43 Process finished with exit code 0注意
您可以使用本练习中介绍的方法,通过使用模板并对它们进行重构以具有示例名称,来创建本书中所有示例的类。之后,您只需将代码复制到新的项目中。
活动一:将符号频率计算添加到 WordTool
向之前创建的WordTool类中添加一个方法来计算特定符号的频率。为此,执行以下步骤:
-
添加一个方法来计算字符串中的单词数量。
-
添加一个方法来计算字符串中的字母数量,并添加区分是否有空格的情况。
-
在
main类中,创建一个WordTool类的对象,并添加一个包含你选择的文本行的字符串变量。 -
在主方法中添加代码以打印出 WordTool 所做的计算。
这个活动的预期结果如下:
Analyzing the text: The river carried the memories from her childhood. Total words: 8 Total symbols (w. spaces): 50 Total symbols (wo. spaces): 43 Total amount of e: 7 Process finished with exit code 0注意
这个活动的解决方案可以在第 534 页找到。
Java 中的继承
继承是面向对象编程的一个关键原则。它涉及将一个类的现有结构,包括其构造函数、变量和方法,转移到另一个类。新的类被称为子类(或子类),而它所继承的那个类被称为父类(或超类)。我们说子类扩展了父类。子类扩展父类的意思是,它不仅继承了父类定义的结构,而且还创建了新的结构。以下是一个父类示例以及子类如何通过向其添加新方法来扩展它。我们将使用我们之前定义的Computer类作为父类,并创建一个新的类Tablet,它是一种计算机。
Example05.java
20 class Tablet extends Computer {
21 // variables
22 private double screenSize; // in inches
23
24 // methods
25 void setScreenSize ( double _screenSize ) {
26 screenSize = _screenSize;
27 }
28
29 double getScreenSize () {
30 return screenSize;
31 }
32 }
33
34 public class Example05 {
35 public static void main(String[] args) {
36 Tablet myTab = new Tablet();
37 myTab.setCpuSpeed( 2.5 );
38 myTab.setScreenSize( 10 );
39 System.out.println( myTab.getCpuSpeed() );
40 System.out.println( myTab.getScreenSize() );
41 }
42 }
https://packt.live/2o3NaqE
注意到Tablet类的定义中不包含任何名为setCpuSpeed()或getCpuSpeed()的方法;然而,当调用它们时,程序不仅不会给出任何错误,而且命令也成功执行。
这是因为Tablet类的定义扩展了Computer类,从而继承了其所有内部对象、变量和方法。当创建Tablet类的一个对象,例如myTab时,JVM 在内存中为cpuSpeed变量及其相应的 setter 和 getter 方法预留空间。
重写和隐藏方法
在扩展一个类时,可以重新定义其中的一些方法。重写意味着重写某个功能。这是通过创建一个新的具有相同名称和原始类方法属性的方法声明来完成的。以下是一个示例。请注意,为了清晰起见,我们继续使用Computer和Tablet,但它们已经被简化,以免使示例程序太长。
class Computer {
public void whatIsIt() {
System.out.println( "it is a PC");
}
}
class Tablet extends Computer {
public void whatIsIt() {
System.out.println( "it is a tablet");
}
}
class Example06 {
public static void main(String[] args) {
Tablet myTab = new Tablet();
myTab.whatIsIt();
}
}
由于Tablet类扩展了Computer类,你可以将程序中的主类修改如下:
class Example06 {
public static void main(String[] args) {
Computer myTab = new Tablet();
myTab.whatIsIt();
}
}
从技术上来说,平板电脑是计算机,这意味着你可以首先将其定义为Computer,然后创建Tablet类的一个对象。这两种情况的结果将是相同的:
it is a tablet
Process finished with exit code 0
由于子类和父类都包含一个名为 whatIsIt() 的非静态方法,所以这两个类的结果是相同的。当调用方法时,覆盖的方法将具有优先权。这是由 JVM 在运行时完成的。这个原则就是我们所说的运行时多态。同一个方法可以有多个定义,而哪个定义将被执行是在程序执行过程中决定的。
但是,如果你调用的是静态方法会怎样呢?这可能是由创建你扩展的类的开发者做出的设计决策,因此这是一个你无法控制的情况。在这种情况下,无法覆盖该方法。然而,子类可以使用相同的机制隐藏父类定义的方法。接下来的代码示例演示了这一点。
class Computer {
public static void whatIsIt() {
System.out.println( "it is a PC");
}
}
class Tablet extends Computer {
public static void whatIsIt() {
System.out.println( "it is a tablet");
}
}
class Example07 {
public static void main(String[] args) {
Computer myTab = new Tablet();
myTab.whatIsIt();
}
}
这个示例的结果是:
it is a PC
Process finished with exit code 0
静态方法应该使用哪个方法的决定不是在运行时,而是在编译时做出的,这确保了被调用的是父类的方法。这个动作被称为 Tablet 类。要做到这一点,你应该将 main 类中的代码修改如下:
class Example07 {
public static void main(String[] args) {
Computer myTab = new Tablet();
Tablet.whatIsIt();
}
}
注意我们如何清楚地指定你实际调用的类。修改后的示例结果是:
it is a tablet
Process finished with exit code 0
避免覆盖:最终类和方法
如果你想要阻止其他开发者覆盖你类的一部分,你可以将你想要保护的方法声明为 final。一个例子是处理温度的类。将摄氏度转换为华氏度的方法被声明为 final,因为覆盖这样的方法没有意义。
class Temperature {
public double t = 25;
public double getCelsius() {
return t;
}
final public double getFahrenheit() {
return t * 9/5 + 32;
}
}
class Example08 {
public static void main(String[] args) {
Temperature temp = new Temperature();
System.out.println( temp.getCelsius() );
System.out.println( temp.getFahrenheit() );
}
}
这个程序将给出这个结果:
25.0
77.0
Process finished with exit code 0
注意
或者,你可以声明整个类为 final。一个 final 类不能被扩展。一个这样的类的例子是 String。你可能会问,如果一个类不能被扩展,这会不会违背面向对象编程的目的。但是,有些类对于编程语言来说非常基础,比如 String,它们最好保持原样。
方法重载和构造函数
Java 的一个非常有趣的特性是它允许你通过使用相同的名称但改变参数的类型或数量来定义具有相同概念功能的方法。让我们看看这是如何工作的。
class Age {
public double a = 0;
public void setAge ( double _a ) {
a = _a;
}
public void setAge ( int year, int month ) {
a = year + (double) month / 12;
}
public double getAge () {
return a;
}
}
class Example09 {
public static void main(String[] args) {
Age age = new Age();
age.setAge(12.5);
System.out.println(age.getAge());
age.setAge(9, 3);
System.out.println(age.getAge());
}
}
注意
看一下前面代码中高亮的部分。由于我们正在将整数参数 month 除以一个数,所以操作的结果将是一个双精度浮点数。为了避免可能出现的错误,你需要将整数转换为浮点数。这个过程称为类型转换,通过在要转换的对象、变量或操作之前添加括号中的新类型来完成。
这个示例的结果是:
12.5
9.25
Process finished with exit code 0
这表明两种方法通过不同的参数集修改了Age类中的a变量。从不同的代码块中产生概念上等效结果的同一种机制,可以用于类的构造函数,如下例所示。
class Age {
public double a = 0;
Age ( double _a ) {
a = _a;
}
Age ( int year, int month ) {
a = year + (double) month / 12;
}
public double getAge () {
return a;
}
}
class Example10 {
public static void main(String[] args) {
Age age1 = new Age(12.5);
Age age2 = new Age(9, 3);
System.out.println(age1.getAge());
System.out.println(age2.getAge());
}
}
在这种情况下,为了展示功能,我们不得不创建两个不同的对象,age1和age2,并带有一个或两个参数,因为这些是Age类中可用的构造函数提供的可能选项。
递归
编程语言允许使用某些机制来简化问题的解决。递归就是这些机制之一。它是方法调用自身的能力。当设计得当,递归方法可以简化使用代码表达特定问题解决方案的方式。
递归的经典例子包括计算一个数的阶乘或对数字数组进行排序。为了简化,我们将查看第一种情况:找到一个数的阶乘。
class Example11 {
public static long fact ( int n ) {
if ( n == 1 ) return 1;
return n * fact ( n – 1 );
}
public static void main(String[] args) {
int input = Integer.parseInt(args[0]);
long factorial = fact ( input );
System.out.println(factorial);
}
}
要运行此代码,您需要进入终端,并使用java Example11 m调用示例,其中m是要计算阶乘的整数。根据您在计算机上创建项目的地方,它可能看起来像这样(请注意,我们已缩短示例的路径以保持其简洁):
usr@localhost:~/IdeaProjects/chapter03/[...]production/Example11$ java Example11 5
120
或者,它可能看起来像这样:
usr@localhost:~/IdeaProjects/chapter03/[...]production/Example11$ java Example11 3
6
调用的结果是阶乘:120是5的阶乘,而6是3的阶乘。
虽然一开始可能看起来不太直观,但fact方法在返回行中调用自身。让我们仔细看看这一点:
public static long fact ( int n ) {
if ( n == 1 ) return 1;
return n * fact ( n – 1 );
}
在设计功能递归方法时,你需要满足一些条件。否则,递归方法将不会收敛到任何东西:
-
需要有一个基本条件。这意味着你需要一些东西来停止递归的发生。在
fact方法的情况下,基本条件是n等于 1:if ( n == 1 ) return 1; -
需要一种方法,在经过一定步骤后,在计算上达到基本条件。在我们的例子中,每次调用
fact时,我们都使用一个比当前方法调用参数小一个单位的参数:return n * fact ( n – 1 );
注解
注解是一种特殊的元数据,可以添加到你的代码中,以通知编译器有关其相关方面的信息。注解可以在声明类、字段、方法、变量和参数时使用。注解的一个有趣方面是,它们在类内部保持可见,指示一个方法是否是父类中不同方法的覆盖,例如。
注解是通过使用@符号后跟注解的名称来声明的。有一些内置的注解,但也可以声明自己的注解。在这个阶段,重要的是要关注一些内置的注解,因为它将帮助你理解本章中迄今为止所介绍的一些概念
最相关的内置注解是@Override、@Deprecated和@SuppressWarnings。这三个命令向编译器告知代码或其生成过程的不同方面。
@Override用于指示在子类中定义的方法是父类中另一个方法的覆盖。它将检查父类中是否存在与子类中相同名称的方法,如果不存在,将引发编译错误。以下示例展示了如何使用这个注解,该示例基于我们在本章关于Tablet类扩展Computer类的早期示例。
class Computer {
public void whatIsIt() {
System.out.println( "it is a PC");
}
}
class Tablet extends Computer {
@Override
public void whatIsIt() {
System.out.println( "it is a tablet");
}
}
class Example12 {
public static void main(String[] args) {
Tablet myTab = new Tablet();
myTab.whatIsIt();
}
}
@Deprecated表示该方法即将变得过时。这通常意味着它将在类的未来版本中被移除。由于 Java 是一种活的语言,核心类被修订、新方法被产生,以及其他功能变得不再相关并变得过时是很常见的。以下示例重新审视了之前的代码列表,如果Computer类的维护者决定将whatIsIt()方法重命名为getDeviceType()。
Example13.java
1 class Computer {
2 @Deprecated
3 public void whatIsIt() {
4 System.out.println( "it is a PC");
5 }
6
7 public void getDeviceType() {
8 System.out.println( "it is a PC");
9 }
10 }
11
12 class Tablet extends Computer {
13 @Override
14 public void whatIsIt() {
15 System.out.println( "it is a tablet");
16 }
17 }
https://packt.live/35NGCgG
调用上一个示例的编译将会发出一个警告,指出whatIsIt()方法将很快不再被使用。这应该有助于开发者规划他们的程序,因为他们会知道一些方法可能在将来消失:
Warning:(13, 17) java: whatIsIt() in Computer has been deprecated
@SuppressWarnings使编译器隐藏在注解参数中定义的可能警告。应该指出的是,注解可以有如overrides、deprecation、divzero和all之类的参数。还有更多类型的警告可以被隐藏,但现在介绍它们还为时尚早。虽然我们目前不会深入探讨这个概念,但你可以在以下代码列表中看到一个例子。
Example14.java
1 class Computer {
2 @Deprecated
3 public void whatIsIt() {
4 System.out.println( "it is a PC");
5 }
6
7 public void getDeviceType() {
8 System.out.println( "it is a PC");
9 }
10 }
11
12 @SuppressWarnings("deprecation")
13 class Tablet extends Computer {
14 @Override
15 public void whatIsIt() {
16 System.out.println( "it is a tablet");
17 }
18 }
https://packt.live/33GKnTt
当调用最新示例的编译时,你会看到与上一个示例的不同之处,因为这次编译不会产生关于whatIsIt()方法过时的任何警告。
注意
使用@SuppressWarnings时应小心,因为它可以隐藏由代码潜在故障引起的风险。特别是应避免使用@SuppressWarnings("all"),因为它可能会掩盖在其他代码部分可能产生运行时错误的警告。
接口
在 Java 中,接口是引用类型。因此,它们定义了类和对象的骨架,但没有包括方法的功能。类实现接口,但不扩展它们。让我们看看一个简单接口的例子,进一步发展构建表示不同类型计算机的类的想法。
interface Computer {
public String getDeviceType();
public String getSpeed();
}
class Tablet implements Computer {
public String getDeviceType() {
return "it is a tablet";
}
public String getSpeed() {
return "1GHz";
}
}
class Example15 {
public static void main(String[] args) {
Tablet myTab = new Tablet();
System.out.println( myTab.getDeviceType() );
System.out.println( myTab.getSpeed() );
}
}
如您所猜测的,这个例子的输出是:
it is a tablet
1GHz
Process finished with exit code 0
接口的一些相关注意事项如下:
-
接口可以扩展其他接口。
-
与一次只能扩展一个类的类不同,接口可以一次扩展多个接口。您可以通过添加以逗号分隔的不同接口来实现这一点。
-
接口没有构造函数。
内部类
如我们所见,类不能被程序的其他部分隐藏。在代码术语中,它们不能被设置为私有。为了提供这种安全机制,Java 开发了所谓的 内部类。这种类型的类是在其他类内部声明的。以下是一个快速示例:
Example16.java
1 class Container {
2 // inner class
3 private class Continent {
4 public void print() {
5 System.out.println("This is an inner class");
6 }
7 }
8
9 // method to give access to the private inner class' method
10 void printContinent() {
11 Continent continent = new Continent();
12 continent.print();
13 }
14 }
https://packt.live/2P2vc30
之前例子的结果是:
This is an inner class
Process finished with exit code 0
之前的例子是一个非静态内部类的例子。还有两个:方法局部内部类(这些是在方法内部定义的)和匿名类。与之前看到的方法局部类的声明相比,没有太大的区别。方法局部内部类的主要特征是它只在该方法的范围内定义;它不能被程序的其他部分调用。
当涉及到匿名内部类时,它们构成了一个值得研究的有意思的案例。它们存在的原因是为了使代码更加简洁。使用匿名类,您可以在同一时间声明和实例化该类。这意味着对于这样的类,只创建了一个对象。匿名类通常通过扩展现有类或接口来创建。让我们看看一个定义这些特定类型匿名类的例子:
class Container {
int c = 17;
public void print() {
System.out.println("This is an outer class");
}
}
class Example17 {
public static void main(String[] args) {
// inner class
Container container = new Container() {
@Override
public void print() {
System.out.println("This is an inner class");
}
};
container.print();
System.out.println( container.c );
}
}
这个例子展示了如何以即兴的方式创建一个匿名类来覆盖原始类的一个方法。这是这种内部类可能应用的许多可能之一。这个程序的输出是:
This is an inner class
17
Process finished with exit code 0
使用 JavaDoc 进行文档化
Javadoc 是 JDK 中的一个工具,可以用来从带有适当注释的代码中直接生成类的文档。它需要使用一种与 第一章,入门 中看到的不同类型的注释。在那里,我们看到可以通过使用 // 或 /* 或 */ 来向代码中添加注释。JavaDoc 使用一种特定的标记来检测哪些注释是故意为了文档目的而制作的。Javadoc 注释包含在 /** 和 */ 之间。以下是一个简单的例子。
Example18.java
1 /**
2 * Anonymous class example
3 * This example shows the declaration of an inner class extending
4 * an existing class and overriding a method. It can be used as a
5 * technique to modify an existing method for something more suitable
6 * to our purpose.
7 *
8 * @author Joe Smith
9 * @version 0.1
10 * @since 20190305
11 */
https://packt.live/2J5u4aT
注意
如果您要从类中生成文档,您需要确保该类是公共的,否则,JavaDoc 生成器将抱怨文档化非公共类没有意义。
新的注释包括有关程序本身的信息。详细解释程序做什么是良好的实践。有时,甚至添加代码块可能很方便。为了支持这些额外信息,有一些标签允许向文档添加特定的功能或元数据。@author、@version 和 @since 是此类元数据的例子——它们分别确定谁编写了代码、代码的版本以及它首次创建的时间。有一个长长的标签列表你可以使用;访问 packt.live/2J2Px4n 获取更多信息。
JavaDoc 将文档渲染为一个或多个 HTML 文件。因此,也可以添加 HTML 标记以帮助信息。你可以将上一个示例中的文档部分更改如下:
/**
* <H1>Anonymous class example</H1>
* This example shows the declaration of an <b>inner class</b> extending
* an existing class and overriding a method. It can be used as a
* technique to modify an existing method for something more suitable
* to our purpose.
*
* @author Joe Smith
* @version 0.1
* @since 20190305
*/
最后,你可以通过从菜单中选择 工具 | 生成 JavaDoc 来创建文档文件:

图 3.9:生成 JavaDoc
JavaDoc 生成对话框将弹出并给你一些选项。确保你插入你想存储文档文件的文件夹(示例中的 /tmp)并勾选 @author 和 @version 的复选框:
![图 3.10:指定 JavaDoc 的作用域]
![img/C13927_03_10.jpg]
图 3.10:指定 JavaDoc 的作用域
这将生成一个格式与官方 Java 文档相同的 HTML 文件:
![图 3.11:生成的 JavaDoc]
![img/C13927_03_11.jpg]
图 3.11:生成的 JavaDoc
活动二:为 WordTool 添加文档
为 练习 1 中创建的 WordTool 类 创建文档。
-
确保为每个示例编写文档,并添加足够的元数据,以便人们知道如何处理不同的方法。
-
导出生成的文档文件。
注意
本活动的解决方案可以在第 536 页找到。
摘要
本章向你介绍了面向对象编程的核心——类的创建以及可以使用它们执行的操作,例如扩展它们、使用它们来覆盖代码的部分,或创建局部实例。
这里提供的示例展示了创建类以更好地组织代码并提高其经济性的重要性。如果在特定上下文中存在多个类,它们很可能具有可以在父类或甚至接口中描述的共同特征。
本章的一部分专门介绍了使用编译器进行的操作。作为开发者,你可能想在你的代码的某些部分将要弃用时通知其他人,或者是否有一个特定类的某个方法已被覆盖。注释代码是维护与他人沟通的好方法。你也看到了如何关闭在开发期间发生的注释可能产生的警告。
最后,我们讨论了文档化的过程。这在分享代码或将其传递给其他人时是相关的。在下一章中,我们将探讨 Java 集合框架,这将简化您处理复杂数据结构的工作。
第四章:4. 集合、列表和 Java 的内置 API
概述
本章向您介绍强大的 Java 集合框架,该框架用于存储、排序和过滤数据。它将首先带您了解内置的集合应用程序编程接口(API),即 Java 集合框架,这将简化您与复杂数据结构的交互,并允许您以最小的努力使用和创建 API。通过这个框架,您将检查列表和数组之间的关系,并学习如何从数组中填充列表。最后,在本章的最后一个活动中,您将创建并完成一个程序,在这个程序中,您将被要求对存储在集合、列表和映射中的数据进行标准操作,为未来的章节做准备。
简介
Java 自带内置的集合 API,允许你以极少的努力操作数据结构。集合是一个包含多个元素的对象。集合用于存储、共享、处理和通信聚合数据。我们称这个系统为Java 集合框架。
作为这个框架的一部分,有不同组件用于优化我们与实际数据的交互:
-
接口:表示集合的抽象数据类型
-
实现:集合接口的具体实现
-
算法:用于在集合中处理数据的多态方法,例如排序和搜索操作
注意
其他编程语言也有自己的集合框架。例如,C++有标准模板库(STL)。Java 在集合框架方面以简单著称。
使用集合框架有许多好处,包括减少处理数据结构程序的复杂性、提高程序性能、简化 API 创建和使用,以及增加功能软件的重用。
即使在可以由多个进程同时访问数据的情况下,集合框架也是相关的,例如在多线程编程场景中。然而,本章的目的并不是处理并发编程。
集合 API 包含五个主要接口:
-
集合:不包含重复元素的集合 -
列表:一个有序集合或序列,允许有重复元素 -
队列:按到达顺序排序数据的集合,通常作为先进先出(FIFO)过程处理 -
双端队列:本质上是一个允许在两端插入数据的队列,这意味着它可以作为先进先出(FIFO)和后进先出(LIFO)处理 -
映射:将键(必须是唯一的)与值相关联
在本章中,我们将定义主要接口(列表、集合和映射),并探讨它们各自的使用示例。该框架比之前列出的接口更多,但其他接口要么是那些列出的变体,要么超出了本章的范围。此外,我们将比以前更深入地探讨数组的工作原理。
简单集合的定义——在这种情况下,特定类型的集合的定义如下:
Set mySet = new HashSet();
注意
可用的不同集合类(集合、列表、队列、双端队列和映射)以接口命名。不同的类具有不同的属性,我们将在本章后面看到。
数组
数组是集合框架的一部分。有一些静态方法可以用来操作数组。您可以执行的操作包括创建、排序、搜索、比较、流式传输和转换数组。您在第二章,学习基础知识中介绍了数组,您看到了它们如何用于存储相同类型的数据。数组的声明相当简单。让我们看看字符串数组会是什么样子:
String[] text = new String[] { "spam", "more", "buy" };
在数组上运行操作与调用java.util.Arrays包中包含的一些方法一样简单。例如,对前面的数组进行排序需要调用以下代码:
java.util.Arrays.sort( text );
专门用于处理数组的函数包括一个可以用来打印完整数组的方法,就像它们是字符串一样。这在调试程序时非常有用:
System.out.println( java.util.Arrays.toString( text ) );
这将打印数组并显示每个元素,用逗号分隔,并用方括号括起来,[]。如果您在排序声明的字符串数组之后执行前面的命令,结果将是:
[buy, more, spam]
如您所见,数组已按升序字母顺序排序。打印数组的方式与使用for循环遍历数组的方式不同:
for (int i = 0; i < text.length; i++)
System.out.print(text[i] + " ");
这将给出以下结果:
buy more spam
如果您想以稍微干净一些的方式编写代码,可以在程序开始时导入整个java.util.Arrays API,这将允许您通过省略命令中的java.util部分来调用方法。以下示例突出了这一技术:
import java.util.Arrays;
public class Example01 {
public static void main(String[] args) {
String[] text = new String[] { "spam", "more", "buy" };
Arrays.sort(text);
System.out.println(Arrays.toString(text));
for (int i = 0; i < text.length; i++)
System.out.print(text[i] + " ");
}
}
结果将是:
[buy, more, spam]
buy more spam
Process finished with exit code 0
如果您想要创建一个新数组,希望所有单元格都填充相同的数据,可以使用java.util.Arrays.fill()方法,如下所示:
int[] numbers = new int[5];
Arrays.fill(numbers, 0);
这样的命令将创建一个填充为零的数组:
[0, 0, 0, 0, 0]
使用现有数组的副本也可以创建带有预填充数据的数组。可以通过复制一个数组的部分来创建一个数组,或者实例化一个更大的数组,其中旧数组只是它的一部分。以下示例展示了这两种方法,您可以在您的编辑器中测试:
import java.util.Arrays;
public class Example02 {
public static void main(String[] args) {
int[] numbers = new int[5];
Arrays.fill(numbers, 1);
System.out.println(Arrays.toString(numbers));
int [] shortNumbers = Arrays.copyOfRange(numbers, 0, 2);
System.out.println(Arrays.toString(shortNumbers));
int [] longNumbers = Arrays.copyOf(numbers, 10);
System.out.println(Arrays.toString(longNumbers));
}
}
此示例将打印numbers、shortNumbers(较短)和longNumbers(较长)数组。数组中新添加的位置将被零填充。如果它是一个字符串数组,它们将被null填充。此示例的结果是:
[1, 1, 1, 1, 1]
[1, 1]
[1, 1, 1, 1, 1, 0, 0, 0, 0, 0]
Process finished with exit code 0
您可以通过调用java.utils.Arrays.equals()或java.util.Arrays.deepEquals()方法来比较数组。它们之间的区别在于后者可以查看嵌套数组。以下是一个使用前者的简单比较示例:
import java.util.Arrays;
public class Example03 {
public static void main(String[] args) {
int[] numbers1 = new int[3];
Arrays.fill(numbers1, 1);
int[] numbers2 = {0, 0, 0};
boolean comparison = Arrays.equals(numbers1, numbers2);
System.out.println(comparison);
int[] numbers3 = {1, 1, 1};
comparison = Arrays.equals(numbers1, numbers3);
System.out.println(comparison);
int[] numbers4 = {1, 1};
comparison = Arrays.equals(numbers1, numbers4);
System.out.println(comparison);
}
}
在这个例子中,我们创建了四个数组:numbers1、numbers2、numbers3和numbers4。其中只有两个数组相同,包含三个1的实例。在示例中,您可以看到最后三个数组是如何与第一个数组进行比较的。您还可以看到最后一个数组在内容上没有区别,但大小不同。此代码的结果是:
false
true
false
Process finished with exit code 0
注意
由于本章没有探讨像嵌套数组这样复杂的数据结构,因此我们不会展示java.util.Arrays.deepEquals()的示例。如果您感兴趣,应该考虑查阅packt.live/2MuRrNa上的 Java 参考文档。
在数组中进行搜索是通过后台的不同算法完成的。显然,在排序数组上执行搜索比在未排序数组上要快得多。要在排序数组上运行此类搜索,应调用Arrays.binarySearch()方法。由于它有许多可能的参数组合,建议访问该方法的官方文档。以下示例说明了它是如何工作的:
import java.util.Arrays;
public class Example04 {
public static void main(String[] args) {
String[] text = {"love","is", "in", "the", "air"};
int search = Arrays.binarySearch(text, "is");
System.out.println(search);
}
}
此代码将在数组 text 中搜索单词the。结果是:
-4
Process finished with exit code 0
这是错误的!binarySearch是集合框架内的优化搜索算法,但与未排序数组一起使用时并不最优。这意味着binarySearch主要用于确定是否可以在数组中找到对象(通过先对其进行排序)。同时,当我们必须搜索未排序的数组或存在多个值时,我们需要不同的算法。
尝试以下对先前示例的修改:
String[] text = {"love","is", "in", "the", "air"};
Arrays.sort(text);
int search = Arrays.binarySearch(text, "is");
System.out.println(search);
由于数组已排序,结果将是:
2
Process finished with exit code 0
在这种情况下,"is"恰好出现在未排序和排序数组版本中的相同位置只是一个巧合。利用您一直在学习的工具,您应该能够创建一个算法,该算法可以遍历数组并计算所有现有项目,即使它们是重复的,以及它们在数组中的位置。请参阅本章中的活动 1,在数组中搜索多个出现,其中我们挑战您编写这样的程序。
你还可以使用java.util.Arrays类的Arrays.toString()方法将对象转换为字符串,就像我们在本节开头看到的那样,使用Arrays.asList()(我们将在后面的章节中看到,以及Example05)或使用Arrays.setAll()转换为集合。
数组和集合在软件开发中扮演着重要的角色。本章的这一部分深入探讨了它们之间的区别以及它们如何一起使用。如果你在网上搜索这两个构造之间的关系,你找到的大多数参考资料都将集中在它们的区别上,例如:
-
数组有固定的大小,而集合有可变的大小。
-
数组可以持有任何类型的对象,也可以持有原始数据类型;集合不能包含原始数据类型。
-
数组将持有同质元素(所有元素性质相同),而集合可以持有异质元素。
-
数组没有底层的数据结构,而集合使用标准结构实现。
如果你知道你将要处理的数据量,数组是首选的工具,主要是因为在这种情况下数组的表现优于列表或集合。然而,会有无数的情况,你不知道你将要处理的数据量,这时候列表就会变得很有用。
此外,数组还可以用于以编程方式填充集合。我们将在本章中这样做,以节省你手动输入最终将存储在集合中的所有数据的时间,例如。以下示例显示了如何使用数组填充一个集合:
import java.util.*;
public class Example05 {
public static void main(String[] args) {
Integer[] myArray = new Integer[] {3, 25, 2, 79, 2};
Set mySet = new HashSet(Arrays.asList(myArray));
System.out.println(mySet);
}
}
在这个程序中,有一个Integer类型的数组被用来初始化HashSet类的一个对象,这个对象随后被打印出来。
这个示例的结果是:
[2, 3, 25, 79]
Process finished with exit code 0
之前的代码示例显示了几个有趣的事情。首先,你会注意到程序输出的结果是排序的;这是因为使用Arrays.asList()将数组转换为列表会使数据集继承列表的性质,这意味着它将是排序的。此外,由于数据已经添加到集合中,而集合不包含重复项,所以第二个重复的数字被省略了。
重要的是要注意,使用集合,你可以指定要存储的类型。因此,在之前的示例中,我们展示了泛型声明,以及接下来的声明之间会有所不同。类型在这里使用尖括号内的名称声明,即<>。在这种情况下,它是<Integer>。你可以将对象的实例化重写如下:
Set<Integer> mySet = new HashSet<Integer>(Arrays.asList(myArray));
你会发现程序执行的结果将是相同的。
活动 1:在数组中搜索多个出现
编写一个程序,用于在字符串数组中搜索某个单词的多个出现,其中每个对象都是一个单独的单词。以下是一个著名的弗兰克·扎帕语录的数组,作为出发点:
String[] text = {"So", "many", "books", "so", "little", "time"};
要搜索的单词是 so,但您必须考虑到它出现了两次,并且其中一个实例不是小写。作为一个提示,比较两个字符串而不看它们中任何字母的具体大小写的方方法是 text1.compareToIgnoreCase(text2)。为此,请执行以下步骤:
-
创建
text数组。 -
创建包含要搜索的单词的变量:
so -
将变量
occurrence初始化为 -1。 -
创建一个循环来遍历数组以检查出现。
这将给出以下结果:
Found query at: 0
Found query at: 3
Process finished with exit code 0
注意
此活动的解决方案可以在第 538 页找到。
集合
集合框架中的集合是数学集合的程序等效。这意味着它们可以存储特定类型的对象,同时避免重复。同样,集合提供的方法将允许您以数学的方式处理数据。您可以将对象添加到集合中,检查集合是否为空,将两个集合的元素合并以将所有元素添加到单个集合中,查看两个集合之间有什么对象是相同的,以及计算两个集合之间的差异。
在 java.util.Sets 类中,我们发现有三个接口用于表示集合:HashSet、TreeSet 和 LinkedHashSet。它们之间的区别是直接的:
-
HashSet将存储数据,但不保证迭代顺序。 -
TreeSet按值对集合进行排序。 -
LinkedHashSet按到达时间对集合进行排序。
每个接口都旨在在特定情况下使用。让我们从 Example05 中的集合出发,看看我们如何添加其他方法来检查如何操作集合。第一步是从数组中填充集合。有几种方法可以做到这一点;让我们使用最快速实现的方法:
import java.util.*;
public class Example06 {
public static void main(String[] args) {
String[] myArray = new String[] {"3", "25", "2", "79", "2"};
Set mySet = new HashSet();
Collections.addAll(mySet, myArray);
System.out.println(mySet);
}
}
上述代码行显示了如何将数组的所有元素添加到集合中;当打印结果时,我们得到:
[2, 79, 3, 25]
Process finished with exit code 0
请注意,输出的顺序可能因您而异。如前所述,HashSet 由于其实现方式,无法保证内容的任何排序。如果您使用 Integer 而不是 String 作为数据执行以下示例,最终结果将是排序的:
import java.util.*;
public class Example07 {
public static void main(String[] args) {
Integer[] myArray = new Integer[] {3, 25, 2, 79, 2};
Set mySet = new HashSet();
Collections.addAll(mySet, myArray);
System.out.println(mySet);
}
}
该程序的结果如下:
[2, 3, 25, 79]
Process finished with exit code 0
这意味着结果最终是排序的,即使我们没有请求它。
注意
在此示例中,集合是排序的只是一个巧合。请意识到在其他情况下可能并非如此。Example08 将展示两个集合之间的并集操作,那里的数据将不会排序。
与集合一起工作涉及处理数据包并对其执行操作。以下示例显示了两个集合的并集操作:
import java.util.*;
public class Example08 {
public static void main(String[] args) {
Integer[] numbers1 = new Integer[] {3, 25, 2, 79, 2};
Integer[] numbers2 = new Integer[] {7, 12, 14, 79};
Set set1 = new HashSet();
Collections.addAll(set1, numbers1);
Set set2 = new HashSet();
Collections.addAll(set2, numbers2);
set1.addAll(set2);
System.out.println(set1);
}
}
此程序将打印出两个数组在示例主方法开头描述的集合的并集的结果:
[2, 3, 7, 25, 12, 14, 79]
Process finished with exit code 0
除了HashSet,我们还发现TreeSet,在这里数据将按值排序。让我们简单地改变上一个例子中集合的类型,看看结果:
Set set1 = new TreeSet();
Collections.addAll(set1, numbers1);
Set set2 = new TreeSet();
Collections.addAll(set2, numbers2);
当在上一个例子中改变时,这将给出以下排序后的集合作为结果:
[2, 3, 7, 12, 14, 25, 79]
你可能想知道使用每种类型集合的优缺点。在排序时,你是在速度和整洁之间做权衡。因此,如果你正在处理大量数据且速度是一个问题,你必须决定是让系统运行得更快,还是让结果排序,这样就可以更快地通过数据集进行二分搜索。
给了最后一个修改,我们可以对数据进行其他操作,例如交集操作,该操作通过set1.retainAll(set2)方法调用。让我们看看它的实际效果:
import java.util.*;
public class Example09 {
public static void main(String[] args) {
Integer[] numbers1 = new Integer[] {3, 25, 2, 79, 2};
Integer[] numbers2 = new Integer[] {7, 12, 14, 79};
Set set1 = new TreeSet();
Collections.addAll(set1, numbers1);
Set set2 = new TreeSet();
Collections.addAll(set2, numbers2);
set1.retainAll(set2);
System.out.println(set1);
}
}
对于输出,由于数组被用来填充数组,我们只会得到存在于两个数组中的那些数字;在这种情况下,只是数字79:
[79]
Process finished with exit code 0
第三种类型的集合,LinkedHashSet,将按对象到达的顺序对对象进行排序。为了演示这种行为,让我们编写一个程序,该程序将使用set.add(element)命令逐个向集合中添加元素。
import java.util.*;
public class Example10 {
public static void main(String[] args) {
Set set1 = new LinkedHashSet();
set1.add(35);
set1.add(19);
set1.add(11);
set1.add(83);
set1.add(7);
System.out.println(set1);
}
}
当运行这个例子时,结果将按数据到达集合的方式排序:
[35, 19, 11, 83, 7]
Process finished with exit code 0
为了实验,请用接下来的 2 分钟再次将集合构造为HashSet:
Set set1 = new LinkedHashSet();
这个修改后的程序的结果是不确定的。例如,我们得到:
[35, 19, 83, 7, 11]
Process finished with exit code 0
这又是同一组数据的未排序版本。
为了结束我们对你可以与集合一起使用的可能方法的解释,让我们使用LinkedHashSet运行一个实验,我们将找到两个集合之间的差异。
import java.util.*;
public class Example11 {
public static void main(String[] args) {
Set set1 = new LinkedHashSet();
set1.add(35);
set1.add(19);
set1.add(11);
set1.add(83);
set1.add(7);
Set set2 = new LinkedHashSet();
set2.add(3);
set2.add(19);
set2.add(11);
set2.add(0);
set2.add(7);
set1.removeAll(set2);
System.out.println(set1);
}
}
在这种情况下,两个集合略有不同,通过确定差异,set1.removeAll(set2)算法背后的算法将在set1中查找set2中每个项目的出现,并将它们消除。这个程序的结果是:
[35, 83]
Process finished with exit code 0
最后,如果你只想检查一个集合是否完全包含在另一个集合中,你可以调用set1.containsAll(set2)方法。我们将把这个留给你去探索——只需注意,该方法简单地返回一个布尔值,表示该语句是真是假。
列表
列表是有序的数据集合。与集合不同,列表可以有重复的数据。在列表中包含数据允许你执行搜索,这将给出给定列表中某些对象的位置。给定一个位置,你可以直接访问列表中的项目,添加新项目,删除项目,甚至添加完整的列表。列表是顺序的,这使得它们很容易通过迭代器进行导航,这一特性将在本章后面的部分中详细探讨。还有一些方法可以对子列表执行基于范围的操作。
有两种不同的列表实现:ArrayList和LinkedList。根据情况,每个都是理想的。在这里,我们将主要使用ArrayList。让我们首先创建并填充一个实例,然后搜索列表中的某个值,并根据其在列表中的位置打印出该值。
import java.util.*;
public class Example12 {
public static void main(String[] args) {
List list = new ArrayList();
list.add(35);
list.add(19);
list.add(11);
list.add(83);
list.add(7);
System.out.println(list);
int index = list.indexOf(19);
System.out.println("Find 19 at: " + index);
System.out.println("Component: " + list.get(index));
}
}
这个示例的输出如下:
[35, 19, 11, 83, 7]
Find 19 at: 1
Component: 19
Process finished with exit code 0
indexOf方法会告诉你传递给方法的对象的位置。它的兄弟方法lastIndexOf报告列表中对象的最后一个出现位置。
你应该将列表视为由链接连接的一系列节点。如果一个节点被消除,曾经指向它的链接将被重定向到列表中的下一个项目。当添加节点时,它们默认附加到列表的末尾(如果它们不是重复的)。由于集合中的所有节点都是同一类型,因此应该可以在列表中交换两个节点的位置。
让我们实验一下从列表中删除一个项目,并确定删除项目前后立即定位的对象的位置:
import java.util.*;
public class Example13 {
public static void main(String[] args) {
List list = new ArrayList();
list.add(35);
list.add(19);
list.add(11);
list.add(83);
list.add(7);
System.out.println(list);
int index = list.lastIndexOf(83);
System.out.println("Before: find 83 at: " + index);
list.remove(index - 1);
System.out.println(list);
index = list.lastIndexOf(83);
System.out.println("After: find 83 at: " + index);
}
}
这个程序创建了一个列表,将其打印出来,然后在列表中查找一个节点并打印其位置。然后,它从列表中删除一个项目,并重复之前的步骤以显示该节点已从列表中删除。这与数组的情况明显不同,在数组中无法删除项目,因此无法更改其大小。观察前一个示例的输出:
[35, 19, 11, 83, 7]
Before: find 83 at: 3
[35, 19, 83, 7]
After: find 83 at: 2
Process finished with exit code 0
也可以更改节点的内容。在前面的示例中,不是删除节点,而是将list.remove(index-1);更改为以下内容并检查结果:
list.set(index - 1, 99);
最终数组将用11替换99。
如果你不想删除一个节点,而是想清空整个列表,那么向其发出的命令将是:
list.clear();
使用subList()操作符,可以从列表生成列表,例如,可以删除列表中一系列单元格。请看以下示例,它删除了字符串数组的一部分,在打印时改变了其含义:
import java.util.*;
public class Example14 {
public static void main(String[] args) {
List list = new ArrayList();
list.add("No");
list.add("matter");
list.add("what");
list.add("you");
list.add("do");
System.out.println(list);
list.subList(2,4).clear();
System.out.println(list);
}
}
看看以下结果:
[No, matter, what, you, do]
[No, matter, do]
Process finished with exit code 0
通过运行示例代码修改了list对象,使其变短。subList()方法中使用的两个索引数字是列表中方法开始和停止的位置。subList()的结果也可以分配给相同类型的另一个变量,在执行subList()操作后,代码中的列表将减少一个副本。
看看最新代码列表中的以下修改:
List list1 = list.subList(2,4);
System.out.println(list1);
这将打印出由前一个示例中删除的节点组成的列表。
集合框架中有许多有趣的算法,提供了操作列表的相关功能:
-
sort:将列表的元素按特定顺序排列。 -
shuffle:随机化列表中所有对象的位置。 -
reverse:反转列表的顺序。 -
rotate:将对象移动到列表的末尾,当它们到达末尾时,在另一端显示。 -
swap:交换两个元素。 -
replaceAll:使用参数替换列表中所有元素的出现。 -
fill:使用一个值填充列表的内容。 -
copy:创建列表的更多实例。 -
binarySearch:在列表中执行优化的搜索。 -
indexOfSubList:搜索列表中某个片段(一组连续节点)的出现。 -
lastIndexOfSubList:搜索列表中某个片段的最后一个出现位置。注意
使用
Arrays.asList()从数组生成的列表与本章中描述的List类对象的行为不同。来自数组的列表具有固定长度,这意味着无法从数组中删除元素。这是因为java.util.Arrays在包内部实现了自己的ArrayList类,它与集合框架中的类不同。这不是很令人困惑吗?
练习 1:创建 AnalyzeInput 应用程序
在这个练习中,我们将创建一个新的应用程序,该应用程序将通过存储提供给它的任何字符串来响应 CLI,然后对数据进行一些统计操作,例如单词计数(确定最频繁的单词或最频繁的字母等)。目的是让你了解如何使用集合框架而不是其他工具来完成此类操作。这次,我们将做一些特别的事情;而不是从 CLI 作为脚本的参数获取数据,我们将使用 java.io.Console API,它允许从终端读取不同类型的字符串,例如用户名(普通字符串)和密码。这个应用程序的目标是读取输入,直到捕获到只有 "*" 符号(星号)的行。一旦输入了终止符号,文本将被处理,并将统计结果发送到终端:
-
打开 IntelliJ 并使用 CLI 模板创建一个新的 Java 程序。将项目命名为
AnalyzeInput。 -
首先,创建一个简单的程序,可以从终端读取一行并将其打印出来:
import java.io.Console; public class AnalyzeInput { public static void main(String[] args) { Console cons; String line = ""; if ((cons = System.console()) != null && (line = cons.readLine()) != null) { System.out.println("You typed: " + line); } } } -
从 CLI 中执行程序,通过在正确的文件夹中调用
java AnalyzeInput来与之交互:usr@localhost:~/IdeaProjects/ch04/out/production/ch04$ java AnalyzeInput hej this is an example You typed: hej this is an example -
您必须导入
java.io.Console,这允许您实例化Console类的对象。您还可以看到对cons = System.console()的调用,这将确保终端已准备好供您读取数据,以及line = cons.readLine(),这将确保在按下键盘上的 Enter 键时,结果数据不为空。 -
下一步是将我们正在捕获的数据存储在集合中。由于我们不知道这个大小,我们应该使用
ArrayList <String>来存储数据。此外,为了存储我们想要存储的数据,我们可以修改if语句并将其改为while循环。最后,使用add方法将行添加到列表中(请注意,以下代码列表永远不会退出,所以请耐心等待,不要现在执行它):import java.util.*; import java.io.Console; public class Exercise01 { public static void main(String[] args) { ArrayList <String> text = new ArrayList<String>(); Console cons; String line = ""; while ((cons = System.console()) != null && (line = cons.readLine()) != null) { text.add(line); } System.out.println("You typed: " + text); } } -
修改
while循环,包括我们为完成数据捕获过程设定的条件——只有星号符号的一行:while (!line.equals("*") && (cons = System.console()) != null && (line = cons.readLine()) != null) { -
结果只有在你在一行中单独输入星号符号时才会发生,就像在与程序交互时的这个日志中看到的那样:
usr@localhost:~/IdeaProjects/ch04/out/production/ch04$ java AnalyzeInput this is the array example until you type * alone in a line * You typed: [this is the array example, until you type *, alone in a line, *] -
由于我们使用了
ArrayList来存储不同的字符串,你可能需要一直输入,直到耗尽计算机的内存。现在,我们可以执行一些命令来处理字符串。第一步是将整个文本转换成一个列表。这需要遍历不同的字符串并将它们分割成将要添加到更大列表中的部分。最简单的技巧是使用split()方法,以空格字符作为分隔符。修改main方法,使其看起来如下,你就会看到结果现在是一个列表,其中所有的单词都作为单独的节点分开:public static void main(String[] args) { ArrayList <String> text = new ArrayList<String>(); Console cons; String line = ""; while (!line.equals("*") && (cons = System.console()) != null && (line = cons.readLine()) != null) { List<String> lineList = new ArrayList<String>(Arrays.asList(line.split(" "))); text.addAll(lineList); } System.out.println("You typed: " + text); } -
以这种方式存储所有数据允许使用集合框架中可用的许多方法,这将让你能够对数据进行操作。让我们从计算文本中的所有单词(包括关闭符号,“
*”)开始。只需在main方法的末尾添加以下内容:System.out.println("Word count: " + text.size());
这个练习的结果是一个可以用于进一步分析数据的程序。但是,为了继续这样做,我们需要使用一个尚未介绍的工具——迭代器。我们将在本章的后面回到这个例子,并通过添加一些额外的功能来完成应用程序。
映射
集合框架提供了一个额外的接口,java.util.Map,当处理以键值对形式存储的数据时可以使用。这种类型的数据存储越来越相关,因为像 JSON 这样的数据格式正逐渐接管互联网。JSON 是一种基于嵌套数组形式存储数据的数据格式,这些数组总是响应键值结构。
以这种方式组织数据提供了通过键而不是,例如,使用索引(就像我们在数组中做的那样)来查找数据的一种非常简单的方法。键是我们可以在映射中识别我们正在寻找的数据块的方式。在我们查看映射的替代方案之前,让我们先看看一个简单的映射示例:
以下示例展示了如何创建一个简单的映射以及如何根据映射中可用的信息打印一些消息。与其他集合框架中的接口相比,你首先会注意到的是,我们不是向映射中添加元素,而是将元素放入映射中。此外,元素有两个部分:键(在我们的例子中,我们使用字符串)和值(其本质可以是异质的):
import java.util.*;
public class Example15 {
public static void main(String[] args) {
Map map = new HashMap();
map.put("number", new Integer(1));
map.put("text", new String("hola"));
map.put("decimal", new Double(5.7));
System.out.println(map.get("text"));
if (!map.containsKey("byte")) {
System.out.println("There are no bytes here!");
}
}
}
这个程序将给出以下结果:
hola
There are no bytes here!
Process finished with exit code 0
由于代码中没有名为"bytes"的键,maps.containsKey()方法将相应地回答,程序将通知用户这一点。此接口中可用的主要方法有:
-
put(Object key, Object value) -
putAll(Map map) -
remove(Object key) -
get(Object key) -
containsKey(Object key) -
keySet() -
entrySet()
除了最后两个之外,其他都是不言自明的。让我们关注增强我们之前的示例,看看这两个方法的作用。在代码中添加以下内容以查看keySet()和entrySet()能提供什么:
System.out.println(map.entrySet());
System.out.println(map.keySet());
修改后的代码列表的结果将是:
hola
There are no bytes here!
[number=1, text=hola, decimal=5.7]
[number, text, decimal]
Process finished with exit code 0
换句话说,entrySet()将使用键 = 值公式打印整个映射,而keySet()将返回映射中的键集合。
注意
你可能现在已经意识到了:键必须是唯一的——映射中不能有两个相同的键。
我们在此处不会深入探讨映射,因为它们在一定程度上是集合的重复。映射有三个不同的类:HashMap、TreeMap和LinkedHashMap。后两者是有序的,而第一个既没有排序也没有按到达顺序排列。你应该根据你的需求使用这些类。
集合的迭代
在本章早期,当我们正在处理练习 01,创建 AnalyzeInput 应用程序时,我们停止了搜索数据的操作。我们做到了必须遍历数据并查找诸如词频等特征的程度。
迭代器用于 Java 遍历集合。让我们看看一个简单的例子,该例子涉及逐个提取简单列表中的元素并打印它们。
import java.util.*;
public class Example16 {
public static void main(String[] args) {
List array = new ArrayList();
array.add(5);
array.add(2);
array.add(37);
Iterator iterator = array.iterator();
while (iterator.hasNext()) {
// point to next element
int i = (Integer) iterator.next();
// print elements
System.out.print(i + " ");
}
}
}
这个程序的结果是:
5 2 37
Process finished with exit code 0
这种类型的迭代器是集合框架中最通用的,可以与列表、集合、队列以及甚至映射一起使用。还有其他更窄的迭代器实现,允许以不同的方式浏览数据,例如在列表中。正如你在最新的代码列表中看到的,iterator.hasNext()方法检查列表中我们所在节点之后的节点是否存在。当启动迭代器时,对象指向列表中的第一个元素。然后,hasNext()会响应一个布尔值,表示是否有更多的节点悬挂在其上。iterator.next()方法将迭代器移动到集合中的下一个节点。这种迭代器没有在集合中回退的可能性;它只能向前移动。迭代器中还有一个最终的方法,称为remove(),它将从集合中删除迭代器所指向的当前元素。
如果我们使用listIterator(),我们将有更多的选项来导航集合,例如添加新元素和修改元素。以下代码示例演示了如何遍历列表,添加元素并修改它们。listIterator仅与列表一起工作:
import java.util.*;
public class Example17 {
public static void main(String[] args) {
List <Double> array = new ArrayList();
array.add(5.0);
array.add(2.2);
array.add(37.5);
array.add(3.1);
array.add(1.3);
System.out.println("Original list: " + array);
ListIterator listIterator = array.listIterator();
while (listIterator.hasNext()) {
// point to next element
double d = (Double) listIterator.next();
// round up the decimal number
listIterator.set(Math.round(d));
}
System.out.println("Modified list: " + array);
}
}
在这个例子中,我们创建了一个Double列表,遍历列表,并对每个数字进行四舍五入。这个程序的输出是:
Original list: [5.0, 2.2, 37.5, 3.1, 1.3]
Modified list: [5, 2, 38, 3, 1]
Process finished with exit code 0
通过调用listIterator.set(),我们修改列表中的每个项目,第二个System.out.println()命令显示了数字是如何被四舍五入或向下取整的。
在本节中我们将看到的最后一个迭代器示例是一个遍历映射的技巧。这可能在需要在对映射中的数据进行某些操作的场景中很有用。通过使用entrySet()方法——它返回一个列表——可以有一个映射的迭代器。请看以下示例以了解这是如何工作的:
import java.util.*;
public class AnalyzeInput {
public static void main(String[] args) {
Map map = new HashMap ();
map.put("name", "Kristian");
map.put("family name", "Larssen");
map.put("address", "Jumping Rd");
map.put("mobile", "555-12345");
map.put("pet", "cat");
Iterator <Map.Entry> iterator = map.entrySet().iterator();
while (iterator.hasNext()) {
Map.Entry entry = iterator.next();
System.out.print("Key = " + entry.getKey());
System.out.println( ", Value = " + entry.getValue());
}
}
}
这个程序将遍历一个映射,并按它们在HashMap中存储的内容打印出来。请记住,这些类型的对象没有按任何特定的方式进行排序。你可以期待以下输出:
Key = address, Value = Jumping Rd
Key = family name, Value = Larssen
Key = name, Value = Kristian
Key = mobile, Value = 555-12345
Key = pet, Value = cat
Process finished with exit code 0
既然我们现在有了遍历集合的方法,我们可以继续进行一个练习,即迭代列表进行数据分析。
练习 2:将分析功能引入 AnalyzeInput 应用程序
我们将从练习 1,创建 AnalyzeInput 应用程序的结尾开始。我们成功捕获了在终端中输入的文本,并将其存储为字符串列表。这次,我们将使用集合框架中的一个方法,称为frequency,它将返回一个对象在列表中可以找到的次数。由于句子中的单词可能会重复,我们首先需要找出一种方法来提取列表中的唯一元素:
-
集合是集合框架中的对象,它只保留每个元素的一个副本。我们在本章的早期看到了一个例子。我们将创建一个
HashSet实例,并将列表中的所有元素复制到其中。这将自动消除重复项:Set <String> textSet = new HashSet <String> (); textSet.addAll(text); -
现在我们有了集合,下一步是创建一个迭代器,它会检查集合中的每个元素在列表中可以找到多少个副本:
Iterator iterator = textSet.iterator(); -
使用我们在前面的例子中看到的技术,即如何遍历一个集合,我们将找到集合中的下一个节点,并在列表中检查节点中存储的字符串的频率:
while (iterator.hasNext()) { //point to next element String s = (String) iterator.next(); // get the amount of times this word shows up in the text int freq = Collections.frequency(text, s); // print out the result System.out.println(s + " appears " + freq + " times"); }注意
最终的代码可以参考:
packt.live/2BrplvS。 -
结果将取决于你输入的文本类型。为了测试,请尝试以下内容(我们将在本章的其余部分坚持使用这个数据输入 – 你每次调用应用程序时都可以将其复制并粘贴到终端中):
this is a test is a test test is this * -
这个输入的完整结果将是:
You typed: [this, is, a, test, is, a, test, test, is, this, *] Word count: 11 a appears 2 times test appears 3 times this appears 2 times is appears 3 times * appears 1 times虽然结果是正确的,但阅读起来并不容易。理想情况下,结果应该按顺序排序。例如,按频率的降序排列,这样就可以一眼看出最频繁和最不频繁的单词。这是我们在继续前进之前再次停下来进行练习的时候,因为我们需要在继续之前介绍排序的概念。
排序集合
正如我们所见,在集合框架中有些类强制其内部的项进行排序。例如TreeSet和TreeMap。本节要探讨的方面是如何使用现有的排序机制对列表进行排序,以及对于每个数据点有多个值的数据集的情况。
我们在本章中进行的练习是一个很好的例子,其中存在具有多个值的点。对于每个数据点,我们需要存储我们正在计算频率的单词以及频率本身。你可能认为一个好的技术是将信息以映射的形式存储。独特的单词可以是键,而频率可以是值。这可以通过修改上一个程序的最后一部分来实现,如下所示:
Map map = new HashMap();
while (iterator.hasNext()) {
// point to next element
String s = (String) iterator.next();
// get the amount of times this word shows up in the text
int freq = Collections.frequency(text, s);
// print out the result
System.out.println(s + " appears " + freq + " times");
// add items to the map
map.put(s, freq);
}
TreeMap mapTree = new TreeMap();
mapTree.putAll(map);
System.out.println(mapTree);
虽然这是一个有趣且简单的方法来排序(将数据复制到按自然排序的结构中),但它提出了一个问题,即数据是按键排序而不是按值排序,正如以下代码的输出所强调的:
Word count: 11
a appears 2 times
test appears 3 times
this appears 2 times
is appears 3 times
* appears 1 times
{*=1, a=2, is=3, test=3, this=2}
因此,如果我们想按值对这些结果进行排序,我们需要找出一种不同的策略。
但让我们退一步,分析一下集合框架中提供的哪些工具用于排序。有一个名为sort()的方法可以用来排序列表。以下是一个例子:
import java.util.*;
public class Example19 {
public static void main(String[] args) {
List <Double> array = new ArrayList();
array.add(5.0);
array.add(2.2);
array.add(37.5);
array.add(3.1);
array.add(1.3);
System.out.println("Original list: " + array);
Collections.sort(array);
System.out.println("Modified list: " + array);
}
}
这个程序的结果是:
Original list: [5.0, 2.2, 37.5, 3.1, 1.3]
Modified list: [1.3, 2.2, 3.1, 5.0, 37.5]
Process finished with exit code 0
给定一个列表,我们可以这样排序它;甚至可以使用listIterator向后导航,以降序排序列表。然而,这些方法并不能解决对具有多个值的数据点进行排序的问题。在这种情况下,我们需要创建一个类来存储我们自己的键值对。让我们通过继续我们在本章中一直在处理的练习来看看如何实现它。
练习 3:从 AnalyzeInput 应用程序中排序结果
我们现在有一个程序,给定一些输入文本,可以识别文本的一些基本特征,例如文本中的单词数量或每个单词的频率。我们的目标是能够按降序排序结果,使其更容易阅读。这个解决方案需要实现一个类来存储我们的键值对,并从这个类中创建一个对象列表:
-
创建一个包含两个数据点的类:单词及其频率。实现一个构造函数,它将接受值并将它们传递给类变量。这将简化后面的代码:
class DataPoint { String key = ""; Integer value = 0; // constructor DataPoint(String s, Integer i) { key = s; value = i; } } -
在计算每个单词的频率时,将结果存储在新创建的新类的对象列表中:
List <DataPoint> frequencies = new ArrayList <DataPoint> (); while (iterator.hasNext()) { //point to next element String s = (String) iterator.next(); // get the amount of times this word shows up in the text int freq = Collections.frequency(text, s); // print out the result System.out.println(s + " appears " + freq + " times"); // create the object to be stored DataPoint datapoint = new DataPoint (s, freq); // add datapoints to the list frequencies.add(datapoint); } -
排序需要创建一个新的类,使用
Comparator接口,我们现在刚刚介绍。这个接口应该实现一个方法,该方法将在数组中的对象内运行比较。这个新类必须实现Comparator <DataPoint>并包含一个名为compare()的单个方法。它应该有两个参数,即正在排序的类的对象:class SortByValue implements Comparator<DataPoint> { // Used for sorting in ascending order public int compare(DataPoint a, DataPoint b) { return a.value - b.value; } } -
我们使用这个新比较器调用
Collections.sort()算法的方式是将该类的对象作为参数添加到sort方法中。我们直接在调用中实例化它:Collections.sort(frequencies,new SortByValue()); -
这将按升序排序频率列表。要打印结果,不再可以直接调用
System.out.println(frequencies),因为它现在是一个对象数组,它不会将数据点的内容打印到终端。而是以以下方式遍历列表:System.out.println("Results sorted"); for (int i = 0; i < frequencies.size(); i++) System.out.println(frequencies.get(i).value + " times for word " + frequencies.get(i).key); -
如果你使用我们之前在几个示例中使用过的相同输入运行程序,结果将是:
Results sorted 1 times for word * 2 times for word a 2 times for word this 3 times for word test 3 times for word is -
我们的目的是按降序排序结果,为此,我们需要在调用
sort算法时添加一个额外的元素。在实例化SortByValue()类时,我们需要告诉编译器我们希望列表按逆序排序。集合框架已经有一个方法可以做到这一点:Collections.sort(frequencies, Collections.reverseOrder(new SortByValue()));注意
为了清晰起见,最终代码可以参考:
packt.live/2W5qhzP。 -
与这个程序的全交互路径,从我们调用它到包括数据输入,如下所示:
user@localhost:~/IdeaProjects/ch04/out/production/ch04$ java AnalyzeInput this is a test is a test test is this * You typed: [this, is, a, test, is, a, test, test, is, this, *] Word count: 11 a appears 2 times test appears 3 times this appears 2 times is appears 3 times * appears 1 times Results sorted 3 times for word test 3 times for word is 2 times for word a 2 times for word this 1 times for word *
属性
在集合框架中,Properties 用于维护键值对列表,其中两者都是 String 类。当从操作系统获取环境值时,Properties 是相关的,并且是许多其他类的基类。Properties 类的一个主要特征是,它允许在搜索某个键不满意的情况下定义默认响应。以下示例突出了这种情况的基本原理:
Example20.java
1 import java.util.*;
2
3 public class Example20 {
4
5 public static void main(String[] args) {
6 Properties properties = new Properties();
7 Set setOfKeys;
8 String key;
9
10 properties.put("OS", "Ubuntu Linux");
11 properties.put("version", "18.04");
12 properties.put("language", "English (UK)");
13
14 // iterate through the map
15 setOfKeys = properties.keySet();
https://packt.live/2N0CzoS
在深入研究结果之前,您会注意到在属性中,我们使用 put 而不是 add 新元素/节点。这与我们之前看到的映射相同。此外,您会注意到,为了迭代,我们使用了 keySet() 技术,这是我们之前在遍历映射时看到的。最后,Properties 的特殊性在于,您可以在找不到搜索属性的情况下设置默认响应。这就是在示例中搜索 getProperty() 方法将返回其默认消息而不会使程序崩溃的原因。
这个程序的结果是:
version = 18.04
OS = Ubuntu Linux
language = English (UK)
keyboard layout = not found
Process finished with exit code 0
在 Properties 类中还可以找到另一个有趣的方法,即 list();它提供了两种不同的实现,允许您将列表的内容发送到不同的数据处理程序。我们可以将整个属性列表流式传输到 PrintStreamer 对象,例如 System.out。这提供了一种简单的方式来显示列表中的内容,而无需遍历它。以下是一个示例:
import java.util.*;
public class Example21 {
public static void main(String[] args) {
Properties properties = new Properties();
properties.put("OS", "Ubuntu Linux");
properties.put("version", "18.04");
properties.put("language", "English (UK)");
properties.list(System.out);
}
}
这将导致:
version=18.04
OS=Ubuntu Linux
language=English (UK)
Process finished with exit code 0
propertyNames() 方法返回一个 Enumeration 列表,通过遍历它,我们将获得整个列表的键。这是创建集合并运行 keySet() 方法的替代方法。
import java.util.*;
public class Example22 {
public static void main(String[] args) {
Properties properties = new Properties();
properties.put("OS", "Ubuntu Linux");
properties.put("version", "18.04");
properties.put("language", "English (UK)");
Enumeration enumeration = properties.propertyNames();
while (enumeration.hasMoreElements()) {
System.out.println(enumeration.nextElement());
}
}
}
这将导致:
version
OS
language
Process finished with exit code 0
在这一点上,我们将从“属性”部分介绍给您的方法是 setProperty()。它将修改现有键的值,或者在找不到键的情况下最终创建一个新的键值对。如果键存在,该方法将返回旧值,否则返回 null。下一个示例将展示它是如何工作的:
import java.util.*;
public class Example23 {
public static void main(String[] args) {
Properties properties = new Properties();
properties.put("OS", "Ubuntu Linux");
properties.put("version", "18.04");
properties.put("language", "English (UK)");
String oldValue = (String) properties.setProperty("language", "German");
if (oldValue != null) {
System.out.println("modified the language property");
}
properties.list(System.out);
}
}
下面是结果:
modified the language property
-- listing properties --
version=18.04
OS=Ubuntu Linux
language=German
Process finished with exit code 0
注意
Properties 类中还有更多方法用于处理从文件中存储和检索属性列表。虽然这是 Java API 中的一个非常强大的功能,但由于我们尚未在本书中介绍文件的使用,所以我们不会在这里讨论这些方法。有关更多信息,请参阅 Java 的官方文档。
活动二:遍历大型列表
在当代计算中,我们处理大量数据集。这个活动的目的是创建一个随机大小的随机数列表,以便对数据进行一些基本操作,例如获取平均值。
-
首先,你应该创建一个随机数字列表。
-
要计算平均值,你可以创建一个迭代器,它会遍历值列表,并为每个元素添加相应的加权值。
-
从
iterator.next()方法返回的值必须在与其总元素数进行比较之前转换为Double类型。
如果你正确实现了所有内容,平均的结果应该类似于:
Total amount of numbers: 3246
Average: 49.785278826074396
或者,它可能是:
Total amount of numbers: 6475
Average: 50.3373892275651
注意
本活动的解决方案可以在第 539 页找到。
如果你设法使这个程序运行起来,你应该考虑如何利用能够模拟如此大量数据的能力。这些数据可能代表你的应用程序中不同数据到达之间的时间间隔,或者每秒从物联网网络中的节点捕获的温度数据。可能性是无限的。通过使用列表,你可以使数据集的大小像它们的可能性一样无限。
摘要
本章向您介绍了 Java 集合框架,这是 Java 语言中一个非常强大的工具,可以用来存储、排序和过滤数据。该框架非常庞大,提供了接口、类和方法等工具,其中一些超出了本章的范围。我们专注于Arrays、Lists、Sets、Maps和Properties。但还有其他一些,如队列和双端队列,值得你自己去探索。
与它们的数学等价物类似,集合存储唯一的项目副本。列表类似于可以无限扩展的数组,并支持重复项。当处理键值对时使用映射,这在当代计算中非常常见,并且不支持使用两个相同的键。属性的工作方式非常类似于HashMap(Map的一种特定类型),但提供了一些额外功能,例如将所有内容列出到流中,这简化了列表内容的打印。
框架中提供的某些类按设计排序,例如TreeHash和TreeMap,而其他类则不是。根据你想要如何处理数据,你必须决定哪种集合是最好的。
使用迭代器遍历数据有一些标准技术。这些迭代器在创建时将指向列表中的第一个元素。迭代器提供了一些基本方法,如hasNext()和next(),分别用于判断列表中是否有更多数据以及从列表中提取数据。虽然这两个方法对所有迭代器都是通用的,但还有一些其他方法,如listIterator,功能更强大,例如,在遍历列表的同时向列表中添加新元素。
我们已经查看了一章长度的示例,其中使用了这些技术中的许多,并且我们介绍了如何通过终端使用控制台读取数据。在下一章中,我们将介绍异常及其处理方法。
第五章:5. 异常
概述
本章讨论了 Java 中异常的处理方式。你将首先学习如何识别代码中产生异常的情况。这种知识将简化处理这些异常的过程,因为它会提醒你在最有可能出现这些异常的情况下。在此过程中,本章还提供了一份最佳实践列表,指导你通过常见场景和最佳方法来捕获异常或将其抛给调用类,并在执行过程中记录其详细信息。你还将学习区分不同类型的异常,并练习处理每种异常的技术。到本章结束时,你甚至能够创建自己的异常类,能够按照严重程度顺序记录每种类型的异常。
简介
异常不是错误,或者更准确地说,异常不是 bug,即使它们可能在你程序崩溃时让你觉得它们是。异常是在你的代码中发生的情况,当处理的数据与用于处理它的方法或命令不匹配时。
在 Java 中,有一个类是专门用于错误的。错误是影响程序在Java 虚拟机(JVM)层面的意外情况。例如,如果你通过非传统方式使用内存填满程序栈,那么整个 JVM 都会崩溃。与错误不同,异常是当你的代码设计得当,可以即时捕获的情况。
异常并不像错误那样严重,即使对你这个开发者来说结果可能相同——也就是说,一个无法工作的程序。在本章中,我们邀请你通过故意引发你将后来学习如何捕获(即处理)并避免的异常来让你的程序崩溃。根据你如何开发捕获机制,你可以决定是让程序恢复并继续运行,还是优雅地结束其执行,并显示一个人类可读的错误消息。
简单异常示例
首先,在你的代码中引发一个简单的异常。首先,在集成开发环境(IDE)中输入以下程序并执行它:
public class Example01 {
public static void main(String[] args) {
// declare a string with nothing inside
String text = null;
// you will see this at the console
System.out.println("Go Java Go!");
// null'ed strings should crash your program
System.out.println(text.length());
// you will never see this print
System.out.println("done");
}
}
这里是输出:
Go Java Go!
Exception in thread "main" java.lang.NullPointerException
at Example01.main(Example01.java:11)
Process finished with exit code 1
之前的代码列表显示了程序开始执行一个运行良好的命令。在控制台上打印了Go Java Go!这句话,但随后出现了一个NullPointerException,突出显示发生了异常情况。在这种情况下,我们尝试通过调用text.length()来打印一个由 null 初始化的字符串的长度。由于没有长度可以计算(也就是说,我们甚至没有空字符串),System.out.println()或text.length()触发了异常。此外,在那个点还有一个错误,所以程序退出了,最后的System.out.println("done")调用没有执行。你可以尝试将这两个命令分开,看看结果会怎样:
// null'ed strings should crash your program
int number = text.length();
System.out.println(number);
这里是输出:
Go Java Go!
Exception in thread "main" java.lang.NullPointerException
at Example01.main(Example01.java:11)
Process finished with exit code 1
如果你检查 IDE 中的行号,你会看到异常发生在我们尝试获取字符串长度的那一行。既然我们已经知道了问题的原因,就有两种方法可以解决这个问题:要么修复数据(注意,有些情况下这是不可能的),要么在我们的代码中包含一种对策来检测异常,然后处理或忽略它们。处理意外事件的行为就是我们所说的捕获异常。另一方面,绕过事件的行为被称为抛出异常。在章节的后面,我们将探讨执行这两种行为的不同方法,以及编写代码处理异常时的良好实践。
然而,在了解如何避免或处理异常之前,让我们再引发一些异常。几乎每个 Java API 都包含了异常的定义,这有助于将错误传播到主类,从而传递给开发者。这样,就可以避免代码在用户面前崩溃的情况。
Java API 涵盖的异常就是我们所说的内置异常。当定义一个类时,你也可以创建自己的异常。谈到类,让我们尝试从一个由String实例化的对象中获取一个不存在位置的字符,看看会发生什么:
public class Example02 {
public static void main(String[] args) {
// declare a string of a fixed length
String text = "I <3 bananas"; // 12 characters long
// provoke an exception
char character = text.charAt(15); // get the 15th element
// you will never see this print
System.out.println("done");
}
}
IDE 将做出以下响应:
Exception in thread "main" java.lang.StringIndexOutOfBoundsException: String index out of range: 15
at java.lang.String.charAt(String.java:658)
at Example02.main(Example02.java:8)
Process finished with exit code 1
注意,文本变量只有 12 个字符长。当尝试提取第 15 个字符时,IDE 将抛出异常并终止程序。在这种情况下,我们得到了一个名为StringOutOfBoundsException的异常。存在许多不同类型的内置异常。
这里是各种异常类型的一个列表:
-
NullPointerException -
StringOutOfBoundsException -
ArithmeticException -
ClassCastException -
IllegalArgumentException -
IndexOutOfBoundsException -
NumberFormatException -
IllegalAccessException -
InstantiationException -
NoSuchMethodException
如你所见,不同异常的名称相当具有描述性。当你遇到一个异常时,应该很容易在 Java 文档中找到更多关于它的信息,以便减轻问题。我们将异常分为检查型或非检查型:
-
检查型异常:这些在编译期间会被突出显示。换句话说,你的程序将无法完成编译过程,因此你将无法运行它。
-
NullPointerException和StringOutOfBoundsException都是非检查型。为什么有两种类型的异常?
异常有两种可能性:要么我们作为开发者犯错,没有意识到我们处理数据的方式会产生错误(例如,当我们试图获取空字符串的长度或当我们除以零时),要么错误发生是因为我们对与程序外部交换的数据的性质不确定(例如,从 CLI 获取参数且类型错误)。在第一种情况下,检查异常更有意义。第二种场景是我们需要未检查异常的原因。在这种情况下,我们应该制定策略来处理可能威胁程序正确执行的风险。
创建一个检查异常的例子稍微复杂一些,因为我们必须预测一些直到稍后的章节才会深入介绍的内容。然而,我们认为以下示例,它展示了IOException的例子,即使它包含了一些书中尚未涉及到的类,也足够简单:
import java.nio.file.*;
import java.util.*;
public class Example03 {
public static void main(String[] args) {
// declare a list that will contain all of the files
// inside of the readme.txt file
List<String> lines = Collections.emptyList();
// provoke an exception
lines = Files.readAllLines(Paths.get("readme.txt"));
// you will never see this print
Iterator<String> iterator = lines.iterator();
while (iterator.hasNext())
System.out.println(iterator.next());
}
}
在这个代码列表中最新的是java.nio.file.*的使用。这是一个包括用于管理文件等内容的类和方法的 API。这个程序的目标是将整个名为 readme.txt 的文本文件读入一个列表中,然后使用迭代器打印出来,正如我们在第四章,集合、列表和 Java 内置 API中看到的。
这是一个在调用Files.readAllLines()时可能发生检查异常的情况,因为没有文件可读,例如,由于文件名声明错误。IDE 知道这一点,因此它会标记存在潜在风险。
注意 IDE 从我们编写代码的那一刻起就显示警告。此外,当我们尝试编译程序时,IDE 将响应如下:
Error:(11, 35) java: unreported exception java.io.IOException; must be caught or declared to be thrown
捕获和抛出是你可以用来避免异常的两种策略。我们将在本章后面更详细地讨论它们。
NullPointerException – 不要害怕
我们在之前的一章中介绍了 Java 中的null概念。你可能还记得,null是在创建对象时隐式分配给对象的值,除非你给它分配不同的值。与null相关的是NullPointerException值。这是一个非常常见的事件,可能会发生,原因有很多。在本节中,我们将突出一些最常见的场景,以便让你在处理代码中的任何类型异常时有一个不同的思维方式。
在Example01中,我们检查了尝试对指向null的对象执行操作的过程。让我们看看一些其他可能的案例:
public class Example04 {
public static void main(String[] args) {
String vehicleType = null;
String vehicle = "car";
if (vehicleType.equals(vehicle)) {
System.out.println("it's a car");
} else {
System.out.println("it's not a car");
}
}
}
这个示例的结果将是以下内容:
Exception in thread "main" java.lang.NullPointerException
at Example04.main(Example04.java:5)
Process finished with exit code 1
你本可以防止这个异常,如果你在编写代码时将现有变量与可能为null的变量进行比较的话。
public class Example05 {
public static void main(String[] args) {
String vehicleType = null;
String vehicle = "car";
if (vehicle.equals(vehicleType)) {
System.out.println("it's a car");
} else {
System.out.println("it's not a car");
}
}
}
上述代码将产生以下结果:
it's not a car
Process finished with exit code 0
如你所见,这些示例在概念上没有区别;然而,在代码级别上存在差异。这种差异足以在编译时使你的代码引发异常。这是因为String类的equals()方法已经准备好处理其参数为null的情况。另一方面,初始化为null的String变量无法访问equals()方法。
当尝试从一个初始化为null的对象调用非静态方法时,会引发NullPointerException的非常常见的情况。以下示例展示了一个包含两个方法的类,你可以调用这些方法来查看它们是否会产生异常。你可以通过简单地注释或取消注释main()中调用这些方法的每一行来实现这一点。将代码复制到 IDE 中并尝试两种情况:
public class Example06 {
private static void staticMethod() {
System.out.println("static method, accessible from null reference");
}
private void nonStaticMethod() {
System.out.print("non-static method, inaccessible from null reference");
}
public static void main(String args[]) {
Example06 object = null;
object.staticMethod();
//object.nonStaticMethod();
}
}
当这种异常出现时,还有其他情况,但让我们专注于如何处理异常。以下章节将描述你可以使用的不同机制来使你的程序能够从意外情况中恢复。
捕获异常
如前所述,处理异常有两种方式:捕获和抛出。在本节中,我们将处理这些方法中的第一种。捕获异常需要将可能产生不期望结果的代码封装到特定的语句中,如下面的代码片段所示:
try {
// code that could generate an exception of the type ExceptionM
} catch (ExceptionM e) {
// code to be executed in case of exception happening
}
我们可以用之前的任何示例来测试这段代码。让我们演示如何停止章节第一个示例中发现的异常,在那个示例中,我们尝试检查一个初始化为null的字符串的长度:
public class Example07 {
public static void main(String[] args) {
// declare a string with nothing inside
String text = null;
// you will see this at the console
System.out.println("Go Java Go!");
try {
// null'ed strings should crash your program
System.out.println(text.length());
} catch (NullPointerException ex) {
System.out.println("Exception: cannot get the text's length");
}
// you will now see this print
System.out.println("done");
}
}
如你所见,我们将可能出错的代码包裹在一个try-catch语句中。这段代码列表的结果与我们之前看到的结果非常不同:
Go Java Go!
Exception: cannot get the text's length
done
Process finished with exit code 0
主要,我们发现程序直到结束都不会被中断。程序的try部分检测到异常的到来,如果异常是NullPointerException类型,catch部分将执行特定的代码。
可以在try调用之后按顺序放置多个catch语句,作为检测不同类型异常的一种方式。为了尝试这一点,让我们回到我们尝试打开一个不存在的文件并尝试捕获readAllLines()停止程序的例子里:
Example08.java
5 public class Example08 {
6 public static void main(String[] args) {
7 // declare a list that will contain all of the files
8 // inside of the readme.txt file
9 List<String> lines = Collections.emptyList();
10
11 try {
12 // provoke an exception
13 lines = Files.readAllLines(Paths.get("readme.txt"));
14 } catch (NoSuchFileException fe) {
15 System.out.println("Exception: File Not Found");
16 } catch (IOException ioe) {
17 System.out.println("Exception: IOException");
18 }
https://packt.live/2VU59wh
如我们在本章前面所见,我们编写了一个尝试打开一个不存在的文件的程序。我们当时得到的异常是IOException。实际上,这个异常是由NoSuchFileException触发的,它被升级并触发IOException。因此,我们在 IDE 中得到了这个异常。在实现多个try-catch语句,如前例所示时,我们得到以下结果:
Exception: File Not Found
Process finished with exit code 0
这意味着程序检测到NoSuchFileException,因此打印出相应的捕获语句中的消息。然而,如果你想看到由不存在的 readme.txt 文件触发的异常的完整序列,你可以使用一个名为printStackTrace()的方法。这将向输出发送程序正确执行过程中的一切。要查看这一点,只需将以下突出显示的更改添加到前面的示例中:
try {
// provoke an exception
lines = Files.readAllLines(Paths.get("readme.txt"));
} catch (NoSuchFileException fe) {
System.out.println("Exception: File Not Found");
fe.printStackTrace();
} catch (IOException ioe) {
System.out.println("Exception: IOException");
}
程序的输出现在将包括程序执行期间触发的不同异常的完整打印输出。你会看到堆栈输出是倒置的:首先,你会看到程序停止的原因(NoSuchFileException),然后它将以引发异常的过程开始的方法结束(readAllLines)。这是由于异常构建的方式。正如我们稍后将要讨论的,有许多不同类型的异常。每一种类型都被定义为异常类,这些类可以由几个其他异常子类扩展。如果发生某种类型的扩展,那么它所扩展的类也会在打印堆栈时出现。在我们的例子中,NoSuchFileException是IOException的子类。
注意
根据你的操作系统,处理打开文件的不同嵌套异常可能被称为不同的名称。
我们已经捕获了两种不同的异常——一个嵌套在另一个内部。也应该能够处理来自不同类别的异常,例如IOException和NullPointerException。以下示例演示了如何做到这一点。如果你正在处理不是彼此子类的异常,你可以使用逻辑或运算符来捕获这两个异常:
import java.io.*;•
import java.nio.file.*;
import java.util.*;
public class Example09 {
public static void main(String[] args) {
List<String> lines = Collections.emptyList();
try {
lines = Files.readAllLines(Paths.get("readme.txt"));
} catch (NullPointerException|IOException ex) {
System.out.println("Exception: File Not Found or NullPointer");
ex.printStackTrace();
}
// you will never see this print
Iterator<String> iterator = lines.iterator();
while (iterator.hasNext())
System.out.println(iterator.next());
}
}
如你所见,你可以在单个catch语句中处理这两个异常。然而,如果你想以不同的方式处理异常,你必须与包含异常信息的对象一起工作,在这个例子中是ex。你需要区分你可能同时处理的异常的关键字是instanceof,如下面前面示例的修改所示:
try {
// provoke an exception
lines = Files.readAllLines(Paths.get("readme.txt"));
} catch (NullPointerException|IOException ex) {
if (ex instanceof IOException) {
System.out.println("Exception: File Not Found");
}
if (ex instanceof NullPointerException) {
System.out.println("Exception: NullPointer");
}
}
在一个单独的try块中你能捕获多少种不同的异常?
事实上,你可以根据需要将尽可能多的catch语句链接起来。如果你使用本章讨论的第二种方法(即使用 OR 语句),你应该记住,不可能同时有一个子类及其父类。例如,不可能在同一个语句中将NoSuchFileException和IOException放在一起——它们应该放在两个不同的catch语句中。
练习 1:记录异常
在捕获异常时,除了您可能想要执行以响应情况的任何类型的创造性编码之外,您还可以执行两种主要操作;这些操作是记录或抛出。在本练习中,您将学习如何记录异常。在后续练习中,您将学习如何抛出它。正如我们将在本章的“异常处理最佳实践”部分中重申的那样,您永远不应该同时执行这两者:
-
在 IntelliJ 中使用 CLI 模板创建一个新的 Java 项目。将其命名为 LoggingExceptions。您将在其中创建类,以后可以在其他程序中使用它们。
-
在代码中,您需要通过以下命令导入日志 API:
import java.util.logging.*; -
声明一个您将用于记录数据的对象。此对象将在程序终止时打印到终端;因此,您无需担心它此时会出现在哪里:
Logger logger = Logger.getAnonymousLogger(); -
按如下方式引发异常:
String s = null; try { System.out.println(s.length()); } catch (NullPointerException ne) { // do something here } -
在捕获异常时,使用
log()方法将数据发送到日志对象:logger.log(Level.SEVERE, "Exception happened", ne); -
您的完整程序应如下所示:
import java.util.logging.*; public class LoggingExceptions { public static void main(String[] args) { Logger logger = Logger.getAnonymousLogger(); String s = null; try { System.out.println(s.length()); } catch (NullPointerException ne) { logger.log(Level.SEVERE, "Exception happened", ne); } } } -
当您执行代码时,输出应如下所示:
may 09, 2019 7:42:05 AM LoggingExceptions main SEVERE: Exception happened java.lang.NullPointerException at LoggingExceptions.main(LoggingExceptions .java:10) Process finished with exit code 0 -
如您所见,异常被记录在确定的
SEVERE级别,但由于我们能够处理异常,代码结束时没有错误代码。日志很有用,因为它告诉我们异常发生在代码的哪个位置,并且还帮助我们找到可以进一步深入代码并修复任何潜在问题的位置。
抛出和抛出
您可以选择不在代码的低级别处理一些捕获的异常,如前所述。过滤掉异常的父类并关注检测对我们可能更有重要性的子类可能很有趣。throws关键字用于您正在创建的方法的定义以及可能发生异常的地方。在以下情况下,这是对示例 09的修改,我们应该在main()的定义中调用throws:
import java.io.*;
import java.nio.file.*;
import java.util.*;
public class Example10 {
public static void main(String[] args) throws IOException {
// declare a list that will contain all of the files
// inside of the readme.txt file
List<String> lines = Collections.emptyList();
try {
lines = Files.readAllLines(Paths.get("readme.txt"));
} catch (NoSuchFileException fe) {
System.out.println("Exception: File Not Found");
//fe.printStackTrace();
}
// you will never see this print
Iterator<String> iterator = lines.iterator();
while (iterator.hasNext())
System.out.println(iterator.next());
}
}
如您所见,我们在运行时抛出了任何IOException。这样,我们可以专注于捕获实际发生的异常:NoSuchFileException。通过使用逗号分隔,可以以这种方式抛出多个异常类型。
这种方法定义的一个例子如下:
public static void main(String[] args) throws IOException, NullPointerException {
一件不可能的事情是在同一个方法定义中抛出异常类及其子类——正如我们在尝试在单个catch语句中捕获多个异常时所看到的那样。还有一点也很有趣,即throws在某个范围内操作;例如,我们可以在类的方法中忽略某个异常,但不能在另一个类中忽略。
另一方面,随着你对术语理解的深入,你还会发现另一个关键字对于处理异常非常有用。throw 关键字(注意这不同于 throws)将显式地引发一个异常。你可以使用它来创建自己的异常并在代码中尝试它们。我们将在后面的部分演示如何创建自己的异常,然后我们将使用 throw 作为示例的一部分来查看异常是如何传播的。使用 throw 的主要原因是如果你想让你的代码将类内部发生的异常传递给层次结构中的另一个类。为了了解这是如何工作的,让我们看看以下示例:
public class Example11 {
public static void main(String args[]) {
String text = null;
try {
System.out.println(text.length());
} catch (Exception e) {
System.out.println("Exception: this should be a NullPointerException");
throw new RuntimeException();
}
}
}
在这种情况下,我们通过尝试在初始化为 null 的字符串上调用 length() 方法来重现我们之前看到的 NullPointerException 示例。然而,如果你运行此代码,你会看到显示的异常是 RuntimeException:
Exception: this should be a NullPointerException
Exception in thread "main" java.lang.RuntimeException
at Example11.main(Example11.java:9)
Process finished with exit code 1
这是因为我们在 catch 块中发出的 throw new RuntimeException() 调用。正如你所看到的,在处理异常时,我们正在引发一个不同的异常。这可以非常有助于捕获异常并将它们通过你自己的异常传递,或者简单地捕获异常,给出一个有意义的消息来帮助用户理解发生了什么,然后让异常继续其自己的路径,如果异常没有被代码中的更高层次处理,最终导致程序崩溃。
练习 2:违反规则(并修复它)
在这个例子中,你将创建自己的检查异常类。你将定义一个类,然后通过引发该异常、记录其结果并分析它们来进行实验:
-
在 IntelliJ 中使用 CLI 模板创建一个新的 Java 项目。将其命名为
BreakingTheLaw。你将在其中创建类,稍后可以在其他程序中使用这些类。 -
在代码中创建一个新的类来描述你的异常。这个类应该扩展基本
Exception类。命名为MyException并包含一个空构造函数:public class BreakingTheLaw { class MyException extends Exception { // Constructor MyException() {}; } public static void main(String[] args) { // write your code here } } -
你的构造函数应该包含所有可能抛出的异常。这意味着构造函数需要考虑几个不同的案例:
// Constructor public MyException() { super(); } public MyException(String message) { super(message); } public MyException(String message, Throwable cause) { super(message, cause); } public MyException(Throwable cause) { super(cause); } -
这将允许我们现在将任何异常包裹在我们的新形成的异常中。然而,我们需要对我们的程序进行一些修改,以便它能够编译。首先,我们需要将异常类设置为静态,以便在当前使用它的上下文中工作:
public static class MyException extends Exception { -
接下来,你需要确保主类正在抛出你新创建的异常,因为你将在代码中发出这个异常:
public static void main(String[] args) throws MyException { -
最后,你需要生成一些代码,当尝试获取初始化为
null的String的长度时,将引发异常,例如NullPointerException,然后捕获它,并使用我们新创建的类将其丢弃:public static void main(String[] args) throws MyException { String s = null; try { System.out.println(s.length()); } catch (NullPointerException ne) { throw new MyException("Exception: my exception happened"); } } -
运行此代码的结果如下:
Exception in thread "main" BreakingTheLaw$MyException: Exception: my exception happened at BreakingTheLaw.main(BreakingTheLaw.java:26) Process finished with exit code 1 -
你现在可以通过使用类中的任何其他构造函数来实验
throw的调用。我们刚刚尝试了一个包含我们自己的错误消息的例子,所以让我们添加异常的堆栈跟踪:throw new MyException("Exception: my exception happened", ne); -
将使输出稍微更有信息量的,是它现在将包括有关生成我们自己的
NullPointerException的异常的信息:Exception in thread "main" BreakingTheLaw$MyException: Exception: my exception happened at BreakingTheLaw.main(BreakingTheLaw.java:26) Caused by: java.lang.NullPointerException at BreakingTheLaw.main(BreakingTheLaw.java:24) Process finished with exit code 1你现在已经学会了如何使用
throw将异常包装到自己的异常类中。当处理大型代码库并需要在长日志文件中查找由你的代码生成的异常时,这会非常有用。注意
最终代码可以参考:
packt.live/2VVdy2f。
finally块
可以使用finally块在代码中用于处理一系列不同异常的任何catch块之后执行一些通用代码。回到我们尝试打开一个不存在的文件的例子,包括一个finally语句的修改版本如下:
Example12.java
11 try {
12 // provoke an exception
13 lines = Files.readAllLines(Paths.get("readme.txt"));
14 } catch (NoSuchFileException fe) {
15 System.out.println("Exception: File Not Found");
16 } catch (IOException ioe) {
17 System.out.println("Exception: IOException");
18 } finally {
19 System.out.println("Exception: Case Closed");
20 }
https://packt.live/2VTBFOS
上述示例的输出如下:
Exception: File Not Found
Exception: Case Closed
Process finished with exit code 0
在检测到NoSuchFileException的catch块之后,处理机制跳入finally块并执行其中的任何内容,在这种情况下,意味着向输出打印另一行文本。
活动一:设计一个记录数据的异常类
我们已经看到了如何记录异常和抛出异常的例子。我们还学习了如何创建异常类并抛出它们。有了所有这些信息,这个活动的目标是创建一个自己的异常类,该类应该根据严重程度记录不同的异常。你应该制作一个基于程序参数的应用程序,程序将以不同的方式响应记录的异常。为了有一个共同的基础,使用以下标准:
-
如果输入是数字 1,则抛出
NullPointerException,严重程度为 SEVERE。 -
如果输入是数字 2,则抛出
NoSuchFileException,严重程度为 WARNING。 -
如果输入是数字 3,则抛出
NoSuchFileException,严重程度为 INFO。 -
为了制作这个程序,你需要考虑创建自己的异常抛出方法,例如以下内容:
public static void issuePointerException() throws NullPointerException { throw new NullPointerException("Exception: file not found"); } public static void issueFileException() throws NoSuchFileException { throw new NoSuchFileException("Exception: file not found"); }注意
这个活动的解决方案可以在第 540 页找到。
处理异常的最佳实践
在你的代码中处理异常需要遵循一系列最佳实践,以避免在编写程序时出现更深层的问题。这个常见实践列表对你的代码来说很重要,以保持一定程度的专业编程一致性:
第一条建议是避免抛出或捕获主Exception类。处理异常时,你需要尽可能具体。因此,以下情况是不推荐的:
public class Example13 {
public static void main(String args[]) {
String text = null;
try {
System.out.println(text.length());
} catch (Exception e) {
System.out.println("Exception happened");
}
}
}
这段代码将捕获任何异常,没有粒度。那么,你应该如何以这种方式正确处理异常呢?
在下一节中,我们将快速回顾 Exception 类在 Java API 结构中的位置。我们将检查它如何与 Error 类在同一级别悬挂在 Throwable 类上。因此,如果你捕获了 Throwable 类,你可能会掩盖代码中发生的可能错误,而不仅仅是异常。记住,错误是那些你的代码应该退出的情况,因为它们会警告到可能导致 JVM 资源误用的真实故障。
在catch块后面掩盖这样的场景可能会使整个 JVM 停滞。因此,避免以下代码:
try {
System.out.println(text.length());
} catch (Throwable e) {
System.out.println("Exception happened");
}
在练习 2,违法(并修复它)中,你看到了如何创建自己的异常类。正如讨论的那样,通过使用throw可以将异常重定向到其他地方。不忽视原始异常的堆栈跟踪是一种好习惯,因为它将帮助你更好地调试问题的来源。因此,在捕获原始异常时,你应该考虑将整个堆栈跟踪作为参数传递给异常构造函数:
} catch (OriginalException e) {
throw new MyVeryOwnException("Exception trace: ", e);
}
在同样的练习中,当你自己创建异常时,你学习了如何使用系统日志来存储异常信息。你应该避免记录异常和再次抛出它。你应该尽量在代码中记录最高级别的信息。否则,你的日志中会出现关于情况的重复信息,使得调试变得更加复杂。因此,我们建议你使用以下方法:
throw new NewException();
或者,你可以在同一个catch块中使用以下内容,但不能同时使用:
log.error("Exception trace: ", e);
此外,在记录信息时,尽量使用系统日志的单次调用。随着你的代码越来越大,会有多个进程并行工作,因此会有很多不同的来源发出日志命令:
log.debug("Exception trace happened here");
log.debug("It was a bad thing");
这很可能不会在日志中显示为连续的两行,而是显示为间隔较远的两行。相反,你应该这样做:
log.debug("Exception trace happened here. It was a bad thing");
当处理多个异常时,一些是其他异常的子类,你应该按照顺序捕获它们,从最具体的开始。我们在本章的一些示例中看到了这一点,例如处理NoSuchFileException和IOException。你的代码应该看起来像这样:
try {
tryAnExceptionCode();
} catch (SpecificException se) {
doTheCatch1();
} catch (ParentException pe) {
doTheCatch2();
}
如果你根本不打算捕获异常,但仍然被迫使用try块来编译代码,请使用finally块来关闭在异常之前启动的所有操作。一个例子是打开一个在离开方法之前应该关闭的文件,这将会因为异常而发生:
try {
tryAnExceptionCode();
} finally {
closeWhatever();
}
throw 关键字是一个非常强大的工具,正如你所注意到的。能够重定向异常允许你为不同的情境创建自己的策略,并且此外,这意味着你不必依赖于 JVM 默认提供的策略。然而,你在捕获时应该小心放置 throw 在某些块中。你应该避免在 finally 块中使用 throw,因为这会掩盖异常的原始原因。
在处理 Java 异常时,这在某种程度上遵循了“尽早抛出,晚些捕获”的原则。想象一下,你正在进行一个低级操作,它是更大方法的一部分。例如,你正在打开一个文件,作为解析其内容并查找模式的一段代码的一部分。如果打开文件的操作由于异常而失败,那么简单地 throw 该异常给后续的方法,以便它能够将其置于上下文中,并能够在更高层次上决定如何继续整个任务,这是一个更好的选择。你应该只在能够做出最终决策的更高层次上处理异常。
我们在之前的例子中看到了 printStackTrace() 的使用,作为一种查看异常完整来源的方法。虽然当调试代码时能够看到这一点非常有趣,但如果不处于那种心态,它几乎是没有关系的。因此,你应该确保删除或注释掉你可能使用过的所有 printStackTrace() 命令。如果以后需要,其他开发者将不得不在分析代码时确定他们想要放置探针的位置。
以类似的方式,当你在方法内部以任何方式处理异常时,你应该记得在 Javadoc 中正确地记录事情。你应该添加一个 @throws 声明来明确指出哪种异常到达,以及它是被处理、传递还是其他什么情况:
/**
* Method name and description
*
* @param input
* @throws ThisStrangeException when ...
*/
public void myMethod(Integer input) throws ThisStrangeException {
...
}
异常从何而来?
离开我们在本章中遵循的更实际的途径,现在是时候从更宏观的角度来看待问题,并理解在 Java API 的更大框架中事物从何而来。正如前一个部分提到的,异常悬挂在 Throwable 类上,它是 java.lang 包的一部分。它们与错误(我们之前解释过)处于同一级别。换句话说,Exception 和 Error 都是 Throwable 的子类。
只有Throwable类的对象实例可以通过 Java 的throw语句抛出;因此,我们必须使用这个类作为起点来定义自己的异常。正如 Java 文档中关于Throwable类的说明,这包括创建时的执行栈快照。这允许您查找异常(或错误)的来源,因为它包括了当时计算机内存的状态。可抛出对象可以包含构建它的原因。这就是所谓的链式异常功能,因为一个异常事件可能是由一系列异常引起的。这是我们分析本章中某些程序堆栈跟踪时看到的情况。
摘要
我们在本章中采用了非常实际的方法。我们首先让您的代码以不同的方式出错,然后解释了错误和异常之间的区别。我们专注于处理后者,因为这些是唯一不应该立即使您的程序崩溃的情况。
异常可以通过捕获或抛出进行处理。前者是通过观察不同的异常并定义不同的策略,通过 try-catch 语句来应对这些情况。您可以选择将异常重新发送到不同的类中使用throw,或者在catch块中响应。无论您遵循哪种策略,您都可以通过finally块设置系统在处理异常后执行一些最后的代码行。
本章还包括了一系列关于如何在更概念层面上处理异常的建议。您有一份最佳实践清单,任何专业程序员都会遵循。
最后,在实践层面,您完成了一系列练习,这些练习引导您通过处理异常的经典场景,并且您已经看到了可以用来调试代码的不同工具,例如日志和printStackTrace()。
第六章:6. 库、包和模块
概述
本章将向您介绍打包和捆绑 Java 代码的各种方法,以及帮助构建您自己的 Java 项目的工具。第一步是学习如何将代码组织到包中,这样您就可以从这些包中构建一个Java ARchive(JAR)文件。从那里,您将练习使用 Maven 和 Gradle 等 Java 构建工具创建可执行 JAR 文件,这将进一步帮助您在项目中包含第三方开源库。到本章结束时,您将准备好创建自己的 Java 模块来组合您的包。
简介
任何复杂的 Java 应用程序都将需要许多独立的 Java 类。Java 提供了几种帮助您组织类的方法;其中之一就是包的概念。您可以将多个编译包组合成一个 Java 库,或者一个Java ARchive(JAR)文件。此外,您可以使用模块在代码中提供更高层次的抽象,仅暴露您认为适当的元素。
当您开始创建较大的应用程序时,您将想要利用 Java 的便捷构建工具——其中 Maven 和 Gradle 是最受欢迎的。构建工具使得构建可能依赖于其他项目和库的大型项目更加容易。构建工具还提供了运行测试和打包项目的标准方式。
Maven 和 Gradle 在将第三方开源库包含到您的应用程序中方面提供了显著的帮助。有数千个这样的库可供使用。
将代码组织到包中
Java 包将相关的类、接口、枚举(包含固定组常量的数据类型)和注解(包含元数据)组合在一起。换句话说,包是一组以共同名称汇集的 Java 类型。使用共同名称使得在较大的项目中查找代码更加容易,并有助于将您的代码与其他可能相似的代码分开。例如,可能不止一个包包含名为Rectangle的类,因此引用适当的包将允许您指定您正在寻找的哪个Rectangle类。包允许您组织您的代码,随着您处理越来越大的应用程序,这一点变得越来越重要。
Java 的 API 包括数百个类,这些类被分为包,例如java.math和java.net。正如您所期望的,java.math包含与数学相关的类,而java.net包含与网络相关的类。
导入类
当您使用java.lang包之外的包中的 Java 类时,您需要使用import语句来导入它们。Java 编译器默认导入java.lang包中的所有类。其余的则由您自己决定。
这里有一个例子:
import java.time.DayOfWeek;
import java.time.LocalDateTime;
此代码从 java.time 包中导入了两种类型,DayOfWeek 和 LocalDateTime。现在,DayOfWeek 是一个表示星期的 Java enum,而 LocalDateTime 是一个包含日期和时间的类。
一旦你导入了这些类型,你就可以在代码中使用它们,如下所示:
LocalDateTime localDateTime = LocalDateTime.now();
DayOfWeek day = localDateTime.getDayOfWeek();
System.out.println("The week day is: " + day);
练习 1:导入类
在这个练习中,我们将显示当前是星期几,并使用 java.time 包提取系统日期和时间。
-
在 IntelliJ 中,从
文件菜单中选择文件、新建,然后选择项目。 -
在
新建项目对话框中,选择一个 Java 项目。点击下一步。 -
打开复选框以从模板创建项目。点击
命令行应用。点击下一步。 -
将项目命名为
chapter06。 -
对于项目的位置,点击带有三个点 (
…) 的按钮,然后选择你之前创建的源文件夹。 -
将
com.packtpub.chapter06作为基本包名输入。我们将在本章后面更多地使用包。 -
点击
完成。IntelliJ 将创建一个名为
chapter06的项目,并在chapter06内部创建一个src文件夹。这是你的 Java 代码将驻留的地方。在这个文件夹内,IntelliJ 将为com、packtpub和chapter06创建子文件夹。IntelliJ 还会创建一个名为
Main的类:public class Main { public static void main(String[] args) { // write your code here } }将名为
Main的类重命名为Example01。 -
在文本编辑器窗口中双击单词
Main。 -
右键点击并从菜单中选择
重构|重命名…。 -
输入
Example01并按 Enter。你现在会看到以下代码:
public class Example01 { public static void main(String[] args) { // write your code here } }现在,在
main()方法内输入以下代码:LocalDateTime localDateTime = LocalDateTime.now(); DayOfWeek day = localDateTime.getDayOfWeek(); System.out.println("The weekday is: " + day);IntelliJ 应该会提供导入两种类型
DayOfWeek和LocalDateTime的选项。如果由于某种原因你点击了错误的按钮,你可以在包声明之后和类定义之前添加以下几行:package com.packtpub.chapter06; import java.time.DayOfWeek; import java.time.LocalDateTime; public class Example01 { -
现在,点击文本编辑器窗口左侧指向类名
Example01的绿色箭头。选择第一个菜单选项,运行Example01.main()。 -
在
运行窗口中,你会看到你的 Java 程序的路径,然后是一些输出,如下所示:The weekday is: SATURDAY你应该能看到当前是星期几。
包声明标识了此代码所在的包。请参阅本章后面的 创建包 部分,了解更多关于此主题的信息。
完全限定类名
你不必使用 import 语句。相反,你可以使用完全限定类名,如下所示:
java.time.LocalDateTime localDateTime = java.time.LocalDateTime.now();
完全限定名称包括包和类型名称。以下示例也会给出与 练习 01、导入类 相同的结果。
package com.packtpub.chapter06;
public class Example02 {
public static void main(String[] args) {
java.time.LocalDateTime localDateTime = java.time.LocalDateTime.now();
java.time.DayOfWeek day = localDateTime.getDayOfWeek();
System.out.println("The weekday is: " + day);
}
}
通常,导入类和类型会使你的代码更容易阅读,并且需要更少的输入。在大型项目中,你会找到非常长的包名。将这些长名称放在每个声明的前面会使你的代码难以阅读。大多数 Java 开发者都会导入类,除非你有两个具有相同名称但存储在不同包中的类。
注意
大多数 IDE,如 IntelliJ,可以为您找到大多数类,并将提供导入类的建议。
导入包中的所有类
您可以使用星号 * 来导入包中的所有类,如下所示:
import java.time.*;
星号被视为通配符字符,并导入给定包中的所有公共类型,在本例中为 java.time。Java 编译器将自动导入您在代码中使用此包中的任何类型。
注意
使用通配符导入可能会引入您未打算引入的不同类。一些包使用常见的类名,如 Event、Duration 或 Distance,这些可能与您想要使用的类型名称冲突。因此,如果您使用通配符导入,您可能会导入错误的类。通常,最好只导入您需要的类型。
Example03.java 展示了如何使用通配符导入:
package com.packtpub.chapter06;
import java.time.*;
public class Example03 {
public static void main(String[] args) {
LocalDateTime localDateTime = LocalDateTime.now();
DayOfWeek day = localDateTime.getDayOfWeek();
System.out.println("The weekend is: " + day);
}
}
当您运行此程序时,您将看到如下输出,具体取决于星期几:
The weekday is: MONDAY
处理重复名称问题
如果出于某种原因,您必须使用两个具有相同名称的不同类,您将需要使用完全限定的类名。
当您与第三方库一起工作时,您可能会发现您的项目中存在多个具有相同名称的类。例如,StringUtils 在多个库的多个包中定义。在这种情况下,使用完全限定的类名来消除歧义。以下是一个示例:
boolean notEmpty = org.springframework.util.StringUtils.isNotEmpty(str);
boolean hasLength = org.apache.commons.lang3.StringUtils.hasLength(str);
这两个类具有相同的基本名称 StringUtils,它们来自不同的第三方库。您将在本章后面了解更多关于第三方库的内容。
静态导入
许多类定义了常量,通常定义为 static final 字段。您可以通过导入封装类然后从类名引用它们来使用这些常量,如 第三章,面向对象编程 中所示。例如,Java 使用 LocalDateTime 类中的 MAX 常量定义时间的结束。
Example04.java 展示了如何静态导入 LocalDateTime 的 MAX,以查看宇宙何时结束,至少根据 Java 背后的公司的说法:
package com.packtpub.chapter06;
import java.time.LocalDateTime;
public class Example04 {
public static void main(String[] args) {
System.out.println("The end of time is: " + LocalDateTime.MAX);
}
}
当您运行此程序时,您将看到以下输出:
The end of time is: +999999999-12-31T23:59:59.999999999
创建一个包
如前所述,一旦您开始编写更复杂的 Java 程序,您将希望将您的代码捆绑到一个包中。要创建一个包,您应该遵循以下步骤:
-
命名您的包。
-
为包创建适当的源目录。
-
根据需要,在新的包中创建类和其他类型。
命名您的包
从技术上讲,您可以命名您的 Java 包任何您想要的名字,只要您遵守 Java 中变量和类型的命名规则。不要使用 Java 会解释为代码的字符。例如,您不能在 Java 包名称中使用连字符,-。Java 编译器会认为您正在进行减法操作。您也不能使用 Java 的保留词,如 class。
通常,您会使用您组织的域名反向作为包名。例如,如果域名是packtpub.com,那么您的包名将始于com.packtpub。您几乎总是希望在域名部分之后添加描述性名称,以便您组织代码。例如,如果您正在制作一个从健身追踪设备中提取数据的医疗应用程序,您可能会创建以下包:
-
com.packtpub.medical.heartrate -
com.packtpub.medical.tracker -
com.packtpub.medical.report -
com.packtpub.medical.ui
使用对您的组织以及包中类的目的都有意义的名称。
使用您组织的域名的原因之一是,防止您的 Java 包与第三方库中的包具有相同的名称。域名注册商已经使域名变得独特。将域名反向使用可以使包的名称在深入包树时更加易于理解,例如com.packtpub.medical.report.daily.exceptions。此外,这个约定有助于将包与多个组织区分开来。
注意
Java API 提供的类位于以java或javax开头的包中。不要使用这些名称为您的包命名。
通常,您会希望将相关的类、接口、枚举和注解分组到同一个包中。
目录和包
Java 大量使用目录来定义包。包名中的每个点,如java.lang,都表示一个子目录。
在您为本章创建的 IntelliJ 项目中,您还创建了一个名为com.packtpub.chapter06的包。使用 IntelliJ 的项目窗格,您可以看到为该包创建的文件夹。
-
在
项目窗格中点击齿轮图标。 -
取消选择
紧凑中间包选项。 -
您现在将看到
com.packtpub.chapter06的文件夹,如图6.1所示:
![图 6.1:IntelliJ 的项目窗格可以显示构成 Java 包的各个文件夹]
![img/C13927_06_01.jpg]
图 6.1:IntelliJ 的项目窗格可以显示构成 Java 包的各个文件夹
注意
文件夹结构可能因您在本章中尝试的示例数量而有所不同。
通常,您会希望保持 IntelliJ 的紧凑中间包设置,因为它使得项目组织在直观上更容易看到。
练习 2:为健身追踪应用创建包
我们已创建一个名为com.packtpub.chapter06的包,作为本章示例的通用容器。在这个练习中,我们将创建另一个包来收集相关的一组类。
当创建一个与健身追踪器交互的应用程序时,你希望有一个用于跟踪每日步数的类包。用户将为每天想要走的步数设定一个目标,比如 10,000 步。追踪器将记录已走的步数,以及每日总步数的集合:
-
在之前创建的
chapter06项目中,在 IntelliJ 项目面板中点击齿轮图标。确保Flatten Packages和Hide Empty Middle Packages都已被选中。 -
保持处于
Project面板中,并在src文件夹上右键点击。选择New,然后选择Package。输入com.packtpub.steps包名,然后点击OK。这是我们新的包。 -
在
com.packtpub.steps包上右键点击,选择New,然后选择Java Class。输入Steps类名。 -
输入以下字段定义:
private int steps; private LocalDate date; -
允许 IntelliJ 导入
java.time.LocalDate,或者简单地在包声明之后、类定义之前输入以下代码:package com.packtpub.steps; import java.time.LocalDate; /** * Holds steps taken (so far) in a day. */ public class Steps { private int steps; private LocalDate date; } -
在类定义内右键点击。从菜单中选择
Generate…。然后选择Constructor。选择steps和date,然后点击OK。你将看到一个全新的构造函数,如下所示:
public Steps(int steps, LocalDate date) { this.steps = steps; this.date = date; } -
再次在类定义内右键点击。选择
Generate…,然后选择Getter and Setter。选择steps和date,然后点击OK。你现在将看到 getter 和 setter 方法:public int getSteps() { return steps; } public void setSteps(int steps) { this.steps = steps; } public LocalDate getDate() { return date; } public void setDate(LocalDate date) { this.date = date; }我们现在有了新包中的第一个类。接下来,我们将创建另一个类。
-
在项目面板中,在
com.packtpub.steps包上右键点击,选择New,然后选择Java Class。输入DailyGoal类名。 -
输入以下字段定义:
int dailyGoal = 10000;注意,我们默认每日步数目标为 10,000 步。
-
在类定义内右键点击。从菜单中选择
Generate…。然后选择Constructor,接着选择dailyGoal,然后点击OK。 -
定义以下方法,用于确定
Steps对象是否实现了每日目标:public boolean hasMetGoal(Steps steps) { if (steps.getSteps() >= dailyGoal) { return true; } return false; } -
在项目面板中,在
com.packtpub.steps包上右键点击,选择New,然后选择Java Class。输入WeeklySteps类名。 -
输入以下字段:
List<Steps> dailySteps = new ArrayList<>(); DailyGoal dailyGoal;你需要导入
java.util.List和java.util.ArrayList。 -
再次在类定义内右键点击。选择
Generate…,然后选择Getter and Setter。选择dailySteps和dailyGoal,然后点击OK。你现在将看到 getter 和 setter 方法。要使用这个新类,我们将添加一些方法来确定最佳日(步数最多的一天),总计步数,并格式化输出。
-
输入以下方法以确定最佳步数日:
public DayOfWeek bestDay() { DayOfWeek best = DayOfWeek.MONDAY; int max = 0; for (Steps steps : dailySteps) { if (steps.getSteps() > max) { max = steps.getSteps(); best = steps.getDate().getDayOfWeek(); } } return best; } -
现在,输入以下方法以总计每周步数:
public int getTotalSteps() { int total = 0; for (Steps steps : dailySteps) { total += steps.getSteps(); } return total; }注意,这两个方法都会遍历
dailySteps。这两个方法可以合并为一个。在一个真实的健身跟踪应用程序中,你可能会有一个智能手机或 Web 用户界面。然而,在这个例子中,我们将简单地生成每周步骤结果的字符串。
-
输入以下方法:
WeeklySteps.java 36 public String format() { 37 38 StringBuilder builder = new StringBuilder(); 39 40 builder.append("Total steps: " + getTotalSteps() + "\n"); 41 42 for (Steps steps : dailySteps) { 43 if (dailyGoal.hasMetGoal(steps)) { 44 builder.append("YAY! "); 45 } else { 46 builder.append(" "); 47 } https://packt.live/2quq4uh此方法使用
StringBuilder和DayOfWeek,它们都是 Java API 的一部分。每当用户达到步骤目标时,都会出现一个鼓励的信息YAY!。最好的日子也会得到一个振奋人心的信息。 -
为了帮助初始化每周步骤数据,我们将创建一个便利方法(一个存在以简化我们的代码并减少输入的方法):
public void addDailySteps(int steps, LocalDate date) { dailySteps.add(new Steps(steps, date)); } -
为了测试整个步骤跟踪包,我们将创建一个
main()方法,以展示一切是如何结合在一起的:WeeklySteps.java 84 public static void main(String[] args) { 85 // Initialize sample data. 86 DailyGoal dailyGoal = new DailyGoal(10000); 87 88 WeeklySteps weekly = new WeeklySteps(); 89 weekly.setDailyGoal(dailyGoal); 90 91 int year = 2021; 92 int month = 1; 93 int day = 4; https://packt.live/2pB8nIG通常,你会将此类代码放入单元测试中,这是一种特殊的代码,用于确保你的类和算法是正确的。有关单元测试的更多信息,请参阅第十八章,单元测试。
-
点击位于文本编辑器窗口左侧的绿色箭头,该箭头指向
WeeklySteps类名。选择第一个菜单选项,运行 'WeeklySteps.main()'。你将看到类似以下内容的输出,对于 2021 年的一周健身数据(在 2021 年):
Total steps: 92772 YAY! MONDAY 11543 YAY! TUESDAY 12112 YAY! WEDNESDAY 10005 YAY! THURSDAY 10011 FRIDAY 9000 YAY! SATURDAY 20053 ***** BEST DAY! YAY! SUNDAY 20048
现在你已经了解了 Java 代码组织的基本知识,我们将探讨一种特殊类型的文件,称为 Java 归档。
构建 JAR 文件
JAR 文件,即 Java 归档,包含多个文件,并提供了一种平台无关的方式来分发 Java 代码。对于一个 Java 库,JAR 文件将包含编译后的.class 文件,以及可能的其他文件,如配置数据、证书和图像文件,这些文件被称为资源,是库所必需的。专门版本的 JAR 文件用于打包和部署服务器端 Java 应用程序。
WAR文件,即Web 归档,包含 Web 应用程序的编译 Java 代码和资源。EAR文件,即企业归档,包含完整服务器端Java 企业版(JavaEE)应用程序的编译 Java 代码和资源。在底层,JAR 文件是一个压缩的 ZIP 文件。
要构建一个 JAR 文件,我们可以使用以下命令:
jar cvf jar_file_name files_to_put_in
c选项告诉jar命令创建一个新的 JAR 文件。f选项指定新 JAR 文件的文件名。此文件名应紧接在选项之后出现。最后,你列出要放入 JAR 文件中的所有文件,通常是.class 文件。
注意
v选项(cvf的一部分)代表详细;也就是说,它是可选的,告诉 JAR 工具在执行时输出详细输出。
练习 3:构建 JAR 文件
在这个练习中,我们将编译com.packtpub.steps包的 Java 代码,然后构建一个 JAR 文件:
-
在 IntelliJ 的终端面板中运行以下命令:
cd src javac com/packtpub/steps/*.javajavac命令在com/packtpub/steps文件夹中创建了.class 文件。 -
接下来,使用以下命令创建一个 JAR 文件:
jar cvf chapter6.jar com/packtpub/steps/*.class此命令将生成输出,因为我们使用了详细选项:
added manifest adding: com/packtpub/steps/DailyGoal.class(in = 464) (out= 321)(deflated 30%) adding: com/packtpub/steps/Steps.class(in = 622) (out= 355)(deflated 42%) adding: com/packtpub/steps/WeeklySteps.class(in = 3465) (out= 1712)(deflated 50%)
您将看到当前目录中的新 JAR 文件,chapter6.jar。按照惯例,为 JAR 文件使用 .jar 文件名扩展名。
注意
名称 jar 和命令行语法基于一个更早的 UNIX 和 Linux 工具,名为 tar。
就像所有压缩文件一样,在我们开始使用它们之前,我们也必须解压缩 JAR 文件。要从 JAR 文件中提取所有文件,请使用 jar xvf 命令:
jar xvf chapter6.jar
在这种情况下,chapter6.jar 是 JAR 文件的名称。
注意
JAR 命令行选项按顺序处理。在这种情况下,f 选项需要一个文件名参数。如果您添加另一个也需要参数的选项(例如后面在 练习 04,构建可执行 JAR 文件 中提到的 e),那么文件名需要在该附加参数之前。
要查看 JAR 文件内部的内容,请使用 jar tf 命令。在这种情况下,您可以通过从终端面板运行以下命令来查看您的新 JAR 文件内部:
jar tf chapter6.jar
您将看到 JAR 文件中的文件列表作为输出:
META-INF/
META-INF/MANIFEST.MF
com/packtpub/steps/DailyGoal.class
com/packtpub/steps/Steps.class
com/packtpub/steps/WeeklySteps.class
注意 jar 命令如何在其中创建一个名为 META-INF 的文件夹和一个在该文件夹中的名为 MANIFEST.MF 的文件。
默认情况下,jar 命令将创建一个包含以下内容的 MANIFEST.MF 文件:
Manifest-Version: 1.0
Created-By: 11.0.2 (Oracle Corporation)
文件列出了一个版本号,以及创建该文件的 Java 版本——在这种情况下,来自 Oracle 的 Java 11。
定义清单
MANIFEST.MF 文件用于向 Java 工具提供有关 JAR 文件内容的信息。您可以添加版本信息,电子签名 JAR 文件等。可能添加到 JAR 文件清单中最有用的事情是标识 main 类。此选项命名具有 main() 方法的类,您希望从 JAR 文件中运行。本质上,这创建了一个可执行 JAR 文件。
可执行 JAR 文件允许您使用如下命令在 JAR 文件内运行 Java 应用程序:
java -jar chapter6.jar
要做到这一点,您需要在 MANIFEST.MF 文件中创建一个条目,以定义主类。例如,对于 WeeklySteps Java 类,您会在 MANIFEST.MF 文件中创建一个条目,如下所示:
Main-Class: com.packtpub.steps.WeeklySteps
练习 4:构建可执行 JAR 文件
在这个练习中,我们将在 JAR 文件内的 MANIFEST.MF 文件中添加一个 Main-Class 条目:
-
使用以下命令重新创建 JAR 文件(所有内容都在一行上):
jar cvfe chapter6.jar com.packtpub.steps.WeeklySteps com/packtpub/steps/*.classe选项定义了一个入口点,换句话说,就是 Main-Class 标头。由于 JAR 命令行选项按顺序处理,这意味着您首先提供 JAR 文件名,然后是主类(入口点)的名称。这些选项很容易混淆。使用此
jar命令,您将看到如下输出:added manifest adding: com/packtpub/steps/DailyGoal.class(in = 464) (out= 321)(deflated 30%) adding: com/packtpub/steps/Steps.class(in = 622) (out= 355)(deflated 42%) adding: com/packtpub/steps/WeeklySteps.class(in = 251) (out= 185)(deflated 26%) -
现在,我们可以从 JAR 文件中运行我们的 Java 应用程序:
java -jar chapter6.jar此命令应生成 练习 02,为健身追踪应用程序创建包 中所示的输出:
Total steps: 92772 YAY! MONDAY 11543 YAY! TUESDAY 12112 YAY! WEDNESDAY 10005 YAY! THURSDAY 10011 FRIDAY 9000 YAY! SATURDAY 20053 ***** BEST DAY! YAY! SUNDAY 20048注意
您可以在
packt.live/2MYsN6N上了解更多关于 jar 命令以及其他 Java 工具的信息。
当你只有一个包时,手动构建 JAR 文件并不困难。但是,当你开始添加越来越多的包时,手动构建 JAR 文件和操作内容变得相当繁琐。有更简单的方法来做这件事——最显著的是,通过使用可以帮助构建 JAR 文件的 Java 构建工具。
构建工具
随着应用程序变得越来越复杂,你会发现使用 Java 构建工具变得至关重要。构建工具允许你做以下事情:
-
构建跨多个包的 Java 应用程序。
-
使构建更容易运行和维护。
-
使构建保持一致。
-
从你的代码中创建库或多个库。
-
下载并将第三方库包含到你的应用程序中。
这些只是 Java 构建工具能为你做的事情的一小部分。
两个主要的 Java 构建工具有以下几种:
-
Maven,它生成 XML 配置文件
-
Gradle,它使用基于 Groovy 的领域特定语言进行配置
注意
有关 Maven 的更多信息,请参阅
packt.live/33Iqprj,有关 Gradle 的更多信息,请参阅packt.live/35PNREO。
Maven
Maven 对你的软件项目的结构有非常具体的要求。例如,Maven 预期你的源代码将放入名为 src 的文件夹中。一般来说,最好是不要与 Maven 的期望作斗争。
注意
有关 Maven 对项目目录结构的期望的更多信息,请参阅packt.live/2nVNI1A。
练习 5:创建 Maven 项目
IntelliJ 在与 Maven 一起工作时提供了一些非常实用的功能。我们现在将使用这些功能来创建 Maven 项目:
-
在 IntelliJ 中,转到“文件”菜单,选择“新建”,然后选择“项目…”。
-
在“新建项目”对话框中,选择
Maven。然后,点击“下一步”,如图 图 6.2 所示:![图 6.2:创建 Maven 项目时选择 Maven]()
图 6.2:创建 Maven 项目时选择 Maven
在下一屏,你需要输入三个值,如图 图 6.3 所示:
![图 6.3:输入 GroupId、ArtifactId 和版本]()
图 6.3:输入 GroupId、ArtifactId 和版本
-
对于
GroupId,输入com.packtpub.steps。Maven 中的
GroupId用来标识整个项目。通常,你将使用你的主要包名作为GroupId。 -
对于
ArtifactId,输入 steps。ArtifactId是你为任何 JAR 文件想要的名字,不包括版本号。Maven 会为你添加版本信息。 -
将
Version保持为1.0-SNAPSHOT。注意
在 Maven 中,SNAPSHOT 版本表示正在进行中的工作。当你准备发布时,你通常会从版本信息中移除 SNAPSHOT 部分。
-
点击“下一步”。
-
在下一屏,IntelliJ 将默认将项目名称设置为 steps(来自
ArtifactId)。在磁盘上选择一个项目位置,然后点击“完成”。
现在你已经有一个 Maven 项目了。
在项目面板中,注意创建的目录结构。您将找到一个src文件夹。这个文件夹包含项目的源代码。在src下,您会看到名为main和test的文件夹。main文件夹是 Java 源代码所在的位置。test文件夹是单元测试所在的位置。单元测试是测试主代码的 Java 类。
注意
参考第十八章单元测试以获取有关单元测试的更多信息。
在主文件夹和测试文件夹中,您会看到名为java的文件夹。这表示 Java 源代码(例如,与 Groovy 或 Kotlin 代码相对)。
图 6.4显示了包含src/main/java和src/test/java文件夹的目录结构:

图 6.4:Maven 项目的 src/main/java 和 src/test/java 文件夹
当 Maven 构建您的项目时,编译代码和构建 JAR 文件,它将构建输出包含在名为target的文件夹中。
您还会看到一个名为pom.xml的文件。简称为项目对象模型,POM提供了 Maven 的配置,它告诉 Maven 您想构建什么以及如何构建。
IntelliJ 默认创建的 POM 文件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.packtpub.steps</groupId>
<artifactId>steps</artifactId>
<version>1.0-SNAPSHOT</version>
</project>
您应该看到在 IntelliJ 中创建项目时输入的groupId、artifactId和版本信息。
练习 6:将 Java 源代码添加到 Maven 项目
我们现在将 Java 源代码添加到 Maven 项目中,如下所示:
-
首先转到
src/main/java文件夹。 -
右键单击,选择
新建,然后选择包。 -
将
com.packtpub.steps作为包名输入。 -
接下来,从
Exercise 02中引入三个源文件。您可以复制之前的文件。 -
将
Steps.java、DailyGoal.java和WeeklySteps.java复制到这个项目中。现在,让我们看看这三个文件。首先,这是
Steps.java:Steps.java 1 package com.packtpub.steps; 2 3 import java.time.LocalDate; 4 5 /** 6 * Holds steps taken (so far) in a day. 7 */ 8 public class Steps { 9 private int steps; 10 private LocalDate date; https://packt.live/2MVxFJO这里是
DailyGoal.java:package com.packtpub.steps; public class DailyGoal { int dailyGoal = 10000; public DailyGoal(int dailyGoal) { this.dailyGoal = dailyGoal; } public boolean hasMetGoal(Steps steps) { if (steps.getSteps() >= dailyGoal) { return true; } return false; } }这里是
WeeklySteps.java:WeeklySteps.java 1 package com.packtpub.steps; 2 3 import java.time.DayOfWeek; 4 import java.time.LocalDate; 5 import java.util.ArrayList; 6 import java.util.List; 7 8 public class WeeklySteps { 9 List<Steps> dailySteps = new ArrayList<>(); 10 DailyGoal dailyGoal; https://packt.live/32wiz3Q -
在 IntelliJ 编辑器窗口中调用
Steps.java。您会注意到项目中出现了一些错误。这是因为 Maven 默认不使用 Java 12。下一步将解决这个问题。
-
在 IntelliJ 编辑器窗口中调用
pom.xml。 -
在
groupId、artifactId和version之后输入以下内容:<groupId>com.packtpub.steps</groupId> <artifactId>steps</artifactId> <version>1.0-SNAPSHOT</version> <build> <plugins> <plugin> <groupId>org.apache.maven.plugins</groupId> <artifactId>maven-compiler-plugin</artifactId> <version>3.6.1</version> <configuration> <source>12</source> <target>12</target> </configuration> </plugin> </plugins> </build>当您输入此配置时,注意 IntelliJ 如何提供帮助您输入 XML 元素。
-
当您完成时,IntelliJ 将显示一个警告,表明 Maven 项目需要导入。单击
导入更改。Steps和WeeklySteps的红色错误行应该消失。
现在,您应该能够构建您的项目。这将在练习 07,构建 Maven 项目中介绍。
练习 7:构建 Maven 项目
现在我们已经添加了 Java 源代码,我们将构建 Maven 项目。
-
首先,转到
steps项目,然后单击 IntelliJ 窗口右上角附近的Maven选项卡。 -
展开
steps项目。 -
展开
生命周期。您现在将看到一个 Maven 目标列表,如图图 6.5所示:
![图 6.5:IntelliJ 的 Maven 选项卡]()
图 6.5:IntelliJ 的 Maven 选项卡
-
双击
package。在运行窗格中,你会看到很多输出。Maven 默认是一个非常冗长的工具。项目现在已构建。 -
查看目标目录。你会看到构建的输出。
Maven 创建的 JAR 文件名为 steps-1.0-SNAPSHOT.jar。它包含了所有编译后的 .class 文件。
然而,Maven 创建的 JAR 文件并不是可执行的 JAR 文件。练习 08,使用 Maven 创建可执行 JAR 将会展示如何配置 Maven 以创建一个可执行 JAR。
练习 8:使用 Maven 创建可执行 JAR
在这个练习中,我们将使用 Maven 创建一个可执行 JAR。
-
在
步骤项目中,在 IntelliJ 编辑器窗口中打开pom.xml。 -
在 Maven 编译器插件的
<plugin>部分之后输入以下内容:pom.xml 24 <plugin> 25 <groupId>org.apache.maven.plugins</groupId> 26 <artifactId>maven-shade-plugin</artifactId> 27 <executions> 28 <execution> 29 <goals> 30 <goal>shade</goal> 31 </goals> 32 <configuration> 33 <transformers> 34 <transformer implementation= 35 "org.apache.maven.plugins.shade.resource.ManifestResourceTransformer"> https://packt.live/33KFUPs此配置引入了 Maven shade 插件,它提供了创建可执行 JAR 的主要方法之一。shade 插件还会创建一个包含所有必要依赖项的 JAR 文件,例如第三方库,这使得这个 Maven 插件非常实用。
-
在
Maven选项卡中运行package目标。你将看到很多输出。
-
切换到
终端窗格。 -
切换到
target目录:cd target -
运行可执行 JAR:
java -jar steps-1.0-SNAPSHOT.jar你将看到如下输出:
Total steps: 92772 YAY! MONDAY 11543 YAY! TUESDAY 12112 YAY! WEDNESDAY 10005 YAY! THURSDAY 10011 FRIDAY 9000 YAY! SATURDAY 20053 ***** BEST DAY! YAY! SUNDAY 20048
Maven 有很多内容。这个练习只是触及了构建工具的表面。
注意
请参考 packt.live/33Iqprj 了解有关 Maven 的多个教程。
使用 Gradle
虽然 Maven 可以为你做很多事情,但它通常不够灵活,有时还会让人困惑,尤其是在大型项目中。试图解决这些问题导致了 Gradle 的诞生。例如,在 Maven 中,每个 POM 文件构建一个东西,比如一个 JAR 文件。使用 Gradle,你可以使用同一个构建文件(Gradle 的 POM 文件等价物)执行额外的任务。
Gradle 比 Maven 更灵活,通常——但并非总是——更容易理解。
练习 9:创建 Gradle 项目
在这个练习中,我们将创建一个 Gradle 项目。
-
首先,打开 IntelliJ,然后在
文件菜单中选择新建,然后选择项目。 -
选择
Gradle,在右侧窗格中,保持 Java 选中,如图 图 6.6 所示:![图 6.6: 创建新项目时选择 Gradle]()
图 6.6:创建新项目时选择 Gradle
-
点击
下一步。输入GroupId、ArtifactId和Version,就像你在 Maven 项目中做的那样,如图 图 6.7 所示:![图 6.7: 输入 GroupId、ArtifactId 和 Version]()
图 6.7: 输入 GroupId、ArtifactId 和 Version
-
为
GroupId输入com.packtpub.steps。 -
为
ArtifactId输入steps-gradle。 -
将版本信息保留在
1.0-SNAPSHOT。注意,Gradle 使用与 Maven 相同的机制来识别依赖项。
-
点击
下一步。 -
保留所有默认选项。为每个源集创建一个单独的模块,并使用默认的 gradle wrapper。
-
点击
下一步。 -
在下一个屏幕上,IntelliJ 会默认将 IntelliJ 项目名称设置为 steps-gradle(从
ArtifactId)。在磁盘上选择一个项目位置,然后点击Finish。IntelliJ 会构建一段时间,然后你可以查看新的项目目录。
IntelliJ 创建的 Gradle 项目与 Maven 项目非常相似。例如,你会在 src 里面看到相同的 main 和 test 文件夹。
你还会看到两个新文件:
-
build.gradle文件为 Gradle 提供了主要的配置文件。 -
settings.gradle包含一些额外的设置。
IntelliJ 生成的 build.gradle 文件包含以下配置:
plugins {
id 'java'
}
group 'com.packtpub.steps'
version '1.0-SNAPSHOT'
sourceCompatibility = 1.8
repositories {
mavenCentral()
}
dependencies {
testCompile group: 'junit', name: 'junit', version: '4.12'
}
练习 10:使用 Gradle 构建可执行 JAR
在这个练习中,我们将添加与 Maven 示例中相同的三个 Java 类,然后配置 build.gradle 文件以创建一个可执行 JAR 文件。我们将在这个之前练习中创建的 steps-gradle 项目中工作。
-
在
steps-gradle项目中,转到src/main/java文件夹。 -
右键点击,然后选择
New并选择Package。 -
将包名输入为
com.packtpub.steps。 -
接下来,从
Exercise 02中引入三个源文件。你可以复制之前的文件。 -
在 IntelliJ 文本编辑器中调用
build.gradle文件。 -
将
sourceCompatibility设置为 12:sourceCompatibility = 12 -
在
build.gradle文件的末尾添加以下部分:jar { manifest { attributes 'Main-Class': 'com.packtpub.steps.WeeklySteps' } } -
点击 IntelliJ 窗口右上角附近的
Gradle选项卡。 -
展开并进入
steps-gradle项目,然后是Tasks,再然后是build。 -
双击
ASSEMBLE来构建项目。你会看到 Gradle 输出的文本比 Maven 少得多。完成后,你会看到一个构建目录。这与 Maven 使用的目标目录类似。Java
.class文件和 JAR 文件都放在构建目录中。 -
切换到
Terminal面板。 -
切换到
build/libs目录:cd build cd libs -
运行可执行 JAR:
java -jar steps-gradle-1.0-SNAPSHOT.jar你应该会看到与之前相同的输出。
与 Maven 一样,你可以用 Gradle 做很多事情。
备注
你可以在 packt.live/2P3Hjg2 上了解更多关于 Gradle 的信息。你可以在 packt.live/2Mv5CBZ 上找到更多关于 Gradle 处理 Java 项目的信息。
使用第三方库
使用 Java 进行开发最美好的事情之一是成千上万的开源第三方库可用。第三方库是一组现成的包,你可以在自己的程序中使用。这意味着你可以实现特定的功能,而无需从头编写代码。
从 Spring Boot 框架到日志库和简单的实用工具,你都可以在网上找到。而且,为了使事情更简单,Maven 和 Gradle 构建工具都支持下载第三方库并将这些库集成到你的项目中。
查找库
对于 Java,有大量的第三方库可用。要查看一些库的描述,一个好的起点是 packt.live/2qnRAcx,它列出了许多 Java 库和框架。
Spring、Hibernate、Apache、Eclipse 和 BouncyCastle 项目提供了大量的库。它们都可以在之前提到的链接中找到,并且是寻找所需功能的好地方。
在选择开源库之前,您可能想查看以下主题:
-
文档 – 良好的文档不仅有助于您学习如何使用库,而且可以作为库成熟度的良好指标。您能理解如何使用库吗?如果不能,这个库可能不适合您。
-
社区 – 一个活跃的社区表明库正在被使用。它还提供了对库维护者如何对待提问者的一个了解。寻找有关该库的邮件列表和讨论组。
-
动力 – 检查库更新的频率。您希望选择处于积极开发中的库。
-
它适合您吗? – 总是尝试每个库,以确保它确实适用于您的项目,并且您能够理解如何使用该库。
-
许可证 – 您能否合法使用这个库?首先确保这一点。参考
packt.live/2MTZfqD了解最常见的开源许可证列表。阅读许可证,看看它是否适合您的组织。如果许可证看起来很奇怪或限制性太强,请避免使用该库。注意
总是查看任何开源库的许可证,以确保您的组织可以合法地以您想要的方式使用该库。
一旦找到一个看起来很有希望的库,下一步就是将库导入到您的应用程序中。
添加项目依赖
您在项目中包含的第三方库被称为依赖项。将此视为您的项目现在依赖于这个库。Maven 和 Gradle 都以类似的方式识别依赖项。您需要以下内容:
-
GroupId
-
ArtifactId
-
版本信息
-
一个构建工具可以从中下载库的仓库
最常用的第三方开源库可以从一个名为 Maven Central 的庞大仓库中下载,该仓库位于 packt.live/2pvXmZs。
您可以在位于 packt.live/33UlfZF 的便捷网站上搜索组、工件和版本信息。
一个好用的开源库是 Apache Commons Lang,它包含了一些方便的类,用于处理字符串和数字。
练习 11:添加第三方库依赖项
在这个练习中,我们将向之前在 练习 09 和 10 中创建的 Gradle 项目添加 Apache Commons Lang 库。在这些练习中,我们将只添加一个,以简化整个设置。
在大型、复杂的项目中,你经常会看到很多依赖项。这里使用的概念在你开始添加更多依赖项时同样适用:
-
在
packt.live/33UlfZF上搜索 Apache Commons Lang。你应该能在packt.live/33JnQ8n找到这个库的页面。 -
查找最新发布的版本。在撰写本文时,版本是 3.8.1。
注意有多少个发布版本。这个库似乎正在积极开发中。
-
点击
3.8.1链接。 -
查看许可信息。Apache 许可证与大多数组织兼容。
在这个页面上,你会看到一组用于不同 Java 构建工具的标签页,默认选中 Maven 标签页。在标签页内,你会看到在 Maven POM 文件中使用的格式显示的组、工件和版本信息。
-
点击
Gradle选项卡以查看格式化的相同信息,如图 6.8 所示:![]()
图 6.8:使用 Gradle 选项卡查看 Gradle 依赖信息
-
复制此文本并将其添加到你的
build.gradle文件中的依赖块。 -
将单词
*compile*改为*implementation*:implementation group: 'org.apache.commons', name: 'commons-lang3', version: '3.8.1'在 Gradle 的较新版本中,编译依赖已被实现依赖所取代。
-
在 IntelliJ 的提示需要导入 Gradle 项目的警告中,点击
导入更改。现在我们已经在项目中有了这个库。接下来我们需要做两件事。首先,我们需要配置 Gradle 以构建一个包含所有依赖项的可执行 JAR 文件。其次,我们需要在我们的代码中使用这个新依赖项——这个新库。
下一步是将 Gradle shadow 插件添加到项目中。此插件将项目中的代码(以及任何第三方库和其他依赖项)合并到一个单独的 JAR 文件中,其中包含所需的一切。
注意
你可以在
packt.live/33Irb7H和packt.live/31qGIYs找到关于 Gradle shadow 插件的更多信息。 -
在 IntelliJ 文本编辑器中调用
build.gradle。 -
将
plugins块替换为以下内容:buildscript { repositories { jcenter() } dependencies { classpath 'com.github.jengelman.gradle.plugins:shadow:2.0.1' } } apply plugin: 'java' apply plugin: 'com.github.johnrengelman.shadow'这告诉 Gradle 将 shadow 插件引入我们的项目。
-
前往 IntelliJ 中的 Gradle 面板。点击
刷新图标(两个圆形箭头)。 -
展开新的 shadow 任务。
-
双击
shadowJar。这将构建一个新的 JAR 文件,
steps-gradle-1.0-SNAPSHOT-all.jar,其中包含项目代码以及所有依赖项。注意,格式是工件 ID - 版本 -all.jar。 -
切换到
终端面板。 -
输入以下命令:
cd build cd libs java -jar steps-gradle-1.0-SNAPSHOT-all.jar你将看到
Steps应用程序的输出。
在这个练习中,我们添加了一个第三方依赖库。接下来,我们将在应用程序中使用这个新库。
使用 Apache Commons Lang 库
当使用新库时,通常最好先查看文档。对于 Java 实用库,Javadoc 是一个好的起点。
对于 Apache Commons Lang 库,您可以在 packt.live/32wkrJR 找到 Javadoc。打开第一个包,org.apache.commons.lang3。
在这个包中,您会发现一组非常实用的实用工具类,包括出色的 StringUtils 类。StringUtils 提供了处理字符串的多种方法。而且,更好的是,这些方法是空安全,所以如果您传递一个 null 字符串,您的代码不会抛出异常。
打开 StringUtils 的 Javadoc。您将看到许多与这个类相关的良好文档。
练习 12:使用 Apache Commons Lang 库
在这个练习中,我们将使用 StringUtils 类的两个实用方法,leftPad() 和 rightPad()。这些方法通过在左侧或右侧填充空格字符来确保字符串具有特定的长度。
我们将使用这些方法使 Steps 应用程序的输出看起来更好:
-
在 IntelliJ 中,将
WeeklySteps类调入文本编辑器。 -
滚动到
format()方法。 -
将该方法替换为以下代码:
public String format() { StringBuilder builder = new StringBuilder(); builder.append("Total steps: " + getTotalSteps() + "\n"); for (Steps steps : dailySteps) { if (dailyGoal.hasMetGoal(steps)) { builder.append("YAY! "); } else { builder.append(" "); } String day = steps.getDate().getDayOfWeek().toString(); builder.append( StringUtils.rightPad(day, 11) ); builder.append(" "); String stp = Integer.toString(steps.getSteps()); builder.append( StringUtils.leftPad( stp, 6 ) ); DayOfWeek best = bestDay(); if (steps.getDate().getDayOfWeek() == best) { builder.append(" ***** BEST DAY!"); } builder.append("\n"); } return builder.toString(); }这段代码将一周中每一天的日期填充到一致长度。它对每日步数计数也做了同样处理。
-
再次从 Gradle 面板运行
shadowJar构建任务。 -
在终端面板中,在
build/libs目录下,运行以下命令:java -jar steps-gradle-1.0-SNAPSHOT-all.jar您将看到现在输出已经对齐得更好:
Total steps: 92772 YAY! MONDAY 11543 YAY! TUESDAY 12112 YAY! WEDNESDAY 10005 YAY! THURSDAY 10011 FRIDAY 9000 YAY! SATURDAY 20053 ***** BEST DAY! YAY! SUNDAY 20048
几乎您所工作的每个 Java 项目都将需要多个依赖项。
使用模块
Java 包允许您将相关的类(以及其他类型)聚集在一起。然后您可以将多个包打包成一个 JAR 文件,创建一个可以使用的库。
模块更进一步,允许您有效地封装您的库。这意味着您可以声明模块的哪些公共类(以及其他类型)可以在模块外部访问。
注意
Java 9 及更高版本支持称为 Java 平台模块系统(JPMS)的模块。
此外,模块可以声明对其他模块的显式依赖。这有助于清理 Java 类路径的混乱。而不是在类路径中搜索类,模块将直接搜索一个命名的依赖模块。这在引入大量依赖项时非常有帮助。对于大型 Java 应用程序,某些库可能依赖于同一库的不同版本,导致各种问题。相反,每个模块都允许您将它的依赖项与其他应用程序部分隔离开来。
模块会查找所谓的模块路径。模块路径只列出模块,不列出类。
在一个模块中,可以导出模块中的包。如果一个模块中的包没有被导出,那么其他模块就不能使用该包。
想要从另一个模块使用代码的模块必须指明它需要该模块。在一个模块内部,您的代码只能使用依赖模块中导出的包。
注意
当你开始使用模块时,你将希望将你创建的每个 Java 库转换为一个或多个模块。每个 JAR 文件只能有一个模块。
注意
创建 Java 模块系统的原始项目被称为 PROJECT JIGSAW。有关模块的更多信息,请参阅packt.live/32yH1le。这项工作的大部分努力是向Java 开发工具包(或JDK)添加模块。这允许你创建针对移动平台等的小型 JDK。
要查看构成 JDK 的所有模块,请使用 java 命令。
从 IntelliJ 终端面板运行以下命令:
java --list-modules
你将在输出中看到很多模块(这里已缩短):
java.base@11.0.2
java.compiler@11.0.2
java.datatransfer@11.0.2
java.desktop@11.0.2
java.instrument@11.0.2
java.logging@11.0.2
java.management@11.0.2
java.management.rmi@11.0.2
java.naming@11.0.2
java.net.http@11.0.2
…
jdk.hotspot.agent@11.0.2
jdk.httpserver@11.0.2
jdk.internal.ed@11.0.2
以 java 开头的模块是我们认为属于 JDK 的类,即你可以在 Java 代码中使用这些类的类。以 jdk 开头的模块是 JDK 内部需要的模块。你不应该使用这些类。
创建模块
一个模块将一组 Java 包和附加资源(文件)组合在一起。每个模块都需要一个module-info.java文件,该文件指定了模块导出什么以及需要哪些其他模块。
练习 13:为模块创建项目
在这个练习中,我们将创建一个 IntelliJ 项目,我们可以使用它来探索 Java 模块,然后在项目中创建一个 Java 模块:
-
从
文件菜单中选择New,然后选择Project…。 -
选择一个
Java项目并点击Next,如图 6.9 所示:![]()
图 6.9:选择 Java 项目
-
不要指定项目模板。点击
Next,如图 6.10 所示:![]()
图 6.10:不要选择项目模板
-
命名项目模块。
-
点击
Finish。你现在有一个空的 Java 项目。下一步将是创建一个非常简单的模块。 -
从
文件菜单中选择New,然后选择Module…。 -
确保选择了 Java。点击
Next。 -
将
com.packtpub.day.module作为模块名输入。确保内容根和文件位置都在模块文件夹下的com.packtpub.day.module。 -
点击
Finish。你现在有一个模块了。初始时可能会觉得模块名
com.packtpub.day.module被创建为一个单独的目录可能会令人困惑。注意
通常,在包中,每个点号在名称中代表一个单独的子文件夹。在模块中,你得到一个带有点号的文件夹名。
IntelliJ 在项目中创建了一个名为
com.packtpub.day.module 的文件夹,并在com.packtpub.day.module下创建了一个src文件夹。 -
右键点击
com.packtpub.day模块下的src文件夹。 -
选择
New,然后选择Package。 -
将
com.packtpub.day作为包名输入。 -
右键点击新包
com.packtpub.day,选择New,然后选择Java class。将类命名为Today。 -
在文本编辑器窗口中,向新类添加一个方法:
public String getToday() { return LocalDate.now().getDayOfWeek().toString(); }此方法返回当前日期的星期几作为字符串。
-
右键点击包
com.packtpub.day,选择New,然后选择module-info.java。 -
在文本编辑器中,在
module块内添加以下exports行:module com.packtpub.day.module { exports com.packtpub.day; }com.packtpub.day模块导出一个包,com.packtpub.day。添加到此模块的任何其他内容都将被隐藏。
现在我们已经有一个模块了,下一步是将此模块用于另一个模块。这将展示模块如何控制哪些类被导出供其他模块使用,以及哪些类在模块内部保持私有。模块并排存在,但两者都需要包含在你的项目模块路径中——这是 Java 类路径的模块等价物。
练习 14:使用第一个模块创建第二个模块
接下来,我们将创建一个第二个非常简单的模块,该模块使用之前创建的com.packtpub.day模块。
-
从
文件菜单中选择新建然后模块…。 -
确保已选择 Java,然后点击
下一步。 -
将此模块命名为
com.packtpub.message.module。 -
点击
完成。 -
右键点击
com.packtpub.message模块下的src文件夹。 -
选择
新建然后包。 -
将
包命名为com.packtpub.message并点击确定。 -
右键点击
com.packtpub.message包。选择新建然后module-info.java。 -
右键点击
com.packtpub.message包。选择新建然后Java 类。 -
将类命名为
Message。 -
在文本编辑器中,编辑
Message类并导入Today类:import com.packtpub.day.Today; -
在文本编辑器中,创建一个如下所示的
main()方法:public static void main(String[] args) { Today today = new Today(); System.out.println("Today is " + today.getToday()); } -
编辑
com.packtpub.message.module模块中的module-info.java文件。添加以下requires语句:module com.packtpub.message.module { requires com.packtpub.day.module; }requires语句将显示错误。我们需要在 IntelliJ 项目中将com.packtpub.day.module模块作为依赖项添加。 -
从
文件菜单中选择项目结构。 -
点击
模块。 -
在
com.packtpub下选择message.module。 -
点击
依赖项选项卡。 -
在对话框窗口底部点击
+图标并选择模块依赖…。 -
选择
com.packtpub.day.module并点击确定。你应该看到新模块依赖项已添加,如图 6.11 所示。
-
在之前的对话框中点击
确定。错误应该不再存在。 -
在
Message类中,点击类定义左侧的绿色箭头,并选择运行'Message.main()'。你将看到如下输出:
Today is THURSDAY



活动一:跟踪夏季高温
关于气候变化的研究已经确定了 2100 年夏季高温将是什么样的。
你可以在packt.live/33IrCyR上看到许多世界城市的此类信息。
创建一个应用程序,显示在不进行重大排放削减或进行适度排放削减的情况下,预计 2100 年夏季高温将有多高。
要完成此操作,请按照以下步骤:
-
在 IntelliJ 中创建一个新的 Gradle 项目。
-
将 Guava 第三方库作为依赖项引入。有关 Guava 的更多信息,请参阅
packt.live/2qkLutt。 -
创建一个名为
City的类,该类包含城市的名称、城市所在国家的名称以及其夏季高温。请记住,IntelliJ 可以为你生成 getter 和 setter 方法。 -
创建一个名为
SummerHigh的类,该类包含一个基础城市,以及在没有减少排放的情况下最接近 2100 年夏季预测的城市,以及如果适度减少排放则匹配 2100 年夏季预测的城市(基于 Climate Central 的数据)。 -
创建一个名为
SummerHighs的类来存储整体数据存储。这个类应该有通过城市名称(不考虑大小写)或国家名称(不考虑大小写)检索数据的方法。 -
使用 Guava Table 来存储底层的
SummerHigh数据,使用如下所示的 Table:Table<String String, SummerHigh> data = HashBasedTable.create(); -
创建一个
Main类,它接受一个城市或国家名称,查找适当的数据,然后打印出来。使用命令行参数-city进行城市查找,使用-country进行国家查找。应该将项目的整个代码库合并到一个可执行的 JAR 中。从 IntelliJ 终端面板运行此 JAR。
你应该能够像以下这样运行这个 JAR:
java -jar temps-1.0-all.jar -city London应该生成如下所示的输出:
In 2100, London, United Kingdom 20.4 C will be like Milan, Italy 25.2 C with no emissions cuts, Paris, France 22.7 C with moderate emissions cuts -
添加一个温度转换类,使其能够输出华氏度而不是摄氏度。
-
添加一个
-f命令行选项,告诉应用程序以华氏度返回温度。 -
创建一个名为
TempConverter的类来执行转换。 -
使用以下公式来转换温度单位:
double degreesF = (degreesC * 9/5) + 32你应该能够运行应用程序:
java -jar temps-1.0-all.jar -city London -f你应该看到以华氏度输出的温度。以下是一个示例:
In 2100, London, United Kingdom 68.72 F will be like Milan, Italy 77.36 F with no emissions cuts, Paris, France 72.86 F with moderate emissions cuts注意
有关
Table类的更多信息,请参阅packt.live/2pvYxbk。row()和column()方法。我们使用这个类来允许通过城市或国家进行查找。
夏季高温
这里列出了来自 Climate Central 地图的一些选定城市。每个城市都列出了其夏季高温。请随意将这些城市包含在你的程序中。如果你喜欢,还可以添加更多来自 Climate Central 的城市。
伦敦,英国,20.4 °C:
-
将类似于巴黎,法国,22.7 °C,适度减少排放。
-
将类似于米兰,意大利,25.2 °C,没有减少排放。
斯德哥尔摩,瑞典,19.3 °C:
-
将类似于维尔纽斯,立陶宛,21.7 °C,适度减少排放。
-
将类似于基辅,乌克兰,24.2 °C,没有减少排放。
巴塞罗那,西班牙,25.7 °C:
-
将类似于马德里,西班牙,28.9 °C,适度减少排放。
-
将类似于伊兹密尔,土耳其,32.2 °C,没有减少排放。
纽约,美国,27.7 °C:
-
将类似于伯利兹城,伯利兹,31.3 °C,适度减少排放。
-
将类似于华雷斯,墨西哥,34.4 °C,没有减少排放。
东京,日本,26.2 °C:
-
将与中国的北京一样,气温为 29.0°C,有适度的排放削减。
-
将与中国的武汉一样,气温为 31.2°C,没有排放削减。
注意
这个活动的解决方案可以在第 542 页找到。
摘要
在本章中,我们看到了如何通过包来更好地组织你的代码,这在处理大型项目时变得至关重要。当你使用来自另一个包的类时,你需要将这些类导入到你的代码中。
当你创建自己的包时,将你的代码放入基于代码目的的包中,并根据你组织的互联网域名来命名这些包。例如,你可能创建名为com.packtpub.medical.report和com.packtpub.medical.heartrate的包。
你经常会将你的 Java 代码集成到一个 JAR 文件中。JAR 文件就像是一个编译后的 Java 代码库。可执行 JAR 文件包含一个具有main()方法的 Java 类名,你可以使用java-jar命令来运行它。
当你在大型项目中工作时,Java 构建工具如 Maven 或 Gradle 非常有帮助。这两个构建工具还支持下载和使用第三方开源 Java 库——这些库几乎在每一个大型 Java 项目中都会用到。
模块是一种新的代码分离方式。在下一章中,我们将介绍关系型数据库以及如何使用 Java 与数据库结合。
第七章:7. 数据库和 JDBC
概述
在下一章中,你将学习如何使用Java 数据库连接(JDBC)从 Java 应用程序中访问关系型数据库。这始于在关系型数据库中创建表以存储和排序数据。只有在此基础上,你才能通过编写基本的 SQL 查询来检索和修改这些数据。一旦这个基础建立起来,你将能够将这些技能应用到 Java 应用程序中,特别是为了通过 JDBC 访问数据库并运行查询。你还将进一步练习使用 JDBC PreparedStatement 接口,以允许使用参数化 SQL 语句,通过减少耗时和重复的按键操作来提高速度。到本章结束时,你将知道如何从 JDBC 中插入和更新数据,并自信地处理它抛出的任何异常。
简介
数据库——尤其是关系型数据库——被用于成千上万的应用程序中,从小型家庭应用到大型的企业系统。为了帮助我们编写访问数据库的应用程序,Java 提供了一些非常实用的工具,从Java 数据库连接(JDBC)开始。
JDBC允许 Java 应用程序连接到众多数据库,前提是你有正确的驱动程序:一个设计用来与特定数据库通信的 Java 库。一旦连接,JDBC 提供了一种通用的方式来访问数据库。你只会遇到少数几个需要了解底层数据库实现具体细节的区域。
关系型数据库
关系型数据库最初由 E. F. Codd 定义,将数据存储在由列和行组成的表中。例如,以下表可以用来存储客户信息:

](https://github.com/OpenDocCN/freelearn-java-zh/raw/master/docs/java-ws/img/C13927_07_01.jpg)
图 7.1:客户数据库表
在这个客户表示例中,每一行有四个列:一个 ID、一个用户名、一个名和一个姓。
注意
除了像 Sting、Cher 和 Bono 这样的名人之外,一些民族群体只使用一个名字。你并不总是会有姓氏和名字。
每一行都需要一种独特的方式来区分该行与其他所有行,这被称为唯一主键。在这种情况下,ID 列充当唯一键。在这个表中,你也可以使用用户名作为唯一键。
一些表使用单个列作为键,而其他表则使用多个列中的值来形成键,这被称为组合键。
关系型数据库使用多个表。你可以根据行中的信息将表与其他表关联起来。
例如,在一个在线系统中,每个客户可能有多个电子邮件地址。你可以使用一个单独的表来模拟这种关系,如表 2 所示:

](https://github.com/OpenDocCN/freelearn-java-zh/raw/master/docs/java-ws/img/C13927_07_02.jpg)
图 7.2:电子邮件地址数据库表
在表 2 中,每一行都有一个唯一的 ID,通过 EMAIL_ID 列。每一行也通过在 CUSTOMER_ID 列中持有用户表的 ID 来链接回客户表。这使得 EMAIL 表能够链接到 CUSTOMER 表。例如,用户 bobmarley 在系统中有两个电子邮件地址,一个用于家庭,一个用于工作。
注意
这些电子邮件地址不是真实的。
在这个假设的例子中,也可能有用于邮政地址、客户偏好、账单和其他事物的表。每个表都可能关联回客户表。
要使用关系数据库,您需要一个 关系数据库管理系统(RDBMS),这是管理表的软件。
关系数据库管理系统
一些最常用的关系数据库管理系统(RDBMS)包括 Oracle、MySQL、SQL Server、PostgreSQL 和 DB2。在每种情况下,您都有在服务器(或服务器)上运行的软件来管理数据,以及用于查询和操作数据的独立客户端软件。
要使用 RDMS,您首先需要安装数据库软件。
安装数据库
在本章中,我们将使用一个名为 H2 的开源数据库。H2 完全用 Java 编写,因此您可以在任何运行 JVM 的地方运行它,例如在 Windows、Linux 或 macOS 系统上。由于其可移植性和简单性,H2 适用于本章中我们将创建的数据库表。
H2 有一些不错的功能,它提供了一个基于浏览器的数据库控制台,您可以使用它来访问数据库。
注意
H2 也可以在您的应用程序中作为嵌入式的内存数据库使用。在这种情况下,数据库服务器和客户端都存在于您的 Java 应用程序中。
要安装 H2,请访问 packt.live/2MYw1XX 并下载 h2。
在 h2 文件夹内,您将看到名为 bin、docs、service 和 src 的子文件夹。docs 文件夹中的文档也在线可用。
bin 文件夹包含打包成 JAR 文件的 H2 数据库软件。它还包含 Windows 批处理文件和 Unix/Linux 脚本。
练习 1:运行 H2 数据库
现在您已经安装了数据库,下一步是让数据库运行起来。为此,请执行以下步骤:
-
要运行 H2 数据库,您可以使用
bin文件夹中的其中一个脚本,或者简单地运行jar文件。例如:java -jar h2*.jar无论您如何启动 H2 数据库,您都可以通过浏览器访问它。在某些系统上,例如 macOS,H2 将在您的默认浏览器中打开数据库控制台。
-
如果它没有自动打开,您只需将浏览器指向
http://10.0.1.7:8082/。 -
您将看到带有填写信息的登录面板,如图 图 7.1 所示:
![图 7.3:Web 数据库控制台的登录面板]()
图 7.3:Web 数据库控制台的登录面板
在开始时,所有信息都应该填写正确。数据库驱动程序(本章后面将讨论)是
org.h2.Driver,JDBC URL 是jdbc:h2:~/test,用户名是sa(系统管理员),密码为空。显然,在实际数据库中,你会使用实际的密码。
-
点击
连接。几分钟后,你会看到主控制台面板,你已进入。
注意
默认情况下,H2 将数据库存储在你的主目录中。使用名为
test的数据库,你应该在你的主目录中看到两个以test开头并以db结尾的文件。
一旦安装并运行了 H2 数据库,下一步就是开始创建表。为此,你需要用一种叫做 SQL 的语言编写命令。
介绍 SQL
结构化查询语言(SQL,通常发音为“sequ-el”)为查询和操作关系数据库中的数据提供了一种通用语言。虽然有一些差异,但 SQL 在 Oracle、SQL Server、MySQL 和 H2 等关系数据库系统中大多以相同的方式工作。
你需要做的第一件事是创建一个表。为此,使用 CREATE TABLE SQL 命令。要创建表,你必须提供表名、列名和类型以及任何约束。
练习 2:创建客户表
使用 SQL CREATE TABLE 命令创建一个 customer 表。它应包含客户 ID 和用户的姓氏和名字。
-
在右上角的输入面板中输入以下 SQL 命令:
CREATE TABLE IF NOT EXISTS customer ( CUSTOMER_ID long, USERNAME varchar(255), FIRST_NAME varchar(255), LAST_NAME varchar(255), UNIQUE(USERNAME), PRIMARY KEY (CUSTOMER_ID) ); -
输入 SQL 命令后,点击
运行按钮。图 7.2 展示了主要数据库控制台窗口:

图 7.4:创建表后的 H2 数据库控制台
注意在 图 7.4 中,一旦表创建完成,你会在左侧面板看到表名 CUSTOMER。你可以点击 + 符号展开表条目并查看列,如 图 7.4 所示。
CREATE TABLE 命令可以被分解为其组成部分。命令以 CREATE TABLE 开始。之后,IF NOT EXISTS 表示如果表已存在则不尝试重新创建表(使用 ALTER TABLE 命令来更改现有表的结构):
CREATE TABLE IF NOT EXISTS customer
接下来是表名,customer。
在括号之后,你会看到列的定义然后是约束:
CUSTOMER_ID long,
USERNAME varchar(255),
FIRST_NAME varchar(255),
LAST_NAME varchar(255),
CUSTOMER_ID 列是 long 类型,类似于 Java 的 long 类型。这个列将是唯一的键。
USERNAME、FIRST_NAME 和 LAST_NAME 列都是 varchar 类型。varchar 类型可以存储最多指定为 255 个字符的变长字符(文本)数据。
接下来是约束:
UNIQUE(USERNAME),
PRIMARY KEY (USER_ID)
USERNAME列必须是唯一的,而CUSTOMER_ID列是主键。(主键也必须是唯一的。)当您插入数据时,数据库将强制执行这些约束。请注意,您可以通过逗号分隔多个列名来创建一个复合主键。这意味着这些列中的值组合必须是唯一的。
整个命令以一个闭括号和一个分号结束。SQL 使用分号与 Java 一样,表示语句的结束。
向表中插入数据
要向表中插入数据,请使用INSERT INTO命令。基本语法如下:
INSERT INTO table_name
(column1, column2, column3, column4)
VALUES (value1, value2, value3, value4);
您首先列出列,然后为这些列提供值。对于不允许为空的列,您必须提供值。在这种情况下,CUSTOMER_ID和USERNAME是必需的。每个都必须是唯一的。
注意
SQL 使用单引号字符来界定字符串。如果您需要输入引号字符,请使用两个一起,例如Java''s。不要尝试使用一些文字处理软件中使用的智能引号。
练习 3:插入数据
此练习再次使用 H2 网络控制台。
-
在右上角的输入面板中输入以下 SQL 语句:
INSERT INTO customer (CUSTOMER_ID, USERNAME, FIRST_NAME, LAST_NAME) VALUES (1, 'bobmarley', 'Bob', 'Marley'); -
输入 SQL 命令后,单击
运行按钮。 -
使用以下两个 SQL 语句重复这两个步骤:
INSERT INTO customer (CUSTOMER_ID, USERNAME, FIRST_NAME, LAST_NAME) VALUES (2, 'petertosh', 'Peter', 'Tosh'); INSERT INTO customer (CUSTOMER_ID, USERNAME, FIRST_NAME, LAST_NAME) VALUES (3, 'jimmy', 'Jimmy', 'Cliff');注意
大多数关系型数据库管理系统(RDBMS)支持自动管理主键 ID 号的类型。然而,不同的数据库软件的语法可能会有所不同。有关 H2 数据库中
IDENTIIY类型的详细信息,请参阅packt.live/2J6z5Qt。
检索数据
要从表(或多个表)中检索数据,请使用SELECT命令。SQL SELECT命令允许您查询数据。您必须指定您要查找的内容。
基本语法如下:
SELECT what_columns_you_want
FROM table_name
WHERE criteria_you_want;
您可以提供以逗号分隔的列列表以返回,或者使用星号*来表示您希望返回所有列。最简单的查询如下:
SELECT * from customer;
您现在应该看到所有返回的行,如图 7.3所示:

图 7.5:查询客户表中的所有行
您可以使用WHERE子句来细化您的查询。例如:
SELECT * from customer
WHERE first_name = 'Bob';
这将返回所有first_name列值等于Bob的行,到目前为止,这将是仅有一行。
您可以使用带有LIKE修饰符的通配符查询:
SELECT * from customer
WHERE username LIKE '%e%';
此查询返回所有用户名中包含e的行。
在 SQL 中,百分号用作通配符。此示例在值的开头和结尾都有一个通配符。例如,您可以使用单个通配符来查询值的结尾:
SELECT * from customer
WHERE username LIKE '%ey';
此示例查询所有用户名值以ey结尾的记录。
您可以使用OR或AND在WHERE子句中进行更详细的查询。例如:
SELECT * from customer
WHERE
first_name = 'Peter'
OR
last_name = 'Cliff';
此示例返回所有first_name为Peter或last_name为Cliff的行,在这个例子中是两行。
使用 OR 运算符,SELECT 语句返回所有符合任一条件的行。使用 AND 运算符,两个条件部分都必须匹配:
SELECT * from customer
WHERE
first_name = 'Peter'
AND
last_name = 'Cliff';
由于没有行同时符合两个条件,此示例将返回零行。
到目前为止,我们使用星号来表示我们想要返回所有列。您可以使用逗号分隔的列名列表来指定。例如:
SELECT first_name, last_name from customer
order by
last_name, first_name;
此示例还使用了 ORDER BY 子句来告诉数据库按特定顺序返回记录,在这种情况下,按 last_name 排序,然后按 first_name 排序。
SQL 使用两个短横线 -- 来表示注释的开始,如下所示:
-- This is a comment.
SQL 查询可以非常复杂。这些例子只是提供了一个小的示例。
注意
有关 SQL 的更多信息,您可以参考以下 Packt 视频:packt.live/33KIi8S。
关联表
大多数数据库都包含多个表,其中许多表将是相关的。从早期的例子中,我们可以将客户表与一个单独的电子邮件地址表相关联。在先前的例子中,电子邮件表中的每一行都包含了与客户表相关行的 ID。
练习 4:创建电子邮件表
这个练习使用的是 H2 网络控制台。在这个练习中,我们将创建一个电子邮件表并向其中插入一些值。
-
在右上角的输入面板中输入以下 SQL:
CREATE TABLE IF NOT EXISTS email ( EMAIL_ID long, CUSTOMER_ID long, EMAIL_ADDRESS varchar(255), EMAIL_TYPE varchar(255), PRIMARY KEY (EMAIL_ID) ); -
输入 SQL 命令后,点击
运行按钮。 -
包含以下
INSERT语句,然后点击运行按钮:INSERT INTO email (EMAIL_ID, CUSTOMER_ID, EMAIL_ADDRESS, EMAIL_TYPE) VALUES (1,1, 'bob@example.com', 'HOME'); -
包含以下
INSERT语句,然后点击运行按钮:INSERT INTO email (EMAIL_ID, CUSTOMER_ID, EMAIL_ADDRESS, EMAIL_TYPE) VALUES (2,1, 'bob.marley@big_company.com', 'WORK'); -
包含以下
INSERT语句,然后点击运行按钮:INSERT INTO email (EMAIL_ID, CUSTOMER_ID, EMAIL_ADDRESS, EMAIL_TYPE) VALUES (3,2, 'petertosh888@example.com', 'HOME');
注意我们如何必须管理 ID,即 EMAIL_ID 和相关的 CUSTOMER_ID。这可能会变得繁琐。将 Java 对象映射到关系表的 Java 库,如 Hibernate,可以帮助解决这个问题。
注意
Hibernate 被认为是 ORM,即对象关系映射器。有关 Hibernate 的更多信息,请参阅 packt.live/2Bs5z3k。
一旦在多个相关表中有了数据,您就可以一次性查询多个表,并将结果连接起来。
从多个表中选择数据
当您使用 SQL 选择语句从多个表中查询数据时,您需要在 WHERE 子句中列出您希望返回的所有列(来自所有表),以及搜索条件。在 WHERE 子句中,您需要根据某些共同值将两个表连接起来。
例如,email 表有一个 customer_id 列,可以用来与 customer 表关联。为了关联它,编写如下查询:
SELECT username, email_address
FROM customer, email
WHERE email_type = 'HOME'
AND
email.customer_id = customer.customer_id;
在此查询中,我们请求来自客户表的 username 以及来自电子邮件表的 email_address。FROM 部分列出了客户和电子邮件表。
WHERE 子句变得更加有趣。此查询查找所有类型为 HOME 的电子邮件地址。为了将其与客户表连接起来,并确保你得到正确的客户,查询添加了一个连接,其中电子邮件表的 customer_id 列与客户表的 customer_id 列相对应。这确保了你得到正确的客户对齐。
修改现有行
UPDATE 命令让你修改现有行。要更新数据,你需要指定要更改的行以及要更改的值。基本语法如下:
UPDATE table_name
SET column1 = value1, column2 = value2
WHERE where_clause_to_find_rows
练习 5:修改电子邮件数据
如果用户,如 bobmarley,切换到不同的工作电子邮件,你需要更新电子邮件表。为此,执行以下步骤:
-
前往 H2 数据库控制台。
-
包含以下 SQL 查询,然后点击“运行”:
SELECT * from email;此命令让你在更改任何内容之前查看表中当前有哪些值。
-
接下来,输入以下
UPDATE语句,然后点击“运行”:UPDATE email SET EMAIL_ADDRESS = 'bob.marley@another_company.com' WHERE customer_id = 1 AND email_type = 'WORK';此查询更改了客户
bobmarley的email_address条目,但只是WORK电子邮件。 -
现在,再次运行选择查询(并点击“运行”)以查看表如何更改:
SELECT * from email;你现在应该看到以下表格中所示的结果:

图 7.6:查询输出
删除数据
要从表中删除数据,请使用 DELETE 命令:
DELETE FROM table_name
WHERE criteria_for_which_rows_to_delete;
例如,要删除客户 bobmarley 的工作电子邮件,你会使用如下命令:
DELETE FROM email
WHERE customer_id = 1
AND email_type = 'WORK';
注意
当你有相关的表时,删除数据变得更加复杂。例如,如果你删除一个客户,你还需要删除该客户的电子邮件表中的所有行。在这个例子中,电子邮件表依赖于客户表,但反之则不成立。
在本章迄今为止的所有示例中,我们都在 H2 控制台中使用了 SQL 来处理测试数据库中的数据。在你的 Java 应用程序中,你将使用 JDBC 来实现几乎相同的目标。
JDBC——从 Java 访问数据库
JDBC 提供了一个通用的 API 来处理数据库。大多数情况下,JDBC 与关系数据库一起工作,但你可以处理任何有 JDBC 驱动程序 的数据源,这是与数据源通信并实现 JDBC API 的 Java 库。
注意
JDBC 最好的部分之一是大多数驱动程序库是用 Java 编写的,因此你可以在运行 JVM 的任何平台上使用这些驱动程序。
使用 JDBC 的第一步是与数据源连接,通常是数据库。
连接到数据库
使用 JDBC 连接到数据库的最简单方法是使用 java.sql.DriverManager 类上的 getConnection() 方法:
Connection conn = DriverManager.getConnection("jdbc:h2:~/test", "sa", "");
此方法需要三个参数:
-
JDBC URL 以
jdbc:h2开头,告诉DriverManager查找 H2 JDBC 驱动程序。~/test告诉 H2 在当前用户的家目录中查找名为test的数据库。(这是运行 Java 程序的你。)test是 H2 创建的默认数据库名。 -
在此情况下,连接的用户的用户名是
sa,代表系统管理员。 -
在这种情况下,密码是空的。
注意
除了 H2,你连接数据库时很可能不会遇到空密码。H2 默认设置了
sa账户,你可以用它进行测试。
getConnection() 方法返回一个 java.sql.Connection 对象,你可以将其用作与数据库交互的起点。
注意
连接到数据库的其他方法有很多,尤其是在使用连接池时,这些方法将在本章后面进行描述。
几乎所有的 JDBC 操作都可能抛出 java.sql.SQLException,因此你通常会使用 try-catch 块来包装 JDBC 调用。
当你完成 JDBC 连接时,你应该关闭连接:
conn.close();
使用 JDBC 查询数据
要使用 JDBC 从数据库中查询,创建 java.sql.Statement 并执行查询:
String sql = "SELECT * from customer order by username";
statement = conn.createStatement();
ResultSet results = statement.executeQuery(sql);
使用 Connection 对象创建一个语句。然后,你可以使用 executeQuery() 方法执行 SQL 查询,该方法返回一个 java.sql.ResultSet 对象。
ResultSet API 最初可能会让人困惑。它基于游标的概念,即程序在数据中的位置记录。通过在 ResultSet 上调用 next(),你可以将游标移动到下一行。
因此,查询的正常流程将类似于以下内容:
String sql = "SELECT * from customer order by username";
statement = conn.createStatement();
ResultSet results = statement.executeQuery(sql);
while (results.next()) {
// Process the current row.
}
ResultSet 从一个位置——游标——开始,在第一行之前,因此你需要调用 next() 来获取第一行数据。当 next() 方法返回 false 时,表示已到达数据的末尾。
这样迭代 ResultSet 的部分原因是因为某些数据库表包含如此多的记录,你无法同时将它们全部保存在内存中。因此,一般的技巧是逐行处理。
对于每一行数据,调用 ResultSet 上的 get 方法。例如,要获取字符串值,调用 getString():
String username = results.getString("USERNAME");
在此示例中,我们将列名传递给 getString()。它返回当前行的 USERNAME 列的值。
你还可以传递结果集中列的位置。例如:
String username = results.getString(2);
位置号是结果集中列的位置,这取决于查询。
注意
与 Java 中的几乎所有其他内容不同,JDBC 列的计数从 1 开始,而不是 0。
你必须知道列中的数据类型,才能调用适当的 get 方法。例如,要获取 long 类型的值,调用 getLong():
Long id = results.getLong("CUSTOMER_ID");
注意
如果你不确定列中的数据类型,可以调用 getObject()。
当完成 ResultSet 的操作后,调用 close()。同样,当你完成语句的操作后,也调用 close()。在这些对象上调用 close() 方法可以释放资源。
练习 6:使用 JDBC 查询数据
这个练习将创建一个 IntelliJ 项目,引入 H2 数据库 JDBC 驱动的依赖项,然后查询数据库:
-
在 IntelliJ 的“文件”菜单中选择“新建”然后选择“项目...”。
-
选择
Gradle作为项目类型。点击“下一步”。 -
对于“组 ID”,输入
com.packtpub.db。 -
对于“工件 ID”,输入
customers。 -
在“版本”中输入
1.0。 -
在下一页接受默认设置。点击“下一步”。
-
将项目名称保留为
customers。 -
点击“完成”。
-
在 IntelliJ 文本编辑器中调用
build.gradle。 -
将
sourceCompatibility设置为12:sourceCompatibility = 12 -
将插件块替换为以下内容,就像我们在 第六章,库、包和模块 中做的那样:
buildscript { repositories { jcenter() } dependencies { classpath 'com.github.jengelman.gradle.plugins:shadow:2.0.1' } } apply plugin: 'java' apply plugin: 'com.github.johnrengelman.shadow' -
添加以下依赖项以将 H2 库纳入项目:
// https://mvnrepository.com/artifact/com.h2database/h2 implementation group: 'com.h2database', name: 'h2', version: '1.4.197'注意,提供 JDBC 驱动的相同 jar 文件还包括整个数据库软件。
-
将以下内容添加到项目的
build.gradle文件末尾,以定义可执行 jar 的主类:jar { manifest { attributes 'Main-Class': 'com.packtpub.db.Query } } -
在
src/main/java文件夹中创建一个新的 Java 包。 -
将包名输入为
com.packtpub.db。 -
在“项目”窗格中右键单击此包,创建一个名为
Query的新 Java 类。 -
为“查询”类创建一个
main()方法:
Query.java
6 public static void main(String[] args) {
7
8 String sql = "SELECT * from customer order by username";
9
10 Statement statement;
11
12 Connection conn;
13 try {
14 conn = DriverManager.getConnection("jdbc:h2:~/test", "sa", "");
15
16 statement = conn.createStatement();
17
18 ResultSet results = statement.executeQuery(sql);
https://packt.live/2PbKanp
此程序建立与 H2 数据库的连接。注意所有 JDBC 调用都被包装在 try-catch 块中。
在建立 connection 之后,程序要求 Connection 创建一个 Statement。在 Statement 上调用 executeQuery() 执行查询,返回一个 ResultSet。通过 while 循环,程序遍历 ResultSet 中的每一行,提取数据并打印。
最后,程序关闭了使用的资源。
这设置了一个可执行的 jar,它将运行 Query 类。请记住运行 shadowJar Gradle 任务来构建带有依赖项的可执行 jar。
当你运行此程序时,你应该看到如下类似的输出:
1 bobmarley Bob Marley
3 jimmy Jimmy Cliff
2 petertosh Peter Tosh
注意,查询要求数据库按用户名排序结果。
如果你从 H2 网页控制台连接到数据库,当你运行此程序时,你会看到如下错误:
org.h2.jdbc.JdbcSQLException: Database may be already in use: null. Possible solutions: close all other connection(s); use the server mode [90020-197]
你还应该看到完整的错误堆栈跟踪。这个错误表明你已经以用户 sa 登录到数据库。点击 H2 网页控制台左上角的断开连接图标来关闭网页控制台与数据库的连接。
在 第六章,使用 JDBC 查询数据 的“查询”类中,我们使用字符串作为 SQL 查询。当你的程序生成整个 SQL 语句时,这没问题。然而,如果你接受用户输入然后构建一个字符串作为 SQL,你的程序可能会受到 SQL 注入攻击的威胁,恶意用户输入的 SQL 语法旨在破坏你的数据库。
注意
要详细了解 SQL 注入漏洞,请参阅 packt.live/2OYGF3g。
由于这个风险,你应该在将用户输入放入 SQL 语句之前对其进行清理。
清理用户输入
要清理用户输入:
-
您可以自己正确地清理数据。例如,您可以禁止可能形成 SQL 语法的字符。
-
您可以使用
PreparedStatement接口并在预编译语句上设置值。JDBC 将为您清理输入。
使用预编译语句
JDBC 预编译语句接受一个带有数据值占位符的 SQL 语句。在大多数数据库中,JDBC 将 SQL 发送到数据库进行编译。当您向数据库发送 SQL 语句时,数据库需要将 SQL 编译成数据库本地的内部格式,然后数据库可以执行该语句。
使用常规语句时,您可以将 SQL 语句提供给executeQuery()和executeUpdate()等方法。您可以使用Statement重复使用并提供一个完全不同的 SQL 语句。
相反,使用PreparedStatement时,您使用 SQL 字符串准备语句,这就是您得到的所有内容。幸运的是,尽管如此,您提供了数据值的占位符。这意味着您可以使用PreparedStatement重复插入多个记录到表中,例如。
从练习 5,修改电子邮件数据,我们使用UPDATE语句:
UPDATE email
SET EMAIL_ADDRESS = 'bob.marley@another_company.com'
WHERE customer_id = 1
AND email_type = 'WORK';
使用PreparedStatement时,您会使用问号?作为输入值的占位符:
String sql = "UPDATE email " +
"SET EMAIL_ADDRESS = ? " +
"WHERE customer_id = ? " +
"AND email_type = ? ";
注意
在预编译语句中,您不需要在字符串占位符周围放置单引号。JDBC 会为您处理这一点。
在使用PreparedStatement之前,需要填写这些占位符。例如:
statement = conn.prepareStatement(sql);
statement.setString(1, "bob.marley@another_company.com");
statement.setLong(2, 1L);
statement.setString(3, "WORK");
int rowsChanged = statement.executeUpdate();
将带有占位符的 SQL 字符串传递给连接上的prepareStatement()方法。然后,调用setString()、setLong()等,以填写占位符值。在每个设置方法调用中,您传递要填充的占位符的索引,从第一个占位符的1开始。然后,传递要填充的值。JDBC 将处理防止 SQL 注入攻击。
对于常规Statement,您可以通过调用executeQuery()执行 SQL 查询,或通过调用executeUpdate()修改数据库。executeUpdate()方法处理INSERT、UPDATE和DELETE SQL语句。
在这个例子中,executeUpdate()返回被修改的表中的行数。
使用预编译语句的主要好处之一是 JDBC 将清理输入值,因此您不必这样做。另一个主要好处是性能提升。如果您反复执行相同的 SQL 语句,或者几乎相同的语句,只是值不同,那么使用预编译语句将加快速度,这主要是由于预编译语句。
事务和回滚
在关系型数据库中,事务将一组 SQL 语句组合在一起。要么所有语句都成功,要么事务将回滚,撤销这些语句。此外,数据库将事务中的所有语句视为同时发生,这有助于确保数据的一致性。
在 JDBC 中,事务会一直持续到你在连接上调用commit()。如果发生故障,你应该在连接上调用rollback()以将数据恢复到事务开始之前的状态。
默认情况下,JDBC 连接以自动提交模式开始。这意味着每个 JDBC 连接都会逐个提交。如果你想将几个语句组合在一个事务中,你首先需要关闭自动提交模式:
conn.setAutoCommit(false);
注意
在完成对数据库的访问后,你应该将自动提交模式关闭后再打开。
当你想结束事务并将结果提交到数据库时,调用commit():
conn.commit();
如果抛出SQLException,你将想要回滚事务:
} catch (SQLException e) {
e.printStackTrace();
try {
if (conn != null) {
conn.rollback();
}
} catch (SQLException nested) {
nested.printStackTrace();
}
}
这段代码展示了使用 JDBC 时最繁琐的部分之一。在你的SQLException异常处理程序中,所做的调用——例如rollback()——也可能抛出另一个SQLException,你需要捕获它。你会发现 JDBC 代码充满了嵌套的try-catch-finally块。练习 7,使用带有事务的预编译语句展示了这一技术的实际应用。
练习 7:使用带有事务的预编译语句
在这个练习中,我们将创建另一个 Java 类,该类使用 JDBC PreparedStatement更新电子邮件表中的数据,并将该更新包装在 JDBC 事务中。
-
在 IntelliJ 中创建一个名为
Prepared的新类并创建一个main()方法。 -
导入所需的库:
package com.packtpub.db; import java.sql.Connection; import java.sql.DriverManager; import java.sql.PreparedStatement; import java.sql.SQLException; -
在
Prepared类中输入以下代码。public class Prepared { public static void main(String[] args) { Connection conn = null; PreparedStatement statement = null; String sql = "UPDATE email " + "SET EMAIL_ADDRESS = ? " + "WHERE customer_id = ? " + "AND email_type = ? ";Prepared类首先定义一个使用占位符的SQL UPDATE语句。这个 SQL 语句稍后将放入PreparedStatement中。 -
在第一个 try-catch 块中,程序获取数据库的
Connection,然后使用参数false调用setAutoCommit()以关闭自动提交模式。JDBC 现在期望程序管理事务。
Prepared.java
20 try {
21 conn = DriverManager.getConnection("jdbc:h2:~/test", "sa", "");
22 conn.setAutoCommit(false);
23
24 statement = conn.prepareStatement(sql);
25 statement.setString(1, "bob.marley@another_company.com");
26 statement.setLong(2, 1L);
27 statement.setString(3, "WORK");
28
29 int rowsChanged = statement.executeUpdate();
30
31 conn.commit();
32
33 System.out.println("Number rows changed: " + rowsChanged);
https://packt.live/2MSobyQ
当你运行main()方法时,你应该看到以下输出:
Number rows changed: 1
只应修改一行。
程序将 SQL 字符串传递给连接的prepareStatement()方法。这创建了一个初始化给定 SQL 的PreparedStatement。接下来,程序在PreparedStatement中填充占位符值。
完成后,程序在语句上调用executeUpdate(),提交事务,然后告诉我们更改了多少行。
如果任何 JDBC 调用抛出SQLException,catch 块将打印堆栈跟踪,然后在连接上调用rollback()。调用rollback()也可能抛出SQLException,因此程序也会捕获它,打印堆栈跟踪。
原始的 try-catch-finally 块中的finally块将自动提交事务模式恢复,然后对PreparedStatement和连接调用close(),这可能会导致每个都抛出SQLException。
简化 JDBC 编程
如你所见,使用 JDBC 编程是繁琐的。正因为如此,许多项目都在 JDBC API 上开发了包装器,以简化 JDBC 调用。
Java 本身包含了许多实用类,例如 JdbcRowSet,它包装 ResultSet 对象并提供了一个相对简单的 API。
注意
Spring 框架提供了一系列实用工具来简化 JDBC 编程。更多信息请参考 packt.live/35PalWP。
到目前为止,最受欢迎的无需 JDBC API 不便即可访问数据库的方法是使用对象关系映射软件。
使用对象关系映射软件
如其名所示,对象关系映射(ORM)软件在对象世界和关系表世界之间进行映射。使用 ORM,你通常编写一个 Java 类来表示表中的一行。
例如,以下类可以代表客户表中的一行:
Customer.java
1 package com.packtpub.db;
2
3 public class Customer {
4 Long customerId;
5 String username;
6 String firstName;
7 String lastName;
8
9 public Customer(Long customerId, String username, String firstName, String lastName) {
10 this.customerId = customerId;
11 this.username = username;
12 this.firstName = firstName;
13 this.lastName = lastName;
14 }
https://packt.live/2pvQhYT
Customer 类通常被称为 纯 Java 对象(POJO)。ORM 软件允许你使用查询表并获取 POJO 列表,或者在一个 POJO 中填写数据,然后将该对象持久化到数据库。在大多数情况下,ORM 软件使用反射来发现类中的字段并将它们映射到表中的列。
注意
第十九章 讲解反射。
Java 持久化 API(JPA)提供了一个标准化的 API,用于使用注解来描述映射,从而定义对象和数据库表之间的映射。JPA 还定义了一个 API,用于将 POJO 持久化到数据库表。
在标准的 Java 持久化 API 之下,你需要使用一个 JPA 提供者,这是一个实现 JPA 的库。最常用的 JPA 提供者是 Hibernate。
注意
关于 JPA 的更多信息,请参考 packt.live/2OZjHsP。JPA 是 Java 企业版(JavaEE)的一部分。
数据库连接池
DriverManager.getConnection() 方法建立数据库连接可能需要相当长的时间。为了帮助解决这个问题,你可以使用数据库连接池。
连接池会建立多个并管理到数据库的连接。然后你的应用程序可以从池中请求一个空闲连接。你的代码使用连接后,将其返回到池中。
一些主要的连接池软件库包括:
-
HikariCP,来自
packt.live/2Bw7gg5 -
Apache Commons DBCP,来自
packt.live/31p4xQg -
C3p0,来自
packt.live/2pw1vN0 -
Tomcat 连接池,来自
packt.live/31pGgcJ
非关系型,或 NoSQL 数据库
关系型数据库在数据与 SQL 数据库表中的列和行很好地工作时表现良好。在现实世界中,并非所有数据都能整齐地适应这个模型。这导致了 NoSQL 数据库的创建,这是一种不支持关系表的数据库管理软件。
注意
奇怪的是,一些 NoSQL 数据库支持类似于 SQL 的语言来访问数据。
NoSQL 数据库各不相同,描述这些数据库的一些类别有所重叠。Terrastore,packt.live/2P23i7e,和 MongoDB,packt.live/31qJVY0,被认为是文档存储数据库。在这些系统中,你存储一个完整的文档,通常是结构化文档。
Cassandra,packt.live/2MtDtej,和 HBase,packt.live/2VWebsp,有时被称为列存储或列族数据库,它们将数据存储在列中,而不是像大多数 SQL 数据库那样按行存储。如果你正确地组织了列,这些数据库可以非常快速地检索数据。你还可以存储大量的列。
Neo4j,packt.live/2o51EXm,是一个图数据库。在图数据库中,你通过元素之间的关系来检索数据。这些关系形成了一个图。
活动一:跟踪你的进度
在这个活动中,我们将设置 H2 数据库中的数据库表来跟踪你在本课程中的进度。以下步骤将帮助我们完成这个活动:
-
创建一个名为
student的表,其中每条记录都包含有关学生(例如你)的信息。定义 ID、姓名和姓氏列。 -
创建一个名为
chapter的表,其中每条记录都包含有关章节的信息。定义 ID(使用章节编号)和章节标题列。为了简单起见,你可以只输入到包括这一章在内的所有章节。 -
创建一个名为
student_progress的表来关联学生和章节,这个表应该包含学生的 ID、章节的 ID 以及完成章节的日期。使用SQL DATE类型,并将数据传递为yyyy-MM-dd。这个表应该有一个复合主键。你可以使用 H2 网络控制台创建表并插入记录。
-
创建两个使用 JDBC 的 Java 程序。
创建第一个程序来查询给定学生完成的所有章节及其完成时间。输入学生的姓名和姓氏。这将生成如下输出:
BOB MARLEY 2019-03-01 2 Learning the Basics 2019-03-01 7 Databases and JDBC创建第二个程序来插入章节完成情况。输入学生的姓名、姓氏以及章节编号。程序应标记该章节为今天已完成。
由于这两个程序都接受用户输入,请确保在每个程序中使用
PreparedStatement来处理可能的有害输入数据。你可以将这些程序作为本章之前创建的客户项目的一部分来创建。注意
活动的解决方案可以在第 548 页找到。
摘要
本章介绍了关系数据库管理系统(RDBMS)和 SQL 语言,这是用于处理关系数据库的语言。我们使用了一个全 Java 数据库,称为 H2。SQL 是一种用于检索和修改存储在关系数据库中的数据的语言。JDBC 是一个与关系数据库通信的 Java API。你可以使用 SQL 命令来检索和修改数据。
数据库的内容远不止一个章节所能展示的,但在完成练习后,你应该能够开始使用 SQL 和 JDBC 与数据库进行工作。一本关于 SQL 的书或培训课程可以帮助你深入研究高级数据库主题。
注意
Packt 视频《SQL 从入门到精通:MySQL 版》- 使用 MySQL 掌握 SQL:packt.live/33KIi8S 将帮助你提升 SQL 技能。
在下一章中,你将学习使用 Java 进行网络和文件操作。
第八章:8. 套接字、文件和流
概述
本章将教会你如何与外部数据存储系统协同工作。在早期部分,你将学习如何列出目录的内容——这是学习使用 Java 创建、打开、读取和写入外部文件的逻辑第一步。从那里,你将研究不同的方法,缓冲和非缓冲,以及如何区分它们。然后,你将学习识别两个主要的java.io和java.nio,它们与上述方法的相应关系,以及何时何地使用它们。在本章的最终活动中,你将被要求使用所有这些 Java 技能和工具,以便在远程计算机上运行的两个不同的程序之间进行通信,为接下来的章节做准备。
简介
在操作系统层面,文件和目录在某种程度上是相似的。它们是代表存储中某个链接的名称,无论是你的硬盘、云中的某个地方,还是你口袋里的 USB 驱动器。然而,在概念层面上,它们本质上是不同的。文件包含信息,而目录链接到其他目录和文件。
有两个主要的java.io和java.nio。这两个 API 都可以用来导航目录和操作文件。关于文件位置的信息称为路径名。它包含文件所在硬盘上目录的完整信息,一直到文件名和扩展名。它应该具有以下形式:
/folder_1/folder_2/[...]/folder_n/file.extension
不同的操作系统对文件和文件夹结构的称呼不同。在 Unix 系统(如 Linux 或 macOSX)中,/符号代表文件夹之间的分隔。在路径名开头有一个/表示相对于系统根文件夹的绝对定位。没有这个符号将表示相对于classpath或程序执行的路径的相对定位。在 Windows 计算机中,文件夹分隔符是\,根目录由硬盘标签确定。默认情况下,Windows 的根文件夹是C:,但你也可以在任何其他驱动器中存储文件,例如D:。
之前提到的两个 API(即java.io和java.nio)之间的主要区别在于它们读取和写入数据的方式。第一个,java.io,可以与流(这是我们将在本章后面探讨的概念)协同工作,并以阻塞方式从一点到另一点逐字节传输数据。第二个,java.nio,与缓冲区协同工作。这意味着数据以块的形式读入和写入内存的一部分(缓冲区),而不是直接从流中读取。这允许非阻塞通信,例如,允许你的代码在不需要等待所有数据发送的情况下继续做其他事情——你只需开始将信息复制到缓冲区,然后继续做其他事情。
当涉及到文件时,主要区别在于使用一种方法或另一种方法在尝试以不同的方式执行相同任务时,程序的速度会更快或更慢。我们将主要关注使用java.nio,因为它更容易用它来使用文件,然后偶尔会提到java.io。java.nio.file API(注意与java.io.File的区别)定义了 JVM 使用的类和接口——包括文件、它们的属性和文件系统——是较新的,并提供了一种更简单的方式来使用接口。然而,并非所有情况都如此,正如我们将在本章中看到的。
列出文件和目录
我们将探讨如何以不同的方式列出文件和目录。当检查某个文件是否存在时,这些技术可能会派上用场,这将在您尝试找到属性文件等情况下,允许您向用户提供更敏感的信息。如果您发现您正在寻找的文件不存在,同时您还注意到您不在正确的目录中,您可以让您的程序定位文件实际所在的文件夹,或者您可以简单地通知用户这种情况。
注意
在您的计算机上的任何位置列出文件和目录都有不同的技术。您必须根据具体情况明智地选择。虽然乍一看最新的 API 似乎更复杂,但正如您将在以下示例中看到的,它比之前的任何版本都要强大得多。
让我们从列出目录内容的老方法开始。在下一个练习中,我们只会使用java.io。它需要调用File(dir).list(),其中dir是一个表示您想要访问的文件夹名称的字符串。为了确保本书中的代码与您的操作系统兼容,我们选择检查您的操作系统的临时文件夹。Java 将其存储在 JVM 属性中,标记为java.io.tmpdir。因此,方法开始时的getProperty()调用提取了文件夹的名称。例如,对于任何 Unix 操作系统,该属性指向/tmp文件夹。
您的临时文件夹将被许多由您计算机中运行的不同程序创建的文件和文件夹填满。因此,我们选择只显示操作系统列出的前五个——顺序由操作系统决定。除非您对list()调用的结果进行排序,否则您很可能找不到输出排序的逻辑:
import java.io.*;
import java.util.*;
public class Example01 {
public static void main(String[] args) throws IOException {
String pathString = System.getProperty("java.io.tmpdir");
String [] fileNames = new File(pathString).list();
for (int i = 0; i < 5; i++ ) {
System.out.println(fileNames[i]);
}
}
}
本例的输出将如下所示:
Slack Crashes
+~JF8916325484854780029.tmp
gnome-software-CAXF1Z
.XIM-unix
.X1001-lock
Process finished with exit code 0
注意
由于每个人的计算机内容都不同——即使在特定的文件夹中也是如此——因此,您将在本章的代码列表中看到的输出信息将与您在终端中看到的不同。
在之前的示例中,我们故意隐藏了处理每个代码块的 API 部分,以简化代码列表。如果你从代码中删除三个导入语句,并按照 IDE 的指示添加更细粒度的 API 来处理此代码,你将得到以下结果:
import java.io.File;
import java.io.IOException;
import java.util.ArrayList;
import java.util.List;
到目前为止,你在本书中几乎已经学习了所有这些 API。甚至在异常章节中,为了捕获IOException,也简要介绍了java.io.File。在接下来的示例中,我们将遵循同样的原则,只是为了尽可能缩短程序头。然而,最好还是减少代码行数。
让我们探索另一种列出目录内容的方法,但这次使用java.nio:
import java.io.IOException;
import java.nio.file.*;
import java.util.*;
public class Example02 {
public static void main(String[] args) throws IOException {
String pathString = System.getProperty("java.io.tmpdir");
List<String> fileNames = new ArrayList<>();
DirectoryStream<Path> directoryStream;
directoryStream = Files.newDirectoryStream(Paths.get(pathString));
for (Path path : directoryStream) {
fileNames.add(path.toString());
}
for (int i = 0; i < 5; i++ ) {
System.out.println(fileNames.get(i));
}
}
}
如你所见,这个列表的输出与之前的示例不同:
/tmp/Slack Crashes
/tmp/+~JF8916325484854780029.tmp
/tmp/gnome-software-CAXF1Z
/tmp/.XIM-unix
/tmp/.X1001-lock
Process finished with exit code 0
在这里,显示了目录和文件的完整路径。这与DirectoryStream从操作系统捕获信息的方式有关。这个例子中的for循环可能对你来说看起来很新。这与我们处理流的方式有关。我们还没有解释它们,而且我们将在本章的后面部分进行解释。但你可以看到它在做什么:它创建了一个缓冲区,用于存储关于不同目录的信息。然后,如果缓冲区中有数据,就可以使用for(Path path : directoryStream)语句遍历缓冲区。由于我们一开始不知道它的大小,我们需要一个列表来存储包含目录内容的字符串。然而,在这个阶段,我们还没有调用java.util.stream API,因为DirectoryStream属于java.nio API。
这里展示了另一个正确使用流的代码示例。请注意,我们没有显示其输出,因为它与之前的示例相同:
import java.io.IOException;
import java.nio.file.*;
import java.util.stream.Stream;
public class Example03 {
public static void main(String[] args) throws IOException {
String pathString = System.getProperty("java.io.tmpdir");
Path path = Paths.get(pathString);
Stream<Path> fileNames = Files.list(path);
fileNames.limit(5).forEach(System.out::println);
}
}
将目录与文件分离
假设你希望在列出文件夹内容时将文件与目录区分开来。为了做到这一点,你可以使用java.nio中的一个方法,即isDirectory(),如下面的示例所示:
Example04.java
17 for (int i = 0; i < 5; i++ ) {
18 String filePath = fileNames.get(i);
19 String fileType = Files.isDirectory(Paths.get(filePath)) ? "Dir" : "Fil";
20 System.out.println(fileType + " " + filePath);
21 }
https://packt.live/2o43Yhe
我们已经突出了与之前使用 java.nio API 访问目录的示例相比的新代码部分。Files.isDirectory()需要一个Paths类的对象。Paths.get()将路径从目录项(作为字符串传递)转换为Paths类的实际实例。有了这个,Files.isDirectory()将返回一个布尔值,如果是目录则为true,如果不是则为false。我们使用内联if语句根据我们处理的是目录还是文件来分配字符串Dir或Fil。这段代码的输出结果如下:
Dir /tmp/Slack Crashes
Fil /tmp/+~JF8916325484854780029.tmp
Dir /tmp/gnome-software-CAXF1Z
Dir /tmp/.XIM-unix
Fil /tmp/.X1001-lock
Process finished with exit code 0
如您所见,在临时目录中,既有文件也有子目录。下一个问题是如何列出子目录的内容。我们将把这个问题作为一个练习来处理,但在我们这样做之前,尝试一个更进一步的例子,该例子将只列出目录项。这是一个更高级的技术,但它将给我们一个机会,回顾并尝试使用我们到目前为止所获得的知识来实现自己的解决方案:
import java.io.IOException;
import java.nio.file.*;
import java.util.*;
import java.util.stream.Collectors;
public class Example05 {
public static void main(String[] args) throws IOException {
String pathString = System.getProperty("user.home");
List<Path> subDirectories = Files.walk(Paths.get(pathString), 1)
.filter(Files::isDirectory)
.collect(Collectors.toList());
for (int i = 0; i < 5; i++ ) {
Path filePath = subDirectories.get(i);
String fileType = Files.isDirectory(filePath) ? "Dir" : "Fil";
System.out.println(fileType + " " + filePath);
}
}
}
首先,为了展示使用其他环境变量的可能性(这就是我们所说的系统属性,它是为您的操作系统定义的),我们将文件夹更改为用户主目录,这对应于您的用户空间,或者您通常存储文件的目录。请从现在开始小心,以避免任何与您的文件相关的意外。
Files.walk() 将提取到一定深度的目录结构,在我们的例子中,是深度一。深度表示您的代码将挖掘多少层子目录。filter(Files::isDirectory) 将排除任何不是目录的东西。我们还没有看到过滤器,但这个概念足够清晰,不需要进一步解释。调用最后的部分,collect(Collectors.toList()),将创建一个输出列表。这意味着 subDirectories 对象将包含目录路径的列表。这就是为什么在这个例子中,与上一个例子不同,我们不需要调用 Paths.get(filePath)。该调用的输出将取决于您的操作系统以及您主文件夹中的内容。在我的计算机上,它运行的是 Linux 版本,结果如下:
Dir /home/<userName>
Dir /home/<userName>/.gnome
Dir /home/<userName>/Vídeos
Dir /home/<userName>/.shutter
Dir /home/<userName>/opt
Process finished with exit code 0
在这里,<userName> 对应于计算机上用户的昵称。如您所见,这仅表示在 pathString 初始化的目录的内容。问题是,我们能否在我们的程序中表示嵌套子目录的内容到初始的 pathString?
练习 1:列出子目录的内容
让我们利用到目前为止所获得的知识,编写一个程序来导航子目录。这可能不是解决这个挑战的最佳方式,但它会有效:
-
让我们从最新的例子开始,我们使用
Files.walk()调用,深度为 1,并使用过滤器来列出特定目录pathString的内容——只是目录。目录搜索中的深度决定了我们的程序将导航到多少层子目录。级别 1 与搜索发起的同一级别相同。级别 2 表示我们还应该表示主目录内部的目录内容。原则上,这应该像给调用一个更高的深度值一样简单,如下所示:List<Path> subDirectories = Files.walk(Paths.get(pathString), 2) .filter(Files::isDirectory) .collect(Collectors.toList()); -
但有一个问题。当运行这样的调用时,很可能存在程序不允许访问的目录或文件。将触发一个关于权限的异常,并且程序将停止:
Exception in thread "main" java.io.UncheckedIOException: java.nio.file.AccessDeniedException: /home/<userName>/.gvfs at java.nio.file.FileTreeIterator.fetchNextIfNeeded(FileTreeIterator.java:88) at java.nio.file.FileTreeIterator.hasNext(FileTreeIterator.java:104) [...] at java.util.stream.ReferencePipeline.collect(ReferencePipeline.java:499) at Example04.main(Example04.java:13) Caused by: java.nio.file.AccessDeniedException: /home/<userName>/.gvfs at sun.nio.fs.UnixException.translateToIOException(UnixException.java:84) at sun.nio.fs.UnixException.rethrowAsIOException(UnixException.java:102) [...] at java.nio.file.FileTreeIterator.fetchNextIfNeeded(FileTreeIterator.java:84) ... 9 more Process finished with exit code 1 -
访问任何包含在这些子目录中的目录或文件,这些子目录处于严格的行政用户权限之下,将导致此程序崩溃。捕获这个异常没有任何用处,因为结果仍然是一个无法使用的目录列表。有一种相当高级的技术可以使它工作,但你还没有被介绍到完成这个任务所需知道的一切。相反,让我们专注于你已经获得的工具,以便创建自己的方法来深入子目录并提取其内容。
-
让我们回到示例 03并修改它,使其仅显示 user.home 目录内的目录:
String pathString = System.getProperty("user.home"); Path path = Paths.get(pathString); Stream<Path> fileNames = Files.list(path).filter(Files::isDirectory); fileNames.limit(5).forEach(System.out::println); -
如您所见,我们应用了之前看到的
filter()方法。我们也可以实现使用isDirectory()的替代方案,就像我们在示例 04中看到的那样,但这更简洁,简洁是关键。 -
基于这样的想法,即
list()可以给出任何文件夹的内容,让我们再次为每个文件名调用它。这意味着我们将不得不修改我们正在使用的forEach()语句,以便我们可以访问嵌套目录的第二层:fileNames.limit(5).forEach( (item) -> { System.out.println(item.toString()); try { Stream<Path> fileNames2 = Files.list(item).filter(Files::isDirectory); fileNames2.forEach(System.out::println); } catch (IOException ioe) {} }); -
如您所见,高亮显示的代码是我们之前代码的重复,只是对象名称改为
fileNames2。这次,我们移除了限制,这意味着它将打印出每个目录拥有的任何子目录的输出。真正的创新之处在于我们是如何从仅调用System.out::print转变为编写更复杂的代码,首先打印出我们所在的路径,然后打印该路径的子文件夹路径。我们在这里期待的是一种称为 lambda 表达式的功能。它们将在后面的章节中解释。然而,这里的代码足够简单,您可以理解。对于fileNames缓冲区中的每个(item),我们将执行上述提到的操作。结果看起来像这样:/home/<userName>/.gnome /home/<userName>/.gnome/apps /home/<userName>/Vídeos /home/<userName>/Vídeos/technofeminism /home/<userName>/Vídeos/Webcam /home/<userName>/Vídeos/thumbnail /home/<userName>/.shutter /home/<userName>/.shutter/profiles /home/<userName>/opt /home/<userName>/opt/Python-3.4.4 /home/<userName>/.local /home/<userName>/.local/share /home/<userName>/.local/bin /home/<userName>/.local/lib Process finished with exit code 0 -
此外,必须在生成列表时捕获
IOException,否则代码将无法编译。在main方法的声明中throw IOException不适用于forEach()表达式,因为它在程序的作用域中更深一层。在这种情况下,我们正在查看方法的内联定义。但问题是,我们如何绕过目录探索任意深度的想法? -
在深入
java.nioAPI 中,我们发现walkFileTree()方法,它可以浏览目录结构,直到达到一定的深度——在下面的示例中是两层——并提供覆盖其部分方法的可能性,以决定到达目录项并尝试访问时会发生什么。对这个方法的调用可能看起来像这样:Path path = Paths.get(System.getProperty("user.home")); Files.walkFileTree(path, Collections.emptySet(), 2, new SimpleFileVisitor<Path>() { @Override public FileVisitResult preVisitDirectory(Path dir, BasicFileAttributes attrs) { System.out.println(dir.toString()); return FileVisitResult.CONTINUE; } }); -
在这里,你可以看到当尝试在文件夹中打开一个目录项时,
preVisitDirectory()方法是如何被调用的。包含该行的程序将一直运行,直到例如,出现与权限相关的异常。如果没有异常情况,重写的方法将打印出所有深度达两级的目录名称。在我们实验的主目录的情况下,我们知道有一个文件夹,Java 的默认用户权限不足以让我们的程序访问。因此,如果我们运行这个程序,我们会看到它正常操作直到遇到异常:/home/<userName>/.gnome/apps /home/<userName>/Vídeos/technofeminism /home/<userName>/Vídeos/Webcam [...] /home/<userName>/.local/lib Exception in thread "main" java.nio.file.AccessDeniedException: /home/<userName>/.gvfs at sun.nio.fs.UnixException.translateToIOException(UnixException. java:84) at sun.nio.fs.UnixException.rethrowAsIOException(UnixException. java:102) at sun.nio.fs.UnixException.rethrowAsIOException(UnixException. java:107) at sun.nio.fs.UnixFileSystemProvider. newDirectoryStream(UnixFileSystemProvider.java:427) at java.nio.file.Files.newDirectoryStream(Files.java:457) at java.nio.file.FileTreeWalker.visit(FileTreeWalker.java:300) at java.nio.file.FileTreeWalker.next(FileTreeWalker.java:372) at java.nio.file.Files.walkFileTree(Files.java:2706) at Exercise01.main(Exercise01.java:11) Process finished with exit code 1 -
preVisitDirectory()方法将告诉walkFileTree方法它应该继续通过其返回值工作。这里的问题是,由于AccessDeniedException,我们的程序将不会进入preVisitDirectory()。我们需要重写另一个名为visitFileFailed()的方法来了解如何处理尝试访问目录项时发生的任何类型的异常:@Override public FileVisitResult visitFileFailed(Path file, IOException exc) throws IOException { System.out.println("visitFileFailed: " + file); return FileVisitResult.CONTINUE; }这将产生预期的结果,如下所示:
/home/<userName>/.gnome/apps /home/<userName>/Vídeos/technofeminism [...] /home/<userName>/.local/lib visitFileFailed: /home/<userName>/.gvfs /home/<userName>/.config/Atom [...] /home/<userName>/drive_c/Program Files /home/<userName>/drive_c/Program Files (x86) /home/<userName>/drive_c/users /home/<userName>/drive_c/windows /home/<userName>/.swt/lib Process finished with exit code 0从这个过程我们可以得出结论,尽管有多种方法可以执行相同的任务,但那些解决方案的实现方式将使我们能够有所控制。在这种情况下,
walk()方法不足以让我们轻松处理异常,因此我们必须探索一个替代方案,最终发现这个方案更容易理解。作为参考,这个练习的最终代码应该如下所示:
Exercise01.java
1 import java.io.IOException;
2 import java.nio.file.*;
3 import java.nio.file.attribute.BasicFileAttributes;
4 import java.util.Collections;
5
6 public class Exercise01 {
7 public static void main(String[] args) throws IOException {
8 Path path = Paths.get(System.getProperty("user.home"));
9
10 Files.walkFileTree(path, Collections.emptySet(), 2, new SimpleFileVisitor<Path>() {
11
12 @Override
13 public FileVisitResult preVisitDirectory(Path dir, BasicFileAttributes attrs) {
14 System.out.println(dir.toString());
15 return FileVisitResult.CONTINUE;
16 }
https://packt.live/35MN9Zd
创建和写入文件
一旦我们熟悉了如何列出目录的内容,下一步合乎逻辑的步骤就是继续创建文件和文件夹。让我们先通过使用 java.nio 创建并写入文件。使用此 API 创建文件的最简单方法需要调用以下代码:
Files.createFile(newFilePath);
同时,创建一个目录就像这样简单:
Files.createDirectories(newDirPath);
作为一种良好的实践,你应该在创建具有相同名称的目录和/或文件之前检查它们是否存在。有一个简单的方法可以检查 Path 类的任何对象是否可以在程序正在探索的文件夹中找到:
Files.exists(path);
让我们把所有这些放在一起,通过一个示例来创建一个文件夹,然后在文件夹内创建一个文件:
Example06.java
1 import java.io.IOException;
2 import java.nio.file.Files;
3 import java.nio.file.Path;
4 import java.nio.file.Paths;
5
6 public class Example06 {
7 public static void main(String[] args) {
8 String pathString = System.getProperty("user.home") + "/javaTemp/";
9 Path pathDirectory = Paths.get(pathString);
10 if(Files.exists(pathDirectory)) {
11 System.out.println("WARNING: directory exists already at: " + pathString);
12 } else {
13 try {
14 // Create the directory
15 Files.createDirectories(pathDirectory);
16 System.out.println("New directory created at: " + pathString);
17 } catch (IOException ioe) {
18 System.out.println("Could not create the directory");
19 System.out.println("EXCEPTION: " + ioe.getMessage());
20 }
21 }
https://packt.live/2MSEPhX
第一次执行此代码列表的结果应该如下所示:
New directory created at: /home/<userName>/javaTemp/
New file created at: /home/<userName>/javaTemp/temp.txt
Process finished with exit code 0
任何后续执行都应该给出以下结果:
WARNING: directory exists already at: /home/<userName>/javaTemp/
WARNING: file exists already at: /home/<userName>/javaTemp/temp.txt
Process finished with exit code 0
这创建了一个本质上为空的文件。使用终端,你可以通过调用 ls -lah ~/javaTemp/temp.txt 命令来列出文件的大小,这将返回如下结果:
-rw-r--r-- 1 userName dialout 0 maj 15 13:57 /[...]/temp.txt
这意味着文件在硬盘上不占用任何字节的存储空间。这意味着文件存在,但它为空。使用java.nio.file.Files API 中的write()方法轻松地将文本写入文件:write()。唯一的问题是传递给此方法的参数不是显而易见的。在其最简单的接口中,你必须传递两个参数:Path对象和一个包含文本的List。除此之外,还存在着文件可能不存在的风险,这需要处理经典的IOException。它可能看起来像这样:
try {
Files.write(pathFile, Arrays.asList("hola"));
System.out.println("Text added to the file: " + pathFile);
} catch (IOException ioe) {
System.out.println("EXCEPTION: " + ioe.getMessage());
}
注意
当调用write()向文件写入文本时,你不需要在字符串的末尾添加换行符。它将自动由方法添加,就像使用println()等命令时预期的那样。
一旦你将最后一个代码片段添加到最新的例子中,程序将给出以下结果:
WARNING: directory exists already at: /home/<userName>/javaTemp/
WARNING: file exists already at: /home/<userName>/javaTemp/temp.txt
Text added to the file: /home/<userName>/javaTemp/temp.txt
Process finished with exit code 0
之前的例子只是将文本写入文件,同时也删除了之前的内容。如果你想追加文本而不是覆盖,你需要修改对写入命令的调用:
Files.write(pathFile, Arrays.asList("hola"), StandardOpenOption.APPEND);
调用中高亮的部分负责确定在文件末尾添加什么文本,而不是删除所有内容并从头开始写入。以下示例简单地追加文本到一个现有文件:
Example07.java
8 public class Example07 {
9 public static void main(String[] args) {
10 String pathString = System.getProperty("user.home") + "/javaTemp/temp.txt";
11 Path pathFile = Paths.get(pathString);
12 String text = "Hola,\nme da un refresco,\npor favor?";
13
14 if(Files.exists(pathFile))
15 try {
16 Files.write(pathFile, Arrays.asList(text), StandardOpenOption.APPEND);
17 System.out.println("Text added to the file: " + pathFile);
18 } catch (IOException ioe) {
19 System.out.println("EXCEPTION: " + ioe.getMessage());
20 }
21 }
https://packt.live/2MrBV4B
这个程序将整个句子追加到了示例文本文件中。文件最终的读取内容如下:
hola
Hola,
me da un refresco,
por favor?
这是在西班牙语中点一杯苏打水的说法。在下一节中,我们将探讨如何读取我们刚刚创建的文件。
活动一:将目录结构写入文件
这个活动的目标是编写一个应用程序,该程序将读取目录结构,从存储在变量中的目录开始。结果将写入一个文本文件,这样,对于每个嵌套级别,你将包括一个制表符或四个空格来从其父目录中视觉上缩进嵌套文件夹。此外,你还需要只显示文件夹的名称,而不是其完整路径。换句话说,文件的内容应该对应以下结构:
Directory structure for folder: /folderA/folderB/.../folderN
folderN
folderN1
folderN11
folderN12
...
folderN2
folderN21
folderN22
...
folderN3
folderN31
folderN32
...
...
folderNN
-
你将要创建的程序需要将目录的深度作为参数,但我们建议你不要过于深入——最多 10 层是合适的:
Files.walkFileTree(path, Collections.emptySet(), 10, new SimpleFileVisitor<Path>() ... -
当处理获取到的目录路径时,你需要使用/符号作为分隔符来分割结果字符串,然后取最后一个项目。此外,你还需要根据深度打印缩进的数量,这需要有一些代码可以估计给定初始路径的当前深度。解决这些问题的技巧可能是使
preVisitDirectory()的内容如下:// get the path to the init directory String [] pathArray = path.toString().split("/"); int depthInit = pathArray.length; // get the path to the current folder String [] fileArray = dir.toString().split("/"); int depthCurrent = fileArray.length; // write the indents for (int i = depthInit; i < depthCurrent; i++) { System.out.print(" "); // HINT: copy to list or write to file here } // write the directory name System.out.println(fileArray[fileArray.length – 1]); // HINT: copy to list or write to file here注意
这个活动的解决方案可以在第 552 页找到。
读取现有文件
以简单的方式读取文件。问题是关于你将数据存储在哪里。我们将使用列表,遍历列表,然后将结果打印到 System.out。下一个示例使用 readAllLines() 打开现有文件,并将内容读取到计算机内存中,放入 fileContent 列表中。之后,我们使用迭代器遍历每一行并将它们发送到终端:
import java.io.IOException;
import java.nio.file.*;
import java.util.List;
public class Example08 {
public static void main(String[] args) {
String pathString = System.getProperty("user.home") + "/javaTemp/temp.txt";
Path pathFile = Paths.get(pathString);
try {
List<String> fileContent = Files.readAllLines(pathFile);
// this will go through the buffer containing the whole file
// and print it line by one to System.out
for (String content:fileContent){
System.out.println(content);
}
} catch (IOException ioe) {
System.out.println("WARNING: there was an issue with the file");
}
}
}
temp.txt 文件是我们之前保存消息的地方;因此,结果如下:
hola
Hola,
me da un refresco,
por favor?
Process finished with exit code 0
如果文件不存在(你可能在之前的练习中删除了它),你将得到以下结果:
WARNING: there was an issue with the file
Process finished with exit code 0
另一种达到相同结果但避免使用列表并使用流的方法如下:
import java.io.IOException;
import java.nio.file.*;
public class Example09 {
public static void main(String[] args) {
String pathString = System.getProperty("user.home") + "/javaTemp/temp.txt";
Path pathFile = Paths.get(pathString);
try {
Files.lines(pathFile).forEach(System.out::println);
} catch (IOException ioe) {
System.out.println("WARNING: there was an issue with the file");
}
}
}
读取属性文件
属性文件以标准格式存储键值对(也称为键映射)。此类文件的示例内容如下:
#user information
name=Ramiro
familyName=Rodriguez
userName=ramiroz
age=37
bgColor=#000000
这是一个虚构的用户属性文件的示例。注意,注释是用井号符号标记的。你将使用属性文件来存储应用程序的可配置参数,甚至用于本地化字符串。
让我们尝试读取一个属性文件。你可以在本章前面创建的用户空间中的同一个临时文件夹中创建一个文本文件。命名为 user.properties,并将前面示例的内容写入其中。这遵循了一个使用 java.io 读取并打印属性文件内容的程序示例。鉴于 Java 的工作方式,没有比使用 java.nio 更好的替代方案来完成这项任务。
注意
阅读属性文件的内容并不仅限于获取文件的每一行,还包括解析键值对并能够从中提取数据。
你首先会注意到,读取属性文件需要以流的形式打开一个文件——再次强调,这是我们将在本章后面探讨的概念——使用 FileInputStream。从那里开始,Properties 类包含一个名为 load() 的方法,可以从数据流中提取键值对。为了清理代码列表,我们将代码的加载和打印方面与处理文件打开的部分分开。此外,我们确保所有异常都在主类中处理,以便有一个单一的管理点,这使得代码更易于阅读。
Example10.java
17 public static void main(String[] args) throws IOException {
18 String pathString = System.getProperty("user.home") + "/javaTemp/user.properties";
19
20 FileInputStream fileStream = null;
21 try {
22 fileStream = new FileInputStream(pathString);
23 PrintOutProperties(fileStream);
24 } catch (FileNotFoundException fnfe) {
25 System.out.println("WARNING: could not find the properties file");
26 } catch (IOException ioe) {
27 System.out.println("WARNING: problem processing the properties file");
28 } finally {
29 if (fileStream != null) {
30 fileStream.close();
31 }
32 }
33 }
https://packt.live/2Bry4OK
在本章中,我们还没有讨论的一个方面是,一旦你完成与流的工作,就必须关闭流。这意味着关闭后它们将无法用于进一步的数据处理。这一步骤对于避免运行时任何类型的 JVM 内存问题非常重要。因此,示例代码在加载属性文件后调用 fileStream.close()。如果你记得 第五章 中的 良好实践 部分,异常 部分提到你应该在 finally 语句中关闭流。这也是为什么这个程序必须在主方法中抛出 IOException 的原因。如果你想要以干净的方式处理这个问题(通过避免嵌套 try-catch 语句或在主方法中使用 throws IOException),你可以将整个 try 块包裹在一个方法中,然后从主方法中调用该方法,在那里你可以捕获 IOException。查看即将到来的练习,看看这是如何完成的。
上述示例的输出如下:
name: Ramiro
family name: Rodriguez
nick: ramiroz
age: 37
background color: #000000
Process finished with exit code 0
Properties 类中包含一些有趣的方法供你探索。例如,properties.keys() 将返回文件中所有键的枚举,在我们的例子中是 name、familyName、userName 等等。这个特定方法由于与 Hashtable 类的关系而被 Properties 类继承。建议你阅读该类的 API 文档,以发现你可以使用的其他有趣方法。
当涉及到属性文件的位置时,它们可以存储在类路径中,有时甚至存储在实际的 JAR 文件中,这为带有属性文件的应用程序的紧凑式分发提供了一种方式。
接下来要探索的另一个方面是如何以编程方式创建自己的属性文件。让我们通过一个逐步练习来探讨这个主题。
练习 2:从 CLI 创建属性文件
在这个练习中,你将制作一个能够从 CLI 输入创建属性文件(或修改现有文件)的应用程序。你将通过将属性文件的名称和键值对作为参数传递给程序来实现这一点。这将使你能够轻松地创建任何类型的属性文件。应用程序预期调用的示例如下:
usr@localhost:~/[...]/Exercise02$ java Exercise02 myProperties.properties name=Petra
这样一个程序的操作过程很简单。首先,你需要检查文件是否存在。如果存在,则加载属性。然后,添加新的属性或使用作为参数传递的数据修改现有的属性。稍后,将信息写入文件,并向用户反馈最终发送到文件的内容。这样,用户将能够看到他们所做的修改正在生效,而无需打开文件。
让我们一步步看看如何制作这样的程序:
-
打开 IntelliJ 并创建一个名为
Exercise02的新 Java CLI 项目。 -
首先,我们需要检查在 CLI 中定义的属性文件是否已经存在。我们将要实现的程序将检查文件是否存在。如果是这样,它将打开它并加载现有的属性。CLI 中的其余参数将用于修改现有的键值对或添加新的键值对。为了查看属性文件是否存在并加载它,我们需要执行以下操作:
if (Files.exists(pathFile)) { properties = LoadProperties(pathString); } -
加载属性是通过重用示例 10中的代码来完成的,但将其包装在我们在上一步中调用的
LoadProperties()方法中。让我们实现它以返回Properties类的对象(注意我们为了确保在可能发生的异常后关闭流而实现的finally语句。我们必须将流初始化为 null):public static Properties LoadProperties (String pathString) throws IOException { Properties properties = new Properties(); FileInputStream fileInputStream = null; try { fileInputStream = new FileInputStream(pathString); properties.load(fileInputStream); } catch (FileNotFoundException fnfe) { System.out.println("WARNING: could not find the properties file"); } catch (IOException ioe) { System.out.println("WARNING: problem processing the properties file"); } finally { if (fileInputStream != null) { fileInputStream.close(); } } return properties; } -
如果文件不存在,在稍后调用
store()方法时将创建它——在这个阶段不需要创建一个空文件。 -
接下来,我们需要从
arg[]数组中读取 CLI 上的剩余参数,并将它们逐个推入属性对象中。属性对象从Hashtable类继承其行为,该类处理键值对。将使用setProperty()方法来修改现有属性或写入新属性。由于参数以 key=value 的字符串格式表达,我们可以使用split()来分隔需要传递给setProperty()的参数:for (int i = 1; i < args.length; i++) { String [] keyValue = args[i].split("="); properties.setProperty(keyValue[0], keyValue[1]); } -
我们将要写入文件,但不是使用输入数据的流,而是使用输出数据的流。它的名字很容易推断,
FileOutputStream。该类的变量声明如下:FileOutputStream fileOutputStream = new FileOutputStream(pathString); -
要向属性文件添加一些注释,我们只需向
store()方法添加一个参数。在这种情况下,只是为了添加一些上下文信息,让我们通过调用以下操作添加时间戳:java.time.LocalDate.now() -
我们调用
store()方法,该方法将属性发送到文件中。我们将覆盖之前文件中的任何内容。这个调用使用输出Stream和我们所选择的任何注释作为参数:properties.store(fileOutputStream, "# modified on: " + java.time.LocalDate.now()); -
为了提高程序的可用性,创建一个方法来遍历整个属性集并打印出来。这样,用户就可以看到他们是否正确地写下了内容:
public static void PrintOutProperties(Properties properties) { Enumeration keys = properties.keys(); for (int i = 0; i < properties.size(); i++) { String key = keys.nextElement().toString(); System.out.println( key + ": " + properties.getProperty(key) ); } } -
使用 CLI 中的以下调用运行代码,例如。在这种情况下,我们故意修改了本章中一直在工作的文件。程序将打印出修改后的集合。请注意,键值对没有明确的顺序:
[...]/Exercise02$ java Exercise02 user.properties name=Pedro age: 37 familyName: Rodriguez name: Pedro bgColor: #000000 userName: ramiroz -
在文本编辑器中打开生成的文件,查看您的更改是否生效。同时请注意,注释以及
store()方法添加的\符号,以避免将颜色参数(使用井号符号以十六进制格式表达)误认为是注释。 -
现在,你可以考虑对程序进行其他修改,使其能够清除现有文件、追加多个文件等。你可以使用不同的命令作为参数来完成这项工作。完整的练习代码可在 GitHub 上找到:
packt.live/2JjUHZL
什么是流?
Java 中的流是字节序列,最终通过扩展也可以是对象。你可以将流理解为两个地方之间的数据流动。创建流类型的变量就像打开一个窥视孔,查看两个容器之间运送水的管道,并看到水通过。我们试图表达的是,流内部的数据始终在变化。
正如我们之前所看到的,在本章中,我们有两种不同的方式来看待事物:一种是通过java.io API 的视角,另一种是通过java.nio API 的视角。虽然后者在更抽象的层面上工作,因此更容易理解,但前者非常强大且底层。继续使用水的类比,java.io将允许你看到水滴,而java.nio则只允许你一次玩一个 1 升瓶子的水。每个都有其优点。
java.io中的流可以细化到字节级别。例如,如果我们查看来自计算机麦克风输入的音频数据流,我们会看到代表声音的不同字节,一个接一个。另一个 API,java.nio是面向缓冲区的,而不是面向流的。虽然这是真的,但有一种在java.nio中处理流的方法。由于其简单性,在本节中,我们将看到一个与java.nio相关的示例,而在下一节中,我们将使用最适合处理它们的 API 来处理流:java.io。
java.nio中的流是对象的序列(不是任意无序的数据)。由于这些对象属于特定的类,流提供了直接将对象对应的方法应用于流的可能性。将方法应用于流的结果是另一个流,这意味着方法可以被管道化。
在本章中,我们已经看到了不同的流,主要是因为流在 Java 中扮演着如此重要的角色,以至于几乎不可能在不使用它们的情况下进行任何类型的文件相关示例。现在,你将更深入地了解它们是如何工作的。这将帮助你理解一些可能之前并不那么清晰的方面。
流的本质通常很难一开始就理解。正如之前提到的,它们不是普通的数据结构。信息以对象的形式排列。输入来自Arrays、程序中的 I/O 通道或Collections。我们可以在流上执行的操作类型如下:
-
map(中间操作):这将允许你将对象映射到一个你可以作为参数提供的谓词。 -
filter(中间操作):这个操作用于从整个流中排除某些元素。 -
sorted(中间操作):这将排序流。 -
collect(终端操作):这将把不同操作的结果放入一个对象形式,例如,一个列表。 -
forEach(终端操作):这将遍历流中的所有对象。 -
reduce(终端操作):这个操作对流进行操作以得到一个单一值。
我们已经用中间或终端来标记每个操作。前者意味着将要执行的操作将产生另一个流作为结果,因此可以在其后链式调用另一个操作。后者意味着在该操作完成后,不能执行进一步的操作。
到目前为止,你已经在这个章节中看到了一些这些操作的实际应用。你可以回到那些操作出现的地方,重新审视它们。这将使 filter()、collect() 和 forEach() 的作用更加清晰。让我们看看其他三个操作的实际应用:
import java.io.IOException;
import java.nio.file.*;
import java.util.*;
public class Example11 {
public static void main(String[] args) {
String pathString = System.getProperty("user.home") + "/javaTemp/numbers.txt";
Path pathFile = Paths.get(pathString);
// if the numbers file doesn't exist, create a file with 10 random numbers
// between 0 and 10, so that we can make something with them
if (Files.notExists(pathFile)) {
int [] numbers = new int[10];
for (int i = 0; i < 10; i++) {
numbers[i] = (int) (Math.random() * 10);
}
Example11.java 的完整代码可以在 Chapter 1/Code.java 中找到。
这个例子分为两部分。程序的前半部分检查我们一直在使用的 javaTemp 文件夹中是否存在名为 numbers.txt 的文件。如果这个文件不存在,程序将使用 Files.createFile(pathFile) 创建它,然后用之前存储在名为 numbers 的 int 数组中的 10 个随机数字填充它。调用 Files.write(pathFile, Arrays.asList("" + n), StandardOpenOption.APPEND) 负责将数组中的每个数字作为单独的行添加到文件中。生成的文件将如下所示:
<contents of javaTemp/numbers.txt>
5
3
1
3
6
2
6
2
7
8
每行一个数字的想法是,我们可以然后以列表的形式读取文件,将列表转换成流,然后开始进行不同的操作。最简单的操作是调用 fileContent.forEach(System.out::print),这将把原始数据作为输出打印出来:
Raw data
5313626278
在应用其他操作,例如 sorted() 之前,我们需要将数据转换成一个流,这可以通过 stream() 方法实现。以下是具体操作:
fileContent.stream().sorted().forEach(System.out::print)
这个操作的结果将会被排序。相等的值将并排显示,重复:
Sorted data
1223356678
使用 map(),我们将能够处理数据并对它执行不同的操作。例如,在这里,我们将它乘以 2 并打印到终端:
fileContent.stream().map( x -> Integer.parseInt(x)*2).forEach(System.out::print):
结果如下:
Mapped data
106261241241416
最后,有不同类型的终止操作可以使用。为此,我们将使用直到更后面的章节才会介绍到的 lambda 表达式。然而,以下内容足够简单,不需要进一步解释。为了计算所有数字的总和,我们需要执行以下操作:
System.out.println(
fileContent
.stream()
.map(x -> Integer.parseInt(x))
.reduce(Integer::sum));
以下为结果:
Sum of data
Optional[43]
注意,在读取文件时,我们将其读取为String的List,因此数字被存储为字符串。这意味着,为了将它们作为数字操作,我们需要将它们转换回整数,这通过调用Integer.parseInt(x)来完成。
Java 语言的流的不同类型
要讨论流类型,我们需要退一步,从java.nio转向java.io。这个 API 是支持流最好的 API。根据情况,流可以进入程序或从程序中出来。这为我们提供了两个主要的流接口:InputStream和OutputStream。
在这两个主要类别中,有四种从它们处理的数据类型的角度来看待流的方法:File、ByteArray、Filter或Object。换句话说,有一个FileInputStream类、一个FileOutputStream类、一个ByteArrayInputStream类等等。
根据 Javadocs,重要的是要理解流有一个层次结构。所有流都是建立在字节流之上的。但我们应尽可能使用与我们所使用的数据类型在层次结构中最接近的流类型。例如,如果我们需要处理来自互联网的一系列图像,我们应该避免在低级别使用字节流来存储图像,而应使用对象流。
注意
在官方 Java 文档中了解更多关于流的信息,请访问docs.oracle.com/javase/tutorial/essential/io/bytestreams.html。
那么如何使用 java.io 和FileInputStream打开并打印文件呢?我们在处理属性文件时已经看到了一些这方面的内容。让我们来看一个最低级别的例子,它将读取一个文件并逐字节打印其内容:
import java.io.FileInputStream;
import java.io.IOException;
public class Example12 {
public static void main(String[] args) throws IOException {
FileInputStream inStream = null;
try {
inStream = new FileInputStream(
System.getProperty("user.home") + "/javaTemp/temp.txt");
int c;
while ((c = inStream.read()) != -1) {
System.out.print(c);
}
} finally {
if (inStream != null) {
inStream.close();
}
}
}
}
此示例打开我们在本章中创建的 temp.txt 文件,并打印其内容。请记住,它包含了一些简单的文本,如hola\nHola,\nme da un ...。当查看终端时,你将读到如下内容:
1041111089710721111089744101091013210097321171103211410110211410111599111441011211111432102971181111146310
Process finished with exit code 0
你可能会想知道——文本怎么了?正如你所知,英语字母表中的每个符号都由一个称为 ASCII 的标准来表示。这个标准用数字表示每个符号。它区分了大写和小写,不同的符号,如感叹号或井号,数字等等。以下是一个表示小写符号的 ASCII 表的摘录:
97 a 107 k 117 u
98 b 108 l 118 v
99 c 109 m 119 w
100 d 110 n 120 x
101 e 111 o 121 y
102 f 112 p 122 z
103 g 113 q
104 h 114 r
105 I 115 s
106 j 116 t
如果你开始使用你得到的数字流,并使用 ASCII 符号表进行解析,你会发现104对应于h,111对应于o,108对应于l,97对应于a。如果你有一个完整的 ASCII 表(包括大写字母、符号和数字),你将能够解码整个消息。我们确实得到了文件的内容,但我们没有在程序中解释我们得到的数据,这使得输出不可读。这就是为什么你应该尝试使用更高层次的流,这样你就不必在如此低级别解码信息,对于字符——就像这个例子一样——这不是什么大问题。但软件实体之间的数据传输可以很快变得复杂。
让我们考察另一种执行相同操作(打开文件)的方法,但使用不同类型的流。在这种情况下,我们将在FileInputStream之上使用FileReader,这是一种不同类型的流。为了以字符的形式获取流并将其传递给BufferedReader,这是一个可以读取文本完整行的流类。由于我们知道我们的文件包含按行排列的文本,这可能是以整洁方式查看文件内容的最优方法:
Example13.java
5 public class Example13 {
6 public static void main(String[] args) throws IOException {
7 BufferedReader inStream = null;
8
9 try {
10 FileReader fileReader = new FileReader(
11 System.getProperty("user.home") + "/javaTemp/temp.txt");
12 inStream = new BufferedReader(fileReader);
13 String line;
14 while ((line = inStream.readLine()) != null) {
15 System.out.println(line);
16 }
https://packt.live/2BsKIgh
这个示例的输出将是我们最初期望看到的结果:
hola
Hola,
me da un refresco,
por favor?
Process finished with exit code 0
简而言之,信息是相同的,但关键在于我们如何看待它。使用流家族中的高级类将为我们提供更好的方法来以不同但更实用的方式处理相同的信息。
还有另一个我们尚未介绍的概念,那就是缓冲流和非缓冲流之间的区别。在用 java.io 进行低级工作时,你很可能会以非缓冲的方式工作。这意味着你将从代码中直接与操作系统交互。这些交换计算密集,尤其是在与在 JVM 内部加载任何信息到缓冲区并直接在那里操作相比(这并不意味着它不会直接访问操作系统——它会,但它将优化其使用)。
这个示例明显使用了BufferedReader,这与前一个示例不同。我们在本章前面提到了java.nio如何与缓冲区一起工作——这意味着,与java.io不同,它不提供直接调用操作系统的可能性。从某种意义上说,这更好,因为它更不容易出错。如果你有一个构建良好的 API,其中包含了执行你想要执行的所有操作所需的所有方法,你应该避免使用其他不太理想的工具。
什么是套接字?
套接字是两个在网络上运行的程序之间的双向通信通道的端点。这就像一条虚拟电缆连接了这两个程序,提供了来回发送数据的能力。Java 的 API 有类可以轻松构建通信两端的程序。例如,互联网上的交换发生在 TCP/IP 网络上,我们区分参与通信的角色。有服务器和客户端。前者可以使用 ServerSocket 类实现,而后者可以使用套接字类。
通信过程的工作方式涉及双方。客户端将向服务器发送请求,请求建立连接。这是通过计算机上可用的一个 TCP/IP 端口完成的。如果连接被接受,两端都会打开套接字。服务器和客户端的端点将是唯一可识别的。这意味着您可以使用该端口进行多个连接。
了解如何处理套接字,以及与流一起使用,将使您能够直接从互联网处理信息,这将使您的程序达到新的水平。在接下来的章节中,我们将看到如何实现客户端和服务器以原型化这种通信。
注意
在使用这些示例进行工作时,请确保您的计算机安全系统(防火墙等)允许通过您决定使用的任何端口进行通信。这种情况并非第一次发生,有人浪费了几个小时以为自己的代码有误,而问题实际上出在其他地方。
创建 SocketServer
从套接字中读取数据需要现有网络资源的一点点参与。如果您想要一个连接到服务器的程序,您在尝试连接之前需要知道一个服务器。在互联网上,有提供连接可能性的服务器,可以打开套接字,发送数据,并接收回数据。这些服务器被称为 EchoServer——这个名字几乎不会让人对其功能产生怀疑。
另一方面,您可以自己实现服务器并确保安全。Oracle 提供了一个简单的 EchoServer 示例供您测试。这将是一个新的挑战,因为您需要在计算机上同时运行两个程序:EchoServer 和您将实现的任何客户端。
让我们从实现 EchoServer 开始,您可以从packt.live/33LmH0k获取。您要分析的代码包含在下一个示例中。请注意,我们已经删除了开头的免责声明和代码注释,以保持其简洁:
Example14.java
14 try (
15 ServerSocket serverSocket =
16 new ServerSocket(Integer.parseInt(args[0]));
17 Socket clientSocket = serverSocket.accept();
18 PrintWriter out =
19 new PrintWriter(clientSocket.getOutputStream(), true);
20 BufferedReader in = new BufferedReader(
21 new InputStreamReader(clientSocket.getInputStream()));
22 ) {
23 String inputLine;
24 while ((inputLine = in.readLine()) != null) {
25 out.println(inputLine);
26 }
27 } catch (IOException e) {
28 System.out.println("Exception caught when trying to listen on port "
29 + portNumber + " or listening for a connection");
30 System.out.println(e.getMessage());
31 }
https://packt.live/2oLURSR
代码的第一部分检查您是否为服务器选择了一个监听端口。这个端口号作为 CLI 上的参数给出:
if (args.length != 1) {
System.err.println("Usage: java EchoServer <port number>");
System.exit(1);
}
如果没有选择端口号,此程序将简单地退出。记住,正如我们之前提到的,确保您使用的任何端口号都没有被计算机的防火墙阻止。
调用 ServerSocket(Integer.parseInt(args[0])) 将启动 ServerSocket 类的对象,配置在参数中定义的端口号,以便调用程序作为监听程序。稍后,serverSocket.accept() 将阻塞服务器,使其等待连接的到来。一旦到来,它将自动接受。
在这个示例的初始代码中,有两个不同的流:BufferedReader in 用于输入,PrintWriter out 用于输出。一旦建立连接,in 将获取数据,而 out 将发送它——无需任何进一步处理——回套接字。服务器程序将一直运行,直到在终端上按下 Ctrl+C 强制退出。
要启动服务器,您需要使用构建图标(锤子)编译它,并在终端中使用特定的端口号调用它。尝试端口号 8080,因为这个端口号通常用于我们即将进行的实验:
usr@localhost:~/IdeaProjects/[...]/Example14$ java Example14 8080
如果一切按计划进行,程序将开始运行,不会打印任何消息。它只是在那里等待建立连接。
注意
请记住,默认情况下,您的计算机始终具有 IP 地址 127.0.0.1,这允许您确定计算机在网络中的 IP 地址。我们将使用此地址与客户端建立连接。
在套接字上写入数据和从套接字读取数据
当我们的服务器在后台运行时,我们需要生成一个简单的程序来打开套接字并向服务器发送一些内容。为此,您需要在 IDE 中创建一个新的项目,但在一个单独的窗口中。记住,您的服务器目前正在运行!
您可以生成的最简单的客户端是 Oracle 的 EchoServer 伴侣。出于明显的原因,它被称为 EchoClient,您可以在 packt.live/2PbLNBx 找到它。
Example15.java
15 try (
16 Socket echoSocket = new Socket(hostName, portNumber);
17 PrintWriter out =18 new PrintWriter(echoSocket.getOutputStream(), true);
19 BufferedReader in =20 new BufferedReader(
21 new InputStreamReader(echoSocket.getInputStream()));
22 BufferedReader stdIn =23 new BufferedReader(
24 new InputStreamReader(System.in))
25 ) {
26 String userInput;
27 while ((userInput = stdIn.readLine()) != null) {
28 out.println(userInput);
29 System.out.println("echo: " + in.readLine());
30 }
https://packt.live/33OrP3t
注意,在这种情况下,我们不是创建一个 SocketServer 对象,而是创建一个 Socket 对象。这个第二个程序介绍了使用系统流之一来捕获数据并发送到套接字:System.in。这个程序将一直运行,直到 System.in 中的输入为 null。这是通过直接与 System.in 交互无法真正实现的事情,因为我们只是按键盘上的键。因此,您需要按 Ctrl + C 来停止客户端,就像服务器的情况一样。
注意发送数据到服务器是通过out.println()完成的,其中out是一个PrinterWriter对象,一个流,它是基于Socket构建的。另一方面,为了读取传入的Socket,我们实现了一个名为in的BufferedReader对象。由于它是缓冲的,我们可以随时轮询该对象。对out.readLine()和in.readLine()的调用是阻塞的。它不会停止从System.in或从套接字读取,直到行尾到达。
这使得这个读者是同步的,因为它等待用户输入,发送数据,最后等待从套接字获取答案。
注意
每个操作系统都向 JVM 提供了三个不同的系统流:System.in、System.out 和 System.err。由于它们是流,您可以使用 Stream 类的全部功能从它们读取数据,将它们放入缓冲区,解析它们等。
要启动客户端,您需要使用构建图标(锤子图标)编译它,并通过特定的 IP 地址和端口号从终端调用它。尝试使用 IP 地址 127.0.0.1 和端口号 8080。记住,在启动客户端之前,您需要先启动服务器:
usr@localhost:~/IdeaProjects/[...]/Example14$ java Example15 127.0.0.1 8080
从那时起,直到您发出 Ctrl + C 命令,只要服务器保持连接,您就可以在终端上输入任何内容,当您按下 Enter 键时,它将被发送到服务器,并从服务器返回。到达后,客户端会在它前面添加消息 echo 将其写入终端。我们通过使来自服务器的响应字体加粗来突出显示:
Hello
echo: Hello
此外,当强制客户端退出时,它也会强制服务器退出。
活动二:改进 EchoServer 和 EchoClient 程序
在这个活动中,您需要对最后两个部分中的程序进行改进。首先,您需要向服务器上传输的数据中添加一些文本。这将使用户更容易理解数据是从服务器发送回来的。让我们将其制作成一个计数器,它将充当交换的唯一 ID。这样,服务器的回答将显示在消息中添加的数字:
Hello
echo: 37-Hello
另一方面,您应该在客户端添加一个命令,该命令将发送一个终止信号到服务器。这个命令将退出服务器,然后退出客户端。要终止任何程序,您可以在向终端发送消息告知用户程序即将结束时调用System.exit()。作为一个终止命令,您可以创建一个简单的消息,其中包含单词“bye”。
-
预期结果将需要您以非常相似的方式修改服务器和客户端。在客户端,您需要做如下操作:
while ((userInput = stdIn.readLine()) != null) { out.println(userInput); if (userInput.substring(0,3).equals("bye")) { System.out.println("Bye bye!"); System.exit(0); } System.out.println("echo: " + in.readLine()); } -
在服务器上,修改应该如下所示:
int contID = 0; while ((inputLine = in.readLine()) != null) { contID++; out.println(contID + "-" + inputLine); if (inputLine.substring(0,3).equals("bye")) { System.out.println("Bye bye!"); System.exit(0); } }服务器和客户端之间的预期交互应该如下所示:
![图 8.1:客户端与服务器之间的交互。
![img/C13927_08_01.jpg]
图 8.1:客户端与服务器之间的交互。
注意
这个活动的解决方案可以在第 555 页找到。
阻塞和非阻塞调用
这是本章一直在讨论的话题,但我们没有直接解决它。java.io的读写操作是阻塞的。这意味着程序将等待直到数据完全读取或数据已经完全写入。然而,使用java.nio中实现的缓冲流可以让你检查数据是否准备好读取。在写入数据时,java.nio会将数据复制到缓冲区,并让 API 自己将数据写入通道。这允许一种完全不同的编程风格,我们不需要等待操作发生。同时,这也意味着我们将不会对通信有低级控制。JVM 的另一个部分会为我们执行这个动作。
摘要
在本章中,你已经了解了 Java 语言中的两个主要 API:java.io 和 java.nio。它们有一些重叠的功能,并且它们被用来处理流和文件。除此之外,你还看到了如何使用流来处理套接字,套接字是数据的一个自然来源,只能通过流来处理。
已经有一系列示例展示了如何从终端捕获数据,最终发现是stream (System.in)。然后你探索了如何使用各种高级函数,如 filter、map、sorted、foreach、reduce 和 collect 来处理它。你已经看到了如何打开文件和属性文件,以及 java.nio 在前者上非常强大,但在后者上则不然。
从更实际的角度来看,本章介绍了一种在早期章节中仅从理论上解释的重要技术:如何使用finally来关闭流,并在运行时避免潜在的内存问题。你已经看到,为了干净地处理异常,你可能不得不将代码块移动到方法中。这样,你可以避免抛出异常,并且总是可以通过 try-catch 语句来处理它们。
为了与套接字进行实验,你已经尝试构建了一个 EchoServer 和一个 EchoClient。你有两个不同的程序相互交互,并通过互联网发送数据。你已经看到了如何在你的电脑上运行服务器和客户端,现在是时候尝试在不同的电脑上运行这两个程序了。
最后,本章中介绍的两个活动通过在程序中键入键值对作为参数来动态创建或修改属性文件,以及通过互联网上的命令远程控制另一个程序。
在下一章中,你将学习关于 HTTP 以及如何创建一个连接到特定 Web 服务器并下载数据的程序。
第九章:9. 使用 HTTP
概述
在本章中,我们将探讨 HTTP 的基础知识,并创建一个程序来连接到特定的 Web 服务器并下载数据。我们将从研究 HTTP 请求方法开始,这样你就可以开始练习使用 Java 的 HttpUrlConnection 类自行发送请求。然后,你将学习使用 GET 和 HEAD 请求检索数据,以及使用 POST 请求发送 JSON 格式的数据。在本章的末尾,你将学习如何使用开源的 jsoup 库提取和解析 HTML 内容,并探索 Java 11 提供的 java.net.http 模块——这是一个新的 HTTP 类,它支持同步和异步 HTTP 请求。
简介
超文本传输协议(HTTP)是 万维网(WWW)的基础。使用 HTTP 的 请求-响应协议,客户端(如网页浏览器)可以从服务器请求数据。在万维网中,网页浏览器请求内容(HTML、JavaScript、图像等),然后显示结果。在许多情况下,返回的内容相对静态。
Java 应用程序通常有所不同。在大多数情况下,使用 Java,你将向专门的后端 Web 服务发送请求以收集数据或更新系统。然而,在这两种情况下,Java 编码保持不变。
本章介绍了如何在 Java 应用程序中发送 HTTP 请求并解析响应数据。
探索 HTTP
使用 HTTP,客户端应用程序向服务器发送一个特殊格式的请求,然后等待响应。技术上讲,HTTP 是一种无状态协议。这意味着服务器不需要维护与客户端相关的任何状态信息。每个客户端请求都可以作为一个新的操作单独处理。服务器不需要存储特定于客户端的信息。
许多服务器确实在多个请求之间维护某种状态,例如,当你在线购物时,服务器需要存储你选择的产品;然而,基本协议并不要求这样做。
HTTP 是一种文本协议(允许压缩)。HTTP 请求包括以下部分:
-
一个操作(称为 请求方法),操作的资源标识符,以及一行上的可选参数。
-
请求头;每行一个。
-
一行空行。
-
一个可选的消息主体。
-
每行以两个字符结束:一个回车符和一个换行符。
HTTP 使用以下两个主要概念来识别你感兴趣的资源:
-
统一资源标识符(URI)格式的资源标识符用于标识服务器上的资源。
-
统一资源定位符(URL)包括一个 URI,以及网络协议信息、服务器和端口号。URL 是你在网页浏览器的地址栏中输入的内容。
Java 包含了这两个概念的相关类:java.net.URI 和 java.net.URL。
例如,考虑以下 URL:http://example.com:80/。
在这个例子中,http 标识了协议。服务器是 example.com,端口号是 80(默认的 HTTP 端口号)。尾随的字符标识了服务器上的资源,在这种情况下,是顶级或根资源。
大多数现代网站都使用 HTTPS 协议。这是一个更安全的 HTTP 版本,因为发送到和从服务器发送的数据都是加密的。
例如,考虑以下 URL:www.packtpub.com/。
在这个情况下,协议是 https,服务器是 www.packtpub.com。端口号默认为 443(HTTPS 的默认端口号)。和之前一样,尾随的字符标识了服务器上的资源。
一个 URI 可以有完整的网络位置,也可以相对于服务器。以下都是有效的 URIs:
-
https://www.packtpub.com/tech/java -
http://www.example.com/docs/java.html -
/tech/java -
/docs/java.html -
file:///java.html
URL 是一个较旧的术语,通常表示互联网上资源的完整规范。话虽如此,像 URIs 一样,你也可以有相对 URL,例如 java.html。在大多数情况下,人们谈论 URIs。
然而,通常情况下,你的 Java 应用程序将使用 URL 类来建立 HTTP 连接。
注意
你可以在 packt.live/32ATULO 上了解更多关于 URIs 的信息,以及 packt.live/2JjIgNN 上的 URLs。
HTTP 请求方法
每个 HTTP 请求都以一个请求方法开始,例如 GET。这些方法名称来自万维网的早期。以下是一些方法:
-
GET:这从服务器检索数据。 -
HEAD:这类似于 GET 请求,但只检索头部信息,不包含响应体。 -
POST:这向服务器发送数据。大多数网页上的 HTML 表单将你填写的数据作为 POST 请求发送。 -
PUT:这也向服务器发送数据。PUT 请求通常用于修改资源,替换现有资源的内容。 -
DELETE:这请求服务器删除指定的资源。 -
TRACE:这个方法会回显服务器接收到的请求数据。这可以用于调试。 -
OPTIONS:这列出了服务器为给定 URL 支持的 HTTP 方法。注意
还有其他 HTTP 方法,值得注意的是
CONNECT和PATCH。本章中描述的HttpUrlConnection类只支持这里列出的方法。
表示状态传输
表示状态传输(REST)是一个术语,用来描述使用 HTTP 作为传输协议的 Web 服务。你可以将其视为带有对象的 HTTP。例如,在 RESTful Web 服务中,一个 GET 请求通常返回一个对象,格式化为 JavaScript 对象表示法(JSON)。JSON 提供了一种将对象编码为文本的方式,这种方式与使用的编程语言无关。JSON 使用 JavaScript 语法以名称-值对或数组的形式格式化数据:
{
"animal": "dog",
"name": "biff"
}
在这个例子中,对象有两个属性:animal和name。
注意
许多 RESTful 网络服务以 JSON 格式发送和接收数据。您可以参考第十九章,反射,以获取有关 JSON 的更多信息。
在网络服务中,通常使用POST请求来创建一个新的对象,使用PUT请求来修改现有的对象(通过用新数据替换它),而使用DELETE请求来删除一个对象。
注意
一些框架对POST和PUT操作有不同的含义。这里采用的方法是 Spring Boot 框架所使用的方法。
您会发现 Java 被大量用于创建 RESTful 网络服务以及网络服务客户端。
注意
您可以在packt.live/2MxDcHz上阅读 HTTP 规范或阅读 HTTP 的概述packt.live/35MM1od。有关 RESTful 网络服务的更多信息,请参阅packt.live/2MYvHbq。有关 JSON 的更多信息,请参阅packt.live/2P2Qz3W。
请求头部
请求头部是一个提供一些信息的名称-值对。例如,User-Agent请求头部标识代表用户运行的应用程序,通常是网络浏览器。几乎所有的User-Agent字符串都以Mozilla/5.0开头,这是由于历史原因,并且因为一些网站如果没有提到现在已经古老的 Mozilla 网络浏览器将无法正确渲染。服务器确实使用User-Agent头部来指导针对特定浏览器的渲染。例如,考虑以下内容:
Mozilla/5.0 (iPhone; CPU iPhone OS 12_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/12.0 Mobile/15E148 Safari/604.1
这个User-Agent设置标识了一个 iPhone 浏览器。Referer头部(由于历史原因拼写错误)标识了您来自的网页。Accept头部列出了您希望的数据格式,例如text/html。Accept-Language头部列出了一个语言代码,例如如果您希望响应为德语(Deutsch),则使用de。
关于请求头部的一个重要观点是,每个头部可以包含多个值(以逗号分隔),尽管在大多数情况下您将提供一个单一的值。
注意
您可以在packt.live/2pFjIaH上查看常用请求头部的列表。
HTTP 响应消息还包含头部信息。这些响应头部可以告诉您的应用程序有关远程资源的信息。
既然我们已经提到了 HTTP 的要点,下一步就是开始进行网络请求。
使用 HttpUrlConnection
java.net.HttpUrlConnection类提供了从 Java 访问 HTTP 资源的主要方式。要建立 HTTP 连接,您可以使用以下代码:
String path = "http://example.com";
URL url = new URL(path);
HttpURLConnection connection = (HttpURLConnection) url.openConnection();
connection.setRequestMethod("HEAD");
此代码设置了一个以链接到 example.com 初始化的 URL。然后 URL 上的openConnection()方法返回HttpUrlConnection。一旦您有了HttpUrlConnection,您就可以设置 HTTP 方法(在这种情况下为HEAD)。您可以从服务器获取数据,上传数据到服务器,并指定请求标题。
使用HttpUrlConnection,您可以调用setRequestProperty()来指定请求标题:
connection.setRequestProperty("User-Agent", "Mozilla/5.0");
每个请求都会生成一个响应,这可能成功或不成功。要检查响应,请获取响应代码:
int responseCode = connection.getResponseCode();
代码 200 表示成功。200 范围内的其他代码也表示成功,但有条件,例如 204,表示成功但没有内容。300 范围内的代码表示重定向。400 范围内的代码指向客户端错误,例如可怕的 404 未找到错误,500 范围内的代码指向服务器错误。
注意
您可以在packt.live/2OP9Rtr查看 HTTP 响应代码列表。这些也在HttpUrlConnection类中定义为常量。
每个响应通常都附带一条消息,例如OK。您可以通过调用getResponseMessage()来检索此消息:
System.out.println( connection.getResponseMessage() );
要查看响应中的标题,请调用getHeaderFields()。此方法返回一个标题映射,其中值是一个字符串列表:
Map<String, List<String>> headers = connection.getHeaderFields();
for (String key : headers.keySet()) {
System.out.println("Key: " + key + " Value: " + headers.get(key));
}
注意
使用 HTTP,每个标题可以具有多个值,这就是为什么映射中的值是一个列表。
您还可以逐个检索标题。下一项练习将所有这些内容结合起来,向您展示如何编写一个简短的 Java 程序来创建一个HTTP HEAD请求。
练习 1:创建 HEAD 请求
此练习将向example.com发送 HEAD 请求,这是一个官方的练习域名,您可以用它来测试:
-
在 IntelliJ 的
文件菜单中选择新建然后选择项目。 -
选择项目类型为
Gradle。点击下一步。 -
对于组 ID,输入
com.packtpub.net。 -
对于工件 ID,输入
chapter09。 -
对于版本,输入
1.0。 -
接受下一页上的默认设置。点击
下一步。 -
将项目名称保留为
chapter09。 -
点击
完成。 -
在 IntelliJ 文本编辑器中调用
build.gradle。 -
将
sourceCompatibility更改为 12:sourceCompatibility = 12 -
在
src/main/java文件夹中,创建一个新的 Java 包。 -
将包名输入为
com.packtpub.http。 -
在
Project窗格中右键单击此包,创建一个名为HeadRequest的新 Java 类。 -
输入以下代码:
HeadRequest.java
1 package com.packtpub.http;
2
3 import java.io.IOException;
4 import java.net.HttpURLConnection;
5 import java.net.MalformedURLException;
6 import java.net.URL;
7 import java.util.List;
8 import java.util.Map;
9
10 public class HeadRequest {
11 public static void main(String[] args) {
12 String path = "http://example.com";
https://packt.live/2MuxxlC
当您运行此程序时,您将看到以下输出:
Code: 200
OK
Accept-Ranges: [bytes]
null: [HTTP/1.1 200 OK]
X-Cache: [HIT]
Server: [ECS (sec/96DC)]
Etag: ["1541025663+gzip"]
Cache-Control: [max-age=604800]
Last-Modified: [Fri, 09 Aug 2013 23:54:35 GMT]
Expires: [Mon, 18 Mar 2019 20:41:30 GMT]
Content-Length: [1270]
Date: [Mon, 11 Mar 2019 20:41:30 GMT]
Content-Type: [text/html; charset=UTF-8]
200代码表示我们的请求成功。然后您可以看到响应标题。输出中的方括号来自 Java 打印列表的默认方式。
您可以自由地将初始 URL 更改为除example.com之外的网站。
使用 GET 请求读取响应数据
使用 GET 请求,您将从连接中获取 InputStream 以查看响应。调用 getInputStream() 获取您请求的资源(URL)服务器发送回来的数据。如果响应代码指示错误,使用 getErrorStream() 获取有关错误的信息,例如一个找不到页面。如果您期望响应中包含文本数据,例如 HTML、文本、XML 等,您可以将 InputStream 包装在 BufferedReader 中:
BufferedReader in = new BufferedReader(
new InputStreamReader(connection.getInputStream())
);
String line;
while ((line = in.readLine()) != null) {
System.out.println(line);
}
in.close();
练习 2:创建 GET 请求
此练习打印出 example.com 的 HTML 内容。如果您愿意,可以更改 URL 并与其他网站进行实验:
-
在 IntelliJ 的项目面板中,右键单击
com.packtpub.http包。选择New,然后Java Class。 -
将
GetRequest作为 Java 类的名称。 -
为
GetRequest.java输入以下代码:GetRequest.java 1 package com.packtpub.http; 2 3 import java.io.BufferedReader; 4 import java.io.IOException; 5 import java.io.InputStreamReader; 6 import java.net.HttpURLConnection; 7 import java.net.MalformedURLException; 8 import java.net.URL; 9 10 public class GetRequest { 11 public static void main(String[] args) { 12 String path = "http://example.com"; https://packt.live/2oLZrjZ -
运行此程序,您将看到
example.com的简要 HTML 内容。
使用此技术,我们可以编写一个程序,使用 GET 请求打印网页内容。
处理慢速连接
HttpUrlConnection 提供了两种方法来帮助处理慢速连接:
connection.setConnectTimeout(6000);
connection.setReadTimeout(6000);
调用 setConnectTimeout() 来调整建立与远程站点的网络连接时的超时时间。您提供的输入值应为毫秒。调用 setReadTimeout() 来调整读取输入流数据时的超时时间。同样,提供新的超时输入值(毫秒)。
请求参数
在许多网络服务中,您在发出请求时必须输入参数。HTTP 参数编码为名称-值对。例如,考虑以下:
String path = "http://example.com?name1=value1&name2=value2";
在这种情况下,name1 是参数的名称,同样 name2 也是。name1 参数的值是 value1,name2 的值是 value2。参数由一个与符号 & 分隔。
注意
如果参数值是简单的字母数字值,您可以像示例中那样输入它们。如果不是,您需要使用 URL 编码对参数数据进行编码。您可以参考 java.net.URLEncoder 类以获取更多详细信息。
处理重定向
在许多情况下,当您向服务器发出 HTTP 请求时,服务器会响应一个状态指示重定向。这告诉您的应用程序资源已移动到新位置,换句话说;您应该使用新的 URL。
HttpUrlConnection 会自动遵循 HTTP 重定向。您可以使用 setInstanceFollowRedirects() 方法将其关闭:
connection.setInstanceFollowRedirects(false);
创建 HTTP POST 请求
POST(和 PUT)请求将数据发送到服务器。对于 POST 请求,您需要打开 HttpUrlConnection 的输出模式并设置内容类型:
connection.setRequestMethod("POST");
connection.setRequestProperty("Content-Type", "application/json");
connection.setDoOutput(true);
接下来,为了上传数据,这里假设是一个字符串,可以使用以下代码:
DataOutputStream out =
new DataOutputStream( connection.getOutputStream() );
out.writeBytes(content);
out.flush();
out.close();
在网络浏览中,大多数 POST 请求发送表单数据。然而,从 Java 程序中,您更有可能使用 POST 和 PUT 请求上传 JSON 或 XML 数据。一旦上传数据,您的程序应该读取响应,特别是查看请求是否成功。
练习 3:使用 POST 请求发送 JSON 数据
在这个练习中,我们将向packt.live/2oyJqxB测试站点发送一个小 JSON 对象。该站点不会对我们的数据进行任何操作,除了将其回显,以及一些关于请求的元数据:
-
在 IntelliJ 的项目视图中,右键单击
com.packtpub.http包。选择New然后Java Class。 -
将
PostJson作为 Java 类的名称。 -
为
PostJson.java输入以下代码:PostJson.java 1 package com.packtpub.http; 2 3 import java.io.BufferedReader; 4 import java.io.DataOutputStream; 5 import java.io.IOException; 6 import java.io.InputStreamReader; 7 import java.net.HttpURLConnection; 8 import java.net.MalformedURLException; 9 import java.net.URL; 10 11 public class PostJson { 12 public static void main(String[] args) { 13 /* 14 { 15 "animal": "dog", 16 "name": "biff" 17 } 18 */ https://packt.live/2MYwuZW -
运行这个程序,你应该会看到以下类似的输出:
Code: 200 { "args": {}, "data": "{ \"animal\": \"dog\", \"name\": \"biff\" }", "files": {}, "form": {}, "headers": { "Accept": "text/html, image/gif, image/jpeg, *; q=.2, */*; q=.2", "Content-Length": "35", "Content-Type": "application/json", "Host": "httpbin.org", "User-Agent": "Java/11.0.2" }, "json": { "animal": "dog", "name": "biff" }, "origin": "46.244.28.23, 46.244.28.23", "url": "https://httpbin.org/post" }注意
Apache
HttpComponents库可以帮助简化你使用 HTTP 的工作。更多信息,你可以参考packt.live/2BqZbtq。
解析 HTML 数据
一个 HTML 文档看起来可能如下所示,但通常包含更多内容:
<!doctype html>
<html lang="en">
<head>
<title>Example Document</title>
</head>
<body>
<p>A man, a plan, a canal. Panama.</p>
</body>
</html>
HTML 将文档结构化为类似树的格式,如本例中通过缩进来显示。<head>元素位于<html>元素内部。<title>元素位于<head>元素内部。一个 HTML 文档可以有多个层次。
注意
大多数网络浏览器都提供了一个查看页面源代码的选项。选择它,你就会看到页面的 HTML。
当你从一个 Java 应用程序运行 GET 请求时,你需要解析返回的 HTML 数据。通常,你将那些数据解析成一个对象树结构。其中一种最方便的方法是使用开源的 jsoup 库。
jsoup 提供了使用 HTTP 连接、下载数据并将这些数据解析成反映页面 HTML 层次结构的元素的方法。
使用 jsoup,第一步是下载一个网页。为此,你可以使用以下代码:
String path = "https://docs.oracle.com/en/java/javase/12/";
Document doc = Jsoup.connect(path).get();
此代码下载了官方 Java 12 文档起始页面,其中包含许多指向特定 Java 文档的链接。解析的 HTML 数据被放置到Document对象中,该对象包含每个 HTML 元素的Element对象。这故意与 Java 的 XML 解析 API 相似,它将 XML 文档解析成一个对象树结构。树中的每个元素可能有子元素。jsoup 提供了一个 API 以类似 Java XML 解析 API 的方式访问这些子元素。
注意
你可以在packt.live/2nZbmua找到关于 jsoup 库的大量有用文档。
在 Java 12 文档页面上,你会看到很多链接。在底层的 HTML 中,许多这些链接如下所示:
<ul class="topics">
<li>
<a href="/en/java/javase/12/docs/api/overview-summary.html">API Documentation </a>
</li>
</ul>
如果我们想要提取 URI 链接(packt.live/2VWd1x7,在这个例子中)以及描述性文本(API 文档),我们需要遍历到LI列表项标签,然后获取 HTML 链接,它被包含在一个A标签中,称为锚点。
jsoup 的一个方便功能是,你可以使用类似于 CSS 和 jQuery JavaScript 库提供的选择器语法从 HTML 中选择元素。
要选择所有具有 topic CSS 类的 UL 元素,你可以使用以下代码:
Elements topics = doc.select("ul.topics");
一旦选择了所需的元素,你可以像下面这样遍历每一个:
for (Element topic : topics) {
for (Element listItem : topic.children()) {
for (Element link : listItem.children()) {
String url = link.attr("href");
String text = link.text();
System.out.println(url + " " + text);
}
}
}
此代码从 UL 级别开始,向下到 UL 标签下的子元素,通常是 LI,即列表项元素。Java 文档页面上的每个 LI 元素都有一个子元素——即一个带有链接的锚标签。
我们可以然后提取链接本身,它存储在 href 属性中。我们还可以提取用于链接的英文描述性文本。
注意
你可以在 packt.live/2o54P1e 和 packt.live/33L4akK 找到更多关于 HTML 的信息。
练习 4:使用 jsoup 从 HTML 中提取数据
这个练习演示了如何使用 jsoup API 从 HTML 文档中提取链接 URI 和描述性文本。将其用作在项目中解析其他 HTML 文档的示例。
在网络浏览器中转到 packt.live/2MO4UOU。你可以看到官方的 Java 文档。
我们将提取页面主要内容下标题如 工具和规范 等部分的链接。
如果你检查规范部分的 API 文档链接,你会看到文档链接位于一个具有 topics CSS 类名的 UL 元素中。如前所述,我们可以使用 jsoup API 找到所有具有该 CSS 类名的 UL 元素:
-
在 IntelliJ 中编辑
build.gradle文件。 -
在依赖项块中添加以下内容:
// jsoup HTML parser from https://jsoup.org/ implementation 'org.jsoup:jsoup:1.11.3' -
选择在添加新依赖项后出现的弹出窗口中的“导入更改”。
-
在 IntelliJ 的项目面板中,右键单击
com.packtpub.http包。选择“新建”然后“Java 类”。 -
将
JavaDocLinks作为 Java 类的名称。 -
为
JavaDocLinks.java输入以下代码:
JavaDocLinks.java
1 package com.packtpub.http;
2
3 import org.jsoup.Jsoup;
4 import org.jsoup.nodes.Document;
5 import org.jsoup.nodes.Element;
6 import org.jsoup.select.Elements;
7
8 import java.io.IOException;
9
10 public class JavaDocLinks {
11 public static void main(String[] args) {
https://packt.live/2nYnBXS
在这个练习中,我们使用了 jsoup API 下载一个 HTML 文档。下载后,我们提取了与每个链接相关的链接 URI 和描述性文本。这为 jsoup API 提供了一个很好的概述,因此你可以在你的项目中使用它。
深入了解 java.net.http 模块
Java 11 在新的 java.net.http 模块中添加了一个全新的 HttpClient 类。HttpClient 类使用现代的 建造者模式(也称为流畅式 API)来设置 HTTP 连接。然后它使用响应式流模型来支持同步和异步请求。
注意
你可以参考 第十六章,断言和其他功能接口,以及 第十七章,使用 Java Flow 进行响应式编程,了解更多关于 Java 的 Stream API 和响应式流的信息。查看 packt.live/32sdPfO 了解模块中 java.net.http 包的概述。
使用建造者模型,你可以配置诸如超时等设置,然后调用 build() 方法。你得到的 HttpClient 类是不可变的:
HttpClient client = HttpClient.newBuilder()
.version(HttpClient.Version.HTTP_2)
.followRedirects(HttpClient.Redirect.NORMAL)
.connectTimeout(Duration.ofSeconds(30))
.build();
在这个例子中,我们指定以下内容:
-
HTTP 版本 2。
-
客户端应正常遵循重定向,除非重定向是从更安全的 HTTPS 到更不安全的 HTTP。这是
HttpClient.Redirect.NORMAL的默认行为。 -
连接超时为 30 秒。
HttpClient类可用于多个请求。下一步是设置 HTTP 请求:
HttpRequest request = HttpRequest.newBuilder()
.uri(URI.create("http://example.com/"))
.timeout(Duration.ofSeconds(30))
.header("Accept", "text/html")
.build();
使用此请求:
-
URL 是
http://example.com/。 -
读取超时为 30 秒。
-
我们将
Accept头设置为请求text/html内容。
一旦构建完成,在客户端上调用send()以同步发送请求或调用sendAsync()以异步发送请求。如果您调用send(),调用将阻塞,并且您的应用程序将等待数据返回。如果您调用sendAsync(),调用将立即返回,并且您的应用程序可以在稍后检查数据是否到达。如果您想在后台线程中处理数据,请使用sendAsync()。有关后台线程和如何并发执行任务的更多详细信息,请参阅第二十二章,并发任务:
HttpResponse<String> response =
client.send(request, HttpResponse.BodyHandlers.ofString());
在此示例中,请求体处理程序指定我们希望以字符串形式返回内容。
练习 5:使用 java.net.http 模块获取 HTML 内容
在此练习中,我们将重新创建练习 2,创建 GET 请求以获取网页内容。虽然看起来代码更多,但这并不一定是这样。java.net.http模块实际上非常灵活,因为您可以使用lambda表达式来处理响应。第十三章,使用 Lambda 表达式的函数式编程涵盖了 lambda 表达式:
-
在 IntelliJ 的
项目面板中,右键单击com.packtpub.http包。选择新建然后Java 类。 -
将
NetHttpClient作为 Java 类的名称。 -
为
NetHttpClient.java输入以下代码:
NetHttpClient.java
1 package com.packtpub.http;
2
3 import java.io.IOException;
4 import java.net.URI;
5 import java.net.http.HttpClient;
6 import java.net.http.HttpRequest;
7 import java.net.http.HttpResponse;
8 import java.time.Duration;
9
10 public class NetHttpClient {
11 public static void main(String[] args) {
12
13 HttpClient client = HttpClient.newBuilder()
14 .version(HttpClient.Version.HTTP_2)
15 .followRedirects(HttpClient.Redirect.NORMAL)
16 .connectTimeout(Duration.ofSeconds(30))
17 .build();
https://packt.live/2W33Ivy
当您运行此程序时,您应该看到与在练习 2,创建 GET 请求中创建的GetRequest程序相同的输出。
活动一:使用 jsoup 库从网络下载文件
通过这个活动,您将下载通过 Packt 提供的 Java 标题。在网页浏览器中访问www.packtpub.com/tech/Java。注意所有可用的 Java 标题。活动内容是编写一个程序来打印所有这些标题:
-
使用
jsoup库访问packt.live/2J5dlEv。 -
下载 HTML 内容。
-
查找所有具有 CSS 类
book-block-title的DIV元素,并打印DIV内的文本。 -
当您运行此程序时,您应该看到以下输出:
Hands-On Data Structures & Algorithms in Java 11 [Video] Java EE 8 Microservices Hands-On Object Oriented Programming with Java 11 [Video] Machine Learning in Java - Second Edition Java 11 Quick Start Object-oriented and Functional Programming with Java 8 [Integrated Course] Mastering Microservices with Java 9 - Second Edition Design Patterns and Best Practices in Java Java Interview Guide : 200+ Interview Questions and Answers [Video] Ultimate Java Development and Certification Guide [Video] Spring MVC For Beginners : Build Java Web App in 25 Steps [Video] Java EE 8 and Angular RESTful Java Web Services - Third Edition Java EE 8 Application Development Mastering Microservices with Java 9 - Second Edition注意
输出将被截断。
活动解决方案可在第 557 页找到。
摘要
本章介绍了 HTTP 网络,它通常用于在 Java 应用程序中连接到 RESTful 网络服务。HTTP 是一种文本请求-响应协议。客户端向服务器发送请求,然后获取响应。每个 HTTP 请求都有一个方法;例如,您会使用 GET 请求来检索数据,POST 来发送数据,等等。在 Java 应用程序中,您通常会以 JSON 格式发送和接收文本。
HttpUrlConnection类提供了发送 HTTP 请求的主要方式。您的代码将数据写入输出流以发送,然后从输入流中读取响应。开源的 jsoup 库提供了一个方便的 API 来检索和解析 HTML 数据。从 Java 11 开始,您可以使用java.net.http模块来实现更现代的响应式流方法来处理 HTTP 网络。在下一章中,您将学习关于证书和加密的内容——这两者通常与 HTTP 网络一起使用。
第十章:10. 加密
概述
本章讨论 Java 对加密的支持。它首先通过定义对称密钥加密和非对称密钥加密来实现这一点,然后教你如何实现这些加密高级加密标准(AES)和RSA(Rivest-Shamir-Adleman),分别。你还将进一步学习区分块密码和流密码,以便在加密文件时能够适当使用它们。
简介
加密是将数据打乱的过程,以便可以在两个或多个当事人之间公开发送,而其他人无法理解发送了什么。如今,你在网上做的几乎所有事情都是加密的——无论是阅读电子邮件、将照片发送到流行的社交网络,还是下载源代码。今天的大多数严肃网站也都是加密的。将加密应用于你的软件对于保护你的完整性、数据、业务以及客户的利益至关重要。
注意
加密是一个非常复杂的话题,每年都在变得更加复杂,因为我们试图保护我们的应用程序免受新的恶意代码和个人的侵害。本章不会详细介绍如何在软件中实现加密。相反,我们将解释如何使用 Java 中可用的 API。
在 Java 中,我们有一系列专门为处理 Java 平台上的大多数安全相关案例而创建的类和接口——它们都被收集在被称为Java 密码架构(JCA)的地方。在 JCA 中,为在 Java 中构建安全应用程序奠定了基础。Java 中还有其他几个安全库使用 JCA 来实现它们的安全性。使用 JCA,你可以创建自己的自定义安全提供者或使用已提供的标准提供者。在大多数情况下,使用标准提供者就足够了。
明文
在密码学术语中,明文是指你希望加密的数据。明文是另一个常用的术语,其使用与明文可以互换,这取决于你询问的对象。
密文
这是明文的加密版本。这是可以安全发送给接收方的数据。
密码
密码是一种数学函数或算法,用于将明文数据加密成密文。然而,仅使用密码从明文创建密文是不够的——你还需要一个密钥来定义你的加密将如何独特地工作。所有密钥都是唯一生成的。根据你选择的密码类型,你可能需要一个或两个密钥来加密和解密你的数据。
要在 Java 中初始化一个密码,你需要了解关于它的三个信息:使用的算法、模式和填充类型。不同的密码以不同的方式工作,因此定义正确的转换对于避免引发异常或创建不安全的应用程序至关重要:
Cipher cipher = Cipher.getInstance(<transformation>);
cipher.init(Cipher.ENCRYPT_MODE, <key>);
算法或加密算法存储在我们所说的加密提供程序(或简称提供程序)中。根据应用程序运行的系统,您可能无法直接访问所有类型的加密算法。在某些情况下,您甚至可能需要安装额外的提供程序才能访问您希望使用的加密算法。
然而,每个 Java 虚拟机(JVM)都附带了一套具有不同转换的可用加密算法。至少,您现在在任何 JVM 上都可以找到以下转换:
-
AES/CBC/NoPadding
-
AES/CBC/PKCS5Padding
-
AES/ECB/NoPadding
-
AES/ECB/PKCS5Padding
-
AES/GCM/NoPadding
-
DES/CBC/NoPadding
-
DES/CBC/PKCS5Padding
-
DES/ECB/NoPadding
-
DES/ECB/PKCS5Padding
-
DESede/CBC/NoPadding
-
DESede/CBC/PKCS5Padding
-
DESede/ECB/NoPadding
-
DESede/ECB/PKCS5Padding
-
RSA/ECB/PKCS1Padding
-
RSA/ECB/OAEPWithSHA-1AndMGF1Padding
-
RSA/ECB/OAEPWithSHA-256AndMGF1Padding
密钥
每个加密算法至少需要一个密钥来加密明文和解密密文。根据加密算法的类型,密钥可以是对称的或非对称的。通常,您会处理存储在非易失性内存中的密钥,但您也可以从代码中生成密钥。在 JCA 中,有一个简单的命令用于为特定加密算法生成密钥:
KeyPair keyPair = KeyPairGenerator.getInstance(algorithm).generateKeyPair();
对称密钥加密
对称加密通常被认为比非对称加密不安全。这并不是因为算法比非对称加密不安全,而是因为用于解锁内容的密钥必须由多个实体共享。以下图表以一般术语说明了对称加密的工作原理。

图 10.1:对称加密
您可以通过这种方式创建对称加密的密钥:
Key key = KeyGenerator.getInstance(<algorithm>).generateKey();
目前最受欢迎的对称加密方法之一是 高级加密标准(AES)。
练习 1:使用高级加密标准加密字符串
在这个练习中,我们将使用 AES 加密字符串 "My secret message":
-
如果 IntelliJ 已经启动但没有打开项目,则选择
创建新项目。如果 IntelliJ 已经打开了项目,则从菜单中选择文件->新建->项目。 -
在
新建项目对话框中,选择 Java 项目。单击下一步。 -
打开复选框以从模板创建项目。选择
命令行应用程序。然后,单击下一步。 -
给新项目命名为
Chapter10。 -
IntelliJ 会为您提供一个默认的项目位置;如果您想选择自己的位置,可以在这里输入。
-
将包名设置为
com.packt.java.chapter10。 -
单击
完成。IntelliJ 将创建名为Chapter10的项目,并具有标准文件夹结构。IntelliJ 还将为您的应用程序创建主入口点,称为Main.java。 -
将此文件重命名为
Exercise1.java。完成时,它应该看起来像这样:package com.packt.java.chapter10; public class Exercise1 { public static void main(String[] args) { // write your code here } } -
决定您想要用于加密的算法——在这个例子中,我们使用 AES——然后,生成加密密钥。如果选定的算法不被系统上的任何提供者支持,生成密钥可能会抛出异常:
package com.packt.java.chapter10; import javax.crypto.KeyGenerator; import java.security.Key; import java.security.NoSuchAlgorithmException; public class Exercise1{ public static void main(String[] args) { try { String algorithm = "AES"; Key privateKey = KeyGenerator.getInstance(algorithm) .generateKey(); } catch (NoSuchAlgorithmException e) { e.printStackTrace(); } } }注意
在这个练习中,我们使用一个变量来存储密钥。然而,在大多数实际场景中,您可能会使用一种更稳定的存储形式——例如文件或数据库。
-
下一步是定义实际要使用的加密转换。如前所述,转换包含有关如何处理加密器的信息。在这种情况下,我们使用 AES,它是一种分组密码,因此我们需要定义如何将密钥应用于明文数据的每个块。此外,我们还需要定义是否应该有任何填充,以及这种填充应该是什么样子:
package com.packt.java.chapter10; import javax.crypto.KeyGenerator; import java.security.Key; import java.security.NoSuchAlgorithmException; public class Exercise1 { public static void main(String[] args) { try { String algorithm = "AES"; Key privateKey = KeyGenerator.getInstance (algorithm).generateKey(); String transformation = algorithm + "/ECB/NoPadding"; } catch (NoSuchAlgorithmException e) { e.printStackTrace(); } } }我们已经选择了 AES 作为算法,所以我们从那个开始转换。在此之后,我们决定采用不太安全的电子密码本(ECB)转换模式,这意味着我们将以相同的方式为每个明文数据块应用密钥。最后,我们定义,如果明文数据块短于密文块长度,我们将不使用填充。
-
查询系统以获取建议的转换的加密器。此方法可能会抛出
NoSuchAlgorithmException和NoSuchPaddingException。如果出现这种情况,请确保处理:public static void main(String[] args) { try { String algorithm = "AES"; Key privateKey = KeyGenerator.getInstance(algorithm) .generateKey(); String transformation = algorithm + "/ECB/NoPadding"; Cipher cipher = Cipher.getInstance(transformation); } catch (NoSuchAlgorithmException e) { e.printStackTrace(); } catch (NoSuchPaddingException e) { e.printStackTrace(); } } -
与 Java API 相比,加密和解密几乎相同。当加密明文文件时,您在加密模式下初始化加密器,当解密密文文件时,您在解密模式下初始化加密器。如果密钥错误,这可能会导致
InvalidKeyException:public static void main(String[] args) { try { String algorithm = "AES"; Key privateKey = KeyGenerator.getInstance(algorithm) .generateKey(); String transformation = algorithm + "/ECB/NoPadding"; Cipher cipher = Cipher.getInstance(transformation); cipher.init(Cipher.ENCRYPT_MODE, privateKey); } catch (NoSuchAlgorithmException e) { e.printStackTrace(); } catch (NoSuchPaddingException e) { e.printStackTrace(); } catch (InvalidKeyException e) { e.printStackTrace(); } } -
实际上,加密您的文本是一个两步过程,并且您始终需要根据实际数据字节调整您的工作。由于我们正在处理一个
String,您将需要获取这个String的实际字节:Exercise1.java 1 package com.packt.java.chapter10; 2 3 import javax.crypto.*; 4 import java.security.InvalidKeyException; 5 import java.security.Key; 6 import java.security.NoSuchAlgorithmException; 7 8 public class Exercise1 { 9 10 public static void main(String[] args) { 11 try { 12 String algorithm = "AES"; 13 Key privateKey = KeyGenerator.getInstance(algorithm) .generateKey(); 14 String transformation = algorithm + "/ECB/PKCS5Padding"; 15 Cipher cipher = Cipher.getInstance(transformation); 16 cipher.init(Cipher.ENCRYPT_MODE, privateKey); https://packt.live/32veWeE如您可能已经注意到的,在使用加密时可能会出现很多问题。通常,您应该优雅地处理这些异常,但在这个例子中,我们只是将它们打印出来。
-
现在,最后要做的就是打印文本的加密版本以验证您已加密数据。您应该在终端中看到乱码。这是正常的;这意味着您已成功将明文消息隐藏在密文文件中:
如果您将转换填充更改为
NoPadding,会发生什么?如果您保留
PKCS5Padding但将明文消息更改为"This is 16 bytes",会发生什么?尝试通过将加密器初始化为
MODE_DECRYPT而不是明文消息来传递密文来解密消息。记住,您需要使用相同的密钥才能使此过程正常工作;否则,您将再次看到乱码。
分组密码
AES 是一种分组密码,这意味着加密是逐个处理一个明文块的。块大小取决于密钥大小;也就是说,更大的密钥意味着更大的块。
初始化向量
一些分组密码的转换模式要求你使用初始化向量——这是对 ECB 模式明显重复模式的改进。这可以通过一个显示 AES/ECB 和 AES/CBC 加密差异的图像来直观地展示。
CBC 指的是分组密码链,简而言之,它是根据前一个数据块来混淆当前数据块的。或者,如果是第一个数据块,则是根据初始化向量来混淆数据。
流密码
另一方面,流密码通过逐个字节加密来工作。有一个关于被称为“一次性密码”的理论讨论,它代表了理想的流加密。在理论上,这些非常安全,但也很不实用,因为密钥必须与明文数据长度相同。对于大量明文数据,这样的密钥是无法使用的。
非对称密钥加密
在非对称密钥加密中,私钥只由一方持有——接收者或数据的所有者。数据发送者,即不被视为所有者的一方,使用我们所说的公钥来加密数据。公钥可以被任何人持有,而不会危及任何之前加密的消息。这被认为是一种更安全的加密处理方式,因为只有接收者才能解密消息。
练习 2:使用 RSA 非对称密钥加密加密字符串
使用Rivest–Shamir–Adleman(RSA)非对称密钥加密来加密"My secret message"消息。这是一个公钥/私钥组合:
-
如果尚未打开,请打开 IDEA 中的
Chapter10项目。 -
使用
File -> New -> Java Class菜单创建一个新的 Java 类。 -
将
Name设置为Exercise2,然后选择OK。现在你应该在你的项目中有一个空的类:package com.packt.java.chapter10; public class Exercise2 { } -
添加一个
main方法——你将在其中编写所有代码进行这个练习:package com.packt.java.chapter10; public class Exercise2 { public static void main(String[] args) { } } -
声明一个包含内容"
My secret message"的明文String:package com.packt.java.chapter10; public class Exercise2 { public static void main(String[] args) { String plaintext = "My secret message"; } } -
在其中添加另一个字符串"RSA",你将在那里编写这个练习的算法:
package com.packt.java.chapter10; public class Exercise2 { public static void main(String[] args) { String plaintext = "My secret message"; String algorithm = "RSA"; } } -
因为 RSA 是一种非对称密钥加密形式,所以你需要生成一个密钥对而不是一个密钥。如果找不到算法,请捕获异常:
public class Exercise2 { public static void main(String[] args) { try { String plaintext = "My secret message"; String algorithm = "RSA"; KeyPair keyPair = KeyPairGenerator.getInstance(algorithm) .generateKeyPair(); } catch (NoSuchAlgorithmException e) { e.printStackTrace(); } } } -
定义转换;在这个练习中,我们将使用电子密码本和
PKCS1Padding:public class Exercise2 { public static void main(String[] args) { try{ String plaintext = "My secret message"; String algorithm = "RSA"; KeyPair keyPair = KeyPairGenerator.getInstance(algorithm) .generateKeyPair(); String transformation = algorithm + "/ECB/PKCS1Padding"; } catch (NoSuchAlgorithmException e) { e.printStackTrace(); } } } -
为算法创建一个密文并使用所选的转换初始化它。记住,在用 RSA 加密时始终使用公钥:
try{ String plaintext = "My secret message"; String algorithm = "RSA"; KeyPair keyPair = KeyPairGenerator.getInstance(algorithm) .generateKeyPair(); String transformation = algorithm + "/ECB/PKCS1Padding"; Cipher cipher = Cipher.getInstance(transformation); cipher.init(Cipher.ENCRYPT_MODE, keyPair.getPublic()); } catch (NoSuchAlgorithmException e) { e.printStackTrace(); } catch (NoSuchPaddingException e) { e.printStackTrace(); } catch (InvalidKeyException e) { e.printStackTrace(); } } -
最后,将明文加密成密文,你会注意到使用 RSA 加密的密文比 AES 加密的密文大得多。这是因为密钥大小的原因。
Exercise2.java
1 package com.packt.java.chapter10;
2
3 import javax.crypto.*;
4 import java.security.InvalidKeyException;
5 import java.security.KeyPair;
6 import java.security.KeyPairGenerator;
7 import java.security.NoSuchAlgorithmException;
8
9 public class Exercise2 {
10
11 public static void main(String[] args) {
12 try {
13 String plaintext = "My secret message";
14 String algorithm = "RSA";
15 KeyPair keyPair = KeyPairGenerator.getInstance(algorithm) .generateKeyPair();
16 String transformation = algorithm + "/ECB/PKCS1Padding";
17 Cipher cipher = Cipher.getInstance(transformation);
18 cipher.init(Cipher.ENCRYPT_MODE, keyPair.getPublic());
https://packt.live/2MvdL9x
你也可以使用 RSA 的解密逻辑。记住,在解密时使用私钥;否则,它将不起作用。
加密文件
加密文件非常类似于加密字符串。然而,对于大文件,清空加密流可能更明智。但如果文件太大,或者有多个文件,那么应用CipherStreams可能更明智——不要与流加密混淆。
CipherStreams继承了 Java 的InputStream和OutputStream的大部分行为,修改之处在于你可以使用提供的加密解密你读取的文件,或者加密你写入的文件。
练习 3:加密文件
以下练习展示了如何加密一个文件。你可以在代码仓库中找到这个文件。
-
如果尚未打开,请打开 IDEA 中的
Chapter10项目。 -
使用
File|New|Java Class菜单创建一个新的 Java 类。 -
将名称输入为
Exercise3,然后选择OK。现在你应该在你的项目中有一个空的类:package com.packt.java.chapter10; public class Exercise3 { } -
添加一个
main方法,在其中编写这个练习的代码:package com.packt.java.chapter10; public class Exercise3 { public static void main(String[] args) { } } -
定义用于加密的算法;在这个练习中我们将回到 AES 并生成密钥:
public static void main(String[] args) { try { String algorithm = "AES"; Key secretKey = KeyGenerator.getInstance(algorithm) .generateKey(); } catch (NoSuchAlgorithmException e) { e.printStackTrace(); } } } -
获取加密实例并初始化它以进行加密:
try{ String algorithm = "AES"; Key secretKey = KeyGenerator.getInstance(algorithm) .generateKey(); String transformation = algorithm + "/CBC/NoPadding"; Cipher cipher = Cipher.getInstance(transformation); cipher.init(Cipher.ENCRYPT_MODE, secretKey); } catch (NoSuchAlgorithmException e) { e.printStackTrace(); } catch (NoSuchPaddingException e) { e.printStackTrace(); } catch (InvalidKeyException e) { e.printStackTrace(); } } -
创建一个用于加密的文件;如果你愿意,可以从书籍的 GitHub 仓库下载
plaintext.txt文件。或者,你也可以使用 lipsum 创建自己的文本文件——甚至更好的是,从你的电脑上复制一个文档。我们将把这些文件放在你项目的"res"文件夹中:try { String algorithm = "AES"; Key secretKey = KeyGenerator.getInstance(algorithm) .generateKey(); String transformation = algorithm + "/CBC/NoPadding"; Cipher cipher = Cipher.getInstance(transformation); cipher.init(Cipher.ENCRYPT_MODE, secretKey); Path pathToFile = Path.of("res/plaintext.txt"); File plaintext = pathToFile.toFile(); } catch (NoSuchAlgorithmException e) { e.printStackTrace(); } catch (NoSuchPaddingException e) { e.printStackTrace(); }catch (InvalidKeyException e){ e.printStackTrace(); } } -
此外,创建一个将保存加密内容的文件。确保该文件不存在:
File ciphertext = Path.of("res/ciphertext.txt").toFile(); if (ciphertext.exists()) { ciphertext.delete(); } } catch (NoSuchAlgorithmException e) { e.printStackTrace(); } catch (NoSuchPaddingException e) { e.printStackTrace(); } catch (InvalidKeyException e) { e.printStackTrace(); } -
现在是时候添加加密流了。在这个例子中,我们需要
FileInputStream来读取plaintext.txt文件的内容,FileOutputStream来写入初始化向量,以及CipherOutputStream来执行加密:try (FileInputStream fileInputStream = new FileInputStream(plaintext); FileOutputStream fileOutputStream = new FileOutputStream(ciphertext); CipherOutputStream cipherOutputStream = new CipherOutputStream(fileOutputStream, cipher)); { } } catch (NoSuchAlgorithmException e) { e.printStackTrace(); } catch (NoSuchPaddingException e) { e.printStackTrace(); } catch (InvalidKeyException e) { e.printStackTrace(); } catch (FileNotFoundException e) { e.printStackTrace(); } catch (IOException e) { e.printStackTrace(); } } } -
编写初始化向量;你将在初始化的加密中找到它。确保使用
FileOutputStream,因为我们不希望加密这些字节:try (FileInputStream fileInputStream = new FileInputStream(plaintext); FileOutputStream fileOutputStream = new FileOutputStream(ciphertext); CipherOutputStream cipherOutputStream = new CipherOutputStream(fileOutputStream, cipher)) { fileOutputStream.write(cipher.getIV()); } } catch (NoSuchAlgorithmException e) { e.printStackTrace(); } catch (NoSuchPaddingException e) { e.printStackTrace(); } catch (InvalidKeyException e) { e.printStackTrace(); } catch (FileNotFoundException e) { e.printStackTrace(); } catch (IOException e) { e.printStackTrace(); } } -
最后,将
FileInputStream的内容写入CipherOutputStream,允许在过程中加密内容:Exercise3.java 1 package com.packt.java.chapter10; 2 3 import javax.crypto.Cipher; 4 import javax.crypto.CipherOutputStream; 5 import javax.crypto.KeyGenerator; 6 import javax.crypto.NoSuchPaddingException; 7 import java.io.*; 8 import java.nio.file.Path; 9 import java.security.InvalidKeyException; 10 import java.security.Key; 11 import java.security.NoSuchAlgorithmException; 12 13 public class Exercise3 { 14 15 public static void main(String[] args) { https://packt.live/2J4nKjI在 Java 中处理文件有众多方法,这只是加密内容的一种方式。如果你有更大的文件,也许使用
BufferedReader会是一个不错的选择。 -
不是加密文件,而是使用加密流来加密整个文件夹。也许最好的做法是首先将文件夹压缩成 ZIP 存档,然后加密那个文件。
概述
JCA 包含了你需要用于加密的一切。在本章中,你只是刚刚触及了这个主要框架的表面。这已经足够让你开始,但如果你打算进一步深入这个框架的复杂性,你将首先需要更深入地了解密码学。
在下一章中,我们将介绍进程的启动,以及向子进程发送输入和捕获输出。
XRB39
第十一章:11. 进程
概述
在本章中,我们将快速了解 Java 如何处理进程。你将从探索Runtime和ProcessBuilder类开始,了解它们的功能以及如何启动它们,然后从任一类创建一个进程。你将学习在父进程和子进程之间发送和接收数据,以及如何将进程的结果存储在文件中。在本章的最终活动中,你将使用这些技能创建一个父进程,该进程将启动一个子进程,该子进程将打印一个结果(然后由父进程捕获)到终端。
简介
java.lang.Process类用于查找有关运行时进程的信息并启动它们。如果你想了解Process类是如何工作的,你可以从查看Runtime类开始。所有 Java 程序都包含一个Runtime类的实例。可以通过调用getRuntime()方法并将结果分配给Runtime类的变量来获取有关Runtime类的信息。有了这个,就可以获取有关控制你程序的JVM环境的信息:
public class Example01 {
public static void main(String[] args) {
Runtime runtime = Runtime.getRuntime();
System.out.println("Processors: " + runtime.availableProcessors());
System.out.println("Total memory: " + runtime.totalMemory());
System.out.println("Free memory: " + runtime.freeMemory());
}
}
进程携带有关在计算机上启动的程序的信息。每个操作系统处理进程的方式都不同。Process类提供了一个机会,以相同的方式控制它们。这是通过Runtime类的一个单独的方法实现的,称为exec(),它返回一个Process类的对象。Exec有不同的实现,允许你简单地发出一个命令,或者通过修改环境变量甚至程序运行的目录来实现。
启动进程
如前所述,进程是通过exec()启动的。让我们看看一个简单的例子,我们将调用 Java 编译器,这在任何操作系统的终端上都是用相同的方式完成的:
import java.io.IOException;
public class Example02 {
public static void main(String[] args) {
Runtime runtime = Runtime.getRuntime();
try {
Process process = runtime.exec("firefox");
} catch (IOException ioe) {
System.out.println("WARNING: something happened with exec");
}
}
}
当运行此示例时,如果你恰好安装了 Firefox,它将自动启动。你可以将其更改为计算机上的任何其他应用程序。程序将无错误退出,但除了这一点之外,它不会做任何事情。
现在,让我们在先前的例子中添加几行,以便你刚刚打开的程序将在 5 秒后关闭:
import java.io.IOException;
import java.util.concurrent.TimeUnit;
public class Example03 {
public static void main(String[] args) {
Runtime runtime = Runtime.getRuntime();
Process process = null;
try {
process = runtime.exec("firefox");
} catch (IOException ioe) {
System.out.println("WARNING: something happened with exec");
}
try {
process.waitFor(5, TimeUnit.SECONDS);
} catch (InterruptedException ie) {
System.out.println("WARNING: interruption happened");
}
process.destroy();
}
}
waitFor(timeOut, timeUnit)方法将等待进程结束 5 秒钟。如果它是没有参数的waitFor(),它将等待程序自行结束。在 5 秒超时之后,进程变量将调用destroy()方法,这将立即停止进程。因此,在短时间内打开和关闭应用程序。
有一种启动进程的方法,不需要创建Runtime对象。这种方法使用ProcessBuilder类。构建ProcessBuilder对象将需要实际要执行的命令作为参数。以下是对先前示例的修订,增加了这个新构造函数:
import java.io.IOException;
public class Example04 {
public static void main(String[] args) {
ProcessBuilder processBuilder = new ProcessBuilder("firefox");
Process process = null;
try {
process = processBuilder.start();
} catch (IOException ioe) {
System.out.println("WARNING: something happened with exec");
}
try {
process.waitFor(10, TimeUnit.SECONDS);
} catch (InterruptedException ie) {
System.out.println("WARNING: interruption happened");
}
process.destroy();
}
}
有几件事情你应该注意。首先,该过程包括在构造函数中将命令作为参数调用。然而,直到你调用 processBuilder.start(),这个过程才启动。唯一的问题是,ProcessBuilder 对象不包含与 Process API 相同的方法。例如,waitFor() 和 destroy() 等方法不可用,因此,如果需要这些方法,你必须在程序中调用它之前实例化一个 Process 对象。
向子进程发送输入
一旦进程开始运行,向其中传递一些数据将很有趣。让我们创建一个小程序,它会将你在 CLI 上输入的任何内容 echo 回来。稍后,我们将编写一个程序来启动第一个应用程序并向它发送文本。这个简单的 echo 程序可能如下所示:
public class Example05 {
public static void main(String[] args) throws java.io.IOException
{
int c;
System.out.print ("Let's echo: ");
while ((c = System.in.read ()) != '\n')
System.out.print ((char) c);
}
}
如你所见,这个简单的程序将读取 System.in 流,直到你按下 Enter。一旦发生这种情况,它将优雅地退出:
Enter some text: Hello World
Hello World
Process finished with exit code 0
在前面的输出第一行中,我们输入字符串 'Hello World' 作为此示例,它在下一行被 echo。接下来,你可以创建另一个程序来启动此示例并向它发送一些文本:
Example06.java
20 try {
21 process.waitFor(5, TimeUnit.SECONDS);
22 } catch (InterruptedException ie) {
23 System.out.println("WARNING: interrupted exception fired");
24 }
25
26 OutputStream out = process.getOutputStream();
27 Writer writer = new OutputStreamWriter(out);
28 writer.write("This is how we roll!\n"); // EOL to ensure the process sends back
29
30 writer.flush();
31 process.destroy();
32 }
33 }
https://packt.live/2pEJLiw
此示例有两个有趣的技巧,你需要注意。第一个是调用前一个示例。由于我们必须启动一个 Java 应用程序,我们需要使用 cp 参数调用 java 可执行文件,这将指示 JVM 应在哪个目录中查找编译的示例。你刚刚编译并尝试了 Example05,这意味着你的计算机中已经有一个编译好的类。
注意
在调用 cp 参数之后,在 Linux/macOS 中,你需要在类名之前添加一个冒号 (:),而在 Windows 的情况下,你应该使用分号 (;)。
一旦编译此示例,其相对于前一个示例的相对路径是 ../../../../Example05/out/production/Example05。这可能会因你如何命名项目文件夹而完全不同。
第二件需要注意的事情也在代码列表中突出显示。在那里,你可以看到与来自进程的 OutStream 链接的 OutStream 的声明。换句话说,我们正在将 Example06 的输出流链接到 Example05 应用程序的 System.in。为了能够向它写入字符串,我们构建了一个 Writer 对象,该对象公开了一个具有向流发送字符串能力的 write 方法。
我们可以使用以下命令从 CLI 调用此示例:
usr@localhost:~/IdeaProjects/chapter11/[...]production/Example06$ java Example06
结果什么也没有。原因是 echo 示例 (Example05) 中的 System.out 没有对启动进程的应用程序开放。如果我们想使用它,我们需要在 Example06 中捕获它。我们将在下一节中看到如何做到这一点。
捕获子进程的输出
现在我们有两个不同的程序;一个可以独立运行(Example05),另一个是从另一个程序中执行的,它也会尝试向它发送信息并捕获其输出。本节的目的就是捕获Example05的输出并将其打印到终端。
为了捕获子进程发送到System.out的内容,我们需要在父类中创建一个BufferedReader,它将从可以由进程实例化的InputStream中获取数据。换句话说,我们需要增强Example06,如下所示:
InputStream in = process.getInputStream();
Reader reader = new InputStreamReader(in);
BufferedReader bufferedReader = new BufferedReader(reader);
String line = bufferedReader.readLine();
System.out.println(line);
需要使用BufferedReader的原因是我们使用行尾(EOL或"\n")作为进程间消息的标记。这允许使用readLine()等方法,这些方法将阻塞程序直到捕获到 EOL;否则,我们可以坚持使用Reader对象。
一旦将此内容添加到示例中,从终端调用之前的程序将产生以下输出:
usr@localhost:~/IdeaProjects/chapter11/[...]production/Example06$ java Example06
Let's echo: This is how we roll!
在此输出之后,程序将结束。
需要考虑的一个重要方面是,由于BufferedReader具有缓冲性质,它需要使用flush()方法来强制将我们发送到缓冲区的数据发送到子进程。否则,当JVM给它优先级时,它将永远等待,这最终可能导致程序停滞。
将子进程的输出存储到文件中
将数据存储在文件中不是很有用吗?这可能是你可能会对有一个进程来运行程序(或一系列程序)感兴趣的原因之一——将它们的输出捕获到日志文件中以供研究。通过向进程启动器添加一个小修改,你可以捕获其他程序发送到System.out的任何内容。这真的很有用,因为你可以创建一个程序,它可以用来启动操作系统中任何现有的命令并捕获所有输出,这些输出可以用于稍后进行某种类型的成果分析:
Example07.java
26 // write to the child's System.in
27 OutputStream out = process.getOutputStream();
28 Writer writer = new OutputStreamWriter(out);
29 writer.write("This is how we roll!\n");
30 writer.flush();
31
32 // prepare the data logger
33 File file = new File("data.log");
34 FileWriter fileWriter = new FileWriter(file);
35 BufferedWriter bufferedWriter = new BufferedWriter(fileWriter);
36
37 // read from System.out from the child
38 InputStream in = process.getInputStream();
39 Reader reader = new InputStreamReader(in);
40 BufferedReader bufferedReader = new BufferedReader(reader);
41 String line = bufferedReader.readLine();
https://packt.live/33X3Wal
结果不仅会将结果写入终端,还会创建一个data.log文件,其中包含完全相同的句子。
活动一:创建一个父进程以启动子进程
在这个活动中,我们将创建一个父进程,该进程将启动一个子进程,该子进程将打印出一系列递增的数字。子进程的结果将被父进程捕获,然后将其打印到终端。
为了防止程序无限运行达到无穷大,子进程应在达到某个数字时停止。让我们以50作为这个活动的限制,此时计数器将退出。
同时,父进程将读取输入并将其与某个数字进行比较,例如 37,之后计数器应重新启动。为了请求子进程重新启动,父进程应向子进程发送一个单字节命令。让我们用星号(*)来完成这项活动。你应该使用sleep()命令,以便终端上的打印不会太快。一个好的配置是sleep(200)。
根据上述简要说明,单独运行子程序预期的输出如下:
0
1
2
3
[...]
49
50
但是,当从父程序调用时,结果应该是:
0
1
2
[...]
36
37
0
1
[loops forever]
-
子程序应该有一个类似于以下算法:
int cont = 0; while(cont <= 50) { System.out.println(cont++); sleep(200); if (System.in.available() > 0) { ch = System.in.read(); if (ch == '*') { cont = 0; } } }这里有一个调用
System.in.available()来检查子程序输出缓冲区中是否有数据。 -
另一方面,父程序应考虑包含类似以下内容:
if (Integer.parseInt(line) == 37) { writer.write('*'); writer.flush(); // needed because of the buffered output }
这将检测刚刚到达的作为String的数字是否将被转换为Integer,然后它将与我们为计数重置所建议的限制进行比较。
我们没有深入探讨Process类提供的所有方法。因此,建议将本章的工作包裹在传统的参考文档中,并访问 JavaDoc 以了解该类还能提供什么。
注意
该活动的解决方案可以在第 559 页找到。你可以在 Oracle 的官方文档中了解更多关于Process类的信息:docs.oracle.com/javase/8/docs/api/java/lang/Process.html。
摘要
在本章简短的介绍中,你了解了 Java 中的Process类。你看到了一个输出到System.out的过程如何在父程序中被捕获。同时,你也看到了父程序如何轻松地向子程序发送数据。示例表明,不仅可以启动自己的程序,还可以启动任何其他程序,例如网页浏览器。使用包含Process API 的程序构建软件自动化的可能性是无限的。
我们还看到了在进程间通信方面流的重要性。基本上,你必须在上层流的基础上创建流来开发更复杂的数据结构,这将使代码运行得更快。下一章将介绍正则表达式。
第十二章:12. 正则表达式
概述
本章讨论正则表达式,并考虑了它们在 Java 中为何以及如何如此有用。首先,你将探索如何构建这些表达式以便在程序中搜索信息——这是任何开发者的一项基本技能。当你对正则表达式的本质和功能有了牢固的理解后,你将能够使用它们在搜索中执行简单的全文匹配,并在本章的后面部分,使用组和非捕获组从文本中提取子字符串。在最后的练习中,你必须运用所有这些技能来执行递归匹配,并从文本中提取一组相似元素(即模式)。
简介
在你的开发者职业生涯中,你经常会发现搜索信息是解决问题的逻辑第一步:搜索文档、搜索特定的代码行,或者只是编写一个程序,从给定的文本体中提取信息,使其成为程序可以理解的数据。
正则表达式是定义这些搜索规则的一种特定语言,就像 Java 是一种构建程序的语言一样。其语法可能相当复杂。当你第一次看到正则表达式时,可能会感到有些令人畏惧。
以下是一个非常基础的用于构建电子邮件地址的模式匹配器,存在许多缺陷:
/.+\@.+\..+/
如果你第一次看到这个,你可能会认为这是一个打字错误(或者认为一只猫参与了其中)。然而,这是一段完全合法的代码。我们很快就会深入探讨这个示例的构建,但首先,让我们看看一个更详尽的模式匹配器,它可以验证电子邮件地址的构建:
/[a-zA-Z]+[a-zA-Z0-9]+\@[a-zA-Z0-9]{2,}\.[a-zA-Z]{2,}/
对于初学者来说,这看起来更像是一堆乱码。也许同样的猫正在你的键盘上筑巢。
在本章中,我们将揭示这种疯狂背后的逻辑。我们将从解码正则表达式的含义开始,然后看看这如何在 Java 中派上用场。
解码正则表达式
正则表达式的构建遵循一些基本规则,这些规则在所有平台和实现中都是相同的;然而,有些实现特定的规则可能会根据正则表达式构建的平台和实现而有所不同。
让我们回顾一下最初的电子邮件模式匹配表达式 /.+\@.+\..+/。我们可以看到它以一个斜杠标记开始,像这样 /,并以一个斜杠结束。这些是表达式的开始和结束标记;这些字符之间的任何内容都属于实际的表达式。
正则表达式由几个基本组件构成;它们是字符类、锚点、组和特殊转义字符。然后,我们有量词,它控制应该匹配前面多少个字符。最后但同样重要的是,我们有表达式标志,它控制整个表达式的某些行为。让我们在接下来的章节中更详细地看看它们。
字符类
字符类定义了模式匹配器将搜索的字符集。集是在方括号中定义的。
表达式 [xyz] 将匹配一个 x、a y 或一个 z。这些是区分大小写的,所以 X 不会匹配。如果你要匹配按字母顺序排列的字符,你可以用范围来替换表达式。而不是 [xyz],你可以写成 [x-z]。如果你想在表达式中覆盖很多字符,这非常方便:

](https://github.com/OpenDocCN/freelearn-java-zh/raw/master/docs/java-ws/img/C13927_12_02.jpg)
图 12.1:字符类的正则表达式
同时也存在预定义的字符类。这些允许你搜索特定的字符集,而无需输入完整的字符集。例如,前面提到的点(.)将匹配除换行符之外的任何字符。如果完整地写出作为一个集合,这个搜索的表达式看起来会是 [^\n\r],所以你可以看到仅使用 . 是更快更简单的。你可以在以下表中看到 ^、\n 和 \r 符号代表什么。
你也可以使用否定集进行搜索。这将匹配不属于该集的任何内容。
字符集
字符集匹配集合中定义的任何字符。以下图示展示了几个示例:

](https://github.com/OpenDocCN/freelearn-java-zh/raw/master/docs/java-ws/img/C13927_12_01.jpg)
图 12.2:字符集的正则表达式
预定义字符集帮助你快速构建表达式。以下图列出了预定义字符集,这对于构建快速表达式很有用:

](https://github.com/OpenDocCN/freelearn-java-zh/raw/master/docs/java-ws/img/C13927_12_03.jpg)
图 12.3:预定义字符集的正则表达式
量词
量词是简单的规则,允许你定义前面的字符集应该如何匹配。是否只允许一个字符,或者一个介于一个和三个之间的范围?请参见以下图示以了解可接受的量词:

](https://github.com/OpenDocCN/freelearn-java-zh/raw/master/docs/java-ws/img/C13927_12_02.jpg)
图 12.4:量词的正则表达式
锚点
锚点为你提供了一个额外的控制维度,这样你就可以定义文本的边界而不是文本本身:

](https://github.com/OpenDocCN/freelearn-java-zh/raw/master/docs/java-ws/img/C13927_12_05.jpg)
图 12.5:锚点的正则表达式
捕获组
捕获组允许你在表达式中对标记进行分组以形成子字符串。任何捕获标记都可以在组中使用,包括嵌套其他组。它们还允许在表达式中使用引用进行重用:

图 12.6:捕获组的正则表达式
转义字符
你可以使用反斜杠字符\来转义字符以在字符串中匹配它们。这对于匹配序列化数据(如 XML 和 JSON)非常有用。它也用于匹配非文本字符,如制表符和换行符。
这里有一些常见的转义字符:

图 12.7:转义字符的正则表达式
标志
直接放置在结束标记之后的任何字符都称为标志。有五个标志,你可以以任何方式组合它们,尽管你可以完全避免使用标志。

图 12.8:标志的正则表达式
现在你已经基本了解了这些正则表达式的工作原理,让我们在下面的练习中看看一个完整的示例。
练习 1:实现正则表达式
使用在线正则表达式检查器,我们将构建一个正则表达式来验证街道地址是否正确指定。地址遵循的格式是街道名称后跟街道号码。街道名称和街道号码之间用一个空格分隔。
我们将检查以下常见的瑞典地址是否有效:
-
Strandvagen 1
-
Storgatan 2
-
Ringvagen3
-
Storgatan
注意:
我们将使用
packt.live/2MYzyFq进行此练习,因为它具有易于使用的界面和现代感。然而,正则表达式也应该在其他平台上工作。
为了完成练习,请执行以下步骤:
-
在标题“文本”下的空间中输入你选择的三个不同的本地地址,至少有一个地址格式不正确。我选择的地址是
Strandvagen 1、Storgatan 2和Ringvagen3。这些都是瑞典非常常见的街道名称,最后一个地址格式不正确,因为它在街道名称和号码之间缺少空格。![图 12.9:输入格式不正确的文本]()
图 12.9:输入格式不正确的文本
从我们定义的简单规则中,我们可以提取以下内容:
街道地址必须以名称开头
街道地址应该有一个号码
-
添加第一条规则。名称是一个仅包含字母的单词(即只包含字母):
![图 12.10:添加第一条规则]()
图 12.10:添加第一条规则
-
在数字和数字之间,最多只能有一个空格。我们已经开始看到有一个地址格式不正确:
![图 12.11:修改规则以考虑数字和数字之间有一个空格]()
图 12.11:修改规则以考虑数字和数字之间有一个空格
-
至少在地址中添加一个数字。现在又有一个地址消失了:

图 12.12:修改规则以在地址中添加一个数字
此示例展示了一个简单的构建正则表达式以验证地址的过程。
活动一:使用正则表达式检查入口是否以期望的格式输入
在前面的正则表达式中添加一条新规则;允许在数字后面有一个可选字符。这将定义在地址有多个入口时使用哪个入口——例如,Strandvagen 1a或Ringvagen 2b。
注意
此活动的解决方案可以在第 560 页找到。
Java 中的正则表达式
现在你已经了解了正则表达式如何用于匹配模式,这个主题将重点介绍如何在 Java 应用程序中使用正则表达式。要在 Java 中使用正则表达式,可以使用java.util.regex包。那里有两个主要的类,分别称为Pattern和Matcher。
Pattern类处理实际的模式;它验证、编译并返回一个可以存储和多次重用的Pattern对象。它还可以用于对提供的字符串进行快速验证。
Matcher类允许我们提取更多信息,并在提供的文本上执行不同类型的匹配。
创建一个Pattern对象就像使用静态的compile方法一样简单。
例如,你可能想编译一个模式以确保文本中至少有一个a。你的 Java 代码应该是这样的:
Pattern pattern = Pattern.compile("a+");
Matcher matcher = pattern.matcher("How much wood would a woodchuck chuck if a woodchuck could chuck wood?");
Boolean matches = matcher.matches();
注意
在 Java 中,我们不应该提供正则表达式的起始和结束标记。有了Pattern对象,然后你可以在给定的字符串上执行匹配。
注意,此方法将尝试将整个字符串与正则表达式匹配;如果只有字符串的一部分与正则表达式匹配,它将返回 false。
如果你只想进行快速验证,可以使用静态的matches方法,它将返回一个布尔值;它只是上一个示例的简写:
boolean matches = Pattern.matches("a+", "How much wood would a woodchuck chuck if a woodchuck could chuck wood?");
练习 2:使用模式匹配提取域名
在这个练习中,你将提取 URL 的每个部分并将它们存储在变量中,从协议开始,然后是域名,最后是路径:
-
如果 IntelliJ IDEA 已经启动,但没有打开任何项目,请选择
创建新项目。如果 IntelliJ 已经打开了一个项目,请从菜单中选择文件->新建->项目。 -
在
新建项目对话框中,选择一个 Java 项目。点击下一步。 -
打开复选框以从模板创建项目。选择
命令行应用程序。点击下一步。 -
给新项目命名为
Chapter12。 -
IntelliJ 将提供默认的项目位置。你也可以输入任何其他希望的位置。
-
将包名设置为
com.packt.java.chapter12。 -
点击
完成。你的项目将以标准文件夹结构创建,并包含程序的入口点类。 -
将此文件重命名为
Exercise2.java。完成时,它应该看起来像这样:package com.packt.java.chapter12; public class Exercise2 { public static void main(String[] args) { // write your code here } } -
声明这本书的网站
url,我们将将其分割成单独的部分。如果你还没有访问过该网站,你可以在www.packtpub.com/application-development/mastering-java-9找到它:package com.packt.java.chapter12; public class Exercise2 { public static void main(String[] args) { String url = "https://www.packtpub.com/application- development/mastering-java-9"; } } -
我们将首先使用正则表达式找到协议。声明一个字符串来保存正则表达式,并将其命名为
regex。它应该至少包含字母http和一个可选的s。将整个表达式包裹在一个组中,以确保你可以在稍后将其提取为子字符串:package com.packt.java.chapter12; public class Exercise2 { public static void main(String[] args) { String url = "https://www.packtpub.com/application-development/mastering-java-9"; String regex = "(http[s]?)"; } }注意
这当然只是提取协议的一个例子。你可以尝试找到第一个冒号之前或其它有趣的字符串。
-
将表达式编译成
pattern对象。由于我们不是进行全局匹配,所以不会使用简写。相反,我们将创建Matcher以供以后使用:package com.packt.java.chapter12; import java.util.regex.Matcher; import java.util.regex.Pattern; public class Exercise2 { public static void main(String[] args) { String url = "https://www.packtpub.com/application-development/ mastering-java-9"; String regex = "(http[s]?)"; Pattern pattern = Pattern.compile(regex); Matcher matcher = pattern.matcher(url); } } -
尝试使用
find()方法找到第一个组:package com.packt.java.chapter12; import java.util.regex.Matcher; import java.util.regex.Pattern; public class Exercise2 { public static void main(String[] args) { String url = "https://www.packtpub.com/application development/mastering-java-9"; String regex = "(http[s]?)"; Pattern pattern = Pattern.compile(regex); Matcher matcher = pattern.matcher(url); boolean foundMatches = matcher.find(); } }注意
你可以使用
groupCount()方法找到可用的组数。如果你想要按顺序遍历所有组,这将非常有用。 -
如果找到了匹配项,开始将组提取到变量中。目前,只需简单地打印变量:
String url = "https://www.packtpub.com/application- development/mastering-java-9"; String regex = "(http[s]?)"; Pattern pattern = Pattern.compile(regex); Matcher matcher = pattern.matcher(url); boolean foundMatches = matcher.find(); if (foundMatches) { String protocol = matcher.group(1); System.out.println("Protocol: " + protocol); } } } -
在捕获域名之前,我们需要忽略域名和协议之间的无用字符——
://。为这些字符添加一个非捕获组:String url = "https://www.packtpub.com/application- development/mastering-java-9"; String regex = "(http[s])(?:://)"; Pattern pattern = Pattern.compile(regex); Matcher matcher = pattern.matcher(url); boolean foundMatches = matcher.find(); if (foundMatches) { String protocol = matcher.group(1); System.out.println("Protocol: " + protocol); } } } -
现在,在正则表达式中添加第三个组来查找域名。我们将尝试找到整个域名,让
www应用标记是可选的:String regex = "(http[s])(?:://)([w]{0,3}\\.?[a-zA-Z]+\\.[a-zA- Z]{2,3})"; Pattern pattern = Pattern.compile(regex); Matcher matcher = pattern.matcher(url); boolean foundMatches = matcher.find(); if (foundMatches) { String protocol = matcher.group(1); System.out.println("Protocol: " + protocol); } } } -
现在,收集域名组并打印出来:
String regex = "(http[s])(?:://)([w]{0,3}\\.?[a-zA-Z]+\\.[a-zA- Z]{2,3})"; Pattern pattern = Pattern.compile(regex); Matcher matcher = pattern.matcher(url); boolean foundMatches = matcher.find(); if (foundMatches) { String protocol = matcher.group(1); String domain = matcher.group(2); System.out.println("Protocol: " + protocol); System.out.println("domain: " + domain); } } } -
最后,提取
path组件并将它们打印到终端:
Exercise2.java
1 package com.packt.java.chapter12;
2
3 import java.util.regex.Matcher;
4 import java.util.regex.Pattern;
5
6 public class Exercise2 {
7
8 public static void main(String[] args) {
9
10 String url = "https://www.packtpub.com/application- development/mastering-java-9";
11
12 String regex = "(http[s])(?:://)([w]{0,3}\\.?[a-zA-Z]+\\.[a-zA- Z]{2,3})(?:[/])(.*)";
13
14 System.out.println(regex);
https://packt.live/2J4qn57
当运行这个练习时,你应该在终端看到以下文本:
(http[s])(?:://)([w]{0,3}\.?[a-zA-Z]+\.[a-zA-Z]{2,3})(?:[/])(.*)
Protocol: https
domain: www.packtpub.com
Path: application-development/mastering-java-9
这个例子展示了如何使用捕获组从一个小字符串中提取关键信息。然而,你会注意到匹配操作只进行了一次。在 Java 中,使用类似的技术,对大量文本进行递归匹配很容易。
练习 3:使用模式匹配提取链接
在这个练习中,你将对 Packt 网站进行递归匹配以提取所有链接,然后在终端打印这些链接。为了简单起见,我们将使用已保存的 Packt 网站快照;当然,你也可以根据自己的平台使用 curl、wget 或类似工具下载网站。你还可以在你的浏览器中查看网站的源代码并将其复制到文件中。
-
如果尚未打开,请打开 IntelliJ IDEA 中的
Chapter12项目。 -
通过访问
File->New->Java Class创建一个新的 Java 类。 -
将名称输入为
Exercise 3并点击OK。IntelliJ IDEA 将创建一个新的类,其外观可能如下所示:package com.packt.java.chapter12; public class Exercise3 { } -
为你的程序创建主入口点——
staticmain方法:public class Exercise3 { public static void main(String[] args) { } } -
将 Packt 网站的数据包复制到你的项目的
res文件夹中。如果该文件夹不存在,则将其创建为src的兄弟文件夹。 -
将文件内容读取到新的字符串中;命名为
packtDump:public class Exercise3 { public static void main(String[] args) { String filePath = System.getProperty("user.dir") + File.separator +"res" + File.separator + "packt.txt"; try { String packtDump = new String(Files.readAllBytes(Paths.get(filePath))); } catch (IOException e) { e.printStackTrace(); } } } -
开始创建用于从网站捕获链接的正则表达式。它们通常看起来像这样。我们需要寻找链接的开始和结束标记,并捕获两者之间的任何内容:
<a href="http://link.to/website">visible text</a>首先寻找开标记,
"<a href=\":String filePath = System.getProperty("user.dir") + File.separator +"res" + File.separator + "packt.txt"; try { String packtDump = new String(Files.readAllBytes(Paths.get(filePath))); String regex = "(?:<a href=\")"; } catch (IOException e) { e.printStackTrace(); } } } -
为结束标记添加另一个非捕获组。链接以下一个双引号实例结束(
"):String filePath = System.getProperty("user.dir") + File.separator +"res" + File.separator + "packt.txt"; try { String packtDump = new String(Files.readAllBytes(Paths.get(filePath))); String regex = "(?:<a href=\")(?:\"{1})"; } catch (IOException e) { e.printStackTrace(); } } } -
最后,添加这个正则表达式所需的唯一捕获组——链接组:
String filePath = System.getProperty("user.dir") + File.separator + "res" + File.separator + "packt.txt"; try { String packtDump = new String(Files.readAllBytes(Paths.get(filePath))); String regex = "(?:<a href=\")([^\"]+)(?:\"{1})"; } catch (IOException e) { e.printStackTrace(); } } } -
编译模式并将其与
packtDump字符串匹配:String filePath = System.getProperty("user.dir") + File.separator + "res" + File.separator + "packt.txt"; try { String packtDump = new String(Files.readAllBytes(Paths.get(filePath))); String regex = "(?:<a href=\")([^\"]+)(?:\"{1})"; Pattern pattern = Pattern.compile(regex); Matcher matcher = pattern.matcher(packtDump); } catch (IOException e) { e.printStackTrace(); } } } -
创建一个用于存储链接的列表:
String regex = "(?:<a href=\")([^\"]+)(?:\"{1})"; Pattern pattern = Pattern.compile(regex); Matcher matcher = pattern.matcher(packtDump); List<String> links = new ArrayList<>(); } catch (IOException e) { e.printStackTrace(); } } } -
最后,遍历所有匹配项并将它们添加到列表中。这里我们只有一个捕获组,因此没有必要检查组数并遍历它们:
String regex = "(?:<a href=\")([^\"]+)(?:\"{1})"; Pattern pattern = Pattern.compile(regex); Matcher matcher = pattern.matcher(packtDump); List<String> links = new ArrayList<>(); while (matcher.find()) { links.add(matcher.group(1)); } } catch (IOException e) { e.printStackTrace(); } } } -
现在,你可以通过将列表打印到终端来结束练习:
Exercise3.java
12 public class Exercise3 {
13
14 public static void main(String[] args) {
15 String filePath = System.getProperty("user.dir") + File.separator + "res" + File.separator + "packt.txt";
16 try {
17 String packtDump = new String(Files.readAllBytes(Paths.get(filePath)));
18 String regex = "(?:<a href=\")([^\"]+)(?:\"{1})";
19 Pattern pattern = Pattern.compile(regex);
20 Matcher matcher = pattern.matcher(packtDump);
21 List<String> links = new ArrayList<>();
22 while (matcher.find()) {
23 links.add(matcher.group(1));
24 }
25 System.out.println(links);
https://packt.live/35OorYo
执行此练习时,你应该在终端看到一个包含相对和绝对链接的长列表。
[/account, #, /register, https://account.packtpub.com/, https://www.packtpub.com/account/password, #, /, /all, /tech, /, /books/content/support, https://hub.packtpub.com, ... ]
你已成功从 Packt 网站提取了链接。现实世界的应用可能使用此功能来构建网站地图或以其他方式记录网站之间的相互连接。这个程序的下一步完全取决于你。以下步骤将帮助你更彻底地分析 Packt 网站的内容:
-
删除任何非函数式链接,例如
#和指向home /的链接。 -
此外,删除所有以
http开头的链接;只保留相对链接。 -
相对链接的第一个路径代表该书的类别。将网站上的书籍分为不同的类别,并查看哪个类别最受欢迎。
摘要
在本章中,你学习了如何使用正则表达式从大量文本中搜索和提取信息。这在解析结构化或半结构化数据时非常有用。正则表达式并非特定于 Java。Java 实现可能与其他平台和语言略有不同;然而,通用语法保持不变。
在下一章中,你将探索一个日益流行的编程范式。虽然函数式编程最初并非为 Java 设计,但它可以帮助你编写更容易测试的程序,这可能会限制状态改变问题的数量。
第十三章:13. 使用 Lambda 表达式进行函数式编程
概述
本章讨论了 Java 如何成为函数式编程语言。它还详细说明了在 Java 中如何使用 lambda 表达式进行模式匹配。它首先通过一般性地解释面向对象编程(OOP)和函数式编程(FP)之间的区别来实现这一点。然后,你将学习纯函数的基本定义,以及函数式接口和普通接口之间的区别。最后,你将练习使用 lambda 表达式作为回调事件,并使用它们来过滤数据。
简介
虽然 Java 已经存在了 20 多年,而函数式编程(FP)甚至比 Java 还要长,但直到最近,FP 这个话题才在 Java 社区中引起关注。这可能是由于 Java 本质上是一种命令式编程语言;当学习 Java 时,你学习的是面向对象编程(OOP)。
然而,在过去的几年里,主流编程社区的动向已经更多地转向了 FP。如今,你可以在每个平台上看到这一点——从网络到移动再到服务器。FP 概念无处不在。
背景
尽管在 Java 中是一个相对较新的话题,但函数式编程(FP)已经存在很长时间了。事实上,它甚至比第一台个人电脑还要早;它的起源可以追溯到 20 世纪 30 年代 Alonzo Church 创建的 lambda 演算研究。
“lambda”这个名字来源于希腊符号,这是丘奇在描述他的 lambda 演算的规则和数学函数时决定使用的符号。
lambda 身份函数非常简单,就是一个返回输入参数的函数——即,身份。在一个更正常的数学脚本中。
如你所见,lambda 演算是一种用于表达数学方程的简单方法。然而,它不一定是数学性的。在其最纯粹的形式中,它是一个具有一个参数和发生算术运算的体的函数。在 lambda 演算中,函数是一等公民——这意味着它可以像任何其他变量一样被对待。如果你需要在函数中具有多个属性,你甚至可以组合多个 lambda。
函数式编程
函数式编程(FP)归结为两件事:副作用和确定性。这些概念构成了我们所说的 FP 的基础,它们也是新来者在这个范式中最容易掌握的两个概念,因为它们没有引入新的、复杂的模式。
副作用
当编写程序时,我们常常努力获得某种形式的副作用——一个没有副作用的程序是一个非常无聊的程序,因为什么都不会发生。然而,当试图可靠地测试程序时,副作用也是一个常见的头痛问题,因为其状态可能会不可预测地改变。
Java 中一个非常实用的类是 Math 类;它包含各种数学辅助工具,并且很可能会在所有 Java 应用程序中直接或间接地被使用。以下是一个将伪随机数打印到控制台的示例:
public static void main(String[] args) {
System.out.println(Math.random());
}
如果我们深入研究 Math.java 的代码并回顾 random() 函数的细节,我们会注意到它使用了一个不属于 random() 函数的 randomNumberGenerator 对象:
public final class Math {
public static double random() {
return RandomNumberGeneratorHolder.randomNumberGenerator.nextDouble();
}
}
它还调用了 randomNumberGenerator 对象的 nextDouble() 方法。这就是我们所说的副作用;random 函数超出其自身的范围,或主体,并对其他变量或类进行更改。这些变量反过来又可能被其他函数或对象调用,这些函数或对象可能会也可能不会产生自己的副作用。当你试图以 FP 风格实现程序时,这种行为是一个红旗,因为它不可预测。它也可能更难在多线程环境中安全使用。
注意
Math.random() 函数按照设计提供不可预测的结果。然而,作为一个例子,它很好地为我们突出了副作用的概念。random 函数在多线程环境中(大部分情况下)也是安全的——Sun 和 Oracle 已经完成了他们的作业!
由于 Math.random() 函数对于相同的参数会产生不同的结果,因此它被定义为非确定性函数。
确定性函数
确定性函数被定义为对于相同的参数,无论执行多少次或何时执行,都会始终产生相同结果的函数:
public static void main(String[] args) {
System.out.println(Math.random());
System.out.println(Math.random());
}
在这个例子中,Math.random() 被调用了两次,并且总是会打印出两个不同的值到终端。无论你调用多少次 Math.random(),它总是会给出不同的结果——因为按照设计,它不是确定性的:
public static void main(String[] args) {
System.out.println(Math.toRadians(180));
System.out.println(Math.toRadians(180));
}
运行这段简单的代码,我们可以看到 Math.toRadians() 函数对两个函数都会给出相同的结果,并且似乎不会改变程序中的其他任何东西。这是一个提示,表明它是确定性的——让我们深入研究这个函数并回顾它:
public final class Math {
private static final double DEGREES_TO_RADIANS = 0.017453292519943295;
public static double toRadians(double angdeg) {
return angdeg * DEGREES_TO_RADIANS;
}
}
如预期,该函数不会从外部世界改变任何东西,并且总是产生相同的结果。这意味着我们可以将其视为确定性函数。然而,它确实读取一个存在于函数作用域之外的常量;这是我们所说的纯函数的一个边缘情况。
纯函数
最纯的函数可以被认为是黑盒,这意味着函数内部发生的事情对程序员来说并不真正感兴趣。他们只对放入盒子的内容以及作为结果从盒子里出来的内容感兴趣——因为纯函数总是会有一个结果。
纯函数接受参数并根据这些参数产生结果。纯函数永远不会改变外部世界的状态或依赖它。函数所需的所有内容都应该在函数内部可用,或者作为输入传递给它。
练习 1:编写纯函数
一家杂货店有一个管理系统来管理他们的库存;然而,构建他们软件的公司已经破产,并且丢失了他们系统的所有源代码。这是一个只允许客户一次购买一件东西的系统。因为他们的客户希望一次购买两件东西,不多也不少,所以他们要求你实现一个函数,该函数接受两种产品的价格并返回这两个价格的总额。他们希望你在不造成任何副作用或与当前系统不兼容的情况下实现这一点。你将作为一个纯函数来实现它:
-
如果 IntelliJ 已经启动但没有打开项目,那么请选择
创建新项目。如果 IntelliJ 已经打开了一个项目,那么请从菜单中选择文件->新建->项目。 -
在
新建项目对话框中,选择Java 项目。然后,点击下一步。 -
打开复选框以从模板创建项目。选择
命令行应用程序。然后,点击下一步。 -
给新项目命名为
Exercise1。 -
IntelliJ 会为你提供一个默认的项目位置;如果你希望选择一个,可以在这里输入。
-
将包名设置为
com.packt.java.chapter13。 -
点击
完成。IntelliJ 将创建你的项目,命名为Exercise1,并使用标准的文件夹结构。IntelliJ 还将创建你的应用程序的主入口点,命名为Main.java;它将类似于以下代码片段:package com.packt.java.chapter13; public class Main { public static void main(String[] args) { // write your code here } } -
将
Main.java重命名为Exercise1.java。 -
在
Main类中创建一个新的函数,将其放置在main(String[] args)函数之下。将新函数命名为sum并让它返回一个整数值。这个函数应该接受两个整数作为输入。为了代码的简洁性,我们将这个函数做成一个static工具函数:package com.packt.java.chapter13; public class Exercise1 { public static void main(String[] args) { // write your code here } static int sum(int price1, int price2) { } } -
这个函数应该做的只是返回两个参数——
price1和price2的总和:package com.packt.java.chapter13; public class Exercise1 { public static void main(String[] args) { // write your code here } static int sum(int price1, int price2) { return price1 + price2; } } -
在你的
main函数中使用相同的参数调用这个新方法几次:package com.packt.java.chapter13; public class Exercise1 { public static void main(String[] args) { System.out.println(sum(2, 3)); System.out.println(sum(2, 3)); System.out.println(sum(2, 3)); } static int sum(int price1, int price2) { return price1 + price2; } } -
现在运行你的程序并观察输出。
备注
System.out.println()方法被许多人视为不纯函数,因为它操作终端——当然,这是“外部世界”,因为在调用堆栈的某个点上,函数将超出其范围来操作一个OutputStream实例。
你刚才编写的函数接受两个参数并产生一个全新的输出,而不修改函数范围之外的内容。通过这一点,你已经成功迈出了编写更函数式应用程序的第一步。
在编写函数式程序时,另一个重要的考虑因素是如何处理应用程序中的状态。在面向对象编程(OOP)中,我们通过使用分而治之的策略来解决大型应用程序中状态处理的问题。在这里,应用程序中的每个对象都包含整个应用程序状态的一小部分。
这种类型状态处理的隐含属性是状态的拥有权和可变性。每个对象通常都有一个私有状态,可以通过公共接口——对象的方法来访问——即对象的方法。例如,如果我们回顾 OpenJDK 源代码中的ParseException.java类,我们也会找到这个模式:
package java.text;
public class ParseException extends Exception {
private static final long serialVersionUID = 2703218443322787634L;
public ParseException(String s, int errorOffset) {
super(s);
this.errorOffset = errorOffset;
}
public int getErrorOffset() {
return errorOffset;
}
private int errorOffset;
}
在这里,我们可以看到一个名为errorOffset的私有成员变量。这个成员变量可以从构造函数中写入,并且可以通过getErrorOffest()方法被其他对象访问。我们还可以想象一个具有另一个更改errorOffset值的方法的类——即一个 setter。
使用这种状态处理方法可能存在的一个问题是多线程应用程序。如果有两个或更多线程要读取或写入这个成员变量,我们通常会看到不可预测的变化。当然,在 Java 中,我们可以通过使用同步来修复这些变化。然而,同步是有代价的;准确规划访问很复杂,而且我们经常遇到竞态条件。在任何支持它的语言中,这也是一个相当昂贵的程序。
注意
使用同步非常流行,并且是构建多线程应用程序的一种安全方式。然而,同步的一个缺点——除了它非常昂贵之外——是它实际上使我们的应用程序表现得像一个单线程应用程序,因为所有访问同步数据的线程都必须等待它们的轮次来处理数据。
在 FP 中,我们试图避免使用同步,而是说我们的状态应该是始终不可变的——有效地消除了同步的需要。
状态不可变性
当状态是不可变的时候,本质上意味着它永远不能改变。在 FP 中,有一种常见的写法来描述这个规则,大致是这样的:替换你的数据而不是就地编辑它。
如我们在第三章面向对象编程中讨论的,OOP 的一个核心概念是继承;即创建子类的能力,这些子类基于或继承父类中已经存在的功能,但也可以向子类添加新功能。在函数式编程(FP)中,这变得相对复杂,因为我们针对的是永远不会改变的数据。
在 Java 中使数据不可变的最简单方法是通过使用final关键字。在 Java 中有三种使用final关键字的方法:锁定变量以更改,使方法无法被覆盖,以及使类无法扩展。当在 Java 中构建不可变数据结构时,仅仅使用这些方法中的任何一个通常是不够的;我们需要使用两个或有时甚至所有三个。
练习 2:创建一个不可变类
一个当地木匠在你的街道上开设了商店,并要求你为他们构建一个简单的购物车应用程序的存储机制,他们将内部使用这个应用程序来处理订购家具的人。该应用程序应该能够安全地处理来自不同线程的多人同时编辑。销售人员将通过电话接收订单,木匠将编辑花费的小时数和使用的材料。购物车必须是不可变的。为此,执行以下步骤:
-
在 IntelliJ 的
Project面板中,右键单击名为src的文件夹。 -
在菜单中选择
New->Java Class,并输入Exercise2。 -
在你的新类中定义
main方法:package com.packt.java.chapter13; public class Exercise2 { public static void main(String[] args) { } } -
创建一个新的内部类
ShoppingCart,并将其设置为final以确保它不能被扩展或改变其行为。你的代码现在可能看起来像这样:package com.packt.java.chapter13; public class Exercise2 { public static void main(String[] args) { } public static final class ShoppingCart { } } -
我们还需要将项目放入这个购物车中,因此为
ShoppingItem创建一个简单的数据对象,给它一个名称和价格属性,然后使这个类不可变。我们稍后会使用这个类来实例化几个不同的对象,以测试我们ShoppingCart类的可变性:public static final class ShoppingCart{ } private static final class ShoppingItem { private final String name; private final int price; public ShoppingItem(String name, int price) { this.name = name; this.price = price; } } -
添加一个列表,我们将在这个不可变购物车中保存所有项目。确保使用
final关键字声明这个列表,使其不可更改:package com.packt.java.chapter13; import java.util.ArrayList; import java.util.List; public class Exercise2 { public static final class ShoppingCart{ private final List<ShoppingItem> mShoppingList = new ArrayList<>(); } }现在我们有了一种为我们的客户创建购买项目的方法,我们也为我们的客户提供了一个放置所选项目的袋子。然而,我们缺少一种让我们的客户能够将项目添加到购物车中的方法。
-
在面向对象的方法中解决这个问题时,我们可以添加一个名为
addItem(ShoppingItem shoppingItem)的方法:package com.packt.java.chapter13; import java.util.ArrayList; import java.util.List; public class Exercise2 { private final class ShoppingCart{ private final List<ShoppingItem> mShoppingList = new ArrayList<>(); public void addItem(ShoppingItem item) { mShoppingList.add(item); } } }从函数式编程的角度来看这个解决方案,我们可以看到它将修改集合。这是我们极力避免的事情,因为多人将同时在这个购物车上工作。在这种情况下,使用
final关键字没有影响,因为 final 列表的内容仍然可以改变。解决这个问题的基本方法之一是在添加项目时返回一个新的ShoppingCart对象。 -
向
ShoppingCart类添加一个新的构造函数,并让它接受一个列表作为参数。然后,将这个列表传递给ShoppingCart类的mShoppingList,并使用Collections.unmodifiableList()方法使其不可修改:package com.packt.java.chapter13; import java.util.ArrayList; import java.util.Collections; import java.util.List; public class Exercise2 { public static final class ShoppingCart{ public final List<ShoppingItem> mShoppingList; public ShoppingCart(List<ShoppingItem> list) { mShoppingList = Collections.unmodifiableList(list); } public void addItem(ShoppingItem item) { mShoppingList.add(item); } } } -
重写
addItem(ShoppingItem item)方法,让它返回一个新的ShoppingCart项目而不是void。将上一个ShoppingCart项目的列表复制到一个临时列表中,并添加另一个项目。然后,将这个临时列表传递给构造函数,并返回新创建的ShoppingCart对象:public static final class ShoppingCart{ public final List<ShoppingItem> mShoppingList; public ShoppingCart(List<ShoppingItem> list) { mShoppingList = Collections.unmodifiableList(list); } public ShoppingCart addItem(ShoppingItem item) { List<ShoppingItem> newList = new ArrayList<>(mShoppingList); newList.add(item); return new ShoppingCart(newList); } }在此代码中,我们可以看到构造函数现在接受
ShoppingItem类的一个列表;我们还可以看到列表被直接保存为一个不可修改的列表。这是 Java 中的一种特殊列表——在您尝试以任何方式直接或通过其迭代器修改它时,它会抛出异常。我们还可以看到,
addItem(ShoppingItem item)函数现在返回一个新的ShoppingCart,包含全新的列表,但两个ShoppingCart实例之间共享了之前的购物列表中的项目。这对于多线程环境来说是一个可接受的解决方案,因为ShoppingItem类是最终的,因此它们的状态可能永远不会改变。注意
Java 8 引入了 Stream API,这是一种全新的处理集合的方式,即更基于函数式编程的方法。您可以在第十五章“使用 Stream 处理数据”中了解更多关于 Stream API 的信息。在本章中,我们将关注不使用 Stream API 的解决方案。
-
现在您需要在程序中使用这个新的
ShoppingCart。编辑您的main方法,然后先创建一个空的ShoppingCart。然后,向该购物车添加一个新的购物项,并将新创建的ShoppingCart存储在另一个变量中。最后,向第二个ShoppingCart添加另一个ShoppingItem,再次将新的ShoppingCart存储在新的变量中:Exercise2.java 7 public class Exercise2 { 8 9 public static void main(String[] args) { 10 ShoppingCart myFirstCart = new ShoppingCart(new ArrayList<ShoppingItem>()); 11 ShoppingCart mySecondCart = myFirstCart.addItem(new ShoppingItem("Chair", 150)); 12 ShoppingCart myThirdCart = mySecondCart.addItem(new ShoppingItem("Table",350)); 13 } https://packt.live/2Jdr10l -
在最后一行设置断点并调试您的代码。您会注意到在调用
addItem时创建的购物车维护着自己的不可修改的ShoppingItem列表,但不可变的ShoppingItem在列表之间是共享的。
Collections.unmodifiableList()方法和其他类似方法(如Set、Map和SortedList)并没有为列表本身提供任何不可变性。它们产生了一个禁止任何更改的列表视图。然而,任何拥有实际列表引用的人仍然能够更改数据。
在这个练习中,列表是安全的,因为main方法没有保留任何对列表的引用,所以没有人可以从外部更改它。然而,当尝试使用函数式方法实现程序时,这不是推荐的方法;除非他们必须严格遵循规则,否则不要相信任何人。自 Java 9 以来,现在有真正的不可变集合可用。
活动一:修改不可变列表
向您的ShoppingCart添加一个新的行为:
-
创建一个
removeItem(ShoppingItem)函数。 -
创建一个函数,该函数接受多个
ShoppingItem作为参数,可以是列表或可变参数。 -
修改您的
ShoppingCart以接受每个ShoppingItem的多个项目——例如,四把椅子和一张桌子。此外,修改addItem(ShoppingItem)和removeItem(ShoppingItem)函数。注意
此活动的解决方案可在第 561 页找到。
不可变集合
使用 Collections.unmodifiableList 是提供现有列表不可修改版本的一种快速方法。自 Java 9 以来,另一个选项是使用具有工厂方法的不可变集合。这些工厂方法允许您创建三种不同的不可变集合类型:List、Set 和 Map。
注意
有几个库提供了更优化的不可变集合;一个流行的例子是 Guava,它有 ImmutableArrayList 和其他类型。
如果我们使用 List 工厂方法而不是 Collections 类来处理购物车,它可能看起来像这样:
public class Main {
public static final class ShoppingCart {
public final List<ShoppingItem> mShoppingList;
public ShoppingCart(List<ShoppingItem> list) {
mShoppingList = List.copyOf(list);
}
public ShoppingCart addItem(ShoppingItem item) {
List<ShoppingItem> newList = new ArrayList<>(mShoppingList);
newList.add(item);
return new ShoppingCart(newList);
}
}
}
在这里,我们可以看到与我们之前所拥有的几乎没有区别。我们不是使用 Collections.unmodifiableList() 来创建列表的不修改视图,而是使用 List.copyOf() 创建此列表的不可变副本。在我们的例子中,对用户来说这种差异是看不见的。然而,在底层,它们基于不同的实现——分别是 UnmodifiableCollection 和 ImmutableCollections 类。
练习 3:重写 String 方法
在这个练习中,我们将做一个小的技术证明,说明 UnmodifiableCollection 和 ImmutableCollection 类之间的差异。为此,我们需要重写 ShoppingItem 和 ShoppingCart 类的 toString() 方法:
-
将
toString()方法添加到ShoppingItem类中,然后让它返回名称:private static final class ShoppingItem { @Override public String toString() { return name + ", " + price; } } -
将
toString()方法添加到ShoppingCart类中。然后,让它返回列表中所有ShoppingItem的连接字符串:public static final class ShoppingCart { public String toString() { StringBuilder sb = new StringBuilder("Cart: "); for (int i = 0; i < mShoppingList.size(); i++) { sb.append(mShoppingList.get(i)).append(", "); } return sb.toString(); } } -
现在我们有一个简单的方法来使用
toString()方法打印ShoppingCart的内容。为了展示差异,替换main方法中的代码。向标准列表中添加几本书,然后将此列表复制到一个不可修改的版本和一个不可变版本。打印这两个副本:public static void main(String[] args) { List<ShoppingItem> books = new ArrayList<>(); books.add(new ShoppingItem("Java Fundamentals", 100)); books.add(new ShoppingItem("Java 11 Quick Start", 200)); List<ShoppingItem> immutableCopy = List.copyOf(books); List<ShoppingItem> unmodifiableCopy = Collections.unmodifiableList(books); System.out.println(immutableCopy); System.out.println(unmodifiableCopy); } -
现在从原始的
books列表中移除第一个项目,即《Java 基础知识》这本书,然后再次打印这两个副本:public static void main(String[] args) { List<ShoppingItem> books = new ArrayList<>(); books.add(new ShoppingItem("Java Fundamentals", 100)); books.add(new ShoppingItem("Java 11 Quick Start", 200)); List<ShoppingItem> immutableCopy = List.copyOf(books); List<ShoppingItem> unmodifiableCopy = Collections.unmodifiableList(books); System.out.println(immutableCopy); System.out.println(unmodifiableCopy); books.remove(0); System.out.println(immutableCopy); System.out.println(unmodifiableCopy); }
这个简单的例子提供了不可修改视图和不可变副本之间差异的证明。在不可修改版本中,列表仍然可以被更改,并且不可修改视图将捕捉到这种更改,而不可变版本将忽略这种更改,因为它包含了一个新的项目列表。
功能接口
功能接口被声明为标准的 Java 接口,除了它们只能包含一个抽象函数,但可以包含任意数量的默认或静态函数。
Comparator 接口是 Java 中较老的接口之一。它自 1.2 版本以来一直伴随着我们,并且多年来经历了许多变化。然而,迄今为止最大的变化可能是 Java 8 中将其转变为功能接口。
回顾 Java 8 中 Comparator 接口的变化,你会注意到一些有趣的变化。首先,接口从 4 行代码增长到 80 行,不包括包声明和注释。然后,你会注意到顶部有一个新的注解:
@FunctionalInterface
这个注解标记表明这是一个功能接口。它的主要目的是告诉读者,这个接口的目的是遵循 Java 8 中定义的功能接口规范。如果它未能遵循这些指南,Java 编译器应该打印出一个错误。
在两个原始的抽象函数声明之后,你会发现至少有七个默认函数。这些默认函数是在 Java 8 中引入的,目的是在不破坏向后兼容性的情况下向接口添加新功能。默认函数始终是公共的,并且总是包含一个代码块。它们可以返回一个值,但这不是规范所要求的。
最后,我们将找到总共九个 static 函数。自从 Java 8 以来,函数式接口可以包含任意数量的 static 方法,它们与普通类中找到的静态方法非常相似。你将在本书的后续章节中了解更多关于构建和使用函数式接口的细节。
Lambda 表达式
随着 Java 8 中功能性的改进,也出现了 Lambda 表达式。Lambda 的一大主要改进是代码可读性——接口的大多数样板代码现在都不见了。
一个非常常用的接口是 Runnable 接口;它在多线程应用程序中用于在后台执行任何类型的任务,例如从网络下载大文件。在 Java 7 及更早版本中,你经常看到 Runnable 接口被用作匿名实例:
new Thread(new Runnable() {
@Override
public void run() {
}
}).start();
自从 Java 8 以来,前面的五行代码现在可以通过使用 lambda 表达式来简化:
new Thread(() -> {}).start();
如你所见,当我们移除大量样板代码时,代码的可读性变得更高。
Lambda 表达式由两个主要部分组成:参数和主体。此外,在这两个部分之间,始终有一个箭头操作符(也称为 lambda 操作符)。主体还包含可选的返回值。括号包含 lambda 表达式的可选参数。尽管它是一个函数式编程组件,但你仍然会想使用参数:
(int arg1, int arg2) -> { return arg1 + arg2; }
你也可以省略参数的类型,因为它们将由 lambda 表达式实现的函数式接口推断出来:
(arg1, arg2) -> { return arg1 + arg2; }
如果你只有一个参数,你可以省略括号:
arg1 -> { return arg1; }
然而,如果你的 lambda 没有参数,那么你必须包含括号:
() -> { return 5; }
然后是函数主体;如果你在 lambda 逻辑中有许多行代码,你必须使用花括号来包围主体:
(arg1, arg2) -> {
int sum = arg1 + arg2;
return sum;
}
然而,如果你只有一行代码,你可以省略花括号,并立即返回值:
(arg1, arg2) -> return arg1 + arg2;
最后,如果你只有一行代码,也可以省略 return 关键字:
(arg1, arg2) -> arg1 + arg2;
如果我们要在 Java 中编写 lambda 算法的恒等函数,假设我们有一个名为 Identity 的函数式接口,它看起来可能像这样:
Identity identity = x -> x;
一个常用的接口是 Comparator 接口,它用于几乎任何需要排序的对象,特别是在某些形式的集合中。
练习 4:列出备用轮胎
一支赛车队联系了你,希望你能组织他们的备用轮胎库存,因为它们现在一团糟。他们要求你编写一个应用程序,以按大小顺序显示可用的轮胎列表,从最大的轮胎开始。
要做到这一点,你需要构建一个实现 Comparator 函数式接口的 lambda 函数。为了参考,这是 Comparator 接口的基本视图,不包括默认和静态函数:
@FunctionalInterface
public interface Comparator<T> {
int compare(T o1, T o2);
}
-
在 IntelliJ 的
Project面板中,右键单击名为src的文件夹。 -
在菜单中选择
New->Java Class,然后输入Exercise4。 -
在你的新类中定义
main方法:package com.packt.java.chapter13; public class Exercise4 { public static void main(String[] args) { } } -
创建一个名为
Tire的新内部类。它应该有一个名为size的变量,表示轮胎的直径(单位为英寸)。确保将类和大小声明为final以符合 FP 指南:package com.packt.java.chapter13; public class Exercise4 { public static void main(String[] args) { } public static final class Tire { private final int size; } } -
创建
Tire构造函数,接受一个参数——size,并将其传递给成员变量。此外,重写toString()方法以打印轮胎的大小:public static void main(String[] args) { } public static final class Tire { private final int size; public Tire(int size) { this.size = size; } @Override public String toString() { return String.valueOf(size); } } } -
在你的
main方法中创建一个需要排序的轮胎列表:public static void main(String[] args) { List<Tire> tires = List.of( new Tire(17), new Tire(16), new Tire(18), new Tire(14), new Tire(15), new Tire(16)); } -
创建实际的 lambda 表达式,使用
Comparator函数式接口,这将用于对不可变轮胎列表进行排序。它应该接受两个参数,并返回大小差异。记住,lambda 表达式推断了很多结构;在这个简单的例子中,你不需要指定类型或返回关键字。lambda 表达式是一等公民,因此可以将其存储在变量中以供以后使用:public static void main(String[] args) { List<Tire> tires = List.of( new Tire(17), new Tire(16), new Tire(18), new Tire(14), new Tire(15), new Tire(16)); Comparator<Tire> sorter = (t1, t2) -> t2.size - t1.size; }注意
当然,你也可以将 lambda 表达式作为匿名实例应用——这样,你可以节省几行代码,同时保持代码的可读性。
-
在
sort方法中应用 lambda 表达式。List.sort()方法会修改列表的内容,因此排序之前需要复制你的不可变轮胎列表:public static void main(String[] args) { List<Tire> tires = List.of( new Tire(17), new Tire(16), new Tire(18), new Tire(14), new Tire(15), new Tire(16)); Comparator<Tire> sorter = (t1, t2) -> t2.size - t1.size; List<Tire> sorted = new ArrayList<>(tires); sorted.sort(sorter); } -
最后,打印结果:
public static void main(String[] args) { List<Tire> tires = List.of( new Tire(17), new Tire(16), new Tire(18), new Tire(14), new Tire(15), new Tire(16)); Comparator<Tire> sorter = (t1, t2) -> t2.size - t1.size; List<Tire> sorted = new ArrayList<>(tires); sorted.sort(sorter); System.out.println(sorted); } -
要使这个程序具有函数式特性,可以将排序智能移动到一个纯函数,该函数接受一个列表作为参数,然后在列表的副本上进行排序,并返回不可变的排序列表。这样,你将避免在主程序中保留可变列表的引用:
https://packt.live/35OxQiJ.
你刚刚创建了一个基于现有 Functional 接口的第一个 lambda 表达式,然后使用它对轮胎列表进行排序。自从 Java 8 以来,有很多函数式接口可用,你可能已经使用过其中大部分;我们将在本书的后面更详细地探讨这一点。
摘要
不同线程对你的数据执行操作的顺序不应该很重要,你应该能够轻松地添加不会影响应用程序旧部分的功能。遵循这些 FP 概念可以使你构建的代码容易在多线程应用程序中使用,以及构建可以非常容易地测试问题的回归错误。这通常也使你的代码更加易于阅读。
使用你在本章中学到的 FP(函数式编程)的核心概念——纯函数和不可变性——在某些情况下可能会导致性能问题,特别是在修改大型数据集时。我们将在后面的章节中探讨解决这些问题的方法。
由于 Java 是为面向对象的方法设计的,因此一开始进入 FP(函数式编程)可能会有些令人畏惧,但如果你只在代码的某些部分“采用函数式”,那么从 OOP(面向对象编程)的过渡可能会变得更容易。
在下一章中,我们将关注如何在不使用循环的情况下导航更大的数据集并重复代码。
第十四章:14. 递归
概述
在本章中,我们将看到如何使用递归帮助你编写有效的代码。章节从一项初始练习开始,该练习说明了你可以用递归犯的最简单的错误之一:忘记编写终止条件。因此,第一步是学习如何在 Java 栈溢出时挽救你的程序。从那里,你将学习编写递归方法来处理数学公式和其他重复处理需求。最后,通过这些技术(以及本章进一步定义的技术),你将练习使用 文档对象模型(DOM)API 创建和处理 XML 文件。
简介
递归是一个方法一次又一次地调用自己。当谨慎使用时,递归可以是一种有用的编程技术;但关键是正确使用它。
一个重要点是,递归只是一种编程技术。如果你愿意,你通常可以通过编写某种形式的迭代循环来避免它。然而,如果你需要解决的问题确实是递归的,那么迭代方法可能比相对简单且更优雅的递归代码复杂得多。
本章深入探讨了这种实用的编程技巧。
深入递归
递归对于许多数学问题很有用,例如在处理细胞自动机、谢宾斯基三角形和分形时。在计算机图形学中,递归可以用来帮助生成看起来逼真的山脉、植物和其他自然现象。经典的汉诺塔问题非常适合使用递归。
在 Java 应用程序中,你将经常在遍历树形数据结构时使用递归,包括 XML 和 HTML 文档。
注意
你可以参考 packt.live/2JaIre8 获取关于汉诺塔问题的更多信息。
递归的一个简单例子如下:
public int add(int num) {
return add(num + 1);
}
在这个例子中,每次对 add() 方法的调用都会用比当前调用使用的数字大一的数字调用自己。
注意
你总是需要一个终止条件来停止递归。这个例子没有。
练习 1:使用递归溢出栈
这个例子演示了当你没有为递归方法提供停止方式时会发生什么。你的程序会遇到一些问题。按照以下步骤进行练习:
-
在 IntelliJ 的“文件”菜单中选择“新建”,然后选择“项目...”。
-
选择项目的类型为
Gradle。点击“下一步”。 -
对于“组 ID”,输入
com.packtpub.recursion。 -
对于“工件 ID”,输入
chapter14。 -
对于“版本”,输入
1.0。 -
在下一页接受默认设置。点击“下一步”。
-
将项目名称保留为
chapter14。 -
点击“完成”。
-
在 IntelliJ 文本编辑器中打开
build.gradle。 -
将
sourceCompatibility设置为12,如图所示:sourceCompatibility = 12 -
在
src/main/java文件夹中创建一个新的 Java 包。 -
将包名输入为
com.packtpub.recursion。 -
在
Project窗格中右键单击此包,创建一个名为RunForever的新 Java 类。 -
按如下方式进入递归方法:
public int add(int num) { return add(num + 1); } -
按如下方式进入
main()方法:public class RunForever { public static void main(String[] args) { RunForever runForever = new RunForever(); System.out.println(runForever.add(1)); } } -
运行这个程序;你会看到它因异常而失败:
Exception in thread "main" java.lang.StackOverflowError at com.packtpub.recursion.RunForever.add(RunForever.java:11)完整的代码如下所示:
package com.packtpub.recursion; public class RunForever { public int add(int num) { return add(num + 1); } public static void main(String[] args) { RunForever runForever = new RunForever(); System.out.println(runForever.add(1)); } }我们可以通过提供终止条件来停止递归,如下面的
RunAndStop.java文件所示:package com.packtpub.recursion; public class RunAndStop { public int add(int num) { if (num < 100) { return add(num + 1); } return num; } public static void main(String[] args) { RunAndStop runAndStop = new RunAndStop(); System.out.println( runAndStop.add(1) ); } }当你运行这个程序时,你会看到以下输出:
100
尝试尾递归
尾递归是指递归方法的最后一个可执行语句是对自身的调用。尾递归很重要,因为 Java 编译器可以——但在此刻还没有——跳回方法的开头。这有助于编译器不需要为方法调用存储栈帧,使其更高效,并在调用栈上使用更少的内存。
练习 2:使用递归计算阶乘
阶乘是演示递归工作原理的绝佳例子。
你可以通过将数字与所有小于它的正数相乘来计算一个整数的阶乘。例如,4 的阶乘,也写作 4!,计算为 4 * 3 * 2 * 1。执行以下步骤来完成练习:
-
右键单击
com.packtpub.recursion包名。 -
创建一个名为
Factorial的新 Java 类。 -
进入递归方法:
public static int factorial(int number) { if (number == 1) { return 1; } else { return number * factorial(number - 1); } }由于阶乘是一个数乘以所有小于它的正数,因此在每次调用
factorial()方法时,它返回该数乘以比该数小一的阶乘。如果传入的数是 1,它就简单地返回数字 1。 -
进入
main()方法,它启动阶乘计算:public static void main(String[] args) { System.out.println( factorial(6) ); }此代码将计算 6 的阶乘,也称为 6 阶乘或 6!。
-
当你运行这个程序时,你会看到以下输出:
720完整的代码如下所示:
package com.packtpub.recursion; public class Factorial { public static int factorial(int number) { if (number == 1) { return 1; } else { return number * factorial(number - 1); } } public static void main(String[] args) { System.out.println( factorial(6) ); } }
阶乘以及许多其他数学概念与递归很好地结合。另一个适合这种编程技术的常见任务是处理层次文档,例如 XML 或 HTML。
处理 XML 文档
XML 文档有节点。每个节点可能有子节点;例如,考虑以下:
cities.xml
1 <?xml version="1.0" encoding="UTF-8" standalone="no"?>
2 <cities>
3 <city>
4 <name>London</name>
5 <country>United Kingdom</country>
6 <summertime-high-temp>20.4 C</summertime-high-temp>
7 <in-year-2100>
8 <with-moderate-emission-cuts>
9 <name>Paris</name>
10 <country>France</country>
11 <summertime-high-temp>22.7 C</summertime-high-temp>
12 </with-moderate-emission-cuts>
https://packt.live/2N4X4Rl
在这个 XML 片段中,<cities>元素有一个子元素<city>。<city>子元素反过来又有四个子元素。
注意
这些数据来自packt.live/33IrCyR,并在第六章,库、包和模块中的练习中使用。
现在,考虑你将如何编写代码来处理上述 XML 数据。Java 提供了解析 XML 文件的类。唯一的问题是解析成 Java 对象后如何处理 XML 文档。这就是递归可以发挥作用的地方。
你可以编写代码来处理每个 <city> 元素,例如 London 的数据。在该元素中,代码将提取子元素中的数据,例如城市名称、国家名称和夏令时高温。
注意如何显示两个额外的城市,Paris 和 Milan。这些数据可以以与 London 数据类似的方式处理。一旦你看到相似性,你可能会发现递归非常有用。
练习 3:创建一个 XML 文件
为了演示如何解析并递归遍历 XML 文档,我们需要一些 XML 数据:
-
右键单击
src/main/resources并选择New,然后选择File。 -
将文件名输入为
cities.xml。 -
将以下 XML 数据输入到文件中:
cities.xml
2 <cities>
3 <city>
4 <name>London</name>
5 <country>United Kingdom</country>
6 <summertime-high-temp>20.4 C</summertime-high-temp>
7 <in-year-2100>
8 <with-moderate-emission-cuts>
9 <name>Paris</name>
10 <country>France</country>
11 <summertime-high-temp>22.7 C</summertime-high-temp>
12 </with-moderate-emission-cuts>
https://packt.live/2N4X4Rl
Java 包含多个用于处理 XML 数据的 API。使用 简单 XML API(SAX),你可以一次处理一个 XML 文档的事件。事件包括开始一个元素、从元素内部获取一些文本以及结束一个元素。
使用 文档对象模型(DOM),API 读取 XML 文档。从这一点开始,你的代码可以遍历 DOM 元素的树。最适合递归处理的 API 是 DOM API。
注意
你可以在 packt.live/31yBoSL 和 packt.live/2BvD2tJ 找到有关 Java XML API 的更多信息。
介绍 DOM XML API
使用 DOM API,你可以使用 DocumentBuilder 类将 XML 文件解析为内存中的对象树。这些对象都实现了 org.w3c.Node 接口。节点接口允许你从每个 XML 元素中提取数据,然后检索节点下的所有子节点。
在我们的例子中,如 <city> 这样的常规 XML 元素实现了 Element 接口,该接口扩展了 Node 接口。此外,文本项实现了 Text 接口。整个文档由 Document 接口表示。
整个 DOM 是分层的。例如,考虑以下内容:
<city>
<name>London</name>
</city>
在这个简短的片段中,<city> 是一个元素,并且有一个子元素 <name>。London 文本是 <name> 元素的子元素。London 文本将保存在一个实现 Text 接口的对象中。
注意
DOM API 需要将整个 XML 文档加载到节点层次结构中。对于大型 XML 文档,DOM API 可能不合适,因为你可能会耗尽内存。
使用 DOM API 时,第一步是加载一个 XML 文件并将其解析为对象层次结构。
要做到这一点,你需要一个 DocumentBuilder 类:
DocumentBuilderFactory factory = DocumentBuilderFactory.newInstance();
DocumentBuilder builder = factory.newDocumentBuilder();
一旦你有了 DocumentBuilder 类,你可以解析 XML 文件以获取 Document 接口:
File xmlFile = new File("src/main/resources/cities.xml");
Document document = builder.parse(xmlFile);
由于 Document 是 Node,你可以开始处理所有子节点。通常,你从 Document 接口的第一子节点开始(在我们的早期示例中是 <cities>):
Node node = document.getFirstChild();
NodeList children = node.getChildNodes();
for (int i = 0; i < children.getLength(); i++) {
Node child = children.item(i);
}
getFirstChild()的调用返回document的第一个子节点,即顶级 XML 元素。然后你可以调用getChildNodes()来检索所有直接子元素。不幸的是,返回的NodeList对象既不是List也不是Collection接口,这使得遍历子节点变得更加困难。
然后,你可以使用递归获取任何给定节点的子节点,以及这些子节点的子节点,依此类推。例如,看看以下内容:
if (node.hasChildNodes()) {
indentation += 2;
NodeList children = node.getChildNodes();
for (int i = 0; i < children.getLength(); i++) {
Node child = children.item(i);
if (child.getNodeType() == Node.TEXT_NODE) {
printText(child.getTextContent() );
} else {
traverseNode(child, indentation);
}
}
}
在这个例子中,我们首先检查给定的节点是否有子节点。如果没有,我们就没有什么可做的。如果有子节点,我们将使用之前展示的相同技术来获取每个子节点。
一旦我们有一个节点,代码就会使用getNodeType()方法检查节点是否是Text节点。如果是Text节点,我们将打印出文本。如果不是,我们将对子节点进行递归调用。这将检索子节点的所有子节点。
练习 4:遍历 XML 文档
在这个练习中,我们将编写代码来遍历从我们在练习 3中创建的cities.xml文件解析出的节点对象树。该代码将打印出 XML 元素作为文本。按照以下步骤完成练习:
-
编辑
build.gradle文件。为Apache Commons Lang库添加新的依赖项:dependencies { testCompile group: 'junit', name: 'junit', version: '4.12' // https://mvnrepository.com/artifact/org.apache.commons/commons-lang3 implementation group: 'org.apache.commons', name: 'commons-lang3', version: '3.8.1' }此库有几个有用的实用方法,我们将在生成输出时使用。
-
右键单击
com.packtpub.recursion包名。 -
创建一个名为
XmlTraverser的新 Java 类。 -
输入以下方法以将 XML 文件加载到 DOM 树中:
XmlTraverser.java 17 public Document loadXml() { 18 Document document = null; 19 20 DocumentBuilderFactory factory = DocumentBuilderFactory.newInstance(); 21 try { 22 DocumentBuilder builder = factory.newDocumentBuilder(); 23 24 File xmlFile = new File("src/main/resources/cities.xml"); 25 document = builder.parse(xmlFile); 26 27 } https://packt.live/33MDhN2注意这段代码如何捕获从读取文件和解析 XML 内容中可能出现的所有异常。
-
接下来,输入一个方法来打印
Text节点的内容:public void printText(String text) { if (StringUtils.isNotBlank(text)) { System.out.print(text); } }此方法使用 Apache
StringUtils类来检查文本是否为空。你会发现 DOM API 填充了很多空的Text节点。 -
为了帮助表示 XML 文档的层次结构,输入一个缩进实用方法:
public void indent(int indentation) { System.out.print( StringUtils.leftPad("", indentation)); }再次,我们使用
StringUtils类来完成用给定数量的空格填充空字符串的繁琐工作。 -
接下来,我们创建主递归方法:
public void traverseNode(Node node, int indentation) { indent(indentation); System.out.print(node.getNodeName() + " "); if (node.hasChildNodes()) { indentation += 2; NodeList children = node.getChildNodes(); for (int i = 0; i < children.getLength(); i++) { Node child = children.item(i); if (child.getNodeType() == Node.TEXT_NODE) { printText( child.getTextContent() ); } else { System.out.println(); // previous line traverseNode(child, indentation); } } } } -
此方法打印出输入节点的名称(这将是一个城市、国家或类似的东西)。然后检查子节点。如果子节点是
Text节点,则打印出文本。否则,此方法将递归调用自身以处理子节点的所有子节点。 -
要开始,创建一个简短的方法从 XML 文档的第一个子节点开始递归调用:
public void traverseDocument(Document document) { traverseNode(document.getFirstChild(), 0); } -
接下来,我们需要一个
main()方法来加载 XML 文件并遍历文档:public static void main(String[] args) { XmlTraverser traverser = new XmlTraverser(); Document document = traverser.loadXml(); // Traverse XML document. traverser.traverseDocument(document); } -
当你运行此程序时,你将看到以下输出:
cities city name London country United Kingdom summertime-high-temp 20.4 C in-year-2100 with-moderate-emission-cuts name Paris country France summertime-high-temp 22.7 C with-no-emission-cuts name Milan country Italy summertime-high-temp 25.2 C注意
上述输出被截断。此练习的完整源代码可以在以下位置找到:
packt.live/33VDygZ。
活动 1:计算斐波那契数列
斐波那契序列是一系列数字,其中每个数字都是前两个数字的和。编写一个递归方法来生成斐波那契序列的前 15 个数字。请注意,斐波那契值对于 0 是 0,对于 1 是 1。
斐波那契序列为 0, 1, 1, 2, 3, 5, 8, 13, 21, 34, 55,等等。
因此,你可以使用以下内容作为指南:
fibonacci(4) =
fibonacci(3) + fibonacci(2) =
{fibonacci(2) + fibonacci(1)} + {fibonacci(1) + fibonacci(0)} =
{fibonacci(1) + fibonacci(0) + fibonacci(1) + fibonacci(0)} + {fibonacci(1) + fibonacci(0)} =
1 + 0 + 1 + 0 + 1 + 0 = 3
我们将使用递归方法来计算给定输入的斐波那契值,然后创建一个循环来显示序列。为此,请执行以下步骤:
-
创建
fibonacci方法。 -
检查传递给
fibonacci方法的值是否为 0,如果是,则返回 0。 -
还要检查传递给
fibonacci方法的值是否为 1,如果是,则返回 1。 -
否则,加上前两个数的斐波那契值。
-
在主方法中,创建一个从 0 到 15 的 for 循环并调用
fibonaci方法。
当你运行你的程序时,你应该看到以下类似的输出:
0
1
1
2
3
5
8
13
21
34
55
89
144
233
377
注意
本活动的解决方案可以在第 562 页找到。
摘要
递归是一种方便的编程技术,用于解决一些复杂问题。你通常会在数学公式中找到递归,以及在遍历二叉树或 XML 文档等分层数据结构时。使用递归,Java 方法或类会调用自身。但不要忘记编写终止条件,否则你会发现你的应用程序在 Java 调用栈上很快就会耗尽内存。
在下一章中,你将学习使用 Java 进行谓词和函数式编程。
第十五章:15. 使用流处理数据
概述
本章讨论了 Java 中的 Stream API,它允许你用更少的代码行有效地编写程序。一旦你掌握了并行流和顺序流之间的区别(在早期章节中定义和概述),你将能够通过首先学习如何创建和关闭这些流来练习使用 Java Stream API 来处理数组和集合。下一步是探索 Java 中可用的不同类型的操作、它们的定义和相应的功能。你将首先遇到的是终端操作和归约器,你将使用它们从元素流中提取数据。然后,你将转向中间操作来过滤、映射以及其他方式修改流结构。最后,在本章的最后练习和活动中,你将学习如何应用不同类型的收集器来将流元素包装在新容器中。
简介
Java 8 引入了新的 Stream API。有了流,Java 程序员现在可以使用一种更声明性的编程风格来编写程序,这种风格你之前只在函数式编程语言或函数式编程库中见过。
使用流,你现在可以用更少的代码行写出更具有表现力的程序,并且可以轻松地对大列表上的多个操作进行链式调用。流还使得在列表上并行化操作变得简单——也就是说,如果你有非常大的列表或复杂的操作。关于流,有一件重要的事情需要记住,那就是,尽管它们可能看起来像是一个改进的集合,但实际上并不是。流没有自己的存储;相反,它们使用提供源的数据存储。
在 Java 中,有四种类型的流:Stream,用于流式传输对象;IntStream,用于流式传输整数;LongStream,用于流式传输长整型;最后是DoubleStream,当然,用于流式传输双精度浮点数。所有这些流都以完全相同的方式工作,除了它们专门用于处理各自的数据类型。
注意
深入代码,你会发现这些类型只是指向StreamSupport类的静态方法的接口。这是任何想要编写特定流库的人的核心 API。然而,在构建应用程序时,通常可以使用四个标准流接口和静态生成函数。
流的来源可以是单个元素、集合、数组,甚至是文件。在流源之后是一系列中间操作,它们构成了核心管道。管道以终端操作结束,通常,它要么遍历剩余的元素以创建副作用,要么将它们减少到特定值——例如,计算最后一个流中剩余元素的数量。
注意
流是延迟构建和执行的。这意味着只有在执行终端操作时,流才会运行。源元素也只有在需要时才会读取;也就是说,只有所需的元素会被传递到下一个操作。
创建流
在 Java 中创建流有多种方式;其中最简单的是使用Stream.of()函数。这个函数可以接受单个对象或varargs中的多个对象:
Stream<Object> objectStream = Stream.of(new Object());
如果你流中有多个对象,则使用varargs版本:
Stream<Object> objectStream = Stream.of(new Object(), new Object(), new Object());
这些流的原始版本以相同的方式工作;只需将Object实例替换为整数、长整型或双精度浮点型。
你还可以从不同的集合中创建流——例如,列表和数组。从列表创建流看起来像这样:
List<String> stringList = List.of("string1", "string2", "string3");
Stream<String> stringStream = stringList.stream();
要从项目数组创建流,你可以使用Arrays类,就像原始版本的流一样:
String[] stringArray = new String[]{"string1", "string2", "string3"};
Stream<String> stringStream = Arrays.stream(stringArray);
有一种特殊的流类型可以优雅地处理令人讨厌的 null 类型,具体如下:
Stream<Object> nullableStream = Stream.ofNullable(new Object());
这个流将取一个单独的对象,该对象可以是 null。如果对象为 null,则将生成一个空流;或者,如果对象不为 null,则将生成一个包含该单个对象的流。当然,在不确定源状态的情况下,这可以非常方便。
生成元素流的另一种方法是使用Stream.iterate()生成函数。这个函数将在你告诉它停止之前,在流中生成无限数量的元素,从种子元素开始:
Stream<Integer> stream = Stream.iterate(0, (i) -> {
return i + 1;
}).limit(5);
在这个例子中,我们正在创建一个包含五个元素的流,从索引0开始。这个流将包含元素0、1、2、3和4:
注意
如果不提供适当的限制,Stream.iterate()生成函数可能会非常危险。有几种方法可以创建无限流——通常是通过错误地放置操作顺序或忘记对流应用限制。
此外,还有一个特殊的Builder类,它嵌入在Stream类型中。这个Builder类允许你在创建元素时添加元素;它消除了需要保持ArrayList或其他集合作为元素临时缓冲区的需求。
Builder类有一个非常简单的 API;你可以将一个元素accept()到构建器中,这在你想从循环中生成元素时非常完美:
Stream.Builder<String> streamBuilder = Stream.builder();
for (int i = 0; i < 10; i++) {
streamBuilder.accept("string" + i);
}
你也可以向构建器中add()元素。add()方法允许链式调用,这在你不希望从循环中生成元素,而是希望在一行中添加它们时非常完美:
Stream.Builder<String> streamBuilder = Stream.builder();
streamBuilder.add("string1").add("string2").add("string3");
使用构建器创建流时,当所有方法都已添加后,你可以调用build()方法。然而,请注意,如果在调用build()方法之后尝试向构建器中添加元素,它将抛出IllegalStateException异常:
Stream<String> stream = streamBuilder.build();
所有这些创建流的方法都使用相同的底层辅助类,称为StreamSupport。此类具有创建具有不同属性的流的许多有用和高级方法。所有这些流的共同特征是Spliterator。
并行流
在 Java Stream API 中,流要么是顺序的,要么是并行的。顺序流仅使用单个线程来执行任何操作。通常,您会发现这个流足以解决大多数问题;然而,有时您可能需要多个核心上的多个线程运行。
并行流在多个核心上的多个线程上并行操作。它们在 JVM 中使用ForkJoinPool来启动多个线程。当您发现自己处于性能热点时,它们可以是一个非常强大的工具。然而,由于并行流使用多个线程,除非需要,否则您应该谨慎使用它们;并行流的开销可能会产生比解决的问题更多的问题。
注意
并行流是一把双刃剑。在某些情况下,它们可能非常有用,然而,同时,它们也可能完全锁定您的程序。因为并行流使用公共的ForkJoinPool,它们可能会产生线程,这些线程可能会阻塞您的应用程序和其他系统组件,以至于用户会受到 影响。
要创建并行流,您可以使用Collections.parallelStream()方法,它将尝试创建一个并行流:
List.of("string1", "string2", "string3").parallelStream()
或者,您可以使用BaseStream.parallel()中间操作使流并行化:
List.of(1, 2, 3).stream().parallel()
注意,在源和终端操作之间,您可以使用BaseStream.parallel()或BaseStream.sequential()操作更改流的类型。如果需要更改流的底层状态,这些操作将对流产生影响;如果流已经具有正确的状态,它将简单地返回自身。多次调用BaseStream.parallel()对性能没有影响:
List.of(1, 2, 3).stream().parallel().parallel().parallel()
遭遇顺序
根据流源的类型,它可能具有不同的遭遇顺序。例如,列表具有内置的元素排序——也称为索引。源排序还意味着元素将以该顺序被遇到;然而,您可以使用BaseStream.unordered()和Stream.sorted()中间操作来更改这种遭遇顺序。
unordered()操作不会改变流的排序;相反,它只尝试移除一个特定属性并通知我们流是否已排序。元素仍然具有特定的顺序。无序流的整个目的就是在应用于并行流时使其他操作更高效。将unordered()操作应用于顺序流将使其非确定性。
关闭流
与之前 Java 版本的流类似,InputStream 和 OutputStream,Stream API 包含一个 close() 操作。然而,在大多数情况下,你实际上根本不需要担心关闭你的流。你应该担心关闭流的情况是当源是一个系统资源——例如文件或套接字——需要关闭以避免占用系统资源。
close() 操作返回 void,这意味着在调用 close() 之后,流将无法进行任何其他中间或终端操作;尽管可以在流关闭时注册 close 处理器来通知。close 处理器是一个 Runnable 函数式接口;最好使用 lambda 函数来注册它们:
Stream.of(1, 2, 3, 4).onClose(() -> {
System.out.println("Closed");
}).close();
你可以在你的管道中注册任意数量的 close 处理器。即使其中任何一个处理器在其代码中抛出异常,close 处理器也总是会运行。此外,值得注意的是,它们将始终以它们被添加到管道中的相同顺序被调用,而不管流的遇到顺序如何:
Stream.of(1, 2, 3, 4).onClose(() -> {
System.out.println("Close handler 1");
}).onClose(() -> {
System.out.println("Close handler 2");
}).onClose(() -> {
System.out.println("Close handler 3");
}).close();
注意
即使可以在任何流上注册关闭处理器,如果流不需要关闭,它可能实际上不会运行。
自 Java 7 以来,有一个名为 AutoCloseable 的接口,它将尝试在 try-with-resources 语句中自动关闭所持有的资源。所有流都继承自的 BaseStream 接口扩展了这个 AutoCloseable 接口。这意味着任何流如果被包裹在 try-with-resources 语句中,都会尝试自动释放资源:
try (Stream<Integer> stream = Stream.of(6, 3, 8, 12, 3, 9)) {
boolean matched = stream.onClose(() -> {
System.out.println("Closed");
}).anyMatch((e) -> {
return e > 10;
});
System.out.println(matched);
}
虽然前面的例子确实可行,但通常没有必要在基本流上包裹 try-with-resources 语句,除非你明确需要在流运行完成后执行逻辑。此示例将首先将 true 打印到终端,然后打印 Closed。
终端操作
每个管道都需要以终端操作结束;没有这个,管道将不会执行。与中间操作不同,终端操作可能有各种返回值,因为它们标志着管道的结束。在终端操作之后,你不能应用另一个操作。
注意
当对流应用终端操作时,你不能再使用该流。因此,在代码中存储流的引用可能会导致混淆,不清楚该引用可能如何使用——不允许“分割”流到两个不同的用例。如果你尝试对一个已经执行了终端操作的流应用操作,那么它将抛出一个带有消息stream has already been operated upon or closed的 IllegalStateException。
Stream API 中有 16 种不同的终端操作——每个都有其特定的用例。以下是对每个操作的说明:
-
forEach:这个终端操作像是一个普通的for循环;它将为流中的每个元素运行一些代码。这不是一个线程安全的操作,所以如果你发现自己正在使用共享状态,你需要提供同步:Stream.of(1, 4, 6, 2, 3, 7).forEach((n) -> { System.out.println(n); });如果在这个并行管道上应用此操作,元素被作用的方式的顺序将无法保证:
Stream.of(1, 4, 6, 2, 3, 7).parallel().forEach((n) -> { System.out.println(n); });如果元素被作用的方式的顺序很重要,你应该使用
forEachOrdered()终端操作。 -
forEachOrdered:与forEach()终端操作类似,这将允许你对流中的每个元素执行一个操作。然而,forEachOrdered()操作将保证处理元素的顺序,无论它们在多少个线程上被处理:Stream.of(1, 4, 6, 2, 3, 7).parallel().forEachOrdered((n) -> { System.out.println(n); });这里,你可以看到具有定义的遭遇顺序的并行流。使用
forEachOrdered()操作,它将始终按自然顺序、索引顺序遇到元素。 -
toArray:这两个终端操作将允许你将流中的元素转换为数组。基本版本将生成一个Object数组:Object[] array = Stream.of(1, 4, 6, 2, 3, 7).toArray();如果你需要一个特定类型的数组,你可以提供一个构造器引用来指定所需的数组类型:
Integer[] array = Stream.of(1, 4, 6, 2, 3, 7).toArray(Integer[]::new);第三种选择是为你自己的
toArray()操作编写自己的生成器:Integer[] array = Stream.of(1, 4, 6, 2, 3, 7).toArray(elements -> new Integer[elements]); -
reduce:对一个流进行归约意味着只提取该流元素的有用部分,并将它们归约为一个单一值。有两个通用的reduce操作可用。第一个,更简单的一个,接受一个累加函数作为参数。它通常在流上应用了映射操作之后使用:int sum = Stream.of(1, 7, 4, 3, 9, 6).reduce(0, (a, b) -> a + b);第二个更复杂的版本采用了一个同时作为归约初始值的身份。它还要求一个累加函数,其中归约发生,以及一个组合函数来定义如何将两个元素归约:
int sum = Stream.of(1, 7, 4, 3, 9, 6).reduce(0, (total, i) -> total + i, (a, b) -> a + b );在这个例子中,累加函数将组合函数的结果加到身份值上,在这种情况下,是归约的总和。
-
sum:这是一个更具体的归约操作,它将流中的所有元素相加。这个终端操作仅适用于IntStream、LongStream和DoubleStream。要在更通用的流中使用此功能,你需要实现一个使用reduce()操作的管道,通常在map()操作之前:int intSum = IntStream.of(1, 7, 4, 3, 9, 6).sum(); System.out.println(intSum);这将打印出结果为
30。以下示例说明了LongStream的使用:long longSum = LongStream.of(7L, 4L, 9L, 2L).sum(); System.out.println(longSum);这将打印出结果为
22。以下示例说明了DoubleStream的使用:double doubleSum = DoubleStream.of(5.4, 1.9, 7.2, 6.1).sum(); System.out.println(doubleSum);这将打印出结果为
20.6。 -
collect:收集操作类似于 reduce 操作,因为它接受流中的元素并创建一个新的结果。然而,与 reduce 操作不同,collect可以接受元素并生成一个新的容器或集合,该集合包含所有剩余的元素;例如,一个列表。通常,你会使用Collectors的help类,因为它包含许多现成的收集操作:List<Integer> items = Stream.of(6, 3, 8, 12, 3, 9).collect(Collectors.toList()); System.out.println(items);这将在控制台打印
[6, 3, 8, 12, 3, 9]。你可以在使用收集器部分查看Collectors的更多用法。另一种选择是为collect()操作编写自己的供应商、累加器和组合器:List<Integer> items = Stream.of(6, 3, 8, 12, 3, 9).collect( () -> { return new ArrayList<Integer>(); }, (list, i) -> { list.add(i); }, (list, elements) -> { list.addAll(elements); }); System.out.println(items);当然,在这个例子中,可以通过使用方法引用来简化:
List<Integer> items = Stream.of(6, 3, 8, 12, 3, 9).collect(ArrayList::new, List::add, List::addAll); System.out.println(items); -
min:正如其名所示,这个终端操作将返回流中所有元素的最小值,用Optional包装,并按照指定的Comparator进行指定。在应用此操作时,大多数情况下你会使用Comparator.comparingInt()、Comparator.comparingLong()或Comparator.comparingDouble()静态辅助函数:Optional min = Stream.of(6, 3, 8, 12, 3, 9).min((a, b) -> { return a - b;}); System.out.println(min);这应该写入
Optional[3]。 -
max:与min()操作相反,max()操作返回具有最大值的元素的值,根据指定的Comparator进行包装,并用Optional包装:Optional max = Stream.of(6, 3, 8, 12, 3, 9).max((a, b) -> { return a - b;}); System.out.println(max);这将在终端打印
Optional[12]。 -
average:这是一个特殊的终端操作,仅在IntStream、LongStream和DoubleStream上可用。它返回一个包含流中所有元素平均值的OptionalDouble:OptionalDouble avg = IntStream.of(6, 3, 8, 12, 3, 9).average(); System.out.println(avg);这将为你提供一个包含值
6.833333333333333的Optional。 -
count:这是一个简单的终端操作,返回流中的元素数量。值得注意的是,有时count()终端操作会找到计算流大小的更有效的方法。在这些情况下,管道甚至不会执行:long count = Stream.of(6, 3, 8, 12, 3, 9).count(); System.out.println(count); -
anyMatch:如果流中的任何元素匹配指定的谓词,则anyMatch()终端操作将返回true:boolean matched = Stream.of(6, 3, 8, 12, 3, 9).anyMatch((e) -> { return e > 10; }); System.out.println(matched);由于有一个值大于 10 的元素,这个管道将返回
true。 -
allMatch:如果流中的所有元素都匹配指定的谓词,则allMatch()终端操作将返回true:boolean matched = Stream.of(6, 3, 8, 12, 3, 9).allMatch((e) -> { return e > 10; }); System.out.println(matched);由于这个源有值小于 10 的元素,它应该返回
false。 -
noneMatch:与allMatch()相反,如果流中的没有任何元素匹配指定的谓词,则noneMatch()终端操作将返回true:boolean matched = Stream.of(6, 3, 8, 12, 3, 9).noneMatch((e) -> { return e > 10; }); System.out.println(matched);因为流中有值大于 10 的元素,所以这也会返回
false。 -
findFirst:这检索流中的第一个元素,并用Optional包装:Optional firstElement = Stream.of(6, 3, 8, 12, 3, 9).findFirst(); System.out.println(firstElement);这将在终端打印
Optional[6]。如果流中没有元素,它将打印Optional.empty。 -
findAny:与findFirst()终端操作类似,findAny()操作将返回一个被Optional包装的元素。然而,这个操作将返回剩余元素中的任何一个。你实际上永远不应该假设它会返回哪个元素。通常,这个操作会比findFirst()操作运行得更快,尤其是在并行流中。当你只需要知道是否还有剩余元素,但并不真正关心哪些元素剩余时,这是理想的:Optional firstElement = Stream.of(7, 9, 3, 4, 1).findAny(); System.out.println(firstElement); -
iterator:这是一个终端操作,它生成一个迭代器,让你遍历元素:Iterator<Integer> iterator = Stream.of(1, 2, 3, 4, 5, 6) .iterator(); while (iterator.hasNext()) { Integer next = iterator.next(); System.out.println(next); } -
summaryStatistics:这是一个特殊的终端操作,适用于IntStream、LongStream和DoubleStream。它将返回一个特殊类型——例如,IntSummaryStatistics——描述流中的元素:IntSummaryStatistics intStats = IntStream.of(7, 9, 3, 4, 1).summaryStatistics(); System.out.println(intStats); LongSummaryStatistics longStats = LongStream.of(6L, 4L, 1L, 3L, 7L).summaryStatistics(); System.out.println(longStats); DoubleSummaryStatistics doubleStats = DoubleStream.of(4.3, 5.1, 9.4, 1.3, 3.9).summaryStatistics(); System.out.println(doubleStats);这将打印出三个流的所有摘要到终端,其外观应该如下所示:
IntSummaryStatistics{count=5, sum=24, min=1, average=4,800000, max=9} LongSummaryStatistics{count=5, sum=21, min=1, average=4,200000, max=7} DoubleSummaryStatistics{count=5, sum=24,000000, min=1,300000, average=4,800000, max=9,400000}
中间操作
流可以接受任意数量的中间操作,这些操作是在创建流之后进行的。中间操作通常是某种类型的过滤器或映射,但还有其他类型。每个中间操作都会返回另一个流;这样,你可以将任意数量的中间操作链接到你的管道中。
中间操作的顺序非常重要,因为从操作返回的流将只引用前一个流中剩余或所需的元素。
有几种不同的中间操作类型。以下是对每种类型的解释:
-
filter:正如其名所示,这个中间操作将从流中返回一个子集元素。它在应用匹配模式时使用谓词,这是一个返回Boolean的功能接口。实现这个接口最简单和最常见的方式是使用 lambda 函数:Stream.of(1, 2, 3, 4, 5, 6) .filter((i) -> { return i > 3; }) .forEach(System.out::println);在这个示例中,
filter方法将过滤掉任何值等于或小于 3 的元素。然后,forEach()终端操作将取剩余的元素,并在循环中打印它们。 -
map:map操作将应用一个特殊函数到流中的每个元素,并返回修改后的元素:Stream.of("5", "3", "8", "2") .map((s) -> { return Integer.parseInt(s); }) .forEach((i) -> { System.out.println(i > 3); });这个管道将取字符串,使用
map()操作将它们转换为整数,然后根据解析的字符串值是否大于 3 来打印true或false。这只是map的一个简单示例;这种方法在将流转换为非常不同的东西时非常灵活。此外,这个中间操作还有特殊的版本,将返回整数值、长值和双精度值。它们分别称为
mapToInt()、mapToLong()和mapToDouble():Stream.of("5", "3", "8", "2") .mapToInt((i) -> { return Integer.parseInt(i); }) .forEach((i) -> { System.out.println(i > 3); });注意,这些特殊的
map操作将返回IntStream、LongStream或DoubleStream,而不是Stream<Integer>、Stream<Long>或Stream<Double>。 -
flatMap: 这为你提供了一个将多维数据结构扁平化到一个单一流中的简单方法——例如,一个包含对象或数组的对象的流。使用flatMap(),你可以将这些子元素连接成一个单一的流:Stream.of(List.of(1, 2, 3), List.of(4, 5, 6), List.of(7, 8, 9)) .flatMap((l) -> { return l.stream(); }) .forEach((i) -> { System.out.print(i); });在这个示例管道中,我们从一个多个列表中创建一个流;然后,在
flatMap操作中,我们提取每个列表的流。flatMap操作然后将它们连接成一个单一的流,我们通过forEach遍历它。终端将打印出完整的流:123456789。flatMap函数也存在于整数、长和双精度特殊操作中——flatMapToInt、flatMapToLong和flatMapToDouble——当然,它们将返回相应的类型流: -
distinct: 这将返回流中的所有唯一元素。如果流中有重复的元素,则将返回第一个项目:Stream.of(1, 2, 2, 2, 2, 3) .distinct() .forEach((i) -> { System.out.print(i); });在这里,我们从一个包含六个元素的流开始,然而,其中四个在值上是相同的。
distinct()操作将过滤这些元素,剩下的三个将被打印到终端。 -
sorted:sorted中间操作存在两个版本。第一个版本没有参数,假设map的元素可以按自然顺序排序——实现Comparable接口。如果它们不能排序,则将抛出异常:Stream.of(1, 3, 6, 4, 5, 2) .sorted() .forEach((i) -> { System.out.print(i); });sorted操作的第二个版本接受一个Comparator作为参数,并将相应地返回排序后的元素:Stream.of(1, 3, 6, 4, 5, 2) .sorted((a, b) -> a - b) .forEach((i) -> { System.out.print(i); }); -
unordered: 与sorted相反,unordered中间操作将对流的元素施加无序的遭遇顺序。在并行流上使用此操作有时可以提高性能,因为某些中间和终端状态操作在元素顺序更宽松的情况下表现更好:Stream.of(1, 2, 3, 4, 5, 6) .unordered() .forEach((i) -> { System.out.print(i); }); System.out.println(); Stream.of(1, 2, 3, 4, 5, 6) .parallel() .unordered() .forEach((i) -> { System.out.print(i); }); -
limit: 这个操作返回一个包含n个元素的新的流。如果元素的数量少于请求的限制,则没有效果:Stream.of(1, 2, 3, 4, 5, 6) .limit(3) .forEach((i) -> { System.out.print(i); });运行此示例的结果将是
123,忽略任何超过第三个元素的元素。 -
skip: 这个操作将跳过此流的前n个元素,并返回一个包含剩余元素的新流:Stream.of(1, 2, 3, 4, 5, 6) .skip(3) .forEach((i) -> { System.out.print(i); });这将打印
456到终端,跳过前三个元素。 -
boxed: 特殊的原始流IntStream、LongStream和DoubleStream都可以访问boxed()操作。此操作将“封装”每个原始元素在相应类型的类版本中,并返回该流。IntStream将返回Stream<Integer>,LongStream将返回Stream<Long>,而DoubleStream将返回Stream<Double>:IntStream.of(1, 2) .boxed() .forEach((i) -> { System.out.println(i + i.getClass().getSimpleName()); }); System.out.println(); LongStream.of(3, 3) .boxed() .forEach((l) -> { System.out.println(l + l.getClass().getSimpleName()); }); System.out.println(); DoubleStream.of(5, 6) .boxed() .forEach((d) -> { System.out.println(d + d.getClass().getSimpleName()); });这个示例将取每个原始流,将其封装在相应的对象类型中,然后打印出值以及该类型的类名:
1Integer 2Integer 3Long 4Long 5.0Double 6.0Double -
takeWhile:这是一种特殊类型的操作,根据流是有序的还是无序的,其行为不同。如果流是有序的——也就是说,它有一个定义的遭遇顺序——它将返回一个包含最长匹配元素序列的流,该序列从流中的第一个元素开始。这个始终以第一个元素开始的元素流有时也被称为前缀:Stream.of(2, 2, 2, 3, 1, 2, 5) .takeWhile((i) -> { return i == 2; }) .forEach((i) -> { System.out.println(i); });此管道将
222打印到终端。然而,你应该注意,如果第一个元素不匹配谓词,此操作将返回一个空流。这是因为takeWhile()的内部工作原理;也就是说,它将从第一个元素开始,直到第一个元素失败匹配——给你一个空流:Stream.of(1, 2, 2, 3, 1, 2, 5) .takeWhile((i) -> { return i == 2; }) .forEach((i) -> { System.out.println(i); });如果流是无序的——也就是说,它没有定义的遭遇顺序——
takeWhile()操作可能会返回任何匹配的元素子集,包括空子集。在这种情况下,filter()操作可能更合适。 -
dropWhile:dropWhile()操作与takeWhile()相反。就像takeWhile()一样,它将根据流是有序的还是无序的不同而有所不同。如果流是有序的,它将丢弃与谓词匹配的最长前缀,而不是像takeWhile()那样返回前缀:Stream.of(2, 2, 2, 3, 1, 2, 5) .dropWhile((i) -> { return i == 2; }) .forEach((i) -> { System.out.print(i); });此管道将
3125打印到终端,丢弃匹配的前缀,即前三个 2。如果流是无序的,操作可能会丢弃任何元素子集,或者丢弃空子集,实际上返回整个流。在使用此操作于无序流时要小心。 -
ForkJoinPool。大多数流都是顺序的,除非特别创建为并行,或者使用此中间操作转换为并行。 -
顺序:这返回一个顺序流,是并行的对立面。
-
peek:这种中间操作主要用于在应用其他中间操作之后检查流。通常,目标是了解操作如何影响元素。在以下示例中,我们正在打印每个元素如何通过管道中的每个流操作:long count = Stream.of(6, 5, 3, 8, 1, 9, 2, 4, 7, 0) .peek((i) -> { System.out.print(i); }) .filter((i) -> { return i < 5; }) .peek((i) -> { System.out.print(i); }) .map((i) -> { return String.valueOf(i); }) .peek((p) -> { System.out.print(p); }) .count(); System.out.println(count);在这个例子中,终端将读取
653338111922244470005。我们可以快速推断的是,任何值大于或等于 5 的元素只会打印一次。Peek将依次跟随整个流中的每个元素;这就是为什么顺序可能看起来很奇怪。6 和 5 只会打印一次,因为它们在第一个peek操作后被过滤掉。然而,3 将在所有三个peek()操作中被触发,因此有一连串的三个 3。输出中的最后一个数字 5 只是剩余元素的数量。虽然
peek()操作最常用于在元素遍历管道时检查元素,但也可以使用这些操作来修改流中的元素。考虑以下类定义:class MyItem { int value; public MyItem(int value) { this.value = value; } }然后,考虑将这些值中的几个添加到一个应用了修改
peek操作的流中:long sum = Stream.of(new MyItem(1), new MyItem(2), new MyItem(3)) .peek((item) -> { item.value = 0; }) .mapToInt((item) -> { return item.value; }) .sum(); System.out.println(sum);
如果我们忽略 peek() 操作,这些对象的总和应该是 6。然而,peek 操作正在将每个对象修改为零值——实际上使总和为零。虽然这是可能的,但它从未被设计成这样使用。使用 peek() 来修改是不推荐的,因为它不是线程安全的,访问任何共享状态可能会引发异常。不同的 map() 操作通常是更好的选择。
练习 1:使用 Stream API
一个允许客户同时收集和保存多个不同购物车的在线杂货店要求你实现他们的多购物车系统的联合结账。结账程序应该连接所有购物车中所有商品的价格,然后向客户展示。为此,执行以下步骤:
-
如果 IntelliJ 已经启动但没有打开项目,那么请选择
Create New Project。如果 IntelliJ 已经打开了项目,那么请从菜单中选择File|New|Project。 -
在
New Project对话框中,选择Java project,然后点击Next。 -
打开复选框以从模板创建项目。选择
Command Line App,然后点击Next。 -
给新项目命名为
Chapter15。 -
IntelliJ 会为你提供一个默认的项目位置。如果你希望选择一个,你可以在这里输入。
-
将包名设置为
com.packt.java.chapter15。 -
点击
Finish。IntelliJ 将创建你的项目,名为
Chapter15,具有标准的文件夹结构。IntelliJ 还会创建你的应用程序的主入口点,名为Main.java。 -
将此文件重命名为
Exercise1.java。完成后,它应该看起来像这样:package com.packt.java.chapter15; public class Exercise1 { public static void main(String[] args) { // write your code here } } -
创建一个新的内部类,称为
ShoppingArticle。将其设置为静态,这样我们就可以轻松地从程序的主入口点访问它。这个类应该包含文章的名称和该文章的价格。让price是一个双精度变量:private static final class ShoppingArticle { final String name; final double price; public ShoppingArticle(String name, double price) { this.name = name; this.price = price; } } -
现在创建一个简单的
ShoppingCart类。在这个版本中,我们将只允许购物车中的每篇文章有一个项目,所以一个列表就足够用来在ShoppingCart中保存文章了:private static final class ShoppingCart { final List<ShoppingArticle> mArticles; public ShoppingCart(List<ShoppingArticle> list) { mArticles = List.copyOf(list); } } -
创建你的第一个购物车,
fruitCart,并向其中添加三种水果文章——Orange、Apple和Banana——每种各一个。设置每单位价格为1.5、1.7和2.2Java-$:public class Exercise1 { public static void main(String[] args) { ShoppingCart fruitCart = new ShoppingCart(List.of( new ShoppingArticle("Orange", 1.5), new ShoppingArticle("Apple", 1.7), new ShoppingArticle("Banana", 2.2) )); } -
创建另一个
ShoppingCart,但这次是蔬菜——Cucumber、Salad和Tomatoes。同样,为它们设置价格,Java-$ 为0.8、1.2和2.7:ShoppingCart vegetableCart = new ShoppingCart(List.of( new ShoppingArticle("Cucumber", 0.8), new ShoppingArticle("Salad", 1.2), new ShoppingArticle("Tomatoes", 2.7) )); } -
用第三个和最后的
shoppingCart包裹测试购物车,其中包含一些肉类和鱼类。它们通常比水果和蔬菜贵一些:ShoppingCart meatAndFishCart = new ShoppingCart(List.of( new ShoppingArticle("Cod", 46.5), new ShoppingArticle("Beef", 29.1), new ShoppingArticle("Salmon", 35.2) )); } -
现在是时候开始实现一个函数,该函数将计算购物车中所有商品的总价。声明一个新的函数,它接受一个
ShoppingCartvararg作为参数并返回一个 double 类型。让它成为静态的,这样我们就可以在main函数中轻松使用它:private static double calculatePrice(ShoppingCart... carts) { } -
从所有购物车流开始构建一个管道:
private static double calculatePrice(ShoppingCart... carts) { return Stream.of(carts) } -
向所有购物车添加一个
flatMap()操作以提取单个ShoppingArticles流:private static double calculatePrice(ShoppingCart... carts) { return Stream.of(carts) .flatMap((cart) -> { return cart.mArticles.stream(); }) } -
使用
mapToDouble()操作提取每个ShoppingArticle的价格;这将创建一个DoubleStream:private static double calculatePrice(ShoppingCart... carts) { return Stream.of(carts) .flatMap((cart) -> { return cart.mArticles.stream(); }) .mapToDouble((item) -> { return item.price; }) } -
最后,使用
DoubleStream中可用的sum()方法将所有ShoppingArticle的价格减少到总和:private static double calculatePrice(ShoppingCart... carts) { return Stream.of(carts) .flatMap((cart) -> { return cart.mArticles.stream(); }) .mapToDouble((item) -> { return item.price; }) .sum(); } -
现在你有一个函数,它将把
ShoppingCart列表减少到 Java-$的统一总和。你现在要做的就是将这个函数应用到你的ShoppingCart类中,然后打印出结果总和到终端,并四舍五入到两位小数:double sum = calculatePrice(fruitCart, vegetableCart, meatAndFishCart); System.out.println(String.format("Sum: %.2f", sum)); }注意
你可以在以下位置找到完整的代码:
packt.live/2qzLaHx。
现在,你已经使用功能 Java Stream API 创建了你第一段完整的代码。你创建了一个复杂对象的流,对流的元素应用映射操作以转换它们,然后又应用另一个映射操作以再次转换元素,改变了流类型两次。最后,你将整个流减少到一个单一的基本值,并将其呈现给用户。
活动一:对商品应用折扣
通过添加一个在计算最终价格之前对购物车中某些商品应用折扣的功能来改进前面的示例。确保价格计算仍然正确。
注意
这个活动的解决方案可以在第 563 页找到。
使用收集器
在 Java 中,收集器当你需要从大型数据结构中提取某些数据点、描述或元素时是一个非常强大的工具。它们提供了一种非常易于理解的方式来描述你想要对元素流执行的操作,而不需要编写复杂的逻辑。
Collector接口有许多有用的默认实现,你可以轻松开始使用。大多数这些收集器不允许 null 值;也就是说,如果它们在你的流中找到一个 null 值,它们将抛出一个NullPointerException。在使用收集器将你的元素减少到这些容器中的任何一种之前,你应该小心处理流中的 null 元素。
以下是对所有默认收集器的介绍:
-
toCollection: 这个泛型收集器将允许你将你的元素包装在任何已知实现Collection接口的类中;例如ArrayList、HashSet、LinkedList、TreeSet和其他:List.of("one", "two", "three", "four", "five") .stream() .collect(Collectors.toCollection(TreeSet::new)); -
toList: 这将把你的元素减少到一个ArrayList实现。如果你需要一个更具体的列表类型,你应该使用toCollection()收集器:List.of("one", "two", "three", "four", "five") .stream() .collect(Collectors.toList()); -
toUnmodifiableList:这本质上与toList()收集器相同,唯一的不同之处在于它使用List.of()生成器函数来创建不可修改的列表:List.of("one", "two", "three", "four", "five") .stream() .collect(Collectors.toUnmodifiableList()); -
toSet:这将在HashSet中包装元素:List.of("one", "two", "three", "four", "five") .stream() .collect(Collectors.toSet()); -
toUnmodifiableSet:这与toSet()收集器类似,区别在于它将使用Set.of()生成器来创建一个不可修改的集合:List.of("one", "two", "three", "four", "five") .stream() .collect(Collectors.toUnmodifiableSet()); -
joining:这个收集器将使用StringBuilder将流元素连接成一个字符串,不包含任何分隔字符:String joined = List.of("one", "two", "three", "four", "five") .stream() .collect(Collectors.joining()); System.out.println(joined);这将在终端打印
onetwothreefourfive。如果你需要元素之间用逗号分隔,例如,使用Collectors.joining(","):String joined = List.of("one", "two", "three", "four", "five") .stream() .collect(Collectors.joining(",")); System.out.println(joined);在这个示例中,你会在终端得到
one,two,three,four,five的打印。最后,你还有添加前缀和后缀到生成的字符串的选项:String joined = List.of("one", "two", "three", "four", "five") .stream() .collect(Collectors.joining(",", "Prefix", "Suffix")); System.out.println(joined);前缀和后缀是添加到字符串上的,而不是每个元素。生成的字符串将看起来像:
Prefixone,two,three,four,fiveSuffix。 -
mapping:这是一种特殊的收集器,允许你在应用定义的收集器之前对流中的每个元素应用映射:Set<String> mapped = List.of("one", "two", "three", "four", "five") .stream() .collect(Collectors.mapping((s) -> { return s + "-suffix"; }, Collectors.toSet())); System.out.println(mapped);在这里,我们从一个
List<String>的源开始,将其收集到一个Set<String>中。但在收集之前,我们使用mapping()收集器将-suffix字符串连接到每个元素上。 -
flatMapping。就像flatMap()中间操作一样,这个收集器将允许你在收集到新容器之前对流元素应用扁平映射。在以下示例中,我们从一个源List<Set<String>>开始,然后将其展开为Stream<Set<String>>并应用Collector.toList()——实际上是将所有集合转换成一个单独的列表:List<String> mapped = List.of( Set.of("one", "two", "three"), Set.of("four", "five"), Set.of("six") ) .stream() .collect(Collectors.flatMapping( (set) -> { return set.stream(); }, Collectors.toList()) ); System.out.println(mapped); -
filter()中间操作,在这里,你可以在对流执行操作之前应用过滤。Set<String> collected = List.of("Andreas", "David", "Eric") .stream() .collect(Collectors.filtering( (name) -> { return name.length() < 6; }, Collectors.toSet()) ); System.out.println(collected); -
collectingAndThen:这个特殊的收集器将允许你使用一个特殊函数来完成收集;例如,将你的集合转换成一个不可变集合:Set<String> immutableSet = List.of("Andreas", "David", "Eric") .stream() .collect(Collectors.collectingAndThen( Collectors.toSet(), (set) -> { return Collections.unmodifiableSet(set); }) ); System.out.println(immutableSet); -
counting:这产生与count()中间操作相同的结果:long count = List.of("Andreas", "David", "Eric") .stream() .collect(Collectors.counting()); System.out.println(count); -
minBy:这个收集器等同于使用min()终端操作符。以下示例将打印Optional[1]到终端:Optional<Integer> smallest = Stream.of(1, 2, 3) .collect(Collectors.minBy((a, b) -> { return a - b; }); System.out.println(smallest); -
maxBy:使用这个收集器可以得到与使用max()终端操作符相同的结果:Optional<Integer> biggest = Stream.of(1, 2, 3) .collect(Collectors.maxBy((a, b) -> { return a - b; })); System.out.println(biggest); -
summingInt:这是reduce()中间操作的替代方案,用于计算流中所有元素的总和:int sum = Stream.of(1d, 2d, 3d) .collect(Collectors.summingInt((d) -> { return d.intValue(); })); System.out.println(sum); -
summingLong:这与Collector.summingInt()相同,但将产生一个long类型的总和:long sum = Stream.of(1d, 2d, 3d) .collect(Collectors.summingLong((d) -> { return d.longValue(); })); System.out.println(sum); -
summingDouble:这与Collector.summingLong()相同,但将产生一个double类型的总和:double sum = Stream.of(1, 2, 3) .collect(Collectors.summingDouble((i) -> { return i.doubleValue(); })); System.out.println(sum); -
averagingInt:返回传入整数的平均值:double average = Stream.of(1d, 2d, 3d) .collect(Collectors.averagingInt((d) -> { return d.intValue(); })); System.out.println(average); -
averagingLong:返回传入的长整数的平均值:double average = Stream.of(1d, 2d, 3d) .collect(Collectors.averagingLong((d) -> { return d.longValue(); })); System.out.println(average); -
averagingDouble:返回传入参数中数字的平均值:double average = Stream.of(1, 2, 3) .collect(Collectors.averagingDouble((i) -> { return i.doubleValue(); })); System.out.println(average);x§ -
reduce()终端操作符,这个收集器从它那里继承了名称和操作。 -
groupingBy:这个收集器将根据给定的函数对元素进行分组,并根据给定的集合类型收集它们。考虑以下示例类,描述一辆汽车:private static class Car { String brand; long enginePower; Car(String brand, long enginePower) { this.brand = brand; this.enginePower = enginePower; } public String getBrand() { return brand; } @Override public String toString() { return brand + ": " + enginePower; } }如果你想要根据品牌对几辆汽车进行排序并将它们收集到新的容器中,那么使用
groupingBy()收集器就很简单:Map<String, List<Car>> grouped = Stream.of( new Car("Toyota", 92), new Car("Kia", 104), new Car("Hyundai", 89), new Car("Toyota", 116), new Car("Mercedes", 209)) .collect(Collectors.groupingBy(Car::getBrand)); System.out.println(grouped);这里,我们有四种不同的汽车。然后,我们根据汽车的品牌应用
groupingBy()收集器。这将产生一个Map<String, List<Car>>集合,其中String是汽车的品牌,List包含该品牌的所有汽车。这总是返回Map;然而,你可以定义收集分组元素时应使用的集合类型。在下面的例子中,我们将它们分组到Set而不是默认列表中:Map<String, Set<Car>> grouped = Stream.of( new Car("Toyota", 92), new Car("Kia", 104), new Car("Hyundai", 89), new Car("Toyota", 116), new Car("Mercedes", 209)) .collect(Collectors.groupingBy(Car::getBrand, Collectors. toSet())); System.out.println(grouped);如果将
groupingBy收集器与另一个收集器结合使用,它将变得更加强大——例如,reducing收集器:Map<String, Optional<Car>> collected = Stream.of( new Car("Volvo", 195), new Car("Honda", 96), new Car("Volvo", 165), new Car("Volvo", 165), new Car("Honda", 104), new Car("Honda", 201), new Car("Volvo", 215)) .collect(Collectors.groupingBy(Car::getBrand, Collectors. reducing((carA, carB) -> { if (carA.enginePower > carB.enginePower) { return carA; } return carB; }))); System.out.println(collected);在这个例子中,我们根据品牌对汽车进行分组,然后只显示每个品牌中最强大的引擎的汽车。当然,这种组合也可以与其他收集器一起使用,例如过滤、计数等:
-
groupingBy收集器,并且具有完全相同的 API。 -
partitioningBy:partitioningBy收集器的工作方式与groupingBy收集器类似,区别在于它将元素分组到两个集合中,这两个集合要么匹配谓词,要么不匹配谓词。它将这两个集合包装到Map中,其中true关键字将引用匹配谓词的元素集合,而false关键字将引用不匹配谓词的元素:Map<Boolean, List<Car>> partitioned = Stream.of( new Car("Toyota", 92), new Car("Kia", 104), new Car("Hyundai", 89), new Car("Toyota", 116), new Car("Mercedes", 209)) .collect(Collectors.partitioningBy((car) -> { return car. enginePower > 100; })); System.out.println(partitioned);你也可以选择将元素包装在哪种类型的集合中,就像
groupingBy收集器一样:Map<Boolean, Set<Car>> partitioned = Stream.of( new Car("Toyota", 92), new Car("Kia", 104), new Car("Hyundai", 89), new Car("Toyota", 116), new Car("Mercedes", 209)) .collect(Collectors.partitioningBy((car) -> { return car. enginePower > 100; }, Collectors.toSet())); System.out.println(partitioned); -
toMap:这个收集器将允许你通过定义映射函数从你的流元素创建map,其中你提供一个要放入map中的键和值。通常,这只是一个元素的唯一标识符和元素本身。这可能有点棘手,因为如果你提供了一个重复的元素,那么你的管道将抛出一个
IllegalStateException,因为Map不允许重复键:Map<String, Integer> mapped = List.of("1", "2", "3", "4", "5") .stream() .collect(Collectors.toMap((s) -> { return s; }, (s) -> { return Integer.valueOf(s); })); System.out.println(mapped);这个简单的例子演示了如何将整数的字符串表示映射到实际的整数。如果你知道你可能会有重复的元素,那么你可以提供一个
merge函数来解决这个问题:Map<String, Integer> mapped = List.of("1", "2", "3", "4", "5", "1", "2") .stream() .collect(Collectors.toMap((s) -> { return s; }, (s) -> { return Integer.valueOf(s); }, (a, b) -> { return Integer.valueOf(b); })); System.out.println(mapped);你还可以通过在收集器的最后应用一个
factory函数来生成自己的Map类型。在这里,我们告诉收集器为我们生成一个新的TreeMap:TreeMap<String, Integer> mapped = List.of("1", "2", "3", "4", "5", "1", "2") .stream() .collect(Collectors.toMap((s) -> { return s; }, (s) -> { return Integer.valueOf(s); }, (a, b) -> { return Integer.valueOf(b); }, () -> { return new TreeMap<>(); })); System.out.println(mapped); -
toUnmodifiableMap:这本质上与toMap相同,具有相同的 API;然而,它返回不可变的Map版本。这在你知道你永远不会在Map中更改数据时非常完美。 -
toConcurrentMap:由于Map的实现方式,当在并行流中使用时可能会对性能造成一定风险。在这种情况下,建议使用toConcurrentMap()收集器。它具有与其他toMap函数类似的 API,不同之处在于它将返回ConcurrentMap实例而不是Map。 -
从之前的收集器中的
Car类,你可以生成所有汽车引擎的摘要,如下所示:LongSummaryStatistics statistics = Stream.of( new Car("Volvo", 165), new Car("Volvo", 165), new Car("Honda", 104), new Car("Honda", 201) ).collect(Collectors.summarizingLong((e) -> { return e.enginePower; })); System.out.println(statistics);
I/O 流
除了集合和其他原始数据类型之外,你还可以在管道中使用文件和 I/O 流作为数据源。这使得针对服务器编写任务变得更加描述性。
由于这些类型的资源通常需要正确关闭,你应该使用 try-with-resources 语句来确保在完成使用后资源被归还给系统。
考虑创建一个名为 authors.csv 的 CSV 文件,其内容如下:
Andreas, 42, Sweden
David, 37, Sweden
Eric, 39, USA
你可以使用 try-with-resources 语句将此文件放入流中:
String filePath = System.getProperty("user.dir") + File.separator + "res/authors.csv";
try (Stream<String> authors = Files.lines(Paths.get(filePath))) {
authors.forEach((author) -> {
System.out.println(author);
});
} catch (IOException e) {
e.printStackTrace();
}
在 I/O 流中,你可以添加 onClose 处理程序来接收当流关闭时的通知。与其他流不同,当流的资源被关闭时,它将自动关闭。在这个例子中,这是由 try-with-resources 语句自动处理的。在下面的例子中,我们添加了一个 onClose 处理程序,当流关闭时将打印单词 Closed:
try (Stream<String> authors = Files.lines(Paths.get(filePath))) {
authors.onClose(() -> {
System.out.println("Closed");
}).forEach((author) -> {
System.out.println(author);
});
} catch (IOException e) {
e.printStackTrace();
}
下面是使用 InputStream 编写的相同示例。请注意,代码现在更加冗长,有三个嵌套对象创建:
try (Stream<String> authors = new BufferedReader(
new InputStreamReader(new FileInputStream(filePath))).lines()
) {
...
} catch (FileNotFoundException e) {
e.printStackTrace();
}
练习 2:将 CSV 转换为列表
一家基于标准 Java List 集合的在线杂货店已经实现了自己的数据库,并且还实现了一个备份系统,将数据库备份到 CSV 文件中。然而,他们还没有构建从 CSV 文件恢复数据库的方法。他们已经要求你构建一个系统,该系统能够读取这样的 CSV 文件,并将其内容扩展到列表中。
数据库备份 CSV 文件包含一种单一类型的对象:ShoppingArticle。每篇文章都有一个 name、一个 price、一个 category,最后还有一个 unit。名称、类别和单位应该是 String 类型,而价格是 double 类型:
-
如果尚未打开,请打开 IDEA 中的
Chapter15项目。 -
创建一个新的 Java 类,使用
File|New|Java。 -
将名称输入为
Exercise2,然后选择OK。IntelliJ 将创建你的新类;它应该看起来像以下片段:
package com.packt.java.chapter15; public class Exercise2 { } -
向此类添加一个
main方法。这是你将编写应用程序大部分代码的地方。你的类现在应该看起来像这样:package com.packt.java.chapter15; public class Exercise2 { public static void main(String[] args) { } } -
创建一个
ShoppingArticle内部类,并将其设置为静态,这样你就可以在主方法中轻松使用它。重写toString方法,以便稍后能够轻松地将文章打印到终端:private static class ShoppingArticle { final String name; final String category; final double price; final String unit; private ShoppingArticle(String name, String category, double price, String unit) { this.name = name; this.category = category; this.price = price; this.unit = unit; } @Override public String toString() { return name + " (" + category + ")"; } } -
如果项目中尚未存在,请创建一个新的文件夹
res。然后,将其放置在根目录中,与src文件夹相邻。 -
将
database.csv文件从 GitHub 复制到你的项目中,并将其放置在res文件夹中。 -
在你的
Exercise2.java类中,添加一个生成List<ShoppingArticle>的函数。这将是我们将数据库加载到列表中的函数。由于该函数将加载文件,它需要抛出一个 I/O 异常(IOException):private static List<ShoppingArticle> loadDatabaseFile() throws IOException { return null; } -
从你的
main方法中调用此函数:public static void main(String[] args) { try { List<ShoppingArticle> database = loadDatabaseFile(); } catch (IOException e) { e.printStackTrace(); } } -
首先使用 try-with-resources 块加载数据库文件。使用
Files.lines加载database.csv文件中的所有行。它应该看起来像这样:private static List<ShoppingArticle> loadDatabaseFile() throws IOException { try (Stream<String> stream = Files.lines(Path.of("res/database.csv"))) { } return null; } -
让我们查看流的状态,看看它现在的状态。中间操作只有在定义了终端操作时才会运行,所以添加一个
count()在最后,只是为了强制执行整个管道:private static List<ShoppingArticle> loadDatabaseFile() throws IOException { try (Stream<String> stream = Files.lines(Path.of("res/database.csv"))) { return stream.peek((line) -> { System.out.println(line); }).count(); } catch (IOException e) { e.printStackTrace(); } return null; }这应该打印出文件中的每一行。注意,它还打印了标题行——当我们将其转换为
ShoppingArticles时,我们并不关心标题行。 -
由于我们并不真正对第一行感兴趣,所以在
count()方法之前添加一个skip操作:private static List<ShoppingArticle> loadDatabaseFile() throws IOException { try (Stream<String> stream = Files.lines(Path.of("res/database.csv"))) { return stream.peek((line) -> { System.out.println(line); }).skip(1).count(); } catch (IOException e) { e.printStackTrace(); } return null; } -
现在数据库文件的每一行都已经加载为流中的元素,除了标题行。现在是时候从这些行中提取每一条数据了;适合这个操作的选项是
map。使用split()函数将每一行分割成String数组:private static List<ShoppingArticle> loadDatabaseFile() throws IOException { try (Stream<String> stream = Files.lines(Path.of("res/database.csv"))) { return stream.peek((line) -> { System.out.println(line); }).skip(1).map((line) -> { return line.split(","); }).count(); } catch (IOException e) { e.printStackTrace(); } return null; } -
添加另一个
peek操作来找出map操作如何改变了流;你的流类型现在应该是Stream<String[]>:private static List<ShoppingArticle> loadDatabaseFile() throws IOException { try (Stream<String> stream = Files.lines(Path.of("res/database.csv"))) { return stream.peek((line) -> { System.out.println(line); }).skip(1).map((line) -> { return line.split(","); }).peek((arr) -> { System.out.println(Arrays.toString(arr)); }).count(); } catch (IOException e) { e.printStackTrace(); } return null; } -
添加另一个
map操作,但这次是将流转换为Stream<ShoppingArticle>:private static List<ShoppingArticle> loadDatabaseFile() throws IOException { try (Stream<String> stream = Files.lines(Path.of("res/database.csv"))) { return stream.peek((line) -> { System.out.println(line); }).skip(1).map((line) -> { return line.split(","); }).peek((arr) -> { System.out.println(Arrays.toString(arr)); }).map((arr) -> { return new ShoppingArticle(arr[0], arr[1], Double.valueOf(arr[2]), arr[3]); }).count(); } catch (IOException e) { e.printStackTrace(); } return null; } -
现在你可以再次使用
peek来确保文章被正确创建:private static List<ShoppingArticle> loadDatabaseFile() throws IOException { try (Stream<String> stream = Files.lines(Path.of("res/database.csv"))) { return stream.peek((line) -> { System.out.println(line); }).skip(1).map((line) -> { return line.split(","); }).peek((arr) -> { System.out.println(Arrays.toString(arr)); }).map((arr) -> { return new ShoppingArticle(arr[0], arr[1], Double.valueOf(arr[2]), arr[3]); }).peek((art) -> { System.out.println(art); }).count(); } catch (IOException e) { e.printStackTrace(); } return null; } -
将所有文章收集到一个列表中。使用不可修改的列表来保护数据库免受不想要的修改:
private static List<ShoppingArticle> loadDatabaseFile() throws IOException { try (Stream<String> stream = Files.lines(Path.of("res/database.csv"))) { return stream.peek((line) -> { System.out.println(line); }).skip(1).map((line) -> { return line.split(","); }).peek((arr) -> { System.out.println(Arrays.toString(arr)); }).map((arr) -> { return new ShoppingArticle(arr[0], arr[1], Double.valueOf(arr[2]), arr[3]); }).peek((art) -> { System.out.println(art); }).collect(Collectors.toUnmodifiableList()); } catch (IOException e) { e.printStackTrace(); } return null; }
这可能看起来有些冗长,因为一些操作可以一起应用来使其更短。然而,保持每个操作都非常小是有意义的,这样可以使整个逻辑非常透明。如果你在管道中发现了问题,你可以简单地移动管道中的一个操作,这样应该就能解决所有问题。
如果在一个操作中组合多个步骤,那么在管道中移动操作或完全替换它会更困难。
活动二:搜索特定内容
数据库加载完成后,应用一些搜索逻辑:
-
编写一个函数,用于从一个
ShoppingArticles列表中找出最便宜的水果。 -
编写一个函数,用于从一个
ShoppingArticles列表中找出最昂贵的蔬菜。 -
编写一个函数,用于将所有水果收集到一个单独的列表中。
-
编写一个函数,用于在数据库中找出五个最便宜的文章。
-
编写一个函数,用于在数据库中找出五个最昂贵的文章。
注意
本活动的解决方案可以在第 564 页找到。
摘要
在编写程序时,描述性代码始终是一个值得追求的理想。代码越简单,就越容易向同事和其他感兴趣的人传达你的意图。
Java Streams API 允许您构建简单且高度描述性的函数。通常情况下,它们将是纯函数,因为 Streams API 使得避免操作状态变得非常容易。
在下一章中,我们将进一步探讨函数式编程主题,探索可用的不同函数式接口。
第十六章:16. 谓词和其他函数式接口
概述
本章探讨了所有有效的函数式接口的使用案例。它将首先定义这些接口是什么(从谓词接口开始),以及如何在代码中最佳地使用它们。然后,你将学习如何构建和应用谓词,研究它们的组合以及如何使用这种组合来模拟复杂行为。你将练习创建消费者接口以改变程序的状态,并最终使用函数来提取有用的结构。
简介
除了 Java 8 的许多其他改进(如流式 API、方法引用、可选和收集器)之外,还有接口改进,允许默认和静态方法,这些方法被称为函数式接口。这些接口只有一个抽象方法,这使得它们可以转换为 lambda 表达式。你可以在第十三章使用 Lambda 表达式的函数式编程中了解更多相关信息。
在 java.util.function 包中总共有 43 个独特的函数式接口;它们大多数是同一类接口的变体,尽管数据类型不同。在本章中,我们将向您介绍谓词函数式接口,以及一些其他精选的接口。
在这里,你会发现许多函数式接口以非常相似的方式操作,通常只是替换了接口可以操作的数据类型。
谓词接口
谓词接口是一个相当简单,但出奇地优雅和复杂的函数式接口,它允许你作为程序员,以布尔形式定义描述程序状态的函数。在 Java 中,言语谓词是一元函数,返回一个布尔值。
谓词 API 看起来是这样的:
boolean test(T t);
然而,谓词 API 还利用了 Java 8 的新接口功能。它使用默认和静态函数来丰富 API,允许更复杂地描述程序的状态。在这里,有三个函数很重要:
Predicate<T> and(Predicate<T>);
Predicate<T> or(Predicate<T>);
Predicate<T> not(Predicate<T>);
使用这三个函数,你可以将谓词链起来,以描述对程序状态的更复杂查询。and 函数将组合两个或多个谓词,确保提供的每个谓词都返回 true。
or 函数等同于逻辑或,允许在需要时短路谓词链。
最后,not 函数返回所提供的谓词的否定版本,并且它具有与在所提供的谓词上调用 negate() 相同的效果。
此外,还有一个 helper 函数用于构建一个谓词,该谓词根据对象上的 equals 方法检查两个对象是否相同。我们可以使用静态的 isEqual(Object target) 方法为两个对象构建该谓词。以下练习将作为定义谓词的示例。
练习 1:定义谓词
定义一个谓词相当简单。考虑构建一个家庭警报系统的后端服务器。这个系统需要能够同时轻松理解多个不同传感器的状态——例如:门是开还是关?电池是否健康?传感器是否连接?
构建这样一个系统是一个复杂任务。我们将尝试在这个练习中简化这个过程:
-
如果 IntelliJ 已经启动,但没有打开项目,请选择
创建新项目。如果 IntelliJ 已经打开了项目,请从菜单中选择文件->新建->项目。 -
在
新建项目对话框中,选择一个 Java 项目。点击下一步。 -
打勾以从模板创建项目。选择
命令行应用程序。点击下一步。 -
给新的项目命名为
Chapter16。 -
IntelliJ 会为你提供一个默认的项目位置。如果你希望选择一个,你可以在这里输入。
-
将包名设置为
com.packt.java.chapter16。 -
点击
完成。你的项目将以标准的文件夹结构创建,并包含一个程序的入口点类。它看起来可能像这样:
package com.packt.java.chapter16; public class Main { public static void main(String[] args) { // write your code here } } -
将此文件重命名为
Exercise1.java,确保使用重构|重命名菜单。完成时,它应该看起来像这样:package com.packt.java.chapter16; public class Exercise1 { public static void main(String[] args) { // write your code here } } -
警报系统将有三类不同的传感器——一个
网关传感器、一个运动传感器和一个火灾传感器。它们都将具有相同的基本特性,但在某些方面可能有所不同。创建Base传感器接口,并让它有两个 getter/setter 对,第一对应该命名为batteryHealth,它将返回一个介于 0 和 100 之间的整数,第二对将是一个布尔值,命名为triggered:package com.packt.java.chapter16; public interface Sensor { int batteryHealth(); void batteryHealth(int health); boolean triggered(); void triggered(boolean state); } -
创建
Gateway Sensor类,并允许它实现Sensor接口并返回实例变量:package com.packt.java.chapter16; public class Gateway implements Sensor { private int batteryHealth; private boolean triggered; @Override public int batteryHealth() { return batteryHealth; } @Override public void batteryHealth(int health) { this.batteryHealth = health; } @Override public boolean triggered() { return triggered; } @Override public void triggered(boolean state) { triggered = state; } } -
对
Movement和Fire传感器类做同样的事情,除了Fire传感器还将有当前的温度,而运动传感器将返回房间内环境光的强度:package com.packt.java.chapter16; public class Fire implements Sensor { private int batteryHealth; private boolean triggered; private int temperature; @Override public int batteryHealth() { return batteryHealth; } @Override public void batteryHealth(int health) { } @Override public boolean triggered() { return triggered; } @Override public void triggered(boolean state) { } public int temperature() { return temperature; } }Movement类的代码如下:package com.packt.java.chapter16; public class Movement implements Sensor { private int batteryHealth; private boolean isTriggered; private int ambientLight; @Override public int batteryHealth() { return batteryHealth; } @Override public void batteryHealth(int health) { } @Override public boolean triggered() { return isTriggered; } @Override public void triggered(boolean state) { } public int ambientLight() { return ambientLight; } } -
为所有三个传感器类添加构造函数,利用 IntelliJ 的助手来完成这个任务。打开
Fire类,使用代码|生成菜单,并选择构造函数。 -
选择所有三个变量并点击
确定。你的Fire类现在应该看起来像这样:package com.packt.java.chapter16; public class Fire implements Sensor { private int batteryHealth; private boolean triggered; private int temperature; public Fire(int batteryHealth, boolean isTriggered, int temperature) { this.batteryHealth = batteryHealth; this.triggered = isTriggered; this.temperature = temperature; } @Override public int batteryHealth() { return batteryHealth; } @Override public void batteryHealth(int health) { } @Override public boolean triggered() { return triggered; } @Override public void triggered(boolean state) { } public int temperature() { return temperature; } } -
为
Gateway和Movement传感器生成构造函数。 -
现在,你应该在你的程序中有了三个代表传感器状态的类。
-
现在是时候描述你的第一个谓词类了,这个谓词用来描述传感器是否触发了警报。创建一个新的类,并将其命名为
HasAlarm:package com.packt.java.chapter16; public class HasAlarm { } -
实现
Predicate接口,使用Sensor作为类型定义。在test函数中,返回传感器的触发状态:package com.packt.java.chapter16; import java.util.function.Predicate; public class HasAlarm implements Predicate<Sensor> { @Override public boolean test(Sensor sensor) { return sensor.triggered(); } } -
在你的程序入口点,即
main方法中,创建一个传感器列表,并添加几个Gateway传感器到其中:package com.packt.java.chapter16; import java.util.ArrayList; import java.util.List; public class Exercise1 { public static void main(String[] args) { List<Sensor> sensors = new ArrayList<>(); sensors.add(new Gateway(34, false)); sensors.add(new Gateway(14, true)); sensors.add(new Gateway(74, false)); sensors.add(new Gateway(8, false)); sensors.add(new Gateway(18, false)); sensors.add(new Gateway(9, false)); } } -
在主方法中使用
for循环遍历列表。在for循环中,添加一个使用谓词检查是否触发了警报的if语句:package com.packt.java.chapter16; import java.util.ArrayList; import java.util.List; import java.util.function.Predicate; public class Exercise1 { public static void main(String[] args) { List<Sensor> sensors = new ArrayList<>(); sensors.add(new Gateway(34, false)); sensors.add(new Gateway(14, true)); sensors.add(new Gateway(74, false)); sensors.add(new Gateway(8, false)); sensors.add(new Gateway(18, false)); sensors.add(new Gateway(9, false)); for (Sensor sensor : sensors) { if (new HasAlarm().test(sensor)) { System.out.println("Alarm was triggered"); } } } }注意
你可能会问自己这有什么用。这与使用传感器的
public triggered()函数没有区别。这也是应用谓词的一种不常见方式,但它说明了谓词的工作原理。一个更常见的方法是使用流和 lambda 表达式。 -
现在,创建另一个谓词,并将其命名为
HasWarning。在这个类中,我们将简单地检查电池状态是否低于10的阈值,在我们的例子中这表示 10%:package com.packt.java.chapter16; import java.util.function.Predicate; public class HasWarning implements Predicate<Sensor> { public static final int BATTERY_WARNING = 10; @Override public boolean test(Sensor sensor) { return sensor.batteryHealth() < BATTERY_WARNING; } } -
使用
HasAlarm和HasWarning谓词生成一个新的组合谓词。实例化HasAlarm谓词,并应用默认的or()函数来链式添加HasWarning谓词:package com.packt.java.chapter16; import java.util.ArrayList; import java.util.List; import java.util.function.Predicate; public class Exercise1 { public static void main(String[] args) { List<Sensor> sensors = new ArrayList<>(); sensors.add(new Gateway(34, false)); sensors.add(new Gateway(14, true)); sensors.add(new Gateway(74, false)); sensors.add(new Gateway(8, false)); sensors.add(new Gateway(18, false)); sensors.add(new Gateway(9, false)); Predicate<Sensor> hasAlarmOrWarning = new HasAlarm().or(new HasWarning()); for (Sensor sensor : sensors) { if (new HasAlarm().test(sensor)) { System.out.println("Alarm was triggered"); } } } } -
在
for循环中使用新组成的谓词添加一个新的if语句:
Exercise1.java
1 package com.packt.java.chapter16;
2
3 import java.util.ArrayList;
4 import java.util.List;
5 import java.util.function.Predicate;
6
7 public class Exercise1 {
8
9 public static void main(String[] args) {
10 List<Sensor> sensors = new ArrayList<>();
11 sensors.add(new Gateway(34, false));
12 sensors.add(new Gateway(14, true));
13 sensors.add(new Gateway(74, false));
14 sensors.add(new Gateway(8, false));
15 sensors.add(new Gateway(18, false));
16 sensors.add(new Gateway(9, false));
https://packt.live/2P9njsy
如前所述,在像这样的循环中对对象直接应用谓词(或任何其他功能性接口)是不常见的。相反,你将主要使用 Java 流 API。
活动一:切换传感器状态
重新编写程序一次,向你的程序中添加一个扫描器以从命令行切换传感器状态。每个传感器应至少能够切换电池健康状态和触发状态。当传感器更新时,你应该检查系统是否有变化,并在命令行上生成适当的响应,如果已触发警告或警报。
注意
本活动的解决方案可在第 565 页找到。
消费者接口
在函数式编程中,我们经常被告知要避免在代码中产生副作用。然而,消费者功能性接口是这一规则的例外。它的唯一目的是根据参数的状态产生副作用。消费者有一个相当简单的 API,其核心功能称为accept(),不返回任何内容:
void accept(T);
这也可以通过使用andThen()函数来链式使用多个消费者,该函数返回新链式连接的消费者:
Consumer<T> andThen(Consumer<T>);
练习 2:产生副作用
继续上一个练习,考虑以下示例,我们将添加对系统警告和警报的反应功能。你可以使用消费者来产生副作用并将系统的当前状态存储在变量中:
-
复制
Exercise1.java类,并将其命名为Exercise2。删除整个for循环,但保留实例化的谓词。 -
在
Exercise2中创建一个新的静态布尔变量,并将其命名为alarmServiceNotified:package com.packt.java.chapter16; import java.util.ArrayList; import java.util.List; import java.util.function.Predicate; public class Exercise2 { static boolean alarmServiceNotified; public static void main(String[] args) { List<Sensor> sensors = new ArrayList<>(); sensors.add(new Gateway(34, false)); sensors.add(new Gateway(14, true)); sensors.add(new Gateway(74, false)); sensors.add(new Gateway(8, false)); sensors.add(new Gateway(18, false)); sensors.add(new Gateway(9, false)); Predicate<Sensor> hasAlarmOrWarning = new HasAlarm().or(new HasWarning()); } }注意
当然,这并不是你通常应用静态变量的方式(如果你真的应该使用静态变量)。然而,在这个例子中,它使说明副作用变得容易得多。
-
创建一个新的类,命名为
SendAlarm,并允许它实现消费者接口。它看起来可能像这样:package com.packt.java.chapter16; import java.util.function.Consumer; public class SendAlarm implements Consumer<Sensor> { @Override public void accept(Sensor sensor) { } } -
在
accept(Sensor sensor)函数内部,检查传感器是否已被触发。如果已被触发,将静态变量设置为true:package com.packt.java.chapter16; import java.util.function.Consumer; public class SendAlarm implements Consumer<Sensor> { @Override public void accept(Sensor sensor) { if (sensor.triggered()) { Exercise2.alarmServiceNotified = true; } } } -
回到
main方法中,实例化一个新的SendAlarm消费者:package com.packt.java.chapter16; import java.util.ArrayList; import java.util.List; import java.util.function.Predicate; public class Exercise2 { static boolean alarmServiceNotified; public static void main(String[] args) { List<Sensor> sensors = new ArrayList<>(); sensors.add(new Gateway(34, false)); sensors.add(new Gateway(14, true)); sensors.add(new Gateway(74, false)); sensors.add(new Gateway(8, false)); sensors.add(new Gateway(18, false)); sensors.add(new Gateway(9, false)); Predicate<Sensor> hasAlarmOrWarning = new HasAlarm().or(new HasWarning()); SendAlarm sendAlarm = new SendAlarm(); } } -
使用流,首先,根据之前定义的复合谓词过滤传感器列表。然后,使用
forEach将SendAlarm消费者应用于每个触发警报或警告的传感器:sensors.stream().filter(hasAlarmOrWarning).forEach(sendAlarm); -
现在,添加一个
if语句,检查是否通知了警报服务,如果是的话,打印一条消息:if (alarmServiceNotified) { System.out.println("Alarm service notified"); } -
构建另一个消费者,这次命名为
ResetAlarm:package com.packt.java.chapter16; import java.util.function.Consumer; public class ResetAlarm implements Consumer<Sensor> { @Override public void accept(Sensor sensor) { } } -
在
ResetAlarm的accept()函数中添加逻辑,将batteryHealth设置为50,将Triggered设置为false。同时,将静态通知变量设置为false:package com.packt.java.chapter16; import java.util.function.Consumer; public class ResetAlarm implements Consumer<Sensor> { @Override public void accept(Sensor sensor) { sensor.triggered(false); sensor.batteryHealth(50); Exercise2.alarmServiceNotified = false; } } -
实例化新的
ResetAlarm消费者,然后使用andThen()函数在SendAlarm消费者之后应用它:package com.packt.java.chapter16; import java.util.ArrayList; import java.util.List; import java.util.function.Consumer; import java.util.function.Predicate; public class Exercise2 { static boolean alarmServiceNotified; public static void main(String[] args) { List<Sensor> sensors = new ArrayList<>(); sensors.add(new Gateway(34, false)); sensors.add(new Gateway(14, true)); sensors.add(new Gateway(74, false)); sensors.add(new Gateway(8, false)); sensors.add(new Gateway(18, false)); sensors.add(new Gateway(9, false)); Predicate<Sensor> hasAlarmOrWarning = new HasAlarm().or(new HasWarning()); if (sensors.stream().anyMatch(hasAlarmOrWarning)) { System.out.println("Alarm or warning was triggered"); } SendAlarm sendAlarm = new SendAlarm(); ResetAlarm resetAlarm = new ResetAlarm(); sensors.stream().filter(hasAlarmOrWarning) .forEach(sendAlarm.andThen(resetAlarm)); if (alarmServiceNotified) { System.out.println("Alarm service notified"); } } } -
最后,一个额外的奖励。在 练习 2 的最后,产生副作用 的
main方法中,应用hasAlarmOrWarning谓词的否定版本,并打印出一切正常消息:
Exercise2.java
21 Predicate<Sensor> hasAlarmOrWarning = new HasAlarm().or(new HasWarning());
22
23 if (sensors.stream().anyMatch(hasAlarmOrWarning)) {
24 System.out.println("Alarm or warning was triggered");
25 }
26
27 SendAlarm sendAlarm = new SendAlarm();
28
29 ResetAlarm resetAlarm = new ResetAlarm();
30
31 sensors.stream().filter(hasAlarmOrWarning) .forEach(sendAlarm.andThen(resetAlarm));
32
33 if (alarmServiceNotified) {
34 System.out.println("Alarm service notified");
35 }
36
37 if (sensors.stream().anyMatch(hasAlarmOrWarning.negate())) {
38 System.out.println("Nothing was triggered");
39 }
https://packt.live/2JqD7n9
函数
函数(是的,它被称为函数)主要被引入来将一个值转换成另一个值。它通常用于映射场景。它还包含默认方法,可以将多个函数组合成一个,并在函数之间进行链式调用。
接口中的主函数被称为 apply,它看起来像这样:
R apply(T);
它定义了一个返回值 R 和函数的输入。想法是返回值和输入不必是同一类型。
组合由 compose 函数处理,它也返回接口的一个实例,这意味着你可以链式调用组合。顺序是从右到左;换句话说,参数函数在调用函数之前被应用:
Function<V, R> compose(Function<V, T>);
最后,andThen 函数允许你将函数一个接一个地链式调用:
Function<T, V> andThen(Function<R, V>);
在接下来的练习中,你将练习使用这些函数。
练习 3:提取数据
提取所有警报系统的数据作为整数——电池百分比、温度、触发状态等,具体取决于你将警报系统推进到什么程度。首先,提取电池健康数据:
-
复制
Exercise2类并命名为Exercise3。 -
删除除了传感器列表之外的所有内容。你的类应该看起来像这样:
package com.packt.java.chapter16; import java.util.ArrayList; import java.util.List; public class Exercise3 { public static void main(String[] args) { List<Sensor> sensors = new ArrayList<>(); sensors.add(new Gateway(34, false)); sensors.add(new Gateway(14, true)); sensors.add(new Gateway(74, false)); sensors.add(new Gateway(8, false)); sensors.add(new Gateway(18, false)); sensors.add(new Gateway(9, false)); } } -
创建一个新的类,命名为
ExtractBatteryHealth,并让它实现Function<T, R>函数式接口。重写apply函数。你的类应该看起来像这样:package com.packt.java.chapter16; import java.util.function.Function; public class ExtractBatteryHealth implements Function<Sensor, Integer> { @Override public Integer apply(Sensor sensor) { return null; } } -
在
apply函数中,让它返回电池健康状态,如下所示:package com.packt.java.chapter16; import java.util.function.Function; public class ExtractBatteryHealth implements Function<Sensor, Integer> { @Override public Integer apply(Sensor sensor) { return sensor.batteryHealth(); } } -
实例化你的新
ExtractBatteryHealth函数,如果你还没有这样做的话,添加一些传感器到列表中:package com.packt.java.chapter16; import java.util.ArrayList; import java.util.List; public class Exercise3 { public static void main(String[] args) { List<Sensor> sensors = new ArrayList<>(); sensors.add(new Gateway(34, false)); sensors.add(new Gateway(14, true)); sensors.add(new Fire(78, false, 21)); sensors.add(new Gateway(74, false)); sensors.add(new Gateway(8, false)); sensors.add(new Movement(87, false, 45)); sensors.add(new Gateway(18, false)); sensors.add(new Fire(32, false, 23)); sensors.add(new Gateway(9, false)); sensors.add(new Movement(76, false, 41)); ExtractBatteryHealth extractBatteryHealth = new ExtractBatteryHealth(); } } -
最后,使用 Java 流的
map操作并应用你的新ExtractBatteryHealth实例。使用toArray操作终止流。你现在应该有一个包含所有电池健康状态的数组:package com.packt.java.chapter16; import java.util.ArrayList; import java.util.List; public class Exercise3 { public static void main(String[] args) { List<Sensor> sensors = new ArrayList<>(); sensors.add(new Gateway(34, false)); sensors.add(new Gateway(14, true)); sensors.add(new Fire(78, false, 21)); sensors.add(new Gateway(74, false)); sensors.add(new Gateway(8, false)); sensors.add(new Movement(87, false, 45)); sensors.add(new Gateway(18, false)); sensors.add(new Fire(32, false, 23)); sensors.add(new Gateway(9, false)); sensors.add(new Movement(76, false, 41)); ExtractBatteryHealth extractBatteryHealth = new ExtractBatteryHealth(); Integer[] batteryHealths = sensors.stream().map(extractBatteryHealth) .toArray(Integer[]::new); } } -
将你的电池健康信息打印到终端:
package com.packt.java.chapter16; import java.util.ArrayList; import java.util.Arrays; import java.util.List; public class Exercise3 { public static void main(String[] args) { List<Sensor> sensors = new ArrayList<>(); sensors.add(new Gateway(34, false)); sensors.add(new Gateway(14, true)); sensors.add(new Fire(78, false, 21)); sensors.add(new Gateway(74, false)); sensors.add(new Gateway(8, false)); sensors.add(new Movement(87, false, 45)); sensors.add(new Gateway(18, false)); sensors.add(new Fire(32, false, 23)); sensors.add(new Gateway(9, false)); sensors.add(new Movement(76, false, 41)); ExtractBatteryHealth extractBatteryHealth = new ExtractBatteryHealth(); Integer[] batteryHealths = sensors.stream().map(extractBatteryHealth) .toArray(Integer[]::new); System.out.println(Arrays.toString(batteryHealths)); } }
活动二:使用递归函数
计算你的警报系统中的平均电池健康——可以通过循环、流或递归函数来实现。
注意
本活动的解决方案可以在第 566 页找到。
活动三:使用 Lambda 函数
不要实例化ExtractBatteryHealth功能接口,而是使用 lambda 表达式并存储其引用。
注意
本活动的解决方案可以在第 567 页找到。
摘要
在本章中,你已经探索了如何使用 Java 8 提供的功能接口。你已经在循环、单个实例和流中使用了它们,所有这些都是功能接口的有效用例。然而,你很快会发现这些功能接口的实例(简称为 lamdas)通常与流一起使用。
Java 中有许多预定义的功能接口,但其中只有少数在功能上是独特的。大多数只是不同函数的原生版本,例如IntPredicate、LongPredicate、DoublePredicate和Predicate。
在下一章中,你将了解更多关于反应式流(Reactive Streams)倡议、Flow API 以及 Java 如何构建良好的基础接口以支持反应式编程的内容。
第十七章:17. 使用 Java Flow 的响应式编程
概述
本章将介绍 Java Flow API 和响应式流规范的优势。它将首先以一般术语定义 Flow 和响应式流的动机,以及 Java 中发布者、订阅者和处理器的相应功能。然后,你将学习如何使用基本的 SubmissionPublisher 来构建响应式应用程序,并在最后几节中练习使用 Flow 实现一个简单的订阅者和处理器。
简介
响应式流规范展示了软件架构中一个持续发展的方向,被称为响应式系统。这些系统理想情况下具有以下优势:
-
更快的响应
-
更可控的相互响应
-
提高可靠性
Java 9 中引入了一个用于开发响应式系统或应用程序的原生支持的 API,称为 Flow。
Java 9 Flow API 的目的不是与已经开发、高度采用且受到赞赏的响应式库或 API 竞争。Flow API 诞生的最大原因是需要在这些库之间找到一个共同点;确保无论使用哪种实现,响应式编程的核心都是相同的。这样,你可以轻松地从一种实现转换到另一种实现。
为了实现这一点,Java Flow API 遵循响应式流规范——大多数库在设计时都将其作为蓝图使用的规范。该规范的设计者响应式流倡议始于 2013 年,由 Netflix 和其他几家对可靠交付内容有利益的大公司发起。
注意
虽然它们可能共享很多相同的术语,但 Flow API 与 Java 8 的 Streams API 完全无关。它们专注于解决不同类型的问题。
简而言之,响应式编程是一种使用通过流式传输事件相互通信的组件来编写程序的方法。这些事件通常是异步的,并且永远不会使接收方感到压力。在响应式系统中,有两个主要组件——发布者和订阅者。这与网络化的 pub/sub 系统类似,但规模更小。
Java Flow API(或者更确切地说,Flow 遵循的响应式流)有三个主要角色:
-
发布者了解可用的数据,并按需将其推送到任何感兴趣的订阅者。
-
订阅者是需求数据的一方。
-
处理器可能位于发布者和订阅者之间。处理器可以在将发布的数据释放给订阅者或另一个处理器之前拦截和转换这些数据。因此,处理器可以同时充当订阅者和发布者。
这些组件之间的通信具有推送和拉取两种性质。订阅者首先请求发布者发送最多 n 条消息。这是通信的拉取部分。在此请求之后,发布者将开始向订阅者发送消息,但不会超过 n 条消息。

图 17.1:订阅者和发布者之间的通信
当发布者发送了最终消息后,它将提供一个通知,表明消息发送已完成,订阅者可以据此采取必要的行动——可能请求更多消息或完全终止通信。
我们将在本章进一步探讨的整个 Flow API 都定义在一个单独的 Java 类中。它为每个参与者定义了一个接口,以及一个额外的接口,描述了订阅对象,这是发布者和订阅者之间的消息链接。
Publisher
发布者持有其他组件感兴趣获取的数据。发布者将等待一个对数据感兴趣的订阅者请求发送 n 个项目,然后才会开始向订阅者发送这些项目。
请求特定数量的项目,而不是请求所有项目,这被称为背压,在 Reactive Streams 规范中非常重要。这种背压允许监听器一次请求他们能够处理的项目数量,确保应用程序不会停滞或崩溃。
Flow 和 Reactive Streams 中 Publisher 的接口如下:
@FunctionalInterface
public static interface Publisher<T> {
public void subscribe(Subscriber<? super T> subscriber);
}
您会注意到它是一个函数式接口,如果您愿意,可以将其实现为 lambda 表达式。
SubmissionPublisher
创建一个完全功能的发布者可能相当复杂。幸运的是,Flow 包含一个完整的实现,称为 SubmissionPublisher。我们将在本章的几个示例中使用这个类。
您可以直接使用 SubmissionPublisher 作为组件,或者作为扩展 Publisher 的超类。SubmissionPublisher 需要一个 Executor 和一个缓冲区大小。默认情况下,它将使用常见的 ForkJoinPool 和一个缓冲区大小为 256:
SubmissionPublisher<?> publisher = new SubmissionPublisher<>();
SubmissionPublisher<?> publisher = new SubmissionPublisher<>(ForkJoinPool.commonPool(), Flow.defaultBufferSize());
选择执行器应根据您的应用程序设计和预期处理的任务来决定。在某些情况下,常见的 ForkJoinPool 是最佳选择,而在其他情况下,一个计划中的线程池可能效果更好。您可能需要尝试不同的执行器和缓冲区大小,以找到最适合您需求的组合:
SubmissionPublisher<?> publisher = new SubmissionPublisher<>(Executors.newCachedThreadPool(), 512);
您还可以将 SubmissionPublisher 作为您自己实现的超类使用。
在以下示例中,MyPublisher 扩展了 SubmissionPublisher,但定义了一个固定的 threadpool 执行器,而不是常见的 ForkJoinPool 执行器:
public class MyPublisher extends SubmissionPublisher<String> {
public MyPublisher() {
super(Executors.newFixedThreadPool(1), Flow.defaultBufferSize());
}
}
Subscriber
订阅者代表最终用户。它在流的末尾接收数据并对它进行操作。操作可能包括更新用户界面、推送到另一个组件或以任何方式进行转换。
订阅者的接口包含四个不同的回调,每个回调都代表来自发布者或订阅者本身的某种类型的信息:
-
onSubscribe:当订阅者拥有有效的订阅时,会调用onSubscribe方法。通常,这用于启动从发布者发送项目的交付。Subscriber通常会在这里通知Publisher,通过请求另一个项目。 -
onNext:当Publisher提供另一个项目时,会调用onNext方法。 -
onError:当发生错误时,会调用onError方法。这通常意味着订阅者将不再接收任何更多消息,并且应该关闭。 -
onComplete:当发布者发送了最后一个项目时,会调用onComplete方法。
以下示例说明了所有这些回调:
public static interface Subscriber<T> {
public void onSubscribe(Subscription subscription);
public void onNext(T item);
public void onError(Throwable throwable);
public void onComplete();
}
订阅
Subscriber 可以使用订阅 API 来控制发布者,无论是通过请求更多项目,还是完全取消订阅:
public static interface Subscription {
public void request(long n);
public void cancel();
}
是 Publisher 创建订阅。每当 Subscriber 订阅了该 Publisher 时,它会这样做。如果一个 Subscriber 竟然订阅了同一个发布者两次,它将触发 onError() 回调并抛出 IllegalStateException。
练习 1:具有单个发布者和单个订阅者的简单应用程序
在这个练习中,我们将构建一个具有单个 Publisher 和单个 Subscriber 的应用程序。Publisher 将发送一条消息字符串到 Subscriber,然后将其打印到终端。这些消息位于 lipsum.txt 文件中,该文件应放置在你的 projects /res 文件夹中。对于这个练习,我们将使用常见的 ForkJoinPool 来生成执行器:
-
如果 IntelliJ 已经启动,但没有打开任何项目,请选择
创建新项目。如果 IntelliJ 已经打开了项目,请从菜单中选择文件à新建à项目。 -
在
新建项目对话框中,选择 Java 项目,然后点击下一步。 -
打勾以从模板创建项目。选择
命令行应用程序,然后点击下一步。 -
将新项目命名为
Chapter17。 -
IntelliJ 会为你提供一个默认的项目位置。如果你希望选择一个不同的位置,你可以在这里输入。
-
将包名设置为
com.packt.java.chapter17。 -
点击
完成。IntelliJ 将创建你的项目,名为
Chapter17,并具有标准的文件夹结构。IntelliJ 还将创建一个名为Main.java的主入口点。 -
将此文件重命名为
Exercise1.java。完成时,它应该看起来像这样:package com.packt.java.chapter17; public class Exercise1 { public static void main(String[] args) { // write your code here } } -
在这个练习中,我们将使用
SubmissionPublisher。这是Publisher接口的一个完整功能实现,您可以使用它来演示反应式应用程序的基本功能。声明一个默认的SubmissionPublisher,如下所示,然后初始化它:package com.packt.java.chapter17; import java.util.concurrent.SubmissionPublisher; public class Exercise1 { public static void main(String[] args) { SubmissionPublisher<String> publisher = new SubmissionPublisher<>(); } } -
Flow 不包含任何现成的
Subscriber实现,因此我们需要实现自己的Subscriber。创建一个名为LipsumSubscriber的新类,并允许它实现Flow.Subscriber接口。您的新类应类似于以下示例:@Override public void onSubscribe(Flow.Subscription subscription) { } @Override public void onNext(String item) { } @Override public void onError(Throwable throwable) { } @Override public void onComplete() { } } -
订阅者有四个方法需要实现。当
Subscription对象被创建时,发布者将调用onSubscribe方法。通常,您会存储对该订阅的引用,以便可以向发布者发出请求。在您的LipsumSubscriber类中创建一个Flow.Subscription成员变量,并将onSubscribe方法中的引用存储起来:private Flow.Subscription subscription; @Override public void onSubscribe(Flow.Subscription subscription) { this.subscription = subscription; } @Override public void onNext(String item) { } @Override public void onError(Throwable throwable) { } @Override public void onComplete() { } } -
通常,在创建订阅时,您也会请求至少一个项目。使用
request方法从发布者请求一个项目:@Override public void onSubscribe(Flow.Subscription subscription) { this.subscription = subscription; this.subscription.request(1); } @Override public void onNext(String item) { } @Override public void onError(Throwable throwable) { } @Override public void onComplete() { } } -
看一下类中的下一个方法,称为
onNext,这是发布者每次向所有订阅者发出项目时执行的回调。在这个例子中,我们将简单地打印项目的内容:@Override public void onNext(String item) { System.out.println(item); } @Override public void onError(Throwable throwable) { } @Override public void onComplete() { } } -
为了从发布者那里获取更多项目,我们需要不断请求它们;这被称为背压。在处理一次可以处理多少个项目方面,控制权在订阅者手中。在这个练习中,我们将一次处理一个项目,然后请求另一个。在将当前项目打印到控制台后,请求另一个项目:
@Override public void onNext(String item) { System.out.println(item); this.subscription.request(1); } @Override public void onError(Throwable throwable) { } @Override public void onComplete() { } } -
订阅者可以使用
onError和onComplete方法进行清理,并确保没有资源被无用地保留。在这个例子中,我们将简单地打印错误和完成消息:@Override public void onError(Throwable throwable) { System.out.println(throwable.getMessage()); } @Override public void onComplete() { System.out.println("completed"); } } -
回到
main方法,创建一个新的订阅者,并允许它订阅发布者:package com.packt.java.chapter17; import java.util.concurrent.SubmissionPublisher; public class Exercise1 { public static void main(String[] args) { SubmissionPublisher<String> publisher = new SubmissionPublisher<>(); LipsumSubscriber lipsumSubscriber = new LipsumSubscriber(); publisher.subscribe(lipsumSubscriber); } } -
然而,这实际上并不会做任何事情。发布者仍然没有要发送的数据,因此我们需要向发布者提供数据。我们将使用
lipsum.txt文件作为源。将文件复制到项目中res/文件夹。如果该文件夹不存在,则创建它:package com.packt.java.chapter17; import java.util.concurrent.SubmissionPublisher; public class Exercise1 { public static void main(String[] args) { SubmissionPublisher<String> publisher = new SubmissionPublisher<>(); LipsumSubscriber lipsumSubscriber = new LipsumSubscriber(); publisher.subscribe(lipsumSubscriber); String filePath = "res/lipsum.txt"; } } -
要将
lipsum.txt文件中的单词发送到Publisher,您需要将文件加载到某种容器中。我们将使用StreamAPI 来加载单词,然后立即将它们推送到发布者。将流包装在 try-with-resources 块中,以便在加载后 JVM 自动关闭资源:package com.packt.java.chapter17; import java.io.IOException; import java.nio.file.Files; import java.nio.file.Paths; import java.util.Arrays; import java.util.concurrent.SubmissionPublisher; import java.util.stream.Stream; public class Exercise1 { public static void main(String[] args) { SubmissionPublisher<String> publisher = new SubmissionPublisher<>(); LipsumSubscriber lipsumSubscriber = new LipsumSubscriber(); publisher.subscribe(lipsumSubscriber); String filePath = "res/lipsum.txt"; try (Stream<String> words = Files.lines(Paths.get(filePath))) { words.flatMap((l) -> Arrays.stream(l.split("[\\s.,\\n]+"))) .forEach(publisher::submit); } catch (IOException e) { e.printStackTrace(); } } }在这里,我们将文件作为字符串流加载。它将逐行将文件中的行加载到单个字符串中。由于每一行可能包含多个单词,我们需要对每一行应用平坦映射以提取单词。我们使用一个简单的正则表达式将行拆分为单词,寻找一个或多个空白字符、标点符号或换行符。
注意
你可以在第十五章,使用流处理数据中了解更多关于 Streams API 和这里使用的方法。
-
在这一点上,程序将执行并打印文件中可用的所有单词。然而,你可能注意到它没有打印任何完成消息。这是因为我们实际上还没有通知
Subscriber流已经结束。发送完成信号,如下所示:package com.packt.java.chapter17; import java.io.IOException; import java.nio.file.Files; import java.nio.file.Paths; import java.util.Arrays; import java.util.concurrent.SubmissionPublisher; import java.util.stream.Stream; public class Exercise1 { public static void main(String[] args) { SubmissionPublisher<String> publisher = new SubmissionPublisher<>(); LipsumSubscriber lipsumSubscriber = new LipsumSubscriber(); publisher.subscribe(lipsumSubscriber); String filePath = "res/lipsum.txt"; try (Stream<String> words = Files.lines(Paths.get(filePath))) { words.flatMap((l) -> Arrays.stream(l.split("[\\s.,\\n]+"))) .forEach(publisher::submit); publisher.close(); } catch (IOException e) { e.printStackTrace(); } } }运行此程序应在控制台产生以下输出:
Lorem ipsum dolor sit amet consectetur adipiscing elit Pellentesque malesuada ultricies ultricies Curabitur ...
在构建了你的第一个响应式应用程序后,你可能注意到在非常简单的程序中使用这种额外的逻辑并没有太多意义,如本例所示。将响应式流的概念应用于简单示例几乎没有意义,因为它旨在用于异步应用程序,在这些应用程序中,你可能不确定Subscriber何时或是否可以接收消息。
处理器
处理器在 Flow 中有点像变色龙;它可以同时充当Subscriber和Publisher。
添加处理器等接口的几个不同原因之一可能是因为你有一个你不完全信任的数据流。想象一下从服务器异步流数据,数据通过缺乏交付承诺的 UDP 连接传递;这些数据最终会被损坏,你需要处理这种情况。一种简单的方法是在发布者和订阅者之间注入某种类型的过滤器。这就是Processor大显身手的地方。
使用处理器另一个可能的原因是将多态数据流在不同的订阅者之间分离,以便可以根据数据类型采取不同的操作。
练习 2:使用处理器将字符串流转换为数字
在这个练习中,我们将首先构建一个发布者,该发布者定期从文本文件发布字符串。然后,我们将使用调度器来控制计时器。然后,Subscriber应该尝试将某个字符串转换为数字。numbers.txt文件将用于构建此应用程序。在这个例子中,我们还将展示如何使用 Supplier 实现来清理数据处理,以使数据源抽象化。
numbers.txt文件包含故意引入的错误,我们将在Subscriber之前应用处理器来处理这些错误:
-
如果
Chapter17项目尚未打开,请将其在 IDEA 中打开。 -
使用
File|New|Java Class菜单创建一个新的 Java 类。 -
在
创建新类对话框中,将Name设置为Exercise2,然后选择OK。IntelliJ 将创建你的新类。它应该看起来像以下代码片段:
package com.packt.java.chapter17; public class Exercise2 { } -
向此类添加一个
main方法:package com.packt.java.chapter17; public class Exercise2 { public static void main (String[] args) { } } -
我们将继续使用 Flow 库中提供的基本
SubmissionPublisher,但在本练习中,我们将创建自己的子类。创建一个名为NumberPublisher的新类。它应该扩展SubmissionPublisher,如下面的代码块所示:package com.packt.java.chapter17; import java.util.concurrent.SubmissionPublisher; public class NumberPublisher extends SubmissionPublisher<String> { } -
我们的新
NumberPublisher应该定期向任何感兴趣的Subscriber发布数字。在如何实现这一点方面有几个不同的选项,但可能最简单的解决方案是使用Timer。向你的发布者添加一个Timer和一个TimerTask:package com.packt.java.chapter17; import java.util.Timer; import java.util.TimerTask; import java.util.concurrent.SubmissionPublisher; public class NumberPublisher extends SubmissionPublisher<String> { final Timer timer = new Timer(); final TimerTask timerTask = new TimerTask() { @Override public void run() { } }; public NumberPublisher() { } } -
当发布者关闭时,
Timer也应该关闭。覆盖发布者的close()方法,并在发布者即将关闭之前调用Timer的cancel()方法:@Override public void close() { timer.cancel(); super.close(); } -
有两种不同的方式让发布者向连接的订阅者发送项目。使用
submit()或offer()。submit()以“发射并遗忘”的方式工作,而offer()允许发布者使用处理程序重试发送项目一次。在我们的情况下,submit()就足够好了。但在提交之前,你需要一些数据。使用依赖注入向Publisher添加一个Supplier:package com.packt.java.chapter17; import java.util.Timer; import java.util.TimerTask; import java.util.concurrent.SubmissionPublisher; import java.util.function.Supplier; public class NumberPublisher extends SubmissionPublisher<String> { final Timer timer = new Timer(); final TimerTask timerTask = new TimerTask() { @Override public void run() { } }; final Supplier<String> supplier; public NumberPublisher(Supplier<String> supplier) { this.supplier = supplier; } @Override public void close() { timer.cancel(); super.close(); } }注意
供应商是一个常用于传递结果的功能式接口——向任何人或任何事物传递。
-
现在我们知道了如何使用
Supplier获取所需的数据,我们实际上可以将其发送给订阅者。在TimerTask的run()方法中,添加对submit()的调用并从供应商获取数据:@Override public void run() { submit(supplier.get()); } }; final Supplier<String> supplier; public NumberPublisher(Supplier<String> supplier) { this.supplier = supplier; } @Override public void close() { timer.cancel(); super.close(); } } -
最后还有一件事,因为发布者在尝试从供应商获取项目或继续发送项目时可能会遇到麻烦。我们需要在尝试执行
submit()方法时捕获任何异常。添加一个 try-catch 子句,并使用closeExceptionally()方法通知任何订阅者我们遇到了困难。执行closeExceptionally()将迫使发布者进入一个无法发送任何其他内容的状态:@Override public void run() { try { submit(supplier.get()); } catch (Exception e) { closeExceptionally(e); } } }; final Supplier<String> supplier; public NumberPublisher(Supplier<String> supplier) { this.supplier = supplier; } @Override public void close() { timer.cancel(); super.close(); } } -
现在,
TimerTask已经完全实现。数据通过Supplier注入到Publisher中,并且关闭处理已经就绪。剩下要做的只是实际安排定期发布。使用Timer,每秒重复执行TimerTask。由于TimerTask只接受毫秒,我们需要记住将延迟乘以1000。我们还设置了初始延迟为1000毫秒:package com.packt.java.chapter17; import java.util.Timer; import java.util.TimerTask; import java.util.concurrent.SubmissionPublisher; import java.util.function.Supplier; public class NumberPublisher extends SubmissionPublisher<String> { final Timer timer = new Timer(); final TimerTask timerTask = new TimerTask() { @Override public void run() { try { submit(supplier.get()); } catch (Exception e) { closeExceptionally(e); } } }; final Supplier<String> supplier; public NumberPublisher(Supplier<String> supplier) { this.supplier = supplier; this.timer.schedule(timerTask, 1000, 1000); } @Override public void close() { timer.cancel(); super.close(); } } -
现在,我们的
NumberPublisher已经准备好了,我们需要开始向它提供数据,但为了提供应该发布的数据,我们需要加载数据。我们将发送的数据位于numbers.txt文件中。将numbers.txt文件复制到/res文件夹,如果文件夹不存在则创建它。 -
在
Exercise2类中,创建一个名为getStrings()的新方法,该方法将返回numbers.txt文件中的数字作为Strings:package com.packt.java.chapter17; public class Exercise2 { public static void main(String[] args) { } private static String[] getStrings() { } } -
在这个新方法中,创建一个名为
filePath的变量。让它指向位于/res文件夹中的numbers.txt文件。我们将使用这个filePath变量在下一步加载文件内容:package com.packt.java.chapter17; public class Exercise2 { public static void main(String[] args) { } private static String[] getStrings() { String filePath = "res/numbers.txt"; } } -
将文件内容加载到
String流中,然后使用 try-with-resources 块包装加载操作,这样我们就不需要在完成时关心释放文件资源:package com.packt.java.chapter17; import java.io.IOException; import java.nio.file.Files; import java.nio.file.Paths; import java.util.stream.Stream; public class Exercise2 { public static void main(String[] args) { } private static String[] getStrings() { String filePath = "res/numbers.txt"; try (Stream<String> words = Files.lines(Paths.get(filePath))) { } catch (IOException e) { e.printStackTrace(); } } } -
numbers.txt文件包含很多数字和一些可能后来引起麻烦的其他字符。但是,为了实际上解码文件到单个单词,我们需要审查文件的结构。让我们打开它,你应该会看到类似这样的内容——多行具有类似列的结构:6 2e 22 4 11 59 73 41 60 8 42 91 99 89 17 96 54 24 77 36 12 9 64 0a 31 75 1 14 34 56 67 78 37 87 93 92 100 28 47 5 52 85 29 38 21 88 65 81 25 70 95 3 74 2 35 84 32 66 86 69 58 45 48 10 26 53 40 13 49 94 98 71 39 68 76 43 63 7g 72 80 61 46 57 18 79 27 20 83 82 33 97 2h 50 44 15 16 55 30 19 51 -
我们刚刚加载的字符串流不会很有帮助。流中的每个项目将代表一行,我们需要在它对我们有用之前对其进行转换。首先,应用一个
flatMap操作符来为原始流中的每个项目创建一个新的流。这将使我们能够将每一行拆分成多个项目,并将它们返回到主流中:package com.packt.java.chapter17; import java.io.IOException; import java.nio.file.Files; import java.nio.file.Paths; import java.util.Arrays; import java.util.stream.Stream; public class Exercise2 { public static void main(String[] args) { } private static String[] getStrings() { String filePath = "res/numbers.txt"; try (Stream<String> words = Files.lines(Paths.get(filePath))) { return words.flatMap((line) -> Arrays.stream(line.split("[\\s\\n]+"))) } catch (IOException e) { e.printStackTrace(); } } }注意
你可以在第十五章“使用流处理数据”和第十二章“正则表达式”中了解更多关于使用流处理数据的信息。
-
流现在包含代表每一行的每一列的项目。但是,为了使用这些数据,我们需要根据长度进行过滤,因为我们不希望有任何长度为
0的单词,然后我们需要将流转换成一个字符串数组。过滤流的项目,只允许长度超过0的单词通过:package com.packt.java.chapter17; import java.io.IOException; import java.nio.file.Files; import java.nio.file.Paths; import java.util.Arrays; import java.util.stream.Stream; public class Exercise2 { public static void main(String[] args) { } private static String[] getStrings() { String filePath = "res/numbers.txt"; try (Stream<String> words = Files.lines(Paths.get(filePath))) { return words.flatMap((line) -> Arrays.stream(line.split("[\\s\\n]+"))) .filter((word) -> word.length() > 0) } catch (IOException e) { e.printStackTrace(); } } } -
现在,将整个流转换成一个字符串数组。这将返回一个字符串数组给方法的调用者。然而,如果我们读取文件时出现错误,我们也需要返回一些内容。在
getStrings()方法的最后返回null。发布者会将null解释为错误并抛出NullPointerException,关闭与订阅者的连接:package com.packt.java.chapter17; import java.io.IOException; import java.nio.file.Files; import java.nio.file.Paths; import java.util.Arrays; import java.util.stream.Stream; public class Exercise2 { public static void main(String[] args) { } private static String[] getStrings() { String filePath = "res/numbers.txt"; try (Stream<String> words = Files.lines(Paths.get(filePath))) { return words.flatMap((line) -> Arrays.stream(line.split("[\\s\\n]+"))) .filter((word) -> word.length() > 0) .toArray(String[]::new); } catch (IOException e) { e.printStackTrace(); } return null; } } -
我们小程序的数据已经准备好推送到发布者,以便它可以发送给任何感兴趣的订阅者。现在,我们需要构建一个供应商,它将接受这些字符串,并在发布者请求时逐个将它们发送给发布者。在
Exercise2类的main方法中创建一个供应商:package com.packt.java.chapter17; import java.io.IOException; import java.nio.file.Files; import java.nio.file.Paths; import java.util.Arrays; import java.util.function.Supplier; import java.util.stream.Stream; public class Exercise2 { public static void main(String[] args) { Supplier<String> supplier = new Supplier<String>() { @Override public String get() { return null; } }; } } -
让供应商调用
getStrings()来检索完整的数组:public class Exercise2 { public static void main(String[] args) { Supplier<String> supplier = new Supplier<String>() { @Override public String get() { String[] data = getStrings(); return null; } }; } } -
然而,供应商不能返回整个数据集;它被设计为一次返回一个字符串。为了使这起作用,我们需要保留发送到
Supplier的最后一个字符串的索引:public class Exercise2 { public static void main(String[] args) { Supplier<String> supplier = new Supplier<String>() { int index; @Override public String get() { String[] data = getStrings(); return data[index]; } }; } } -
这将不断返回文件中的第一个数字,而这不是我们想要的。因此,我们需要在有人请求供应商字符串时每次递增索引:
public class Exercise2 { public static void main(String[] args) { Supplier<String> supplier = new Supplier<String>() { int index; @Override public String get() { String[] data = getStrings(); return data[index++]; } }; } } -
然而,当我们达到文件中的最后一个数字时,这会抛出一个异常。因此,我们需要对此进行保护。在这种情况下,当我们到达末尾时,我们将返回
null。添加一个if语句,检查我们是否走得太远:public class Exercise2 { public static void main(String[] args) { Supplier<String> supplier = new Supplier<String>() { int index; @Override public String get() { String[] data = getStrings(); if (index < data.length - 1) { return data[index++]; } else { return null; } } }; } } -
供应商现在可以由我们的
NumberPublisher使用了。在Exercise2的main()方法中创建一个NumberPublisher实例,并将供应商作为参数传递:public class Exercise2 { public static void main(String[] args) { Supplier<String> supplier = new Supplier<String>() { int index; @Override public String get() { String[] data = getStrings(); if (index < data.length - 1) { return data[index++]; } else { return null; } } }; NumberPublisher publisher = new NumberPublisher(supplier); } } -
创建一个订阅者,并允许它在订阅成功时请求一个项目。然后,每次它接收到一个项目时请求一个新的项目——背压。在实现订阅者时,为每个方法添加打印输出,这样我们就可以轻松地看到发生了什么:
package com.packt.java.chapter17; import java.io.IOException; import java.nio.file.Files; import java.nio.file.Paths; import java.util.Arrays; import java.util.concurrent.Flow; import java.util.function.Supplier; import java.util.stream.Stream; public class Exercise2 { public static void main(String[] args) { Supplier<String> supplier = new Supplier<String>() { int index; @Override public String get() { String[] data = getStrings(); if (index < data.length - 1) { return data[index++]; } else { return null; } } }; NumberPublisher publisher = new NumberPublisher(supplier); publisher.subscribe(new Flow.Subscriber<>() { Flow.Subscription subscription; @Override public void onSubscribe(Flow.Subscription subscription) { this.subscription = subscription; subscription.request(1); } @Override public void onNext(String item) { System.out.println("onNext: " + item); subscription.request(1); } @Override public void onError(Throwable throwable) { System.out.println("onError: " + throwable.getMessage()); } @Override public void onComplete() { System.out.println("onComplete()"); } }); } }运行此代码,你应该在控制台得到输出,整个文件应该打印:
onNext: 6 onNext: 2e onNext: 22 onNext: 4 onNext: 11 onNext: 59 onNext: 73 ... -
然而,在订阅者中,我们期望得到可以轻松转换为整数的数据。如果我们对文本应用简单的整数解析,我们最终会遇到麻烦:
publisher.subscribe(new Flow.Subscriber<>() { Flow.Subscription subscription; @Override public void onSubscribe(Flow.Subscription subscription) { this.subscription = subscription; subscription.request(1); } @Override public void onNext(String item) { System.out.println("onNext: " + Integer.valueOf(item)); subscription.request(1); } @Override public void onError(Throwable throwable) { System.out.println("onError: " + throwable.getMessage()); } @Override public void onComplete() { System.out.println("onComplete()"); } });这将在到达第二个项目
2e时停止,抛出一个解析异常,显然,这并不是一个整数:onNext: 6 onError: For input string: "2e"为了纠正有问题的订阅者的问题,你当然可以在那里捕获异常。但在这个练习中,我们将涉及一个过滤器处理器。
Processor将订阅Publisher,而Subscriber将订阅Processor。本质上,Processor既是发布者也是订阅者。为了让我们更容易理解,允许NumberProcessor扩展SubmissionPublisher,就像NumberPublisher一样。![图 17.2:订阅者、处理器和发布者之间的通信
![img/C13927_17_02.jpg]()
图 17.2:订阅者、处理器和发布者之间的通信
-
创建一个名为
NumberProcessor的类,允许它扩展SubmissionPublisher并实现Flow.Processor接口:package com.packt.java.chapter17; import java.util.concurrent.Flow; import java.util.concurrent.SubmissionPublisher; public class NumberProcessor extends SubmissionPublisher<String> implements Flow.Processor<String, String> { @Override public void onSubscribe(Flow.Subscription subscription) { } @Override public void onNext(String item) { } @Override public void onError(Throwable throwable) { } @Override public void onComplete() { } } -
NumberProcessor将订阅NumberPublisher,就像订阅者一样,它需要存储对发布者的引用,以便它可以控制何时请求新项目。将onSubscribe()中接收到的引用存储在处理器中的私有字段中。同时,利用这个机会从发布者那里请求第一个项目:package com.packt.java.chapter17; import java.util.concurrent.Flow; import java.util.concurrent.SubmissionPublisher; public class NumberProcessor extends SubmissionPublisher<String> implements Flow.Processor<String, String> { private Flow.Subscription subscription; @Override public void onSubscribe(Flow.Subscription subscription) { this.subscription = subscription; this.subscription.request(1); } @Override public void onNext(String item) { } @Override public void onError(Throwable throwable) { } @Override public void onComplete() { } } -
每次从发布者接收项目时,你也需要请求下一个项目,就像订阅者一样:
package com.packt.java.chapter17; import java.util.concurrent.Flow; import java.util.concurrent.SubmissionPublisher; public class NumberProcessor extends SubmissionPublisher<String> implements Flow.Processor<String, String> { private Flow.Subscription subscription; @Override public void onSubscribe(Flow.Subscription subscription) { this.subscription = subscription; this.subscription.request(1); } @Override public void onNext(String item) { this.subscription.request(1); } @Override public void onError(Throwable throwable) { } @Override public void onComplete() { } } -
如果
NumberPublisher的订阅被关闭,我们还需要通知订阅者存在问题。同样,当订阅结束时,我们也需要通知订阅者。在onError()回调中,添加对closeExceptionally()的调用,并在onComplete()中添加对close()的调用:package com.packt.java.chapter17; import java.util.concurrent.Flow; import java.util.concurrent.SubmissionPublisher; public class NumberProcessor extends SubmissionPublisher<String> implements Flow.Processor<String, String> { private Flow.Subscription subscription; @Override public void onSubscribe(Flow.Subscription subscription) { this.subscription = subscription; this.subscription.request(1); } @Override public void onNext(String item) { this.subscription.request(1); } @Override public void onError(Throwable throwable) { closeExceptionally(throwable); } @Override public void onComplete() { close(); } } -
处理器几乎完成了。唯一缺少的是将接收到的项目传达给订阅者。我们将在
onNext()回调方法中这样做。然而,由于我们知道可能存在无效的值,我们想要过滤掉这些值。我们将为此使用一个谓词,在NumberProcessor类中声明一个谓词:package com.packt.java.chapter17; import java.util.concurrent.Flow; import java.util.concurrent.SubmissionPublisher; import java.util.function.Predicate; public class NumberProcessor extends SubmissionPublisher<String> implements Flow.Processor<String, String> { private Flow.Subscription subscription; private Predicate<String> predicate = new Predicate<String>() { @Override public boolean test(String s) { return false; } }; @Override public void onSubscribe(Flow.Subscription subscription) { this.subscription = subscription; this.subscription.request(1); } @Override public void onNext(String item) { this.subscription.request(1); } @Override public void onError(Throwable throwable) { closeExceptionally(throwable); } @Override public void onComplete() { close(); } } -
谓词是一个简单的函数式接口,它使用
test()方法来验证输入。如果值是可接受的,则test()方法应始终返回true;如果值有误,则返回false。在我们的谓词中,我们将尝试解析提供的字符串。如果解析成功,我们将返回true;否则,返回false:package com.packt.java.chapter17; import java.util.concurrent.Flow; import java.util.concurrent.SubmissionPublisher; import java.util.function.Predicate; public class NumberProcessor extends SubmissionPublisher<String> implements Flow.Processor<String, String> { private Flow.Subscription subscription; private Predicate<String> predicate = new Predicate<String>() { @Override public boolean test(String s) { try { Integer.valueOf(s); return true; } catch (NumberFormatException e) { return false; } } }; @Override public void onSubscribe(Flow.Subscription subscription) { this.subscription = subscription; this.subscription.request(1); } @Override public void onNext(String item) { this.subscription.request(1); } @Override public void onError(Throwable throwable) { closeExceptionally(throwable); } @Override public void onComplete() { close(); } } -
在
onNext()回调中,我们现在可以使用我们的谓词来验证在提交给订阅者之前提供的值:package com.packt.java.chapter17; import java.util.concurrent.Flow; import java.util.concurrent.SubmissionPublisher; import java.util.function.Predicate; public class NumberProcessor extends SubmissionPublisher<String> implements Flow.Processor<String, String> { private Flow.Subscription subscription; private Predicate<String> predicate = new Predicate<String>() { @Override public boolean test(String s) { try { Integer.valueOf(s); return true; } catch (NumberFormatException e) { return false; } } }; @Override public void onSubscribe(Flow.Subscription subscription) { this.subscription = subscription; this.subscription.request(1); } @Override public void onNext(String item) { if (predicate.test(item)) { submit(item); } this.subscription.request(1); } @Override public void onError(Throwable throwable) { closeExceptionally(throwable); } @Override public void onComplete() { close(); } }注意
你可以在第十六章,谓词和其他功能接口中了解更多关于谓词及其使用方法的信息。
-
现在既然你的
Processor已经准备好了,就在NumberPublisher和Subscriber之间注入它:package com.packt.java.chapter17; import java.io.IOException; import java.nio.file.Files; import java.nio.file.Paths; import java.util.Arrays; import java.util.concurrent.Flow; import java.util.function.Supplier; import java.util.stream.Stream; public class Exercise2 { public static void main(String[] args) { Supplier<String> supplier = new Supplier<String>() { int index; @Override public String get() { String[] data = getStrings(); if (index < data.length - 1) { return data[index++]; } else { return null; } } }; NumberPublisher publisher = new NumberPublisher(supplier); NumberProcessor processor = new NumberProcessor(); publisher.subscribe(processor); processor.subscribe(new Flow.Subscriber<>() { Flow.Subscription subscription; @Override public void onSubscribe(Flow.Subscription subscription) { this.subscription = subscription; subscription.request(1); } @Override public void onNext(String item) { System.out.println("onNext: " + Integer.valueOf(item)); subscription.request(1); } @Override public void onError(Throwable throwable) { System.out.println("onError: " + throwable.getMessage()); } @Override public void onComplete() { System.out.println("onComplete()"); } }); } private static String[] getStrings() { String filePath = "res/numbers.txt"; try (Stream<String> words = Files.lines(Paths.get(filePath))) { return words.flatMap((line) -> Arrays.stream(line.split("[\\s\\n]+"))) .filter((word) -> word.length() > 0) .toArray(String[]::new); } catch (IOException e) { e.printStackTrace(); } return null; } }运行此示例后,你应该看到处理器在值到达订阅者之前从文件中过滤掉错误数值:
onNext: 6 onNext: 22 onNext: 4 onNext: 11 onNext: 59 onNext: 73 onNext: 41 onNext: 60 onNext: 8 ...
这个例子展示了如何从出版商那里获取内容,并通过处理器确保值的有效性。
活动 1:让 NumberProcessor 将值格式化为整数
进一步改进NumberProcessor。让它不仅验证值可以被解析为整数,而且将它们作为整数发布给订阅者。订阅者应仅接受整数值,不再需要解析接收到的值。
-
将处理器发布的项类型更改为 Integer。在实现中做出必要的更改以匹配新类型。
-
更改处理器的订阅者,它应在
onNext方法中仅接受整数值。注意
本活动的解决方案可以在第 568 页找到。
摘要
在本章中,你学习了响应式流组件的基础知识,它们如何通信,以及它们在响应式应用程序中的相应角色。
在大多数情况下,你应该避免使用 Flow API 构建响应式应用程序,因为那里有更多高级且用户友好的响应式库可用。Flow API 仅提供响应式应用程序的基本构建块,而像 Akka 或 RxJava 这样的实现将为你提供更丰富的体验,提供诸如节流、过滤和去抖动等基本功能。如果你对深入响应式编程感兴趣,有整本书都是关于这个主题的。
如前所述,Flow 为构建自己的响应式流库提供了基础,尽管这可能很复杂。如果你希望实现自己的响应式流库,你应该首先审查响应式流技术兼容性套件。这个基于测试的套件将帮助你确保你的实现遵循响应式流规则。
在下一章,专注于单元测试之后,你应该准备好开始查看兼容性套件并构建自己的响应式流库。
第十八章:18. 单元测试
概述
本章重点介绍使用 JUnit 进行测试,它是 Java 的主要测试框架之一。在其早期章节和练习中,你将学习如何使用 JUnit 编写成功的单元测试来测试你的代码,使用断言来验证你的代码是否正确。然后,你将介绍参数化测试——一种允许你在一系列数据输入上运行相同测试的单元测试类型——你也将学习如何编写。最后,本章将定义模拟技术,这是一种练习如何“模拟”外部依赖的技术,这样你就可以专注于测试单个 Java 类。
简介
测试可以帮助你确保你的 Java 代码运行正确。例如,如果你在计算员工的工资,你希望代码是准确的;否则,你的组织可能会面临法律后果。虽然并非每个编程问题都会导致法律灾难,但测试你的代码仍然是一个好主意。
在编码时编写测试,而不是在完成时编写,可以加快你的工作速度。这是因为你不会花费时间去试图弄清楚为什么事情似乎不起作用。相反,你会确切地知道代码的哪个部分是不正确的。这对于任何需要复杂逻辑的代码来说特别有用。
此外,随着代码中添加了新的增强功能,你将想要确保新代码中没有破坏旧功能。一套编写良好的单元测试可以在这一方面真正帮助你。如果你是一名新开发人员,被雇佣到一个已经开发了一段时间的应用程序团队中,一套良好的测试是团队遵循工程最佳实践的标志。
开始编写单元测试
单元测试测试一个代码单元。在 Java 术语中,这通常意味着单元测试测试一个单一的 Java 类。测试应该运行得很快,这样你就可以尽快知道是否有任何问题。
单元测试是一个专门用于测试的独立 Java 类。你应该为原始类中你想测试的每个部分编写单独的测试方法。通常,测试越细粒度,越好。
有时,由于必要性,单元测试可能会测试多个类。这是可以的,不必担心。不过,通常来说,你希望专注于为你的 Java 应用程序中的每个类编写单独的测试。
注意
编写易于测试的 Java 类可以提高你的代码质量。这将使你的代码组织更好,代码更清晰,质量更高。
相反,集成测试测试整个系统的一部分,包括外部依赖。例如,单元测试不应该访问数据库。这是集成测试的工作。
功能测试更进一步,测试整个系统,例如在线银行应用程序或零售商店应用程序。这有时被称为端到端测试。
注意
如果你表示不相信编写测试,软件开发工作面试往往会进行得很糟糕。
介绍 JUnit
JUnit 为 Java 代码提供了最广泛使用的测试框架。现在已经是第 5 版,JUnit 已经存在多年。
使用 JUnit,你的测试将位于测试类中,即使用 JUnit 框架来验证代码的类。这些测试类位于主应用程序代码之外。这就是为什么 Maven 和 Gradle 项目在 src 目录下都有两个子目录:main,用于你的应用程序代码,test,用于测试。
通常,测试不是你构建的应用程序的一部分。所以,如果你为你的应用程序构建一个 JAR 文件,测试将不会包含在那个 JAR 文件中。
JUnit 已经存在很长时间了,你可以在packt.live/2J9seWE找到官方文档,在packt.live/31xFtXu找到官方网站。
注意
另一个流行的测试框架叫做 Spock。Spock 使用 Groovy 语言,这是一种类似于 Java 的 JVM 语言。你可以参考packt.live/2P4fPqG了解更多关于 Spock 的信息。TestNG 是另一个 Java 单元测试框架。你可以参考packt.live/33X2nct了解更多关于 TestNG 的信息。
使用 JUnit 编写单元测试
Oozie 是 Hadoop 大数据集群的工作流调度器。Oozie 工作流是在 Hadoop 集群中存储的潜在大量数据上执行任务的作业。Oozie 协调器作业按计划运行工作流作业。
在定义计划时,你通常设置三个值:
-
开始时间戳,定义协调器何时应该启动工作流作业。
-
结束时间戳,定义协调器何时应该结束。
-
协调器应该启动作业的频率(以分钟为单位)。例如,60 的频率表示每 60 分钟(即每小时)启动一个工作流作业,从开始时间戳到结束时间戳。
注意
你可以参考
packt.live/2BzqlOJ了解更多关于 Oozie 协调器和更多调度选项的信息。现在,我们只需集中验证协调器的调度信息。
在这里,我们将定义一个简单的 JavaBean 类来存储调度信息,然后编写一个 JUnit 测试来验证协调器的调度。
基本 Bean 的样子如下(省略了 getters、setters 和构造函数):
public class CoordSchedule {
private String startingTimestamp;
private String endingTimestamp;
private int frequency;
}
开始和结束时间戳是基于这样一个假设,即这个 Bean 会持有从配置文件中读取的数据的 String 值。它还允许我们验证时间戳的 String 格式。
注意
记住 IntelliJ 可以生成构造函数以及 getter 和 setter 方法。
现在,考虑你想要测试的内容,以及你将如何编写这些测试。测试边缘情况是一个好主意。
对于协调者,以下是一些规则:
-
结束时间戳必须晚于开始时间戳。
-
两个时间戳都必须以
yyyy-MM-ddTHH:mmZ的格式(这是 ISO 8601 格式)在 UTC 中。 -
频率必须小于 1,440(即正常一天中的分钟数)。Oozie 提供了超越此限制的替代配置设置。现在,我们只需测试这个限制。
-
频率应大于 5(这是一个旨在防止在另一个工作流程仍在运行时启动新工作流程的任意规则)。
要创建一个测试,你需要创建一个单独的测试类。测试类应该有一个无参数的构造函数。测试类不能是抽象类。
注意
如果你使用 Maven 构建工具(参考第六章,库、包和模块),那么你的测试类应该都以 Test、Tests 或 TestCase 结尾。本章中所有测试类的名称都以 Test 结尾。
JUnit 使用 @Test 注解来识别测试方法。你可以添加一个 @DisplayName 注解来指定在测试失败时显示的文本。这可以使你的测试报告更容易阅读:
@Test
@DisplayName("Frequency must be less than 1440")
void testFrequency() {
}
在你的测试方法中,使用 Assertions 类的方法来验证结果:
Assertions.assertTrue(schedule.getFrequency() < 1440);
注意
JUnit 提供了一些其他断言方法,例如 assertEquals() 和 assertAll()。
练习 1:编写第一个单元测试
这个例子将展示编写 JUnit 单元测试的基础。对于这个练习,我们将简单地测试属性是否正确;尽管通常,你还会测试程序逻辑:
-
从 IntelliJ 的
文件菜单中选择新建,然后选择项目…。 -
选择项目的类型为
Gradle。点击下一步。 -
对于
Group Id,输入com.packtpub.testing。 -
对于
Artifact Id,输入chapter18。 -
对于
版本,输入1.0。 -
接受下一页的默认设置。点击
下一步。 -
将项目名称保留为
chapter18。 -
点击
完成。 -
在 IntelliJ 文本编辑器中调用
build.gradle。 -
将
sourceCompatibility修改为12:sourceCompatibility = 12 -
从
build.gradle文件中删除定义的 JUnit 依赖项(它是为旧版本设计的)。用以下依赖项替换该依赖项:testImplementation('org.junit.jupiter:junit-jupiter-api:5.4.2') testImplementation('org.junit.jupiter:junit-jupiter-engine:5.4.2')这会将 JUnit 5 引入到我们的项目中,而不是 JUnit 4。
在依赖项部分之后添加以下内容到
build.gradle:test { useJUnitPlatform() }这确保了你使用 JUnit 5 测试平台来运行测试。
-
在
src/main/java文件夹中,创建一个新的 Java 包。 -
将包名输入为
com.packtpub.testing。 -
在
src/test/java文件夹中,创建一个新的 Java 包。 -
输入相同的名称,
com.packtpub.testing。src/test/java文件夹是你放置测试类的地方。src/main/java文件夹是应用程序类所在的位置。 -
在
src/main/java文件夹中右键单击此包,并创建一个名为CoordSchedule的新 Java 类。 -
输入两个我们将用于验证数据的常量:
public static final int MAX_FREQUENCY = 1440; public static final int MIN_FREQUENCY = 5; -
输入这个类的属性:
private String startingTimestamp; private String endingTimestamp; private int frequency; -
将编辑光标放在类内部(即起始和结束的大括号之间),右键点击并选择
Generate…。 -
选择
Constructor,然后选择所有三个属性。您应该看到一个如下所示的构造函数:public CoordinatorSchedule(String startingTimestamp, String endingTimestamp, int frequency) { this.startingTimestamp = startingTimestamp; this.endingTimestamp = endingTimestamp; this.frequency = frequency; } -
再次,将编辑光标放在类内部(即起始和结束的大括号之间),右键点击并选择
Generate…。 -
选择
Getter和Setter,然后选择所有三个属性。您将看到三个属性的get和set方法。 -
输入以下方法来解析
String时间戳值:private Date parseTimestamp(String timestamp) { Date date = null; SimpleDateFormat format = new SimpleDateFormat("yyyy-MM-dd'T'HH:mm'Z'",Locale.getDefault()); format.setTimeZone(TimeZone.getTimeZone("UTC")); try { date = format.parse(timestamp); } catch (ParseException e) { e.printStackTrace(); } return date; } -
输入以下两个实用方法来返回两个时间戳的
Date对象:public Date getStartingTimestampAsDate() { return parseTimestamp(startingTimestamp); } public Date getEndingTimestampAsDate() { return parseTimestamp(endingTimestamp); }这些方法允许其他代码以日期格式获取时间戳,而不是作为字符串。
我们现在有了将要测试的 Java 类。
下一步是创建一个单元测试类。
-
在
src/test/java文件夹中右键点击此包,创建一个名为CoordScheduleTest的新 Java 类。 -
输入以下测试方法:
@Test @DisplayName("Frequency must be less than 1440") void testFrequency() { CoordSchedule schedule = new CoordSchedule( "2020-12-15T15:32Z", "2020-12-30T05:15Z", 60 ); Assertions.assertTrue(schedule.getFrequency() < 50); }注意,这个测试应该失败,因为我们使用了
50的最大值而不是实际要求的最大值1,440。首先看到失败的样子是好的。 -
点击
Gradle面板。展开Tasks,然后展开verification。 -
双击
Test。这会运行 Gradle 的test任务。这将显示如下输出(为了清晰起见,省略了大部分堆栈跟踪):> Task :compileJava UP-TO-DATE > Task :processResources NO-SOURCE > Task :classes UP-TO-DATE > Task :compileTestJava UP-TO-DATE > Task :processTestResources NO-SOURCE > Task :testClasses UP-TO-DATE > Task :test FAILED expected: <true> but was: <false> org.opentest4j.AssertionFailedError: expected: <true> but was: <false> at com.packtpub.testing.CoordScheduleTest.testFrequency(CoordScheduleTest.java:19) com.packtpub.testing.CoordScheduleTest > testFrequency() FAILED org.opentest4j.AssertionFailedError at CoordScheduleTest.java:19 1 test completed, 1 failed FAILURE: Build failed with an exception. -
这不是一个很好的测试报告。幸运的是,JUnit 提供了一个更好的报告。点击 Gradle 的象形图标,测试报告将在您的网页浏览器中显示:
![图 18.1:显示 Gradle 图标的 IntelliJ 运行面板]()
![img/C13927_18_01.jpg]()
图 18.1:显示 Gradle 图标的 IntelliJ 运行面板
-
切换到您的网页浏览器,您将看到测试报告:

图 18.2:在浏览器中显示的测试报告
您将看到失败测试的列表,其中包含 DisplayName 注解的文本。对于每个失败的测试,您可以深入测试。这提供了一个更好的格式来显示测试结果。
接下来,我们将修复损坏的测试并验证其他规则。
练习 2:编写成功的测试
现在我们有一个失败的测试,我们需要修复测试并添加测试方法来验证开始和结束时间戳:
-
在 IntelliJ 中编辑
CoordScheduleTest。 -
将
testFrequency()方法替换为以下代码:@Test @DisplayName("Frequency must be less than 1440") void testFrequency() { CoordSchedule schedule = new CoordSchedule( "2020-12-15T15:32Z", "2020-12-30T05:15Z", 60 ); int frequency = schedule.getFrequency(); Assertions.assertTrue(frequency < CoordSchedule.MAX_FREQUENCY); Assertions.assertTrue(frequency > CoordSchedule.MIN_FREQUENCY); } -
添加一个测试方法来检查格式不正确的日期:
@Test @DisplayName("Timestamp will be null if not formatted correctly") void testStartingTimestamps() { CoordSchedule schedule = new CoordSchedule( "2020/12/15T15:32Z", "2020-12-15T15:35Z", 60 ); Date starting = schedule.getStartingTimestampAsDate(); // Timestamp is not formatted properly. Assertions.assertNull(starting); } -
添加一个测试方法来验证结束时间戳是否晚于开始时间戳:
@Test @DisplayName("Ending timestamp must be after starting") void testTimestamps() { CoordSchedule schedule = new CoordSchedule( "2020-12-15T15:32Z", "2020-12-15T15:35Z", 60 ); Date starting = schedule.getStartingTimestampAsDate(); Assertions.assertNotNull(starting); Date ending = schedule.getEndingTimestampAsDate(); Assertions.assertNotNull(ending); Assertions.assertTrue(ending.after(starting)); } -
点击
Gradle面板。展开Tasks,然后展开verification。 -
双击
Test。这会运行 Gradle 的test任务。这将显示如下输出(为了清晰起见,省略了大部分堆栈跟踪):Testing started at 14:59 ... 14:59:33: Executing tasks ':cleanTest :test --tests "com.packtpub.testing.CoordScheduleTest"'... > Task :cleanTest > Task :compileJava > Task :processResources NO-SOURCE > Task :classes > Task :compileTestJava > Task :processTestResources NO-SOURCE > Task :testClasses > Task :test java.text.ParseException: Unparseable date: "2020/12/15T15:32Z" at java.base/java.text.DateFormat.parse(DateFormat.java:395) at com.packtpub.testing.CoordSchedule.parseTimestamp(CoordSchedule.java:64) at com.packtpub.testing.CoordSchedule.getStartingTimestampAsDate(CoordSchedule.java:49) at com.packtpub.testing.CoordScheduleTest.testStartingTimestamps(CoordScheduleTest.java:41) … BUILD SUCCESSFUL in 0s 4 actionable tasks: 4 executed 14:59:34: Tasks execution finished ':cleanTest :test --tests "com.packtpub.testing.CoordScheduleTest"'.
注意,格式错误的日期时间戳显示了一个异常堆栈跟踪(此处已截断以节省空间)。这是预期的(输入的日期时间戳不正确),因此这不是错误。这些测试应该成功。
决定要测试什么
你总是可以编写更多的测试,所以,迟早,你需要决定你真正需要测试什么。
通常,专注于以下内容是一个好主意:
-
如果代码出错,哪种代码会造成最大的影响?
-
什么代码被其他代码依赖最多?这段代码应该得到额外的测试。
-
你是否正在检查边缘情况,例如最大值和最小值?
为了简化编写更好的测试,特别是为了处理一些边缘情况,你可能想要使用参数化测试。
编写参数化测试
参数化测试是一种接受参数的单元测试。你不需要在test方法中设置所有测试值,而是可以传递参数。这使得测试多个案例变得容易得多。例如,在处理字符串数据时,你可能想要测试多个字符串,包括 null 和空字符串。
使用参数化测试,你需要指定要传递给测试的参数。JUnit 会将这些参数作为实际方法参数传递给你的测试。例如,看看以下:
@ParameterizedTest
@ValueSource(ints = { 10000, 11000 })
public void testMetStepGoal(int steps) {
DailyGoal dailyGoal = new DailyGoal(DAILY_GOAL);
Assertions.assertTrue(dailyGoal.hasMetGoal(steps));
}
在这个例子中,你使用@ParameterizedTest注解而不是@Test。这告诉 JUnit 寻找参数。
@ValueSource注解定义了两个值传递给test方法:10000和11000。在这两种情况下,这个测试假设传入的参数将导致hasMetGoal()方法返回true。
注意
参数化测试使 JUnit 对使用 Spock 的人更加可接受。
JUnit 将为@ValueSource列表中的每个值调用一次test方法,所以在这个例子中会调用两次。
@ValueSource注解期望一个值列表传递给测试方法。如果你有更复杂的值,可以使用@CsvSource注解。
@CsvSource注解接受一个以逗号分隔的值集。例如,看看以下:
@ParameterizedTest
@CsvSource({
"10, false",
"9999, false",
"10000, true",
"20000, true"
})
public void testHasMetStepGoal(int steps, boolean expected) {
// …
}
在这个例子中,对testHasMetStepGoal()的第一个调用将为steps参数返回10,为expected参数返回false。请注意,JUnit 会为你转换类型。类似于@ValueSource,每行数据都会导致对test方法的单独调用。
如果你想要传递多个值进行比较,或者在这个例子中,你想要传递好值和坏值,以及一个参数来指示测试是否预期为true,那么@CsvSource非常有用。
因为@CsvSource中的值存储为字符串,所以你需要一些特殊的语法来处理空字符串、null 字符串和包含空格的字符串:
@CsvSource({
"'A man, a plan, a canal. Panama', 7",
"'Able was I ere I saw Elba', 7",
", 0",
"'', 0"
})
第一行有一个包含空格的字符串。使用单引号字符(')来界定包含空格的字符串。
第三行只有一个逗号作为第一个参数。JUnit 将为这个构造传递null。
第四行使用了两个单引号来生成一个空字符串。
除了 @CsvSource,你还可以使用 @CsvFileSource 注解从外部逗号分隔值(CSV)文件加载数据。
注意
JUnit 支持几种获取参数值的方法,包括从单独的文件、从你编写的方法中获取,等等。你可以参考 packt.live/2J8oXGU 获取有关参数化测试的更多信息。
练习 3:编写参数化测试
假设你正在编写访问可穿戴健身设备的代码。设备跟踪的一件事是佩戴者在某一天所走的步数。然后你可以将所走的步数与每日目标进行比较。佩戴者是否达到了这个目标?
本例演示了如何根据第六章中库、包和模块的每日步数目标编写参数化测试:
-
编辑
build.gradle文件。 -
将以下内容添加到依赖项块中:
testImplementation('org.junit.jupiter:junit-jupiter-params:5.4.2') -
这个依赖项引入了对参数化测试的支持。
-
在
src/main/java文件夹中的com.packtpub.testing包上右键单击。选择New和Java Class。 -
将
DailyGoal作为类名。 -
为此类输入以下代码:
int dailyGoal = 10000; public DailyGoal(int dailyGoal) { this.dailyGoal = dailyGoal; } public boolean hasMetGoal(int steps) { if (steps >= dailyGoal) { return true; } return false; }这是我们将要测试的类。
-
在
src/test/java文件夹中的com.packtpub.testing包上右键单击。选择New和Java Class。 -
将
DailyGoalTest作为类名。 -
为设备佩戴者的每日步数目标输入以下常量:
public static final int DAILY_GOAL = 10000; -
接下来,输入一个用于满足或超过每日步数目标的
test方法:@ParameterizedTest @ValueSource(ints = { 10000, 11000 }) public void testMetStepGoal(int steps) { DailyGoal dailyGoal = new DailyGoal(DAILY_GOAL); Assertions.assertTrue(dailyGoal.hasMetGoal(steps)); }每日步数目标为
10000步时,10000和11000都达到了这个目标。 -
接下来,我们将测试步数低于每日步数目标的结果:
@ParameterizedTest @ValueSource(ints = { 10, 9999 }) public void testNotMetStepGoal(int steps) { DailyGoal dailyGoal = new DailyGoal(DAILY_GOAL); Assertions.assertFalse(dailyGoal.hasMetGoal(steps)); }注意
9999只比目标少一步。接下来,使用
@CsvSource的测试参数值输入一个测试方法:@ParameterizedTest @CsvSource({ "10, false", "9999, false", "10000, true", "20000, true" }) public void testHasMetStepGoal(int steps, boolean expected) { DailyGoal dailyGoal = new DailyGoal(DAILY_GOAL); // Using a lambda will lazily evaluate the expression Assertions.assertTrue( dailyGoal.hasMetGoal(steps) == expected, () -> "With " + steps + " steps, hasMetGoal() should return " + expected); }这个测试方法稍微复杂一些。每次调用测试都会传递两个参数。
在
Assertions.assertTrue()调用中的 lambda 表达式是错误消息。使用 lambda 表达式意味着错误消息只有在测试断言失败时才会被评估。当你运行这个测试类时,它应该成功。
当测试无法工作——禁用测试
@Disabled 注解允许你禁用测试。通常,简单地禁用任何失败的测试并不是一个好的实践。这违背了测试的整个目的。然而,你可能遇到由于一些你无法控制的条件,你不得不禁用测试的情况。例如,如果你正在使用另一个组的代码,并且该组在其代码中破坏了一个期望或引入了一个错误,你可能需要——暂时——禁用依赖于该代码的测试:
@Disabled("Until platform team fixes issue 5578")
@Test
public void testThatShouldNotFail() {
// …
}
你可以将 @Disabled 注解添加到整个测试类,或者只添加到测试方法,如前面的代码块所示。
测试设置
在许多测试中,你可能需要执行一些设置工作,以及测试后的清理工作。例如,你可能需要初始化测试所需的对象。JUnit 提供了一系列生命周期注解来支持此类工作。
如果你用@BeforeEach注解一个方法,JUnit 将在运行每个测试方法之前运行该方法。同样,用@AfterEach注解的方法将在每个测试方法之后运行。如果你想为测试类只运行一次设置或清理代码,可以使用@BeforeAll和@AfterAll。尽管这两个方法有一些限制。
JUnit 为每个测试方法创建你的测试类的新实例。这确保了你的测试在隔离状态下运行,避免了所谓的测试污染,即一个测试影响另一个测试。通常,这是好事,因为追踪依赖于测试执行顺序的测试失败尤其令人沮丧。
因为 JUnit 为每个测试方法创建测试类的新实例,所以@BeforeAll和@AfterAll方法必须是static。此外,这些方法初始化或清理的数据也应该是static。
如果你不想创建static方法,你可以更改 JUnit 为每个测试方法创建测试类新实例的策略。
如果你用以下内容注解你的测试类,JUnit 将为所有测试方法创建一个共享的测试类实例:
@TestInstance(TestInstance.Lifecycle.PER_CLASS)
你可以在Mocking部分看到一个例子。
练习 4,使用测试设置和清理方法演示了如何编写这些设置和清理方法。
练习 4:使用测试设置和清理方法
这个练习演示了一个简单的单元测试,其中包含用于设置和清理的占位符方法。该测试将验证一个简单的类,该类将摄氏温度值转换为华氏温度:
-
右键点击
src/main/java文件夹中的com.packtpub.testing包。选择New然后Java Class。 -
将类名输入为
TempConverter。 -
输入以下方法:
public static double convertToF(double degreesC) { double degreesF = (degreesC * 9/5) + 32; // Round to make nicer output. return Math.round(degreesF * 10.0) / 10.0; } -
右键点击
src/test/java文件夹中的com.packtpub.testing包。选择New然后Java Class。 -
将类名输入为
TempConverterTest。 -
输入以下测试方法,检查两种温度尺度上的
-40.0度:@Test public void testFahrenheitWhenCold() { // -40 C == -40 F double degreesC = -40.0; double degreesF = TempConverter.convertToF(degreesC); Assertions.assertEquals(degreesC, degreesF); }无论使用哪种温度尺度,这种温度都是令人不愉快的。
注意这个测试是如何使用
assertEquals()断言的。 -
输入另一个测试方法以确保当温度为
100.0摄氏度时转换工作正常:@Test public void testFahrenheitWhenHot() { // 100 C == 212 F double degreesC = 100.0; double degreesF = TempConverter.convertToF(degreesC); Assertions.assertEquals(212.0, degreesF); } -
接下来,输入在每个测试之前运行的测试方法:
@BeforeAll public static void runBeforeAllTests() { System.out.println("Before all tests"); }注意,此方法必须是静态的(或者你必须使用前面列出的类级别注解)。
通常,你会使用此方法来设置复杂的测试数据,而不仅仅是打印一个值。
-
输入一个在每个测试之后运行的测试方法:
@AfterAll public static void runAfterAllTests() { System.out.println("After all tests"); }再次强调,此方法必须是静态的。
-
现在,输入在每个两个测试方法之前运行的测试方法:
@BeforeEach public void runBeforeEachTest() { System.out.println("Before each test"); } -
类似地,输入一个在每个测试方法之后运行的测试方法:
@AfterEach public void runAfterEachTest() { System.out.println("After each test"); } -
点击类语句旁边的绿色箭头,并选择“运行'TempConverterTest'”。测试应该无错误运行。
您将看到以下类似的输出:
Before all tests Before each test After each test Before each test After each test After all tests BUILD SUCCESSFUL in 0s 4 actionable tasks: 2 executed, 2 up-to-date注意,
@BeforeAll方法只运行一次。然后,对于每个测试方法,执行@BeforeEach和@AfterEach方法。最后,执行@AfterAll方法。
模拟
单元测试应该只测试一个 Java 类。然而,有时一个类高度依赖于其他类,甚至可能依赖于外部系统,如数据库或手持设备。在这些情况下,一种称为模拟的技术非常有用。模拟就是模拟其他依赖项,以便您可以测试您想要查看的类。
模拟是一个仅用于测试的类,它假装是某些外部依赖。使用模拟框架,您可以检查模拟类以确保正确的方法以正确的次数和参数被调用。
当您有代码在数据库或外部系统中查询数据时,模拟效果很好。您所做的是创建一个特定类的模拟实例。然后,当查询方法被调用时,模拟返回任意测试数据。这避免了对外部系统的依赖。
模拟在您想验证特定方法被调用,而实际上并没有调用该方法时也效果很好。想象一下一个在某种失败情况下发送电子邮件消息的电子邮件通知器。在单元测试中,您不希望实际发送电子邮件消息。(然而,在集成或功能测试中,您应该验证消息确实被发送。)
使用 Mockito 进行模拟测试
Mockito 是一个用于向测试添加模拟的出色框架。假设您有一个监控在大数据集群中运行的工作流程的应用程序;这些可能是之前提到的 Oozie 工作流程,或者任何其他类型的工作流程。
您的应用程序通过调用远程 Web 服务来获取工作流程的状态。在您的单元测试中,您不想调用远程 Web 服务。相反,您只想模拟外部系统。
我们想要测试的代码可能看起来像以下这样:
WorkflowStatus workflowStatus = workflowClient.getStatus(id);
if (!workflowStatus.isOk()) {
emailNotifier.sendFailureEmail(workflowStatus);
}
首先,代码调用远程 Web 服务以获取工作流程的状态,基于工作流程 ID。然后,如果工作流程状态不是 OK,代码发送电子邮件消息。对于单元测试,我们需要模拟对getStatus()和sendFailureEmail()的调用。
WorkflowClient类管理对远程 Web 服务的 HTTP 通信。
使用工作流程 ID 调用getStatus()方法返回给定工作流程的状态:
WorkflowStatus workflowStatus = workflowClient.getStatus(id);
注意
您可以参考第九章,与 HTTP 协作,以获取更多关于 HTTP 和 Web 服务的相关信息。
使用 Mockito,您需要做的第一件事是创建WorkflowClient类的模拟:
import static org.mockito.Mockito.*;
workflowClient = mock(WorkflowClient.class);
下一步是模拟对getStatus()的调用。在 Mockito 术语中,当发生某事时,将返回特定的结果。在这种情况下,模拟的代码应该返回一个预先构建的、具有所需测试状态的WorkflowStatus对象。
String id = "WORKFLOW-1";
WorkflowStatus workflowStatus = new WorkflowStatus(id, WorkflowStatus.OK);
when(workflowClient.getStatus(id)).thenReturn(workflowStatus);
在此代码中,我们首先设置一个工作流程 ID 的字符串,然后使用成功状态(OK)构造一个WorkflowStatus对象。关键代码从when()开始。当在模拟的WorkflowClient类上使用给定 ID 调用getStatus时,请阅读此代码,然后返回我们预先构建的WorkflowStatus对象。
在这种情况下,Mockito 正在寻找一个精确匹配。传入的工作流程 ID 必须匹配,否则模拟将不会返回指定的结果。你还可以指定模拟应该返回任何输入工作流程 ID 的结果,如下所示:
when(workflowClient.getStatus(anyString())).thenReturn(workflowStatus);
在这种情况下,anyString()调用意味着任何传入的字符串值都会匹配。请注意,Mockito 还有其他调用,例如anyInt()。
注意
Mockito 在packt.live/2P6ogl9提供了非常好的文档。你可以使用模拟做比这里展示的更多的事情,但你应该避免模拟一切的诱惑。
在模拟外部 Web 服务调用之后,下一步是检查是否发送了失败电子邮件。为此,模拟发送电子邮件失败消息的类:
import static org.mockito.Mockito.*;
EmailNotifier emailNotifier = mock(EmailNotifier.class);
在我们想要测试的代码中,只有在失败时才会发送电子邮件消息。因此,我们将想要测试以下两点:
-
如果状态是 OK,则不会发送电子邮件。
-
如果状态不是 OK,则会发送电子邮件。
在这两种情况下,我们将使用 Mockito 来检查sendFailureEmail()方法被调用的次数。如果为零次,则不会发送电子邮件。如果是一次或更多次,则发送电子邮件消息。
要确保没有发送电子邮件消息,请使用以下代码:
verify(emailNotifier, times(0)).sendFailureEmail(workflowStatus);
此代码检查sendFailureEmail()方法没有被调用零次,即完全没有被调用。
要验证是否发送了电子邮件消息,你可以指定次数为1:
verify(emailNotifier, times(1)).sendFailureEmail(workflowStatus);
你也可以使用 Mockito 的快捷方式,它假设该方法只被调用一次:
verify(emailNotifier).sendFailureEmail(workflowStatus);
在更复杂的测试中,你可能想要确保一个方法被调用几次。
如前所述,JUnit 为每个测试方法创建你的测试类的新实例。在模拟时,你可能想要在测试方法运行时只设置一次模拟。
要让 JUnit 只为测试类创建一个实例并在所有测试方法之间共享它,请将以下注解添加到类中:
@TestInstance(TestInstance.Lifecycle.PER_CLASS)
public class WorkflowMonitorTest {
private EmailNotifier emailNotifier;
private WorkflowClient workflowClient;
private WorkflowMonitor workflowMonitor;
@BeforeAll
public void setUpMocks() {
emailNotifier = mock(EmailNotifier.class);
workflowClient = mock(WorkflowClient.class);
workflowMonitor =
new WorkflowMonitor(emailNotifier, workflowClient);
}
}
setUpMocks()方法将在所有测试方法运行之前被调用一次。它设置了两个模拟类,然后将模拟对象传递给WorkflowMonitor类的构造函数。
以下练习展示了所有这些类一起使用,在单元测试中使用基于 Mockito 的模拟。
练习 5:在测试时使用模拟
这个练习创建了一个WorkflowMonitor类,然后使用模拟对象来处理外部依赖:
-
在 IntelliJ 的
项目面板中的src/main/java文件夹中创建一个新的 Java 包。 -
输入包名
com.packtpub.workflow。 -
在
src/test/java文件夹中创建一个新的 Java 包。 -
输入相同的名称,
com.packtpub.workflow。 -
编辑
build.gradle。 -
在依赖项块中添加以下内容:
testImplementation("org.mockito:mockito-core:2.+") -
在
src/main/java文件夹中的com.packtpub.workflow包上右键单击。选择新建然后选择Java 类。 -
将类名命名为
WorkflowStatus。 -
为此简单值对象类输入以下代码:
public static final String OK = "OK"; public static final String ERROR = "ERROR"; private String id; private String status = OK; public WorkflowStatus(String id, String status) { this.id = id; this.status = status; } public boolean isOk() { if (OK.equals(status)) { return true; } return false; }在实际系统中,这个类将包含额外的值,例如工作流程开始的时间、停止的时间以及其他关于工作流程的信息。为了这个练习,状态信息被简化了。
-
在
src/main/java文件夹中的com.packtpub.workflow包上右键单击。选择新建然后选择Java 类。 -
将类名命名为
EmailNotifier。 -
输入以下方法:
public void sendFailureEmail(WorkflowStatus workflowStatus) { // This would have actual code... }在实际应用中,这将发送电子邮件消息。为了简单起见,我们将留空。
-
在
src/main/java文件夹中的com.packtpub.workflow包上右键单击。选择新建然后选择Java 类。 -
将类名命名为
WorkflowClient。 -
输入以下方法:
public WorkflowStatus getStatus(String id) { // This would use HTTP to get the status. return new WorkflowStatus(id, WorkflowStatus.OK); }同样,这也是简化的。
-
在
src/main/java文件夹中的com.packtpub.workflow包上右键单击。选择新建然后选择Java 类。 -
将类名命名为
WorkflowMonitor。 -
输入以下属性:
private EmailNotifier emailNotifier; private WorkflowClient workflowClient; -
在类上右键单击,选择
生成…然后选择构造函数。 -
选择两个属性,然后点击
确定。 -
输入以下方法:
public void checkStatus(String id) { WorkflowStatus workflowStatus = workflowClient.getStatus(id); if (!workflowStatus.isOk()) { emailNotifier.sendFailureEmail(workflowStatus); } }这是我们将使用模拟对象进行测试的方法。
-
在
src/test/java文件夹中的com.packtpub.workflow包上右键单击。选择新建然后选择Java 类。 -
将类名命名为
WorkflowMonitorTest。 -
注释该类,以便我们可以创建一个
@BeforeAll方法:@TestInstance(TestInstance.Lifecycle.PER_CLASS) -
输入以下属性并设置
@BeforeAll方法:private EmailNotifier emailNotifier; private WorkflowClient workflowClient; private WorkflowMonitor workflowMonitor; @BeforeAll public void setUpMocks() { emailNotifier = mock(EmailNotifier.class); workflowClient = mock(WorkflowClient.class); workflowMonitor = new WorkflowMonitor(emailNotifier, workflowClient); }这设置了模拟对象,然后使用模拟的依赖项实例化一个
WorkflowMonitor对象。 -
输入以下测试方法以测试工作流程成功的情况:
@Test public void testSuccess() { String id = "WORKFLOW-1"; WorkflowStatus workflowStatus = new WorkflowStatus(id, WorkflowStatus.OK); when(workflowClient.getStatus(id)).thenReturn(workflowStatus); workflowMonitor.checkStatus(id); verify(emailNotifier, times(0)).sendFailureEmail(workflowStatus); }我们还应该测试一个工作流程状态不是 OK 的情况。
-
输入以下测试方法:
@Test public void testFailure() { String id = "WORKFLOW-1"; WorkflowStatus workflowStatus = new WorkflowStatus(id, WorkflowStatus.ERROR); when(workflowClient.getStatus(anyString())) .thenReturn(workflowStatus); workflowMonitor.checkStatus(id); verify(emailNotifier).sendFailureEmail(workflowStatus); } } -
点击类声明旁边的绿色箭头,选择
运行 'WorkflowMonitorTest'。测试应该没有错误运行。
活动一:计算字符串中的单词
单词计数在出版行业中至关重要。编写一个类,给定一个字符串,将计算字符串中的所有单词。
-
您可以使用
split()方法将字符串拆分为单词,使用\s+正则表达式来分隔单词,这匹配空白字符(即空格和制表符)。将此类命名为WordCount。 -
去除输入字符串开头或结尾的任何空格。
注意,空字符串应该生成单词计数为零;同样,
null字符串也应该生成零。全部为空格的输入字符串也应该生成零。 -
一旦你写好了类,就为该类编写一个参数化单元测试。使用参数和
@CsvSource传递一个字符串以及预期的单词数。确保在你的输入字符串中包含标点符号,如逗号和句号。此外,确保在输入参数中包含包含空字符串和空字符串的输入字符串。注意
本活动的解决方案可以在第 569 页找到。
摘要
本章介绍了单元测试。测试是好的,你希望为所有的 Java 代码编写测试。如果你编写了成功的测试,那么你可以有信心你的代码是正确编写的。
JUnit 提供了编写 Java 单元测试最流行的测试框架,尽管你也可以尝试其他框架。方法上的@Test注解告诉 JUnit 给定的代码被视为一个测试。JUnit 将执行测试并查看它是否成功。JUnit 断言类包含一些你可以用来验证测试结果的static方法。
参数化测试是一种测试,你向其中传递一些参数。当你需要为想要确保能够处理各种输入的代码编写测试时,这非常有用。模拟是一种技术,通过模拟外部依赖,使得单元测试可以专注于测试单个类。
附录
关于
本节包含帮助学生执行书中活动的说明。它包括学生为完成和实现本书目标而要执行的详细步骤。
1. 入门
活动一:获取两个数字的最小值
解决方案
-
声明
3个double变量:a、b和m。分别用3、4和0初始化它们。double a = 3; double b = 4; double m = 0; // variable for the minimum -
创建一个
String变量r,它应包含要打印的输出消息。// string to be printed String r = "The minimum of numbers: " + a + " and " + b + " is "; -
使用
min()方法获取两个数字的最小值,并将其存储在m中。// mathematical operation m = Math.min(a,b); -
打印结果。
System.out.println(r + m); // print out the results注意
本活动的完整代码可以在以下链接找到:
packt.live/2MFtRNM
2. 学习基础知识
活动一:获取输入并比较范围
解决方案
-
在
main()中,引入一个if语句来检查输入的参数长度是否正确:public class Activity1 { public static void main(String[] args) { if (args.length < 2) { System.err.println("Error. Usage is:"); System.err.println("Activity1 systolic diastolic"); System.exit(-1); } -
将这些参数解析为
int值,并保存到变量中:int systolic = Integer.parseInt(args[0]); int diastolic = Integer.parseInt(args[1]); -
使用以下代码检查输入的不同值,以查看血压是否在期望的范围内:
System.out.print(systolic + "/" + diastolic + " is "); if ((systolic <= 90) || (diastolic <= 60)) { System.out.println("low blood pressure."); } else if ((systolic >= 140) || (diastolic >= 90)) { System.out.println("high blood pressure."); } else if ((systolic >= 120) || (diastolic >= 80)) { System.out.println("pre-high blood pressure."); } else { System.out.println("ideal blood pressure."); } } }
3. 面向对象编程
活动一:将符号频率计算添加到 WordTool
解决方案
向之前创建的 WordTool 类添加一个方法来计算特定符号的频率。为此,执行以下步骤:
-
添加一个方法来计算字符串中的单词数量。
public int wordCount ( String s ) { int count = 0; // variable to count words // if the entry is empty or is null, count is zero // therefore we evaluate it only otherwise if ( !(s == null || s.isEmpty()) ) { // use the split method from the String class to // separate the words having the whitespace as separator String[] w = s.split("\\s+"); count = w.length; } return count; } -
添加一个方法来计算字符串中的字母数量,并添加区分是否有空格的选项。
public int symbolCount ( String s, boolean withSpaces ) { int count = 0; // variable to count symbols // if the entry is empty or is null, count is zero // therefore we evaluate it only otherwise if ( !(s == null || s.isEmpty()) ) { if (withSpaces) { // with whitespaces return the full length count = s.length(); } else { // without whitespaces, eliminate whitespaces // and get the length on the fly count = s.replace(" ", "").length(); } } return count; } -
添加一个方法来计算特定符号的频率。
public int getFrequency ( String s, char c ) { int count = 0; // if the entry is empty or is null, count is zero // therefore we evaluate it only otherwise if ( !(s == null || s.isEmpty()) ) { count = s.length() - s.replace(Character.toString(c), "").length(); } return count; } -
在主类中,创建
WordTool类的对象,并添加一个包含您选择的文本行的字符串变量。WordTool wt = new WordTool(); String text = "The river carried the memories from her childhood."; -
添加一个变量来包含在文本中要查找的符号,并选择一个符号,在这种情况下是 '
e'。因为它是一个字符,所以使用单引号来界定它。char search = 'e'; -
在主方法中添加代码以打印出
WordTool执行的计算。System.out.println( "Analyzing the text: \n" + text ); System.out.println( "Total words: " + wt.wordCount(text) ); System.out.println( "Total symbols (w. spaces): " + wt.symbolCount(text, true) ); System.out.println( "Total symbols (wo. spaces): " + wt.symbolCount(text, false) ); System.out.println( "Total amount of " + search + ": " + wt.getFrequency(text, search) );
活动二:向 WordTool 添加文档
解决方案
确保为每个示例都添加文档,并添加足够的元数据,以便人们知道如何处理不同的方法。
-
为类添加一个介绍性注释,您至少应包括简短文本、
@author、@version和@since参数。/** * <H1>WordTool</H1> * A class to perform calculations about text. * * @author Joe Smith * @version 0.1 * @since 20190305 */ -
为
wordCount方法添加解释,记得包括参数和方法的预期输出,作为@param和@return。/** * <h2>wordCount</h2> * returns the amount of words in a text, takes a string as parameter * * @param s * @return int */ public int wordCount ( String s ) { [...] -
同样为
symbolCount添加一个方法。/** * <h2>symbolCount</h2> * returns the amount of symbols in a string with or without counting spaces * * @param s * @param withSpaces * @return int */ public int symbolCount ( String s, boolean withSpaces ) { [...] -
不要忘记类中的最后一个方法,
getFrequency。/** * <h2>getFrequency</h2> * returns the amount of occurrences of a symbol in a string * * @param s * @param c * @return int */ public int getFrequency ( String s, char c ) { [...] -
现在您已经准备好从本示例中导出文档文件。
本活动产生的文档网站应类似于以下截图所示:

图 3.12:文档网站
4. 集合、列表和 Java 的内置 API
活动一:在数组中搜索多个出现
解决方案
-
创建
text数组。String[] text = {"So", "many", "books", "so", "little", "time"}; -
创建包含要搜索的单词的变量:so
String searchQuery = "so"; -
初始化变量
occurrence为-1。int occurrence = -1; -
创建一个
for循环来遍历数组并检查其出现。for(int i = 0; i < text.length; i++) { occurrence = text[i].compareToIgnoreCase(searchQuery); if (occurrence == 0) { System.out.println("Found query at: " + i); } }注意
此活动的完整代码可以在以下链接找到:
packt.live/35RQ9Ud
活动二:遍历大型列表
解决方案
-
首先,你应该创建一个包含随机数字的随机大小的列表。创建你将用于存储数字的列表。你将存储类型为
Double的数字。List <Double> numbers = new ArrayList <Double> (); -
定义下一个列表的大小,使用我们命名为
numNodes的变量。将变量类型设为long。由于随机方法不生成该类型,你需要对结果进行类型转换。long numNodes = (long) Math.round(Math.random() * 10000); -
使用
for循环遍历列表并创建其中的每个元素。for (int i = 0; i < numNodes; i++) { numbers.add(Math.random() * 100); } -
为了计算平均值,你可以创建一个迭代器,它将遍历值列表并添加与每个元素对应的加权值。
Iterator iterator = numbers.iterator(); -
来自
iterator.next()方法返回的值必须在与其他元素总数进行比较之前转换为Double类型。Double average = 0.0; while(iterator.hasNext()) { average += (Double) iterator.next() / numNodes; } -
不要忘记打印出结果。
System.out.println("Average: " + average);注意
此活动的完整代码可以在以下链接找到:
packt.live/35Yvo9m
5. 异常
活动一:设计异常类记录数据
解决方案
-
导入此程序运行所需的相关类:
NoSuchFileException和logging。import java.nio.file.NoSuchFileException; import java.util.logging.*; -
创建自己的方法来引发异常,首先从创建一个用于
NullPointerException情况的方法开始。public static void issuePointerException() throws NullPointerException { throw new NullPointerException("Exception: file not found"); } -
如果文件未找到,你还需要一个方法来处理:
NoSuchFileException。public static void issueFileException() throws NoSuchFileException { throw new NoSuchFileException("Exception: file not found"); } -
返回到
Main方法,创建一个logger对象,该对象将报告异常及其严重程度。Logger logger = Logger.getAnonymousLogger(); -
通过命令行界面捕获传递给脚本的参数并将其存储在一个变量中。
int exceptionNum = Integer.valueOf(args[0]); -
使用 switch-case 语句区分可能的异常以进行日志记录。记住使用 try-catch 捕获异常。
Activity01.java
1 switch (exceptionNum) {
2 case 1:
3 try {
4 issuePointerException();
5 } catch (NullPointerException ne) {
6 logger.log(Level.SEVERE, "Exception happened", ne);
7 }
8 break;
9 case 2:
10 try {
11 issueFileException();
12 } catch (NoSuchFileException ne) {
13 logger.log(Level.WARNING, "Exception happened", ne);
14 }
15 break;
https://packt.live/33SEL8B
6. 库、包和模块
活动一:跟踪夏季高温
解决方案
-
创建一个 IntelliJ Gradle 项目。按照以下方式修改
settings.gradle:rootProject.name = 'temps' -
按照以下方式修改
build.gradle:build.gradle 1 buildscript { 2 repositories { 3 jcenter() 4 } 5 dependencies { 6 classpath 'com.github.jengelman.gradle.plugins:shadow:2.0.1' 7 } 8 } https://packt.live/2pJJJFY -
创建一个名为
City的class。添加以下字段和构造函数:package com.packtpub.temps; public class City { private String name; private String country; double summertimeHigh; // In degrees C public City(String name, String country, double summertimeHigh) { this.name = name; this.country = country; this.summertimeHigh = summertimeHigh; } } -
在类内部右键单击。选择
Generate,然后选择Getter and Setter。选择所有字段并点击OK。这将生成 getter 和 setter 方法:public String getName() { return name; } public void setName(String name) { this.name = name; } public String getCountry() { return country; } public void setCountry(String country) { this.country = country; } public double getSummertimeHigh() { return summertimeHigh; } public void setSummertimeHigh(double summertimeHigh) { this.summertimeHigh = summertimeHigh; } -
添加一个将摄氏度转换为华氏度的方法。此方法使用
TempConverter类:public String format(boolean fahrenheit) { String degrees = summertimeHigh + " C"; if (fahrenheit) { degrees = TempConverter.convertToF(summertimeHigh) + " F"; } return name + ", " + country + " " + degrees; } -
创建一个名为
SummerHigh的class来保存夏季高温的城市信息。输入以下属性和构造函数:package com.packtpub.temps; public class SummerHigh { private City base; private City moderateCuts; private City noCuts; public SummerHigh(City base, City moderateCuts, City noCuts) { this.base = base; this.moderateCuts = moderateCuts; this.noCuts = noCuts; } } -
在类内部右键单击。选择
Generate,然后选择Getter and Setter。选择所有字段并点击OK。这将生成 getter 和 setter 方法:public City getBase() { return base; } public void setBase(City base) { this.base = base; } public City getModerateCuts() { return moderateCuts; } public void setModerateCuts(City moderateCuts) { this.moderateCuts = moderateCuts; } public City getNoCuts() { return noCuts; } public void setNoCuts(City noCuts) { this.noCuts = noCuts; } -
输入一个格式化方法以便输出可读:
public String format(boolean fahrenheit) { StringBuilder builder = new StringBuilder(); builder.append("In 2100, "); builder.append(base.format(fahrenheit)); builder.append(" will be like\n "); builder.append(noCuts.format(fahrenheit)); builder.append(" with no emissions cuts,"); builder.append("\n "); builder.append(moderateCuts.format(fahrenheit)); builder.append(" with moderate emissions cuts"); return builder.toString(); }此代码使用了
City类的format()方法。 -
创建一个名为
SummerHighs的类。此类包含SummerHigh对象的表。输入以下字段和构造函数以初始化表:package com.packtpub.temps; import com.google.common.collect.HashBasedTable; import com.google.common.collect.Table; import java.util.Map; public class SummerHighs { private Table<String, String, SummerHigh> data; public SummerHighs() { data = HashBasedTable.create(); } } -
通过
city获取夏季高温信息的方法:public SummerHigh getByCity(String city) { Map<String, SummerHigh> row = data.row(city.toLowerCase()); SummerHigh summerHigh = null; for ( String key : row.keySet()) { summerHigh = row.get(key); } return summerHigh; }此方法使用
Guava库的Table类。 -
通过
country获取夏季高温信息的方法:public SummerHigh getByCountry(String country) { Map<String, SummerHigh> column = data.column(country.toLowerCase()); SummerHigh summerHigh = null; for ( String key : column.keySet()) { summerHigh = column.get(key); } return summerHigh; }此方法还使用了
Guava库的Table类。 -
现在创建一些方便的方法,以便更容易地添加城市:
// Convenience methods to help initialize data. public void addSummerHigh(SummerHigh summerHigh) { City baseCity = summerHigh.getBase(); data.put(baseCity.getName().toLowerCase(), baseCity.getCountry().toLowerCase(), summerHigh); } public SummerHigh createSummerHigh(City base, City moderateCuts, City noCuts) { return new SummerHigh(base, moderateCuts, noCuts); } public City createCity(String name, String country, double summertimeHigh) { return new City(name, country, summertimeHigh); } -
然后,创建一个初始化之前描述的夏季高温数据的方法:
SummerHighs.java 67 addSummerHigh( 68 createSummerHigh( 69 createCity("Tokyo", "Japan", 26.2), 70 createCity("Beijing", "China", 29.0), 71 createCity("Wuhan", "China", 31.2) 72 ) 73 ); https://packt.live/2qELV2d -
创建一个名为
Main的类来运行我们的程序。然后,创建如下所示的main()方法:Main.java 6 SummerHighs summerHighs = new SummerHighs(); 7 summerHighs.initialize(); 8 9 boolean fahrenheit = false; 10 // Handle inputs 11 if (args.length < 2) { 12 System.err.println("Error: usage is:"); 13 System.err.println(" -city London"); 14 System.err.println(" -country United Kingdom"); 15 } 16 17 String searchBy = args[0]; 18 String name = args[1]; 19 SummerHigh high = null; 20 if ("-city".equals(searchBy)) { 21 high = summerHighs.getByCity(name); 22 } else if ("-country".equals(searchBy)) { 23 high = summerHighs.getByCountry(name); 24 } https://packt.live/2BBF2AO -
最后,创建一个名为
TempConverter的类,用于将摄氏度转换为华氏度:package com.packtpub.temps; public class TempConverter { public static double convertToF(double degreesC) { double degreesF = (degreesC * 9/5) + 32; // Round to make nicer output. return Math.round(degreesF * 10.0) / 10.0; } }
7. 数据库和 JDBC
活动 1:跟踪你的进度
解决方案
-
student表包含有关student的信息:CREATE TABLE IF NOT EXISTS student ( STUDENT_ID long, FIRST_NAME varchar(255), LAST_NAME varchar(255), PRIMARY KEY (STUDENT_ID) ); -
chapter表包含chapter number和name:CREATE TABLE IF NOT EXISTS chapter ( CHAPTER_ID long, CHAPTER_NAME varchar(255), PRIMARY KEY (CHAPTER_ID) );注意
chapter ID是chapter number。 -
student_progress表将student ID映射到chapter ID,表示特定学生完成了特定章节:CREATE TABLE IF NOT EXISTS student_progress ( STUDENT_ID long, CHAPTER_ID long, COMPLETED date, PRIMARY KEY (STUDENT_ID, CHAPTER_ID) );注意使用
student ID和chapter ID作为复合primary key,每个学生只能完成每个章节一次。没有重试的机会。 -
这里是一个假设的学生:
INSERT INTO student (STUDENT_ID, FIRST_NAME, LAST_NAME) VALUES (1, 'BOB', 'MARLEY');注意,为了便于匹配名称,我们将它们全部大写插入。
-
以下
INSERT语句提供了前七个章节的数据:INSERT INTO chapter (CHAPTER_ID, CHAPTER_NAME) VALUES (1, 'Getting Started'); INSERT INTO chapter (CHAPTER_ID, CHAPTER_NAME) VALUES (2, 'Learning the Basics'); INSERT INTO chapter (CHAPTER_ID, CHAPTER_NAME) VALUES (3, 'Object-Oriented Programming: Classes and Methods'); INSERT INTO chapter (CHAPTER_ID, CHAPTER_NAME) VALUES (4, 'Collections, Lists, and Java's Built-In APIs'); INSERT INTO chapter (CHAPTER_ID, CHAPTER_NAME) VALUES (5, 'Exceptions'); INSERT INTO chapter (CHAPTER_ID, CHAPTER_NAME) VALUES (6, 'Modules, Packages, and Libraries'); INSERT INTO chapter (CHAPTER_ID, CHAPTER_NAME) VALUES (7, 'Databases and JDBC');注意插入带引号的文本时使用的两个单引号。
-
要添加
student_progress记录,生成如下INSERT语句:INSERT INTO student_progress (STUDENT_ID, CHAPTER_ID, COMPLETED) VALUES (1, 2, '2019-08-28');这应该在 Java 程序中使用
PreparedStatement来完成。 -
要查询学生的进度,使用如下查询:
SELECT first_name, last_name, chapter.chapter_id, chapter_name, completed FROM student, chapter, student_progress WHERE first_name = 'BOB' AND last_name = 'MARLEY' AND student.student_id = student_progress.student_id AND chapter.chapter_id = student_progress.chapter_id ORDER BY chapter_id;注意
first name和last name将由用户输入。这应该放在PreparedStatement中。ORDER BY子句确保输出将按章节顺序显示。 -
ShowProgress程序输出给定学生已完成的章节:ShowProgress.java 1 package com.packtpub.db; 2 import java.sql.*; 3 public class ShowProgress { 4 public static void main(String[] args) { 5 if (args.length < 2) { 6 System.err.println("Error: please enter the first and last name."); 7 System.exit(-1); 8 } 9 // Get student first and last name as inputs. 10 String firstName = args[0].toUpperCase(); 11 String lastName = args[1].toUpperCase(); https://packt.live/31EPNg4注意在搜索数据库之前,将输入的姓氏和名字强制转换为大写。此外,注意我们不允许两个学生有相同的名字。这并不现实。
查询后,程序输出学生的姓名,然后为每个完成的章节输出一行。
-
要运行此程序,在 Gradle 中构建
shadowJar任务,然后在构建目录的IntelliJ子目录下的libs中运行如下命令。输出将如下所示:
BOB MARLEY 2019-03-01 2 Learning the Basics 2019-03-01 3 Object-Oriented Programming: Classes and Methods 2019-03-01 7 Databases and JDBC -
RecordProgress程序添加了一个student_progress记录:RecordProgress.java 1 package com.packtpub.db; 2 3 import java.sql.*; 4 5 public class RecordProgress { 6 public static void main(String[] args) { 7 8 // Get input 9 if (args.length < 3) { 10 System.err.println("Error: please enter first last chapter."); 11 System.exit(-1); 12 } 13 14 // Get student first and last name and chapter number. 15 String firstName = args[0].toUpperCase(); https://packt.live/362QaF1 -
要运行此程序,使用如下命令:
java -cp customers-1.0-all.jar com.packtpub.db.RecordProgress bob marley 4你将看到如下输出:
Number rows added: 1如前所述,在搜索数据库之前,将输入的名称强制转换为大写。
8. 套接字、文件和流
活动 1:将目录结构写入文件
解决方案
-
导入相关的类以使此示例工作。基本上,你将使用集合、文件和相关的异常。
import java.io.IOException; import java.nio.file.*; import java.nio.file.attribute.BasicFileAttributes; import java.util.Collections; -
确定你将开始查找目录的文件夹。让我们假设你从
user.home开始。声明一个指向该文件夹的 Path 对象。Path path = Paths.get(System.getProperty("user.home")); -
接下来,你将调用
File.walkFileTree,这将允许你迭代文件夹结构直到一定深度。在这种情况下,你可以设置任何深度,例如10。这意味着程序将挖掘到10级目录以查找文件。Files.walkFileTree(path, Collections.emptySet(), 10, new SimpleFileVisitor<Path>() { [...] -
在这种情况下,方法包括覆盖
SimpleFileVisitor的几个方法以提取路径信息并将其作为字符串返回,从而使文件结构易于阅读。第一个要覆盖的方法是preVisitDirectory,当类中的目录项恰好是一个嵌套目录时,将触发此方法。@Override public FileVisitResult preVisitDirectory(Path dir, BasicFileAttributes attrs) { -
在
preVisitDirectory内部,有一些操作你需要执行。首先,你需要计算你在目录结构中的深度,因为你将需要它来打印空格作为格式化程序输出的方式。preVisitDirectory将当前路径作为名为dir的参数获取。但它也会使用全局路径参数(记住我们从user.home开始)。声明一个名为depthInit的变量来存储在路径时你在目录结构中的位置。String [] pathArray = path.toString().split("/"); int depthInit = pathArray.length; -
重复此操作,但这次使用当前目录,将结果存储在名为
depthCurrent的变量中。String [] fileArray = dir.toString().split("/"); int depthCurrent = fileArray.length; -
使用
for循环在当前文件夹名前打印一些空格。for (int i = depthInit; i < depthCurrent; i++) { System.out.print(" "); } -
最后,打印出文件夹/文件的名称,并退出方法。
System.out.println(fileArray[fileArray.length - 1]); return FileVisitResult.CONTINUE; -
在
SimpleFileVisitor中需要覆盖的第二个方法是visitFileFailed。此方法处理当读取用户无权进入的路径或类似情况时触发的异常。完整的方法如下。@Override public FileVisitResult visitFileFailed(Path file, IOException exc) throws IOException { System.out.println("visitFileFailed: " + file); return FileVisitResult.CONTINUE; } -
现在尝试运行程序。你将看到类似以下内容的家目录目录列表,其中每个四个空格的块代表在文件夹结构中深入一级。
topeka snap gnome-calculator gnome-system-monitor libreoffice Downloads [...] -
现在将结果存储在文件中而不是简单地打印到 CLI 中,已经是一个简单的练习了。首先,你需要声明一个文件名,在这个例子中,让我们创建一个最终会存储在 Main 类所在同一文件夹中的文件。
String fileName = "temp.txt"; Path pathFile = Paths.get(fileName); -
接下来,你应该检查日志文件是否已存在于文件夹中,这将有助于决定你是创建它还是简单地将其数据附加到它上面。
if(!Files.exists(pathFile)) { try { // Create the file Files.createFile(pathFile); System.out.println("New file created at: " + pathFile); } catch (IOException ioe) { System.out.println("EXCEPTION when creating file: " + ioe.getMessage()); } } -
你将不得不修改覆盖的
preVisitDirectory以包括将数据写入你刚刚创建的文件的可能性。Activity01.java 27 public FileVisitResult preVisitDirectory(Path dir, BasicFileAttributes attrs) { 28 29 String toFile = ""; 30 31 String [] pathArray = path.toString().split("/"); 32 int depthInit = pathArray.length; 33 String [] fileArray = dir.toString().split("/"); 34 int depthCurrent = fileArray.length; 35 for (int i = depthInit; i < depthCurrent; i++) { 36 toFile += " "; 37 } 38 toFile += fileArray[fileArray.length - 1]; 39 40 if(Files.exists(pathFile)) https://packt.live/35Ye4kR -
执行代码现在将给出一个位于你执行代码的同一文件夹中的文件作为结果。
user@computer:~/[...]/Activity0801/out/production/Activity0801$ ls 'Main$1.class' Main.class temp.txt
活动二:改进 EchoServer 和 EchoClient 程序
解决方案
-
预期结果将需要您以非常相似的方式修改服务器和客户端。在客户端,您将不得不做以下类似的事情:
Client.java 1 import java.io.*; 2 import java.net.*; 3 4 public class Client { 5 public static void main(String[] args) throws IOException { 6 if (args.length != 2) { 7 System.err.println( 8 "Usage: java EchoClient <host name> <port number>"); 9 System.exit(1); 10 } 11 12 String hostName = args[0]; 13 int portNumber = Integer.parseInt(args[1]); 14 https://packt.live/2MEFg0w -
在服务器上,修改应该看起来像以下这样:
Server.java
1 import java.net.*;
2 import java.io.*;
3
4 public class Server {
5 public static void main(String[] args) throws IOException {
6
7 if (args.length != 1) {
8 System.err.println("Usage: java EchoServer <port number>");
9 System.exit(1);
10 }
11
12 int portNumber = Integer.parseInt(args[0]);
13
14 try (
15 ServerSocket serverSocket =
https://packt.live/2WbxAWv
服务器和客户端之间预期的交互应该如下所示:

图 8.2:服务器和客户端之间的交互
9. 使用 HTTP
活动一:使用 jsoup 库从网络下载文件
解决方案
程序相对较短。主要任务是找出 select() 方法调用中的 CSS 查询:
-
创建一个名为
Activity1的类。 -
在
main()中,启动一个try-catch块try { } catch (IOException e) { e.printStackTrace(); } -
在
try块内部,使用Jsoup库下载位于packt.live/2BqZbtq的远程网页内容。这将导致网页内容存储在Document对象中。String url = "http://hc.apache.org/"; Document doc = Jsoup.connect(url).get(); -
接下来,查询文档以获取所有具有
sectionCSS 类的DIV元素。Elements sections = doc.select("div.section"); -
您将需要遍历这些
DIV元素中的每一个。for (Element div : sections) { for (Element child : div.children()) { } } -
在内层循环内部,查找
H3标题标签。String tag = child.tagName(); if (tag.equalsIgnoreCase("h3")) { } -
最后,在检测标签是否为 H3 元素的
if语句内部,找到所有用于 HTML 链接的锚(A)标签,并打印出每个链接的文本。Elements links = child.getElementsByTag("a"); for (Element link : links) { System.out.println(link.text()); }注意
您可以在此处找到此活动的完整代码:
packt.live/33SEhPP
11. 进程
活动一:创建父进程以启动子进程
解决方案
-
子进程应该有一个类似于以下算法:
Child.java 9 public static void main(String[] args) throws java.io.IOException, InterruptedException { 10 int ch; 11 System.out.print ("Let's echo: "); 12 while ((ch = System.in.read ()) != '\n') 13 System.out.print ((char) ch); 14 BufferedWriter bw=new BufferedWriter( 15 new FileWriter(new File("mycal2022.txt"))); 16 int cont = 0; 17 while(cont <= 50) { 18 System.out.println(cont++); 19 cont %= 50; 20 bw.write(cont + "\n"); https://packt.live/32I5Afu这里有一个调用
System.in.available()来检查子程序输出缓冲区中是否有数据。 -
另一方面,父程序应该考虑包含类似以下内容:
Parent.java
18 try {
19 process.waitFor(5, TimeUnit.SECONDS);
20 } catch (InterruptedException ie) {
21 System.out.println("WARNING: interrupted exception fired");
22 }
23
24 System.out.println("trying to write");
25 OutputStream out = process.getOutputStream();
26 Writer writer = new OutputStreamWriter(out);
27 writer.write("This is how we roll!\n");
28 writer.flush();
29
30 File file = new File("data.log");
31 FileWriter fileWriter = new FileWriter(file);
32 BufferedWriter bufferedWriter = new BufferedWriter(fileWriter);
33
34 System.out.println("trying to read");
35 InputStream in = process.getInputStream();
36 Reader reader = new InputStreamReader(in);
37 BufferedReader bufferedReader = new BufferedReader(reader);
38 String line = bufferedReader.readLine();
39
40 // send to screen
41 System.out.println(line);
https://packt.live/2MEBlR9
12. 正则表达式
活动一:检查入口是否以所需格式输入的正则表达式
解决方案
-
将练习 1 中已有的正则表达式添加规则,允许数字后面有一个可选的字母:
[a-zA-Z]{2,}\s{1}\d+[a-zA-Z]{1} -
检查 RE 是否适用于 Strandvagen 1a 和 Ringvagen 2b。
13. 使用 Lambda 表达式的函数式编程
活动一:修改不可变列表
解决方案
-
编写一个应用程序,修改练习 2 中找到的 ShoppingCart 类,以允许从购物车中删除项目。
public ShoppingCart removeItem(ShoppingItem item) { Map<String, Integer> newList = new HashMap<>(mShoppingList); int value = 0; if (newList.containsKey(item.name)) { value = newList.get(item.name); } if (value > 0) { newList.put(item.name, --value); } return new ShoppingCart(newList); } -
向 ShoppingCart 类添加一个新功能,允许用户在单次调用中向购物车添加多个项目。
public ShoppingCart addItems(ShoppingItem... items) { Map<String, Integer> newList = new HashMap<>(mShoppingList); ShoppingCart newCart = null; for (ShoppingItem item : items) { newCart = addItem(item); } -
通过修改主应用程序文件中的代码来证明新功能按预期工作。
public class Activity1 { public static void main(String[] args) { ShoppingCart myFirstCart = new ShoppingCart(new HashMap<>()); ShoppingCart mySecondCart = myFirstCart.addItem(new ShoppingItem("Chair", 150)); ShoppingCart myThirdCart = mySecondCart.addItem(new ShoppingItem("Table", 350));
您可以在packt.live/2q045er找到此活动的完整代码。
14. 递归
活动一:计算斐波那契数列
解决方案
-
创建一个名为
Fibonacci的类。 -
创建一个名为
fibonacci的静态方法,用于计算给定数字的斐波那契数列。如果输入数字大于1,则此方法将调用自身。public static int fibonacci(int number) { if (number == 0) { return number; } else if (number == 1) { return 1; } else { return (fibonacci(number - 1) + fibonacci(number - 2)); } }如果输入数字是
0或1,则此方法返回输入数字(分别是0或1)。 -
创建一个
main()方法,调用fibonacci方法,输入从0到16(或小于17的值,如下所示)。public static void main(String[] args) { for (int i = 0; i < 17; i++) { System.out.println( fibonacci(i) ); } }注意
本活动的完整代码可以在以下链接找到:
packt.live/32DtjNT.
15. 使用流处理数据
活动一:对商品应用折扣
解决方案
Activity1.java
30 double sum = calculatePrice(fruitCart, vegetableCart, meatAndFishCart);
31 System.out.println(String.format("Sum: %.2f", sum));
32
33 Map<String, Double> discounts = Map.of("Cod", 0.2, "Salad", 0.5);
34
35 double sumDiscount = calculatePriceWithDiscounts(discounts, fruitCart, vegetableCart, meatAndFishCart);
36 System.out.println(String.format("Discount sum: %.2f", sumDiscount));
37 }
38
39 private static double calculatePrice(ShoppingCart... carts) {
40 return Stream.of(carts)
41 .flatMap((cart) -> { return cart.mArticles.stream(); })
42 .mapToDouble((item) -> { return item.price; })
43 .sum();
44 }
https://packt.live/35Zm5Gj
活动二:寻找具体信息
解决方案
Activity2.java
61 private ShoppingArticle(String name, String category, double price, String unit) {
62 this.name = name;
63 this.category = category;
64 this.price = price;
65 this.unit = unit;
66 }
67
68 @Override
69 public String toString() {
70 return name + " (" + category + ")";
71 }
72 }
73
74 private static ShoppingArticle findCheapestFruit (List<ShoppingArticle> articles) {
75 return articles.stream()
76 .filter((article) -> article.category.equals("Fruits"))
77 .min(Comparator.comparingDouble(article -> article.price))
78 .orElse(null);
79 }
80
81 private static ShoppingArticle findMostExpensiveVegetable (List<ShoppingArticle> articles) {
82 return articles.stream()
83 .filter((article) -> article.category.equals("Vegetables"))
84 .max(Comparator.comparingDouble(article -> article.price))
85 .orElse(null);
86 }
https://packt.live/32EnOid
16. 断言和其他功能接口
活动一:切换传感器状态
解决方案
Activity1.java
52 for (Sensor sensor : sensors) {
53 if (hasAlarmOrWarning.test(sensor)) {
54 alarmOrWarning = true;
55 }
56 }
57
58 if (alarmOrWarning) {
59 System.out.println("Alarm, or warning, was triggered!");
60
61
62 for (Sensor sensor : sensors) {
63 System.out.println(sensor.batteryHealth() + ", " + sensor.triggered());
64 }
65 }
66 }
67
68 }
https://packt.live/33Vnc7X
活动二:使用递归函数
解决方案
Activity2.java
36 private static double loopedAverageHealth(Integer[] batteryHealths) {
37 double average = 0;
38 for (int i = 0; i < batteryHealths.length; i++) {
39 average += batteryHealths[i];
40 }
41 average = average / batteryHealths.length;
42 return average;
43 }
44
45 private static double streamedAverageHealth(Integer[] batteryHealths) {
46 return Stream.of(batteryHealths)
47 .mapToDouble(Integer::intValue)
48 .average()
49 .orElse(0);
50 }
51
52 private static double recursiveAverageHealth(Integer[] batteryHealths, int index) {
53 double average = batteryHealths[index] / (double) batteryHealths.length;
54 if (index == 0) {
55 return average;
56 } else {
57 return average + recursiveAverageHealth(batteryHealths, index - 1);
58 }
59 }
60 }
https://packt.live/32EnJep
活动三:使用 Lambda 函数
解决方案
Activity3.java
37 private static double loopedAverageHealth(Integer[] batteryHealths) {
38 double average = 0;
39 for (int i = 0; i < batteryHealths.length; i++) {
40 average += batteryHealths[i];
41 }
42 average = average / batteryHealths.length;
43 return average;
44 }
45
46 private static double streamedAverageHealth(Integer[] batteryHealths) {
47 return Stream.of(batteryHealths)
48 .mapToDouble(Integer::intValue)
49 .average()
50 .orElse(0);
51 }
52
53 private static double recursiveAverageHealth(Integer[] batteryHealths, int index) {
54 double average = batteryHealths[index] / (double) batteryHealths.length;
55 if (index == 0) {
56 return average;
57 } else {
58 return average + recursiveAverageHealth(batteryHealths, index - 1);
59 }
60 }
61 }
https://packt.live/2BxaoIK
17. 使用 Java Flow 进行响应式编程
活动一:让 NumberProcessor 格式化值为整数
解决方案
-
将处理器发布的商品类型更改为 Integer。在实现中做出必要的更改以匹配新类型:
Activity1.java 53 @Override 54 public void onComplete() { 55 System.out.println("onComplete()"); 56 } 57 }); 58 } 59 60 61 private static String[] getStrings() { 62 String filePath = "res/numbers.txt"; 63 try (Stream<String> words = Files.lines(Paths.get(filePath))) { 64 return words.flatMap((line) -> Arrays.stream(line.split("[\\s\\n]+"))) 65 .filter((word) -> word.length() > 0) 66 .toArray(String[]::new); 67 } catch (IOException e) { 68 e.printStackTrace(); 69 } 70 return null; 71 } 72 } https://packt.live/32GPq6b -
更改处理器的订阅者,它应该在
onNext方法中只接受整数值:@Override public void onSubscribe(Flow.Subscription subscription) { this.subscription = subscription; subscription.request(1); }
18. 单元测试
活动一:计算字符串中的单词数
解决方案
-
创建一个名为
WordCount的类。 -
定义一个名为
countWords()的方法,它接受一个String作为输入。该方法将计算String中的单词数。public int countWords(String text) { int count = 0; return count; } -
在
countWords()中,检查输入字符串是否为 null。如果不是,则删除文本开头和结尾的任何空格。然后,将String分割成单词。if (text != null) { String trimmed = text.trim(); if (trimmed.length() > 0) { String[] words = trimmed.split("\\s+"); count = words.length; } }注意正则表达式
\s+的使用,它被传递给split()方法。这将把String分割成单词。另外,注意开头的反斜杠字符需要转义。注意
WordCount.java 文件的完整代码可以在以下链接找到:
packt.live/32DtjNT.
接下来,按照以下方式编写参数化测试。
-
在
src/test目录下(不是src/main)创建一个名为WordCountTest的类。 -
使用
ParameterizedTest注解并定义一个CsvSource,包含测试数据。@ParameterizedTest @CsvSource({ "'A man, a plan, a canal. Panama', 7", "'Able was I ere I saw Elba', 7", ", 0", "'', 0", "' ', 0", "' A cat in the hat with spaces ', 7" })测试数据接受两个值,一个要检查的文本字符串和单词数。注意标点符号和空格的位置,以查看
WordCount类是否正常工作。 -
创建
test方法,使用输入参数并验证报告的单词数是否与预期的单词数匹配。public void testWordCounts(String text, int expected) { WordCount wordCount = new WordCount(); int count = wordCount.countWords(text); Assertions.assertEquals(expected, count, "Expected " + expected + " for input[" + text + "]"); }注意
WordCountTest.java 文件的完整代码可以在以下链接找到:
packt.live/2oafOq9.




























浙公网安备 33010602011771号