Java-开发者的设计模式实践指南-全-

Java 开发者的设计模式实践指南(全)

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

译者:飞龙

协议:CC BY-NC-SA 4.0

前言

Java 语言是与一个非常丰富的平台进行通信的工具,该平台提供了许多为应用程序开发准备就绪的功能。本书通过最有用的设计模式的示例探讨了语言语法的最新发展。本书通过示例实现揭示了功能、模式和平台效率之间的关系。本书探讨了理论基础如何帮助提高源代码的可维护性、效率和可测试性。内容帮助读者解决不同的任务,并提供了使用各种可持续和透明方法解决编程挑战的指导。

本书面向的对象

本书献给所有渴望提高软件设计技能的“饥渴”工程师,他们希望通过新的语言增强和更深入地了解 Java 平台来实现这一目标。

本书涵盖的内容

第一章, 进入软件设计模式,介绍了源代码设计结构的初步基础,并概述了应遵循的原则以实现可维护性和可读性。

第二章, 探索 Java 平台以应用设计模式,讨论了 Java 平台,这是一个非常广泛且强大的工具。本章更详细地介绍了 Java 平台的功能、功能和设计,以继续构建理解使用设计模式的目的和价值的必要基础。

第三章, 与创建型设计模式一起工作,探讨了对象实例化,这是任何应用程序的关键部分。本章描述了在考虑需求的同时如何应对这一挑战。

第四章, 应用结构型设计模式,展示了如何创建允许所需对象之间关系清晰的源代码。

第五章, 行为设计模式,探讨了如何创建允许对象进行通信和交换信息的同时保持透明形式的源代码。

第六章, 并发设计模式,讨论了 Java 平台及其本质上是一个并发环境。它展示了如何利用其力量为设计应用程序的目的服务。

第七章, 理解常见反模式,处理在任何应用程序开发周期中可能遇到的反模式。它将帮助您处理根本原因及其识别,并提出可能的反模式补救措施。

要充分利用本书

要执行本书中的说明,您需要以下内容:

本书涵盖的软件/硬件 操作系统要求
Java 开发工具包 17+ Windows、macOS 或 Linux
推荐的 IDE VSCode 1.73.1+ Windows、macOS 或 Linux
文本编辑器或 IDE Windows、macOS 或 Linux

对于本书,需要安装 Java 开发工具包 17+。要验证它是否在您的系统上可用,请执行以下命令:

  • Windows 命令提示符:java –version

  • Linux 或 macOS 系统命令行:java –version

预期输出:

openjdk version "17" 2021-09-14
OpenJDK Runtime Environment (build 17+35-2724)
OpenJDK 64-Bit Server VM (build 17+35-2724, mixed mode,
sharing)

如果您在本地机器上没有安装 JDK,请在dev.java/learn/getting-started-with-java/搜索您平台的相关说明,并在jdk.java.net/archive/找到匹配的 JDK 版本。

要下载和安装 Visual Studio Code,请访问code.visualstudio.com/download

以下页面描述并指导了 VSCode 终端的使用:code.visualstudio.com/docs/terminal/basics

下载示例代码文件

您可以从 GitHub 下载本书的示例代码文件:github.com/PacktPublishing/Practical-Design-Patterns-for-Java-Developers。如果代码有更新,它将在 GitHub 仓库中更新。

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

下载彩色图像

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

使用的约定

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

文本中的代码:表示文本中的代码单词、数据库表名、文件夹名、文件名、文件扩展名、路径名、虚拟 URL、用户输入和 Twitter 昵称。以下是一个示例:“让我们检查Vehicle类开发中的泛化过程。”

代码块设置如下:

public class Vehicle {
    private boolean moving;
    public void move(){
        this.moving = true;
        System.out.println("moving...");
    }

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

sealed interface Engine permits ElectricEngine,
    PetrolEngine  {
    void run();
    void tank();
}

任何命令行输入或输出都应如下所示:

$ mkdir main
$ cd main

粗体:表示新术语、重要单词或您在屏幕上看到的单词。例如,菜单或对话框中的单词以粗体显示。以下是一个示例:“字节码正在运行一个Java 虚拟****机器JVM)。”

小贴士或重要注意事项

看起来像这样。

联系我们

我们欢迎读者的反馈。

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

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

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

如果您想成为一名作者:如果您在某个领域有专业知识,并且对撰写或参与书籍感兴趣,请访问authors.packtpub.com

分享您的想法

一旦您阅读了Java 开发者实用设计模式,我们很乐意听到您的想法!请点击此处直接进入此书的亚马逊评论页面并分享您的反馈。

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

下载本书的免费 PDF 副本

感谢您购买本书!

您喜欢在路上阅读,但无法携带您的印刷书籍到处走?您的电子书购买是否与您选择的设备不兼容?

不要担心,现在,随着每本 Packt 书籍,您都可以免费获得该书的 DRM 免费 PDF 版本。

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

优惠远不止于此,您还可以获得独家折扣、时事通讯和每日免费内容的每日电子邮件。

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

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

packt.link/free-ebook/9781804614679

  1. 提交您的购买证明

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

第一部分:设计模式和 Java 平台功能

本部分涵盖了软件设计模式的目的。它概述了面向对象编程 APIE 和 SOLID 设计原则的基本思想,并介绍了 Java 平台,这对于理解如何有效地利用设计模式至关重要。

本部分包含以下章节:

  • 第一章进入软件设计模式

  • 第二章探索 Java 平台的设计模式

第一章:进入软件设计模式

每个软件架构师或开发者通常都会面临结构化代码的挑战——如何开发一个可持续的代码结构,就像艺术家绘制他们的画作一样。本章将带我们进入编写程序代码的旅程。您将探索代码结构和组织背后的挑战。我们将从面向对象编程的支柱——APIE 的早期阶段开始探讨这个主题。我们还将回顾 SOLID 原则,以获得对设计模式的理解清晰。

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

  • 代码——从符号到程序

  • 检查面向对象编程和 APIE

  • 理解 SOLID 设计原则

  • 设计模式的重要性

  • 回顾设计模式解决的问题

到本章结束时,您将回顾基本的编程概念,这些概念将构成本书其余部分的基础。

技术要求

您可以在 GitHub 上找到本章的代码文件,链接为github.com/PacktPublishing/Practical-Design-Patterns-for-Java-Developers/tree/main/Chapter01

代码——从符号到程序

人类语言是富有成效的、丰富的、多彩的,远远超出了单词本身可能表达的内容。可以用名词、动词和形容词来精确地表达一个时刻或动作。相比之下,机器不能理解人类能够创造出的复杂结构或表达。

机器语言是有限的、定义明确的、极其具体的,并且是简化的。它的目标是提供精确的表达意图,这正是它被设计的目的。这与人类语言的目的形成对比,人类语言的目的仅仅是沟通,而不一定是具体的内容。

机器的意图可以通过定义明确的指令或一系列指令来表达。这意味着机器理解这些指令。这些指令必须在执行时以某种形式提供给机器。每种机器通常都有一组指令。基于这种指令集,机器可以执行所需的指令,如下所示:

图 1.1 – CPU 内部的简化指令周期(指令来自内存,结果存储在其中)

图 1.1 – CPU 内部的简化指令周期(指令来自内存,结果存储)

让我们探索一条单独的指令。指令可以被理解为给处理器的命令。处理器是机器的心脏,或者说是过程排序和执行的中心。机器可能包含一个或多个处理器。这取决于其设计,但无论如何,总有一个起主导作用。为了进一步简化,我们只考虑一个——也就是说,考虑一个只有中央处理单元CPU)的系统,该系统专门用于执行程序。

CPU 是一种执行包含计算机程序指令的设备。CPU 必须包含如前图所示的指令集,以处理请求的操作。

由于指令的形式可能完全不同,取决于 CPU,因此没有定义的标准。这促进了不同的 CPU 平台,这并不一定是坏事,并有助于进化。然而,事实仍然是,指令对人们来说不易阅读。

我们已经说过,机器可以执行指令集合,理想情况下是一个连续的流程。指令的流程可以简化为内存中的队列,其中一条指令进入,另一条离开。CPU 扮演着与这种内存循环工作的解释者的角色(正如我们在图 1.1中看到的)。好的,所以 CPU 进行了解释,但随着指令被添加到内存中,它们从何而来,如何创建这样的流?

让我们收集一些想法。在大多数情况下,机器指令来自编译器。

什么是编译器?编译器可以看作是一个 CPU 或特定平台的程序,它将文本转换为目标操作。我们用来调用程序和结果的文本可以命名为机器码。以下图示说明了这一点:

图 1.2 – 从源代码通过编译器程序到其结果的简化平台特定流程

图 1.2 – 从源代码通过编译器程序到其结果的简化平台特定流程

机器码是机器理解的一种低级语言,由按顺序处理的指令组成(见图 1.1);程序被编译、执行和运行。

在 Java 的情况下,没有机器码:

图 1.3 – Java 程序通过编译器到其平台执行的简化流程

图 1.3 – Java 程序通过编译器到其平台执行的简化流程

源代码由 Java 编译器编译成字节码。字节码运行在Java 虚拟机JVM)中(见图 1.3)。在这种情况下,JVM 充当字节码和 CPU 上实际执行指令之间的接口。JVM 模拟字节码指令。它通过 JVM 的一部分即时编译器JIT)来完成这项工作。JIT 编译器将字节码指令转换为本地处理器指令。JVM 是一个特定平台的解释器,类似于直接编译的代码(见图 1.2)。JVM 还提供了额外的功能,如内存管理和垃圾回收,这使得 Java 平台如此强大。所有这些功能都允许开发者一次编写代码,编译成字节码,并在支持的平台上运行——这被称为一次编写,到处运行WORA)。

在前一次探索的背景下,Java 是一种高级语言,它被翻译成低级语言。Java 提供了从计算机功能细节中的强大抽象。它允许程序员为复杂挑战创建更简单的程序。

在这一点上,我们开始共同探索标准化解决方案的旅程。在本书的后面部分,我们将回顾如何创建具有较少内存需求的可维护和可扩展的代码。我们将一起讨论不同类型的设计模式,这些模式可以帮助我们使日常工作变得可理解、透明,并且更有趣。

检查面向对象编程(OOP)和 APIE

在上一节中,我们学习了如何将用高级语言编写的程序转换成由 CPU 处理的机器指令。高级语言通过遵循语言实现的细节来提供一个表达所需想法的框架。这些语言通常提供许多整洁的结构或语句,这些结构或语句不会限制想象力。在面向对象编程OOP)语言中,核心载体的表示是通过对象的概念来呈现的。本书专注于 Java 语言。Java 是一种具有额外功能的完全面向对象的语言。面向对象语言究竟意味着什么?在计算机科学中,这意味着程序关注类的概念,其中这些类的实例代表一个对象。接下来,我们将重复 OOP 范式的重点,并处理一些基本概念。

这些术语可以用抽象、多态、继承和封装APIE)的缩写来表示。APIE 字母表示 OOP 语言的四个基本支柱。让我们按相反的顺序分别检查每个词 - 因此,EIPA。动机是为了使我们对 OOP 概念的理解更加清晰。

只暴露所需的内容 - 封装

按照相反的顺序,首先是封装 - 让我们从它开始。面向对象的语言,包括 Java,与类这一概念一起工作。想象一下,一个类就像一辆车。类提供了所有可以静态类型或对象特定的字段 - 也就是说,在对象在分配的内存中实例化之后启动。在类或对象方法的概念上,这一概念是相似的。方法可能属于一个类或其实例 - 在考虑的例子中,属于一辆车。任何方法都可以在对象或类字段上工作,并改变车辆的内部状态或字段值(见 示例 1.1):

public class Vehicle {
    private boolean moving;
    public void move(){
        this.moving = true;
        System.out.println("moving...");
    }
    public void stop(){
        this.moving = false;
        System.out.println("stopped...");
    }
}

示例 1.1 - 车辆类隐藏内部状态(移动)

我们可以将封装应用于车辆的例子。我们想象一辆真实的车辆——只有一辆。在这样的想象车辆中,所有内部元素和内部函数都隐藏在驾驶员视线之外。它只暴露其提供的功能,例如方向盘,驾驶员可以控制。这就是封装的一般原则。实例的状态可以通过公开的方法或字段进行更改或更新;其他一切对外界都是隐藏的。使用方法修改实例的内部数组或数组集合是一种相当好的做法。但我们在本书的后面会重复这一点。到目前为止,这只是一个好的提示。

不可避免的发展——继承

在上一节中,我们创建了一个想象中的车辆类实例。我们封装了所有不应向驾驶员公开的功能。这意味着驾驶员可能不知道发动机是如何工作的,只知道如何使用它。

本节专门讨论继承属性,我们将在以下示例中展示。假设车辆的发动机损坏了。我们该如何更换它?目标是用一台功能正常的发动机来替换现有的发动机。以这种方式工作的发动机可能并不一定相同,尤其是如果车辆型号已经有一些市场上无法获得的旧部件。

我们所做的是从创建新发动机所需的所有属性和函数中派生出来的。关于类,新的替换模块将在类层次结构中成为子类。

虽然发动机可能不是完美的复制品,并且没有相同的唯一对象标识符,但它将匹配所有父属性。

有了这些,我们已经描述了面向对象编程中继承的第二大支柱——在现有子类之上创建新类的功能。然而,软件设计师应该警惕第四大支柱——封装,以及子类依赖于其超类实现细节所引起的任何违规行为。

需求行为——多态性

第三个概念是多态性。稍加想象,这可以理解为“多种形式”。那么,这在这里意味着什么呢?

对于前面描述的车辆,它可以定义为以多种方式执行特定动作的能力。这意味着,在车辆的情况下,其他方法move的运动可能会根据输入或实例的状态而有所不同。

Java 允许两种类型的多态性,它们在运行时行为上有所不同。我们将详细讨论这两种类型。

方法重载

这种类型被称为静态多态性。这意味着正确的方法是在程序编译期间解决的——所以,在编译时。Java 提供了两种类型的方法重载:

  • 改变输入参数类型:

图 1.4 – 通过更改输入类型重载 Vehicle 类的方法

图 1.4 – 通过更改输入类型重载 Vehicle 类的方法

  • 改变方法参数的数量:

图 1.5 – 通过改变参数数量来重载 Vehicle 类的方法

图 1.5 – 通过改变参数数量来重载 Vehicle 类的方法

现在,让我们看看多态的第二种类型。

方法重写

这有时被称为动态多态。这意味着执行的方法在运行时已知。重写的方法通过引用属于该对象实例的引用来调用。让我们通过一个简单的例子来说明这一点。考虑一个父类Vehicle(参见图 1**.6示例 1.2)中名为move的方法:

图 1.6 – 父类和子类重写 move 方法之间的关系

图 1.6 – 父类和子类重写 move 方法之间的关系

我们打算创建一个子类Car,具有类似名称的move方法。子类提供略微不同的功能,因为Car实例比父实例Vehicle移动得更快:

public class Vehicle {
    public void move(){
        System.out.println("moving...");
    }
}
public class Car extends Vehicle {
    @Override
    public void move(){
        System.out.println("moving faster.");
    }
}
Vehicle vehicle = new Car();
vehicle.move();
output: moving faster...

示例 1.2 – Vehicle 变量持有 Car 实例的引用,并在运行时执行适当的 move 方法(参见图 1.6)

我们将在第三章中更详细地讨论这个主题,使用创建型设计模式

标准特性 – 抽象

最后要覆盖的字母(但在缩写 APIE 中的第一个字母)引导我们到达迄今为止未指定的抽象支柱。这个概念的关键是不断去除具体或个别细节,以达到对象目的的泛化。

为了获得这个概念的最佳体验,让我们以车辆为例来探讨这个背景。我们并不打算描述属于一组车辆的具体汽车型号。我们的目标是定义一个所有考虑的车辆类型都可以在我们的努力中包含的通用功能。有了这样的知识,我们创建一个合适的抽象,一个可以在构建特定模型类时继承的抽象类(参见示例 1.3)。

这种方法使我们能够将精力集中在泛化和抽象车辆特性上。这可以对我们减少代码和提高可重用性产生积极影响。

在 Java 中,可以通过两种方式实现抽象:

  • 带有抽象方法的抽象类(参见示例 1.3图 1**.7):

图 1.7 – 带有 CommonCar 实现和 SportCar 类的 AbstractVehicle 类

图 1.7 – 带有 CommonCar 实现和 SportCar 类的 AbstractVehicle 类

public abstract class AbstractVehicle {
    abstract public void move();
    public void stop(){
        System.out.println("stopped...");
    }
}
public class CommonCar extends AbstractVehicle{
    @Override
    public void move() {
        System.out.println("move slow...");
    }
}
public class SportCar extends AbstractVehicle{
    @Override
    public void move() {
        System.out.println("move fast...");
    }
}

示例 1.3 – 使用抽象类概念提取通用功能,而不提供特定的实现

  • 使用接口(参见示例 1.4图 1**.8)和通用抽象方法:

图 1.8 – 使用接口实现的抽象概念

图 1.8 – 使用接口实现的抽象概念

public interface VehicleInterface {
    void move();
}
public class Truck implements VehicleInterface{
    @Override
    public void move() {
        System.out.println("truck moves...");
    }
}
public class Bus implements VehicleInterface{
    @Override
    public void move() {
        System.out.println("bus moves...");
    }
}

示例 1.4 – 使用 Java 接口进行类似的功能提取

这两种抽象概念可以结合使用(见图1.9):

图 1.9 – 两种抽象概念的组合

图 1.9 – 两种抽象概念的组合

抽象类和接口在代码结构的设计中都有其位置。它们的使用取决于需求,但两者都对代码的可维护性产生了非常积极的影响,并有助于设计模式的使用。

将部分粘接到 APIE

前几节提到的每个支柱的动机是通过一组给定的概念将结构引入代码。这些支柱是定义明确的且相互补充的。让我们仅考察一个单元,即Vehicle类及其实例。实例逻辑和数据通过方法封装并暴露给外部世界。车辆特性可以继承,以便可以指定新的车辆设计,例如新车型。公开的方法可以提供基于模型的行为和传入参数的内部实例状态变化。当我们对新的车辆进行概念化时,我们总能概括其行为并使用抽象类或接口提取它。

让我们考察Vehicle类开发中的泛化过程。当准备定义新的车辆模型时,我们总能概括其特性并使用抽象类或接口提取它。让我们看一下以下图表:

图 1.10 – 将 APIE 视为一个持续改进的过程

图 1.10 – 将 APIE 视为一个持续改进的过程

尽管这四个支柱看似微不足道,但遵循它们是极其困难的,正如我们将在以下章节中继续展示的那样。

到目前为止,在本节中,我们学习了 OOP 的四个基本支柱,并考察了这些原则如何影响代码设计。接下来,我们将学习更多关于可持续代码设计概念的知识。让我们继续到下一节。

理解 SOLID 设计原则

在前几节中,我们介绍了结构化工作的概念。通过示例详细阐述了 APIE 的开发支柱。您已经对面向对象原则中的类实例概念有了基础的理解,以及我们如何创建不同类型的特定对象:

图 1.11 – 车辆 N,其中 N 是一个正整数,代表车辆类的一个实例

图 1.11 – 车辆 N,其中 N 是一个正整数,代表车辆类的一个实例

类可以被实例化,使得一个实例成为一个对象。对象必须适应空闲内存。我们说对象分配内存空间。当考虑 Java 时,分配的内存是物理系统内存内的虚拟空间。

顺便提一下 - 我们之前讨论了 JVM 的存在,它是为所需平台编译的字节码的解释器(参见 图 1.3)。我们提到了其他 JVM 功能,其中之一是内存管理。换句话说,JVM 负责分配虚拟内存空间。这个虚拟内存空间可以用来分配一个类的实例。这个虚拟内存及其碎片由 JVM 负责处理,未使用的对象通过选定的垃圾收集算法进行清理,但这超出了本书的范围,将是进一步研究的主题(参见 参考文献 1)。

每个程序员,尽管一开始可能并不明显,都扮演着软件设计师的角色。程序员通过编写代码来创建代码。代码承载着一种思想,这种思想根据输入的文本被语义地转化为行动。

随着时间的推移,软件开发已经经历了许多阶段,许多关于软件维护和可重用性的文章已经被撰写并发表。软件开发的一个里程碑可能被认为是 2000 年,当时罗伯特·C·马丁发表了关于 设计原则和设计模式 的论文(参见 参考文献 2)。这篇论文回顾和检查了软件开发设计和实现的技术。这些技术后来在 2004 年被简化为记忆法缩写 SOLID。

SOLID 原则的目标是帮助软件设计师使软件及其结构更加可持续、可重用和可扩展。在接下来的章节中,我们将检查 SOLID 缩写中隐藏的每个单独的术语。

单一职责原则(SRP) - 发动机只是一个发动机

第一原则是一个定义良好的类目标。我们可以这样说,每个类应该只有一个存在的理由。也就是说,它只对功能的一部分有意图和责任。这个类应该封装这个程序的部分。让我们用一个例子来说明这一点。想象一下之前的车辆及其抽象的例子。我们现在通过 EngineVehicleComputer 类扩展这个类,如下所示:

图 1.12 – 使用 Engine 和 VehicleComputer 实现的 Vehicle 类实例,但发动机功能不会干扰灯光

图 1.12 – 使用 EngineVehicleComputer 实现的 Vehicle 类实例,但发动机功能不会干扰灯光

发动机可以启动和停止,但 Engine 类的实例不能控制车辆灯光,例如。灯光控制是车辆计算机类实例的责任。

开放封闭原则(OCP)

这个原则指出,正在考虑的类或实体应该是开放的,可以扩展,但对修改应该是封闭的。它与已经提到的概念相辅相成。让我们以一个例子来阐述这一点,在这个例子中,我们考虑CarTruck类。这两个类都继承了Vehicle接口。它们都认为车辆实体有一个move方法。

如果不考虑适当的抽象,不尊重 OCP(开闭原则),代码在类不易重用或难以处理时可能会遇到意外的困难(参见示例 1.5):

public interface Vehicle {}
public class Car implements Vehicle{
    public void move(){}
}
public class Truck implements Vehicle {
    public void move(){}
}
-- usage --
List<Vehicle> vehicles = Arrays.asList(new Truck(), new 
    Car());
vehicles.get(0).move() // ERROR, NOT POSISBLE!

示例 1.5 – 虽然TruckCar都被视为实体,但它们继承了Vehicle接口,move方法符合规范,这导致在扩展或执行时出现问题

在这种情况下,对当前示例的修正非常简单(参见示例 1.6):

public interface Vehicle {
    void move();    // CORRECTION!
}
--- usage ---
List<Vehicle> vehicles = Arrays.asList(new Truck(), new 
    Car());
vehicles.get(0).move() // CONGRATULATION, ALL WORKS!

示例 1.6 – Vehicle接口提供了一个移动抽象方法

显然,随着代码的演变,不遵守原则会导致意外的挑战。

Liskov 替换原则(LSP) – 类的可替换性

前几节讨论了继承和抽象作为 OOP(面向对象编程)的两个关键支柱。对于那些仔细阅读的人,给定父-子关系的类层次结构,一个子类可以被其父类替换或表示,反之亦然,这不会令人惊讶(参见示例 1.7)。让我们看看CarWash的例子,你可以清洗任何车辆:

public interface Vehicle {
    void move();
}
public class CarWash {
    public void wash(Vehicle vehicle){}      
}
public class Car implements Vehicle{
    public void move(){}
}
public class SportCar extends Car {}
--- usage ---
CarWash carWash = new CarWash();
carWash.wash(new Car());
carWash.wash(new SportCar());

示例 1.7 – 一个CarWash示例,其中任何车辆类型都可以由类层次结构中的适当类实例替换

这意味着相似类型的类可以类似地行动,并替换原始类。这个说法最早是在 1988 年由 Barbara Liskov 在主题演讲中提出的(参见参考文献 3)。该会议专注于数据抽象和层次结构。这个说法基于类实例和接口分离的可替换性。让我们看看接口分离。

接口分离原则(ISP)

这个原则指出,一个类的任何实例都不应该被迫依赖于未使用或在其抽象中的方法。它还提供了如何构建接口或抽象类的指导。换句话说,它控制了如何将预期的方法划分为更小、更具体的实体。客户端可以透明地使用这些实体。为了指出一个恶意实现,考虑CarBike作为Vehicle接口的子类,它们共享所有抽象方法(参见示例 1.8):

public interface Vehicle {
    void setMove(boolean moving);
    boolean engineOn();
    boolean pedalsMove();
}
public class Bike implements Vehicle{
    ...
    public boolean engineOn() {
        throw new IllegalStateException("not supported");
    }
    ...
}
public class Car implements Vehicle {
    ...
    public boolean pedalsMove() {
        throw new IllegalStateException("not supported");
    }
}
--- usage ---
private static void printIsMoving(Vehicle v) {
    if (v instanceof Car) { 
        System.out.println(v.engineOn());}
    if(v instanceof Bike) 
        {System.out.println(v.pedalsMove());}
}

示例 1.8 – 继承方法抽象的多种实现

一些具有敏锐眼光的人可能已经注意到,这种软件开发方向通过不必要的操作(例如异常)对软件灵活性产生负面影响。补救措施基于对 ISP 的严格遵守,以一种非常透明的方式进行。考虑两个额外的接口,HasEngineHasPedals,以及它们各自的功能(参见示例 1.9)。这一步迫使printIsMoving方法进行重载。整个代码对客户端来说是透明的,并且不需要任何特殊处理来确保代码稳定性,以异常为例(如示例 1.8所示):

public interface Vehicle {
    void setMove(boolean moving);
}
public interface HasEngine {
    boolean engineOn();
}
public interface HasPedals {
    boolean pedalsMove();
}
public class Bike implements HasPedals, Vehicle {...}
public class Car implements HasEngine, Vehicle {...}
--- usage --- 
private static void printIsMoving(Vehicle v){
    // no access to internal state
}
private static void printIsMoving(Car c) {
    System.out.println(c.engineOn());
}
private static void printIsMoving(Bike b) {
    System.out.println(b.pedalsMove());
}

示例 1.9 – 根据目的将功能拆分为更小的单元(接口)

引入了两个接口,HasEngineHasPedals,它们强制方法代码重载和透明性。

依赖倒置原则(DIP)

每个程序员,或者更确切地说,软件设计师,在其职业生涯中都会面临层次结构类组合的挑战。以下 DIP 是一个关于如何接近它的非常简单的指南。

原则建议低级类不应该了解高级类。相反,这意味着高级类,即位于上面的类,不应该了解较低层次的基本类(参见示例 1.10SportCar类):

public interface Vehicle {}
public class Car implements Vehicle{}
public class SportCar extends Car {}
public class Truck implements Vehicle {}
public class Bus implements Vehicle {}
public class Garage {
    private List<Vehicle> parkingSpots = new ArrayList<>();
    public void park(Vehicle vehicle){
        parkingSpots.add(vehicle);
    }
}

示例 1.10 – 车库实现依赖于车辆抽象,而不是层次结构中的具体类

这也意味着特定功能的实现不应该依赖于特定类,而应该依赖于它们的抽象(参见示例 1.10Garage类)。

设计模式的重要性

前几节介绍了两种互补的软件开发方法——APIE 和 SOLID 概念。开始显现的是,代码以透明形式存在可以因多种原因而有益,因为每个程序员通常,如果不是总是,都会面临设计一段扩展或修改现有代码的挑战。

一位智者曾经说过,“通往地狱的道路是持续的技术债务无知之路...。”任何减缓或阻止应用程序开发的事情都可以被认为是技术债务。用编程语言来翻译,这意味着即使是一小部分,如果现在不重要,那么将来也会重要。这也意味着代码的可读性和目的对于应用程序逻辑至关重要,因为可以验证各种假设(例如,应用程序操作)。

无法执行面向业务的应用程序测试可以被认为是错误开发趋势的第一个迹象。这可能会在验证期间需要使用不同的模拟技术。这种方法很容易变成提供假阳性结果。这通常可以归因于代码结构的杂乱,迫使程序员使用模拟。

虽然 SOLID 和 APIE 概念提出了几个原则,但它们仍然不能保证项目代码库不会开始腐烂。遵守这些原则使这变得困难,但仍然有空间,因为并非所有概念都提供了处理腐烂所需框架。

软件随着时间的推移可能会腐烂的长篇故事可能有很多,但一个不变的事实是,有一种方法可以避免它或让它腐烂。这种治疗方法被一种称为设计模式的想法所涵盖。设计模式的概念不仅涵盖了代码库的可读性和其目的,还提高了验证所需业务假设的能力。

定义它的背后是什么想法可以让我们更清晰?设计模式的概念可以被描述为一套可重用的编码方法,这些方法解决了在应用开发过程中遇到的最常见问题。这些方法与之前提到的 APIE 或 SOLID 概念一致,并且对带来透明度、可读性和可测试性对开发路径产生了极大的积极影响。简单来说,设计模式的概念为访问软件设计中的常见挑战提供了一个框架。

检查设计模式解决哪些挑战

深吸一口气,思考编写程序的动力。程序是用编程语言编写的,在我们的例子中是 Java,它是一种人类可读的形式,用于解决特定的挑战。让我们从不同的角度来审视它。

我们可以声明编写程序被视为一个目标。在大多数情况下,目标有其原因,由已知的需要或需求定义。期望和限制被定义。当目标已知时,每个行动都是选择以实现它的目标。目标被评估、组织,并放置在目标的环境中,其中目标意味着一个解决所需挑战的工作程序。想象一下前几节中提到的所有困难。

日复一日,提出的是新的解决方案,而不是一个透明的解决方案。每一天,另一个局部成功使项目得以维持,尽管表面上看起来一切都很顺利。

目前,大多数团队遵循 SCRUM 框架。想象一下,当团队遵循 SCRUM 框架(见参考 4)并且应用开发开始偏离目标时的情况。日常站立会议有时运行得顺利:提到发现了一个基本错误。几天后,这个错误被成功修复,并得到了热烈的掌声。有趣的是,这种通知的频率正在增长——更多的修正,更多的掌声。但这真的意味着项目正在朝着目标前进吗?这真的意味着应用程序运行正常吗?让我们看看答案。

有一个更黑暗的一面——待办事项随着功能和技术债务的增长。技术债务并不一定是一件糟糕的事情。技术债务可以刺激项目,在概念验证阶段尤其有用。技术债务的问题在于它没有被认识到、被忽视,并且评估不佳——甚至更糟糕的是,当技术债务开始被标记为新功能时。

尽管产品待办事项应该是一个整体,但它开始由两个不同且不幸不兼容的部分组成——业务和冲刺待办事项(主要是技术债务)。当然,团队正在处理来自规划会议的冲刺待办事项,但随着技术债务的增加,留给产品相关业务功能的空间越来越少。通过这种方式观察到的趋势可能导致在每次新的冲刺规划会议期间出现极其棘手的情况,那时应该分配开发资源。让我们暂时停下来,回顾一下团队由于技术债务而无法推动产品前进的情况。

SCRUM 方法的价值观可以简化为勇气、专注、决心、尊重和开放。这些价值观并不特定于 SCRUM 框架。因为团队的动机是交付产品,它们听起来都非常合理和公平。

现在,让我们刷新一下对团队所达到状态的记忆。这是一个团队无法推动项目前进,并且在定义和适当整合技术部门方面挣扎的状态。这意味着团队正在做它的工作,但可能偏离了实现其最终目标。每一次讨论都极其困难,因为正确解决和描述问题的难度很大,原因有很多。可能看起来开发者可能会失去他们的沟通语言,并开始互相误解。我们可以看到,软件的熵增加了,因为一致性没有得到保持。项目开始腐烂,不可避免地浪费的开发时间增加。

让我们再深呼吸一下,一起思考如何防止这种情况。必须有可能识别这些趋势。通常,每个团队都有一些共性:团队在知识方面并不总是同质化的,但这不应该阻止我们识别学习曲线的退化。

项目学习曲线可以帮助我们识别一个烂项目。而不是逐步向目标改进,团队经历了充满技术修复和解决方案的局部成功。这些成功甚至不符合敏捷开发(SCRUM)的价值观,逐步改进似乎不太可能。解决方案可能不会被看作是改进,因为它针对特定的运动,可能违反了所使用技术的规范。在解决方案期间,团队可能不会获得任何适用于未来的有用知识。这很快就可以被视为由于无法提供业务元素或仅提供部分元素而错失的商业机会。

除了学习曲线的退化,还可以识别出其他症状。这可以描述为无法测试业务功能。项目代码变得难以处理,依赖关系失控,这也会损害代码的可读性、可测试性和,当然,程序员的纪律。软件设计师的日常目标可以简化为关闭工单。

为了避免达到这种状态,本书将在以下章节中通过介绍和质疑不同类型的设计模式,为解决最常见的常见问题提供一些指导。这些设计模式与前面提到的面向对象(OOP)和 APIE 的基本支柱一致,并促进 SOLID 原则。

此外,设计模式可以突出任何误解的方向,并强制执行不要重复自己(DRY)原则。因此,项目中的代码重复较少,可测试性更高,更有趣。

这就结束了本章的内容。

摘要

在我们开始研究设计模式之旅之前,让我们快速总结一下。本章扩展或改进了我们对于各个领域的理解。这些领域从不同的角度影响程序代码:

  • 代码透明度和可读性

  • 解决复杂挑战的能力

  • 遵循 SOLID 和面向对象(OOP)原则

  • 代码的可测试性(可以验证代码的目的)

  • 易于扩展和修改

  • 支持持续重构

  • 代码是自我解释的

程序代码已经编写完成——做得好。下一章将带我们了解实现平台的概述——在我们的案例中,是 Java 平台。我们将更详细地了解如何运行程序以及这意味着什么。

问题

  1. Java 代码是如何被解释到平台上的,又是如何解释的?

  2. APIE 这个缩写代表什么?

  3. Java 语言允许哪些类型的多态?

  4. 哪个原则帮助软件设计师产生可维护的代码?

  5. OCP 代表什么?

  6. 在设计模式方面应该考虑什么?

进一步阅读

  • 《垃圾回收手册:自动内存管理的艺术》,作者:Anthony Hosking,J. Eliot B. Moss,Richard Jones,CRC Press,ISBN-13:978-1420082791,ISBN-10:9781420082791,1996 年。

  • 《设计原则与设计模式》,作者:Robert C. Martin,Object Mentor,2000 年。

  • 主旨演讲 - 数据抽象和层次结构,芭芭拉·利斯科夫,dl.acm.org/doi/10.1145/62139.62141,1988 年。

  • SCRUM 框架,www.scrum.org/,2022 年。

第二章:探索 Java 平台的设计模式

多年前,由于缺乏合适的应用程序编程接口API)设计,一些非凡的事情开始发生。在早期使用万维网WWW)的时候,应用开发的走向有些模糊不清。一方面,行业对处理大量数据库事务或开发特定专有硬件和软件有强烈的需求。另一方面,不清楚需要什么样的应用程序来推动需求的发展,以及这样的应用程序应该如何维护。

在本章中,我们将从内存利用的角度为理解设计模式的价值做好准备。我们将通过以下主题来实现这一点:

  • Java 的兴起和简要历史事实

  • Java 平台内部的工作原理

  • 探索 Java 内存区域分配和管理

  • 通过垃圾收集维护分配的堆内存

  • 在平台上运行第一个程序

  • Java 平台的线程特性

  • 检查核心 Java API 及其在软件设计中的价值

  • 探索 Java 平台模块系统的重要性

  • 探索新的有用平台增强功能

  • 介绍 Java 并发

到本章结束时,你将对 Java 平台上的内存分配、平台保证、核心 API 等有良好的理解。结合上一章的内容,这些主题将形成一个良好的基础,以便你可以带着对设计模式益处的充分认识开始学习。

技术要求

本章的代码文件可在 GitHub 上找到,链接为github.com/PacktPublishing/Practical-Design-Patterns-for-Java-Developers/tree/main/Chapter02

敲响 Java 的大门

在 20 世纪 90 年代初,Sun Microsystems 的一个小团队成立,目的是探索新的领域。团队从考虑扩展当时可用的 C++特性开始。其中一个目标是为小型智能设备引入新一代软件。软件的可重用性是这一目标的一部分。小型智能设备,如机顶盒,内存有限,必须明智地使用其资源。内存,以及其他因素,如复杂性、易出错的程序,以及可能詹姆斯·高斯林的语言扩展尝试,最终导致了 C++想法的放弃。为了避免与 C++的斗争,创造了一种新的语言,称为Oak。由于商标问题,新创建的语言 Oak 被更名为 Java。

第一个公开的 Java 版本 1.0a.2,连同 HotJava 浏览器,于 1995 年在 SunWorld 会议上由 Sun Microsystems 的科学总监 John Gage 宣布。他参与了将 Java 语言从小型硬件设备的语言重新定位为 WWW 应用程序的平台。在这些早期日子里,Java 被用作网站的一部分,使用一种称为 applet 的技术。Java applet 是小型沙盒,由具有有限访问权限的框架定义,并能够在本地Java 虚拟机JVM)上执行 Java 字节码。Applet 位于网络浏览器中或作为独立应用程序;它们是支持 Java 的一个基本原则——一次编写,到处运行WORA)的非常强大的工具。然而,由于许多问题(如安全和稳定性),applet 技术被标记为移除(Java SE 17)。

Java 平台由三个主要部分组成(图 2.1):

图 2.1 – Java 开发工具包架构

图 2.1 – Java 开发工具包架构

这些部分如下:

  • 一个 JVM

  • Java SE(标准版)运行时环境JRE

  • Java SE 开发工具包JDK

让我们开始一段激动人心的旅程,通过平台本身及其各个部分。

探索 Java 平台模型和功能

历史已经证明,预期的方向可以演变或改变:Java 是一个很好的例子,也不例外。从其原始目的来看,它已经从智能设备的平台转变为整个 Web 解决方案的平台,但它的开发并没有停止在这里。多年来,Java 已经成为应用开发中最广泛使用的语言之一。这可以被视为基本硬件独立性的副作用。它极大地发展了一套可用的工具,并得到了一个充满活力的社区的积极响应。

让我们逐个回顾平台(从图 2.1)的各个部分,这将有助于我们更好地理解编写代码。

JDK

JDK 是一个提供开发和分析 Java 应用程序所需工具和库的软件开发环境。JDK 提供了一组基本库、函数和程序,用于将编写的代码编译成字节码。JDK 包含运行应用程序所需的 JRE。JDK 还提供了一些非常实用的工具,例如以下示例:

  • jlink:这有助于生成自定义 JRE

  • jshell:这是一个方便的读取-评估-打印-循环REPL)工具,用于尝试 Java 语言

  • jcmd:这是一个实用程序,可以向活动的 JVM 发送诊断命令

  • javac:这是一个 Java 编译器,它读取具有.java后缀的输入文件,并生成具有.class后缀的 Java 类文件

  • java:这执行 JRE

  • 其他:位于 JDK bin目录中

代码被编写(示例 2.1)并存储在.java文件中,使用javac命令进行编译:

public class Program {
    public static void main(String... args){
        System.out.println("Hello Program!");
    }
}

示例 2.1 – 简单的 Java 程序作为一个可执行类,也可以直接运行而不需要编译步骤,因为 Java SE 11 (参考 26)

接下来,可以创建并编译一个包含字节码的类(示例 2.2)。使用 java 命令运行文件以运行 JRE:

...
public static void main(java.lang.String...);
descriptor: (Ljava/lang/String;)V
flags: (0x0089) ACC_PUBLIC, ACC_STATIC, ACC_VARARGS
Code:
stack=2, locals=1, args_size=1
        0: getstatic     #7      // Field java/lang/
            System.out:Ljava/io/PrintStream;
        3: ldc           #13       // String Hello Program!
...

示例 2.2 – 由 Java 程序显示的编译程序的字节码示例

JRE

JRE 是 JDK 的一部分,或者它可以作为针对目标操作系统的独立程序进行分发。要运行扩展名为 .class 的文件或 Java 归档JAR)文件,目标系统必须包含适当的 JRE 版本。与 JDK 不同,JRE 只包含运行程序所需的最小组件集合,例如以下内容:

  • 核心库和属性文件:例如,rt.jarcharset.jar

  • Java 扩展文件:可能位于 lib 文件夹中的附加库

  • 安全相关文件:证书、策略等

  • 字体文件

  • 特定于操作系统的工具

JRE 包含 JVM 和精确两种类型的编译器:

  • 客户端编译器:快速加载而不进行优化。它旨在快速运行指令以获得结果。通常用于独立程序。

  • 服务器编译器:加载的代码会经过额外的检查以确保代码稳定性。还有努力产生高度优化的机器代码以提供更好的性能。它支持更好的统计信息,以便运行由 即时JIT)编译器执行的机器代码优化(图 2.2)。

JVM

JDK 和 JRE 都包含 JVM(参考 6)。JVM 是平台相关的。这意味着每个系统平台都需要使用专用版本。好吧,但 JVM 究竟做什么,以及如何做?

虽然有多个版本的 JVM,甚至来自多个供应商,但 JVM 本身是由一个必须遵循的规范定义的。参考实现由 OpenJDK 表示。实际上,OpenJDK 是几个较小的开源项目的集合,这些项目甚至可能具有不同的开发动态,但 OpenJDK 发布包含每个计划的版本。

![图 2.2 – JVM 的关键部分图 2.2 – JVM 的关键部分 OpenJDK JVM 实现包括一个称为 HotSpot参考 7)的 JIT 编译器。HotSpot 是 JVM 的一部分,其责任是运行时编译。换句话说,JIT 编译器在运行时将提供的字节码转换为本地系统指令。这个过程有时被称为 动态转换。由于 JVM 的这些动态转换能力,Java 应用程序有时被称为系统平台无关,并使用 WORA 这个缩写。这个陈述需要稍微抽象一下,因为需要 JVM 系统实现来将字节码转换为本地指令。除了 JVM JIT 编译器外,它还包括具有各种算法的垃圾收集器、类加载器、Java 内存实现模型以及带有库的Java 本地接口JNI)(如图 2.2*所示)。每个 JVM 提供者都必须遵循规范。这保证了字节码不仅会被相应地创建,而且会被执行并正确地转换为机器指令。这意味着不同的供应商可能提供具有略微不同指标或优化(如垃圾收集器动态)的不同 JVM 实现。这些供应商包括 IBM、Azul、Oracle 等。供应商的多样性可以被认为是 Java 平台演化的主要推动因素之一。新功能通过JDK 增强提案JEP)进行扩展或修改,其中每个供应商都可以做出贡献或获得非常详细的概述。总结一下,JVM 需要记住的责任如下:+ 加载链接+ 初始化类和接口+ 程序指令执行 JVM 定义了多个不同区域,每个程序都会使用到(示例 2.2)。让我们逐个查看它们,一个区域一个区域(*图 2.2*)。这可以增强我们对设计模式及其方法,如 Builder 或 Singleton 模式的价值的理解。图 2.3 – 程序编译和执行的简化方案

图 2.3 – 程序编译和执行的简化方案

一切始于书面文本,代表存储在.java文件中的程序。文件将被编译(图 2.3)并运行(图 2**.4),线程将被启动。启动启动了运行 JRE 的系统进程,JVM 作为 JRE 的一部分运行。

图 2.4 – 程序.java 执行示例背后的场景事件启动的线程(Java 飞行记录器)

图 2.4 – 程序.java 执行示例背后的场景事件启动的线程(Java 飞行记录器)

在对流程有一个大致的了解后,我们首先将类加载到内存中。

类加载器加载区域

类加载器子系统位于随机存取存储器RAM)中,负责将类加载到内存中。加载步骤包括子步骤和类在运行时的首次运行。链接是将类或接口准备为运行时环境的过程,这可能包括内部依赖项,例如。平台提供内部函数或自定义函数;为了管理所有这些功能,平台提供专门的类加载器:

  • BOOTPATH(属性)。

  • lib/ext目录,它是 JRE 安装的一部分。

  • main方法并运行来自服务类或模块路径的类。

  • ClassLoader 可以用来定义动态加载到 JVM 中的自定义类的过程。可以使用用户定义的类目标。类可以位于网络上,存储在文件中的加密状态,或者通过网络下载并在运行时生成。

类加载器按顺序工作。顺序由一个层次结构表示。这意味着每个子类必须引用其父类。这自动定义了二进制类的搜索顺序。

当一个类存在于 RAM 中时,Java 平台会采取行动使该类可供运行时环境使用。Java 平台在幕后运行多个进程,将相关的类数据移动到其他区域,例如栈、堆等。接下来让我们看看栈区域。

栈区域

栈区域(图 2**.2)在运行时为每个线程保留。这是一个用于存储方法引用的小区域。当一个线程执行一个方法时,为该方法创建一个条目并将其移动到栈顶。这种项目被称为栈帧,它引用局部变量的字段、操作数的栈和一个常量池以识别适当的方法。当方法正常执行时(即没有引发任何异常)会移除栈帧。这意味着局部原始变量,如 booleanbyteshortcharintlongfloatdouble 也存储在这里,因此它们对第二个线程不可见。每个线程可以传递一个副本,但这并不共享原始数据。

堆区域

堆是所有类的实例和数组所在分配的内存。堆在启动时分配,并由所有 JVM 启动的线程共享。分配的内存由自动管理系统进程自动回收,也称为垃圾回收GC)。局部变量可以包含对对象的引用。引用的对象位于堆中。

方法区域

方法区域是所有由 JVM 启动的线程共享的。该区域在 JVM 启动时分配。它包含每个类的运行时数据,例如常量池、字段和方法数据、构造函数和方法的代码。提到的不太熟悉的概念可能是常量池。常量池是在将类加载到方法区域的过程中创建的。它包含字符串和原始常量的初始值、引用类的名称以及其他执行加载的类所需的数据,包括编译时已知的常量和必须在运行时解析的字段引用。

程序计数器

程序计数器PC)寄存器是内存中的另一个重要预留区域。它包含创建的程序计数器列表。每个线程的开始都会创建一个 PC 记录,包含特定线程当前执行的指令的地址。该地址指向方法区。唯一的例外是原生方法,它将地址设置为未定义。

原生方法栈

为每个单独的线程启动一个原生方法栈记录。其功能是通过 JNI 提供对原生方法的访问。JNI 与底层系统资源一起操作。不当使用可能导致两种异常状态:

  • 第一个异常出现在线程需要更多栈空间时。在这种情况下,会抛出StackOverflowError错误,程序崩溃,执行状态高于 1。

  • 第二种情况代表尝试向栈中添加更多条目。程序结果是一个OutOfMemoryError错误。这是由于尝试动态扩展已完全分配的内存空间。内存不足,无法为新线程分配新的栈空间。

我们已经检查了加载和执行程序所需的所有区域,我们将了解数据所在区域及其相互关联的方式。逐渐变得明显的是,为了在运行时实现程序的可稳定性和可维护性,有必要以反映潜在限制的方式设计软件,因为预留区域对应于各个区域。

让我们更详细地看看 Java 平台是如何为每个新创建的对象提供可用内存空间的。

回顾 GC 和 Java 内存模型

我们之前提到 JIT 编译器是 JVM 的一部分(图 2.2)。为了刷新一下 JIT 编译器的概念,它负责将字节码转换为系统特定的原生指令。这些指令处理程序可用的基本内存和 I/O 资源。为了正确组织这些指令,Java 平台需要一套规则来保证程序,即字节码,在运行时必须由 JIT 编译器转换为相同的最终状态。因为 Java 平台不直接使用物理内存,而是使用虚拟和缓存的视图,内存管理必须透明。该模型必须提供所需的保证,并被称为Java 内存模型JMM)。

JMM

JMM 描述了线程如何通过访问分配的内存(图 2.2)相互交互。单线程程序的执行可能看起来很明显,因为指令以某种顺序处理,没有外部影响,线程是隔离的。在单线程的情况下(参见示例 2.2中的main方法和图 2.4中的main线程),每次执行指令时都会修改运行区域;没有惊喜。当程序启动多个线程时,情况就改变了。JMM 强制执行其关于可靠 Java 程序执行的保证。JMM 定义了一组规则,用于可能指令顺序更改和由不同线程之间在内存中共享对象引起的执行限制。JMM 严格遵循这些规则的事实,使得 JIT 优化无需担心代码不稳定(保持一致状态)。

规则可以很容易地重新表述,只要线程的执行不违反程序顺序,每个动作都可以更改。基本上,这意味着程序保持一致状态。

对象的锁定或释放由程序顺序控制,每个线程共享修改数据的相应内存视图。内存视图表示由堆表示的已分配物理内存的部分,因为每个创建的对象都位于堆内。

JMM 的一个重要保证被称为 happens-before。它指出,为了保持程序顺序,一个动作总是发生在另一个动作之前。为了更好地理解这条规则,有必要描述系统内存的工作方式,并简要介绍内存的一般类型以及 CPU 如何适应读取值和执行机器指令的过程。

让我们从 CPU 开始。每个 CPU 都包含自己的指令寄存器。JIT 编译器编译的机器代码有一个对可用指令集的引用。CPU 包含一个内部缓存,用于存储主 RAM 中的数据副本。CPU 与保留的 RAM 进行通信。一个 CPU 可以同时运行多个平台线程(取决于 CPU 的类型)。这种实现的结果会修改线程堆栈或堆中的 RAM 状态。然后,运行 Java 应用程序的专用 RAM 被复制到 CPU 缓存中,并由 CPU 寄存器使用(图 2.5)。

图 2.5 – CPU 和内存交互

图 2.5 – CPU 和内存交互

仔细观察的人可能已经注意到,由于内存差异,程序可能会面临由查看程序内存引起的不可预测的困难。当多个线程试图在不加小心处理的情况下更新或读取变量的特定值时,这可能导致以下问题之一:

  • 竞态条件:当两个线程以非同步方式尝试访问相同的值时,就会发生这种情况。

  • 值更新可见性:在多个线程之间共享的变量更新尚未传播到主内存,因此其他线程得到旧值。

为了应对这些挑战,让我们分析一个对变量的实际访问。已知的是,每个值都位于分配的 RAM 堆中。显然,更新每个变量的状态可能会带来一些惩罚,因为每个指令都必须走完整个旅程(图 2.5)。在大多数情况下,这也不是必要的。一个很好的例子是隔离方法的实现(示例 2.2)。然而,在某些情况下,需要从内存中获取变量的实际值,为此 Java 平台引入了volatile关键字。在变量前使用volatile关键字为变量提供了一个保证,即当另一个线程请求值时,它会检查主内存中的当前值。这意味着使用volatile关键字提供了一种 happens-before 的保证,并且每个线程都能看到其真实值。值得注意的是,由于使用volatile提供了一定程度的内存同步,因此应谨慎使用。它的使用与由主内存访问引起的性能限制相关。

在多个线程之间共享变量值的另一种方法是使用synchronized关键字。它的使用为方法或变量提供了一个保证,即每个参与者,即线程,都会被告知这些方法。显然,使用synchronized的主要缺点是所有线程都会被告知对方法或变量的访问,这反过来又会因为内存同步而导致性能下降。与volatile一样,synchronized保证了 happens-before。

JMM 是明亮且新鲜的;我们提到每个新对象都位于堆中(图 2.2)。我们对 JRE 架构的大致情况很熟悉,我们知道大多数 Java 程序似乎是多线程的——Java 平台遵循一组规则,以便进程强制程序的正确顺序以实现一致性。

GC 和自动内存管理

尽管 Java 平台可能给人留下底层内存无限的印象,但这并不正确,我们将在下一节中探讨这一点。到目前为止,我们已经探讨了变量在多个线程之间的可见性工作原理以及如何在物理内存中引用值。JMM 只是整个故事的一部分——让我们继续调查。

我们已经知道 Java 平台使用自动内存管理过程来维护堆分配的内存。这个过程的一部分是一个在守护线程后台运行的程序。它被称为垃圾回收器(参考 5),在幕后默默运行,回收和压缩未使用的内存。这是堆中对象动态分配的一个优点。另一个可能不那么明显的优点是能够处理递归数据结构,例如列表或映射。

垃圾回收(GC)大约在 1959 年由约翰·麦卡锡发明。目标是简化 Lisp 中的手动内存管理。从那时起,GC 经历了大规模的发展,并发明了各种 GC 技术(参考 1)。即使在各种 GC 方法的发展之后,安全规则仍然是最重要的。GC 永远不应该恢复包含活动引用的活对象存储库。

虽然开发者不必担心内存回收,但了解底层过程可以非常有用,以避免意外的应用程序失败,因为系统内存总是有限的。原因在于,即使 GC 已经到位,仍然可能编写永远不会加载对象的代码,这意味着应用程序可能会因为OutOfMemoryError错误而崩溃。

GC 过程的目标是保持堆干净整洁,准备好分配新的对象。堆区域被划分为更小的段,如图图 2.6所示:

图 2.6 – 简化的堆结构分为晋升段

图 2.6 – 简化的堆结构分为晋升段

众所周知,大多数对象将被分配并放置在伊甸园内存中,并且不会在第二次清理中存活。在执行 Minor GC 之后,所有存活的对象都被移动到其中一个存活空间(用S0S1表示)。次要 GC 轮次也会检查S0S1字段,并在其中一个存活位置可能为空的时候将它们分散到其他地方。对象存活了;许多 Minor GC 已经移动到老年代。堆还包含一个永久部分。永久部分包含 JVM 描述类、静态方法和私有变量所需的元数据,并在运行时填充。这个区域以前被称为永久代Permgen)。它从主堆内存中分离出来,没有加载,并且需要配置。这个缺点经常由于内存需求不足而导致应用程序不稳定。Java SE 8 引入了 MetaSpace,取代了 Permgen 概念。MetaSpace 解决了空间配置问题,因为它可以自动增长,此外,垃圾也可以被回收。

GC 基本上分为两个步骤,称为 Minor 和 Major GC。这些步骤是基于持久性的建议 – 即,长期引用:

  • Minor GC:当没有引用到对象时,对象被标记为不可达,年轻代区域被回收,内存可以进行压缩。

  • Major/Full GC:一个已经经历了几次 Minor GC 并移动到老年代堆区的对象。经过一段时间后,它不再引用任何其他对象,没有其他对象引用它,它准备被删除。Full GC 比 Minor GC 少见,并且有一个较长的暂停(停止世界)。

GC 过程可以用三个步骤简化:

  1. 在第一步中,GC 标记不可达的对象(图 2.7.7):

图 2.7 – 第一收集标记步骤识别堆中的未使用对象

图 2.7 – 第一收集标记步骤识别堆中的未使用对象

  1. 在第二步中,移除链接,空间保持空闲,就像它之前一样(图 2.8):

图 2.8 – 通过删除标记对象释放内存

图 2.8 – 通过删除标记对象释放内存

  1. 第三步称为压缩(图 2.9)。它将内存重新组织成更大的部分,因此当程序尝试分配更大的对象时,空间已经准备好。这使得所有对象的内存分配速度大大加快,不仅因为空闲空间,还消除了扫描空闲内存帧的需要:

图 2.9 – 压缩释放的内存以便在帧中分配更大的对象

图 2.9 – 压缩释放的内存以便在帧中分配更大的对象

在对 JMM 和 GC 的任务有了新的认识之后,还有一个与两者都相关的重要概念。引用类型的概念是一种告诉平台如何处理堆空间中特定分配部分的方式 – 更具体地说,如何帮助平台的内部分析过程。引用类型被添加以帮助 GC 评估变量的目的。这意味着加快是否收集变量的决策。引用类型的概念是一个整洁的工具,与设计模式相结合,以及之前更新的主题,使事情更有意义。每个程序的目标是尽可能快地运行。这意味着即使是垃圾收集过程也会导致暂停,所以它必须尽可能快。因此,内部平台过程也必须尽可能快。所以,无论使用哪种 GC 算法,当数据集较小时,过程会快得多。引用类型也有助于保持分配的内存新鲜和干净。平台提供了以下类型,按其抵抗 GC 的能力排序:

  • 强引用:最常见的引用类型 – 不需要指定。

  • var obj = new WeakReference<Object>(); – 这是向 GC 算法发出信号,在下一个 GC 周期回收内存。这主要用于程序初始化阶段或缓存。

  • OutOfMemoryError错误。

  • 虚引用:这代表最弱类型的引用。这种引用会尽快被收集,这意味着没有进一步的分析或提升到另一个级别。当 GC 周期运行时,此类类型的变量会立即被回收。

在开始 Java API 之旅之前,让我们快速总结一下我们新获得的知识。

引用在 GC 过程中扮演着重要角色。它们告诉垃圾收集器如何处理特定的变量。Java 内存模型提供了关于变量值如何读取、更新或删除的必要保证。我们探讨了值在分配内存中的存储、内存分段及其与底层系统的关系。所有这些新信息都有助于我们进行更好的软件设计和 API 使用。

检查核心 Java API

JDK 提供了一套用于创建、编译和运行所需 Java 程序的工具。我们学习了该程序如何使用基本资源来提供所需的结果。我们还检查了在设计此类程序时必须考虑的许多限制。通过使内部类集合分组到 API 中可用,JDK 为软件设计师提供了工具。上一节探讨了如何通过外部 API 扩展 JDK,这些 API 可以根据需要添加(在JRE部分中讨论过)。

在本节中,我们将详细讨论我们用于设计模式的最重要基本 API。

Java 是一种面向对象的语言,具有许多其他特性和扩展。官方基本 Java API 可以在java.*包中找到(如表 2.1所示)。

子包 描述
java.io.* 通过数据流、序列化和文件系统与系统 I/O 相关
java.lang.* Java 语言自动导入的基本类
java.math.* 与整数(BigInteger)和小数(BigDecimal)的任意精度算术相关的类
java.net.* 与网络协议和通信相关的 API
java.nio.* 作为数据容器和其他非阻塞包的缓冲定义概述
java.security.* 用于 Java 安全框架的类和接口
java.text.* 提供用于处理带有文本、数字和日期的格式化消息的类
java.time.* 日历、日期、时间、瞬间和持续时间的 API
java.util.* 作为集合框架、字符串解析、扫描类、随机数生成器、Base64 编码器和解码器以及一些其他杂项实用工具

表 2.1 – Java 17 SE 中可用的 java.*包

每个新创建的类都可以自动访问位于java.lang.*包中的公共类和接口,这些类和接口位于java.base模块中。由于一切都是对象,这意味着每个类都有一个Object实例。

原始数据类型和包装类

Java 还提供了一组称为字面量的原始类型(参考 4),称为字面量(表 2.2)。字面量和Object实例之间的一个区别是,每个字面量在内存中都有一个定义良好的大小。相比之下,Object实例的大小可能根据需求而变化。Java 的字面量类型是有符号的,如果你在处理数据缓冲操作,这将非常有用记住。

大小 字面量名称 范围
1 位(*) boolean truefalse
1 字节 byte -128 到 127
2 字节 short -32,768 到 32,767
2 字节 char \u0000 到\uffff
4 字节 int -2³¹ 到 2³¹-1
4 字节 float -3.4e38 到 3.4e38
8 字节 long -2⁶³ 到 2⁶³-1
8 字节 double -1.7e308 到 1.7e308

表 2.2 – 原始类型及其大小;(*)布尔类型的大小未精确定义

原始类型位于栈区(参见图 2.2)并且每个字面量都包含一个包装对象(图 2.10*)。

图 2.10 – 数据类型包装继承与字面量与 String 类型关联

图 2.10 – 数据类型包装继承与字面量与 String 类型关联

封装围绕字面量值启动。这意味着字面量存储在栈区,而包装对象位于堆中。封面提供了额外的功能。以Integer类为例,它提供了以下方法:byteValuedoubleValuetoString。这些方法可以在特定的设计模式中调用以实现预期的目标并避免不必要的内存污染。这与仅提供值原生实现的字面量形成对比。

Java 平台自动将字面量分配给适当的包装类等。这一事实不仅有其光明的一面,也有其阴暗的一面,即所谓的自动装箱问题(示例 2.3)。这正好发生在原始类型被转换为包装类型时。这可能导致非常频繁的垃圾回收,这可能意味着大量的停止世界事件:

Int valueIntLiteral = 42;
Integer valueIntWrapper = valueIntLiteral;

示例 2.3 – 在幕后创建新的 Integer 包装器的自动装箱示例

当处理字面量数字时,记住一个具有较小字节大小的字面量(表 2.2)可以自动分配给一个具有较大大小的字面量(示例 2.4)。反过来,由于内存栈区中精确分配的字节大小,这会导致编译错误:

byte byteNumber = 1;
short shortNumber = byteNumber;
int intNumber = shortNumber;

示例 2.4 – 字面量自动装箱

我们已经检查了编号以及 Java 平台上的自动提交方式。布尔字面量是truefalse,在内存中以 1 位表示。

最后尚未提到的特定字面量是char及其覆盖字符。让我们仔细看看,因为它也与基本的String对象有关。

使用 String API

String实例不是字面量。在 Java 中,字符串表示为一个对象,由字符序列定义。几乎不可能编写任何程序而不使用字符串。除了 Java 可执行文件需要将String字段作为main方法的输入之外,变量名也以字符串的形式表示。在 Java 中,字符串是不可变的。这意味着对其值的任何操作,如连接,都将创建一个新的字符串。更准确地说,无法更改其当前值。字符串是 Java 平台的基本类。

存储字符串值的一种常见方式是使用字符串池。字符串池只存储固有值(图 2**.11)。这意味着只能有一个不同的常量值存在。这种方法在内存效率方面更为高效,包括耗时的字符串操作。

图 2.11 – 字符串池是堆内存的一部分,字符串对象像其他对象一样驻留在堆中

图 2.11 – 字符串池是堆内存的一部分,字符串对象像其他对象一样驻留在堆中

您还可以使用String构造函数直接在堆中存储字符串 – 例如,new String。在这种情况下,链接不是指向字符串池中已存在的等效值(在示例 2.5中由t3表示),因为它位于不同的堆内存空间中。如果您强制在池字符串中进行搜索,可以使用intern方法(示例 2.5中的t4):

String t1="text1";
String t2="text1";
String t3= new String("text1");
String t4 = t3.intern();

输出:

1 == t2 => true
t1 == t3 => false
t3 == t4 => false
t1 == t4 => true

示例 2.5 – 比较不同的字符串值赋值方式

在 String 类上使用+运算符可能会导致连接或程序可维护性非常低效。为了防止字符串污染,Java 平台在其 API 中提供了StringBuilder类。StringBuilder防止存储临时值,并且只存储执行其内部toString方法创建的结果,该方法在堆空间中创建一个新的String对象(示例 2.6)。StringBuilder还引入了 Java SDK 中的创建型设计模式和其实施及使用:

String t5 = new StringBuilder()
        .append("value")
        .append(42)
        .toString();
String t6 = "value42";

输出:

t5 == t6 => false

示例 2.6 – StringBuilder 默认在堆空间中创建一个新的字符串对象

我们了解到 String 对象是如何创建的,以及它们存储在哪个堆内存中。这些新获得的信息可以帮助我们在选择合适的设计模式或它们的组合时做出决策,以避免内存的误用。因为字符串在底层是一个字符数组,原始类型 char[],所以数组不是原始的——实际上,它是一个对象。让我们更仔细地考察这个概念,因为它对于 Java 语言和平台也是至关重要的。

介绍数组

为了更好地理解 Java 集合框架,首先,我们将探讨一个重要概念,即数组。在 Java 中,数组通过一系列相同类型的按位置索引元素序列来表示。字段是基于索引的。任何在运行时尝试从不存在位置获取元素的尝试都会导致 ArrayIndexOutOfBoundsException 异常。数组字段作为对象分配并存储在堆空间中。这意味着在空间不足的情况下,会抛出 OutOfMemoryError 异常。由于内存分配,每个数组都需要一个定义的大小。使用字面量进行简单的字段分配相对内存效率较高(示例 2.8):

int[]     array1;        
byte[][]  array2;        
Object[]  array3;        
Collection<?>[] array4;

示例 2.8 – 多种数组分配方法

数组允许我们存储实现接口类或一系列抽象类的元素。字段变量声明不会创建或分配一个新的字段;变量包含一个字段引用(示例 2.9):

int[] a1 = {1,2,3,4};
a1[0] = a1.length;
int e1 = a1[0];
a1.length == 4 => TRUE 
a1 instanceof Object => TRUE

示例 2.9 – 数组的初始化、赋值和验证

由于字段可能具有精确的要求和有限的辅助方法,其使用往往被忽视。然而,它可以帮助强制执行开放-封闭原则,该原则假设代码的可维护性。

字段通常被集合或映射结构所取代,这些结构提供了额外的辅助方法。让我们更深入地探讨这个主题。

发现集合框架

与字段不同,高级集合提供了自动调整大小的功能。这意味着所需的基本表示将被复制,而之前的版本将变得适合进行垃圾回收。Java 集合框架包括 List表 2.3)、Set表 2.4)、Queue表 2.5)和 Map 接口,以及多个实现(图 2**.12)。实现可能因供应商而异,但所有实现都必须符合基本规范。

图 2.12 – Java 集合框架接口之间的依赖关系

图 2.12 – Java 集合框架接口之间的依赖关系

集合的含义位于 java.base 模块及其 java.util 包中。该包包含最常用的实现,具有已知的时间复杂度行为。空间复杂度不是非常相关,因为框架自带自动调整大小的功能。当涉及到设计模式的选择时,时间复杂度可以发挥更重要的作用,因为这可能会显著惩罚所提出程序的响应。为了评估 O-符号的时间复杂度,使用 O-符号来突出上限,并必须使用最坏情况的程序来获得它。

为了评估时间复杂度的影响,我们可以通过一些很好的例子,例如选择正确数据结构的重要性。让我们从列表结构(表 2.3)开始,它允许使用索引访问每个元素。

名称 包含 添加 获取 移除 数据结构
ArrayList O(n) O(1) O(1) O(n) 数组
LinkedList O(n) O(1) O(n) O(1) 链表

表 2.3 – 按提供的操作及其时间复杂度排序的所选 List 接口实现

有时算法要求您验证数据结构中元素的存在,并添加或移除新的元素。对于这些情况,让我们看看 Set 接口的实现(表 2.4)。

名称 包含 添加 移除 数据结构
HashSet O(1) O(1) O(1) 哈希表
TreeSet O(log n) O(log n) O(log n) 红黑树

表 2.4 – 按提供的操作和时间复杂度排序的所选 Set 接口实现

集合组提供的最后一个接口是 Queue图 2**.12)。当您只需要处理第一个或最后一个元素时,这种数据结构非常有用(表 2.5)。

名称 峰值 提供 轮询 大小 数据结构
PriorityQueue O(1) O(log n) O(log n) O(1) 优先堆
ArrayDequeue O(1) O(1) O(1) O(1) 数组

表 2.5 – 提供的操作及其时间复杂度的所选 Queue 接口实现

当涉及到实现 Map 接口时,重要的是要记住考虑哪种类型的映射实现。映射表示键值对的结构。键和值都是 Object 类的子类。除了在映射的定义或初始化中不能使用字面量之外,还需要正确实现 hashCodeequals 对象方法。这一要求基于识别正确桶以解决潜在的映射冲突的需要。这种冲突可能导致时间复杂度偏离我们的预期(表 2.6):

名称 包含键 通过键获取 通过键移除 数据结构
HashMap O(1) O(1) O(1) 哈希表
LinkedHashMap O(1) O(1) O(1) 哈希表,链表

表 2.6 – 通过提供的操作选择 Map 接口实现及其时间复杂度

集合框架使用了一种高度行为性的迭代器设计模式来遍历考虑的元素。那些有敏锐眼光的人肯定已经注意到,没有适当的数学基础,集合框架中的任何功能都是不可能实现的。使用设计模式的主要原因之一是将或创建业务逻辑所使用的正确结构。让我们简要地看一下一些基本的数学特性。

Math API

Java 通过提供Math类的静态实现来揭示基本的数学函数。最终意味着这个类不能被扩展,这包括不情愿的更改或替换基本函数。Math类(示例 2.10)位于java.lang包中,这意味着它可以直接使用,无需导入:

Double sin = Math.sin(90);
double abs = Math.abs(-10);
double sqrt = Math.sqrt(2);

示例 2.10 – 使用Math类提供的常用数学函数

虽然Math类使用了random方法,但它只得到一个double结果。Random类位于java.util包中,不仅为类型提供了更多可定制的功能,也为所需的范围提供了更多可定制的功能(示例 2.11):

Random randomNumberWithRange = new Random();
int upperBound = 10;
int randomIntInRange = randomNumberWithRange.
nextInt(upperBound);
double randomDoubleInRange = randomNumberWithRange.
nextDouble(upperBound);

示例 2.11 – 在一个范围内生成随机数(0 – 上限)

Java 的Math类在这里也被使用,类似于任何超出标准数学运算能力所需的计算。在遵循函数式编程方法时,使用Math类的方法可能会有所帮助。

函数式编程与 Java

在上一章中,我们学习了并演示了面向对象编程(OOP)的关键原则(APIE)。在过去的几十年里,Java 平台随着商业和开发社区的需求而发展。平台通过实现一个使用树函数组合来提供所需结果的 API 来应对这一挑战。这与传统的循环方法以及一系列命令式命令的集合方法形成对比。这种方法导致更大的代码库达到了预期的目标。

从 Java SE 8 开始,平台提供了一种流式 API(参考 15)。它位于java.util.stream包中,与由输入和错误(System.outSystem.inSystem.err)表示的 Java 数据流无关。Stream API 引入了对元素序列应用操作的能力。有两种类型的中间操作可以编辑或检查数据,以及终端操作。终端操作可能提供一个单一的结果或无返回值。中间操作可以连接,但终端操作终止流。元素序列是惰性评估的,也可以并行执行。默认情况下,执行并行流使用的是公共Fork/Join 框架执行服务。Fork/Join 模型可以被认为是一种在 20 世纪 60 年代初形成的并行设计模式(参考 17)。

尽管该平台允许您编程功能类型,但面向对象的概念仍然存在,并随后是强类型要求。这为 Stream API 提供了安全性,即原始元素类型保持不变或必须由中间操作或终端操作正确强制执行 – 否则,平台将引发编译错误。提醒一下,没有 Java SE 5 中泛型类型的引入,这些功能都不可能实现。泛型(参考 4)允许我们通过类型标志来参数化类或接口,以保持编译安全(参考 2)。

中间操作或终端操作是匿名函数或函数式接口的实现。它们代表一小块代码,正式称为 lambda。让我们更详细地解释一下 lambda 的概念。

引入 lambda 和函数式接口

Lambda 概念被引入以实现元素操作。Lambda 基本上将数据视为代码或函数视为方法。Lambda 依赖于匿名类概念 – 即,只有一个执行动作的方法的类。Java 包含了一组已实现的函数式接口或现成的函数。类使用@FunctionalInterface注解进行标注,这是一个从 Java SE 8 开始可用的标签。它告诉平台,特定接口只包含一个抽象方法,可以用来实例化匿名类,如表 2.7所示。这也意味着接口可能包含一些属于类的默认或静态函数。

名称 输入 参数 返回 类型 抽象 方法 描述
Supplier<T> - T get 返回类型为的值
Consumer<T> T - accept 消费类型为的值
Function<T, R> T R apply 消费类型为的值并应用一个具有返回类型的转换
Predicate<T> T 布尔值 test 消费类型为的输入并返回布尔结果

表 2.7 – 自 Java SE 8 以来在 JDK 中可用的基本函数式接口

在 lambda 表达式中使用功能接口

我们发现每个 lambda 表达式都是惰性加载的,这意味着代码是在需要时评估的,而不是在编译时,并且可能由终端操作关闭(示例 2.12):

List<String> list = Arrays.asList("one", "two", 
    "forty_two");
list.forEach(System.out::println);

示例 2.12 – 将 List 接口的元素转换为流,并对每个 Consumer 类型实例应用终端操作

我们可以将不同的中间函数链接在一起(示例 2.13)并使用终端操作关闭流,或将流传递给另一个方法或对象:

Predicate<Integer> numberTest = new Predicate<Integer>() {
    @Override
    public boolean test(Integer e) {
        return e > 2;
    }
};
String result = Stream.of(1,2,3, 42)
        //.filter(e -> e > 2) //Anonymous class example
        .filter(numberTest)
        .map(e -> "element" + e)
        .collect(Collectors.joining(","));
System.out.println("result: " + result);

示例 2.13 – 命名和匿名功能接口的高级组合

lambda 表达式流 API 在代码组合中扮演着重要的角色。它可以想象成一个生产线,输入对象进入其中,并得益于一系列调整,返回预期的结果或结束操作。由于 lambda 表达式是惰性评估的,这意味着生产线有一个开关。换句话说,Stream API 可以被认为是语法上最重要的突破之一。

掌握 Java 模块系统

使用像 Java 这样的高阶编程语言的主要目的之一是代码重用。语言的基本构建块是根据 APIE 原则的类概念。Java 可以将这些类本地化到由特定包名定义的组中。包概念封装了一组类。类可以为其内部字段和方法提供不同级别的可见性。Java 指定了以下可见性级别:public、默认、privateprotected。关键字用于在不同包之间减少可见性以管理它们的交互。在应用程序域中共享包的方法是将其保持为公共的——也就是说,对每个人都是可见的。

Java 多年来一直在使用类路径的概念。类路径是一个特殊的地方,类加载器在这里加载其类。然后,在运行时使用这些加载的类(在 图 2.2 中表示为 类加载器子系统)。

然而,这个概念并不为存储的包或类提供任何保证。多年来,这个概念被认为是不好的、脆弱的和容易出错的。一个很好的例子是尝试打包一个包含具有类似包结构和类名的不同版本库的 JAR 可执行文件。类路径没有区别,类可以被不同版本覆盖。

突破随着 Java SE 9 的发布而来。JSR-376,以前是 Jigsaw 项目的核心(参考 3),已成为平台的一个共同部分。JSR-376 实现了 Java 平台模块系统JPMS)(示例 2.14):

$ java –list-modules          
java.base@17     
java.compiler@17 
java.datatransfer@17
<more>

示例 2.14 – 列出特定版本的可用 JDK 模块

此外,平台已根据模块进行迁移(示例 2.15):

$ java –describe-module java.logging
java.logging@17
exports java.util.logging
requires java.base mandated
provides jdk.internal.logger.DefaultLoggerFinder with 
    sun.util.logging.internal.LoggingProviderImpl
<more> 

示例 2.15 – 描述 java.logging 模块。java.base 模块自动存在,因为它包含核心平台和语言功能。

JMPS 提供了一个强大的包封装概念,它定义了应用在包级别的交互 (示例 2.16)。应用可以被划分为只能检测 API 或服务的模块。JMPS 支持包级别的依赖构建,并提高了正在开发的应用的维护性、可靠性和安全性:

Module java.logging {
    exports java.util.logging;
    provides jdk.internal.logger.DefaultLoggerFinder with
        sun.util.logging.internal.LoggingProviderImpl;
}

示例 2.16 – 模块-info.class 描述符示例,暴露给外部使用的包

使用 JPMS 不是强制性的。Java 平台使用 JMPS,但如果应用尚未准备好,可以使用未命名的模块。在这种情况下,所有包或类都将属于这种未命名的模块。原则上,未命名的模块从类路径中读取每个可读的模块或类,而不反映 JPMS 要求的任何包级别限制。这样,就实现了与先前开发的应用的兼容性,并且软件设计者对代码库的故障没有疑问——也就是说,JPMS 被禁用了。

虽然 JPMS 在应用可持续性、安全性和可重用性方面具有巨大的潜力,但它通常没有被使用,因为它会间接地施加压力,要求正确配置底层的 JPMS 并使用一种强制 SOLID 原则的设计模式。

当使用 JMPS 时,平台确保开发的应用不包含任何循环依赖。在幕后,JPMS 创建了一个无环的模块图(而不是类路径的情况)。

通过创建模块描述符文件,平台提供了一组指令,可用于将模块的某些部分暴露给外部世界。

让我们创建一个简单的模块示例,以消除对 JPMS 使用的任何疑虑 (示例 2.17)。到目前为止的讨论可以克服最初的困难:

module-example
├── example
│   └── ExampleMain.java
└── module-info.java

示例 2.17 – 使用 OpenJDK 17 开发的模块 example 的文件夹结构

我们创建一个合适的可执行类,ExampleMain.java,以及一个模块描述符,module-info.java (示例 2.18)。这样,我们告诉平台使用 JPMS:

// file module-info.java
module module.example {
    exports example;
}
// file ExampleMain.java
package example;
public class ExampleMain {
    public static void main(String[] args) {
        System.out.println("Welcome to JMPS!");
    }
}

示例 2.18 – 由示例 2.17 中的文件结构引入的简单模块示例

示例展示了如何将项目分割成包含它们自己的描述符、module-info.java 文件的模块 (示例 2.17)。此描述符定义了通过依赖关系或模块内部暴露与其他模块的交互。JPMS 确保包括可见性在内的限制得到维护:

$ javac -d ./out ./module-example/module-info.java 
    ./module-example/example/ExampleMain.java
$ jar –create -file module-example.jar -C ./out .
$ java –module-path ./module-example.jar –module 
    module.example/example.ExampleMain

输出:

   Welcome to JMPS!

$ java –module-path ./module-example.jar –describe-module 
    module.example

输出:

module.example
exports example
requires java.base mandated

示例 2.19 – 编译示例 2.17 的步骤,包括输出,以及编译结果后的模块描述符检查(示例 2.18)

JPMS 对平台来说是一个重大变化,尽管它通过提供定义包结构清晰性的能力为软件设计师开辟了新的视野,但它并不总是受到欢迎或被理解。这可能是由于在设计系统时需要考虑的额外要求,这些要求本质上与 APIE 或 SOLID 原则的知识相关。

JPMS 与 Stream API 以及 lambda 一起,可能被认为是 Java SE 11 版本中解决的一些重大变化——Java SE 11 是 8 版本之后的下一个长期支持LTS)版本。让我们进一步探讨一些从 Java SE 11 到下一个由发布 17 呈现的 LTS 版本的变化。

快速回顾 Java 从 11 到 17+ 的特性

本版本更新展示了性能和优化改进。在本节中,我们将检查那些对特定设计模式及其结构的使用非常有用的改进。这相当于平台增强,可以提高代码可读性、平台使用或语法增强。

lambda 参数的局部变量语法(Java SE 11,JEP-323)

Java 常常因为变量使用中的标准代码量而受到批评;Java SE 10 引入了一个新的关键字,var。这个关键字背后的推导是局部类型变量。它本质上要求值类型从新创建的引用实例中获取(示例 2.20)。使用流 boxed 函数展示了装饰者模式,它将流值包装在所需类型中:

Consumer<Integer> consumer = (var number) -> {
    var result = number + 1;
    System.out.println("result:" + result);
};
IntStream.of(1, 2, 3).boxed().forEach(consumer);

示例 2.20 – 在 lambda 表达式和流中使用局部类型推断以减少样板代码

虽然 lambda 已经允许隐式类型定义,例如,使用注解是不可能的。

Switch 表达式(Java SE 14,JEP-361)

软件设计师长期以来一直抱怨 switch 命令的使用存在一些不一致性,例如控制流问题。尽管这个增强与所有控制流完全兼容,但它引入了新的 switch 标签形式,case CONSTANT->。扩展还允许使用更多的常量,使整个 switch 表达式更加紧凑。最后一个改进是 switch 表达式能够返回其计算值(示例 2.21)。这对设计模式的实现有非常积极的影响,因为例如,行为类型需要精确的控制流(参考 8):

Var inputNumber = 42;
String textNumber = switch (inputNumber){
    case 22,42 -> String.valueOf(inputNumber);
    default -> throw new RuntimeException("not allowed");
};
System.out.printf("""
        number:'%s'
        %n""", textNumber);

示例 2.21 – 使用简单的文本块进行紧凑的 switch 表达式和带有返回控制流的用法

文本块(Java SE 15,JEP-378)

许多时候,你需要创建具有特定格式的多行文本。之前使用多个转义序列和字符并不实用,因为它可能不可预测。文本块扩展引入了一个字面量,允许你以可预测的方式表示一个字符串(参见示例 2.21中的System.out.printf方法,以及参考 9)。

instanceof 的模式匹配(Java SE 16,JEP-394)

以前,需要重新输入一个已经验证为正值的值类型。这增加了代码库,有时甚至对代码的稳定性产生负面影响,即使在设计模式时也是如此。这个平台扩展消除了需要后置转换的需求,变量可以直接使用正确的类型(示例 2.22参考 10):

Object obj = "text";
if(obj instanceof String s){
    System.out.println(s.toUpperCase());
}

示例 2.22 – 使用 instanceof 与直接类型方法

记录(Java SE 16,JEP-395)

record 类类型非常有用,因为它的声明非常简单,可以携带程序业务逻辑所需的所有数据。记录携带不可变数据。它们提供了已实现的 hashCodeequals 方法。这意味着设计的软件不需要提供额外的代码(示例 2.23参考 11):

private record Example(int number, String text){
    private String getTogether(){
        return number + text;
    }
}

示例 2.23 – 新的记录类类型可能对代码减少产生非常积极的影响,因为它提供了生成的方法

密封类(Java SE 17,JEP-409)

这些是获得对类和接口、或类扩展和接口实现的控制的非常优雅的增强(参考 12)。封闭类为软件设计师提供了对超类的广泛访问,而无需扩展它。它们克服了广泛使用的包访问修饰符的限制,之前需要完全实现抽象方法。示例展示了如何定义一个用于扩展的开放类,使用 non-sealed 关键字(示例 2.24):

public sealed interface Vehicle permits Car, Bus {
    void start();
    void stop();
}
public non-sealed class Car extends NormalEngine implements 
    Vehicle {
    public String toString(){
        return "Car{running="+ super.running +'}';
    }
}

示例 2.24 – 接口方法的实现由抽象类 NormalEngine 提供

密封类强制控制可能的扩展(示例 2.25),因为它们为软件提供了潜在的安全,以防止不想要的软件设计更改:

Public class Motorbike implements Vehicle{
    public void start() {}
    public void stop() {}
}

这是编译输出:

Motorbike.java:2: error: class is not allowed to extend 
sealed class: Vehicle (as it is not listed in its permits 
clause)

示例 2.25 – 密封类强制控制增强

密封类也带来了一些潜在问题,因为软件设计师必须决定新创建的类将如何被使用,指示是否允许类扩展,关键字是未密封的(示例 2.25),或者使用 final 关键字(示例 2.26)锁定:

Public final class Bus extends SlowEngine implements 
    Vehicle {}

示例 2.26 – 需要决定类的行为,并且 Bus 类示例被锁定,不允许任何扩展

虽然这看起来可能是一个可能的缺点,但它从可维护性和设计模式的角度提供了软件开发中的更大清晰度。这减少了潜在的接口或类错误。

默认使用 UTF-8 编码(Java SE 18,JEP-400)

多年来,编码不明确导致了问题。编码问题不易检测,并且在不同系统平台上不可预测地出现。这个增强统一了所有内容,并强制使用 UTF-8 作为默认编码(参考 13)。

switch 的模式匹配(Java SE 18,第二次预览,JEP-420)

instanceof字段(JEP-394)和 switch case 表达式(JEP-361)的改进使得代码库的压缩和通过在非常紧凑、命令导向的命令语句中使用instanceof来移除之前不必要的 if-else 结构变得更加容易:类型服务(示例 2.27参考 14):

Object variable = 42;
String text = switch (variable){
    case Integer i -> "number"+i;
    default -> "text";
};

示例 2.27 – 压缩的 switch 语句,具有隐式类型匹配

在审查了最重要的语法改进之后,我们可以安全地开始深入探讨平台的主要优势之一。是的,它是并发框架。

理解 Java 并发

在本章的开头,已经展示了即使运行一个简单的程序(示例 2.2图 2**.3)也会导致多个线程的具体化(图 2**.4)。这意味着通过执行main方法创建的程序线程并没有创建属于该程序的任何其他线程。Java 平台因其能够执行并发或并行任务而闻名且价值巨大。

我们了解到线程是如何以及在哪里存储它们的变量,以及为什么在堆中同步对象可能会导致不希望或意外的程序行为。在本节中,我们将探讨主线程使用可用 CPU 的可能性。

软件设计师考虑使用任何并发设计模式的动机可能是对更好的应用程序响应性或吞吐量的日益增长的需求。

尽管平台已经包含了Thread类和java.lang包,但 Java 并发特性,如java.util.concurrent包,它是java.base模块的一部分。

让我们更仔细地看看。

从基本线程到执行器

平台的基本构建元素是线程。线程由Thread类的实例表示。使用new关键字初始化的对象仍然不会创建平台线程。该对象提供了一个名为start的方法,该方法需要显式使用(示例 2.28):

public class Multithreaded Program {
    public static void main(String[] args) {
        var t = new Thread(() -> {
            while(true){System.out.println("Welcome 
                Thread!");}
        });
        t.setDaemon(true);
        t.start();
    }
}

示例 2.28 – 一个在 JVM 停止后立即结束的守护线程的简单程序

虽然看起来平台可以创建不受限制的Thread实例,但这种说法是不正确的。每个新创建的线程实例不仅占用堆空间或分配栈空间,而且通过 Java 运行时分区(图 2**.2)与基本系统线程(处理周期)相连。这意味着不受控制的线程启动可能会由于资源不可用、内存不足等原因导致系统错误异常。

Java 平台创建的系统线程的最大数量可能因硬件以及 JVM 配置而异。Java 的 Thread 类可能将 Runnable 接口视为包装器,线程接受其实现。Runnable 接口是另一个函数式接口,需要实现一个运行方法。从 Java SE 8 的 Runnable 接口开始,实例可以作为匿名函数传递给执行器服务。

Java 平台允许你运行一个线程,甚至可以在主程序终止后仍然存活,这在许多情况下是一个不情愿的条件,应该谨慎考虑,因为它可能会阻塞其他核心资源或持续运行。

重要的是要记住,只有当所有运行中的线程都是守护线程时,JVM 才会终止 (示例 2.28)。

由于主程序新创建的每个线程都是非守护线程,默认情况下,当示例程序在没有显式守护标志的情况下运行时,JVM 仍然是一个活跃进程,直到基本系统将其销毁。

它允许你管理不受控制的线程创建,并让软件设计者控制程序的资源和行为。Java SE 5 引入了 ExecutorServiceThreadFactory 接口,其中多个实现展示了使用类似名称的创建型设计模式工厂。ThreadFactory 接口只包含一个 newThread 方法,它返回一个 Thread 实例。此方法逻辑可以容纳新线程的创建,并设置组、线程优先级和守护标志。它还消除了新线程调用的数量。ThreadFactory 可以由 ExecutorService 服务 (示例 2.30)。

一些最常用的执行器静态方法名称如下:

  • newSingleThreadExecutor()

  • newSingleThreadExecutor(ThreadFactory threadFactory)

  • newCachedThreadPool()

  • newCachedThreadPool(ThreadFactory threadFactory)

Java SE 5 提出了一个关于未来的概念,一个具有泛型类型 <T>Future 接口。Future 接口可以被视为一个异步计算,它提供结果。

Java 平台提供了两个不同的接口,可以携带线程逻辑。

执行任务

Java 平台从一开始就提供了线程概念,由 Runnable 接口和 Thread 类表示 (示例 2.29):

ExecutorService executorService = 
    Executors.newSingleThreadExecutor();
var runnable = new Runnable(){
    @Override
    public void run() {
        System.out.println("Welcome Runnable");
    }
};
executorService.execute(runnable);
executorService.execute(() -> System.out.println("Welcome 
    Runnable"));

示例 2.29 – 向执行器服务提供 Runnable 接口实现的多种方法,实现和匿名类

商业需求以及社区期望共同创造了一个用于反应式编程或执行多个异步回调任务的平台。截至 Java SE 5,该平台提供了一个 Callable 接口。Callable 接口被认为是一个函数式接口。它只包含一个带有必需返回类型 <T> 的抽象方法调用。由于计算是不确定的,它可能引发必须正确处理的异常。Callable 实现可以发送到执行器,启动的计算被封装到未来结果中。

Future 实例是基础系统在后台执行的计算工作。该接口提供了一个 get 方法(示例 2.30),可以用来检索结果。使用此方法会暂停当前线程,直到有结果可用。由于当前线程的暂停,此方法应谨慎使用,因为它可能会造成性能损失:

var futureCallable = executorService.submit(callable);
Future<String> futureCallableAnonymous = executor.submit(() 
    -> "Welcome to Future");
System.out.println("""
        futureCallable:'%s',
        futureCallableAnonymous:'%s'
        """.formatted(futureCallable.get(), 
            futureCallableAnonymous.get()));

示例 2.30 – 向执行器服务提供 Callable 实例的不同方法,实现或匿名类

这与 Runnable 接口形成对比,因为 Callable 接口提供了一个 Future 实例作为临时结果。Callable 异常处理请求也相关,因为它可能导致逻辑执行或工作线程被中断。在这种情况下,有必要将此转移到由 Future 接口表示的临时结果。

摘要

在本章中,我们为理解内部 Java 平台建立了一个良好的知识库。我们了解了静态分配的数组或方法与对象实例之间的区别。我们研究了适当数据同步的需要以及 Java 内存管理的工作原理和平台提供的保证。我们现在理解了堆内存、分段和维护的重要性。我们也已经发现了一些常用的设计模式,这意味着当我们开始实现任何设计模式或集合时,我们将意识到以下内容:

  • 平台如何处理字段或变量

  • 内存管理的重要性

  • 特定程序错误退出状态及其原因

  • Java 平台提供的核心 API

  • 如何利用函数式编程特性

  • Java 平台提供了哪些新增强功能,以使应用设计模式更加容易

  • 如何应对 Java 并发挑战

我们在前两章中建立了一个坚实的知识库。现在,我们将按模式逐一介绍。下一章将带我们进入创建型设计模式的旅程。创建型设计模式增强了我们对代码结构和如何创建可持续解决方案的认识。让我们开始吧。

问题

  1. 构成 Java 平台的是哪些元素?

  2. 静态类型语言意味着什么?

  3. Java 语言字面量是什么?

  4. 在 Java 内存管理概念中,谁负责内存回收?

  5. Java 集合框架中有哪些集合?

  6. Map存储了哪些类型的元素?

  7. Set中检索元素的时间复杂度是多少?

  8. 验证ArrayList中元素存在的时间复杂度是多少?

  9. 在 Stream API 的filter方法中使用了哪种功能接口?

  10. 在 Stream API 中元素是如何评估的?

进一步阅读

第二部分:使用 Java 编程实现标准设计模式

设计模式通常被分为三个著名的类别:创建型、行为型和结构型。本部分将探索并展示每个类别中的设计模式。它将通过实际的真实世界案例展示每个设计模式所解决的问题类型。

本部分包含以下章节:

  • 第三章, 使用创建型设计模式

  • 第四章, 应用结构设计模式

  • 第五章, 行为设计模式

第三章:使用创建型设计模式进行工作

在过去的几十年里,IT 社区经历了从之前孤立的系统到分布式或混合解决方案的巨大转变。这些方法为软件开发带来了新的可能性。

分布式解决方案可能看起来可以满足遗留系统的迁移需求,但现实可能并非如此。所需的重构可能会因为职责划分或紧密耦合的逻辑和业务规则的重构以及许多太晚发现而无法反应的未知隐藏逻辑而引起额外的问题。

在本章中,我们将探讨创建型设计模式。这些模式在软件组成中起着至关重要的作用。它们对于实现代码库的可维护性或可读性非常有用。创建型设计模式试图遵循所有之前提到的原则或不要重复自己DRY)的方法。让我们以下列顺序深入了解具体的模式:

  • 应用工厂方法模式

  • 在封装中实例化额外的工厂,使用抽象工厂模式

  • 使用建造者模式创建不同配置的对象实例

  • 避免使用原型模式重复复杂的配置

  • 使用单例模式检查只有一个实例的存在

  • 通过使用对象池模式使用预准备的对象来加快运行时

  • 根据按需初始化模式控制实例

  • 使用依赖注入模式减少对象实例

到本章结束时,你将建立起如何编写可维护的代码以创建可以驻留在 JVM 堆或栈上的对象的坚实基础。

技术要求

你可以在 GitHub 上找到本章的代码文件,网址为github.com/PacktPublishing/Practical-Design-Patterns-for-Java-Developers/tree/main/Chapter03

所有这一切都始于一个成为对象的类

在 Java 中,每个对象都必须首先由一个类来描述。让我们简要介绍一个常见的软件应用理论场景。这些场景通常分为以下几部分:

图 3.1 – 从宏观角度看的常见应用数据处理

图 3.1 – 从宏观角度看的常见应用数据处理

进入的输入数据流(即信息流)已被应用程序接受。应用程序处理输入并创建结果。结果被存储并受到系统所需的目标处理。

这样的系统具有在多种条件下满足几个不同过程的能力。结果以多种方式存储,例如数据库或文件,或者可能嵌入到预期的输出流中,如网页,以向用户显示信息。

系统充当 incoming information flow 的水库,将其处理并存储在数据库中,并提供结果。大多数时候,一切都是紧密耦合和相互关联的。

耦合在不同的层面上发生,而软件设计师并没有注意到。紧密的连贯性存在于类、对象甚至包之间。在许多方面,可以通过更强大的硬件来纠正应用性能的弱点。系统的演变大致上是一个对 1965 年发布的摩尔定律的统计观察,该定律于 1965 年发布。

摩尔定律指出,每年,每块集成电路的组件数量都会翻倍。该定律在 1975 年进行了修订,指出组件数量每年翻倍。尽管关于该定律有效性的辩论可能会变得有争议,但当前的趋势(以及硬件升级所需的速度)表明,进行另一次审查的时间即将到来。在全球范围内,可能没有必要加快硬件升级(已经非常快)的速度,因为这可能对信息处理速度没有影响。这一观察结果针对的是软件应用的功能需求,更多地关注实现算法的质量和复杂性。

由于物理限制,可能无法持续增加对象的实例化速率,因为此类信息必须物理存储在内存中。这意味着在未来的几十年里,我们可以预期提高软件和设计效率的压力会增加。为了使应用逻辑清晰,需要清楚地了解应用的工作方式,以及应用如何向关键 JVM 区域提供支持,即方法栈和堆,然后是通过栈区域(如图 2.2 所示)的线程利用率。

由于当前软件应用的趋势集中在映射、转换或管理大量数据上,因此值得研究、理解和学习如何处理常见场景的创建型设计模式。尽管《设计模式:可复用面向对象软件的基础》(GoF)一书的时代已经过去,但进化是不可避免的,挑战仍然存在。在许多情况下,通过适当的抽象,初始的创建型设计模式是适用的。创建对象和类实例,以及填充 JVM 的预期部分,可能会极大地影响计算和性能成本,以及加强业务逻辑的清晰性。

在下一节中,我们将讨论对象创建的不同可能性。我们还将考虑最近添加的 Java 语法特性和可能性,这应该会减少源代码的冗长。让我们从一个最常见的模式开始。

基于输入创建对象的工厂方法模式

此模式的主要目的是集中化特定类型的类实例化。该模式将创建确切类类型的决策留给客户端在运行时决定。工厂方法设计模式在 GoF 的书中进行了描述。

动机

工厂方法模式强制将代码与其创建新实例的责任分离,也就是说,该方法提供了预期的结果。工厂隐藏了一个基于泛型抽象的应用程序类层次结构,并引入了一个公共接口。它透明地将实例化逻辑与代码的其他部分分离。通过引入公共接口,客户端在运行时可以自由地决定特定的类实例。

该模式通常在应用程序的早期阶段使用,因为它易于重构,并提供高清晰度。

虽然这可能会引入一点复杂性,但模式很容易遵循。

在 JDK 中查找

工厂方法模式经常在 Java 集合框架中用于构建所需类型。框架实现位于 java.base 模块的 java.util 包中。此包包含 SetListMap 的不同实现。尽管 Map 类型是 Java 集合框架的有效成员,但它不继承 Collection 接口,因为它实现 Map.Entry 来存储元素元组、键和值。SetListMap 的每个实现都提供了重载的 of 工厂方法来创建实例。

Collections 类是一个实用工具类。它包含创建特定集合的几个工厂方法,例如单个项目的列表、映射或集合。工厂方法模式使用的另一个有用示例是 Executors 实用工具类,该类位于 java.base 模块的 java.util.concurrent 包中。Executors 类定义了静态方法,如 newFixedThreadPool

示例代码

让我们想象一个简单直接且易于在现实世界中应用的示例,使用合适的抽象。目标是设计一个跟踪车辆生产的应用程序。公司很可能提供不同类型的车辆。每辆车都可以用其自身的对象来表示。为了绘制意图,我们创建了一个统一建模语言UML)类图以保持清晰度(图 3**.2):

图 3.2 – 车辆生产跟踪示例

图 3.2 – 车辆生产跟踪示例

目标工厂旨在生产两种不同类型的车辆,并且应用程序能够即时满足这些愿望(示例 3.1):

public static void main(String[] args) {
    System.out.println("Pattern Factory Method: Vehicle
        Factory 2");
    var sportCar = VehicleFactory.produce("sport");
    System.out.println("sport-car:" + sportCar);
    sportCar.move();
}

这里是输出:

Pattern Factory Method: Vehicle Factory 2
sport-car:SportCar[type=porsche 911]
SportCar, type:'porsche 911', move

示例 3.1 – VehicleFactory 根据输入参数生产同一“家族”的车辆

我们不是将此类车辆类型的创建分散到多个地方,而是创建一个工厂。工厂抽象集中了整个车辆组合过程,并仅暴露一个入口点,允许客户端创建所需的车辆类型(如示例 3.2所示)。工厂只实现一个静态方法,因此保持其构造函数为私有是有意义的,因为工厂实例是不希望的:

final class VehicleFactory {
private VehicleFactory(){}
    static Vehicle produce(String type){
        return switch (type) {
            case "sport" -> new SportCar("porsche 911");
            case "suv" -> new SuvCar("skoda kodiaq");
            default -> throw new
                IllegalArgumentException("""
            not implemented type:'%s'
                """.formatted(type));
        };
    }
}

示例 3.2 – VehicleFactory 类公开了静态工厂方法来生产实现 Vehicle 接口的对象实例

所展示的switch表达式可能使用模式匹配方法来简化代码,而不是传统的switch-label-match结构。应用程序提供了多种车辆实现(示例 3.3):

interface Vehicle {
    void move();
}

示例 3.3 – 考虑到的每种车辆都通过 Vehicle 接口继承方法抽象

由于另一个平台语法改进,records类型,现在可以选择类封装的级别,通过 SOLID 原则的反射。这取决于软件架构师打算允许车辆实例改变其内部状态的程度。让我们首先看看标准的 Java 类定义方法(示例 3.4):

class SuvCar implements Vehicle {
    private final String type;
    public SuvCar(String t){
        this.type = t;
    }
    @Override
    public void move() {...}
}

示例 3.4 – SuvCar 允许添加可能包含可变状态的内部字段

软件架构师有机会使用record类来创建所需车辆的不可变实例,以及随后的hashCodeequals方法,然后是toString实现:

record SportCar(String type) implements Vehicle {
    @Override
    public void move() {
        System.out.println("""
        SportCar, type:'%s', move""".formatted(type));
    }
}

示例 3.5 – 被认为是不可变的 SportCar

最近引入的record功能在减少潜在样板代码的同时,仍然允许实现内部功能(如前一章的Records (Java SE 16, JEP-395)部分所述)。

结论

工厂方法有一些限制。其中最重要的一点是它只能用于特定家族的对象。这意味着所有类都必须保持相似的性质或共同的基础。一个类与基类之间的偏差可能会在代码和应用之间引入强烈的耦合。

需要考虑的点可能与方法本身有关,因为它可能是静态的或属于实例(如前一章在栈区域堆区域部分所述)。这取决于软件设计师的决定。

创建了一个家族的对象。让我们研究如何处理具有共同属性的工厂家族。

使用抽象工厂模式从不同的家族创建对象

此模式引入了一个工厂抽象,而不需要定义特定的类(或应该实例化的类)。客户端请求一个适当的工厂来实例化对象,而不是尝试创建它。抽象工厂模式在 GoF 的书中被提及。

动机

将应用程序模块化可能是一个挑战。软件设计者可以通过避免向类中添加代码来保持封装性。动机是将工厂逻辑与应用程序代码分离,以便它可以提供适当的工厂来生成所需的对象。抽象工厂提供了一种标准化的方式来创建所需工厂的实例,并将该实例提供给客户端使用。客户端使用生成的工厂来实例化对象。抽象工厂提供了一个接口,用于创建工厂和对象,而不指定它们的类。该模式通过隔离参与者和内部人员的逻辑,隐式支持 SOLID 原则和可维护性。应用程序与其产品的创建、组合和表示方式无关。

在 JDK 中找到它

抽象工厂方法模式可以在 JDK 的java.xml模块的java.xml包中找到。抽象工厂模式可以在DocumentBuilderFactory抽象类及其静态newInstance方法的表示和实现中找到。该工厂使用查找服务来找到所需的构建器实现。

示例代码

考虑到虽然车辆有一些共同特性,但它们的制造需要不同类型的流程(图 3**.3):

图 3.3 – 使用抽象工厂模式制造不同类型的车辆

图 3.3 – 使用抽象工厂模式制造不同类型的车辆

在这种情况下,我们创建多个负责特定对象的工厂。尽管这些类属于不同的家族,但它们确实有一些共同属性。一个重要特性是每个工厂都可以实现自己的初始化序列,同时共享通用逻辑。示例需要正确的CarFactory实例来创建一个SlowCar对象(示例 3.6):

public static void main(String[] args) {
    ...
    AbstractFactory carFactory =
         FactoryProvider.getFactory("car");
     Vehicle slowCar = carFactory.createVehicle("slow");
        slowCar.move();
}

这里是输出:

Pattern Abstract Factory: create factory to produce
vehicle...
slow car, move

示例 3.6 – 客户端决定需要哪种车辆类型

游戏的一个关键元素是工厂提供者;它根据传入的参数区分创建哪个工厂(示例 3.7)。提供者实现为一个实用工具,因此其类是最终的,构造函数是私有的,因为不需要实例。当然,实现可能根据需求而有所不同:

final class FactoryProvider {
private FactoryProvider(){}
    static AbstractFactory getFactory(String type){
        return switch (type) {
            case "car" -> new CarFactory();
            case "truck" -> new TruckFactory();
            default -> throw new IllegalArgumentException
                ("""         this is %s
                 """.formatted(type));
        };
    }
}

示例 3.7 – 工厂提供者类定义了对象家族的特定工厂的配置和实例化方式

组中的每个工厂都共享通用逻辑或特性,以在代码库中保持 DRY(不要重复自己)方法:

abstract class AbstractFactory {
    abstract Vehicle createVehicle(String type);
}

示例 3.8 – 抽象工厂类提供了可能需要特定工厂实现的通用逻辑或方法

这些单独的工厂可以实现额外的逻辑来区分应该提供哪种产品,类似于以下示例中的TruckFactoryCarFactory实现(示例 3.9):

class TruckFactory extends AbstractFactory {
    @Override
    Vehicle createVehicle(String type) {
        return switch(type) {
            case "heavy" -> new HeavyTruck();
            case "light" -> new LightTruck();
            default -> throw new IllegalArgumentException
                ("not implemented");
        };
    }
}

示例 3.9 – TruckFactory 类代表特定的抽象工厂实现

结论

抽象工厂模式在产品之间提供一致性。使用超级工厂可能导致客户端运行时出现不稳定,因为请求的产品可能会由于实现不正确而抛出异常或错误,因为这种信息是在运行时才知道的。另一方面,抽象工厂模式促进了可测试性。抽象工厂可以自由地表示与其实现一起出现的许多其他接口。该模式提供了一种处理产品的方法,而不依赖于它们的实现,这可以提高应用程序代码的关注点分离。它可以使用接口或抽象类。客户端变得独立于对象的组合和创建方式。

封装工厂和代码分离的好处可以被视为一种限制。抽象工厂必须通过一个或多个参数来控制,以正确地定义依赖关系。为了提高所需工厂的代码可维护性,考虑之前讨论的密封类增强(参见前一章中的密封类(Java SE 17,JEP-409)部分)可能是有用的。密封类可以对代码库的稳定性产生积极影响。

让我们在下一节中考察如何自定义对象创建过程。

使用建造者模式实例化复杂对象

建造者模式有助于将复杂对象的构建与其代码表示分离,以便相同的组合过程可以重用来创建不同配置的对象类型。建造者设计模式被早期识别,并成为 GoF 书籍的一部分。

动机

建造者模式背后的主要动机是在不污染构造函数的情况下构建复杂实例。它有助于将创建过程分解成特定的步骤,甚至可以将其拆分。对象的组合对客户端是透明的,并允许创建同一类型的不同配置。建造者由一个单独的类表示。它可以帮助在需要时透明地扩展构造函数。该模式有助于封装并强制执行与之前讨论的 SOLID 设计原则相关的实例化过程的清晰度。

在 JDK 中找到它

建造者模式在 JDK 内部是一种常用的模式。一个很好的例子是创建表示字符串的字符序列。例如,StringBuilderStringBuffer位于java.base模块的java.lang包中,默认情况下对每个 Java 应用程序都是可见的。字符串构建器提供了多个重载的连接方法,这些方法接受不同类型的输入。这种输入与已创建的字节序列连接。另一个例子可以在java.net.http包中找到,它由HttpRequest.Builder接口及其实现或java.util.stream包中的Stream.Builder接口表示。如前所述,建造者模式非常常用。值得注意的是Locale.BuilderCalendar.Builder,它们使用 setter 方法存储最终产品的值。这两个都可以在java.based模块的java.util包中找到。

示例代码

建造者是模式的关键元素,在创建Vehicle实例期间持有所需的字段值,更确切地说,是对象的引用(图 3**.4):

图 3.4 – 如何使用建造者模式透明地创建新车辆

图 3.4 – 如何使用建造者模式透明地创建新车辆

建造者模式的整体责任是创建车辆(示例 3.10):

public static void main(String[] args) {
    System.out.println("Builder pattern: building
        vehicles");
    var slowVehicle = VehicleBuilder.buildSlowVehicle();
    var fastVehicle = new FastVehicle.Builder()
                        .addCabin("cabin")
                        .addEngine("Engine")
                        .build();
    slowVehicle.parts();
    fastVehicle.parts();
}

这里是输出:

Builder pattern: building vehicles
SlowVehicle,engine: RecordPart[name=engine]
SlowVehicle,cabin: StandardPart{name='cabin'}
FastVehicle,engine: StandardPart{name='Engine'}
FastVehicle,cabin: RecordPart[name=cabin]

示例 3.10 – 建造者模式可以根据需求以几种方式实现

建造者模式可以通过不同的方法实现。一种方法是将所有建造者逻辑封装并隐藏,直接提供产品而不暴露实现细节:

final class VehicleBuilder {
    static Vehicle buildSlowCar(){
        var engine = new RecordPart("engine");
        var cabin = new StandardPart("cabin");
        return new SlowCar(engine, cabin);
    }
}

示例 3.11 – VehicleBuilder隐藏逻辑以提供特定实例

或者,建造者可能成为要创建实例的类的组成部分。在这种情况下,可以决定应该将哪个元素添加到新创建的特定实例中:

class FastCar implements Vehicle {
    final static class Builder {
        private Part engine;
        private Part cabin;
        Builder(){}
        Builder addEngine(String e){...}
        Builder addCabin(String c){...}
        FastCar build(){
            return new FastCar(engine, cabin);
        }
    }
    private final Part engine;
    private final Part cabin;
    ...
    @Override
    public void move() {...}
    @Override
    public void parts() {...}
}

示例 3.12 – FastVehicle.Builder被表示为一个静态类,需要实例化,并提供最终结果定制的可能性

两种示例方法都是根据 SOLID 原则实现的。建造者模式是抽象、多态、继承和封装(APIE)原则的一个很好的例子,并且非常易于重构、扩展或验证属性。

结论

建造者模式通过将复杂创建与业务逻辑分离,有助于强制执行单一职责原则。它还提高了代码可读性和 DRY(Don't Repeat Yourself)原则,因为实例化是可扩展且易于用户理解的。建造者模式是一种常用的设计模式,因为它减少了“代码异味”和构造函数污染。它还提高了可测试性。代码库有助于避免具有不同表示形式的多个构造函数,其中一些从未被使用过。

在实现模式时,另一个值得考虑的好点是使用 JVM 的堆或栈——更具体地说,是创建模式的静态或动态分配表示。这个决定通常由软件设计师自己回答。

并非总是有必要揭示构建过程。下一节将介绍对象克隆的简单性。

使用原型模式克隆对象

原型模式解决了创建具有复杂实例化过程的对象新实例的困难,因为其过程过于繁琐且不理想,可能会导致不必要的子类化。原型是一种非常常见的模式,并在 GoF 的书中进行了描述。

动机

当需要创建重量级对象且工厂方法不受欢迎时,原型设计模式非常有用。新创建的实例是从其父实例克隆的,因为父实例充当原型。实例之间相互独立,可以定制。实例逻辑不会暴露给客户端,也不能由客户端贡献。

在 JDK 中查找

在 JDK 的各个包中有很多使用原型模式的例子。集合框架成员实现了继承自Cloneable接口所需的clone方法。例如,ArrayList.clone()方法的执行会逐字段创建实体的浅拷贝。另一个原型实现可以是java.util包中的Calendar类。对重写方法的克隆也用于Calendar实现本身,因为它有助于避免对已配置的实例进行不希望的修改。这种用法可以在getActualMinimumgetActualMaximum方法中找到。

示例代码

当生产中只有少数几种车辆型号时,没有必要通过工厂或构建器不断建立新对象,这实际上可能导致代码行为难以控制,因为内部属性可能会发生变化。想象一下车辆生产的早期阶段,其中每个新迭代都需要进行等量比较以跟踪进度(图 3**.5):

图 3.5 – 从原型创建新实例

图 3.5 – 从原型创建新实例

在这种情况下,创建一个已经设计好的车辆的精确副本作为其原型更容易:

public static void main(String[] args) {
    Vehicle fastCar1 = VehicleCache.getVehicle("fast-car");
    Vehicle fastCar2 = VehicleCache.getVehicle("fast-car");
    fastCar1.move();
    fastCar2.move();
    System.out.println("equals : " + (fastCar1
        .equals(fastCar2)));
}

这里是输出:

Pattern Prototype: vehicle prototype 1
fast car, move
fast car, move
equals : false
fastCar1:FastCar@659e0bfd
fastCar2:FastCar@2a139a55

示例 3.13 – 可以从现有实例克隆出新的车辆

实例可以根据需要重新创建(分别,克隆)。Vehicle抽象类为每个新的原型实现提供了一个基础,并提供了克隆细节:

abstract class Vehicle implements Cloneable{
    protected final String type;
    Vehicle(String t){
        this.type = t;
    }
    abstract void move();
    @Override
    protected Object clone() {
        Object clone = null;
        try{
        clone = super.clone();
        } catch (CloneNotSupportedException e){...}
        return clone;
    }
}

示例 3.14 – 车辆抽象类必须实现Cloneable接口并引入克隆方法实现

每个车辆实现都需要扩展父Vehicle类:

class SlowCar extends Vehicle {
    SlowCar(){
        super("slow car");
    }
    @Override
    void move() {...}
}

示例 3.15 – 由 SlowCar 类提供的车辆接口特定实现和移动方法实现

原型模式引入了一个内部缓存,收集可用的 Vehicle 类型原型(示例 3.16)。所提出的实现实现了一个静态方法,使缓存作为一个工具工作。将其构造函数设为私有是有意义的:

final class VehicleCache {
private static final Map<String, Vehicle> map =
    Map.of("fast-car", new FastCar(), "slow-car", new
        SlowCar());
private VehicleCache(){}
    static Vehicle getVehicle(String type){
        Vehicle vehicle = map.get(type);
        if(vehicle == null) throw
        new     IllegalArgumentException("not allowed:" +
            type);
        return (Vehicle) vehicle.clone();
    }
}

示例 3.16 – VehicleCache 存储了对已准备的原型的引用,这些原型可以被克隆

这些示例表明,客户端每次都使用基原型的相同副本进行工作。这个副本可以根据需求进行定制。

结论

原型模式对于动态加载或避免通过引入不必要的抽象(称为子类化)来增加代码库的复杂性是有用的。这并不意味着克隆不需要实现接口,但克隆可以减少暴露需求或使实例化过程过于复杂。原型正确地封装了那些不打算被触摸或修改的实例的复杂逻辑。软件设计者应该意识到这样的代码库很容易变成遗留代码。另一方面,一个模式可以推迟并支持代码库的迭代更改。

对象的多个实例并不总是希望的,有时甚至是不希望的。在下一节中,我们将学习如何确保在运行时只有一个唯一的类实例存在。

使用单例模式确保只有一个实例

单例对象为其实例提供透明和全局访问,并确保只有一个实例存在。单例模式在行业需求提出得很早,并在 GoF 的书中被提及。

动机

客户端或应用程序想要确保在运行时只有一个实例存在。一个应用程序可能需要多个对象实例,这些实例都使用一个独特的资源。这个事实引入了不稳定性,因为任何这些对象都可以访问这样的资源。单例确保只有一个实例,为所有客户端提供全局访问点,在运行 JVM 的期望范围内。

在 JDK 中找到它

使用单例的最佳例子是一个正在运行的 Java 应用程序,或者更确切地说,是运行时。它在 Runtime 类中,其方法 getRuntime 位于 java.base 模块的 java.lang 包中。该方法返回与当前 Java 应用程序关联的对象。运行时实例允许客户端向运行中的应用程序添加,例如,关闭钩子。

示例代码

以下示例建议一个只运行一辆车及其引擎的应用程序(图 3**.6):

图 3.6 – 单例模式如何表示一个引擎

图 3.6 – 单例模式如何表示一个引擎

换句话说,这意味着在 JVM 中必须存在特定类型的引擎和车辆的一个实例:

public static void main(String[] args) {
    System.out.println("Singleton pattern: only one
        engine");
    var engine = OnlyEngine.getInstance();
    var vehicle = OnlyVehicle.getInstance();
    vehicle.move();
    System.out.println("""
        OnlyEngine:'%s', equals with vehicle:'%s'"""
        .formatted(engine, (vehicle.getEngine()
            .equals(engine))));
}

这里是输出结果:

Pattern Singleton: only one engine
OnlyVehicle, move
OnlyEngine:'OnlyEngine@7e9e5f8a', equals with
    vehicle:'true'

示例 3.17 – OnlyEngine 和 OnlyCar 在运行时只有一个实例

确保对象实例唯一性的方法有多种。OnlyEngine 类的实现引入了可能的单例实现,其实例在需要时才会懒加载(示例 3.18)。OnlyEngine 类实现了通用的 Engine 接口。其实现提供了一个静态的 getInstance 方法作为透明的入口点:

interface Engine {}
class OnlyEngine implements Engine {
    private static OnlyEngine INSTANCE;
    static OnlyEngine getInstance(){
        if(INSTANCE == null){
            INSTANCE = new OnlyEngine();
        }
    return INSTANCE;
    }
    private OnlyEngine(){}
}

示例 3.18 – OnlyEngine 类检查其实例的存在 – 懒加载

实现单例的另一种方法是创建一个属于类本身的 static 字段,并向潜在客户端公开 getInstance 入口点(示例 3.19)。值得注意的是,在这种情况下,构造函数变为私有:

class OnlyVehicle {
    private static OnlyVehicle INSTANCE = new
        OnlyVehicle();
    static OnlyVehicle getInstance(){
        return INSTANCE;
}
    private OnlyVehicle(){
        this.engine = OnlyEngine.getInstance();
    }
    private final Engine engine;
    void move(){
       System.out.println("OnlyVehicle, move");
   }
    Engine getEngine(){
        return engine;
    }
}

示例 3.19 – OnlyVehicle 类将其实例作为属于类的静态字段提供

在多线程环境中,懒加载的单例模式实现可能会成为一个挑战,因为必须同步 getInstance 方法以获取唯一的实例。一种可能性是将单例创建为一个 enum 类(示例 3.20):

enum OnlyEngineEnum implements Engine {
    INSTANCE;
    }
    ...
    private OnlyVehicle(){
    this.engine = OnlyEngineEnum.INSTANCE;
}
...

示例 3.20 – 使用 OnlyEngineEnum 单例枚举类方法

结论

单例是一个相对简单的设计模式,尽管在多线程环境中使用时可能会变得复杂,因为它保证了所需对象只有一个实例。当强制执行单一责任原则时,该模式可能会受到挑战,因为类实际上负责实例化自己。另一方面,单例模式确保客户端可以全局访问分配的资源,防止意外初始化或销毁对象。该模式应谨慎使用,因为它以所需类实例化的方式创建了紧密耦合的代码,这可能导致可测试性问题。该模式还抑制了其他子类,使得任何扩展几乎都不可能。

创建实例并不总是好的方法。让我们看看如何按需进行。

使用对象池模式提高性能

对象池模式实例化了可用的对象,并限制了它们的初始化时间。所需的实例可以在需要时重新创建。对象池可以代表一个基础条件集,基于这些条件可以创建新的实例,或者限制它们的创建。

动机

与在代码库中不断创建新的对象实例相比,对象池通过提供已初始化并准备好使用的对象,为管理应用程序或客户端性能提供了一个封装的解决方案。该模式将构建逻辑与业务代码分离,并有助于管理应用程序的资源性能方面。它不仅可以帮助管理对象的生命周期,还可以在创建或销毁时进行验证。

在 JDK 中查找

对象池模式的一个很好的例子是 java.util.concurrent 包中找到的 ExecutorService 接口,以及 util 工厂中 Executors 类提供的实现,该实现处理适当的执行器实例,例如 newScheduledThreadPool 方法。

样本代码

当前示例介绍了一个场景,其中车库包含一定数量的汽车,司机可以驾驶(图 3**.7):

图 3.7 – 遵循对象池模式的车库

图 3.7 – 遵循对象池模式的车库

当没有汽车可用时,车库实现了一种逻辑,即购买一辆新车以保持所有司机忙碌:

public static void main(String[] args) {
    var garage = new PooledVehicleGarage();
    var vehicle1 = garage.driveVehicle();
    ...
    vehicle1.move();
    vehicle2.move();
    vehicle3.move();
    garage.returnVehicle(vehicle1);
    garage.returnVehicle(vehicle3);
    garage.printStatus();
    var vehicle4 = garage.driveVehicle();
    var vehicle5 = garage.driveVehicle();
    vehicle4.move();
    vehicle5.move();
    garage.printStatus();
}

这里是输出:

Pattern Object Pool: vehicle garage
PooledVehicle, move, vin=1
PooledVehicle, move, vin=2
PooledVehicle, move, vin=3
returned vehicle, vin:1
returned vehicle, vin:3
Garage Pool vehicles available=2[[3, 1]] inUse=1[[2]]
PooledVehicle, move, vin=3
PooledVehicle, move, vin=1
Garage Pool vehicles available=0[[]] inUse=3[[3, 2, 1]]

示例 3.21 – 车库实例中的池化车辆有助于降低对象成本

模式的一个核心元素是池抽象,因为它包含管理实体所需的所有逻辑。一个选项是创建一个抽象车库池类(示例 3.22),其中包含所有同步机制。这些机制是必要的,以避免潜在的代码不稳定和不一致性:

abstract class AbstractGaragePool<T extends Vehicle> {
    private final Set<T> available = new HashSet<>();
    private final Set<T> inUse = new HashSet<>();
    protected abstract T buyVehicle();
    synchronized T driveVehicle() {
        if (available.isEmpty()) {
            available.add(buyVehicle());
        }
        var instance = available.iterator().next();
        available.remove(instance);
        inUse.add(instance);
        return instance;
    }
    synchronized void returnVehicle(T instance) {...}
    void printStatus() {...}
}

示例 3.22 – 抽象车库池提供了正确管理元素所需的所有逻辑

车库池限制了可能的实例类型。一个类被 Vehicle 抽象绑定(示例 3.23)。该接口提供了客户端使用的常用函数。在以下示例中,AbstractGaragePool 的实现代表客户端:

interface Vehicle {
    int getVin();
    void move();
}

示例 3.23 – 车辆接口需要由 PooledVehicle 实现

除了实现函数外,PooledVehicle 类还提供了一个私有计数器(示例 3.24)。计数器属于一个类,因此被标记为 staticfinal。该计数器统计车库池购买的实例数量:

class PooledVehicle implements Vehicle{
    private static final AtomicInteger COUNTER = new
        AtomicInteger();
    private final int vin;
    PooledVehicle() {
        this.vin = COUNTER.incrementAndGet();
    }
    @Override
    public int getVin(){...}
    @Override
    public void move(){..}
}

示例 3.24 – PooledVehicle 类实现还保留了创建的实例数量

结论

提高客户端性能有助于减少昂贵的对象实例化时间(如我们在 示例 3.21 中所见)。对象池在只需要短期对象的情况下也非常有用,因为它们有助于通过无控制的实例减少内存碎片。值得提及的是,车库示例中看到的内部缓存模式的实现。

虽然该模式非常高效,但正确选择集合结构也会对其性能产生重大影响。它可以减少搜索并节省时间。

另一个积极的结果可以考虑对垃圾收集过程和内存压缩的影响,因为分析活动对象可能会导致需要分析的对象更少。

并非总是有必要将所有内容存储在内存中以供以后重用。让我们看看如何推迟对象初始化过程,并避免内存污染。

使用延迟初始化模式按需初始化对象

此模式的目的是将所需类实例的创建推迟到客户端实际请求时。

动机

尽管近年来操作内存急剧增长,但我们上一章了解到 JVM 为堆分配了定义明确的特定大小的内存。当堆耗尽且 JVM 无法分配任何新对象时,会导致内存溢出错误。懒处理可以对这种堆污染产生相当积极的影响。由于延迟实例,它有时也被称为异步加载。在网页按需生成而不是在应用程序初始化过程中生成的 Web 应用程序中,此模式非常有用。它也在成本较高的相关对象操作的应用程序中有其位置。

在 JDK 中查找

可以使用 ClassLoader 加载在应用程序启动时未在运行时链接的类的动态加载示例来演示懒初始化。类可以通过类策略预先加载或延迟加载。某些类,如 ClassNotFoundException,通过 java.base 模块隐式加载。它们支持位于 java.lang 包及其 forName 方法中的类实现。方法的实现由内部 API 提供。懒初始化的类可能是应用程序需要预热时间的原因。例如,Enum 类是一种特殊类型的静态最终类,充当常量,并且会预先加载。

-> 指的是将加载步骤放入类加载器并填充适当的方法区域,正如我们在上一章所学。

示例代码

懒初始化示例的基本思想是创建的车辆按需初始化,或者当已经创建时,向客户端提供一个引用(图 3**.8):

图 3.8 – 如何使用懒加载模式按需创建车辆

图 3.8 – 如何使用懒加载模式按需创建车辆

只有当客户端真正需要时,这些车辆才存在。在这种情况下,会创建特定的车辆实例。当车辆已经存在于提供者上下文中时,则重用该实例:

public static void main(String[] args) {
    System.out.println("Pattern Lazy Initialization: lazy
        vehicles");
    var vehicleProvider = new VehicleProvider();
    var truck1 = vehicleProvider.getVehicleByType("truck");
    vehicleProvider.printStatus();
    truck1.move();
    var car1 = vehicleProvider.getVehicleByType("car");
    var car2 = vehicleProvider.getVehicleByType("car");
    vehicleProvider.printStatus();
    car1.move();
    car2.move();
    System.out.println("ca1==car2: " + (car1.equals
       (car2)));
}

这里是输出:

Pattern Lazy Initialization: lazy vehicles
lazy truck created
status, truck:LazyVehicle[type=truck]
status, car:null
LazyVehicle, move, type:truck
lazy car created
status, truck:LazyVehicle[type=truck]
status, car:LazyVehicle[type=car]
LazyVehicle, move, type:car
LazyVehicle, move, type:car
ca1==car2: true

示例 3.25 – 池化车辆的示例实现

VehicleProvider 类的实现将字段视为私有。如果需要,这些字段持有所需车辆的引用。提供者封装了决策和实例化逻辑。可能的实现之一可能使用 switch-label-match 构造(示例 3.26)、switch 表达式等。值得注意的是,在此示例中,VehicleProvider 类需要一个包作用域的实例,因此其构造函数是 private 包,不对其他包公开:

final class VehicleProvider {
    private Vehicle truck;
    private Vehicle car;
    VehicleProvider() {}
    Vehicle getVehicleByType(String type){
        switch(type){
        case "car":
            ...
            return car;
        case "truck":
            if(truck == null){
                System.out.println("lazy truck created");
                truck = new LazyVehicle(type);
            }
            return truck;
        default:
            ...
    }
    void printStatus(){...}
}

示例 3.26 – VehicleProvider 隐藏了可能的实体实例化逻辑,使其对客户端不可见

为了强制实现可能延迟初始化的对象的可扩展性,每个实体都实现了Vehicle抽象:

interface Vehicle {
    void move();
}
record LazyVehicle(String type) implements Vehicle{
    @Override
    public void move() {
        System.out.println("LazyVehicle, move, type:" +
            type);
    }
}

示例 3.27 – 使用 record 强制不可变性实现车辆抽象和可能的 LazyVehicle 实现

这种方法强制执行持续的车辆进化,而不需要对供应商逻辑进行复杂的更改。

结论

懒初始化设计模式可以帮助保持应用程序内存小。另一方面,不当的使用可能会导致不希望的延迟,因为对象可能过于复杂而难以创建,并且运行时间较长。

下一个部分展示了如何将逻辑注入客户端,由新创建的车辆实例表示。

使用依赖注入模式减少类依赖

这种模式将类(作为服务)的初始化与客户端(使用服务)的初始化分开。

动机

依赖注入模式在需要将特定对象(服务)的实现与使用其公开服务、方法等的目标对象(客户端)分离时被广泛使用。当需要创建客户端实例时,服务可用。该模式允许您消除任何硬编码的依赖。这些服务是在客户端创建过程之外实例化的。这意味着两者之间是松散连接的,可以强制执行 SOLID 原则。实现依赖注入有三种方式:

  • 构造函数依赖注入:通过构造函数的初始化,将预期服务提供给客户端。

  • 注入方法:客户端通过接口正常暴露方法。该方法向客户端提供依赖。供应商对象使用方法将服务注入客户端。

  • public属性。

在 JDK 中查找

使用依赖注入模式的良好例子是ServiceLoader实用类。它位于java.base模块和其java.util包中。ServiceLoader实例在应用启动时尝试在运行时查找服务。一个服务被认为是由相关服务提供者或提供者实现的良好定义的接口表示。应用程序代码能够在运行时区分所需的提供者。值得注意的是,ServiceLoader与经典的classpath配置一起工作,或者可以无缝地与 Java 平台模块系统(在第第二章发现 Java 平台设计模式中讨论)一起使用。

在过去,依赖注入是 Java EE 范围的一部分,而不是经典 JDK。这意味着该功能在 Java 平台上可用。由于 JDK 的发展,依赖注入功能被移动到 @Inject@Named@Scope@Qualifier 注解。这些注解允许在运行时将类转换为托管对象,从而可以区分所需提供者的实现。

样本代码

一个示例展示了简化后的依赖注入模式,以便获得一般经验。这是一个非常简单的实现;实际上,它描绘了之前提到的 API 在幕后是如何工作的。让我们想象一个场景,其中预期的车辆实例需要一个引擎(图 3**.9):

图 3.9 – 将引擎作为服务注入到车辆实例中

图 3.9 – 将引擎作为服务注入到车辆实例中

特定的引擎构建逻辑与车辆相关代码分离(示例 3.28):

1 public static void main(String[] args) {
2     System.out.println("Pattern Dependency Injection:
          vehicle and engine");
3     EngineServiceProvider.addEngine(new FastEngine
          ("sport"));
4     Engine engine =
        EngineServiceProvider.getEngineByType("sport");
5     Vehicle vehicle = new SportVehicle(engine);
6     vehicle.move();
7 }

这里是输出:

Pattern Dependency Injection: vehicle and engine
FastEngine, started
FastEngine, run
SportCar, move

示例 3.28 – 从车辆之外单独创建 FastEngine 实例,然后将引擎添加到构建的 SportVehicle 中

FastEngine实例完全就绪(已启动、已验证等)时使用。所需类型的车辆可以独立构建,无需依赖任何引擎逻辑。使用EngineServiceProvider示例 3.29)为SportVehicle提供引擎实例:

final class EngineServiceProvider {
    private static final Map<String, Engine> ENGINES = new
        HashMap<>();
    ...
    static Engine getEngineByType(String t){
        return ENGINES.values().stream()
                .filter(e -> e.type().equals(t))
                .findFirst().orElseThrow
                    (IllegalArgumentException::new);
    }
}

示例 3.29 – EngineServiceProvider 注册已实例化的可重用服务

SportVehicle 类实现 Vehicle 接口(示例 3.30)以反映 SOLID 设计原则中提到的开放-封闭方法:

interface Vehicle {
    void move();
}
class SportVehicle implements Vehicle{
    private final Engine engine;
    SportVehicle(Engine e) {...}
    @Override
    public void move() {
        if(!engine.isStarted()){
            engine.start();
    }
        engine.run();
        System.out.println("SportCar, move");
    }
}

示例 3.30 – SportVehicle 类实现 Vehicle 接口,并包含为提供的 Engine 实例提供的附加内部逻辑

重要的是要注意,尽管在某个地方创建了特定的 Engine 类型(示例 3.31)实例(FastEngine),但在实例化 SportVehicle 对象时需要其存在(示例 3.28,第 5 行):

interface Engine {
    void start();
    boolean isStarted();
    void run();
    String type();
}
class FastEngine implements Engine{
    private final String type;
    private boolean started;
    FastEngine(String type) {
        this.type = type;
    }
    ...
}

示例 3.31 – FastEngine 类实现的 Engine 接口和由 EngineServiceProvider 提供的接口

在所描述的示例中的关键玩家对象是 EngineServiceProvider。它提供对已创建的所需 Engine 实例的引用,并将它们分布到业务代码中。这意味着任何需要处理 Engine(类似于 SportVehicle 实例)的客户端,将通过 EngineServiceProvider 暴露的链接获得正确的实例访问权限。

所展示的简单示例可以很容易地通过 ServiceProvider 工具类实例转换为另一个示例。更改非常微小(示例 3.32):

public static void main(String[] args) {
    System.out.println("Pattern Dependency Injection
       Service Loader: vehicle and engine");
    ServiceLoader<Engine> engineService =
        ServiceLoader.load(Engine.class);
    Engine engine = engineService.findFirst()
        .orElseThrow();
    Vehicle vehicle = new SportVehicle(engine);
    vehicle.move();
}

示例 3.32 – ServiceLoader 提供了用于实例化 SportVehicle 类型的 Engine 接口的可用实现

在标准的类路径利用中,Java 平台要求服务提供者在META-INF文件夹和services子文件夹中注册。文件名由包和服务的接口名称组成,文件包含可用的服务提供者。

Java 平台模块系统简化了配置步骤。相关模块为(并为目标模块提供和使服务实现可用),正如我们在第二章**,发现 Java 平台设计模式中提到的。

结论

依赖注入模式确保客户端不知道所使用服务的实例化。客户端可以通过通用接口访问服务。这使得代码库更容易测试。它还简化了代码库的可测试性。依赖注入是各种框架(如 Spring 和 Quarkus)广泛使用的模式。Quarkus 使用 Jakarta 依赖注入规范。依赖注入模式符合 SOLID 和 APIE 面向对象编程原则,因为它提供了接口的抽象。代码不依赖于实现,而是通过接口与服务进行通信。依赖注入模式强制执行 DRY 原则,因为它不需要不断启动服务。

摘要

创建型设计模式在软件应用设计中扮演着非常重要的角色。它们有助于根据基本的面向对象原则,透明地集中管理对象实例化逻辑。示例表明,每种模式可能有多种实现方式。这是因为实现决策可能取决于其他软件架构因素。这些因素考虑了 JVM 堆和栈的使用、应用程序运行时间或业务逻辑封装。

隐式使用设计模式进行创作,促进了 DRY(Don't Repeat Yourself)方法,这对应用开发有积极影响,并减少了代码库的污染。应用程序变得可测试,软件架构师有一个框架来确认 JVM 内存在预期对象。当识别出逻辑问题时,这一点尤为重要,这可能是异常或意外结果。一个良好的代码库有助于快速找到根本原因,甚至可能不需要调试。

在本章中,我们学习了如何使用透明工厂方法模式创建特定家族的对象。抽象工厂模式展示了创建不同工厂类型的封装方式。并非所有所需信息总是同时存在,而建造者模式介绍了一种处理这种复杂对象组合挑战的方法。原型模式展示了不向客户端暴露实例逻辑的方法,而单例模式确保了在运行时只有一个实例。对象池设计模式揭示了如何在运行时提高内存使用效率,而延迟实例化模式展示了如何将对象延迟到需要时再创建。依赖注入模式展示了在创建新对象时实例的可重用性。

创造性设计模式不仅使创建新实例的过程更加清晰,而且在许多情况下,它们还可以帮助决定正确的代码结构(反映所需业务逻辑的代码结构)。最后提到的三个模式不仅解决了创建对象的问题,还解决了它们在高效内存使用中的可重用性问题。我们讨论的例子展示了如何实现创造模式,并了解了每个模式的差异和目的。

下一章将讨论结构设计模式。这个模式将帮助我们根据最常见的场景来组织我们的代码。让我们继续前进。

问题

  1. 创造性设计模式解决了哪些挑战?

  2. 哪些模式可能有助于减少对象初始化成本?

  3. 利用单例设计模式的关键原因是什么?

  4. 哪个模式有助于减少构造函数污染?

  5. 你如何隐藏复杂的实例化逻辑,使其对客户端不可见?

  6. 是否有可能减少实例化应用程序的内存占用?

  7. 哪个设计模式适用于创建特定家族的对象?

进一步阅读

  • 《设计模式:可复用面向对象软件元素》 by Erich Gamma, Richard Helm, Ralph Johnson, and John Vlissides, Addison-Wesley, 1995

  • 《设计原则与设计模式》 by Robert C. Martin, Object Mentor, 2000

  • 《集成电路上的更多组件》 by Gordon E. Moore, Electronics Magazine, 1965-04-19

  • Oracle 教程:泛型docs.oracle.com/javase/tutorial/java/generics/index.html

  • Quarkus 框架quarkus.io/

  • Spring 框架spring.io/

  • Jakarta 依赖注入jakarta.ee/specifications/dependency-injection/

  • 《代码整洁之道》 by Robert C. Martin, Pearson Education, Inc, 2009

  • 《有效 Java – 第三版》 by Joshua Bloch, Addison-Wesley, 2018

第四章:应用结构型设计模式

每个软件都有一个目的或换句话说,它应该满足的预期行为。虽然前一章详细描述了创建型设计模式,但本章将专注于为创建的对象设计可维护和灵活的源代码。结构型模式试图阐明创建实例之间的关系,不仅是为了维护应用程序,也是为了容易理解其目的。让我们深入了解,并开始检查以下主题:

  • 使用适配器模式实现不兼容对象协作

  • 使用桥接模式解耦并独立开发对象

  • 使用组合模式以相同的方式处理对象

  • 通过使用装饰器模式扩展对象功能

  • 使用外观模式简化通信

  • 使用条件选择所需对象与过滤器模式

  • 使用享元模式在应用程序中共享对象

  • 使用前端控制器模式处理请求

  • 使用标记模式识别实例

  • 使用模块模式探索模块的概念

  • 使用代理模式为对象提供一个占位符

  • 使用双生模式在 Java 中探索多重继承

到本章结束时,您将牢固地理解如何围绕创建的实例构建代码库。

技术要求

您可以在 GitHub 上找到本章的代码文件:github.com/PacktPublishing/Practical-Design-Patterns-for-Java-Developers/tree/main/Chapter04

使用适配器模式实现不兼容对象协作

适配器模式的主要目标是连接源类接口到客户端期望的另一个接口。适配器模式允许类在没有不兼容抽象或实现的情况下协同工作。它被认为是最常见的模式之一,也是四人帮GoF)设计模式之一。

动机

适配器模式也被称为包装器。适配器封装了适配者(连接类)的行为,并允许通过一个已存在的接口来访问适配者,而不进行修改。通常,适配者使用不兼容的接口,适配器将这种行为合并并提供对所需功能的透明访问。

在 JDK 中找到它

java.base模块提供了适配器模式的多种实现。java.util包中的Collections实用类提供了一个list方法,该方法接受一个Enumeration接口,并将结果适配为ArrayList实例。

示例代码

适配器模式可以以多种方式实现。其中一种在以下车辆示例中考虑(示例 4.1):

public static void main(String[] args) {
    System.out.println("Adapter Pattern: engines");
    var electricEngine = new ElectricEngine();
    var enginePetrol = new PetrolEngine();
    var vehicleElectric = new Vehicle(electricEngine);
    var vehiclePetrol = new Vehicle(enginePetrol);
    vehicleElectric.drive();
    vehicleElectric.refuel();
    vehiclePetrol.drive();
    vehiclePetrol.refuel();
}

这里是输出:

Adapter Pattern: engines
...
Vehicle, stop
Vehicle needs recharge
ElectricEngine, check plug
ElectricEngine, recharging
...
Vehicle needs petrol
PetrolEngine, tank

示例 4.1 – 尽管每种类型的车辆都共享类似的逻辑,但加油方法的操作因引擎类型而异

这些引擎共享一些类似的功能和特性,但并非全部。它们彼此之间非常不同(图 4**.1):

图 4.1 – 强调引擎类型差异的 UML 类图

图 4.1 – 强调引擎类型差异的 UML 类图

在此示例中,Vehicle类及其实例扮演适配器的角色。在drive方法的情况下,两种马达的行为相似。tank方法的执行是一个不同的场景,因为车辆适配器需要知道确切的引擎类型才能正确执行refuel方法(示例 4.2):

class Vehicle {
    private final Engine engine;
  ...
    void refuel(){
        System.out.println("Vehicle, stop");
        switch (engine){
            case ElectricEngine de -> {
                System.out.println("Vehicle needs diesel");
                de.checkPlug();
                de.tank();
            }
            case PetrolEngine pe -> {
                System.out.println("Vehicle needs petrol");
                pe.tank();
            }
            default -> throw new IllegalStateException
               ("Vehicle has no engine");
        }
        engine.tank();
    }
}

示例 4.2 – Vehicle实例根据其类型使用引擎,该类型由模式匹配功能识别

新的语言特性,如switch语句增强,非常有用,因为不需要保留适配器的确切实例引用。sealed类通过保护其意图,例如通过避免不想要的扩展,来强制执行所需的目的并提高可维护性。

考虑的两种引擎类型仍然可以实施类似抽象,以保持引擎的概念(示例 4.3):

sealed interface Engine permits ElectricEngine,
    PetrolEngine  {
    void run();
    void tank();
}

示例 4.3 – Engine接口只允许某些类实现其方法

Vehicle适配器提供了处理不同引擎实现所需逻辑的必要逻辑。ElectricEngine实现提供了一个额外的checkPlug方法(示例 4.4):

final class ElectricEngine implements Engine{
    @Override
    public void run() {
        System.out.println("ElectricEngine, run");
    }
    @Override
    public void tank() {
        System.out.println("ElectricEngine, recharging");
    }
    public void checkPlug(){
        System.out.println("ElectricEngine, check plug");
    }
}

示例 4.4 – ElectricEngine实现了与通用Engine概念不共享的附加逻辑

结论

适配器结构设计模式在开发中有一个合理的位置,因为它代表了一种连接不同功能并通过类似接口控制它们的可维护方式。适配器被适当封装,甚至可以更加抽象。新的sealed类支持可维护性和清晰性的模式概念。使用适配器模式的后果可能是适配器需要承诺特定的适配器或接口。适配器可以作为子类扩展一些适配器功能。当需要实现额外的第三方库或 API 时,适配器模式值得考虑。它提供了一个透明且解耦的方式来与库交互,遵循 SOLID 原则。解决方案也可以轻松重构。

对适配器模式的这种分析展示了使用不兼容 API 的方法。接下来,让我们研究如何使用不同的可替换实现。

使用桥接模式解耦并独立开发对象

此模式的目的是将抽象与其实现分离,以便两者可以独立更改。桥接模式由 GoF 描述。

动机

桥接模式是优先考虑组合而非继承。实现细节从层次结构移动到另一个具有独立层次结构的对象。桥接模式使用封装和聚合,并且可能使用继承将责任分离到不同的类中。

在 JDK 中找到它

桥接模式的使用可以在java.util.logging包和Logger类的实现中找到。该类位于java.logging模块中。它实现了Filter接口。该接口用于在标准日志级别之外获得对记录内容的额外控制。

样本代码

让我们看看两种类型车辆的例子:一辆跑车和一辆皮卡。这些车辆在发动机类型上有所不同:汽油和柴油。目的是为了强制对VehicleEngine抽象源代码进行独立开发。示例案例创建了车辆并执行了drivestop方法(示例 4.5):

public static void main(String[] args) {
    System.out.println("Pattern Bridge, vehicle
        engines...");
    Vehicle sportVehicle = new SportVehicle(new
        PetrolEngine(), 911);
    Vehicle pickupVehicle = new PickupVehicle(new
        DieselEngine(), 300);
    sportVehicle.drive();
    sportVehicle.stop();
    pickupVehicle.drive();
    pickupVehicle.stop();
}

下面是输出:

Pattern Bridge, vehicle engines...
SportVehicle, starting engine
PetrolEngine, on
SportVehicle, engine started, hp:911
SportVehicle, stopping engine
PetrolEngine, self check
PetrolEngine, off
SportVehicle, engine stopped
PickupVehicle, starting engine
DieselEngine, on
PickupVehicle, engine started, hp:300
PickupVehicle, stopping engine
DieselEngine, off
PickupVehicle, engine stopped

示例 4.5 – 车辆使用不同的发动机;由于桥接模式的隔离,它们可以独立开发

每个车辆都扩展了运行发动机并封装基本功能的Vehicle抽象类。车辆抽象使用的Engine接口扮演了桥接的角色,如下面的图所示(图 4**.2):

图 4.2 – UML 类图显示了如何通过 Engine 接口桥接对特定实现的访问

发动机已经表现出不同的行为,由于桥接,它们可能会继续发展(示例 4.6):

class DieselEngine implements Engine{
        ...
    @Override
    public void turnOff() {...}
}
class PetrolEngine implements Engine{
        ...
    @Override
    public void turnOff() {
       selfCheck();
       ...
    }
    private void selfCheck(){ ...}
}

示例 4.6 – 发动机在实现上有所不同

车辆抽象没有任何发动机实现细节,这些细节可能在类层次结构中有所不同。车辆只需要依赖于提供的接口。

结论

当应用程序源代码需要减少对特定实现类的绑定时,桥接模式是一个很好的考虑方案。由于桥接模式,关于特定实现的决定可以推迟到运行时。桥接模式通过责任分离和封装来鼓励 SOLID 设计原则。实现可以自由地通过应用程序源代码进行测试和共享。需要记住的是,不要向桥接实现添加不希望的责任,并在出现这种情况时考虑设计模式的替代方法。

桥接模式可以为实现细节的更好组合打开大门,我们将在下一个模式中探讨。

使用组合模式以相同方式处理对象

组合模式是一个在以树结构排列对象的同时统一处理对象的显著解决方案,这简化了对实例的访问。对它的需求自然来自工业界,该模式很快就被 GoF 识别并描述。

动机

围绕底层业务逻辑对对象进行分组是一种强大的方法。组合设计模式概述了实现这种状态的方法。由于每个组员都受到统一对待,因此可以创建层次树结构和部分-整体层次结构。它有助于建立应用程序的逻辑关系和所需对象的组合。

在 JDK 中找到它

在 JDK 中,组合模式可以在 java.base 模块、java.util 包和 Properties 类中找到。Properties 类通过其 Hashtable 实现实现了 Map 接口,并且还包含一个 ConcurrentHashMap 实例来内部存储属性值。尽管由于 Hashtable 实现的原因,Properties 类的 put 操作保持同步,但 get 操作并不同步,因为将其读取到并发映射中很简单。

示例代码

要探索组合模式的力量,可以考虑实现 Vehicle 接口的 SportVehicle 类。众所周知,每辆车都是部件的集合,每个部件都是更小部件的分组(图 4**.3):

图 4.3 – UML 类图显示了 SportVehicle 是如何由 VehiclePart 类型组成的

图 4.3 – UML 类图显示了 SportVehicle 是如何由 VehiclePart 类型组成的

当车辆制造过程开始时,组合模式提供了一组完整的部件,这些部件最终都会出现在结果中(示例 4.7):

public static void main(String[] args) {
    System.out.println("Pattern Composite, vehicle
        parts...");
    var fastVehicle = new SportVehicle("sport");
    var engine = new VehiclePart("fast-engine");
    engine.addPart(new VehiclePart("cylinder-head"));
    var brakes = new VehiclePart("super-brakes");
    var transmission = new VehiclePart("automatic-
        transmission");
    fastVehicle.addPart(engine);
    fastVehicle.addPart(brakes);
    fastVehicle.addPart(transmission);
    fastVehicle.printParts();
}

这里是输出:

Pattern Composite, vehicle parts...
SportCar, type'sport', parts:'
[{type='fast-engine', parts=[{type='cylinder-head',
  parts=[]}]},
{type='super-brakes', parts=[]},
{type='automatic-transmission', parts=[]}]'

示例 4.7 – 检查 SportVehicle 实例的组合

结论

组合模式允许以细粒度详细表示类的组合。它通过创建部分-整体层次结构来考虑组合的较小部分。虽然这提供了优势,因为每个部分都受到统一对待,但它可能导致忽略部分之间的差异。另一方面,组合模式以透明的方式将所有涉及的部分组合在一起。

让我们现在看看如何在不改变 API 的情况下,通过附加额外的功能来扩展单个对象。

通过使用装饰器模式扩展对象功能

装饰器模式通过将对象放入装饰器中,提供了向对象添加新功能的能力,因此装饰后的实例提供了扩展功能。装饰器模式的实现相对简单且在 Python 和 Kotlin 等语言中具有动态性。另一方面,Java 通过可见性和新增强功能提供了源代码的更多稳定性和可维护性,这非常有价值。装饰器模式是由 GoF 确定并描述的。

动机

使用装饰器模式,你可以动态地为对象附加额外的职责。装饰器为扩展类的功能提供了一个灵活的替代子类的方法。装饰器可以静态或动态地添加,而不会改变对象当前的行为。

在 JDK 中查找

装饰器模式的使用可以在 Java 集合框架、java.base 模块和 java.util 包中找到。Collection 类包含了使用装饰器模式的不同方式。例如,unmodifiableCollection 方法将请求的集合包装成一个不可修改的集合,该集合由一个 UnmodifiableCollection 实例表示,该实例作为提供集合类型的装饰器,类似于以 unmodifiable... 开头的方法。另一个例子是 Collections 工具类中以 synchronized... 开头的方法。

样本代码

当你考虑之前的车辆示例时,装饰器模式可以被视为调校过的车辆。标准的 SportVehicle 类就是这样。它实现了 Vehicle 接口以执行标准功能。应用设计师后来决定改进当前状态,并创建了一个 TunedVehicleDecorator 类来包装标准车辆,而不必改变之前的函数(图 4**.4):

图 4.4 – UML 类图突出了 SportVehicle 和 TunedVehicleDecorator 类型之间的关系

图 4.4 – UML 类图突出了 SportVehicle 和 TunedVehicleDecorator 类型之间的关系

所考虑的车辆都公开了类似的 API 来执行它们的实现(示例 4.8):

public static void main(String[] args) {
    System.out.println("Pattern Decorator, vehicle 1");
    Vehicle standardVehicle = new StandardVehicle();
    Vehicle vehicleToBeTuned = new StandardVehicle();
    Vehicle tunedVehicle = new SportVehicle
        (vehicleToBeTuned, 200);
    System.out.println("Drive a standard vehicle");
    standardVehicle.move();
    System.out.println("Drive a tuned vehicle");
    tunedVehicle.move();
}

这里是输出:

Pattern Decorator, tuned vehicle
Drive a standard vehicle
Vehicle, move
Drive a tuned vehicle
SportVehicle, activate horse power:200
TunedVehicleDecorator, turbo on
Vehicle, move

示例 4.8 – 调校汽车抽象通过增加更多马力(200)扩展了 SportVehicle 类型的功能

装饰器模式可以通过多种方式引入。在所提供的示例中,TunedVehicleDecorator 是一个抽象类,它持有对车辆的引用。SportVehicle 实例扩展了新实现的功能(示例 4.9):

sealed abstract class TunedVehicleDecorator implements
    Vehicle permits SportVehicle {
    private final Vehicle vehicle;
    TunedVehicleDecorator(Vehicle vehicle) {
        this.vehicle = vehicle;
    }
    @Override
    public void move() {
        System.out.println("TunedVehicleDecorator,
           turbo on");
        vehicle.move();
    }
}
final class SportVehicle extends TunedVehicleDecorator {
    private final int horsePower;
    public SportVehicle(Vehicle vehicle, int horsePower) {
        super(vehicle);
        this.horsePower = horsePower;
    }
    @Override
    public void move() {
        System.out.println("SportVehicle, activate horse
            power:" + horsePower);
        super.move();
    }
}

示例 4.9 – 装饰器包装了 Vehicle 实例并扩展了其功能

结论

在应用开发过程中的许多情况下,类装饰可以非常有用。装饰器模式可以用来迁移应用逻辑,其中之前的功能应该保持隐藏或应避免不想要的子类化。示例展示了密封类如何有助于代码的可维护性和可理解性。装饰器不仅有助于添加新功能,还有助于移除过时的功能。装饰器模式是一种在不破坏当前接口的情况下修改对象的可透明方式。

有时使用装饰器模式与我们将要检查的另一个设计模式(外观模式)一起使用是有意义的。

使用外观模式简化通信

门面模式为一系列底层子系统提供了一个统一的接口。换句话说,门面定义了一个高级接口,便于使用。门面模式由 GoF 描述。

动机

随着子系统的演变,它们通常变得更加复杂。大多数模式在使用时会导致类变得更小,从而使子系统更易于重用和定制,但也使得所有客户端与之交互变得更加困难。门面模式提供了一个简单的默认视图,这对于大多数客户端来说已经足够好。只有需要更多定制的客户端才需要超越门面模式。

在 JDK 中找到它

Java 集合框架位于 java.base 模块中,java.util 已经被提及多次。它是 JDK 中广泛使用的一部分,尤其是在内部逻辑实现方面。例如 ListSetQueueMapEnumeration 等接口可以被视为特定实现的门面。让我们更详细地回顾一下 List 接口。它由常用的 ArrayListLinkedList 类以及其他类实现。实现的具体细节有所不同,其中一些在 第二章 《发现 Java 平台设计模式》中提到(表 2.32.42.5)。

示例代码

门面模式是软件工程中常用的一种设计模式,且易于展示。考虑一个司机获得驾驶执照的情况,该执照使他们有权驾驶汽油和柴油汽车,当然,也可以为他们加油。司机作为奖励获得这两种类型(示例 4.10):

public static void main(String[] args) {
    System.out.println("Pattern Facade, vehicle types");
    List<Vehicle> vehicles = Arrays.asList(new
        DieselVehicle(), new PetrolVehicle());
    for (var vehicle: vehicles){
        vehicle.start();
        vehicle.refuel();
    }
}

这是输出结果:

Pattern Facade, vehicle types
DieselVehicle, engine warm up
DieselVehicle, engine start
DieselVehicle, refuel diesel
PetrolVehicle, engine start
PetrolVehicle, refuel petrol

示例 4.10 – 门面模式促进标准化控制接口

整合车辆类型对代码结构有积极的影响,易于实现(图 4**.5):

图 4.5 – 门面模式在车辆实现中的 UML 类图

图 4.5 – 门面模式在车辆实现中的 UML 类图

结论

大量使用门面模式使其在任何应用开发阶段都成为考虑的好候选。它不仅促进了接口分离原则,还促进了整个 SOLID 概念。它有助于实现内部依赖关系,同时保持可定制性和可维护性。门面有助于引入松散耦合,并分离客户端,迫使移除不情愿的依赖。门面模式自然支持源代码的水平扩展。尽管门面模式提供了很多好处,但由未维护的源代码引起的误用可能会变成不受欢迎的状态。解决方案是重新评估当前实现,并根据 SOLID 原则进行改进。

接下来,我们将探讨如何根据规则从集合中选择正确的对象。

使用过滤模式通过条件选择所需对象

过滤模式——有时也称为标准模式——是一种设计模式,它允许客户端使用不同的标准或规则来过滤一组对象,并通过逻辑运算分别将它们链接起来。

动机

过滤模式有助于简化代码库,使其像使用子类型而不是参数化(泛型)的可扩展类结构的容器对象一样工作。它允许客户端轻松扩展并公开容器类似对象的过滤能力。不同的过滤条件可以动态添加或删除,而无需通知客户端。

在 JDK 中找到它

让我们将过滤器视为一个具有单个函数和逻辑布尔结果的接口。过滤模式的一个很好的例子是 Predicate 类,它在 java.base 模块和 java.util.function 包中找到。Predicate 表示一个布尔函数,并打算用于 Java Stream API(之前在 第二章发现 Java 平台设计模式)中,更具体地说是在 filter 方法中,该方法接受一个谓词并返回一个布尔值。

样本代码

使用过滤模式的一个很好的例子是一个需要选择车辆中所需传感器的应用程序。如今,每辆车都包含大量的传感器,因此客户端可能难以单独研究每一个(示例 4.11):

private static final List<Sensor> vehicleSensors = new
    ArrayList<>();
static {
    vehicleSensors.add(new Sensor("fuel", true));
    vehicleSensors.add(new Sensor("fuel", false));
    vehicleSensors.add(new Sensor("speed", false));
    vehicleSensors.add(new Sensor("speed", true));
}
public static void main(String[] args) {
    ...
    Rule analog = new RuleAnalog();
    Rule speedSensor = new RuleType("speed");
    ...
    var analogAndSpeedSensors = new RuleAnd(analog,
        speedSensor);
    var analogOrSpeedSensors = new RuleOr(analog,
        speedSensor);
    System.out.println("analogAndSpeedSensors=" +
        analogAndSpeedSensors.validateSensors
            (vehicleSensors));
    System.out.println("analogOrSpeedSensors=" +
          analogOrSpeedSensors.validateSensors
              (vehicleSensors));
}

这是输出:

Pattern Filter, vehicle sensors
AnalogSensors: [Sensor[type=fuel, analog=true],
    Sensor[type=speed, analog=true]]
SpeedSensors: [Sensor[type=speed, analog=false],
    Sensor[type=speed, analog=true]]
analogAndSpeedSensors=[Sensor[type=speed, analog=true]]
analogOrSpeedSensors=[Sensor[type=fuel, analog=true],
    Sensor[type=speed, analog=true], Sensor[type=speed,
        analog=false]]

示例 4.11 – 使用过滤模式简单且透明地将特定车辆传感器组链接起来

让我们画一个例子(图 4**.6):

图 4.6 – 容器用于选择合适的传感器实例的可能规则的 UML 类图

图 4.6 – 容器用于选择合适的传感器实例的可能规则的 UML 类图

Rule 接口满足功能接口的期望,因为它只包含一个方法,validateSensors。这也意味着编译器将 Rule 接口像其他注解功能接口一样处理和优化。每个规则都可以包含一个特定的实现(示例 4.12):

@FunctionalInterface
interface Rule {
    Collection<Sensor> validateSensors(Collection<Sensor>
        sensors);
}
class RuleAnalog implements Rule {
    @Override
    public Collection<Sensor> validateSensors
        (Collection<Sensor> sensors) {
        return sensors.stream()
                .filter(Sensor::analog)
                .collect(Collectors.toList());
    }
}
record RuleAnd(Rule rule, Rule additionalRule) implements
    Rule {
    @Override
    public Collection<Sensor> validateSensors
        (Collection<Sensor> sensors) {
        Collection<Sensor> initRule = rule.validateSensors
            (sensors);
        return additionalRule.validateSensors(initRule);
    }
}

示例 4.12 – 规则可以包含简单的逻辑,如 RuleAnalog,或与决策过程中运行的其他规则相关的复杂逻辑,如 RuleAnd

样本应用程序可以通过一个透明定义的接口轻松地扩展以包含任何额外的、更复杂的规则。

结论

在 Java 堆中连接不同的请求类型或数据库结果的地方可能需要过滤或更好地选择正确的实例。过滤模式已经展示了其灵活性,以及每个规则可以独立开发,即在不涉及他人的情况下优化,这使得它成为客户端需要与容器结构一起工作时的一个合适候选。

下一个模式代表了一种通过共享实例来减少内存占用的一种可能方式。

使用享元模式在应用程序中动态共享对象

享元模式通过尽可能多地与相似对象共享来最小化内存使用或计算成本。享元模式由 GoF 作者组描述。

动机

当一个新开发的应用程序使用许多客户端不需要的对象时,内存维护成本可能很高,这不仅因为实例数量庞大,还因为新对象的创建。在许多情况下,这些对象组可以成功地被相对较少的实例所替代。这些实例可以在所需的客户端之间透明地共享。这将减轻垃圾收集算法的压力。此外,当实例使用此类通信类型时,应用程序可以减少打开套接字的数量。

在 JDK 中找到它

享元模式在 JDK 中很容易找到。对许多人来说可能并不明显。例如,在原始包装类型的实现中,java.base模块和java.lang包使用这种模式来减少内存开销。当应用程序需要处理许多重复值时,模式特别有用。例如,IntegerByteCharacter类提供了一个valueOf方法,其实现使用内部缓存来存储重复元素。

示例代码

让我们考察一个例子,其中车库持续出租特定类型的车辆。车库中包含一些可以出租的车辆。每个车辆默认已经准备好了车辆文件。当需要另一辆车时,新的文件会按需放入系统中(示例 4.13):

public static void main(String[] args) {
    System.out.println("Pattern Flyweight, sharing
        templates");
    Vehicle car1 = VehicleGarage.borrow("sport");
    car1.move();
    Vehicle car2 = VehicleGarage.borrow("sport");
    System.out.println("Similar template:" +
        (car1.equals(car2)));
}

这里是输出结果:

Pattern Flyweight, sharing vehicles
VehicleGarage, borrowed type:sport
Vehicle, type:'sport-car', confirmed
VehicleGarage, borrowed type:sport
Similar template: true

示例 4.13 – 使用享元模式共享模板是透明的,并且不会污染内存

我们下一个示例(示例 4.14)的核心是实现VehicleGarage,它包含用于存储注册模板的缓存:

class VehicleGarage {
    private static final Map<String, Vehicle> vehicleByType
        = new HashMap<>();
    static {
        vehicleByType.put("common", new VehicleType
            ("common-car"));
        vehicleByType.put("sport", new VehicleType("sport-
            car"));
    }
    private VehicleGarage() {
    }
    static Vehicle borrow(String type){
        Vehicle v = vehicleByType.get(type);
        if(v == null){
            v =  new VehicleType(type);
            vehicleByType.put(type, v);
        }
        System.out.println("VehicleGarage, borrowed type:"
            + type);
        return v;
    }
}

示例 4.14 – VehicleGarage 实现允许你按需添加缺失的类型以控制模板大小

以下示例图显示客户端没有意识到VehicleType类,因为它不是必需的(图 4**.7):

图 4.7 – UML 类图显示了 VehicleGarage 需要哪些类

图 4.7 – UML 类图显示了 VehicleGarage 需要哪些类

结论

享元模式的大优点是能够管理大量对所需对象的请求。它按需实例化对象,并允许你控制现有实例。应用程序不需要依赖于对象的标识(hashCodeequals)。享元模式提供了一种透明的方式来访问对象及其实现强制执行 SOLID 设计概念和 DRY 方法。

下一个部分将描述如何以受控的方式合并传入的请求。

使用前端控制器模式处理请求

该模式的目标是为大多数客户端需求创建一个通用服务。该模式定义了一个程序,允许将认证、安全、自定义操作和日志记录等公共功能封装在单个位置。

动机

这种模式在 Web 应用程序中很常见。它实现了并定义了控制器使用的标准处理器。评估所有传入请求的有效性是处理器的责任,尽管处理器本身可能在运行时以多种形式存在。代码封装在一个地方,并由客户端引用。

在 JDK 中找到它

前端控制器模式的使用可以在jdk.httpserver模块、sun.net.httpserver包和HttpServer抽象类中找到。该类实现了接受HttpHandler接口的createContext抽象方法。处理器实例通过执行处理器方法参与 HTTP 请求处理。JDK 18 的发布附带了对底层HttpServer实现的SimpleFileServer包装器,也可以作为独立的命令jwebserverJEP-408: 简单 Web 服务器)。

示例代码

让我们创建一个简单的理论示例,不专注于解析网络请求(示例 4.15):

public static void main(String[] args) {
    System.out.println("Pattern FrontController, vehicle
        system");
    var vehicleController = new VehicleController();
    vehicleController.processRequest("engine");
    vehicleController.authorize();
    vehicleController.processRequest("engine");
    vehicleController.processRequest("brakes");
}

这里是输出:

Pattern FrontController, vehicle system
VehicleController, log:'engine'
VehicleController, is authorized
VehicleController, not authorized request:'engine'
VehicleController, authorization
VehicleController, log:'engine'
VehicleController, is authorized
EngineUnit, start
VehicleController, log:'brakes'
VehicleController, is authorized
BrakesUnit, activated

示例 4.15 – 车辆系统使用前端控制器模式处理传入的命令

想象一下,车辆中包含一个控制器,该控制器负责控制制动器和电机单元。所有传入的命令都在这个控制器中处理(图 4.8.8):

图 4.8 – 前端控制器模式强制控制器和调度器的松耦合

图 4.8 – 前端控制器模式强制控制器和调度器的松耦合

VehicleController对象需要一个特定处理器的实例。处理器由RequestDispatcher类的实例定义(示例 4.16):

record RequestDispatcher(BrakesUnit brakesUnit, EngineUnit
    engineUnit) {
    void dispatch(String command) {
        switch (command.toLowerCase()) {
            case "engine" -> engineUnit.start();
            case "brakes" -> brakesUnit.activate();
            default -> throw new IllegalArgumentException
                ("not implemented:" + command);
        }
    }
}
class VehicleController {
    private final RequestDispatcher dispatcher;
    ...
    void processRequest(String request) {
        logRequest(request);
        if (isAuthorized()) {
            dispatcher.dispatch(request);
        } else {
            System.out.printf("""
                VehicleController, not authorized request:
                    '%s'%n""", request);
        }
    }
}

示例 4.16 – 请求处理器表示RequestDispatcher实例需要注入到VehicleController

BrakesUnitEngineUnit类都与处理或控制逻辑分离,可以独立开发。

结论

前端控制器模式的主要用途在于 Web 框架中,除了封装请求处理请求和增加不同类型处理器的可移植性。这些工具只需要在运行时正确注册并运行。根据实现,该模式支持动态处理行为,而不需要在运行时替换类。前端控制器模式引入了一种集中机制来处理传入的信息。

软件设计有时需要为类组传播特定信息。为此,标记是一个值得考虑的好方法。让我们在下一节深入探讨。

使用标记模式识别实例

这种模式在运行时识别特定实例以进行特定处理时极为有用,例如在实例可用时触发所需操作。

动机

标记接口模式代表一个空接口。这种接口用于在运行时识别一组特殊的类。正因为如此,标记模式有时被称为标记,因为它的唯一目的是区分一种特殊的实例。因此,应用程序提供了在运行时对这些情况使用特殊处理的可能。逻辑可以分离并适当封装。由于注解代表一种特殊的接口形式,Java 以两种方式实现标记接口——一个类可以继承接口或被注解。

在 JDK 中查找

java.base模块中可以找到一个更清晰的 JDK 中使用标记接口的例子。java.io包定义了Serializable接口,而java.lang包提供了Cloneable接口。这两个接口都没有实现任何方法,它们都用于通知运行时进行特殊处理。Serializable接口在序列化和反序列化过程中非常重要(writeObjectreadObject方法),在遍历对象图时,每个嵌套字段都需要一个接口实现来获取实例的状态。以类似的方式,Cloneable接口通知 JVM 正在使用Object.clone()方法,并且它可以创建对象的字段到字段的复制。了解字段差异是很好的。原始类型提供值,但只有对象引用。这意味着对象需要一个Cloneable接口的实现来提供复制。

示例代码

让我们举一个简单的现实世界例子,其中一辆车包含多个传感器(图 4.9):

图 4.9 – 使用标记接口模式的认证传感器标签(CertifiedSensor 和 CertifiedAnnotation)的 UML 类图

图 4.9 – 使用标记接口模式的认证传感器标签(CertifiedSensor 和 CertifiedAnnotation)的 UML 类图

车辆控制器需要识别那些被认证用于提供特定信息的特殊传感器组(示例 4.17):

public static void main(String[] args) {
    System.out.println("Pattern Marker, sensor
        identification");
    var sensors = Arrays
            .asList(new BrakesSensor(), new EngineSensor()
                    , new ConsumptionSensor());
    sensors.forEach(sensor -> {
        if(sensor.getClass().isAnnotationPresent
            (CertifiedAnnotation.class)){
            System.out.println("Sensor with Marker
                annotation:" + sensor);
        } else {
            switch (sensor){
                case CertifiedSensor cs -> System.out.
                    println("Sensor with Marker interface:
                        " + cs);
                case Sensor s -> System.out.println
                    ("Sensor without identification:"+ s);
            }
        }
    });
}

这里是输出:

Pattern Marker, sensor identification
Sensor without identification:BrakesSensor[]
Sensor with Marker interface:chapter04.marker
  .EngineSensor@776ec8df
Sensor with Marker annotation:chapter04.marker
  .ConsumptionSensor@30dae81

示例 4.17 – 使用 switch 模式匹配结构的传感器识别的标记接口模式

这个例子介绍了两种类型的模式使用。它定义了CertifiedAnnotationCertifiedSensor接口。

为了在实现过程中对所有的传感器种类进行分组,使用了Sensor接口(示例 4.18):

@Retention(RetentionPolicy.RUNTIME)
@interface CertifiedAnnotation {}
public interface CertifiedSensor extends Sensor {}
public interface Sensor {
    void activate();
}

示例 4.18 – 标签接口 CertifiedAnnotation 和 CertifiedSensor 的实现,以及带有方法的传感器抽象

使用标签是微不足道的。一个类必须被注解或继承标记接口(示例 4.19):

@CertifiedAnnotation
class ConsumptionSensor implements Sensor {
    @Override
    public void activate() {...}
}
final class EngineSensor implements CertifiedSensor {
    @Override
    public void activate() {...}
}

示例 4.19 – 用于传感器识别的标记使用

结论

标记接口模式在运行时可以是一个强大的工具,但必须明智地使用,因为它可能有一些缺点。一是使用标记模式的目的可能会被遗忘,或者随着应用程序的发展可能会变得过时。二是特殊处理逻辑的实现。分发此类逻辑可能会对应用程序行为产生负面影响。另一方面,标记接口可以简化应用程序逻辑,在许多情况下,接口比注解更可追踪,因此更受欢迎。

让我们在下一个模式中介绍车辆单元的模块化。

使用模块模式探索模块的概念

此模式实现了模块化编程定义的软件模块概念。当编程语言没有直接支持此类概念或应用程序需要它时,会使用此模式。

动机

这种模式可以根据应用需求以多种方式实现。模块模式将应用程序功能组合精确地封装或集中到模块中。Java 平台已经通过 Jigsaw 项目实现了模块概念的基本支持,自 JDK 9 发布以来即可使用,但也可以尝试以类似的方式程序性地创建它,尽管不是完全独立,因为源代码可以影响其模块化方法。

在 JDK 中查找

在 JDK 中可以找到的模块模式的最佳示例是 Java 平台模块。这个概念在第二章中进行了详细讨论,即《发现 Java 平台设计模式》,在掌握 Java 模块系统部分。

样本代码

让我们想象一个需要具有独立刹车和发动机系统的车辆。这在很大程度上符合现实世界的情况。每个模块将独立运行,并且在运行时只有一个提供者。在车辆可以使用之前,两个模块都需要被激活(示例 4.20):

class ModuleMain {
    ...
    private static void initModules() {
        brakesModule = BrakesModule.getInstance();
        engineModule = EngineModule.getInstance();
        engineModule.init();
    }
    ...
    public static void main(String[] args) {
        initModules();
        printStatus();
    }
}

这是输出:

BrakesModule, unit:BrakesModule@5ca881b5
EngineModule, unit:EngineModule@4517d9a3
EngineModule, init
BrakesModule, ready:false
EngineModule, ready:true

示例 4.20 – 客户端函数 initModules 在封装中正确激活模块

以下图表强调了模块的分离,尽管程序性方法允许共享或实现共享抽象(图 4.10):

图 4.10 – UML 类图,展示了通过提供的 VehicleModule 接口实现的模式实现

图 4.10 – UML 类图,展示了通过提供的 VehicleModule 接口实现的模式实现

每个模块都表示为一个单例实例,以确保只有一个实例提供对模块功能的透明网关:

class EngineModule implements VehicleModule {
    private static volatile EngineModule INSTANCE;
    static EngineModule getInstance() {
       ...
        return INSTANCE;
    }
    private boolean ready;
     ...
    @Override
    public void init() {...}
    @Override
    public void status() {...}
}

示例 4.21 – EngineModule 和 BrakesModule 的示例实现以单例形式表示,具有相似的结构

结论

模块模式以非常透明的方式为源代码引入结构。每个模块可以独立开发,不受影响。由于程序性解决方案可能无法完全强制源代码隔离,因此有必要明智地扩展模块。另一个缺点可能是模块初始化,因为单例可能不是一个可接受解决方案。另一方面,模块模式提供了一种以所有 SOLID 概念为前提开发源代码的工作流程。

关于使用代理而不是模块和实现,让我们在下一节深入探讨。

使用代理模式为对象提供一个占位符

代理模式被认为是一个占位符,用于管理对另一个对象的访问以控制它。该模式也被称为代理。代理模式由 GoF 描述。

动机

在其最一般的形式中,代理是一个充当客户端接口的类。代理被认为是一个包装器或代理对象,由客户端使用。客户端通过相同的接口访问实际对象,而实际实现则在客户端背后保持隐藏。由于代理模式,客户端和实现之间的通信保持透明。

通过使用代理,客户端可以访问实际对象,或者它可以提供额外的逻辑。

在 JDK 中查找

代理设计模式在 JDK 中也有其位置。最著名的是公共 Proxy 类,您可以在 java.base 模块的 java.reflect 包中找到它。Proxy 类提供了几个静态方法用于创建用于方法调用的对象。

示例代码

给定的示例可以被视为车辆的遥控器。一个控制器,由代理设计模式表示,提供了与真实车辆完全相同的功能,同时也管理着真实车辆实例之间的连接(示例 4.22):

public static void main(String[] args) {
    System.out.println("Pattern Proxy, remote vehicle
        controller");
    Vehicle vehicle = new VehicleProxy();
    vehicle.move();
    vehicle.move();
}

这是输出:

Pattern Proxy, remote vehicle controller
VehicleProxy, real vehicle connected
VehicleReal, move
VehicleReal, move

示例 4.22 – VehicleProxy 实例像真实车辆一样工作

实际车辆实现由 Vehicle 接口提供的通用抽象定义(图 4**.11):

图 4.11 – 车辆代理的示例可以用 UML 类图表示

图 4.11 – 车辆代理的示例可以用 UML 类图表示

这允许无缝地扩展受控车辆类型,如图 4.23 中代理模式实现所示:

class VehicleProxy implements Vehicle{
    private Vehicle vehicleReal;
    @Override
    public void move() {
        if(vehicleReal == null){
            System.out.println("VehicleProxy, real vehicle
                connected");
            vehicleReal = new VehicleReal();
        }
        vehicleReal.move();
    }
}

示例 4.23 – VehicleProxy 类包含对实际 Vehicle 实例的引用

结论

代理设计模式为源代码带来了许多优势,例如,实现可以在运行时替换。除了用于完全控制对实际实例的访问外,它还可以用于延迟初始化,就像我们在示例 4.23中看到的那样。代理在驱动实现或网络连接中有其合法的位置,因为它自然地强制执行不仅记录的可能性,而且通过接口和其他 SOLID 原则的隔离来实现代码分离。当应用程序需要 I/O 操作时,考虑这一点是有用的。

Java 作为一种语言不支持多重继承,但仍然可以实现。让我们在下一节中看看如何实现。

使用双重模式在 Java 中探索多重继承

该模式允许您组合那些倾向于一起使用的对象的功能,这是没有多重继承支持的语言中常用的范式。

动机

双重模式在 Java 中提供了实现多重继承的可能性。多重继承不是一个受支持的概念,因为它可能导致编译器不一致,即所谓的菱形问题。菱形问题定义了一个通过类抽象的状态,编译器可能会出现不一致。这种状态是由于多个抽象类信息不足造成的。编译器没有足够的信息来确定应该执行哪些方法。

示例代码

该模式不受平台支持,并且很少在开发中需要。因此,根据描述,这种模式很可能不存在于已发布的 JDK 中。然而,让我们通过一个可能的例子来更好地理解这个模式。想象一下车辆初始化序列。在初始化过程中,发动机和制动单元需要一起初始化。换句话说,当发动机被初始化时,制动也必须被初始化,反之亦然(示例 4.24):

public static void main(String[] args) {
        System.out.println("Pattern Twin, vehicle
            initiation sequence");
        var vehicleBrakes1  = new VehicleBrakes();
        var vehicleEngine1 = new VehicleEngine();
        vehicleBrakes1.setEngine(vehicleEngine1);
        vehicleEngine1.setBrakes(vehicleBrakes1);
        vehicleEngine1.init();
    }

这里是输出:

Pattern Twin, vehicle initiation sequence
AbstractVehiclePart, constructor
AbstractVehiclePart, constructor
VehicleBrakes, initiated
VehicleEngine, initiated

示例 4.24 – 双重模式保证了两个单元总是被初始化

以下图表显示了单元之间的紧密耦合:

图 4.12 – 考虑的两个单元,VehicleEngine 和 VehicleBrakes,非常紧密地耦合

图 4.12 – 考虑的两个单元,VehicleEngine 和 VehicleBrakes,非常紧密地耦合

这种耦合也转化为一个对未来的开发非常脆弱的代码库(示例 4.25):

public class VehicleBrakes extends AbstractVehiclePart {
    private VehicleEngine twin;
    VehicleBrakes() {
    }
    void setEngine(VehicleEngine engine) {
        this.twin = engine;
    }
    @Override
    void init() {
        if (twin.isReady()) {
            setReady();
        } else {
            setReady();
            twin.init();
        }
        System.out.println("VehicleBrakes, initiated");
    }
}

示例 4.25 – VehicleBrakes 类实现与其孪生对象 VehicleEngine 之间的紧密耦合

结论

双重模式可以用来在 Java 中实现多重继承。它必须被明智地使用,因为一个逻辑上未写出的要求是保证考虑中的对象完全分离。换句话说,双重设计模式允许孪生对象作为一个具有扩展功能和特性的单一实例来运行。

摘要

对结构模式的知识以及新增的 Java 语法增强不仅提高了可维护性,还强制执行了所有之前学习的面向对象概念,并提高了对代码行为潜在偏差(如异常、意外崩溃或逻辑问题)的响应能力。

通过本章中的示例,我们建立了坚实的基础,学习了如何使用适配器模式在互不兼容的对象之间建立协作,以及如何使用桥接模式透明地分离一个对象的实现与其抽象。组合模式提供了一种将对象组织并包装成围绕底层业务逻辑的树结构的方法。我们研究了通过使用装饰器模式扩展对象功能的可能性。门面模式提供了一种简化对象间通信的方法,随后是过滤器模式,它允许我们仅选择我们想要的实例。我们学习了如何使用享元设计模式来复用已创建的运行时实例,以及通过前端控制器模式处理传入信息的方法,这样客户端只能对有效的请求做出响应。我们发现如何使用标记模式以独特的方式让客户端处理一组特定的对象。我们探讨了通过实现模块模式模块化代码库的可能性。我们看到了如何使用代理模式让客户端在不了解其实施细节的情况下间接控制对象,以及如何使用双生模式在 Java 中实现多重继承,尽管该语言不支持它。

通过对创建型和结构设计模式的知识积累,底层源代码结构得到了良好的组织,并便于持续的应用开发。下一章将探讨行为设计模式,这些模式有助于组织目标实例之间的通信和责任。

问题

  1. 结构设计模式解决了哪些挑战?

  2. 四人帮描述了哪些结构设计模式?

  3. 哪种设计模式适用于创建相关对象的树结构?

  4. 哪种结构设计模式可以在运行时识别一个对象?

  5. 哪种设计模式可以用于间接访问对象,同时具有与对象本身相同的功能?

  6. 哪种设计模式促进了逻辑与其抽象的分离?

进一步阅读

  • Erich Gamma、Richard Helm、Ralph Johnson 和 John Vlissides 所著的 《设计模式:可复用面向对象软件元素》,Addison-Wesley,1995 年

  • Robert C. Martin 所著的 《设计原则与设计模式》,Object Mentor,2000 年

  • JSR-376: Java 平台模块 系统, openjdk.java.net/projects/jigsaw/spec/

  • JSR-408: 简单 Web 服务器, openjdk.org/jeps/408

  • 《代码整洁之道》 by Robert C. Martin, Pearson Education, Inc, 2009

  • 《有效 Java – 第三版》 by Joshua Bloch, Addison-Wesley, 2018

  • 《双胞胎 – 多重继承建模设计模式》 by Hanspeter Mössenböck, 林茨大学,系统软件研究所,1999, ssw.jku.at/Research/Papers/Moe99/Paper.pdf

第五章:行为设计模式

代码可维护性在跨行业的应用中起着关键作用,但仅仅停留在那里并不再深入是不公平的。这意味着跳过运行时代码的行为,这会影响物理和虚拟内存的使用。使用行为模式的主要动机是对象之间的透明通信,换句话说,是提高内存分配的效率。利用行为模式提高了通信的灵活性,并有助于通过单个对象或多个对象之间交换信息来完成一项任务。结构设计模式有时似乎接近行为模式,但正如我们将看到的那样,每个案例的目的略有不同。让我们深入了解以下内容:

  • 使用缓存模式限制昂贵的初始化

  • 使用责任链模式处理事件

  • 使用命令模式将信息转化为行动

  • 使用解释器模式为上下文赋予意义

  • 使用迭代器模式检查所有元素

  • 利用中介者模式进行信息交换

  • 使用备忘录模式恢复所需状态

  • 使用空对象模式避免空指针异常状态

  • 使用观察者模式让所有相关方保持知情

  • 使用管道模式处理实例阶段

  • 使用状态模式改变对象行为

  • 使用策略模式改变对象行为

  • 使用模板模式标准化流程

  • 使用访问者模式根据对象类型执行代码

到本章结束时,你将有一个很好的基础来理解程序行为的重要性,这不仅涉及资源利用,而且从 SOLID 设计原则的角度来看。

技术要求

你可以在 GitHub 上找到本章的代码文件,地址为github.com/PacktPublishing/Practical-Design-Patterns-for-Java-Developers/tree/main/Chapter05

使用缓存模式限制昂贵的初始化

缓存模式在传统的四人帮GoF)列表中找不到。然而,由于行业需求和资源使用,它已被确定为常用的方法,并获得了重要性。

动机

缓存模式支持元素重用。它不会根据需求创建新元素,而是重用存储在缓存中的已创建元素。它将频繁需要的数据存储在快速访问存储中,以提高性能。从缓存中读取数据比实例化新实体要快,因为获取所需元素的操作复杂度较低。

在 JDK 中找到它

java.base 模块及其 java.lang 包为原始类型提供包装类。对于 double、float、integer、byte 或 character 类型,valueOf 方法使用缓存机制来减少频繁请求的值所占用的内存空间并提高性能。

示例代码

让我们通过创建一个 Vehicle 类来想象以下缓存示例。它包含对其内部系统的引用(图 5**.1):

图 5.1 – 显示考虑的 VehicleSystem 类型的 UML 类图,用于 Vehicle 类

图 5.1 – 显示考虑的 VehicleSystem 类型的 UML 类图,用于 Vehicle 类

这意味着车辆被精确定义且不会改变。当客户端请求特定的系统时,它总是启动与对象相对应的那个。此模式还强制控制存储过程(示例 5.1):

public static void main(String[] args) {
    System.out.println("Caching Pattern, initiated vehicle
        system");
    var vehicle = new Vehicle();
    vehicle.init();
    var suspension = new SuspensionSystem("suspension");
    vehicle.addSystem(suspension);
    System.out.printf("Systems types:'%s%n",
        vehicle.systemTypes());
    var suspensionCache =
        vehicle.getSystemByType("suspension");
    System.out.printf("Is suspension equal? '%s:%s'%n",
        suspension.equals(suspensionCache),
            suspensionCache);
    vehicle.addSystem(new EngineSystem("engine2"));
}

这里是输出:

Caching Pattern, initiated vehicle system
Vehicle, init cache:'break':'BreakSystem@adb0cf77',
  'engine':'EngineSystem@a0675694'
Systems types:''break':'BreakSystem@adb0cf77','engine'
  :'EngineSystem@a0675694','suspension':'Suspension
    System@369ef459'
Is suspension equal? 'true:SuspensionSystem@369ef459'
SystemCache, not stored:EngineSystem@6c828066

示例 5.1 – 缓存模式提供保证,始终获取所需元素并控制存储

换句话说,没有必要创建另一个实例,如 EngineSystem,来访问其功能。这些对象访问或程序行为很容易导致不希望出现的情况。

车辆的 SystemCache 只考虑特定类型的实例,并且也受大小的限制(示例 5.2):

class SystemCache {
    private final VehicleSystem[] systems;
    private int end;
...
    boolean addSystem(VehicleSystem system) {
        var availableSystem = getSystem(system.type());
        if (availableSystem == null && end <
            systems.length) {
            systems[end++] = system;
            return true;
        }
        return false;
    }
   VehicleSystem getSystem(String type) {…}
    ...
}

示例 5.2 – SystemCache 实例提供确保程序稳定性的功能,并可能提供额外的保证

结论

例子(来自 图 5**.1)显示实现缓存很简单。当客户端需要重复访问同一组元素时,这可能是一个好主意。这可能会对性能产生积极影响。

这些元素中的一些可能负责程序的运行时行为。让我们在下一节中深入了解这一点。

使用责任链模式处理事件

责任链模式有助于避免将处理逻辑绑定到触发事件的发送者。这种模式是由 GoF 的书中确定的。

动机

程序接收一个初始触发事件。每个链式处理程序决定是否处理请求或将其传递给下一个处理程序而不做出响应。一个模式可以由一系列处理对象处理的命令对象组成。一些处理程序可以作为调度器,能够将命令发送到不同的方向以形成一个责任树。

责任链模式允许你构建一个实现链,在该链中,在调用链中的下一个处理程序之前或之后执行某个操作。

在 JDK 中查找

java.logging 模块包括 java.util.logging 包,其中包含一个 Logger 类,用于记录应用程序组件消息。记录器可以串联,并且记录的消息只由所需的 Logger 实例处理。

JDK 中提供的另一个示例是DirectoryStream类,它包含在java.base模块中,位于java.nio包中。这个类负责遍历整个目录,并包含一个嵌套的过滤器接口。该接口提供了一个accept方法。链式过滤器的实际表示取决于目录是否需要处理或排除。

示例代码

让我们考察一个示例,说明如何使用职责链设计模式来响应来自驱动系统的触发事件(示例 5.3):

   System.out.println("Pattern Chain of Responsibility, vehicle 
      system initialisation");
    var engineSystem = new EngineSystem();
    var driverSystem = new DriverSystem();
    var transmissionSystem = new TransmissionSystem();
    driverSystem.setNext(transmissionSystem);
    transmissionSystem.setNext(engineSystem);
    driverSystem.powerOn();
}

这里是输出:

Pattern Chain of Responsibility, vehicle system initialisation
DriverSystem: activated
TransmissionSystem: activated
EngineSystem, activated

示例 5.3 – DriverSystem 实例启动通过链式实例传播的 powerOn 事件

创建的系统链的行为是透明的,并且每个系统都适当地封装了逻辑。提供的通用抽象VehicleSystem实例定义了功能,每个元素必须完成的功能,以及后续元素应该如何链式连接(示例 5.4):

sealed abstract class VehicleSystem permits DriverSystem,
    EngineSystem, TransmissionSystem {
    ...
    protected VehicleSystem nextSystem;
    protected boolean active;
       ...
    void setNext(VehicleSystem system){
        this.nextSystem = system;
    }
    void powerOn(){
        if(!this.active){
            activate();
        }
        if(nextSystem != null){
            nextSystem.powerOn();
        }
    }
}

示例 5.4 – 封闭类使用提供了额外的稳定性和控制

客户端接收一个框架,说明在构建链时可以考虑哪些类(图 5**.2):

图 5.2 – UML 类图显示参与 powerOn 事件的元素

图 5.2 – UML 类图显示参与 powerOn 事件的元素

结论

职责链模式表明,影响程序运行行为的传入事件可以导致创建多个对象。操作符被封装,并且根据 SOLID 原则,逻辑被适当隔离。使用此模式,客户端有机会动态决定哪些处理器应参与事件处理。因此,它是安全框架或类似应用的热门候选。

连接的处理器可以在运行时向客户端发出多个命令。让我们更详细地探讨命令响应。

使用命令模式将信息转化为行动

命令模式有时也被称为动作。命令模式封装了触发的事件作为一个对象,允许客户端进行操作。这种模式在 GoF 的书中被早期识别和描述。

动机

命令模式指定命令接口的哪些实例在接收客户端上执行哪些动作。命令对象可以被参数化以更详细地定义一个动作。命令可以包括一个回调函数来通知其他人事件的发生。有时,命令可以被视为面向对象的回调函数的替代品。新创建的命令对象可以根据启动它的事件具有不同的动态。客户端可以根据已安排的场景对其进行反应。

在 JDK 中查找

JDK 中提供了很好的示例,来自 java.base 模块中的 CallableRunnable 接口以及 java.util.concurrent 包。每个接口的实现都是基于已知场景进行执行的。

命令模式的其它用途可以在 java.desktop 模块中的 javax.swing 包以及实现 Action 接口的一个类中找到。

示例代码

以下示例展示了如何使用定义良好的命令来控制 Driver 对象(示例 5.5):

public static void main(String[] args) {
    System.out.println("Pattern Command, turn on/off
        vehicle");
    var vehicle = new Vehicle("sport-car");
    var driver = new Driver();
    driver.addCommand(new StartCommand(vehicle));
    driver.addCommand(new StopCommand(vehicle));
    driver.addCommand(new StartCommand(vehicle));
    driver.executeCommands("start_stop");
}

这里是输出:

Pattern Command, turn on/off vehicle
START:Vehicle{type='sport-car', running=true}
STOP:Vehicle{type='sport-car', running=false}
START:Vehicle{type='sport-car', running=true}

示例 5.5 – 触发的 start_stop 事件被 Driver 实例转换成动作

命令被适当封装,可能包含与不同客户端交互的额外逻辑,或者可能决定执行步骤(示例 5.6):

sealed interface VehicleCommand permits StartCommand,
    StopCommand {
    void process(String command);
}
record StartCommand(Vehicle vehicle) implements
    VehicleCommand {
    @Override
    public void process(String command) {
        if(command.contains("start")){ ... }
}

示例 5.6 – 为了保留预期的命令设计,可以采用密封类的概念

随着驱动器功能的演变,命令可以透明地扩展(图 5**.3):

图 5.3 – 展示哪些命令可以被视为驱动器的 UML 类图

图 5.3 – 展示哪些命令可以被视为驱动器的 UML 类图

结论

一个简单的示例(图 5**.3)展示了命令模式的价值。命令对象与逻辑分离,可能包含额外的有价值信息。命令有其自己的生命周期,并使得实现可以触发另一个事件的回调函数变得容易。

以文本形式表示这些命令可能很棘手。以下部分展示了客户端如何理解它们。

使用解释器模式为上下文赋予意义

解释器模式将字符序列解释为期望的动作。由于其用于 SQL 语句翻译而被早期识别,并在 GoF 的书中进行了更详细的描述。

动机

解释器模式定义了两种类型的对象,它们指向特定的字符序列。它们是终端和非终端动作或操作,可以在考虑的字符序列上执行。这些操作代表了使用的计算机语言,并具有自己的语义。给定句子的句法树——字符序列——是复合模式的实例,用于评估和解释客户端程序的意义。

在 JDK 中查找

java.base 模块包含 java.util.regex 包和 Pattern 类。这个类代表了正则表达式的编译。特定的语义应用于字符序列以验证所需的匹配。

另一个例子来自类似的模块和 java.text 包。抽象的 Format 类实现用于表示与位置敏感的信息,如日期、数字格式等。

示例代码

让我们创建一个简单的字符串数学公式。该公式包含来自不同传感器的值及其对结果的影响。结果表示公式的最终值(示例 5.7):

public static void main(String[] args) {
    System.out.println("Pattern Interpreter, sensors
        value");
    var stack = new Stack<Expression>();
    var formula = "1 - 3 + 100 + 1";
    var parsedFormula = formula.split(" ");
    var index = 0;
    while (index < parsedFormula.length ){
        var text = parsedFormula[index++];
        if(isOperator(text)){
            var leftExp = stack.pop();
            var rightText = parsedFormula[index++];
            var rightEpx = new IntegerExpression
                (rightText);
            var operatorExp = getEvaluationExpression(text,
                left, right);
            stack.push(operatorExp);
        } else {
            var exp = new IntegerExpression(text);
            stack.push(exp);
        }
    }
    System.out.println("Formula result: " +
        stack.pop().interpret());
}

这里是输出:

Pattern Interpreter, math formula evaluation
Formula result: 99

示例 5.7 – 解析器将数学字符串公式转换为适当的表达式类型

基本元素是接口及其 interpret 方法。1 - 3 + 100 + 1 公式按顺序评估,最后一个元素包含结果。每个表达式都被封装,解释器可以方便地扩展(图 5**.4):

图 5.4 – 显示哪些参与者需要用于评估的 UML 类图

图 5.4 – UML 类图显示哪些参与者需要用于评估

结论

解释器模式是一种强大的设计模式,其中客户端需要处理具有已知语义的命令的文本表示。它允许您创建一个由类层次结构表示的语法,该语法可以轻松扩展,甚至可以在运行时动态修改。

下一节将向我们展示如何遍历命令集合。让我们深入探讨。

使用迭代器模式检查所有元素

这种迭代器模式可以接近于指向所需位置的游标抽象。由于数组构造是一个常用的结构类型,这种模式很快就被识别出来。它被认为是 GoF 书籍中包含的核心模式之一。

动机

迭代器模式定义了一种透明的方式来遍历对象集合,而无需暴露或了解任何对象的内部细节。为了在元素之间移动,迭代器模式使用迭代函数。

在 JDK 中查找

java.base 模块包含多个迭代器模式的实现。第一个实现可以被认为是位于 java.util 包中的集合框架提供的实现。Iterator 接口的实现遍历集合,而不了解元素的成员资格。

另一个可以考虑的例子是 BaseStream 接口及其 iterator 方法提供的迭代器。此类来自类似的模块和 java.util.stream 包,即流 API。它表示终端操作。

样本代码

每个车辆都有几个共同的部分。以下示例展示了使用迭代器模式遍历它们的用法(示例 5.8):

public static void main(String[] args) {
    System.out.println("Iterator Pattern, vehicle parts");
    var standardVehicle = new StandardVehicle();
    for(PartsIterator part = standardVehicle.getParts();
        part.hasNext();){
        var vehiclePart = part.next();
        System.out.println("VehiclePart name:" +
            vehiclePart.name());
    }
}

这里是输出:

Iterator Pattern, vehicle parts
VehiclePart name:engine
VehiclePart name:breaks
VehiclePart name:navigation

示例 5.8 – 为了保留预期的命令设计,可以采用密封类的概念

该车辆可以提供一个通用的抽象车辆,用于处理迭代器(示例 5.9):

interface PartsIterator {
    boolean hasNext();
    VehiclePart next();
}

示例 5.9 – 一个程序可以实现具有不同动态的不同迭代器

客户端可以单独遍历所有元素。这种迭代器的实现可以被视为具体实现的一个嵌套类(示例 5.10):

sealed interface Vehicle permits StandardVehicle {
    PartsIterator getParts();
}
final class StandardVehicle implements Vehicle {
    private final String[] vehiclePartsNames = {"engine",
        "breaks", "navigation"};
    private class VehiclePartsIterator implements
        PartsIterator {
        ...
    }
    @Override
    public PartsIterator getParts() {
        return new VehiclePartsIterator();
    }
}

示例 5.10 – 一个程序可以实现具有不同动态的不同迭代器

样本程序的行为对客户端是透明的,它提供了一个框架来扩展车辆的预期实现,以及一种导航它们的方式(图 5**.5):

图 5.5 – 代表为 StandardVehicle 设计的部件集合的 UML 类图

图 5.5 – 代表为 StandardVehicle 设计的部件集合的 UML 类图

结论

迭代器模式的优点是它可以非常通用地实现 – 无需了解正在考虑的元素类型。迭代器遍历它们而不在运行时触及它们的内部表示。结合另一个模式,它可以在运行时更改策略或仅考虑特定的对象类型。

下一个部分将探讨特定对象类型之间的运行时通信 – 让我们开始吧。

利用中介者模式进行信息交换

在不同类型的应用程序中,一个常见的场景是管理需要交换信息以维持流程的客户端之间的通信。这个模式被早期识别,并且是 GoF 书籍的核心模式之一。

动机

中介者模式代表一个对象,一个中间人,它定义了对象组内对象之间相互作用的规则。中介为客户端通信建立了一个自由连接。客户端可以通过中介明确地相互引用。这样,通信可以被调节。

在 JDK 中查找

虽然一开始可能不明显,但中介者模式可以在 java.base 模块和 java.util.concurrent 包中轻松找到。ExecutorService 类定义了一个 submit 方法。其父类 Executor 公开了 execute 方法。这些方法可以用来传递 CallableRunnable 接口的实现,这些实现之前被称为命令模式的实现。

示例代码

与其他示例相比,以下示例相当简单,但它展示了处理器如何维护车辆传感器通信(示例 5.11):

record Sensor(String name) {
    void emitMessage(String message) {
        VehicleProcessor.acceptMessage(name, message);
    }
}
public static void main(String[] args) {
    System.out.println("Mediator Pattern, vehicle parts");
    var engineSensor = new Sensor("engine");
    var breakSensor = new Sensor("break");
    engineSensor.emitMessage("turn on");
    breakSensor.emitMessage("init");
}

这里是输出:

Mediator Pattern, vehicle parts
Sensor:'engine', delivered message:'turn on'
Sensor:'break', delivered message:'init'

示例 5.11 – 通信由一个 VehicleProcessor 实例处理

示例的核心元素是 VehicleProcessor 实例,它获取所有发出的消息并能对它们做出反应(图 5**.6):

图 5.6 – 强调通过处理器进行的通信的 UML 类图

图 5.6 – 强调通过处理器进行的通信的 UML 类图

结论

中介者模式引入了隔离不同对象之间复杂通信的能力。参与的对象数量可能在运行时变化。该模式提供了一种封装和解耦的方式,允许所有客户端相互通信。

通信可以导致各种状态。在下一节中,我们将探讨如何记住和恢复它们。

使用备忘录模式恢复所需状态

有时,考虑保留关于对象状态的最小信息,以便继续或恢复它,可能是有用的。备忘录模式提供了这种功能,并在 GoF 的书中进行了描述。

动机

在不破坏封装的情况下,需要捕获和外部化对象的内部状态,即备忘录,以便对象可以稍后恢复到该状态。备忘录模式提供了一个客户端函数,可以在需要时恢复对象的所需状态,即备忘录。

在 JDK 中查找

java.base 模块及其 java.util 包中包含的 Date 类是对备忘录模式的一个很好的实现。类的实例代表时间线上的一个特定点,可以通过引用日历或时区将日期恢复到该时间线。

示例代码

让我们看看车辆空调的例子。控制器为我们提供了几个设置驾驶舱温度的选项,这也意味着驾驶员可以恢复已选状态(示例 5.12):

 public static void main(String[] args) {
    System.out.println("Memento Pattern, air-condition
        system");
    var originator = new AirConditionSystemOriginator();
    var careTaker = new AirConditionSystemCareTaker();
    originator.setState("low");
    var stateLow = originator.saveState(careTaker);
    originator.setState("medium");
    var stateMedium = originator.saveState(careTaker);
    originator.setState("high");
    var stateHigh = originator.saveState(careTaker);
    System.out.printf("""
            Current Air-Condition System state:'%s'%n""",
                originator.getState());
    originator.restoreState(careTaker.getMemento(stateLow));
    System.out.printf("""
            Restored position:'%d', Air-Condition System
                state:'%s'%n""", stateLow,
                    originator.getState());
}

这是输出:

Memento Pattern, air-condition system
Current Air-Condition System state:'high'
Restored position:'0', Air-Condition System state:'low'

示例 5.12 – 每个状态都被记住,驾驶员可以按需恢复

扮演备忘录提供者角色的 AirConditionSystemCareTaker 实例包含指向已使用状态的链接(示例 5.13):

final class AirConditionSystemCareTaker {
    private final List<SystemMemento> memory = new
        ArrayList<>();
     ...
    int add(SystemMemento m) {... }
    SystemMemento getMemento(int i) {... }
}

示例 5.13 – 每个状态都通过一个标识符记住,以便恢复

AirConditionSystemOriginator 实例考虑创建备忘录状态,并从备忘录对象中恢复先前的状态。客户端需要记住提供的状态标识符,以便向保管人请求备忘录状态(示例 5.14):

final class AirConditionSystemOriginator {
    private String state;
    ...
    int saveState(AirConditionSystemCareTaker careTaker){
        return careTaker.add(new SystemMemento(state));
    }
    void restoreState(SystemMemento m){
        state = m.state();
    }
}

示例 5.14 – 发起者持有可变状态并更新保管人

程序允许客户端仅对几个考虑的状态进行操作,而不创建任何其他实例(图 5**.7):

图 5.7 – 显示只涉及少数类的 UML 类图

图 5.7 – 显示只涉及少数类的 UML 类图

结论

当程序需要执行任何撤销操作或回滚时间线时,备忘录模式非常有用。它提供了透明的实现和逻辑分离,以强制代码库的可持续性。

让我们看看程序按预期运行,并在下一节中对其进行检查。

使用空对象模式避免空指针异常状态

空对象模式提供了一种优雅地处理未识别对象的方法,而不会导致意外的或未定义的程序行为。

动机

与使用 Java 的 null 构造来表示对象不存在相比,考虑引入空对象模式。空对象被认为属于特定对象家族。该对象实现了预期的接口,但实现其方法不会引起任何操作。与使用未定义的空引用相比,这种方法的优势在于空对象非常可预测,并且没有副作用:它什么也不做。它还试图消除令人不快的空指针异常。

在 JDK 中找到它

传统上提到的 java.base 模块和位于 java.util 包中的 Collection 框架定义了 Collections 工具类。该类包含一个内部私有 EmptyIterator 类,用于提供无元素的迭代器实例。

另一个很好的例子可以在 java.io 模块和包中找到。抽象类 InputStream 定义了 nullInputStream 方法,该方法提供零字节的输入流。

示例代码

让我们更仔细地检查空对象模式的使用。今天的车辆包含大量不同类型的传感器。为了利用 Stream API 的更多功能,定义一个包含传感器类型且程序可以透明响应的空对象非常有用(示例 5.15):

public static void main(String[] args) {
    System.out.println("Null Object Pattern, vehicle
        sensor");
    var engineSensor = VehicleSensorsProvider
        .getSenorByType("engine");
    var transmissionSensor = VehicleSensorsProvider
        .getSenorByType("transmission");
    System.out.println("Engine Sensor: " + engineSensor);
    System.out.println("Transmission Sensor: " +
        transmissionSensor);
}

这是输出:

Null Object Pattern, vehicle sensor
Engine Sensor: Sensor{type='engine'}
Transmission Sensor: Sensor{type='not_available'}

示例 5.15 – 客户端收到信息,请求的传感器作为 NullSensor 实例不可用

VehicleSensorProvider 实例总是返回预期类型的结果,实现该模式非常简单(图 5**.8):

图 5.8 – 显示类型维护中使用的关系的 UML 类图

图 5.8 – 显示类型维护中使用的关系的 UML 类图

结论

例子表明,该模式不仅可以提高代码库的可维护性,还可以减少不想要的运行时状态,例如空指针异常。

可以使用我们将在下一节中探讨的方法来解决未定义的程序状态。

使用观察者模式保持所有相关方的信息

观察者模式有时也被称为生产者-消费者模式。再次强调,这是一个非常常见的用例,它出现在各种应用程序中,因此被 GoF 的书中提到。

动机

一个模式代表对象之间的直接关系。一个对象扮演生产者的角色。生产者可能有许多客户,信息需要发送给这些客户。这些对象有时被称为接收者。当观察者改变其状态时,所有已注册的客户端都会被通知这一变化。换句话说,对象发生的任何变化都会导致观察者被通知。

在 JDK 中找到它

观察者模式是 JDK 模块中另一种相当常用的模式。一个例子是来自 java.base 模块和 java.util 包的 Observer 接口。尽管接口已经被弃用,但它们仍然通过编译器实现中的 Observable 类被使用。

示例代码

让我们检查车辆不同位置的温控情况。VehicleSystem 实例应始终通知所有相关方关于每个系统可以调整到的温度目标(示例 5.16):

public static void main(String[] args) {
    System.out.println("Observer Pattern, vehicle
        temperature senors");
    var temperatureControlSystem = new VehicleSystem();
    new CockpitObserver(temperatureControlSystem);
    new EngineObserver(temperatureControlSystem);
    temperatureControlSystem.setState("low");
}

这里是输出:

Observer Pattern, vehicle temperature senors
CockpitObserver, temperature:'11'
EngineObserver, temperature:'4'

示例 5.16 – 每个子系统根据全局设置调整其温度

SystemObserver 抽象类不仅使用密封类的构造来表示正在考虑的子系统,还提供了一个构建预期子系统的基本模板(示例 5.17):

sealed abstract class SystemObserver permits
    CockpitObserver, EngineObserver {
    protected final VehicleSystem system;
    public SystemObserver(VehicleSystem system) {
        this.system = system;
    }
    abstract void update();
}

示例 5.17 – 新增的子系统遵循通用模板以确保可维护性

每个实例都包含一个指向控制温度目标的主体系统的引用(图 5**.9):

图 5.9 – 一个 UML 类图强调了系统之间的关系

图 5.9 – 一个 UML 类图强调了系统之间的关系

结论

观察者模式是另一个非常强大的模式:它允许客户端在不改变或理解实现的情况下,让所有利益相关者保持知情。该模式正确地封装并解耦了逻辑,并允许在运行时使用可配置的过程。

下一个部分展示了如何单独解决链接过程。

使用管道模式处理实例阶段

管道模式可以为改善多个下游操作的组织做出重大贡献。

动机

此模式通过提供初始输入并传递处理后的输出以供后续阶段使用,在一系列阶段中改进数据处理。处理元素被安排在一个连续的管道中,以便一个的输出是另一个的输入,类似于物理管道的工作方式。管道模式可以在连续成员之间提供某种类型的缓冲,由对象实例表示。通过这些管道流动的信息通常是记录流。

在 JDK 中查找

管道模式最明显的例子是 Stream 接口及其实现。该接口是 Stream API 的一部分,与 java.base 模块和 java.util.stream 包一起分发。

示例代码

让我们想象一系列需要在车辆中执行的过程,定义它们,并将它们按顺序排列。然后我们初始化一个 SystemElement 容器,该容器收集每个过程的结果信息(示例 5.18):

public static void main(String[] args) {
    System.out.println("Pipeline Pattern, vehicle turn on
        states");
    var pipeline = new PipeElement<>(new EngineProcessor())
            .addProcessor(new BreakProcessor())
            .addProcessor(new TransmissionProcessor());
    var systemState = pipeline.process(new
        SystemElement());
    System.out.println(systemState.logSummary());
}

这里是输出:

Pipeline Pattern, vehicle turn on states
engine-system,break-system,transmission-system

示例 5.18 – 每个过程的结果都被考虑在最终总结中

基本结构是PipeElement,它不仅定义了输入类型,还定义了输出。此外,它还定义了信息处理的顺序(示例 5.19):

class PipeElement<E extends Element, R extends Element> {
    private final Processor<E, R> processor;
   ...
    <O extends Element> PipeElement<E, O> addProcessor
        (Processor<R, O> p){
        return new PipeElement<>(input -> p.process
            (processor.process(input)));
    }
    R process(E inputElement){
        return processor.process(inputElement);
    }
}

示例 5.19 – addProcessor方法定义了处理器过程方法执行的顺序

每个处理器实现都可以被视为一个功能接口构造,并且可以根据需要更改Element实现,而不会破坏管道基础代码(图 5**.10):

图 5.10 – 显示如何维护管道类型安全的 UML 类图

图 5.10 – 显示如何维护管道类型安全的 UML 类图

结论

所展示的示例显示了清晰分离过程以贡献最终结果的优点。管道模式有潜力创建复杂的操作序列,这些序列可以轻松测试,并且可以动态更改。

让我们在下一节中探讨每个预期元素如何改变其状态。

使用状态模式改变对象行为

行为状态模式定义了基于对象内部状态的突变来影响对象内部过程的过程。这个模式是 GoF(设计模式:可复用面向对象软件的基础)一书的一部分。

动机

可以将对象状态视为代表有限机器的概念。一个模式允许对象在其内部状态改变时改变其行为。状态模式强制对象使用特定的类来描述其内部状态,并将对这些状态的响应映射到特定的实例。

在 JDK 中查找

状态模式的使用可以在jlink插件的实现、jdk.jlink模块以及jdk.tools.jlink.plugin包中找到。接口插件定义了一个嵌套枚举类State,其值是对所讨论状态的引用。

示例代码

以下示例考虑了每辆车都有不同的、被良好识别的状态(示例 5.20):

public static void main(String[] args) {
    System.out.println("State Pattern, vehicle turn on
        states");
    ...
    var initState = new InitState();
    var startState = new StartState();
    var stopState = new StopState();
    vehicle.setState(initState);
    System.out.println("Vehicle state2:" +
        vehicle.getState());
    vehicle.setState(startState);
    System.out.println("Vehicle state3:" +
        vehicle.getState());
    vehicle.setState(stopState);
    System.out.println("Vehicle state4:" +
        vehicle.getState());
}

这里是输出:

State Pattern, vehicle turn on states
Vehicle state2:InitState{vehicle=truck}
Vehicle state3:StartState{vehicle=truck}
Vehicle state4:StopState{vehicle=truck}

示例 5.20 – 车辆状态被很好地封装并从客户端逻辑中分离出来

考虑到的每个Vehicle状态都可以独立且正确地与客户端逻辑分离(图 5**.11):

图 5.11 – 显示考虑到的状态之间关系的 UML 类图

图 5.11 – 显示考虑到的状态之间关系的 UML 类图

结论

状态模式展示了将状态具体表示为对象的专用实例的优点。这不仅提高了可测试性,而且极大地促进了底层代码的可维护性,因为每个状态都被清晰地封装,并符合 SOLID 概念中的单一职责原则。程序执行对客户端来说是透明的,无需实现任何额外的异常处理逻辑。

每个状态都可以对应于特定的程序行为或运行时交互。让我们在下一节深入探讨这个问题。

使用策略模式改变对象行为

策略模式有时也被称为策略模式,因为它在特定情况或状态下为运行时执行建立了精确的步骤。这个模式是 GoF 书籍的一部分。

动机

策略模式代表了一组算法,其中每个算法都得到了适当的封装。它定义了特定对象可以响应的算法的可互换性。这种策略允许算法独立于使用它的客户端进行更改,并允许客户端在运行时选择最合适的一个。换句话说,代码允许客户端附加各种策略对象,这些对象会影响程序的行为。

在 JDK 中找到它

策略模式是另一种常用的模式,人们往往没有意识到它的使用。java.base模块的Collection框架和java.util包实现了Comparator类。这个类通常用于排序目的,例如Collections.sort()方法的实现。

另一种可能更广泛使用的实现是 Stream API 中引入的mapfilter方法,这也来自java.base模块,但在java.util.stream包中。

示例代码

假设一个驾驶员拥有多种驾驶执照,这些执照是针对特定类型的车辆所需的。每种车辆都需要略微不同的驾驶策略(示例 5.21):

public static void main(String[] args) {
    System.out.println("Strategy Pattern, changing
        transport options");
    var driver = new VehicleDriver(new CarStrategy());
    driver.transport();
    driver.changeStrategy(new BusStrategy());
    driver.transport();
    driver.changeStrategy(new TruckStrategy());
    driver.transport();
}

这里是输出:

Strategy Pattern, changing transport options
Car, four persons transport
Bus, whole crew transport
Truck, transporting heavy load

示例 5.21 – VehicleDriver 实例可以在运行时更改运输策略

VehicleDriver实例仅持有当前使用的TransportStrategy实例的引用(示例 5.22):

class VehicleDriver {
    private TransportStrategy strategy;
    VehicleDriver(TransportStrategy strategy) {
        this.strategy = strategy;
    }
    void changeStrategy(TransportStrategy strategy){
        this.strategy = strategy;
    }
    void transport(){
        strategy.transport();
    }
}

示例 5.22 – VehicleDriver 实例通过可见方法与策略进行通信

客户端可以在运行时决定使用哪种策略。每个策略都得到了适当的封装(图 5**.12):

图 5.12 – 显示如何简单地定义新策略的 UML 类图

图 5.12 – 显示如何简单地定义新策略的 UML 类图

结论

这个简单的例子展示了在行动中一个很好地隔离的战略模式。驾驶员可以根据提供的车辆类型改变他们的能力。这种模式将逻辑与代码库的其他部分分离的能力使其非常适合实现不应向客户端暴露的复杂算法或操作。

许多跑步活动都有一个一般的基础。让我们在下一节探讨如何处理这种情况。

使用模板模式标准化流程

模板方法模式统一了密集操作的泛化与模板方法。模板模式被早期认可,并被认为是 GoF 书籍的一部分。

动机

模板方法模式基于识别类似使用的步骤。这些步骤定义了一个算法的骨架。每个操作都可以将其步骤推迟到特定的子类。模板方法引入子类来重新定义算法的某些部分,而不改变其结构。模板可以用来按所需顺序执行内部方法。

在 JDK 中查找

Java 使用位于 java.base 模块和 java.io 包中的 I/O API 定义的输入或输出字节流。InputStream 类包含一个重载的 read 方法,它代表一个字节处理模板。这与定义重载 write 方法的 OutputStream 类类似的方法。

模板模式在 Collection 框架中也有另一种用途,该框架位于同一模块和 java.util 包中。抽象的 AbstractListAbstractSetAbstractMap 类使用不同的模板实现了 indexOflastIndexOf 方法 – 例如,AbstractList 使用 ListIterator,与常见的 Iterator 接口实现相比。

示例代码

让我们看看模板方法模式如何简化创建一个新的传感器(示例 5.23):

public static void main(String[] args) {
    System.out.println("Template method Pattern, changing
        transport options");
    Arrays.asList(new BreaksSensor(), new EngineSensor())
            .forEach(VehicleSensor::activate);
}

这是输出:

Template method Pattern, changing transport options
BreaksSensor, initiated
BreaksSensor, measurement started
BreaksSensor, data stored
BreaksSensor, measurement stopped
EngineSensor, initiated
EngineSensor, measurement started
EngineSensor, data stored
EngineSensor, measurement stopped

示例 5.23 – 模板提供了对每个传感器都有效的通用激活步骤

VehicleSensor 抽象类通过定义一个最终的 activate 方法(示例 5.24)来表示示例的核心元素:

abstract sealed class VehicleSensor permits BreaksSensor,
    EngineSensor {
    abstract void init();
    abstract void startMeasure();
    abstract void storeData();
    abstract void stopMeasure();
    final void activate(){
        init();
        startMeasure();
        storeData();
        stopMeasure();
    }
}

示例 5.24 – activate() 模板方法定义了每个实现的步骤

换句话说,模板方法模式还描述了一种扩展车辆传感器库的方法(图 5**.13):

图 5.13 – 突出显示添加新传感器的简单性的 UML 类图

图 5.13 – 突出显示添加新传感器的简单性的 UML 类图

结论

模板方法模式展示了它在通用动作方面的巨大优势。它无缝地将内部逻辑与客户端分离,并为执行动作提供了透明和通用的步骤。维护代码库或在其中发现潜在问题都很容易。

运行时环境可能很复杂。始终了解哪些实例存在是很好的。我们将在下一节中了解如何做到这一点。

使用访问者模式根据对象类型执行代码

访问者模式引入了算法执行与相关对象实例的分离。此模式在 GoF 的书中被提及。

动机

访问者模式允许客户端在不改变它正在工作的类的实例的情况下定义一个新的操作。此模式提供了一种将底层代码与对象结构分离的方法。这种分离实际上提供了在不修改其结构的情况下向现有对象添加新操作的能力。

在 JDK 中查找

访问者模式的用法可以在java.base模块和java.nio.file包中找到。Files实用类使用的FileVisitor接口及其walkFileTree方法使用了一种模式来遍历目录结构和相关文件。

示例代码

车辆的安全通常依赖于其传感器的稳健性。示例展示了如何确保每个特定传感器的存在(示例 5.25):

public static void main(String[] args) {
    System.out.println("Visitor Pattern, check vehicle
        parts");
    var vehicleCheck = new VehicleCheck();
    vehicleCheck.accept(new VehicleSystemCheckVisitor());
}

这里是输出:

Visitor Pattern, check vehicle parts
BreakCheck, ready
BreakCheck, ready, double-check, BreaksCheck@23fc625e
EngineCheck, ready
EngineCheck, ready, double-check, EngineCheck@3f99bd52
SuspensionCheck, ready
SuspensionCheck, ready, double-check,
    SuspensionCheck@4f023edb
VehicleCheck, ready
VehicleCheck, ready, double-check, VehicleCheck@3a71f4dd

示例 5.25 – 客户端也双重检查每个传感器的存在

VehicleSystemCheackVisitor类定义了visit方法的重载实现。每个特定的传感器实例可以通过重载visit方法简单地考虑(示例 5.26):

class VehicleSystemCheckVisitor implements  CheckVisitor{
    @Override
    public void visit(EngineCheck engineCheck) {
        System.out.println("EngineCheck, ready");
        visitBySwitch(engineCheck);
    }
   private void visitBySwitch(SystemCheck systemCheck){
        switch (systemCheck){
        case EngineCheck e -> System.out.println
            ("EngineCheck, ready, double-check, " + e);
        ...
        default -> System.out.println(
           "VehicleSystemCheckVisitor, not implemented");
     }
   }
  ....
}

示例 5.26 – 在instanceof概念中应用模式匹配可以强制代码可维护性

每个系统检查都正确地注册了预期的传感器,并增加了对车辆安全系统的信心(图 5**.14):

图 5.14 – 车辆传感器及其抽象的 UML 类图

图 5.14 – 车辆传感器及其抽象的 UML 类图

结论

这个例子展示了VehicleCheck系统如何确保每个部件的存在。每个控制都是自包含的,可以根据需要轻松添加或删除新控制。缺点是它需要为每种类型的控制创建一个专用实例。这也意味着至少有两个类在层次结构中引用相似的状态。另一个优点或缺点是,当在运行时可能发现的新的元素被添加时,该模式不会导致编译失败。可以通过利用新添加的具有模式匹配的switch语句和其他一些改进来克服潜在的重复,并确保类型安全——遵循维护 Liskov 替换原则,而标准访问者模式违反了这一原则。示例 5.26展示了visitBySwitch方法,该方法接受SystemCheck对象作为输入。

在探讨了访问者模式之后,我们来到了本章的结尾——让我们简要总结一下我们学到了什么。

概述

在本章中,我们学习了运行时环境的重要性以及程序执行的动态特性。行为设计模式可以改善程序与 Java 平台内部部分的交互。JIT 编译器可以更好地处理运行时的动态字节码转换,或者垃圾收集器可以执行更有效的内存回收。

大多数这些设计模式都符合 SOLID 原则——只有访问者模式留下了一些思考的空间。然而,最近添加到 Java 平台上的改进可以帮助克服这一点。无论是密封类、switch语句、模式匹配增强还是记录,该平台为加强程序的不可变性和代码稳定性以及简化设计模式的使用提供了坚实的基础。其中一些可能直接可用,例如工厂方法和switch-case语句增强。

在本章中,我们通过示例学习了如何在运行时解决问题。我们探讨了如何处理链式任务并指挥所需的演员。公式解释器将文本转换为对象,我们弄清楚了如何遍历它们。中介模式集中了对象之间的复杂通信。我们学习了如何使用空对象模式避免空指针异常,并给未定义的对象赋予类型。

管道公式按顺序处理一组客户端。我们探讨了如何改变特定演员的状态,并回顾了如何使用观察者模式来监控这些变化。我们最后学习到的模式是访问者模式,它展示了如何根据对象类型执行特定操作。

通过从行为模式中获得的知识,我们为单线程程序的完整生命周期增添了缺失的一块。这包括对象的创建、与这些对象一起工作的编程结构,以及在运行时这些对象之间的动态行为和通信。

尽管预期的程序从主线程开始,可能需要是单线程的,但 Java 平台以及大多数业务需求都不是单线程的。任务的共享性质使其适合多线程通信。这可以通过各种框架来实现。正如我们将在下一章中看到的,我们可以探索一些常见的并发模式来解决这方面的最常见挑战。让我们来点刺激的!

问题

  1. 标准访问者模式违反了哪个原则?

  2. 哪个模式帮助我们遍历集合中的元素而无需知道其类型?

  3. 是否存在一种模式允许我们在运行时改变实例的行为?

  4. 哪个模式有助于在运行时透明地识别未定义的状态?

  5. Java Stream API 最常用的模式有哪些?

  6. 是否有方法可以在运行时通知所有集群客户端?

  7. 哪个模式可以用来实现回调?

进一步阅读

第三部分:其他重要模式和反模式

这一部分涵盖了构建高度并发应用程序的设计原则和模式。它还讨论了几个反模式,即针对给定挑战的不适当的软件设计解决方案。

这一部分包含以下章节:

  • 第六章, 并发设计模式

  • 第七章, 理解常见反模式

第六章:并发设计模式

前面的章节在创建型、结构型和行为型模式中提出的设计关注基代码。它们的主要焦点是可维护的基代码,该代码在主单应用程序线程中运行。换句话说,生成的字节码按照定义的顺序执行,以实现预期的结果。

现在,随着硬件的巨大改进,业务需求已经将 GoF 书籍描述的应用程序期望在多年的并发和并行世界中越来越多地转移。这已经通过硬件的巨大改进而实现。

Java 平台从一开始就在底层提供了并发功能。任务控制器的 Flight Recorder 工具帮助收集有关线程行为的数据点,并以可视化的方式显示它们,提高我们对应用程序动态的认识。在本章中,我们将检查信息技术行业中最常见的场景:

  • 使用活动对象模式解耦方法执行

  • 使用异步方法调用模式进行非阻塞任务

  • 使用 balking 模式延迟执行直到前一个任务完成

  • 使用双重检查锁定模式提供唯一的对象实例

  • 通过读写锁模式有目的地进行线程阻塞

  • 使用生产者-消费者模式解耦执行逻辑

  • 使用调度器模式执行隔离的任务

  • 使用线程池模式有效利用线程

到本章结束时,我们将为理解 Java 平台的并发可能性并开始有效地应用它们建立一个坚实的基础。

技术要求

您可以在 GitHub 上找到本章的代码文件:github.com/PacktPublishing/Practical-Design-Patterns-for-Java-Developers/tree/main/Chapter06

使用活动对象模式解耦方法执行

活动对象设计模式通过运行自己的控制线程来分离和延迟方法执行与方法调用。

动机

活动对象模式向应用程序引入了一个透明并发模型。它创建并启动一个内部线程来执行所需的逻辑,临界区。一个活动对象实例公开一个公共接口,客户端可以使用它来运行封装的临界区。一个外部、由客户端发起的事件被排队并准备执行。执行步骤由内部调度器执行。结果可以通过回调风格传递给适当的处理程序。

示例代码

让我们介绍一个带有无线电系统的移动车辆示例(示例 6.1):

public static void main(String[] args) throws Exception {
    System.out.println("Active Object Pattern, moving
        vehicle");
    var sportVehicle = new SportVehicle("super_sport");
    sportVehicle.move();
    sportVehicle.turnOnRadio();
    sportVehicle.turnOffRadio();
    sportVehicle.turnOnRadio();
    sportVehicle.stopVehicle();
    sportVehicle.turnOffRadio();
    TimeUnit.MILLISECONDS.sleep(400);
    System.out.println("ActiveObjectMain, sportVehicle
    moving:" + sportVehicle.isMoving());
}

这里是输出:

Active Object Pattern, moving vehicle
MovingVehicle:'super_sport', moving
MovingVehicle:'super_sport', radio on
MovingVehicle:'super_sport', moving
MovingVehicle:'super_sport', stopping, commands_active:'3'
MovingVehicle:'super_sport', stopped
ActiveObjectMain, sportVehicle moving:false

示例 6.1 – SportVehicle 实例允许客户端通过其公共方法创建事件

新创建的抽象类MovingVehicle定义了公共方法 – moveturnOnRadioturnOffRadiostopVehicle。除了控制线程外,该类还定义了一个用于接收事件的条件队列(示例 6.2):

abstract class MovingVehicle {
    private static final AtomicInteger COUNTER = new
       AtomicInteger();
    private final BlockingDeque<Runnable> commands;
    private final String type;
    private final Thread thread;
    private boolean active;
    MovingVehicle(String type) {
        this.commands = new LinkedBlockingDeque<>();
        this.type = type;
        this.thread = createMovementThread();
    }
   …
  private Thread createMovementThread() {
    var thread = new Thread(() -> {
        while (active) {
            try {
                var command = commands.take();
                command.run();
               ...
        }
    });
    thread.setDaemon(true);
    thread.setName("moving-vehicle-" +
        COUNTER.getAndIncrement());
    return thread;
   …
}

示例 6.2 – MovingVehicle 包含一个用于调度事件的主动标志

队列中的事件根据内部周期接收和触发。LinkedBlockingDeque提供了从顶部或底部插入或删除元素的功能,这在车辆需要停止时很有用。StopVehicle事件比收音机有优先级(示例 6.3):

abstract class MovingVehicle {
  ...
  void turnOffRadio() throws InterruptedException {
    commands.putLast(() -> {...});
  }
  void stopVehicle() throws InterruptedException {
    commands.putFirst(() -> {...});
  }
  ...
}

示例 6.3 – 条件性地将接收的事件添加到队列中

SportVehicle实例的生命周期不会干扰主应用程序线程。它是可预测的,并且不会阻塞应用程序(图 6**.1):

图 6.1 – 移动车辆线程显示了命令的顺序

图 6.1 – 移动车辆线程显示了命令的顺序

示例中引入的组件无缝协作(图 6**.2):

图 6.2 – UML 类图显示了 SportVehicle 类与 Java 并发特性的关系

图 6.2 – UML 类图显示了 SportVehicle 类与 Java 并发特性的关系

结论

一个良好发展的主动对象模式尊重 SOLID 设计方法,因为它封装了关键部分,并且只暴露所需的控制接口。实例不会干扰应用程序,整个方法可以推广到期望的级别。主动对象可以是一个将并发模型引入应用程序的好候选者,但有一些挑战需要记住。其中之一是可能的应用程序线程数量,大量线程可能会使应用程序变得脆弱或导致不稳定,因为它依赖于可用资源。

让我们在下一节中探索事件的异步特性。

使用异步方法调用模式的无阻塞任务

异步方法调用模式是一种解决不惩罚主进程线程可能耗时任务的方法。

动机

异步方法调用模式引入了通过回调从异步运行的任务接收结果的能力,而不会阻塞主进程线程。该模式展示了处理所需任务类型的线程模型和并行级别。任务结果由专门的回调处理程序处理,并无论任务执行时间如何,都提供给主进程。这些处理程序可能已经属于主进程。

示例代码

让我们看看几个车辆温度传感器的简单场景,这些传感器需要向驾驶员提供结果,即客户端(示例 6.4):

public static void main(String[] args) throws Exception {
    System.out.println("Async method invocation Pattern,
        moving vehicle");
    var sensorTaskExecutor = new
        TempSensorExecutor<Integer>();
    var tempSensorCallback = new TempSensorCallback();
    var tasksNumber = 5;
    var measurements = new ArrayList<SensorResult
       <Integer>>();
    System.out.printf("""
                AsyncMethodMain, tasksNumber:'%d' %n""",
                    tasksNumber);
    for(int i=0; i<tasksNumber; i++) {
        var sensorResult = sensorTaskExecutor.measure(new
            TempSensorTask(), tempSensorCallback);
        measurements.add(sensorResult);
    }
    sensorTaskExecutor.start();
    AsyncMethodUtils.delayMills(10);
    for(int i=0; i< tasksNumber; i++){
        var temp = sensorTaskExecutor.stopMeasurement
            (measurements.get(i));
        System.out.printf("""
                AsyncMethodMain, sensor:'%d'
                    temp:'%d'%n""", i, temp);
    }

这里是输出:

Async method invocation Pattern, moving vehicle
AsyncMethodMain, tasksNumber:'5'
SensorTaskExecutor, started:5
...
TempSensorTask,n:'4' temp:'5', thread:'thread-3'
TempSensorTask,n:'3' temp:'26', thread:'thread-0'
TemperatureSensorCallback, recorded value:'26',
  thread:'main'
AsyncMethodMain, sensor:'0' temp:'26'
…

示例 6.4 – 示例任务 temp:26 在线程-0 线程中异步执行

监控所有结果的 TempSensorCallback 实例位于 main 进程线程中(图 6**.3):

图 6.3 - 温度传感器回调实例异步调用,因此不同线程的完成时间不同

图 6.3 - 温度传感器回调实例异步调用,因此不同线程的完成时间不同

TempSensorTask 实例由自定义的 TempSensorExecutor 实例处理,它不仅提供了对启动线程的控制,还可以通过提供任务引用来终止特定传感器的长时间运行测量。TempSensorExecutor 实例公开了一个测量公共方法,该方法提供了一个长时间运行任务的 TempSensorResult 实例(示例 6.5):

class TempSensorExecutor<T> implements SensorExecutor<T> {
    ...
    @Override
    public SensorResult<T> measure(Callable<T> sensor,
        SensorCallback<T> callback) {
        var result = new TempSensorResult<T>(callback);
        Runnable runnable = () -> {
            try {
                result.setResult(sensor.call());
            } catch (Exception e) {
                result.addException(e);
            }
        };
        var thread = new Thread(runnable, "thread-" +
            COUNTER.getAndIncrement());
        thread.setDaemon(true);
        threads.add(thread);
        return result;
    }
}

示例 6.5 – 每个新的特定于线程的长期测量都会将结果传递给回调处理程序

处理由多个温度传感器提供的信息的性质显然是并行的。异步方法调用模式提供了一组非常小的类来解决这个挑战(图 6**.4):

图 6.4 – UML 类图展示了如何从温度传感器获取数据

图 6.4 – UML 类图展示了如何从温度传感器获取数据

结论

给出的示例清楚地说明了如何通过初步与主处理线程分离来处理长时间运行的任务。换句话说,这不是由延迟引起的。Java 平台提供了多种选项来创建这种模式。其中之一是使用 Callable 接口,并通过其 submit 方法将实例发送到 ExecutorServicesubmit 方法返回一个实现 Future 接口的结果。Future 与示例 TempSensorResult 实例有相似之处,但不提供需要不同处理的回调函数。另一种可能性是使用 CompletableFuture,它不仅公开了 supplyAsync 方法,还提供了许多其他有用的函数。所有提到的建议都可以在 java.base 模块和 java.util.concurrent 包中找到。

下一个部分将展示如何延迟任务的执行,直到前一个任务完成;让我们开始吧。

使用 balking 模式延迟执行直到前一个任务完成

有时需要考虑任务状态的变化,以便正确执行下一个任务并实现目标。

动机

尽管实例的可变性不是一个理想的状态,特别是在并发领域,但依赖对象状态的能力可能会很有用。当多个线程试图获取一个对象以执行其临界区时,可以通过对象状态来限制这种情况。状态可以决定是否使用处理时间来协调可用的资源。例如,一辆车在没有运动的情况下不能停止。

样本代码

考虑一个Vehicle实例共享两组驾驶员的例子。尽管有多个小组,但一次只能有一辆车运行(示例 6.6):

public static void main(String[] args) throws Exception {
    System.out.println("Balking pattern, vehicle move");
    var vehicle = new Vehicle();
    var numberOfDrivers = 5;
    var executors = Executors.newFixedThreadPool(2);
    for (int i = 0; i < numberOfDrivers; i++) {
        executors.submit(vehicle::drive);
    }
    TimeUnit.MILLISECONDS.sleep(1000);
    executors.shutdown();
    System.out.println("Done");
}

这是输出:

Balking pattern, vehicle move
Vehicle state:'MOVING', moving, mills:'75',
  thread='Thread[pool-1-thread-2,5,main]'
Vehicle state:'STOPPED' stopped, mills:'75',
  thread='Thread[pool-1-thread-2,5,main]'
Vehicle state:'MOVING', moving, mills:'98',
  thread='Thread[pool-1-thread-1,5,main]'
…

示例 6.6 – 驾驶员小组由 ExecutorService 提供的线程表示

双重检查锁定模式提供了一种解决方案,其中任务的临界区基于由VehicleState枚举表示的Vehicle实例状态执行(示例 6.7):

class Vehicle {
    synchronized void driveWithMills(int mills) throws
        InterruptedException {
        var internalState = getState();
        switch (internalState) {
            case MOVING -> System.out.printf("""
                    Vehicle state:'%s', vehicle in move,
                        millis:'%d', thread='%s'%n""",
                           state, mills, Thread
                             .currentThread());
            case STOPPED -> startEngineAndMove(mills);
            case null -> init();
        }
}
...

示例 6.7 – 使用 synchronized 关键字使驾驶员小组知道车辆实例是否准备好使用

驾驶员小组线程被阻塞,每次只有一个线程处于活动状态(图 6.5):

图 6.5 – 蓝色和绿色代表小组活动,而其他部分被阻塞

图 6.5 – 蓝色和绿色代表小组活动,而其他部分被阻塞

所提供的示例需要创建非常少的类,这些类被清晰地封装(图 6.6):

图 6.6 – UML 类图显示了两个最需要的自定义类,Vehicle 和 VehicleState

图 6.6 – UML 类图显示了两个最需要的自定义类,Vehicle 和 VehicleState

结论

在 Java 平台上实现双重检查锁定模式很简单。为了正确处理对象状态的不确定性,需要牢记 Java 内存模型。考虑使用原子类型(例如AtomicIntegerAtomicBoolean),这些类型自动带有 happens-before 保证。这种保证是 Java 内存模型的一部分,用于在交互线程之间保持内存一致性,正如我们在第二章中学习的,探索 Java 平台以设计模式。另一个可以考虑的选项是volatile关键字,它提供了线程间等值可见性的保证。

下一个部分将检查保证实例的唯一性 – 让我们开始吧。

提供一个具有双重检查锁定模式的独特对象实例

双重检查锁定模式解决了在运行时应用程序只需要特定类的一个实例的问题。

动机

Java 平台默认是多线程的,正如我们在第二章中学习的,发现 Java 平台的设计模式。不仅仅是垃圾收集线程负责主程序的生命周期。不同的框架引入了额外的线程模型,这可能会对类机构的进程产生意想不到的影响。双重检查锁定模式确保在运行时只有一个类的实例。在多线程环境中,这种状态可能变得具有挑战性,因为它可能取决于其实现。

示例代码

让我们用一个简单的Vehicle实例来演示在多线程环境中双重检查锁定模式的重要性。示例展示了单例模式的两种不同实现。VehicleSingleton由于多个线程访问getInstance方法而期望保持其承诺(示例 6.8):

public static void main(String[] args) {
    System.out.println("Double checked locking pattern,
        only one vehicle");
    var amount = 5;
    ExecutorService executor = Executors
        .newFixedThreadPool(amount);
    System.out.println("Number of executors:" + amount);
    for (int i = 0; i < amount; i++) {
        executor.submit(VehicleSingleton::getInstance);
        executor.submit(VehicleSingletonChecked
           ::getInstance);
    }
    executor.shutdown();
}

这里是输出:

Double checked locking pattern, only one vehicle
Number of executors:5
VehicleSingleton, constructor thread:'pool-1-thread-1'
hashCode:'1460252997'
VehicleSingleton, constructor thread:'pool-1-thread-5'
hashCode:'1421065097'
VehicleSingleton, constructor thread:'pool-1-thread-3'
hashCode:'1636104814'
VehicleSingletonChecked, constructor thread:'pool-1-thread-
2' hashCode:'523532675'

示例 6.8 – VehicleSingleton 构造函数已被多次调用,这通过多次实例化违反了给定的承诺(参见 hashCode 值)

Executors.newFixedThreadPool提供的ExecutorService实例接收多个Runnable接口的实例。Runnable方法的实现代表了两种情况下getInstance方法调用的临界区(图 5.5):

图 6.7 – 所有池线程持续执行 getInstance 方法,而 VehicleSingletonCheck 只被创建一次

图 6.7 – 所有池线程持续执行 getInstance 方法,而 VehicleSingletonCheck 只被创建一次

两种实现方式在getInstance方法实现的非常小的细节上有所不同(示例 6.9):

public static VehicleSingleton getInstance(){
    if (INSTANCE == null){
        INSTANCE = new VehicleSingleton();
    }
    return INSTANCE;
}
...
static VehicleSingletonChecked getInstance() {
    if (INSTANCE == null) {
        synchronized (VehicleSingletonChecked.class) {
            if (INSTANCE == null) {
                INSTANCE = new VehicleSingletonChecked();
            }
        }
    }
    return INSTANCE;
}

示例 6.9 – VehicleSingletonChecked 的 getInstance 方法的实现使用 synchronized 关键字来确保线程栈帧状态

在这两种情况下,UML 图保持不变(图 6.8):

图 6.8 – UML 类图没有突出显示双重检查单例模式的实现细节

图 6.8 – UML 类图没有突出显示双重检查单例模式的实现细节

结论

这个例子展示了一种实现双重检查锁定模式的方法。Java 平台也可以通过使用Enum构造来强制执行双重检查锁定模式,它只提供一个元素 – 它的INSTANCE对象,这是期望的类型。

下一个部分将演示如何处理锁定独占性。

通过读写锁模式使用有目的的线程阻塞

并发应用程序可能会考虑仅为了更新特定实例的信息而授予临界区独占访问权限。这个特定的挑战可以通过使用读写锁模式来解决。

动机

读写锁模式引入了锁获取的自然排他性。此上下文用于区分是否可以执行临界区。换句话说,由于任何读者的目标都是获取尽可能准确和最新的值,因此写入操作在本质上优先于读取操作。在底层,这意味着当写入线程修改数据时,所有读者都会被阻塞,而当写入线程完成任务时,读者会被解除阻塞。

样本代码

假设车辆内部有多个传感器需要关于温度值的准确信息,但只有一个能够更新温度值的温度设备(示例 6.10):

public static void main(String[] args) throws Exception {
    System.out.println("Read-Write Lock pattern, writing
        and reading sensor values");
    ReentrantReadWriteLock readWriteLock = new
        ReentrantReadWriteLock();
    var sensor = new Sensor(readWriteLock.readLock(),
        readWriteLock.writeLock());
    var sensorWriter = new SensorWriter("writer-1",
        sensor);
    var writerThread = getWriterThread(sensorWriter);
    ExecutorService executor = Executors.newFixedThreadPool
        (NUMBER_READERS);
    var readers = IntStream.range(0, NUMBER_READERS)
            .boxed().map(i -> new SensorReader("reader-"
               + i, sensor, CYCLES_READER)).toList();
    readers.forEach(executor::submit);
    writerThread.start();
    executor.shutdown();
}

这是输出:

Read-Write Lock pattern, writing and reading sensor values
SensorReader read, type:'reader-2', value:'50,
thread:'pool-1-thread-3'
SensorReader read, type:'reader-0', value:'50,
thread:'pool-1-thread-1'
SensorReader read, type:'reader-1', value:'50,
thread:'pool-1-thread-2'
SensorWriter write, type:'writer-1', value:'26',
thread:'pool-2-writer-1'
SensorReader read, type:'reader-2', value:'26,
thread:'pool-1-thread-3'
...

示例 6.10 – 运行其自身线程的SensorWriter实例获得对Sensor实例的独占访问

读取器可以连续读取传感器值而不会被阻塞。当写入者介入时,情况发生了变化 – 此时,读取者会被阻塞并必须等待SensorWriter实例完成(图 6**.9):

图 6.9 – 突出显示当读取线程被阻塞时写入锁排他性的线程活动

图 6.9 – 突出显示当读取线程被阻塞时写入锁排他性的线程活动

临界区由两个方法writeValuereadValue提供服务。这两个方法都属于Sensor类(示例 6.11):

class Sensor {
    ...
    int getValue() {
        readLock.lock();
        int result;
        try {  result = value; … } finally {  readLock.
          unlock(); }
        return result;
    }
    void writeValue(int v) {
        writeLock.lock();
        try { this.value = v; ...} finally {
            writeLock.unlock();}
    }
}

示例 6.11 – 当获取写锁时,读锁被暂停

重要的是要注意,锁实例位于执行的主线程中,并由ExecutorService实例提供的线程获取(图 6**.10):

图 6.10 – 读写锁模式的 UML 类图

图 6.10 – 读写锁模式的 UML 类图

结论

读写锁非常强大,并且可以非常积极地促进应用程序的稳定性。它清楚地分离了参与者与驱动逻辑的临界区代码。根据请求,每个示例类都可以根据 SOLID 设计原则进行泛化或调整。

JDK 定义了另一种值得考虑的交换传感器值的方法。java.base模块包中的java.util.concurrent包含Exchanger类,它提供了所需的同步保证。

让我们考察另一个常见的模式,其中实例被广播到目标。

使用生产者-消费者模式解耦执行逻辑

常见的工业场景表示在不会阻塞主应用程序线程的情况下产生和消费值。生产者-消费者模式通过解耦逻辑和分离行来帮助解决这一挑战。

动机

一个常见的工业场景涉及在不阻塞主执行线程的情况下产生和消费值。生产者-消费者模式正是为了应对这一挑战而设计的,通过解耦逻辑并分离目标接收者。

示例代码

另一种场景是,车辆从多个来源产生多个事件,这些事件需要被广播并传递给消费者(示例 6.12):

public static void main(String[] args) throws Exception{
    System.out.println("Producer-Consumer pattern,
        decoupling receivers and emitters");
    var producersNumber = 12;
    var consumersNumber = 10;
    var container = new EventsContainer(3);
    ExecutorService producerExecutor =
        Executors.newFixedThreadPool(4, new
            ProdConThreadFactory("prod"));
    ExecutorService consumersExecutor = Executors.
        newFixedThreadPool(2, new ProdConThreadFactory
            ("con"));
    IntStream.range(0, producersNumber)
            .boxed().map(i -> new EventProducer(container))
            .forEach(producerExecutor::submit);
    IntStream.range(0, consumersNumber)
            .boxed().map(i -> new EventConsumer(i,container))
            .forEach(consumersExecutor::submit);
    TimeUnit.MILLISECONDS.sleep(200);
    producerExecutor.shutdown();
    consumersExecutor.shutdown();
}

这里是输出:

Producer-Consumer pattern, decoupling mess
VehicleSecurityConsumer,event:'Event[number=0, source=pool-
prod-0]', number:'0', thread:'pool-con-0'
VehicleSecurityConsumer,event:'Event[number=1, source=pool-
prod-3]', number:'1', thread:'pool-con-1'
VehicleSecurityConsumer,event:'Event[number=3, source=pool-
prod-1]', number:'2', thread:'pool-con-0'
VehicleSecurityConsumer,event:'Event[number=2, source=pool-prod-2]', number:'3', thread:'pool-con-1'
...

示例 6.12 – 与生产者相比,消费者在数量上较少,不仅在数量上,而且在可用资源上

每个 ExecutorService 实例都使用 ProdConThreadFactory 对象类型来提供有意义的线程名称(图 6**.11):

图 6.11 – 消费者数量较少,有时可能因为事件存储已满而被阻塞

图 6.11 – 消费者数量较少,有时可能因为事件存储已满而被阻塞

参与类是解耦的,并准备好扩展(图 6**.12):

图 6.12 – UML 类图显示了事件类与 Java 平台内部的关系

图 6.12 – UML 类图显示了事件类与 Java 平台内部的关系

结论

在分布式系统领域,生产者-消费者方法被广泛使用。它有利于清楚地分离和定义事件发送者和接收者组。根据所需的线程模型,这些组可以放置在不同的线程中。

JDK 19 版本带来了新增加的虚拟线程概念。虚拟线程通过引入类似线程的框架和包装器来尝试简化核心平台线程的使用。虚拟线程包装器由 JVM 调度,通过使用新添加的执行器在可用的平台线程上运行,例如,Executors.newVirtualThreadPerTaskExecutor。这种方法满足生产者-消费者模式,其中生产者是使用新的虚拟线程执行器应用程序,平台消耗已调度的虚拟线程。

让我们在下一节更详细地揭示调度器方法。

使用调度器模式执行隔离的任务

一个表现确定性的应用程序在其成功中可以发挥关键作用。调度器模式可以帮助实现预期的目标。

动机

尽管调度器有时设计得不好,无法让应用程序保持忙碌,但它们的主要目的是重要的。在需要系统表现出可预测性的微服务或分布式方法中,使用模式的必要性更为明显。一般的目标是确定何时执行特定任务,以便合理地使用底层资源或为在站点可靠性工程中描述的所需资源创建预算估计。

示例代码

以下示例将带我们进入温度测量。每辆车都包含机械或数字形式的温度传感器。温度传感器在车辆运行中起着关键作用(示例 6.13):

public static void main (String [] args) throws Exception {
    System.out.println("Scheduler pattern, temperature
        measurement");
    var scheduler = new CustomScheduler(100);
    scheduler.run();
    for (int i=0; i < 15; i++){
        scheduler.addTask(new SensorTask(
            "temperature-"+i));
    }
    TimeUnit.SECONDS.sleep(1);
    scheduler.stop();
}

这里是输出结果:

Scheduler pattern, providing sensor values
SensorTask, type:'temperature-0'
,activeTime:'58',thread:'scheduler-1'
SensorTask, type:'temperature-1',
activeTime:'65',thread:'scheduler-1'
SensorTask, type:'temperature-2',
activeTime:'75',thread:'scheduler-1'
...
CustomScheduler, stopped

示例 6.13 – CustomScheduler 实例每隔 100 毫秒从阻塞队列中执行一个 SensorTask 实例

CustomerScheduler 展示了一个简单的实现,该实现管理执行过程(图 6.14):

图 6.13 – 每个任务执行都分配了 100 毫秒的时间窗口

图 6.13 – 每个任务执行都分配了 100 毫秒的时间窗口

调度器实例化准备了一个带有活动标志的线程来控制生命周期(示例 6.14):

CustomScheduler  { ...
    CustomScheduler(int intervalMillis) {
    this.intervalMills = intervalMillis;
    this.queue = new ArrayBlockingQueue<>(10);
    this.thread = new Thread(() -> {
        while (active){
            try {
                var runnable = queue.poll(intervalMillis,
                    TimeUnit.MILLISECONDS);
                 ...
                 var delay = intervalMillis – runnable
                    .activeTime();
                TimeUnit.MILLISECONDS.sleep(delay);
            } catch (InterruptedException e) {  throw new
                RuntimeException(e); }
        }
        System.out.println("CustomScheduler, stopped");
    }, "scheduler-1");
}
...

示例 6.14 – CustomScheduler 确保时间窗口得到维护

创建简单调度器的任务很简单,但除此之外,还要牢记线程模型——即在何处以及如何执行(图 6.14):

图 6.14 – UML 类图突出了 CustomScheduler 线程模型

图 6.14 – UML 类图突出了 CustomScheduler 线程模型

在调度模式的情况下,提及第二个示例是公平的。第二个示例使用了内置的 JDK 函数及其定制。规划过程完全由平台管理。应用程序示例再次类似于第一个示例,温度测量(示例 6.15):

public static void main(String[] args) throws Exception {
    System.out.println("Pooled scheduler pattern ,
        providing sensor values");
    var pool = new CustomScheduledThreadPoolExecutor(2);
    for(int i=0; i < 4; i++){
        pool.scheduleAtFixedRate(new SensorTask
           ("temperature-"+i), 0, 50,
                TimeUnit.MILLISECONDS);
    }
    TimeUnit.MILLISECONDS.sleep(200);
    pool.shutdown();
}

这里是输出结果:

Pooled scheduler pattern, providing sensor values
POOL: scheduled task:'468121027', every MILLS:'50'
POOL, before execution, thread:'custom-scheduler-pool-0',
task:'852255136'
...
POOL: scheduled task:'1044036744', every MILLS:'50'
SensorTask, type:'temperature-1',
activeTime:'61',thread:'custom-scheduler-pool-1'
SensorTask, type:'temperature-0',
activeTime:'50',thread:'custom-scheduler-pool-0'
POOL, after execution, task:'852255136', diff:'56'
POOL, before execution, thread:'custom-scheduler-pool-0',
task:'1342170835'
SensorTask, type:'temperature-2'
,activeTime:'71',thread:'custom-scheduler-pool-0'
...
POOL is going down

示例 6.15 – 间隔设置为 100 毫秒,SensorTask 实例在每个迭代中重复使用

扩展的 CustomScheduledThreadPoolExecutor 实例可以通过覆盖 beforeExecuteafterExecute 等可用方法提供基于任务执行的信息。使用 JDK 内部机制可以轻松地在线程之间扩展 SensorTask 实例(图 6.15):

图 6.15 – CustomScheduledThreadPoolExecutor 实例简化了线程管理以及其他 JDK 内部机制的管理

图 6.15 – CustomScheduledThreadPoolExecutor 实例简化了线程管理以及其他 JDK 内部机制的管理

利用 JDK 内部机制进行调度不需要您创建定制的解决方案,同时可以更好地了解调度周期(图 6.16):

图 6.16 – UML 类图显示了创建具有所有内部机制的定制调度器所需的最小努力

图 6.16 – UML 类图显示了创建具有所有内部机制的定制调度器所需的最小努力

结论

两个预设示例展示了调度器模式可能的用途。使用 JDK 内部功能有许多需要考虑的优点。它赋予平台更高效使用和优化可用资源的能力,例如我们在第二章**,《发现 Java 平台设计模式》*中了解到的动态 JIT 翻译。

使用线程池模式有效利用线程

并非每次任务都需要创建一个新的线程,因为这可能导致资源使用不当。线程池模式可能是解决这一挑战的好方法。

动机

短暂的任务不需要每次运行时都创建一个新的线程,因为每个线程的实例化都与底层资源的分配相关。浪费资源可能会导致应用程序吞吐量或性能惩罚。线程池模式描述了一种更好的选择,它定义了执行关键部分所需的可重用线程的数量。特定的工作者可以透明地操作需要执行的关键部分代码上方。

示例代码

让我们再次想象一下通过具有不同测量动态的传感器进行的温度测量(示例 6.16):

public static void main (String[] args) throws Exception{
    System.out.println("Thread-Pool pattern, providing
        sensor values");
    var executor = Executors.newFixedThreadPool(5);
    for (int i=0; i < 15; i++){
        var task = new TemperatureTask("temp" + i);
        var worker  = new SensorWorker(task);
        executor.submit(worker);
    }
    executor.shutdown();
}

这里是输出:

Thread-Pool pattern, providing sensor values
TemperatureTask, type:'temp3', temp:'0', thread:'pool-1-
thread-4'
TemperatureTask, type:'temp4', temp:'7', thread:'pool-1-
thread-5'
TemperatureTask, type:'temp2', temp:'15', thread:'pool-1-
thread-3'
TemperatureTask, type:'temp1', temp:'20', thread:'pool-1-
thread-2'
..

示例 6.16 – 线程池按需运行工作温度测量任务

线程池有助于使用和管理创建的线程,以确保始终有任务可以处理。这有利于应用程序的行为,并便于根据可用资源进行规划(图 6**.17):

图 6.17 – 线程池的行为展示了创建的线程的使用

图 6.17 – 线程池的行为展示了创建的线程的使用

核心示例元素是SensorWorker。工作器实现了Runnable接口,并负责TemperatureTask的评估(示例 6.17):

class SensorWorker implements Runnable {
    ...
    @Override
    public void run () {
        try {task.measure();} catch (InterruptedException
            e) {...}
    }
}

示例 6.17 – SensorTask 实例可以为围绕其的任务评估提供额外的逻辑

示例实现不需要引入任何额外的自定义类类型来引入并发(图 6**.16):

图 6.18 – UML 类图突出显示 Java 平台提供了所有所需的线程池元素

图 6.18 – UML 类图突出显示 Java 平台提供了所有所需的线程池元素

结论

线程池模式可以提供另一种引入并发到应用程序的可接受方式。它不仅支持继承Runnable接口的类类型的执行,还支持Callable接口。使用Callable接口允许您通过Future接口创建一个结果。将Callable实例执行到Future类型实例的结果是,关键部分的执行是由控制线程异步完成的。换句话说,产生结果所需的时间是未知的。

线程池模式也是另一种 SOLID 方法,可以正确地构建您的代码库,以确保可维护性和资源利用。

让我们简要总结本章学到的经验教训。

摘要

本章演示了一些解决并发问题的最常用方法。它还展示了先前获得知识的重要性,并发应用程序开发需要更多的精确性和纪律,以实现预期的结果,类似于在第二章中讨论的 Java 平台内部知识,发现 Java 平台的设计模式

每个目前采用的模式都迫使创建一个可持续的、清洁的应用代码库。其中许多明显遵循并使用了讨论的开发方法,如 APIE 或 SOLID。

Java 平台的演变倾向于简化如何接近平台并发能力的方法。本章的一些部分已经提到了一个很好的例子。像CompletableFutureExecutors实用工具这样的功能已经存在了一段时间,但即将推出的功能可能值得考虑。虚拟线程的初步目标是提高应用程序吞吐量,同时合理利用底层资源,并仍然保持线程便利性,如调试和提供相关的堆栈帧。另一方面,结构并发试图提供一个框架,以简单设计回调,同时使用命令式代码风格。除了尝试提高应用程序吞吐量或简化并发框架使用的即将推出的功能外,我们不应忘记由record类型提供的实例不可变性。由于相等性,record类型提供了一种强大的状态合约。实例不可变性在线程交互中可以发挥关键作用。

整个应用程序开发有时会偏离预期的目标。在这种情况下,已经识别出一些常见的症状。这些信号可能会引起我们注意,重新考虑开发方向。

我们将在下一章中涉及其中的一些。

问题

  1. 双重检查单例模式解决了哪些挑战?

  2. 使用 JDK 创建所需线程池的最佳方式是什么?

  3. 哪个并发设计模式反映了实例处理下一步的变异性?

  4. 处理可重复任务的最佳模式是什么?

  5. 哪种模式有助于分离调度逻辑和事件处理?

进一步阅读

答案

  1. 双重检查单例模式解决的问题是在运行中的 JVM 中确保只有一个类实例存在,以避免可能的泄漏

  2. 位于java.base模块和java.util.concurrent包中的Executors实用工具的使用

  3. 拒绝模式依赖于实例状态

  4. 调度器模式

  5. 生产者-消费者模式是最常见的并发设计模式之一,具有清晰分离和处理的逻辑

第七章:理解常见反模式

在前几章中,我们探讨了想象中与车辆相关的应用的绿色工作路径。在本章中,车辆的抽象将保持为一个辅助元素,因为人们可能比其他抽象更容易想象出受车辆启发的应用。一辆车及其所有部件,是一个容易理解的概念。

让我们快速回顾一下设计模式的重要性以及它们如何有助于组织的成功。

梅尔文·E·康威说,应用程序的设计和实现强烈反映了组织的内部沟通。这个说法在今天仍然具有同等的相关性,尤其是在许多项目使用敏捷方法的情况下。自动构建、持续集成或测试以及随后的自动化部署在将应用程序交付到生产中起着关键作用。任何被忽视或意外的限制都可能限制或损害应用程序的主要目标。

在本章中,我们将回顾一些识别偏离主要目标迹象的重要领域,以便您能够拥有功能性强、可维护和透明的应用程序。缺乏这些品质可能会在多个层面上对应用程序的功能产生负面影响。运行时可能会受损,这可能导致不可预测的成本。错误可能隐藏在应用程序架构中,不允许扩展性和可维护性。这些问题也可能反复出现,并需要在每个案例中都需要专业的关注。

我们将专注于以下领域:

  • 什么是反模式以及如何识别它们

  • 检查典型的软件反模式

  • 理解软件架构反模式

到本章结束时,您将能够识别和理解一些应该注意的反模式迹象。

技术要求

您可以在 GitHub 上找到本章的代码文件,链接为github.com/PacktPublishing/Practical-Design-Patterns-for-Java-Developers/tree/main/Chapter07

什么是反模式以及如何识别它们

一个人可能将反模式定义为优秀设计模式或良好实践的完全相反。尽管这可能看起来是底线,但它忽略了导致软件实践被称为反模式的具体背景和行动顺序。换句话说,它没有解释为什么反模式是一系列高度风险、无效和反生产性的步骤。理解这些步骤很重要,因为它们允许创建一个可重复的过程来获得类似的结果,只是为了验证其模糊性。底线是,这些步骤可能会限制有效地解决问题的能力。让我们更深入地探讨一下理论。

理论原则挑战

由于多种原因,反模式可能会在软件开发过程中自然出现。它们可能是由于业务逻辑的变化、技术迁移或信息缺失。事实仍然是,反模式确实存在,简单来说,由于团队规模、沟通问题等,它们可能是开发过程的一部分。

关键问题是如何识别它们。在第一章《进入软件设计模式》中,我们提到了违反 APIE 和 SOLID 设计原则的负面影响;这可能是考虑源代码重构响应的信号。另一种现象可能是不尊重CAP 定理一致性可用性分区容错性)。开发时间可能已经投入到试图同时实现这三个属性,这是不可能的。这样的尝试可能被视为重新思考开发策略的强烈信号。

虽然 APIE 和 SOLID 的原则通常被认为是常识,但事实并非如此,尤其是在创建敏捷方法和完成任务方面。开发可能会倾向于产生持续的技术债务。这个词“持续”非常重要,因为这种债务的积累可能会导致非常不愉快的后果。

将技术债务作为瓶颈收集

技术债务是一个有趣的概念,因为它需要软件应用及其目标的上下文才能理解。技术债务包含一些可能一开始并不明显的属性,但可能导致严重的应用瓶颈。让我们想象一个汽车生产线,它是由多个在并行运行的各种动态下的过程组成的集合。生产线应以车辆的形式交付预期的结果。如果存在瓶颈的积累,那么结果将无法实现。这种汽车生产线的抽象相当简单,但将其应用于运行中的软件可能更困难,因为软件依赖于底层技术、平台和硬件。

Java 平台有一些必须考虑的注意事项。基本上,规则是开发者尊重平台,平台尽力为运行中的软件提供服务。

不恰当地挤压 Java 平台的能力

第二章《发现 Java 平台设计模式》中,我们讨论了诸如类型和内存模型等重要主题;不考虑这些可能被视为反模式。作为 Java 平台多线程特性的提醒,我们可以提到 Java 内存模型,它保证在多线程环境中应用程序对值的可见性。另一个可能变成反模式的是在主应用程序执行线程旁边运行的垃圾回收算法。因为 Java 应用程序与最终分配的内存空间(堆)一起工作,垃圾收集器试图确保仍有足够数量的堆内存可供使用。

获得的知识帮助我们避免与任何反模式相关的最常见误解之一:不想要的自动装箱,即编译器在原始类型和包装类之间自动执行转换。

自动装箱的影响初看并不明显,可能直到你的应用程序面临关键负载时才会被发现。让我们看看聚合传感器值的情况,其中每个传感器值都需要经过验证以识别警报。警报当然是由关键值的出现引起的。警报系统并行启动多个线程以验证传递的值(示例 7.1):

record Sensor(int value) {}
class SensorAlarmWorker implements Runnable {
    ...
    @Override
    public void run() {
        ....
        while (active) {
            ..
            Collection<Sensor> set = provider.values();
            for (Sensor e : set) {
                SensorAlarmSystemUtil.evaluateAlarm
                  (provider, e.value(), measurementCount);}
            ...
       }
    }
}

示例 7.1 – SensorAlarmWorker 实例尝试通过读取 Sensor 实例值来识别警告信号

车辆传感器警报系统显然必须分析其各种传感器提供的海量数据,以识别关键信号。自动装箱问题往往非常明显,因为它会导致密集和非确定性的垃圾回收(图 7**.1):

图 7.1 – 繁重的垃圾回收导致明显的延迟

图 7.1 – 繁重的垃圾回收导致明显的延迟

虽然垃圾回收的根本原因似乎已经解决,但它可能会意外出现。示例 7.1介绍了Sensor记录类,它以原始int类型持有整数值。当原始类型的值在传递给需要使用Integer包装类的evaluateAlarm方法时自动装箱,问题就会显现出来。让我们对Sensor值类型进行一行修正(示例 7.2):

....
static void evaluateAlarm(Map<Integer, Sensor> storage,
    Integer criticalValue, long measurementNumber) {
...
record Sensor(Integer value) {}

示例 7.2 – 将 Sensor 字段值更改为 Integer 类型,并与方法输入类型相对应

这次更改对整个应用程序产生了相当显著的影响,导致垃圾回收的发生非常有限。换句话说,消除了由于停止世界事件(正如我们在第二章**,《发现 Java 平台设计模式》*中了解到)引起的不必要的延迟,从而加快了整个应用程序的运行速度:

图 7.2 – 由于应用程序没有创建不必要的短生命周期对象,垃圾收集压力消失了

图 7.2 – 由于应用程序没有创建不必要的短生命周期对象,垃圾收集压力消失了

有时可以通过代码审查识别出自动装箱,这在移除代码异味反模式中起着至关重要的作用。

Java 平台包含许多有用的工具,如果使用不当,可能会导致不希望的状态。让我们在下一节中看看这些工具中的一些。

选择合适的工具

下一个例子乍一看可能似乎与代码异味相去甚远。Java 平台包含非常有用的工具,如果它们被正确选择和使用,可以为应用程序提供良好的服务。集合框架提供了一个很好的例子,说明了谨慎选择的重要性。第二章,“发现 Java 平台的设计模式”,回顾了常用集合类型的各个方面。这使我们看到,错误的集合选择可能会导致由于底层资源的消耗而形成瓶颈。这种问题在小数据量下可能不明显,但在更大的负载下出现,并击中应用程序的非常特定的部分。这种现象可以称为繁忙方法热点方法(图 7.3)。

图 7.3 – 计算时间受繁忙方法执行限制

图 7.3 – 计算时间受繁忙方法执行限制

在这里,应用程序的计算工作集中在一种极其繁忙的方法执行上。ArrayListO(n)时间复杂度与HashSet实例的O(1)时间复杂度(图 7.4):

图 7.4 – 收集交换导致所需的计算工作分布

图 7.4 – 收集交换导致所需的计算工作分布

修正结果导致更大的应用程序吞吐量,这对于试图在旅途中评估收集数据的车辆数据分析器来说是非常理想的。

虽然代码异味并不明显,但我们之所以发现它,是因为我们使用了正确的工具。让我们总结一下从前几节中我们可以学到的东西。

代码异味反模式的结论

在本章中,我们发现任何试图挑战理论原则的尝试都会导致一个被称为代码异味反模式。

我们回顾了一个案例,其中未知的代码异味对应用程序的目标构成了威胁。我们讨论了在尝试解决瓶颈之前,需要在其上下文中理解每个瓶颈。在不理解重要细节的情况下移除这种瓶颈可能会导致另一个反模式和重构的无限循环,正如所展示的示例(图 7.1 和图 7.3)所示。

另一种无效的做法是在没有解决潜在的技术债务的情况下优化代码库,这可能会导致应用吞吐量下降的问题,也可能导致不希望的行为。

在继续探讨一些最著名的软件反模式之前,让我们做一些最后的考虑。任何反模式都可能是由于迁移过程中产生了技术债务,包括错误的信息。它们的出现可能是由于选择了不正确的平台工具或对 Java 编程语言背后的理论缺乏认识。由于这个话题可能会引起争议,我将留给你们自己得出结论。

检查典型的软件反模式

关于这个主题的文献充满了各种各样的反模式,其中一些有着非常有趣的名字,尽管它们的影响远非有趣。有时,反模式可能是由于在向同事提供经过测试、结构良好和可维护的代码时缺乏纪律。今天在这个领域中经常使用的一个术语是清洁代码。以下几节将探讨一些在代码库中可以找到的常见反模式,特别是方法实现中的反模式。

意面代码

多个因素可能导致应用程序代码库看起来非常无结构:这是代码恶臭的第一个迹象。在这种情况下,最著名的反模式之一,意面代码往往会出现。由于接口仍然保持一致,意面代码可能仍然被忽视,但它们的实现将包含长方法,具有相互关联的依赖关系(示例 7.3):

class VehicleSpaghetti {
    void drive(){
        /*
          around 100 lines of code
          heavily using the if-else construct
         */
    ...

示例 7.3 – 一个不清晰的 drive()方法,其中包含从引擎控制到刹车检查的所有逻辑

处于这种状态时,几乎不可能扩展应用程序或验证其功能。有时,这样的代码可能成为遗留代码,人们随后会以此为借口。这样的借口不会有助于应用程序的成功;解决方案在于重构和清理代码库。

剪贴板编程

这种反模式可能是最常见的之一,其中以前开发的代码被用来应对下一个挑战。这看起来像是一种聪明的代码重用,但它可能很快就会变成一个维护噩梦,因为初始的实现条件被完全忽视了。这是一个问题,尤其是当初始代码已经存在反模式倾向并且忽略了前几章中提到的原则时。为了提供良好的保护,应该明智地重用已经生成的代码。

这种模式可以在许多较老的单一系统中识别出来。如今,开发者会声称这个问题已经得到了解决。但事实并非如此确定。甚至框架也无法抵御这种反模式。一个包含最基本的神圣类集合或仅包含一个,即上帝类的包,也可能表现出这种反模式。这种反模式通常存在于被称为控制器的类中,它们控制着应用程序的整个行为。这样的控制器积累了大量具有不同功能的不同方法,意味着关注点的分离最终可能会被遗忘(示例 7.4):

class VehicleBlob {
    void drive(){}
    void initEngine(){}
    ...
    void alarmOilLevel(){}
    void runCylinder() {}
    void checkCylinderHead(){}
    void checkWaterPump(){}
}

示例 7.4 – VehicleBlob 实例尝试控制每个可能的部分

关于代码库假设可维护性的反论可能被认为是无效的,因为这样的代码可能难以测试,甚至可能无法测试。当单例设计模式被过度使用时,也可能出现类似的问题。一旦识别出 blob 反模式,就是开始思考在为时已晚之前通过重构源代码创建简单图表的大好时机。

火山熔岩流

清洁代码有时被用作一个深层次的术语。如今,直接将概念验证应用程序投入生产而不做进一步思考已成为一种常见的做法。当出现不兼容或可扩展性问题,这种反模式就会出现。概念验证验证了一个可能的解决方案,但这并不保证它已经根据常见的开发原则和技术为生产做好了准备。这种反模式可以通过出现长期实现类来识别,随着时间的推移,其目的已经消失,但每个人都害怕删除它们,因为可能会影响系统。这种反模式是以火山熔岩命名的,火山熔岩是一种热液,它沿着火山流下直到着火。在微服务、分布式系统和云解决方案的时代,共享功能,如库或解决方案的例子,可以考虑。当这种模式在开发过程中出现时,可能就是重新评估代码库设计的大好时机,也许可以绘制一些图表,并应用结论来减轻火灾的可能性。

功能分解

由于使用了现代框架以及反模式在过程语言领域的知名度更高,功能分解反模式可能看起来已经过时。但现实可能略有不同,因为许多遗留系统在没有充分理解代码库和业务逻辑的情况下进行了迁移。识别这种反模式是微不足道的,因为不可能不注意到包含许多具有单一职责、缺乏抽象和高度内聚的类的代码库。这种反模式的根本原因可能是对面向对象编程的基本原则理解不足或对应用程序目标的误解。解决方案是根据所需的抽象级别重构代码库,同时牢记编程原则。

船锚

有时,一个应用程序或新开发的软件可能继承了一个过时的抽象,这变得不再必要。这种抽象可能成为瓶颈,不仅因为它需要维护,而且因为它可以很容易地在代码库中广泛复制。最坏的情况可能是这种抽象在共享库或应用程序模块中的大量使用。这种反模式可能会加速应用程序代码库在各个层面的退化。

一种减轻这种情况的简单方法是在心中牢记 SOLID 设计和 APIE 原则,以允许持续重构。这使可以利用之前学到的设计模式。

结论

能够识别和描述一些常见的偏离已知原则和方法的偏差,为任何项目带来价值。在本小节中,我们研究了反模式并提出了保持代码库可维护和可读性的解决方案。本节最后一点是关于方法、字段和类的适当命名,这可以显著提高可读性和可维护性,并限制对 API 使用的误解。适当的命名也很重要,因为它允许对 UML 图有一个良好的理解。下一节将带我们更深入地了解源代码架构。

理解软件架构反模式

对类、包和模块组成的清晰理解不仅对应用程序本身至关重要,正如我们在第二章中学习的,“发现 Java 平台设计模式”,对平台也是如此。多亏了 JIT 编译器处理的字节码的动态翻译,Java 平台收集了有关其优化的关键信息。糟糕的代码质量和软件架构可能导致延迟、不正确的内存使用或崩溃。让我们了解可能的障碍。

金锤

在一段时间内应用一种经过验证的方法,而不探索替代方案,很容易变成遗留代码。难以接受其他提议或迁移的事实可能是因为一组特定的方法,也称为“黄金锤”反模式,其中开发者认为,当某事物已经连续几年运行良好时,没有必要调查对其进行的更改是否会有益。

可扩展性不仅受到前面提到的 CAP 定理的惩罚,还可能因为几乎无法在设计模块或应用程序部分之间使用而受到影响。

在你的应用架构中使用特定供应商的产品并不一定是问题。挑战在于,应用程序的开发完全依赖于供应商提供的功能和能力,而没有评估自身的功能。

一种可能的解决方案是重新评估当前的开发方法,并通过有效研究解决方案来允许改进。

持续过时

改进是不可避免的。今天,产品可以利用自动化部署或持续集成支持,以及各种不同的测试场景。改进的速度正在迅速增加。一个很好的例子是 Java 平台,它最近将其发布周期缩短到 6 个月。这一事实可能导致反模式的出现,因为需要重构,但另一方面,它对消除以前的缺点有巨大的影响。

持续过时反模式可以通过无法使用持续集成和交付将项目推进到下一阶段来轻松识别(示例 7.5):

interface VehicleCO {
    void checkEngine();
    void initSystem();
    void initRadio(); /* never used */
    void initCassettePlayer(); /* never used */
    void initMediaSystem(); /* actual logic */
}

示例 7.5 – VehicleCO 抽象包含需要测试的过时方法

当然,持续交付和集成的概念并不提供任何关于代码库清晰度的保证,因为它们需要遵循开发纪律。持续关注清洁代码的审查、面向对象原则和适当的模式可以大大减少持续过时的发生,并对整个应用程序架构产生重大影响。

输入修补

虽然一开始可能并不明显,但输入修补反模式相当常见。一个很好的例子是几个相互连接的服务,直到其中一个开始偏离其功能才进行测试。一个快速的临时解决方案产生了更多副作用,由于禁用测试而导致的长期延迟才被认识到。各种服务已经应用了比其他服务更多的补丁,因此禁用了更多测试。事实仍然是,那些禁用的测试对于维护应用程序的完整性至关重要。

解决方案可能是保持测试纪律,确保测试输入和输出有效且更新,而不是关闭必要的测试。

在雷区工作

完全的单一应用程序时代已经过去了。当前应用程序的分布式特性要求应用测试以实现持续交付和重构。尽管一个应用程序可能包含针对已知问题的测试,但无法保证完全的完整性。如果对应用程序进行了增强但未进行测试,会发生什么?即使是代码库中微小的贡献也可能变成一场噩梦——与这样的代码库合作就像在雷区中行走。解决问题的方案非常明显:需要进行重构。通过简化的测试将受影响的部分隔离以获得稳定性,应用所有学到的知识,并逐渐继续扩展测试库。

模糊的观点

在微服务和分布式系统设计方法的时代,这种反模式可能会对最终结果造成重大损害。这种反模式的持续存在可能导致洋葱架构方法,其中关注点的分离和其他 SOLID 原则成为理论而不是实践。这种反模式的一个指标是创建不明确的服务,随后是实体,用于在层之间传递冗余信息,使总体架构不明确。在早期阶段,这种反模式可以通过所提供的设计模型不支持任何 SOLID 应用程序设计原则来识别,因为这些模型由于信息不完整或模型和潜在软件设计中的不明确和重叠视角而存在。解决方案可以通过使用建模技术,如 UML,来确保视觉清晰性和源代码透明度来有效执行。

鬼火

这种反模式不能完全忽视。如果你能发现一些预料之外突然出现又消失的特征,就可以识别出这种反模式。这种反模式是极其复杂的抽象和实现不必要的类所导致的结果。Java 平台上有一些框架可以提供无用的功能。我们可以将 AspectJ 和 AOP 视为很好的例子,因为它们的使用可能直接导致了神秘的副作用。解决方案是重新审视和理解类层次结构以及生命周期。

死胡同

IT 行业的发展似乎势不可挡,随着越来越多的技术改进方法和程序的出现。因此,对系统架构中先前构建的非更新组件的依赖可能是有害的,并且可能比预期的更难移除。以 Java 版本迁移为例,继续使用旧版本不仅会失去支持,还会增加维护成本。尝试扩展应用程序也可能导致许多与应用程序可测试性相关的问题。

尽管这种死胡同反模式可以转变为可接受的软件设计,但建议考虑替代方案,因为这样做可能会带来重大成本。

结论

列出的并非所有反模式都是完全不好的(例如,参见黄金锤或死胡同),但通常,任何反模式在接受和记录之前都应该重新评估。

Java 是一种非常强大的语言和平台,不仅因为它允许使用实例突变,还因为它使实体能够保持其状态并保持不可变。在并发应用程序的情况下,应特别注意代码库的状态,因为这些应用程序不仅需要对软件工程师透明,还需要对 Java 平台透明,正如我们在第二章,“发现 Java 平台的设计模式”中所学到的。并发环境一方面带来了许多可能性和性能改进,但另一方面,它可能导致对某些设计模式(如双重检查锁定模式)的误用,这归因于对平台(而不是使用该平台的框架)的误解。

缺乏测试覆盖率、编码纪律、信息或能力也可能显著导致反模式的出现。从代码库架构的角度来看,验证正确实现的函数可能变得不可能,或者对模拟或调试造成额外复杂性。一般来说,透明的代码架构是成功应用程序的关键之一。让我们回顾一下我们所学的所有内容。

摘要

了解反模式和它们的识别方法可以对应用程序的可行性产生重大影响,尤其是在分布式系统方面。本章已表明,如果对 Java 平台及其工具了解不够充分,可能会导致意外结果,例如在副线程上运行的垃圾收集算法承受巨大压力,以及错失优化机会。认识到 Java 平台的并发特性可以导致适当的代码结构,但同时也需要正确使用不可变性以实现持续的应用程序开发,即重构。

由于大多数反模式的一个根本原因就是缺乏测试,因此测试环境可能是确定反模式发生或重构需求的最佳起点。编译后的测试代码不位于部署代码中,这使得测试代码成为未来探索和理解应用程序行为的最佳起点。

Java 平台和其他使用的库变化迅速,维护应用程序代码库的关键之一是正确使用开闭原则。它使持续重构成为可能,这对于健康代码库的演变至关重要。

反模式是应用程序生命周期的一部分。它们存在,并且可以以各种形式存在,以促进进步。可能不值得完全移除它们;可能更好的方法是要理解它们,并使代码库达到期望的状态,同时持续解决已知的限制。在整个应用程序的开发周期中,将进行改进,编写代码将变成一个愉快的体验,许多挑战都将得到解决。

虽然 Java 平台仍面临挑战,但它仍然是一块美丽的软件,它同时使用了数学、统计学和概率科学!

恭喜您成功完成这本书。因为每个结束都带来一个新的开始,我鼓励您保持灵感和渴望,并在编码或设计软件中享受很多乐趣!保持您的思想开放,让它成为有价值信息的来源。

诚挚地,米罗·温格纳(Miro Wengner)!

进一步阅读

  • 设计模式:可重用面向对象软件的元素》由埃里希·伽玛(Erich Gamma)、理查德·赫尔姆(Richard Helm)、拉尔夫·约翰逊(Ralph Johnson)和约翰·弗利斯(John Vlissides)著,Addison-Wesley,1995

  • 设计原则与设计模式》由罗伯特·C·马丁(Robert C. Martin)著,Object Mentor,2000

  • 反模式:重构软件、架构和危机项目》由威廉·J·布朗(William J. Brown)、拉斐尔·C·马尔维乌(Raphael C. Malveau)、海斯·W·麦科密克三世(Hays W. McCormick III)和托马斯·J·莫布里(Thomas J. Mowbray)著,约翰·威利与 Sons, Inc, 1998

  • CAP 十二年后:规则如何改变》,www.infoq.com/articles/cap-twelve-years-later-how-the-rules-have-changed,2012

  • 凤凰项目:一部关于 IT、DevOps 和帮助您的业务获胜的小说》由吉恩·金(Gene Kim)、凯文·贝赫(Kevin Behr)和乔治·斯帕福德(George Spafford)著,IT 革命出版社,2016

  • 委员会如何发明?》由梅尔文·爱德华·康威(Melvin Edward Conway)著,Datamation 14,第 5 页,第 28-31 页,1968

  • 任务控制 项目》,github.com/openjdk/jmc

评估

第一章 – 进入软件设计模式

  1. 编译器将 Java 代码编译成字节码,分别由 JVM 和 JRE 执行(参见图 1.3)。

  2. 它指的是抽象、多态、继承和封装。

  3. 方法重写和方法重载。

  4. SOLID 原则:单一职责原则、开闭原则、里氏替换原则、接口隔离原则和依赖倒置原则。

  5. 程序应该对扩展开放,对修改封闭。

  6. 设计模式代表了常用问题和解决方案的集合,以产生可维护的软件。

第二章 – 发现 Java 平台的设计模式

  1. Java 虚拟机JVM)、Java 运行时环境JRE)和Java 开发工具包JDK)。

  2. Java 是一种静态类型语言,这意味着任何值在可以分配给值之前都需要声明。

    1. 原始类型:booleanbyteshortcharintfloatlongdouble
  3. 垃圾收集器。

  4. QueueSetList

  5. 键值对。

  6. 在 O-notation O(1)。

  7. 在 O-notation O(n)。

  8. Predicate<T>的返回类型是原始类型,boolean

  9. Java Stream API 中的元素流是延迟评估的。

第三章 – 与创建型设计模式一起工作

  1. 创建型设计模式通过将其委托给应用程序的负责部分来帮助抽象对象实例化过程。

    1. 为了减少创建新对象的成本,可以考虑使用依赖注入、延迟初始化和对象池模式。
  2. JVM 中只需要存在一个实例。

  3. 建造者模式有助于创建类似对象类型的配置,同时减少构造函数的数量。

  4. 应该考虑工厂方法模式或抽象工厂模式,因为两者都可以在不向客户端暴露逻辑的情况下组合复杂对象。

  5. 对象池设计模式引入了一个已创建和可重用对象的缓存,而不是分配和销毁新实例。

  6. 创建特定家族对象最有用的模式是工厂方法模式。

第四章 – 应用结构设计模式

    1. 结构设计模式定义了对象之间的通信。这些模式支持实现灵活性和透明性。
  1. GoF 作者组描述的结构设计模式包括适配器、桥接、组合、代理、享元、外观和装饰器模式。

  2. 组合结构设计模式,同时也保证了对象处理的统一性。

  3. 标记模式,充分意识到其缺点。

  4. 代理模式需要考虑,因为适配器和外观模式有略微不同的目的。

  5. 桥接模式。

第五章 – 行为设计模式

  1. 第一章中探讨的 Liskov 替换原则,进入软件 设计模式

  2. 迭代器模式。

  3. 是的 – 策略模式。

  4. 这是空对象模式,它提供了此类状态的类型,并限制了空指针异常的原因。

  5. 这可以是管道模式,map()filter()方法的策略模式,或者空对象模式。

  6. 可以通过使用观察者模式来通知所有客户端,该模式还透明地控制条件。

  7. 可以使用命令模式。命令由一个唯一的对象表示。对象允许客户端传递参数,并且可以轻松调用回调函数。

第六章 – 并发设计模式

  1. 双重检查单例模式解决的问题是在运行中的 JVM 中确保只有一个类实例存在,以避免可能的泄漏。

  2. 使用位于java.base模块和java.util.concurrent包中的Executors实用程序。

  3. 阻塞模式取决于实例状态

  4. 调度模式

  5. 生产者-消费者模式是其中最常见的一种并发设计模式,其逻辑被明确分离和解决

posted @ 2025-09-11 09:43  绝不原创的飞龙  阅读(1)  评论(0)    收藏  举报