Java9-编程示例-全-

Java9 编程示例(全)

原文:zh.annas-archive.org/md5/ccdb3a568aa2bef9cabd623af3ceea5f

译者:飞龙

协议:CC BY-NC-SA 4.0

前言

Java 随着 Java 8 的引入而发生了巨大变化,这种变化在新版本 Java 9 中达到了一个新的高度。Java 有着坚实的过去,超过 20 年历史,但与此同时,它又是新的,函数式的,反应式的,并且性感。这是一种开发者喜爱的语言,同时也是许多企业项目的首选开发语言。

现在学习 Java 可能比以往任何时候都更有利可图,从 Java 9 开始。我们鼓励你通过学习 Java 9 开始你的专业开发者生涯,在这本书中,我们尽力帮助你走这条路。我们整理了书中的主题,使其易于开始,你可以很快感受到事物的工作和移动。同时,我们试图走得很远,为专业开发者指明前方的道路。

时间之沙不断流逝,我发现了函数式编程。

我完全理解为什么编写无副作用的代码有效!我被深深吸引,并开始尝试 Scala、Clojure 和 Erlang。在这里,不可变性是常态。

然而,我很好奇在函数式环境中传统算法会是什么样子,于是开始学习这方面的知识。

数据结构永远不会原地修改。相反,会创建数据结构的新版本。最大化共享的复制和写入策略非常吸引人!所有那些仔细的同步根本就不需要!

这些语言都配备了垃圾回收功能。因此,如果一个版本不再需要,运行时就会负责回收内存。

虽然如此,但一切都在合适的时机!阅读这本书将帮助你看到,我们在避免原地修改的同时,不必牺牲算法性能!

这本书涵盖了什么内容

第一章,Java 9 入门,让你在 Java 上快速入门,帮助你安装它到你的电脑上,并使用新的 Jshell 运行你的第一个交互式程序。

第二章,第一个真正的 Java 程序 - 排序名称,教你如何创建开发项目。这次,我们将创建程序文件并编译代码。

第三章,优化排序 - 使代码专业,进一步发展代码,使其可重用,而不仅仅是玩具。

第四章,大师 - 创建游戏,是开始有趣事情的时候。我们开发一个有趣的应用程序,它并不像最初看起来那么简单,但我们会的。

第五章,扩展游戏 - 并行运行,更快运行,展示了如何利用现代架构的多处理器能力。这是一章非常重要的内容,详细介绍了只有少数开发者真正理解的技术。

第六章,将我们的游戏专业化为 Web 应用 - 以 Web 浏览器为基础进行操作,将用户界面从命令行转换为基于 Web 浏览器的,提供更好的用户体验。

第七章,使用 REST 构建商业 Web 应用程序,带你了解具有许多商业应用程序特性的应用程序的开发过程。我们将使用在企业计算中已经确立标准的 REST 协议。

第八章,扩展我们的电子商务应用程序,帮助你利用现代语言功能,如脚本和 lambda 表达式,进一步开发应用程序。

第九章,使用响应式编程构建会计应用程序,教你如何使用响应式编程方法来处理一些问题。

第十章,将 Java 知识提升到专业水平,提供了一个俯瞰视角,展示了在 Java 开发者生活中扮演重要角色的开发者主题,并将指导你进一步作为专业开发者工作。

你需要这本书什么

为了沉浸于本书的内容并吸收大部分技能和知识,我们假设你已经有一些编程经验。我们不会期望太多,但希望你已经知道什么是变量,计算机有内存、磁盘、网络接口,以及它们通常是什么。

除了这些基本技能外,还有一些技术要求来尝试本书的代码和示例。你需要一台计算机——今天可以运行 Windows、Linux 或 OSX 的设备。你需要一个操作系统,可能这就是你需要支付的所有费用。所有其他你需要的工具和服务都是开源的,免费的。其中一些也是作为具有扩展功能集的商业产品提供的,但就本书的范围而言,开始学习 Java 9 编程,这些功能不是必需的。Java、开发环境、构建工具以及我们使用的所有其他软件组件都是开源的。

这本书是为谁准备的

这本书是为任何想要学习 Java 编程语言的人准备的。你应具备一些其他语言的编程经验,例如 JavaScript 或 Python,但不需要了解 Java 的早期版本。

习惯用法

在本书中,你会发现许多文本样式,用于区分不同类型的信息。以下是一些这些样式的示例及其含义的解释。

文本中的代码单词、数据库表名、文件夹名、文件名、文件扩展名、路径名、虚拟 URL、用户输入和 Twitter 昵称如下所示:“以下函数f有一个副作用。”

代码块设置为如下:

package packt.java9.by.example.ch03; 

public interface Sort { 
 void sort(SortableCollection collection); 
}

如果需要突出显示一行(或多行)代码,它将设置为如下:

id=123 
title=Book Java 9 by Example 
description=a new book to learn Java 9 
weight=300 
width=20 
height=2 
depth=18

新术语重要词汇以粗体显示。你在屏幕上看到的单词,例如在菜单或对话框中,在文本中如下所示:“点击下一步按钮将您移动到下一屏幕。”

警告或重要注意事项以如下框的形式出现。

小贴士和技巧看起来像这样。

读者反馈

我们始终欢迎读者的反馈。告诉我们您对这本书的看法——您喜欢或不喜欢什么。读者反馈对我们很重要,因为它帮助我们开发出您真正能从中获得最大收益的标题。

要向我们发送一般反馈,只需发送电子邮件至 feedback@packtpub.com,并在邮件主题中提及书的标题。

如果您在某个主题上具有专业知识,并且您对撰写或为书籍做出贡献感兴趣,请参阅我们的作者指南www.packtpub.com/authors

客户支持

现在你已经是 Packt 图书的骄傲拥有者,我们有许多事情可以帮助你从你的购买中获得最大收益。

下载示例代码

您可以从github.com/PacktPublishing/Java-9-Programming-By-Example的账户下载此书的示例代码文件。如果您在其他地方购买了这本书,您可以访问www.packtpub.com/support并注册,以便将文件直接通过电子邮件发送给您。

您可以通过以下步骤下载代码文件:

  1. 使用您的电子邮件地址和密码登录或注册我们的网站。

  2. 将鼠标指针悬停在顶部的“支持”标签上。

  3. 点击“代码下载与错误清单”。

  4. 在搜索框中输入书的名称。

  5. 选择您想要下载代码文件的书籍。

  6. 从下拉菜单中选择您购买此书的来源。

  7. 点击“代码下载”。

文件下载完成后,请确保您使用最新版本的以下软件解压缩或提取文件夹:

  • Windows 的 WinRAR / 7-Zip

  • Mac 的 Zipeg / iZip / UnRarX

  • Linux 的 7-Zip / PeaZip

该书的代码包也托管在 GitHub 上,网址为 https://github.com/PacktPublishing/Java-9-Programming-By_Example。我们还有其他来自我们丰富图书和视频目录的代码包可供在github.com/PacktPublishing/找到。查看它们吧!

错误清单

尽管我们已经尽最大努力确保内容的准确性,但错误仍然可能发生。如果您在我们的某本书中发现了错误——可能是文本或代码中的错误——如果您能向我们报告这一点,我们将不胜感激。通过这样做,您可以节省其他读者的挫败感,并帮助我们改进此书的后续版本。如果您发现任何错误清单,请通过访问www.packtpub.com/submit-errata,选择您的书,点击“错误清单提交表”链接,并输入您的错误清单详情。一旦您的错误清单得到验证,您的提交将被接受,错误清单将被上传到我们的网站或添加到该标题的错误清单部分。

要查看之前提交的勘误表,请访问www.packtpub.com/books/content/support,并在搜索字段中输入书籍名称。所需信息将出现在勘误表部分。

海盗行为

互联网上对版权材料的盗版是一个持续存在的问题,涉及所有媒体。在 Packt,我们非常重视保护我们的版权和许可证。如果您在互联网上发现任何形式的非法副本,请立即提供位置地址或网站名称,以便我们可以追究补救措施。

请通过 copyright@packtpub.com 与我们联系,并提供涉嫌盗版材料的链接。

我们感谢您在保护我们作者和我们为您提供有价值内容的能力方面的帮助。

询问

如果您对本书的任何方面有问题,您可以联系我们的 questions@packtpub.com,我们将尽力解决问题。

第一章:Java 9 入门

你想学习 Java,并且你有一个很好的理由。Java 是一种现代且成熟的编程语言,在许多行业中广泛使用,无论是电信、金融还是其他行业。Java 开发者的职位数量最多,可能也是薪酬最高的。这使语言对年轻专业人士来说具有吸引力。另一方面,这并非没有原因。Java 语言、工具以及围绕它的整个基础设施都是复杂和综合的。成为一名 Java 专业人士不是一天或一周就能发生的;这是一项多年的工作。要成为 Java 专家,你需要了解的不仅仅是编程语言,还包括面向对象编程原则、开源库、应用服务器、网络、数据库以及你可以成为专家的许多其他事物。尽管如此,学习语言是绝对必须的,所有其他实践都应建立在它之上。通过这本书,你将能够学习 Java 版本 9 以及更多内容。

在本章中,您将了解 Java 环境,并获得如何安装它、编辑示例代码、编译和运行 Java 的逐步指导。您将熟悉帮助开发的基本工具,无论是 Java 的一部分还是由其他供应商提供的。本章将涵盖以下主题:

  • Java 简介

  • 安装 Windows、Linux 和 Mac OS X

  • 执行jshell

  • 使用其他 Java 工具

  • 使用集成开发环境

Java 入门

这就像在森林中走一条路。你可以专注于道路上的砾石,但这毫无意义。相反,你可以欣赏周围的环境,树木、鸟儿和周围的环境,这会更加愉快。这本书也是类似的,因为我不会只关注语言。不时地,我会涉及与道路接近的主题,并在你完成这本书后,给你一些概述和方向,告诉你可以进一步探索的地方。我不会只教你语言,还会稍微谈谈算法、面向对象编程原则、围绕 Java 开发的工具以及专业人士是如何工作的。这将会与我们将要遵循的编码示例混合在一起。最后,最后一章将完全致力于这个主题,即接下来要学习什么以及如何进一步成为专业的 Java 开发者。

到这本书印刷的时候,Java 将已经完成了 22 年的历史。www.oracle.com/technetwork/java/javase/overview/javahistory-index-198355.html。在这段时间里,这种语言发生了很大的变化,并且变得更加完善。真正需要问的问题不是它已经存在了多久,而是它还能持续多久?学习这种语言是否仍然值得?自从 Java 诞生以来,已经发展出了许多新的语言(blog.takipi.com/java-vs-net-vs-python-vs-ruby-vs-node-js-who-reigns-the-job-market/)。这些语言更加现代,并且具有函数式编程特性,顺便说一句,Java 从版本 8 开始也拥有了这些特性。许多人认为 Java 已经是过去式——未来属于 Scala、Swift、Go、Kotlin、JavaScript 等语言。你可以将许多其他语言添加到这个列表中,并且对于每一种语言,你都可以找到一篇庆祝 Java 被埋葬的博客文章。对于这种担忧,有两种答案——一种是务实的商业方法,另一种则是更偏向于工程:

  • 考虑到 COBOL 仍然在金融行业中积极使用,并且 COBOL 开发者的收入可能比 Java 开发者更高,所以说作为一名 Java 开发者,你将在接下来的 40 年内找到职位并不太冒险。就我个人而言,我愿意赌超过 100 年,但考虑到我的年龄,预测超过 20 到 40 年之后可能并不公平。

  • Java 不仅仅是一种语言;它也是一种技术,你将从这本书中了解到一些关于它的内容。这项技术包括 Java 虚拟机JVM),通常被称为 JVM,并为许多语言提供了运行时环境。例如,Kotlin 和 Scala 无法在没有 JVM 的情况下运行。即使 Java 将被边缘化,JVM 仍然在企业场景中占据着领先地位。

理解和学习 JVM 的基本操作几乎和语言本身一样重要。Java 是一种编译和解释型语言。它是一种结合了两者最佳特性的特殊生物。在 Java 之前,存在解释型语言和编译型语言。

解释型语言是由解释器从源代码中读取,然后解释器执行代码。在这些语言中的每一个,都有一个初步的词法和语法分析步骤;然而,在那之后,作为程序本身的解释器由处理器执行,并且解释器持续不断地解释程序代码以了解应该做什么。编译型语言则不同。在这种情况下,源代码被编译成二进制(在 Windows 平台上是 .exe 文件),操作系统加载并直接执行。编译型程序通常运行得更快,但通常有一个较慢的编译阶段,可能会使开发速度变慢,并且执行环境不太灵活。Java 结合了这两种方法。

要执行 Java 程序,Java 源代码必须编译成 JVM 字节码(.class文件),然后由 JVM 加载并解释或编译。嗯...它是解释还是编译?Java 附带的是即时编译(JIT)器。这使得计算密集型的编译阶段和编译语言相对较慢。JVM 首先开始解释 Java 字节码,在执行这一过程的同时,它还会跟踪执行统计信息。当它收集到足够的代码执行统计信息时,它会编译成本地代码(例如,在 Intel/AMD 平台上的 x86 代码)以直接执行频繁执行的代码部分,并继续解释很少使用的代码片段。毕竟,为什么浪费昂贵的 CPU 时间来编译一些几乎从未使用过的代码呢?(例如,在启动时读取配置并在应用程序服务器重新启动之前不再执行的代码。)编译到字节码是快速的,并且只为有回报的代码段生成代码。

同样有趣的是,即时编译(JIT)利用代码执行的统计数据来优化代码。例如,如果它看到某个条件分支在 99%的情况下被执行,而另一个分支只在 1%的情况下被执行,那么它将生成运行快速的本地代码,从而优先考虑频繁执行的分支。如果该程序部分的行性行为随时间变化,并且统计数据表明比率发生了变化,JIT 会自动定期重新编译字节码。这一切都是自动的,并且是在幕后进行的。

除了自动编译之外,JVM 还有一个极其重要的特性——它管理 Java 程序的内存。现代语言的执行环境都这样做,Java 是第一个主流具有自动垃圾回收(GC)功能的语言。在 Java 之前,我用了 20 年的时间在 C 语言编程,跟踪所有内存分配并确保在程序不再需要时释放内存是一件非常痛苦的事情。在代码的某个点上忘记内存分配,以及长时间运行的程序会慢慢消耗所有内存。这些问题在 Java 中实际上已经不存在了。我们必须为此付出代价——GC 需要处理器容量和一些额外的内存,但在大多数企业应用程序中,我们并不缺少这些。一些特殊的程序,如控制重型卡车刹车的实时嵌入式系统可能没有这样的奢侈。这些程序仍然使用汇编或 C 语言编写。对于我们其他人来说,我们有 Java,尽管对于许多专业人士来说这可能看起来很奇怪,甚至几乎实时的程序,如高频交易应用程序,也是用 Java 编写的。

这些应用程序通过网络连接到证券交易所,并以毫秒级的速度买卖股票,以响应市场变化。Java 能够做到这一点。Java 的运行时环境,您需要用它来执行编译后的 Java 代码,这还包括 JVM 本身,其中包含允许 Java 程序访问网络、磁盘上的文件和其他资源的代码。为此,运行时包含高级类,代码可以实例化、执行,并完成底层工作。您也将这样做。这意味着实际的 Java 代码在想要使用或提供 REST 服务时,不需要处理 IP 数据包、TCP 连接,甚至 HTTP 处理。这些功能已经在运行时库中实现,应用程序程序员只需在代码中包含这些类,并在与程序匹配的抽象级别上使用它们提供的 API。当您用 Java 编程时,可以专注于您想要解决的真正问题,即业务代码,而不是底层系统代码。如果它不在标准库中,您可以在某些外部库的某个产品中找到它,而且很可能您还会找到一个针对该问题的开源解决方案。

这也是 Java 的一个优点。有大量的开源库可供各种用途使用。如果您在开始编写底层代码时找不到适合您问题的库,那么可能您正在做错事。这本书中有一些重要的话题,例如类加载器或反射,不是因为您每天都需要使用它们,而是因为它们被框架使用,了解它们有助于理解这些框架的工作原理。如果您不使用反射或编写自己的类加载器或直接编写多线程程序就无法解决问题,那么您可能选择了错误的框架。几乎肯定有一个好的选择:Apache 项目、Google 以及软件行业中的许多其他重要参与者都将他们的 Java 库作为开源发布。

这也适用于多线程编程。Java 从一开始就是一个多线程编程环境。JVM 和运行时支持执行代码的程序。执行在多个线程上并行运行。有一些运行时语言结构支持从非常低级到高抽象级别的并行执行程序。多线程代码利用多核处理器,这更加高效。这些处理器越来越普遍。20 年前,只有高端服务器才有多个处理器,只有 Digital Alpha 处理器具有 64 位架构和超过 100 MHz 的 CPU 时钟。10 年前,服务器端普遍采用多处理器结构,大约 5 年前,多核处理器出现在一些台式机和笔记本电脑上。今天,甚至移动电话也有。当 Java 在 1995 年开始时,创造它的天才们已经看到了这个未来。

他们设想 Java 是一种一次编写,到处运行的语言。在当时,该语言的首要目标是运行在浏览器中的 applet。如今,许多人(我也持有这种观点)认为 applet 是一个错误的目标,或者至少事情并没有以正确的方式进行。至于现在,您在互联网上遇到 applet 的频率比 Flash 应用程序或恐龙要低。

然而,与此同时,Java 解释器也在没有任何浏览器的情况下执行服务器和客户端应用程序;此外,随着语言和执行环境的发展,这些应用领域变得越来越相关。如今,Java 的主要用途是企业计算和移动应用程序,主要是 Android 平台;对于未来,随着物联网(IoT)越来越成为焦点,该环境在嵌入式系统中的使用也在增长。

安装 Java

要开发、编译和执行 Java 程序,您将需要 Java 执行环境。由于我们通常用于软件开发的操作系统没有预先安装该语言,您将不得不下载它。尽管有多个语言的实现,但我建议您从 Oracle 下载软件的官方版本。Java 的官方网站是java.com,您可以从这里下载语言的最新版本。在撰写本书时,Java 的第九版尚未发布。一个早期预发布版本可以通过jdk9.java.net/下载。稍后,发布版本也将从这里提供。

图片

您可以从这里下载的被称为早期访问版本的代码,仅供实验使用,任何专业人士都不应将其用于真正的专业目的

在该页面上,您必须点击单选按钮以接受许可协议。之后,您可以点击链接,直接开始下载安装包。该许可协议是一个特殊的早期访问许可版本,您作为专业人士应仔细阅读、理解,并且只有在您同意条款的情况下才接受。

有针对 Windows 32 位和 64 位系统、Mac OS X、Linux 32 位和 64 位版本、适用于 ARM 处理器的 Linux、适用于 SPARC 处理器系统的 Solaris 以及 Solaris x86 版本的单独安装包。由于您不太可能使用 Solaris,我将仅详细说明 Windows、Linux 和 Mac OS X 的安装过程。在后面的章节中,示例将始终是 Mac OS X,但既然 Java 是一种“一次编写,到处运行”的语言,安装后没有区别。目录分隔符可能不同,Windows 上的类路径分隔符是分号而不是冒号,终端或命令应用程序的外观和感觉也可能不同。然而,在重要的情况下,我会尽力不忘记提及。

为了让您感到困惑,每个操作系统版本的 Java 下载都列出了 JRE 和 JDK 的链接。JRE代表Java 运行环境,它包含运行 Java 程序所需的所有工具和可执行文件。JDKJava 开发工具包,它包含开发 Java 程序所需的所有工具和可执行文件,包括 Java 程序的执行。换句话说,JDK 包含自己的 JRE。目前,您只需要下载 JDK。

在安装过程中,有一个重要的点在三个操作系统上都是相同的,您在安装前必须做好准备:安装 Java,您应该有管理员权限。

Windows 上的安装

Windows 上的安装过程是从双击下载的文件开始的。它将启动安装程序,显示欢迎界面。

图片

按下“下一步”按钮,我们会得到一个窗口,您可以在其中选择要安装的部分。让我们保留默认选择,这意味着我们将安装下载的所有 Java 部分,然后按下“下一步”按钮。下一个窗口是我们选择安装目标文件夹的地方。

图片

就目前而言,我们不更改安装程序选择的目录。按“下一步”。稍后,当您成为一名专业的 Java 开发者时,您可能决定将 Java 安装到不同的位置,但那时您已经必须知道自己在做什么。

您可能需要多次点击“下一步”按钮,然后安装程序完成。当被要求时,提供管理员密码,然后 Voilà!Java 已安装。这真的是非常常见的 Windows 安装过程。

最后一步是设置环境变量 JAVA_HOME。在 Windows 上,我们必须打开控制面板并选择“编辑账户环境变量”菜单。

图片

这将打开一个新窗口,我们应该使用它来为当前用户创建一个新的环境变量。

图片

新变量的名称必须是 JAVA_HOME,其值应指向 JDK 的安装目录。

图片

在大多数系统中,此值是 C:Program FilesJavajdk-9. 这被许多 Java 程序和工具用来定位 Java 运行时。

MAC OS X 上的安装

在本节中,我们将逐步介绍如何在 OS X 平台上安装 Java。我将描述本书编写时发布的版本安装过程。至于现在,Java 9 预览版安装可能有些棘手。很可能 Java 9 版本的安装步骤与 Java 8 更新 92 相似或相同。

OS X 版本的 Java 以 .dmg 文件的形式提供。这是 OS X 的打包格式。要打开它,只需在浏览器保存文件的 下载 文件夹中双击文件,操作系统将文件挂载为只读磁盘镜像。

图片

该磁盘上只有一个文件:安装镜像。在 Finder 应用程序中双击文件名或图标,安装过程将开始。

图片

首次打开的屏幕是欢迎屏幕。点击继续,您将看到显示将要安装内容的摘要页面。

您会看到一个标准的 Java 安装并不奇怪。这次,按钮的名称是安装。点击它,您将看到以下内容:

图片

这是您必须提供管理员用户登录参数(用户名和密码)的时候。提供后,安装开始,几秒钟后,您将看到一个摘要页面。

图片

点击关闭,您就准备好了。您已经在您的 Mac 上安装了 Java。可选地,您可以卸载安装磁盘,稍后也可以删除 .dmg 文件。您不需要它,如果您需要,您可以从 Oracle 下载它。

最后,需要检查安装是否成功。实践是检验真理的唯一标准。启动一个终端窗口,在提示符下输入 java -version,Java 将告诉您已安装的版本。

在下一张屏幕截图中,您可以看到我的工作站上的输出以及切换不同 Java 版本时有用的 Mac OS 命令:

图片

在屏幕截图中,您可以看到我已经安装了 Java JDK 1.8u92 版本,同时,我还安装了一个 Java 9 预发布版本,我将用它来测试本书中 Java 的新特性。

Linux 上的安装

根据 Linux 的版本,有几种方法可以在 Linux 上安装 Java。在这里,我将描述一种在所有版本上大致相同的方法。我使用的是 Debian。

第一步与其他任何操作系统相同:下载安装包。在 Linux 的情况下,您应该选择以tar.gz结尾的包。这是一种压缩归档格式。您还应该仔细选择与您的机器处理器和操作系统的 32/64 位版本相匹配的包。下载完包后,您必须切换到 root 模式,发出su命令。这是截图上显示的安装命令中的第一个命令。

图片

tar命令将归档解压缩到子文件夹中。在 Debian 中,此子文件夹必须移动到/opt/jdk,并使用mv命令来完成此操作。这两个update-alternatives命令是 Debian 特有的。这些告诉操作系统,如果已经安装了较旧的 Java,则使用新安装的 Java。我用来在虚拟机上测试和演示安装过程的 Debian 带有 7 年前的 Java 版本。

安装的最后一步与其他任何操作系统相同:通过发出java -version命令来检查安装是否成功。在 Linux 的情况下,这一点尤为重要,因为安装过程不会检查下载的版本是否与操作系统和处理器架构相匹配。

设置 JAVA_HOME

JAVA_HOME环境变量在 Java 中扮演着特殊角色。尽管 JVM 可执行文件java.exejava位于PATH中(因此您可以在命令提示符(终端)中通过输入名称java来执行它,而不需要指定目录),但建议您使用正确的 Java 安装来设置此环境变量。变量的值应指向已安装的 JDK。有许多 Java 相关程序,例如 Tomcat 或 Maven,它们使用此变量来定位已安装和当前使用的 Java 版本。在 Mac OS X 中,设置此变量是不可避免的。

在 OS X 中,当您输入java时开始执行的程序是一个包装器,它首先查看JAVA_HOME以决定启动哪个 Java 版本。如果此变量未设置,那么 OS X 将自行决定,从可用的已安装 JDK 版本中选择。要查看可用的版本,您可以发出以下命令:

    ~$ /usr/libexec/java_home -V
Matching Java Virtual Machines (10):
 9, x86_64:    "Java SE 9-ea"    /Library/Java/JavaVirtualMachines/jdk-9.jdk/Contents/Home
 1.8.0_92, x86_64:    "Java SE 8"    /Library/Java/JavaVirtualMachines/jdk1.8.0_92.jdk/Contents/Home
 1.7.0_60, x86_64:    "Java SE 7"    /Library/Java/JavaVirtualMachines/jdk1.7.0_60.jdk/Contents/Home
/Library/Java/JavaVirtualMachines/jdk-9.jdk/Contents/Home

然后,您将获得已安装 JDK 的列表。请注意,命令是小写的,但选项是大写的。如果您不向程序提供任何选项和参数,它将简单地返回它认为最新且最适合目的的 JDK。正如我从我的终端窗口复制了命令输出一样,您可以看到我在我的机器上安装了相当多的 Java 版本。

程序响应的最后一行是 JDK 的主目录,这是默认的。你可以使用一些 bash 编程来设置你的 JAVA_HOME 变量:

    export JAVA_HOME=$(/usr/libexec/java_home)

你可以将这个文件放在你的 .bashrc 文件中,每次启动终端应用程序时都会执行它,因此 JAVA_HOME 将始终被设置。如果你想使用不同的版本,可以使用 -v(这次是小写的选项)来指定相同的实用程序,如下所示:

    export JAVA_HOME=$(/usr/libexec/java_home -v 1.8)

参数是你想要使用的 Java 版本。请注意,这个版本号变成了:

    export JAVA_HOME=$(/usr/libexec/java_home -v 9)

如果你想使用 Java JDK 预览版而不是 1.9,对于这个事实没有解释——这就是生活。

注意,还有一个对 Java 很重要的环境变量——CLASSPATH。我们稍后会讨论它。

执行 jshell

既然我们已经花费了很多时间安装 Java,现在是时候稍微烧一下手指了。因为我们使用的是 Java 9,所以有一个新的工具可以帮助开发者与语言互动。这是一个 读取-评估-打印-循环REPL)工具,许多语言工具集都包含这个工具,Java 也有实现,但 9 版本是第一个包含这个功能的版本。

REPL 是一个具有交互式提示和可以直接输入而无需编辑独立文件的命令语言的工具。输入的命令将被直接执行,然后循环再次开始,等待用户输入下一个命令。这是一个非常有效的工具,可以在不等待编辑、编译和加载的情况下尝试一些语言结构。这些步骤由 REPL 工具自动且透明地完成。

Java 9 中的 REPL 工具被称为 jshell。要启动它,只需输入其名称。如果它不在 PATH 中,那么输入与 Java 9 一起安装的 jshell 的完整路径,如下面的示例所示:

    $ jshell
|  Welcome to JShell -- Version 9-ea
|  For an introduction type: /help intro
jshell> 

jshell 以交互式方式启动,它显示的提示符是 jshell>,以帮助您识别 jshell 是否正在运行以及您输入的内容是否被程序读取而不是操作系统 shell。由于这是您第一次启动 jshell,它会告诉您输入 /help intro。让我们试试。它将打印出有关 jshell 的简短文本,如下面的代码所示:

    jshell> /help intro
| 
|  intro
| 
|  The jshell tool allows you to execute Java code, getting immediate results.
|  You can enter a Java definition (variable, method, class, etc), like:  int x = 8
|  or a Java expression, like:  x + x
|  or a Java statement or import.
|  These little chunks of Java code are called 'snippets'.
| 
|  There are also jshell commands that allow you to understand and
|  control what you are doing, like:  /list
| 
|  For a list of commands: /help

好吧,所以我们可以输入 Java 片段和 /list,但这只是可用命令的一个例子。我们可以通过输入 /help 来获取更多信息,如下面的代码所示:

    jshell> /help
|  Type a Java language expression, statement, or declaration.
|  Or type one of the following commands:
|     /list [<name or id>|-all|-start]                             -- list the source you have typed
|     /edit <name or id>                                           -- edit a source entry referenced by name or id
|     /drop <name or id>                                           -- delete a source entry referenced by name or id
|     /save [-all|-history|-start] <file>                          -- Save snippet source to a file.
...

Hello World example:
    jshell> System.out.println("Hello World!")
Hello World!

这是 Java 中最短的 Hello World 程序。在 Java 9 之前,如果你想做的只是打印出 Hello World!,你必须创建一个程序文件。它必须包含一个类的源代码,包括 public static main 方法,这个方法包含了我们在 Java 9 jshell 中必须输入的那一行。仅仅为了简单的打印示例代码,这很麻烦。现在它要容易得多,jshell 也更加宽容,允许我们在行尾省略分号。

我们接下来应该尝试的是声明一个变量,如下所示:

    jshell> int a = 13
a ==> 13
jshell> 

我们声明了一个名为 a 的变量,并将其值赋给它-13。该变量的类型是 int,这是 Java 中整数类型的缩写。现在这个变量已经在我们片段中了,所以如果我们想的话可以打印出来,如下所示:

    jshell> System.out.println(a)
13

是时候在 jshell 中输入比单行更复杂的代码了。

    jshell> void main(String[] args){
 ...>  System.out.println("Hello World")
 ...> }
|  Error:
|  ';' expected
|   System.out.println("Hello World")
| 

jshell 识别出这不是一条单行命令,并且它无法处理我们之前输入的内容,当我们按下第一行末尾的 Enter 键时,它会提示我们输入更多的字符,因此它显示 ...> 作为续行提示。我们输入组成整个 hello world main 方法的命令,但这次 jshell 不允许我们遗漏分号。这只有在单行片段的情况下才被允许。由于 jshell 是交互式的,所以很容易纠正错误;按上箭头键几次以获取上一行,这次在第二行的末尾添加分号:

    jshell> void main(String[] args){
 ...>  System.out.println("Hello World");
 ...> }
|  created method main(String[])

这个方法是为我们创建的片段,现在我们可以调用它:

    jshell> main(null)
Hello World

它确实工作了。你可以列出创建的所有片段,如下所示:

    jshell> /list
 1 : System.out.println("Hello World!")
 2 : int a = 13;
 3 : System.out.println(a)
 4 : void main(String[] args){
 System.out.println("Hello World");
 }

而且,既然我们想要继续编写一个完整的 Java 版本的 hello world,我们可以将我们的工作从 jshell 保存到文件中,如下所示:

    jshell> /save HelloWorld.java

最后,我们通过输入 /exit 退出了 jshell。当你回到系统提示符时,输入 cat HelloWorld.java(或在 Windows 上输入 type HelloWorld.java)以查看文件的内容。如下所示:

    $ cat HelloWorld.java
System.out.println("Hello World!")
int a = 13;
System.out.println(a)
void main(String[] args){
 System.out.println("Hello World");
 }

该文件包含我们依次输入的所有片段。如果你认为你弄乱了 shell,有很多不再需要的变量和代码片段,你可以发出 /reset 命令:

    jshell> /reset
|  Resetting state.

在此命令之后,jshell 和它早期启动时一样干净

    jshell> /list

jshell>

仅列出不会产生任何内容,因为我们已经删除了所有内容。幸运的是,我们已经将 jshell 的状态保存到了文件中,我们也可以通过发出 /open 命令来加载文件的内容:

    jshell> /open HelloWorld.java
Hello World!
13

它从文件中加载行并执行它,就像字符被输入到命令提示符中一样。

/edit command followed by the number of the snippet:
    jshell> /edit 1

你可能还记得我们输入的第一个命令是 System.out.println 系统调用,它将参数打印到控制台。在 /edit 1 命令后按下 Enter 键后,你不会得到提示符。相反,jshell 打开一个包含要编辑片段的独立图形编辑器,如下所示:

图片

编辑框中的文本,使其看起来像这样:

    printf("Hello World!")

点击接受然后退出。当你点击接受时,终端将执行片段并显示以下结果:

    Hello World!

我们使用的 printf 方法代表格式化打印。这可能在许多其他语言中很常见。它最初由 C 语言引入,尽管名字晦涩,但名字保留了下来。这也是标准 Java 类 PrintStream 的一部分,就像 println 一样。在 println 的情况下,我们必须在方法名前写 System.out。在 printf 的情况下,我们不需要。为什么?

原因是 jshell 定义了一些片段,这些片段在 jshell 启动或重置时自动加载。如果你发出带有 -start 选项的 /list 命令,你可以看到这些片段,如下所示:

    jshell> /list -start

 s1 : import java.util.*;
 s2 : import java.io.*;
 s3 : import java.math.*;
 s4 : import java.net.*;
 s5 : import java.util.concurrent.*;
 s6 : import java.util.prefs.*;
 s7 : import java.util.regex.*;
 s8 : void printf(String format, Object... args) { System.out.printf(format, args); }

printf, which is also the name of a method in the PrintStream class.

如果你想要列出你输入的所有片段以及预定义的片段,以及那些包含某些错误因此未执行的片段,你可以使用 /list 命令的 -all 选项,如下所示:

    jshell> /list -all

...
 s7 : import java.util.regex.*;
...
 1 : System.out.println("Hello World!")
...
 e1 : System.out.println("Hello World!")
 int a = 14;
 5 : System.out.println("Hello World!");
...

为了简洁,一些行已被从实际输出中删除。预加载的行带有 s 前缀进行编号。包含错误的片段带有 e 前缀的编号。

如果你想要再次执行一些代码片段,你只需要在相应位置输入 /n,其中 n 是片段的编号,如下所示:

    jshell> /1
System.out.println("Hello World!")
Hello World!

你不能重新执行预加载的片段或包含错误的片段。实际上,这些都不需要。预加载的片段声明了一些导入并定义了一个片段方法;错误的片段由于是错误的,所以无法执行。

/-n. Here, n is the number of the snippet counting from the last one. So, if you want to execute the very last snippet, then you have to write /-1. If you want to execute the one before the last one, you have to write /-2. Note that if you already typed /-1, then the last one is the re-execution of the last snippet and snippet number -2 will become number -3.

通过其他方式也可以避免列出所有片段。当你只对某些类型的片段感兴趣时,你可以使用特殊的命令。

如果我们只想看到在片段中定义的变量,那么我们可以发出 /vars 命令,如下所示:

    jshell> /vars
|    int a = 13

如果我们只想看到类,可以使用 command/types 命令:

    jshell> class s {}
|  created class s

jshell> /types
|    class s

这里,我们只是创建了一个空类,然后列出了它。

要列出在片段中定义的方法,可以发出 /methods 命令:

    jshell> /methods
|    printf (String,Object...)void
|    main (String[])void

你可以在输出中看到,只有两个方法,如下所示:

  • printf:这是在预加载的片段中定义的

  • main:这是我们定义的

如果你想要查看你输入的所有内容,你必须为所有输入的片段和命令发出 /history 命令。(我不会在这里复制输出;我不想让自己难堪。你应该亲自尝试并查看你自己的历史。)

回想一下,我们可以通过发出 /reset 命令来删除所有片段。你还可以单独删除片段。为此,你应该发出 /drop n 命令,其中 n 是片段编号:

    jshell> /drop 1
|  This command does not accept the snippet '1' : System.out.println("Hello World!")
|  See /types, /methods, /vars, or /list

1 was executed and the /drop command actually drops the defined variable, type, or method. There is nothing to be dropped in the first snippet. But, if we reissue the /list command, we will get the following results:
    jshell> /list

 1 : System.out.println("Hello World!")
 2 : int a = 13;
 3 : System.out.println(a)
 4 : void main(String[] args){
 System.out.println("Hello World");
 }

我们可以看到,我们也可以删除第二个或第四个片段:

    jshell> /drop 2
|  dropped variable a

jshell> /drop 4
|  dropped method main(String[])

jshell 的错误信息提示查看 /types/methods/vars/list 命令的输出。问题是 /types/methods/vars 并不显示片段的编号。这很可能是 jshell 预发布版本中的一个小的错误,可能在 JDK 发布时得到修复。

当我们编辑片段时,jshell 打开了一个单独的图形编辑器。可能的情况是,你正在远程服务器上使用 ssh 运行 jshell,并且无法打开一个单独的窗口。你可以使用 /set 命令设置编辑器。这个命令可以设置 jshell 的许多配置选项。要使用无处不在的 vi 设置编辑器,请发出以下命令:

    jshell> /set editor "vi"
|  Editor set to: vi

之后,jshell 将在你发出/edit命令的同一终端窗口中打开内嵌的 vi。

不仅编辑器可以设置。你可以设置启动文件,以及 jshell 在执行命令后如何将反馈打印到控制台。

如果你设置了启动文件,那么在/reset命令之后,将执行启动文件中列出的命令,而不是 jshell 的内置命令。这也意味着你将无法直接使用默认导入的类,并且你将没有printf方法片段,除非你的启动文件包含导入和片段的定义。

创建以下内容的sample.startup文件:

void println(String message) { System.out.println(message); }

启动一个新的 jshell 并执行它如下所示:

    jshell> /set start sample.startup

jshell> /reset
|  Resetting state.

jshell> println("wuff")
wuff

jshell> printf("This won't work...")
|  Error:
|  cannot find symbol
|    symbol:   method printf(java.lang.String)
|  printf("This won't work...")
|  ^----^

println方法已定义,但默认启动中定义的printf方法没有。

反馈定义了 jshell 打印的提示,然后等待输入,继续行的提示,以及每个命令后的消息详情。有预定义的模式,如下所示:

  • 普通

  • 静默

  • 简洁

  • 详细

默认选择普通。如果你发出/set feedback silent命令,则提示变为->,jshell 将不会打印关于命令的详细信息。/set feedback concise代码打印更多一些信息,而/set feedback verbose则打印关于执行命令的详细信息:

    jshell> /set feedback verbose
|  Feedback mode: verbose

jshell> int z = 13
z ==> 13
|  modified variable z : int
|    update overwrote variable z : int

你也可以定义自己的模式,使用/set mode xyz命令给新模式命名,其中xyz是新模式的名称。之后,你可以为该模式设置提示、截断和格式。当格式定义后,你可以像使用内置模式一样使用它。

最后,但同样重要的是,jshell 最重要的命令是/exit。这将仅终止程序,然后你将返回到操作系统 shell 提示符。

现在,让我们编辑HelloWorld.java文件以创建我们的第一个 Java 程序。为此,你可以使用 vi、记事本、Emacs 或你机器上可用的任何东西,只要它适合你。稍后,我们将使用一些集成开发环境(IDE),如 NetBeans、Eclipse 或 IntelliJ;然而,现在一个简单的文本编辑器就足够了。

编辑文件,使其内容如下所示:

public class HelloWorld { 
  public static void main(String[] args){ 
        System.out.println("Hello World"); 
       } 
  }

要将源代码编译成可由 JVM 执行的字节码,我们必须使用名为javac的 Java 编译器:

    javac HelloWorld.java

这将在当前目录中生成java.class文件。这是一个可执行的编译代码,可以按照以下方式执行:

    $ java HelloWorld
Hello World

使用这个命令,你已经创建并执行了你的第一个完整的 Java 程序。你可能还在想我们在做什么。如何和为什么,我会解释;但首先,我想让你有一种感觉,它确实可以工作。

main method and we inserted the declaration of the class around it.

在 Java 中,你不能像许多其他语言那样有独立的方法或函数。每个方法都属于某个类,每个类都应该在单独的文件中声明(好吧,几乎是这样,但现在让我们跳过例外)。文件名必须与类名相同。编译器要求 public 类必须这样做。即使是非 public 类,我们通常也遵循这个约定。如果你将文件名从 HelloWorld.java 改为 Hello.java,当你尝试用新名称编译文件时,编译器会显示错误。

    $ mv HelloWorld.java Hello.java
~/Dropbox/java_9-by_Example$ javac Hello.java
Hello.java:2: error: class HelloWorld is public, should be declared in a file named HelloWorld.java
public class HelloWorld {
 ^
1 error

因此,让我们将其改回原始名称:mv Hello.java HelloWorld.java

类的声明从关键字 class 开始,然后是类的名称,一个开括号,直到匹配的闭括号。其中之间的一切都属于该类。

现在,让我们跳过为什么我在类名前写 public,而专注于其中的主方法。由于该方法不返回任何值,因此它的返回值是 void。参数名为 args,是一个字符串数组。当 JVM 启动 main 方法时,它会将命令行参数传递给程序的这个数组。然而,这次我们不使用它。main 方法包含打印出 Hello World 的行。现在,让我们更详细地检查这一行。

在其他语言中,将内容打印到控制台只需要一个 print 语句或一个非常类似的命令。我记得一些 BASIC 解释器甚至允许我们用 ? 代替 print,因为打印到屏幕非常常见。在过去 40 年中,这种情况发生了很大变化。我们现在使用图形屏幕、互联网以及许多其他输入和输出通道。如今,将内容写入控制台并不常见。

通常,在专业的企业级大型应用中,甚至没有一行代码是做这个的。相反,我们会将文本直接导向日志文件,通过消息队列发送消息,以及通过 TCP/IP 协议发送请求并接收响应。由于这种情况很少使用,所以在语言中创建一个快捷方式没有必要。在编写了几个程序之后,当你熟悉了调试器和日志功能时,你将不会直接将任何内容打印到控制台。

尽管如此,Java 具有一些特性,允许你以最初为 UNIX 设计的“老方法”直接将文本发送到进程的标准输出。在 Java 中,所有东西都必须是一个对象或类,因此这是以 Java 方式实现的。要访问系统输出,有一个名为 System 的类,它包含以下三个变量:

  • in:这是标准输入流

  • out:这是标准输出流

  • err:这是标准错误流

要引用输出流变量,因为它不在我们的类中,而是在System中,我们必须指定类名,因此我们将在程序中将其称为System.out。这个变量的类型是PrintStream,它也是一个类。在 Java 中,类和类型是同义词。每个类型为PrintStream的对象都有一个名为println的方法,它接受一个String。如果实际的打印流是标准输出,并且我们是从命令行执行 Java 代码,那么字符串就会被发送到控制台。

该方法被命名为main,在 Java 程序中这是一个特殊的名称。当我们从命令行启动 Java 程序时,JVM 会从我们在命令行上指定的类中调用名为main的方法。它可以这样做,因为我们声明了这个方法为public,这样任何人都可以看到并调用它。如果它是private的,那么它只能从定义在同一个源文件中的同一个类或类中看到和调用。

该方法也被声明为static,这意味着它可以在不实际实例化包含这些方法的类的情况下被调用。如今,使用静态方法通常被认为不是一种好的做法,除非它们实现的是根本无法与实例相关联的功能,或者有不同实现,例如java.lang.Math类中的函数;但是,代码执行必须从某个地方开始,Java 运行时通常不会自动为我们创建类的实例。

要启动代码,命令行应该如下所示:

    java -cp . HelloWorld

-cp选项代表类路径。对于 Java 来说,类路径是一个相当复杂的概念,但就目前而言,让我们简单地说,它是一个包含我们的类的目录和 JAR 文件的列表。类路径的列表分隔符在 UNIX-like 系统中是:(冒号),在 Windows 中是;(分号)。在我们的情况下,类路径是实际的目录,因为 Java 编译器就是在那里创建了HelloWorld.class。如果我们没有在命令行上指定类路径,Java 将使用当前目录作为默认值。这就是为什么我们的程序最初在没有-cp选项的情况下也能工作。

javajavac都处理许多选项。要获取选项列表,请输入javac -helpjava -help。我们使用 IDE 来编辑代码,并且在开发过程中,我们经常使用 IDE 来编译、构建和运行代码。在这种情况下,环境设置了合理的参数。对于生产,我们使用支持环境配置的构建工具。正因为如此,我们很少遇到这些命令行选项。尽管如此,专业人士至少需要理解它们的含义,并且知道在哪里学习它们实际的使用方法,以防万一需要。

查看字节码

类文件是一个二进制文件。这种格式的最主要作用是让 JVM 执行,并在代码使用库中的某些类时为 Java 编译器提供符号信息。当我们编译包含 System.out.println 的程序时,编译器会查看编译后的 .class 文件,而不是源代码。它必须找到名为 System 的类、名为 out 的字段和 println 方法。当我们调试一段代码或试图找出为什么程序找不到类或方法时,我们需要一种方法来查看 .class 文件的二进制内容。这不是日常任务,需要一些高级知识。

要做到这一点,有一个可以以或多或少可读的格式显示 .class 文件内容的 反汇编器。这个命令叫做 javap。要执行它,你可以发出以下命令:

    $ javap HelloWorld.class
Compiled from "HelloWorld.java"
public class HelloWorld {
 public HelloWorld();
 public static void main(java.lang.String[]);
}

程序的输出显示,类文件包含一个名为 HelloWorld() 的 Java 类;这似乎是一个与类名相同的名称的方法,它还包含我们编写的那个方法。

与类名相同名称的 方法 是类的构造函数。由于 Java 中的每个类都可以被实例化,因此需要一个构造函数。如果我们不提供,那么 Java 编译器会为我们创建一个。这就是默认构造函数。默认构造函数没有做任何特殊的事情,只是返回一个新实例。如果我们自己提供一个构造函数,那么 Java 编译器就不会费心创建一个。

javap 反汇编器不会显示方法内部的内容或它包含的 Java 代码,除非我们提供 -c 选项:

    $ javap -c HelloWorld.class
Compiled from "HelloWorld.java"
public class HelloWorld {
 public HelloWorld();
 Code:
 0: aload_0
 1: invokespecial #1                  // Method java/lang/Object."<init>":()V
 4: return
 public static void main(java.lang.String[]);
 Code:
 0: getstatic     #2                  // Field java/lang/System.out:Ljava/io/PrintStream;
 3: ldc           #3                  // String hali
 5: invokevirtual #4                  // Method java/io/PrintStream.println:(Ljava/lang/String;)V
 8: return
}

它非常晦涩难懂,不是普通人类能理解的。只有少数处理 Java 代码生成的专家才能流畅地阅读它。但是,看看它可以帮助你了解字节码的含义。它就像一个古老的汇编语言。尽管这是二进制代码,但其中并没有什么秘密:Java 是开源的,类文件格式有很好的文档记录,并且对专家来说是可调试的。

将类打包到 JAR 文件中

当你交付一个 Java 应用程序时,通常代码会被打包成 JAR、WAR、EAR 或其他一些打包格式。我们再次学到一些看似晦涩难懂的东西,但实际上,这并不复杂。它们都是 ZIP 文件。你可以使用 WinZip 或其他有许可证的压缩管理器打开这些文件。额外的要求是,例如,在 JAR 文件的情况下,归档应包含一个名为 META-INF 的目录,并在其中包含一个名为 MANIFEST.MF 的文件。这个文件是一个文本文件,包含以下格式的元信息:

Manifest-Version: 1.0 
Created-By: 9-ea (Oracle Corporation)

文件中可能包含很多其他信息,但如果我们使用以下命令将类文件打包到 jar 中,Java 提供的工具 jar 会放入这些信息的最小版本:

         jar -cf hello.jar HelloWorld.class

-c选项告诉 JAR 归档器创建一个新的 JAR 文件,而f选项用于指定新存档的名称。我们在这里指定的是hello.jar,添加到其中的是类文件。

打包的 JAR 文件也可以用来启动 Java 应用程序。Java 可以直接从 JAR 存档中读取并从那里加载类。唯一的要求是它们必须在类路径上。

注意,您不能将单个类放在类路径上,只能是目录。由于 JAR 文件是包含内部目录结构的存档,它们的行为就像目录一样。

使用ls hello.jar检查 JAR 文件是否已创建,并删除rm HelloWorld.class类文件,只是为了确保当我们发出命令行时,代码是从 JAR 文件而不是从类中执行的。

    $ java -cp hello.jar HelloWorld
Hello World

然而,要查看 JAR 文件的内容,建议您使用 JAR 工具而不是 WinZip,尽管这可能更舒适。真正的专业人士使用 Java 工具来处理 Java 文件。

$ jar -tf hello.jar 
META-INF/ 
META-INF/MANIFEST.MF 
HelloWorld.class

管理正在运行的 Java 应用程序

JDK 附带的一套 Java 工具支持运行和管理正在运行的 Java 应用程序。为了有一个在执行时我们可以管理的程序,我们需要一个不仅运行几毫秒,而且在运行时还会向控制台打印一些内容的代码。让我们创建一个名为HelloWorldLoop.java的新程序,其内容如下:

public class HelloWorldLoop { 
  public static void main(String[] args){ 
       for( ;; ){ 
         System.out.println("Hello World"); 
         } 
       } 
  }

程序包含一个for循环。循环允许重复执行代码块,我们将在第二章,“第一个真正的 Java 程序 - 排序姓名”中讨论它们。我们在这里创建的循环是一个特殊的循环,它永远不会终止,而是重复打印方法调用,打印Hello World,直到我们通过按Ctrl + c或发出 Linux 或 OSX 上的kill命令或通过 Windows 的任务管理器终止程序来结束程序。

在一个窗口中编译并启动它,然后打开另一个终端窗口来管理应用程序。

我们首先应该熟悉的是jps命令。docs.oracle.com/javase/7/docs/technotes/tools/share/jps.html它列出了在机器上运行的 Java 进程,如下所示:

$ jps 
21873 sun.tools.jps.Jps 
21871 HelloWorldLoop

您可以看到有两个进程——一个是我们要执行的程序,另一个是jps程序本身。不出所料,jps 工具也是用 Java 编写的。您也可以向jps传递选项,这些选项在网上有文档说明。

有许多其他工具,我们将考察其中之一,这是一个非常强大且易于使用的工具——Java VisualVM。

VisualVM 是一个连接到正在运行的 Java 进程的命令行图形工具,它显示了不同的性能参数。要启动 VisualVM 工具,您将发出不带任何参数的jvisualvm命令。很快,一个窗口就会出现,左侧有一个探索树,右侧有一个欢迎面板。左侧显示了名为“本地”的分支下所有正在运行的 Java 进程。如果您双击HelloWorldLoop,它将在右侧面板中打开进程的详细信息。在标题选项卡上,您可以选择概览、监控、线程、采样器和分析器。前三个选项卡是最重要的,它们可以给您一个关于 JVM 中线程数量、CPU 使用率、内存消耗等方面的良好视图。

使用 IDE

集成开发环境是出色的工具,通过从开发者的肩上卸下机械任务来帮助开发。它们在我们编写代码时会识别许多编程错误,帮助我们找到所需的库方法,显示库的文档,并提供额外的工具用于样式检查、调试等。

在本节中,我们将探讨一些 IDE 以及如何利用它们提供的功能。

要获取集成开发环境(IDE),您需要下载并安装它。它不包含 Java 开发工具,因为它们不是语言环境的一部分。但是,不用担心。它们可以免费下载,并且安装起来也很简单。它们可能比记事本编辑器启动起来更复杂,但即使工作几小时后,它们也会回报您投入在学习和使用它们上的时间。毕竟,没有理由说没有任何开发者会在记事本或 vi 中编码 Java。

三大顶级 IDE 是NetBeansEclipseIntelliJ。它们都有社区版本,这意味着您不需要为它们付费。IntelliJ 还有一个可以购买的完整版本。社区版可以用于学习语言。如果您不喜欢 IntelliJ,您可以使用 Eclipse 或 NetBeans。这些都是免费的。我个人在大多数项目中使用 IntelliJ 社区版,本书中展示 IDE 的屏幕样本也将使用这个 IDE。但这并不意味着您必须坚持使用这个 IDE。

在开发者社区中,有一些话题可能会被激烈辩论。这些话题是关于观点的。如果它们是关于事实的,辩论很快就会结束。其中一个话题是:“哪个是最佳的 IDE?”这是一个口味问题。没有明确的答案。如果你学会了如何使用一个,你会喜欢它,并且你可能会不愿意学习另一个,除非你看到另一个更好。这就是开发者喜欢他们使用的 IDE(或者根据他们的个性,可能会讨厌),但他们通常长时间使用同一个 IDE。没有最好的 IDE。

要下载您选择的 IDE,您可以访问以下任何一个网站:

NetBeans

NetBeans 由 Oracle 支持,并且持续开发。它包含组件,如 NetBeans 分析器,这些组件已成为 Oracle Java 分发的组成部分。你可能注意到,当你启动 Visual VM 并开始分析时,启动的 Java 进程名称中包含netbeans

通常,NetBeans 是一个用于开发丰富客户端应用程序的框架,而 IDE 只是建立在框架之上的许多应用程序之一。它支持许多语言,而不仅仅是 Java。你可以使用 NetBeans 开发 PHP、C 或 JavaScript 代码,并且对于 Java 也有类似的服务。对于不同语言的支持,你可以下载插件或 NetBeans 的特殊版本。这些特殊版本可以从 IDE 的下载页面获得,它们只是带有一些预配置插件的基 IDE。在 C 包中,开发者在你想开发 C 时配置所需的插件;在 PHP 版本中,他们配置 PHP 插件。

Eclipse

Eclipse 由 IBM 支持。与 NetBeans 类似,它也是一个用于丰富客户端应用程序的平台,并且它围绕OSGi容器架构构建,这本身就是一个可以填满像这样一本书的主题。大多数开发者使用 Eclipse,并且几乎在所有情况下,当开发者为IBM WebSphere应用程序服务器编写代码时,它都是首选。Eclipse 的特别版本包含 WebSphere 的开发者版本。

Eclipse 也有支持不同编程语言的插件,并且也有类似于 NetBeans 的不同变体。这些变体是包含在基本 IDE 中的预包装插件。

IntelliJ

在上述列举的最后一个 IDE 是 IntelliJ。这个 IDE 是唯一一个不想成为框架的 IDE。IntelliJ 是一个 IDE。它也有插件,但大多数你需要下载以在 NetBeans 或 Eclipse 中使用的插件都是预先配置好的。当你想要使用一些更高级的插件时,可能需要付费,但这在你进行专业、付费工作时不应成为问题,对吧?这些事情并不那么昂贵。对于学习这本书中的主题,你将不需要任何不在社区版中的插件。正如这本书一样,我将使用 IntelliJ 开发示例,并建议你在学习过程中跟随我。

我想强调,这本书中的示例与实际使用的 IDE 无关。你可以使用 NetBeans、Eclipse,甚至 Emacs、记事本或 vi 来遵循这本书。

IDE 服务

集成开发环境为我们提供了服务。最基本的服务是您可以用它们编辑文件,但它们还帮助构建代码、查找错误、运行代码、以开发模式部署到应用服务器、调试等等。在接下来的章节中,我们将探讨这些功能。我不会给出如何使用一个或另一个 IDE 的精确和精确的介绍。这样的书籍不是这样的教程的好媒介。

IDEs 在菜单位置、键盘快捷键上有所不同,甚至在新版本发布时可能会发生变化。最好是查看实际的 IDE 教程视频或在线帮助。另一方面,它们的特性非常相似。IntelliJ 有视频文档在www.jetbrains.com/idea/documentation/

IDE 屏幕结构

不同的 IDE 看起来很相似,屏幕结构大致相同。在下面的屏幕截图中,您可以看到一个 IntelliJ IDE:

在左侧,您可以看到 Java 项目的文件结构。Java 项目通常包含不同目录中的许多文件,我们将在下一章中讨论。简单的HelloWorld应用程序包含一个pom.xml项目描述文件。这个文件是 Maven 构建工具所需的,这也是下一章的主题。现在,您只需知道它是一个描述 maven 项目结构的文件。IDE 还会跟踪一些自己的管理数据。它存储在HelloWorld.iml中。主程序文件存储在src/main/java目录中,命名为HelloWorld.java

在右侧,您可以看到文件。在屏幕截图上,我们只有一个文件被打开。如果有多个文件被打开,那么会有标签页——每个文件一个标签页。现在,活动文件是HelloWorld.java,可以在源代码编辑器中编辑。

编辑文件

在编辑时,您可以输入字符或删除字符、单词和行,但这所有编辑器都能做到。IDEs 提供额外的功能。IDEs 分析源代码并格式化它,这反过来又自动缩进行。同时,在您编辑代码的背景下,它还会持续编译代码,如果存在语法错误,则用红色波浪线下划线标记。当您修复错误时,红色下划线消失。

编辑器在您键入时也会自动给出后续字符的建议。您可以忽略弹出的窗口并继续键入。然而,很多时候,在键入一个字符后停下来,使用上下箭头选择需要完成的单词,然后按Enter键会更简单:单词将自动插入到源代码中。

在截图上,你可以看到我写了 System.o,编辑器立即建议我写 out。其他选项是类 System 中包含字母 o 的其他静态字段和方法。

IDE 编辑器不仅在你需要它为你输入时提供提示,而且在它不能代替你输入时也提供提示。在截图上,IDE 告诉你将某些表达式作为 println() 方法的参数,这些表达式是 booleancharint 等类型。IDE 完全不知道在那里输入什么。你必须构造这个表达式。尽管如此,它仍然可以告诉你它需要是某种类型。

图片

编辑器不仅知道内置类型。与 JDK 集成的编辑器会持续扫描源文件,并知道在源代码中哪些类、方法和字段在编辑位置是可用的。

当你想重命名一个方法或变量时,这种知识也会被大量使用。以前的方法是在源文件中重命名字段或方法,然后对变量的所有引用进行彻底搜索。使用 IDE,机械工作由它来完成。它知道字段或方法的全部用法,并自动将旧标识符替换为新标识符。它还识别出是否有局部变量恰好与我们要重命名的变量同名,IDE 只重命名那些真正指向我们要重命名的变量。

你通常可以做的不仅仅是重命名。程序员称之为重构的机械任务或多或少。这些任务由 IDE 使用一些键盘快捷键和编辑器中的上下文相关菜单来支持——鼠标右键单击并点击菜单。

图片

IDE 还帮助你阅读库和源代码的文档,如下面的图像所示:

图片

库为 public 方法提供 Javadoc 文档,你也应该为自己的方法编写 Javadoc。Javadoc 文档是从源代码中的特殊注释中提取出来的,我们将在第四章[Mastermind - 创建一个游戏]中学习如何创建这些注释。这些注释位于实际方法头部前面的注释中。由于创建编译文档是编译流程的一部分,IDE 也知道文档,并在将光标置于源文件中的方法名称、类名称或其他元素上时,将其显示为悬停框。

管理项目

在 IDE 窗口的左侧,你可以看到项目的目录结构。IDE 知道不同类型的文件,并以编程角度有意义的方式显示它们。例如,它不会将Main.java显示为文件名。相反,它显示Main和一个表示Main是一个类的图标。它也可以是一个名为Main.java的文件中的接口,但在那种情况下,图标将显示这是一个接口。这是通过 IDE 持续扫描和编译代码来实现的。

在我们开发 Java 代码时,文件会被组织到子目录中。这些子目录遵循代码的打包结构。在 Java 中,我们经常使用复合和长的包名,将它们显示为深层的嵌套目录结构将不容易处理。

包用于组织源文件。那些以某种方式相关的类的源文件应该放在一个包中。我们将在下一章讨论包的概念以及如何使用它们。

对于包含源文件的那些项目目录,IDE 能够显示包结构而不是嵌套目录。

图片

当你将一个类或接口从一个包移动到另一个包时,这和重命名或其他重构操作类似。源文件中对类或接口的所有引用都会被重命名为新的包名。如果一个文件包含一个引用该类的import语句,那么该语句中的类名会被修正。要移动一个类,你可以打开包并使用传统的拖放操作。

包层次结构不是 IDE 中显示的唯一层次结构。类在包中,但同时也存在一个继承层次结构。类可以实现接口并扩展其他类。Java IDE 通过显示类型层次结构来帮助我们,在这些层次结构中,你可以沿着继承关系在图形界面中导航。

IDE 还可以显示另一种层次结构,以帮助我们进行开发:方法调用层次结构。在分析代码后,IDE 可以显示显示方法之间关系的图形:哪个方法调用了哪个其他方法。有时,这个调用图在显示方法之间的依赖关系时也非常重要。

编译代码并运行它

IDE 通常会编译代码以进行分析,帮助我们即时发现语法错误或未定义的类和方法。这种编译通常是局部的,覆盖代码的一部分,并且由于它持续运行,源代码会发生变化,因此永远不会真正完整。要创建可部署的文件,即项目的最终交付代码,必须启动一个单独的构建过程。大多数 IDE 都有一些内置工具用于此目的,但除了最小的项目外,不建议使用这些工具。专业开发项目通常使用 Ant、Maven 或 Gradle。以下是一个 Maven 的例子。

图片

集成开发环境已准备好使用此类外部工具,并且它们可以帮助我们启动它们。这样,构建过程可以在开发机器上运行,而无需启动新的 shell 窗口。IDE 还可以从这些外部构建工具的配置文件中导入设置,以识别项目结构,源文件的位置以及编译的内容以支持在编辑时的错误检查。

构建过程通常包含对代码执行某些检查。一组 Java 源文件可能编译顺利,但代码可能仍然包含大量错误,并且可能编写得风格不佳,这将在长期内使项目变得难以维护。为了避免此类问题,我们将使用单元测试和静态代码分析工具。这些工具不能保证代码无错误,但出错的可能性要小得多。

集成开发环境(IDE)有插件可以运行静态代码分析工具以及单元测试。集成到 IDE 中具有巨大的优势。当分析工具或某些单元测试识别出任何问题时,IDE 会提供一个错误消息,该消息也像网页上的链接一样起作用。如果您点击该消息,通常为蓝色并带有下划线,就像网页上一样,编辑器会打开有问题的文件并将光标放置在问题所在的位置。

调试 Java

开发代码需要调试。Java 在开发过程中提供了非常好的调试代码的设施。JVM 通过 Java 平台调试架构支持调试器。这允许您以调试模式执行代码,JVM 将接受通过网络连接的外部调试工具,或者它将尝试根据命令行选项连接到调试器。JDK 包含一个客户端,即jdb工具,其中包含调试器;然而,与集成到 IDE 中的图形客户端相比,使用起来非常繁琐,以至于我从未听说过有人用它来实际工作。

要以调试模式启动 Java 程序,以便 JVM 可以接受调试器客户端连接到它,请执行以下命令:

    -Xagentlib:jdwp=transport=dt_socket,server=y,suspend=y,address=7896

Xagentlib选项指示 Java 运行时加载jdwp代理。选项中跟随-Xagentlib:jdwp=的部分由调试器代理解释。这些选项如下:

  • transport:这应指定要使用哪种传输方式。它可以是共享内存(dt_shmem)套接字或 TCP/IP 套接字传输,但在实践中,您将始终使用后者。这在上面的dt_socket示例中指定。

  • server:这指定了调试的 JVM 是启动在服务器模式还是客户端模式。当您以服务器模式启动 JVM 时,它开始监听套接字并接受调试器连接到它。如果以客户端模式启动,则它尝试连接一个应该以服务器模式启动的调试器,监听在端口上。选项的值是y,表示服务器模式,或n,表示非服务器模式,即客户端模式。

  • suspend: 这也可以是 yn。如果 JVM 以挂起模式启动,它将不会启动 Java 代码,直到一个调试器连接到它。如果以 suspend=n 启动,那么 JVM 将启动,并在达到断点时立即停止。如果你启动一个独立的 Java 应用程序,你通常会使用 suspend=y 来启动调试,这是默认设置。如果你想在一个应用程序服务器或 servlet 容器环境中调试应用程序,那么最好使用 suspend=n 启动;否则,服务器将不会启动,直到调试器连接到它。在 servlet 应用程序中,以 suspend=y 模式启动 Java 进程仅在你想要调试 servlet 静态初始化代码时有用,该代码在服务器启动时执行。如果没有挂起模式,你将需要非常快地连接调试器。在这种情况下,让 JVM 等待你更好。

  • address: 这应该指定 JVM 通信的地址。如果 JVM 以客户端模式启动,那么它将开始连接到这个地址。如果 JVM 以服务器模式运行,那么它将在该地址上接受来自调试器的连接。地址可以仅指定端口号。在这种情况下,IP 地址是本地机器的地址。

调试代理可能处理的其它选项是针对特殊情况的。对于本书涵盖的主题,前面的选项已经足够。

以下截图显示了一个典型的调试会话,我们在 IntelliJ IDE 中调试最简单的程序:

图片

当你在调试模式下从 IDE 启动程序时,所有这些选项都会自动为你设置。你只需在编辑器中的源代码上单击即可设置断点。你可以有一个单独的表单来添加、删除和编辑断点。断点可以附加到特定的行或特定的事件,例如当抛出异常时。附加到特定行的断点也可以有条件,告诉调试器只有当条件为真时才停止代码的执行;例如,如果变量具有某些预定义的值。

摘要

在本章中,我们用 Java 互相认识。我们彼此之间了解不多,但我们已经熟悉了。我们已经安装了 Java 环境:Java、JDK 和集成开发环境。我们编写了一个小程序,并简要地看了看可以使用开发工具做什么。这还远未达到精通,但即使是漫长的旅程也始于第一步,有时这是最难迈出的一步。我们在 Java 之旅中做到了这一点。我们开始滚动,对于我们这些热衷的人来说,没有什么可以阻止我们一路前行。

第二章:第一个真正的 Java 程序 - 排序姓名

在上一章中,我们熟悉了 Java,特别是使用 REPL 工具和交互式执行一些简单代码。这是一个好的开始,但我们还需要更多。在这一章中,我们将开发一个简单的排序程序。使用这段代码作为示例,我们将查看常用的构建工具,这些工具经常用于 Java 项目,并学习 Java 语言的基本特性。本章将涵盖以下主题:

  • 排序问题

  • 项目结构和构建工具

  • Make、Ant、Maven 和 Gradle 构建工具

  • 与代码示例相关的 Java 语言特性

排序入门

排序问题是工程师处理的最古老的编程任务之一。我们有一组记录,我们知道我们会在以后某个时候找到特定的一个,并且我们希望快速找到它。为了找到它,我们将记录按照特定的顺序排序,这有助于我们快速找到我们想要的记录。

例如,我们有学生的名字和他们在一些卡片上的分数。当学生来到办公室询问他们的成绩时,我们会逐张查看所有卡片,以找到询问学生的名字。然而,如果我们将卡片按学生的名字字母顺序排序会更好。当学生询问时,我们可以更快地搜索附在名字上的分数。

我们可以查看中间的牌;如果它显示了学生的名字,那么我们就很高兴找到了名字和分数。如果这张牌在学生名字的字母顺序之前,那么我们将继续在下半部分搜索;否则,我们将检查上半部分。

按照这种方法,我们可以通过几个步骤找到学生的名字。步骤的数量不能超过将牌组对半分所需的次数。如果有两张牌,那么最多两步。如果是四张,那么最多需要三步。如果有八张牌,那么可能需要四步,但不会更多。如果有 1,000 张牌,那么可能最多需要 11 步,而原始的非排序集合则需要 1,000 步,最坏的情况。也就是说,大约提高了搜索速度 100 倍,所以排序卡片是值得的,除非排序本身花费太多时间。我们刚才描述的找到已排序集合中元素的算法称为二分查找en.wikipedia.org/wiki/Binary_search_algorithm)。

在许多情况下,对数据集进行排序是值得的,并且有许多排序算法可以完成这项任务。有简单和复杂的算法,而且在许多情况下,更复杂的算法运行得更快。

由于我们专注于 Java 编程部分而不是算法锻造,在这一章中,我们将开发一个实现简单且不太快的算法的 Java 代码。

冒泡排序

我们将在本章中实现的算法是众所周知的冒泡排序。方法非常简单。从卡片的开头开始,比较第一张和第二张卡片。如果第一张卡片在字典顺序上比第二张卡片晚,那么交换这两张卡片。然后对现在位于第二位的卡片重复此操作,然后是第三位,以此类推。有一张字典顺序上最新的卡片,比如说威尔逊。当我们得到这张卡片并开始与下一张卡片比较时,我们总是会交换它们;这样,威尔逊的卡片就会移动到最后一个位置,它必须在排序后放在那里。我们唯一要做的就是从开始重复这个移动,偶尔再次交换卡片,但这次只交换到最后一个元素。这次,第二最新的元素将到达它的位置——比如说,威尔金森将在威尔逊之前。如果我们有n张卡片,并且重复n-1次,所有卡片都将到达它们的位置。

在接下来的章节中,我们将创建一个实现此算法的 Java 项目。

开始学习项目结构和构建工具

当一个项目比单个类更复杂,这通常是这样的,那么定义一个项目结构是明智的。我们得决定在哪里存储源文件,资源文件(那些包含程序资源但不包含 Java 源文件的文件)在哪里,编译器应该将.class文件写入哪里,等等。一般来说,结构主要是目录设置和执行构建的工具配置。

使用命令行发出javac命令来编译复杂程序是不可行的。如果我们有 100 个 Java 源文件,编译将需要发出那么多的javac命令。可以使用通配符来缩短这个过程,例如javac *.java,或者我们可以编写一个简单的 bash 脚本或 BAT 命令文件来完成这个任务。首先,它将只有 100 行,每行编译一个源 Java 文件到类文件。然后,我们会意识到,编译自上次编译以来没有改变的文件只是浪费时间、CPU 和电力,因此我们可以添加一些 bash 编程来检查源文件和生成文件的时间戳。然后,我们可能会意识到……无论什么。最后,我们将得到一个本质上是一个构建工具的工具。构建工具是现成的,不值得重新发明轮子。

我们不会创建一个,而是会使用一个现成的构建工具。可以在en.wikipedia.org/wiki/List_of_build_automation_software找到一些。在本章中,我们将使用一个名为 Maven 的工具;然而,在深入探讨这个工具的细节之前,我们将看看一些你作为 Java 专业人士在企业项目中可能会遇到的其他工具。

在接下来的章节中,我们将讨论四种构建工具中的几种:

  • Make

  • Ant

  • Maven

  • Gradle

我们将简要提及Make,因为它现在在 Java 环境中不再使用。然而,Make是第一个构建工具,现代 Java 构建工具的许多想法都源于“古老的”make。作为专业的 Java 开发者,您也应该熟悉Make,这样如果您在项目中偶然看到它的使用,就不会感到惊慌,并且可以了解它是什么以及其详细文档可以在哪里找到。

Ant 是多年前广泛用于 Java 的第一个构建工具,它仍然被许多项目使用。

Maven 比 Ant 更新,它采用了一种不同的方法。我们将详细探讨它。Maven 也是 Apache 软件基金会对 Java 项目的官方构建工具。我们也将在这个章节中使用 Maven 作为构建工具。

Gradle 甚至更新,并且它最近开始赶上 Maven。我们将在后面的章节中更详细地介绍这个工具。

Make

make程序最初是在 1976 年 4 月创建的,所以这不是一个新工具。它包含在 Unix 系统中,因此这个工具在 Linux、Mac OS X 或任何其他基于 Unix 的系统上无需额外安装即可使用。此外,该工具在 Windows 上有许多端口,并且某些版本包含在 Visual Studio 编译器工具集中。

Make与 Java 无关。它是在主要编程语言是 C 的时候创建的,但它并不局限于 C 或任何其他语言。make是一种具有非常简单语法的依赖描述语言。

make,就像任何其他构建工具一样,由一个项目描述文件控制。在make的情况下,这个文件包含一组规则。描述文件通常命名为Makefile,但如果描述文件的名称不同,可以在make命令的命令行选项中指定。

Makefile中的规则依次排列,由一行或更多行组成。第一行从第一个位置开始(行首没有制表符或空格),接下来的行以制表符开头。因此,Makefile可能看起来像以下代码:

run : hello.jar 
    java -cp hello.jar HelloWorld 

hello.jar : HelloWorld.class 
    jar -cf hello.jar HelloWorld.class 

HelloWorld.class : HelloWorld.java 
    javac HelloWorld.java

此文件定义了三个所谓的目标:runhello.jarHelloWorld.class。要创建HelloWorld.class,请在命令提示符下输入以下行:

    make HelloWorld.class

make将查看规则并看到它依赖于HelloWorld.java。如果HelloWorld.class文件不存在,或者HelloWorld.java比 Java 类文件新,make将执行下一行上的命令,并编译 Java 源文件。如果类文件是在HelloWorld.java的最后一次修改后创建的,那么make知道不需要运行该命令。

在创建HelloWorld.class的情况下,make程序的任务很简单。源文件已经存在。如果你发出make hello.jar命令,过程会更复杂。make命令看到,为了创建hello.jar,它需要HelloWorld.class,而HelloWorld.class本身也是另一个规则上的目标。因此,它可能需要被创建。

首先,它以与之前相同的方式开始解决问题。如果存在HelloWorld.class文件,并且它的版本比hello.jar旧,那么就没有什么需要做的。如果它不存在,或者版本比hello.jar新,那么就需要执行jar -cf hello.jar HelloWorld.class命令,尽管不一定是在意识到需要执行的那一刻。make程序会记住,在未来某个时刻,当所有创建HelloWorld.class所需的命令都已成功执行后,这个命令需要被执行。因此,它会以与我之前描述的完全相同的方式继续创建类文件。

通常,一个规则可以有以下格式:

target : dependencies 
    command

make命令可以通过首先计算要执行的命令,然后逐个执行它们,使用make target命令创建任何目标。这些命令是在不同的进程中执行的 shell 命令,可能在 Windows 下引起问题,这可能会使Makefile文件的操作系统依赖。

注意,run目标不是一个make实际创建的文件。目标可以是一个文件名,或者只是目标的名称。在后一种情况下,make永远不会认为目标是可以立即使用的。

由于我们不使用make进行 Java 项目,因此没有必要深入了解。此外,我在描述规则时稍微作弊了一点,使其比应有的描述更简单。make工具具有许多超出本书范围的功能。还有几个实现彼此略有不同。你很可能会遇到由自由软件基金会制作的版本——GNU make。当然,对于任何 Unix 命令行工具,man是你的朋友。man make命令将在屏幕上显示工具的文档。

关于make你应该记住的主要点如下:

  • 它以声明式的方式定义了各个工件(目标)的依赖关系

  • 它以命令式的方式定义了创建缺失工件的动作

这种结构是在几十年前发明的,并且至今为止在大多数构建工具中仍然存在,你将在接下来的几章中看到这一点。

Ant

ant 构建工具是在 2000 年左右专门为 Java 项目构建的。Java 希望成为一次编写,到处运行的语言,这需要一个可以在不同环境中使用的工具。尽管make在 Unix 机器和 Windows 上都有,但Makefiles并不总是兼容。使用制表符字符存在一个小问题,一些编辑器将其替换为空格,导致Makefile无法使用,但这不是主要原因。make的主要问题,也是激发 Ant 开发的原因是,它的命令是 shell 命令。即使make程序的实现被制作成可以在不同的操作系统上兼容,但使用的命令很多时候是不兼容的,这是make本身无法改变的事情。因为make向外部命令发出构建目标,开发者可以自由使用他们在开发机器上可用的任何外部工具。使用相同操作系统的另一台机器可能没有make调用的相同工具集。这削弱了make构建项目的可移植性。

同时,Ant 遵循make的主要原则。有一些目标可能相互依赖,有一些命令需要按照适当的顺序执行以创建目标,并遵循依赖顺序。依赖和命令的描述是 XML(解决了制表符问题),命令是用 Java 实现的(解决了系统依赖问题,嗯...更多或更少)。

由于 Ant 既不是操作系统的一部分,也不是 JDK 的一部分,如果您想使用它,您必须单独下载和安装。

安装 Ant

您可以从其官方网站(ant.apache.org)下载 Ant。您可以下载源代码或预编译版本。最简单的方法是下载tar.gz格式的二进制文件。

无论何时您从互联网下载软件,都强烈建议您检查下载文件的完整性。HTTP 协议不包含错误检查,可能发生网络错误仍然隐藏或恶意内部代理修改了下载的文件。下载网站通常为可下载文件提供校验和。这些通常是 MD5、SHA1、SHA512 或其他校验和。

当我以tar.gz格式下载 Apache Ant 1.9.7 版本时,我也打开了指向 MD5 校验和的页面。校验和值是bc1d9e5fe73eee5c50b26ed411fb0119

您可以使用以下命令行检查下载的文件:

$ md5 apache-ant-1.9.7-bin.tar.gz

MD5 (apache-ant-1.9.7-bin.tar.gz) = bc1d9e5fe73eee5c50b26ed411fb0119

计算出的 MD5 校验和与网站上的相同,这意味着文件完整性没有受损。

在 Windows 操作系统上,没有包含计算 MD5 摘要的工具。有一个由微软提供的工具,称为文件完整性校验和验证工具,可通过页面support.microsoft.com/en-us/help/841290/availability-and-description-of-the-file-checksum-integrity-verifier-utility获取。如果你使用 Linux,可能会发生md5md5sum实用工具未安装的情况。在这种情况下,你可以使用apt-get或你的 Linux 发行版支持的任何安装工具来安装它。

文件下载后,你可以使用以下命令将其展开到子目录:

    tar xfz apache-ant-1.9.7-bin.tar.gz

创建的子目录是 Ant 的可用二进制发行版。通常,我会将其移动到~/bin目录下,使其仅在 OS X 上对我的用户可用。之后,你应该设置环境变量ANT_HOME指向此目录,并将安装的bin目录添加到PATH中。为此,你应该编辑~/.bashrc文件,并向其中添加以下行:

export ANT_HOME=~/bin/apache-ant-1.9.7/ 
export PATH=${ANT_HOME}bin:$PATH

然后,重新启动终端应用程序,或者只需输入. ~/.bashrc并测试 Ant 的安装,通过输入以下命令:

    $ ant
Buildfile: build.xml does not exist!
Build failed

如果安装正确,你应该看到前面的错误信息。

使用 Ant

当你看到 Ant 要构建的项目时,你会看到一个build.xml文件。这是项目构建文件,即 Ant 在检查安装是否正确时缺失的那个文件。它可以有其他任何名称,你可以将文件的名称作为 Ant 的命令行选项指定,但这是默认文件名,就像Makefile对于make一样。一个build.xml示例如下:

<project name="HelloWorld" default="jar" basedir="."> 
<description> 
    This is a sample HelloWorld project build file. 
</description> 
    <property name="buildDir" value="build"/> 
    <property name="srcDir" value="src"/> 
    <property name="classesDir" value="${buildDir}/classes"/> 
    <property name="jarDir" value="${buildDir}/jar"/> 

    <target name="dirs"> 
        <mkdir dir="${classesDir}"/> 
        <mkdir dir="${jarDir}"/> 
    </target> 

    <target name="compile" depends="dirs"> 
        <javac srcdir="${srcDir}" destdir="${classesDir}"/> 
    </target> 

    <target name="jar" depends="dirs,compile"> 
        <jar destfile="${jarDir}/HelloWorld.jar" basedir="${classesDir}"/> 
    </target> 
</project>

最高级别的 XML 标签是project。每个构建文件描述一个项目,因此得名。该标签有三个可能的属性,如下所示:

  • name:这定义了项目的名称,并被一些 IDE 用于在左侧面板中标识项目

  • default:当在启动 Ant 时命令行上没有定义目标时,使用此目标

  • basedir:这定义了在构建文件中用于任何其他目录名称计算的初始目录

构建文件可以包含项目的描述,以及属性标签中的属性。这些属性可以用作${}字符之间的任务属性中的变量,并在构建过程中发挥重要作用。

目标在目标 XML 标签中定义。每个标签都应该有一个唯一标识构建文件中目标的名称,并且可能有一个 depends 标签,指定一个或多个此目标所依赖的其他目标。如果有多个目标,则目标在属性中以逗号分隔。属于目标的任务将按照目标依赖链要求的顺序执行,这与我们在 make 的情况中看到的方式非常相似。

你还可以为 Ant 打印的具有 -projecthelp 命令行选项的目标添加一个 description 属性。这有助于构建文件的用户了解有哪些目标以及它们的作用。随着目标数量的增加,构建文件往往会变得很大,当你有十个或更多目标时,很难记住每一个目标。

包含 HelloWorld.java 的示例项目现在已按以下目录排列:

  • 项目根目录下的 build.xml

  • 项目 src 文件夹中的 HelloWorld.java

  • build/ 文件夹不存在;它将在构建过程中创建

  • build/classesbuild/jar 也尚未存在,将在构建过程中创建

当你第一次为 HelloWorld 项目启动构建时,你会看到以下输出:

$ ant  
Buildfile: /Users/verhasp/Dropbox/java_9-by_Example/sources/ch02/build.xml 

dirs: 
    [mkdir] Created dir: /Users/verhasp/Dropbox/java_9-by_Example/sources/ch02/build/classes 
    [mkdir] Created dir: /Users/verhasp/Dropbox/java_9-by_Example/sources/ch02/build/jar 

compile: 
... 
    [javac] Compiling 1 source file to /Users/verhasp/Dropbox/java_9-by_Example/sources/ch02/build/classes 

jar: 
      [jar] Building jar: /Users/verhasp/Dropbox/java_9-by_Example/sources/ch02/build/jar/HelloWorld.jar 

BUILD SUCCESSFUL 
Total time: 0 seconds

从实际输出中删除了一些不重要的行。

Ant 实现了首先需要创建目录,然后需要编译源代码,最后才能将 .class 文件打包成 .jar 文件。现在,你需要记住执行 HelloWorld 应用程序的命令。它已在第一章中列出。请注意,这次,JAR 文件名为 HelloWorld.jar,它不在当前目录中。你也可以尝试阅读 Ant 的在线文档,创建一个名为 run 的目标,以执行编译和打包的程序。

Ant 内置了一个名为 java 的任务,它以几乎与你在终端中输入 java 命令相同的方式执行 Java 类。

Maven

由于 Ant 是为了克服 make 的不足而创建的,Maven 也是出于类似的目的——为了克服 Ant 的不足而创建的。你可能还记得,make 不能保证构建的可移植性,因为 make 执行的命令是任意的 shell 命令,可能具有系统特定性。只要 Java 在不同平台上以相同的方式运行,只要所有任务都在类路径上可用,Ant 构建就是可移植的。

Ant 的问题略有不同。当你下载项目的源代码并想要构建时,将会使用什么命令?你应该要求 Ant 列出所有目标并选择看起来最合适的一个。任务的名称取决于编写 build.xml 文件的工程师。有一些约定,但它们不是严格的规则。

你在哪里可以找到 Java 源文件?它们是否在src目录下?如果项目是多语言的,是否还会有 Groovy 或其他编程语言的文件?这取决于。再次强调,可能会有一些某些团队或公司文化建议的惯例,但并没有普遍的最佳行业实践。

当你使用 Ant 开始一个新项目时,你必须创建编译、测试执行和打包的目标。这是你已经在其他项目中做过的。在第二个或第三个项目之后,你只需复制并粘贴你之前的build.xml到新项目中。这是问题吗?是的,是问题。这是复制/粘贴编程,即使它只是仅仅一些构建文件。

开发者意识到,使用 Ant 的项目中,相当一部分工作都投入到了项目构建工具的配置中,包括重复性任务。当新成员加入团队时,他们首先必须学习如何配置构建。如果启动了一个新项目,就必须创建构建配置。如果是重复性任务,那么最好让计算机来做。这不就是编程通常要做的事情吗?

Maven 在构建问题上的处理方式略有不同。我们想要构建 Java 项目。有时,可能会有一些 Groovy 或 Jython 的东西,但它们也是 JVM 语言;因此,说我们想要构建 Java 项目并不是一个很大的限制。Java 项目包含 Java 文件,有时还有一些其他编程语言的源文件、资源文件,通常就是这样。Ant 可以做任何事情,但我们不希望用构建工具做任何事情。我们想要构建项目。

好吧,在限制自己并接受我们不需要一个可以用于任何目的的构建工具之后,我们可以继续前进。我们可以要求源文件位于src目录下。有一些文件是操作代码所需的,还有一些文件包含一些测试代码和数据。因此,我们将有两个目录,src/testsrc/main。Java 文件位于src/main/java以及src/test/java。资源文件位于src/main/resourcessrc/test/resources

如果你想要将源文件放在其他地方,那就不要这么做。我是认真的。虽然可能可行,但我甚至不会告诉你如何做。没有人这么做。我甚至不知道为什么 Maven 允许这样做。无论何时你看到使用 Maven 作为构建工具的项目,源文件都是这样组织的。没有必要理解项目构建工程师所设想的目录结构。它总是相同的。

目标和任务又是如何的呢?在所有基于 Maven 的项目中,它们都是相同的。除了编译、测试、打包或部署 Java 项目之外,您还想用 Maven 做些什么?Maven 为我们定义了这些项目生命周期。当您想使用 Maven 作为构建工具编译项目时,您将需要输入 $ mvn compile 来编译项目。即使您还不了解项目实际上是什么,您也可以这样做。

由于我们有相同的目录结构和相同的目标,导致这些目标的实际任务也都是相同的。当我们创建 Maven 项目时,我们不必描述构建过程需要做什么以及如何做。我们将不得不描述项目,以及仅限于项目特定的部分。

Maven 项目的构建配置在一个 XML 文件中给出。这个文件的名称通常是 pom.xml,它应该位于项目的 root 目录中,这应该是启动 Maven 时的当前工作目录。POM 这个词代表 项目对象模型,它以分层的方式描述项目。源代码目录、打包和其他内容定义在一个所谓的超级 POM 中。这个 POM 是 Maven 程序的一部分。POM 中定义的任何内容都会覆盖超级 POM 中定义的默认值。当有一个包含多个模块的项目时,POMs 会按照层次结构排列,并且从父项目继承配置值到模块。由于我们将使用 Maven 来开发我们的排序代码,我们将在稍后看到更多细节。

安装 Maven

Maven 既不是操作系统的一部分,也不是 JDK 的一部分。它必须以与 Ant 非常相似的方式下载和安装。您可以从其官方网站的下载部分下载 Maven(maven.apache.org/)。目前,最新稳定版本是 3.3.9。当您下载时,实际发布的版本可能不同;相反,请使用最新稳定版本。您可以下载源代码或预编译版本。最简单的方法是下载 tar.gz 格式的二进制文件。

我不能忽视提醒您检查下载完整性的重要性,使用校验和进行详细说明已在 Ant 安装部分的章节中。

文件下载完成后,您可以使用以下命令将其解压到子目录:

tar xfz apache-maven-3.3.9-bin.tar.gz

创建的子目录是 Maven 的可用二进制发行版。通常,我会将它移动到 ~/bin 目录下,使其仅在 OS X 上对我的用户可用。之后,您应该将安装的 bin 目录添加到 PATH 中。为此,您应该编辑 ~/.bashrc 文件,并向其中添加以下行:

export M2_HOME=~/bin/apache-maven-3.3.9/ 
export PATH=${M2_HOME}bin:$PATH

然后,重新启动终端应用程序,或者只需输入 . ~/.bashrc 并通过以下方式测试 Maven 的安装:

    $ mvn -v
Apache Maven 3.3.9 (bb52d8502b132ec0a5a3f4c09453c07478323dc5; 2015-11-10T17:41:47+01:00)
Maven home: /Users/verhasp/bin/apache-maven-3.3.9
Java version: 9-ea, vendor: Oracle Corporation
Java home: /Library/Java/JavaVirtualMachines/jdk-9.jdk/Contents/Home
Default locale: en_US, platform encoding: UTF-8
OS name: "mac os x", version: "10.11.6", arch: "x86_64", family: "mac"

您应该在屏幕上看到类似的消息,显示已安装的 Maven 版本和其他信息。

使用 Maven

与 Ant 不同,Maven 帮助您创建新项目的骨架。为此,您必须输入以下命令:

    $ mvn archetype:generate

Maven 首先会从网络上下载实际可用的项目类型,并提示您选择想要使用的一个。当 Maven 还比较新的时候,这种方法看起来是个好主意。当我第一次开始使用 Maven 时,列出的项目数量在 10 到 20 之间。今天,当我写这本书的时候,它列出了 1,635 种不同的原型。这个数字看起来更像是一个历史日期(法国科学院的宪法),而不是一个可用的不同原型的列表大小。然而,请不要慌张。当 Maven 询问您的选择时,它会提供一个默认值,这对于我们想要的 HelloWorld 来说是很好的。

    Choose a number: 817: 

实际的数字可能因您的安装而异。无论是什么,接受建议并按 Enter 键。之后,Maven 将询问项目的版本:

    Choose version: 
1: 1.0-alpha-1
2: 1.0-alpha-2
3: 1.0-alpha-3
4: 1.0-alpha-4
5: 1.0
6: 1.1
Choose a number: 6: 5

选择列表中编号为 51.0 版本。接下来,Maven 会询问项目的组 ID 和工件 ID。我们将在后面讨论的依赖关系管理使用这些信息。我根据书籍和出版社选择了组 ID。项目的工件是 SortTutorial,因为我们将从这个项目的章节示例开始。

Define value for property 'groupId': : packt.java9.by.example
Define value for property 'artifactId': : SortTutorial

接下来的问题是项目的当前版本。我们已经选择了 1.0,Maven 提供了 1.0-SNAPSHOT。在这里,我选择了 1.0.0-SNAPSHOT,因为我更喜欢语义版本控制。

Define value for property 'version':  1.0-SNAPSHOT: : 1.0.0-SNAPSHOT

语义版本控制,定义在 semver.org/ 上,是一种建议使用三位数字版本号作为 M.m.p. 的版本控制方案,分别代表 修补 版本号。这对于库来说非常有用。如果自上次发布以来只有错误修复,您将增加最后一个版本号。当新版本包含新功能,但库与旧版本兼容时,您将增加次要号;换句话说,任何使用旧版本的程序仍然可以使用新版本。当新版本与旧版本有显著不同时,增加主发布号。

在应用程序程序的情况下,没有使用应用程序 API 的代码;因此,次要版本号并不那么重要。尽管如此,它并不妨碍,而且它通常被证明是有用的,可以用来表示应用程序中的较小变化。我们将在最后一章讨论如何对软件进行版本控制。

Maven 将带有 -SNAPSHOT 后缀的版本视为非发布版本。在我们开发代码的过程中,我们将有许多 版本 的代码,所有这些代码都具有相同的快照版本号。另一方面,非快照版本号只能用于单个版本。

Define value for property 'package':  packt.java9.by.example: :

程序骨架生成中的最后一个问题是 Java 包的名称。默认值是我们为 groupId 给定的值,我们将使用这个值。使用其他东西的情况很少。

当我们指定了所有需要的参数后,最后的请求是确认设置:

Confirm properties configuration: 
groupId: packt.java9.by.example 
artifactId: SortTutorial 
version: 1.0.0-SNAPSHOT 
package: packt.java9.by.example 
 Y: : Y

在输入 Y 后,Maven 将生成项目所需的文件并显示关于此的报告:

[INFO] ----------------------------------------------------------- 
[INFO] Using following parameters for creating project from Old (1.x) Archetype: maven-archetype-quickstart:1.0 
[INFO] ----------------------------------------------------------- 
[INFO] Parameter: basedir, Value: .../mavenHelloWorld 
[INFO] Parameter: package, Value: packt.java9.by.example 
[INFO] Parameter: groupId, Value: packt.java9.by.example 
[INFO] Parameter: artifactId, Value: SortTutorial 
[INFO] Parameter: packageName, Value: packt.java9.by.example 
[INFO] Parameter: version, Value: 1.0.0-SNAPSHOT 
[INFO] *** End of debug info from resources from generated POM *** 
[INFO] project created from Old (1.x) Archetype in dir: .../mavenHelloWorld/SortTutorial 
[INFO] ----------------------------------------------------------- 
[INFO] BUILD SUCCESS 
[INFO] ----------------------------------------------------------- 
[INFO] Total time: 01:27 min 
[INFO] Finished at: 2016-07-24T14:22:36+02:00 
[INFO] Final Memory: 11M/153M 
[INFO] -----------------------------------------------------------

你可以查看以下生成的目录结构:

你还可以看到它生成了以下三个文件:

  • SortTutorial/pom.xml 包含 项目对象模型

  • SortTutorial/``src/main/java/packt/java9/by/example/App.java 包含一个 HelloWorld 示例应用程序

  • SortTutorial/src/test/java/packt/java9/by/example/AppTest.java 包含一个使用 junit4 库的单元测试框架

我们将在下一章讨论单元测试。现在,我们将专注于排序应用程序。由于 Maven 非常友好,为应用程序生成了一个示例类,我们可以编译并运行它,而不需要实际编码,只是为了看看我们如何使用 Maven 构建项目。通过执行 cd SortTutorial 将默认目录更改为 SortTutorial,然后执行以下命令:

    $ mvn package

我们将得到以下输出:

Maven 会自动启动、编译和打包项目。如果不这样做,请阅读下一个信息框。

当你第一次启动 Maven 时,它会从中央仓库下载大量的依赖项。这些下载需要时间,并且会在屏幕上报告,所以实际的输出可能与前面代码中看到的不同。

Maven 使用 Java 版本 1.5 的默认设置编译代码。这意味着生成的类文件与 Java 版本 1.5 兼容,并且编译器只接受在 Java 1.5 中已经可用的语言结构。如果我们想使用更新的语言特性,本书中我们使用了很多,那么应该编辑 pom.xml 文件以包含以下行:

<build>

    <plugins>

      <plugin>

        <groupId>org.apache.maven.plugins</groupId>

        <artifactId>maven-compiler-plugin</artifactId>

        <configuration>

          <source>1.9</source>

          <target>1.9</target>

        </configuration>

      </plugin>

    </plugins>

  </build>

当使用 Java 9 的 Maven 默认设置时,它变得更加复杂,因为 Java 9 不生成类格式,也不限制比 Java 1.6 更早的源兼容性。在我写下这些行的时候,最新的 Maven 版本是 3.3.9。当我尝试不进行修改编译前面的代码时,Java 编译器会停止并显示以下错误:

[ERROR] 源选项 1.5 已不再支持。请使用 1.6 或更高版本。

**[ERROR] 目标选项 1.5 已不再支持。请使用 1.6 或更高版本。**

以后,Maven 的发布可能会表现出不同的行为。

现在,你可以使用以下命令开始代码:

    $ java -cp target/SortTutorial-1.0.0-SNAPSHOT.jar packt.java9.by.example.App

你可以在以下图片中看到示例运行的输出结果:

Gradle

Ant 和 Maven 是两个世界,使用其中一个可能会导致在互联网论坛上引发激烈的争论。Ant 赋予开发者创建符合自己口味的构建过程的自由。Maven 则限制团队使用更标准的构建过程。某些不符合任何标准构建过程但有时在某些环境中需要的特殊过程,使用 Maven 很难实现。在 Ant 中,您可以使用内置任务几乎编写任何脚本,几乎就像您编写 bash 脚本一样。使用 Maven 并不简单,并且通常需要编写插件。尽管编写插件并非难事,但开发者通常更喜欢有以更简单的方式完成任务的可能性:脚本。我们有两种方法,两种思维方式和风格,而不是一个能满足所有需求的单一工具。因此,在 Java 技术发展过程中,一个新的构建工具应运而生。

Gradle 试图融合两者的优点,使用在 Maven 和 Ant 最初开发时不可用的技术。

Gradle 具有内置的目标和生命周期,但与此同时,您也可以编写自己的目标。您可以通过配置项目,就像使用 Maven 一样,而不需要编写脚本任务来完成,但与此同时,您也可以像在 Ant 中一样编写自己的目标。更重要的是,Gradle 集成了 Ant,因此任何为 Ant 实现的任务也可以在 Gradle 中使用。

Maven 和 Ant 使用 XML 文件来描述构建过程。如今,XML 已经成为一种过时的技术。我们仍然在使用它,并且开发者应该熟练掌握处理、读取和编写 XML 文件,但一个现代的工具不会使用 XML 进行配置。新的、花哨的格式,如 JSON,更为流行。Gradle 也不例外。Gradle 的配置文件使用基于 Groovy 的领域特定语言DSL)。这种语言对程序员来说更易读,并为编程构建过程提供了更多自由。这也是 Gradle 的危险所在。

将强大的 JVM 语言 Groovy 掌握在开发者手中,以创建构建工具,赋予了他们创建看似不错但后来可能证明过于复杂和难以维护的复杂构建过程的自由和诱惑。这正是 Maven 最初被实施的原因。

在进入另一个可能引发激烈且无意义的争论的领域之前,我必须停下来。Gradle 是一个非常强大的构建工具。您应该小心使用它,就像您使用武器一样——不要射击自己的腿。

安装 Gradle

要安装 Gradle,您需要从gradle.org/gradle-download/网站下载编译后的二进制文件。

再次强调,使用校验和检查下载完整性非常重要。我在关于 Ant 安装的部分中给出了详细的操作方法。

不幸的是,Gradle 网站没有提供可下载文件的校验和值。

Gradle 可以以 ZIP 格式下载。要解压文件,您必须使用 unzip 命令:

    $ unzip gradle-3.3-bin.zip

创建的子目录是 Gradle 的可用二进制发行版。通常,我会将它移动到 ~/bin 目录下,使其仅对我的 OS X 用户可用。之后,您应该将安装的 bin 目录添加到 PATH 中。为此,您应该编辑 ~/.bashrc 文件并添加以下行:

export GRADLE_HOME=~/bin/gradle-3.3/ 
export PATH=${GRADLE_HOME}bin:$PATH

然后,重新启动终端应用程序,或者只需输入 . ~/.bashrc 并测试 Gradle 的安装,输入以下内容:

    $ gradle -version

我们得到了以下输出,如这个截图所示:

图片

使用 Maven 设置项目

要启动项目,我们将使用 Maven 在以下命令行启动时创建的目录结构和 pom.xml 文件:

    $ mvn archetype:generate

它创建了目录,pom.xml 文件和 App.java 文件。现在,我们将通过创建新文件来扩展这个项目。我们首先将在 packt.java9.by.example.stringsort 包中编写排序算法的代码:

图片

当我们在 IDE 中创建新包时,编辑器将自动在已存在的 src/main/java/packt/java9/by/example 目录下创建 stringsort 子目录:

图片

使用 IDE 创建新的 Sort 类也会自动在这个目录下创建一个名为 Sort.java 的新文件,并填充类的骨架:

package packt.java9.by.example.stringsort; 

public class Sort { 
}

我们现在将拥有包含以下代码的 App.java

package packt.java9.by.example; 

public class App  
{ 
    public static void main( String[] args ) 
    { 
        System.out.println( "Hello World!" ); 
    } 
}

Maven 以起始版本创建了它。我们将编辑这个文件以提供一个排序算法可以排序的样本列表。我建议您使用 IDE 编辑文件,并编译和运行代码。IDE 提供了一个快捷菜单来启动代码,这比在终端中输入命令要简单一些。通常,建议您熟悉 IDE 功能以节省时间,避免重复性任务,例如输入终端命令。专业开发者几乎只使用命令行来测试命令行功能,并在可能的情况下使用 IDE。

图片

编写排序代码

Maven 和 IDE 为排序程序创建了文件。它们形成了我们代码的骨架,现在是我们让它们变得有肌肉的时候了。我们花费了相当多的时间通过访问不同的构建工具来设置项目,只是为了学习如何编译代码。我希望这没有让您分心太多,但无论如何,我们应看到一些真正的代码。

首先,我们将编写排序代码,然后编写调用排序的代码。调用排序的代码是一种测试代码。为了简单起见,我们现在将简单地使用 public static void main 方法来启动代码。我们将在后面的章节中使用测试框架。

目前,排序的代码将看起来像这样:

package packt.java9.by.example.stringsort; 

public class Sort { 

    public void sort(String[] names) { 
        int n = names.length; 
        while (n > 1) { 
            for (int j = 0; j < n - 1; j++) { 
                if (names[j].compareTo(names[j + 1]) > 0) { 
                    final String tmp = names[j + 1]; 
                    names[j + 1] = names[j]; 
                    names[j] = tmp; 
                } 
            } 
            n--; 
        } 
    } 
}

这是一个执行排序的类。这个类中只有一个方法用于排序。该方法接受一个包含字符串的数组作为参数,并对这个数组进行排序。这个方法没有返回值。在声明中,这通过伪类型void表示。方法使用它们的参数执行一些任务,并且可能返回一个值。方法的参数是通过值传递的,这意味着方法不能修改作为参数传递的变量。然而,它可以修改参数包含的对象。在这种情况下,数组将被修改,我们将对其进行排序。另一方面,actualNames变量将指向同一个数组,而sort方法无法做任何操作来使这个变量指向不同的数组。

这个类中没有main方法,这意味着它不能从命令行自行启动。这个类只能从其他类中使用,因为每个 Java 程序都应该有一个包含public static void main方法的类,这是我们单独创建的。

我也可以在类中放入一个main方法使其可执行,但这不是好的做法。真正的程序由许多类组成,一个类不应该做很多事情。相反,应该是相反的。单一职责原则指出,一个类应该只负责一件事情;因此,class sort执行排序。执行应用程序是不同的任务,因此它必须在不同的类中实现。

通常,我们不会实现包含main方法的类。通常,一个框架会提供它。例如,编写在 servlet 容器中运行的servlet需要包含一个实现javax.servlet.Servlet接口的类。在这种情况下,程序表面上没有main方法。servlet 容器的实际实现有。Java 命令行启动容器,容器在需要时加载 servlets。

在下面的示例代码中,我们实现了包含main方法的App类:

package packt.java9.by.example; 

import packt.java9.by.example.stringsort.Sort; 

public class App { 
    public static void main(String[] args) { 
        String[] actualNames = new String[]{ 
                "Johnson", "Wilson", 
                "Wilkinson", "Abraham", "Dagobert" 
        }; 
        final Sort sorter = new Sort(); 
        sorter.sort(actualNames); 
        for (final String name : actualNames) { 
            System.out.println(name); 
        } 
    } 
}

这段代码包含一个初始化为包含常量值的字符串数组,创建了一个Sort类的新实例,调用了sort方法,然后将其打印到标准输出。

在真正的程序中,我们几乎从不将这样的常量放在程序代码中;我们将它们放入资源文件中,并有一些代码来读取实际的值。这使代码与数据分离,便于维护,消除了仅更改数据时意外修改代码结构的风险。同样,我们几乎不会使用System.out将任何内容写入标准输出。通常,我们将使用来自不同来源的日志记录可能性。有不同库提供日志记录功能,日志记录也可以从 JDK 本身获得。

至今为止,我们将专注于简单的解决方案,以免不同库和工具的众多选择分散你对 Java 的注意力。在下一节中,我们将查看我们用来编写算法的 Java 语言结构。首先,我们将一般地查看它们,然后,在更详细地查看。这些语言特性不是相互独立的:一个建立在另一个之上,因此,解释将首先是一般的,我们将在子节中深入细节。

理解算法和语言结构

算法在章节的开始处已经解释过了。实现位于Sort类中的sort方法内,并且只有几行代码:

        int n = names.length; 
        while (n > 1) { 
            for (int j = 0; j < n - 1; j++) { 
                if (names[j].compareTo(names[j + 1]) > 0) { 
                    final String tmp = names[j + 1]; 
                    names[j + 1] = names[j]; 
                    names[j] = tmp; 
                } 
            } 
            n--; 
        }

n变量在排序开始时持有数组的长度。Java 中的数组总是有一个属性来给出长度,这个属性叫做length。当我们开始排序时,我们将从数组的开始到结束进行遍历,正如你可能记得的,最后一个元素,Wilson,将在第一次迭代中走到最后一个位置。后续的迭代将会更短,因此变量n将会减少。

Java 中的代码是在代码块中创建的。任何位于{}字符之间的内容都是一个块。在上一个示例中,方法的代码就是一个块。它包含命令,其中一些命令,如while循环,也包含一个块。在这个块内部,有两个命令。其中一个是for循环,同样包含一个块。虽然我们可以使用单个表达式来形成循环体,但我们通常使用块。我们将在接下来的几页中详细讨论循环。

正如前一个示例中所看到的,循环可以嵌套,因此{}字符形成一对。一个块可以位于另一个块内部,但两个块不能重叠。当代码包含一个}字符时,它是在关闭最后打开的块。

变量

在 Java 中,就像在几乎任何编程语言中一样,我们使用变量。Java 中的变量是有类型的。这意味着一个变量只能持有单一类型的值。在程序中的某个时刻,一个变量不能同时持有int类型和String类型。当声明变量时,它们的类型会写在变量名之前。

变量也有可见作用域。方法中的局部变量只能在其定义的块内部使用。变量可以在方法中使用,或者它们可以属于一个类或一个对象。为了区分这两种,我们通常将这些变量称为字段

类型

每个变量都有一个类型。在 Java 中,主要有两大类型组:原始类型和引用类型。原始类型是预定义的,你不能定义或创建一个新的原始类型。有八个原始类型:byteshortintlongfloatdoublebooleanchar

前四种类型,byteshortintlong,是有符号的数值整数类型,能够在 8、16、32 和 64 位上存储正数和负数。

floatdouble 类型在 IEEE 754 浮点格式上以 32 位和 64 位存储浮点数。

boolean 类型是一种原始类型,它只能是 truefalse

char 类型是一种字符数据类型,存储单个 16 位 Unicode 字符。

对于每种原始类型,都有一个可以存储相同类型值的类。当一个原始类型需要转换为相应的类类型时,这是自动完成的。这被称为自动装箱。这些类型包括 ByteShortIntegerLongFloatDoubleBooleanCharacter。以以下变量声明为例:

Integer a = 113;

这将值 113,它是一个 int 数字,转换为 Integer 对象。

这些类型是运行时的一部分,也是语言的一部分。尽管没有它的原始对应类型,但有一个非常重要且无处不在的类,我们已经在使用它了:String。字符串包含字符。

原始类型和对象之间的主要区别是,原始类型不能用来调用方法,但它们消耗的内存更少。在数组的情况下,内存消耗及其对速度的影响非常重要。

数组

变量可以是原始类型,根据其声明,或者它们可能持有对象的引用。一个特殊的对象类型是数组。当一个变量持有数组的引用时,它可以使用 [] 字符以及一个由 0 或一个正值组成的整数值(该值小于数组的长度),来访问数组的特定元素。Java 支持多维数组,当数组有也是数组的元素时。Java 中的数组从零开始索引。在运行时检查下标越界,结果是异常。

异常是特殊条件,它中断了正常的执行流程,并停止代码的执行或跳转到最近的 catch 语句。我们将在下一章讨论异常及其处理方法。

当代码有一个原始类型的数组时,数组包含许多内存槽,每个槽存储该类型的值。当数组是引用类型时,换句话说,当它是一个对象数组时,那么数组元素是对象的引用,每个对象包含该类型。以 int 为例,数组的每个元素是 32 位,即 4 字节。如果数组是 Integer 类型,那么元素是对象的引用,可以说是指针,通常在 64 位 JVM 上是 64 位,在 32 位 JVM 上是 32 位。此外,还有一个包含 4 字节值的 Integer 对象存储在内存中,还有一个可能多达 24 字节的对象头。

管理每个对象所需额外信息的实际大小在标准中未定义。在不同的 JVM 实现中可能不同。实际的编码,甚至是在某个环境中的代码优化,都不应依赖于实际的大小。然而,开发者应该意识到这种开销存在,并且每个对象大约在 20 个字节左右。从内存消耗的角度来看,对象是昂贵的。

内存消耗是一个问题,但还有其他问题。当程序处理大量数据并且工作需要数组的连续元素时,CPU 会将一大块内存加载到处理器缓存中。这意味着 CPU 可以更快地访问数组的连续元素。如果数组是原始类型,那么它很快。如果数组是某些类类型,那么 CPU 必须访问内存以获取实际值,这可能会慢 50 倍。

表达式

Java 中的表达式与其他编程语言非常相似。你可以使用类似于 C 或 C++ 这样的语言中的运算符。它们如下所示:

  • 一元前缀和后缀增量运算符 (--++ 在变量前后)

  • 一元符号 (+-) 运算符

  • 逻辑 (!) 和位运算 (~) 取反

  • 乘法 (*), 除法 (/), 和取模 (%)

  • 加法和减法 (+ 和 - 再次,但这次作为二元运算符)

  • 移位运算符将值按位移动,有左 (<<) 和右 (>>) 移位,以及无符号右移 (>>>)

  • 比较运算符是 <, >, <=, >=, ==, !=instanceof,它们的结果是 boolean

  • 有按位或 (|), 与 (&), 异或 (^) 运算符,以及类似的逻辑或 (||), 与 (&&) 运算符

当逻辑运算符被评估时,它们是短路评估。这意味着只有当无法从左操作数的结果中识别出结果时,才会评估右操作数。

三元运算符与 C 语言中的类似,根据某些条件从表达式中选择一个:condition ? expression 1 : expression 2。通常,三元运算符没有问题,但有时你必须小心,因为当两个表达式不是同一类型时,有一个复杂的规则控制类型转换。最好让两个表达式具有相同的类型。

最后,有一个赋值运算符 (=),它将表达式的值赋给一个变量。对于每个二元运算符,都有一个赋值版本,它将 = 与二元运算符组合起来执行涉及右操作数的操作,并将结果赋给左操作数,该操作数必须是变量。这些是 +=, -=, *=, /=, %=, &=, ^=, |=, <<=, >>=, 和 >>>=

运算符有优先级,并且可以通过括号覆盖,就像通常一样。

表达式的一个重要部分是调用方法。可以通过类名和方法名来调用静态方法。例如,为了计算 1.22 的正弦值,我们可以编写以下行:

double z = Math.sin(1.22);

在这里,Math 是来自 java.lang 包的类。方法 sin 是在不使用 Math 的任何实例的情况下调用的。这个方法是 static 的,我们不太可能需要 Math 类中提供的其他实现。

可以使用实例和方法的名称来调用非静态方法,方法名称之间用点分隔。例如,以下代码行是一个例子:

System.out.println("Hello World");

上述代码使用了一个 PrintStream 类的实例,这个实例可以通过 System 类中的一个静态字段轻松获得。这个变量被称为 out,当我们编写代码时,我们必须将其引用为 System.outprintln 方法是在 PrintStream 类中定义的,我们通过变量 out 引用的对象来调用它。这个例子还表明,静态字段也可以通过类名和字段名之间用点分隔的方式来引用。同样,当我们需要引用非静态字段时,我们可以通过类的实例来引用。

在同一类中定义的静态方法,或者是从该类继承而来的类中定义的静态方法,可以在不使用类名的情况下调用。在同一个类或继承的类中定义的非静态方法,可以在没有实例的情况下调用。在这种情况下,实例是执行中的当前对象。这个对象也可以通过 this 关键字来访问。同样,当我们使用与我们的代码相同的类的字段时,我们只需使用名称。在静态字段的情况下,默认情况下我们所在的类。在非静态字段的情况下,实例是由 this 关键字引用的对象。

您还可以使用 import static 语言特性将静态方法导入到您的代码中,在这种情况下,您可以在不使用类名的情况下调用该方法。

方法调用参数之间使用逗号分隔。方法和方法参数传递是我们将在单独的小节中详细讨论的重要主题。

循环

while 循环内部的 for 循环将遍历从第一个(在 Java 中用零索引)到最后一个(在 Java 中用 n-1 索引)的所有元素。通常,for 循环的语法与 C 语言中的相同:

for( initial expression ; condition ; increment expression ) 
  block

首先,评估初始表达式。它可能包含变量声明,如我们的例子所示。在先前的例子中,变量 j 只在循环的块内部可见。之后,评估条件,并在每次执行块之后执行增量表达式。只要条件为真,循环就会重复。如果条件在执行初始表达式后立即为假,则循环根本不会执行。块是由分号分隔的命令列表,并用 {} 字符括起来。

{} 包围的代码块不同,Java 允许你在 for 循环的头部之后使用单个命令。在 while 循环的情况下也是如此,以及 if...else 构造。实践表明,这并不是专业人士应该使用的东西。专业的代码总是使用大括号,即使在只有单个命令的地方也是如此。这防止了悬挂的 else 问题,并且通常使代码更易于阅读。这与许多类似 C 的语言相似。它们中的大多数在这些地方允许使用单个命令,而专业的程序员为了避免可读性,在这些语言中避免使用单个命令。

这是一种讽刺,唯一严格要求在这些地方使用 {} 大括号的编程语言是 Perl——这种语言以其难以阅读的代码而闻名。

for (int j = 0; j < n - 1; j++) { 样例中,循环从零开始,到 n-2 结束。在这种情况下,写 j < n-1j <= n-2 是相同的。我们将限制 j 在到达数组末尾之前停止循环,因为我们通过比较和有条件地交换索引为 jj+1 的元素时,已经越过了索引 j。如果我们再走一步,我们就会尝试访问一个不存在的数组元素,这将导致运行时异常。尝试将循环条件修改为 j < nj <= n-1,你将得到以下错误信息:

图片

这是 Java 的重要特性,运行时会检查内存访问,并在出现不良数组索引的情况下抛出异常。在那些美好的旧日子里,当我们用 C 语言编码时,我们经常遇到无法解释的错误,这些错误在代码的完全不同的位置停止了我们的代码,而真正的错误却在那里。C 语言中的数组索引在无声中破坏了内存。Java 会在你犯错时立即阻止你。它遵循了 fail-fast 方法,你也在你的代码中应该使用这种方法。如果有什么问题,程序应该失败。没有任何代码应该试图忍受或克服来自编码错误的错误。编码错误应该在它们造成更多损害之前得到修复。

Java 中还有两个额外的循环结构:while 循环和 do 循环。示例中包含一个 while 循环:它是外层循环,只要数组中至少有两个可能需要交换的元素就会运行:

while (n > 1) {

如此可见,while 循环的通用语法和语义非常简单:

while ( condition ) block

只要条件为真,就重复执行该块。如果循环开始时条件不为真,则根本不执行该块。do 循环也类似,但它是在每次执行块之后检查条件的:

do block while( condition );

由于某种原因,程序员很少使用 do 循环。

条件执行

排序的核心是循环中的条件和值交换。

                if (names[j].compareTo(names[j + 1]) > 0) { 
                    final String tmp = names[j + 1]; 
                    names[j + 1] = names[j]; 
                    names[j] = tmp; 
                }

Java 中只有一个条件命令,即 if 命令。它具有以下格式:

if( condition ) block else block

代码结构的含义非常直接。如果条件为真,则执行第一个块,否则执行第二个块。else关键字以及第二个块是可选的。如果条件为假时没有要执行的代码,那么就不需要else分支,就像示例中那样。如果用j索引的数组元素在排序顺序上比j+1的元素靠后,那么我们就交换它们,但如果它们已经是有序的,就没有必要对它们做任何事情。

为了交换两个数组元素,我们将使用一个名为tmp的临时变量。这个变量的类型是String,并且这个变量被声明为finalfinal关键字在 Java 中的使用位置不同,其含义也不同。除非你被警告,否则这可能会让初学者感到困惑,就像现在这样。一个final类或方法与一个final字段完全不同,而final字段又与final局部变量不同。

final变量

在我们的例子中,tmp是一个final局部变量。这个变量的作用域仅限于if语句之后的块,并且在这个块内部,这个变量只获得一次值。这个块在代码执行期间会执行多次,每次变量进入作用域时,它都会获得一个值。然而,这个值在块内不能被改变。这可能会有些令人困惑。你可以把它想象成每次块执行时都有一个新的tmp。变量被声明并具有未定义的值,并且只能获得一次值。

最终局部变量不需要在声明的地方获取值。你可以在稍后某个时间点为final变量赋值。重要的是不应该有代码执行将值赋给已经赋过值的final变量。编译器会检查这一点,如果存在final变量被重新赋值的可能性,则不会编译代码。

声明一个变量为final通常是为了提高代码的可读性。当你看到代码中声明的final变量时,你可以假设变量的值不会改变,并且变量的意义在方法中任何使用的地方都将保持一致。这也有助于你在尝试修改某些final变量时避免一些错误,IDE 会立即对此提出警告。在这种情况下,这很可能是编程错误,而且会在非常早期被发现。

在原则上,可以编写一个所有变量都是final的程序。通常,将所有可以声明为final的变量声明为final是一个好的实践。如果某些变量可能不会被声明为final,那么尝试以不同的方式编写方法。

如果你需要引入一个新变量来完成这个任务,这可能意味着你正在使用一个变量来存储两件不同的事情。这些事情在逻辑上仍然是不同的,尽管它们在相同类型的变量中存储,并在不同时间使用。不要试图优化变量的使用。永远不要因为你的代码中已经有了同类型的可用变量就使用它。如果它逻辑上是不同的事情,那么就声明一个新的变量。

在编码时,始终优先考虑源代码的清晰度和可读性。在 Java 中,尤其是即时编译器会为你优化所有这些。

尽管我们通常不会在方法参数列表中显式使用final关键字,但确保如果参数被声明为final,你的方法仍然可以编译和运行是一个好的实践。一些专家,包括我自己,认为语言中方法参数应该默认是final的。但这在任何版本的 Java 中都不会发生,只要 Java 继续遵循向后兼容的哲学。

现在我们已经查看实际的代码行并理解了算法的工作原理,让我们看看代码的更全局的结构,这些结构将它们组合在一起:封装方法的类和包。

Java 程序中的每个文件都定义了一个类。Java 程序中的任何代码都在一个类内部。Java 中没有像 C、Python、Go 或其他语言中的全局变量或全局函数。Java 是完全面向对象的。

单个文件中可以有多个类,但通常一个文件对应一个类。稍后,我们将看到当类在另一个类内部时,会有内部类。但就目前而言,我们将把一个类放入一个文件中。

Java 语言中有些特性我们没有使用。当语言被创建时,这些特性看起来是个好主意。CPU、内存和其他资源,包括平庸的开发者,都比现在更有限。一些特性可能因为这些环境限制而更有意义。有时,我会提到这些。在类的例子中,只要只有一个类是public的,你就可以在一个文件中放入多个类。这是不好的实践,我们永远不会这样做。

Java 永远不会使这些特性过时。Java 的哲学是保持与所有先前版本的兼容性。这种哲学对已经编写的大量遗留代码是有益的。使用旧版本编写的 Java 代码在新环境中也能运行。同时,这些特性可能会诱使初学者走向错误的风气。因此,有时我甚至不会提到这些特性。例如,在这里,我可以说:“一个文件中有一个类。”这并不完全正确。同时,详细解释一个我不建议使用的特性多少有些无意义。以后,我可能会简单地跳过它们并“撒谎”。这些特性并不多。

类使用class关键字定义,每个类都必须有一个名称。该名称应在包内(见下一节)是唯一的,并且必须与文件名相同。一个类可以实现一个接口或扩展另一个类,我们将在稍后看到示例。一个类也可以是abstractfinalpublic。这些是通过适当的关键字定义的,正如你将在示例中看到的那样。

我们的程序有两个类。它们都是public的。public类可以在任何地方访问。不是public的类只能在包内部可见。内部和嵌套类也可以是private的,仅在文件级别的顶层类内部可见。

包含要由 Java 环境调用的main方法的类应该是public的。这是因为它们是由 JVM 调用的。

类从文件的开始处开始,紧随包声明之后,{}字符之间的所有内容都属于类。方法、字段、内部或嵌套类等都是类的一部分。通常,大括号在 Java 中表示某个块。这是在 C 语言中发明的,许多语言都遵循这种表示法。类声明是某个块,方法是通过某个块定义的,循环和条件命令使用块。

当我们使用类时,我们需要创建类的实例。这些实例是对象。换句话说,对象是通过实例化一个类来创建的。为了做到这一点,在 Java 中使用new关键字。当在App类中执行final Sort sorter = new Sort();这一行时,它通过实例化Sort类创建了一个新的对象。我们也可以说我们创建了一个新的Sort对象,或者该对象类型是Sort。当创建一个新的对象时,会调用对象的构造函数。我可能有点草率地说,构造函数是类中的一个特殊方法,它具有与类本身相同的名称,并且没有返回值。这是因为它返回创建的对象。为了更精确,构造函数不是方法。它们是初始化器,并且不返回新对象。它们在尚未准备好的对象上工作。当一个构造函数执行的对象尚未完全初始化时,一些最终字段可能尚未初始化,如果构造函数抛出异常,整体初始化仍然可能失败。在我们的例子中,代码中没有构造函数。在这种情况下,Java 会创建一个接受无参数且不修改已分配但未初始化的对象的默认构造函数。如果 Java 代码定义了一个初始化器,那么 Java 编译器不会创建一个默认的。

一个类可以有多个构造函数,每个构造函数都有不同的参数列表。

除了构造函数之外,Java 类还可以包含初始化块。它们是类级别的块,与构造函数和方法处于同一级别。这些块中的代码被编译到构造函数中,并在构造函数执行时执行。

还可以在静态初始化块中初始化静态字段。这些是带有static关键字的类顶级块。它们只在类加载时执行一次。

我们在我们的例子中将类命名为AppSort。这是 Java 中的一个约定,几乎所有的东西都使用驼峰式命名法。

驼峰式命名法是指单词之间没有空格。第一个单词可能以小写或大写字母开头,而为了表示第二个和后续单词的开始,它们以大写字母开头。ForExampleThisIsALongCamelCase名称。

类名以大写字母开头。这并不是语言形式上的要求,但这是每个程序员都应该遵循的约定。这些编码约定有助于你创建其他程序员更容易理解的代码,并有助于更容易的维护。静态代码分析工具,如 Checkstyle (checkstyle.sourceforge.net/),也会检查程序员是否遵循这些约定。

内部、嵌套、局部和匿名类

我已经在上一节中提到了内部和嵌套类。现在我们更详细地看看它们。

到目前为止,内部和嵌套类的细节可能很难理解。如果你没有完全理解这一节,不要感到羞愧。如果太难,可以跳到下一节,阅读有关包的内容,稍后再回来。尽管嵌套、内部和局部类在 Java 中很少使用,但它们有自己的角色和用途。匿名类在具有 Swing 用户界面的 GUI 编程中非常流行,它允许开发者创建 Java GUI 应用程序。随着 Java 8 和 lambda 功能的出现,匿名类现在不再那么重要了,随着 JavaScript 和浏览器技术的兴起,Java GUI 变得不那么受欢迎。

当一个类在单独的文件中定义时,它被称为顶级类。显然,位于另一个类内部的类不是顶级类。如果它们在字段(不是某些方法或其他代码块局部变量的变量)所在的同一级别类内部定义,它们就是内部或嵌套类。它们之间有两个区别。一个是嵌套类在其定义中有static关键字在class关键字之前,而内部类则没有。

另一个区别是,嵌套类的实例可以在没有外围类实例的情况下存在。内部类实例始终有一个对外围类实例的引用。

因为内部类实例不能在没有周围类实例的情况下存在,它们的实例只能通过提供外部类的实例来创建。如果周围类实例是实际的this变量,我们不会看到任何区别,但如果我们想从外部创建一个内部类的实例,那么我们必须在new关键字之前提供一个实例变量,通过点号分隔,就像new是一个方法一样。例如,我们可以有一个名为TopLevel的类,它有一个名为InnerClass的类,如下面的代码片段所示:

public class TopLevel { 

    class InnerClass { } 
}

然后,我们只需使用一个TopLevel对象就可以从外部创建InnerClass的实例,就像以下代码片段所示:

TopLevel tl = new TopLevel(); 
InnerClass ic = tl.new InnerClass();

内部类有一个对封装类实例的隐式引用,因此内部类中的代码可以访问封装类的字段和方法。

嵌套类没有对封装类实例的隐式引用,并且可以使用new关键字实例化,而不需要引用任何其他类的实例。正因为如此,除非是静态字段,否则它们不能访问封装类的字段。

局部类是在方法、构造函数或初始化代码块内部定义的类。我们很快就会讨论初始化代码块和构造函数。局部类可以在定义它们的代码块内部使用。

匿名类可以在单个命令中定义和实例化。它们是嵌套类、内部类或局部类的一种简写形式,并且包含类的实例化。匿名类始终实现一个接口或扩展一个命名类。新关键字后面跟着接口或类的名称,以及括号内的构造函数参数列表。定义匿名类主体的代码块紧接在构造函数调用之后。在扩展接口的情况下,构造函数只能是无参的。没有名称的匿名类不能有自己的构造函数。在现代 Java 中,我们通常使用 lambda 表达式而不是匿名类。

最后但同样重要的是——实际上,我应该先提到的是,嵌套类和内部类也可以在更深层次的结构中嵌套。内部类不能包含嵌套类,但嵌套类可以包含内部类。为什么?我从未遇到过任何能可靠地告诉我真正原因的人。没有架构上的原因。它可能就是这样。Java 不允许这样做。然而,这并不真正有趣。如果你碰巧编写了具有多级类嵌套的代码,那么请停止这样做。你很可能会做错事。

类被组织成包,文件中的第一行代码应该指定类所在的包。

package packt.java9.by.example.stringsort;

如果你没有指定包,那么类将位于 默认 包中。这不应该被使用,除非在最简单的情况下你想尝试一些代码。在 Java 9 中,你可以使用 jshell 来实现这个目的,因此,与 Java 的先前版本相比,现在的建议变得非常简单——永远不要将任何类放在默认包中。

包的名称是分层的。名称的部分由点分隔。使用包名称可以帮助你避免名称冲突。类的名称通常保持简短,将它们放入包中有助于程序的组织。类的完整名称包括该类所在的包的名称。通常,我们会将那些以某种方式相关的类放入一个包中,并为程序的类似方面添加一些内容。例如,在 MVC 模式程序中的控制器被保存在一个单独的包中。包也有助于避免类的名称冲突。然而,这仅仅是将问题从类名冲突推到了包名冲突。我们必须确保包的名称是唯一的,并且在使用我们的代码与其他任何库一起使用时不会引起任何问题。在开发应用程序时,我们根本无法知道在以后的版本中会使用哪些其他库。为了应对意外情况,惯例是按照某些互联网域名来命名包。当一个开发公司的域名是 acmecompany.com 时,他们的软件通常位于 com.acmecompany... 包下。这并不是严格的语言要求,而是一种惯例,即从右到左书写域名,并将其用作包名,但实践证明这相当有效。有时,就像我在这本书中所做的那样,可以偏离这一惯例,这样你就可以看到这条规则并非一成不变。

当橡胶接触地面,代码编译成字节码时,包名就变成了类的名称。因此,Sort 类的完整名称是 packt.java9.by.example.stringsort.Sort。当你使用来自另一个包的类时,你可以使用这个完整名称或者将类导入到你的类中。再次强调,这在语言层面上是如此。使用完全限定名称或导入在 Java 成为字节码时没有区别。

方法

我们已经讨论了方法,但不是非常详细,还有一些方面在我们继续之前我们应该了解。

样本类中有两种方法。一个类中可以有多个方法。按照惯例,方法名也是驼峰式命名,并且以小写字母开头,与类名不同。方法可以返回一个值。如果一个方法返回一个值,那么该方法必须声明返回值的类型,并且在这种情况下,任何代码执行都必须以一个return语句结束。return语句在关键字后有表达式,该方法执行时该表达式将被评估并由方法返回。只有一个单一返回值的方法是一种良好的编程习惯,但在某些简单情况下,违反这一编程习惯可能会被原谅。编译器检查可能的方法执行路径,如果某些路径没有返回值,则是一个编译时错误。

当一个方法不返回任何值时,它必须声明为void。这是一个特殊类型,表示没有值。void类型的方法,如public static void main方法,可以简单地省略返回语句并直接结束。如果有return语句,则在return关键字之后就没有地方放置定义返回值的表达式了。再次强调,这是一个编程习惯,在不需要返回任何值的方法中不使用return语句,但在某些编程模式中,可能不会遵循这一习惯。

方法可以是privateprotectedpublicstatic,我们将在后面讨论它们的含义。

我们已经看到,当程序启动时调用的main方法是一个static方法。这样的方法属于类,可以在没有类的实例的情况下调用。静态方法使用static修饰符声明,并且不能访问任何非静态的字段或方法。

在我们的例子中,sort方法不是静态的,但由于它不访问任何字段也不调用任何非静态方法(实际上,它根本不调用任何方法),它完全可以是静态的。如果我们将方法的声明更改为public static void sort(String[] names) {(注意单词static),程序仍然可以工作,但在编辑时,IDE 会给出警告,例如:

    Static member 'packt.java9.by.example.stringsort.Sort.sort(java.lang.String[])' accessed via instance reference

这是因为你可以直接通过Sort.sort(actualNames);类的名称来访问方法,而不需要sorter变量。在 Java 中,通过实例变量调用静态方法是可能的(这再次似乎是 Java 诞生时的一个好主意,但可能不是),但它可能会误导代码的读者,使他们认为该方法是一个实例方法。

sort方法定义为staticmain方法可以如下所示:

public static void main(String[] args) { 
    String[] actualNames = new String[]{ 
            "Johnson", "Wilson", 
            "Wilkinson", "Abraham", "Dagobert" 
    }; 
    Sort.sort(actualNames); 
    for (final String name : actualNames) { 
        System.out.println(name); 
    } 
}

这似乎要简单得多(确实如此),而且,如果方法没有使用任何字段,你可能认为没有必要将方法设为非静态。在 Java 的前十年里,静态方法被广泛使用。甚至有一个术语,工具类,指的是只包含静态方法且不应被实例化的类。随着控制反转容器的出现,我们倾向于使用更少的静态方法。当使用静态方法时,使用依赖注入会更困难,创建测试也会更加困难。我们将在下一章讨论这些高级主题。现在,你已经了解到静态方法是什么以及它们可以被使用;然而,通常,除非有非常特殊的需求,我们通常会避免使用它们。

之后,我们将探讨在层次结构中类是如何实现的,以及类如何实现接口和扩展其他类。当这些特性被考虑时,我们会看到所谓的抽象类,它们可能包含抽象方法。这些方法具有abstract修饰符,并且没有被定义——只有名称、参数类型(和名称)以及返回类型被指定。扩展抽象类(非抽象)的具体类应该定义这些方法。

抽象方法的相反面是使用final修饰符声明的最终方法。一个final方法不能在子类中被覆盖。

接口

方法也可以在接口中声明。接口中声明的方法并不定义方法的实际行为;它们不包含代码。它们只有方法头;换句话说,它们是隐式抽象的。尽管没有人这样做,你甚至可以在接口中定义方法时使用abstract关键字。

接口看起来与类非常相似,但我们使用interface关键字而不是class关键字。因为接口主要用于定义方法,所以如果没有使用修饰符,方法默认是public的。

接口也可以定义字段,但由于接口不能有实例(只有实现类可以有实例),这些字段都是static的,并且也必须是final的。这是接口中字段的默认行为,因此如果我们定义了接口中的字段,我们不需要写这些。

在某些接口中只定义常量,然后在类中使用这些常量的做法很常见。为此,最简单的方法是实现该接口。由于这些接口没有定义任何方法,实现只是将implements关键字和接口名称写入类声明头部的实现。这是不好的做法,因为这样接口就成为了类公共声明的一部分,尽管这些常量在类内部是需要的。如果你需要定义不是局部于类的常量,但被许多类使用,那么在类中定义它们,并使用import static导入字段,或者只需使用类名和字段名。

接口也可以有嵌套类,但不能有内部类。显然的原因是内部类实例有一个对封装类实例的引用。在接口的情况下,没有实例,所以嵌套类不能有对封装接口实例的引用,因为那根本不存在。令人高兴的是,在这种情况下,我们不需要在嵌套类中使用static关键字,因为这是默认的,就像字段的情况一样。

随着 Java 8 的推出,你还可以在接口中有default方法,为实现该接口的类提供默认的方法实现。从 Java 9 开始,接口中也可以有staticprivate方法。

方法通过其名称和参数列表来识别。你可以为方法重用名称并具有不同的参数类型;Java 将根据实际参数的类型来确定使用哪个方法。这被称为方法重载。通常,很容易判断调用的是哪个方法,但当存在相互扩展的类型时,情况变得更加复杂。标准定义了非常精确的规则,编译器会遵循这些规则来实际选择方法,因此没有歧义。然而,阅读代码的其他程序员可能会误解重载方法,或者至少在识别实际调用的是哪个方法时会遇到困难。方法重载也可能在你想扩展你的类时阻碍向后兼容性。一般的建议是在创建重载方法之前三思而后行。它们是有利可图的,但有时可能会付出代价。

参数传递

在 Java 中,参数是通过值传递的。当方法修改一个参数变量时,只有原始值的副本被修改。任何原始值在方法调用期间都会被复制。当一个对象作为参数传递时,传递的是对该对象的引用的副本。

这样,对象就可以被方法修改。对于有原始类型对应类的类,以及对于String和一些其他类类型,对象根本不提供修改状态的方法或字段。这对于语言的完整性很重要,并且当对象和原始值自动转换时,可以避免麻烦。

在其他情况下,当对象是可修改的,方法可以有效地对其传递给它的对象本身进行操作。这也是我们示例中的sort方法对数组进行操作的方式。相同的数组,它本身也是一个对象,被修改了。

这种参数传递方式比其他语言要简单得多。其他语言允许开发者混合使用按引用传递按值传递的参数传递方式。在 Java 中,当你仅用变量本身作为表达式来向方法传递参数时,你可以确信该变量本身永远不会被修改。然而,它所引用的对象,如果它是可变的,则可能会被修改。

一个对象如果是可变的,那么它可以被修改,直接或通过某些方法调用改变其某些字段的值。当一个类被设计成在对象创建后没有正常的方式来修改对象的状态时,该对象就是不可变的。ByteShortIntegerLongFloatDoubleBooleanCharacter以及String类在 JDK 中被设计成不可变的对象。

使用反射可以克服某些类不可变实现方式的限制,但这样做是黑客行为,而不是专业的编码。这样做可以用于单一目的——更好地了解某些 Java 类的内部工作原理,但别无其他。

字段

字段是类级别的变量。它们代表了一个对象的状态。它们是变量,具有定义的类型和可能的初始值。字段可以是staticfinaltransientvolatile,并且可以通过publicprotectedprivate关键字修改访问权限。

静态字段属于类。这意味着类中的所有实例共享一个。普通的非静态字段属于对象。如果你有一个名为f的字段,那么类的每个实例都有自己的f。如果f被声明为static,那么实例将共享同一个f字段。

final字段在初始化后不能被修改。初始化可以在声明它们的行上完成,在初始化块中或在构造函数代码中完成。严格的要求是初始化必须在构造函数返回之前发生。这样,在这种情况下,final关键字的意义与在类或方法的情况下有很大的不同。一个final类不能被扩展,一个final方法不能在扩展类中被覆盖,正如我们将在下一章中看到的。final字段要么在实例创建期间未初始化,要么在实例创建期间获得一个值。编译器还会检查代码是否在对象实例创建期间或类加载期间初始化了所有final字段,如果final字段是static的,并且代码没有访问/读取尚未初始化的任何final字段。

人们普遍认为final字段必须在声明时初始化。可以在初始化代码或构造函数中完成。限制是,无论调用哪个构造函数(如果有多个),final字段必须恰好初始化一次。

transient字段不是对象序列化状态的一部分。序列化是将对象的实际值转换为物理字节的操作。反序列化是当对象从字节创建时的相反操作。它用于在框架中保存状态。执行序列化的代码java.lang.io.ObjectOutputStream仅与实现Serializable接口的类一起工作,并且仅使用那些对象中不是transient的字段。很明显,transient字段也不会从表示对象序列化形式的字节中恢复,因为它们的值不存在。

序列化通常用于分布式程序中。一个很好的例子是 servlet 的会话对象。当 servlet 容器在集群节点上运行时,存储在会话对象中的某些对象字段可能在 HTTP 请求之间神奇地消失。这是因为序列化保存和重新加载会话以在节点之间移动会话。在这种情况下,如果开发者不知道会话中存储的大型对象的副作用,序列化可能也会成为性能问题。

volatile 关键字是一个告诉编译器该字段可能被不同线程使用的关键字。当一个 volatile 字段被任何代码访问时,JIT 编译器会生成代码以确保访问的字段值是最新的。当一个字段不是 volatile 时,编译器生成的代码可能会将字段的值存储在处理器缓存或寄存器中,以便在它看到后续代码片段很快需要该值时,可以更快地访问。在 volatile 字段的情况下,这种优化不能进行。此外,请注意,将值保存到内存中并始终从那里加载可能比从寄存器或缓存中访问值慢 50 倍或更多。

修饰符

方法、构造函数、字段、接口和类可以有访问修饰符。一般规则是,如果没有修饰符,方法、构造函数等的范围是包。同一包中的任何代码都可以访问它。

当使用 private 修饰符时,作用域被限制在所谓的编译单元内。这意味着在一个文件中的类。一个文件内部的内容可以看到并使用被声明为 private 的任何内容。这样,内部和嵌套类可以访问彼此的 private 变量,这可能并不是一个好的编程风格,但 Java 允许这样做。

private 的对立面是 public。它将可见性扩展到整个 Java 程序,或者至少在项目是一个 Java 9 模块的情况下,扩展到整个模块。

有一个折中的方法:protected。任何带有此修饰符的内容都可以在包内部访问,也可以在扩展了包含受保护方法、字段等的类的类(无论包如何)中访问。

对象初始化器和构造函数

当一个对象被实例化时,会调用适当的构造函数。构造函数声明看起来像是一个方法,但有以下差异:构造函数没有返回值。这是因为构造函数在 new 命令操作符被调用时工作在尚未完全准备好的实例上,并且不返回任何内容。具有与类相同名称的构造函数无法区分彼此。如果需要多个构造函数,它们必须被重载。因此,构造函数可以相互调用,几乎就像它们是具有不同参数的 void 方法一样。然而,有一个限制——当构造函数调用另一个时,它必须是构造函数中的第一条指令。您可以使用带有适当参数列表的 this() 语法(可能为空)来从另一个构造函数中调用构造函数。

对象实例的初始化也会执行初始化器块。这些块包含在 {} 字符之外的方法和构造函数内部的可执行代码。它们按照在代码中出现的顺序执行,与字段初始化一起,如果它们的声明包含值初始化的话。

如果你看到初始化块前有 static 关键字,则该块属于类,并在类加载时与静态字段初始化器一起执行。

编译和运行程序

最后,我们将从命令行编译并执行我们的程序。这里没有什么新东西;我们只会应用本章学到的知识,使用以下两个命令:

    $ mvn package

这将编译程序,将结果打包成 JAR 文件,并最终执行以下命令:

    $ java -cp target/SortTutorial-1.0.0-SNAPSHOT.jar packt.java9.by.example.App

这将在命令行上打印以下结果:

**

摘要

在本章中,我们开发了一个非常基础的排序算法。它是故意设计得如此简单,以便我们可以重复介绍基本的和最重要的 Java 语言元素,如类、包、变量、方法等。我们还探讨了构建工具,这样在下一章中,当项目包含的不仅仅是两个文件时,我们就不至于两手空空。在下一章中,我们将使用 Maven 和 Gradle。

在下一章中,我们将使排序程序更加复杂,实现更有效的算法,并使我们的代码更加灵活,给我们学习更多高级 Java 语言特性的机会。

第三章:优化排序 - 使代码专业

在本章中,我们将开发排序代码并使其更加通用。我们不仅想要排序字符串数组。本质上,我们将编写一个可以排序任何可排序内容的程序。这样,我们将把编码推向其极限,朝着 Java 的一个主要优势:抽象

然而,抽象并不是没有代价的。当你有一个排序字符串的类,你意外地将一个整数或其他不是字符串的东西混合到可排序的数据中,编译器将会抱怨:Java 不允许你将int放入String数组中。当代码更加抽象时,这样的编程错误可能会悄悄溜进来。我们将探讨如何通过捕获和抛出异常来处理这样的异常情况。

为了识别错误,我们将使用单元测试,应用行业标准 JUnit 4 版本。由于 JUnit 大量使用注解,并且因为注解很重要,你将对其有所了解。

之后,我们将修改代码以使用 Java 5 版本中引入的泛型特性。利用这个可能性,我们将在编译时捕获编码错误,这比在运行时更好。越早发现错误,修复成本就越低。

对于构建,我们仍然会使用 Maven,但这次,我们将代码拆分成小的模块。这样,我们将有一个多模块项目。我们将为排序模块的定义和不同的实现分别创建模块。这样,我们将研究类如何相互扩展并实现接口,并且,总的来说,我们将真正开始以面向对象的方式编程。

我们还将讨论测试驱动开发TDD),在章节的结尾,我们将开始使用 Java 9 的新特性:模块支持。

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

  • 面向对象编程原则

  • 单元测试实践

  • 算法复杂性和快速排序

  • 异常处理

  • 递归方法

  • 模块支持

通用排序程序

在上一章中,我们实现了一个简单的排序算法。这段代码可以排序String数组中的元素。我们这样做是为了学习。对于实际应用,JDK 中有一个现成的排序解决方案,可以排序collections中的可比较成员。

JDK 中包含一个名为Collections的实用工具类。这个类包含一个静态的Collections.sort方法,能够对任何包含Comparable成员的List进行排序。ListComparable是在 JDK 中定义的接口。因此,如果我们想对一个字符串列表进行排序,最简单的解决方案如下:

public class SimplestStringListSortTest { 
    @Test 
    public void canSortStrings() { 
        ArrayList actualNames = new ArrayList(Arrays.asList( 
                "Johnson", "Wilson", 
                "Wilkinson", "Abraham", "Dagobert" 
        )); 
        Collections.sort(actualNames); 
        Assert.assertEquals(new ArrayList<String>(Arrays.<String>asList( 
                "Abraham", "Dagobert", "Johnson", "Wilkinson", "Wilson")), actualNames); 
    } 
}

这段代码片段来自一个示例 JUnit 测试,这也是我们在方法前有@Test注解的原因。我们将在稍后详细讨论这一点。要执行这个测试,你可以输入以下命令:

$ mvn -Dtest=SimplestStringListSortTest test

然而,这个排序实现并不符合我们的需求。首先,因为它已经准备好了(无需编码)并且使用它不需要你在前几章中学到的新东西。除了方法前面的注解外,代码中没有你无法理解的新内容。你可以通过翻回一些页面来刷新,或者查阅 JDK 的在线文档(docs.oracle.com/javase/8/docs/api/),但这就足够了。你已经知道这些了。

你可能会想知道我为什么把 Java 版本 8 的 API 的 URL 写成了链接。嗯,那么这就是诚实和真实的时候——当我写这本书的时候,Java 9 JDK 还没有最终形式。我大多数示例都是在我的 Mac Book 上使用 Java 8 创建的,我只测试了 Java 9 特有的功能。目前 IDE 对 Java 9 的支持并不完美。当你读这本书的时候,Java 9 将会可用,所以你可以尝试将 URL 中的那个单个数字从 8 改为 9,以获取版本 9 的文档。目前,我得到的是 HTTP ERROR 404。

有时候,你可能需要查看旧版本的文档。你可以在 URL 中使用 3、4、5、6 或 7 代替 8。3 和 4 版本的文档无法在线阅读,但可以下载。希望你永远不会再需要它了。也许版本 5 会是这样。版本 6 在大公司中仍然被广泛使用。

虽然你可以从阅读其他程序员编写的代码中学到很多东西,但我不建议你在学习初期尝试从 JDK 源代码中学习。这些代码块经过了高度优化,不是教程代码,而且已经过时。它们在多年中并未生锈,但它们并没有被重构以遵循 Java 成熟后的编码风格。在某些地方,你可以在 JDK 中找到真正丑陋的代码。

好吧,说我们需要开发一个新的排序代码,因为我们可以从中学到东西,这有点牵强。我们真正需要排序实现的原因是我们想要一个能够对不仅List数据类型和实现了Comparable接口的List进行排序的东西。我们想要对一组对象进行排序。我们唯一的要求是包含对象的这组提供简单的方法,这些方法足以对它们进行排序并得到一个排序后的组。

最初我想用“collection”这个词代替“bunch”,但 Java 中有一个Collection接口,我想强调我们不是在谈论java.util.Collection对象。

我们也不希望对象实现Comparable接口。如果我们要求对象实现Comparable接口,可能会违反单一职责原则SRP)。

当我们设计一个类时,它应该模拟现实世界中的某个对象类。我们将使用类来模拟问题空间。该类应该实现代表其模型对象行为的特征。如果我们看看第二章中的学生例子,那么一个Student类应该代表所有学生共有的特征,并且从建模角度来看是重要的。一个Student对象应该能够说出学生的名字、年龄、去年平均分数等等。所有学生都有脚,而且当然每只脚都有大小,所以我们可能会认为一个Student类也应该实现一个返回学生脚大小的方法(一只左脚和一只右脚,为了精确起见),但我们没有这么做。我们之所以没有这么做,是因为脚的大小从模型角度来看是不相关的。如果我们想要对一个包含Student对象的列表进行排序,Student类必须实现Comparable接口。但是等等!你怎么比较两个学生?是通过名字、年龄,还是通过他们的平均分数?

将一个学生与另一个学生进行比较不是Student类的特征。每个类,或者更确切地说,每个包、库或编程单元都应该有一个职责,并且它应该只实现那个职责,不实现其他任何职责。这不是精确的。这不是数学。有时,很难判断一个特征是否适合于职责。有一些简单的技术。例如,在学生的例子中,你可以询问真实的人关于他的名字和年龄,他们可能也能告诉你他们的平均分数。如果你要求他们中的一个compareTo(另一个学生),正如Comparable接口所要求的这个方法,他们可能会反过来问,但是通过什么属性?或者怎么比较?或者只是,什么?在这种情况下,你可以怀疑实现这个特征可能不在该类和这个关注点的领域内;比较应该从原始类的实现中分离出来。这也被称为关注点分离,它与 SRP 密切相关。

JDK 开发者对此有所了解。Collections.sort方法用于对一个包含Comparable元素的List进行排序,但这并不是这个类中唯一的排序方法。还有一个方法,如果你传递一个实现了Comparator接口的对象作为第二个参数,并且这个对象能够比较列表中的两个元素,那么它就可以对任何List进行排序。这是一种干净的分离关注点的模式。在某些情况下,不需要分离比较。在其他情况下,这是可取的。Comparator接口声明了一个实现类必须提供的一个单一方法:compare。如果两个参数相等,则此方法返回0。如果它们不同,则应根据哪个参数在前返回一个负数或正数int

在 JDK 类 java.util.Arrays 中也有 sort 方法。它们可以排序数组,或者只排序数组的一部分。这个方法是方法重载的一个好例子。有同名的多个方法,但具有不同的参数,用于为每种原始类型排序整个数组,为每个切片排序,以及为实现了 Comparable 接口的对象数组排序,还有为使用 Comparator 排序的对象数组排序。正如您所看到的,JDK 中有整个系列的排序实现可用,在 99% 的情况下,您不需要自己实现排序。排序使用相同的算法,一种稳定的归并排序,并进行了优化。

我们将要实现的是一个通用方法,可以用来排序列表、数组或任何具有元素且可以交换任意两个元素的东西;解决方案将能够使用我们已开发的冒泡排序以及其他算法。

各种排序算法的简要概述

有许多不同的排序算法。正如我所说,有简单和复杂的算法,在许多情况下,更复杂的算法运行得更快。在本章中,我们将实现冒泡排序和快速排序。我们在上一章已经实现了字符串的冒泡排序,所以在这种情况下,实现将主要关注通用可排序对象排序的编码。实现快速排序将涉及一些算法兴趣。

警告:本节旨在为您提供算法复杂性的初步了解。它远非精确,我抱有一种徒劳的希望,即没有数学家会阅读这篇文章并对我施加诅咒。一些解释可能比较模糊。如果您想深入学习计算机科学,那么在阅读这本书之后,请寻找其他书籍或访问在线课程。

当我们谈论通用排序问题时,我们会考虑一些可以比较的通用对象集,在排序过程中,任何两个对象都可以交换。我们还将假设这是一个就地排序;因此,我们不会创建另一个列表或数组来收集已排序的对象。当我们谈论算法的速度时,我们谈论的是一些抽象的东西,而不是毫秒。当我们想要谈论毫秒时,实际的现实世界持续时间,我们已经在某种编程语言中运行了一些实现,在真实的计算机上。

在其抽象形式中,算法没有实现是不会那样做的。尽管如此,讨论算法的时间和内存需求仍然是有价值的。当我们这样做时,我们通常会研究算法在大量数据集上的行为。对于小数据集,大多数算法都运行得很快。排序两个数字通常不是问题,对吧?

在排序的情况下,我们通常会检查排序n个元素集合需要多少比较。冒泡排序大约需要 n²(n 乘以 n)次比较。我们不能说这正好是n²,因为当n=2时,结果是 1,对于n=3它是 3,对于n=4它是 6,以此类推。然而,当n开始变得更大时,实际需要的比较次数和n²将趋于相同的值。我们说冒泡排序的算法复杂度是O(n²)。这也被称为大 O 符号。如果你有一个O(n²)的算法,并且它对于 1,000 个元素在 1 秒内运行良好,那么你应该预期这个算法在 1 百万个元素上大约需要 10 天或一个月。如果一个算法是线性的,即O(n),那么在 1 秒内完成 1,000 个元素应该让你预期 1 百万个元素将在 1,000 秒内完成。这比咖啡休息时间长,但比午餐时间短。

这使得如果我们想要进行一些严肃的商业排序对象,我们需要比冒泡排序更好的方法。这么多的不必要的比较不仅浪费我们的时间,还浪费 CPU 功率,消耗能源,污染环境。然而,问题是:排序可以有多快?是否存在一个无法克服的证明最小值?

答案是肯定的。

当我们实现任何排序算法时,实现将执行比较和元素交换。这是排序对象集合的唯一方法。比较的结果可以有两个值。比如说,这些值是01。这是一位信息。如果比较的结果是1,那么我们进行交换,如果结果是0,那么我们不交换。

在开始比较之前,我们可以有不同顺序的对象,不同顺序的数量是n!n的阶乘)。也就是说,从 1 乘到n的数,换句话说,n! = 1 * 2 * 3 * ... * (n-1)**n*。

假设我们将单个比较的结果存储为一个数字,作为排序中每个可能输入的位序列。现在,如果我们反转排序的执行,从已排序的集合开始运行算法,使用描述比较结果的位来控制交换,并且我们以相反的方式使用这些位,即先进行最后一次交换,然后是排序过程中最先进行的交换,我们应该能够恢复对象的原始顺序。这样,每个原始顺序都与一个以位序列表示的数字唯一对应。

现在,我们可以这样表达原始问题:描述n阶乘的不同数字需要多少位?这正是我们需要对n个元素进行排序的比较次数。位数是log2。通过一些数学运算,我们可以知道log2等于log2+ log2+...+ log2。如果我们观察这个表达式的渐近值,那么我们可以说这等同于O(nlog n)*。我们不应该期望任何通用排序算法会更快。

对于特殊情况,存在更快的算法。例如,如果我们想要对每个介于 1 到 10 之间的 100 万个数字进行排序,我们只需要计算不同数字的数量,然后创建一个包含这么多 1、2 等数字的集合。这是一个O(n)算法,但这种方法不适用于一般情况。

再次强调,这并不是一个正式的数学证明。

快速排序

查尔斯·安东尼·理查德·霍华爵士于 1959 年开发了快速排序算法。这是一个典型的分而治之算法。为了对一个长数组进行排序,从数组中选取一个元素作为枢轴元素。然后,对数组进行分区,使得左侧将包含所有小于枢轴的元素,右侧将包含所有大于或等于枢轴的元素。完成此操作后,可以通过递归调用对数组的左侧和右侧进行排序。为了停止递归,当我们数组中只有一个元素时,我们将它声明为已排序。

当算法部分地使用自身定义时,我们谈论递归算法。最著名的递归定义是斐波那契数列,前两个元素是 0 和 1,任何后续元素的第n个元素是第(n-1)个元素和第(n-2)个元素的和。递归算法在现代编程语言中经常实现,通过一种执行某些计算但有时会调用自身的方。在设计递归算法时,拥有某种停止递归调用的机制至关重要;否则,递归实现将分配程序栈中所有可用的内存,并导致程序出错而停止。

分区算法的步骤如下:我们将从数组的开始和结束使用两个索引开始读取数组。我们首先从小的索引开始,增加索引直到它小于大的索引,或者直到我们找到一个大于或等于枢轴的元素。之后,我们将开始减少大的索引,只要它大于小的索引,并且索引处的元素大于或等于枢轴。当我们停止时,如果两个索引不相同,我们将交换两个索引指向的元素,然后分别开始增加和减少小和大索引。如果索引相同,那么分区就完成了。数组的左侧是从开始到两个索引相遇的索引减一;右侧从索引开始,一直持续到待排序数组的末尾。

这个算法通常是 O(n log n),但在某些情况下可能会退化到 O(n²),这取决于枢轴的选择。对于枢轴的选择有不同的方法。在这本书中,我们将使用最简单的方法:我们将选择可排序集合的第一个元素作为枢轴。

项目结构和构建工具

这次的项目将包含许多模块。我们将在本章继续使用 Maven。我们将在 Maven 中设置一个所谓的多模块项目。在这样的项目中,目录包含模块的目录和 pom.xml 文件。顶级目录中没有源代码。这个目录中的 pom.xml 文件有以下两个作用:

  • 它引用了模块,并且可以用来一起编译、安装和部署所有模块

  • 它为所有模块定义了相同的参数

每个 pom.xml 都有一个父级,这个 pom.xml 是模块目录中 pom.xml 文件的父级。为了定义模块,pom.xml 文件包含以下行:

<project> 
... 
    <modules> 
        <module>SortInterface</module> 
        <module>bubble</module> 
        <module>quick</module> 
    </modules> 
</project>

这些是模块的名称。这些名称被用作目录名称,也用作 pom.xml 模块中的 artifactId。在这个设置中的目录看起来如下:

$ tree 
   |-SortInterface 
   |---src/main/java/packt/java9/by/example/ch03 
   |-bubble 
   |---src 
   |-----main/java/packt/java9/by/example/ch03/bubble 
   |-----test/java/packt/java9/by/example/ch03/bubble 
   |-quick/src/ 
   |-----main/java 
   |-----test/java

Maven 依赖管理

依赖关系在 POM 文件中也很重要。之前的项目没有依赖关系,但这次我们将使用 JUnit。依赖关系使用 pom.xml 中的 dependencies 标签定义。例如,冒泡排序模块包含以下代码片段:

<dependencies> 
    <dependency> 
        <groupId>packt.java9.by.example</groupId> 
        <artifactId>SortInterface</artifactId> 
    </dependency> 
    <dependency> 
        <groupId>junit</groupId> 
        <artifactId>junit</artifactId> 
    </dependency> 
</dependencies>

你可以下载的代码集中的实际 pom.xml 将包含比这更多的代码。在印刷品中,我们经常展示一个版本或仅展示有助于理解我们当时讨论的主题的片段。

它告诉 Maven,模块代码使用了在这些模块中定义的类、接口和 enum 类型,这些类型可以从某个仓库中获取。

当您使用 Maven 编译代码时,您的代码所使用的库可以从仓库中获取。当 Ant 被开发时,仓库的概念尚未发明。当时,开发者将库的所用版本复制到源代码结构中的一个文件夹中。通常,用于此目的的目录是 lib。这种方法有两个问题。一个是源代码仓库的大小。例如,如果有 100 个不同的项目使用了 JUnit,那么 JUnit 库的 JAR 文件就被复制了 100 次。另一个问题是收集所有库。当一个库使用了另一个库时,开发者必须阅读描述(许多时候过时且不精确)需要使用此库的其他库的库的文档。这些库必须以相同的方式下载和安装。这既耗时又容易出错。当一个库缺失时,开发者没有注意到,错误会在编译时显现,当编译器找不到类,甚至在运行时 JVM 无法加载类时。

为了解决这个问题,Maven 内置了一个仓库管理器客户端。仓库是一个包含库的存储空间。由于仓库中可能包含其他类型的文件,而不仅仅是库,因此 Maven 术语中使用了工件。groupIdartifactIdversion 版本号用于标识一个工件。有一个非常严格的要求,即工件只能放入仓库一次。即使发布过程中出现了错误,在错误发布上传后才发现,该工件也不能被覆盖。对于相同的 groupIdartifactIdversion,只能有一个文件,该文件永远不会改变。如果出现错误,则需要创建一个新的工件,并使用新的版本号,错误的工件可以被删除但不能被替换。

如果版本号以 -SNAPSHOT 结尾,则这种唯一性没有保证或要求。快照通常存储在单独的仓库中,并且不会向全世界发布。

仓库包含在目录中组织得井井有条的工件。当 Maven 运行时,它可以使用 https 协议访问不同的仓库。

以前,也使用了 http 协议,对于非付费客户,中央仓库只能通过 http 访问。然而,发现从仓库下载的模块可能成为中间人安全攻击的目标,Sonatype (www.sonatype.com) 改变了政策,只使用 https 协议。永远不要配置或使用带有 http 协议的仓库。永远不要信任从 HTTP 下载的文件。

开发者机器上有一个本地仓库,通常位于~/.m2/repository目录下。当你执行mvn install命令时,Maven 会将创建的构件存储在这里。当通过 HTTPS 从仓库下载构件时,Maven 也会在这里存储构件。这样,后续的编译就不需要从网络上获取构件。

公司通常会设置自己的仓库管理器(由支持 Maven 的公司 Sonatype 提供的 Nexus)。这些应用程序可以配置为与多个其他仓库通信,并按需从那里收集构件,本质上实现了代理功能。构件从远端仓库传输到本地仓库,再到更近的仓库,形成一个层次结构,如果项目的打包类型是warear或其他包含依赖构件的格式,最终传输到最终构件。这本质上是一种文件缓存,没有重新验证和缓存淘汰。这可以做到,因为构件唯一性的严格规则。这就是如此严格规则的原因。

如果项目泡沫是一个独立的项目,而不是多模块项目的一部分,那么依赖关系看起来是这样的:

<dependencies> 
    <dependency> 
        <groupId>packt.java9.by.example</groupId> 
        <artifactId>SortInterface</artifactId> 
        <version>1.0.0-SNAPSHOT</version> 
    </dependency> 
    <dependency> 
        <groupId>junit</groupId> 
        <artifactId>junit</artifactId> 
        <version>4.12</version> 
    </dependency> 
</dependencies>

如果没有为依赖项定义version,Maven 将无法识别要使用哪个构件。在多模块项目中,version可以在父项目中定义,模块可以继承这个版本。由于父项目不依赖于实际的依赖项,它只定义与groupIdartifactId关联的版本;XML 标签不是dependencies,而是在顶级project标签下的dependencyManagement/dependencies,如下例所示:

<dependencyManagement> 
    <dependencies> 
        <dependency> 
            <groupId>packt.java9.by.example</groupId> 
            <artifactId>SortInterface</artifactId> 
            <version>${project.version}</version> 
        </dependency> 
        <dependency> 
            <groupId>junit</groupId> 
            <artifactId>junit</artifactId> 
            <version>4.12</version> 
            <scope>test</scope> 
        </dependency> 
    </dependencies> 
</dependencyManagement>

如果父 POM 直接使用 dependencies 标签,Maven 无法决定父项目是否依赖于该构件或某些模块。当模块想要使用junit时,它们不需要指定版本。它们将从定义为 4.12 的父项目中获取,这是 JUnit 4 的最新版本。如果将来会有一个新版本 4.12.1,修复了一些严重错误,那么唯一修改版本号的地方是父 POM,模块将在下一次执行 Maven 编译时使用新版本。

然而,当新的版本 JUnit 5 发布时,所有模块都必须进行修改,因为 JUnit 不仅仅是一个新版本。JUnit 5 被拆分为几个模块,因此groupIdartifactId也会发生变化。

值得注意的是,实现SortInterface模块接口的模块最终都会依赖于这个模块。在这种情况下,版本定义如下:

<version>${project.version}</version>

这似乎有点自相矛盾(实际上确实如此)。${project.version}属性是项目的版本,并且由SortInterface模块继承。这是其他模块所依赖的工件版本。换句话说,模块总是依赖于我们目前正在开发的版本。

编写排序代码

要实现排序,首先,我们将定义一个排序库应该实现的接口。在实际编码之前定义接口是一个好习惯。当有多个实现时,有时建议首先创建一个简单的实现并开始使用它,这样接口可以在该阶段演变,当更复杂的实现即将到来时,要实现的接口已经大致确定。

创建接口

在我们的情况下,接口非常简单。

package packt.java9.by.example.ch03; 

public interface Sort { 
    void sort(SortableCollection collection); 
}

接口应该只做一件事——对可排序的东西进行排序。由于我们希望这种方法非常通用,我们还需要定义什么是可排序的。为此,我们需要另一个接口。

package packt.java9.by.example.ch03; 

public interface SortableCollection { 
}

创建 BubbleSort

现在,我们可以开始创建实现Sort接口的冒泡排序:

package packt.java9.by.example.ch03.bubble; 

import packt.java9.by.example.ch03.*; 
import java.util.Comparator; 

public class BubbleSort implements Sort { 
    @Override 
    public void sort(SortableCollection collection) { 
        int n = collection.size(); 
        while (n > 1) { 
            for (int j = 0; j < n - 1; j++) { 
                if (comparator.compare(collection.get(j), 
                        collection.get(j + 1)) > 0) { 
                    swapper.swap(j, j + 1); 
                } 
            } 
            n--; 
        } 
    }

通常,执行算法需要两个我们在上次代码中实现的操作,针对String数组特定:比较两个元素和交换两个元素。由于这次排序实现本身并不知道元素的类型是什么,也不知道它排序的是数组、列表还是其他什么,它需要某种在需要时进行排序的东西。更精确地说,它需要一个能够比较两个元素的comparator对象,以及一个能够交换集合中两个元素的swapper对象。

为了获得这些,我们可以实现两个 setter 方法,在调用排序之前设置这些对象。由于这并不特定于冒泡排序算法,而是更通用的,这两个方法也应该成为接口的一部分,因此实现会覆盖它。

    private Comparator comparator = null; 

    @Override 
    public void setComparator(Comparator comparator) { 
        this.comparator = comparator; 
    } 

    private Swapper swapper = null; 

    @Override 
    public void setSwapper(Swapper swapper) { 
        this.swapper = swapper; 
    } 
}

@Override注解向 Java 编译器指示该方法正在覆盖父类或接口中的方法。一个方法可以不使用此注解覆盖父方法;然而,如果我们使用此注解,如果方法实际上没有覆盖任何东西,编译将失败。这有助于我们在编译时发现父类或接口中发生了变化,而我们没有在实现中遵循这种变化,或者我们只是犯了一些错误,认为我们将覆盖一个方法,而实际上我们没有。由于注解在单元测试中大量使用,我们将在稍后更详细地讨论注解。

修改接口

修改后的Sort接口将看起来像这样:

public interface Sort { 
    void sort(SortableCollection collection); 
    void setSwapper(Swapper swap); 
    void setComparator(Comparator compare); 
}

这也意味着我们需要两个新的接口:SwapperComparator。幸运的是,Java 运行时已经定义了一个 Comparator 接口,正好符合我们的需求。你可能已经从下面的导入语句中猜到了:

import java.util.Comparator;

当你需要一些非常基本的东西,比如一个 comparator 接口时,它很可能已经在运行时定义了。在编写自己的版本之前咨询运行时是明智的。然而,Swapper 接口我们却必须自己创建。

package packt.java9.by.example.ch03; 

public interface Swapper { 
    void swap(int i, int j); 
}

由于它用于交换 SortableCollection 中指定索引的两个元素,因此有一个名为 swap 的方法,这个名字相当简单。但是,我们还没有准备好。如果你尝试编译前面的代码,编译器将会对 getsize 方法提出抱怨。算法实现排序需要这些方法,但它们本身并不是排序的固有部分。这是一个不应该在排序中实现的责任。由于我们不知道我们将对什么类型的集合进行排序,因此在排序中实现这些功能不仅不妥,而且是不可能的。看起来我们根本无法对任何东西进行排序。我们必须设置一些限制。排序算法必须知道我们排序的集合的大小,并且应该能够通过索引访问元素,以便将其传递给比较器。

这些限制在 SortableCollection 接口中得到表达,我们之前在不知道第一次排序实现需要什么时,将其留空。

package packt.java9.by.example.ch03; 

public interface SortableCollection { 
    Object get(int i); 
    int size(); 
}

现在,我们已经准备好了接口和实现,可以继续测试代码。但在那之前,我们将简要回顾一下我们所做的工作以及为什么这样做。

架构考虑

我们创建了一个接口及其简单的实现。在实现过程中,我们发现接口需要其他接口和方法来支持算法。这通常发生在代码的架构设计阶段,在实现之前。出于教学目的,我遵循了接口的构建过程,在我们开发代码的同时。在现实生活中,当我创建接口时,我一次创建所有接口,因为我有足够的经验。我大约在 1983 年用 Fortran 编写了我的第一个快速排序代码。然而,这并不意味着我只要遇到任何问题就能一击即中,并得出最终解决方案。只是恰好排序是一个太为人所知的问题。如果你在开发过程中需要修改接口或其他设计方面,不要感到尴尬。这是自然的结果,也是你随着时间的推移对事物理解越来越好的证明。如果架构需要改变,最好是进行改变,而且越早越好。在现实生活中的企业环境中,我们设计接口只是为了在开发过程中学习到我们忘记了一些方面。它们是非常真实且比排序集合更复杂的操作。

在排序问题的情况下,我们将我们想要排序的“某物”抽象到了最极端的可能。Java 内置的排序方法可以排序数组或列表。如果你想要排序的不是列表或数组,你必须创建一个类,该类实现了需要超过 24 个方法的 java.util.List 接口,以便将你的可排序对象包装起来,使其可以通过 JDK 排序。说实话,这并不多,在一个实际的项目中,我会将其视为一个选项。

然而,我们并不知道,内置排序使用了接口的哪些方法。应该功能实现那些使用的方法,而没有使用的方法可以包含一个简单的 return 语句,因为它们根本不会被调用。开发者可以查阅 JDK 的源代码,看看实际使用了哪些方法,但这不是搜索实现的合同。不能保证新版本仍然只会使用那些方法。如果新版本开始使用我们用单个 return 语句实现的方法,排序可能会神奇地失败。

另一个有趣的问题是,如何仅使用 List 接口通过搜索来交换两个元素。List 接口中没有 put(int, Object) 方法。有 add(int, Object),但它会插入一个新元素,如果对象存储在磁盘上,例如,那么将列表的所有元素向上推可能非常昂贵(消耗 CPU、磁盘、能源)。此外,下一步可能是删除我们刚刚插入的元素之后的一个元素,再次进行昂贵的移动列表尾部的操作。也就是说,排序可能或可能不会遵循的 put(int, Object) 的简单实现。同样,这不应该被假设。

当开发者使用 JDK、开源或商业库中的库、类和方法时,开发者可以查阅源代码,但他们不应该依赖于实现。你应该只依赖于库附带 API 的合同和定义。当你从某个外部库实现一个接口,并且你不需要实现它的某些部分,并创建一些虚拟方法时,感受到空气中的危险。这是一个伏击。很可能是库质量差,或者你没有理解如何使用它。

在我们的案例中,我们将交换和比较从排序中分离出来。集合应该实现这些操作,并为排序提供它们。合同是接口,要使用排序,你必须实现我们定义的所有接口方法。

Sort接口定义了设置SwapperComparator的设置器。以这种方式设置依赖可能会导致创建实现Sort接口的新实例的代码,但在调用Sort之前没有设置SwapperComparator。这将导致在第一次调用Comparator(或当实现首先调用Swapper时,这不太可能,但有可能)时抛出NullPointerException。调用方法应该在使用类之前注入依赖。当它通过设置器完成时,这被称为设置器注入。这个术语在我们使用 Spring、Guice 或其他容器等框架时被大量使用。创建这些服务类并将实例注入到我们的类中通常是相似的。

容器实现以通用方式包含功能并提供配置选项来配置要注入到哪些其他对象中的实例。通常,这会导致代码更短、更灵活、更易读。然而,依赖注入并不局限于容器。当我们编写下一节的测试代码并调用设置器时,我们实际上是在进行依赖注入。

另一种依赖注入的方式可以避免依赖未设置的问题。这被称为构造函数注入。依赖是final private字段,没有值。记住,这些字段应该在构造函数完成时获得它们的最终值。构造函数注入将注入的值作为参数传递给构造函数,构造函数设置字段。这样,字段在对象构造时就被保证了。然而,这种注入不能定义在接口中。

现在,我们已经有代码了,我们也知道接口是如何创建的考虑因素。这是进行一些测试的时候了。

创建单元测试

当我们编写代码时,我们应该对其进行测试。没有任何代码在投入生产之前至少进行过一些测试运行。存在不同级别的测试,它们有不同的目标、技术、行业实践和名称。

单元测试,正如其名所示,测试代码的一个单元。集成测试测试单元如何集成在一起。冒烟测试测试有限的功能,只是为了看看代码是否完全损坏。还有其他测试,直到最后的测试,即工作的证明:用户验收测试。甜点的证明是吃它。如果用户接受它,代码就是好的。

许多时候,我会告诉新手,用户验收测试这个名称有点误导性,因为接受项目结果的不是用户,而是客户。根据定义,客户是支付账单的人。专业发展需要付费;否则,就不算专业。然而,术语却是用户验收测试。只是碰巧客户只有在用户能够使用程序的情况下才会接受项目。

当我们在 Java 中开发时,单元测试是测试独立类。换句话说,在 Java 开发中,当我们谈论单元测试时,单元是一个类。为了提供单元测试,我们通常使用 JUnit 库。还有其他库,如 TestNG,但 JUnit 是最广泛使用的,所以我们将使用JUnit。要将其作为库使用,首先,我们必须将其添加到 Maven POM 中作为依赖项。

添加 JUnit 作为依赖项

回想一下,我们有一个多模块项目,依赖项版本在父 POM 的dependencyManagement标签下维护。

<dependencyManagement> 
    <dependencies> 
        ... 
        <dependency> 
            <groupId>junit</groupId> 
            <artifactId>junit</artifactId> 
            <version>4.12</version> 
            <scope>test</scope> 
        </dependency> 
    </dependencies> 
</dependencyManagement>

依赖项的作用域是测试,这意味着这个库只需要编译测试代码并在测试执行期间使用。JUnit 库不会进入最终发布的产品;不需要它。如果你在某个部署的生产Web ArchiveWAR)文件中找到 JUnit 库,怀疑有人没有正确管理库的作用域。

Maven 支持在项目的生命周期中编译和执行 JUnit 测试。如果我们想执行测试,只有我们可以发出mvn test命令。IDEs 也支持单元测试的执行。通常,可以用来执行具有public static main方法的类的相同菜单项可以用来执行。如果类是一个使用 JUnit 的单元测试,IDE 将识别它并执行测试,通常会在图形界面上给出关于哪些测试执行良好以及哪些失败的反馈,以及失败的原因。

编写 BubbleSortTest 类

测试类与生产类分开。它们放入src/test/java目录。当我们有一个名为,例如,BubbleSort的类时,测试将命名为BubbleSortTest。这个约定有助于执行环境将测试与那些不包含测试但需要执行测试的类分开。为了测试我们刚刚创建的排序实现,我们可以提供一个包含,目前只有一个canSortStrings方法的类。

单元测试方法名称用于记录正在测试的功能。由于 JUnit 框架调用每个带有@Test注解的方法,因此测试方法的名称在我们的代码中没有任何地方被引用。我们可以大胆地使用任意长的方法名;它不会妨碍在方法被调用处的可读性。

package packt.java9.by.example.ch03.bubble; 

// imports deleted from print 

public class BubbleSortTest { 

    @Test 
    public void canSortStrings() { 
        ArrayList actualNames = new ArrayList(Arrays.asList( 
                "Johnson", "Wilson", 
                "Wilkinson", "Abraham", "Dagobert" 
        ));

方法包含ArrayList,其中包含我们已经熟悉的实际名称。由于我们有一个需要SortableCollection的排序实现和接口,我们将创建一个由ArrayList支持的实现。

        SortableCollection namesCollection = new SortableCollection() { 

            @Override 
            public Object get(int i) { 
                return actualNames.get(i); 
            } 

            @Override 
            public int size() { 
                return actualNames.size(); 
            } 
        };

我们声明了一个具有SortableCollection类型的新对象,这是一个接口。为了实例化实现SortableCollection的对象,我们需要一个类。我们不能实例化一个接口。在这种情况下,在实例化的地方定义这个类。这在 Java 中被称为匿名类。这个名字来源于新类的名称在源代码中没有定义。Java 编译器将自动为新类创建一个名称,但这对于程序员来说并不重要。我们将简单地写new SortableCollection(),并在大括号{}之间立即提供所需实现。在方法内部定义这个匿名类非常方便,这样它就可以访问ArrayList,而无需在类中传递ArrayList的引用。

实际上,需要引用,但 Java 编译器会自动完成这个操作。在这种情况下,Java 编译器还会确保通过这种方式自动传递的引用只能用于在匿名类实例化后初始化且不会改变的变量。变量actualNames已被设置,并且在后续的方法中不应更改。实际上,我们甚至可以将actualNames定义为 final,如果我们在 Java 1.7 或更早版本中使用,这将是一个要求。从 1.8 版本开始,要求变量实际上是 final 的,但您不需要声明它为 final。

下一步我们需要的是ArrayListSwapper实现。在这种情况下,我们将在方法内部定义整个类。它也可以是一个匿名类,但这次我决定使用一个命名类来展示一个类可以在方法内部定义。通常,在生产项目中我们不会这样做。

        class SwapActualNamesArrayElements implements Swapper { 
            @Override 
            public void swap(int i, int j) { 
                final Object tmp = actualNames.get(i); 
                actualNames.set(i,actualNames.get(j)); 
                actualNames.set(j, tmp); 

            } 
        }

最后,但同样重要的是,在调用排序之前,我们需要一个比较器。由于我们要比较的是String,这很简单且直接。

        Comparator stringCompare = new Comparator() { 
            @Override 
            public int compare(Object first, Object second) { 
                final String f = (String) first; 
                final String s = (String) second; 
                return f.compareTo(s); 
            } 
        };

在排序准备工作完成后,我们最终需要 Sort 实现的实例,设置comparatorswapper,然后调用排序。

        Sort sort = new BubbleSort(); 
        sort.setComparator(stringCompare); 
        sort.setSwapper(new SwapActualNamesArrayElements()); 
        sort.sort(namesCollection);

测试的最后一个,但最重要的部分是断言结果是我们预期的。JUnit 通过Assert类帮助我们做到这一点。

        Assert.assertEquals(Arrays.asList("Abraham", "Dagobert", "Johnson", "Wilkinson", "Wilson"), actualNames); 
    } 

}

assertEquals的调用检查第一个参数,即预期结果,是否等于第二个参数,即排序后的actualNames。如果它们不同,则抛出AssertionError;否则,测试将正常结束。

好的单元测试

这是一个好的单元测试吗?如果您在像这样的教程书中阅读它,它必须是。实际上,它不是。这是一段很好的代码,用于展示 JUnit 提供的一些工具和一些 Java 语言特性,但作为一个真正的 JUnit 测试,我将在实际项目中不会使用它。

什么是好的单元测试?为了回答这个问题,我们必须找到单元测试的好处以及我们用它来做什么。

我们将创建单元测试来验证单元的操作并记录文档。

单元测试不是为了找 bug。开发者最终会在调试会话中使用单元测试,但很多时候,为调试创建的测试代码是临时的。当 bug 被修复时,用于找到它的代码不会进入源代码。对于每个新的 bug,都应该创建一个新的测试来覆盖那些没有正确工作的功能,但这几乎不是用来找到 bug 的测试代码。这是因为单元测试主要是为了文档。你可以使用JavaDoc来记录一个类,但经验表明,文档往往会过时。开发者修改了代码,但他们没有修改文档,文档变得过时且具有误导性。然而,单元测试是由构建系统执行的,如果使用持续集成CI)(在专业环境中应该使用),那么如果测试失败,构建将会中断,所有开发者都会收到关于它的邮件通知,这将迫使破坏构建的开发者修复代码或测试。这样,测试可以验证持续开发没有在代码中破坏任何东西,或者至少不是可以用单元测试发现的东西。

一个好的单元测试是可读的

我们的测试远远达不到可读性。一个测试用例是可读的,如果你看它,在 15 秒内就能知道它做什么。当然,这假设读者有一些 Java 经验,但你的观点是明确的。我们的测试用例中充斥着非核心的辅助类。

我们的测试几乎不能验证代码是否正常工作。实际上,它并没有。其中有一些我故意放入的 bug,我们将在下面的章节中找到并消除。一个单独的测试用例对单个String数组进行排序,远远不能验证排序实现。如果我要扩展这个测试到实际的测试,我们需要有名为canSortEmptyCollectioncanSortOneElementCollectioncanSortTwoElementscanSortReverseOrdercanSortAlreadySorted的方法。如果你看这些名字,你会看到我们需要哪些测试。从排序问题的本质来看,实现可能对特殊情况的错误非常敏感。

我们单元测试的优点是什么,除了它是一个可接受的演示工具之外?

单元测试是快速的

我们的单元测试运行得很快。每次执行单元测试时,CI 都会启动构建,测试的执行不应持续很长时间。你不应该创建一个对数十亿个元素进行排序的单元测试。那是一种稳定性或负载测试,它们应该在单独的测试期间运行,而不是每次构建运行时。我们的单元测试对五个合理的元素进行排序。

单元测试是确定的

我们的单元测试是确定的。非确定性的单元测试是开发者的噩梦。如果你在一个团队中,有些构建在 CI 服务器上失败,当一个构建失败时,你的同事说你需要再试一次;不可能!如果一个单元测试运行了,它应该每次都运行。如果它失败了,它应该无论你启动多少次都失败。在我们的案例中,一个非确定性的单元测试将是生成随机数并将它们排序。我们将在每次测试运行中结束于不同的数组,如果代码中存在某些数组会显示的 bug,我们将无法重现它。更不用说断言代码运行良好也是困难的。

如果我们在单元测试中对一个随机数组进行排序(我们实际上并没有这样做),理论上我们可以断言数组已排序,通过逐个比较元素来检查它们是否按升序排列。这也会是一个完全错误的做法。

断言应该尽可能简单

如果断言复杂,引入断言中的 bug 的风险更高。断言越复杂,风险越高。我们将编写单元测试来简化我们的生活,而不是增加更多的代码来调试。

此外,一个测试应该只断言一件事。这个断言可能通过多个Assert类方法编码,一个接一个。然而,这些方法的目的是断言单元的一个单一特性的正确性。记住 SRP:一个测试,一个特性。一个好的测试就像一个好的狙击手:一枪,一命。

单元测试是隔离的

当我们测试单元A时,另一个单元B的任何变化或不同单元中的错误都不应该影响我们针对单元A的单元测试。在我们的案例中,这很容易,因为我们只有一个单元。稍后,当我们为快速排序开发测试时,我们会看到这种分离并不那么简单。

如果单元测试被正确地分离,一个失败的单元测试可以清楚地指出问题的位置。问题就在单元测试失败的单元中。如果测试没有分离单元,那么一个测试的失败可能是由我们预期的不同单元中的错误引起的。在这种情况下,这些测试实际上并不是单元测试。

在实践中,你应该找到一个平衡点。如果单元的隔离成本过高,你可以决定创建集成测试;如果它们仍然运行得很快,可以让它们由CI 系统执行。同时,你也应该试图找出隔离困难的原因。如果你不能轻易地在测试中隔离单元,这意味着单元之间的耦合太强,这可能不是一个好的设计。

单元测试覆盖代码

单元测试应该测试所有常规情况以及所有功能性的特殊案例。如果存在某个代码的特殊案例没有被单元测试覆盖,那么代码就处于危险之中。在排序实现的例子中,一般情况是排序,比如五个元素。特殊案例通常更多。我们的代码在只有一个元素或没有元素的情况下会如何表现?如果有两个元素呢?如果元素是逆序的呢?如果它们已经排序了呢?

通常,特殊案例在规范中没有被定义。程序员在编码之前必须考虑它,并且在编码过程中可能会发现一些特殊案例。困难之处在于你根本无法确定是否覆盖了所有特殊案例和代码的功能。

你可以判断的是,在测试过程中是否执行了所有代码行。如果 90%的代码行在测试中执行了,那么代码覆盖率就是 90%,这在现实生活中已经相当不错了,但你永远不应该满足于低于 100%的覆盖率。

代码覆盖率不等于功能覆盖率,但它们之间存在关联。如果代码覆盖率低于 100%,那么以下两个陈述中的至少一个是真的:

  • 功能覆盖率不是 100%

  • 在被测试的单元中存在未使用的代码,这些代码可以直接删除

代码覆盖率是可以测量的,而功能覆盖率则不行。工具和 IDE 支持代码覆盖率测量。这些测量被集成到编辑器中,所以你不仅会得到覆盖率百分比,编辑器还会显示哪些行没有被覆盖率覆盖,通过给这些行着色(例如在 Eclipse 中)或编辑器窗口左侧的空白区域(IntelliJ)。图片显示在 IntelliJ 中,测试覆盖了左侧空白区域上用绿色标出的行。(在打印版本中这只是一个灰色矩形)。

图片

优化测试

现在我们已经讨论了什么是好的单元测试,让我们改进我们的测试。首先,我们需要将辅助类移动到单独的文件中。我们将创建ArrayListSortableCollection

package packt.java9.by.example.ch03.bubble; 

import packt.java9.by.example.ch03.SortableCollection; 

import java.util.ArrayList; 

public class ArrayListSortableCollection implements SortableCollection { 
    final private ArrayList actualNames; 

    ArrayListSortableCollection(ArrayList actualNames) { 
        this.actualNames = actualNames; 
    } 

    @Override 
    public Object get(int i) { 
        return actualNames.get(i); 
    } 

    @Override 
    public int size() { 
        return actualNames.size(); 
    } 
}

这个类封装了ArrayList并实现了getsize方法以访问ArrayListArrayList本身被声明为final。回想一下,一个final字段必须在构造函数完成时定义。这保证了当我们开始使用对象时字段已经存在,并且在对象的生命周期内不会改变。然而,请注意,对象的内容,在这个例子中是ArrayList的元素,可能会改变。如果不是这样,我们就无法对其进行排序。

下一个类是StringComparator。这个类非常简单,所以我不会在这里列出它;我会留给你来实现一个可以比较两个字符串的java.util.Comparator接口。这不应该很难,特别是因为这个类已经是BubbleSortTest类前一个版本的组成部分(提示:它是一个存储在名为stringCompare的变量中的匿名类)。

我们还必须实现ArrayListSwapper,这也不应该是一个大的惊喜。

package packt.java9.by.example.ch03.bubble; 

import packt.java9.by.example.ch03.Swapper; 

import java.util.ArrayList; 

public class ArrayListSwapper implements Swapper { 
    final private ArrayList actualNames; 

    ArrayListSwapper(ArrayList actualNames) { 
        this.actualNames = actualNames; 
    } 

    @Override 
    public void swap(int i, int j) { 
        Object tmp = actualNames.get(i); 
        actualNames.set(i, actualNames.get(j)); 
        actualNames.set(j, tmp); 
    } 
}

最后,我们的测试将看起来是这样的:

package packt.java9.by.example.ch03.bubble; 

// ... imports deleted from print ... 
public class BubbleSortTest { 
    @Test 
    public void canSortStrings() { 
        ArrayList actualNames = new ArrayList(Arrays.asList( 
                "Johnson", "Wilson", 
                "Wilkinson", "Abraham", "Dagobert" 
        )); 
        ArrayList expectedResult = new ArrayList(Arrays.asList( 
                "Abraham", "Dagobert", 
                "Johnson", "Wilkinson", "Wilson" 
        )); 
        SortableCollection names = 
                new ArrayListSortableCollection(actualNames); 
        Sort sort = new BubbleSort(); 
        sort.setComparator( 
                new StringComparator()); 
        sort.setSwapper( 
                new ArrayListSwapper(actualNames)); 
        sort.sort(names); 
        Assert.assertEquals(expectedResult, actualNames); 
    } 
}

现在这是一个可以在 15 秒内理解测试。它很好地记录了我们如何使用我们定义的排序实现。它仍然有效,并且没有揭示任何错误,正如我承诺的那样。

包含错误元素的集合

这个错误不是微不足道的,而且像往常一样,这并不是算法的实现问题,而是在定义上,或者说是定义不足。如果我们在排序的集合中不仅有字符串,程序应该做什么呢?

如果我创建一个新的测试,以以下行开始,它将抛出ClassCastException

@Test 
public void canNotSortMixedElements() { 
    ArrayList actualNames = new ArrayList(Arrays.asList( 
            42, "Wilson", 
            "Wilkinson", "Abraham", "Dagobert" 
    )); 
... the rest of the code is the same as the previous test

这里的问题在于 Java 集合可以包含任何类型的元素。你永远不能确定一个集合,比如ArrayList,只包含你期望的类型。即使你使用泛型(我们还没有学习,但我们将在本章中学习),某种方式将不适当类型的对象意外地放入集合中的机会更小,但仍然存在。不要问我如何;我无法告诉你。这就是错误的本质——你无法知道它们是如何工作的,直到你解决了它们。关键是你要为这样的异常情况做好准备。

处理异常

在 Java 中,应该使用异常来处理异常情况。ClassCastException是存在的,当排序尝试使用StringComparator比较StringInteger时发生,为了做到这一点,它试图将一个Integer转换为String

当程序使用throw命令或 Java 运行时抛出异常时,程序执行将在该点停止,而不是执行下一个命令,而是继续在异常被捕获的地方。它可能是在同一个方法中,或者在上面的调用链中的某个调用方法中。为了捕获异常,抛出异常的代码应该在一个try块内部,并且跟随try块的catch语句应该指定一个与抛出的异常兼容的异常。

如果异常没有被捕获,那么 Java 运行时会打印出异常的消息,以及一个包含所有在异常发生时调用栈上的类、方法和行号的堆栈跟踪。在我们的情况下,mvn test命令将在输出中产生以下跟踪:

java.lang.ClassCastException: java.lang.Integer cannot be cast to java.lang.String 
    at packt.java9.by.example.ch03.bubble.StringComparator.compare(StringComparator.java:9) 
    at packt.java9.by.example.ch03.bubble.BubbleSort.sort(BubbleSort.java:13) 
    at packt.java9.by.example.ch03.bubble.BubbleSortTest.canNotSortMixedElements(BubbleSortTest.java:49) 
    at sun.reflect.NativeMethodAccessorImpl.invoke0(Native Method) 
    at sun.reflect.NativeMethodAccessorImpl.invoke(NativeMethodAccessorImpl.java:62) 
    at sun.reflect.DelegatingMethodAccessorImpl.invoke(DelegatingMethodAccessorImpl.java:43) 
    at java.lang.reflect.Method.invoke(Method.java:498) 
... some lines deleted from the print 
    at org.apache.maven.surefire.junit4.JUnit4Provider.executeTestSet(JUnit4Provider.java:141) 
    at org.apache.maven.surefire.junit4.JUnit4Provider.invoke(JUnit4Provider.java:112) 
    at sun.reflect.NativeMethodAccessorImpl.invoke0(Native Method) 
    at sun.reflect.NativeMethodAccessorImpl.invoke(NativeMethodAccessorImpl.java:62) 
... some lines deleted from the print 
    at org.apache.maven.surefire.booter.ProviderFactory.invokeProvider(ProviderFactory.java:85) 
    at org.apache.maven.surefire.booter.ForkedBooter.runSuitesInProcess(ForkedBooter.java:115) 
    at org.apache.maven.surefire.booter.ForkedBooter.main(ForkedBooter.java:75)

这个堆栈跟踪并不长。在生产环境中,运行在应用服务器上的应用程序的堆栈跟踪可能包含几百个元素。在这个跟踪中,你可以看到 Maven 正在启动测试执行,涉及到 Maven surefire 插件,然后是 JUnit 执行器,直到我们通过测试到达比较器,在那里实际异常被抛出。

这个异常没有被 Java 运行时打印到控制台。这个异常被 JUnit 库代码捕获,并且使用 Maven 日志记录功能将堆栈跟踪记录到控制台。

这种方法的缺点是真正的问题不是类转换失败。真正的问题是集合中包含混合元素。只有在 Java 运行时尝试将两个不兼容的类进行转换时才会意识到这一点。我们的代码可以更智能。我们可以修改比较器。

package packt.java9.by.example.ch03.bubble; 
import java.util.Comparator; 
public class StringComparator implements Comparator { 
    @Override 
    public int compare(Object first, Object second) { 
        try { 
            final String f = (String) first; 
            final String s = (String) second; 
            return f.compareTo(s); 
        } catch (ClassCastException cce) { 
            throw new NonStringElementInCollectionException ( 
                    "There are mixed elements in the collection.", cce); 
        } 
    } 
}

这段代码捕获了 ClassCastException 并抛出了一个新的异常。抛出新异常的优势在于你可以确信这个异常是从比较器抛出的,并且真正的问题确实是集合中存在混合元素。类转换问题也可能发生在代码的其他地方,例如某些排序实现中。一些应用程序代码可能想要捕获异常并处理这种情况;例如,发送一个特定于应用程序的错误消息,而不仅仅是向用户显示堆栈跟踪。这段代码也可以捕获 ClassCastException,但它不能确定异常的真正原因。另一方面,NonStringElementInCollectionException 是确定的。

NonStringElementInCollectionException 是一个在 JDK 中不存在的异常。我们必须创建它。异常是 Java 类,我们的异常看起来如下:

package packt.java9.by.example.ch03.bubble; 

public class NonStringElementInCollectionException extends RuntimeException { 
    public NonStringElementInCollectionException (String message, Throwable cause) { 
        super(message, cause); 
    } 
}

Java 有检查异常的概念。这意味着任何不扩展 RuntimeException 的异常都应该在方法定义中声明。假设我们的异常被声明如下:

public class NonStringElementInCollectionException extends Exception

然后,我们必须将 compare 方法声明如下:

public int compare(Object first, Object second) throws NonStringElementInCollectionException

问题在于方法抛出的异常是方法签名的一部分,因此这种方式 compare 不会覆盖接口的 compare 方法,并且,这样,类就不会实现 Comparator 接口。因此,我们的异常必须是一个运行时异常。

应用程序中可能存在异常的层次结构,而且通常,新手程序员会创建大量的层次结构。如果你能做某事,并不意味着你应该做。层次结构应该尽可能保持扁平,这对于异常来说尤其如此。如果 JDK 中有描述异常情况的异常,那么就使用现成的异常。就像对任何其他类一样:如果它是现成的,就不要再次实现它。

还要注意的是,抛出异常只应在异常情况下进行。这不是为了表示某种正常操作条件。这样做会阻碍代码的可读性,并消耗 CPU 资源。对于 JVM 来说,抛出异常并不是一件容易的事情。

不仅异常可以被抛出。throw命令可以抛出,catch命令可以捕获任何扩展Throwable类的对象。Throwable有两个子类:ErrorException。如果在 Java 代码执行过程中发生某些错误,将抛出Error异常。最臭名昭著的错误是OutOfMemoryErrorStackOverflowError。如果发生这些错误中的任何一个,你将无法可靠地捕获错误。

JVM 中也有InternalErrorUnknownError,但由于 JVM 相当稳定,你几乎不会遇到这些错误。

当这些错误中的任何一个发生时,尝试调试代码并找出为什么你使用了这么多内存或如此深的调用方法,并尝试优化你的解决方案。我刚才提到的创建异常层次结构对于捕获错误也是适用的。你可以捕获错误这一事实并不意味着你应该这样做。相反,你永远不应该捕获一个错误,尤其是永远不应该捕获一个Throwable

这样,我们处理了这种特殊情况,即当某个程序员不小心在名称中写下了 42 时,但在编译时识别错误会更好吗?为了做到这一点,我们将引入泛型。

在我们继续之前,先思考一下。我们使用canNotSortMixedElements单元测试测试哪个类的行为?这个测试在BubbleSortTest测试类内部,但功能在比较器实现StringComparator中。这个测试检查的是单元测试类范围之外的东西。我可以用它来演示,但这不是一个单元测试。排序实现的真正功能可以这样表述:比较器抛出的任何异常都会由排序实现抛出。你可以尝试编写这个单元测试,或者继续阅读;我们将在下一节中介绍它。

StringComparator类没有测试类,因为StringComparator是测试的一部分,我们永远不会为测试编写测试。否则,我们将陷入无尽的兔子洞。

泛型

泛型特性是在 Java 5 版本中引入的。以一个例子开始,我们之前的Sortable接口是这样的:

package packt.java9.by.example.ch03; 
public interface SortableCollection { 
    Object get(int i); 
    int size(); 
}

在引入泛型之后,它将如下所示:

package packt.java9.by.example.ch03; 

public interface SortableCollection<E> { 
    E get(int i); 
    int size(); 
}

E标识符表示一个类型。它可以是指任何类型。它表示,如果一个类实现了该接口,即实现了两个方法——sizeget,那么这个类就是一个可排序的集合。get方法应该返回一个类型为E的对象,无论E是什么。这可能现在还不太容易理解,但很快你就会明白。毕竟,泛型是一个复杂的话题。

Sort接口将变成以下形式:

package packt.java9.by.example.ch03; 
import java.util.Comparator; 
public interface Sort<E> { 
    void sort(SortableCollection<E> collection); 
    void setSwapper(Swapper swap); 
    void setComparator(Comparator<E> compare); 
}

这与没有泛型的上一个版本相比,并没有提供更多的价值,但至少它做了些什么。在实际实现Sort接口的类中,Comparator应该接受与SortableCollection使用的相同类型。不可能SortableCollection在字符串上工作,而我们注入一个用于整数的比较器。

BubbleSort的实现如下:

package packt.java9.by.example.ch03.bubble; 
import packt.java9.by.example.ch03.*; 
import java.util.Comparator; 
public class BubbleSort<E> implements Sort<E> { 
    @Override 
    public void sort(SortableCollection<E> collection) { 
        ... sort code same as before 
    } 
    private Comparator<E> comparator = null; 
    @Override 
    public void setComparator(Comparator<E> comparator) { 
        this.comparator = comparator; 
    } 
        ... method swapper same as before 
}

泛型的真正力量将在我们编写测试时显现出来。第一个测试变化不大,尽管有了泛型,它更加明确。

@Test 
public void canSortStrings() { 
    ArrayList<String> actualNames = new ArrayList< >(Arrays.asList( 
            "Johnson", "Wilson", 
            "Wilkinson", "Abraham", "Dagobert" 
    )); 
    ArrayList<String> expectedResult = new ArrayList<>(Arrays.asList( 
            "Abraham", "Dagobert", 
            "Johnson", "Wilkinson", "Wilson" 
    )); 
    SortableCollection<String> names = 
            new ArrayListSortableCollection<>(actualNames); 
    Sort<String> sort = new BubbleSort<>(); 
    sort.setComparator(String::compareTo); 
    sort.setSwapper(new ArrayListSwapper<>(actualNames)); 
    sort.sort(names); 
    Assert.assertEquals(expectedResult, actualNames); 
}

当我们定义ArrayList时,我们也会声明列表的元素将是字符串。当我们分配新的ArrayList时,没有必要再次指定元素是字符串,因为它是从实际元素那里来的。每个元素都是一个字符串;因此,编译器知道在<>字符之间只能出现String

两个字符<>,中间没有类型定义,被称为菱形运算符。类型是推断出来的。如果你习惯了泛型,这段代码会给你更多关于集合操作类型的信息,代码也变得更易读。可读性和额外信息并不是唯一的目的。

正如我们所知,现在的Comparator参数是Comparator<String>,我们可以使用自 Java 8 以来可用的 Java 高级特性,并将String::compareTo方法引用传递给比较器设置器。

第二个测试对我们来说现在很重要。这是确保Sort不会干扰比较器抛出的异常的测试。

@Test(expected = RuntimeException.class) 
public void throwsWhateverComparatorDoes () { 
    ArrayList<String> actualNames = new ArrayList<>(Arrays.asList( 
            42, "Wilson", 
            "Wilkinson", "Abraham", "Dagobert" 
    )); 
    SortableCollection<String> names = 
            new ArrayListSortableCollection<>(actualNames); 
    Sort<String> sort = new BubbleSort<>(); 
    sort.setComparator((String a, String b) -> { 
        throw new RuntimeException(); 
    }); 
    final Swapper neverInvoked = null; 
    sort.setSwapper(neverInvoked);  
    sort.sort(names); 
}

问题是,它甚至无法编译。编译器表示它无法在第三行推断ArrayList<>的类型。当asList方法的全部参数都是字符串时,该方法返回一个包含String元素的列表,因此新操作符已知会生成ArrayList<String>。这次,有一个整数,因此编译器无法推断ArrayList<>是用于String元素的。

将类型定义从ArrayList<>更改为ArrayList<String>并不是一个解决办法。在这种情况下,编译器会抱怨值42。这就是泛型的力量。当你使用有类型参数的类时,编译器可以检测到你提供了错误类型的值。为了将值放入ArrayList以检查实现是否真的抛出了异常,我们必须将值放入其中。我们可以尝试将值42替换为空字符串,然后添加以下行,这将仍然无法编译:

actualNames.set(0,42);

编译器仍然知道你想要在ArrayList中设置的值应该是String。为了得到包含Integer元素的数组,你必须显式解锁安全控制并扣动扳机,即自杀:

((ArrayList)actualNames).set(0,42);

现在,测试看起来是这样的:

@Test(expected = RuntimeException.class) 
public void throwsWhateverComparatorDoes() { 
    ArrayList<String> actualNames = new ArrayList<>(Arrays.asList( 
            "", "Wilson", 
            "Wilkinson", "Abraham", "Dagobert" 
    )); 
    ((ArrayList) actualNames).set(0, 42); 
    SortableCollection<String> names = 
            new ArrayListSortableCollection<>(actualNames); 
    Sort<String> sort = new BubbleSort<>(); 
    sort.setComparator((a, b) -> { 
        throw new RuntimeException(); 
    }); 
    final Swapper neverInvoked = null; 
    sort.setSwapper(neverInvoked); 
    sort.sort(names); 
}

我们将设置 Swapper 为 null,因为它从未被调用。当我第一次写这段代码时,对我来说很明显。几天后,我阅读了代码,然后停了下来。为什么 swapper 是 null?然后我几秒钟就回想起来了。但每次阅读和理解代码遇到困难时,我往往会考虑重构。

我可以在表示//never invoked的行上添加注释,但注释往往会在功能改变后仍然存在。我在 2006 年学到了这一点,当时一个错误的注释阻止了我看到代码的执行方式。我在调试时阅读注释,而不是代码,修复错误花了两天时间,而系统一直处于关闭状态。

而不是注释,我倾向于使用使代码表达所发生情况的构造。额外的变量可能会使类文件大几字节,但 JIT 编译器会将其优化掉,所以最终的代码不会运行得更慢。

抛出异常的比较器被提供为一个 lambda 表达式。Lambda 表达式可以在需要使用只有一个简单方法的匿名类或命名类的情况下使用。Lambda 表达式是存储在变量中或作为参数传递以供稍后调用的匿名方法。我们将在第八章,扩展我们的电子商务应用中讨论 lambda 表达式的细节。

现在,我们将继续实现QuickSort,为此,我们将使用 TDD 方法。

测试驱动开发

测试驱动开发(TDD)是一种代码编写方法,其中开发者首先根据规范编写测试,然后编写代码。这与开发者社区习惯的做法正好相反。我们遵循的传统方法是先编写代码,然后为它编写测试。说实话,许多时候真正的实践是先编写代码,然后用即兴测试测试它,甚至没有单元测试。无论如何,作为一个专业人士,你永远不会那样做。你总是编写测试。(现在,把它写下百遍:我会总是编写测试。)

TDD 的一个优点是测试不依赖于代码。由于代码在测试创建时不存在,开发者不能依赖于单元的实现,因此它不能影响测试创建过程。这通常是好的。单元测试应该尽可能多地是黑盒测试。

黑盒测试是一种不考虑被测试系统实现的测试。如果一个系统被重构,以不同的方式实现,但提供给外部世界的接口相同,那么黑盒测试应该能够正常运行。

白盒测试依赖于被测试系统的内部工作。当代码发生变化时,白盒测试可能也需要调整以适应变化。白盒测试的优点可能是更简单的测试代码。并不总是这样。

灰盒测试是两者的混合体。

单元测试应该是黑盒测试,但很多时候,编写黑盒测试并不简单。开发者会编写他们认为的黑盒测试,但很多时候,这种信念被证明是错误的。当实现发生变化时,某些东西被重构,测试不再工作,需要被修正。恰好知道实现,开发者,尤其是编写单元测试的开发者,会编写依赖于代码内部工作的测试。在编写代码之前编写测试是防止这种情况的工具。如果没有代码,你无法依赖它。

TDD 还表示开发应该是一个迭代的过程。你一开始只写一个测试。如果你运行它,它会失败。当然会失败!因为没有代码,它必须失败。然后,你将编写满足这个测试的代码。没有更多,只有使这个测试通过的代码。然后,你将继续为规范的其他部分编写一个新的测试。你将运行它,它会失败。这证明了新的测试确实测试了尚未开发的内容。然后,你将开发代码以满足新的测试,并且可能还需要修改之前迭代中已经编写的一段代码。当代码准备好时,测试将通过。

许多时候,开发者不愿意修改代码。这是因为他们害怕破坏已经正常工作的东西。当你遵循 TDD 时,你不应该这样做,同时,你也不必为此感到害怕。所有已开发的功能都有测试。如果某些代码修改破坏了某些功能,测试将立即发出错误信号。关键是,在代码修改时,尽可能频繁地运行测试。

实现快速排序

如我们之前讨论的,快速排序由两个主要部分组成。一个是分区,另一个是递归地进行分区,直到整个数组排序。为了使我们的代码模块化并准备好演示 Java 9 模块处理功能,我们将分区和递归排序开发成单独的类和单独的包。代码的复杂性不足以证明这种分离是合理的。

分区类

分区类应该提供一个方法,该方法根据枢轴元素移动集合中的元素,并且方法完成后我们需要知道枢轴元素的位置。方法的签名应该看起来像这样:

public int partition(SortableCollection<E> sortable, int start, int end, E pivot);

该类还应能够访问SwapperComparator。在这种情况下,我们定义了一个类而不是接口;因此,我们将使用构造函数注入。

这些结构,如设置器和构造函数注入器,如此常见且频繁发生,以至于集成开发环境(IDEs)支持生成这些。你需要在代码中创建final字段,并使用代码生成菜单来创建构造函数。

分区类将如下所示:

package packt.java9.by.example.ch03.qsort; 

import packt.java9.by.example.ch03.SortableCollection; 
import packt.java9.by.example.ch03.Swapper; 

import java.util.Comparator; 

public class Partitioner<E> { 

    private final Comparator<E> comparator; 
    private final Swapper swapper; 
    public Partitioner(Comparator<E> comparator, Swapper swapper){ 
        this.comparator = comparator; 
        this.swapper = swapper; 
    } 

    public int partition(SortableCollection<E> sortable, int start, int end, E pivot){ 
        return 0; 
    } 
}

这段代码没有任何作用,但这就是 TDD 的开始。我们将创建一个需求的定义,提供代码的骨架和将调用它的测试。为此,我们需要可以分割的东西。最简单的选择是 Integer 数组。partition 方法需要一个 SortableCollection<E> 类型的对象,我们需要一个可以包装数组并实现此接口的东西。我们将这个类命名为 ArrayWrapper。这个类具有通用目的,不仅用于测试。因此,我们将其作为生产代码创建,并将其放在 main 目录中,而不是 test 目录中。由于这个包装器与 Sort 的实现无关,这个类的正确位置是在一个新的 SortSupportClasses 模块中。我们将创建新的模块,因为它不是接口的一部分。实现依赖于接口,但不依赖于支持类。也可能有一些应用程序使用我们的库,可能需要接口模块和一些实现,但仍然不需要支持类,当它们自己提供包装功能时。毕竟,我们不能实现所有可能的包装功能。SRP 也适用于模块。

Java 库往往包含不相关的功能。从短期来看,这使得库的使用更加简单。你只需要在 POM 文件中指定一个依赖项,你就可以拥有所有需要的类和 API。从长远来看,应用程序会变得更大,携带许多库中的类,但应用程序永远不会使用它们。

要添加新模块,必须创建模块目录以及源目录和 POM 文件。该模块必须添加到父 POM 中,并且还必须添加到 dependencyManagement 部分,以便 QuickSort 模块的测试代码可以使用它而无需指定版本。新模块依赖于接口模块,因此必须将其添加到支持类的 POM 中。

ArrayWrapper 类简单且通用。

package packt.java9.by.example.ch03.support; 
import packt.java9.by.example.ch03.SortableCollection; 
public class ArrayWrapper<E> implements SortableCollection<E> { 
    private final E[] array; 
    public ArrayWrapper(E[] array) { 
        this.array = array; 
    } 
    public E[] getArray() { 
        return array; 
    } 
    @Override 
    public E get(int i) { 
        return array[i]; 
    } 
    @Override 
    public int size() { 
        return array.length; 
    } 
}

我们还需要 ArraySwapper 类,它也属于同一个模块。它和包装器一样简单。

package packt.java9.by.example.ch03.support; 
import packt.java9.by.example.ch03.Swapper; 
public class ArraySwapper<E> implements Swapper { 
    private final E[] array; 
    public ArraySwapper(E[] array) { 
        this.array = array; 
    } 
    @Override 
    public void swap(int k, int r) { 
        final E tmp = array[k]; 
        array[k] = array[r]; 
        array[r] = tmp; 
    } 
}

有这些类后,我们可以创建我们的第一个测试。

package packt.java9.by.example.ch03.qsort; 

// imports deleted from print 

public class PartitionerTest {

在创建 @Test 方法之前,我们需要两个辅助方法来进行断言。断言并不总是简单的,在某些情况下,它们可能涉及一些编码。一般规则是,测试及其中的断言应该尽可能简单;否则,它们只是可能成为编程错误的来源。此外,我们创建它们是为了避免编程错误,而不是创造新的错误。

assertSmallElements 方法断言 cutIndex 之前的所有元素都小于 pivot

    private void assertSmallElements(Integer[] array, int cutIndex, Integer pivot) { 
        for (int i = 0; i < cutIndex; i++) { 
            Assert.assertTrue(array[i] < pivot); 
        } 
    }

assertLargeElements 方法确保所有在 cutIndex 之后的所有元素至少与 pivot 一样大。

    private void assertLargeElemenents(Integer[] array, int cutIndex, Integer pivot) { 
        for (int i = cutIndex; i < array.length; i++) { 
            Assert.assertTrue(pivot <= array[i]); 
        } 
    }

测试使用一个常量 Integers 数组,并将其包装在 ArrayWrapper 类中。

    @Test 
    public void partitionsIntArray() { 
        Integer[] partitionThis = new Integer[]{0, 7, 6}; 
        Swapper swapper = new ArraySwapper<>(partitionThis); 
        Partitioner<Integer> partitioner = 
                new Partitioner<>((a, b) -> a < b ? -1 : a > b ? +1 : 0, swapper); 
        final Integer pivot = 6; 
        final int cutIndex = partitioner.partition(new ArrayWrapper<>(partitionThis), 0, 2, pivot); 
        Assert.assertEquals(1, cutIndex); 
        assertSmallElements(partitionThis, cutIndex, pivot); 
        assertLargeElemenents(partitionThis, cutIndex, pivot); 
    } 
}

JDK 中没有为 Integer 类型定义 Comparator,但定义一个作为 lambda 函数很容易。现在我们可以编写 partition 方法,如下所示:

public int partition(SortableCollection<E> sortable, int start, int end, E pivot){ 
    int small = start; 
    int large = end; 
    while( large > small ){ 
        while( comparator.compare(sortable.get(small), pivot) < 0 && small < large ){ 
            small ++; 
        } 
        while( comparator.compare(sortable.get(large), pivot) >= 0 && small < large ){ 
            large--; 
        } 
        if( small < large ){ 
            swapper.swap(small, large); 
        } 
    } 
    return large; 
}

如果我们运行测试,它会正常运行。然而,如果我们用覆盖率运行测试,IDE 会告诉我们覆盖率只有 92%。测试只覆盖了 partition 方法的 14 行中的 13 行。

在第 28 行的页边空白处有一个红色矩形。这是因为测试数组已经被分区了。当枢轴值是 6 时,不需要在其中交换任何元素。这意味着我们的测试是好的,但还不够好。如果那一行有错误怎么办?

为了解决这个问题,我们将扩展测试,将测试数组从 { 0, 7, 6 } 改为 { 0, 7, 6, 2}。运行测试,它失败了。为什么?经过一些调试后,我们会意识到我们使用固定参数 2 作为数组的最后一个索引调用了 partition 方法。但是,我们使数组变长了。为什么一开始我们要在那里写一个常量呢?这是一个坏习惯。让我们将其替换为 partitionThis.length-1。现在,它说 cutIndex2,但我们期望的是 1。我们忘记调整断言以适应新的数组。让我们修复它。现在它工作了。

最后,我们需要重新思考断言。代码越少越好。断言方法相当通用,我们将用它来对一个单一的测试数组进行测试。断言方法如此复杂,以至于它们值得拥有自己的测试。但是,我们没有编写测试代码。相反,我们可以简单地删除这些方法,并拥有测试的最终版本。

@Test 
public void partitionsIntArray() { 
    Integer[] partitionThis = new Integer[]{0, 7, 6, 2}; 
    Swapper swapper = new ArraySwapper<>(partitionThis); 
    Partitioner<Integer> partitioner = 
            new Partitioner<>((a, b) -> a < b ? -1 : a > b ? +1 : 0, swapper); 
    final Integer pivot = 6; 
    final int cutIndex = partitioner.partition(new ArrayWrapper<>(partitionThis), 0, partitionThis.length-1, pivot); 
    Assert.assertEquals(2, cutIndex); 
    final Integer[] expected = new Integer[]{0, 2, 6, 7}; 
    Assert.assertArrayEquals(expected,partitionThis); 
}

然后,这又是一个黑盒测试吗?如果分区返回 {2, 1, 7, 6} 呢?它符合定义。我们可以创建更复杂的测试来覆盖这种情况。但是,更复杂的测试本身也可能有错误。作为不同的方法,我们可以创建可能更简单但依赖于实现内部结构的测试。这些不是黑盒测试,因此不是理想的单元测试。我将选择第二种方法,但如果有人选择其他方法,我不会争论。

递归排序

我们将在 qsort 包中实现快速排序,该包包含分区类以及一个额外的类,如下所示:

package packt.java9.by.example.ch03.qsort; 

// imports deleted from the print 

public class Qsort<E>  { 
// constructor injected final fields deleted from the print 
    public void qsort(SortableCollection<E> sortable, int start, int end) { 
        if (start < end) { 
            final E pivot = sortable.get(start); 
            final Partitioner<E> partitioner = new Partitioner<>(comparator, swapper); 
            int cutIndex = partitioner.partition(sortable, start, end, pivot); 
            if (cutIndex == start) { 
                cutIndex++; 
            } 
            qsort(sortable, start, cutIndex - 1); 
            qsort(sortable, cutIndex, end); 
        } 
    } 
}

该方法接收 SortableCollection<E> 和两个索引参数。它不会对整个集合进行排序;它只对 startend 索引之间的元素进行排序。

总是极其精确地处理索引非常重要。通常,Java 中的起始索引没有问题,但很多错误都源于如何解释 end 索引。

在这个方法中,end的值可能意味着索引已经不再是待排序区间的部分。在这种情况下,应该使用end-1调用partition方法,并使用cutIndex作为最后一个参数进行第一次递归调用。这取决于个人喜好。重要的是要精确并定义索引参数的解释。

如果只有一个元素(start == end),那么就没有东西要排序,方法返回。这是递归的结束条件。该方法还假设end索引永远不会小于start索引。由于这个方法只在我们目前正在开发的库中使用,这样的假设风险并不太大。

如果有东西要排序,那么方法会取待排序区间的第一个元素作为枢轴,并调用partition方法。当分区完成后,方法会递归地对自己调用两次,分别针对两个半区。

这个算法是递归的。这意味着该方法会调用自身。当方法调用执行时,处理器在称为的区域分配一些内存,并将局部变量存储在那里。这个属于栈中的方法区域称为栈帧。当方法返回时,这个区域被释放,栈被恢复,简单地将栈指针移动到之前的状态。这样,方法可以在调用另一个方法后继续执行;局部变量仍然存在。

当一个方法调用自身时,并没有什么不同。局部变量是方法实际调用的局部变量。当方法调用自身时,它会在栈上再次为局部变量分配空间。换句话说,这些都是局部变量的新实例

当算法的定义是递归时,我们将在 Java 和其他编程语言中使用递归方法。极其重要的是要理解,当处理器代码运行时,它不再是递归的。在那个层面上,有指令、寄存器存储和内存加载和跳转。没有函数或方法,因此在那个层面上,也没有递归。

如果你明白了这一点,那么理解任何递归都可以编码为循环就很容易了。

实际上,反过来也是真的——每个循环都可以编码为递归,但直到你开始函数式编程,这并不是很有趣。

Java 中的递归问题,以及许多其他编程语言中的问题,是它可能会耗尽栈空间。在快速排序的情况下,这并不是问题。你可以安全地假设 Java 中方法调用的栈有几百层。快速排序需要一个深度大约为 log[2]n 的栈,其中 n 是要排序的元素数量。对于十亿个元素,这是 30 层,刚好合适。

为什么栈没有被移动或调整大小?这是因为耗尽栈空间的代码通常是不良的编程风格。它们可以用某种循环的形式表达得更易于阅读。一个更健壮的栈实现只会诱导新手程序员编写一些不太易于阅读的递归代码。

递归有一个特殊的案例,称为尾递归。尾递归方法在方法中的最后一个指令处调用自己。当递归调用返回代码时,执行该方法除了释放用于此方法调用的栈帧外,不再做其他任何事情。换句话说,我们将在递归调用期间保留栈帧,只是为了之后将其丢弃。为什么不在调用之前丢弃?在这种情况下,具有相同大小和调用的实际帧将分配,因为这只是保留的方法,递归调用被转换成跳转指令。这是一个 Java 没有做的优化。函数式语言正在这样做,但 Java 并不是真正的函数式语言,因此尾递归函数应该尽量避免,并在 Java 源级别转换为循环。

非递归排序

为了证明即使是非尾递归方法也可以用非递归的方式表达,这里以快速排序为例:

public class NonRecursiveQuickSort<E> { 
    // injected final fields and constructor deleted from print  
    private static class Stack { 
        final int begin; 
        final int fin; 
        public Stack(int begin, int fin) { 
            this.begin = begin; 
            this.fin = fin; 
        } 
    } 

    public void qsort(SortableCollection<E> sortable, int start, int end) { 
        final List<Stack> stack = new LinkedList<>(); 
        final Partitioner<E> partitioner = new Partitioner<>(comparator, swapper); 
        stack.add(new Stack(start, end)); 
        int i = 1; 
        while (!stack.isEmpty()) { 
            Stack iter = stack.remove(0); 
            if (iter.begin < iter.fin) { 
                final E pivot = sortable.get(iter.begin); 
                int cutIndex = partitioner.partition(sortable, iter.begin, iter.fin, pivot); 
                if( cutIndex == iter.begin ){ 
                    cutIndex++; 
                } 
                stack.add(new Stack(iter.begin, cutIndex - 1)); 
                stack.add(new Stack(cutIndex, iter.fin)); 
            } 
        } 
    } 
}

这段代码在 Java 级别实现了栈。当它看到stack中仍有待排序的内容时,它会从栈中取出它,进行排序分区,并为排序的两个部分安排排序。

这段代码比之前的更复杂,你必须理解Stack类的作用以及它是如何工作的。另一方面,程序只使用一个Partitioner类的实例,也可以使用线程池来安排后续的排序,而不是在单个进程中处理任务。这可能会在多 CPU 机器上执行排序时加快排序速度。然而,这是一个更复杂的任务,本章包含了许多新内容,没有多任务处理;因此,我们将在稍后的两章中仅查看多线程代码。

在排序的第一个版本中,我编写代码时没有包含比较cutIndex与区间起始和增加它的三条代码行。这非常必要。但是,我们在这本书中创建的单元测试如果没有这些行,就无法发现这个错误。我建议你直接删除这些行,并尝试编写一些失败的单元测试。然后尝试理解当这些行至关重要时的特殊情况,并尝试修改你的单元测试,使其尽可能简单但仍能发现这个错误。(最后,将这四行代码放回,看看代码是否还能工作。)

此外,找出一些架构上的原因,说明为什么不要将这个修改放入partition方法中。如果large == start,该方法只需返回large+1

实现 API 类

做了所有这些之后,我们最后需要的是将 QuickSort 作为一个非常简单的类(所有真正的工都已经在不同类中完成)。

public class QuickSort<E> implements Sort<E> { 
    public void sort(SortableCollection<E> sortable) { 
        int n = sortable.size(); 
        Qsort<E> qsort = new Qsort<>(comparator,swapper); 
        qsort.qsort(sortable, 0, n-1); 
    } 
// ... setter injectors were deleted from the print 
}

不要忘记我们还需要一个测试!但是,在这种情况下,这并不比 BubbleSort 的测试有太大不同。

@Test 
public void canSortStrings() { 
    final String[] actualNames = new String[]{ 
            "Johnson", "Wilson", 
            "Wilkinson", "Abraham", "Dagobert" 
    }; 
    final String[] expected = new String[]{"Abraham", "Dagobert", "Johnson", "Wilkinson", "Wilson"}; 
    Sort<String> sort = new QuickSort<>(); 
    sort.setComparator(String::compareTo); 
    sort.setSwapper(new ArraySwapper<String>(actualNames)); 
    sort.sort(new ArrayWrapper<>(actualNames)); 
    Assert.assertArrayEquals(expected, actualNames); 
}

这次,我们使用了 String 数组而不是 ArrayList。这使得这个测试更简单,这次我们已经有支持类了。

你可能会意识到这并不是一个单元测试。在 BubbleSort 的情况下,算法是在一个单独的类中实现的。测试这个单个类是一个单元测试。在 QuickSort 的情况下,我们将功能分离到单独的类中,甚至分离到单独的包中。对 QuickSort 类的真实单元测试将揭示该类对其他类的依赖性。当这个测试运行时,它涉及到 PartitionerQsort 的执行;因此,这并不是真正的单元测试。

我们应该为此烦恼吗?实际上不必。我们想要创建涉及单个单元的单元测试,以便在单元测试失败时知道问题在哪里。如果只有集成测试,失败的测试用例在指出问题所在方面帮助不大。它所表明的只是测试中涉及的某些类中存在问题。在这种情况下,只有有限数量的类(三个)涉及到这个测试,并且它们是相互关联的。实际上,它们是如此紧密地相互关联,以至于在真正的生产代码中,我会将它们实现为一个单独的类。我在这里将它们分开,以展示如何测试单个单元,同时也展示需要比单个类在 JAR 文件中更多支持的 Java 9 模块支持。

创建模块

模块处理,也称为项目 Jigsaw,是仅在 Java 9 中提供的一项功能。这是一个开发者们期待已久的长期计划的功能。最初它计划在 Java 7 中实现,但由于其复杂性,它被推迟到了 Java 8,然后又推迟到了 Java 9。一年前,它似乎还会再次被推迟,但最终,项目代码进入了早期发布,现在没有什么可以阻止它成为发布的一部分。

为什么需要模块

我们已经看到,Java 中有四个访问级别。一个方法或字段可以是 privateprotectedpublicdefault(也称为包私有),如果没有提供修饰符。当你开发一个要在多个项目中使用的复杂库时,该库本身将包含许多包中的许多类。肯定会有一些类和方法、字段,这些是在库内部由来自不同包的其他类使用的,但不是要由库外部的代码使用的。使它们比 public 更不可见将使它们在库内部不可用。使它们 public 将使它们对外可见。

在我们的代码中,编译成 JAR 的 Maven 模块 quick 只能在方法 sort 可以调用 qsort 的情况下使用。但是,我们不希望 qsort 被直接从外部使用。在下一个版本中,我们可能希望开发一个使用 NonRecursiveQuickSort 类中的 qsort 的排序版本,我们不希望因为库的微小升级而导致客户代码无法编译或工作而抱怨。我们可以记录内部方法和类仍然是公共的,但不建议使用,但这是徒劳的。使用我们库的开发者不会阅读文档。这也是我们不写过多注释的原因。没有人会阅读它,甚至执行代码的处理程序也不会。

这个问题的最著名且臭名昭著的例子是 JDK 中的 sun.misc.Unsafe 类。正如其名所示,其中包含了一些非常不安全的代码。你可以访问堆外的内存,创建未初始化的对象等等。你不应该这样做。为什么费这个劲?你是一个行为良好的开发者,你只是遵守规则,不使用那个包。每当 JDK 的新版本中有所改变,你的程序只使用公共和良好文档化的 JDK API,程序就是安全的,对吧?

错误!没有意识到这一点,你可能会使用一些依赖其他库的库,而这些库又使用了这个包。Mockito 和 Spring 框架只是众多处于危险中的库中的两个。此外,Java 9 一定会带来这个包的新版本。然而,它也会带来模块处理。虽然 Java 9 将为使用 Unsafe 包的库提供一些有用的 API,因为这些库没有提供它们需要的功能,但它将提供模块来避免再次出现同样的问题。

什么是 Java 模块

Java 模块是一组类,这些类在一个 JAR 文件或目录中,同时包含一个名为 module-info 的特殊类。如果在 JAR 文件或目录中存在这个文件,那么它就是一个模块,否则它只是一个位于 classpath(或不在)上的类的集合。Java 8 及更早的版本将忽略这个类,因为它永远不会被用作代码。这样,使用较旧的 Java 不会造成伤害,并且保持了向下兼容性。

模块信息定义了模块导出什么以及它需要什么。它有一个特殊的格式。例如,我们可以将 module-info.java 放入我们的 SortInterface Maven 模块中。

module packt.java9.by.example.ch03{ 
        exports packt.java9.by.example.ch03; 
        }

这意味着任何 public 类,位于 packt.java9.by.example.ch03 包内,都可以从外部使用。此包由模块导出,但来自其他包的其他类即使在它们是 public 的情况下,也无法从模块外部可见。模块的名称与包的名称相同,但这仅是一种惯例,以防只有一个包被导出。要求与包的情况相同:应该有一个不太可能与其他模块名称冲突的名称。反向域名是一个不错的选择,但并非必须,正如你在本书中看到的那样。目前还没有顶级域名 packt

我们还应该配置父 POM,以确保我们使用的编译器是 Java 9,

<build> ... 
    <plugins> ... 
        <plugin> 
            <groupId>org.apache.maven.plugins</groupId> 
            <artifactId>maven-compiler-plugin</artifactId> 
            <version>3.5.1</version> 
            <configuration> 
                <source>1.9</source> 
                <target>1.9</target> 
            </configuration> 
        </plugin> 
...

旧版本可能会与 module-info.java 文件混淆。(顺便说一句,即使是这本书中使用的 Java 9 的早期访问版本有时也会给我带来麻烦。)

我们还在 Maven 模块 quick 中创建了一个 module-info.java 文件,内容如下:

module packt.java9.by.example.ch03.quick{ 
        exports packt.java9.by.example.ch03.quick; 
        requires packt.java9.by.example.ch03; 
        }

此模块导出另一个包,并需要我们刚刚创建的 packt.java9.by.example.ch03 模块。现在,我们可以编译模块和创建的 JAR 文件,位于 ./quick/target./SortInterface/target 目录中的现在都是 Java 9 模块。

由于 Maven 尚未完全支持模块,当我发出 mvn install 命令时,我得到了以下错误信息:

[ERROR] .../genericsort/quick/src/main/java/module-info.java:[3,40] 模块未找到:packt.java9.by.example.ch03

Maven 将编译的模块放在 classpath 上,但 Java 9 寻找 modulepath 以查找模块。Maven 尚未处理 modulepath。为了将 modulepath 欺骗到编译器中,我们必须向父 POM 的编译器插件的 configuration 中添加以下配置行:<compilerArgs>

<arg>-modulepath</arg>

<arg>${project.parent.basedir}/SortInterface/target/SortInterface-1.0.0-SNAPSHOT.jar: ...</arg>

</compilerArgs>

实际的文件应列出所有由 Maven 生成的以冒号分隔的 JAR 文件,以及一些模块所依赖的文件。这些是 SortInterfacequickSortSupportClasses

为了测试模块支持的功能,我们将创建另一个名为 Main 的 Maven 模块。它只有一个名为 Main 的类,其中包含一个 public static void main 方法:

package packt.java9.by.example.ch03.main; 

// ... imports deleted from the print 

public class Main { 
    public static void main(String[] args) throws IOException { 
        String fileName = args[0]; 
        BufferedReader br = new BufferedReader(new InputStreamReader(new FileInputStream(new File(fileName)))); 
        List<String> lines = new LinkedList<>(); 
        String line; 
        while ((line = br.readLine()) != null) { 
            lines.add(line); 
        } 
        br.close(); 
        String[] lineArray = lines.toArray(new String[0]); 
        Sort<String> sort = new QuickSort<>(); 
        Qsort<String> qsort = new Qsort<>(String::compareTo,new ArraySwapper<>(lineArray)); 
        sort.setComparator(String::compareTo); 
        sort.setSwapper(new ArraySwapper<>(lineArray)); 
        sort.sort(new ArrayWrapper<>(lineArray)); 
        for (final String outLine : lineArray) { 
            System.out.println(outLine); 
        } 
    } 
}

它接受第一个参数(不检查是否存在一个,我们不应该在生产代码中使用它)并将其用作文件名。然后,它将文件的行读入一个 String 数组中,对其进行排序,并将其打印到标准输出。

由于模块支持仅适用于模块,因此此 Maven 模块也必须是一个 Java 模块,并有一个 module-info.java 文件。

module packt.java9.by.example.ch03.main{ 
        requires packt.java9.by.example.ch03.quick; 
        requires packt.java9.by.example.ch03; 
        requires packt.java9.by.example.ch03.support; 
        }

此外,我们还需要为支持模块创建一个 module-info.java 文件;否则,我们将无法从我们的模块中使用它。

使用 mvn install 编译模块后,我们可以运行它以打印出父 POM。

    java -cp Main/target/Main-1.0.0-SNAPSHOT.jar:SortInterface/target/SortInterface-1.0.0-SNAPSHOT.jar:quick/target/quick-1.0.0-SNAPSHOT.jar:SortSupportClasses/target/SortSupportClasses-1.0.0-SNAPSHOT.jar packt.java9.by.example.ch03.main.Main pom.xml 

注意,这是一条命令行,它将输出内容分成多行。

现在,如果我们尝试直接在 main 方法中插入以下行 Qsort<String> qsort = new Qsort<>(String::compareTo,new ArraySwapper<>(lineArray)); 来访问 Qsort,Maven 将会抱怨,因为模块系统将其隐藏在我们的 Main 类中:

    [ERROR] Failed to execute goal org.apache.maven.plugins:maven-compiler-plugin:3.5.1:compile (default-compile) on project Main: Compilation failure: Compilation failure:
[ERROR] .../Main/src/main/java/packt/java9/by/example/ch03/main/Main.java:[4,41] package packt.java9.by.example.ch03.qsort does not exist
[ERROR] .../Main/src/main/java/packt/java9/by/example/ch03/main/Main.java:[25,9] cannot find symbol

模块系统还支持基于 java.util.ServiceLoader 的类加载机制,我们在这本书中不会讨论这个机制。这是一个旧技术,在企业环境中当使用 Spring、Guice 或其他依赖注入框架时很少使用。如果你看到一个包含 usesprovides 关键字的 module-info.java 文件,那么首先查阅关于 ServiceLoader 类 的 Java 文档,然后查阅 Java 9 语言文档中的模块支持部分 (openjdk.java.net/projects/jigsaw/quick-start)。

摘要

在本章中,我们开发了一个通用的排序算法,实现了快速排序。我们将我们的项目修改为多模块 Maven 项目,并使用 Java 模块定义。我们使用 JUnit 开发单元测试,并使用 TDD 开发代码。我们将代码从旧式 Java 转换为新式,使用了泛型,并使用了异常处理。这些是接下来章节所需的基本工具,在这些章节中,我们将开发一个猜谜游戏。首先,我们将开发一个更简单的版本,然后在下一章中,我们将开发一个使用并行计算和多个处理器的版本。

第四章:Mastermind - 创建一个游戏

在本章中,我们将开始开发一个简单的游戏。这个游戏是两人玩的 Mastermind 游戏。玩家一从六种可能的颜色中选择四个不同颜色的针,并将它们按行排列在隐藏给另一个玩家的板上。另一个玩家试图猜测针的颜色和位置。每次尝试后,玩家一告诉匹配的颜色数量以及匹配颜色和位置的针的数量。程序将扮演玩家一和玩家二。我们的代码将独立运行。然而,我们最重要的任务是代码本身。

这个例子足够复杂,可以加深我们对面向对象原则的理解以及如何设计类和模拟现实世界。我们已经使用了 Java 运行时提供的类。这次,我们将使用集合并讨论这个重要领域。这些类和接口在 JDK 中广泛使用,对于专业 Java 开发者来说,它们的重要性不亚于语言本身。

这次的构建工具是 Gradle。

在本章中,我们将介绍:

  • Java 集合

  • 依赖注入

  • 如何注释我们的代码以及创建 JavaDoc 文档

  • 如何创建集成测试

游戏

Mastermind (zh.wikipedia.org/wiki/Mastermind_(board_game)) 是一款老游戏。那个在每家每户都有孩子的家庭中无处不在的塑料版本是在 1970 年发明的。我大约在 1980 年收到了一个作为圣诞礼物的板子,用 BASIC 语言解决游戏谜题是我 1984 年左右编写的第一个程序之一。

游戏板在四列的几行中都有孔。有六种不同颜色的塑料针可以插入孔中。每个针都有一个颜色。它们通常是红色、绿色、蓝色、黄色、黑色和白色。有一行是隐藏给一个玩家(猜测者)的。

为了玩游戏,一个玩家(隐藏者)必须从一组针中选择四个。所选的针应该有不同的颜色。针一个接一个地放置在隐藏行中,每个针都放在一个位置。

猜测者试图通过猜测颜色和位置来找出颜色。每次猜测都涉及选择四个针并将它们放在一行中。隐藏者告诉猜测者有多少针在正确的位置,以及有多少针颜色在桌面上,但不在隐藏颜色的位置。

图片

一场典型的游戏可能如下进行:

  • 隐藏者用蓝色、黄色、白色和黑色的四个针进行隐藏。

  • 猜测者猜测黄色、蓝色、绿色和红色。

  • 隐藏者告诉猜测者有两个颜色匹配,但它们都不在隐藏行的位置。隐藏者之所以这么说,是因为黄色和蓝色在隐藏行,但不在猜测者猜测的位置。实际上它们是交换过的,但这个信息隐藏者保密。她只说有两个颜色匹配,没有颜色在正确的位置。

  • 下一个猜测是 ...

当猜谜者找到正确的颜色并按正确顺序排列时,游戏结束。如图所示的相同游戏也可以用文本表示法来描述,B代表蓝色,Y代表黄色,G代表绿色,W代表白色,R代表红色,b代表黑色(幸运的是,我们在电脑上有大小写字母)。

RGBY 0/0
GRWb 0/2
YBbW 0/2
BYGR 0/4
RGYB 2/2
RGBY 4/0

猜猜看!这是我们在这个章节中开发的程序的真正输出。

游戏的模型

当我们用面向对象的心态编写一段代码时,我们试图模拟现实世界,并将现实世界的对象映射到程序中的对象。你肯定听说过用非常典型的几何对象、汽车和发动机等例子来解释面向对象。我个人认为这些例子太简单了,无法获得良好的理解。它们可能适合初学者,但我们已经在书的第四章了。Mastermind 游戏要好得多。它比简单的矩形和三角形复杂一些,但不如电信计费应用或原子能电站控制复杂。

在那个游戏中我们有哪些现实世界的对象呢?我们有一个台球桌,还有不同颜色的球。我们肯定需要两个 Java 类。台球桌里有什么呢?每一行有四个位置。也许我们需要一个代表行的类。台球桌将有行。我们还需要一些隐藏秘密的东西。这也可能是一个行,每一行也可能包含有关位置数和匹配颜色的信息。对于秘密行来说,这个信息是显而易见的:4 和 0。

什么是球?每个球都有一个颜色,通常就是这样。球没有其他特征,除了它可以插入到台球桌上的一个洞里,但这是现实生活中我们不会模拟的特征。本质上,球就是一个颜色,没有其他。这样,我们可以在早期就消除球类从我们的模型中,甚至在 Java 创建它之前。相反,我们有颜色。

什么是颜色?这可能是第一次难以沉浸其中的东西。我们都知道颜色是什么。它是不同频率的光的混合,正如我们的眼睛所感知的那样。我们可以有不同颜色的油漆和印刷品,等等。在这个程序中,我们有很多东西没有模拟。在代码中很难说我们模拟了关于颜色的哪些内容,因为这些特征如此明显,我们在现实生活中都认为是理所当然的;我们可以描述两种颜色是不同的。这是我们唯一需要的特征。为此,我们可以使用 Java 中最简单的类:

package packt.java9.by.example.mastermind; 
public class Color {}

如果你有两个Color类型的变量,你可以判断它们是否相同。你可以使用对象身份比较ab的表达式a == b,或者你可以使用从Object类继承的equals方法,a.equals(b)。用字母编码颜色或使用String常量来表示它们可能更容易,但后来会有严重的缺点。当代码变得复杂时,它会导致错误;很容易传递也编码为 String 的东西而不是颜色,只有单元测试可能拯救这一天。更好的是,当你输入错误的参数时,编译器已经在 IDE 中抱怨了。

当我们玩游戏时,球针在小盒子里。我们从盒子里拔出球针。我们如何在程序中获取颜色?我们需要从我们可以获取颜色的地方,或者从另一个角度看,我们可以提供颜色的地方。我们将称之为ColorManagerColorManager知道我们有多少种不同的颜色,任何我们需要颜色的时候,我们都可以要求它。

再次,有一种诱惑要设计一个ColorManager,它可以按照序列号提供颜色。如果我们有四种颜色,我们可以要求颜色编号 0、1、2 或 3。但再次,它只是隐式地将颜色编码为整数,这是我们同意不会做的。我们应该找到我们需要的最小特征来模拟游戏。

为了描述类的结构,专业开发者通常使用 UML 类图。UML 是一种标准化的图表符号,几乎专门用于可视化软件架构。UML 中有许多图表类型来描述程序的静态结构和动态行为。这次,我们将查看一个非常简化的类图。

图片

我们没有空间深入了解 UML 类图。矩形表示类,普通箭头表示当类具有其他类类型的字段时的关系,三角形箭头表示一个类扩展了另一个类。箭头指向被扩展的类的方向。

一个Game包含一个秘密的Row和一个TableTable有一个ColorManager和一个RowList<>ColorManager有一个第一个颜色和一个ColorMap<>。我们还没有讨论为什么是这种设计,我们将会到达那里,图表帮助我们走这条路。一个Row本质上是一个Color的数组。

玩游戏的人有一个功能:它必须猜测很多次,直到找到隐藏的秘密。为了得到ColorManager的模型,我们不得不设计Guesser的算法。

当玩家第一次猜测时,任何颜色的组合都和其他任何组合一样好。后来,猜测应该考虑之前猜测得到的回应。尝试只有可能是实际秘密的颜色变化是一种合理的方法。玩家选择一个变化,并查看所有之前的猜测,假设所选的变化是秘密。如果他对已经做出的行回应与游戏中未知秘密的回应相同,那么尝试这个变化是合理的。如果有任何差异,那么这个变化肯定不是隐藏的变化。

为了遵循这种方法,猜测者必须一个接一个地生成所有可能的颜色变化,并将其与表格进行比较。猜测者的代码不会提前创建和存储所有可能的变化,但它必须知道它在哪里,并且必须能够计算出下一个变化。这假设了变化的顺序。暂时让我们忘记在一个变化中可能不会出现两次颜色。简单的排序可以像我们排序十进制数字一样进行。如果我们有一个三位数,那么第一个是 000,下一个是 001,以此类推,直到 009,总是为最后一个位置获取下一个数字。之后,010 出现。我们增加最后一个旁边的数字,并将最后一个数字设置为 0。现在,我们有 011,012,以此类推。你知道我们是如何数数的。现在,用颜色替换数字,我们只有六种颜色而不是十种。或者,当我们实例化一个ColorManager对象时,我们可以有我们想要的任何数量。

这导致了ColorManager的功能。它必须完成以下两件事:

  • 将第一种颜色分配给呼叫者

  • 分配给给定颜色之后的下一个颜色(我们将该方法命名为nextColor

后者功能还应该以某种方式在没有任何下一个颜色时发出信号。这将通过另一个名为thereIsNextColor的方法来实现。

is开头是返回布尔值的方法名的惯例。这将导致遵循此惯例的名称为isThereNextColorisNextColor。这两个名称中的任何一个都解释了该方法的功能。如果我提出问题isThereNextColor,该方法将回答我truefalse。但是,我们不会这样使用该方法。我们将用简单的句子来交流。我们将使用简短的句子。我们将避免不必要的、混乱的表达。我们也将这样编程。很可能会在if语句中使用这个方法。他们将写下以下内容:

If( thereIsNextColor(currentColor)){...}

而不是

if( isThereNextColor(currentColor)){...}

我认为第一个版本更易于阅读,可读性是最重要的。最后但同样重要的是,如果你遵循旧惯例,没有人会责怪你,如果这是公司的标准,你无论如何都必须这样做。

要完成这些操作,ColorManager 还需要创建颜色对象,并且应该将它们存储在一个有助于执行操作的机构中。

package packt.java9.by.example.mastermind; 

 import java.util.HashMap; 
 import java.util.Map; 

 public class ColorManager { 
     final protected int nrColors; 
     final protected Map<Color, Color> successor = new HashMap<>(); 
     final private Color first; 

     public ColorManager(int nrColors) { 
         this.nrColors = nrColors; 
         first = new Color(); 
         Color previousColor = first; 

         for (int i = 1; i < nrColors; i++) { 
             final Color thisColor = new Color(); 
             successor.put(previousColor, thisColor); 
             previousColor = thisColor; 
         } 
         successor.put(previousColor, Color.none); 
     } 

     public Color firstColor() { 
         return first; 
     } 

     boolean thereIsNextColor(Color color) { 
         return successor.get(color) != Color.none; 
     } 

     public Color nextColor(Color color) { 
         return successor.get(color); 
     } 
 }

我们使用的结构是 MapMap 是在 Java 运行时中定义的一个接口,并且从 Java 的早期版本开始就可用。Map 有键和值,对于任何键,你可以轻松地检索分配给该键的值。

您可以在定义变量 successor 的那一行看到,我们定义了变量的类型为接口,但值是一个类的实例。显然,值不能是一个接口的实例,因为这样的生物是不存在的。但,我们为什么要把变量定义为接口呢?原因是抽象和编码实践。如果我们需要因为某种原因更改使用的实现,变量类型仍然可能保持不变,而且不需要在其他地方更改代码。将变量声明为接口也是一个好的实践,这样我们就不会因为方便而使用接口中不可用的某些特殊 API。当真正需要时,我们可以更改变量的类型并使用特殊的 API。毕竟,API 存在是有原因的,但仅仅因为某些特殊的东西存在就使用它的诱惑被阻止了。这有助于编写更简单、更干净的程序。

Map 只是 Java 运行时中定义的属于 Java 集合的接口之一。还有很多其他的接口和类。尽管 JDK 和所有类都非常庞大,几乎没有人知道所有存在的类,但集合是一个专业开发者应该了解的特殊领域。在深入探讨为什么在这段代码中使用 HashMap 之前,我们将对集合类和接口有一个概述。这将帮助我们理解这个程序中使用的其他集合。

Java 集合

集合是帮助我们存储多个对象的接口和类。我们已经在之前的章节中看到了可以做到这一点的数组,以及 ArrayList,但我们没有详细讨论 JDK 中还有哪些其他可能性。在这里,我们将更详细地探讨,但将流和函数式方法留到后面的章节,我们也将避免深入探讨,这更像是参考书的任务。

使用集合类和接口的实现可以减少编程工作量。首先,你不需要编写已经存在的东西。其次,这些类在实现和功能上都非常优化。它们有非常精心设计的 API,代码运行速度快,内存占用小。遗憾的是,它们的代码是多年前编写的,很多时候风格不佳,难以阅读和理解。

当你使用 JDK 中的集合时,你更有可能与某些库进行交互。如果你自己编写版本的链表,你不太可能找到一个现成的解决方案来排序你的列表。如果你使用 JDK 标准类库中的LinkedList类,你将从Collections类中获得一个现成的解决方案,直接来自 JDK。也值得提到的是,Java 语言本身支持这些类,例如,你可以使用简化的特殊语法轻松遍历Collection的元素。

JDK 中的集合包含定义不同集合类型、实现类以及执行某些操作(如排序)的算法的接口。很多时候,这些算法在不同的实现版本上工作,得到相同的结果,但针对特定类进行了优化。

你可以使用接口提供的 API,如果你在代码中更改实现,你将得到一个适合实现的优化版本。

集合接口可以分为两类。一类包含扩展Collection接口的接口,另一类包含Map和扩展MapSortedMap。这样,Map实际上不是一个集合,因为它不仅包含其他对象,还包含键值对。

界面集合

收集是界面层次结构中的顶层。这个界面定义了所有实现应该提供的方法,无论它们是否直接实现了SetSortedSetListQueueDeque接口。正如Collection所简单说明的,实现Collection接口的对象只是一个收集其他对象的集合,它定义的方法就像向集合中添加一个新对象、从那里清除所有元素、检查一个对象是否已经是集合的成员以及遍历元素。

对于接口的最新定义,请参阅 Java pi 文档(download.java.net/java/jdk9/docs/api/overview-summary.html)。你可以随时在线查看 API,并且建议这样做。

Java 语言本身直接支持这个接口。你可以使用增强的for循环语法遍历Collection的元素,就像你可以遍历数组中的元素一样,其中集合应该是一个表达式,该表达式结果是一个实现Collection接口的对象:

for( E element : collection ){...}

在前面的代码中,E是 Object 或者是Collection元素的类型参数。

Collection接口在 JDK 中不是直接实现的。类实现了Collection的一个子接口。

集合

Set 是一个特殊的集合,不能包含重复的元素。当你想要将一个对象添加到一个已经包含与实际对象相同或相等的对象的集合中时,add 方法将不会添加实际的对象。add 方法将返回 false 表示失败。

当你需要一个包含唯一元素的集合,你只需要检查一个元素是否是集合的成员或不是,一个对象是否属于某个特定组时,你可以在你的程序中使用 Set

当我们回到程序代码时,我们会看到 UniqueGuesser 类必须实现一个算法,该算法检查猜测中的颜色只出现一次。这个算法是用于 Set 的理想候选:

private boolean isNotUniqueWithSet(Color[] guess) { 
     final Set<Color> alreadyPresent = new HashSet<>(); 
     for (Color color : guess) { 
         if (alreadyPresent.contains(color)) { 
             return true; 
         } 
         alreadyPresent.add(color); 
     } 
     return false; 
 }

代码创建了一个集合,当方法开始时它是空的。之后,它检查每个颜色(注意对数组元素的增强型 for 循环)是否之前已经存在。为了做到这一点,代码检查颜色是否已经在集合中。如果它在其中,那么猜测就不唯一,因为我们已经发现了一个至少出现两次的颜色。如果颜色不在集合中,那么猜测在颜色上仍然可以是唯一的。为了能够稍后检测到这一点,代码将颜色放入集合中。

我们将要使用的 Set 的实际实现是 HashSet。在 JDK 中,有许多类实现了 Set 接口。最广泛使用的是 HashSet,也值得提一下 EnumSetLinkedHashSetTreeSet。最后一个也实现了 SortedSet 接口,所以我们将在那里详细说明。

要理解 HashSet(以及稍后的 HashMap)是什么以及它们是如何工作的,我们必须讨论哈希是什么。它们在许多应用中扮演着非常重要和核心的角色。它们在 JDK 的底层执行其工作,但程序员必须遵循一些非常重要的约束,否则会出现非常奇怪且极其难以发现的错误,这将使他们的生活变得痛苦。我敢说,违反 HashSetHashMap 中的哈希契约是仅次于多线程问题的第二难以发现的错误的原因。

因此,在继续不同的集合实现之前,我们将探讨这个主题。我们已经在这个关于集合的偏离中深入了一层,现在我们将再深入一层。我保证这是最后一个深入的偏离层次。

哈希函数

哈希是一种数学函数,它将一个数字分配给一个元素。比如说你在大学行政部门工作,你需要判断 Wilkinson 是否是你们班的学生。你可以将名字写在小纸条上,然后放入信封中,每个信封对应一个首字母。这样,你就不需要搜索一万名学生,只需查看标题为 W 的信封中的纸条。这个非常简单的哈希函数将名字的第一个字母分配给该名字(或者说是字母的序号,因为我们说过哈希函数的结果是一个数字)。这并不是一个好的哈希函数,因为它只将少数几个元素(如果有的话)放入标记为 X 的信封中,而将许多元素放入例如 A 的信封中。

一个好的哈希函数以相似的概率将每个可能的序号分配给每个元素。在哈希表中,我们通常有比要存储的元素数量更多的桶(如前例中的信封)。因此,当搜索一个元素时,很可能只有一个元素在那里。至少这是我们希望得到的。如果单个桶中有多个元素,这被称为冲突。一个好的哈希函数具有尽可能少的冲突。

为了保持向后兼容性,JDK 中有一个Hashtable类。这是 Java 中第一个哈希表实现之一,就在第一个版本中,由于 Java 具有向后兼容性,它没有被丢弃。Map接口是在版本 1.2 中引入的。Hashtable有很多缺点,并且不建议使用。(甚至它的名字也违反了 Java 的命名约定。)我们在这本书中不讨论这个类。当我们谈论哈希表时,它指的是HashSetHashMap或任何使用某种哈希索引表的集合实现中的实际数组。

哈希表是使用哈希函数的结果来索引数组的数组。通常,链表管理冲突。哈希表实现还实现了一种策略,当要存储的元素数量变得过高且冲突的可能性增加时,调整数组的大小。这个操作可能需要相当长的时间,在此期间,各个元素会在桶之间移动。

在这个操作过程中,哈希表无法可靠地使用,这可能是多线程环境中出现问题的来源之一。在单线程代码中,你不会遇到这个问题。当你调用add方法时,哈希表(集合或映射)决定表需要调整大小。add方法调用调整大小的方法,并且直到完成才返回。单线程代码在这个期间没有可能使用哈希表:唯一的一条线程正在执行调整大小操作。在多线程环境中,然而...

HashSetHashMap 使用存储在集合中的 Object 提供的哈希函数。Object 类实现了 hashCodeequals 方法。您可以覆盖它们,如果您这样做,应该以一致的方式覆盖它们。首先,我们将了解它们是什么,然后了解如何一致地覆盖它们。

equals 方法

集合的文档说明“集合不包含任何元素对 e1e2,使得 e1.equals(e2)”。equals 方法返回 true 如果 e1e2 以某种方式相等。它们可能不同于两个完全相同的对象。可能存在两个不同的对象相等。例如,我们可以有一个颜色实现,其中颜色的名称作为属性,当两个字符串相等时,两个颜色对象在其中一个上调用 equals 方法并将另一个作为参数传递,它们会返回 trueequals 方法的默认实现位于 Object 类的代码中,并且仅在 e1e2 完全相同且是单个对象时返回 true

虽然看起来很明显,但我的经验表明,强调对象中 equals 的实现必须如下所示是不够的:

  • 自反性:这意味着一个始终等于自身的对象

  • 对称性(交换性):这意味着如果 e1.equals(e2)true,则 e2.equals(e1) 也应该是 true

  • 传递性:这意味着如果 e1.equals(e2)e2.equals(e3),则 e1.equals(e3)

  • 一致性:这意味着如果对象在调用之间未被更改,则返回值不应改变

hashCode 方法

hashCode 方法返回一个 int。文档说明,任何重新定义此方法的类都应该提供以下实现:

  • 如果对象未被修改,则始终返回相同的值

  • 对于两个相等的对象返回相同的 int 值(equals 方法返回 true

文档还提到,这不是为不相等的对象返回不同的 int 值的要求,但支持实现哈希集合的性能是可取的。

如果在 equalshashCode 的实现中违反了这些规则中的任何一个,那么使用它们的 JDK 类将失败。您可以确信 HashSetHashMap 和类似类已经完全调试过,看到您向集合中添加了一个对象,然后集合报告它不在那里将会是一个令人困惑的经历。然而,只有当您发现存储在集合中的两个相等的对象具有不同的 hashCode 值时,HashSetHashMap 才会在由 hashCode 值索引的桶中查找对象。

也将是一个常见的错误,将对象存储在HashSetHashMap中,然后修改它。对象在集合中,但你找不到它,因为hashCode返回不同的值。存储在集合中的对象不应该被修改,除非你知道你在做什么。

许多时候,对象包含从相等性角度来看不感兴趣的字段。hashCodeequals方法应该对这些字段是幂等的,你甚至可以在将对象存储在HashSetHashMap之后修改这些字段。

例如,你可能在对象中管理三角形的顶点坐标和三角形的颜色。然而,你并不关心颜色用于相等性,只关心两个三角形在空间中的位置是否完全相同。在这种情况下,equalshashCode方法不应考虑字段颜色。这样,我们可以给我们的三角形上色;无论颜色字段是什么,它们仍然可以在HashSetHashMap中找到。

实现equalshashCode

实现这些方法相当简单。由于这是一个非常常见的任务,IDE 支持生成这些方法。这些方法紧密相连,以至于 IDE 中的菜单项不是分开的;它们提供一次性生成这些方法。

要求 IDE 生成equals方法将产生如下代码:

@Override 
 public boolean equals(Object o) { 
     if (this == o) return true; 
     if (o == null || getClass() != o.getClass()) return false; 
     MyObjectJava7 that = (MyObjectJava7) o; 
     return Objects.equals(field1, that.field1) && 
             Objects.equals(field2, that.field2) && 
             Objects.equals(field3, that.field3); 
 }

对于这个示例,我们有三个名为field1field2field3Object字段。任何其他类型和字段的代码看起来非常相似。

首先,该方法检查对象身份。一个Object总是equals自身。如果作为参数传递的引用是null且不是对象,或者它们属于不同的类,那么这个生成的方法将返回false。在其他情况下,将使用类的静态方法Objects(注意复数形式)来比较每个字段。

实用类Objects是在 Java 7 中引入的,因此示例类的名称。静态方法equalshash支持覆盖Object equalshashCode方法。在 Java 7 之前创建hashCode相当复杂,需要实现带有一些难以仅通过查看代码而不了解其背后的数学原理的魔数模运算。

这种复杂性现在被隐藏在下面的Objects.hash方法之后。

@Override 
 public int hashCode() { 
     return Objects.hash(field1, field2, field3); 
 }

生成的简单方法调用Objects.hash方法,并将重要字段作为参数传递。

HashSet

现在,我们基本上对哈希了解很多,因此我们可以大胆地讨论HashSet类。HashSet是实现Set接口的类,内部使用哈希表。一般来说,就是这样。你将对象存储在那里,你可以看到对象是否已经存在。当需要Set实现时,几乎总是选择HashSet。几乎...

枚举集

EnumSet 可以包含来自某个枚举的元素。回想一下,枚举是声明在 enum 内部的固定数量实例的类。由于这限制了不同对象实例的数量,并且这个数量在编译时是已知的,因此 EnumSet 代码的实现相当优化。内部,EnumSet 被实现为一个位字段,并且当可以使用位字段操作时是一个很好的选择。

LinkedHashSet

LinkedHashSet 是一个 HashSet,同时维护了一个包含其元素的循环链表。当我们遍历一个 HashSet 时,元素没有保证的顺序。当 HashSet 被修改时,新元素会被插入到一个桶中,并且可能需要调整哈希表的大小。这意味着元素会被重新排列,并进入完全不同的桶中。在 HashSet 中遍历元素只是按照某种任意顺序取桶及其中的元素。

然而,LinkedHashSet 使用它维护的链表遍历元素,并且遍历保证按照元素插入的顺序进行。

SortedSet

SortedSet 是一个接口,它保证实现它的类将按排序顺序遍历集合。顺序可能是如果对象实现了 Comparable 接口,则为对象的自然排序;或者可能由一个 Comparator 对象驱动。当创建实现 SortedSet 的类的实例时,应该提供这个对象;换句话说,它必须是构造函数的参数。

NavigableSet

NavigableSet 扩展了 SortedSet 接口,提供了允许你在集合中进行邻近搜索的方法。这实际上允许你搜索一个在搜索中且小于被搜索对象的元素,小于或等于被搜索元素,大于或等于,或大于被搜索对象。

TreeSet

TreeSetNavigableSet 的一个实现,因此它也是一个 SortedSet,实际上也是一个 Set。根据 SortableSet 文档的说明,有两种类型的构造函数,尽管每种都有多个版本。一种需要一些 Comparator,另一种则依赖于元素的默认排序。

List

List是一个接口,要求实现类跟踪元素的顺序。还有通过索引访问元素和由Collection接口定义的迭代定义的方法,该接口保证了元素的顺序。该接口还定义了listIterator方法,该方法返回一个实现ListIterator接口的Iterator。此接口提供了允许调用者在迭代列表的同时插入元素的方法,也可以在迭代中前后移动。在List中搜索特定元素也是可能的,但大多数实现接口的接口在搜索时提供较差的性能,因为搜索只是简单地遍历所有元素,直到找到要搜索的元素。在 JDK 中有许多实现此接口的类。在这里,我们将提到两个。

LinkedList

这是一个双向链表实现的 List 接口,每个元素都有一个指向列表中前一个元素和后一个元素的引用。该类还实现了 Deque 接口。从列表中插入或删除元素相对便宜,因为它只需要调整少数几个引用。另一方面,通过索引访问元素将需要从列表的开始迭代,或者从列表的末尾迭代, whichever is closer to the specified indexed element。

ArrayList

这个类是实现List接口的类,它将元素的引用存储在数组中。这样,通过索引访问元素相对较快。另一方面,向ArrayList插入元素可能代价高昂。它需要将插入元素以上的所有引用向上移动一个索引,并且如果原始数组中没有空间存储新元素,可能还需要调整底层数组的大小。本质上,这意味着分配一个新的数组并将所有引用复制到它上面。

如果我们知道数组将如何增长,我们可以调用ensureCapacity方法来优化数组的重新分配。这将根据提供的参数大小调整数组的大小,即使当前使用的槽位数量较少。

我的经验是,新手程序员在需要列表而不考虑不同实现算法性能的情况下使用ArrayList。我实际上不知道为什么ArrayList会有这种流行度。程序中实际使用的实现应该基于正确的决策,而不是习惯。

Queue

队列是一个通常存储元素以供以后使用的集合。你可以将元素放入队列中,也可以将它们取出。实现可能指定给定的顺序,可能是先进先出FIFO)或后进先出LIFO)或基于优先级的排序。

在队列中,你可以调用 add 方法添加元素,remove 方法移除头元素,以及 element 方法访问头元素而不从队列中移除它。当存在容量问题时,add 方法将抛出异常,并且元素无法添加到队列中。当队列空时,没有头元素,elementremove 方法将抛出异常。

由于异常只能在异常情况下使用,并且调用程序可能在代码的正常流程中处理这些情况,因此所有这些方法都有一个只返回一些特殊值的版本,以表示这种情况。而不是 add,调用者可以调用 offer 来存储元素。如果队列无法存储元素,它将返回 false。同样,peek 将尝试获取头元素或在没有头元素时返回 null,而 poll 将移除并返回头元素,如果没有头元素则只返回 null

注意,这些返回 null 的方法在实现,例如 LinkedList 允许 null 元素时,只会使情况变得模糊不清。永远不要在队列中存储 null 元素。

Deque

Deque 是一个双端队列的接口。它通过提供访问队列两端的方法扩展了 Queue 接口,这些方法允许从两端添加、查看和移除元素。

对于 Queue 接口,我们需要六个方法。具有两个可管理端点的 Dequeue 需要 12 个方法。我们不是 add,而是有 addFirstaddLast。同样,我们还可以 offerFirstofferLast 以及 peekFirstpeekLastpollFirstpollLast。由于某种原因,实现 Queueelement 方法功能的方法被命名为 getFirstgetLast

由于这个接口扩展了 Queue 接口,因此也可以使用那里定义的方法来访问队列的头。除了这些,这个接口还定义了 removeFirstOccurrenceremoveLastOccurrence 方法,可以用来在队列内部移除特定元素。我们无法指定要移除元素的索引,也无法根据索引访问元素。removeFirst/LastOccurrence 方法的参数是要移除的对象。如果我们需要这个功能,即使从队列的同一边添加和移除元素,我们也可以使用 Deque

为什么 Deque 中有这些方法,而 Queue 中没有?这些方法与 Deque 的双头特性无关。原因是方法不能在接口发布后添加。如果我们向接口添加一个方法,就会破坏向后兼容性,因为所有实现该接口的类都必须实现新方法。Java 8 引入了默认方法,这放宽了这一限制,但 Queue 接口是在 Java 1.5 中定义的,而 Deque 接口是在 Java 1.6 中定义的。当时没有方法可以将新方法添加到已经存在的接口中。

Map

Map将键和值配对。如果我们想从Collection的角度来接近Map,那么Map是一组键/值对。你可以将键值对放入Map中,并且可以根据键获取值。键是唯一的,就像Set中的元素一样。如果你查看Set接口的不同实现的源代码,你可能会看到其中一些是作为Map实现的包装实现的,其中值被简单地丢弃。

使用Map既简单又吸引人。许多语言,如 Python、Go、JavaScript、Perl 等,在语言级别上支持这种数据结构。然而,当数组足以满足需求时使用Map是一种不良实践,我见过很多次,尤其是在脚本语言中。Java 不太容易犯这种新手程序员的错误,但你可能仍然会遇到想要使用Map的情况,而且仍然有更好的解决方案。一个普遍的规则是,应该使用最简单的数据结构来实现算法。

HashMap

HashMapMap接口的基于哈希表的实现。由于映射基于哈希表,基本的putget方法是在常数时间内执行的。此外,由于Map非常重要,并且因为 JDK 中最常用的实现是HashMap,所以实现相当可配置。你可以使用不带参数的默认构造函数来实例化HashMap,但还有一个构造函数可以定义初始容量和加载因子。

IdentityHashMap

IdentityHashMap是一个特殊的Map,它直接实现了Map接口,但实际上它违反了Map接口文档中定义的契约。它这样做是有充分理由的。该实现使用哈希表,就像HashMap一样,但在决定桶中找到的键与作为 get 方法参数提供的键元素是否相等时,它使用Object引用(==运算符)而不是equals方法,这是Map接口文档所要求的。

当我们想要区分不同Object实例作为键,而这些键在其他情况下是相等的,使用这种实现是合理的。出于性能原因使用这种实现几乎肯定是一个错误的决定。此外,请注意,JDK 中没有IdentityHashSet实现。可能这样的集合很少使用,它的存在在 JDK 中可能会造成比好处更多的伤害,吸引新手程序员误用。

依赖注入

在上一章中,我们简要地讨论了依赖注入DI)。现在我们将更深入地探讨它。

对象通常不会独立工作。大多数时候,实现依赖于其他类的服务。当我们想要向控制台写入内容时,我们使用System类。当我们管理猜测表时,我们需要Color对象和ColorManager

在向控制台写入的情况下,我们可能没有意识到这种依赖,因为作为 JDK 类库的一部分的类始终可用,我们只需要编写System.out.println。在这种情况下,这种依赖已经通过代码连接。除非我们更改代码,否则我们无法将输出发送到其他地方。这并不太灵活,在许多情况下,我们需要一个可以与不同输出、不同颜色管理器或不同服务(我们的代码依赖于这些服务)一起工作的解决方案。为此,第一步是有一个字段,该字段包含一个引用对象,该对象为我们类提供服务。在输出方面,该字段的类型可以是OutputStream类型。接下来,更有趣的一步是如何获取这个字段的值。

其中一种解决方案是使用 DI。在这种方法中,一些外部代码准备依赖关系并将它们注入到对象中。当首次调用类的方法时,所有依赖关系都已填充并准备好使用。

在这个结构中,我们有四个不同的参与者:

  • 客户端对象是在过程中获取注入服务对象的那个对象

  • 服务对象或对象被注入到客户端对象中

  • 注入器是执行注入的代码

  • 接口定义了客户端需要的服务

如果我们将服务对象的创建逻辑从客户端代码中移除,代码就会变得更短、更干净。客户端类的实际能力几乎不应该包括服务对象的创建。例如,一个Game类包含一个Table实例,但游戏并不负责创建Table。它被赋予它来与之工作,就像我们在现实生活中所模拟的那样。

服务对象的创建有时就像使用new运算符一样简单。有时服务对象也依赖于其他服务对象,因此也在依赖注入过程中充当客户端。在这种情况下,服务对象的创建可能需要很多行代码。依赖的结构可以用声明式的方式表达,描述了哪些服务对象需要哪些其他服务对象,以及要使用哪些服务接口的实现。依赖注入注入器与这种声明性描述一起工作。当需要这样一个对象,该对象本身需要其他服务对象时,注入器会按照匹配声明性描述的实现顺序创建服务实例。注入器发现所有依赖关系,并创建依赖关系的传递闭包图。

需要的依赖项的声明性描述可以是 XML,或者为依赖注入特别开发的语言,甚至可以使用特别设计的 Java 流畅 API(blog.jooq.org/2012/01/05/the-java-fluent-api-designer-crash-course/)。XML 最初用于DI注入器。后来,基于Groovy领域特定语言martinfowler.com/books/dsl.html)和 Java 流畅 API 方法出现。我们将只使用最后一种,因为它是最现代的,我们将使用SpringGuice****DI容器,因为它们是最知名的注入器实现。

游戏实现

没有示例的集合是无聊的。幸运的是,我们有一个游戏,我们在其中使用了一些集合类,以及我们将在本章中检查的其他方面。

ColorManager

我们从ColorManager类的实现中跳入了充满集合类的泳池。现在让我们回顾一下对我们来说有趣的类部分——构造函数:

final protected int nrColors; 
 final protected Map<Color, Color> successor = new HashMap<>(); 
 final private Color first; 

 public ColorManager(int nrColors) { 
     this.nrColors = nrColors; 
     first = new Color(); 
     Color previousColor = first; 

     for (int i = 1; i < nrColors; i++) { 
         final Color thisColor = new Color(); 
         successor.put(previousColor, thisColor); 
         previousColor = thisColor; 
     } 
     successor.put(previousColor, Color.none); 
 }

我们将使用HashMap来保持颜色的有序列表。起初,选择HashMap看起来很奇怪。确实如此,在编写ColorManager代码时,我也考虑了List,这似乎是一个更明显的选择。当我们有一个List<Color> colors变量时,那么nextColor方法就像这样:

public Color nextColor(Color color) { 
     if (color == Color.none) 
         return null; 
     else 
         return colors.get(colors.indexOf(color) + 1); 
 }

构造函数将非常简单,如下所示的一段代码:

final List<Color> colors = new ArrayList<>(); 

     public ColorManager(int nrColors) { 
         this.nrColors = nrColors; 
         for (int i = 0; i < nrColors; i++) { 
             colors.add(new Color()); 
         } 
         colors.add(Color.none); 
     } 

     public Color firstColor() { 
         return colors.get(0); 
     }

我为什么选择了更复杂且不明显的解决方案?原因在于性能。当调用nextColor方法时,列表实现首先找到元素,检查列表中的所有元素,然后获取下一个元素。所需时间是颜色数量的比例。当颜色数量增加时,获取下一个颜色所需的时间也会增加,仅仅是为了得到一个颜色。

同时,如果我们关注的是不是来自我们想要解决的问题的口头表达的数据结构(按排序顺序获取颜色),而是关注我们想要实现的实际方法nextColor(Color),那么我们很容易得出结论,Map更合理。我们需要的正是Map:有一个元素我们想要另一个与之相关的元素。键和值也是Color。使用HashMap获取下一个元素是常数时间。这种实现可能比基于ArrayList的实现更快。

问题在于它可能只是更快一点。当你考虑重构代码以获得更好的性能时,你的决定应该始终基于测量。如果你实现了一个你认为更快的代码,实践表明,你会失败。在最好的情况下,你将优化代码以使其非常快,并在应用程序服务器设置期间运行。同时,优化后的代码通常可读性较差。有所得必有所失。

优化永远不应该过早进行。首先编写易于阅读的代码。然后,评估性能,如果性能存在问题,那么分析执行情况,并在对整体性能影响最大的地方优化代码。微优化不会有所帮助。

我在选择了HashMap实现而不是List时是否过早进行了优化?如果我真的使用了List来实现代码然后进行了重构,那么是的。如果我在考虑List解决方案时,后来想到Map解决方案更好,而没有先进行编码,那么我没有。经过多年的经验,这样的考虑会更容易,因为你也会经历。

类颜色

我们已经查看过类代码的代码,它是世界上最简单的类。实际上,正如 GitHub 仓库(github.com/j9be/chapter04github.com/PacktPublishing/Java-9-Programming-By-Example/tree/master/Chapter04)中所示,代码要复杂一些:

package packt.java9.by.example.mastermind; 

 /** 
  * Represents a color in the MasterMind table. 
  */ 
 public class Color { 
     /** 
      * A special object that represents a 
      * value that is not a valid color. 
      */ 
     public static final Color none = new Color(); 
 }

我们有一个名为none的特殊颜色常量,我们用它来表示一个类型为Color但不是有效Color的引用。在专业开发中,我们长期以来一直使用null值来表示无效引用,因为我们具有向后兼容性,所以我们仍然使用它。然而,建议尽可能避免使用null引用。

托尼·霍尔(en.wikipedia.org/wiki/Tony_Hoare),他在 1965 年发明了null引用,曾承认这是一个代价数十亿美元的 IT 行业的错误。

null值的问题在于它将控制权从类中移走,从而打开了封装。如果某个方法在某种情况下返回null,调用者必须严格检查其空值并根据该值采取行动。例如,你不能在null引用上调用方法,也不能访问任何字段。如果方法返回,一个特殊对象实例,这些问题就不那么严重了。如果调用者忘记检查特殊返回值并在特殊实例上调用方法,被调用的方法仍然有可能实现一些异常或错误处理。类具有封装控制权,可以抛出一个特殊异常,这可能提供更多关于由调用者未检查特殊值而导致的程序错误的信息。

JavaDoc 和代码注释

我们在这里之前展示的内容和列表之间还有一个区别。这是代码注释。代码注释是程序的一部分,被编译器忽略或过滤掉。这些注释仅针对维护或使用代码的人。

在 Java 中,有两种不同的注释。在 /**/ 之间的代码是注释。注释的开始和结束不需要在同一行上。另一种类型的注释以 // 字符开始,并在行尾结束。

要对代码进行文档化,可以使用 JavaDoc 工具。JavaDoc 是一种特殊的工具,它读取源代码并提取有关类、方法、字段和其他实体的 HTML 文档,这些实体的注释以 /** 字符开始。文档将以格式化的方式包含 JavaDoc 注释,以及从程序代码中提取的信息。

文档还以在线帮助的形式出现在 IDE 中,当你将鼠标移至方法调用或类名上时,如果存在的话。JavaDoc 注释可以包含 HTML 代码,但通常不应这样做。如果确实需要,你可以使用 <p> 标签来开始一个新段落,或者使用 <pre> 标签将一些预格式化的代码示例包含到文档中,但不要添加更多内容。文档应尽可能简短,并包含尽可能少的格式。

JavaDoc 文档中会出现特殊的标签。当你开始以 /** 输入 JavaDoc 并按下 Enter 键时,IDE 会预先填充这些标签。这些标签位于注释中,并以 @ 字符开始。有一组预定义的标签:@author@version@param@return@exception@see@since@serial@deprecated。最重要的标签是 @param@return。它们用于描述方法参数和返回值。虽然我们还没有到达那里,但让我们提前看看 Guesser 类中的 guessMatch 方法。

/** 
  * A guess matches if all rows in the table matches the guess. 
  * 
  * @param guess to match against the rows 
  * @return true if all rows match 
  */ 
 protected boolean guessMatch(Color[] guess) { 
     for (Row row : table.rows) { 
         if (!row.guessMatches(guess)) { 
             return false; 
         } 
     } 
     return true; 
 }

参数的名称由 IDE 自动生成。当你创建文档时,写一些有意义的、非同义反复的内容。很多时候,新手程序员会迫切地想要编写 JavaDoc,并认为必须对参数进行说明。他们创建的文档如下:

* @param guess is the guess

真的吗?我绝不会猜到。如果你不知道在那里写什么来文档化参数,可能发生的情况是你选择了参数的名称非常优秀。我们前面示例的文档将如下所示:

图片

专注于方法、类和接口的功能以及如何使用它们。不要解释其内部工作原理。JavaDoc 不是解释算法或编码的地方。它用于帮助使用代码。然而,如果有人偶然解释了方法的工作原理,这并不是灾难。注释可以很容易地被删除。

然而,有一个注释比没有还糟糕:过时的文档,它已经不再有效。当元素的合约发生变化时,但文档没有跟随变化,并且误导了想要调用方法、接口或类的用户,那么他们将会遇到严重的错误,并且会毫无头绪。

从现在起,JavaDoc 注释将不会在打印中列出以节省树木,以及电子书版本中的电子,但它们在存储库中,并且可以被检查。

Row

现在,如果我们需要有一个ColorManager,我们就有Colors 和实例。这是在Rows 中存储Colors 的时候。Row类稍微长一点,但并不复杂。

package packt.java9.by.example.mastermind; 

 import java.util.Arrays; 

 public class Row { 
     final Color[] positions; 
     private int matchedPositions; 
     private int matchedColors;

Row包含三个字段。一个是positions数组。数组的每个元素都是一个ColormatchedPositions是匹配的位置数,matchedColors是匹配隐藏行中的颜色但不在位置上的颜色数。

    public static final Row none = new Row(Guesser.none);

none是一个包含特殊Row实例的常量,我们将在需要使用null的地方使用它。构造函数获取一个数组,该数组应该位于行中。

  public Row(Color[] positions) { 
         this.positions = Arrays.copyOf(positions, positions.length); 
     }

构造函数会复制原始数组。这是一段重要的代码,我们将稍作探讨。让我们再次强调,Java 通过值传递参数。这意味着当你将一个数组传递给一个方法时,你传递的是包含该数组的变量的值。然而,在 Java 中,数组就像任何其他东西一样是一个对象(除了像int这样的原始类型)。因此,变量包含的是指向一个恰好是数组的对象的引用。如果你更改数组的元素,实际上你更改的是原始数组的元素。当参数传递时,数组引用被复制,但数组本身及其元素则不是。

java.util.Arrays实用工具类提供了许多有用的工具。我们可以在 Java 中轻松地编码数组复制,但为什么要重新发明轮子呢?除此之外,数组是内存中的连续区域,可以非常有效地使用低级机器代码从一个地方复制到另一个地方。我们调用的copyOf方法调用System.arraycopy方法,这是一个本地方法,因此执行本地代码。

注意,没有保证Arrays.copyOf调用本地实现,并且在大数组的情况下这将非常快。我正在测试和调试的版本就是这样做的,我们可以假设一个好的 JDK 会做类似的事情,既有效又快。

在我们复制数组之后,如果调用者修改了传递给构造函数的数组,这不会成为问题。类将有一个指向包含相同元素的副本的引用。然而,请注意,如果调用者更改数组中存储的任何对象(不是数组中的引用,而是被数组元素引用的对象),则相同的对象将被修改。Arrays.copyOf不会复制数组引用的对象,只会复制数组元素。

行与颜色一起创建,因此我们为Color数组使用了final字段。然而,当行被创建时,无法知道匹配项。一位玩家创建Row,之后另一位玩家会告知两个int值。但我们没有为这两个值创建两个 setter,因为在游戏中它们总是同时定义的。

  public void setMatch(int matchedPositions, int matchedColors) { 
         if (matchedColors + matchedPositions > positions.length) { 
             throw new IllegalArgumentException( 
                     "Number of matches can not be more that the position."); 
         } 
         this.matchedColors = matchedColors; 
         this.matchedPositions = matchedPositions; 
     }

setMatch方法不仅设置值,还检查这些值是否一致。这两个值的和不能超过列数。这个检查确保调用者,即使用Row类 API 的人,不会不一致地使用它。如果这个 API 只从我们的代码内部使用,这个断言不应该成为代码的一部分。在这种情况下,良好的编码风格将确保该方法在单元测试中不会被不一致地调用。当我们创建超出我们控制的 API 时,我们应该检查使用是否一致。如果不这样做,我们的代码在不一致使用时可能会表现得非常奇怪。当调用者设置的匹配值与任何可能的猜测都不匹配时,游戏可能永远无法结束,调用者可能很难弄清楚发生了什么。这种弄清楚可能需要调试我们的代码执行。

如果在这种情况下抛出异常,程序将在出现错误的地方停止。没有必要调试库。

public boolean guessMatches(Color[] guess) { 
     return nrMatchingColors(guess) == matchedColors && 
             nrMatchingPositions(guess) == matchedPositions; 
 }

下一个方法决定给定的猜测是否与实际行匹配。此方法检查行中猜测的答案是否在当前猜测是隐藏行的情况下有效。实现相当简短且简单。如果猜测匹配的颜色数量和位置匹配的数量与行中给出的数量相同,则猜测匹配行。不要害羞地编写简短的方法。不要认为本质上只包含一个语句的一行方法是没用的。无论我们在哪里使用这个方法,我们也可以写出紧随返回语句之后的表达式,但我们没有这样做,原因有两个。第一个也是最重要的原因是,决定一行是否匹配猜测的算法属于Row类的实现。如果实现发生变化,代码需要更改的唯一位置就是这里。另一个原因也很重要,那就是可读性。在我们的代码库中,我们从abstract class Guesser调用这个方法。它包含一个if语句,其中包含以下表达式:

if (!row.guessMatches(guess)) {

以下方式是否更易于阅读:

if( !(nrMatchingColors(guess) == matchedColors && nrMatchingPositions(guess) == matchedPositions)) {

我确信大多数程序员更容易理解第一个版本的目的。我甚至建议实现doesNotMatchGuess方法来进一步提高代码的可读性。

  public int nrMatchingColors(Color[] guess) { 
         int count = 0; 
         for (int i = 0; i < guess.length; i++) { 
             for (int j = 0; j < positions.length; j++) { 
                 if (i != j && guess[i] == positions[j]) { 
                     count++; 
                 } 
             } 
         } 
         return count; 
     }

匹配的颜色数量是同时出现在行和猜测中,但不在同一位置的颜色数量。在隐藏行中,颜色不能重复出现的情况下,这个定义以及我们如何计算它是相当简单和明确的。如果隐藏行中可能重复出现颜色,此实现将计算猜测中该颜色的所有出现次数,就像它在隐藏行中出现的次数一样。例如,如果我们有一个隐藏的RRGB行,猜测是bYRR,计算将得出 4。玩家如何计数的问题取决于玩家之间的协议。重要的是他们使用相同的算法,在我们的情况下应该是真实的,因为我们将会让程序为两个玩家进行游戏。由于我们将自己编写代码,我们可以相信它不会作弊。

public int nrMatchingPositions(Color[] guess) { 
         int count = 0; 
         for (int i = 0; i < guess.length; i++) { 
             if (guess[i] == positions[i]) { 
                 count++; 
             } 
         } 
         return count; 
     }

计算颜色数量是否正确,以及它们所在的位置是否正确,甚至更加简单。

public int nrOfColumns() { 
     return positions.length; 
 }

此方法告诉Row中的列数。这个方法在控制整个游戏流程的Game类中是必需的。由于这个类与Row位于同一个包中,它可以访问字段位置。我创建了代码来获取列数作为row.positions.length。但是,第二天我阅读代码时告诉自己:这太难看了,难以阅读!我这里感兴趣的不是一些神秘的位置长度;而是列数。列数是Row类的责任,而不是其他任何类的业务。如果我开始将位置存储在一个没有length(它有size方法)的List中,那么这将是Row的唯一责任,不应该影响任何其他代码。因此,我创建了nrOfColumns方法来改进代码。

剩余的类包含一些更简单的方法,这些方法仅用于显示游戏,而不是用于算法进行游戏:

  public int nrColumns() { 
         return positions.length; 
     } 

     public Color position(int i) { 
         return positions[i]; 
     } 

     public int matchedPositions() { 
         return matchedPositions; 
     } 

     public int matchedColors() { 
         return matchedColors; 
     } 
 }

如果你是一个纯粹主义者,你可以将这些方法封装在一个名为OutputPrint的内部类中,并通过在Row类中创建的该类的最终实例来调用它们。也有可能将这些字段的可见性从private改为protected,并在一个可以由现有的Row实例化并实现这些方法的PrintableRow中实现这些方法。

PrintableRow的第一个版本将如下所示:

public class PrintableRow extends Row { 
     public PrintableRow(Row row) { 
         super(row.positions); 
         super.setMatch(row.matchedPositions,row.matchedColors); 
     } 
 // the methods are deleted from the print ... 
 }

这些方法与前面的打印方法完全相同;它们是通过 IDE 重构支持从一类剪切粘贴,或者更确切地说,是从一个类移动到另一个类。

当你编写代码时,请永远不要使用复制粘贴。然而,你可以使用剪切粘贴来移动代码片段。危险在于复制粘贴的使用。许多开发者声称他们实际使用的复制粘贴并不是复制粘贴编程。他们的理由是,他们修改了粘贴的代码,以至于它与原始代码几乎没有关系。

真的吗?在这种情况下,为什么你在修改代码时需要复制的代码?为什么不从头开始呢?那是因为如果你使用 IDE 的复制粘贴功能,那么无论如何,你都会进行复制粘贴编程。

PrintableRow类相当整洁,将输出关注点与核心功能分离。当你需要一个实例时,如果你已经手头有一个Row实例,这并不是问题。构造函数本质上会克隆原始类并返回一个可打印版本。让我烦恼的是克隆的实现。构造函数中的代码调用父构造函数,然后是一个方法,所有这些都与Row类的原始功能有关。它们与PrintableRow实现的打印功能无关。这个功能实际上属于Row类。我们应该创建一个受保护的构造函数来完成克隆:

protected Row(Row cloneFrom) { 
     this(cloneFrom.positions); 
     setMatch(cloneFrom.matchedPositions, cloneFrom.matchedColors); 
 }

PrintableRow的构造函数应该简单地调用super(row),然后就是它了。

代码永远不会完成,也永远不会完美。在专业环境中,程序员很多时候会在代码足够好时停止对代码的打磨。没有不能变得更好的代码,但是有截止日期。软件必须传递给测试人员和用户,并用于帮助经济发展。毕竟,这是专业开发者的最终目标:拥有支持业务的代码。一个永远不会运行的代码毫无价值。

我不希望你认为我提供的示例一开始就是完美的。原因是(你仔细阅读了吗?)因为它们并不完美。正如我所说,代码永远不会完美。

当我第一次创建 Row 时,它在一个内部类中包含了打印方法。我不喜欢这样。代码很糟糕。所以,我决定将功能移动到Row类。然而,我仍然不喜欢这个解决方案。然后,我上床睡觉,工作,几天后再次回到它。我前一天无法创造的东西现在看起来很明显——这些方法必须移动到子类。

现在又遇到了另一个困境。我应该展示这个最终解决方案,还是应该展示不同的版本?在某些情况下,我只会展示最终版本。在其他情况下,比如这个,我们可以从开发步骤中学到东西。在这些情况下,我不仅展示代码,还展示其如何创建的部分演变。如果你想看到那些我不敢发布的,看看 Git 历史。我承认,有时我创建的代码甚至让我第二天都感到尴尬。

表格

Table是一个只有一个非常简单功能的简单类。

public class Table { 
     final ColorManager manager; 
     final int nrColumns; 
     final List<Row> rows; 

     public Table(int nrColumns, ColorManager manager) { 
         this.nrColumns = nrColumns; 
         this.rows = new LinkedList<>(); 
         this.manager = manager; 
     } 

     public void addRow(Row row) { 
         rows.add(row); 
     } 
 }

有一点需要提及,这并不是什么新内容,但值得重复。rows 变量被声明为final,并在构造函数中获取其值。这是一个List<Row>类型的变量。它是final的事实意味着在其生命周期内将保持相同的列表对象。列表的长度、成员和其他特性可能会改变,也可能改变。我们将向这个列表中添加新的行。final对象变量引用一个对象,但这并不保证对象本身是不可变的。只有变量本身是不变的。

当你进行代码审查并向你的同事解释一个类的作用时,如果你发现自己多次以“这个类非常简单”开始解释,这意味着代码是好的。

嗯,在其他方面可能不正确,但这个类的粒度看起来是合适的。

Guesser

Guesser及其子类UniqueGuesserGeneralGuesser是程序中最有趣的类。它们实际上执行了游戏核心的任务。给定一个带有隐藏行的Table,猜测器必须创建越来越新的猜测。

为了做到这一点,Guesser需要在创建时获取一个Table。这作为构造函数参数传递。它应该实现的方法只有一个,即guess,它根据表格和其实际状态返回一个新的猜测。

由于我们想要实现一个假设隐藏行中所有颜色都不同的猜测器,以及一个不做出这种假设的猜测器,我们将实现三个类。Guesser是一个抽象类,它只实现了与假设无关的逻辑。这些方法将由实际的实现继承:UniqueGuesserGeneralGuesser

让我们来看看这个类的实际代码:

package packt.java9.by.example.mastermind; 

 public abstract class Guesser { 
     protected final Table table; 
     private final ColorManager manager; 
     public Guesser(Table table) { 
         this.table = table; 
         this.lastGuess = new Color[table.nrColumns]; 
         this.manager = table.manager; 
     }

猜测器的状态是它最后做出的猜测。尽管这位于表格的最后一行,但它更多的是猜测器的一个内部问题。猜测器拥有所有可能的猜测,一个接一个;lastGuess是它上次停止的地方,当它再次被调用时,它应该从那里继续。

    abstract protected void setFirstGuess();

设置第一个猜测很大程度上取决于颜色唯一性的假设。如果隐藏行中没有重复的颜色(至少在我们的实现中是这样),第一个猜测不应该包含重复的颜色,而GeneralGuesser可以随时猜测,甚至可以将所有颜色都猜测为相同的firstGuess

    protected final Color[] lastGuess; 
    public static final Color[] none = new Color[]{Color.none};

再次强调,这个类中的none只是一个我们试图在需要返回一个指向Guess的引用但不是真正的猜测时使用的对象。

  protected Color[] nextGuess() { 
         if (lastGuess[0] == null) { 
             setFirstGuess(); 
             return lastGuess; 
         } else { 
             return nextNonFirstGuess(); 
         } 
     }

nextGuess方法是一个内部方法,它生成下一个猜测,就像我们按顺序排列可能的猜测一样。它不对Table进行检查;它只是几乎不加思考地生成下一个猜测。我们如何进行第一次猜测以及如何进行连续猜测的实现方式不同。因此,我们将这些算法实现为不同的方法,并从这里调用它们。

nextNonFirstGuess方法代表在猜测不是第一个的特殊情况下的下一个猜测:

  private Color[] nextNonFirstGuess() { 
         int i = 0; 
         boolean guessFound = false; 
         while (i < table.nrColumns && !guessFound) { 
             if (manager.thereIsNextColor(lastGuess[i])) { 
                 lastGuess[i] = manager.nextColor(lastGuess[i]); 
                 guessFound = true; 
             } else { 
                 lastGuess[i] = manager.firstColor(); 
                 i++; 
             } 
         } 
         if (guessFound) { 
             return lastGuess; 
         } else { 
             return none; 
         } 
     }

回顾一下几页之前我们详细说明算法是如何工作的。我们做出了这样的陈述:这种工作方式非常类似于我们用十进制数计数的方式。到现在为止,你已经有了足够的 Java 知识和编程技能来理解这个方法做了什么。更有趣的是知道为什么它被这样编码。

提示:像往常一样,为了可读性。

有一种诱惑要消除guessFound变量。当我们找到幸运的猜测时,从方法中间返回不是更简单吗?如果我们这样做,我们就不需要在返回none值之前检查guessFound的值。如果我们从循环中间返回,代码就不会到达那里。

是的,写起来会更简单。但是,我们编写代码是为了可读性,而不是为了可写性。是的,但代码越少,可读性越好。但在这个情况下不是这样!从循环中返回会降低可读性。更不用说,return语句在方法的不同执行阶段散布开来。

private Color[] nextNonFirstGuess() { 
     int i = 0; 
     while (i < table.nrColumns) { 
         if (manager.thereIsNextColor(lastGuess[i])) { 
             lastGuess[i] = manager.nextColor(lastGuess[i]); 
             return lastGuess; 
         } else { 
             lastGuess[i] = manager.firstColor(); 
             i++; 
         } 
     } 
     return none; 
 }

当有人以这种方式编写优化过的代码时,它就像一个蹒跚学步的孩子第一次走路后自豪地看着妈妈。好吧,男孩/女孩,你很棒。现在继续走吧。当你成为邮递员时,走路就会变得无聊。那将是你的职业。所以,放下骄傲,写些无聊的代码。专业人士写无聊的代码。难道不会慢吗?

不!它不会慢。首先,只有在性能分析器证明代码不符合业务需求之前,它才不慢。如果它符合要求,那么它就足够快,不管它有多慢。只要对业务来说没问题,慢就是好的。毕竟,即时编译器应该有一些任务来优化代码的运行。

下一个方法检查猜测是否与之前的猜测及其在Table上的结果匹配:

    private boolean guessMatch(Color[] guess) { 
         for (Row row : table.rows) { 
             if (!row.guessMatches(guess)) { 
                 return false; 
             } 
         } 
         return true; 
     } 
     private boolean guessDoesNotMatch(Color[] guess) { 
         return !guessMatch(guess); 
     }

由于我们在Row类中已经实现了猜测匹配,我们只需要对表中的每一行调用该方法。如果所有行都匹配,那么这个猜测对表来说就是好的。如果之前的任何猜测不匹配,那么这个猜测就失败了。

在检查匹配的否定表达式时,我们创建了一个方法的方法的英文版本。

在这种情况下,创建guessDoesNotMatch版本的方法可能就足够了。然而,如果方法没有被否定,代码的逻辑执行会更易读。因此,单独编写guessDoesNotMatch方法更容易出错。因此,我们将实现原始的、易读的版本,并将辅助方法仅仅作为一个否定。

在所有辅助方法之后,我们现在正在实现Guesser的公共方法。

public Row guess() { 
         Color[] guess = nextGuess(); 
         while (guess != none && guessDoesNotMatch(guess)) { 
             guess = nextGuess(); 
         } 
         if (guess == none) { 
             return Row.none; 
         } else { 
             return new Row(guess); 
         } 
     } 

 }

它只是重复地取nextGuess,直到找到一个与隐藏行匹配的,或者没有更多的猜测。如果找到一个合适的猜测,它将封装成一个Row对象并返回它,以便稍后可以被Game对象添加到Table中。

UniqueGuesser

UniqueGuesser类必须实现setFirstGuess(所有扩展抽象类的具体类都应该实现父类的抽象方法)并且它可以并且会覆盖受保护的nextGuess方法:

package packt.java9.by.example.mastermind; 

 import java.util.HashSet; 
 import java.util.Set; 

 public class UniqueGuesser extends Guesser { 

     public UniqueGuesser(Table table) { 
         super(table); 
     } 

     @Override 
     protected void setFirstGuess() { 
         int i = lastGuess.length-1; 
         for (Color color = table.manager.firstColor(); 
              i >= 0; 
              color = table.manager.nextColor(color)) { 
             lastGuess[i--] = color; 
         } 
     }

setFirstGuess方法选择第一个猜测,以便任何可能的颜色变化在第一个之后按照算法依次创建猜测。

辅助的isNotUnique方法如果猜测包含重复的颜色则返回 true。看到有多少并不有趣。如果所有颜色都相同,或者只有一种颜色出现两次,那就没关系。猜测不是唯一的,也不适合我们的猜测器。这个方法就是这样的判断。

   private boolean isNotUnique(Color[] guess) { 
         final Set<Color> alreadyPresent = new HashSet<>(); 
         for (Color color : guess) { 
             if (alreadyPresent.contains(color)) { 
                 return true; 
             } 
             alreadyPresent.add(color); 
         } 
         return false; 
     }

为了做到这一点,它使用了一个Set,并且每次在guess数组中找到新的颜色时,颜色都会被存储在集合中。如果我们找到集合中已经包含的颜色,这意味着颜色之前已经被使用过;猜测不是唯一的。

     @Override 
     protected Color[] nextGuess() { 
         Color[] guess = super.nextGuess(); 
         while (isNotUnique(guess)) { 
             guess = super.nextGuess(); 
         } 
         return guess; 
     } 

重写的nextGuess方法很简单。它要求 super nextGuess实现进行猜测,但丢弃它不喜欢的那些猜测。

GeneralGuesser

GeneralGuesser类也必须实现构造函数和setFirstGuess,但通常就是这样。

package packt.java9.by.example.mastermind; 

public class GeneralGuesser extends Guesser { 

     public GeneralGuesser(Table table) { super(table); } 

     @Override 
     protected void setFirstGuess() { 
         int i = 0; 
         for (Color color = table.manager.firstColor();  
                                    i < lastGuess.length; ) { 
             lastGuess[i++] = color; 
         } 
     } 

 }

设置lastGuess只是将第一个颜色放在所有列上。猜测再简单不过了。其他所有内容都是继承自abstract class Guesser

游戏类

Game类的一个实例包含一个Row,它持有秘密颜色值,并且还包含一个Table。当有新的猜测时,Game实例将猜测存储到Table中,并设置与秘密行匹配的位置和颜色数。

package packt.java9.by.example.mastermind; 

 public class Game { 

     final Table table; 
     final private Row secretRow; 
     boolean finished = false; 

     public Game(Table table, Color[] secret ) { 
         this.table = table; 
         this.secretRow = new Row(secret); 
     } 

     public void addNewGuess(Row row) { 
         if( isFinished()){ 
             throw new IllegalArgumentException( 
                        "You can not guess on a finished game."); 
         } 
         final int positionMatch = secretRow. 
                           nrMatchingPositions(row.positions); 
         final int colorMatch = secretRow. 
                           nrMatchingColors(row.positions); 
         row.setMatch(positionMatch, colorMatch); 
         table.addRow(row); 
         if( positionMatch == row.nrOfColumns() ){ 
             finished = true; 
         } 
     } 

     public boolean isFinished() { 
         return finished; 
     } 
 }

想想我之前写的关于简短方法的内容,当你从 GitHub 下载代码来玩的时候,尽量让它看起来更易读。也许,你可以创建并使用一个名为boolean itWasAWinningGuess(int positionMatch)的方法。

创建集成测试

我们在上一章中创建了单元测试,并且本章中类的实现功能也有单元测试。我们只是不会在这里打印这些单元测试。而不是列出单元测试,我们将查看一个集成测试。

集成测试需要调用许多协同工作的类。它们检查整个应用程序或至少是应用程序的更大部分是否能够提供功能,而不是关注单个单元。它们被称为集成测试,因为它们测试了类之间的集成。单独的类都是好的。它们不应该有任何问题,因为它们已经被单元测试验证过了。集成关注的是它们如何协同工作。

如果我们要测试Game类,我们可能必须创建模拟其他Game类行为的模拟,或者我们只需编写一个集成测试。技术上,集成测试与单元测试非常相似。很多时候,完全相同的 JUnit 框架被用来执行集成测试。这个游戏的集成测试就是这样做的。

然而,构建工具需要配置为仅在需要时执行集成测试。通常,集成测试的执行需要更多的时间,有时还需要资源,例如可能不在每个开发者的桌面上都可用的外部数据库。单元测试每次编译应用程序时都会运行,因此它们必须很快。为了区分单元测试和集成测试,有不同的技术和配置选项,但没有像 Maven(后来被 Gradle 采用)引入的目录结构那样更或更实际的规范。

在我们的情况下,集成测试不需要任何额外的资源,并且运行时间也不会很长。它从头到尾玩一整局游戏,并且扮演着两个玩家的角色。这非常像某个人和自己下棋,走一步然后翻转棋盘。

这段代码的目标有两个。一方面,我们想看看代码能否运行并玩一整局游戏。如果游戏结束,那就足够了。这是一个非常弱的断言,而真正的集成测试会执行很多断言(尽管一个测试只测试一个断言)。我们将关注另一个目标——带来一些乐趣,并在控制台上以文本格式可视化游戏,这样读者就不会感到无聊。

为了做到这一点,我们将创建一个实用工具类,它会在运行时打印颜色并给Color实例分配字母。这就是PrettyPrintRow类。在这个类中存在一些限制,我们将在查看代码后讨论。可以说,这段代码在这里只是为了演示不应该做什么,为下一章建立一些推理,以及为什么我们需要重构在这一章中创建的代码。

package packt.java9.by.example.mastermind; 

 import java.util.HashMap; 
 import java.util.Map; 

 public class PrettyPrintRow { 

     private static final Map<Color, Character> 
             letterMapping = new HashMap<>(); 
     private static final String letters = "RGBYWb"; 
     private static int counter = 0; 

     private static char colorToChar(Color color) { 
         if (!letterMapping.containsKey(color)) { 
             letterMapping.put(color, letters.charAt(counter)); 
             counter++; 

         } 
         return letterMapping.get(color); 
     }

这是这个类的心脏。当要打印颜色时,它会分配一个字母,除非它已经有一个了。由于包含每个正在 JVM 中运行的游戏的分配的Map将使用相同的映射,因此会启动一个新的Game。它分配新的Color,很快就会用完我们在String常量中分配的六个字符。

如果Game实例是并行运行的,那么我们遇到的麻烦就更大了。这个类根本不是线程安全的。如果有两个线程同时调用同一个Color实例的colorToChar方法(这不太可能,因为每个Game都使用自己的颜色,但请注意,编程中的“不太可能”非常类似于墓碑上著名的最后遗言引用),那么两个线程可能会同时看到没有字母分配给颜色,并且两者都会分配字母(根据运气可能是相同的字母或两个不同的字母)并增加计数器一次或两次。至少,我们可以说的是,执行是非确定性的。

你可能还记得我说违反哈希契约是继多线程问题之后最难找到的第二个 bug。这样的非确定性代码正是这样的:一个多线程问题。找到最难的 bug 没有奖励。当应用程序无法运行,并且一个 bug 影响了生产系统数小时或数天时,任何商人都会不高兴,而且在你找到 bug 后,他们也不会感到惊讶。这可能是一个智力挑战,但真正的价值不是最初就创建 bug。

总结来说,这段代码在 JVM 中只能由一个线程使用一次。对于本章来说,虽然它是一个有异味且令人羞愧的代码,但它将是下一章的好例子,在下一章中,我们将看到如何重构应用程序,使其不需要这样的黑客手段来打印颜色。

代码异味是由 Kent Back 提出的术语,根据 Martin Fowler(martinfowler.com/bliki/CodeSmell.html)。这意味着某些代码看起来不好,也不明显不好,但某些结构会让开发者感觉到它可能不是好的。正如网页上定义的那样,“代码异味是通常对应于系统中更深层次问题的表面迹象。”这个术语被广泛接受并在过去 10 年的软件开发中被使用。

代码的其余部分是简单明了的:

   public static String pprint(Row row) { 
         String string = ""; 
         PrintableRow pRow = new PrintableRow(row); 
         for (int i = 0; i < pRow.nrOfColumns(); i++) { 
             string += colorToChar(pRow.position(i)); 
         } 
         string += " "; 
         string += pRow.matchedPositions(); 
         string += "/"; 
         string += pRow.matchedColors(); 
         return string; 
     }

集成测试,或者更确切地说,演示代码(因为它除了运行不抛出异常之外不包含任何断言),定义了六种颜色和四列。这是原始游戏的大小。它创建了一个颜色管理器,然后创建了一个表格和一个秘密。秘密可以是六种可用颜色中的任何一种随机颜色选择(在 GitHub 上可用的UniqueGuesserTest单元测试中测试了 360 种不同的可能性)。正如我们所知,Guesser实现从颜色集的一端开始,并系统地创建新的猜测,我们希望设置一个秘密,这样它就能猜到最后一个。这并不是因为我们邪恶,而是因为我们想看到我们的代码真的能工作。

代码的目录结构与我们在使用 Maven 构建工具时的结构非常相似,如下面的屏幕截图所示,该截图是在 Windows 机器上创建的:

图片

源代码位于src目录下,maintest源代码文件被分别放置在两个子目录结构中。当我们使用 Gradle 时,编译文件将在build目录下生成。集成测试类的代码如下:

package packt.java9.by.example.mastermind.integration; 

 import org.junit.Assert; 
 import org.junit.Test; 
 import packt.java9.by.example.mastermind.*; 

 public class IntegrationTest { 

     final int nrColors = 6; 
     final int nrColumns = 4; 
     final ColorManager manager = new ColorManager(nrColors); 

     private Color[] createSecret() { 
         Color[] secret = new Color[nrColumns]; 
         int count = 0; 
         Color color = manager.firstColor(); 
         while (count < nrColors - nrColumns) { 
             color = manager.nextColor(color); 
             count++; 
         } 
         for (int i = 0; i < nrColumns; i++) { 
             secret[i] = color; 
             color = manager.nextColor(color); 
         } 
         return secret; 
     } 

     @Test 
     public void testSimpleGame() { 
         Table table = new Table(nrColumns, manager); 
         Color[] secret = createSecret(); 
         System.out.println( 
             PrettyPrintRow.pprint(new Row(secret))); 
         System.out.println(); 
         Game game = new Game(table, secret); 

         Guesser guesser = new UniqueGuesser(table); 
         while (!game.isFinished()) { 
             Row guess = guesser.guess(); 
             if (guess == Row.none) { 
                 Assert.fail(); 
             } 
             game.addNewGuess(guess); 
             System.out.println(PrettyPrintRow.pprint(guess)); 
         } 
     } 
 }

运行测试的最简单方法是直接在 IDE 中启动它。当 IDE 根据构建文件导入项目时,无论是 Maven 的pom.xml还是 Gradle 的build.gradle,IDE 通常会提供一个运行按钮或菜单来启动代码。运行游戏将打印出我们在本章中辛苦编写的一段代码:

RGBY 0/0
GRWb 0/2
YBbW 0/2
BYGR 0/4
RGYB 2/2
RGBY 4/0

概述

在本章中,我们编写了一个桌游:Mastermind。我们不仅编写了游戏模型,还创建了一个可以猜测的算法。我们回顾了一些面向对象的原则,并讨论了为什么模型被创建成这样。在我们创建的游戏模型中,我们将在下一章对其进行完善,同时你学习了 Java 集合、什么是集成测试以及如何创建 JavaDoc。

第五章:扩展游戏 - 并行运行,更快运行

在本章中,我们将扩展 Mastermind 游戏。现在,它不仅可以猜测隐藏的秘密,还可以隐藏柱子。测试代码甚至可以同时做这两件事。它可以与自己玩游戏,只留下编程的乐趣。它不能利用我们今天在笔记本电脑和服务器上拥有的所有处理器。代码是同步运行的,并且只利用单个处理器核心。

我们将修改代码,扩展猜测算法,将猜测分割成子任务,并并行执行代码。在这个过程中,我们将熟悉 Java 并发编程。这将是一个巨大的主题,其中隐藏着许多细微的角落和陷阱。我们将深入研究最重要的细节,这将为你需要并发程序时提供一个坚实的基础。

由于游戏的结果与之前相同,只是更快,我们必须评估“更快”是什么意思。为了做到这一点,我们将利用 Java 9 中引入的新功能:微基准测试工具。

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

  • 进程、线程和纤维的含义

  • Java 中的多线程

  • 多线程编程的问题以及如何避免它们

  • 锁定、同步和阻塞队列

  • 微基准测试

如何使 Mastermind 并行化

旧算法是遍历所有可能的变体,并尝试找到一个与当前表格状态匹配的猜测。假设当前检查的猜测是秘密,我们是否会得到与表格上实际答案相同的答案?如果是,那么当前的猜测可以是秘密,它和其他任何猜测一样好。

一种更复杂的方法可以实现最小-最大算法(en.wikipedia.org/wiki/Minimax)。这个算法不仅得到下一个可能的猜测,还考虑了所有可能的猜测,并选择那个最能缩短游戏结果的猜测。如果一个猜测在最坏情况下可以跟随三个更多的猜测,而另一个只有两个,那么最小-最大算法将选择后者。这对感兴趣的读者来说是一个很好的练习。在六种颜色和四个柱子的游戏中,最小-最大算法最多在 5 步内解决游戏。我们实现的那种简单算法也在 5 步内解决游戏。然而,我们不走那条路。

我们想要的不是只用一个处理器的游戏版本。你该如何将算法转换成并行算法呢?这个问题没有简单的答案。当你有一个算法时,你可以分析算法的计算部分,并尝试找出依赖关系。如果有一些计算 B 需要另一计算 A 的数据,而 AB 的结果,那么很明显,A 只能在 B 准备好的时候执行。如果算法中有不依赖于其他部分结果的计算部分,那么它们可以并行执行。

例如,快速排序有两个主要任务:分区和排序两个部分。很明显,分区必须在开始排序两个已分区的部分之前完成。然而,两个部分的排序任务并不相互依赖,它们可以独立完成。你可以将它们分配给两个不同的处理器。一个会高兴地排序包含较小元素的分区;另一个则会处理较重、较大的元素。

如果你翻到第三章,优化排序 - 使代码专业化,我们在这里以非递归的方式实现了快速排序,你可以看到我们将排序任务安排到栈中,然后通过在 while 循环中从栈中获取元素来执行排序。我们本可以在循环的核心处直接执行排序,但我们可以将任务传递给异步线程来执行,然后返回去处理下一个等待的任务。我们只是不知道如何做。目前还不知道。这就是我们为什么在这里的原因。

处理器、线程和进程是复杂且抽象的概念,它们很难想象。不同的程序员有不同的技巧来想象并行处理和算法。我可以告诉你我是如何做的,但这并不能保证这对你也有效。其他人可能在心中有不同的技巧。实际上,当我写这段话的时候,我实际上从未告诉过任何人。这听起来可能有些幼稚,但无论如何,就这样吧。

当我想象算法时,我会想象人。一个处理器就是一个人。这有助于我克服这样一个事实:处理器可以在一秒钟内进行数十亿次的计算。我实际上想象一个穿着棕色西装的官僚在进行计算。当我为并行算法编写代码时,我会想象他们中的许多人在他们的办公桌后面工作。他们独自工作,不交谈。重要的是他们不互相交谈。他们非常正式。当需要信息交流时,他们会站起来,拿着写有内容的纸,把它带给对方。有时,他们需要纸来完成工作。然后他们会站起来,走到纸所在的地方,取回纸,回到他们的办公桌继续工作。当他们准备好时,他们会回去取回纸。如果他们需要纸时纸不在那里,他们会排队等待,直到有纸的人把纸带来。

它如何帮助理解“大师心智”游戏?

我想象一个负责猜测的老板。办公室墙上有一张表格,上面记录了之前的猜测和每行的结果。老板太懒了,不想想新的猜测,所以他把这个任务交给下级。当一个下级提出一个猜测时,老板会检查这个猜测是否有效。他不信任下级,如果猜测好,他会将其作为官方猜测,放在表格上,并附上结果。

下级将写在小便签上的猜测交给老板,并将它们放在老板桌子上的一个盒子里。老板时不时地看看盒子,如果有便签,他会拿走。如果盒子满了,一个下级想要放一张纸进去,他会停下来等待,直到老板至少拿走一张便签,这样盒子里就有空间放新的便签。如果下级排队将猜测放入盒子,他们都会等待他们的时间。

下级应该协调一致;否则,他们只会提出相同的猜测。每个下级都应该有一个猜测的间隔。例如,第一个应该检查从 1234 到 2134 的猜测,第二个应该检查从 2134 到 3124,以此类推,如果我们用数字表示颜色的话。

这个结构能行得通吗?常识告诉我们它应该可以。然而,在这个例子中,官僚主义者是隐喻,而隐喻并不精确。官僚主义者是人,即使他们看起来不像,也比线程或处理器多得多。他们有时会表现得极其奇怪,做一些正常人类不太经常做的事情。然而,如果这个隐喻能帮助我们想象并行算法是如何工作的,我们仍然可以使用它。

我们可以想象老板去度假了,他没有触摸桌子上堆积如山的纸张。我们可以想象一些工人比其他人生产结果要快得多。由于这只是想象,加速可以高达 1000 倍(想想时间流逝的视频)。想象这些情况可能有助于我们发现很少发生但可能引起问题的特殊行为。由于线程并行工作,许多时候微小的差异可能会极大地影响整体行为。

在某些早期版本中,当我编写并行 Mastermind 算法时,官僚们开始工作,在老板能够将任何猜测放在桌子上之前,他们已经填满了老板的盒子。由于桌子上没有猜测,官僚们只是简单地在他们间隔中找到所有可能的变化,这可能是一个好的猜测。老板通过并行助手的帮助一无所获;他们必须从所有可能的猜测中选择正确的,而猜测者只是闲置。

另一次,当老板正在将一个事先创建的猜测放在桌子上时,官僚们正在检查猜测与表格的一致性。有些官僚惊慌失措地说,如果有人在更改它,就不可能检查一个猜测与表格的一致性。更确切地说,在一个线程中执行的代码,当表格的List被修改时,抛出了ConcurrentModificationException

另一次,我试图避免官僚们过于快速的工作,我限制了他们可以放置包含猜测的纸张的盒子的大小。当老板最终找到秘密,游戏结束时,老板告诉官僚们他们可以回家了。老板通过创建一张小纸条这样做:你可以回家了,把它放在官僚们的桌子上。官僚们做了什么?他们继续等待盒子有空间放纸条!(直到进程被终止。这在 Mac OS 和 Linux 上与在 Windows 的任务管理器中结束进程相当。)

这样的编码错误会发生,为了尽可能避免,我们必须至少做两件事。首先,我们必须理解 Java 多线程是如何工作的;其次,编写尽可能干净的代码。对于第二点,我们将进一步清理代码,然后我们将看看如何将之前描述的并行算法在 Java 中实现,在 JVM 上运行而不是利用官僚们。

重构

当我们完成上一章时,我们已经以优雅且完全面向对象的方式设计了 Mastermind 游戏的类,并且没有违反任何OO原则。是这样吗?荒谬。除了某些琐碎的例子外,没有代码不能变得更好看或更优秀。通常,当我们开发代码并完成编码时,它看起来很棒。它运行正常,所有测试都通过了,文档也准备好了。从专业角度来看,这真的很完美。嗯,已经足够好了。我们还没有测试的大问题是可维护性。改变代码的成本是多少?

这不是一个容易的问题,尤其是因为它不是确定的。改变到什么程度?要对代码进行什么样的修改?当我们最初创建代码时,我们并不知道这一点。如果修改是为了修复一个错误,那么很明显,我们事先并不知道。如果我们知道,我们最初就不会引入这个错误。如果是新功能,那么有可能这个功能是事先预见的。然而,通常情况下并非如此。当开发者试图预测未来,以及程序将需要的未来功能时,他们通常都会失败。了解业务是客户的责任。在专业软件开发中,所需的功能是由业务驱动的。毕竟,这就是专业意味着什么。

即使我们并不确切知道代码的哪个部分需要稍后进行修改,但有些事情可能会给经验丰富的软件开发者提供线索。通常,面向对象的代码比临时编写的代码更容易维护,并且可以识别出代码的某些“异味”。例如,看看下面的代码行:

while (guesser.guess() != Row.none) { 
    while (guesser.nextGuess() != Guesser.none) { 
        public void addNewGuess(Row row) { 
            Color[] guess = super.nextGuess();

我们可能察觉到某种奇怪气味的存在。(每一行都包含在我们在第四章,“思维大师 - 创建游戏”中完成的应用代码中。)guess方法的返回值与Row.none进行比较,Row.none是一个Row。然后,我们将nextGuess的返回值与Guesser.none进行比较,Guesser.none应该是一个Guesser。当我们向某个东西添加一个新的猜测时,我们实际上是在添加一个Row。最后,我们可以意识到nextGuess返回的猜测不是一个具有自己声明类的对象。一个guess只是一个颜色数组。

我们是否应该引入另一层抽象,创建一个Guess类?这将使代码更易于维护吗?还是只会使代码更复杂?通常情况下,代码行数越少,出现错误的可能性就越小。然而,有时缺乏抽象会使代码变得复杂和混乱。在这种情况下是什么情况?我们如何一般性地决定?

你的经验越多,你通过查看代码和敏锐地知道你想要进行的修改就越容易。很多时候,你不会费心使代码更加抽象,而在许多其他时候,你会毫不犹豫地创建新的类。当有疑问时,创建新的类并看看结果。重要的是不要破坏已经存在的功能。你只能在你有足够的单元测试的情况下做到这一点。

当你想引入一些新的功能或修复一个错误,但代码不合适时,你将不得不先修改它。当你修改代码而不改变功能的过程时,这个过程被称为重构。你在一个有限的时间内修改代码的一小部分,然后构建它。如果它能编译并且所有单元测试都运行,那么你可以继续。提示是经常运行构建。这就像在现有道路附近修建一条新道路。每隔几英里,你应该遇到旧线路。如果没有这样做,你最终会在沙漠的中间某个地方,完全错误的方向,而你唯一能做的就是回到起点——你旧的重构代码。这是徒劳的。

不仅安全性建议我们经常运行构建,还有时间限制。重构不会直接带来收入。程序的功能直接与收入挂钩。没有人会为我们无限期的重构工作付费。重构必须在某时停止,这通常不是没有更多重构要做的时候。代码永远不会完美,但你可以在它足够好的时候停止。而且,很多时候,程序员永远不会对代码的质量感到满意,当被某些外部因素(通常称为项目经理)阻止时,代码应该能编译,测试应该能运行,以便在实际代码库上执行新功能和错误修复。

重构是一个非常大的主题,在进行这样的活动时可以遵循许多技术。它如此复杂,以至于有一整本书是关于它的,由马丁·福勒所著(martinfowler.com/books/refactoring.html)。

在我们的情况下,我们想要应用到我们的代码中的修改是实现一个并行算法。我们首先将修改的是ColorManager。当我们想在终端上打印猜测和行时,我们必须实现一些糟糕的技巧。为什么不有可以打印的颜色实现呢?我们可以有一个扩展原始Color类并具有返回表示该颜色的方法的类。你有没有为这个方法想出一个候选名称?它是toString方法。它在Object类中实现,并且任何类都可以自由地覆盖它。当你将一个对象连接到一个字符串时,自动类型转换将调用此方法将对象转换为String。顺便说一句,使用""+object而不是object.toString()来避免null指针异常是一个老技巧。不言而喻,我们不使用技巧。toString方法也会在 IDEs 调用调试器显示某些对象的值时被调用,因此通常建议实现toString,即使不是为了别的,也是为了简化开发。如果我们有一个实现了toStringColor类,那么PrettyPrintRow类就变得相当直接,技巧也更少:

package packt.java9.by.example.mastermind; 

 public class PrettyPrintRow { 

     public static String pprint(Row row) { 
         String string = ""; 
         PrintableRow pRow = new PrintableRow(row); 
         for (int i = 0; i < pRow.nrOfColumns(); i++) { 
             string += pRow.pos(i); 
         } 
         string += " "; 
         string += pRow.full(); 
         string += "/"; 
         string += pRow.partial(); 
         return string; 
     } 
 }

我们已经从打印类中移除了问题,但你可能会争辩说问题仍然存在,你是对的。很多时候,当类设计有问题时,解决问题的方法是将问题从类移动到另一个类。如果问题仍然存在,那么你可能需要将设计分割得越来越细,到最后阶段,你将意识到你遇到的是一个问题而不是一个难题。

实现一个LetteredColor类也很直接:

package packt.java9.by.example.mastermind.lettered; 

 import packt.java9.by.example.mastermind.Color; 

 public class LetteredColor extends Color { 

     private final String letter; 
     public LetteredColor(String letter){ 
         this.letter = letter; 
     } 

     @Override 
     public String toString(){ 
         return letter; 
     } 
 }

再次,问题被推向前。但在现实中,这并不是一个问题。这是一个面向对象的设计。打印不负责为颜色的表示分配String。颜色实现本身也不负责这一点。分配必须在颜色创建的地方进行,然后String必须传递给LetteredColor类的构造函数。color实例在ColorManager中创建,所以我们必须在ColorManager类中实现这一点。或者不是吗?ColorManager做什么?它创建颜色,...

当你来到一个列出功能的类的解释或描述时,你可能立即会看到单一职责原则被忽略了。"ColorManager"应该管理颜色。管理意味着提供一种方式以确定顺序获取颜色,并在已知一个颜色时获取下一个颜色。我们应该实现另一个职责——在单独的类中创建颜色。

只有一个功能来创建另一个类实例的类被称为工厂。这几乎和使用new操作符一样,但与new不同,工厂可以更加灵活地使用。我们马上就会看到。ColorFactory接口包含一个方法,如下所示:

package packt.java9.by.example.mastermind; 

 public interface ColorFactory { 
     Color newColor(); 
 }

只定义了一个方法的接口被称为函数式接口,因为它们的实现可以作为 lambda 表达式在需要使用实现函数式接口的类的实例的对象的地方提供。SimpleColorFactory实现创建了以下Color对象:

package packt.java9.by.example.mastermind; 

 public class SimpleColorFactory implements ColorFactory { 
     @Override 
     public Color newColor() { 
         return new Color(); 
     } 
 }

这非常类似于我们创建一个接口,然后创建一个实现,而不是在ColorManager的代码中直接写new Color()LetteredColorFactory有点更有趣:

package packt.java9.by.example.mastermind.lettered; 

 import packt.java9.by.example.mastermind.Color; 
 import packt.java9.by.example.mastermind.ColorFactory; 

 public class LetteredColorFactory implements ColorFactory { 

     private final String letters = "0123456789ABCDEFGHIJKLMNOPQRSTVWXYZabcdefghijklmnopqrstvwxzy"; 
     private int counter = 0; 

     @Override 
     public Color newColor() { 
         Color color = new LetteredColor(letters.substring(counter, counter + 1)); 
         counter++; 
         return color; 
     } 
 }

现在,这里我们有在创建Color对象时分配String的功能。非常重要的一点是,跟踪已创建颜色的counter变量不是static的。前一章中类似的变量是static的,这意味着它可能会因为新的ColorManager创建了太多的颜色而耗尽字符。实际上,在我执行单元测试时发生了这种情况,每个测试都创建了ColorManager和新的Color实例,而打印代码试图为新颜色分配新的字母。测试是在同一个 JVM 和同一个类加载器下运行的,不幸的static变量根本不知道它可以从零开始为新测试计数。缺点是,某处某人有责任实例化工厂,而这个责任不是ColorManager的。ColorManager已经有了责任,那就是不是创建颜色工厂。ColorManager必须在构造函数中获取ColorFactory

package packt.java9.by.example.mastermind; 

 import java.util.HashMap; 
 import java.util.List; 
 import java.util.Map; 

 public class ColorManager { 
     final protected int nrColors; 
     final protected Map<Color, Color> successor = new HashMap<>(); 
     private Color first; 
     private final ColorFactory factory; 

     public ColorManager(int nrColors, ColorFactory factory) { 
         this.nrColors = nrColors; 
         this.factory = factory; 
         createOrdering(); 
     } 

     private Color[] createColors() { 
         Color[] colors = new Color[nrColors]; 
         for (int i = 0; i < colors.length; i++) { 
             colors[i] = factory.newColor(); 
         } 
         return colors; 
     } 

     private void createOrdering() { 
         Color[] colors = createColors(); 
         first = colors[0]; 
         for (int i = 0; i < nrColors - 1; i++) { 
             successor.put(colors[i], colors[i + 1]); 
         } 
     } 

     public Color firstColor() { 
         return first; 
     } 

     public boolean thereIsNextColor(Color color) { 
         return successor.containsKey(color); 
     } 

     public Color nextColor(Color color) { 
         return successor.get(color); 
     } 

     public int getNrColors() { 
         return nrColors; 
     } 
 }

你可能也注意到,我无法抗拒将createColors方法重构为两个方法,以遵循单一责任原则。

现在,创建ColorManager的代码必须创建一个工厂并将其传递给构造函数。例如,单元测试的ColorManagerTest类将包含以下方法:

@Test
 public void thereIsAFirstColor() { 
     ColorManager manager  
          = new ColorManager(NR_COLORS, Color::new); 
     Assert.assertNotNull(manager.firstColor()); 
 }

这是有史以来实现由函数式接口定义的工厂的最简单方式。只需命名类并创建一个方法引用,就像使用new操作符一样引用它。

接下来,我们将重构Guess类,实际上,我们之前还没有这个类。Guess类包含猜测的针,可以计算完全匹配(颜色和位置)和部分匹配(颜色存在但位置错误)的数量,还可以计算在此猜测之后的下一个Guess。到目前为止,这个功能是在Guesser类中实现的,但这并不是我们在检查表格上已经做出的猜测时选择猜测的方式的功能。如果我们遵循为颜色设定的模式,我们可以在一个名为GuessManager的单独类中实现这个功能,但到目前为止,它不是必需的。再次强调,这不是非黑即白。

重要的是要注意,Guess对象只能创建一次。如果它在桌子上,玩家不允许更改它。如果我们有一个尚未放在桌子上的Guess,它仍然只是一个由珠子的颜色和顺序标识的GuessGuess对象在创建后永远不会改变。这样的对象在多线程程序中很容易使用,被称为不可变对象:

package packt.java9.by.example.mastermind; 

 import java.util.Arrays; 
 import java.util.HashSet; 
 import java.util.Set; 

 public class Guess { 
     final static public Guess none = new Guess(new Color[0]); 
     final private Color[] colors; 
     private boolean uniquenessWasNotCalculated = true; 
     private boolean unique; 

     public Guess(Color[] colors) { 
         this.colors = Arrays.copyOf(colors, colors.length); 
     }

构造函数正在创建传递的颜色数组的副本。由于Guess是不可变的,这非常重要。如果我们只是保留原始数组,任何在Guess类之外的代码都可以改变数组的元素,本质上改变了不应该改变的内容:

public Color getColor(int i) { 
         return colors[i]; 
     } 

     public int nrOfColumns() { 
         return colors.length; 
     } 

     /** 
      * Calculate the next guess and return a new Guess object. 
      * The guesses are ordered in the order of the colors as 
      * specified by the color manager. 
      * 
      * @param manager that specifies the order of the colors 
      *                can return the next color after one color. 
      * @return the guess that comes after this guess. 
      */ 
     public Guess nextGuess(ColorManager manager) { 
         final Color[] colors = Arrays.copyOf( 
                                     this.colors, nrOfColumns()); 

         int i = 0; 
         boolean guessFound = false; 
         while (i < colors.length && !guessFound) { 
             if (manager.thereIsNextColor(getColor(i))) { 
                 colors[i] = manager.nextColor(colors[i]); 
                 guessFound = true; 
             } else { 
                 colors[i] = manager.firstColor(); 
                 i++; 
             } 
         } 
         if (guessFound) { 
             return new Guess(colors); 
         } else { 
             return Guess.none; 
         } 
     }

在这个方法中,我们从实际对象中包含的颜色数组开始计算下一个Guess。我们需要一个可修改的工作数组,所以我们将复制原始数组。这次,新的最终对象可以使用我们在计算期间使用的数组,因此需要一个不创建副本的单独构造函数。这可能需要额外的代码,但我们应该考虑只在看到这是代码瓶颈且我们对实际性能不满意时才这么做。

下一个方法只是检查传递的Guess是否有与实际相同的颜色数量。这只是下一个两个计算匹配的方法使用的一个安全检查:

private void assertCompatibility(Guess guess) { 
         if (nrOfColumns() != guess.nrOfColumns()) { 
             throw new IllegalArgumentException("Cannot compare different length guesses"); 
         } 
     } 

     /** 
      * Count the number of colors that are present on the guess 
      * but not on the pos where they are in the other guess. 
      * If the same color is on multiple pos it is counted 
      * for each pos once. For example the secret is 
      * <pre> 
      *     RGRB 
      * </pre> 
      * and the guess is 
      * <pre> 
      *     YRPR 
      * </pre> 
      * then this method will return 2\. 
      * 
      * @param guess is the actual guess that we evaluate 
      * @return the number of good colors not in pos 
      */ 
     public int nrOfPartialMatches(Guess guess) { 
         assertCompatibility(guess); 
         int count = 0; 
         for (int i = 0; i < nrOfColumns(); i++) { 
             for (int j = 0; j < nrOfColumns(); j++) { 
                 if (i != j && 
                         guess.getColor(i) == this.getColor(j)) { 
                     count++; 
                 } 
             } 
         } 
         return count; 
     } 

     /** 
      * Count the number of colors that are correct and are in pos. 
      * 
      * @param guess is the actual guess that we evaluate 
      * @return the number of colors that match in pos 
      */ 
     public int nrOfFullMatches(Guess guess) { 
         assertCompatibility(guess); 
         int count = 0; 
         for (int i = 0; i < nrOfColumns(); i++) { 
             if (guess.getColor(i) == this.getColor(i)) { 
                 count++; 
             } 
         } 
         return count; 
     }

isUnique方法检查Guess中是否有任何颜色出现超过一次。由于Guess是不可变的,所以Guess可能在某个时间点是唯一的,而在另一个时间点不是唯一的。这个方法应该在每次被特定对象调用时都返回相同的结果。正因为如此,才有可能缓存结果。这个方法就是这样做的,将返回值保存到一个实例变量中。

你可能会说这是过早优化。是的,确实是。我决定这么做的一个原因是示范,基于这个,你可以尝试修改nextGuess方法来做到同样的事情:

     /** 
      * @return true if the guess does not 
      *         contain any color more than once 
      */ 
     public boolean isUnique() { 
         if (uniquenessWasNotCalculated) { 
             final Set<Color> alreadyPresent = new HashSet<>(); 
             unique = true; 
             for (Color color : colors) { 
                 if (alreadyPresent.contains(color)) { 
                     unique = false; 
                     break; 
                 } 
                 alreadyPresent.add(color); 
             } 
             uniquenessWasNotCalculated = false; 
         } 
         return unique; 
     }

对于返回相同结果的方法,我们称之为幂等。如果该方法被多次调用且计算使用了大量资源,缓存返回值可能非常重要。当方法有参数时,结果缓存并不简单。对象方法必须记住所有已计算过的参数的结果,并且这种存储必须有效。如果查找存储结果所需的资源比计算它所需的资源还多,那么使用缓存不仅会使用更多内存,还会减慢程序的速度。如果在对象的整个生命周期中调用该方法多次,那么存储内存可能会变得过大。一些元素必须被清除——那些未来不再需要的元素。然而,我们无法知道缓存中哪些元素是不需要的,所以我们将不得不猜测。

正如你所见,缓存可以迅速变得复杂,为了专业地完成这项工作,几乎总是更好的使用一些现成的缓存实现。我们这里使用的缓存只是冰山一角。或者,甚至可以说只是阳光一瞥。

类的其余部分相当标准,这是我们之前详细讨论过的——一个很好的知识检查是理解equalshashCodetoString方法是如何这样实现的。我实现了toString方法来帮助我在调试期间,但它也被用于以下示例输出中:

     @Override 
     public boolean equals(Object other) { 
         if (this == other) return true; 
         if (other == null || !(other instanceof Guess)) 
                                               return false; 
         Guess guess = (Guess) other; 
         return Arrays.equals(colors, guess.colors); 
     } 

     @Override 
     public int hashCode() { 
         return Arrays.hashCode(colors); 
     } 

     @Override 
     public String toString() { 
         if (this == none) { 
             return "none"; 
         } else { 
             String s = ""; 
             for (int i = colors.length - 1; i >= 0; i--) { 
                 s += colors[i]; 
             } 
             return s; 
         } 
     } 
 }

这主要是我开发并行算法时需要的修改。现在,代码相当更新,描述的重点是本章的主要主题:如何在 Java 中并行执行代码。

Java 中的代码并行执行是通过线程来完成的。你可能知道 Java 运行时中有一个Thread对象,但如果不理解计算机中的线程是什么,那么这毫无意义。在接下来的小节中,我们将学习这些线程是什么,如何启动一个新的线程,如何同步线程间的数据交换,最后将所有这些整合起来并实现 Mastermind 并行猜测算法。

进程

当你启动你的计算机时,启动的程序是操作系统OS)。操作系统控制机器硬件以及你可以在机器上运行的程序。当你启动一个程序时,操作系统会创建一个新的进程。这意味着操作系统在它管理进程的表中(数组)分配一个新的条目,并填写它所知道以及需要知道的关于进程的参数。例如,它会注册进程允许使用的内存段,进程的 ID,以及哪个用户从哪个其他进程启动。你不能凭空启动一个进程。当你双击一个 EXE 文件时,你实际上是在告诉文件浏览器,这是一个作为进程运行的程序,让它作为一个单独的进程启动 EXE 文件。浏览器通过某些 API 调用系统,并友好地请求操作系统这样做。操作系统会将浏览器进程注册为新进程的父进程。操作系统实际上并没有启动进程,而是创建了启动进程所需的所有数据,当有可用的 CPU 资源时,进程就会被启动,然后很快就会暂停。你不会注意到这一点,因为操作系统会不断地重新启动它,并反复暂停进程。它需要这样做,以便为所有进程提供运行的可能性。这样,我们体验到的所有进程都是同时运行的。实际上,在单个处理器上,进程并不是同时运行的,但它们会得到运行的时间槽。

如果机器中有多于一个 CPU,那么进程可以真正地同时运行,CPU 的数量有多少,进程就可以同时运行多少。随着技术的不断进步,现在的台式电脑中包含的 CPU 拥有多个核心,这些核心几乎可以像独立的 CPU 一样工作。在我的机器上,我有四个核心,每个核心可以同时执行两个线程;因此,我的 Mac 几乎就像一个 8CPU 的机器。

进程有各自的内存。它们被允许使用内存的一部分,如果一个进程试图使用不属于它的另一部分内存,处理器将停止这样做。操作系统将杀死该进程。

想象一下,原始 UNIX 的开发者给停止进程的程序命名为“kill”,停止进程被称为“杀死”,这就像中世纪时代他们砍掉罪犯的手一样。你触摸了内存的错误部分,就会被“杀死”。我不想成为一个进程。

操作系统对内存的处理非常复杂,除了将进程彼此分离之外。当内存不足时,操作系统会将部分内存写入磁盘,从而释放内存,并在需要时再次加载这部分内存。这是一个非常复杂、低级实现且高度优化的算法,这是操作系统的责任。

线程

当我说操作系统在时间槽中执行进程时,我并不是完全精确的。每个进程都有一个或多个线程,线程被执行。线程是外部调度器管理的最小执行单元。较老的操作系统没有线程的概念,它们只执行进程。实际上,最早的线程实现只是共享内存的进程的复制。

你可能会听到术语“轻量级进程”——这意味着线程。

重要的是,线程没有自己的内存。它们使用进程的内存。换句话说,在同一个进程中运行的线程对相同的内存段有未区分的访问权限。这是实现利用机器中多个核心的并行算法的极其强大的可能性,但同时也可能导致错误。

图片

假设有两个线程增加同一个长整型变量。增加操作首先计算低 32 位的增加值,然后是高 32 位(如果有溢出位)。这些是可能被操作系统中断的两个或多个步骤。可能发生的情况是,一个线程增加了低 32 位,记得需要对高 32 位进行一些操作,开始计算,但在它被中断之前没有时间存储结果。然后,另一个线程增加了低 32 位,高 32 位,而第一个线程只是保存了它计算出的高 32 位。结果变得混乱。在旧的 32 位 Java 实现中,演示这种效果非常容易。在 64 位 Java 实现中,所有 64 位都一次性加载到寄存器中,并一次性保存回内存,因此演示多线程问题并不那么容易,但这并不意味着没有。

当一个线程被暂停而另一个线程被启动时,操作系统必须执行上下文切换。这意味着,在其他事情中,CPU 寄存器必须被保存,然后设置为其他线程应有的值。上下文切换始终保存线程的状态,并将之前保存的线程状态加载到要启动的线程中。这是在 CPU 寄存器级别上。这种上下文切换是耗时的;因此,执行的上下文切换越多,用于线程管理的 CPU 资源就越多,而不是让它们运行。另一方面,如果切换不够,一些线程可能得不到足够的时间片来执行,程序可能会挂起。

纤程

Java 没有纤程,但既然有一些库支持纤程处理,那么提一下也是有价值的。纤程是一个比线程更细小的单位。在一个线程中执行的程序代码可以决定放弃执行并告诉纤程管理器去执行其他纤程。这有什么意义,为什么它比使用另一个线程更好呢?原因在于这种方式,纤程可以避免部分上下文切换。上下文切换无法完全避免,因为开始执行它的不同部分的代码可能会以完全不同的方式使用 CPU 寄存器。由于它们是同一个线程,上下文切换不是操作系统的任务,而是应用程序的任务。

操作系统不知道寄存器的值是否被使用。寄存器中有位,仅通过查看处理器状态,没有人能说出这些位是否与当前代码执行相关,或者只是偶然以这种方式存在。编译器生成的程序知道哪些寄存器是重要的,哪些可以忽略。这些信息在代码的不同位置会变化,但当需要切换时,纤程会将需要在该点切换的信息传递给执行切换的代码。

编译器计算这些信息,但 Java 当前版本不支持纤程。在编译阶段之后,实现纤程的工具会分析和修改类的字节码来完成这项工作。

Go 语言的 goroutines 是纤程,这就是为什么在 Go 中可以轻松启动成千上万的 goroutines,但最好将 Java 中的线程数量限制在一个较低的数字。它们不是同一件事。

由于轻量级进程这个术语正在逐渐消失,并且越来越少地用于纤程,因此很多时候被称为轻量级线程。

java.lang.Thread

由于 Java(几乎)中的所有东西都是对象,如果我们想启动一个新的线程,我们需要一个表示线程的类。这个类是内置在 JDK 中的java.lang.Thread。当你启动 Java 代码时,JVM 会自动创建几个Thread对象,并使用它们来运行它需要的不同任务。如果你启动VisualVM,你可以选择任何 JVM 进程的“线程”标签,并查看 JVM 中实际存在的线程。例如,我启动的 VisualVM 有 29 个活跃的线程。其中一个是名为main的线程。这就是开始执行main方法的线程(惊喜!)。main线程启动了大多数其他线程。当我们想要编写一个多线程应用程序时,我们必须创建新的Thread对象并启动它们。最简单的方法是new Thread(),然后在线程上调用start方法。它将启动一个新的线程,但由于我们没有给它分配任何任务,所以这个线程将立即结束。Thread类,正如它在 JDK 中一样,并不执行我们的业务逻辑。以下有两种指定业务逻辑的方法:

  • 创建一个实现Runnable接口的类

  • 创建一个扩展Thread类并重写run方法的类

以下代码块是一个非常简单的演示程序:

public class ThreadIntermingling { 
     static class MyThread extends Thread { 
         private final String name; 
         MyThread(String name){ 
             this.name = name; 
         } 
         @Override 
         public void run(){ 
             for(int i = 1 ; i < 1000 ; i ++ ){ 
                 System.out.print(name + " " + i+ ", "); 
             } 
         } 
     } 
     public static void main(String[] args){ 
         Thread t1 = new MyThread("t1"); 
         Thread t2 = new MyThread("t2"); 
         t1.start(); 
         t2.start(); 
         System.out.print("started "); 

     } 
 }

上述代码创建了两个线程并依次启动它们。当调用start方法时,它将线程对象调度为执行,然后返回。因此,新线程将很快异步开始执行,而调用线程继续执行。在以下示例中,这两个线程以及main线程是并行运行的,并创建了一个类似以下输出的结果:

started t2 1, t2 2, t2 3, t2 4, t2 5, t2 6, t2 7, t2 8, t1 1, t2 9, t2 10, t2 11, t2 12,...

实际输出每次运行都会变化。没有执行顺序或线程如何访问单个屏幕输出的确定顺序。甚至不能保证在每次执行中,started消息都会在任何一个线程消息之前打印出来。

为了更好地理解这一点,我们需要查看线程的状态图。Java 线程可以处于以下状态之一:

  • NEW

  • RUNNABLE

  • BLOCKED

  • WAITING

  • TIMED_WAITING

  • TERMINATED

这些状态在enumThread.State中定义。当你创建一个新的线程对象时,它处于NEW状态。在这个时候,线程并没有什么特别之处,它仅仅是一个对象,但操作系统的执行调度并不知道它。从某种意义上说,它只是 JVM 分配的一块内存。

当调用 start 方法时,线程的信息被传递给操作系统,操作系统调度线程,以便在适当的时间槽中由它执行。这样做是一种资源丰富的行为,这也是为什么我们不会仅在需要时创建和,尤其是启动新的 Thread 对象。相反,我们将保留现有的线程一段时间,即使它们目前不需要,如果有一个合适的线程,我们将重用现有的一个。

当操作系统调度并执行线程时,线程在 OS 中也可以处于运行状态以及可运行状态。Java JDK API 没有在这两者之间进行区分,这是有充分理由的。这样做是没有用的。当线程处于RUNNABLE状态并询问它是否实际上正在运行时,它将得到一个明显的答案:如果代码刚刚从Thread类中实现的getState方法返回,那么它正在运行。如果没有运行,它最初就不会从调用中返回。如果getState方法是从另一个线程调用的,那么在方法返回时关于其他线程的结果将是没有意义的。操作系统可能在此之前已经停止或启动了查询的线程几次。

当线程中正在执行的代码试图访问当前不可用的资源时,线程处于BLOCKED状态。为了避免对资源的持续轮询,操作系统提供了一个有效的通知机制,以便当线程需要的资源变得可用时,线程能够回到RUNNABLE状态。

当线程等待其他线程或锁时,它处于WAITTIMED_WAITING状态。TIMED_WAITING是等待开始调用具有超时版本的方法的状态。

最后,当线程完成其执行时,会达到TERMINATED状态。如果你将以下几行代码添加到我们之前的示例末尾,那么你会得到一个TERMINATED的输出,并且屏幕上还会抛出一个异常,抱怨非法的线程状态,这是因为你不能启动一个已经终止的线程:

System.out.println(); 
System.out.println(t1.getState()); 
System.out.println(); 
t1.start();

我们不是通过扩展Thread类来定义异步执行的内容,而是可以创建一个实现Runnable接口的类。这样做更符合面向对象编程的方法。我们在类中实现的东西不是线程的功能。它更像是可以执行的东西。它是一种可以简单运行的东西。

如果这个执行是在不同的线程中异步进行的,或者它是在调用 run 方法的同一个线程中执行的,这是一个需要单独考虑的不同问题。如果我们那样做,我们可以将类作为构造函数参数传递给Thread对象。在Thread对象上调用start将启动我们传递的对象的 run 方法。这不是我们的收获。收获在于我们还可以将Runnable对象传递给一个Executor(名字听起来很糟糕,嗯!)。Executor是一个接口,它以高效的方式在Threads 中执行Runnable(以及稍后将要提到的Callable)对象。Executors通常有一个准备好的Thread对象池,它们处于BLOCKED状态。当Executor有一个新的任务要执行时,它会将其分配给一个Thread对象,并释放阻塞线程的锁。Thread进入RUNNABLE状态,执行Runnable,然后再次被阻塞。它不会终止,因此可以稍后重新用于执行另一个Runnable。这样,Executors 避免了将线程注册到操作系统的资源消耗过程。

专业应用程序代码永远不会创建一个新的Thread。应用程序代码使用某些框架来处理代码的并行执行,或者使用由某些ExecutorService提供的Executors 来启动RunnableCallable对象。

陷阱

我们已经讨论了许多在开发并行程序时可能遇到的问题。在本节中,我们将使用通常用于描述这些问题的术语来总结它们。术语不仅有趣,而且在与同事交谈时,为了轻松理解彼此,它也很重要。

死锁

死锁是并行编程中最臭名昭著的陷阱,因此,我们将从这里开始。为了描述这种情况,我们将遵循官僚主义的隐喻。

官僚必须在他手中的文件上盖章。为了做到这一点,他需要印章,也需要墨盒。首先,他走到放印章的抽屉那里,把它拿走。然后,他走到放墨盒的抽屉那里,拿走墨盒。他给印章上墨,按在纸上。然后,他把印章放回原位,然后把墨盒放回原位。一切都很完美,我们正处在天堂里。

如果另一个官僚先拿墨盒,然后拿印章,会发生什么?他们可能很快就会变成一个拿着印章等待墨盒的官僚,另一个拿着墨盒等待印章的官僚。而且,他们可能就这样僵在那里,永远冻结,然后越来越多的人开始等待这些锁,文件永远不会被盖章,整个系统陷入无政府状态。

为了避免这种情况,锁必须按顺序排列,并且应该始终按顺序获取锁。在先前的例子中,简单的协议是首先获取墨垫,然后是邮票,这解决了问题。任何获取邮票的线程都可以确信墨垫是空闲的或很快就会空闲。

竞态条件

当计算的结果可能基于不同并行运行的线程的速度和 CPU 访问时,我们谈论竞态条件。让我们看看以下两行代码:

    void method1(){ 
1       a = b; 
2       b = a+1; 
        } 
    void method2(){ 
3       c = b; 
4       b = c+2; 
        }

如果在执行开始时b的值为 0,并且两个不同的线程执行这两个方法,那么行的顺序可以是 1234、1324、1342、3412、3142 或 3142。四个行的任何执行顺序都可能发生,这保证了 1 在 2 之前运行,3 在 4 之前运行,但没有其他限制。结果,b的值在段执行结束时为 1 或 2,这可能不是我们在编码时想要的,也不太好。

注意,并行 Mastermind 游戏的实现也有类似的情况。实际的猜测很大程度上取决于不同线程的速度,但从最终结果的角度来看,这是无关紧要的。我们可能在不同的运行中有不同的猜测,这样算法就不是确定性的,但我们保证能找到最终解决方案。

过度使用的锁

在许多情况下,可能会发生线程正在等待一个锁,该锁保护资源免受并发访问。如果资源不能被多个线程同时使用,并且有比可以服务的线程更多的线程,那么这些线程就会处于饥饿状态。然而,在许多情况下,资源可以被组织成一种方式,使得线程可以访问资源提供的一些服务,并且锁定结构可以不那么限制性。在这种情况下,锁被过度使用,并且可以通过为线程分配更多资源来修复这种情况。可能可以使用多个锁来控制对资源不同功能的访问。

饥饿

饥饿是当多个线程等待一个资源,试图获取一个锁,而一些线程只有在经过极长时间或永远之后才能获取到锁的情况。当锁被释放并且有线程等待它时,其中一个线程可以获取到锁。通常没有保证如果线程等待足够长的时间,它就能获取到锁。这种机制需要大量管理线程,对等待队列中的线程进行排序。由于锁定应该是低延迟和高性能的操作,即使是几个 CPU 时钟周期也是重要的;因此,锁默认不提供这种公平访问。如果锁只有一个线程等待,不浪费时间在线程调度中的公平性是一个好方法。锁的主要目标不是调度等待的线程,而是防止对资源的并行访问。

这就像在商店里。如果有人在收银台,你就得等。这是一个隐含的锁。如果人们不排队等待收银,只要几乎总是有一个空闲的收银台,那就没问题。然而,当收银台前有多个队伍时,如果没有排队和等待的顺序,肯定会给那些慢吞吞地到达收银台的人带来非常长的等待时间。通常,公平性和创建等待线程(顾客)的队列的解决方案并不是一个好的解决方案。好的解决方案是消除导致等待队列的情况。你可以增加收银员,或者你可以做一些完全不同的事情,以减少高峰负载。在商店里,你可以给在非高峰时段来店的顾客打折。在编程中,可以应用几种技术,通常,这取决于我们实际编写的业务代码和锁的公平调度,通常是一个解决方案。

ExecutorService

ExecutorService 是 JDK 中的一个接口。该接口的实现可以以异步方式执行 RunnableCallable 类。该接口仅定义了实现该接口的 API,并不要求调用必须是异步的,但实际上,这是实现此类服务的主要点。以同步方式调用 Runnable 接口的 run 方法仅仅是调用一个方法。我们不需要为这个目的创建一个特殊的类。

Runnable 接口定义了一个 run 方法。它没有参数,不返回任何值,也不抛出任何异常。Callable 接口是参数化的,它定义的唯一方法 call 没有参数,但返回一个泛型值,并且可能抛出 Exception。在我们的代码中,如果我们只想运行某些东西,我们将实现 Runnable,如果我们想返回某些东西,我们将实现 Callable。这两个接口都是函数式接口,因此,它们是使用 lambda 实现的好候选。

要有一个 ExecutorService 实现的实例,我们可以使用实用工具类 Executors。很多时候,当 JDK 中有一个 XYZ 接口时,可能有一个 XYZs(复数)实用工具类,它为该接口的实现提供工厂。如果我们想多次启动 t1 任务,我们可以这样做,而不需要创建一个新的 Thread。我们应该使用以下执行服务:

public class ThreadIntermingling { 
      static class MyThread implements Runnable { 
          private final String name; 

          MyThread(String name) { 
              this.name = name; 
          } 

          @Override 
          public void run() { 
              for (int i = 1; i < 1000; i++) { 
                  System.out.print(name + " " + i + ", "); 
              } 
          } 
      } 
      public static void main(String[] args) 
                throws InterruptedException, ExecutionException { 
          ExecutorService es = Executors.newFixedThreadPool(2); 
          Runnable t1 = new MyThread("t1"); 
          Runnable t2 = new MyThread("t2"); 
          Future<?> f1 = es.submit(t1); 
          Future<?> f2 = es.submit(t2); 
          System.out.print("started "); 
          f1.get(); 
          f2.get(); 
          System.out.println(); 
          f1 = es.submit(t1); 
          es.shutdown(); 
      } 
  }

这次,我们没有遇到任何异常。相反,t1任务运行了第二次。在这个例子中,我们使用了一个固定大小的线程池,它包含两个Thread。由于我们只想同时启动两个线程,所以这已经足够了。有一些实现会动态地增加和减少池的大小。当我们想要限制线程的数量或者从其他信息源得知预先线程的数量时,应该使用固定大小的池。在这种情况下,将池的大小改为一个,并观察在这种情况下第二个任务不会启动,直到第一个任务完成,这是一个很好的实验。服务将不会有另一个线程用于t2,它必须等待池中唯一的Thread被释放。

当我们将任务提交给服务时,即使任务当前无法执行,它也会返回。任务被放入队列中,一旦有足够的资源启动它们,就会开始执行。submit方法返回一个Future对象,正如我们在前面的示例中所看到的。

这就像一张服务票。你把你的车带到维修技师那里,你得到一张票。你不需要在那里一直等到车修好,但任何时候你都可以询问车是否准备好了。你所需要的只是这张票。你也可以决定等到车修好。一个Future对象也是这样。你不会得到你需要的值。它将异步计算。然而,有一个Future承诺它将会在那里,你需要访问所需对象的票就是Future对象。

当你有一个Future对象时,你可以调用isDone方法来查看它是否已经准备好。你可以开始等待它,用或不用超时调用get,你也可以取消正在执行的任务,但在这种情况下,结果可能是可疑的。就像,在你的车的情况下,如果你决定取消任务,你可能会得到一辆发动机被拆解的车。同样,取消一个没有为此准备的任务可能会导致资源损失,打开且无法访问的数据库连接(这是我的一个痛苦记忆,即使是在 10 年后),或者只是一个混乱的无法使用的对象。为取消任务做好准备,或者不要取消它们。

在前面的例子中,Future没有返回值,因为我们提交了一个Runnable对象而不是Callable对象。在这种情况下,传递给Future的值不应该被使用。它通常是null,但这并不是可以依赖的。

许多开发者甚至包括我在内,在多年没有使用代码编写多线程 Java API 之后,常常会忽略的一个重要事情是关闭ExecutorServiceExecutorService被创建后,它包含Thread元素。当所有非守护线程都停止时,JVM 才会停止。没有到最后,胖女人还没有唱完。

如果一个线程在启动之前被设置为守护线程(调用setDaemon(true)),则该线程是一个守护线程。如果一个线程的启动线程是守护线程,则该线程自动成为守护线程。当所有其他线程完成并且 JVM 想要结束时,守护线程会被 JVM 停止。JVM 自己执行的某些线程是守护线程,但在应用程序程序中创建守护线程可能没有实际用途。

不关闭服务只是阻止 JVM 停止。在main方法完成后,代码将挂起。为了告诉ExecutorService它不需要它拥有的线程,我们必须shutdown服务。调用将仅启动关闭并立即返回。在这种情况下,我们不想等待。JVM 无论如何都会这样做。如果我们需要等待,我们必须调用awaitTermination

ForkJoinPool

ForkJoinPool是一个特殊的ExecutorService,它有执行ForkJoinTask对象的方法。当我们要执行的任务可以分解成许多小任务,然后当结果可用时聚合时,这些类非常有用。使用此执行器,我们不需要关心线程池的大小和关闭执行器。线程池的大小调整到给定机器上的处理器数量以获得最佳性能。由于ForkJoinPool是一个为短运行任务设计的特殊ExecutorService,它不期望有任何任务在任务运行完毕后仍然存在或被需要。因此,它作为守护线程执行;当 JVM 关闭时,ForkJoinPool自动停止,女士不再唱歌。

要创建一个任务,程序员应该扩展RecursiveTaskRecursiveAction之一。第一个用于任务有返回值的情况,第二个用于没有计算值返回的情况。它们被称为递归的,因为很多时候,这些任务将它们必须解决的问题分解成更小的子问题,并通过 fork-join API 异步调用这些任务。

使用此 API 要解决的问题的一个典型问题是快速排序。在第三章,优化排序 - 使代码专业化中,我们创建了快速排序算法的两个版本。一个使用递归调用,另一个不使用。我们也可以创建一个新的版本,它不是通过递归调用自己,而是调度要执行的任务,可能由另一个处理器执行。调度是ForkJoinPool实现ExecutorService的任务。

你可以回顾第三章中的Qsort.java代码,优化排序 - 使代码专业化。这里使用ForkJoinPool的版本:

public class FJQuickSort<E> { 
     final private Comparator<E> comparator; 
     final private Swapper swapper; 

     public FJQuickSort(Comparator<E> comparator, Swapper swapper){ 
         this.comparator = comparator; 
         this.swapper = swapper; 
     } 

     public void qsort(SortableCollection<E> sortable, 
                       int start, int end) { 
         ForkJoinPool pool = new ForkJoinPool(); 
         pool.invoke(new RASort(sortable,start,end)); 
     } 

     private class RASort extends RecursiveAction { 

         final SortableCollection<E> sortable; 
         final int start, end; 

         public RASort(SortableCollection<E> sortable, 
                       int start, int end) { 
             this.sortable = sortable; 
             this.start = start; 
             this.end = end; 
         } 

         public void compute() { 
             if (start < end) { 
                 final E pivot = sortable.get(start); 
                 final Partitioner<E> partitioner =  
                          new Partitioner<>(comparator, swapper); 
                 int cutIndex = partitioner.partition( 
                                    sortable, start, end, pivot); 
                 if (cutIndex == start) { 
                     cutIndex++; 
                 } 
                 RecursiveAction left =  
                    new RASort(sortable, start, cutIndex - 1); 
                 RecursiveAction right =  
                    new RASort(sortable, cutIndex, end); 
                 invokeAll(left,right); 
                 left.join(); 
                 right.join(); 
             } 
         } 
     }

每当你可以将任务分解成类似于前面快速排序示例中的子任务时,我建议你使用ForkJoinPool作为ExecutorService。你可以在 Oracle 的 JavaDoc 文档中找到关于 API 和使用的良好文档。

变量访问

现在我们能够启动线程并创建并行运行的代码,是时候稍微谈谈这些线程之间如何交换数据了。乍一看,这似乎相当简单。线程使用相同的共享内存;因此,它们都可以读取和写入 Java 访问保护允许它们读取和写入的所有变量。这是真的,除非某些线程可能只是决定不读取内存。毕竟,如果它们刚刚读取了某个变量的值,为什么还要从内存中再次读取到寄存器,如果它没有被修改?谁会修改它们?让我们看看以下简短的例子:

package packt.java9.by.example.thread; 

 public class VolatileDemonstration implements Runnable { 
     private Object o = null; 
     private static final Object NON_NULL = new Object(); 
     @Override 
     public void run() { 
         while( o == null ); 
         System.out.println("o is not null"); 
     } 
     public static void main(String[] args) 
                            throws InterruptedException { 
         VolatileDemonstration me = new VolatileDemonstration(); 
         new Thread(me).start(); 
         Thread.sleep(1000); 
         me.o = NON_NULL; 
     } 
 }

会发生什么?你可能预期代码会启动,启动新线程,然后一分钟,当main线程将对象设置为非null时,它就会停止?不会的。

在某些 Java 实现中,它可能会停止,但在大多数情况下,它只是会继续旋转。这是因为 JIT 编译器优化了代码。它看到循环什么也没做,而且变量永远不会是非null的。它被允许假设这一点,因为未声明为volatile的变量不应该被任何其他线程修改,JIT 编译器有资格进行优化。如果我们声明Object o变量为volatile(使用volatile关键字),那么代码就会停止。

如果你尝试移除调用 sleep 的代码,代码也会停止。然而,这并不能解决问题。原因是 JIT 优化只在代码执行大约 5000 次循环之后才会启动。在此之前,代码会以原始方式运行,并在优化消除额外的、定期不需要访问的非volatile变量之前停止。

如果这听起来如此糟糕,那么我们为什么不将所有变量都声明为volatile呢?为什么 Java 不为我们这样做?答案是速度,为了更深入地理解它,我们将使用我们的比喻,办公室和官僚主义者。

CPU 的心跳

这些天,CPU 运行在 2 到 4 GHz 频率的处理器上。这意味着处理器每秒会接收到 2 到 4 次 10⁹个时钟信号来执行某些操作。处理器无法以比这更快的速度执行任何原子操作,也没有理由创建一个比处理器能跟上的更快的时钟。这意味着 CPU 执行一个简单的操作,比如在半纳秒或四分之一纳秒内增加一个寄存器。这是处理器的心跳,如果我们把官僚主义者比作人类,那么他们的心跳相当于大约一秒钟。

处理器在芯片上有不同级别的寄存器和缓存,L1、L2,有时还有 L3;有内存、SSD、磁盘、网络和可能需要检索数据的磁带。

访问位于 L1 缓存中的数据大约需要 0.5 纳秒。你可以抓起你桌上的纸张——半秒钟。L2 缓存是 7 纳秒。这就像从抽屉里拿出一张纸。你必须把椅子稍微往后推,弯腰坐好,拉开抽屉,拿出纸张,把抽屉推回去,然后站起来把纸张放在桌子上;这大概需要 10 秒,上下浮动。

主内存读取需要 100 纳秒。官僚站起来,走向墙上的共享文件,他等待其他官僚取回或放回他们的文件,选择抽屉,拉开它,拿出纸张,然后走回桌子。这是两分钟。这是每次你在文档上写一个单词并需要执行两次的易失性变量访问。一次读取,一次写入,即使你碰巧知道你接下来要做的就是填写同一张纸上的另一个字段。

现代架构,其中没有多个 CPU,而是有多个核心的单个 CPU,要快一些。一个核心可能会检查另一个核心的缓存,看看是否有相同的变量被修改,但这将易失性访问速度加快到大约 20 纳秒,这仍然比非易失性慢一个数量级。

虽然其余部分不太关注多线程编程,但在这里提一下是值得的,因为它能让我们对不同时间尺度有更好的理解。

从 SSD(通常是 4K 块)读取数据需要 150,000 纳秒。以人类速度来计算,这相当于 5 天多。在网络上的 Gb 本地以太网上读取或发送数据到服务器需要 0.5 毫秒,这就像等待几乎一个月的象征性官僚。如果网络上的数据在旋转的磁盘中,那么寻址时间(直到磁盘旋转到磁表面的一部分到达读取头)会增加到 20 毫秒。在人类术语中,这大约是一年。

如果我们在互联网上通过大西洋发送一个网络数据包,它大约需要 150 毫秒。这就像 14 年一样,而这只是一个单一的数据包;如果我们想要通过海洋发送数据,可能就是几秒钟的时间,这可以追溯到历史时期,甚至几千年。如果我们把机器启动一分钟算作时间,那么它就相当于我们整个文明的时间跨度。

当我们想要了解 CPU 大部分时间在做什么时,我们应该考虑这些数字:它是在等待。此外,当你想到现实生活中官僚的速度时,这也帮助你冷静下来。实际上,他们并不那么慢,如果我们考虑到他们的心跳,这意味着他们有心脏的假设。然而,让我们回到现实生活,回到 CPU,以及 L1、L2 缓存和易失性变量。

易失性变量

让我们修改我们样本代码中o变量的声明如下:

private volatile Object o = null;

前面的代码运行良好,大约一秒后停止。任何 Java 实现都必须保证多个线程可以访问volatile字段,并且字段的值是一致更新的。这并不意味着volatile声明将解决所有同步问题,但它保证了不同变量及其值变化关系的一致性。例如,让我们考虑我们在一个方法中递增以下两个字段:

private volatile int i=0,j=0; 

 public void method(){ 
     i++; j++; 
 }

在前面的代码中,从另一个线程读取ij永远不会导致i>j。如果没有volatile声明,编译器在需要时可以自由重新组织增量操作的执行,因此它不能保证异步线程读取一致值。

同步块

声明变量不是确保线程之间一致性的唯一工具。Java 语言中还有其他工具,其中之一就是synchronized块。synchronized关键字是语言的一部分,它可以用在方法或方法内部程序块的前面。

Java 程序中的每个对象都有一个可以被任何运行线程锁定和解锁的监视器。当一个线程锁定一个监视器时,我们说该线程持有锁,并且任何时候不会有两个线程同时持有监视器的锁。如果一个线程尝试锁定已经锁定的监视器,它将BLOCKED直到监视器被释放。一个synchronized块以synchronized关键字开始,然后是一个在括号中指定的对象实例和块。以下小程序演示了synchronized块:

public class SynchronizedDemo implements Runnable { 
     public static final int N = 1000; 
     public static final int MAX_TRY = 1_000_000; 

     private final char threadChar; 
     private final StringBuffer sb; 
     public SynchronizedDemo(char threadChar, StringBuffer sb) { 
         this.threadChar = threadChar; 
         this.sb = sb; 
     } 
     @Override 
     public void run() { 
         for (int i = 0; i < N; i++) { 
             synchronized (sb) { 
                 sb.append(threadChar); 
                 sleep(); 
                 sb.append(threadChar); 
             } 
         } 
     } 
     private void sleep() { 
         try { 
             Thread.sleep(1); 
         } catch (InterruptedException ignored) {} 
     } 
     public static void main(String[] args) { 
         boolean failed = false; 
         int tries = 0; 
         while (!failed && tries < MAX_TRY) { 
             tries++; 
             StringBuffer sb = new StringBuffer(4 * N); 
             new Thread(new SynchronizedDemo('a', sb)).start(); 
             new Thread(new SynchronizedDemo('b', sb)).start(); 
             failed = sb.indexOf("aba") != -1 || 
                      sb.indexOf("bab") != -1; 
         } 
         System.out.println(failed ?  
               "failed after " + tries + " tries" : "not failed"); 
     } 
 }

代码启动了两个不同的线程。其中一个线程将aa追加到StringBuffer中。另一个线程追加bb。这种追加是在两个独立的步骤中完成的,中间有一个睡眠时间。睡眠是必要的,以避免 JIT 将两个独立的步骤优化为一个步骤。每个线程执行append 1000 次,每次追加ab两次。由于两个连续的append操作都在synchronized块内部,所以不可能出现ababab序列进入StringBuffer。当一个线程执行synchronized块时,另一个线程不能执行它。

如果我移除synchronized块,那么我用来测试 Java HotSpot (TM) 64 位服务器虚拟机(构建 9-ea+121,混合模式)的 JVM 将打印出失败,尝试计数大约几百次。

这清楚地展示了同步的含义,但它也引起了我们对另一个重要现象的关注。错误只会在每执行几百万次之后才会发生。尽管这个例子是为了展示这种意外情况而提供的,但它仍然极其罕见。如果错误如此罕见,那么重现它就非常困难,更不用说调试和修复了。大多数同步错误以神秘的方式表现出来,它们的修复通常是通过细致的代码审查而不是调试来实现的。因此,在开始商业多线程应用程序之前,清楚地理解 Java 多线程行为的真正性质至关重要。

synchronized关键字也可以用于方法之前。在这种情况下,获取锁的对象是当前对象。如果是static方法,则同步是在整个类上完成的。

等待和通知

Object类中实现了五种方法,可以用来获取更高级的同步功能:带有三个不同超时参数签名的waitnotifynotifyAll。要调用wait,调用线程应该拥有被wait调用的Object的锁。这意味着你只能从synchronized块内部调用wait,当它被调用时,线程会进入BLOCKED状态并释放锁。当另一个线程在同一个Object上调用notifyAll时,该线程会进入RUNNABLE状态。它不能立即继续执行,因为它无法获取对象的锁。此时,锁被刚刚调用notifyAll的线程持有。然而,在其他线程释放锁之后的一段时间内,锁会从synchronized块中退出,等待的线程将继续执行。

如果有更多线程正在等待一个对象,它们都会从BLOCKED状态中退出。notify方法只会唤醒其中一个等待的线程。无法保证哪个线程会被唤醒。

waitnotifynotifyAll的典型用法是在一个或多个线程创建由其他线程或线程消费的Object时。对象在线程之间传输的存储通常是某种类型的队列。消费者会等待直到可以从队列中读取到内容,生产者则依次将对象放入队列。当生产者将对象存入队列时,它会通知消费者。如果队列中没有剩余空间,生产者必须停止并等待直到队列中有空间。在这种情况下,生产者会调用wait方法。为了唤醒生产者,消费者在读取到内容时会调用notifyAll

消费者从队列中以循环的方式消费对象,并且只有在队列中没有东西可读时才调用wait。当生产者调用notifyAll,但没有消费者等待时,通知就被忽略了。它飞走了,但这不是问题;消费者没有在等待。当消费者消费一个对象并调用notifyAll,但没有生产者等待时,情况相同。这不是问题。

不可能出现消费者消费了,调用了notifyAll,但在通知在空中飞舞时没有找到任何等待的生产者,然后一个生产者开始等待的情况。这种情况不可能发生,因为整个代码都在一个synchronized块中,并且它确保没有生产者在临界区。这就是为什么waitnotifynotifyAll只能在获取了Object类的锁时调用。

如果有多个消费者,它们正在执行相同的代码,并且消费对象的能力相当,那么调用notify而不是notifyAll是一种优化。在这种情况下,notifyAll将唤醒所有消费者线程,但幸运的那个会意识到他们被唤醒了,但有人已经用诱饵逃脱了。

我建议你至少实践一次实现一个可以用于在线程之间传递Object的阻塞队列。然而,永远不要在生产环境中使用那段代码:从 Java 1.5 开始,就有BlockingQueue接口的实现。使用适合你需求的实现。在我们的示例代码中,我们也会这样做。

感谢你能在 Java 9 中编码。我开始使用 Java 是在 1.4 版本时,那时我不得不实现一个阻塞队列。随着 Java 的使用,生活变得越来越美好和简单。

在专业代码中,我们通常避免使用synchronized方法或块以及volatile字段,以及waitnotify方法,notifyAll也是如此,如果可能的话。我们可以使用线程间的异步通信,或者将整个多线程传递给框架处理。在某些特殊情况下,当代码的性能很重要,或者我们找不到更好的结构时,Synchronizedvolatile是无法避免的。有时,对特定代码和数据结构的直接同步比 JDK 类提供的方法更有效。然而,需要注意的是,这些类也使用这些低级同步结构,所以它们的工作方式并不是魔法;在你想实现自己的版本之前,你可以查看 JDK 类的代码。你会意识到实现这些队列并不简单;类的代码不是没有理由地复杂和复合。如果你觉得代码简单,这意味着你已经足够资深,知道什么不要重写。或者,也许你没有意识到你读到的代码。

Lock是 Java 内置的;每个Object都有一个锁,线程在进入synchronized块时可以获取这个锁。我们已经讨论过这一点了。在某些编程代码中,这种结构可能不是最优的。

在某些情况下,锁的结构可能被排列以避免死锁。可能需要先获取锁A,然后再获取锁B,以及先获取锁B,然后再获取锁C。然而,A应该尽快释放,不仅是为了防止访问由锁D保护的资源,也是因为需要在使用锁A之前获取它。在复杂且高度并行的结构中,锁通常被多次结构化为树,线程在访问资源时应该沿着树向下爬到代表资源的叶子节点。在这个过程中,线程会获取一个节点的锁,然后是它下面的节点的锁,然后释放上面的锁,就像真正的登山者下降(或者如果你想象树叶在顶部的树,这更现实,尽管图形通常将树倒置显示)一样。

你不能让一个synchronized块留在另一个块中,该块位于第一个块内部。synchronized块是可以嵌套的。《java.util.concurrent.Lock》接口定义了处理这种情况的方法,JDK 中也提供了相应的实现,可以在我们的代码中使用。当你拥有锁时,你可以调用lockunlock方法。实际的顺序由你控制,你可以编写以下代码行来获取锁定顺序:

a.lock(); b.lock(); a.unlock(); c.lock()

然而,这种自由也伴随着责任。锁和解锁并不绑定到代码的执行顺序,就像在synchronized块的情况下,这可能会非常容易创建出在某些情况下只是丢失了锁而没有解锁,导致某些资源无法使用的代码。这种情况类似于内存泄漏:你会分配(锁定)某物,却忘记了释放(解锁)它。过了一段时间,程序将耗尽资源。

我个人的建议是尽可能避免使用锁,并使用线程之间的高级构造和异步通信,例如阻塞队列。

Condition

Java 的java.util.concurrent.Condition接口在功能上类似于内置的waitnotifynotifyAll。任何Lock的实现都应该创建新的Condition对象,并将它们作为newCondition方法调用的结果返回。当线程拥有Condition时,它可以在拥有创建该条件对象的锁时调用awaitsignalsignalAll

功能与前面提到的Object方法非常相似。然而,最大的区别是你可以为单个Lock创建多个Condition,它们将相互独立工作,但不是独立于Lock

ReentrantLock

ReentrantLock 是 JDK 中接口锁的最简单实现。创建此类锁有两种方式:带有公平策略和不带有公平策略。如果使用 ReentrantLock(Boolean fair) 构造函数并传入 true 参数,那么在存在多个等待线程的情况下,锁将被分配给等待时间最长的线程。这将避免线程被无限期地等待,从而避免饥饿。

ReentrantReadWriteLock

这个类是 ReadWriteLock 的实现。ReadWriteLock 是一种可以用于并行读访问和独占写访问的锁。这意味着多个线程可以读取由锁保护的资源,但当线程写入资源时,其他线程无法访问它,甚至在那个期间也不能读取。ReadWriteLock 简单来说就是由 readLockwriteLock 方法返回的两个 Lock 对象。要获取 ReadWriteLock 的读访问,代码必须调用 myLock.readLock().lock(),要获取写锁访问,则调用 myLock.writeLock().lock()。在实现中获取一个锁并释放它与另一个锁是耦合的。例如,要获取写锁,不应有任何线程持有活动的读锁。

在使用不同锁的过程中存在一些复杂性。例如,你可以获取一个读锁,但只要你有读锁,就无法获取写锁。你必须先释放读锁才能获取写锁。这只是简单细节中的一个,但这是新手程序员经常遇到的问题。为什么它要以这种方式实现?为什么程序在还不确定是否要写入资源时,应该获取更昂贵的写锁(在意义上是更高的概率锁定其他线程)?代码想要读取它,并且基于内容,它可能稍后会决定想要写入它。

问题不在于实现。库的开发者决定了这个规则,并不是因为他们只是喜欢这种方式,也不是因为他们意识到并行算法和死锁的可能性。当两个线程都持有读锁并且每个线程都决定将锁升级为写锁时,它们本质上会创建一个死锁。每个线程都会持有读锁等待写锁,而它们中的任何一个都不会再得到它。

在另一方面,你可以将写锁降级为读锁,而不用担心在此期间有人获取了写锁并修改了资源。

原子类

原子类将原始值封装到对象中,并提供了对它们的原子操作。我们讨论了竞态条件和volatile变量。例如,如果我们有一个用作计数的int变量,并希望为处理的对象分配一个唯一的值,我们可以递增值并使用结果作为唯一 ID。然而,当多个线程使用相同的代码时,我们无法确保在递增后读取的值。可能发生的情况是,在同时,另一个线程也递增了该值。为了避免这种情况,我们必须将递增和将递增后的值赋给对象的操作封装在一个synchronized块中。这也可以使用AtomicInteger来完成。

如果我们有一个AtomicInteger类型的变量,那么调用incrementAndGet会递增类中封装的int值,并返回递增后的值。为什么不用同步块来做这件事呢?第一个答案是,如果这个功能已经在 JDK 中存在,那么使用它比再次实现它要少写很多代码。维护你创建的代码的开发者预期应该了解 JDK 库,但不得不研究你的代码,这需要时间和金钱。

另一个原因是这些类高度优化,很多时候,它们使用特定平台的本地代码来实现功能,这比我们使用同步块实现的版本性能要好得多。过早担心性能并不好,但并行算法和线程间的同步通常在性能至关重要的场合使用;因此,使用原子类编写的代码的性能可能很重要。

java.util.concurrent.atomic包中,有几个类,其中包括AtomicIntegerAtomicBooleanAtomicLongAtomicReference。它们都提供了针对封装值的特定方法。

每个原子类都实现了一个方法,即compareAndSet。这是一个条件值设置操作,其格式如下:

boolean compareAndSet(expectedValue, updateValue);

当它应用于原子类时,它会比较实际值与expectedValue,如果它们相同,则将值设置为updateValue。如果值已更新,则方法返回true,并且它所有这些操作都是在原子操作中完成的。

你可能会问,如果这个方法在所有这些类中都有,为什么没有定义这个方法的Interface?原因在于根据封装的类型,参数类型不同,而这些类型是原始类型。由于原始类型不能用作泛型类型,甚至不能定义泛型接口。在AtomicXXXArray的情况下,方法有一个额外的第一个参数,即调用中处理的数组元素的索引。

封装变量在重新排序方面的处理方式与volatile相同,但有一些特殊方法稍微放宽了条件,以便在可能的情况下使用,并且性能是关键。

通常建议考虑使用原子类,如果有一个可用的,你会发现自己在创建用于检查和设置、原子递增或加法操作的同步块。

BlockingQueue

BlockingQueue是一个接口,它通过提供适合多线程应用程序使用的方法扩展了标准的Queue接口。任何实现此接口的类都提供了允许不同线程将元素放入队列、从队列中取出元素以及等待队列中元素的机制。

当有新元素要存储在队列中时,你可以add它,offer它,或者put它。这些是存储元素的名称,它们做的是同一件事,但略有不同。如果队列已满且没有空间容纳元素,add元素会抛出异常。offer元素不会抛出异常,但根据成功与否返回truefalse。如果它可以将元素存储在队列中,则返回true。还有一个指定超时的offer版本。该版本的方法会等待,如果在指定的时间内无法将值存储到队列中,则返回falseput元素是最简单的版本;它会等待直到可以执行其任务。

当谈论队列中的可用空间时,不要感到困惑,并将其与一般的 Java 内存管理混淆。如果没有更多内存,并且垃圾收集器也无法释放任何内存,你肯定会得到OutOfMemoryError错误。当队列达到限制时,add方法会抛出异常,而offer方法会返回false

BlockingQueue实现中获取元素也有四种不同的方式。在这种情况下,特殊情况是当队列为空时。在这种情况下,remove方法会抛出异常而不是返回元素,poll在没有元素时返回null,而take会等待直到可以返回一个元素。

最后,有两个从Queues接口继承的方法,它们不会从队列中消耗元素,只是查看element方法返回队列的头部,如果队列为空,则抛出异常,而peek方法在没有元素在队列中时返回null。以下表格总结了从接口文档中借用的操作:

抛出异常 特殊值 阻塞 超时
插入 add(e) offer(e) put(e) offer(e, time, unit)
移除 remove() poll() take() poll(time, unit)
检查 element() peek() 不适用 不适用

LinkedBlockingQueue

这是BlockingQueue接口的一个实现,它由一个链表支持。队列的大小默认不受限制(确切地说,是Integer.MAX_VALUE),但可以在构造函数参数中可选地限制。在这个实现中限制大小的原因是为了帮助当并行算法在有限大小的队列上表现更好时使用,但实现本身对大小没有任何限制。

LinkedBlockingDeque

这是BlockingQueue最简单的实现,也是其子接口BlockingDeque的实现。正如我们在上一章所讨论的,Deque是一个双端队列,它具有addremoveoffer等类型的方法,以xxxFirstxxxLast的形式出现,用于对队列的一端或另一端进行操作。Deque接口定义了getFirstgetLast,而不是始终使用elementFirstelementLast来命名,所以这是你应该习惯的东西。毕竟,IDEs 可以帮助自动完成代码,所以这不应该是一个真正的大问题。

ArrayBlockingQueue

ArrayBlockingQueue实现了BlockingQueue接口,因此也实现了 Queue 接口。这个实现管理一个具有固定大小元素的队列。在实现中,存储是一个数组,元素以FIFO(先进先出)的方式处理。这是我们将在 Mastermind 游戏的并行实现中使用,以实现老板和下属官僚之间的通信的类。

LinkedTransferQueue

TransferQueue接口扩展了BlockingQueue,在 JDK 中,它的唯一实现是LinkedTransferQueue。当线程想要将一些数据传递给另一个线程并确保有其他线程接收该元素时,TransferQueue非常有用。这个TransferQueue有一个transfer方法,它将一个元素放入队列,但不会返回,直到另一个线程remove(或poll)它。这样,生产线程可以确信队列中放置的对象已落入另一个处理线程手中,而不会在队列中等待。transfer方法还有一个tryTransfer格式,你可以指定一个超时值。如果方法超时,元素将不会放入队列。

IntervalGuesser

我们讨论了所有可用于实现并行算法的不同 Java 语言元素和 JDK 类。现在,我们将看到如何使用这些方法来实现 Masterrmind 游戏的并行猜数器。

执行猜测创建的类被命名为IntervalGuesser。它创建介于起始猜测和结束猜测之间的猜测,并将它们发送到一个BlockingQueue。这个类实现了Runnable接口,因此它可以在一个单独的Thread中运行。纯粹的实施将把Runnable功能与区间猜测分开,但由于整个类只有 50 行左右,将两个功能实现在一个类中是可以原谅的罪过。

public class IntervalGuesser extends UniqueGuesser implements Runnable { 
     private final Guess start; 
     private final Guess end; 
     private Guess lastGuess; 
     private final BlockingQueue<Guess> guessQueue; 

     public IntervalGuesser(Table table, Guess start, Guess end, BlockingQueue<Guess> guessQueue) { 
         super(table); 
         this.start = start; this.end = end; 
         this.lastGuess = start; 
         this.guessQueue = guessQueue; 
         nextGuess = start; 
     } 
     @Override 
     public void run() { 
         Guess guess = guess(); 
         try { 
             while (guess != Guess.none) { 
                 guessQueue.put(guess); 
                 guess = guess(); 
             } 
         } catch (InterruptedException ignored) { 
         } 
     } 
     @Override 
     protected Guess nextGuess() { 
         Guess guess; 
         guess = super.nextGuess(); 
         if (guess.equals(end)) { 
             guess = Guess.none; 
         } 
         lastGuess = guess; 
         return guess; 
     } 

     public String toString() { 
         return "[" + start + "," + end + "]"; 
     } 
 }

实现非常简单,因为大部分功能已经在前置的 Guesser 抽象类中实现。更有趣的代码是调用 IntervalGuesser 的代码。

ParallelGamePlayer

ParallelGamePlayer 类实现了定义了 play 方法的 Player 接口:

@Override 
 public void play() { 
     Table table = new Table(NR_COLUMNS, manager); 
     Secret secret = new RandomSecret(manager); 
     Guess secretGuess = secret.createSecret(NR_COLUMNS); 
     Game game = new Game(table, secretGuess); 
     final IntervalGuesser[] guessers = createGuessers(table); 
     startAsynchronousGuessers(guessers); 
     final Guesser finalCheckGuesser = new UniqueGuesser(table); 
     try { 
         while (!game.isFinished()) { 
             final Guess guess = guessQueue.take(); 
             if (finalCheckGuesser.guessMatch(guess)) { 
                 game.addNewGuess(guess); 
             } 
         } 
     } catch (InterruptedException ie) { 

     } finally { 
         stopAsynchronousGuessers(guessers); 
     } 
 }

此方法创建一个表,一个 RandomSecret,它以随机方式创建用于秘密猜测的代码,一个 Game 对象,IntervalGuessers 和一个 UniqueGuesserIntervalGuessers 是官僚主义者;UniqueGuesser 是老板,他交叉检查 IntervalGuessers 创建的猜测。该方法启动异步猜测器,然后循环读取它们的猜测,如果它们是正确的,就将它们放在桌子上,直到游戏结束。在方法结束时,在 finally 块中,异步猜测器被停止。

异步猜测器的启动和停止方法使用 ExecutorService

private ExecutorService executorService; 

 private void startAsynchronousGuessers( 
                                   IntervalGuesser[] guessers) { 
     executorService = Executors.newFixedThreadPool(nrThreads); 
     for (IntervalGuesser guesser : guessers) { 
         executorService.execute(guesser); 
     } 
 } 

private void stopAsynchronousGuessers( 
                                   IntervalGuesser[] guessers) { 
     executorService.shutdown(); 
     guessQueue.drainTo(new LinkedList<>()); 
 }

代码相当直接。唯一可能需要提及的是,猜测的队列被排空到一个我们之后不再使用的集合中。这是为了帮助任何手持建议猜测并试图将其放入队列中的 IntervalGuesser。当我们排空队列时,猜测器线程会从 IntervalGuesser 中的 guessQueue.put(guess); 行返回方法,并可以捕获中断。其余的代码不包含任何与我们之前看到的截然不同之处,你可以在 GitHub 上找到它。

我们在本章中还想讨论的最后一个问题是,我们通过使代码并行化获得了多少速度?

微基准测试

微基准测试是测量一小段代码的性能。当我们想要优化我们的代码时,我们必须对其进行测量。没有测量,代码优化就像蒙着眼睛射击。你不会击中目标,但你很可能会射中其他人。

射击是一个好的隐喻,因为你通常不应该这样做,但当你真的不得不这样做时,你别无选择。如果没有性能问题,软件满足要求,那么任何优化,包括速度测量,都是金钱的浪费。这并不意味着你被鼓励编写缓慢和草率的代码。当我们测量性能时,我们会将其与要求进行比较,而要求通常在用户层面。例如,应用程序的响应时间应小于 2 秒。为了进行此类测量,我们通常在测试环境中创建负载测试,并使用不同的分析工具,这些工具告诉我们什么消耗了最多时间,我们应该在哪里进行优化。很多时候,不仅限于 Java 代码,还包括配置优化、使用更大的数据库连接池、更多内存等类似的事情。

微基准测试是一个不同的话题。它关乎一小段 Java 代码的性能,因此更接近 Java 编程。

这种用法很少见,在开始为真实商业环境进行微基准测试之前,我们必须三思而后行。微基准测试是一个诱人的工具,可以在不知道是否值得优化代码的情况下优化一些小东西。当我们有一个由多个模块组成的大型应用程序,这些模块运行在多个服务器上时,我们如何确保改进应用程序的某个特殊部分会显著提高性能?这将通过增加的收入来偿还我们在性能测试和开发中投入的成本吗?从统计学的角度来看,几乎可以肯定,包括微基准测试在内的这种优化不会带来回报。

曾经我负责维护一位资深同事的代码。他编写了一个高度优化的代码来识别文件中存在的配置关键字。他创建了一个程序结构,该结构基于关键字字符串中的字符表示一个决策树。如果配置文件中存在拼写错误的关键字,代码会在第一个能够判断该关键字可能不正确的地方抛出异常。

要插入一个新关键字,需要通过代码结构找到代码中第一个新关键字与已存在的关键字不同的地方,并扩展深层嵌套的 if/else 结构。要读取处理的关键字列表,可以从注释中读取,注释中列出了他没有忘记记录的所有关键字。代码运行得非常快,可能节省了几个毫秒的 servlet 应用程序启动时间。应用程序仅在每几个月的系统维护后启动。

你感觉到了讽刺,不是吗?资深并不总是指年数。幸运的人可以保留他们的内心小孩。

那么,何时使用微基准测试呢?我可以看到两个领域:

  • 你已经确定了消耗你应用程序大部分资源的代码段,并且可以通过微基准测试来测试改进效果。

  • 你无法确定将消耗应用程序大部分资源的代码段,但你有所怀疑。

第一个是通常的情况。第二个是你开发了一个库,但你并不知道所有将使用它的应用程序。在这种情况下,你将尝试优化你认为对于大多数想象中的、怀疑的应用程序来说最关键的部分。即使在那种情况下,最好也收集一些由你的库的用户创建的示例应用程序,并收集一些关于它们使用的统计数据。

为什么我们要详细讨论微基准测试?有哪些陷阱?基准测试是一种实验。我写的第一个程序是 TI 计算器代码,我可以简单地计算程序执行分解两个大(当时是 10 位数)质数所需的步骤数。即使在那时,我也使用了一个老式的俄罗斯秒表来测量时间,因为懒得计算步骤数。实验和测量更容易。

现在,即使你想计算 CPU 的步骤数,也是不可能的。有太多微小的因素可能会改变应用程序的性能,而这些因素超出了程序员的控制范围,这使得计算步骤变得不可能。我们只剩下测量留给我们的,我们将获得所有测量问题。

最大的问题是什么?我们感兴趣的是某物,比如说 X,而我们通常无法测量它。所以,我们将测量 Y 而不是 X,并希望 YX 的值是相互关联的。我们想要测量房间的长度,但相反,我们测量激光束从一端到另一端所需的时间。在这种情况下,长度 X 和时间 Y 是强烈关联的。很多时候,XY 只是有一定的相关性。大多数时候,当人们进行测量时,XY 的值之间根本没有任何关系。尽管如此,人们还是把金钱和更多的东西押在了基于这种测量的决策上。

微基准测试也没有什么不同。第一个问题是如何测量执行时间?小代码运行时间短,System.currentTimeMillis() 可能会在测量开始和结束时返回相同的值,因为我们仍然在同一毫秒内。即使执行时间是 10ms,测量的误差至少也有 10%,纯粹是因为我们测量时间时的量化。幸运的是,有 System.nanoTime()。但是,它真的有吗?仅仅因为名字说它返回从特定开始时间以来的纳秒数,并不意味着它真的可以。

这在很大程度上取决于硬件和 JDK 中方法的实现。它被称为纳秒,因为这是我们无法肯定达到的精度。如果它是微秒,那么某些实现可能受到定义的限制,即使在某些特定的硬件上,有一个更精确的时钟。然而,这不仅仅是可用硬件时钟的精度;这是关于硬件的精度。

让我们记住官僚的心跳,以及从内存中读取某物所需的时间。调用一个方法,比如 System.nanoTime(),就像要求酒店的门童从二楼跑到大厅,向外窥视对面街道上塔楼上的时钟,然后回来,告诉我们在我们询问时的秒精度时间。荒谬。我们应该知道塔楼时钟的精度和门童从楼层跑到大厅再回来的速度。这不仅仅是调用 nanoTime。这就是微基准测试工具箱为我们做的事情。

Java 微基准测试工具JMH)作为库已经存在一段时间了。它由 Oracle 开发,用于调整一些核心 JDK 类的性能,并且从 Java 9 开始,这些性能测量和结果成为分布式 JDK 的一部分。这对那些为新型硬件开发 Java 平台的开发者来说是个好消息,同样对开发者来说也是个好消息,因为这意味着 JMH 将由 Oracle 支持。

"*JMH 是一个 Java 工具,用于构建、运行和分析用 Java 和其他语言编写的针对 JVM 的纳米/微/毫/宏基准测试。"(摘自 JMH 官方网站,openjdk.java.net/projects/code-tools/jmh/)。

你可以将 jmh 作为独立于实际测量项目的单独项目运行,或者你只需将测量代码存储在单独的目录中。工具将针对生产类文件进行编译并执行基准测试。在我看来,最简单的方法是使用 Gradle 插件来执行 JMH。你可以在名为 jmh 的目录(与 maintest 同级)中存储基准测试代码,并创建一个可以启动基准测试的 main

Gradle 构建脚本通过以下行进行了扩展:

buildscript { 
     repositories { 
         jcenter() 
     } 
     dependencies { 
         classpath "me.champeau.gradle:jmh-gradle-plugin:0.2.0" 
     } 
 } 
 apply plugin: "me.champeau.gradle.jmh" 

 jmh { 
     jmhVersion = '1.13' 
     includeTests = true 
 }

微基准测试类如下:

public class MicroBenchmark { 
     public static void main(String... args) 
                              throws IOException, RunnerException { 
         Options opt = new OptionsBuilder() 
                 .include(MicroBenchmark.class.getSimpleName()) 
                 .forks(1) 
                 .build(); 
         new Runner(opt).run(); 
     } 

     @State(Scope.Benchmark) 
     public static class ThreadsAndQueueSizes { 
         @Param(value = {"1", "4", "8"}) 
         String nrThreads; 
         @Param(value = { "-1","1", "10", "100", "1000000"}) 
         String queueSize; 
     } 

     @Benchmark 
     @Fork(1) 
     public void playParallel(ThreadsAndQueueSizes t3qs) throws InterruptedException { 
         int nrThreads = Integer.valueOf(t3qs.nrThreads); 
         int queueSize = Integer.valueOf(t3qs.queueSize); 
         new ParallelGamePlayer(nrThreads, queueSize).play(); 
     } 

     @Benchmark 
     @Fork(1) 
     public void playSimple(){ 
         new SimpleGamePlayer().play(); 
     } 

 }

ParallelGamePlayer 是为了使用 -1、1、4 和 8 个 IntervalGuesser 线程来玩游戏而创建的,并且每种情况下都有一个长度为 1、10、100 和 100 万的队列进行测试。这些是 16 次测试执行。当线程数为负数时,构造函数使用 LinkedBlockingDeque。还有另一个单独的测量,用于测量非并行玩家。测试使用了独特的猜测和秘密(没有使用超过一次的颜色)以及十种颜色和六列。

当工具启动时,它会自动进行校准,并运行多次测试以让 JVM 启动。你可能还记得,除非我们使用了 volatile 修饰符来为用于通知代码停止的变量,否则代码永远不会停止。这是因为 JIT 编译器优化了代码。这只有在代码已经运行了几千次之后才会进行。工具执行这些执行以预热代码并确保测量是在 JVM 全速运行时进行的。

在我的机器上运行这个基准测试大约需要 15 分钟。在执行过程中,建议停止所有其他进程,让基准测试使用所有可用资源。如果在测量过程中有任何进程使用资源,那么它将反映在结果中。

Benchmark     (nrThreads)  (queueSize) Score   Error 
playParallel            1           -1 15,636 &pm; 1,905 
playParallel            1            1 15,316 &pm; 1,237 
playParallel            1           10 15,425 &pm; 1,673 
playParallel            1          100 16,580 &pm; 1,133 
playParallel            1      1000000 15,035 &pm; 1,148 
playParallel            4           -1 25,945 &pm; 0,939 
playParallel            4            1 25,559 &pm; 1,250 
playParallel            4           10 25,034 &pm; 1,414 
playParallel            4          100 24,971 &pm; 1,010 
playParallel            4      1000000 20,584 &pm; 0,655 
playParallel            8           -1 24,713 &pm; 0,687 
playParallel            8            1 24,265 &pm; 1,022 
playParallel            8           10 24,475 &pm; 1,137 
playParallel            8          100 24,514 &pm; 0,836 
playParallel            8      1000000 16,595 &pm; 0,739 
playSimple            N/A          N/A 18,613 &pm; 2,040

程序的实际输出更为详细;为了打印目的进行了编辑。Score 列显示基准测试在一秒内可以运行多少次。Error 显示测量显示的散布小于 10%。

我们最快的性能是在算法在八个线程上运行时,这是在我的机器上处理器可以独立处理的线程数。有趣的是,限制队列的大小并没有帮助性能。我实际上预期它会不同。使用一个长度为一百万的数组作为阻塞队列有很大的开销,这并不奇怪,在这种情况下,执行速度比我们只有队列中 100 个元素时慢。另一方面,基于无限链表的队列处理相当快,并且清楚地表明,对于 100 个元素的有限队列,额外的速度并不是因为限制不允许IntervalThreads运行得太远。

当我们启动一个线程时,我们期望得到与运行串行算法相似的结果。串行算法打败了在单个线程上运行的并行算法并不令人惊讶。线程创建以及主线程和额外线程之间的通信都有开销。这种开销是显著的,尤其是在队列不必要地大时。

摘要

在本章中,你学到了很多东西。首先,我们将代码重构以准备进一步使用并行猜测的开发。我们熟悉了进程和线程,甚至提到了纤程。之后,我们研究了 Java 如何实现线程以及如何创建在多个线程上运行的代码。此外,我们还看到了 Java 为需要并行程序的开发者提供的不同手段,包括启动线程或只是启动现有线程中的某些任务。

本章最重要的部分可能是关于官僚和不同速度的隐喻。当你想要理解并发应用程序的性能时,这一点非常重要。我希望这是一个容易记住的生动画面。

关于 Java 提供的不同同步手段有一个很大的主题,你也学习了程序员在编写并发应用程序时可能会陷入的陷阱。

最后但同样重要的是,我们创建了 Mastermind 猜谜游戏的并发版本,并测量了它确实比仅使用一个处理器的版本快(至少在我的机器上)。我们使用了 Java Microbenchmark Harness 和 Gradle 构建工具,并讨论了如何进行微基准测试。

这是一个很长且不容易的一章。我可能倾向于认为这是最复杂和最理论化的一章。如果你第一次阅读时理解了一半,你可以感到自豪。另一方面,要意识到这只是一个好的基础,可以开始尝试并发编程,而在这个领域成为高级和专业人士还有很长的路要走。而且,这并不容易。但首先,在结束这一章时,要为自己感到自豪。

在接下来的章节中,我们将学习更多关于网页和网页编程的知识。在下一章,我们将开发我们的小游戏,使其能够在服务器上运行,玩家可以使用网页浏览器来玩。这将为我们建立网页编程的基本知识。稍后,我们将在此基础上开发基于网页的服务应用程序、响应式编程以及所有将使 Java 开发者成为专业人士的工具和领域。

第六章:让我们的游戏变得专业 - 以 Web 应用程序的形式来实现

在本章中,我们将编写一个 Web 应用程序。我们将基于我们已经取得的成果,创建一个 Mastermind 游戏的 Web 版本。这次,它不仅会独立运行,猜测并回答位置数和匹配颜色,还会与用户沟通,请求猜测的答案。这将是一个真正的游戏。对于 Java 程序员来说,Web 编程极其重要。大多数程序都是 Web 应用程序。互联网上可用的通用客户端是 Web 浏览器。基于瘦客户端、Web 浏览器架构在企业中得到了广泛接受。只有当架构有其他东西而不是 Web 客户端时,才会有一些例外。如果你想成为一名专业的 Java 开发者,你必须熟悉 Web 编程。而且这也很有趣!

在开发过程中,我们将探讨许多技术主题。首先,我们将讨论网络和 Web 架构。这是整个建筑的具体基础。它并不太吸引人,就像你建造大楼时一样。你花费大量的金钱和努力挖掘沟渠,然后埋下混凝土,最终在阶段结束时得到你看似之前就有的平坦地面。只不过这次有了基础。没有这个基础,房子要么很快就会倒塌,要么在建造过程中倒塌。网络对于网络编程同样重要。有很多看似与编程无关的主题。尽管如此,它是建筑的基础,当你编写 Web 应用程序时,你也会在其中找到乐趣的部分。

我们也会简单谈谈 HTML、CSS 和 JavaScript,但不会过多。我们无法避免它们,因为它们对于网络编程同样重要,但它们也是你可以从其他地方学习的内容。如果你在这些领域不是专家,企业项目团队中通常有其他专家可以扩展你的知识。(在网络方面,没有宽容可言。)此外,JavaScript 是一个如此复杂和庞大的主题,以至于它值得从一本整本书开始学习。只有极少数专家能够深入理解 Java 和 JavaScript。我理解语言的总体结构和它运行的环境,但鉴于我专注于其他领域,我无法跟上每周发布的新的框架。

你将学习如何创建在应用服务器上运行的 Java 应用程序,这次是在 Jetty 上,我们将了解什么是 servlet。我们将创建一个快速启动的 Web hello world 应用程序,然后我们将创建 Mastermind 的 servlet 版本。请注意,我们几乎从不直接编写 servlet,而不借助某些实现处理参数、认证以及许多其他非特定于应用程序的框架。在本章中,我们仍将坚持使用裸露的 servlet,因为如果不首先了解什么是 servlet,就无法有效地使用框架,如 Spring。Spring 将在下一章中介绍。

我们将提及 Java 服务器页面JSP)仅因为你可能遇到一些使用该技术开发的遗留应用程序,但现代 Web 应用程序不使用 JSP。尽管如此,JSP 是 servlet 标准的一部分,并且可供使用。还有一些最近开发的技术,但似乎在今天并不具备未来性。它们仍然可用,但只出现在遗留应用程序中,为新项目选择它们是相当可疑的。我们将在单独的部分中简要讨论这些技术。

到本章结束时,你将了解基本网络技术的工作原理以及主要架构元素是什么,你将能够创建简单的 Web 应用程序。这还不足以成为一名专业的 Java Web 开发者,但将为下一章打下良好的基础,在下一章中,我们将探讨当今企业在实际应用程序开发中使用的专业框架。

网络和网络

程序在计算机上运行,计算机连接到互联网。这个网络在过去 60 年里发展起来,最初是为了提供对火箭攻击具有弹性的军事数据通信,然后它被扩展为一个学术网络,后来它成为任何人都可以使用的商业网络,几乎遍布地球的每个角落。

网络的设计和研究始于对五十年代加加林飞越地球的反应。将加加林送入太空并在地球上方飞行是俄罗斯能够将火箭发射到地球上任何地方的证明,可能带有原子弹。这意味着任何需要某种中央控制的数据网络都无法抵御这种攻击。拥有一个中心位置的单一故障点的网络是不可行的。因此,开始进行研究以创建即使任何部分被摧毁也能继续工作的网络。

IP

网络在连接到它的任意两台计算机之间传递数据包。网络使用的协议是 IP,这只是一个互联网协议的缩写。使用 IP,一台计算机可以向另一台发送数据包。数据包包含一个头部和数据内容。头部包含发送者和目标机器的互联网地址,其他标志和有关数据包的信息。由于机器之间不是直接连接的,因此路由器转发数据包。这就像邮局之间互相发送邮件,直到邮件落入你认识的邮递员手中,他可以直接将邮件送到你的邮箱。为了做到这一点,路由器使用头部中的信息。路由器交互的算法和组织结构很复杂,但我们不需要知道这些,因为我们不是 Java 专业人士。

如果你需要编程来直接发送 IP 数据包,你应该查看java.net.DatagramPacket,其余的由 JDK、操作系统和网络卡的固件实现。你可以创建一个数据包;发送它并改变网络卡上的调制电压或发射光子不是你的头疼事。然而,你们都将知道是否真的需要直接编程数据包。

IP 有两个版本。仍在使用的旧版本是 IPv4。与旧版本共存的新版本是 IPv6 或 IPng(ng代表新一代)。可能让 Java 开发者关心的主要区别是,版本 4 使用 32 位地址,而版本 6 使用 128 位地址。当你看到版本 4 的地址时,你会看到类似192.168.1.110的东西,它包含四个以点分隔的十进制格式的字节。IPv6 地址表示为2001:db8:0:0:0:0:2:1,这是以十六进制表示的八个 16 位数字,以冒号分隔。

网络比发送数据包要复杂一些。如果发送数据包就像发送一页信件,那么网页下载就像通过纸质邮件讨论合同。在最初的纸质邮件中应该有一个协议,说明要发送什么,要回答什么,等等,直到合同签署。在互联网上,这个协议被称为传输控制协议TCP)。虽然遇到 IP 路由问题的可能性非常小(但有可能),但作为一个 Java 开发者,你肯定会遇到 TCP 编程。因此,我们将简要介绍 TCP 的工作原理。请注意,这非常简短。真的。阅读下一节后,你不会成为 TCP 专家,但你将了解影响网络编程的最重要问题。

TCP/IP

TCP 协议在操作系统中得到实现,并且提供了比 IP 协议更高层次的接口。当你编程 TCP 时,你不需要处理数据报。相反,你有一个字节流通道,你可以将字节放入要发送到另一台计算机的通道中,你也可以从通道中读取由另一台计算机发送的字节,顺序与发送时的顺序相同。这是一种在两台计算机之间,以及两个程序之间的连接。

有其他协议是在 IP 协议之上实现的,并且不是面向连接的。其中之一是用户数据报协议UDP),用于不需要连接的服务,当数据可能丢失时,及时到达目的地比丢失一些数据包更重要(视频流、电话)。当数据量小且未送达时,可以再次请求;丢失它的成本很低(DNS 请求,见下一节)。

当数据包在网络中丢失,或者发送了两次,或者比后续数据包先到达时,它将由操作系统实现的 TCP 软件层处理。这个层也通常被称为TCP 栈

由于 TCP 是一个面向连接的协议,因此需要一个机制来告诉 TCP 栈当数据报到达时它属于哪个流。流通过两个端口号来识别。端口号是一个 16 位的整数。一个端口号用来标识发起连接的程序,称为源端口号。另一个端口号用来标识目标程序:目标端口号。这些信息包含在每个 TCP 数据包中。当一台机器运行一个安全外壳SSH)服务器和一个 Web 服务器时,它们使用不同的端口号,通常是 22 号端口和 80 号端口。当一个包含目标端口号 22 的 TCP 头部的数据包到达时,TCP 栈就知道数据包中的数据属于由 SSH 服务器处理的流。同样,如果目标端口号是 80,那么数据就会发送到 Web 服务器。

当我们编程服务器时,通常需要定义端口号;否则,客户端将无法找到服务器程序。Web 服务器通常监听 80 号端口,客户端尝试连接到该端口。客户端端口号通常不重要且未指定;它由 TCP 栈自动分配。

从客户端代码连接到服务器很简单:只需要几行代码。有时,可能只需要一行代码。然而,在底层,TCP 栈做了很多工作,这是我们应当关注的——建立 TCP 连接需要时间。

为了建立连接,TCP 堆栈必须向目的地发送一个数据报以确认其存在。如果没有服务器在端口上监听,通过网络发送数据将没有任何结果,除了浪费网络带宽。因此,客户端首先发送一个名为 SYN 的空数据包。当另一端收到它时,它会发送一个类似的包,称为 SYN-ACK。最后,客户端发送一个名为 ACK 的包。如果数据包穿越大西洋,每个包大约需要 45ms,这在官僚时间中相当于 4500 万秒。这几乎是 1 年半的时间。我们需要三个这样的数据包来建立连接,而且还有更多。

当一个 TCP 连接开始时,客户端在没有控制的情况下不会开始发送数据。它会发送一些数据包,然后等待服务器确认它们的接收。如果发送服务器尚未准备接受的数据,那么发送这些数据不仅毫无用处,而且还会造成网络资源的浪费。TCP 被设计用来优化网络使用。因此,客户端发送一些数据后,它会等待确认。TCP 堆栈会自动管理这个过程。如果收到确认,它会发送更多的数据包。如果 TCP 堆栈中实现的一个精心设计的优化算法认为发送更多数据是合适的,它将发送比第一步更多的数据。如果有负确认告诉客户端服务器无法接受某些数据并不得不丢弃它们,那么客户端将减少未确认发送的数据包数量。但首先它会开始得慢而谨慎。这被称为 TCP 慢启动,我们必须对此有所了解。尽管这是一个低级网络特性,但它对我们的 Java 代码有影响,我们必须考虑:我们使用数据库连接池而不是每次需要数据时都创建新的数据库连接;我们尝试通过使用诸如keep-aliveSPDY协议或http/2.0(也取代了 SPDY)等技术来尽量减少与 Web 服务器的连接数。

首先,只要 TCP 是面向连接的,你就可以建立一个与服务器的连接,发送和接收字节,最后关闭连接。当你遇到网络性能问题时,你必须查看我列出的那些问题。

DNS

TCP 协议使用机器的 IP 地址创建一个通道。当你在一个浏览器中输入一个 URL 时,它通常不包含 IP 号码。它包含机器名称。该名称通过一个称为域名系统DNS)的分布式数据库转换为 IP 号码。这个数据库是分布式的,当一个程序需要将名称转换为地址时,它会向它所知道的 DNS 服务器之一发送 DNS 请求。这些服务器相互查询或告诉客户端向谁询问,直到客户端知道分配给该名称的 IP 地址。服务器和客户端还会缓存最近请求的名称,因此响应速度快。另一方面,当服务器的 IP 地址发生变化时,这个名称,全球上的所有客户端不会立即看到地址分配。DNS 查找可以很容易地编程,JDK 中有支持这一点的类和方法,但通常我们不需要关心这一点;当我们编程时,在 Web 编程中它是自动完成的。

HTTP 协议

超文本传输协议HTTP)建立在 TCP 之上。当你在一个浏览器中输入一个 URL 时,浏览器会打开一个 TCP 通道到服务器(当然是在 DNS 查找之后)并发送一个 HTTP 请求到 Web 服务器。服务器在收到请求后,生成一个响应并发送给客户端。之后,TCP 通道可能会关闭或保持活跃以进行进一步的 HTTP 请求-响应对。

请求和响应都包含一个头部和一个可选的(可能为零长度)正文。头部是文本格式,并且通过一个空行与正文分隔。

更精确地说,标题和正文之间由四个字节分隔:0x0D0x0A0x0D0x0A,这是两个CRLF行分隔符。HTTP 协议使用回车和换行符来终止标题中的行,因此,一个空行是两个CRLF连续出现。

头部的开始是一个状态行加上头部字段。以下是一个示例 HTTP 请求:

GET /html/rfc7230 HTTP/1.1 
Host: tools.ietf.org 
Connection: keep-alive 
Pragma: no-cache 
Cache-Control: no-cache 
Upgrade-Insecure-Requests: 1 
User-Agent: Mozilla/5.0 (Macintosh; Intel Mac OS X 10_11_6) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/52.0.2743.116 Safari/537.36 
Accept: text/html,application/xhtml+xml,application/xml;q=0.9,image/webp,*/*;q=0.8 
DNT: 1 
Referer: https://en.wikipedia.org/ 
Accept-Encoding: gzip, deflate, sdch, br 
Accept-Language: en,hu;q=0.8,en-US;q=0.6,de;q=0.4,en-GB;q=0.2

以下是一个响应:

HTTP/1.1 200 OK 
Date: Tue, 04 Oct 2016 13:06:51 GMT 
Server: Apache/2.2.22 (Debian) 
Content-Location: rfc7230.html 
Vary: negotiate,Accept-Encoding 
TCN: choice 
Last-Modified: Sun, 02 Oct 2016 07:11:54 GMT 
ETag: "225d69b-418c0-53ddc8ad0a7b4;53e09bba89b1f" 
Accept-Ranges: bytes 
Cache-Control: max-age=604800 
Expires: Tue, 11 Oct 2016 13:06:51 GMT 
Content-Encoding: gzip 
Strict-Transport-Security: max-age=3600 
X-Frame-Options: SAMEORIGIN 
X-Xss-Protection: 1; mode=block 
X-Content-Type-Options: nosniff 
Keep-Alive: timeout=5, max=100 
Connection: Keep-Alive 
Transfer-Encoding: chunked 
Content-Type: text/html; charset=UTF-8 

<!DOCTYPE html PUBLIC "-//W3C//DTD XHTML 1.0 Transitional//EN" 
  "http://www.w3.org/TR/xhtml1/DTD/xhtml1-transitional.dtd"> 
<html  xml:lang="en" lang="en"> 
<head profile="http://dublincore.org/documents/2008/08/04/dc-html/"> 
    <meta http-equiv="Content-Type" content="text/html; charset=utf-8" /> 
    <meta name="robots" content="index,follow" />

请求不包含正文。状态行如下:

GET /html/rfc7230 HTTP/1.1

它包含所谓的请求方法,请求的对象以及请求使用的协议版本。请求头部的其余部分包含格式为label : value的头部字段。在打印版本中,某些行被换行,但在头部行中没有换行符。

响应指定了它使用的协议(通常与请求相同),状态码以及状态的消息格式:

HTTP/1.1 200 OK

之后,响应字段以与请求相同的语法出现。一个重要的头部是内容类型:

Content-Type: text/html; charset=UTF-8

它指定响应体(在打印输出中被截断)是 HTML 文本。

实际请求被发送到了 URL,tools.ietf.org/html/rfc7230,这是定义 HTTP 1.1 版本的规范。你可以轻松地自己查看通信,启动浏览器并打开开发者工具。如今,这样的工具已经内置在每一个浏览器中。你可以用它来在网络应用程序级别调试程序行为,查看实际的 HTTP 请求和响应的字节级信息。以下截图显示了开发者工具如何显示这种通信:

HTTP 方法

请求状态行中的第一个单词所表示的方法告诉服务器如何处理该请求。标准定义了不同的方法,例如GETHEADPOSTPUTDELETE以及一些其他方法。

当客户端想要获取资源的内 容时,它会使用GET方法。在GET请求的情况下,请求体是空的。这是浏览器在我们下载网页时使用的方法。它也是,很多时候,当在浏览器中运行的 JavaScript 程序请求一些信息时使用的方法,但它不想向服务器发送太多信息。

当客户端使用POST时,通常的意图是向服务器发送一些数据。服务器确实会回复,很多时候,回复中也有一个体,但请求/回复通信的主要目的是从客户端向服务器发送一些信息。这在某种程度上与GET方法相反。

GETPOST方法是使用最频繁的方法。尽管有一般性指南建议使用GET来检索数据,使用POST将数据发送到服务器,但这只是一个建议,两种情况之间没有清晰的分离。很多时候,GET也会用来向服务器发送一些数据。毕竟,它是一个带有状态行和头字段的 HTTP 请求,尽管请求中没有主体,但状态行中方法后面的对象(URL 的一部分)仍然能够传递参数。很多时候,测试响应GET请求的服务也很容易,因为你只需要一个浏览器,输入带有参数的 URL,然后在浏览器开发者工具中查看响应。如果你看到使用GET请求执行修改 Web 服务器状态的操作的程序,你不必感到惊讶。然而,不感到惊讶并不意味着认可。你应该意识到,在大多数情况下,这并不是好的做法。当我们使用GET请求发送敏感信息时,URL 中的参数在浏览器的地址行中可供客户端访问。当我们使用POST发送时,参数仍然可以被客户端访问(毕竟,客户端发送的信息是由客户端生成的,因此不能不可用),但对于一个简单的、对安全性无知的用户来说,复制粘贴信息并发送给第三方并不那么容易。使用GETPOST的决定应始终考虑实际性和安全问题。

HEAD方法与GET请求相同,但响应将不包含主体。当客户端对实际响应不感兴趣时,会使用这种方法。可能的情况是客户端已经拥有该对象,并想查看它是否已更改。Last-Modified头将包含资源最后更改的时间,客户端可以决定是否拥有更新的版本或需要在新请求中请求资源。

当客户端想要在服务器上存储某些内容时,使用PUT方法,而当客户端想要删除某些资源时,使用DELETE方法。这些方法通常只由用 JavaScript 编写的应用程序使用,而不是直接由浏览器使用。

标准中定义了其他方法,但这些都是最重要且最常使用的方法。

状态码

响应以状态码开始。这些代码也是定义好的,响应中可用的代码数量有限。最重要的是200,表示一切正常;响应包含请求所需的内容。这些代码始终在100599的范围内,包含三位数字,并按第一位数字分组。

  • 1xx: 这些代码是信息代码。它们很少使用,但在某些情况下可能非常重要。例如,100 表示继续。当服务器收到 POST 请求并且服务器想要向客户端发出信号以发送请求体,因为它可以处理它时,可以发送此代码。如果服务器和客户端正确实现此代码,并且客户端等待此代码,可能会节省大量带宽。

  • 2xx: 这些代码表示成功。请求得到了适当的回答,或者请求的服务已经完成。标准中定义了诸如 200201202 等代码,并描述了何时使用其中一个或另一个。

  • 3xx: 这些代码表示重定向。当服务器无法直接服务请求但知道可以服务的 URL 时,会发送这些代码之一。实际的代码可以区分永久重定向(当已知所有未来的请求都应该发送到新的 URL 时)和临时重定向(当任何后续请求都应该发送到这里,并且可能被服务或重定向时),但决定保留在服务器端。

  • 4xx: 这些是错误代码。最著名的代码是 404,表示未找到,即服务器无法响应请求,因为找不到资源。401 表示可能可以提供请求的资源,但它需要身份验证。403 是一个表示请求有效但服务器仍然拒绝服务的代码。

  • 5xx: 这些代码是服务器错误代码。当响应包含这些错误代码之一时,其含义是服务器存在某些错误。这种错误可能是临时的,例如,当服务器正在处理过多的请求并且无法以计算密集型的响应响应新的请求(这通常由错误代码 503 表示)或当功能未实现(代码 501)时。一般错误代码 500 被解释为内部错误,这意味着关于服务器上发生什么错误没有任何信息,但它并不好,因此没有有意义的响应。

HTTP/2.0

自从上次 HTTP 发布以来近 20 年后,新的 HTTP 版本于 2015 年发布。这个新版本的协议在之前版本的基础上有几个增强。其中一些增强也将影响服务器应用程序的开发方式。

最重要和首次增强的是,新协议将使得在单个 TCP 连接中并行发送多个资源成为可能。保持连接的标志可用于避免 TCP 通道的重新创建,但当响应创建缓慢时,这并没有帮助。在新协议中,即使一个请求尚未完全服务,其他资源也可以在相同的 TCP 通道中传输。这需要在协议中处理复杂的包,但这一点对服务器应用程序程序员和浏览器程序员都是透明的。应用服务器、Servlet 容器和浏览器都透明地实现了这一点。

HTTP/2.0 将始终是加密的,因此不可能在浏览器 URL 中使用http作为协议。它始终是https

在 Servlet 编程中需要修改以利用新版本协议优势的功能是服务器推送。Servlet 规范的第 4.0 版包括对 HTTP/2.0 的支持,而这个版本目前仍处于草案阶段。

服务器推送是对未来请求的 HTTP 响应。服务器如何回答一个尚未发出的请求呢?嗯,服务器是预测的。例如,应用程序发送一个包含许多小图片和图标的 HTML 页面。客户端下载 HTML 页面,构建 DOM 结构,分析它,并意识到需要这些图片,然后发送请求。应用程序程序员知道有哪些图片,并且可能编写代码让服务器在浏览器请求之前就发送图片。每个这样的响应都包含一个 URL,即这个响应是为哪个 URL 准备的。当浏览器需要资源时,它会意识到资源已经存在,因此不会发出新的请求。在HttpServlet中,程序应通过请求的新getPushBuilder方法访问PushBuilder,并使用它将资源推送到客户端。

Cookies

Cookies 由浏览器维护,并在 HTTP 请求头中使用Cookie头字段发送。每个 cookie 都有一个名称、值、域名、路径、过期时间和一些其他参数。当请求发送到与域名匹配的 URL,且非过期的 cookie 路径时,客户端会将 cookie 发送到服务器。Cookies 通常由浏览器存储在客户端的小文件中,或者存储在本地数据库中。实际的实现是浏览器的事务,我们无需担心。它只是客户端不执行的文字信息。只有当某些规则(主要是域名和路径)匹配时,它才会被发送回服务器。Cookies 由服务器创建,并在 HTTP 响应中使用Set-Cookie头字段发送给客户端。因此,本质上服务器告诉客户端,嘿,这里有这个 cookie,当你下次来的时候,给我展示这条信息,这样我就知道是你了。

Cookies 通常用于记住客户端。广告商和需要记住与谁交流的在线商店大量使用它。但这并不是唯一用途。如今,任何维护用户会话的应用程序都使用 cookies 来链接来自同一用户的 HTTP 请求。当你登录到应用程序时,你用来识别自己的用户名和密码只发送到服务器一次,在随后的请求中,只发送一个特殊的 cookie 到服务器,用于识别已经登录的用户。这种使用 cookie 的方式强调了为什么使用难以猜测的 cookie 值非常重要。如果用于识别用户的 cookie 容易被猜测,那么攻击者只需创建一个 cookie 并发送到服务器,模仿其他用户即可。出于这个目的,cookie 值通常是长的随机字符串。

Cookies 并不总是被发送回它们起源的服务器。当设置 cookie 时,服务器会指定一个 URL 域名,该域名用于将 cookie 发送回。这通常发生在需要认证的服务器之外的其他服务器执行用户认证时。

应用程序有时会将值编码到 cookie 中。这并不一定不好,尽管在大多数实际情况下是这样的。当将某些内容编码到 cookie 中时,我们应始终考虑这样一个事实:cookie 会通过网络传输,并且随着越来越多的数据被编码其中,可能会变得非常大,从而给网络带来不必要的负担。通常,最好只发送一些独特的、否则无意义的随机密钥,并将值存储在数据库中,无论是磁盘上的还是内存中的。

客户端服务器和 Web 架构

我们迄今为止开发的应用程序都是在单个 JVM 上运行的。我们已经有了一些并发编程的经验,现在这将非常有用。当我们编写 Web 应用程序时,代码的一部分将在服务器上运行,而应用程序逻辑的一部分将在浏览器中执行。服务器部分将用 Java 编写,浏览器部分将用 HTML、CSS 和 JavaScript 实现。由于这是一本 Java 书,我们将主要关注服务器部分,但我们仍然应该意识到,许多功能可以也应该在浏览器中实现。这两个程序通过 IP 网络(即互联网)或企业内部应用程序的情况,即公司的网络相互通信。

今天,浏览器能够运行非常强大的应用程序,所有这些应用程序都是用 JavaScript 实现的。几年前,这样的应用程序需要用 Delphi、C++或 Java 编写的客户端应用程序,利用客户端操作系统的窗口功能。

最初,客户端-服务器架构意味着应用程序的功能是在客户端实现的,程序仅使用服务器的一般服务。服务器提供数据库访问和文件存储,但仅此而已。后来,三层架构将业务功能放在使用其他服务器进行数据库和其他一般服务的服务器上,客户端应用程序实现用户界面和有限业务功能。

当网络技术开始渗透企业计算时,网络浏览器开始取代许多用例中的客户端应用程序。在此之前,浏览器无法运行复杂的 JavaScript 应用程序。应用程序是在网络服务器上执行的,客户端显示服务器创建的 HTML,作为应用程序逻辑的一部分。每当用户界面发生变化时,浏览器就会与服务器建立通信,并在 HTTP 请求-响应对中,浏览器内容被替换。一个网络应用程序本质上是一系列表单填写和向服务器发送表单数据,服务器则以 HTML 格式的页面响应,可能包含新的表单。

开发了 JavaScript 解释器,并且变得越来越有效和标准化。今天,现代网络应用程序包含 HTML(它是客户端代码的一部分,不是由服务器动态生成的)、CSS 和 JavaScript。当代码从网络服务器下载时,JavaScript 开始执行并与服务器通信。它仍然是 HTTP 请求和响应,但响应不包含 HTML 代码。它包含纯数据,通常是 JSON 格式。这些数据被 JavaScript 代码使用,如果需要,一些数据也会在由 JavaScript 控制的网络浏览器显示上显示。这在功能上等同于三层架构,但有一些细微但非常重要的差异。

第一个不同之处在于代码没有安装在客户端。客户端从网络服务器下载应用程序,唯一安装的是现代浏览器。这减少了企业维护负担和成本。

第二个不同之处在于客户端无法或受限地访问客户端机器的资源。厚客户端应用程序可以在本地文件中保存任何内容或访问本地数据库。与在浏览器上运行的程序相比,这非常有限,出于安全原因。同时,这也是一个方便的限制,因为客户端不应且不应该是架构中受信任的部分。客户端计算机的硬盘又硬又贵,备份成本高昂。它可以被笔记本偷走,加密它也很昂贵。有工具可以保护客户端存储,但大多数情况下,仅在服务器上存储数据是一个更可行的解决方案。

信任客户端应用程序也是常见的程序设计错误。客户端实际上控制着客户端计算机,尽管在技术上可以使其变得非常困难,但客户端仍然可以克服客户端设备和客户端代码的安全限制。如果只有客户端应用程序检查某些功能或数据的有效性,那么服务器物理控制提供的物理安全就不会被利用。每当数据从客户端发送到服务器时,都必须检查数据的有效性,无论客户端应用程序是什么。实际上,由于客户端应用程序可以更改,我们实际上并不真正知道客户端应用程序是什么。

在本章中,实际上在整个书中,我们专注于 Java 技术;因此,示例应用程序将几乎不包含任何客户端技术。我忍不住创建了一些 CSS。另一方面,我肯定避免了 JavaScript。因此,我必须再次强调,示例是为了演示服务器端编程,并且仍然提供真正有效的东西。一个现代应用程序会使用 REST 和 JSON 通信,而不会在服务器端动态创建 HTML。最初,我想创建一个 JavaScript 客户端和 REST 服务器应用程序,但由于服务器端 Java 编程的焦点转移得太多,我放弃了这个想法。另一方面,你可以扩展应用程序,使其类似于那样。

编写 Servlet

Servlet 是 Java 类,在实现 servlet 容器环境的 Web 服务器中执行。最初的 Web 服务器只能向浏览器提供静态 HTML 文件。对于每个 URL,在 Web 服务器上都有一个 HTML 页面,服务器在浏览器发送请求后,将文件的內容作为响应发送。很快,就有必要扩展 Web 服务器,使其能够在处理请求时动态启动一些程序来计算响应的内容。

第一个实现这一功能的标准化方法是 CGI。它启动了一个新的进程来响应请求。新的进程从其标准输入获取请求,并将标准输出发送回客户端。这种方法浪费了大量的资源。正如你在上一章中学到的,仅仅为了响应一个 HTTP 请求而启动一个新的进程是非常昂贵的。甚至启动一个新的线程似乎也是不必要的,但在这方面,我们已经走得很远了。

下一个方法是 FastCGI,它持续执行外部进程并重用它,然后出现了不同的其他方法。在 Servlet 之后的方法是

FastCGIall use in-process extensions. In these cases, the code calculating the response runs inside the same process as the web server. Such standards or extension interfaces were ISAPI for the Microsoft IIS server, NSASPI for the Netscape server, and the Apache module interface. Each of these made it possible to create a dynamically loaded library (DLL on Windows or SO files on Unix systems) to be loaded by the web server during

startupand to map certain requests to be handled by the code implemented in these libraries.

当某人编写 PHP 程序时,例如,Apache 模块扩展是读取 PHP 代码并对其采取行动的 PHP 解释器。当某人编写用于 Microsoft IIS 的 ASP 页面时,执行 ASP 页面解释器的 ISAPI 扩展(好吧,这样说有点草率和简化,但可以作为例子)。

对于 Java 来说,接口定义是 JSR340 版本 3.1 中定义的 servlet。

JSR 代表 Java 规范请求。这些是对 Java 语言、库接口和其他组件修改的请求。这些请求经过评估过程,当它们被接受时,它们成为标准。这个过程由 Java 社区进程(JCP)定义。JCP 也有文档和版本。当前版本是 2.10,可以在jcp.org/en/procedures/overview找到。JSR340 标准可以在jcp.org/en/jsr/detail?id=340找到。

一个 servlet 程序实现了 servlet 接口。通常这是通过扩展 HttpServlet,即 Servlet 接口的抽象实现来完成的。这个抽象类实现了方法,如 doGetdoPostdoPutdoDeletedoHeaddoOptiondoTrace,这些方法可以由扩展它的实际类自由覆盖。如果一个 servlet 类没有覆盖这些方法之一,发送相应的 HTTP 方法,如 GETPOST 等,将返回 405 Not Allowed 状态码。

Hello world servlet

在深入技术细节之前,让我们创建一个非常简单的 hello world servlet。为此,我们设置了一个 Gradle 项目,包括构建文件 build.gradle,在文件 src/main/java/packt/java9/by/example/mastermind/servlet/HelloWorld.java 中的 servlet 类,最后但同样重要的是,我们必须创建文件 src/main/webapp/WEB-INF/web.xmlgradle.build 文件将如下所示:

apply plugin: 'java' 
 apply plugin: 'jetty' 

 repositories { 
     jcenter() 
 } 

 dependencies { 
     providedCompile "javax.servlet:javax.servlet-api:3.1.0" 
 } 

 jettyRun { 
     contextPath '/hello' 
 }

Gradle 构建文件使用了两个插件,javajetty。我们已经在上一章中使用了 java 插件。jetty 插件添加了如 jettyRun 这样的任务,这些任务加载 Jetty servlet 容器并启动应用程序。jetty 插件也是 war 插件的扩展,它将 Web 应用程序编译成 Web Archive (WAR`) 包装格式。

WAR 打包格式实际上与 JAR 相同;它是一个 zip 文件,它包含一个lib目录,其中包含所有 Web 应用程序所依赖的 JAR 文件。应用程序的类位于WEB-INF/classes目录中,并且有一个WEB-INF/web.xml文件,该文件描述了 servlet URL 映射,我们将在不久的将来详细探讨。

由于我们想要开发一个非常简单的 servlet,我们将 servlet API 作为依赖项添加到项目中。然而,这并不是一个编译依赖项。当 servlet 在容器中运行时,API 是可用的。尽管如此,它必须在编译我们的代码时可用;因此,通过指定为providedCompile的工件提供了一个虚拟实现。因为是这样指定的,构建过程不会将库打包到生成的 WAR 文件中。生成的文件将不包含任何特定于 Jetty 或其他 servlet 容器的特定内容。

servlet 容器将提供 servlet 库的实际实现。当应用程序在 Jetty 中部署并启动时,servlet 库的 Jetty 特定实现将可在类路径上使用。当应用程序部署到 Tomcat 时,将可用 Tomcat 特定实现。

在我们的项目中创建一个类,如下所示:

package packt.java9.by.example.mastermind.servlet; 

 import javax.servlet.ServletException; 
 import javax.servlet.http.HttpServlet; 
 import javax.servlet.http.HttpServletRequest; 
 import javax.servlet.http.HttpServletResponse; 
 import java.io.IOException; 
 import java.io.PrintWriter; 

 public class HelloWorld extends HttpServlet { 

     private String message; 

     @Override 
     public void init() throws ServletException { 
         message = "Hello, World"; 
     } 

     @Override 
     public void doGet(HttpServletRequest request, 
                       HttpServletResponse response) 
             throws ServletException, IOException { 
         response.setContentType("text/html"); 
         PrintWriter out = response.getWriter(); 
         out.println("<h1>" + message + "</h1>"); 
     } 

     @Override 
     public void destroy() { 
     } 
 }

当 servlet 启动时,将调用init方法。当它被移出服务时,将调用destroy方法。这些方法可以被重写,并提供比构造函数和其他最终化可能性更细粒度的控制。servlet 对象可以被多次放入服务,在调用destroy之后,servlet 容器可能会再次调用init;因此,这个周期并不严格绑定到对象的生命周期。通常,在这些方法中我们不会做很多事情,但有时你可能需要在其中添加一些代码。

此外,请注意,单个 servlet 对象可以用来处理多个请求,甚至可以同时处理;因此,其中的 servlet 类和方法应该是相当线程安全的。规范要求在容器在非分布式环境中运行时,servlet 容器只使用一个 servlet 实例。如果容器在同一个机器上的多个进程中运行,每个进程执行一个 JVM,或者甚至在不同的机器上运行,可能会有许多 servlet 实例来处理请求。通常,servlet 类应该设计成它们不假设只有一个线程在执行它们,但同时也不能假设实例对不同的请求是相同的。我们根本无法知道。

这在实践中意味着什么?你不应该使用特定于某个请求的实例字段。在示例中,初始化以保存消息的字段对每个请求都保持相同的值;本质上,这个变量几乎是一个最终的常量。它仅用于演示init方法的一些功能。

当 Servlet 容器接收到一个使用GET方法的 HTTP 请求时,会调用doGet方法。该方法有两个参数。第一个参数代表请求,第二个参数代表响应。可以使用request来收集请求中包含的所有信息。在先前的例子中,并没有这样的操作。我们并没有使用任何输入。如果有一个请求发送到我们的 Servlet,那么无论什么情况,我们都将返回Hello, World字符串。稍后,我们将看到从请求中读取参数的例子。response提供了可以用来处理输出的方法。在例子中,我们获取了PrintWriter,它将被用来向 HTTP 响应的主体发送字符。这是在浏览器中显示的内容。我们发送的 MIME 类型是text/html,这是通过调用setContentType方法设置的。这将进入 HTTP 头字段Content-Type。类的标准文档和 JavaDoc 文档定义了所有可以使用的的方法,以及如何使用这些方法。

最后,我们有一个web.xml文件,它声明了我们代码中实现的 Servlet。正如文件名所表明的,这是一个 XML 文件。它声明性地定义了存档中包含的所有 Servlet 以及其他参数。在例子中,参数没有被定义,只有 Servlet 和 URL 的映射。由于在这个例子中我们只有一个 Servlet,所以 WAR 文件被映射到根上下文。所有到达 Servlet 容器和这个存档的GET请求都将由这个 Servlet 来处理:

<?xml version="1.0" encoding="UTF-8"?> 
<web-app version="2.5" 

         xsi:schemaLocation="http://java.sun.com/xml/ns/javaee http://java.sun.com/xml/ns/javaee/web-app_2_5.xsd"> 

    <servlet> 
        <display-name>HelloWorldServlet</display-name> 
        <servlet-name>HelloWorldServlet</servlet-name> 
        <servlet-class>packt.java9.by.example.mastermind.servlet.HelloWorld</servlet-class> 
    </servlet> 

    <servlet-mapping> 
        <servlet-name>HelloWorldServlet</servlet-name> 
        <url-pattern>/</url-pattern> 
    </servlet-mapping> 

</web-app>

Java 服务器页面

我承诺过不会用 Java 服务器页面来让您感到无聊,因为那是一种过时的技术。尽管它是过时的,但它还没有成为历史,因为还有很多程序仍在使用 JSP,并且包含 JSP 代码。

JSP 页面是包含 HTML 和 Java 代码混合的网页。当一个 HTTP 请求由 JSP 页面服务时,Servlet 容器读取 JSP 页面,执行 Java 部分,将 HTML 部分原样保留,以这种方式将两者混合在一起,创建一个发送到浏览器的 HTML 页面。

<%@ page language="java" 
         contentType="text/html; charset=UTF-8" 
         pageEncoding="UTF-8"%> 
<html> 
<body> 
<% for( int i = 0 ; i < 5 ; i ++ ){ %> 
  hallo<br/> 
<% } %> 
</body> 
</html>

前一页将创建一个包含hallo文本五次的 HTML 页面,每次都在新的行中,由br标签分隔。在幕后,Servlet 容器将 JSP 页面转换为 Java Servlet,然后使用 Java 编译器编译 Servlet,然后运行 Servlet。每次源 JSP 文件有变化时,它都会这样做;因此,使用 JSP 增量地编写一些简单的代码非常容易。从先前的 JSP 文件生成的代码有 138 行长(在 Tomcat 8.5.5 版本上),这里简单地列出会非常冗长且无聊,但有助于理解 Java 文件生成过程的部分只有几行。

如果你想查看生成的 servlet 类的所有行,可以将应用程序部署到 Tomcat 服务器,并查看目录work/Catalina/localhost/hello/org/apache/jsp/。这是一个很少为开发者所知的真相,即此代码实际上被保存到磁盘上,并且是可用的。有时当你需要调试一些 JSP 页面时,这很有帮助。

下面是从前面的代码生成的几行有趣的内容:

      out.write("\n"); 
      out.write("<html>\n"); 
      out.write("<body>\n"); 
 for( int i = 0 ; i < 5 ; i ++ ){  
      out.write("\n"); 
      out.write("  hallo<br/>\n"); 
 }  
      out.write("\n"); 
      out.write("</body>\n"); 
      out.write("</html>\n");

JSP 编译器将 JSP 代码的内部移到外部,外部移到内部。在 JSP 代码中,Java 被 HTML 包围,而在生成的 servlet Java 源代码中,HTML 被 Java 包围。这就像当你想修补衣服时:首先要做的就是将裙子翻过来。

你不仅可以在 JSP 页面中将 Java 代码混合到 HTML 中,还可以混合所谓的标签。标签被收集到标签库中,用 Java 实现,并打包成 JAR 文件,它们应该在类路径上可用。使用某些库中标签的 JSP 页面应该声明使用:

<%@ taglib prefix="c" 
           uri="http://java.sun.com/jsp/jstl/core" %>

标签看起来像 HTML 标签,但它们是由 JSP 编译器处理的,并由taglib库中实现的代码执行。JSP 还可以引用 JSP 作用域内可用的 Java 对象值。要在 HTML 页面内完成此操作,可以使用 JSP 表达式语言。

JSP 最初是为了简化 Web 应用程序的开发而创建的。主要优势是快速启动开发。在开发过程中没有长时间的配置、设置等,当 JSP 页面有任何更改时,也不需要再次编译整个应用程序:servlet 容器生成 Java 代码,将其编译成类文件,将其加载到内存中,并执行。JSP 是 Microsoft ASP 页面的竞争对手,它们混合了 HTML 和 VisualBasic 代码。

当应用程序开始变得庞大时,使用 JSP 技术会导致比好处更多的问题。混合业务逻辑和应用程序视图的代码,它在浏览器中的渲染变得混乱。开发 JSP 需要前端技术知识。Java 开发者预期需要了解一些前端技术,但很少是设计专家和 CSS 大师。现代代码还包含 JavaScript,很多时候嵌入在 HTML 页面中。毕竟,JSP 的大优势是它包含在服务器端以及客户端运行的代码。开发者多次遵循这种模式,所以看到一些包含 Java、HTML、CSS 和 JavaScript 的遗留代码混合在一个 JSP 文件中并不奇怪。由于 Java 和 JavaScript 在某些情况下语法相似,很难看出在服务器上执行的是什么,在客户端执行的是什么。我甚至看到过在 JSP 文件中从 Java 代码创建 JavaScript 代码的代码。这是一个不同责任的完全混合,几乎无法维护的混乱。这导致了 JSP 今天被完全废弃。

JSP 的弃用并不是官方的。这是我的专业意见。你可能会遇到一些仍然热爱 JSP 的资深开发者,你也可能会发现自己身处需要用 JSP 开发程序的项目中。这样做并不丢人。有些人为了钱做得更糟。

为了解决混乱的局面,越来越多的技术倡导将服务器代码和客户端功能分离。这些技术包括 Wicket、Vaadin、JSF 以及不同的 Java 模板引擎,如 Freemarker、Apache Velocity 和 Thymeleaf。这些后端技术在你从 Java 生成文本输出时也非常有趣,即使代码与 Web 无关。

这些技术,通过纪律性,有助于控制中等和大型 Web 项目的开发和维护成本,但架构的基本问题仍然存在:没有明确的关注点分离。

现在,现代应用程序在单独的项目中实现 Web 应用程序的代码:一个用于客户端,使用 HTML、CSS 和 JavaScript,另一个用于在 Java(或其它,但在此我们关注 Java)中实现服务器功能。两者之间的通信是 REST 协议,我们将在后续章节中介绍。

HTML、CSS 和 JavaScript

HTML、CSS 和 JavaScript 是客户端技术。这些对于 Web 应用至关重要,一个专业的 Java 开发者应该对这些技术有所了解。虽然这并非不可能,但没有人期望你同时成为 Java 和 Web 客户端技术的专家。一定的理解是可取的。

HTML 是有结构文本的文本表示。文本以字符形式给出,就像任何文本文件一样。标签代表结构。一个开始标签以<字符开始,然后是标签名,然后是可选的name="value"属性,最后是一个闭合的>字符。一个结束标签以</开始,然后是标签名,然后是>。标签被嵌套在层次结构中;因此,你不应该在打开的标签之前关闭一个标签。首先,最后打开的标签必须关闭,然后是下一个,依此类推。这样,HTML 中的任何实际标签都有一个级别,并且所有在开始和结束标签之间的标签都位于此标签的下方。一些不能包含其他标签或文本的标签没有结束标签,它们独立存在。考虑以下示例:

<html> 
  <head> 
    <title>this is the title</title> 
  </head> 
</html>

标签head位于html之下,title位于head之下。这可以结构化为一个树,如下所示:

html 
+ head 
  + title 
    + "this is the title"

浏览器以树结构存储 HTML 文本,这个树是网页文档的对象模型,因此得名,文档对象模型DOM)树。

原始的 HTML 概念将格式和结构混合在一起,即使在当前的 HTML5 版本中,我们仍然有bitt这样的标签,提示浏览器将开始和结束标签之间的文本以粗体、斜体和电传打字机字体显示。

正如 HTML(超文本标记语言)这个名字所暗示的,文本可以包含以超链接形式指向其他网页的引用。这些链接使用a标签(代表锚点)或某些可能包含不同字段的形式分配,当表单的提交按钮被按下时,字段的内容以POST请求的形式发送到服务器。当表单被发送时,字段的内容被编码在所谓的application/x-www-form-urlencoded表单中。

HTML 结构总是试图促进结构和格式的分离。为此,格式被移动到样式。在层叠样式表CSS)中定义的样式比 HTML 提供了更多的格式化灵活性;CSS 的格式化效果更有效。创建 CSS 的目的是使设计可以从文本的结构中分离出来。如果我要在这三个中选择一个,我会选择 CSS,因为它对于 Java 服务器端网络开发者来说最不重要,同时对于用户来说最重要(东西应该看起来很漂亮)。

JavaScript 是客户端技术的第三根支柱。JavaScript 是一种由浏览器执行的完整功能、解释型编程语言。它可以访问 DOM 树,并读取和修改它。当 DOM 树被修改时,浏览器会自动显示修改后的页面。JavaScript 函数可以被安排和注册,以便在某个事件发生时调用。例如,你可以注册一个函数,当文档完全加载、用户按下按钮、点击链接或鼠标悬停在某个部分上时调用该函数。尽管 JavaScript 最初仅用于在浏览器上创建有趣的动画,但如今,使用浏览器的功能来编写完全功能性的客户端是可能的,并且已经成为一种常见的做法。确实有一些用 JavaScript 编写的强大程序,甚至包括像 PC 模拟器这样耗能的应用程序。

在这本书中,我们专注于 Java,并在演示技术所需的最大程度上使用客户端技术。然而,作为一个 Java 网络开发者专业人士,你至少在一定程度上必须学习这些技术,以便理解客户端能做什么,并能与负责前端技术的专业人士合作。

大脑.servlet

通过网络玩 Mastermind 游戏与过去有点不同。到目前为止,我们没有用户交互,我们的类也是相应设计的。例如,我们可以向表格添加一个新的猜测,包括程序计算出的部分和完全匹配。现在我们必须分离新猜测的创建、将其添加到游戏中以及设置完全和部分匹配。这次,我们必须首先显示表格,然后用户必须计算并提供匹配的数量。

我们必须修改一些类才能做到这一点。我们需要向Game.java添加一个新方法:

public Row addGuess(Guess guess, int full, int partial) { 
    assertNotFinished(); 
    final Row row = new Row(guess, full, partial); 
    table.addRow(row); 
    if (itWasAWinningGuess(full)) { 
        finished = true; 
    } 
    return row; 
}

到目前为止,我们只有一个方法,那就是添加一个新的猜测,因为程序知道秘密,所以它立即计算出fullpartial的值。这个方法的名字可以是addNewGuess,它覆盖了原始方法,但这次,这个方法不仅用于添加新的猜测,还用于添加旧的猜测以重建表格。

当程序启动时,没有猜测。程序创建一个,第一个。后来,当用户告诉程序完整的和部分的匹配时,程序需要包含Guess对象以及fullpartial匹配值的Game结构体和Table以及Row对象。这些已经可用,但当新的 HTTP 请求到来时,我们必须从某处获取它。在编写 servlet 时,我们必须将游戏状态存储在某处,并在新的 HTTP 请求击中服务器时恢复它。

存储状态

状态的存储可以在两个地方进行。一个地方,我们将在我们的代码中首先做,是客户端。当程序创建一个新的猜测时,它将其添加到表格中,并发送一个包含不仅新的猜测,还包括所有之前的猜测以及用户为每一行提供的fullpartial匹配值的 HTML 页面。为了将数据发送到服务器,这些值存储在表单的字段中。当表单提交时,浏览器收集字段中的信息,从字段内容创建一个编码字符串,并将内容放入POST请求的主体中。

存储实际状态的另一种可能性是在服务器上。服务器可以存储游戏状态,并在创建新的猜测时重建结构。在这种情况下的问题是我们知道使用哪个游戏。服务器可以也应该存储许多游戏,每个用户一个,用户可能并发使用应用程序。这并不一定意味着像我们在上一章中检查的那样强并发。

即使用户不是在多个线程中同时被服务,也可能存在一些活跃的游戏。想象一下cnn.com告诉你现在不能阅读新闻,因为其他人正在阅读。可能有多个用户在玩多个游戏,而在处理 HTTP 请求时,我们应该知道我们正在为哪个用户服务。

Servlets 维护会话,我们将在下一节中看到,这些会话可以用于此目的。

HTTP 会话

当客户端从同一浏览器向同一 Servlet 发送请求时,一系列请求属于一个会话。为了知道请求属于同一个会话,Servlet 容器会自动向客户端发送一个名为JSESSIONID的 cookie,并且这个 cookie 有一个长、随机、难以猜测的值(例如,我在 Jetty 中运行应用程序时,值为tkojxpz9qk9xo7124pvanc1z)。Servlet 维护一个包含HttpSession实例的会话存储。在JSESSIONIDcookie 的值中传递的键字符串标识了这些实例。当一个 HTTP 请求到达 Servlet 时,容器将存储中的会话附加到请求对象。如果没有为键创建会话,那么就会创建一个,代码可以通过调用request.getSession()方法来访问会话对象。

HttpSession对象可以存储属性。程序可以通过调用setAttribute(String,Object)getAttribute(String)removeAttribute(String)方法来存储、检索或删除属性对象。每个属性都分配给一个String,可以是任何Object

尽管会话属性存储在本质上看起来就像一个Map<String,?>对象一样简单,但实际上并非如此。当 Servlet 容器在集群或其他分布式环境中运行时,存储在会话中的值可以从一个节点移动到另一个节点。为了做到这一点,这些值会被序列化;因此,存储在会话中的值应该是Serializable。未能这样做是一个非常常见的初学者错误。在开发过程中,在简单的开发 Tomcat 或 Jetty 容器中执行代码几乎永远不会将会话序列化到磁盘,也永远不会从序列化形式加载它。这意味着使用setAttribute设置的值将通过调用getAttribute来可用。当应用程序第一次在集群环境中安装时,我们会遇到麻烦。一旦不同的节点上有 HTTP 请求到达,getAttribute可能会返回null。在第一个节点上调用setAttribute,而在处理下一个请求时,在另一个节点上的getAttribute无法从节点之间共享的磁盘上反序列化属性值。这通常,并且遗憾的是,是在生产环境中发生的。

作为一名开发者,你应该知道序列化和反序列化对象是一项耗时的操作,它需要消耗几个 CPU 周期。如果应用程序的结构只使用客户端状态的一部分来服务大多数 HTTP 请求,那么从序列化形式在内存中创建整个状态然后再进行序列化是一种 CPU 资源的浪费。在这种情况下,更明智的做法是只在会话中存储一个键,并使用某些数据库(SQL 或 NoSQL)或其他服务来存储由键引用的实际数据。企业应用程序几乎完全使用这种结构。

在客户端存储状态

首先,我们将开发我们的代码,将状态存储在客户端。所需的表单用于发送用户输入和新的完全匹配和部分匹配的数量,还包含用户在那时给出的所有猜测和答案的先前颜色。为此,我们创建了一个新的辅助类来格式化 HTML 代码。这是在现代企业环境中使用模板、JSP 文件或完全避免使用纯 REST 和单页应用程序所做的事情。尽管如此,在这里我们将使用旧技术来演示现代引擎底下的齿轮:

package packt.java9.by.example.mastermind.servlet; 

import packt.java9.by.example.mastermind.Color; 
import packt.java9.by.example.mastermind.Table; 

import javax.inject.Inject; 
import javax.inject.Named; 

public class HtmlTools { 
    @Inject 
    Table table; 

    @Inject 
    @Named("nrColumns") 
    private int NR_COLUMNS; 

    public String tag(String tagName, String... attributes) { 
        StringBuilder sb = new StringBuilder(); 
        sb.append("<").append((tagName)); 
        for (int i = 0; i < attributes.length; i += 2) { 
            sb.append(" "). 
                    append(attributes[i]). 
                    append("=\""). 
                    append(attributes[i + 1]). 
                    append("\""); 
        } 
        sb.append(">"); 
        return sb.toString(); 
    } 

    public String inputBox(String name, String value) { 
        return tag("input", "type", "text", "name", name, "value", value, "size", "1"); 
    } 

    public String colorToHtml(Color color, int row, int column) { 
        return tag("input", "type", "hidden", "name", paramNameGuess(row, column), 
                "value", color.toString()) + 
                tag("div", "class", "color" + color) + 
                tag("/div") + 
                tag("div", "class", "spacer") + 
                tag("/div"); 
    } 

    public String paramNameFull(int row) { 
        return "full" + row; 
    } 

    public String paramNamePartial(int row) { 
        return "partial" + row; 
    } 

    public String paramNameGuess(int row, int column) { 
        return "guess" + row + column; 
    } 

    public String tableToHtml() { 
        StringBuilder sb = new StringBuilder(); 
        sb.append("<html><head>"); 
        sb.append("<link rel=\"stylesheet\" type=\"text/css\" href=\"colors.css\">"); 
        sb.append("<title>Mastermind guessing</title>"); 
        sb.append("<body>"); 
        sb.append(tag("form", "method", "POST", "action", "master")); 

        for (int row = 0; row < table.nrOfRows(); row++) { 
            for (int column = 0; column < NR_COLUMNS; column++) { 
                sb.append(colorToHtml(table.getColor(row, column), row, column)); 
            } 

            sb.append(inputBox(paramNameFull(row), "" + table.getFull(row))); 
            sb.append(inputBox(paramNamePartial(row), "" + table.getPartial(row))); 
            sb.append("<p>"); 
        } 
        return sb.toString(); 
    } 
}

除了@Inject注解之外,其余的代码简单直接。我们将在稍后专注于@Inject,但很快就会进行。我们必须关注的是代码生成的 HTML 结构。生成的页面将看起来像这样:

<html> 
    <head> 
        <link rel="stylesheet" type="text/css" href="colors.css"> 
        <title>Mastermind guessing</title> 
        <body> 
            <form method="POST" action="master"> 
                <input type="hidden" name="guess00" value="3"> 
                <div class="color3"></div> 
                <div class="spacer"></div> 
                <input type="hidden" name="guess01" value="2"> 
                <div class="color2"></div> 
                <div class="spacer"></div> 
                <input type="hidden" name="guess02" value="1"> 
                <div class="color1"></div> 
                <div class="spacer"></div> 
                <input type="hidden" name="guess03" value="0"> 
                <div class="color0"></div> 
                <div class="spacer"></div> 
                <input type="text" 
                       name="full0" value="0" size="1"> 
                <input type="text" 
                       name="partial0" value="2" size="1"> 
                <p> 
                <input type="hidden" name="guess10" value="5"> 
                <div class="color5"></div> 

...deleted content that just looks almost the same... 

                <p> 
                <input type="submit" value="submit"> 
            </form> 
        </body> 
    </head> 
</html>

表单包含以 DIV 标签形式呈现的颜色,并且它还包含颜色的“字母”在隐藏字段中。这些输入字段在表单提交时发送到服务器,就像任何其他字段一样,但它们不会出现在屏幕上,用户无法编辑它们。完全匹配和部分匹配显示在文本输入字段中。由于无法在 HTML 文本中显示Color对象,我们使用LetteredColorLetteredColorFactory,它们将单个字母分配给颜色。前 6 种颜色简单地编号为012345。CSS 文件可以控制颜色在浏览器窗口中的外观。你可能还记得我们介绍了如何和在哪里实现单个颜色的显示。首先,我们创建了一个特殊的打印类,它将字母分配给已经存在的颜色,但只能在非常有限的环境中(主要是单元测试)使用。现在,我们再次遇到了这个问题。我们有字母颜色,但现在我们需要真正的颜色,因为这次我们有一个能够显示颜色的客户端显示。现代网络技术的真正力量在这里闪耀。内容和格式可以彼此分离。不同颜色的木桩在 HTML 中以div标签的形式列出。它们有一个格式化类,但实际的外观由负责外观的 CSS 文件定义:

.color0 { 
    background: red; 
    width : 20px; 
    height: 20px; 
    float:left 
} 
.color1 { 
    background-color: green; 
    width : 20px; 
    height: 20px; 
    float:left 
} 
... .color2 to .color5 is deleted, content is the same except different colors ... 

.spacer { 
    background-color: white; 
    width : 10px; 
    height: 20px; 
    float:left 
}

使用 Guice 进行依赖注入

如下所示,servlet 类非常简单:

package packt.java9.by.example.mastermind.servlet; 

import com.google.inject.Guice; 
import com.google.inject.Injector; 
import org.slf4j.Logger; 
import org.slf4j.LoggerFactory; 

import javax.servlet.ServletException; 
import javax.servlet.http.HttpServlet; 
import javax.servlet.http.HttpServletRequest; 
import javax.servlet.http.HttpServletResponse; 
import java.io.IOException; 

public class Mastermind extends HttpServlet { 
    private static final Logger log = LoggerFactory.getLogger(Mastermind.class); 

    public void doGet(HttpServletRequest request, 
                      HttpServletResponse response) 
            throws ServletException, IOException { 
        doPost(request, response); 
    } 

    public void doPost(HttpServletRequest request, 
                       HttpServletResponse response) 
            throws ServletException, IOException { 

        Injector injector =  
            Guice.createInjector(new MastermindModule()); 
        MastermindHandler handler =  
            injector.getInstance(MastermindHandler.class); 
        handler.handle(request, response); 
    } 
}

由于许多线程并发使用 servlet,因此我们不能使用仅持有单个击中数据的实例字段,servlet 类除了创建一个MastermindHandler类的新实例并调用其handle方法之外,不做其他任何事情。由于每个请求都有一个新的MastermindHandler实例,它可以在特定于请求的字段中存储对象。要创建处理器,我们使用由 Google 创建的 Guice 库。

我们已经讨论了依赖注入。处理程序需要一个 Table 对象来玩游戏,一个 ColorManager 对象来管理颜色,以及一个 Guesser 对象来创建一个新的猜测,但创建这些或从某处获取预制的实例并不是处理程序的核心功能。处理程序必须做一件事:处理请求;完成此操作所需的所有实例应从外部注入。这是通过 Guice 注入器完成的。

要使用 Guice,我们必须在 build.gradle 的依赖项中列出库:

apply plugin: 'java' 
apply plugin: 'jetty' 

repositories { 
    jcenter() 
} 

dependencies { 
    providedCompile "javax.servlet:javax.servlet-api:3.1.0" 
    testCompile 'junit:junit:4.12' 
    compile 'org.slf4j:slf4j-api:1.7.7' 
    compile 'ch.qos.logback:logback-classic:1.0.11' 
    compile 'com.google.inject:guice:4.1.0' 
} 

jettyRun { 
    contextPath '/hello' 
}

然后我们必须创建一个 injector 实例,该实例将执行注入。在 servlet 中,通过以下行创建 injector:

Injector injector = Guice.createInjector(new MastermindModule());

MastermindModule 的实例指定了在哪里注入什么。这本质上是一个 Java 格式的配置文件。其他使用的依赖注入器框架和它们使用 XML 和注解来描述注入绑定以及在哪里注入什么,但 Guice 仅使用 Java 代码。以下是对 DI 配置代码的说明:

public class MastermindModule extends AbstractModule { 
    @Override 
    protected void configure() { 
        bind(int.class) 
          .annotatedWith(Names.named("nrColors")).toInstance(6); 
        bind(int.class) 
          .annotatedWith(Names.named("nrColumns")).toInstance(4); 
        bind(ColorFactory.class).to(LetteredColorFactory.class); 
        bind(Guesser.class).to(UniqueGuesser.class); 
    } 
}

configure 方法中使用的方法是以流畅 API 的方式创建的,以便方法可以一个接一个地链接,并且代码可以几乎像英语句子一样阅读。可以在 blog.jooq.org/2012/01/05/the-java-fluent-api-designer-crash-course/ 找到流畅 API 的良好介绍。例如,第一配置行可以用英语这样阅读:

将类 int 绑定到任何带有 @Name 注解且值为 "nrColor" 的实例 6

(注意,int6 被自动装箱为 Integer 实例。)

MastermindHandler 类包含带有 @Inject 注解的字段:

@Inject 
@Named("nrColors") 
private int NR_COLORS; 
@Inject 
@Named("nrColumns") 
private int NR_COLUMNS; 
@Inject 
private HtmlTools html; 
@Inject 
Table table; 
@Inject 
ColorManager manager; 
@Inject 
Guesser guesser;

这个注解不是 Guice 特有的。@Injectjavax.inject 包的一部分,并且是 JDK 的标准部分。JDK 不提供 依赖注入器DI)框架,但支持不同的框架,以便它们可以使用标准 JDK 注解,并且如果 DI 框架被替换,注解可能保持不变,而不是框架特定的。

当 injector 被调用以创建 MastermindHandler 的实例时,它会查看类,并看到它有一个带有 @Inject@Named("nrColors") 注解的 int 字段,并在配置中找到这样一个字段应该具有值 6。在返回 MastermindHandler 对象之前,它将值注入到字段中。同样,它也将值注入到其他字段中,如果它应该创建任何要注入的对象,它也会这样做。如果这些对象中有字段,那么它们也将通过注入其他对象来创建,依此类推。

这样,DI 框架从程序员肩上移除了创建实例的负担。这本来就是一个相当无聊的事情,而且这并不是类的核心功能。相反,它创建了所有需要的对象以使 MastermindHandler 功能化,并通过 Java 对象引用将它们链接在一起。这样,不同对象之间的依赖关系(MastermindHandler 需要 GuesserColorManagerTableColorManager 需要 ColorFactoryTable 也需要 ColorManager,等等)变成了一个声明,通过在字段上使用注解来指定。这些声明在类的代码内部,这是它们正确的位置。我们还能在哪里指定一个类需要什么才能正常工作,除了在类本身之外?

我们示例中的配置指定了,无论何时需要 ColorFactory,我们将使用 LetteredColorFactory,而无论何时需要 Guesser,我们将使用 UniqueGuesser。这部分与代码分离,并且必须这样。如果我们想更改猜测策略,我们替换配置,代码应该可以在不修改使用猜测器的类的情况下正常工作。

Guice 足够聪明,你不需要指定无论何时需要 Table,我们将使用 Table:没有 bind(Table.class).to(Table.class)。最初我在配置中创建了一条这样的线,但 Guice 给我返回了一个错误信息,现在,用普通的英语再次写它,我觉得自己真的很愚蠢。如果我需要一个表格,我就需要一个表格。真的吗?

MastermindHandler 类

我们已经开始了 MastermindHandler 类的列举,由于这个类有一百多行,所以我不会在这里全部列出。这个类最重要的方法是 handle

public void handle(HttpServletRequest request, 
                   HttpServletResponse response) 
        throws ServletException, IOException { 

    Game game = buildGameFromRequest(request); 
    Guess newGuess = guesser.guess(); 
    response.setContentType("text/html"); 
    PrintWriter out = response.getWriter(); 
    if (game.isFinished() || newGuess == Guess.none) { 
        displayGameOver(out); 
    } else { 
        log.debug("Adding new guess {} to the game", newGuess); 
        game.addGuess(newGuess, 0, 0); 
        displayGame(out); 
    } 
    bodyEnd(out); 
}

我们执行三个步骤。第一步是创建表格,我们从请求中创建它。如果不是游戏的开始,已经有一个表格,HTML 表单包含了所有之前的猜测颜色和相应的答案。然后,作为第二步,我们根据这些创建一个新的猜测。第三步是将新的 HTML 页面发送到客户端。

再次强调,这并不是一个现代的方法,在 servlet 代码中创建 HTML,但仅用 REST、JSON 和 JavaScript 以及一些框架来展示纯 servlet 功能,就会使这一章的内容达到几百页,这肯定会分散我们对 Java 的注意力。

将 HTML 文本打印到 PrintWriter 在这本书的这个阶段不应该对你来说是新事物;因此,我们这里不会列出那段代码。你可以在 GitHub 上下载工作示例。这个代码版本的分支是 nosession。我们不会关注打印,而是将重点放在 servlet 参数处理上。

请求参数可以通过 getParameter 方法获得,该方法返回参数的字符串值。此方法假设任何参数,无论是 GET 还是 POST,在请求中只出现一次。如果存在多次出现的参数,其值应该是一个字符串数组。在这种情况下,我们应该使用 getParameterMap,它返回包含 String 键和 String[] 值的整个映射。尽管这次我们没有任何键的多个值,我们也知道作为 POST 参数传入的键的值,我们仍然会使用后者。这样做的原因是,我们稍后会将这些值存储在会话中,我们希望有一个在这种情况下可重用的方法。

如果你查看 Git 仓库中的早期提交,你会看到第一个版本使用了 getParameter,而我只是在创建程序的第二个版本时对其进行了重构,该版本将状态存储在会话中。不要相信任何人告诉你程序在开发过程中一开始就完美无缺,没有任何重构。不要因为创建了愚蠢的代码而感到羞愧,稍后进行重构。如果你不重构,那才是可耻的。

为了达到这个目的,我们将请求的 Map<String,String[]> 转换为 Map<String,String>

private Game buildGameFromRequest(HttpServletRequest request) { 
    return buildGameFromMap(toMap(request)); 
} 
private Map<String, String> toMap(HttpServletRequest request) { 
    log.debug("converting request to map"); 
    return request.getParameterMap().entrySet(). 
            stream().collect( 
                    Collectors.toMap( 
                            Map.Entry::getKey, 
                            e -> e.getValue()[0])); 
}

然后,我们使用那个映射来重新创建游戏:

private Game buildGameFromMap(Map<String, String> params) { 
    final Guess secret = new Guess(new Color[NR_COLUMNS]); 
    final Game game = new Game(table, secret); 
    for (int row = 0; 
         params.containsKey(html.paramNameGuess(row, 0)); 
         row++) { 
        Color[] colors = getRowColors(params, row); 
        Guess guess = new Guess(colors); 
        final int full = Integer.parseInt(params.get(html.paramNameFull(row))); 
        final int partial = Integer.parseInt(params.get(html.paramNamePartial(row))); 
        log.debug("Adding guess to game"); 
        game.addGuess(guess, full, partial); 
    } 
    return game; 
}

String 转换为 int 是通过 parseInt 方法完成的。当输入不是数字时,此方法会抛出 NumberFormatException。尝试运行游戏,使用浏览器,看看 Jetty 如何处理当 servlet 抛出异常的情况。浏览器中显示了多少有价值的信息,可以被潜在的黑客利用?修复代码,以便在数字格式不正确时再次询问用户!

在服务器上存储状态

应用程序的状态通常不应该保存在客户端。除了编写教育代码并演示如何操作的特殊情况外,可能还有一些特殊情况。通常,与实际使用相关的应用程序状态存储在会话对象或某些数据库中。这在应用程序请求用户输入大量数据且不希望用户在客户端计算机出现故障时丢失工作的情况下尤为重要。

你在在线商店中花费大量时间选择合适的商品,选择可以协同工作的商品,创建你新模型飞机的配置,突然,你家停电了。如果状态存储在客户端,你就不得不从头开始。如果状态存储在服务器上,状态会被保存到磁盘;服务器被复制,由电池供电,当你家恢复供电并重新启动客户端计算机时,你登录,奇迹般地,购物车里的商品都在那里。好吧,这不是奇迹;这是网络编程。

在我们的案例中,第二个版本会将游戏状态存储在会话中。这样,只要会话存在,用户就可以恢复游戏。如果用户退出并重新启动浏览器,会话就会丢失,可以开始新的一局游戏。

由于这次不需要在隐藏字段中发送实际的颜色和匹配信息,HTML 生成器也做了一些修改,生成的 HTML 也会更简单:

<html> 
<head> 
    <link rel="stylesheet" type="text/css" href="colors.css"> 
    <title>Mastermind guessing</title> 
<body> 
<form method="POST" action="master"> 
    <div class="color3"></div> 
    <div class="spacer"></div> 
    <div class="color2"></div> 
    <div class="spacer"></div> 
    <div class="color1"></div> 
    <div class="spacer"></div> 
    <div class="color0"></div> 
    <div class="spacer"></div>0 
    <div class="spacer"></div>2<p> 
    <div class="color5"></div> 
... 
    <div class="spacer"></div> 
    <div class="color1"></div> 
    <div class="spacer"></div> 
    <input type="text" name="full2" value="0" size="1"><input type="text" name="partial2" value="0" size="1"> 
    <p> 
        <input type="submit" value="submit"> 
</form> 
</body> 
</head></html>

完全匹配和部分匹配的颜色数量以简单的数字形式显示,因此这个版本不允许作弊或更改之前的结果。(这些数字是 02,位于具有 spacer CSS 类的 div 标签之后。)

MastermindHandler 中的 handle 方法也发生了变化,如下所示:

public void handle(HttpServletRequest request, 
                   HttpServletResponse response) 
        throws ServletException, IOException { 

    Game game = buildGameFromSessionAndRequest(request); 
    Guess newGuess = guesser.guess(); 
    response.setContentType("text/html"); 
    PrintWriter out = response.getWriter(); 
    if (game.isFinished() || newGuess == Guess.none) { 
        displayGameOver(out); 
    } else { 
        log.debug("Adding new guess {} to the game", newGuess); 
        game.addGuess(newGuess, 0, 0); 
        sessionSaver.save(request.getSession()); 
        displayGame(out); 
    } 
    bodyEnd(out); 
}

这个类的版本通过 Guice 注入器获取一个 SessionSaver 对象。这是一个我们创建的类。这个类将当前的表格转换为存储在会话中的内容,并且也可以从会话中存储的数据重新创建表格。handle 方法使用 buildGameFromSessionAndRequest 方法来恢复表格,并添加用户在请求中刚刚给出的完整和部分匹配答案。当方法创建一个新的猜测并填写到表格中,并将其发送到客户端的响应中时,它通过 sessionSaver 对象调用 save 方法来保存状态。

buildGameFromSessionAndRequest 方法替换了另一个版本,我们将其命名为 buildGameFromRequest

private Game buildGameFromSessionAndRequest(HttpServletRequest request) { 
    Game game = buildGameFromMap(sessionSaver.restore(request.getSession())); 
    Map<String, String> params = toMap(request); 
    int row = getLastRowIndex(params); 
    log.debug("last row is {}", row); 
    if (row >= 0) { 
        final int full = Integer.parseInt(params.get(html.paramNameFull(row))); 
        final int partial = Integer.parseInt(params.get(html.paramNamePartial(row))); 
        log.debug("setting full {} and partial {} for row {}", full, partial, row); 
        table.setPartial(row, partial); 
        table.setFull(row, full); 
        if (full == table.nrOfColumns()) { 
            game.setFinished(); 
        } 
    } 
    return game; 
}

注意,这个版本与使用 JDK 中 Integer 类的 parseInt 方法有相同的毛病,它会抛出异常。

GameSessionSaver

这个类有三个公共方法:

  • save:将表格保存到用户会话中

  • restore:从用户会话中获取表格

  • reset:删除会话中可能存在的任何表格

类的代码如下:

public class GameSessionSaver { 
    private static final String STATE_NAME = "GAME_STATE"; 
    @Inject 
    private HtmlTools html; 
    @Inject 
    Table table; 
    @Inject 
    ColorManager manager; 

    public void save(HttpSession session) { 
        Map<String,String> params = convertTableToMap(); 
        session.setAttribute(STATE_NAME,params); 
    } 

    public void reset(HttpSession session) { 
        session.removeAttribute(STATE_NAME); 
    } 

    public Map<String,String> restore(HttpSession session){ 
        Map<String,String> map= 
                    (Map<String,String>) 
                            session.getAttribute(STATE_NAME); 
        if( map == null ){ map = new HashMap<>(); } 
        return map; 
    } 

    private Map<String,String> convertTableToMap() { 
        Map<String, String> params = new HashMap<>(); 
        for (int row = 0; row < table.nrOfRows(); row++) { 
            for (int column = 0; 
                 column < table.nrOfColumns(); column++) { 
                params.put(html.paramNameGuess(row,column), 
                           table.getColor(row,column).toString()); 
            } 
            params.put(html.paramNameFull(row), 
                           ""+table.getFull(row)); 
            params.put(html.paramNamePartial(row), 
                           ""+table.getPartial(row)); 
        } 
        return params; 
    } 
}

当我们保存会话并将表格转换为映射时,我们使用 HashMap。在这个情况下,实现很重要。HashMap 类实现了 Serializable 接口;因此,我们可以安全地将它放入会话中。这本身并不能保证 HashMap 中的所有内容都是 Serializable。在我们的案例中,键和值是字符串,幸运的是,String 类也实现了 Serializable 接口。这样,转换后的 HashMap 对象就可以安全地存储在会话中。

还要注意,尽管序列化可能会很慢,但将 HashMap 存储在会话中是如此频繁,以至于它实现了自己的序列化机制。这个实现是经过优化的,避免了序列化依赖于映射的内部结构。

是时候思考为什么我们在类中有convertTableToMap方法,而在MastermindHandler中有buildGameFromMap。将游戏及其中的表格转换为Map以及相反的操作应该一起实现。它们只是同一转换的两个方向。另一方面,TableMap方向的实现应该使用一个Serializable版本的Map。这与会话处理有很大关系。通常,将Map对象转换为Table对象是更高一级的操作,从任何存储位置恢复表格:客户端、会话、数据库,或在云的湿度中。会话存储只是可能的实现之一,并且应该在满足抽象级别的类中实现这些方法。

最好的解决方案是在一个单独的类中实现这些功能。你有家庭作业!

reset方法不是从处理器中使用的。它是从Mastermind类中调用的,即 servlet 类,在我们启动游戏时重置游戏:

public void doGet(HttpServletRequest request, 
                  HttpServletResponse response) 
        throws ServletException, IOException { 
    GameSessionSaver sessionSaver = new GameSessionSaver(); 
    sessionSaver.reset(request.getSession()); 
    doPost(request, response); 
}

没有这个,与机器玩一次游戏就会在每次我们想要再次启动时显示完成的游戏,直到我们退出浏览器并重新启动它,或者明确地在浏览器的高级菜单中删除JSESSIONIDcookie。调用reset不会删除会话。会话保持不变,因此JSESSIONID的值也保持不变,但游戏已从 servlet 容器维护的会话对象中删除。

运行 Jetty 网络 servlet

由于我们已经将 Jetty 插件包含到我们的 Gradle 构建中,插件的目标现在是可用的。要启动 Jetty,只需输入以下命令即可:

    gradle jettyRun

这将编译代码,构建 WAR 文件,并启动 Jetty servlet 容器。为了帮助我们记住,它还在命令行上打印以下内容:

    Running at http://localhost:8080//hello

我们可以打开这个 URL,查看游戏的开场屏幕,其中包含了程序作为第一次猜测所创建的颜色:

现在是时候享受一些乐趣,玩我们的游戏,向程序给出答案。不要让代码变得容易!参考以下截图:

同时,如果你查看你输入gradle jettyRun的终端,你会看到代码正在打印日志消息,如下面的截图所示:

这些打印输出是通过我们代码中的记录器完成的。在前面的章节中,我们使用了System.out.println方法调用将信息消息发送到控制台。这种做法不应该在任何比“hello world”更复杂的程序中使用

记录日志

对于 Java,有几种日志框架可供选择,每种都有其优缺点。有一个内置在 JDK 的java.util.logging包中,通过System.getLogger方法支持访问记录器:System.LoggerSystem.LoggerFinder类。尽管java.util.logging自 JDK 1.4 以来就可用,但许多程序使用其他日志解决方案。除了内置的日志记录外,我们还需要提及log4jslf4j和 Apache Commons Logging。在深入探讨不同框架的细节之前,让我们讨论一下为什么使用日志而不是仅仅打印到标准输出是很重要的。

可配置性

最重要的原因是可配置性和易用性。我们使用日志来记录代码运行的信息。这并不是应用程序的核心功能,但拥有一个可操作的程序是不可避免的。我们打印到日志的消息可以被操作人员用来识别环境问题。例如,当抛出IOException并被记录时,操作人员可能会查看日志并确定磁盘已满。他们可能会删除文件,或者添加新磁盘并扩展分区。没有日志,唯一的信息就是程序无法工作。

日志也被多次用于查找错误。有些错误在测试环境中不会显现,并且很难重现。在这种情况下,打印出代码执行详细信息的日志是找到某些错误根本原因的唯一来源。

由于日志记录需要 CPU、IO 带宽和其他资源,因此需要仔细考虑记录什么和何时记录。这项检查和决策可以在编程期间完成,实际上,如果我们使用System.out.println进行日志记录,那才是唯一可能的方式。如果我们需要查找错误,我们应该记录很多。如果我们记录很多,系统的性能将会下降。结论是我们只有在需要时才进行记录。如果系统中存在无法重现的错误,开发者会要求运维在短时间内开启调试日志。当使用System.out.println时,无法切换开启和关闭不同的日志部分。当开启调试级别的日志时,性能可能会暂时下降,但同时,日志将可用于分析。同时,当我们需要找到相关的日志行(而你事先并不知道哪些是相关的)时,分析会变得更加简单,尤其是当日志文件较小(几百兆字节)而不是大量 2GB 的压缩日志文件时,要找到这些行。

使用日志框架,你可以定义记录器来标识日志消息的来源和日志级别。一个字符串通常用来标识记录器,并且使用创建日志消息的类的名称作为记录器的名称是一种常见的做法。这是一个如此常见的做法,以至于不同的日志框架提供了工厂类,这些类获取类本身,而不是它的名称,以获取记录器。

不同的日志框架中可能的日志级别可能略有不同,但最重要的级别如下:

  • FATAL:当日志消息是关于某些错误,阻止程序继续执行时使用。

  • ERROR:当存在某些严重错误时使用,尽管程序可能仍然可以继续运行,但可能是在某些有限的方式下。

  • WARNING:当存在某种条件不是直接问题,但如果不注意可能会后来导致错误时使用。例如,程序识别到磁盘快满了,一些数据库连接在限制内但接近超时值,以及类似的情况。

  • INFO:用于创建关于正常操作的消息,这些消息可能对操作有趣,但不是错误或警告。这些消息可能有助于调试操作环境设置。

  • DEBUG:用于记录关于程序的详细信息,这些信息足够详细(希望如此),可以找到代码中的错误。技巧是,当我们将日志语句放入代码中时,我们不知道它可能是什么错误。如果我们知道了,我们最好修复它。

  • TRACE:这是关于代码执行的更详细的信息。

日志框架通常使用一些配置文件进行配置。配置可能限制了日志记录,关闭某些级别。在正常操作环境中,通常前三个级别是开启的,当真正需要时,会开启INFODEBUGTRACE级别。也有可能只为某些日志记录器开启或关闭特定级别。如果我们知道错误肯定在GameSessionSaver类中,那么我们只为该类开启DEBUG级别。

日志文件可能还包含我们没有直接编码的其他信息,直接打印到标准输出会非常繁琐。通常,每个日志消息都包含消息创建的确切时间、记录器的名称,以及很多时候线程的标识符。想象一下,如果你被迫将所有这些信息放入每个println参数中,你可能会很快编写一个额外的类来做这件事。不要这样做!这已经由专业人士完成了:这就是日志框架。

记录器也可以配置为将消息发送到不同的位置。将日志记录到控制台只是其中一种可能性。日志框架准备将消息发送到文件、数据库、Windows 事件记录器、syslog 服务或任何其他目标。这种灵活性,即打印什么消息,打印什么额外信息,以及打印在哪里,是通过将记录器框架执行的不同任务分离成几个类,遵循单一责任原则来实现的。

日志框架通常包含创建日志的记录器,格式化原始日志信息的格式化器,很多时候还会添加线程 ID 和时间戳等信息,以及将格式化后的消息追加到某个目标位置的追加器。这些类实现了日志框架中定义的接口,而阻止我们创建自己的格式化和追加器的,仅仅是书籍的大小。

当配置日志时,会配置追加器和格式化器,给定实现它们的类。因此,当你想要将一些日志发送到某个特殊位置时,你不仅限于框架作者提供的追加器。有许多独立的开源项目为不同的日志框架提供针对不同目标的追加器。

性能

使用日志框架的第二个原因是性能。尽管在分析代码之前优化性能(过早优化)并不好,但使用已知较慢的方法并在性能关键代码中插入几行,调用慢速方法也不是真正的专业做法。以行业最佳实践的方式使用经过良好建立、高度优化的框架,不应存在问题。

使用 System.out.println 将消息发送到流中,并且只有在 IO 操作完成后才返回。使用真正的日志处理会将信息传递给记录器,并让记录器异步进行日志记录,而不等待完成。如果发生系统故障,日志信息可能会丢失,但这通常不是一个严重的问题,考虑到这种情况发生的频率很低,以及权衡的另一面:性能。如果磁盘满了,丢失了调试日志行,我们到底失去了什么?这会导致系统无法使用。

这里有一个例外:审计日志——当系统事务的某些日志信息必须保存以供法律原因审计操作和实际交易时。在这种情况下,日志信息以事务方式保存,使日志成为交易的一部分。因为这是一个完全不同的要求,审计日志通常不使用这些框架中的任何一个。

此外,System.out.println 不是同步的,因此不同的线程可能会混乱输出。日志框架关注这个问题。

日志框架

最广泛使用的日志框架是Apachelog4j。它目前有一个第二版,是对第一版的完全重写。它非常灵活,拥有许多附加器和格式化工具。log4j 的配置可以是 XML 或属性文件格式,也可以通过 API 进行配置。

log4j 版本 1 的作者创建了一个新的日志框架:slf4j。这个日志库本质上是一个外观,可以与任何其他日志框架一起使用。因此,当你在开发的库中使用 slf4j,并且你的代码作为一个依赖项添加到使用不同日志框架的程序中时,很容易配置 slf4j 将日志发送到其他框架的日志记录器。这样,日志将一起处理,而不是分别存储在不同的文件中,这有助于降低运营成本。在开发你的库代码或使用 slf4j 的应用程序时,没有必要选择另一个日志框架来替代 slf4j。它有一个名为 backlog 的简单实现。

如果其他方法都失败,Apache Commons Logging 也是一个具有自己日志实现的日志外观。与 slf4j 的主要区别在于它在配置和选择底层日志方面更加灵活,并实现了一个运行时算法来发现可用的日志框架以及要使用的框架。行业最佳实践表明,这种灵活性(同时也伴随着更高的复杂性和成本)并不是必需的。

Java 9 日志

Java 9 包含一个日志外观的实现。使用非常简单,我们可以预期日志框架很快就会开始支持这个外观。这个外观内置在 JDK 中的事实有两个主要优势:

  • 想要记录日志的库不再需要依赖任何日志框架或日志外观。唯一的依赖是 JDK 日志外观,它无论如何都是存在的。

  • JDK 中记录日志的库使用这个外观,因此它们将记录到与应用程序相同的日志文件中。

如果我们使用 JDK 提供的日志外观,ColorManager类的开始将变为以下内容:

package packt.java9.by.example.mastermind; 

import javax.inject.Inject; 
import javax.inject.Named; 
import javax.inject.Singleton; 
import java.util.HashMap; 
import java.util.Map; 
import java.lang.System.Logger; 

import static java.lang.System.Logger.Level.DEBUG; 

@Singleton 
public class ColorManager { 
    protected final int nrColors; 
    protected final Map<Color, Color> successor = new HashMap<>(); 
    private Color first; 
    private final ColorFactory factory; 
    private static final Logger log = System.getLogger(ColorManager.class.getName()); 

    @Inject 
    public ColorManager(@Named("nrColors") int nrColors, 
                                           ColorFactory factory) { 
        log.log(DEBUG,"creating colorManager for {0} colors", 
                                           nrColors);

在这个版本中,我们不导入 slf4j 类。相反,我们导入java.lang.System.Logger类。

注意,我们不需要导入 System 类,因为java.lang包中的类会自动导入。对于System类中的嵌套类,则不适用此规则。

要获取访问日志记录器的权限,需要调用 System.getLogger 静态方法。此方法找到可用的实际日志记录器,并返回一个与作为参数传递的名称相对应的日志记录器。没有 getLogger 方法的版本接受类作为参数。如果我们想坚持约定,那么我们必须编写 ColorManager.class.getName() 来获取类的名称,或者我们可以直接将类的名称作为字符串写入。第二种方法的缺点是它不会跟随类名称的变化。像 IntelliJ、Eclipse 或 Netbeans 这样的智能 IDE 会自动重命名对类的引用,但当类名称被用作字符串时,它们会遇到困难。

接口 System.Logger 没有声明类似于其他日志框架和外观中常见的便利方法 errordebugwarning 等。这里只有一个名为 log 的方法,该方法的第一个参数是我们实际发出的日志级别。定义了八个级别:ALLTRACEDEBUGINFOWARNINGERROROFF。在创建日志消息时,我们应该使用中间六个级别之一。ALLOFF 是用来传递给 isLoggable 方法的。这个方法可以用来检查实际的日志级别是否会被记录。例如,如果级别设置为 INFO,那么使用 DEBUGTRACE 发送的消息将不会被打印。

实际实现是通过 JDK 使用服务加载器功能定位的。日志实现必须在一个提供 java.lang.System.LoggerFinder 接口的模块中。换句话说,该模块应该有一个实现 LoggerFinder 接口的类,并且 module-info.java 应该声明它使用的代码:

provides java.lang.System.LoggerFinder with 
                            packt.java9.by.example.MyLoggerFinder;

MyLoggerFinder 类必须扩展 LoggerFinder 抽象类,并实现 getLogger 方法。

日志记录实践

日志记录实践非常简单。如果你不想花太多时间实验不同的日志解决方案,也没有特殊要求,那么只需使用 slf4j,将其 JAR 添加到依赖列表中作为编译依赖项,然后在源代码中开始使用日志记录。

由于日志记录不是实例特定的,并且日志记录器实现了线程安全,我们通常使用的日志对象存储在 static 字段中,并且只要类被使用,它们就会被使用,因此运行字段的程序也是 final 的。例如,使用 slf4j 外观,我们可以使用以下命令获取日志记录器:

private static final Logger log = 
           LoggerFactory.getLogger(MastermindHandler.class);

要获取日志记录器,使用日志记录器工厂,它只是创建日志记录器或返回已存在的日志记录器。

变量的名称通常是 loglogger, 但如果你看到 LOGLOGGER 也不会感到惊讶。将变量名称大写的原因是,一些静态代码分析检查器将 static final 变量视为常量,正如它们确实那样,Java 社区的惯例是使用大写名称来表示这类变量。这纯粹是个人喜好问题;很多时候 loglogger 都使用小写。

要创建日志项,tracedebuginfowarnerror 方法会创建一个消息,其名称表示相应的级别。例如,考虑以下行:

log.debug("Adding new guess {} to the game", newGuess);

它创建一个调试消息。Slf4j 支持使用字符串中的 {} 文字符号进行格式化。这样,就没有必要将字符串从小的部分拼接起来,并且如果实际的日志项没有发送到日志目标,格式化将不会执行。如果我们以任何形式使用 String 连接来传递字符串作为参数,那么即使不需要调试日志,格式化也会发生。

记录方法也有只接受两个参数的版本:一个 String 消息和一个 Throwable。在这种情况下,记录框架将负责输出异常及其堆栈跟踪。如果你在异常处理代码中记录某些内容,请记录异常并让记录器格式化它。

其他技术

我们讨论了 servlet 技术、一点 JavaScript、HTML 和 CSS。在实际的专业编程环境中,这些技术通常会被使用。然而,应用程序用户界面的创建并不总是基于这些技术。较老的操作系统原生 GUI 应用程序以及 Swing、AWT 和 SWT 使用不同的方法来创建 UI。它们从程序代码中构建面向用户的 UI,UI 被构建为一个组件的分层结构。当网络编程开始时,Java 开发者对这些技术有经验,并创建了尝试隐藏网络技术层的框架。

值得一提的一项技术是 Google Web Toolkit,它使用 Java 实现了服务器和浏览器代码,但由于浏览器中没有实现 Java 环境,它将客户端代码的部分从 Java 转译(转换)为 JavaScript。该工具包的最后一个版本是在两年前的 2014 年创建的,从那时起,Google 已经发布了支持原生 JavaScript、HTML 和 CSS 客户端开发的其它类型的网络编程工具包。

Vaadin 也是一个你可能遇到的工具包。它允许你在服务器上用 Java 编写 GUI 代码。它建立在 GWT 之上,并得到商业支持。如果有一些有 Java GUI 开发经验的开发者,但不是在原生网络技术方面,并且应用程序不需要在客户端进行特殊可用性调整,那么它可能是一个不错的选择。一个典型的企业内部网络应用程序可以选择它作为技术。

Java 服务器端面JSF)是一种试图将应用程序的客户端开发从提供现成小部件的开发者那里卸载下来的技术,同时也处理服务器端。它是一系列Java 规范请求JSR)的集合,并且有多个实现。组件及其关系在 XML 文件中配置,服务器创建客户端本地代码。在这个技术中,没有从 Java 到 JavaScript 的编译。它更像是使用一个有限但庞大的小部件集,限制使用仅限于这些小部件,并放弃直接编程 Web 浏览器的做法。然而,如果一个人有经验和知识,他们可以在 HTML、CSS 和 JavaScript 中创建新的小部件。

为了支持 Java 中的 Web 应用程序,还开发了其他许多技术。大多数大型玩家提倡的现代方法是使用不同的工具集和方法来开发服务器端和客户端,并通过 REST 通信将两者连接起来。

摘要

在本章中,你学习了 Web 编程的结构。没有理解 TCP/IP 网络的基础知识,这是互联网的协议,这是不可能的。在之上使用的应用层协议是 HTTP,目前是全新的 2.0 版本,但 Servlet 标准仍然不支持它。我们创建了一个 Mastermind 游戏的版本,这次它真的可以通过浏览器来玩,我们使用 Jetty 在开发环境中启动了它。我们研究了如何存储游戏状态,并实现了两个版本。最后,我们学习了日志记录的基础知识,并探讨了其他技术。同时,我们还研究了 Google 的依赖注入实现 Guice,并研究了它在内部是如何工作的,以及为什么以及如何使用它。

在本章之后,你将能够开始使用 Java 开发一个 Web 应用程序,并理解此类程序的结构。当你开始学习如何使用 Spring 框架编程 Web 应用程序时,你将了解其内部的工作原理,Spring 框架隐藏了许多 Web 编程的复杂性。

第七章:使用 REST 构建商业 Web 应用

到目前为止,我们一直在玩弄,但 Java 不是玩具。我们希望用 Java 做一些真实和严肃的事情,商业和专业的事情。在本章中,我们将这样做。示例不仅仅是有趣的玩具,比如前三章中的 Mastermind,而是一个真正的商业应用。实际上,它不是一个真实生活的应用。你不应该期望在书中看到这样的东西。那会太长,而且教育意义不足。然而,在本章中我们将开发的应用可以扩展,并且可以作为你决定这样做时真实生活应用的核心。

在上一章中,我们创建了 servlets。为了做到这一点,我们使用了 servlet 规范,并且手动实现了 servlets。这在当今时代是非常少见的。相反,我们将使用一个现成的框架,这次是 Spring。它是 Java 商业应用中最广泛使用的框架,我敢说它已经成为事实上的标准。它将完成我们在上一章中必须做的所有繁琐工作(至少是理解和学习 servlet 是如何工作的)。我们还将使用 Spring 进行依赖注入(为什么使用两个框架,当其中一个就能完成所有工作的时候?),并且我们将使用 Tomcat。

在上一章中,我们使用了 Guice 作为 DI 框架和 Jetty 作为 servlet 容器。它们对于某些项目来说可能是一个完美的选择。对于其他项目,其他框架可能做得更好。为了有机会在本书中查看不同的工具,我们将使用不同的框架,尽管所有示例都可以简单地使用 Tomcat 和 Spring 来创建。

我们将要开发的商业应用将是一个面向分销商的订购系统。我们将提供给用户的接口将不是一个网络浏览器;而是一个 REST。用户将自行开发与我们的系统通信并订购不同产品的应用程序。我们将开发的应用的结构将是微服务架构,我们将使用 soapUI 来测试应用程序,除了标准的 Chrome 开发者工具功能之外。

MyBusiness 网络商店

想象一下,我们有一个庞大的贸易和物流公司。货架上有着成千上万种不同的产品;数百辆卡车来到我们的仓库运送新商品,还有数百辆卡车将商品运送到我们的客户那里。为了管理信息,我们有一个库存系统,每天、每小时、每分钟跟踪商品,以便知道我们实际上在仓库里有什么。我们不通过人工管理仓库信息来服务我们的客户。以前,我们有电话、传真机,甚至电传,但今天,我们只使用互联网和 Web 服务。我们不为我们客户提供网站。在我们想象中的业务中,我们从未直接为最终用户服务,但如今,我们有一个子公司,我们最初将其作为一个独立公司来开展这项业务。他们有一个网站,并且完全独立于我们。他们只是我们数百个注册合作伙伴之一,每个合作伙伴都使用 Web 服务接口查看我们拥有的产品、订购产品以及跟踪订单状态。

样本业务架构

我们的合作伙伴也是拥有自动化管理的大型公司,在多台机器上运行着几个程序。我们对他们的架构和他们使用的科技没有兴趣,但我们想整合他们的运营。我们希望以不需要任何人为交互的方式为他们提供服务,以便在我们的任何一方订购商品。为此,提供了一个可以无论他们使用什么 IT 基础设施都可以利用的 Web 服务接口。

在我们的例子中,我们想象一下,我们最近将我们的单体应用替换成了微服务架构,尽管系统中仍然有一些基于 SOAP 的解决方案,但大多数后端模块都是通过 HTTPS 和 REST 协议进行通信的。一些模块仍然依赖于每天通过 UNIX cron作业启动的 FTP 异步文件传输。总账系统是用 COBOL 编写的。幸运的是,我们不需要处理这些恐龙。

所有这些结构都是一个想象中的但却是现实的设置。我编造并描述了这些部分,以便您了解在一个大型企业中您可能会看到混合技术的样子。我在这里描述的是一个非常简单的设置。有些公司在其系统中使用超过一千个软件模块,这些模块使用不同的技术和完全不同的接口,所有这些模块相互连接。这并不是因为他们喜欢混乱,而是在经过 30 年的持续 IT 发展之后,新技术出现而旧技术逐渐消失。业务在变化,如果您想保持竞争力,就不能坚持使用旧技术。同时,您也不能立即替换整个基础设施。结果是,我们在企业中看到相当老旧的技术仍在运行,很多时候还有新技术。旧技术随着时间的推移而被淘汰。它们不会永远存在,而且有时当一种“恐龙”出现在我们面前时,我们还是会感到惊讶。

我们必须处理的是我们将要开发的两个前端组件。具体如下:

  • 产品信息

  • 订单放置和跟踪

在以下图像中,您可以查看我们所查看的结构体系结构的 UML 图。我们将与之交互的部分仅限于前端组件,但如果有一个更大的图景,这有助于理解其工作方式和它们的作用:

图片

产品信息提供有关单个产品的信息,但它也可以根据查询标准提供产品列表。订单放置和跟踪提供放置订单的功能,并允许客户查询过去订单的状态。

为了提供产品信息,我们需要访问包含实际产品详情的产品目录模块。

产品目录可能还有许多其他任务,这也是它成为一个独立模块的原因。例如,它可以有一个工作流和审批引擎,允许产品管理员输入产品数据,经理检查和批准数据。审批通常是一个复杂的过程,考虑到拼写错误和法律问题(我们不希望交易未经许可的药物、爆炸物等),以及检查商品来源的质量和审批状态。包含了许多复杂任务,使其成为一个后端模块。在大型的企业应用程序中,前端系统很少执行除了为外部各方提供非常基本的功能之外的其他任何事情。但这对我们来说是个好事;我们可以专注于我们必须提供的服务。这对架构来说也是好事。这与面向对象编程中的原则相同:单一责任。

产品信息模块还必须咨询访问控制模块,以查看是否可以将某种产品实际交付给客户,以及与库存查看是否有任何产品剩余,这样我们就不提供缺货的产品。

订单放置和跟踪也需要访问产品库存和访问控制模块,以检查订单是否可以履行。同时,它还需要来自定价模块的服务,该模块可以计算订单的价格,以及来自物流模块的服务,该模块从库存位置触发货物的收集并运送给客户。物流还与开票系统相连,而开票系统与总账相连,但这些只是为了说明信息的流动并没有结束。还有许多其他模块在运行公司,所有这些目前都不是我们的兴趣所在。

微服务

上一章中描述的架构并不是一个干净的微服务架构。在任何企业中,你永远都不会遇到其纯粹的形式。它更像是我们在从单体架构迁移到微服务架构的真正公司中遇到的东西。

当应用以许多小型服务的形式开发,这些服务通过一些简单的 API(通常是通过 HTTP 和 REST)相互通信时,我们谈论微服务架构。这些服务实现业务功能,并且可以独立部署。很多时候,自动化服务部署是可取的。

单个服务可以使用不同的编程语言开发,可以使用不同的数据存储,并可以在不同的操作系统上运行;因此,它们彼此之间高度独立。它们可以,并且通常是由不同的团队开发的。重要的要求是它们可以合作;因此,一个服务实现的 API 可以被构建在其上的其他服务使用。

微服务架构并不是所有架构的圣杯。它对一些问题给出了与单体架构不同的答案,而且很多时候,这些答案使用现代工具效果更好。应用仍然需要测试和调试,性能需要管理,并且需要解决错误和问题。区别在于测试可以沿着不同的技术分离;调试可能需要更多的网络相关工作。这些可能是好的,坏的,或者同时是两者。然而,对于开发者来说,优势是明显的。他们可以独立地在更小的单元上工作,并且可以更快地看到他们工作的结果。在开发单体应用的单一模块时,结果只有在整个应用部署后才能看到。在大型应用的情况下,这可能很少见。在大型企业中,单体应用的典型部署周期是每几个月一次,比如 3 个月,但每年发布两次或一次并不罕见。开发微服务时,新模块可以在准备好并测试后立即部署。

如果你想了解更多关于微服务的信息,最原始和最权威的来源是 Martin Fowler 的文章(www.martinfowler.com/articles/microservices.html)。

服务接口设计

我们设计了我们将要实现的两个接口。当我们设计接口时,我们首先关注功能。格式化和协议随后考虑。接口通常应该是简单的,同时应该适应未来的变化。这是一个难题,因为我们无法预知未来。商业、物流以及所有其他专家可能看到未来的一部分:世界将如何变化,以及它将对公司的运营,尤其是对我们为合作伙伴提供的接口施加什么影响。

接口的稳定性至关重要,因为合作伙伴是外部实体。我们无法重构他们使用的代码。当我们更改代码中的 Java 接口时,编译器会在所有需要跟随更改的代码位置发出警告。对于在领域外使用的接口,情况并非如此。即使它只是一个我们在GitHub上发布的开源 Java 接口,我们也应该准备好,如果以不兼容的方式更改库,我们的用户将面临问题。在这种情况下,他们的软件将无法编译并与我们的库一起工作。在订单系统的例子中,这意味着他们不会从我们这里订购,我们很快就会失去业务。

这是接口应该简单的一个原因。尽管这在生活中大多数事情上都是普遍适用的,但对于这样的接口来说,这一点尤为重要。提供便利功能给合作伙伴是有吸引力的,因为这些功能易于实现。然而,从长远来看,这些功能可能会变得非常昂贵,因为它们需要维护,应该保持向后兼容,而且从长远来看,可能不会带来与成本相匹配的收益。

要访问产品信息,我们需要两个函数。其中一个列出某些产品,另一个返回特定产品的详细信息。如果它是一个 Java API,它看起来如下:

List<ProductId> query(String query); 
ProductInformation byId(ProductId id);

类似地,订单放置可能看起来如下:

OrderId placeOrder(Order order);

我们通过 Web 服务接口提供这些功能,更具体地说,是通过使用 JSON 的 REST。我们将更详细地讨论这些技术,包括 Spring 框架和模型-视图-控制器设计模式,但首先让我们看看产品信息控制器,以了解我们的程序将如何看起来:

package packt.java9.by.example.mybusiness.productinformation; 

import ... 

@RestController 
public class ProductInformationController { 

    @Autowired 
    ProductLookup lookup; 

    @RequestMapping("/pi/{productId}") 
    public ProductInformation 
           getProductInformation(@PathVariable String productId) { 
        ProductInformation productInformation = 
                                lookup.byId(productId); 
        return productInformation; 
    } 

    @RequestMapping("/query/{query}") 
    public List<String> lookupProductByTitle(@PathVariable String query, HttpServletRequest request) { 
        //to be developed later 
    } 
}

如果你将 servlet 的代码与前面的代码进行比较,你可以看到这要简单得多。我们不需要处理HttpServletRequest对象,调用 API 获取参数,或者创建 HTML 输出并将其写入响应。框架会做这些。我们注解@RestController类,告诉 Spring 这是一个利用REST网络服务的控制器;因此,它将默认从我们返回的对象创建JSON响应。我们不需要关心对象到JSON的转换,尽管如果真的需要,我们也可以这样做。对象将自动使用类中使用的字段名和返回实例的字段值转换为JSON。如果对象包含比简单的Stringintdouble值更复杂的结构,则转换器已准备好嵌套结构和最常见的数据类型。

要在 servlet 上实现不同的代码处理和不同的 URL,我们只需要注解方法为@RequestMapping,提供 URL 的路径部分。映射字符串中的{productId}表示法是可读的,易于维护。Spring 将从那里剪切值并将其放入我们请求的productId变量中,正如@PathVariable注解所要求的。

实际上,产品的查找并没有在控制器中实现。这不是控制器的功能。控制器只决定调用什么业务逻辑以及使用什么视图。其中一部分是在框架中实现的,你可以看到前面代码中的非常小的一部分。业务逻辑是在一个服务类中实现的。这个类的实例被注入到lookup字段中。这也是 Spring 做的。我们实际上需要做的工作是调用业务逻辑,这次,因为我们只有一个,所以相当简单。

在没有更多关于框架为我们做了什么的具体细节的情况下,这些事情看起来像是魔法。因此,在继续之前,我们将查看构建块:JSON、REST、MVC 以及 Spring 框架的一些内容。

JSON

JSON代表JavaScript 对象表示法。它在www.json.org/网站上定义。这是一种与 JavaScript 中对象字面量定义相同的文本表示法。对象表示法以{字符开始,以}字符结束。之间的文本定义了对象的字段,形式为string : value。字符串是字段的名称,由于 JSON 希望是语言无关的,它允许任何字符成为字段名称的一部分,因此这个字符串(以及 JSON 中的任何字符串)应该以"字符开始和结束。

这可能看起来很奇怪,很多时候,当你开始使用 JSON 时,很容易忘记,并写成{ myObject : "has a string" }而不是正确的{ "myObject" : "has a string" }表示法。

字段之间用逗号分隔。在 JSON 中也可以有数组。它们分别以[]字符开始和结束,并包含逗号分隔的值。对象字段或数组中的值可以是字符串、数字、对象、数组或常量之一,如truefalsenull

通常来说,JSON 是一种非常简单的表示法,可以用来描述可以存储在对象中的数据。使用文本编辑器编写它很容易,而且阅读起来也很方便,因此使用 JSON 而不是更复杂的格式进行通信时,调试起来更容易。在本章中我们将使用的库中,提供了将 JSON 转换为 Java 对象以及相反方向的转换方法。描述我们示例代码中产品的 JSON 对象样本也包含在程序的源代码中,如下所示:

{"id":"125","title":"Bar Stool","description":"another furniture","size":[20.0,2.0,18.0],"weight":300.0}

注意,JSON 的格式不需要换行,但同时也可能这样做。程序生成的 JSON 对象通常很紧凑,并且没有格式化。当我们使用文本编辑器编辑某些对象时,我们倾向于以与我们在 Java 编程中通常所做的方式相同的方式来格式化字段的缩进。

REST

REST 协议没有确切的定义。它代表表示状态转移,这可能对从未听说过它的人来说没有什么意义。当我们编写 REST API 时,我们使用 HTTP(S)协议。我们向服务器发送简单的请求,并得到我们编程的简单回答。这样,Web 服务器的客户端也是一个程序(顺便说一句,浏览器也是一个程序),它消耗来自服务器的响应。因此,响应的格式不是使用 CSS 格式化的 HTML,也不是通过 JavaScript 增强客户端功能,而是某种数据描述格式,如 JSON。REST 不对实际格式设置限制,但如今,JSON 是最广泛使用的。

描述 REST 的维基页面可在en.wikipedia.org/wiki/Representational_state_transfer找到。REST 接口通常很简单。HTTP 请求几乎总是使用GET方法。这也使得测试 REST 服务变得简单,因为没有什么比从浏览器发出GET请求更容易了。POST请求仅在服务在服务器上执行某些事务或更改时使用,这样请求就是向服务器发送数据,而不是获取某些数据。

在我们的应用程序中,我们将使用GET方法查询产品列表并获取有关产品的信息,我们只使用POST来订购产品。处理这些请求的应用程序将在 servlet 容器中运行。你已经学会了如何在不使用框架的情况下创建裸露的 servlet。在本章中,我们将使用 Spring 框架,它将许多任务从开发者那里卸载。在 servlet 编程中有很多程序结构,大多数时候都是相同的。它们被称为样板代码。Spring 框架利用模型-视图-控制器设计模式来开发 Web 应用程序;因此,在讨论 Spring 的一般情况之前,我们将简要地看看它。

模型-视图-控制器

模型-视图-控制器MVC)是一种设计模式。设计模式是编程结构:一些简单的结构,为解决某些特定问题提供了一些提示。术语“设计模式”是由 Erich Gamma、Richard Helm、Ralph Johnson 和 John Vlissides 在他们的著作《设计模式:可复用面向对象软件元素》中提出并正式描述的。这本书将设计模式定义为具有名称问题解决方案的结构。名称描述了模式,并为开发者在讨论这些模式时提供了词汇。重要的是,不同的开发者使用相同的语言术语以便相互理解。问题描述了情况,即模式可以应用的设计问题。解决方案描述了类和对象及其之间的关系,这些关系有助于良好的设计。

其中之一是 MVC,它适合于编程 Web 应用程序,但通常适用于任何具有用户界面的应用程序。在我们的情况下,我们没有传统的用户界面,因为客户端也是一个程序;尽管如此,MVC 可以并且是一个好的选择。

图片

如同其名称所暗示的,MVC 模式有三个部分:一个模型、一个视图和一个控制器。这种分离遵循单一责任原则,要求每个部分对应一个不同的责任。控制器负责处理系统的输入,并决定使用哪个模型和视图。它控制执行,但通常不执行任何业务逻辑。模型执行业务逻辑并包含数据。视图将模型数据转换为客户端可消费的表示形式。

MVC 是一个众所周知且广泛使用的架构模式,Spring 直接支持它,这样当您创建一个 Web 应用程序时,您可以通过使用注解来编程框架内构建的控制器,从而本质上对其进行配置。您可以编程视图,但更有可能的是您将使用框架内构建的视图。您希望将数据以 XML、JSON或 HTML 的形式发送到客户端。如果您非常独特,您可能希望发送 YAML,但通常就是这样。您不希望实现一个需要在服务器上编程的新格式,而且由于这是新的,也需要在客户端上编程。

我们创建模型,这次我们还对它进行编程。毕竟,那是业务逻辑。框架可以为我们做很多事情,主要是大多数应用程序都相同的事情,但对于业务逻辑。业务逻辑是区分我们的代码与其他程序的代码。这正是我们必须编写的代码。

另一方面,这正是我们所希望的。专注于业务代码,避免框架提供的所有样板代码。

现在我们已经了解了 JSON、REST 以及通用的模型视图控制器设计模式,让我们看看 Spring 如何管理这些技术,以及我们如何将这些技术付诸实践。

Spring 框架

Spring 框架是一个庞大的框架,包含多个模块。该框架的第一个版本于 2003 年发布,自那时起,已有四个主要版本发布,提供了新的和增强的功能。目前,Spring 是事实上的企业级框架,可能比标准的 EJB 3.0 更广泛地被使用。

Spring 支持依赖注入、面向切面编程(AOP)、以传统和对象关系映射方式对 SQLNoSQL 数据库进行持久化、事务支持、消息传递、Web 编程以及许多其他功能。您可以使用 XML 配置文件、注解或 Java 类来配置它。

Spring 架构

Spring 不是单体架构。您可以使用它的一部分,或者只使用一些功能。您可以包含 Spring 需要的一些模块,并排除其他模块。一些模块依赖于其他模块,但 Gradle、Maven 或其他构建工具会处理这一点。

下图显示了 Spring 框架版本 4 的模块:

Spring 自从首次发布以来一直在不断发展,它仍然被视为一个现代框架。框架的核心是一个类似于我们在上一章中看到的依赖注入容器。随着框架的发展,它还支持 AOP 和许多其他企业功能,例如面向消息的模型和基于 Model View Controller 的 Web 编程,不仅支持 servlets,还支持 portlets 和 WebSockets。由于 Spring 面向企业应用领域,它还支持多种数据库处理方式。它支持使用模板的 JDBC、对象关系映射ORM)和事务管理。

在示例程序中,我们使用了一个相当新的模块:Spring Boot。这个模块使得开始编写和运行应用程序变得极其容易,假设了很多通常对许多程序都相同的配置。它包含一个嵌入的 servlet 容器,它为默认设置进行配置,并在可能的地方配置 Spring,这样我们就可以专注于编程方面,而不是 Spring 配置。

Spring 核心

核心模块的核心元素是上下文。当 Spring 应用程序启动时,容器需要一个上下文,以便容器可以在其中创建不同的 bean。这对于任何依赖注入容器来说都是非常通用和真实的。如果我们以编程方式创建两个不同的上下文,它们可能独立存在于同一个 JVM 中。如果声明了一个作为单例的 bean,那么应该只有一个实例,那么容器在需要时将为上下文创建一个单例 bean 的实例。代表上下文的对象引用了我们已经创建的对象。然而,如果有多个上下文,它们将不知道 JVM 中已经存在另一个上下文,并且已经有一个实例,容器将为另一个上下文创建单例 bean 的新实例。

通常,我们不会在程序中使用超过一个上下文,但在单个 JVM 中存在多个上下文的例子却很多。当不同的 servlet 在同一个 servlet 容器中运行时,它们在同一个 JVM 中运行,由类加载器分隔,并且它们可能各自使用 Spring。在这种情况下,上下文将属于 servlet,并且每个 servlet 都将有一个新的上下文。

在上一章中,我们使用了 Guice。Spring 上下文类似于 Guice 注入器。在上一章中,我有点作弊,因为我正在编程 Guice 以为每个请求创建一个新的注入器。这远远不是最优的,Guice 提供了一个可以处理 servlet 环境的注入器实现。作弊的原因是我想要更多地关注 DI 架构的基本要素,并且我不想通过引入复杂的(好吧,更复杂的)注入器实现来使代码复杂化。

在 Spring 上下文行为中,它所执行的操作由ApplicationContext接口定义。这个接口有两个扩展和许多实现。ConfigurableApplicationContext扩展了ApplicationContext,定义了设置器,而ConfigurableWebApplicationContext定义了在 Web 环境中需要的方 法。当我们编程 Web 应用程序时,我们通常不需要直接与上下文交互。框架以编程方式配置 servlet 容器,并包含创建上下文和调用我们方法的 servlet。这些都是为我们创建的样板代码。

上下文跟踪创建的豆,但它不会创建它们。要创建豆,我们需要豆工厂(至少一个)。Spring 中豆工厂的最顶层接口是BeanFactory。对象和豆之间的区别在于,豆工厂创建豆,它在上下文中注册,并且有一个String名称。这样,程序可以通过名称引用豆。

在 Spring 中,不同的豆(bean)可以以几种不同的方式配置。最古老的方法是创建一个 XML 文件来描述不同的豆,指定名称、需要实例化的类以创建豆,以及如果豆需要注入其他豆以进行创建的字段。

这种方法的动机在于,这样豆的连接和配置可以完全独立于应用程序代码。它变成一个可以单独维护的配置文件。如果我们有一个可能在不同环境中运行的大型应用程序,库存数据的访问可能有多种方式。在一个环境中,库存可以通过调用 SOAP 服务来获取。在另一个环境中,数据可以通过 SQL 数据库访问。在第三个环境中,它可以在某些 NoSQL 存储中可用。每种访问都作为实现一个公共库存访问接口的单独类来实现。应用程序代码只依赖于接口,而容器必须提供一种或另一种实现。

当豆连接配置在 XML 中时,则只需编辑此 XML 文件,并且可以使用适合该环境的接口实现来启动代码。

下一个可能性是使用注解来配置豆(Beans)。很多时候,我们使用豆和 Spring 并不是因为存在许多针对豆功能的实现,而是因为我们想将对象实例的创建与功能分离。这是一种良好的风格:即使实现是单一的,没有替代方案,也要分离关注点。然而,在这种情况下,创建 XML 配置是多余的。如果我们的代码中有一个接口以及它的单一实现,那么为什么我要在 XML 中指定通过创建一个实现该接口的类的对象,我应该使用实现该接口的类呢?这显然是很明显的,不是吗?我们不喜欢编程那些可以被自动化的东西。

为了表示一个类可以用作豆,并且可能提供名称,我们可以使用@Component注解。我们不需要提供名称作为参数。在这种情况下,名称将是一个空字符串,但如果我们不引用它,为什么要有一个名称呢?Spring 扫描类路径上的所有类,并识别注解的类,并且知道它们是用于创建豆的候选者。当一个组件需要注入另一个豆时,字段可以注解为@Autowired@Inject@Autowired注解是 Spring 注解,在@Inject注解标准化之前就已经存在。如果你打算在 Spring 容器之外使用你的代码,建议使用标准注解。功能上,它们是等效的。

在我们的代码中,当 Spring 创建ProductInformationController组件的实例时,它会看到需要一个ProductLookup的实例。这是一个接口,因此 Spring 开始寻找实现这个接口的某个类,创建它的实例,可能首先创建其他豆,然后注入它,设置字段。你可以选择注解字段的 setter 而不是字段本身。在这种情况下,即使 setter 是私有的,Spring 也会调用 setter。你可以通过构造函数参数注入依赖项。setter 注入、字段注入和构造函数注入之间的主要区别是,如果你使用构造函数注入,你不能在没有依赖项的情况下创建豆。当豆被实例化时,它应该并且将会注入所有其他豆,以便它依赖于使用构造函数注入。同时,需要通过 setter 注入或直接注入字段的依赖项,可以在容器在实例化类和准备豆之间某个时间点实例化。

这种细微的差异可能在你构造函数代码可能比简单的依赖设置更复杂或依赖变得复杂之前,看起来并不有趣或不重要。在复杂构造函数的情况下,代码应该注意对象尚未完全创建的事实。这通常适用于任何构造函数代码,但在由依赖注入容器创建的 bean 的情况下,这一点尤为重要。因此,可能建议使用构造函数注入。在这种情况下,依赖项已经存在;如果程序员犯了一个错误,忘记了对象尚未完全初始化,并在构造函数或从构造函数调用的方法中使用它,那么依赖项就在那里。此外,使用构造函数初始化依赖项并将这些字段声明为final是干净且结构良好的。

另一方面,构造函数注入有其缺点。

如果不同的对象相互依赖,并且依赖图中存在环,那么如果你使用构造函数依赖,Spring 将面临困难。当类A需要类B,反之亦然,作为最简单的循环,如果依赖注入是构造函数依赖,那么没有另一个类AB可以创建。在这种情况下,构造函数注入不能使用,至少应该打破一个依赖的环。在这种情况下,setter 注入是不可避免的。

当存在可选依赖时,setter 注入可能也更好。很多时候,某个类可能不需要同时使用所有依赖。有些类可能同时使用数据库连接或 NoSQL 数据库句柄,但不是同时使用。尽管这也可能是一个代码异味,可能是糟糕的 OO 设计的迹象,但这种情况可能发生。这可能是出于故意,因为纯 OO 设计会导致对象层次结构太深,类太多,超出了可维护的极限。如果这种情况发生,可选依赖可能更适合使用 setter 注入。一些被配置并设置;一些保留默认值,通常是null

最后但同样重要的是,如果注解不够用,我们可以使用 Java 类来配置容器。例如,在我们的代码库中,ProductLookup接口有多个实现,正如它现在所做的那样。(如果你没有认出这一点,不用担心;我还没有告诉你。)有一个ResourceBasedProductLookup类,它从包中读取属性文件,主要用于测试应用程序,还有一个RestClientProductLookup,它是接口的生产型实现。如果我没有其他配置,只是用@Autowired注解lookup字段,Spring 将不知道使用哪个实现,并在启动时返回以下错误信息:

Error starting ApplicationContext. To display the auto-configuration report re-run your application with 'debug' enabled. 
2016-11-03 07:25:01.217 ERROR 51907 --- [  restartedMain] o.s.b.d.LoggingFailureAnalysisReporter   :  

*************************** 
APPLICATION FAILED TO START 
*************************** 

Description: 

Parameter 0 of constructor in packt.java9.by.example.mybusiness.productinformation.ProductInformationController required a single bean, but 2 were found: 
        - resourceBasedProductLookup: defined in file [/.../sources/ch07/productinformation/build/classes/main/packt/java9/by/example/mybusiness/productinformation/lookup/ResourceBasedProductLookup.class] 
        - restClientProductLookup: defined in file [/.../sources/ch07/productinformation/build/classes/main/packt/java9/by/example/mybusiness/productinformation/lookup/RestClientProductLookup.class] 

Action: 

Consider marking one of the beans as @Primary, updating the consumer to accept multiple beans, or using @Qualifier to identify the bean that should be consumed

这是一个相当直白的错误消息;它告诉了我们很多。现在是我们可以在 XML 中配置 bean 的时候,但与此同时,我们也可以使用 Java 来配置它。

许多开发者第一次没有理解这个要点。我自己也没有理解。整个 XML 配置是为了将配置与代码分离。是为了创建系统管理员更改配置并自由选择某个接口的一个或另一个实现,将应用程序连接起来的可能性。现在 Spring 告诉我,返回到程序化方式更好?

同时,多年来我听到很多人对 XML 并不比 Java 代码更好的担忧。XML 编写本质上是一种编程,只是工具和 IDE 对 XML 的支持不如 Java 代码(尽管后者在近年来发展了很多,尽管对于 Spring XML 配置)。

要理解从 XML 返回 Java 代码的概念,我们必须回到 XML 配置方式的纯粹原因和目标。XML Spring 配置的主要优势不在于格式不是程序性的,而在于配置代码与应用程序代码的分离。如果我们用 Java 编写配置并保持那些配置类尽可能少,并且它们保持应有的状态,那么应用程序代码与配置代码的分离仍然存在。我们只是将配置的格式从 XML 改为 Java。优势众多。其中之一是,当我们编辑时,IDE 会识别类名,我们可以在 Java 中实现自动完成(注意,这也在一些 IDE 中使用插件扩展的情况下使用 XML 实现)。在 Java 的情况下,IDE 支持无处不在。Java 比 XML 更易读。好吧,这是一个口味问题,但大多数人更喜欢 Java 而不是 XML。

系统管理员也可以编辑 Java 代码。当他们编辑 XML 配置时,通常需要从 JAR 或 WAR 文件中提取它,编辑它,然后再次打包存档。在 Java 编辑的情况下,他们还必须发出gradle war命令或类似命令。这对于在服务器上运行 Java 应用程序的系统管理员来说不应该是一个阻止因素。再次强调,这并不是 Java 编程。这只是编辑一些 Java 代码文件和替换一些类名字面量和字符串常量。

我们在我们的示例应用程序代码中遵循这种方法。在应用程序中我们有两个配置文件:一个用于本地部署和测试,另一个用于生产。@Profile注解指定了配置应该使用哪个配置文件。当代码执行时,配置文件可以在命令行上指定为一个系统属性,如下所示:

    $ gradle -Dspring.profiles.active=local bootRun

配置类被注解为@Configuration。作为 bean 工厂的方法被注解为@Bean

package packt.java9.by.example.mybusiness.productinformation; 
import ... 
@Configuration 
@Profile("local") 
public class SpringConfigurationLocal { 
    @Bean 
    @Primary 
    public ProductLookup productLookup() { 
        return new ResourceBasedProductLookup(); 
    } 
    @Bean 
    public ProductInformationServiceUrlBuilder urlBuilder(){ 
        return null; 
    } 
}

Bean 工厂简单地返回一个实现了ProductLookup接口的ResourceBasedProductLookup类的新实例。这个实现可以在没有外部服务可依赖的情况下用于本地测试应用程序。这个实现从打包到 JAR 应用程序中的本地资源文件中读取产品数据。

配置的生产版本与之前没有太大不同,但正如预期的那样,有一些更多的事情需要配置:

@Configuration 
@Profile("production") 
public class SpringConfiguration { 

    @Bean 
    @Primary 
    public ProductLookup productLookup() { 
        return new RestClientProductLookup(urlBuilder()); 
    } 

    @Bean 
    public ProductInformationServiceUrlBuilder urlBuilder(){ 
        return new ProductInformationServiceUrlBuilder( 
                                         "http://localhost"); 
    } 
}

这个版本的ProductLookup服务类使用外部 REST 服务来检索它将向客户端展示的数据。为此,它需要这些服务的 URL。这些 URL 通常应该进行配置。在我们的例子中,我们实现了一个解决方案,这些 URL 可以即时计算。我试图构造一个可能在实际生活中需要的情况,但所有推理都变得扭曲,我放弃了。真正的理由是,这样我们可以看到包含需要另一个 Bean 注入的 Bean 的代码。现在,请注意,ProductInformationServiceUrlBuilder实例 Bean 的定义方式与ProductLookupBean 相同,当它需要注入到ProductLookupBean 的构造函数中时,使用的是定义 Bean 的方法,而不是直接使用下面的表达式:

new ProductInformationServiceUrlBuilder("http://localhost");

后者可能工作,但并非在所有情况下都适用,我们不应该使用它。关于原因,我们将在后续章节讨论 Spring 的 AOP 时再返回。

还要注意,没有必要定义一个接口来定义一个 Bean。Bean 方法返回的类型也可以是一个类。上下文将使用适合所需类型的那个方法,如果有多个合适的类型并且配置不够精确,就像我们看到的,容器将记录一个错误并且不会工作。

在为本地配置文件提供服务的配置中,我们为ProductInformationServiceBuilder创建了一个null值。这是因为我们在使用本地测试时不需要它。此外,如果调用这个类的任何方法,它将是一个错误。错误应该尽快被发现;因此,null值是一个不错的选择。

ProductInformationServiceUrlBuilder类非常简单:

package packt.java9.by.example.mybusiness.productinformation; 

public class ProductInformationServiceUrlBuilder { 
    private final String baseUrl; 

    public ProductInformationServiceUrlBuilder(String baseUrl) { 
        this.baseUrl = baseUrl; 
    } 

    public String url(String service, String parameter) { 
        final String serviceUrl; 
        switch (service) { 
            case "pi": 
                serviceUrl =  
                  baseUrl + ":8081/product/{id}"; 
                break; 
            case "query": 
                serviceUrl =  
                  baseUrl + ":8081/query/{query}"; 
                break; 
            case "inventory": 
                serviceUrl =  
                  baseUrl + ":8083/inventory/{id}"; 
                break; 
            default: 
                serviceUrl = null; 
                break; 
        } 
        return serviceUrl; 
    } 
}

这个类也需要一个构造函数参数,我们在配置中使用了字符串常量。这清楚地表明,可以使用简单的对象来初始化一些依赖项(毕竟,这还是纯 Java),但它可能会妨碍某些 Spring 特性的工作。

服务类

我们有两个服务类。这些类为控制器提供数据并实现业务逻辑,无论它们多么简单。其中一个服务类实现调用基于 REST 的服务,而另一个从属性文件中读取数据。后者可以用于离线测试应用程序。调用 REST 服务的那个用于生产环境。它们都实现了ProductLookup接口:

package packt.java9.by.example.mybusiness.productinformation; 
import java.util.List; 
public interface ProductLookup { 
    ProductInformation byId(String id); 
    List<String> byQuery(String query); 
}

ResourceBasedProductLookup 将整个数据库存储在一个名为 products 的映射中。当调用其中一个服务方法时,它会从属性文件中填充。当服务方法开始时,会从每个服务方法中调用 private 方法 loadProducts,但只有当数据尚未加载时才会加载数据:

package packt.java9.by.example.mybusiness.productinformation.lookup; 
import... 

@Service 
public class ResourceBasedProductLookup implements ProductLookup { 
    private static Logger log = LoggerFactory.getLogger(ResourceBasedProductLookup.class);

该类使用 @Service 注解。这个注解实际上等同于 @Component 注解。这仅仅是相同注解的另一个名称。Spring 也处理 @Component 注解,使得如果一个注解接口被 @Component 注解,那么这个注解也可以用来表示一个类是 Spring 组件。如果你想表示一个类不是简单的组件而是其他特殊类型,以便提高可读性,你可以编写自己的注解接口。

例如,启动你的 IDE 并导航到 org.springframework.stereotype.Service 接口的源代码:

    private ProductInformation 
                  fromProperties(Properties properties) { 
        final ProductInformation pi = new ProductInformation(); 
        pi.setTitle(properties.getProperty("title")); 
        pi.setDescription(properties.getProperty("description")); 
        pi.setWeight( 
           Double.parseDouble(properties.getProperty("weight"))); 
        pi.getSize()[0] = 
           Double.parseDouble(properties.getProperty("width")); 
        pi.getSize()[1] =  
           Double.parseDouble(properties.getProperty("height")); 
        pi.getSize()[2] =  
           Double.parseDouble(properties.getProperty("depth")); 
        return pi; 
    }

fromProperties 方法创建一个 ProductInformation 实例,并从 Properties 对象中提供的参数填充它。Properties 类是一个古老且广泛使用的类型。尽管有更多现代的格式和类,但它仍然被广泛使用,你可能会遇到这个类。这正是我们在这里使用它的原因。

ProductInformation 是一个简单的 数据传输对象DTO),它不包含任何逻辑,只有字段、setter 和 getter。它还包含一个常量,emptyProductInformation,它持有具有空值的类实例的引用。

一个 Properties 对象类似于一个 Map 对象。它包含用 String 键分配的 String 值。正如我们将在示例中看到的那样,有一些方法可以帮助程序员从一个所谓的属性文件中加载 Properties 对象。这样的文件通常具有 .properties 扩展名,并且包含以下格式的键值对:

key=value

例如,123.properties 文件包含以下内容:

id=123 
title=Book Java 9 by Example 
description=a new book to learn Java 9 
weight=300 
width=20 
height=2 
depth=18

properties 文件用于存储简单的配置值,并且几乎仅用于包含特定语言的常量。这是一个非常扭曲的使用,因为 properties 文件是 ISO Latin-1 编码的文件,如果你需要使用一些特殊的 UTF-8 字符,你必须使用 \uXXXX 格式或使用 native2ascii 转换程序来输入它们。你不能简单地以 UTF-8 格式保存它们。尽管如此,这是用于程序国际化的特定语言字符串所使用的文件格式(也简称为 i18n,因为从起始的 i 到最后的 n 之间有 18 个字符)。

要获取 Properties 对象,我们必须读取项目中的文件,并将它们打包成一个 JAR 文件。Spring 类 PathMatchingResourcePatternResolver 帮助我们这样做。

哎呀,是的,我知道!当我们使用 Spring 时,我们必须习惯这些长名称。无论如何,在企业环境中广泛使用这样的长且描述性的名称,它们是解释类功能所必需的。

我们声明一个将包含所有产品的映射,在测试期间:

    final private Map<String, ProductInformation> 
                            products = new HashMap<>();

键是产品 ID,在我们的例子中是一个字符串。值是我们使用fromProperties方法填充的ProductInformation对象:

    private boolean productsAreNotLoaded = true;

下一个字段表示产品尚未加载:

新手程序员通常使用名为productsAreLoaded的相反值,并将其默认设置为false。在这种情况下,我们只会在否定值的地方读取值,或者if命令的主分支成为不执行部分。这两种做法都不是最佳实践。

    private void loadProducts() { 
        if (productsAreNotLoaded) { 
            try { 
                Resource[] resources = 
                   new PathMatchingResourcePatternResolver() 
                            .getResources( 
                               "classpath:products/*.properties"); 
                for (Resource resource : resources) { 
                    loadResource(resource); 
                    } 
                } 
                productsAreNotLoaded = false; 
            } catch (IOException ex) { 
                log.error("Test resources can not be read",ex); 
            } 
        } 
    }

getResources方法返回所有位于products目录下且具有.properties扩展名的资源(文件):

private void loadResource(Resource resource) throws IOException { 
    final int dotPos = resource.getFilename().lastIndexOf('.'); 
    final String id = resource.getFilename().substring(0, dotPos); 
    Properties properties = new Properties(); 
    properties.load(resource.getInputStream()); 
    final ProductInformation pi = fromProperties(properties); 
    pi.setId(id); 
    products.put(id, pi); 
}

产品 ID 由文件名给出。这是通过简单的字符串操作计算的,切除了扩展名。Resource也可以提供一个输入流,Properties类的load方法可以使用它一次性从文件中加载所有属性。最后,我们将新的ProductInformation对象保存到映射中。

我们还有一个特殊的空noProduct列表。当我们想要搜索产品但没有产品时,会返回这个列表:

    private static final List<String> noProducts = 
                                            new LinkedList<>();

产品查找服务只是从Map中取一个产品并返回它,或者如果不存在,则返回一个空产品:

@Override 
public ProductInformation byId(String id) { 
    loadProducts(); 
    if (products.containsKey(id)) { 
        return products.get(id); 
    } else { 
        return ProductInformation.emptyProductInformation; 
    } 
}

查询要复杂一些。它实现了通过标题搜索产品的功能。现实生活中的实现可能实现更复杂的逻辑,但这个版本仅用于本地测试;因此,通过标题搜索就足够了,可能甚至比实际必要的还要复杂:

@Override 
public List<String> byQuery(String query) { 
    loadProducts(); 
    List<String> pis = new LinkedList<>(); 
    StringTokenizer st = new StringTokenizer(query, "&="); 
    while (st.hasMoreTokens()) { 
        final String key = st.nextToken(); 
        if (st.hasMoreTokens()) { 
            final String value = st.nextToken(); 
            log.debug("processing {}={} query", key, value); 
            if (!"title".equals(key)) { 
                return noProducts; 
            } 
            for (String id : products.keySet()) { 
                ProductInformation pi = products.get(id); 
                if (pi.getTitle().startsWith(value)) { 
                    pis.add(id); 
                } 
            } 
        } 
    } 
    return pis; 
}

实现生产功能的服务类要简单得多。奇怪的是,但很多时候测试代码比生产代码复杂:

package packt.java9.by.example.mybusiness.productinformation.lookup; 

import ... 

@Component 
public class RestClientProductLookup implements ProductLookup { 
    private static Logger log = LoggerFactory.getLogger(RestClientProductLookup.class); 

    final private ProductInformationServiceUrlBuilder piSUBuilder; 

    public RestClientProductLookup( 
               ProductInformationServiceUrlBuilder piSUBuilder) { 
        this.piSUBuilder = piSUBuilder; 
    }

构造函数用于注入 URL 构建器 bean,这就是该类所有的辅助代码。其余的是两个服务方法:

    @Override 
    public ProductInformation byId(String id) { 
        Map<String, String> uriParameters = new HashMap<>(); 
        uriParameters.put("id", id); 
        RestTemplate rest = new RestTemplate(); 
        InventoryItemAmount amount = rest.getForObject( 
                        piSUBuilder.url("inventory"), 
                        InventoryItemAmount.class, 
                        uriParameters); 
        if ( amount.getAmount() > 0) { 
            return rest.getForObject(piSUBuilder.url("pi"), 
                    ProductInformation.class, 
                    uriParameters); 
        } else { 
            return ProductInformation.emptyProductInformation; 
        } 
    }

byId方法首先调用库存服务以查看库存中是否有任何产品。这个 REST 服务返回一个具有以下格式的 JSON,{ amount : nnn };因此,我们需要一个具有int amount字段、setter 和 getter 的类(如此简单,我们在此不列出)。

Spring 的RestTemplate提供了一个访问 REST 服务的简单方法。它只需要 URL 模板、用于转换结果的类型以及一个带有参数的Map对象。URL 模板字符串可以像 Spring 控制器中的请求映射一样包含参数,参数名称位于{}字符之间。模板类提供了访问 REST 服务的简单方法。它自动执行序列化、发送参数和反序列化、接收响应。在GET请求的情况下,不需要序列化。数据在请求 URL 中,{xxx}占位符被作为第三个参数提供的映射中的值替换。对于大多数格式,反序列化都是现成的。在我们的应用程序中,REST 服务发送 JSON 数据,并在响应的Content-Type HTTP 头中指示。RestTemplate将 JSON 转换为作为参数提供的类型。如果服务器决定以 XML 格式发送响应,并且它也会在 HTTP 头中指示,RestTemplate将自动处理这种情况。实际上,查看代码,我们无法知道响应是如何编码的。这也是一件好事,因为它使客户端变得灵活,同时我们也不需要处理这些技术细节。我们可以专注于业务逻辑。

同时,在序列化或其他一些功能需要配置参数的情况下,该类也提供了配置参数,以便它自动需要这些参数。例如,您可以提供序列化方法,尽管我建议您使用默认可用的任何方法。在大多数情况下,当开发者认为需要任何这些函数的特殊版本时,他们的代码原始设计是有缺陷的。

商业逻辑非常简单。我们首先询问库存是否有任何产品在库存中。如果有(超过零),那么我们查询产品信息服务并返回详细信息。如果没有,则返回一个空记录。

另一个服务甚至更简单。它只是调用基础服务并返回结果:

    @Override 
    public List<String> byQuery(String query) { 
        Map<String, String> uriParameters = new HashMap<>(); 
        uriParameters.put("query", query); 
        RestTemplate rest = new RestTemplate(); 
        return rest.getForObject( 
                  piSUBuilder.url("query"), 
                  List.class, 
                  uriParameters); 
    } 
}

编译和运行应用程序

我们使用gradle来编译和运行应用程序。由于应用程序没有在大多数类似应用程序中出现的特定配置,因此使用 Spring Boot 是明智的。Spring Boot 使得创建和运行 Web 应用程序变得极其简单。我们需要一个 Java 标准的public static void main方法,通过 Spring 启动应用程序:

package packt.java9.by.example.mybusiness.productinformation; 
import ... 
@SpringBootApplication( 
        scanBasePackageClasses = 
          packt.java9.by.example.mybusiness.SpringScanBase.class) 
public class Application { 
    public static void main(String[] args) { 
        SpringApplication.run(Application.class, args); 
    } 
}

该方法只是启动StringApplication类的run方法。它传递原始参数以及应用程序所在的类。Spring 使用这个类来读取注解。@SpringBootApplication注解表示这个类是一个 Spring Boot 应用程序,并提供配置包含应用程序的包的参数。为此,你可以提供包含类的包的名称,但你也可以提供一个位于基础包中的类,该基础包包含 Spring 需要了解的所有类。你可能无法使用注解参数的类版本,因为根包可能不包含任何类,只有子包。同时,将根包的名称作为String提供,在编译时不会揭示任何错误或错位。一些IDE可能认识到参数应该是包名,或者在你重构或重命名包时,它可能会扫描程序中的字符串以查找包名,并为你提供支持,但这只是启发式方法。通常的做法是在根包中创建一个什么也不做的占位符类,以防那里没有类。这个类可以用作将scanBasePackageClasses作为注解参数而不是需要StringscanBasePackages。在我们的例子中,我们有一个空的接口SpringScanBase作为占位符。

Spring 扫描类路径上的所有类,识别它可以解释的组件和字段注解,并使用这些知识在需要时创建配置为无配置的 bean。

注意,包含在 JDK 中的抽象类ClassLoader不提供任何类扫描方法。由于 Java 环境和框架可以实施自己的ClassLoaders,因此有可能(但非常不可能)某些实现没有提供URLClassLoader提供的扫描功能。URLClassLoader是类加载功能的非抽象实现,就像ClassLoader一样,也是 JDK 的一部分。我们将在后续章节中讨论类加载机制的复杂性。

gradle构建文件包含通常的东西。它指定了仓库、Java 插件、IDE 以及 Spring Boot 插件。它还指定了在构建过程中生成的 JAR 文件的名称。最重要的部分是依赖列表:

buildscript { 
    repositories { 
        mavenCentral() 
    } 
    dependencies { 
        classpath("org.springframework.boot:spring-boot-gradle-plugin:1.4.1.RELEASE") 
    } 
} 

apply plugin: 'java' 
apply plugin: 'eclipse' 
apply plugin: 'idea' 
apply plugin: 'spring-boot' 

jar { 
    baseName = 'packt-ch07-microservice' 
    version =  '1.0.0' 
} 

repositories { 
    mavenCentral() 
} 

bootRun { 
    systemProperties System.properties 
} 

sourceCompatibility = 1.9 
targetCompatibility = 1.9 

dependencies { 
    compile("org.springframework.boot:spring-boot-starter-web") 
    compile("org.springframework.boot:spring-boot-devtools") 
    compile("org.springframework:spring-aop") 
    compile("org.springframework:spring-aspects") 
    testCompile("org.springframework.boot:spring-boot-starter-test") 
}

我们依赖于 Spring Boot 包,一些测试包,AOP 支持(我们很快将探讨),以及 Spring Boot devtools。

Spring Boot devtools 使得在重新编译时能够重新启动 Web 应用程序,而无需重新启动内置的 Tomcat 服务器。假设我们使用以下命令行启动应用程序:

    gradle -Dspring.profiles.active=production bootRun

Gradle 启动应用程序,并且每当它看到它运行的类被修改时,它会重新加载它们,我们可以在几秒钟内测试修改后的应用程序。

-Dspring.profiles.active=production参数指定生产配置文件应该处于活动状态。为了能够使用此命令行参数,我们还需要在构建文件中包含bootRun{}配置闭包。

测试应用程序

应用程序应该为每个类编写单元测试,除了可能不包含任何功能的 DTO 类。设置器和获取器是由 IDE 创建的,而不是由程序员输入的,因此在这些设置中不太可能出现错误。如果有与这些类相关的错误,更有可能是一些无法通过单元测试发现的集成问题。由于我们在前面的章节中详细讨论了单元测试,因此我们将更多地关注集成测试和应用测试。

集成测试

集成测试与单元测试非常相似,很多时候,新手程序员声称他们在进行单元测试,而实际上他们正在进行集成测试。

集成测试驱动代码,但不单独测试单个类(单元),模拟类可能使用的所有内容。相反,它们测试了执行测试所需的大多数类的功能。这样,集成测试确实测试了类能够协同工作,不仅满足它们自己的规范,而且确保这些规范能够协同工作。

在集成测试中,外部世界(如外部服务)和数据库的访问仅进行模拟。这是因为集成测试应该在集成服务器上运行,在执行单元测试的相同环境中,在这些外部接口可能不可用。很多时候,数据库使用内存 SQL 进行模拟,外部服务使用一些模拟类进行模拟。

Spring 提供了一个很好的环境来执行此类集成测试。在我们的项目中,我们有一个示例集成测试:

package packt.java9.by.example.mybusiness.productinformation; 
import ...  
@RunWith(SpringRunner.class) 
@SpringBootTest(classes = Application.class) 
@AutoConfigureMockMvc 
@ActiveProfiles("local") 
public class ProductInformationControllerTest { 
    @Autowired 
    private MockMvc mockMvc; 
    @Test 
    public void noParamGreetingShouldReturnDefaultMessage()  
                                             throws Exception { 
        this.mockMvc.perform(get("/pi")).andDo(print()) 
                .andExpect(status().isNotFound()); 
    } 
    @Test 
    public void paramGreetingShouldReturnTailoredMessage() 
                                             throws Exception { 

        this.mockMvc.perform(get("/pi/123")) 
                .andDo(print()).andExpect(status().isOk()) 
                .andExpect(jsonPath("$.title") 
                .value("Book Java 9 by Example")); 
    } 

}

这远非一个完整且功能齐全的集成测试。有许多情况没有被测试,但在这里它作为一个例子是很好的。为了支持 Spring 环境,我们必须使用SpringRunner类。@RunWith注解由 JUnit 框架处理,所有其他注解都是针对 Spring 的。当 JUnit 框架看到存在一个@RunWith注解和一个指定的运行器类时,它将启动该类而不是标准运行器。SpringRunner为测试设置 Spring 上下文并处理注解。

@SpringBootTest指定了我们需要测试的应用程序。这有助于 Spring 读取该类及其上的注解,识别要扫描的包。

@AutoConfigureMockMvc告诉 Spring 配置一个模拟的模型视图控制器框架版本,可以在没有 servlet 容器和 Web 协议的情况下执行。使用它,我们可以测试我们的 REST 服务而无需真正进入网络。

@ActiveProfiles告诉 Spring 活动配置是本地配置,并且 Spring 必须使用由注解@Profile("local")表示的配置。这是一个使用.properties文件而不是外部 HTTP 服务的版本;因此,这对于集成测试是合适的。

测试在模拟框架内部执行GET请求,执行控制器中的代码,并使用模拟框架和流畅 API 以非常可读的方式测试返回的值。

注意,使用属性文件并且服务实现基于属性文件有点过度。我创建这个是为了能够交互式地启动应用程序而不需要任何真实的服务支持。考虑以下命令:gradle -Dspring.profiles.active=local bootRun

如果我们发出前面的命令,那么服务器将使用这个本地实现启动。如果我们只是为了进行集成测试,那么服务类的本地实现应该位于test目录下,并且应该非常简单,主要只对任何预期的请求返回常量响应,如果收到任何非预期的请求则抛出错误。

应用程序测试

考虑以下命令:

    gradle -Dspring.profiles.active=production bootRun

如果我们发出前面的命令启动应用程序,并打开浏览器到 URL http://localhost:8080/pi/123,我们将在浏览器屏幕上看到一个巨大的错误消息。哎呀...

它显示Internal Server Error, status=500或类似错误。这是因为我们的代码想要连接到支持服务,但我们还没有。为了在这个级别上测试应用程序,我们应该创建支持服务或者至少是它们的模拟。最简单的方法是使用 soapUI 程序。

soapUI 是一个可以从www.soapui.org/获取的 Java 程序。它有一个开源免费版本和一个商业版本。对于我们的目的,免费版本就足够了。我们可以通过简单的点击下一步的方式安装它,因为它有一个设置向导。安装后,我们可以启动它并使用图形用户界面。

我们创建一个新的测试项目,目录和库存,并在其中设置两个 REST 模拟服务:目录和库存,如下面的截图所示:

图片

我们为每个模拟服务设置请求匹配和响应。响应的内容是文本,可以输入到用户界面的文本字段中。重要的是我们不要忘记将响应的媒体类型设置为application/json(默认是 XML)。

图片

在启动服务之前,我们必须通过点击齿轮图标来设置端口号,使其在服务器上可用。由于 8080 被 Gradle 执行的 Tomcat 服务器使用,而 8082 被 soapUI 用于列出当前运行的模拟服务,我将目录设置为监听 8081,库存设置为 8083。您也可以在ProductInformationServiceUrlBuilder类的列表中看到这些端口号。

soapUI 将项目保存为 XML 文件,并且该项目在 GitHub 的project目录中可供您使用。

在启动模拟服务后,当我们点击刷新时,错误信息会从浏览器屏幕上消失:

图片

我们看到的就是我们在 soapUI 中输入的内容。

如果我现在将库存模拟服务更改为返回 0 而不是 100,就像原始版本一样,我得到的是以下空记录:

{"id":"","title":"","description":"","size":[0.0,0.0,0.0],"weight":0.0}

即使在这个层面上,测试也可以自动化。现在,我们使用浏览器进行了一些实验,这是件好事。不知何故,我觉得当有一个真正在做事的程序时,当我能在浏览器窗口中看到一些响应时,我好像在创造些什么。然而,过了一会儿,这变得无聊了,手动测试应用程序是否仍然工作变得繁琐。对于那些没有改变的功能来说,这尤其无聊。事实上,即使我们没有触摸影响它们的代码,它们也会奇迹般地多次改变。我们触摸了影响功能的代码,只是我们没有意识到这一点。糟糕的设计、糟糕的编码,或者也许我们只是忘记了,但事情就是这样发生了。回归测试是不可避免的。

虽然浏览器测试用户界面也可以自动化,但这次我们有一个可以测试的 REST 服务,这正是 soapUI 的作用。我们已经安装了该工具,已经启动它,并且其中运行了一些模拟服务。接下来,我们需要从 URI 添加一个新的 REST 服务到项目中,并指定 URL,http://localhost:8080/pi/{id},这与我们为 Spring 所做的方式完全相同:

图片

当我们在项目中定义了一个 REST 服务时,我们可以在套件内创建一个新的测试套件和一个测试用例。然后我们可以向测试用例中添加一个步骤,该步骤将使用参数123调用 REST 服务,如果我们修改默认值,该默认值与参数名称相同,在这种情况下,id。我们可以通过在窗口左上角使用绿色三角形来运行测试步骤,并且由于我们正在运行测试应用程序和 soapUI 模拟服务,我们应该得到一个 JSON 格式的答案。我们必须在响应侧选择 JSON;否则,soapUI 会尝试将响应解释为 XML,而由于我们有 JSON 响应,这并不是很有成效。我们看到的是以下窗口:

图片

这是我们之前在浏览器中看到的相同响应。编程计算机时没有奇迹。有时,我们不理解发生了什么,有些事情非常复杂,似乎是个奇迹,但实际上并不是。每件事都有解释,可能只是我们不知道。在这种情况下,我们当然知道发生了什么,但为什么在 soapUI 的屏幕上看到 JSON 比在浏览器上更好呢?原因在于 soapUI 可以执行断言,在某些情况下,基于 REST 调用的结果进一步执行测试步骤,最终结果是简单的 YES 或 NO。测试是 OK,或者它失败。

要添加一个断言,请点击窗口左下角的“断言”文本。正如你在前面的屏幕截图中所见,我已经添加了一个比较返回 JSON 的 "title" 字段与文本 "Bar Stool" 的断言。当我们添加断言时,它建议的默认值是实际返回的值,这只是一个非常方便的功能。

然后,再次运行整个测试套件将运行所有测试用例(我们只有一个),然后依次运行所有测试步骤(我们再次只有一个),最后它将在 UI 上显示一个绿色的“完成”条,如下面的屏幕截图所示:

图片

这并不是 soapUI 能做的全部。这是一个经过良好发展的测试工具,已经在市场上存在很多年了。soapUI 可以测试 SOAP 服务和 REST 服务,并且它可以处理 JMS 消息。你可以使用这些调用、循环和断言在调用或单独的测试中创建多步骤的测试,如果所有其他方法都失败了,你还可以通过在 Groovy 语言中创建程序步骤或在 Java 中创建扩展来做到任何事情。

Servlet 过滤器

服务现在运行良好,任何人都可以查询我们产品的详细信息。这可能会成为一个问题。产品的详细信息不一定是公开信息。我们必须确保我们只向有资格查看这些数据的合作伙伴提供数据。

为了确保这一点,我们需要在请求中包含一些信息来证明请求来自合作伙伴。这种信息通常是密码或其他秘密。它可以被放置在 GET 请求参数中或 HTTP 请求头中。将其放入头中更好,因为信息是秘密的,不应该被任何人看到。

GET 参数是 URL 的一部分,浏览器历史记录会记住这一点。也很容易将此信息输入到浏览器的位置窗口中,复制粘贴,并通过聊天频道或电子邮件发送。这样,应用程序的用户,如果他们对安全不太了解且不关心,可能会泄露秘密信息。尽管使用发送在 HTTP 头中的信息做同样的事情并非不可能,但这种情况不太可能发生。如果信息在头中,并且有人通过电子邮件发送信息,他们可能知道自己在做什么;他们自愿跨越安全边界,而不是简单的疏忽。

为了在 HTTP 请求中发送认证信息,Spring 提供了一个可以通过注解和配置 XMLs 以及/或类轻松配置的安全模块。这次,我们将采取不同的方法来介绍 servlet 过滤器。

我们将要求供应商在请求中插入X-PartnerSecret头。这是一个非标准头,因此它必须具有X-前缀。遵循这种方法还有一些额外的安全功能。这样,我们可以防止用户通过简单的浏览器访问服务。至少,需要一些额外的插件来插入自定义头或肥皂 UI 等程序。这样,将确保我们的合作伙伴将程序化地使用接口,或者如果他们需要临时测试接口,只有具备一定技术水平的人才能够这样做。这对于控制支持成本非常重要。

由于这个秘密需要在每个服务的情况下进行检查,所以我们最好不要将检查代码插入到每个服务控制器中。即使我们正确地创建了代码并将对秘密的检查分解为单独的类,也必须在每个控制器中插入断言秘密存在且正确的方法的调用。控制器执行服务;检查客户端的真实性是一个基础设施问题。它们是不同的关注点,因此必须分离。

服务器端过滤器标准为我们提供的最佳方式是 servlet 过滤器。servlet 过滤器是一个在配置了过滤器的情况下由 servlet 容器在 servlet 本身之前调用的类。过滤器可以在 servlet 容器的web.xml配置文件中配置,或者在我们使用 Spring Boot 时使用注解。过滤器不仅接收请求和响应作为参数,还接收一个FilterChain类型的第三个参数,它应该使用该参数来调用 servlet 或链中的下一个过滤器。

可以定义多个过滤器,并且它们会被链式调用。过滤器可以自行决定是否调用链中的下一个。

图片

我们将我们的 servlet 过滤器放入应用程序的auth子包中:

package packt.java9.by.example.mybusiness.productinformation.auth; 

import ... 

@Component 
public class AuthFilter implements Filter { 
    private static Logger log = 
           LoggerFactory.getLogger(AuthFilter.class); 
    public static final int NOT_AUTHORIZED = 401; 

    @Override 
    public void init(FilterConfig filterConfig) 
                                throws ServletException { 
    } 
    @Override 
    public void doFilter(ServletRequest request, 
                         ServletResponse response, 
                         FilterChain chain) 
                          throws IOException, ServletException { 
        HttpServletRequest httpRequest = 
                     (HttpServletRequest) request; 
        final String secret = 
                     httpRequest.getHeader("X-PartnerSecret"); 
        log.info("Partner secret is {}", secret); 
        if ("packt".equals(secret)) { 
            chain.doFilter(request, response); 
        } else { 
            HttpServletResponse httpResponse = 
                     (HttpServletResponse) response; 
            httpResponse.sendError(NOT_AUTHORIZED); 
        } 
    } 
    @Override 
    public void destroy() { 
    } 
}

过滤器实现了定义了三个方法的Filter接口。在我们的情况下,我们在过滤器中没有要考虑的任何参数,也没有分配任何资源来释放;因此,initdestroy方法都是空的。过滤器的主要工作是doFilter方法。它有三个参数,其中两个与 servlet 的参数相同,第三个是FilterChain

请求被转换为HttpServletRequest,这样我们就可以通过getHeader方法访问X-PartnerSecret头。如果这个头字段发送的值是好的,我们就调用链中的下一个。在我们的应用程序中,没有配置更多的过滤器;因此,链中的下一个是 servlet。如果秘密不可接受,那么我们不调用链中的下一个。相反,我们向客户端返回401 未授权的 HTTP 错误。

在这个应用程序中,秘密非常简单。这是一个常量字符串packt。这并不是一个真正的秘密,尤其是在它被发表在这本书之后。一个现实生活中的应用需要更隐秘且不太为人所知的秘密。很可能每个合作伙伴都会使用不同的秘密,而且秘密需要不时地更改。

当我们的程序处理的 servlet 中出现错误条件时,使用 HTTP 错误处理机制是一个好的做法。而不是发送带有状态代码200 OK的消息并解释,例如,以 JSON 格式说明认证未成功,我们必须发送回401代码。这是由标准定义的,不需要任何进一步的解释或文档。

在我们的程序中,还剩下一点,那就是审计日志。

审计日志和 AOP

在我们的示例代码中,我们使用了 slf4j 进行日志记录,这在上一章中已经介绍过。日志记录基本上是开发者的决定,并支持技术操作级别。在那里,我们也提到了一些句子审计日志。这种类型的日志通常在功能需求中明确要求。

通常,面向切面编程(AOP)是将代码功能的不同方面分离成独立的代码片段,并独立实现它们。这非常符合单一责任原则。这次,它是以一种不仅不同的功能是独立实现的,而且连接它们的方式也是独立定义的方式实现的。在执行其他部分之前和之后执行的操作被单独编码到 Spring 配置中。我们已经看到过类似的情况。一个类为了正确运行所需的依赖关系被定义在单独的段(XML 或 Java 代码)中。在 AOP 的情况下,同样也是使用 Spring 来实现的。方面是在配置文件或类中配置的。

一个典型的方面是审计日志,我们将以此为例。有许多主题可以使用方面实现,其中一些甚至值得那样实现。

我们不希望在需要审计日志的每个业务方法或类中实现审计日志代码。相反,我们实现一个通用方面,并配置连接,使得每当需要审计日志的 bean 方法被调用时,Spring 都会调用审计日志。

有其他一些重要的术语我们需要理解,特别是关于 AOP 如何在 Spring 中配置。

第一件也是最重要的事情是方面。这是我们想要实现的功能,在我们的例子中,是审计日志。

连接点是方面被调用的执行点。当在 Java 中使用全功能的方面解决方案修改生成的类的字节码时,连接点可以是几乎任何东西。它可以是对字段的访问、读取或写入;它可以是方法的调用或异常抛出。在 Spring 的情况下,类字节码没有被修改;因此,Spring 无法识别字段的访问或异常抛出。使用 Spring 时,连接点总是在方法被调用时使用。

通知是方面在连接点被调用的方式。它可以是前置通知、后置通知或环绕通知。当通知是前置时,方面在方法调用之前被调用。当通知是后置时,方面在方法调用之后被调用。环绕意味着方面在方法调用之前被调用,并且方面还有一个调用方法并在方法调用之后执行一些操作的参数。这样,环绕通知非常类似于 servlet 过滤器。

在方法调用之前调用前置通知,并在它返回后,框架将调用方法。方面没有阻止原始方法调用的方法。唯一的例外是当方面抛出异常时。

后置通知也会受到异常的影响。当方法返回时,可以有一个返回后通知被调用。只有当方法抛出异常时,才会调用抛出异常通知。在异常或返回的情况下,最终通知会被调用。

切入点是一个特殊的字符串表达式,用于标识连接点。切入点表达式可以匹配零个、一个或多个连接点。当方面与切入点表达式关联时,框架将知道连接点以及何时何地调用方面。换句话说,切入点是告诉何时以及为哪个方法调用方面的字符串。

尽管 Spring 的 AOP 实现不使用 AspectJ,也不修改为类创建的字节码,但它支持切点表达式语言。尽管这种表达式语言提供的功能比 Spring 实现的功能更多,但它是一个经过良好建立、广泛使用并被接受的用于描述切点的表达式语言,因此发明新的东西是没有意义的。

引入是在运行时向已存在的类型添加方法或字段,并在运行时完成。Spring 允许这种 AOP 功能向现有类型添加一个接口,并以建议类的形式添加接口的实现。在我们的例子中,我们没有使用这个功能。

目标对象是被切面建议的对象。这是包含切面方法(即切面之前或之后)的 bean。

这只是一个简化的定义集合,几乎就像在数学书中一样。如果你只是阅读它而没有理解,不要担心。我也没有理解。这就是为什么我们有了以下示例,之后我们刚刚覆盖的所有内容都会更有意义:

package packt.java9.by.example.mybusiness.productinformation; 

import ... 

@Configuration 
@Aspect 
public class SpringConfigurationAspect { 
    private static Logger log = 
              LoggerFactory.getLogger("AUDIT_LOG"); 

    @Around("execution(* byId(..))") 
    public ProductInformation byIdQueryLogging( 
                            ProceedingJoinPoint jp) 
                                         throws Throwable { 
        log.info("byId query is about to run"); 
        ProductInformation pi = 
             (ProductInformation) jp.proceed(jp.getArgs()); 
        log.info("byId query was executed"); 
        return pi; 
    } 

    @Around("execution(* url(..))") 
    public String urlCreationLogging(ProceedingJoinPoint jp) 
                                            throws Throwable { 
        log.info("url is to be created"); 
        String url = (String) jp.proceed(jp.getArgs()); 
        log.info("url created was "+url); 
        return url; 
    } 
}

该类被@Configuration注解标记,这样 Spring 就知道这个类包含配置。@Aspect注解表示这个配置也可能包含切面定义。方法上的@Around注解给出了建议的类型,注解的参数字符串是切点表达式。如果建议的类型不同,应使用以下注解之一:@Before@After@AfterReturning@AfterThrowing

在我们的例子中,我们使用@Around切面来演示最复杂的场景。我们在方法执行前后记录目标方法的执行情况,并且通过ProceedingJoinPoint对象调用原始方法。由于这两个对象返回不同的类型,而我们希望以不同的方式记录,因此我们定义了两个切面方法。

建议注解的参数是切点字符串。在这种情况下,它很简单。第一个是execution(* byId(..)),表示对于任何名为 byId 且具有任何参数的方法的执行,都应该调用切面。第二个与第一个非常相似,只是方法名不同。这些都是简单的切点表达式,但在一个大量使用 AOP 的大型应用程序中,它们可能非常复杂。

Spring 中切点表达式语法主要遵循 AspectJ 使用的语法。该表达式使用切点设计符PCD)的概念,通常是 execution。它后面跟着定义要拦截哪个方法的模式。一般格式如下:

execution(modifiers-pattern? ret-type-pattern declaring-type-pattern?name-pattern(param-pattern) throws-pattern?)

除了返回类型部分外,其他所有部分都是可选的。例如,我们可以写出以下内容:

execution(public * *(..))

这将拦截所有public方法。以下表达式拦截所有以 set 开头的方法名:

execution(* set*(..))

我们可以使用*字符作为通配符,就像在 Windows 或 Unix shell 的命令行中一样使用。参数匹配定义稍微复杂一些。(..)表示任何参数,()表示没有参数,而(*)表示恰好一个任意类型的参数。当有更多参数时,最后一个也可以使用;例如,(*,Integer)表示有两个参数,第二个是Integer类型,而我们并不关心第一个参数的类型。

切入点表达式可以更复杂,通过&&(和)和||(或)逻辑运算符将匹配表达式连接起来,或者使用!(否定)一元运算符。

使用@Pointcut()注解,配置可以定义带有方法注解的切入点。例如,考虑以下:

@Pointcut("execution(* packt.java.9.by.example.service.*.*(..))")  
public void businessService() {}

它将为packt.java.9.by.example.service包中任何实现的任何方法定义一个连接点。这仅仅定义了切入点表达式并将其分配给名为businessService的名称,该名称由方法的名称给出。稍后,我们可以在方面注解中引用此表达式,例如:

@After("businessService()")

注意,使用该方法纯粹是为了它的名称。这个方法不是由 Spring 调用的。它仅用于将@Pointcut注解定义在其上的表达式的名称借用过来。需要某种东西,比如一个方法,来放置这个注解,并且由于方法有名称,为什么不使用它:Spring 就是这样做的。当它扫描配置类并看到注解时,它将其内部结构分配给方法的名称,当使用该名称(连同括号,以混淆模仿方法调用的初学者程序员)时,它会查找该名称的表达式。

AspectJ 定义了其他设计符。Spring AOP 识别其中的一些,但它会抛出IllegalArgumentException,因为 Spring 只实现了方法执行切入点。另一方面,AspectJ 还可以拦截对象创建,其中 PCD 是初始化,例如。除了执行之外,一些其他 PCD 可以限制执行 PCD。例如,PCD within 可以用来限制方面到属于某些包的类的连接点,或者@target PCD 可以用来限制匹配到具有在@target关键字之后()之间给出的注解的对象中的方法。

Spring 使用的一个 PCD 在 AspectJ 中不存在。这是一个 bean。你可以定义一个包含bean(name pattern)的切入点表达式,以限制连接点到命名 bean 中的方法执行。该模式可以是整个名称,也可以像几乎任何 PCD 表达式匹配一样,使用*作为通配符。

基于动态代理的 AOP

当 Spring AOP 首次向 Java 程序员介绍时,看起来像是魔法。我们是如何拥有一个class X的变量,并在该对象上调用某个方法,但结果却是在方法执行之前或之后,甚至围绕它执行某些方面,拦截调用

Spring 所使用的技术称为动态代理。当我们有一个实现接口的对象时,我们可以创建另一个对象——代理对象,它也实现了该接口,但每个方法实现都会调用一个不同的对象,称为处理者,实现 JDK 接口InvocationHandler。当在代理对象上调用接口的方法时,它将在处理者对象上调用以下方法:

public Object invoke(Object target, Method m, Object[] args)

这个方法可以自由地做任何事情,甚至可以调用目标对象上的原始方法,使用原始或修改后的参数。

图片

当我们没有可用的接口,该接口是即将被代理的类实现的,我们无法使用 JDK 方法。幸运的是,有一些广泛使用的库,例如cglib,Spring 也使用这些库,并且可以执行类似操作。Cglib可以创建一个代理对象,它扩展了原始类并实现了其方法,以类似于 JDK 版本对接口方法的方式调用处理对象的invoke方法。

这些技术会在 Java 运行时创建和加载类到内存中,它们是非常深入的技术工具。它们是高级主题。我并不说作为一个初学者 Java 程序员不应该玩弄它们。毕竟,会发生什么?Java 不是一把装满子弹的枪。然而,当你不理解一些细节或者某些事情一开始(或者第二次、第三次)不工作时,不要失去兴趣是很重要的。继续前进。

Spring 中的 AOP 实现是通过为目标对象生成代理对象来工作的,处理器调用我们在 Spring 配置中定义的方面。这就是为什么你不能在final类或final方法上放置方面。同样,你也不能在privateprotected方法上配置方面。原则上,protected方法可以被代理,但这不是好的做法,因此 Spring AOP 不支持它。同样,你也不能在不是 Spring bean 的类上放置方面。它们是由代码直接创建的,而不是通过 Spring,并且当对象被创建时,没有机会返回一个代理而不是原始对象。简单来说,如果我们不要求 Spring 创建对象,它就不能创建一个自定义的。我们最不想做的事情就是执行程序并看到方面是如何表现的。我们审计日志的实现非常简单。我们使用标准的日志记录,这实际上并不足以用于实际的审计日志应用。我们唯一特别做的事情是使用名为AUDIT_LOG的记录器,而不是类的名称。这是大多数日志框架中记录器的合法使用。尽管我们通常使用类来识别记录器,但绝对可以使用字符串来识别记录器。在我们的日志记录中,这个字符串也将打印在日志行中的控制台上,并且它将视觉上突出显示。

考虑以下命令:

    gradle -Dspring.profiles.active=production bootRun

如果我们再次使用前面的命令启动应用程序,启动项目的 soapUI,启动模拟服务,并执行测试,我们将在控制台上看到以下方面打印的日志行:

    2016-11-10 19:14:09.559  INFO 74643 --- [nio-8080-exec-1] o.a.c.c.C.[Tomcat].[localhost].[/]       : Initializing Spring FrameworkServlet 'dispatcherServlet'
2016-11-10 19:14:09.567  INFO 74643 --- [nio-8080-exec-1] o.s.web.servlet.DispatcherServlet        : FrameworkServlet 'dispatcherServlet': initialization started
2016-11-10 19:14:09.626  INFO 74643 --- [nio-8080-exec-1] o.s.web.servlet.DispatcherServlet        : FrameworkServlet 'dispatcherServlet': initialization completed in 59 ms
2016-11-10 19:14:09.629  INFO 74643 --- [nio-8080-exec-1] p.j.b.e.m.p.auth.AuthFilter              : Partner secret is packt
2016-11-10 19:14:09.655  INFO 74643 --- [nio-8080-exec-1] AUDIT_LOG                                : byId query is about to run
2016-11-10 19:14:09.666  INFO 74643 --- [nio-8080-exec-1] AUDIT_LOG                                : url is to be created
2016-11-10 19:14:09.691  INFO 74643 --- [nio-8080-exec-1] AUDIT_LOG                                : url created was http://localhost:8083/inventory/{id}
2016-11-10 19:14:09.715  INFO 74643 --- [nio-8080-exec-1] p.j.b.e.m.p.l.RestClientProductLookup    : amount {id: 123, amount: 100}.
2016-11-10 19:14:09.716  INFO 74643 --- [nio-8080-exec-1] p.j.b.e.m.p.l.RestClientProductLookup    : There items from 123\. We are offering
2016-11-10 19:14:09.716  INFO 74643 --- [nio-8080-exec-1] AUDIT_LOG                                : url is to be created
2016-11-10 19:14:09.716  INFO 74643 --- [nio-8080-exec-1] AUDIT_LOG                                : url created was http://localhost:8081/product/{id}
2016-11-10 19:14:09.725  INFO 74643 --- [nio-8080-exec-1] AUDIT_LOG                                : byId query was executed

摘要

在本章中,我们构建了一个简单的业务应用程序,支持企业间交易。我们使用事实上的企业框架标准特性:Spring,在微服务(几乎)架构中实现了 REST 服务。回顾本章,令人惊讶的是我们只写了很少的代码就实现了所有功能,这是很好的。我们需要编写的代码越少,我们想要实现的东西就越好。这证明了框架的力量。

我们讨论了微服务、HTTP、REST、JSON 以及如何使用 MVC 设计模式来使用它们。我们学习了 Spring 是如何构建的,有哪些模块,Spring 中依赖注入的工作原理,甚至我们还稍微接触了一点 AOP。这非常重要,因为随着 AOP,我们发现了 Spring 是如何使用动态代理对象来工作的,这在需要调试 Spring 或使用类似解决方案的其他框架时非常有价值(而且有一些是经常使用的)。

我们开始使用简单的浏览器测试我们的代码,但之后我们意识到使用一些专业的测试工具来测试 REST 服务会更好,为此我们使用了 soapUI,并构建了一个简单的 REST 测试套件,包括 REST 测试步骤和模拟服务。

学会了这一切,没有什么能阻止我们利用非常现代和先进的 Java 技术来扩展这个应用,例如反射(在我们讨论 JDK 动态代理时已经稍微涉及过),Java 流,lambda 表达式,以及服务器端的脚本编写。

第八章:扩展我们的电子商务应用

在上一章中,我们开始开发一个电子商务应用,并创建了根据产品 ID 和一些参数查找产品的功能。在本章中,我们将扩展功能,以便我们还可以订购我们选择的产品。在这个过程中,我们将学习新技术,重点关注 Java 中的函数式编程以及一些其他语言特性,如运行时反射和注解处理,以及脚本接口。

正如我们在前面的章节中所做的那样,我们将逐步开发应用。当我们发现新学的技术时,我们将重构代码,以引入新的工具和方法,以产生更易读和有效的代码。我们还将模仿真实项目的开发,即在开始时,我们将有简单的需求,而后来,随着我们想象中的业务发展和销售更多产品,新的需求将被设定。我们将成为想象中的百万富翁。

我们将使用上一章的代码库,并将在此基础上进一步开发,尽管如此,我们将在一个新的项目中这样做。我们将使用 Spring、Gradle、Tomcat 和 soapUI,这些在上一章中我们已经熟悉了。在本章中,你将学习以下主题:

  • 注解处理

  • 使用反射

  • 使用以下方式在 Java 中进行函数式编程:

    • Lambda 表达式

    • 从 Java 调用脚本

MyBusiness 订购

订单过程比仅仅查找产品要复杂一些。订单表单本身列出了产品和数量,并确定了该订单的客户是谁。标识符提供了产品信息。我们只需要检查这些产品是否在我们的商店中有货,并且我们可以将它们交付给指定的客户。这是最简单的方法;然而,对于某些产品,存在更多的限制。例如,当有人订购桌面台灯时,我们会单独交付电源线。这是因为电源线是特定于国家的。我们向英国和德国交付不同的电源线。一种可能的方法是识别客户的国籍。但这种方法没有考虑到我们的客户是转售商。所有客户可能都位于英国,同时他们可能希望将台灯和电源线一起运往德国。为了避免这种情况和歧义,我们的客户最好将桌面台灯和电源线作为同一订单中的单独项目订购。在某些情况下,我们不附带电源线交付桌面台灯,但这是一种特殊情况。我们需要一些逻辑来识别这些特殊情况。因此,我们必须实现逻辑来查看是否存在桌面台灯的电源线,如果没有自动处理订单,则拒绝订单。这并不意味着我们不会交付产品。我们只会将订单放入队列,然后某个操作员需要查看它。

这种方法的缺点在于,桌面台灯只是需要配置支持的一个产品。我们拥有的产品越多,它们可能具有的专长就越多,检查订单一致性的代码变得越来越复杂,直到达到无法管理的复杂程度。当一个类或方法变得过于复杂时,程序员会对其进行重构,将方法或类拆分成更小的部分。我们必须对产品检查做同样的事情。我们不应该试图创建一个巨大的类来检查产品和所有可能的订单组合,而应该有许多较小的检查,以便每个检查只针对一个小集合。

在某些情况下,检查一致性更简单。检查灯具是否有电源线,其复杂度任何新手程序员都可以编写。我们在代码中使用这个例子,因为我们想关注代码的实际结构,而不是检查本身的复杂性质。然而,在现实生活中,检查可能相当复杂。想象一下一家销售电脑的商店。它组装一个配置:电源供应、显卡和主板,适当的 CPU 和内存。有许多选择,其中一些可能无法一起工作。在现实情况下,我们需要检查主板与所选内存兼容,主板有与订单中相同数量的存储器组,它们被适当配对(一些内存只能成对安装),有一个兼容的插槽用于显卡,以及电源有足够的瓦特数来可靠地运行整个配置。这是非常复杂的,最好不要与检查灯具是否有电源线的代码混淆。

设置项目

由于我们仍在使用 Spring Boot,构建文件不需要任何修改;我们将像使用上一章的文件一样使用它。然而,包结构略有不同。这次,我们要做比仅仅获取请求并响应后端服务提供的内容更复杂的事情。现在,我们必须实现复杂的企业逻辑,正如我们将看到的,这需要许多类。当我们在一个包中有 10 多个类时,是时候考虑将它们放入单独的包中。相互关联并具有相似功能的类应该放在一个包中。这样,我们将有一个包用于以下内容:

  • 控制器(尽管在这个例子中我们只有一个,但通常会有更多)

  • 只具有存储数据功能的数据存储 bean,因此,字段、setter 和 getter

  • 当订购台式灯时帮助我们检查电源线的检查器

  • 为控制器执行不同服务的服务

  • 包含Application类、SpringConfiguration和一些接口的我们程序的主要包

订单控制器和 DTOs

当服务器收到一个请求来订购一系列产品时,它以 HTTPS POST请求的形式到来。请求体以 JSON 编码。到目前为止,我们已经有处理GET参数的控制器,但当我们能够依赖 Spring 的数据绑定时,处理POST请求并不比这更困难。控制器代码本身很简单:

package packt.java9.by.example.mybusiness.bulkorder.controllers; 

import ... 

@RestController 
public class OrderController { 
    private Logger log = 
                LoggerFactory.getLogger(OrderController.class); 
    private final Checker checker; 

    public OrderController(@Autowired Checker checker) { 
        this.checker = checker; 
    } 

    @RequestMapping("/order") 
    public Confirmation getProductInformation(@RequestBody Order order) { 
        if (checker.isConsistent(order)) { 
            return Confirmation.accepted(order); 
        } else { 
            return Confirmation.refused(order); 
        } 
    } 
}

在这个控制器中,我们只处理一个请求:order。这映射到 URL /order。订单会自动从请求体中转换为 JSON 格式的订单对象。这就是 @RequestBody 注解要求 Spring 为我们做的事情。控制器的功能仅仅是检查订单的一致性。如果订单是一致的,那么我们接受订单;否则,我们拒绝它。现实生活中的例子也会检查订单不仅是一致的,而且来自有资格购买这些产品的客户,并且这些产品在仓库中有货,或者至少根据生产者的承诺和交货期可以交付。

要检查订单的一致性,我们需要一个为我们做这项工作的东西。正如我们所知,我们必须模块化代码,不要在单个类中实现太多东西,因此我们需要一个检查对象。这是根据类的注解和控制器构造函数的 @Autowired 自动提供的。

Order 类是一个简单的 JavaBean,仅列出项目:

package packt.java9.by.example.mybusiness.bulkorder.dtos; 
import ...; 
public class Order { 
    private String orderId; 
    private List<OrderItem> items; 
    private String customerId; 

... setters and getters ... 
}

包的名称是 dtos,代表 数据传输对象(DTO)的复数形式。DTOs 是用于在不同组件之间传输数据(通常通过网络)的对象。由于另一端可以用任何语言实现,序列化可以是 JSON、XML 或其他仅能传输数据的格式。这些类没有实际的方法。DTOs 通常只有字段、设置器和获取器。

以下是一个包含订单中一个项目的类:

package packt.java9.by.example.mybusiness.bulkorder.dtos; 

public class OrderItem { 
    private double amount; 
    private String unit; 
    private String productId; 

... setters and getters ... 
}

订单确认也在此包中,尽管这也是一个真正的 DTO,但它有一些简单的辅助方法:

package packt.java9.by.example.mybusiness.bulkorder.dtos; 

public class Confirmation { 
    private final Order order; 
    private final boolean accepted; 

    private Confirmation(Order order, boolean accepted) { 
        this.order = order; 
        this.accepted = accepted; 
    } 

    public static Confirmation accepted(Order order) { 
        return new Confirmation(order, true); 
    } 

    public static Confirmation refused(Order order) { 
        return new Confirmation(order, false); 
    } 

    public Order getOrder() { 
        return order; 
    } 

    public boolean isAccepted() { 
        return accepted; 
    } 
}

我们为该类提供了两个工厂方法。这有点违反了单一职责原则,这是纯粹主义者所厌恶的。大多数时候,当代码变得更加复杂,这样的捷径会反过来咬人,代码不得不重构以变得更加简洁。纯粹主义的解决方案是创建一个单独的工厂类。使用这个类或分离的类的工厂方法可以使控制器的代码更易于阅读。

我们的主要任务是进行一致性检查。到目前为止,代码几乎是微不足道的。

一致性检查器

我们有一个一致性检查器类,并且它的一个实例被注入到控制器中。这个类用于检查一致性,但它本身并不执行检查。它只控制我们提供的不同检查器,并依次调用它们来完成实际工作。

我们要求一致性检查器,例如当订购台灯时检查订单是否包含电源线的一致性检查器,实现 ConsistencyChecker 接口:

package packt.java9.by.example.mybusiness.bulkorder; 

import ... 
public interface ConsistencyChecker { 
    boolean isInconsistent(Order order); 
}

isInconsistent方法应该在订单不一致时返回true。如果它不知道订单是否不一致,则返回false,但从实际检查器检查订单的角度来看,没有不一致性。由于有多个ConsistencyChecker类,我们必须一个接一个地调用,直到其中一个返回true或者我们用完它们。如果没有一个返回true,那么至少从自动化检查器的角度来看,我们可以安全地假设订单是一致的。

在开发初期,我们就知道我们将会真正拥有很多一致性检查器,并不是所有检查器都对所有订单都相关。我们希望避免为每个订单调用每个检查器。为了做到这一点,我们实现了一些过滤。我们让产品指定它们需要哪种类型的检查。这是一些产品信息,比如大小或描述。为了适应这一点,我们需要扩展ProductInformation类。

我们将创建每个ConsistencyChecker接口,实现类作为 Spring bean(使用@Component注解标注),同时,我们将使用一个注解来指定它们实现哪种类型的检查。同时,ProductInformation被扩展,包含一组Annotation类对象,这些对象指定了要调用哪些检查器。我们本可以简单地列出检查器类而不是注解,但这给了我们在配置产品与注解之间的映射时一些额外的自由度。注解指定了产品的类型,检查器类被注解。台灯有PoweredDevice类型,检查器类NeedPowercord@PoweredDevice注解标注。如果有其他类型的需要电源线的产品,那么该类型的注解应该添加到NeedPowercord类中,我们的代码就能工作。由于我们开始深入研究注解和注解处理,我们首先必须了解注解到底是什么。自从第三章,优化排序,使代码专业以来,我们已经使用了注解,但我们只知道如何使用它们,而且如果不理解我们所做的,这通常是危险的。

注解

注解使用前面的@字符,可以附加到包、类、接口、字段、方法、方法参数、泛型类型声明和使用,以及最后,附加到注解上。注解几乎可以用于任何地方,它们用于描述一些程序元信息。例如,@RestController注解不会直接改变OrderController类的行为。类的行为由内部的 Java 代码描述。注解帮助 Spring 理解类是什么以及它应该如何以及如何使用。当 Spring 扫描所有包和类以发现不同的 Spring beans 时,它看到类上的注解并将其考虑在内。类上可能有 Spring 不理解的其它注解。它们可能被某些其他框架或程序代码使用。Spring 像任何良好行为的框架一样忽略它们。例如,正如我们稍后将要看到的,在我们的代码库中,有一个名为NeedPowercord的类,它是一个 Spring bean,因此被@Component注解标注。同时,它还被@PoweredDevice注解标注。Spring 对什么是带电设备一无所知。这是我们定义和使用的。Spring 忽略它。

包、类、接口、字段等都可以附加许多注解。这些注解应该简单地写在它们所附加的语法单元声明之前。

在包的情况下,注解必须写在package-info.java文件中的包名之前。此文件可以放置在包的目录中,可以用来编辑包的JavaDoc,也可以用来向包添加注解。此文件不能包含任何 Java 类,因为package-info名称不是一个有效的标识符。

我们不能随意在任意位置写任何内容作为注解。注解应该被声明。它们位于 Java 运行时的特殊接口中。例如,声明@PoweredDevice注解的 Java 文件看起来像这样:

package packt.java9.by.example.mybusiness.bulkorder.checkers; 

import java.lang.annotation.Retention; 
import java.lang.annotation.RetentionPolicy; 

@Retention(RetentionPolicy.RUNTIME) 
public @interface PoweredDevice { 
}

interface关键字前面的@字符表明这是一个特殊类型:注解类型。有一些特殊规则;例如,注解接口不应扩展任何其他接口,甚至不是注解接口。另一方面,编译器自动使注解接口扩展 JDK 接口java.lang.annotation.Annotation

注解位于源代码中,因此,它们在编译过程中是可用的。编译器还可以保留这些注解并将它们放入生成的类文件中,当类加载器加载类文件时,它们也可能在运行时可用。默认行为是编译器将注解与其注解的元素一起存储在类文件中,但类加载器不会在运行时保持它们可用。

要在编译过程中处理注解,必须使用注解处理器扩展 Java 编译器。这是一个相当高级的话题,在处理 Java 时你只能遇到少数几个例子。注解处理器是一个实现了特殊接口的 Java 类,当编译器处理注解处理器声明的源文件中的注解时,会调用它。

注解保留

Spring 和其他框架通常在运行时处理注解。编译器和类加载器必须被指示注解在运行时应该保持可用。为此,必须使用@Retention注解来注解注解接口本身。这个注解有一个RetentionPolicy类型的参数,它是一个枚举。我们很快就会讨论注解参数应该如何定义。

有趣的是要注意,注解接口上的@Retention注解必须在类文件中可用;否则,类加载器将不知道如何处理注解。我们如何通知编译器在编译过程结束后保留注解?我们注解注解接口声明。因此,@Retention的声明被自己注解,并声明为在运行时可用。

注解声明可以使用@Retention(RetentionPolicy.SOURCE)@Retention(RetentionPolicy.CLASS)@Retention(RetentionPolicy.RUNTIME)进行注解。

注解目标

最后的保留类型将是使用最频繁的。还有其他可以在注解声明上使用的注解。@Target注解可以用来限制注解的使用范围。这个注解的参数是一个java.lang.annotation.ElementType的值或这些值的数组。限制注解的使用有一个很好的理由。当我们将注解放置在错误的位置时,得到编译时错误要比在运行时寻找框架为什么忽略我们的注解要好得多。

注解参数

正如我们所见,注解可以有参数。为了在注解的@interface声明中声明这些参数,我们使用方法。这些方法有一个名称和返回值,但它们不应该有参数。你可以尝试声明一些参数,但 Java 编译器将会非常严格,不会编译你的代码。

这些值可以在使用注解的地方定义,使用方法名称和=字符,将一些与方法的类型兼容的值赋给它们。例如,假设我们修改注解PoweredDevice的声明如下:

public @interface ParameteredPoweredDevice { 
    String myParameter(); 
}

在这种情况下,在使用注解时,我们应该为参数指定一些值,例如以下内容:

@Component 
@ParameteredPoweredDevice(myParameter = "1966") 
public class NeedPowercord implements ConsistencyChecker { 
...

如果参数的名称是一个值,并且在注解的使用位置没有定义其他参数,那么名称“value”可以被省略。例如,当我们只有一个参数时,按照以下方式修改代码是一个方便的缩写:

public @interface ParameteredPoweredDevice{ 
    String value(); 
} 
... 
@Component 
@ParameteredPoweredDevice("1966") 
public class NeedPowercord implements ConsistencyChecker { 
...

我们也可以使用default关键字在方法声明后定义可选参数。在这种情况下,我们必须为参数定义一个默认值。进一步修改我们已有的示例注解,我们仍然可以,但不必指定值。在后一种情况下,它将是一个空字符串:

public @interface ParameteredPoweredDevice { 
    String value() default ""; 
}

由于我们指定的值应该在编译时是常量和可计算的,因此复杂类型并没有太大的用途。注解参数通常是字符串、整数,有时是双精度浮点数或其他原始类型。语言规范提供的类型列表如下:

  • 原始类型(doubleint等)

  • 字符串

  • 枚举

  • 另一个注解

  • 上述任何类型的数组

我们已经看到了String的例子,也看到了enum:RetentionTarget都有enum参数。我们想要关注的有意思的部分是上述列表的最后两项。

当参数的值是一个数组时,值可以在{}字符之间指定为逗号分隔的值。例如:

String[] value();

然后,我们可以将其添加到我们可以编写的@interface注解中:

@ParameteredPoweredDevice({"1966","1967","1991"})

然而,如果我们只想传递一个值作为参数值,我们仍然可以使用以下格式:

@ParameteredPoweredDevice("1966")

在这种情况下,属性的值将是一个长度为1的数组。当注解的值是一个注解类型的数组时,事情会变得稍微复杂一些。我们创建一个@interface注解(注意名字中的复数形式):

@Retention(RetentionPolicy.RUNTIME) 
public @interface PoweredDevices { 
ParameteredPoweredDevice[] value() default {}; 
}

这个注解的使用可以如下所示:

@PoweredDevices( 
        {@ParameteredPoweredDevice("1956"), @ParameteredPoweredDevice({"1968", "2018"})} 
)

注意,这并不等同于拥有三个参数的ParameteredPoweredDevice注解。这是一个有两个参数的注解。每个参数都是一个注解。第一个有一个字符串参数,第二个有两个。

如您所见,注解可以相当复杂,一些框架(或者更确切地说,创建它们的程序员)在使用它们时有些失控。在您开始编写框架之前,研究一下是否已经存在您可以使用的一个框架。同时,检查是否有其他方法可以解决您的问题。99%的注解处理代码可以避免,并变得简单。我们编写的代码越少,对于相同的功能就越满意。我们程序员是懒惰的类型,这就是必须这样做的原因。

最后一个例子,其中注解的参数是一个注解数组,理解我们如何创建可重复的注解是很重要的。

可重复注解

使用@Repeatable注解注解注解的声明,以表示该注解可以在一个地方多次应用。这个注解的参数是一个注解类型,它应该有一个类型为该注解数组的参数。不要试图理解!我将给出一个例子。实际上,我已经有了:我们有@PoweredDevices。它有一个参数是一个@ParameteredPoweredDevice的数组。考虑我们现在将这个@interface注解如下:

... 
@Repeatable(PoweredDevices.class) 
public @interface ParameteredPoweredDevice { 
...

然后,我们可以简化@ParameteredPoweredDevice的使用。我们可以多次重复注解,Java 运行时会自动将其封装在包装类中,在这种情况下是@PoweredDevices。在这种情况下,以下两个将是等效的:

... 
@ParameteredPoweredDevice("1956") 
@ParameteredPoweredDevice({"1968", "2018"}) 
public class NeedPowercord implements ConsistencyChecker { 
... 

@PoweredDevices( 
        {@ParameteredPoweredDevice("1956"), @ParameteredPoweredDevice({"1968", "2018"})} 
) 
public class NeedPowercord implements ConsistencyChecker { 
...

这种复杂方法的理由再次是 Java 严格遵循的后向兼容性的一个例子。注解是在 Java 1.5 中引入的,而可重复注解则只从版本 1.8 开始可用。我们很快就会谈到我们用来在运行时处理注解的反射 API。这个 API 在java.lang.reflect.AnnotatedElement接口中有一个getAnnotation(annotationClass)方法,它返回一个注解。如果一个注解可以在类、方法等上出现多次,那么就没有办法调用这个方法来获取所有不同参数的不同实例。通过引入包含类型来包装多个注解,确保了后向兼容性。

注解继承

注解,就像方法或字段一样,可以在类层次结构之间继承。如果一个注解声明被标记为@Inherited,那么扩展了具有此注解的另一个类的类可以继承它。如果子类有注解,则可以覆盖注解。因为 Java 中没有多重继承,所以接口上的注解不能继承。即使注解被继承,检索特定元素注解的应用程序代码也可以区分继承的注解和实体本身声明的注解。有方法可以获取注解,还有单独的方法可以获取实际元素上声明的注解,而不是继承的。

@Documented注解

@Documented注解表达了这样的意图:注解是实体契约的一部分,因此它必须包含在文档中。这是一个JavaDoc生成器在创建引用@Documented注解的元素的文档时查看的注解。

JDK 注解

除了用于定义注解的注解之外,JDK 中还定义了其他一些注解。我们已经看到了其中的一些。最常用的是@Override注解。当编译器看到这个注解时,它会检查该方法是否真正覆盖了某个继承的方法。如果没有这样做,将会导致错误,从而避免我们在运行时调试中的痛苦。

@Deprecated注解在方法、类或其他元素的文档中表示该元素不应使用。它仍然存在于代码中,因为一些用户可能仍然会使用它,但在依赖于包含该元素库的新开发中,新开发的代码不应使用它。该注解有两个参数。一个参数是since,它可以有一个字符串值,可以提供关于方法或类何时或从哪个版本开始被弃用的版本信息。另一个参数是forRemoval,如果该元素将不会出现在库的未来版本中,则应设置为true。某些方法可能因为存在更好的替代方案而被弃用,但开发者并不打算从库中删除该方法。在这种情况下,forRemoval可以设置为false

@SuppressWarning注解也是一个常用的注解,尽管其使用是有疑问的。它可以用来抑制编译器的一些警告。如果可能,建议编写没有警告的代码。

@FunctionalInterface注解声明一个接口只打算有一个方法。这样的接口可以作为 lambda 表达式实现。你将在本章后面学习关于 lambda 表达式的内容。当这个注解应用于一个接口,并且接口中声明了多个方法时,编译器将发出编译错误。这将防止任何开发者早期向一个打算与函数式编程和 lambda 表达式一起使用的接口中添加另一个方法。

使用反射

现在你已经学会了如何声明注解以及如何将它们附加到类和方法上,我们可以回到我们的ProductInformation类。回想一下,我们想要指定这个类中的产品类型,并且每种产品类型都由一个@interface注解表示。我们已经在之前的几页中列出了它,即我们将在@PoweredDevice示例中实现的一个。我们将假设将来会有许多这样的注解、产品类型和带有@Component以及一个或多个我们注解的一致性检查器。

获取注解

我们将使用以下字段扩展ProductInformation类:

private List<Class<? extends Annotation>> check;

由于这是一个 DTO,Spring 需要设置器和获取器,因此我们也将添加一个新的获取器和设置器。这个字段将包含每个类实现的我们的一些注解以及内置的 JDK 接口 Annotation,因为这就是 Java 编译器生成它们的方式。在这个阶段,这可能会有些模糊,但我保证随着我们的继续前进,曙光将破晓,光明将到来。

要获取产品信息,我们必须通过 ID 查找它。这是我们在上一章中开发的接口和服务,除了这次我们有一个新的字段。实际上,这是一个重大的区别,尽管 ProductLookup 接口完全没有变化。在上一章中,我们开发了两个版本。其中一个版本是从属性文件中读取数据,另一个版本是连接到 REST 服务。

属性文件很丑陋,是过时的技术,但如果你打算通过 Java 面试或在 21 世纪初开发的企业应用程序中工作,则是必须的。我不得不将其包含在最后一章中。这是我自己强烈希望在书中包含它的愿望。同时,在编写这一章的代码时,我没有勇气继续使用它。我还想向你展示相同的内容可以用 JSON 格式管理。

现在,我们将扩展 ResourceBasedProductLookup 的实现,以从 JSON 格式的资源文件中读取产品信息。在类中,大部分代码保持不变;因此,我们只列出这里的不同之处:

package packt.java9.by.example.mybusiness.bulkorder.services; 

import ... 

@Service 
public class ResourceBasedProductLookup implements ProductLookup { 
    private static final Logger log = LoggerFactory.getLogger(ResourceBasedProductLookup.class); 

    private ProductInformation fromJSON(InputStream jsonStream) 
                                              throws IOException { 
        ObjectMapper mapper = new ObjectMapper(); 
        return mapper.readValue(jsonStream, 
                                   ProductInformation.class); 
    } 

... 
    private void loadProducts() { 
        if (productsAreNotLoaded) { 
            try { 
                Resource[] resources =  
                     new PathMatchingResourcePatternResolver(). 
                        getResources("classpath:products/*.json"); 
                for (Resource resource : resources) { 
                    loadResource(resource); 
                } 
                productsAreNotLoaded = false; 
            } catch (IOException ex) { 
                log.error("Test resources can not be read", ex); 
            } 
        } 
    } 

    private void loadResource(Resource resource) 
                                       throws IOException { 
        final int dotPos = 
                      resource.getFilename().lastIndexOf('.'); 
        final String id = 
                      resource.getFilename().substring(0, dotPos); 
        final ProductInformation pi = 
                      fromJSON(resource.getInputStream()); 
        pi.setId(id); 
        products.put(id, pi); 
    } 
...

project resources/products 目录中,我们有一些 JSON 文件。其中之一包含台灯产品的信息:

{ 
  "id" : "124", 
  "title": "Desk Lamp", 
  "check": [ 
    "packt.java9.by.example.mybusiness.bulkorder.checkers.PoweredDevice" 
  ], 
  "description": "this is a lamp that stands on my desk", 
  "weight": "600", 
  "size": [ "300", "20", "2" ] 
}

产品类型在 JSON 数组中指定。在这个例子中,这个数组只有一个元素,而这个元素是表示产品类型的注解接口的完全限定名称。当 JSON 序列化器将 JSON 转换为 Java 对象时,它会识别出需要此信息的字段是一个 List,因此它将数组转换为列表,并将元素从 String 转换为表示注解接口的 Class 对象。

现在我们已经从 JSON 格式的资源中加载了资源,并看到了使用 Spring 读取 JSON 数据是多么容易,我们可以回到订单一致性检查。Checker 类实现了收集可插拔检查器并调用它们的逻辑。它还实现了基于注解的筛选,以便不调用我们实际上不需要用于实际产品实际订单的检查器:

package packt.java9.by.example.mybusiness.bulkorder.services; 

import ... 

@Component() 
@RequestScope 
public class Checker { 
    private static final Logger log = 
                        LoggerFactory.getLogger(Checker.class); 

    private final Collection<ConsistencyChecker> checkers; 
    private final ProductInformationCollector piCollector; 
    private final ProductsCheckerCollector pcCollector; 

    public Checker( 
              @Autowired Collection<ConsistencyChecker> checkers, 
              @Autowired ProductInformationCollector piCollector, 
              @Autowired ProductsCheckerCollector pcCollector) { 
        this.checkers = checkers; 
        this.piCollector = piCollector; 
        this.pcCollector = pcCollector; 
    } 

    public boolean isConsistent(Order order) { 
        Map<OrderItem, ProductInformation> map = 
                piCollector.collectProductInformation(order); 
        if (map == null) { 
            return false; 
        } 
        Set<Class<? extends Annotation>> annotations =  
                pcCollector.getProductAnnotations(order); 
        for (ConsistencyChecker checker :  
                checkers) { 
            for (Annotation annotation :  
                    checker.getClass().getAnnotations()) { 
                if (annotations.contains( 
                                 annotation.annotationType())) { 
                    if (checker.isInconsistent(order)) { 
                        return false; 
                    } 
                    break; 
                } 
            } 
        } 
        return true; 
    } 
}

有一个有趣的事情要提一下,那就是 Spring 的自动装配非常聪明。我们有一个Collection<ConsistencyChecker>类型的字段。通常,如果有一个恰好与要连接的资源类型相同的类,自动装配就会工作。在我们的情况下,我们没有这样的候选者,因为这是一个集合,但我们有多个ConsistencyChecker类。我们所有的检查器都实现了这个接口,Spring 能够识别它们,实例化它们,神奇地创建一个它们的集合,并将这个集合注入到这个字段中。

通常一个好的框架在逻辑上是合理的。我之前并不了解 Spring 的这个特性,但我想这应该是合理的,神奇的是,它真的工作了。如果事情是合理的并且顺利运行,你就不需要阅读和记住文档。然而,一点谨慎并不会有害。在我体验过这个功能是这样工作的之后,我在文档中查看了这个功能,发现这确实是 Spring 的一个保证特性,而不是仅仅偶然工作,可能在未来的版本中未经通知而改变。只使用保证的特性非常重要,但我们的行业中经常被忽视。

isConsistent方法被调用时,它首先将产品信息收集到HashMap中,为每个OrderItem分配一个ProductInformation实例。这是在一个单独的类中完成的。之后,ProductsCheckerCollector收集一个或多个产品项需要的ConsistencyChecker实例。当我们有了这个集合后,我们只需要调用那些带有这个集合中注解之一的检查器。我们在循环中这样做。

在此代码中,我们使用反射。我们遍历每个检查器拥有的注解。为了获取注解集合,我们调用checker.getClass().getAnnotations()。这个调用返回一个对象集合。每个对象都是实现我们自己在源文件中声明的注解接口的 JDK 运行时生成的类的实例。然而,没有保证动态创建的类只实现了我们的@interface而没有实现其他接口。因此,为了获取实际的注解类,我们必须调用annotationType方法。

ProductCheckerCollectorProductInformationCollector类非常简单,我们将在学习流时再讨论它们。它们将在那个地方作为一个很好的例子,当我们使用循环实现它们,然后立即使用流来实现。

在拥有它们之后,我们最终可以创建我们的实际检查器类。帮助我们看到我们的台灯订购了电源线的那个类如下:

package packt.java9.by.example.mybusiness.bulkorder.checkers; 

import ... 
@Component 
@PoweredDevice 
public class NeedPowercord implements ConsistencyChecker { 
    private static final Logger log = 
               LoggerFactory.getLogger(NeedPowercord.class); 

    @Override 
    public boolean isInconsistent(Order order) { 
        log.info("checking order {}", order); 
        CheckHelper helper = new CheckHelper(order); 
        return !helper.containsOneOf("126", "127", "128"); 
    } 
}

辅助类包含许多检查器需要的简单方法,例如:

public boolean containsOneOf(String... ids) { 
    for (final OrderItem item : order.getItems()) { 
        for (final String id : ids) { 
            if (item.getProductId().equals(id)) { 
                return true; 
            } 
        } 
    } 
    return false; 
}

调用方法

在这个例子中,我们只使用了一个单独的反射调用来获取附加到类上的注解。反射可以做更多的事情。处理注解是这些调用最重要的用途,因为注解没有自己的功能,并且在运行时无法以任何其他方式处理。然而,反射并没有停止告诉我们一个类或任何其他可注解元素有哪些注解。反射可以用来获取类的列表、方法名称作为字符串、类的实现接口、它扩展的父类、字段、字段类型等等。反射通常提供方法和类来遍历实际的代码结构,直到方法级别,以编程方式。

这种遍历不仅允许读取类型和代码结构,而且还使得在编译时不知道方法名称的情况下设置字段值和调用方法成为可能。我们甚至可以设置那些是private且通常无法由外界访问的字段。还值得注意的是,通过反射访问方法和字段通常比通过编译代码慢,因为它总是涉及到在代码中通过元素名称进行查找。

经验法则表明,如果你发现必须使用反射来创建代码,那么意识到你可能正在创建一个框架(或者正在编写一本关于 Java 的书籍,详细介绍了反射)。这听起来熟悉吗?

Spring 也使用反射来发现类、方法和字段,以及注入对象。它使用 URL 类加载器列出类路径上所有的 JAR 文件和目录,加载它们,并检查类。

为了举例说明,为了演示目的,让我们假设ConsistencyChecker的实现是由许多外部软件供应商编写的,而最初设计程序结构的架构师恰好忘记在接口中包含isConsistent方法。(同时,为了保护我们的心理健康,我们也可以想象这个人因为这样做而不再在公司工作。)结果,不同的供应商提供了“实现”这个接口的 Java 类,但我们无法调用该方法,不仅因为我们没有具有此方法的公共父接口,而且因为供应商恰好使用了不同的方法名称。

在这种情况下我们能做什么呢?从业务角度来看,要求所有供应商重写他们的检查器是不被允许的,因为他们知道我们遇到麻烦,这给任务贴上了高昂的标签。我们的经理们希望避免这种成本,我们开发者也希望表明我们可以修复这种情况并创造奇迹。(稍后,我会对此发表评论。)

我们可以创建一个类,它知道每个校验器以及如何以多种不同的方式调用它们。这要求我们每次向系统中引入新的校验器时都要维护这个类,而我们希望避免这样做。我们最初发明整个插件架构正是为了这个目的。

我们如何调用一个对象上的方法,我们知道这个对象只有一个声明的方法,它接受一个订单作为参数?这就是反射发挥作用的地方。我们不是调用checker.isInconsistent(order),而是实现一个小的private方法isInconsistent,它通过反射调用该方法,无论其名称是什么:

private boolean isInconsistent(ConsistencyChecker checker, Order order) { 
    Method[] methods = checker.getClass().getDeclaredMethods(); 
    if (methods.length != 1) { 
        log.error( 
                "The checker {} has zero or more than one methods", 
                checker.getClass()); 
        return false; 
    } 
    final Method method = methods[0]; 
    final boolean inconsistent; 
    try { 
        inconsistent = (boolean) method.invoke(checker, order); 
    } catch (InvocationTargetException | 
            IllegalAccessException | 
            ClassCastException e) { 
        log.error("Calling the method {} on class {} threw exception", 
                method, checker.getClass()); 
        log.error("The exception is ", e); 
        return false; 
    } 
    return inconsistent; 
}

我们可以通过调用getClass方法来获取对象的类,对于代表类的对象本身,我们可以调用getDeclaredMethods。幸运的是,校验器类并没有被许多方法所充斥,因此我们检查校验器类中只声明了一个方法。请注意,反射库中还有一个getMethods方法,但它总是会返回多个方法。它返回声明和继承的方法。因为每个类都继承自java.lang.Object,至少Object类的那些方法都会存在。

在此之后,我们尝试使用代表反射类中方法的Method对象来调用类。请注意,这个Method对象并不是直接附加到一个实例上的。我们是从类中检索到这个方法的,因此当我们调用它时,我们应该将作为第一个参数传递给它应该工作的对象。这样,x.y(z)就变成了method.invoke(x,z)invoke方法的最后一个参数是一个可变数量的参数,它们作为Object数组传递。在大多数情况下,当我们调用一个方法时,即使我们不知道方法的名称,也必须使用反射,我们也会知道我们的代码中的参数。当即使参数也不为人所知但可以通过计算获得时,我们必须将它们作为Object数组传递。

通过反射调用方法是一个风险很大的调用。如果我们尝试以正常的方式调用一个方法,比如private,编译器将会报错。如果参数的数量或类型不合适,编译器会再次给我们报错。如果返回值不是boolean,或者根本没有返回值,那么我们再次得到编译器错误。在反射的情况下,编译器是无知的。它不知道代码执行时将调用哪个方法。另一方面,invoke方法在调用时可以并会注意到所有这些失败。如果上述任何问题发生,我们将会得到异常。如果invoke方法本身看到它无法执行我们所要求的事情,那么它将抛出InvocationTargetExceptionIllegalAccessException。如果从实际返回值到boolean的转换不可能,那么我们将得到ClassCastException

关于做魔术,这是一种自然的冲动,我们想要创造出非凡的、杰出的事物。当我们进行实验或从事爱好工作时,这是可以接受的。另一方面,当我们从事专业工作时,这却绝对是不妥的。那些不理解你聪明解决方案的普通程序员,会在企业环境中维护代码。他们会将你精心整理的代码变成一堆乱麻,在修复一些错误或实现一些小的新功能时。即使你是编程界的莫扎特,他们充其量也只是不知名的歌手。在企业环境中,一段精彩的代码可能就像一首挽歌,带有所有这个比喻的含义。

最后但同样重要的是,一个令人悲伤的现实是,我们通常不是编程界的莫扎特。

注意,如果原始值的返回值是原始类型,那么它将通过反射转换为对象,然后我们将其转换回原始值。如果没有返回值,换句话说,如果它是void,那么反射将返回一个java.lang.Void对象。Void对象只是一个占位符。我们不能将其转换为任何原始值或任何其他类型的对象。这是必需的,因为 Java 是严格的,invoke必须返回一个Object,所以运行时需要一些可以返回的东西。我们所能做的就是检查返回值的类是否确实是Void

让我们继续故事情节和我们的解决方案。我们提交了代码,它在生产环境中运行了一段时间,直到软件供应商的新更新将其破坏。我们在测试环境中调试代码,看到现在这个类包含多个方法。我们的文档明确指出,它们应该只有一个public方法,他们提供了一个有...嗯...我们意识到其他方法都是private的。他们是正确的;根据合同,他们可以有private方法,所以我们必须修改代码。我们替换了查找唯一方法的行:

Method[] methods = checker.getClass().getDeclaredMethods(); 
if (methods.length != 1) { 
... 
} 
final Method method = methods[0];

新的代码如下:

final Method method = getSingleDeclaredPublicMethod(checker); 
if (method == null) { 
    log.error( 
            "The checker {} has zero or more than one methods", 
            checker.getClass()); 
    return false; 

}

我们编写的新方法来查找唯一的public方法如下:

private Method getSingleDeclaredPublicMethod( 
                           ConsistencyChecker checker) { 
    final Method[] methods = 
        checker.getClass().getDeclaredMethods(); 
    Method singleMethod = null; 
    for (Method method : methods) { 
        if (Modifier.isPublic(method.getModifiers())) { 
            if (singleMethod != null) { 
                return null; 
            } 
            singleMethod = method; 
        } 
    } 
    return singleMethod; 
}

要检查方法是否是public的,我们使用Modifier类的一个static方法。有方法可以检查所有可能的修饰符。getModifiers方法返回的值是一个int位字段。不同的位有不同的修饰符,有一些常量定义了这些。这种简化导致了不一致性,你可以检查一个方法是否是接口或易失性的,实际上这是荒谬的。事实是,只能用于其他类型反射对象的位永远不会被设置。

有一个例外,就是volatile。这个位被重新用于表示桥接方法。桥接方法是由编译器自动创建的,可能存在深奥和复杂的问题,我们在这本书中不讨论。由于重用了相同的位,所以不会引起混淆,因为字段可以是volatile的,但作为一个字段,它不能是桥接方法。显然,字段就是字段,而不是方法。同样,方法不能是volatile字段。一般规则是:不要在反射对象上使用没有意义的方法;否则,了解你在做什么。

为了使故事线更加复杂,一个检查器的版本意外地将检查方法实现为包private。程序员只是忘记使用public关键字。为了简化,让我们再次假设类只声明了一个方法,但这个方法不是公开的。我们如何使用反射来解决这个问题?

显然,最简单的解决方案是要求供应商修复问题:这是他们的责任。然而,在某些情况下,我们必须在某个问题上创建一个解决方案。另一个解决方案是创建一个具有相同包中public方法的类,从其他类中调用包private方法,从而传递其他类。实际上,这个解决方案,作为此类错误的解决方案,似乎更加合理和干净,但这次,我们想使用反射。

为了避免java.lang.IllegalAccessException,我们必须将method对象设置为可访问。为此,我们必须在调用之前插入以下行:

method.setAccessible(true);

注意,这不会将方法改为public。它只会使方法对于通过我们设置为可访问的method对象实例的可访问。

我见过一些代码通过调用isAccessible方法来检查一个方法是否可访问,并将此信息保存下来;如果方法原本不可访问,则将其设置为可访问,并在调用后恢复原始的可访问性。这完全是多余的。一旦method变量超出作用域,并且没有引用我们设置可访问性标志的对象,设置的效果就会消失。此外,设置public或可调用的方法的可访问性没有惩罚。

设置字段

我们也可以在Field对象上调用setAccessible,然后我们可以甚至使用反射设置私有字段的值。不进一步编造故事,只是为了举例,让我们创建一个名为ConsistencyCheckerSettableChecker

@Component 
@PoweredDevice 
public class SettableChecker implements ConsistencyChecker { 
    private static final Logger log = LoggerFactory.getLogger(SettableChecker.class); 

    private boolean setValue = false; 

    public boolean isInconsistent(Order order) { 
        return setValue; 
    } 
}

这个检查器将返回false,除非我们使用反射将字段设置为true。我们确实这样做了。我们在Checker类中创建了一个方法,并在检查过程中的每个检查器中调用它:

private void setValueInChecker(ConsistencyChecker checker) { 
    Field[] fields = checker.getClass().getDeclaredFields(); 
    for( final Field field : fields ){ 
        if( field.getName().equals("setValue") && 
            field.getType().equals(boolean.class)){ 
            field.setAccessible(true); 
            try { 
                log.info("Setting field to true"); 
                field.set(checker,true); 
            } catch (IllegalAccessException e) { 
                log.error("SNAFU",e); 
            } 
        } 
    } 
}

该方法遍历所有声明的字段,如果名称是setValue且类型是boolean,则将其设置为true。这将本质上使所有包含有源设备的订单被拒绝。

注意,尽管boolean是一个内置的语言原语,它绝对不是一个类,但它仍然有一个类,以便反射可以比较字段的类型与boolean人为具有的类。现在boolean.class是语言中的一个类字面量,对于每个原语,都可以使用类似的常量。编译器将这些识别为类字面量,并在字节码中创建适当的伪类引用,以便原语也可以以这种方式进行检查,正如在setValueInChecker方法的示例代码中所展示的。

我们检查了字段是否具有适当的类型,并且我们还对该字段调用了setAccessible方法。尽管编译器不知道我们确实已经做了所有事情来避免IllegalAccessException,但它仍然认为在field上调用set可能会抛出这样的异常,因为它被声明了。然而,我们知道这种情况不应该发生。(这是程序员的著名最后遗言吗?)为了处理这种情况,我们在方法调用周围包围了一个try块,并在catch分支中记录了异常。

Java 中的函数式编程

由于我们在本章的示例中创建了大量的代码,我们将查看 Java 的函数式编程功能,这将帮助我们从我们的代码中删除许多行。我们拥有的代码越少,维护应用程序就越容易;因此,程序员喜欢函数式编程。但这并不是函数式编程如此受欢迎的唯一原因。它也是以更可读和更不易出错的方式描述某些算法的绝佳方式,比传统的循环更好。

函数式编程不是新事物。它在 20 世纪 30 年代为其开发了数学背景。第一个(如果不是第一个)函数式编程语言是 LISP。它在 20 世纪 50 年代开发,至今仍在使用,以至于有该语言在 JVM 上的一个版本(Clojure)。

简而言之,函数式编程意味着我们用函数来表示程序结构。在这个意义上,我们应该将函数视为数学中的函数,而不是像 C 等编程语言中使用的术语。在 Java 中,我们有方法,当我们遵循函数式编程范式时,我们创建并使用像数学函数一样行为的方法。一个方法是函数式的,如果无论我们调用多少次它都给出相同的结果,就像sin(0)总是零一样。函数式编程避免了改变对象的状态,因为状态没有改变,结果总是相同的。这也简化了调试。

如果一个函数对于给定的参数已经返回了一个特定的值,它将始终返回相同的值。我们也可以将代码读作是对计算的声明,而不是依次执行的命令。如果执行顺序不重要,那么代码的可读性也可能提高。

Java 通过 lambda 表达式和流操作帮助实现函数式编程风格。请注意,这些流不是 I/O 流,实际上与那些流没有真正的关联。

我们将首先简要了解 lambda 表达式和流操作是什么,然后,我们将将程序的一些部分转换为使用这些编程结构。我们还将看到这些代码的可读性提高了多少。

可读性是一个有争议的话题。一段代码可能对一个开发者来说是可读的,而对另一个开发者来说可能不太可读。这很大程度上取决于他们习惯了什么。我多次经历过开发者被流操作分散注意力。当开发者第一次遇到流操作时,他们思考流的方式以及流的外观都是陌生的。但这就像刚开始学习骑自行车一样。当你还在学习如何使用它并且摔倒的次数比滚动次数多时,它肯定比走路慢。另一方面,一旦你学会了如何骑自行车...

Lambda

我们已经在第三章,优化排序 - 使代码专业化中使用了 lambda 表达式,当时我们编写了异常抛出测试。在那段代码中,我们将比较器设置为在每次调用时抛出RuntimeException的特殊值:

sort.setComparator((String a, String b) -> { 
        throw new RuntimeException(); 
    });

参数类型是Comparator;因此,我们必须要设置的是一个实现了java.util.Comparator接口的类的实例。该接口只定义了一个实现必须定义的方法:compare。因此,我们可以将其定义为 lambda 表达式。如果没有 lambda,如果我们需要一个实例,我们必须输入很多。我们必须创建一个类,给它命名,在其中声明compare方法,并编写方法的主体,如下面的代码段所示:

public class ExceptionThrowingComparator implements Comparator { 
  public int compare(T o1, T o2){ 
    throw new RuntimeException(); 
  } 
}

在使用位置,我们应该实例化类并将其作为参数传递:

sort.setComparator(new ExceptionThrowingComparator());

如果我们将类定义为匿名类,我们可以节省一些字符,但开销仍然存在。我们真正需要的是必须定义的那个单一方法的主体。这就是 lambda 出现的地方。

我们可以在任何需要定义只有一个方法的类的实例的地方使用 lambda 表达式。从Object继承的方法不算,我们也不关心接口中定义的作为default方法的方法。它们就在那里。Lambda 定义了尚未定义的那个。换句话说,lambda 以比匿名类更少的开销清晰地描述了值是一个作为参数传递的功能。

lambda 表达式的简单形式如下:

parameters -> body

参数可以放在括号内,也可以不放在括号内。同样,主体可以放在 {} 字符之间,也可以是一个简单的表达式。这样,lambda 表达式可以将开销降到最低,只在真正需要的地方使用括号。

lambda 表达式还有一个极其有用的特性,即如果我们使用的表达式上下文已经很明显,我们不需要指定参数的类型。因此,前面的代码段甚至可以更短,如下所示:

sort.setComparator((a, b) -> { 
    throw new RuntimeException(); 
});

参数 ab 将具有所需的类型。为了使其更加简单,如果只有一个参数,我们甚至可以省略参数周围的 () 字符。

如果有多个参数,括号不是可选的。这是为了避免在某些情况下产生歧义。例如,方法调用 f(x,y->x+y) 可能是一个有两个参数的方法:x 和一个只有一个参数的 lambda 表达式 y。同时,它也可能是一个有两个参数的 lambda 表达式的方法调用,参数分别是 xy

当我们想要将功能作为参数传递时,lambda 表达式非常方便。在方法声明的地方声明参数类型应该是函数式接口类型。这些接口可以选择性地使用 @FunctionalInterface 进行注解。Java 运行时在 java.util.function 包中定义了许多这样的接口。我们将在下一节讨论其中的一些,以及它们在流中的使用。其余的,可以通过 Oracle 的标准 Java 文档获取。

流(Streams)

流(Streams)也是 Java 8 的新特性,就像 lambda 表达式一样。它们之间有着非常紧密的协作,因此它们同时出现并不令人惊讶。lambda 表达式和流都支持函数式编程风格。

首先要明确的是,流与输入输出流没有任何关系,除了名称。它们是完全不同的东西。流更像是集合,但有一些显著的不同。(如果没有差异,它们就只是集合。)流本质上是一系列可以顺序或并行运行的运算管道。它们从集合或其他来源获取数据,包括即时生成的数据。

流支持对多个数据执行相同的计算。这种结构被称为单指令多数据SIMD)。不要害怕这个表达式。这实际上是一个非常简单的事情。我们在这本书中已经多次这样做过了。循环也是一种 SIMD 结构。当我们遍历检查类以查看是否有任何反对顺序的检查时,我们对每个检查执行相同的指令。多个检查器就是多个数据。

循环的一个问题是,我们在不需要时定义了执行顺序。在棋盘游戏的情况下,我们并不关心棋子执行的顺序。我们只关心所有棋子都按照顺序执行。我们仍然在编写循环时指定了某些顺序。这是循环的本质,我们无法改变这一点。这就是它们的工作方式。然而,如果我们能够以某种方式简单地表示“对每个棋子执行这个和那个”,那将是非常好的。这是流出现的一个地方。

另一点是,使用循环的代码更偏向于命令式而不是描述性。当我们阅读循环结构的程序时,我们关注的是各个步骤。我们首先看到循环中的命令做了什么。这些命令作用于数据中的单个元素,而不是整个集合或数组。

在我们的大脑中将各个步骤组合起来后,我们意识到整体图景是什么,循环的目的何在。在流的情况下,操作的描述是一个更高的层次。一旦我们学会了流方法,阅读它们就更容易了。流方法作用于整个流而不是单个元素,因此更具描述性。

java.lang.Stream 是一个接口。实现此接口的类型对象代表许多对象,并提供可以用于对这些对象执行指令的方法。当我们对其中一个对象开始操作时,这些对象可能可用,也可能不可用,或者可能仅在需要时创建。这取决于 Stream 接口的实际实现。例如,假设我们使用以下代码生成包含 int 值的流:

IntStream.iterate( 0, (s) -> s+1 )

在前面的代码片段中,由于流包含无限数量的元素,因此无法生成所有元素。此示例将返回数字 0、1、2 等等,直到进一步的非列表流操作终止计算。

当我们编程 Stream 时,我们通常从一个 Collection 创建一个流——不是总是这样,但很多时候。Java 8 中扩展了 Collection 接口以提供 streamparallelStream 方法。这两个方法都返回代表集合元素的流对象。当存在自然顺序时,stream 返回与集合中相同的顺序的元素,而 parallelStream 创建一个可以并行处理的流。在这种情况下,如果我们使用的某些流方法是以这种方式实现的,代码就可以利用计算机中可用的多个处理器。

一旦我们有了流,我们就可以使用 Stream 接口定义的方法。首先开始的是 forEach 方法。此方法有一个参数,通常作为 lambda 表达式提供,并将对流的每个元素执行 lambda 表达式。

Checker类中,我们有isConsistent方法。在这个方法中,有一个遍历 checker 类注解的循环。如果我们想记录循环中注解实现的接口,我们可以添加以下内容:

for (ConsistencyChecker checker :checkers) { 
  for (Annotation annotation : 
checker.getClass().getAnnotations()) { 
Arrays.stream(annotation.getClass().getInterfaces()) 
.forEach( 
t ->log.info("annotation implemented interfaces {}",t) 
); 
...

在这个例子中,我们使用Arrays类的工厂方法从一个数组创建一个流。该数组包含由反射方法getInterfaces返回的接口。lambda 表达式只有一个参数;因此,我们不需要在它周围使用括号。表达式的主体是一个返回无值的调用;因此,我们也省略了{}字符。

为什么会有这么多麻烦?有什么好处?为什么我们不能只写一个简单的循环来记录数组的元素?

获益在于可读性和可维护性。当我们创建一个程序时,我们必须关注程序应该做什么,而不是它应该如何做。在一个理想的世界里,规范应该只是可执行的。我们可能在将来实现这一点,那时编程工作将由人工智能取代。(当然不是程序员。)我们还没有达到那里。我们必须告诉计算机如何完成我们想要实现的事情。我们过去不得不在 PDP-11 的控制台上输入二进制代码,以便将机器代码部署到内存中执行。后来,我们有了汇编器;再后来,我们有了 FORTRAN 和其他高级编程语言,它们取代了 40 年前的大部分编程工作。所有这些编程发展都使方向从“怎么做”转向了“做什么”。今天,我们使用 Java 9 编程,这条路还有很长的路要走。

我们能更多地表达“做什么”而不是“怎么做”,我们的程序就会越短、越容易理解。它将包含本质,而不是一些机器为了仅仅完成我们想要的事情而需要的人工垃圾。

当我看到我需要维护的代码中的一个循环时,我假设循环执行的顺序很重要。可能根本不重要。几秒钟后可能就显而易见了。可能需要几分钟甚至更长时间才能意识到顺序并不重要。这种时间是浪费的,可以通过更好的表达“做什么”的编程结构来节省,而不是“怎么做”。

函数式接口

该方法的参数应该是java.util.function.Consumer。这是一个需要定义accept方法的接口,该方法返回void。lambda 表达式或实现此接口的类将“消费”accept方法的参数,不产生任何内容。

在那个包中定义了几个其他接口,每个接口都作为功能接口使用,用于描述可以作为实际参数给出的 lambda 表达式的方法参数。

例如,Consumer的对立面是Supplier。这个接口有一个名为get的方法,它不需要任何参数,但会返回一些Object作为返回值。

如果有一个参数和一个返回值,那么这个接口就被称为Function。如果返回值必须与参数具有相同的类型,那么UnaryOperator接口就是我们的朋友。同样,还有一个BinaryOperator接口,它返回与参数相同类型的对象。正如我们从FunctionUnaryOperator所看到的那样,在另一个方向上,如果参数和返回值不共享类型,也存在BiFunction

这些接口并不是相互独立定义的。如果一个方法需要Function,而我们手头有UnaryOperator可以传递,那么这不应该是个问题。UnaryOperator实际上就是具有相同类型参数的Function。一个可以与Function一起工作,接受一个对象并返回一个对象的方法,如果它们具有相同的类型,那么应该不会有问题。这些可以是,但不必是,不同的。

为了让这种情况发生,UnaryOperator接口扩展了Function,因此可以在Function的位置使用。

我们在这个类中遇到的接口是使用泛型定义的。因为泛型类型不能是原始类型,所以操作原始值的接口应该单独定义。例如,Predicate是一个定义booleantest(T t)的接口。它是一个返回boolean值的函数,在流方法中被多次使用。

还有一些接口,例如BooleanSupplierDoubleConsumerDoubleToIntFunction等,它们与原始的booleandoubleint类型一起工作。不同参数类型和返回值的可能组合数量是无限的……几乎如此。

有趣的事实:非常精确地说,它并不是无限的。一个方法最多可以有 254 个参数。这个限制是在 JVM 中指定的,而不是在 Java 语言规范中。当然,没有一个是无用的。有 8 种原始类型(加上Object,加上可能少于 254 个参数的可能性),这意味着可能的函数式接口总数是 10²⁵⁴,上下几个数量级。实际上,是无限的!

我们不应该期望在这个包中定义所有可能的接口。这里只包含那些最有用的接口。例如,没有使用shortchar的接口。如果我们需要类似的东西,我们可以在我们的代码中定义这个interface。或者,仔细思考并找出如何使用已经定义好的一个。 (在我的职业生涯中,我从未使用过short类型。它从未被需要过。)

这些功能接口在流中是如何使用的?Stream接口定义了具有某些功能接口类型作为参数的方法。例如,allMatch方法有一个Predicate参数,并返回一个Boolean值,如果流中的所有元素都匹配Predicate,则返回true。换句话说,此方法仅在Predicate作为参数返回true对于流中的每个元素时才返回true

在下面的代码中,我们将使用流重写我们在示例代码中使用循环实现的一些方法,并通过这些示例,我们将讨论流提供的重要方法。我们保存了两个类,ProductsCheckerCollectorProductInformationCollector,以展示流的使用。我们可以从这里开始。ProductsCheckerCollector遍历Order中包含的所有产品,并收集产品中列出的注释。每个产品可能包含零个、一个或多个注释。这些都在列表中。相同的注释可能被引用多次。为了避免重复,我们使用HashSet,即使产品中有多个实例,它也只包含元素的一个实例:

public class ProductsCheckerCollector { 

    private final ProductInformationCollector pic; 
    public ProductsCheckerCollector(@Autowired 
      ProductInformationCollector pic) { this.pic = pic; } 

    public Set<Class<? extends Annotation>> 
                       getProductAnnotations(Order order) { 
        Map<OrderItem, ProductInformation> piMap = 
                          pic.collectProductInformation(order); 
        final Set<Class<? extends Annotation>> 
                            annotations = new HashSet<>(); 
        for (OrderItem item : order.getItems()) { 
            final ProductInformation pi = piMap.get(item); 
            if (pi != null && pi.getCheck() != null) { 
                for (Class<? extends Annotation> check : 
                                              pi.getCheck()) { 
                    annotations.addAll(pi.getCheck()); 
                } 
        } 
        return annotations; 
    } 
}

现在,让我们看看当我们使用流重新编写此方法时,它看起来如何:

public Set<Class<? extends Annotation>> 
                getProductAnnotations(Order order) { 
    Map<OrderItem, ProductInformation> piMap = 
                      pic.collectProductInformation(order); 

    return order.getItems().stream() 
            .map(piMap::get) 
            .filter(Objects::nonNull) 
            .peek(pi -> { 
                if (pi.getCheck() == null) { 
                    log.info("Product {} has no annotation", 
                                                  pi.getId()); 
                } 
            }) 
            .filter(pi -> pi.getCheck() != null) 
            .peek(pi -> log.info("Product {} is annotated with class {}", pi.getId(), pi.getCheck())) 
            .flatMap(pi -> pi.getCheck().stream()) 
            .collect(Collectors.toSet()); 
}

该方法的主要工作被包含在一个单一、尽管庞大的流表达式中。我们将在接下来的几页中介绍表达式的元素。order.getItems返回的List通过调用stream方法进行转换:

returnorder.getItems().stream()

正如我们之前简要提到的,stream方法是Collection接口的一部分。任何实现Collection接口的类都将拥有此方法,即使是在 Java 8 引入流之前实现的类也是如此。这是因为stream方法在接口中作为default方法实现。这样,如果我们偶然实现了一个实现此接口的类,即使我们不需要流,我们也会免费获得它作为额外的功能。

Java 8 中的default方法是为了支持接口的后向兼容性。JDK 的一些接口需要修改以支持 lambda 和函数式编程。一个例子是stream方法。在 Java 8 之前的特性集中,实现一些修改后接口的类应该被修改。它们将需要实现新方法。这种更改不是向后兼容的,Java 作为一种语言和 JDK 非常关注向后兼容性。因此,引入了default方法。这些方法允许开发者扩展接口并保持向后兼容性,为新的方法提供默认实现。

与此相反,Java 8 JDK 中的全新函数式接口也有default方法,尽管它们在 JDK 中没有先前的版本,因此没有兼容性问题。在 Java 9 中,接口也得到了扩展,现在它们不仅可以包含defaultstatic方法,还可以包含private方法。这样,接口就变成了类似于抽象类的东西,尽管接口中没有字段,除了常量static字段。这种接口功能开放是一个备受批评的特性,它仅仅提出了其他允许多重类继承的语言所面临的编程风格和结构问题。Java 一直避免这种情况,直到 Java 8 和 Java 9。

从这个例子中我们能学到什么?要小心使用default方法和接口中的private方法。如果确实需要使用,请明智地使用它们。

这个流中的元素是OrderItem对象。我们需要为每个OrderItem提供ProductInformation

方法引用

幸运的是,我们拥有Map,它将订单项与产品信息配对,因此我们可以对Map调用get方法:

.map(piMap::get)

map方法在 Java 中与另一个具有相同名称的元素相关联,不应混淆。虽然Map类是一个数据结构,但Stream接口中的map方法执行流元素的映射。该方法的一个参数是Function(回想一下,这是一个我们最近讨论过的函数式接口)。这个函数将一个值T转换为另一个值R,这个值作为原始流(Stream<T>)的元素可用,map方法的返回值是Stream<R>map方法使用给定的Function<T,R>Stream<T>转换为Stream<R>,为原始流的每个元素调用它,并从转换后的元素创建一个新的流。

我们可以说,Map接口以静态方式在数据结构中将键映射到值,而Stream方法map则动态地将一种类型的值映射到另一种(或相同的)类型的值。

我们已经看到,我们可以以 lambda 表达式的形式提供一个函数式接口的实例。这个参数不是 lambda 表达式。这是一个方法引用。它表示map方法应该使用实际的流元素作为参数,在Map piMap上调用get方法。我们很幸运,get也需要一个参数,不是吗?我们也可以这样写:

.map( orderItem ->piMap.get(orderItem))

然而,这将与piMap::get完全相同。

这样,我们可以引用一个在特定实例上工作的实例方法。在我们的例子中,这个实例是由piMap变量引用的。也可以引用static方法。在这种情况下,类名应写在::字符之前。当我们使用Objects类的static方法nonNull时,我们将很快看到这个例子(注意,类名是复数形式,它位于java.util包中,而不是java.lang包)。

也有可能引用一个实例方法,而不给出它应该调用的引用。这可以在功能接口方法有一个额外的第一个参数的地方使用,该参数将用作实例。我们已经在第三章,“优化排序 - 使代码专业化”中使用了这种方法,当时我们传递了String::compareTo,当期望的参数是一个Comparator时。compareTo方法期望一个参数,但Comparator接口中的compare方法需要两个参数。在这种情况下,第一个参数将用作compare必须调用的实例,第二个参数传递给compare。在这种情况下,String::compareTo与编写 lambda 表达式(String a, String b) -> a.compareTo(b)相同。

最后但同样重要的是,我们可以使用构造函数引用。当我们需要一个(让我们简单一点)ObjectSupplier时,我们可以写Object::new

下一步是从流中过滤掉null元素。注意,在这个阶段,流中包含ProductInformation元素:

.filter(Objects::nonNull)

filter方法使用Predicate并创建一个只包含匹配谓词的元素的流。在这种情况下,我们使用了static方法的引用。filter方法不会改变流的类型。它只过滤掉元素。

我们接下来应用的方法有点反函数式。纯函数式流方法不会改变任何对象的任何状态。它们创建并返回新的对象,但除此之外,没有副作用。peek本身也没有不同,因为它只返回一个与它应用在上的相同元素的流。然而,这个“无操作”特性诱使新手程序员做一些非函数式的事情,并编写带有副作用的代码。毕竟,如果没有(副作用)在调用它时,为什么还要使用它呢?

.peek(pi -> { 
    if (pi.getCheck() == null) { 
        log.info("Product {} has no annotation", pi.getId()); 
    } 
})

虽然peek方法本身没有副作用,但 lambda 表达式的执行可能会有。然而,这同样适用于任何其他方法。只是在这种情况下,做一些不恰当的事情更有诱惑力。不要这样做。我们是守纪律的成年人。正如方法名所暗示的,我们可能可以窥视流,但我们不应该做其他任何事情。在这种情况下,编程是一种特定的活动,窥视是恰当的。这正是我们在代码中实际做的事情:我们记录了一些信息。

然后,我们去除没有ProductInformation的元素;我们还想去除那些有,但没有定义检查器的元素:

.filter(pi ->pi.getCheck() != null)

在这种情况下,我们不能使用方法引用。相反,我们使用 lambda 表达式。作为另一种解决方案,我们可以在ProductInformation中创建一个boolean hasCheck方法,当private字段检查不是null时返回true。这将如下所示:

.filter(ProductInformation::hasCheck)

这完全有效且可行,尽管该类没有实现任何函数式接口并且有很多方法,不仅仅是这个方法。然而,方法引用是明确的,并指定了要调用的方法。

在这个第二个过滤器之后,我们再次记录元素:

.peek(pi -> log.info( 
     "Product {} is annotated with class {}", pi.getId(), 
                                            pi.getCheck()))

下一个方法是flatMap,这是一个特别且不易理解的东西。至少对我来说,它比学习函数式编程时的mapfilter要困难一些:

.flatMap(pi ->pi.getCheck().stream())

此方法期望传入的 lambda 表达式、方法引用或其他作为参数传递的内容,为原始流中每个元素创建一个新的对象流。然而,结果并不是流中的流,尽管这也是可能的,而是返回的流被连接成一个巨大的流。

如果我们应用此流的流是整数流,例如 1,2,3,...,并且每个数字n的函数返回一个包含三个元素nn+1n+2的流,那么flatMap产生的流将包含 1,2,3,2,3,4,3,4,5,4,5,6,等等。

最后,我们应该将流收集到一个Set中。这是通过调用collector方法来完成的:

.collect(Collectors.toSet());

collector方法的参数是(再次是名称滥用)Collector。它可以用来收集流中的元素到某个集合中。请注意,Collector不是一个函数式接口。您不能仅仅使用 lambda 表达式或简单的方法来收集某些内容。为了收集元素,我们确实需要一个地方来收集元素,因为随着从流中来的新元素,元素会被收集。Collector接口并不简单。幸运的是,java.util.streams.Collectors类(再次注意复数形式)有很多static方法,这些方法创建并返回创建并返回Collector对象的Object

其中之一是toSet,它返回一个Collector,帮助将流中的元素收集到一个Set中。当所有元素都到达时,collect方法将返回Set。还有其他方法可以帮助通过求和元素、计算平均值或将元素收集到ListCollectionMap中。将元素收集到Map中是一件事,因为Map的每个元素实际上是一个键值对。当我们查看ProductInformationCollector时,我们将看到这个示例。

ProductInformationCollector类代码中包含collectProductInformation方法,我们将从Checker类以及ProductsCheckerCollector类中使用该方法:

private Map<OrderItem, ProductInformation> map = null; 

public Map<OrderItem, ProductInformation>  
                  collectProductInformation(Order order) { 
    if (map == null) { 
        map = new HashMap<>(); 
        for (OrderItem item : order.getItems()) { 
            final ProductInformation pi = 
                     lookup.byId(item.getProductId()); 
            if (!pi.isValid()) { 
                map = null; 
                return null; 
            } 
            map.put(item, pi); 
        } 
    } 
    return map; 
}

简单的技巧是将收集的值存储在Map中,如果该值不是null,则只需返回已计算出的值,这可能在处理相同的 HTTP 请求时多次调用此方法时节省大量的服务调用。

有两种方式来编码这样的结构。一种是通过检查Map的非空性,如果Map已经存在则返回。这种模式被广泛使用,并且有一个名字。这被称为保护if。在这种情况下,方法中有多于一个的返回语句,这可能会被视为一种弱点或反模式。另一方面,方法的缩进应该比之前浅一个制表符。

这纯粹是个人喜好问题。如果你发现自己正处于关于一个或另一个解决方案的辩论中,就请自己行个方便,让你的同伴在这件事上获胜,并为你节省精力,用于更重要的问题,例如,你是否应该使用流还是仅仅使用普通的循环。

现在,让我们看看如何将这个解决方案转换为函数式风格:

public Map<OrderItem, ProductInformation> collectProductInformation(Order order) { 
    if (map == null) { 
        map = 
        order.getItems() 
                .stream() 
                .map(item -> tuple(item, item.getProductId())) 
                .map(t -> tuple(t.r, lookup.byId((String) t.s))) 
                .filter(t -> ((ProductInformation)t.s).isValid()) 
                .collect( 
                    Collectors.toMap( t -> (OrderItem)t.r, 
                                      t -> (ProductInformation)t.s 
                                    ) 
                ); 
        if (map.keySet().size() != order.getItems().size()) { 
            log.error("Some of the products in the order do not have product information, {} != {} ",map.keySet().size(),order.getItems().size()); 
            map = null; 
        } 
    } 
    return map; 
}

我们使用一个辅助类Tuple,它只是两个名为rsObject实例。我们稍后会列出这个类的代码。它非常简单。

在流表达式,我们首先从集合中创建流,然后将OrderItem元素映射到OrderItemproductId元组的流。然后我们将这些元组映射到现在包含OrderItemProductInformation的元组。这两个映射可以在一个映射调用中完成,这将只执行这两个步骤。我决定创建两个,希望这样可以使每行的步骤更简单,从而使得生成的代码更容易理解。

过滤步骤也并非什么新鲜事物。它只是过滤掉无效的产品信息元素。实际上,不应该有任何。如果订单包含一个指向不存在产品的订单 ID,就会发生这种情况。在下一个语句中,我们会检查收集到的产品信息元素的数量,以确保所有项目都有适当的信息。

有趣的代码是如何将流元素收集到一个Map中。为了做到这一点,我们再次使用collect方法和Collectors类。这次,toMap方法创建了一个Collector。这需要两个Function结果表达式。第一个应该将流元素转换为键,第二个应该产生用于Map中的值。因为键和值的实际类型是从传递的 lambda 表达式的结果计算出来的,所以我们必须显式地将元组的字段转换为所需类型。

最后,简单的Tuple类如下:

public class Tuple<R, S> { 
    final public R r; 
    final public S s; 

    private Tuple(R r, S s) { 
        this.r = r; 
        this.s = s; 
    } 

    public static <R, S> Tuple tuple(R r, S s) { 
        return new Tuple<>(r, s); 
    } 
}

在我们的代码中,还有一些类值得转换为函数式风格。这些是CheckerCheckerHelper类。

Checker类中,我们可以重写isConsistent方法:

public boolean isConsistent(Order order) { 
    Map<OrderItem, ProductInformation> map = 
                  piCollector.collectProductInformation(order); 
    if (map == null) { return false; } 
    final Set<Class<? extends Annotation>> annotations = 
                       pcCollector.getProductAnnotations(order); 
    return !checkers.stream().anyMatch( 
                 checker -> Arrays.stream( 
                              checker.getClass().getAnnotations() 
                            ).filter( 
                              annotation -> 
                                annotations.contains( 
                                      annotation.annotationType()) 
                            ).anyMatch( 
                              x ->  
                                checker.isInconsistent(order) 
                            )); 
}

由于你已经学会了大多数重要的流方法,这里几乎没有什么新问题。我们可以提到anyMatch方法,如果至少有一个元素使得传递给anyMatchPredicate参数为true,则它将返回true。它可能还需要一些调整,以便我们可以在另一个流中使用流。这很可能是一个流表达式过于复杂,需要使用局部变量将其拆分成更小部分的例子。

最后,在我们离开函数式风格之前,我们重新编写了CheckHelper类中的containsOneOf方法。这个方法没有引入新元素,将帮助你检查关于mapfilterflatMapCollector所学的知识。请注意,正如我们讨论的那样,如果order包含至少一个作为字符串给出的订单 ID,则此方法返回true

public boolean containsOneOf(String... ids) { 
    return order.getItems().stream() 
            .map(OrderItem::getProductId) 
            .flatMap(itemId -> Arrays.stream(ids) 
                    .map(id -> tuple(itemId, id))) 
            .filter(t -> Objects.equals(t.s, t.r)) 
            .collect(Collectors.counting()) > 0; 
}

我们创建了OrderItem对象的流,并将其映射到包含在该流中的产品 ID 的流。然后,我们为每个 ID 创建另一个流,包含 ID 的元素和作为参数给出的一个字符串 ID。接着,我们将这些子流展平成一个流。这个流将包含order.getItems().size()乘以ids.length个元素:所有可能的配对。我们将过滤掉包含相同 ID 两次的配对,最后,我们将计算流中元素的数量。

Java 9 中的脚本

我们几乎完成了本章的示例程序。不过有一个问题,尽管它不是专业的。当我们有一个需要新检查器的新产品时,我们必须创建代码的新版本。

在专业环境中,程序有发布。当代码被修改、错误被修复或实现了一个新功能时,组织在应用程序可以投入生产之前需要执行许多步骤。这些步骤构成了发布过程。一些环境有轻量级的发布流程;而另一些则需要严格且昂贵的检查。这并不是因为组织内部人员的口味。当非工作生产代码的成本很低,程序出现故障或功能错误并不重要时,发布流程可以很简单。这样,发布可以更快、更便宜地完成。例如,一些用户用于娱乐的聊天程序。在这种情况下,发布新功能可能比确保无错误运行更重要。在另一端,如果你创建的代码控制着核电站,失败的成本可能相当高。即使是微小的更改,也要进行严肃的测试和仔细检查所有功能,这可能会带来回报。

在我们的例子中,简单的检查器可能是一个不太可能引起严重错误的问题领域。虽然并非不可能,但代码如此简单……是的,我知道这样的论点有点牵强,但让我们假设这些小例程可以通过比代码的其他部分更少的测试和更简单的方式进行更改。那么,如何将这些小脚本的代码分离出来,以便它们不需要技术发布、应用程序的新版本,甚至不需要重新启动应用程序?我们有一个新产品需要新的检查,我们希望有一种方法可以将这个检查注入到应用程序环境中,而不会造成任何服务中断。

我们选择的解决方案是脚本。Java 程序可以执行用JavaScriptGroovyJython(这是Python语言的JVM版本)和许多其他语言编写的脚本。除了JavaScript之外,这些语言的解释器不是 JDK 的一部分,但它们都提供了一个标准接口,该接口在 JDK 中定义。结果是,我们可以在我们的代码中实现脚本执行,提供脚本的开发者可以自由选择任何可用的语言;我们不需要关心执行JavaScript代码。我们将使用与执行GroovyJython相同的 API。我们唯一需要知道的是脚本使用的语言。这通常很简单:我们可以从文件扩展名中猜测,如果猜测不够,我们可以要求脚本开发者将JavaScript放入以.js扩展名命名的文件中,Jython放入以.jy.py扩展名命名的文件中,Groovy放入以.groovy扩展名命名的文件中,依此类推。同样重要的是要注意,如果我们想让我们的程序执行这些语言之一,我们应该确保解释器在类路径上。在JavaScript的情况下,这是既定的;因此,在本章的演示中,我们将用JavaScript编写我们的脚本。不会有太多;毕竟,这是一本 Java 书,而不是JavaScript书。

脚本通常是我们想要通过编程方式配置或扩展应用程序功能时的一个不错的选择。这正是我们现在的情形。

我们必须做的第一件事是扩展生产信息。如果有脚本检查产品中订单的一致性,我们需要一个字段来指定脚本的名称:

    private String checkScript; 
    public String getCheckScript() { 
        return checkScript; 
    } 
    public void setCheckScript(String checkScript) { 
        this.checkScript = checkScript; 
    }

我们不希望每个产品指定多个脚本;因此,我们不需要脚本名称列表。我们只指定了一个名为的脚本。

坦白说,检查器类和注释的数据结构太复杂了,允许每个产品以及每个检查器类有多个注释。我们虽然无法避免这一点,但需要足够复杂的结构来展示流表达式的强大功能和能力。现在我们已经过了这个主题,我们可以继续使用更简单的数据结构,专注于脚本执行。

我们还必须修改Checker类,使其不仅使用检查器类,还使用脚本。我们不能丢弃检查器类,因为当我们意识到我们最好需要脚本时,我们已经有大量的检查器类,我们没有资金将它们重写为脚本。是的,我们是在书中,而不是现实生活中,但在企业中,情况就是这样。这就是为什么在设计企业解决方案时你应该非常小心。结构和解决方案将长期存在,而且很难仅仅因为技术上不是最好的就丢弃一段代码。如果它有效并且已经存在,业务将非常不愿意在代码维护和重构上花钱。

摘要:我们修改了Checker类。我们需要一个新的类来执行我们的脚本;因此,构造函数被修改:

private final CheckerScriptExecutor executor; 

    public Checker( 
        @Autowired Collection<ConsistencyChecker> checkers, 
        @Autowired ProductInformationCollector piCollector, 
        @Autowired ProductsCheckerCollector pcCollector, 
        @Autowired CheckerScriptExecutor executor ) { 
        this.checkers = checkers; 
        this.piCollector = piCollector; 
        this.pcCollector = pcCollector; 
        this.executor = executor; 
    }

我们还必须在isConsistent方法中使用这个executor

public boolean isConsistent(Order order) { 
        final Map<OrderItem, ProductInformation> map = 
                piCollector.collectProductInformation(order); 
        if (map == null) { 
            return false; 
        } 
        final Set<Class<? extends Annotation>> annotations = 
                pcCollector.getProductAnnotations(order); 
        Predicate<Annotation> annotationIsNeeded = annotation -> 
                annotations.contains(annotation.annotationType()); 
        Predicate<ConsistencyChecker> productIsConsistent = 
                checker -> 
                Arrays.stream(checker.getClass().getAnnotations()) 
                        .parallel().unordered() 
                        .filter(annotationIsNeeded) 
                        .anyMatch( 
                             x -> checker.isInconsistent(order)); 
        final boolean checkersSayConsistent = !checkers.stream(). 
                anyMatch(productIsConsistent); 
        final boolean scriptsSayConsistent = 
                !map.values(). 
                        parallelStream(). 
                        map(ProductInformation::getCheckScript). 
                        filter(Objects::nonNull). 
                        anyMatch(s -> 
                           executor.notConsistent(s,order)); 
        return checkersSayConsistent && scriptsSayConsistent; 
    }

注意,在这段代码中,我们使用并行流,因为为什么不呢?只要可能,我们都可以使用并行流,即使是无序的,以告知底层系统以及维护代码的程序员同行,顺序并不重要。

我们还修改了我们产品的一个 JSON 文件,通过一些注释来引用脚本而不是检查器类:

{ 
  "id" : "124", 
  "title": "Desk Lamp", 
  "checkScript" : "powered_device", 
  "description": "this is a lamp that stands on my desk", 
  "weight": "600", 
  "size": [ "300", "20", "2" ] 
}

即使是 JSON 也更为简单。请注意,由于我们决定使用 JavaScript,在命名脚本时我们不需要指定文件名扩展名。

我们可能稍后考虑进一步的开发,届时我们将允许产品检查脚本维护者使用不同的脚本语言。在这种情况下,我们可能仍然要求他们指定扩展名,如果没有扩展名,我们的程序将自动添加为.js。在我们的当前解决方案中,我们没有检查这一点,但我们可能会花几秒钟时间思考,以确保解决方案可以进一步开发。重要的是,我们不要为了进一步开发而开发额外的代码。开发者不是算命先生,无法可靠地预测未来的需求。这是业务人员的任务。

我们将脚本放入我们项目的scripts目录下的resource目录中。文件名必须是powered_device.js,因为这是我们已在 JSON 文件中指定的名称:

function isInconsistent(order){ 
    isConsistent = false 
    items = order.getItems() 
    for( i in items ){ 
    item = items[i] 
    print( item ) 
        if( item.getProductId() == "126" || 
            item.getProductId() == "127" || 
            item.getProductId() == "128"  ){ 
            isConsistent = true 
            } 
    } 
    return ! isConsistent 
}

这是一个极其简单的 JavaScript 程序。作为旁注,当你使用 JavaScript 遍历列表或数组时,循环变量将遍历集合或数组的索引。由于我很少在 JavaScript 中编程,我陷入了这个陷阱,调试我犯的错误花了我超过半小时的时间。

我们已经准备好了调用脚本所需的一切。我们仍然需要调用它。为此,我们使用 JDK 脚本 API。首先,我们需要一个ScriptEngineManager。这个管理器用于获取访问 JavaScript 引擎的权限。尽管 JavaScript 解释器自 Java 7 以来一直是 JDK 的一部分,但它仍然以抽象的方式管理。它是 Java 程序可以使用的许多可能的解释器之一,用于执行脚本。它恰好存在于 JDK 中,所以我们不需要将解释器 JAR 添加到类路径中。ScriptEngineManager发现类路径上的所有解释器并将它们注册。

它使用服务提供者规范来实现这一点,这个规范已经很长时间是 JDK 的一部分,到了 Java 9,它在模块处理方面也得到了额外的支持。这要求脚本解释器实现ScriptEngineFactory接口,并在META-INF/services/javax.script.ScriptEngineFactory文件中列出实现它的类。这些文件,从所有构成类路径的 JAR 文件中,都被ScriptEngineManager作为资源读取,通过这种方式,它知道哪些类实现了脚本解释器。ScriptEngineFactory接口要求解释器提供诸如getNamesgetExtensionsgetMimeTypes等方法。管理器调用这些方法来收集关于解释器的信息。当我们询问 JavaScript 解释器时,管理器将返回由工厂创建的,并声称其名称之一是JavaScript的解释器。

通过名称、文件名扩展名或 MIME 类型来访问解释器只是ScriptEngineManager的功能之一。另一个功能是管理Bindings

当我们从 Java 代码内部执行脚本时,我们并不是因为我们想增加我们的多巴胺水平。在脚本的情况下,这种情况不会发生。我们想要一些结果。我们想要传递参数,并在脚本执行后,我们想要从脚本中获取我们可以用于 Java 代码的值。这可以通过两种方式发生。一种是通过将参数传递给脚本中实现的方法或函数,并从脚本中获取返回值。这通常有效,但甚至可能发生某些脚本语言甚至没有函数或方法的观念。在这种情况下,这不是一个可能性。可能的是,将一些环境传递给脚本,并在脚本执行后从环境中读取值。这个环境由Bindings表示。

Bindings是一个具有String键和Object值的映射。

在大多数脚本语言的情况下,例如在 JavaScript 中,Bindings与我们在执行的脚本中的全局变量连接。换句话说,如果我们在我们 Java 程序中在调用脚本之前执行以下命令,那么 JavaScript 全局变量globalVariable将引用myObject对象:

myBindings.put("globalVariable",myObject)

我们可以创建绑定并将其传递给ScriptEngineManager,同样我们也可以使用它自动创建的那个,并且我们可以直接在引擎对象上调用put方法。

当我们执行脚本时,有两种绑定。一种是设置在ScriptEngineManager级别上的。这被称为全局绑定。还有一个由ScriptEngine本身管理的。这是局部绑定。从脚本的角度来看,没有区别。从嵌入的角度来看,有一些区别。如果我们使用同一个ScriptEngineManager来创建多个ScriptEngine实例,那么全局绑定将由它们共享。如果一个获取了值,所有其他实例都将看到相同的值;如果一个设置了值,所有其他实例稍后都将看到这个改变后的值。局部绑定特定于它所管理的引擎。由于我们在这本书中只介绍了 Java 脚本 API,所以我们不会深入探讨,并且我们不会使用绑定。我们只需要调用 JavaScript 函数并从中获取结果即可。

实现脚本调用的类是CheckerScriptExecutor

package packt.java9.by.example.mybusiness.bulkorder.services; 

import ... 

@Component 
public class CheckerScriptExecutor { 
    private static final Logger log = ... 

    private final ScriptEngineManager manager = 
                             new ScriptEngineManager(); 

    public boolean notConsistent(String script, Order order) { 

        try { 
            final Reader scriptReader = getScriptReader(script); 
            final Object result =  
                         evalScript(script, order, scriptReader); 
            assertResultIsBoolean(script, result); 
            log.info("Script {} was executed and returned {}", 
                                                 script, result); 
            return (boolean) result; 

        } catch (Exception wasAlreadyHandled) { 
            return true; 
        } 
    }

唯一的public方法notConsistent获取要执行的脚本的名称以及order。后者必须传递给脚本。首先它获取Reader,可以读取脚本文本,评估它,并在结果是boolean或至少可以转换为boolean的情况下最终返回结果。如果我们在这个类中实现并调用的任何方法出现错误,它将抛出异常,但只有在适当记录之后才会这样做。在这种情况下,安全的方式是拒绝订单。

实际上,这是业务应该决定的事情。如果有一个无法执行的检查脚本,这显然是一个错误的情况。在这种情况下,接受订单并在之后手动处理问题是有一定成本的。由于某些内部错误而拒绝订单或确认也不是订单流程中的愉快路径。我们必须检查哪种方法对公司的损害最小。这当然不是程序员的职责。在我们的情况下,我们处于一个简单的情况。

我们假设业务代表说在这种情况下应该拒绝订单。在现实生活中,类似的决策很多时候是由业务代表拒绝的,他们说这根本不应该发生,IT 部门必须确保程序和整个操作完全无错误。这种反应有一个心理原因,但这确实让我们离 Java 编程非常远。

引擎可以通过Reader或作为String执行传递给它的脚本。因为现在我们的脚本代码在资源文件中,似乎让引擎读取资源而不是将其读取到String中是一个更好的主意:


        private Reader getScriptReader(String script) 
                                throws IOException { 
        final Reader scriptReader; 
        try { 
            final InputStream scriptIS = new ClassPathResource( 
                    "scripts/" + script + ".js").getInputStream(); 
            scriptReader = new InputStreamReader(scriptIS); 
        } catch (IOException ioe) { 
            log.error("The script {} is not readable", script); 
            log.error("Script opening exception", ioe); 
            throw ioe; 
        } 
        return scriptReader; 
    }

要从资源文件中读取脚本,我们使用 Spring 的ClassPathResource类。脚本的名称在scripts目录前缀,以.js扩展名后缀。其余部分相当标准,我们在这本书中已经见过。下一个评估脚本的方程序更有趣:

        private Object evalScript(String script, 
                              Order order, 
                              Reader scriptReader)  
            throws ScriptException, NoSuchMethodException { 
        final Object result; 
        final ScriptEngine engine = 
                          manager.getEngineByName("JavaScript"); 
        try { 
            engine.eval(scriptReader); 
            Invocable inv = (Invocable) engine; 
            result = inv.invokeFunction("isInconsistent", order); 
        } catch (ScriptException | NoSuchMethodException se) { 
            log.error("The script {} thruw up", script); 
            log.error("Script executing exception", se); 
            throw se; 
        } 
        return result; 
    }

要执行脚本中的方法,首先我们需要一个能够处理JavaScript的脚本引擎。我们通过名称从管理器获取引擎。如果不是JavaScript,我们应该检查返回的engine不是null。在JavaScript的情况下,解释器是JDK的一部分,检查JDK是否符合标准可能会有些过度谨慎。

如果我们想要扩展这个类来处理不仅JavaScript,还要处理其他类型的脚本,这个检查必须进行,并且脚本引擎可能需要通过文件名扩展从管理器请求,而我们在这个private方法中无法访问这个扩展。但这将是未来的开发,而不是这本书的内容。

当我们拥有引擎时,我们必须评估脚本。这将定义脚本中的函数,以便我们之后可以调用它。为了调用它,我们需要一个Invocable对象。在JavaScript的情况下,引擎也实现了Invocable接口。并非所有脚本引擎都实现了这个接口。有些脚本没有函数或方法,其中没有可以调用的内容。同样,这也是我们稍后要做的事情,当我们想要允许不仅JavaScript脚本,还要允许其他类型的脚本时。

要调用函数,我们将它的名称传递给invokeFunction方法,并传递我们想要传递的参数。在这种情况下,这是order。在JavaScript的情况下,两种语言之间的集成相当成熟。正如我们的示例所示,我们可以访问作为参数传递的 Java 对象的字段和方法,并且返回的 JavaScript truefalse值也会神奇地转换为Boolean。尽管有些情况下访问并不那么简单:


private void assertResultIsBoolean(String script, 
                                       Object result) { 
        if (!(result instanceof Boolean)) { 
            log.error("The script {} returned non boolean", 
                                                    script); 
            if (result == null) { 
                log.error("returned value is null"); 
            } else { 
                log.error("returned type is {}", 
                                 result.getClass()); 
            } 
            throw new IllegalArgumentException(); 
        } 
    } 
}

类的最后一个方法检查返回的值,由于这是一个脚本引擎,它可以是一切,确保它可以转换为boolean

需要注意的是,虽然一些功能是用脚本实现的,但这并不能保证应用程序能够无缝运行。可能会有多个问题,脚本可能会影响整个应用程序的内部工作。一些脚本引擎提供了特殊的方法来保护应用程序免受恶意脚本的影响,而其他则没有。我们不对脚本进行传递,而是进行命令,但这并不能保证脚本不能访问其他对象。使用反射、static方法和其他技术,我们可以在 Java 程序内部访问任何东西。当我们的代码库中只有脚本发生变化时,我们可能会在测试周期中更容易一些,但这并不意味着我们应该盲目地信任任何脚本。

在我们的例子中,让产品的制作者上传脚本到我们的系统可能是一个非常糟糕的主意。他们可能会提供他们的检查脚本,但这些脚本在部署到系统中之前必须从安全角度进行审查。如果这样做得当,那么脚本就是 Java 生态系统的一个极其强大的扩展,为我们的程序提供了极大的灵活性。

摘要

在本章中,我们开发了企业应用程序的排序系统。随着代码的开发,我们遇到了许多新事物。你学习了关于注解以及它们如何通过反射来处理的知识。尽管它们之间没有很强的关联,但你学习了如何使用 lambda 表达式和流来表示比传统循环更简单的编程结构。在章节的最后部分,我们通过从 Java 调用 JavaScript 函数以及从 JavaScript 调用 Java 方法,使用脚本扩展了应用程序。

事实上,凭借所有这些知识,我们成熟到了企业编程所需的 Java 水平。书中涵盖的其他主题则是为高手准备的。但你想成为其中的一员,不是吗?这就是我写下其余章节的原因。继续阅读吧!

第九章:使用响应式编程构建会计应用程序

在本章中,我们将开发一个示例程序,该程序负责我们为创建订单处理代码的公司所做的库存管理部分。不要期待一个完全开发、可直接使用的专业应用程序,也不要期待我们会深入探讨会计和簿记的细节。这不是我们的目标。我们将更多地关注我们感兴趣的编程技术——响应式编程。抱歉,我知道会计和簿记很有趣,但这不是那个话题。

响应式编程是一种古老的(嗯,在计算机科学中什么是古老的呢?)方法,最近才被引入 Java。Java 9 是第一个支持标准 JDK 中一些响应式编程方面的版本。简单来说,响应式编程是关于更多地关注数据流如何流动,而不是如何处理数据流。你可能还记得,这也是从“如何做”的描述转向“我们想做什么”的一个步骤。

在阅读完本章后,你将了解响应式编程是什么,以及 Java 中有哪些工具可以利用。你还将了解响应式编程的优点,以及何时以及如何在未来利用这一原则,因为越来越多的框架将支持 Java 中的响应式编程。在本章中,你将学习以下主题:

  • 响应式编程概述

  • Java 中的响应式流

  • 如何以响应式方式实现我们的示例代码

响应式...是什么?

有响应式编程、响应式系统和响应式流。这三者之间是相互关联的三个不同的事物。三者都被称为“响应式”并非没有原因。

响应式编程是一种与面向对象编程和函数式编程类似的编程范式。响应式系统是一种系统设计,它对某种类型的信息系统应该如何设计以实现响应性设定了某些目标和技术限制。在这其中有很多与响应式编程原则相似之处。响应式流是一组接口定义,有助于实现与响应式系统相似的编码优势,并且可以用来创建响应式系统。响应式流接口是 JDK 9 的一部分,不仅限于 Java,也适用于其他语言。

我们将在单独的章节中探讨这些内容,在每个章节的结尾,你可能会对为什么每个都被称为响应式有更好的理解。

响应式编程概述

反应式编程是一种范式,它更关注计算过程中数据流向,而不是如何计算结果。如果问题最好描述为几个相互依赖的计算,但其中一些可能独立于其他计算执行,那么反应式编程可能就会派上用场。作为一个简单的例子,我们可以有以下计算,它从一些给定的bcef值计算出h的值,使用f1f2f3f4f5作为简单的计算步骤:

a = f1(b,c) 
d = f2(e,f) 
k = f3(e,c) 
g = f4(b,f,k) 
h = f5(d,a,g)

如果我们以传统的方式用 Java 编写这些代码,方法f1f5将依次被调用。如果我们有多个处理器并且能够并行化执行,我们可能会并行执行一些方法。当然,这假设这些方法是纯粹的计算方法,不会改变环境的状态,并且可以独立于彼此执行。例如,f1f2f3可以独立执行。函数f4的执行依赖于f3的输出,而函数f5的执行依赖于f1f2f4的输出。

如果我们有两个处理器,我们可以同时执行f1f2,然后执行f3,接着是f4,最后是f5。这共有四个步骤。如果我们把前面的计算不是看作命令,而是看作表达式以及计算如何相互依赖,那么我们并不指定实际的执行顺序,环境可能会决定同时计算f1f3,然后是f2f4,最后是f5,这样可以节省一个步骤。这样,我们可以专注于数据流,让反应式环境对其产生影响,而不需要额外的约束。

图片

这是一个非常简单的反应式编程方法。以表达式形式描述的计算描述了数据流,但在解释中,我们仍然假设计算是同步执行的。如果计算在不同的处理器上执行,这些处理器连接到网络上不同的机器,那么计算可能不需要也不必是同步的。如果环境是异步的,反应式程序可以异步执行。可能发生的情况是,不同的计算,从f1f4,在不同的机器上实现和部署。在这种情况下,计算出的值通过网络从一个发送到另一个,节点在输入发生变化时执行计算。这与使用简单构建块和模拟信号进行计算的好老式模拟计算机非常相似。

该程序被实现为一个电子电路,当输入电压或电流(通常是电压)在输入端发生变化时,模拟电路以光速跟随变化,结果出现在输出端。在这种情况下,信号传播受限于电线上的光速和在有线模块中的模拟电路速度,这非常快,可能比数字计算机还要快。

当我们谈论数字计算机时,信号的传播是数字的,因此需要从一个计算节点发送到另一个节点,无论是 JVM 中的某个对象还是网络上的某个程序。如果节点需要执行其计算,则:

  • 输入中的一些值已经发生了变化

  • 需要计算的结果

如果输入没有变化,那么结果最终应该与上次相同;因此,不需要再次执行计算——这将是一种资源浪费。如果计算的结果不需要,那么即使结果与上次不同,也不需要执行计算。没有人关心。

为了适应这一点,反应式环境实施了两种传播值的方法。节点可以从其他模块的输出中拉取值。这将确保不会执行不必要的计算。模块可以将它们的输出推送到依赖于它们的下一个模块。这种方法将确保只有更改的值才会引发计算。一些环境可能会实施混合解决方案。

当系统中的值发生变化时,这种变化会传播到其他节点,这些节点再次将变化传播到另一个节点,依此类推。如果我们把计算依赖看作一个有向图,那么变化会沿着连接的节点传播到更改值的传递闭包。数据可能携带所有值从一个节点的输出到另一个节点的输入,或者只携带变化。第二种方法更复杂,因为它需要更改的数据以及描述更改了什么的元信息。另一方面,当输出和输入数据集很大,而只有一小部分发生变化时,这种收益可能是显著的。在有很大可能性某些节点对于许多不同的输入不会更改输出时,计算和传播实际的变化差分可能也很重要。在这种情况下,即使输入值发生了变化,变化传播可能会在没有任何实际变化的节点停止。这可以在某些网络中节省大量的计算。

在数据传播的配置中,有向无环图可以通过程序的代码来表示,它可以被配置,甚至可以在代码执行过程中动态地设置和更改。当程序代码包含图的架构时,路由和依赖关系相对静态。要更改数据传播,必须更改程序代码,重新编译并部署。在多个网络节点程序的情况下,可能甚至需要多次部署,这些部署应该仔细安排以避免不同节点上运行不同不兼容版本。当图以某些配置描述时,也应考虑类似的问题。在这种情况下,如果只是更改图的连接,可能不需要重新编译程序,但在网络执行的情况下,确保不同节点上配置兼容的负担仍然存在。

允许图动态更改也不能解决这个问题。设置和结构更加灵活,同时,也更加复杂。沿着图边传播的数据可能不仅包含计算数据,还包含驱动图变化的数据。很多时候,这导致一个非常灵活的模型,称为高阶反应式编程。

反应式编程有很多好处,但与此同时,对于简单问题来说,它可能非常复杂,有时过于复杂。当要解决的问题可以很容易地使用数据图和简单的数据传播来描述时,应考虑使用它。我们可以将问题的描述与不同模块执行顺序的描述分开。这与我们在上一章中讨论的相同考虑。我们更多地描述“做什么”,而不是“如何做”。

另一方面,当反应式系统决定执行顺序、更改内容以及如何反映在其他模块的输出上时,它应该在不了解它正在解决的问题的核心时这样做。在某些情况下,根据原始问题手动编码执行顺序可能会表现得更好。

这与内存管理问题类似。在现代运行时环境中,例如 JVM、Python 运行时、Swift 编程或甚至 Golang,都存在一些自动内存管理。当用 C 语言编程时,程序员对内存分配和内存释放有完全的控制权。在实时应用中,性能和响应时间至关重要,无法让自动垃圾回收器占用时间并时不时地延迟执行。在这种情况下,C 代码可以被优化以在需要时分配内存;当可能时,有资源进行内存的分配和释放,并且有时间来管理内存。这些程序的性能比使用垃圾回收器创建的相同目的的程序更好。尽管手动管理内存的代码可能会更快,但自动代码的速度比普通程序员使用 C 语言编写的代码要快,而且编程错误的频率也低得多。

正如在使用自动内存管理时我们必须注意一些问题一样,在反应性环境中,我们必须注意一些在手动编码情况下不存在的问题。尽管如此,我们仍然使用反应性方法,因为它有其优点。

最重要的问题是避免依赖图中的循环。虽然编写计算的定义绝对完美,但反应性系统可能无法处理这些定义。一些反应性系统可能在某些简单情况下的循环冗余中解决,但这是一种额外功能,我们通常只需要避免这种情况。考虑以下计算:

a = b + 3 
b = 4 / a

在这里,a 依赖于 b,所以当 b 发生变化时,a 被计算。然而,b 也依赖于 a,它将被重新计算,这样系统就会陷入无限循环。前面的例子看起来很简单,但这正是好例子的特点。现实生活中的问题并不简单,在分布式环境中,有时很难找到循环冗余。

另一个问题被称为故障。考虑以下定义:

a = b + 3 
q = b + a

当参数 b 发生变化时,例如,从 3 变为 6a 的值将从 6 变为 9,因此,q 的值将从 9 变为 15。这非常简单。然而,基于对变化的识别的执行顺序可能会首先将 q 的值从 9 改变为 12,然后再在第二步中将其修改为 15。这种情况可能会发生,如果负责计算 q 的计算节点在 a 的值因 b 的值变化而变化之前就识别到了 b 的变化。在短时间内,q 的值将是 12,这既不符合之前的状态,也不符合变化后的状态。这个值只是系统在输入变化后发生的一个小故障,而且在没有进一步改变输入的情况下,这个值会消失。

如果你曾经学习过逻辑电路的设计,那么静态故障可能让你想起一些东西。它们确实是相同的现象。

响应式编程还假设计算是无状态的。执行计算的各个节点在实践中可能具有状态,很多时候确实如此。在某些计算中具有状态本身并不是固有的坏事。然而,调试具有状态的东西比调试无状态、函数式的要复杂得多。

它也是响应式环境的重要辅助工具,允许它根据计算是函数性的这一事实执行不同的优化。如果节点具有状态,那么计算可能无法自由重排,因为结果可能取决于实际的评估顺序。这些系统可能真的不是 响应式 的,或者至少,这可能会引起争议。

响应式系统

响应式系统在响应式宣言中定义,见 www.reactivemanifesto.org/。宣言的制作者意识到,随着技术的变化,企业计算中需要开发新的系统模式来利用新技术并获得更好的结果。宣言设想了以下系统:

  • 响应式

  • 弹性

  • 弹性

  • 消息驱动。

前三个特性是用户值;最后一个更像是获取值的技术方法。

响应式

一个系统如果能够以可靠的方式给出结果,那么它是响应的。如果你和我交谈,我会回答你的问题,或者至少告诉你我不知道答案或者我没有理解你的问题。如果你得到了答案,那就更好了,但如果一个系统不能给你提供答案,它仍然应该给出一些反馈。如果你有十年前客户操作系统的经验,以及一些旧电脑,你就能理解这一点。得到一个旋转的沙漏图标是非常令人沮丧的。你根本不知道系统是在努力为你获取答案,还是完全冻结了。

一个反应性系统必须具有响应性。响应应该及时到来。实际的时间取决于实际的系统。它可能是毫秒、秒,甚至在系统运行在前往木星另一侧的宇宙飞船上时,可能是数小时。重要的是,系统应该保证响应时间有一个的上限。这并不一定意味着系统应该是一个实时解决方案,这是一个更加严格的要求。

响应性的优势不仅仅是用户在电脑前不会感到紧张。毕竟,这些服务中的大多数都是由其他服务使用的,它们主要相互通信。真正的优势是错误发现更加可靠。如果一个反应性系统元素变得无响应,这肯定是一个错误状态,应该对此采取措施,超出正常操作的范围(更换故障的通信卡、重启系统等)。我们越早能够识别错误状态,修复它就越便宜。我们能够识别问题的位置越多,我们花费在定位错误上的时间和金钱就越少。响应性不是关于速度,而是关于更好的操作,更好的质量。

弹性

弹性系统即使在出现某些错误的情况下也能继续工作。好吧,不是任何错误。那将是奇迹,或者简单地说就是胡说八道!错误通常就是错误。如果世界末日来临,我们所知道的世界就此终结,即使是弹性系统也不会响应。然而,对于较小的干扰,可能有一些方法可以使系统具有弹性。

如果只有磁盘失败、断电或出现编程错误,有一些技术可能有所帮助。系统可以被复制,因此当其中一个实例停止响应时,另一个实例可以接管失败实例的任务并继续工作。易于出错的系统可以在空间或时间上相互隔离。当一个地点发生地震或洪水时,另一个地点仍然可以继续工作。如果不同的组件不需要实时通信,并且消息以可靠的方式存储和转发,那么即使两个系统永远不会同时可用,这也不是问题。它们仍然可以合作接收消息,执行它们应该执行的任务,并在之后发送结果消息。

即使系统保持响应,系统中的错误也必须得到解决。错误不会影响弹性系统的响应性,但弹性水平会降低,应该得到恢复。

弹性

弹性意味着系统正在适应负载。我们可以有一个庞大的系统,拥有大量的处理器,能够满足最大的预期需求。但这不是弹性。由于需求不是恒定的,而且大多数时候,需求小于最大值,这样的系统的资源是闲置的。这导致了时间、CPU 周期、能源的浪费,从而增加了生态足迹。

图片

在云上运行系统可以避免此类损失。云不过是许多由某人运营的多台计算机,用于多个应用程序,甚至多个公司,每个租户只租用其实际需要的 CPU 周期,并且只在需要时租用。在其他时候,当负载较小时,CPU 和电力可以被其他人使用。由于不同应用程序和不同公司的峰值时间不同,这种模式下的资源损失较少。有许多问题需要解决,例如数据隔离和防止信息被窃听,但这些主要已经解决了。秘密服务公司不会从云服务中租用资源来运行他们的计算(也许,他们会为了其他目的这么做)以及一些其他偏执的公司也可能避免这么做,但大多数公司都会这么做。这更加有效,因此即使考虑了所有可以考虑的副作用,成本也更低。

弹性意味着分配的资源会跟随,或者更确切地说,会预测即将到来的需求。当系统预测到更高的容量需求时,它会分配更多资源,在非高峰时间,它会释放资源,以便其他云客户可以使用。

弹性还假设系统是可扩展的。弹性和可扩展性这两个概念密切相关,但并不相同。可扩展性意味着应用程序可以适应更高的负载,分配更多资源。可扩展性并不关心这种分配是静态购买和供电给一个专门用于应用程序的计算中心的巨大计算机箱,还是按需从云中动态分配资源。可扩展性仅仅意味着如果需求加倍,那么资源也可以成倍增加以满足需求。如果所需资源的乘数与需求的乘数相同或不超过需求乘数,那么该应用程序是可扩展的。如果我们需要更多资源来满足需求,或者即使需求适度增加,我们也不能满足需求,那么该应用程序是不可扩展的。弹性应用程序总是可扩展的;否则,它们就不能是弹性的。

消息驱动

反应式系统是消息驱动的;不是因为我们需要消息驱动的系统,而是因为消息驱动的系统能够同时提供响应性、弹性和弹性。

消息驱动架构意味着信息在断开连接的组件之间传递。一个组件发送一条消息,然后忘记它。它不会等待另一个组件对消息采取行动。当消息发送时,代表发送组件的所有任务都会执行,并且处理这些任务所需的所有资源都会释放,从而使消息被释放并准备好用于下一个任务。

消息驱动不一定意味着网络。消息可以在同一台机器内的对象、线程和进程之间传递。另一方面,如果消息架构的接口设计得很好,那么在基础设施发生变化时,组件不需要修改,之前在线程之间传递的消息现在将不得不通过 IP 数据包穿越海洋。

发送消息使得在空间和时间上隔离发送者和接收者成为可能,正如我们描述的那样,作为一种弹性的手段。接收者可能在收到消息一段时间后取走它,当它有资源这样做的时候。然而,响应性要求这个时间不是在遥不可及的将来,而是在某个有限的距离内。如果消息无法成功处理,另一个消息可能会发出错误信号。错误消息不是我们期望的结果,但它仍然是一种响应,系统仍然保持响应性,并带来所有它意味着的好处。

背压

消息处理,通过适当的消息接口和实现,支持背压。背压是一种在组件无法或几乎无法处理更多消息时减轻其负担的手段。消息可能会排队等待处理,但现实中没有任何队列具有无限容量,反应式系统不应无控制地丢失消息。背压向消息生产者发出信号,要求它们减少生产。这就像一个水管。如果你开始关闭水管的出口,水管背面的压力开始增加,水源迫使它减少和减少水的供应。

背压是一种有效的处理负载的方法,因为它将负载处理转移到了真正能够做到这一点的组件。在传统的队列系统中,有一个队列存储项目,直到接收它们的组件可以消费它们,完成其工作。如果对负载大小和队列最大大小有明确的限制,队列设计可以是好的。如果队列满了,项目就无法交付,系统就会停滞。

应用反压略有不同。队列仍然可以用于组件前面进行性能优化和确保响应性。项目的生产者仍然可以将生产的项目放入队列,并返回处理自己的职责,而不需要等待消费者能够处理该项目。这就是我们之前提到的解耦。看到队列已满或几乎满也可以作为一个非常简单的反压。如果有人说队列完全缺少这个功能,那是不正确的。有时,仅仅查看队列的容量以及其中的项目,看看是否需要减轻队列所属接收器的负载,可能就足够了。但是,这是生产者而不是接收者所做的,这是一个基本问题。

生产者看到接收者没有跟上供应的速度,但生产者没有关于原因的任何信息,不知道原因就无法预测未来的行为。从接收器到生产者的反压信息通道使得故事更加细致。

生产者可能看到队列中有,比如说,10 个槽位,并且认为没有问题;生产者决定在接下来的 150ms 内再交付 8 个项目。一个项目通常需要 10ms 来处理,上下浮动;因此预计项目将在不到 100ms 内处理完毕,这正好比所需的 200ms 最大值要好。生产者只知道一个项目通常需要 10ms 来处理。

另一方面,接收者看到它最后放入队列的项目需要如此多的处理,以至于仅靠它本身就需要 200ms。为了发出信号,它可以通过反压告诉生产者不要在进一步通知之前交付新项目。接收者知道这些项目可以很好地放入队列,但不会及时处理。利用这些信息,生产者将向云控制发送一些命令来分配另一个处理,并将下一个八个项目发送到新的接收器,让旧的接收器处理它必须处理的比平均水平高的项目。

反压允许你通过接收器提供的信息来协助数据加载控制,这些接收器拥有关于处理项目最多的信息。

反应式流

反应式流最初是一个旨在通过调节数据流的推送来提供一个在异步模式下处理数据流的标准,这个项目的原始网站是www.reactive-streams.org/

反应式流现在已在 JDK 9 的 java.util.concurrent 包中实现。

反应式流定义的目的是定义一个接口,该接口能够以完全异步的方式处理生成数据的传播,无需接收方缓冲无限创建的数据。当数据在流中创建并可供工作者处理时,工作者必须足够快,能够处理所有生成的数据。容量应足够高,以处理最高产量。一些中间缓冲区可以处理峰值,但如果没有控制机制在消费者达到容量极限时停止或延迟生产,系统将失败。反应式系统接口旨在提供一种支持背压的方式。背压是一种向数据生产者发出信号的过程,指示其减慢或甚至停止生产,以达到适合消费者的水平。接口定义的每个调用都是异步的,这样一部分的性能就不会受到其他部分执行延迟的影响。

该倡议的目标不是定义数据在生产与消费之间的传输方式。它专注于接口,为程序提供清晰的架构,并提供一个将适用于所有实现的 API。

Java 中的反应式编程

Java 不是一种反应式语言。这并不意味着我们无法在 Java 中创建反应式程序。有一些库支持不同的反应式编程方法。这里要提到的是 Akka 框架和 ReactiveX,它们也存在于其他语言中。从 Java 9 开始,JDK 开始支持反应式编程,提供了一些类和接口来实现这一目的。我们将关注这些特性。

JDK 包含了 java.util.concurrent.Flow 类,该类包含相关接口和一些静态方法,以支持流控制程序。该类支持的模型基于 PublisherSubscriberSubscription

用一个非常简单的解释来说,Publisher 接受来自 Subscriber 的订阅。当数据可用时,Subscriber 会获取其订阅的数据。接口专注于通信数据流控制的非常核心部分,因此有些抽象。毫不奇怪,它们是接口。然而,一开始可能并不容易理解它们的工作方式。

Publisher 接口定义了 subscribe 方法。这是该接口定义的唯一方法,这也是因为这是唯一一个真正的发布者可能被要求做的事情。你可以订阅发布。该方法的一个参数是订阅发布的 Subscriber

void subscribe(Flow.Subscriber<? super T> subscriber)

JDK 中有一个现成的 Publisher 类,我们稍后会查看。当 Publishersubscribe 方法被调用时,它必须决定订阅者是否能获得订阅。通常,订阅会被接受,但实现有自由拒绝订阅尝试。例如,如果实际订阅者的订阅已经完成,并且 Publisher 实现不允许同一个订阅者进行多次订阅,Publisher 可能会拒绝订阅。

方法实现需要调用 subscriberonError 方法,参数为 Throwable。在多个订阅的情况下,IllegalStateException 似乎很合适,因为 JDK 文档目前是这样定义的。

如果订阅成功,Publisher 预期会调用 subscriberonSubscribe 方法。此方法的参数是一个 Subscription 对象(实现 Subscription 接口的一个类的实例)。这样,Publisher 就会通知 Subscriber 订阅请求已被接受,并且传递一个对象来管理订阅。

将订阅作为一个抽象来管理可能看起来是一个复杂任务,但在响应式流的情况下,它非常简单。所有订阅者能且应该做的就是设置它当前可以接收的项目数量,并且可以取消订阅。

为什么 Publisher 要回调 SubscriberonSubscribe 方法?为什么它不直接返回订阅或抛出一些错误?这种复杂行为的理由是,可能不是 Subscriber 调用了 subscribe 方法。正如现实生活中,我可以作为圣诞礼物订阅并支付一年的杂志订阅。 (这是我在写这本书的这一部分的时候。) 在我们的代码中,一些负责通知谁关于某些数据变化的连接组件调用 subscribe,而不一定是订阅者。Subscriber 只负责订阅者应该负责的最基本的事情。

另一个原因是整个方法是非同步的。当我们订阅某物时,订阅可能不会立即可用和就绪。可能有一些需要完成的长运行过程,直到订阅可用,调用 subscribe 的调用者不需要等待过程的完成。当订阅就绪时,它会被传递给订阅者,传递给真正需要它的实体。

Subscriber 接口定义了 onSubscribeonError(我们已经讨论过这些)、onCompleteonNext 方法。

在定义这些接口时,重要的是让订阅者能够从Publisher或通过某种推送方式委托给其他对象的Publisher获取项目。订阅者不需要去报亭获取下一期;调用onNext方法的人会直接将期号传递给它。

这也意味着,除非Subscriber手中有一些控制措施,否则可能会发生PublisherSubscriber发送大量项目的情况。并非每个Subscriber都能够处理无限量的项目。在执行订阅操作后,Subscriber会获得一个Subscription对象,并且可以使用该对象来控制项目对象的流动。

Publisher创建Subscription对象,并且接口定义了两个方法:cancelrequestSubscriber应该调用cancel方法来通知Publisher它不应该再发送更多项目。订阅将被取消。request(long n)方法指定订阅者准备通过后续调用onNext方法获取最多n个项目:

如果订阅者已经调用了request方法,指定的数量将添加到订阅计数器中。换句话说,指定的long值并不反映订阅者的实际状态。它是一个增量,增加由Publisher维护的一些计数器,这些计数器通过将long参数的值添加到可以交付的项目数量中,并在每个交付给Subscriber的项目上递减一个。

如果使用Long.MAX_VALUE参数调用request方法,Publisher可能会发送任何它能够发送的项目,而不进行计数和限制。这本质上就是关闭了背压机制。

规范还提到,调用cancel并不一定意味着将不再发送任何期号。取消操作是尽力而为的。就像现实生活中,当你把邮件寄给日报,意图取消订阅时,出版商不会派代理人去阻止邮递员在你邮箱里放下期号。如果取消到达出版商时,期号已经在路上,它就会继续前进。如果Publisher已经开始了一些无法合理停止的异步过程,那么onNext方法将使用一些元素被调用。

PublisherSubscriber 接口有一个泛型参数 T。这是 Publisher 接口发布的项目类型和 Subscriber 接口在 onNext 方法中获取的类型。为了更精确一点,Subscriber 接口可以有一个 R 类型,它是 T 的超类;因此,它与 Publisher 接口兼容。例如,如果 Publisher 发布 Long 值,那么 Subscriber 接口可以在 onNext 方法的参数中接受 LongNumberObject,具体取决于实现 Subscriber 的类的声明。

Flow 类还包含一个扩展了 PublisherSubscriber 接口的 Processor 接口。这个接口是为了由那些也接受数据并将数据发送到反应流中其他组件的类来实现。这样的元素在反应流程序中非常常见,因为许多执行某些任务的元素从其他反应流元素中获取要处理的项目;因此,它们是 Subscriber,同时,在完成它们的任务后,它们也会发送数据;因此,它们也是 Publisher

实现库存

现在我们已经讨论了很多技术和编程方法,现在是时候实现一些示例代码了。我们将使用反应流在我们的应用程序中实现库存管理。例如,库存将非常简单。它是一个 Map<Product,InventoryItem>,它保存了每个产品的项目数量。实际的映射是 ConcurrentHashMap,而 InventoryItem 类比 Long 数字更复杂,以便正确处理并发问题。当我们设计基于响应流的程序时,我们不需要处理太多的并发锁定,但我们仍然应该意识到代码是在多线程环境中运行的,如果我们不遵循一些规则,可能会表现出奇怪的行为。

Inventory 类的代码相当简单,因为它只处理一个映射:

package packt.java9.by.example.mybusiness.inventory; 

import ...; 

@Component 
public class Inventory { 
    private final Map<Product, InventoryItem> inventory = 
            new ConcurrentHashMap<>(); 

    private InventoryItem getItem(Product product) { 
        inventory.putIfAbsent(product, new InventoryItem()); 
        return inventory.get(product); 
    } 

    public void store(Product product, long amount) { 
        getItem(product).store(amount); 
    } 

    public void remove(Product product, long amount) 
            throws ProductIsOutOfStock { 
        if (getItem(product).remove(amount) != amount) 
            throw new ProductIsOutOfStock(product); 
    } 
}

维护库存项的类稍微复杂一些,因为这是我们需要处理一些并发或至少是这个我们必须注意的类:

package packt.java9.by.example.mybusiness.inventory; 

import java.util.concurrent.atomic.AtomicLong; 

public class InventoryItem { 
    private final AtomicLong amountOnStock = 
            new AtomicLong(0); 
    void store(long n) { 
        amountOnStock.accumulateAndGet(n, 
                (stock, delta) -> stock + delta); 
    } 
    long remove(long delta) { 
        class ClosureData { 
            long actNr; 
        } 
        ClosureData d = new ClosureData(); 
        amountOnStock.accumulateAndGet(delta, 
                (stock, n) -> 
                        stock >= n ? 
                                stock - (d.actNr = n) 
                                : 
                                stock - (d.actNr = 0) 
        ); 
        return d.actNr; 
    } 
}

当我们向库存中添加产品时,我们没有限制。存储货架非常大,我们没有模拟它们可能会满的情况,库存可能无法容纳更多项目。然而,当我们想要从存储库中移除项目时,我们必须处理可能没有足够项目的情况。在这种情况下,我们不会从存储库中移除任何项目。我们为客户提供完全满意的服务,或者我们根本不提供服务。

为了维护库存中项目的数量,我们使用AtomicLong。这个类有accumulateAndGet方法。这个方法接受一个Long参数和一个我们用 lambda 表达式提供的LongBinaryOperator。这段代码由accumulateAndGet方法调用以计算库存的新值。如果有足够的物品,则移除请求的数量。如果没有足够的库存,则移除零。该方法返回实际返回的项目数量。由于这个数字是在 lambda 内部计算的,所以它必须从那里逃逸出来。为了做到这一点,我们使用方法内部定义的ClosureData

注意,例如,在 Groovy 中,我们可以简单地使用一个Long d变量并在闭包内部更改该变量。Groovy 将 lambda 称为闭包。在 Java 中,我们无法这样做,因为我们从方法内部可以访问的变量应该是有效最终的。然而,这不过是闭包环境中的一个更明确的表示法。ClosureData d对象是最终的,与类中可以修改的字段相对,该字段可以在 lambda 内部被修改。

在本章中我们真正感兴趣的最有趣的类是InventoryKeeper。这个类实现了Subscriber接口,能够消费订单以维护库存:

package packt.java9.by.example.mybusiness.inventory; 

import ... 

public class InventoryKeeper implements Flow.Subscriber<Order> { 
    private static final Logger log = 
            LoggerFactory.getLogger(InventoryKeeper.class); 
    private final Inventory inventory; 

    public InventoryKeeper(@Autowired Inventory inventory) { 
        this.inventory = inventory; 
    } 

    private Flow.Subscription subscription = null; 
    private static final long WORKERS = 3; 

    @Override 
    public void onSubscribe(Flow.Subscription subscription) { 
        log.info("onSubscribe was called"); 
        subscription.request(WORKERS); 
        this.subscription = subscription; 
    }

onSubscribe方法在对象被订阅后调用。订阅传递给对象,并也存储在一个字段中。由于订阅者需要在后续调用中需要这个订阅,当onNext中传递的项目被处理并且可以接受新项目时,字段是一个存储这个对象的不错的地方。在这个方法中,我们还设置了初始请求为三个项目。实际值只是示范性的。企业环境应该能够配置这样的参数:

    private ExecutorService service =  
                   Executors.newFixedThreadPool((int) WORKERS);

代码中最重要的部分是onNext方法。它的作用实际上是遍历订单中的所有项目,并从库存中移除相应的项目数量。如果某些项目缺货,那么它会记录一个错误。这部分很无聊。有趣的部分是它通过一个执行器服务来完成这个操作。这是因为对onNext的调用应该是异步的。发布者调用onNext来传递项目,但我们不应该让它等待实际的处理。当邮递员送来你最喜欢的杂志时,你不会立即开始阅读并让邮递员等待你的签名确认接收。在onNext中你只需要获取下一个订单并确保它将及时处理:

    @Override 
    public void onNext(Order order) { 
        service.submit(() -> { 
                    int c = counter.incrementAndGet(); 
                    for (OrderItem item : order.getItems()) { 
                        try { 
                            inventory.remove(item.getProduct(), 
                                               item.getAmount()); 
                        } catch (ProductIsOutOfStock exception) { 
                            log.error("Product out of stock"); 
                        } 
                    } 
                    subscription.request(1); 
                    counter.decrementAndGet(); 
                } 
        ); 
    } 

    @Override 
    public void onError(Throwable throwable) { 
        log.info("onError was called for {}", throwable); 
    } 

    @Override 
    public void onComplete() { 
        log.info("onComplete was called"); 
    } 
}

代码的实际实现使用了包含三个线程的 ThreadPool。此外,所需项目的数量也是三个。这是一个逻辑巧合:每个线程处理一个单独的项目。它不需要这样,尽管在大多数情况下是这样的。如果这样做有意义,我们无法阻止我们创建更多线程来处理同一个项目。相反的情况也是真实的。可能只需要一个线程来处理多个项目。这些代码可能会更复杂,而整个复杂执行模型的想法是使编码和逻辑更简单,将多线程、编码和实现问题移入框架,并专注于应用程序代码中的业务逻辑。但我不能保证没有示例是订阅者同时在多个项目上使用多个线程,这些线程是交织在一起的。

在本章中,我们需要查看的最后一段代码是使用一些示例驱动的单元测试:

    public void testInventoryRemoval() { 
        Inventory inventory = new Inventory(); 
        SubmissionPublisher<Order> p = 
                         new SubmissionPublisher<>();

我们使用 JDK 类 SubmissionPublisher 创建 Publisher,它优雅地实现了这个接口,为我们提供了多线程功能,而无需太多麻烦:

        p.subscribe(new InventoryKeeper(inventory));

我们创建了一个库存管理员并订阅了发布者。由于还没有发布,所以它不会开始交付任何东西,但它会在订阅者和发布者之间建立联系,告诉他们,每当有产品提交时,订阅者都想要它。

之后,我们创建了产品并将它们存储在库存中,总共 20 件,我们还创建了一个需要交付 10 件产品的订单。我们将多次执行这个订单。这有点简化,但为了测试,没有必要创建具有相同产品和相同数量列表的单独订单对象:

        Product product = new Product(); 
        inventory.store(product, 20); 
        OrderItem item = new OrderItem(); 
        item.setProduct(product); 
        item.setAmount(10); 
        Order order = new Order(); 
        List<OrderItem> items = new LinkedList<>(); 
        items.add(item); 
        order.setItems(items);

在完成所有这些之后,我们将订单提交给 Publisher 10 次。这意味着有 10 个相同产品的订单,每个订单要求 10 件,即总共 100 件。这些是 100 件与仓库中的 20 件相抗衡。我们应该预期的是,只有前两个订单会被满足,其余的将被拒绝,这就是当我们执行此代码时实际发生的情况:

        for (int i = 0; i < 10; i++) 
            p.submit(order); 
        log.info("All orders were submitted");

在所有订单发布完毕后,我们等待半秒钟,以便其他线程有时间执行,然后我们结束:

        for (int j = 0; j < 10; j++) { 
            log.info("Sleeping a bit..."); 
            Thread.sleep(50); 
        } 
        p.close(); 
        log.info("Publisher was closed"); 
    }

注意,这并不是一个常规的单元测试文件。这是一些用于探索的测试代码,我也推荐您执行、调试并查看不同的日志输出。

摘要

在这个简短的章节中,我们探讨了响应式编程、响应式系统和响应式流。我们讨论了这些之间的相似之处和不同之处,这些可能会导致混淆。我们特别关注 Java 9 响应式流,它与 Stream 类和方法实际上没有什么关系。

在本章的后半部分,我们讨论了一个非常简单的示例,它使用了响应式流。

在阅读完本章之后,你已经对 Java 语言和编程有了很多了解。我们没有详细讲解 Java 的所有细节,但在一本书中这是不可能的。我敢说,在这个地球上,无论在地球表面还是在轨道上,无论人类在哪里,没有人(或女人)能知道关于 Java 的一切。然而,我们现在已经足够了解,可以开始在企业环境中编码,并在旅途中不断学习更多,直到退休,甚至退休之后。剩下的是一点编程知识。在上一个句子中,我说的是编码,以区分编程。编码是编程职业中使用的技巧。在接下来的,也是最后一章中,我们将看到编程的各个方面以及它应该如何以专业的方式进行。这通常不是入门书籍的一部分,但我很高兴我们与出版商就这个话题达成了一致。这样,你不仅可以学到这本书中的知识,还可以有一个展望,展望你将攀登的山坡之路。你会知道你可以继续学习的主题、领域和学科。

第十章:将 Java 知识提升到专业水平

到目前为止,你已经学到了成为一名专业 Java 开发者所需的最重要领域和主题。在这本书中,我们接下来要讨论的一些主题将引导你从初级开发者成长为高级开发者。尽管阅读这一章不会使任何人成为高级开发者,但前几章是我们走过的路。这一章只是地图。如果前几章涵盖了在编码之旅中到达港口前的一段几英里的短途旅行,那么这一章就是探索新大陆的航海图。

我们将简要地涉猎一些非常深入和高级的专业领域,例如创建一个 Java 代理、编译时注解处理、多语言编程、一点架构设计和工具,以及团队协作的技术。我们这样做只是为了尝尝鲜。现在,你已经拥有了足够的知识来理解这些主题的重要性,而尝试这些将激发你对未来几年自我发展的渴望,至少,这是我想要让你,作为读者,上瘾的意图。

Java 深度技术

在本节中,我们将列出三种技术:

  • Java 代理

  • 多语言编程

  • 注解处理

对于 Java 专业人士来说,了解它们不是必须的。了解它们是必须的。Java 代理主要用于开发环境和运营中。它们是与已经运行的JVM交互的复杂运行时技术。注解处理是另一端。注解处理器被连接到 Java 编译器。多语言编程位于中间。它是 JVM 编程,就像用 Java 编程一样,但通过使用一些不同的语言,或者可能是某种不同的语言和 Java 一起。甚至可能是多种语言,例如 Jython、Groovy、Clojure 和 Java 一起。

我们将讨论这些技术,以便我们能够了解它们是什么,以及如果我们想要了解更多关于它们的信息,我们应该在哪里寻找更多信息。

Java 代理

Java 代理是一种以特殊方式由 Java 运行时加载的 Java 程序,可以用来干扰已加载类的字节码,从而改变它们。它们可以用来:

  • 在运行时列出或记录,并报告加载的类,就像它们被加载时一样

  • 修改类,使方法包含额外的代码来报告运行时行为

  • 支持调试器在开发者修改源代码时更改类的内容

这种技术被用于,例如,来自zeroturnaround.com/JRebelXRebel产品。

虽然 Java 代理在 Java 的深层细节中工作,但它们并不是魔法。它们有点复杂,你需要深入理解 Java,但任何能够用 Java 编程的人都可以编写 Java 代理。所需的一切只是将代理类(即代理)打包到一个JAR文件中,其中包含代理的其他类,并且有一个META-INF/MANIFEST.MF文件,该文件定义了实现premain和/或agentmain方法的类的名称,以及一些其他字段。

详细且精确的参考文档是java.lang.instrument包文档中可用的JDK JavaDoc的一部分,可在download.java.net/java/jdk9/docs/api/找到。

当使用 Java 代理启动 Java 应用程序时,命令行必须包含以下选项:

    -javaagent:jarpath[=options]

在这里,jarpath指向包含代理类和清单文件的 JAR 文件。该类必须有一个名为premainagentmain的方法。它可能有一个或两个参数。JVM 在 JVM 初始化后尝试首先调用两个参数版本:

public static void premain(String agentArgs, Instrumentation inst);

如果不存在两个参数版本,则使用一个参数版本,它本质上与两个参数版本相同,但缺少了仪器参数,在我看来,这并不太有意义,因为 Java 代理没有Instrumentation对象就无法做很多事情:

public static void premain(String agentArgs);

agentArgs参数是作为命令行选项传递的字符串。第二个参数Instrumentation提供了注册类转换器的方法,这些转换器可以修改类字节码,以及可以请求 JVM 在运行时重新定义或重新转换类的方法。

Java 应用程序也可以在程序启动后加载代理。在这种情况下,由于程序已经启动,代理不能在 Java 应用程序的主方法之前被调用。为了区分这两种情况,JVM 在这种情况下调用agentmain。请注意,对于代理来说,要么调用premain,要么调用agentmain,永远不会同时调用。单个代理可以实现两者,使其能够在启动时执行其任务,指定在命令行上或在 JVM 启动后。

如果使用agentmain,它具有与premain相同的参数。

premainagentmain的调用之间存在一个主要且重要的区别。如果代理在启动期间无法加载,例如,如果找不到它,如果 JAR 文件不存在,如果类没有premain方法,或者如果它抛出异常,JVM 将终止。如果代理在 JVM 启动后加载(在这种情况下,应使用agentmain),即使代理中存在某些错误,JVM 也不会终止。

这种方法相当合理。想象一下,有一个在 Tomcat 服务器容器上运行的服务器应用程序。当启动新版本时,系统会停机进行维护。如果由于代理表现不佳而无法启动新版本,那么最好不启动。调试这种情况并修复它,或者将应用程序回滚到旧版本并请求更长时间的修复会话可能比启动应用程序而没有适当的代理功能造成的损害要小。如果应用程序仅在没有代理的情况下启动,那么次优操作可能不会立即被识别。

另一方面,当代理稍后附加时,应用程序已经正在运行。将代理附加到已运行的应用程序是为了从已运行的实例中获取信息。停止已运行的实例并使其失败,特别是在操作环境中,比仅仅不附加代理更有害。无论如何,这可能会被注意到,因为最有可能附加的代理可能被操作人员使用。

premainagentmain 代理获取一个 Instrumentation 对象作为第二个参数。此对象实现了几个方法。其中之一是:

void addTransformer(ClassFileTransformer transformer)

代理实现了转换器,并具有 transform 方法签名:

byte[] transform(Module module, ClassLoader loader, 
                 String className, 
                 Class<?> classBeingRedefined, 
                 ProtectionDomain protectionDomain, 
                 byte[] classfileBuffer) 
throws IllegalClassFormatException

当 JVM 在类加载时或要转换时调用此方法。该方法获取类对象本身,但更重要的是,它获取包含类字节码的字节数组。该方法预期返回转换后的类的字节码。修改字节码需要对字节码的构建方式和类文件的结构有所了解。有一些库可以帮助完成这项工作,例如 Javassist (www.javassist.org/) 或 ASM (asm.ow2.org/)。尽管如此,我将在熟悉字节码结构之前不会开始编码。

代理在单独的线程中运行,并假设在任何时候都与用户或文件系统交互,基于某些外部观察,可以调用以下方法来使用已注册的转换器重新转换类:

void retransformClasses(Class<?>... classes)

代理还可以调用以下方法,这将重新定义作为参数给出的类:

void redefineClasses(ClassDefinition... definitions)

ClassDefinition 类只是一个 Classbyte[] 对的简单组合。这将通过 JVM 的类维护机制重新定义类。

注意,这些方法和 Java 代理与 JVM 的深层、低级部分交互。这也意味着,很容易破坏整个 JVM。与类加载时的字节码检查不同,字节码不会被检查,因此,如果其中存在错误,后果可能不仅是一个异常,还可能是 JVM 的崩溃。此外,重新定义和转换不应改变类的结构。它们不应改变它们的继承足迹,添加、重命名或删除方法,或更改方法的签名,这也适用于字段。

还要注意,已经创建的对象不会受到更改的影响;它们仍然使用类的旧定义,只有新的实例才会受到影响。

多语言编程

多语言编程是在同一应用程序中使用不同编程语言的技术。这种做法不仅适用于应用程序的不同部分运行在不同的环境中。例如,客户端在浏览器中使用 JavaScript、CSS 和 HTML 执行,而服务器是用 Java 编程以在 Tomcat 环境中运行。这是一个不同的情况,通常,当人们谈论多语言编程时,这并不是典型的用法。

当运行在服务器上的应用程序部分运行在 Java 中,同时也运行在某些其他语言中时,我们就可以谈论多语言编程。例如,我们用 Java 创建订单处理应用程序,而检查订单正确性的某些代码(基于订单包含的产品特定代码)是用 JavaScript 编写的。这让你想起什么了吗?我们已经在本书中这样做来演示 JDK 的脚本 API。即使我们没有那样说,那也是一个真正的多语言程序。

运行编译后的 Java 代码的 JVM 是不同语言编译器的良好目标,因此,有许多语言可以为其编译。当 JVM 运行一个类的字节码时,它不知道源语言是什么,它并不真正关心;某个编译器创建了字节码,它只是执行它。

我们可以使用不同的语言,例如 Jython、Groovy 和 Scala,仅举几个编译为 JVM 的流行语言。我们可以使用一种语言编写一个类,而使用另一种语言编写另一个类。当它们被组合成 JAR、WAR 或 EAR 文件时,运行时系统将直接运行它们。

我们在什么情况下使用多语言编程?

多语言配置

通常,当我们想要创建一个更加灵活和可配置的应用程序时,我们会转向多语言编程。通常,这些被安装在多个实例中的应用程序,通常在不同的客户站点,都有一些配置。这些配置可以是 XML 文件、属性文件和 INI 文件(这些来自 Windows)。随着程序的不断发展,这些静态配置的可能性最终会达到极限。应用程序开发者很快就会意识到,他们需要配置一些使用这些技术难以描述的功能。配置文件开始变得越来越大,同时,读取和解释配置文件的代码也变得庞大。优秀的开发者必须意识到这种情况,并且在配置文件和它们所处理的代码变得难以管理之前,必须实施一些脚本配置和多语言编程。

图片

优秀的开发者团队可能会达到一个阶段,即他们开发自己的配置语言和该语言的解释器。它可以基于 XML,也可以是任何其他语言。毕竟,编写一种语言很有趣;我自己也做过几次。然而,这些大多数都是爱好,而不是专业项目。通常,创造另一种语言并没有客户价值。我们最好使用现有的语言。

在配置的情况下,Groovy 是一种非常实用的语言,它支持复杂的闭包和元类语法和实现。这样,这种语言非常适合创建领域特定语言。由于 Groovy 编译到 JVM,Groovy 类可以直接从 Java 调用,反之亦然,读取配置本质上就是调用从配置文件编译的类。编译可以在应用程序构建时进行,但在配置的情况下,在应用程序启动时进行更有意义。我们已经看到,Groovy 脚本 API 的实现或 Groovy 提供的特殊 API 完全能够做到这一点。

我们在书中看到过这样的例子吗?这可能对你来说是个惊喜,但事实上,我们已经多次使用 Groovy 来描述一些配置。Gradle构建文件不过是主要用 Groovy 开发的 Groovy DSL,用于支持项目构建配置。

多语言脚本

配置并不是多语言编程的唯一应用。配置在程序启动时执行,配置数据随后用作静态数据。我们可以在应用程序执行过程中任何时间执行脚本,而不仅仅是启动时。这可以用来为使用相同应用程序但配备了不同脚本的安装提供额外的功能。

提供这种脚本能力的第一个应用之一是 emacs 编辑器。该应用程序的核心是用 C 语言编写的,并包含一个 Lisp 解释器,允许用户编写脚本,这些脚本在编辑器环境中执行。工程程序 AutoCAD 也为了类似的目的使用了 Lisp 解释器。为什么 Lisp 被用于这个目的?

Lisp 的语法非常简单,因此解析 Lisp 代码很容易。同时,这种语言功能强大,而且最重要的是,在那时已经有了开源的 Lisp 解释器(至少有一个)。

为了获得这种灵活性,应用程序通常会提供插件 API,开发者可以使用它来扩展应用程序。然而,这要求开发者设置编码工具,包括 IDE、构建工具、持续集成等,即一个专业的编程环境。当插件要解决的问题很简单时,这种开销就太大了。在这种情况下,脚本解决方案会更加方便。

脚本并不是解决所有问题的方案。当扩展应用程序的脚本变得过于复杂时,这意味着脚本的可能性太多了。然而,要从一个孩子的玩具中拿回玩具是困难的。如果用户习惯了脚本的可能性,那么当我们的下一个版本的应用程序不提供这种可能性时,他们可能不会轻易接受。因此,评估我们应用程序中脚本能力的潜在用途非常重要。脚本以及我们程序的任何功能都不会被用于我们原本打算它们被用的目的。它们将被用于它们可能被用于的任何目的。当涉及到滥用某些功能时,用户可以超越所有想象。事先考虑限制脚本的可能性可能是一个好主意,限制脚本的运行时间或我们程序同意处理的脚本大小。如果这些限制设置得合理,并且用户理解和接受这些限制,那么除了脚本能力之外,还需要考虑插件结构。

应用程序的安全性,包括插件或脚本扩展,也非常重要。脚本或插件在核心应用程序相同的 JVM 上运行。一些脚本语言为脚本提供了一些围栏,限制了它们对核心应用程序的对象和类的访问,但这只是一个例外。通常,脚本与核心应用程序具有相同的权限,因此它们可以做任何事情。因此,脚本应该像核心应用程序一样被信任。应用程序的无权限用户不应可能安装或修改脚本。这种操作几乎总是留给系统管理员。

如果一个无权限的用户可以将脚本上传到服务器并执行它,那么我们就在我们的应用程序中打开了一个安全漏洞。由于访问限制是由应用程序实施的,因此很容易使用不受控制的脚本覆盖这些限制。黑客可以轻易地访问他无权访问的其他用户的数据,并读取和修改我们的数据库。

业务 DSL

当应用程序的代码可以被分为业务代码和技术代码时,多语言编程也可能成为问题。业务代码包含我们实际编写应用程序的顶层业务逻辑,这是客户支付逻辑的代码。技术代码是为了支持在业务 DSL 中编写的算法。

大多数企业应用程序包含这两种类型的代码,但许多没有将它们分开。这导致了一个包含重复代码的单一应用程序。当你感觉到在需要持久性或网络时需要编写相同类型的代码,而在编写一些业务规则时又需要编写相同类型的代码,那么这就是表明这两种代码类型没有分开的代码异味。领域特定语言(DSL)和脚本并不是一个魔杖,并不能解决所有源于错误的应用程序结构的问题。在这种情况下,首先必须重构代码以分离业务逻辑和基础设施代码,然后才是实施支持它的 DSL 和业务 API,并将业务代码重写为 DSL。这样的项目的每一步都对应用程序产生价值,即使它永远达不到 DSL 和脚本,所投入的努力也不会白费。

业务 DSL 脚本与可插拔脚本非常相似,但这次不是应用程序时不时地调用脚本以执行一些特殊扩展功能。相反,DSL 代码通过它提供的业务 API 调用应用程序。提供 API 和使用 DSL 的优势在于,实现业务逻辑的代码摆脱了技术细节,可以非常抽象,并且这样就可以更接近问题的业务级描述,而不是仅仅的程序代码。甚至一些业务人员也能理解业务 DSL,尽管在现实生活中的例子中这不是一个目标,他们甚至可以编写代码。

在维也纳科技大学,我们也采用了类似的方法,使半导体模拟对半导体设计工程师更加可用。核心计算代码是用 Fortran 编写的。一个处理大量模拟数据输入和输出并嵌入 XLISP 解释器的 C 语言框架执行了这些程序。Lisp 代码包含模拟配置数据,当模拟需要针对许多配置点执行时,也可以包含简单的循环。

这是一种多语言编程,只是我们不知道在几年后的应用程序编码风格中,这个名字将会是什么。

多语言编程的问题

多语言编程不仅仅是关于优势。在跳入这个方向之前,做出决定的开发者必须考虑很多因素。

使用另一种语言进行应用程序开发需要知识。找到能够使用这些语言的程序员最终比找到只懂 Java 的开发者更困难。(如果核心应用程序语言不是 Java,这也同样适用。)不同的语言需要不同的思维方式,很多时候,不同的人。团队也应该有一些精通两种语言的成员,如果大多数人至少对另一种语言有一些了解,这也是一个优势。

支持 Java 的工具集非常出色。构建工具、集成开发环境、库、调试可能性以及日志框架等,与其他语言相比都非常好。多语言开发还需要支持其他语言,这可能不如 Java 的支持先进。很多时候,调试 DSL 解决方案真的是一个问题,IDE 支持也可能落后。

当我们用 Java 编程时,很多时候,我们都会想当然地认为 IDE 读取库的元数据,无论何时我们需要调用一个方法或引用一个类,IDE 都会建议最佳的可能性。XML 和属性文件也可能得到支持,IDE 可能知道一些最常用的框架,如Spring,并理解处理类名的 XML 配置,即使类名位于某些属性字符串中。

在其他语言的情况下,这远非如此简单。对于拥有广泛用户基础的语言,工具支持可能很好,但如果你选择了一些异国情调的语言,你就得自己解决了。语言越异国情调,你可能拥有的支持就越少。

你可以创建一些工具来支持你开发的领域特定语言(DSL)。使用像www.eclipse.org/Xtext/这样的工具来做这件事并不难。在这种情况下,你可能会被绑定到Eclipse,这可能会或可能不会成为一个问题。你可以选择一种特殊的语言,例如Kotlin,它被IntelliJ广泛支持,因为同一家公司支持该语言和 IDE,但再次,你购买了一种可能在你需要更换时昂贵的特殊技术。这不仅仅适用于语言,也适用于你包含到你的开发中的任何技术。当你选择一种时,你应该考虑支持和退出该技术的成本,如果或当它开始衰落时。

注解处理

我们已经详细讨论了注解。你可能还记得,我们使用以下注解定义了我们的注解接口:

@Retention(RetentionPolicy.RUNTIME)

这告诉 Java 编译器保留注解并将其放入 JVM 代码中,以便代码在运行时可以通过反射访问它。默认值是RetentionPolicy.CLASS,这意味着注解会进入字节码,但 JVM 不会将其提供给运行时系统。如果我们使用RetentionPolicy.SOURCE,注解甚至不会进入类文件。在这种情况下,唯一能够对注解做任何事情的可能性就是编译时。

我们如何编写在编译时运行的代码?Java 支持注解处理器的概念。如果编译器的类路径上有一个实现了javax.annotation.processing.Processor接口的类,那么编译器将调用实现的方法一次或多次,传递编译器实际处理的源文件的信息。这些方法将能够访问编译的方法、类或任何被注解的内容,以及触发处理器调用的注解。然而,重要的是,这种访问与运行时不同。注解处理器访问的既不是编译的也不是加载的类,也就是说,当代码使用反射时它是可用的。此时,源文件正在编译中;因此,描述代码的数据结构实际上是编译器的结构,正如我们将在下一个示例中看到的那样。

注解处理器被调用一次或多次。它被多次调用的原因是因为编译器使得注解处理器能够根据其在部分编译的源代码中看到的内容生成源代码。如果注解处理器生成了任何 Java 源文件,编译器就必须编译新的源代码,也许还需要重新编译一些已经编译过的文件。这个新的编译阶段需要注解处理器的支持,直到没有更多的执行轮次。

注解处理器一个接一个地执行,并且它们在相同的源文件集上工作。无法指定注解处理器执行的顺序;因此,两个一起工作的处理器应该按照它们被调用的顺序执行任务。此外,请注意,这些代码是在编译器内部运行的。如果一个注解处理器抛出异常,那么编译过程很可能会失败。因此,只有在有无法恢复的错误并且注解处理器决定在该错误之后的编译无法完成时,才应该在注解处理器中抛出异常。

当编译器到达执行注解处理器的阶段时,它会查看实现javax.annotation.processing.Processor接口的类,并创建这些类的实例。这些类必须有一个公共的无参数构造函数。为了简化处理器的执行,并且只为处理器可以处理的注解调用处理器,该接口包含两个方法:

  • getSupportedSourceVersion以返回注解处理器可以支持的最新版本

  • getSupportedAnnotationTypes以返回一个包含此处理器可以处理的注解的完全限定类名的String对象集合

如果为 Java 1.8 创建了一个注解处理器,它可能与 Java 9 一起工作,但也可能不工作。如果它声明最新支持版本是 1.8,那么 Java 9 环境中的编译器将不会调用它。最好不调用注解处理器,而不是调用它并搞乱编译过程,这甚至可能创建编译但错误的代码。

这些方法返回的值对于注解处理器来说是相当恒定的。注解处理器将返回它可以处理的相同源版本,并将返回相同的注解集合。因此,在源代码中以声明方式定义这些值将是明智的。

这可以通过扩展javax.annotation.processing.AbstractProcessor类而不是直接实现Processor接口来完成。这个抽象类实现了这些方法。它们都从注解中获取信息,以便我们可以装饰扩展抽象类的类。例如,getSupportedAnnotationTypes方法查看SupportedAnnotationTypes注解,并返回一个包含在注解中列出的注解类型字符串的数组。

现在这一点有点让人费解,一开始也可能令人困惑。我们是在编译时执行注解处理器。但编译器本身是一个 Java 应用程序,以这种方式,编译器内部运行的代码的时间是运行时。AbstractProcessor的代码使用反射方法将SupportedAnnotationTypes注解作为运行时注解访问。这里没有魔法。JDK 9 中的方法是这样的:

public Set<String> getSupportedAnnotationTypes() { 
    SupportedAnnotationTypes sat = this.getClass().getAnnotation 
    (SupportedAnnotationTypes.class); 
    if  (sat == null) { 
        ... error message is sent to compiler output ... 
        return Collections.emptySet(); 
    } 
    else 
        return arrayToSet(sat.value()); 
}

(代码已被编辑以缩短篇幅。)

为了举例,我们将大致查看一个多语言注解处理器的代码。我们非常简单的注解处理器将处理一个简单的注解:com.javax0.scriapt.CompileScript,它可以指定一个脚本文件。注解处理器将加载脚本文件并使用 Java 9 的脚本接口执行它。

这段代码是由本书作者几年前开发的一个演示代码,并且可以从 GitHub 上以 Apache 许可证获取。因此,类的包保持不变。

注解处理器包含两个代码文件。一个是处理器将要工作的注解本身:

@Retention(RetentionPolicy.SOURCE) 
@Target(ElementType.TYPE) 
public @interface CompileScript { 
    String value(); 
    String engine() default ""; 
}

如您所见,这个注解在编译后不会进入类文件;因此,在运行时不会有任何痕迹,这样任何类源代码都可能偶尔使用这个注解。注解的TargetElementType.TYPE,这意味着这个注解只能应用于那些 Java 9 语言结构中的一些类型:classinterfaceenum

注解有两个参数。值应指定脚本文件名,引擎可以可选地定义该文件中脚本的类型。我们将创建的实现将尝试从文件名扩展名中识别脚本类型,但如果有人想在具有.jy扩展名的文件(通常用于 Jython)中隐藏一些 Groovy 代码,那也行。

处理器扩展了AbstractProcessor,因此,以牺牲一些类中使用的注解为代价,继承了一些方法:

package com.javax0.scriapt; 
import ... 
@SupportedAnnotationTypes("com.javax0.scriapt.CompileScript") 
@SupportedSourceVersion(SourceVersion.RELEASE_9) 
public class Processor extends AbstractProcessor {

没有必要实现getSupportedAnnotationTypesgetSupportedSourceVersion方法。这些方法被类上的注解所取代。在这个处理器中,我们只支持一个注解,即我们在之前列出的源文件中定义的注解,并且我们准备管理到 Java 版本 9 的源代码。我们唯一需要重写的方法是process

@Override 
public boolean process( 
    final Set<? extends TypeElement> annotations, 
    final RoundEnvironment roundEnv) { 
        for (final Element rootElement : 
            roundEnv.getRootElements()) { 
                try { 
                    processClass(rootElement); 
                }  
                catch (Exception e) { 
                    throw new RuntimeException(e); 
                } 
            } 
        return false; 
    }

RunTimeException. If any of these exceptions are thrown by the called method, then the compilation could not run the scripts and it should be treated as failed. The compilation should not succeed in such a case:
private void processClass(final Element element) 
    throws ScriptException, FileNotFoundException { 
        for (final AnnotationMirror annotationMirror : 
            element.getAnnotationMirrors()) { 
                processAnnotation(annotationMirror); 
        } 
    }

正如我们之前提到的,实际的注解在编译时是不可用的。因此,我们所能得到的是注解的编译时镜像。它具有AnnotationMirror类型,可以用来获取注解的实际类型以及注解的值。注解的类型在编译时是可用的。编译器需要它;否则,它无法编译注解。值可以从注解本身获得。我们的processAnnotation方法处理它作为参数接收到的每个注解:

private void processAnnotation( 
    final AnnotationMirror annotationMirror) 
    throws ScriptException, FileNotFoundException { 
        final String script = 
            FromThe.annotation(annotationMirror). 
            getStringValue(); 
        final String engine = 
            FromThe.annotation(annotationMirror). 
            getStringValue("engine"); 
        execute(script, engine); 
    }

我们的@CompileScript注解定义了两个参数。第一个值是脚本文件名,第二个值是脚本引擎名。如果第二个值没有指定,则将空字符串设置为默认值。对于注解的每个实例,都会调用execute方法:

private void execute(final String scriptFileName, 
                    final String engineName) 
    throws ScriptException, FileNotFoundException { 
        final ScriptEngineManager factory = 
        new ScriptEngineManager(); 
        final ScriptEngine engine; 
        if (engineName != null && engineName.length() > 0) { 
            engine = factory.getEngineByName(engineName); 
        }  
        else { 
            engine = 
            factory.getEngineByExtension 
            (getExtensionFrom(scriptFileName)); 
        } 
        Reader scriptFileReader = new FileReader 
        (new File(scriptFileName)); 
        engine.eval(scriptFileReader); 
    }

该方法尝试根据文件名加载脚本,并根据给定的名称尝试实例化脚本引擎。如果没有给出名称,则使用文件名扩展名来识别脚本引擎。默认情况下,JavaScript 引擎在类路径上,因为它是 JDK 的一部分。如果使用任何其他基于 JVM 的脚本引擎,那么它必须放在类路径或模块路径上。

类的最后一个方法是简单的脚本操作方法,没有什么特别之处。它只是切掉了文件名扩展名,以便根据扩展字符串来识别引擎:

private String getExtensionFrom(final String scriptFileName) { 
    final int indexOfExtension = scriptFileName.lastIndexOf('.'); 
    if (indexOfExtension == -1) { 
        return ""; 
    }  
    else { 
        return scriptFileName.substring(indexOfExtension + 1); 
    } 
}

仅为了完整性,我们还有类的结束括号:

}

企业中的编程

当一个专业人士为企业工作时,她并不是独自工作。有很多很多人,包括开发者和其他同事,我们必须与他们合作。企业的 IT 部门越老,企业规模越大,人们的专业角色就越多样化。你肯定会遇到业务分析师、项目经理、测试工程师、构建工程师、领域专家、测试员、架构师、敏捷教练和自动化工程师等角色。其中一些角色可能存在重叠,没有人可能承担超过一个的责任,而在其他情况下,一些角色甚至可能更加专业化。有些角色非常技术性,需要较少的业务相关知识;而另一些则更偏向于业务导向。

与众多不同角色的人一起作为一个团队工作并不简单。任务的复杂性可能对新手开发者来说是压倒性的,而且没有所有操作成员都遵循的明确政策是无法完成的,或多或少都是如此。也许你的经验会表明,这更多的是少而不是多,但这又是另一个故事了。

对于开发者如何协作,有成熟的行业实践。这些实践支持使用瀑布、敏捷或两种模型的某种组合的软件开发生命周期SDLC)。在接下来的章节中,我们将探讨在每一个软件开发组织中,至少应该使用过的工具和技术。这些包括:

  • 静态代码分析工具,通过检查源代码来控制代码质量

  • 源代码版本控制,存储所有源代码版本,并帮助获取任何旧版本开发的源代码

  • 软件版本控制,以保持我们对不同版本标识的某种顺序,并避免在众多版本中迷失

  • 代码审查和帮助定位测试未揭示的缺陷的工具,以及促进知识共享

  • 知识库工具,用于记录和文档化发现

  • 问题跟踪工具,用于记录缺陷、客户问题和其他人必须处理的任务

  • 外部产品和库的选择流程和考虑因素

  • 持续集成,保持软件的一致状态,并在错误传播到其他版本或其他代码之前立即报告错误,这取决于错误代码是如何开发的

  • 版本管理,它跟踪软件的不同版本

  • 代码仓库,存储编译和打包的工件

以下图表显示了这些任务最广泛使用的工具:

图片

静态代码分析

静态代码分析工具读取代码的方式就像编译器一样,并对其进行分析,但它们不是进行编译,而是试图在其中找到错误或错误。不是语法错误。对于这一点,我们已经有 Java 编译器了。错误,例如在循环外部使用循环变量,这可能是绝对有效的,但通常是糟糕的风格,而且很多时候,这种用法可能源于一些简单的错误。它们还检查代码是否遵循我们设定的样式规则。

静态代码分析器有助于识别代码中的许多小而明显的错误。有时,它们可能会令人烦恼,警告某些可能并不是真正的问题。在这种情况下,最好是稍微改变一下程序代码,不是因为我们要让静态代码分析工具在没有警告的情况下运行。我们永远不应该因为一个工具而修改代码。如果我们以某种方式编写代码,使其通过某些质量检查工具,而不是因为它这样做更好,那么我们就是在为工具服务,而不是让工具为我们服务。

将代码修改为通过代码分析的原因是,如果代码不违反编码风格,那么它对普通程序员来说更有可能是可读的。你或其他团队成员可能是优秀的程序员,即使代码使用了某些特殊结构,也能很容易地理解代码。然而,你不能说所有将来会维护你的代码的程序员都是这样。代码的生命周期很长。我工作的一些程序是 50 年前编写的。它们仍在运行,并由大约 30 岁的年轻专业人士维护。这意味着它们在代码开发时甚至还没有出生。很容易发生的情况是,维护你代码的人在你编写代码的时候甚至还没有出生。你不能对他们的聪明才智和编码实践有任何了解。我们能做的最好的事情就是为平均水平做准备,这正是静态代码分析工具设定的目的。

这些工具执行的检查不是硬编码在工具中的。工具内部有一些特殊的语言描述了规则,可以删除某些规则,可以添加其他规则,并且可以修改规则。这样,你可以适应你工作的企业的编码标准。不同的规则可以分为外观、轻微、重要和关键。外观问题主要是警告,我们并不真正关心它们,尽管修复这些问题也很不错。有时,这些小问题可能预示着一些真正的大问题。我们可以在检查被宣布为失败之前设置轻微和重要错误的数量限制,也可以为关键错误设置限制。在最后一种情况下,这个限制通常是零。如果一个编码错误似乎很关键,那么最好代码中没有任何这样的错误。

最常用的工具是 CheckstyleFindBugsPMD。这些工具的执行通常自动化,尽管可以从 IDE 或开发者的命令行执行,但它们的主要用途是在 持续集成 (CI) 服务器上。在构建过程中,这些工具在 CI 服务器上配置为运行,并且可以配置为如果静态代码分析失败达到某些限制时构建应中断。执行静态代码分析通常是编译和单元测试执行之后的下一步,以及实际打包之前。

SonarQube 工具 (www.sonarqube.org/) 除了是一个静态代码分析工具外,还是一个特殊的工具。SonarQube 维护了之前检查的历史记录,同时支持单元测试代码覆盖率,并能报告质量随时间的变化。这样,你可以看到质量、覆盖率百分比以及代码风格错误的不同质量级别的数量是如何变化的。很多时候,你可以看到,当接近发布日期时,由于人们急于完成工作,代码质量会下降。这是非常糟糕的,因为这是大多数错误应该被消除的时候。有关质量的统计数据可能有助于通过在质量恶化之前看到趋势来改变实践,从而使代码的可维护性失控。

源代码版本控制

源代码版本控制系统存储源代码的不同版本。如今,我们无法想象没有它进行专业软件开发。这并非一直如此,但免费在线仓库的可用性鼓励了业余开发者使用某些版本控制,而当这些开发者后来为企业工作时,很明显,使用这些系统是必不可少的。

存在着许多不同的版本控制系统。最广泛使用的是 Git。之前广泛使用的版本控制是 SVN,甚至在那之前是 CVS。这些系统现在越来越不常用。我们可以将 SVN 视为 CVS 的继承者,Git 视为 SVN 的继承者。除此之外,还有其他版本控制系统,如 MercurialBazaarVisual Studio Team Services。要查看可用工具的完整列表,请访问维基百科页面 en.wikipedia.org/wiki/List_of_version_control_software

我的猜测是,你首先会遇到 Git,并且在你为企业编程时遇到 SVN 的可能性很高。Mercury 可能会在你的实践中出现,但任何目前存在的其他系统都非常罕见,它们用于特定领域,或者它们已经灭绝。

版本控制系统允许开发团队以有组织的方式在维护的存储(以可靠的方式定期备份)上存储软件的不同版本。这对于不同的目的都很重要。

第一件事是,软件的不同版本可能部署到不同的实例中。如果我们为客户端开发软件,并且我们有很多希望与之建立卓越业务的客户,那么不同的客户可能有不同的版本。这不仅是因为一些客户不愿意为升级付费,我们也不想免费提供新版本。很多时候,客户方面的成本上升会长期阻止升级。软件产品不会在孤立的环境中自行工作。不同的客户有不同的集成环境;软件与不同的其他应用程序进行通信。当要在企业环境中引入新版本时,必须测试它是否与所有必须合作的系统兼容。这项测试需要大量的努力和资金。如果新版本提供的新功能或其他价值不足以证明成本合理,那么部署新版本将是金钱的浪费。我们软件有新版本的事实并不意味着旧版本不可用。

如果客户端存在一些错误,那么我们修复该版本中的错误至关重要。为此,必须在开发环境中重现该错误,这意味着该版本的源代码必须对开发者可用。

这确实需要客户数据库包含对客户站点上安装的我们软件产品不同版本的引用。更复杂的是,客户可能在不同的系统中同时拥有多个版本,也可能拥有不同的许可证,因此问题比最初看起来更复杂。如果我们不知道客户拥有哪个版本,那么我们就麻烦了。

由于为顾客和现实生活注册版本的数据库可能会不同步,软件产品在启动时记录其版本。在本章中,我们专门有一节关于版本控制的内容。

如果客户端所使用的版本中已经修复了该错误,那么在部署后,客户端的故障可能就会得到解决。然而,如果版本不是软件的上一版本,问题仍然存在。对旧版本软件引入的错误修复可能仍然潜伏在后续版本,甚至可能是早期版本。开发团队必须确定哪些版本与客户相关。例如,不再安装在任何客户站点上的旧版本不值得调查。之后,必须调查相关版本以检查它们是否表现出该错误。这只有在我们有源版本的情况下才能完成。如果导致错误的代码是在后续版本中引入的,那么一些旧版本可能没有错误。一些新版本也可能对错误免疫,因为错误已经在上一版本中修复,或者简单地因为导致错误的代码在错误出现之前就已经重构。一些错误甚至可能只影响特定版本而不是一系列产品。对不同的版本可能需要应用不同的修复,所有这些都需要维护一个源版本库。

即使我们没有不同版本的客户,我们很可能在开发中拥有我们软件的多个版本。主要发布版本的开发即将结束,因此,负责测试和错误修复的团队的一部分专注于这些活动。同时,下一版本的特性开发仍在继续。实现下一版本功能的代码不应该进入即将发布的版本。新代码可能非常新鲜,未经测试,并可能引入新的错误。在发布过程中引入冻结时间是很常见的。例如,可能禁止实现即将发布版本的新功能。这被称为特性冻结。

版本控制系统处理这些冻结期,维护代码的不同分支。发布将维护在一个分支上,而后续发布的版本将维护在另一个分支上。当发布推出时,应用于它的错误修复也应该传播到新版本;否则,可能会发生这样的情况,即下一版本将包含已经在上一版本中修复的错误。为此,发布分支将与正在进行的分支合并。因此,版本控制系统维护一个版本图,其中每个代码版本是图中的一个节点,而更改是顶点。

Git 在这方面做得非常出色。它很好地支持分支创建和合并,以至于开发者为每个创建的更改创建单独的分支,然后在功能开发完成后将其合并回主分支。这也为代码审查提供了良好的机会。进行功能开发或错误修复的开发者在 GitHub 应用程序中创建一个拉取请求,并请求另一位开发者审查更改并执行拉取。这是一种将四眼原则应用于代码开发的方法。

一些版本控制系统将仓库保存在服务器上,任何更改都会发送到服务器。这种做法的优势在于,任何提交的更改都会存储在定期备份的服务器磁盘上,因此是安全的。由于服务器端访问受到控制,发送到服务器的任何代码都无法无痕迹地回滚。所有版本,即使是错误的版本,都会存储在服务器上。这可能是由某些法律控制所要求的。另一方面,如果提交需要网络访问和服务器交互,可能会很慢,这最终会促使开发者不经常提交更改。更改在本地机器上停留的时间越长,我们丢失代码的风险就越大,随着时间的推移,合并变得越来越困难。为了解决这个问题,Git 将仓库分散到各个地方,提交操作发生在本地仓库上,这与服务器上的远程仓库完全相同。当一个仓库将更改推送到另一个仓库时,仓库会同步。这鼓励开发者频繁地向仓库提交更改,提供简短的提交信息,这有助于跟踪对代码所做的更改。

一些较老的版本控制系统支持文件锁定。这种方式下,当开发者检出代码文件时,其他人不能在相同的代码片段上工作。这本质上避免了代码合并时的冲突。多年来,这种方法似乎并不适合开发方法。合并问题不如检出并遗忘的文件问题严重。SVN 支持文件锁定,但这并不是真正的锁定,并不能阻止一个开发者提交更改到另一个开发者锁定的文件。这更像是一种建议,而不是真正的锁定。

源代码仓库非常重要,但不应该与存储编译后发布版本的代码的二进制文件的发布仓库混淆。源代码仓库和发布仓库协同工作。

软件版本控制

软件版本化是神奇的。想想 Windows 或星球大战电影的各个版本。嗯,后者其实并不是软件版本化,但它表明这个问题是非常普遍的。在 Java 的情况下,版本化并不那么复杂。首先,我们现在使用的 Java 版本是 9。之前的版本是 1.8,再之前是 1.7,以此类推,直到 1.0。早期的 Java 版本被称为 Oak,但那是历史。毕竟,谁又能说清楚 Java 2 是什么?

幸运的是,当我们创建一个 Java 应用程序时,情况要简单得多。从 Java 1.3 时代起,Oracle 就提出了关于如何版本化 JAR 的建议:

docs.oracle.com/javase/7/docs/technotes/guides/extensions/versioning.html

本文档区分了规范版本和实现版本。如果 JAR 内容的规范发生变化,代码必须以不同的方式运行,与之前的行为不同;规范版本应该改变。如果规范没有改变,但实现发生了变化——例如,当我们修复一个错误时——那么实现版本就会改变。

在实践中,没有人使用这个方案,尽管在理论上将实现版本和规范版本分开是一个绝妙的主意。我甚至打赌,你们中的大多数同事甚至从未听说过这种版本化。我们在实践中使用的是语义版本化。

语义版本化(semver.org/)将规范版本和实现版本混合成一个单一的版本号三元组。这个三元组的格式为mmp,即:

  • m:主版本号

  • m:次版本号

  • p:补丁号

规范说明这些数字从零开始,每次增加一。如果主版本号是零,这意味着软件仍在开发中。在这种情况下,API 是不稳定的,可能会在没有新的主版本号的情况下发生变化。当软件发布时,主版本号达到 1。之后,当应用程序(库)的 API 从上一个版本发生变化,并且应用程序与上一个版本不向后兼容时,主版本号必须增加。当变化仅影响实现但变化是显著的,也许 API 也在变化,但以向后兼容的方式时,次版本号会增加。当修复了一些错误,但变化不是主要的,API 没有变化时,补丁版本号会增加。如果任何三元组中的版本号增加,则次要和补丁级别必须重置为零:主版本号增加重置次要和补丁版本;次版本号增加重置补丁号。

这样,语义版本控制保持了三元组中规范版本的第一个元素。次要版本是规范版本和实现版本的混合。补丁版本的变化显然是实施版本的变化。

除了这些,语义版本控制允许附加预发布字符串,例如 -RC1-RC2。它还允许附加元数据,例如在加号之后的日期,例如,+20160120 作为日期。

语义版本控制的使用有助于那些使用软件的人轻松地找到兼容版本,并看到哪个版本更老,哪个版本更新。

代码审查

当我们以专业的方式创建程序时,是在团队中完成的。除了在业余爱好或跟随教程之外,没有单打独斗的编程。这不仅是因为团队合作更有效,而且因为一个人是脆弱的。如果你独自工作,被撞到或中了彩票而失去了工作项目的能力或动力,你的客户就会陷入麻烦。这不是专业。专业项目应该能够抵御任何成员的退出。

团队合作需要合作,代码审查就是其中一种合作形式。这是当开发者或一组开发者阅读其他团队成员编写的代码的一部分时的过程。从这个活动中可以直接获得收益;

  • 阅读代码的开发者可以更多地了解代码;他们学习了代码。这样,如果创建代码的开发者因任何原因退出流程,其他人可以以最小的干扰继续工作。

  • 编码风格可以统一。即使是经验丰富的开发者,在仔细注意的情况下也会犯编码错误。这可能是错误,也可能是编码风格违规。编码风格很重要,因为代码越易读,出现未注意到的错误的可能性就越小。(也参见下一个要点。)同样重要的是,编码风格对团队来说应该是统一的。所有团队成员都应该使用相同的风格。查看与我编写的代码风格不同的代码有点难以跟随和理解。这些差异可能会分散读者的注意力,团队成员必须能够阅读代码。代码属于团队,而不是单个开发者。任何团队成员都应该了解代码,并能够修改它。

  • 在代码审查过程中,可以发现很多错误。审查代码并试图理解其工作原理的各方可能会偶尔从代码结构中发现错误,这些错误在其他情况下很难通过测试发现。如果您愿意,代码审查就是最纯粹的白盒测试。人们有不同的思维方式,不同的思维模式可以捕捉到不同的错误。

代码审查可以在线上和线下进行。它可以在团队内部或对等之间进行。

大多数团队遵循 GitHub 支持的代码审查流程,这是最简单的。代码更改被提交到一个分支,而不是直接与代码合并,而是在网页界面上创建一个拉取请求。本地政策可能要求不同的开发者执行拉取。网页界面将突出显示更改,我们可以在更改的代码上添加评论。如果评论很重要,那么请求拉取的原开发者应该修改代码以回应评论,并再次请求拉取。这确保至少有两个开发者看到任何更改;知识得到了共享。

反馈是同侪之间的。这不是一个资深人士在教一个初级人士。这需要不同的渠道。GitHub 上的评论并不适合这个目的;至少,有更好的渠道。也许面对面交谈更好。评论可能来自资深人士对初级人士或初级人士对资深人士。在这项工作中,对代码质量的反馈,资深人士和初级人士是平等的。

最简单也许是最常见的评论如下:

*我可以看到 Xyz.java 在修改中有所改变,但我看不到对XyzTest.java的任何更改。这几乎是对合并的即时拒绝。如果开发了一个新功能,必须创建单元测试来测试该功能。如果修复了一个错误,那么必须创建单元测试以防止错误再次出现。我个人收到了很多这样的评论,甚至来自初级人士。其中一个人告诉我,“我们知道如果你敢给出反馈,你是在考验我们。”

上帝知道我并不是。他们不相信。

虽然在开发过程中,变更审查和 GitHub 是一个好工具,但当需要审查大量代码时,可能并不合适。在这种情况下,必须使用其他工具,例如FishEye。在这个工具中,我们可以选择要审查的源文件,即使它们最近没有更改。我们还可以选择审查人员和截止日期。评论与 GitHub 类似。最后,这种代码审查以代码审查会议结束,开发人员聚集在一起亲自讨论代码。

在组织此类会议时,一个有管理他人经验的人调解这些会议是很重要的。代码和关于风格的讨论可能会变得非常个人化。同时,在参加会议时,你也应该注意不要变得个人化。会有足够的参与者可能不知道这一点或纪律性较差。

在使用在线工具审查代码之前,永远不要参加审查会议。当你发表评论时,语言应该非常礼貌,原因我已经提到。最后,会议的调解者应该能够区分重要和不那么重要的问题,并停止对琐事进行辩论。不知何故,不那么重要的问题更为敏感。我个人并不关心格式化制表符的大小,如果它是两个或四个空格,以及文件是否应仅包含空格或是否允许制表符字符,但人们往往喜欢在这些问题上浪费时间。

在代码审查会议期间,最重要的问题是我们要专业,可能发生的情况是,我今天会审查并评论你的代码,但明天情况可能正好相反,我们必须一起工作,并且我们必须作为一个团队一起工作。

知识库

知识库几年前还是一个热门词汇。当时很少有公司在宣扬维基技术的理念,也没有人使用它。如今,知识库的格局已经完全不同。所有企业都在使用某种维基实现,目的是为了分享知识。他们主要使用 Confluence,但也有其他商业和免费的维基解决方案可供选择。

知识库存储的信息是你作为开发者可能会写在纸笔记本上以供日后参考的信息,例如,开发服务器的 IP 地址、安装 JAR 文件的目录、要使用的命令、你收集的库以及为什么使用它们。主要区别在于你以格式化的方式将其写入维基,这样其他开发者可以立即访问。对于开发者来说,编写这些页面是一种负担,并且首先需要一定的自律。以开发服务器的 IP 地址和安装目录为例,你必须不仅写下服务器的 IP 地址,还要写一些解释信息是什么的文字,因为其他人可能不理解。将包含信息的页面放置在维基系统中并取一个好名字、将其链接到其他页面或找到页面在页面树中的适当位置,也是一项工作。如果你使用的是纸笔记本,你只需在书的第一个空白页上写下 IP 地址和目录,然后你就可以记住所有其他信息。

当同事不需要自己寻找信息时,维基方法将得到回报;你可以通过更简单的方式找到信息,因为其他同事也已经将他们的发现记录在知识库中,最后但并非最不重要的是,几个月后,你找到了自己记录的信息。在纸笔记本的情况下,你会翻页以找到 IP 地址,你可能记得或不记得哪个是主服务器,哪个是辅助服务器。你甚至可能忘记那时有两个服务器(或者是一个双集群?)。

要查看可用的长列表的 wiki 软件,请访问en.wikipedia.org/wiki/Comparison_of_wiki_software

问题跟踪

问题跟踪系统跟踪问题、错误和其他任务。最初的问题跟踪系统是为了维护错误列表以及错误修复过程的状况,以确保已识别和记录的错误不会遗忘。后来,这些软件解决方案发展并成为完整的跟踪器,成为每个企业中不可避免的项目管理工具。

在许多企业中,最广泛使用的跟踪问题应用是 Jira,但您可以在en.wikipedia.org/wiki/Comparison_of_issue-tracking_systems页面上找到列出的许多其他应用。

问题跟踪应用最重要的功能是必须以可编辑的方式详细记录问题。在处理问题期间需要更多信息时,必须记录记录问题的人员。问题的来源很重要。同样,问题必须分配给某个负责人员,该人员对问题处理进度负责。

现代问题跟踪系统提供复杂的访问控制、工作流程管理、关系管理和与其他系统的集成。

访问控制只会允许与问题有关的人员访问该问题,因此其他人无法更改问题的状态,甚至无法阅读附加到其上的信息。

问题的处理步骤可能因问题的类型而异:错误可能被报告或重现,根本原因分析,修复开发或测试,补丁创建,修复与下一个发布版本合并或发布在发布中。这是一个具有几个状态简单的工作流程。

关系管理允许设置不同的问题关系,并允许用户通过这些关系从一个问题导航到另一个问题。例如,客户报告了一个错误,并且该错误被识别为与另一个已修复的错误相同。在这种情况下,通过原始工作流程并创建相同错误的新的补丁将是愚蠢的。相反,问题会得到一个指向原始问题的关系,并将状态设置为已关闭。

与其他系统的集成也有助于保持一致的开发状态。版本控制可能需要,对于每次提交,提交信息包含对描述代码修改支持的必要、错误或更改的问题的引用。问题可能通过网页链接链接到知识库文章或敏捷项目管理软件工具。

测试

当我们谈论单元测试时,我们已经讨论了测试。单元测试在敏捷开发中非常重要,它有助于保持代码清洁并减少错误数量。但这是你在企业开发中看到的测试类型之一。

测试类型

进行测试的原因有很多,但至少有两个原因我们必须提到。一个是寻找错误并尽可能多地创建无错误代码。另一个是证明应用程序是可用的,并且可以用于其预期目的。从企业角度来看,这很重要,并考虑了许多单元测试没有考虑的方面。虽然单元测试专注于一个单元,因此是指出错误位置的极好工具,但在发现来自模块之间错误接口的错误时,它完全不可用。单元测试模拟外部模块,因此测试单元是否按预期工作。然而,如果这个期望有错误,并且其他模块的行为与单元测试模拟不同,错误将不会被发现。

为了发现这个层次上的错误,这是单元测试之上的下一个层次,我们必须使用集成测试。在集成测试期间,我们测试单个单元如何协同工作。当我们用 Java 编程时,单元通常是类;因此,集成测试将测试不同的类如何协同工作。虽然关于 Java 编程中的单元测试有一个共识(或多或少),但在集成测试的情况下则不然。

在这方面,外部依赖项,例如通过网络或数据库层可访问的其他模块,可能被模拟,或者可以在集成测试期间使用某些测试实例来设置。这个论点并不是关于这些部分是否应该被模拟,而仅仅是术语问题。模拟某些组件,如数据库,既有优点也有缺点。就像任何模拟一样,缺点是设置模拟的成本以及模拟的行为与真实系统不同。这种差异可能会导致系统中仍然存在一些错误,直到后来的测试案例或,愿上帝宽恕,在生产中使用时才会被发现。

集成测试通常以类似于单元测试的方式自动化。然而,它们通常需要更多的时间来执行。这就是为什么这些测试不是在每次源代码更改时都执行的原因。通常,会创建一个单独的 maven 或 Gradle 项目,该项目依赖于应用程序 JAR,并且只包含集成测试代码。这个项目通常每天编译和执行一次。

可能会发生的情况是,日常执行并不频繁,无法及时发现集成问题,但更频繁地执行集成测试仍然不可行。在这种情况下,会更频繁地执行集成测试用例的子集,例如,每小时一次。这种测试称为冒烟测试。

以下图表显示了不同测试类型的定位:

当应用程序在完全设置的环境中测试时,这种测试称为系统测试。这种测试应该发现之前测试阶段中潜伏和覆盖的所有集成错误。不同类型的系统测试也可以发现非功能性问题。功能测试和性能测试都在这个层面上进行。

功能测试检查应用程序的功能。它确保应用程序按预期工作,或者至少具有在生产环境中安装的功能,可以导致成本节约或利润增加。在现实生活中,程序几乎从未实现任何需求文档中设想的所有功能,但如果程序以合理的方式可用,则值得安装,前提是没有安全问题或其他问题。

如果应用程序中有许多功能,功能测试可能会花费很多。在这种情况下,一些公司会进行合理性测试。这种测试并不检查应用程序的全部功能,而只是检查一部分,以确保应用程序达到最低质量要求,并且值得在功能测试上花钱。

可能存在一些在设计应用程序时未设想到的测试用例,因此在功能测试计划中没有相应的测试用例。这可能是一些奇怪的用户行为,例如,当没有人认为可能时,用户按下屏幕上的按钮。用户,即使心地善良,也可能按下或触摸任何东西,并将所有可能的不切实际输入输入到系统中。即兴测试试图弥补这种不足。在即兴测试期间,测试人员尝试所有可能的、在测试执行时他能想象到的应用程序使用方式。

这也与安全测试相关,当发现系统的漏洞时,也称为渗透测试。这些是专业人员进行的专业测试,他们的核心专业领域是安全。开发者通常没有这种专业知识,但至少,开发者应该能够讨论在测试期间发现的问题,并修改程序以修复安全漏洞。这在互联网应用程序的情况下尤为重要。

性能测试检查应用程序在合理的环境下能否处理用户对系统施加的预期负载。负载测试模拟攻击系统的用户并测量响应时间。如果响应时间合适,即低于最大负载下的所需最大值,则测试通过;否则,测试失败。如果负载测试失败,并不一定是软件错误。可能的情况是应用程序需要更多或更快的硬件。负载测试通常只以有限的方式测试应用程序的功能,并且只测试对应用程序产生读负载的使用场景。

多年前,我们正在测试一个必须达到 2 秒响应时间的 Web 应用程序。负载测试非常简单:发出GET请求,使得同时最多有 10,000 个请求处于活动状态。我们开始时使用 10 个客户端,然后一个脚本将并发用户数增加到 100,然后是 1,000,之后每分钟增加 1,000。这样,负载测试持续了 12 分钟。脚本打印出平均响应时间,我们准备在周五下午 4:40 执行负载测试。

随着负载增加到 5,000 个并发用户,平均响应时间从几毫秒上升到 1.9 秒,然后随着负载增加到 10,000 用户,从那里下降到 1 秒。你可以理解周五下午人们的态度,因为我们的要求得到了满足而感到高兴。我的同事们高兴地离开了周末。我继续测试了一段时间,因为我被这样一个现象所困扰:当负载增加到 5,000 以上时,响应时间会下降。首先,我重复了测量,然后开始查看日志文件。晚上 7 点,我已经知道了原因。

当负载超过 5,000 时,Apache 服务器管理的连接开始耗尽,Web 服务器开始返回 500 内部错误代码。这是 Apache 可以非常有效地做到的事情。它非常快地告诉你你无法被服务。当负载大约为 10,000 个并发用户时,70%的响应已经出现了 500 错误。平均响应时间下降了,但实际上用户并没有得到服务。我重新配置了 Apache 服务器,使其能够处理所有请求并将每个请求转发到我们的应用程序,只是为了了解在最大负载下我们的应用程序的响应时间大约是 10 秒。大约晚上 10 点,当我的妻子第三次给我打电话时,我也知道了在 JVM 启动文件中为 Tomcat 设置多大的内存才能在 10,000 个并发用户的情况下获得所需的 2 秒响应时间。

压力测试也是一种你可能遇到的性能测试类型。这种测试会增加系统负载,直到系统无法处理。这个测试应该确保系统能够自动或手动从极端负载中恢复,但在任何情况下,都不应该做它不应该做的事情。例如,烘焙系统永远不应该提交未确认的交易,无论负载有多大。如果负载过高,那么它应该让面团保持原样,但不应该烤出额外的面包。

层次结构中最重要的测试是用户验收测试。这通常是一个官方测试,由购买软件的客户执行,如果执行成功,客户将支付软件的费用。因此,这在专业开发中非常重要。

测试自动化

测试可以自动化。这不是一个是否能够自动化测试的问题,而是一个是否值得这样做的问题。单元测试和集成测试已经自动化,随着时间的推移,越来越多的测试在向更高层次的用户验收测试UAT)迈进的过程中被自动化。UAT 不太可能被自动化。毕竟,这个测试检查的是应用程序与用户之间的集成。虽然用户作为一个外部模块,可以在较低级别使用自动化进行模拟,但我们应该达到集成测试发生时无需模拟的水平。

有许多工具可以帮助测试自动化。目前,测试自动化的障碍是进行测试的工具成本、学习和开发测试的成本,以及担心自动测试可能没有发现一些错误。

确实,用程序做错事情比不用程序更容易。这一点对于几乎所有的事情都适用,不仅仅是测试。尽管如此,我们仍然在使用程序;否则,你为何要读这本书?一些错误可能在自动功能测试中没有被发现,而这些错误本可以通过手动测试发现。同时,当同一个测试由同一个开发者执行第一百次时,很容易忽略一个错误。自动测试永远不会这样做。最重要的是,自动测试的成本并不是运行一次的成本的 100 倍。

我们在这本书中使用了测试自动化工具。SoapUI是一个帮助你创建可以自动执行的测试的工具。其他值得关注的测试工具有CucumberConcordionFintnesseJBehave。在www.qatestingtools.com/上有一个很好的工具比较。

黑盒与白盒

你可能多次听说过测试是黑盒测试。这仅仅意味着测试不知道关于被测试系统(SUT)是如何实现的任何信息。测试仅依赖于 SUT 对外界公开的接口。在测试的另一端,白盒测试测试 SUT 的内部工作,并且非常依赖于实现:

图片

这两种方法都有优点和缺点。我们应该选择其中一种,或者两种方法的混合,最符合实际测试需求的方法。一个不依赖于实现的黑盒测试,如果实现发生变化,则不需要改变。如果被测试系统的接口发生变化,那么测试也应该随之改变。白盒测试在实现发生变化时可能需要改变,即使接口保持不变。白盒测试的优势在于,很多时候,创建这样的测试更容易,测试也可以更有效。

为了兼得两者之利,系统被设计成可测试的。但要注意,这意味着很多时候,被测试系统内部的函数被传播到接口。这样,测试将仅使用接口,因此可以声明为黑盒,但这并没有帮助。如果被测试系统的内部工作发生变化,测试必须跟随它。唯一的区别是,如果你认为接口也发生了变化,你可以称之为黑盒测试。这并不会节省任何工作,反而会增加工作量:我们必须检查所有依赖于接口的模块,看看它们是否也需要任何更改。

我并不是说我们不应该注意创建可测试的系统。很多时候,使系统可测试会导致代码更干净、更简单。然而,如果代码因为我们要使其可测试而变得杂乱无章且过长,那么我们可能没有走对路。

选择库

为企业编程或甚至为一个中等规模的项目编程,没有使用外部库是不可能的。在 Java 领域,我们使用的多数库都是开源的,或多或少是免费使用的。当我们购买需要付费的库时,通常由采购部门强制执行一个标准流程。在这种情况下,有一个关于如何选择供应商和库的书面政策。在“免费”软件的情况下,他们通常不太关心,尽管他们应该关心。在这种情况下,选择过程主要取决于 IT 部门,因此了解在选择库之前需要考虑的主要因素非常重要,即使对于免费软件也是如此。

在上一段中,我把“免费”这个词放在了引号里。这是因为没有软件是免费的。正如他们所说,没有免费的午餐。你听过很多次,但在你将要选择的开源代码库或框架的情况下可能并不明显。任何购买或实施的最重要的选择因素是成本,即价格。如果软件是免费的,这意味着你不需要为软件支付预付费。然而,集成和使用它是有成本的。支持需要花钱。有人可能会说,支持是社区支持,也是免费的。问题是,你花费寻找解决方案以帮助你克服错误的时间仍然是钱。这是你的时间,或者如果你是经理,这是你部门的专业人员的时间,你为他们支付的时间,或者,实际上,它可能是一个外部承包商,如果你内部没有解决这个问题的专业知识,他将会给你开一张大账单。

由于免费软件没有价格标签,我们在选择过程中必须考虑其他重要因素。最终,它们都会以某种方式影响成本。有时,一个标准如何改变成本并不明显或难以计算。然而,对于每一个,我们都可以设定基于技术决策的不可接受水平,并且我们可以比较库在各个标准上的优劣。

适合目的

也许,这是最重要的因素。其他因素可能在重要性规模上存在争议,但如果一个库不适合我们想要使用的目的,那么这绝对不是我们应该选择的东西,无论什么情况。在很多情况下,这可能是显而易见的,但你可能会惊讶于我见过多少次因为某个产品是其他项目的宠儿,而该库被强制用于新项目,尽管需求完全不同。

许可证

许可证是一个重要的问题,因为并非所有免费软件都对所有用途免费。一些许可证允许免费用于爱好项目和教育活动,但要求你在专业、盈利性使用时购买软件。

最常用的许可证及其解释(以及许可证的全文)可在开源倡议组织的网页上找到(opensource.org/licenses)。它列出了九种不同的许可证,为了使情况更加复杂,这些许可证还有版本。

最古老的许可证之一是代表 GNU 的通用公共许可证GPL)。此许可证包含以下句子:

例如,如果你分发此类程序的副本,无论是免费还是付费,你必须将你收到的相同自由传递给接收者。你必须确保他们也能收到或获取源代码。

如果你为盈利企业创建软件,并且公司打算销售软件,你可能不能使用任何来自 GPL 许可软件的代码行。这可能意味着你必须传递你自己的源代码,这可能不是最好的销售策略。另一方面,Apache 许可证可能适合你的公司。这是律师应该决定的事情。

尽管这是律师的工作,但有一个重要的观点,我们作为开发者必须意识到并密切关注。有时,库包含来自其他项目的代码,其许可证,如广告中所说,可能不是真实的。一个库可能在 Apache 许可证下分发,但包含 GPL 许可的代码。这显然是违反 GPL 许可证的行为,这是由一些开源开发者犯下的。你为什么会关心?下面通过一个想象的情况来解释。

你为一家企业开发软件。假设这家公司是世界上最大的汽车制造商之一,或者是一家最大的银行、制药公司,等等。GPL 软件的所有者寻求对其软件滥用的补救措施。她会起诉拥有 20K 总财富的软件开发者 John Doe,还是起诉你的公司,声称你没有妥善检查代码的许可证?她当然不会在没有金子的地方挖金子。起诉你所在的公司可能不会成功,但这绝对不是你或公司任何人都希望的过程。

我们作为软件专业人士能做什么?

我们必须使用广为人知、广泛使用的库。我们可以检查库的源代码,看看是否有抄袭的代码。一些包名可能提供一些线索。你可以通过谷歌搜索源代码的一部分来找到匹配项。最后但同样重要的是,公司可以订阅提供类似研究的库的服务。

文档

文档是一个重要的方面。如果文档不合适,将很难学习如何使用这个库。一些团队成员可能已经了解这个库,但再次强调,这可能并不适用于后来的团队成员。我们应该考虑我们的同事,他们预计是平均程序员,他们将不得不学习库的使用。因此,文档非常重要。

当我们谈论文档时,我们不仅应该考虑JavaDoc参考文档,如果有的话,还应该考虑教程和书籍。

项目活跃

重要的是不要选择一个不活跃的库来使用。看看库的路线图,最后一次发布的时间,以及提交的频率。如果一个库不活跃,我们应该考虑不使用它。库在一个环境中工作,环境会发生变化。库可能连接到数据库。如果库被修改以适应这些新功能,数据库的新版本可能只提供给我们更好的性能。库通过 HTTP 进行通信;它是否会支持新的 2.0 版本协议?至少,Java 环境的版本会在几年内发生变化,我们使用的库迟早应该跟随它以利用新功能。

没有保证一个活跃的库会一直保持活跃。然而,一个已经死亡的库肯定不会复活。

即使项目目前是活跃的,也有一些可能预示着库未来发展的线索。如果开发该公司的建立稳固且财务状况良好,并且库是以合理的商业模式开发的,那么项目死亡的风险很低。如果有许多公司使用该库,那么即使原始团队停止工作或原始融资结构发生变化,项目也很可能保持活跃。然而,这些只是小因素,并不是确凿的事实。没有保证,预测未来更多的是艺术而非科学。

成熟度

成熟度与之前的标准类似。一个项目可能非常好地处于起步阶段,但如果它处于婴儿期,我们最好不要在大型项目中使用该库。当一个项目处于早期阶段时,代码中可能会有很多错误,API 可能会发生根本性的变化,并且可能只有少数公司依赖该代码。这也意味着社区支持较低。

当然,如果所有项目只选择成熟的开源代码,那么没有任何开源项目会达到成熟状态。我们应该评估项目的重要性。项目是否是业务关键性的?项目是否会变得业务关键性?

如果项目不是业务关键性的,公司可能负担得起发明一个不那么成熟的全新库。如果没有成熟的库可用,因为你要使用的科技相对较新,这可能是有道理的。在这种情况下,公司中的项目可能也是新的,并且目前还不是业务关键性的。我们希望,在一段时间后,它将成为业务关键性的,但到那时,库将变得成熟,或者可能只是死亡,我们可以在项目变得过于昂贵之前选择一个竞争性解决方案。

判断一个库的成熟度总是困难的,并且必须与我们要使用该库的项目成熟度和重要性相一致。

用户数量

如果库活跃且成熟,但用户不多,那么就有问题。为什么人们不喜欢一个好的库呢?如果一个库或框架的用户数量很少,且用户中没有大型企业,那么它可能不是一个好的选择。没有人使用它可能意味着我们对其他标准的评估可能不合适。

还要注意,如果库的用户很少,那么社区中的知识也很稀缺,我们可能无法获得社区支持。

“我喜欢它”这个因素

最后但同样重要的是,我喜欢它这个因素极其重要。问题不在于你是否喜欢这个库,而在于开发者有多喜欢它。开发者会喜欢易于使用且工作愉快的库,这将导致成本降低。如果库难以使用且开发者不喜欢它,那么他们不会学习到达到高质量所需的专业水平,而只会达到基本需求水平。最终结果将是次优的软件。

持续集成和部署

持续集成意味着每当有新版本推送到源代码仓库时,持续集成服务器就会启动,将代码拉到其磁盘上,并开始构建。它首先编译代码,然后运行单元测试,启动静态代码分析工具,如果一切顺利,它将打包快照版本并在开发服务器上部署。

CI 服务器有网络界面,可以用来创建发布。在这种情况下,部署甚至可以到测试服务器,甚至根据本地业务需求和相应创建的政策部署到生产环境。

自动化构建和部署过程与其他自动化一样具有相同的优势:重复的任务可以在没有人工干预的情况下执行,这既繁琐又无聊,如果由人来完成,则容易出错。突出的优势是,如果源代码中存在自动化构建过程可以发现的错误,那么它将被发现。新手开发者说,在本地构建代码更便宜、更容易,开发者通常也会这样做,如果构建过程已经过检查,那么只需将代码推送到服务器即可。这在一定程度上是正确的。在将代码发送到中央仓库之前,开发者必须检查代码的质量良好且构建良好。然而,这并不总是能够实现。一些错误可能不会在本地环境中表现出来。

有可能发生这样的情况,一位开发者不小心使用了比支持版本更新的 Java 新版本,并使用了新版本的新特性。企业通常不会使用最新的技术。他们倾向于使用经过验证、用户众多且成熟的版本。今年,2017 年,当 Java 9 计划在 7 月份发布时,大型企业仍在使用 Java 1.6 和 1.7。由于 Java 9 有许多新特性,这些特性并非易事实现,我预计技术的采用可能比 Java 1.8 的采用时间更长,Java 1.8 为我们带来了函数式编程和 lambda 表达式。

也可能发生这样的情况,一个新的库被添加到构建的依赖中,而添加它到构建文件(pom.xmlbuild.gradle)的开发者可以在她的本地机器上无任何问题地使用它。这并不意味着库被正式添加到项目中,它可能不在中央代码仓库(Artifactory、Nexus 或其他代码仓库的实现)中。这个库可能只存在于开发者的本地仓库中,她可能认为既然代码可以编译,构建就是好的。

一些大型组织为不同的项目使用不同的代码仓库。库在经过细致的审查和决策后进入这些仓库。一些库可能进入其中,而另一些可能不会。拥有不同仓库的原因可能有很多。有些项目是为一个客户开发的,这个客户对开源项目的政策与其他客户不同。如果企业为自己开发代码,可能发生某些库被淘汰或不再支持的情况,并且只能用于旧项目。维护版本可能不需要替换库,但新项目可能不允许使用即将淘汰的软件库。

CI 服务器可以运行在一台机器上,也可以运行在多台机器上。如果它服务于许多项目,它可能被设置为一个中央服务器,有多个代理在不同的机器上运行。当某个构建过程需要启动时,中央服务器将这个任务委托给一个代理。代理可能有不同的负载,运行几个不同的构建过程,并且可能有不同的硬件配置。构建过程可能有关于处理器速度或可用内存的要求。某些代理可能运行较小项目的简单构建,但可能无法执行大型项目的构建或某些小型项目的构建,这些小型项目仍然有巨大的内存需求来执行一些测试。

当构建失败时,构建服务器会向开发者发送电子邮件,并且最后向代码库发送更新的人有义务立即修复错误。这鼓励开发者频繁提交。更改越小,构建问题的可能性就越小。构建服务器网络界面可以用来查看项目的实际状态,哪个项目构建失败,哪个项目构建正常。如果构建失败,构建行上会有红色标志,如果构建正常,标志是绿色的。

许多时候,这些报告会持续显示在一些旧机器上,使用大屏幕,以便每个进入房间的人都能看到构建的实际状态。甚至还有专门的硬件可以购买,它有红色、黄色和绿色的灯来跟踪构建状态,并在构建失败时响起铃声。

发布管理

开发软件意味着代码库持续变化。并非每个软件版本都应安装在生产环境中。大多数版本都推送到分支上的半完成状态。有些版本仅用于测试,而只有少数版本打算安装在生产环境中,即使最终只有其中一些会进入生产。

几乎所有时候,发布都遵循我们在前面章节讨论的语义版本控制。仅用于测试的版本通常在版本号末尾带有 -SNAPSHOT 修饰符。例如,1.3.12-SNAPSHOT 版本曾经是调试过的版本,并即将成为 1.3.12 版本。快照版本不是确定性的版本。它们是那时存在的代码。因为快照发布从未在生产环境中安装,所以不需要为维护重现快照版本。因此,快照版本不会持续增加。有时,它们可能会改变,但这是一种罕见的例外。

有可能我们在修复一个错误,1.3.12-SNAPSHOT,在开发过程中,我们修改了太多代码,以至于我们决定在发布时它必须是 1.4.0,并将快照重命名为 1.4.0-SNAPSHOT。这是一个罕见的情况。很多时候,发布创建是从 1.3.12-SNAPSHOT 创建 1.4.0 版本,因为新发布号的决策是在创建发布时做出的。

当开始发布流程时,通常是从 CI 服务器的 Web 界面开始的,创建发布的开发者必须指定发布版本。这通常与不带-SNAPSHOT后缀的快照版本相同。在这种情况下,构建过程不仅创建构建,还标记了使用的源代码仓库版本,并将打包的程序(工件)加载到代码仓库中。这个标签可以用来访问创建发布时使用的确切源代码版本。如果特定版本存在错误,那么这个版本必须在开发机上检出以重现错误并找到根本原因。

如果一个发布版本的构建失败,它可以被回滚,或者你最好直接跳过那个发布号,并注明为一个失败的发布构建。一个现有的发布版本永远不能有两个版本。源代码是唯一一个属于那个发布的,生成的代码必须与任何存储中的代码完全一致。使用相同源代码的后续编译可能会产生略微不同的代码,例如,如果使用不同版本的 Java 创建后者。即使在这样的情况下,最初由构建服务器创建的版本才是属于该发布的版本。当重现了一个错误,并且从完全相同的源代码重新编译代码时,它已经是一个快照版本。可能从同一个源版本产生多个发布版本,例如,使用 Java 版本从 1.5 到 1.8 和版本 9 编译,但一个发布版本总是属于完全相同的源代码。

如果一个本应作为发布版本的发布版本在 QA 检查期间失败,那么必须创建一个新的发布版本,并将失败的发布版本注明为失败。营销用来命名不同版本的版本不应与我们所工作的技术版本号有联系。很多时候,它们是相关的,这会导致很多麻烦。如果你意识到这两者完全是不同的事情,一个不需要与另一个有任何关系,生活就会变得简单。看看 Windows 操作系统或 Java 的不同版本。作为营销,Java 使用了 1.0 然后是 1.1,但 Java 1.2 被宣传为 Java 2,代码仍然包含 1.2(现在在七个主要版本发布后也变成了 9 而不是 1.9)。

发布管理的一部分是部署应该注册版本号。公司必须知道哪个版本发布安装在了哪个服务器上,以及哪个客户端。

代码仓库

代码仓库存储库和帮助管理不同库的依赖关系。在旧时代,当 Java 项目使用 ANT 作为构建工具,并且没有后来添加的 Ivy 依赖关系管理时,项目需要的库被下载到源代码中,通常到lib库。如果一个库需要另一个库,那么这些库也会被下载并手动存储,这个过程会一直持续到所有已下载库需要的库都被复制到源代码树中。

这是一项大量的手动工作,而且,库代码以许多副本的形式存储在源代码仓库中。编译后的库不是源代码,与源代码仓库无关。可以自动化的手动工作必须自动化。不是因为开发者懒惰(是的,我们是,而且我们必须是)而是因为手动工作容易出错,因此成本高昂。

这就是 Apache Ivy 被发明的时候,Maven 在 ANT 之后已经内置了仓库管理支持。它们都存储了结构化的库,并支持描述其他库依赖关系的元数据。幸运的是,Gradle 没有发明自己的代码仓库。相反,它支持 Maven 和 Ivy 仓库。

使用仓库,构建工具会自动下载所需的库。如果一个库有新版本,那么开发者只需更新构建配置中所需库的版本,所有任务,包括下载该版本所需的所有其他库的新版本,都会自动完成。

逐步攀登

到目前为止,你已经获得了大量的信息,这将使你作为企业 Java 开发者的起步更加迅速。你已经获得了可以在此基础上构建的基础知识。要成为一名专业的 Java 开发者还有很长的路要走。有很多文档需要阅读,有很多代码需要扫描和理解,还有大量的代码需要编写,直到你可以声称自己是一名专业的 Java 开发者。你可能需要面对多年的持续教育。好事是,即使在那之后,你还可以继续你的旅程,你可以自学,因为成为一名专业的 Java 开发者很少是人们退休的工作。不,不!不是因为他们在做这件事的时候死了!而是因为随着经验的积累,专业的软件开发者开始越来越少地编写代码,并以不同的方式支持开发过程,这更多地利用了他们的经验。他们可以成为业务分析师、项目经理、测试工程师、领域专家、架构师、敏捷大师、自动化工程师等等。这是一个熟悉的列表吗?是的,这些是作为开发者你会与之共事的人。他们中的许多人可能自己就是从开发者开始的。

下面的图示显示了这些角色之间的相对位置:

图片

让我们更详细地了解一下这些角色在企业开发中的表现:

  • 业务分析师与客户合作,创建开发者开发代码所需的文档、规范、用例和用户故事。

  • 项目经理负责管理项目,并与其他团队协作,帮助团队完成工作,关心所有开发者无法处理或会浪费他们本应用于编码时间的项目事务。

  • 主题专家在了解业务需求方面更为先进,因此开发者成为主题专家的情况相对较少,但如果你所在的行业是技术导向的,那么成为主题专家可能并不令人惊讶。

  • 测试工程师控制着质量保证过程,不仅了解测试方法和测试要求,还了解开发过程,以便他们能够支持错误修复,而不仅仅是识别它们,这将是糟糕的。

  • 架构师与业务分析师合作,设计应用程序和代码的高级结构,并以一种帮助开发者专注于他们必须执行的实际任务的方式进行文档记录。架构师还负责选择适合目的、具有前瞻性、经济实惠的技术、解决方案和结构等。

  • Scrum 团队成员帮助开发团队遵循敏捷方法论,并帮助团队控制管理并解决问题。

作为软件开发者,有众多的发展道路,我仅列出了今天在企业中可以找到的一些职位。随着技术的发展,我可以想象,从今天起 20 年后,软件开发者将教授和培育人工智能系统,而这将是今天我们所指的编程。谁能说得准呢?

摘要

沿着这个方向前进是一个不错的选择。成为一名 Java 开发者并成为该领域的专业人士,在未来 10 到 20 年内肯定能获得良好的回报,甚至可能更久。同时,我个人发现这项技术非常吸引人,有趣,在超过 10 年的 Java 编程和超过 35 年的编程生涯中,我每天都在其中学到新的东西。

在这本书中,你学习了 Java 编程的基础知识。不时地,我也提到了问题、建议的方向,并警告你关于非 Java 特定的问题。然而,我们也完成了学习 Java 语言、基础设施、库、开发工具和 Java 网络的学习作业。你还学习了只有 Java 8 和 9 才带来的最现代的方法,例如 Java 中的函数式编程、流和响应式编程。如果你知道这本书中我写的一切,你就可以开始作为 Java 开发者工作了。接下来是什么?去寻找你在编程和 Java 中的宝藏吧!

posted @ 2025-09-10 15:06  绝不原创的飞龙  阅读(42)  评论(0)    收藏  举报