Java-过渡指南-全-

Java 过渡指南(全)

原文:zh.annas-archive.org/md5/558165f0864e3ee4f6a79811b214bcf5

译者:飞龙

协议:CC BY-NC-SA 4.0

前言

1980 年,结婚仅几个月后,我购买了第一台电脑。为什么?因为 26 岁时,我认为这可能是我能拥有的最好的玩具。它是一台 Apple]+。在其到达后的几天内,我就对它着迷了。在 70 年代末和 80 年代初,许多杂志都致力于个人电脑的新领域,我买了大部分。我下班回家时,我在道森学院担任摄影技师,输入我能找到的任何程序。三年后,我离开了道森学院,开始了自己的专业编程生涯,七年后,我在道森学院成为计算机科学系的大学讲师。

我提到这个故事是因为计算机编程改变了我的人生。每天我醒来,时至今日,我仍然醒来面对需要解决的新挑战和问题。你很可能是因为同样的原因成为一名程序员。

几乎没有开发者只使用单一语言或单一操作系统编程度过他们的整个职业生涯。这就是我写这本书的原因。如果你已经使用一种语言进行编码,你已经了解了几乎所有编程语言的基础。这不是一本针对编程初学者的书。这是一本针对 Java 编程初学者的书。

这本书的四个部分介绍了你作为成功的 Java 程序员需要熟悉的技能。Java 是一种经历快速演变的语言。这本书涵盖了桌面和服务器端编程语言最近的增强。

在这本书中,我展示了与书中的文本一样重要的源代码。所有源代码都可在 GitHub 上找到。为了使这本书发挥其全部影响,我要求你下载所有示例。运行这些示例并增强它们。我所学到的许多东西都来自于与代码示例一起工作。我把这些示例的做法描述为进行实验。我鼓励你使用示例代码进行自己的实验。

虽然我简要地讨论了集成开发环境IDEs),但所有示例都可以使用简单的文本编辑器进行编辑,然后可以直接从命令行或使用 Maven 构建工具运行。如果你已经有一个喜欢的 IDE 或计划使用一个,请知道所有为 Maven 构建的代码都可以加载到任何 IDE 中,无需你做任何更改。

到写作时,Java 已经存在了 27 年。每当它被宣布过时、过于冗长或过于复杂时,Java 社区都贡献了新的想法、语法和库,这些已经成为语言的一部分。我可以写上几页关于 Java 自 1996 年推出以来如何变化的内容。我不会这么做,但我希望给你留下深刻印象的是,Java 是一种你一直在学习的语言。这本书只是一个起点。

这本书面向的对象

本书是为有其他语言经验的开发者编写的。假设你在过去几年中至少使用过一种语言。最近你可能被分配了一个新的或现有的 Java 项目,但这并不是你的背景。或者你可能只是想丰富你的简历。将本书视为经验丰富的开发者的入门书。

本书涵盖的内容

[第一章, 理解 Java 发行版,介绍了语言的一些历史,随后是 Java 在 Windows、Linux 和 macOS 上的分发、下载和安装方式。

第二章, 代码、编译和执行,为你编写、编译和执行第一个程序做准备。不需要 IDE,你只需要一个文本编辑器,并在你的 PC 上安装 Java 的最新版本。

第三章, Maven 构建工具,展示了如何管理由许多文件组成且依赖于外部库的程序,这是 Maven 构建工具的职责范围。

第四章, 语言基础 – 数据类型和变量,介绍了 Java 中可用的数据类型以及我们可以对它们执行的操作。

第五章, 语言基础 – 类,介绍了在 Java 中执行面向对象编程的方式,并围绕称为类的结构展开。

第六章, 方法、接口、记录及其关系,探讨了 Java 代码编写的结构。你将了解到类有一个接口,即类的公共成员。接口类强制执行接口,而记录则代表不可变的数据类。类之间的关系被描述。

第七章, Java 语法和异常,介绍了 Java 在方法中使用的底层语法,以及当事情出错时抛出异常的情况。

第八章, 数组、集合、泛型、函数和流,探讨了在 Java 中使用数组和集合处理多个数据元素的方法。泛型增强了类型安全性,函数增强了元素的处理,而流为处理多个项目提供了循环的替代方案。

第九章, 在 Java 中使用线程,介绍了 Java 的一个最大优势,即它对线程的内建支持。还考察了名为虚拟线程的新线程方法。

第十章, 在 Java 中实现软件设计原则和模式,介绍了提供指导的软件设计原则,这些原则指导你如何构建类以及你的对象应该如何交互。

第十一章文档和日志记录,展示了如何在源代码中记录程序的功能。程序运行时,使用日志记录显示有关程序的信息。

第十二章BigDecimal 和单元测试,演示了当需要计算精度,通常用于货币时,BigDecimal 是答案。确定您的程序是否提供正确的结果是单元测试的角色。

第十三章使用 Swing 和 JavaFX 进行桌面图形用户界面编码,介绍了两个支持图形用户界面(GUI)的 Java 图形用户界面GUI)库,Swing 和 JavaFX。您将看到使用每个库展示相同的业务逻辑。

第十四章使用 Jakarta 进行服务器端编码,介绍了 Java 的后端网页编程。您将设置您的应用程序服务器并将 Java 代码以 servlet 的格式部署。

第十五章Jakarta Faces 应用程序,介绍了网页的客户端渲染。来自 第十三章使用 Swing 和 JavaFX 进行桌面图形用户界面编码 的应用程序现在将作为一个 Faces 应用程序展示。

第十六章在独立包和容器中部署 Java,展示了我们可以分发我们的应用程序。您将看到我们如何为桌面程序创建安装程序,并为用于云部署的网页应用程序创建 Docker 容器。

为了充分利用本书

我对您只有一个假设,那就是您在任意命令式语言中工作过几年。如果您不确定这是什么意思,那么请考虑 JavaScript 是一种命令式语言,而 HTML 是一种声明式语言。具有 C# 或 C++ 等面向对象、命令式语言的实践经验可能有所帮助,但不是必需的。

在撰写本文时,Java 17 是 长期支持LTS)版本。本书中的所有代码都将在 Java 17 及其所有后续版本中运行。Java 17 之前的版本可能无法运行。

本书涵盖的软件/硬件 操作系统要求
Java JDK 17 或更高版本 Windows、macOS 或 Linux
Java JDK 19 或更高版本 Windows、macOS 或 Linux
Maven 3.8.6 或更高版本 Windows、macOS 或 Linux
GlassFish 7.0 应用程序服务器 Windows、macOS 或 Linux
Wix 工具集 Windows
Xcode 命令行工具 macOS
Docker 桌面 Windows、macOS 或 Linux
rpm-build 软件包 Red Hat Linux
fakeroot 软件包 Ubuntu Linux
您选择的文本编辑器 Windows、macOS 或 Linux
您选择的网页浏览器 Windows、macOS 或 Linux

如果您正在使用本书的数字版,我们建议您亲自输入代码或从本书的 GitHub 仓库(下一节中有一个链接)获取代码。这样做将帮助您避免与代码的复制和粘贴相关的任何潜在错误。

下载示例代码文件

您可以从 GitHub(github.com/PacktPublishing/Transitioning-to-Java)下载本书的示例代码文件。如果代码有更新,它将在 GitHub 仓库中更新。

我们还有其他来自我们丰富的图书和视频目录的代码包可供选择,这些资源可在github.com/PacktPublishing/找到。查看它们吧!

下载彩色图像

我们还提供包含本书中使用的截图和图表彩色图像的 PDF 文件。您可以从这里下载:packt.link/fyH3U

使用的约定

本书使用了多种文本约定。

文本中的代码:表示文本中的代码单词、数据库表名、文件夹名、文件名、文件扩展名、路径名、虚拟 URL、用户输入和 Twitter 昵称。以下是一个示例:“我们从一个实现Runnable的类开始。actionCounter是我们将在线程中计数的数字。”

代码块设置如下:

private final double principal = 100.0;
private final double annualInterestRate = 0.05;
private final double compoundPerTimeUnit = 12.0;
private final double time = 5.0;

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

private final NumberFormat currencyFormat;
private final NumberFormat percentFormat;

任何命令行输入或输出都按以下方式编写:

brew install openjdk@17

粗体:表示新术语、重要单词或屏幕上看到的单词。例如,菜单或对话框中的单词以粗体显示。以下是一个示例:“当你点击接受时,JShell 编辑器中的代码将被传输到 JShell。”

小贴士或重要提示

看起来是这样的。

联系我们

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

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

勘误表:尽管我们已经尽一切努力确保内容的准确性,但错误仍然可能发生。如果您在这本书中发现了错误,我们将不胜感激,如果您能向我们报告这一点。请访问www.packtpub.com/support/errata并填写表格。

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

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

分享您的想法

一旦你阅读了《Transitioning to Java》,我们很乐意听听你的想法!请点击此处直接进入此书的亚马逊评论页面并分享你的反馈。

你的评论对我们和科技社区都很重要,并将帮助我们确保我们提供高质量的内容。

下载此书的免费 PDF 副本

感谢您购买此书!

你喜欢在旅途中阅读,但无法随身携带你的印刷书籍吗?你的电子书购买是否与你的选择设备不兼容?

别担心,现在,每购买一本 Packt 书籍,你都可以免费获得该书的 DRM 免费 PDF 版本。

在任何地方、任何时间、任何设备上阅读。直接从你最喜欢的技术书籍中搜索、复制和粘贴代码到你的应用程序中。

优惠远不止于此,你将能够获得独家折扣、时事通讯和丰富的免费内容,每天直接发送到你的收件箱。

按照以下简单步骤获取好处:

  1. 扫描下面的二维码或访问以下链接

packt.link/free-ebook/9781804614013

  1. 提交你的购买证明

  2. 就这样!我们将直接将你的免费 PDF 和其他好处发送到你的电子邮件。

第一部分:Java 开发环境

本部分将向你介绍你将在整本书中使用的开发环境。虽然你可能使用集成开发环境IDE)工具,但这不是必需的。正如你将学到的,你需要的只是 Java、一个文本编辑器和 Maven 构建工具。

本部分包含以下章节:

  • 第一章理解 Java 发行版

  • 第二章代码、编译和执行

  • 第三章Maven 构建工具

第一章:理解 Java 发行版

在本章中,我们将探讨 Java 语言的起源以及它是如何被管理的。虽然本书中使用了Java这个词,但请注意,我指的是Java 标准版Java SE。Java 有众多版本和发行版,这有时会导致对使用哪个版本产生混淆。Java 是免费的,还是我需要获得许可?我能否将 Java 运行时包含在我的软件中?我能否分发自己的 Java 版本?这些问题以及其他问题将在本章中解答。

您将学习如何在 Linux、macOS 和 Windows 上安装 Java。本章突出了 Java 安装中包含的重要工具,这些工具将在后续章节中使用。

本章我们将涵盖以下主题:

  • 一点历史

  • Java 的独特之处在哪里?

  • 为什么会有这么多 Java 发行版?

  • 您应该选择哪个 Java 版本?

  • Java 是如何授权的?

  • 为什么会有这么多版本的 Java?

  • 安装 Java

  • 箱子里有什么?

让我们从快速的历史课开始。

技术要求

要在 Java 或您的桌面上运行 Java 程序,您需要一个支持Java 开发工具包JDK)的计算机和操作系统。不同操作系统和不同中央处理器CPU)都有可用的 JDK。如果您正在运行 Windows 操作系统,您唯一需要关心的是您是否在运行 32 位或 64 位。在 macOS 上,有适用于 Intel 和 Apple(ARM)CPU 的 Java 版本。如果您的操作系统是 Linux,则根据您的计算机硬件,有更多变体。甚至还有适用于运行 Linux 的 IBM 大型机的 Java 版本。

除了硬件要求之外,还需要考虑您系统上的 RAM 数量。我曾在只有 1GB RAM 的 Raspberry Pi 3 Model B 上运行 Java 应用程序。作为一名开发者,您所做的不仅仅是运行程序。您还需要运行编辑器、编译器、Web 服务器、数据库服务器以及您常用的软件。完成这些任务需要内存。我建议开发系统至少配备 8GB 内存;16GB 是理想的选择,而 32GB 可能让您在编码的同时还能玩游戏。

一点历史

Java 并非一开始就被称为 Java。在 20 世纪 90 年代初,以 SPARC 工作站和 Solaris 操作系统闻名的公司Sun Microsystems,看到了消费电子领域的潜力。他们组建了一支工程师团队,在这个领域下以Green Project的名义开发产品。他们的第一个设备被称为Star7,这是一款使用定制版 Solaris 的小型手持式电脑。Star7 是第一个个人数字助理,比苹果的 Newton 早了一年。作为 Star7 开发的一部分,创造了一种语言。加拿大软件工程师 James Gosling 领导了一个团队,为 Star7 开发了一种新的语言,称为Oak。Star7 从未投入生产,但 Oak 注定要统治世界。

Sun 公司希望将 Star7 用于消费电子产品的目标之一是为有线电视行业开发机顶盒。他们成立了一家名为 FirstPerson 的公司,并投标开发为有线电视提供商 Time Warner 的机顶盒。他们失去了投标。虽然他们未能将 Star7 推向市场,但他们看到了 Oak 语言的潜力。唯一的问题是 Oak 已经被注册商标。

关于 Oak 如何成为 Java 的故事有很多。它是以他们最喜欢的饮料命名,还是以印度尼西亚的一个岛屿命名?Java 只是 12 个可能的名字之一。这些名字被提交给法律部门进行商标搜索。在提交给法律团队的名字列表中,Java 是第四个名字,也是第一个通过法律审查的名字。Java 成为了 Oak 的新名字。

在 1998 年,Java 1.2 版本被引入,也被称为 Java 2。其中许多新特性中包括了Swing GUI 库,它显著提高了编写独立于浏览器的桌面 GUI 程序的能力。Java EE平台于 1999 年作为J2EE发布。它被用于开发 Java 网络服务器。现在,你可以编写能够响应浏览器请求并在网络服务器上运行的 Java 程序。剩下的,正如常言所说,就是历史了。

Java 有什么特别之处?

Java 是由 Gosling 和他的团队设计的,旨在解决他们感知到的 C++ 的不足。其中最显著的问题是内存管理。在 C++ 中,使用指针类型的变量来为对象分配内存。一旦对象不再需要,开发者的责任就是释放或重新分配内存。忘记这样做会导致内存泄漏。泄漏是指标记为 正在使用 但不再可通过指针访问的一块内存。虽然 Java 仍然要求你分配内存,但你不需要重新分配它。一个称为 垃圾回收器 的过程跟踪所有内存分配。当一个名为 Java 中的引用的指针超出作用域时,垃圾回收器会自动释放其内存。有五种垃圾回收器可用。并行垃圾回收器是默认的通用回收器。串行垃圾回收器、CMS 垃圾回收器、G1 垃圾回收器和 Z 垃圾回收器使用针对特定类型应用的算法,例如那些需要低延迟或仅需要单个线程的应用。

然而,垃圾回收并不是 Java 最显著的特征。Java 与其前辈 CC++ 区别开来的是,Java 程序不会直接在计算机的操作系统上执行。相反,编译后的 Java 程序,称为 字节码,在另一个称为 Java 虚拟机JVM)的进程中执行。

JVM 是计算机的软件模拟。字节码是这个模拟机的机器语言。然后 JVM 将字节码转换为底层计算机的机器语言。

JVM 负责优化代码和执行垃圾回收。

原生语言,如 C 和 C++,会直接编译成 CPU 的机器语言,并与其运行的计算机操作系统相结合。任何使用的库也必须为特定的 CPU 和操作系统编译。这意味着为运行 Windows 的 Intel CPU 编译的程序或为运行特定版本 macOS 的 Apple M1 CPU 编译的程序必须为运行 Linux 的 Intel CPU 重新编译。

Java 将这一概念颠倒过来。如果你用 Java 编写代码并将其编译成字节码,那么只要有相应的 JVM,这些代码就可以在任何硬件和操作系统上无变化地运行。Java 自称为 一次编写,到处运行 的语言。这意味着在 Intel CPU 上编写和运行的 Java 应用程序也可以在基于 ARM 的系统上无变化地运行,无需重新编译,前提是该平台有 JVM。

第四章语言基础 – 数据类型和变量,以及 第五章语言基础 – 类 中,我们将检查 Java 语言的语法。

Java 不是唯一能在 JVM 上运行的编程语言。为了利用 JVM 的优势,同时采用与 Java 不同的方法和语法,开发了更多语言。以下是最广泛使用的四种:

  • Scala

  • Kotlin

  • Groovy

  • Clojure

现在我们已经知道了 Java 与没有虚拟机的语言相比的独特之处。可能令人困惑的是,并非只有一个公司分发 Java 的一个版本。为什么?让我们接下来看看这一点。

为什么会有许多 Java 发行版?

Java 首次作为专有软件发布。2006 年,Sun Microsystems 创建了一个名为 OpenJDK 的开源版本 Java,它带有 GNU 通用公共许可证,允许开发者修改和共享程序。Sun(以及后来的新所有者 Oracle)保留了与 Java 相关的知识产权和版权。

描述 Java 的一种方式是指出,只有通过称为 技术兼容性工具包TCK)的广泛测试套件,JDK 和运行时才被认为是 Java。虽然 Java 被指定为开源,但最初 TCK 并不是。它需要付费从 Oracle 获得许可。这导致很少有公司推出自己的 Java 品牌版本。

现在,然而,你可以无需支付费用就能获取 TCK。你必须向 Oracle 提出正式申请,并提交几份支持性文件,解释你为什么需要访问 TCK。一个筛选委员会将审查你的申请并决定是否授予你访问 TCK 的权限。在撰写本文时,已有 27 个组织签署了OpenJDK 社区 TCK 许可协议OCTLA)并有权访问 TCK。

那么,为什么公司仍然分发他们自己的 Java 品牌版本呢?最简单的答案是向希望在使用 Java 的情况下获得更多特定领域经验的客户提供服务。像 Microsoft 和 Amazon 这样的云服务提供商有自己的 Java 品牌版本,这些版本已经针对他们的云基础设施进行了优化。Liberica 发行版的发行商 BellSoft 是参与 Java ARM 版本的主要领导者之一。虽然选择哪个发行版可能不会有多大区别,但你的客户将使用的发行版是重要的。

不论是哪个发行商,语言都是由 Oracle 维护的。一个成熟的流程允许任何人提出对语言的修改建议。通过Java 社区流程JCP),对语言的全部更改、新增和删除都得到了仔细的审查。

JDK 变更的实际编码主要是 Oracle 工作的开发者的责任。考虑加入 JCP,以了解变化并贡献于语言。

让我们继续看看你应该使用哪个版本,因为你对这个语言没有任何经验。

你应该获取哪种 Java?

自 Java 11 以来,所有 Java 的发行版,包括 Oracle 的发行版,都是基于 OpenJDK 源代码的。如果你没有必须使用的发行版,我推荐使用名为Temurin的 Eclipse Adoptium 版本。这个版本已经通过了 TCK。Java 是一个注册商标,因此这个词不能用于 Oracle 以外的发行版,所以有了这个名字 Temurin。如果你对这个名字的来源好奇,我会给你一个提示——它是一个字母表重排。

你可能会认为 Java 发行版的明显选择是 Oracle 品牌的版本。这几乎就是 Java 8 最终发布之前的情况。随着这次发布,Oracle 要求将 Java 作为其商业产品一部分进行分发的公司购买商业支持许可证以获取 Java 更新的访问权限。从 Java 11 开始,Oracle 要求商业许可证持有者为每位开发者购买订阅。尽管如此,Oracle 品牌的 JDK 的个人使用仍然是免费的。

这可能会让人困惑,因为如果你选择使用 OpenJDK 发行版或基于 OpenJDK 的任何其他发行版(除了 Oracle 的),商业分发不需要支付费用。随着 Java 17 的发布,Oracle 再次改变了其授权方式。现在被称为 Oracle 的无费条款和条件NFTC),这现在允许你在开发软件时使用 Oracle 的 Java,然后无需订阅或费用即可将此版本的 Java 与你的程序一起分发。这仅适用于从 17 开始的 Java 版本。从 8 到 16 的版本仍然受限于许可证。

Java 是如何授权的?

如果你计划使用 Java 进行商业软件开发,那么它的授权对你来说很重要。如前所述,OpenJDK 携带 GNU 通用公共许可证版本 2,通常称为GPLv2。GPL 在开源软件中广泛使用。在最基本层面上,它要求任何使用 GPL 许可代码的软件也必须受 GPL 约束。这意味着你创建的任何软件都必须在相同条件下提供源代码。版权和知识产权归作品作者所有,无论是 Oracle 还是你。

Java 的 GPLv2 附带 Classpath 异常,也称为链接异常。类路径,就像操作系统路径一样,是 JVM 和 Java 编译器将使用的类和包的位置。根据这个例外,当你分发你的应用程序时,你不需要提供源代码。链接到 Java 的软件不需要 GPLv2 许可证。它可以保持专有,不能像 GPL 软件那样自由使用。你选择你生成的代码的授权。

为什么 Java 有这么多版本?

Java 一直在不断发展——错误修复、增强和新功能都在持续开发中。Java 最初以 1 加上版本号进行编号。从 1996 年开始,直到 2014 年的前九个版本分别是 1.0、1.1、1.2、1.3、1.4、1.5、1.6、1.7 和 1.8。在这些版本之间,有一个代表更新而不是主要修订的第三个数字,例如 1.8_202。

从 Java 1.8 开始,随后被称为 Java 8,以下是 Java 版本的时间线:

图片

表 1.1 – Java 版本的时间线

你会看到 Oracle 标记了几个LTS版本,即长期支持版本。这些版本预计将至少提供 8 年的错误修复和安全更新。非 LTS 版本,也称为功能发布,是累积的修复、更新和预览功能。对这些版本的支持预计将持续到下一个非 LTS 或 LTS 版本发布。拥有自己 Java 分发的公司可能提供的支持时间比 Oracle 更长。

LTS 版本通常是许多组织用于其产品的首选。Java 8 于 2014 年 3 月发布,目前仍在支持中,并将持续到 2030 年 12 月。随后的 LTS 版本仅支持 8 年,但如前所述,其他 Java 分销商可能会提供更长时间的支持。Java 新版本发布的当前计划是每两年一个 LTS 版本,每六个月发布一个非 LTS 版本。

如果您计划开发服务器端软件,您必须使用 LTS 版本。服务器端所需的库是针对特定 LTS 版本编写的。当一个新的 LTS 版本发布时,所有这些库可能需要一些时间才能更新,正如目前 LTS Java 17 的情况一样。在我撰写本文时,大多数服务器端应用程序正在运行 Java 11,一些甚至仍在使用 Java 8。

对 Java 成功做出贡献的因素在持续且现在已成为常规的发布节奏中显而易见。这确保了 Java 继续成为一门最前沿的语言。

安装 Java

安装 Java 是一个简单的过程。作为开发者,您将从任何分销商那里安装 JDK。大多数 Java 分销商都打包了带有安装程序的 Java,以及一个没有安装程序的压缩文件,您可以下载。选择取决于您的操作系统、CPU,以及您是否是管理员或超级用户并且可以使用安装程序。或者,您是客户端用户,只能安装压缩文件。

在您的分发和版本确定后,您就可以准备安装 Java 作为管理员和非管理员用户。

作为管理员

作为管理员,您可以通过以下方式为计算机上的所有用户安装 Java。

Windows

adoptium.net/下载适用于 Java 的适当(32 位或 64 位).msi文件。此类文件包含一个安装程序,它将 Java 放置在您选择的文件夹中,并配置适当的环境变量。下载后,只需双击.msi文件。Windows 安装程序将引导您完成安装过程。

macOS

对于 macOS 安装 Java,您有两个选择。第一个是下载包含安装程序的 Mac .pkg文件。下载后,只需双击.pkg文件。Apple 安装程序将引导您完成安装过程。

第二种方法是使用HomeBrew,这是一个用于管理新软件和更新的命令行工具,它将下载并安装 Java。

在安装了 HomeBrew 之后,您可以使用以下命令安装 OpenJDK 版本:

brew install openjdk@17 

要安装 Java 17 的 Eclipse Temurin 版本,请使用以下命令:

brew tap homebrew/cask-versions
brew install --cask temurin17

Linux

在 Linux 上,您使用apt install命令行工具。您必须是超级用户/管理员才能使用此工具。您还必须包含所需的分发和版本。您可以使用以下命令在命令行中安装 OpenJDK Java:

sudo apt install openjdk-17-jdk

要安装 Eclipse Temurin 版本的 Java,请使用以下命令:

sudo apt install temurin-17-jdk

验证安装

安装完成后,通过以下命令验证 Java 是否正常工作:

java -version

如果它显示了你刚刚安装的 Java 的版本和发行名称,那么你就完成了,可以开始编写 Java 代码。版本号可能因你下载 Java 或使用apt install的时间而有所不同。你应该看到以下内容:

Windows

>java -version
openjdk version "17.0.3" 2022-04-19
OpenJDK Runtime Environment Temurin-17.0.3+7 (build 17.0.3+7)
OpenJDK 64-Bit Server VM Temurin-17.0.3+7 (build 17.0.3+7, mixed mode, 
    sharing)

Linux 和 macOS

$ java -version
openjdk version "17.0.3" 2022-04-19
OpenJDK Runtime Environment Temurin-17.0.3+7 (build 17.0.3+7)
OpenJDK 64-Bit Server VM Temurin-17.0.3+7 (build 17.0.3+7, mixed mode, 
    sharing)

如果它告诉你找不到 Java,那么请按照即将到来的配置环境变量部分中的说明来设置环境变量。

作为非管理员

如果你不是管理员,那么你仍然可以安装 Java,但只有你才能使用它。

Windows

Windows 用户可以下载适当的.zip文件版本,并将其解压到所需的文件夹中。

Linux 和 macOS

下载适用于 Linux 或 macOS 的适当.tar.gz文件版本。下载完成后,使用以下命令行。Linux 和 macOS 之间的唯一区别是文件名。

对于 Linux,使用以下命令:

tar xzf OpenJDK17U-jdk_x64_linux_hotspot_17.0.3_7.tar.gz

对于 macOS,使用以下命令:

 tar xzf OpenJDK17U-jdk_x64_mac_hotspot_17.0.3_7.tar.gz 

配置环境变量

需要设置两个环境变量。虽然 Windows、Linux 和 macOS 上的环境变量相同,但设置它们的过程不同。

第一个环境变量是JAVA_HOME。某些 Java 进程,如 Web 服务器,需要知道 Java 的安装位置才能访问 JDK 中的特定组件。它必须被分配到安装 Java 的文件夹的完整路径。

第二个环境变量是PATH。当从命令行运行程序时,操作系统将在当前目录中查找可执行文件。如果没有找到,它将遍历路径中的每个目录以查找它。

你每次打开控制台时都必须输入这些命令。根据你的登录名和要安装的 Java 版本调整命令。虽然你可以安装多个 Java 版本,但只能有一个用于JAVA_HOMEPATH

Windows

set JAVA_HOME= C:\devapp\jdk-17.0.2+8
set PATH=%JAVA_HOME%\bin;%PATH%

调整解压 Java 文件时创建的文件夹的路径。你还可以将这些两行放入一个批处理文件中,每次你打开控制台编写 Java 代码时都可以运行:

Linux

export JAVA_HOME=/home/javadev/java/jdk-17.0.2+8
export PATH=$JAVA_HOME/bin:$PATH

这假设你已经以javadev的身份登录,并且你将 Java 放置在名为java的目录中。这两行可以添加到你的家目录中的.profile文件中,以便每次登录时执行。

macOS

export JAVA_HOME=/Users/javadev/java/jdk-17.03+7/Contents/Home
export PATH=$JAVA_HOME/bin:$PATH

这假设你已经以javadev的身份登录,并且你将 Java 放置在名为java的目录中。这两行可以添加到你的家目录中的.bash.profile文件中,以便每次登录时执行。

验证安装

你可以快速确定 Java 的安装是否正确。打开你正在使用的系统上的命令或控制台窗口。如果你执行了非管理员安装,请确保JAVA_HOMEPATH已更新并设置。在命令窗口中,输入以下命令:

java -version

如果安装成功,如果你安装了 OpenJDK,输出将如下所示:

openjdk version "17.0.3" 2022-04-19
OpenJDK Runtime Environment (build 17.0.3+7-Ubuntu-0ubuntu0.20.04.1)
OpenJDK 64-Bit Server VM (build 17.0.3+7-Ubuntu-0ubuntu0.20.04.1, mixed mode, sharing)

如果您安装了 Temurin JDK,输出将如下所示:

openjdk version "17.0.3" 2022-04-19
OpenJDK Runtime Environment Temurin-17.0.3+7 (build 17.0.3+7)
OpenJDK 64-Bit Server VM Temurin-17.0.3+7 (build 17.0.3+7, mixed mode, 
    sharing)

您的安装现在已完成并已验证。现在让我们检查一下您刚刚安装的一些文件。

盒子里有什么?

JDK 包含将源代码编译成字节码并在 JVM 程序中执行代码所需的程序和库。它还包括许多支持您作为开发人员工作的工具。

Java 还有一种第二种打包方式,称为Java 运行时版JRE)。这个较小的包只包含运行 Java 字节码所需的组件,而不包括 Java 编译器。

Java 9 引入了一种新的打包 Java 应用程序的方法,这使得 JRE 变得多余。截至 Java 11,Oracle 不再为其分发和 OpenJDK 提供 JRE。其他公司的一些分发可能仍然为 Java 的当前版本提供 JRE。我们将在后面的章节中探讨打包 Java 应用程序的模块化方法。

Java 的安装将占用大约 300 MB 的磁盘空间,具体取决于底层操作系统。

以下是从 Linux 和 Windows Java 安装的目录结构。第一个是为 Ubuntu 设计的,但在所有 Linux 和 macOS 安装上几乎相同。

Ubuntu 20.04.4 LTS 的目录结构如下:

$ ls -g -G
total 36
-rw-r--r--  1 2439 Apr 19 17:34 NOTICE
drwxr-xr-x  2 4096 Apr 19 17:34 bin
drwxr-xr-x  5 4096 Apr 19 17:33 conf
drwxr-xr-x  3 4096 Apr 19 17:33 include
drwxr-xr-x  2 4096 Apr 19 17:33 jmods
drwxr-xr-x 72 4096 Apr 19 17:33 legal
drwxr-xr-x  5 4096 Apr 19 17:34 lib
drwxr-xr-x  3 4096 Apr 19 17:33 man
-rw-r--r--  1 1555 Apr 19 17:34 release

Windows Enterprise 11 版本 21H2 的目录结构如下:

>dir
2022-03-29  11:28 AM    <DIR>          .
2022-05-03  05:41 PM    <DIR>          ..
2022-03-29  11:28 AM    <DIR>          bin
2022-03-29  11:28 AM    <DIR>          conf
2022-03-29  11:28 AM    <DIR>          include
2022-03-29  11:28 AM    <DIR>          jmods
2022-03-29  11:28 AM    <DIR>          legal
2022-03-29  11:28 AM    <DIR>          lib
2022-03-29  11:28 AM             2,401 NOTICE
2022-03-29  11:28 AM             1,593 release

如果我们调查bin文件夹,我们会发现几个 Java 称为其工具的可执行程序。在 Windows 系统上,它们都有.exe扩展名;在 Linux 和 macOS 上,它们只显示名称。在本章中,我们将讨论以下工具:

  • jar

  • java

  • javadoc

  • jlink

  • jmod

  • jpackage

  • jshell

  • javaw

这些是我们将在接下来的章节中使用的工具。请参阅进一步阅读部分的工具规范链接,以了解 JDK 中包含的所有工具的详细信息。

我们将这些工具分为以下类别:

  • 编译和执行 Java 程序

  • 组装和打包 Java 应用程序

  • 记录 Java 类文档

  • 读取、评估、打印和循环REPL

让我们逐一查看这些类别。

编译和执行 Java 程序

这些是将我们从源代码带到运行 Java 程序的工具。一些重要的工具如下。

javac

这就是 Java 编译器。它的作用是将以.java结尾的 Java 源代码文件编译成以.class结尾的字节码文件。

java 或 javaw.exe

这是启动 JVM 进程并执行该进程中的字节码文件的工具。当使用java时,将打开一个控制台窗口,并保持打开状态,直到 JVM 进程结束。javaw工具也会启动 JVM 并执行字节码程序。它不会打开控制台。

Windows 用户通常不期望打开控制台,因为他们可能从未见过或与之交互过。如果您想创建一个 Windows 快捷方式来运行 Java 程序,您将使用javaw.exe program.class

我们将在第二章,“代码、编译和执行”中检查这三个命令。

组装和打包 Java 应用程序

一个 Java 程序可以由数百、数千甚至更多的.class文件组成。在这些情况下,有必要将这些文件以及任何支持文件(如图像)组装成一个单独的文件。执行此类操作的某些工具如下。

jar

一个 Java 程序或库通常由多个.class文件组成。为了简化此类程序的交付,jar工具将应用程序或库的所有类文件合并成一个使用 ZIP 压缩且具有.jar扩展名的单个文件。.jar文件可以被指定为可执行文件。在这种情况下,您已经安装了 Java JDK 或 JRE。我们将在第二章,“代码、编译和执行”中看到这个工具是如何使用的。

Java 9 引入了模块化 Java 的概念,这是一种使用称为.jmod文件的新格式来组装 Java 应用程序的方法。这些文件类似于.jar文件,因为它们是 ZIP 压缩文件。jmod工具创建.jmod文件。

在 Java 9 出现之前,一个名为rt.jar的单个文件包含了所有的 Java 库。从 Java 9 开始,Java 库作为单独的.jmod文件存在。Java 将 JVM 文件打包成.jmod文件。这意味着对于开发者来说,现在可以分发包含 JVM 和仅包含执行程序必须可用的 Java 组件的 Java 应用程序。现在不再需要预先安装 JDK 或 JRE,因为所有执行程序所需的都在存档中。您仍然需要jar工具来构建这样的可执行文件,因为您不能直接执行.jmod文件。我们将在第十六章,“独立包和容器中的 Java 部署”中看到这两个工具是如何使用的。

jpackage

jpackage工具创建包含 Java 应用程序和 Java 运行时的本地应用程序。它与.jar.jmod文件一起使用。输出是一个可执行文件,例如 Windows 的.msi.exe文件,或者 macOS 系统的.dmg文件。我们将在第十六章,“独立包和容器中的 Java 部署”中看到这个工具是如何使用的。

记录 Java 类

在仔细记录了代码之后,Java 有一个工具可以收集所有注释,以便其他可能使用您的代码的开发者可以方便地访问。

javadoc

在几乎每种语言中,文档化代码一直是一个问题。Java 采取了一种独特的方法来鼓励文档化。如果你以特定的格式注释你的代码(我们将在第四章语言基础 - 数据类型和变量中探讨),javadoc工具将为每个你创建的类生成一个 HTML 页面。在这个页面上,你可以找到类的所有公共成员。

查看 https://docs.oracle.com/en/java/javase/17/docs/api/java.base/java/util/ArrayList.html 上的ArrayList类的javadoc页面。你在这个网页上看到的一切都是写入源代码文件中的,然后转换为你现在看到的 HTML 页面。我们将在第十一章**, 文档和日志记录中探讨这个工具。

REPL

REPL 工具是一种支持逐行执行代码的工具。

jshell

jshell工具允许你编写和执行单个 Java 语句,无需对类和方法进行通常的装饰。这对于学习 Java 非常有用。它可以逐行执行你编写的代码。我们将在第二章**, 代码、编译和执行中探讨jshell

摘要

在本章中,我们了解了一些关于 Java 的历史、它的许可方式以及为什么有这么多 Java 的发行版和版本。你现在理解了 Java 作为一个开发工具,并知道如何选择 Java 发行版和版本。我们看到了如何在不同的操作系统上安装 Java。在第十二章BigDecimal 和单元测试中,我们还将探讨如何在 Docker 容器中安装 Java。我们以查看 JDK 中包含的九个 Java 工具结束本章;我们将在后面的章节中再次看到它们。我们将在那些章节中了解更多关于这些工具的信息。

第二章代码、编译和执行中,我们将学习如何编写、编译、链接和执行 Java 程序。使用纯文本编辑器、jshell以及集成开发环境IDE)将是我们的重点。

进一步阅读

第二章:编码、编译和执行

安装 Java 后,我们几乎准备好查看编码了。不过,在我们到达那里之前,我们需要学习如何编码、编译和执行 Java 应用程序。虽然集成开发环境IDE)可能是你大部分工作的选择,但理解在没有 IDE 的指导下的编码方式,是区分 Java 爱好者与 Java 专业人士的关键。

在本章中,我们将探讨从命令行工作,然后从一些最广泛使用的 IDE 中工作。本章将不会是 IDE 教程,而是一个关于它们为程序员提供内容的回顾。任何 IDE 的基本操作都非常类似于最常用的 IDE。在我们检查 Java 的各种使用方法之前,我们将查看一个小程序,我们将使用它。这本书是一个Hello World!免费区,这意味着第一个示例将做一些有用的事情。

本章的目标是使您熟悉编译和执行 Java 代码的四种方法,并介绍开发者可用的 IDE 工具。我们将涵盖以下主题:

  • 第一个程序

  • JShell – Java 的 REPL

  • 两步编译和执行过程 – javacjava/javaw

  • 启动单文件源代码程序

  • 集成开发环境

技术要求

要跟随本章的示例,您需要以下内容:

  • 已安装 Java 17

  • 文本编辑器,例如记事本

您可以在 GitHub 上找到本章的代码文件,地址为github.com/PacktPublishing/Transitioning-to-Java/tree/chapter02

第一个程序

在我们学习如何编译和执行 Java 代码之前,我们需要一个 Java 程序来操作。我们的第一个程序将计算复利。有一句引用自阿尔伯特·爱因斯坦的话,据说他曾说过,“复利是世界第八大奇迹。”他是否真的说过这句话还有待商榷。无论如何,计算利息的利息是进行的最重要金融计算之一。以下是我们将实现的公式:

图片

在这里,P 是存入复利账户的本金,r 是通常表示为年利率的利率,n 是复利期数(如果按月复利,则该值为 12),而 t 是资金复利的时间。这以年为单位表示,必须除以复利期数,在这种情况下,也将是 12。

在本书的第二部分中,我们将探讨语言的语法和结构。在那里,我们将探索这个程序的代码。因此,我们将只使用这个程序,它易于理解。您可以从这本书的 GitHub 仓库下载它。

下面是一个简单的 Java 程序的代码,该程序将计算在固定利率下,经过一段时间后固定金额的价值:

import java.text.NumberFormat;
public class CompoundInterest01 {
    private final double principal = 100.0;
    private final double annualInterestRate = 0.05;
    private final double compoundPerTimeUnit = 12.0;
    private final double time = 5.0; // 
    private final NumberFormat currencyFormat;
    private final NumberFormat percentFormat;
    public CompoundInterest01() {
        currencyFormat = 
          NumberFormat.getCurrencyInstance();
        percentFormat = NumberFormat.getPercentInstance();
        percentFormat.setMinimumFractionDigits(0);
        percentFormat.setMaximumFractionDigits(5);
    }
    public void perform() {
        var result = calculateCompoundInterest();
        System.out.printf("If you deposit %s in a savings 
                account " + "that pays %s annual interest 
                compounded monthly%n" + "you will have 
                after %1.0f years %s", 
                currencyFormat.format(principal),
                percentFormat.format(annualInterestRate),
                time, currencyFormat.format(result));
    }
    private double calculateCompoundInterest() {
        var result = principal * Math.pow(1 + 
            annualInterestRate / compoundPerTimeUnit, 
            time * compoundPerTimeUnit);
        return result;
    }
    public static void main(String[] args) {
        var banker = new CompoundInterest01();
        banker.perform();
    }
}

所有 Java 程序都必须至少包含一个称为 的结构。而 C++ 允许你混合结构化风格和面向对象风格,Java 则要求使用后者风格。

通过这样,你已经看到了你的第一个完整的 Java 程序。如果你来自 C++ 或 C# 背景,你很可能理解它是如何工作的。如果你没有面向对象编程(OOP)的背景,你可以将其视为一个结构化程序。在本书的 第二部分 中,我们将探讨 Java 的语法。接下来,我们将从命令行以三种不同的方式运行此程序。

JShell – Java 的 REPL

读取-评估-打印循环REPL)是一个可以逐行执行代码的环境。REPL 从 Java 9 版本开始成为 Java 的标准部分。它通过一个名为 JShell 的工具实现。它有两个用途:

  • 它提供了一个无需编程背景即可学习 Java 的环境。

  • 它提供了一种快速测试概念、语法和库的方法。

因此,你可以执行 Java 代码而不需要通常的装饰。

我们可以使用 JShell 的两种方式。第一种是只需输入必要的代码来使用公式。想象一下,你想验证复利公式的正确性,如源代码所示。你可以通过仅输入执行计算所需的代码来实现这一点。

在以下代码块中,我们已输入了四个变量声明及其所需的计算值,然后是执行计算并将其分配给变量的代码行:

private final double principal = 100.0;
private final double annualInterestRate = 0.05;
private final double compoundPerTimeUnit = 12.0;
private final double time = 5.0; // 
var result = principal * Math.pow(1 + annualInterestRate / compoundPerTimeUnit, time * compoundPerTimeUnit);

在 JShell 中按照以下步骤输入这五行代码:

  1. 在 Windows 上,打开命令提示符。如果你在 macOS/Linux 系统上工作,请转到终端。如有必要,设置 PathJAVA_HOME 值。然后,输入 jshell 命令。控制台将看起来像这样:

图 2.1 – 运行 JShell

图 2.1 – 运行 JShell

  1. 现在,我们可以输入以下五行代码:

图 2.2 – 在 JShell 中逐行执行代码

图 2.2 – 在 JShell 中逐行执行代码

注意,每行代码都是在输入时执行的,JShell 会报告分配给每个变量的值。Java 不会自动格式化值,因此结果是原始浮点数。

我们可以假设由于没有错误执行,结果就是正确的吗?绝对不行!特别是对于计算,你需要第二个结果来源。这就是电子表格如此宝贵的所在。以下是使用 Microsoft Excel 中的公式计算后的结果:

图 2.3 – Excel 中的复利计算

图 2.3 – Excel 中的复利计算

如果你在输入代码时出现了错误,这意味着你的代码与工作表中的结果不匹配,那么你可以使用/reset命令清除你在 JShell 中输入的所有内容并重新开始。JShell 总是保留你最后输入的代码,除非你重置它。JShell 还维护了你输入的所有内容的记录,当你使用/reset时不会丢失。历史记录,可以通过上下箭头访问,将仅允许你将之前使用的代码输入到你当前正在工作的内容中。

我们可以使用 JShell 的第二种方式是通过使用编辑器并将整个程序提供给工具。在我们向 JShell 提供完整的程序之前,我们必须使用/reset命令来删除之前输入的内容。

要执行整个程序,请按照以下步骤操作:

  1. JShell 有一个基本的编辑器,支持多行 Java 代码。使用/edit命令来打开编辑器。

  2. 此编辑器不能打开文件,因此你需要在你自己的文本编辑器中打开CompoundInterest01.java文件,复制文件的内容,并将其粘贴到 JShell 编辑器中。

  3. 当你点击接受时,JShell 编辑器中的代码将被传输到 JShell:

图 2.4 – 将程序粘贴到默认 JShell 编辑器后的样子

图 2.4 – 将程序粘贴到默认 JShell 编辑器后的样子

  1. 现在,你必须点击退出以离开编辑器,这将恢复 JShell 提示符。

  2. 现在,你可以在 JShell 提示符中输入CompoundInterest01.main(null)来运行main方法,这将导致程序执行。main方法期望一个参数。如果你没有要传递给main方法的参数,那么你将自动传递 null:

图 2.5 – 将程序从编辑器传输到 JShell 后运行

图 2.5 – 将程序从编辑器传输到 JShell 后运行

你不需要在/set editor命令中使用基本编辑器并包含你选择的编辑器的路径,如下面的截图所示:

图 2.6 – 在 JShell 中更改编辑器

图 2.6 – 在 JShell 中更改编辑器

在这里,我已经将编辑器设置为 Windows 的 Notepad++。请注意,在 Windows 系统中输入/set editor命令时,路径分隔符使用了双反斜杠,因为它是被输入在一个 Windows 系统上的。在 Linux 或 Mac 系统上,路径分隔符是一个正斜杠,不需要双倍。由于路径中包含空格,因此它们必须用引号括起来。

当使用 JShell 的外部编辑器时,你必须退出编辑器以将你输入的内容传输到 JShell。这是因为没有接受按钮。

JShell 工具对于测试或学习 Java 的新功能或语法非常有用。它对于教授 Java 给初学者也非常有用。尽快熟悉这个工具。

两个步骤的编译和执行过程——javac 和 java/javaw

运行 Java 程序最常见的方法涉及两个步骤。首先,你必须使用 javac 编译代码,然后使用 Java 或在 Windows 上使用 javaw 在 Java 虚拟机JVM)中执行代码。

准备 Java 源代码以执行的第一步是将它编译成 字节码。这是 JVM 的机器语言。应用程序的每个 Java 源文件都必须编译成字节码。

第二步是在 JVM 中执行字节码。与 C 或 C++ 不同,没有链接步骤。链接步骤将所有编译代码组合成一个单一的执行文件。在 Java 中,所有字节码文件都必须在类路径上,即所有字节码文件的路径,而不一定组合成一个单一的文件。这可能会让人感到困惑,因为有一个名为 jlink 的工具,但它的目的是将 Java 运行时与你的代码结合起来,这样最终用户就不需要在他们计算机上之前安装的 Java 版本。我们将在 第十六章 部署 Java 到独立包和容器 中检查 jlink

让我们编译并执行我们的 CompoundInterest01 程序:

  1. 首先,将 CompoundInterest01.java 文件放置在其自己的文件夹中。这不是必需的,但如果它有自己的文件夹,代码管理起来会更方便。

  2. 现在,在那个文件夹中打开一个控制台;如果你没有使用 Java 的管理员安装版本,请设置 PathJAVA_HOME 属性,如前一章所示。现在,你可以使用 javac CompoundInterest01.java 编译程序:

图 2.7 – 编译 Java 程序

图 2.7 – 编译 Java 程序

如果编译代码没有出现任何错误,那么编译器将在控制台不显示任何内容。现在文件夹中将包含一个名为 CompoundInterest01.class 的新文件。注意,它比 Java 源代码文件要大。

Java 类文件包含必要的 字节码 和源代码。该文件中源代码的存在支持 反射内省 的概念。

反射允许你编写可以在运行时检查对象类型或属性的代码,而反射允许你编写可以在运行时检查和修改对象结构的代码。这两个特性在商业编程中很少使用。

  1. 代码编译完成后,我们可以运行它。在这里,我们使用 java 可执行文件来运行我们的程序。

一个常见的误解是认为 java 可执行文件是 JVM。它不是。它是 JVM 的加载器,由 JDK 安装的一部分其他文件组成。JVM 利用其 ClassLoader 组件来定位和执行字节码文件。

让我们运行 CompoundInterest01 程序。在控制台中输入 java CompountInterest01。不需要包含 .class 扩展名,因为这仅是唯一可接受的扩展名:

图 2.8 – 运行 Java 程序

图 2.8 – 运行 Java 程序

这里,你可以看到程序的输出。

在 Windows 系统中,还有一个名为 javaw 的第二个 JVM 加载器,用于在没有打开控制台的情况下执行 GUI 应用程序。如果你创建一个 Windows 快捷方式来运行 GUI Java 程序并使用 java.exe,那么将打开一个控制台窗口,然后是程序的 GUI 窗口。如果你使用 javaw.exe,则不会出现控制台窗口。

在大多数情况下,使用 javac 然后使用 java 是在命令行中处理 Java 代码最常见的方式。可能由多个文件组成的程序需要每个文件都进行编译,但只有包含 main 方法的文件会被执行。让我们看看一种最后一种在单步中编译和执行 Java 程序的方法。

启动单文件源代码程序

在 Java 11 之前,从源代码到执行的过程是两步过程:你编译代码然后运行代码。从 Java 11 开始,引入了另一种运行 Java 程序的方法,称为 启动单文件源代码程序。这允许你在单行中编译、启动 JVM 并执行程序。我们将在检查 Linux 和 macOS 的独特方法之前,看看这是如何在 Windows、macOS 和 Linux 上工作的。

对于 Windows、macOS 和 Linux

在你想要运行的文件所在的文件夹中打开命令提示符或终端,如果需要,更新 PathJAVA_HOME 属性。现在,只需输入 java 和源文件名:

图 2.9 – 运行 Java 程序

图 2.9 – 运行 Java 程序

正如这种技术的名称所暗示的,你的程序只能由一个文件组成。这个源文件可以包含多个类,并且文件中的第一个类必须有一个 main 方法。让我们看看一个将程序拆分为一个文件中的两个类的新版本:

import java.text.NumberFormat;
public class CompoundInterest02 {
    public static void main(String[] args) {
        var banker = new CompoundInterestCalculator02();
        banker.perform();
    }
}

这个文件中的第一个类只包含所需的 main 方法。下一个类,如下面的代码块所示,也在同一个文件中;这是实际工作执行的地方:

class CompoundInterestCalculator02 {
    private final double principal = 100.0;
    private final double annualInterestRate = 0.05;
    private final double compoundPerTimeUnit = 12.0;
    private final double time = 5.0; // 
    private final NumberFormat currencyFormat;
    private final NumberFormat percentFormat;
    public CompoundInterestCalculator02() {
        currencyFormat = 
                       NumberFormat.getCurrencyInstance();
        percentFormat = NumberFormat.getPercentInstance();
        percentFormat.setMinimumFractionDigits(0);
        percentFormat.setMaximumFractionDigits(5);
    }
    public void perform() {
        var result = calculateCompoundInterest();
        System.out.printf("If you deposit %s in a savings 
                account " + "that pays %s annual interest 
                compounded monthly%n" + "you will have 
                after %1.0f years %s", 
                currencyFormat.format(principal),
                percentFormat.format(annualInterestRate),
                time, currencyFormat.format(result));
    }
    private double calculateCompoundInterest() {
        var result = principal * Math.pow(1 + 
                 annualInterestRate / compoundPerTimeUnit, 
                 time * compoundPerTimeUnit);
        return result;
    }
}

这两个类是 CompoundInterest02CompoundInterestCalculator02。在命令提示符或终端中输入 java CompoundInterest02.java;你会得到相同的结果:

图 2.10 – 在 java 文件中运行包含两个类的 Java 程序

图 2.10 – 在 java 文件中运行包含两个类的 Java 程序

这种技术不会创建一个字节码 .class 文件;它只会在内存中创建。

对于 macOS 和 Linux – Shebang 文件

在 macOS 或 Linux 上,有一种独特的方式来使用启动单文件源代码程序,这是仅在 macOS 或 Linux 上可用的:java 命令。这使得单文件 Java 程序可以作为类似 Bash 的脚本文件使用。Shebang 是指 #! 字符。

让我们看看添加了 Shebang 的源代码的开始部分:

  1. 在源代码中添加 #!,如下所示:
#!//home/omniprof/jdk-17.0.3+7/bin/java --source 17
import java.text.NumberFormat;
public class CompoundInterest03 {
    public static void main(String[] args) {
        var banker = new CompoundInterestCalculator03();
        banker.perform();
    }
}

以 Shebang 开头的第一行包括 Java 可执行文件的路径和–source version开关。版本是你正在使用的 Java 的编号版本,在这个例子中是17。要使用这种技术,文件不能有.java扩展名。将 Java 源代码文件重命名以删除扩展名。

  1. 下一步是使文件可执行。使用chmod +x CompoundInterest03来执行。

  2. 最后,你必须通过输入./CompoundInterest03来执行文件。以下是输出:

图 2.11 – 在 Linux 中使用 Shebang 运行 Java 程序

图 2.11 – 在 Linux 中使用 Shebang 运行 Java 程序

在这里,我们正在运行我们的 Java 程序,就像它是一个普通的 Linux 或 macOS 程序一样。

这就结束了我们关于使用命令行 Java 的话题。我们首先查看 JShell 中的 REPL,然后是经典的两个步骤方法,最后是启动单个文件源代码程序的方法。我们还介绍了独特的 Shebang 技术。现在,让我们了解四种最广泛使用的 IDE。

集成开发环境

是时候说点实话了——非常少的 Java 开发者只使用像vi记事本这样的文本编辑器。知道如何使用独立的文本编辑器并在命令行中进行编译/执行是一个重要的技能,但当我们有选择工具的时候,我们总是会选择 IDE。本节中我们将探讨的特性将解释为什么是这样。

在本节中,我们将简要回顾四种最广泛使用的 IDE。每个 IDE 都有一个独特的构建系统,我们将在第三章《Maven 构建工具》中讨论,所有 IDE 都支持相同的外部构建系统。这意味着在一个团队中,每个成员都可以使用他们认为最能提高生产力的 IDE,同时能够在团队成员之间自由地移动代码,而无需为特定 IDE 进行更改。在我介绍这些 IDE 之前,让我们看看它们共有的特性。

特性 1 – 代码编辑器

每个 IDE 的核心是其编辑器。像任何普通的文本编辑器一样,它支持通常的功能列表,如剪切、复制和粘贴。使 IDE 编辑器与这些不同的地方在于,每个按键都被监控。如果你误输了变量或方法的名称,你将立即在你的屏幕上得知错误。

编辑器还分享了 JShell 一次执行一行代码的能力。这发生在看不见的地方。如果你正在执行会产生错误的代码——例如引用不属于项目的库——你将在输入时而不是在尝试编译和执行代码时在编辑器中得知错误。尽管不是所有的错误,但大多数错误都是在输入时被检测到的。

这些编辑器另一个非常有价值的特性被称为代码补全。微软称此特性为IntelliSense。代码补全可以意味着几件事情——例如,如果你写了一个开括号、方括号或圆括号,IDE 将会添加相应的闭括号。

在 Java 中,像其他一些面向对象的语言一样,点操作符(.)表示你想要调用对象的成员方法或实例变量。代码补全支持在点操作符后列出所有可能的选项。以下图显示了percentFormat的所有选项:

图 2.12 – NetBeans 中代码补全的示例

图 2.12 – NetBeans 中代码补全的示例

代码补全还可以推荐你代码中的更高效或更现代的更改。例如,原始的 Java switch 语句与 C 语言的 switch 语句相同。switch 语法最近的增强可以有效地消除古老的 switch 语句。如果 IDE 识别出可以使用现代语法,那么你将收到建议,并且在你同意的情况下,IDE 将会以新格式重写你的 switch 语句。

功能 2 – 服务器管理

服务器代表你的代码可能需要与之交互的外部服务。例如,包括数据库服务器如MySQLPostgreSQL,以及 Web 服务器如PayaraWildFly。在 IDE 内部,你可以停止和启动这些服务。对于数据库服务器,你可以将 IDE 连接到服务器并编写SQL查询,查看结果。应用程序或 Web 服务器也可以启动和停止。你可以将编译后的代码部署或取消部署到服务器。

功能 3 – 调试和性能分析

单步执行代码是调试工具的一个非常有价值的特性。现代 IDE 提供了这样的调试能力;当你的代码运行但返回错误结果时,这非常有价值。在调试器运行时,你可以在源代码中跟踪程序的执行。你可以检查变量的状态。你的编码语法错误主要是由编辑器识别的。

性能分析允许你在应用程序运行时对其进行监控。性能分析器报告内存使用情况和方法消耗的 CPU 时间。这些信息在确定程序执行速度慢于预期的地方非常有价值。即使你没有怀疑程序速度存在问题,性能分析器也可以为你提供改进程序性能所需的数据。

功能 4 – 源代码管理

现代 IDE 支持与源代码管理工具如GitMercurialSubversion的交互。这些工具将你的代码保存在一个仓库中。无需使用独立的客户端工具来从仓库中推送或拉取。如果推送导致冲突,IDE 可以展示当前仓库中的内容以及你想要推送的内容,并允许你决定如何解决冲突。

功能 5 – 构建系统

我们迄今为止看到的代码仅由一个文件组成。随着我们对 Java 了解的深入,我们将发现应用程序通常由多个文件组成。这些文件可能放在多个文件夹中。然后,还有提供不属于 Java 功能的外部库,例如与特定数据库交互所需的代码。构建系统负责确保所有组件和库可用。它还负责运行 Java 编译器,然后运行程序。

所有 IDE 都有自己的构建系统。作为独立于 IDE 的外部构建系统,Apache Maven 和 Gradle 将在下一章中介绍。本章我们将讨论的四个 IDE 都支持这些外部构建系统。这意味着,如果你用 IntelliJ 编写了一个配置为使用 Maven 的程序,那么如果 NetBeans 也被配置为使用 Maven,相同的文件也可以在 NetBeans 中打开。

现在,让我们简要地看看最广泛使用的四个 IDE。

Eclipse 基金会 – Eclipse

Eclipse IDE 最初由 IBM 开发,作为他们现有的 Java IDE VisualAge 的替代品,后者是用 Smalltalk 编写的。2001 年,IBM 发布了用 Java 编写的 Eclipse 平台,并将其作为一个开源项目发布。由与 Java 合作的公司组成的董事会负责监督 Eclipse 的开发。随着越来越多的公司加入董事会,决定创建一个独立的开源组织。2004 年,创建了 Eclipse 基金会,其第一个开源项目是 Eclipse IDE。Eclipse 可以在 Windows、macOS 和 Linux 上运行。

你可以从www.eclipse.org/downloads/packages/下载 Eclipse。这里有针对 Java 开发者的 Eclipse IDE,主要用于桌面软件开发。第二个版本称为 Eclipse IDE for enterprise Java and web developers,它增加了对服务器端编程的支持。Eclipse 支持一系列插件,增加了额外的功能。

让我们看看复利程序——我们在第一个程序部分编写的程序——在 Eclipse IDE 中的样子:

图 2.13 – Eclipse 中的复利程序

图 2.13 – Eclipse 中的复利程序

在这里,你可以看到输出,以及你在命令行编译和执行时的样子。所有 IDE 都会捕获控制台输出,并在 IDE 中的窗口中显示。

Apache NetBeans

NetBeans始于 1996 年在捷克共和国的一个学生项目。当詹姆斯·高斯林在推广 Java 的过程中第一次遇到 NetBeans 时,他对其印象深刻,以至于回到公司后,他说服 Sun Microsystems 的管理层收购了 NetBeans 背后的公司。2010 年,Oracle 收购了 Sun Microsystems,2016 年,Oracle 将 NetBeans 源代码捐赠给了Apache 基金会。NetBeans 可以在 Windows、macOS 和 Linux 上运行。

NetBeans 采用了类似于 Java 的更新节奏,预计每 6 个月推出新版本。虽然不如一些其他 IDE 功能丰富,但它是最简单的四个之一,易于使用。因此,当教授 Java 或希望使用没有陡峭学习曲线的 IDE 时,它是理想的选择。作为一个 Apache 开源项目,它也是最易于参与和贡献的。

你可以从netbeans.apache.org/下载 Apache NetBeans。只有一个版本,它支持桌面和服务器端开发。此外,一些插件增加了额外的功能,例如对 Spring 等框架的支持。

让我们看看我们在第一个程序部分编写的复利程序在 Apache NetBeans 中的样子:

图 2.14 – NetBeans 中的复利程序

图 2.14 – NetBeans 中的复利程序

在这里,你可以看到 NetBeans 如何显示复利程序的输出。

微软 Visual Studio Code

Visual Studio CodeVS Code)由微软于 2016 年推出。它的目的是成为一个适用于 JavaScript、C++、Python 和 Java 等多种语言的开发环境。该程序由一个核心组件组成,作为开源发布。对特定语言的支持由扩展处理。主要的 Java 扩展由 Red Hat 开发,与微软编写的扩展不同,这个扩展是开源的。

VS Code 是用TypeScript编写的,并使用开源的 Electron 框架来创建桌面应用程序。VS Code 适用于 Windows、macOS 和 Linux,尽管并非所有扩展都能在所有操作系统上运行。

你可以从code.visualstudio.com/docs/languages/java下载 VS Code 以及几个 Java 扩展。你将想要下载包含 VS Code 和 Java 扩展的编码包。如果你已经下载了 VS Code 的基本版本,你可以通过下载 Java 扩展包来向现有安装添加 Java 支持。

下面是我们在第一个程序部分编写的复利程序在 VS Code 中的样子:

图 2.15 – VS Code 中的复利程序

图 2.15 – VS Code 中的复利程序

在这里,你可以看到 VS Code 如何显示复利程序的输出。

JetBrains IntelliJ IDEA

来自 JetBrains 公司的IntelliJ IDEA是用 Java 编写的,于 2001 年推出。它有两种版本。首先,有一个免费的社区版,具有开源许可证,用于开发桌面 Java 应用程序。第二个商业版本,称为 Ultimate,包括对 Java EE/Jakarta EE 和 Spring 等额外 Java 框架的支持。商业版本需要年度付费订阅。

IntelliJ 被认为是 Java IDE 中最功能丰富的。这并不一定意味着它是最好的,但它是所有 IDE 中最广泛使用的。您可以从www.jetbrains.com/idea/download/下载它。如前所述,社区版是免费的,而终极版本需要订阅。

让我们看看复利程序——我们在第一个程序部分编写的程序——在 IntelliJ IDEA 中的样子:

图 2.16 – IntelliJ IDEA 中的复利程序

图 2.16 – IntelliJ IDEA 中的复利程序

在这里,您可以看到 IntelliJ IDEA 中的输出。这就是您在命令行编译和执行时的样子。

你应该使用哪个 IDE?

选择 IDE 时需要考虑两个因素:

  • 第一个因素是您所在的公司是否强制要求使用特定的 IDE。如果是这样,那么选择已经为您做出了。

  • 第二个因素是使用 IDE 时的感受。这里展示的所有四个 IDE 都可以支持你在 Java 中编写的任何代码。它们也可以用于其他语言,例如 C、C++、PHP 和 JavaScript。

我只能解释我的选择以及选择该选择的原因。我需要一个需要最少课堂指导的 IDE。我在魁北克省一所大学的三年计算机科学课程的最后一年教授 Java 项目课程。我需要教授高级桌面编程并介绍学生服务器端编程。我不想教授 IDE。出于这些原因,我选择了 NetBeans。如果学生使用外部 Maven 构建系统,他们可以自由选择其他三个 IDE 中的任何一个,但如果他们在 IDE 上遇到麻烦,我只能提供最基本的支持。

因此,我建议您花时间尝试每个 IDE。根据您使用时的感受选择您个人的 IDE。它们在展示完成任务的不同方法时都同样有效。本书的所有源代码,可以在本书的 GitHub 仓库中找到,都可以在这四个提到的 IDE 上运行。

摘要

在本章中,我们探讨了从命令行编写、编译和执行 Java 程序的各种方法。我们研究了 JShell 中的 REPL 来快速运行代码片段。然后,我们看到了 Java 在两个步骤中编译和执行的经典方式。最后,我们探讨了如何执行单个文件源代码程序来执行在单个文件中编写的 Java 程序。通过在 macOS 和 Linux 中发现的 Shebang 概念,我们看到了 Java 甚至可以用作脚本语言。最后,我们简要地回顾了四种最常用的 IDE。

现在你已经知道了如何编写、编译和执行 Java 程序,在下一章中,我们将探讨一个可以从命令行或 IDE 内部使用的外部构建系统。这个主题将帮助解释为什么你的 IDE 选择是个人化的。你将看到为什么开发者可以一起工作,而团队成员可能使用不同的 IDE。

进一步阅读

要了解更多关于本章所涉及主题的信息,请查看以下资源:

第三章:Maven 构建工具

Java 程序很少只有一个文件。它们可以只包含几个文件,或者成千上万个文件。我们已经看到,你必须将 Java 源代码文件编译成字节码。对于这么多文件来说,这样做的工作相当繁琐。这就是构建工具非常有价值的地方。

在上一章中,程序都是在我们存储它们的文件夹中运行的。随着程序发展成为多个文件,你通过分类来管理它们。这些基本分类可以追溯到编程的早期,包括输入、处理和输出。你可以将这些分类分解成程序必须执行的具体任务。在 Java 中,我们称这类别为。一个包反过来是一个文件夹,你将属于该类别的所有 Java 文件存储在其中。一个复杂的程序可能包含成百上千个组织成包的文件。

在这个环境中,你必须编译每一个文件。正如你可以想象的那样,如果你必须逐个编译它们,这可能会非常繁琐。构建系统的一个目的就是简化这项任务,并且至少,你必须在命令行中输入的是mvn,这是 Maven 的可执行程序。在本章中,我们将看到如何使用Maven。由于 Maven 是每个 IDE 的一个特性,你可以将任何组织为 Maven 管理的项目的程序加载到任何 IDE 中。

重要提示

虽然 Maven 是最广泛使用的构建系统,但它并非唯一。另一个流行的构建系统被称为Gradle。它区别于 Maven 的地方在于它使用的是命令式配置文件而不是声明式配置文件。Gradle 使用基于Groovy语言的领域特定语言DSL)。因此,它可以用于通用编程,尽管它的词汇和语法是为构建软件项目而设计的。

本章将涵盖以下内容:

  • 安装 Maven

  • Maven 功能概述

  • pom.xml配置文件

  • 运行 Maven

到本章结束时,你将足够了解 Maven 构建过程,以便能够立即使用它。本书后面的部分,我们将看到如何使用 Maven 来管理测试。请参阅进一步阅读部分,以获取有关 Maven 的详细文章和免费书籍的链接。

技术要求

这里是运行本章示例所需的工具:

  • Java 17 已安装

  • 文本编辑器

  • Maven 3.8.6 或更高版本已安装

我建议在继续之前从github.com/PacktPublishing/Transitioning-to-Java/tree/chapter03下载与本书相关的源代码,这样你就可以尝试本章中展示的内容。

注意

Ubuntu 和其他 Linux 发行版可能已经安装了 Maven 的一个版本。如果它不是 3.8.6 或更高版本,你必须用最新版本替换它。

安装 Maven

访问 Maven 下载页面 maven.apache.org/download.html。在这里,您将找到两种不同的压缩格式,一个是 Windows(.zip),另一个是 Linux/macOS (tar.gz)。

图 3.1 – Maven 压缩文件

图 3.1 – Maven 压缩文件

这里显示的版本代表的是撰写本文时的当前版本。在开始时,安装最新版本是最好的选择。现在,让我们回顾一下如何为每个操作系统安装 Maven。

Windows

Maven 没有安装程序。将 ZIP 归档解压到一个文件夹中。正如我们在没有安装程序的情况下安装 Java 时所看到的,我使用名为 devapp 的文件夹来存放所有我的开发工具。一旦解压,您需要将 bin 文件夹的位置添加到您的路径中。您可能会遇到对两个环境变量 M2_HOMEMAVEN_HOME 的引用。虽然它们不会造成任何伤害,但自 Maven 3.5.x 版本以来,这两个变量都已过时。

如果您是计算机管理员,只需将 bin 文件夹的路径添加到您的路径中。如果不是,则使用 set 命令将其添加到路径中。以下是我的 setjava.bat 文件,适用于非管理员用户。根据您的文件夹结构修改您的批处理文件。

set JAVA_HOME=C:\devapp\jdk-17.0.2+8
set PATH=%JAVA_HOME%/bin;C:\devapp\apache-maven-3.8.6\bin;%PATH%

您可以使用 mvn --version 验证 Maven 是否正在运行:

图 3.2 – Windows 上 mvn --version 的输出

图 3.2 – Windows 上 mvn --version 的输出

Linux

如果您具有超级用户权限,可以使用此命令:

$ sudo apt-get install maven

使用 mvn --version 验证。

如果您不是超级用户,将从 Maven 网站下载的 tar.gz 文件解压到您选择的文件夹中:

$ tar -xvf apache-maven-3.8.6-bin.tar.gz -C /usr/local/apache-maven/apache-maven-3.8.6

现在,将 Maven 的位置添加到您的路径中:

export PATH=/usr/local/apache-maven/apache-maven-3.8.6/bin
:$PATH

根据您的 Linux 发行版,将此行添加到 .profile.bash_profile 文件中。

macOS

假设您在 Mac 上使用 Homebrew,您可以使用以下命令安装 Maven:

$ brew install maven

使用 mvn --version 验证安装。

如果您没有 Homebrew 或不是超级用户,则可以像为 Linux 非超级用户安装 Maven 一样安装 Maven。较新的 macOS 版本使用 .zshenv 而不是 .profile 作为用户脚本。

图 3.3 – Linux 或 macOS 上的安装验证

图 3.3 – Linux 或 macOS 上的安装验证

安装 Maven 后,让我们看看它为我们提供了什么。

Maven 功能概述

JDK 部分的标准 Java 库非常广泛。然而,还有一些提供功能的附加库,例如连接到关系型数据库,您必须下载并将它们添加到项目运行之前。您可以使用 Maven 来配置它为您完成这项工作。无需访问库的网页 - 下载文件,将其放置在正确的文件夹中,并让 Java 编译器知道它可用。

与大多数构建工具一样,Maven 不仅仅是一个编译程序的工具。

在今天的开发环境中,如果代码编译成功,代码不会直接从开发者那里进入生产。必须对方法进行单元测试,对程序中各个模块或类之间的交互进行集成测试。你将使用专门的服务器来完成这项工作,并且你可以配置 Maven 来执行这项工作。

让我们回顾一下它还能做什么。

依赖管理

虽然 Java 语言及其标准库可以覆盖多个用例,但它们只能覆盖程序员想要做的很小一部分。经常,添加的 Java 库提供对任务的支持,例如使用 JavaFX 进行 GUI 编程;某些驱动程序,例如用于与一系列数据库工作的驱动程序,用于复杂的日志记录或增强数据收集等,需要成为你程序的一部分。问题是这些库必须位于Java 类路径上。类路径是包含 Java 库的文件和文件夹列表,这些库必须在你的文件系统中可访问。如果没有像 Maven 这样的工具,你必须手动下载你希望使用的每个库并更新 Java 类路径。

然而,Maven 允许你在项目的配置文件pom.xml中列出你计划在项目中使用的所有库。Maven 在你的文件系统中保留一个文件夹,用于存储所需的依赖库文件。这个文件夹被称为仓库。默认情况下,这个文件夹位于另一个名为.m2的文件夹中,而这个文件夹又存储在你的主目录中。你可以将其更改为使用电脑上的任何文件夹,尽管大多数程序员都保留默认文件夹位置不变。

如果所需的依赖项尚未存在于你的本地仓库中,那么 Maven 将下载它。存在一个默认的中心仓库,称为 Maven Central,可以在repo1.maven.org/maven2/找到。你可以在search.maven.org/搜索库并检索添加到你的pom.xml文件中的条目。

Maven 插件

Maven 程序并不大;它依赖于称为插件的 Java 程序来执行其任务。例如,有用于编译和打包代码、运行测试、执行并将代码部署到服务器的插件,等等。《pom.xml》文件是我们列出插件及其依赖的地方。你可以在 MVNRepository 中像查找依赖项一样查找插件。

Maven 程序使用一系列默认插件,这些插件可以在不包含在pom.xml文件中的情况下使用。Maven 的主要构建使用在构建发布时默认插件的版本。为了确保你使用的是插件的最新版本,我建议在pom.xml文件中列出你将使用的每个插件。一般来说,始终明确列出你将使用的插件,而不是让 Maven 使用其内置或隐式插件。

Maven 项目布局

要使用 Maven,有必要将程序的文件夹组织成特定的布局。您可以配置 Maven 使用您选择的布局。以下是将要使用的默认布局。这将允许 Maven 发现您项目中的所有文件和资源。让我们看看桌面应用程序的文件夹结构:

Project Folder
    /src
        /main
            /java        
            /resources    
        /test
            /java        
            /resources    
    pom.xml file

在您使用编辑器从命令行开始编码之前,您必须创建这些文件夹和 pom.xml 文件。如果您在创建时指定它是一个 Maven 项目,您的 IDE 将创建此结构。所有 IDE 都会在遵循此文件夹布局的项目中打开 Maven 项目。

在您成功构建程序后,您将找到一个名为 target 的新文件夹。这是 Maven 存储编译的源代码文件和最终打包文件(称为 jar 文件)的地方。以下是 target 的文件夹结构:

    /target
        /classes
        /generated-sources
        /generated-test-sources
        /maven-archiver
        /maven-status
        /test-classes
        project.jar file

Maven 在您第一次为项目构建时创建此文件夹。每次构建时,如果您更改了匹配的源代码文件,Maven 将用新版本替换 target 中的任何文件。请勿编辑或更改 target 中找到的任何文件,因为下次您创建构建时,它将替换 target 中的文件,并且您所做的任何编辑都将丢失。

您还可以指示 Maven 清理项目,这将导致 target 中的内容被删除。如果 pom.xml 文件指示 Maven 将您的程序打包为存档,例如 JAR 文件,那么您将在 target 文件夹中找到 JAR 或您正在创建的任何存档。

如果您不使用 IDE,您可以考虑编写一个批处理文件或 shell 脚本来创建此文件夹结构。

下一个任务是创建包含所需插件的 pom.xml 文件——但在我们这样做之前,让我们看看我们如何使用包组织多个源代码文件。

Java 源代码包

Java 语言鼓励开发者根据功能组织他们的代码。这可能包括与用户交互的代码、从数据库访问记录或执行业务计算。在本节中,我们将了解包以及如何在 Maven 项目中使用它们。我们已经知道您不需要包。我们在上一章中运行的第一个程序 CompoundInterest 没有任何包。当项目仅由一个文件组成时,这很有用。一旦项目包含多个文件,您将使用包。

由于我们使用 Maven,我们的包的位置必须是 src/main/java

Project Folder
    /src
        /main
            /java        

包命名的规则与标识符的规则相似,以及您操作系统中文件夹命名的任何规则:

com.kenfogel.business

点号代表斜杠,具体是正斜杠还是反斜杠取决于您的操作系统。这意味着 businesskenfogel 中的一个文件夹,而 kenfogelcom 中的一个文件夹。使用我们的 Maven 布局,它将如下所示:

Project Folder
    /src
        /main
            /java        
                /com
                    /kenfogel
                        /business

当我们使用包时,属于该包的每个文件都必须有一个声明包名称的语句作为代码的第一行。

在上一章中,我们使用了一个包含两个类在一个文件中的 CompoundInterest 程序版本,因为 Single-File-Source-Code 功能不能有超过一个文件,正如其名称所暗示的。除非您需要使用 Single-File-Source-Code 功能,否则您应该为程序中的每个类创建一个文件。文件名必须与文件中公共类的名称相同。

这里是包含业务流程的类;请注意,它以包声明开始。在这个第一部分,我们声明了我们需要的外部库,然后声明了类和类变量:

package com.kenfogel.compoundinterest.business;
import java.text.NumberFormat;
public class CompoundInterestCalculator04 {
    private final double principal = 100.0;
    private final double annualInterestRate = 0.05;
    private final double compoundPerTimeUnit = 12.0;
    private final double time = 5.0; 
    private final NumberFormat currencyFormat;
    private final NumberFormat percentFormat;

接下来是包含可执行代码的方法:

    public CompoundInterestCalculator04() {
        currencyFormat = 
            NumberFormat.getCurrencyInstance();
        percentFormat = NumberFormat.getPercentInstance();
        percentFormat.setMinimumFractionDigits(0);
        percentFormat.setMaximumFractionDigits(5);
    }
    public void perform() {
        var result = calculateCompoundInterest();
        System.out.printf(
        "If you deposit %s in a savings account " +
        "that pays %s annual interest compounded " +                   
        "monthly%nyou will have after %1.0f years " +
        "%s%n", 
                currencyFormat.format(principal),
                percentFormat.format(annualInterestRate),
                time, currencyFormat.format(result));
    }
    private double calculateCompoundInterest() {
        var result = principal * 
            Math.pow(1 + annualInterestRate / 
                compoundPerTimeUnit, 
                time * compoundPerTimeUnit);
        return result;
    }
}

每个 Java 程序都必须至少有一个名为 main 的方法。以下是包含 main 方法的类:

package com.kenfogel.compoundinterest.app;
import com.kenfogel.compoundinterest04.business.
CompoundInterestCalculator04;
public class CompoundInterest04 {
    public static void main(String[] args) {
        var banker = new CompoundInterestCalculator04();
        banker.perform();
    }
}

在这两个文件中,您都会看到一个 import 语句。要访问不在同一包中的类,您必须导入它。这个语句通知编译器将使用来自另一个包中的类的代码。让我们讨论一下这两个 import 语句:

  • 第一个 import 语句使 NumberFormat 类(它是 java.text 包的一部分)对编译器可用。请注意,以 javajavax 开头的包通常是 Java 安装的一部分:

    import java.text.NumberFormat;
    
  • 在第二个 import 语句中,通过使用 CompoundInterest04.java,我们在 main 方法中实例化了 CompoundInterestCalculator04 类。这个类文件不在同一个包中,因此您必须导入它以引用它:

    import com.kenfogel.compoundinterest.business.
                          CompoundInterestCalculator04;
    

这里是 Maven 期望在您的存储设备上找到的 CompoundInterest 程序的文件夹结构:

图 3.4 – 基本 Maven 文件结构

图 3.4 – 基本 Maven 文件结构

在这里,我们正在查看 Maven 管理的 CompoundInterest 程序的目录结构。项目被组织成与您的代码中的 import 语句匹配的包。在这里,您可以看到包名,例如 com.kenfogel.compoundinterest,如何在您的文件系统中存在。我们还有 Maven 项目的最后一部分需要了解,那就是 pom.xml 文件。

pom.xml 配置文件

您必须使用一个声明性的 XML 文件来配置 Maven。这个文件以 XML 格式声明了构建项目所需的所有信息。它列出了所需的库以及支持 Maven 任务的插件。在本节中,我们将检查包含 Maven 构建配置的 pom.xml 文件。

这里是每个人在每一个 pom.xml 文件中使用的第一个三个标签:

<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0" 
    xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" 
    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>

让我们描述一下我们刚刚编写的代码部分:

  • <?xml 开头的第一行是 XML 前言。这些是默认值,可以省略,因为它是可选的。

  • project 标签定义了 XML 命名空间。它包括验证 pom.xml 文件的模式文件的存储位置。

  • modelVersion 指的是 pom.xml 文件遵守的版本。自从 Maven 2 以来,它一直是 4.0.0,任何其他值都会导致错误。

接下来是关于我们如何识别一个项目;考虑以下代码块:

    <groupId>com.kenfogel</groupId>
    <artifactId>compoundinterest</artifactId>
    <version>0.1-SNAPSHOT</version>

这三个标签通常被称为项目的 GAV,使用每个标签的首字母 – groupIdartifactIdversion。结合起来,这些应该对您创建的每个项目都是唯一的。

groupIdartifactId 也在您的代码中定义了一个默认包。您不需要有这个包,但可以有任何您认为合适的包结构。当一个项目(无论是您自己的还是为您的项目下载的)存储在您的本地仓库中时,version 变成了另一个文件夹。这允许您拥有具有不同版本号的多个项目版本。如果您正在开发一个供 Maven 下载的库,那么这三个标签的内容就成为了用户下载您的工作的标识。

您可以在 groupIdartifactId 中使用任何您想要的名称。它必须符合字符串的 XML 规则。有一个约定说 groupIdartifactId 的组合应该符合 Java 的命名包规则。名称应该是唯一的,尤其是如果您计划通过 Maven Central 提供它。因此,程序员使用他们公司或个人的域名(反向)。如果您没有域名,那么简单地使用您的名字,就像我一样。我确实拥有 kenfogel.com,并且我建议所有开发者都为他们的工作获取一个域名。

对于 version,我们也可以自由使用任何内容 – 数字或字符串。如果您之前下载了特定版本的库,那么 Maven 将使用本地副本。有一个特殊的词,SNAPSHOT,当添加到版本标识的末尾时,意味着该项目仍在开发中。这意味着即使它在本地仓库中存在,Maven 也会下载这个库。除非您重新配置 Maven,否则 SNAPSHOT 版本每天只会更新一次。

这里是使用 Maven 运行项目后 Maven 在您的本地仓库中存储的内容。您可以看到它是如何存储的,使用完整的 GAV:

图 3.5 – .m2/repository

图 3.5 – .m2/repository

除了 compoundinterest 之外的其他文件夹包含 Maven 程序和您的项目所需的依赖项和插件。

接下来,我们将看到pom.xml文件中的一个名为defaultGoals的部分——这是pom.xml文件build部分的组成部分。这是您提供 Maven 必须执行的任务的地方。除非您将install作为您的目标之一,否则 Maven 不会将您的项目放置在本地仓库中,这就是这个目录结构是如何创建的。pom.xml文件中的groupId元素根据您在标签中放置的点分解为文件夹。虽然pom.xml文件中的artifactIdversion可能在其文本中有点,但它们不会像groupId那样分解为文件夹。

接下来,我们将描述包含运行代码所需所有内容的最终文件。这被称为package,指的是 Java 程序可以存储的各种归档格式:

    <packaging>jar</packaging>

如果您没有<packaging>标签,则 Maven 将默认为jar。这些包是包含文件夹结构和项目所需文件的压缩 ZIP 文件。您可以使用任何.zip实用程序检查这些打包格式。打包的选择如下:

jar – Java 归档

META-INF文件夹中有一个名为MANIFEST.MF的文件。如果 Maven 已配置MANIFEST.MF以包含包含main方法的类的包和文件名,则可以通过双击它或在命令提示符中输入文件名来运行此文件。

war – Web 归档

这个 ZIP 归档文件用于在 Web 服务器上使用,例如.war文件与.jar文件不同,以满足 Web 服务器的要求,如 HTML 和 JavaScript 文件的文件夹。

ear – 企业归档

这个 ZIP 归档文件用于在 Java 企业配置服务器上使用,如 Glassfish 或 WildFly。这些也被称为提供运行复杂 Web 应用程序功能的应用服务器。现代 Java Web 编程建议即使对于复杂系统也使用.war文件。当我们在第十四章“使用 Jakarta 的服务器端编码”中查看 Web 编程时,我将更详细地讨论这些内容。

pom – POM

Maven 支持使用多个 POM 文件。一种方法是,将父 POM 文件包含在项目的 POM 文件中作为一部分。我为我的学生创建了数百个项目。一开始,我发现自己在每个项目的每个 POM 文件中编辑,以更新版本或添加每个项目都会共享的新依赖项和插件。使用父 POM 文件,我可以将所有通用组件放在这个文件中,然后将其包含在每个项目的单个 POM 文件中。如果项目和父 POM 文件都有相同的标签,则项目 POM 会覆盖父 POM。

让我们继续查看pom.xml文件:

    <description>    
        First example of a Maven multi source code project 
    </description>
    <developers>
        <developer>
            <id></id>
            <name></name>
            <email></email>
        </developer>
    </developers>
    <organization>
        <name></name>
    </organization>

这些是三个可选部分,提供额外的信息,有助于管理项目:

  • <描述>

    • 用句子简要描述项目
  • <开发者>

    • 在这里,您可以列出团队成员。我已用它来识别我的学生。
  • <``organization>

    • 你所在公司的名称或客户的名称

archive文件,创建的打包文件,包含所有编译的字节码和所需的库。它还包括pom.xml文件。这使得 Web 和企业服务器能够在 Web 控制台或仪表板上显示这些信息。

接下来是文件的属性部分;考虑以下代码块:

<properties>
    <java.version>17</java.version>
    <project.build.sourceEncoding>
        UTF-8
    </project.build.sourceEncoding>
    <maven.compiler.release>
        ${java.version}
    </maven.compiler.source>
    <exec.mainClass>
        com.kenfogel.compoundinterest.app.CompoundInterest04
    </exec.mainClass>
</properties>

在前面的代码块中,将properties视为可以在 POM 文件的其他地方使用的变量,例如定义编译器将从中来的 Java 版本或创建MANIFEST.MF文件时包含main方法的类的名称。你可以看到java.version变成了${java.version}。你现在可以在 POM 文件的其他地方使用这个值。管理编译的 Maven 插件将使用编译器源和目标源。exec.mainClass表示包含main方法的类。

接下来是依赖项;这些是程序所需的外部库。考虑以下代码块:

    <dependencies>
        <dependency>
            <groupId>org.openjfx</groupId>
            <artifactId>javafx-controls</artifactId>
            <version>18.0.1</version>
            <scope>compile</scope>
        </dependency>
        <dependency>
            <groupId>mysql</groupId>
            <artifactId>mysql-connector-java</artifactId>
            <version>8.0.29</version>
            <scope>runtime</scope>
        </dependency>
        <dependency>
            <groupId>org.junit.jupiter</groupId>
            <artifactId>junit-jupiter-engine</artifactId>
            <version>5.8.2</version>
            <scope>test</scope>
        </dependency>      
    </dependencies>

依赖项是一个必须可用于程序编译和执行的库。如果依赖项在你的本地仓库中找不到,那么 Maven 将下载它。

就像你命名你的项目一样,前三个标签groupIdartifactIdversion命名了你希望使用的库。Maven 使用这些信息来识别它必须在本地仓库或远程仓库中查找什么,以便下载。

这里出现了一个新的标签,称为<scope>。以下是四个最常用的作用域:

  • 编译作用域:这是默认的作用域。这意味着这个库是编译程序所必需的。它也将被添加到包中。

  • 运行时作用域:这个库必须在运行时可用,但不用于编译。Java 数据库连接驱动程序属于这一类别,因为 Java 仅在程序运行时使用它们。

  • 提供作用域:当你在一个框架如 Spring 或一个应用服务器如 WildFly 中运行程序时,项目的大多数依赖库都包含在服务器中。这意味着你不需要将它们添加到存档中。你需要这些文件来编译代码,Maven 将它们下载到你的仓库中,以便编译器可以验证你是否正确使用它们。

  • Maven 项目的test分支而不是java分支。

构建部分

接下来是build部分,其中我们定义了 Maven 要执行的任务以及我们需要完成的事情。在 Maven 中,你可以将你想要执行的任务表达为生命周期、阶段或目标。一个生命周期由多个阶段组成,一个阶段可以由多个目标组成,一个目标是一个特定的任务。

在 Maven 中,只有三个生命周期,而有许多阶段和目标:

    <build>
        <defaultGoal>clean package exec:java</defaultGoal>

这里我们有 POM 文件 build 部分的 defaultGoal 标签。如果你不使用此标签,那么 Maven 将使用 Default 生命周期,它反过来会调用 21 个阶段。在这个例子中,我们明确调用两个阶段和一个目标。

如此标签的名称所暗示的,这是在未定义任何目标或阶段(通过命令行)的情况下将要执行的一组阶段和目标。clean 属于 Clean 生命周期,它反过来由三个阶段组成。当我们列出如 clean 这样的阶段时,Maven 也会执行其前面的每个阶段。在 Clean 生命周期的案例中,如果你显示 clean 阶段,它也会执行 pre-clean 阶段,但不会执行 post-clean 阶段。要执行生命周期的所有操作,你只需使用生命周期的最后一个阶段。

在这个例子中,我们看到两个阶段和一个目标。我们刚刚看到 clean 阶段首先调用其前面的阶段。包阶段之前有 16 个阶段,每个阶段都将被执行。目标是一个单一的任务,不会调用其他任何东西。exec:java 目标用于在所有前面的阶段和目标成功完成后显式执行你的代码。

这里列举了我们将会使用的一些阶段和目标。

阶段

  • clean: 删除目标文件夹。这将强制 Maven 编译所有源文件。如果不使用,则只有日期和时间晚于相应 .class 文件的源代码文件被编译。此目标属于 Clean 生命周期,并且不会调用其他生命周期的任何目标。

  • compile: compile 将遍历源代码树,编译每个源代码文件,并将字节码写入目标文件夹。作为 Default 生命周期的成员,在 Maven 运行 compile 之前,它将首先执行其前面的所有目标。

  • test: 此目标将调用单元测试。由于后续的目标将运行测试,我们不必明确列出它们。但是,如果你只想编译和测试你的代码,那么你可以使用 test 作为最终目标。

  • package: 如果 POM 文件中的 <packaging> 标签是 jar,则此目标会将所有文件组合成一个 jar 包。测试目标在 Default 生命周期中先于包执行。因此,如果存在单元测试,Maven 将首先运行它们。

  • install: 如果所有前面的目标都成功完成,则此目标会将此项目添加到你的本地仓库中。

目标

  • exec:javaexec:exec: 这两个不属于标准生命周期。它们需要一个特殊的插件,并且不会执行任何其他目标。exec:java 将使用 Maven 运行的相同 JVM。exec:exec 将启动或创建一个新的 JVM。如果你需要配置 JVM,这可能会很有用。

你可以通过在命令行上放置阶段名称来覆盖 defaultGoal 标签,如下所示:

mvn package

在这个例子中,由于包属于Default生命周期,所有在其之前的阶段都将首先执行。defaultGoal中的所有阶段和目标都将被忽略。

插件

这是构建的结论部分,我们将定义插件。除了maven-clean-pluginexec-maven-plugin之外,所有这些插件在 Maven 中作为默认插件存在。插件版本在主要修订发生时决定,例如从 Maven 2 到 Maven 3。此列表不会随着点版本更新。

Maven 3,于 2010 年推出,有一个相当旧的默认插件列表。因此,你应该声明你将使用的每个插件,即使有默认的;这就是你在这里看到的内容。

一些插件有标签,允许你配置它们如何执行任务。maven-jar-plugin允许你在<mainClass>标签中显示包含 main 方法的类。当我们检查单元测试时,我们将配置surefire插件来打开或关闭单元测试。当我们查看不同的程序时,我们将增强这个以及其他我们将使用的 POM 文件:

  • 此插件负责删除 Maven 之前运行产生的任何输出:

            <plugin>
                <groupId>org.apache.maven.plugins</groupId>
                <artifactId>maven-clean-plugin</artifactId>
                <version>3.2.0</version>
            </plugin>
    
  • 此插件将项目资源文件夹中的任何文件包含到最终打包中;资源可以是图像或属性文件:

            <plugin>
                <groupId>org.apache.maven.plugins</groupId>
                <artifactId>  
                    maven-resources-plugin
                </artifactId>
                <version>3.2.0</version>
            </plugin>
    
  • 这是调用 Java 编译器的插件:

            <plugin>
                <groupId>org.apache.maven.plugins</groupId>
                <artifactId>
                    maven-compiler-plugin
                </artifactId>
                <version>3.10.1</version>
            </plugin>
    
  • 如果你正在执行单元测试,此插件用于配置测试,例如将测试结果写入文件:

            <plugin>
                <groupId>org.apache.maven.plugins</groupId>
                <artifactId>
                    maven-surefire-plugin
                </artifactId>
                <version>2.22.2</version>
            </plugin>
    
  • 这是负责将你的程序打包成jar文件的插件。它包括配置,使得只需双击即可使jar文件可执行:

            <plugin>
                <groupId>org.apache.maven.plugins</groupId>
                <artifactId>maven-jar-plugin</artifactId>
                <version>3.2.2</version>    
                <configuration>
                    <archive>
                        <manifest>
                            <mainClass>
                                ${exec.mainClass}
                            </mainClass>
                        </manifest>
                    </archive>
                 </configuration>
            </plugin>
    
  • 此插件允许 Maven 执行你的程序:

            <plugin>
                <groupId>org.codehaus.mojo</groupId>
                <artifactId>exec-maven-plugin</artifactId>
                <version>3.0.0</version>
            </plugin>
        </plugins>
    </build>
    
  • 我们关闭根标签:

    </project>
    

我们的 POM 文件已经准备好了,我们现在可以使用 Maven 了。

运行 Maven

一旦你设置了 Maven 文件结构,编写了pom.xml文件,编写了源代码,并添加了任何资源,如图片,那么你所需要做的就是使用 Maven,这相当直接。

让我们先在命令行上运行 Maven。

命令行 Maven

这里是使用命令行上 Maven 的步骤:

  1. 在包含项目文件夹的文件夹中打开终端或控制台,例如src

  2. 如果你不是管理员或超级用户,需要配置你的设置。

  3. 在提示符下输入mvn命令。如果你的代码没有错误,它应该执行你请求的所有目标。如果有错误,那么你需要检查 Maven 的输出,纠正错误,然后再次使用mvn

这里是我的成功构建的输出:

C:\PacktJavaCode\CompoundInterest04>mvn
[INFO] Scanning for projects...
[INFO]
[INFO] -------------------< com.kenfogel:compoundinterest >--------------------
[INFO] Building compoundinterest 0.1-SNAPSHOT
[INFO] --------------------------------[ jar ]---------------------------------

首先是清理,意味着删除我们上次构建此程序时生成的任何代码:

[INFO]
[INFO] --- maven-clean-plugin:3.2.0:clean (default-clean) @ compoundinterest ---
[INFO] Deleting C:\PacktJavaCode\CompoundInterest04\target

如果有资源,我们就能看到它们被添加到程序中。我们没有资源,所以此插件将不会做任何事情:

[INFO]
[INFO] --- maven-resources-plugin:3.2.0:resources (default-resources) @ compoundinterest ---
[INFO] Using 'UTF-8' encoding to copy filtered resources.
[INFO] Using 'UTF-8' encoding to copy filtered properties files.
[INFO] skip non existing resourceDirectory C:\PacktJavaCode\CompoundInterest04\src\main\resources

现在,编译器被调用了。由于我们首先清理了项目,插件检测到所有源代码文件都必须被编译。如果我们没有使用清理目标,它只会编译日期比字节码文件更近的源代码文件:

[INFO]
[INFO] --- maven-compiler-plugin:3.10.1:compile (default-compile) @ compoundinterest ---
[INFO] Changes detected - recompiling the module!
[INFO] Compiling 2 source files to C:\PacktJavaCode\CompoundInterest04\target\classes

你可以有仅用于单元测试的资源。如果有,它们将被添加到项目的测试构建中:

[INFO]
[INFO] --- maven-resources-plugin:3.2.0:testResources (default-testResources) @ compoundinterest ---
[INFO] Using 'UTF-8' encoding to copy filtered resources.
[INFO] Using 'UTF-8' encoding to copy filtered properties files.
[INFO] skip non existing resourceDirectory C:\PacktJavaCode\CompoundInterest04\src\test\resources

编译器现在将第二次被调用以编译你编写的任何单元测试类:

[INFO]
[INFO] --- maven-compiler-plugin:3.10.1:testCompile (default-testCompile) @ compoundinterest ---
[INFO] No sources to compile

此插件负责运行刚刚编译的单元测试:

[INFO]
[INFO] --- maven-surefire-plugin:2.22.2:test (default-test) @ compoundinterest ---
[INFO] No tests to run.

由于打包被定义为.jar,此插件现在将创建.jar文件:

[INFO]
[INFO] --- maven-jar-plugin:3.2.2:jar (default-jar) @ compoundinterest ---
[INFO] Building jar: C:\PacktJavaCode\CompoundInterest04\target\compoundinterest-0.1-SNAPSHOT.jar

最后一个插件将执行你的代码:

[INFO]
[INFO] --- exec-maven-plugin:3.0.0:java (default-cli) @ compoundinterest ---

下面是程序输出:

If you deposit $100.00 in a savings account that pays 5% annual interest compounded monthly,
you will have after 5 years $128.34

一切顺利,你将收到以下报告,说明整个过程花费了多长时间:

[INFO] ------------------------------------------------------------------------
[INFO] BUILD SUCCESS
[INFO] ------------------------------------------------------------------------
[INFO] Total time:  2.982 s
[INFO] Finished at: 2022-07-10T13:27:19-04:00
[INFO] ------------------------------------------------------------------------

Java 要求每个语句或表达式都必须以分号结束。我故意从一个文件中移除了一个分号,以便我们可以看到编码错误是如何表达的。在声明构建失败之后,会出现以下内容:

[ERROR] Failed to execute goal org.apache.maven.plugins:maven-compiler-plugin:3.10.1:compile (default-compile) on project compoundinterest: Compilation failure
[ERROR] /C:/PacktJavaCode/CompoundInterest04/src/main/java/com/kenfogel/compoundinterest/business/CompoundInterestCalculator04.java:[31,53] ';' expected

你也可以通过以mvn -X运行 Maven 来请求更多关于失败的信息。如果错误是由于pom.xml文件的问题,这将提供更多信息。

在 IDE 中运行 Maven

Maven 通常包含在 IDE 发行版中。如果你不打算从命令行工作,除非你的 IDE 要求,否则你不需要下载和安装 Maven。

所有 IDE 都有一个run命令和/或一个run maven命令。如果两者都存在,请使用run maven。如果没有run maven命令,请预期run命令会识别这是一个 Maven 项目,并使用 Maven 而不是其内部构建系统来处理你的程序。

在运行项目之前,IDE 会突出显示你的源代码和pom.xml文件中的错误。当 IDE 识别到错误时,它将不会编译你的代码,直到问题得到解决。

摘要

在本章中,我们学习了如何使用 Maven,这是最广泛使用的 Java 构建工具。Maven 的核心是pom.xml文件;我们已经看到了这个文件最重要的部分以及它们的作用。从现在开始,所有示例都将基于 Maven。

到现在为止,你知道如何组织 Maven 项目的目录,基本pom.xml文件的组件,以及如何调用 Maven 来构建和执行你的程序。

接下来,我们将检查 Java 程序的对象结构,什么是对象,以及循环和决策的编码语法。

进一步阅读

第二部分:语言基础

你是一位经验丰富的程序员,需要尽可能快地学习 Java 语言的语法。本书的这一部分涵盖了您构建和编写 Java 解决方案所需了解的详细信息。

本部分包含以下章节:

  • 第四章, 语言基础 – 数据类型和变量

  • 第五章, 语言基础 – 类

  • 第六章, 方法、接口、记录及其关系

  • 第七章, Java 语法和异常

  • 第八章, 数组、集合、泛型、函数和流

  • 第九章, 在 Java 中使用线程

  • 第十章, 在 Java 中实现软件设计原则和模式

  • 第十一章, 文档和日志

  • 第十二章, BigDecimal 和单元测试

第四章:语言基础 – 数据类型和变量

现在我们已经对基本的 Java 工具感到舒适(希望如此),我们可以开始研究这门语言本身了。既然你已经是一名开发者,在这个章节中就没有必要再介绍低级概念,比如什么是变量。因此,这个章节将利用你已有的知识,并介绍 Java 中可用的数据类型以及我们可以对这些类型执行的操作。

在本章中,你将学习以下内容:

  • 类型安全

  • 八种原始数据类型

  • 文字值

  • String数据类型

  • 标识符命名

  • 常量

  • 数据操作

  • 类型转换

  • 溢出和下溢

  • 数学类

技术要求

这里是运行本章示例所需的工具:

  • Java 17

  • 文本编辑器

  • Maven 3.8.6 或更高版本

你可以在 GitHub 仓库中找到本章的代码,网址为github.com/PacktPublishing/Transitioning-to-Java/tree/chapter04

注意

Ubuntu 和其他 Linux 发行版可能已经安装了 Maven 的一个版本。如果它不是 3.8.6 或更高版本,你必须用最新版本替换它。

原始数据类型

原始数据类型创建值变量。这意味着一旦你在程序中声明了变量,你就可以在代码中使用它们。然而,在由引用变量表示之前,类必须被实例化为对象。但值不需要实例化。

CompoundInterest程序中,我们需要在可以使用它之前实例化CompoundInterestCalculation类,如下所示:

var banker = new CompoundInterestCalculator04();

另一方面,当我们需要变量来保存principalannualInterestRatecompoundPerTimeUnittime时,我们只需声明它们,如下面的代码行所示——我们直接将值赋给变量。我们没有添加new运算符,它负责将类转换为对象。原始数据类型是现成的:

double principal = 100.0;

Java 中有八个原始类型。在我们查看它们之前,让我们快速了解一下类型安全是什么意思。

类型安全

根据你从 Java 路径中来的语言,类型安全的概念可能或可能不是你所熟悉的。类型安全的一种形式意味着每个变量在声明时都必须显示其类型,并且这种类型不能改变。你不能将整数变量赋值给字符串。如果你这样做,你会得到一个错误消息作为异常。这是静态类型。

静态类型的一个替代方案是动态类型。在这里,没有必要声明变量的类型。Java 会从你分配的内容中推断类型。人们经常错误地认为动态类型不安全。这并不一定正确。

你如何声明变量并不是类型安全的核心。相反,它是语言在运行时如何处理变量类型不匹配的情况。

这里有一个演示 Python 即使使用动态类型也是类型安全的 Python 脚本:

def print_hi(name):
    name = name + 2
    print(f'Hi, {name}') 
if __name__ == '__main__':
    x="bob"
    print_hi(x)

在这个例子中,print_hi 函数期望接收一个名为 name 的变量。该函数的第一行代码使用 name 变量执行一个数学运算。

在调用 print_hi 的代码中,我们声明了一个变量 x 为字符串。我们知道这一点,因为我们将其赋值为字符串。在这段小代码片段中,很明显这将生成一个错误。确实如此,以下是错误信息:

C:\devapp\PycharmProjects\PythonTest\venv\Scripts\python.exe C:/devapp/PycharmProjects/PythonTest/main.py
Traceback (most recent call last):
  File "C:\devapp\PycharmProjects\PythonTest\main.py", line 8, in <module>
    print_hi(x)
  File "C:\devapp\PycharmProjects\PythonTest\main.py", line 2, in print_hi
    name = name + 2
TypeError: can only concatenate str (not "int") to str
Process finished with exit code 1

Python 只在运行时检测这个问题,但这是一个会导致程序终止的错误。这意味着尽管动态类型语言不是类型安全的论点,但我们刚刚看到这并不是真的。Python 事实上是类型安全的。

另一方面,Java 是一种静态类型语言。以下是 Java 中的相同代码:

public class TypeSafetyTest {
    private void print_hi(String x) {
        System.out.printf(x);
    }

    public void perform() {
        int x = 4;
        print_hi(x);
    }

    public static void main(String[] args) {
        var typeTest = new TypeSafetyTest();
        typeTest.perform();
    }
}

注意到 print_hi 明确期望一个字符串,但在 perform 方法中,我们传递了一个整数。当我们使用 Maven 运行此代码时,我们将得到以下错误信息:

com/kenfogel/typesafetytest/TypeSafetyTest.java:[12,18] incompatible types: int cannot be converted to java.lang.String

在 Python 中,你通过变量的使用位置和方式来确定变量类型。Python 的 print_hi 方法没有指示 name 的类型。只有当我们看到函数中的数学表达式时,我们才会意识到 name 必须是整数才能工作。由于静态类型 Java 要求在每次声明时都必须包含类型,这使得发现类型错误更加容易。

当我们与 Python – 一种优秀的语言 – 进行比较时,请注意每种语言的编译器之间存在着显著差异。Java 编译器可以增量编译程序。IDE 中的编辑器可以通过逐行编译代码来检测你在输入时的错误。没有增量编译器的语言,如 Python,只能在 IDE 中编译或运行代码时报告错误。

哪种方法更好?这由你决定。

然而,静态类型会导致程序更加冗长。这意味着与 Python 相比,你必须在 Java 程序中输入更多的代码。另一方面,静态类型使得追踪类型错误更加容易,并增强了代码的可读性。

关于 Python 的最后一个要点 – 在 3.0 版本中,语言开发者引入了类型注解。这些注解在 Python 代码中看起来就像你在静态类型一个变量一样。但这并不是真的,因为编译器会忽略这些注解。它们的存在是为了支持类型检查器,例如 PyCharm IDE 可以做到的。

在我们查看声明变量时使用的数据类型之前,让我们花一点时间看看文字值。

文字值

Java 将 42 视为一个整数。如果将文字值赋给整数时超过了整数的范围,你将得到一个 integer number to large 编译器错误。如果你将文字值赋给 long 整数,必须在数字后添加字母 L,例如 14960000000000L

当我们书写大数字时,我们经常每三位数字使用一个分隔符来提高可读性。如果你想使用分隔符使源代码更容易阅读,你只能使用下划线。你不能使用逗号或其他任何字符作为分隔符。值14960000000000L可以输入为14_960_000_000_000L

当处理字面浮点数时,默认的原始类型是 double。如果你正在分配一个没有小数位的字面 double 值,那么添加一个,而不是像这样添加:

double value = 100;

按以下方式输入:

double value = 100.0;

或者,你可以使用后缀D表示 double 和F表示 float。你可以像这里一样用大写或小写写所有字面后缀:

double value = 100D;

现在,让我们继续讨论原始数据类型。

整数

整数家族有四个成员 – byteshortintlong。它们之间的区别是它们用来存储值的字节数。Java,像大多数语言一样,使用二进制补码来编码整数。这意味着任何整数类型的值范围从负值到正值。像 Python 一样,Java 没有无符号整数,而 C、C++和 C#有。

在下面的表中,你可以找到大小(以字节为单位)、允许的范围以及如何声明、分配或声明和初始化整数家族的所有成员:

表 4.1 – 整数规格及其使用方法

](https://github.com/OpenDocCN/freelearn-java-zh/raw/master/docs/tstn-java/img/Table_4.1_B19088.jpg)

表 4.1 – 整数规格及其使用方法

大多数计算机上的整数数据类型与 CPU 寄存器的大小有关。JVM 是一个 32 位虚拟机,这意味着它的寄存器是 32 位或 4 字节宽。虽然 JVM 程序被实现为一个 64 位应用程序,但它仍然是 32 位计算机的实现。

浮点数

与大多数语言一样,Java 使用 IEEE 标准二进制浮点数的一个子集来在内存中表示浮点值。在进一步阅读部分,你可以找到深入探讨这种物理格式的网站链接。从我们的角度来看,我们主要感兴趣的是精度。

精度定义为精确表示一个值。我们称整数是精确的,因为每个十进制整数都可以转换成二进制数。我们称这种转换为一个无损转换

并非所有十进制浮点值都能映射到固定长度的二进制值。最好的例子是十进制中的 0.1。这是 10 除以 1。如果我们用二进制的 1(1)除以二进制的 10(1010),结果将是一个无限重复的序列 0.00110011001100110011……这意味着浮点数没有整数那样的精度。IEEE 754 标准处理这个问题,但你必须始终知道浮点值是近似值。我们称之为floatdouble。从十进制到二进制浮点数以及返回的精度称为结果的精度。如果一个数字超过了精度,它被认为是实际结果的近似值。

在以下表中,你可以找到大小(以字节为单位)、允许的范围以及如何声明、分配或声明并初始化浮点数家族的所有成员。这些信息对于决定是否使用双精度浮点数或浮点数至关重要:

Table_4.2 – Specs for floating point and how we can use it

Table_4.2_B19088.jpg

表 4.2 – 浮点数规格及其使用方法

我们通常将精度解释为小数点右边有效数字的数量。浮点数使用 23 位,双精度浮点数使用 53 位。因此,从数字的位数来定义精度是一种粗略的方法;根据 IEEE 754 标准,它是指尾数的长度。简单来说,由于双精度浮点数的尾数更长,所以它具有更大的值域和更高的精度,比浮点数要高。

你现在可能认为你应该只使用双精度浮点数而不是使用浮点数。毕竟,我们都希望我们的结果尽可能准确。但双精度浮点数在字节大小上是浮点数的两倍,64 位而不是 32 位,这会有性能损失。在决定是否使用双精度浮点数或浮点数时,考虑值域和所需的精度。例如,如果值域较小,小数点后的位数永远不会超过大约六位,那么浮点数就足够了。你可能会进行的数学运算也会影响你的选择。加法和减法不是问题,但乘法和除法可能会有影响。

Java 编译器可以识别当你将浮点值分配给整型变量时。如果你这样做,你会得到从doubleint的可能丢失转换错误。在本章的后面部分,我们将探讨类型转换,以将一个数值数据类型转换为另一个。

现在,我们已经完成了数值类型——整数和浮点数。现在,让我们看看非数值类型。

布尔

false和 1 表示true。在 Java 中,你可以分配给布尔值的集合是truefalse关键字。所有逻辑运算的结果,例如“x 是否大于 y”?,用x > y表示,总是返回一个布尔值。

在 Python 中,你可以将整数强制转换为布尔值或将布尔值强制转换为整数。C 语言没有布尔类型,因此该语言使用零表示 false 和非零表示 true。C++ 有布尔类型,但它只是整数的一个子集,其中零和一分别由 truefalse 关键字表示。C++ 将整数视为布尔值,与 C 的处理方式相同。

在 Java 中,布尔是一个独立的数据类型。你不能用整数代替 truefalse。这意味着你不能使用可能为零或非零的计算结果,其中你需要布尔类型。

在以下表中,你可以找到允许的值集合以及如何声明、赋值、声明和初始化布尔值。技术上,你只需要一个位来表示 truefalse。然而,没有机器语言或字节码指令可以读取单个位。Java 语言架构师将布尔类型的字节大小留给了 Java 的具体实现:

![表 4.3 – 布尔类型的规格及其使用方法

![img/Table_4.3_B19088.jpg]

表 4.3 – 布尔类型的规格及其使用方法

CPU 以字节为单位从内存中检索数据,通常是每次 4 个字节,因为这通常是 CPU 的字大小。它不能直接读取 RAM 中的单个位。一旦检索并存储在 CPU 寄存器中,CPU 就可以确定字节中任何位的状态。这意味着布尔值不能小于一个字节。Java 没有像整数和浮点数那样定义字节的大小。布尔值使用的字节数取决于虚拟机的实现。这意味着一个组织对 Java 的实现可能使用的字节数与另一个组织不同。

布尔值是许多组织决策和迭代的核心。现在,让我们继续了解用于表示我们书面语言字符的数据类型。

char

char 数据类型包含 2 字节 Unicode 字符的数值。Unicode UTF-8 是一种可变长度的字符编码,每个字符占用 2 到 4 个字节。目前,Java 只支持 2 字节编码。前 128 个字符与 ASCII 编码中的前 128 个字符相同。

在 C 和 C++ 中,char 是整数的一个子集,你可以将其用作整数。Python 没有字符类型,但使用长度为 1 的字符串来表示单个字符。在 Java 中,char 是一个独特的数据类型;你不能像 C 和 C++ 那样将其用作整数。你可以将 char 强制转换为整数或将整数强制转换为 char

注意,单个字符周围的单引号表示一个 char

在以下表中,你可以找到允许的值集合以及如何声明、赋值或声明并初始化 char

![表 4.4 – char 的规格及其使用方法

![img/Table_4.4_B19088.jpg]

表 4.4 – char 的规格及其使用方法

还有另一个表格需要查看,那就是在声明时未初始化的变量的默认值。变量可以作为类中的字段或作为方法中的局部变量声明。以下是字段的默认值:

类型 默认值
boolean false
byte 0
short 0
int 0
long 0
float 0.0f
double 0.0d
char \u0000(Unicode 等效于 null)
对象引用 null

表 4.5 – 字段默认值

在方法中声明的变量没有默认值。任何尝试读取未分配初始值的局部变量的代码都会导致编译时错误。

我们现在已经介绍了八个原始类型,还有一个特殊类型可以使用,类似于原始类型,但它不是原始类型。让我们来认识一下String

特殊情况 - 字符串

字符串,小写s,是一系列字符,通常代表我们可以书写或说出的单词。String,大写S,是一个包含零个或多个字符并可以对其执行许多操作的类。作为一个类,它通常必须实例化为一个对象。由于开发人员经常使用String对象,Java 可以在使用赋值运算符(=)与String变量赋值时隐式地实例化它。当我们引用这种数据类型时,我们总是将第一个字母大写。这样,我们知道我们正在引用String类。我们将在下一章更深入地介绍类和对象。

让我们考察String及其使用方法。我们首先从规范表开始:

Table 4.6 – Specs for String and how we can use it

Table 4.6 – Specs for String and how we can use it

表 4.6 – 字符串规范及其使用方法

在此表中,String。第一个,称为String对象。引用类似于其他语言中的指针,但你不能像在 C 或 C++中那样操作它。此对象在内存中的长度包括对象开销以及你实际存储的文本中的字符。

在 Java 中,通过使用new运算符,类在内存中成为对象,如前表所示。开发人员经常使用String对象,Java 通过在赋值时隐式实例化它来简化其使用。你可以像这样使用new,但很少这样写。相反,String似乎像原始值一样工作,以方便程序员。

在定义了八个原始类型和一个特殊案例之后,我们现在可以继续探讨如何在我们的代码中使用它们。

标识符命名

任何语言中的标识符只是我们分配给变量、类或方法的名称。我们首先将关注命名变量,在第五章“语言基础 - 类”,我们将探讨命名类和方法。

Java 关于标识符命名的规则非常少,但对于那些有的规则,编译器会强制执行。这些规则如下:

  • 标识符的第一个字符可以是以下之一:

    • 美元符号 ($)

    • 下划线 (_)

    • Alpha 字符 (AZ, az)

  • 后续字符可以是之前提到的任何字符和数字。

一旦你遵守了规则,命名选择就取决于你了。这是因为 Java 有命名约定。约定不是规则,编译器不会验证它们。相反,约定是编程社区为特定语言推荐的技术。在团队工作中,你的同事期望你遵循这些约定。以下是命名变量的约定:

  • 变量的名称应该是名词;变量是事物而不是动作。

  • 第一个字符应该是小写字母。对于类标识符的约定要求其第一个字符为大写字母。

  • 当使用由多个单词组成的名称时,使用驼峰式命名。标识符中的每个单词应该小写,除了第一个单词的首字母。标识符中第一个单词之后的每个后续单词必须以大写字母开头。如果你更喜欢使用下划线在多单词标识符中表示空格而不是驼峰式命名,这是可以接受的。对于这种用法,所有字符都应该小写。

  • 不要使用缩写;使用完整的单词。

  • 避免使用单字符标识符。在有限的情况下,单字符是可接受的,例如用于循环索引变量。否则,使用有意义的名称。

  • 此表描述了变量的命名约定:

约定 可接受 不可接受
名词 double salary; double receive;
第一个字符小写 int cars; int Cars;
驼峰式命名 int platesOfPasta; int platesofpasta;
下划线分隔符 int plates_of_pasta; int plates_Of_Pasta;
缩写 从不接受 int lol; 代表清漆层

表 4.7 – 命名约定

  • 你不应该使用美元符号;编译器使用它来创建标识符。

  • 你不应该像其他语言(如 C++)那样将下划线用作第一个字符,因为在 Java 中,它和美元符号作为第一个字符的含义相同。

  • 一旦超过第一个字符,你可以使用字母表中的任何字母、任何数字、下划线或美元符号。

现在让我们看看以下表格中标识符长度与其他语言的比较:

语言 最大有效字符数
Python 79
标准 C 31
标准 C++ 1,024
微软 C++ 2,048
GNU C++ 无限制
Java 无限制

表 4.8 – 标识符最大长度

虽然 Java 和 GNU C++ 对标识符名称中的字符数没有限制,但你应该合理地使用字符数。

为标识符想出一个有意义的名字是使你的代码可读的重要任务,所以请多加思考。现在,让我们看看一旦赋值后就不能更改的数据。

常量

常量可以是使用final关键字声明的任何数据类型。它必须在声明时赋值,例如,final double TAX_RATE = 0.05;

如果你在一个类的字段中声明为final,那么你还可以在类构造函数中为其赋值。然而,一旦常量被赋值,它的值就不能更改。

常量的命名规则与标识符相同。不同的是约定。常量是使用大写字母书写的名词。你可以在标识符中用下划线分隔单词,如前例中的TAX_RATE所示。

运算符

Java 支持几乎在每种语言中都找到的常见运算符集合,如下表所示:

操作 运算符 赋值
加法 x = x + y x += y
减法 x = x – y x -= y
乘法 x = x * y x *= y
除法 x = x / y x /= y
取模(余数) x = x % y x %= y
增量 ++x 或 x++ N/A
减量 --x 或 x-- N/A

表 4.9 – 基本数学运算符

Java 遵循标准的优先级规则,除了增量或减量运算符。如果运算符在变量的左侧,那么 Java 会在其他任何操作之前执行该操作。技术上,这给了它最高的优先级。放在右侧,它具有最低的优先级,Java 会在所有其他操作完成后执行它。我们将在下一章讨论逻辑运算时回顾逻辑运算符。

字符串运算符

String没有数值,你不能在计算中使用String对象。正如你所期望的,int numberOfDogs = 23;并不意味着与String numberOfDogs = "``23 ";相同。

你不能在算术表达式中使用String变量。如果String中的字符与数字允许的字符匹配,那么你必须先将String转换为数字变量,然后再在算术表达式中使用它。

然而,加号(+)运算符与String一起使用是被允许的。当与String一起使用时,它意味着将多个String值连接或合并为一个,如下所示:

String animal = "moose";
String favoriteAnimal 
    = "My favorite animal is a " + animal;

你还可以使用连接将String与任何八个原始数据类型结合。这将自动将原始数据类型转换为String,如下所示:

int numberOfMoose = 36;
String message = "There are " + numberOfMoose 
    + " in the park. "

String消息将包含以下内容:

There are 36 moose in the park.

你不能直接将数字类型赋值给String。你必须将它连接到String或使用String.valueOf方法;这是一个更简单的方法。将原始数据类型连接到任何字符串,例如这里显示的空字符串,将有效:

String piecesOfSilver = "" + 23;

或者,你也可以使用以下方法:

String piecesOfSilver = String.valueOf(23);

在你看到字面值的地方,你也可以使用原始数据类型,如下所示:

int silverCoins = 23;
String piecesOfSilver = String.valueOf(silverCoins);

你可以用一个操作符与 String 一起使用,那就是用于连接而不是数学函数。

类型转换

类型转换提供了一种将一种数据类型转换为另一种数据类型的能力。当使用原始数据类型编码时,有两种类型的类型转换 – 隐式和显式。首先,看看这个图表,它按值范围顺序显示了原始数据类型:

最大值 最小值
double float long int short byte

表 4.10 – 类型之间的相对字节数

这意味着当将图表中的一种类型的原始数据类型赋值给图表中更高类型的原始数据类型时,Java 将执行隐式转换,如下所示:

int apples = 23;
double fruit = apples;

这是一个无损转换。你可以尝试反向赋值,如下所示:

double apples = 34.6;
int fruit = apples;

你将得到以下错误:

incompatible types: possible lossy conversion from double to int

如果你需要将一个大于目标变量数据类型的值转换,你必须进行显式转换。例如,当从浮点数转换为整数时,Java 将截断小数部分。没有四舍五入;它只是消失,如下所示:

double apples = 34.6;
int fruit = (int)apples;

使用类型转换时没有错误,但fruit中的值将是34

如果右侧的值超出了你要转换到的类型的范围,当它是一个整型时,就像下一节中讨论的溢出一样,它将回绕,如下所示:

double apples = 67000.6;
short fruit = (short)fruit;

fruit中的值将是1464

Java 类型转换的语法是将要转换的类型放在括号内。Python、C 和 C++通过将值放在括号内进行转换,而 C#遵循与 Java 相同的模式。

表 4.10中,你没有看到char类型。在语言中,它的唯一目的是表示 Java 将渲染为屏幕上的字符的 UTF-8 代码。你可以将一个字符赋给char变量,或者你可以赋一个整数。如果你赋的整数超出了允许的范围,那么你需要进行类型转换,并且会发生溢出回绕。

下面是一些声明char变量的例子:

char letterA1 = 'A';
char letterA2 = 66;
char letterA3 = (char)65601;

这三个都会变成字母A

我们已经看到,提升(promotion)是隐式发生的,从图表中的一个数据类型移动到另一个范围更高的数据类型。要向相反方向移动,你必须进行类型转换。

溢出和下溢

当处理浮点类型时,可能会发生溢出和下溢。只有整数和char类型可能会发生溢出。以下是 Java 在这些情况下的行为。

整数溢出

当一个值超出允许值的范围时,会发生溢出。对于浮点值,溢出会导致特殊值无穷大,结果可以是正无穷或负无穷。

整数溢出会导致回绕。例如,在下面的代码片段中,我们正在将一个值赋给一个短整型数据类型。这个值比short的可允许的上限大 1:

short testValue = (short)32768;
System.out.printf("testValue = %d", testValue);

下面的代码片段是这段代码的输出:

testValue = -32768

浮点数溢出

与整型不同,浮点型在溢出时不会回绕。相反,Java 赋予特殊的值 Infinity

以双精度浮点数为例,我们可以使用下一节中讨论的 Double 类,作为包含双精度浮点数允许的最大值的静态常量。当我们给双精度浮点数赋值超过最大允许值时:

double testValue = Double.MAX_VALUE + Double.MAX_VALUE;
System.out.printf("testValue = %f", testValue);

以下将是输出结果:

testValue = Infinity

浮点数的性质是这样的,即使超过最大值的一点点增加也不会导致溢出。正如前一个例子所示,超过最大值的显著增加将生成 Infinity。以下是一个例子:

double testValue = Double.MAX_VALUE + 1.0;
double testValue = Double.MAX_VALUE ;

这两个表达式返回相同的答案:

testValue = 179769313486231570000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000.000000

浮点数下溢

下溢发生在浮点值无法表示极小分数时。就像溢出一样,这种情况不会在值低于浮点数最小值后立即发生。一个有意义的变化,将值降低到最小值以下将触发下溢。在这个例子中,我们首先将允许的最小值赋给一个双精度浮点数。当我们除以 2 时,它变得更小,但现在小于最小值:

double testValue = Double.MIN_VALUE;
System.out.printf("testValue = %2.16e", testValue);

运行后的结果是:

testValue = 4.9000000000000000e-324

这是双精度的最小值。比如说我们尝试通过除以 2 来赋予一个更小的值,就像我们在这里做的那样:

double testValue = Double.MIN_VALUE / 2;

然后,结果将是以下内容:

testValue = 0.0000000000000000e+00

这是代码在浮点或双精度浮点数下溢时返回的值。

你应该始终警惕溢出,在浮点数的情况下,还要警惕下溢。现在,让我们看看一组提供基本数据类型类支持的类。

包装类

Java,像大多数语言一样,有一个数组数据类型——你可以有一个整数数组、布尔数组或基本类型数组。你可以有一个对象数组,例如 String。数组中的每个元素都必须是相同的类型。Java 中许多面向对象的功能需要使用对象而不是基本类型。例如,Java 有一个名为集合的数据结构库,它提供了比基本数组更强大的功能。这些集合只能存储或收集对象。你不能有一个 intdouble 或其他基本类型的集合。

String 类,你不需要使用 new 来创建包装对象。包装器有从字符串转换为基本类型的方法。它们还包含有关基本类型的信息。当我使用 Double.MIN_VALUEDouble.MAX_VALUE 时,我们已经瞥见了这一点。

这里是所有基本类型及其匹配的包装类的一个表。除了静态变量之外,这些包装器还有将值转换为和从 String 转换的静态方法:

基本数据类型 包装类
byte 字节型
short 短整型
int 整型
long 长整型
float 单精度浮点型
double 双精度浮点型
boolean 布尔型
char 字符

表 4.11 – 基本类型和包装器

这些类,如 String,不需要显式实例化。这意味着你可以写如下:

Integer number = Integer.valueOf(12);

但你也可以直接分配整数值,如下所示:

Integer number = 12;

Java 将此称为自动装箱。第二个特性,称为自动拆箱,允许将包装类读取为原始类型。

在这里,我们看到一个对象number被分配给一个原始类型:

int value = number;

这现在允许我们使用对象作为原始类型。每个包装类都包含几个有用的方法,我们将随着对语言的深入研究来探索这些方法。

数学库

在本章前面,我们检查了可用于处理原始数据类型的运算符。有许多你可能希望执行的操作没有对应的符号,例如对一个值进行幂运算。有些语言使用 caret (^) 或双星号 (**) 来表示幂运算。在 Python 中,你会写如下:

value = 5.0**2.0;

结果将是25。Java 没有这个操作的符号。相反,我们必须使用属于数学类的方法,如下所示:

double value = Math.pow(5.0, 2.0);

我们已经在计算复利的程序中看到了这一点;再看看:

var result = principal * Math.pow(
              1 + annualInterestRate / compoundPerTimeUnit, 
           time * compoundPerTimeUnit);

数学库提供了广泛的数学运算。请参阅进一步阅读部分中的链接,了解所有可用的选择。

摘要

你将要编写的每个程序的核心是程序操作的数据。在本章中,我们学习了关于八个原始类型。有byte,如果你正在编写与其它设备交互的软件特别有用。shortintlong在需要描述没有分数时很有用。然而,当有分数时,你可以使用浮点类型——floatdoublechar类型是字符串的构建块。如果你想跟踪什么是真或假,你应该使用boolean类型。

在你继续学习 Java 的过程中,始终牢记可用的数据类型。同样重要的是要了解如果值超出范围会发生什么。

确定了类型之后,我们转向识别具有有意义的名称的变量。我们讨论了如何将这些数据分配给这些变量,以及如何使用它们是本章的一个重要部分。

我们简要地离开了原始类型,来看看与原始类型紧密相关的类。有String——包含我们可以阅读的文本的字符。包装类提供了关于其匹配原始类型的运行时信息,并且可以与原始类型自由互换,当你需要一个对象而不是原始类型时。

接下来,我们将探讨关注访问控制、包以及如何构建类的类。

进一步阅读

第五章:语言基础 – 类

面向对象OO)程序基于称为类的结构的设计,这些结构用作对象的蓝图。对象是类的实现。这意味着在面向对象编程OOP)中编码的第一步是创建类。本章将探讨 Java 中如何实现 OOP 的特性。我们首先看看如何在类中定义变量,然后是控制类成员及其本身的访问方式。从这里开始,我们将查看 Java 为我们提供的用于创建或处理类和对象的类结构。

在本章中,你将学习以下内容:

  • 类字段

  • 理解访问控制

  • 理解类

到本章结束时,你将能够定义类,将它们实例化为对象,并与其他类交互。让我们从查看访问控制开始。在我们开始之前,让我们看看你可以在类中声明的两种变量类别。

技术要求

这里是运行本章示例所需的工具:

  • 已安装 Java 17

  • 文本编辑器

  • 已安装 Maven 3.8.6 或更高版本

你可以在 GitHub 仓库github.com/PacktPublishing/Transitioning-to-Java/tree/chapter05中找到本章的代码。

类字段

在类中声明而不是在方法调用中声明的变量被称为字段。它们分为两类:

  • 实例变量

  • 类变量

在类中定义double,如果我们创建了 100 个对象实例,我们就会有 100 个double

在 Java 中,你可以在类中有一个变量,它被从该类创建的所有对象共享。换句话说,每个对象都有一个唯一的实例变量集,但所有对象都共享类变量。这是通过将变量指定为静态来实现的。静态变量只有一个内存分配。在我们的 100 个对象实例中,如果你将double声明为静态,就只有一个double

静态或类变量的另一个特点是,假设它具有公共访问控制,你可以不实例化对象即可访问它。例如,考虑以下代码块:

 public class TestBed {
    public static String bob;

在这个片段中,我们可以通过简单地写出TestBed.bob来访问class变量bob。我们不需要实例化对象。如果我们实例化了它,我们也可以使用引用,尽管这种情况很少。

类变量被所有对象共享的事实使它们成为同一类创建的对象之间通信的理想工具。如果一个对象改变了类变量的值,那么即使是同一类的另一个对象也可以看到更新后的值。

在我们进入下一节关于访问控制的讨论之前,让我们澄清一个术语。我们将所有在类中声明的变量称为字段。这包括类和实例变量。

理解访问控制

面向对象编程(OOP)的一个显著且宝贵的特性是访问控制。如果你已经使用过面向对象的编程语言,那么你可能已经熟悉这个概念;如果不是,让我来解释一下访问控制是什么意思。

Java 中的访问控制涉及类、字段和方法对其他类的可见性。你必须有足够的访问权限来创建对象并访问类中的字段和方法。

在其他语言中,访问控制可能意味着一种安全机制,可以确保对方法访问的请求(例如)来自经过身份验证的用户。Java 并不是这样做的;在 Java 中,它关乎对象如何相互交互。

让我们来看看可见性的选项;第一个将是 Java 包。

访问控制难题的第一部分是 Java 包及其相应的import语句。在第三章《Maven 构建工具》中,我们学习了 Java 包以及它们是如何简单地包含 Java 代码的文件夹。除非你通过包含import语句来提供访问权限,否则一个包中的代码不能访问另一个包中的代码。导入类的类可以访问它导入的代码。如果没有import类,不同包中的statement类不能相互交互。

请记住,这种交互是单向的。例如,类 A 对类 B 有一个import语句。类 A 可以调用或向类 B 中的代码发送消息,但不能反过来。你可以在类 B 中为类 A 添加一个import语句,然后它们可以相互发送消息。

使用包进行访问控制很简单;这是一个简单的二进制设置。你可以看到你导入的类的对象,或者如果你没有导入它,你根本看不到这个类。同一个包中的所有类都对同一包中的其他类有隐式导入,在这种情况下,没有必要显式导入同包中的类。

现在,我们准备查看我们可用的四个访问控制指定符。

公共指定符

如果需要,import语句可以创建任何其他对象的公共类的对象。

你可以从任何拥有指向第二个对象的引用的对象中访问公共类的字段。我们应该始终将类字段保持为私有,这样我们就不能直接向字段赋值。要与私有变量交互,你需要同一类中的方法,这些方法将成为你读取或写入私有变量的代理。在向字段写入时,你将能够在将其分配给字段之前验证新值。

你可以从任何拥有指向包含公共方法的对象的引用的其他对象中调用公共方法。我们将类中的公共方法称为其接口。我们将在下一章中探讨接口。

私有指定符

Java 中的私有指定符与 C++和 C#相同——您使用此指定符来定义字段和方法访问控制。

如前所述,类字段始终应该是私有的。您需要验证您想要存储在私有字段中的数据。一种常见的方法是使用一个修改器,通常称为 setter 方法。在这里,您可以添加验证并通过抛出异常拒绝无效数据。

被指定为私有的方法只能由同一类中的其他方法调用。使用私有方法允许我们将复杂任务分解成更小的单元。由于它们是私有的,因此不能从其他对象调用,这确保了复杂任务的必要步骤将按正确顺序执行。

Java 允许您在另一个类中定义一个新类。这是唯一一个类可能是私有的情况。一个私有的非内部类不能被实例化。您不能在声明它的类之外使用私有内部类。您可以在声明它的类中实例化私有内部类。如前所述,实例变量始终应该是私有的,而方法可以是四种访问指定中的任何一种。

受保护的指定符

protected/package只在您使用继承时使用。在非继承情况下,受保护的属性表现得与包访问相同。在 C++和 C#中,包的概念不存在,因此在非继承情况下,这些语言将受保护的属性视为私有。

正如我们在下一章中将要看到的,继承是两个类之间的安排,其中一个类是超类,另一个类是子类。子类可以访问其超类的所有公共成员,包括在超类中指定的受保护实例变量。与继承无关且引用超类的其他对象将受保护的成员视为私有,或者如果这些对象定义在同一个包中,则视为包。

您指定的受保护的方法和类变量也具有包访问权限,如下一节所述。您不能有受保护的类。

包指定符

这个最终的访问指定符包在 C++或 C#中没有等效项,尽管这些语言中的 friend 概念与之有些相似。它定义了在同一个包中定义的其他对象中类的字段、字段和方法的可视性。没有类似于 public、private 或 protected 这样的指定符。当在类、字段或方法上没有显式使用指定符时,则隐式访问控制是包。

在不同包或文件夹中定义的另一个对象的引用被视为私有。同一包中两个不同类的对象可以像访问公共元素一样访问受保护的元素。

注意

在我们继续之前,最后一点——谨慎使用protected和包指定符。它们存在于可以加快对象之间交互的情况。问题是它们将字段和方法暴露给不应访问它们的对象。我建议你在开始设计和编写程序时只使用公共和私有。只有在你能够证明仅使用公共或私有组件会导致系统性能下降时,才考虑使用protected和包。

最后一点需要重申——在类内部不存在访问控制。这意味着一个公共方法可以调用同一类中的private方法。无论其访问控制指定如何,每个方法都可以访问每个字段。

现在,我们准备查看类是如何工作的。我们首先将查看上一章程序中的类,然后我们将创建程序的更新版本。

理解类

Java,与其他面向对象的语言一样,使用一种围绕源代码结构的语法,称为。但首先,什么是类?引入对象概念的理论家们将类视为自定义数据类型。想象一下原始整数类型——它有一个允许的值范围和一组预定义的操作,如加法、减法和其他常用运算符。想象一下类作为一个自定义原始类型,你决定你的类型将执行哪些操作,以方法的形式。面向对象编程的一个目标是通过开发将数据和动作结合在一起的自定义数据类型来专注于问题解决,而不是结构化编程方法,其中数据和动作是分开的。

这意味着你通过首先列出类的所有字段来开发一个类,无论是原始类型还是其他类的引用。接下来是执行有用任务的方法,这些任务利用了这些字段。这些变量对类中的每个方法都是可见的,无论其访问控制级别如何。

类不是可执行代码,有一个例外我们很快就会看到。相反,类是一个蓝图,必须在运行时使用new关键字实例化或创建。当你的程序开始执行时,Java 虚拟机JVM)将类定义存储在称为类方法区域的内存区域中。

new运算符执行两个任务:

  • 为类蓝图中的所有实例变量分配足够的内存。我们称这个内存区域为Class-Method区域。

  • 将分配的内存区域的地址分配给适当的引用变量。引用变量始终只有 4 个字节长。通过引用变量访问对象称为间接寻址。

存在第三个内存区域,称为栈。JVM 按照需要将所有局部变量——在方法中声明的变量——存储在栈数据结构中。栈是一个动态结构,可以为变量分配空间,然后通过移动指针来释放它们。如果你对内存管理感兴趣,请参阅本章末尾的进一步阅读部分以获取更多信息。

在我们继续之前,我们需要了解 JVM 在创建对象时可以调用的两种方法。

构造函数和 finalize 方法

在大多数面向对象的语言中,与内存管理相关的有两种特殊方法。constructor方法在对象的创建过程中的最后一步运行,而destructor方法,在 Java 中称为finalize,在对象超出作用域时作为第一步运行。Java 中这种情况与其他语言,如 C++,是不同的。

finalize

一个类中只能有一个finalize方法。你不能对其进行重载。不要使用它——Java 9 已经弃用了它。如果你是 C++开发者,你可能会错误地认为finalize是 C++析构函数的 Java 等价物。然而,这并不正确——原因如下。

在 C++中,delete运算符首先运行析构函数。一旦析构函数运行,delete运算符就会释放对象使用的内存,现在可以重新分配。这是因为当你对一个有效指针执行delete运算符时,析构函数中的操作会立即执行。

在 Java 中,没有delete运算符;相反,JVM 监视所有对象的引用。当一个对象引用超出作用域时,JVM 将其添加到 JVM 将为你释放的引用列表中。然而,为了效率和性能,JVM 不会立即释放内存。相反,它会尽可能推迟。这是由于释放内存所需的时间,这可能会影响 JVM 中当前运行的程序。我们称内存释放为垃圾回收

JVM 在垃圾回收之前运行finalize方法。这种垃圾回收可能每几分钟或更少发生,但这非常不可能。考虑到大量的 RAM(我的系统有 32 GB),这意味着垃圾回收可能每几个小时或甚至几天发生一次。因此,finalize是非确定性的。它可能因为不再有效的原因而想要影响程序的一部分或发送消息,或者该程序的一部分已经被垃圾回收。

由于这个和其他原因,Java 架构师决定弃用finalize。所以,不要使用它。如果你想在对象超出作用域之前调用一个方法,那么你必须显式地调用你创建的方法。

在讨论了finalize方法并将其分配给垃圾或弃用回收站之后,让我们来看看构造函数。

构造函数

构造函数的目的是在对象创建的最后一步执行任何必要的操作。通常,您使用构造函数来初始化类实例变量。我们已经看到我们可以在类的声明点直接初始化这些变量。有时,初始化需要比仅仅赋值更多的步骤,这就是构造函数非常有价值的地方。

您通过调用new运算符来创建一个对象。new运算符执行导致创建对象的任务。我在简化这个过程,但这是您需要了解的内容:

  1. 所有实例变量以及其他 JVM 所需的所需结构都在内存的堆区域分配内存。这个内存的地址就是this引用捕获的地址。随后,对这个类中非静态方法的每次调用现在都有一个第一个参数,即不可见的this参数。

让我们编写以下代码:

public class MyClass() {
   public void doSomething(int value) { … }
   . . .
}

然后,我们将实例化它,如下所示:

var testClass = new MyClass();

编译后,非静态方法变成了以下内容:

   public void doSomething(MyClass this, int value) { … }

您永远不能这样编写代码。MyClass this是隐含的,因此可能不写。

现在,我们将调用这个方法:

testClass.doSomething(42);

然后,它变成了以下内容:

testClass.doSomething(testClass, 42);
  1. 一旦分配了内存,以及其他的维护工作,JVM 将调用适当的constructor方法。它没有返回类型,因为它没有变量可以返回结果。您必须用与它们所属的类相同的名字来命名它们。

构造函数分为两类:默认和非默认。只有一个默认构造函数,它没有参数。一个非默认构造函数是带有参数的。它受重载的影响,因此如果参数类型不同,可以有多个非默认构造函数。

Java 提供了调用另一个构造函数的能力。第一个被调用的构造函数由重载的规则决定。然后,被调用的构造函数可以调用另一个构造函数。这个调用必须是代码的第一行。注意可能的递归构造函数调用错误,其中构造函数 A 调用构造函数 B,而构造函数 B 又调用构造函数 A。

修改复利程序

现在,我们已经准备好回顾我们的复利程序,并将我们刚刚学到的内容应用到这个项目中的类中。

对于这一点,让我们更深入地看看我们在第二章,“代码、编译和执行”中讨论的CompoundInterest04程序。

我们将首先在CompoundInterestCalculator04.java文件中声明包。包,我们将源代码放入的文件夹,允许我们通过功能来管理我们的代码。您可能不想使用包的情况是如果您正在创建一个单文件源代码或 Linux shebang 应用程序。

这里是包声明。文件将位于名为business的文件夹中,该文件夹位于com/kenfogel/compoundinterest04文件夹中:

package com.kenfogel.compoundinterest04.business;

这个程序将使用NumberFormat类。这个类是 Java 标准库的一部分,我们知道这一点是因为其包命名的第一个名字是java。为了使用这个类,我们必须将其导入到我们的文件中,如下所示:

import java.text.NumberFormat;

类声明的第一行显示了以下内容:

  • 这个类是公开的,因此可以在任何声明了对其引用的其他类中实例化。

  • 公共类名CompoundInterestCalculator04也必须是文件名。你可以在一个文件中拥有多个类结构,但其中只有一个可以是公开的。

这是声明中的第一行:

public class CompoundInterestCalculator04 {

这是类中的字段:

    private final double principal = 100.0;
    private final double annualInterestRate = 0.05;
    private final double compoundPerTimeUnit = 12.0;
    private final double time = 5.0; // 

我们正在声明四个类型为double的实例变量。private的访问控制指定意味着你不能从任何可能引用这个类的其他类中访问这些变量。final修饰符将这些变量定义为不可变的。在一个类中,访问控制不适用,但修饰符适用。你必须在你声明final变量的地方或在构造函数中初始化它。

接下来,我们正在声明我们将要在代码中使用的对象引用:

    private final NumberFormat currencyFormat;
    private final NumberFormat percentFormat;

我们正在声明NumberFormat类的两个实例。你可以从变量名中看出,我们为每个不同的格式规划了一个。这些是最终的,这意味着我们必须用值初始化它们,并且不能再次实例化。我们不仅可以在声明中实例化NumberFormat引用,还可以在构造函数中实例化它们,这就是我们将要做的。

以下方法是构造函数:

    public CompoundInterestCalculator04() {
        currencyFormat = 
              NumberFormat.getCurrencyInstance();
        percentFormat = NumberFormat.getPercentInstance();
        percentFormat.setMinimumFractionDigits(0);
        percentFormat.setMaximumFractionDigits(5);
    }

构造函数很容易识别,因为它必须与类的名称相同。它不返回值,因为 JVM 将构造函数作为new操作的一部分调用。没有从return语句分配结果的内容。这是一个默认构造函数,因为括号中没有参数。一个类可能只有一个默认构造函数,但可以通过接受不同数据类型参数的构造函数来重载构造函数。

这个构造函数执行的任务是初始化和配置NumberFormat对象。我们不是仅仅使用new运算符,而是通过使用工厂方法来实例化这个类。工厂方法在调用new之前执行额外的任务。此外,请注意,我们通过类名而不是通过对象名调用方法,这与Math库方法非常相似。这告诉我们getCurrencyInstancegetPercentInstance是可用的静态方法。我们将在本节稍后讨论静态方法。

接下来是对象实例化后我们想要调用的方法:

    public void perform() {

perform这个名字只是我选择的名字。重要的是要记住,除了构造函数之外,所有方法都应该使用动词。记住,类和变量标识符应该是名词。

方法调用的第一行调用calculateCompoundInterest方法进行计算,并将结果存储在result变量中:

        var result = calculateCompoundInterest();

下一行显示的结果格式正确:

        System.out.printf(
            "If you deposit %s in a savings account "
            + "that pays %s annual interest compounded "     
            + "monthly%nyou will have after %1.0f "
            + "years %s%n", 
            currencyFormat.format(principal),
            percentFormat.format(annualInterestRate),
            time, currencyFormat.format(result));
    }

代码中的加号符号表示连接。由于字符串相当长,它已经被拆分成多个由加号运算符连接的字符串。

在这里,我们看到执行答案计算的方法:

    private double calculateCompoundInterest() {
        var result = principal * 
          Math.pow(1 + annualInterestRate / 
          compoundPerTimeUnit, time * compoundPerTimeUnit);
        return result;
    }
}

使用实例变量,将结果计算到一个名为result的变量中,该方法将其返回给调用者。这是一个私有方法,因此只有本类中的其他方法可以看到它。

复利示例中的第二个类只包含main方法。

再次,让我们声明这个文件所在的包:

package com.kenfogel.compoundinterest04.app;

这个程序将使用我们编写的CompoundInterestCalculator04类。与所有导入一样,我们正在引用我们创建的包/文件夹中编写的类:

import com.kenfogel.compoundinterest04.business
                            .CompoundInterestCalculator04;

这里是声明类的第一行:

public class CompoundInterest04 {

它展示了以下内容:

  • 这个类是公共的,因此可以在任何声明对其有引用的其他类中实例化。

  • 类名CompoundInterest04也必须是文件名。

在一个文件中可以有多个类结构,但其中只有一个可以是公共的。

每个 Java 程序都必须有一个main方法。这是 JVM 开始执行你的程序的地方:

    public static void main(String[] args) {

main方法是一个静态方法。静态方法与非静态方法的不同之处在于,我们可以调用这些静态方法而不需要实例化对象,就像我们使用NumberFormat的静态方法那样。Math库也是这样工作的。要使用pow函数,我们只需写Math.pow。我们不需要首先实例化Math对象。

在这里,我们使用名为banker的引用实例化CompoundInterestCalculator04类:

        var banker = new CompoundInterestCalculator04();

我们在main方法中调用CompoundInterestCalculator04类中的perform方法结束:

        banker.perform();
    }
}

我们现在已经回顾了CompoundInterest04程序是如何构建的,以及我们如何利用访问控制和包。

基于功能的功能类组织

我们从本书开始使用的复利程序正确地执行了其特定任务。然而,这种方法的问题在于程序是一个死胡同——这并不意味着死胡同一定是坏事。有时,你只是想要一个可以确定特定问题答案的一次性程序。

但如果我们编写一个复杂的程序呢?在现实世界中,意味着在工作中编码,你很少会编写像我们的复利计算器这样的程序。想象一下,你想要创建一个更完善的银行系统。在这个系统中,将需要从用户那里获取输入,而不是在程序的源代码中硬编码它。更进一步,你可能希望将输入和结果存储在外部存储中,如数据库。你可能还希望根据存储的数据生成报告。

现在我们根据功能重新组织CompoundInterest04程序,现在重命名为CompoundInterest05

数据类

第一步是设计一个只持有数据的类。这个类将没有领域方法,例如计算、存储在数据库中或与最终用户进行输入输出方法交互。我们正在创建一个新的数据类型,我们可以在执行其他动作的类中使用它。这种类型的类遵循一个最初被描述为JavaBean的模式。Java 将其引入作为一种可重用的软件组件。

我们创建的不是一个纯 JavaBean,而是一个变体。我经常将这种类型的类称为一个简单的变量盒子。让我们看看我们的复利问题中的一个例子。

我们从package语句开始。我们将使用包名,任何需要使用这个包的类都可以通过导入名称来使用,如下所示:

package com.kenfogel.compoundinterest05.data;

这里是标准的公共类声明:

public class CompoundInterestData {

这里是执行计算所需的四个变量:

    private final double principal;
    private final double annualInterestRate;
    private final double compoundPerTimeUnit;
    private final double time;

我们在这里声明了四个实例变量。它们是 final 的,所以一旦赋值,就变为不可变。我们预计这些值将来自程序的用户,而不是像我们之前那样硬编码值。这意味着每次新的计算都需要一个新的CompoundInterestData对象。

最后这个变量是我们计划存储计算结果的地方:

    private double result;

由于这个类中没有动作,我们无法确定这个值何时会被设置,因此它不能是 final 的。

这是构造函数:

    public CompoundInterestData(double principal, 
               double annualInterestRate, 
               double compoundPerTimeUnit, 
               double time) {
        this.principal = principal;
        this.annualInterestRate = annualInterestRate;
        this.compoundPerTimeUnit = compoundPerTimeUnit;
        this.time = time;
    }

当这个类被实例化时,它有四个必需的参数。一旦赋值给类变量,你不能改变它们所持有的值。注意this关键字。由于我们使用了与方法参数相同的实例变量名,我们使用this来指定实例变量。记住this是类中实例变量的地址。对于类或静态变量没有this引用,因为每个类只有一个。不使用this,你将引用同名的参数。result实例变量不是参数之一,因为你在程序中稍后计算它的值。

接下来的四个方法是为四个类实例变量提供的 getter:

    public double getPrincipal() {
        return principal;
    }
    public double getAnnualInterestRate() {
        return annualInterestRate;
    }
    public double getCompoundPerTimeUnit() {
        return compoundPerTimeUnit;
    }
    public double getTime() {
        return time;
    }

这个类基于的 JavaBean 规范要求所有实例变量必须是私有的。规范接着定义了 setter 和 getter 方法。由于这前四个变量通过final属性是不可变的,因此你只能有一个 getter。当你使用new创建对象时,你将提供初始值。

这最后两种方法很特殊,因为result变量不是最终的:

    public double getResult() {
        return result;
    }
    public void setResult(double result) {
        this.result = result;
    }
}

我们在实例化这个类之后确定值。我们在构造函数中为四个输入值赋值。Java 持久化 API 或 Jakarta 等框架期望这种 getter 和 setter 语法。

业务类

现在,让我们编写calculation类。它的唯一目的将是计算结果并将其存储在 bean 中。我们正在导入我们刚刚创建的数据类:

package com.kenfogel.compoundinterest05.business;
import com.kenfogel.compoundinterest05.data.
                                    CompoundInterestData05;
public class CompoundInterestCalculator05 {

这个类只有一个方法,没有实例变量。由于所有值都在CompoundInterestData05中,我们通过调用属性的getter方法来检索这些值。最后,我们通过调用唯一的 setter 将结果分配给 bean 的结果变量:

    public void calculateCompoundInterest(
                    CompoundInterestData05 value) {
        var result = value.getPrincipal() * 
               Math.pow(1 + value.getAnnualInterestRate() / 
               value.getCompoundPerTimeUnit(),
               value.getTime() *
               value.getCompoundPerTimeUnit());
        value.setResult(result);
    }
}

用户界面类

最后一个组件是用户界面,在这里我们可以向用户请求执行计算所需的四条信息。这就是我们将创建此对象的地方:

package com.kenfogel.compoundinterest05.ui;

package语句之后,我们有我们将使用的类和库的导入。我们有一个新的导入,那就是Scanner库类。Scanner类的对象允许我们在控制台应用程序中收集最终用户输入,例如从键盘输入:

import com.kenfogel.compoundinterest05.business.
                         CompoundInterestCalculator05;
import com.kenfogel.compoundinterest05.data.
                         CompoundInterestData05;
import java.text.NumberFormat;
import java.util.Scanner;
public class CompoundInterestUI05 {
    private CompoundInterestData05 inputData;
    private final CompoundInterestCalculator05 calculator;
    private final Scanner sc;
    private final NumberFormat currencyFormat;
    private final NumberFormat percentFormat;

在 Java 中,没有关于方法顺序或字段位置的规则,这与 C 和 C++不同,在 C 和 C++中,顺序可能很重要。我个人的风格,正如你在这些示例中看到的,是将实例变量放在第一位,然后将构造函数紧随其后。关于这个问题的建议是,与你的团队就编码风格达成一致。这将使阅读彼此的代码变得容易得多。

这里是实例化NumberFormat对象和Scanner类的构造函数。你必须向Scanner类的构造函数提供输入源。它可以是来自磁盘上的文件,但在这个程序中,它来自键盘。我们调用与键盘交互的对象的System.in

    public CompoundInterestUI05() {
        currencyFormat = 
                NumberFormat.getCurrencyInstance();
        percentFormat = NumberFormat.getPercentInstance();
        percentFormat.setMinimumFractionDigits(0);
        percentFormat.setMaximumFractionDigits(5);

        sc = new Scanner(System.in);
        calculator = new CompoundInterestCalculator05();
    }

接下来是这个用户界面类的入口点。这将是这个类中唯一的公共方法。我使用do前缀,因为它确保名称是一个动词或动作。我们必须从用户请求的四个值作为局部或方法变量存在。我们将用户输入的结果分配给每一个。在这个表达式中,我们使用四个局部变量作为参数实例化数据对象。new运算符通过构造函数将值从局部变量复制到CompoundInterestData05对象的实例变量中。然后我们调用Calculator类中的calculateCompoundInterest来计算结果。最后一步是显示结果:

    public void doUserInterface() {
        doUserInstructions();
        var principal = doPrincipalInput();
        var annualInterestRate = doAnnualInterestRate();
        var compoundPerTimeUnit = doCompoundPerTimeUnit();
        var time = doTimeInput();
        inputData = new CompoundInterestData05(
              principal, 
              annualInterestRate, 
              compoundPerTimeUnit, 
              time);
        calculator.calculateCompoundInterest(inputData);
        displayTheResults();
    }

这个版本的CompoundInterest程序遵循经典的输入、处理和输出模式。我们现在遇到的第一个方法是output

    private void displayTheResults() {
        System.out.printf(
         "If you deposit %s in a savings account that pays"
         + " %s annual interest compounded monthly%n"
         + "you will have after %1.0f years %s%n", 
         currencyFormat.format(inputData.getPrincipal()),
            percentFormat.format(
                inputData.getAnnualInterestRate()),
                inputData.getTime(), 
                currencyFormat.format(
                    inputData.getResult()));
    }

下一个方法是输入过程的一部分。它可以在屏幕上向最终用户提供额外的说明,但我在这里保持了简单:

    private void doUserInstructions() {
        System.out.printf(
                 "Compound Interest Calculator%n%n");
    }

现在,我们来到用户输入部分。每个输入都会显示一个提示并等待输入。一旦你输入字符串,nextDouble方法就会尝试将其转换为适当的类型——在这种情况下,是double

    private double doPrincipalInput() {
        System.out.printf("Enter the principal: ");
        var value = sc.nextDouble();
        return value;
    }
    private double doAnnualInterestRate() {
        System.out.printf("Enter the interest rate: ");
        var value = sc.nextDouble();
        return value;
    }
    private double doCompoundPerTimeUnit() {
        System.out.printf("Enter periods per year: ");
        var value = sc.nextDouble();
        return value;
    }
    private double doTimeInput() {
        System.out.printf("Enter the years: ");
        var value = sc.nextDouble();
        return value;
    }
}

但是等等——四个输入方法中有一个严重的问题。除了String提示外,它们完全相同。作为一名教师,我把重复的代码描述为失败的邀请。如果我们决定进行更改,比如从double切换到float,我们必须记住在八个不同的地方进行四次更改。不小心遗漏其中一个更改的可能性太高。让我们将这四个方法合并为一个。

很简单——只需将提示作为单个输入方法的参数,如下所示:

    private double doUserInput(String prompt) {
        System.out.printf(prompt);
        var value = sc.nextDouble();
        return value;
    }

现在,我们可以使用doUserInput方法来处理所有四个用户输入:

    public void doUserInterface() {
        doUserInstructions();
        var principal = 
            doUserInput("Enter the principal: ");
        var annualInterestRate = 
            doUserInput("Enter the interest rate: ");
        var compoundPerTimeUnit = 
            doUserInput("Enter periods per year: ");
        var time = doUserInput("Enter the years: ");
        inputData = new CompoundInterestData05(
              principal, 
              annualInterestRate, 
              compoundPerTimeUnit, 
              time);
        calculator.calculateCompoundInterest(inputData);
        displayTheResults();
    }

所有用户输入都是String对象;你不能输入纯数字、布尔值或字符。Scanner类负责将字符串转换为目的地类型,如next方法所表达的那样。

在我们的例子中,它们都是double类型。如果我们输入bob字符串而不是数字会发生什么?Java 会抛出异常。这是一个错误条件。当我们研究循环时,我们将学习如何创建防用户输入错误,当我们研究 GUI 编程时,我们将了解其他管理用户输入的方法。在所有情况下,所有输入都作为字符串到达——如前所述。

最后一个类是app类。我们通常使用app这个名称来定义一个包含包含main方法的类的包。这是一个约定,你可以自由地更改它:

package com.kenfogel.compoundinterest05.app;
import com.kenfogel.compoundinterest05.ui
                             .CompoundInterestUI05;
public class CompoundInterest05 {
    public static void main(String[] args) {
        var calculator = new CompoundInterestUI05();
        calculator.doUserInterface();
    }
}

当我们运行这个新版本时,这将是我们得到的输出:

Welcome to the Compound Interest Calculator
Enter the principal: 5000
Enter the interest rate: 0.05
Enter periods per year: 12
Enter the years: 5
If you deposit $5,000.00 in a savings account that pays 5% annual interest compounded monthly
you will have after 5 years $6,416.79

程序会要求用户输入它所需的四个值,然后使用这些值计算结果并显示。

摘要

在本章中,我们探讨了类的基本组成部分。一旦我们回顾了如何组装CompoundInterest04示例,我们就将程序拆分,创建了用于存储数据、显示用户界面和计算结果的类。我们还了解了构造函数和已弃用的finalize方法。我们了解了new的作用以及 JVM 如何管理程序的内存。

第二个版本,CompoundInterest05,展示了如何根据功能专业地组织程序。它将数据、用户界面和操作(通常称为业务)分开。为了收集用户输入,我们首次了解了 Java 库中的Scanner类。你现在应该对 Java 类的组织结构以及如何控制类成员的访问有很好的理解。

在下一章中,我们将更深入地探讨执行类操作的策略以及我们如何管理类之间的关系。

进一步阅读

第六章:方法、接口、记录及其关系

在 Java 中,我们定义和组织代码的方式是语言的基础。在本章中,我们将首先检查方法在 Java 中的作用。从这里,我们将检查继承和接口提供的关系。接下来是不可变的record类。多态,即使用对象在类层次结构中的能力,作为继承和接口的应用,也将被覆盖。我们将通过查看对象之间的关系以及它们如何调用其他对象中的方法来结束本章。

在本章中,我们将学习以下主题:

  • 理解方法

  • 理解继承

  • 理解类接口

  • 理解record

  • 理解多态

  • 理解类中的组合

通过理解 Java 中所有可用组件和关系,你将能够阅读或编写 Java 代码。

技术要求

这里是运行本章示例所需的工具:

  • 已安装 Java 17

  • 一个文本编辑器

  • 已安装 Maven 3.8.6 或更高版本

本章的示例代码可在github.com/PacktPublishing/Transitioning-to-Java/tree/chapter06找到。

理解方法

现在,我必须承认,我从 1980 年开始编码,从那时起,我们用来描述代码离散块术语已经改变。当我 1980 年开始使用BASIC,一种无结构语言编码时,我很快学会了将我的代码分解成子程序。从 BASIC,我转向Pascal,其中代码块有一个正式的指定。这些是为返回结果的函数和没有返回结果的过程。接下来是 C,然后是 C++。这些语言将它们的代码块命名为函数,因为它们所有(除了构造函数)都必须返回一个值。转向 Java,这些块被称为方法。让我们来检查方法组件。

在创建方法时,你需要考虑一些甚至所有这些组件:

  • 访问控制指定

  • 静态或非静态指定和this引用

  • 重写权限

  • 需要重写

  • 返回类型

  • 方法名

  • 参数变量

  • 注解

  • 异常处理

  • 线程设置

  • 泛型参数

让我们逐一讨论。

访问控制指定

在上一章中,我介绍了访问控制的概念,因此让我们看看这些概念如何应用于可能为私有、公共、受保护或包的方法:

  • 使用private指定符的私有方法只能被同一类中的非静态方法访问:

    private void doSomething() { … }
    
  • 公共方法可以被同一类中的非静态方法访问。它们也可以被程序中任何具有指向包含公共方法的对象的引用变量的其他对象访问:

    public void doSomething() { … }
    
  • 受保护的成员可以从继承它们的任何子类中像公共方法一样访问。我们将在本章后面讨论继承。类的一个受保护成员也具有包访问权限。在没有继承和不同包中的对象的情况下,protected的行为与private相同:

    protected void doSomething() { … }
    
  • 包含在一个对象中的方法可以被同一包中具有对其引用的其他对象访问,就像它是公共的。其他包中的对象,如果它们有适当的引用,将包方法视为私有,无法访问它们:

    void doSomething() { … } // Package
    

静态或非静态的指定以及 this 引用

方法默认是非静态的。这意味着当方法被调用时,始终有一个未声明的第一个参数。如果存在其他参数,则这个未声明的参数始终位于第一位。

未声明的参数是对调用方法的对象的引用。它的类型是类,标识符是this

为了理解这一点,让我们看看一些代码片段。这里有一个只有一个方法的类的一部分。由于字段和方法参数具有相同的名称,我们将使用this引用来区分它们:

public class StaticTest01 {
   private int value;
   public void nonStaticMethod(int value) {
      this.value = value;
   }
}

this引用是由编译器添加的,作为非静态方法的第一个参数。让我们看看编译器添加了this引用后方法是如何出现的:

public class StaticTest01 {
  private int value;
  public void nonStaticMethod(StaticTest01 this, int value){
     this.value = value;
  }
   …
}

我们实例化对象,如下所示:

var sTest = new StaticTest01();

然后,我们调用它的方法,如下所示:

sTest.nonStaticMethod(42);

然后,编译器将方法转换为以下形式:

sTest.nonStaticMethod(sTest, 42);

this引用允许方法只有一个代码块,无论你创建了类的多少个实例。

你可以在方法体中使用this引用,但不能声明它。如前所述,this最常见的使用是区分具有相同标识符名称的字段标识符和局部方法变量。

静态方法,即通过添加static关键字指定的方法,没有this引用。这意味着你可以先不实例化它所属的对象就调用这个方法。这也意味着它不能调用同一类中声明的非静态方法,也不能访问任何非静态字段。

重写权限 - final

继承是从现有类创建新类的过程。我们将在本章后面详细讨论这个主题。这意味着派生类或子类中具有相同名称、相同类型和参数数量的方法会覆盖父类或超类中的相同方法。

如果StaticTest是一个具有继承关系的超类,则nonStaticMethod方法可以被重写,但如果我们将final关键字添加到方法声明中,我们可以防止这种情况。现在,我们不能再重写nonStaticMethod,如下面的代码块所示:

public class StaticTest {
   private int value;
   public final void nonStaticMethod(int value) {
      this.value = value;
   }
}

我们也可以在声明类时使用final关键字。这将阻止这个类被用作超类。换句话说,你不能从一个final类继承:

public final class StaticTest {

现在,我们不能再扩展这个类。这也意味着类中的所有方法实际上都是final的,所以没有必要在方法名中使用关键字。

必须重写 - 抽象

在使用或不使用final关键字来决定方法在涉及继承时是否可以被重写的同时,我们还可以要求方法必须被重写。我们刚刚讨论了如何控制你是否可以重写一个方法。另一个选择是强制子类化,这样你必须重写该方法。我们通过定义一个abstract类并声明没有代码的抽象方法来实现这一点,如下面的代码块所示:

public abstract class ForcedOverrideTest {
   private int value;
   public abstract void nonStaticMethod(int value);
   }
}

包含一个或多个抽象方法的类不能被实例化。你只能将其用作继承中的超类。因此,我们必须也将该类指定为抽象的。

返回类型

在 Java 中,与 C 语言一样,所有方法都返回一个结果。当我们声明一个方法时,除了构造函数外,我们必须显示返回类型。返回类型可以是原始变量或引用变量。

有一种特殊的返回类型,那就是void。使用void意味着此方法没有返回值,且在方法中不能有返回值的return语句:

  • 此方法不返回任何内容:

    public void doSomething() { … } //
    
  • 此方法返回一个原始类型:

    public double doSomething() { … }
    
  • 此方法返回一个引用:

    public String doSomething() { … }
    

根据你在方法中使用的逻辑,可能会有时候你想提前返回或跳出方法。在这种情况下,当返回类型为void时,你可以单独使用return。如果方法不是void,那么提前返回也必须包括适当的值。

方法名

命名方法的规则和约定与我们讨论过的命名变量的规则和约定相同,见第四章语言基础 - 数据类型和变量。一个区别是,根据命名约定,变量是名词,而方法则期望是动词。

参数变量

方法名之后跟着一个开括号和闭括号;如果为空,则此方法不接收任何数据。正如已经指出的,对于非静态方法,在参数列表的第一个位置会自动添加一个未声明的this引用。如果我们编写一个没有任何参数的非静态方法,则会得到这个引用。这实际上意味着所有非静态方法至少有一个参数。

参数可以是任何原始类型或类引用类型。一个方法最多可以有 255 个参数,尽管如果你有接近 255 个参数,那么你很可能在做一些错误的事情。

当你调用一个方法时,重要的是你传递的值的类型。标识符并不重要。如果你有一个名为 bobint 类型参数,你可以传递任何命名的 int 给这个方法。

Java 没有为参数设置默认值的方法。

注解

注解是一个提示。它可以在编译时修改你的代码的行为,或者只是提供信息而不产生任何效果。一个方法注解必须出现在方法及其任何组件之前。

注解是一个遵循与类相同的命名约定并以下划线符号(@)开头的短语。它可以在括号内包含注解后的参数,但这些不是方法参数中的变量声明。它们可以是任何东西。

框架,如 Servlet 类,这是一个由网络应用程序服务器实例化和运行类,我们会这样注解它:

@WebServlet(name = "AddToEmailList", 
                        urlPatterns = {"/AddToEmailList"})
public class AddToEmailListServlet extends HttpServlet{...}

在这个例子中,注解将这个类定义为名为 AddToEmailListWebServlet 类,而不是使用类名 AddToEmailListServlet

你可以在任何地方使用注解,而不仅仅是用于网络。

异常处理 – 抛出

当我们在下一章查看 Java 代码的语法时,我们会遇到在程序运行时可以预测或期望出现错误的情况。例如,你试图打开到数据库服务器的连接,但操作失败。尝试建立连接的代码将抛出一个异常。异常只是一个包含错误详情的对象。

在 Java 中,异常可以是检查型或非检查型。检查型异常期望有代码来处理异常;如果你不处理异常,它就是一个错误。非检查型异常不需要处理,可能会或可能不会结束你的程序。

你也可以决定当发生异常时,不要在发生异常的方法中处理它。相反,你希望将异常传递回调用该方法。你可以通过 throws 子句来完成这个操作。以下是一个连接到数据库以检索数据库中项目列表的方法片段。

在下面的代码块中,我们有一个打开数据库连接的方法:

Connection connection;
private void openConnection(){
    connection = 
        DriverManager.getConnection(url, user, password);
}

如果连接失败,将发生 SQLException 类型的异常,这是一个检查型异常。这段代码将导致编译时错误,因为你没有处理检查型异常。你可以决定在调用此方法的方法中延迟处理异常。为此,我们在方法中添加一个 throws 子句,如下面的代码块所示:

Connection connection;
private void openConnection()throws SQLException{
    connection = 
        DriverManager.getConnection(url, user, password);
}

编译器现在将验证这个异常将在调用此方法的地方被处理。

为什么不在异常发生时处理它?我们已经看到我们的CompoundInterest程序被分解为用户界面类和业务计算类。想象一下,你决定在发生错误的地方处理错误,例如请求用户的新凭据,如用户名或密码。你将如何做?你是在控制台、GUI 还是 Web 应用程序中询问?

在用户界面类中询问用户数据库凭据后,你将此信息传递给业务类中的openConnection方法。如果出现问题,我们抛出异常并返回到用户界面类的调用方法。数据库类不需要知道用户界面是什么。它只是返回一个结果或异常。现在,无论用户界面如何,这个业务类都是可用的。我们称之为关注点分离;我们将在第十章《在 Java 中实现软件原则和设计模式》中探讨这个主题。

线程设置

并发运行的代码块使用线程,正如我们将在第九章《在 Java 中使用线程》中看到的。在某些情况下,你希望一个线程中的代码块在另一个线程可以运行相同的代码块之前完成。我们可以强制这种情况发生的一种方法是指示一个方法必须在另一个线程可以执行相同的代码块之前运行完成。这就是synchronized关键字的作用,如下面的代码块所示。这个关键字并不是万无一失的,但它是我们编写线程安全代码的一部分:

synchronized public int add(int num1, int num2) {
   return num1 + num2;
}

泛型参数

泛型概念存在于许多语言中。这意味着你可以编写代码,其中变量的数据类型由泛型参数确定。我们将在第七章《Java 语法和异常》中更详细地探讨这一点,但到目前为止,你应该能够识别泛型参数语法。

在下面的代码块中,我们声明了一个方法来返回较大列表的一部分。列表就像数组一样,当我们讨论T时,我们会详细探讨它,此代码确保返回的结果与传递给方法的参数类型相同:

    public <T> List<T>getSubList(List<T> a, int b, int c) {
        return a.subList(b, c);
    }

此代码片段将返回一个在编译时确定类型的对象列表。第一个<T>,泛型参数,通知编译器T是一个类型参数,它将由调用此方法的任何代码确定。在这个例子中,它表明给定的List可以是任何类型的对象,并且此方法将返回相同类型的子列表。

无论你从哪种语言过渡过来,我怀疑你从未考虑过使用至少 11 个不同的概念来声明一个方法。在声明方法时,没有要求你必须使用所有 11 个。我们有这么多可用的部分是 Java 成为广泛应用的理想语言的一个方面。

我们将在下一章中查看 Java 语言的基本语法,它在方法中使用。接下来,让我们了解类和对象之间可以存在的相互关系。

理解继承

当你有一个几乎可以做你需要完成的几乎所有事情的课程时,你会怎么做?当你有一个在类中的方法并不完全做你需要完成的事情时,你会怎么做?如果你没有访问该类及其方法的源代码,你会如何处理这两个问题?如果你确实可以访问源代码,但你的程序的其他部分期望原始未更改的代码呢?答案是继承

继承被描述为两个类之间的关系,其中一个类被称为超类。这个类包含处理特定任务的字段和方法。这个超类是一般化,有时你需要增强这种类。在这种情况下,你不必重写原始类,而是可以创建一个新的类,称为子类,通过覆盖超类中的方法或添加额外的字段和方法来继承或专门化超类。子类现在由超类的公共和受保护方法以及它所添加的内容组成。在另一些语言中,子类也可能被称为派生类或子类。

想象一下,你必须编写一个管理银行账户的程序。所有账户,如支票和储蓄,都必须执行类似但又不完全相同的任务。你必须能够存款和取款。在定义的期限结束时,例如每月,必须执行一些任务,这些任务对于我们的两种账户类型来说是类似的,但又不完全相同。这就是继承可以发挥作用的地方。

在这个程序中,我们定义了一个包含公共元素的超类。然后我们创建了两个子类,它们继承自超类的公共和受保护成员。这通常被描述为is-a关系,意味着子类是超类的一种类型。当我们研究多态时,我们将看到如何使用这种关系。

这里是一个统一建模语言UML)图。这种图样式对于规划解决一个问题的所需类及其之间的关系非常有用。我们首先为每个类创建一个框,然后用线连接这些框。线的末端描述了关系。空心三角形表示继承。

在以下图中,我们可以看到BankAccount将是超类,而SavingsAccountChequingAccount是子类:

图 6.1 – BankAccount 继承层次结构

图 6.1 – BankAccount 继承层次结构

在这个例子中,我们使用继承在超类和子类之间共享数据和功能。将永远不会有一个BankAccount类型的对象,只有SavingsAccountChequingAccount类型的对象。这意味着BankAccount将是一个抽象类。抽象类,用斜体表示类名,不能实例化为对象;它只能用作超类,因此BankAccount将包含数据元素。这些元素必须对子类可访问,但对系统中的任何其他类都是私有的。为了在 UML 图中定义访问控制,我们使用以下前缀:

  • 八角形(#)表示方法或字段是受保护的

  • 加号(+)表示类、方法或字段是公共的

  • 减号(-)表示类、方法或字段是私有的

  • 没有前缀表示方法或字段是包级别的

在这里,我们展示了BankAccount中的所有字段都是受保护的,并且对两个子类可用:

图 6.2 – BankAccount 类字段

图 6.2 – BankAccount 类字段

这个继承设计的最后一部分是确定需要哪些方法。在这里,我们可以看到超类和子类中的方法:

图 6.3 – 方法及其访问控制

图 6.3 – 方法及其访问控制

BankAccount的情况下,三个任务——存款、取款和报告——每个任务都有一个执行两种账户类型共同操作的方法。这些方法将由子类调用。

SavingsAccount由于其本质,必须重写BankAccount中的每个方法。重写意味着有一个具有相同名称和相同参数的方法。返回类型可以不同,但必须是原始返回类型的子类。子类中的方法可以通过在方法调用前加上super引用来调用超类中的重写方法,例如super.makeDeposit(amt)

ChequingAccount类只重写了两个超类方法。它没有重写makeDeposit方法,因为超类处理这个方法的方式已经足够。

继承是一条单行道。子类可以调用超类的公共和受保护成员,但超类对子类一无所知。

只在子类中重写超类方法而不在子类中添加任何额外的公共方法或变量的继承模型被称为纯继承。在子类中添加额外的方法和变量,并且可能或可能不重写任何超类方法的继承模型被描述为is-like-a关系,并称为扩展

我们使用extends关键字来编码两种类型的继承。假设我们已经定义了BankAccount超类,那么我们编码继承如下:

  • public class SavingsAccount extends BankAccount { … }

  • public class ChequingAccount extends BankAccount { … }

我们需要检查子类的代码,以确定采用哪种方法,纯继承还是扩展。

当实例化时,这些对象中的每一个都将从其超类中拥有自己的数据集,如果继承是扩展,那么它还将有自己的字段。

在以下示例中,BankAccount类将被声明为抽象。这意味着你不能如下实例化BankAccount

var account = new BankAcount();

这将被编译器标记为错误,因为BankAccount是抽象的。如果我们有任何抽象方法在BankAccount中,我们就会被要求在SavingsAccountChequingAccount中重写它们。

注意

C++语言支持多重继承。这意味着一个子类可以有一个以上的超类。Java 的设计者选择只支持单一继承。你只能用一个超类扩展一个类。

如果你不想你的类可以被继承,你可以添加final指定符,如下所示:

public final AClass { … }

如果我们现在尝试创建一个继承或派生类,我们会看到以下内容:

public class AnotherClass extends AClass { … }

当我们尝试编译此代码时,将声明错误。

所有对象的超类,Object

在 Java 中,所有类都扩展了一个名为Object的特殊类,这个类始终可用。Object类存在的原因是什么?Object类定义了支持任何类中的线程和对象管理的方法。这意味着每个类都有这些方法,可以选择重写它们或按原样使用它们。有三种这些方法在类中经常被重写。以下是最先重写的一个:

public boolean equals(Object obj)

Object继承的默认实现比较调用equals的对象的地址与作为参数传递的对象的地址。这意味着只有当两个引用在内存中指向同一个对象的地址时,它才能为true。在大多数情况下,你可能想要比较一个对象字段中的值,因此你经常会想要重写它。例如,参见以下内容:

public class Stuff {
    private int x;
    public Stuff(int y){
        x = y;
    }
    @Override
    public boolean equals(Object obj) {
        if (this == obj) {
            return true;
        }
        if (obj == null) {
            return false;
        }
        if (getClass() != obj.getClass()) {
            return false;
        }
        final Stuff other = (Stuff) obj;
        return this.x == other.x;
    }

equals方法执行四个测试,一个对象要等于另一个对象,必须通过所有这些测试。以下就是这些测试:

  • this引用和obj包含相同的内存地址

  • 比较的obj不为null

  • 两个引用都是同一类类型

  • 最后,它比较字段值

下一个常见的重写方法是:

public int hashCode()

当比较两个对象中的多个字段时,必要的代码可能相当耗时。如果你需要比较两个字符串,你需要逐个字符地比较它们。对于具有许多字段的类,你需要对每个字段进行比较。有一种优化,那就是hashCode

哈希是从对象字段计算出的整数。在大多数情况下,计算哈希值比逐个比较字段要快。如果没有重写,此方法的默认值是对象在内存中的地址。你想要做的是根据对象的字段计算哈希值。

这里是一个重写 Object 类的 hashCode 方法的示例:

    @Override
    public int hashCode() {
        int hash = 5;
        hash = 79 * hash + this.x;
        return hash;
    }

生成的值不是唯一的。可能存在两个具有不同值的对象会生成相同的哈希码。我们可以确定的是,如果两个对象的哈希码不相同,那么根据字段,这两个对象也不相等。如果两个对象的哈希码相同,那么为了确保它们相等,现在必须使用 equals 方法。这几乎在所有情况下都会加快比较对象的过程,因为较慢的 equals 方法只有在哈希码相同的情况下才会被调用。

hashCode 的另一个应用是在数据结构中——这些结构将数据存储为键值对的值,其中值是一个对象。hashCode 返回的值是一个整数,用作键。由于整数处理速度比任何其他原始数据类型都快,因此与可以使用任何类型作为键的结构相比,这些数据结构执行效率更高。

三个常用重写方法中的最后一个是这个:

public String toString()

Object 类中此方法的实现返回 hashCode 方法返回的对象地址和类名。将其重写为返回字段的值作为字符串可能更有用。通过重载它,你可以检查对象的状态,如下所示:

    @Override
    public String toString() {
        return "Stuff{" + "x=" + x + '}';
    }

让我们现在看看 Java 中继承的替代方案,一个 类接口

理解类接口

在具有访问控制功能的编程语言中,公共方法被称为类的 接口。这些是可以从系统中的任何其他对象调用的方法。在 Java 中,我们可以创建一个合同,该合同将要求实现该合同(称为接口)的任何类将接口中列出的所有方法作为公共方法实现。

这里是一个用于读取和写入关系型数据库的接口:

public interface GamingDAO {
    // Create
    int create(Gamer gamer) throws SQLException;
    int create(Games games) throws SQLException;
    // Read
    List<Gamer> findAll() throws SQLException;
    Gamer findID(int id) throws SQLException;
    // Update
    int update(Gamer gamer) throws SQLException;
    int update(Games games) throws SQLException;
    // Delete
    int deleteGamer(int ID) throws SQLException;
    int deleteGames(int ID) throws SQLException;
}

在接口类的代码块中,每个方法都被声明为抽象方法,因为它以分号结尾且没有代码块。默认情况下,它们都是公共的——因此,没有使用 public 关键字。

当我们声明类时使用接口。实现类现在必须具有接口中描述的公共方法。以下是一个实现 GamingDAO 的类的第一行代码。我没有包括这些方法的实现:

public class GamingDAOImpl implements GamingDAO {
    @Override
    public List<Gamer> findAll() throws SQLException {…}
    @Override
    public int create(Gamer gamer) throws SQLException {…}
    @Override
    public Gamer findID(int id) throws SQLException {…}

每个方法都有@Override注解。这是一个信息性注解,在这种情况下是可选的。如果你使用这个注解,你的代码将更容易被其他开发者理解。你也在通知编译器注意任何在超类方法中的更改,如果发现更改,编译器将报错。这是 Java 接口的原始应用。

Java 8 和 Java 9 修改了接口中可以包含的内容。接口类原始定义的更改如下:

  • 在接口中实现default接口方法。这是一个在接口中而不是在实现类中实现的public方法。

  • 在接口类中实现private方法。这些private方法只能被default方法调用,或者可以被接口中声明的其他private方法调用。因此,正如以下代码块所示,我可以从write2()调用write4()

  • 在接口类中实现static方法。与所有static方法一样,它没有this引用,因此不能调用接口或类中的其他方法。它必须是一个公共方法,因此不需要public关键字。

这里是一个示例,展示了接口类中可能存在的这三种方法:

public interface Pencil {
   void write1(); // Standard interface method
   default void write2() {
      System.out.printf("default%n");
      write4();
   }
   static void write3() {
      System.out.printf("static%n");
   }
   private void write4() {
      System.out.printf("private%n");
   }
   public void perform(); // Standard interface method
}

如果我们实现这个接口,那么我们唯一有合同义务实现的方法是write1()

public class WritingTool implements Pencil {
   @Override
   public void write1() {
      System.out.printf("standard interface");
   }
   @Override
   public void perform() {
       write1();
       write2();
       Pencil.write3();
   }
   public void write5() {
       System.out.printf("Method only in WritingTool");
   }
   public static void main(String[] args) {
       Pencil tool = new WritingTool();
       tool.perform();
       tool.write5(); 
   }
}

注意,在main方法中,我们创建的对象WritingTool将其引用分配给了Pencil类型的对象。你不能直接使用new Pencil(),但在创建WritingTool时,你使用Pencil作为引用类型。这将限制你在WritingTool类中使用代码的范围,仅限于重写的方法。main方法中的最后一个调用tool.write5()将生成编译错误,因为write5方法不是Pencil接口的一部分。

实现类,在这个例子中是WritingTool,可以拥有接口中未列出的任何访问控制指定的额外方法。

将类的公共方法定义在接口类中是一种最佳实践。没有必要列出每个公共方法,一个类可以有多个接口,这样你可以对类执行的操作限制在特定的任务集中。

抽象类与接口的区别

在一个抽象超类中,你必须实现子类中的每个抽象方法。这可能会让你认为每个方法都是抽象的抽象类与接口相同。然而,正如这里提到的,两者之间存在显著差异。

抽象类可能有以下特性:

  • 抽象方法声明

  • 类和实例变量(字段)

  • 构造函数

  • 任何访问控制的额外非抽象方法

而接口可能具有以下特性:

  • 抽象方法声明

  • 默认、私有或静态方法

有一个其他显著的区别——由于 Java 只支持单继承模型,你只能扩展一个超类。另一方面,你可以为单个类实现多个接口,如下所示:

public class SingleInheritance extends ASuperClass { ... }
public class MultiInterface implements IFace1,IFace2 {...}

你也可以有一个只有一个超类和一个或多个接口的子类,如下所示:

public class MultiClass extends ASuperClass implements  
                                     IFace1,IFace2 {...}

然而,在使用多个接口时要注意的一个问题是,如果有多个接口中有相同的抽象方法,则这是一个编译时错误。你可以在超类中有一个与接口中相同的抽象方法。

接口和抽象类都可以定义一个类必须实现的内容。一个类只能从单个类继承的事实使得接口类更有用,因为一个类可以有多个接口。

密封类和接口

默认情况下,任何类都可以是任何其他类的超类,就像任何接口都可以与任何类一起使用一样。然而,你可以通过使用密封类密封接口来限制这种行为。这意味着你可以列出可能扩展或实现它的类名,如下面的代码片段所示:

public sealed class SealedClass permits SubClass{ }

这里,我们声明了一个只能用作名为SubClass的类的超类的类。

现在我们已经允许SubClass扩展Sealed类,我们可以写出如下代码:

public final class SubClass extends SealedClass { }

注意,这个子类必须定义为final。这样,就不可能让SubClass成为其他类的超类。以下是一个密封接口的类似语法:

public sealed interface SealedInterface permits SubClass{ }
public final class SubClass implements SealedInterface { }

我们还有一个结构要检查,那就是record

理解记录类

实例化了一个record对象。

在最简单的情况下,记录只需要在声明record类时列出的字段,如下所示:

public record Employee(String name, double salary) { }

当我们实例化这个记录时,我们必须提供名称和薪水的值:

var worker = new Employee("Bob", 43233.54);

所有记录都有一个默认的规范构造函数,它期望记录中每个字段的值。在一个常规类中,你需要编写一个规范构造函数方法来分配字段的值。你可以在记录中添加一个紧凑构造函数,允许你检查每个字段分配的值。

这里是一个紧凑构造函数。注意,它没有参数列表,因为它只能有参数,这些参数是类第一行中声明的每个字段:

public record Employee(String name, double salary) { 
    public Employee {
        if (salary < 50000) {
            // code if this is true
        }
    }
}

要访问字段的值,你可以使用标识符作为方法,如下所示:

var aSalary = worker.salary(); 

你不能改变记录中字段的值——你只能读取它的值。作为一个类,记录也扩展了Object。此外,record根据字段提供了默认的equalshashCodetoString方法的覆盖。

记录可以实现接口,因此它可以出现在密封接口的列表中。由于记录是隐式最终的,它不能扩展任何其他类,因此它不能被密封为类或类。

现在,让我们来探讨多态的概念,看看它是如何允许我们重用代码的。

理解多态

多态是面向对象语言的一个定义特征。Java 通过包括接口来扩展了这个概念。让我们从一个简单的类层次结构开始,如下所示:

public class SuperClass {

    protected int count;

    public void setCount(int count) {
       this.count = count;
    }

    public void displayCount() {
       System.out.printf("SuperClass count = %d%n", count);
    }
}

在这个简单的类中,我们有一个公共方法来分配count的值,以及第二个方法,它显示count的值以及类的名称。在下面的代码块中,我们有一个使用SuperClass的类:

public class Polymorphism {
     private void show(SuperClass sc) {
        sc.setCount(42);
        sc.displayCount();
    }

    public void perform() {
        var superClass = new SuperClass();
        show(superClass);
    }
    public static void main(String[] args) {
        new Polymorphism().perform();
    }
}

当我们运行这个程序时,结果正如我们所预期:

SuperClass count = 42

现在,让我们从SuperClass创建一个子类:

public class SubClass extends SuperClass {
    @Override
    public void displayCount() {
        System.out.printf("SubClass count = %d%n", count);
    }
}

这个子类只重写了displayCount方法。类是关于创建新的数据类型。当我们创建一个子类时,我们通过父类名称来引用类类型。换句话说,我们可以声明SubClassSuperClass类型。如果我们向SubClass添加一个subclass变量,那么这个类也是SuperClass类型。现在让我们将Polymorphism类更改为使用SubClass对象:

    public void perform() {
        var subClass = new SubClass();
        show(subClass);
    }
    private void show(SuperClass sc) {
        sc.setCount(42);
        sc.displayCount();
    }

虽然show方法没有改变,仍然期望一个SuperClass类型的对象,但在perform中我们创建了一个SubClass对象,然后调用show方法,传递子类的引用。由于SubClassSuperClass类型,多态允许将SuperClass的任何子类传递给show方法。当我们调用sc.setCount时,运行时确定必须使用superclass的计数方法,因为在子类中没有以该名称公开的方法。当它调用sc.displayCount时,它必须决定是使用它期望的SuperClass类型的方法还是传递的SubClass类型的方法。

多态意味着在子类中重写的方法会优先于父类版本被调用,即使传递给方法的声明类型是SuperClass类型。这次运行代码的结果如下:

SubClass count = 42

具有相同接口的类也受到多态的影响。这里有一个简单的接口,它只需要一个方法:

public interface Interface {
    void displayCount();
}

实现此接口的任何类都必须重写抽象的displayCount方法。现在,让我们创建一个实现此接口的类:

public class Implementation implements Interface {
    protected int count;
    public void setCount(int count) {
        this.count = count;
    }
    @Override
    public void displayCount() {
        System.out.printf("Implement count = %d%n", count);
    }
}

现在,让我们使用接口的多态:

public class PolyInterfaceExample {
    private void show(Interface face) {
        face.displayCount();
    }
    public void perform() {
        var implement = new Implementation();
        implement.setCount(42);
        show(implement);
    }
    public static void main(String[] args) {
        new PolyInterfaceExample().perform();
    }
}

在这个类中,我们向一个期望接口的方法传递了Implementation对象。当运行时,它产生了以下输出:

Implementation count = 42

实现名为Interface的任何类都可以传递给任何声明参数使用接口而不是类的任何方法。

多态是一个强大的工具,它允许你编写随时间演变的代码,但不需要更改使用具有相同接口或继承自相同父类的对象的现有代码。

理解类中的组合

当我们创建一个使用众多类的应用程序时,我们必须决定它们将如何相互交互。在面向对象编程术语中,一个类中的方法调用另一个类的方法被称为消息传递。尽管如此,大多数开发者描述这为调用方法,正如我所做的那样。对象如何发送这些消息或调用其他对象的方法是组合的内容。

对象之间有两种连接方式——关联聚合。让我们来讨论这些连接。

关联

在关联中,我们需要调用或发送消息的方法的对象引用是在调用对象外部创建的。让我们从一个我们想要调用的类开始:

public class Receiver {
    public void displayName(String name) {
        System.out.printf("%s%n", name);
    }
}

这是一个简单的函数,它有一个用于显示传递给它的字符串的方法。请注意,我们在这里关注的是概念,而不是类负责的任务。现在,让我们创建一个想要调用或发送消息给 Receiver 的类:

public class Association {

    private final Receiver receiveString;

    public Association(Receiver receiveString) {
        this.receiveString = receiveString;
    }
    public void sendMessage() {
        receiveString.displayName("Bob");
    }
}

在这个类中,我们声明了一个对 Receiver 类的引用。我们不打算在它被分配初始值时改变它,因此我们将其指定为 final。我们不是使用 new 创建这个对象的实例,而是期望这个对象在另一个类中创建,然后我们将那个引用传递给构造函数。换句话说,这个 Association 类不拥有 Receiver 的引用。

这里有一个例子,展示了在一个类中声明一个对象,然后将该对象传递给另一个类:

public class Composition {
    public void perform() {
        var receive = new Receiver();
        var associate = new Association(receive);
        associate.sendMessage();
    }
    public static void main(String[] args) {
        new Composition().perform();
    }
}

Composition 类的 perform 方法中,我们正在实例化一个 Receiver 类型的对象。随后,我们实例化一个 Association 类型的对象,将其构造函数传递了我们刚刚创建的 Receiver 对象的引用。

当需要将 Association 类型的对象进行垃圾回收时,如果该对象也被另一个对象使用,则 Receiver 对象保持不变。换句话说,如果 Receiver 对象在另一个对象的作用域内,即可见且有效,那么 Receiver 对象不会被垃圾回收。让我们看看如何重写 perform 方法:

    public void perform() {
        var receive = new Receiver();
        var associate = new Association(receive);
        associate.sendMessage();
        associate = null;
        associate.sendMessage(); // ERROR       
        receive.displayName("Ken");
    }

要显式地将对象移出作用域并使其可用于垃圾回收,你可以分配一个名为 null 的特殊值。这将设置该引用包含的地址为零,对象将可用于垃圾回收。如果我们尝试在分配 null 后调用 sendMessage,那么编译器将标记这为错误。如果我们删除错误行,最后一条在 Receiver 对象中调用 displayName 的行将工作,因为 Association 类没有拥有 Receiver 对象。所有权是指属于创建它的对象。Association 没有创建 Receiver。如果 Association 对象的 receive 引用在程序的其他地方没有作用域,它将进行垃圾回收。

聚合

在一个类中创建的对象属于或被该类拥有。当我们创建一个拥有类的对象时,该类中的所有其他对象都会在该类中实例化。当这个类超出作用域时,它所拥有的所有东西也会超出作用域。

下面是我们的使用聚合的程序:

public class Aggregation {

    private final Receiver receiveString;

    public Aggregation() {
        receiveString = new Receiver();
    }

    public void sendMessage() {
        receiveString.displayName("Bob");
    }
}

在这个聚合类中,构造函数中创建了一个Receiver类型的对象。它不是来自另一个对象,就像在关联中那样。这意味着聚合拥有Receiver对象,当聚合对象超出作用域时,它所实例化的Receiver对象也会超出作用域。

摘要

在本章中,我们完成了对 Java 中组织代码的构建块或概念的考察,这些概念我们在第五章“语言基础 – 类”中开始探讨。我们探讨了编写方法时需要考虑的方法和一系列问题。从那里,我们考察了继承,这是我们重用或共享代码的一种方式。

接口引入了合同或必须由实现接口的任何类编写的列表方法的概念。接下来是用于简化不可变对象创建的专用类类型record

继承和接口支持多态的概念。这允许创建期望超类或接口类实例的方法,但接收任何继承或扩展超类或实现接口的类的实例。

我们通过探讨如何将对象连接到对象来结束本章。组合意味着对象是在具有引用的对象之外创建的。引用必须通过构造函数或另一个方法传递给对象。聚合意味着我们需要使用的对象是在希望使用它的对象内部创建的。

接下来,我们将最终回顾 Java 语言的语法。

第七章:Java 语法和异常

在本章中,我们将从查看 Java 语言的语法开始。它可能看起来很奇怪,直到本章才查看语法。为了理解为什么,我必须告诉你一个秘密:你已经知道如何编码。这本书的受众就是这样的——你可以编程,但可能对 Java 的经验很少或没有。我毫不怀疑,你能够理解你迄今为止看到的每个代码示例中发生的事情。我们现在将正式化 Java 语法。

这里是我们将要讨论的主题:

  • 理解编码结构

  • 处理异常

到本章结束时,你将能够将 Java 代码组织成方法和类。Java 代码中的决策和迭代将得到介绍。当事情出错时,在许多情况下,可能需要离开导致错误的代码,执行额外的处理以解决问题或退出程序。这就是异常的作用。

技术要求

这里是运行本章示例所需的工具:

  • 安装了 Java 17

  • 文本编辑器

  • 安装了 Maven 3.8.6 或更高版本

你可以在 GitHub 仓库中找到本章的代码:github.com/PacktPublishing/Transitioning-to-Java/tree/chapter07

理解编码结构

当我们在任何语言中编写代码时,我们知道它必须以非常具体的方式进行组织。你从你所知道的任何语言中熟悉这个概念,所以我们只需要检查它们在 Java 中的编码方式。我们从代码块开始。

代码块

每种语言都有一种组织你编写的代码行的结构,这通常被称为beginend关键字。Java 使用开括号({)和闭括号(}),C、C++、C#和 JavaScript 也是如此。

在 Java 中,所有类和方法都必须有开括号和闭括号。块可以嵌套,正如我们在本节稍后讨论迭代和决策时将看到的。当涉及到变量时,块也起到另一个作用。这被称为变量的作用域。让我们通过以下示例来实际看看:

public class Blocks {
 2
 3     private int classScope;
 4     private static int staticVar;
 5    
 6     static {
 7        staticVar = 42;
 8        System.out.printf(
              "static block staticVar is %d%n",staticVar);
 9     }
10    
11     public Blocks() {
12        System.out.printf("constructor method block%n");
13     }
14    
15     public void perform() {
16        int methodScope;
17        
18        if (classScope > 0) {
19           int blockScope = 4;
20        }
21          
22        {
23           int classScope = 3;
24           this.classScope = classScope;
25        }
26     }
27  }

我们将详细讨论每一行。

第 1 行 声明了一个名为Blocks的类,并且在这一行上出现了一个开括号。C/C++程序员通常将开括号放在自己的行上,Java 对此也持开放态度。Java 的风格是将开括号放在命名块的同一行上。

第 3 行 声明了一个实例字段。我们知道这是因为它在类块内部声明,并且不是静态的。对于每个对象,将有一个唯一的classScope变量。因为这个字段在类块中,所以它对所有非静态方法都是可见的。它也可以在任何方法的任何内部块中使用。只有当从这个类实例化的对象超出作用域时,它才会超出作用域。

第 4 行声明了一个静态或类变量。这个变量将由类的所有实例共享。它在类的所有块中都是可见的。它不能是一个局部方法变量。静态变量只能声明在类块或作用域中。

第 6 行至第 9 行声明了一个静态块。这个块中的代码只会在创建这个对象的第一个实例时执行一次。你无法在这个块中声明字段。你无法与实例(非静态)变量交互,但你可以与类(静态)变量交互。你也可以调用静态方法,例如System.out.print或这个类中的任何静态方法。静态块的一个有趣特性是它们在构造函数之前执行。这就是为什么它们不能访问非静态变量和方法。这些非静态变量仅在构造函数执行后才是有效的,而不是在执行之前。

第 11 行至第 13 行只是一个构造函数。如果你向这个示例添加一个main方法,你将能够看到静态块总是先于构造函数执行。

第 15 行至第 26 行是一个名为perform的非静态方法块,它包含两个额外的块。在method块中,我们有一个名为methodScope的局部变量,它在方法和任何内部块中都是可见和可访问的。当方法的执行达到method块的闭合大括号时,这个变量将超出作用域。

第 18 行至第 20 行由一个if语句和一个随后执行的块组成。如果if语句为true,则执行这个块。在这个块中,我们声明了一个名为blockScope的变量。这个变量在遇到开括号并找到声明后进入作用域。当块结束时,这个变量将超出作用域。

第 22 行至第 25 行是另一个块。在这里,我们声明了一个与类作用域变量具有相同名称和类型的变量。当这种情况发生时,块版本的变量会隐藏任何在外部块(在这种情况下是类本身)中声明的变量。要访问类块变量,就像我们在讨论方法时看到的那样,我们使用this引用。如果你在块中创建更多的块,这实际上并不是一个好主意,你只能使用this来访问类级别的变量,并且任何与外部块中相同名称和类型的变量都变得不可访问。

接下来,让我们简要回顾一下 Java 中术语语句表达式的含义。

语句

在 Java 中,任何执行任务并以分号结束的代码行都是语句。以下是一些示例:

1  int x = 4;
2  printHeader();
3  d = Math.sqrt(aDoubleValue);

这些都是语句。第 1 行是一个声明语句,其中为整数变量分配了内存空间,并给它赋了一个值。第 2 行是对一个方法的调用。第 3 行使用了Math库中的平方根方法来计算一个结果,并将其赋给一个变量。

表达式

Java 中的表达式是指任何作为语句一部分返回结果的代码。结果可能来自赋值、一些简单的数学运算,或者来自另一个方法或 Java 构造的返回值,例如我们很快将要看到的switch表达式。

语句部分的例子中,我们可以看到第 1 行的语句包含一个将值赋给变量的表达式。第 2 行只是一个语句,因为没有值在改变。第 3 行Math.sqrt调用的返回值赋给一个变量。当我们给变量赋新值时,我们将其描述为改变其状态。改变变量状态的语句使用表达式来完成。

运算符

Java 的运算符家族与 C 语言以及大多数从 C/C++派生或基于它们构建的语言中的运算符非常相似。优先级规则得到尊重,括号内的表达式总是首先执行。所有标准逻辑运算符都存在。由于 Java 没有指针,因此处理指针的运算符,如地址运算符(&)和间接引用运算符(*),在这个语言中不存在。有一组运算符我想特别强调。

在 C/C++中,我们以两种方式之一表达组合多个布尔表达式的结果——逻辑AND或逻辑OR。它们分别表示为双与运算符(&&)和双或运算符(||)。它们使用短路计算,这意味着如果第一个比较中的条件验证或无效化语句,则不需要执行第二个比较。运算符两边的值必须是布尔值。以下是一个例子:

numberOfTrees > 10 && numberOfSquirrels > 20

存在一组可以执行相同任务但不进行短路计算的匹配运算符。这些是单个与运算符(&)和单个或运算符(|)。当与原始类型一起使用时,它们执行位运算。对于单个与运算符(&),每个值中必须有一个二进制 1 在相同的位置,该位置在新值中也将变为二进制 1。否则,新值中将放置一个二进制 0。对于单个或运算符(|),匹配的位必须有一个位是二进制 1。

在这个家族中还有一个值得提到的运算符,那就是撇号(^)。这是XOR运算符。当与原始类型一起使用时,新值仅在比较的两个值中有一个在相同位置有二进制 1 时才取二进制 1。否则,结果是 0。

在 Java 中,数值原始类型有一个基于它们在内存中的大小和允许值范围的层次结构——如下所示。我们之前在第四章中看到过,语言基础——数据类型变量

  1. byte

  2. char

  3. short

  4. int

  5. long

  6. float

  7. double

赋值语句有一个右侧和一个左侧,例如:

LHS = RHS

根据这个列表,你只能在左侧有一个比右侧类型值范围更大的类型。这意味着你可以写像这样的事情:

int intValue = 27;
double doubleValue = intValue;

这之所以有效,是因为从右侧的 int 到左侧的 double 的转换是无损的。在相反方向,如以下所示,将会出现错误,因为 double 的分数部分将会丢失:

double doubleValue = 23.76;
int intValue = doubleValue;

所有这些都导致类型转换运算符——括号加类型。括号也有其他用途,但在这里使用时,它变成了一个运算符。为了使前面的例子工作,你可以将 double 转换为 int,如下所示:

int intValue = (int) doubleValue;

这是一个有损转换,因为 double 值的分数部分被截断,没有四舍五入。最终在 intValue 中的值将是 23

还有一个运算符——箭头运算符 (->),我们将在检查现代 switch 和函数式编程时遇到。现在让我们继续前进,检查迭代,通常称为循环。

迭代

Java 为我们提供了两种迭代方法。第一种,我们现在将探讨,是经典的循环技术。我们将在下一章中检查如何使用流来遍历集合中的每个成员。

for 循环

让我们从 C 风格的 for 循环开始。这是一个迭代条件在循环括号第一行的循环:

        For (int x = 0; x < 10; ++x) {
            System.out.printf("Value of x is %d%n", x);
        }

整个 for 循环被视为一个块。这意味着当进入 for 循环时创建一个 x 变量,当循环结束时它将超出作用域。如果你需要在循环结束后访问 x,那么请在循环之前声明它,如下所示:

        int x;
        for (x = 0; x < 10; ++x, doMethod()) {
            System.out.printf("Value of x is %d%n", x);
        }

经典循环中有两个特殊语句可用:

  • break 语句将在循环完成迭代之前结束循环

  • continue 语句结束当前迭代并继续到下一个迭代

foreach 循环

for 循环还有一种风格,称为 foreach 循环。它基于这样一个事实:数组或集合中的每个元素都将被处理。我们将在下一章查看集合时检查 foreach 循环。

whiledo/while 循环

当编写 for 循环时,最大迭代次数立即可知。对于我们的下一个循环,whiledo/while,迭代次数无法预测,因为它将取决于循环体内的变化。

在使用 whiledo/while 时,循环依赖于循环块内部发生的事情,这可能会改变正在逻辑上检查的变量。以下是一个具有不可预测结束的例子:

        var rand = new Random();
        int x = rand.nextInt(12);
        while (x < 10) {
            x = rand.nextInt(12);
            System.out.printf("x = %d%n", x);
        }

第一行实例化了java.util.Random对象。接下来,我们实例化一个将作为逻辑测试基础的变量,并给它一个随机值。调用rand.nextInt(12)方法将返回一个介于 0 到 11(包含)之间的 12 个可能整数的值。这表明while循环可以迭代零次或多次,但无法预测迭代次数。我们在while语句的括号中表达逻辑测试。在循环内部,我们必须执行一些改变x循环变量状态的行动。在while块中你可以编写的内容没有任何限制。

while循环的一种变体是do/while循环。这个循环保证至少迭代一次,因为逻辑测试发生在循环的末尾。你可以在以下示例中看到它的作用:

        Var rand = new Random();
        int x;
        do {
            x = rand.nextInt(12);
            System.out.printf("x = %d%n", x);
        } while (x < 10);

注意,与while循环不同,不需要初始化循环变量,因为它将在循环内部获得第一个值。

决策

Java 中的决策语法支持 C/C++和其他语言中可用的三种结构。它们是if/else语句、switch语句和三元运算符。

一个简单的if语句不需要else块:

if (age >= 65) {
    designation = "Senior";
}

你可以使用else创建一个“要么/要么”的表达式:

if (age >= 65) {
    designation = "Senior";
} else {
    designation = "Adult";
}

你可以通过使用三元运算符来简化这个例子,它使用问号和冒号:

String designation = (age >= 65) ? "Senior" : "Adult";

它从逻辑测试开始。虽然在这种情况下使用括号是可选的,但我强烈建议使用它们。在问号和冒号两边是表达式将返回的值。你也可以调用一个返回适当类型值的函数。

如果你需要定义一个测试值范围的测试,你可以使用if/else/if语法:

        if (age < 12) {
            designation = "child";
        } else if (age < 18) {
            designation = "teenager";
        } else if (age < 25) {
            designation = "young adult";
        } else if (age < 65) {
            designation = "adult";
        } else {
            designation = "senior";
        }

接下来是 C 风格的switch语句。截至 Java 17,C 风格switch的语法可以被认为是过时的。由于新的switch版本是最近添加的,因此理解 C 风格版本很重要。switch是一种比较switch变量与以下内容的逻辑结构:

  • 一个字面整数

  • 一个整数常量变量

  • 一个字面字符串

这里是一个用于确定邮政费用的switch语句,这取决于邮件发送到的区域:

        double postage;
        int zone = 3;
        switch (zone) {
            case 1:
                postage = 2.25;
                break;
            case 2:
                postage = 4.50;
                break;
            case 3:
                postage = 7.75;
                break;
            default:
                postage = 10.00;
        }

以冒号结尾的行被称为条件标签。如果zone变量的值与字面值匹配,则执行匹配的 case 之后的代码。当找到与 case 的匹配时,所有后续的 case 都变为true,无论 case 的值如何。因此,每个 case 的末尾都有一个break语句。你可以通过故意不在每个地方使用break来模拟对值范围的测试,如下面的代码片段所示:

        String continent;
        String country = "Japan";

        switch (country) {
            case "UK":
            case "France":
            case "Germany":
                continent = "Europe";
                break;
            case "Canada":
            case "USA":
            case "Mexico":
                continent = "North America";
                break;
            default:
                continent = "Not found";
        }

截至 Java 14,引入了两种新的 switch 语法。这些是新的 switch 表达式和新的 switch 语句。这将是第一次我们看到新的箭头操作符。以下是 switch 的表达式版本:

        postage = switch (zone) {
            case 1 -> 2.25;
            case 2 -> 4.50;
            case 3 -> 7.75;
            default -> 10.00;
        };

break 语句已经不存在了,因为任何匹配都将结束 switch。为了匹配多个项目中的一个,我们可以使用逗号操作符来创建一个列表。箭头操作符 (->) 指向将被分配给 continent 的值:

        continent = switch (country) {
            case "UK", "France", "Germany" -> "Europe";
            case "Canada", "USA", "Mexico" -> "North America";
            default -> "Not found";
        };

switch 表达式不同,switch 语句不返回值,但匹配的 case 执行一些操作,例如调用一个方法:

        switch (continent) {
           case "Europe":
                showEuropeMap();
                break;
            case "North America":
                showNorthAmericaMap();
                break;
            default:
                showNotFound();
        }

下面是新的 switch 语句:

        switch (continent) {
            case "Europe" -> showEuropeMap();
            case "North America" -> showNorthAmericaMap();
            default -> showNotFound();
        }

到目前为止,还有一种类型的 switch,它只作为 Java 19 的预览功能提供,这就是模式匹配 switch。作为一个预览功能,它可能在其成为正式语言的一部分或甚至从语言中删除时发生变化。我认为这是一个令人兴奋的新类型的 switch——你可以在下面看到它的实际应用:

        String designation;
        Object value = 4;
        designation = switch (value) {
            case Integer I when i < 12 ->
               "child";
            case Integer i when i < 18 ->
               "teenage";
            case Integer i when i < 25 ->
               "young adult";
            case Integer i when i < 65 ->
               "adult";
            default ->
               "senior";
        };

模式匹配仅适用于对象,而不是原始类型,除非它们是 Java 的原始包装类之一。当我们把值 4 赋给类型为 Object 的变量时,编译器会将 int 原始类型自动装箱成一个 Integer 类型的对象。每个 case 语句使用 case 中的类类型而不是字面值。它还允许你分配一个标识符——在我们的例子中,是 i。标识符之后是新 when 关键字,之后你可以写任何有效的布尔表达式。只有当类型匹配并且 when 关键字之后的逻辑表达式为 true 时,case 才为 true。这应该会减少你程序中 if/else if/ else if/ else 结构的数量。你需要安装 Java 19 来实验这个预览功能。

在 Java 处理决策的问题解决之后,我们现在可以看看 Java 如何处理异常。

处理异常

在 Java 中,当事情出错时,它们可以被分类为错误或异常。错误是一个无法恢复的问题。异常是一个可以在你的代码中检测到的错误,你可能会从中恢复。例如,一个永远不会结束的递归将导致 StackOverflowError 类型的错误。将 Bob 字符串转换为整数将导致 NumberFormatException 异常。

下面是主要异常类的图示:

图 7.1 – 异常层次结构

图 7.1 – 异常层次结构

异常是命名异常类型的类的对象。在图中,你可以看到在层次结构的根处是 Throwable 类。从 Throwable,我们有两个子类:ErrorExceptionError 的子类是以程序执行期间可能发生的错误命名的。这些是通常无法恢复的错误,应该导致程序结束。

由于可能从异常中恢复,而不是从错误中恢复,这些类型的问题属于Exception分支。这个分支分为两个类别:try/catch。未能使用try/catch块将生成编译器错误。你必须解决这个问题;否则,你无法编译你的代码。

未检查的异常不需要try/catch块。编译器会愉快地编译可能生成未检查异常但不在try/catch块中的代码。如果你决定不处理未检查的异常,你的程序将结束。

让我们看看可能同时具有这两种异常的代码:

public class FileException {
2    
3      public void doCheckedException() {
4          List<String> fileContents = null;
5          Path path2 = Paths.get("c:/temp/textfile.tx"");
6          try {
7              fileContents = Files.readAllLines(path2);
8                 System.out.printf("%s%", fileContents);
9          } catch (NoSuchFileException ex) {
10              ex.printStackTrace();
11         } catch(IOException ex) {
12              ex.printStackTrace();
13         }
14      }
15
16      public void doUncheckedException() {
17          int dividend = 8;
18          int divisor = 0;
19          int result = dividend / divisor;
20          System.out.printf("%d%", result);
21      }
22    
23      public void perform() {
24          checkedException();
25          uncheckedException();
26      }
27
28      public static void main(String[] args) {
29          new FileException().perform();
30      }
31  }

让我们回顾一下重要的代码行。

第 3 行是第一个包含可能抛出检查异常的代码的方法。

第 4 行声明了一个List变量并将其设置为null,这设置了List引用为零。局部变量未初始化,因此它们可能已经根据引用在内存中的位置包含了一个值。如果你没有正确分配List引用,通常使用null,那么在第 11 行将会有编译器错误。这将结束程序。

第 5 行定义了一个文件路径。Paths.get()方法不会验证文件是否存在,因此如果文件不存在,不会抛出异常。

第 6 行是我们try块的开始,任何可能抛出检查异常的代码都写在这里。你可以在try块中有不抛出异常的代码行。

第 7 行,使用Files.readAllLines(),文件中的每一行都被添加到List变量中。这就是无效的文件Path对象可能导致名为IOException的检查异常的地方。

第 8 行try块的末尾和第一个catch块的开始。catch块接受一个参数,即当在try块中的代码执行时检测到异常时,JVM 创建的Exception对象的引用。NoSuchFileException异常是IOException的子类。子类异常必须在超类异常之前处理。

第 9 行catch块的主体,你可以在这里编写代码来处理错误,这样程序就不需要结束。所有Exception对象都有一个显示堆栈跟踪的方法。在生产环境中,你不会以这种方式处理错误。在我们下一章讨论日志记录时,我们将看到一种最佳实践方法。

第 10 行,我们有一个第二个catch块。这是IOException类。读取文件的代码可能会抛出NoSuchFileException异常或IOException异常。一些程序员可能只捕获IOException。由于NoSuchFileExceptionIOException的子类,多态性允许你在期望IOException的一个catch块中捕获这两个异常。我的偏好是在可能的情况下使用特定的异常类。

就像在第 9 行一样,这里在第 11 行,如果在这里捕获到这个异常,我们只是打印堆栈跟踪。

第 13 行,如果 fileContents 变量既没有被分配 null,也没有从调用 File.readAllLines 方法分配引用,则可能会发生编译错误。

在开发过程中,在 Exception 对象中使用 printStackTrace 方法可能很有用。当我们转向生产代码时,我们永远不应该调用这个方法。在下一章中,我们将看到如何使用日志来保留这些信息,而不会出现在控制台中。

第 16 行是一个将执行除以零的方法。这将生成一个未检查的 ArithmeticException 异常。因此,你不需要使用 try/catch 块。由于代码正在除以零,所以会抛出异常,如果这是一个控制台应用程序,则会显示堆栈跟踪,并且程序将结束。GUI 程序没有地方显示堆栈跟踪,所以它看起来会突然结束。

堆栈跟踪

当你的程序由于异常而结束或在捕获异常后,你可以显示堆栈跟踪。堆栈跟踪将出现在控制台窗口中。它是一系列代码行,这些代码行导致了异常,在捕获之前或程序结束后。以下是当 Path 对象中的文件名找不到时 doCheckedException 方法的堆栈跟踪:

图 7.2 – 当发生异常时显式显示的堆栈跟踪

图 7.2 – 当发生异常时显式显示的堆栈跟踪

如你所见,异常已经穿越了几个方法,其中许多发生在 Java 库中而不是你的代码中。为了使用这些信息来定位可能的有问题的源代码,从跟踪的开始处遍历列表,找到来自你代码的第一个条目:

at com.kenfogel.FileException.doCheckedException(FileException.java:17)

这一行告诉我们异常发生在第 17 行的 doCheckedException 方法中。

结束程序

在某些情况下,你可能希望在捕获异常后结束程序。你可以使用 System.exit(n) 来结束大多数程序,其中 n 是你分配给这个错误的数字:

        } catch(IOException ex) {
            ex.printStackTrace();
            System.exit(12);
        }

数字——在这个例子中是 12——映射到一个必须结束程序的已知错误条件。在这里,在显示堆栈跟踪后,程序结束。

抛出和抛出语句

如果在方法中抛出异常,Java 会查找 catch 块。如果在抛出异常的方法中没有 catch 块,那么 Java 会查看调用该方法的那个方法。这个过程会一直持续到 main 方法,在那个点上,程序结束。有些情况下,你会在异常发生的地方捕获异常,但同时又想将其重新抛给之前有 catch 块的任何方法。在这个 catch 块中,我们将堆栈跟踪显示到控制台,然后重新抛出异常:

        } catch(IOException ex) {
            ex.printStackTrace();
            throw ex;
        }

要能够重新抛出异常,我们必须向该方法添加一个throws子句:

    public void doCheckedException() throws IOException {

当你使用throws时,调用doCheckedException的任何方法都必须像下面这样在try/catch块中执行:

        try {
            checkedException();
        } catch(IOException ex) {
            ex.printStackTrace();
        }

我们还可以使用throws子句来定义一个方法有一个检查异常,但该方法不会处理它。这意味着我们可以在没有try/catch块的情况下调用checkedException(),因为该方法表明它将被抛出到调用此方法的另一个方法的try/catch块中。

finally

可以有一个用于处理异常的第三个块,称为finally块。在这个块中,你可以编写任何你希望在抛出异常或不抛出异常时执行的代码。在这个例子中,无论是否抛出异常,都会显示一条消息:

public void doFinallyExample(int dividend, int divisor) {
    int result = 0;
    try {
        result = dividend / divisor;
    } catch (ArithmeticException ex) {
        ex.printStackTrace();
    } finally {
        System.out.printf(
            "Finally block is always executed%n");
    }
}

如果除数有效——不是零——则执行finally块中的代码。如果除数无效——是零——则执行catch块中的代码,然后执行finally块中的代码。

注意

不要将finallyfinalize方法混淆。finally块是有用的。finalize方法没有用,不应该使用。

创建自己的异常类

异常类的名称是导致抛出异常的问题的描述。你可以创建自己的异常,然后在检测到代码中的严重问题时抛出自定义异常。第一步是创建一个异常类,如下所示:

public class NegativeNumberException extends Exception{}

这是一个检查异常类。如果你不希望它被检查,那么扩展RuntimeException。你可以在Exception中添加额外的方法或覆盖方法,但这不是必要的。你创建自定义异常是为了定义你程序中独特的异常,这些异常在现有的异常类族中描述不足。

现在,我们需要一些会抛出这个异常的代码:

    public void doCustomException(int value) 
                     throws NegativeNumberException {
        if (value < 0) {
            throw new NegativeNumberException();
        }
        System.out.printf("value = %d%n", value);
    }

现在,我们需要代码来调用这个方法。因为我们调用的方法有一个throws子句,我们必须将其视为检查异常,并且必须使用try/catch块:

    public void makeCustomException() {
        try {
            doCustomException(-1);
        } catch (NegativeNumberException ex) {
            ex.printStackTrace();
        }
    }

这是代码执行时发生的堆栈跟踪:

com.kenfogel.NegativeNumberException
  at com.kenfogel.FileException.doCustomException(FileException.java:39)
  at com.kenfogel.FileException.makeCustomException(FileException.java:46)
  at com.kenfogel.FileException.perform(FileException.java:69)
  at com.kenfogel.FileException.main(FileException.java:73)

你可以看到我们创建的异常类就是堆栈跟踪中报告的异常。

关于异常,还有一个问题需要指出。许多语言,如 C#和 JavaScript,没有检查异常。是否捕获这些异常的决定完全取决于开发者。

在 JVM 中抛出异常是一个缓慢的过程。你可能不会注意到这一点,但如果它经常发生,将会导致程序执行速度变慢。因此,永远不要将异常处理作为程序逻辑的一部分。异常是严重的问题,在大多数情况下,意味着一个错误或缺陷,可能会影响程序的输出。如果你可以在代码中检测到一个错误,通常是通过使用if语句测试一个值,你应该使用你编写的代码来处理它,而不是期望或抛出异常。

摘要

在本章中,我们学习了 Java 代码是如何根据开闭大括号组织成块的。这些块可以是一个完整的类,类中的每个方法,以及迭代和决策语句的主体。从那里,我们学习了如何将代码行分类为语句或表达式。

操作符是下一个主题。我们回顾了数学和逻辑操作符以及它们的组合方式。还展示了用于将一种类型转换为另一种类型的 cast 操作符。

接下来,我们讨论了两种最常见的编码结构:循环和决策。我们介绍了经典的 for 循环,这是一种在循环开始之前已知迭代次数的循环。第二种循环风格是 whiledo/while 循环。这些循环不知道会有多少次迭代,这由重复的代码块确定。

接下来是决策。我们讨论了 ifif/else 语句。这些语句实际上与任何起源于 C 语言的编程语言中的语句相同。我们讨论的第二种决策结构是 switch 语句。与 if 一样,它几乎与其 C 语言根源没有变化。好消息是这种风格的 switch 已经通过三个新版本得到了增强。

我们之前讨论的最后一个主题是异常。我们探讨了异常类和对象是什么,以及它们属于哪一类,是检查型还是非检查型。我们还介绍了如何处理异常,而不是让程序直接结束。最后,我们还讨论了如何创建我们自己的命名异常以及如何使用它们。

到目前为止,你应该已经能够舒适地阅读 Java 代码了。在我们下一章中,我们将探讨语言的附加功能以及如何使用它们来编写更干净的代码。

进一步阅读

第八章:数组、集合、泛型、函数和流

到目前为止,我们使用变量来表示原始数据类型和引用数据类型的一个实例。然而,在现实世界中,更频繁的需求是处理和加工多个数据元素。在本章中,我们将探讨管理多个元素的各种选项。在检查 Java 中用于此目的的选项时,我们将看到我们如何提高类型安全性。

为了更有效地处理多个元素,我们将检查流——当与函数结合使用时,它是传统循环的替代品。

我们将涵盖以下主题:

  • 理解数组数据结构

  • 理解集合框架

  • 使用泛型实现和接口

  • 理解集合框架中的泛型

  • 使用泛型实现和接口

  • 理解集合框架的映射结构

  • 理解 Java 中的函数

  • 在集合中使用流

完成本章后,你将能够以数组或集合的形式处理多个数据实例,并应用Stream库中的算法。

技术要求

这里是运行本章示例所需的工具:

  • 安装 Java 17

  • 文本编辑器

  • 安装 Maven 3.8.6 或更高版本

本章的示例代码可在github.com/PacktPublishing/Transitioning-to-Java/tree/chapter08找到。

理解数组数据结构

与大多数语言一样,Java 有一个内置的数组数据结构,不需要任何导入或外部库。因此,数组的行为与其他语言中的数组类似。唯一的区别是,要实例化一个数组,你需要new关键字。以下是声明一个包含 10 个int类型元素的数组的两种方式:

        int[] quantities01 = new int[10];
        int quantities02[] = new int[10];

区别在于空方括号放置在左侧的位置。在类型之后放置它们被认为是 Java 的方式。在标识符之后放置被认为是 C 语言的方式。任何一种语法都是可以的。

在大多数编程语言中,数字可以是序数或基数。当我们实例化时声明的数组长度是一个基数——或者说计数数字。在之前的例子中,长度一直是 10。序数表示结构中的位置,如数组中的位置。在大多数编程语言中,序数从零开始。当我们声明长度为 10 的基数数组时,序数位置从 0 到 9,而不是从 1 到 10。

数组长度固定;它们不能被扩展或收缩。数组中的每个位置都准备好使用。你可以在将值赋给第一个位置之前,先将其赋给最后一个位置。

你可以延迟实例化数组,如下所示:

int[] quantities01;
…
quantities01 = new int[10];

数组将数组的值存储在连续的内存块中。这告诉我们,数组将占用 10 个元素乘以每个 int 的 4 个字节,总共 40 个字节,以及 Java 中对象的必要开销。数组的长度是这部分开销的一部分。

对象数组由引用数组组成。例如,你可以创建一个包含四个字符串的数组,如下所示:

String[] stuff = new String[4];

在这种情况下,数组将为每个 String 对象的引用占用 4 个字节,以及通常的数组对象开销。字符串本身在内存中以 null 的形式存储,直到你为数组分配一个有效的引用:

String myThing = "Moose";
stuff[0] = myThing;

当在数组中使用引用时,我们在数据结构中存储的是引用而不是对象。只有原始数据类型可以直接存储在数组中。

从这里,我们使用索引来读取和写入数组。要获取数组的长度,我们使用最终的常量变量 length

        System.out.printf("Length: %d%n", stuff.length);

要使用 for 循环遍历数组中的每个元素,你会使用以下代码:

        stuff[0] = "Java";
        stuff[1] = "Python";
        stuff[2] = "JavaScript";
        stuff[3] = "C#";
        for (int i = 0; i < stuff.length; ++i) {
            System.out.printf("Stuff %d = %s%n", i, stuff[i]);
        }

Java 还有一个增强的 for 循环,用于访问数组中的每个元素。索引值不再可用:

for(String s : stuff) {
    System.out.printf("Stuff %s%n", s);
}

最后一点需要注意的是:Java 有一个用于在数组上执行一系列操作的库,称为 Arrays 库。这个类包含用于排序、搜索以及从一个数组创建列表(集合之一)的静态方法。我们将在后面的部分看到将数组转换为列表的示例,在集合中使用流 *。

你应该已经熟悉使用数组了。你可以读取和写入任何有效的索引元素。如果你使用一个超出范围的无效索引,Java 将会抛出 ArrayIndexOutOfBoundsException 异常。现在,让我们来看看集合框架。

理解集合框架

一旦创建了数组,它的长度就不能增加或减少。这意味着在实例化数组之前,你必须知道你需要的确切元素数量。你可以使用变量来声明数组,但一旦创建,它就不能调整大小。看看以下示例:

        int numberOfCats = 6;
        int[] cats = new int[numberOfCats];

这就是集合发挥作用的地方。这些是动态数据结构,可以随着元素的添加而增加大小。你也可以删除元素,尽管减少大小并不总是可用,如果可以减少,那么你必须调用适当的方法。

集合框架分为实现和接口。一个实现可能支持多个接口。虽然实现可以有一系列丰富的方法,但使用接口可以让你限制对集合的操作。

集合框架类分为两类。有顺序集合,它们保留元素添加的顺序。然后,有映射集合,其中元素以数据对的形式存储。第一个通常是对象的字段,称为键,而第二个是对对象本身的引用,称为值。这些集合根据键组织自己。

这些类的所有成员管理的数据类型默认为Object。这意味着你可以将任何对象存储在集合中,因为所有类都扩展了Object,多态性允许你在需要超类的地方使用子类。这种方法的缺点是,你可能会有一个包含苹果和橙子的集合。在语言中引入泛型之前,避免类型混合是开发者的责任。

让我们更仔细地看看顺序结构。

使用顺序实现和接口

让我们从实现开始。这些是管理数据以多种方式进行的类。它们是ArrayListLinkedListArrayDeque

ArrayList

这是一个动态的类似数组的结构。作为一个类,你必须使用方法而不是下标来访问特定元素。你将在列表的末尾添加元素。一旦添加了元素,你可以从中读取、写入、搜索特定值,以及从特定位置或匹配特定值的元素中删除元素。

你可以带或不带初始容量实例化ArrayList类。如果你没有指定容量,则默认为 10。如果你事先知道你需要多少元素,那么在实例化ArrayList类时包含该值。ArrayList类的自动调整大小涉及开销,如果你知道确切的大小,则可以避免这种开销。在任何情况下,你都不能在添加元素之前访问元素。随着元素的添加,大小会增加。你可以访问你添加的任何元素,但你不能访问最后添加的元素和随后的未使用容量之间的任何位置。

LinkedList

这种结构在节点对象中存储数据,每个节点都知道它之前和之后的内容。从表面上看,这似乎非常高效,因为你需要时才创建节点。链表的缺点是它不支持随机访问。在ArrayList中,你可以通过使用表示其位置的整数来访问任何元素,就像数组一样,使用方法而不是方括号。这种访问是直接的。在LinkedList类中,你只能直接访问第一个和最后一个元素。要访问任何其他元素,你必须从开始或结束处开始,然后跟随前向或后向引用到后续节点。这使得访问元素的速度远慢于ArrayList

我向学生讲解链表,因为它们非常适合黑板图。我们将很快查看的Map结构基于链表的变体。让我以 Joshua Bloch 关于他编写的 Java LinkedList类的一条推文结束:

图 8.1 – 著名的 LinkedList 推文

图 8.1 – 著名的 LinkedList 推文

ArrayDeque

ArrayDeque类与ArrayList类似,因为它是一个动态结构,以类似数组的方式存储元素。与ArrayList不同,它不支持直接访问。相反,它优化了在开始处插入或删除元素(DequeQueueStack接口)。在 Java 1.6 引入ArrayDeque类之前,你使用LinkedList类作为这些接口的实现。ArrayDeque类优于LinkedList类。

这不是一个完整的列表。例如,有一个Stack类,但使用带有Deque接口的ArrayDeque类将优于Stack类。第二个问题与线程安全相关。这三个实现都不是线程安全的。框架中有专门版本的实现,特别是当线程必须共享对数据结构的访问时。

你可以实现这些类中的任何一个,但被认为是一个糟糕的选择。每个实现都有许多方法来支持以多种方式使用结构。当你使用 Java 集合类时,你希望使用最小的接口来完成你的任务,而不是允许访问实现中的每个方法。让我们看看这些接口。

集合接口

下面是一个最常见的接口的图示:

图 8.2 – 集合接口

图 8.2 – 集合接口

每个框代表一个集合实现可能支持或不支持的接口。Collection是超接口。任何实现其下接口的类也必须实现Collection

最常见的接口是List。这最接近数组。SetSortedSet是确保元素不会出现多次的接口。Queue是一个 FIFO 结构。你只能向结构的末尾添加元素,也只能从结构的开头移除元素。Deque是一个支持 LIFO 的结构。Deque的独特之处在于你可以从两端添加或删除。QueueDeque都不允许通过索引访问。

如何声明一个集合

如我们已在第六章中讨论的,“方法、接口、记录及其关系”,你使用接口类来定义类必须实现的方法。对于顺序集合最广泛使用的接口是List。我们现在可以声明一个只能使用List接口中显示的方法的数据结构,而不能使用其他方法:

 List moreStuff = new ArrayList();

在我们查看更多接口之前,是时候看看泛型的概念以及它们如何与集合接口相关联了。我们需要现在就看看这个问题,因为虽然前面的代码行是可执行的,但很少会有 Java 开发者这样写。

在集合框架中理解泛型

正如所指出的,集合框架中的默认类是为了仅管理类型为Object的对象的引用而设计的。多态允许在实现这些类中的任何类中使用任何子类。这种方法的缺点是不安全类型。看看这个代码片段:

        int numberOfApples = 9;
        String orange = "Valencia";

        List stuff = new ArrayList();
        stuff.add(numberOfApples);
        stuff.add(orange);

        System.out.printf("Stuff: %s%n", stuff);

此代码首先声明了两个变量。第一个是带有numberOfApples标识符的int类型。集合不能包含原始数据类型,所以如果原始数据类型是int类型,则需要一个Integer类型的对象。Java 会为你执行从原始类型到对象的这种转换。第二行创建了一个String对象。

接下来是创建一个类型为ArrayList的对象,但其接口仅限于List接口类允许的内容。现在,我们可以将IntegerString对象添加到集合中。最后一行显示了List的内容,因为它的toString()方法创建了一个包含所有成员的String对象。这导致了一个表达,即不要把苹果和橘子混在一起。集合必须为单一类型。虽然集合的默认语法不限制可以添加的内容,但使用泛型符号将这样做。

让我们看看之前代码的一个新变体:

        int numberOfApples = 9;
        String orange1 = "Valencia";
        String orange2 = "Navel";

        List<String> stuff = new ArrayList<>();
        stuff.add(orange1);
        stuff.add(orange2);
        stuff.add(numberOfApples);

        System.out.printf("Stuff: %s%n", stuff);

在这个例子中,我们保留了int类型,然后创建了两个字符串。现在List的声明中包含了尖括号。括号内是你想要限制List包含的类类型。在这个例子中,类是String。虽然我们必须在左侧显示类类型,但右侧可以只保留空尖括号,因为这两种类类型永远不会不同。

接下来的几行将对象添加到List中。前两个将正常工作,但第三个,当我们尝试添加一个Integer类型的对象时,将生成一个异常:

java.lang.RuntimeException: Uncompilable code - incompatible types: java.lang.Integer cannot be converted to java.lang.String

Java 将不再允许你把苹果和橘子混在一起。这个确保添加到集合中的所有对象都是同一类型的测试只发生在编译时。这意味着如果这个操作只在运行时发生,则可以添加不同的对象类型。这可能会发生在多个进程在 JVM 中运行,并且一个进程调用另一个进程中的方法时。

我们现在将回到集合,并且只从现在开始使用泛型语法。

使用泛型实现和接口

正如我们刚刚看到的,创建List的最佳实践将是这个:

List<String> moreStuff = new ArrayList<>();

任何有效的类类型都可以使用。一旦我们在集合中有元素,我们就可以使用get方法和下标来访问它们:

        String orange1 = "Valencia";
        String orange2 = "Navel";

        List<String> stuff = new ArrayList<>();
        stuff.add(orange1);
        stuff.add(orange2);

        System.out.printf("Stuff: %s%n", stuff.get(0));
        System.out.printf("Stuff: %s%n", stuff.get(1));

在最后两行中,我们正在引用列表中的特定位置。要更改特定位置存储的对象,我们使用set方法:

        stuff.set(0, "Blood Orange");

接口和实现类都支持允许您确定特定对象是否包含在集合中的方法。因此,您必须重写从Object继承的equals方法。某些集合方法需要哈希值,因此您的类必须有一个hashCode方法。

您还可以对集合进行排序。为此,您存储的对象的类类型必须实现Comparable接口。此接口要求您编写一个名为compareTo的方法,该方法返回一个负数、零或正数。以下是实现compareTo的类的片段:

public class ComparableClass implements 
                     Comparable<ComparableClass>{
    private final int value;
    public ComparableClass(int initialValue) {
        value = initialValue;
    }
    @Override
    public int compareTo(ComparableClass o) {
        return value – o.value;        
    }
}

此类只有一个字段,由构造函数初始化。它使用泛型表示法实现Comparable接口,表示我们只能将此对象与同一类的对象进行比较。

由于我们正在实现Comparable接口,因此必须返回compareTo方法的compareTo方法:

  • 如果当前比较对象的值大于被比较对象的值,则返回一个正整数

  • 如果当前比较对象的值等于被比较对象的值,则返回 0

  • 如果当前比较对象的值小于被比较对象的值,则返回一个负整数

您可能会想知道,当字段是私有的时,我们如何使用点符号访问传递给compareToComparableClass的值。这是可能的,因为 Java 允许同一类的对象访问同一类的另一个实例的私有成员。以下是测试此功能的类:

public class ComparableTest {
    private final ComparableClass comparable01;
    private final ComparableClass comparable02;

    public ComparableTest(int value1, int value2) {
        comparable01 = new ComparableClass(value1);
        comparable02 = new ComparableClass(value2);
    }
    public void perform() {
        System.out.printf("comparable01 to comparable02 %d%n", 
                comparable01.compareTo(comparable02));
        System.out.printf("comparable02 to comparable01 %d%n", 
                comparable02.compareTo(comparable01));
    }
    public static void main(String[] args) {
        ComparableTest examples = new ComparableTest(12, 2);
        examples.perform();
    }
}

perform方法中,我们正在显示调用compareTo方法的结果。现在让我们创建一个对象List并对其进行排序。对ComparableClass进行了一些小的修改。已添加一个返回类中存储的值的方法:

    public int getValue() {
        return value;
    }

现在,我们有一个创建 10 个ComparableClass对象、将它们放入List并排序的类:

public class ComparableSorting {
    private final List<ComparableClass> comparableClasses;

这里是创建一个类型为ArrayList的对象的构造函数,该对象将仅限于使用List接口的方法:

    public ComparableSorting() {
        comparableClasses = new ArrayList<>();
    }

注意,ArrayList后面的尖括号是空的。当我们声明comparableClasses时,我们将List声明为包含ComparableClass对象。没有必要重复此操作。

以下方法创建了 10 个ComparableClass实例,在将它们添加到List时,用随机整数初始化它们。每个值也会在控制台上显示,这样我们就可以看到分配时的原始值:

    private void fillList() {
        Random rand = new Random();
        int upperBound = 25;
        System.out.printf("Unsorted:%n");
        for(int i = 0; i < 10; ++i) {
            comparableClasses.add(
                new ComparableClass(rand.nextInt(upperBound)));
        }
        System.out.printf("%n");
    }

此方法显示List中每个对象的值:

    private void displayList() {
        for(int i = 0; i < 10; ++i) {
            System.out.printf(
                "%s ", comparableClasses.get(i).getValue());
        }
        System.out.printf("%n");
    }

现在,让我们填充列表,显示它,对其进行排序,然后再显示一次:

    public void perform() {
        fillList();
        displayList();

Collections类包含一系列静态方法,可以应用于实现Collection接口的对象。其中一个是Collections.sort方法。它改变输入而不是返回一个新值:

        Collections.sort(comparableClasses);
        displayList();
    }    
    public static void main(String[] args) {
        ComparableSorting examples = new ComparableSorting();
        examples.perform();
    }
}

我们到目前为止看到的是如何使用顺序集合。也需要特殊接口如Comparable的需求也得到了强调。现在让我们看看有序集合。

理解集合框架的映射结构

第二组集合是映射集合。映射是一种数据结构,你可以通过一对值将元素添加到映射中。第一个值是键。这是一个对象的引用,根据映射的类型,它要么实现了Comparable接口——如我们在上一节中看到的,要么重写了hashCodeequals方法。如果键是原始类型,那么我们将其声明为其包装类,Java 将管理必要的从原始类型到包装类型以及从包装类型到原始类型的转换。第二个是值——存储在映射中的对象的引用。这个类不需要实现Comparable接口。

Java 中有三种映射实现,我们现在将介绍。

HashMap

在 Java 和大多数其他语言中可用的所有数据类型中,性能最快的是整数。整数的大小与 CPU 的字长相同。JVM 是一个 32 位或 4 字节的机器。即使是 64 位的 Java 也只是在模拟 32 位机器。这就是哈希码的作用所在。

与所有映射结构一样,条目由两个组件组成。第一个是键,第二个是值。使HashMap特殊的是键值的哈希码决定了它将存储的位置。其底层结构是一个数组,数组的每个位置都是一个桶。通过使用如取模等算术运算,可以从键的哈希码中确定数组中的索引。

哈希码不是唯一的。这意味着两个或多个键可能生成相同的哈希码。在这种情况下,它们也将想要使用相同的索引,桶将变成一个桶的单链表。如果键的数量超过八个,那么链表将转换为平衡二叉树。在列表中搜索键时,使用equals方法测试每个桶以找到值。

当你必须收集能够快速从结构中检索的数据时,请使用哈希表。没有定义的顺序。你放入哈希表中的第一个项目可能是桶数组中的第七个元素。这也意味着放入结构中的元素的顺序无法确定。

要在一个给定的键的HashMap对象中查找值,你可以使用get方法。此方法将键作为参数,如果找到则返回值,如果没有找到则返回null。让我们看看一个例子。

首先,我们使用Map接口创建一个HashMap对象:

        Map<Integer, Integer> hashMap = new HashMap<>();

现在,我们可以使用带有两个参数的put方法将数据放入HashMap对象中。这两个参数是键和值:

        hashMap.put(6, 6);
        hashMap.put(5, 4);
        hashMap.put(4, 8);
        hashMap.put(3, 10);
        hashMap.put(2, 6);

下面的两行将检索与键关联的值,如果键存在的话。否则,返回null

        System.out.printf("%s%n",hashMap.get(4));
        System.out.printf("%s%n",hashMap.get(1));

HashMap对象中没有使用整数 1 作为键的条目,所以它将打印出null

要迭代或处理整个HashMap对象中的每个元素,我们首先需要从哈希表中的所有条目创建一个Set对象:

        Set s = hashMap.entrySet();

Set对象中,我们创建一个Iterator对象。迭代器允许我们按键的顺序访问集合中的每个元素:

        Iterator it = s.iterator();

Iterator对象的hasNext方法返回true,如果Set对象中还有另一个元素;否则,返回false

        while (it.hasNext()) {

Iterator对象的next方法返回键/值对:

            System.out.printf("%s%n",it.next());
        }

这段代码的输出将是这样的:

8
null
2=6
3=10
4=8
5=4
6=6

注意,键的顺序与它们放入哈希表的顺序不同。

LinkedHashMap

这种结构是HashMap的一个变体。内部操作就像HashMap一样,但还包括第二个数据结构。这是一个链表,它保留了数据放入LinkedHashMap中的顺序。如果顺序不重要,请使用HashMap结构。

如果我们在前面的示例代码中使用了LinkedHashMap,我们唯一要做的改变就是使用LinkedHashMap而不是HashMap

        Map<Integer, Integer> linkedHashMap = new              LinkedHashMap<>();
        linkedHashMap.put(6, 6);
        linkedHashMap.put(5, 4);
        linkedHashMap.put(4, 8);
        linkedHashMap.put(3, 10);
        linkedHashMap.put(2, 6);
        Set s = linkedHashMap.entrySet();
        Iterator it = s.iterator();
        System.out.printf("key=Value%n");
        while (it.hasNext()) {
            System.out.printf("%s%n",it.next());
        }

这个版本的输出将是这样的:

6=6
5=4
4=8
3=10
2=6

这是键/值对放入映射中的相同顺序。

TreeMap

HashMapLinkedHashMap不同,TreeMap的底层结构是一个红黑二叉树。键值直接使用,并且必须实现Comparable接口。你不需要hashCodeequals方法,但包含它们是一个好习惯。以下是使用TreeMap的相同代码:

        Map<Integer, Integer> treeMap = new TreeMap<>();

在这里,键没有特定的顺序。作为整数,它们确实有一个自然顺序,这将决定键/值对在二叉树中的位置:

        treeMap.put(6, 6);
        treeMap.put(4, 4);
        treeMap.put(3, 8);
        treeMap.put(2, 10);
        treeMap.put(5, 6);

当我们使用迭代器显示所有键/值对时,它们将根据键的顺序排列:

        Set s = treeMap.entrySet();
        Iterator it = s.iterator();
        while (it.hasNext()) {
            System.out.printf("%s%n",it.next());
        }

输出将是这样的:

2=6
3=10
4=8
5=4
6=6

虽然数组在需要多个元素时通常是首选的结构,但请考虑它的接口相当有限。集合有一系列丰富的方法,可以扩展你在代码中能做的事情。在我们继续到函数之前,请记住以下注意事项。

重要注意事项

本章中展示的集合不是线程安全的。每个集合都有线程安全的变体。

理解 Java 中的函数

在 Java 中,我们称一个类中的代码单元为方法。在 C 和 C++中,我们称它们为函数。在 JavaScript 中,我们甚至使用keyword函数。Java 与其他这些语言的不同之处在于,函数代表了一个与类及其方法不同的编码模型。有一些是函数式语言,其中 Haskell 是一个例子。我们简要地检查函数,因为我们的下一个主题,流,是基于函数而不是类模型。

让我们看看一些在 JavaFX 中为按钮附加事件处理器的代码。我们将在第十三章中查看 JavaFX,使用 Swing 和 JavaFX 进行桌面图形用户界面编码。让我们首先看看一个功能性的EventHandler接口是什么:

@FunctionalInterface
public interface EventHandler<T extends Event> extends     EventListener {
    void handle(T event);
}

这是 JavaFX 中EventHandler接口的接口类。@FunctionalInterface注解是可选的,但增加了对接口背后目的的清晰度。函数式接口只能有一个抽象方法。JavaFX 中没有这个接口的实现。你必须为handle方法提供代码:

        btn.setOnAction(new EventHandler<ActionEvent>() {
            @Override
            public void handle(ActionEvent event) {
                actionTarget.setText(userBean.toString());
            }
        });

这段代码为按钮按下时注册了一个事件处理器。处理器必须是EventHandler类型,并且必须有一个名为handle的方法。handle方法调用userBeantoString方法来返回一个字符串,该字符串将被分配给名为actionTargetText字段。

第一个片段演示了一个匿名内部类。它是匿名的,因为引用从未分配给一个标识符。它只能在方法调用中使用。我们在动作对仅此按钮按下是独特的情况下这样做。它不能在其他地方重用:

        btn.setOnAction((ActionEvent event) -> {
            actionTarget.setText(userBean.toString());
        });

第二个片段使用了 lambda 表达式。EventHandler中只有一个方法,handle。因此,我们不需要额外的装饰。(ActionEvent event)handle方法必须接收的参数。我们的 lambda 提供了当按钮被按下时将执行的handle方法的代码。虽然 lambda 中的代码行数没有限制,但最佳实践是不超过三行,而一行是最理想的。但如果需要执行多行代码呢?这让我们来到了使用函数的第三种语法:

        btn.setOnAction(this::signInButtonHandler);

Java 中的函数可以作为方法的参数传递,也可以由方法返回。在这个片段中,我们声明signInButtonHandler方法将被调用,就像它是handle方法一样:

private void signInButtonHandler(ActionEvent e) {
    actionTarget.setText(userBean.toString());
}

这里,这个方法在同一文件中。因此,我们在setOnAction中使用this来引用它。它必须与handle方法有相同的返回值和参数。

函数式编程有助于简化我们的代码。在任何需要定义其操作的地方的方法的情况下,使用函数是最好的选择。

在集合中使用流

处理集合中的所有数据元素是一个常见操作。也许你想根据特定要求提取集合的子集。你可能想增加或减少值或更改字符串的大小写。这就是流发挥作用的地方。所有实现 Collection 接口的类都有一个流方法,我们可以从其中链式调用多个流方法。你不能直接在映射上使用流,但如果你将映射转换成集合,那么你可以使用流。

流方法的一个重要特征是它们作为纯函数操作。纯函数不会改变类中任何字段的状态或传递给它的任何参数的状态。流方法始终返回一个新的流。原始流没有改变。让我们看看它是如何工作的:

public record Employee(String employeeId, String firstName, 
         String lastName, String department, double salary) { }

在这里,我们有一个包含表示员工信息的字段的记录。现在,让我们在 StreamsExample.java 文件的构造函数中创建一个包含六个员工的 List。这些信息应该来自数据库,但出于我们的目的,我们将在 StreamsExample.java 文件的构造函数中创建这个 List

public class StreamsExample {
    private List<Employee> staffList;
    public StreamsExample() {
        staffList = Arrays.asList(new Employee("A9", "Benson", 
                             "Bill", "Sales", 56000),
                new Employee("A1", "Clarkson", 
                             "Bill", "Sales", 56000),
                new Employee("A2", "Blunt", 
                             "Wesley", "HR", 56000),
                new Employee("A3", "Smith", 
                             "Joan", "Software", 56000),
                new Employee("A4", "Smith", 
                             "John", "Accounting", 56000),
                new Employee("A5", "Lance", 
                             "Gilbert", "Sales", 56000));
    }

这段代码演示了在声明时通过逗号分隔元素可以创建一个数组,而不是集合。创建了六个 Employee 对象,并通过使用 Arrays 方法的 asList,它们被转换成与 List 兼容的结构。这是必要的,因为流不作用于数组。随着我们的列表完成,我们现在可以应用流方法。许多流方法的行为与 SQL 操作类似。

列表就绪后,让我们使用一些流方法:

List<Employee> filteredList = staffList.stream().
    filter( s -> s.firstName().startsWith("J")).
    collect(Collectors.toList());

在调用 stream() 之后,我们可以应用流方法。第一个是 filter。它需要一个函数,该函数根据函数中的代码返回 truefalse。该函数以 lambda 表达式的形式表达,将接收一个类型为 Employee 的记录对象。lambda 表达式中的代码从记录中检索 firstName 字符串,并应用 startsWith 字符串方法以找到以 "J" 开头的名字。

filter 函数的结果是一个只包含符合条件对象的新流。必须将流转换回集合,这正是 collect 方法的角色。它接受 Collectors.toList() 函数作为参数,该函数来自 Collectors 类,并将返回一个 List 对象。

最后一个例子,是要按 lastName 字段排序打印员工对象:

staffList.stream().sorted((e1, e2) ->  
    e1.lastName().compareTo(e2.lastName())).
    forEach(System.out::println);

这行代码使用了sorted流函数。它需要一个基于字段的函数来确定两个对象之间的顺序。所选择的字段是一个String对象,因此它已经有一个compareTo方法;否则,您将需要编写一个compareTo方法。lambda 表达式接受两个参数,这些参数被定义为来自RecordString对象。这将产生一个排序后的流,然后由forEach函数使用。forEach函数不是返回一个新的List对象,而是接收一个流,并将它的每个成员传递给System.outprintln方法。

现代 Java 程序在很大程度上依赖于流。另一种选择是使用迭代器或for循环来访问所有元素。如果您需要处理集合中的所有元素,请在其他任何东西之前查看流。

摘要

虽然我们似乎在本章中涵盖了众多主题,但您应该认识到它们都是相关的。无论您是从数据库中检索记录还是从键盘接收用户输入,一旦有多个项目,您将需要这里所展示的内容。我们从类似于其他语言中的结构的基本数组开始。从数组,我们转向了集合框架。这些是动态结构,可以根据需要增长。从顺序集合到映射集合,Java 为我们提供了一套丰富的选择。

我们接下来探讨了泛型。与声明为特定类型的数组不同,原始集合可以存储任何对象,而不考虑已经存储的内容。使用泛型表示法,我们可以将一个集合绑定到特定的数据类型。

从 Java 8 开始,函数成为了 Java 语言的一部分。虽然类中的普通方法可以用作函数,但使用 lambda 表达式允许我们为特定问题定义特定的操作。在顺序集合中可用的 Stream 库简化了对集合元素的处理。

接下来,我们将探讨如何记录我们的代码,并在日志中记录程序的操作信息。

进一步阅读

第九章:在 Java 中使用线程

我最早的一个软件开发合同是为美国肯塔基州纯血马场开发隐形围栏安全系统软件。我们使用的计算机是 Apple II plus。在 6502 CPU 或 ProDOS 操作系统中没有线程的概念。我们所做的是用汇编语言编写所有代码,这些代码是以每个单元所需的周期数来衡量的。一旦我们完成了分配的周期,我们就会在内存的一个定义区域中保存我们的状态,并将控制权交给下一个单元。这工作得相当好,如果马走失了,就会响起警报。即使在警报响起的同时,围栏的监控,也就是可以检测到马走过它的地下电缆,也会继续进行。这是我接触到的线程编程。这是在 1982 年。

我直到 1999 年从 C++迁移到 Java 后,才再次与线程打交道。使 Java 脱颖而出的一个特性,以及我放弃 C++的原因,是 Java 语言对线程的标准支持。此外,Swing 对 GUI 应用程序的支持也让我清楚地认识到,Java 是我项目中的学生需要学习的语言。2002 年,多伦多道格森学院的计算机科学技术专业,我是该专业的负责人,放弃了 COBOL 作为主要教学语言,转而使用 Java。

今天,包括 C++在内的许多语言都原生支持多线程编程。在本章中,我们将探讨如何在 Java 中编写依赖于 Java 虚拟机与计算机操作系统协同工作的线程代码。当线程共享资源时,需要处理一些问题。我们将探讨使用同步来处理这些问题。在本章中,我们将探讨以下内容:

  • 创建 Java 本地操作系统线程

  • 防止线程中的竞争和死锁条件

  • 创建新的虚拟线程

让我们从探讨如何使用本地线程编写线程代码开始。

技术要求

下面是运行本章示例所需的工具:

  • 安装 Java 17 以仅使用本地线程

  • 文本编辑器

  • 安装 Maven 3.8.6 或更高版本

本章的示例代码可在github.com/PacktPublishing/Transitioning-to-Java/tree/chapter09找到。

创建 Java 本地操作系统线程

“本地线程”一词指的是由计算机操作系统管理的线程。当我们创建 Java 本地线程时,我们指的是 JVM 使用底层操作系统的线程库 API 管理的线程。这也意味着 JVM 处理不同操作系统上的不同线程库,而我们使用 Java API 来创建线程。在苹果 Mac 上编写的使用线程的程序可以在 Windows 机器上运行,因为 JVM 处理线程的最低级别。

我们将探讨三种创建 Java 本地线程的方法以及一种创建线程池的方法。这些将涉及以下内容:

  • 扩展Thread

  • 实现Runnable接口

  • 使用ExecutorService创建线程池

  • 实现Callable接口

  • 线程管理

我们将要讨论的最后几个主题是以下内容:

  • 守护线程和非守护线程

  • 线程优先级

扩展Thread

任何扩展Thread类的类都可以包含作为线程一部分执行的方法。仅仅创建一个对象并不会创建线程。相反,扩展Thread类的类必须重写Thread超类的run方法。这个方法中的任何内容都成为线程。run方法可以执行线程中的所有工作。除非这是一个简单的任务,否则run方法就像我在之前的示例中使用的perform方法一样。run方法中的所有内容都是这个线程将要执行的操作。让我们看看一个扩展Thread类的简单类。

我们扩展Thread类来表示这个类中的代码将被线程化:

public class ThreadClass extends Thread {

在这个例子中,每个线程将要执行的工作是从我们最初分配给名为actionCounter的字段的任何值开始倒数:

    private int actionCounter = 25;

线程可以被赋予一个名称。我们希望在这个例子中的每个线程都有一个数字作为其名称。因此,它必须是一个静态变量,因为不管我们创建多少个threadCounter实例,都只有一个threadCounter整数。静态字段被认为是线程安全的,这意味着如果有两个或更多线程想要访问静态字段,则不会发生冲突:

    private static int threadCounter = 0;

构造函数将线程的名称分配给超类的构造函数。每次我们创建这个对象的实例时,threadCounter的值将与前一个实例设置的值相同。这使得每个线程都有一个唯一的名称:

    public ThreadClass() {
        super("" + ++threadCounter);
    }

这个线程唯一要执行的任务是显示其名称和actionCounter字段的当前值。我们重写了在对象引用必须作为String操作时调用的toString方法。它将返回一个字符串,由分配给它的名称组成,通过调用超类的getName方法,以及actionCounter的当前值:

    @Override
    public String toString() {
        return "#" + getName() + " : " + actionCounter;
    }

线程类必须重写超类的run方法。线程的工作就在这里发生。在这种情况下,我们使用一个无限while循环,在这个循环中我们显示这个对象的线程名称和actionCounter的当前值。当actionCounter达到零时,我们从run方法返回,线程结束。使用无限循环语法while (true)意味着结束循环的决定是基于循环中发生的事情,在这种情况下,是递减actionCounter直到它达到零。这不是编写run方法的唯一方法,但这是最常见的方法:

    @Override
    public void run() {
        System.out.printf("extends Thread%n");
        while (true) {
            System.out.printf("%s%n", this);
            if (--actionCounter == 0) {
                return;
            }
        }
    }
}

在我们的线程类就绪后,我们现在可以编写一个类,该类将实例化和运行每个线程:

public class ThreadClassRunner {

在这里,在perform中,我们创建了ThreadClass的五个实例。我们调用start而不是runstart方法是对Thread超类start方法的覆盖,它负责设置线程并调用run方法。自己调用run方法不会启动线程:

    public void perform() {
        for (int i = 0; i < 5; i++) {
            new ThreadClass().start();
        }
    }
    public static void main(String[] args) {
        new ThreadClassRunner().perform();
    }
}

重要提示

线程是非确定性的。

这是一个始终需要注意的重要点。每次运行这个示例代码时,输出都会不同。线程的执行顺序与它们创建的顺序无关。运行这个示例几次并注意每次结果的顺序都是不同的。

这种方法有一个问题。你无法在ThreadClass中扩展任何其他超类。这把我们带到了第二种方法。

实现 Runnable 接口

在这种方法中,我们实现了Runnable接口。我们执行的任务与上一个示例相同:

public class ThreadRunnableInterface implements Runnable{
    private int actionCounter = 25;
    @Override
    public String toString() {

我们调用Thread.currentThread().getName()来获取这个线程的名称。当我们扩展Thread类时,我们可以调用getName。由于我们正在实现Runnable接口,我们没有超类方法可以调用。我们现在通过使用Thread类的静态方法来获取名称,这些方法将返回调用这些方法时当前线程的信息:

        return "#" + Thread.currentThread().getName() +
                 " : " + actionCounter;
    }

run方法没有改变:

    @Override
    public void run() {
        while (true) {
            System.out.printf("%s%n", this);
            if (--actionCounter == 0) {
                return;
            }
        }
    }
}

在使用Runnable接口时,启动线程的类中的perform方法有所不同:

    public void perform() {
        System.out.printf("implements Runnable%n");
        for (int i = 0; i < 5; i++) {

我们通过实例化一个Thread类,将其构造函数传递给Runnable线程类的一个实例以及线程的名称来创建这些线程。在这个例子中,Thread对象是匿名的;我们没有将其分配给一个变量,并在其上调用start

            new Thread(new ThreadRunnableInterface(), ""
                          + ++i).start();
        }
    }

就像上一个示例一样,每次运行都会得到不同的输出。

应该使用哪种技术?当前的最好实践是优先选择Runnable接口。这允许你在线程化的同时扩展另一个类。让我们看看线程池。

使用 ExecutorService 创建线程池

到目前为止我们所看到的内容要求我们为Thread类的每一个实例创建一个线程。一种替代方法是创建一个可以重复使用的线程池。这就是ExecutorService方法发挥作用的地方。使用这种方法,我们可以在定义最大并发数的同时创建一个线程池。如果需要的线程数超过了池中允许的数量,那么线程将等待直到一个正在执行的线程结束。让我们改变我们的基本示例以使用这个服务。

我们从一个实现了Runnable接口的类开始。actionCounter字段是线程中将递减的数字:

public class ExecutorThreadingInterface implements Runnable {
    private int actionCounter = 250;

由于我们将创建 Thread 类的任务留给 ExecutorService,我们不再有接受线程名称 String 的构造函数。我们将把名称作为 int 传递给构造函数,并在这里存储它。成为单个线程的类的字段将各自拥有自己的 actionCounterthreadCount 实例:

    private final int threadCount;

这里是接受我们想要知道的线程名称的构造函数:

    public ExecutorThreadingInterface(int count) {
        threadCount = count;
    }

我们重写 toString 方法以返回包含当前线程名称的 String,这是由 ExecutorService 分配的,以及我们分配给 threadCount 的名称,后面跟着 actionCounter 的当前值,它在线程运行时减少:

    @Override
    public String toString() {
        return "#" + Thread.currentThread().getName()
            + "-" + threadCount + " : " + actionCounter;
    }

最后一个方法是 run。这保持不变:

    @Override
    public void run() {
        while (true) {
            System.out.printf("%s%n", this);
            if (--actionCounter == 0) {
                return;
            }
        }
    }
}

现在,让我们看看我们如何使用 ExecutorService 来创建线程:

public class ExecutorServiceRunner {

我选择将我们使用的变量作为字段。它们都可以在单个方法中声明为局部变量:

    private final int numOfThreads = 5;
    private final int threadPoolSize = 2;
    private final ExecutorService service;

构造函数现在负责实例化 ExecutorService,以及一个 Runnable 线程数组:

    public ExecutorServiceRunner() {
        service =
             Executors.newFixedThreadPool(threadPoolSize);
    }
    public void perform() {
        for (int i = 0; i < numOfThreads; i++) {

我们使用 execute 方法将线程添加到 ExecutorService。我们不需要访问线程,因此它们是匿名实例化的:

            service.execute(
                      new ExecutorThreadingInterface(i));
        }

所有线程完成后,服务将关闭。在此方法调用后,你不能再向服务添加任何线程:

        service.shutdown();
    }

我们以通常的 main 方法结束这个类:

    public static void main(String[] args) {
        new ExecutorServiceRunner().perform();
    }
}

这三种方法允许你轻松地创建线程。它们共同的一个问题是,当线程结束时,它不会返回一个值,因为 run 是空值。如果需要,我们可以通过结合 ExecutorService 和使用 Callable 接口的一种第三类线程类来解决这个问题。

实现 Callable 接口

在我们看到的每个线程类中,它们都有一个 run 方法,当线程结束时返回空值。这引导我们到 Callable 接口。使用这个接口,线程的结束会返回一个值。我们只能在使用 ExecutorService 的情况下使用这种技术。让我们先看看一个 Callable 线程类。

我们从想要线程化的类开始。我们实现 Callable 接口,并使用泛型表示法,声明线程结束时返回的值将是一个字符串。字段、构造函数和 toString 方法与 ExecutorThreadingInterface 相同:

public class ThreadCallableInterface
                          implements Callable<String> {
    private int actionCounter = 250;
    private final int threadCount;
    public ThreadCallableInterface(int count) {
        threadCount = count;
    }
    @Override
    public String toString() {
        return "#" + Thread.currentThread().getName() +
                "-" + threadCount + " : " + actionCounter;
    }

在这里,我们将 run 替换为 call 并显示返回类型。return 语句将显示我们分配给每个线程的线程名称,作为一个整数。

    @Override
    public String call() {
        while (true) {
            System.out.printf("%s%n", this);
            if (--actionCounter == 0) {
                return "Thread # " + threadCount +
                                          " is finished";
            }
        }
    }
}

现在,让我们看看这个 Callable 线程的运行者。

public class ThreadCallableInterfaceRunner {

我们声明的第一个变量是Future类型的ListFuture是一个接口,就像List一样。当我们使用(而不是执行)ExecutorServicesubmit方法时,它返回一个实现Future接口的对象。实现此接口的对象代表异步任务的结果。当我们在这里几行之后实例化此对象时,它将是线程传递的Future字符串的List

    private final List<Future<String>> futureList;
    private final ExecutorService executor;
    private final int numOfThreads = 5;
    private final int threadPoolSize = 2;

这个例子中的一个新特性是显示每个线程结束时的当前日期和时间。DateTimeFormatter对象将LocalDateTime对象转换为可读的字符串:

    private final DateTimeFormatter dtf;

构造函数实例化了类的字段:

    public ThreadCallableInterfaceRunner() {
        executor =
            Executors.newFixedThreadPool(threadPoolSize);

我们将futureList实例化为ArrayList。随后我们定义我们想要的日期和时间的格式:

        futureList = new ArrayList<>();
        dtf = DateTimeFormatter.ofPattern(
                                "yyyy/MM/dd HH:mm:ss");
    }
    public void perform(){

在这里,我们使用submit将线程提交给ExecutorService。使用submit意味着我们期望返回类型为Future。我们还把每个Future对象添加到一个ArrayList实例中:

        for (int i = 0; i < numOfThreads; i++) {
            Future<String> future = executor.submit(
                           new ThreadCallableInterface(i));
            futureList.add(future);
        }

在这里,我们遍历ArrayList实例,显示当前日期和时间以及线程返回的值——在这个例子中,是String。我们通过调用get方法来访问Future对象的返回值。这是一个阻塞的方法调用。每个Future对象都与一个特定的线程相关联,get将在允许下一个Future对象的get执行之前等待其结果。对get的调用可能导致两个检查型异常,因此我们必须将调用放在try/catch块中。为了这个示例的目的,我们只是打印堆栈跟踪。你永远不应该在没有采取任何适当行动的情况下打印堆栈跟踪:

        for (Future<String> futureResult : futureList) {
            try {
                System.out.println(
                    dtf.format(LocalDateTime.now()) + ":" +
                    futureResult.get());
            } catch (InterruptedException |
                             ExecutionException e) {
                e.printStackTrace();
            }
        }

当你不再需要服务时,必须显式关闭ExecutorService

        executor.shutdown();
    }

我们以通常的main方法结束:

    public static void main(String[] args) {
        new ThreadCallableInterfaceRunner().perform();
    }
}

现在我们已经回顾了创建Threads最常见的方法,让我们更深入地看看我们如何管理一个线程。

线程管理

有三种常用的Thread方法用于管理线程。这些如下:

  • yield()

  • join()

  • sleep()

yield()方法通知线程调度器它可以放弃当前对处理器的使用,但希望尽快重新调度。这只是一个建议,调度器可以自由地做它想做的事情。这使得它是非确定性的,并且依赖于它运行的平台。只有在可以证明的情况下,通常通过代码分析,它才能提高性能时才应该使用。

当一个线程(我们将称之为join())影响创建了第二个线程的第一个线程时,join()方法可能很有用。join()有两个额外的重载版本,允许你设置阻塞启动线程的时间长度,可以是毫秒,也可以是毫秒和纳秒。

最后一种方法是Thread类的一个静态方法。sleep()方法会使正在执行的线程暂停特定的时间长度。时间可以是,就像join一样,以毫秒为单位,也可以是毫秒和纳秒。

join()sleep()的一个共同特点是它们可以抛出检查异常。它们必须在try/catch块中编码。以下是一个线程类,它实例化和启动第二个类,但随后与第二个类连接,从而阻塞自身,直到它启动的线程完成。

这就像我们之前看到的第一个ThreadClass实例。区别在于它实例化另一个线程类,然后在run方法的开头启动该线程:

public class ThreadClass1 extends Thread {
    private int actionCounter = 500;
    private static int threadCounter = 0;
    private final ThreadClass2 tc2;
    public ThreadClass1() {
        super("" + ++threadCounter);
        tc2 = new ThreadClass2();
    }
    @Override
    public String toString() {
        return "#" + getName() + " : " + actionCounter;
    }
    @Override
    public void run() {

在这里,我们启动第二个线程,它现在将根据调度器的决定执行:

        tc2.start();
        while (true) {
            System.out.printf("%s%n", this);

当第一个线程达到 225 时,我们对第二个线程发出连接请求。结果将是第一个线程被阻塞,第二个线程将一直运行到完成,然后才会解除第一个线程的阻塞:

                if (actionCounter == 225) {
                    try {
                        tc2.join();
                    } catch (InterruptedException ex) {
                        ex.printStackTrace();
                    }
                }
            if (--actionCounter == 0) {
                return;
            }
        }
    }
}

守护线程和非守护线程

“守护”一词指的是被认为是低优先级的线程。这意味着任何被指定为守护的本地线程都将结束,无论在应用程序的主线程结束时它们正在做什么。

非守护线程,当创建本地线程时的默认设置,将阻止应用程序的主线程结束,直到该线程完成任务。这告诉我们非守护线程必须有一个结束条件。守护线程不需要结束条件,因为它会随着主线程结束。

你可以通过简单的调用方法将本地线程设置为守护线程:

thread.setDaemon(true);

你只能在线程实例化后但在它启动之前调用此方法。一旦它开始,就不能更改守护线程的状态。调用此方法会导致抛出异常。

线程优先级

正如已经指出的,线程是非确定性的。这意味着我们无法绝对控制线程何时获得其运行的时间片或该时间片将持续多长时间。我们可以提出一个建议,也称为提示,这就是线程优先级的作用所在。

线程优先级的可能值范围是 1 到 10。在大多数情况下,使用三个定义的静态常量而不是数字。如下所示:

  • Thread.MAX_PRIORITY

  • Thread.MIN_PRIORITY

  • Thread.NORM_PRIORITY

正如这所暗示的,你不能依赖最大优先级比最小优先级获得更多的时间片。它应该获得更多的时间片,这是你能期望的最好的结果。

现在我们已经看到了如何管理我们的线程,让我们再看看一个额外的主题,线程安全。

防止线程中的竞态和死锁条件

有两个常见问题可能导致线程代码出现问题。第一个是竞态条件。当两个或更多线程共同操作一个改变所有线程共享变量的代码块时,就会发生这种情况。

第二种是死锁条件。为了解决竞态条件,你锁定一段代码。如果多个线程使用相同的锁对象,那么可能会出现这些线程都在等待其他线程完成锁但没有任何一个线程完成的情况。让我们更仔细地看看这两个条件。

竞态条件

想象一个场景,你需要在多个线程之间共享一个对象的引用。调用这个共享类中只使用局部变量的方法是线程安全的。在这种情况下,线程安全发生是因为每个线程都维护自己的私有栈用于局部变量。线程之间不可能存在冲突。

如果共享对象的方法访问并修改类字段,情况就不同了。与局部变量不同,类字段是共享对象的唯一属性,并且每个调用此类方法线程都有可能修改字段。在这些字段上的操作可能不会在线程的时间片结束之前完成。现在,想象一下,一个线程期望基于上次访问该字段的时间片,字段具有特定的值。然而,它并不知道,另一个线程已经改变了这个值。这导致所谓的竞态条件。让我们看看一个例子。

这里是一个简单的类,它将传递的值添加到名为counter的类字段中,并返回加法的结果。每次我们调用addUp时,我们都期望counter字段改变值:

public class Adder {
    private long counter = 0;
    public long addUp(long value) {
        counter += value;

线程从调度器获得的时间与你的计算机的 CPU 有关。高时钟频率以及多个 CPU 核心有时允许线程在下一个线程接管之前完成其任务。因此,我通过让addUp方法休眠半秒钟来减慢了addUp方法:

        try {
            Thread.sleep(500);
        } catch (InterruptedException ex) {
            ex.printStackTrace();
        }
        return counter;
    }
}

线程类基于我们之前看到的ThreadClass

public class SynchronizedThreadClass extends Thread {
    private int actionCounter = 5;
    private static int threadCounter = 0;

我们有一个字段用于存储对Adder对象的引用。无论我们创建多少个线程,它们都将共享字段变量:

    private final Adder adder;

构造函数接收对Adder对象的引用:

    public SynchronizedThreadClass(Adder adder) {
        super("" + ++threadCounter);
        this.adder = adder;
    }
    @Override
    public String toString() {
        return "#" + getName() + " : " + actionCounter;
    }
    @Override
    public void run() {
        while (true) {
            var value = adder.addUp(2);

在这里,我们正在打印有关此线程和addUp方法当前值的信息:

            System.out.printf(
                 "%s : %d%n", this, adder.addUp(2));
            if (--actionCounter == 0) {
                return;
            }
        }
    }
}

这里是main类:

public class SynchronizedExample {

我们将有两个类型的SynchronizedThreadClass线程:

    private final SynchronizedThreadClass tc1;
    private final SynchronizedThreadClass tc2;
    private final Adder sa;

在我们实例化每个线程之前,我们创建一个单独的Adder对象,并将其与每个线程类共享:

    public SynchronizedExample() {
        sa = new Adder();
        tc1 = new SynchronizedThreadClass(sa);
        tc2 = new SynchronizedThreadClass(sa);
    }
    public void perform() {
        tc1.start();
        tc2.start();
    }
    public static void main(String[] args) {
        new SynchronizedExample().perform();
    }
}

此代码尚未同步。以下是访问加法器未同步时的结果表:

未同步
线程
#1
#2
#1
#2
#1
#2
#1
#2
#1
#2

表 9.1 – 运行未同步代码的结果

预期的是Adder类计数器应该从 2 计数到 20。但它没有。第一个线程开始将传递的值 2 加到计数器上。但在它能够显示结果之前,第二个线程出现了,并将 2 加到同一个计数器上,现在值增加到 4。当我们回到第一个线程去显示结果时,它现在是 4,而不是它第一次时间片结束时的 2。如果你多次运行这个程序,结果将会不同,但我们将在输出的其他地方看到这个问题。

现在,让我们同步代码。同步将锁应用于代码的一个部分,通常称为临界区。锁是对一个对象的引用,因为所有对象,凭借它们的Object超类,都可以用作锁。我们只需要更改Adder类,特别是addUp方法:

    public long addUp(long value) {
        synchronized (this) {
            counter += value;
            try {
                Thread.sleep(10);
            } catch (InterruptedException ex) {
                ex.printStackTrace();
            }
            return counter;
        }
    }

由于整个方法被视为临界区,我们可以移除同步块并将synchronized关键字应用于方法名:

    public synchronized long addUp(long value) {

这里是使用synchronized版本的addUp Adder类方法的结果表:

同步
线程
#1
#2
#1
#2
#1
#2
#1
#2
#1
#2

表 9.2 – 运行代码同步的结果

你可以看到从 2 到 20 的每个值都出现了。

作为一名开发者,你总是在寻找可以并发执行的任务。一旦确定,你将在适当的地方应用线程。任何长时间运行的任务都适合作为线程的候选。用户界面通常在一个或多个线程中运行界面,当从菜单或按钮中选择任务时,它们也在线程中运行。这意味着用户界面即使在执行长时间运行的任务时也能对你做出响应。

现在,让我们看看如果我们不正确地使用相同的锁对象同步代码块可能会出现的问题。

死锁条件

当线程锁交织在一起时,尤其是在一个线程嵌套在另一个线程内部时,会发生线程死锁。这导致每个线程都在等待另一个线程结束。当使用synchronized时,锁可以是 Java 中任何将保护临界区的对象或类,通常是为了避免竞态条件。你还可以创建LockReentrantLock类型的对象。无论哪种方法,正如我们将看到的,都可能导致死锁。死锁可能很难识别,因为它不会使程序崩溃或抛出异常。让我们看看一个会导致死锁的代码示例。

我们首先创建锁对象所在的类,然后我们使用它们启动两个线程:

public class Deadlock1 {

这里是我们将在Thread1Thread2中使用的两个锁对象。Java 中的任何对象,无论是你创建的还是已经存在的,例如String,都可以用作锁:

    public final Object lock1 = new Object();
    public final Object lock2 = new Object();
    public void perform() {
        var t1 = new ThreadLock1(lock1, lock2);
        var t2 = new ThreadLock2(lock1, lock2);
        t1.start();
        t2.start();
    }
    public static void main(String args[]) {
        new Deadlock1().perform();
    }
}

现在,让我们看看扩展Thread类的类。请注意,Thread1在使用lock2之前使用lock1,而Thread2在使用lock1之前使用lock2

public class ThreadLock1 extends Thread {
    private final Object lock1;
    private final Object lock2;
    public ThreadLock1(Object lock1, Object lock2) {
       this.lock1 = lock1;
       this.lock2 = lock2;
    }

在这个run方法中,我们有一个使用lock1的同步块,然后是一个嵌套的同步块,使用lock2

    @Override
    public void run() {
        synchronized (lock1) {
            System.out.printf(
                      "Thread 1: Holding lock 1%n");
            try {
                Thread.sleep(10);
            } catch (InterruptedException e) {
            }
            System.out.printf(
                       "Thread 1: Waiting for lock 2%n");
            synchronized (lock2) {
                System.out.printf(
                        "Thread 1: Holding lock 1 & 2%n");
            }
        }
    }
}
public class ThreadLock2 extends Thread {
    private final Object lock1;
    private final Object lock2;
    public ThreadLock2(Object lock1, Object lock2) {
       this.lock1 = lock1;
       this.lock2 = lock2;
    }

在这个run方法中,我们有一个使用lock2的同步块,然后是一个嵌套的同步块,使用lock1

    @Override
    public void run() {
        synchronized (lock2) {
            System.out.printf(
                       "Thread 2: Holding lock 2%n");
            try {
                Thread.sleep(10);
            } catch (InterruptedException e) {
            }
            System.out.printf(
                     "Thread 2: Waiting for lock 1%n");
            synchronized (lock1) {
                System.out.printf(
                      "Thread 2: Holding lock 1 & 2%n");
            }
        }
    }
}

当我们运行此代码时,输出将如下所示:

Thread 1: Holding lock 1
Thread 2: Holding lock 2
Thread 1: Waiting for lock 2
Thread 2: Waiting for lock 1

现在这个程序已经死锁,两个线程都在等待另一个线程完成。如果我们改变我们使用的锁的顺序,使得两个都先使用lock1然后使用lock2,我们将得到以下结果:

Thread 1: Holding lock 1
Thread 1: Waiting for lock 2
Thread 1: Holding lock 1 & 2
Thread 2: Holding lock 2
Thread 2: Waiting for lock 1
Thread 2: Holding lock 1 & 2

死锁条件已解决。死锁很少这么明显,你可能甚至没有意识到正在发生死锁。你需要线程转储来确定你的代码中是否存在死锁。

关于这个话题的最后一个要点——与其使用Object类的一个实例作为锁,你可以使用Lock类。语法略有不同,你可以询问一个Lock对象它是否正在被使用。以下代码片段显示了它将是什么样子,但它不能解决死锁。

main类中,锁将使用实现该接口的ReentrantLock类的Lock接口:

    public final Lock lock1 = new ReentrantLock();
    public final Lock lock2 = new ReentrantLock();

在此代码中,我们通过构造函数将Lock对象传递给类:

public class ThreadLock1a extends Thread {
    private final Lock lock1;
    private final Lock lock2;
    public ThreadLock1a(Lock lock1, Lock lock2) {
       this.lock1 = lock1;
       this.lock2 = lock2;
    }
    @Override
    public void run() {

注意,我们没有使用同步块,而是调用lock1上的lock,当关键部分完成时,我们在lock1上发出unlock

        lock1.lock();
        System.out.printf("Thread 1a: Holding lock 1%n");
        try {
            Thread.sleep(10);
        } catch (InterruptedException e) {
        }
        System.out.printf(
              "Thread 1a: Waiting for lock 2%n");
        lock2.lock();
        System.out.printf("Thread 1a: Holding lock 1 & 2");
        lock2.unlock();
        lock1.unlock();
    }
}

使用lock对象而不是同步块的一个优点是,unlock调用不需要在同一类中。

多线程是 Java 的一个强大特性,你应该在适当的时候使用它。我们所看到的是基于从 Java 1.0 版本中可用的原生线程。最近,引入了一种新的线程类型。让我们看看它。

创建新的虚拟线程

如前一小节开头所指出的,原生 Java 线程是由 JVM 通过直接与操作系统的线程库合作来管理的。原生线程和操作系统线程之间存在一对一的关系。这种新的方法被称为虚拟线程。虽然原生线程是由 JVM 与操作系统合作管理的,但虚拟线程完全由 JVM 管理。操作系统线程仍然被使用,但使这种方法变得显著的是,虚拟线程可以共享操作系统线程,并且不再是点对点的关系。

虚拟线程运行速度并不快,可能会遭受竞态和死锁条件。虚拟线程的特殊之处在于,您可以启动的线程数量可能达到数百万。我们使用虚拟线程的方式与本地线程没有太大区别。以下代码片段显示了我们在前面的示例中看到的perform方法创建虚拟线程。线程类没有改变,这使得使用虚拟线程而不是本地线程变得非常容易:

     public void perform() {
        for (int i = 0; i < 5; ++i) {

这里,我们正在创建一个虚拟线程并启动它:

            Thread.ofVirtual().name("Thread # " + i).
               start(new VirtualThreadRunnableInterface());
        }
        try {
            Thread.sleep(500);
        } catch (InterruptedException ex) {
            ex.printStackTrace();
        }
    }

我必须尴尬地承认,我花了 3 天时间才让这段代码工作。为什么?我忽略了虚拟线程的一个重要特性——它们是守护线程。尝试使它们非守护线程没有任何效果。发生在我身上的情况是,程序会在任何输出出现之前结束,或者只出现了一些输出,但没有出现所有预期的线程输出。当perform结束并返回到main()方法时,主本地线程结束。当这种情况发生时,所有守护线程都会结束。我的电脑执行了perform,返回到main,并在单个虚拟线程能够显示其输出之前结束。

您可以看到我用来使这段代码工作的解决方案。我调用了Thread.sleep()。这会使当前线程休眠指定的时间长度。在这种情况下,500 毫秒足够虚拟线程完成所有任务,而主线程结束。

最后,您不能更改虚拟线程的优先级。它们都运行在NORM_PRIORITY

摘要

如本章开头所述,Java 对线程的原生支持是其受欢迎的原因之一。在本章中,我们看到了如何通过扩展Thread类和实现RunnableCallable接口来创建线程。我们看到了ExecutorService如何允许我们池化线程。我们通过查看一个特定问题来结束本章,即两个或多个线程竞争访问共享资源,称为竞态条件,并看到我们如何通过应用同步来解决这个问题。

线程领域即将发生一些变化。在撰写本文时,Project Loom 引入了由 JVM 独家管理的线程以及一个并发框架。一些功能处于预览阶段,而其他功能处于孵化阶段。在几年内,这些新型线程才会变得普遍。我建议关注这个项目的开发。

在我们下一章中,我们将探讨 Java 开发中最常用的设计模式。这些模式将为我们提供组织代码的既定方法。

进一步阅读

第十章:在 Java 中实现软件设计原则和模式

软件设计原则提供了如何构建你的类以及你的对象应该如何交互的指导。它们与特定问题无关。例如,单一职责原则鼓励我们编写执行单一任务的方法。软件设计模式是解决软件设计中常见问题的可重用概念。例如,如果我们需要在应用程序中有一个对象的单一实例,你将希望使用单例模式。这个模式与你所使用的语言无关,也不描述该模式的必需代码。这些原则和模式所做的是描述解决常见问题的方案,然后你可以将这些方案实现在你所使用的语言中。原则和模式可以应用于任何语言,并且我假设你很可能在你所使用的语言中已经应用了它们。

本章的目标是查看一些最常用的原则和模式,以及它们如何在 Java 中编码。它们如下所示:

  • SOLID 软件设计原则

  • 软件设计模式

作为软件开发者,你被期望编写可重用、可理解、灵活且可维护的代码。

技术要求

下面是运行本章示例所需的工具:

  • 安装了 Java 17

  • 文本编辑器

  • 安装了 Maven 3.8.6 或更高版本

本章的示例代码可在github.com/PacktPublishing/Transitioning-to-Java/tree/chapter10找到。

SOLID 软件设计原则

软件设计原则,应用于面向对象编程,提供了如何构建你的类的指导方针。与特定编码要求相关的模式不同,原则应该适用于你每天编写的任何代码。有许多原则,但我们将探讨五个属于缩写SOLID的原则。

S – 关注点分离/单一职责

在我看来,CompoundInterest05程序根据功能组织了类:

图 10.1 – 基于关注点分离原则的类组织

图 10.1 – 基于关注点分离原则的类组织

在这个图中我们看到四个包,它们描述了包中任何类所需的功能。每个包中的单个类包含执行其任务所需的所有方法和数据结构。业务类独立于用户界面UI)类。我们可以将 UI 类改为使用 GUI 而不是文本或控制台界面。我们可以这样做而不需要对业务类进行任何修改。我们将在第十三章使用 Swing 和 JavaFX 进行桌面图形用户界面编码中这样做,当我们从文本改为 GUI 时。我们甚至将在第十五章Jakarta Faces 应用程序中将这个应用程序转变为一个网络应用程序,而不会触及业务和数据类。

单一职责原则虽然在技术上不是 SOLID 的一部分,但它与关注点分离的紧密关联使其值得在这里包括。这个原则应用于方法。应该编写一个方法来负责单一的关注点。你不会编写一个执行计算然后显示结果的单个方法。这些是两个关注点,每个都应属于其自己的方法。

O – 开放/封闭

这个原则指出,你应该能够在不更改或修改类的任何代码的情况下添加或扩展类的功能,即开放部分,而不改变或修改类的封闭部分。这可以通过继承或接口来实现。考虑我们的CompoundInterestCalculator05.java示例。它包含一个复利计算的单一计算。如果我想要添加另一个计算呢?我应该只是编辑这个文件并添加一个新的方法来处理新的计算吗?答案是:不。

如果我使用继承,我将创建一个新的类,它扩展了原始的单个计算并添加了新的计算。这里是一个实现计算贷款还款方法的类:

public class Calculation {
    public void loanCalculator(FinancialData data) {
        var monthlyPayment = data.getPrincipalAmount() *
           (data.getMonthlyInterestRate() / (1 - Math.pow(
           (1 + data.getMonthlyInterestRate()), -
           data.getMonthlyPeriods())));
        data.setMonthlyPayment(monthlyPayment);
    }
}

你已经彻底测试了这个计算,并且你确信它是正确的。现在,你被要求添加两个更多的计算,一个用于储蓄目标,另一个用于定期储蓄的未来价值。开放/封闭原则告诉我们不应该修改这个类。尽管这是一个简单的例子,但在添加方法时,你仍然有可能无意中在这个类中更改某些内容。解决方案是继承:

public class Calculation2 extends Calculation {
    public void futureValueCalculator(FinancialData data) {
        var futureValue = data.getMonthlyPayment() *
            ((1 - Math.pow(
            (1 + data.getMonthlyInterestRate()),
            data.getMonthlyPeriods())) /
            data.getMonthlyInterestRate());
        data.setPrincipalAmount(futureValue);
    }
    public void savingsGoalCalculator(FinancialData data) {
        double monthlyPayment = data.getPrincipalAmount() *
            (data.getMonthlyInterestRate() /
            (1 - Math.pow(
            (1 + data.getMonthlyInterestRate()),
            data.getMonthlyPeriods())));
        data.setMonthlyPayment(monthlyPayment);
    }
}

这个Calculator2类从Calculator超类继承了公共的loanCalculator方法,然后添加了两个新的计算。

第二种方法,称为多态开放/封闭原则,是使用接口类。这是一个封闭类。所有计算都必须实现这个接口:

public interface FinanceCalculate {
    void determine(FinancialData data);
}

现在,让我们看看将要实现这个接口的三个类中的一个:

 public class FutureValue implements FinanceCalculate {
    @Override
    public void determine(FinancialData data) {
        var futureValue = data.getMonthlyPayment() *
           ((1 - Math.pow(
           (1 + data.getMonthlyInterestRate()),
           data.getMonthlyPeriods())) /
           data.getMonthlyInterestRate());
        data.setPrincipalAmount(futureValue);
    }
}

现在,我们可以编写一个可以调用这些操作中的任何一个的类。如果我们希望添加新的财务计算,我们可以在不修改此类的情况下做到这一点,因为它期望接收一个实现FinanceCalculate接口的对象的引用:

public class BankingServices {
    public void doCalculation(FinanceCalculate process,
                              FinancialData data) {
        process.determine(data);
    }
}

随着新功能被添加到应用程序中,你希望在不修改现有代码的情况下完成这一点。有一个例外,那就是纠正错误。这可能会要求修改代码。

L – Liskov 替换

这个原则描述了如何有效地使用继承。简单来说,当你创建一个新的类来扩展一个现有类时,你可以重写超类中的方法。你必须不做的事情是重用超类方法名并更改其返回类型或参数的数量或类型。

这里是一个非常简单的显示消息的超类:

public class SuperClass {
    public void display(String name) {
        System.out.printf("Welcome %s%n", name);
    }
}

现在,让我们创建一个子类,它显示一条略有不同的消息:

public class SubClass extends SuperClass {
    @Override
    public void display(String name) {
        System.out.printf("Welcome to Java %s%n", name);
    }
}

当我们重写display方法时,我们并没有改变它的返回值或参数。这意味着在下面的代码中,我们可以使用超类或子类,并且与传递给doDisplay的引用类型匹配的版本将会运行:

public class Liskov {
    public void doDisplay(SuperClass sc) {
        sc.display("Ken");
    }
    public static void main(String[] args) {
        new Liskov().doDisplay(new SuperClass());
    }
}

程序的输出将如下所示:

Welcome Ken

现在,让我们传递一个SubClass的引用:

        new Liskov().doDisplay(new SubClass());

输出现在将如下所示:

Welcome to Java Ken

在子类中更改display的返回类型将导致编译错误。将返回类型保留为void但添加或删除参数会破坏重写。如果display方法的引用是SubClass,则只能调用超类display方法。以下是新的SubClass

public class SubClass extends SuperClass {
    public void display(String name, int age) {
        System.out.printf("Welcome to Java %s at age %d%n",
                           name, age);
    }
}

我们不能使用@Override注解,因为这被认为是重载,保持相同的方法名但更改参数。现在,如果我们传递SubClassdoDisplay,选择的方法将始终是SuperClass版本,从而破坏 Liskov 原则。

I – 接口分离

这个原则为开发接口提供了指导。简单来说,不要向接口添加不是每个接口实现都需要的新的方法。让我们看看一个简单的配送服务接口。记住,为了简洁,这个和许多其他示例都不是完整的,但展示了与所解释的概念相关的部分:

public interface Delivery {
    void doPackageSize(int length, int height, int width);
    void doDeliveryCharge();
}

现在,让我们实现这个接口:

public class Courier implements Delivery {
    private double packageSize;
    private double charge;
    @Override
    public void doPackageSize(int length, int height,
                                            int width) {
       packageSize = length * width * width;
    }
    @Override
    public void doDeliveryCharge() {
        if (packageSize < 5) {
            charge = 2.0;
        } else if (packageSize < 10 ) {
            charge = 4.0;
        } else {
            charge = 10.0;        }
    }
}

现在,假设我们需要扩展Courier以处理将通过空运旅行的包裹。我们现在必须添加仅用于此类运输的方法。我们会将其添加到现有接口中吗?不,我们不会。接口分离原则告诉我们,为了特定的用途,应将接口保持为所需的最小方法数。新的接口可能看起来像这样:

public interface AirDelivery extends Delivery {
    boolean isHazardous();
}

我们在这里使用接口继承。使用这个接口,你需要实现Delivery接口中的方法以及AirDelivery中的新方法。现在,如果我们实现一个具有Delivery接口的类,我们只需要实现两个方法。当我们使用AirDelivery时,我们需要实现三个方法。

D – 依赖倒置

SOLID 中的最后一个原则指出,类不应该依赖于具体的类。相反,类应该依赖于抽象。抽象可以是一个抽象类,或者在 Java 中更常见的是接口。想象一个处理商店库存的程序。我们会为每个物品创建一个类。我们可能有以下情况:

public class Bread {
    private String description;
    private int stockAmount;
    public String getDescription() {
        return description;
    }
    public void setDescription(String description) {
        this.description = description;
    }
    public int getStockAmount() {
        return stockAmount;
    }
    public void setStockAmount(int stockAmount) {
        this.stockAmount = stockAmount;
    }
}

对于另一个物品,例如牛奶,我们可能有以下情况:

public class Milk {
    private String description;
    private int stockAmount;
    public String getDescription() {
        return description;
    }
    public void setDescription(String description) {
        this.description = description;
    }
    public int getStockAmount() {
        return stockAmount;
    }
    public void setStockAmount(int stockAmount) {
        this.stockAmount = stockAmount;
    }
}

使用面包牛奶的程序被认为是高级模块,而面包牛奶被认为是具体的低级模块。这也意味着高级模块必须依赖于具体的类。想象一下,我们需要一个程序来生成库存物品的报告。如果不遵循依赖倒置原则,我们将需要为库存中的每个物品创建一个报告类:

public class MilkReport {
    private final Milk milkData;
    public MilkReport(Milk data) {
        milkData = data;
    }
    public void displayReport() {
        System.out.printf("Description: %s  Stock: %d%n",
            milkData.getDescription(),
            milkData.getStockAmount());
    }
}

现在,我们需要为BreadReport创建第二个类。一个有 100 种商品出售的商店将需要 100 个类,每个商品一个。依赖倒置解决的问题是需要 100 个报告类。我们开始使用接口来解决这个问题:

public interface Inventory {
    public String getDescription();
    public void setDescription(String description);
    public int getStockAmount();
    public void setStockAmount(int stockAmount);
}

现在,每个物品类都将实现Inventory

public class MilkDI implements Inventory{ . . . }
public class BreadDI implements Inventory{ . . . }

现在可以只有一个报告类:

public class InventoryReport {
    private final Inventory inventoryData;
    public InventoryReport(Inventory data) {
        inventoryData = data;
    }
    public void displayReport() {
        System.out.printf("Description: %s  Stock: %d%n",
            inventoryData.getDescription(),
            inventoryData.getStockAmount() );
    }
}

在使用依赖倒置时,你的程序可以消除冗余,同时仍然允许程序处理库存中的任何新物品。

软件设计原则有助于编写高效且易于维护的代码。这些原则,以及其他原则,每次编写代码时都应予以考虑。您可以从进一步阅读部分中的链接了解更多关于这些原则和其他原则的信息。

原则应该指导你写的每一行代码。即将到来的模式将指导你解决特定问题。

软件设计模式

软件设计模式描述了软件中特定问题的解决方案。这个概念来自建筑和工程。想象一下,你需要设计一座桥梁来跨越一条河流。你可能会首先选择桥梁的类型或模式。有七种桥梁类型:

  • 拱桥

  • 梁桥

  • 悬臂桥

  • 悬索桥

  • 钢索斜拉桥

  • 系杆拱桥

  • 桁架桥

这些类型或模式描述了桥梁应该如何跨越你想要在其上建造桥梁的河流,但它们并不提供详细的说明或蓝图。它们指导建筑师进行桥梁设计。软件模式以类似的方式工作。让我们看看四种广泛使用的模式以及它们如何在 Java 中实现。

单例

单例是一个只能实例化一次的 Java 对象。它是一个创建型模式。无论这个对象在应用程序中的哪个地方被使用,它总是同一个对象。在一个需要通过传递令牌对象来独占访问资源的应用程序中,单例模式是你可能遵循的一种模式。在第十一章,“文档和日志”,我们将探讨日志记录,大多数日志框架都使用单例日志对象。否则,每个使用它的类都会有单独的日志记录器。管理线程池的对象也经常被写成单例。

在 Java 中实现单例模式可以相当简单。在接下来的示例中,这些单例除了确保只有一个实例之外,不做任何其他事情。我留给你们去添加这些单例应该执行的实际工作。

public class SingletonSafe {

当调用getInstance方法时,我们使用一个静态变量来表示这个对象。因为这个方法是静态的,所以它只能访问类中的静态字段。静态字段也是对象所有实例共享的。

    private static Singleton instance;

到目前为止,所有构造函数都是public的。将构造函数指定为private意味着你不能用new来实例化这个对象。

    private Singleton() {}

在这个方法中,我们测试是否已经存在一个实例。如果存在,则返回该实例。如果不存在,则使用new实例化对象。但是等等,我刚刚说过你不能在具有private构造函数的类上使用new。如果这个对象在另一个对象中实例化,这是正确的。在这里,我们是在对象内部实例化对象,访问控制不适用。类中的每个方法都可以访问任何其他方法,而不管其访问权限如何。所以,虽然构造函数对getInstance方法是private的,但它可以在对象用new创建时运行:

    public static Singleton getInstance() {
        if (instance == null) {
            instance = new Singleton();
        }
        return instance;
    }
}

现在,让我们测试一下这是否有效:

public class SingletonExample {
    public void perform() {

在这里,我们通过调用SingletongetInstance方法来实例化两个类字段:

        var myInstance1 = Singleton.getInstance();
        var myInstance2 = Singleton.getInstance();

如果我们的Singleton类工作正常,两个Singleton实例将是相同的。当我们比较对象引用时,我们是在比较这些对象的内存地址。如果地址相同,那么我们就有了同一个对象:

        if (myInstance1 == myInstance2) {
            System.out.printf(
               "Objects are the same%n");
        } else {
            System.out.printf(
              " Objects are different%n");
        }
    }
    public static void main(String[] args) {
        new SingletonExample().perform ();
    }
}

我们的Singleton类有一个问题。它不是线程安全的。可能getInstance方法会被线程中断,这可能导致出现两个或更多个Singleton实例。我们可以通过同步对象的创建来使这个类线程安全。以下是更新后的getInstance方法:

    public static Singleton getInstance() {

当我们创建一个同步块时,我们确保Singleton类的实例化不会被中断。这确保了在实例化一次之后,所有线程都将获得相同的实例:

        synchronized (Singleton.class) {
            if (instance == null) {
                instance = new Singleton();
            }
        }
        return instance;
    }

通过这种方式,我们现在有一个线程安全的Singleton类。

工厂

工厂,另一种创建型模式,是一个类,它从一组共享相同接口或都是同一抽象类子类的类中实例化特定的类。它是一个创建型模式。我们将查看一个使用接口的示例。以下是共享接口:

public interface SharedInterface {
    String whatAmI();
    void perform();
}

现在,让我们创建两个实现相同接口的类。如前所述,我的示例只展示了演示概念的代码。你将添加必要的代码,以便类能够执行其工作:

public class Version01 implements SharedInterface{
    @Override
    public String whatAmI() {
        return "Version 01";
    }
    @Override
    public void perform() {
        System.out.printf("Running perform in Version 01");
    }
}
public class Version02 implements SharedInterface {
    @Override
    public String whatAmI() {
        return "Version 02";
    }
    @Override
    public void perform() {
        System.out.printf("Running perform in Version 02");
    }
}

现在,我们可以看看Factory类本身:

public class Factory {
    public static SharedInterface getInstance(
                                 String designator) {

根据我们传递给getInstance的字符串,我们将实例化相应的对象。注意switch中的default。它将返回一个null引用,即对无物的引用。你应该检查这一点,并在使用无效字符串作为designator的情况下采取适当的行动:

        return switch (designator) {
            case "version01" -> new Version01();
            case "version02" -> new Version02();
            default -> null;
        };
    }
}

现在,让我们看看将使用Factory模式来实例化类的代码:

public class FactoryExample {

我们创建的对象将实现此接口:

    private SharedInterface version;
    public void perform(String versionName) {

这里,我们传递Factory模式将使用以确定要实例化哪个类的字符串:

        version = Factory.getInstance(versionName);
        System.out.printf(
            "Version: %s%n",version.whatAmI());
        version.perform();
    }
    public static void main(String[] args) {
        new FactoryExample().perform("version02");
    }
}

使用工厂模式可以简化创建具有相同接口的类族。

适配器

想象一下,你正在使用具有自己独特接口的特定类的工作。有一天,你遇到了另一个执行类似任务但满足不同客户需求或优于你所使用的类的类。新类的问题在于它没有相同的接口。你是否重写代码以便调用新类的方法?你可以这样做,但当你开始修改现有代码时,就可能出现不可预见的问题。解决方案是将新类包装在一个适配器类中。适配器提供了你的代码已经熟悉的接口,但随后调用新类中的适当方法。这就是适配器,一个结构型模式,发挥作用的地方。

让我们从一个非常简单的应用程序开始,该应用程序计算车辆的燃油消耗量,并将结果作为每加仑英里数返回。我们从一个执行计算的类的接口开始,然后是其实现:

public interface USFuelConsumption {
    String calculateUS(double distance, double volume);
}
public class USCar implements USFuelConsumption {
    @Override
    public String calculateUS(double distance,
                                   double volume) {
        return "MPG = " + distance/volume;
    }
}

这里是将在该类中使用的代码:

public class AdapterExample {
    private USFuelConsumption consumption;
    public AdapterExample() {
        consumption = new USCar();
    }
    public void perform() {
        System.out.printf(
              "%s%n",consumption.calculateUS(350.0, 12.0));
    }
    public static void main(String[] args) {
        new AdapterExample().perform();
    }
}

该程序的输出如下:

MPG = 29.166666666666668

现在想象一下,一个新客户想要使用你的系统,但需要使用公制测量方法进行计算。对于汽车,这被描述为每 100 公里升数。我们有一个接口和一个将执行此操作的类:

public interface MetricFuelConsumptions {
    String calculateMetric(double distance, double volume);
}
public class MetricCar implements MetricFuelConsumptions {
    @Override
    public String calculateMetric(double distance,
                                     double volume) {
        return "l/100km = " + volume/distance * 100;
    }
}

为了能够使用这个新类,我们需要一个适配器,它将实现相同的接口,但在方法调用中,将使用度量计算类:

public class UstoMetricAdapter implements USFuelConsumption {
    private final MetricCar metric;
    public UstoMetricAdapter() {
        metric = new MetricCar();
    }

这里是我们正在适配的方法。而不是在这里进行计算,它将调用MetricCar的方法:

    @Override
    public String calculateUS(double distance,
                                     double volume) {
        return metric.calculateMetric(distance, volume);
    }
}

现在,让我们看看它将如何被使用:

public class AdapterExample {
    private USFuelConsumption consumption;
    public AdapterExample() {

这里是我们需要更改的唯一一行。由于适配器共享相同的接口,它可以替代USCar

        consumption = new UstoMetricAdapter();
    }
    public void perform() {
        System.out.printf("%s%n",
            consumption.calculateUS(350.0, 44.0));
    }
    public static void main(String[] args) {
        new AdapterExample().perform();
    }
}

程序输出现在如下所示:

l/100km = 12.571428571428573

这是一个简单的例子,但它是一个代码重用的例子。适配器允许你使用具有特定接口的新代码重用代码。新代码具有不同的接口,适配器通过向你的代码呈现原始接口来解决这个问题。

观察者

对于此模式,我们感兴趣的是对象状态的变化。当状态发生变化时,将调用另一个类中的方法来执行一些任务。这些任务可以是验证状态更改、将更改写入数据库,或者更新显示。Java 通过提供PropertyChangeListener接口和PropertyChangeSupport类,使使用此模式变得容易。这是一个行为模式的例子。

我们从一个必须通知其他类其任何或所有字段状态发生更改的类开始:

public class TheProperty {

这是此类中我们计划监听其状态变化的字段:

    private String observedValue = "unicorn";
    private final PropertyChangeSupport support;

构造函数正在实例化PropertyChangeSupport类的实例。此对象将允许我们向或从实现此类的PropertyChangeListener监听器的所有类的列表中添加或删除。它支持在字段更改时触发事件:

    public TheProperty() {
        support = new PropertyChangeSupport(this);
    }

此方法允许我们将监听器列表添加到列表中:

    Public void addPropertyChangeListener(
                  PropertyChangeListener listener) {
        support.addPropertyChangeListener(listener);
    }

此方法允许移除一个监听器:

    Public void removePropertyChangeListener(
                  PropertyChangeListener listener) {
        support.removePropertyChangeListener(listener);
    }

这是observedValue变量的set方法。当此方法被调用时,firePropertyMethod将在监听器列表中的每个类中调用propertyChange

    public void setObservedValue(String value) {
        System.out.printf(
               "TP: observedValue has changed.%n");
        support.firePropertyChange(
               "observedValue", this.observedValue, value);
        observedValue = value;
    }

observedValue是一个private字段,正如它应该的那样。我们需要像之前的set方法这样的方法和这个get方法来读取字段中的值:

    public String getObservedValue() {
        return observedValue;
    }
}

现在,我们需要一个listener类。可能只有一个或多个:

public class TheListener implements PropertyChangeListener{

如果TheProperty字段发生变化,我们希望更改的领域是此领域。虽然这是使用此模式的一种常见方式,但在发生更改时将被调用的方法可以自由地做任何它想做的事情,而不仅仅是更新监听器中的一个字段:

    private String updatedValue;

这里是调用每个监听器TheProperty的方法。当它将TheProperty的新值分配给其自己的updatedValue时,你可以在该方法中做任何事情,例如作为一个例子写入数据库。注意,PropertyChangeEvent对象可以访问你为属性给出的名称,通常是字段名称,以及旧值和新值。名称可以用来根据更改的字段决定不同的操作:

    @Override
    public void propertyChange(PropertyChangeEvent evt) {
        System.out.printf("TL: The state has changed.%n");
        System.out.printf("TL: Observed field:%s%n",
                          evt.getPropertyName());
        System.out.printf("TL: Previous value: %s%n",
                          evt.getOldValue());
        System.out.printf("TL: New value: %s%n",
                          evt.getNewValue());
        setUpdatedValue((String) evt.getNewValue());
    }

此类还有一个为其私有的updatedValue字段设置和获取方法:

    public String getUpdatedValue() {
        return updatedValue;
    }
    public void setUpdatedValue(String updatedValue) {
        this.updatedValue = updatedValue;
    }
}

现在,我们可以测试如果对TheProperty对象的字段进行了更改,那么TheListener将收到通知:

public class PropertyListenerExample {
    public void perform() {

我们需要至少一个可观察对象和任意数量的观察者。它们可以是像这样的局部变量或类字段:

        var observable = new TheProperty();
        var observer = new TheListener();

向观察对象添加了一个监听器:

        observable.addPropertyChangeListener(observer);

现在,我们更新可观察对象中的观察字段。这也会导致观察者对象中的字段被更新:

        observable.setObservedValue("moose");
        System.out.printf(
                "PLE: New value in observer is %s%n",
                observable.getObservedValue());
    }
    public static void main(String[] args) {
        new PropertyListenerExample().perform();
    }
}

在本节中,我们探讨了众多模式中的四种。模式本身被划分为不同的类别。单例和工厂是创建型模式。适配器是结构型模式。观察者是行为型模式。所有这些模式都可以应用于你使用的任何语言。

摘要

在本章中,我们仅简要介绍了 SOLID 软件设计原则以及单例、工厂、适配器和观察者设计模式。还有许多其他的原则和模式。设计原则应指导你的日常编码,而设计模式则提供了解决设计问题的方案。两者都适用于任何语言。

接下来,我们将探讨编写代码的文档化和测试。

进一步阅读

第十一章:文档和日志记录

在本章中,我们将探讨软件开发中的两个方面,它们不会直接影响代码的运行。第一个是文档,通常称为注释。第二个是日志记录,这是一种在程序运行期间记录事件以用于监控程序所做之事的工具。我们将从代码内联注释开始。你可能已经注意到,本书迄今为止展示的所有代码都没有任何注释。这是故意为之的,因为每一章都描述了代码正在做什么。如果你查看 GitHub 仓库中的本书代码,你将在每个文件中找到注释。

你可能看到过一条消息,告诉你当程序出现问题时查看日志文件。这些日志文件从哪里来?我们将探讨我们如何显示控制台上的消息或将某些事件发生或异常抛出写入文件。

下面是本章的概述:

  • 创建文档

  • 使用日志记录

到本章结束时,你将了解在源代码中添加注释的各种方法。你还将学习如何使用日志记录代码中的事件。

我们将从文档开始,但在那之前,让我们快速看一下本章的先决条件。

技术要求

下面是运行本章示例所需的工具:

  • Java 17

  • 文本编辑器

  • 安装 Maven 3.8.6 或更高版本

本章的示例代码可在github.com/PacktPublishing/Transitioning-to-Java/tree/chapter11找到。

创建文档

作为一名 31 年的计算机科学讲师,我可以告诉你,大多数学生最晚推迟的任务之一就是注释他们的代码。我了解到有些公司禁止他们的开发者对代码进行注释。这些公司认为代码应该是自文档化的。如果你不能从代码的编写方式中理解代码的目的,那么代码就是写得不好的。这是一个大错误。在这些公司实习的学生报告说,他们花费了大量的时间试图理解公司的代码库。

在代码中注释或评论并不是为了解释或道歉写出的糟糕代码。代码执行任务,任务应该从代码本身中显而易见。永远不明显的是代码为何以某种方式构建,以及它如何与程序的其他部分相匹配。你可以问自己一个问题:当你晋升或跳槽到另一家公司时,接手这个代码库的程序员是否理解你所编写的代码以及为什么这样做。

现在我们来看看我们如何添加注释到我们的代码,以及 Java 中独特的注释技术,称为 Javadoc。

注释

在 Java 中,有三种方式可以在代码中指示注释。第一种是原始的 C 风格注释标识,它使用一个开头的正斜杠,然后是一个星号,一个闭合的星号,然后是一个闭合的正斜杠作为一组字符:

/* This is a single line comment */
/* This is a multiple line
    Java comment */

最后,还有内联注释形式:

 System.out.println("Moose "/* + x */);

在这里,你可以使用 /* ... */ 字符来注释掉代码的一部分。

一个重要的前提是,你不能像下面这样将这些注释嵌套在彼此内部:

/* Comment 1 /* Comment 2 */ end of comment 1 */

Java 编译器会将第一个 /* 符号视为在第一个 */ 符号处结束;注释的结尾很可能是语法错误。

你可以使用第二种字符集进行注释的是双正斜杠。这些是单行注释,它们在行结束时结束。它们可以放在任何地方,行后面的所有内容都将成为注释:

// Meaningless example
int x = 4;
int z = 6;
int y = x * z; // Initializing y with x times z

这些注释对于注释代码行也很有用。每次我修改现有的代码行时,我都会先注释掉要替换的行,然后再写新行。除非我确定新代码能正常工作,否则很少删除代码。

我们还可以通过创建 Javadocs 来添加代码注释的另一种方式。

Javadocs

Javadocs 是由 Javadoc 工具创建的 HTML 页面,该工具包含在 Java 安装中。它检查每个 Java 文件,并为每个公共类构建一个 HTML 页面。这些页面包括所有公共字段和方法。虽然我们只会查看这种默认行为,但你也可以调整它。尽管 Javadocs 会忽略私有元素,但将所有内容都注释为公共元素被认为是最佳实践。

这里是一个我们将应用 Javadocs 工具的示例程序。这些注释继续讨论 Javadocs,所以请不要快速浏览,而应该像阅读本章的每一页一样阅读它。

package com.kenfogel.javadocsexample;
/**
 * This is an example of using Javadocs in your source
 * code. It is only used for commenting a class, a method,
 * or a public field. It cannot be used inline. It begins
 * with a forward slash followed by two asterisks.
 *
 * You can inline certain HTML designations as follows:
 * <p>
 * <b>bold</b></p>
 * <p>
 * paragraph</p>
 * <p>
 * <i>italic</i></p>
 *
 * You can even create a list:
 * <ul>
 * <li>First item in the list
 * <li>Second item in the list
 * </ul>
 *
 * There is a lot of discussion on whether the
 * {@literal @version} tag should be used or whether the
    version should come from a repo such as Git.
 *
 * @version 1.0
 * @author Ken Fogel
 */
public class JavaDocsExample {
    /**
     * This is a private field so this comment will
     * not appear in the HTML file.
     */
    private final String name;
    /**
     * This is a public field, so this comment, written
     * above the field, will appear using /** to start
     * the comment.
     */
    public final int number;
    /**
     * In the method's Javadocs, list all the method
     * parameters with the {@literal @param} tag. That this
     * is also a constructor and is recognized as the
     * method has the same name as the class and does not
     * have a return type.
     *
     * @param name: The user's name
     * @param number: The answer to the ultimate question
     */
    public JavaDocsExample(String name, int number) {
        this.name = name;
        this.number = number;
    }
    /**
     * While you can and should comment private methods as
     * Javadocs, they will not appear on the HTML page.
     * Only public methods appear in the Javadocs.
     *
     * @param day The day of the week that will be
     * displayed
     * @return The string to display
     */
    private String constructMessage(String day) {
        return name + " " + number + " " + day;
    }
    /**
     * Here is a public method whose Javadoc block will
     * appear in the HTML.
     *
     * @param day The day of the week
     */
    public void displayTheMessage(String day) {
        System.out.printf("%s%n", constructMessage(day));
    }
    /**
     * Here is the method where the program will begin:
     *
     * @param args values passed at the command line
     */
    public static void main(String[] args) {
        new JavaDocsExample(
                "Ken", 42).displayTheMessage("Wednesday");
    }
}

要运行 javadoc 工具,请使用以下带有开关的命令行:

javadoc -d docs

-d 开关是 HTML 文件将被写入的位置。在这种情况下,假设你当前所在的文件夹中有一个名为 docs 的文件夹。该文件夹必须存在,因为 javadoc 不会创建它。如果文件夹不存在,则 HTML 文件将写入当前文件夹:

javadoc -d docs -sourcepath C:\dev\PacktJava\Transitioning-to-java\JavadocsExample\src\main\java

-sourcepath 开关是包含 Java 文件或包的文件夹的路径。由于这是一个基于 Maven 的项目,包和源文件始终位于 Maven 项目的 \src\main\java 文件夹中:

javadoc -d docs -sourcepath C:\dev\PacktJava\Transitioning-to-Java\JavaDocsExample\src\main\java -subpackages com:org

最后一个开关 -subpackages 是项目中的包的冒号分隔列表。javadoc 将递归地遍历每个文件夹和子文件夹,从列表中的名称开始,以找到要处理的 Java 文件。我创建了一个以 org 开头的第二个包。-subpackages 将递归搜索,并在任何以列表中名称开头的文件夹中找到的所有公共或包类都将被文档化。

当在项目上运行 javadoc 工具时,它将创建 HTML 网页。以下是为 JavaDocsExample 类创建的 Javadocs 网页。它可能相当长。请注意,只有公共方法会出现。尽管像公共方法一样注释,但私有方法不会出现在 HTML 输出中。以下是 Javadocs 的样子。

图 11.1 – 生成的 Javadocs 的前半部分

图 11.1 – 生成的 Javadocs 的前半部分

图 11.2 – 生成的 Javadocs 的后半部分

图 11.2 – 生成的 Javadocs 的后半部分

整个 Java 库都在 Javadocs 中进行了描述,并且可以在浏览器中搜索。参见 进一步阅读 获取这些文档的 URL。编写代码的最佳实践是编写 Javadocs 注释。这也意味着你必须描述程序的每个部分做什么,更重要的是,为什么它应该这样做。使用 /* ... */// 注释来在方法中包含额外的注释或临时移除代码。

现在,让我们看看我们如何使用日志记录来记录代码中发生的特定事件。

使用日志记录

在你自己的代码中,你可能希望在程序运行时在控制台中显示消息。这些消息可能是通知你捕获到异常,或者记录程序执行期间发生的任何其他事件。虽然你可以使用 System.out.printprintln 或我最喜欢的 printf 将消息写入控制台,但不要这样做。如果应用程序是基于控制台的,那么这些语句将出现在控制台用户界面中。对于 GUI 或 Web 应用程序,控制台可能可见或不可见。一旦程序进入生产阶段,最终用户可能会被你在控制台显示的消息所困惑或压倒。

解决方案是使用日志记录。这允许你将日志消息写入控制台、文件或数据库,甚至可以通过电子邮件发送给自己。我们只关注控制台或文件。Java 有一个日志框架,位于 java.util.logging 中。我们还将查看 Apache 基金会的一个外部日志框架,称为 Log4j2

java.util.logging

日志框架有两个部分。有一个 Java 类的框架和配置文件。对于 java.util.logging 的常见名称,有一个名为 logging.properties 的配置文件位于 Java 安装目录下的 conf 文件夹中。我们将看到如何使用自定义配置文件而不是使用所有应用程序共享的配置。JUL 的默认配置位置在 Java 的 conf 文件夹中。我们可以将我们的 JUL 自定义属性文件放在系统上的任何位置,因为我们必须在实例化记录器时提供文件的路径。

这是一个使用记录器的简单程序:

public class JULDemoDefaultConfig {

我们使用在Logger类中实现的工厂软件模式来实例化Logger。我们传递这个类的名称,以便它可以在日志输出中显示,并且我们可以为不同的类支持不同的Logger项:

    private static final Logger LOG =
    Logger.getLogger(JULDemoDefaultConfig.class.getName());

日志消息必须与一个级别相关联,这是我们使用log方法时的第一个参数。有六个级别,所有级别都有一个可选的第三个参数Exception对象。通常,Level.INFO参数用于记录您希望记录的程序正在做什么或谁正在做的信息。Level.SEVERE用于记录异常。FINESTFINERFINE参数用于调试应用程序。您可以在配置文件中决定最小级别。在开发期间,您将使用ALL,一旦投入生产,您将级别提升到INFO。这意味着您不需要删除或注释掉低于INFO级别的日志消息。

在这个方法中,我们只是创建日志消息:

    public void perform() {
        LOG.log(Level.FINEST,
            "JUL default-Using LOG.log at Level.FINEST");
        LOG.log(Level.FINER,
            "JUL default-Using LOG.log at Level.FINER");
        LOG.log(Level.FINE,
            "JUL default-Using LOG.log at Level.FINE");
        LOG.log(Level.INFO,
            "JUL default-Using LOG.log at Level.INFO");
        LOG.log(Level.WARNING,
            "JUL default-Using LOG.log at Level.WARNING");

对于SEVERE级别,我在try块中强制抛出异常,并在捕获它时,通过包含Exception对象来记录它。

        try {

您可以通过将String对象传递给构造函数来向异常添加自定义消息:

            throw new Exception(
                   "JUL default config exception");
        } catch (Exception ex) {
            LOG.log(Level.SEVERE,
          "JUL default-Using LOG.log at Level.SEVERE", ex);
        }
    }
    public static void main(String[] args) {
        new JULDemoDefaultConfig().perform();
    }
}

如果您有一个自定义配置文件,您必须显式加载该文件;否则,将使用 Java conf文件夹中的默认配置logging.properties。更改默认配置不是一个好主意,因为它将影响您运行的每个使用 JUL 的程序。

要加载自定义配置文件,您需要找到这个:

private static final Logger LOG =
    Logger.getLogger(JULDemoDefaultConfig.class.getName());

用以下内容替换它:

private static final Logger LOG;
static {

当您的代码被打包成 JAR 文件时,src/main/resources中存在的资源文件的位置是项目的根目录。retrieveURLOfJarResource方法知道这一点,因此它可以加载放置在此文件夹中的配置文件。这在一个static初始化块中,这将确保如果存在此类的多个实例,则此Logger类只会被实例化一次:

    try (InputStream is =
            retrieveURLOfJarResource("logging.properties").
            openStream()) {
        LogManager.getLogManager().readConfiguration(is);
    } catch (Exception e) {
        Logger.getAnonymousLogger().severe(
              "Unable to load config\nProgram is exiting");
        System.exit(1);
     }
     LOG = Logger.getLogger(
         JULDemoCustomConfig.class.getName());
    }

默认的logging.properties文件注释非常详细。以下是移除注释后的文件内容。我鼓励您检查您机器上此文件的版本。

这是控制台显示的输出:

handlers= java.util.logging.ConsoleHandler

除非被覆盖,否则它只会显示此级别或更高级别的日志:

.level= INFO

如果您正在将日志写入文件,那么pattern属性是文件夹和文件名。在这种情况下,%h表示将文件写入您的家目录。这是 JUL 的最佳实践。如果您希望将日志文件存储在特定的文件夹名称中,那么该文件夹必须已经存在:

java.util.logging.FileHandler.pattern = %h/java%u.log
java.util.logging.FileHandler.limit = 50000

每次程序运行时,它都会覆盖之前的日志文件,因为只允许一个日志文件:

java.util.logging.FileHandler.count = 1

日志记录是线程安全的。这告诉我们,可以同时使用多达 100 个日志文件锁。如果您在写入日志时遇到IOException错误,您可以通过增加锁的数量来解决这个问题:

java.util.logging.FileHandler.maxLocks = 100

以 XML 格式写入日志文件:

java.util.logging.FileHandler.formatter =
                java.util.logging.XMLFormatter

覆盖处理器级别将取代全局级别:

java.util.logging.ConsoleHandler.level = INFO

这是日志在屏幕上显示的格式。你可以配置 SimpleFormatter,这已在默认配置文件的注释中解释:

java.util.logging.ConsoleHandler.formatter =
                java.util.logging.SimpleFormatter

自定义属性文件有以下更改:

  • FileHandler 类已被添加,以便日志将被写入文件和控制台:

    handlers= java.util.logging.FileHandler,
              java.util.logging.ConsoleHandler
    
  • 两个处理器现在将显示每个级别的日志消息:

    java.util.logging.ConsoleHandler.level = ALL
    java.util.logging.FileHandler.level = ALL
    

我们使用 %h 来表示我们希望将日志写入我们的主目录。如果你希望将它们写入特定文件夹,则该文件夹必须已经存在。如果文件夹不存在,则不会创建文件:

java.util.logging.FileHandler.pattern =
                          %h/loggingdemo-JUL_%g.log

可以有三个日志文件,每个程序运行一个。在写入第三个日志文件后,如果需要另一个日志文件,则它将回绕并按创建顺序覆盖现有文件:

java.util.logging.FileHandler.count = 3

Java 记录器始终可用,并且不需要添加到 Maven POM 文件中的任何依赖项。记录可能会影响你的代码性能。因此,有一些替代 JUL 的方案,它们执行时间更短或提供 JUL 中没有的功能。让我们看看最广泛使用的第三方日志记录器之一,Log4j2。

Log4j2

Log4j2 与 JUL 非常相似。在我们能够使用它之前,我们需要向我们的 POM 文件中添加新的依赖项:

        <dependency>
            <groupId>org.apache.logging.log4j</groupId>
            <artifactId>log4j-api</artifactId>
            <version>2.19.0</version>
        </dependency>
        <dependency>
            <groupId>org.apache.logging.log4j</groupId>
            <artifactId>log4j-core</artifactId>
            <version>2.19.0</version>
        </dependency>

在你计划使用 Log4j2 的任何文件中,你从以下 class 字段开始:

private static final Logger LOG =
                LogManager.getLogger(Log4jDemo.class);

现在为了能够记录日志,你只需要以下内容。请注意,级别现在是 LOG 对象的方法。可选的第二个参数可以接受所有级别的 Exception 引用,如 Level 5 所示:

    public void perform() {
        LOG.trace("log4j2-Level 1: I am a trace");
        LOG.debug("log4j2-Level 2: I am a debug");
        LOG.info("log4j2-Level 3: I am an info");
        LOG.warn("log4j2-Level 4: I am a warning");
        try {
            throw new Exception("log4j2 exception");
        } catch (Exception ex) {
            LOG.error("log4j2-Level 5: I am an error", ex);
        }

当使用 Log4j2 时,你应该创建一个配置文件,因为其默认行为有限。如果没有此文件,记录器将不执行任何操作。与 JUL 的配置一样,期望 log4j2.xml 配置文件位于 src/main/resources

而不是审查此文件,我要求你从 GitHub 克隆此章节的仓库,并查看 LoggingExample 项目中的 log4j2.xml 文件。其注释解释了可以配置的内容。与 JUL 相比的一个改进是,如果你希望将日志存储在任意文件夹中,Log4j2 的文件处理器将创建该文件夹。

我几乎在写的每个文件中都添加了一个记录器。这允许我按需编写日志。声明一个未使用的 Logger 对象不会对你的程序性能产生影响。

摘要

在本章中,我们介绍了每个程序员都应该包括在他们的代码中的两个重要任务,无论使用哪种语言。第一个是文档。注释和 Javadoc 对于现有代码的维护或添加新功能可能是关键的。你可能认为你永远不会忘记为什么以某种方式编写代码,但 6 个月后,这种记忆可能不如所需的准确。

在软件开发过程中,一旦程序投入生产,让程序将其正在执行的操作写入控制台,或者更常见的是写入文件,可以在追踪错误方面起到很大的作用。对受监管的软件进行审计是日志可以执行的其他任务之一。永远不要使用System.out.print或其类似方法来显示有关程序操作的信息——使用日志记录器。无论是 Java 日志记录器还是像 Log4j2 这样的外部日志记录器,都必须部署到您的代码中。

对代码进行文档化是强制性的。使用日志记录程序中的事件也是强制性的。请记住,编程是一门工程学科,而不是一种艺术形式。工程需要这里描述的那种文档,并且需要使用日志来监控程序的性能。

接下来,我们将探讨在需要绝对精度时如何处理浮点数。在下一章中,我们还将涵盖测试我们的代码以确保其按设计运行。

进一步阅读

第十二章:BigDecimal 和单元测试

我们从本章开始,解决大多数语言中存在的浮点表示问题。这个问题围绕着无法将每个十进制分数表示为二进制分数,正如在第四章中指出的,语言基础 – 数据类型和变量。在大多数情况下,它已经足够准确。但如果你必须保证准确性和精度呢?你必须放弃浮点原语,并使用 BigDecimal 类。

你如何知道你刚刚编写的代码是否工作?编译器可以检测语法错误。无错误的编译只能告诉你编译器很高兴。但它真的工作吗?你的代码如何处理无效输入、数据库连接丢失或边缘情况?始终意识到,对于你工作的大多数项目,你编写的系统中最不可靠的组件是最终用户。你不能修复他们,但你需要设计和实现你的代码来处理意外情况。单元测试是在编写代码时验证代码的一种技术。

单元测试与质量保证QA)不同。这是一个执行的过程,以确保程序符合其编码时的规格。QA 是关于运行的程序。单元测试是关于单个方法的性能。有时必须测试一起工作的对象和方法,这被称为集成测试,但测试技术是相似的。这种测试是程序员的职责。

在本章中,我们将探讨如何使用 JUnit 5 框架编写单元测试。这个框架的显著之处在于,你可以测试代码中的任何方法,而无需主方法。我们将涵盖以下主题:

  • 使用 BigDecimal

  • 什么是 JUnit 5?

  • 使用 JUnit 5 进行测试

  • 执行参数化测试

在我教授的高级 Java 课程中,单元测试是强制性的。如果一个学生不能证明他们编写的代码通过了单元测试,那么我就没有兴趣去查看代码。提交的代码在程序执行之前必须运行其测试。如果没有测试,则自动失败。我可能很严厉,但结果是,我对他们编写的代码能否工作有信心。

技术要求

在本章中运行示例所需的工具如下:

  • Java 17

  • 文本编辑器

  • 安装 Maven 3.8.6 或更高版本

本章的示例代码可在github.com/PacktPublishing/Transitioning-to-Java/tree/chapter12找到。

使用 BigDecimal

BigDecimal 类是 java.math 库的一个成员,它是对浮点数的固定精度表示。这意味着以 BigDecimal 表示的值不会受到大多数 CPU 的硬件浮点单元(FPU)在执行计算时可能发生的近似问题的困扰。

BigDecimal 类与字符串共享一个重要的特性。它们都是不可变的。这意味着当一个值成为 BigDecimal 对象后,它就不能再改变了。对 BigDecimal 对象的任何操作都会返回一个新的 BigDecimal 对象。

让我们看看一个可以计算借款还款额的应用程序。这个计算的公式如下:

在这里:

  • rate = 每期的利率

  • n = 期数数量

  • PV = 现值(贷款金额)

  • PMT = 付款(每月付款)

如果我们使用双精度浮点数(doubles)来表示所有值,Java Bean 数据对象将如下所示:

public class FinancialData {
    private double amountBorrowed;
    private double annualRate;
    private double term;
    private double monthlyPayment;
    public FinancialData(double amountBorrowed,
            double annualRate,
            double term) {
        this.amountBorrowed = amountBorrowed;
        this.annualRate = annualRate;
        this.term = term;
        this.monthlyPayment = 0.0;
    }
    public FinancialData() {
        this(0.0, 0.0, 0.0);
    }
    public double getAnnualRate() {
        return annualRate;
    }
    public void setAnnualRate(double annualRate) {
        this.annualRate = annualRate;
    }
// There are setters and getters for
// the other three fields.

Object 超类有一个名为 toString 的方法,它将返回对象存储位置的字符串表示。我们重写了它以显示所有字段的值。这在调试中非常有用,所以我建议你在任何数据类中始终包含一个 toString 方法:

    @Override
    public String toString() {
            return "FinancialData{" + "amountBorrowed=" +
            amountBorrowed + ", annualRate=" +
            annualRate + ", term=" + term +
            ", monthlyPayment=" + monthlyPayment + '}';
    }

计算结果的公式如下。它被拆分成几个部分,以反映最终计算的每个部分,尽管它也可以像 CompoundInterest 示例中那样写成一行。注释描述了公式的每个部分:

public class Calculation {
    public void loanCalculator(FinancialData data) {
        // Convert APR to monthly rate because payments are
        // monthly
        var monthlyRate = data.getAnnualRate() / 12.0;
        // (1+rate)
        var temp = 1.0 + monthlyRate;
        // (1+rate)^term
        temp = Math.pow(temp, -data.getTerm());
        // 1 - (1+rate)^-term
        temp = 1 - temp;
        // rate / (1 - (1+rate)^-term)
        temp = monthlyRate / temp;
        // pv * (rate / 1 - (1+rate)^-term)
        temp = data.getAmountBorrowed() * temp;
        data.setMonthlyPayment(Math.abs(temp));
    }
}

如果我们以 5% 的年利率借了 5000 美元,期限为 60 个月,答案将是 94.35616822005495 美元。所以,结果应该是 94.36 美元。这里的问题是所有计算都是到 14 位小数,而它们应该只使用具有两位小数的值,除了每月的利率。利率可能有多于两位小数。将年利率除以 12,对于一年 12 次付款,得到一个值,其前两位小数是 0。在大多数情况下,结果将是准确的,但并不总是如此。如果你在编写所谓的会计问题,这是一个严重的问题。解决方案是使用 BigDecimal。以下是数据对象:

public class FinancialData {
    private BigDecimal amountBorrowed;
    private BigDecimal annualRate;
    private BigDecimal term;
    private BigDecimal monthlyPayment;
    public FinancialData(BigDecimal amountBorrowed,
            BigDecimal annualRate,
            BigDecimal term) {
        this.amountBorrowed = amountBorrowed;
        this.annualRate = annualRate;
        this.term = term;.

BigDecimal 中有一些便利的对象,其中之一是 BigDecimal.ZERO,它返回一个初始化为 0 的 BigDecimal 对象:

        this.monthlyPayment = BigDecimal.ZERO;
    }

默认构造函数使用这个非默认构造函数,并传递三个初始化为 0 的 BigDecimal 对象:

    public FinancialData() {
        this(BigDecimal.ZERO, BigDecimal.ZERO,
             BigDecimal.ZERO);
    }
    public BigDecimal getAnnualRate() {
        return annualRate;
    }
    public void setAnnualRate(BigDecimal annualRate) {
        this.annualRate = annualRate;
    }
// There are setters and getters for the other three
// fields along with a toString method.

使用 BigDecimalCalculation 类现在看起来如下所示:

public class Calculation {
    public void loanCalculation(FinancialData data)
                 throws ArithmeticException {
        var monthlyRate = data.getAnnualRate().
                      divide(new BigDecimal("12"),
                      MathContext.DECIMAL64);
        // (1+rate)
        var temp = BigDecimal.ONE.add(monthlyRate);
        // (1+rate)^term
        temp = temp.pow(data.getTerm().intValueExact());
        // BigDecimal pow does not support negative
        // exponents so divide 1 by the result

除法是一个可能产生无限循环小数序列的操作。如果检测到这种情况,则会抛出异常。为了防止这种异常,我们使用 MathContext.DECIMAL64 限制小数位数。这将限制数字到 16 位小数:

        temp = BigDecimal.ONE.divide(
                   temp, MathContext.DECIMAL64);
        // 1 - (1+rate)^-term
        temp = BigDecimal.ONE.subtract(temp);
        // rate / (1 - (1+rate)^-term)
        temp = monthlyRate.divide(
                   temp, MathContext.DECIMAL64);
        // pv * (rate / 1 - (1+rate)^-term)
        temp = data.getAmountBorrowed().multiply(temp);

在这里,我们使用setScale将输出限制为两位小数。我们还定义了如何进行舍入。你们中许多人可能被教导 1 到 4 向下舍入,5 到 9 向上舍入。在会计中并不是这样做的。银行使用HALF_EVEN。例如,27.555 将舍入为 27.56。如果值是 27.565,它将舍入为 27.56。如果最后请求的小数位上的值是偶数,并且随后的值正好是 5,则向下舍入。如果是奇数,则向上舍入。随着时间的推移,你和银行将收支平衡。如果没有HALF_EVEN,你可能会损失给银行的钱:

        temp = temp.setScale(2, RoundingMode.HALF_EVEN);

一些财务计算会返回一个负数。这告诉你资金的流向,是流向你还是银行。我正在使用BigDecimal的绝对值方法来消除符号:

        data.setMonthlyPayment(temp.abs());
    }
}

现在的问题是,我们如何测试这段代码以确保它给出了正确的答案。我们可以在main方法中编写代码来测试它,如下所示:

    public static void main(String[] args) {
        var data = new FinancialData(
               new BigDecimal("5000.0"),
               new BigDecimal("0.05"),
               new BigDecimal("60.0"));
        new Calculation().loanCalculation(data);

如同在第八章,“数组、集合、泛型、函数和流”中已经提到的,你不能使用+、>和==等运算符与对象一起使用。相反,你使用equals等方法:

        if (data.getMonthlyPayment().equals(
                 new BigDecimal("94.36"))) {
            System.out.printf("Test passed%n");
        } else {
            System.out.printf("Test failed: %.2f %s%n",
                   data.getMonthlyPayment(), "94.36");
        }
    }

如果你想测试许多值怎么办?loanCalculation方法表明它可能会抛出ArithmeticException。我们如何测试在适当的时候抛出这个异常?答案是单元测试。

什么是 JUnit 5?

JUnit 5 是一个开源库,不是 Java 开发工具包库的一部分。它使用 Eclipse Public License v2.0 进行许可。这仅仅意味着你可以自由地使用这个库,并在开源或商业软件中与你的工作一起分发,而无需支付任何费用。那么,它做什么呢?

这个单元测试框架允许你实例化项目中的任何类并调用任何非私有方法。这些非私有方法,如publicpackage,可以在所谓的测试类中执行。这些是由 JUnit 框架实例化的类。测试类包含可以实例化项目中的任何类并调用类中方法的函数。

使用 JUnit 5 进行测试

测试类不是通常的源代码文件夹src/main/java的一部分,而是放在src/test/java中。它们可以也应该组织成包。你也可以有仅由测试类使用的资源,例如logging.propertieslog4j2.xml。它们将被放在src/test/resources

我们需要向我们的 Maven pom.xml文件中添加新组件。第一个是 JUnit 5 的依赖项。第一个添加的是dependencyManagement

    <dependencyManagement>
        <dependencies>
            <dependency>
                <groupId>org.junit</groupId>
                <artifactId>junit-bom</artifactId>
                <version>5.9.1</version>
                <type>pom</type>
                <scope>import</scope>
            </dependency>
        </dependencies>
    </dependencyManagement>

基于 Maven 的项目中的所有依赖项和插件都需要一个版本值。你可以确保给定库的所有依赖项的版本值正确的一种方法是在可用的情况下使用 BOM。现在,不再需要为每个库包含版本值。

接下来是针对 JUnit 5 的特定依赖项。此依赖项支持单测试方法和参数化测试。请注意,这些依赖项的范围是test,这意味着它们不包括在代码的最终打包中。测试类也不包括在代码的最终打包中:

    <dependency>
        <groupId>org.junit.jupiter</groupId>
        <artifactId>junit-jupiter</artifactId>
        <scope>test</scope>
    </dependency>

下一个更改是添加 Maven 的surefire插件。此插件将运行所有单元测试。测试结果将显示在控制台、文本文件和 XML 文件中。这些文件可以在target/surefire-reports中找到。当你运行测试时,此文件夹会为你创建。当重新运行测试时,现有的测试报告将被覆盖:

            <plugin>
                <groupId>org.apache.maven.plugins</groupId>
                <artifactId>
                      maven-surefire-plugin
                </artifactId>
                <version>2.22.2</version>
            </plugin>

示例代码没有main方法,因为它代表一个正在进行中的项目。它不能运行,但它可以进行单元测试。要使用mvn仅运行测试,请在build部分设置defaultGoal

<defaultGoal>test</defaultGoal>

让我们创建一个基本的单元测试。你需要做的第一件事是将test/javatest/resources文件夹添加到你要为单元测试编写的 Maven 项目中。这是我示例项目的文件夹结构。我还添加了一个名为com.kenfogel.calculationtest的包到test/java中。

图 12.1 – 单元测试的文件夹结构

图 12.1 – 单元测试的文件夹结构

现在,让我们看看我们的测试类。到目前为止,书中提供的代码示例没有显示所需的导入。它们可以在 GitHub 仓库中找到该书的代码示例。以下示例将查看导入。以下是SimpleTest.java类:

package com.kenfogel.calculationtest;

这两个导入使CalculationFinancialData类可用于此类:

import
  com.kenfogel.loanbigdecimalunittest.business.Calculation;
import
  com.kenfogel.loanbigdecimalunittest.data.FinancialData;

这里列出了我们将从 JUnit 5 中使用的导入:

import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;

最后这个static导入允许我们使用assertEquals而不需要显示这里看到的整个包结构:

import
  static org.junit.jupiter.api.Assertions.assertEquals;

最后,我们有BigDecimal的导入:

import java.math.BigDecimal;
public class SimpleTest {
    private Calculation calc;
    private FinancialData data;

@BeforeEach注解用于定义必须在每个测试方法之前运行的方法。它有一个配套的@AfterEach注解。还有@BeforeAll@AfterAll,它们是在所有测试开始之前或所有测试结束后运行的方法。在测试中,最佳实践是始终为测试实例化你将使用的对象。避免重复使用在先前的测试中使用过的对象,因为这可能导致依赖于它的测试中出现意外的错误:

    @BeforeEach
    public void init() {
        calc = new Calculation();
        data = new FinancialData();
    }

这里是带有@Test注解的测试。它设置了FinancialData中的三个变量,并调用Calculation类来计算贷款支付。它以assertEquals结束,以将结果与已知答案进行比较:

    @Test
    public void knownValueLoanCalculationTest () {
        data.setAmountBorrowed(new BigDecimal("5000"));
        data.setAnnualRate(new BigDecimal("0.05"));
        data.setTerm(new BigDecimal("60"));
        calc.loanCalculation(data);
        assertEquals(new BigDecimal("94.36"),
            data.getMonthlyPayment());
    }
}

要运行默认目标设置为test的测试,你只需在项目的根目录下命令行中运行mvn。以下是控制台中的测试输出:

图 12.2 – 测试结果

图 12.2 – 测试结果

你还可以测试是否抛出了预期的异常。以下是重写的方法,但将term设置为0。这将导致带有消息Division by zeroArithmeticException。断言消息很重要,因为ArithmeticException有两个可能的原因。第一个是除以零。第二个发生在使用BigDecimal进行计算时出现无限循环序列。

首先,我们需要另一个import语句:

import static
     org.junit.jupiter.api.Assertions.assertThrowsExactly;

现在我们可以编写测试:

    @Test
    public void knownValueLoanExceptionTest() {
        data.setAmountBorrowed(new BigDecimal("5000"));
        data.setAnnualRate(new BigDecimal("0.05"));
        data.setTerm(new BigDecimal("0"));

在这里,我们正在调用我们期望在assertThrowsExactly中抛出异常的方法。这个方法以我们期望的异常类名开头,后面跟着一个 lambda 表达式来调用我们期望抛出ArithmeticException的方法。assertThrowsExactly方法返回抛出的异常对象,我们将其赋值给一个ArithmeticException对象。现在我们可以使用assertEquals方法来确定除以零是否是此异常的原因。如果没有抛出异常或找到不同的消息,则测试将失败:

        ArithmeticException ex =
            assertThrowsExactly(ArithmeticException.class,
            () -> {calc.loanCalculation(data);});
        assertEquals("Division by zero", ex.getMessage());
    }

这就结束了我们对基本单元测试的探讨,其中每个测试只运行一次。理想情况下,单元测试应该使用一系列值运行,而不仅仅是单个值。这就是我们接下来要探讨的内容。

执行参数化测试

这还留下了一种需要查看的测试类型,即参数化测试。正如你可能已经意识到的,如果你想运行一个测试来确定结果是否对多个值准确,那么你需要为每组值创建一个方法。JUnit 5 通过允许你创建一个值列表来简化这项任务。让我们看看这是如何工作的。以下是新的参数化测试类:

public class ParameterizedTests {
    private Calculation calc;
    private FinancialData data;

我们在这里不会像上一个例子那样实例化FinancialData对象。它将由一个私有辅助方法创建:

    @BeforeEach
    public void init() {
        calc = new Calculation();
    }

第一个注解声明这将是一个参数化测试。这意味着这个方法将为@CsvSource中列出的每一行数据运行一次:

    @ParameterizedTest
    @CsvSource({
        "5000, 0.05, 60, 94.36",
        "3000, 0.05, 24, 131.61",
        "20000, 0.05, 72, 322.10"
    })

ArgumentsAccessor参数将包含要测试的当前数据行。这个方法将为@CsvSource中的每一行调用:

    public void knownValueLoanCalculationTest_param (
                     ArgumentsAccessor args) {

我们的数据类需要BigDecimal值。为了实现这一点,我们有一个名为buildBean的私有方法,它接收一个类型为ArgumentsAccessor的对象,并将其转换为FinancialData对象:

        data = buildBean(args);
        calc.loanCalculation(data);

CSV 数据中的每一行都以答案作为最后一个元素。我们正在比较存储在monthlyPayment中的结果与最后一个参数:

        assertEquals(new BigDecimal(args.getString(3)),
                      data.getMonthlyPayment());
    }

我们的辅助方法从ArgumentsAccessor对象的前三个元素中构建一个FinancialData对象:

   private FinancialData buildBean(ArgumentsAccessor args){
      return
       new FinancialData(new BigDecimal(args.getString(0)),
                        new BigDecimal(args.getString(1)),
                        new BigDecimal(args.getString(2)));
    }
}

关于单元测试,还有更多东西要学习。如果一个测试依赖于特定对象向测试方法提供特定值,但又不被视为失败点,你可以伪造它。这被称为模拟对象。你创建模拟并指定其方法必须返回的内容。请参阅进一步阅读部分,以获取到广泛使用的模拟库之一,名为 Mockito 的链接。

摘要

本章我们首先介绍了 BigDecimal 类。现代 FPUs 处理的浮点值在从十进制到二进制以及反向转换时存在问题。这在会计领域尤为重要,因为每一分钱都必须平衡。作为一个类,BigDecimal 对象不像原语那样容易使用,但绝对精确的需求决定了它们的使用。

正如我在本章开头所述,测试是一项程序员应该做的关键任务。你应该交付几乎在所有使用情况下都能按预期工作的代码。单元测试并不能证明程序的逻辑必然是正确的。这通常由 QA 团队通过测试程序的执行来验证。

在编写这本书的过程中,我遇到了一篇关于单元测试的研究论文。你可以在进一步阅读部分找到链接。该论文专注于使用集成开发环境(如 Visual Studio 或 IntelliJ)的 Java 和 C# 开发者。论文发现,在研究中,不到一半的开发者进行了任何形式的软件测试,尽管在 IDE 中编写软件测试非常容易。请不要成为错误的一半。

在本章中,我们探讨了两个不同的概念。第一个是如何进行计算,例如在会计中,必须精确到特定的十进制位数。我们通过使用 BigDecimal 类来表示浮点数而不是 floatdouble 来实现这一点。

第二个概念介绍了使用单元测试进行软件测试。作为一名程序员,你需要能够证明你编写的公共方法按预期执行。这就是单元测试的内容。我在上一章中提到,注释和日志是强制性的。我将单元测试添加到程序员应执行的任务列表中。

接下来,让我们继续前进,看看用户体验,也称为UX。到目前为止,示例代码使用了类似打字机输出的控制台 UX。在下一章中,我们将探讨 Java 为我们提供的图形用户界面 UX。

进一步阅读

第三部分:Java 的 GUI 和网络编码

基础知识已经掌握,现在是时候看看一个 Java 应用程序了。在本部分,我们将看到上一节中使用的业务流程如何被用于逻辑相同但使用不同 GUI 库的程序中,从桌面应用程序到网络应用程序。

本部分包含以下章节:

  • 第十三章, 使用 Swing 和 JavaFX 进行桌面图形用户界面编码

  • 第十四章, 使用 Jakarta 进行服务器端编码

  • 第十五章, Jakarta Faces 应用程序

第十三章:使用 Swing 和 JavaFX 进行桌面图形用户界面编码

本章将介绍一个简单但完整的应用程序,用于提供三种常见的财务计算。这些是贷款还款、货币的未来价值和储蓄目标。我们将查看这个应用程序的两个版本,一个使用 Swing 库编写,另一个使用 JavaFX 库编写。该应用程序的服务器端编码将在 第十五章Jakarta Faces 应用程序 中介绍。

本章我们将涵盖以下内容:

  • Java GUIs 的简要历史

  • 财务计算器程序设计

  • 使用 Swing GUI 框架

  • 使用 JavaFX GUI 框架

  • 我应该使用哪个?

到本章结束时,您将了解使用两个最广泛使用的框架进行 GUI 编码的基础知识。

技术要求

下面是运行本章示例所需的工具:

  • Java 17

  • 文本编辑器

  • 安装 Maven 3.8.6 或更高版本

本章的示例代码可在 github.com/PacktPublishing/Transitioning-to-Java/tree/chapter13 找到。

Java GUIs 的简要历史

最初的个人计算机用户界面模仿了大型机或小型机的终端。苹果公司在 1984 年推出了 Mac,微软公司在一年后推出了 Windows。然而,20 世纪 80 年代销售的多数个人计算机都配备了终端界面。改变一切的是互联网的普及和蒂姆·伯纳斯-李(Tim Berners-Lee)从 1989 年开始创建的万维网技术。到 20 世纪末,我们期望使用的计算机拥有图形用户界面。在 第十五章Jakarta Faces 应用程序 中,我们将探讨 Java 的网络编程,而本章我们将探讨桌面 GUI 编程。

当 Java 在 1995 年推出时,其原始目的是创建小程序(applets),这些小程序可以在网络浏览器内部从网页中运行。这些页面提供了在 JVM 中运行而不是在浏览器中运行的编译小程序。同时开发的 JavaScript 在浏览器内部运行。这导致了第一个 GUI 库,今天仍然包含在 Java 中,称为 抽象窗口工具包AWT),仍然可用于桌面应用程序。小程序不再存在,因为它们已被弃用。

AWT 依赖于底层操作系统来渲染 GUI。在苹果 Mac 上运行的 AWT 程序看起来和感觉就像一个原生 Mac 应用程序。如果你在 Windows PC 上运行相同的代码,它的外观和感觉就像一个原生 Windows 应用程序。随着 Java 在应用空间而不是网络空间中的普及,一个增强的 GUI 库 Swing – 首次在 1996 年推出 – 获得了发展。与 AWT 不同,Swing 可以渲染自己的控件。现在,可以开发出在 Windows、Mac 和 Linux 上看起来几乎相同的用户界面。在进一步阅读列表中有一个链接到名为Napkin的 Swing 外观和感觉库。这是一个极端的例子,说明了你可以如何设计 Swing 应用程序。Swing 至今仍在广泛使用。它是一个标准库,包含在所有 Java 发行版中。它通过错误修复和微小增强进行维护。

2008 年,JavaFX 1.0,Swing 的替代品,被引入。我们将要查看的版本始于 2011 年的 JavaFX 2.0。JavaFX 的原始目的是为桌面、网页和移动设备上的 GUI 应用程序提供一个通用平台。像 Swing 一样,它负责自己的渲染。它还引入了一种基于 XML 语言 FXML 的声明性方法来定义用户界面。在本章中,我们将探讨定义界面的命令式或编码方法。

现在,让我们看看我们将要构建的应用程序。

财务计算器程序设计

在上一章中,我们将使用双精度浮点数的贷款计算改为使用 BigDecimal。我们将继续使用这个计算以及另外两个。一个是储蓄目标,其中你指明你希望储蓄的金额、预期的利率以及你希望达到目标的月份数。第二个是未来价值,其中你可以确定在特定月份数和预期利率下储蓄相同金额后你将拥有的金额。

我们将使用上一章中的相同的数据类和业务类。我们将向业务类添加两个新的计算。我们不会在本章中详细介绍新的计算,因为你可以从章节的源代码中看到它们。现在,我们将考虑 GUI。

应用程序将向用户提供三种计算选择。我们希望有一个单一表单,我们可以输入每个计算所需的三个值,并且结果将显示在那里。最后,我们希望有一个按钮来表示可以进行计算。我通常在纸上或白板上实际设计设计,例如这个图:

图 13.1 – 手绘设计

图 13.1 – 手绘设计

当我们完成时,进行计算时它将看起来像这样:

图 13.2 – Swing 版本

图 13.2 – Swing 版本

计算的选择由一组三个单选按钮决定。这些是可选择或不可选的控件,并且只能选择一个。当用户选择一个计算时,跟随单选按钮的标题描述将会改变,所有字段都将变为零,第一个输入字段描述将改变,并且按下计算按钮将使用与所选单选按钮匹配的计算。

图 13.3 – 未来价值和储蓄目标屏幕

图 13.3 – 未来价值和储蓄目标屏幕

正如您所知,用户并不总是可以信赖输入有效信息。总会有用户会将借款金额输入为Bob。我们可以弹出消息框通知用户他们的错误。在某些情况下,这确实是有意义的。在这个用户界面中,我们可以识别出前三个字段中唯一允许的输入必须是数字,并且只允许一个十进制点。您不能输入Bob。空白字段将被更改为包含零。最后一个字段不可编辑,因为它显示结果。

国际化 – i18n

设计的最后一个元素是国际化,通常称为 i18n。这意味着这个程序可以用多种语言展示。我住在加拿大,那里有两种官方语言 – 英语和法语。这意味着界面中所有带有文本的内容都必须有每种语言的版本。以下是法语版本:

图 13.4 – 法语版本

图 13.4 – 法语版本

我们通过将所有将出现在 GUI 中的文本放入一个属性文件来实现国际化,每个语言一个。以下是英文属性文件:

loan_amount=Loan Amount
savings_amount=Savings Amount
goal_amount=Goal Amount
interest_rate=Interest Rate
term_in_months=Term In Months
result=Result
calculate=Calculate
calc_error=Arithmetic Exception
calc_msg=You may not have a zero in any input field.
title=Calculations - Swing
loan_title=Loan Calculation
savings_title=Future Value of Savings Calculation
goal_title=Savings Goal Calculation
loan_radio=Loan
savings_radio=Savings
goal_radio=Goal
alert_a=The value >%s< cannot \nbe converted to a number.
format_error=Number Format Error

您可以在项目中看到法语版本。这些属性文件通常称为ResourceBundle,您必须首先加载属性文件。

使用默认的Locale

var form = ResourceBundle.getBundle("MessagesBundle");

在代码中设置Locale,这在测试中非常有用:

Locale locale = new Locale("en", "CA");
var form = ResourceBundle.getBundle(
                         "MessagesBundle", locale);

国际化在 JavaFX 中工作方式相同。让我们看看这个应用程序是如何用 Swing 编写的。

使用 Swing GUI 框架

选择好 GUI 库后,我们现在可以决定这个应用程序需要哪些类以及它们如何打包。以下是完成的项目布局:

图 13.5 – Swing 项目布局

图 13.5 – Swing 项目布局

GitHub 上的源代码注释非常详细,我鼓励您在阅读时下载它。让我们首先看看 Swing 应用程序的基本组件和控制。我们从JFrame开始。

JFrame

每个 Swing 应用程序都需要一个主或顶级容器。为此目的有四个类,但现在其中一个已被弃用并计划删除。它们是JFrameJDialogJWindow和已弃用的JAppletJWindow类非常适合启动画面,因为它们没有装饰,如边框、标题栏或窗口控件。你通常将JDialog用作应用程序的一部分,以与用户交互,处理你不想在使用的顶级容器中的详细信息。它也可以用于需要与用户进行最少交互的简单应用程序,因此也可以用作顶级容器。JApplet将 Swing 带到了网页浏览器,但现在它正走向历史的垃圾堆。让我们来谈谈JFrame

JFrame类是一个装饰容器,这意味着它有一个边框和标题栏。如果你想要一个菜单,它支持JMenuBar。它可以调整大小或固定大小。所有其他组件和控制都放入JFrame中。在我的示例代码中,我使用了继承在FinanceCalculatorMain类中扩展JFrame,这简化了编码。

下面是设置框架的代码。你将在JPanel部分看到一个额外的步骤:

setDefaultCloseOperation(JFrame.EXIT_ON_CLOSE);
setTitle(form.getString("title"));
setSize(620, 450);
setVisible(true);

JPanel

JPanel类是其他组件和控制的容器。虽然JFrame类已经包含了JPanel类,通常被称为内容面板,但我们很少用它做任何事情,而只是添加一个用户设计的JPanel。这正是我的示例所做的事情。还有一个名为FinanceCalculatorUI的第二个类,它扩展了JPanel

要将控件(如按钮、文本字段或其他JPanel)添加到JPanel类中,我们首先必须决定一个LayoutManager类。这些对象负责在JPanel中放置项目。应用程序中的主要面板是FinancialCalculatorUI,它扩展了JPanel。在其构造函数中,我们编写以下内容:

super(new BorderLayout());

在代码的后面,创建了另一个不扩展其他类的JPanel。我们可以通过JPanel构造函数传递布局管理器:

var innerForm = new JPanel(new BorderLayout());

使用多个面板创建用户界面是非常常见的。以下图表显示了所有使用的面板:

图 13.6 – JPanel 布局

图 13.6 – JPanel 布局

第一个JPanelFinancialCalculatorUI,被分配到JFrame的内容面板。它将有一个BorderLayout,包括北、南、东、西和中心区域。在每个布局区域中,你可以添加另一个面板或控件。你放置在这个布局中的任何内容都将填充该区域。在北部,我们放置了一个带有JRadioButtons的面板。在中心,我们放置了另一个带有BorderLayoutJPanel。在这个面板的北部,我们放置了一个包含标题的面板。在南部,有一个带有计算按钮的面板。在中心,我们放置了另一个带有GridBagLayout的面板,这允许我们将面板视为行和列,这对于表单来说是非常理想的。

事件处理器

任何可以接受用户从键盘或鼠标输入的组件都可以生成事件。你可以选择忽略事件,或者你可以注册你编写的事件处理器。以下是注册与处理器同一类中的方法的代码:

calculate.addActionListener(this::calculateButtonHandler);

calculateButtonHandler 方法接受一个 ActionEvent 对象。该对象包含有关事件的信息,但在此情况下我们不需要它。该方法验证字段是否可以是 BigDecimal 类型,并将它们分配给 FinanceBean,如果一切顺利,则调用 FinanceCalculations 对象进行计算。

文档过滤器

在我们的设计中,我们决定只允许最终用户输入一个数字。这个数字只能有一个小数点,且不带符号,因为所有值都必须是正数。在 Swing 中,JTextField 内部有一个 Document 类型的对象,我们可以从这个对象中访问并替换 DocumentFilter 对象。在过滤器中,有一个名为 replace 的方法,它在每次按键时被调用。我们将安装自己的过滤器,该过滤器继承或扩展了 DocumentFilter

replace 方法接收你输入的字符串以及当前文本字段中该字符串插入的位置。它在文本进入文档之前被调用;在文本改变之前,我们可以访问文本字段的内容。这意味着如果用户输入无效,我们可以恢复我们在输入新字符串之前文本字段中的内容。

正则表达式模式匹配

正则表达式regex 是对字符串中允许或不允许的内容的描述。模式匹配使用正则表达式来确定应用模式的字符串是否符合正则表达式的需求。这些表达式定义了一个将应用于用户输入字符串的模式:

private final Pattern numberPattern =
            Pattern.compile("^[\\d]*[\\.]?[\\d]*$");
private final Matcher numberMatch =
            numberPattern.matcher("");

第一行将正则表达式编译。如果事先没有编译,每次使用正则表达式时都会进行编译。这就是创建 Pattern 对象的作用。

第二行创建了一个 Matcher 对象。在这里,我们创建了一个由 Pattern 对象构建的 Matcher 对象,它可以评估括号内的字符串并返回 truefalse。在 Matcher 声明中,搜索空字符串可能看起来很奇怪,但正如我们将看到的,你可以在创建 Matcher 对象之后更改你想要搜索的字符串:

if (numberMatch.reset(newText).matches()) {
    super.replace(fb, offset, length, string, attrs);
}

Matcher 类的 reset 方法允许我们将字符串更改为搜索内容。newText 对象是在将新字符添加到原始字符串之后得到的字符串。如果匹配失败,则文本字段中已有的字符串保持不变。如果用户将字符串粘贴到文本字段中,这里采取的方法将有效。

许多开发者认为模式匹配成本较高,这意味着它使用了大量的 CPU 时间。异常也是昂贵的。如果两者都很昂贵,那么对于大多数用户来说,简单的异常方法将具有优势。然而,使用 Java Microbenchmark Harness库比较模式匹配与抛出异常的性能的简单测试表明,模式匹配可以比使用异常快 8 到 10 倍。

控制面板

让我们看看两个放置在带有GridBagLayout的面板中的控件。我们首先从JLabel开始。我们在其构造函数中提供要显示的文本。实际的文本来自form对象表示的适当ResourceBundle。接下来,我们设置字体。这个字体在多个地方使用,因此它已经被定义为labelFont。最后,我们将JLabel添加到面板中。为此,我们需要一个GridBagConstraints类型的对象。关于如何添加这个控件有五个细节。数字0代表列,数字2代表行。接下来的两个数字代表这个控件在网格中将使用多少列和行。最后,我们指示控件在网格中的对齐方式:

inputLabel = new JLabel(form.getString("loan_amount"));
inputLabel.setFont(labelFont);
panelForm.add(inputLabel,
     getConstraints(0, 2, 1, 1, GridBagConstraints.WEST));

JTextField控件增加了水平对齐和字段宽度的设置,宽度以列为单位:

inputValue = new JTextField("0");
inputValue.setFont(textFont);
inputValue.setHorizontalAlignment(SwingConstants.RIGHT);
inputValue.setColumns(15);

在这里,我们提取控制器的Document并使用这个引用来安装我们的过滤器:

var inputDocument =
    (AbstractDocument) inputValue.getDocument();
inputDocument.setDocumentFilter(filter);

最后,我们将JTextField添加到网格中:

panelForm.add(inputValue,
     getConstraints(1, 2, 1, 1, GridBagConstraints.WEST));

你可以在源代码中找到关于这个 Swing 版本的更多细节。现在,让我们看看 JavaFX 版本。

使用 JavaFX GUI 框架

这个程序版本类似于 Swing 版本。用户界面的设计相同,因为它在面板中使用面板。以下是完成的项目布局:

图 13.7 – JavaFX 程序布局

图 13.7 – JavaFX 程序布局

让我们现在看看 JavaFX 框架中我们将需要的类。

应用程序

一个 JavaFX 程序必须包含一个扩展Application的类。在这个类中,我们可以构建用户界面或将这项工作委托给另一个类。扩展Application的类必须实现一个名为start的方法,可选地实现一个名为init的方法。你很少有一个构造函数。JavaFX 框架不对扩展Application的类的构造函数可用。这就是init发挥作用的地方。它在 JavaFX 运行的环境中扮演构造函数的角色。你不需要调用init;JavaFX 会调用。

start方法是 GUI 创建开始的地方。该方法在init之后由 JavaFX 调用。

主舞台

PrimaryStage对象类似于JFrame。你不需要创建它的实例。你正在扩展的Application类创建了一个PrimaryStage对象并将其传递给start

    @Override
    public void start(Stage primaryStage) {

面板

与 Swing 不同,在 Swing 中您定义一个面板并将其分配给布局管理器,FX 使用包含布局的面板。我们正在创建的第一个面板是BorderPane,它有顶部、底部、左侧、右侧和中心区域。我们在顶部添加了一个包含单选按钮的面板,并在中心添加了另一个面板:

        var root = new BorderPane();
        root.setTop(gui.buildRadioButtonsBox());
        root.setCenter(gui.buildForm());

场景

据说 JavaFX 的原开发者是一位表演艺术爱好者,这使他使用剧院名称作为框架的一部分。一个Stage对象必须包含一个Scene对象,该对象反过来又包含一个面板对象。如果需要,您可以创建多个场景并在它们之间切换。对我来说,JavaFX 最引人注目的特性之一是它使用级联样式表CSS),下一行将加载它。

        var scene = new Scene(root, 620, 450);
        scene.getStylesheets().add("styles/Styles.css");

下四行应该是自解释的。最后一行调用show方法来启动程序。

        primaryStage.setTitle(form.getString("title"));
        primaryStage.setScene(scene);
        primaryStage.centerOnScreen();
        primaryStage.show();
    }

CSS 样式表

使用 CSS 来设置组件样式。这与在 Web 开发中使用的 CSS 类似,但并不完全相同。在进一步阅读部分,有一个链接到 CSS 参考文档。以下是本应用程序使用的样式表。以点开头的类名是预定义的。以井号开头的类名是您分配的:

#prompt_label {
    -fx-font-size:14pt;
    -fx-font-weight:bold;
    -fx-font-family:Verdana, sans-serif;
}
#input_field {
    -fx-font-size:14pt;
    -fx-font-weight:normal;
    -fx-font-family:Verdana, sans-serif;
}
#title {
    -fx-font-size:18pt;
    -fx-font-weight:bold;
    -fx-font-family:Verdana, sans-serif
}
.button {
    -fx-font-family:Verdana, sans-serif;
    -fx-font-weight:bold;
    -fx-font-size:14pt;
}
.radio-button {
    -fx-font-family:Verdana, sans-serif;
    -fx-font-weight:bold;
    -fx-font-size:14pt;
}

您可以通过使用控件的setId方法为控件分配一个自定义类名:

resultLabel.setId("prompt_label");

您也可以直接在源代码中输入 CSS 样式。以这种方式输入的 CSS 将覆盖外部样式表中的内容:

resultLabel.setStyle("-fx-font-size:18pt; "
     + "-fx-font-weight:bold; "
     + "-fx-font-family:Verdana, sans-serif;");

JavaFX Bean

JavaFX Bean 的设计是为了支持将 Bean 中的字段与控件绑定。对控件所做的任何更改都会写入 Bean,写入 Bean 的内容也会更新控件。这通常被称为观察者模式。为了实现这一点,我们必须将所有数据类型包装到一个称为属性的家族对象中。JavaFX Bean 可以在期望 JavaBean 的地方使用。这意味着不需要修改FinancialCalculations类,因为它将与 JavaFX Bean 一起工作,而无需对其代码进行任何更改。以下是我们的FinanceFXBean类作为 JavaFX Bean 的示例:

public class FinanceFXBean {

与直接声明原始或类类型相比,它们必须被包装到适当的Property类中。对于所有原始数据类型,例如DoublePropertyStringProperty,都有这样的类。在我们的示例中,我们使用BigDecimal,对于它没有属性。这就是ObjectProperty类发挥作用的地方。这允许您将任何类用作属性:

private ObjectProperty<BigDecimal> inputValue;
private ObjectProperty<BigDecimal> rate;
private ObjectProperty<BigDecimal> term;
private ObjectProperty<BigDecimal> result;

有两个构造函数。第一个是默认构造函数,它调用非默认构造函数:

public FinanceFXBean() {
   this(BigDecimal.ZERO, BigDecimal.ZERO, BigDecimal.ZERO);
}

默认构造函数必须实例化每个Property对象并对其进行初始化,传入的参数是BigDecimal。对于每个定义的属性都有一个简单的属性。当没有命名属性时,例如SimpleDoubleProperty,使用SimpleObjectProperty

public FinanceFXBean(BigDecimal inputValue,
                BigDecimal rate, BigDecimal term) {
    this.inputValue =
            new SimpleObjectProperty<>(inputValue);
    this.rate =
            new SimpleObjectProperty<>(rate);
    this.term =
            new SimpleObjectProperty<>(term);
    this.result =
            new SimpleObjectProperty<>(BigDecimal.ZERO);
}

与 JavaBean 不同,每个属性有三个方法。前两个,获取器和设置器,从属性内部检索值或更改属性中的值。这就是 JavaFX bean 可以替换 Java bean 的方式,因为获取器和设置器的命名相同,返回或接收相同的数据类型:

public BigDecimal getInputValue() {
    return inputValue.get();
}
public void setInputValue(BigDecimal inputValue) {
    this.inputValue.set(inputValue);
}

最后三个方法之一返回属性。这用于实现绑定和观察者模式:

public ObjectProperty<BigDecimal> inputValueProperty() {
    return inputValue;
}

BigDecimalTextField

这是一个扩展 JavaFX TextField 的类。与 Swing 的 JTextField 不同,没有 Document。按下一个键将调用我们重写以满足我们需求的方法。这些方法使用正则表达式进行模式匹配。现在还有一个用于通过删除前导零来修改字符串的正则表达式。

我们重写了两种方法。第一种是 replaceText,用于处理键盘输入。第二种是 replaceSelection,用于处理从选择文本然后替换它(无论是通过键盘还是粘贴)而产生的变化。

控制

我们创建控制和将它们添加到面板中的方式有相似之处。你创建 GridPane 来保存表单:

var loanGrid = new GridPane();

使用 ResourceBundle 实例化 inputLabel。接下来分配一个自定义的 CSS-style id。最后,我们将标签添加到 financeGrid 面板中,指示列和行:

inputLabel = new Label(form.getString("loan_amount"));
inputLabel.setId("prompt_label");
financeGrid.add(inputLabel, 0, 0);

inputValue 是由我们的自定义 BigDecimalTextField 构造的。我们给它分配一个自定义的 CSS id,更改其对齐方式,并将其添加到 financeGrid

inputValue = new BigDecimalTextField();
inputValue.setId("input_field");
inputValue.setAlignment(Pos.CENTER_RIGHT);
financeGrid.add(inputValue, 1, 0);

绑定

将原始数据类型绑定到 JavaFX bean 中的字段需要一个转换器。有一些标准转换器,其中之一是将字符串转换为 BigDecimal 的转换器。以下是绑定方法:

private void doBinding() {

我们正在创建一个 BigDecimalStringConverter 对象,我们可以在每个绑定中重用它:

    var convert = new BigDecimalStringConverter();

对于每个绑定,我们传递每个 TextFieldtextPropertyFinanceFXBean 中每个字段的属性,以及我们的转换器:

    Bindings.bindBidirectional(inputValue.textProperty(),
        financeData.inputValueProperty(), convert);
    Bindings.bindBidirectional(rateValue.textProperty(),
        financeData.rateProperty(), convert);
    Bindings.bindBidirectional(termValue.textProperty(),
        financeData.termProperty(), convert);
    Bindings.bindBidirectional(resultValue.textProperty(),
        financeData.resultProperty(), convert);
}

你应该使用哪一个?阅读以下摘要来决定。

摘要

Swing 是一个成熟的 GUI 框架,它是 Java 分发的一部分进行维护。由于存在了这么久,已经有很多关于如何使用它的文章。为您的 GUI 项目选择 Swing 是一个好的选择。

JavaFX 可以说是新来的孩子。两个最显著的区别是绑定和 CSS 样式表。虽然这里没有涉及,但 JavaFX 还提供了更好的图形支持。在 JavaFX 图形中,如线或矩形这样的原语与控件处于同一级别。JavaFX 有一个 Animation 类,它简化了创建动态图形的过程。还有一个用于创建折线图、饼图等的图表库。

今天的用户期望他们使用的软件中有一个图形用户界面。不需要使用 JavaFX 重新编写 Swing 项目,并且继续有新的项目使用 Swing 编写。那么,你应该使用哪个?在我看来,新项目应该使用 JavaFX。Swing 在很大程度上处于维护模式,以确保其正常工作,并利用核心语言的变化。另一方面,JavaFX 作为开源项目,每个版本都积极维护,并添加了维护和新功能。

在我们下一章中,我们将探讨如何使用 Java 编写运行在称为应用程序服务器的容器中的软件,用于万维网。

进一步阅读

)

)

)

第十四章:使用 Jakarta 进行服务器端编码

虽然 Java 与网络的最初连接是通过小程序开发实现的,但该语言出现后的几年内,服务器端 Java,最初称为 Java 2 Enterprise EditionJ2EE,后来称为 Java Enterprise EditionJEE,才被引入。与可以在您的桌面上运行的独立应用程序不同,JEE 应用程序在另一组称为应用服务器的 Java 程序中运行。当 Oracle 决定主要关注核心语言 Java SE 时,规范和库被转交给 Eclipse 基金会。这些规范和库被重命名为 Jakarta EE

任何编程语言的服务器端编码通常涉及软件监听互联网端口,例如 80。信息从浏览器到服务器以及返回遵循 HTTP 协议。浏览器向服务器发送请求。请求可以通过返回由 HTML 和 JavaScript 组成的响应来满足,浏览器再将页面渲染出来。HTTP 协议是语言和服务器无关的。这意味着它与特定的语言或浏览器无关。

在 Java 中,我们有一种特殊的类,它在应用服务器中运行,等待请求,在接收到请求时执行一些操作,然后返回浏览器可以渲染的响应。这个特殊类是 new 关键字。

一个 Jakarta EE 应用程序打包在一个具有 .war 扩展名的 ZIP 文件中。它不包含 JEE 库。这些库是应用服务器的一部分。这意味着网络应用相对较小。这些库是编译代码所必需的。Maven 会下载这些库,以便 Java 编译器可以验证你的 Jakarta 使用情况。当将应用程序打包成用于服务器的 .war 文件时,这些库不是最终包的一部分。

Jakarta EE 规范描述了两个页面渲染库。第一个称为 Jakarta Server Pages,之前称为 JavaServer PagesJSP。第二个称为 Jakarta Faces,之前被称为 JavaServer FacesJSF。这个缩写仍然被广泛使用,而不是 JF。这两个库都支持从运行在应用服务器的 Java 代码生成 HTML 和 JavaScript。我们将在下一章中探讨 Jakarta Faces。

在本章中,我们将探讨以下主题:

  • 理解 Java 应用服务器的作用

  • 使用 Maven 配置网络项目

  • 理解 servlet 的作用以及其编码方式

  • 使用 web.xml 文件配置部署

到本章结束时,你应该能够理解如何在 Java 中基于 HTML 构建一个网络应用,以及如何编写 servlet 代码。

技术要求

在本章中运行示例所需的工具如下:

  • Java 17

  • 一个文本编辑器

  • 安装 Maven 3.8.6 或更高版本

  • GlassFish 7.0 应用服务器

  • 一个网络浏览器

本章的示例代码可在github.com/PacktPublishing/Transitioning-to-Java/tree/chapter14找到。

理解 Java 应用程序服务器的作用

在 Jakarta EE 10 编程宇宙的中心是应用程序服务器。这些程序提供了一系列您的应用程序可以调用的服务。它们还包含了您的应用程序可能需要的所有 Jakarta 10 库。这意味着您的应用程序不需要包含所有必需的外部库,例如桌面应用程序必须在最终的 JAR 文件中包含的库。

应用程序服务器可以通过以下三种方式之一指定:

  • 第一项是平台。它提供了 Jakarta EE 10 服务的全部集合。

  • 第二项是 Web 配置文件,它提供平台服务的子集。

  • 最后,是核心配置文件。这是最小的配置文件,旨在为微服务提供基础设施。

下表显示了每个配置文件中可以找到哪些 Jakarta EE 10 库。每个配置文件右侧的列中的库(除核心库外)属于该配置文件。该平台包括 Web 配置文件和核心配置文件,而 Web 配置文件包括核心配置文件。随着 Jakarta EE 的发展,可以添加新功能,库也会更新。

平台 Web 配置文件 核心配置文件
授权 2.1 表达式语言 5.0 CDI Lite 4.0
激活 2.1 认证 3.0 JSON 绑定 3.0
批处理 2.1 并发 3.0 注解 2.1
连接器 2.1 持久化 3.1 拦截器 2.1
邮件 2.1 Faces 4.0 RESTful Web Services 3.1
消息传递 3.1 安全性 3.0 JSON 处理 2.1
企业 Bean 4.0 Servlet 6.0 依赖注入 2.0
标准标签库 3.0
服务器页面 3.1
CDI 4.0
WebSocket 2.1
Bean 验证 3.0
调试支持 2.0
企业 Bean Lite 4.0
管理 Bean 2.0
事务 2.0

表 14.1 – Jakarta EE 10 库/服务

预期平台服务器提供前表中列出的所有服务。Web 配置文件服务器提供 Web 配置文件和核心配置文件服务。最后,核心配置文件服务器仅支持其列中的内容。在本章中,我们只将查看其中的一些服务。

应用程序服务器由多家公司提供。这些服务器通常有一个免费的社区/开源版本,以及付费许可的版本。付费许可为您提供对服务器的支持。社区版本维护邮件列表,您可以在列表上提问,并从公司或其他社区版本用户那里获得回复。一个特定的服务器脱颖而出,那就是开源的。这就是 Eclipse GlassFish 服务器。这就是我们在本章中将使用的服务器。

GlassFish 7.0

GlassFish 服务器最初由太阳微系统公司开发,作为 Java EE 的参考服务器。这意味着任何希望被识别为 Java EE 服务器的其他服务器都需要通过与 GlassFish 相同的 技术兼容性工具包 测试套件,通常称为 TCK

当甲骨文公司收购了太阳微系统公司后,他们继续维护 GlassFish。2017 年,甲骨文决定不再开发 Java EE。他们将 Eclipse 基金会指定为 Java EE 的新家,该基金会随后将其更名为 Jakarta EE。技术转让包括了 GlassFish。这也意味着 Jakarta EE 和 GlassFish 都是开源的。

下载、安装和运行 GlassFish

GlassFish 服务器可以从 glassfish.org/ 下载。对于独立服务器有两个选择。如下所示:

  • Eclipse GlassFish 7.0.0 和 Jakarta EE 平台 10

  • Eclipse GlassFish 7.0.0 和 Jakarta EE Web Profile 10

此外,还有两个嵌入式版本。嵌入式版本可以用作应用程序的一部分。每个选择只有一个下载。这些不是 Linux、macOS 或 Windows 版本,因为它们都使用几乎相同的类文件和库,任何特定于操作系统的组件都是单个版本的一部分。这是一个 ZIP 文件。安装相当简单。以下是步骤:

  1. 下载 GlassFish。

  2. 将环境或 JAVA_HOME shell 变量设置为 JVM 的位置。

  3. 解压您下载的文件。它应该创建一个名为 glassfish7 的文件夹,您现在可以将其移动到您希望的位置。

  4. 前往 glassfish7 文件夹中的 bin 文件夹。

  5. bin 文件夹中打开终端或控制台窗口。

  6. 通过输入 asadmin start-domain 在任何操作系统上启动服务器。

在 Linux 上,在运行之前确保 asadmin 脚本是可执行的。在 Windows 上,您将运行 asadmin.bat 批处理文件。要停止服务器,请输入 asadmin stop-domain。您应该在控制台/终端窗口中看到消息,告诉您操作成功。如果您不成功,请查阅 GlassFish 网站上的更详细的安装说明。

  1. 要测试安装,请打开您的网页浏览器并输入 http://localhost:8080

GlassFish 监听的默认端口是 8080,用于服务器上运行的应用程序,以及 4848 用于访问管理控制台。如果需要,这两个端口都可以更改。

您应该看到一个看起来像这样的网页:

图 14.1 – 默认端口 8080 的网页

图 14.1 – 默认端口 8080 的网页

  1. 要访问管理控制台,请输入 http://localhost:4848

您现在应该看到以下内容:

图 14.2 – 端口 4848 的管理控制台

图 14.2 – 端口 4848 的管理控制台

你应该使用admin用户,因为你可能已经注意到,访问管理控制台时没有要求你输入密码。就我们的目的而言,在 GlassFish 上没有更多的事情要做。当我们想要测试我们的应用程序时,我们可以使用部署应用程序这个常用任务。

探索 GlassFish 并阅读其文档,你将在下载网站上找到它。它的默认配置就是我们所需要的。现在让我们创建构建 Maven Web 应用程序所需的必要文件夹结构。

使用 Maven 配置 Web 项目

制作 Web 应用程序的第一步是为 Maven 配置你的项目。首先,我们需要为任何使用 Maven 构建的 Jakarta EE 应用程序创建适当的文件夹结构。以下是设置所需的步骤:

图 14.3 – 使用 Maven 构建的 Jakarta EE 应用程序所需的文件夹

图 14.3 – 使用 Maven 构建的 Jakarta EE 应用程序所需的文件夹

Maven 桌面设置和 Web 设置之间的唯一区别是在main文件夹中添加了webapp文件夹。在这个文件夹中有一个WEB-INF文件夹和一个可选的styles文件夹。以下是文件夹的概述:

  • src/main/java: 所有 Java 源文件都存储在子文件夹/包中,就像我们在桌面应用程序中做的那样。

  • src/main/resources/: 语言包和日志配置文件放在这里。一些包可以放在子文件夹中,而另一些则不能。

  • src/main/webapp: 这是一个将包含任何静态网页、JavaServer 页面和 JavaServer Faces 页面的文件夹。你可以创建子文件夹。

  • src/main/webapp/WEB-INF: 这个文件夹包含配置文件和私有文件。私有文件可以是任何可能在WEB-INF文件夹中的东西。URL 不能包含这个文件夹,这就是为什么它们被认为是私有的。这个文件夹可以通过在服务器上运行的代码访问。

  • src/main/webapp/styles: 这个文件夹将包含任何 CSS 文件。这不是一个标准文件夹,所以你可以将 CSS 文件放在任何文件夹中,除了webapp文件夹中的WEB_INF

  • src/test: 这是任何单元测试或仅在运行单元测试时使用的其他文件的存放地。

当你的代码准备好编译时,你只需要在项目的根文件夹中打开一个终端/控制台窗口,并输入 Maven 命令mvn。如果没有错误,那么你将在项目中有一个名为target的新文件夹,在这里你可以找到.war文件。.war文件,就像.jar文件一样,是一个 ZIP 压缩文件。它们之间的区别在于它们在文件中的布局。Web 服务器期望的文件组织与桌面程序不同。

修改 pom.xml 文件

一个 Web 应用程序被打包在一个以war为扩展名的文件中。这个文件中的文件夹组织基于应用程序服务器的标准。这意味着对 POM 文件的第一次更改将如下所示:

    <packaging>war</packaging>

在我们的桌面 pom 文件中,我们包括了日志记录和单元测试的依赖项。我们将使用java.util.logging,从而消除所有日志记录依赖项。Web 应用程序的单元测试需要一个特殊的代码运行器,例如来自 Red Hat 的 Arquillian。我们不会涉及这部分内容,因此可以删除单元测试依赖项和插件。新的pom.xml文件现在,从properties开始,将包含以下内容:

    <properties>
        <java.version>17</java.version>
        <project.build.sourceEncoding>
            UTF-8
        </project.build.sourceEncoding>
        <maven.compiler.release>
            ${java.version}
        </maven.compiler.release>
        <jakartaee>10.0.0</jakartaee>
    </properties>

在下面的dependencies部分,我们展示了 Jakarta 库依赖项。请注意,scope设置被设置为provided,这意味着库不会被包含在 WAR 文件中:

    <dependencies>
        <dependency>
            <groupId>jakarta.platform</groupId>
            <artifactId>jakarta.jakartaee-api</artifactId>
            <version>${jakartaee}</version>
            <scope>provided</scope>
        </dependency>
    </dependencies>
    <build>
        <finalName>
            ${project.artifactId}
        </finalName>

与桌面应用程序不同,我们不能简单地运行一个 Web 应用程序。它必须被复制到 GlassFish 的相应文件夹中,然后打开浏览器来访问该网站。虽然有一些 Maven 插件可以为你完成这项工作,但我们将保持简单。Maven 将输出一个 WAR 文件,你可以使用 GlassFish 管理控制台来部署它:

        <defaultGoal>verify package</defaultGoal>
        <plugins>
            <plugin>
                <groupId>org.apache.maven.plugins</groupId>
                <artifactId>maven-war-plugin</artifactId>
                <version>3.3.2</version>
            </plugin>
        </plugins>
    </build>

部署应用程序还有另一种方法。在 GlassFish 中有一个名为glassfish7\glassfish\domains\domain1\autodeploy的文件夹。

如果你只是将 WAR 文件复制到这个文件夹,那么服务器将自动部署它。

现在,让我们看看 Java Web 编程的核心,即 servlet。

理解 servlet 的功能以及它的编码方式

在 Java Web 编程中,没有 main 方法。相反,所有应用程序至少必须有一个 servlet。当我们查看 Jakarta Faces 的客户端渲染时,没有 servlet,因为它已经是库的一部分。让我们看看一个 servlet。

第一行是一个注解,它定义了此类是一个 servlet。描述在服务器的管理控制台中可见。urlPattern属性是在 URL 中使用的名称。servlet 可以命名为任何东西,可以有任意扩展名,尽管标准做法是不使用扩展名。servlet 可以有多个模式。以下是一个 servlet 的示例:

@WebServlet(description = "Basic Servlet",
                     urlPatterns = {"/basicservlet"})

如果我们希望使用多个模式来引用此 servlet,我们可以编写以下内容:

@WebServlet(description = "Basic Servlet",
                     urlPatterns = {"/basicservlet",
                                      "/anotherservlet"})

Java 中的 servlet 是一个扩展HttpServlet的类:

public class BasicServlet extends HttpServlet {
    private static final Logger LOG =
          Logger.getLogger(BasicServlet.class.getName());

Servlet 类的构造函数很少使用,因为它不能调用HttpServlet超类中的任何方法。你可以安全地省略它:

    public BasicServlet() {
        LOG.info(">>> Constructor <<<");
    }

如果你必须在 servlet 接收到第一个请求之前准备或初始化某些内容,那么你可以使用init方法。它可以访问超类,但不会得到requestresponse对象。它在 servlet 在第一个请求到达之前运行时被调用:

    @Override
    public void init() throws ServletException {
        LOG.info(">>> init <<<");
    }

destroy方法类似于 C++中的析构函数。在服务器卸载之前,它会被服务器调用以执行任何必要的结束任务:

    @Override
    public void destroy() {
        LOG.info(">>> destroy <<<");
    }

getServletInfo方法允许你准备一个包含关于此 servlet 信息的字符串:

    @Override
    public String getServletInfo() {
        LOG.info(">>> getServletInfo <<<");
        return "BasicServlet01 Version 2.0";
    }

服务器在每次请求此 servlet 时都会调用service方法。service方法调用HttpServletRequest对象的getMethod以确定请求类型,然后调用匹配的do方法,例如doPostdoGet。覆盖此方法的常见原因是你希望无论请求类型如何都执行一项任务。在这个例子中,我们只是调用service超类方法,如果你没有在覆盖的service方法体中调用适当的方法,你必须这样做:

    @Override
    protected void service(HttpServletRequest request,
              HttpServletResponse response)
              throws ServletException, IOException {
        super.service(request, response);
        LOG.info(">>> service <<<");
    }

有八种不同的请求类型。这些都是 servlet 提供的 HTTP 动词,以支持协议。它们是GETPOSTPUTDELETEHEADOPTIONSCONNECTTRACE。前四种是最常用的,尽管只有GETPOST可以在 HTML 页面上使用。要测试无法从 HTML 页面发出的请求,你可以使用curl实用程序。这个工具允许你从你的计算机的终端/控制台中发送任何类型的请求。当你运行这个应用程序时,你会看到下载和使用curl的说明:

    @Override
    protected void doGet(HttpServletRequest request,
              HttpServletResponse response)
              throws ServletException, IOException {

response对象中可以返回给浏览器的不同内容类型。类型可以是image/gifapplication/pdf。纯文本是text/plain

        response.setContentType("text/html;charset=UTF-8");

为了让 servlet 返回文本给浏览器,我们使用PrintWriter对象。它由response对象实例化,这样你写入的数据就会发送到response对象中找到的 URL:

        try ( PrintWriter writer = response.getWriter()) {
            writer.print(createHTMLString("GET"));
        }
        LOG.info(">>> doGet <<<");
    }

这里是doPost方法,它将显示由createHTMLString创建的网页,并显示已发出POST请求:

    @Override
    protected void doPost(HttpServletRequest request,
              HttpServletResponse response) throws
              ServletException, IOException {
        response.setContentType("text/html;charset=UTF-8");
        try ( PrintWriter writer = response.getWriter()) {
            writer.print(createHTMLString("POST"));
        }
        LOG.info(">>> doPost <<<");
    }

这里是doPut方法。由于我们只能使用curl发出PUT,它只返回一个简单的字符串,curl将在你的终端/控制台中显示:

    @Override
    protected void doPut(HttpServletRequest request,
              HttpServletResponse response)
              throws ServletException, IOException {
        response.setContentType(
                        "text/plain;charset=UTF-8");
        try ( PrintWriter writer = response.getWriter()) {
            writer.print("You have called doPut");
        }
        LOG.info(">>> doPut <<<");
    }

这里是doDelete方法。就像PUT一样,你只能使用curl来发出它:

    @Override
    protected void doDelete(HttpServletRequest request,
              HttpServletResponse response)
              throws ServletException, IOException {
        response.setContentType(
                        "text/plain;charset=UTF-8");
        try ( PrintWriter writer = response.getWriter()) {
            writer.print("You have called doDelete");
        }
        LOG.info(">>> doDelete <<<");
    }

最后一种方法是一个用户方法,用于生成 HTML 代码字符串,该字符串可以返回给用户的浏览器。请注意,HTML 页面使用三个引号包含在一个文本块中。文本中还有一个占位符%s,它使用formatted字符串方法进行替换:

    private String createHTMLString(String methodName) {
        String htmlStr = """
           <html>
              <head><link rel='stylesheet'
                 href='styles/main.css'
                 type='text/css'/>
              <title>The Basic Servlet</title></head>
              <body>
                 <h1>%s method</h1>
                 <br/><br/>
                 <form action='index.html'>
                    <label>Return to Home page</label>
                    <br/>
                    <button class='button'>Return</button>
                 </form>
              </body>
           </html>
           """.formatted(methodName);
        return htmlStr;
    }
}

当请求一个 servlet 时会发生什么?

服务器在开始时或第一次调用 servlet 时,会实例化一个 servlet 类。一旦实例化,它就会保留在服务器上,直到你明确要求服务器将其删除。每个 servlet 只有一个实例。

每个请求都会生成一个 servlet 的线程。创建线程比创建对象更快。servlet 的线程可以自由地做几乎所有它想做的事情,比如实例化其他对象。如果一个线程在用户定义的时间段内(通常是 30 分钟)没有收到请求,它就会被停止,线程创建的对象将进入垃圾回收。

servlet 如何访问请求中的查询字符串?

假设有一个 HTML 表单,有三个名为 emailAddressfirstNamelastName 的输入字段。点击 submit 类型的按钮将创建一个查询字符串,如果你使用 GET 请求,它将被附加到 URL 上;如果你使用 POST,它将被添加到请求体中。在两种情况下,数据都是 key = value 格式。以下是一个这样的 HTML 页面:

<html>
    <head>
        <title>Just Servlet Input</title>
        <link rel="stylesheet" href="styles/main.css"
                                   type="text/css"/>
    </head>
    <body>
        <h1>Join our email list</h1>
        <p>To join our email list, enter your name and
            email address below.</p>
        <form action="AddToEmailList" method="get">
            <label class="pad_top">Email:</label>
            <input type="email" name="emailAddress"
                                         required><br>
            <label class="pad_top">First Name:</label>
            <input type="text" name="firstName"
                                          required><br>
            <label class="pad_top">Last Name:</label>
            <input type="text" name="lastName"
                                          required><br>
            <label>&nbsp;</label>
            <input type="submit" value="Join Now"
                                    class="margin_left">
        </form>
    </body>
</html>

这段 HTML 将生成以下页面:

图 14.4 – 浏览器对 HTML 的渲染

图 14.4 – 浏览器对 HTML 的渲染

在 HTML 中,我使用 method 属性来显示当按钮被按下时发出的请求类型是 GET。由于这个表单将数据提交到服务器,它应该使用 POST 方法。我这里使用 GET,因为它在地址栏中显示了查询字符串,而 POST 将查询字符串传输为请求的一个单独组件,因此是不可见的。如果你需要防止查询字符串中的信息以纯文本形式发送并在服务器日志中显示,也应该首选 POST

我已经填写了表单,当我点击按钮时,浏览器中的 URL 将更新为显示为单行:

http://localhost:8080/HTMLServlet/AddToEmailList?
                          emailAddress=moose%40moose.com&
                          firstName=Ken&lastName=Fogel

servlet 中的 doGet 方法现在可以读取三个参数。在我的例子中,我将这些数据存储在一个简单的 JavaBean 风格的对象中:

    @Override
    protected void doGet(HttpServletRequest request,
                   HttpServletResponse response)
                   throws ServletException, IOException {

使用查询字符串中键值的名称,我们可以检索数据,然后将它们分配给 User 对象:

        String firstName =
              request.getParameter("firstName");
        String lastName = request.getParameter("lastName");
        String emailAddress =
              request.getParameter("emailAddress");
        User user =
             new User(firstName, lastName, emailAddress);

在这里,我展示了使用名为 displayConfirmation 的方法构建的结果页面:

        response.setContentType("text/html;charset=UTF-8");
        try (PrintWriter out = response.getWriter()) {
            displayConfirmation(out, user);
        }
    }

servlet 的输出将如下所示:

图 14.5 – servlet 的输出

图 14.5 – servlet 的输出

不要忘记回顾本章的源代码。

servlet 如何记住我的数据?

每次你第一次调用应用程序中的 servlet 时,你将收到一个 ID 号码,该号码用于标识一个 HttpSession 对象。这个 ID 作为 cookie 发送到你的浏览器,或者如果你正在阻止 cookie,则在每次请求时作为 URL 中的隐藏字段发送。如果你已经在 cookie 中有了这个 ID,那么就会使用它。服务器管理这个 ID;你不需要做任何事情。服务器使用这个 ID 来管理 HttpSession 对象,并确保你的请求是唯一可以访问的。你可以使用以下代码访问会话对象:

HttpSession session = request.getSession();

如果与你的 ID 关联的 HttpSession 对象存在,则返回它。如果不存在,则返回一个新的 HttpSession 对象,它有自己的 ID。我们将在 HttpSession 对象中使用两种方法,一种用于读取,一种用于写入。如果你想在示例中保留 User 对象,以便在另一个 servlet 中使用,你将编写以下代码:

HttpSession session = request.getSession();
session.setAttribute("myUser", user);

这个HttpSession对象将保持有效,直到HttpSession计时器,通常是 30 分钟,结束或者你调用session.invalidate()。如果我们想在另一个 servlet 中检索User对象,那么我们可以写以下内容:

HttpSession session = request.getSession();
String animal = (User) session.getAttribute("myUser");

你不希望保留比必要更长的时间数据。存储在HttpServletRequest对象中的数据在给出响应后就会丢失。在许多情况下,这已经足够了。然而,如果你正在编写一个购物车应用,你希望保留客户在网站中从一个页面移动到另一个页面时所做的选择。这就是HttpSession对象被使用的地方。

现在我们来看一个可以用来配置服务器如何处理 servlets 的文件,称为web.xml

使用 web.xml 文件配置部署

在 Web 项目的WEB-INF文件夹中,通常有一个名为web.xml的文件。在@WebServlet注解引入之前,这是强制性的。有了这个注解,应用服务器可以自己确定哪些文件是 servlet,哪些不是。在这个文件中,你可以做的不仅仅是列出 servlet。因此,我建议你始终保留一个web.xml文件。

我们的描述符将会相当基础:

<web-app xmlns="https://jakarta.ee/xml/ns/jakartaee"
     xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
     xsi:schemaLocation=
                 "https://jakarta.ee/xml/ns/jakartaee
       https://jakarta.ee/xml/ns/jakartaee/web-app_5_0.xsd"
       version="5.0">
    <display-name>BasicServlet</display-name>
    <welcome-file-list>
        <welcome-file>index.html</welcome-file>
    </welcome-file-list>
</web-app>

我们有一个显示名称,应用服务器可以在报告中使用,然后是欢迎页面。欢迎页面是当 URL 不包含页面名称时显示的页面名称。假设你在浏览器中输入以下内容:

http://localhost:8080/BasicServlet/index.html

而不是写上面那样,你只需要写以下内容:

http://localhost:8080/BasicServlet

HTTP 协议是无状态的。这意味着每次你发起请求时,服务器都会表现得像是你第一次访问网站。应用服务器可以通过使用HttpSession对象来记住你。这个对象自你上次访问网站以来有一个默认的 30 分钟生命周期。当时间到了,对象就会失效,服务器将不再记住你。你可以通过在web.xml文件中添加以下内容来改变这个时间长度:

    <session-config>
        <session-timeout>
            30
        </session-timeout>
    </session-config>

在某些情况下,你可能有一些数据,以字符串的形式存在,这些数据在应用中的每个 servlet 中都是通用的——例如,需要在每个页面上显示的公司电子邮件地址。我们使用context-param来处理这种情况:

    <context-param>
        <param-name>email</param-name>
        <param-value>me@me.com</param-value>
    </context-param>

要在 servlet 中访问这个文件,我们只需要以下内容:

String email =
          getServletContext().getInitParameter("email");

现在你应该能够启动一个基于 servlet 的 Web 应用。

摘要

在本章中,我们探讨了 Web 应用的基础。这个宇宙的中心是 servlet。还有许多其他框架,如 Spring,提供了另一组库,但所有这些框架都建立在 servlet 规范之上,并依赖于 Jakarta 库。

Jakarta 是基于标准的。这意味着通过遵循 HTTP 协议,它可以向任何前端提供服务,例如 React.js、Bootstrap 和 Angular。在下一章中,我们将探讨一个前端编程库,即 Jakarta Faces,它是 Jakarta 框架的一部分。

在本章中,我们使用了 GlassFish 服务器,但还有许多其他选择用于 Java 应用程序服务器。例如,Payara 服务器基于 Glassfish,但由于它由 Payara 公司支持,因此它提供了 Glassfish 所没有的商业支持。还有来自 Red Hat、IBM 和其他公司的服务器。通常有一个社区版本,您可以在不支付商业许可证费用的情况下使用。

在我们查看服务器端编程时,我们需要对我们的 Maven pom.xml 文件进行修改。有了这些,我们就能像创建桌面.jar文件一样轻松地创建用于服务器的.war文件。

接下来,我们将通过检查一个将上一章中看到的财务计算器应用到网络的应用程序来更深入地了解 Jakarta EE。

进一步阅读

)

第十五章:Jakarta Faces 应用

Jakarta Faces,现在简称 Faces,是网络应用中两种客户端渲染技术之一。另一种是Jakarta Server PagesJSP)。在本章中,我们将检查一个 Faces 网络应用,就像我们的 Swing 和 JavaFX 示例一样,允许你执行三个常见的财务计算。

JSP 渲染方法允许在 HTML 页面上放置 Java 源代码。在 JSP 页面可以渲染之前,该文件由应用服务器转换为 servlet。如果你有 50 个 JSP 页面,那么应用服务器上将有 50 个 servlets。在应用设计中的典型方法是用 JSP 进行渲染,通过混合标准 HTML、表达式语言代码来访问数据或调用 Java 方法,以及 Java 源代码。这些文件以 .jsp 扩展名结尾。虽然你可以在页面上进行处理,但常见的方法是让 JSP 页面调用 servlet 进行处理,并决定将哪个 JSP 页面返回给浏览器。

Faces 方法相当不同。首先,框架提供了 Faces servlet。所有对 .jsf 页面的请求都由这个 servlet 处理。虽然 JSP 应用通常是 .jsp 页面和 servlets 的组合,但 Faces 应用不需要任何 servlets,尽管它们也可以使用。在 servlets 的位置,Faces 允许你通过在代码中添加一些注解来使用来自 第十三章,《使用 Swing 和 JavaFX 编写桌面图形用户界面编码》,的 CalculationsFinanceBean 类。

一个 Faces 页面包含 Facelets 以及任何标准 HTML 标签。Facelets 是 pom 文件。请参阅 进一步阅读 部分以获取这些第三方库的示例。

我们将通过使用 new 来与大多数对象交互。相反,CDI 框架在首次使用时实例化一个对象,并确定是否需要垃圾回收。我们也可以使用 if 语句。

本章现在将带你了解我们的金融计算器是如何使用 Faces、CDI 和 BV 编写的。我们将探讨以下主题:

  • 配置 Faces 应用

  • 创建由 CDI 管理并通过 BV 验证的对象

  • 使用 XHTML、Facelets 和 Jakarta 表达语言渲染页面

  • 理解 Faces 页面的生命周期

到本章结束时,你将了解如何构建 Faces 网络应用。有了这些知识,你将能够评估其他网络应用框架,如 Spring 和 Vaadin。

技术要求

对于本章,你需要以下内容:

  • Java 17

  • 文本编辑器

  • 安装 Maven 3.8.6 或更高版本

  • GlassFish 7.0 应用服务器

  • 网络浏览器

本章的示例代码可在 github.com/PacktPublishing/Transitioning-to-Java/tree/chapter15 找到。

配置 Faces 应用

这是金融计算器应用的网页版本看起来是这样的:

图 15.1 – 财务计算器网页

图 15.1 – 财务计算器网页

配置 Faces 项目开始于任何基本 Web 应用程序相同的设置,例如我们在上一章中看到的。Maven 文件夹设置是相同的。在WEB-INF文件夹中,我们有三个必需的 XML 配置文件和一个可选的。让我们从beans.xml开始:

<beans xmlns="https://jakarta.ee/xml/ns/jakartaee"
    xmlns:xsi=http://www.w3.org/2001/XMLSchema-instance
    xsi:schemaLocation="https://jakarta.ee/xml/ns/jakartaee
    https://jakarta.ee/xml/ns/jakartaee/beans_3_0.xsd"
       bean-discovery-mode="annotated">
</beans>

这看起来很奇怪,因为只有一个标签beans。在注解广泛使用之前,列出每个 bean 或类是必要的,以便启用 CDI。bean-discovery-mode标签定义了任何带有 CDI 注解的 bean 现在都受 CDI 的影响。在 Jakarta EE 10 之前,要使用的发现模式是all。当前的最好实践是使用annotated而不是all

下一个配置文件是faces-config.xml。在此文件中,您可以定义应用程序属性。其中一些属性可以是导航规则,以确定提交请求应转到哪个页面,需要实例化的 bean 对象,以及用于 i18n 支持的消息包。在此示例中,我们只使用此文件来定义此应用程序的消息包:

<faces-config version="4.0"
     xmlns="https://jakarta.ee/xml/ns/jakartaee"
     xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
     xsi:schemaLocation=
           "https://jakarta.ee/xml/ns/jakartaee
            https://jakarta.ee/xml/ns/jakartaee/
                                web-facesconfig_4_0.xsd">
    <application>
        <resource-bundle>
            <base-name>
                com.kenfogel.bundles.MessagesBundle
            </base-name>
            <var>msgs</var>
        </resource-bundle>
    </application>
</faces-config>

您可以在base-name中看到消息包的包和基本文件名。在var下,您可以看到我们可以在 Faces XHTML 页面中使用的标识符的名称。Faces 中的包与我们为桌面应用程序配置的资源包的方式相同,后面跟着一个键和一个值。要引用消息包中的值,我们使用表达式语言,如#{msgs.result}

最后必需的配置文件是web.xml文件。它可以承担我们在上一章中看到的相同职责。此外,我们还可以修改 Faces 的执行方式。为了简单起见,我已经删除了开闭标签,因为它们与上一章的版本相同。

第一个参数PROJECT_STAGE配置框架错误处理。使用Development错误消息比小段代码和较慢的性能携带更多信息。通常,代码完成后,你会将其更改为Production

<context-param>
    <param-name>jakarta.faces.PROJECT_STAGE</param-name>
    <param-value>Development</param-value>
</context-param>

下一个参数确定 Faces 页面中的注释是否在发送到浏览器的 HTML 中:

<context-param>
   <param-name>
       jakarta.faces.FACELETS_SKIP_COMMENTS
   </param-name>
   <param-value>true</param-value>
</context-param>

如果服务器明确销毁会话对象或会话超时,则会销毁该会话对象并将其发送到垃圾回收:

<session-config>
   <session-timeout>
      30
   </session-timeout>
</session-config>

当通过网站名称而不是特定页面来引用网站时,则此页面将被显示。在 Faces 中,它应该始终是一个.xhtml文件:

<welcome-file-list>
   <welcome-file>index.xhtml</welcome-file>
</welcome-file-list>

最后一个可选的配置文件是glassfish-web.xml。我们可以在该文件中提供数据库连接池、安全信息以及其他应用程序服务器负责的配置信息。在我的示例项目中,我已经删除了此文件,因为我们不需要它。

在项目组织就绪并放置配置文件后,我们就可以开始我们的应用程序了。在我们关注网页设计之前,我们需要设置应用程序所需并配置它们在 CDI 的照顾下工作。

创建由上下文依赖注入管理的对象并使用 Bean Validation 进行验证

在这个程序中只使用了两个 Java 类,它们与我们用于第十三章中,使用 Swing 和 JavaFX 进行桌面图形用户界面编码所用的几乎相同。它们都受 CDI 控制,数据类也使用了 BV。我们不会展示这些在第十三章中看到的 bean 的完整代码,我们只会看看需要更改的部分。

FinanceBean

第一个注解@Named定义了此类受 CDI 控制。在 CDI 广泛使用之前,JSF 有自己的 CDI-like 实现,使用了@ManagedBean注解。这被认为是过时的,不应再使用。括号中的名称money是我们可以在表达式语言中使用的别名:

@Named("money")

范围

当在 Jakarta 应用程序中由 CDI 管理的对象被创建或销毁,并且其他类可能访问它时,它被称为范围。有以下几种类型:

  • @RequestScoped:这意味着服务器将为每个请求创建一个新的对象,并且每个用户的前一个请求对象都会被发送出去进行垃圾回收。

  • @SessionScoped:这意味着在第一次请求创建的对象将保持原位,并且只有当会话计时器结束时或明确销毁时,才会销毁。

  • @ApplicationScoped:这些对象对所有用户的每个会话都可用。

  • @ViewScoped:这是最终的范围。使用此范围创建的 bean 与一个 Faces 页面相关联。只要你不更改视图,例如通过有一个链接或按钮调用另一个页面,那么 bean 仍然有效。

现在回到代码:

@RequestScoped
public class FinanceBean implements Serializable {
    private static final Logger LOG =
        Logger.getLogger(FinanceBean.class.getName());

每个 BigDecimal 变量都使用 BV 注解声明。在这个例子中,我们设置了一个最小值和最大值。message属性是名为ValidationMessages的单独消息包的键。就像普通包一样,你需要一个默认的,然后为每种支持的语言提供一个。这些验证包预计将在resources文件夹中找到,而不是在任何子文件夹中:

    @DecimalMin(value = "1.00",
            message = "{com.kenfogel.minInput}")
    @DecimalMax(value = "100000.00",
            message = "{com.kenfogel.maxInput}")
    private BigDecimal inputValue;
    @DecimalMin(value = "0.00",
          message = "{com.kenfogel.minInput}")
    @DecimalMax(value = "1.00",
          message = "{com.kenfogel.maxInput}")
    private BigDecimal rate;
    @DecimalMin(value = "1.00",
           message = "{com.kenfogel.minInput}")
    @DecimalMax(value = "300.00",
           message = "{com.kenfogel.maxInput}")
    private BigDecimal term;
    private BigDecimal result;

在原始的FinanceBean类中找不到的两个新字段。第一个是calculationType字段,它定义了使用哪三个计算公式中的哪一个。它还用于更新第一个输入字段的Label名称:

    private String calculationType;

当计算类型更改时,必须在第一个输入标签中显示新的文本。这将从资源包中读取:

    private final ResourceBundle msgs;
    public FinanceBean() {
        result = BigDecimal.ZERO;
        inputValue = BigDecimal.ZERO;
        rate = BigDecimal.ZERO;
        term = BigDecimal.ZERO;

在构造函数中,我们将计算定义为loan并初始化msgs

        calculationType = "loan";
        msgs = ResourceBundle.getBundle(
              "com.kenfogel.bundles.MessagesBundle");
   }

此数据类中剩余的方法只是常规的 getter 和 setter。

关于 CDI 和 BV 的最后一点是,它们可以用于任何包含 CDI 和/或 BV 库的 Java 应用程序。该库是 Jakarta 的一部分,因此在pom文件中没有特定的引用。要在您的应用程序中使用 CDI、BV 或两者,请将以下内容添加到您的pom文件中:

<dependency>
    <groupId>jakarta.enterprise</groupId>
    <artifactId>jakarta.enterprise.cdi-api</artifactId>
    <version>4.0.1</version>
</dependency>
<dependency>
    <groupId>jakarta.validation</groupId>
    <artifactId>jakarta.validation-api</artifactId>
    <version>3.0.2</version>
</dependency>

在 Faces 中,有一个称为后端 bean 的数据 bean,现在我们可以查看Calculations类。

计算

Calculations类也基本没有变化。所有三个计算方法中的公式都是相同的。第一个变化是FinanceBean对象现在是一个由 CDI 实例化的类字段,而不是传递给每个方法的参数。第二个变化是计算调用到一个方法,该方法反过来选择适当的计算方法。现在让我们看看这个。

类以定义此为 CDI 管理 bean 的注解开始。作用域为@RequestScope。CDI bean 是在将其注入到另一个类时实例化的,正如我们接下来将要看到的,或者是在 Faces 页面上首次使用时:

@Named("calculate")
@RequestScoped
public class Calculations implements Serializable {
    private static final Logger LOG =
      Logger.getLogger(Calculations.class.getName());

使用@Inject注解,CDI 将检查此对象当前是否存在。如果存在,则将其引用分配给名为money的变量。如果不存在,则将在将引用传递给money之前实例化它:

    @Inject
    FinanceBean money;

此方法将从 Faces 页面调用:

    public String performCalculation() {
        switch (money.getCalculationType()) {
            case "loan" ->
                loanCalculation();
            case "savings" ->
                futureValueCalculation();
            case "goal" ->
                savingsGoalCalculation();
        }
        return null;
    }

这是使用类字段获取用户输入并将结果存储的地方的计算过程之一:

    public void loanCalculation()
            throws ArithmeticException {
        // Divide APR by 12
        var monthlyRate = money.getRate().divide(
              new BigDecimal("12"), MathContext.DECIMAL64);

我们只是对 CDI 和 BV 的表面进行了探讨。参见进一步阅读以获取有关这些功能的更多信息。现在让我们继续到 Faces 的网页渲染。

使用 XHTML、Facelets 和表达式语言来渲染页面

Faces 应用程序使用扩展名为xhtml的文件。此扩展名意味着 HTML 或自定义标签(称为 Facelets)必须遵循 XML 的规则。这意味着每个标签都必须关闭。HTML 允许使用<br><p>等标签,而在 XHTML 中使用这些标签时,必须有一个开标签后跟一个闭标签。标签也可以通过以正斜杠结尾来自动关闭,例如<br/><p/>

让我们看看负责用户界面的index.xhtml文件。

我们首先声明此文件是 XHTML 格式:

<!DOCTYPE xhtml>

XML 文档将被检查以确保所有标签都是有效的。这里列出的五个命名空间代表了 Faces 中可用的常见标签集合:

<html xmlns:faces="jakarta.faces"
      xmlns:ui="jakarta.faces.facelets"
      xmlns:f="jakarta.faces.core"
      xmlns:h="jakarta.faces.html"
      xmlns:pt="jakarta.faces.passthrough" >

在这里,我们看到我们的第一个 Facelet,即h:head标签。当此文件被 Faces 框架处理时,每个 Facelet 都是一个调用 Java 方法的过程,该方法返回一个有效的 HTML 字符串,如果需要,还可以返回 JavaScript:

    <h:head>

这里我们看到我们的第一个表达式语言语句。在这种情况下,我们正在从 faces-config.xml 文件中定义的消息包中检索文本。请注意,我们还在使用一个 HTML 标签和一个标题,并且这些标签在处理 Faces 页面生成的 HTML 中被保留。在任何 Facelet 与 HTML 标签匹配的情况下,您都应该始终使用 Facelet:

        <title>#{msgs.title}</title>
        <h:outputStylesheet library="css" name="main.css"/>
    </h:head>
    <h:body>
        <h:form>
            <h1>#{msgs.heading}</h1>

在这里我们可以看到单选按钮输入的 Facelets。当我们调用表达式语言中的方法而不以括号结束调用时,我们表示我们想要 getCalculationType()setCalculationType()。如果不是 setter 或 getter,我们必须使用方法的全名后跟括号:

            <h:selectOneRadio
                value="#{money.calculationType}"
                immediate="true" styleClass="radiocenter" >
                <f:selectItem itemValue="loan"
                    itemLabel="#{msgs.loan_radio}"
                    styleclass="radiocenter"/>
                <f:selectItem itemValue="savings"
                     itemLabel="#{msgs.savings_radio}"
                     styleclass="radiocenter"/>
                <f:selectItem itemValue="goal"
                     itemLabel="#{msgs.goal_radio}"
                     styleclass="radiocenter" />

单选按钮的常见用途是在表单提交时提供所需的选择。在这个应用程序的设计中,我希望字段被清除,并且表单重新渲染。这种重新渲染还将更改第一个标签的输入标签文本。valueChange 事件表示将发生一个 Ajax 部分提交,这将调用 money.clear() 方法将所有值重置为零。render="@form" 属性将导致页面重新渲染:

                <f:ajax event="valueChange" render="@form"
                      action="#{money.clear()}"/>
            </h:selectOneRadio>

我们在这里使用 panelGrid,它创建一个 HTML 表格。您指定列数,而行数由 HTML 标签或 Facelets 的数量决定。每两行中的第一个值是一个非换行符。这将消耗一个表格单元格,但不会显示任何内容:

            <h:panelGrid columns="2" >
                <h:outputLabel value="&#160;"/>

第二个值是 h:message。这个 Facelet 默认为空白条目。如果发生错误,如无效输入或值超出范围,则会在输入字段上方显示一条消息。您可以使用 style 属性在此属性中编写 CSS,或者使用 styleclass 来引用 CSS 文件中的类:

                <h:message id = "inputError"
                   for="inputValue"
                   style="white-space:nowrap; color:red;
                   font-size: 100%;"/>

如果用户输入无效或未转换的输入,就会出现以下情况。这些消息以及 Faces 页面上的其他一切都可以使用 CSS 进行样式化:

图 15.2 – h:message 输出

图 15.2 – h:message 输出

这个输入标签的文本是从 FinanceBean 而不是直接从消息包中检索的。这就是标签可以根据单选按钮选择而改变的原因。

每个 h:inputText 字段包含一个 f:ajax Facelet。这将触发部分提交,允许将您输入的字符串转换为 BigDecimal 并检查它是否在范围内。否则,这些检查只有在按下 提交 按钮时才会发生。没有什么比填写完表格后,在按下 提交 按钮后才发现几个输入错误更令人烦恼的了。

Faces 框架负责将 String 转换为 BigDecimal。如果由于存在无效字符而失败,则匹配的 h:message 字段将显示来自消息包文件的错误消息。converterMessage 属性包含包的键值:

                <h:outputLabel id = "inputLabel"
                    value="#{money.getInputLabel()}"
                    for="inputValue" />
                <h:inputText value="#{money.inputValue}"
                  id="inputValue"
                  converterMessage="#{msgs.invalidInput}" >
                  <f:ajax event="blur"
                    render="inputError" />
                </h:inputText>
                <h:outputLabel value="&#160;"/>
                <h:message id="interestError"
                    for="interestValue"
                    style="white-space:nowrap;
                    color:red; font-size: 100%; " />
                <h:outputLabel value="#{msgs.interest}"
                    for="interestValue"/>
                <h:inputText value="#{money.rate}"
                 id="interestValue"
                  converterMessage="#{msgs.invalidInput}" >
                     <f:ajax event="blur"
                         render="interestError" />
                </h:inputText>

在文本中,我移除了这个表单上的两行,因为它们几乎与上一个相同。

在我们表单的底部有两个按钮。一个调用Calculations类来生成答案,而另一个重置所有字段并使单选按钮中的Load成为选择:

              <h:commandButton type="submit"
                action="#{calculate.performCalculation()}"
                value="#{msgs.submit}"  styleClass="btn" />
               <h:commandButton type="reset"
                 value="#{msgs.clear}" styleClass="btn2" >
                    <f:ajax event="click" execute="@this"
                    render="@form" />
                </h:commandButton>
            </h:panelGrid>
        </h:form>

这是一个基本的应用程序,但它应该能给您一个关于 Faces 应用程序如何工作的感觉。

部署 Faces Web 应用程序

就像本书中用 Maven 构建的每个示例程序一样,您只需要在project文件夹中打开一个终端/控制台窗口。在提示符下,您只需输入mvn。假设没有错误,您应该在目标文件夹中找到您的项目。

您可以将此文件复制并粘贴到上一章中讨论的autodeploy文件夹中。另一种选择是从 GlassFish 控制台部署应用程序:

图 15.3 – 从服务器部署

图 15.3 – 从服务器部署

选择部署应用程序将带您到一个表单,您可以在其中上传应用程序到服务器。随着我们的应用程序运行起来,让我们更深入地了解当我们与 Faces 页面交互时会发生什么。

理解 Faces 页面生命周期

第十四章中,我们看到了使用 servlet 的 Web 应用程序的基本生命周期。简单来说,一个提交请求被发送到 servlet,servlet 以请求对象的形式从页面接收数据,然后您编写必要的任务,然后从 servlet 或 HTML 或 JSP 页面返回响应。Faces 与这不同。

一个从.jsf页面请求开始的 Faces 页面生命周期有六个部分。以下是一个显示 Faces 生命周期步骤的图表:

图 15.4 – Faces 生命周期

图 15.4 – Faces 生命周期

让我们回顾每一部分:

  • 恢复视图:当一个请求到达时,会检查查询字符串。如果不存在,那么这很可能是第一次请求这个页面。这意味着页面可以在不经过其他任何阶段的情况下渲染。

  • String变量。这允许在将数据分配给与请求关联的 bean 之前对其进行验证和转换。与页面关联的 bean 被称为后端 bean。

  • StringBigDecimaldouble。您也可以编写自己的自定义转换器。如果在转换过程中出现任何问题,则忽略剩余的阶段,并调用h:message来显示错误消息。

转换之后是验证。有标准的验证器 facelets,以及允许您编写自定义验证器。如果使用 BV,它也会在这里被调用。如果验证失败,那么,就像失败的转换一样,生命周期会跳转到渲染响应。

  • 更新模型值:在这个阶段,在成功转换和验证之后,值被分配给后端 bean。

  • 调用应用程序:许多标签都有一个 action 属性,允许你调用后端 bean 中的方法。随着数据现在已在后端 bean 中,这些方法可以被调用。

  • 渲染响应:在这里,Faces 页面被渲染为 HTML 和 JavaScript。

有可能编写一个阶段监听器类,你可以在大多数阶段添加额外的任务。理解生命周期对于开发 Faces 页面至关重要。

摘要

本章简要介绍了 Jakarta Faces,支持框架如 CDI 和 BV,以及如何部署应用程序。查看生命周期应该能让你了解 Faces servlet 在做什么。虽然网页渲染通常是 JavaScript 框架的领域,但应该将 Faces 视为 JavaScript 方法的替代方案。它与 CDI 和 BV 的集成使其成为 Web 应用的坚实基础。BV 可以确保所有验证都在服务器上完成。这并不妨碍在 JS 中使用验证。然而,使用像curl这样的简单工具,如果验证仅在 JS 客户端发生,你就可以轻松提交无效数据。

在我们接下来的最后一章中,我们将探讨如何打包 Java 应用程序以便轻松部署到服务器或作为桌面应用程序分发。

进一步阅读

第四部分:打包 Java 代码

这一部分是关于你如何分发你的代码。你将学习如何创建一个自定义 Java 运行时,并将其与一个应用程序打包在一个单一文件安装程序中。包括 Java、应用程序服务器和你的应用程序在内的整个环境的分发,使用 Docker 作为云部署的第一步,是本部分的最终主题。

这一部分包含以下章节:

  • 第十六章在独立包和容器中部署 Java

第十六章:在独立包和容器中部署 Java

在最后一章中,我们将探讨不同的打包和分发 Java 应用程序的方法。我们已经看到了桌面应用程序的 JAR 文件和网络应用程序的 WAR 文件,以及如何部署它们。虽然这种方法对于部署来说是足够的,但在某些情况下,这种传统方法可以改进。

Java 很大。Java SE 分发中包含许多库,尽管你的应用程序可能只需要其中的一些。第三方或外部库也是如此。现代使用 Java 模块方法的打包允许你生成只包含你将使用的库部分的 JAR 或 WAR 文件。

在网络应用程序的情况下,这种打包方式可以将 WAR 文件的大小减少到仅包含所需外部库中的所需模块,而不是整个库。在桌面应用程序的情况下,要求计算机上已经安装了 Java 语言。Java 运行时现在已经模块化。这允许你创建不需要安装 Java 版本即可运行的可执行应用程序,而是将其作为打包安装程序的一部分。

最后,我们将探讨 Docker 容器系统。想象一个由不同 操作系统OSes)的开发商组成的团队,他们正在开发一个应用程序。虽然 Java 是 一次编写,到处运行,但有时让每个开发商在相同的环境中工作是有利的。Docker 容器有助于满足这一需求。此外,你可以将这些容器部署到云端。虽然我们不会探讨云部署,但了解容器的工作原理将使你为在云中工作做好准备。

本章我们将探讨以下内容:

  • 探索模块化 Java 是什么

  • 使用 jlink 创建自定义 JRE

  • 使用 jpackage 进行打包安装

  • 使用 Docker 容器系统

  • 与 Docker 镜像一起工作

  • 创建 Docker 镜像

  • 发布镜像

我们将使用来自 第十三章使用 Swing 和 JavaFX 进行桌面图形用户界面编码,最初名为 BankSwing,在本章中重命名为 BankSwingJUL 的修改版本来探索模块和包。为了查看 Docker,我们将使用上一章未更改的 JSF_FinancialCalculator

技术要求

你需要以下内容来完成本章:

  • Java 17。

  • 文本编辑器。

  • Maven 3.8.6 或更高版本。

  • 对于 jpackage:

    • rpm-build 软件包

    • fakeroot 软件包

    • macOS: Xcode 命令行工具

  • Docker Desktop (https://www.docker.com/)。要使用 Docker,你需要创建一个账户。免费的个人账户就足够了。一旦你有了账户,你就可以下载 Docker Desktop。每个操作系统都有一个版本。

本章的示例代码可在github.com/PacktPublishing/Transitioning-to-Java/tree/chapter16找到。

探索模块化 Java 是什么

到目前为止,我们看到了由类字段和方法组成的类中的代码。然后,我们将这些类分组到包中,最后作为一个 JAR 或 WAR 文件。模块化 Java 引入了一个新的分组,称为模块。模块是一个 JAR 文件,但包含模块描述符。还有一个自动模块,其清单文件中有模块名称。这个 Java 特性被称为Java 平台模块系统JPMS)。

到目前为止,我们使用 Maven 构建我们的应用程序。Maven 是一个构建工具,它会下载我们需要的任何库,并确保我们的代码可以成功编译。它不做的是确定所有必需的外部库,例如 Java 本身,是否都存在。它的主要工作在代码成功编译后结束。另一方面,JPMS 专注于成功运行程序所需的库。与 Maven 不同,JPMS 检查作为模块编写的库是否存在或将在代码运行时存在。这引出了一个问题,什么是模块?

模块是一个 JAR 文件。普通 JAR 文件和模块 JAR 文件之间有一些细微的差别。至少,模块文件必须在src/main/java文件夹中包含一个名为module-info.java的文件。这个文件的一个目的是列出所需的模块。可能没有所需的模块,但这个文件的存在表示该项目可以是一个模块。并非所有用 Java 编写的库都已被重新编码为模块,但许多新的库都是这样编写的。模块文件可以用作普通 JAR 文件,也可以在使用 JPMS 工具时用作模块。你不需要两个版本的库。

曾经,用户有 Java 的两个版本可用。有一个包含 JVM 和所有必需的开发工具,如 Java 编译器的 JDK。第二个版本是Java 运行时版JRE)。正如其名称所暗示的,JRE 包含运行几乎任何 Java 程序所需的所有库。JRE 的大小显著减小,大约为 90 MB,而完整的 JDK 大约为 300 MB。随着 JPMS 的引入,JRE 不再作为下载提供。时代在变化,现在一些 Java 发行版又包含了 JRE。

由于 JRE 比 JDK 小得多,模块能为我们做什么呢?原因与为什么 JRE 从 Java 发行版中删除有关。使用 JPMS,你可以构建自己的自定义 JRE,只包含你需要的模块。那么,Java 语言中的模块是什么呢?在终端/控制台窗口中,输入以下内容:

java --list-modules

现在,你将获得每个模块的列表。在我的 Windows 11 系统上,有 71 个模块被列出——22 个以java开头,49 个以jdk开头。为了构建自定义 JRE,你需要知道你的程序使用了哪些模块。从本章的 GitHub 获取BankSwingJUL项目。与第十三章版本的不同之处在于JUL替换了log4j2。我这样做是为了将所需的模块数量减少到 Java 发行版中的那些。构建项目,你应该在target文件夹中找到一个名为BankSwingJUL-0.1-SNAPSHOT.jar的 JAR 文件。在target文件夹中打开一个终端/控制台窗口,并输入以下命令:

jdeps BankSwingJUL-0.1-SNAPSHOT.jar

输出将开始于你需要用到的 Java 模块的摘要:

BankSwingJUL-0.1-SNAPSHOT.jar -> java.base
BankSwingJUL-0.1-SNAPSHOT.jar -> java.desktop
BankSwingJUL-0.1-SNAPSHOT.jar -> java.logging

输出的剩余部分将查看项目中的每个类,显示你正在使用的 Java 类以及它们所属的模块。java.base模块是核心类集的家园。java.desktop模块是 Swing 的家园,而java.logging模块是 JUL 的家园。现在,是时候创建我们的自定义 JRE 了。

使用 jlink 创建自定义 JRE

我们将使用 Java JDK 的一部分jlink工具来创建我们的自定义 JRE。我们将首先创建一个包含所有必需模块的 JRE:

jlink --add-modules ALL-MODULE-PATH --output jdk-17.0.2-jre
      --strip-debug --no-man-pages --no-header-files
      --compress=2

这是一行。在 Linux 中,你可以使用反斜杠(\)输入多行命令,而在 Windows 中,你使用撇号(^)。此命令的输出将是一个名为jdk-17.0.2-jre的文件夹,其中包含仅 76 MB 的 JRE。这比原始 JRE 小,但我们不需要所有 Java 模块;我们只需要三个。以下是我们的新命令:

jlink --add-modules java.base,java.desktop,java.logging
      --output jdk-17.0.2-minimaljre --strip-debug
      --no-man-pages --no-header-files --compress=2

现在,我们将在jdk-17.0.2-minimaljre文件夹中有一个新的 JRE,大小仅为 41 MB。现在,我们需要使用我们的自定义 JRE 与我们的应用程序一起使用。为了测试我们的 JRE 是否工作,你可以在自定义 JRE 的bin文件夹中打开一个终端/控制台窗口,然后执行以下命令来运行你的代码。请注意,路径是为 Windows 准备的,因此它们必须根据 Linux 或 Mac 进行调整:

java.exe -jar C:\dev\Packt\16\BankSwingJUL\target\
  BankSwingJUL-0.1-SNAPSHOT.jar

这是一个单行命令。如果一切顺利,你的BankSwingJUL应用将运行。现在,是时候将应用程序打包成一个包含我们的应用程序和 JRE 的单个可执行文件了。这将使我们能够在不需要接收者首先安装 Java 的情况下分发我们的应用程序。

使用 jpackage 进行打包

自定义 JRE 创建完成后,我们现在可以创建自定义的可安装包了。你可以为 Windows、Linux 或 Mac 创建这些包。你必须使用你的包的目标操作系统。此外,每个操作系统都有额外的步骤。

Windows 需要您安装 WiX 工具集。您可以在wixtoolset.org/找到它。下载最新版本并安装。当您运行jpackage时,它将生成一个 EXE 文件。您可以分发此文件,当运行时,它将在C:\Program Files目录中安装运行程序所需的所有内容。可执行 EXE 文件将位于文件夹中,这就是您运行程序的方式。

Linux 用户,根据他们使用的版本,可能需要rpm-buildfakeroot包。当您运行jpackage时,它将为 Debian Linux 生成一个 DEB 文件,或其他发行版生成一个 RPM 文件。您可以分发此文件,当运行时,它将在/opt/application-name目录中安装运行程序所需的所有内容。可执行文件将位于文件夹中,这就是您运行程序的方式。

Mac 用户需要 Xcode 命令行工具。当您运行jpackage时,它将生成一个 DMG 文件。您可以分发此文件,当运行时,它将在/Applications/application-name目录中安装运行程序所需的所有内容。可执行文件将位于文件夹中,这就是您运行程序的方式。

在所有三种情况下,没有必要安装 Java。即使安装了,您也将使用自定义的 JRE。

要使用jpackage创建安装程序包,您只需在命令行中输入以下内容:

jpackage --name BankSwingJUL
  --input C:\dev\Packt\16\BankSwingJUL\target
  --main-jar BankSwingJUL-0.1-SNAPSHOT.jar
  --runtime-image C:\dev\Packt\16\jre\jdk-17.0.2-minimaljre
  --dest C:\temp

这是一个单行命令。以下是参数的概述:

  • --name:添加-1.0的可执行文件名称。使用--app-version后跟版本标识来覆盖此名称。

  • --input:您要打包的 JAR 文件的存放位置。

  • --main-jar:包含具有main方法的类的 JAR 文件的名称。如果您在 JAR 文件中没有包含具有main方法类的MANIFEST.MF文件,您可以使用--main-class后跟包含main方法的类的名称。

  • --runtime-image:这是您使用jlink创建的 JRE 文件夹的路径和名称。

  • --dest:默认情况下,打包的应用程序将位于您发出jpackage命令的任何文件夹中。您可以使用此参数选择您想要的文件夹。

在此命令成功完成后,您将拥有一个可执行的程序包,该程序包将安装您的程序,并包含一个可执行文件来运行它。

网络应用程序依赖于应用程序服务器而不是 JRE 来运行。因此,我们不能使用jpackage。这就是我们的下一个打包选择,Docker 容器。

使用 Docker 容器系统

Docker 是一个平台即服务系统,允许你构建一个运行中的应用程序的镜像,该镜像可以在虚拟化的 Linux 容器中运行。这些镜像可以在支持 Docker 容器的任何计算机上运行。这包括 Windows 和 Linux 发行版。这个镜像可以包含运行程序所需的一切。在本节中,我们将创建一个包含 Java 应用服务器、Java JDK 和我们的JSF_FinancialCalculator Web 应用程序的镜像,并在容器中部署它。这之所以重要,是因为大多数云提供商,如 AWS,都支持在 Docker 容器中部署云应用程序。我们不会讨论云部署,因为不同的云提供商工作方式不同。他们共同点是使用 Docker。

第一步是安装 Docker 系统。最简单的方法是从www.docker.com/下载并安装 Docker Desktop 系统。它为 Windows、Mac 和 Linux 都提供了版本,它们包含 GUI 界面以及命令行工具。在支持 WSL2 的 Windows 10 或 11 系统上,命令行工具在 Windows 终端和 WSL2 Linux 终端中都可用。这意味着,除了文件路径的描述方式不同外,所有命令在所有操作系统上都是相同的。现在,花点时间安装 Docker。

与 Docker 镜像一起工作

虽然我们可以从头开始构建镜像,但还有另一种方法。许多为云而创建软件的组织会提供预构建的镜像。我们可以将这些镜像添加到我们的应用程序中。在我们的例子中,我们想要一个包含 Java 17 和应用服务器的预构建镜像。我们将使用来自 Payara 的镜像。这家公司提供基于 GlassFish 的服务器,在开源社区版本和商业付费版本中都进行了增强。

Docker Hub 上的镜像可能是出于恶意目的而创建的。虽然 Docker 提供了一种扫描漏洞的服务,但你仍应扫描镜像中的任何可执行文件,以检查潜在的恶意行为。你注册的 Docker 计划决定了你可以从或推送到 Hub 的镜像数量。使用免费的个人订阅,你可能可以推送无限数量的公共仓库,但每天的限制为 400 次镜像拉取。商业订阅增加了从仓库拉取的次数,并可以对您的镜像执行漏洞扫描。

启动 Docker Desktop。它包含一个包含基本 Web 服务器的镜像和容器,该服务器具有 Docker 的文档页面。我们将大部分设置工作在命令行上完成,而桌面 GUI 对于查看 Docker 镜像和容器的状态非常有用。

第一步是下载我们将要修改的镜像,通过添加JSF_FinancialCalculator应用程序。我们将使用这个程序,与上一章保持不变。以下是命令:

docker pull payara/server-full:6.2023.2-jdk17

如果你访问 hub.docker.com/r/payara/server-full/tags,你可以看到所有可用的 Payara 服务器版本。正如你从之前的命令中看到的,我们正在拉取包含服务器和 Java 17 的 server-full:6.2023.2-jdk17 镜像。在 Docker 中,成功的命令返回一串长数字。

现在,我们需要在一个容器中运行这个镜像。虽然你可以运行多个容器,但使用 TCP 端口的网络应用程序可能会导致冲突。因此,我建议停止任何正在运行的容器。使用 Docker Desktop,从菜单中选择容器列表,查找任何列为 运行中 的容器,然后通过点击 操作 列中的方块按钮来停止它们。你还可以通过在命令行中输入以下内容来停止容器:

docker stop my_container

在这里,my_container 被替换为正在运行的容器或镜像的名称。

我们现在希望将这个镜像包装在一个容器中并运行这个镜像:

docker run --name finance -p 8080:8080 -p 4848:4848
            payara/server-full:6.2023.2-jdk17

这是一个单行命令。--name 开关允许你为容器分配一个名称。如果你省略了这个开关,Docker 将分配一个随机名称。-p 开关将容器中的端口映射到计算机的端口。在这个例子中,我们将它们映射到相同的端口。镜像的名称与我们所拉取的镜像名称相同。假设没有错误,你现在可以测试容器了。打开你的浏览器,首先通过输入 http://localhost:8080 访问 Payara 主页。接下来,访问管理控制台页面 https://localhost:4848。你的浏览器可能会发出警告,因为 TLS 证书是自签名的。忽略警告,你应该会到达登录页面。用户名和密码都是 admin

在上一章的 JSF_FinancialCalculator 示例中,你可以在项目的 target 文件夹中找到它。

你现在可以通过在浏览器中输入 http://localhost:8080/JSF_FinancialCalculator 来验证应用程序是否已正确部署。项目名称必须与 WAR 文件名称匹配。如果一切正常,计算器在你的浏览器中打开,你现在可以基于 payara/server-full:6.2023.2-jdk17 图像创建自己的容器,该容器将包含服务器上安装的计算器应用程序。

创建 Docker 图像

现在,我们准备创建自己的镜像。首先,我们需要停止我们刚刚使用的容器:

docker stop finance

在终端/控制台中,输入以下命令来创建你的新容器,该容器将包含 Payara 图像:

docker create --name transition -p 8080:8080
         -p 4848:4848 payara/server-full:6.2023.2-jdk17

名称 transition 是任意的,可以是任何你想要的内容。你现在有一个基于 Payara 图像的新容器。我们希望修改这个容器以包含计算器应用程序。第一步是运行这个新容器:

docker start transition

在这里最常发生的错误是如果另一个容器正在监听相同的端口。确保任何运行 Payara 的容器或镜像都没有运行。Docker Desktop 应用程序可以显示哪些容器或镜像正在运行。

就像我们在测试 Payara 镜像时做的那样,使用您的浏览器打开 Payara 的管理控制台。现在,将JSF_FinancialCalculator WAR 文件部署到服务器上。通过访问应用程序的网页来验证它是否成功运行。

现在,通过输入以下内容,将容器中的镜像更改,将 Web 应用程序的添加永久化:

docker commit transition

最后一步。输入以下内容:

docker images

您将看到REPOSITORYTAG都显示为<none>的条目:

REPOSITORY  TAG     IMAGE ID       CREATED          SIZE
<none>      <none>  c0236a80bba3   52 minutes ago   618MB

为了解决这个问题,并在创建镜像的最后一步,通过输入以下内容来分配一个标签名称和镜像 ID:

docker tag c0236a80bba3  transition-image

注意,必须使用的十六进制数字可以在之前的docker images命令的表中找到。在运行docker tag之后运行docker images,表将显示以下内容:

REPOSITORY        TAG     IMAGE ID    CREATED      SIZE
transition-image  latest  c0236a80bba 32 hours ago 618MB

您现在已经在本地仓库中配置了一个镜像。为了任何人都能使用您的镜像,您必须在 Docker Hub 网站上发布它。

发布一个镜像

如前所述,出于安全原因,您用作新镜像基础的任何镜像都必须进行漏洞扫描,特别是镜像中的任何可执行代码。免费的个人版允许您拥有无限数量的公共镜像。付费层支持私有镜像。发布的第一步是在 Hub 上创建一个仓库。为此,打开您的浏览器并转到hub.docker.com/。如果需要,请登录您的账户。

接下来,从网页顶部的选项中选择仓库。您现在将看到您可能已经创建的任何仓库。点击创建仓库。在此页面上,您必须填写表格,输入容器的名称以及可选的描述。它还显示了您的 Docker 用户名。确保公共是仓库类型的选项。

现在,您可以将您的镜像推送到 Hub。这里有三个步骤:

  1. 登录到 Docker Hub:

    docker login --username my_username
    

my_username替换为您的 Docker 用户名。您现在将被要求输入您的密码。您将收到成功登录的确认。

  1. 接下来,您需要更改您的镜像标签transition-image,以匹配您创建的仓库名称omniprof/transitioning_to_java。该名称由您的用户名和仓库名称组成:

    docker tag transition-image omniprof/
      transitioning_to_java
    
  2. 现在是最后一步,将您的镜像推送到 Hub:

    docker push omniprof/transitioning_to_java
    

为了确定您是否成功,请访问 Docker Hub 并选择omniprof/transitioning_to_java

您现在有一个可以与您的团队或客户共享的 Docker 镜像。

摘要

在本章中,我们探讨了模块化 Java 的含义。我们利用了 Java 自身已经模块化的这一事实。这使得你可以使用jlink构建一个比 JDK 小得多的 JRE。你甚至可以通过只包含你的代码所依赖的模块来进一步减小其大小。

我们然后探讨了两种分发代码的方法。第一种方法是使用jpackage为你的应用程序创建一个安装程序。安装程序可以包含你定制的 JRE,并将安装你的程序,以及一个可执行文件来运行应用程序。这通常是分发桌面应用程序的最佳方式。

第二种分发方法使用 Docker 容器系统。Docker 允许我们构建和发布一个包含不仅我们的代码和 JDK,还包括任何其他所需程序的镜像。在我们的例子中,额外的程序是一个安装了财务应用程序的应用服务器。我们构建的镜像被发布到仓库中,例如 Docker Hub。现在,任何在任意操作系统上运行 Docker 的人都可以拉取我们的镜像,并在 Docker 容器中运行它。

这也带我们来到了本书的结尾。我的目标是提供一个参考,帮助那些需要学习和理解 Java 的资深开发者。还有很多东西要学习,但我希望这本书能让你走上正确的道路。

进一步阅读

posted @ 2025-09-10 15:11  绝不原创的飞龙  阅读(28)  评论(0)    收藏  举报