Java-项目大全-全-

Java 项目大全(全)

原文:JAVA PROJECTS

协议:CC BY-NC-SA 4.0

零、前言

随着 Java8 的引入,Java 发生了巨大的变化,这个变化随着新版本 Java8 以及 Java8 和 11 被提升到了一个全新的水平。Java 有着悠久的历史,已经有 20 多年的历史了,但同时,它是新的、函数式的、反应式的和性感的。这是一种开发人员喜爱的语言,同时也是许多企业项目开发人员语言的首选。

从 Java11 开始,现在学习 Java 可能比以前更有利可图。我们鼓励您通过学习 Java 开始您的专业开发生涯,在本书中我们已经尽了最大的努力来帮助您沿着这条道路前进。我们把这本书的主题组合起来,这样就很容易开始,而且你可以感觉到事情进展得很快。同时,我们也尝试着走得更远,为专业开发人员指明了前进的道路。

时间的沙子不停地移动,我发现了函数式编程。

我很清楚为什么写副作用免费代码有效!我被迷住了,开始和 Skara、克鲁和埃尔朗一起玩。不可变性是这里的标准,但是,我想知道传统算法是如何在函数环境中看到的,并开始学习它。

数据结构永远不会原地突变。相反,将创建数据结构的新版本。最大化共享的复制和写作策略是一个有趣的策略!所有这些小心的同步根本不需要!这些语言配备了垃圾收集。因此,如果不再需要某个版本,运行时将负责回收内存。不过,一切都来得正是时候!阅读这本书将帮助你看到,我们不需要牺牲算法性能,同时避免原地变异!

这本书是给谁的

这本书是给任何想学习 Java 编程语言的人准备的。无需编程经验。如果你有先例,它将帮助你更容易地读完这本书。

这本书的内容

第 1 章“Java11 入门”,为您提供 Java 入门,帮助您在计算机上安装 Java,并使用新的 JShell 运行第一个交互式程序。

第 2 章、“第一个真正的 Java 程序-排序名称”,教您如何创建开发项目。我们将创建程序文件并编译代码。

第 3 章、“优化专业排序代码”,进一步开发代码,使代码可重用,不仅是玩具。

第 4 章、“策划者——创造一个游戏*”,就是乐趣开始的时候。我们开发了一个有趣的游戏应用,并不像最初看起来那么简单,但我们会做到的。

第 5 章、“扩展游戏——跑得并行,跑得更快”,展示如何利用现代架构的多处理器功能。这是一个非常重要的章节,详细介绍了只有少数开发人员真正了解的技术。

第 6 章、“让我们的游戏专业化——做一个 Web 应用*”,将用户界面从命令行转变为基于 Web 浏览器,提供更好的用户体验。

第 7 章“使用 REST 构建一个商业 Web 应用”,带领您完成一个具有许多商业应用特性的应用的开发。我们将使用标准的 REST 协议,它已经在企业计算领域取得了进展。

第 8 章“扩展我们的电子商务应用”,利用脚本和 Lambda 表达式等现代语言特性,帮助您进一步开发应用。

第 9 章“使用反应式编程构建会计应用”,教您如何使用反应式编程解决一些问题。

第 10 章“将 Java 知识提升到专业水平”,对 Java 开发人员生活中起重要作用的开发人员话题进行了鸟瞰,这将指导您进一步成为专业开发人员。

充分利用这本书

为了让自己沉浸在这本书的内容中并吸收技能和知识,我们假设您已经有了一些编程经验。我们不做太多假设,但希望您已经知道什么是变量,计算机有内存、磁盘、网络接口,以及它们通常是什么。
除了这些基本技能外,还有一些技术要求你需要尝试一下书中的代码和例子。你需要一台今天可以使用的电脑,可以运行 Windows、Linux 或 OSX。你需要一个操作系统,也许,这就是你需要支付的全部费用。您需要的所有其他工具和服务都是开源的,并且是免费的。其中一些还可以作为带有扩展特性集的商业产品提供,但是在本书的范围内,开始学习 Java9 编程时,这些特性是不需要的。Java、开发环境、构建工具以及我们使用的所有其他软件组件都是开源的。

下载示例代码文件

您可以从您的帐户下载本书的示例代码文件 www.packtpub.com。如果您在其他地方购买了本书,您可以访问www.packtpub.com/support并注册,将文件直接通过电子邮件发送给您。

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

  1. 登录或注册 www.packtpub.com
  2. 选择“支持”选项卡。
  3. 点击代码下载和勘误表。
  4. 在搜索框中输入图书名称,然后按照屏幕上的说明进行操作。

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

  • 用于 Windows 的 WinRAR/7-Zip
  • Mac 的 Zipeg/iZip/UnRarX
  • 用于 Linux 的 7-Zip/PeaZip

这本书的代码包也托管在 GitHub 上。如果代码有更新,它将在现有 GitHub 存储库中更新。

我们的丰富书籍和视频目录中还有其他代码包,可在这个页面上找到。看看他们!

下载彩色图像

我们还提供了一个 PDF 文件,其中包含本书中使用的屏幕截图/图表的彩色图像。您可以在这里下载

使用的约定

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

CodeInText:表示文本中的码字、数据库表名、文件夹名、文件名、文件扩展名、路径名、伪 URL、用户输入和 Twitter 句柄。下面是一个例子:“最简单的方法是启动new Thread(),然后在线程上调用start()方法。”

代码块设置如下:

 private boolean isNotUnique(Color[] guess) {
        final var alreadyPresent = new HashSet<Color>();
        for (final var color : guess) {
            if (alreadyPresent.contains(color)) {
                return true;
            }
            alreadyPresent.add(color);
        }
        return false;
    }

当我们希望提请您注意代码块的特定部分时,相关行或项以粗体显示:

@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); 
 }

任何命令行输入或输出的编写方式如下:

Benchmark     (nrThreads)  (queueSize)  Score   Error 
playParallel            1           -1 15,636  ± 1,905 
playParallel            1            1 15,316  ± 1,237 
playParallel            1           10 15,425  ± 1,673 
playParallel            1          100 16,580  ± 1,133 
playParallel            1      1000000 15,035  ± 1,148 
playParallel            4           -1 25,945  ± 0,939 

粗体:表示一个新术语、一个重要单词或屏幕上显示的单词。例如,菜单或对话框中的单词会像这样出现在文本中。下面是一个例子:“如果您启动了 VisualVM,您可以选择任何 JVM 进程的 Threads 选项卡,并查看 JVM 中的实际线程

警告或重要提示如下所示。

提示和窍门是这样出现的。

一、Java11 入门

你想学习 Java,你有充分的理由。Java 是一种成熟的现代应用编程语言,广泛应用于电信、金融等行业。Java 开发人员的职位是最多的,而且可能是薪水最高的。除其他外,这使得年轻的专业人士学习这门语言有利可图。

另一方面,这并非毫无道理。Java 语言、工具以及它周围的整个基础设施都是复杂和复杂的。成为 Java 专业人员不会在一天或一周内发生;这是一项多年的工作。要成为一名 Java 专家,您不仅需要了解编程语言,还需要了解面向对象编程原则、开源库、应用服务器、网络、数据库等许多方面。然而,学习语言是绝对的最低限度。所有其他实践都是基于此。在本书中,您将学习 Java 版本 18.9,也称为 Java11,以及其他内容。您不仅要学习语言,还将学习最重要的工具,如 Maven、gradle、spring、Guice、SoapUI;HTTP/2、SOAP、REST 等协议;如何在敏捷专业团队中工作;以及团队应该使用哪些工具进行合作。在最后一章中,您甚至将学习如何规划您打算作为 Java 开发人员开始的职业生涯。

在本章中,您将介绍 Java 环境,并将逐步给出如何安装、编辑示例代码、编译和运行 Java 的说明。您将了解在开发中帮助的基本工具,无论是 Java 的一部分还是其他供应商提供的。本章将介绍以下主题:

  • Java 简介
  • 在 Windows、Linux 和 MacOS 上安装
  • 执行jshell
  • 使用其他 Java 工具
  • 使用集成开发环境

Java 入门

就像穿过森林里的小路。你可以把注意力集中在道路的碎石上,但这是毫无意义的。相反,你可以欣赏你周围的景色、树木、鸟儿和环境,这更令人愉快。这本书很相似,因为我不会只关注语言。我会不时地介绍一些接近道路的话题,并将给你一些概述和指导,你可以在你完成这本书之后去哪里。我不仅要教你语言,而且还将介绍一些算法、面向对象的编程原则、围绕 Java 开发的工具以及专业人员如何工作。这将与我们将要遵循的编码示例混合。最后,最后一章将全面讨论这个主题,接下来要学习什么,以及如何进一步成为一个专业的 Java 开发人员。

到这本书出版的时候,Java 已经完成了 22 年。在这段时间里,语言发生了很大的变化,变得更好了。真正要问的问题不是它在这里呆了多久,而是它会呆多久?这门语言还值得学吗?自从 Java 诞生以来,有许多新的语言被开发出来。这些语言更加现代,并且具有函数式编程特性,顺便说一句,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 之前,我用 C 编程了 20 年,跟踪所有内存分配情况,并且在程序不再需要时忘记释放内存是一个巨大的痛苦。忘记代码中的单个点的内存分配,而长时间运行的程序会慢慢地耗尽所有内存。这种问题在 Java 中实际上已经不存在了。我们必须为 GC 支付一个代价,它需要处理器容量和一些额外的内存,但这在大多数企业应用中我们并不缺少。一些特殊的程序,比如控制重型卡车刹车的实时嵌入式系统,可能没有那么豪华。

这些程序仍然是用汇编或 C 语言编写的。对于我们其他人来说,我们有 Java,尽管这对许多专业人士来说似乎很奇怪,但即使是几乎实时的程序,如高频交易应用,也是用 Java 编写的。

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

这也是 Java 的一个优点。有大量的开源库可用于各种不同的用途。如果您找不到适合您的问题的库,并且开始编写一些低级代码,那么您可能是做错了什么。本书中的一些主题很重要,比如类加载器或反射,不是因为你必须每天使用它们,而是因为它们被框架使用,了解它们有助于你理解这些框架是如何工作的。如果不使用反射或直接编写自己的类加载器或程序多线程就无法解决问题,那么您可能选择了错误的框架。几乎可以肯定有一个很好的例子:ApacheCommons 、Google 和软件行业的许多其他重要参与者将其 Java 库发布为开源。

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

他们设想 Java 是一种只写一次,可以在任何地方运行的语言。当时,该语言的第一个目标是浏览器中运行的 Applet。今天,许多人认为(我也同意这种观点)Applet 是一个错误的目标,或者至少事情没有以正确的方式进行。到目前为止,您在互联网上遇到小程序的频率将低于 Flash 应用或恐龙。更重要的是,applet 接口在 Java9 中已经被弃用了,这使得人们认为 Applet 并不好。

然而,与此同时,Java 解释器也在不使用任何浏览器的情况下执行服务器和客户端应用。此外,随着语言和执行环境的发展,这些应用领域变得越来越重要。如今,Java 主要用于企业计算和移动应用,主要用于 Android 平台。未来,随着物联网IoT)越来越多地进入人们的视野,环境在嵌入式系统中的应用也越来越广泛。

版本号

Java 版本控制是不断变化的。这不仅仅意味着版本号在从一个版本到另一个版本的变化。这是很明显的;毕竟,这就是版本号的意义所在。然而,在 Java 中,版本号的结构也在改变。Java 从版本 1.0 开始(惊喜!)紧接着是 1.1 版。下一个版本是 1.2,它与以前的版本有很大的不同,人们开始称它为 Java2。然后,我们使用 Java1.3 直到 Java1.8。就我们考虑版本号的结构而言,这是一个稳定的时期。然而,下一个 Java 版本在 2017 年被命名为 Java9,而不是去年的 1.9。这是有道理的,因为经过 22 年的开发和 9 次发布,版本号的1.部分并没有真正意义。没有人期待一个“真正的”Java2.0,它与任何其他版本有如此大的不同,以至于它应该有2.版本前缀。实际上,Java 版本实际上是 1、2、3 等等;它们只是被命名为 1.1、1.2、1.3 等等。

您可以预期,在版本号格式发生巨大变化之后,Java 的下一个版本将是 Java10。一点也不。甲骨文决定使用基于日期的版本号。点之前的版本号的第一部分将是两位数的年份,如 2018 年发布的版本的18。点后面的部分是月份的数字,通常是 3 月的3,9 月的9。因此,当您看到 Java 版本号 18.3 时,您马上就会知道这个版本是在 2018 年 3 月发布的,按照旧的命名法,实际上是 Java10。

安装 Java

要开发、编译和执行 Java 程序,需要 Java 执行环境。由于我们通常用于软件开发的操作系统不包含预先安装的语言,因此您必须下载它。尽管该语言有多种实现,但我建议您从 Oracle 下载该软件的正式版本。Java 的官方网站是这个页面,这是该语言最新版本的下载站点。在撰写本书时,Java 的第 11 个版本尚未发布。早期预发布版本可通过这个页面下载。稍后,还将从以下位置提供发布版本:

您可以从这里下载的是所谓的早期访问版本的代码,它只能用于实验,任何专业人士都不应将其用于商业目的。

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

对于 Windows 32 和 64 位系统、MacOS、Linux 32 和 64 位版本、Linux for ARM 处理器、Solaris for SPARC 处理器系统和 Solaris x86 版本,有一个单独的安装工具包。由于不太可能使用 Solaris,因此我将仅详细介绍 Windows、Linux 和 MacOS 的安装过程。在后面的章节中,示例将始终是 MacOS,但是由于 Java 是一种编写一次、在任何地方运行的语言,因此在安装之后没有区别。目录分隔符的倾斜方式可能不同,类路径分隔符字符在 Windows 上是分号而不是冒号,终端或命令应用的外观也不同。然而,在重要的地方,我将尽量不忘记提到它。

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

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

在 Windows 上安装

Windows 上的安装过程从双击下载的文件开始。它将启动安装程序并向您显示欢迎屏幕。Windows 10 可能会要求您具有安装 Java 的管理员权限:

按下“下一步”按钮可获得一个窗口,您可以在其中选择要安装的部件,并且,我们还可以更改 Java 将安装的位置:

让我们在这里保留默认设置,这意味着我们将安装 Java 的所有下载部分,然后按“下一步”:

当 Java 正在安装时,我们会看到一个进度屏幕。这是一个相当快的过程,不超过 10 秒。安装 Java 后,我们会看到一个确认屏幕:

我们可以按关闭键。可以按下“下一步”按钮,打开浏览器,进入一个页面,描述我们可以使用 Java 执行的下一步操作。使用预发布版本会导致 HTTP404 错误。当你读这本书的时候,这个问题有望得到解决。

最后一步是设置环境变量JAVA_HOME。为此,在 Windows 中,我们必须打开控制中心并选择“编辑帐户的环境变量”菜单:

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

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

大多数系统上的这个值是C:\Program Files\Java\jdk-11。许多 Java 程序和工具都使用它来定位 Java 运行时。

在 MacOS 上安装

在本节中,我们将逐步了解如何在 MacOS 平台上安装 Java。我将描述在编写本书时发布的版本的安装过程。到目前为止,Java18.9EarlyAccess 版本的安装有点棘手。Java18.9 的发行版很可能有与 Java9 相似或相同的安装步骤。

Java 的 MacOS 版本以.dmg文件的形式出现。这是 MacOS 的打包格式。要打开它,只需双击浏览器保存的Download文件夹中的文件,操作系统就会将该文件挂载为只读磁盘映像:

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

第一个屏幕是欢迎屏幕。单击“继续”,您将看到概览页面,其中显示将要安装的内容。

您将看到一个标准的 Java 安装,这并不奇怪。这次,这个按钮被称为“安装”。单击它,您将看到以下内容:

此时您必须为管理用户提供登录参数(用户名和密码):

提供后,安装开始,几秒钟后,您将看到摘要页面:

点击关闭,你就准备好了。你的 Mac 上安装了 Java。或者,您可以卸载安装盘,稍后还可以删除.dmg文件。您将不需要它,如果需要,您可以随时从 Oracle 下载它。

最后一件事是检查安装是否正常。吃布丁就是证据。启动一个终端窗口,在提示符处键入java -version;Java 将告诉您已安装的版本。

在下面的屏幕截图中,您可以看到 my workstation 上的输出以及便于在不同版本的 Java 之间切换的 MacOS 命令:

在前面的屏幕截图中,您可以看到我已经安装了 Java11 版本,同时,我还安装了 Java18.9 早期版本,我将用它来测试本书中 Java 的新特性。

在 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 主目录

JAVA_HOME环境变量在 Java 中起着特殊的作用。即使 JVM 可执行文件java.exejava位于PATH(因此,您可以通过键入名称java来执行它,而无需在命令提示符中指定目录)(终端),建议您使用正确的 Java 安装来设置此环境变量。变量的值应该指向已安装的 JDK。有许多与 Java 相关的程序,例如 Tomcat 或 Maven,使用这个变量来定位已安装和当前使用的 Java 版本。在 MacOS 中,设置这个变量是不可避免的。

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

~$ /usr/libexec/java_home -V
Matching Java Virtual Machines (13):
    11, x86_64: "Java SE 11-ea" /Library/Java/JavaVirtualMachines/jdk-11.jdk/Contents/Home
    10, x86_64: "Java SE 10"    /Library/Java/JavaVirtualMachines/jdk-10.jdk/Contents/Home
    9.0.1, x86_64:      "Java SE 9.0.1" /Library/Java/JavaVirtualMachines/jdk-9.0.1.jdk/Contents/Home
    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.8.0_20, x86_64:   "Java SE 8"     /Library/Java/JavaVirtualMachines/JDK1.8.0_20.jdk/Contents/Home
    1.8.0_05, x86_64:   "Java SE 8"     /Library/Java/JavaVirtualMachines/JDK1.8.0_05.jdk/Contents/Home
    1.8.0, x86_64:      "Java SE 8"     /Library/Java/JavaVirtualMachines/JDK1.8.0.jdk/Contents/Home
    1.7.0_60, x86_64:   "Java SE 7"     /Library/Java/JavaVirtualMachines/JDK1.7.0_60.jdk/Contents/Home
    1.7.0_40, x86_64:   "Java SE 7"     /Library/Java/JavaVirtualMachines/JDK1.7.0_40.jdk/Contents/Home
    1.7.0_21, x86_64:   "Java SE 7"     /Library/Java/JavaVirtualMachines/JDK1.7.0_21.jdk/Contents/Home
    1.7.0_07, x86_64:   "Java SE 7"     /Library/Java/JavaVirtualMachines/JDK1.7.0_07.jdk/Contents/Home
    1.7.0_04, x86_64:   "Java SE 7"     /Library/Java/JavaVirtualMachines/1.7.0.jdk/Contents/Home

/Library/Java/JavaVirtualMachines/jdk-11.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 11)

如果您想使用 JavaJDKEarlyAccess 版本,而不是 1.11,那么对于同样的情况没有一个解释。

注意,还有一个环境变量对 Java 很重要-CLASSPATH。我们稍后再谈。

执行 JShell

现在我们已经花了很多时间安装 Java,是时候让你的手指烧伤了。当我们使用 Java18.9 时,有一个新的工具可以帮助开发人员使用该语言。这是一个读取-求值-打印-循环REPL)工具,许多语言工具集都包含这个工具,也有来自 Java 的实现,但是版本 9 是第一个包含这个特性的现成工具。

REPL 是一个具有交互式提示和语言命令的工具,可以直接输入这些命令,而无需编辑一些独立的文件。直接执行输入的命令,然后循环再次启动,等待用户键入下一个命令。

这是一个非常有效的工具,可以在不延迟编辑、编译和加载的情况下尝试一些语言构造。这些步骤由 REPL 工具自动透明地完成。

Java18.9 中的 REPL 工具称为 JShell。要启动它,只需键入它的名称。如果它不在PATH上,则键入 Java18.9 附带的 JShell 的完整路径,如下例所示:

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

JShell 以交互方式启动,它显示的提示是jshell>,以帮助您识别 JShell 正在运行。输入的内容由程序读取,而不是由操作系统外壳读取。由于这是您第一次启动 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 the jshell tool 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
|  /drop <name or id>
|       delete a source entry
|  /save [-all|-history|-start] <file>
|       Save snippet source to a file
...

你得到的是一长串命令。这里介绍的大部分内容并不是为了节省纸张和您的注意力。在接下来的几页中,我们将使用其中的许多命令。让我们从一个小的 Java 片段开始,即永恒的 Hello World 示例:

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

这是 Java 中有史以来最短的 Hello World 程序。在 Java9 之前,如果您只想打印出Hello World!,就必须创建一个程序文件。它必须包含一个类的源代码,包括public static main方法,其中包含一行我们必须用 Java9JShell 输入的代码。仅仅对于一个简单的示例代码打印输出来说,这是很麻烦的。现在就容易多了,JShell 也很宽容。它原谅了我们在行尾缺少分号的问题。

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

jshell> var a = 13
a ==> 13

我们声明了一个名为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时,它无法处理我们迄今为止键入的内容,并且它表示它希望我们输入更多字符,因此它显示...>作为继续提示。我们输入组成整个 helloworldmain方法的命令。

但是,这次 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 : var a = 13;
   3 : System.out.println(a)
   4 : void main(String[] args){
       System.out.println("Hello, World");
       }
   5 : main(null)

另外,当我们想继续编写一个完整的 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!")
var a = 13;
System.out.println(a)
void main(String[] args){
System.out.println("Hello, World");
}
main(null)

这个文件包含我们输入的所有片段,一个接一个。如果你认为你已经用很多你不再需要的变量和代码片段弄乱了 Shell,你可以发出/reset命令:

jshell> /reset
|  Resetting state.

执行此命令后,JShell 与之前启动时一样干净:

jshell> /list

jshell>

清单并没有产生任何结果,因为我们将其全部删除。幸运的是,我们将 JShell 的状态保存到了一个文件中,我们还可以通过发出/open命令来加载该文件的内容:

jshell> /open HelloWorld.java
Hello, World!
13
Hello, World

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

您可能还记得,/list命令在每个片段前面都打印了一个数字。我们可以使用它来单独编辑代码段。为此,发出/edit命令,后跟代码段的编号:

jshell> /edit 1

您可能还记得,我们输入的第一个命令是将参数打印到控制台的System.out.println系统调用。当您在/edit 1命令后按Enter时,不会得到返回的提示。相反,JShell 会打开一个单独的图形编辑器,其中包含要编辑的代码段,如图所示:

编辑框中的文本,使其如下所示:

void printf(String format, Object... args) { System.out.printf(format, args); }
printf("Hello World!")

单击“接受”,然后单击“退出”。单击“接受”时,终端将执行代码段并显示以下结果:

| created method printf(String,Object...) Hello World!

我们使用的方法printf表示格式化打印。这可能是许多其他语言所熟知的。它最初是由 C 语言引入的,虽然它很神秘,但它的名字仍然存在。这也是标准 Java 类PrintStream的一部分,就像println。如果是println,我们必须在方法名称前面写System.out。为了避免这种情况,我们在编辑器中定义了被截取的,并为我们定义了printf方法。

JShell 还定义了一些在 JShell 启动或重置时自动加载的代码段。如果您使用-start选项发出/list命令,您可以看到这些,如下所示:

jshell> /list -start

  s1 : import java.io.*;
  s2 : import java.math.*;
  s3 : import java.net.*;
  s4 : import java.nio.file.*;
  s5 : import java.util.*;
  s6 : import java.util.concurrent.*;
  s7 : import java.util.function.*;
  s8 : import java.util.prefs.*;
  s9 : import java.util.regex.*;
 s10 : import java.util.stream.*;

这些预定义的代码片段有助于 JShell 的使用。大多数用户将导入这些类。

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

jshell> /list -all
  s1 : import java.io.*;
  s2 : import java.math.*;
  s3 : import java.net.*;
  s4 : import java.nio.file.*;
  s5 : import java.util.*;
  s6 : import java.util.concurrent.*;
  s7 : import java.util.function.*;
  s8 : import java.util.prefs.*;
  s9 : import java.util.regex.*;
 s10 : import java.util.stream.*;
   1 : System.out.println("Hello, World!")
   2 : var a = 13;
   3 : System.out.println(a)
   4 : void main(String[] args){
       System.out.println("Hello, World");
       }
   5 : main(null)
   6 : void printf(String format, Object... args) { System.out.printf(format, args); }
   7 : System.out.println("Hello, World!");

预加载的行用s前缀编号。包含错误的代码段有一个前缀为e的数字。(此打印输出中没有。)

如果要再次执行某些代码段,只需键入/n,其中n是代码段的编号,如下所示:

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

不能重新执行预加载的代码段或包含错误的代码段。无论如何,这些都没有必要。预加载的代码段声明了一些导入;错误的代码段不会执行,因为它们是错误的。

当您想重新执行一个代码段时,不需要依赖 JShell 的数量。当 JShell 会话中已经有很多代码段时,将它们全部列出会太麻烦;有一个快捷方式可以重新执行最后n个代码段。你必须写/-n。这里,n是从最后一个开始计算的片段数。因此,如果要执行最后一个代码段,就必须编写/-1。如果要执行上一个之前的一个,必须写入/-2。请注意,如果您已经键入了/-1,那么最后一个是最后一个代码段的重新执行,代码段编号-2将成为编号-3

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

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

jshell> /vars
|    int a = 13

如果我们只想看到类,/types命令将执行以下操作:

jshell> class s {}
|  created class s

jshell> /types
|    class s

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

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

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

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

  • main:该程序的主要类
  • printf:这个,我们在使用编辑器的时候定义的

如果您想查看您键入的所有内容,则必须对您键入的所有代码段和命令发出/history命令。(我不会在这里复制输出;我不想让自己羞愧地展示我所有的打字错误和失败。你应该试试自己,看看自己的历史!)

回想一下,我们可以通过发出/reset命令来删除所有代码段。也可以单独删除代码段。为此,您应该发出/drop n命令,其中n是截取的编号:

jshell> /drop 1

jshell> /list

   2 : var a = 13;
   3 : System.out.println(a)
   4 : void main(String[] args){
       System.out.println("Hello, World");
       }
   5 : main(null)
   6 : void printf(String format, Object... args) { System.out.printf(format, args); }
   7 : System.out.println("Hello, World!");
   8 : 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 打印并等待输入的提示、连续行的提示以及每个命令之后的消息详细信息。有预定义的模式,如下所示:

  • normal
  • silent
  • concise
  • verbose

默认情况下选择normal。如果您发出/set feedback silent,提示变为->,JShell 将不打印有关命令的详细信息。/set feedback concise代码打印更多信息,/set feedback verbose打印执行命令的详细信息:

jshell> /set feedback verbose
|  Feedback mode: verbose

jshell> int z = 13
z ==> 13
|  created variable z : int

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方法的声明,并在其周围插入了类的声明。

在 Java 中,不能像在许多其他语言中那样拥有独立的方法或函数。每个方法都属于某个类,每个类都应该在一个单独的文件中声明(好吧,差不多,但现在,让我们跳过异常)。文件名必须与类名相同。编译器对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,重点讨论其中的main方法。该方法不返回任何值,因此返回值为void。参数,名为args,是一个字符串数组。当 JVM 启动main方法时,它将命令行参数传递给这个数组中的程序。然而,这次我们没有用。main方法包含打印出Hello World的行。现在,让我们再检查一下这条线。

在其他语言中,将内容打印到控制台只需要一个print语句,或者一个非常类似的命令。我记得有些初级解释器甚至允许我们输入?而不是print,因为在屏幕上打印是很常见的。这在过去的 40 年里已经发生了很大的变化。我们使用图形屏幕、互联网和许多其他输入和输出通道。现在,在控制台上写东西已经不是很常见了。

通常,在专业的大型企业应用中,甚至没有一行可以做到这一点。相反,我们将文本定向到日志文件,将消息发送到消息队列,并通过 TCP/IP 协议发送请求和响应。由于这是如此不经常使用,没有理由创造一个快捷方式的目的,在语言。在最初的几个程序之后,当您熟悉了调试器和日志记录的可能性之后,您将不会自己将任何内容直接打印到控制台。

尽管如此,Java 仍然有一些特性,可以让您直接将文本发送到进程的标准输出,就像它最初是为 UNIX 发明的那样。这是以 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 系统中是:(冒号),在 Windows 系统中是;(分号)。在我们的例子中,类路径是实际的目录,因为那是 Java 编译器创建HelloWorld.class的地方。如果不在命令行中指定类路径,Java 将使用当前目录作为默认目录。这就是为什么我们的程序在没有-cp选项的情况下运行的原因。

javajavac都处理许多选项。要获取选项列表,请键入javac -helpjava -help。我们使用 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[]);
}

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

与类同名的方法是类的构造器。由于 Java 中的每个类都可以实例化,因此需要一个构造器。如果我们不给出一个,Java 编译器将为我们创建一个。这是默认构造器。默认构造器不执行任何特殊操作,但返回类的新实例。如果我们自己提供一个构造器,Java 编译器就不会费心去创建一个。

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

$ 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 或其他您有许可证的 ZIP 管理器打开这些文件中的任何一个。额外的要求是,例如,在 JAR 文件的情况下,归档文件应该包含一个名为META-INF的目录和一个名为MANIFEST.MF的文件。此文件为文本文件,包含格式如下的元信息:

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

文件中可能有很多其他信息,但这是 Java 提供的工具jar在将类文件打包到一个 JAR 中时,发出以下命令的最低限度:

jar -cf hello.jar HelloWorld.class

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

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

不能将单个类放在类路径上,只能放在目录上。由于 JAR 文件是带有内部目录结构的归档文件,它们的行为就像一个目录。

检查 JAR 文件是使用ls hello.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循环。循环允许重复执行代码块,我们将在第 2 章“第一个真正的 Java 程序——排序名称”讨论。我们在这里创建的循环是一个特殊的循环,它从不终止,而是重复打印方法调用,打印Hello World,直到在 Linux 或 MacOSX 上按Ctrl + C或发出kill命令终止程序,或者在 Windows 下的任务管理器中终止程序。

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

我们首先要熟悉的命令是jps。为了更熟悉jps您可以在这里阅读一些内容,列出了机器上运行的 Java 进程,如下所示:

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

您可以看到有两个进程:一个是我们执行的程序,另一个是jps程序本身。毫不奇怪,jps工具也是用 Java 编写的。您还可以将选项传递给jps,这些选项记录在 Web 上。

还有许多其他工具,我们将研究其中一个,它是一个非常强大且易于使用的工具 Java VisualVM:

VisualVM 是一个命令行图形工具,它连接到正在运行的 Java 进程并显示不同的性能参数。要启动 VisualVM 工具,您将发出不带任何参数的jvisualvm命令。很快,就会出现一个窗口,左侧是一棵探索树,右侧是一个欢迎窗格。左侧显示了名为 Local 的分支下所有正在运行的 Java 进程。如果您双击HelloWorldLoop,它将在右侧窗格中打开流程的详细信息。在 header 选项卡上,可以选择 Overview、Monitor、Threads、Sampler 和 Profiler。前三个选项卡是最重要的,它可以让您很好地了解 JVM 中的线程数、CPU 使用率、内存消耗等情况。

使用 IDE

集成开发环境是优秀的工具,它通过将机械任务从开发人员的肩上卸下来来帮助开发。当我们键入代码时,它们可以识别许多编程错误,帮助我们找到所需的库方法,显示库的文档,并为样式检查、调试等提供额外的工具。

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

要获得 IDE,您必须下载并安装它。它不随 Java 开发工具一起提供,因为它们不是语言环境的一部分。不过,别担心,它们可以免费下载,安装也很简单。它们可能比记事本编辑器更复杂,但即使工作了几个小时,它们也会回报你花在学习上的时间。毕竟,并不是没有理由没有开发人员用记事本或 vi 编写 Java 代码。

最上面的三个 IDE 是* NetBeansEclipse* 和 IntelliJ。所有这些都可以在社区版本,这意味着你不需要支付他们。IntelliJ 有一个完整的版本,您也可以购买。社区版将用于学习语言。如果您不喜欢 IntelliJ,可以使用 Eclipse 或 NetBeans。这些都是免费的。就我个人而言,我的大多数项目都使用 IntelliJ 社区版,本书中显示 IDE 的屏幕示例将以该 IDE 为特色。然而,这并不一定意味着你必须坚持这个 IDE。

在开发人员社区中,有些话题可以引起激烈的争论。这些话题是关于意见的。如果他们讨论的是事实,辩论很快就会结束。其中一个主题是“哪一个是最好的 IDE?”。这是品味的问题。没有确切的答案。如果你学会如何使用一个,你会喜欢的,你会不愿意学习另一个,除非你看到另一个更好。这就是为什么开发人员喜欢他们使用的 IDE(或者只是讨厌,这取决于他们的个性),但是他们一直使用同一个 IDE,通常是很长一段时间。没有最好的 IDE。

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

NetBeans

NetBeans 是由 Oracle 支持的,并且是不断开发的。它包含一些组件,如 NetBeans profiler,这些组件已成为 OracleJava 发行版的一部分。您可能会注意到,当您启动 visualvm 并启动评测时,Java 启动进程的名称中有netbeans

一般来说,NetBeans 是一个开发富客户端应用的框架,IDE 只是构建在该框架之上的众多应用中的一个。它支持多种语言,不仅仅是 Java。您可以使用 NetBeans 开发 PHP、C 或 JavaScript 代码,并为 Java 提供类似的服务。为了支持不同的语言,您可以下载插件或 NetBeans 的特殊版本。这些特殊版本可以从 IDE 的下载页面获得,它们只不过是带有一些预配置插件的基本 IDE。在 C 包中,开发人员配置开发 C 所需的插件;在 PHP 版本中,开发人员配置 PHP。

Eclipse

IBM 支持 Eclipse。与 NetBeans 类似,它也是一个富客户端应用平台,它是围绕 OSGi 容器架构构建的,而这个架构本身就是一个可以填满这样一本书的主题。大多数开发人员都使用 Eclipse,而且几乎完全是这样,当开发人员为 ibmwebsphere 应用服务器创建代码时,可以选择 Eclipse。Eclipse 特殊版本包含 WebSphere 的开发人员版本。

Eclipse 还具有支持不同编程语言的插件,并且具有类似于 NetBeans 的不同变体。这些变体是用基本 IDE 预先打包的插件。

IntelliJ

前面枚举中的最后一个是 IntelliJ。这个 IDE 是唯一一个不想成为框架的 IDE。IntelliJ 是一个 IDE。它也有插件,但是您需要下载以在 NetBeans 或 Eclipse 中使用的大多数插件都是预先配置的。当你想使用一些更高级的插件时,它可能是你必须付费的,这在你从事专业的有偿工作时应该不是问题,对吗?这些东西没那么贵。要学习本书中的主题,您不需要任何社区版以外的插件。在本书中,我将使用 IntelliJ 开发示例,我建议您在学习过程中遵循我的建议。

我想强调的是,本书中的示例独立于要使用的实际 IDE。您可以使用 NetBeans、Eclipse 甚至 Emacs、notepad 或 vi 来阅读本书。

IDE 服务

集成开发环境为我们提供服务。最基本的服务是您可以用它们编辑文件,但它们也可以帮助构建代码、查找 bug、运行代码、以开发模式部署到应用服务器、调试等等。在下面的部分中,我们将研究这些特性。关于如何使用一个或另一个 IDE,我将不作确切的介绍。像这样的书对这样的教程来说不是一个好的媒介。

IDE 在菜单位置、键盘快捷键上有所不同,甚至可能随着新版本的发布而改变。最好看一下实际的 IDE 教程视频或在线帮助。另一方面,它们的特征非常相似。IntelliJ 在这个页面有视频文档。

IDE 屏幕结构

不同的 IDE 看起来很相似,它们的屏幕结构也差不多。在下面的屏幕截图中,您可以看到 IntelliJ IDE:

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

在右边,你可以看到文件。在前面的截图中,我们只打开了一个文件。如果打开了多个文件,则会有选项卡,每个文件有一个选项卡。现在,活动文件为HelloWorld.java,可以在源代码编辑器中编辑。

编辑文件

编辑时,您可以键入字符或删除字符、单词和行,但这是所有编辑器都可以做的事情。IDE 提供了额外的功能,它们分析源代码并对其进行格式化,从而自动缩进行。它还会在您编辑代码时在后台不断编译代码,如果有语法错误,它会用红色的放弃线来强调这一点。修复错误时,红色下划线将消失:

编辑器还会自动为您键入的其他字符提供建议。您可以忽略弹出的窗口并继续键入。但很多时候,在按Enter键之前,更容易在一个字符后停下来,用上下箭头选择需要完成的单词;该单词会自动插入到源代码中。

在前面的截图中,你可以看到我写了System.o,编辑马上建议我写out。其他替代方法是System类中包含字母o的其他静态字段和方法。

IDE 编辑器不仅可以为您输入提示,而且可以为您输入提示。在下面的屏幕截图中,IDE 告诉您键入一些表达式作为println()方法的参数,即booleancharint等等。IDE 完全不知道在那里输入什么。你必须构造表达式。不过,它可以告诉你它需要某种类型:

编辑不仅知道内置类型。与 JDK 集成的编辑器不断地扫描源文件,并知道源代码中有哪些类、方法和字段,以及哪些类、方法和字段在编辑时可用。

当您想重命名方法或变量时,也会大量使用此知识。旧方法是重命名源文件中的字段或方法,然后对变量的所有引用进行彻底搜索。使用 IDE,机械工作由它完成。它知道字段或方法的所有用途,并自动用新标识符替换旧标识符。它还可以识别本地变量是否恰好与我们重命名的变量同名,IDE 仅重命名那些真正指的事件,我们正在重命名。

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

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

库为public方法提供 Javadoc 文档,您还应该为自己的方法编写 Javadoc。Javadoc 文档是从源代码中的特殊注释中提取出来的,我们将在第 4 章、“策划人——创建游戏”中学习如何创建这些文档。它们位于实际方法头前面的注释中。由于创建编译文档是编译流的一部分,IDE 也知道文档,当您将光标定位到元素上时,它会显示为方法名、类名或任何要在源文件中使用的元素上方的悬停框。

管理项目

在 IDE 窗口的左侧,您可以看到项目的目录结构。IDE 了解不同类型的文件,并以编程的角度显示它们的方式。例如,它不显示Main.java作为文件名。相反,它显示Main和一个图标,表示Main是一个类。它也可以是一个仍然在名为Main.java的文件中的接口,但是在这种情况下,图标将显示这是一个接口。IDE 继续扫描和编译代码,这一点再次实现。

当我们开发 Java 代码时,这些文件被构造成子目录。这些子目录遵循代码的打包结构。很多时候,在 Java 中,我们使用复合的和长的包名,而将其显示为一个深度嵌套的目录结构将不那么容易处理。

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

IDE 能够显示包结构,而不是包含源文件的项目目录的嵌套目录:

当您将类或接口从一个包移动到另一个包时,它的发生方式与重命名或任何其他重构操作的发生方式类似。源文件中对类或接口的所有引用都将重命名为新包。如果文件包含引用该类的import语句,则该语句中的类名称将被更正。要移动一个类,可以打开包并使用旧的拖放技术。

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

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

构建代码并运行它

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

下面是 Maven 的一个例子:

IDE 准备使用这样一个外部工具,它们可以帮助我们启动它们。这样,构建过程可以在开发人员机器上运行,而不需要启动新的 Shell 窗口。IDE 还可以从这些外部构建工具的配置文件导入设置,以识别项目结构、源文件所在位置以及在编辑时支持错误检查的编译内容。

构建过程通常包含对代码执行某些检查。一堆 Java 源文件可以编译得很好,很流畅。尽管如此,代码可能包含很多 bug,并且可能以糟糕的风格编写。从长远来看,这些东西使这个项目无法维持。为了避免这些问题,我们将使用单元测试和静态代码分析工具。这些并不能保证无错误的代码,但可能性要小得多。

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开始;否则,直到调试器连接到服务器,服务器才会启动。在suspend=y模式下启动 Java 进程,以防 Servlet 应用只在您想要调试 Servlet 静态初始化器代码时才有用,该代码是在服务器启动时执行的。如果没有挂起模式,则需要快速附加调试器。在这种情况下,JVM 最好只是等待您。
  • address:应该指定 JVM 与之通信的地址。如果 JVM 以客户端模式启动,它将开始连接到此地址。如果 JVM 在服务器模式下运行,它将接受来自该地址上调试器的连接。地址只能指定端口。在这种情况下,IP 地址是本地机器的 IP 地址。

调试器代理可能处理的其他选项用于特殊情况。对于本书涵盖的主题,前面的选项就足够了。

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

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

总结

在本章中,我们互相介绍了 Java。我们不太了解对方,但我们是认识的。我们安装了 Java 环境:Java、JDK 和集成开发环境。我们编写了一个小程序,简要介绍了使用开发工具可以做些什么。这远非精通,但即使是最长的旅程也要从第一步开始,这一步有时是最难迈出的。我们已经在 Java 之旅中做到了这一点。我们开始滚动,对于我们这样的狂热者来说,没有什么能阻止我们一路前行。

二、第一个真正的 Java 程序-排序名称

在上一章中,我们熟悉了 Java,特别是使用 REPL 工具和交互式地执行一些简单代码。这是一个好的开始,但我们需要更多。在本章中,我们将开发一个简单的程序。以这段代码为例,我们将研究 Java 项目中常用的不同构建工具,并学习 Java 语言的基本特性。

本章将涵盖以下主题:

  • 排序问题
  • 项目结构和构建工具
  • Make、Ant、Maven 和 Gradle 构建工具
  • Java 语言相关功能的代码示例

排序入门

排序问题是工程师处理的最古老的编程任务之一。我们有一套记录,我们知道我们想尽快找到一个具体的。为了找到它,我们按照特定的顺序对记录进行排序,以帮助我们快速找到所需的记录。

例如,我们有学生的名字,他们的标记在一些卡片上。当学生们来到院长的小屋要求成绩时,我们一张接一张地查看所有卡片,找到询问学生的姓名。然而,如果我们按学生的名字按字母顺序排列卡片,那就更好了。当学生进行查询时,我们可以更快地搜索附加在名字上的标记。

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

按照这个方法,我们可以通过几个步骤找到学生的名字。步数不能超过牌包减半的次数。如果我们有两张牌,那么最多是两步。如果是四步,那么我们最多需要三步。如果有八张牌,那么我们可能需要四个步骤,但不能更多。如果有 1000 张卡片,那么我们可能最多需要 11 个步骤,而原始的,未排序的一组将需要 1000 个步骤,作为最坏的情况。也就是说,它大约将搜索速度提高了 100 倍,因此这是值得对卡片进行排序的,除非排序本身花费太多时间。在我们刚才描述的已经排序的集合中查找元素的算法称为二分搜索

在许多情况下,对数据集进行排序是值得的,有许多排序算法可以做到这一点。有更简单和更复杂的算法,在许多情况下,更复杂的算法运行得更快。

由于我们关注的是 Java 编程部分,而不是算法锻造,因此在本章中,我们将开发一个 Java 代码来实现一个简单而不是那么快的算法。

冒泡排序

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

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

项目结构和构建工具入门

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

使用发出javac命令的命令行不可能编译复杂的程序。如果我们有 100 个 Java 源文件,编译将需要发出许多javac命令。它可以使用通配符来缩短,比如javac *.java,或者我们可以编写一个简单的 bash 脚本或一个 BAT 命令文件来实现这一点。首先,它将只有 100 行,每行编译一个源 Java 文件到类文件。然后,我们会意识到这是编译自上次编译以来没有更改的文件所消耗的 CPU 和电源的唯一时间,因此我们可以添加一些 bash 编程来检查源代码和生成的文件的时间戳。最后,我们将得到一个基本上是构建工具的工具。构建工具是现成的;不值得重新设计轮子。

我们将使用一个准备好的构建工具,而不是创建一个。在这个页面可以找到一些软件。在本章中,我们将使用一个名为 Maven 的工具;但是,在深入讨论这个工具的细节之前,我们将研究一些其他工具,您可能会在企业项目中作为 Java 专业人员遇到这些工具。

在接下来的部分中,我们将讨论以下四种构建工具:

  • Make
  • Ant
  • Maven
  • Gradle

我们将简要地提到 Make,因为它现在不在 Java 环境中使用。然而,Make 是第一个构建工具,现代 Java 构建工具所基于的许多思想都来自于古老的make。作为一名专业的 Java 开发人员,您还应该熟悉 Make,这样当您碰巧看到 Make 在某个项目中用于某种目的时,您就不会惊慌失措,并且可以知道它是什么以及在哪里可以找到它的详细文档。

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

Maven 比 Ant 更新,它使用了不同的方法。我们将详细地看一下。Maven 也是 Apache 软件基金会的 Java 项目的官方构建工具。我们也将在本章中使用 Maven 作为构建工具。

Gradle 甚至比 Maven 更新,最近它已经开始赶上 Maven 了。我们将在本书后面的章节中更详细地使用这个工具。

Make

make程序最初创建于 1976 年 4 月,因此这不是一个新工具。它包含在 Unix 系统中,因此此工具在 Linux、MacOSX 或任何其他基于 Unix 的系统上都不需要额外安装。另外,这个工具在 Windows 上有许多端口,VisualStudio 编译器工具集中包含了一些版本。

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存在且年龄大于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 制造的。当然,就任何 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 可从其官方网站下载。您可以下载源或预编译版本。最简单的方法是以tar.gz格式下载二进制文件。

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

当我以tar.gz格式下载 ApacheAnt1.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 摘要的工具。微软提供了一个工具,叫做文件完整性校验和验证工具,可以在这个页面上找到。如果您使用 Linux,可能会发生未安装md5md5sum工具的情况。在这种情况下,您可以使用apt-get命令或 Linux 发行版支持的任何安装工具来安装它。

下载文件后,可以使用以下命令将其分解为子目录:

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

创建的子目录是 Ant 的可用二进制分布。通常我会把它移到~/bin下,只让我在 OSX 上的用户可以使用,然后把环境变量设为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:当命令行上没有定义目标时,命名要使用的目标
  • basedir:定义生成文件中其他目录名计算的初始目录

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

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

您还可以向 Ant 在使用-projecthelp命令行选项时打印的目标添加一个description属性。这有助于构建文件的用户知道存在哪些目标,哪些目标做什么。构建文件往往会随着许多目标而变大,当您有 10 个或更多目标时,很难记住每个目标。

HelloWorld.java样本项目现安排在以下目录中:

  • build.xml:存在于项目的root文件夹中
  • HelloWorld.java:存在于项目的src文件夹中
  • build/:此文件夹不存在,将在生成过程中创建
  • build/classesbuild/jar:这些还不存在,将在构建过程中创建

当您第一次启动HelloWorld项目的构建时,您将看到以下输出:

$ ant
Buildfile: ~/java_11-fundamentalssources/ch02/build.xml

dirs:
    [mkdir] Created dir:
~/java_11-fundamentalssources/ch02/build/classes
    [mkdir] Created dir:
~/java_11-fundamentalssources/ch02/build/jar

compile:
...
    [javac] Compiling 1 source file to
~/java_11-fundamentalssources/ch02/build/classes

jar:
      [jar] Building jar:
~/java_11-fundamentalssources/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目录中?如果项目是 polyglot,还会有一些 Groovy 或其他编程语言文件吗?那要看情况了。同样,有些团体或公司文化可能会提出一些惯例,但没有一般的最佳行业实践。

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

开发人员意识到,使用 Ant 的项目的很大一部分精力都集中在项目构建工具配置上,包括重复性任务。当一个新手加入团队时,他们首先要学习如何配置构建。如果启动了新项目,则必须创建生成配置。如果这是一个重复的任务,那么最好让电脑来做。这通常就是编程的意义所在,不是吗?

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

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

如果你想把你的源文件放在别的地方,那就不要。我是认真的。这是可能的,但我甚至不告诉你怎么做。没人会这么做。我甚至不知道为什么 Maven 能做到这一点。每当您看到一个使用 Maven 作为构建工具的项目时,源代码都是这样组织的。不需要理解项目的构建工程师所设想的目录结构。总是一样的。

目标和任务如何?对于所有基于 Maven 的项目,它们也是相同的。除了编译、测试、打包或部署 Java 项目之外,您还想对它做些什么?Maven 为我们定义了这些项目生命周期。当您想使用 Maven 作为构建工具来编译项目时,您必须键入$ mvn compile来编译项目。你甚至可以在了解项目的实际情况之前就这么做。

由于我们有相同的目录结构和相同的目标,导致目标的实际任务也都是相同的。当我们创建一个 Maven 项目时,我们不必描述构建过程必须做什么以及它必须如何做。我们将不得不描述该项目,只有部分是具体项目。

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

安装 Maven

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

我不能不提请您注意使用校验和检查下载完整性的重要性。我在“安装 Ant”一节中详细介绍了该方法。

下载文件后,可以使用以下命令将其分解为子目录:

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

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

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

然后,重新启动终端应用,或者只需键入. ~/.bashrc并测试 Maven 的安装,如下所示:

$ mvn -v
Apache Maven 3.5.4 (1edded0938998edf8bf061f1ceb3cfdeccf443fe; 2018-06-17T20:33:14+02:00)
Maven home: /Users/verhasp/bin/apache-maven-3.5.4
Java version: 11-ea, vendor: Oracle Corporation, runtime: /Library/Java/JavaVirtualMachines/jdk-11.jdk/Contents/Home
Default locale: en_HU, platform encoding: UTF-8
OS name: "mac os x", version: "10.13.6", arch: "x86_64", family: "mac" 

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

使用 Maven

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

$ mvn archetype:generate

Maven 将首先从网络上下载实际可用的项目类型,并提示您选择要使用的项目类型。这种方法似乎是一个好主意,而 Maven 是新的。当我第一次启动 Maven 时,列出的项目数量大约在 10 到 20 个之间。今天,我在写这本书的时候,列出了 1635 种不同的原型。这个数字似乎更像是一个历史日期(法国科学院的章程),而不是不同原型的可用大小列表。但是,不要惊慌失措。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.java11.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

语义版本控制,定义于这个页面是一种版本控制方案,建议主要次要补丁版本号使用三位版本号M.M.p。这对库非常有用。如果自上一版本以来只有一个 bug 修复,那么您将增加最后一个版本号。当新版本还包含新功能,但库与以前的版本兼容时,您将增加次要数字;换句话说,任何使用旧版本的程序仍然可以使用新版本。当新版本与前一版本有显著差异时,主要版本号会增加。在应用的情况下,没有使用应用 API 的代码;因此,次要版本号没有那么重要。不过,这并没有什么坏处,而且事实证明,在应用中发出较小变化的信号通常是有用的。我们将在最后一章讨论如何对软件进行版本化。

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

Define value for property 'package':  packt.java11.example: :

程序框架生成的最后一个问题是 Java 包的名称。默认值是我们为groupId提供的值,我们将使用它。使用其他东西是一个罕见的例外。

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

Confirm properties configuration:
groupId: packt.java11.example
artifactId: SortTutorial
version: 1.0.0-SNAPSHOT
package: packt.java11.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.java11.example
[INFO] Parameter: groupId, Value: packt.java11.example
[INFO] Parameter: artifactId, Value: SortTutorial
[INFO] Parameter: packageName, Value: packt.java11.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/java11/example/App.java:这包含一个HelloWorld示例应用
  • SortTutorial/src/test/java/packt/java11/example/AppTest.java:它包含一个利用junit4库的单元测试框架

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

$ mvn package

我们将得到以下输出:

Maven 自动启动、编译和打包项目。如果没有,请阅读下一个信息框。

当您第一次启动 Maven 时,它会从中央存储库下载很多依赖项。这些下载需要时间,时间值会在屏幕上报告,并且这些值对于不同的运行可能不同。实际输出可能与您在前面代码中看到的不同。Maven 使用 Java 版本 1.5 的默认设置编译代码。这意味着生成的类文件与 Java1.5 版本兼容,而且编译器只接受 Java1.5 中已有的语言结构。后来的 Maven 编译器插件版本将此行为更改为使用 1.6 作为默认版本。如果我们想使用较新的语言特性,并且在本书中,我们使用了很多这些特性,pom.xml文件应该被编辑为包含以下行:

<build>
    <plugins>
      <plugin>
        <groupId>org.apache.maven.plugins</groupId>
        <artifactId>maven-compiler-plugin</artifactId>
        <version>3.8.0</version>
        <configuration>
            <source>1.11</source>
            <target>1.11</target>
            <release>11</release>
        </configuration>
      </plugin>
    </plugins>
  </build>

当使用 Java11 对 Maven 的默认设置时,它变得更加复杂,因为 Java9 和更高版本不生成类格式,也不限制早于 Java1.6 的源代码兼容性。这就是编译器插件更改其默认行为的原因。

现在,可以使用以下命令启动代码:

$ java -cp target/SortTutorial-1.0.0-SNAPSHOT.jar packt.java11.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,您必须从网站载编译的二进制文件。

再次强调使用校验和检查下载完整性的重要性。我已经在 Ant 安装一节中给出了一个详细的方法。不幸的是,Gradle 网站没有提供可下载文件的校验和值。

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

$ unzip gradle-4.9-bin.zip

创建的子目录是 Gradle 的可用二进制分布。通常,我会把它移到~/bin下,使它只对我在 OSX 上的用户可用。之后,您应该将安装的bin目录添加到PATH中。

为此,您应该编辑~/.bashrc文件并添加以下行:

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

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

$ gradle -version

我们将得到以下输出:

使用 Maven 建立项目

为了启动项目,我们将使用目录结构和pom.xml,它是由 Maven 自己创建的,当我们使用以下命令行启动时:

$ mvn archetype:generate

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

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

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

package packt.java11.example.stringsort;

public class Sort {
}

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

package packt.java11.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()方法来启动代码。我们将在后面的章节中使用测试框架。

目前,排序代码如下所示:

var n = names.length;
while (n > 1) {
    for (var j = 0; j < n - 1; j++) {
        if (names[j].compareTo(names[j + 1]) > 0) {
            final var 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 命令行启动容器,容器在需要时加载 Servlet。

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

package packt.java11.example.stringsort;

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

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

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

目前,我们将重点关注简单的解决方案,以避免由于大量不同的库和工具而分散您对 Java 的关注。在接下来的部分中,我们将介绍我们在编码算法时使用的 Java 语言构造。首先,我们将一般地看它们,然后,在更详细的地方。这些语言特性彼此不独立,因此,解释首先是一般性的,我们将在下面的小节中详细介绍。

理解算法和语言结构

在本章的开头对算法进行了说明。实现在sort()方法内Sort类中,仅由几行组成:

var n = names.length;
while (n > 1) {
    for (var j = 0; j < n - 1; j++) {
        if (names[j].compareTo(names[j + 1]) > 0) {
            final var 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类型。声明变量时,变量的类型写在变量名前面。当局部变量在声明它的行上获得初始值时,可以使用名为var的特殊保留类型。它表示与赋值运算符右侧表达式的类型完全相同的类型。

代码的外观如下:

var n = names.length;

也可以这样写:

int n = names.length;

这是因为表达式names.length具有int类型。此功能称为局部变量类型推断,因为类型是从右侧推断的。如果变量不是某个方法的局部变量,则不能使用此选项。

当我们声明一个字段(一个在类的方法体之外的类级别上的变量,而不是在初始化器块或构造器中)时,我们必须指定我们想要的变量的确切类型。

变量也具有可见性范围。方法中的局部变量只能在定义它们的块内使用。变量可以在方法内部使用,也可以属于类或对象。为了区分两者,我们通常称之为变量字段。

类型

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

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

floatdouble类型以 IEEE754 浮点格式存储 32 位和 64 位的浮点数。

boolean类型是一个原始类型,只能是truefalse

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

对于每个原始类型,都有一个对应的类。类的实例可以存储相同类型的值。当一个原始类型必须转换为匹配的类类型时,它是自动补全的。它被称为自动装箱。这些类型是ByteShortIntegerLongFloatDoubleBooleanCharacter。以以下变量声明为例:

Integer a = 113;

这将值113(即int数字)转换为Integer对象。

这些类型是运行时的一部分,也是语言的一部分。

有一个特殊的类,叫String。此类型的对象包含字符。String没有原始对应物,但我们使用它很多次,就像是原始类型,它不是。它在 Java 程序中无处不在,并且有一些语言构造,例如直接与这种类型一起工作的字符串连接。

原始类型和对象之间的主要区别在于原始类型不能用来调用它们的方法。它们只是值。当我们创建并发程序时,它们不能用作锁。另一方面,它们消耗更少的内存。内存消耗与其对速度的影响之间的差异非常重要,尤其是当我们有一个值数组时。

数组

根据它们的声明,变量可以是原始类型,也可以包含对对象的引用。一种特殊的对象类型是数组。当一个变量持有一个数组的引用时,它可以用[]字符以及一个由 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++ 语言的操作符。具体如下:

  • 一元前缀和后缀递增运算符(--++在变量前后)
  • 一元符号(+-运算符)
  • 逻辑(!)和位(~)取反
  • 乘法(*)、除法(/)和模(%
  • 加减法(再次是+-,但这次是二进制运算符)
  • 移位运算符按位移动值,有左移位(<<)、右移位(>>)和无符号右移位(>>>
  • 比较运算符为产生boolean值的<><=>===!=instanceof
  • 有位或(|)和(&)、异或(^)运算符,以及类似的逻辑或(||)和(&&)运算符

对逻辑运算符求值时,将对其进行快捷方式求值。这意味着,只有在无法从左操作数的结果中识别结果时,才对右操作数求值。

三元运算符也类似于 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");

该代码使用通过System类中的静态字段随时可用的PrintStream类实例。这个变量叫做out,当我们编写代码时,我们必须引用它为System.outprintln方法是在PrintStream类中定义的,我们在out变量引用的对象上调用它。这个例子还显示静态字段也可以通过类的名称和用点分隔的字段来引用。类似地,当我们需要引用非静态字段时,我们可以通过类的实例来实现。

在同一个类中定义的静态方法,可以从调用它或者继承的地方,在没有类名的情况下调用。调用在同一类中定义的或被继承的非静态方法可以在没有显式实例表示法的情况下调用。在本例中,实例是执行所在的当前对象。这个对象也可以通过this关键字获得。类似地,当我们使用代码所在的同一类的字段时,我们只使用名称。对于静态字段,我们所在的类是默认的。对于非静态字段,实例是由this关键字引用的对象。

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

方法调用的参数用逗号分隔。方法和方法参数传递是我们稍后将讨论的一个重要主题。

循环

让我们再次看看字符串排序的代码。while循环中的for循环将遍历从第一个元素(在 Java 中用零索引)到最后一个元素(用n-1索引)的所有元素。一般来说,这个for循环与 C 中的语法相同:

for( initial expression ; condition ; increment expression )
  block

首先,计算初始表达式。它可能包含变量声明,如我们的示例所示。前例中的j变量仅在循环块内可见。之后,将求值条件,在执行块之后,执行增量表达式。只要条件为真,循环就会重复。如果在执行初始表达式之后条件为false,则循环根本不会执行。该块是一个用分号分隔的命令列表,并在{}字符之间封闭。

封闭块 Java 代替了{},它允许您在for循环头之后使用单个命令。在while循环的情况下也是如此,对于if...else构造也是如此。实践表明,这不是专业人士应该使用的。专业代码总是使用大括号,即使只有一个命令块在适当的位置。这就避免了悬空else问题,通常使代码更具可读性。这类似于许多 C 语言。它们中的大多数都允许在这些地方使用单个命令,而专业程序员为了可读性的目的避免在这些语言中使用单个命令。讽刺的是,在这些地方,唯一严格要求使用{}大括号的语言是 Perl—一种因代码不可读而臭名昭著的语言。

for (var 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 就会阻止你。它遵循同样应该在代码中使用的快速失败方法。如果出了问题,程序就会失败。任何代码都不应该试图忍受或克服来自编码错误的错误。在编码错误造成更大的损害之前,应该先修复它们。

Java 中还有另外两个循环构造,while循环和do循环。下面的示例包含一个while循环。只要数组中至少有两个元素可能需要交换,就可以运行外部循环:

while (n > 1) {

while循环的一般语法和语义非常简单,如下代码所示:

while ( condition ) block

只要条件是true,就重复执行该块。如果条件在循环的最开始不是真的,那么根本不执行块。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

代码结构的含义非常简单。如果条件为true,则执行第一块,否则执行第二块。else关键字和第二个块是可选的。创建else并在其后面创建一个块是可选的。如果条件为false时没有要执行的内容,那么我们就不创建else部分。如果用j索引的数组元素在排序顺序上晚于元素j+1,那么我们交换它们;但是,如果它们已经在排序中,则与它们无关。

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

注意,这次我们使用显式类型String来声明变量。我们可以用varfinal var来代替,这样就可以推断出相同的类型。这里使用显式类型的唯一原因是为了演示。

最终变量

在我们的例子中,tmp是一个final局部变量。这个变量的作用域被限制在if语句后面的块中,在这个块中,这个变量只得到一个值。该块在代码执行期间执行多次,每次变量进入作用域时,它都会得到一个值。但是,此值不能在块内更改,并且在块外不存在。这可能有点混乱。您可以将其视为每次执行块时都有一个新的tmp。变量被声明;首先它是未定义的,然后它得到一个值。

最终的局部变量不需要获得声明它们的值。您可以稍后为一个final变量赋值。重要的是,不应该有一个代码执行为之前已经赋值的final变量赋值。如果存在重新分配final变量的可能性,编译器会检查它,并且不会编译代码。编译器还检查在未定义变量时不应使用局部变量(不仅仅是final变量)的值。

将变量声明为final通常是为了简化代码的可读性。当您在代码中看到一个声明为final的变量时,您可以假设该变量的值不会改变,并且该变量的含义在方法中使用的任何地方都是相同的。当你试图修改一些变量时,它也会帮助你避免一些错误,IDE 会立即对此提出抱怨。在这种情况下,很可能是一个很早就发现的编程错误。

原则上,可以编写一个所有变量都是final的程序。通常,将所有可声明为finalfinal变量声明为final是一种好的做法,如果某些变量可能未声明为final,则尝试找到某种方法对该方法进行稍微不同的编码。

如果您需要引入一个新变量来实现这一点,可能意味着您使用了一个变量来存储两个不同的东西。这些东西属于同一类型,在不同的时间存储在同一个变量中,但从逻辑上讲,它们仍然是不同的东西。不要试图优化变量的使用。永远不要使用变量,因为您的代码中已经有一个可用的相同类型的变量。如果它在逻辑上是一个不同的东西,那么声明一个新变量。在编码时,总是喜欢源代码的清晰性和可读性。特别是在 Java 中,即时编译器将为您优化所有这些。

尽管我们不明确地倾向于在方法的参数列表中使用final关键字,但是如果参数声明为final,那么确保方法编译并工作是一种很好的做法。包括我在内的一些专家认为,默认情况下,该语言应该将方法参数设置为final。只要 Java 遵循向后兼容的理念,这在任何版本的 Java 中都不会发生。

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

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

一个文件中可以有多个类,但通常一个文件就是一个类。稍后,当一个类在另一个类中时,我们将看到有内部类,但是,现在,我们将把一个类放入一个文件中。

Java 语言中有一些我们不使用的特性。当语言被创建时,这些特性似乎是个好主意。CPU、内存和其他资源,包括平庸的开发人员,也比今天更加有限。由于这些环境限制,其中一些特性可能更有意义。有时候,我会提到这些。对于类,您可以将多个类放入一个文件中,只要只有一个是public。那是不好的做法,我们永远不会那样做。Java 从不抛弃这些特性。直到最近,Java 的一个理念是保持与以前所有版本的兼容性,这种理念变化缓慢。这对于已经编写的大量遗留代码来说是很好的。使用旧版本编写和测试的 Java 代码将在更新的环境中工作。同时,这些特性将初学者引入错误的风格。出于这个原因,有时,我甚至不会提及这些特性。例如,在这里,我可以说-文件中有一个类。这不是绝对正确的。同时,详细解释一个我建议不要使用的特性或多或少是没有意义的。稍后,我可能会跳过它们。这些功能并不多。

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

我们的项目有两个类。它们都是publicpublic类可以从任何地方访问。不是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 示例AppSort中的约定。这是 Java 中的一个约定,在这个约定中,您必须命名驼峰大小写中的几乎所有内容。

驼峰大小写是单词之间没有空格的情况。第一个单词可以以小写或大写开头,为了表示第二个和随后的单词的开头,它们以大写开头。ForExampleThisIsALongCamelCase姓名。

类名以大写字母开头。这不是语言形式上的要求,但这是每个程序员都应该遵循的惯例。这些编码约定可以帮助您创建其他程序员更容易理解的代码,并使维护更容易。静态代码分析器工具,如 Checkstyle,还要检查程序员是否遵循约定。

内部、嵌套、本地和匿名类

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

此时,内部类和嵌套类的细节可能很难理解。如果你不完全理解这一节,不要感到羞愧。如果太难,请跳到下一节,阅读有关包的内容,稍后返回此处。嵌套类、内部类和本地类很少使用,尽管它们在 Java 中有自己的角色和用途。匿名类在 GUI 编程中非常流行,Swing 用户界面允许开发人员创建 JavaGUI 应用。有了 Java8 和 Lambda 特性,匿名类现在已经不那么重要了,而随着 JavaScript 和浏览器技术的出现,JavaGUI 变得不那么流行了。

当一个类单独在一个文件中定义时,它被称为顶级类。显然,在另一个类中的类不是顶级类。如果它们是在与字段(不是某个方法或另一个块的局部变量)相同级别的类中定义的,则它们是内部类或嵌套类。它们之间有两个区别。一种是嵌套类在其定义中将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.java11.example.stringsort;

如果不指定包,则类将位于默认包中。除非在最简单的情况下您想尝试一些代码,否则不应使用此选项。在 Java11 中,您可以使用jshell来实现这个目的。因此,与以前版本的 Java 不同,现在的建议变得非常简单:不要将任何类放在默认包中。

包的名称是分层的。名字的各个部分用点隔开。使用包名有助于避免名称冲突。类的名称通常保持简短,将它们放入包中有助于程序的组织。类的全名包括类所在的包的名称。通常,我们会将这些类放入一个以某种方式相关的包中,并向程序的类似方面添加一些内容。例如,MVC 模式程序中的控制器保存在单个包中。包还可以帮助您避免类的名称冲突。但是,这只会将问题从类名冲突推到包名冲突。我们必须确保包的名称是唯一的,并且当我们的代码与任何其他库一起使用时不会引起任何问题。当开发一个应用时,我们只是不知道在以后的版本中将使用哪些库。为了防患于未然,惯例是根据一些互联网域名来命名包。当开发公司拥有域名acmecompany.com时,他们的软件通常在com.acmecompany...包下。这不是一个严格的语言要求。从右到左写域名,并将其用作包名,这只是一种惯例,但这在实践中证明是相当好的。有时,就像我在这本书中所做的,一个人可以偏离这一做法,所以你可以看到这条规则不是刻在石头上的。

当机器启动时,代码被编译成字节码,包就成为类的名称。因此,Sort类的全名是packt.java11.example.stringsort.Sort。使用另一个包中的类时,可以使用此全名或将该类导入到类中。同样,这是在语言层面。当 Java 变成字节码时,使用完全限定名或导入没有区别。

方法

我们已经讨论了方法,但没有详细讨论,在继续之前,还有一些方面需要讨论。

示例类中有两个方法。一个类中可以有许多方法。方法名也是按约定大小写的,名称以小写字母开头,而不是类。

方法可能返回一个值。如果一个方法返回一个值,那么这个方法必须声明它返回的值的类型,在这种情况下,任何代码的执行都必须用一个return语句来完成。return语句在关键字后面有一个表达式,在方法执行时对其求值,然后由方法返回。一个方法只有一个返回是一个很好的实践,但是在一些简单的情况下,打破这种编码惯例是可以原谅的。编译器检查可能的方法执行路径,如果某些路径不返回值,则为编译时错误。

当一个方法不返回任何值时,它必须声明为void。这是一个特殊类型,表示没有值。void方法,例如public static void main()方法,可能只是错过了return语句而只是结束。如果有一个return语句,则在return关键字后面没有定义返回值的表达式。同样,这是一种编码约定,在方法不返回任何值的情况下不使用return语句,但在某些编码模式中,可能不遵循这种约定。

方法可以是privateprotectedpublicstatic,我们稍后再讨论它们的含义。

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

在我们的例子中,sort()方法不是静态的,但是因为它不访问任何字段,也不调用任何非静态方法(事实上,它根本不调用任何方法);它也可以是static。如果我们将方法的声明改为public static void sort(String[] names) {(注意static一词),程序仍然可以运行,但是编辑时 IDE 会给出警告,如下例所示:

Static member 'packt.java11.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 的前 10 年中,静态方法得到了大量使用。甚至还有一个术语,工具类,它意味着一个类只有静态方法,不应该实例化。随着控制反转容器的出现,我们往往采用较少的静态方法。当使用静态方法时,使用依赖注入难度较大,创建测试也比较困难。我们将在接下来的几章中讨论这些高级主题。目前,您将了解静态方法是什么,哪些方法可以使用;但是,通常,除非对它们有非常特殊的需求,否则我们将避免使用它们。

稍后,我们将研究如何在层次结构中实现类,以及类如何实现接口和扩展其他类。当我们查看这些特性时,我们将看到,有所谓的抽象类可能包含抽象方法。这些方法有abstract修饰符,它们不仅定义名称、参数类型(和名称)以及返回类型。扩展抽象类的具体(非抽象)类应该定义它们。

抽象方法的对立面是用final修饰符声明的最终方法。final方法不能在子类中覆盖。

接口

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

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

接口也可以定义字段,但由于接口不能有实例(只有实现类才能有实例),所以这些字段都是static,也必须是final。这是接口中字段的默认值,因此如果在接口中定义字段,则不需要编写这些字段。

通常的做法是只在一些接口中定义常量,然后在类中使用这些常量。为此,最简单的方法是实现接口。因为这些接口没有定义任何方法,所以实现只不过是将implements关键字和接口的名称写入类声明的头中。这种做法不好,因为这样接口就成为类的公共声明的一部分,尽管类中需要这些常量。如果您需要定义不是某个类的本地常量,而是在许多类中使用的常量,那么可以在一个类中定义这些常量,并使用import static导入字段,或者只使用类和字段的名称。

接口也可以有嵌套类,但不能有内部类。这样做的明显原因是内部类实例引用了封闭类的实例。在接口的情况下,没有实例,因此内部类不能有对封闭接口实例的引用,因为封闭接口实例不存在。令人高兴的是,在嵌套类的情况下,我们不需要使用static关键字,因为这是默认值,就像在字段的情况下一样。

随着 Java8 的出现,您还可以在接口中拥有default方法,这些方法为实现接口的类提供该方法的默认实现。从 Java9 开始,接口中也可以有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字段的情况下,无法进行此优化。另外,请注意,将值保存到内存并从中一直加载可能比从注册表或缓存访问值慢 50 倍或更多倍。

修饰符

方法、构造器、字段、接口和类可以有访问修饰符。一般规则是,如果没有修饰符,那么方法、构造器等的作用域就是包。同一个包中的任何代码都可以访问它。

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

private成员可以从同一顶级类中的代码访问。如果顶级类中有内部类,那么编译器将从这些文件中生成单独的类文件。JVM 不知道什么是内部类。对于 JVM,类只是一个类。private成员仍然必须可以从顶级类访问,或者在private成员(方法或字段)所在的顶级类中访问。同时,其他类应该不能访问private字段。为了解决这种模糊性,Java 生成了所谓的合成代理方法,这些方法从外部可见,因此可以访问。当您想从同一顶级类调用不同内部类中的private方法时,编译器会生成一个代理类。这就是 IDE 多次警告private方法从性能角度来看可能不是最优方法的原因。

Java11 引入了嵌套的概念,这就改变了。顶级类是一个嵌套宿主,每个类都能分辨出哪些在它们的嵌套中,哪些是它们的嵌套宿主。通过这种方式,JVM 知道是否允许访问private成员(读取或写入字段或调用方法)。同时,Java11 不再生成合成代理方法。

private的反面是public。它将可见性扩展到整个 Java 程序,或者至少在整个模块中(如果项目是 Java 模块)扩展。

中间有一条路:protected。具有此修饰符的任何内容都可以在包内访问,也可以在扩展受保护方法、字段等所在的类(无论包是什么)的类中访问。

对象初始化器和构造器

当实例化一个对象时,会调用相应的构造器。构造器声明看起来像具有以下偏差的方法构造器没有返回值。这是因为构造器在调用new命令操作符时处理未完全就绪的实例,并且不返回任何内容。构造器与类同名,不能相互区分。如果需要多个构造器,则必须重载它们。因此,构造器可以互相调用,就像它们是具有不同参数的方法一样。但是,当一个构造器调用另一个构造器时有一个限制,它必须是构造器中的第一条指令。使用this()语法和适当的参数列表(可能为空)从另一个构造器调用构造器。

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

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

编译和运行程序

最后,我们将从命令行编译并执行我们的程序。本章没有什么新内容;我们将仅使用以下两个命令应用本章所学内容:

$ mvn package

这将编译程序,将结果打包到 JAR 文件中,最后执行以下命令:

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

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

总结

在本章中,我们开发了一个非常基本的排序算法。它被有意地简化了,以便我们可以重申基本的和最重要的 Java 语言元素、类、包、变量、方法等等。我们还研究了构建工具,因此在接下来的章节中,当项目将包含两个以上的文件时,我们不会空手而归。在接下来的章节中,我们将使用 Maven 和 Gradle。

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

三、优化排序代码

在本章中,我们将开发排序代码并使其更通用。我们希望对更一般的内容进行排序,而不仅仅是字符串数组。基本上,我们将编写一个程序,可以排序任何可排序的。通过这种方式,我们将充分利用 Java 的一个主要优势——抽象

然而,抽象并不是没有价格标签的。当您有一个对字符串进行排序的类,并且您不小心将一个整数或其他非字符串的内容混合到可排序数据中时,编译器将对此进行抱怨。Java 不允许将int放入String数组。当代码更抽象时,这样的编程错误可能会溜进来。我们将研究如何通过捕获和抛出异常来处理此类异常情况。稍后,我们还将研究泛型,这是 Java 的一个特性,可以帮助在编译时捕获此类编程错误。

为了识别 bug,我们将使用单元测试,应用行业标准 JUnitVersion4。由于 JUnit 大量使用注释,而且由于注释很重要,我们还将了解一些注释。

之后,我们将修改代码以使用 Java 的泛型特性,该特性是在版本 5 中引入到语言中的。使用它,我们将捕获编译期间的编码错误。这比在运行时处理异常要好得多。越早发现 bug,修复的成本就越低。

对于构建,我们仍将使用 Maven,但这一次,我们将把代码分成几个小模块。因此,我们将有一个多模块的项目。对于排序模块的定义和不同的实现,我们将有单独的模块。这样,我们将了解类如何相互扩展和实现接口,通常,我们将真正开始以面向对象的方式编程。

我们还将讨论测试驱动开发TDD),在本节的最后,我们将开始使用版本 9 模块支持中引入的全新特性 Java。

在本章中,我们将介绍以下主题:

  • 面向对象编程原理
  • 单元测试实践
  • 算法复杂性与快速排序
  • 异常处理
  • 递归方法
  • 模块支持

通用排序程序

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

JDK 包含一个名为Collections的工具类,它本身包含一个静态方法Collections.sort。此方法可以对具有成员为Comparable的任何List进行排序(更准确地说,成员是实现Comparable接口的类的实例)。ListComparable是在 JDK 中定义的接口。因此,如果我们要对Strings列表进行排序,最简单的解决方案如下:

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

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

$ mvn -Dtest=SimplestStringListSortTest test

然而,这种实现并不能满足我们的需要。主要原因是我们想学些新东西。使用 JDK 的sort()方法并没有教给您任何新的东西,除了该方法前面的@Test注解。

如果在前面的代码中有一些您无法理解的内容,那么您可以在本书中翻回一些页面,并查阅 JDK 的 Oracle 在线文档,但仅此而已。你已经知道这些事情了。

您可能想知道为什么我要将 JavaVersion9API 的 URL 写到链接中。好吧,现在是我写这本书时诚实和真实的时刻,Java11JDK 还没有最终的版本。事实上,甚至 Java10JDK 也只是预发布的。在第一版中,我在 MacBook 上使用 Java8 创建了大多数示例,后来我只测试了 Java10、10 或 11 特定的特性。当您阅读本书时,Java8 将可用,因此您可以尝试将 URL 中的一个数字从 9 改为 11,并获得版本 11 的文档。目前,我得到 HTTP 错误 404。有时,您可能需要旧版本的文档。您可以在 URL 中使用 3、4、5、6、7、8 或 9 而不是 11。3 和 4 的文档不能在线阅读,但可以下载。希望你永远都不需要。也许是第五版。第 6 版在本书第一版出版时仍被大公司广泛使用,自那以后没有太大变化。

尽管您可以从阅读其他程序员编写的代码中学到很多,但我不建议您在学习的早期阶段尝试从 JDK 源代码中学习。这些代码块经过了大量优化,不是教程代码,而且它们很旧。它们不会生锈,但是它们没有被重构以遵循 Java 成熟时更新的编码风格。在某些地方,您可以在 JDK 中找到一些非常难看的代码。

好吧,说我们需要开发一个新的,因为我们可以从中学习,这有点自作主张。我们需要一个排序实现的真正原因是我们想要的东西不仅可以对List数据类型和实现Comparable接口的东西进行排序,我们想要对一组对象进行排序。我们所需要的是,包含对象的提供了简单的方法,这些方法足以对它们进行排序,并有一个已排序的

最初我想用单词集合来代替,但是 Java 中有一个Collection接口,我想强调的是,我们不是在讨论对象的java.util.Collection

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

当我们设计一个类时,它应该对现实世界中的某个对象类进行建模。我们将用类来建模问题空间。类应该实现表示它所建模的对象行为的特性。如果我们看第二章学生的例子,那么一个Student类应该代表所有学生共享的特征,从建模的角度来看是重要的。一个Student对象应该能够说出学生的名字、年龄、去年的平均分数等等。但是,我们应该关注与我们的编程需求相关的特性。例如,所有学生都有脚,当然,每只脚都有一个大小,所以我们可能认为一个Student类也应该实现一个返回学生脚大小的方法。为了突出荒谬之处,我们可以实现数据结构和 API,为左脚注册一个大小,为右脚注册一个不同的大小。我们没有,因为脚的大小与模型的观点无关。

但是,如果我们想要对包含Student对象的列表进行排序,Student类必须实现Comparable接口。但是等等!你如何比较两个学生?按姓名、年龄或平均分数?

把一个学生和另一个学生作比较并不是这个类的基本特征。每个类或包、库或编程单元都应该有一个职责,它应该只实现这个职责,而不实现其他职责。这并不确切。这不是数学。有时,很难判断一个特性是否适合这个职责。可比性可能是某些数据类型的固有特征,例如IntegerDouble。其他类没有这种固有的比较特性。

有一些简单的技术可以确定特性是否应该是类的一部分。例如,对于一个学生,你可以问真人他们的名字和年龄,他们也可以告诉你他们的平均分。如果你让他们中的一个去compareTo(另一个学生),因为Comparable接口需要这个方法,他们很可能会问,“用什么属性或者怎么做?”如果他们不是有礼貌的类型,他们可以简单地回答“什么?”(更不用说缩写 WTF,它代表一周的最后三个工作日,在这种情况下很流行。)在这种情况下,您可能会怀疑实现该特性可能不在该类及其关注的领域;比较应该与原始类的实现分离开来。这也称为关注点分离,与 SRP 密切相关。

JDK 开发人员知道这一点。对Comparable元素中的List进行排序的Collections.sort并不是此类中唯一的排序方法。另一种方法是,如果传递第二个参数,则对任何List进行排序,该参数应该是实现Comparator接口的对象,并且能够比较List的两个元素。这是分离关注点的干净模式。在某些情况下,不需要分离比较。在其他情况下,这是可取的。Comparator接口声明了实现类必须提供的一个方法—compare。如果两个参数相等,则方法返回0。如果它们不同,它应该返回一个否定或肯定的int,这取决于哪个参数在另一个参数之前。

JDK 类java.util.Arrays中还有sort方法。它们对数组排序或仅对数组的一部分排序。该方法是方法重载的一个很好的例子。有一些方法具有相同的名称,但参数不同,可以对每个原始类型的整个数组进行排序,也可以对每个原始类型的片进行排序,还有两个方法用于实现Comparable接口的对象数组,还可以用于使用Comparator进行排序的对象数组。如您所见,JDK 中提供了一系列排序实现,在 99% 的情况下,您不需要自己实现排序。排序使用相同的算法,一个稳定的合并排序和一些优化。

我们要实现的是一种通用的方法,它可以用来排序列表、数组,或者任何有元素的东西,我们可以在比较器的帮助下进行比较,并且可以交换任意两个元素。我们将实现可用于这些接口的不同排序算法。

各种排序算法的简要概述

有许多不同的排序算法。正如我所说,有更简单和更复杂的算法,在许多情况下,更复杂的算法运行得更快。(毕竟,如果更高复杂度的算法运行得更慢,它会有什么好处?)在本章中,我们将实现冒泡排序和快速排序。在上一章中,我们已经实现了字符串的冒泡排序,因此在本例中,实现将主要集中在一般可排序对象排序的编码上。实现快速排序需要一些算法方面的兴趣。

请注意,本节只是让您体验一下算法的复杂性。这是远远不够精确,我在徒劳的希望,没有数学家阅读这一点,并把诅咒我。有些解释含糊不清。如果你想深入学习计算机科学,那么在读完这本书之后,找一些其他的书或者访问在线课程。

当我们讨论一般排序问题时,我们考虑的是一些对象的一般有序集合,其中任意两个对象可以在排序时进行比较和交换。我们还假设这是一种原地排序。这意味着我们不会创建另一个列表或数组来按排序顺序收集原始对象。当我们谈论算法的速度时,我们谈论的是一些抽象的东西,而不是毫秒。当我们想谈论毫秒时,实际的持续时间,我们应该已经有了一个在真实计算机上运行的编程语言的实现。

没有实现的抽象形式的算法不会这样做。不过,一个算法的时间和内存需求还是值得讨论的。当我们这样做的时候,我们通常会研究算法对于大量数据的行为。对于一小部分数据,大多数算法都很快。排序两个数字通常不是问题,是吗?

在排序的情况下,我们通常检查需要多少比较来对n个元素的集合进行排序。冒泡排序大约需要nn次)比较。我们不能说这就是,因为在n=2的情况下,结果是 1,n=3是 3,n=4是 6,依此类推。然而,随着n开始变大,实际需要的比较次数和将逐渐地具有相同的值。我们说冒泡排序的算法复杂度是O(n²)。这也称为大 O 表示法。如果你有一个算法是O(n²),它只适用于 1000 个元素,在一秒钟内完成,那么你应该期望同样的算法在大约 10 天到一个月内完成 100 万个元素。如果算法是线性的,比如说O(n),那么在一秒钟内完成 1000 个元素应该会让你期望在 1000 秒内完成 100 万个元素。这比喝咖啡的时间长一点,但午餐时间太短了。

这使得如果我们想要一些严肃的业务排序对象,我们需要比冒泡排序更好的东西成为可能。许多不必要的比较不仅浪费了我们的时间,而且浪费了 CPU 的能量,消耗了能源,污染了环境。

然而,问题是排序的速度有多快?有没有一个可以证明的最低限度,我们不能减少?

答案是肯定的,有一个可证明的最低限度。这一点的基础非常有趣,在我看来,每个 IT 工程师不仅应该知道实际答案,而且还应该知道背后的原因。毕竟,必要最小值的证明,只不过是纯粹的信息。下面,再次,不是一个数学证明,只是一种模糊的解释。

当我们实现任何排序算法时,实现将执行比较和元素交换。这是对对象集合进行排序的唯一方法,或者至少所有其他可能的方法都可以简化为以下步骤。比较的结果可以有两个值。假设这些值是01。这是一点信息。如果比较结果为1,则我们交换;如果比较结果为0,则我们不交换。

在开始比较之前,我们可以将对象按不同的顺序排列,不同的顺序数是n!n阶乘),即数字从 1 乘到n,换言之,n! = 1 x 2 x 3 x ... x (n - 1) x n

假设我们将单个比较的结果存储在一个数字中,作为排序中每个可能输入的一系列位。现在,如果我们反转排序的执行,从排序后的集合开始运行算法,用描述比较结果的位来控制交换,用另一种方式来控制交换,先进行最后一次交换,再进行排序时首先进行的交换,我们应该恢复物品原来的顺序。这样,每个原始顺序都与一个表示为位数组的数字唯一关联。

现在,我们可以用这种方式来表达最初的问题,描述n阶乘不同的数需要多少位?这正是我们需要对n元素进行排序的比较数。

要区分n!的位数,数字log2(n!)。用一些数学,我们会知道log2(n!)等于log2(1) + log2(2) + ... + log2(n)。如果我们看这个表达式的渐近值,那么我们可以说这与O(n * logn)一样的。我们不应该期望任何通用的排序算法更快。

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

同样,这不是一个正式的数学证明。

快速排序

查尔斯·安东尼·理查德·霍尔爵士于 1959 年开发了快速排序算法。它是一种典型的分治算法。事情是这样的。

要对长数组进行排序,请从数组中选择一个元素,该元素将成为所谓的枢轴元素。然后,对数组进行分区,使左侧包含所有小于轴的元素,右侧包含所有大于或等于轴的元素。当我们开始分区时,我们不知道左边会有多长,右边会从哪里开始。我们解决这个问题的精确方法将很快解释。现在,重要的是我们要将一个数组进行划分,以便从数组开始到某个索引的元素都小于轴,从那里到数组结束的元素都大于轴。这还有一个简单的结果,左边的元素都比右边的任何元素都小。这已经是偏序了。因为枢轴是从数组中选择的,所以可以保证任何一方都不能包含整个原始数组,使另一方成为空数组。

完成此操作后,可以通过递归调用排序来排序数组的左右两侧。在这些调用中,子数组的长度总是小于上一级的整个数组。当我们要排序的实际级别的数组段中有一个元素时,我们停止递归。在这种情况下,我们可以从递归调用返回,而不需要比较或重新排序;显然,一个元素总是排序的。

当算法部分地使用自身定义时,我们讨论递归算法。最著名的递归定义是斐波那契级数,0 和 1 表示前两个元素,而对于所有后续元素,第n个元素是第n-1和第n-2个元素的总和。递归算法通常在现代编程语言中实现,实现的方法进行一些计算,但有时会调用自身。在设计递归算法时,最重要的是要有停止递归调用的东西;否则,递归实现将为程序栈分配所有可用内存,当内存耗尽时,它将以错误停止程序。

算法的分区部分按照以下方式进行:我们将开始使用从开始到结束的两个索引来读取数组。我们将首先从一个小的索引开始,然后增加索引,直到它小于大的索引,或者直到找到一个大于或等于轴的元素。在此之后,我们将开始减少较大的索引,只要它大于较小的索引,并且索引的元素大于或等于轴。当我们停止时,我们交换两个索引所指向的两个元素。如果指数不一样,我们开始分别增加和减少小指数和大指数。如果索引是相同的,那么我们就完成了分区。数组的左侧是从开始到索引相接处的索引减 1;右侧是从要排序的数组末尾的索引结束处开始。

这种快速排序算法通常消耗O(n logn)时间,但在某些情况下,它可以退化为O(n²),具体取决于如何选择枢轴。例如,如果我们选择数组段的第一个元素作为轴心,并且数组已经排序,那么这种快速排序算法将退化为简单的冒泡排序。为了修正这一点,有不同的方法来选择轴心。在本书中,我们将使用最简单的方法选择可排序集合的第一个元素作为轴心。

项目结构和构建工具

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

  • 它引用模块,可以用来编译、安装和部署所有模块
  • 它为所有模块定义相同的参数

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

<modules>
    <module>SortSupportClasses</module>
    <module>SortInterface</module>
    <module>bubble</module>
    <module>quick</module>
    <module>Main</module>
</modules>

这些是模块的名称。这些名称用作目录名,在pom.xml模块中也用作artifactId。此设置中的目录如下所示:

$ tree
   |-SortInterface
   |---src/main/java/packt/java189fundamentals/ch03
   |-bubble
   |---src
   |-----main/java/packt/java189fundamentals/ch03/bubble
   |-----test/java/packt/java189fundamentals/ch03/bubble
   |-quick/src/
   |-----main/java
   |-----test/java

Maven 依赖关系管理

依赖项在 POM 文件中也扮演着重要的角色。上一个项目没有任何依赖项。这次我们将使用 JUnit,所以我们依赖于 JUnit。依赖项在pom.xml文件中使用dependencies标记定义。例如,冒泡排序模块包含以下代码:

<dependencies>
    <dependency>
        <groupId>packt.java189fundamentals</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 将策略更改为仅使用https协议。千万不要配置或使用具有https协议的存储库,也不要信任通过 HTTP 下载的文件。

开发人员的机器上有一个本地存储库,通常位于~/.m2/repository目录中。在 Windows 上,用户的主目录通常是C:\Users\your_username。在 Unix 操作系统上,Shell 类似于 Windows 命令提示符应用,它使用~字符来引用这个目录。当您发出mvn install命令时,Maven 将创建的工件存储在这里。Maven 还通过 HTTPS 从存储库下载工件时,将其存储在此处。这样,后续的编译就不需要到网络上查找工件了。

公司通常会建立自己的存储库管理器。这些应用可以配置为与其他几个存储库通信,并根据需要从那里收集工件,基本上实现代理功能。工件以层次结构从远端存储库到更近的构建,到本地回购,如果项目的包装类型为warear,或者包含相关工件的其他格式,则构件将从更近的存储库转移到本地回购,实质上也会传递到最终工件。这基本上是文件缓存,不需要重新验证和缓存驱逐。这可以做到,因为工件永远不会被替换。

如果bubble项目是一个独立的项目,而不是多模块项目的一部分,那么依赖关系如下所示:

<dependencies>
    <dependency>
        <groupId>packt.java189fundamentals</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标记中的ddependencyManagement/dependencies,如下例所示:

<dependencyManagement>
    <dependencies>
        <dependency>
            <groupId>packt.java189fundamentals</groupId>
            <artifactId>SortSupportClasses</artifactId>
            <version>${project.version}</version>
        </dependency>
        <dependency>
            <groupId>packt.java189fundamentals</groupId>
            <artifactId>SortInterface</artifactId>
            <version>${project.version}</version>
        </dependency>
        <dependency>
            <groupId>packt.java189fundamentals</groupId>
            <artifactId>quick</artifactId>
            <version>${project.version}</version>
        </dependency>
        <dependency>
            <groupId>junit</groupId>
            <artifactId>junit</artifactId>
            <version>4.12</version>
            <scope>test</scope>
        </dependency>
    </dependencies>
</dependencyManagement>

当模块要使用junit时,不需要指定版本。他们将从定义为 4.12 的父项目中获得它,这是 junit4 中的最新版本。如果有一个新版本,4.12.1,修复了一些严重的错误,那么修改版本号的唯一地方就是父 POM,当 Maven 执行下一步时,模块将使用新版本。

然而,当项目开发人员决定使用新的 JUnit 5 版本时,所有的模块都会被修改,因为 JUnit 5 不仅仅是一个新版本。junit5 与老版本 4 有很大的不同,它被分成几个模块。这样,groupIdartifactId也会改变。

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

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

这似乎有点重复(实际上是)。${project.version}属性是项目的版本,SortInterface模块继承这个值。这是其他模块所依赖的工件的版本。换句话说,模块总是依赖于我们当前开发的版本。

编写排序

为了实现排序,首先,我们将定义库应该实现的接口。在实际编码之前定义接口是一种很好的做法。当有许多实现时,有时建议首先创建一个简单的实现并开始使用它,这样接口就可以在这个开发阶段发展,当更复杂的实现到期时,接口就已经固定了。实际上,没有什么是固定的,因为编程中没有阿基米德点。

创建接口

本例中的接口非常简单:

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

接口应该只做一件事,对可排序的内容进行排序。因此,我们定义了一个接口,实现这个接口的任何类都将是Sortable

public interface Sortable {
}

创建冒泡排序

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

 ...
import java.util.Comparator;

public class BubbleSort implements Sort, SortSupport {
    @Override
    public void sort(Sortable collection) {
        var 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--;
        }
    }
 ...

通常,算法需要两个操作。我们实现了一个比较两个元素并交换两个元素的数组。然而,这次排序实现本身并不知道应该对什么类型进行排序。它也不知道元素是如何存储的。它可以是数组、列表或其他一些。它知道它可以比较元素,而且它还可以交换两个元素。如果提供了这些,那么排序工作。

在 Java 术语中,它需要一个能够比较两个元素的comparator对象,需要一个能够交换集合中两个元素的swapper对象。

排序对象应该可以访问这些对象。拥有两个引用这些对象的字段是完美的解决方案。唯一的问题是字段如何获得对比较和交换对象的引用。我们现在遵循的解决方案是,我们提供了可以用来将这些依赖项注入排序对象的设置器。

这些设置器并不特定于冒泡排序算法。这些是相当一般的;因此,定义一个冒泡排序可以实现的接口是有意义的:

public interface SortSupport {
    void setSwapper(Swapper swap);

    void setComparator(Comparator compare);
}

BubbleSort类中的实现只是以下代码:

    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 编译器发出信号,表示该方法正在覆盖父类的方法,或者在本例中覆盖接口的方法。方法可以覆盖没有此注释的父方法;但是,如果使用注释,如果方法没有覆盖,编译将失败。这有助于您在编译时发现父类或接口中发生了更改,而我们在实现中没有遵循该更改,或者我们只是犯了一个错误,认为我们将覆盖一个方法,而实际上我们没有这样做。由于注释在单元测试中大量使用,我们将在后面更详细地讨论注释。

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

import java.util.Comparator;

当您需要一些非常基本的东西时,比如一个Comparator接口,它很可能是在运行时定义的。在编写自己的版本之前,最好先查阅运行时。但是,Swapper接口必须创建:

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

由于它用于交换Sortable中索引指定的两个元素,因此有一种方法非常明显地命名为swap。但我们还没有准备好。如果您试图编译前面的代码,编译器会抱怨getget方法。算法需要它们来实现排序,但它们本身并不是排序本身的一部分。这是不应在排序中实现的功能。由于我们不知道将对哪种类型的集合进行排序,因此在排序中实现这些方法不仅是不可取的,而且也是不可能的。看来我们什么都分类不了。我们必须设置一些限制。排序算法必须知道我们排序的集合的大小,并且还应该通过索引访问元素,以便它可以将其传递给比较器。这些似乎是我们通常可以接受的相当合理的限制。

这些限制在Sortable接口中表示,我们刚刚将其留空,在第一个排序实现之前不知道需要什么:

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

现在,我们已经准备好了接口和实现,可以继续测试代码了。但是,在此之前,我们将简要重申我们所做的以及我们为什么这样做。

架构考虑

我们创建了一个接口和一个简单的实现。在实现过程中,我们发现该接口需要支持该算法的其他接口和方法。这通常发生在代码的架构设计期间,在实现之前。出于说教的原因,我在开发代码时遵循了接口的构建。在现实生活中,当我创建接口时,我一步就创建了它们,因为我有足够的经验。我在 1983 年左右用 FORTRAN 编写了第一个快速排序代码。然而,这并不意味着我只是用任何问题来击中靶心,并给出最终的解决方案。碰巧这类问题太有名了。如果在开发过程中需要修改接口或设计的其他方面,请不要感到尴尬。这是一个自然的结果,也是一个证明,随着时间的推移,你对事物的理解会越来越好。如果架构需要更改,那么最好是这样做,而且越快越好。在实际的企业环境中,我们设计接口只是为了在开发过程中了解一些我们忘记的方面。它们的操作比排序集合要复杂一些。

在排序问题的例子中,我们抽象了我们想要排序到最可能的极限的东西。Java 内置的排序可以对数组或列表进行排序。如果要对不是列表或数组的对象进行排序,则必须创建一个类来实现java.util.List接口,该接口包含 24 个以上的方法,这些方法用于包装可排序对象,使其可以通过 JDK 排序。24 种方法似乎有很多,只是为了让我们的变得有点可分性。老实说,这并不是太多,在一个真实的项目中,我会把它作为一个选择。

我们不知道,也不知道,内置排序使用什么接口方法。那些应该在功能上实现的语句被使用,而那些语句可以包含一个简单的return语句,因为它们从未被调用,所以没有被使用。开发人员可以查阅 JDK 的源代码并查看实际使用的方法,但这不是搜索实现的契约。不能保证新版本仍然只使用这些方法。如果一个新版本开始使用我们用一个return语句实现的方法,排序将神奇地失败。

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

当您使用来自 JDK、开源或商业库的库、类和方法时,您可以参考源代码,但不应依赖于实现。您应该只依赖于该库附带的 API 的契约和定义。当您从某个外部库实现一个接口时,您不需要实现它的某些部分,也不需要创建一些虚拟方法,您会感到危险。这是埋伏。很可能是库质量不好,或者你不知道如何使用它。我不知道哪个更糟。

在我们的例子中,我们将交换和比较与排序分开。集合应该实现这些操作并为排序提供它们。契约就是接口,要使用排序,必须实现我们定义的接口的所有方法。

SortSupport的接口定义了设置SwapperComparator的设置器。以这种方式设置依赖项可能会导致代码创建实现SortSortSupport接口的类的新实例,但在调用Sort之前不设置SwapperComparator。这将导致在第一次调用Comparator时调用NullPointerException(或者在实现首先调用Swapper时调用Swapper,这不太可能,但可能)。调用方法应该在使用类之前注入依赖项。通过设定器进行时,称为设置器注入。当我们使用诸如 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>

依赖关系的范围是test,这意味着只有在编译测试代码和执行测试时才需要这个库。JUnit 库不会进入最终发布的产品;不需要它。如果在已部署的生产 Web 存档WAR)或企业存档EAR)文件中发现 JUnit 库,请怀疑有人没有正确管理库的范围。

Maven 支持在项目生命周期中编译和执行 JUnit 测试。如果我们只想执行测试,我们应该发出mvn test命令。IDEs 还支持执行单元测试。通常,可以使用相同的菜单项来执行具有public static main()方法的类。如果该类是一个使用 JUnit 的单元测试,IDE 将识别它并执行测试,并且通常给出图形化的反馈,说明哪些测试执行得很好,哪些测试失败,以及如何执行。

编写BubbleSortTest

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

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

package packt.java189fundamentals.ch03.main.bubble.simple;

// import statements are deleted from the print for brevity

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

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

var names = new Sortable() {
    @Override
    public Object get(int i) {
        return actualNames.get(i);
    }
    @Override
    public int size() {
        return actualNames.size();
    }
};

我们声明了一个新对象,它具有Sortable类型,它是一个接口。要实例化实现Sortable的东西,我们需要一个类。我们无法实例化接口。在这种情况下,在实例化的位置定义类。这在 Java 中称为匿名类。名称来自于源代码中未定义新类的名称。Java 编译器将自动为新类创建一个名称,但这对程序员来说并不有趣。我们只需写new Sortable()并在{}之间立即提供所需的实现。在方法中定义这个匿名类非常方便,这样,它可以访问ArrayList,而不需要在类中传递对ArrayList的引用。

事实上,引用是需要的,但是 Java 编译器会自动补全这项工作。在本例中,Java 编译器还注意到,以这种方式传递的自动引用只能使用初始化的变量来完成,并且在匿名类实例化之后的代码执行期间不会更改。actualNames变量已设置,以后方法中不应更改。事实上,我们甚至可以将actualNames定义为final,如果我们使用 Java1.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实现的实例。我们必须设置SortSort,最后调用sort

var sort = new BubbleSort();
sort.setComparator(stringCompare);
sort.setSwapper(new SwapActualNamesArrayElements());
sort.sort(names);

测试的最后但最重要的部分是断言结果是我们期望的结果。JUnit 在Assert类的帮助下帮助我们做到这一点:

Assert.assertEquals(List.of(
    "Abraham", "Dagobert",
    "Johnson", "Wilkinson", "Wilson"
), actualNames);

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

良好的单元测试

这是一个好的单元测试吗?如果你在这样一本教程里读到它,那一定是。其实不是。这是一个很好的代码来演示 JUnit 提供的一些工具和一些 Java 语言特性,但我不会在专业项目中使用它。

什么使单元测试好?为了回答这个问题,我们必须定义单元测试的用途。单元测试有两个目的。单元测试的目的是验证单元的正确功能并记录它。

单元测试不用于发现 bug。开发人员最终会在调试会话期间使用单元测试,但很多时候,为调试创建的测试代码是临时的。当 bug 修复后,用于查找它的代码将不会进入源代码存储库。对于每一个新的 bug,都应该创建一个新的测试来覆盖不能正常工作的功能,但是很难使用测试代码来查找 bug。这是因为单元测试主要用于文档。您可以使用 JavaDoc 对类进行文档化,但经验表明,文档化常常会过时。开发人员修改代码,但不修改文档。文件变得过时和具有误导性。然而,单元测试是由构建系统执行的,如果持续集成CI)正在使用(在专业环境中应该是这样),那么如果测试失败,构建将被破坏。所有的开发人员都会收到一封关于它的邮件通知,它会促使开发人员破坏构建来修复代码或测试。通过这种方式,测试在持续集成过程中验证代码没有被破坏,至少,没有使用单元测试可以发现的东西。

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

我们的测试远没有可读性。一个测试用例是可读的,如果你看它,在 15 秒内你可以告诉它做什么。当然,它假设读者有一些 Java 方面的经验,但你明白这一点。我们的测试充斥着不是测试核心的支持类。

我们的测试也很难验证代码是否正常工作。实际上没有。其中有一些我故意放在那里的 bug,我们将在下面几节中找到并消除它们。对单个String数组进行排序的单个测试远远不能验证排序实现。如果我要将这个测试扩展到一个真实世界的测试,我们需要名称为canSortEmptyCollectioncanSortOneElementCollectioncanSortTwoElementscanSortReverseOrdercanSortAlreadySorted的方法。如果你看这些名字,你就会知道我们需要什么样的测试。由于排序问题的性质,实现可能对这些特殊情况下的错误相当敏感。

除了作为一个可接受的演示工具之外,我们的单元测试还有哪些优点?

单元测试很快

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

单元测试是确定性的

我们的单元测试是确定性的。不确定性单元测试是开发人员的噩梦。如果您所在的组中有一些构建在 CI 服务器上中断,而当一个构建中断时,您的开发伙伴会说您只需再试一次;不可能!如果单元测试运行,它应该一直运行。如果失败了,不管你启动它多少次,它都应该失败。在我们的例子中,一个不确定的单元测试是呈现随机数并对它们进行排序。它最终会在每个测试运行中使用不同的数组,并且,如果代码中出现了一些针对某个数组的 bug,我们将无法重现它。更不用说确保代码正常运行的断言也很难产生。

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

断言应该尽可能简单

如果断言很复杂,那么在断言中引入 bug 的风险会更高。断言越复杂,风险就越高。我们编写单元测试以简化我们的生活,而不是有更多的代码需要调试。

另外,一个测试应该只断言一件事。这个断言可以用多个Assert类方法进行编码,一个接着一个。尽管如此,这些功能的目的是维护单元的一个单一特性的正确性。

记住 SRP 一个测试,一个特性。一个好的测试就像一个好的狙击手一枪一杀。

单元测试是孤立的

当我们测试一个单元a时,另一个单元B中的任何更改或不同单元中的错误都不应影响我们对该单元a的单元测试。在我们的情况下,这很容易,因为我们只有一个单位。稍后,当我们为快速排序开发测试时,我们将看到这种分离并不是那么简单。

如果单元测试正确地分开,那么失败的单元测试会清楚地指出问题所在。在单元测试失败的单元中。如果测试没有将单元分开,那么一个测试中的失败可能是由不同单元中的 bug 引起的。在这种情况下,这些测试并不是真正的单元测试。

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

单元测试涵盖了代码

单元测试应该测试功能的所有常规和特殊情况。如果有一种特殊情况的代码没有被单元测试覆盖,那么代码就处于危险之中。在排序实现的情况下,一般情况是排序,比如说,五个元素。特殊情况通常要多得多。如果只有一个元素或者没有元素,我们的代码是如何工作的?如果有两个呢?如果元素的顺序相反呢?如果已经分类了呢?

通常,规范中没有定义特殊情况。程序员在编写代码之前必须考虑这个问题,在编写代码的过程中会发现一些特殊的情况。困难的是,你只是无法判断你是否涵盖了所有的特殊情况和代码的功能。

您可以判断的是是否所有的代码行都是在测试期间执行的。如果 90% 的代码行是在测试期间执行的,那么代码覆盖率是 90%,这在现实生活中是相当好的,但是您永远不应该满足于任何低于 100% 的内容。

代码覆盖率与功能覆盖不相同,但存在相关性。如果代码覆盖率小于 100%,则以下两个语句中至少有一条为真:

  • 功能覆盖率不是 100%。
  • 测试单元中有一个未使用的代码,可以直接删除。

代码覆盖率可以测量,但功能覆盖率却无法合理地进行测量。工具和 IDE 支持代码覆盖率测量。这些测量值集成到编辑器中,这样您不仅可以获得覆盖率,而且编辑器将精确地显示覆盖着色行(例如 Eclipse 中)或编辑器窗口左侧的边沟(IntelliJ)中未覆盖哪些行。以下截图显示,在 IntelliJ 中,测试覆盖了檐沟上绿色指示的线条(在打印版本中,这只是一个灰色矩形):

重构测试

现在我们已经讨论了什么是好的单元测试,让我们改进一下测试。第一件事是将支持类移动到单独的文件中。我们将创建ArrayListSortable

package packt.java189fundamentals.ch03.main.bubble.simple;

import packt.java189fundamentals.ch03.Sortable;

import java.util.ArrayList;

public class ArrayListSortable implements Sortable {
    final private ArrayList actualNames;

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

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

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

这个类封装了ArrayList,然后实现了getssize方法对ArrayList的访问。ArrayList本身声明为final。回想一下,final字段必须在构造器完成时定义。这保证了当我们开始使用对象时字段就在那里,并且在对象生存期内它不会改变。然而,注意,对象的内容,在这种情况下,ArrayList的元素可以改变。如果不是这样的话,我们就无法整理它。

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

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

package packt.java189fundamentals.ch03.main.bubble.simple;

import packt.java189fundamentals.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);
    }
}

最后,我们的测试如下:

@Test
public void canSortStrings2() {
    var actualNames = new ArrayList(List.of(
        "Johnson", "Wilson",
        "Wilkinson", "Abraham", "Dagobert"
    ));
    var expectedResult = List.of(
        "Abraham", "Dagobert",
        "Johnson", "Wilkinson", "Wilson"
    );
    var names = new ArrayListSortable(actualNames);
    var sort = new BubbleSort();
    sort.setComparator(new StringComparator());
    sort.setSwapper(new ArrayListSwapper(actualNames));
    sort.sort(names);
    Assert.assertEquals(expectedResult, actualNames);
}

现在,这已经是一个可以在 15 秒内理解的测试了。它很好地记录了如何使用我们定义的某种实现。到目前为止,它仍在运行,没有发现任何 bug。

包含错误元素的集合

bug 并不简单,而且与往常一样,这不是算法的实现,而是在定义上,或者缺少它。如果我们排序的集合中不仅有字符串,程序应该怎么做?

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

@Test(expected = ClassCastException.class)
public void canNotSortMixedElements() {
    var 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 运行时将打印出异常消息以及栈跟踪,该跟踪将包含异常发生时调用栈上的所有类、方法和行号。在我们的例子中,如果我们移除@Test注解的(expected = ClassCastException.class)参数,测试执行将在输出中产生以下跟踪:

packt.java189fundamentals.ch03.main.bubble.simple.NonStringElementInCollectionException: There are mixed elements in the collection.

        at packt.java189fundamentals.ch03.main.bubble.simple.StringComparator.compare(StringComparator.java:13)
        at packt.java189fundamentals.ch03.main.bubble.BubbleSort.sort(BubbleSort.java:17)
        at packt.java189fundamentals.ch03.main.bubble.simple.BubbleSortTest.canNotSortMixedElements(BubbleSortTest.java:108)
        at java.base/jdk.internal.reflect.NativeMethodAccessorImpl.invoke0(Native Method)
        at java.base/jdk.internal.reflect.NativeMethodAccessorImpl.invoke(NativeMethodAccessorImpl.java:62)
        at java.base/jdk.internal.reflect.DelegatingMethodAccessorImpl.invoke(DelegatingMethodAccessorImpl.java:43)
        at java.base/java.lang.reflect.Method.invoke(Method.java:564)
        at org.junit.runners.model.FrameworkMethod$1.runReflectiveCall(FrameworkMethod.java:50)
        at org.junit.internal.runners.model.ReflectiveCallable.run(ReflectiveCallable.java:12)
        at org.junit.runners.model.FrameworkMethod.invokeExplosively(FrameworkMethod.java:47)
        at org.junit.internal.runners.statements.InvokeMethod.evaluate(InvokeMethod.java:17)
        at org.junit.runners.ParentRunner.runLeaf(ParentRunner.java:325)
        at org.junit.runners.BlockJUnit4ClassRunner.runChild(BlockJUnit4ClassRunner.java:78)
        at org.junit.runners.BlockJUnit4ClassRunner.runChild(BlockJUnit4ClassRunner.java:57)
        at org.junit.runners.ParentRunner$3.run(ParentRunner.java:290)
        at org.junit.runners.ParentRunner$1.schedule(ParentRunner.java:71)
        at org.junit.runners.ParentRunner.runChildren(ParentRunner.java:288)
        at org.junit.runners.ParentRunner.access$000(ParentRunner.java:58)
        at org.junit.runners.ParentRunner$2.evaluate(ParentRunner.java:268)
        at org.junit.runners.ParentRunner.run(ParentRunner.java:363)
        at org.junit.runner.JUnitCore.run(JUnitCore.java:137)
        at com.intellij.junit4.JUnit4IdeaTestRunner.startRunnerWithArgs(JUnit4IdeaTestRunner.java:68)
        at com.intellij.rt.execution.junit.IdeaTestRunner$Repeater.startRunnerWithArgs(IdeaTestRunner.java:47)
        at com.intellij.rt.execution.junit.JUnitStarter.prepareStreamsAndStart(JUnitStarter.java:242)
        at com.intellij.rt.execution.junit.JUnitStarter.main(JUnitStarter.java:70)
Caused by: java.lang.ClassCastException: java.base/java.lang.Integer cannot be cast to java.base/java.lang.String
        at packt.java189fundamentals.ch03.main.bubble.simple.StringComparator.compare(StringComparator.java:9)
        ... 24 more

这个栈跟踪实际上并不长。在生产环境中,在应用服务器上运行的应用中,栈跟踪可能包含几百个元素。在这个跟踪中,您可以看到 IntelliJ 正在启动涉及 JUnitRunner 的测试执行,直到我们完成了对比较器的测试,在那里抛出了实际的异常。

这种方法的问题是,真正的问题不是类铸造失败。真正的问题是集合包含混合元素。只有当 Java 运行时试图强制转换两个不兼容的类时,它才能实现。我们的代码可以更智能。我们可以修改比较器:

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.java189fundamentals.ch03.main.bubble.simple;

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

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

package packt.java189fundamentals.ch03.main.bubble.simple;

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

然后,我们可以声明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 相当稳定,您几乎不会遇到这些错误。

通过这种方式,当一些程序员意外地在名称中写入 42 个时,我们处理了这种特殊情况,但是如果在编译时识别错误会更好吗?为此,我们将引入泛型。

在我们去那里之前最后一个想法。我们用canNotSortMixedElements单元测试测试什么样的类行为?测试在BubbleSortTest测试类中,但功能在比较器实现StringComparator中。此测试检查超出单元测试类范围的内容。我可以用它来演示,但这不是一个单元测试。排序实现的真正功能可以用这种方式形式化,无论排序实现抛出什么样的异常,比较器都会抛出什么样的异常。您可以尝试编写这个单元测试,或者继续阅读;我们将在下一节中介绍它。

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

泛型

泛型特性在版本 5 中被引入到 Java 中。从一个例子开始,到目前为止,我们的Sortable接口是这样的:

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

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

package packt.java189fundamentals.ch03.generic;

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

E标识符表示一种类型。它可以是任何类型。如果类实现了接口,即两个方法-sizeget,那么它就是一个可排序的集合。get方法应该返回E类型的内容,不管E是什么。到目前为止,这可能还不太合理,但你很快就会明白重点。毕竟,泛型是一个困难的话题。

Sort接口如下:

package packt.java189fundamentals.ch03.generic;

public interface Sort<E> {
    void sort(Sortable<E> collection);
}

SortSupport变为:

package packt.java189fundamentals.ch03.generic;

import packt.java189fundamentals.ch03.Swapper;

import java.util.Comparator;

public interface SortSupport<E> {
    void setSwapper(Swapper swap);

    void setComparator(Comparator<E> compare);
}

这仍然没有提供比没有泛型的前一个版本更多的澄清,但是,至少,它做了一些事情。在实现Sort接口的实际类中,Comparator应该接受Sortable使用的相同类型。不可能SortableStrings起作用,我们为Integers注入了一个比较器。

BubbleSort的实现如下:

package packt.java189fundamentals.ch03.main.bubble.generic;

// ... imports were removed from printout ...

public class BubbleSort<E> implements Sort<E>, SortSupport<E> {
    private Comparator<E> comparator = null;
    private Swapper swapper = null;

    @Override
    public void sort(Sortable<E> collection) {
        var 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--;
        }
    }

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

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

泛型的真正威力将在我们编写测试时显现。第一个测试没有太大变化,不过,对于泛型,它更明确:

    @Test
    public void canSortStrings() {
        var actualNames = new ArrayList<>(List.of(
            "Johnson", "Wilson",
            "Wilkinson", "Abraham", "Dagobert"
        ));
        var expectedResult = List.of(
            "Abraham", "Dagobert",
            "Johnson", "Wilkinson", "Wilson"
        );
        Sortable<String> names =
            new ArrayListSortable<>(actualNames);
        var sort = new BubbleSort<String>();
        sort.setComparator(String::compareTo);
        sort.setSwapper(new ArrayListSwapper<>
        (actualNames));
        sort.sort(names);
        Assert.assertEquals(expectedResult, 
        actualNames);
    }

当我们定义ArrayList时,我们还将声明列表中的元素将是字符串。当我们分配新的ArrayList时,不需要再次指定元素是字符串,因为它来自那里的实际元素。每一个字符都是一个字符串;因此,编译器知道唯一可以位于<<字符之间的是String

两个字符<<之间没有类型定义,称为菱形运算符。类型是推断的。如果您习惯了泛型,那么这段代码将为您带来有关集合所处理的类型的更多信息,代码的可读性也将提高。可读性和额外的信息不是唯一的问题。

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

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

 1\. @Test
 2\. public void throwsWhateverComparatorDoes() {
 3\.     final ArrayList<String> actualNames =
 4\.         new ArrayList<>(List.of(
 5\.             42, "Wilson"
 6\.         ));
 7\.     final var names = new ArrayListSortable<>
        (actualNames);
 8\.     final var sort = new BubbleSort<>();
 9\.     final var exception = new RuntimeException();
10\.     sort.setComparator((a, b) -> {
11\.         throw exception;
12\.     });
13\.     final Swapper neverInvoked = null;
14\.     sort.setSwapper(neverInvoked);
15\.     try {
16\.         sort.sort(names);
17\.     } catch (Exception e) {
18\.         Assert.assertSame(exception, e);
19\.         return;
20\.     }
21\.     Assert.fail();
22\. }

问题是,它甚至不编译。编译器说它不能推断第四行的ArrayList<>类型。当asList方法的所有参数都是字符串时,该方法返回一个String元素列表,因此新操作符生成ArrayList<String>。这一次,有一个整数,因此编译器无法推断出ArrayList<>是针对String元素的。

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

actualNames.set(0,42);

编译器仍然会知道您要在ArrayList中设置的值应该是String。要获得带有Integer元素的数组,你必须明确地解锁安全手柄并扣动扳机,射击自己:

((ArrayList)actualNames).set(0,42);

我们不这样做,即使是为了考试。我们不想测试 JVM 是否识别出一个Integer不能转换为一个String。该测试由不同的 Java 实现完成。我们真正测试的是,无论比较器抛出什么异常,sort都会抛出相同的异常。

现在,测试如下:

@Test
public void throwsWhateverComparatorDoes() {
    final var actualNames =
        new ArrayList<>(List.of(
            "", "Wilson"
        ));
    final var names = new ArrayListSortable<>(actualNames);
    final var sort = new BubbleSort<>();
    final var exception = new RuntimeException();
    sort.setComparator((a, b) -> {
        throw exception;
    });
    final Swapper neverInvoked = null;
    sort.setSwapper(neverInvoked);
    try {
        sort.sort(names);
    } catch (Exception e) {
        Assert.assertSame(exception, e);
        return;
    }
    Assert.fail();
}

现在,我们将变量actualNames的声明更改为var,以便从右侧表达式推断类型。在这种情况下,它是ArrayList<String>,泛型String参数是从调用List.of()创建的列表中推断出来的。此方法也有泛型参数,因此我们可以编写List.<String>of()。但是,在这个调用中,这个泛型参数是从参数中推断出来的。所有参数都是字符串,因此返回的列表是List<String>。在上一个未编译的示例中,创建的列表具有类型List<Object>。这与左侧的声明不兼容,编译器对此表示不满。如果我们使用var作为变量声明,编译器此时无法检测到此错误,我们将使用List<Object>变量而不是List<String>

我们将交换程序设置为null,因为它从未被调用。当我第一次写这段代码的时候,这对我来说是显而易见的。几天后,我读了代码,就停了下来。“为什么交换器为空?”过了一两秒钟我就想起来了。但是任何时候,当阅读和理解代码时,我都倾向于考虑重构。我可以在一行中添加一条注释,上面写着//never invoked,但注释往往会保留在那里,即使功能发生了变化。我在 2006 年艰难地学会了这一点,当时一个错误的注释使我无法看到代码是如何执行的。我是在调试时阅读注释的,而不是代码,在系统关闭时修复错误花了两天时间。我倾向于使用使代码表达所发生的事情的结构,而不是注释。额外的变量可能会使类文件变大几个字节,但它是由 JIT 编译器优化的,因此最终的代码不会运行得较慢。

抛出异常的比较器是作为 Lambda 表达式提供的。Lambda 表达式可以用于匿名类或命名类只有一个简单方法的情况。Lambda 表达式是匿名方法,存储在变量中或传入参数以供以后调用。我们将在第 8 章中讨论 Lambda 表达式的细节,“扩展我们的电子商务应用”。

现在,我们将继续实现QuickSort,为此,我们将使用 TDD 方法。

测试驱动开发

TDD 是一种代码编写方法,开发人员首先根据规范编写测试,然后编写代码。这与开发者社区所习惯的恰恰相反。我们遵循的传统方法是编写代码,然后为其编写测试。老实说,真正的做法是编写代码并用临时测试进行测试,而根本不使用单元测试。作为一个专业人士,你永远不会那么做,顺便说一句。你总是写测试。(现在,把它写一百遍——我会一直写测试。)

TDD 的优点之一是测试不依赖于代码。由于代码在创建测试时不存在,开发人员不能依赖单元的实现,因此,它不能影响测试创建过程。这通常是好的。单元测试应该尽可能采用黑盒测试。

黑盒测试是不考虑被测系统实现的测试。如果一个系统被重构,以不同的方式实现,但是它提供给外部世界的接口是相同的,那么黑盒测试应该可以正常运行。白盒测试取决于被测系统的内部工作情况。当代码更改白盒测试时,可能还需要对代码进行调优以跟踪更改。白盒测试的优点是测试代码更简单。不总是这样。灰盒测试是两者的混合。

单元测试应该是黑盒测试,但是,很多时候,编写黑盒测试并不简单。开发人员会编写一个他们认为是黑匣子的测试,但很多时候,这种想法被证明是错误的。当实现发生变化时,一些东西被重构,测试不再工作,需要进行纠正。开发人员,尤其是编写单元的开发人员,在了解实现的情况下,会编写一个依赖于代码内部工作的测试。在编写代码之前编写测试是防止这种情况的一种工具。如果没有代码,就不能依赖它。

TDD 还说开发应该是一种迭代的方法。一开始只写一个测试。如果你跑,它就会失败。当然,它失败了!由于还没有代码,它必须失败。然后,您将编写完成此测试的代码。没有更多,只有使这个测试通过的代码。然后,您将继续为规范的另一部分编写新的测试。你将运行它,但它失败了。这证明新的测试测试了一些尚未开发的东西。然后,您将开发代码以满足新的测试,并且可能还将修改在以前的迭代中已经编写的代码块。当代码准备就绪时,测试将通过。

很多时候,开发人员不愿意修改代码。这是因为他们害怕打破已经在工作的东西。当你遵循 TDD,你不应该,同时,你不必害怕这一点。所有已经开发的特性都有测试。如果某些代码修改破坏了某些功能,测试将立即发出错误信号。关键是在修改代码时尽可能频繁地运行测试。

实现快速排序

正如我们已经讨论过的,快速排序由两个主要部分组成。一个是分区,另一个是递归地进行分区,直到整个数组被排序。为了使我们的代码模块化并准备好演示 JPMS 模块处理特性,我们将把分区和递归排序开发成单独的类和单独的包。代码的复杂性不能证明这种分离是合理的。

分区类

分区类应该提供一个基于枢轴元素移动集合元素的方法,我们需要在方法完成后知道枢轴元素的位置。方法的签名应如下所示:

public int partition(Sortable<E> sortable, int start, int end, E pivot);

该类还应该可以访问SwapperComparator。在本例中,我们定义了一个类而不是一个接口;因此,我们将使用构造器注入。

这些构造,如设置器和构造器注入器,是如此的常见和频繁,以至于 IDE 支持这些构造的生成。您需要在代码中创建final字段,并使用代码生成菜单来创建构造器。

分区类将如下所示:

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(Sortable<E> sortable, int start, int end, E pivot) {
        return 0;
    }
}

这段代码什么也不做,但 TDD 就是这样开始的。我们将创建需求的定义,提供代码的框架和调用它的测试。要做到这一点,我们需要一些我们可以分割的东西。最简单的选择是一个Integer数组。partition方法需要一个Sortable<E>类型的对象,我们需要一些包装数组并实现这个接口的东西。我们把那个类命名为ArrayWrapper。这是一个通用类。这不仅仅是为了考试。因此,我们将其创建为生产代码,因此,我们将其放在main目录中,而不是test目录中。因为这个包装器独立于Sort的实现,所以这个类的正确位置是在一个新的SortSupportClasses模块中。我们将创建新模块,因为它不是接口的一部分。实现依赖于接口,而不依赖于支持类。也可能有一些应用使用我们的库,可能需要接口模块和一些实现,但当它们自己提供包装功能时仍然不需要支持类。毕竟,我们不能实现所有可能的包装功能。SRP 也适用于模块。

Java 库往往包含不相关的功能实现。这不好。就短期而言,它使库的使用更简单。您只需要在 POM 文件中指定一个依赖项,就可以拥有所需的所有类和 API。从长远来看,应用变得越来越大,携带了许多属于某些库的类,但应用从不使用它们。

要添加新模块,必须创建模块目录以及源目录和 POM 文件。该模块必须添加到父 POM 中,并且还必须添加到dependencyManagement部分,以便QuickSort模块的测试代码可以使用它而不指定版本。新模块依赖于接口模块,因此必须将此依赖关系添加到支持类的 POM 中。

ArrayWrapper类简单而通用:

package packt.java189fundamentals.ch03.support;

import packt.java189fundamentals.ch03.generic.Sortable;

public class ArrayWrapper<E> implements Sortable<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.java189fundamentals.ch03.support;

import packt.java189fundamentals.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.java189fundamentals.ch03.qsort.phase1;

// ... 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 assertLargeElements(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() {
    final var partitionThis = new Integer[]{0, 7, 6};
    final var swapper = new ArraySwapper<> \   
    (partitionThis);
    final var partitioner =
            new Partitioner<Integer>(
                  (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);
    assertLargeElements(partitionThis, cutIndex, pivot);
}

在 JDK 中,Integer类型没有Comparator,但是很容易将其定义为 Lambda 函数。现在,我们可以编写partition方法,如下所示:

 1\. public int partition(Sortable<E> sortable,
 2\.                      int start,
 3\.                      int end,
 4\.                      E pivot) {
 5\.     var small = start;
 6\.     var large = end;
 7\.     while (large > small) {
 8\.         while(comparator.compare(sortable.get(small), pivot) < 0
 9\.                 && small < large) {
10\.             small++;
11\.         }
12\.         while(comparator.compare(sortable.get(large), pivot) >= 0
13\.                 && small < large) {
14\.             large--;
15\.         }
16\.         if (small < large) {
17\.             swapper.swap(small, large);
18\.         }
19\.     }
20\.     return large;
21\. }

如果我们运行测试,它运行良好。然而,如果我们用覆盖率运行测试,那么 IDE 告诉我们覆盖率只有 92%。这个测试只覆盖了partition方法 14 行中的 13 行。

17行的天沟上有一个红色矩形。这是因为测试数组已经分区。当枢轴值为6时,不需要交换其中的任何元素。这意味着我们的测试很好,但还不够好。如果那条线上有错误怎么办?

为了修正这个问题,我们将扩展测试,将测试数组从{0, 7, 6 }改为{0, 7, 6, 2}。运行测试,它将失败。为什么?经过调试,我们将发现调用方法partition,并将固定参数2作为数组的最后一个索引。但是,我们把数组做得更长。为什么我们首先在那里写一个常数?这是一个坏做法。让我们用partitionThis.length-1替换。现在,它说cutIndex2,但我们期望1。我们忘记将断言调整为新数组。我们来修吧。现在它有效了。

最后一件事是重新考虑这些断言。代码越少越好。断言方法非常通用,我们将对单个测试数组使用它。断言方法非常复杂,它们值得自己测试。但是,我们不编写测试代码。相反,我们可以简单地删除这些方法,并将测试的最终版本如下所示:

@Test
public void partitionsIntArray() {
    final var partitionThis = new Integer[]{0, 7, 6, 2};
    final var swapper = new ArraySwapper<>(partitionThis);
    final var partitioner =
            new Partitioner<Integer>(
        (a, b) -> a < b ? -1 : a > b ? +1 : 0, swapper);
    final var pivot = 6;
    final var cutIndex = partitioner.partition(
            new ArrayWrapper<>(partitionThis),
            0,
            partitionThis.length - 1,
            pivot);
    Assert.assertEquals(2, cutIndex);
    final var expected = new Integer[]{0, 2, 6, 7};
    Assert.assertArrayEquals(expected, partitionThis);
}

再说一遍,这是黑箱测试吗?如果分区返回{2, 1, 7, 6}呢?这符合定义。我们可以创建更复杂的测试来覆盖这些情况。但是更复杂的测试本身也可能有一个 bug。作为一种不同的方法,我们可以创建可能更简单但依赖于实现的内部结构的测试。这些不是黑盒测试,因此也不是理想的单元测试。我会选择第二个,但如果有人选择另一个,我不会争辩。

递归排序

我们将使用qsort包中的一个额外类和分区类来实现快速排序,如下所示:

package packt.java189fundamentals.ch03.qsort;

// ... imports are deleted from print ...
public class Qsort<E> {
    final private Comparator<E> comparator;
    final private Swapper swapper;
// ... constructor setting fields deleted from print ...
    public void qsort(Sortable<E> sortable, int start, int end) {
        if (start < end) {
            final var pivot = sortable.get(start);
            final var partitioner = new Partitioner<E>(comparator, swapper);
            var cutIndex = partitioner.partition(sortable, start, end, pivot);
            if (cutIndex == start) {
                cutIndex++;
            }
            qsort(sortable, start, cutIndex - 1);
            qsort(sortable, cutIndex, end);
        }
    }
}

该方法得到Sortable<E>和两个指标参数。它不会对整个集合进行排序;它只对startend索引之间的元素进行排序。

非常精确的索引总是很重要的。通常,Java 中的起始索引没有问题,但是很多错误源于如何解释end索引。在这种方法中,end的值可能意味着索引已经不是待排序区间的一部分。在这种情况下,应该使用end-1调用partition方法,并使用end-1作为最后一个参数调用第一个递归调用。这是品味的问题。重要的是要精确定义指标参数的解释。

如果只有一个(start == end)元素,则没有要排序的内容,方法返回。这是递归的结束标准。该方法还假设end指数从不小于start指数。由于这种方法只在我们目前正在开发的库中使用,所以这样的假设不太冒险。

如果有要排序的内容,则该方法将要排序的间隔的第一个元素作为轴心并调用partition方法。当分区完成时,该方法递归地调用自己的两部分。

这个算法是递归的。这意味着该方法调用自身。当一个方法调用被执行时,处理器在一个名为的区域中分配一些内存,并在那里存储局部变量。这个属于栈中方法的区域称为栈帧。当方法返回时,释放此区域并恢复栈,只需将栈指针移动到调用之前的位置。这样,一个方法可以在调用另一个方法后继续执行;局部变量就在那里。

当一个方法调用它自己时,它没有什么不同。局部变量是方法实际调用的局部变量。当方法调用自身时,它会在栈上再次为局部变量分配空间。换句话说,这些是局部变量的新实例。

我们在 Java 中使用递归方法,在其他编程语言中,当算法的定义是递归的时,非常重要的是要理解当处理器代码运行时,它不再递归。在这一级别上,有指令、寄存器和内存加载和跳跃。没有什么比函数或方法更像,因此,在这个级别上,没有什么比递归更重要的了。

如果你明白了,很容易理解任何递归都可以被编码成循环。

事实上,在每个循环周围,也可以用递归的方式进行编码,但在开始函数编程之前,这并不真正有趣。

在 Java 和许多其他编程语言中,递归的问题是它可能会耗尽栈空间。对于快速排序,情况并非如此。您可以安全地假设 Java 中方法调用的栈只有几百层。快速排序需要一个深度约为log2(n)的栈,其中n是要排序的元素数。在 10 亿元素的情况下,这是 30,应该正好合适。

为什么栈没有移动或调整大小?这是因为耗尽栈空间的代码通常是糟糕的样式。它们可以以某种循环的形式以更可读的形式表示。一个更加健壮的栈实现只会吸引新手程序员去做一些可读性较差的递归编码。

递归有一个特例,叫做尾部递归。尾部递归方法将自己作为方法的最后一条指令调用。当递归调用返回代码时,调用方法只释放用于此方法调用的栈帧。换句话说,我们将在递归调用期间保留栈帧,以便在调用之后立即丢弃它。为什么不在电话前把它扔掉呢?在这种情况下,实际帧将被重新分配,因为这与保留的方法相同,并且递归调用被转换为跳转指令。这是一个 Java 没有做的优化。函数式语言正在这样做,但 Java 并不是真正的函数式语言,因此应该避免使用尾部递归函数,并将其转换为 Java 源代码级别的循环。

非递归排序

为了证明即使是非尾部递归方法也可以用非递归的方式来表示,这里有一个这样的快速排序:

 1\. public class NonRecursiveQuickSort<E> {
 2\. // ... same fields and constructor as in Qsort are  
    deleted from print ...
 3\. 
 4\.     private static class StackElement {
 5\.         final int begin;
 6\.         final int fin;
 7\. 
 8\.         public StackElement(int begin, int fin) {
 9\.             this.begin = begin;
10\.             this.fin = fin;
11\.         }
12\.     }
13\. 
14\.     public void qsort(Sortable<E> sortable, int  
        start, int end) {
15\.         final var stack = new  
        LinkedList<StackElement>();
16\.         final var partitioner = new Partitioner<E> 
            (comparator, swapper);
17\.         stack.add(new StackElement(start, end));
18\.         var i = 1;
19\.         while (!stack.isEmpty()) {
20\.             var it = stack.remove(0);
21\.             if (it.begin < it.fin) {
22\.                 final E pivot =  
                    sortable.get(it.begin);
23\.                 var cutIndex = 
              partitioner.partition(sortable, it.begin, 
              it.fin, pivot);
24\.                 if( cutIndex == it.begin ){
25\.                     cutIndex++;
26\.                 }
27\.                 stack.add(new StackElement(it.begin, 
                     cutIndex - 1));
28\.                 stack.add(new StackElement(cutIndex, 
                     it.fin));
29\.             }
30\.         }
31\.     }
32\. }

这段代码在 Java 级别实现了一个栈。虽然在stack中似乎还有一些被安排排序的内容,但它从栈中取出它并进行排序分区,并安排这两部分进行排序。

这段代码比前一段代码更复杂,您必须了解StackElement类的角色及其工作方式。另一方面,程序只使用一个Partitioner类实例,也可以使用线程池来安排后续排序,而不是在单个进程中处理任务。在多 CPU 机器上执行排序时,这可能会加快排序速度。但是,这是一个更复杂的任务,本章包含了许多没有多任务处理的新事物;因此,我们将在后面的两章中介绍多线程代码。

在排序的第一个版本中,我对它进行了编码,没有三行代码将cutIndex与间隔起始进行比较,并在if分支中增加它(第 24-26 行)。这是非常需要的。但是,我们在本书中创建的单元测试如果错过了这些行,就不会发现 bug。我建议您删除这些行并尝试编写一些失败的单元测试。然后,试着理解当这些行非常重要时的特殊情况是什么,并试着修改单元测试,以便尽可能简单地发现 bug。(最后,将四行放回原处,看看代码是否有效。)另外,找出一些不将此修改放在方法partition中的架构原因。在large == start的情况下,该方法只能返回large+1

实现 API 类

完成所有这些之后,我们最不需要的就是把QuickSort作为一个简单的类(所有真正的工作都已经在不同的类中完成了):

public class QuickSort<E> extends AbstractSort<E> {
    public void sort(Sortable<E> sortable) {
        final var n = sortable.size();
        final var qsort = new Qsort<E>(comparator,swapper);
        qsort.qsort(sortable, 0, n-1);
    }
}

别忘了我们还需要一个测试!但是,在这种情况下,这与BubbleSort没有太大区别:

    @Test
    public void canSortStrings() {
        final var actualNames = new String[]{
                "Johnson", "Wilson",
                "Wilkinson", "Abraham", "Dagobert"
        };
        final var expected = new String[]{"Abraham",
                "Dagobert", "Johnson", "Wilkinson", "Wilson"};
        var sort = new QuickSort<String>();
        sort.setComparator(String::compareTo);
        sort.setSwapper(new ArraySwapper<>(actualNames));
        sort.sort(new ArrayWrapper<>(actualNames));
        Assert.assertArrayEquals(expected, actualNames);
    }

这次我们用了String数组而不是ArrayList。这使得这个测试更简单,而且,这一次,我们已经有了支持类。

您可能认识到这不是单元测试。在BubbleSort的情况下,算法是在单个类中实现的。测试单个类是一个单元测试。在QuickSort的例子中,我们将函数划分为不同的类,甚至是不同的包。对QuickSort类的真正单元测试将揭示该类对其他类的依赖性。当这个测试运行时,它涉及到PartitionerQsort的执行,因此,它不是一个真正的单元测试。

我们应该为此烦恼吗?不是真的。我们希望创建涉及单个单元的单元测试,以便在单元测试失败时知道问题所在。如果只有集成测试,一个失败的测试用例将无助于指出问题所在。它只说明测试中涉及的类中存在一些问题。在本例中,只有有限数量的类(三个)参与了这个测试,并且它们被绑定在一起。它们实际上是紧密联系在一起的,而且彼此之间的联系如此紧密,以至于在实际的生产代码中,我可以在单个模块中实现它们。我在这里将它们分开,以演示如何测试单个单元,并演示 Java 模块支持,它需要的不仅仅是 JAR 文件中的单个类。

创建模块

模块处理,也称为项目 JigsawJPMS,是仅在 Java9 中提供的特性。这是一个计划已久的专题。首先,它是为 Java7 设计的,但是它太复杂了,所以被推迟到 Java8,然后是 Java9。最后,JPMS 被包含在 Java 的 Release9 中。与此同时,Oracle 引入了长期和短期支持发布的概念。只有在该语言的下一个版本发布之前,才支持短期版本。另一方面,长期版本的支持时间更长,很多次甚至在新版本甚至新的长期支持版本发布后的几年。在 Java9 之前,所有版本都是长期支持版本。如果有任何影响应用稳定性或安全性的重大缺陷,Oracle 正在创建新的次要版本。当 Java1.8 可用时,甚至还为 Java1.6 创建了新版本。

当时 ORACLE 宣布 Java9 和 Java9 将不再是长期受支持的版本。然而,根据新的版本控制方案编号的 Java9 或 Java18.9 是一个长期支持版本,因此,它是第一个实现了 JPMS 的长期支持版本。

为什么需要模块

我们已经看到 Java 中有四种访问级别。当类内部没有提供修饰符时,方法或字段可以是privateprotectedpublicdefault(也称为包私有)。当您开发一个用于多个项目的复杂库时,库本身将在许多包中包含许多类。当然会有一些类和方法,这些类和方法中的字段应该只在库中由来自不同包的其他类使用。这些类不能被库外的代码使用。使它们比public更不可见会使它们在库中无法使用。制造它们public将使它们从外面可见。这不好。

在我们的代码中,编译成 JAR 的 Maven 模块quick只有在sort方法可以调用qsort的情况下才能使用。但是,我们不希望qsort直接从外部使用。在下一个版本中,我们可能希望开发一个使用来自NonRecursiveQuickSort类的qsort的版本,我们不希望客户抱怨他们的代码由于库的小升级而无法编译或工作。我们可以证明,内部方法和类是公共的,它们不是用来使用的,而是徒劳的。使用我们库的开发人员不阅读文档。这也是为什么我们不写过多的注释。没有人会读它,甚至执行代码的处理器也不会。

什么是 Java 模块?

Java 模块是 JAR 或目录中类的集合,其中还包含一个名为module-info的特殊类。如果 JAR 或目录中有这个文件,那么它就是一个模块,否则,它只是classpath上的类的集合(或者不是)。Java8 和早期版本只会忽略该类,因为它从未用作代码。这样,使用较旧的 Java 不会造成伤害,并且保持了向后兼容性。

创建这样一个罐子有点棘手。module-info.class文件应具有符合 Java9 字节码或更高版本的字节码,但其他类应包含较旧版本的字节码。

模块信息定义了模块导出的内容及其所需的内容。它有一种特殊的格式。例如,我们可以将module-info.java放在我们的SortInterfaceMaven 模块中:

module packt.java189fundamentals.SortInterface{
    exports packt.java189fundamentals.ch03;
    exports packt.java189fundamentals.ch03.generic;
}

这意味着可以从外部使用publicpackt.java189fundamentals.ch03包内部的任何类。这个包是从模块导出的,但是从模块外部看不到其他包中的其他类,即使它们是public。命名要求与包的情况相同,应该有一个不可能与其他模块名称冲突的名称。反向域名是一个很好的选择,但它不是必须的,你可以在这本书中看到。还没有顶级域packt

我们还应该修改父 POM,以确保我们使用的编译器是 Java9 或更高版本,在project/build/plugins/处配置 Maven 编译器插件:

<plugin>
    <groupId>org.apache.maven.plugins</groupId>
    <artifactId>maven-compiler-plugin</artifactId>
    <version>3.7.0</version>
    <configuration>
        <source>1.10</source>
        <target>1.10</target>
    </configuration>
    <dependencies>
        <dependency>
            <groupId>org.ow2.asm</groupId>
            <artifactId>asm</artifactId>
            <version>6.1.1</version> 
        </dependency>
    </dependencies>
</plugin>

旧版本会与module-info.java文件混淆。(顺便说一句,即使是我在本书第一版中使用的 Java9 的早期访问版本有时也会给我带来困难。)

我们还在 Maven 模块中创建了一个module-info.java文件quick,如下所示:

module packt.java189fundamentals.quick {
    exports packt.java189fundamentals.ch03.quick;
    requires packt.java189fundamentals.SortInterface;
    }

这个模块导出另一个包,需要我们刚刚创建的packt.java189fundamentals.SortInterface模块。现在,我们可以编译模块,./quick/target./SortInterface/target目录中创建的 Jar 现在是 Java 模块。

为了测试模块支持的功能,我们将创建另一个名为Main的 Maven 模块。它只有一个类,叫做Main,有一个public static void main方法:

package packt.java189fundamentals.ch03.main;

// ... imports are deleted from print ...

public class Main {
    public static void main(String[] args) throws IOException {
        final var fileName = args[0];
        BufferedReader br = null;
        try {
            br = new BufferedReader(new InputStreamReader(new FileInputStream(new File(fileName))));
            final var lines = new LinkedList<String>();
            String line;
            while ((line = br.readLine()) != null) {
                lines.add(line);
            }
            String[] lineArray = lines.toArray(new String[0]);
            var sort = new FQuickSort<String>();
            sort.setComparator((a, b) -> ((String) a).compareTo((String) b));
            sort.setSwapper(new ArraySwapper<>(lineArray));
            sort.sort(new ArrayWrapper<>(lineArray));
            for (final String outLine : lineArray) {
                System.out.println(outLine);
            }
        } finally {
            if (br != null) {
                br.close();
            }
        }
    }
}

它接受第一个参数(不检查是否有一个参数,我们不应该在生产代码中使用它)并将其用作文件名。然后,它将文件的行读入一个String数组,对其排序,并将其打印到标准输出。

由于模块支持只对模块起作用,这个 Maven 模块也必须是 Java 模块,并且有一个module-info.java文件:

module packt.java189fundamentals.Main{
    requires packt.java189fundamentals.quick;
    requires packt.java189fundamentals.SortInterface;
    requires packt.java189fundamentals.SortSupportClasses;
}

此外,我们必须为支持模块创建一个module-info.java文件;否则,我们将无法从我们的模块中使用它。

在使用mvn install编译模块之后,我们可以运行它来打印已排序文件的行。例如,我们可以打印出排序后的父 POM 的行,这没有多大意义,但很有趣。下面是启动 Java 代码的 Windows 命令文件:

set MODULE_PATH=Main/target/Main-1.0.0-SNAPSHOT.jar;
set MODULE_PATH=%MODULE_PATH%SortInterface/target/SortInterface-1.0.0-SNAPSHOT.jar;
set MODULE_PATH=%MODULE_PATH%quick/target/quick-1.0.0-SNAPSHOT.jar;
set MODULE_PATH=%MODULE_PATH%SortSupportClasses/target/SortSupportClasses-1.0.0-SNAPSHOT.jar
java -p %MODULE_PATH% -m packt.java189fundamentals.Main/packt.java189fundamentals.ch03.main.Main pom.xml

JAR 文件位于模块路径上,该路径通过命令行选项-p提供给 Java 执行。要启动模块中类的public static void main()方法,仅指定类的完全限定名是不够的。我们必须使用-m选项,后跟模块和类的module/class格式规范。

现在,如果我们尝试直接访问Qsort,将下面的行Qsort<String> qsort = new Qsort<>(String::compareTo,new ArraySwapper<>(lineArray));插入main方法,Maven 会抱怨,因为模块系统对我们的Main类隐藏了它。

模块系统还支持基于java.util.ServiceLoader的类加载机制,这在本书中我们将不讨论。当使用 Spring、Guice 或其他依赖注入框架时,这是一种很少在企业环境中使用的老技术。如果您看到一个包含usesprovides关键字的module-info.java文件,那么请首先查阅 Java 文档中关于ServiceLoader的文档,然后是关于模块支持的 Java9 语言文档

总结

在本章中,我们开发了一个实现快速排序的通用排序算法。我们将项目修改为多模块 Maven 项目,并使用 Java 模块定义。我们使用 JUnit 开发单元测试,并使用 TDD 开发代码。我们使用泛型将代码从旧式 Java 转换为新的,并使用异常处理。在接下来的章节中,我们将开发一个猜谜游戏,这些是需要的基本工具。首先,我们将开发一个更简单的版本,在下一章中,我们将开发一个使用并行计算和多处理器的版本。

四、Mastermind-创造游戏

在本章中,我们将开始开发一个简单的游戏。游戏是主谋,两个玩家。玩家一从六种可能的颜色中选择四种不同颜色的别针,并将它们排列在一个棋盘上,对另一个玩家隐藏起来。另一个玩家试着猜别针的颜色和位置。在每一次猜测中,玩家一猜匹配颜色的数量以及匹配颜色和位置的针脚。该程序将同时充当播放器 1 和播放器 2。我们的代码将单独运行。然而,留给我们玩的是最重要的代码。

这个例子非常复杂,足以深化面向对象OO)原则,以及我们如何设计类和建模现实世界。我们已经使用了 Java 运行时中提供的类。这次,我们将使用集合并讨论这一重要领域。这些类和接口在 JDK 中广泛使用和可用,对于专业 Java 开发人员来说,它们和语言本身一样重要。

这次的构建工具是 Gradle。

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

  • Java 集合
  • 依赖注入
  • 如何注释代码和创建 JavaDoc 文档
  • 如何创建集成测试

游戏

策划人是一个古老的游戏。在每个有孩子的房子里随处可见的塑料版本是 1970 年发明的。我在 1980 年得到了一块棋盘作为圣诞礼物,一个用 BASIC 语言解决游戏难题的程序是我在 1984 年左右创建的第一个程序之一。

游戏板上有四列几行的洞。有六种不同颜色的塑料别针可以插入孔中。每个针都有一种颜色。它们通常是红色、绿色、蓝色、黄色、黑色和白色。有一个特殊的行对其中一个玩家(猜测者)隐藏。

要玩这个游戏,其中一个玩家(hider)必须从一组别针中选择四个别针。所选管脚应具有不同的颜色。这些插针被一个接一个地放置在隐藏的行中,每个插针都处于一个位置。

猜测者试图找出什么颜色在哪个位置,猜测。每个猜测选择四个管脚并将它们排成一行。隐藏者告诉猜测者有多少针脚在正确的位置,有多少针脚的颜色在桌子上,但不在正确的位置:

示例剧本可能是这样的:

  1. 隐藏者隐藏了四个针脚,分别是蓝色、黄色、白色和黑色。

  2. 猜测者猜测黄色、蓝色、绿色和红色。

  3. 隐藏者告诉猜测者有两种颜色匹配,但没有一种颜色在隐藏行中的正确位置。隐藏者这样说是因为黄色和蓝色在隐藏行中,而不是在猜测者猜测的位置。它们实际上是交换的,但是这个信息隐藏者是保密的。他们只说有两种颜色匹配,没有一种在正确的位置。

  4. 下一个猜测是。。。

当猜猜者找到正确的颜色以正确的顺序时,游戏结束。同样的游戏也可以用文字符号来描述-B表示蓝色,Y表示黄色,G表示绿色,W表示白色,R表示红色,b表示黑色(幸运的是,计算机上有上下小写字母):

RGBY 0/0

GRWb 0/2
YBbW 0/2
BYGR 0/4
RGYB 2/2
RGBY 4/0

你猜怎么着!这是我们将在本章中开发的程序的实际输出。

我们也玩了这个游戏,允许一个位置是空的。这与第七种颜色基本相同。当我们发现游戏太简单,即使有七种颜色,我们改变了规则,允许颜色出现在不同的位置。这些都是游戏的有效变体。

在本章中,我们将使用六种颜色,在隐藏行中不使用颜色重复。游戏的其他版本编程起来有点复杂,但它们本质上是相同的,解决这些变化不会增加我们的学习经验。

游戏的模型

当我们用面向对象的思想开发一段代码时,我们会尝试对真实世界建模,并将真实世界的对象映射到程序中的对象。你肯定听过面向对象的解释,用非常典型的几何物体的例子,或者用汽车和马达的东西来解释组成。就我个人而言,我认为这些例子太简单了,无法得到很好的理解。他们可能是好的开始,但我们已经在这本书的第四章。策划者的游戏好多了。它比矩形和三角形要复杂一些,但没有电信计费应用或原子能发电厂控制那么复杂。

在这个游戏中,我们有哪些真实世界的物体?我们有一张桌子,我们有不同颜色的别针。我们当然需要两个 Java 类。桌子里有什么?每行有四个位置。也许我们需要一个类。表将有行。我们还需要一些隐藏秘密的东西。这也可以是一行,并且每行还可以保存关于有多少位置和多少颜色匹配的信息。在秘密行的情况下,这个信息是明显的 -4 和 0。

什么是别针?每个别针都有一种颜色,通常就是它。除了可以插入桌子上的孔之外,没有其他的销钉的特性,但这是我们不会建模的真实特性。基本上,别针是一种颜色,而不是别的。这样,我们可以在早期就从模型中消除别针类,甚至在我们用 Java 创建别针类之前。相反,我们有颜色。

什么是颜色?这可能是第一次很难理解的。我们都很清楚什么是颜色。它是不同频率光的混合物,正如我们的眼睛所感知的那样。我们可以有不同颜色的油漆和印刷品,等等。在这个程序中有很多东西我们没有建模。在我们的代码中,很难判断我们对颜色的模型是什么,因为这些特性非常明显,在现实生活中我们认为这是理所当然的;我们可以分辨出两种颜色是不同的。这是我们唯一需要的功能。为此,可以使用最简单的 Java 类:

public class Color {
}

如果您有两个类型为Color的变量,您可以判断它们是否相同。可以使用表达式a == b比较ab的对象标识,也可以使用继承自Objecta.equals(b)equals()方法。用字母来编码颜色或用String常数来表示它们是很诱人的。首先可能比较容易,但之后会有严重的缺点。当代码变得复杂时,它会导致 bug;传递同样编码为String而不是颜色的东西很容易,而且只有单元测试可以节省时间。这比编译器在 IDE 中输入错误参数时发出的抱怨要好。

当我们玩游戏时,别针在小盒子里。我们从盒子里拔出别针。我们如何在程序中获得颜色?我们需要一些东西,从那里我们可以得到颜色。或者换个角度看,我们需要一些能给我们颜色的东西。我们称之为ColorManager。现在,ColorManager知道我们有多少种不同的颜色,任何时候我们需要一种颜色,我们都可以要求它。

同样,有一种设计ColorManager的诱惑,它可以通过序列号来提供颜色。如果我们有四种颜色,我们可以要求颜色数字 0,1,2,或 3。但话说回来,它只是将颜色隐式编码为整数,我们同意不这样做。我们应该找到最基本的功能,我们将需要模型的游戏。

为了描述类的结构,专业开发人员通常使用统一建模语言UML)类图。UML 是一种标准化的图表符号,几乎只用于可视化软件架构。UML 中有许多图表类型来描述程序的静态结构和动态行为。这一次,我们将看到一个非常简化的类图:

我们没有空间去深入了解 UML 类图的细节。矩形表示类,法线箭头表示类具有另一类类型的字段时的关系,三角形箭头表示类扩展另一类。箭头指向要扩展的类的方向。

一个Game类包含一个秘密Row类和一个Table类。TableColorManager类和Row类的List<>ColorManager具有第一颜色,并且具有Color类的Map<>。我们还没有讨论为什么会有这样的设计;我们将到达那里,图表帮助我们做到这一点。一个Row类本质上是一个Color类的数组。

玩家有一个功能,他们必须猜很多次,直到他们找到隐藏的秘密。为了得到ColorManager的模型,我们需要设计Guesser的算法。

当玩家做出第一个猜测时,任何颜色的组合都和其他颜色一样好。稍后,猜测应该考虑之前猜测的回答。这是一个合理的方法,只尝试颜色的变化,可以是真正的秘密。玩家选择一个变体并查看所有先前的猜测,假设所选变体是秘密。如果对他们已经做出的行的响应与对游戏中未知秘密的响应相同,那么尝试这种变化是合理的。如果在反应中有任何差异,那么这种变化肯定不是隐藏的变化。

有更复杂的方法,这个游戏有一个特殊的策略,选择一个颜色变化从一组可能的猜测匹配的答案。我们这里不讨论这些算法。当我们找到一个颜色变化,可以解决,我们将使用这个。

为了遵循这种方法,猜测者必须一个接一个地生成所有可能的颜色变化,并将它们与表格进行比较。猜测代码不会创建和存储所有可能的变体。它必须知道它在哪里,并且能够计算出下一个变化。这假定了变化的顺序。暂时,让我们忘记没有颜色可能会出现两次的变化。一个简单的排序方法可以和我们对十进制数排序的方法一样。如果我们有一个三位数的数字,那么第一个是 000,下一个是 001,依此类推直到 009,总是取最后一个位置的下一个数字。之后,010 来了。我们在最后一个数字旁边增加了一个数字,然后又将最后一个数字设为 0。现在,我们有 011012,等等。你知道,我们是怎么数数的。现在,把数字换成颜色,我们只有六个而不是十个。或者,当我们实例化一个ColorManager对象时,我们有我们想要的任何数量。

这就产生了ColorManager的功能。它必须做以下两件事:

  • 给调用者第一种颜色
  • 给出给定颜色后面的下一种颜色(我们将方法命名为nextColor

后一种功能也应该在没有下一种颜色时发出信号。这将使用另一个名为thereIsNextColor的方法来实现。

这是一个惯例,以返回布尔值的方法名开始使用is。这将导致这个约定后面的名称-isThereNextColor;或者这个名称-isNextColor。这两个名称中的任何一个都解释了方法的功能。如果我问问题isThereNextColor,方法会回答我truefalse。但是,这不是我们将如何使用的方法。我们将用简单的句子交谈。我们将使用短句。我们将避免不必要的、胡言乱语的表达。我们也将以这种方式编程。调用者很可能会在if语句中使用此方法。他们将写下:

 if( thereIsNextColor(currentColor)){...}

They will not write this:

 if( isThereNextColor(currentColor)){...}

我认为第一个版本更具可读性,可读性是第一位的。最后,但并非最不重要的一点是,如果你遵循旧的惯例,没有人会责怪你,如果这是公司的标准,你无论如何都必须这样做。

要做到这一切,ColorManager还必须创建颜色对象,并将它们存储在有助于执行操作的结构中:

 1\. package packt.java189fundamentals.example.mastermind;
 2\. 
 3\. import java.util.HashMap;
 4\. import java.util.Map;
 5\. 
 6\. public class ColorManager {
 7\.     final protected int nrColors;
 8\.     final protected Map<Color, Color> successor = new HashMap<>();
 9\.     private Color first;
10\. 
11\.     public ColorManager(int nrColors) {
12\.         this.nrColors = nrColors;
13\.         createOrdering();
14\.     }
15\. 
16\.     protected Color newColor(){
17\.         return new Color();
18\.     }
19\. 
20\.     private Color[] createColors() {
21\.         Color[] colors = new Color[nrColors];
22\.         for (int i = 0; i < colors.length; i++) {
23\.             colors[i] = newColor();
24\.         }
25\.         return colors;
26\.     }
27\. 
28\.     private void createOrdering() {
29\.         Color[] colors = createColors();
30\.         first = colors[0];
31\.         for (int i = 0; i < nrColors - 1; i++) {
32\.             successor.put(colors[i], colors[i + 1]);
33\.         }
34\.     }
35\.     public Color firstColor() {
36\.         return first;
37\.     }
38\. 
39\.     boolean thereIsNextColor(Color color) {
40\.         return successor.containsKey(color);
41\.     }
42\. 
43\.     public Color nextColor(Color color) {
44\.         return successor.get(color);
45\.     }
46\. }

我们使用的结构是一个Map。现在,Map是 Java 运行时中定义的一个接口,从 Java 的早期版本开始就可以使用。Map有键和值,对于任何键,您都可以轻松地检索分配给键的值。

您可以在第 8 行看到,successor变量的定义,我们将变量的类型定义为接口,但值是类的实例。显然,该值不能是接口的实例,因为这样的对象不存在。但是,为什么我们要将变量定义为接口呢?原因是抽象和编码实践。如果出于某种原因需要更改所使用的实现,那么变量类型可能仍然保持不变,不需要在其他地方更改代码。将变量声明为接口也是一种很好的做法,这样我们就不会因为方便而使用接口中不可用的实现的某些特殊 API。当确实需要时,我们可以更改变量的类型并使用特殊的 API。毕竟,API 的存在是有原因的,但是仅仅因为 API 存在而使用某些特殊事物的诱惑是受到阻碍的。这有助于编写更简单、更干净的程序。

Map只是 Java 运行时中定义的属于 Java 集合的接口之一。还有许多其他接口和类。尽管 JDK 和所有的类都很多,而且几乎没有人知道其中的所有类,但是集合是一个专业开发人员应该了解的特殊领域。在详细说明此代码中使用HashMap的原因之前,我们将对集合类和接口进行概述。这将有助于我们了解本程序中使用的其他集合。

Java 集合

集合是帮助我们存储多个对象的接口和类。我们已经看到了数组,它可以做到这一点。我们在前面的章节中也看到了ArrayList。我们没有详细讨论 JDK 中还有哪些其他可能性。在这里,我们将更详细地讨论,但将流和函数方法留给后面的章节,我们也将避免讨论细节。那是一本参考书的任务。

使用集合类和接口的实现可以减少编程工作。首先,您不需要编写已经存在的程序。其次,这些类在实现和特性上都进行了高度优化。他们有非常好的设计 API 和代码是快速的,并使用小内存占用。但是,他们的代码是很久以前写的,风格不好,很难阅读和理解。

当您使用来自 JDK 的集合时,更有可能与某些库进行互操作。如果你自己制作一个链表,你不可能找到一个现成的解决方案来排序你的列表。如果您使用 JDK 标准类库中的LinkedList类,您将从Collections类获得现成的解决方案,就在 JDK 中。还值得一提的是,Java 语言本身支持这些类。例如,您可以使用for命令的缩短的特殊语法轻松地遍历Collection的元素。

JDK 中的集合包含定义不同集合类型、实现类和执行某些操作(如排序)的算法的行为的接口。很多时候,这些算法在不同的实现版本上工作,得到相同的结果,但是针对特定于实现的类进行了优化。

您可以使用接口提供的 API,如果您在代码中更改实现,您将获得适合实现的优化版本。

下图显示了不同Collection接口之间的关系:

Collection接口可分为两类。一个包包含扩展Collection接口的接口,另一个包包含Map和扩展MapSortedMap。这样,Map就不是一个真正的集合,因为它不仅仅包含其他对象,而是成对的值和键。

Collection接口

Collection是接口层次结构的顶层。这个接口定义了所有实现应该提供的方法,不管它们是直接实现SetSortedSetListQueue还是Deque接口。正如Collection简单地说,实现Collection接口的对象只是一个将其他对象集合在一起的对象,它定义的方法就像向集合中添加一个新对象,清除其中的所有元素,检查一个对象是否已经是集合的成员,并遍历这些元素。

有关接口的最新定义,请参阅 Java API 文档。您可以随时查阅在线 API,建议您这样做。

Java 语言本身直接支持接口。您可以使用增强的for循环语法遍历Collection的元素,就像您可以迭代数组中的元素一样,在数组中集合应该是表达式,从而导致实现Collection接口的对象:

for( E element : collection ){...}

在前面的代码中,E要么是对象,要么是Collection元素的泛型类型。

JDK 中没有直接实现Collection接口。类实现了Collection的一个子接口。

Set

Set是一个特殊的集合,不能包含重复的元素。当您想将一个对象添加到一个已经有该对象或一个与实际对象相等的对象集时,add方法将不会添加实际对象。add方法返回false,表示失败。

当您需要一个唯一元素的集合时,您可以在程序中使用Set,您只需要检查一个元素是否是一个集合的成员,一个对象是否属于某个组。

当我们返回到我们的程序代码时,我们将看到UniqueGuesser类必须实现一个算法来检查猜测中的颜色只存在一次。此算法是使用Set的理想候选者:

    private boolean isNotUnique(Color[] guess) {
        final var alreadyPresent = new HashSet<Color>();
        for (final var color : guess) {
            if (alreadyPresent.contains(color)) {
                return true;
            }
            alreadyPresent.add(color);
        }
        return false;
    }

代码创建一个集合,当方法启动时该集合为空。之后,它检查每种颜色(注意数组元素上增强的for循环),如果它以前已经存在的话。为此,代码检查color是否已经在集合中。如果有,猜测是不是唯一的,因为我们已经发现了一种颜色,是目前至少两次。如果color不在场景中,那么猜测的颜色仍然是唯一的。为了以后能够检测到,代码将颜色放入集合中。

我们使用的Set的实际实现是HashSet。在 JDK 中,有许多类实现了Set接口。使用最广泛的是HashSet。值得一提的还有EnumSetLinkedHashSetTreeSet。最后一个还实现了SortedSet接口,我们将在这里详细介绍。

为了理解什么是HashSet(以及后面的HashMap)以及它们是如何工作的,我们必须讨论什么是散列。它们在许多应用中起着非常重要的核心作用。他们在 JDK 中秘密地工作,但是程序员必须遵循一些非常重要的限制,否则真的很奇怪而且很难找到 bug 会让他们的生活很悲惨。我敢说,违反了HashSetHashMap中的哈希约定,是继多线程问题之后第二个最难发现的 bug 的原因。

因此,在继续不同的集合实现之前,我们将访问这个主题。在本次讨论集合的绕道中,我们的示例已经深入了一个层次,现在我们将更深入一个层次。我保证这是最后一次深入的迂回。

散列函数

散列是一个数学函数,它为一个元素赋值。听起来很可怕,不是吗?假设你是一名大学行政人员,你必须判断威尔金森是否是你班的学生。你可以把名字放在信封里的小纸上,每封信一个。不用搜索 10000 名学生,你可以查看信封中标题为 W 的论文。这个非常简单的哈希函数将名字的第一个字母指定给名字(或者字母的序数,正如我们所说的哈希函数产生一个数字)。这实际上不是一个好的散列函数,因为它只将一些元素(如果有的话)放入表示为 X 的信封中,而将许多元素放入 A 中。

好的散列函数以相似的概率返回每个可能的序数。在哈希表中,我们通常拥有比要存储的元素数量更多的桶(在上一个示例中是信封)。因此,当搜索一个元素时,很可能只有一个元素。至少这是我们想要的。如果一个桶中有多个元素,则称为碰撞。一个好的哈希函数有尽可能少的冲突。

为了向后兼容,JDK 中有一个Hashtable类。这是第一个版本中第一个用 Java 实现的哈希表,因为 Java 是向后兼容的,所以它没有被丢弃。Map接口仅在版本 1.2 中引入。Hashtable有很多缺点,不推荐使用。(甚至名称也违反了 Java 命名约定)本书中我们不讨论这个类。每当我们谈论哈希表时,我们指的是HashSetHashMap实现中的实际数组,或者使用哈希索引表的任何其他集合。

哈希表是使用哈希函数的结果对数组进行索引的数组。数组元素称为桶。哈希表实现试图避免在同一个桶中有多个元素的情况。要做到这一点,当桶满了超过某个限制时,表会不时地调整大小。当超过 70% 的桶已经包含一个元素时,表的大小将增加一倍。即使有这样一个阈值和调整数组的大小,也不能保证在一个桶中永远不会有多个元素。因此,实现通常不在桶中存储单个元素,而是存储元素的链表。大多数情况下,列表将只包含一个元素,但当发生冲突时,两个或多个元素具有相同的哈希值,然后列表可以存储这些元素。

调整桶数组的大小时,必须再次放置每个元素。此操作可能需要相当长的时间,在此期间,单个元件在铲斗之间移动。

在此操作期间,无法可靠地使用哈希表,这可能是多线程环境中的一些问题源。在单线程代码中,您不会遇到这个问题。当您调用add()方法时,哈希表(集合或映射)决定必须调整表的大小。add()方法调用调整大小的方法,直到完成后才返回。单线程代码在此期间不可能使用哈希表—单线程正在执行调整大小。在多线程环境中,可能会发生这样的情况:一个线程调用开始调整大小的add(),而另一个线程也在重新组织哈希表时调用add()。在这种情况下,JDK 中的哈希表实现将抛出ConcurrentModificationException

HashSetHashMap使用集合中存储的Object提供的哈希函数。Object类实现了hashCode()equals()方法。你可以覆盖它们,如果你这样做了,你应该以一致的方式覆盖它们。首先,我们将看到它们是什么,然后如何一致地覆盖它们。

equals()方法

Set的文件中,集中不含e1e2两个元素,使e1.equalse2equals()方法返回true如果e1e2在某种程度上相等。它可能与两个对象相同。可以有两个不同的对象是相等的。例如,我们可以有一个颜色实现,该颜色实现的名称为属性,两个颜色对象可以返回true,在其中一个对象上调用equals()方法,当两个字符串相等时,将参数作为另一个传递。equals()方法默认实现在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值的要求,但希望支持哈希实现集合的性能。

如果在实现equals()hashCode()时违反了这些规则中的任何一个,那么使用它们的 JDK 类可能会失败。您可以确定,HashSetHashMap和类似的类已经过充分的调试,看到您向一个集合添加了一个对象,然后集合报告如果它不在那里,将是一个令人困惑的体验。但是,只有在您发现集合中存储的两个相等的对象具有不同的hashCode()值之前,HashSetHashMap才会在由hashCode()值索引的桶中查找该对象。

将对象存储在HashSetHashMap中,然后对其进行修改也是一个常见的错误。对象在集合中,但找不到它,因为hashCode()返回的值不同,因为它已被修改。除非您知道自己在做什么,否则不应修改存储在集合中的对象。

很多时候,对象包含的字段从平等的角度看是不有趣的。hashCode()equals()方法对这些字段都是幂等的,即使将对象存储在HashSetHashMap中,也可以对这些字段进行修改。(幂等表示可以随意更改这些字段的值,并且方法的结果不会更改。)

例如,可以管理对象中的三角形,以保持顶点的坐标和三角形的颜色。但是,您并不关心颜色是否相等,只关心两个三角形在空间中处于完全相同的位置。在这种情况下,equals()hashCode()方法不应考虑字段颜色。这样,我们就可以画出我们的三角形;不管颜色场是什么,它们仍然会出现在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工具类是在 Java7 中引入的。静态方法equals()hash()支持Object equalshashCode()方法的覆盖。hashCode()在 Java7 之前的创建是相当复杂的,需要用一些幻数实现模运算,这些幻数很难解释,仅仅看代码而不知道背后的数学。

这种复杂性现在隐藏在以下Objects.hash方法背后:

@Override 
 public int hashCode() { 
     return Objects.hash(field1, field2, field3); 
 }

生成的方法只是调用Objects::hash方法,将重要字段作为参数传递。

HashSet(哈希集)

现在,我们基本上知道了很多关于散列的事情,所以我们可以勇敢地讨论HashSet类。HashSetSet接口的一个实现,它在内部使用哈希表。一般来说,就是这样。将对象存储在那里,可以查看对象是否已经存在。当需要一个Set实现时,几乎总是选择HashSet。几乎。。。

EnumSet(枚举集)

EnumSet可以包含某个枚举中的元素。回想一下,枚举是一些类,它们修复了enum本身中声明的大量实例。由于这限制了不同对象实例的数量,并且这个数量在编译时是已知的,EnumSet代码的实现是相当优化的。在内部,EnumSet被实现为一个位域,是一个很好的选择,如果我们用低级语言编程,位域操作将是一个选择。

LinkedHashSet(链接哈希集)

LinkedHashSet是一个HashSet,它还维护它所持有的元素的双链表。当我们迭代一个HashSet时,元素没有保证的顺序。当HashSet被修改时,新元素被插入到其中一个桶中,并且哈希表可能被调整大小。这意味着元素被重新排列并进入完全不同的桶中。对HashSet中的元素的迭代只是将桶和其中的元素按某种顺序进行,从调用者的角度来看,这种顺序是任意的。

然而,LinkedHashSet使用它维护的链表对元素进行迭代,并且迭代保证按照元素插入的顺序进行。这样,LinkedHashSet就是一个复合数据结构,同时是一个HashSet和一个LinkedList

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接口。从列表中插入或删除元素是相当便宜的,因为它只需要调整很少的引用。另一方面,按索引访问元素需要从列表的开始或列表末尾进行迭代;以更接近指定索引元素的为准。

ArrayList(数组列表)

这个类是List接口的一个实现,该接口保持对数组中元素的引用。这样,通过索引访问元素就相当快了。另一方面,在ArrayList中插入一个元素可能代价高昂。它需要将插入元素上方的所有引用移到一个更高的索引,并且可能还需要调整背景数组的大小,以防原始数组中没有空间存储新元素。本质上,这意味着分配一个新数组并复制对它的所有引用。

如果我们知道数组将增长多大并调用ensureCapacity()方法,那么可以优化数组的重新分配。这会将数组调整为作为参数提供的大小,即使当前使用的插槽编号较少。

我的经验是,新手程序员在需要列表时使用ArrayList,而不考虑不同实现的算法性能。我真的不知道为什么ArrayList会这么流行。在程序中使用的实际实现应该基于正确的决定而不是习惯。

Queue(队列)

Queue是一个集合,通常存储元素供以后使用。您可以将元素放入队列中,然后将它们拉出。一个实现可以指定给定的顺序,可以是先进先出FIFO)或后进先出LIFO),或者一些基于优先级的顺序。

在队列中,您可以调用add()方法添加元素,remove()方法删除头元素,element()方法访问头元素而不将其从队列中删除。当存在容量问题且无法将元素添加到队列时,add()方法将引发异常。当队列为空且没有头元素时,element()remove()方法抛出异常。

由于异常只能在异常情况下使用,并且调用程序可以在正常的代码过程中处理这些情况,因此所有这些方法都有一个版本,该版本只返回一些特殊的值来表示这种情况。调用者可以调用offer()来代替add(),以提供用于存储的元素。如果队列不能存储元素,则返回false。同样地,peek()会尝试接近头部元件,如果没有,则返回null,如果没有,poll()会移除并返回头部元件,如果没有,则返回null

请注意,这些返回null的方法只是在实现(如LinkedList允许null元素)时使情况变得模糊。永远不要在队列中存储一个null元素,否则您将无法判断队列是空的还是只有第一个元素是null

Deque(双端队列)

Deque是一个双端队列接口。它使用允许访问队列两端的方法来扩展Queue接口,以便从队列两端添加、查看和删除元素。

对于Queue接口,我们需要六种方法。Dequeue有两个可管理的端,需要 12 种方法。我们有addFirst()addLast(),而不是add()。同样,我们可以使用offerFirst()offerLast()peekFirst()peekLast()pollFirst()pollLast()。由于某种原因,在Queue接口中实现element()方法功能的方法在Dequeue接口中被命名为getFirst()getLast()

因为这个接口扩展了Queue接口,所以这里定义的方法也可以用来访问队列的头部。除此之外,此接口还定义了removeFirstOccurrence()removeLastOccurrence()方法,可用于移除队列中的特定元素。我们不能指定要删除的元素的索引,也不能基于索引访问元素。removeFirst()/LastOccurrence()方法的参数是要删除的对象。如果我们需要这个功能,我们可以使用Deque,即使我们在队列的一端添加和删除元素。

为什么Deque中有这些方法而Queue中没有?这些方法与Deque的双头性无关。原因是方法在发布后无法添加到接口。如果我们向接口添加一个方法,就会破坏向后兼容性,因为实现该接口的所有类都必须实现新方法。Java8 引入了默认方法来减轻这个限制,但是在 Java1.5 中定义了Queue接口,在 Java1.6 中定义了Deque接口。当时没有办法将新方法添加到已经存在的接口中。

Map(映射)

Map将键和值配对。如果我们想从Collection的角度接近Map,那么Map就是一组键/值对。您可以将键/值对放入一个Map中,并可以基于一个键获得一个值。键与Set中的元素具有相同的唯一性。如果您查看Set接口的不同实现的源代码,您可能会发现其中一些实现是作为Map实现的包装器实现的,其中的值被简单地丢弃。

接口定义了很多方法。两种最重要的方法是put()get()put(key,value)方法可用于在映射中存储键/值对。如果有一对有一个键,我们想在对中设置的键equals(),那么旧值将被替换。此时,put()的返回值为旧对象,否则返回null。注意,返回的null值也可能表示与该键相关的值为null

get(key)方法返回用指定键存储的值。同样,方法equals()用于检查所提供的键与映射中使用的键是否相等。如果映射没有任何与作为参数提供的键相关联的值,则此方法返回null。这也可能意味着与键相关联的实际值是null引用。

为了区分给定键没有存储值和存储值为null的两种情况,有另一种方法称为contains()。如果此映射包含指定键的映射,则此方法返回true

您可以在 JDK 的 JavaDoc 文档中找到Map接口中的许多其他方法。

使用Map简单,诱人。许多语言(如 Python、Go、JavaScript 和 Perl)在语言级别上支持这种数据结构。然而,当数组足够时使用Map是一种我见过很多次的糟糕做法,尤其是在脚本语言中。Java 不容易出现新手程序员的错误,但是当你想使用一个Map的时候,你仍然会发现你自己处于这样的境地,而且,还有一个更好的解决方案。一般来说,应该使用最简单的数据结构来实现算法。

HashMap(哈希映射)

HashMap是基于哈希表的Map接口实现。因为实现使用哈希表,get()put()方法通常执行速度非常快、恒定,并且与映射中的实际元素数无关。如果映射大小增加,并且表元素的数量不足以以符合人体工程学的方式存储元素,那么将新元素放入这种类型的映射可能会迫使实现调整底层数组的大小。在这种情况下,数组中已经存在的每个元素都必须重新放置在新的、增大的数组中。在这些情况下,put()操作可能消耗大量时间,与映射中元素的数量成比例。

当一个元素要存储在这个映射中时,对键对象调用hashCode()方法,返回值用来决定底层哈希表的哪个桶应该存储这个新元素。桶包含一个简单的二叉树结构。如果桶是空的,在这个结构中存储元素就像元素直接存储在桶中一样简单。另一方面,当两个或多个元素具有相同的hashCode()值时,它们也可以存储在同一个存储桶中的映射中,尽管效率有点下降。

由于hashCode()值可能发生冲突,get()put()方法调用所需的时间可能比其他方法长一点点。

Map接口的实现是 Java 程序中使用最频繁的一种,实现经过微调,使用可以配置。最简单的方法是创建一个调用默认构造器的HashMap。如果我们碰巧知道映射上有多少元素,那么我们可以指定它,并将int传递给构造器。在这种情况下,构造器将分配一个数组,数组的大小不小于我们所需的容量,大小是两个幂。

还有第三个构造器,我们可以在其中定义一个float负载因子。负荷系数的默认值为0.75。当映射中的元素数大于哈希表大小乘以加载因子的大小时,下一个put()调用将使基础哈希表的大小加倍。这种提前调整大小的方法试图避免hashCode()碰撞变得过于频繁。如果我们将某个特殊类的实例存储在我们确信hashCode()非常好的地方,那么我们可以将负载因子设置得更接近1.0。如果我们不太在意速度,也不希望底层哈希表增加太多,我们甚至可以使用大于1.0的值。不过,在这种情况下,我会考虑使用一些不同的数据结构。

在大多数情况下,我们需要Map时的选择是HashMap。通常,我们不应该太过担心这些实现细节。然而,在一些罕见的情况下,当内存使用或性能下降时,我们应该知道我们使用的映射的实现复杂性。

IdentityHashMap(身份哈希映射)

IdentityHashMap是实现Map接口本身的特殊Map,但事实上,该实现违反了文档定义的Map接口的约定。它这样做是有充分理由的。实现使用的哈希表与HashMap相同,但是为了确定桶中找到的键与get方法的参数键元素的相等性,它使用了Object引用(==运算符),而不是Map接口文档要求的方法equals()

当我们想将不同的Object实例区分为键时,使用此实现是合理的,否则是相等的。出于性能原因使用此实现几乎肯定是错误的决定。另外,注意,JDK 中没有IdentityHashSet实现。很可能,这样的集合很少使用,以至于它在 JDK 中的存在会造成比好的更大的危害,这会引诱新手程序员误用。

依赖注入

在上一章中,我们已经简要讨论了依赖注入DI)。在继续我们的示例之前,我们将更详细地研究它。我们之所以这样做,是因为我们将创建的编程结构在很大程度上建立在这个原则之上。

对象通常不会自己工作。大多数时候,实现依赖于其他类的服务。例如,当我们想向控制台写入内容时,我们使用System类,并通过该对象使用final字段outprintln()方法。另一个例子是,当我们管理猜测表时,我们需要Color对象和ColorManager

在写入控制台时,我们可能没有意识到依赖性,因为类作为 JDK 类库的一部分,一直都是可用的,我们需要做的就是写入System.out.println()。因为我们有腿,所以这可能和能走路一样明显。无论这看起来多么简单,我们都依赖于我们的腿来行走,同样地,当我们要向控制台写入数据时,我们也依赖于System类。

当我们刚刚编写System.out.println()时,依赖关系就被连接到了代码中。除非修改代码,否则无法将输出发送到其他地方。这不是很灵活,而且在许多情况下,我们需要一个能够处理不同输出、不同颜色管理器或不同类型的代码所依赖的服务的解决方案。

第一步是使用一个字段来引用为类提供服务的对象。在输出的情况下,字段的类型可以是OutputStream类型。如果我们在代码中使用这个字段,而不是直接连接到代码中的内容,那么我们就有可能使用不同的依赖关系。例如,我们可以将输出发送到文件而不是控制台。我们不需要更改编写代码的地方。我们所要做的就是在对象创建过程中为引用OutputStream的字段指定一个不同的值。

这已经是下一步了,即该字段如何获取值。

解决方案之一是使用 DI。在这种方法中,一些外部代码准备依赖项并将它们注入到对象中。当发出对类的方法的第一个调用时,所有依赖项都已填充并准备好使用。

在这个结构中,我们有四个不同的参与者:

  • client对象是在该过程中获取注入的service对象的对象
  • service对象被注入client对象
  • 注入器是执行注入的代码
  • 接口定义客户端需要的服务

如果我们从客户端代码中移动创建service对象的逻辑,代码就会变得更短、更干净。client类的实际能力几乎不应涵盖service对象的创建。例如,Game类包含Table实例,但游戏不负责创建Table。它被赋予与它一起工作,就像我们在现实生活中建模一样。

创建service对象有时就像发出new操作符一样简单。有时,service对象也依赖于其他service对象,并且在 DI 过程中充当客户端。在这种情况下,service对象的创建可能需要很多行。依赖关系的结构可以用一种声明性的方式来表示,它描述了哪个service对象需要其他service对象,以及要使用的服务接口的实现。DI 注入器使用这种声明性描述。当需要一个需要service对象的对象本身需要其他service对象时,注入器使用与声明性描述匹配的实现以适当的顺序创建服务实例。注入器以传递方式发现所有依赖项,并创建依赖项的传递闭包图。

对所需依赖项的声明性描述可以是 XML,或者是专门为 DI 开发的一种特殊语言,甚至可以是 Java 本身,使用一个专门设计的 Fluent API。XML 最早是在 DI 中使用的。后来,基于 Groovy领域专用语言是作为一种 Java Fluent API 方法出现的。我们将只使用最后一个,因为它是最现代的,我们将使用 SpringGuice DI 容器,因为它们是最著名的注入器实现。

实现游戏

没有例子的集合很无聊。幸运的是,在我们的游戏中,我们使用了一些集合类,以及我们将在本章中研究的其他方面。

色彩管理

我们跳进了池中,池中充满了实现ColorManager类的集合类。让我们用类中我们感兴趣的部分来刷新我们的记忆,现在是构造器:

package packt.java189fundamentals.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<>();
    private Color first;

    public ColorManager(int nrColors) {
        this.nrColors = nrColors;
        createOrdering();
    }

    protected Color newColor(){
        return new Color();
    }

    private Color[] createColors() {
        Color[] colors = new Color[nrColors];
        for (int i = 0; i < colors.length; i++) {
            colors[i] = 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]);
        }
    }
    // ...
}

我们用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解决方案在没有事先编码的情况下更好,那么我没有。随着你积累更多的经验,这样的考虑会变得更容易。

Color

我们已经研究了类代码的代码,它是世界上最简单的类。实际上,由于它位于 Packt 代码存储库中,代码更复杂:

/**
 * 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

托尼·霍尔,曾经承认这是一个错误,在 IT 行业花费了数十亿美元。

null值的问题是它将控件从类中移除,从而打开了封装。如果某个方法在某种情况下返回null,则严格要求调用方检查空值并据此进行操作。例如,您不能在null引用上调用方法(至少在 Java 中不能这样做),也不能访问任何字段。如果方法返回一个对象的特殊实例,这些问题就不那么严重了。如果调用方忘记检查特殊返回值并调用特殊实例上的方法,则调用的方法仍有可能实现某些异常或错误处理。该类封装了控件,并可以引发一个特殊异常,该异常可能会提供有关调用方未检查特殊值的编程错误所导致的错误的更多信息。

JavaDoc 和代码注释

我们前面介绍的内容和清单之间还有另一个区别。这是代码的注释。代码注释是程序的一部分,被编译器忽略并过滤掉。这些注释仅适用于维护或使用代码的人员。

在 Java 中,有两种不同的注释。/**/之间的代码是注释。注释的开头和结尾不必在同一行。另一种类型的注释以//字符开始,并在行尾结束。

为了记录代码,可以使用 JavaDoc 工具。JavaDoc 是 JDK 的一部分,它是一个特殊的工具,可以读取源代码并提取有关类、方法、字段和其他实体的 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
 */
private 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 不是解释算法或编码的地方。它的目的是帮助使用代码。然而,如果有人碰巧解释了一个方法是如何工作的,那就不是灾难了。注释很容易被删除。

然而,有一条注释比什么都没有更糟糕:过时的文档不再有效。当元素的约定发生了更改,但文档没有遵循更改,并且误导了希望调用方法、接口或类的用户时,它将面临严重的错误,并且将不知所措。

从现在起,JavaDoc 注释将不会以打印的形式列出以保存树,电子版也不会列出,但它们在存储库中,可以检查。

Row

现在,我们有一个Color类,甚至当我们需要一个ColorManager时还有实例。这是在Row对象中存储Color对象的时间。Row类稍长,但不太复杂。在本节中,我们将以小片段的形式查看代码,并在其中进行解释:

package packt.java189fundamentals.example.mastermind;

public class Row {
    final Color[] positions;
    protected int matchedPositions;
    protected 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个实例,因为这个类根本没有字段,所以它本质上是不可变的,没有可以更改的实例。

该行与颜色一起创建,因此我们为名为positionsColor数组使用了一个final字段。但是,当创建一行时,无法知道匹配项;因此,它们不能是final。其中一个玩家创建了Row,然后,另一个玩家稍后会告诉你这两个int值。我们需要一个设置器来设置这些字段。但是,我们不会为这两个值创建两个设置器,因为它们在游戏中总是同时定义在一起:

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 时,我们应该检查使用是否一致。如果不这样做,我们的代码在不一致地使用时可能会表现得很奇怪。当调用者将匹配设置为与任何可能的猜测都不匹配的值时,游戏可能永远不会结束,调用者可能很难弄清楚到底发生了什么。这可能需要我们代码的调试执行。这不是库用户的任务。始终尝试创建不需要从 API 使用者处调试的代码。

如果我们在这种情况下抛出异常,程序将在错误所在的位置停止。不需要调试库。

以下方法决定作为参数给出的猜测是否与实际行匹配:

public boolean guessMatches(Color[] guess) {
    return nrMatchingColors(guess) == matchedColors &&
            nrMatchingPositions(guess) == matchedPositions;
}

如果当前猜测在隐藏行中,此方法检查行中猜测的答案是否有效。实现相当简短。如果匹配的颜色数和匹配的位置数与行中给定的数字相同,则猜测匹配一行。当然,在nrMatchingColors()nrMatchingPositions()方法的实现中有一些额外的代码,但是这个方法确实很简单。不要羞于写简短的方法!不要认为本质上只包含一条语句的单行方法是无用的。无论在哪里使用这个方法,我们都可以编写表达式,它就在return语句的后面,但是我们不这样做有两个原因。第一个也是最重要的原因是,决定行与猜测匹配的算法属于类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类的责任,而不是任何其他类的业务。如果我开始将位置存储在一个List中,它没有length(它有方法size()),这是Row的唯一责任,不应影响任何其他代码。因此,我创建了nrOfColumns()方法来改进代码并进行适当的封装。

Row类有另一个从另一行克隆行的构造器:

protected Row(Row cloneFrom) {
    this(cloneFrom.positions);
    setMatch(cloneFrom.matchedPositions, cloneFrom.matchedColors);
}

这是通过扩展PrintableRow类来使用的。这个类使得在测试运行期间,我们可以打印出表、猜测以及游戏的一般运行方式。

PrintableRow类如下:

package packt.java189fundamentals.example.mastermind;

public class PrintableRow extends Row {
    public PrintableRow(Row row) {
        super(row);
    }

    public Color position(int i) {
        return positions[i];
    }

    public int matchedPositions() {
        return matchedPositions;
    }

    public int matchedColors() {
        return matchedColors;
    }
}

这些方法的第一个版本在Row类中,然后转移到新的PrintableRow类中。在重构过程中,我经常剪切和粘贴 IDE 的功能。我还可以使用重构支持将方法直接从一个类移动到另一个类。有一个 IDE 功能不应该用于复制和粘贴。

在编写代码时,请不要使用复制和粘贴。您可以使用剪切和粘贴来移动代码片段。危险在于复制粘贴的使用。许多开发人员声称他们实际使用的复制和粘贴并不是复制粘贴编程。他们的理由是,他们更改粘贴的代码太多,几乎与原始代码没有任何关系。真正地?在这种情况下,为什么在开始修改时需要复制的代码?为什么不从头开始呢?这是因为如果您使用 IDE 的复制和粘贴功能,那么不管怎样,您都要进行复制粘贴编程。面对现实,不要试图欺骗自己。

PrintableRow非常简洁,将输出关注点与核心功能分开。当你需要一个实例时,你手头已经有一个Row实例不是问题。构造器将基本上克隆原始类并返回可打印的版本,调用父类中定义的克隆构造器。在这个类的开发过程中,我在PrintableRow类中创建了克隆代码。但是,这种功能放置违反了封装。即使PrintableRow扩展了Row类,因此,了解父类的内部工作并不是永恒的罪恶,如果可能的话,最好不要依赖它。因此,新的protected构造器是在父类中创建的,并从子类调用。

一段代码永远不会完成,也永远不会完美。在专业环境中,程序员往往会在代码足够好的时候完成抛光。没有任何代码是无法改进的,但是有一个最后期限。软件必须传递给测试人员和用户,并且必须用来帮助节约。毕竟,拥有支持业务的代码是专业开发人员的最终目标。从不运行的代码一文不值。我不想让你认为我在这里提供的例子是在前面完美地创造出来的。在这本书的第二版中,我甚至不敢说它们是完美的。原因是(你仔细阅读了吗?)因为它们并不完美。正如我所说,代码永远都不是完美的。当我第一次创建Row时,它包含了一个内部类中的打印方法。我不喜欢它。密码很臭。所以,我决定将功能移到Row类。不过,我还是不喜欢这个解决方案。然后,我上床睡觉,工作,几天后又回来了。我前一天无法创建的东西现在看来很明显,这些方法必须移动到一个子类中。现在又出现了另一个困境。我应该给出这个最终的解决方案还是应该在这里有不同的版本?在某些情况下,我将只介绍最终版本。在其他情况下,像这样,从开发步骤中可以学到一些东西。在这些案例中,我不仅介绍了代码,而且还介绍了代码的一部分演变过程。我承认,有时候,我创建的代码甚至让我一天后自己也会捂脸。谁不呢?

Table

Mastermind 表是一个简单的类,它只有一个非常简单的功能:

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这一事实意味着它将在其生存期内持有相同的列表对象。列表的长度、成员和其他特性可能会改变,也将改变。我们将向该列表添加新行。最终对象变量引用一个对象,但不能保证对象本身是不可变的。只有变量不变。

当你做一个代码回顾并向你的同事解释一个类是做什么的时候,你发现自己开始非常简单地解释这个类很多次,这意味着代码是好的。好吧,它在其他方面可能仍然是错误的,但至少类的粒度似乎是好的。

Guesser

Guesser抽象类和UniqueGuesserGeneralGuesser子类是程序中最有趣的类。他们实际执行的任务是游戏的核心。给定一个带有隐藏行的Table,猜测者必须创建新的猜测。

为此,Guesser需要在创建时获得Table。这是作为构造器参数传递的。它应该实现的唯一方法是guess,它根据表和它的实际状态返回一个新的猜测。

我们要实现一个猜测器,它假设隐藏行中的所有颜色都是不同的,同时也要实现一个不做此假设的猜测器;我们将实现三个类来实现这一点。Guesser是一个抽象类,它只实现独立于假设的逻辑。这些方法将被两个实际实现继承,UniqueGuesserGeneralGuesser,如果每种颜色在一行中是唯一的或不是唯一的,它们将分别实现猜测功能。

让我们看看这个类的实际代码:

package packt.java189fundamentals.example.mastermind;

public abstract class Guesser {
    protected final Table table;
    private final ColorManager manager;
    protected final Color[] lastGuess;
    public static final Color[] none = new Color[]{Color.none};

    public Guesser(Table table) {
        this.table = table;
        this.lastGuess = new Color[table.nrColumns];
        this.manager = table.manager;
    }

猜测者的状态是最后一次猜测。虽然这是表的最后一行,但更多的是猜测者的内部问题。猜测者拥有所有可能的猜测,一个接一个;lastGuess是它上次停止的地方,当它再次被调用时,应该从那里继续。

在这个类中,none只是一个对象,当我们需要返回某个对Guess的引用但不是真正的猜测时,我们尝试使用它来代替null

设置第一个猜测在很大程度上取决于颜色唯一性的假设:

abstract protected void setFirstGuess();

如果隐藏行不允许包含任何颜色,则第一个猜测不应包含重复的颜色,因此此类中的方法是抽象的。

下一个方法是在具体类中覆盖的内部方法:

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语句分散在方法的不同执行阶段。

此外,从循环返回表示循环的隐式结束条件。在我们的例子中,循环的头清楚地说明了我们在循环中迭代了多长时间,直到我们在计算表的总宽度或者我们找到了一个猜测。

当有人以这种方式编写优化的代码时,就像一个蹒跚学步的孩子迈出第一步,然后骄傲地看着他/她的母亲。好吧,男孩/女孩,你很棒。现在,继续走吧。当你是邮递员时,走路会很无聊。那将是你的职业。所以,把骄傲放在一边,写一些无聊的代码。专业人士编写枯燥的代码。不会很慢吧?

不!不会慢的。首先,在探查器证明代码不满足业务需求之前,它并不慢。如果是这样的话,它就足够快了,不管它有多慢。慢是好的,只要它是好的业务。毕竟,实时编译器(JIT)应该有一些任务来优化要运行的代码。

下面的方法检查猜测是否与之前的猜测及其在Table上的结果相匹配:

private boolean guessMatch(Color[] guess) {
    for (Row row : table.rows) {
        if (!row.guessMatches(guess)) {
            return false;
        }
    }
    return true;
}

因为我们已经在类Row中实现了猜测匹配,所以我们所要做的就是为表中的每一行调用该方法。如果所有行都匹配,那么猜测可能对表有利。如果前面的任何猜测都不匹配,那么这个猜测就泡汤了。

在检查匹配的否定表达式时,我们创建了否定方法的英文版本。

在这种情况下,创建方法的guessDoesNotMatch版本就足够了。但是,如果方法没有被求反,那么代码的逻辑执行更具可读性。因此,单独编写guessDoesNotMatch方法更容易出错。相反,我们将实现原始的、可读的版本,并且 aux 方法只不过是一个否定。

在所有 aux 方法之后,我们要实现的下一个也是最后一个方法是public方法,guess()

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.java189fundamentals.example.mastermind;

import java.util.HashSet;

public class UniqueGuesser extends Guesser {

    public UniqueGuesser(Table table) {
        super(table);
    }

    @Override
    protected void setFirstGuess() {
        int i = lastGuess.length - 1;
        for (var color = table.manager.firstColor();
             i >= 0;
             color = table.manager.nextColor(color)) {
            lastGuess[i--] = color;
        }
    }

setFirstGuess方法选择第一个猜测的方式是,如果我们遵循算法,在第一个猜测之后出现的任何可能的颜色变化都会一个接一个地产生猜测。

如果猜测包含重复的颜色,isNotUnique aux 方法返回true。看多少不有趣。如果所有颜色都相同,或者只有一种颜色出现两次,则无所谓。这个猜测并不独特,不适合我们的猜测者。这个方法告诉我们。

注意,在讨论SetJDK 接口时,已经列出了此方法:

private boolean isNotUnique(Color[] guess) {
    final var alreadyPresent = new HashSet<Color>();
    for (final var color : guess) {
        if (alreadyPresent.contains(color)) {
            return true;
        }
        alreadyPresent.add(color);
    }
    return false;
}

为此,它使用一个Set,并且每当在guess数组中发现新颜色时,该颜色就存储在集合中。如果在数组中找到该颜色时,该集包含该颜色,则表示该颜色以前已经使用过;猜测不是唯一的。

另外,请注意,在本例中,我以从循环中间返回的方式对循环进行了编码。不要从循环/方法中间返回规则不是一成不变的。在这种情况下,我觉得从循环的中间返回会提供更好的可读性,而不是引入一个新的boolean。循环很短,无论谁读代码,都可以很容易地发现循环头下面的两行。

我们必须在这个具体类中实现的最后一个方法是nextGuess()

@Override
protected Color[] nextGuess() {
    Color[] guess = super.nextGuess();
    while (isNotUnique(guess)) {
        guess = super.nextGuess();
    }
    return guess;
}

覆盖的nextGuess()方法很简单。它要求超类的nextGuess()实现进行猜测,但丢弃了它不喜欢的猜测。

GeneralGuesser

GeneralGuesser类还必须实现构造器和setFirstGuess,但一般来说就是这样。它不需要做任何其他事情:

package packt.java189fundamentals.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数组的每一列。Guess再简单不过了。其他一切都是从abstract class Guesser继承的。

Game

Game类的实例包含保存秘密颜色值的Row,还包含Table。当有新的猜测时,Game实例将猜测存储到Table中,并设置与秘密行匹配的位置数和颜色数:

package packt.java189fundamentals.example.mastermind;

public class Game {

    final Table table;
    final private Row secretRow;
    boolean finished = false;
    final int nrOfColumns;

    public Game(Table table, Color[] secret) {
        this.table = table;
        this.secretRow = new Row(secret);
        this.nrOfColumns = secretRow.nrOfColumns();
    }

    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 == nrOfColumns) {
            finished = true;
        }
    }

    public boolean isFinished() {
        return finished;
    }
}

想想我之前写的简短方法。当您从 Packt 存储库下载代码来使用它时,请尝试使它看起来更可读。您也许可以创建并使用一个名为boolean itWasAWinningGuess(int positionMatch)的方法。

创建集成测试

我们已经在上一章中创建了单元测试,并且在本章的类中也有实现功能的单元测试。我们不会在这里打印这些单元测试,但是您可以在 Packt 代码库中找到它们。我们将看一个集成测试,而不是列出单元测试。

集成测试需要调用许多协同工作的类。它们检查功能是否可以由整个应用交付,或者至少是由应用的较大部分交付,而不是集中在单个单元上。它们被称为集成测试,因为它们测试类之间的集成。光上课都可以。他们不应该有任何问题,因为它已经被单元测试验证了。集成的重点是它们如何协同工作。

如果我们想测试Game类,我们要么创建模仿其他Game类行为的模拟,要么编写一个集成测试。从技术上讲,集成测试与单元测试非常相似。在大多数情况下,使用完全相同的 JUnit 框架来执行集成测试。这个游戏的集成测试就是这样。

但是,构建工具需要配置为仅在需要时执行集成测试。通常,集成测试的执行需要更多的时间,有时还需要更多的资源,例如外部数据库,这些资源可能不在每个开发人员的桌面上都可用。每次编译应用时都会运行单元测试,所以它们必须很快。为了将单元测试和集成测试分开,有不同的技术和配置选项,但实际上没有这样的标准,比如 Maven 引入的目录结构(后来由 Gradle 改编)。

在我们的例子中,集成测试不需要任何额外的资源,也不需要花费大量的时间来运行。它从头到尾都是一场比赛,扮演着双方球员的角色。这很像一个人和自己下棋,迈出一步,然后转身。在那些比赛中谁赢是一个有趣的问题。

这段代码有两个目的。一方面,我们希望看到代码运行并执行整个游戏。如果比赛结束了,那就没事了。这是一个非常弱的断言,而真正的集成测试执行很多断言(尽管一个测试只测试一个断言)。我们将集中在另一个目标,提供一些乐趣和可视化的游戏控制台上的文本格式,使读者不会感到无聊。

为此,我们将创建一个工具类,该类打印出一种颜色,并动态地将字母分配给Color实例。

警告:这个类中有几个限制,我们必须在查看代码后讨论。我想说这段代码在这里只是为了演示不要做什么,为下一章建立一些推理,以及为什么我们需要重构我们在这一章中创建的代码。仔细阅读!

这是PrettyPrintRow类:

package packt.java189fundamentals.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。

总之,这个代码只能在一个 JVM 中(在同一个类加载器下)由一个线程使用一次。对于这一章来说,它是好的,虽然是一个难闻和可耻的代码。稍后,这将是下一章的一个很好的例子,在下一章中,我们将看到如何重构应用,以便它不需要这样的黑客来打印颜色。

根据 Martin Fowler 的说法,代码气味是 Kent Back 创造的一个术语。这意味着有些代码看起来不好,也不明显不好,但是有些构造让开发人员觉得可能不好。正如在网页上定义的那样,代码气味是一种表面指示,通常对应于系统中更深层的问题。这个术语被广泛接受,并在过去的 10 年中用于软件开发。

其余代码简单明了:

    public static String pprint(Row row) {
        var string = "";
        final var 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;
    }
}

集成测试,或者更确切地说,演示代码(因为它不包含任何断言,除了它运行之外,它无一例外地运行),定义了六种颜色和四列。这是原来游戏的大小。它创建颜色管理器,然后创建一个表和一个秘密。这个秘密可能只是从可用的六种颜色中随机选择颜色(在 Packt 代码库中的UniqueGuesserTest单元测试中有 360 种不同的可能性进行测试)。我们知道Guesser实现从颜色集的一端开始,系统地创建新的猜测,我们希望设置一个秘密,它将持续猜测。这不是因为我们是邪恶的,而是因为我们希望看到我们的代码确实有效。

代码的目录结构与我们在 Maven 构建工具中使用的目录结构非常相似,如在 Windows 机器上创建的以下屏幕截图所示:

源代码位于src目录下,maintest源代码文件分为两个子目录结构。编译后的文件在build目录下使用 Gradle 时生成。

集成测试类的代码如下:

package packt.java189fundamentals.example.mastermind.integration;

import org.junit.Assert;
import org.junit.Test;
import packt.java189fundamentals.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 根据生成文件导入项目时,无论是 Mavenpom.xml还是 Gradlebuild.gradle,IDE 通常提供一个运行按钮或菜单来启动代码。运行游戏将打印出我们在本章中努力工作的以下代码:

RGBY 0/0

GRWb 0/2
YBbW 0/2
BYGR 0/4
RGYB 2/2
RGBY 4/0

总结

在这一章中,我们编写了一个桌游策划。我们不仅编写了游戏的模型,还创建了一个可以猜测的算法。我们重温了一些面向对象的原则,并讨论了为什么模型是这样创建的。我们创建了游戏模型,同时学习了 Java 集合、集成测试和 JavaDoc。在下一章中,我们将以这些知识为基础,增强游戏在多个处理器上运行的能力。

五、扩展游戏-跑得并行,跑得更快

在本章中,我们将扩展主谋游戏。就像现在这样,它能猜出隐藏的秘密,也能藏起钉子。测试代码甚至可以同时做这两件事。它可以与自己作对,只留给我们编程的乐趣。它不能做的是利用我们今天笔记本和服务器上的所有处理器。代码同步运行,只使用一个处理器内核。

我们将修改扩展猜测算法的代码,以便将猜测分割成子任务并并行执行代码。这样,我们将熟悉 Java 并发编程。这将是一个巨大的话题,许多微妙的曲折潜伏在阴影中。我们将深入了解这些最重要的细节,并为您需要并行程序时的进一步学习奠定坚实的基础。

由于比赛的结果和以前一样,只是速度更快,我们必须评估什么是更快。为此,我们将利用 Java9 中引入的一个新特性——微基准测试工具。

在本章中,我们将介绍以下主题:

  • 进程、线程和纤程的含义
  • Java 中的多线程技术
  • 多线程编程的问题及避免方法
  • 锁定、同步和阻塞队列
  • 微基准

如何让 Mastermind 并行

旧的算法是遍历所有的变化,并试图找到与表的当前状态相匹配的猜测。假设当前检查的猜测是秘密,我们会得到与实际答案相同的答案吗?如果是的话,那么当前的猜测就是秘密,它和其他猜测一样好。

更复杂的方法可以实现 min-max 算法。这个算法不只是简单地得到下一个可能的猜测,而是查看所有可能的猜测,并选择一个最缩短游戏结果的猜测。如果有一个猜测在最坏的情况下可以再进行三次猜测,而另一个猜测的数字只有两次,那么 minmax 将选择后者。对于那些感兴趣的读者来说,实现 minmax 算法是一个很好的练习。在六种颜色和四列的情况下,最小-最大算法在不超过五个步骤的情况下解决游戏。我们实现的简单算法也分五步求解游戏。然而,我们没有朝这个方向走。

相反,我们希望有一个版本的游戏,利用一个以上的处理器。如何将算法转换为并行算法?这个问题没有简单的答案。当你有一个算法,你可以分析计算和部分算法,你可以尝试找到依赖关系。如果有一个计算,B需要数据,这是另一个计算,a的结果,那么很明显,a只能在B准备就绪时执行。如果算法的某些部分不依赖于其他部分的结果,那么它们可以并行执行。

例如,快速排序有两个主要任务,分别对这两个部分进行分区和排序。很明显,在我们开始对两个分区的部分进行排序之前,分区必须完成。但是,这两部分的排序任务并不相互依赖,它们可以独立完成。你可以给他们两个不同的处理器。一个会很高兴地将包含较小元素的部分分类,而另一个会携带较大的元素。

如果我们回想一下非递归快速排序实现,您可以看到我们将排序任务安排到一个栈中,然后通过在一个while循环中从栈中获取元素来执行排序:

public class NonRecursiveQuickSort<E> {
// ... same fields and constructor as in Qsort are deleted from print ...

    private static class StackElement {
        final int begin;
        final int fin;

        public StackElement(int begin, int fin) {
            this.begin = begin;
            this.fin = fin;
        }
    }

    public void qsort(Sortable<E> sortable, int start, int end) {
        final var stack = new LinkedList<StackElement>();
        final var partitioner = new Partitioner<E>(comparator, swapper);
        stack.add(new StackElement(start, end));
        var i = 1;
        while (!stack.isEmpty()) {
            var it = stack.remove(0);
            if (it.begin < it.fin) {
                final E pivot = sortable.get(it.begin);
                var cutIndex = partitioner.partition(sortable, it.begin, it.fin, pivot);
                if( cutIndex == it.begin ){
                    cutIndex++;
                }
                stack.add(new StackElement(it.begin, cutIndex - 1));
                stack.add(new StackElement(cutIndex, it.fin));
            }
        }
    }
}

我们可以将任务传递给异步线程来执行排序,然后返回到下一个等待的任务,而不是在循环的核心执行排序。我们只是不知道怎么做。但是。这就是我们在这一章的原因。

处理器、线程和进程是复杂而抽象的东西,它们很难想象。不同的程序员有不同的技术来想象并行处理和算法。我可以告诉你我是怎么做的。这不能保证对你有用。其他人的头脑中可能有不同的技巧。事实上,我刚刚意识到,在我写这篇文章的时候,我从来没有告诉过任何人。这可能看起来很幼稚,但不管怎样,还是来了。

当我想象算法时,我想象人。一个处理器就是一个人。这有助于我克服一个奇怪的事实,即一个处理器可以在一秒钟内执行数十亿次计算。我真的想象一个穿棕色西装的官僚在做计算。当我为一个并行算法创建一个代码时,我想象他们中的许多人在办公桌后面工作。他们一个人工作,不说话。重要的是他们不要互相交谈。他们非常正式。当需要交换信息时,他们会拿着一张纸站起来,上面写着什么,然后把它带给对方。有时,他们的工作需要一张纸。然后,他们站起来,走到放报纸的地方,拿着它,把它带回办公桌,继续工作。准备好后,他们回去把报纸拿回来。如果他们需要的时候报纸不在那里,他们就会排队等候,直到有人把报纸带来。

这对主谋游戏有什么帮助?

我想象一个老板对猜测负责。办公室的墙上有一张桌子,上面有以前的猜测和每行的结果。老板懒得提出新的猜测,所以他把这个任务交给下属。当下属提出猜测时,老板会检查猜测是否有效。他不信任下属,如果猜得好,他就把它当成官方的猜测,和结果一起放在桌子上。

下属把猜测写在小便笺上,然后放在老板桌上的盒子里。老板时不时地看盒子,如果有纸条,老板就收了。如果箱子满了,下属想把一张纸放在那里,他就会停下来,等老板至少记下一张纸条,这样箱子里就有地方放新纸条了。如果下属们排队把猜词放进盒子里,他们都会等着轮到他们。

下属之间要协调,否则,他们也会有同样的猜测。他们每个人都应该有一段时间的猜测。例如,如果我们用数字表示颜色,第一个应该检查从 1234 到 2134 的猜测,第二个应该检查从 2134 到 3124 的猜测,依此类推。

这个结构能用吗?常识告诉我们会的。然而,在这种情况下,官僚是隐喻,隐喻并不确切。官僚主义者是人,即使他们看起来不是人,也远比线程或处理器更像人。他们有时行为异常,做正常人不常做的事情。然而,如果这个比喻有助于我们想象并行算法是如何工作的,我们仍然可以使用它。

我们可以想象,老板去度假,不碰桌上堆积如山的一堆纸。我们可以想象,有些工人比其他工人生产的结果快得多。因为这只是想象,所以加速可以是 1000 倍(想想一个延时视频)。想象这些情况可以帮助我们发现很少发生的特殊行为,但这可能会导致问题。当线程并行工作时,大量细微的差异可能会极大地影响一般行为。

在早期版本中,当我编写并行主谋算法时,官僚们开始工作,在老板把它们中的任何一个都摆在桌子上之前,他们就开始用猜测填满老板的盒子。由于桌上没有猜测,官僚们只是发现了他们的间隔中可能出现的所有可能的变化,这可能构成了一个很好的猜测。老板在并行助手的帮助下什么也没有得到;老板必须从所有可能的猜测中选择正确的,而猜测者只是闲着。

还有一次,当老板在猜测的时候,官僚们正在对照桌子核对猜测。按照我们的比喻,一些官僚吓了一跳,说如果有人在换桌子,就不可能对照桌子来核对猜测。更准确地说,在官僚线程中执行代码时,当表的List被修改时抛出ConcurrentModificationException

另一次,我试图避免官僚们过于迅速的工作,我限制了他们可以把包含猜测的文件放在盒子里的大小。当老板终于发现了这个秘密,游戏结束后,老板告诉官僚们可以回家了。老板是这样做的,他写了一份小报告,上面写着指示,你可以回家把它放在官僚们的桌子上。官僚们做了什么?他们一直等着箱子有地方放报纸,因为在那儿等的时候,他们没有在看桌子上的零钱!(直到进程被终止。这在 MacOS 和 Linux 上相当于从 Windows 上的任务管理器结束进程。)

这样的编码错误时有发生,为了尽可能避免,我们至少要做两件事。首先,我们必须了解 Java 多线程是如何工作的,其次,要有一个尽可能干净的代码。第二,我们将进一步清理代码,然后我们将研究如何在 Java 中实现前面描述的并行算法,在 JVM 上运行,而不是使用官僚程序。

重构

当我们完成上一章的时候,我们用一种完美的面向对象的方式设计和编码了 Mastermind 游戏的类,这种方式没有破坏任何一个 OO 原则。是吗?荒谬的。除了一些微不足道的例子外,没有任何代码是不能让它看起来更好或更好的。通常,当我们开发代码并完成编码时,它看起来很棒。它工作了,测试都运行了,文档也准备好了。从专业的角度来看,它确实是完美的。好吧,够了。我们尚未测试的最大问题是可维护性。修改代码的成本是多少?

这不是一个容易的问题,特别是因为它不是一个确定的问题。改成什么?我们要做什么修改?当我们首先创建代码时,我们不知道这一点。如果修改是为了修复一个 bug,那么很明显我们事先并不知道这一点。如果我们知道的话,我们一开始就不会引入这个 bug。如果这是一个新特性,那么就有可能预见到该功能。然而,通常情况并非如此。当开发人员试图预测未来,以及程序将来需要什么特性时,通常都会失败。了解业务是客户的任务。在专业软件开发的情况下,需要的特性是由业务驱动的。毕竟,这就是专业的含义。

尽管我们不知道代码后面需要修改什么,但是有些东西可能会给有经验的软件开发人员一些提示。通常情况下,OO 代码比即兴代码更容易维护,并且有一种可以检测的代码香气。例如,请查看以下代码行:

while (guesser.guess() != Row.none) {
. . .
while (guesser.nextGuess() != Guesser.none) {
. . .
public void addNewGuess(Row row) {
. . .
Color[] guess = super.nextGuess();

我们可能感觉到某种奇怪的气味。(每一行都在我们在第 4 章、“策划者-创建游戏”中完成的应用代码中。)guess()方法的返回值与Row.none进行比较,后者是一个Row。在下一个示例行中,我们将nextGuess()方法的返回值与Guesser.none进行比较,后者应该是猜测,而不是Guesser。当我们在下一个示例行中添加新的猜测时,我们实际上添加了一个Row。最后,我们可以意识到方法nextGuess()返回的猜测不是一个有自己声明类的对象。猜测只是Colors的一个数组。这些东西乱七八糟。我们如何提高代码的质量?

我们应该引入另一层抽象来创建一个Guess类吗?它会使代码更易于维护吗?还是只会让代码更复杂?通常情况下,代码行越少,出现错误的可能性就越小。然而,有时缺乏抽象会使代码变得复杂和纠结。在这种情况下是什么情况?一般的决定方法是什么?

你的经验越多,你就越容易通过看代码和敏锐地知道你想要做什么修改来判断。很多时候,您不会费心让代码更抽象,而很多时候,您会毫不犹豫地创建新的类。当有疑问时,创建新类并查看结果。重要的是不要破坏已经存在的功能。只有在有足够的单元测试的情况下才能这样做。

当您想引入一些新功能或修复一个 bug,但代码不合适时,您必须首先修改它。当您修改代码以使功能不改变时,这个过程被命名为重构。在有限的时间内更改一小部分代码,然后构建它。如果它编译并运行所有单元测试,那么您可以继续。提示是要经常运行构建。这就像在现有道路附近修建一条新道路。每隔几英里,你就会遇到一条旧路线。如果做不到这一点,你最终会在沙漠中的某个地方走上完全错误的方向,你所能做的就是回到你要重构的旧代码的起点。努力白费了。

迫使我们频繁运行构建的不仅是安全性,还有时间限制。重构并不能直接带来收益。该计划的功能直接与收入挂钩。没有人会为无限的重构工作付钱给我们。重构必须在某个时候停止,而且通常不再是什么都不需要重构的时候。代码永远不会是完美的,但是当它足够好的时候你可以停下来。而且,很多时候,程序员对代码的质量并不满意,当他们被一些外部因素(通常称为项目经理)强迫停止时,应该编译代码并运行测试,以便在实际的代码基础上执行新特性和错误修复。

重构是一个巨大的主题,在这样的活动中可以遵循许多技术。它是如此的复杂以至于有一整本马丁·福勒的书,很快就会有第二版。

在我们的例子中,我们希望对代码进行的修改是实现一个并行算法。首先要修改的是ColorManager。当我们想在终端上打印猜测和行时,我们使用了一些糟糕的技巧来实现它。为什么没有可以打印的颜色实现?我们可以有一个扩展原始Color类的类,并有一个返回表示该颜色的内容的方法。你有那种方法的候选名称吗?这是toString()方法。它在Object类中实现,任何类都可以自由覆盖它。当您将一个对象连接到一个字符串时,自动类型转换将调用此方法将该对象转换为String。顺便说一下,使用""+object而不是object.toString()来避免null指针异常是一个老把戏。不用说,我们不使用诡计。

当调试器想要显示某个对象的值时,toString()方法也会被 IDE 调用,因此如果没有其他原因,那么为了便于开发,通常建议实现toString()。如果我们有一个实现了toString()Color类,那么PrettyPrintRow类就变得相当简单,欺骗性更小:

public class PrettyPrintRow {

    public static String pprint(Row row) {
        var string = "";
        var 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.java189fundamentals.mastermind.lettered;

import packt.java189fundamentals.mastermind.Color;

public class LetteredColor extends Color {

    private final String letter;
    public LetteredColor(String letter){
        this.letter = letter;
    }

    @Override
    public String toString(){
        return letter;
    }
}

问题再次被推进。但实际上,这不是问题。这是一个OO设计。印刷不负责为颜色指定一个String来表示颜色。而颜色实现本身也不对此负责。必须在生成颜色的地方执行赋值,然后必须将String传递给LetteredColor类的构造器。color实例是在ColorManager中创建的,所以必须在ColorManager类中实现。还是不?ColorManager做什么?它创造了颜色和。。。

当您对列出功能的类进行解释或描述时,您可能会立即看到违反了单一责任原则ColorManager应该管理颜色。管理就是提供一种方法,使颜色按一定的顺序排列,当我们知道一种颜色时,得到第一种和第二种颜色。我们应该在一个单独的类中实现另一个职责,即创建颜色。

只有创建另一个类实例的功能的类称为factory。这与使用new运算符几乎相同,但与new不同的是,工厂可以以更灵活的方式使用。我们马上就会看到。ColorFactory接口包含一个方法,如下所示:

package packt.java189fundamentals.mastermind;

public interface ColorFactory {
    Color newColor();
}

只定义一个方法的接口称为函数式接口,因为它们的实现可以作为 Lambda 表达式提供,也可以作为方法引用提供,方法引用位于您要使用的对象的位置,对象是实现函数式接口的类的实例。例如,SimpleColorFactory实现创建以下Color对象:

package packt.java189fundamentals.mastermind;

public class SimpleColorFactory implements ColorFactory {
    @Override
    public Color newColor() {
        return new Color();
    }
}

在代码中使用new SimpleColorFactory()的地方,我们也可以编写Color::new() -> new Color()

这很像我们如何创建一个接口,然后创建一个实现,而不是仅仅在ColorManager中的代码中编写new Color()LetteredColorFactory更有趣一点:

package packt.java189fundamentals.mastermind.lettered;

import packt.java189fundamentals.mastermind.Color;
import packt.java189fundamentals.mastermind.ColorFactory;

public class LetteredColorFactory implements ColorFactory {

    private static final String letters = "0123456789ABCDEFGHIJKLMNOPQRSTVWXYZabcdefghijklmnopqrstvwxzy";
    private int counter = 0;

    @Override
    public Color newColor() {
        Color color = new LetteredColor(letters.substring(counter, counter + 1));
        counter++;
        return color;
    }
}

现在,在这里,我们有一个功能,当String对象被创建时,将它们分配给Color对象。非常重要的是,跟踪已经创建的颜色的counter变量不是static。上一章中的类似变量是static,这意味着每当较新的ColorManager对象创建太多颜色时,它可能会用完字符。当每个测试创建ColorManager对象和新的Color实例时,它确实发生在单元测试执行期间。印刷代码试图将新字母分配给新颜色。这些测试运行在同一个 JVM 中的同一个类加载器下,不幸的static变量不知道什么时候可以从零开始计算新的测试。

另一方面,这种工厂解决方案的缺点是,某个地方的某个人必须实例化工厂,而它不是ColorManagerColorManager已经有责任了,不是要创建一个色彩工厂。ColorManager必须在其构造器中获得ColorFactory

package packt.java189fundamentals.mastermind;

import java.util.HashMap;
import java.util.Map;

public class ColorManager {
    protected final int nrColors;
    protected final Map<Color, Color> successor = new HashMap<>();
    private final ColorFactory factory;
    private Color first;

    public ColorManager(int nrColors, ColorFactory factory) {
        this.nrColors = nrColors;
        this.factory = factory;
        createOrdering();
    }

    private Color[] createColors() {
        var colors = new Color[nrColors];
        for (int i = 0; i < colors.length; i++) {
            colors[i] = factory.newColor();
        }
        return colors;
    }

    private void createOrdering() {
        var 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() {
    var manager = new ColorManager(NR_COLORS, Color::new);
    Assert.assertNotNull(manager.firstColor());
}

这是实现由函数式接口定义的工厂的最简单方法。只需命名类并引用new操作符,就好像它是通过创建方法引用的方法一样。

接下来我们要重构的是Guess类,实际上,到目前为止我们还没有这个类。Guess类包含猜测的标记,可以计算完全匹配(颜色和位置)和部分匹配(颜色存在但位置错误)的数量。它还可以计算出这个猜测之后的下一个Guess。到目前为止,这个功能是在Guesser类中实现的,但是这并不是我们在检查表上已经做出的猜测时如何选择猜测的功能。如果我们遵循为颜色设置的模式,我们可以在一个名为GuessManager的单独类中实现这个功能,但是,到目前为止,还不需要它。同样,所需的抽象层次在很大程度上是一个品味的问题;这个东西不是黑的也不是白的。

需要注意的是,Guess对象只能一次生成。如果放在桌上,球员就不能换。如果我们有一个Guess还没有出现在桌子上,它仍然只是一个Guess,通过钉子的颜色和顺序来识别。Guess对象在创建后不会更改。这样的对象很容易在多线程程序中使用,被称为不可变对象。因为这是一个相对较长的类,所以我们将在本书的各个部分中研究代码:

package packt.java189fundamentals.mastermind;

import java.util.Arrays;
import java.util.HashSet;

public class Guess {
    public final static Guess none = new Guess(new Color[0]);
    private final Color[] colors;
    private boolean uniquenessWasNotCalculated = true;
    private boolean unique;

    public Guess(Color[] colors) {
        this.colors = Arrays.copyOf(colors, colors.length);
    }

构造器正在创建作为参数传递的颜色数组的副本。因为Guess是不可变的,所以这是非常重要的。如果我们只保留原始数组,那么Guess类之外的任何代码都可能改变数组的元素,实质上改变了不应该改变的Guess的内容。

代码的下一部分是两个简单的获取器:

public Color getColor(int i) {
    return colors[i];
}

public int nrOfColumns() {
    return colors.length;
}

下一种方法是计算nextGuess

public Guess nextGuess(ColorManager manager) {
    final var colors = Arrays.copyOf(this.colors, nrOfColumns());

    int i = 0;
    var 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;
    }
}

在这种方法中,我们从实际对象中包含的颜色数组开始计算nextGuess。我们需要一个工作数组,它被修改了,所以我们将复制原始数组。最后一个新对象可以使用我们在计算过程中使用的数组。为了实现这一点,我们需要一个独立的构造器,它不会创建Color数组的副本。这是一个可能的额外代码。只有当我们看到这是代码中的瓶颈并且对实际性能不满意时,我们才应该考虑创建它。在这个应用中,它也不是瓶颈,我们对性能感到满意,您将在稍后讨论基准测试时看到这一点。

下一种方法只是检查通过的Guess是否与实际的颜色数相同:

private void assertCompatibility(Guess guess) {
    if (nrOfColumns() != guess.nrOfColumns()) {
        throw new IllegalArgumentException("Can not compare different length guesses");
    }
}

这只是计算匹配的下两种方法使用的安全检查:

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;
}

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()方法来执行相同的操作。isUnique()方法如下:

public boolean isUnique() {
    if (uniquenessWasNotCalculated) {
        final var alreadyPresent = new HashSet<Color>();
        unique = true;
        for (final var color : colors) {
            if (alreadyPresent.contains(color)) {
                unique = false;
                break;
            }
            alreadyPresent.add(color);
        }
        uniquenessWasNotCalculated = false;
    }
    return unique;
}

对于相同的参数返回相同结果的方法称为幂等。如果该方法被多次调用并且计算占用大量资源,那么缓存该方法的返回值可能非常重要。当方法有参数时,缓存结果并不简单。object方法必须记住已计算的所有参数的结果,并且该存储必须有效。如果查找存储的结果比计算结果需要更多的资源,那么使用缓存不仅会占用更多的内存,而且会降低程序的速度。如果在对象的生存期内为多个参数调用了该方法,那么存储内存可能会变得太大。不再需要的元素必须清除。但是,我们无法知道缓存的哪些元素以后不需要。我们不是算命的,所以我们得猜。(就像算命师一样)

如您所见,缓存可能会变得复杂。要专业地做到这一点,最好使用一些现成的缓存实现。我们在这里使用的缓存只是冰山一角。或者,它甚至只是在它身上瞥见的阳光。

其余的类都相当标准,我们已经详细讨论了一些内容——对您的知识的一个很好的检查就是理解equals()hashCode()toString()方法是如何以这种方式实现的。我实现了toString()方法来帮助我进行调试,但它也被用于接下来的示例输出中。方法如下:

@Override
public boolean equals(Object o) {
    if (this == o) return true;
    if (o == null || !(o instanceof Guess)) return false;
    var guess = (Guess) o;
    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 一样运行。在我的机器上,我有四个内核,每个内核都能同时执行两个线程;所以,我的 MacOS 几乎就像一台 8 CPU 机器。当我开始工作时,一台 8 CPU 的电脑是一台价值百万美元的机器。

进程有不同的记忆。允许它们使用内存的一部分,如果进程试图使用不属于它的部分,处理器将停止这样做。操作系统将终止进程。

试想一下,最初的 Unix 开发人员将停止进程的程序命名为kill,他们一定很沮丧。停止一个进程叫做终止它。就像中世纪,他们砍掉了一个重罪犯的手。你碰错了记忆的一部分,就死定了。我不想成为一个过程。

操作系统的内存处理非常复杂,除了将进程彼此分离之外。当内存不足时,操作系统会将内存的一部分写入磁盘,释放内存,并在需要时重新加载该部分。这是一个非常复杂、低层次实现和高度优化的算法,由特殊的硬件操作支持。这是操作系统的责任。

线程

当我说操作系统在时隙中执行进程时,我简化了这种情况的实际发生方式。每个进程都有一个或多个线程,线程被执行。线程是由外部调度器管理的最小执行。较旧的操作系统没有线程的概念,正在执行进程。事实上,第一个线程实现只是共享内存的进程的副本。

如果你读一些旧的东西,你可能会听到术语轻量级进程。意思是一根线。

重要的是线程没有自己的内存。他们利用记忆的过程。换句话说,在同一进程中运行的线程对同一内存段具有不可区分的访问权限。

实现并行算法的可能性,该算法在机器中使用多个核非常强大,但同时,它可能会导致错误:

假设两个线程递增相同的长变量。增量首先计算低 32 位的增量值,如果有溢出位,则计算高 32 位的增量值。这是操作系统可能中断的两个或多个步骤。一个线程可能会增加低 32 位,它会记住对高 32 位有一些操作,开始计算,但在中断之前没有时间存储结果。然后,另一个线程增加低 32 位,高 32 位,然后第一个线程只保存它计算的高 32 位。结果变得混乱。在旧的 32 位 Java 实现上,演示这种效果非常容易。在 64 位 Java 实现中,所有的 64 位都加载到寄存器中,并在一个步骤中保存回内存,因此演示这个多线程不是那么容易,但并不意味着没有多线程。

当一个线程暂停而另一个线程启动时,操作系统必须执行上下文切换。这意味着,除其他外,必须保存 CPU 寄存器,然后将其设置为其他线程应有的值。上下文切换总是保存线程的状态,并加载要启动的线程先前保存的状态。这是一个 CPU 寄存器级别。这种上下文切换非常耗时;因此,上下文切换越多,用于线程管理的 CPU 资源就越多,而不是让它们运行。另一方面,如果没有足够的开关,一些线程可能没有足够的时间执行,程序就会挂起。

纤程

Java 版本 11 没有纤程,但是有一些库支持有限的纤程处理,还有一个 JDK 项目,其目标是拥有支持纤程的更高版本的 JVM。因此,我们迟早会有 Java 中的纤程,因此,理解和了解它们是什么很重要。

纤程是比线更细的单位。在线程中执行的程序代码可能会决定放弃执行,并告诉纤程管理器只执行其他纤程。有什么意义?为什么它比使用另一个线程更好?原因是这样,纤程可以避免部分上下文切换。上下文切换不能完全避免,因为开始执行它的代码的不同部分可能以完全不同的方式使用 CPU 寄存器。因为是同一个线程,所以上下文切换不是操作系统的任务,而是应用的任务。

操作系统不知道是否使用了寄存器的值。寄存器中有位,只有看到处理器的状态,没有人能分辨出这些位是与当前代码执行相关,还是恰好以这种方式存在。编译器生成的程序确实知道哪些寄存器很重要,哪些寄存器可以忽略。这些信息在代码中的位置不同,但当需要交换机时,纤程会将需要在该点进行切换的信息传递给进行切换的代码。

编译器计算这些信息,但 Java 在当前版本中不支持纤程。在 Java 中实现纤程的工具在编译阶段之后会分析和修改类的字节码。

Golang 的 GoRoutine 是纤程类型,这就是为什么您可以轻松地在 Go 中启动数千个,甚至数百万个 GoRoutine 的原因,但是建议您将 Java 中的线程数限制为较低的数目。他们不是一回事。

尽管术语轻量线程正在慢慢消失,被越来越少的人使用,但纤程仍然经常被称为轻量线。

Java.lang.Thread线程

Java 中的一切(几乎)都是一个对象。如果我们想启动一个新线程,我们将需要一个对象,因此,一个代表线程的类。这个类是java.lang.Thread,它内置在 JDK 中。当您启动 Java 代码时,JVM 会自动创建一些Thread对象,并使用它们来运行它所需要的不同任务。如果您启动了 VisualVM,您可以选择任何 JVM 进程的线程选项卡,并查看 JVM 中的实际线程。例如,我启动的 VisualVM 有 29 个活动线程。其中一个是名为main的线程。这是一个开始执行main方法的方法(惊喜!)。main线程启动了大多数其他线程。当我们要编写一个多线程应用时,我们必须创建新的Thread对象并启动它们。最简单的方法是启动new Thread(),然后在线程上调用start()方法。它将开始一个新的Thread,它将立即结束,因为我们没有给它任何事情做。在 JDK 中,Thread类不执行我们的业务逻辑。以下是指定业务逻辑的两种方法:

  • 创建实现Runnable接口的类并将其实例传递给Thread对象
  • 创建扩展Thread类并覆盖run方法的类

下面的代码块是一个非常简单的演示程序:

package packt.java189fundamentals.thread;

public class SimpleThreadIntermingling {
    public static void main(String[] args) {
        Thread t1 = new MyThread("t1");
        Thread t2 = new MyThread("t2");
        t1.start();
        t2.start();
        System.out.print("started ");

    }

    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 + ", ");
            }
        }
    }
}

前面的代码创建两个线程,然后一个接一个地启动它们。当调用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 分配的一块内存。

当启动方法被调用时,关于线程的信息被传递给操作系统,操作系统对线程进行调度,以便在有适当的时隙时可以由它执行。这样做是一种足智多谋的行为,这就是为什么我们不创建,尤其是不在需要时才开始新的Thread对象的原因。我们不会创建新的Threads,而是将现有的线程保留一段时间,即使目前不需要,如果有合适的线程,也会重用现有的线程。

当操作系统调度和执行线程时,操作系统中的线程也可以处于运行状态,也可以处于可运行状态。目前,Java JDK API 没有很好的理由将两者区分开来。那就没用了。当一个线程处于RUNNABLE状态时,从线程内部询问它是否真的在运行,如果代码刚从Thread类中实现的getState()方法返回,那么它就会运行。如果它没有运行,它本来就不会从调用中返回。更进一步说,在未运行的Thread中调用方法getState()也是不可能的。如果getState()方法是从另一个线程调用的,那么在该方法返回时与另一个线程相关的结果将是无意义的。到那时,操作系统可能已经多次停止或启动被查询的线程。

当线程中执行的代码试图访问当前不可用的资源时,线程处于BLOCKED状态。为了避免资源的不断轮询,操作系统提供了一种有效的通知机制,以便线程在需要的资源可用时返回到RUNNABLE状态。

线程在等待其他线程或锁时处于WAITTIMED_WAITING状态。TIMED_WAITING等待开始时的状态,调用有超时的方法版本。

最后,当线程完成执行后,达到TERMINATED状态。如果在前面示例的末尾附加以下行,则将得到一个TERMINATED打印输出,并向屏幕抛出异常,抱怨线程状态非法,这是因为您无法启动已终止的线程:

System.out.println(); 
System.out.println(t1.getState()); 
System.out.println(); 
t1.start();

我们可以创建一个实现Runnable接口的类,而不是扩展Thread类来定义异步执行什么,这样做与OO编程方法更为一致。我们在类中实现的东西不是线程的功能。它更像是一种可以执行的东西。这是一个可以运行的东西。

如果在不同的线程中执行是异步的,或者在调用run方法的同一个线程中执行,那么这是一个需要分离的不同关注点。如果这样做的话,我们可以将类作为构造器参数传递给一个Thread对象。对Thread对象调用start将启动我们传递的对象的run方法。这不是收益。好处是我们还可以将Runnable对象传递给Executor(可怕的名字,哈!)。Executor是一个接口,实现以高效的方式在Thread对象中执行Runnable(还有Callable,见下文)对象。执行者通常有一个准备就绪并处于BLOCKED状态的Thread对象池。当Executor有一个新任务要执行时,它将它交给Thread对象之一,并释放阻塞线程的锁。Thread进入RUNNABLE状态,执行Runnable,再次被阻塞。它不会终止,因此,可以在以后重用它来执行另一个Runnable。这样,Executor实现就避免了操作系统中线程注册的资源消耗过程。

专业应用代码从不创建新的Thread。应用代码使用框架来处理代码的并行执行,或者使用一些ExecutorService提供的Executor实现来启动RunnableCallable对象。

陷阱

我们已经讨论了在开发并行程序时可能遇到的许多问题。在本节中,我们将用解决问题的常用术语对它们进行总结。术语不仅有趣,而且在与同事交谈时也很重要,这样你们就可以互相理解了。

死锁

死锁是最臭名昭著的并行编程陷阱,因此,我们将从这个开始。为了描述这种情况,我们将采用官僚的比喻。

官僚必须在纸上盖章。为此,他需要邮票和纸张。首先,他走到放邮票的抽屉里拿了邮票。然后,他走向放纸的抽屉,拿起纸。他在邮票上涂上墨水,然后在纸上按。然后,他把邮票和纸放回原处。一切都是桃色的;我们在云端 9。

如果另一个官僚先拿报纸,然后再拿邮票,会怎么样?他们很快就会变成一个拿着邮票等着报纸的官僚,一个拿着报纸等着邮票的官僚。而且,他们可能只是呆在那里,永远冻结,然后越来越多的人开始等待这些锁,纸张永远不会被盖章,整个系统陷入冻结的无政府状态。

为了避免这种情况,必须对锁进行排序,并且应该始终以相同的顺序获取锁。在前面的示例中,首先获取墨垫,然后获取戳记的简单约定解决了问题。无论是谁得到了邮票,都可以肯定墨水垫是免费的,或者很快就会免费的。

竞态条件

我们讨论竞态条件,当计算结果可能基于不同并行运行线程的速度和 CPU 访问而不同时。我们来看看以下两种方法:

   void method1(){
1       a = b; 
2       b = a+1; 
        } 
    void method2(){ 
3       c = b; 
4       b = c+2; 
        }

线路的顺序可以是 1234、1324、1342、3412、3142 或 3142。四行执行顺序,可保证123运行前4前运行,但无其他限制。假设b的值在开始时为零,则b的值在段执行结束时为 1 或。这几乎是我们永远不想要的。我们更喜欢我们的程序的行为不是随机的,除非,也许在实现随机生成器时。

注意,并行主谋游戏的实现也面临着一种种族条件。实际猜测很大程度上取决于不同线程的速度,但从最终结果的角度来看,这与此无关。我们可能在不同的运行中有不同的猜测,这样,算法就不确定了。我们所保证的是我们找到了最终的解决方案。

过度使用的锁

在许多情况下,可能会发生线程在等待锁,锁保护资源不受并发访问的影响。如果资源不能同时被多个线程使用,并且线程数较多,则线程将处于饥饿状态。然而,在许多情况下,资源可以以某种方式组织,以便线程能够访问资源提供的某些服务,并且锁结构可以更少地限制。在这种情况下,锁被过度使用,并且可以修复这种情况,而不为线程分配更多资源。可以使用多个锁来控制对资源不同功能的访问。

饥饿

饥饿是指多个线程等待一个资源试图获取锁,而一些线程只有在很长一段时间后才能访问该锁,或者从来没有访问过该锁。当锁被释放并且有线程在等待它时,其中一个线程就可以获得锁。如果线程等待的时间足够长,通常无法保证它能获得锁。这样的机制需要对线程进行密集的管理,在等待队列中对线程进行排序。由于锁定应该是一种低延迟和高性能的操作,因此即使只有几个 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 {
    public static void main(String[] args) throws InterruptedException, ExecutionException {
        final var es = Executors.newFixedThreadPool(2);
        final var t1 = new MyRunnable("t1");
        final var t2 = new MyRunnable("t2");
        final Future<?> f1 = es.submit(t1);
        final Future<?> f2 = es.submit(t2);
        System.out.print("started ");
        var o = f1.get();
        System.out.println("object returned " + o);
        f2.get();
        System.out.println();
        es.submit(t1);
        es.shutdown();
    }

    static class MyRunnable implements Runnable {
        private final String name;

        MyRunnable(String name) {
            this.name = name;
        }

        @Override
        public void run() {
            for (int i = 1; i < 10; i++) {
                System.out.print(name + " " + i + ", ");
            }
        }
    }
}

这次,我们第二次提交任务时没有异常。在本例中,我们使用的是一个固定大小的线程池,该线程池有两个Thread插槽。因为我们只想同时启动两个线程,这就足够了。有些实现动态地扩展和缩小池的大小。当我们想要限制线程数量或从其他信息源中事先知道线程数时,应该使用固定大小的池。在这种情况下,将池的大小更改为一个是一个好的实验,并且在这种情况下,第二个任务在第一个任务完成之前不会开始。服务将没有另一个线程用于t2,并且必须等到池中的一个线程和唯一的Thread可用。

当我们将任务提交给服务时,即使任务当前无法执行,它也会返回。这些任务被放入队列中,一旦有足够的资源启动它们,它们就会立即开始执行。submit方法返回一个Future<?>对象,正如我们在前面的示例中看到的那样。

它就像一张服务票。你把车交给修理工,然后你就得到了一张罚单。在汽车修好之前,你不需要呆在那里,但是,你可以随时询问汽车是否准备好了。你只需要一张票。你也可以决定等到车子准备好。物体是类似的东西。你没有得到你需要的值。它将异步计算。然而,有一个Future承诺它会在那里,而您访问所需对象的票证就是Future对象。

当您有一个Future对象时,您可以调用isDone()方法来查看它是否准备就绪。您可以开始等待它在有或没有超时的情况下调用get()。您也可以取消执行它的任务,但是,在这种情况下,结果可能是有问题的。就像,在你的车的情况下,如果你决定取消任务,你可能会得到你的车与电机拆解。类似地,取消一个没有准备好的任务可能会导致资源丢失、数据库连接被打开和无法访问(这对我来说是一个痛苦的记忆,即使 10 年之后),或者只是一个乱七八糟的不可用对象。准备要取消的任务或不要取消它们。

在前面的示例中,由于我们提交了Runnable对象,而不是Callable对象,所以Future没有返回值。在这种情况下,不使用传递给Future的值。通常是null,但这并不是什么可依赖的。

最后也是最重要的一件事,许多开发人员都错过了,即使是我,在多年没有使用代码编写多线程 JavaAPI 之后,就是关闭了ExecutorService。创建了ExecutorService,它有Thread个元素。当所有非守护线程停止时,JVM 将停止。”直到胖女人唱歌,一切才结束。”

如果线程在启动前被设置为守护进程(调用setDaemon(true)),那么它就是守护线程。一个自动成为启动它的守护线程的线程也是守护线程。当所有其他线程都完成并且 JVM 想要完成时,守护线程被 JVM 停止。JVM 本身执行的一些线程是守护线程,但是在应用中创建守护线程可能没有实际用途。

不关闭服务只会阻止 JVM 停止。在main方法完成后,代码将挂起。为了告诉ExecutorService不需要它拥有的线程,我们必须shutdown服务。调用只会启动关机并立即返回。在这种情况下,我们不想等待。无论如何,JVM 都会这样做。如果我们需要等待,我们将不得不调用awaitTermination

CompletableFuture

Java 版本 1.8 引入了接口FutureCompletableFuture的新实现。java.util.concurrent.CompletableFuture类可用于异步执行定义回调的程序以处理结果。由于 Java1.8 还引入了 Lambda 表达式,因此可以使用它们来描述回调:

public static void main(String[] args) throws ExecutionException, InterruptedException {
    var future = CompletableFuture.supplyAsync(() ->
            {
                var negative = true;
                var pi = 0.0;
                for (int i = 3; i < 100000; i += 2) {
                    if (negative)
                        pi -= (1.0 / i);
                    else
                        pi += (1.0 / i);
                    negative = !negative;
                }
                pi += 1.0;
                pi *= 4.0;
                return pi;
            }
    ).thenAcceptAsync(piCalculated -> System.out.println("pi is " + piCalculated));
    System.out.println("All is scheduled");
    future.get();
}

completable future 类实现了Future接口,但它还提供了其他方法,当我们需要描述异步代码的执行时,它也提供了其他方便的方法。额外的方法在CompletionStage接口中定义,起初这个名字有点奇怪,但我们很快就会理解它的真正含义。

我们已经看到了在这个接口中定义的许多方法之一-thenAcceptAsync()。前面的代码创建了一个由 Lambda 表达式定义的完全Future。静态方法supplyAsync()接受Supplier作为参数。Java 的线程系统稍后会调用这个供应器。此方法的返回值是一个CompletableFuture,用于使用thenAcceptAsync()方法创建另一个CompletableFuture。第二个CompletableFuture与第一个CompletableFuture相连。只有当第一个完成时,它才会开始。thenAcceptAsync()的参数是一个消费者,它将消费Supplier提供的第一个CompletableFuture的结果。代码的结构可以用以下伪代码来描述:

CompletableFuture.supplyAsync( supply_value ).thenAcceptAsync( consume_the_value )

它说启动由supply_value表示的Supplier,当它完成时,将这个值提供给由consume_the_value表示的消费者。示例代码计算 PI 的值并提供该值。consume_the_value部分将值打印到输出。当我们运行代码时,文本All is scheduled可能会首先打印到输出中,然后才打印 PI 的计算值。

类还实现了许多其他方法。当CompletableFuture不产生任何值或者我们不需要消耗值时,我们应该使用thenRunAsync(Runnable r)方法。

如果我们想消费值,同时又想从中创造新的值,那么我们应该使用thenApplyAsync()方法。此方法的参数是一个Function,它获取运行后CompletableFuture的结果,结果是CompletableFuture thenApplyAsync()返回的值。

CompletableFuture完成之后,还有许多其他方法执行代码。所有这些都用于在第一个可完成的将来完成后指定某个回调。CompletableFuture代码的执行可能引发异常。在这种情况下,CompletableFuture就完成了;它不会抛出异常。异常被捕获并存储在CompletableFuture对象中,只有当我们想访问调用get()方法的结果时才会抛出异常。方法get()抛出一个封装原始异常的ExecutionExceptionjoin()方法抛出原始异常。

thenAcceptAsync()这样的方法有它们的同步对,例如thenAccept()。如果调用此函数,则将执行传递的代码:

  • 如果此代码所依赖的CompletableFuture尚未完成,则使用用于执行原始CompletableFuture的同一线程;或者
  • 如果CompletableFuture已经完成,则使用普通调用线程

换句话说,如果我们再看看伪代码:

var cf = CompletableFuture.supplyAsync( supply_value );
cf.thenAccept( consume_the_value )

但这次是thenAccept()而不是thenAcceptAsync(),所以执行supply_value表示的代码的线程在完成supply_value后继续执行consume_the_value,或者,如果调用方法thenAccept()supply_value的执行已经完成,则只执行如下:

consume_the_value( cf.get() )

在本例中,代码consume_the_value只是同步执行。(请注意,如果发生异常,它将被存储,而不是直接抛出。)

使用CompletableFuture的最佳用例是当我们进行异步计算并且需要回调方法来处理结果时。

ForkJoinPool

ForkJoinPool是一个特殊的ExecutorService,它有执行ForkJoinTask对象的方法。当我们要执行的任务可以被分解成许多小任务,然后当结果可用时,这些类非常方便。使用这个执行器,我们不需要关心线程池的大小和关闭执行器。线程池的大小根据给定机器上的处理器数量进行调整,以获得最佳性能。因为ForkJoinPool是一个特殊的ExecutorService是为短期运行的任务而设计的,所以它不希望有任何任务在那里停留更长的时间,也不希望在没有更多任务要运行时需要任何任务。因此,它作为守护线程执行;当 JVM 关闭时,ForkJoinPool自动停止。

为了创建任务,程序员应该扩展java.util.concurrent.RecursiveTaskjava.util.concurrent.RecursiveAction。第一个是在任务有返回值时使用的,第二个是在没有返回计算值时使用的。它们之所以被称为递归的,是因为很多时候,这些任务会分解它们必须解决的问题,并通过 Fork/Join API 异步调用这些任务。

使用此 API 要解决的一个典型问题是快速排序。在第 3 章“优化专业排序代码”中,我们创建了两个版本的快速排序算法,一个使用递归调用,一个不使用递归调用。我们还可以创建一个新的任务,它不是递归地调用自己,而是将任务调度到另一个处理器执行。调度是ForkJoinPool实现ExecutorService的任务。

您可以重温第 3 章中的Qsort.java代码,“优化专业排序代码”。以下是使用ForkJoinPool的版本,没有一些明显的代码,包括构造器和final字段定义:

public void qsort(Sortable<E> sortable, int start, int end) {
    ForkJoinPool pool = new ForkJoinPool();
    pool.invoke(new RASort(sortable, start, end));
}

private class RASort extends RecursiveAction {

    final Sortable<E> sortable;
    final int start, end;

    public RASort(Sortable<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);
            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();
        }
    }
}

数组被枢轴元素拆分后,创建两个RecursiveAction对象。它们存储对数组的左侧和右侧进行排序所需的所有信息。当invokeAll()被调用时,这些操作被安排。invokeAll()方法由前面的代码通过RecursiveActionForkJoinClass类继承,而RecursiveAction本身在该代码中进行了扩展。

API 和 Oracle 的 Javadoc 文档的应用上都有很好的阅读材料。

变量访问

既然我们可以启动线程并创建并行运行的代码,现在是时候谈谈这些线程如何在彼此之间交换数据了。乍一看,这似乎相当简单。线程使用相同的共享内存;因此,它们可以读取和写入 Java 访问保护允许它们的所有变量。这是正确的,只是有些线程可能只是决定不读取内存。毕竟,如果他们最近刚刚读取了一个特定变量的值,如果没有修改,为什么还要从内存中再次读取到寄存器中呢?谁会修改它?下面是一个简短的例子:

package packt.java189fundamentals.thread;

public class VolatileDemonstration implements Runnable {
    private final Object o;
    private static final Object NON_NULL = new Object();
    @Override
    public void run() {
        while( o == null );
        System.out.println("o is not null");
    }

    public VolatileDemonstration() throws InterruptedException {
        new Thread(this).start();
        Thread.sleep(1000);
        this.o = NON_NULL;
    }

    public static void main(String[] args) throws InterruptedException {
        VolatileDemonstration me = new VolatileDemonstration();
    }
}

会发生什么?您可能期望代码启动,启动新线程,然后,当main线程将对象设置为非null的对象时,它会停止吗?不会的。

它可能会在一些 Java 实现上停止,但在大多数实现中,它只会继续旋转。原因是 JIT 编译器优化了代码。它看到循环什么也不做,而且变量永远不会是非空的。允许假设因为没有声明为volatile的变量不应该被不同的线程修改,所以 JIT 可以进行优化。如果我们将Object o变量声明为volatile,那么代码将停止。您还必须删除final关键字,因为变量不能同时是finalvolatile

如果您试图删除对sleep的调用,代码也将停止。然而,这并不能解决这个问题。原因是 JIT 优化只在大约 5000 次代码执行循环之后才开始。在此之前,代码运行简单,并在优化之前停止,这将消除对非易失性变量的额外访问(通常不需要)。

如果这是如此可怕,那么为什么我们不声明所有变量都是易变的呢?为什么 Java 不能为我们做到这一点?答案是速度。为了更深入地理解这一点,我们将用办公室和官僚来比喻。

CPU 心跳

现在,CPU 在 2 到 4GHz 频率的处理器上运行。这意味着处理器每秒得到 2 到 4 倍于10**9的时钟信号来做某事。处理器不能执行比这更快的任何原子操作,而且也没有理由创建一个比处理器可以遵循的更快的时钟。这意味着 CPU 在半纳秒或四分之一纳秒内执行一个简单的操作,例如递增寄存器。这是处理器的心跳,如果我们认为官僚是人,他们是谁,那么它相当于一秒钟,大约,他们的心跳。在我们的想象中,这会将计算机的运行速度减慢到可以理解的速度。

处理器在芯片上有不同级别的寄存器和高速缓存;L1、L2,有时还有 L3;还有存储器、SSD、磁盘、磁盘、网络和磁带,可能需要它们来检索数据。

访问一级缓存中的数据大约需要 0.5 ns。你可以抓起你桌上半秒钟的一张纸。访问二级缓存中的数据需要 7 ns。这是抽屉里的一张纸。你得把椅子往后推一点,弯曲成坐姿,拉出抽屉,拿着纸,把抽屉往后推,把纸抬起来放在桌子上;这需要 10 秒钟,左右。

主存读取为 100ns。官僚站起来,走到靠墙的共享文件库,等着其他官僚把文件拿出来或放回去,选择抽屉,把它拿出来,拿着文件,走回办公桌。这需要两分钟。这是一种易变的变量访问,每次你在一个文档上写一个单词,它必须做两次,一次读,一次写,即使你碰巧知道下一件事就是在同一张纸上填写表单的另一个字段。

现代架构没有多个 cpu,而是有多个核的单个 cpu,速度要快一些。一个内核可以检查另一个内核的缓存,以查看是否对同一变量进行了任何修改。这将易变访问加速到 20ns 左右,这仍然比非易变慢一个数量级。

尽管其余部分不太关注多线程编程,但这里值得一提,因为它很好地理解了不同的时间量级。

从 SSD 读取一个块(通常为 4k 块)需要 150000ns。以人类的速度,这比 5 天多一点。在 Gb 本地以太网上通过网络向服务器读取或发送数据需要 0.5 毫秒,这就好像是在等一个月的时间。如果网络上的数据是在一个旋转的磁盘上,那么寻道时间加起来(直到磁盘旋转以使磁表面的一部分进入读取头下的时间)为 20ms。对于在我们的计算环境中来回运行的想象中的小官僚来说,这大约是一年。

如果我们在互联网上通过大西洋发送一个网络数据包,大约需要 150 毫秒。这就像 14 年,而这仅仅是一个数据包;如果我们要通过海洋发送数据,这将构成数千年的历史。如果我们计算一台机器启动一分钟,它相当于我们整个文明的时间跨度。

当我们想了解 CPU 大部分时间在做什么时,我们应该考虑这些数字。它等待着。此外,当你想到现实生活中官僚的速度时,这个比喻也有助于安抚你的神经。如果我们考虑他们的心跳,他们毕竟没有那么慢,这意味着他们有心脏。然而,让我们回到现实生活中,CPU,L1 和 L2 缓存,以及易失性变量。

易变变量

让我们在示例代码中修改o变量的声明,如下所示:

private volatile Object o = null;

前面的代码运行良好,大约一秒钟后停止。任何 Java 实现都必须保证多个线程可以访问volatile字段,并且该字段的值是一致更新的。这并不意味着volatile声明将解决所有的同步问题,而是保证不同的变量及其值变化关系是一致的。例如,让我们考虑在一个方法中增加以下两个字段:

private int i=0,j=0; 

 public void method(){ 
     i++; j++; 
 }

在前面的代码中,在不同的线程中读取ij可能永远不会产生i>j。如果没有volatile声明,编译器可以自由地重新组织增量操作的执行,因此,它不能保证异步线程读取一致的值。

同步块

声明变量不是确保线程之间一致性的唯一工具。Java 语言中还有其他工具,其中一个是同步块。synchronized关键字是语言的一部分,它可以在方法或程序块前面使用,该方法、构造器或初始化器块中。

Java 程序中的每个对象都有一个监视器,可以被任何正在运行的线程锁定和解锁。当一个线程锁定一个监视器时,据说该线程持有该锁,并且没有两个线程可以同时持有一个监视器的锁。如果一个线程试图锁定一个已经被锁定的监视器,它会得到BLOCKED,直到监视器被释放。同步块以synchronized关键字开始,然后在括号之间指定一个对象实例,然后发生阻塞。下面的小程序演示了synchronized块:

package packt.java189fundamentals.thread;

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附加到名为sbStringBuffer上。另一个附加bb。这个附加操作分两个阶段进行,中间是睡眠。睡眠是为了避免 JIT 将两个单独的步骤优化为一个步骤。每个线程执行append1000 次,每次追加ab两次。由于两个append一个接一个,并且它们在synchronized块内,所以ababab序列不可能进入StringBuffer中。当一个线程执行同步块时,另一个线程不能执行它。

如果我删除synchronized块,那么我用来测试 Java HotSpot(TM)64 位服务器 VM 的 JVM(对于本书的第二版,构建 9-ea+121,混合模式和 18.3 b 构建 10+46,混合模式)打印出失败,尝试数量大约为几百次。(看看 Packt 提供的代码库中的SynchronizedDemoFailing类。)

它清楚地说明了同步意味着什么,但它也将我们的注意力吸引到另一个重要的现象上。错误只发生在大约每几十万次执行中。这是极为罕见的,即使这个例子是用来证明这样的灾难。如果一个 bug 很少出现,那么很难重现,甚至更难调试和修复。大多数同步错误都以神秘的方式表现出来,修复它们通常是仔细检查代码而不是调试的结果。因此,在启动商业多线程应用之前,清楚地了解 Java 多线程行为的真正本质是非常重要的。

synchronized关键字也可以用在方法前面。在这种情况下,获取锁的对象是this对象。在static方法的情况下,对整个类执行同步。

等待和通知

Object类中实现了五个方法,可以用来获得进一步的同步功能—wait,其中有三个不同的超时参数签名notifynotifyAll。要调用wait,调用线程应该拥有调用waitObject的锁。这意味着您只能从同步块内部调用wait,当调用它时,线程得到BLOCKED并释放锁。当另一个线程对同一个Object调用notifyAll时,该线程进入RUNNABLE状态。它无法立即继续执行,因为它无法获得对象上的锁。此时锁被刚才称为notifyAll的线程所持有。然而,在另一个线程释放锁之后的某个时候,换句话说,它从synchronized块中出来,等待的线程可以获取它并继续执行。

如果有更多线程在等待一个对象,那么所有线程都会脱离BLOCKED状态。notify方法只唤醒一个等待的线程。不能保证哪根线被唤醒。

waitnotifynotifyAll的典型用法是当一个或多个线程正在创建被另一个或多个线程使用的对象时。对象在线程之间移动的存储是一种队列。使用者等待,直到队列中有要读取的内容,生产者将对象一个接一个地放入队列。生产者在队列中放入内容时通知消费者。如果队列中没有剩余的空间,生产者必须停止并等待,直到队列有一些空间。在这种情况下,生产者调用wait方法。为了唤醒生产者,消费者在读到某样东西时会打电话给notifyAll

使用者在循环中使用队列中的对象,并且只有在队列中没有可读取的内容时才调用wait。当生产者调用notifyAll时,没有消费者等待,通知被忽略。它飞走了,但这不是问题;消费者没有等待。当消费者消费了一个对象并调用了notifyAll,并且没有生产者等待时,情况也是一样的。这不是问题。

消费者消费,调用notifyAll,在通知悬而未决后,找不到等待的生产者,生产者就开始等待,这是不可能发生的。这不可能发生,因为整个代码都在一个synchronized块中,它确保没有生产者在关键部分。这就是为什么只有在获取Object类的锁时才能调用waitnotifynotifyAll的原因。

如果有许多使用者执行相同的代码,并且他们同样擅长使用对象,那么调用notify而不是notifyAll就是一种优化。在这种情况下,notifyAll只会唤醒所有使用者线程。然而,只有幸运的人才会意识到他们被吵醒了;其他人会看到其他人已经逃脱了诱饵。

我建议您至少练习一次,以实现可用于在线程之间传递对象的阻塞队列。只作为实践来做,不要在生产中使用实践代码。从 Java1.5 开始,有BlockingQueue接口的实现。用一个适合你需要的。在我们的示例代码中,我们也将这样做。

幸运的是你能用 Java11 编写代码。我在 Java1.4 的时候就开始专业地使用它,有一次,我不得不实现一个阻塞队列。有了 Java,生活变得越来越美好和轻松。

在专业代码中,我们通常避免使用synchronized方法或块和volatile字段以及waitnotify方法,如果可能的话,还可以使用notifyAll。我们可以在线程之间使用异步通信,也可以将整个多线程过程传递给框架进行处理。在某些特殊情况下,当代码的性能很重要时,synchronizedvolatile关键字是不可避免的,或者我们找不到更好的构造。有时,特定代码和数据结构的直接同步比 JDK 类提供的方法更有效。但是,应该注意的是,这些类也使用这些低级同步结构,因此它们的工作方式并不神奇。要从专业代码中学习,可以在实现自己的版本之前查看 JDK 类的代码。您将认识到,实现这些队列并不是那么简单;没有充分的理由,类的代码并不复杂。如果你觉得代码很简单,那就意味着你有足够的资历去知道哪些东西不能重新实现。或者,你甚至不知道你读了什么代码。

锁包含在 Java 中;每个Object都有一个锁,线程在进入synchronized块时可以获得该锁。我们已经讨论过了。在某些编程代码中,这种结构有时不是最优的。

在某些情况下,可以排列锁的结构以避免死锁。可能需要在B之前获取锁A,在C之前获取B。但是,A应该尽快释放,以允许访问受锁D保护的资源,也需要先锁A。在复杂且高度并行的结构中,锁通常被构造为树。一个线程应该沿着树向下爬到一个表示获取锁的资源的叶子上。在攀爬的过程中,线程先抓住一个节点上的锁,然后抓住它下面的一个节点上的锁,然后释放上面的锁,就像一个真正的攀爬者在下降一样(或者攀爬,如果你想象树的叶子在顶部,这更真实;然而,图形通常显示树是颠倒的)。

你不能留下一个synchronized块留在第一个街区内的另一个。同步块嵌套。java.util.concurrent.Lock接口定义了处理这种情况的方法,并且在我们的代码中使用的 JDK 中也有实现。有锁时,可以调用lock()unlock()方法,实际顺序在手中,可以写下一行代码,得到锁顺序:

a.lock(); b.lock(); a.unlock(); c.lock()

然而,伴随着巨大的自由,也伴随着巨大的责任。与同步块的情况不同,锁定和解锁并不与代码的执行序列相关联,在某些情况下,创建代码可能非常容易,因为在某些情况下,它只是丢失了一个锁而没有解锁,从而导致一些资源无法使用。这种情况类似于内存泄漏。你会分配(锁定)一些东西而忘记释放(解锁)它。一段时间后,程序将耗尽资源。

我个人的建议是尽可能避免使用锁,而是在线程之间使用更高级别的构造和异步通信,比如阻塞队列。

条件

java.util.concurrent.Condition接口在功能上与内置的wait()notify()notifyAll()对象类似。任何Lock的实现都应该创建新的Condition对象,并将它们作为newCondition()方法调用的结果返回。当线程有一个Condition时,当线程有创建条件对象的锁时,它可以调用await()signal()signalAll()

其功能与前面提到的Object方法非常相似。最大的区别是,你可以为一个Lock创建许多Condition对象,它们彼此独立地工作,而不是独立于Lock

重入锁

ReentrantLock是 JDK 中Lock接口的最简单实现。创建这种类型的锁有两种方法,一种是使用公平策略,另一种是不使用公平策略。如果以true作为参数调用ReentrantLock(Boolean fair)构造器,那么在有多个线程等待的情况下,锁将被分配给等待锁时间最长的线程。这将避免线程等待过多的时间和饥饿。另一方面,以这种方式处理锁需要更多的来自ReentrantLock代码的管理,并且运行速度较慢。(在测量代码之前,不要害怕代码太慢。)

重入读写锁

这个类是ReadWriteLock的一个实现。ReadWriteLock是一种可用于并行读访问和独占写访问的锁。这意味着多个线程可以读取受锁保护的资源,但是当一个线程写入资源时,没有其他线程可以访问它,甚至在此期间也不能读取它。ReadWriteLock只是readLock()writeLock()方法返回的两个Lock对象。为了获得对ReadWriteLock的读访问权,代码必须调用myLock.readLock().lock(),并获得对写锁myLock.writeLock().lock()的访问权。获取其中一个锁并在实现中释放它与另一个锁是耦合的。例如,要获取写锁,任何线程都不应该具有活动的读锁。

使用不同的锁有几个复杂的地方。例如,可以获取读锁,但只要具有读锁,就无法获取写锁。必须先释放读锁才能获得写锁。这只是一个简单的细节,但这是一个新手程序员有很多次麻烦。为什么要这样实现?为什么程序要获得一个写锁,当它仍然不确定是否要写入资源时,从锁定其他线程的概率更高的意义上讲,写锁的成本更高?代码想要读取它,并且基于内容,它可能稍后决定要编写它。

问题不在于执行。库的开发人员决定了这个规则,并不是因为他们喜欢这样,也不是因为他们知道并行算法和死锁的可能性。当两个线程有readLock并且每个线程都决定将锁升级到writeLock时,它们本质上会创建死锁。每个人都会在等待writeLock的时候拿着readLock,没有人会得到它。

另一方面,您可以将writeLock降级为readLock,而无需冒风险,同时,有人获得writeLock并修改资源。

原子变量

原子类将原始类型值封装到对象中,并对其提供原子操作。我们讨论了竞争条件和可变变量。例如,如果我们有一个int变量用作计数器,并且我们想为我们处理的对象分配一个唯一的值,我们可以增加该值并将结果用作唯一的 ID。但是,当多个线程使用同一代码时,我们不能确定在增加后读取的值。同时,另一个线程也可能增加该值。为了避免这种情况,我们必须将增量括起来,并将增量值赋给synchronized块中的对象。这也可以使用AtomicInteger来完成。

如果我们有一个变量AtomicInteger,那么调用incrementAndGet会增加类中包含的int的值,并返回增加的值。为什么不使用同步块而使用它呢?第一个答案是,如果功能在 JDK 中,那么使用它会比再次实现它产生更少的代码行。维护您创建的代码的开发人员应该了解 JDK 库。另一方面,为他们学习代码需要时间,时间就是金钱。

另一个原因是,这些类经过了高度优化,而且它们通常使用特定于平台的本机代码来实现特性,这大大优于我们可以使用同步块实现的版本。过早地担心性能是不好的,但是当性能至关重要时,通常使用并行算法和线程之间的同步;因此,使用原子类的代码的性能很有可能是重要的。尽管如此,主要原因仍然是可读性和简单性。

java.util.concurrent.atomic包中有AtomicIntegerAtomicBooleanAtomicLongAtomicReference等几种类别。它们都提供了特定于封装值的方法。

compareAndSet()方法由每个原子类实现。这是具有以下格式的条件值设置操作:

boolean compareAndSet(expectedValue, updateValue);

当它应用于一个原子类时,它将实际值与一个expectedValue进行比较,如果它们相同,则将值设置为updateValue。如果值被更新,方法返回true,并在原子操作中完成所有这一切。不用说,如果条件不成立并且没有执行更新,则返回值为false

你可能会问这样一个问题:如果这个方法在所有这些类中,为什么没有Interface定义这个方法?原因是参数类型根据封装的类型不同而不同,这些类型是原始类型。由于原始类型还不能用作泛型类型,因此无法定义接口。

AtomicXXXArray的情况下,方法有一个额外的第一个参数,它是调用中处理的数组元素的索引。

就运行在不同处理器内核上的多个线程的重新排序和访问而言,封装的变量的处理方式与volatile相同。原子类的实际实现可能使用特殊的硬件代码,这些代码可以提供比 Java 中的原始实现更好的性能,因此原子类可能比使用易失性变量和同步块的普通 Java 代码中实现的相同功能具有更好的性能。

一般的建议是,如果有可用的原子类,可以考虑使用原子类,您将发现自己正在为检查和设置、原子增量或加法操作创建一个同步块。

BlockingQueue

BlockingQueue是一个用适合多线程应用使用的方法扩展标准Queue接口的接口。此接口的任何实现都提供了允许不同线程将元素放入队列、从队列中拉出元素并等待队列中的元素的方法。

当队列中要存储新元素时,您可以add()它、offer()它或put()它。这些是存储元素的方法的名称,它们做同样的事情,只是有点不同。如果队列已满且元素没有空间,add()方法抛出异常。offer()方法不抛出异常,而是根据操作是否成功返回truefalse。如果可以将元素存储在队列中,则返回true。还有一个版本的offer()指定超时。如果在此期间无法将值存储在队列中,则该版本的方法将等待并仅返回falseput()方法是最简单的版本;它会等到它能完成它的工作。

当谈到队列中的可用空间时,不要感到困惑,不要把它与一般的 Java 内存管理混淆起来。如果没有更多的内存,垃圾收集器也无法释放任何内存,您肯定会得到一个OutOfMemoryError。异常由add()抛出,当达到队列限制时false值由offer()返回。一些BlockingQueue实现可以限制可以同时存储在队列中的元素的数量。如果达到该限制,则队列已满,无法接受更多元素。

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() | not applicable | not applicable |

LinkedBlockingQueue

这是BlockingQueue接口的一个实现,它由一个链表备份。默认情况下,队列的大小不受限制(准确地说,它是Integer.MAX_VALUE),但是可以选择在构造器参数中进行限制。在这个实现中限制大小的原因是,当并行算法在有限大小的队列中执行得更好时,可以帮助使用。实现本身对大小没有任何限制,只有Integer.MAX_VALUE比较大。

LinkedBlockingDeque

这是BlockingQueue及其BlockingDeque子接口的最简单实现,如前一章所述,Deque是一个双端队列,具有addremoveoffer等方法类型,以xxxFirstxxxLast的形式与队列的一端或另一端执行动作。Deque接口定义了getFirstgetLast,而不是一致地命名elementFirstelementLast,所以这是你应该习惯的。毕竟,IDE 有助于自动补全代码,所以这应该不是什么大问题。

ArrayBlockingQueue

ArrayBlockingQueue实现BlockingQueue接口,因此实现Queue接口。此实现管理具有固定大小元素的队列。实现中的存储是一个数组,元素以先进先出的方式进行处理。这是一个类,我们也将在“策划”的并行实现中使用,用于老板和下属官僚之间的沟通。

LinkedTransferQueue

TransferQueue接口正在扩展BlockingQueue,在 JDK 中它的唯一实现是LinkedTransferQueue。当一个线程想要将一些数据移交给另一个线程,并且需要确保另一个线程接受元素时,TransferQueue就很有用了。这个TransferQueue有一个transfer()方法,它将一个元素放在队列中,但是直到其他线程调用remove()之后才返回,从而删除它(或者调用poll(),从而轮询它)。这样,生产线程就可以确保放入队列的对象在另一个处理线程手中,而不是在队列中等待。transfer()方法还有一种格式tryTransfer(),您可以在其中指定超时值。如果方法超时,则元素不会放入队列。

IntervalGuesser

我们讨论了可用于实现并行算法的不同 Java 语言元素和 JDK 类。现在,我们将看到如何使用这些方法来实现主谋游戏的并行猜测器。

在我们开始之前,我必须承认这个任务不是一个典型的并行编程教程任务。讨论并发编程技术的教程倾向于选择易于使用并行代码解决且可扩展性好的问题作为示例。如果在N处理器上运行的并行算法实际运行的速度是非并行解的N倍,那么问题就可以很好地扩展。我个人的看法是,这些例子描绘的天空蓝色没有风暴云。然而,当你面对现实生活中的并发编程时,那些云彩就在那里,你会看到雷声和闪电,如果你没有经验,你会大惊小怪的。

现实生活中的问题往往规模不理想。我们已经访问了一个扩展性很好的示例,尽管它不是理想的快速排序。这一次,我们将为更接近现实问题的问题开发一个并行算法。在N个处理器上解算 Mastermind 游戏不会使解算速度提高N倍,而且代码也不平凡。这个例子将向您展示现实生活中的问题是什么样子的,尽管它不会教您所有可能的问题,但是当您在商业环境中第一次看到其中一个问题时,您不会感到震惊。

这个解决方案中最重要的类之一是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() {
        Thread.currentThread()
            .setName("guesser [" + start + "," + end + "]");
        var guess = guess();
        try {
            while (guess != Guess.none) {
                guessQueue.put(guess);
                guess = guess();
            }
        } catch (InterruptedException ignored) {
        }
    }

    @Override
    protected Guess nextGuess() {
        var 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() {
    final var table = new Table(NR_COLUMNS, colorManager);
    final var secret = new RandomSecret(colorManager);
    final var secretGuess = secret.createSecret(NR_COLUMNS);
    final var game = new Game(table, secretGuess);
    final var guessers = createGuessers(table);
    final var finalCheckGuesser = new UniqueGuesser(table);
    startAsynchronousGuessers(guessers);
    try {
        while (!game.isFinished()) {
            final var guess = guessQueue.take();
            if (finalCheckGuesser.guessMatch(guess)) {
                game.addNewGuess(guess);
            }
        }
    } catch (InterruptedException ie) {

    } finally {
        stopAsynchronousGuessers(guessers);
    }
}

此方法创建一个Table、一个以随机方式创建用作秘密的猜测的RandomSecret、一个Game对象、IntervalGuesser对象和一个UniqueGuesser

IntervalGuesser对象是官僚;UniqueGuesser对象是老板,他交叉检查IntervalGuesser对象产生的猜测。我们用一个单独的方法创建区间猜测器,createGuessers()

private IntervalGuesser[] createGuessers(Table table) {
    final var colors = new Color[NR_COLUMNS];
    var start = firstIntervalStart(colors);
    final IntervalGuesser[] guessers = new IntervalGuesser[nrThreads];
    for (int i = 0; i < nrThreads - 1; i++) {
        Guess end = nextIntervalStart(colors);
        guessers[i] = new IntervalGuesser(table, start, end, guessQueue);
        start = end;
    }
    guessers[nrThreads - 1] = new IntervalGuesser(table, start, Guess.none, guessQueue);
    return guessers;
}

private Guess firstIntervalStart(Color[] colors) {
    for (int i = 0; i < colors.length; i++) {
        colors[i] = colorManager.firstColor();
    }
    return new Guess(colors);
}

private Guess nextIntervalStart(Color[] colors) {
    final int index = colors.length - 1;
    int step = NR_COLORS / nrThreads;
    if (step == 0) {
        step = 1;
    }
    while (step > 0) {
        if (colorManager.thereIsNextColor(colors[index])) {
            colors[index] = colorManager.nextColor(colors[index]);
            step--;
        } else {
            return Guess.none;
        }
    }
    Guess guess = new Guess(colors);
    while (!guess.isUnique()) {
        guess = guess.nextGuess(colorManager);
    }
    return guess;
}

间隔猜测器的创建方式是,每种颜色都有其独特的颜色变化范围,因此,它们一起涵盖了所有可能的颜色猜测。firstIntervalStart()方法返回在所有位置包含第一个颜色的猜测。nextIntervalStart()方法返回开始下一个范围的颜色集,推进颜色,以便每个猜测者在结束时有相同数量的猜测要检查(加或减一)。

startAsynchronousGuessers()方法启动异步猜测器,然后从它们那里读取循环中的猜测,如果它们正常的话,就把它们放在桌子上,直到游戏结束。在方法的末尾,在finally块中,异步猜测器停止。

异步猜测器的启动和停止方法采用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<>());
}

代码非常简单。唯一需要解释的是drainTo()电话。这个方法将工作线程仍然拥有的未使用的猜测排出到一个我们立即丢弃的链表中(我们不保留对它的任何引用)。这是必要的,以帮助任何IntervalGuesser,这可能是等待与建议猜测在手,试图把它放入队列。当我们排空队列时,猜测线程从IntervalGuesserguessQueue.put(guess);行的put()方法返回,并可以捕获中断。代码的其余部分不包含任何与我们已经看到的完全不同的内容。

在本章中,我们仍然要讨论的最后一个问题是,通过使代码并行,我们获得了多少时间?

微基准

微基准是衡量一个小代码片段的性能。当我们想要优化我们的代码时,我们必须对它进行度量。没有度量,代码优化就像蒙着眼睛射击。你不会击中目标,但很可能会射杀其他人。

射击是一个很好的比喻,因为你通常不应该这样做,但当你真的必须这样做,那么你就别无选择。如果没有性能问题,并且软件满足要求,那么任何优化,包括速度测量,都是浪费金钱。这并不意味着鼓励您编写慢而草率的代码。当我们衡量性能时,我们会将其与需求进行比较,而需求通常在用户级别,类似于“应用的响应时间应该少于 2 秒”。为了进行这样的度量,我们通常在一个测试环境中创建负载测试,并使用不同的分析工具,以防度量的性能不令人满意,这些工具告诉我们什么是最耗时的,以及我们应该在哪里进行优化。很多时候,不仅仅是 Java 代码,还有配置优化,使用更大的数据库连接池、更多的内存等等。

微基准是另一回事。它是关于一个小的 Java 代码片段的性能,因此更接近于 Java 编程。

它很少使用,在开始为实际商业环境执行微基准之前,我们必须三思而后行。MicroBenchmark 是一个诱人的工具,可以在不知道是否值得优化代码的情况下优化一些小东西。当我们有一个在多个服务器上运行多个模块的大型应用时,我们如何确保改进应用的某个特殊部分能够显著提高性能?它是否会回报增加的收入,产生如此多的利润,以弥补性能测试和开发中产生的成本?从统计学上讲,你几乎可以肯定,这样的优化,包括微基准,不会有回报。

我曾经维护过一位资深同事的密码。他创建了一个高度优化的代码来识别文件中存在的配置关键字。他创建了一个程序结构,它表示基于键字符串中的字符的决策树。如果配置文件中有一个关键字拼写错误,代码会在第一个字符处抛出异常,从而确定关键字不正确。要插入一个新的关键字,它需要通过代码结构来找到新关键字最初与已有关键字不同的地方,并扩展深度嵌套的if/else结构。阅读关键字列表处理是可能的,从注释中列出了所有的关键字,他没有忘记文件。代码运行速度惊人,可能节省了 Servlet 应用几毫秒的启动时间。应用仅在每隔几天进行一次系统维护之后才启动几个月。你呢感受一下讽刺吧?资历并不总是年数。那些更幸运的人可以拯救他们内心的孩子。

那么,什么时候应该使用微基准呢?我可以看到两个方面:

  • 您已经确定了消耗应用中大部分资源的代码段,可以通过微基准测试改进
  • 您无法识别将消耗应用中大部分资源的代码段,但您可能会怀疑它

第一种是通常情况。第二种情况是,当您开发一个库时,您并不知道将使用它的所有应用。在这种情况下,您将尝试优化您认为对大多数想象中的可疑应用最关键的部分。即使在这种情况下,最好还是采集一些由库用户创建的示例应用,并收集一些有关使用情况的统计信息。

为什么我们要详细讨论微基准?陷阱是什么?基准测试是一个实验。我写的第一个程序是一个 TI 计算器代码,我只需计算程序将两个大素数(当时 10 位是大素数)分解的步数。即使在那个时候,我也在用一块老式的俄罗斯机械秒表测量时间,懒得计算步数。实验和测量更容易。

现在,即使您想手动计算 CPU 的步数,也无法手动计算。有太多的小因素可能会改变程序员无法控制的应用的性能,这使得计算步骤变得不可能。我们还有度量,我们将获得与度量相关的所有问题。

最大的问题是什么?我们对某物感兴趣,比如说X,我们通常无法测量它。因此,我们将测量Y,并希望YX的值耦合在一起。我们想测量房间的长度,但我们测量的是激光束从一端传输到另一端所需的时间。在这种情况下,长度,X和时间,Y是强耦合的。很多时候,XY只是或多或少的相关。大多数情况下,当一个人进行测量时,XY值根本没有关系。尽管如此,人们还是把自己的房子,甚至更多的钱,押在有这些衡量标准支持的决策上。

微基准也不例外。第一个问题是,我们如何衡量执行时间?小代码运行的时间很短,System.currentTimeMillis()可能只是在测量开始和结束时返回相同的值,因为我们仍然在同一毫秒内。即使执行时间为 10ms,测量误差仍至少为 10%,这纯粹是因为我们测量的时间被量化了。幸运的是,有System.nanoTime()。但是有吗?仅仅因为它的名字说它从一个特定的开始时间返回纳秒数并不一定意味着它真的可以。

这在很大程度上取决于硬件和方法在 JDK 中的实现。它被称为纳米,因为这是我们无法达到的精度。如果是微秒,那么一些实现可能会受到定义的限制,即使在特定的硬件上有更精确的时钟。然而,这不仅关系到可用硬件时钟的精度水平,还关系到硬件的精度。

让我们记住官僚们的心跳,以及从记忆中读东西所需要的时间。打电话给一个方法,比如System.nanoTime(),,就像让酒店的行李员从二楼跑到大堂,往外看一眼路对面塔楼上的钟,回来,准确地告诉我们询问的时间。胡说。我们应该知道塔台上的钟的精确度,以及行李员从地板到大堂和大厅的速度。这不仅仅是打电话给System.nanoTime()。这就是微型标记装置为我们所做的。

Java 微基准线束JMH)作为库提供了一段时间。它是由 Oracle 开发的,用于调整几个核心 JDK 类的性能。这对那些为新硬件开发 Java 平台的人来说是个好消息,但对开发人员来说也是个好消息,因为这意味着 JMH 现在和将来都会受到 Oracle 的支持。

“JMH 是一个 Java 工具,用于构建、运行和分析以 Java 编写的 nano/micro/mili/macro 基准,以及其他针对 JVM 的语言。”

(引自 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
}

MicroBenchmark类如下:

public class MicroBenchmark {

    public static void main(String... args)
        throws RunnerException {
        var opt = new OptionsBuilder()
            .include(MicroBenchmark.class.getSimpleName())
            .forks(1)
            .build();

        new Runner(opt).run();
    }

    @Benchmark
    @Fork(1)
    public void playParallel(ThreadsAndQueueSizes t3qs) {
        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();
    }

    @State(Scope.Benchmark)
    public static class ThreadsAndQueueSizes {
        @Param(value = {"1", "4", "8"})
        String nrThreads;
        @Param(value = {"-1", "1", "10", "100", "1000000"})
        String queueSize;
    }
}

创建ParallelGamePlayer是为了用 -1、1、4 和 8IntervalGuesser线程玩游戏,在每种情况下,都有一个测试运行,队列长度分别为 1、10、100 和 100 万。这是 16 个测试执行。当线程数为负数时,构造器使用LinkedBlockingDeque。还有另一个单独的测量方法来测量非并行玩家。测试是用独特的猜测和秘密(没有颜色使用超过一次)、十种颜色和六列来执行的。

当线束启动时,它会自动执行所有校准,并运行多次迭代的测试,以让 JVM 启动。您可能会想起从未停止过的代码,除非我们对用于向代码发出停止信号的变量使用了volatile修饰符。这是因为 JIT 编译器优化了代码。只有当代码已经运行了几千次时,才会这样做。线束执行这些执行是为了预热代码,并确保在 JVM 已经全速运行时完成测量。

在我的机器上运行这个基准测试大约需要 15 分钟。在执行过程中,建议停止所有其他进程,并让基准使用所有可用资源。如果在测量过程中有任何使用资源的情况,则会反映在结果中:

Benchmark      (nrThreads) (queueSize)  Score    Error
playParallel            1         -1   15,636  ± 1,905
playParallel            1          1   15,316  ± 1,237
playParallel            1         10   15,425  ± 1,673
playParallel            1        100   16,580  ± 1,133
playParallel            1    1000000   15,035  ± 1,148
playParallel            4         -1   25,945  ± 0,939
playParallel            4          1   25,559  ± 1,250
playParallel            4         10   25,034  ± 1,414
playParallel            4        100   24,971  ± 1,010
playParallel            4    1000000   20,584  ± 0,655
playParallel            8         -1   24,713  ± 0,687
playParallel            8          1   24,265  ± 1,022
playParallel            8         10   24,475  ± 1,137
playParallel            8        100   24,514  ± 0,836
playParallel            8    1000000   16,595  ± 0,739
playSimple            N/A       N/A   18,613   ± 2,040

程序的实际输出要详细一些;它是为了打印而编辑的。Score列显示了基准测试在一秒钟内可以运行多少次。Error列显示测量值的散射小于 10%。

我们拥有的最快性能是算法在 8 个线程上运行时,这是处理器在我的机器上可以独立处理的线程数。有趣的是,限制队列的大小并没有提高性能。我真的以为会不一样。使用一个一百万长度的数组作为阻塞队列有着巨大的开销,在这种情况下,执行速度比队列中只有 100 个元素时要慢也就不足为奇了。另一方面,具有无限链接的基于列表的队列处理速度相当快,并且清楚地表明,对于 100 个元素的有限队列,额外的速度并不是因为限制防止了IntervalThreads跑得太远。

当我们启动一个线程时,我们期望得到与运行串行算法时类似的结果。串行算法胜过在一个线程上运行的并行算法这一事实并不奇怪。线程的创建以及主线程和额外的单线程之间的通信都有开销。开销很大,特别是当队列不必要的大时。

总结

在这一章中,我们学到了很多东西。首先,我们重构了代码,为使用并行猜测的进一步开发做好准备。我们熟悉了进程和线程,甚至还提到了纤程。之后,我们研究了 Java 如何实现线程以及如何创建在多个线程上运行的代码。此外,我们还看到了 Java 为需要并行程序、启动线程或只是在现有线程中启动任务的程序员提供的不同方法。

也许这一章最重要的部分你应该记住的是官僚和不同速度的隐喻。当您想了解并发应用的性能时,这一点非常重要。我希望这是一幅引人入胜的图画,一幅容易记住的图画。

关于 Java 提供的不同同步方式有一个很大的话题,您还了解了程序员在编写并发应用时可能遇到的陷阱。

最后,但并非最不重要的是,我们创建了 Mastermind 猜测器的并发版本,并且还测量了它确实比只使用一个处理器的版本(至少在我的机器上)要快。我们在 Gradle 构建工具中使用了 JavaMicroBenchmark 工具,并讨论了如何执行微基准。

这是一个漫长的章节,并不容易。我可能倾向于认为这是最复杂和理论的一章。如果你一开始就理解了一半,你会感到骄傲的。另一方面,请注意,这仅仅是一个坚实的基础,可以从中开始试验并发编程,在被公认为该领域的经验丰富和专业人士之前,还有很长的路要走。而且,这一章也不容易。但是,首先,在这一章的结尾,要为自己感到骄傲。

在接下来的章节中,我们将学习更多关于 Web 和 Web 编程的知识。在下一章中,我们将开发我们的小游戏,这样它就可以在服务器上运行,玩家可以使用 Web 浏览器玩它。这将为网络编程奠定基础知识。稍后,我们将在此基础上开发基于 Web 的服务应用、反应式编程,以及使您成为专业 Java 开发人员的所有工具和领域。

六、使我们的游戏专业化-将其作为 Web 应用

在本章中,我们将编写一个 Web 应用。我们将建立在我们已经取得的成就和创造一个网络版的策划游戏。这一次,它不仅会单独运行,猜测并回答位置的个数和匹配的颜色,还会与用户进行交流,询问猜测的答案。这将是一个真正的游戏,你可以玩。Web 编程对于 Java 开发人员来说非常重要。大多数程序都是 Web 应用。互联网上可用的通用客户端是 Web 浏览器。瘦客户端、基于 Web 浏览器的架构也被企业广泛接受。当架构与 Web 客户端不同时,只有少数例外。如果你想成为一名专业的 Java 开发人员,你必须熟悉 Web 编程。而且也很有趣!

在开发过程中,我们将访问许多技术主题。首先,我们将讨论网络和 Web 架构。这是整栋楼的混凝土底座。它不是太性感,就像你建造一座建筑物。你花了很多钱和精力挖壕沟,然后你埋了混凝土,最后,在这个阶段结束时,你似乎有平坦的地面之前,除了有基础。如果没有这个基础,房子可能会在建造后不久或建造过程中倒塌。网络对于网络编程同样重要。有很多话题似乎与编程无关。尽管如此,它仍然是构建的基础,当您编写 Web 应用时,您还将发现它的有趣之处。

我们还将讨论一些 HTML、CSS 和 JavaScript,但不会太多。我们无法避免它们,因为它们对 Web 编程也很重要,但它们也是您可以从其他地方学习的主题。如果您不是这些领域的专家,那么企业项目团队中通常还有其他专家可以扩展您的知识。除此之外,JavaScript 是一个如此复杂和庞大的主题,它值得一本完整的书作为开始。只有极少数的专家对 Java 和 JavaScript 都有深刻的理解。我了解该语言的总体结构和运行环境,但我无法跟上这些天每周发布的新框架,就像我关注其他领域一样。

您将学习如何创建在应用服务器上运行的 Java 应用,这次是在 Jetty 中,我们将看到 Servlet 是什么。为了快速启动,我们将创建一个 HelloWorld Web 应用。然后,我们将创建 Mastermind 的 Servlet 版本。请注意,如果没有一个框架的帮助,我们几乎不会直接编写 Servlet,这个框架实现了处理参数、认证和许多其他非特定于应用的事情的代码。在本章中,我们仍将坚持使用裸 Servlet,因为如果不首先了解 Servlet 是什么,就不可能有效地使用 Spring 之类的框架。要成为一名工程师,你必须先把手弄脏。Spring 将在下一章到来。

我们将提到 JavaServer PagesJSP),只是因为您可能会遇到一些遗留应用,这些应用是使用该技术开发的,但是现代 Web 应用不使用 JSP。尽管如此,JSP 还是 Servlet 标准的一部分,可以使用。还有其他一些技术是在最近的过去发展起来的,但现在似乎还不能证明未来。它们仍然可用,但只出现在遗留应用中,选择它们用于新项目是相当值得怀疑的。我们将在单独的一节中讨论这些技术。

在本章结束时,您将了解基本的 Web 技术是如何工作的以及主要的架构元素是什么,并且您将能够创建简单的 Web 应用。这还不足以成为一名专业的 Java Web 开发人员,但将为下一章打下良好的基础,在下一章中,我们将了解当今企业中用于实际应用开发的专业框架。

Web 和网络

程序在计算机上运行,计算机连接到互联网。这个网络是在过去的 60 年里发展起来的,最初是为了提供能够抵御火箭攻击的军事数据通信,后来被扩展为学术网络,后来成为任何人都可以使用的商业网络,几乎遍布世界各地。

该网络的设计和研究始于 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)。虽然作为一名 Java 开发人员,您很可能会遇到 IP 路由问题,但您肯定会面临 TCP 编程。因此,我们将简要介绍 TCP 的工作原理。请注意,这是非常简短的。真正地。在阅读下一节内容时,您不会成为 TCP 专家,但您将看到影响 Web 编程的最重要问题。

TCP/IP 协议

TCP 协议是在操作系统中实现的,它提供了比 IP 更高级别的接口。编写 TCP 时,不处理数据报。相反,您有一个字节流通道,您可以将要传递到另一台计算机的字节放入其中,并且可以从另一台计算机发送的通道中读取字节,完全按照它们发送的顺序。这是两台计算机之间的连接,更重要的是,两个程序之间的连接。

还有其他协议是通过 IP 实现的,并且不是面向连接的。其中一个是用户数据报协议UDP)。当不需要连接时,它用于服务。它还用于数据可能丢失时,并且数据及时到达目的地比不丢失任何数据包(视频流、电话)更重要。该协议的另一个应用是当数据量较小且丢失时可以再次请求;再次请求的成本比使用更复杂的 TCP 协议要便宜。最后一种使用的典型示例是 DNS 请求,我们将在下一节中详细介绍。

在操作系统中实现的 TCP 软件层处理复杂的数据包处理。重新发送丢失的包、重新排序以不同于最初预期的顺序到达的包,以及删除可能多次到达的额外包,都是由该层自动补全的。这一层通常被称为 TCP 栈

由于 TCP 是一个连接协议,所以需要告诉 TCP 栈当数据报到达时属于哪个流。流由两个端口标识。端口是 16 位整数。一个程序标识启动连接的程序,称为源端口。另一个程序标识目标程序目标端口。这些包含在每个和每个传输的 TCP 包中。当机器运行安全外壳SSH)服务器和 Web 服务器时,这些应用使用不同的端口。这些端口通常为2280。当 TCP 头中包含目标端口号22的包出现时,TCP 栈知道数据包中的数据属于 SSH 服务器处理的流。同样,如果目标端口为80,则数据将被发送到 Web 服务器。

在编写服务器程序时,通常必须定义端口号;否则,客户端将找不到服务器程序。Web 服务器通常监听端口80,客户端尝试连接到该端口。客户端端口通常不重要,也不指定;它由 TCP 栈自动分配。

从客户端代码连接到服务器很容易,这只需要几行代码。有时,它只是一行代码。然而,在后台,TCP 栈做了很多我们应该关心的工作,因为建立 TCP 连接需要时间,而且它会极大地影响应用的性能。

为了建立连接,TCP 栈向目的地发送一个数据报。这还不足以建立连接,但这是建立连接的第一步。这个包是空的,它的名字是 SYN。发送此数据包后,客户端开始等待服务器应答。如果没有服务器,或者服务器太忙而无法应答,或者由于任何原因无法向该特定客户端提供应答,那么发送任何进一步的包都将是网络流量浪费。

当服务器接收到 SYN 包时,它会用 SYN-ACK 包进行回复。最后,在接收到 SYN-ACK 包之后,客户端发送一个名为 ACK 的包。如果数据包通过大西洋,每个数据包大约需要 45 毫秒,相当于 4500 万秒的官僚时间。这差不多是一年半了。我们需要其中三个来建立连接,这只是连接的建立;到目前为止,我们还没有发送任何数据。

当建立 TCP 连接时,客户端不会在没有自我控制的情况下开始发送数据。它只发送几个包,然后等待查看发生了什么。如果包到达并且服务器承认这些包,则发送更多,一旦看到连接和服务器能够接受更大的包量,则会增加此卷。发送服务器未准备好、无法处理的数据,不仅无用,而且会浪费网络资源。TCP 是为了优化网络使用率而设计的。客户端发送一些数据,然后等待确认。TCP 栈自动管理此操作。如果确认到达,它会发送更多的数据包。如果精心设计的优化算法,在 TCP 栈中实现,认为发送更多是好的,那么它发送的数据比第一步多一些。如果有负面的确认告诉客户端服务器无法接受某些数据,并且必须将其丢弃,那么客户端将减少它在没有确认的情况下发送的数据包数。但首先,它开始缓慢谨慎。

这就是所谓的 TCP 慢启动,我们必须意识到这一点。尽管这是一个低级的网络特性,但它会产生一些后果,我们必须在 Java 代码中考虑到这一点:我们使用数据库连接池,而不是在每次需要一些数据时创建到数据库的新连接;我们尝试尽可能少地连接到 Web 服务器,使用 keep-aliveSPDY 协议或 http/2 等技术(也代替 SPDY)。

就目前而言,TCP 是面向连接的,即建立到服务器的连接,发送和接收字节,最后关闭连接就足够了。当您遇到网络性能问题时,您必须查看我之前详述的问题(并询问网络专家)。

DNS

TCP 协议使用机器的 IP 地址创建一个通道。在浏览器中键入 URL 时,它通常不包含 IP 号码。它包含机器名。使用名为域名系统DNS)的分布式数据库将名称转换为 IP 号码。这个数据库是分布式的,当一个程序需要将一个名称转换成一个地址时,它会将一个 DNS 请求发送到它所知道的一个 DNS 服务器。这些服务器相互发送查询,或者告诉客户端询问谁,直到客户端知道分配给该名称的 IP 地址。服务器和客户端还缓存最近请求的名称,因此应答很快。另一方面,当服务器的 IP 地址更改这个名称时,并不是所有的客户端都能立即在全球范围内看到地址分配。DNS 查找可以很容易地编程,JDK 中有一些类和方法支持这一点,但是通常,我们不需要担心这一点;当我们编程时,它是在 Web 编程中自动补全的。

HTTP 协议

超文本传输协议HTTP)建立在 TCP 之上。在浏览器中键入 URL 时,浏览器会打开一个到服务器的 TCP 通道(当然,在 DNS 查找之后),并向 Web 服务器发送一个 HTTP 请求。服务器在接收到请求后,生成一个响应并将其发送给客户端。之后,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 文本。

实际请求发送到这个页面,定义 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 协议

自上次发布 HTTP 以来,经过近 20 年的时间,最新版本的 HTTP 于 2015 年发布。这个新版本的协议与以前的版本相比有一些增强。其中一些增强也会影响服务器应用的开发方式。

第一个也是最重要的增强是,新协议将使在单个 TCP 连接中并行发送多个资源成为可能。Keep-Alive标志已经可以用来避免重新创建 TCP 通道,但是当响应创建缓慢时,它没有帮助。在新协议中,其他资源也可以在同一个 TCP 通道中传递,甚至在请求得到完全服务之前。这需要协议中复杂的包处理。这对服务器程序员和浏览器程序员都是隐藏的。应用服务器、Servlet 容器和浏览器透明地实现了这一点。

HTTP/2 将始终加密。因此,在浏览器 URL 中不可能使用http作为协议。永远是https

需要更改 Servlet 编程以利用新版本协议的优势的特性是服务器推送。Servlet 规范的 4.0 版本包括对 HTTP/2 的支持。规范可从这个页面获得。

服务器推送是对将来将出现的请求的 HTTP 响应。服务器如何回答一个甚至没有发出的请求?好吧,服务器已经预料到了。例如,应用发送一个 HTML 页面,其中引用了许多小图片和图标。客户端下载 HTML 页面,构建 DOM 结构,进行分析,实现所需图片,并发送图片请求。程序员知道那里有什么图片,甚至在浏览器请求图片之前就可以编写代码让服务器发送这些图片。每一个这种性质的响应都包含一个该响应所针对的 URL。当浏览器需要资源时,它会意识到资源已经存在,并且不会发出新的请求。在HttpServlet中,程序应该通过请求的新getPushBuilder()方法访问PushBuilder,并使用该方法将资源下推到客户端。

Cookie

Cookie 由浏览器维护,并通过使用Cookie头字段在 HTTP 请求头中发送。每个 Cookie 都有一个名称、值、域、路径、过期时间和一些其他参数。当请求被发送到与域(未过期 Cookie 的路径)匹配的 URL 时,客户端将 Cookie 发送到服务器。Cookies 通常通过浏览器存储在客户端的小文件中,或者存储在本地数据库中。实际的实现是浏览器的业务,我们不必担心。它只是文本信息,而不是由客户端执行。只有当某些规则(主要是域和路径)匹配时,才会将其发送回服务器。Cookie 由服务器创建,并使用Set-Cookie头字段在 HTTP 响应中发送给客户端。因此,本质上,服务器告诉客户端,“嘿,这是 Cookie,下次你来找我时,给我看这段信息,这样我就知道是你了”。Cookies 也可以通过 JavaScript 客户端代码创建。但是,由于 JavaScript 代码也来自服务器,因此这些 Cookie 也可以被视为来自服务器。

Cookies 通常是用来记住客户的。广告商和在线商店需要记住他们在和谁交谈,他们大量使用它。但这不是唯一的用途。现在,任何维护用户会话的应用都使用 Cookie 来链接来自同一用户的 HTTP 请求。当您登录到应用时,用于标识自己的用户名和密码只发送到服务器一次,并且在随后的请求中,只向服务器发送一个特殊的 Cookie 来标识已登录的用户。Cookie 的这种用法强调了为什么使用不容易猜测的 Cookie 值很重要。如果用来识别用户的 Cookie 很容易猜测,那么攻击者就可以创建一个 Cookie 并模仿其他用户将其发送到服务器。为此,Cookie 值通常是长的随机字符串。

Cookie 并不总是发送回它们发源的服务器。发送 Cookie 时,服务器指定应将 Cookie 发送回的 URL 的域。当与提供需要认证的服务的服务器不同的服务器执行用户认证时,将使用此选项。

应用有时将值编码到 Cookie 中。这并不一定是坏的,尽管在大多数实际情况下,它是坏的。在将某些内容编码到 Cookie 中时,我们应该始终考虑 Cookie 在网络中传播的事实。随着越来越多的数据被编码到 Cookie 中,带有编码数据的 Cookie 会变得越来越大。它们会给网络带来不必要的负担。通常,最好只发送一个唯一的、否则没有意义的随机键,并将值存储在数据库中,无论是磁盘上还是内存中。

客户端-服务器和 Web 架构

到目前为止,我们开发的应用运行在一个 JVM 上。我们已经有了一些并发编程的经验,这是一些现在会派上用场的东西。当我们编写一个 Web 应用时,一部分代码将在服务器上运行,一部分应用逻辑将在浏览器中执行。服务器部分将用 Java 编写,浏览器部分将用 HTML、CSS 和 JavaScript 实现。因为这是一本 Java 书籍,所以我们将主要关注服务器部分,但是我们仍然应该意识到这样一个事实:许多功能可以而且应该实现为在浏览器中运行。这两个程序通过 IP 网络(即互联网)或公司网络(如果是企业内部应用)相互通信。

如今,浏览器可以执行用 JavaScript 实现的强大应用。新的浏览器版本也支持 WebAssembly。这种技术在具有实时编译器的虚拟机中执行代码,就像 Java 虚拟机一样,因此,代码执行速度与本地应用一样快。在浏览器中运行的图形游戏已经有了展示安装。诸如 C、Rust 和 GO 之类的语言可以编译到 WebAssembly,我们可以预期其他语言也可以使用。这意味着浏览器的编程方法将被取代,越来越多的功能将在客户端应用中实现。这样,应用将变得越来越像传统的旧客户端-服务器应用,区别在于客户端将在浏览器的沙盒中运行,并且通信是 HTTP 协议。

几年前,这种应用需要客户端应用在 Delphi、C++ 或 Java 中实现,使用客户端操作系统的窗口能力。

最初,客户端-服务器架构意味着应用的功能是在客户端上实现的,程序只使用来自服务器的常规服务。服务器提供了数据库访问和文件存储,但仅此而已。后来,三层架构将业务功能放在使用其他服务器进行数据库和其他常规服务的服务器上,客户端应用实现了用户界面和有限的业务功能。

当 Web 技术开始渗透到企业计算时,Web 浏览器开始在许多用例中取代客户端应用。以前,浏览器不能运行复杂的 JavaScript 应用。应用在 Web 服务器上执行,客户端显示服务器创建的 HTML 作为应用逻辑的一部分。每次用户界面上发生更改时,浏览器都会启动与服务器的通信,并且在 HTTP 请求-响应对中,浏览器内容会被替换。Web 应用本质上是一系列表单填充和表单数据发送操作,服务器用 HTML 格式的页面进行响应,可能包含新表单。

JavaScript 解释器得到了发展,变得越来越有效和标准化。如今,现代 Web 应用包含 HTML(这是客户端代码的一部分,不是由服务器动态生成)、CSS 和 JavaScript。当代码从 Web 服务器下载时,JavaScript 开始执行并与服务器通信。它仍然是 HTTP 请求和响应,但是响应不包含 HTML 代码。它包含纯数据,通常是 JSON 格式。这些数据由 JavaScript 代码使用,一些数据(如果需要)显示在 Web 浏览器的显示屏上,也由 JavaScript 控制。这在功能上相当于三层架构,有几个很小但非常重要的区别。

第一个区别是,客户端上没有安装代码。客户端从 Web 服务器下载应用,唯一安装的是现代浏览器。这就消除了许多企业维护负担和成本。

第二个区别是客户端不能访问客户端机器的资源,或者只有有限的访问权限。厚客户端应用可以将任何内容保存在本地文件中或访问本地数据库。对于浏览器应用,出于安全原因,这是非常有限的。同时,这是一个方便的限制,因为客户端不是,也不应该是架构的可信部分。客户端计算机中的磁盘备份成本很高。它可以用笔记本偷走,加密是昂贵的。有一些工具可以保护客户端存储,但大多数情况下,仅将数据存储在服务器上是一种更可行的解决方案。

信任客户端应用也是常见的程序设计错误。客户端在物理上控制客户端计算机,尽管这在技术上非常困难,但是客户端仍然可以克服客户端设备和客户端代码的安全限制。如果只有客户端应用检查某些功能或数据的有效性,则不使用服务器的物理控件提供的物理安全性。每当数据从客户端发送到服务器时,无论客户端应用是什么,都必须检查数据的有效性。实际上,由于客户端应用是可以更改的,我们只是不知道客户端应用到底是什么。

在本章中,事实上,在本书中,我们主要关注 Java 技术;因此,示例应用几乎不包含任何客户端技术。我忍不住创建了一些 CSS。另一方面,我绝对避免使用 JavaScript。因此,我必须再次强调,这个示例旨在演示服务器端的编程,并且仍然提供一些真正有效的东西。现代应用将使用 REST 和 JSON 通信,不会在服务器端动态创建 HTML。最初,我想创建一个 JavaScript 客户端和 REST 服务器应用,但是重点从服务器端 Java 编程转移了太多,所以我放弃了这个想法。另一方面,您可以将应用扩展为这样的应用。

编写 Servlet

Servlet 是在实现 Servlet 容器环境的 Web 服务器中执行的 Java 类。最初的 Web 服务器只能向浏览器提供静态 HTML 文件。对于每个 URL,Web 服务器上都有一个 HTML 页面,服务器根据浏览器发送的请求传递该文件的内容。很快,就需要扩展 Web 服务器,以便能够启动一个程序,在处理请求时动态地计算响应的内容。

第一个这样做的标准是定义的公共网关接口CGI)。它启动了一个新的进程来响应请求。新进程获得了对其标准输入的请求,并将标准输出发送回客户端。这种方法浪费了大量资源。正如您在上一章中了解到的那样,启动一个新的进程对于响应一个 HTTP 请求来说代价太高了。即使开始一个新的线程似乎是没有必要的,但有了它,我们就有点超前了。

下一种方法是 FastCGI,它不断地执行外部进程并重用它。FastCGI 后面的方法都使用进程中扩展。在这些情况下,计算响应的代码运行在与 Web 服务器相同的进程中。这些标准或扩展接口是针对 Microsoft IIS 服务器的 ISAPI、Netscape 服务器的 NSASPI 和 Apache 模块接口。这些都使得在 Windows 上创建一个动态加载库(DLL),或在 Unix 系统上加载共享对象(SO),并映射这些库中实现的代码处理的某些请求。

例如,当有人编写 PHP 时,Apache 模块扩展就是 PHP 解释器,它读取 PHP 代码并对其执行操作。当有人为 NicrosoftIIS 编写 ASP 页面时,将执行实现 ASP 页面解释器的 ISAPI 扩展(好吧,这有点草率,说起来过于简单,但可以作为一个例子)。

对于 Java 来说,接口定义是 JSR369 中从 4.0 版开始定义的 Servlet。

JSR 代表 Java 规范请求。这些是对 Java 语言、库接口和其他组件的修改请求。这些请求经过一个评估过程,当它们被接受时,它们就成为一个标准。这个过程由 Java 社区流程(JCP)定义。JCP 也有文档记录,有不同的版本。当前版本为 2.10,可在这个页面找到。

Servlet 程序实现 Servlet 接口。通常,这会受到扩展HttpServlet类的影响,这个类是Servlet接口的抽象实现。这个抽象类实现了doGet()doPost()doPut()doDelete()doHead()doOption()doTrace()等方法,可以被扩展它的实际类自由覆盖。如果 Servlet 类没有覆盖其中一个方法,则发送相应的 HTTP 方法GETPOST等,将返回405 Not Allowed状态码。

HelloWorld Servlet

在进入技术细节之前,让我们创建一个非常简单的 HelloWorld 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: 'war'
apply from: 'https://raw.github.com/gretty-gradle-plugin/gretty/master/pluginScripts/gretty.plugin'

repositories {
    jcenter()
}
targetCompatibility = "1.10"
sourceCompatibility = "1.10"
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'
}

Gradle 构建文件使用两个插件,javagretty。我们已经在上一章中使用了java插件。gretty插件添加了appRun之类的任务,用于加载 Jetty Servlet 容器并启动应用。gretty插件还使用war插件,它将 Web 应用编译成 Web 归档(WAR)打包格式。

WAR 打包格式实际上与 JAR 相同;它是一个 zip 文件,包含一个包含 Web 应用所依赖的所有 JAR 文件的目录。应用的类在目录WEB-INF/classes中,有一个描述 Servlet URL 映射的WEB-INF/web.xml文件,我们将很快详细探讨这个文件。

因为我们想开发一个非常简单的 Servlet,所以我们将 Servlet API 作为依赖项添加到项目中。然而,这不是一种依赖关系。当 Servlet 在容器中运行时,API 可用。但是,它必须在编译器编译我们的代码时可用;因此,实现是由指定为providedCompile的工件提供的。因为是这样指定的,所以构建过程不会将库打包到生成的 WAR 文件中。生成的文件将不包含任何特定于 Jetty 或任何其他 Servlet 容器的内容。

Servlet 容器将提供 Servlet 库的实际实现。当应用在 Jetty 中部署和启动时,Servlet 库的 Jetty 特定实现将在类路径上可用。当应用部署到 Tomcat 时,特定于 Tomcat 的实现将可用。

我们在项目中创建了一个类,如下所示:

package packt.java11.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;

    public void init() throws ServletException {
        message = "Hello World";
    }

    public void doGet(HttpServletRequest request,
                      HttpServletResponse response)
            throws ServletException, IOException {
        response.setContentType("text/html");
        PrintWriter out = response.getWriter();
        out.println("<h1>" + message + "</h1>");
    }

    public void destroy() {
    }
}

当 Servlet 启动时,init方法被调用。当 Servlet 停止服务时,调用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 提供服务:

<web-app version="2.5" 
         xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
         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.java11.mastermind.servlet.HelloWorld</servlet-class>
    </servlet>
    <servlet>
        <display-name>Mastermind</display-name>
        <servlet-name>Mastermind</servlet-name>
        <servlet-class>packt.java11.mastermind.servlet.Mastermind</servlet-class>
    </servlet>

    <servlet-mapping>
        <servlet-name>HelloWorldServlet</servlet-name>
        <url-pattern>/hello</url-pattern>
    </servlet-mapping>
    <servlet-mapping>
        <servlet-name>Mastermind</servlet-name>
        <url-pattern>/master</url-pattern>
    </servlet-mapping>

</web-app>

Java 服务器页面

我答应过你,我不会让你厌烦 JavaServerPages(JSP),因为这是过去的技术。尽管它已经成为过去,但它仍然不是历史,因为仍有许多运行的程序使用 JSP。

JSP 页面是包含 HTML 和 Java 代码组合的 Web 页面。当 JSP 页面提供 HTTP 请求时,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>

前面的页面将创建一个 HTML 页面,其中包含五次文本hallo,每一次都在一个新行中,由标记br分隔。在幕后,Servlet 容器将 JSP 页转换为 JavaServlet,然后使用 Java 编译器编译 Servlet,然后运行 Servlet。每次对源 JSP 文件进行更改时,它都会这样做;因此,使用 JSP 以增量方式编写一些简单的代码非常容易。从前面的 JSP 文件生成的代码有 138 行长(在 tomcat8.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 包围。就像你要修补衣服一样:第一件事就是把衣服翻过来。

不仅可以将 Java 代码混合到 JSP 页面中的 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 是 MicrosoftASP 页面的竞争对手,它将 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、ApacheVelocity 和 Thymeleaf。当您从 Java 生成文本输出时,后一种技术也很有趣,即使代码与 Web 完全无关。

这些技术,加上规程,帮助控制了中型和大型 Web 项目的开发和维护成本,但是架构的基本问题仍然存在:没有明确的关注点分离。

今天,现代应用在不同的项目中实现 Web 应用的代码:一个用于客户端,使用 HTML、CSS 和 JavaScript,另一个用于在 Java 中实现服务器功能(或在其他方面,但我们在这里重点讨论 Java)。两者之间的通信是 REST 协议,我们将在后面的章节中介绍。

HTML、CSS 和 JavaScript

HTML、CSS 和 JavaScript 是客户端技术。这些对于 Web 应用非常重要,一个专业的 Java 开发人员应该对它们有所了解。如今,这两个领域的专家级开发人员被称为全栈开发人员,尽管我觉得这个名字有点误导。一定的理解是不可避免的。

HTML 是结构化文本的文本表示。文本以字符形式给出,就像在任何文本文件中一样。标记表示结构。开始标记以一个<字符开始,然后是标记的名称,然后可选地是name="value"属性,最后是结束符>。结束标记以</开始,然后是标记的名称,然后是>。标记包含在层次结构中;因此,您不应该比稍后打开的标记更早关闭标记。首先,必须关闭上一个打开的标签,然后关闭下一个,依此类推。这样,HTML 中的任何实际标记都有一个级别,所有介于开始标记和结束标记之间的标记都在该标记之下。一些不能包含其他标记或文本的标记没有结束标记,它们自己独立存在。考虑以下示例:

<html>
  <head>
    <title>this is the title</title>
  </head>
</html>

标签headhtml下,titlehead下。可以将其结构化为树,如下所示:

html
+ head
  + title
    + "this is the title"

浏览器以树形结构存储 HTML 文本,此树是网页文档的对象模型,因此命名为文档对象模型DOM)树。

最初的 HTML 概念混合了格式和结构,即使使用当前版本的 HTML5,我们仍然有像bitt这样的标签,它们建议浏览器分别以粗体、斜体和电传显示开始和结束标签之间的文本。

正如代表超文本标记语言(HyperTextMarkupLanguage)的名称 HTML 所暗示的那样,文本可以以超链接的形式包含对其他网页的引用。这些链接被分配给使用a标签(代表锚定)的文本或可能由不同字段组成的某个表单,当按下表单的提交按钮时,字段的内容将在POST请求中发送给服务器。发送表单时,字段的内容以所谓的application/x-www-form-urlencoded形式编码。

HTML 结构总是试图促进结构和格式的分离。为此,格式被移动到样式。层叠样式表CSS)中定义的样式为格式化提供了比 HTML 更大的灵活性;CSS 的格式对格式化更有效。创建 CSS 的目的是使设计与文本结构分离。

如果我必须从这三个选项中选择一个,我会选择 CSS 作为对 Java 服务器端 Web 开发人员最不重要的选项,同时也是对用户最重要的选项(事情看起来应该不错)。

JavaScript 是客户端技术的第三大支柱。JavaScript 是一种由浏览器执行的全功能、解释性编程语言。它可以访问 DOM 树,并读取和修改它。修改 DOM 树时,浏览器会自动显示修改后的页面。可以计划和注册 JavaScript 函数,以便在事件发生时调用。例如,您可以注册一个函数,以便在文档完全加载、用户按下按钮、单击链接或将鼠标悬停在某个节上时调用。尽管 JavaScript 最初只用于在浏览器上创建有趣的动画,但今天,使用浏览器的功能对功能齐全的客户端进行编程是可能的,这也是标准做法。有很多用 JavaScript 编写的强大程序,甚至像 PC 仿真器这样的耗电应用。

最后,但并非最不重要的一点是,美国 Java 开发人员必须关注我前面描述的新 WebAssembly 技术。

在本书中,我们将重点介绍 Java,并尽可能多地使用演示技术所需的客户端技术。然而,作为一名 Java Web 开发人员专业人员,您还必须学习这些技术,至少在某种程度上,这样才能理解客户端可以做什么,并能够与负责前端技术的专业人员合作。

Mastermind 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,重载了原始方法,但这次,该方法不仅用于添加新的猜测,还用于添加旧的猜测以重建表。这是因为每次玩家给出下一个猜测的答案时,我们都会根据浏览器发送给服务器的信息来重建游戏的实际状态。游戏的状态存储在客户端中,并通过 HTTP 请求发送到服务器。

程序启动时,没有猜测。程序创建了一个,第一个。之后,当用户告诉程序完全匹配和部分匹配时,程序需要使用包含有Guess对象和fullpartial匹配值的Game结构和TableRow对象。这些已经可用了,但是当新的 HTTP 命中时,我们必须从某个地方获取它。编写 Servlet 时,我们必须将游戏的状态存储在某个地方,并在新的 HTTP 请求到达服务器时还原它。

存储状态

存储状态可以在两个地方完成。我们将在代码中首先做的一个地方是客户端。当程序创建一个新的猜测时,它会将其添加到表中,并发送一个 HTML 页面,该页面不仅包含新的猜测,还包含所有以前的猜测以及用户为每一行提供的匹配值。要将数据发送到服务器,值存储在窗体的字段中。提交表单时,浏览器收集字段中的信息,根据字段内容创建编码字符串,并将内容放入POST请求的主体中。

存储实际状态的另一种可能性是在服务器上。服务器可以存储游戏的状态,并且在创建新的猜测时可以重建结构。在这种情况下,问题是知道使用哪种游戏。如果状态存储在服务器上,那么它应该存储许多游戏,每个用户至少一个。用户可以同时使用应用。它并不一定意味着我们在上一章中所研究的内容具有很强的并发性。

即使用户不是在多个线程中同时服务的,也可能存在活动的游戏。可以有多个用户在玩多个游戏,在服务一个 HTTP 请求时,我们应该知道我们在服务哪个用户。

Servlet 维护可用于此目的的会话,我们将在下一节中看到。

决定在哪里存储应用的状态是一个重要的架构问题。在做决定时,你应该考虑可靠性,信任,安全性,这本身也取决于信任,性能,以及其他可能的因素。

HTTP 会话

当客户端从同一个浏览器向同一个 Servlet 发送请求时,这一系列请求属于一个会话。为了知道请求属于同一个会话,Servlet 容器自动向客户端发送一个名为JSESSIONID的 Cookie,这个 Cookie 有一个长的、随机的、难以猜测的值(tkojxpz9qk9xo7124pvanc1z,因为我在 Jetty 中运行应用)。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无法从节点之间共享的磁盘反序列化属性值。不幸的是,这通常是生产环境。

尽管目前会话只能可靠地存储实现Serializable接口的类的对象,但是我们应该知道 Java 序列化在将来的某个时候会发生变化。序列化是一种低级功能,在创建 Java 时将其连接到一种语言并不是一个好的决定。至少现在看来不是这样。在 Servlet 标准和实现方面没有什么可怕的,它们将正确地处理这种情况。另一方面,在框架提供的代码之外的代码中使用序列化是违反直觉的。

作为一名开发人员,您还应该意识到,序列化和反序列化对象是一项耗费数个 CPU 周期的繁重操作。如果应用的结构仅使用服务于大多数 HTTP 请求的客户端状态的一部分,那么从序列化窗体在内存中创建整个状态,然后再次序列化它,这是对 CPU 的浪费。在这种情况下,更可取的做法是只在会话中存储一个键,并使用一些数据库(SQL 或 NoSQL)或其他服务来存储该键引用的实际数据。企业应用几乎完全使用这种结构。

在客户端上存储状态

首先,我们将通过在客户端上存储状态来开发代码。发送用户输入和新的完全匹配和部分匹配的数量所需的表单还包含用户当时给出的所有猜测和答案的所有以前的颜色。为此,我们创建一个新的辅助类来格式化 HTML 代码。这是在现代企业环境中使用模板、JSP 文件完成的,或者完全避免在企业环境中使用纯 REST 和单页应用。然而,在这里,我们将使用旧技术来演示在现代发动机罩下旋转的齿轮:

package packt.java11.mastermind.servlet;

import packt.java11.mastermind.Color;
import packt.java11.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("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\"")
                .append(" 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++) {
                final String html =
                        colorToHtml(table.getColor(row, column),
                                row, column);
                sb.append(html);
            }
            if (row < table.nrOfRows() - 1) {
                sb.append("" + table.getFull(row));
                sb.append(tag("div", "class", "spacer"))
                        .append(tag("/div"));
                sb.append("" + table.getPartial(row));
            } else {
                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">

                <input type="hidden" name="guess10" value="5">
                <div class="color5"></div>

...deleted content that just looks almost the same...

                <input type="submit" value="submit">
            </form>
        </body>
    </head>
</html>

表单包含div标签中的颜色,还包含隐藏字段中颜色的字母。这些输入字段在表单提交时发送到服务器,就像其他任何字段一样,但它们不会出现在屏幕上,用户无法编辑它们。完全匹配和部分匹配显示在文本输入字段中。由于无法在 HTML 文本中显示Color对象,因此我们使用LetteredColorLetteredColorFactory,它们将单个字母指定给颜色。前六种颜色简单地编号为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.java11.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中列出依赖项中的库。文件的实际内容已经列在HelloWorldServlet 之前。

然后,我们必须创建一个injector实例来执行注入。使用 Servlet 中的以下行创建注入器:

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方法中使用的方法是以 Fluent API 的方式创建的,这样方法就可以一个接一个地链接起来,这样代码就可以像英语句子一样阅读。有关 Fluent API 的详细介绍,请访问这个页面。例如,第一个配置行可以用英语读作:

“绑定到带有"nrColor"值为 6 的@Name的注解int类”

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 框架被替换,注解可以保持不变,而不是特定于框架。

当调用注入器来创建一个MastermindHandler实例时,它查看类,发现它有一个int字段,用@Inject@Named("nrColors")注解,并在配置中发现这样一个字段的值应该是 6。它在返回MastermindHandler对象之前将值注入字段。类似地,它还将值注入其他字段,如果需要创建任何要注入的对象,它也会这样做。如果这些对象中有字段,那么它们也是通过注入其他对象等方式创建的。

这样,DI 框架就免除了程序员创建实例的负担。这是一件相当无聊的事情,而且无论如何也不是类的核心特性。相反,它创建了所有需要有一个函数式的MastermindHandler的对象,并通过 Java 对象引用将它们链接在一起。这样,不同对象的依赖关系(MastermindHandler需要GuesserColorManagerTableColorManager需要ColorFactoryTable也需要ColorManager等等)就变成了一个声明,通过字段上的注解来指定。这些声明在类的代码中,是它们的正确位置。除了类本身之外,我们还能在哪里指定类需要什么才能正常运行呢?

我们示例中的配置指定,无论哪里需要ColorFactory,我们都将使用LetteredColorFactory,无论哪里需要Guesser,我们都将使用UniqueGuesser。这是从代码中分离出来的,必须是这样。如果我们想改变猜测策略,我们将替换配置,代码应该在不修改使用猜测器的类的情况下工作。

Guice 足够聪明,您不必指定任何需要Table的地方,我们都将使用Table——没有bind(Table.class).to(Table.class)。首先,我在配置中创建了一行这样的代码,但是 Guice 给了我一条错误消息,现在,用纯英语再写一遍,我觉得自己真的很愚蠢。如果我需要一张桌子,我需要一张桌子。真的?

当使用 Java9 或更高版本并且我们的代码使用 JPMS 时,我们必须向我们使用的框架打开我们的代码库。模块不允许来自外部的代码使用反射操作私有类或对象成员。如果我们不在模块定义文件中声明我们想要使用 Guice,并且我们允许 Guice 访问私有字段,它将无法做到这一点,这样,它将无法工作。要将我们的模块打开到 Guice,我们必须编辑module_info.java文件并插入opens关键字,指定需要注入的类所在的包。

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 表单包含了所有先前的猜测颜色和这些颜色的答案。然后,作为第二步,我们在此基础上创建一个新的猜测。第 3 步是将新的 HTML 页面发送到客户端。

同样,这不是一种现代的方法,在 Servlet 代码上创建 HTML,但是用 REST、JSON 和 JavaScript 以及一些框架演示纯 Servlet 功能,仅此一章就有几百页的篇幅,而且它肯定会转移我们对 Java 的注意力。

在本书中,将 HTML 文本打印到PrintWriter对您来说并不是什么新鲜事;因此,我们将不在这里列出这些代码。您可以从 Packt GitHub 存储库下载工作示例。我们将重点讨论 Servlet 参数处理,而不是打印。

请求参数可通过返回参数字符串值的getParameter()方法获得。此方法假设任何参数,无论是GET还是POST,在请求中只出现一次。如果存在多次出现的参数,则该值应该是一个String数组。在这种情况下,我们应该使用getParameterMap(),它返回带有String键和String[]值的整个映射。即使我们这次没有任何键的多个值,并且我们也知道键的值作为POST参数,我们仍然将使用后一种方法。这样做的原因是我们稍后将使用会话来存储这些值,并且我们希望在这种情况下有一个可重用的方法。

为了到达该阶段,我们将请求的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) {
    var secret = new Guess(new Color[NR_COLUMNS]);
    var 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);
        var full = Integer.parseInt(params.get(html.paramNameFull(row)));
        var partial = Integer.parseInt(params.get(html.paramNamePartial(row)));
        log.debug("Adding guess to game");
        game.addGuess(guess, full, partial);
    }
    return game;
}

Stringint的转换是通过parseInt()方法完成的。当输入不是数字时,此方法抛出NumberFormatException。试着运行游戏,使用浏览器,看看当 Servlet 抛出异常时 Jetty 是如何处理的。你在浏览器中看到多少有价值的信息可以被潜在的黑客使用?修复代码,以便它再次询问用户是否有任何数字格式不正确!

在服务器上存储状态

应用状态通常不应保存在客户端上。除了编写教育代码并希望演示如何不这样做之外,可能还有一些特殊情况。通常,与实际使用相关的应用状态存储在会话对象或某个数据库中。当应用要求用户输入大量数据,并且不希望用户在客户端计算机出现故障时丢失工作时,这一点尤为重要。

你花了很多时间在网店里挑选合适的商品,选择合适的可以协同工作的商品,创建新模型飞机的配置,突然,家里停电了。如果状态存储在客户端上,则必须从头开始。如果该状态存储在服务器上,则该状态将保存到磁盘;服务器将被复制,由电池供电,当您重新启动客户端计算机时,电源将回到您的家中,您登录,奇迹般地,这些项目都在您的购物篮中。嗯,这不是奇迹,而是网络编程。

在我们的例子中,第二个版本将在会话中存储游戏的状态。这将允许用户恢复游戏,只要会话还在。如果用户退出并重新启动浏览器,会话将丢失,新游戏可以开始。

由于这次不需要在隐藏字段中发送实际的颜色和匹配,因此 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
    <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">
    <input type="submit" value="submit">
</form></body></head></html>

完全匹配和部分匹配的颜色数显示为一个简单的数字,因此此版本不允许欺骗或修改以前的结果。(这些是 CSS 类spacerdiv标记后面的数字02。)

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()); // note the added line
        displayGame(out);
    }
    bodyEnd(out);
}

变量sessionSaver是一个类型为SessionSaver的字段,它由 Guice 注入器注入到类中。SessionSaver是我们创建的一个类。这个类将当前的Table转换成存储在会话中的内容,并且它还根据存储在会话中的数据重新创建表。handle方法使用buildGameFromSessionAndRequest方法来恢复表,并添加用户刚刚在请求中给出的全部和部分匹配答案。当该方法创建新的猜测并将其填充到表中,并在响应中将其发送给客户端时,它通过sessionSaver对象调用save()方法来保存会话中的状态。

buildGameFromSessionAndRequest方法取代了另一个版本,我们称之为buildGameFromRequest

private Game buildGameFromSessionAndRequest(HttpServletRequest request) {
    var game = buildGameFromMap(sessionSaver.restore(request.getSession()));
    var params = toMap(request);
    int row = getLastRowIndex(params);
    log.debug("last row is {}", row);
    if (row >= 0) {
        var full = Integer.parseInt(params.get(html.paramNameFull(row)));
        var 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) {
        var params = convertTableToMap();
        session.setAttribute(STATE_NAME, params);
    }

    public void reset(HttpSession session) {
        session.removeAttribute(STATE_NAME);
    }

    public Map<String, String> restore(HttpSession session) {
        return (Map<String, String>)
                Optional.ofNullable(session.getAttribute(STATE_NAME))
                        .orElse(new HashMap<>());
    }

    private Map<String, String> convertTableToMap() {
        var params = new HashMap<String, String>();
        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方向的实现应该使用Map版本,即Serializable。这与会话处理密切相关。一般来说,将一个Map对象转换为Table对象要高一级,即从客户端、会话、数据库或云中存储表的任何位置恢复表。会话存储只是一种可能的实现,方法应该在满足抽象级别的类中实现。最好的解决方案是在一个单独的类中实现这些。你有作业!

reset()方法未从处理器中使用。这是从Mastermind类调用的,也就是说,Servlet 类,在我们启动游戏时重置游戏:

public void doGet(HttpServletRequest request,
                  HttpServletResponse response)
        throws ServletException, IOException {
    var sessionSaver = new GameSessionSaver();
    sessionSaver.reset(request.getSession());
    doPost(request, response);
}

如果没有这一点,在机器上玩一次游戏,每次我们想重新启动它时,只会显示完成的游戏,直到我们退出浏览器并重新启动它,或者明确删除浏览器高级菜单中某个地方的JSESSIONID Cookie。调用reset不会删除会话。会话保持不变,因此JSESSIONIDCookie 的值也保持不变,但是游戏将从 Servlet 容器维护的会话对象中删除。

运行 Jetty WebServlet

因为我们已经在 Gradle 构建中包含了 Jetty 插件,所以插件的目标是可用的。要启动 Jetty,只需键入以下内容:

gradle appRun

这将编译代码,构建 WAR 文件,并启动 JettyServlet 容器。为了帮助我们记住,它还会在命令行上打印以下内容:

Running at http://localhost:8080//hello

我们可以打开这个 URL,然后看到游戏的打开屏幕,其中的颜色是程序创建的第一个猜测:

现在,是时候找点乐子玩我们的游戏了,给程序答案。不要让代码变得简单!请参阅以下屏幕截图:

同时,如果您在控制台中输入gradle appRun,您会看到代码正在打印日志消息,如下图所示:

这些打印输出通过我们代码中的记录器。在前面的章节中,我们使用System.out.println()方法调用向控制台发送信息性消息。这是一种实践,在任何比 HelloWorld 更复杂的程序中都不应该遵循。

日志

Java 有几种可用的日志框架,每种都有优点和缺点。java.util.logging包中的 JDK 中内置了一个,并且System.LoggerSystem.LoggerFinder类中的System.getLogger()方法支持对记录器的访问。尽管自从 JDK1.4 以来,java.util.logging已经在 Java 中可用,但是很多程序使用其他日志解决方案。除了内置的日志记录之外,我们还要提到log4jslf4j和 ApacheCommons 日志记录。在深入了解不同框架的细节之前,让我们先讨论一下为什么使用日志记录而不是仅仅打印到标准输出中是很重要的。

可配置性

最重要的原因是可配置性和易用性。我们使用日志记录有关代码操作的信息。这不是应用的核心功能,但是不可避免地需要一个可以操作的程序。我们在日志中打印了一些信息,操作人员可以使用这些信息来识别环境问题。例如,当抛出一个IOException并将其记录下来时,操作可能会查看日志并确定磁盘已满。他们可以删除文件,或者添加新磁盘并扩展分区。如果没有日志,唯一的信息就是程序无法运行。

这些日志也被多次用来搜寻虫子。有些 bug 在测试环境中没有表现出来,很难重现。在这种情况下,打印有关代码执行的详细信息的日志是查找某些错误的根本原因的唯一来源。

由于日志记录需要 CPU、IO 带宽和其他资源,因此应该仔细检查日志记录的内容和时间。这个检查和决策可以在编程过程中完成,事实上,如果我们使用System.out.println进行日志记录,这是唯一的可能性。如果我们需要找到一个错误,我们应该记录很多。如果我们记录太多,系统的性能就会下降。结论是,我们应该只在需要时记录。如果系统中存在无法复制的 bug,开发人员会要求操作在短时间内打开调试日志记录。当使用System.out.println时,无法打开和关闭日志的不同部分。当调试级别日志打开时,性能可能会下降一段时间,但与此同时,日志可用于分析。

同时,如果有一个小的(几百兆字节)日志文件而不是大量的 2GB 压缩日志文件来查找相关的日志行,那么当我们必须找到相关的日志行(并且您事先不知道哪些相关)时,分析就更简单了。

使用日志框架,可以定义标识日志消息源和日志级别的记录器。字符串通常标识记录器,通常使用从中创建日志消息的类的名称。这是一种常见的做法,不同的日志框架提供工厂类,这些工厂类获取类本身(而不是其名称)来获取记录器。

在不同的日志框架中,可能的日志级别略有不同,但最重要的级别如下:

  • FATAL:当日志消息涉及阻止程序继续执行的错误时使用。
  • ERROR:当出现严重错误时使用,但程序仍然可以继续运行,即使很可能以有限的方式运行。
  • WARNING:当有一个条件不是直接的问题,但如果不注意可能会导致错误时使用;例如,程序识别出一个磁盘已接近满,一些数据库连接在限制内应答,但接近超时值,以及类似的情况。
  • INFO:用于创建关于正常操作的消息,这些消息可能对操作很有意义,而不是错误或警告。这些消息可能有助于操作调试操作环境设置。
  • DEBUG:用于记录程序的信息,这些信息(希望)足够详细,以在代码中找到错误。诀窍是,当我们将日志语句放入代码中时,我们不知道它可能是什么 bug。如果我们知道,最好是修一下。
  • TRACE:这是关于代码执行的更详细的信息。

日志框架通常使用配置文件进行配置。配置可能会限制日志记录,关闭某些级别。在正常的操作环境中,前三级通常是开启的,INFODEBUGTRACE在真正需要时开启。也可以只为某些记录器打开和关闭某些级别。如果我们知道错误肯定在GameSessionSaver类中,那么我们可以为该类打开DEBUG级别。

日志文件还可能包含我们没有直接编码的其他信息,打印到标准输出时会非常麻烦。通常,每条日志消息都包含创建消息的精确时间、记录器的名称,在许多情况下,还包含线程的标识符。想象一下,如果你被迫把所有这些都放到每一个参数中,你很可能很快就会写一些额外的类来做这件事。不要!它已经做了专业它是记录器框架。

记录器还可以配置为将消息发送到不同的位置。登录到控制台只是一种可能性。日志框架准备将消息发送到文件、数据库、Windows 事件记录器、SysLog 服务或任何其他目标。这种灵活性,即打印哪条消息、打印哪些额外信息以及打印到哪里,是通过按照单一责任原则将记录器框架执行的不同任务分为几个类来实现的。

记录器框架通常包含创建日志的记录器、格式化原始日志信息的消息格式器、经常添加诸如线程 ID 和时间戳等信息的记录器,以及将格式化消息附加到目标的附加程序。这些类实现了日志框架中定义的接口,除了书的大小之外,其他任何东西都无法阻止我们创建自己的格式化程序和附加程序。

配置日志时,将根据实现附加程序和格式化程序的类来配置附加程序和格式化程序。因此,当您想将一些日志发送到一个特殊的目的地时,您并不局限于框架作者提供的附加器。有许多针对不同日志框架的独立开源项目为不同的目标提供了附加器。

性能

使用日志框架的第二个原因是性能。虽然在我们分析代码之前优化性能(过早优化)是不好的,但是使用一种已知速度慢的方法并在性能关键代码中插入几行代码,调用慢方法也不是真正专业的。以一种行业最佳实践的方式使用一个完善的、高度优化的框架应该是无可置疑的。

使用System.out.println()将消息发送到流,并且仅在 IO 操作完成时返回。使用真实日志将信息处理到记录器,并允许记录器异步地进行日志记录,而不等待完成。

如果出现系统故障,日志信息可能会丢失,这确实是一个缺点,但考虑到这种情况很少发生以及性能的另一方面,这通常不是一个严重的问题。如果磁盘已满时缺少调试日志行,导致系统在任何情况下都不可用,我们会损失什么?

当出于法律原因必须保存有关系统事务的某些日志信息以便可以审核操作和实际事务时,此审核日志记录有一个例外。在这种情况下,以事务方式保存日志信息,使日志成为事务的一部分。因为这是一种完全不同的需求类型,审计日志记录通常不使用这些框架中的任何一个来完成。

而且,System.out.println()是不同步的,因此,不同的线程可能只会使输出混乱。日志框架关注这个问题。

日志框架

使用最广泛的日志框架是 Apache log4j。它目前有一个第二个版本,完全重写了第一个版本。它是非常多功能的,有许多附加程序和格式化程序。log4j 的配置可以是 XML 或属性文件格式,也可以通过 API 进行配置。

log4j 版本 1 的作者创建了一个新的日志框架-slf4j。这个日志库本质上是一个外观,可以与任何其他日志框架一起使用。因此,当您在开发的库中使用 slf4j,并且您的代码作为使用不同日志框架的依赖项添加到程序中时,很容易将 slf4j 配置为将日志发送到另一个框架的日志记录器。因此,日志将一起处理,而不是在单独的文件中,这对于降低操作成本是可取的。在开发库代码或使用 slf4j 的应用时,无需选择其他日志框架来创建 slf4j,它有自己的简单实现,称为 backlog。

ApacheCommons 日志记录也是一个有自己日志实现的立面,如果没有其他任何事情失败。与 slf4j 的主要区别在于它在配置和底层日志记录使用上更灵活,并且实现了一个运行时算法,以发现哪些日志框架可用,哪些日志框架将被使用。行业最佳实践表明,这种灵活性也具有更高的复杂性和成本,是不需要的。

Java 日志记录

自版本 9 以来的 Java 包括一个用于日志记录的外观实现。它的应用非常简单,我们可以预期日志框架将很快开始支持这个外观。事实上,该立面内置于 JDK 中有两个主要优点:

  • 想要记录的库不再需要依赖于任何日志框架或日志外观。唯一的依赖关系是 JDK 日志外观,它无论如何都在那里。
  • 记录自己的 JDK 库使用这个外观,因此,它们将与应用登录到同一个日志文件中。

如果我们使用 JDK 提供的日志外观,ColorManager类的开头将更改为:

package packt.java11.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);
        this.nrColors = nrColors;
        this.factory = factory;
        createOrdering();
    }

    private Color[] createColors() {
        var colors = new Color[nrColors];
        for (int i = 0; i < colors.length; i++) {
            colors[i] = factory.newColor();
        }
        return colors;
    }

    private void createOrdering() {
        var 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);
    }
}

在这个版本中,我们不导入 slf4j 类。相反,我们导入java.lang.System.Logger类。

注意,我们不需要导入系统类,因为来自java.lang包的类是自动导入的。对于在System类中嵌套的类,这是不正确的。

为了访问记录器,调用静态方法System.getLogger()。此方法查找可用的实际记录器,并为作为参数传递的名称返回一个记录器。getLogger()方法没有接受类作为参数的版本。如果我们想遵守约定,那么我们必须编写ColorManager.class.getName()来获取类的名称,或者我们可以在那里将类的名称写成一个字符串。第二种方法的缺点是它不跟随类名的更改。智能 IDE,如 IntelliJ、Eclipse 或 Netbeans,会自动重命名对类的引用,但是当在字符串中使用类名时,它们会遇到困难。

System.Logger接口没有声明方便方法errordebugwarning等,这些方法是其他日志框架和外观所熟悉的。只有一个方法名为log(),这个方法的第一个参数是我们发布的实际日志的级别。定义了八个级别-ALLTRACEDEBUGINFOWARNINGERROR,OFF。创建日志消息时,我们应该使用中间六个级别中的一个。ALLOFF仅传递给isLoggable()方法。此方法可用于检查是否记录了实际日志记录级别。例如,如果级别设置为INFO,则不打印用DEBUGTRACE发送的消息。

实际实现由 JDK 使用服务加载器功能定位。日志实现必须位于通过某种实现提供java.lang.System.LoggerFinder接口的模块中。换句话说,模块应该有一个实现LoggerFinder接口的类,module-info.java应该声明哪个类在使用代码:

provides java.lang.System.LoggerFinder with
                            packt.java11.MyLoggerFinder;

MyLoggerFinder类必须用getLogger()方法扩展LoggerFinder抽象类。

日志实践

日志的实践非常简单。如果您不想花太多时间尝试不同的日志记录解决方案,并且没有特定的需求,那么只需使用 slf4j,将 JAR 作为编译依赖项添加到依赖项列表中,并开始在源代码中使用日志记录。

由于日志记录不是特定于实例的,并且日志记录器实现线程安全,所以我们通常使用的日志对象存储在一个static字段中,并且只要使用类,就使用它们,所以该字段也是final。例如,使用 slf4j 外观,我们可以使用以下命令获取记录器:

private static final Logger log =
           LoggerFactory.getLogger(MastermindHandler.class);

要获取记录器,使用记录器工厂,它只创建记录器或返回已经可用的记录器。

变量的名称通常是loglogger,,但如果您看到LOGLOGGER,请不要感到惊讶。将变量名大写的原因是,某些静态代码分析检查器将static final变量视为常量,因为它们实际上是常量,Java 社区的惯例是对这些变量使用大写名称。这是一个品味的问题;通常情况下,loglogger用小写。

为了创建日志项,trace()debug()info()warn()error()方法创建了一条消息,其级别如名称所示。例如,考虑以下代码行:

log.debug("Adding new guess {} to the game", newGuess);

它创建一个调试消息。Slf4j 支持在字符串中使用{}文本进行格式化。这样,就不需要从小部分追加字符串,而且如果实际的日志项没有发送到日志目标,则不会执行格式化。如果我们以任何形式使用String连接来传递一个字符串作为参数,那么格式化就会发生,即使根据示例不需要调试日志记录。

日志记录方法的版本也只有两个参数,String消息和Throwable。在这种情况下,日志框架将负责异常的输出和栈跟踪。如果您在异常处理代码中记录了一些内容,请记录异常并让记录器格式化它。

其他技术

我们讨论了 Servlet 技术,一些 JavaScript、HTML 和 CSS。在真正的专业环境中编程时,通常使用这些技术。然而,应用用户界面的创建并不总是基于这些技术。较旧的操作系统本机 GUI 应用以及 Swing、AWT 和 SWT 使用不同的方法来创建 UI。它们从程序代码构建面向用户的 UI,UI 构建为组件的层次结构。当 Web 编程开始时,Java 开发人员有过类似的技术经验,项目创建的框架试图隐藏 Web 技术层。

值得一提的一项技术是 GoogleWebToolkit,它用 Java 实现服务器和浏览器代码,但由于浏览器中没有实现 Java 环境,因此它将代码的客户端部分从 Java 传输(转换)到 JavaScript。该工具包的最新版本创建于两年前的 2014 年,此后,谷歌发布了其他类型的网络编程工具包,支持原生 JavaScript、HTML 和 CSS 客户端开发。

Vaadin 也是你可能会遇到的工具箱。它允许您在服务器上用 Java 编写 GUI 代码。它是建立在 GWT 之上的,有商业支持。如果有开发人员在 Java 开发 GUI 方面有经验,但在 Web 原生技术方面没有经验,并且应用不需要在客户端进行特殊的可用性调优,那么这可能是一个很好的选择。典型的企业内部网应用可以选择它作为一种技术。

JavaServer FacesJSF)是一种技术,它试图将应用的客户端开发从提供可供使用的小部件的开发人员和服务器端卸载。它是几个 Java 规范请求JSR)的集合,有几个实现。组件及其关系在 XML 文件中配置,服务器创建客户端本机代码。在这种技术中,没有从 Java 到 JavaScript 的转换。它更像是使用一组有限但庞大的小部件,只使用那些小部件,而放弃对 Web 浏览器的直接编程。但是,如果他们有经验和知识,他们可以用 HTML、CSS 和 JavaScript 创建新的小部件。

还有许多其他技术是为支持 Java 中的 Web 应用而开发的。大多数大公司提倡的现代方法是使用单独的工具集和方法开发服务器端和客户端,并使用 REST 通信将两者连接起来。

总结

在本章中,您了解了 Web 编程的结构。如果不了解 TCP/IP 网络的基本知识,这是互联网的协议,这是不可能的。在此之上的应用级协议是 HTTP,目前处于非常新的版本 2.0 中,Servlet 标准版本 4.0 已经支持该协议。我们创建了一个版本的 Master 游戏,这次,可以真正使用浏览器播放,我们使用 Jetty 在开发环境中启动它。我们研究了如何存储游戏状态并实现了两个版本。最后,我们学习了日志的基本知识,并研究了其他技术。同时,我们还研究了 Google 的依赖注入实现 GUI,并研究了它在引擎盖下的工作原理,以及为什么和如何使用它。

在本章之后,您将能够开始用 Java 开发 Web 应用,并了解此类程序的架构。当您开始学习如何使用 Spring 框架来编写 Web 应用时,您将了解其中的秘密,Spring 框架隐藏了 Web 编程的许多复杂性。

七、使用 REST 构建商业 Web 应用

我们一直在玩,但 Java 不是玩具。我们希望使用 Java 来实现真正的、严肃的、商业的和专业的。在这一章中,我们将这样做。我们将要看的例子不是我们在前三章中所看到的,只是有趣的东西,比如 Mastermind,而是一个真正的商业应用。实际上,这不是一个真实的应用。你不应该指望书中有这样的东西。它太长了,教育不够。但是,我们将在本章中开发的应用可以扩展,并且可以作为实际应用的核心,以防您决定这样做。

在上一章中,我们创建了 Servlet。为此,我们使用了 Servlet 规范,并手工实现了 Servlet。这是你现在很少做的事。在本章中,我们将使用一个现成的框架。这次,我们将使用 Spring,它是 Java 商业应用中使用最广泛的框架,我敢说它是事实上的标准。它将完成上一章中我们必须完成的所有繁琐工作(至少是为了理解和学习 Servlet 的工作原理)。我们还将使用 Spring 进行依赖注入(为什么要使用两个框架,而一个框架可以完成所有工作?),还有 Tomcat。

在前一章中,我们使用 Guice 作为 DI 框架,Jetty 作为 Servlet 容器。对于一些项目,这些都是非常好的选择。对于其他项目,其他框架做得更好。为了有机会查看本书中的不同工具,我们将使用不同的框架,尽管我们将展示的所有示例都可以通过仅使用 Tomcat 和 Spring 来创建。

我们将开发的商业应用将是一个针对经销商的订购系统。我们将提供给用户的界面将不是一个 Web 浏览器可消费的 HTML/JavaScript/CSS 界面。相反,它将是一个 REST 接口。用户将自行开发与我们的系统通信的应用,并为不同的产品下订单。应用的结构将是一个微服务架构,除了标准的 Chrome 开发工具特性之外,我们还将使用 SoapUI 来测试应用。

MyBusiness 网上商店

想象一下,我们有一个庞大的贸易和物流公司。货架上有上万种不同的产品;数百辆卡车带着新的货物来到我们的仓库,数百辆卡车为我们的客户送货。为了管理这些信息,我们有一个库存系统,它每分钟、每小时、每天跟踪货物,以便我们知道仓库里实际有什么。我们为客户提供服务,而无需人工管理仓库信息。以前,有电话,传真机,甚至电传。今天,我们使用的只是互联网和网络服务。我们不为客户提供网站。我们从未在想象中的业务中直接为最终用户服务,但现在,我们有一个子公司,我们作为一个独立的公司开始这样做。他们有一个网站,完全独立于我们。他们只是我们数百个注册合作伙伴中的一个,他们每个人都使用 Web 服务接口/界面来查看我们拥有的产品、订购产品和跟踪订单状态。

业务架构示例

我们的合作伙伴也是具有自动化管理的大型公司,在多台机器上运行多个程序。我们对他们的架构和使用的技术不感兴趣,但我们希望与他们的业务相结合。我们希望以一种不需要任何人际互动的方式为他们提供服务,以便政府向我们任何一方订货。为此,提供了一个 Web 服务接口,无论他们使用什么 IT 基础设施,都可以使用它。

在我们这边,正如我们想象的例子,我们最近用 microservice 架构替换了我们的单片应用,尽管系统中仍然有一些基于 SOAP 的解决方案,但是大多数后端模块使用 HTTPS 和 REST 协议进行通信。一些模块仍然依赖于每天使用 FTP 进行的异步文件传输,FTP 是从 Unix 作业开始的。总账系统是用 COBOL 语言编写的。幸运的是,我们不需要对付这些恐龙。

这个结构是一个虚构的设置,但一个现实的。我编写并描述这些部分是为了让您了解如何在大型企业中看到混合技术。我在这里描述的是一个非常简单的设置。有些公司的系统中有一千多个软件模块,使用不同的技术和完全不同的接口,所有这些模块都相互连接。这并不是因为他们喜欢这种混乱,而是因为经过 30 年的持续发展,这种混乱才变得如此。新技术来了,旧技术也消失了。业务发生了变化,如果你想保持竞争力,就不能固守旧技术。同时,您无法立即替换整个基础结构。其结果是,我们看到相当老的技术仍然在运行,而且主要是新技术。旧技术得到及时推广。它们不会永远呆在这里,而且,当恐龙出现在我们面前时,我们有时会感到惊讶。

我们必须处理我们将要开发的两个前端组件。具体如下:

  • 产品信息
  • 下单跟踪

在下面的图片中,您可以看到我们将要看到的结构的架构 UML 图。我们将只与前端组件进行交互,但如果我们有更大的了解,这有助于了解它们的功能和作用:

产品信息提供单个产品的信息,也可以根据查询条件提供产品列表。下单跟踪提供了客户下单的功能,也可以让我们的客户查询过去订单的状态。

要提供产品信息,我们需要访问保存实际产品详细信息的产品目录模块。

产品目录可以执行许多其他任务,这就是为什么它是一个单独的模块。例如,它可以有一个工作流和批准引擎,让产品管理员输入产品数据,让经理检查和批准数据。审批通常是一个复杂的过程,考虑到打字错误和法律问题(我们不想交易未经许可的毒品、爆炸物等),并检查货物来源的质量和审批状态。许多复杂的任务使它成为后端模块。在大型企业应用中,前端系统除了为外部服务的基本功能外,很少做其他任何事情。但这对我们有好处;我们可以专注于我们必须提供的服务。这对架构也有好处。这与面向对象编程中的单一责任原则相同。

产品信息模块还要咨询门禁模块,看某个产品能不能送到实际客户手中,再跟库存一起看有没有剩余的产品,这样我们才不会提供一个缺货的产品。

下单跟踪模块还需要访问产品库存访问控制模块,检查订单是否可以完成。同时,它还需要来自定价模块的服务,该模块可以计算订单的价格,以及来自物流模块的服务,该模块触发从库存位置收集货物并将货物发送给客户。物流也与发票有关联,发票与总账有关联,但这些只是在图片上显示信息的旅行并没有到此为止。有许多其他模块运行公司,所有这些都不是我们目前感兴趣的。

微服务

上一章中描述的架构不是一个干净的微服务架构。在任何事业中,你都不会遇到一个纯粹的人。它更像是我们在一个真正的公司里遇到的东西,从单片到微服务。

当应用以许多小型服务的形式开发时,我们将讨论微服务架构,这些服务使用一些简单的 API(通常通过 HTTP 和 REST)相互通信。这些服务实现业务功能,可以独立部署。在大多数情况下,希望服务部署是自动化的。

各个服务可以使用不同的编程语言开发,可以使用不同的数据存储,并且可以在不同的操作系统上运行;因此,它们彼此高度独立。它们可以而且通常是由不同的团队开发的。重要的要求是它们相互协作;因此,一个服务实现的 API 可以被构建在它之上的其他服务使用。

微服务架构并不是所有架构中的圣杯。它对单片架构的一些问题给出了不同的答案,在大多数情况下,这些答案在使用现代工具时效果更好。这些应用还需要测试和调试。性能必须得到管理,错误和问题必须得到解决。不同之处在于,各个组件之间没有强耦合,这样,开发、部署和测试就可以沿着不同的技术进行分离。由于微服务架构在实践中沿着网络协议将模块分开,调试可能需要更多与网络相关的工作。这可能是好的,也可能是坏的,或者两者兼而有之。然而,对于开发商来说,优势是显而易见的。他们可以独立地在较小的单元上工作,并且可以更快地看到工作的结果。

在开发单片应用的单个模块时,只有在部署整个应用时才能看到结果。在大型应用的情况下,这可能很少见。在开发单片电路的大型公司中,一个典型的部署周期是每隔几个月,比如说三个月,但是一年只发布两次甚至一次的情况并不少见。开发微服务时,只要新模块没有破坏它提供给我们的网络接口和其他模块使用的网络接口,只要它准备好并经过测试,就可以部署它。

如果你想阅读更多关于微服务的文章,第一个也是最真实的来源是 martinfowler 的文章。请注意,此页面引用了微服务资源指南,其中列出了许多微服务信息资源。

服务接口设计

在本节中,我们将设计要实现的两个接口。在设计接口时,我们首先关注功能。格式和协议稍后提供。接口,一般来说,应该是简单的,同时,适应未来的变化。这是一个困难的问题,因为我们看不到未来。商业、物流和所有其他专家可能会看到未来世界的某些部分将如何变化,以及它将对公司的运营,特别是我们为合作伙伴提供的接口带来什么影响。

接口的稳定性是最重要的,因为合作伙伴是外部实体。我们无法重构它们使用的代码。当我们在代码中更改 Java 接口时,编译器将在所有应该遵循更改的代码位置抱怨。如果是在我们的领域之外使用的接口,情况并非如此。即使我们在 GitHub 上发布为开源的 Java 接口,我们也应该做好准备,如果我们以不兼容的方式更改库,用户也会面临问题。在这种情况下,他们的软件将不会编译和与我们的库一起工作。如果是订购系统,这意味着他们不会从我们那里订购,我们很快就会倒闭。

这就是为什么接口应该简单的原因之一。虽然这通常适用于生活中的大多数事情,但对于接口来说却是极其重要的。为合作伙伴提供方便的特性是很有诱惑力的,因为它们易于实现。但是,从长远来看,这些特性可能会变得非常昂贵,因为它们需要维护;它们应该保持向后兼容。从长远来看,他们可能得不到成本那么多。

要访问产品信息,我们需要两个函数。其中一个列出特定产品,另一个返回特定产品的详细信息。如果它是 Java API,则如下所示:

List<ProductId> query(String query);
ProductInformation byId(ProductId id);

类似地,订单安排可能类似于以下代码所示:

OrderId placeOrder(Order order);

我们通过 Web 服务接口在应用中提供这些函数;更具体地说,REST 使用 JSON。我们将更详细地讨论这些技术,以及 Spring 框架和模型-视图-控制器设计模式,但首先,让我们看看产品信息控制器,以了解我们的程序将是什么样子:

package packt.java11.mybusiness.productinformation;
import ...
@RestController
public class ProductInformationController {
    private final ProductLookup lookup;

    public ProductInformationController(
            @Autowired ProductLookup lookup) {
        this.lookup = lookup;
    }

    @RequestMapping("/pi/{productId}")
    public ProductInformation getProductInformation(
            @PathVariable String productId) {
        return lookup.byId(productId);
    }

    @RequestMapping("/query/{query}")
    public List<String> lookupProductByTitle(
            @PathVariable String query,
            HttpServletRequest request) {
        return lookup.byQuery(query)
                .stream().map(s -> "/pi/" + s)
                .collect(Collectors.toList());
    }
}

如果将 Servlet 的代码与前面的代码进行比较,您会发现这要简单得多。我们不需要处理HttpServletRequest对象,不需要调用 API 来获取参数,也不需要创建 HTML 输出并将其写入响应。框架就是这样做的。我们对@RestController类进行注解,告诉 Spring 这是一个利用 RESTWeb 服务的控制器;因此,它将从我们默认返回的对象创建一个 JSON 响应。我们不需要关心对象到 JSON 的转换,尽管如果确实需要的话我们可以。对象将使用类中使用的字段名和返回的实例的字段值自动转换为 JSON。如果对象包含比普通的Stringintdouble值更复杂的结构,那么转换器将为嵌套结构和最常见的数据类型做好准备。

为了在 Servlet 上有不同的代码处理和不同的 URL,我们需要做的就是用@RequestMapping注解方法,提供 URL 的路径部分。映射字符串中的{productId}符号可读且易于维护。Spring 只是从那里切下值,然后按照@PathVariable注解的要求,将其放入productId变量中。

控制器中未实现产品的实际查找。这不是控制器的功能。控制器只决定调用什么业务逻辑和使用什么视图。业务逻辑在服务类中实现。这个服务类的一个实例被注入到lookup字段中。这种注射也是由 Spring 完成的。我们要做的实际工作是调用业务逻辑,这一次,因为我们只有一个,是相当容易的。

如果没有更多关于框架为我们做了什么的细节,大多数这些东西看起来都很神奇。因此,在继续之前,我们将先看看构建块 JSON、REST、MVC 和一些 Spring 框架。

JSON 文件

JSON 代表 JavaScript 对象表示法。在官方 JSON 网站上定义。这是一种文本表示法,与 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 协议没有确切的定义。它代表表述性状态转移,对于一个从未听说过它的人来说,这可能并不意味着什么。当我们编写 RestAPI 时,我们使用 HTTP(S)协议。我们向服务器发送简单的请求,然后得到我们编写的简单答案。这样,Web 服务器的客户端也是一个程序(顺便说一下,浏览器也是一个程序),它使用来自服务器的响应。因此,响应的格式不是使用 CSS 的 HTML 格式,也不是通过 JavaScript 的客户端函数来丰富的,而是一些数据描述格式,比如 JSON。REST 没有对实际的格式设置限制,但是现在,JSON 是使用最广泛的格式。

描述 REST 的 wiki 页面位于这个页面

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 应用时,您可以通过使用注解对框架中内置的控制器进行编程。基本上就是配置它。您可以对视图进行编程,但更有可能使用内置到框架中的视图。您将希望以 XMLJSONHTML 格式向客户端发送数据。如果你很有异国情调,你可能会想发送 YAML,但一般来说,就是这样。您不希望实现需要在服务器上编程的新格式,因为它是新的,所以也需要在客户端上编程。

我们创建了模型,这一次,我们还编写了程序。毕竟,这是业务逻辑。框架可以为我们做很多事情,主要是对大多数应用来说都是一样的,但对业务逻辑来说却不一样。业务逻辑是将我们的代码与其他程序区别开来的代码。这就是我们要规划的。

另一方面,这正是我们喜欢做的,关注业务代码,避免框架提供的所有样板文件。

既然我们知道了什么是 JSONREST,以及通用的模型-视图-控制器设计模式,那么让我们看看 Spring 是如何管理它们的,以及如何将这些技术付诸实现。

Spring 框架

Spring 框架是一个包含多个模块的巨大框架。该框架的第一个版本是在 2003 年发布的,从那时起,已经有四个主要版本提供了新的和增强的特性。目前,Spring 是实际使用的企业框架,可能比法律标准 EJB3.0 更广泛。

Spring 支持依赖注入、面向切面编程AOP)、对 SQLNoSQL 数据库的持久化等传统方式和对象关系映射方式。它具有事务支持、消息传递、Web 编程和许多其他特性。您可以使用 XML 配置文件、注解或 Java 类来配置它。

Spring 的架构

Spring 不是整体的。你可以使用它的一部分,或者只使用一些功能。您可以包含一些您需要的 Spring 模块,而忽略其他模块。一些模块依赖于其他模块,Gradle、Maven 或其他一些构建工具处理依赖关系。

下图显示了版本 4 的 Spring 框架的模块:

Spring 自第一次发布以来一直在不断发展,它仍然被认为是一个现代框架。框架的核心是一个依赖注入容器,类似于我们在前面一章中看到的容器。随着框架的发展,它还支持 AOP 和许多其他企业功能,例如面向消息的模式和通过模型视图控制器实现的 Web 编程,不仅支持 Servlet,还支持 Portlet 和 WebSocket。由于 Spring 针对企业应用领域,因此它还支持以多种不同的方式处理数据库。支持 JDBC 使用模板、对象关系映射ORM),以及事务管理。

在这个示例程序中,我们将使用一个相当新的模块 SpringBoot。这个模块使得编写和运行应用非常容易,假设许多程序的配置通常是相同的。它包含一个嵌入的 Servlet 容器,它为默认设置进行配置,并在可能的情况下配置 Spring,以便我们可以关注编程方面,而不是 Spring 配置。

Spring 核心

核心模块的中心元素是上下文。当 Spring 应用启动时,容器需要一个上下文,容器可以在其中创建不同的 bean。这对于任何依赖注入容器来说都是非常普遍和正确的。如果我们以编程方式创建两个不同的上下文,它们可能在同一个 JVM 中彼此独立地存在。如果有一个 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。这是为我们创建的所有样板代码。

上下文跟踪已创建的 bean,但不创建它们。要创建 bean,我们需要 bean 工厂或至少一个工厂。Spring 中的 bean 工厂是实现接口BeanFactory的类。这是 Spring 中 bean 工厂类型层次结构的最顶层接口。bean 只是一个对象,所以 bean 工厂只是创建一个类的新实例。但是,它还必须将这个新对象注册到上下文中,bean 还应该有一个名称,即String。这样,程序和其中的 Spring 就可以通过名称引用 bean。

在 Spring 中,可以用几种不同的方式配置不同的 bean。最古老的方法是创建一个描述不同 bean 的 XML 文件,指定名称、创建 bean 必须实例化的类,以及 bean 需要注入其他 bean 才能创建的字段。

这种方法背后的动机是,通过这种方式,bean 布线和配置可以完全独立于应用代码。它成为一个可以单独维护的配置文件。

例如,我们可能有一个在多个不同环境中工作的大型应用。在我们的示例中,可以通过多种方式访问库存数据。在一种环境中,清单可以通过调用 SOAP 服务来获得。在另一个环境中,可以在 SQL 数据库中访问数据。在第三种环境中,它可以在一些 NoSQL 存储中使用。这些访问中的每一个都实现为一个单独的类,实现一个公共的库存访问接口。应用代码只依赖于接口,而容器提供了一个或另一个实现。

当 bean 连接的配置是 XML 格式时,那么只需要编辑这个 XML 文件,并且代码可以从实现适合特定环境的接口开始。

下一种可能是使用注解配置 bean。在大多数情况下,使用 Spring 的原因是将对象创建与功能分离。在这种情况下,bean 可能只有一个实现。仍然使用 Spring,实际代码使用为依赖注入提供的框架更干净。另一方面,外部 XML 将将配置从需要配置的代码中移开。在这种情况下,可以控制 bean 创建和注入的注解作为代码中的声明工作。

当只有一个实现是冗余的时,XML 配置。为什么我要在 XML 配置中指定我希望通过实现该接口的程序的单个类获得该接口的实例?这是非常明显的,而且不能以任何其他方式,因此这是实现接口的唯一类。我们不喜欢键入不提供新信息的内容。

为了表示类可以用作 bean,并可能提供名称,我们可以使用@Component注解。我们不需要提供名称作为参数。在这种情况下,名称将是一个空字符串,但是如果我们不引用它,为什么还要有一个名称呢?Spring 扫描类路径上的所有类并识别已注解的类,它知道这些类是用于 bean 创建的候选类。当一个组件需要注入另一个 bean 时,可以使用@Autowired@Inject对该字段进行注解。@Autowired注解是 Spring 注解,在@Inject注解标准化之前就已经存在。如果要在 Spring 容器之外使用代码,建议使用标准注解。在功能上,它们是等价的。

在我们的代码中,当 Spring 创建一个ProductInformationController组件的实例时,它似乎需要一个ProductLookup的实例。这是一个接口,因此,Spring 开始寻找实现这个接口的类,然后创建它的一个实例,可能首先创建其他 bean,然后容器注入它,设置字段。您可以决定注解字段的设置器而不是字段本身。在这种情况下,Spring 将调用设置器,即使设置器是private。可以通过构造器参数注入依赖项。设置器、字段注入和构造器注入之间的主要区别在于,在使用构造器注入的情况下,不能创建没有依赖关系的 bean。当 bean 被实例化时,它应该并且将要注入所有其他 bean,以便它依赖于使用构造器注入。同时,需要通过设置器注入或直接注入到字段中的依赖项可以稍后由容器在实例化类和准备 bean 之间的某个时间实例化。

在构造器代码变得比简单的依赖项设置更复杂之前,或者在依赖项变得更复杂之前,这种细微的差异可能看起来并不有趣或重要。对于复杂的构造器,代码应该注意对象没有完全创建。这通常适用于任何构造器代码,但对于依赖项注入容器创建的 bean,通过直接字段访问或通过设置器注入注入依赖项,这一点更为重要。建议使用构造器注入来确保存在依赖项。如果程序员犯了一个错误,忘记了对象没有完全初始化,并在构造器或方法中使用它,而方法本身是从构造器中调用的,那么依赖关系就已经存在了。此外,使用构造器初始化依赖项并声明那些字段final更简洁、结构更完善。

另一方面,构造器注入也有其缺点。

如果不同的对象相互依赖,并且依赖关系图中有一个环,那么如果使用构造器依赖关系,Spring 将很困难。当类A需要类B反过来作为最简单的圆时,如果依赖注入是构造器依赖,那么AB都不能没有他者而创建。在这样的情况下,不能使用构造器注入,应该将循环分解为至少一个依赖项。在这种情况下,塞特注射是不可避免的。

当存在可选依赖项时,设置器注入也可能更好。在大多数情况下,一个类可能不需要同时使用它的所有依赖项。有些类可以使用数据库连接或 NoSQL 数据库句柄,但不能同时使用两者。尽管这也可能是一种代码味道,可能是 OO 设计糟糕的标志,但它可能会发生。这可能是一个深思熟虑的决定,因为纯 OO 设计会导致太深的对象层次结构和太多的类,超出可维护的限制。如果是这种情况,那么使用设置器注入可以更好地处理可选的依赖关系。有的配置设置,有的留有默认值,通常是null

最后但同样重要的是,我们可以使用 Java 类来配置容器,以防注解不够。例如,在我们的代码库中,ProductLookup接口有多种实现。(如果您不知道,请不要担心;我还没有告诉您)有一个ResourceBasedProductLookup类从包中读取属性文件,主要用于测试应用,还有一个RestClientProductLookup,它是一个类似于产品的接口实现。如果我除了用@Autowired注解lookup字段外,没有其他配置,Spring 将不知道使用哪个实现,并在启动时向用户奖励以下错误消息:

Error starting ApplicationContext. To display the auto-configuration report re-run your application with 'debug' enabled.
2023-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 代码的支持(后者近年来开发了很多,尽管这是针对 SpringXML 配置的)。

要理解从 XML 返回 Java 代码的概念,我们必须回到 XML 配置方式的纯粹原因和目的。

SpringXML 配置的主要优点不是格式不是编程的,而是配置代码与应用代码分离。如果我们用 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

package packt.java11.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 应用的本地资源文件中读取产品数据。

配置的生产版本差别不大,但正如预期的那样,还有一些东西需要配置:

package packt.java11.mybusiness.productinformation;
import ...
@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");

后者可能有效,但不是在所有情况下都有效,我们不应该使用它。基于这些原因,我们将在下一节讨论 AOP 和 Spring 时返回。

另外,请注意,不需要定义接口来定义 bean。bean 方法返回的类型也可以是类。上下文将使用适合所需类型的方法,如果有多个合适的类型,并且配置不够精确,正如我们所看到的,容器将记录一个错误,并且不会工作。

在服务于本地概要文件的配置中,我们将为ProductInformationServiceBuilder创建一个null值。这是因为当我们使用本地测试时不需要它。另外,如果调用这个类中的任何方法,它将是一个错误。应尽快检测到错误;因此,null值是一个简单合理的选择。一个更好的选择是,如果调用了任何方法,bean 都会抛出一个特定的异常。这样,您就可以看到一个特定的异常,以及被测试代码想要调用的方法,而不是空指针异常。

ProductInformationServiceUrlBuilder类非常简单:

package packt.java11.mybusiness.productinformation;

public class ProductInformationServiceUrlBuilder {
    private final String baseUrl;

    public ProductInformationServiceUrlBuilder(String baseUrl) {
        this.baseUrl = baseUrl;
    }

    public String url(String service) {
        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;
    }
}

这个 bean 还需要一个构造器参数,我们在配置中使用了字符串常量。这清楚地表明,可以使用一个简单的对象初始化一些依赖项(什么会阻止我们?毕竟它是纯 Java,但它可能会阻碍某些 Spring 特性的工作。

服务类

我们有两个服务类。这些类为控制器提供数据并实现业务逻辑,不管它们有多简单。其中一个服务类实现对基于 REST 的服务的调用,而另一个服务类从属性文件中读取数据。后者可用于在应用脱机时对其进行测试。在生产环境中使用调用 REST 服务的服务。它们都实现了ProductLookup接口:

package packt.java11.mybusiness.productinformation;
import java.util.List;
public interface ProductLookup {
    ProductInformation byId(String id);
    List<String> byQuery(String query);
}

ResourceBasedProductLookup将整个数据库存储在一个名为products的映射中。当调用其中一个服务方法时,它将从属性文件中填充。private方法loadProducts在每个服务方法启动时都会被调用,但只有在尚未加载的情况下才会加载数据:

package packt.java11.mybusiness.productinformation.lookup;
import ...

@Service
public class ResourceBasedProductLookup implements ProductLookup {

该类使用@Service进行注解。此注解实际上等同于@Component注解。这只是同一注解的替代名称。Spring 还处理@Component注解,因此,如果使用@Component注解对注解接口进行注解,那么该注解还可以用来表示类是 Spring 组件。如果您想要更好的可读性,您可以编写自己的注解接口,声明类不是简单的组件,而是其他一些特殊类型。

例如,启动 IDE 并导航到ResourceBasedProductLookup类中的fromProperties()方法:

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),其中不包含逻辑-仅包含字段,设置器和获取器。 它还包含一个常量emptyProductInformation,其中包含对具有空值的类的实例的引用。

Properties对象类似于Map对象。它包含分配给String键的String值。我们将在示例中看到,有一些方法可以帮助程序员从所谓的属性文件中加载一个Properties对象。这样的文件通常有.properties扩展名,它包含以下格式的键值对:

key=value

例如,123.properties文件包含以下内容:

id=123
title=Fundamentals of Java18.9
description=a new book to learn Java11
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)) {
                log.error("Search by title is allowed only");
                return noProducts;
            }
            for (String id : products.keySet()) {
                log.error("key: {} value:{} id:{}", key, value, id);
                ProductInformation pi = products.get(id);
                if (pi.getTitle().startsWith(value)) {
                    pis.add(id);
                }
            }
        }
    }
    return pis;
}

实现生产函数的服务类要简单得多。奇怪,但在大多数情况下,测试代码比生产代码更复杂:

package packt.java11.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,这是该类的所有辅助代码。其余为byId()byQuery()两种服务方式。首先,我们看一下byId()方法:

@Override
public ProductInformation byId(String id) {
    var uriParameters = new HashMap<String, String>();
    uriParameters.put("id", id);
    var rest = new RestTemplate();
    var amount =
        rest.getForObject(piSUBuilder.url("inventory"),
            InventoryItemAmount.class,
            uriParameters);
    log.info("amount {}.", amount);
    if (amount.getAmount() > 0) {
        log.info("There items from {}. We are offering", id);
        return rest.getForObject(piSUBuilder.url("pi"),
            ProductInformation.class,
            uriParameters);
    } else {
        log.info("There are no items from {}. Amount is {}", id, amount);
        return ProductInformation.emptyProductInformation;
    }
}

byId()方法首先调用库存服务,查看库存中是否有产品。这个 REST 服务返回一个格式为{ amount : nnn }的 JSON;因此,我们需要一个具有int amount字段、一个设置器和一个获取器的类(非常简单,这里不列出它)。

SpringRestTemplate提供了一种方便的方式访问休息服务。它所需要的只是 URL 模板,一种用于转换结果的类型,以及一个包含参数的Map对象。URL 模板字符串可以以与 Spring 控制器中的请求映射相同的方式包含参数,参数的名称介于{}字符之间。模板类提供了访问 REST 服务的简单方法。它自动执行封送、发送参数和取消封送,接收响应。如果是GET请求,则不需要封送。数据位于请求 URL 中,并且{xxx}占位符被映射中的值替换,这些值作为第三个参数提供。大多数格式都可以随时使用联合国封送。在我们的应用中,REST 服务发送 JSON 数据,并在响应Content-TypeHTTP 头中指示。RestTemplate将 JSON 转换为作为参数提供的类型。如果服务器决定以 XML 发送响应,也会在 HTTP 头RestTemplate中显示,该消息头将自动处理这种情况。事实上,看看代码,我们无法分辨响应是如何编码的。这也是一个好的,因为它使客户灵活,同时,我们不需要处理这样的技术细节。我们可以集中精力于业务逻辑。

同时,该类还提供封送处理或其他一些功能的配置参数,以便它自动需要这些参数。例如,您可以提供封送处理方法,但我建议您使用默认情况下可用的方法。在大多数情况下,当开发人员认为需要这些函数的特殊版本时,他们的代码的原始设计是有缺陷的。

业务逻辑非常简单。我们首先询问存货是否有某种产品在库存。如果有(大于零),则查询产品信息服务并返回详细信息。如果没有,则返回一个空记录。

另一项服务更简单。它只调用基础服务并返回结果:

@Override
public List<String> byQuery(String query) {
    var uriParameters = new HashMap<String, String>();
    uriParameters.put("query", query);
    var rest = new RestTemplate();
    return rest.getForObject(piSUBuilder.url("query"), List.class, uriParameters);
}

编译和运行应用

我们使用gradle编译并运行应用。由于应用没有任何特定的配置,这些配置不会出现在大多数类似的应用中,因此使用 Spring 引导是明智的。SpringBoot 使创建和运行 Web 应用变得非常简单。我们需要一个 Java 标准的public static void main方法,通过 Spring 启动应用:

package packt.java11.mybusiness.productinformation;

import ...

@SpringBootApplication(scanBasePackageClasses = SpringScanBase.class)
public class Application {

    public static void main(String[] args) {
        SpringApplication.run(Application.class, args);
    }
}

这个方法除了启动StringApplication类的run方法外什么都不做。它传递原始参数和应用所在的类。Spring 使用这个类来读取注解。@SpringBootApplication注解表示该类是一个 Spring 引导应用,并提供参数来配置包含该应用的包。为此,您可以提供包含类的包的名称,但也可以在基包中提供包含 Spring 必须知道的所有类的类。您可能无法使用注解参数的类版本,因为根包不能包含任何类,只能包含子包。同时,将根包的名称提供为String不会在编译期间显示任何打字错误或未对齐。一些 IDE 可能会识别出参数应该是一个包名,或者在重构或重命名包时,它可能会扫描程序的字符串以查找包名,并为您提供支持,但这只是更多的启发式方法。通常的做法是创建一个占位符类,如果根包中没有类,则该类不在根包中执行任何操作。此类可用于指定scanBasePackageClasses作为注解参数,而不是需要StringscanBasePackages。在我们的示例中,有一个空接口SpringScanBase作为占位符。

Spring 扫描类路径上的所有类,识别它可以解释的组件和字段注解,并在需要时使用这些知识来创建 bean 而不进行配置。

注意,JDK 中包含的抽象类ClassLoader没有提供任何类扫描方法。由于 Java 环境和框架可以实现它们自己的ClassLoaders,所以一些实现可能(但不太可能)不提供URLClassLoader提供的扫描功能。URLClassLoader是类加载功能的非抽象实现,是 JDKClassLoader的一部分。我们将在后面的章节中讨论类加载机制的复杂性。

gradle构建文件包含通常的内容。它指定了存储库、Java 插件和 Spring 引导的 IDE。它还指定在构建期间生成的 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.10
targetCompatibility = 1.10

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 引导包、一些测试包、AOP 支持(我们很快就会看到这些),以及 Spring 引导开发工具。

SpringBootDevTools 使 Web 应用在重新编译时可以重新启动,而无需重新启动内置的 Tomcat 服务器。假设我们使用以下命令行启动应用:

gradle -Dspring.profiles.active=production bootRun

Gradle 启动应用。每当它看到它运行的类被修改时,就会重新加载它们,我们可以在几秒钟内测试修改后的应用。

-Dspring.profiles.active=production参数指定生产配置文件应该是活动的。为了能够使用这个命令行参数,我们还需要构建文件中的bootRun{}配置闭包。

测试应用

应用应该为它所拥有的每个类都进行单元测试,可能除了不包含任何功能的 DTO 类。设置器和获取器是由 IDE 创建的,而不是由程序员输入的,因此不太可能出现任何错误。如果存在与这些类相关的错误,则更可能是无法通过使用单元测试发现的集成问题。由于我们在前面的章节中详细讨论了单元测试,因此我们将在这里更多地关注集成测试和应用测试。

集成测试

集成测试与单元测试非常相似,在大多数情况下,新手程序员声称他们在实际执行集成测试时执行单元测试。

集成测试驱动代码,但不要单独测试单个类(单元),模拟类可能使用的所有内容。相反,它们测试了执行测试所需的大多数类的功能。这样,集成测试将测试这些类是否能够协同工作,不仅满足它们自己的规范,而且还确保这些规范能够一起工作。

在集成测试中,模拟外部世界(如外部服务)和对数据库的访问。这是因为集成测试应该在集成服务器上运行,在执行单元测试的同一环境中,这些外部接口可能不可用。在大多数情况下,使用内存中的 SQL 模拟数据库,使用一些模拟类模拟外部服务。

Spring 提供了一个很好的环境来执行这样的集成测试。在我们的项目中,我们有一个示例集成测试:

package packt.java11.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 Java9 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请求,在控制器中执行代码,并使用模拟框架和 Fluent API 以非常可读的方式测试返回值。

请注意,使用属性文件并基于属性文件实现服务有点过分。我创建它是为了能够在没有任何真正的备份服务的情况下以交互方式启动应用。考虑以下命令-gradle -Dspring.profiles.active=local bootRun。如果我们发出前面的命令,那么服务器将使用此本地实现启动。如果我们只以集成测试为目标,那么服务类的本地实现应该在test目录下,并且应该简单得多,主要是对任何预期的请求只返回常量响应,如果出现任何非预期的请求则抛出错误。

应用测试

考虑以下命令:

gradle -Dspring.profiles.active=production bootRun

如果我们启动应用,发出前面的命令并启动浏览器到 URLhttp://localhost:8080/pi/123,我们将在浏览器屏幕上得到一条庞大的错误消息。哎哟。。。

上面写着类似的东西。这是因为我们的代码想连接到备份服务,但我们还没有。要在这个级别上测试应用,我们应该创建备份服务,或者至少创建一些模拟它们的东西。最简单的方法是使用 SoapUI 程序。

SoapUI 是一个 Java 程序,可从这个页面获得。有一个开源版本和免费版本,还有一个商业版本。就我们而言,免费版本就足够了。我们可以用最简单的单击转发方式安装它,因为它有一个安装向导。之后,我们可以启动它并使用图形用户界面。

我们将创建一个新的测试项目 CatalogAndInventory,并在其中设置两个 REST 模拟服务 CatalogAndInventory,如下面的屏幕截图所示:

对于每个模拟服务,我们设置要匹配的请求以及响应。响应的内容是文本,可以在用户界面的文本字段中键入。重要的是不要忘记将响应的媒体类型设置为application/json(默认为 XML):

在启动服务之前,我们必须将端口号(通过单击齿轮)设置为服务器上可用的端口号。由于 8080 由 Tomcat 服务器使用并由 Gradle 执行,而 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 的用途。我们已经安装了这个工具,我们已经启动了它,并且在其中运行了一些模拟服务。下一步是将一个新的 REST 服务从 URI 添加到项目中,并指定 URLhttp://localhost:8080/pi/{id},方法与我们为 Spring 所做的完全相同:

当我们在项目中定义了 REST 服务时,我们可以在套件中创建一个新的测试套件和一个测试用例。然后,我们可以在测试用例中添加一个步骤,使用参数123调用 REST 服务,如果我们修改默认值,它与参数的名称相同,在本例中为id。我们可以使用窗口左上角的绿色三角形运行测试步骤,因为我们已经运行了测试应用和 SoapUI 模拟服务,所以我们应该得到 JSON 的答案。我们必须在响应端选择 JSON;否则,SoapUI 会尝试将响应解释为 XML,而且由于我们有一个 JSON 响应,因此不会产生太多的结果。我们看到的是以下窗口:

这和我们在浏览器中看到的反应是一样的。当我们给计算机编程时,没有奇迹。有时,我们不明白发生了什么,有些事情是如此复杂,他们似乎是一个奇迹,但他们实际上不是。对我们所不知道的一切都有一个解释。在这种情况下,我们当然知道发生了什么,但是为什么在 SoapUI 的屏幕上看到 JSON 比在浏览器上看到更好呢?原因是 SoapUI 可以执行断言,在某些情况下,还可以根据 REST 调用的结果执行进一步的测试步骤,最终结果是简单的 YES 或 NO。测试正常,或者失败。

要添加断言,请单击窗口左下角的断言文本。正如您在前面的屏幕截图中看到的,我已经添加了一个将返回的 JSON 的"title"字段与文本"Bar Stool"进行比较的截图。当我们添加断言时,它建议的默认值是实际返回的值,这只是一个非常方便的特性。

在此之后,再次运行整个测试套件将运行所有测试用例(我们只有一个)和所有测试步骤,一个接一个(同样,我们只有一个),最后它将在 UI 上显示一个绿色的完成条,如下面的屏幕截图所示:

这不是 SoapUI 所能做的一切。这是一个开发良好的测试工具,已经在市场上多年。SoapUI 可以测试 SOAP 服务和 REST 服务,并且可以处理 JMS 消息。您可以在调用或单独的测试中使用这些调用、循环和断言创建多个步骤的测试,如果其他所有操作都失败,您可以通过使用 Groovy 语言创建编程步骤或使用 Java 创建扩展来做任何事情。

Servlet 过滤器

到现在为止,服务应该很好,任何人都可以查询我们产品的详细信息。这可能是个问题。产品的细节不一定是公开信息。我们必须确保只向有资格查看数据的合作伙伴提供数据。

为了确保这一点,我们在请求中需要一些东西来证明请求来自合作伙伴。这些信息通常是密码或其他一些秘密。它可以放入GET请求参数或 HTTP 请求头中。最好把它放在标题里,因为信息是保密的,任何人都看不见。

GET 参数是 URL 的一部分,浏览器历史会记住这一点。将这些信息输入浏览器位置窗口、复制/粘贴并通过聊天频道或电子邮件发送也非常容易。这样,应用的用户如果没有受过这样的教育,也不关心安全性,可能会泄露机密信息。尽管对 HTTP 标头中发送的信息进行同样的处理并非不可能,但这种情况不太可能发生。如果信息在邮件头中,并且有人通过电子邮件发送了这些信息,他们可能知道自己在做什么;他们是自愿跨越安全边界的,而不是简单的疏忽。

为了沿着 HTTP 请求发送认证信息,Spring 提供了一个安全模块,可以使用注解和配置 XML 和/或类轻松配置该模块。这一次,我们将以不同的方式引入 Servlet 过滤器。

我们将要求供应商将X-PartnerSecret标题插入请求。这是一个非标准头,因此必须有X-前缀。遵循此方法还提供了额外的安全特性。这样,我们可以防止用户使用简单的浏览器访问服务。至少,需要额外的插件,可以插入自定义头或其他程序,如 SoapUI。这样,它将确保我们的合作伙伴将以编程方式使用接口,或者如果他们需要临时测试接口,只有具有一定技术水平的用户才能这样做。这对于控制支持成本非常重要。

由于每个服务都必须检查这个秘密,所以最好不要在每个服务控制器中插入检查代码。即使我们正确地创建代码,并将对机密的检查考虑到一个单独的类中,断言机密存在并且正确的方法调用也必须插入到每个控制器中。控制器执行服务;检查客户端的真实性是一个基础设施问题。它们是不同的关注点,因此,它们必须分开。

Servlet 标准为我们提供的最好的方法是通过 Servlet 过滤器。如果配置了过滤器,Servlet 过滤器是由 Servlet 容器在 Servlet 自身之前调用的类。过滤器可以在 Servlet 容器的web.xml配置文件中配置,也可以在使用 SpringBoot 时使用注解进行配置。过滤器不仅获取作为参数的请求和响应,而且还获取第三个FilterChain类型的参数,该参数应用于调用 Servlet 或链中的下一个过滤器。

可以定义多个过滤器,它们会被链接起来。过滤器可自行决定是否调用链中的下一个过滤器:

我们将 Servlet 过滤器放入应用的auth子包中:

package packt.java11.mybusiness.productinformation.auth;

import ...

@Component
public class AuthFilter implements Filter {
    public static final int NOT_AUTHORIZED = 401;
    private static Logger log = LoggerFactory.getLogger(AuthFilter.class);

    @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 (true || "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。如果秘密是不可接受的,那么我们就不打电话给下一个。相反,我们将未授权的 HTTP 错误返回给客户端。

在这个应用中,秘密非常简单。这是常量字符串packt。这其实不是什么大秘密,尤其是现在这本书已经出版了。一个真实的应用需要一些更隐秘、更鲜为人知的东西。很可能每个合伙人都会使用不同的秘密,而且秘密必须不时地改变。

当我们的程序处理的 Servlet 中存在错误条件时,使用 HTTP 错误处理机制是一种很好的做法。我们不需要发回状态码为 200 OK 的消息,例如用 JSON 格式解释认证不成功,而是发回 401。这由标准定义,不需要任何进一步的解释或文件。

我们的程序还剩下一件事,那就是审计日志记录。

审计日志和 AOP

我们已经登录了我们的示例代码,为此,我们使用了 slf4j,我们在上一章中介绍了它。日志记录或多或少是开发人员的决定,支持技术级别的操作。在这里,我们还谈到了一些句子审计日志。这种类型的日志记录通常在功能需求中明确要求。

通常,AOP 将代码功能的不同切面分离为单独的代码片段,并相互独立地实现它们。这是一个非常单一的责任原则。这次,它的实现方式不仅是不同功能单独实现的,而且我们可以将它们连接在一起。这是单独定义的。在其他部分分别编码并获得 Spring 配置之前和之后执行什么?我们已经看到类似的东西了。类需要正确操作的依赖关系在单独的段(XML 或 Java 代码)中定义。对于 AOP,同样使用 Spring 也不奇怪。切面在配置文件或类中配置。

审计日志记录是一个典型的切面,我们将以它为例。有许多主题可以使用切面来实现,其中一些甚至值得通过这种方式实现。

我们不希望在每个需要审计日志的业务方法或类中实现审计日志代码。相反,我们实现了一个通用切面并配置了连接,以便每当调用需要审计日志记录的 bean 方法时,Spring 就会调用审计日志记录。

对于 AOP,我们还应该了解其他一些重要的术语,特别是如何在 Spring 中配置 AOP。

首先也是最重要的是切面。这是我们想要实现的功能,在我们的示例中是审计日志记录。

连接点是调用切面时的执行点。在 Java 中全面使用切面解决方案修改生成的类的字节码时,连接点几乎可以是任何东西。它可以是对字段的访问,读或写;它可以是对方法的调用或异常抛出。在 Spring 的情况下,不会修改类字节码;因此,Spring 无法识别对字段的访问或抛出的异常。使用 Spring,调用方法时总是使用连接点。

一条建议是如何在连接点调用切面。它可以在建议前,建议后,或周围的建议。如果通知在前面,则在调用方法之前调用切面。当通知在之后时,在调用方法之后调用切面。Around 意味着在方法调用之前调用切面,切面也有一个参数来调用方法,并且在方法调用之后仍然执行一些操作。这样,环绕建议与 Servlet 过滤器非常相似。

在方法调用之前调用事先通知,在它返回之后,框架将调用该方法。切面无法阻止调用原始方法。唯一的例外是当切面抛出异常时。

事后通知也受异常的影响。返回后的通知可以在方法返回时调用。只有当方法抛出异常时才调用抛出后通知。最后,在异常或返回的情况下调用事后通知。

切入点是一个特殊的字符串表达式,用于标识连接点。切入点表达式可以匹配零个、一个或多个连接点。当切面与切入点表达式相关联时,框架将知道连接点以及何时何地调用切面。换句话说,切入点是一个字符串,它告诉您何时以及为哪个方法调用切面。

尽管 AOP 的 Spring 实现不使用 AspectJ,也不修改为类创建的字节码,但它支持切入点表达式语言。尽管这种表达式语言提供了比 Spring 实现的更多的特性,但它是一种成熟的、广泛使用和接受的用于描述切入点的表达式语言,发明新的东西是没有意义的。

序言是向已经存在的类型添加方法或字段,并在运行时添加。Spring 允许此 AOP 功能向现有类型添加接口,并以建议类的形式添加接口的实现。在我们的示例中,我们不使用此功能。

目标对象是切面建议的对象。这是包含关于切面的方法的 bean,即在调用切面之前或之后。

那只是一组浓缩的定义,就像在数学书中一样。如果你读到这篇文章还没明白,别担心。我第一次读的时候也不明白。这就是为什么我们有下面的例子,在这些例子之后,我们刚刚讨论的内容将更有意义:

package packt.java11.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");
        var 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(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,我们只是不关心第一个参数的类型。

切入点表达式可以更复杂,将匹配表达式与&&(and)和||(or)逻辑运算符连接在一起,或者使用!(否定)一元运算符。

使用@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可以用来限制切面连接属于某些包中类的点,或者@targetPCD 可以用来限制对象中的方法匹配,这些对象的注解在切入点表达式中的关键字@target之后在()之间给出。

Spring 使用的 PCD 在 AspectJ 中并不存在。这是一颗豆子。您可以定义一个包含bean(name pattern)的切入点表达式,将连接点限制为指定 bean 中的方法执行。模式可以是全名,也可以像几乎所有匹配的 PCD 表达式一样,*可以是小丑角色。

基于动态代理的 AOP

当 SpringAOP 第一次出现在 Java 程序员面前时,它看起来很神奇。我们如何有一个变量classX并调用该对象上的方法?相反,它在方法执行之前或之后执行某些切面,甚至在其周围执行某些切面,以拦截调用。

Spring 使用的技术称为动态代理。当我们有一个实现接口的对象时,我们可以创建另一个对象——代理对象——也实现该接口,但是每个方法实现都调用一个名为处理器的不同对象,实现 JDK 接口InvocationHandler。当代理对象上调用接口方法时,它将在处理器对象上调用以下方法:

public Object invoke(Object target, Method m, Object[] args)

此方法可以自由执行任何操作,甚至可以使用原始或修改的参数调用目标对象上的原始方法:

当我们手头没有要代理的类实现的接口时,我们不能使用 JDK 方法。幸运的是,有广泛使用的库,比如cglib,Spring 也使用这些库来做类似的事情。Cglib可以创建一个代理对象来扩展原始类并实现其方法,以类似于 JDK 版本对接口方法的方式调用 handler 对象的 invoke 方法。

这些技术在运行时创建类并将其加载到 Java 内存中,它们是非常深入的技术工具。它们是高级主题。我并不是说当我还是一个 Java 程序员新手的时候不要玩它们。毕竟,会发生什么?Java 不是一把上膛的枪。然而,重要的是,当你不了解一些细节或者一开始有些东西不起作用时,不要失去兴趣。或者第二个。或者第三。。。继续游泳。

Spring 中的 AOP 实现通过为目标对象生成代理对象来工作,处理器调用我们在 Spring 配置中定义的切面。这就是您不能将切面放在final类或final方法上的原因。此外,您不能在privateprotected方法上配置切面。原则上,protected方法可以被代理,但这不是一个好的实践,因此 Spring AOP 不支持它。类似地,不能将切面放在不是 SpringBean 的类上。它们是由代码直接创建的,而不是通过 Spring 创建的,并且在创建对象时没有机会返回代理而不是原始对象。简单地说,如果不要求 Spring 创建对象,它就不能创建自定义对象。我们最不想做的就是执行这个程序,看看切面是如何执行的。审计日志的实现非常简单。我们使用标准日志,这对于审计日志的实际应用来说是不够的。我们所做的唯一特殊的事情是使用一个由名称AUDIT_LOG而不是类名称标识的记录器。在大多数日志框架中,这是对日志记录器的合法使用。尽管我们通常使用类来标识记录器,但是使用字符串来标识记录器是绝对可能的。在我们的日志记录中,这个字符串也将被打印在控制台的日志行中,并且它将在视觉上突出。

考虑以下命令:

gradle -Dspring.profiles.active=production bootRun

如果我们用前面的命令启动应用,为项目启动 SoapUI,启动模拟服务,并执行测试,我们将看到 Aspects 在控制台上打印的以下日志行:

2023-10-07 23:42:07.559  INFO 74643 --- [nio-8080-exec-1] o.a.c.c.C.[Tomcat].[localhost].[/]       : Initializing Spring FrameworkServlet 'dispatcherServlet'
2023-10-07 23:42:07.567  INFO 74643 --- [nio-8080-exec-1] o.s.web.servlet.DispatcherServlet        : FrameworkServlet 'dispatcherServlet': initialization started
2023-10-07 23:42:07.626  INFO 74643 --- [nio-8080-exec-1] o.s.web.servlet.DispatcherServlet        : FrameworkServlet 'dispatcherServlet': initialization completed in 59 ms
2023-10-07 23:42:07.629  INFO 74643 --- [nio-8080-exec-1] p.j.b.e.m.p.auth.AuthFilter              : Partner secret is packt
2023-10-07 23:42:07.655  INFO 74643 --- [nio-8080-exec-1] AUDIT_LOG                                : byId query is about to run
2023-10-07 23:42:07.666  INFO 74643 --- [nio-8080-exec-1] AUDIT_LOG                                : url is to be created
2023-10-07 23:42:07.691  INFO 74643 --- [nio-8080-exec-1] AUDIT_LOG                                : url created was http://localhost:8083/inventory/{id}
2023-10-07 23:42:07.715  INFO 74643 --- [nio-8080-exec-1] p.j.b.e.m.p.l.RestClientProductLookup    : amount {id: 123, amount: 100}.
2023-10-07 23:42:07.716  INFO 74643 --- [nio-8080-exec-1] p.j.b.e.m.p.l.RestClientProductLookup    : There items from 123\. We are offering
2023-10-07 23:42:07.716  INFO 74643 --- [nio-8080-exec-1] AUDIT_LOG                                : url is to be created
2023-10-07 23:42:07.716  INFO 74643 --- [nio-8080-exec-1] AUDIT_LOG                                : url created was http://localhost:8081/product/{id}
2023-10-07 23:42:07.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 调用脚本

我的业务订单

订购过程比仅仅查找产品要复杂一些。订单表单本身列出产品和金额,并标识该订单的客户。我们所要做的就是检查产品是否在我们的商店有售,以及我们是否可以将它们交付给特定的客户。这是最简单的方法;但是,对于某些产品,有更多的限制。例如,当有人订购台灯时,我们会单独提供电源线。这是因为电源线是特定于国家的。我们向英国和德国提供不同的电源线。一种可能的方法是确定客户的国家。但这种方法没有考虑到我们的客户是转售商这一事实。所有的客户都可以在英国,同时,他们可能希望将灯与电力电缆一起运送到德国。为了避免这种情况和模棱两可,我们的客户最好将台灯和电源线作为单独的项目在同一订单中订购。在某些情况下,我们提供的台灯没有电源线,但这是一个特殊的情况。我们需要一定程度的逻辑来识别这些特殊情况。因此,我们必须执行逻辑,看看是否有一个台灯电源线,如果没有自动处理的命令,它被拒绝。这并不意味着我们将不交付产品。我们只需将订单放入队列中,运算符就必须查看它。

这种方法的问题在于,台灯只是一种需要配置支持的产品。我们拥有的产品越多,他们可能拥有的专业性就越强,检查订单一致性的代码也变得越来越复杂,直到达到无法管理的复杂程度。当一个类或方法变得太复杂时,程序员会对其进行重构,将该方法或类拆分为更小的部分。我们在产品检验方面也必须这样做。我们不应该试图创建一个庞大的类来检查产品和所有可能的订单星座,而是应该有许多较小的检查,以便每个检查只检查一个小集合。

在某些情况下,检查一致性比较简单。检查灯是否有电源线对于任何一个新手程序员来说都很复杂。我们在代码中使用这个示例是因为我们希望关注代码的实际结构,而不是检查本身的复杂性质。然而,在现实生活中,检查可能相当复杂。想象一下一家卖电脑的商店。它将一个配置放在一起:电源、图形卡、主板、适当的 CPU 和内存。有很多选择,其中一些可能无法协同工作。在现实生活中,我们需要检查主板是否与所选内存兼容,是否有按顺序排列的尽可能多的内存组,它们是否正确配对(有些内存只能成对安装),是否有图形卡的兼容插槽,而且电源有足够的瓦特来可靠地运行整个配置。这非常复杂,最好不要与检查灯是否有电源线的代码混淆。

设置项目

由于我们仍在使用 SpringBoot,构建文件不需要任何修改;我们将使用与上一章相同的文件。然而,包的结构有点不同。这一次,我们做的事情比获取请求和响应后端服务提供给我们的任何内容都要复杂。现在,我们必须实现复杂的业务逻辑,正如我们将看到的,它需要许多类。当我们在一个特定的包中有 10 个以上的类时,是时候考虑把它们放在不同的包中了。相互关联并具有类似功能的类应该放在一个包中。这样,我们就有了以下产品的包装:

  • 控制器(虽然在本例中我们只有一个,但通常有更多)
  • 数据存储 bean,除了存储数据之外没有其他功能,因此是字段、设置器和获取器
  • 检查器,将帮助我们在订购桌面台灯时检查电源线
  • 为控制器执行不同服务的服务
  • 我们程序的主包,包含Application类、SpringConfiguration和几个接口

订单控制器和 DTO

当服务器请求订购一系列产品时,它会收到 HTTPSPOST请求。请求的主体是用 JSON 编码的。到目前为止,我们有控制器在处理GET参数。当我们可以依赖 Spring 的数据封送时,处理POST请求就不难了。控制器代码本身很简单:

package packt.java11.bulkorder.controllers;

import ...

@RestController
public class OrderController {
    private static final 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类是一个简单的 bean,只列出以下项:

package packt.java11.bulkorder.dtos;

import ...

public class Order {
    private String orderId;
    private List<OrderItem> items;
    private String customerId;

    // ... setters and getters ...
}

包的名称为dtos,代表数据传输对象DTO)的复数形式。DTO 是用于在不同组件(通常通过网络)之间传输数据的对象。由于另一方可以用任何语言实现,封送可以是 JSON、XML 或其他一些只能传递数据的格式。这些类没有真正的方法。DTO 通常只有字段、设置器和获取器。

以下是包含订单中一个项目的类:

package packt.java11.bulkorder.dtos;

public class OrderItem {
    private double amount;
    private String unit;
    private String productId;

    // ... setters and getters ...
}

订单确认也在这个包中,虽然这也是一个真正的 DTO,但它有几个简单的辅助方法:

package packt.java11.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.java11.bulkorder;

import packt.java11.bulkorder.dtos.Order;

public interface ConsistencyChecker {

    boolean isInconsistent(Order order);
}

如果顺序不一致,方法isInconsistent应该返回true。如果不知道订单是否不一致,则返回false,但从实际检查者检查订单的角度来看,不存在不一致。有几个ConsistencyChecker类,我们必须一个接一个地调用,直到其中一个返回true,否则我们就没有这些类了。如果没有一个返回true,那么我们可以安全地假设,至少从自动检查器的角度来看,顺序是一致的。

我们知道,在开发之初,我们将有很多一致性检查,并不是所有的订单都相关。我们希望避免为每个订单调用每个检查器。为此,我们实现了一些过滤。我们让产品指定他们需要什么类型的检查。这是一段产品信息,如尺寸或描述。为了适应这种情况,我们需要扩展ProductInformation类。

我们将创建每个ConsistencyChecker接口,将类实现为一个 SpringBean(用@Component注解进行注解),同时,我们将用一个注解对它们进行注解,该注解指定它们实现的检查类型。同时,ProductInformation被扩展,包含一组Annotation类对象,这些对象指定要调用哪些检查器。我们可以简单地列出检查器类,而不是注解,但是这给了我们在配置产品和注解之间的映射时更多的自由。注解指定产品的性质,并对检查器类进行注解。台灯是PoweredDevice类型,检查器类NeedPowercord@PoweredDevice注解。如果有任何其他类型的产品也需要电源线,那么该类型的注解应该添加到NeedPowercord类中,我们的代码就可以工作了。既然我们开始深入研究注解和注解处理,我们就必须首先了解注解到底是什么。我们从第 3 章“优化专业排序代码”开始就已经使用了注解,但我们所知道的只是如何使用它们,如果不了解我们所做的事情,这通常是危险的。

注解

注解前面带有@字符,可以附加到包、类、接口、字段、方法、方法参数、泛型类型声明和用法,最后附加到注解。注解几乎可以在任何地方使用,它们被用来描述一些程序元信息。例如,@RestController注解不会直接改变OrderController类的行为。类的行为由其内部的 Java 代码描述。注解有助于 Spring 理解类是什么以及如何使用它。当 Spring 扫描所有包和类以发现不同的 SpringBean 时,它会看到类上的注解并将其考虑在内。这个类上可能还有 Spring 不理解的其他注解。它们可能被其他框架或程序代码使用。Spring 将它们视为任何行为良好的框架。例如,正如我们稍后将看到的,在我们的代码库中,我们有一个NeedPowercord类,它是一个 SpringBean,因此用@Component注解进行了注解。同时,还附有@PoweredDevice注解。Spring 不知道什么是电动设备。这是我们定义和使用的东西。Spring 忽略了这一点。

包、类、接口、字段等可以附加许多注解。这些注解应该简单地写在它们所附加的语法单元声明的前面。

对于包,注解必须写在package-info.java文件中包名的前面。这个文件可以放在包的目录中,可以用来编辑包的JavaDoc,也可以给包添加注解。此文件不能包含任何 Java 类,因为名称package-info不是有效的标识符。

我们不能在任何东西前面写任何东西作为注解。应声明注解。它们在 Java 特殊接口的运行时。例如,声明@PoweredDevice注解的 Java 文件如下所示:

package packt.java11.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类型,即enum。我们将很快讨论如何定义注解参数。

有趣的是,注解接口上的@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 ""; 
}

由于我们指定的值应该是常量并且在编译时是可计算的,所以复杂类型的使用并不多。注解参数通常是字符串、整数,有时还包括double或其他基本类型。语言规范给出的确切类型列表如下:

  • 原始类型(doubleint等)
  • 字符串
  • 枚举
  • 另一个注解
  • 上述任何一种类型的数组

我们已经看到了String的例子,而且enumRetentionTarget都有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 严格遵循的向后兼容性的一个例子。注解是在 Java1.5 中引入的,可重复的注解只有在 1.8 版本之后才可用。我们将很快讨论在运行时用于处理注解的反射 API。java.lang.reflect.AnnotatedElement接口中的这个 API 有一个getAnnotation(annotationClass)方法,它返回一个注解。如果单个注解可以在一个类、方法等上出现多次,则无法调用此方法来获取具有所有不同参数的所有不同实例。通过引入包装多个注解的包含类型,确保了向后兼容性。

注解继承

注解,就像方法或字段一样,可以在类层次结构之间继承。如果一个注解声明被标记为@Inherited,那么用这个注解扩展另一个类的类可以继承它。如果子类具有注解,则可以覆盖注解。因为 Java 中没有多重继承,所以不能继承接口上的注解。即使继承了注解,检索特定元素注解的应用代码也可以区分继承的注解和在实体本身上声明的注解。有两种方法可以获取注解,另外两种方法可以获取在实际元素上声明的、未继承的已声明注解。

@Documented注解

@Documented注解表示注解是实体合同的一部分的意图,因此必须进入文档。这是一个注解,当为引用@Documented注解的元素创建文档时,JavaDoc 生成器将查看该注解。

JDK 注解

除了用于定义注解的注解外,JDK 中还定义了其他注解。我们已经看到了其中的一些。最常用的是@Override注解。当编译器看到此注解时,它会检查该方法是否确实覆盖了继承的方法。否则将导致一个错误,使我们免于痛苦的运行时调试。

方法、类或其他元素的文档中的注解信号,表示不使用该元素。代码中仍然存在,因为有些用户可能仍然使用它,但是如果是依赖于包含元素的库的新开发,新开发的代码不应该使用它。注解有两个参数。一个参数是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.java11.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 {
        final var mapper = new ObjectMapper();
        return mapper.readValue(jsonStream, ProductInformation.class);
    }

// ...
    private void loadProducts() {
        if (productsAreNotLoaded) {
            try {
                final var resources = new PathMatchingResourcePatternResolver().
                        getResources("classpath:products/*.json");
                for (final var 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 var dotPos = resource.getFilename().lastIndexOf('.');
        final var id = resource.getFilename().substring(0, dotPos);
        final var pi = fromJSON(resource.getInputStream());
        pi.setId(id);
        products.put(id, pi);
        if( pi.getCheck() != null )
        log.info("Product {} check is {}",id,pi.getCheck().get(0));
    }
// ...

project resources/products目录中,我们有一些 JSON 文件。其中一个包含台灯产品信息:

{ 
  "id" : "124", 
  "title": "Desk Lamp", 
  "check": [ 
    "packt.java11.bulkorder.checkers.PoweredDevice" 
  ], 
  "description": "this is a lamp that stands on my desk", 
  "weight": "600", 
  "size": [ "300", "20", "2" ] 
}

产品的类型是在 JSON 数组中指定的。在本例中,此数组只有一个元素,该元素是表示产品类型的注解接口的完全限定名。当 JSON Marshaller 将 JSON 转换为 Java 对象时,它会识别出需要此信息的字段是一个List,因此它会将数组转换为一个列表,以及从StringClass对象中表示注解接口的元素。

现在我们已经从 JSON 格式的资源中加载了资源,并且我们已经看到了在使用 Spring 时读取 JSON 数据是多么容易,我们可以回到顺序一致性检查。Checker类实现了收集可插入检查器并调用它们的逻辑。它还实现了基于注解的过滤,以避免调用我们在实际订单中实际产品并不需要的检查:

package packt.java11.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) {
        final var map = piCollector.collectProductInformation(order);
        if (map == null) {
            return false;
        }
        final var annotations = pcCollector.getProductAnnotations(order);
        for (final var checker : checkers) {
            for (final var 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.java11.bulkorder.checkers;

//SNIPPET SKIL TILL "import ..."

import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.stereotype.Component;
import packt.java11.bulkorder.ConsistencyChecker;
import packt.java11.bulkorder.dtos.Order;

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);
        var helper = new CheckHelper(order);
        return !helper.containsOneOf("126", "127", "128");
    }
}

助手类包含许多检查器需要的简单方法,例如:

public boolean containsOneOf(String... ids) {
    for (final var item : order.getItems()) {
        for (final var 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) {
    final var methods = checker.getClass().getDeclaredMethods();
    if (methods.length != 1) {
        log.error("The checker {} has zero or more than one methods",
            checker.getClass());
        return false;
    }
    final var 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

关于表演魔术,这是一种自然的冲动,我们觉得要做一些非凡的东西,一些杰出的。当我们尝试一些事情,做一些有趣的事情时,这是可以的,但是当我们从事专业工作时,这绝对是不可以的。一般的程序员,如果不了解您的优秀解决方案,就会在企业环境中维护代码。他们会在修复一些 bug 或实现一些小的新特性的同时,把你精心梳理的代码变成草堆。即使你是编程界的莫扎特,他们充其量也只是无名歌手。在企业环境中,一个优秀的代码可以是一首安魂曲,包含了隐喻所包含的所有含义。

最后但同样重要的是,可悲的现实是,我们通常不是编程的莫扎特。

请注意,如果原始值的返回值是原始类型,那么它将通过反射转换为对象,然后我们将它转换回原始值。如果方法没有返回值,换句话说,如果它是void,那么反射将返回java.lang.Void对象。Void对象只是一个占位符。我们不能将它转换为任何原始类型值或任何其他类型的对象。它是必需的,因为 Java 是严格的,invoke必须返回一个Object,所以运行时需要一些它可以返回的东西。我们所能做的就是检查返回值类是否真的是Void

让我们继续我们的故事和解决方案。我们提交了代码,它在生产中运行了一段时间,直到一个软件供应商的新更新打破它。我们在测试环境中调试代码,发现类现在包含多个方法。我们的文档清楚地说明了他们应该只有一个public方法,并且他们提供了一个代码,这个代码有……嗯……我们意识到其他方法是private。他们是对的,根据合同他们可以有private方法,所以我们必须修改代码。我们替换查找唯一方法的行:

final var methods = checker.getClass().getDeclaredMethods(); 
if (methods.length != 1) { 
... 
} 
final var method = methods[0];

新代码如下:

final var 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 var methods = checker.getClass().getDeclaredMethods();
    Method singleMethod = null;
    for (final var method : methods) {
        if (Modifier.isPublic(method.getModifiers())) {
            if (singleMethod != null) {
                return null;
            }
            singleMethod = method;
        }
    }
    return singleMethod;
}

为了检查方法是否为public,我们使用了Modifier类中的static方法。有一些方法可以检查所有可能的修饰符。getModifiers()方法返回的值是int位字段。不同的位有不同的修饰符,有常量定义这些修饰符。只可用于其他类型反射对象的位永远不会被设置。

有一个例外,那就是volatile。该位被重新用于信号桥方法。桥接方法是由编译器自动创建的,并且可能有一些我们在本书中没有讨论的深层次和复杂的问题。重复使用同一位不会造成混淆,因为字段可以是volatile,但作为字段,它不能是桥接方法。显然,字段是字段而不是方法。同样地,方法不能是volatile字段。一般规则如下:不要在反射对象没有意义的地方使用方法;否则,要知道你在做什么。

一个新版本的检查器意外地将check方法实现为一个private包,这使得故事情节更加复杂,程序员只是忘记了使用public关键字。为了简单起见,让我们假设类再次只声明一个方法,但它不是公共的。我们如何使用反射来解决这个问题?

显然,最简单的解决方案是要求供应商解决问题-这是他们的错。然而,在某些情况下,我们必须为某些问题创建一个解决方案。另一种解决方案是在同一个包中创建一个具有public方法的类,从另一个类调用private包方法,从而中继另一个类。事实上,这个解决方案,作为这样一个 bug 的解决方案,似乎更符合逻辑,更清晰,但是这次,我们希望使用反射。

为了避免java.lang.IllegalAccessException,我们必须将method对象设置为可访问。为此,我们必须在调用前插入以下行:

method.setAccessible(true);

注意,这不会将方法更改为public。它只会通过我们设置为可访问的method对象的实例来访问调用方法。

我见过这样的代码:通过调用isAccessible()方法检查方法是否可访问,并保存此信息;如果方法不可访问,则将其设置为可访问,并在调用后恢复原始的可访问性。这完全没用。一旦method变量超出范围,并且没有对设置可访问性标志的对象的引用,设置的效果就会消失。另外,设置一个public或一个其他可调用方法的可访问性也不会受到惩罚。

设置字段

我们还可以对Field对象调用setAccessible,然后我们甚至可以使用反射设置私有字段的值。没有更多的假故事,就为了这个例子,让我们制作一个名为SettableCheckerConsistencyChecker

@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

在编写异常抛出测试时,我们已经在第 3 章中使用了 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(); 
});

或者,我们可以这样写:

sort.setComparator((var a, var b) -> { 
    throw new RuntimeException(); 
});

参数ab将具有所需的类型。为了更简单,如果只有一个参数,我们还可以省略参数周围的()字符。

如果有多个参数,则括号不是可选的。这是为了避免在某些情况下出现歧义。例如,方法调用f(x,y->x+y)可能是一个具有两个参数的方法—x,以及一个具有一个参数y的 Lambda 表达式。同时,它也可以是一个具有 Lambda 表达式的方法调用,Lambda 表达式有两个参数,xy。当有多个参数并且编译器可以计算参数的类型时,自 Java11 发布以来就可以使用var关键字。

当我们想将函数作为参数传递时,Lambda 表达式非常方便。方法声明处参数类型的声明应为函数式接口类型。这些接口可以选择使用@FunctionalInterface进行注解。Java 运行时在java.util.function包中定义了许多这样的接口。我们将在下一节讨论其中的一些,以及它们在流中的使用。对于其余部分,标准 Java 文档可从 Oracle 获得。

流在 Java8 中也是新的,就像 Lambda 表达式一样。他们一起工作非常强烈,所以他们的出现在同一时间并不令人惊讶。Lambda 表达式以及流都支持函数式编程风格。

首先要澄清的是,流与输入和输出流没有任何关系,除了名称。它们是完全不同的东西。流更像是具有一些显著差异的集合。(如果没有区别,它们就只是集合。)流本质上是可以顺序或并行运行的操作管道。他们从收集或其他来源获得数据,包括动态制造的数据。

流支持对多个数据执行相同的计算。该结构称为单指令多数据SIMD)。别害怕这个表情。这是一件非常简单的事情。这本书我们已经做了很多次了。循环也是一种 SIMD 结构。当我们循环检查类以查看其中是否有一个反对该顺序时,我们对每个和每个检查程序执行相同的指令。多个检查器意味着多个数据。

循环的一个问题是,我们定义了不需要的执行顺序。在跳棋的情况下,我们并不关心跳棋的执行顺序。我们关心的是,所有人都同意这个命令。在编程循环时,我们仍然指定一些顺序。这来自循环的本质,我们无法改变这一点。他们就是这样工作的。然而,如果我们能,不知何故,说“对每个检查者做这个和那个”,那就太好了。这就是流发挥作用的地方。

另一点是,使用循环的代码更重要,而不是描述性的。当我们阅读循环构造的程序时,我们将重点放在各个步骤上。我们首先看到循环中的命令是做什么的。这些命令作用于数据的单个元素,而不是整个集合或数组。

当我们在大脑中把各个步骤放在一起时,我们就会意识到什么是大局,什么是循环。在流的情况下,操作的描述更高一级。一旦我们学习了流方法,就更容易阅读了。流方法作用于整个流而不是单个元素,因此更具描述性。

java.lang.Stream是一个接口。具有实现此接口的类型的对象表示许多对象,并提供可用于对这些对象执行指令的方法。当我们开始对其中一个对象执行操作时,这些对象可能不可用,也可能不可用,或者只在需要时创建。这取决于Stream接口的实际实现。例如,假设我们使用以下代码生成一个包含int值的流:

IntStream.iterate( 0, (s) -> s+1 )

在前面的代码段中,无法生成所有元素,因为流包含无限个元素。此示例将返回数字 0、1、2 等,直到其他流操作(此处未列出)终止计算。

当我们编程Stream时,我们通常从Collection创建一个流—不总是,但经常。在 Java8 中扩展了Collection接口,提供了streamparallelStream()方法。它们都返回表示集合元素的流对象。当stream返回元素时,如果存在自然顺序,parallelStream会创建一个可以并行处理的流。在这种情况下,如果我们在流上使用的某些方法是以这种方式实现的,那么代码可以使用计算机中可用的多个处理器。

一旦我们有了一个流,我们就可以使用Stream接口定义的方法。首先是forEach()。此方法有一个参数,通常作为 Lambda 表达式提供,并将为流的每个元素执行 Lambda 表达式。

Checker类中,我们有isConsistent()方法。在这个方法中,有一个循环遍历检查器类的注解。如果要记录循环中注解实现的接口,可以添加以下内容:

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 年前的大部分编程工作。所有这些编程的发展都从如何转向什么。今天,我们用 Java11 编程,这条路还有很长的路要走。我们越能表达我们该做什么,而不是如何做,我们的程序就越短,也越容易理解。它将包含本质,而不是一些人造垃圾,是机器所需要的只是做我们想要的。当我在我必须维护的代码中看到一个循环时,我假设循环的执行顺序有一定的重要性。可能根本不重要。几秒钟后可能很明显。可能需要几分钟或更长时间才能意识到订购并不重要。这种时间是浪费的,可以通过更好地表达要做什么部分而不是如何做**部分的编程构造来节省时间。

函数式接口

方法的参数应该是java.util.function.Consumer。这个接口需要定义accept()方法,这个方法是void。实现此接口的 Lambda 表达式或类将使用方法 T3 的参数而不产生任何结果。

该包中还定义了其他几个接口,每个接口都用作函数式接口,用于描述一些方法参数,这些参数可以在实际参数中作为 Lambda 表达式给出。

例如,Consumer的对立面是Supplier。这个接口有一个名为get()的方法,它不需要任何参数,但是它给出了一些Object作为返回值。

如果有一个参数和一个返回值,则该接口称为Function。如果返回值必须与参数的类型相同,那么UnaryOperator接口就是我们的朋友。类似地,还有一个BinaryOperator接口,它返回一个与参数类型相同的对象。正如我们从FunctionUnaryOperator一样,我们可以看到在另一个方向上,也有BiFunction,以防参数和返回值不共享类型。

这些接口不是相互独立定义的。如果一个方法需要Function,而我们有UnaryOperator要通过,那应该不是问题。UnaryOperatorFunction基本相同,参数类型相同。一个可以与接受一个对象并返回一个对象的Function一起工作的方法,如果它们具有相同的类型,应该不会有问题。这些可以是,但不一定是,不同的。为了实现这一点,UnaryOperator接口扩展了Function,因此可以用来代替Function

到目前为止,我们遇到的这个类中的接口是使用泛型定义的。因为泛型类型不能是原始类型,所以操作原始值的接口应该单独定义。例如,Predicate是定义booleantest(T t)的接口。它是一个返回boolean值的函数,常用于流方法。

还有一些接口,例如BooleanSupplierDoubleConsumerDoubleToIntFunction等等,它们与原始类型booleandoubleint一起工作。不同参数类型和返回值的可能组合的数量是无限的。。。几乎。

有趣的事实:确切地说,它不是无限的。 一个方法最多可以有 254 个参数。 此限制是在 JVM 中指定的,而不是在 Java 语言规范中指定的。 当然,一个没有另一个就没有用。 有 8 种原始类型(加上“对象”,再加上少于 254 个参数的可能性),这意味着可能的函数时接口总数为10 ** 254,给出或取几个幅度。 几乎是无限的!

我们不应该期望在这个包的 JDK 中定义所有可能的接口。这些只是最有用的接口。例如,没有使用shortchar的接口。如果我们需要这样的东西,那么我们可以在代码中定义interface。或者只是仔细想想,找出如何使用一个已经定义好的。(我在职业生涯中从未使用过short型号。从来就不需要它。)

这些函数式接口是如何在流中使用的?Stream接口定义了一些函数式接口类型作为参数的方法。例如,allMatch()方法有一个Predicate参数并返回一个Boolean值,如果流中的所有元素都匹配Predicate,则返回的值就是true。换句话说,当且仅当作为参数提供的Predicate为流的每个元素返回true时,此方法才返回true

在下面的代码中,我们将重写我们在示例代码中使用循环来使用流实现的一些方法,并且通过这些示例,我们将讨论流提供的最重要的方法。我们保存了两个类,ProductsCheckerCollectorProductInformationCollector来演示流的用法。我们可以从这些开始。ProductsCheckerCollector遍历Order中包含的所有产品,并收集产品中列出的注解。每个产品可能包含零个、一个或多个注解。这些在列表中提供。同一注解可以多次引用。为了避免重复,我们使用HashSet,它只包含元素的一个实例,即使产品中有多个实例:

public class ProductsCheckerCollector {
    private static final Logger log =
            LoggerFactory.getLogger(ProductsCheckerCollector.class);

    private final ProductInformationCollector pic;

    public ProductsCheckerCollector
            (@Autowired ProductInformationCollector pic) {
        this.pic = pic;
    }

    public Set<Class<? extends Annotation>> getProductAnnotations(Order order) {
        var piMap = pic.collectProductInformation(order);
        final var annotations = new HashSet<Class<? extends Annotation>>();
        for (var item : order.getItems()) {
            final var pi = piMap.get(item);
            if (pi != null && pi.getCheck() != null) {
                for (final var check : pi.getCheck()) {
                    annotations.addAll(pi.getCheck());
                }
            }
        }
        return annotations;
    }

现在,让我们看看当我们使用流重新编码时,这个方法是如何看待的:

public Set<Class<? extends Annotation>> getProductAnnotations(Order order) {
    var 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(ProductInformation::hasCheck)
            .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()方法进行转换:

return order.getItems().stream()

我们已经简单地提到过,stream()方法是Collection接口的一部分。任何实现Collection接口的类都会有这个方法,即使是那些在 Java8 中引入流之前实现的类。这是因为stream()方法在接口中实现为default方法。这样,如果我们碰巧实现了一个实现这个接口的类,即使我们不需要流,我们也可以免费获得它。

为了支持接口的向后兼容性,引入了 Java8 中的default方法。JDK 的一些接口将被修改以支持 Lambda 和函数式编程。一个例子是stream()方法。在 Java8 之前的特性集中,实现一些修改过的接口的类应该已经被修改过了。他们将被要求实现新方法。这样的变化是不向后兼容的,Java 作为一种语言和 JDK 非常关注向后兼容。为此,介绍了default方法。这使得开发人员可以扩展接口并保持其向后兼容,从而为新方法提供默认实现。与此相反,java8JDK 的全新函数式接口也有default方法,尽管 JDK 中没有以前的版本,它们没有什么可兼容的。在 Java9 中,接口也被扩展,现在它们不仅可以包含defaultstatic方法,还可以包含private方法。这样,接口就相当于抽象类,尽管接口中除了常量static字段外没有其他字段。这个接口功能扩展是一个备受批评的特性,它只会带来允许多类继承的其他语言所面临的编程风格和结构问题。Java 一直在避免这种情况,直到 Java8 和 Java9 出现。
这有什么好处?注意接口中的default方法和private方法。明智地使用它们,如果有的话。

这个流的元素是OrderItem对象。我们需要为每个OrderItem设置ProductInformation

方法引用

幸运的是我们有Map,它将订单项目与产品信息配对,所以我们可以在Map上调用get()

.map(piMap::get)

map()方法与 Java 中的其他方法同名,不应混淆。当Map类是数据结构时,Stream接口中的map()方法执行流元素的映射。该方法的参数是一个Function(回想一下,这是我们最近讨论的一个函数式接口)。此函数将值T转换为值Rmap()方法的返回值为Stream<R>,该值可用作原始流的元素(Stream<T>)。map()方法使用给定的Function<T,R>Stream<T>转换为Stream<R>,为原始流的每个元素调用它,并从转换后的元素创建一个新流。

可以说,Map接口以静态方式将键映射到数据结构中的值,流方法map()动态地将一种值映射到另一种(或相同)类型的值。

我们已经看到可以以 Lambda 表达式的形式提供函数式接口的实例。此参数不是 Lambda 表达式。这是一个方法引用。它说map()方法应该调用Map piMap上的get()方法,使用实际的流元素作为参数。我们很幸运get()也需要一个参数,不是吗?我们也可以这样写:

.map( orderItem ->piMap.get(orderItem))

然而,这与piMap::get完全相同。

这样,我们就可以引用在某个实例上工作的实例方法。在我们的示例中,实例是由piMap变量引用的实例。也可以引用static方法。在这种情况下,类的名称应该写在::字符前面。当我们使用来自Objects类的static方法nonNull时,我们很快就会看到这样一个例子(注意类名是复数形式的,它在java.util包中,而不是java.lang)。

也可以引用实例方法,而不给出应该调用它的引用。这可以在函数式接口方法有一个额外的第一个参数的地方使用,这个参数将用作实例。我们已经在第 3 章中使用过了,“优化专业排序代码”,当我们通过String::compareTo时,当期望的参数是Comparator时。compareTo()方法需要一个参数,而Comparator接口中的compare()方法需要两个参数。在这种情况下,第一个参数将用作必须调用compare()的实例,第二个参数将传递给compare()。在这种情况下,String::compareTo与写入 Lambda 表达式(String a, String b) -> a.compareTo(b)相同。

最后但并非最不重要的一点,我们可以使用构造器的方法引用。当我们需要SupplierObject时,我们可以写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的元素;我们也想去掉有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,这是一种特殊的、不易理解的方法。至少对我来说,当我学习函数式编程时,这比理解map()filter()要困难一些:

.flatMap(pi ->pi.getCheck().stream())

此方法期望 Lambda、方法引用或作为参数传递给它的任何内容为调用该方法的原始流的每个元素创建一个全新的对象流。然而,结果不是流的流,这也是可能的,而是返回的流被连接成一个巨大的流。

如果我们应用它的流是一个整数流,比如 1,2,3,…,并且每个数的函数n返回一个包含三个元素的流nn+1n+2,那么得到的流flatMap()生成一个包含 1,2,3,2,3,4,4,5、6 等等。

最后,我们的流应该被收集到一个Set。这是通过调用collector()方法完成的:

.collect(Collectors.toSet());

collector()方法的参数是(同样,一个过度使用的表达式)Collector。它可以用于将流的元素收集到集合中。注意,Collector不是函数式接口。你不能仅仅用 Lambda 或者简单的方法来收集一些东西。为了收集元素,我们肯定需要一个地方来收集元素,因为不断更新的元素来自流。Collector接口不简单。幸运的是,java.util.streams.Collectors类(同样注意复数形式)有许多static方法创建并返回Object字段,这些字段反过来又创建并返回Collector对象。

其中之一是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) {
        log.info("Collecting product information");
        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已经存在则返回。这种模式被广泛使用,并有一个名字,称为保护。在这种情况下,方法中有多个return语句,这可能被视为一个弱点或反模式。另一方面,该方法的制表法是一个标签浅。这是一个品味的问题,如果你发现自己正处于一个或另一个解决方案的争论中,那么就帮自己一个忙,让你的同伴在这个话题上获胜,并为更重要的问题节省精力,例如,你应该使用流还是简单的旧循环。

现在,让我们看看如何将此解决方案转换为函数式:

public Map<OrderItem, ProductInformation> collectProductInformation(Order order) {
    if (map == null) {
        log.info("Collecting product information");
        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,它只不过是两个Object实例,分别命名为rs。稍后我们将列出这个类的代码。这很简单。

在流表达式中,我们首先从集合中创建流,然后将OrderItem元素映射到一个由OrderItemproductId元组组成的流。然后,我们将这些元组映射到现在包含OrderItemProductInformation的元组。这两个映射可以在一个映射调用中完成,该调用将在一个映射调用中执行这两个步骤。我决定在每一行中创建两个简单的步骤,希望得到的代码更容易理解。

过滤步骤也不是什么新鲜事。它只是过滤掉无效的产品信息元素。实际上应该没有。如果订单包含不存在产品的订单 ID,则会发生这种情况。在下一个语句中,当我们查看收集的产品信息元素的数量,以确定所有项目都具有适当的信息时,就会检查这一点。

有趣的代码是我们如何将流的元素收集到一个Map中。为此,我们再次使用collect()方法和Collectors类。这次,toMap()方法创建Collector。这需要两个结果表达式。第一个应该将流的元素转换为键,第二个应该生成要在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) {
    var map = piCollector.collectProductInformation(order);
    if (map == null) {
        return false;
    }
    final var as = pcCollector.getProductAnnotations(order);
    return !checkers.stream().anyMatch(
            c -> Arrays.stream(c.getClass().getAnnotations()
            ).filter(a -> as.contains(a.annotationType())
            ).anyMatch(x -> c.isInconsistent(order)
            ));
}

因为您已经学习了大多数重要的流方法,所以这里几乎没有什么新问题。我们可以提到anyMatch()方法,如果至少有一个元素,则返回true,这样传递给anyMatch()Predicate参数就是true。它可能还需要一些住宿,这样我们就可以使用另一条流中的一条流。这很可能是一个例子,当一个流表达式过于复杂,需要使用局部变量分解成更小的片段。

最后,在离开函数样式之前,我们覆盖了CheckHelper类中的containsOneOf()方法。这不包含新元素,将帮助您检查您对map()filter()flatMap()Collector的了解。请注意,如我们所讨论的,如果order至少包含一个以字符串形式给出的订单 ID,则此方法返回true

public boolean containsOneOf(String... ids) {
    return order.getItems().parallelStream()
        .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 的对,最后,我们将计算流中的元素数。

JavaScript

我们已经准备好了本章的示例程序。有一个问题,尽管它不专业。当我们有一个新产品需要一个新的检查器时,我们必须创建一个新的代码版本。

专业环境中的程序有版本。当修改代码、修复 bug 或实现新功能时,在应用投入生产之前,组织需要执行许多步骤。这些步骤包括释放过程。一些环境具有轻量级的发布过程;另一些环境需要严格且昂贵的检查。然而,这并不取决于组织中人员的偏好。当一个非工作的生产代码的成本很低,并且不管程序中是否有中断或不正确的功能时,那么发布过程可以很简单。这样,发布速度更快,成本更低。一个例子可以是用户用来取乐的聊天程序。在这种情况下,发布新的花哨特性可能比确保无 bug 工作更重要。另一方面,如果你创建了控制原子能发电厂的代码,那么失败的代价可能相当高。对所有特性进行认真的测试和仔细的检查,即使是在最小的更改之后,也会有回报。

在我们的示例中,简单的跳棋可能是一个不太可能导致严重错误的区域。这不是不可能的,但代码是如此简单…是的,我知道这样的论点有点可疑,但让我们假设,这些小例程可以用更少的测试和更简单的方式比其他部分的代码来改变。那么,如何将这些小脚本的代码分离开来,使它们不需要技术版本、应用的新版本,甚至不需要重新启动应用?我们有一个新产品,需要一个新的检查,我们希望有一些方法,注入这个检查到应用环境中,没有任何服务中断。

我们选择的解决方案是脚本。Java 程序可以执行用 JavaScriptGroovyJython(即 JVM 版本的 Python 语言)等多种语言编写的脚本。除了 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类。我们需要一个新类来执行我们的脚本;因此,我们必须插入一个新的final字段,如下所示:

private final CheckerScriptExecutor executor; 

我们还必须通过添加一个新参数来初始化final字段来修改构造器。

我们还必须在isConsistent()方法中使用此executor

public boolean isConsistent(Order order) {
    final var map = piCollector.collectProductInformation(order);
    if (map == null) {
        return false;
    }
    final var annotations = pcCollector.getProductAnnotations(order);
    var needAnntn = (Predicate<Annotation>) an ->
            annotations.contains(an.annotationType());
    var consistent = (Predicate<ConsistencyChecker>) c ->
            Arrays.stream(c.getClass().getAnnotations())
                    .parallel()
                    .unordered()
                    .filter(needAnntn)
                    .anyMatch(x -> c.isInconsistent(order));
    final var checkersOK = !checkers.stream().anyMatch(consistent);
    final var scriptsOK = !map.values().parallelStream().
            map(ProductInformation::getCheckScript).
            filter(Objects::nonNull).
            anyMatch(s -> executor.notConsistent(s, order));
    return checkersOK && scriptsOK;
}

注意,在这段代码中,我们使用并行流,因为,为什么不呢?只要有可能,我们就可以使用并行流(即使是无序的)来告诉底层系统,以及维护代码的程序员,顺序并不重要。

我们还修改了一个产品 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 解释器自 Java7 以来一直是 JDK 的一部分,但它仍然以抽象的方式进行管理。它是 Java 程序可以用来执行脚本的许多可能的解释器之一。它正好在 JDK 中,所以我们不需要将解释器 JAR 添加到类路径中。ScriptEngineManager发现类路径上的所有解释器并注册它们。

它使用服务提供者规范来实现这一点,服务提供者规范很长时间以来一直是 JDK 的一部分,而且通过 Java9,它还获得了模块处理方面的额外支持。这要求脚本解释器实现ScriptEngineFactory接口,并在META-INF/services/javax.script.ScriptEngineFactory文件中列出执行该接口的类。这些文件,从属于类路径的所有 JAR 文件中,作为资源被ScriptEngineManager读取,通过它,它知道哪些类实现了脚本解释器。ScriptEngineFactory接口要求解释器提供getNames()getExtensions()getMimeTypes()等方法。管理器调用这些方法来收集有关解释器的信息。当我们询问 JavaScript 解释器时,管理器会返回工厂创建的名称,其中一个名称是JavaScript

为了通过名称访问解释器,文件扩展名或 MIME 类型只是ScriptEngineManager的函数之一。另一个是管理Bindings

当我们在 Java 代码中执行一个脚本时,我们不会这样做,因为我们想增加多巴胺的水平。在脚本的情况下,它不会发生。我们想要一些结果。我们希望传递参数,并且在脚本执行之后,我们希望从脚本中获得可以在 Java 代码中使用的值。这可以通过两种方式实现。一种是将参数传递给脚本中实现的方法或函数,并从脚本中获取返回值。这通常是可行的,但有些脚本语言甚至可能没有函数或方法的概念。在这种情况下,这是不可能的。可以将环境传递给脚本,并在脚本执行后从环境中读取值。这个环境用Bindings表示。

Bindings是具有String键和Object值的映射。

在大多数脚本语言的情况下,例如,在 JavaScript 中,Bindings连接到我们执行的脚本中的全局变量。换句话说,如果我们在调用脚本之前在 Java 程序中执行以下命令,那么 JavaScript 全局变量globalVariable将引用myObject对象:

myBindings.put("globalVariable",myObject)

我们可以创建Bindings并将其传递给ScriptEngineManager,但也可以使用它自动创建的方法,并可以直接调用引擎对象上的put()方法。

当我们执行脚本时,有两个Bindings。一个设置在ScriptEngineManager层。这称为全局绑定。还有一个是由ScriptEngine自己管理的。这是当地的Bindings。从剧本的角度看,没有区别。从嵌入的角度看,存在一定程度的差异。如果我们使用相同的ScriptEngineManager来创建多个ScriptEngine实例,那么全局绑定将由它们共享。如果一个人得到一个值,所有人都会看到相同的值;如果一个人设置了一个值,其他人都会看到更改后的值。本地绑定特定于它所管理的引擎。由于本书只介绍了 Java 脚本 API,所以我们不做详细介绍,也不使用Bindings。我们擅长调用 JavaScript 函数并从中获得结果。

实现脚本调用的类是CheckerScriptExecutor。它从以下几行开始:

package packt.java11.bulkorder.services;
import ...

@Component
public class CheckerScriptExecutor {
    private static final Logger log =
            LoggerFactory.getLogger(CheckerScriptExecutor.class);

    private final ScriptEngineManager manager = new ScriptEngineManager();

    public boolean notConsistent(String script, Order order) {

        try {
            final var scriptReader = getScriptReader(script);
            final var 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 部门必须确保程序和整个操作完全没有 bug。这种反应是有心理原因的,但这确实使我们离 Java 编程非常遥远。

引擎可以执行通过Reader或作为String传递的脚本。因为现在我们在资源文件中有了脚本代码,所以让引擎读取资源似乎是一个更好的主意,而不是将其读取到一个String

private Reader getScriptReader(String script) throws IOException {
    final Reader scriptReader;
    try (final var 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;
}

为了从资源文件中读取脚本,我们使用 SpringClassPathResource类。脚本的名称前面有scripts目录,后面有.js扩展名。其余的是相当标准的,没有什么我们在这本书中没有看到。下一个求值脚本的方法更有趣:

private Object evalScript(String script, Order order, Reader scriptReader)
        throws ScriptException, NoSuchMethodException {
    final Object result;
    final var engine = manager.getEngineByName("JavaScript");
    try {
        engine.eval(scriptReader);
        final var 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,以及其他类型的脚本,那么就必须完成这个检查,并且脚本引擎可能应该根据文件扩展名从管理器请求,而我们在这个方法中没有访问这个文件扩展名的权限。但这是未来的发展,不是本书的一部分。

当我们有了引擎,我们必须求值脚本。这将在脚本中定义函数,以便我们以后可以调用它。为了调用它,我们需要一些对象。对于 JavaScript,引擎还实现了一个Invocable接口。并非所有脚本引擎都实现此接口。有些脚本没有函数或方法,也没有可调用的内容。同样,当我们希望不仅允许 JavaScript 脚本,而且还允许其他类型的脚本时,这是以后要做的事情。

为了调用这个函数,我们将它的名称传递给invokeFunction()方法,同时传递我们想要传递的参数。在本例中,这是order。就 JavaScript 而言,两种语言之间的集成已经相当成熟。在我们的示例中,我们可以访问作为参数传递的 Java 对象的字段和方法,并且返回的 JavaScripttruefalse值也被神奇地转换为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 中的方法。Java9 是第一个支持标准 JDK 中反应式编程的版本。用一句话概括一下,反应式编程更多地关注数据流,而较少关注实现如何处理数据流。您可能还记得,这也是从描述如何做到描述我们想做什么的一步。

读完本章之后,您将了解什么是反应式编程,以及 Java 中有哪些工具可以使用。您还将了解反应式编程的好处,以及将来何时以及如何利用这一原则,因为将有越来越多的框架支持 Java 中的反应式编程。在本章中,您将了解以下主题:

  • 一般反应式编程
  • Java 中的反应流
  • 如何以反应式的方式实现我们的示例代码

反应式是什么?

有反应式编程、反应式系统和反应式流。这是三个相互关联的不同事物。三者都被称为反应式,这并非没有道理。

反应式编程是一种类似于面向对象编程和函数式编程的编程范式。反应式系统是一种系统设计,它为某些类型的信息系统如何设计为反应式系统设定了一定的目标和技术约束。这与反应式编程原理有很多相似之处。反应流是一组接口定义,有助于实现与反应式系统相似的编码优势,可用于创建反应式系统。反应流接口是 JDK9 的一部分。它们有 Java 和其他语言版本。

我们将在单独的章节中讨论这些问题;在这些章节的末尾,您应该更好地理解为什么它们中的每一个都被称为反应式

反应式程序设计

反应式编程是一种范式,它更多地关注计算过程中的数据流向,而不是如何计算结果。这个问题最好描述为几个依赖于另一个输出的计算,但是如果几个计算可以独立于另一个执行,那么反应式编程就可能出现。作为一个简单的例子,我们可以使用f1f2f3f4f5作为简单的计算步骤,通过一些给定的bcef值来计算h的值:

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,因为我们可以负担自动内存收集所需的额外资源。尽管可以通过手动管理内存来编写速度更快的代码,但自动化代码比普通程序员使用 C 语言编写的代码要快,而且编程错误的频率也要低得多。

正如在使用自动内存管理时需要注意的一些问题一样,我们必须注意反应环境中的一些问题,而在手动编码的情况下,这些问题是不存在的。不过,我们使用了反应式方法来实现其好处。

最重要的问题是避免依赖关系图中的循环。尽管编写计算的定义是绝对完美的,但反应式系统可能无法处理这些定义。一些反应式系统可能在一些简单的情况下解决循环冗余,但这是一个额外的功能,我们通常只能避免。考虑以下计算:

a = b + 3 
b = 4 / a

这里,a依赖于b,所以当b改变时,计算a。然而,b也依赖于a,重新计算,这样系统就进入了一个无限循环。前面的例子看起来很简单,但这是一个好例子的特点。现实生活中的问题并不简单,在分布式环境中,有时很难找到循环冗余。

另一个问题称为故障。考虑以下定义:

a = b + 3 
q = b + a

当参数b改变时,例如从3改变为6,则a的值将从6改变为9,从而q将从9改变为15。这很简单。然而,基于对变更的识别的执行命令可以首先将q的值从9改变为12,然后在第二步骤中将其修改为15。如果负责计算q的计算节点将b中的变化识别为b值变化的结果,则会发生这种情况。在短时间内,q的值将是12,这与前一个值不匹配,也与更改的状态不匹配。此值只是系统中的一个小故障,在输入更改后发生,并且在系统中输入没有任何进一步更改的情况下消失:

如果你曾经学过逻辑电路的设计,那么静电危害可能会敲响警钟。它们是完全相同的现象。

反应式编程还假设计算是无状态的。执行计算的各个节点在实践中可能具有某种状态,并且在大多数情况下,它们确实具有这种状态。在某些计算中有一个状态本身并不是邪恶的。然而,调试有状态的东西要比调试无状态和函数式的东西复杂得多。

它也是反应式环境的一个重要辅助工具,允许它基于计算是函数式的这一事实执行不同的优化。如果节点有一个状态,那么计算可能无法自由地重新安排,因为结果可能取决于实际的求值顺序。这些系统可能不是真正的反应式,或者,至少,这可能是有争议的。

反应式系统

反应式系统在反应宣言中定义。宣言的创建者意识到,随着技术的变化,需要在企业计算中开发新的系统模式,以利用新技术并产生更好的结果。宣言设想的系统如下:

  • 响应式
  • 可恢复
  • 弹性
  • 消息驱动

前三个特性是用户价值;最后一个特性更像是获取价值的技术方法。

响应式

如果系统以可靠的方式给出结果,那么它就是响应式的。如果你跟我说话,我会回答你的问题,或者,至少,告诉你我不知道答案或者我不能理解这个问题。是的,得到答案会更好,但是如果一个系统不能给你答案,它仍然会给你一些回报。如果您有十年前的客户端操作系统和一些旧计算机的经验,您可以理解这一点。得到一个旋转沙漏是令人沮丧的。你只是不知道这个系统是在帮你找到答案,还是完全冻结了。

反应式系统必须响应。应及时作出答复。实际计时取决于实际系统。如果系统运行在一艘向木星另一侧的宇宙飞船上运行,可能需要毫秒、秒甚至数小时。重要的是系统应保证响应时间的一些上限。这并不一定意味着系统应该是一个实时解决方案,这是一个更严格的要求。

响应性的优势不仅在于用户在电脑前不会变得紧张。毕竟,这些服务中的大多数都是由其他主要相互通信的服务使用的。真正的优点是错误发现更可靠。如果反应式系统元素变得不响应,那么它肯定是一个错误情况,应该在正常操作范围之外对其进行处理(更换故障的通信卡,重新启动系统等等)。我们越早识别错误状态,修复它就越便宜。我们越能识别问题的所在,我们就越少地花费时间和金钱来定位错误。响应性不是速度,而是关于更好的操作和更好的质量。

可恢复的

可恢复的系统即使有错误,也要继续工作。好吧,没什么错误。那将是奇迹,或者是胡说八道!错误通常是错误。如果末日来临,正如我们所知,世界末日已经结束,即使是有弹性的系统也不会有响应。然而,对于较小的中断,可能有一些方法可以使系统具有弹性。

如果只有一个磁盘出现故障、断电或出现编程错误,有些技术可能会有所帮助。系统可能会被复制,因此当其中一个实例停止响应时,其他实例可能会承担失败实例的任务并继续工作。容易出错的系统可能在空间或时间上相互隔离。当一个地点发生地震或洪水时,另一个地点仍可能继续工作。如果不同的组件不需要实时通信,并且消息以可靠的方式存储和转发,那么这不是问题,即使这两个系统永远不可能同时可用。他们仍然可以通过接收消息,执行他们应该执行的任务,然后发送结果消息来进行合作。

必须解决系统中的错误,即使系统保持响应。错误不会影响弹性系统的响应能力,但弹性水平会降低,应该恢复。

弹性的

弹性表示系统正在适应负载。我们可以有一个巨大的系统,有许多处理器能够满足最大的预期需求,但这不是弹性的,因为需求不是恒定的,而且在大多数情况下,小于最大值,所以这样一个系统的资源是空闲的。这会浪费时间、CPU 周期和能源,从而产生生态足迹:

在云上运行系统可以避免这种损失。云只不过是一些人为多个应用(甚至是多个公司)操作的许多计算机,每个计算机只租用它真正需要的 CPU 周期,而且只在它需要的时候租用。在其他时候,当负载较小时,CPU 和电力可以由其他人使用。由于不同的应用和不同的公司有不同的峰值时间,因此该模型的资源损失较小。有许多问题需要解决,如数据隔离和信息保护,防止窃听,但这些主要是解决。秘密服务公司不会从云服务租用资源来运行计算(也许,他们会做一些其他的用途),一些其他偏执的公司也可能不这样做,但大多数公司会这样做。即使考虑到所有可能的副作用,它也更有效,因此更便宜。

弹性意味着分配的资源遵循或更确切地说是预期需求。当系统预期到更高的容量需求时,它会分配更多的资源,在非高峰时间,它会释放资源,以便其他云客户可以使用它。

弹性还假设系统是可伸缩的。弹性和可伸缩性这两件事密切相关,但并不相同。可扩展性意味着应用可以适应更高的负载,分配更多的资源。可伸缩性并不关心这种分配是在一个专门用于应用的计算中心中静态购买和驱动巨大的计算机机箱,还是按需从云中动态分配资源。可伸缩性仅仅意味着如果需求翻倍,那么资源也可以成倍地满足需求。如果所需资源中的乘法因子与需求中的乘法因子相同或不大于需求中的乘法因子,则应用是可伸缩的。如果我们需要更多的资源来满足需求,或者如果我们不能满足需求,即使需求只是适度增长,那么应用是不可伸缩的。弹性应用总是可伸缩的;否则,它们就不能是弹性的。

消息驱动

反应式系统是消息驱动,不是因为我们需要消息驱动系统,而是因为消息驱动系统是那些可以同时提供响应性、弹性和弹性的系统。

消息驱动架构意味着信息在断开连接的组件之间传输。一个组件发送一条消息,然后忘记了它。它不会等待其他组件对消息执行操作。当消息被发送时,代表发送组件的所有任务都被执行,并且处理这些任务所需的所有资源都被释放,从而消息被释放并准备好用于下一个任务。

消息驱动并不一定意味着联网。消息可以在同一台机器内的对象、线程和进程之间传递。另一方面,如果消息传递架构的接口设计良好,那么如果基础结构发生变化,则不需要修改组件,以前在线程之间传递的消息现在必须以 IP 包的形式穿越海洋。

发送消息使得在空间和时间上隔离发送者和接收器成为可能,正如我们所描述的,这是一种弹性的手段。接收方可以在消息到达后的一段时间内在有资源的情况下提取消息。然而,响应性要求这个时间不是在遥不可及的遥远未来,而是在有限的距离内。如果无法成功处理该消息,则另一条消息可能会发出错误信号。错误消息不是我们期望的结果,但它仍然是一个响应,并且系统仍然对它提供的所有好处做出响应。

背压

消息处理具有适当的消息接口和实现,支持背压。当组件不能或几乎不能处理更多消息时,背压是一种减轻组件负担的方法。消息可以排队等待处理,但现实生活中的队列没有无限的容量,反应式系统不应该丢失不受控制的消息。背压将组件的负载信号发送给消息生产者,要求他们减少产量。它就像一根水管。如果你开始关闭管道的出口,管道内的压力开始向后增加,水源迫使它输送的水越来越少。

背压是处理负载的一种有效方法,因为它将负载处理移到真正能够处理负载的部件上。在老式的排队系统中,有一个队列存储项目,直到接收项目的组件能够使用它们,完成其工作。如果对负载的大小和队列的最大大小有一个明确的限制,那么队列设计就是好的。如果队列已满,则无法传递项目,系统将暂停。

施加背压有点不同。为了优化性能和确保响应性,仍然可以在组件前面使用队列。产品的生产者仍然可以将生产的产品放入队列中,然后重新开始履行自己的职责,而不需要等到消费者能够处理产品。正如我们前面提到的,这是脱钩。看到队列已满或几乎已满,也可以作为一个非常简单的背压。如果有人说队列完全没有这个功能,那就不对了。有时,只需查看队列的容量以及队列中的项目,就足以确定是否需要减轻队列所属接收器的负载。但是制作者做这个,而不是接收器,这是一个重要的问题。

生产者发现接受者没有跟上供给的步伐,但是生产者没有任何关于原因的信息,不知道原因不能预测未来的行为。从接受者到制作者有一个背压信息通道,这使得故事更细粒度。

生产者可能会看到,比如排队有 10 个插槽,认为没有问题;生产者决定在接下来的 150ms 内再交付 8 个项目,一个项目通常需要 10 毫秒的时间来处理、给予或带走;因此,预计在 100 毫秒以内处理,这正好比要求的 200 毫秒最大值要好。生产方只知道一个项目通常需要 10 毫秒的时间才能加工。另一方面,接收方看到,进入队列的最后一个项目需要大量处理,因此,本身需要 200 毫秒。要发出信号,它可以通过背压告诉生产者在进一步通知之前不要交付新项目。接收人知道这些项目本来可以很好地装入队列中,但不会及时处理。使用这些信息,生产者将向云控制发出一些命令,分配另一个处理,并将接下来的 8 个项目发送到新的接收器,让旧的接收器完成它必须处理的繁琐工作,而这远远高于平均项。

背压使您可以通过接收程序创建的信息来辅助数据加载控制,这些接收程序拥有关于处理项目的最多信息。

反应流

反应流作为一项倡议启动,通过使用背压调节数据推送来提供异步模式下处理数据流的标准。项目原址为这个页面

反应流现在在 JDK9 中的java.util.concurrent包中实现。

反应流定义的目的是定义一个接口,该接口可以以完全异步的方式处理所生成数据的传播,而不需要在接收端缓冲所创建的无限数据。当数据在流中创建并可供处理时,获取数据的 worker 必须足够快以处理生成的所有数据。容量应足够高,以处理最高产量。一些中间缓冲区可以处理峰值,但是如果没有控制,当用户达到其最大容量时停止或延迟生产,系统将失败。反应式系统接口旨在提供一种支持背压的方法。背压是一个向数据生产者发出信号,要求其放慢甚至停止生产,使其达到适合消费者的水平的过程。接口定义的每个调用都是异步的,这样一个部分的性能就不会受到其他部分执行延迟的影响。

该倡议的目的并不是确定生产和消费之间数据传输的方式。它关注于接口,为程序提供一个清晰的结构,并提供一个能与所有实现一起工作的 API。

Java 中的反应式编程

Java 不是一种反应式语言。然而,这并不意味着我们不能用 Java 创建反应式程序。有些库支持不同的反应式编程方法。我应该提到,Akka 框架和 ReactiveX 也适用于其他语言。在 Java9 中,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方法?为什么它不直接返回订阅或者抛出一些错误呢?产生这种复杂行为的原因是,调用subscribe()方法的可能不是Subscriber。就像在现实生活中一样,我可以订阅一本杂志并支付一年的费用作为圣诞礼物。(这是我写这部分书的季节)在我们的代码中,一些负责向谁通知某些数据更改的布线组件调用subscribe,而不一定是用户。Subscriber只负责订户应该负责的最小的事情。另一个原因是整个方法是异步的。当我们订阅某些东西时,订阅可能不会立即可用并准备就绪。可能有一些长时间运行的进程需要在订阅可用之前完成,而调用subscribe的调用方不需要等待进程完成。当订阅准备就绪时,它将被传递给订阅服务器,传递给真正需要它的实体。

Subscriber接口定义了onSubscribe()onError()(我们已经讨论过)、onComplete()onNext()方法。

在这些接口的定义中,订户从PublisherPublisher通过推送将此任务委托给的其他对象获取项目是很重要的。订户不需要去报摊取下一期,有人调用onNext方法直接将该期交给它。

这也产生了这样的后果,除非Subscriber手中有一些控制,否则Publisher可能会用项目淹没Subscriber。不是每个Subscriber都能处理无限的项目。Subscriber在订阅时得到一个Subscription对象,这个对象可以用来控制项目对象的流程。

Publisher创建Subscription对象,接口定义了cancelrequest两种方法。Subscriber应调用cancel()方法,通知Publisher不应交付更多项目。订阅已取消。request(long n)方法指定用户准备通过后续调用onNext()方法最多获取n项:

如果订阅者已经调用了request()方法,则指定的号码被添加到订阅计数器中。换句话说,指定的long值并不反映订户的实际状态。它是一个增量,增加一些由Publisher维护的计数器,这些计数器统计可以传递的项目的数量,将long参数的值相加,并在每个传递到Subscriber的项目上减少。最常用的方法是每次Subscriber处理完请求后调用request(1)

如果使用Long.MAX_VALUE参数调用request()方法,Publisher可以只发送它可以发送的任何项,而不进行计数和限制。这实际上是关闭背压机制。

该规范还提到,对cancel的调用并不一定意味着将不再交付问题。尽最大努力取消。就像在现实生活中一样,当你把你的邮件发送到日报上并打算取消订阅时,出版商不会在邮递员把邮件发送到你的邮箱之前派一个代理来阻止他们。如果在取消通知到达出版商时,有些东西已经在路上了,那么它将继续前进。如果Publisher已经启动了一些无法合理停止的异步进程,那么onNext()方法将与一些元素一起调用。

PublisherSubscriber接口有一个通用参数T。这是Publisher接口发布的项目类型,Subscriber接口在onNext()方法中获取的项目类型。更确切地说,Subscriber接口可以有一个R类型,它是T的一个超类;因此,它与Publisher接口兼容。例如,如果Publisher发布Long值,则Subscriber接口可以接受onNext()方法的参数中的LongNumberObject,具体取决于实现Subscriber的类的声明。

Flow类还包含一个Processor接口,该接口扩展了PublisherSubscriber。这个接口将由类实现,这些类也接受数据并将数据发送到反应流中的其他组件。这些元素在反应流程序中非常常见,因为许多执行某些任务的元素从其他反应流元素获得要处理的项;因此,它们是Subscriber,同时,它们在完成任务后发送;因此,它们是Publisher

实现库存

现在我们已经讨论了很多技术和编程方法,现在是时候实现一些示例代码了。我们将实现库存,在我们的应用中保持使用反应流。例如,清单将非常简单。它是一个Map<Product,InventoryItem>,用于保存每个产品的项数。实际的映射是ConcurrentHashMapInventoryItem类要比Long数字复杂一些,以便正确处理并发问题。当我们设计一个建立在响应流上的程序时,我们不需要处理太多的并发锁定,但是我们仍然应该意识到代码运行在多线程环境中,如果我们不遵循一些规则,可能会表现出奇怪的行为。

Inventory类的代码相当简单,因为它只处理一个映射:

package packt.java11.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.java11.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;
        }
        var d = new ClosureData();
        amountOnStock.accumulateAndGet(delta,
                (stock, n) ->
                        stock >= n ?
                                stock - (d.actNr = n) :
                                stock - (d.actNr = 0));
        return d.actNr;
    }
}

当我们把产品加入存货时,我们没有限制。储存架是非常巨大的,我们没有模型,他们可能会得到充分和库存可能无法容纳更多的项目。然而,当我们想从存储库中删除项目时,我们必须处理这样一个事实:产品中可能没有足够的项目。在这种情况下,我们不会从存储库中删除任何项。我们为顾客提供完全满意的服务,否则根本不提供服务。

为了维护库存中的项目数量,我们使用了AtomicLong。这个类有accumulateAndGet()方法。这个方法得到一个参数和一个在我们的代码中作为 Lambda 提供的参数。此代码由accumulateAndGet()方法调用,以计算股票的新价值。如果有足够的项目,那么我们将删除请求的项目数。如果没有足够的存货,我们就去掉零。方法返回实际返回的项数。因为这个数字是在 Lambda 内部计算出来的,所以它必须从 Lambda 中逃逸出来。为此,我们使用方法内部定义的ClosureData

请注意,例如,在 Groovy 中,我们可以简单地使用一个Long d变量并在闭包内更改该变量。可以说,Groovy 将 Lambda 调用为闭包。在 Java 中,我们不能这样做,因为我们可以从方法内部访问的变量应该是有效的final。然而,这只不过是属于闭包环境的更显式的表示法。ClosureData d对象是final,与类具有的字段相反,可以在 Lambda 中修改该字段。

本章中我们真正感兴趣的最有趣的类是InventoryKeeper。此类实现了Subscriber接口,能够使用订单维护库存:

package packt.java11.mybusiness.inventory;

import ...

public class InventoryKeeper implements Flow.Subscriber<Order> {
    private static final Logger log = LoggerFactory.getLogger(InventoryKeeper.class);
    private static final long WORKERS = 3;
    private final Inventory inventory;
    private Flow.Subscription subscription = null;
    private ExecutorService service = Executors.newFixedThreadPool((int) WORKERS);

    public InventoryKeeper(@Autowired Inventory inventory) {
        this.inventory = inventory;
    }

    @Override
    public void onSubscribe(Flow.Subscription subscription) {
        log.info("onSubscribe was called");
        subscription.request(3);
        this.subscription = subscription;
    }

    @Override
    public void onNext(Order order) {
        service.submit(() -> {
                    log.info("Thread {}", Thread.currentThread().getName());
                    for (final var item : order.getItems()) {
                        try {
                            inventory.remove(item.getProduct(), item.getAmount());
                            log.info("{} items removed from stock", item.getAmount());
                        } catch (ProductIsOutOfStock exception) {
                            log.error("Product out of stock");
                        }
                    }
                    subscription.request(1);
                }
        );
    }

    @Override
    public void onError(Throwable throwable) {
        log.info("onError was called for {}", throwable);
    }

    @Override
    public void onComplete() {
        log.info("onComplete was called");
    }
}

订阅对象后调用onSubscribe()方法。订阅将传递给对象,并存储在字段中。由于用户在以后的调用中需要这个订阅,所以当处理传入onNext的项目并且可以接受新的项目时,字段是存储这个对象的好地方。在这个方法中,我们还将初始请求设置为三项。实际值只是说明性的。企业环境应该能够配置这样的参数:

private ExecutorService service =  
                   Executors.newFixedThreadPool((int) WORKERS);

代码中最重要的部分是onNext()方法。它所做的实际上是检查订单的所有项目,然后从库存中删除项目的数量。如果有些商品缺货,它会记录一个错误。这是无聊的部分。有趣的是,它是通过一个执行器服务来实现的。这是因为对onNext的调用应该是异步的。出版者打电话给onNext送货,但我们不能让它等待实际处理。当邮递员带来你最喜欢的杂志时,你不会马上开始阅读,而是让邮递员等待你的签名认可。在onNext()中,您所要做的就是获取下一个订单,并确保这将在适当的时候得到处理。

此代码中的实际实现使用了三个线程的ThreadPool。此外,所需项目的数量为 3 件。这是一个逻辑上的巧合,每个线程在一个项目上工作。它不需要像那样,即使在大多数情况下是这样。如果这有意义的话,没有什么能阻止我们在同一个项目上创建更多的线程。反之亦然。可以创建一个线程来处理多个项目。这些代码可能会更复杂,而这些复杂执行模型的整体思想是使编码和逻辑更简单,将多线程、编码和实现问题转移到框架中,并将重点放在应用代码中的业务逻辑上。但我不能说,可能没有一个订阅者在多个项目上处理多个线程的例子,这些线程混杂在一起。

我们在本章中要看的最后一段代码是单元测试,它通过一些示例来驱动代码:

@Test
public void testInventoryRemoval() throws InterruptedException {
    Inventory inventory = new Inventory();
    try (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);
    }
}//try( p )

请注意,这不是常规的单元测试文件。这是一些测试代码,我还建议您执行、调试和查看不同的日志输出。

总结

在这短短的一章中,我们了解了反应式编程、反应式系统和反应式流。我们讨论了它们之间可能导致混淆的相似之处和不同之处。我们特别关注 Java 反应流(在 Java9 中引入),它们实际上与Stream类和方法无关。

在本章的后半部分,我们讨论了一个使用反应流的非常简单的示例。

在阅读了本章之后,您已经学到了很多关于 Java 语言和编程的知识。我们没有详细介绍 Java 的所有细节,因为这在一本书中是不可能的。我敢说,地球上,或者说地球周围的轨道上,无论人类在哪里,都没有人知道 Java 的一切。然而,到现在为止,我们已经知道了足够的知识,可以开始在企业环境中编写代码,并且在退休之前,甚至退休之后,我们可以不断地学习更多的知识。剩下的是一点编程。在上一句中,我用了编码这个词来区分。编码与编程不同。编码是编程专业中使用的一种技术。在最后一章中,我们将看到编程的各个方面,以及如何以专业的方式进行编程。这在介绍性的书中很少涉及,但我很高兴我们能在这个问题上与出版商达成一致。这样,你就可以完成这本书,不仅用你从这本书中学到的知识,而且用一种远见,展望未来的道路,你将走上山坡的顶端。你将知道你可以继续学习的主题、领域和科目。

将 Java 知识提升到专业水平

到目前为止,您已经了解了专业 Java 开发人员所需的最重要的领域和主题。我们仍然要讨论的是一些话题,这些话题将引导您从初级开发人员变成高级开发人员。不过,阅读本章并不能使任何人成为高级开发人员。前几章是我们走过的路。这一章只是地图。如果前面的每一章都涵盖了编码到达港口的旅程中几英里的短距离步行,那么这一章就是发现新大陆的航海图。

我们将简要介绍一些非常深入和高级的专业领域,例如创建 Java 代理、编译时注解处理、多语言编程、一些架构设计、工具和团队协作的技术。我们将提供这些地区的品尝者。现在,你有足够的知识来理解这些话题的重要性,尝一尝就会对未来几年的自我发展产生胃口,或者,至少,我的意图是让你这个读者沉迷其中。

Java 深层技术

在本节中,我们将介绍三种技术:

  • Java 代理
  • 多语言程序设计
  • 注解处理

对于 Java 专业人士来说,了解它们不是必须的。对他们的了解是非常重要的。Java 代理主要用于开发环境和操作中。它们是与已经运行的 JVM 交互的复杂运行时技术。注解处理是另一个领域。注解处理器被插入 Java 编译器。多语言编程处于中间地位。它是 JVM 编程,就像用 Java 编程一样,但是使用不同的语言,或者,也许,不同的语言和 Java 一起使用。甚至包括许多语言,比如 Jython、Groovy、Clojure 和 Java。

我们将讨论这些技术,以便您了解它们是什么,以及在何处查找更多信息,以防您想了解更多。

Java 代理

Java 代理是由 Java 运行时以特殊方式加载的 Java 程序,可以用来干扰加载类的字节码,从而改变它们。它们可用于执行以下操作:

  • 列出或记录,并在运行时报告加载的类
  • 修改这些类,以便这些方法将包含额外的代码来报告运行时行为
  • 支持调试器在开发人员修改源代码时更改类的内容

例如,该技术用于来自 JRebelXRebel 产品

尽管 Java 代理在 Java 的深层细节中工作,但它们并不神奇。它们有点复杂,您需要对 Java 有深入的了解,但是任何能用 Java 编程的人都可以编写 Java 代理。所需要的是,该类(即代理)具有一些预定义的方法,这些方法与代理的其他类一起打包到一个 JAR 文件中,并且具有一个META-INF/MANIFEST.MF文件,该文件定义实现premain()和/或agentmain()方法的类的名称,以及一些其他字段。

详细而精确的参考文件是 JDK JavaDoc 的一部分,可在java.lang.instrument的文件中获得。

使用 Java 代理启动 Java 应用时,命令行必须包含以下选项:

-javaagent:jarpath[=options]

这里,jarpath指向包含代理类和清单文件的 JAR 文件。类必须有一个名为premainagentmain的方法。它可能有一个或两个参数。在初始化 JVM 之后,JVM 首先尝试调用双参数版本:

public static void premain(String agentArgs, Instrumentation inst);

如果不存在两个参数版本,则使用一个参数版本,这与两个参数版本基本相同,但忽略了inst参数,我认为,这并没有太大意义,因为 Java 代理没有Instrumentation对象不能做太多事情:

public static void premain(String agentArgs);

agentArgs参数是在命令行上作为选项传递的字符串。第二个参数Instrumentation提供了注册类转换器的方法,这些类转换器可以修改类字节码,并提供了在运行时要求 JVM 执行类的重新定义或重新转换的方法。

Java 应用也可以在程序启动后加载代理。在这种情况下,不能在 Java 应用的main方法之前调用代理,因为此时它已经启动了。为了区分这两种情况,JVM 在这种情况下调用了agentmain。请注意,premainagentmain是为代理调用的,而不是两者都调用。单个代理可以同时实现这两个,这样它就能够执行在启动时加载的、在命令行上指定的或在 JVM 启动后加载的任务。

如果使用了agentmain,则其参数与premain相同。

premain()agentmain()之间有一个重要的区别。如果在启动期间无法加载代理,例如,如果找不到代理,如果 JAR 文件不存在,如果类没有premain()方法,或者如果它抛出异常,JVM 将中止。如果代理是在 JVM 启动后加载的(本例中使用的是agentmain),那么如果代理出现错误,JVM 不会中止。

这种方法是相当合理的。假设有一个在 TomcatServlet 容器上运行的服务器应用。新版本启动后,系统进入停机维护期。如果新版本无法启动,因为代理的行为不好,最好不要启动。调试情况并修复它,或将应用回滚到旧版本并调用更长的修复会话所造成的损害,可能比启动应用并且没有适当的代理功能要小。如果应用仅在没有代理的情况下启动,则可能无法立即识别次优操作。另一方面,当稍后附加代理时,应用已经在运行。将代理附加到已运行的应用以从已运行的实例获取信息。停止已经运行的实例并使其失败,特别是在操作环境中,比不连接代理更具破坏性。无论如何,它可能不会被忽视,因为最有可能连接的代理是由操作人员使用的。

premainagentmain代理获取一个Instrumentation对象作为第二个参数。这个对象实现了几个方法。其中之一是:

void addTransformer(ClassFileTransformer transformer)

代理实现了转换器,它有transform()方法签名:

byte[] transform(Module module, ClassLoader loader, 
                 String className, 
                 Class<?> classBeingRedefined, 
                 ProtectionDomain protectionDomain, 
                 byte[] classfileBuffer) 
throws IllegalClassFormatException

当类被加载或被转换时,JVM 调用这个方法。该方法获取类对象本身,但更重要的是,它获取包含类的字节码的字节数组。该方法应返回转换类的字节码。修改字节码需要了解字节码是如何构建的以及类文件的结构。有一些库可以帮助实现这一点,比如 JavassistASM。不过,在熟悉字节码的结构之前,我不会开始编码。

代理在单独的线程中运行,并且可能与用户或文件系统交互,并且基于在任何时候的一些外部观察,可以调用以下方法来使用注册的转换器执行类的重新传输:

void retransformClasses(Class<?>... classes)

代理还可以调用以下方法,该方法将重新定义作为参数给定的类:

void redefineClasses(ClassDefinition... definitions)

ClassDefinition类只是Classbyte[]对。这将通过 JVM 的类维护机制重新定义类。

注意,这些方法和 Java 代理与 JVM 的深层、低级部分交互。这也产生了这样的结果:很容易破坏整个 JVM。字节码不会被检查,与加载类时不同,因此,如果它中有错误,结果可能不仅是异常,而且可能是 JVM 的崩溃。此外,重新定义和转换也不应改变类的结构。它们不应该更改继承占用、添加、重命名或删除方法,也不应更改方法的签名,字段也是如此。

另外,请注意,已经创建的对象不会受到更改的影响;它们仍将使用类的旧定义,并且只会影响新实例。

多语言程序设计

多语言编程是一种在同一应用中使用不同编程语言的技术。这种方法不仅适用于应用的不同部分在不同环境中运行的情况。例如,客户端使用 JavaScript、CSS 和 HTML 在浏览器中执行,而服务器被编程为在 Java 的 Tomcat 环境中运行。这是一个不同的故事,通常,当有人谈论多语言编程时,这不是典型的用例。

当在服务器上运行的应用部分地用 Java 和其他语言运行时,我们可以称之为 polyglot 编程。例如,我们用 Java 创建了订单处理应用,一些基于订单包含的产品特定代码检查订单正确性的代码是用 JavaScript 编写的。它响了吗?我们已经在本书中演示了 JDK 的脚本 API。这是真正的多语言编程,即使我们没有提到它的方式。

运行编译后的 Java 代码的 JVM 是不同语言编译器的一个非常好的目标,因此,有许多语言可以为它编译。当 JVM 运行一个类的字节码时,它不知道源语言是什么,也不关心;编译器是由字节码创建的,它只是执行它。

我们可以使用不同的语言,比如 Jython、Groovy 和 Scala,举几个流行的为 JVM 编译的语言。我们可以用一种语言编写一个类,用另一种语言编写另一个类。当它们被放在一个 JAR、WAR 或 EAR 文件中时,运行时系统只会运行它们。

我们什么时候使用多语言编程?

多语言配置

通常,当我们想要创建一个更灵活和可配置的应用时,我们转向多语言编程。在许多实例中安装的应用(通常在不同的客户站点上)都有一些配置。这些配置可以是 XML 文件、属性文件和 INI 文件(这些文件来自 Windows)。随着程序的发展,这些静态配置的可能性达到了极限。应用开发人员很快就会发现,他们需要配置一个使用这些技术描述起来很麻烦的功能。配置文件开始变大,读取和解释配置文件的代码变得太大。优秀的开发人员必须意识到这一点,在配置文件和处理它们的代码变得不可管理之前,必须实现一些脚本配置、多语言编程:

优秀的开发人员团队在开发他们的配置语言和该语言的解释器时,可能会达到这样的程度。它可以基于 XML,也可以是任何其他语言。毕竟,写一门语言很有趣,我自己也写过几次。然而,其中大多数都是业余爱好,而不是专业项目。通常,在制作另一种语言时没有客户价值。最好使用现有的。

在配置方面,Groovy 是一种非常方便的语言,它支持复杂的闭包、元类语法和实现。这样,该语言非常适合创建特定于领域的语言。因为 Groovy 是编译到 JVM 的,所以 Groovy 类可以直接从 Java 调用,而读取配置实际上是调用从配置文件编译的类。编译可以在应用构建期间进行,但是在配置的情况下,在应用启动期间进行编译更有意义。我们已经看到,脚本 API 的 Groovy 实现或 Groovy 提供的特殊 API 绝对能够做到这一点。

我们在书中见过这样的例子吗?这可能会让您感到惊讶,但实际上我们已经多次使用 Groovy 来描述配置。Gradle 构建文件就是主要在 Groovy 中开发的 Groovy DSL,用于支持项目的构建配置。

多语言脚本

配置不是 PolyPlot 编程的唯一应用。配置在程序启动时执行,配置数据随后用作静态数据。我们可以在应用的执行期间执行脚本,而不仅仅是在应用启动期间。这可以用于向程序用户提供额外功能,安装时使用相同的应用,但提供不同的脚本。

最早提供这种脚本功能的应用之一是 Emacs 编辑器。应用的核心是用 C 语言编写的,它包含一个 Lisp 解释器,用户可以编写脚本,这些脚本是在编辑器环境中执行的。工程程序 AutoCAD 也使用 Lisp 解释器来实现类似的目的。为什么使用 Lisp 来实现这个目的?Lisp 有非常简单的语法,因此,很容易解析 Lisp 代码。同时,该语言功能强大,当时有开源的 Lisp 解释器(至少一个)。

为了获得这种灵活性,应用通常提供插件 API,开发人员可以使用这些 API 来扩展应用。这就需要开发人员设置编码工具,包括 IDE、构建工具和持续集成,即专业的编程环境。当插件要解决的任务很简单时,开销就太大了。在这种情况下,脚本解决方案更方便。

脚本不是万能的。当扩展应用的脚本变得太复杂时,就意味着编写脚本的可能性太大了。然而,要从一个孩子手里夺回一个玩具是很困难的。如果用户习惯了脚本的可能性,那么如果我们发布的应用的下一个版本没有提供这种可能性,他们就不会接受。因此,求值脚本功能在我们的应用中的可能用途是非常重要的。脚本,更一般地说,我们的程序的任何功能,都不会按我们预期的方式使用。它们将被用于任何可能的用途。当涉及到滥用某个功能时,用户可能会超出想象。最好事先考虑限制脚本编写的可能性,或者限制脚本的运行时间,或者限制程序同意使用的脚本的大小。如果这些限制设置合理,并且用户理解并接受这些限制,那么除了脚本功能之外,还必须考虑插件结构。

应用的安全性,包括插件或脚本扩展,也非常重要。脚本或插件与核心应用运行在同一个 JVM 上。一些脚本语言在脚本周围提供了一个围栏,限制了对核心应用对象和类的访问,但这是一个例外。通常,脚本以与核心应用相同的权限运行,这样它们就可以做任何事情。因此,应该以与核心应用相同的方式信任脚本。对于应用的非特权用户,脚本安装或修改不应该是可能的。这样的操作几乎总是留给系统管理员。

如果一个没有特权的用户可以上传一个脚本到服务器,然后让它执行,我们就在应用中打开了一个安全漏洞。由于访问限制是由应用强制执行的,因此使用不受控制的脚本很容易覆盖这些限制。黑客可以轻松地访问其他用户的数据(他们无权访问),并读取和修改我们的数据库。

业务 DSL

当应用的代码可以分为业务代码和技术代码时,也可能出现多语言编程。业务代码包含我们实际为其编写应用的顶级业务逻辑,这是包含客户付费的逻辑的代码。技术代码支持在业务 DSL 中编码的算法。

大多数企业应用都包含这两种类型的代码,但许多应用并没有将它们分开。这将导致一个包含重复代码的单片应用。当您觉得在需要持久性或联网时正在编写相同类型的代码,并且在编写某些业务规则时又在编写相同类型的代码时,这种代码气味表明这两种代码类型没有分开。DSL 和脚本并不是一根魔杖,也不能解决所有源于不正确的应用结构的问题。在这种情况下,必须首先重构代码以分离业务逻辑和基础结构代码,实现 DSL 和支持它的业务 API 并将业务代码重写到 DSL 中只是第二步。这样一个项目的每一步都为应用提供了价值,即使它从未涉及到 DSL 和脚本,投入的精力也不会白费。

业务 DSL 脚本非常类似于可插入脚本,只是这次不是应用调用脚本来执行一些特殊的扩展功能。相反,DSL 代码通过它提供的业务 API 调用应用。提供 API 和使用 DSL 的优势在于,实现业务逻辑的代码可以摆脱技术细节,可以非常抽象,并且通过这种方式,可以更接近于问题的业务级描述,而不仅仅是程序代码。即使是商人也能理解业务 DSL,尽管在实际例子中这不是一个目标,但他们甚至可以编写代码。

在维也纳理工大学,我们也使用了类似的方法,使半导体模拟更适用于半导体设计工程师。核心计算代码是用 Fortran 语言编写的,Fortran 是一种处理大量仿真数据输入和输出的 C 语言框架,它嵌入了执行这些程序的 XLISP 解释器。Lisp 代码包含了仿真配置数据,当要对许多配置点执行仿真时,还可以包含简单的循环。它是多语言编程,只是当时没这么叫。

多语言的问题

多功能编程不仅仅是优势。在跳进这个方向之前,开发人员必须考虑很多事情。

为应用使用另一种语言需要知识。找到能用所用语言编写代码的人最终要比找到只懂 Java 的开发人员困难得多。(如果核心应用语言不是 Java,这也是事实。)不同的语言需要不同的思维方式,通常需要不同的人。团队还应该有一些精通两种语言的成员,如果大多数人对另一种语言至少了解一点,这也是一个优势。

支持 Java 的工具集非常出色。与其他语言相比,构建工具、集成开发环境、库、调试可能性和日志框架都非常好。Polyglot 开发也需要对其他语言的支持,这可能不如对 Java 的支持那么先进。通常,调试 DSL 解决方案确实是一个问题,IDE 支持也可能滞后。

当我们用 Java 编程时,我们常常想当然地认为 IDE 读取库的元数据,每当我们需要调用一个方法或引用一个类时,IDE 都会给出最好的建议。也可以支持 XML 和属性文件,IDE 可能知道一些最常用的框架,例如 Spring,并且理解将类名作为超链接处理的 XML 配置,即使类名在某些属性字符串中也是如此。

对于其他语言来说,这远没有这么容易。对于拥有广泛用户基础的语言,工具支持可能是很好的,但是如果您选择了一种外来语言,就只能靠自己了。语言越有异国情调,你得到的支持就越少。

您可以创建一个工具来支持 DSL。使用这样的工具并不难做到。在这种情况下,您被绑定到 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对象,其中包含此处理器可以处理的注解的完全限定类名

如果注解处理器是为 Java1.8 创建的,那么它可以与 Java9 一起工作,但也可能不工作。如果它声明支持的最新版本是 1.8,那么 Java9 环境中的编译器将不会调用它。最好不要调用注解处理器,而不是调用它并破坏编译过程,这甚至可能创建已编译但错误的代码。

对于注解处理器,这些方法返回的值相当稳定。注解处理器将返回它可以处理的相同源版本,并返回相同的注解集。因此,以声明的方式在源代码中定义这些值是明智的。

这可以在扩展javax.annotation.processing.AbstractProcessor类而不是直接实现Processor接口时完成。这个抽象类实现了这些方法。它们都从注解中获取信息,这样我们就可以修饰扩展抽象类的类。例如,getSupportedAnnotationTypes()方法查看SupportedAnnotationTypes注解并返回注解中列出的注解类型字符串数组。

现在,这是一个有点扭曲的大脑,也可能是混乱的开始。我们正在编译时执行注解处理器。但是编译器本身是一个 Java 应用,这样,时间就是编译器内部运行的代码的运行时间。AbstractProcessor的代码使用反射方法将SupportedAnnotationTypes注解作为运行时注解进行访问。这里面没有魔法。JDK9 中的方法如下:

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()); 
}

(为了简洁起见,对代码进行了编辑。在 Java11 中,代码要复杂一些,因为它处理 Java9 中引入的模块,但结构基本相同。)

为了看一个例子,我们将看一看 polyglot 注解处理器的代码。我们非常简单的注解处理器将处理一个简单的注解,com.javax0.scriapt.CompileScript,它可以指定一个脚本文件。注解处理器将加载脚本文件并使用 Java9 的脚本接口执行它。

几年前,我将此代码作为演示代码开发;它可以从 GitHub 获得 Apache 许可证。因此,类的包被保留。

注解处理器包含两个代码文件。一个是处理器将处理的注解本身:

@Retention(RetentionPolicy.SOURCE) 
@Target(ElementType.TYPE) 
public @interface CompileScript { 
    String value(); 
    String engine() default ""; 
}

如您所见,在编译之后,该注解不会进入类文件;因此,在运行时不会有任何跟踪,因此任何类源都可能偶尔使用该注解。注解的TargetElementType.TYPE表示该注解只能应用于那些类型为classinterfaceenum的 Java9 语言构造。

注解有两个参数。该值应该指定脚本文件的名称,引擎可以定义该文件中脚本的类型。我们将创建的实现将尝试从文件扩展名中识别脚本的类型,但是如果有人想将一些 Groovy 代码嵌入到具有.jy扩展名的文件中(通常用于 Jython),那就这样吧。

处理器扩展了AbstractProcessor,通过这种方式,继承了一些方法,但牺牲了类中使用的一些注解:

package com.javax0.scriapt; 
import ... 
@SupportedAnnotationTypes("com.javax0.scriapt.CompileScript") 
@SupportedSourceVersion(SourceVersion.RELEASE_11) 
public class Processor extends AbstractProcessor {

不需要实现getSupportedAnnotationTypes()getSupportedSourceVersion()方法。它们被类上注解的使用所取代。我们在这个处理器中只支持一个注解,即我们在前面列出的源文件中定义的注解,并且我们准备管理源代码,直到 Java 版本 11。我们必须覆盖的唯一方法是process()

@Override
public boolean process(final Set<? extends TypeElement> annotations,
                       final RoundEnvironment roundEnv) {
    for (final var element : roundEnv.getRootElements()) {
        processClass(element);
    }
    return false;
}

此方法获取两个参数。第一个是为其调用的注解集。二是圆环境。因为处理器可以被多次调用,不同的调用可能有不同的环境。每次调用都在其中,RoundEnvironment参数是一个对象,可以用来获取给定回合的信息。它可用于获取为其调用此注解的回合的根元素。在我们的例子中,这将是一组具有CompileScript注解的类元素。我们迭代这个集合,对于每个类,我们调用processClass()方法。请参见下一个代码段:

private static void processClass(final AnnotatedConstruct element) {
    for (final var ann : element.getAnnotationMirrors()) {
        processAnnotation(ann);
    }
}

正如我们已经提到的,实际的注解在编译时不可用。因此,我们只能得到注解的编译时镜像。它具有AnnotationMirror类型,可用于获取注解的实际类型,以及注解的值。注解的类型在编译时可用。编译器需要它;否则,它无法编译注解。这些值可以从注解本身获得。我们的processAnnotation()方法将得到的每个注解作为一个参数进行处理:

private static void processAnnotation(final AnnotationMirror mirror) {
    final var script = FromThe.annotation(mirror).getStringValue();
    final var engine = FromThe.annotation(mirror).getStringValue("engine");
    execute(script, engine);
}

我们的@CompileScript注解定义了两个参数。第一个值是脚本文件名,第二个值是脚本引擎名称。如果未指定第二个,则将空字符串设置为默认值。注解的每一次调用execute()方法:

private static void execute(final String scriptFileName, final String engineName) {
    final var manager = new ScriptEngineManager();
    final ScriptEngine engine;
    if (engineName != null && !engineName.isEmpty()) {
        engine = manager.getEngineByName(engineName);
    } else {
        final var ext = getExtensionFrom(scriptFileName);
        engine = manager.getEngineByExtension(ext);
    }
    try (final var reader = new FileReader(new File(scriptFileName), StandardCharsets.UTF_8)) {
        engine.eval(reader);
    } catch (final IOException | ScriptException e) {
        throw new RuntimeException(e);
    }
}

该方法尝试基于文件名加载脚本,并尝试基于给定名称实例化脚本引擎。如果没有指定名称,则使用文件扩展名来标识脚本引擎。默认情况下,JavaScript 引擎位于类路径上,因为它是 JDK 的一部分。如果正在使用任何其他基于 JVM 的脚本引擎,那么它必须在类路径或模块路径上可用。

类的最后一个方法是一个简单的脚本操作方法,没有什么特别的。它只是删掉文件扩展名,以便根据扩展字符串识别引擎:

private static String getExtensionFrom(final String scriptFileName) {
    final int extPos = scriptFileName.lastIndexOf('.');
    return extPos == -1 ? "" : scriptFileName.substring(extPos + 1);
}

为了完整起见,我们有这个类的右括号:

}

企业编程

当一个专业人员为一个企业工作时,他们并不是一个人工作。我们需要与很多人、开发人员以及其他同事合作。企业的 IT 部门越老,企业就越大,人们扮演的角色就越专业。您将遇到业务分析师、项目经理、测试工程师、构建工程师、主题专家、测试人员、架构师、scrum 大师和自动化工程师等角色。其中一些角色可能重叠;每个人可能都有特定的职责,而在其他情况下,有些角色甚至可能更专业。有些角色技术性很强,需要的业务相关知识较少;另一些则更面向业务。

作为一个有这么多人和这么多不同角色的团队工作并不简单。对于新手开发人员来说,任务的复杂性可能是难以承受的,如果没有操作的所有成员或多或少遵循的明确策略,就无法完成任务。也许你的经验会表明,它往往是少而不是多,但这是一个不同的故事。

对于开发人员的合作方式,有成熟的行业实践。它们支持软件开发生命周期SDLC),使用瀑布、敏捷或以某种方式混合使用这两种模型。在下面的部分中,我们将研究在每个软件开发组织中使用的或至少应该使用的工具和技术。这些是:

  • 控制检查源代码的代码质量的静态代码分析工具
  • 源代码版本控制,它存储源代码的所有版本,并帮助获取开发过程的任何旧版本的源代码
  • 软件版本控制,以保持一定的秩序,我们如何识别不同的版本,并不会迷失其中
  • 代码审查和工具,有助于查明测试未发现的错误,并帮助知识共享
  • 以知识为基础的工具,用于记录调查结果
  • 问题跟踪工具,记录错误、客户问题和其他必须处理的任务
  • 外部产品和库的选择过程和考虑因素
  • 使软件保持一致状态并在错误传播到其他版本或其他代码之前立即报告错误的连续集成,这取决于错误代码的开发方式
  • 版本管理,它跟踪软件的不同版本
  • 一个代码存储库,存储编译和打包的工件

下图显示了这些任务中使用最广泛的工具:

静态代码分析

静态代码分析工具像编译器一样读取代码并对其进行分析,但是它们不进行编译,而是试图找出其中的错误或错误。不是语法错误。为此,我们已经有了 Java 编译器。错误,例如在循环外使用循环变量,这可能是绝对有效的,但通常是不好的样式,而且这种用法通常来自一个简单的错误。他们还检查代码是否遵循我们设置的样式规则。

静态代码分析器有助于识别代码中许多小而明显的错误。有时,他们很烦人,警告一些可能不是真正的问题。在这种情况下,最好对程序进行稍微不同的编码,而不是因为我们希望静态代码分析在没有警告的情况下运行。我们不应该因为工具而修改代码。如果我们以这样一种方式编写代码,它通过了一个质量检查工具,而不是因为这样更好,那么我们是在为工具服务,而不是为我们服务的工具。

更改代码以通过代码分析的原因是,如果代码不违反编码样式,那么代码很可能对普通程序员更容易阅读。您或其他团队成员可以是优秀的程序员,即使它使用了一个特殊的构造,他们也非常理解代码。但是,对于将来维护代码的所有程序员,您不能这么说。代码寿命很长。我在 50 年前写的一些节目上工作。他们仍然在运行,由年轻的专业人员维护;他们甚至在代码开发时都没有出生。很容易发生,当您编写代码时,维护代码的人还没有出生。您无法了解他们的能力和编码实践。您能做的最好的事情就是准备平均值,这正是静态代码分析工具所要做的。

这些工具执行的检查没有硬连接到工具中。工具中的一些特殊语言描述了规则,可以删除这些规则,也可以添加其他规则,还可以修改规则。这样,您就可以适应您工作的企业的编码标准。不同的规则可以分为装饰性的、次要的、主要的和关键的。化妆品的东西主要是警告,我们并不真正关心他们,即使它是很好的解决这些问题。有时候,这些小事可能预示着一个非常大的问题。在检查被宣布为失败之前,我们可以为次要错误和主要错误的数量设置限制,也可以为严重错误设置限制。在最后一种情况下,这个极限通常为零。如果编码错误看起来很严重,那么最好不要在代码中出现任何错误。

最常用的工具是 CheckstyleFindBugsPMD。这些工具的执行通常是自动化的,虽然它们可以从 IDE 或开发人员的命令行执行,但它们的主要用途是在持续集成CI)服务器上。在构建过程中,这些工具在 CI 服务器上配置为运行,并且可以配置为在静态代码分析因某些限制而失败时中断构建。执行静态代码分析通常是编译和单元测试执行之后、实际打包之前的下一步。

Sonar 是静态代码分析工具之外的特殊工具。SonarQube 维护以前检查的历史记录,支持单元测试代码覆盖率,并且可以报告质量随时间的变化。这样,您可以看到代码样式错误的质量、覆盖率和不同限定条件的数量是如何变化的。通常,当临近发布日期时,代码质量会随着人们的匆忙而降低。这是非常糟糕的,因为这是大多数错误应该被消除的时候。有一个关于质量的统计数据可能有助于在看到质量之前就看到趋势,从而改变实践,从而使代码的可维护性失控。

源代码版本控制

源代码版本控制系统存储不同版本的源代码。如今,我们无法想象没有它的专业软件开发。情况并非总是如此,但免费在线存储库的可用性鼓励了爱好开发人员使用一些版本控制,当这些开发人员后来为企业工作时,显然使用这些系统是必须的。

有许多不同的版本控制系统。最广泛使用的是 Git。以前广泛使用的版本控制是 SVN,甚至在此之前还有 CVS。这些东西现在越来越少用了。我们可以看到 SVNCVS 的继承者,Git 是 SVN 的继承者。除此之外,还有其他版本控制系统,如 MercurialBazaarVisual Studio Team Services。有关可用工具的全面列表,请访问维基百科页面

我打赌你会首先遇到 Git,在为企业编程时很有可能遇到 SVN。水星可能会出现在你的实践中,但目前存在的任何其他水星都是非常罕见的,被用于特定的区域,或者只是灭绝。

版本控制系统允许开发团队以有组织的方式将软件的不同版本存储在维护的存储器上(以可靠的方式定期备份)。这对于不同的目的很重要。

不同版本的软件可以部署到不同的实例。如果我们为客户开发软件,并且我们有许多客户希望与我们做一笔了不起的生意,那么不同的客户可能有不同的版本。这不仅是因为有些客户不愿意为升级付费,而且我们也不想免费提供新版本。通常,客户方面产生的成本会在很长一段时间内阻止升级。软件产品不能在孤立的环境中独立工作。不同的客户端有不同的集成环境;软件与不同的应用通信。在企业环境中引入新版本时,必须测试它是否能与它必须合作的所有系统一起工作。这个测试需要很多的努力和金钱。如果新版本比旧版本提供的新特性或其他价值不能证明成本合理,那么部署新版本将是一种浪费。我们的软件有一个新版本并不意味着旧版本不可用。

如果客户端有一些 bug,那么我们在该版本中修复 bug 是至关重要的。为此,bug 必须在开发环境中重现,这最终意味着该版本的源代码必须可供开发人员使用。

这确实需要客户数据库包含对安装在客户站点上的软件产品的不同版本的引用。更为复杂的是,一个客户在不同的系统中一次可能有多个版本,而且可能有不同的许可证,因此问题比最初看起来更复杂。如果我们不知道客户的版本,我们就有麻烦了。由于为客户和现实生活注册版本的数据库可能会不同步,软件产品在启动时会记录其版本。在本章中,我们有一个关于版本控制的单独部分。

如果在客户端的版本中修复了 bug,那么在部署之后,客户端的事件可能会得到解决。不过,如果该版本不是该软件的前一版本,问题仍然存在。在旧版本的软件中引入的 bug 修复可能仍然潜伏在更高版本或更早版本中。开发团队必须确定哪些版本与客户端相关。例如,一个旧版本不再安装在任何客户端的站点上,就不值得进行调查。之后,必须对相关版本进行调查,以检查它们是否显示出错误。这只能在我们有源代码版本的情况下完成。如果导致错误的代码是在以后的版本中引入的,则某些旧版本可能没有错误。一些新版本也可能对这个 bug 免疫,因为这个 bug 已经在以前的版本中修复了,或者仅仅是因为导致这个 bug 的代码在 bug 出现之前就已经被重构了。有些 bug 甚至可能影响特定的版本,而不是一系列的产品。错误修复可能应用于不同的版本,它们可能需要稍微不同的修复。所有这些都需要一个维护的源版本存储库。

即使我们没有拥有不同版本的不同客户,我们开发的软件也很可能不止一个版本。主要版本的开发即将结束,因此,团队中负责测试和 bug 修复的一部分将重点放在这些活动上。同时,下一版本的特性开发仍在继续。实现下一版本功能的代码不应进入即将发布的版本。新代码可能非常新鲜,未经测试,并可能引入新的错误。在释放过程中引入冻结时间是很常见的。例如,可能禁止实现即将发布的版本的任何新特性。这称为功能冻结。

修订控制系统处理这些冻结期,维护代码的不同分支。该版本将保存在一个分支中,用于以后版本的版本将保存在另一个分支中。当发行版发布时,应用于它的 bug 修复也应该传播到较新的版本;否则,下一个版本可能会包含在上一个版本中已经修复的 bug。为此,发布分支与正在进行的分支合并。因此,版本控制系统维护一个版本图,其中每个版本的代码是图中的一个节点,更改是顶点。

Git 在这个方向上走得很远。它非常支持分支创建和合并,以至于开发人员为他们创建的每个更改创建单独的分支,然后在特性开发完成后将其与主分支合并。这也为代码评审提供了一个很好的机会。进行功能开发或 bug 修复的开发人员在 GitHub 应用中创建一个拉取请求,并请求另一个开发人员检查更改并执行拉取。这是一种应用于代码开发的四眼原则。

有些版本控制系统将存储库保存在服务器上,任何更改都会到达服务器。这样做的好处是,任何提交的更改都会进入定期备份的服务器磁盘,因此是安全的。由于服务器端的访问是受控制的,因此发送到服务器的任何代码都不能在没有跟踪的情况下回滚。所有版本,甚至错误的版本,都存储在服务器上。这可能是法律要求的。另一方面,如果提交需要网络访问和服务器交互,那么它可能会很慢,从长远来看,这将促使开发人员不要频繁提交他们的更改。更改在本地计算机上保留的时间越长,丢失部分代码的风险就越大,并且随着时间的推移,合并变得越来越困难。为了解决这种情况,Git 分发了存储库,提交发生在本地存储库上,这与服务器上的远程存储库完全相同。当一个存储库将更改推送到另一个存储库时,存储库是同步的。这鼓励开发人员频繁地提交到存储库,提供简短的提交消息,这有助于跟踪对代码所做的更改。

一些旧版本的控制系统支持文件锁定。这样,当开发人员签出代码文件时,其他人就不能处理同一段代码。这从本质上避免了代码合并期间的冲突。多年来,这种方法似乎不适合开发方法。合并问题比签出和遗忘的文件问题要小。SVN 支持文件锁定,但这并不是真正的严重问题,也不会阻止某个开发人员将更改提交给其他人锁定的文件。这与其说是真正的锁定,不如说是一个建议。

源代码存储库非常重要,但不应与发行版存储库混淆,发行版存储库以二进制形式存储已编译的代码发行版。源代码和版本存储库一起工作。

软件版本控制

软件版本控制很神奇。想想不同版本的 Windows 或者《星球大战》电影。嗯,后者不是真正的软件版本控制,但它表明这个问题是非常普遍的。对于 Java,版本控制并没有那么复杂。首先,我们现在使用的 Java 版本是 9。之前的版本是 1.8,之后是 1.7,以此类推,一直到 1.0。Java 的早期版本被称为 Oak,但这是历史。毕竟,谁能说出 Java2 是什么?

幸运的是,当我们创建一个 Java 应用时,情况更简单。从 Java1.3 时代起,Oracle 就提出了一个关于如何版本 JARs 的建议

本文档区分规范版本和实现版本。如果 JAR 内容的规范发生了变化,那么代码的行为必须与之前的行为有所不同;规范版本应该发生变化。如果规范没有改变,但实现却改变了(例如,当我们修复一个 bug 时),那么实现版本就改变了。

在实践中,没有人使用这个方案,尽管至少在理论上,将实现版本和规范版本分开是一个绝妙的想法。我甚至敢打赌,您的大多数同事从未听说过这种版本控制。我们在实践中使用的是语义版本控制。

语义版本控制将规范和实现版本混合到一个单一版本号三元组中。这个三胞胎的格式是mmp,即:

  • m:主要版本号
  • m:次要版本号
  • p:补丁号

说明书上说这些数字从零开始,然后增加一。如果主数字为零,则表示软件仍在开发中。在这种状态下,API 是不稳定的,并且可能在没有新的主要版本号的情况下发生更改。当软件发布时,主版本号变为 1。后来,当应用(库)的 API 与以前的版本不同,并且应用与以前的版本不向后兼容时,它必须增加。当更改只影响实现但更改很重要时,次要版本号会增加;甚至 API 也可能在更改,但以向后兼容的方式进行更改。修补程序版本会在某些 bug 修复后增加,但更改不是主要的,API 也不会更改。如果前面的三元组中的任何版本号增加,则次要版本和修补程序级别必须重置为零:主要版本号增加将重置次要版本和修补程序版本;次要版本号增加将重置修补程序编号。

这样,语义版本控制将保留规范版本的三元组的第一个元素。次要版本是规范和实现版本的混合。补丁版本更改显然是实现版本更改。

除此之外,语义版本控制允许我们附加一个预发布的字符串,比如-RC1-RC2。它还允许附加元数据,例如加号后面的日期,例如,+20160120作为日期。

语义版本控制的使用帮助那些使用软件的人容易地发现兼容的版本,并查看哪个版本更旧,哪个版本更新。

代码评审

当我们以专业的方式创建程序时,它是在团队中完成的。在编程方面,除了作为一种爱好或配合教程之外,没有一个人的表演。这不仅是因为团队合作更有效,还因为一个人很脆弱。如果你独自一人工作,被公共汽车撞了,或者你中了彩票,失去了从事这个项目的能力或动力,你的客户就有麻烦了。那不专业。专业项目应该对任何成员脱落都有弹性。

团队合作需要合作,合作的一种形式是代码评审。这是一个开发人员或一组开发人员读取其他团队成员编写的部分代码的过程。这项活动有直接收益:

  • 阅读代码的开发人员获得更多关于代码的知识;他们学习代码。这样,如果创建代码的开发人员出于任何原因离开了流程,其他人可以以最小的问题继续工作。
  • 编码样式可以对齐。开发人员,甚至是老年人,都需要小心地注意编码错误。可能存在错误或编码风格冲突。编码风格很重要,因为代码的可读性越高,就越不可能有未被注意到的错误,同样重要的是,对于团队来说,编码风格是相同的。所有团队成员应使用相同的样式。看一个与我写的代码风格不同的代码有点难以理解和理解。这些差异可能会分散读者的注意力,团队成员必须能够阅读代码。代码属于团队而不是单个开发人员。任何团队成员都应该知道代码并能够修改它。
  • 在代码审查期间,可以发现很多 bug。查看代码并试图理解其工作原理的各方可能偶尔会发现代码结构中的错误,否则很难通过测试发现这些错误。如果您愿意,代码复查是最白盒测试。人们的思维方式不同,不同的思维方式会捕捉到不同的错误。

代码审查可以在线和离线进行。它可以在团队或点对点中完成。

大多数团队遵循 GitHub 支持的代码审查过程,这是最简单的。对代码的更改将提交到分支,并且不会直接与代码合并,而是在 Web 界面上创建一个拉请求。本地策略可能要求其他开发人员执行拉取。Web 界面将突出显示更改,我们可以向更改的代码添加注解。如果注解是重要的,那么请求拉取的原始开发人员应该修改代码以回答注解并再次请求拉取。这样可以确保至少有两个开发人员看到任何更改;知识是共享的。

反馈是点对点的。它不是一个高级教师,而是一个初级教师。这需要一个不同的频道。GitHub 中的评论不适合这个目的;至少,有更好的渠道,比如面对面交谈。评语可以是从大四到大三,也可以是从大三到大四。在这项工作中,以及在对代码质量进行反馈时,高年级学生和低年级学生是平等的。

最简单的,也许是最常见的评论是以下-我可以看到Xyz.java在修改中发生了变化,但我没有看到XyzTest.java发生变化。这几乎是对合并的即时拒绝。如果开发了一个新特性,就必须创建单元测试来测试该特性。如果一个 bug 被修复了,就必须创建单元测试来防止 bug 再次出现。我个人也收到过很多次这样的评论,甚至是来自大三的学生。其中一个告诉我,“我们知道你只是在考验我们,如果我们敢于给出反馈的话。”天知道,我没有。他们不相信。有一种情况,Xyz.java中的更改不需要跟随XyzTest.java中的更改—当更改不更改类的功能时,它只更改非功能特性,例如性能。

虽然 ChangeReview 和 GitHub 在开发过程中是很好的工具,但是当需要检查更大的代码块时,它可能并不合适。在这种情况下,必须使用其他工具,如 FishEye。在这个工具中,我们可以选择要检查的源文件,即使它们最近没有更改。我们还可以选择评审人和截止日期。评论类似于 GitHub。最后,这种类型的代码评审以一个代码评审会议结束,在这个会议上,开发人员将亲自收集并讨论代码。

在组织这样的会议时,一个有管理他人经验的人来主持这些会议是很重要的。关于样式的代码和讨论会变得非常个人化。同时,在参加这样的会议时,要注意不要涉及个人隐私。有足够多的参与者可能不知道这一点或纪律性较差。

如果不先使用联机工具审阅代码,则不要参加审阅会议。当你发表评论时,语言应该是非常礼貌的,因为我已经提到过。最后,调解人应该能够区分重要和不太重要的问题,并停止任何关于琐事的辩论。不知何故,不太重要的问题更敏感。我个人并不关心如何格式化制表符大小,如果它是两个或四个空格,文件是否应该只包含空格或制表符字符是否允许,但人们往往喜欢在这些问题上浪费时间。

在代码评审会议期间,最重要的问题是我们是专业人士。我今天可能会回顾并评论你的代码,但是明天,情况正好相反,我们必须作为一个团队一起工作。

知识库

知识库在几年前还是个时髦词。一些公司在宣传 wiki 技术的理念,但没有人使用它。今天,知识库的情况完全不同了。所有企业都使用某种 wiki 实现来共享知识。它们大多使用合流,但也有其他可用的 wiki 解决方案,商业和免费的。

知识库存储的信息,作为一个开发人员,您可以写在一个纸质笔记本上,以供以后参考,例如,开发服务器的 IP 地址、安装 JAR 文件的目录、要使用的命令、收集的库以及使用它们的原因。主要的区别在于,您以格式化的方式将其写入 wiki 中,其他开发人员可以立即使用它。编写这些页面对开发人员来说有点负担,而且一开始需要一些自律。以开发服务器的 IP 地址和安装目录为例,您不仅要编写服务器的 IP 地址,还要编写一些文本来解释信息是什么,因为其他人可能无法理解。在 wiki 系统中以一个好的名称放置包含信息的页面,将其链接到其他页面,或者在页面树中找到页面的适当位置,也是一项工作。如果你用的是纸质笔记本,你只要在书的第一页上写下 IP 地址和目录,你就会记住所有其他的。

当同事不需要自己查找信息时,wiki 方法会带来好处;您可以更轻松地查找信息,因为其他同事也在知识库中记录了他们的发现,几个月后,您可以找到自己记录的信息。在纸质笔记本的情况下,您可以翻页查找 IP 地址,您可能记得,也可能不记得哪个是主服务器,哪个是辅助服务器。您甚至可能会忘记有两个服务器(或者它是一个双集群?)。

要查看可用 wiki 软件的长列表,请访问这个页面

问题跟踪

问题跟踪系统跟踪问题、错误和其他任务。创建第一个问题跟踪系统是为了维护 bug 列表以及 bug 修复过程的状态,以确保识别和记录的 bug 不会被遗忘。后来,这些软件解决方案被开发出来,成为成熟的问题跟踪工具,是每个企业不可避免的项目管理工具。

最广泛使用的问题跟踪应用是 Jira,但是在页面上,您可以找到许多其他应用。

问题跟踪应用最重要的特性是,它必须以可编辑的方式详细记录问题。它必须记录记录问题的人,以防在问题处理过程中需要更多的信息。问题的根源很重要。同样,问题必须分配给负责人,负责问题处理的进度。

现代问题跟踪系统提供复杂的访问控制、工作流管理、关系管理以及与其他系统的集成。

访问控制只允许与某个问题有关的人访问该问题,因此其他人无法更改问题的状态,甚至无法读取该问题附带的信息。

问题可能会根据问题类型经历不同的工作流步骤—可能报告或复制错误、分析根本原因、开发或测试修复、创建修补程序、与下一版本合并或发布在版本中的修复。这是一个简单的工作流,具有几个状态。

关系管理允许我们在问题之间设置不同的关系,并允许用户沿着这些关系从一个问题导航到另一个问题。例如,客户端报告一个 bug,并且该 bug 被标识为与另一个已经修复的 bug 相同。在这种情况下,通过原来的工作流程为同一个 bug 创建一个新的补丁是很疯狂的。相反,问题得到一个指向原始问题的关系,并将状态设置为关闭。

与其他系统的集成也有助于保持一致的开发状态。版本控制可能要求,对于每个提交,提交消息都包含对描述代码修改支持的需求、bug 或更改的问题的引用。问题可以通过 Web 链接链接到知识库文章或敏捷项目管理软件工具。

测试

当我们谈到单元测试时,我们已经讨论了测试。单元测试在敏捷开发中是非常重要的,它有助于保持代码干净并减少错误的数量。但这并不是您在企业开发中看到的唯一类型的测试。

测试类型

执行测试有很多原因,但至少有两个原因我们必须提到。一种是寻找 bug,并尽可能多地创建无错误代码。另一个是证明应用是可用的,并且可以用于它本来的目的。从企业的角度来看,它很重要,并且考虑了很多单元测试没有考虑的方面。虽然单元测试集中在一个单元上,因此是指出错误所在的一个非常好的工具,但是当发现来自模块间错误接口的 bug 时,它是完全不可用的。单元测试模拟外部模块,从而测试单元是否按预期工作。但是,如果此期望中存在错误,并且其他模块的行为方式与单元测试模拟不同,则不会发现错误。

为了发现这个级别上的错误,也就是单元测试之上的下一个级别,我们必须使用集成测试。在集成测试期间,我们测试各个单元如何协同工作。当我们用 Java 编程时,单元通常是类;因此,集成测试将测试不同类如何协同工作。虽然人们对 Java 编程中的单元测试有一个共识(或多或少),但在集成测试的情况下就不是这样了。

在这方面,可以模拟外部依赖,例如通过网络或数据库层可以访问的其他模块,或者可以在集成测试期间使用一些测试实例来设置。争论的焦点不是这些部分是否应该被模仿,而是术语。模拟一些组件,例如数据库,既有优点也有缺点。与任何模拟一样,缺点是设置模拟的成本以及模拟的行为与实际系统不同的事实。这种差异可能导致系统中仍然存在一些 bug,并潜伏在系统中,直到稍后的测试案例,或者,上帝禁止,生产被使用。

集成测试通常以类似于单元测试的方式实现自动化。然而,它们通常需要更多的时间来执行。这就是为什么这些测试不会在每次源代码更改时执行。通常,会创建一个单独的 Maven 或 Gradle 项目,该项目依赖于应用 JAR,并且只包含集成测试代码。这个项目通常是每天编制和执行的。

可能发生的情况是,日常执行的频率不足以及时发现集成问题,但是更频繁地执行集成测试仍然是不可行的。在这种情况下,集成测试用例的子集被更频繁地执行,例如,每小时执行一次。这种类型的测试称为烟雾测试。下图显示了不同测试类型的位置:

当应用在完全设置的环境中进行测试时,这种测试称为系统测试。这样的测试应该能够发现在以前的测试阶段中潜伏和覆盖的所有集成缺陷。不同类型的系统测试也可以发现非功能性问题。功能测试和性能测试都是在这个级别上完成的。

功能测试检查应用的功能。它确保应用按预期运行,或者至少具有值得在生产环境中安装的功能,并且可以节省成本或增加利润。在现实生活中,程序几乎从不提供任何需求文档中所设想的所有功能,但是如果程序可以正常使用,那么假设没有安全问题或其他问题,就值得安装。

如果应用中有很多函数,那么函数测试可能会花费很多。在这种情况下,一些公司会进行健全性测试。此测试不检查应用的全部功能,只检查一个子集,以确保应用达到最低质量要求,并且值得在功能测试上花钱。

在设计应用时,可能有一些测试用例是不可预见的,因此在功能测试计划中没有测试用例。这可能是一些奇怪的用户行为,例如用户在没有人认为可能的情况下按下屏幕上的按钮。用户,即使是仁慈的,也可能碰巧按下或触摸任何东西,并将所有可能的不切实际的输入输入到系统中。特别测试试图弥补这一不足。在即席测试期间,测试人员会尝试所有可能的方法来使用他们在执行测试时所能想到的应用。

这也与发现系统漏洞时的安全测试(也称为渗透测试)有关。这些是由在安全领域拥有核心专业知识的专业人员执行的特殊类型的测试。开发人员通常不具备这种专业知识,但至少,开发人员应该能够讨论在这样的测试中发现的问题,并修改程序以修复安全漏洞。这在互联网应用中是非常重要的。

性能测试检查应用在合理的环境中是否能够处理用户在系统上施加的预期负载。负载测试模拟攻击系统的用户并测量响应时间。如果响应时间合适,即低于最大负载下所需的最大值,则测试通过;否则,测试失败。如果负载测试失败,则不一定是软件错误。应用可能需要更多或更快的硬件。负载测试通常只以有限的方式测试应用的功能,并且只测试对应用造成读取负载的用例场景。

许多年前,我们在测试一个响应时间必须为 2 秒的 Web 应用。负载测试是非常简单的问题GET请求,因此在同一时间最多有 10000 个请求处于活动状态。我们从 10 个客户端开始,然后一个脚本将并发用户增加到 100 个,然后增加到 1000 个,然后每分钟增加 1000 个。这样,负载测试长达 12 分钟。脚本打印了平均响应时间,我们准备在周五下午 4:40 执行负载测试。平均响应时间从几毫秒开始,随着负载增加到 5000 个并发用户,平均响应时间上升到 1.9 秒,然后随着负载增加到 10000 个用户,平均响应时间下降到 1 秒。你可以理解人们在星期五下午的态度,因为我们满足了要求而感到高兴。我的同事们愉快地去度周末了。我留下来做了更多的测试,因为当负载增加到 5000 以上时,响应时间会减少,这一现象让我很烦恼。首先,我复制了测量结果,然后开始查看日志文件。晚上 7 点,我已经知道原因了。当负载超过 5000 时,Apache 服务器管理的连接开始耗尽,Web 服务器开始发回 500 个内部错误代码。这是 Apache 可以非常有效地做到的。它很快就告诉你,你不能得到服务。当负载为 10000 个并发用户时,70% 的响应已经有 500 个错误。平均值下降了,但用户实际上没有得到服务。我重新配置了 Apache 服务器,这样它就可以为所有请求提供服务,并将每个请求转发给我们的应用,只是为了了解应用在最大负载下的响应时间大约为 10 秒。晚上 10 点左右,当我妻子第三次打电话给我的手机时,我也知道在 JVM 选项中的 Tomcat 启动文件中应该设置多大的内存,以便在 10000 个并发用户的情况下获得所需的 2 秒响应时间。

压力测试是一种你也可能面临的性能测试。这种类型的测试会增加系统的负载,直到无法处理负载为止。该测试应确保系统可以自动或手动从极端负载中恢复,但在任何情况下都不会执行不应该执行的操作。例如,烘焙系统永远不应提交未确认的事务,无论负载有多大。如果负荷太高,面团应该生的,但不应该烤额外的面包。

层次结构顶部最重要的测试是用户验收测试。这通常是一种官方测试,购买软件的客户执行测试,如果执行成功,则支付软件的价格。因此,这在职业发展中是极其重要的。

测试自动化

测试可以自动化。自动化测试不是一个问题,只是是否值得这样做。单元测试和集成测试是自动化的,随着时间的推移,越来越多的测试自动化,随着我们向用户验收测试UAT)的方向前进,越来越多的测试自动化。UAT 不可能是自动化的。毕竟,此测试检查应用和用户之间的集成。虽然用户作为一个外部模块,可以在较低的级别上使用自动化来模拟,但是我们应该达到集成测试在没有模拟的情况下进行的级别。

有许多工具可以帮助测试自动化。如今,测试自动化的障碍是这样做的工具的成本、学习和开发测试的成本,以及对自动化测试没有发现某些错误的担心。

的确,用一个程序做错事比不用它容易。这几乎适用于任何情况,而不仅仅是测试。我们仍然使用程序,不然你为什么要读这本书?有些错误可能不会在自动功能测试过程中被发现,否则会使用手动测试被发现。同时,当同一个开发人员第一百次执行同一个测试时,很容易出错。自动化测试永远不会做到这一点。最重要的是,自动化测试的成本不是运行一次测试成本的 100 倍。

我们在这本书中使用了测试自动化工具。SoapUI 是一个帮助您创建可以自动执行的测试的工具。其他值得一看的测试工具有 CucumberconcorsionFintnesseJBehave这里有一个很好的工具比较

黑盒和白盒

你可能听过很多次测试是黑盒测试。这仅仅意味着测试不知道被测系统SUT)是如何实现的。测试仅依赖于为外部世界导出的 SUT 接口。在量表的另一端,白盒测试测试 SUT 的内部工作,并在很大程度上依赖于实现:

这两种方法各有优缺点。我们应该使用一种方法,或者两种方法的混合,当它最符合测试需要的目的时。如果实现发生变化,不依赖于实现的黑盒测试不需要改变。如果被测系统的接口发生变化,测试也应发生变化。如果实现发生变化,白盒测试可能需要更改,即使接口保持不变。白盒测试的优点是,通常创建这样的测试更容易,而且测试更有效。

为了两全其美,系统被设计成可测试的。不过,要小心。它通常意味着被测试系统内部的功能被传播到接口。这样,测试将只使用接口,因此可以声明为黑盒,但这没有帮助。如果被测试系统的内部工作发生了变化,测试必须遵循它。唯一的区别是,如果接口也发生更改,您可以将其称为黑盒测试。这不能节省任何工作。相反,它增加了我们必须检查依赖于接口的所有模块,如果它们也需要任何更改。

我并不是说我们不应该注意创建可测试的系统。通常,使系统可测试会产生更干净、更简单的代码。然而,如果代码变得更混乱、更长,因为我们想让它成为可测试的,那么我们可能就走错了路。

选择库

为企业编程,甚至为中等规模的项目编程,都离不开外部库的使用。在 Java 世界中,我们使用的大多数库都是开源的,而且或多或少是免费的。当我们购买库时,通常有一个由采购部门执行的标准流程。在这种情况下,有一个关于如何选择供应商和库的书面策略。在“自由”软件的情况下,他们通常不关心,尽管他们应该关心。在这种情况下,选择过程主要取决于 IT 部门,因此,在选择库之前,即使库是免费的,也要知道要考虑的要点。

在上一段中,我把“免费”放在引号之间。这是因为没有软件是真正免费的。没有什么像他们说的那样是免费的午餐。您已经听过很多次了,但是对于您将要选择的开放源代码库或框架来说,这可能并不明显。任何购买或实现的主要选择因素是成本、价格。如果软件是免费的,这意味着你不需要为软件支付预付费。然而,集成和使用它是有成本的。支持要花钱。有人可能会说,这种支持是社区支持,也是免费的。问题是,你花在寻找一个能帮助你克服错误的变通方法上的时间仍然是金钱。这是你的时间,或者如果你是一个经理,这是你所在部门的专业人员的时间,你为他们的时间买单,或者,如果你没有解决问题的内部专业知识,外部承包商会给你一大笔账单。

因为自由软件没有价格标签,所以我们必须考虑其他在选择过程中很重要的因素。归根结底,它们都会以某种方式影响成本。有时,标准改变成本的方式并不明显,也不容易计算。但是,对于每一个库,我们都可以根据技术决策设置禁止级别,并且我们可以根据每个标准比较库的优劣。

适合目的

这也许是最重要的因素。其他因素可能会因重要性的大小而引起争论,但如果一个库不适合我们的目的,那么不管怎样,这肯定不是可以选择的。在许多情况下,这可能是显而易见的,但您可能会惊讶地发现,有多少次我看到一个产品被选中,因为它是其他项目中某个人的最爱,而且该库被迫用于新项目中,尽管要求完全不同。

许可证

许可证是一个重要问题,因为并非所有免费软件都是免费的。一些许可证允许免费使用业余爱好项目和教育,但要求您购买软件,以专业,利润为导向的使用。

最广泛使用的许可证及其解释(以及许可证的全文)可在开放源码倡议下找到。它列出了九个不同的许可证,为了使情况更复杂一些,这些许可证有不同的版本。

最古老的许可证之一是代表 GNU 的通用公共许可证GPL)。本许可证包含以下句子:

“例如,如果你分发这样一个节目的副本,不管是免费的还是收费的,你必须把你获得的同样的自由传递给接收器。您必须确保他们也能接收或获取源代码。”

如果您为营利性企业创建软件,而公司打算销售软件,则可能无法使用 GPL 许可软件中的任何代码行。这意味着你需要传递你自己的源代码,这可能不是最好的销售策略。另一方面,Apache 许可证对您的公司可能是合适的。这是你的律师应该决定的。

尽管这是律师的工作,但有一点我们开发人员必须意识到并密切关注。有时,这些库包含来自其他项目的代码,它们的许可证(如广告所示)可能不是真正的许可证。库可以在 Apache 许可下分发,但包含 GPL 许可的代码。这显然违反了 GPL 许可,这是由一些开源开发人员犯下的。你为什么会在意?通过一个想象的情境来解释。

为企业开发软件。假设这家公司是世界上最大的汽车制造商之一,或者是最大的银行、制药公司等等。GPL 软件的所有者寻求对滥用其软件的补救措施。他们会起诉拥有 20 万美元财富的软件开发者 JohnDoe,还是你的公司,声称你没有及时检查代码的许可证?他们当然不会在没有金子的地方挖金子。起诉你工作的公司可能不会成功,但肯定不是你或公司任何人想要的好程序。

作为软件专业人士,我们能做些什么?

我们必须使用知名度高、用途广泛的库。我们可以检查库的源代码,看看是否有复制的代码。一些包名可能提供线索。你可以用谷歌搜索部分源代码来找到匹配的。最后但同样重要的是,该公司可以订阅为库提供类似研究的服务。

文档

文档是一个重要方面。如果文档不合适,就很难学会如何使用这个库。一些团队成员可能已经知道这个库,但是,对于较新的团队成员来说,情况可能并非如此。我们应该考虑一下我们的同事,他们应该是普通的程序员,因为他们必须学会使用库。因此,文档很重要。

当我们谈到文档时,我们不仅要考虑到 JavaDoc 参考文档,还要考虑到教程和书籍(如果有的话)。

有活力的项目

重要的是不要选择一个不存在的库来使用。请查看库的路线图、上一次发布版本的时间以及提交的频率。如果库不存在,我们应该考虑不使用它。库在一个环境中工作,环境也在变化。库可以连接到数据库。新版本的数据库可能会提供新的特性,只有在库被修改以适应这些新特性的情况下,才能提供更好的性能。该库通过 HTTP 进行通信;它是否支持新的 2.0 版本的协议?如果没有其他变化的话,Java 环境的版本会随着时间的推移而改变,我们使用的库迟早会跟随它来利用新特性。

不能保证一个活着的库永远活着。然而,一个已经死了的库肯定不会复活。

即使这个项目目前还活着,也有一些要点可能会给库的未来带来一些提示。如果开发它的公司是成熟的,财务稳定,并且库是以合理的商业模式开发的,那么项目死亡的风险就很低。如果有很多公司使用这个库,那么即使原来的团队停止工作或者原来的融资结构发生变化,这个项目也很可能继续存在。然而,这些只是小因素,并不是确凿的事实。没有保证,告诉未来更像是一门艺术而不是一门科学。

成熟度

成熟度与之前的标准相似。一个项目在刚开始的时候很可能还活着,但是如果它还处于初级阶段,我们最好不要将库用于大型项目。当一个项目处于早期阶段时,代码中可能会有很多 bug,API 可能会发生根本性的变化,而且可能只有少数公司依赖于代码。这也意味着社区支持度较低。

当然,如果所有的项目都只选择成熟的开源代码,那么任何开源项目都不会达到成熟状态。我们应该评估这个项目的重要性。项目是否对业务至关重要?项目是否会成为关键业务?

如果这个项目不是业务关键型的,公司就有能力发明一个新的不那么成熟的库。如果没有成熟的库来实现这个目的,这可能是合理的,因为您将要使用的技术相对较新。在这种情况下,公司的项目可能也是新的,还不是业务关键型的。我们希望,经过一段时间后,它将是业务关键型的,但到那时,库将变得成熟,或者可能刚刚消亡,我们可以在项目变得过于昂贵而无法切换之前选择一个竞争解决方案。

判断一个库的成熟度总是很困难的,必须与我们想要使用该库的项目的成熟度和重要性保持一致。

用户数

如果库是活的、成熟的,但用户不多,那就错了。如果库好的话,人们为什么不用呢?如果一个库或框架的用户数量很低,并且用户中没有大公司,那么它可能不是一个好的库或框架。如果没有人使用它,这可能表明我们对其他标准的评估可能不合适。

还要注意的是,如果库只有少数用户,社区的知识也很匮乏,我们可能无法得到社区的支持。

“我喜欢”的因素

最后但并非最不重要的一点是,“我喜欢”这个因素非常重要。问题不在于你是否喜欢这个库,而在于开发人员有多喜欢它。开发人员会喜欢一个易于使用和有趣的库,这将导致低成本。如果这个库很难使用,而开发人员又不喜欢它,那么他们就不会学习如何将它使用到高质量所需的级别,只会学习到所需的级别。最终的结果将是次优的软件。

持续集成和部署

持续集成意味着每当新版本被推送到源代码存储库时,持续集成服务器就会启动,将代码拉到其磁盘上,并开始构建。它首先编译代码,运行单元测试,启动静态代码分析工具,如果一切顺利,打包一个快照版本并将其部署到开发服务器上。

CI 服务器具有可用于创建版本的 Web 界面。在这种情况下,根据本地业务需要和相应创建的策略,部署甚至可以转到测试服务器或生产服务器。

自动化构建和部署过程与任何其他自动化具有相同的优点重复的任务可以在没有人工干预的情况下执行,这是乏味的,因此,如果由人工完成,则容易出错。突出的优点是,如果源代码中存在可以通过自动构建过程发现的错误,那么它将被发现。新手开发人员说,在本地构建代码更便宜、更容易,开发人员无论如何都会这样做,如果已经检查了构建过程,那么就将代码推送到服务器上。这在一定程度上是正确的。在将代码发送到中央回购之前,开发人员必须检查代码是否具有良好的质量和良好的构建。然而,这并不总是能够实现的。某些错误可能不会在本地环境中显示。

可能会发生这样的情况:一个开发人员意外地使用了比所支持的版本更新的 Java,并使用了新版本的新特性。企业一般不使用最新技术。他们倾向于使用经过验证、拥有众多用户和成熟的版本。今年,在 2018 年 9 月即将发布 Java11 的时候,大型企业仍然使用 Java1.6 和 1.7。由于 Java9、10 和 11 有许多新特性,实现起来并不容易,因此我预计采用该技术可能比采用 Java9.8 花费更长的时间,后者为我们提供了函数式编程和 Lambda。

也可能会发生这样的情况:一个新的库被添加到构建的依赖项中,并且将它添加到构建文件(pom.xmlbuild.gradle中的开发人员可以在本地机器上毫无问题地使用它。这并不意味着该库已正式添加到项目中,而且它可能在中央代码库(Artifactory、Nexus 或代码库的其他实现)中不可用。这个库可能只存在于开发人员的本地存储库中,而且他们可能认为既然代码已经编译,构建就可以了。

一些大型组织对不同的项目使用不同的代码库。库在经过仔细的检查和决定后进入这些资料库。有些库可能到达那里,而另一些库可能无法到达。拥有不同存储库的原因可能有很多。一个项目是为对一个开源项目有不同政策的客户开发的。如果企业为自己开发代码,可能会出现库被淘汰或不再受支持的情况,并且只能用于旧的项目。维护版本可能不需要替换库,但新项目可能不允许使用正在消亡的软件库。

CI 服务器可以在一台计算机上运行,也可以在多台计算机上运行。如果它服务于许多项目,那么可以将它设置为一个中央服务器,其中有许多代理在不同的机器上运行。当必须启动构建过程时,中央服务器将此任务委托给其中一个代理。代理可能有不同的负载,运行几个不同的构建进程,并且可能有不同的硬件配置。构建过程可能对处理器的速度或可用内存有要求。有些代理可能会为较小的项目运行更简单的构建,但无法执行大型项目的构建,或者执行某些测试仍需要大量内存的小型项目的构建。

当构建失败时,构建服务器会向开发人员发送电子邮件,向代码库发送最后更新的人有义务毫不延迟地修复 bug。这鼓励开发人员频繁地提交。更改越小,出现构建问题的可能性就越小。BuildServerWeb 界面可以用来查看项目的实际状态,哪个项目无法生成,哪个项目刚刚好。如果生成失败,则在生成的行中有一个红色标志,如果生成正常,则该标志为绿色。

通常,这些报告会使用一个巨大的显示器持续地显示在一台旧机器上,这样每个开发人员或任何进入房间的人都可以看到构建的实际状态。你甚至可以购买一些特殊的硬件,它们有红、黄、绿三色的指示灯来跟踪构建的状态,并在构建失败时发出响铃。

发布管理

开发软件意味着不断变化的代码库。并不是每个版本的软件都应该安装在生产中。大多数版本都会被推送到一个完成一半的分支上的存储库中。有些版本仅用于测试,有些版本将在生产中安装,即使只有其中的一部分将最终投入生产。

几乎所有的时候,发布都遵循我们在前面一节中讨论的语义版本控制。仅用于测试的版本通常在版本号的末尾有-SNAPSHOT修饰符。例如,1.3.12-SNAPSHOT版本是曾经调试过的版本,将成为1.3.12版本。快照版本不是确定的版本。他们是当时的代码。由于快照版本从未在生产环境中安装,因此不需要为维护而复制快照版本。因此,快照版本不会不断增加。有时,他们可能会改变,但这是一个罕见的例外。

可能我们正在修复一个 bug,1.3.12-SNAPSHOT,在开发过程中,我们更改了太多的代码,以至于我们决定在发布时必须是1.4.0,所以我们将快照重命名为1.4.0-SNAPSHOT。这是一个罕见的病例。通常,发布创建会从1.3.12-SNAPSHOT创建1.4.0版本,因为在创建发布时会决定新的发布号。

当发布过程开始时,通常从 CI 服务器的 Web 界面开始,创建发布的开发人员必须指定发布版本。这通常与没有-SNAPSHOT后缀的快照版本相同。构建过程不仅创建构建,还标记它正在使用的源代码存储库版本,并将打包的程序(工件)加载到代码存储库中。稍后可以使用该标记访问用于创建发行版的源代码的确切版本。如果某个特定版本中有一个 bug,那么必须在开发人员机器上签出这个版本,以重现 bug 并找到根本原因。

如果某个版本的生成失败,可以回滚该版本,也可以跳过该版本号并将其记为失败的版本生成。现有版本不能有两个版本。源代码是该版本的唯一源代码,生成的代码必须是任何存储中的源代码。同一个源代码的后续编译可能会产生稍微不同的代码,例如,如果使用不同版本的 Java 来创建后一个版本。即使在这种情况下,构建服务器首先创建的版本也是属于发行版的版本。当一个 bug 被复制并且代码被从完全相同的源代码重新编译时,它已经是一个快照版本了。同一源代码版本可能有多个版本,例如,使用 1.5 到 1.8 的 Java 版本和版本 9 编译,但单个版本始终属于完全相同的源代码。

如果在 QA 检查期间本应是发布版本的版本失败了,那么必须创建一个新的版本,并且必须将失败的版本记录下来。市场营销用来命名不同版本的版本不应该与我们使用的技术版本号相关。通常是这样,而且会引起很多头痛。如果你意识到这两件事是完全不同的,一件不必和另一件做任何事,生活就会变得简单。看看 Windows 操作系统或 Java 的不同版本。作为市场营销,Java 先使用 1.0,然后使用 1.1,但 Java1.2 被宣传为 Java2,代码仍然包含 1.2(现在,7 个主要版本之后,也变成了 9,而不是 1.9)

发布管理的最后一部分是部署应该注册版本号。公司必须知道哪个版本安装在哪个服务器上,哪个客户端。

代码存储库

代码存储库存储库并帮助管理不同库的依赖关系。很久以前,当 Java 项目使用 ANT 作为构建工具并且没有后来添加的 Ivy 依赖管理时,项目所需的库被下载到源代码中,通常是下载到lib库中。如果一个库需要另一个库,那么这些库也会被手动下载和存储,直到已经下载的库所需要的所有库都被复制到源代码树中。

这是大量的手工工作,而且,库代码存储在源代码存储库中,有很多副本。编译后的库不是源代码,与源代码存储库无关。可以自动化的手工工作必须自动化。这不是因为开发人员懒惰(是的,我们是,我们必须是),而是因为手工工作容易出错,因此成本高昂。

这是 Apache Ivy 发明的时候,Maven 跟随 ANT 已经支持了存储库管理。库是在目录和支持的元数据中构造的,这些元数据描述了与其他库的依赖关系。幸运的是,Gradle 没有发明自己的代码库。相反,它支持 Maven 和 Ivy 存储库。

使用存储库,构建工具会自动下载所需的库。如果库有新版本,开发人员只需在生成配置中更新所需库的版本,然后所有任务(包括下载该版本所需的其他库的所有新版本)都会自动补全。

更进一步

在这一点上,您有很多信息可以帮助您成为企业级 Java 开发人员。你有一个可以建立的知识基础。要成为一名专业的 Java 开发人员还有很长的路要走。有很多文档要读,有很多代码要扫描和理解,还有很多代码要写,直到你可以自称是一个专业的 Java 开发人员。你可能会面临多年的继续教育。好的是,即使在那之后,你也可以继续你的旅程,你可以教育自己,因为作为一个专业的 Java 开发人员很少是一个人们退休的工作。不,不!不是因为他们死了!相反,有经验的专业软件开发人员开始越来越少地编写代码,并以不同的方式支持开发过程,这将更多地利用他们的经验。他们可以成为业务分析师、项目经理、测试工程师、主题专家、架构师、scrum 大师、自动化工程师等等。这是一张熟悉的单子吗?是的,这些人是你作为一个开发人员将要与之合作的人。他们中的许多人可能是从开发人员开始的。下图显示了这些角色的相对位置:

让我们更详细地了解一下这些角色在企业开发中的作用:

  • 业务分析师与客户端合作,创建开发人员开发代码所需的文档、规范、用例和用户案例。
  • 项目经理管理项目,并帮助团队与其他团队合作完成任务,关心开发人员不能处理的所有项目事务,或者不必要地浪费他们应该花在编码上的时间。
  • 主题专家在了解业务需求方面更为先进,因此成为一名开发人员有点罕见,但如果你所从事的行业是以技术为导向的,那么成为一名开发人员可能并非不可能。
  • 测试工程师控制 QA 过程;他们不仅了解测试方法和测试需求,而且还了解开发过程,这样他们就可以支持 bug 修复,而不仅仅是识别它们,这是很糟糕的。
  • 架构师与 BAs 合作,为应用和代码设计一个高级结构,并以一种有助于开发人员专注于他们必须执行的实际任务的方式记录它。架构师还负责解决方案的使用技术、解决方案和结构,这些技术、解决方案和结构符合目的,经得起未来考验,而且价格合理。
  • Scrum 伙伴帮助开发团队遵循敏捷方法,并指导团队控制管理和解决问题。

作为一名软件开发人员,你可以选择许多不同的未来道路,我只列出了你现在在企业中可以找到的一些职位。随着技术的发展,我可以想象在 20 年后,软件开发人员将教授和管理人工智能系统,这就是我们所说的编程。谁知道呢?

总结

朝这个方向走是个不错的选择。成为一名 Java 开发人员并成为一名高级开发人员是一项在未来 10 到 20 年甚至更长时间内都能获得丰厚回报的职业。同时,我个人觉得这项技术很吸引人,也很有趣,经过 10 多年的 Java 编程和 35 年的编程,我仍然每天学习新的东西。

在本书中,您学习了 Java 编程的基础知识。我还提到了一些问题,建议了一些方向,并警告您注意一些与 Java 无关的陷阱。但是,我们也做了学习 Java 语言、基础设施、库、开发工具和 Java 网络的功课。您还了解了仅随 Java8 和 Java9 提供的最新方法,例如 Java 中的函数式编程、流和反应式编程。现在您可以开始作为 Java 开发人员工作了。下一步是什么?去寻找编程和 Java 的乐趣吧!

posted @ 2025-09-11 09:43  绝不原创的飞龙  阅读(30)  评论(0)    收藏  举报