C--和--NETCore-设计模式实用指南-全-

C# 和 .NETCore 设计模式实用指南(全)

原文:zh.annas-archive.org/md5/99BBE5B6F8F1801CD147129EA46FD82D

译者:飞龙

协议:CC BY-NC-SA 4.0

前言

本书的目的是让读者对现代软件开发中的模式有一个广泛的理解,同时通过具体示例深入了解更多细节。在开发解决方案时使用的模式数量是庞大的,而且通常开发人员在不知情的情况下使用模式。本书涵盖了从低级代码到在云中运行的解决方案中使用的高级概念的模式。

尽管许多所呈现的模式不需要特定的语言,但 C#和.NET Core 将用于许多示例。选择 C#和.NET Core 是因为它们的流行和设计,支持从简单的控制台应用程序到大型企业分布式系统的解决方案构建。

本书涵盖了大量的模式,是对许多模式的很好的介绍,同时允许对一组特定模式进行更深入、实践性的探讨。所涵盖的具体模式之所以被选择,是因为它们说明了特定的观点或模式的方面。提供了额外资源的参考,以便读者深入研究特别感兴趣的模式。

从简单的网站到大型企业分布式系统,正确的模式可以决定成功、长寿的解决方案和因性能不佳和成本高而被视为失败的解决方案之间的区别。本书涵盖了许多可以应用于构建解决方案的模式,以处理在商业竞争中所需的不可避免的变化,以及实现现代应用程序所期望的健壮性和可靠性。

这本书是为谁写的

目标受众是在协作环境中工作的现代应用程序开发人员。故意地,这代表了许多不同的背景和行业,因为这些模式可以应用于各种解决方案。由于本书深入代码来解释所涵盖的模式,读者应该具有软件开发背景——本书不应被视为一本如何编程的书,而更像是一本如何更好地编程的书。因此,目标受众将从初级开发人员到高级开发人员、软件架构师和设计师都有,对于一些读者,内容将是新的;对于其他人,它将是一个复习。

本书涵盖的内容

第一章,《.NET Core 和 C#中面向对象编程概述》,包括了面向对象编程OOP)的概述以及它如何应用于 C#。本章作为对 OOP 和 C#的重要构造和特性的复习,包括继承、封装和多态性。

第二章,《现代软件设计模式和原则》,对现代软件开发中使用的不同模式进行了分类和介绍。本章调查了许多模式和目录,如 SOLID、四人帮和企业集成模式,以及软件开发生命周期和其他软件开发实践的讨论。

第三章,《实现设计模式-基础部分 1》,深入探讨了用于在 C#中构建应用程序的设计模式。通过开发一个示例应用程序、测试驱动开发、最小可行产品和四人帮的其他模式来进行说明。

第四章,《实现设计模式-基础部分 2》,继续深入探讨了用于在 C#中构建应用程序的设计模式。还将介绍依赖注入和控制反转的概念,继续探讨包括单例模式和工厂模式在内的设计模式。

第五章,《实现设计模式-.NET Core》,在第三章和第四章的基础上,探讨了.NET Core 提供的模式。将使用.NET Core 框架重新讨论几种模式,包括依赖注入和工厂模式。

第六章,《为 Web 应用程序实现设计模式-第一部分》,继续探索.NET Core,通过继续构建示例应用程序来查看 Web 应用程序开发中支持的特性。本章提供了创建初始 Web 应用程序的指导,讨论了 Web 应用程序的重要特性,并介绍了如何创建 CRUD 网站页面。

第七章,《为 Web 应用程序实现设计模式-第二部分》,继续探讨使用.NET Core 进行 Web 应用程序开发,包括不同的架构模式和解决方案安全模式。还涵盖了身份验证和授权。还添加了单元测试,包括使用 Moq 模拟框架。

第八章,《.NET Core 中的并发编程》,深入讨论了 C#和.NET Core 应用程序开发中的并发性。探讨了 Async/await 模式,以及关于多线程和并发性的部分。还涵盖了并行 LINQ,包括延迟执行和线程优先级。

第九章,《函数式编程实践》,探讨了.NET Core 中的函数式编程。这包括说明支持函数式编程的 C#语言特性,并将其应用于示例应用程序,包括应用策略模式。

第十章,《响应式编程模式和技术》,继续探讨.NET Core Web 应用程序开发,探讨了用于构建响应式和可扩展网站的响应式编程模式和技术。在本章中,探讨了响应式编程的原则,包括响应式和 IObservable 模式。还讨论了不同的框架,包括流行的.NET Rx 扩展,以及Model-view-viewmodel(MVVM)模式的示例。

第十一章,《高级数据库设计和应用技术》,探讨了数据库设计中使用的模式,包括对数据库的讨论。展示了应用命令查询责任分离模式的实际示例,包括使用分类账式数据库设计。

第十二章,《云编程》,探讨了应用程序开发在云解决方案中的应用,包括可扩展性、可用性、安全性、应用程序设计和 DevOps 这五个关键问题。解释了云解决方案中使用的重要模式,包括不同类型的扩展和事件驱动架构、联合安全、缓存和遥测中使用的模式。

附录 A,《杂项最佳实践》,总结了模式的讨论,涵盖了其他模式和最佳实践。这包括用例建模、最佳实践以及空间架构和容器化应用程序等其他模式。

为了充分利用本书

本书假定读者对面向对象编程和 C#有一定的了解。尽管本书涵盖了高级主题,但它并不是一本全面的开发指南。相反,本书的目标是通过提供大量的模式、实践和原则来提高开发人员和设计师的技能水平。使用工具箱的类比,本书通过从低级代码设计到更高级的架构,以及当今常用的重要模式和原则,为现代应用程序开发人员提供了大量工具。

本书介绍了以下主要观点,这些观点是对读者知识的补充:

  • 通过使用 C#7.x 和.NET Core 2.2 的编码示例,了解更多关于 SOLID 原则和最佳实践。

  • 深入理解经典设计模式(四人帮模式)。

  • 使用 C#语言的函数式编程原则及其工作示例。

  • 架构模式(MVC、MVVM)的真实世界示例。

  • 了解原生云、微服务等。

下载示例代码文件

您可以从您在www.packt.com的帐户中下载本书的示例代码文件。如果您在其他地方购买了本书,您可以访问www.packt.com/support并注册,以便文件直接发送到您的邮箱。

您可以按照以下步骤下载代码文件:

  1. 登录或注册www.packt.com

  2. 选择“支持”选项卡。

  3. 单击“下载代码和勘误表”。

  4. 在搜索框中输入书名,然后按照屏幕上的说明操作。

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

  • WinRAR/7-Zip for Windows

  • Zipeg/iZip/UnRarX for Mac

  • 7-Zip/PeaZip for Linux

本书的代码包也托管在 GitHub 上,网址为github.com/PacktPublishing/Hands-On-Design-Patterns-with-C-and-.NET-Core。如果代码有更新,将在现有的 GitHub 存储库上进行更新。

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

实际运行的代码

单击以下链接查看代码实际运行情况:bit.ly/2KUuNgQ

下载彩色图像

我们还提供了一个 PDF 文件,其中包含本书中使用的屏幕截图/图表的彩色图像。您可以在这里下载:static.packt-cdn.com/downloads/9781789133646_ColorImages.pdf

使用的约定

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

CodeInText:表示文本中的代码词、数据库表名、文件夹名、文件名、文件扩展名、路径名、虚拟 URL、用户输入和 Twitter 句柄。例如:“三个CounterA()CounterB()CounterC()方法代表一个单独的票务收集柜台。”

代码块设置如下:

3-counters are serving...
Next person from row
Person A is collecting ticket from Counter A
Person B is collecting ticket from Counter B
Person C is collecting ticket from Counter C

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

public bool UpdateQuantity(string name, int quantity)
{
    lock (_lock)
    {
        _books[name].Quantity += quantity;
    }

    return true;
}

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

dotnet new sln

粗体:表示新术语、重要单词或屏幕上看到的单词。例如,菜单或对话框中的单词会以这种方式出现在文本中。例如:“从创建新产品,您可以添加新产品,而编辑将为您提供更新现有产品的功能。”

警告或重要说明会显示为这样。

提示和技巧会显示为这样。

第一部分:C#和.NET Core 中设计模式的基本要点

在本节中,读者将获得对设计模式的新视角。我们将学习面向对象编程、模式、实践和 SOLID 原则。到本节结束时,读者将准备好创建自己的设计模式。

本节包括以下章节:

  • 第一章,在.NET Core 和 C#中的面向对象编程概述

  • 第二章,现代软件设计模式和原则

第一章:.NET Core 和 C#中 OOP 的概述

20 多年来,最流行的编程语言都是基于面向对象编程(OOP)原则的。OOP 语言的流行主要是因为能够将复杂逻辑抽象成一个称为对象的结构,这样更容易解释,更重要的是在应用程序中更容易重用。实质上,OOP 是一种软件设计方法,即使用包含数据和功能的对象概念来开发软件的模式。随着软件行业的成熟,OOP 中出现了用于常见问题的模式,因为它们在解决相同问题时在不同的上下文和行业中都是有效的。随着软件从大型机移动到客户服务器,然后再到云端,出现了额外的模式,以帮助降低开发成本和提高可靠性。本书将探讨设计模式,从 OOP 的基础到面向云端软件的架构设计模式。

OOP 基于对象的概念。这个对象通常包含数据,称为属性和字段,以及代码或行为,称为方法。

设计模式是软件程序员在开发过程中面临的一般问题的解决方案,是根据经验构建的,这些解决方案经过多位开发人员在各种情况下的试验和测试。使用基于以前活动的模式的好处确保不会一遍又一遍地重复相同的努力。此外,使用模式会增加一种可靠性感,即问题将在不引入缺陷或问题的情况下得到解决。

本章将回顾 OOP 以及它如何应用于 C#。请注意,这只是一个简要介绍,不是 OOP 或 C#的完整入门;相反,本章将详细介绍这两个方面,以便向您介绍后续章节中将涵盖的设计模式。本章将涵盖以下主题:

  • OOP 的讨论以及类和对象的工作原理

  • 继承

  • 封装

  • 多态性

技术要求

本章包含各种代码示例来解释这些概念。代码保持简单,仅用于演示目的。大多数示例涉及使用 C#编写的.NET Core 控制台应用程序。

要运行和执行代码,您需要以下内容:

  • Visual Studio 2019(您也可以使用 Visual Studio 2017 版本 3 或更高版本运行应用程序)

  • .NET Core

  • SQL Server(本章中使用 Express Edition)

安装 Visual Studio

为了运行这些代码示例,您需要安装 Visual Studio 或更高版本(也可以使用您喜欢的 IDE)。要做到这一点,请按照以下说明进行操作:

  1. 从以下链接下载 Visual Studio:docs.microsoft.com/en-us/visualstudio/install/install-visual-studio

  2. 按照链接中包含的安装说明进行操作。有多个版本的 Visual Studio 可用;在本章中,我们使用的是 Windows 版的 Visual Studio。

设置.NET Core

如果您没有安装.NET Core,您需要按照以下说明进行操作:

  1. 从以下链接下载.NET Core:www.microsoft.com/net/download/windows

  2. 按照相关库中的安装说明进行操作:dotnet.microsoft.com/download/dotnet-core/2.2

完整的源代码可以在 GitHub 上找到。本章中显示的源代码可能不完整,因此建议您检索源代码以运行示例(github.com/PacktPublishing/Hands-On-Design-Patterns-with-C-and-.NET-Core/tree/master/Chapter1)。

本书中使用的模型

作为学习辅助,本书将包含许多 C#代码示例,以及图表和图像,以帮助尽可能清楚地描述特定概念。本书不是统一建模语言UML)书;然而,对于了解 UML 的人来说,许多图表应该看起来很熟悉。本节提供了本书中将使用的类图的描述。

在这里,一个类将被定义为包括由虚线分隔的字段和方法。如果讨论重要,可通过-表示私有,+表示公共,#表示受保护,~表示内部来指示可访问性。以下截图通过显示一个带有私有_name变量和公共GetName()方法的Car类来说明这一点:

当展示对象之间的关系时,用实线表示关联,用开放的菱形表示聚合,用填充的菱形表示组合。如果讨论重要,多重性将显示在相关类旁边。以下图表说明了Car类有一个Owner和最多三个Passengers;它由四个Wheels组成:

继承使用实线在基类上显示一个开放的三角形。以下图表显示了Account基类与CheckingAccountSavingsAccount子类之间的关系:

接口的显示方式与继承类似,但它们使用虚线以及额外的<<interface>>标签,如下图所示:

本节概述了本书中使用的模型。选择这种风格/方法是因为希望大多数读者都能熟悉。

面向对象编程和类与对象的工作原理

面向对象编程是指使用类定义的对象的软件编程方法。这些定义包括字段,有时称为属性,用于存储数据和方法以提供功能。第一种面向对象编程语言是称为 Simula 的真实系统模拟语言(en.wikipedia.org/wiki/Simula),于 1960 年在挪威计算中心开发。第一种纯面向对象编程语言诞生于 1970 年,名为 Smalltalk(en.wikipedia.org/wiki/Smalltalk)。这种语言旨在为 Alan Kay 创建的个人计算机 Dynabook(history-computer.com/ModernComputer/Personal/Dynabook.html)编程。从那时起,有几种面向对象编程语言发展而来,最流行的是 Java、C++、Python 和 C#。

面向对象编程是基于包含数据的对象。面向对象编程范式允许开发人员将代码组织成一个称为对象的抽象或逻辑结构。对象可以包含数据和行为。

通过使用面向对象的方法,我们正在做以下事情:

  • 模块化:在这里,一个应用程序被分解成不同的模块。

  • 重用软件:在这里,我们重新构建或组合一个应用程序,使用不同的(即现有的或新的)模块。

在接下来的章节中,我们将更详细地讨论和理解面向对象编程的概念。

解释面向对象编程

早期的编程方法有局限性,通常变得难以维护。面向对象编程提供了一种新的软件开发范式,优于其他方法。将代码组织成对象的概念并不难解释,这对于采用新模式是一个巨大的优势。可以从现实世界中找到许多例子来解释这个概念。复杂的系统也可以用更小的构建块(即对象)来描述。这使开发人员能够单独查看解决方案的各个部分,同时了解它们如何适应整个解决方案。

考虑到这一点,让我们定义一个程序如下:

程序是一系列指令的列表,指示语言编译器该做什么。

正如你所看到的,对象是以一种逻辑方式组织指令的一种方式。回到房子的例子,建筑师的指令帮助我们建造房子,但它们不是房子本身。相反,建筑师的指令是房子的抽象表示。类似的,类定义了对象的特征。然后从类的定义中创建对象。这通常被称为实例化对象

为了更近距离地了解面向对象编程,我们应该提到另外两种重要的编程方法:

  • 结构化编程:这是由 Edsger W. Dijkstra 在 1966 年创造的一个术语。结构化编程是一种解决问题的编程范式,将 1000 行代码分成小部分。这些小部分通常被称为子程序块结构forwhile循环等。使用结构化编程技术的语言包括 ALGOL、Pascal、PL/I 等。

  • 过程式编程:这是从结构化编程派生出来的一种范式,简单地基于我们如何进行调用(也称为过程调用)。使用过程式编程技术的语言包括 COBOL、Pascal 和 C。一个最近的例子是 2009 年发布的 Go 编程语言。

过程调用

程序调用是指一组语句,称为过程,被激活。有时这被称为调用的过程。

这两种方法的主要问题是,一旦程序变得更加复杂和庞大,就不容易管理。更复杂和更大的代码库会使这两种方法变得紧张,导致难以理解和难以维护的应用程序。为了克服这些问题,面向对象编程提供了以下功能:

  • 继承

  • 封装

  • 多态

在接下来的几节中,我们将更详细地讨论这些功能。

继承、封装和多态有时被称为面向对象编程的三大支柱。

在开始之前,让我们讨论一些在面向对象编程中发现的结构。

一个类

是描述对象的方法和变量的组或模板定义。换句话说,类是一个蓝图,包含了对所有类实例(称为对象)通用的变量和方法的定义。

让我们看一下以下代码示例:

public class PetAnimal
{
    private readonly string PetName;
    private readonly PetColor PetColor;

    public PetAnimal(string petName, PetColor petColor)
    {
        PetName = petName;
        PetColor = petColor;
    }

    public string MyPet() => $"My pet is {PetName} and its color is {PetColor}.";
}

在前面的代码中,我们有一个名为PetAnimal的类,其中有两个名为PetNamePetColor的私有字段,以及一个名为MyPet()的方法。

一个对象

在现实世界中,对象共享两个特征,即状态和行为。换句话说,我们可以说每个对象都有一个名字,颜色,等等;这些特征只是对象的状态。让我们以任何类型的宠物为例:狗和猫都有一个名字,它们被称为。所以,以这种方式,我的狗叫 Ace,我的猫叫 Clementine。同样,狗和猫有特定的行为,例如,狗会叫,猫会喵喵叫。

解释面向对象编程部分,我们讨论了面向对象编程是一种旨在将状态或结构(数据)与行为(方法)结合起来以提供软件功能的编程模型。在之前的例子中,宠物的不同状态构成了实际数据,而宠物的行为则是方法。

对象通过属性存储信息(即数据),并通过方法展示其行为。

在面向对象的语言(如 C#)中,对象是类的一个实例。在我们之前的例子中,现实世界中的对象Dog将是PetAnimal类的一个对象。

对象可以是具体的(即现实世界中的对象,如狗或猫,或任何类型的文件,如物理文件或计算机文件),也可以是概念性的,如数据库模式或代码蓝图。

以下代码片段显示了一个对象包含数据和方法,以及如何使用它:

namespace OOPExample
{
    class Program
    {
        static void Main(string[] args)
        {
            Console.WriteLine("OOP example");
            PetAnimal dog = new PetAnimal("Ace", PetColor.Black);
            Console.WriteLine(dog.MyPet());
            Console.ReadLine();
            PetAnimal cat = new PetAnimal("Clementine", PetColor.Brown);
            Console.WriteLine(cat.MyPet());
            Console.ReadLine();
        }
    }
}

在上面的代码片段中,我们创建了两个对象:dogcat。这些对象是PetAnimal类的两个不同实例。可以看到,包含有关于动物的数据的字段或属性是通过构造方法赋值的。构造方法是用于创建类的实例的特殊方法。

让我们在下图中将这个例子可视化:

上图是我们之前代码示例的图示表示,我们创建了两个不同的DogCat对象,它们属于PetAnimal类。图示相对容易理解;它告诉我们Dog类的对象是PetAnimal类的一个实例,Cat对象也是如此。

关联

对象关联是面向对象编程的一个重要特性。现实世界中对象之间存在关系,在面向对象编程中,关联允许我们定义拥有关系;例如,自行车拥有骑手或猫拥有鼻子。

拥有关系的类型如下:

  • 关联:关联用于描述对象之间的关系,不涉及所有权的描述,例如汽车和人之间的关系。汽车和人之间有一个关系,比如司机。一个人可以驾驶多辆汽车,一辆汽车也可以被多个人驾驶。

  • 聚合:聚合是关联的一种特殊形式。与关联类似,对象在聚合中有自己的生命周期,但它涉及所有权。这意味着子对象不能属于另一个父对象。聚合是一种单向关系,对象的生命周期彼此独立。例如,子对象和父对象的关系是一种聚合,因为每个子对象都有一个父对象,但并不是每个父对象都有一个子对象。

  • 组合:组合指的是一种依赖关系;它代表了两个对象之间的关系,其中一个对象(子对象)依赖于另一个对象(父对象)。如果父对象被删除,所有子对象将自动被删除。让我们以房子和房间为例。一个房子有多个房间,但一个房间不能属于多个房子。如果我们拆除了房子,房间将自动被删除。

让我们通过扩展之前的宠物示例并引入PetOwner类来在 C#中说明这些概念。PetOwner类可以与一个或多个PetAnimal实例相关联。由于PetAnimal类可以存在有或没有主人,所以这种关系是一种聚合。PetAnimalPetColor相关联,在这个系统中,只有当PetColorPetAnimal相关联时,PetColor才存在,使得关联成为一种组合。

以下图示说明了聚合和组合:

上述模型是基于 UML 的,可能对你来说不太熟悉;所以,让我们指出一些关于图表的重要事项。类由一个包含类名以及其属性和方法(用虚线分隔)的方框表示。现在先忽略名称前面的符号,例如+-,因为我们将在后面讨论封装时涵盖访问修饰符。关联关系用连接类的线表示。在组合的情况下,父类的一侧使用实心菱形,而聚合的情况下,父类的一侧使用空心菱形。此外,注意图表支持表示可能的子类数量的多重性值。在图表中,PetOwner类可以有0个或更多个PetAnimal类(注意*****表示关联数量没有限制)。

UML

UML 是一种专门为软件工程开发的建模语言。它已经发展了 20 多年,由对象管理组OMG)管理。你可以参考www.uml.org/了解更多细节。

接口

在 C#中,接口定义了一个对象包含的内容,或者说它的契约;特别是对象的方法、属性、事件或索引。然而,接口不提供实现。接口不能包含属性。这与基类形成对比,基类既提供了契约又提供了实现。实现接口的类必须实现接口中指定的所有内容。

抽象类

抽象类是接口和基类之间的混合体,因为它既提供实现和属性,也提供必须在子类中定义的方法。

签名

术语签名也可以用来描述对象的契约。

继承

面向对象编程中最重要的概念之一是继承。类之间的继承允许我们定义一个是一种关系;例如,汽车是一种车辆。这个概念的重要性在于它允许相同类型的对象共享相似的特征。假设我们有一个在线书店管理不同产品的系统。我们可能有一个类用于存储关于实体书的信息,另一个类用于存储关于数字或在线书的信息。两者之间相似的特征,比如名称、出版商和作者,可以存储在另一个类中。然后实体书和数字书类可以继承自另一个类。

在继承中有不同的术语来描述类:子类派生类继承自另一个类,而被继承的类可以被称为父类基类

在接下来的部分,我们将更详细地讨论继承。

继承的类型

继承帮助我们定义一个子类。这个子类继承了父类或基类的行为。

在 C#中,继承是用冒号(:)来表示的。

让我们来看看不同类型的继承:

  • 单继承:作为最常见的继承类型,单继承描述了一个类从另一个类派生出来的情况。

让我们重新审视之前提到的PetAnimal类,并且使用继承来定义我们的DogCat类。通过继承,我们可以定义一些两者共有的属性。例如,宠物的名字和颜色是共有的,所以它们会位于一个基类中。猫或狗的具体信息会在特定的类中定义;例如,猫和狗发出的声音。下图展示了一个PetAnimal基类和两个子类:

C#只支持单继承。

  • 多重继承:多重继承发生在派生类继承多个基类的情况下。诸如 C++的语言支持多重继承。C#不支持多重继承,但我们可以通过接口实现类似多重继承的行为。

您可以参考以下帖子了解有关 C#和多重继承的更多信息:

blogs.msdn.microsoft.com/csharpfaq/2004/03/07/why-doesnt-c-supportmultiple-inheritance/

  • 分层继承:当多个类从另一个类继承时发生分层继承。

  • 多级继承:当一个类从已经是派生类的类中派生时,称为多级继承。

  • 混合继承:混合继承是多种继承的组合。

C#不支持混合继承。

  • 隐式继承:.NET Core 中的所有类型都隐式继承自System.Object类及其派生类。

封装

封装是面向对象编程中的另一个基本概念,其中类的细节,即属性和方法,可以在对象外部可见或不可见。通过封装,开发人员提供了关于如何使用类以及如何防止类被错误处理的指导。例如,假设我们只允许使用AddPet(PetAnimal)方法添加PetAnimal对象。我们可以通过将PetOwner类的AddPet(PetAnimal)方法设置为可用,同时将Pets属性限制为PetAnimal类之外的任何内容来实现这一点。在 C#中,通过将Pets属性设置为私有,这是可能的。这样做的一个原因是,如果需要在添加PetAnimal类时需要额外的逻辑,例如记录或验证PetOwner类是否可以拥有宠物。

C#支持可以在项上设置的不同访问级别。项可以是类、类的属性或方法,或枚举:

  • Public:表示该项可以在外部访问。

  • Private:表示只有对象可以访问该项。

  • Protected:表示只有对象(以及扩展了该类的类的对象)可以访问属性或方法。

  • Internal:表示只有同一程序集中的对象可以访问该项。

  • Protected Internal:表示只有对象(以及扩展了该类的类的对象)可以在同一程序集中访问属性或方法。

在下图中,访问修饰符已应用于PetAnimal

例如,宠物的名称和颜色被设置为私有,以防止外部访问PetAnimal类。在这个例子中,我们限制了PetNamePetColor属性,所以只有PetAnimal类才能访问它们,以确保只有基类PetAnimal可以更改它们的值。PetAnimal的构造函数被保护,以确保只有子类可以访问它。在这个应用程序中,只有与Dog类相同的库中的类才能访问RegisterInObedienceSchool()方法。

多态性

使用相同接口处理不同对象的能力称为多态性。这为开发人员提供了通过编写单个功能来构建灵活性的能力,只要它们共享一个公共接口,就可以应用于不同的形式。在面向对象编程中有不同的多态性定义,我们将区分两种主要类型:

  • 静态或早期绑定:当应用程序编译时发生这种形式的多态性。

  • 动态或晚期绑定:当应用程序正在运行时发生这种形式的多态性。

静态多态性

静态或早期绑定多态发生在编译时,主要由方法重载组成,其中一个类具有多个具有相同名称但具有不同参数的方法。这通常有助于传达方法背后的含义或简化代码。例如,在计算器中,为不同类型的数字添加多个方法比为每种情况使用不同的方法名更可读;让我们比较以下代码:

int Add(int a, int b) => a + b;
float Add(float a, float b) => a + b;
decimal Add(decimal a, decimal b) => a + b;

在下面的代码中,展示了相同功能的代码,但没有重载Add()方法:

int AddTwoIntegers(int a, int b) => a + b;
float AddTwoFloats(float a, float b) => a + b;
decimal AddTwoDecimals(decimal a, decimal b) => a + b;

在宠物的例子中,主人会使用不同的食物来喂养catdog类的对象。我们可以定义PetOwner类,其中有两个Feed()方法,如下所示:

public void Feed(PetDog dog)
{
    PetFeeder.FeedPet(dog, new Kibble());
}

public void Feed(PetCat cat)
{
    PetFeeder.FeedPet(cat, new Fish());
}

两种方法都使用PetFeeder类来喂养宠物,而dog类被给予Kibblecat实例被给予FishPetFeeder类在泛型部分中描述。

动态多态

动态或后期绑定多态发生在应用程序运行时。有多种情况会发生这种情况,我们将涵盖 C#中的三种常见形式:接口、继承和泛型。

接口多态

接口定义了类必须实现的签名。在PetAnimal的例子中,假设我们将宠物食物定义为提供一定数量的能量,如下所示:

public interface IPetFood
{
    int Energy { get; }
}

接口本身不能被实例化,但描述了IPetFood的实例必须实现的内容。例如,KibbleFish可能提供不同级别的能量,如下面的代码所示:

public class Kibble : IPetFood
{
    public int Energy => 7;
}

public class Fish : IPetFood
{
    int IPetFood.Energy => 8;
}

在上面的代码片段中,Kibble提供的能量比Fish少。

继承多态

继承多态允许在运行时确定功能,类似于接口,但适用于类继承。在我们的例子中,宠物可以被喂食,所以我们可以定义一个新的Feed(IPetFood)方法,它使用之前定义的接口:

public virtual void Feed(IPetFood food)
{
    Eat(food);
}

protected void Eat(IPetFood food)
{
    _hunger -= food.Energy;
}

上面的代码表明,PetAnimal的所有实现都将有一个Feed(IPetFood)方法,子类可以提供不同的实现。Eat(IPetFood food)没有标记为虚拟,因为预期所有PetAnimal对象都将使用该方法,而无需覆盖其行为。它还被标记为受保护,以防止从对象外部访问它。

虚方法不必在子类中定义;这与接口不同,接口中的所有方法都必须被实现。

PetDog不会覆盖基类的行为,因为狗既吃Kibble又吃Fish。而猫更挑剔,如下面的代码所示:

public override void Feed(IPetFood food)
{
    if (food is Fish)
    {
        Eat(food);
    }
    else
    {
        Meow();
    }
}

使用 override 关键字,PetCat将改变基类的行为,导致猫只吃鱼。

泛型

泛型定义了可以应用于类的行为。这种常用形式在集合中使用,无论对象的类型如何,都可以使用相同的处理对象的方法。例如,可以使用相同的逻辑处理字符串列表或整数列表,而无需区分特定类型。

回到宠物,我们可以为喂养宠物定义一个通用类。这个类简单地给宠物和食物喂食,如下面的代码所示:

public static class PetFeeder
{
    public static void FeedPet<TP, TF>(TP pet, TF food) where TP : PetAnimal
                                                    where TF : IPetFood 
    {
        pet.Feed(food); 
    }
}

这里有几件有趣的事情要指出。首先,由于类和方法都被标记为静态,所以类不必被实例化。使用方法签名FeedPet<TP, TF>描述了通用方法。where关键字用于指示对TPTF的额外要求。在这个例子中,where关键字将TP定义为必须是PetAnimal类型,而TF必须实现IPetFood接口。

摘要

在本章中,我们讨论了面向对象编程及其三个主要特征:继承、封装和多态性。使用这些特性,应用程序中的类可以被抽象化,以提供易于理解且受到保护的定义,以防止其被用于与其目的不一致的方式。这是面向对象编程与一些早期类型的软件开发语言(如结构化和过程化编程)之间的重要区别。通过抽象功能,增加了代码重用和维护的能力。

在下一章中,我们将讨论企业软件开发中使用的各种模式。我们将涵盖编程模式以及软件开发原则和在软件开发生命周期SDLC)中使用的模式。

问题

以下问题将帮助您巩固本章中包含的信息:

  1. 术语“晚绑定”和“早绑定”是指什么?

  2. C#支持多重继承吗?

  3. 在 C#中,可以使用什么级别的封装来防止外部库访问类?

  4. 聚合和组合之间有什么区别?

  5. 接口可以包含属性吗?(这有点像是一个陷阱问题。)

  6. 狗会吃鱼吗?

第二章:现代软件设计模式和原则

在上一章中,讨论了面向对象编程OOP),为了探索不同的模式做了准备。由于许多模式依赖于 OOP 中的概念,因此介绍和/或重新访问这些概念非常重要。类之间的继承允许我们定义是一种类型的关系。这提供了更高程度的抽象。例如,通过继承,可以进行比较,比如是一种动物是一种动物。封装提供了一种控制类的细节的可见性和访问性的方法。多态性提供了使用相同接口处理不同对象的能力。通过 OOP,可以实现更高级别的抽象,提供了一种更易于管理和理解的方式来处理大型解决方案。

本章目录和介绍了现代软件开发中使用的不同模式。本书对模式的定义非常宽泛。在软件开发中,模式是软件程序员在开发过程中面临的一般问题的任何解决方案。它们建立在经验之上,是对什么有效和什么无效的总结。此外,这些解决方案经过了许多开发人员在各种情况下的试验和测试。使用模式的好处基于过去的活动,既在不重复努力方面,也在保证问题将被解决而不会引入缺陷或问题方面。

特别是在考虑到技术特定模式时,有太多内容无法在一本书中涵盖,因此本章将重点介绍特定模式,以说明不同类型的模式。我们试图根据我们的经验挑选出最常见和最有影响力的模式。在随后的章节中,将更详细地探讨特定模式。

本章将涵盖以下主题:

  • 包括 SOLID 在内的设计原则

  • 模式目录,包括四人帮GoF)模式和企业集成模式EIP

  • 软件开发生命周期模式

  • 解决方案开发、云开发和服务开发的模式和实践

技术要求

本章包含各种代码示例来解释这些概念。代码保持简单,仅用于演示目的。大多数示例涉及使用 C#编写的.NET Core 控制台应用程序。

要运行和执行代码,您需要以下内容:

  • Visual Studio 2019(您也可以使用 Visual Studio 2017 版本 3 或更高版本运行应用程序)

  • .NET Core

  • SQL Server(本章中使用 Express Edition)

安装 Visual Studio

要运行这些代码示例,您需要安装 Visual Studio,或者您可以使用您喜欢的 IDE。要做到这一点,请按照以下说明进行操作:

  1. 从以下链接下载 Visual Studio:docs.microsoft.com/en-us/visualstudio/install/install-visual-studio

  2. 按照包含的安装说明进行安装。Visual Studio 有多个版本可供安装。在本章中,我们使用的是 Windows 版的 Visual Studio。

设置.NET Core

如果您尚未安装.NET Core,您需要按照以下说明进行操作:

  1. 从以下链接下载.NET Core:www.microsoft.com/net/download/windows

  2. 遵循安装说明和相关库:dotnet.microsoft.com/download/dotnet-core/2.2

完整的源代码可在 GitHub 上找到。本章中显示的源代码可能不完整,因此建议检索源代码以运行示例:github.com/PacktPublishing/Hands-On-Design-Patterns-with-C-and-.NET-Core/tree/master/Chapter2

设计原则

可以说,良好软件开发最重要的方面是软件设计。开发既功能准确又易于维护的软件解决方案具有挑战性,并且在很大程度上依赖于使用良好的开发原则。随着时间的推移,项目初期做出的一些决定可能导致解决方案变得过于昂贵,无法维护和扩展,迫使系统进行重写,而具有良好设计的其他解决方案可以根据业务需求和技术变化进行扩展和调整。有许多软件开发设计原则,本节将重点介绍一些您需要熟悉的流行和重要原则。

DRY – 不要重复自己

不要重复自己DRY)原则的指导思想是重复是时间和精力的浪费。重复可以采取过程和代码的形式。多次处理相同的需求是一种精力浪费,并在解决方案中造成混乱。首次查看此原则时,可能不清楚系统如何最终会重复处理过程或代码。例如,一旦有人确定了如何满足某个需求,为什么其他人还要努力复制相同的功能?在软件开发中存在许多这种情况,了解为什么会发生这种情况是理解这一原则的价值的关键。

以下是代码重复的一些常见原因:

  • 理解不足:在大型解决方案中,开发人员可能不完全了解现有解决方案和/或不知道如何应用抽象来解决现有功能的问题。

  • 复制粘贴:简而言之,代码在多个类中重复,而不是重构解决方案以允许多个类访问共享功能。

KISS – 保持简单愚蠢

与 DRY 类似,保持简单愚蠢KISS)多年来一直是软件开发中的重要原则。KISS 强调简单应该是目标,复杂应该被避免。关键在于避免不必要的复杂性,从而减少出错的可能性。

YAGNI – 你不会需要它

你不会需要它YAGNI)简单地表明功能只有在需要时才应该添加。有时在软件开发中,存在一种倾向,即为设计未来可能发生变化的情况而进行未雨绸缪。这可能会产生实际上当前或未来实际上不需要的需求:

“只有在实际需要时才实现事物,而不是在你预见到需要它时实现。”

- Ron Jeffries

MVP – 最小可行产品

通过采用最小可行产品MVP)方法,一项工作的范围被限制在最小的需求集上,以便产生一个可用的交付成果。MVP 经常与敏捷软件开发结合使用(请参见本章后面的软件开发生命周期模式部分),通过将需求限制在可管理的数量,可以进行设计、开发、测试和交付。这种方法非常适合较小的网站或应用程序开发,其中功能集可以在单个开发周期中进展到生产阶段。

在第三章中,实现设计模式 - 基础部分 1,MVP 将在一个虚构的场景中进行说明,该技术将被用于限制变更范围,并在设计和需求收集阶段帮助团队集中精力。

SOLID

SOLID 是最有影响力的设计原则之一,我们将在第三章中更详细地介绍它,实现设计模式-基础部分 1。实际上,SOLID 由五个设计原则组成,其目的是鼓励更易于维护和理解的设计。这些原则鼓励更易于修改的代码库,并减少引入问题的风险。

在第三章中,实现设计模式-基础部分 1,将更详细地介绍 SOLID 在 C#应用中的应用。

单一责任原则

一个类应该只有一个责任。这一原则的目标是简化我们的类并在逻辑上对其进行结构化。具有多个责任的类更难理解和修改,因为它们更复杂。在这种情况下,责任简单地是变化的原因。另一种看待责任的方式是将其定义为功能的单一部分:

“一个类应该有一个,且仅有一个,改变的理由。”

- Robert C. Martin

开闭原则

开闭原则最好用面向对象编程来描述。一个类应该设计为具有继承作为扩展功能的手段。换句话说,在设计类时应该考虑到变化。通过定义并使用类实现的接口,应用了开闭原则。类是开放进行修改,而其描述,即接口,是关闭进行修改。

里氏替换原则

能够在运行时替换对象是里氏替换原则的基础。在面向对象编程中,如果一个类继承自基类或实现了一个接口,那么它可以被引用为基类或接口的对象。这可以用一个简单的例子来描述。

我们将为动物定义一个接口,并实现两种动物,CatDog,如下所示:

interface IAnimal
{
     string MakeNoise();
}
class Dog : IAnimal
{
   public string MakeNoise()
     {
        return "Woof";
     }
}
class Cat : IAnimal
{
    public string MakeNoise()
    {
        return "Meouw";
    }
}

然后我们可以将CatDog称为动物,如下所示:

var animals = new List<IAnimal> { new Cat(), new Dog() };

foreach(var animal in animals)
{
    Console.Write(animal.MakeNoise());
}

接口隔离原则

与单一责任原则类似,接口隔离原则规定接口应该仅包含与单一责任相关的方法。通过减少接口的复杂性,代码变得更容易重构和理解。遵循这一原则在系统中的一个重要好处是通过减少依赖关系来帮助解耦系统。

依赖反转原则

依赖反转原则(DIP),也称为依赖注入原则,规定模块不应该依赖于细节,而应该依赖于抽象。这一原则鼓励编写松散耦合的代码,以增强可读性和维护性,特别是在大型复杂的代码库中。

软件模式

多年来,许多模式已被编制成目录。本节将以两个目录作为示例。第一个目录是GoF的一组与面向对象编程相关的模式。第二个与系统集成相关,保持技术中立。在本章末尾,还有一些额外目录和资源的参考资料。

GoF 模式

可能最有影响力和知名度的面向对象编程模式集合来自GoF可重用面向对象软件元素的设计模式一书。该书中的模式的目标是在较低级别上,即对象创建和交互,而不是更大的软件架构问题。该集合包括可以应用于特定场景的模板,旨在产生坚实的构建模块,同时避免面向对象开发中的常见陷阱。

Erich Gamma, John Vlissides, Richard HelmRalph Johnson因在 1990 年代的广泛有影响的出版物而被称为 GoF。书籍设计模式:可重用面向对象软件的元素已被翻译成多种语言,并包含 C++和 Smalltalk 的示例。

该收藏分为三类:创建模式、结构模式和行为模式,将在以下部分进行解释。

创建模式

以下五种模式涉及对象的实例化:

  • 抽象工厂:一种用于创建属于一组类的对象的模式。具体对象在运行时确定。

  • 生成器:用于更复杂对象的有用模式,其中对象的构建由构建类外部控制。

  • 工厂方法:一种用于在运行时确定特定类的对象的模式。

  • 原型:用于复制或克隆对象的模式。

  • 单例:用于强制类的仅一个实例的模式。

在第三章中,实现设计模式 - 基础部分 1,将更详细地探讨抽象工厂模式。在第四章中,实现设计模式 - 基础部分 2,将详细探讨单例和工厂方法模式,包括使用.NET Core 框架对这些模式的支持。

结构模式

以下模式涉及定义类和对象之间的关系:

  • 适配器:用于提供两个不同类之间的匹配的模式

  • 桥接:一种允许替换类的实现细节而无需修改类的模式

  • 组合:用于创建树结构中类的层次结构

  • 装饰器:一种用于在运行时替换类功能的模式

  • 外观:用于简化复杂系统的模式

  • 享元:用于减少复杂模型的资源使用的模式

  • 代理:用于表示另一个对象,允许在调用和被调用对象之间增加额外的控制级别

装饰器模式

为了说明结构模式,让我们通过一个示例来更详细地了解装饰器模式。这个示例将在控制台应用程序上打印消息。首先,定义一个基本消息,并附带一个相应的接口:

interface IMessage
{
    void PrintMessage();
}

abstract class Message : IMessage
{
    protected string _text;
    public Message(string text)
    {
        _text = text;
    }
    abstract public void PrintMessage();
}

基类允许存储文本字符串,并要求子类实现PrintMessage()方法。然后将扩展为两个新类。

第一个类是SimpleMessage,它将给定文本写入控制台:

class SimpleMessage : Message
{
    public SimpleMessage(string text) : base(text) { }

    public override void PrintMessage()
    {
        Console.WriteLine(_text);
    }
}

第二个类是AlertMessage,它还将给定文本写入控制台,但也执行蜂鸣:

class AlertMessage : Message
{
    public AlertMessage(string text) : base(text) { }
    public override void PrintMessage()
    {
        Console.Beep();
        Console.WriteLine(_text);
    }
}

两者之间的区别在于AlertMessage类将发出蜂鸣声,而不仅仅像SimpleMessage类一样将文本打印到屏幕上。

接下来,定义一个基本装饰器类,该类将包含对Message对象的引用,如下所示:

abstract class MessageDecorator : IMessage
{
    protected Message _message;
    public MessageDecorator(Message message)
    {
        _message = message;
    }

    public abstract void PrintMessage();
}

以下两个类通过为现有的Message实现提供附加功能来说明装饰器模式。

第一个是NormalDecorator,它打印前景为绿色的消息:

class NormalDecorator : MessageDecorator
{
    public NormalDecorator(Message message) : base(message) { }

    public override void PrintMessage()
    {
        Console.ForegroundColor = ConsoleColor.Green;
        _message.PrintMessage();
        Console.ForegroundColor = ConsoleColor.White;
    }
}

ErrorDecorator使用红色前景色,使消息在打印到控制台时更加显著:


class ErrorDecorator : MessageDecorator
{
    public ErrorDecorator(Message message) : base(message) { }

    public override void PrintMessage()
    {
        Console.ForegroundColor = ConsoleColor.Red;
        _message.PrintMessage();
        Console.ForegroundColor = ConsoleColor.White;
    }
}

NormalDecorator将以绿色打印文本,而ErrorDecorator将以红色打印文本。这个示例的重要之处在于装饰器扩展了引用Message对象的行为。

为了完成示例,以下显示了如何使用新消息:

static void Main(string[] args)
{
    var messages = new List<IMessage>
    {
        new NormalDecorator(new SimpleMessage("First Message!")),
        new NormalDecorator(new AlertMessage("Second Message with a beep!")),
        new ErrorDecorator(new AlertMessage("Third Message with a beep and in red!")),
        new SimpleMessage("Not Decorated...")
    };
    foreach (var message in messages)
    {
        message.PrintMessage();
    }
    Console.Read();
}

运行示例将说明如何使用不同的装饰器模式来更改引用功能,如下所示:

这是一个简化的例子,但想象一种情景,项目中添加了一个新的要求。系统不再使用蜂鸣声,而是应该播放感叹号的系统声音。

class AlertMessage : Message
{
    public AlertMessage(string text) : base(text) { }
    public override void PrintMessage()
    {
        System.Media.SystemSounds.Exclamation.Play();
        Console.WriteLine(_text);
    }
}

由于我们已经有了处理这个的结构,所以修正是一个一行的更改,如前面的代码块所示。

行为模式

以下行为模式可用于定义类和对象之间的通信:

  • 责任链:处理一组对象之间请求的模式

  • 命令:用于表示请求的模式

  • 解释器:一种用于定义程序中指令的语法或语言的模式

  • 迭代器:一种在不详细了解集合中元素的情况下遍历集合的模式

  • 中介者:简化类之间通信的模式

  • 备忘录:用于捕获和存储对象状态的模式

  • 观察者:一种允许对象被通知另一个对象状态变化的模式

  • 状态:一种在对象状态改变时改变对象行为的模式

  • 策略:一种在运行时应用特定算法的模式

  • 模板方法:一种定义算法步骤的模式,同时将实现细节留在子类中

  • 访问者:一种促进数据和功能之间松散耦合的模式,允许添加额外操作而无需更改数据类

责任链

您需要熟悉的一个有用模式是责任链模式,因此我们将以此为例使用它。使用此模式,我们将设置一个处理请求的集合或链。理念是请求将通过每个类,直到被处理。这个例子使用了一个汽车服务中心,每辆汽车将通过中心的不同部分,直到服务完成。

让我们首先定义一组标志,用于指示所需的服务:

[Flags]
enum ServiceRequirements
{
    None = 0,
    WheelAlignment = 1,
    Dirty = 2,
    EngineTune = 4,
    TestDrive = 8
}

在 C#中,FlagsAttribute是使用位字段来保存一组标志的好方法。单个字段将用于指示通过位操作打开的枚举值。

Car将包含一个字段来捕获所需的维护以及一个在服务完成时返回 true 的字段:

class Car
{
    public ServiceRequirements Requirements { get; set; }

    public bool IsServiceComplete
    {
        get
        {
            return Requirements == ServiceRequirements.None;
        }
    }
}

指出的一件事是,一辆“汽车”被认为在所有要求都完成后其服务已完成,这由IsServiceComplete属性表示。

将使用抽象基类来表示我们的每个服务技术人员,如下所示:

abstract class ServiceHandler
{
    protected ServiceHandler _nextServiceHandler;
    protected ServiceRequirements _servicesProvided;

    public ServiceHandler(ServiceRequirements servicesProvided)
    {
        _servicesProvided = servicesProvided;
    }
}

请注意,由扩展ServiceHandler类的类提供的服务,换句话说,技术人员,需要被传递进来。

然后将使用按位NOT操作(~)执行服务,关闭给定Car上的位,指示Service方法中需要服务:

public void Service(Car car)
{
    if (_servicesProvided == (car.Requirements & _servicesProvided))
    {
        Console.WriteLine($"{this.GetType().Name} providing {this._servicesProvided} services.");
        car.Requirements &= ~_servicesProvided;
    }

    if (car.IsServiceComplete || _nextServiceHandler == null)
        return;
    else
        _nextServiceHandler.Service(car);
}

如果汽车的所有服务都已完成和/或没有更多服务,则停止链条。如果有另一个服务并且汽车还没有准备好,那么将调用下一个服务处理程序。

这种方法需要设置链条,并且前面的例子显示了使用SetNextServiceHandler()方法来设置要执行的下一个服务:

public void SetNextServiceHandler(ServiceHandler handler)
{
    _nextServiceHandler = handler;
}

服务专家包括DetailerMechanicWheelSpecialistQualityControl工程师。代表DetailerServiceHandler在以下代码中显示:

class Detailer : ServiceHandler
{
    public Detailer() : base(ServiceRequirements.Dirty) { }
}

专门调校发动机的机械师在以下代码中显示:

class Mechanic : ServiceHandler
{
    public Mechanic() : base(ServiceRequirements.EngineTune) { }
}

以下代码显示了轮胎专家:

class WheelSpecialist : ServiceHandler
{
    public WheelSpecialist() : base(ServiceRequirements.WheelAlignment) { }
}

最后是质量控制,谁将驾驶汽车进行测试:

class QualityControl : ServiceHandler
{
    public QualityControl() : base(ServiceRequirements.TestDrive) { }
}

服务中心的技术人员已经定义好了,下一步是为一些汽车提供服务。这将在Main代码块中进行说明,首先是构造所需的对象:

static void Main(string[] args)
{ 
    var mechanic = new Mechanic();
    var detailer = new Detailer();
    var wheels = new WheelSpecialist();
    var qa = new QualityControl();

下一步将是为不同的服务设置处理顺序:

    qa.SetNextServiceHandler(detailer);
    wheels.SetNextServiceHandler(qa);
    mechanic.SetNextServiceHandler(wheels);

然后将会有两次调用技师,这是责任链的开始:

    Console.WriteLine("Car 1 is dirty");
    mechanic.Service(new Car { Requirements = ServiceRequirements.Dirty });

    Console.WriteLine();

    Console.WriteLine("Car 2 requires full service");
    mechanic.Service(new Car { Requirements = ServiceRequirements.Dirty | 
                                                ServiceRequirements.EngineTune | 
                                                ServiceRequirements.TestDrive | 
                                                ServiceRequirements.WheelAlignment });

    Console.Read();
}

一个重要的事情要注意的是链的设置顺序。对于这个服务中心,技师首先进行调整,然后进行车轮定位。然后进行一次试车,之后对车进行详细的工作。最初,试车是作为最后一步进行的,但服务中心确定,在下雨天,这需要重复进行车辆细节。这是一个有点愚蠢的例子,但它说明了以灵活的方式定义责任链的好处。

上述截图显示了我们的两辆车在接受服务后的显示。

观察者模式

一个值得更详细探讨的有趣模式是观察者模式。这种模式允许实例在另一个实例中发生特定事件时被通知。这样,就有许多观察者和一个单一的主题。以下图表说明了这种模式:

让我们通过创建一个简单的 C#控制台应用程序来提供一个例子,该应用程序将创建一个Subject类的单个实例和多个Observer实例。当Subject类中的数量值发生变化时,我们希望每个Observer实例都能收到通知。

Subject类包含一个私有的数量字段,由公共的UpdateQuantity方法更新:

class Subject
{
    private int _quantity = 0;

    public void UpdateQuantity(int value)
    {
        _quantity += value;

        // alert any observers
    }
}

为了通知任何观察者,我们使用 C#关键字delegateeventdelegate关键字定义了将被调用的格式或处理程序。当数量更新时要使用的委托如下代码所示:

public delegate void QuantityUpdated(int quantity);

委托将QuantityUpdated定义为一个接收整数并且不返回任何值的方法。然后,事件被添加到Subject类中,如下所示:

public event QuantityUpdated OnQuantityUpdated;

UpdateQuantity方法中,它被调用如下:

public void UpdateQuantity(int value)
{
    _quantity += value;

    // alert any observers
    OnQuantityUpdated?.Invoke(_quantity);
}

在这个例子中,我们将在Observer类中定义一个具有与QuantityUpdated委托相同签名的方法:

class Observer
{
    ConsoleColor _color;
    public Observer(ConsoleColor color)
    {
        _color = color;
    }

    internal void ObserverQuantity(int quantity)
    {
        Console.ForegroundColor = _color;
        Console.WriteLine($"I observer the new quantity value of {quantity}.");
        Console.ForegroundColor = ConsoleColor.White;
    }
}

这个实现将在Subject实例的数量发生变化时得到通知,并以特定颜色在控制台上打印一条消息。

让我们将这些放在一个简单的应用程序中。在应用程序开始时,将创建一个Subject和三个Observer对象:

var subject = new Subject();
var greenObserver = new Observer(ConsoleColor.Green);
var redObserver = new Observer(ConsoleColor.Red);
var yellowObserver = new Observer(ConsoleColor.Yellow);

接下来,每个Observer实例将注册以在Subject的数量发生变化时得到通知:

subject.OnQuantityUpdated += greenObserver.ObserverQuantity;
subject.OnQuantityUpdated += redObserver.ObserverQuantity;
subject.OnQuantityUpdated += yellowObserver.ObserverQuantity;

然后,我们将更新数量两次,如下所示:

subject.UpdateQuantity(12);
subject.UpdateQuantity(5); 

当应用程序运行时,我们会得到三条不同颜色的消息打印出每个更新语句,如下截图所示:

这是一个使用 C# event关键字的简单示例,但希望它说明了这种模式如何被使用。这里的优势是它将主题与观察者松散地耦合在一起。主题不必知道不同观察者的情况,甚至不必知道是否存在观察者。

企业集成模式

集成是软件开发的一个学科,它极大地受益于利用他人的知识和经验。考虑到这一点,存在许多 EIP 目录,其中一些是技术无关的,而另一些则专门针对特定的技术堆栈。本节将重点介绍一些流行的集成模式。

企业集成模式,由Gregor HohpeBobby Woolf提供了许多技术上的集成模式的可靠资源。在讨论 EIP 时,经常引用这本书。该书可在www.enterpriseintegrationpatterns.com/上获得。

拓扑

企业集成的一个重要考虑因素是被连接系统的拓扑。一般来说,有两种不同的拓扑结构:中心枢纽和企业服务总线。

中心枢纽(中心枢纽)拓扑描述了一种集成模式,其中一个单一组件,中心枢纽,是集中的,并且它与每个应用程序进行显式通信。这种集中的通信使得中心枢纽只需要了解其他应用程序,如下图所示:

图表显示了蓝色的中心枢纽具有如何与不同应用程序通信的明确知识。这意味着,当消息从 A 发送到 B 时,它是从 A 发送到中心枢纽,然后转发到 B。对于企业来说,这种方法的优势在于,与 B 的连接只需要在一个地方,即中心枢纽中定义和维护。这里的重要性在于安全性在一个中心位置得到控制和维护。

企业服务总线ESB)依赖于由发布者和订阅者(Pub-Sub)组成的消息模型。发布者向总线提交消息,订阅者注册以接收已发布的消息。以下图表说明了这种拓扑:

在上图中,如果要将消息从A路由到BB订阅 ESB 以接收从A发布的消息。当A发布新消息时,消息将发送到B。在实践中,订阅可能会更加复杂。例如,在订购系统中,可能会有两个订阅者,分别用于优先订单和普通订单。在这种情况下,优先订单可能会与普通订单有所不同。

模式

如果我们将两个系统之间的集成定义为具有不同步骤,那么我们可以在每个步骤中定义模式。让我们看一下以下图表,讨论一下集成管道:

这个管道是简化的,因为根据使用的技术,管道中可能会有更多或更少的步骤。图表的目的是在我们查看一些常见的集成模式时提供一些背景。这些可以分为以下几类:

  • 消息传递:与消息处理相关的模式

  • 转换:与改变消息内容相关的模式

  • 路由:与消息交换相关的模式

消息传递

与消息相关的模式可以采用消息构造和通道的形式。在这种情况下,通道是端点和/或消息进入和离开集成管道的方式。一些与构造相关的模式的例子如下:

  • 消息序列:消息包含一个序列,表示特定的处理顺序。

  • 相关标识符:消息包含一个标识相关消息的媒介。

  • 返回地址:消息标识有关返回响应消息的信息。

  • 过期:消息具有被视为有效的有限时间。

拓扑部分,我们涵盖了一些与通道相关的模式,但以下是您在集成中应考虑的其他模式:

  • 竞争消费者:多个进程可以处理相同的消息。

  • 选择性消费者:消费者使用标准来确定要处理的消息。

  • 死信通道:处理未成功处理的消息。

  • 可靠传递:确保消息的可靠处理,不会丢失任何消息。

  • 事件驱动消费者:消息处理基于已发布的事件。

  • 轮询消费者:处理从源系统检索的消息。

转换

在集成复杂的企业系统时,转换模式允许以系统中处理消息的方式灵活处理。通过转换,可以改变和/或增强两个应用程序之间的消息。以下是一些与转换相关的模式:

  • 内容丰富器:通过添加信息来丰富消息。

  • 规范数据模型:将消息转换为应用程序中立的消息格式。

  • 消息转换器:用于将一条消息转换为另一条消息的模式。

规范数据模型CDM)是一个很好的模式来强调。通过这种模式,可以在多个应用程序之间交换消息,而无需为每种特定消息类型执行翻译。这最好通过多个系统交换消息的示例来说明,如下图所示:

在图中,应用程序AC希望以它们的格式将它们的消息发送到应用程序BD。如果我们使用消息转换器模式,只有处理转换的过程需要知道如何从A转换到B,从A转换到D,以及C转换到BC转换到D。随着应用程序数量的增加以及发布者可能不了解其消费者的细节,这变得越来越困难。通过 CDM,AB的源应用程序消息被转换为中性模式 X。

规范模式

规范模式有时被称为中性模式,意味着它不直接与源系统或目标系统对齐。然后将模式视为中立的。

然后将中性模式格式的消息转换为BD的消息格式,如下图所示:

在企业中,如果没有一些标准,这将变得难以管理,幸运的是,许多组织已经创建并管理了许多行业的标准,包括以下示例(但还有许多其他!):

  • 面向行政、商业和运输的电子数据交换EDIFACT):贸易的国际标准

  • IMS 问题和测试互操作规范QTI):由信息管理系统IMS全球学习联盟GLC)制定的评估内容和结果的表示标准

  • 酒店业技术整合标准(HITIS):由美国酒店和汽车旅馆协会维护的物业管理系统标准

  • X12 EDI(X12):由 X12 认可标准委员会维护的医疗保健、保险、政府、金融、交通运输和其他行业的模式集合

  • 业务流程框架eTOM):由 TM 论坛维护的电信运营模型

路由

路由模式提供了处理消息的不同方法。以下是一些属于这一类别的模式示例:

  • 基于内容的路由:路由或目标应用程序由消息中的内容确定。

  • 消息过滤器:只有感兴趣的消息才会转发到目标应用程序。

  • 分裂器:从单个消息生成多个消息。

  • 聚合器:从多个消息生成单个消息。

  • 分散-聚合:用于处理多条消息的广播并将响应聚合成单条消息的模式。

分散-聚合模式是一个非常有用的模式,因为它结合了分裂器和聚合器模式,是一个很好的探索示例。通过这种模式,可以建模更复杂的业务流程。

在我们的场景中,我们将考虑一个小部件订购系统的实现。好消息是,有几家供应商出售小部件,但小部件的价格经常波动。那么,哪家供应商的价格变化最好?使用散点-聚合模式,订购系统可以查询多个供应商,选择最佳价格,然后将结果返回给调用系统。

分流器模式将用于生成多个消息给供应商,如下图所示:

路由然后等待供应商的回应。一旦收到回应,聚合器模式用于将结果编译成单个消息返回给调用应用程序:

值得注意的是,这种模式有许多变体和情况。散点-聚合模式可能要求所有供应商做出回应,也可能只需要其中一些供应商做出回应。另一种情况可能要求该过程等待供应商回应的时间限制。有些消息可能需要毫秒级的回应,而其他情况可能需要几天才能得到回应。

集成引擎是支持许多集成模式的软件。集成引擎可以是本地安装的服务,也可以是基于云的解决方案。一些更受欢迎的引擎包括微软 BizTalk、戴尔 Boomi、MuleSoft Anypoint Platform、IBM WebSphere 和 SAS Business Intelligence。

软件开发生命周期模式

管理软件开发有许多方法,最常见的两种软件开发生命周期(SDLC)模式是“瀑布”和“敏捷”。这两种 SDLC 方法有许多变体,通常组织会根据项目、团队以及公司文化来调整方法论。

瀑布和敏捷 SDLC 模式只是两个例子,还有其他几种软件开发模式,可能比其他模式更适合公司的文化、软件成熟度和行业。

瀑布 SDLC

瀑布方法包括项目或工作逐个经历的明确定义的阶段。从概念上讲,它很容易理解,并且遵循其他行业使用的模式。以下是不同阶段的示例:

  • 需求阶段:收集和记录要实施的所有需求。

  • 设计阶段:使用上一步产生的文档,完成要实施的设计。

  • 开发阶段:使用上一步的设计,实施更改。

  • 测试阶段:对上一步实施的更改进行与指定要求的验证。

  • 部署阶段:测试完成后,项目所做的更改被部署。

瀑布模型有许多优点。该模型易于理解和管理,因为每个阶段都清楚定义了每个阶段必须完成和交付的内容。通过具有一系列阶段,可以定义里程碑,从而更容易地报告进展情况。此外,有了明确定义的阶段,可以更容易地规划所需资源的角色和责任。

但是,如果出现了意外情况或事情发生了变化怎么办?瀑布式 SDLC 确实有一些缺点,其中许多缺点源于其对变更的灵活性不足,或者在发现事情时需要输入之前步骤的情况。在瀑布式中,如果出现需要来自前一阶段信息的情况,前一阶段将被重复。这带来了几个问题。由于阶段可能被报告,因此报告变得困难,因为项目(已通过阶段或里程碑的项目)现在正在重复该阶段。这可能会促进一种“寻找替罪羊”的公司文化,其中努力转向寻找责任,而不是采取措施防止问题再次发生。此外,资源可能不再可用,因为它们已被移至其他项目和/或已离开公司。

以下图表说明了成本和时间随着问题在各个阶段被发现的时间越晚而增加的情况:

由于变更所带来的成本,瀑布式 SDLC 倾向于适用于风险较低的较小项目。较大和更复杂的项目增加了变更的可能性,因为在项目进行过程中需求可能会被改变或业务驱动因素发生变化。

敏捷 SDLC

敏捷 SDLC 方法试图接纳变化和不确定性。这是通过使用允许在项目或产品开发过程中发现问题的模式来实现的。关键概念是将项目分解为较小的开发迭代,通常称为开发周期。在每个周期中,基本的瀑布式阶段都会重复,因此每个周期都有需求、设计、开发、测试和部署阶段。

这只是一个简化,但将项目分解为周期的策略比瀑布式具有几个优点:

  • 随着范围变小,业务需求变化的影响减小。

  • 利益相关者比瀑布式更早地获得可见的工作系统。虽然不完整,但这提供了价值,因为它允许更早地将反馈纳入产品中。

  • 资源配置可能会受益,因为资源类型的波动较少。

上图提供了两种方法的总结。

总结

在本章中,我们讨论了现代软件开发中使用的主要设计模式,这些模式是在上一章中介绍的。我们从讨论各种软件开发原则开始,如 DRY、KISS、YAGNI、MVP 和 SOLID 编程原则。然后,我们涵盖了软件开发模式,包括 GoF 和 EIPs。我们还涵盖了 SDLC 的方法,包括瀑布和敏捷。本章的目的是说明模式如何在软件开发的各个层次上使用。

随着软件行业的成熟,随着经验的积累、技术的进步,模式开始出现。一些模式已经被开发出来,以帮助 SDLC 的不同阶段。例如,在第三章中,将探讨测试驱动开发(TDD),其中测试的定义用于在开发阶段提供可衡量的进展和清晰的需求。随着章节的进展,我们将讨论软件开发中更高层次的抽象,包括 Web 开发的模式以及面向本地和基于云的解决方案的现代架构模式。

在下一章中,我们将从在.NET Core 中构建一个虚构的应用程序开始。此外,我们将解释本章讨论的各种模式,包括 SOLID 等编程原则,并说明几种 GoF 模式。

问题

以下问题将帮助您巩固本章中包含的信息:

  1. 在 SOLID 中,S 代表什么?责任是什么意思?

  2. 哪种 SDLC 方法是围绕循环构建的:瀑布还是敏捷?

  3. 装饰者模式是创建型模式还是结构型模式?

  4. Pub-Sub 集成代表什么?

第二部分:深入研究.NET Core 中的实用程序和模式

在本节中,读者将亲身体验各种设计模式。在构建一个用于维护库存应用程序的过程中,将说明特定的模式。选择库存应用程序是因为它在概念上很简单,但在开发过程中足够复杂,可以从模式的使用中受益。某些模式和原则将被多次重提,如 SOLID、最小可行产品(MVP)和测试驱动开发(TDD)。到本节结束时,读者将能够借助各种模式编写整洁和干净的代码。

本节包括以下章节:

  • 第三章,《实施设计模式-基础部分 1》

  • 第四章,《实施设计模式-基础部分 2》

  • 第五章,《实施设计模式-.Net Core》

  • 第六章,《为 Web 应用程序实现设计模式-第一部分》

  • 第七章,《为 Web 应用程序实现设计模式-第二部分》

第三章:实施设计模式 - 基础部分 1

在前两章中,我们介绍并定义了与软件开发生命周期(SDLC)相关的现代模式和实践的广泛范围,从较低级别的开发模式到高级解决方案架构模式。本章将在一个示例场景中应用其中一些模式,以便提供上下文和进一步理解这些定义。该场景是创建一个解决方案来管理电子商务书商的库存。

选择了这个场景,因为它提供了足够的复杂性来说明这些模式,同时概念相对简单。公司需要一种管理他们的库存的方式,包括允许用户订购他们的产品。组织需要尽快建立一个应用程序,以便他们能够跟踪他们的库存,但还有许多其他功能,包括允许客户订购产品并提供评论。随着场景的发展,所请求的功能数量增长到开发团队不知道从何处开始的地步。幸运的是,通过应用一些良好的实践来帮助管理期望和需求,开发团队能够简化他们的初始交付并重新回到正轨。此外,通过使用模式,他们能够建立一个坚实的基础,以帮助解决方案的扩展,随着新功能的添加。

本章将涵盖一个新项目的启动和应用程序的第一个发布。本章中将演示以下模式:

  • 最小可行产品(MVP)

  • 测试驱动开发(TDD)

  • 抽象工厂模式(四人帮)

  • SOLID 原则

技术要求

本章包含各种代码示例来解释概念。代码保持简单,仅用于演示目的。大多数示例涉及使用 C#编写的.NET Core 控制台应用程序。

要运行和执行代码,您需要以下内容:

  • Visual Studio 2019(您也可以使用 Visual Studio 2017 版本 3 或更高版本来运行应用程序)

  • .NET Core

  • SQL Server(本章中使用 Express Edition)

安装 Visual Studio

要运行这些代码示例,您需要安装 Visual Studio 或者您可以使用您喜欢的集成开发环境。要做到这一点,请按照以下说明操作:

  1. 从以下链接下载 Visual Studio:docs.microsoft.com/en-us/visualstudio/install/install-visual-studio

  2. 按照包含的安装说明操作。Visual Studio 有多个版本可供安装。在本章中,我们使用的是 Windows 版的 Visual Studio。

设置.NET Core

如果您尚未安装.NET Core,则需要按照以下说明操作:

  1. 从以下链接下载.NET Core:www.microsoft.com/net/download/windows

  2. 按照安装说明和相关库:dotnet.microsoft.com/download/dotnet-core/2.2

完整的源代码可在 GitHub 上找到。本章中显示的源代码可能不完整,因此建议检索源代码以运行示例:github.com/PacktPublishing/Hands-On-Design-Patterns-with-C-and-.NET-Core/tree/master/Chapter3

最小可行产品

本节涵盖了启动新项目以构建软件应用程序的初始阶段。这有时被称为项目启动或项目启动,其中收集应用程序的初始特性和功能(换句话说,需求收集)。

有许多方法可以视为模式,用于确定软件应用程序的功能。关于如何有效地建模、进行面试和研讨会、头脑风暴和其他技术的最佳实践超出了本书的范围。相反,本书描述了一种方法,即最小可行产品,以提供这些模式可能包含的示例。

该项目是针对一个假设情况,一个名为 FlixOne 的公司希望使用库存管理应用程序来管理其不断增长的图书收藏。这个新应用程序将被员工用于管理库存,也将被客户用于浏览和创建新订单。该应用程序需要具有可扩展性,并且作为业务的重要系统,计划在可预见的未来使用。

公司主要分为业务用户开发团队,业务用户主要关注系统的功能,开发团队关注满足需求,以及保持系统的可维护性。这是一个简化;然而,组织并不一定如此整洁地组织,个人可能无法正确地归入一个分类或另一个分类。例如,业务分析师BA)或主题专家SME)经常代表业务用户和开发团队的成员。

由于这是一本技术书籍,我们将主要从开发团队的角度来看待这个情景,并讨论用于实现库存管理应用程序的模式和实践。

需求

在几次会议中,业务和开发团队讨论了新库存管理系统的需求。定义一组清晰的需求的进展缓慢,最终产品的愿景也不清晰。开发团队决定将庞大的需求列表削减到足够的功能,以便一个关键人物可以开始记录一些库存信息。这将允许简单的库存管理,并为业务提供一个可以扩展的基础。然后,每组新的需求都可以添加到初始发布中。

最小可行产品(MVP)

最小可行产品是应用程序的最小功能集,仍然可以发布并为用户群体提供足够的价值。

MVP 方法的优势在于它通过缩小应用程序的范围,为业务和开发团队提供了一个简化的交付需求的愿景。通过减少要交付的功能,确定需要做什么的工作变得更加集中。在 FlixOne 的情况下,会议的价值经常会降低到讨论一个功能的细节,尽管这个功能对产品的最终版本很重要,但需要在发布几个功能之前。例如,围绕面向客户的网站的设计让团队分散注意力,无法专注于存储在库存管理系统中的数据。

MVP 在需求复杂性不完全理解和/或最终愿景不明确的情况下非常有用。然而,仍然很重要要保持产品愿景,以避免开发可能在应用程序的最终版本中不需要的功能。

业务和开发团队能够为初始库存管理应用程序定义以下功能需求:

  • 该应用程序应该是一个控制台应用程序:

  • 它应该打印包含程序集版本的欢迎消息。

  • 它应该循环直到给出退出命令。

  • 如果给定的命令不成功或不被理解,那么它应该打印一个有用的消息。

  • 应用程序应该对简单的不区分大小写的文本命令做出响应。

  • 每个命令都应该有一个短形式,一个字符,和一个长形式。

  • 如果命令有额外的参数:

  • 每个都应按顺序输入,并使用回车键提交。

  • 每个都应该有一个提示输入{参数}:,其中{参数}是参数的名称。

  • 应该有一个帮助命令(?):

  • 打印可用命令的摘要。

  • 打印每个命令的示例用法。

  • 应该有一个退出命令(qquit):

  • 打印一条告别消息

  • 结束应用程序

  • 应该有一个添加库存命令("a""addinventory"):

  • 类型为字符串的name参数。

  • 它应该向数据库中添加一个具有给定名称和 0 数量的条目。

  • 应该有一个更新数量命令("u""updatequantity"):

  • 类型为字符串的name参数。

  • quantity参数为正整数或负整数。

  • 它应该通过添加给定数量来更新具有给定名称的书的数量值。

  • 应该有一个获取库存命令("g""getinventory"):

  • 返回数据库中所有书籍及其数量。

并且定义了以下非功能性要求:

  • 除了操作系统提供的安全性外,不需要其他安全性。

  • 命令的短格式是为了可用性,而命令的长格式是为了可读性。

FlixOne 示例是如何使用 MVP 来帮助聚焦和简化 SDLC 的示例。值得强调的是概念验证(PoC)和 MVP 之间的区别在每个组织中都会有所不同。在本书中,PoC 与 MVP 的不同之处在于所得到的应用程序不被视为一次性或不完整的。对于商业产品,这意味着最终产品可以出售,对于内部企业解决方案,该应用程序可以为组织增加价值。

MVP 如何与未来的开发相适应?

使用 MVP 聚焦和包含需求的另一个好处是它与敏捷软件开发的协同作用。将开发周期分解为较小的开发周期是一种在传统瀑布式开发中获得流行的软件开发技术。驱动概念是需求和解决方案在应用程序的生命周期中演变,并涉及开发团队和最终用户之间的协作。通常,敏捷软件开发框架具有较短的发布周期,其中设计、开发、测试和发布新功能。然后重复发布周期,以包含额外的功能。当工作范围适合发布周期时,MVP 在敏捷开发中表现良好。

Scrum 和 Kanban 是基于敏捷软件开发的流行软件开发框架。

初始 MVP 要求的范围被保持在可以在敏捷周期内设计、开发、测试和发布的范围内。在下一个周期中,将向应用程序添加其他要求。挑战在于限制新功能的范围,使其能够在一个周期内完成。每个新功能的发布都限于基本要求或其 MVP。这里的原则是,通过使用迭代方法进行软件开发,应用程序的最终版本将对最终用户产生比使用需要提前定义所有要求的单个发布更大的好处。

以下图表总结了敏捷和瀑布式软件开发方法之间的区别:

测试驱动开发

存在不同的测试驱动开发TDD)方法,测试可以是在开发过程中按需运行的单元测试,也可以是在项目构建期间运行的单元测试,还可以是作为用户验收测试UAT)一部分运行的测试脚本。同样,测试可以是代码,也可以是描述用户执行步骤以验证需求的文档。这是因为对于 TDD 试图实现的目标有不同的看法。对于一些团队来说,TDD 是一种在编写代码之前完善需求的技术,而对于其他人来说,TDD 是一种衡量或验证交付的代码的方式。

UAT

UAT 是在 SDLC 期间用于验证产品或项目是否满足指定要求的活动的术语。这通常由业务成员或一些客户执行。根据情况,这个阶段可以进一步分为 alpha 和 beta 阶段,其中 alpha 测试由开发团队执行,beta 测试由最终用户执行。

团队为什么选择 TDD?

开发团队决定使用 TDD 有几个原因。首先,团队希望在开发过程中清晰地衡量进展。其次,他们希望能够在后续的开发周期中重复使用测试,以便在添加新功能的同时继续验证现有功能。出于这些原因,团队将使用单元测试来验证编写的功能是否满足团队给定的要求。

以下图表说明了 TDD 的基础知识:

测试被添加并且代码库被更新,直到所有定义的测试都通过为止。重要的是要注意这是重复的。在每次迭代中,都会添加新的测试,并且在所有测试,新的和现有的,都通过之前,测试都不被认为是通过的。

FlixOne 开发团队决定将单元测试和 UAT 结合到一个敏捷周期中。在每个周期开始时,将确定新的验收标准。这将包括要交付的功能,以及在开发周期结束时如何验证或接受。这些验收标准将用于向项目添加测试。然后,开发团队将构建解决方案,直到新的和现有的测试都通过,然后准备一个用于验收测试的构建。然后,将运行验收测试,如果检测到任何问题,开发团队将根据失败定义新的测试或修改现有测试。应用程序将再次开发,直到所有测试都通过并准备一个新的构建。这将重复直到验收测试通过。然后,应用程序将部署,并开始一个新的开发周期。

以下图表说明了这种方法:

团队现在有了一个计划,让我们开始编码吧!

设置项目

在这种情况下,我们将使用Microsoft Unit TestMSTest)框架。本节提供了一些使用.NET Core 命令行界面CLI)工具创建初始项目的说明。这些步骤也可以使用集成开发环境(IDE)如 Visual Studio 或 Visual Studio Code 完成。这里提供这些说明是为了说明 CLI 如何用于补充 IDE。

CLI

.NET Core CLI 工具是用于开发.NET 应用程序的跨平台实用程序,并且是更复杂工具的基础,例如 IDE。请参阅文档以获取更多信息:docs.microsoft.com/en-us/dotnet/core/tools

本章的解决方案将包括三个项目:控制台应用程序、类库和测试项目。让我们创建解决方案目录 FlixOne,以包含解决方案和三个项目的子目录。在创建的目录中,以下命令将创建一个新的解决方案文件:

dotnet new sln

以下截图说明了创建目录和解决方案(注意:目前只创建了一个空解决方案文件):

类库FlixOne.InventoryManagement将包含我们的业务实体和逻辑。在后面的章节中,我们将把它们拆分成单独的库,但是由于我们的应用程序还很小,它们包含在一个单独的程序集中。创建项目的dotnet核心 CLI 命令如下所示:

dotnet new classlib --name FlixOne.InventoryManagement

请注意,在以下截图中,创建了一个包含新类库项目文件的新目录:

应该从解决方案到新类库进行引用,使用以下命令:

dotnet sln add .\FlixOne.InventoryManagement\FlixOne.InventoryManagement.csproj

要创建一个新的控制台应用程序项目,应使用以下命令:

dotnet new console --name FlixOne.InventoryManagementClient

以下截图显示了console模板的恢复:

控制台应用程序需要引用类库(注意:该命令需要在将引用添加到其中的项目文件所在的目录中运行):

dotnet add reference ..\FlixOne.InventoryManagement\FlixOne.InventoryManagement.csproj

将使用以下命令创建一个新的MSTest项目:

dotnet new mstest --name FlixOne.InventoryManagementTests

以下截图显示了创建 MSTest 项目,并应在与解决方案相同的文件夹中运行,FlixOne(注意包含所需 MSTest NuGet 包的命令中恢复的包):

测试项目还需要引用类库(注意:此命令需要在与 MSTest 项目文件相同的文件夹中运行):

dotnet add reference ..\FlixOne.InventoryManagement\FlixOne.InventoryManagement.csproj

最后,通过在与解决方案文件相同的目录中运行以下命令,将控制台应用程序和 MSTest 项目添加到解决方案中:

dotnet sln add .\FlixOne.InventoryManagementClient\FlixOne.InventoryManagementClient.csproj
dotnet sln add .\FlixOne.InventoryManagementTests\FlixOne.InventoryManagementTests.csproj

从视觉上看,解决方案如下所示:

现在我们的解决方案的初始结构已经准备好了,让我们首先开始添加到我们的单元测试定义。

初始单元测试定义

开发团队首先将需求转录成一些基本的单元测试。由于还没有设计或编写任何内容,因此这些测试大多以记录应该验证的功能为形式。随着设计和开发的进展,这些测试也将朝着完成的方向发展;例如,需要添加库存:

添加库存命令(“a”,“addinventory”)可用:

  • name参数为字符串类型。

  • 使用给定的名称和0数量向数据库添加条目。

为了满足这个需求,开发团队创建了以下单元测试作为占位符:

[TestMethod]
private void AddInventoryCommand_Successful()
{
  // create an instance of the command
  // add a new book with parameter "name"
  // verify the book was added with the given name with 0 quantity

  Assert.Inconclusive("AddInventoryCommand_Successful has not been implemented.");
}

随着应用程序设计的逐渐明确和开发的开始,现有的测试将扩展,新的测试将被创建,如下所示:

不确定测试的重要性在于它们向团队传达了需要完成的任务,并且在开发进行时提供了一种衡量。随着开发的进行,不确定和失败的测试将表明需要进行的工作,而成功的测试将表明朝着完成当前一组任务的进展。

抽象工厂设计模式

为了说明我们的第一个模式,让我们通过开发帮助命令和初始控制台应用程序来走一遍。初始版本的控制台应用程序如下所示:

private static void Main(string[] args)
{
    Greeting();

    // note: inline out variable introduced as part of C# 7.0
    GetCommand("?").RunCommand(out bool shouldQuit); 

    while (!shouldQuit)
    { 
        // handle the commands
        ...
    }

    Console.WriteLine("CatalogService has completed."); 
}

应用程序启动时,会显示问候语和帮助命令的结果。然后,应用程序将处理输入的命令,直到输入退出命令为止。

以下显示了处理命令的详细信息:

    while (!shouldQuit)
    { 
        Console.WriteLine(" > ");
        var input = Console.ReadLine();
        var command = GetCommand(input);

        var wasSuccessful = command.RunCommand(out shouldQuit);

        if (!wasSuccessful)
        {
            Console.WriteLine("Enter ? to view options.");
        }
    }

直到应用程序解决方案退出,应用程序将继续提示用户输入命令,如果命令没有成功处理,那么将显示帮助文本。

RunCommand(out bool shouldQuit)

C# 7.0 引入了一种更流畅的语法,用于创建out参数。这将在命令块的范围内声明变量。下面的示例说明了这一点,其中shouldQuit布尔值不是提前声明的。

InventoryCommand 抽象类

关于初始控制台应用程序的第一件事是,团队正在使用面向对象编程OOP)来创建处理命令的标准方式。团队从这个初始设计中学到的是,所有命令都将包含一个RunCommand()方法,该方法将返回两个布尔值,指示命令是否成功以及程序是否应该终止。例如,HelpCommand()将简单地在控制台上显示帮助消息,并且不应该导致程序结束。然后两个返回的布尔值将是true,表示命令成功运行,false,表示应用程序不应该终止。以下显示了初始版本:

这个...表示额外的声明,在这个特定的例子中,额外的Console.WriteLine()声明。

public class HelpCommand
{
    public bool RunCommand(out bool shouldQuit)
    {
        Console.WriteLine("USAGE:");
        Console.WriteLine("\taddinventory (a)");
        ...
        Console.WriteLine("Examples:");
        ...

        shouldQuit = false;
        return true;
    }
}

QuitCommand将显示一条消息,然后导致程序结束。最初的QuitCommand如下:

public class QuitCommand
{
    public bool RunCommand(out bool shouldQuit)
    {
        Console.WriteLine("Thank you for using FlixOne Inventory Management System");

        shouldQuit = true;
        return true;
    }
}

团队决定要么创建一个接口,两个类都实现,要么创建一个抽象类,两个类都继承。两者都可以实现所需的动态多态性,但团队选择使用抽象类,因为所有命令都将具有共享功能。

在 OOP 中,特别是在 C#中,多态性以三种主要方式得到支持:函数重载、泛型和子类型或动态多态性。

使用抽象工厂设计模式,团队创建了一个抽象类,命令将从中继承,InventoryCommandInventoryCommand类有一个单一的方法,RunCommand,将执行命令并返回命令是否成功执行以及应用程序是否应该退出。该类是抽象的,意味着类包含一个或多个抽象方法。在这种情况下,InternalCommand()方法是抽象的,意图是从InventoryCommand类派生的类将使用特定命令功能实现InternalCommand方法。例如,QuitCommand将扩展InventoryCommand并为InternalCommand()方法提供具体实现。以下片段显示了带有抽象InternalCommand()方法的InventoryCommand抽象类:

public abstract class InventoryCommand
{
    private readonly bool _isTerminatingCommand;
    internal InventoryCommand(bool commandIsTerminating)
    {
        _isTerminatingCommand = commandIsTerminating; 
    }
    public bool RunCommand(out bool shouldQuit)
    {
        shouldQuit = _isTerminatingCommand;
        return InternalCommand();
    }

    internal abstract bool InternalCommand();
}

然后抽象方法将在每个派生类中实现,就像HelpCommand所示。HelpCommand简单地向控制台打印一些信息,然后返回true,表示命令成功执行:

public class HelpCommand : InventoryCommand
{
    public HelpCommand() : base(false) { }

    internal override bool InternalCommand()
    { 
        Console.WriteLine("USAGE:");
        Console.WriteLine("\taddinventory (a)");
        ...
        Console.WriteLine("Examples:");
        ... 
        return true;
    }
}

开发团队随后决定对InventoryCommand进行两个额外的更改。他们不喜欢的第一件事是shouldQuit布尔值作为out变量返回。因此,他们决定使用 C# 7 的新元组功能,而不是返回一个单一的Tuple<bool,bool>对象,如下所示:

public (bool wasSuccessful, bool shouldQuit) RunCommand()
{
    /* additional code hidden */

    return (InternalCommand(), _isTerminatingCommand);
}

元组

元组是 C#类型,提供了一种轻量级的语法,可以将多个值打包成一个单一对象。与定义类的缺点是你失去了继承和其他面向对象的功能。更多信息,请参见docs.microsoft.com/en-us/dotnet/csharp/tuples

另一个变化是引入另一个抽象类,指示命令是否是一个非终止命令;换句话说,不会导致解决方案退出或结束的命令。

如下代码所示,这个命令仍然是抽象的,因为它没有实现InventoryCommandInternalCommand方法,但它向基类传递了一个 false 值:

internal abstract class NonTerminatingCommand : InventoryCommand
{
    protected NonTerminatingCommand() : base(commandIsTerminating: false)
    {
    }
}

这里的优势是现在不会导致应用程序终止的命令 - 换句话说,非终止命令 - 现在有了更简单的定义:

internal class HelpCommand : NonTerminatingCommand
{
    internal override bool InternalCommand()
    {
        Interface.WriteMessage("USAGE:");
        /* additional code hidden */

        return true;
    }
}

以下类图显示了InventoryCommand抽象类的继承:

只有一个终止命令,QuitCommand,而其他命令扩展了NonTerminatingCommand抽象类。还值得注意的是,AddInventoryCommandUpdateQuantityCommand需要参数,并且IParameterisedCommand的使用将在Liskov 替换原则部分中解释。图表中的另一个微妙之处是除了基本的InventoryCommand之外,所有类型都不是公共的(对外部程序集可见)。这将在本章后面的访问修饰符部分变得相关。

SOLID 原则

随着团队使用模式简化代码,他们还使用 SOLID 原则来帮助识别问题。通过简化代码,团队的目标是使代码更易于维护,并且更容易让新团队成员理解。通过使用一套原则审查代码的方法,在编写只做必要的事情并提供一层抽象的类时非常有用,这有助于编写更容易修改和理解的代码。

单一职责原则(SRP)

团队应用的第一个原则是单一职责原则SRP)。团队发现写入控制台的实际机制不是InventoryCommand类的责任。因此,引入了一个负责与用户交互的ConsoleUserInterface类。SRP 将有助于保持InventoryCommand类更小,并避免重复相同的代码的情况。例如,应用程序应该有一种统一的方式提示用户输入信息和显示消息和警告。这种逻辑不是在InventoryCommand类中重复,而是封装在ConsoleUserInterface类中。

ConsoleUserInteraface将包括三种方法,如下所示:

public class ConsoleUserInterface
{
    // read value from console

    // message to the console

    // writer warning message to the console
}

第一种方法将用于从控制台读取输入:

public string ReadValue(string message)
{
    Console.ForegroundColor = ConsoleColor.Green;
    Console.Write(message);
    return Console.ReadLine();
}

第二种方法将使用绿色在控制台上打印一条消息:

public void WriteMessage(string message)
{
    Console.ForegroundColor = ConsoleColor.Green;
    Console.WriteLine(message);
}

最终的方法将使用深黄色在控制台上打印一条警告消息:

public void WriteWarning(string message)
{
    Console.ForegroundColor = ConsoleColor.DarkYellow;
    Console.WriteLine(message);
}

通过ConsoleUserInterface类,我们可以减少与用户交互方式的变化对我们的影响。随着解决方案的发展,我们可能会发现界面从控制台变为 Web 应用程序。理论上,我们将用WebUserInterface替换ConsoleUserInterface。如果我们没有将用户界面简化为单个类,这种变化的影响很可能会更加破坏性。

开闭原则(OCP)

开闭原则,SOLID 中的 O,由不同的InventoryCommand类表示。团队可以定义一个包含多个if语句的单个类,而不是为每个命令定义一个InventoryCommand类的实现。每个if语句将确定要执行的功能。例如,以下说明了团队如何打破这个原则:

internal bool InternalCommand(string command)
{
    switch (command)
    {
        case "?":
        case "help":
            return RunHelpCommand(); 
        case "a":
        case "addinventory":
            return RunAddInventoryCommand(); 
        case "q":
        case "quit":
            return RunQuitCommand();
        case "u":
        case "updatequantity":
            return RunUpdateInventoryCommand();
        case "g":
        case "getinventory":
            return RunGetInventoryCommand();
    }
    return false;
}

上述方法违反了这一原则,因为添加新命令会改变代码的行为。该原则的理念是它对于会改变其行为的修改是封闭的,而是开放的,以扩展类以支持附加行为。通过具有抽象的InventoryCommand和派生类(例如QuitCommandHelpCommandAddInventoryCommand)来实现这一点。尤其是与其他原则结合使用时,这是一个令人信服的理由,因为它导致简洁的代码,更易于维护和理解。

里氏替换原则(LSP)

退出、帮助和获取库存的命令不需要参数,而AddInventoryUpdateQuantityCommand需要。有几种处理方式,团队决定引入一个接口来标识这些命令,如下所示:

public interface IParameterisedCommand
{
    bool GetParameters();
}

通过应用里氏替换原则LSP),只有需要参数的命令应该实现GetParameters()方法。例如,在AddInventory命令上,使用在基类InventoryCommand上定义的方法来实现IParameterisedCommand

public class AddInventoryCommand : InventoryCommand, IParameterisedCommand
{
    public string InventoryName { get; private set; }

    /// <summary>
    /// AddInventoryCommand requires name
    /// </summary>
    /// <returns></returns>
    public bool GetParameters()
    {
        if (string.IsNullOrWhiteSpace(InventoryName))
            InventoryName = GetParameter("name");

        return !string.IsNullOrWhiteSpace(InventoryName);
    }    
}

InventoryCommand类上的GetParameter方法简单地使用ConsoleUserInterface从控制台读取值。该方法将在本章后面显示。在 C#中,有一个方便的语法,可以很好地显示 LSP 如何用于仅将功能应用于特定接口的对象。在RunCommand方法的第一行,使用is关键字来测试当前对象是否实现了IParameterisedCommand接口,并将对象强制转换为新对象:parameterisedCommand。以下代码片段中的粗体显示了这一点:

public (bool wasSuccessful, bool shouldQuit) RunCommand()
{
    if (this is IParameterisedCommand parameterisedCommand)
    {
        var allParametersCompleted = false;

        while (allParametersCompleted == false)
        {
            allParametersCompleted = parameterisedCommand.GetParameters();
        }
    }

    return (InternalCommand(), _isTerminatingCommand);
}

接口隔离原则(ISP)

处理带参数和不带参数的命令的一种方法是在InventoryCommand抽象类上定义另一个方法GetParameters,对于不需要参数的命令,只需返回 true 以指示已接收到所有(在本例中为零)参数。例如,QuitCommand**HelpCommand**GetInventoryCommand都将有类似以下实现:

internal override bool GetParameters()
{
    return true;
}

这将起作用,但它违反了接口隔离原则ISP),该原则规定接口应仅包含所需的方法和属性。与 SRP 类似,适用于类的 ISP 适用于接口,并且在保持接口小型和专注方面非常有效。在我们的示例中,只有AddInventoryCommandUpdateQuantityCommand类将实现InventoryCommand接口。

依赖反转原则

依赖反转原则DIP),也称为依赖注入原则DIP),模块不应依赖于细节,而应依赖于抽象。该原则鼓励编写松散耦合的代码,以增强可读性和维护性,特别是在大型复杂的代码库中。

如果我们重新访问之前介绍的ConsoleUserInterface类(在单一职责原则部分),我们可以在没有QuitCommand的情况下使用该类如下:

internal class QuitCommand : InventoryCommand
{
    internal override bool InternalCommand()
    {
        var console = new ConsoleUserInterface();
        console.WriteMessage("Thank you for using FlixOne Inventory Management System");

        return true;
    }
}

这违反了几个 SOLID 原则,但就 DIP 而言,它在QuitCommandConsoleUserInterface之间形成了紧密耦合。想象一下,如果控制台不再是向用户显示信息的手段,或者如果ConsoleUserInterface的构造函数需要额外的参数会怎么样?

通过应用 DIP 原则,进行了以下重构。首先引入了一个新的接口IUserInterface,其中包含了ConsoleUserInterface中实现的方法的定义。接下来,在InventoryCommand类中使用接口而不是具体类。最后,在InventoryCommand类的构造函数中传递了一个实现IUserInterface的对象的引用。这种方法保护了InventoryCommand类免受对IUserInterface类实现细节的更改,并为更轻松地替换IUserInterface的不同实现提供了一种机制,使代码库得以发展。

DIP 如下图所示,QuitCommand是本章的最终版本:

internal class QuitCommand : InventoryCommand
{
    public QuitCommand(IUserInterface userInterface) : 
           base(commandIsTerminating: true, userInteface: userInterface)
    {
    }

    internal override bool InternalCommand()
    {
        Interface.WriteMessage("Thank you for using FlixOne Inventory Management System");

        return true;
    }
}

请注意,该类扩展了InventoryCommand抽象类,提供了处理命令的通用方式,同时提供了共享功能。构造函数要求在实例化对象时注入IUserInterface依赖项。还要注意,QuitCommand实现了一个方法InternalCommand(),使QuitCommand简洁易读易懂。

为了完成整个图片,让我们来看最终的InventoryCommand基类。以下显示了构造函数和属性:

public abstract class InventoryCommand
{
    private readonly bool _isTerminatingCommand;
    protected IUserInterface Interface { get; }

    internal InventoryCommand(bool commandIsTerminating, IUserInterface userInteface)
    {
        _isTerminatingCommand = commandIsTerminating;
        Interface = userInteface;
    }
    ...
}

请注意,IUserInterface被传递到构造函数中,以及一个布尔值,指示命令是否终止。然后,IUserInterface对于所有InventoryCommand的实现都可用作Interface属性。

RunCommand是该类上唯一的公共方法:

public (bool wasSuccessful, bool shouldQuit) RunCommand()
{
    if (this is IParameterisedCommand parameterisedCommand)
    {
        var allParametersCompleted = false;

        while (allParametersCompleted == false)
        {
            allParametersCompleted = parameterisedCommand.GetParameters();
        }
    }

    return (InternalCommand(), _isTerminatingCommand);
}

internal abstract bool InternalCommand();

此外,GetParameter方法是所有InventoryCommand实现的公共方法,因此它被设置为内部方法:

internal string GetParameter(string parameterName)
{
    return Interface.ReadValue($"Enter {parameterName}:"); 
}

DIP 和 IoC

DIP 和控制反转(IoC)密切相关,都以稍微不同的方式解决相同的问题。IoC 及其专门形式的服务定位器模式(SLP)使用机制按需提供抽象的实现。因此,IoC 充当代理以提供所需的细节,而不是注入实现。在下一章中,将探讨.NET Core 对这些模式的支持。

InventoryCommand 单元测试

随着InventoryCommand类的形成,让我们重新审视单元测试,以便开始验证到目前为止编写的内容,并确定任何缺失的要求。在这里,SOLID 原则将显示其价值。因为我们保持了类(SRP)和接口(ISP)的小型化,并且专注于所需的最小功能量(LSP),我们的测试也应该更容易编写和验证。例如,关于其中一个命令的测试将不需要验证控制台上消息的显示(例如颜色或文本大小),因为这不是InventoryCommand类的责任,而是IUserInterface的实现的责任。此外,通过依赖注入,我们将能够将测试隔离到仅涉及库存命令。以下图表说明了这一点,因为单元测试将仅验证绿色框中包含的内容:

通过保持单元测试的范围有限,将更容易处理应用程序的变化。在某些情况下,由于类之间的相互依赖关系(换句话说,当未遵循 SOLID 原则时),更难以分离功能,测试可能会跨应用程序的较大部分,包括存储库。这些测试通常被称为集成测试,而不是单元测试。

访问修饰符

访问修饰符是处理类型和类型成员可见性的重要方式,通过封装代码来实现。通过使用清晰的访问策略,可以传达和强制执行程序集及其类型应该如何使用的意图。例如,在 FlixOne 应用程序中,只有应该由控制台直接访问的类型被标记为公共。这意味着控制台应用程序应该能够看到有限数量的类型和方法。这些类型和方法已标记为公共,而控制台不应该访问的类型和方法已标记为内部、私有或受保护。

请参阅 Microsoft 文档编程指南,了解有关访问修饰符的更多信息:

docs.microsoft.com/en-us/dotnet/csharp/programming-guide/classes-and-structs/access-modifiers

InventoryCommand抽象类被公开,因为控制台应用程序将使用RunCommand方法来处理命令。

在下面的片段中,请注意构造函数和接口被标记为受保护,以便给予子类访问权限:

public abstract class InventoryCommand
{
    private readonly bool _isTerminatingCommand;
    protected IUserInterface Interface { get; }

    protected InventoryCommand(bool commandIsTerminating, IUserInterface userInteface)
    {
        _isTerminatingCommand = commandIsTerminating;
        Interface = userInteface;
    }
    ...
}

在下面的片段中,请注意RunCommand方法被标记为公共,而InternalCommand被标记为内部:

public (bool wasSuccessful, bool shouldQuit) RunCommand()
{
    if (this is IParameterisedCommand parameterisedCommand)
    {
        var allParametersCompleted = false;

        while (allParametersCompleted == false)
        {
            allParametersCompleted = parameterisedCommand.GetParameters();
        }
    }

    return (InternalCommand(), _isTerminatingCommand);
}

internal abstract bool InternalCommand();

同样,InventoryCommand的实现被标记为内部,以防止它们被直接引用到程序集外部。这在QuitCommand中有所体现:

internal class QuitCommand : InventoryCommand
{
    internal QuitCommand(IUserInterface userInterface) : base(true, userInterface) { }

    protected override bool InternalCommand()
    {
        Interface.WriteMessage("Thank you for using FlixOne Inventory Management System");

        return true;
    }
}

因为不同实现的访问对于单元测试项目来说不会直接可见,所以需要额外的步骤来使内部类型可见。assembly指令可以放置在任何已编译的文件中,对于 FlixOne 应用程序,添加了一个包含程序集属性的assembly.cs文件:

using System.Runtime.CompilerServices;
[assembly: InternalsVisibleTo("FlixOne.InventoryManagementTests")]

在程序集已签名的情况下,InternalsVisibleTo()需要一个公钥。请参阅 Microsoft Docs C#指南,了解更多信息:docs.microsoft.com/en-us/dotnet/csharp/programming-guide/concepts/assemblies-gac/how-to-create-signed-friend-assemblies

Helper TestUserInterface

作为对InventoryCommand实现之一的单元测试的一部分,我们不希望测试引用的依赖关系。幸运的是,由于命令遵循 DIP,我们可以创建一个helper类来验证实现与依赖关系的交互。其中一个依赖是IUserInterface,它在构造函数中传递给实现。以下是接口的方法的提醒:

public interface IUserInterface : IReadUserInterface, IWriteUserInterface { }

public interface IReadUserInterface
{
    string ReadValue(string message);
}

public interface IWriteUserInterface
{
    void WriteMessage(string message);
    void WriteWarning(string message);
}

通过实现一个helper类,我们可以提供ReadValue方法所需的信息,并验证WriteMessageWriteWarning方法中是否收到了适当的消息。在测试项目中,创建了一个名为TestUserInterface的新类,该类实现了IUserInterface接口。该类包含三个列表,包含预期的WriteMessageWriteWarningReadValue调用,并跟踪调用次数。

例如,WriteWarning方法显示如下:

public void WriteWarning(string message)
{
    Assert.IsTrue(_expectedWriteWarningRequestsIndex < _expectedWriteWarningRequests.Count,
                  "Received too many command write warning requests.");

    Assert.AreEqual(_expectedWriteWarningRequests[_expectedWriteWarningRequestsIndex++], message,                             "Received unexpected command write warning message");
}

WriteWarning方法执行两个断言。第一个断言验证方法调用的次数不超过预期,第二个断言验证接收到的消息是否与预期消息匹配。

ReadValue方法类似,但它还将一个值返回给调用的InventoryCommand实现。这将模拟用户在控制台输入信息:

public string ReadValue(string message)
{
    Assert.IsTrue(_expectedReadRequestsIndex < _expectedReadRequests.Count,
                  "Received too many command read requests.");

    Assert.AreEqual(_expectedReadRequests[_expectedReadRequestsIndex].Item1, message, 
                    "Received unexpected command read message");

    return _expectedReadRequests[_expectedReadRequestsIndex++].Item2;
}

作为额外的验证步骤,在测试方法结束时,调用TestUserInterface来验证是否收到了预期数量的ReadValueWriteMessageWriteWarning请求:

public void Validate()
{
    Assert.IsTrue(_expectedReadRequestsIndex == _expectedReadRequests.Count, 
                  "Not all read requests were performed.");
    Assert.IsTrue(_expectedWriteMessageRequestsIndex == _expectedWriteMessageRequests.Count, 
                  "Not all write requests were performed.");
    Assert.IsTrue(_expectedWriteWarningRequestsIndex == _expectedWriteWarningRequests.Count, 
                  "Not all warning requests were performed.");
}

TestUserInterface类说明了如何模拟依赖项以提供存根功能,并提供断言来帮助验证预期的行为。在后面的章节中,我们将使用第三方包提供更复杂的模拟依赖项的框架。

单元测试示例 - QuitCommand

QuitCommand开始,要求非常明确:命令应打印告别消息,然后导致应用程序结束。我们已经设计了InventoryCommand来返回两个布尔值,以指示应用程序是否应该退出以及命令是否成功结束:

[TestMethod]
public void QuitCommand_Successful()
{
    var expectedInterface = new Helpers.TestUserInterface(
        new List<Tuple<string, string>>(), // ReadValue()
        new List<string> // WriteMessage()
        {
            "Thank you for using FlixOne Inventory Management System"
        },
        new List<string>() // WriteWarning()
    );

    // create an instance of the command
    var command = new QuitCommand(expectedInterface);

    var result = command.RunCommand();

    expectedInterface.Validate();

    Assert.IsTrue(result.shouldQuit, "Quit is a terminating command.");
    Assert.IsTrue(result.wasSuccessful, "Quit did not complete Successfully.");
}

测试使用TestUserInterface来验证文本"感谢您使用 FlixOne 库存管理系统"是否发送到WriteMessage方法,并且没有接收到ReadValueWriteWarning请求。这两个标准通过expectedInterface.Validate()调用进行验证。通过检查shouldQuitwasSuccessful布尔值为 true 来验证QuitCommand的结果。

在 FlixOne 场景中,为了简化,要显示的文本在解决方案中是硬编码的。更好的方法是使用资源文件。资源文件提供了一种将文本与功能分开维护的方式,同时支持为不同文化本地化数据。

总结

本章介绍了在线书商 FlixOne 想要构建一个管理其库存的应用程序的情景。本章涵盖了开发团队在开发应用程序时可以使用的一系列模式和实践。团队使用 MVP 来帮助将初始交付的范围保持在可管理的水平,并帮助业务集中确定对组织最有益的需求。团队决定使用 TDD 来验证交付是否符合要求,并帮助团队衡量进展。基本项目以及单元测试框架 MSTest 已创建。团队还使用了 SOLID 原则来帮助以一种既有利于可读性又有利于代码库的维护的方式构建代码,随着对应用程序的新增增强。第一个四人帮模式,抽象工厂设计模式,用于为所有库存命令提供基础。

在下一章中,团队将继续构建初始库存管理项目,以满足 MVP 中定义的要求。团队将使用四人帮的 Singleton 模式和 Factory Method 模式。这些将在.NET Core 中支持这些功能的机制的情况下展示。

问题

以下问题将帮助您巩固本章中包含的信息:

  1. 在为组织开发软件时,为什么有时很难确定需求?

  2. 瀑布软件开发与敏捷软件开发的两个优点和缺点是什么?

  3. 编写单元测试时,依赖注入如何帮助?

  4. 为什么以下陈述是错误的?使用 TDD,您不再需要人们测试新软件部署。

第四章:实施设计模式 - 基础知识第二部分

在上一章中,我们介绍了 FlixOne 以及新库存管理应用程序的初始开发。开发团队使用了多种模式,从旨在限制交付范围的模式(如最小可行产品MVP))到辅助项目开发的模式(如测试驱动开发TDD))。还应用了四人帮GoF)的几种模式,作为解决方案,以利用他人过去解决类似问题的经验,以免重复常见错误。应用了单一责任原则、开闭原则、里氏替换原则、接口隔离原则和依赖反转原则(SOLID 原则),以确保我们正在创建一个稳定的代码库,将有助于管理和未来开发我们的应用程序。

本章将继续解释通过合并更多模式来构建 FlixOne 库存管理应用程序。将使用更多的 GoF 模式,包括单例模式和工厂模式。将使用单例模式来说明用于维护 FlixOne 图书收藏的存储库模式。工厂模式将进一步理解依赖注入DI)。最后,我们将使用.NET Core 框架来促进控制反转IoC)容器,该容器将用于完成初始库存管理控制台应用程序。

本章将涵盖以下主题:

  • 单例模式

  • 工厂模式

  • .NET Core 的特性

  • 控制台应用程序

技术要求

本章包含各种代码示例,以解释这些概念。代码保持简单,仅用于演示目的。大多数示例涉及使用 C#编写的.NET Core 控制台应用程序。

要运行和执行代码,您需要以下内容:

  • Visual Studio 2019(您也可以使用 Visual Studio 2017 版本 3 或更高版本运行应用程序)

  • .NET Core

  • SQL Server(本章使用 Express Edition)

安装 Visual Studio

要运行这些代码示例,您需要安装 Visual Studio 或更高版本。您可以使用您喜欢的集成开发环境。要做到这一点,请按照以下说明进行操作:

  1. 从以下链接下载 Visual Studio:docs.microsoft.com/en-us/visualstudio/install/install-visual-studio

  2. 按照包含的安装说明进行操作。安装 Visual Studio 有多个版本可供选择;在本章中,我们使用的是 Windows 版的 Visual Studio。

.NET Core 的设置

如果您尚未安装.NET Core,则需要按照以下说明进行操作:

  1. 从以下链接下载.NET Core:www.microsoft.com/net/download/windows

  2. 按照相关库的安装说明进行操作:dotnet.microsoft.com/download/dotnet-core/2.2

完整的源代码可在 GitHub 上找到。本章中显示的源代码可能不完整,因此建议您检索源代码以运行示例(github.com/PacktPublishing/Hands-On-Design-Patterns-with-C-and-.NET-Core/tree/master/Chapter4)。

单例模式

单例模式是另一个 GoF 设计模式,用于限制类的实例化为一个对象。它用于需要协调系统内的操作或限制对数据的访问的情况。例如,如果需要在应用程序内将对文件的访问限制为单个写入者,则可以使用单例模式防止多个对象同时尝试向文件写入。在我们的场景中,我们将使用单例模式来维护书籍及其库存的集合。

单例模式的价值在使用示例时更加明显。本节将从一个基本类开始,然后继续识别单例模式所解决的不同问题。这些问题将被识别出来,然后通过单元测试进行更新和验证。

单例模式应仅在必要时使用,因为它可能为应用程序引入潜在的瓶颈。有时,该模式被视为反模式,因为它引入了全局状态。全局状态会引入应用程序中的未知依赖关系,因此不清楚有多少类型可能依赖于该信息。此外,许多框架和存储库已经在需要时限制了访问,因此引入额外的机制可能会不必要地限制性能。

.NET Core 支持许多讨论的模式。在下一章中,我们将利用ServiceCollection类对工厂方法和单例模式的支持。

在我们的场景中,单例模式将用于保存包含书籍集合的内存存储库。单例将防止多个线程同时更新书籍集合。这将要求我们锁定代码的一部分,以防止不可预测的更新。

将单例引入应用程序的复杂性可能是微妙的;因此,为了对该模式有一个坚实的理解,我们将涵盖以下主题:

  • .Net Framework 对进程和线程的处理

  • 存储库模式

  • 竞争条件

  • 单元测试以识别竞争条件

进程和线程

要理解单例模式,我们需要提供一些背景。在.Net Framework 中,一个应用程序将由称为应用程序域的轻量级托管子进程组成,这些子进程可以包含一个或多个托管线程。为了理解单例模式,让我们将其定义为包含一个或多个同时运行的线程的多线程应用程序。从技术上讲,这些线程实际上并不是同时运行的,而是通过在线程之间分配可用处理器时间来实现的,因此每个线程将执行一小段时间,然后该线程将暂停活动,从而允许另一个线程执行。

回到单例模式,在多线程应用程序中,需要特别注意确保对单例的访问受限,以便只有一个线程同时进入特定逻辑区域。由于线程的同步,一个线程可以检索值并更新它,然后在存储之前,另一个线程也更新该值。

多个线程可能访问相同的共享数据并以不可预测的结果进行更新,这可能被称为竞争条件

为了避免数据被错误更新,需要一些限制,以防止多个线程同时执行相同的逻辑块。在.Net Framework 中支持几种机制,在单例模式中,使用lock关键字。在下面的代码中,演示了lock关键字,以表明一次只有一个线程可以执行突出显示的代码,而所有其他线程将被阻塞:

public class Inventory
{
   int _quantity;
    private Object _lock = new Object();

    public void RemoveQuantity(int amount)
    {
        lock (_lock)
        {
            if (_quantity - amount < 0)
 {
 throw new Exception("Cannot remove more than we have!");
 }
 _quantity -= amount;
        }
    }
}

锁是限制代码段访问的简单方法,可以应用于对象实例,就像我们之前的例子所示的那样,也可以应用于标记为静态的代码段。

存储库模式

引入到项目中的单例模式应用于用于维护库存书籍集合的类。单例将防止多个线程访问被错误处理,另一个模式存储库模式将用于创建一个外观,用于管理的数据。

存储库模式提供了一个存储库的抽象,以在应用程序的业务逻辑和底层数据之间提供一层。这提供了几个优势。通过进行清晰的分离,我们的业务逻辑可以独立于底层数据进行维护和单元测试。通常,相同的存储库模式类可以被多个业务对象重用。一个例子是GetInventoryCommandAddInventoryCommandUpdateInventoryCommand对象;所有这些对象都使用相同的存储库类。这使我们能够在不受存储库影响的情况下测试这些命令中的逻辑。该模式的另一个优势是,它使得更容易实现集中的数据相关策略,比如缓存。

首先,让我们考虑以下描述存储库将实现的方法的接口;它包含了检索书籍、添加书籍和更新书籍数量的方法:

internal interface IInventoryContext
{
    Book[] GetBooks();
    bool AddBook(string name);
    bool UpdateQuantity(string name, int quantity);
}

存储库的初始版本如下:

internal class InventoryContext : IInventoryContext
{ 
    public InventoryContext()
    {
        _books = new Dictionary<string, Book>();
    }

    private readonly IDictionary<string, Book> _books; 

    public Book[] GetBooks()
    {
        return _books.Values.ToArray();
    }

    public bool AddBook(string name)
    {
        _books.Add(name, new Book { Name = name });
        return true;
    }

    public bool UpdateQuantity(string name, int quantity)
    {
        _books[name].Quantity += quantity;
        return true;
    }
}

在本章中,书籍集合以内存缓存的形式进行维护,而在后续章节中,这将被移动到提供持久数据的存储库中。当然,这种实现并不理想,因为一旦应用程序结束,所有数据都将丢失。但是,它用来说明单例模式。

单元测试

为了说明单例模式解决的问题,让我们从一个简单的单元测试开始,向存储库添加 30 本书,更新不同书籍的数量,然后验证结果。以下代码显示了整体单元测试,我们将逐个解释每个步骤:

 [TestClass]
public class InventoryContextTests
{ 
    [TestMethod]
    public void MaintainBooks_Successful()
    { 
        var context = new InventoryContext();

        // add thirty books
        ...

        // let's update the quantity of the books by adding 1, 2, 3, 4, 5 ...
        ...

        // let's update the quantity of the books by subtracting 1, 2, 3, 4, 5 ...
        ...

        // all quantities should be 0
        ...
    } 
}

为了添加 30 本书,使用context实例从Book_1Book_30添加书籍:

        // add thirty books
        foreach(var id in Enumerable.Range(1, 30))
        {
            context.AddBook($"Book_{id}"); 
        }

接下来的部分通过将数字110添加到每本书的数量来更新书籍数量:

        // let's update the quantity of the books by adding 1, 2, 3, 4, 5 ...
        foreach (var quantity in Enumerable.Range(1, 10))
        {
            foreach (var id in Enumerable.Range(1, 30))
            {
                context.UpdateQuantity($"Book_{id}", quantity);
            }
        }

然后,在下一部分,我们将从每本书的数量中减去数字110

        foreach (var quantity in Enumerable.Range(1, 10))
        {
            foreach (var id in Enumerable.Range(1, 30))
            {
                context.UpdateQuantity($"Book_{id}", -quantity);
            }
        }

由于我们为每本书添加和移除了相同的数量,所以我们测试的最后部分将验证最终数量是否为0

        // all quantities should be 0
        foreach (var book in context.GetBooks())
        {
            Assert.AreEqual(0, book.Quantity);
        }

运行测试后,我们可以看到测试通过了:

因此,当测试在单个进程中运行时,存储库将按预期工作。但是,如果更新请求在单独的线程中执行会怎样呢?为了测试这一点,单元测试将被重构为在单独的线程中对InventoryContext类进行调用。

书籍的添加被移动到一个执行添加书籍的方法中(即在自己的线程中):

public Task AddBook(string book)
{
    return Task.Run(() =>
    {
        var context = new InventoryContext();
        Assert.IsTrue(context.AddBook(book));
    });
}

此外,更新数量步骤被移动到另一个具有类似方法的方法中:

public Task UpdateQuantity(string book, int quantity)
{
    return Task.Run(() =>
    {
        var context = new InventoryContext();
        Assert.IsTrue(context.UpdateQuantity(book, quantity));
    });
}

然后更新单元测试以调用新方法。值得注意的是,单元测试将等待所有书籍添加完成后再更新数量。

添加三十本书部分现在如下所示:

    // add thirty books
    foreach (var id in Enumerable.Range(1, 30))
    {
        tasks.Add(AddBook($"Book_{id}"));
    }

    Task.WaitAll(tasks.ToArray());
    tasks.Clear();

同样,更新数量被更改为在任务中调用Addsubtract方法:

    // let's update the quantity of the books by adding 1, 2, 3, 4, 5 ...
    foreach (var quantity in Enumerable.Range(1, 10))
    {
        foreach (var id in Enumerable.Range(1, 30))
        {
            tasks.Add(UpdateQuantity($"Book_{id}", quantity));
        }
    }

    // let's update the quantity of the books by subtractin 1, 2, 3, 4, 5 ...
    foreach (var quantity in Enumerable.Range(1, 10))
    {
        foreach (var id in Enumerable.Range(1, 30))
        {
            tasks.Add(UpdateQuantity($"Book_{id}", -quantity));
        }
    }

    // wait for all adds and subtracts to finish
    Task.WaitAll(tasks.ToArray());

重构后,单元测试不再成功完成,当单元测试现在运行时,会报告错误,指示在集合中找不到书籍。这将报告为“字典中未找到给定的键”。这是因为每次实例化上下文时,都会创建一个新的书籍集合。第一步是限制上下文的创建。这是通过更改构造函数的访问权限来完成的,以便该类不再可以直接实例化。相反,添加一个新的公共static属性,只支持get操作。该属性将返回InventoryContext类的底层static实例,并且如果实例丢失,将创建它:

internal class InventoryContext : IInventoryContext
{ 
    protected InventoryContext()
    {
        _books = new Dictionary<string, Book>();
    }

    private static InventoryContext _context;
    public static InventoryContext Singleton
    {
        get
        {
            if (_context == null)
            {
                _context = new InventoryContext();
            }

            return _context;
        }
    }
    ...
}    

这仍然不足以修复损坏的单元测试,但这是由于不同的原因。为了确定问题,单元测试在调试模式下运行,并在UpdateQuantity方法中设置断点。第一次运行时,我们可以看到已经创建了 28 本书并加载到书籍集合中,如下面的截图所示:

在单元测试的这一点上,我们期望有 30 本书;然而,在我们开始调查之前,让我们再次运行单元测试。这一次,当我们尝试访问书籍集合以添加新书时,我们遇到了一个“对象引用未设置为对象的实例”错误,如下面的截图所示:

此外,当单元测试第三次运行时,不再遇到“对象引用未设置为对象的实例”错误,但我们的集合中只有 27 本书,如下面的截图所示:

这种不可预测的行为是竞争条件的典型特征,并且表明共享资源,即InventoryContext单例,正在被多个线程处理而没有同步访问。静态对象的构造仍然允许创建多个InventoryContext单例的实例:

public static InventoryContext Singleton
{
    get
    {
        if (_context == null)
        {
            _context = new InventoryContext();
        }

        return _context;
    }
}

竞争条件是多个线程评估if语句为真,并且它们都尝试构造_context对象。所有线程都会成功,但它们会通过这样做覆盖先前构造的值。当然,这是低效的,特别是当构造函数是昂贵的操作时,但单元测试发现的问题是_context对象实际上是由一个线程在另一个线程或多个线程更新书籍集合之后构造的。这就是为什么书籍集合_books在运行之间具有不同数量的元素。

为了防止这个问题,该模式在构造函数周围使用锁定,如下所示:

private static object _lock = new object();
public static InventoryContext Singleton
{
    get
    { 
        if (_context == null)
        {
 lock (_lock)
            {
                _context = new InventoryContext();
            }
        }

        return _context;
    }
}

不幸的是,单元测试仍然失败。这是因为虽然一次只有一个线程可以进入锁定,但所有被阻塞的实例仍然会在阻塞线程完成后进入锁定。该模式通过在锁定内部进行额外检查来处理这种情况,以防构造已经完成:

public static InventoryContext Singleton
{
    get
    { 
        if (_context == null)
        {
            lock (_lock)
            {
 if (_context == null)
                {
                    _context = new InventoryContext();
                }
            }
        }

        return _context;
    }
}

前面的锁定是必不可少的,因为它防止静态的InventoryContext对象被多次实例化。不幸的是,我们的测试仍然没有始终通过;随着每次更改,单元测试越来越接近通过。一些单元测试运行将在没有错误的情况下完成,但偶尔,测试将以失败的结果完成,如下面的截图所示:

我们的静态存储库实例现在是线程安全的,但我们对书籍集合的访问不是。需要注意的一点是,所使用的Dictionary类不是线程安全的。幸运的是,.Net Framework 中有线程安全的集合可用。这些类确保了对集合的添加和删除是为多线程进程编写的。需要注意的是,只有添加和删除是线程安全的,因为这将在稍后变得重要。更新后的构造函数如下所示:

protected InventoryContext()
{
    _books = new ConcurrentDictionary<string, Book>();
}

微软建议在目标为.Net Framework 1.1 或更早版本的应用程序中,使用System.Collections.Concurrent中的线程安全集合,而不是System.Collections中对应的集合。

再次运行单元测试后,引入ConcurrentDictionary类仍然不足以防止书籍的错误维护。单元测试仍然失败。并发字典可以防止多个线程不可预测地添加和删除,但对集合中的项目本身没有任何保护。这意味着对集合中的对象的更新不是线程安全的。

让我们更仔细地看一下多线程环境中的竞争条件,以了解为什么会出现这种情况。

竞争条件示例

以下一系列图表描述了两个线程ThreadAThreadB之间概念上发生的情况。第一个图表显示了两个线程都没有从集合中获取任何值:

下图显示了两个线程都从名称为Chester的书籍集合中读取:

下图显示了ThreadA通过增加4来更新书籍的数量,而ThreadB通过增加3来更新书籍的数量:

然后,当更新后的书籍被持久化回集合时,我们得到了一个未知数量的结果,如下图所示:

为了避免这种竞争条件,我们需要在更新操作进行时阻止其他线程。在InventoryContext中,阻止其他线程的方法是在更新书籍数量时进行锁定:

public bool UpdateQuantity(string name, int quantity)
{
    lock (_lock)
    {
        _books[name].Quantity += quantity;
    }

    return true;
}

单元测试现在可以顺利完成,因为额外的锁定防止了不可预测的竞争条件。

InventoryContext类仍然不完整,因为它只是完成了足够的部分来说明单例和存储库模式。在后面的章节中,InventoryContext类将被改进以使用 Entity Framework,这是一个对象关系映射ORM)框架。此时,InventoryContext类将被改进以支持额外的功能。

AddInventoryCommand

有了我们的存储库后,三个InventoryCommand类可以完成。首先是AddInventoryCommand,如下所示:

internal class AddInventoryCommand : NonTerminatingCommand, IParameterisedCommand
{
    private readonly IInventoryContext _context;

    internal AddInventoryCommand(IUserInterface userInterface, IInventoryContext context) 
                                                            : base(userInterface)
    {
        _context = context;
    }

    public string InventoryName { get; private set; }

    /// <summary>
    /// AddInventoryCommand requires name
    /// </summary>
    /// <returns></returns>
    public bool GetParameters()
    {
        if (string.IsNullOrWhiteSpace(InventoryName))
            InventoryName = GetParameter("name");

        return !string.IsNullOrWhiteSpace(InventoryName);
    }

    protected override bool InternalCommand()
    {
        return _context.AddBook(InventoryName); 
    }
}

首先要注意的是,存储库IInventoryContext在构造函数中与前一章描述的IUserInterface接口一起被注入。命令还需要提供一个参数,即name这在实现了前一章中也涵盖的IParameterisedCommand接口的GetParameters方法中被检索。然后在InternalCommand方法中运行命令,该方法简单地在存储库上执行AddBook方法,并返回一个指示命令是否成功执行的布尔值。

TestInventoryContext

与上一章中使用的TestUserInterface类类似,TestInventoryContext类将用于模拟我们的存储库的行为,实现IInventoryContext接口。该类将支持接口的三种方法,以及支持在单元测试期间添加到集合中的两种附加方法和更新的书籍。

为了支持TestInventoryContext类,将使用两个集合:

private readonly IDictionary<string, Book> _seedDictionary;
private readonly IDictionary<string, Book> _books;

第一个用于存储书籍的起始集合,而第二个用于存储书籍的最终集合。构造函数如下所示;请注意字典是彼此的副本:

public TestInventoryContext(IDictionary<string, Book> books)
{
    _seedDictionary = books.ToDictionary(book => book.Key,
                                         book => new Book { Id = book.Value.Id, 
                                                            Name = book.Value.Name, 
                                                            Quantity = book.Value.Quantity });
    _books = books;
}

IInventoryContext方法被编写为更新和返回集合中的一本书,如下所示:

public Book[] GetBooks()
{
    return _books.Values.ToArray();
}

public bool AddBook(string name)
{
    _books.Add(name, new Book() { Name = name });

    return true;
}

public bool UpdateQuantity(string name, int quantity)
{
    _books[name].Quantity += quantity;

    return true;
}

在单元测试结束时,可以使用剩余的两种方法来确定起始和结束集合之间的差异:

public Book[] GetAddedBooks()
{
    return _books.Where(book => !_seedDictionary.ContainsKey(book.Key))
                                                    .Select(book => book.Value).ToArray();
}

public Book[] GetUpdatedBooks()
{ 
    return _books.Where(book => _seedDictionary[book.Key].Quantity != book.Value.Quantity)
                                                    .Select(book => book.Value).ToArray();
}

在软件行业中,关于模拟、存根、伪造和其他用于识别和/或分类测试中使用的类型或服务的差异存在一些混淆,这些类型或服务不适用于生产,但对于单元测试是必要的。这些依赖项可能具有与其真实对应项不同、缺失和/或相同的功能。

例如,TestUserInterface类可以被称为模拟,因为它提供了对单元测试的一些期望(例如,断言语句),而TestInventoryContext类将是伪造的,因为它提供了一个工作实现。在本书中,我们不会严格遵循这些分类。

AddInventoryCommandTest

团队已经更新了AddInventoryCommandTest来验证AddInventoryCommand的功能。此测试将验证向现有库存中添加一本书。测试的第一部分是定义接口的预期,这只是一个单独的提示,用于接收新书名(请记住TestUserInterface类需要三个参数:预期输入、预期消息和预期警告):

const string expectedBookName = "AddInventoryUnitTest";
var expectedInterface = new Helpers.TestUserInterface(
    new List<Tuple<string, string>>
    {
        new Tuple<string, string>("Enter name:", expectedBookName)
    },
    new List<string>(),
    new List<string>()
);

TestInventoryContext类将初始化为模拟现有书籍集合中的一本书:

var context = new TestInventoryContext(new Dictionary<string, Book>
{
    { "Gremlins", new Book { Id = 1, Name = "Gremlins", Quantity = 7 } }
});

以下代码片段显示了AddInventoryCommand的创建、命令的运行以及用于验证命令成功运行的断言语句:

// create an instance of the command
var command = new AddInventoryCommand(expectedInterface, context);

// add a new book with parameter "name"
var result = command.RunCommand();

Assert.IsFalse(result.shouldQuit, "AddInventory is not a terminating command.");
Assert.IsTrue(result.wasSuccessful, "AddInventory did not complete Successfully.");

// verify the book was added with the given name with 0 quantity
Assert.AreEqual(1, context.GetAddedBooks().Length, "AddInventory should have added one new book.");

var newBook = context.GetAddedBooks().First();
Assert.AreEqual(expectedBookName, newBook.Name, "AddInventory did not add book successfully."); 

命令运行后,将验证结果是否无错误运行,并且命令不是终止命令。Assert语句的其余部分验证了预期只添加了一本带有预期名称的书。

UpdateQuantityCommand

UpdateQuantityCommandAddInventoryCommand非常相似,其源代码如下:

internal class UpdateQuantityCommand : NonTerminatingCommand, IParameterisedCommand
{
    private readonly IInventoryContext _context; 

    internal UpdateQuantityCommand(IUserInterface userInterface, IInventoryContext context) 
                                                                            : base(userInterface)
    {
        _context = context;
    }

    internal string InventoryName { get; private set; }

    private int _quantity;
    internal int Quantity { get => _quantity; private set => _quantity = value; }

    ...
}

AddInventoryCommand一样,UpdateInventoryCommand命令是一个带参数的非终止命令。因此,它扩展了NonTerminatingCommand基类,并实现了IParameterisedCommand接口。同样,IUserInterfaceIInventoryContext的依赖项在构造函数中注入:

    /// <summary>
    /// UpdateQuantity requires name and an integer value
    /// </summary>
    /// <returns></returns>
    public bool GetParameters()
    {
        if (string.IsNullOrWhiteSpace(InventoryName))
            InventoryName = GetParameter("name");

        if (Quantity == 0)
            int.TryParse(GetParameter("quantity"), out _quantity);

        return !string.IsNullOrWhiteSpace(InventoryName) && Quantity != 0;
    }   

UpdateQuantityCommand类确实具有一个额外的参数quantity,该参数是作为GetParameters方法的一部分确定的。

最后,通过存储库的InternalCommand重写方法,更新书的数量:

    protected override bool InternalCommand()
    {
        return _context.UpdateQuantity(InventoryName, Quantity);
    }

现在UpdateQuantityCommand类已经定义,接下来的部分将添加一个单元测试来验证该命令。

UpdateQuantityCommandTest

UpdateQuantityCommandTest包含一个测试,用于验证在现有集合中更新书籍的情景。预期接口和现有集合的创建如下代码所示(请注意,测试涉及将6添加到现有书的数量):

const string expectedBookName = "UpdateQuantityUnitTest";
var expectedInterface = new Helpers.TestUserInterface(
    new List<Tuple<string, string>>
    {
        new Tuple<string, string>("Enter name:", expectedBookName),
        new Tuple<string, string>("Enter quantity:", "6")
    },
    new List<string>(),
    new List<string>()
);

var context = new TestInventoryContext(new Dictionary<string, Book>
{
    { "Beavers", new Book { Id = 1, Name = "Beavers", Quantity = 3 } },
    { expectedBookName, new Book { Id = 2, Name = expectedBookName, Quantity = 7 } },
    { "Ducks", new Book { Id = 3, Name = "Ducks", Quantity = 12 } }
});

下面的代码块显示了命令的运行以及非终止命令成功运行的初始验证:

// create an instance of the command
var command = new UpdateQuantityCommand(expectedInterface, context);

var result = command.RunCommand();

Assert.IsFalse(result.shouldQuit, "UpdateQuantity is not a terminating command.");
Assert.IsTrue(result.wasSuccessful, "UpdateQuantity did not complete Successfully.");

测试的期望是不会添加新书籍,并且现有书籍的数量为 7,将增加 6,结果为新数量为 13:

Assert.AreEqual(0, context.GetAddedBooks().Length, 
                    "UpdateQuantity should not have added one new book.");

var updatedBooks = context.GetUpdatedBooks();
Assert.AreEqual(1, updatedBooks.Length, 
                    "UpdateQuantity should have updated one new book.");
Assert.AreEqual(expectedBookName, updatedBooks.First().Name, 
                    "UpdateQuantity did not update the correct book.");
Assert.AreEqual(13, updatedBooks.First().Quantity, 
                    "UpdateQuantity did not update book quantity successfully.");

添加了 UpdateQuantityCommand 类后,将在下一节中添加检索库存的能力。

GetInventoryCommand

GetInventoryCommand 命令与前两个命令不同,因为它不需要任何参数。它使用 IUserInterface 依赖项和 IInventoryContext 依赖项来写入集合的内容。如下所示:

internal class GetInventoryCommand : NonTerminatingCommand
{
    private readonly IInventoryContext _context;
    internal GetInventoryCommand(IUserInterface userInterface, IInventoryContext context) 
                                                           : base(userInterface)
    {
        _context = context;
    }

    protected override bool InternalCommand()
    {
        foreach (var book in _context.GetBooks())
        {
            Interface.WriteMessage($"{book.Name,-30}\tQuantity:{book.Quantity}"); 
        }

        return true;
    }
}

实现了 GetInventoryCommand 命令后,下一步是添加一个新的测试。

GetInventoryCommandTest

GetInventoryCommandTest 涵盖了当使用 GetInventoryCommand 命令检索书籍集合时的场景。测试将定义预期的消息(记住,第一个参数是用于参数,第二个参数是用于消息,第三个参数是用于警告),这些消息将在测试用户界面时发生:

var expectedInterface = new Helpers.TestUserInterface(
    new List<Tuple<string, string>>(),
    new List<string>
    {
        "Gremlins                      \tQuantity:7",
        "Willowsong                    \tQuantity:3",
    },
    new List<string>()
);

这些消息将对应于模拟存储库,如下所示:

var context = new TestInventoryContext(new Dictionary<string, Book>
{
    { "Gremlins", new Book { Id = 1, Name = "Gremlins", Quantity = 7 } },
    { "Willowsong", new Book { Id = 2, Name = "Willowsong", Quantity = 3 } },
});

单元测试使用模拟依赖项运行命令。它验证命令是否无错误执行,并且命令不是终止命令:

// create an instance of the command
var command = new GetInventoryCommand(expectedInterface, context); 
var result = command.RunCommand();

Assert.IsFalse(result.shouldQuit, "GetInventory is not a terminating command.");

预期的消息在 TestUserInterface 中进行验证,因此单元测试剩下的唯一任务就是确保命令没有神秘地添加或更新书籍:

Assert.AreEqual(0, context.GetAddedBooks().Length, "GetInventory should not have added any books.");
Assert.AreEqual(0, context.GetUpdatedBooks().Length, "GetInventory should not have updated any books.");

现在已经添加了适合 GetInventoryCommand 类的单元测试,我们将引入工厂模式来管理特定命令的创建。

工厂模式

团队应用的下一个模式是 GoF 工厂模式。该模式引入了一个创建者,其责任是实例化特定类型的实现。它的目的是封装围绕构造类型的复杂性。工厂模式允许更灵活地应对应用程序的变化,通过限制所需更改的数量,而不是在调用类中进行构造。这是因为构造的复杂性在一个位置,而不是分布在应用程序的多个位置。

在 FlixOne 示例中,InventoryCommandFactory 实现了该模式,并屏蔽了构造每个不同的 InventoryCommand 实例的细节。在这种情况下,从控制台应用程序接收到的输入将用于确定要返回的 InventoryCommand 的具体实现。重要的是要注意返回类型是 InventoryCommand 抽象类,因此屏蔽了调用类对具体类的细节。

InventoryCommandFactory 在下面的代码块中显示。但是,现在专注于 GetCommand 方法,因为它实现了工厂模式:

public class InventoryCommandFactory : IInventoryCommandFactory
{
    private readonly IUserInterface _userInterface;
    private readonly IInventoryContext _context = InventoryContext.Instance;

    public InventoryCommandFactory(IUserInterface userInterface)
    {
        _userInterface = userInterface;
    }

    ...
}

GetCommand 使用给定的字符串来确定要返回的 InventoryCommand 的特定实现:

public InventoryCommand GetCommand(string input)
{
    switch (input)
    {
        case "q":
        case "quit":
            return new QuitCommand(_userInterface);
        case "a":
        case "addinventory":
            return new AddInventoryCommand(_userInterface, _context);
        case "g":
        case "getinventory":
            return new GetInventoryCommand(_userInterface, _context);
        case "u":
        case "updatequantity":
            return new UpdateQuantityCommand(_userInterface, _context);
        case "?":
            return new HelpCommand(_userInterface);
        default:
            return new UnknownCommand(_userInterface);
    }
}

所有命令都需要提供 IUserInterface,但有些还需要访问存储库。这些将使用 IInventoryContext 的单例实例提供。

工厂模式通常与接口一起使用作为返回类型。在这里,它被说明为 InventoryCommand 基类。

单元测试

乍一看,为这样一个简单的类构建单元测试似乎是团队时间的浪费。通过构建单元测试,发现了两个重要问题,这些问题可能会被忽略。

问题一 - UnknownCommand

第一个问题是当接收到一个不匹配任何已定义的 InventoryCommand 输入的命令时该怎么办。在审查要求后,团队注意到他们错过了这个要求,如下面的截图所示:

团队决定引入一个新的InventoryCommand类,UnknownCommand,来处理这种情况。 UnknownCommand类应该通过IUserInterfaceWriteWarning方法向控制台打印警告消息,不应导致应用程序结束,并且应返回 false 以指示命令未成功运行。 实现细节如下所示:

internal class UnknownCommand : NonTerminatingCommand
{ 
    internal UnknownCommand(IUserInterface userInterface) : base(userInterface)
    {
    }

    protected override bool InternalCommand()
    { 
        Interface.WriteWarning("Unable to determine the desired command."); 

        return false;
    }
}

UnknownCommand创建的单元测试将测试警告消息以及InternalCommand方法返回的两个布尔值:

[TestClass]
public class UnknownCommandTests
{
    [TestMethod]
    public void UnknownCommand_Successful()
    {
        var expectedInterface = new Helpers.TestUserInterface(
            new List<Tuple<string, string>>(),
            new List<string>(),
            new List<string>
            {
                "Unable to determine the desired command."
            }
        ); 

        // create an instance of the command
        var command = new UnknownCommand(expectedInterface);

        var result = command.RunCommand();

        Assert.IsFalse(result.shouldQuit, "Unknown is not a terminating command.");
        Assert.IsFalse(result.wasSuccessful, "Unknown should not complete Successfully.");
    }
}

UnknownCommandTests覆盖了需要测试的命令。 接下来,将实现围绕InventoryCommandFactory的测试。

InventoryCommandFactoryTests

InventoryCommandFactoryTests包含与InventoryCommandFactory相关的单元测试。 因为每个测试都将具有类似的模式,即构造InventoryCommandFactory及其IUserInterface依赖项,然后运行GetCommand方法,因此创建了一个共享方法,该方法将在测试初始化时运行:

[TestInitialize]
public void Initialize()
{
    var expectedInterface = new Helpers.TestUserInterface(
        new List<Tuple<string, string>>(),
        new List<string>(),
        new List<string>()
    ); 

    Factory = new InventoryCommandFactory(expectedInterface);
}

Initialize方法构造了一个存根IUserInterface并设置了Factory属性。 然后,各个单元测试采用简单的形式,验证返回的对象是否是正确的类型。 首先,当用户输入"q""quit"时,应返回QuitCommand类的实例,如下所示:

[TestMethod]
public void QuitCommand_Successful()
{ 
    Assert.IsInstanceOfType(Factory.GetCommand("q"), typeof(QuitCommand), 
                                                            "q should be QuitCommand");
    Assert.IsInstanceOfType(Factory.GetCommand("quit"), typeof(QuitCommand), 
                                                            "quit should be QuitCommand");
}

QuitCommand_Successful测试方法验证了当运行InventoryCommandFactory方法GetCommand时,返回的对象是QuitCommand类型的特定实例。 当提交"?"时,HelpCommand才可用:

[TestMethod]
public void HelpCommand_Successful()
{
    Assert.IsInstanceOfType(Factory.GetCommand("?"), typeof(HelpCommand), "h should be HelpCommand"); 
}

团队确实添加了一个针对UnknownCommand的测试,验证了当给出与现有命令不匹配的值时,InventoryCommand将如何响应:

[TestMethod]
public void UnknownCommand_Successful()
{
    Assert.IsInstanceOfType(Factory.GetCommand("add"), typeof(UnknownCommand), 
                                                        "unmatched command should be UnknownCommand");
    Assert.IsInstanceOfType(Factory.GetCommand("addinventry"), typeof(UnknownCommand), 
                                                        "unmatched command should be UnknownCommand");
    Assert.IsInstanceOfType(Factory.GetCommand("h"), typeof(UnknownCommand), 
                                                        "unmatched command should be UnknownCommand");
    Assert.IsInstanceOfType(Factory.GetCommand("help"), typeof(UnknownCommand), 
                                                        "unmatched command should be UnknownCommand");
}

有了测试方法,现在我们可以涵盖一种情况,即在应用程序中给出一个不匹配已知命令的命令。

问题二 - 不区分大小写的文本命令

第二个问题是在再次审查要求时发现的,即命令不应区分大小写:

通过对UpdateInventoryCommand的测试,发现InventoryCommandFactory在以下测试中是区分大小写的:

[TestMethod]
public void UpdateQuantityCommand_Successful()
{
    Assert.IsInstanceOfType(Factory.GetCommand("u"), 
                            typeof(UpdateQuantityCommand), 
                            "u should be UpdateQuantityCommand");
    Assert.IsInstanceOfType(Factory.GetCommand("updatequantity"), 
                            typeof(UpdateQuantityCommand), 
                            "updatequantity should be UpdateQuantityCommand");
    Assert.IsInstanceOfType(Factory.GetCommand("UpdaTEQuantity"), 
                            typeof(UpdateQuantityCommand), 
                            "UpdaTEQuantity should be UpdateQuantityCommand");
}

幸运的是,通过在确定命令之前对输入应用ToLower()方法,这个测试很容易解决,如下所示:

public InventoryCommand GetCommand(string input)
{
    switch (input.ToLower())
    {
        ...
    }
}

这种情况突出了Factory方法的价值以及利用单元测试来帮助验证开发过程中的需求的价值,而不是依赖用户测试。

.NET Core 中的功能

第三章,实现设计模式 - 基础部分 1,以及本章的第一部分已经演示了 GoF 模式,而没有使用任何框架。 有必要覆盖这一点,因为有时针对特定模式可能没有可用的框架,或者在特定场景中不适用。 此外,了解框架提供的功能是很重要的,以便知道何时应该使用某种模式。 本章的其余部分将介绍.NET Core 提供的一些功能,支持我们迄今为止已经涵盖的一些模式。

IServiceCollection

.NET Core 设计时内置了依赖注入DI)。 通常,.NET Core 应用程序的启动包含为应用程序设置 DI 的过程,主要包括创建服务集合。 框架在应用程序需要时使用这些服务来提供依赖项。 这些服务为强大的控制反转IoC)框架奠定了基础,并且可以说是.NET Core 最酷的功能之一。 本节将完成控制台应用程序,并演示.NET Core 如何基于IServiceCollection接口支持构建复杂的 IoC 框架。

IServiceCollection接口用于定义容器中可用的服务,该容器实现了IServiceProvider接口。这些服务本身是在应用程序需要时在运行时注入的类型。例如,之前定义的ConsoleUserInterface接口将在运行时作为服务注入。这在下面的代码中显示:

IServiceCollection services = new ServiceCollection();
services.AddTransient<IUserInterface, ConsoleUserInterface>();

在上述代码中,ConsoleUserInterface接口被添加为实现IUserInterface接口的服务。如果 DI 提供了另一种需要IUserInterface接口依赖的类型,那么将使用ConsoleUserInterface接口。例如,InventoryCommandFactory也被添加到服务中,如下面的代码所示:

services.AddTransient<IInventoryCommandFactory, InventoryCommandFactory>();

InventoryCommandFactory有一个需要IUserInterface接口实现的构造函数:

public class InventoryCommandFactory : IInventoryCommandFactory
{
    private readonly IUserInterface _userInterface;

    public InventoryCommandFactory(IUserInterface userInterface)
    {
        _userInterface = userInterface;
    }
    ...
}

稍后,请求一个InventoryCommandFactory的实例,如下所示:

IServiceProvider serviceProvider = services.BuildServiceProvider();
var service = serviceProvider.GetService<IInventoryCommandFactory>();
service.GetCommand("a");

然后,IUserInterface的一个实例(在这个应用程序中是注册的ConsoleUserInterface)被实例化并提供给InventoryCommandFactory的构造函数。

在注册服务时可以指定不同类型的服务生命周期。生命周期规定了类型将如何实例化,包括瞬态、作用域和单例。瞬态意味着每次请求时都会创建服务。作用域将在后面讨论,特别是在查看与网站相关的模式时,服务是按照网页请求创建的。单例的行为类似于我们之前讨论的单例模式,并且将在本章后面进行讨论。

CatalogService

CatalogService接口代表团队正在构建的控制台应用程序,并被描述为具有一个Run方法,如ICatalogService接口中所示:

interface ICatalogService
{
    void Run();
}

该服务有两个依赖项,IUserInterfaceIInventoryCommandFactory,它们将被注入到构造函数中并存储为局部变量:

public class CatalogService : ICatalogService
{
    private readonly IUserInterface _userInterface;
    private readonly IInventoryCommandFactory _commandFactory;

    public CatalogService(IUserInterface userInterface, IInventoryCommandFactory commandFactory)
    {
        _userInterface = userInterface;
        _commandFactory = commandFactory;
    }
    ...
}

Run方法基于团队在第三章中展示的早期设计。它打印一个问候语,然后循环,直到用户输入退出库存命令为止。每次循环都会执行命令,如果命令不成功,它将打印一个帮助消息:

public void Run()
{
    Greeting();

    var response = _commandFactory.GetCommand("?").RunCommand();

    while (!response.shouldQuit)
    {
        // look at this mistake with the ToLower()
        var input = _userInterface.ReadValue("> ").ToLower();
        var command = _commandFactory.GetCommand(input);

        response = command.RunCommand();

        if (!response.wasSuccessful)
        {
            _userInterface.WriteMessage("Enter ? to view options.");
        }
    }
}

现在我们已经准备好了CatalogService接口,下一步将是把所有东西放在一起。下一节将使用.NET Core 来完成这一点。

IServiceProvider

有了CatalogService定义,团队最终能够在.NET Core 中将所有东西放在一起。所有应用程序的开始,即 EXE 程序,都是Main方法,.NET Core 也不例外。程序如下所示:

class Program
{
    private static void Main(string[] args)
    {
        IServiceCollection services = new ServiceCollection();
        ConfigureServices(services);
        IServiceProvider serviceProvider = services.BuildServiceProvider();

        var service = serviceProvider.GetService<ICatalogService>();
        service.Run();

        Console.WriteLine("CatalogService has completed.");
    }

    private static void ConfigureServices(IServiceCollection services)
    {
        // Add application services.
        services.AddTransient<IUserInterface, ConsoleUserInterface>(); 
        services.AddTransient<ICatalogService, CatalogService>();
        services.AddTransient<IInventoryCommandFactory, InventoryCommandFactory>(); 
    }
}

ConfigureServices方法中,不同类型被添加到 IoC 容器中,包括ConsoleUserInterfaceCatalogServiceInventoryCommandFactory类。ConsoleUserInterfaceInventoryCommandFactory类将根据需要注入,而CatalogService类将从ServiceCollection对象中包含的添加类型构建的IServiceProvider接口中显式检索出来。程序将一直运行,直到CatalogServiceRun方法完成。

在第五章中,实现设计模式-.NET Core,将重新讨论单例模式,使用.NET Core 内置的能力,通过使用IServiceCollectionAddSingleton方法来控制InventoryContext实例。

控制台应用程序

控制台应用程序在命令行中运行时很简单,但它是一个遵循 SOLID 原则的良好设计代码的基础,这些原则在第三章中讨论过,实现设计模式-基础部分 1。运行时,应用程序提供一个简单的问候,并显示一个帮助消息,包括命令的支持和示例:

然后应用程序循环执行命令,直到收到退出命令。以下屏幕截图说明了其功能:

这并不是最令人印象深刻的控制台应用程序,但它用来说明了许多原则和模式。

摘要

与第三章类似,实现设计模式-基础部分 1,本章继续描述了为 FlixOne 构建库存管理控制台应用程序,以展示使用面向对象编程(OOP)设计模式的实际示例。在本章中,GoF 的单例模式和工厂模式是重点。这两种模式在.NET Core 应用程序中起着特别重要的作用,并将在接下来的章节中经常使用。本章还介绍了使用内置框架提供 IoC 容器的方法。

本章以一个符合第三章 实现设计模式-基础部分 1中确定的要求的工作库存管理控制台应用程序结束。这些要求是两章中创建的单元测试的基础,并用于说明 TDD。通过拥有一套验证本开发阶段所需功能的测试,团队对应用程序能够通过用户验收测试(UAT)有更高的信心。

在下一章中,我们将继续描述构建库存管理应用程序。重点将从基本的面向对象编程模式转移到使用.NET Core 框架来实现不同的模式。例如,本章介绍的单例模式将被重构以利用IServiceCollection的能力来创建单例,我们还将更仔细地研究其依赖注入能力。此外,该应用程序将扩展以支持使用各种日志提供程序进行日志记录。

问题

以下问题将帮助您巩固本章中包含的信息:

  1. 提供一个例子,说明为什么使用单例模式不是限制访问共享资源的好机制。

  2. 以下陈述是否正确?为什么?ConcurrentDictionary可以防止集合中的项目被多个线程同时更新。

  3. 什么是竞态条件,为什么应该避免?

  4. 工厂模式如何帮助简化代码?

  5. .NET Core 应用程序需要第三方 IoC 容器吗?

第五章:实现设计模式-.NET Core

上一章继续构建 FlixOne 库存管理应用程序,同时还包括其他模式。使用了更多的四人帮模式,包括 Singleton 和 Factory 模式。Singleton 模式用于说明用于维护 FlixOne 图书集合的 Repository 模式。Factory 模式用于进一步探索依赖注入DI)。使用.NET Core 框架完成了初始库存管理控制台应用程序,以便实现控制反转IoC)容器。

本章将继续构建库存管理控制台应用程序,同时还将探索.NET Core 的特性。将重新访问并创建上一章中介绍的 Singleton 模式,使用内置于.NET Core 框架中的 Singleton 服务生命周期。将展示使用框架的 DI 的配置模式,以及使用不同示例解释构造函数注入(CI)

本章将涵盖以下主题:

  • .Net Core 服务生命周期

  • 实现工厂

技术要求

本章包含用于解释概念的各种代码示例。代码保持简单,仅用于演示目的。大多数示例涉及使用 C#编写的.NET Core 控制台应用程序。

要运行和执行代码,您需要以下内容:

  • Visual Studio 2019(您也可以使用 Visual Studio 2017 版本 3 或更高版本运行应用程序)。

  • 设置.NET Core。

  • SQL Server(本章中使用的是 Express 版本)。

安装 Visual Studio

要运行这些代码示例,您需要安装 Visual Studio 2010 或更高版本。您可以使用您喜欢的 IDE。要做到这一点,请按照以下说明进行操作:

  1. 从以下链接下载 Visual Studio:docs.microsoft.com/en-us/visualstudio/install/install-visual-studio

  2. 按照包含的安装说明进行操作。Visual Studio 有多个版本可供安装。在本章中,我们使用的是 Windows 版的 Visual Studio。

设置.NET Core

如果您没有安装.NET Core,则需要按照以下说明进行操作:

  1. 从以下链接下载.NET Core:www.microsoft.com/net/download/windows

  2. 安装说明和相关库可以在以下链接找到:dotnet.microsoft.com/download/dotnet-core/2.2

完整的源代码可在 GitHub 存储库中找到。本章中显示的源代码可能不完整,因此建议检索源代码以运行示例。请参阅github.com/PacktPublishing/Hands-On-Design-Patterns-with-C-and-.NET-Core/tree/master/Chapter5.

.Net Core 服务生命周期

在使用.NET Core 的 DI 时,理解服务生命周期是一个基本概念。服务生命周期定义了依赖项的管理方式,以及它被创建的频率。作为这一过程的说明,将 DI 视为管理依赖项的容器。依赖项只是 DI 知道的一个类,因为该类已经与它注册。对于.NET Core 的 DI,可以使用IServiceCollection的以下三种方法来完成这一过程:

  • AddTransient<TService, TImplementation>()

  • AddScoped<TService, TImplementation>()

  • AddSingleton<TService, TImplementation>()

IServiceCollection接口是已注册的服务描述的集合,基本上包含依赖项以及 DI 应该何时提供依赖项。例如,当请求TService时,会提供TImplementation(也就是注入)。

在本节中,我们将查看三种服务生命周期,并通过单元测试提供不同生命周期的示例。我们还将看看如何使用实现工厂来创建依赖项的实例。

瞬态

瞬态依赖项意味着每次 DI 接收到对依赖项的请求时,将创建依赖项的新实例。在大多数情况下,这是最合理使用的服务生命周期,因为大多数类应设计为轻量级、无状态的服务。在需要在引用之间保持状态和/或在实例化新实例方面需要大量工作的情况下,可能会更合理地使用另一种服务生命周期。

作用域

在.Net Core 中,有一个作用域的概念,可以将其视为执行过程的上下文或边界。在某些.Net Core 实现中,作用域是隐式定义的,因此您可能不知道它已经被放置。例如,在 ASP.Net Core 中,为接收到的每个 Web 请求创建一个作用域。这意味着,如果一个依赖项具有作用域生命周期,那么它将仅在每个 Web 请求中构造一次,因此,如果相同的依赖项在同一 Web 请求中多次使用,它将被共享。

在本章后面,我们将明确创建一个范围,以说明作用域生命周期,相同的概念也适用于单元测试,就像在 ASP.Net Core 应用程序中一样。

单例

在.Net Core 中,Singleton 模式的实现方式是依赖只被实例化一次,就像在上一章中实现的 Singleton 模式一样。与上一章中的 Singleton 模式类似,singleton类需要是线程安全的,只有用于创建单例类的工厂方法才能保证只被单个线程调用一次。

回到 FlixOne

为了说明.Net Core 的 DI,我们需要对 FlixOne 库存管理应用程序进行一些修改。首先要做的是更新之前定义的InventoryContext类,以便不再实现 Singleton 模式(因为我们将使用.Net Core 的 DI 来实现):

public class InventoryContext : IInventoryContext
{
    public InventoryContext()
    {
       _books = new ConcurrentDictionary<string, Book>();
    }

    private readonly static object _lock = new object(); 

    private readonly IDictionary<string, Book> _books;

    public Book[] GetBooks()
    {
        return _books.Values.ToArray();
    }

    ...
}

AddBookUpdateQuantity方法的详细信息如下所示:

public bool AddBook(string name)
{
    _books.Add(name, new Book {Name = name});
    return true;
}

public bool UpdateQuantity(string name, int quantity)
{
    lock (_lock)
    {
        _books[name].Quantity += quantity;
    }

    return true;
}

有几件事情需要注意。构造函数已从受保护更改为公共。这将允许类在类外部被实例化。还要注意,静态Instance属性和私有静态_instance字段已被删除,而私有_lock字段仍然存在。与上一章中定义的 Singleton 模式类似,这只保证了类的实例化方式;它并不阻止方法被并行访问。

IInventoryContext接口和InventoryContextBook类都被设为公共,因为我们的 DI 是在外部项目中定义的。

随后,用于返回命令的InventoryCommandFactory类已更新,以便在其构造函数中注入InventoryContext的实例:

public class InventoryCommandFactory : IInventoryCommandFactory
{
    private readonly IUserInterface _userInterface;
    private readonly IInventoryContext _context;

    public InventoryCommandFactory(IUserInterface userInterface, IInventoryContext context)
    {
        _userInterface = userInterface;
        _context = context;
    }

    // GetCommand()
    ...
}

GetCommand方法使用提供的输入来确定特定的命令:

public InventoryCommand GetCommand(string input)
{
    switch (input.ToLower())
    {
        case "q":
        case "quit":
            return new QuitCommand(_userInterface);
        case "a":
        case "addinventory":
            return new AddInventoryCommand(_userInterface, _context);
        case "g":
        case "getinventory":
            return new GetInventoryCommand(_userInterface, _context);
        case "u":
        case "updatequantity":
            return new UpdateQuantityCommand(_userInterface, _context);
        case "?":
            return new HelpCommand(_userInterface);
        default:
            return new UnknownCommand(_userInterface);
    }
}

如前所述,IInventoryContext接口现在将由客户端项目中定义的 DI 容器提供。控制台应用程序现在有一个额外的行来使用InventoryContext类创建IInventoryContext接口的单例:

class Program
{
    private static void Main(string[] args)
    {
        IServiceCollection services = new ServiceCollection();
        ConfigureServices(services);
        IServiceProvider serviceProvider = services.BuildServiceProvider();

        var service = serviceProvider.GetService<ICatalogService>();
        service.Run();

        Console.WriteLine("CatalogService has completed.");
        Console.ReadLine();
    }

    private static void ConfigureServices(IServiceCollection services)
    {
        // Add application services.
        services.AddTransient<IUserInterface, ConsoleUserInterface>(); 
        services.AddTransient<ICatalogService, CatalogService>();
        services.AddTransient<IInventoryCommandFactory, InventoryCommandFactory>();

 services.AddSingleton<IInventoryContext, InventoryContext>();
    }
}

控制台应用程序现在可以使用与上一章中执行的手动测试相同的方式运行,但是单元测试是了解使用.Net Core 的 DI 实现的成果的好方法。

本章提供的示例代码显示了完成的项目。接下来的部分集中在InventoryContext测试上。InventoryCommandFactory测试也进行了修改,但由于更改是微不足道的,因此不会在此处进行介绍。

单元测试

随着对InventoryContext类的更改,我们不再有一个方便的属性来获取该类的唯一实例。这意味着InventoryContext.Instance需要被替换,首先,让我们创建一个方法来返回InventoryContext的新实例,并使用GetInventoryContext()代替InventoryContext.Instance

private IInventoryContext GetInventoryContext()
{
    return new InventoryContext();
}

如预期的那样,单元测试失败,并显示错误消息:给定的键在字典中不存在

正如我们在上一章中看到的,这是因为每次创建InventoryContext类时,书籍的列表都是空的。这就是为什么我们需要使用 Singleton 创建一个上下文的原因。

让我们更新GetInventoryContext()方法,现在使用.Net Core 的 DI 来提供IInventoryContext接口的实例:

private IInventoryContext GetInventoryContext()
{
    IServiceCollection services = new ServiceCollection();
    services.AddSingleton<IInventoryContext, InventoryContext>();
    var provider = services.BuildServiceProvider();

    return provider.GetService<IInventoryContext>();
}

在更新的方法中,创建了ServiceCollection类的一个实例,用于包含所有注册的依赖项。InventoryContext类被注册为 Singleton,以便在请求IInventoryContext依赖项时提供。然后生成了一个ServiceProvider实例,它将根据IServiceCollection接口中的注册执行 DI。最后一步是在请求IInventoryContext接口时提供InventoryContext类。

Microsoft.Extensions.DependencyInjection库需要添加到InventoryManagementTests项目中,以便能够引用.Net Core DI 组件。

很不幸,单元测试仍然无法通过,并且导致相同的错误:给定的键在字典中不存在。这是因为每次请求IInventoryContext时,我们都会创建一个新的 DI 框架实例。这意味着,即使我们的依赖是一个 Singleton,每个ServiceProvider实例都会提供一个新的InventoryContext类的实例。为了解决这个问题,我们将在测试启动时创建IServiceCollection,然后在测试期间使用相同的引用:

ServiceProvider Services { get; set; }

[TestInitialize]
public void Startup()
{
    IServiceCollection services = new ServiceCollection();
    services.AddSingleton<IInventoryContext, InventoryContext>();
    Services = services.BuildServiceProvider();
}

使用TestInitialize属性是在TestClass类中分离多个TestMethod测试所需的功能的好方法。该方法将在每次测试运行之前运行。

现在有了对同一个ServiceProvider实例的引用,我们可以更新以检索依赖项。以下说明了AddBook()方法的更新方式:

public Task AddBook(string book)
{
    return Task.Run(() =>
    {
        Assert.IsTrue(Services.GetService<IInventoryContext>().AddBook(book));
    });
}

我们的单元测试现在成功通过,因为在测试执行期间只创建了一个InventoryContext类的实例:

使用内置的 DI 相对容易实现 Singleton 模式,就像本节中所示。了解何时使用该模式是一个重要的概念。下一节将更详细地探讨作用域的概念,以便更深入地理解服务的生命周期。

作用域

在同时执行多个进程的应用程序中,了解服务生命周期对功能和非功能需求都非常重要。正如在上一个单元测试中所示,如果没有正确的服务生命周期,InventoryContext就无法按预期工作,并导致了一个无效的情况。同样,错误使用服务生命周期可能导致应用程序无法良好扩展。一般来说,在多进程解决方案中应避免使用锁和共享状态。

为了说明这个概念,想象一下 FlixOne 库存管理应用程序被提供给多个员工。现在的挑战是如何在多个应用程序之间执行锁定,以及如何拥有一个单一的收集状态。在我们的术语中,这将是多个应用程序共享的单个InventoryContext类。当然,这就是我们改变解决方案以使用共享存储库(例如数据库)或改变解决方案以使用 Web 应用程序的地方。我们将在后面的章节中涵盖数据库和 Web 应用程序模式,但是,由于我们正在讨论服务生命周期,现在更详细地描述这些内容是有意义的。

以下图示了一个 Web 应用程序接收两个请求:

在服务生命周期方面,单例服务生命周期将对两个请求都可用,而每个请求都会接收到自己的作用域生命周期。需要注意的重要事情是垃圾回收。使用瞬态服务生命周期创建的依赖项在对象不再被引用时标记为释放,而使用作用域服务生命周期创建的依赖项在 Web 请求完成之前不会被标记为释放。而使用单例服务生命周期创建的依赖项直到应用程序结束才会被标记为释放。

此外,如下图所示,重要的是要记住,在.Net Core 中,依赖项在 Web 园或 Web 农场中的服务器实例之间不共享:

在接下来的章节中,将展示共享状态的不同方法,包括使用共享缓存、数据库和其他形式的存储库。

实现工厂

.Net Core DI 支持在注册依赖项时指定实现工厂的能力。这允许对由提供的服务提供的依赖项的创建进行控制。在注册时使用IServiceCollection接口的以下扩展来完成:

public static IServiceCollection AddSingleton<TService, TImplementation>(this IServiceCollection services,     Func<IServiceProvider, TImplementation> implementationFactory)
                where TService : class
                where TImplementation : class, TService;

AddSingleton扩展接收要注册的类以及在需要依赖项时要提供的类。值得注意的是,.Net Core DI 框架将维护已注册的服务,并在请求时提供实现,或作为依赖项之一的实例化的一部分。这种自动实例化称为构造函数注入CI)。我们将在以下章节中看到这两种的例子。

IInventoryContext

举个例子,让我们重新审视一下用于管理书籍库存的InventoryContext类,通过将对书籍集合的读取和写入操作进行分离。IInventoryContext被分成了IInventoryReadContextIInventoryWriteContext

using FlixOne.InventoryManagement.Models;

namespace FlixOne.InventoryManagement.Repository
{
    public interface IInventoryContext : IInventoryReadContext, IInventoryWriteContext { }

    public interface IInventoryReadContext
    {
        Book[] GetBooks();
    }

    public interface IInventoryWriteContext
    {
        bool AddBook(string name);
        bool UpdateQuantity(string name, int quantity);
    }
}

IInventoryReadContext

IInventoryReadContext接口包含读取书籍的操作,而IInventoryWriteContext包含修改书籍集合的操作。最初创建IInventoryContext接口是为了方便一个类需要两种依赖类型时。

在后面的章节中,我们将涵盖利用分割上下文的模式,包括命令和 查询责任分离CQRS)模式。

通过这种重构,需要进行一些更改。首先,只需要读取书籍集合的类将其构造函数更新为IInventoryReadContext接口,如GetInventoryCommand类所示:

internal class GetInventoryCommand : NonTerminatingCommand
{
    private readonly IInventoryReadContext _context;
    internal GetInventoryCommand(IUserInterface userInterface, IInventoryReadContext context) : base(userInterface)
    {
        _context = context;
    }

    protected override bool InternalCommand()
    {
        foreach (var book in _context.GetBooks())
        {
            Interface.WriteMessage($"{book.Name,-30}\tQuantity:{book.Quantity}"); 
        }

        return true;
    }
}

IInventoryWriteContext

同样,需要修改书籍集合的类将其更新为IInventoryWriteContext接口,如AddInventoryCommand所示:

internal class AddInventoryCommand : NonTerminatingCommand, IParameterisedCommand
{
    private readonly IInventoryWriteContext _context;

    internal AddInventoryCommand(IUserInterface userInterface, IInventoryWriteContext context) : base(userInterface)
    {
        _context = context;
    }

    public string InventoryName { get; private set; }

    ...
}

以下显示了GetParametersInternalCommand方法的详细信息:

/// <summary>
/// AddInventoryCommand requires name
/// </summary>
/// <returns></returns>
public bool GetParameters()
{
    if (string.IsNullOrWhiteSpace(InventoryName))
        InventoryName = GetParameter("name");
    return !string.IsNullOrWhiteSpace(InventoryName);
}

protected override bool InternalCommand()
{
    return _context.AddBook(InventoryName); 
}

请注意 InternalCommand 方法,其中将带有 InventoryName 参数中保存的书名添加到库存中。

接下来,我们将看看库存命令的工厂。

InventoryCommandFactory

InventoryCommandFactory 类是使用 .Net 类实现工厂模式的一个实现,需要对书籍集合进行读取和写入:

public class InventoryCommandFactory : IInventoryCommandFactory
{
    private readonly IUserInterface _userInterface;
    private readonly IInventoryContext _context; 

    public InventoryCommandFactory(IUserInterface userInterface, IInventoryContext context)
    {
        _userInterface = userInterface;
        _context = context; 
    }

    public InventoryCommand GetCommand(string input)
    {
        switch (input.ToLower())
        {
            case "q":
            case "quit":
                return new QuitCommand(_userInterface);
            case "a":
            case "addinventory":
                return new AddInventoryCommand(_userInterface, _context);
            case "g":
            case "getinventory":
                return new GetInventoryCommand(_userInterface, _context);
            case "u":
            case "updatequantity":
                return new UpdateQuantityCommand(_userInterface, _context);
            case "?":
                return new HelpCommand(_userInterface);
            default:
                return new UnknownCommand(_userInterface);
        }
    }
}

值得注意的是,这个类实际上不需要修改前一章版本的内容,因为多态性处理了从 IInventoryContextIInventoryReadContextIInventoryWriteContext 接口的转换。

有了这些变化,我们需要改变与 InventoryContext 相关的依赖项的注册,以使用实现工厂:

private static void ConfigureServices(IServiceCollection services)
{
    // Add application services.
    ...            

    var context = new InventoryContext();
 services.AddSingleton<IInventoryReadContext, InventoryContext>(p => context);
 services.AddSingleton<IInventoryWriteContext, InventoryContext>(p => context);
 services.AddSingleton<IInventoryContext, InventoryContext>(p => context);
}

对于所有三个接口,将使用相同的 InventoryContext 实例,并且这是使用实现工厂扩展一次实例化的。当请求 IInventoryReadContextIInventoryWriteContextIInventoryContext 依赖项时提供。

InventoryCommand

InventoryCommandFactory 在展示如何使用 .Net 实现工厂模式时非常有用,但现在让我们重新审视一下,因为我们现在正在使用 .Net Core 框架。我们的要求是给定一个字符串值;我们希望返回 InventoryCommand 的特定实现。这可以通过几种方式实现,在本节中将给出三个示例:

  • 使用函数的实现工厂

  • 使用服务

  • 使用第三方容器

使用函数的实现工厂

GetService() 方法的实现工厂可以用于确定要返回的 InventoryCommand 类型。对于这个示例,在 InventoryCommand 类中创建了一个新的静态方法:

public static Func<IServiceProvider, Func<string, InventoryCommand>> GetInventoryCommand => 
                                                                            provider => input =>
{
    switch (input.ToLower())
    {
        case "q":
        case "quit":
            return new QuitCommand(provider.GetService<IUserInterface>());
        case "a":
        case "addinventory":
            return new AddInventoryCommand(provider.GetService<IUserInterface>(), provider.GetService<IInventoryWriteContext>());
        case "g":
        case "getinventory":
            return new GetInventoryCommand(provider.GetService<IUserInterface>(), provider.GetService<IInventoryReadContext>());
        case "u":
        case "updatequantity":
            return new UpdateQuantityCommand(provider.GetService<IUserInterface>(), provider.GetService<IInventoryWriteContext>());
        case "?":
            return new HelpCommand(provider.GetService<IUserInterface>());
        default:
            return new UnknownCommand(provider.GetService<IUserInterface>());
    }
};

如果您不熟悉 lambda 表达式体,这可能有点难以阅读,因此我们将详细解释一下代码。首先,让我们重新审视一下 AddSingleton 的语法:

public static IServiceCollection AddSingleton<TService, TImplementation>(this IServiceCollection services, Func<IServiceProvider, TImplementation> implementationFactory)
            where TService : class
            where TImplementation : class, TService;

这表明 AddSingleton 扩展的参数是一个函数:

Func<IServiceProvider, TImplementation> implementationFactory

这意味着以下代码是等价的:

services.AddSingleton<IInventoryContext, InventoryContext>(provider => new InventoryContext());

services.AddSingleton<IInventoryContext, InventoryContext>(GetInventoryContext);

GetInventoryContext 方法定义如下:

static Func<IServiceProvider, InventoryContext> GetInventoryContext => provider =>
{
    return new InventoryContext();
};

在我们的特定示例中,特定的 InventoryCommand 类型已被标记为 FlixOne.InventoryManagement 项目的内部,因此 FlixOne.InventoryManagementClient 项目无法直接访问它们。这就是为什么在 FlixOne.InventoryManagement.InventoryCommand 类中创建了一个新的静态方法,返回以下类型:

Func<IServiceProvider, Func<string, InventoryCommand>>

这意味着当请求服务时,将提供一个字符串来确定具体的类型。由于依赖项发生了变化,这意味着 CatalogService 构造函数需要更新:

public CatalogService(IUserInterface userInterface, Func<string, InventoryCommand> commandFactory)
{
    _userInterface = userInterface;
    _commandFactory = commandFactory;
}

当请求服务时,将提供一个字符串来确定具体的类型。由于依赖项发生了变化,CatalogueService 构造函数需要更新:

现在,当用户输入的字符串被提供给 CommandFactory 依赖项时,将提供正确的命令:

while (!response.shouldQuit)
{
    // look at this mistake with the ToLower()
    var input = _userInterface.ReadValue("> ").ToLower();
    var command = _commandFactory(input);

    response = command.RunCommand();

    if (!response.wasSuccessful)
    {
        _userInterface.WriteMessage("Enter ? to view options.");
    }
}

与命令工厂相关的单元测试也进行了更新。作为对比,从现有的 InventoryCommandFactoryTests 类创建了一个新的 test 类,并命名为 InventoryCommandFunctionTests。初始化步骤如下所示,其中突出显示了更改:

ServiceProvider Services { get; set; }

[TestInitialize]
public void Startup()
{
    var expectedInterface = new Helpers.TestUserInterface(
        new List<Tuple<string, string>>(),
        new List<string>(),
        new List<string>()
    );

    IServiceCollection services = new ServiceCollection();
    services.AddSingleton<IInventoryContext, InventoryContext>();
 services.AddTransient<Func<string, InventoryCommand>>(InventoryCommand.GetInventoryCommand);

    Services = services.BuildServiceProvider();
}

还更新了各个测试,以在 QuitCommand 中提供字符串作为获取服务调用的一部分,如下所示:

[TestMethod]
public void QuitCommand_Successful()
{
    Assert.IsInstanceOfType(Services.GetService<Func<string, InventoryCommand>>().Invoke("q"),             
                            typeof(QuitCommand), 
                            "q should be QuitCommand");

    Assert.IsInstanceOfType(Services.GetService<Func<string, InventoryCommand>>().Invoke("quit"),
                            typeof(QuitCommand), 
                            "quit should be QuitCommand");
}

这两个测试验证了当服务提供程序提供 "q""quit" 时,返回的服务类型是 QuitCommand

使用服务

ServiceProvider类提供了一个Services方法,可以用来确定适当的服务,当同一类型有多个依赖项注册时。这个例子将采用不同的方法处理InventoryCommands,由于重构的范围,这将通过新创建的类来完成,以说明这种方法。

在单元测试项目中,创建了一个新的文件夹ImplementationFactoryTests,用于包含本节的类。在这个文件夹中,创建了一个新的InventoryCommand基类:

public abstract class InventoryCommand
{
    protected abstract string[] CommandStrings { get; }
    public virtual bool IsCommandFor(string input)
    {
        return CommandStrings.Contains(input.ToLower());
    } 
}

这个新类背后的概念是,子类将定义它们要响应的字符串。例如,QuitCommand将响应"q""quit"字符串:

public class QuitCommand : InventoryCommand
{
    protected override string[] CommandStrings => new[] { "q", "quit" };
}

以下显示了GetInventoryCommandAddInventoryCommandUpdateQuantityCommandHelpCommand类,它们采用了类似的方法:

public class GetInventoryCommand : InventoryCommand
{
    protected override string[] CommandStrings => new[] { "g", "getinventory" };
}

public class AddInventoryCommand : InventoryCommand
{
    protected override string[] CommandStrings => new[] { "a", "addinventory" };
}

public class UpdateQuantityCommand : InventoryCommand
{
    protected override string[] CommandStrings => new[] { "u", "updatequantity" };
}

public class HelpCommand : InventoryCommand
{
    protected override string[] CommandStrings => new[] { "?" };
}

然而,UnknownCommand类将被用作默认值,因此它将始终通过重写IsCommandFor方法来评估为 true:

public class UnknownCommand : InventoryCommand
{
    protected override string[] CommandStrings => new string[0];

    public override bool IsCommandFor(string input)
    {
        return true;
    }
}

由于UnknownCommand类被视为默认值,注册的顺序很重要,在单元测试类的初始化中如下所示:

[TestInitialize]
public void Startup()
{
    var expectedInterface = new Helpers.TestUserInterface(
        new List<Tuple<string, string>>(),
        new List<string>(),
        new List<string>()
    );

    IServiceCollection services = new ServiceCollection(); 
    services.AddTransient<InventoryCommand, QuitCommand>();
    services.AddTransient<InventoryCommand, HelpCommand>(); 
    services.AddTransient<InventoryCommand, AddInventoryCommand>();
    services.AddTransient<InventoryCommand, GetInventoryCommand>();
    services.AddTransient<InventoryCommand, UpdateQuantityCommand>();
    // UnknownCommand should be the last registered
 services.AddTransient<InventoryCommand, UnknownCommand>();

    Services = services.BuildServiceProvider();
}

为了方便起见,创建了一个新的方法,以便在给定匹配输入字符串时返回InventoryCommand类的实例:

public InventoryCommand GetCommand(string input)
{
    return Services.GetServices<InventoryCommand>().First(svc => svc.IsCommandFor(input));
}

这个方法将遍历为InventoryCommand服务注册的依赖项集合,直到使用IsCommandFor()方法找到匹配项。

然后,单元测试使用GetCommand()方法来确定依赖项,如下所示,用于UpdateQuantityCommand

[TestMethod]
public void UpdateQuantityCommand_Successful()
{
    Assert.IsInstanceOfType(GetCommand("u"), 
                            typeof(UpdateQuantityCommand), 
                            "u should be UpdateQuantityCommand");

    Assert.IsInstanceOfType(GetCommand("updatequantity"), 
                            typeof(UpdateQuantityCommand), 
                            "updatequantity should be UpdateQuantityCommand");

    Assert.IsInstanceOfType(GetCommand("UpdaTEQuantity"), 
                            typeof(UpdateQuantityCommand), 
                            "UpdaTEQuantity should be UpdateQuantityCommand");
}

使用第三方容器

.Net Core 框架提供了很大的灵活性和功能,但可能不支持一些功能,第三方容器可能是更合适的选择。幸运的是,.Net Core 是可扩展的,允许用第三方容器替换内置的服务容器。为了举例,我们将使用Autofac作为.Net Core DI 的 IoC 容器。

Autofac有很多很棒的功能,在这里作为一个例子展示出来;当然,还有其他 IoC 容器可以使用。例如,Castle Windsor 和 Unit 都是很好的替代方案,也应该考虑使用。

第一步是将所需的Autofac包添加到项目中。使用包管理器控制台,使用以下命令添加包(仅在测试项目中需要):

install-package autofac

这个例子将再次通过使用Autofac的命名注册依赖项的功能来支持我们的InventoryCommand工厂。这些命名的依赖项将用于根据提供的输入来检索正确的InventoryCommand实例。

与之前的例子类似,依赖项的注册将在TestInitialize方法中完成。注册将根据将用于确定命令的命令命名。以下显示了创建ContainerBuilder对象的Startup方法结构,该对象将构建Container实例:

[TestInitialize]
public void Startup()
{
    IServiceCollection services = new ServiceCollection();

    var builder = new ContainerBuilder(); 

    // commands
    ...

    Container = builder.Build(); 
}

命令的注册如下:

// commands
builder.RegisterType<QuitCommand>().Named<InventoryCommand>("q");
builder.RegisterType<QuitCommand>().Named<InventoryCommand>("quit");
builder.RegisterType<UpdateQuantityCommand>().Named<InventoryCommand>("u");
builder.RegisterType<UpdateQuantityCommand>().Named<InventoryCommand>("updatequantity");
builder.RegisterType<HelpCommand>().Named<InventoryCommand>("?");
builder.RegisterType<AddInventoryCommand>().Named<InventoryCommand>("a");
builder.RegisterType<AddInventoryCommand>().Named<InventoryCommand>("addinventory");
builder.RegisterType<GetInventoryCommand>().Named<InventoryCommand>("g");
builder.RegisterType<GetInventoryCommand>().Named<InventoryCommand>("getinventory");
builder.RegisterType<UpdateQuantityCommand>().Named<InventoryCommand>("u");
builder.RegisterType<UpdateQuantityCommand>().Named<InventoryCommand>("u");
builder.RegisterType<UnknownCommand>().As<InventoryCommand>();

与之前的例子不同,生成的容器是Autofac.IContainer的实例。这将用于检索每个注册的依赖项。例如,QuitCommand将被命名为"q""quit",这表示可以用于执行命令的两个命令。另外,注意最后注册的类型没有命名,并属于UnknownCommand。如果没有找到命令,则这将充当默认值。

为了确定一个依赖项,将使用一个新方法来按名称检索依赖项:

public InventoryCommand GetCommand(string input)
{
    return Container.ResolveOptionalNamed<InventoryCommand>(input.ToLower()) ?? 
           Container.Resolve<InventoryCommand>();
}

Autofac.IContainer接口具有ResolveOptionalNamed<*T*>(*string*)方法名称,该方法将返回具有给定名称的依赖项,如果找不到匹配的注册,则返回 null。如果未使用给定名称注册依赖项,则将返回UnknownCommand类的实例。这是通过使用空值合并操作??IContainer.Resolve<*T*>方法来实现的。

如果依赖项解析失败,Autofac.IContainer.ResolveNamed<*T*>(*string*)将抛出ComponentNotRegisteredException异常。

为了确保正确解析命令,为每个命令编写了一个测试方法。再次以QuitCommand为例,我们可以看到以下内容:

[TestMethod]
public void QuitCommand_Successful()
{
    Assert.IsInstanceOfType(GetCommand("q"), typeof(QuitCommand), "q should be QuitCommand");
    Assert.IsInstanceOfType(GetCommand("quit"), typeof(QuitCommand), "quit should be QuitCommand");
}

请查看源代码中的InventoryCommandAutofacTests类,以获取其他InventoryCommand示例。

总结

本章的目标是更详细地探索.Net Core 框架,特别是.Net Core DI。支持三种类型的服务生命周期:瞬态(Transient)、作用域(Scoped)和单例(Singleton)。瞬态服务将为每个请求创建一个已注册依赖项的新实例。作用域服务将在定义的范围内生成一次,而单例服务将在 DI 服务集合的生命周期内执行一次。

由于.Net Core DI 对于自信地构建.Net Core 应用程序至关重要,因此了解其能力和局限性非常重要。重要的是要有效地使用 DI,同时避免重复使用已提供的功能。同样重要的是,了解.Net Core DI 框架的限制,以及其他 DI 框架的优势,以便在替换基本的.Net Core DI 框架为第三方 DI 框架可能对应用程序有益的情况下,能够明智地做出选择。

下一章将在前几章的基础上构建,并探索.Net Core ASP.Net Web 应用程序中的常见模式。

问题

以下问题将帮助您巩固本章中包含的信息:

  1. 如果不确定要使用哪种类型的服务生命周期,最好将类注册为哪种类型?为什么?

  2. 在.Net Core ASP.Net 解决方案中,作用域是按照每个 web 请求定义的,还是按照每个会话定义的?

  3. 在.Net Core DI 框架中将类注册为单例是否会使其线程安全?

  4. .Net Core DI 框架只能被其他由微软提供的 DI 框架替换吗?

第六章:为网络应用程序实施设计模式-第一部分

在本章中,我们将继续构建FlixOne库存管理应用程序(参见第三章,实施设计模式基础-第一部分),并讨论将控制台应用程序转换为网络应用程序。网络应用程序应该更吸引用户,而不是控制台应用程序;在这里,我们还将讨论为什么要进行这种改变。

本章将涵盖以下主题:

  • 创建一个.NET Core 网络应用程序

  • 制作一个网络应用程序

  • 实施 CRUD 页面

如果您尚未查看早期章节,请注意FlixOne Inventory Management网络应用程序是一个虚构的产品。我们创建这个应用程序来讨论网络项目中所需的各种设计模式。

技术要求

本章包含各种代码示例来解释概念。代码保持简单,仅用于演示目的。大多数示例涉及使用 C#编写的.NET Core控制台应用程序。

要运行和执行代码,您需要以下内容:

  • Visual Studio 2019(您也可以使用 Visual Studio 2017 更新 3 或更高版本来运行应用程序)

  • .NET Core 的环境设置

  • SQL Server(本章使用 Express 版本)

安装 Visual Studio

要运行这些代码示例,您需要安装 Visual Studio(2017)或更新版本,如 2019(或您可以使用您喜欢的 IDE)。要做到这一点,请按照以下步骤操作:

  1. 从以下网址下载 Visual Studio:docs.microsoft.com/en-us/visualstudio/install/install-visual-studio

  2. 按照包含的安装说明进行操作。Visual Studio 有多个版本可供安装。在本章中,我们使用的是 Windows 版的 Visual Studio。

设置.NET Core

如果您尚未安装.NET Core,则需要按照以下步骤操作:

  1. 从以下网址下载.NET Core:www.microsoft.com/net/download/windows

  2. 按照安装说明并关注相关库:dotnet.microsoft.com/download/dotnet-core/2.2

安装 SQL Server

如果您尚未安装 SQL Server,则需要按照以下说明操作:

  1. 从以下网址下载 SQL Server:www.microsoft.com/en-in/download/details.aspx?id=1695

  2. 您可以在以下网址找到安装说明:docs.microsoft.com/en-us/sql/ssms/download-sql-server-management-studio-ssms?view=sql-server-2017

有关故障排除和更多信息,请参阅:www.blackbaud.com/files/support/infinityinstaller/content/installermaster/tkinstallsqlserver2008r2.htm

本节旨在提供开始使用网络应用程序的先决条件信息。我们将在后续章节中详细了解更多细节。在本章中,我们将使用代码示例来详细解释各种术语和部分。

完整的源代码可在以下网址找到:github.com/PacktPublishing/Hands-On-Design-Patterns-with-C-and-.NET-Core/tree/master/Chapter6

创建一个.Net Core 网络应用程序

在本章的开头,我们讨论了我们基于 FlixOne 控制台的应用程序,并且业务团队确定了采用 Web 应用程序的各种原因。现在是时候对应用程序进行更改了。在本节中,我们将开始创建一个新的 UI,给我们现有的 FlixOne 应用程序一个新的外观和感觉。我们还将讨论所有的需求和初始化。

启动项目

在我们现有的 FlixOne 控制台应用程序的基础上,管理层决定对我们的 FlixOne 库存控制台应用程序进行大幅改进,增加了许多功能。管理层得出结论,我们必须将现有的控制台应用程序转换为基于 Web 的解决方案。

技术团队和业务团队一起坐下来,确定了废弃当前控制台应用程序的各种原因:

  • 界面不具有交互性。

  • 该应用程序并非随处可用。

  • 维护复杂。

  • 不断增长的业务需要一个可扩展的系统,具有更高的性能和适应性。

开发需求

以下的需求清单是讨论的结果。确定的高级需求如下:

  • 产品分类

  • 产品添加

  • 产品更新

  • 产品删除

业务要求实际上落在开发人员身上。这些技术需求包括以下内容:

  • 一个登陆或主页:这应该是一个包含各种小部件的仪表板,并且应该显示商店的摘要。

  • 产品页面:这应该具有添加、更新和删除产品和类别的功能。

打造 Web 应用程序

根据刚刚讨论的需求,我们的主要目标是将现有的控制台应用程序转换为 Web 应用程序。在这个转换过程中,我们将讨论 Web 应用程序的各种设计模式,以及这些设计模式在 Web 应用程序的背景下的重要性。

网络应用程序及其工作原理

Web 应用程序是客户端-服务器架构的最佳实现之一。Web 应用程序可以是一小段代码、一个程序,或者是一个解决问题或业务场景的完整解决方案,用户可以通过浏览器相互交互或与服务器交互。Web 应用程序主要通过浏览器提供请求和响应,主要通过超文本传输协议HTTP)。

每当客户端和服务器之间发生通信时,都会发生两件事:客户端发起请求,服务器生成响应。这种通信由 HTTP 请求和 HTTP 响应组成。有关更多信息,请参阅文档:www.w3schools.com/whatis/whatis_http.asp

在下图中,你可以看到 Web 应用程序的概述和工作原理:

从这个图表中,你可以很容易地看到,通过使用浏览器(作为客户端),你为数百万用户打开了可以从世界各地访问网站并与你作为用户交互的大门。通过 Web 应用程序,你和你的客户可以轻松地进行沟通。通常,只有在你捕获并存储了业务和用户所需的所有必要信息的数据时,才能实现有效的参与。然后这些信息被处理,结果呈现给你的用户。

一般来说,Web 应用程序使用服务器端代码来处理信息的存储和检索,以及客户端脚本来向用户呈现信息。

Web 应用程序需要 Web 服务器(如IISApache)来管理来自客户端的请求(从浏览器中可以看到)。还需要应用程序服务器(如 IIS 或 Apache Tomcat)来执行请求的任务。有时还需要数据库来存储信息。

简而言之,Web 服务器和应用程序服务器都旨在提供 HTTP 内容,但具有一定的差异。Web 服务器提供静态 HTTP 内容,如 HTML 页面。应用程序服务器除了提供静态 HTTP 内容外,还可以使用不同的编程语言提供动态内容。有关更多信息,请参阅stackoverflow.com/questions/936197/what-is-the-difference-between-application-server-and-web-server

我们可以详细说明 Web 应用程序的工作流程如下。这些被称为 Web 应用程序的五个工作过程:

  1. 客户端(浏览器)通过互联网使用 HTTP(在大多数情况下)触发请求到 Web 服务器。这通常通过 Web 浏览器或应用程序的用户界面完成。

  2. 请求在 Web 服务器处发出,Web 服务器将请求转发给应用程序服务器(对于不同的请求,将有不同的应用程序服务器)。

  3. 在应用程序服务器中,完成了请求的任务。这可能涉及查询数据库服务器,从数据库中检索信息,处理信息和构建结果。

  4. 生成的结果(请求的信息或处理的数据)被发送到 Web 服务器。

  5. 最后,响应将从 Web 服务器发送回请求者(客户端),并显示在用户的显示器上。

以下图表显示了这五个步骤的图解概述:

在接下来的几节中,我将描述使用模型-视图-控制器MVC)模式的 Web 应用程序的工作过程。

编写 Web 应用程序

到目前为止,我们已经了解了要求并查看了我们的目标,即将控制台应用程序转换为基于 Web 的平台或应用程序。在本节中,我们将使用 Visual Studio 开发实际的 Web 应用程序。

执行以下步骤,使用 Visual Studio 创建 Web 应用程序:

  1. 打开 Visual Studio 实例。

  2. 单击文件|新建|项目或按Ctrl + Shift + N,如下截图所示:

  1. 从“新建项目”窗口中,选择 Web|.NET Core|ASP.NET Core Web 应用程序。

  2. 命名它(例如FlixOne.Web),选择位置,然后您可以更新解决方案名称。默认情况下,解决方案名称将与项目名称相同。选中“为解决方案创建目录”复选框。您还可以选择选中“创建新的 Git 存储库”复选框(如果要为此创建新存储库,您需要有效的 Git 帐户)。

以下截图显示了创建新项目的过程:

  1. 下一步是为您的 Web 应用程序选择适当的模板和.NET Core 版本。我们不打算为此项目启用 Docker 支持,因为我们不打算使用 Docker 作为容器部署我们的应用程序。我们将仅使用 HTTP 协议,而不是 HTTPS。因此,应保持未选中“启用 Docker 支持”和“配置 HTTPs”复选框,如下截图所示:

现在,我们拥有一个完整的项目,其中包含我们的模板和示例代码,使用 MVC 框架。以下截图显示了我们目前的解决方案:

架构模式是在用户界面和应用程序设计中实施最佳实践的一种方式。它们为我们提供了常见问题的可重用解决方案。这些模式还允许我们轻松实现关注点的分离。

最流行的架构模式如下:

  • 模型-视图-控制器MVC

  • 模型-视图-展示者MVP

  • 模型-视图-视图模型MVVM

您可以尝试通过按下F5来运行应用程序。以下屏幕截图显示了 Web 应用程序的默认主页:

在接下来的章节中,我将讨论 MVC 模式,并创建CRUD创建更新删除)页面与用户交互。

实现 CRUD 页面

在本节中,我们将开始创建功能页面来创建、更新和删除产品。要开始,请打开您的FlixOne解决方案,并将以下类添加到指定的文件夹中:

Models:在解决方案的Models文件夹中添加以下文件:

  • Product.csProduct类的代码片段如下:
public class Product
{
   public Guid Id { get; set; }
   public string Name { get; set; }
   public string Description { get; set; }
   public string Image { get; set; }
   public decimal Price { get; set; }
   public Guid CategoryId { get; set; }
   public virtual Category Category { get; set; }
}

Product类几乎代表了产品的所有元素。它有一个Name,一个完整的Description,一个Image,一个Price,以及一个唯一的ID,以便我们的系统识别它。Product类还有一个Category ID,表示该产品所属的类别。它还包括对Category的完整定义。

为什么我们应该定义一个virtual属性?

在我们的Product类中,我们定义了一个virtual属性。这是因为在Entity FrameworkEF)中,此属性有助于为虚拟属性创建代理。这样,属性可以支持延迟加载和更高效的更改跟踪。这意味着数据是按需可用的。当您请求使用Category属性时,EF 会加载数据。

  • Category.csCategory类的代码片段如下:
public class Category
{
    public Category()
    {
        Products = new List<Product>();
    }

    public Guid Id { get; set; }
    public string Name { get; set; }
    public string Description { get; set; }
    public virtual IEnumerable<Product> Products { get; set; }
}

我们的Category类代表产品的实际类别。类别具有唯一的ID,一个Name,一个完整的Description,以及属于该类别的Products集合。每当我们初始化我们的Category类时,它也会初始化我们的Product类。

  • ProductViewModel.csProductViewModel类的代码片段如下:
public class ProductViewModel
{
    public Guid ProductId { get; set; }
    public string ProductName { get; set; }
    public string ProductDescription { get; set; }
    public string ProductImage { get; set; }
    public decimal ProductPrice { get; set; }
    public Guid CategoryId { get; set; }
    public string CategoryName { get; set; }
    public string CategoryDescription { get; set; }
}

我们的ProductViewModel类代表了一个完整的Product,具有唯一的ProductId,一个ProductName,一个完整的ProductDescription,一个ProductImage,一个ProductPrice,一个唯一的CategoryId,一个CategoryName,以及一个完整的CategoryDescription

Controllers:在解决方案的Controllers文件夹中添加以下文件:

  • ProductController负责与产品相关的所有操作。让我们看看在此控制器中我们试图实现的代码和操作:
public class ProductController : Controller
{
    private readonly IInventoryRepositry _repositry;
    public ProductController(IInventoryRepositry inventoryRepositry) => _repositry = inventoryRepositry;

...
}

在这里,我们定义了继承自Controller类的ProductController。我们使用了内置于 ASP.NET Core MVC 框架的依赖注入

我们在第五章中详细讨论了控制反转;Controller是 MVC 控制器的基类。有关更多信息,请参阅:docs.microsoft.com/en-us/dotnet/api/microsoft.aspnetcore.mvc.controller

我们已经创建了我们的主控制器ProductController。现在让我们开始为我们的 CRUD 操作添加功能。

以下代码只是一个ReadGet操作,请求存储库(_``inventoryRepository)列出所有可用产品,然后将此产品列表转换为ProductViewModel类型并返回Index视图:

   public IActionResult Index() => View(_repositry.GetProducts().ToProductvm());
   public IActionResult Details(Guid id) => View(_repositry.GetProduct(id).ToProductvm());

在上面的代码片段中,Details方法根据其唯一的Id返回特定Product的详细信息。这也是一个类似于我们的Index方法的Get操作,但它提供单个对象而不是列表。

MVC 控制器的方法也称为操作方法,并且具有ActionResult的返回类型。在这种情况下,我们使用IActionResult。一般来说,可以说IActionResultActionResult类的一个接口。它还为我们提供了返回许多东西的方法,包括以下内容:

  • EmptyResult

  • FileResult

  • HttpStatusCodeResult

  • ContentResult

  • JsonResult

  • RedirectToRouteResult

  • RedirectResult

我们不打算详细讨论所有这些,因为这超出了本书的范围。要了解有关返回类型的更多信息,请参阅:docs.microsoft.com/en-us/aspnet/core/web-api/action-return-types

在下面的代码中,我们正在创建一个新产品。下面的代码片段有两个操作方法。一个有[HttpPost]属性,另一个没有属性:

public IActionResult Create() => View();
[HttpPost]
[ValidateAntiForgeryToken]
public IActionResult Create([FromBody] Product product)
{
    try
    {
        _repositry.AddProduct(product);
        return RedirectToAction(nameof(Index));
    }
    catch
    {
        return View();
    }
}

第一个方法只是返回一个View。这将返回一个Create.cshtml页面。

如果MVC 框架中的任何操作方法没有任何属性,它将默认使用[HttpGet]属性。在其他视图中,默认情况下,操作方法是Get请求。每当用户查看页面时,我们使用[HttpGet],或者Get请求。每当用户提交表单或执行操作时,我们使用[HttpPost],或者Post请求。

如果我们在操作方法中没有明确提到视图名称,那么 MVC 框架会以这种格式查找视图名称:actionmethodname.cshtmlactionmethodname.vbhtml。在我们的情况下,视图名称是Create.cshtml,因为我们使用的是 C#语言。如果我们使用 Visual Basic,它将是vbhtml。它首先在与控制器文件夹名称相似的文件夹中查找文件。如果在这个文件夹中找不到文件,它会在shared文件夹中查找。

上面代码片段中的第二个操作方法使用了[HttpPost]属性,这意味着它处理Post请求。这个操作方法只是通过调用_repositoryAddProduct方法来添加产品。在这个操作方法中,我们使用了[ValidateAntiForgeryToken]属性和[FromBody],这是一个模型绑定器。

MVC 框架通过提供[ValidateAntiForgeryToken]属性为我们的应用程序提供了很多安全性,以保护我们免受跨站脚本/跨站请求伪造XSS/CSRF)攻击。这种类型的攻击通常包括一些危险的客户端脚本代码。

MVC 中的模型绑定将数据从HTTP请求映射到操作方法参数。与操作方法一起经常使用的模型绑定属性如下:

  • [FromHeader]

  • [FromQuery]

  • [FromRoute]

  • [FromForm]

我们不打算详细讨论这些,因为这超出了本书的范围。但是,您可以在官方文档中找到完整的详细信息:docs.microsoft.com/en-us/aspnet/core/mvc/models/model-binding

在上面的代码片段中,我们讨论了CreateRead操作。现在是时候为Update操作编写代码了。在下面的代码中,我们有两个操作方法:一个是Get,另一个是Post请求:

public IActionResult Edit(Guid id) => View(_repositry.GetProduct(id));

[HttpPost]
[ValidateAntiForgeryToken]
public IActionResult Edit(Guid id, [FromBody] Product product)
{
    try
    {
        _repositry.UpdateProduct(product);
        return RedirectToAction(nameof(Index));
    }
    catch
    {
        return View();
    }
}

上面代码的第一个操作方法根据ID获取Product并返回一个View。第二个操作方法从视图中获取数据并根据其 ID 更新请求的Product

public IActionResult Delete(Guid id) => View(_repositry.GetProduct(id));

[HttpPost]
[ValidateAntiForgeryToken]
public IActionResult Delete(Guid id, [FromBody] Product product)
{
    try
    {
        _repositry.RemoveProduct(product);
        return RedirectToAction(nameof(Index));
    }
    catch
    {
        return View();
    }
}

最后,上面的代码表示了我们的CRUD操作中的Delete操作。它还有两个操作方法;一个从存储库中检索数据并将其提供给视图,另一个获取数据请求并根据其 ID 删除特定的Product

CategoryController负责Product类别的所有操作。将以下代码添加到控制器中,它表示CategoryController,我们在其中使用依赖注入来初始化我们的IInventoryRepository

public class CategoryController: Controller
{
  private readonly IInventoryRepositry _inventoryRepositry;
  public CategoryController(IInventoryRepositry inventoryRepositry) => _inventoryRepositry = inventoryRepositry;
 //code omitted
}

以下代码包含两个操作方法。第一个获取类别列表,第二个是根据其唯一 ID 获取特定类别:

public IActionResult Index() => View(_inventoryRepositry.GetCategories());
public IActionResult Details(Guid id) => View(_inventoryRepositry.GetCategory(id));

以下代码是用于在系统中创建新类别的GetPost请求:

public IActionResult Create() => View();
    [HttpPost]
    [ValidateAntiForgeryToken]
    public IActionResult Create([FromBody] Category category)
    {
        try
        {
            _inventoryRepositry.AddCategory(category);

            return RedirectToAction(nameof(Index));
        }
        catch
        {
            return View();
        }
    }

在以下代码中,我们正在更新我们现有的类别。代码包含了带有GetPost请求的Edit操作方法:

public IActionResult Edit(Guid id) => View(_inventoryRepositry.GetCategory(id));
    [HttpPost]
    [ValidateAntiForgeryToken]
    public IActionResult Edit(Guid id, [FromBody]Category category)
    {
        try
        {
            _inventoryRepositry.UpdateCategory(category);

            return RedirectToAction(nameof(Index));
        }
        catch
        {
            return View();
        }
    }

最后,我们有一个Delete操作方法。这是我们Category删除的CRUD页面的最终操作,如下所示:

public IActionResult Delete(Guid id) => View(_inventoryRepositry.GetCategory(id));

    [HttpPost]
    [ValidateAntiForgeryToken]
    public IActionResult Delete(Guid id, [FromBody] Category category)
    {
        try
        {
            _inventoryRepositry.RemoveCategory(category);

            return RedirectToAction(nameof(Index));
        }
        catch
        {
            return View();
        }
    }

Views:将以下视图添加到各自的文件夹中:

  • Index.cshtml

  • Create.cshtml

  • Edit.cshtml

  • Delete.cshtml

  • Details.cshtml

Contexts:将InventoryContext.cs文件添加到Contexts文件夹,并使用以下代码:

public class InventoryContext : DbContext
{
    public InventoryContext(DbContextOptions<InventoryContext> options)
        : base(options)
    {
    }

    public InventoryContext()
    {
    }

    public DbSet<Product> Products { get; set; }
    public DbSet<Category> Categories { get; set; }
}

上述代码提供了使用 EF 与数据库交互所需的各种方法。在运行代码时,您可能会遇到以下异常:

要解决此异常,您应该在Startup.cs文件中映射到IInventoryRepository,如下截图所示:

我们现在已经为我们的 Web 应用程序添加了各种功能,我们的解决方案现在如下截图所示:

有关本章的 GitHub 存储库,请参阅github.com/PacktPublishing/Hands-On-Design-Patterns-with-C-and-.NET-Core/tree/master/Chapter6

如果我们要可视化 MVC 模型,那么它将按照以下图表所示的方式工作:

上述图像改编自commons.wikimedia.org/wiki/File:MVC-Process.svg

如前图所示,每当用户发出请求时,它都会传递到控制器并触发操作方法进行进一步操作或更新,如果需要的话,传递到模型,然后向用户提供视图。

在我们的情况下,每当用户请求/Product时,请求会传递到ProductControllerIndex操作方法,并在获取产品列表后提供Index.cshtml视图。您将会得到如下截图所示的产品列表:

上述截图是一个简单的产品列表,它代表了CRUD操作的Read部分。在此屏幕上,应用程序显示了总共可用的产品及其类别。

以下图表描述了我们的应用程序如何交互:

它显示了我们应用程序流程的图形概述。InventoryRepository依赖于InventoryContext进行数据库操作,并与我们的模型类CategoryProduct进行交互。我们的ProductCategory控制器使用IInventoryRepository接口与存储库进行 CRUD 操作的交互。

总结

本章的主要目标是启动一个基本的 Web 应用程序。

我们通过讨论业务需求开始了本章,解释了为什么需要 Web 应用程序以及为什么要升级我们的控制台应用程序。然后,我们使用 Visual Studio 在 MVC 模式中逐步创建了 Web 应用程序。我们还讨论了 Web 应用程序如何作为客户端-服务器模型工作,并且研究了用户界面模式。我们还开始构建 CRUD 页面。

在下一章中,我们将继续讨论 Web 应用程序,并讨论更多 Web 应用程序的设计模式。

问题

以下问题将帮助您巩固本章中包含的信息:

  1. 什么是 Web 应用程序?

  2. 精心打造一个您选择的 Web 应用程序,并描述其工作原理。

  3. 控制反转是什么?

  4. 在本章中我们涵盖了哪些架构模式?您喜欢哪一种,为什么?

进一步阅读

恭喜!您已完成本章内容。我们涵盖了与身份验证、授权和测试项目相关的许多内容。这并不是您学习的终点;这只是一个开始,还有更多书籍可以供您参考,以增进您的理解。以下书籍深入探讨了 RESTful Web 服务和测试驱动开发:

第七章:实施 Web 应用程序的设计模式-第二部分

在上一章中,我们将我们的 FlixOne 库存管理控制台应用程序扩展为 Web 应用程序,同时说明了不同的模式。我们还涵盖了用户界面UI)架构模式,如模型-视图-控制器MVC)、模型视图呈现器MVP)等。上一章旨在讨论 MVC 等模式。现在我们需要扩展我们现有的应用程序,以纳入更多模式。

在本章中,我们将继续使用我们现有的 FlixOne Web 应用程序,并通过编写代码来扩展应用程序,以查看认证和授权的实现。除此之外,我们还将讨论测试驱动开发TDD)。

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

  • 认证和授权

  • 创建一个.NET Core Web 测试项目

技术要求

本章包含各种代码示例,以解释概念。代码保持简单,仅用于演示目的。大多数示例涉及使用 C#编写的.NET Core 控制台应用程序。

要运行和执行代码,Visual Studio 2019 是必需的(您也可以使用 Visual Studio 2017 来运行应用程序)。

安装 Visual Studio

要运行这些代码示例,您需要安装 Visual Studio(首选集成开发环境IDE))。要做到这一点,请按照以下说明进行操作:

  1. 从以下下载链接下载 Visual Studio,其中包含安装说明:docs.microsoft.com/en-us/visualstudio/install/install-visual-studio

  2. 按照您在那里找到的安装说明进行操作。Visual Studio 有多个版本可供安装。在这里,我们使用的是 Windows 版的 Visual Studio。

设置.NET Core

如果您没有安装.NET Core,则需要按照以下说明进行操作:

  1. 使用www.microsoft.com/net/download/windows下载 Windows 版.NET Core。

  2. 有关多个版本和相关库,请访问dotnet.microsoft.com/download/dotnet-core/2.2

安装 SQL Server

如果您没有安装 SQL Server,则需要按照以下说明进行操作:

  1. 从以下链接下载 SQL Server:www.microsoft.com/en-in/download/details.aspx?id=1695

  2. 您可以在这里找到安装说明:docs.microsoft.com/en-us/sql/ssms/download-sql-server-management-studio-ssms?view=sql-server-2017

有关故障排除和更多信息,请参考以下链接:www.blackbaud.com/files/support/infinityinstaller/content/installermaster/tkinstallsqlserver2008r2.htm

完整的源代码可以从以下链接获得:github.com/PacktPublishing/Hands-On-Design-Patterns-with-C-and-.NET-Core/tree/master/Chapter7

扩展.NET Core Web 应用程序

在本章中,我们将继续使用我们的 FlixOne 库存应用程序。在本章中,我们将讨论 Web 应用程序模式,并扩展我们在上一章中开发的 Web 应用程序。

本章将继续上一章开发的 Web 应用程序。如果您跳过了上一章,请返回查看,以与当前章节同步。

在本节中,我们将介绍需求收集的过程,然后讨论我们之前开发的 Web 应用程序所面临的各种挑战。

项目启动

在第六章中,为 Web 应用程序实现设计模式-第一部分,我们扩展了我们的 FlixOne 库存控制台应用程序并开发了一个 Web 应用程序。在考虑了以下几点后,我们扩展了该应用程序:

  • 我们的业务需要一个丰富的用户界面。

  • 新的机会需要一个响应式的 Web 应用程序。

需求

经过几次会议和与管理层、业务分析师(BAs)和售前人员的讨论后,管理层决定着手处理以下高级需求:业务需求技术需求

业务需求

业务团队最终提出了以下业务需求:

  • 产品分类:有多种产品,但如果用户想要搜索特定产品,他们可以通过按类别筛选所有产品来实现。例如,像芒果、香蕉等产品应该属于名为“水果”的类别。

  • 产品添加:应该有一个界面,提供给我们添加新产品的功能。这个功能只能提供给具有“添加产品”权限的用户。

  • 产品更新:应该有一个新的界面,可以进行产品更新。

  • 产品删除:管理员需要删除产品。

技术要求

满足业务需求的实际需求现在已经准备好进行开发。经过与业务人员的多次讨论,我们得出以下需求:

  • 应该有一个着陆页或主页

  • 应该有一个包含各种小部件的仪表板

  • 应该显示商店的一览图片

  • 应该有一个产品页面

  • 应该具备添加、更新和删除产品的能力

  • 应该具备添加、更新和删除产品类别的能力

FlixOne 库存管理 Web 应用程序是一个虚构的产品。我们正在创建此应用程序来讨论 Web 项目中所需/使用的各种设计模式。

挑战

尽管我们已将现有的控制台应用程序扩展为新的 Web 应用程序,但对开发人员和企业来说都存在各种挑战。在本节中,我们将讨论这些挑战,然后找出克服这些挑战的解决方案。

开发人员面临的挑战

由于应用程序发生了重大变化而出现的挑战。这也是将控制台应用程序升级为 Web 应用程序的主要扩展的结果:

  • 不支持 TDD:目前解决方案中没有包含测试项目。因此,开发人员无法遵循 TDD 方法,这可能会导致应用程序中出现更多的错误。

  • 安全性:在当前应用程序中,没有机制来限制或允许用户访问特定屏幕或模块。也没有与身份验证和授权相关的内容。

  • UI 和用户体验(UX):我们的应用程序是从基于控制台的应用程序推广而来,因此 UI 并不是非常丰富。

企业面临的挑战

实现最终输出需要时间,这延迟了产品,导致业务损失。在我们采用新技术栈并对代码进行大量更改时,出现了以下挑战:

  • 客户流失:在这里,我们仍处于开发阶段,但对我们业务的需求非常高;然而,开发团队花费的时间比预期的要长,以交付产品。

  • 生产更新需要更多时间:目前开发工作非常耗时,这延迟了后续活动,并导致生产延迟。

找到解决问题/挑战的解决方案

经过数次会议和头脑风暴后,开发团队得出结论,我们必须稳定我们的基于 Web 的解决方案。为了克服这些挑战并提供解决方案,技术团队和业务团队联合起来确定了各种解决方案和要点。

解决方案支持以下要点:

  • 实施身份验证和授权

  • 遵循 TDD

  • 重新设计 UI 以满足 UX

身份验证和授权

在上一章中,我们开始将控制台应用程序升级为 Web 应用程序,我们添加了创建、读取、更新和删除(CRUD)操作,这些操作对任何能够执行它们的用户都是公开可用的。没有编写任何代码来限制特定用户执行这些操作的权限。这样做的风险是,不应执行这些操作的用户可以轻易执行。其后果如下:

  • 无人值守访问

  • 黑客/攻击者的开放大门

  • 数据泄漏问题

现在,如果我们渴望保护我们的应用程序并将操作限制为允许的用户,那么我们必须实施一个设计,只允许这些用户执行操作。可能有一些情况下,我们可以允许一些操作的开放访问。在我们的情况下,大多数操作仅限于受限访问。简而言之,我们可以尝试一些方法,告诉我们的应用程序,传入的用户是属于我们的应用程序并且可以执行指定的任务。

身份验证只是一个系统通过凭据(通常是用户 ID 和密码)验证或识别传入请求的过程。如果系统发现提供的凭据错误,那么它会通知用户(通常通过 GUI 屏幕上的消息)并终止授权过程。

授权始终在身份验证之后。这是一个过程,允许经过验证的用户在验证其对特定资源或数据的访问权限后访问资源或数据。

在前面的段落中,我们已经讨论了一些机制,阻止了对我们应用程序操作的无人值守访问。让我们参考下图并讨论它显示了什么:

上图描述了一个场景,即系统不允许无人值守访问。这简单地定义为:接收到一个请求,内部系统(身份验证机制)检查请求是否经过身份验证。如果请求经过身份验证,那么用户被允许执行他们被授权的操作。这不仅是单一的检查,但对于典型的系统来说,授权在身份验证之后生效。我们将在接下来的章节中讨论这一点。

为了更好地理解这一点,让我们编写一个简单的登录应用程序。让我们按照这里给出的步骤进行:

  1. 打开 Visual Studio 2018。

  2. 打开文件 | 新建 | 新项目。

  3. 从项目窗口,为您的项目命名。

  4. 选择 ASP.NET Core 2.2 的 Web 应用程序(模型-视图-控制器)模板:

  1. 您可以选择所选模板的各种身份验证。

  2. 默认情况下,模板提供了一个名为无身份验证的选项,如下所示:

  1. 按下F5并运行应用程序。从这里,您将看到默认的主页:

现在你会注意到你可以在没有任何限制的情况下浏览每个页面。这是显而易见的,并且有道理,因为这些页面是作为开放访问的。主页和隐私页面是开放访问的,不需要任何身份验证,这意味着任何人都可以访问/查看这些页面。另一方面,我们可能有一些页面是为无人值守访问而设计的,比如用户资料和管理员页面。

请参阅 GitHub 存储库,了解该章节的应用程序,网址为github.com/PacktPublishing/Hands-On-Design-Patterns-with-C-and-.NET-Core/tree/master/Chapter6,并浏览我们使用 ASP.NET Core MVC 构建的整个应用程序。

继续使用我们的 SimpleLogin 应用程序,让我们添加一个专门用于受限访问的屏幕:Products 屏幕。在本章中,我们不会讨论如何向现有项目添加新的控制器或视图。如果您想知道如何将这些添加到我们的项目中,请重新访问第六章,实现 Web 应用程序的设计模式-第一部分

我们已经为我们的项目添加了新功能,以展示具有 CRUD 操作的产品。现在,按下F5并检查输出:

您将得到前面截图中显示的输出。您可能会注意到我们现在有一个名为 Products 的新菜单。

让我们浏览一下新的菜单选项。点击 Products 菜单:

前面的截图显示了我们的产品页面。这个页面对所有人都是可用的,任何人都可以在没有任何限制的情况下查看它。您可以看一看并观察到这个页面具有创建新产品、编辑和删除现有产品的功能。现在,想象一个情景,一个未知的用户来了并删除了一个非常重要并吸引高销量的特定产品。您可以想象这种情景以及这对业务造成了多大的影响。甚至可能会有顾客流失。

在我们的情景中,我们可以通过两种方式保护我们的产品页面:

  • 先前认证:在这个页面上,产品的链接对所有人都不可用;它只对经过身份验证的请求/用户可用。

  • 后续认证:在这个页面上,产品的链接对所有人都是可用的。但是,一旦有人请求访问页面,系统就会进行身份验证检查。

身份验证进行中。

在这一部分,我们将看到如何实现身份验证,并使我们的网页对未经身份验证的请求受限。

为了实现身份验证,我们应该采用某种机制,为我们提供一种验证用户的方式。一般情况下,如果用户已登录,那就意味着他们已经经过身份验证。

在我们的 Web 应用程序中,我们也会遵循相同的方法,并确保用户在访问受限页面、视图和操作之前已登录:

public class User
{
    public Guid Id { get; set; }
    public string UserName { get; set; }
    public string EmailId { get; set; }
    public string FirstName { get; set; }
    public string LastName { get; set; }
    public byte[] PasswordHash { get; set; }
    public byte[] PasswordSalt { get; set; }
    public string SecretKey { get; set; }
    public string Mobile { get; set; }
    public string EmailToken { get; set; }
    public DateTime EmailTokenDateTime { get; set; }
    public string OTP { get; set; }
    public DateTime OtpDateTime { get; set; }
    public bool IsMobileVerified { get; set; }
    public bool IsEmailVerified { get; set; }
    public bool IsActive { get; set; }
    public string Image { get; set; }
}

前面的类是一个典型的User模型/实体,代表我们的数据库User表。这个表将保存关于User的所有信息。每个字段的样子如下:

  • Id 是一个全局唯一标识符GUID)和表中的主键。

  • UserName 通常在登录和其他相关操作中使用。它是一个程序生成的字段。

  • FirstNameLastName 组合了用户的全名。

  • Emailid 是用户的有效电子邮件地址。它应该是一个有效的电子邮件,因为我们将在注册过程中/之后验证它。

  • PasswordHashPasswordSalt 是基于哈希消息认证码,安全哈希算法HMAC****SHA)512 的字节数组。PasswordHash属性的值为 64 字节,PasswordSalt为 128 字节。

  • SecretKey 是一个 Base64 编码的字符串。

  • Mobilie 是一个有效的手机号码,取决于系统的有效性检查。

  • EmailTokenOTP 是随机生成的一次性密码OTPs),用于验证emailIdMobile number

  • EmailTokenDateTimeOtpDateTimedatetime数据类型的属性;它们表示为用户发出EmailTokenOTP的日期和时间。

  • IsMobileVerifiedIsEmailverified是布尔值(true/false),告诉系统手机号和/或电子邮件 ID 是否已验证。

  • IsActive是布尔值(true/false),告诉系统User模型是否处于活动状态。

  • Image是图像的 Base64 编码字符串。它代表用户的个人资料图片。

我们需要将我们的新类/实体添加到我们的Context类中。让我们添加我们在下面截图中看到的内容:

通过在我们的Context类中添加上一行,我们可以直接使用Entity FrameworkEF)功能访问我们的User表:

public class LoginViewModel
{
    [Required]
    public string Username { get; set; }
    [Required]
    [DataType(DataType.Password)]
    public string Password { get; set; }
    [Display(Name = "Remember Me")]
    public bool RememberMe { get; set; }
    public string ReturnUrl { get; set; }
}

LoginViewModel用于验证用户。这个viewmodel的值来自登录页面(我们将在接下来的部分讨论和创建此页面)。它包含以下内容:

  • UserName:这是用于识别用户的唯一名称。这是一个易于识别的人类可读值。它不像 GUID 值。

  • Password:这是任何用户的秘密和敏感值。

  • RememberMe:这告诉我们用户是否希望允许当前系统持久化存储在客户端浏览器的 cookie 中的值。

执行 CRUD 操作,让我们将以下代码添加到UserManager类中:

public class UserManager : IUserManager
{
    private readonly InventoryContext _context;

    public UserManager(InventoryContext context) => _context = context;

    public bool Add(User user, string userPassword)
    {
        var newUser = CreateUser(user, userPassword);
        _context.Users.Add(newUser);
        return _context.SaveChanges() > 0;
    }

    public bool Login(LoginViewModel authRequest) => FindBy(authRequest) != null;

    public User GetBy(string userId) => _context.Users.Find(userId);

以下是UserManager类其余方法的代码片段:

   public User FindBy(LoginViewModel authRequest)
    {
        var user = Get(authRequest.Username).FirstOrDefault();
        if (user == null) throw new ArgumentException("You are not registered with us.");
        if (VerifyPasswordHash(authRequest.Password, user.PasswordHash, user.PasswordSalt)) return user;
        throw new ArgumentException("Incorrect username or password.");
    }
    public IEnumerable<User> Get(string searchTerm, bool isActive = true)
    {
        return _context.Users.Where(x =>
            x.UserName == searchTerm.ToLower() || x.Mobile == searchTerm ||
            x.EmailId == searchTerm.ToLower() && x.IsActive == isActive);
    }

    ...
}

上述代码是UserManager类,它使我们能够使用 EF 与我们的User表进行交互:

以下代码显示了登录屏幕的视图:

<form asp-action="Login" asp-route-returnurl="@Model.ReturnUrl">
    <div asp-validation-summary="ModelOnly" class="text-danger"></div>

    <div class="form-group">
        <label asp-for="Username" class="control-label"></label>
        <input asp-for="Username" class="form-control" />
        <span asp-validation-for="Username" class="text-danger"></span>
    </div>

    <div class="form-group">
        <label asp-for="Password" class="control-label"></label>
        <input asp-for="Password" class="form-control"/>
        <span asp-validation-for="Password" class="text-danger"></span>
    </div>

    <div class="form-group">
        <label asp-for="RememberMe" ></label>
        <input asp-for="RememberMe" />
        <span asp-validation-for="RememberMe"></span>
    </div>
    <div class="form-group">
        <input type="submit" value="Login" class="btn btn-primary" />
    </div>
</form>

上述代码片段来自我们的Login.cshtml页面/视图。该页面提供了一个表单来输入Login详细信息。这些详细信息传递到我们的Account控制器,然后进行验证以认证用户:

以下是Login操作方法:

[HttpGet]
public IActionResult Login(string returnUrl = "")
{
    var model = new LoginViewModel { ReturnUrl = returnUrl };
    return View(model);
}

上述代码片段是一个Get /Account/Login请求,显示空的登录页面,如下截图所示:

用户点击登录菜单选项后立即出现上一个截图。这是一个用于输入登录详细信息的简单表单。

以下代码显示了处理应用程序Login功能的Login操作方法:

[HttpPost]
public IActionResult Login(LoginViewModel model)
{
    if (ModelState.IsValid)
    {
        var result = _authManager.Login(model);

        if (result)
        {
           return !string.IsNullOrEmpty(model.ReturnUrl) && Url.IsLocalUrl(model.ReturnUrl)
                ? (IActionResult)Redirect(model.ReturnUrl)
                : RedirectToAction("Index", "Home");
        }
    }
    ModelState.AddModelError("", "Invalid login attempt");
    return View(model);
}

上述代码片段是从登录页面发出的Post /Account/Login请求,发布整个LoginViewModel类:

以下是我们登录视图的截图:

在上一个截图中,我们尝试使用默认用户凭据(用户名:aroraG和密码:test123)登录。与此登录相关的信息将被持久化在 cookie 中,但仅当用户勾选了“记住我”复选框时。系统会在当前计算机上记住用户登录会话,直到用户点击“注销”按钮。

用户一点击登录按钮,系统就会验证他们的登录详细信息,并将他们重定向到主页,如下截图所示:

您可能会在菜单中看到文本,例如欢迎 Gaurav。这个欢迎文本不是自动显示的,而是我们通过添加几行代码来指示系统显示这个文本,如下面的代码所示:

<li class="nav-item">
    @{
        if (AuthManager.IsAuthenticated)
        {
            <a class="nav-link text-dark" asp-area="" asp-controller="Account" asp-action="Logout"><strong>Welcome @AuthManager.Name</strong>, Logout</a>

        }
        else
        {
            <a class="nav-link text-dark" asp-area="" asp-controller="Account" asp-action="Login">Login</a>
        }
    }
</li>

上一个代码片段来自_Layout.cshtml视图/页面。在上一个代码片段中,我们正在检查IsAuthenticated是否返回 true。如果是,那么欢迎消息将被显示。这个欢迎消息伴随着“注销”选项,但当IsAuthenticated返回false值时,它显示Login菜单:

public bool IsAuthenticated
{
    get { return User.Identities.Any(u => u.IsAuthenticated); }
}

IsAuthenticatedAuthManager类的ReadOnly属性,用于检查请求是否已经认证。在我们继续之前,让我们重新审视一下我们的Login方法:

public IActionResult Login(LoginViewModel model)
{
    if (ModelState.IsValid)
    {
        var result = _authManager.Login(model);

        if (result)
        {
           return !string.IsNullOrEmpty(model.ReturnUrl) && Url.IsLocalUrl(model.ReturnUrl)
                ? (IActionResult)Redirect(model.ReturnUrl)
                : RedirectToAction("Index", "Home");
        }
    }
    ModelState.AddModelError("", "Invalid login attempt");
    return View(model);
}

前面的Login方法只是简单地验证用户。看看这个声明——var result = _authManager.Login(model);。这调用了AuthManager中的Login方法:

如果Login方法返回true,那么它将当前的登录页面重定向到主页。否则,它将保持在相同的登录页面上,抱怨登录尝试无效。以下是Login方法的代码:

public bool Login(LoginViewModel model)
{
    var user = _userManager.FindBy(model);
    if (user == null) return false;
    SignInCookie(model, user);
    return true;
}

Login方法是AuthManager类的典型方法,它调用UserManagerFindBy(model)方法并检查是否存在。如果存在,那么它进一步调用AuthManager类的SignInCookie(model,user)方法,否则,它简单地返回false,意味着登录不成功:

private void SignInCookie(LoginViewModel model, User user)
{
    var claims = new List<Claim>
    {
        new Claim(ClaimTypes.Name, user.FirstName),
        new Claim(ClaimTypes.Email, user.EmailId),
        new Claim(ClaimTypes.NameIdentifier, user.Id.ToString())
    };

    var identity = new ClaimsIdentity(claims, CookieAuthenticationDefaults.AuthenticationScheme);
    var principal = new ClaimsPrincipal(identity);
    var props = new AuthenticationProperties { IsPersistent = model.RememberMe };
    _httpContext.SignInAsync(CookieAuthenticationDefaults.AuthenticationScheme, principal, props).Wait();
}

以下代码片段确保如果用户经过身份验证,那么他们的详细信息应该被持久化在HttpContext中,这样系统就可以对来自用户的每个传入请求进行身份验证。你可能会注意到_httpContext.SignInAsync(CookieAuthenticationDefaults.AuthenticationScheme, principal, props).Wait();语句实际上签署并启用了 cookie 身份验证:

//Cookie authentication
services.AddAuthentication(CookieAuthenticationDefaults.AuthenticationScheme).AddCookie();
//For claims
services.AddSingleton<IHttpContextAccessor, HttpContextAccessor>();
services.AddTransient<IAuthManager, AuthManager>();

前面的声明帮助我们为我们的应用程序启用 cookie 身份验证和声明的传入请求。最后,app.UseAuthentication();语句将身份验证机制能力添加到我们的应用程序中。这些语句应该添加到Startup.cs类中。

这有什么区别吗?

我们已经在我们的 Web 应用程序中添加了大量代码,但这真的有助于我们限制我们的页面/视图免受未经许可的请求吗?产品页面/视图仍然是开放的;因此,我可以从产品页面/视图执行任何可用的操作:

作为用户,我无论是否登录都可以看到产品选项:

前面的截图显示了登录后与登录前相同的产品菜单选项。

我们可以像这样限制对产品页面的访问:

<li class="nav-item">
    @{
        if (AuthManager.IsAuthenticated)
        {
            <a class="nav-link text-dark" asp-area="" asp-controller="Product" asp-action="Index">Products</a>
        }
    }
</li>

以下是应用程序的主屏幕:

前面的代码帮助系统只在用户登录/经过身份验证后显示产品菜单选项。产品菜单选项将不会显示在屏幕上。像这样,我们可以限制未经许可的访问。然而,这种方法也有其缺点。最大的缺点是,如果有人知道产品页面的 URL——它将引导您到/Product/Index——那么他们可以执行受限制的操作。这些操作是受限制的,因为它们不是供未登录用户使用的。

授权的实际应用

在前一节中,我们讨论了如何避免对特定或受限制的屏幕/页面的未经许可访问。我们已经看到登录实际上对用户进行身份验证,并允许他们向系统发出请求。另一方面,身份验证并不意味着如果用户经过身份验证,那么他们就被授权访问特定的部分、页面或屏幕。

以下描述了典型的授权和身份验证过程:

在这个过程中,第一个请求/用户得到了身份验证(通常是登录表单),然后授权请求执行特定/请求的操作。可能有许多情况,其中请求经过身份验证,但未经授权访问特定资源或执行特定操作。

在我们的应用程序(在上一节中创建)中,我们有一个带有 CRUD 操作的Products页面。Products页面不是公共页面,这意味着这个页面不是所有人都可以访问的;它是受限访问的。

我们回到了前一节中留下的主要问题:“如果用户经过身份验证,但未被授权访问特定页面/资源怎么办?无论我们是否将页面从未经授权的用户隐藏起来,因为他们可以通过输入其 URL 轻松访问或查看它。”为了克服这一挑战/问题,我们可以实施以下步骤:

  1. 检查对受限资源的每次访问的授权,这意味着每当用户尝试访问资源(通过在浏览器中输入直接 URL),系统都会检查授权,以便授权来访的请求。如果用户的来访请求未经授权,则他们将无法执行指定的操作。

  2. 在受限资源的每次操作上检查授权意味着如果用户经过身份验证,他们将能够访问受限页面/视图,但只有在用户经过授权时才能访问此页面/视图的操作。

Microsoft.AspNetCore.Authorization命名空间提供了授权特定资源的内置功能。

为了限制访问并避免对特定资源的未经监控的访问,我们可以使用Authorize属性:

前面的截图显示我们将Authorize属性放入了我们的ProductController中。现在,按下F5并运行应用程序。

如果用户未登录到系统,则他们将无法看到产品页面,因为我们已经添加了条件。如果用户经过验证,则在菜单栏中显示产品。

不要登录到系统并直接在浏览器中输入产品 URL,http://localhost:56229/Product。这将重定向用户到登录屏幕。请查看以下截图并检查 URL;您可能会注意到 URL 包含一个ReturnUrl部分,该部分将指示系统在成功登录尝试后重定向到何处。

请参阅以下截图;请注意 URL 包含ReturnUrl部分。一旦用户登录,系统将重定向应用程序到此 URL:

以下截图显示了产品列表:

我们的产品列表屏幕提供了诸如创建新产品、编辑、删除和详细信息等操作。当前应用程序允许用户执行这些操作。因此,是否有意义让任何访问和经过身份验证的用户都可以创建、更新和删除产品?如果我们允许每个用户这样做,后果可能如下:

  • 我们可以有许多已经添加到系统中的产品。

  • 产品的不可避免的移除/删除。

  • 产品的不可避免的更新。

我们是否可以有一些用户类型,可以将Admin类型的所有用户与普通用户区分开来,只允许具有管理员权限的用户而不是普通用户执行这些操作?更好的想法是为用户添加角色;因此,我们需要使特定类型的用户成为用户。

让我们在项目中添加一个新的实体并命名为Role

public class Role
{
    public Guid Id { get; set; }
    public string Name { get; set; }
    public string ShortName { get; set; }
}

定义用户的Role类的前面的代码片段具有以下列表中解释的属性:

  • Id:这使用GUID作为主键。

  • Namestring类型的Role名称。

  • ShortNamestring类型的角色的简短或缩写名称。

我们需要将我们的新类/实体添加到我们的Context类中。让我们按照以下方式添加:

前面的代码提供了使用 EF 进行各种 DB 操作的能力:

public IEnumerable<Role> GetRoles() => _context.Roles.ToList();

public IEnumerable<Role> GetRolesBy(string userId) => _context.Roles.Where(x => x.UserId.ToString().Equals(userId));

public string RoleNamesBy(string userId)
{
    var listofRoleNames = GetRolesBy(userId).Select(x=>x.ShortName).ToList();
    return string.Join(",", listofRoleNames);
}

在前面的代码片段中出现的UserManager类的三种方法为我们提供了从数据库中获取Roles的能力:

private void SignInCookie(LoginViewModel model, User user)
{
    var claims = new List<Claim>
    {
        new Claim(ClaimTypes.Name, user.FirstName),
        new Claim(ClaimTypes.Email, user.EmailId),
        new Claim(ClaimTypes.NameIdentifier, user.Id.ToString())
    };

    if (user.Roles != null)
    {
        string[] roles = user.Roles.Split(",");

        claims.AddRange(roles.Select(role => new Claim(ClaimTypes.Role, role)));
    }

    var identity = new ClaimsIdentity(claims, CookieAuthenticationDefaults.AuthenticationScheme);

    var principal = new ClaimsPrincipal(identity);
    var props = new AuthenticationProperties { IsPersistent = model.RememberMe };
    _httpContext.SignInAsync(CookieAuthenticationDefaults.AuthenticationScheme, principal, props).Wait();
}

我们通过修改AuthManager类的SigningCookie方法,将Roles添加到我们的Claims中:

上一张截图显示了一个名为Gaurav的用户有两个角色:AdminManager

我们限制ProductController仅供具有AdminManager角色的用户使用。现在,尝试使用用户aroraG登录,您将看到Product Listing,如下截图所示:

现在,让我们尝试用第二个用户aroraG1登录,该用户具有Editor角色。这将引发AccessDenied错误。请参见以下截图:

通过这种方式,我们可以保护我们的受限资源。有很多方法可以实现这一点。.NET Core MVC 提供了内置功能来实现这一点,您也可以以可定制的方式实现。如果您不想使用这些可用的内置功能,您可以通过添加到现有代码中来轻松起草所需功能的自己的功能。如果您想这样做,您需要从头开始。此外,如果某样东西已经存在,那么再次创建类似的东西就没有意义。如果您找不到可用组件的功能,那么您应该定制现有的功能/特性,而不是从头开始编写整个代码。

开发人员应该实现一个不可篡改的身份验证机制。在本节中,我们已经讨论了很多关于身份验证和授权,以及编写代码和创建我们的 Web 应用程序。关于身份验证,我们应该使用一个良好的身份验证机制,这样就不会有人篡改或绕过它。您可以从以下两种设计开始:

  • 身份验证过滤器

  • 验证个别请求/端点

在实施了前面的步骤之后,每个通过任何模式发出的请求在系统响应给用户或发出调用的客户端之前都应经过身份验证和授权。这个过程主要包括以下内容:

  • 保密性:安全系统确保任何敏感数据不会暴露给未经身份验证和未经授权的访问请求。

  • 可用性:系统中的安全措施确保系统对通过系统的身份验证和授权机制确认为真实用户的用户可用。

  • 完整性:在一个安全的系统中,数据篡改是不可能的,因此数据是安全的。

创建一个 Web 测试项目

单元测试是检查代码健康的一种方法。这意味着如果代码有错误(不健康),那么这将成为应用程序中许多未知和不需要的问题的基础。为了克服这种方法,我们可以遵循 TDD 方法。

您可以通过 Katas 练习 TDD。您可以参考www.codeproject.com/Articles/886492/Learning-Test-Driven-Development-with-TDD-Katas了解更多关于 TDD katas 的信息。如果您想要练习这种方法,请使用这个存储库:github.com/garora/TDD-Katas

我们已经在前几章讨论了很多关于 TDD,所以我们不打算在这里详细讨论。相反,让我们按照以下步骤创建一个测试项目:

  1. 打开我们的 Web 应用程序。

  2. 在 Visual Studio 的解决方案资源管理器中,右键单击解决方案,然后单击添加 | 新建项目...,如下截图所示:

  1. 从添加新项目模板中,选择.NET Core 和 xUnit 测试项目(.NET Core),并提供一个有意义的名称:

您将得到一个默认的单元test类,其中包含空的测试代码,如下代码片段所示:

namespace Product_Test
{
    public class UnitTest1
    {
        [Fact]
        public void Test1()
        {
        }
    }
}

您可以更改此类的名称,或者如果您想编写自己的test类,可以放弃此类:

public class ProductData
{
    public IEnumerable<ProductViewModel> GetProducts()
    {
        var productVm = new List<ProductViewModel>
        {
            new ProductViewModel
            {
                CategoryId = Guid.NewGuid(),
                CategoryDescription = "Category Description",
                CategoryName = "Category Name",
                ProductDescription = "Product Description",
                ProductId = Guid.NewGuid(),
                ProductImage = "Image full path",
                ProductName = "Product Name",
                ProductPrice = 112M
            },
           ... 
        };

        return productVm;
    }
  1. 先前的代码来自我们新添加的ProductDate类。请将其添加到名为Fake的新文件夹中。这个类只是创建虚拟数据,以便我们可以测试产品的 Web 应用程序:
public class ProductTests
{
    [Fact]
    public void Get_Returns_ActionResults()
    {
        // Arrange
        var mockRepo = new Mock<IProductRepository>();
        mockRepo.Setup(repo => repo.GetAll()).Returns(new ProductData().GetProductList());
        var controller = new ProductController(mockRepo.Object);

        // Act
        var result = controller.GetList();

        // Assert
        var viewResult = Assert.IsType<OkObjectResult>(result);
        var model = Assert.IsAssignableFrom<IEnumerable<ProductViewModel>>(viewResult.Value);
        Assert.NotNull(model);
        Assert.Equal(2, model.Count());
    }
}
  1. Services文件夹中添加一个名为ProductTests的新文件。请注意,我们在这段代码中使用了StubsMocks

我们的先前代码将通过红色波浪线抱怨错误,如下截图所示:

  1. 先前的代码存在错误,因为我们没有添加一些必需的包来执行测试。为了克服这些错误,我们应该在我们的test项目中安装moq支持。在您的包管理器控制台中输入以下命令:
install-package moq 
  1. 上述命令将在测试项目中安装moq框架。请注意,在执行上述命令时,您应该选择我们创建的测试项目:

一旦安装了moq,您就可以开始测试了。

在使用xUnit测试项目时需要注意的重要点如下:

  • Fact是一个属性,用于没有参数的普通测试方法。

  • Theory是一个属性,用于带参数的测试方法。

  1. 一切准备就绪。现在,点击“测试资源管理器”并运行您的测试:

最后,我们的测试通过了!这意味着我们的控制器方法很好,我们的代码中没有任何问题或错误,可以破坏应用程序/系统的功能。

总结

本章的主要目标是使我们的 Web 应用程序能够防范未经授权的请求。本章介绍了使用 Visual Studio 逐步创建 Web 应用程序,并讨论了身份验证和授权。我们还讨论了 TDD,并创建了一个新的 xUnit Web 测试项目,其中我们使用了StubsMocks

在下一章中,我们将讨论在.NET Core 中使用并发编程时的最佳实践和模式。

问题

以下问题将帮助您巩固本章中包含的信息:

  1. 什么是身份验证和授权?

  2. 在第一级请求中使用身份验证然后允许受限区域的传入请求是否安全?

  3. 您如何证明授权始终在身份验证之后进行?

  4. 什么是 TDD,为什么开发人员关心它?

  5. 定义 TDD katas。它们如何帮助我们改进 TDD 方法?

进一步阅读

恭喜,您已经完成了本章!要了解本章涵盖的主题,请参考以下书籍:

第三部分:函数式编程、响应式编程和云编程

这是本书中最重要的部分。在这一部分中,熟悉.NET Framework 的读者可以将他们的学习与.NET Core 联系起来,而熟悉.NET Core 的读者可以通过实际示例增进他们的知识。我们将使用模式来解决一些现代软件开发中更具挑战性的方面。

本节包括以下章节:

  • 第八章,《.NET Core 并发编程》

  • 第九章,《函数式编程实践-一种方法》

  • 第十章,《响应式编程模式和技术》

  • 第十一章,《高级数据库设计和应用技术》

  • 第十二章,《云编程》

第八章:.NET Core 中的并发编程

在上一章(第七章,为 Web 应用程序实现设计模式 - 第二部分)中,我们使用各种模式创建了一个示例 Web 应用程序。我们调整了授权和认证机制以保护 Web 应用程序,并讨论了测试驱动开发TDD)以确保我们的代码已经经过测试并且可以正常工作。

本章将讨论在.NET Core 中执行并发编程时采用的最佳实践。在本章的后续部分中,我们将学习与 C#和.NET Core 应用程序中良好组织的并发相关的设计模式。

本章将涵盖以下主题:

  • Async/Await - 为什么阻塞是不好的?

  • 多线程和异步编程

  • 并发集合

  • 模式和实践 - TDD 和并行 LINQ

技术要求

本章包含各种代码示例来解释概念。代码保持简单,仅用于演示目的。大多数示例涉及使用 C#编写的.NET Core 控制台应用程序。

完整的源代码可在以下链接找到:github.com/PacktPublishing/Hands-On-Design-Patterns-with-C-and-.NET-Core/tree/master/Chapter8

要运行和执行代码,您需要以下内容:

  • Visual Studio 2019(您也可以使用 Visual Studio 2017)

  • .NET Core 的设置

  • SQL Server(本章中使用 Express Edition)

安装 Visual Studio

要运行代码示例,您需要安装 Visual Studio(首选 IDE)。要做到这一点,您可以按照以下说明进行操作:

  1. 从安装说明中提到的下载链接下载 Visual Studio:docs.microsoft.com/en-us/visualstudio/install/install-visual-studio

  2. 按照提到的安装说明进行操作。

  3. Visual Studio 安装有多个选项可供选择。在这里,我们使用 Windows 的 Visual Studio。

设置.NET Core

如果您没有安装.NET Core,您需要按照以下说明进行操作:

  1. www.microsoft.com/net/download/windows下载 Windows 的.NET Core。

  2. 有关多个版本和相关库,请访问dotnet.microsoft.com/download/dotnet-core/2.2

安装 SQL Server

如果您没有安装 SQL Server,可以按照以下说明进行操作:

  1. 从以下链接下载 SQL Server:www.microsoft.com/en-in/download/details.aspx?id=1695

  2. 您可以在这里找到安装说明:docs.microsoft.com/en-us/sql/ssms/download-sql-server-management-studio-ssms?view=sql-server-2017

有关故障排除和更多信息,请参考以下链接:www.blackbaud.com/files/support/infinityinstaller/content/installermaster/tkinstallsqlserver2008r2.htm

现实世界中的并发

并发是我们生活的一部分:它存在于现实世界中。当我们讨论并发时,我们指的是多任务处理。

在现实世界中,我们经常进行多任务处理。例如,我们可以在使用手机通话时编写程序,我们可以在吃饭时看电影,我们可以在阅读乐谱时唱歌。有很多例子说明我们作为人类可以进行多任务处理。不用深入科学细节,我们可以看到我们的大脑试图掌握新事物的同时,也指挥身体的其他器官工作,比如心脏或我们的嗅觉,这是一种多任务处理。

同样的方法也适用于我们的系统(计算机)。如果我们考虑今天的计算机,每台可用的计算机都有多核 CPU(多个核心)。这是为了允许同时执行多个指令,让我们能够同时执行多个任务。

在单个 CPU 机器上真正的并行是不可能的,因为任务是不可切换的,因为 CPU 只有一个核心。这只有在具有多个 CPU(多个核心)的机器上才可能。简而言之,并发编程涉及两件事:

  • 任务管理:将工作单元分配给可用线程。

  • 通信:设置任务的初始参数并获取结果。

每当有多个事情/任务同时发生时,我们称之为并发。在我们的编程语言中,每当程序的任何部分同时运行时,这被称为并发编程。您也可以将并行编程用作并发编程的同义词。

举个例子,想象一下一个需要门票才能进入特定会议厅的大型会议。在会议厅的门口,您必须购买门票,用现金或信用卡付款。当您付款时,柜台助理可能会将您的详细信息输入系统,打印发票,并为您提供门票。现在假设还有更多人想要购买门票。每个人都必须执行必要的活动才能从售票处领取门票。在这种情况下,每次只能有一个人从一个柜台接受服务,其他人则等待他们的轮到。假设一个人从柜台领取门票需要两分钟;因此,下一个人需要等待两分钟才能轮到他们。如果排队的人数是 50 人,那么最后一个人的等待时间可以改变。如果有两个以上的售票柜台,每个柜台都在两分钟内执行任务,这意味着每两分钟,三个人将能够领取三张门票——或者三个柜台每两分钟卖出两张门票。换句话说,每个售票柜台都在同一时间执行相同的任务(即售票)。这意味着所有柜台都是并行服务的;因此,它们是并发的。这在下图中有所体现:

在上图中,清楚地显示了排队的每个人都处于等待位置或者在柜台上活动,而且有三个队列,任务是按顺序进行的。所有三个柜台(CounterACounterBCounterC)在同一时间执行任务——它们在并行进行活动。

并发是指两个或更多任务在重叠的时间段内开始、运行和完成。

并行性是指两个或更多任务同时运行。

这些是并发活动,但想象一下一个巨大的人群在排队(例如,10,000 人);在这里进行并行处理是没有用的,因为这不会解决这个操作中可能出现的瓶颈问题。另一方面,您可以将柜台数量增加到 50 个。它们会解决这个问题吗?在我们使用任何软件时,这种问题会发生。这是一个与阻塞相关的问题。在接下来的章节中,我们将更详细地讨论并发编程。

多线程和异步编程

简而言之,我们可以说多线程意味着程序在多个线程上并行运行。在异步编程中,一个工作单元与主应用程序线程分开运行,并告诉调用线程任务已完成、失败或正在进行中。在异步编程周围需要考虑的有趣问题是何时使用它以及它的好处是什么。

更多线程访问相同的共享数据并以不可预测的结果更新它的潜力可以称为竞争条件。我们已经在第四章中讨论了竞争条件,实现设计模式 - 基础部分 2

考虑我们在上一节讨论的场景,即排队的人们正在领取他们的票。让我们尝试在一个多线程程序中捕捉这种情况:

internal class TicketCounter
{
    public static void CounterA() => Console.WriteLine("Person A is collecting ticket from Counter A");
    public static void CounterB() => Console.WriteLine("Person B is collecting ticket from Counter B");
    public static void CounterC() => Console.WriteLine("Person C is collecting ticket from Counter C");
}

在这里,我们有一个代表我们整个领取柜台设置的TicketCounter类(我们在上一节中讨论过这些)。三个方法:CounterA()CounterB()CounterC()代表一个单独的领取柜台。这些方法只是向控制台输出一条消息,如下面的代码所示:

internal class Program
{
    private static void Main(string[] args)
    {
        var counterA = new Thread(TicketCounter.CounterA);
        var counterB = new Thread(TicketCounter.CounterB);
        var counterC = new Thread(TicketCounter.CounterC);
        Console.WriteLine("3-counters are serving...");
        counterA.Start();
        counterB.Start();
        counterC.Start();
        Console.WriteLine("Next person from row");
        Console.ReadLine();
    }
}

上面的代码是我们的Program类,它从Main方法中启动活动。在这里,我们为所有柜台声明并启动了三个线程。请注意,我们按顺序启动了这些线程。由于我们期望这些线程将按照相同的顺序执行,让我们运行程序并查看输出,如下面的屏幕截图所示:

根据代码,上面的程序没有按照给定的顺序执行。根据我们的代码,执行顺序应该如下:

3-counters are serving...
Next person from row
Person A is collecting ticket from Counter A
Person B is collecting ticket from Counter B
Person C is collecting ticket from Counter C

这是由于线程,这些线程在没有保证按照它们被声明/启动的顺序/序列执行的情况下同时工作。

再次运行程序,看看我们是否得到相同的输出:

上面的快照显示了与先前结果不同的输出,所以现在我们按顺序得到了输出:

3-counters are serving...
Person A is collecting ticket from Counter A
Person B is collecting ticket from Counter B
Next person from row
Person C is collecting ticket from Counter C

因此,线程正在工作,但不是按照我们定义的顺序。

您可以像这样设置线程的优先级:counterC.Priority = ThreadPriority.Highest;counterB.Priority = ThreadPriority.Normal;,和counterA.Priority = ThreadPriority.Lowest;

为了以同步的方式运行线程,让我们修改我们的代码如下:

internal class SynchronizedTicketCounter
{
    public void ShowMessage()
    {
        int personsInQueue = 5; //assume maximum persons in queue
 lock (this)
        {
            Thread thread = Thread.CurrentThread;
            for (int personCount = 0; personCount < personsInQueue; personCount++)
            {
                Console.WriteLine($"\tPerson {personCount + 1} is collecting ticket from counter {thread.Name}.");
            }
        }
    }
}

我们创建了一个新的SynchronizedTicketCounter类,其中包含ShowMessage()方法;请注意前面代码中的lock(this){...}。运行程序并检查输出:

我们得到了我们期望的输出,现在我们的柜台按照正确的顺序服务。

异步/等待 - 为什么阻塞是不好的?

异步编程在我们期望在同一时间点进行各种活动的情况下非常有帮助。通过async关键字,我们将方法/操作定义为异步的。考虑以下代码片段:

internal class AsyncAwait
{
    public async Task ShowMessage()
    {
        Console.WriteLine("\tServing messages!");
        await Task.Delay(1000);
    }
}

在这里,我们有一个带有async方法ShowMessage()AsyncAwait类。这个方法只是打印一个消息,会显示在控制台窗口中。现在,每当我们在另一个代码中调用/使用这个方法时,该部分代码可能会等待/阻塞操作,直到ShowMessage()方法执行并完成其任务。参考以下快照:

我们之前的屏幕截图显示,我们为我们的ShowMessage()方法设置了 1,000 毫秒的延迟。在这里,我们指示程序在 1,000 毫秒后完成。如果我们尝试从先前的代码中删除await,Visual Studio 将立即发出警告,要求将await放回去;参考以下快照:

通过await运算符的帮助,我们正在使用非阻塞 API 调用。运行程序并查看以下输出:

我们将得到如前面快照中所示的输出。

并发集合

.NET Core 框架提供了各种集合,我们可以使用 LINQ 查询。作为开发人员,在寻找线程安全集合时,选择余地要少得多。没有线程安全的集合,开发人员在执行多个操作时可能会变得困难。在这种情况下,我们将遇到我们已经在第四章中讨论过的竞争条件。为了克服这种情况,我们需要使用lock语句,就像我们在前一节中使用的那样。例如,我们可以编写一个简化的lock语句的实现代码-参考以下代码片段,我们在其中使用了lock语句和集合类Dictionary

public bool UpdateQuantity(string name, int quantity)
{
    lock (_lock)
    {
        _books[name].Quantity += quantity;
    }

    return true;
}

前面的代码来自InventoryContext;在这段代码中,我们正在阻止其他线程锁定我们正在尝试更新数量的操作。

Dictionary集合类的主要缺点是它不是线程安全的。当我们在多个线程中使用Dictionary时,我们必须在lock语句中使用它。为了使我们的代码线程安全,我们可以使用ConcurrentDictionary集合类。

ConcurrentDictionary是一个线程安全的集合类,它存储键值对。这个类有lock语句的实现,并提供了一个线程安全的类。考虑以下代码:

private readonly IDictionary<string, Book> _books;
protected InventoryContext()
{
    _books = new ConcurrentDictionary<string, Book>();
}

前面的代码片段来自我们的 FlixOne 控制台应用程序的InventoryContext类。在这段代码中,我们有_books字段,并且它被初始化为ConcurrentDictionary集合类。

由于我们在多线程中使用InventoryContext类的UpdateQuantity()方法,有一种可能性是一个线程增加数量,而另一个线程将数量重置为其初始水平。这是因为我们的对象来自单个集合,对集合的任何更改在一个线程中对其他线程不可见。所有线程都引用原始未修改的集合,简单来说,我们的方法不是线程安全的,除非我们使用lock语句或ConcurretDictionary集合类。

模式和实践- TDD 和并行 LINQ

当我们使用多线程时,我们应该遵循最佳实践来编写流畅的代码。流畅的代码是指开发人员不会面临死锁的代码。换句话说,在编写过程中,多线程需要非常小心。

当多个线程在一个类/程序中运行时,当每个线程接近在lock语句下编写的对象或资源时,死锁就会发生。实际的死锁发生在每个线程都试图锁定另一个线程已经锁定的对象/资源时。

一个小错误可能导致开发人员不得不处理由于被阻塞的线程而发生的未知错误。除此之外,代码中几个字的错误实现可能会影响 100 行代码。

让我们回到本章开头讨论的会议门票的例子。如果售票处无法履行其职责并分发门票会发生什么?在这种情况下,每个人都会尝试到达售票处并获取门票,这可能会导致售票处被堵塞。这可能会导致售票处被阻塞。相同的逻辑适用于我们的程序。我们将遇到多个线程尝试锁定我们的对象/资源的死锁情况。避免这种情况的最佳做法是使用一种同步访问对象/资源的机制。.NET Core 框架提供了Monitor类来实现这一点。我已经重新编写了我们的旧代码以避免死锁情况-请参阅以下代码:

private static void ProcessTickets()
{
    var ticketCounter = new TicketCounter();
    var counterA = new Thread(ticketCounter.ShowMessage);
    var counterB = new Thread(ticketCounter.ShowMessage);
    var counterC = new Thread(ticketCounter.ShowMessage);
    counterA.Name = "A";
    counterB.Name = "B";
    counterC.Name = "C";
    counterA.Start();
    counterB.Start();
    counterC.Start();
}

在这里,我们有ProcessTicket方法;它启动了三个线程(每个线程代表一个售票处)。每个线程都会到达TicketCounter类的ShowMessage。如果我们的ShowMessage方法没有很好地编写来处理这种情况,就会出现死锁问题。所有三个线程都将尝试为与ShowMessage方法相关的各自对象/资源获取锁。

以下代码是ShowMessage方法的实现,我编写了这段代码来处理死锁情况:

private static readonly object Object = new object();
public void ShowMessage()
{
    const int personsInQueue = 5;
    if (Monitor.TryEnter(Object, 300))
    {
        try
        {
            var thread = Thread.CurrentThread;
            for (var personCount = 0; personCount < personsInQueue; personCount++)
                Console.WriteLine(
                    $"\tPerson {personCount + 1} is collecting ticket from counter {thread.Name}.");
        }
        finally
        {
            Monitor.Exit(Object);
        }
    }
}

上述是我们TicketCounter类的ShowMessage()方法。在这个方法中,每当一个线程尝试锁定Object时,如果Object已经被锁定,它会尝试 300 毫秒。Monitor类会自动处理这种情况。使用Monitor类时,开发人员不需要担心多个线程正在运行的情况,每个线程都在尝试获取锁。运行程序以查看以下输出:

在上面的快照中,您会注意到在counterA之后,counterC正在服务,然后是counter B。这意味着在thread A之后,thread C被启动,然后是thread B。换句话说,thread A首先获取锁,然后在 300 毫秒后,thread C尝试获取锁,然后thread B尝试锁定对象。如果要设置线程的顺序或优先级,可以添加以下代码行:

counterC.Priority = ThreadPriority.Highest
counterB.Priority = ThreadPriority.Normal;
counterA.Priority = ThreadPriority.Lowest;

当您将上述行添加到ProcessTickets方法时,所有线程将按顺序工作:首先是Thread C,然后是Thread B,最后是Thread A

线程优先级是一个枚举,告诉我们如何调度线程和System.Threading.ThreadPriority具有以下值:

  • Lowest:这是最低的优先级,意味着具有Lowest优先级的线程可以在任何其他优先级的线程之后进行调度。

  • BelowNormal:具有BelowNormal优先级的线程可以在具有Normal优先级的线程之后,但在具有Lowest优先级的线程之前进行调度。

  • Normal:所有线程都具有默认优先级Normal。具有Normal优先级的线程可以在具有AboveNormal优先级的线程之后,但在具有BelowNormal优先级的线程之前进行调度。

  • AboveNormal:具有AboveNormal优先级的线程可以在具有Normal优先级的线程之前,但在具有Highest优先级的线程之后进行调度。

  • Highest:这是线程的最高优先级级别。具有Highest优先级的线程可以在具有任何其他优先级的线程之前进行调度。

在为线程设置优先级级别后,执行程序并查看以下输出:

根据上面的快照,在设置了优先级后,计数器按顺序为CBA提供服务。通过小心和简单的实现,我们可以处理死锁情况,并安排我们的线程按特定顺序/优先级提供服务。

.NET Core 框架还提供了任务并行库TPL),它是属于System.ThreadingSystem.Threading.Tasks命名空间的一组公共 API。借助 TPL,开发人员可以通过简化实现使应用程序并发运行。

考虑以下代码,我们可以看到 TPL 的最简单实现:

public void PallelVersion()
{
    var books = GetBooks();
    Parallel.ForEach(books, Process);
}

上面是一个简单的使用Parallel关键字的ForEach循环。在上面的代码中,我们只是遍历了一个books集合,并使用Process方法进行处理:

private void Process(Book book)
{
    Console.WriteLine($"\t{book.Id}\t{book.Name}\t{book.Quantity}");
}

前面的代码是我们的Process方法(再次强调,这是最简单的方法),它打印了books的细节。根据他们的要求,用户可以执行尽可能多的操作:

private static void ParallelismExample()
{
    var parallelism = new Parallelism();
    parallelism.GenerateBooks(19);
    Console.WriteLine("\n\tId\tName\tQty\n");
    parallelism.PallelVersion();
    Console.WriteLine($"\n\tTotal Processes Running on the machine:{Environment.ProcessorCount}\n");
    Console.WriteLine("\tProcessing complete. Press any key to exit.");
    Console.ReadKey();
}

如您所见,我们有ParallelismExample方法,它生成书籍列表并通过执行PallelVersion方法处理书籍。

在执行程序以查看以下输出之前,首先考虑顺序实现的以下代码片段:

public void Sequential()
{
    var books = GetBooks();
    foreach (var book in books) { Process(book); }
}

上面的代码是一个Sequential方法;它使用简单的foreach循环来处理书籍集合。执行程序并查看以下输出:

注意上面的快照。首先,在我运行此演示的系统上有四个进程正在运行。第二个迭代的集合是按顺序从 1 到 19。程序不会将任务分成在机器上运行的不同进程。按任意键退出当前进程,执行ParallelismVersion方法的程序,并查看以下输出:

上面的截图是并行代码的输出;您可能会注意到代码没有按顺序处理,ID 也没有按顺序出现,我们可以看到Id 139之后但在10之前。如果这些是按顺序运行的,那么Id的顺序将是910,然后是13

在.NET Core 诞生之前,LINQ 就已经存在于.NET 世界中。LINQ-to-Objects允许我们使用任意对象序列执行内存中的查询操作。LINQ-to-Objects是建立在IEnumerable<T>之上的一组扩展方法。

延迟执行意味着数据枚举后才执行。

PLINQ 可以作为 TPL 的替代方案。它是 LINQ 的并行实现。PLINQ 查询操作在内存中的IEnumerableIEnumerable<T>数据源上执行。此外,它具有延迟执行。LINQ 查询按顺序执行操作,而 PLINQ 并行执行操作,并充分利用机器上的所有处理器。考虑以下代码以查看 PLINQ 的实现:

public void Process()
{
    var bookCount = 50000;
    _parallelism.GenerateBooks(bookCount);
    var books = _parallelism.GetBooks();
    var query = from book in books.AsParallel()
        where book.Quantity > 12250
        select book;
    Console.WriteLine($"\n\t{query.Count()} books out of {bookCount} total books," +
                      "having Qty in stock more than 12250.");
    Console.ReadKey();
}

上面的代码是我们的 PLINQ 类的处理方法。在这里,我们使用 PLINQ 查询库存中数量超过12250的任何书籍。执行代码以查看此输出:

PLINQ 使用机器的所有处理器,但我们可以通过使用WithDegreeOfParallelism()方法来限制 PLINQ 中的处理器。我们可以在Linq类的Process()方法中使用以下代码:

var query = from book in books.AsParallel().WithDegreeOfParallelism(3)
    where book.Quantity > 12250
    select book;
return query;

上面的代码将只使用机器的三个处理器。执行它们,您会发现您得到与前面代码相同的输出。

总结

在本章中,我们讨论了并发编程和现实世界中的并发性。我们看了看如何处理与我们日常生活中的并发相关的各种情景。我们看了看如何从服务柜台收集会议门票,并了解了并行编程和并发编程是什么。我们还涵盖了多线程、Async/AwaitConcurrent集合和 PLINQ。

在接下来的章节中,我们将尝试使用 C#语言进行函数式编程。我们将深入探讨这些概念,以展示如何在.NET Core 中使用 C#进行函数式编程。

问题

以下问题将帮助您巩固本章中包含的信息:

  1. 什么是并发编程?

  2. 真正的并行性是如何发生的?

  3. 什么是竞争条件?

  4. 为什么我们应该使用并发字典?

进一步阅读

以下书籍将帮助您更多地了解本章涉及的主题:

第九章:函数式编程实践

上一章(第八章,* .NET Core 中的并发编程*)介绍了.NET Core 中的并发编程,本章的目的是利用async/await和并行性,使我们的程序更加高效。

在本章中,我们将品尝使用 C#语言的函数式编程。我们还将深入探讨这些概念,向您展示如何利用.NET Core 中的 C#来执行函数式编程。本章的目的是帮助您了解函数式编程是什么,以及我们如何使用 C#语言来实现它。

函数式编程受数学启发,以函数式方式解决问题。在数学中,我们有公式,在函数式编程中,我们使用各种函数的数学形式。函数式编程的最大优点是它有助于无缝实现并发。

本章将涵盖以下主题:

  • 理解函数式编程

  • 库存应用程序

  • 策略模式和函数式编程

技术要求

本章包含各种代码示例,以解释函数式编程的概念。代码保持简单,仅用于演示目的。大多数示例涉及使用 C#编写的.NET Core 控制台应用程序。

完整的源代码可在以下链接找到:github.com/PacktPublishing/Hands-On-Design-Patterns-with-C-and-.NET-Core/tree/master/Chapter9

要运行和执行代码,先决条件如下:

  • Visual Studio 2019(也可以使用 Visual Studio 2017 更新 3 或更高版本来运行应用程序)。

  • 设置.NET Core

  • SQL Server(本章中使用 Express Edition)

安装 Visual Studio

要运行这些代码示例,您需要安装 Visual Studio 2017(或更新版本,如 2019)。要执行此操作,请按照以下说明操作:

  1. 从以下下载链接下载 Visual Studio,其中包括安装说明:docs.microsoft.com/en-us/visualstudio/install/install-visual-studio

  2. 按照安装说明操作。

  3. Visual Studio 安装有多个版本可供选择。在这里,我们使用 Windows 的 Visual Studio。

设置.NET Core

如果您尚未安装.NET Core,需要按照以下说明操作:

  1. www.microsoft.com/net/download/windows下载 Windows 的.NET Core。

  2. 访问dotnet.microsoft.com/download/dotnet-core/2.2获取多个版本和相关库。

安装 SQL Server

如果您尚未安装 SQL Server,需要按照以下说明操作:

  1. 从以下链接下载 SQL Server:www.microsoft.com/en-in/download/details.aspx?id=1695

  2. 在此处找到安装说明:docs.microsoft.com/en-us/sql/ssms/download-sql-server-management-studio-ssms?view=sql-server-2017

有关故障排除和更多信息,请参阅以下链接:www.blackbaud.com/files/support/infinityinstaller/content/installermaster/tkinstallsqlserver2008r2.htm

理解函数式编程

简而言之,函数式编程是一种符号计算的方法,它与解决数学问题的方式相同。任何函数式编程都是基于数学函数及其编码风格的。任何支持函数式编程的语言都可以解决以下两个问题:

  • 它需要解决什么问题?

  • 它是如何解决的?

函数式编程并不是一个新的发明。这种语言在行业中已经存在很长时间了。以下是一些支持函数式编程的知名编程语言:

  • Haskell

  • Scala

  • Erlang

  • Clojure

  • Lisp

  • OCaml

2005 年,微软发布了 F#的第一个版本(发音为EffSharp—fsharp.org/)。这是一种具有许多良好特性的函数式编程语言。在本章中,我们不会讨论太多关于 F#,但我们将讨论函数式编程及其在 C#语言中的实现。

纯函数是通过说它们是纯的来加强函数式编程的函数。这些函数在两个层面上工作:

  • 最终结果/输出对于提供的参数始终保持不变。

  • 它们不会影响程序的行为或应用程序的执行路径,即使它们被调用了一百次。

考虑一下我们 FlixOne 库存应用程序中的例子:

public static class PriceCalc
{
    public static decimal Discount(this decimal price, decimal discount) => 
        price * discount / 100;

    public static decimal PriceAfterDiscount(this decimal price, decimal discount) =>
        decimal.Round(price - Discount(price, discount));
}

正如你所看到的,我们有一个PriceCalc类,其中有两个扩展方法:DiscountPriceAfterDiscount。这些函数可以被称为纯函数;PriceCalc函数和PriceAfterDiscount函数都符合函数的标准;Discount方法将根据当前价格和折扣计算折扣。在这种情况下,该方法的输出对于提供的参数值永远不会改变。这样,价格为190.00且折扣为10.00的产品将以这种方式计算:190.00 * 10.00 /100,并返回19.00。我们的下一个方法—PriceAfterDiscount—使用相同的参数值将计算190.00 - 19.00并返回171.00的值。

函数式编程中另一个重要的点是函数是纯的,并传达完整的信息(也称为函数诚实)。考虑前面代码中的Discount方法;这是一个纯函数,也是诚实的。那么,如果有人意外地提供了负折扣或超过实际价格的折扣(超过 100%),这个函数还会保持纯和诚实吗?为了处理这种情况,我们的数学函数应该这样编写,如果有人输入discount <= 0 or discount > 100,那么系统将不予考虑。考虑以下代码以此方法编写:

public static decimal Discount(this decimal price, ValidDiscount validDiscount)
{
    return price * validDiscount.Discount / 100;
}

正如你所看到的,我们的Discount函数有一个名为ValidDiscount的参数类型,用于验证我们讨论的输入。这样,我们的函数现在是一个诚实的函数。

这些函数就像函数式编程一样简单,但是要想使用函数式编程仍然需要大量的实践。在接下来的章节中,我们将讨论函数式编程的高级概念,包括函数式编程原则。

考虑以下代码,我们正在检查折扣值是否有效:

private readonly Func<decimal, bool> _vallidDiscount = d => d > 0 || d % 100 <= 1;

在上面的代码片段中,我们有一个名为_validDiscount的字段。让我们看看它的作用:Func接受decimal作为输入,并返回bool作为输出。从它的名称可以看出,field只存储有效的折扣。

Func是一种委托类型,指向一个或多个参数的方法,并返回一个值。Func的一般声明是Func<TParameter, TOutput>,其中TParameter是任何有效数据类型的输入参数,TOutput是任何有效数据类型的返回值。

考虑以下代码片段,我们在一个方法中使用了_validDiscount字段:

public IEnumerable<DiscountViewModel> FilterOutInvalidDiscountRates(
    IEnumerable<DiscountViewModel> discountViewModels)
{
    var viewModels = discountViewModels.ToList();
    var res = viewModels.Select(x => x.Discount).Where(_vallidDiscount);
    return viewModels.Where(x => res.Contains(x.Discount));
}

在上述代码中,我们有FilterOutInvalidDiscountRates方法。这个方法不言自明,表明我们正在过滤掉无效的折扣率。现在让我们分析一下代码。

FilterOutInvalidDiscountRates方法返回一个具有有效折扣的产品的DiscountViewModel类的集合。以下代码是我们的DiscountViewModel类的代码:

public class DiscountViewModel
{
    public Guid ProductId { get; set; }
    public string ProductName { get; set; }
    public decimal Price { get; set; }
    public decimal Discount { get; set; }
    public decimal Amount { get; set; }
}

我们的DiscountViewModel类包含以下内容:

  • ProductId:这代表一个产品的 ID。

  • ProductName:这代表一个产品的名称。

  • Price:这包含产品的实际价格。实际价格是在任何折扣、税收等之前。

  • Discount:这包含折扣的百分比,如 10 或 3。有效的折扣率不应为负数,等于零或超过 100%(换句话说,不应超过产品的实际成本)。

  • Amount:这包含任何折扣、税收等之后的产品价值。

现在,让我们回到我们的FilterOutInavlidDiscountRates方法,看一下viewModels.Select(x => x.Discount).Where(_vallidDiscount)。在这里,您可能会注意到我们正在从我们的viewModels列表中选择折扣率。这个列表包含根据_validDiscount字段有效的折扣率。在下一行,我们的方法返回具有有效折扣率的记录。

在函数式编程中,这些函数也被称为一等函数。这些函数的值可以作为任何其他函数的输入或输出使用。它们也可以被分配给变量或存储在集合中。

转到 Visual Studio 并打开FlixOne库存应用程序。从这里运行应用程序,您将看到以下屏幕截图:

上一张屏幕截图是产品列表页面,显示了所有可用的产品。这是一个简单的页面;您也可以称之为产品列表仪表板,在这里您将找到所有产品。从创建新产品,您可以添加一个新产品,编辑将为您提供更新现有产品的功能。此外,详细页面将显示特定产品的完整详细信息。通过单击删除,您可以从列表中删除现有产品。

请参考我们的DiscountViewModel类。我们有多个产品的折扣率选项,业务规则规定一次只能激活一个折扣率。要查看产品的所有折扣率,请从前一屏幕(产品列表)中单击折扣率。这将显示以下屏幕:

上述屏幕是产品折扣列表,显示了产品名称 Mango 的折扣列表。这有两个折扣率,但只有季节性折扣率是活动的。您可能已经注意到备注栏;这被标记为无效的折扣率,因为根据前一节讨论的_validDiscount,这个折扣率不符合有效折扣率的标准。

Predicate也是一种委托类型,类似于Func委托。这代表一个验证一组标准的方法。换句话说,Predicate返回Predicate <T>类型,其中T是有效的数据类型。如果标准匹配并返回T类型的值,则它起作用。

考虑以下代码,我们在其中验证产品名称是否有效为句子大小写:

private static readonly TextInfo TextInfo = new CultureInfo("en-US", false).TextInfo;
private readonly Predicate<string> _isProductNameTitleCase = s => s.Equals(TextInfo.ToTitleCase(s));

在上述代码中,我们使用了Predicate关键字,这分析了使用TitleCase关键字验证ProductName的条件。如果标准匹配,结果将是true。如果不匹配,结果将是false。考虑以下代码片段,我们在其中使用了_isProductNameTitleCase

public IEnumerable<ProductViewModel> FilterOutInvalidProductNames(
    IEnumerable<ProductViewModel> productViewModels) => productViewModels.ToList()
    .Where(p => _isProductNameTitleCase(p.ProductName));

在前面的代码中,我们有FilterOutInvalidProductNames方法。该方法的目的是选择具有有效产品名称(仅TitleCase产品名称)的产品。

增强我们的库存应用程序

该项目是针对一个假设情况,即一家名为 FlixOne 的公司希望增强一个库存管理应用程序,以管理其不断增长的产品收藏。这不是一个新的应用程序,因为我们已经开始开发这个应用程序,并在第三章中讨论了初始阶段,即实施设计模式 - 基础部分 1,在那里我们已经开始开发基于控制台的库存系统。利益相关者将不时审查应用程序,并尝试满足最终用户的需求。增强非常重要,因为这个应用程序将被员工(用于管理库存)和客户(用于浏览和创建新订单)使用。该应用程序需要具有可扩展性,并且是业务的重要系统。

由于这是一本技术书,我们将主要从开发团队的角度讨论各种技术观察,并讨论用于实现库存管理应用的模式和实践。

要求

有必要增强应用程序,这不可能在一天内完成。这将需要大量的会议和讨论。在几次会议的过程中,业务和开发团队讨论了对库存管理系统的新增强的要求。定义一组清晰的要求的进展缓慢,最终产品的愿景也不清晰。开发团队决定将庞大的需求列表精简到足够的功能,以便一个关键人物可以开始记录一些库存信息。这将允许简单的库存管理,并为业务提供一个可以扩展的基础。我们将按照需求进行工作,并采取最小可行产品MVP)的方法。

MVP 是一个应用程序的最小功能集,仍然可以发布并为用户群体提供足够的价值。

在管理层和业务分析师之间进行了几次会议和讨论后,产生了一系列要求的清单,以增强我们的FlixOne web 应用程序。高级要求如下:

  • 分页实现:目前,所有页面列表都没有分页。通过向下滚动或向上滚动屏幕来查看具有大页数的项目是非常具有挑战性的。

  • 折扣率:目前,没有提供添加或查看产品的各种折扣率。折扣率的业务规则如下:

  • 一个产品可以有多个折扣率。

  • 一个产品只能有一个活动的折扣率。

  • 有效的折扣率不应为负值,也不应超过 100%。

回到 FlixOne

在前一节中,我们讨论了增强应用程序所需的内容。在本节中,我们将实现这些要求。让我们首先重新审视一下我们项目的文件结构。看一下下面的快照:

之前的快照描述了我们的 FlixOne web 应用程序,其文件夹结构如下:

  • wwwroot:这是一个带有静态内容的文件夹,例如 CSS 和 jQuery 文件,这些文件是 UI 项目所需的。该文件夹带有 Visual Studio 提供的默认模板。

  • 公共:这包含所有与业务规则和更多相关的公共文件和操作。

  • 上下文:这包含InventoryContext,这是一个提供Entity Framework Core功能的DBContext类。

  • 控制器:这包含我们FlixOne应用程序的所有控制器类。

  • 迁移:这包含了InventoryModel的快照和最初创建的实体。

  • 模型:这包含了我们应用程序所需的数据模型、ViewModels

  • 持久性:这包含了InventoryRepository及其操作。

  • 视图:这包含了应用程序的所有视图/屏幕。

考虑以下代码:

public interface IHelper
{
    IEnumerable<DiscountViewModel> FilterOutInvalidDiscountRates(
        IEnumerable<DiscountViewModel> discountViewModels);

    IEnumerable<ProductViewModel> FilterOutInvalidProductNames(
        IEnumerable<ProductViewModel> productViewModels);
}

上面的代码包含一个IHelper接口,其中包含两个方法。我们将在下面的代码片段中实现这个接口:

public class Helper : IHelper
{
    private static readonly TextInfo TextInfo = new CultureInfo("en-US", false).TextInfo;
    private readonly Predicate<string> _isProductNameTitleCase = s => s.Equals(TextInfo.ToTitleCase(s));
    private readonly Func<decimal, bool> _vallidDiscount = d => d == 0 || d - 100 <= 1;

    public IEnumerable<DiscountViewModel> FilterOutInvalidDiscountRates(
        IEnumerable<DiscountViewModel> discountViewModels)
    {
        var viewModels = discountViewModels.ToList();
        var res = viewModels.Select(x => x.ProductDiscountRate).Where(_vallidDiscount);
        return viewModels.Where(x => res.Contains(x.ProductDiscountRate));
    }

    public IEnumerable<ProductViewModel> FilterOutInvalidProductNames(
        IEnumerable<ProductViewModel> productViewModels) => productViewModels.ToList()
        .Where(p => _isProductNameTitleCase(p.ProductName));
}

Helper类实现了IHelper接口。在这个类中,我们有两个主要且重要的方法:一个是检查有效折扣,另一个是检查有效的ProductName属性。

在我们的应用程序中使用这个功能之前,我们应该将它添加到我们的Startup.cs文件中,如下面的代码所示:

public void ConfigureServices(IServiceCollection services)
{
    services.AddTransient<IInventoryRepositry, InventoryRepositry>();
    services.AddTransient<IHelper, Helper>();
    services.AddDbContext<InventoryContext>(o => o.UseSqlServer(Configuration.GetConnectionString("FlixOneDbConnection")));
    services.Configure<CookiePolicyOptions>(options =>
    {
        // This lambda determines whether user consent for non-essential cookies is needed for a given request.
        options.CheckConsentNeeded = context => true;
        options.MinimumSameSitePolicy = SameSiteMode.None;
    });
}

在上面的代码片段中,我们有一个写入语句,services.AddTransient<IHelper, Helper>();。通过这样做,我们向我们的应用程序添加了一个瞬态服务。我们已经在第五章中讨论了控制反转部分,实现设计模式-.Net Core

考虑以下代码,我们在这里使用IHelper类,利用了控制反转:

public class InventoryRepositry : IInventoryRepositry
{
    private readonly IHelper _helper;
    private readonly InventoryContext _inventoryContext;

    public InventoryRepositry(InventoryContext inventoryContext, IHelper helper)
    {
        _inventoryContext = inventoryContext;
        _helper = helper;
    }

... 
}

上面的代码包含了InventoryRepository类,我们可以看到适当使用了依赖注入DI):

    public IEnumerable<Discount> GetDiscountBy(Guid productId, bool activeOnly = false)
        {
            var discounts = activeOnly
                ? GetDiscounts().Where(d => d.ProductId == productId && d.Active)
                : GetDiscounts().Where(d => d.ProductId == productId);
            var product = _inventoryContext.Products.FirstOrDefault(p => p.Id == productId);
            var listDis = new List<Discount>();
            foreach (var discount in discounts)
            {
                if (product != null)
                {
                    discount.ProductName = product.Name;
                    discount.ProductPrice = product.Price;
                }

                listDis.Add(discount);
            }

            return listDis;
        }

上面的代码是InventoryRepository类的GetDiscountBy方法,它返回了activede-active记录的折扣模型集合。考虑以下用于DiscountViewModel集合的代码片段:

    public IEnumerable<DiscountViewModel> GetValidDiscoutedProducts(
        IEnumerable<DiscountViewModel> discountViewModels)
    {
        return _helper.FilterOutInvalidDiscountRates(discountViewModels);
    }
}

上面的代码使用了一个DiscountViewModel集合,过滤掉了根据我们之前讨论的业务规则没有有效折扣的产品。GetValidDiscountProducts方法返回DiscountViewModel的集合。

如果我们忘记在项目的startup.cs文件中定义IHelper,我们将会遇到一个异常,如下面的截图所示:

上面的截图清楚地表明IHelper服务没有被解析。在我们的情况下,我们不会遇到这个异常,因为我们已经将IHelper添加到了Startup类中。

到目前为止,我们已经添加了辅助方法来满足我们对折扣率的新要求,并对其进行验证。现在,让我们添加一个控制器和随后的操作方法。为此,从解决方案资源管理器中添加一个新的DiscountController控制器。之后,我们的FlixOne web 解决方案将看起来类似于以下快照:

在上面的快照中,我们可以看到我们的Controller文件夹现在有一个额外的控制器,即DiscountController。以下代码来自DiscountController

public class DiscountController : Controller
{
    private readonly IInventoryRepositry _repositry;

    public DiscountController(IInventoryRepositry inventoryRepositry)
    {
        _repositry = inventoryRepositry;
    }

    public IActionResult Index()
    {
        return View(_repositry.GetDiscounts().ToDiscountViewModel());
    }

    public IActionResult Details(Guid id)
    {
        return View("Index", _repositry.GetDiscountBy(id).ToDiscountViewModel());
    }
}

执行应用程序,并从主屏幕上点击产品,然后点击产品折扣清单。从这里,你将得到以下屏幕:

上面的快照描述了所有可用产品的产品折扣清单。产品折扣清单有很多记录,因此需要向上或向下滚动以查看屏幕上的项目。为了处理这种困难的情况,我们应该实现分页。

策略模式和函数式编程

在本书的前四章中,我们讨论了很多模式和实践。策略模式是四人帮模式中的重要模式之一。这属于行为模式类别,也被称为策略模式。这通常是使用类来实现的模式。这也是一个更容易使用函数式编程实现的模式。

回到本章的理解函数式编程部分,重新考虑函数式编程的范式。高阶函数是函数式编程的重要范式之一;使用它,我们可以轻松地以函数式的方式实现策略模式。

高阶函数HOFs)是接受函数作为参数的函数。它们也可以返回函数。

考虑以下代码,展示了函数式编程中 HOFs 的实现:

public static IEnumerable<T> Where<T>
    (this IEnumerable<T> source, Func<T, bool> criteria)
{
    foreach (var item in source)
        if (criteria(item))
            yield return item;
}

上述代码是Where子句的简单实现,我们在其中使用了LINQ 查询。在这里,我们正在迭代一个集合,并在满足条件时返回一个项。上述代码可以进一步简化。考虑以下更简化版本的代码:

public static IEnumerable<T> SimplifiedWhere<T>
    (this IEnumerable<T> source, Func<T, bool> criteria) => 
    Enumerable.Where(source, criteria);

正如你所看到的,SimplifiedWhere方法产生了与之前讨论的Where方法相同的结果。这个方法是基于条件的,并且有一个返回结果的策略,这个条件在运行时执行。我们可以轻松地在后续方法中调用上述函数,以利用函数式编程。考虑以下代码:

public IEnumerable<ProductViewModel>
    GetProductsAbovePrice(IEnumerable<ProductViewModel> productViewModels, decimal price) =>
    productViewModels.SimplifiedWhere(p => p.ProductPrice > price);

我们有一个名为GetProductsAbovePrice的方法。在这个方法中,我们提供了价格。这个方法很容易理解,它在一个ProductViewModel的集合上工作,并根据条件列出产品价格高于参数价格的产品。在我们的FlixOne库存应用中,你可以找到更多实现函数式编程的范围。

总结

函数式编程关注的是函数,主要是数学函数。任何支持函数式编程的语言都会通过两个主要问题来解决问题:需要解决什么,以及如何解决?我们看到了函数式编程及其在 C#编程语言中的简单实现。

我们还学习了FuncPredicate、LINQ、Lambda、匿名函数、闭包、表达式树、柯里化、闭包和递归。最后,我们研究了使用函数式编程实现策略模式。

在下一章(第十章,响应式编程模式和技术)中,我们将讨论响应式编程以及其模型和原则。我们还将讨论响应式扩展

问题

以下问题将帮助你巩固本章中包含的信息:

  1. 什么是函数式编程?

  2. 函数式编程中的引用透明是什么?

  3. 什么是纯函数?

第十章:响应式编程模式和技术

在上一章(第九章,函数式编程实践)中,我们深入研究了函数式编程,并了解了FuncPredicateLINQLambda匿名函数表达式树递归。我们还看了使用函数式编程实现策略模式。

本章将探讨响应式编程的使用,并提供使用 C#语言进行响应式编程的实际演示。我们将深入探讨响应式编程的原理和模型,并讨论IObservableIObserver提供程序。

库存应用程序将通过对变化的反应和讨论Model-View-ViewModelMVVM)模式来进行扩展。

本章将涵盖以下主题:

  • 响应式编程的原则

  • 响应式和 IObservable

  • 响应式扩展 - .NET Rx 扩展

  • 库存应用程序用例 - 使用过滤器、分页和排序获取库存

  • 模式和实践 - MVVM

技术要求

本章包含各种代码示例,以解释响应式编程的概念。代码保持简单,仅用于演示目的。大多数示例涉及使用 C#编写的.NET Core 控制台应用程序。

完整的源代码可在以下链接找到:github.com/PacktPublishing/Hands-On-Design-Patterns-with-C-and-.NET-Core/tree/master/Chapter10

运行和执行代码将需要以下内容:

  • Visual Studio 2019(也可以使用 Visual Studio 2017)

  • 设置.NET Core

  • SQL Server(本章中使用 Express Edition)

安装 Visual Studio

要运行代码示例,您需要安装 Visual Studio(首选 IDE)。要做到这一点,您可以按照以下说明进行操作:

  1. 从安装说明中提到的下载链接下载 Visual Studio 2017 或更高版本(2019):docs.microsoft.com/en-us/visualstudio/install/install-visual-studio

  2. 按照安装说明进行操作。

  3. Visual Studio 安装有多个选项可用。在这里,我们使用 Windows 的 Visual Studio。

设置.NET Core

如果您尚未安装.NET Core,则需要按照以下步骤进行操作:

  1. 下载 Windows 的.NET Core:www.microsoft.com/net/download/windows

  2. 对于多个版本和相关库,请访问dotnet.microsoft.com/download/dotnet-core/2.2

安装 SQL Server

如果您尚未安装 SQL Server,则可以按照以下说明进行操作:

  1. 从以下链接下载 SQL Server:www.microsoft.com/en-in/download/details.aspx?id=1695

  2. 您可以在此处找到安装说明:docs.microsoft.com/en-us/sql/ssms/download-sql-server-management-studio-ssms?view=sql-server-2017

有关故障排除和更多信息,请参考以下链接:www.blackbaud.com/files/support/infinityinstaller/content/installermaster/tkinstallsqlserver2008r2.htm

响应式编程的原则

如今,每个人都在谈论异步编程。各种应用程序都建立在使用异步编程的 RESTful 服务之上。术语异步与响应式编程相关。响应式编程关乎数据流,而响应式编程是围绕异步数据流构建的模型结构。响应式编程也被称为变化传播的艺术。让我们回到第八章中的例子,在.NET Core 中进行并发编程,我们当时正在讨论大型会议上的取票柜台。

除了三个取票柜台,我们还有一个名为计算柜台的柜台。这第四个柜台专注于计算收集,它计算从三个柜台中分发了多少张票。考虑以下图表:

在上图中,A+B+C 的总和是剩下三列的总和;即 1+1+1=3。总计列总是显示剩下三列的总和,它永远不会显示实际站在队列中等待领取票的人。总计列的值取决于剩下的列的数量。如果A 柜台中有两个人在队列中,那么总计列将是 2+1+1=4。你也可以把总计列称为计算列。这一列在其他行/列移动其计数(排队等候的人)时计算总和。如果我们要用 C#编写总计列,我们会选择计算属性,代码如下:public int TotalColumn { get { return ColumnA + ColumnB + ColumnC; } }

在上图中,数据从一列流向另一列。你可以把这看作是一个数据流。你可以为任何事物创建一个流,比如点击事件和悬停事件。任何东西都可以是一个流变量:用户输入、属性、缓存、数据结构等等。在流世界中,你可以监听流并做出相应的反应。

一系列事件被称为。流可以发出三种东西:一个值,一个错误和一个完成的信号。

你可以轻松地使用流进行工作:

  • 一个流可以作为另一个流的输入。

  • 多个流可以作为另一个流的输入。

  • 流可以合并。

  • 数据值可以从一个流映射到另一个流。

  • 流可以用你需要的数据/事件进行过滤。

要更近距离地了解流,看看下面代表流(事件序列)的图表:

上图是一个流(事件序列)的表示,其中我们有一到四个事件。任何这些事件都可以被触发,或者有人可以点击它们中的任何一个。这些事件可以用值来表示,这些值可以是字符串。X 符号表示在合并流或映射它们的数据过程中发生了错误。最后,|符号表示一个流(或一个操作)已经完成。

用响应式编程来实现响应式

显然,我们在前一节中讨论的计算属性不能是响应式的,也不能代表响应式编程。响应式编程具有特定的设计和技术。要体验响应式编程或成为响应式,你可以从reactivex.io/上获取文档,并通过阅读响应式宣言(www.reactivemanifesto.org/)来体验它.

简单来说,响应式属性是绑定属性,当事件触发时会做出反应。

如今,当我们处理各种大型系统/应用程序时,我们发现它们太大,无法一次处理。这些大型系统被分割或组成较小的系统。这些较小的单元/系统依赖于反应性属性。为了遵循反应式编程,反应式系统应用设计原则,使这些属性可以应用于所有方法。借助这种设计/方法,我们可以构建一个可组合的系统。

根据宣言,反应式编程和反应式系统是不同的。

根据反应式宣言,我们可以得出反应式系统如下:

  • 响应式:反应式系统是基于事件的设计系统;这些系统能够在短时间内快速响应任何请求。

  • 可扩展:反应式系统天生具有反应性。这些系统可以通过扩展或减少分配的资源来对可扩展性变化做出反应。

  • 弹性:弹性系统是指即使出现故障/异常也不会停止的系统。反应式系统设计成这样,以便在任何异常或故障中,系统都不会崩溃;它会继续工作。

  • 基于消息的:任何数据项都代表可以发送到特定目的地的消息。当消息或数据项到达给定状态时,事件会发出信号通知订阅者消息已到达。反应式系统依赖于这种消息传递。

下图显示了反应式系统的图形视图:

在这个图表中,反应式系统由具有弹性、可扩展、响应式和基于消息的小系统组成。

反应式流的操作

到目前为止,我们已经讨论了反应式编程是数据流的事实。在前面的部分中,我们还讨论了流的工作方式以及这些流如何及时传输。我们已经看到了事件的一个例子,并讨论了反应式程序中的数据流。现在,让我们继续使用相同的示例,看看两个流如何与各种操作一起工作。

在下一个示例中,我们有两个整数数据类型集合的可观察流。请注意,我们在本节中使用伪代码来解释这些数据流的行为和工作方式。

下图表示了两个可观察流。第一个流Observer1包含数字 1、2 和 4,而第二个流Observer2包含数字 3 和 5:

合并两个流涉及将它们的序列元素合并成一个新流。下图显示了当Observer1Observer2合并时产生的新流:

前面的图表只是流的表示,不是流中元素顺序的实际表示。在这个图表中,我们看到元素(数字)的顺序是 1、2、3、4、5,但在实际例子中并非如此。顺序可能会变化;它可以是 1、2、4、3、5,或者任何其他顺序。

过滤流就像跳过元素/记录一样。你可以想象 LINQ 中的Where子句,看起来像这样:myCollection.Where(num => num <= 3);

下图说明了标准的图形视图,我们试图仅选择符合特定标准的元素:

我们正在过滤我们的流,并只选择那些<=3的元素。这意味着我们跳过元素 4 和 5。在这种情况下,我们可以说过滤器是用来跳过元素或符合标准的。

要理解映射流,您可以想象任何数学运算,例如通过添加一些常数值来计数序列或递增数字。例如,如果我们有一个整数值为3,而我们的映射流是+3,那意味着我们正在计算一个序列,如3 + 3 = 6。您还可以将其与 LINQ 和选择以及像这样投影输出进行关联:return myCollection.Select(num => num+3);

以下图表表示了流的映射:

在应用条件为<= 3的过滤器后,我们的流具有元素123。此外,我们对过滤后的流应用了Map (+3),其中包含元素123,最后,我们的流具有元素456(1+3, 2+3, 3+3)。

在现实世界中,这些操作将按顺序或按需发生。我们已经按顺序执行了这些序列操作,以便我们可以按顺序应用合并、过滤和映射操作。以下图表表示我们想象中例子的流程:

因此,我们尝试通过图表来表示我们的例子,并且我们已经经历了各种操作,其中两个流相互交谈,我们得到了一个新的流,然后我们过滤和映射了这个流。

要更好地理解这一点,请参考rxmarbles.com/

现在让我们创建一个简单的代码来完成这个真实世界的例子。首先,我们将学习实现示例的代码,然后我们将讨论流的输出。

考虑以下代码片段作为IObservable接口的示例:

public static IObservable<T> From<T>(this T[] source) => source.ToObservable();

这段代码表示了T类型数组的扩展方法。我们创建了一个通用方法,并命名为From。这个方法返回一个Observable序列。

您可以访问官方文档了解更多关于扩展方法的信息:docs.microsoft.com/en-us/dotnet/csharp/programming-guide/classes-and-structs/extension-methods

在我们的代码中,我们有TicketCounter类。这个类有两个观察者,实际上是整数数据类型的数组。以下代码显示了两个可观察对象:

public IObservable<int> Observable1 => Counter1.From();
public IObservable<int> Observable2 => Counter2.From();

在这段代码中,我们将From()扩展方法应用于Counter1Counter2。这些计数器实际上代表我们的售票处,并回顾了我们在第八章中的例子,在.NET Core 中进行并发编程

以下代码片段表示Counter1Counter2

internal class TicketCounter
{
    private IObservable<int> _observable;
    public int[] Counter1;
    public int[] Counter2;
    public TicketCounter(int[] counter1, int[] counter2)
    {
        Counter1 = counter1;
        Counter2 = counter2;
    }
...
}

在这段代码中,我们有两个字段,Counter1Counter2,它们是从构造函数中初始化的。当初始化TicketCounter类时,这些字段从类的构造函数中获取值,如下面的代码所定义的:

TicketCounter ticketCounter = new TicketCounter(new int[]{1,3,4}, new int[]{2,5});

要理解完整的代码,请转到 Visual Studio 并按下F5执行代码。从这里,您将看到以下屏幕:

这是控制台输出,在这个控制台窗口中,用户被要求输入一个从09的逗号分隔数字。继续并在这里输入一个逗号分隔的数字。请注意,这里,我们试图创建一个代码,描述我们之前在本节中讨论的数据流表示的图表。

根据前面的图表,我们输入了两个不同的逗号分隔数字。第一个是1,2,4,第二个是3,5。现在考虑我们的Merge方法:

public IObservable<int> Merge() => _observable = Observable1.Merge(Observable2);

Merge方法将数据流的两个序列合并为_observableMerge操作是通过以下代码启动的:

Console.Write("\n\tEnter comma separated number (0-9): ");
var num1 = Console.ReadLine();
Console.Write("\tEnter comma separated number (0-9): ");
var num2 = Console.ReadLine();
var counter1 = num1.ToInts(',');
var counter2 = num2.ToInts(',');
TicketCounter ticketCounter = new TicketCounter(counter1, counter2);

在这段代码中,用户被提示输入逗号分隔的数字,然后程序通过ToInts方法将这些数字存储到counter1counter2中。以下是我们ToInts方法的代码:

public static int[] ToInts(this string commaseparatedStringofInt, char separator) =>
    Array.ConvertAll(commaseparatedStringofInt.Split(separator), int.Parse);

这段代码是string的扩展方法。目标变量是一个包含由separator分隔的整数的string类型。在这个方法中,我们使用了.NET Core 提供的内置ConvertAll方法。它首先分割字符串,并检查分割值是否为integer类型。然后返回整数的Array。这个方法产生的输出如下截图所示:

以下是我们merge操作的输出:

上述输出显示,我们现在有了一个最终合并的观察者流,其中包含了按顺序排列的元素。让我们对这个流应用一个筛选器。以下是我们的Filter方法的代码:

public IObservable<int> Filter() => _observable = from num in _observable
    where num <= 3
    select num;

我们有数字<= 3的筛选条件,这意味着我们只会选择值小于或等于3的元素。这个方法将以以下代码开始:

ticketCounter.Print(ticketCounter.Filter());

当执行上述代码时,会产生以下输出:

最后,我们得到了一个按顺序排列的筛选流,其中包含了元素 1,3,2。现在我们需要在这个流上进行映射。我们需要一个通过num + 3得到的映射元素,这意味着我们需要通过给这个数字加上3来输出一个整数。以下是我们的Map方法:

public IObservable<int> Map() => _observable = from num in _observable
    select num + 3;

上述方法将以以下代码初始化:

Console.Write("\n\tMap (+ 3):");
ticketCounter.Print(ticketCounter.Map());

执行上述方法后,我们将看到以下输出:

应用Map方法后,我们得到了一个按顺序排列的元素流 4,6,5。我们已经讨论了响应式如何与一个虚构的例子一起工作。我们创建了一个小的.NET Core 控制台应用程序,以查看MergeFilterMap操作对可观察对象的影响。以下是我们控制台应用程序的输出:

前面的快照讲述了我们示例应用程序的执行过程;Counter1Counter2是包含数据序列 1,2,4 和 3,5 的数据流。我们有了Merge的输出结果是1,3,2,5,4 Filter (<=3),结果是 1,3,2 和Map (+3)的数据是 4,6,5。

响应式和 IObservable

在前面的部分,我们讨论了响应式编程并了解了它的模型。在这一部分,我们将讨论微软对响应式编程的实现。针对.NET Core 中的响应式编程,我们有各种接口,提供了在我们的应用程序中实现响应式编程的方法。

IObservable<T>是一个泛型接口,定义在System命名空间中,声明为public interface IObservable<out T>。在这里,T代表提供通知信息的泛型参数类型。简单来说,这个接口帮助我们定义了一个通知的提供者,这些通知可以被推送出去。在你的应用程序中实现IObservable<T>接口时,可以使用观察者模式。

观察者模式 - 使用 IObservable进行实现

简单来说,订阅者注册到提供者,以便订阅者可以得到与消息信息相关的通知。这些通知通知提供者消息已经被传递给订阅者。这些信息也可能与操作的变化或方法或对象本身的任何其他变化相关。这也被称为状态变化

观察者模式指定了两个术语:观察者和可观察对象。可观察对象也称为提供者或主题。观察者注册在Observable/Subject/Provider类型上,并且当由于预定义的标准/条件、更改或事件等发生任何变化时,提供者会自动通知观察者。

下面的图表是观察者模式的简单表示,其中主题通知了两个不同的观察者:

从第九章的FlixOne库存 Web 应用程序返回,功能编程实践,启动你的 Visual Studio,并打开FlixOne.sln解决方案。

打开解决方案资源管理器。从这里,你会看到我们的项目看起来类似于以下快照:

在解决方案资源管理器下展开Common文件夹,并添加两个文件:ProductRecorder.csProductReporter.cs。这些文件是IObservable<T>IObserver<T>接口的实现。我们还需要添加一个新的 ViewModel,以便向用户报告实际的消息。为此,展开Models文件夹并添加MessageViewModel.cs文件。

以下代码展示了我们的MessageViewModel类:

public class MessageViewModel
{
    public string MsgId { get; set; }
    public bool IsSuccess { get; set; }
    public string Message { get; set; }

    public override string ToString() => $"Id:{MsgId}, Success:{IsSuccess}, Message:{Message}";
}

MessageViewModel包含以下内容:

  • MsgId:唯一标识符

  • IsSuccess:显示操作是失败还是成功。

  • Message:根据IsSuccess的值而定的成功消息或错误消息

  • ToString():一个重写方法,在连接所有信息后返回一个字符串

现在让我们讨论我们的两个类;以下代码来自ProductRecorder类:

public class ProductRecorder : IObservable<Product>
{
    private readonly List<IObserver<Product>> _observers;

    public ProductRecorder() => _observers = new List<IObserver<Product>>();

    public IDisposable Subscribe(IObserver<Product> observer)
    {
        if (!_observers.Contains(observer))
            _observers.Add(observer);
        return new Unsubscriber(_observers, observer);
    }
...
}

我们的ProductRecorder类实现了IObservable<Product>接口。如果你回忆一下我们关于观察者模式的讨论,你会知道这个类实际上是一个提供者、主题或可观察对象。IObservable<T>接口有一个Subscribe方法,我们需要用它来订阅我们的订阅者或观察者(我们将在本节后面讨论观察者)。

应该有一个标准或条件,以便订阅者可以收到通知。在我们的情况下,我们有一个Record方法来实现这个目的。考虑以下代码:

public void Record(Product product)
{
    var discountRate = product.Discount.FirstOrDefault(x => x.ProductId == product.Id)?.DiscountRate;
    foreach (var observer in _observers)
    {
        if (discountRate == 0 || discountRate - 100 <= 1)
            observer.OnError(
                new Exception($"Product:{product.Name} has invalid discount rate {discountRate}"));
        else
            observer.OnNext(product);
    }
}

前面是一个Record方法。我们创建这个方法来展示模式的强大之处。这个方法只是检查有效的折扣率。如果根据标准/条件,折扣率无效,这个方法将引发异常并与无效的折扣率一起分享产品名称。

前面的方法根据标准验证折扣率,并在标准失败时向订阅者发送关于引发异常的通知。看一下迭代块(foreach循环)并想象一种情况,我们没有任何东西可以迭代,所有订阅者都已经收到通知。我们能想象在这种情况下会发生什么吗?同样的情况可能会发生在无限循环中。为了阻止这种情况,我们需要一些终止循环的东西。为此,我们有以下的EndRecording方法:

public void EndRecording()
{
    foreach (var observer in _observers.ToArray())
        if (_observers.Contains(observer))
            observer.OnCompleted();
    _observers.Clear();
}

我们的EndRecoding方法正在循环遍历_observers集合,并显式触发OnCompleted()方法。最后,它清除了_observers集合。

现在,让我们讨论ProductReporter类。这个类是IObserver<T>接口实现的一个例子。考虑以下代码:

public void OnCompleted()
{
    PrepReportData(true, $"Report has completed: {Name}");
    Unsubscribe();
}

public void OnError(Exception error) => PrepReportData(false, $"Error ocurred with instance: {Name}");

public void OnNext(Product value)
{
    var msg =
        $"Reporter:{Name}. Product - Name: {value.Name}, Price:{value.Price},Desc: {value.Description}";
    PrepReportData(true, msg);
}

IObserver<T>接口有OnCompleteOnErrorOnNext方法,我们需要在ProductReporter类中实现这些方法。OnComplete方法的目的是通知订阅者工作已经完成,然后清除代码。此外,OnError在执行过程中发生错误时被调用,而OnNext提供了流序列中下一个元素的信息。

在以下代码中,PrepReportData是一个增值,它为用户提供了有关过程的所有操作的格式化报告:

private void PrepReportData(bool isSuccess, string message)
{
    var model = new MessageViewModel
    {
        MsgId = Guid.NewGuid().ToString(),
        IsSuccess = isSuccess,
        Message = message
    };

    Reporter.Add(model);
}

上述方法只是向我们的Reporter集合添加了一些内容,这是MessageViewModel类的集合。请注意,出于简单起见,您还可以使用我们在MessageViewModel类中实现的ToString()方法。

以下代码片段显示了SubcribeUnsubscribe方法:

public virtual void Subscribe(IObservable<Product> provider)
{
    if (provider != null)
        _unsubscriber = provider.Subscribe(this);
}

private void Unsubscribe() => _unsubscriber.Dispose();

前两种方法告诉系统有一个提供者。订阅者可以订阅该提供者,或在操作完成后取消订阅/处理它。

现在是展示我们的实现并看到一些好结果的时候了。为此,我们需要对现有的Product Listing页面进行一些更改,并向项目添加一个新的 View 页面。

在我们的Index.cshtml页面中添加以下链接,以便我们可以看到查看审计报告的新链接:

<a asp-action="Report">Audit Report</a>

在上述代码片段中,我们添加了一个新链接,以显示基于我们在ProductConstroller类中定义的Report Action方法的审计报告。

添加此代码后,我们的产品列表页面将如下所示:

首先,让我们讨论Report action方法。为此,请考虑以下代码:

var mango = _repositry.GetProduct(new Guid("09C2599E-652A-4807-A0F8-390A146F459B"));
var apple = _repositry.GetProduct(new Guid("7AF8C5C2-FA98-42A0-B4E0-6D6A22FC3D52"));
var orange = _repositry.GetProduct(new Guid("E2A8D6B3-A1F9-46DD-90BD-7F797E5C3986"));
var model = new List<MessageViewModel>();
//provider
ProductRecorder productProvider = new ProductRecorder();
//observer1
ProductReporter productObserver1 = new ProductReporter(nameof(mango));
//observer2
ProductReporter productObserver2 = new ProductReporter(nameof(apple));
//observer3
ProductReporter productObserver3 = new ProductReporter(nameof(orange));

在上述代码中,我们只取前三个产品进行演示。请注意,您可以根据自己的实现修改代码。在代码中,我们创建了一个productProvider类和三个观察者来订阅我们的productProvider类。

以下图表是我们讨论过的IObservable<T>IObserver<T>接口的所有活动的图形视图:

以下代码用于订阅productrovider

//subscribe
productObserver1.Subscribe(productProvider);
productObserver2.Subscribe(productProvider);
productObserver3.Subscribe(productProvider);

最后,我们需要记录报告,然后取消订阅:

//Report and Unsubscribe
productProvider.Record(mango);
model.AddRange(productObserver1.Reporter);
productObserver1.Unsubscribe();
productProvider.Record(apple);
model.AddRange(productObserver2.Reporter);
productObserver2.Unsubscribe();
productProvider.Record(orange);
model.AddRange(productObserver3.Reporter);
productObserver3.Unsubscribe();

让我们回到我们的屏幕,并将Report.cshtml文件添加到 Views | Product。以下代码是我们报告页面的一部分。您可以在Product文件夹中找到完整的代码:

@model IEnumerable<MessageViewModel>

    <thead>
    <tr>
        <th>
            @Html.DisplayNameFor(model => model.IsSuccess)
        </th>
        <th>
            @Html.DisplayNameFor(model => model.Message)
        </th>
    </tr>
    </thead>

此代码将为表格的列创建标题,显示审计报告。

以下代码将完成表格并向IsSuccessMessage列添加值:

    <tbody>
    @foreach (var item in Model)
    {
        <tr>
            <td>
                @Html.HiddenFor(modelItem => item.MsgId)
                @Html.DisplayFor(modelItem => item.IsSuccess)
            </td>
            <td>
                @Html.DisplayFor(modelItem => item.Message)
            </td>

        </tr>
    }
    </tbody>
</table>

在这一点上,我们已经使用IObservable<T>IObserver<T>接口实现了观察者模式。在 Visual Studio 中按下F5运行项目,在主页上点击 Product,然后点击审计报告链接。从这里,您将看到我们选择的产品的审计报告,如下图所示:

上述屏幕截图显示了一个简单的列表页面,显示了来自MessageViewModel类的数据。您可以根据需要进行更改和修改。一般来说,审计报告来自我们在上述屏幕中看到的许多操作活动。您还可以将审计数据保存在数据库中,然后根据需要为不同目的提供这些数据,例如向管理员报告等。

响应式扩展 - .NET Rx 扩展

上一节讨论的是响应式编程以及使用IObservable<T>IObserver<T>接口作为观察者模式实现响应式编程。在本节中,我们将借助Rx 扩展扩展我们的学习。如果您想了解有关 Rx 扩展开发的更多信息,可以关注官方存储库github.com/dotnet/reactive

请注意,Rx 扩展现在已与System命名空间合并,您可以在System.Reactive命名空间中找到所有内容。如果您有 Rx 扩展的经验,您应该知道这些扩展的命名空间已更改,如下所示:

  • Rx.Main已更改为System.Reactive

  • Rx.Core已更改为System.Reactive.Core

  • Rx.Interfaces已更改为System.Reactive.Interfaces

  • Rx.Linq已更改为System.Reactive.Linq

  • Rx.PlatformServices已更改为System.Reactive.PlatformServices

  • Rx.Testing已更改为Microsoft.Reactive.Testing

要启动 Visual Studio,请打开在上一节中讨论的SimplyReactive项目,并打开 NuGet 包管理器。点击浏览,输入搜索词System.Reactive。从这里,您将看到以下结果:

本节的目的是让您了解响应式扩展,而不深入其内部开发。这些扩展受 Apache2.0 许可证管辖,并由.NET 基金会维护。我们已经在我们的SimplyReactive应用程序中实现了响应式扩展。

库存应用用例

在本节中,我们将继续讨论我们的 FlixOne 库存应用程序。在本节中,我们将讨论 Web 应用程序模式,并扩展我们在第四章中开发的 Web 应用程序,实现设计模式-基础知识第二部分

本章继续讨论了上一章中讨论的 Web 应用程序。如果您跳过了上一章(第九章,函数式编程实践),请重新阅读以便跟上当前章节。

在本节中,我们将介绍需求收集的过程,然后讨论我们之前开发的 Web 应用程序的开发和业务的各种挑战。

启动项目

在第七章,为 Web 应用程序实现设计模式-第二部分中,我们为 FlixOne 库存 Web 应用程序添加了功能。在考虑以下几点后,我们扩展了应用程序:

  • 业务需要一个丰富的用户界面。

  • 新的机会需要一个响应式 Web 应用程序。

需求

经过几次会议和与管理层、业务分析师BA)和售前人员的讨论后,组织的管理层决定处理以下高层需求。

业务需求

我们的业务团队列出了以下要求:

  • 项目过滤:目前,用户无法按类别筛选项目。为了扩展列表视图功能,用户应该能够根据其各自的类别筛选产品项目。

  • 项目排序:目前,项目按照它们添加到数据库的顺序显示。没有机制可以让用户根据项目的名称、价格等对项目进行排序。

FlixOne 库存管理 Web 应用程序是一个虚构的产品。我们正在创建此应用程序来讨论 Web 项目中所需/使用的各种设计模式。

使用过滤器、分页和排序获取库存

根据我们的业务需求,我们需要对我们的 FlixOne 库存应用程序应用过滤、分页和排序。首先,让我们开始实现排序。为此,我创建了一个项目并将该项目放在FlixOneWebExtended文件夹中。启动 Visual Studio 并打开 FlixOne 解决方案。我们将对我们的产品清单表应用排序,包括这些列:类别产品名称描述价格。请注意,我们不会使用任何外部组件进行排序,而是将创建我们自己的登录。

打开“解决方案资源管理器”,并打开ProductController,该文件位于Controllers文件夹中。向Index方法添加[FromQuery]Sort sort参数。请注意,[FromQuery]属性表示此参数是一个查询参数。我们将使用此参数来维护我们的排序顺序。

以下代码显示了Sort类:

public class Sort
{
    public SortOrder Order { get; set; } = SortOrder.A;
    public string ColName { get; set; }
    public ColumnType ColType { get; set; } = ColumnType.Text;
}

Sort类包含以下三个公共属性:

  • Order:表示排序顺序。SortOrder是一个枚举,定义为public enum SortOrder { D, A, N }

  • ColName:表示列名。

  • ColType:表示列的类型;ColumnType是一个枚举,定义为public enum ColumnType { Text, Date, Number }

打开IInventoryRepositry接口,并添加IEnumerable<Product> GetProducts(Sort sort)方法。此方法负责对结果进行排序。请注意,我们将使用 LINQ 查询来应用排序。实现这个InventoryRepository类的方法,并添加以下代码:

public IEnumerable<Product> GetProducts(Sort sort)
{
    if(sort.ColName == null)
        sort.ColName = "";
    switch (sort.ColName.ToLower())
    {
        case "categoryname":
        {
            var products = sort.Order == SortOrder.A
                ? ListProducts().OrderBy(x => x.Category.Name)
                : ListProducts().OrderByDescending(x => x.Category.Name);
            return PDiscounts(products);

        }

以下代码处理了sort.ColNameproductname的情况:


       case "productname":
        {
            var products = sort.Order == SortOrder.A
                ? ListProducts().OrderBy(x => x.Name)
                : ListProducts().OrderByDescending(x => x.Name);
            return PDiscounts(products);
        }

以下代码处理了sort.ColNameproductprice的情况:


        case "productprice":
        {
            var products = sort.Order == SortOrder.A
                ? ListProducts().OrderBy(x => x.Price)
                : ListProducts().OrderByDescending(x => x.Price);
            return PDiscounts(products);
        }
        default:
            return PDiscounts(ListProducts().OrderBy(x => x.Name));
    }
}

在上面的代码中,如果sort参数包含空值,则将其值设置为空,并使用switch..casesort.ColName.ToLower()中进行处理。

以下是我们的ListProducts()方法,它给我们IIncludeIQuerable<Product,Category>类型的结果:

private IIncludableQueryable<Product, Category> ListProducts() =>
    _inventoryContext.Products.Include(c => c.Category);

上面的代码简单地通过包含每个产品的Categories来给我们Products。排序顺序将来自我们的用户,因此我们需要修改我们的Index.cshtml页面。我们还需要在表的标题列中添加一个锚标记。为此,请考虑以下代码:

 <thead>
        <tr>
            <th>
                @Html.ActionLink(Html.DisplayNameFor(model => model.CategoryName), "Index", new Sort { ColName = "CategoryName", ColType = ColumnType.Text, Order = SortOrder.A })
            </th>
            <th>
                @Html.ActionLink(Html.DisplayNameFor(model => model.ProductName), "Index", new Sort { ColName = "ProductName", ColType = ColumnType.Text, Order = SortOrder.A })

            </th>
            <th>
                @Html.ActionLink(Html.DisplayNameFor(model => model.ProductDescription), "Index", new Sort { ColName = "ProductDescription", ColType = ColumnType.Text, Order = SortOrder.A })
            </th>
        </tr>
    </thead>

上面的代码显示了表的标题列;new Sort { ColName = "ProductName", ColType = ColumnType.Text, Order = SortOrder.A } 是我们实现SorOrder的主要方式。

运行应用程序,您将看到产品列表页面的以下快照,其中包含排序功能:

现在,打开Index.cshtml页面,并将以下代码添加到页面中:

@using (Html.BeginForm())
{
    <p>
        Search by: @Html.TextBox("searchTerm")
        <input type="submit" value="Search" class="btn-sm btn-success" />
    </p>
}

在上面的代码中,我们在Form下添加了一个文本框。在这里,用户输入数据/值,并且当用户点击提交按钮时,这些数据会立即提交到服务器。在服务器端,过滤后的数据将返回并显示产品列表。在实现上述代码之后,我们的产品列表页面将如下所示:

转到ProductController中的Index方法并更改参数。现在Index方法看起来像这样:

public IActionResult Index([FromQuery]Sort sort, string searchTerm)
{
    var products = _repositry.GetProducts(sort, searchTerm);
    return View(products.ToProductvm());
}

同样,我们需要更新InventoryRepositoryInventoryRepositoryGetProducts()方法的参数。以下是InventoryRepository类的代码:

private IEnumerable<Product> ListProducts(string searchTerm = "")
{
    var includableQueryable = _inventoryContext.Products.Include(c => c.Category).ToList();
    if (!string.IsNullOrEmpty(searchTerm))
    {
        includableQueryable = includableQueryable.Where(x =>
            x.Name.Contains(searchTerm) || x.Description.Contains(searchTerm) ||
            x.Category.Name.Contains(searchTerm)).ToList();
    }

    return includableQueryable;
}

现在通过从 Visual Studio 按下F5并导航到产品列表中的过滤/搜索选项来运行项目。为此,请参阅此快照:

输入搜索词后,单击搜索按钮,这将给您结果,如下快照所示:

在上述产品列表截图中,我们正在使用searchTerm mango过滤我们的产品记录,并且它产生了单个结果,如前面的快照所示。在搜索数据的这种方法中存在一个问题:将fruit作为搜索词添加,然后看看会发生什么。它将产生零结果。这在以下快照中得到了证明:

我们没有得到任何结果,这意味着当我们将searchTerm转换为小写时,我们的搜索不起作用。这意味着我们的搜索是区分大小写的。我们需要更改我们的代码以使其起作用。

这是我们修改后的代码:

var includableQueryable = _inventoryContext.Products.Include(c => c.Category).ToList();
if (!string.IsNullOrEmpty(searchTerm))
{
    includableQueryable = includableQueryable.Where(x =>
        x.Name.Contains(searchTerm, StringComparison.InvariantCultureIgnoreCase) ||
        x.Description.Contains(searchTerm, StringComparison.InvariantCultureIgnoreCase) ||
        x.Category.Name.Contains(searchTerm, StringComparison.InvariantCultureIgnoreCase)).ToList();
}

我们忽略大小写以使我们的搜索不区分大小写。我们使用了StringComparison.InvariantCultureIgnoreCase并忽略了大小写。现在我们的搜索将使用大写或小写字母。以下是使用小写fruit产生结果的快照:

在之前的 FlixOne 应用程序扩展讨论中,我们应用了SortFilter;现在我们需要添加paging。为此,我们添加了一个名为PagedList的新类,如下所示:

public class PagedList<T> : List<T>
{
    public PagedList(List<T> list, int totalRecords, int currentPage, int recordPerPage)
    {
        CurrentPage = currentPage;
        TotalPages = (int) Math.Ceiling(totalRecords / (double) recordPerPage);

        AddRange(list);
    }
}

现在,将ProductControllerIndex方法的参数更改如下:

public IActionResult Index([FromQuery] Sort sort, string searchTerm, 
    string currentSearchTerm,
    int? pagenumber,
    int? pagesize)

将以下代码添加到Index.cshtml页面:

@{
    var prevDisabled = !Model.HasPreviousPage ? "disabled" : "";
    var nextDisabled = !Model.HasNextPage ? "disabled" : "";
}

<a asp-action="Index"
   asp-route-sortOrder="@ViewData["CurrentSort"]"
   asp-route-pageNumber="@(Model.CurrentPage - 1)"
   asp-route-currentFilter="@ViewData["currentSearchTerm"]"
   class="btn btn-sm btn-success @prevDisabled">
    Previous
</a>
<a asp-action="Index"
   asp-route-sortOrder="@ViewData["CurrentSort"]"
   asp-route-pageNumber="@(Model.CurrentPage + 1)"
   asp-route-currentFilter="@ViewData["currentSearchTerm"]"
   class="btn btn-sm btn-success @nextDisabled">
    Next
</a>

前面的代码使我们能够将屏幕移动到下一页或上一页。我们的最终屏幕将如下所示:

在本节中,我们讨论并扩展了我们的 FlixOne 应用程序的功能,通过实现SortingPagingFilter。本节的目的是让您亲身体验一个工作中的应用程序。我们已经编写了我们的应用程序,以便它可以直接满足实际应用程序的需求。通过前面的增强,我们的应用程序现在能够提供可以排序、分页和过滤的产品列表。

模式和实践-MVVM

在第六章中,为 Web 应用程序实现设计模式-第一部分,我们讨论了 MVC 模式,并创建了一个基于此模式的应用程序。

肯·库珀(Ken Cooper)和泰德·彼得斯(Ted Peters)是 MVVM 模式背后的名字。在这一发明时,肯和泰德都是微软公司的架构师。他们制定了这一模式,以简化基于事件驱动的编程的用户界面。后来,它被实现在 Windows Presentation Foundation(WPF)和 Silverlight 中。

MVVM 模式是由 John Gossman 于 2005 年宣布的。John 在博客中讨论了这一模式,与构建 WPF 应用程序有关。链接在这里:blogs.msdn.microsoft.com/johngossman/2005/10/08/introduction-to-modelviewviewmodel-pattern-for-building-wpf-apps/

MVVM 被认为是 MVC 的变体之一,以满足现代用户界面(UI)开发方法,其中 UI 开发是设计师/UI 开发人员的核心责任,而不是应用程序开发人员。在这种开发方法中,一个专注于使 UI 更具吸引力的图形爱好者的设计师可能会或可能不会关心应用程序的开发部分。通常,设计师(UI 人员)使用各种工具来使 UI 更具吸引力。UI 可以使用简单的 HTML、CSS 等,使用 WPF 或 Silverlight 的丰富控件来制作。

Microsoft Silverlight 是一个帮助开发具有丰富用户界面的应用程序的框架。许多开发人员将其称为 Adobe Flash 的替代品。2015 年 7 月,微软宣布不再支持 Silverlight。微软宣布在其构建期间支持.NET Core 3.0 中的 WPF(developer.microsoft.com/en-us/events/build)。这里还有一个关于支持 WPF 计划更多见解的博客:devblogs.microsoft.com/dotnet/net-core-3-and-support-for-windows-desktop-applications/

MVVM 模式可以通过其各个组件进行详细说明,如下所示:

  • Model:保存数据,不关心应用程序中的任何业务逻辑。我更喜欢将其称为领域对象,因为它保存了我们正在处理的应用程序的实际数据。换句话说,我们可以说模型不负责使数据变得美观。例如,在我们的 FlixOne 应用程序的产品模型中,产品模型保存各种属性的值,并通过名称、描述、类别名称、价格等描述产品。这些属性包含产品的实际数据,但模型不负责对任何数据进行行为更改。例如,产品模型不负责将产品描述格式化为在 UI 上看起来完美。另一方面,我们的许多模型包含验证和其他计算属性。主要挑战是保持纯净的模型,这意味着模型应该类似于真实世界的模型。在我们的情况下,我们的product模型被称为clean model。干净的模型是类似于真实产品属性的模型。例如,如果Product模型存储水果的数据,那么它应该显示水果的颜色等属性。以下代码来自我们虚构应用程序的一个模型:
export class Product {
  name: string;
  cat: string; 
  desc: string;
}

请注意,上述代码是用 Angular 编写的。我们将在接下来的实现 MVVM部分详细讨论 Angular 代码。

  • View:这是最终用户通过 UI 访问的数据表示。它只是显示数据的值,这个值可能已经格式化,也可能没有。例如,我们可以在 UI 上显示折扣率为 18%,而在模型中它可能存储为 18.00。视图还可以负责行为变化。视图接受用户输入;例如,可能会有一个提供添加新产品的表单/屏幕的视图。此外,视图可以管理用户输入,比如按键、检测关键字等。它也可以是主动视图或被动视图。接受用户输入并根据用户输入操纵数据模型(属性)的视图是主动视图。被动视图是什么都不做的视图。换句话说,与模型无关的视图是被动视图,这种视图由控制器操纵。

  • ViewModel:它在 View 和 Model 之间充当中间人。它的责任是使呈现更好。在我们之前的例子中,View 显示折扣率为 18%,但 Model 的折扣率为 18.00,这是 ViewModel 的责任,将 18.00 格式化为 18%,以便 View 可以显示格式化的折扣率。

如果我们结合讨论的所有要点,我们可以将整个 MVVM 模式可视化,看起来像下面的图表:

上述图表是 MVVM 的图形视图,它向我们展示了View Model如何将ViewModel分开。ViewModel还维护stateperform操作。这有助于View向最终用户呈现最终输出。视图是 UI,它获取数据并将其呈现给最终用户。在下一节中,我们将使用 Angular 实现 MVVM 模式。

MVVM 的实现

在上一节中,我们了解了 MVVM 模式是什么以及它是如何工作的。在本节中,我们将使用我们的 FlixOne 应用程序并使用 Angular 构建一个应用程序。为了演示 MVVM 模式,我们将使用基于 ASP.NET Core 2.2 构建的 API。

启动 Visual Studio 并打开FlixOneMVVM文件夹中的 FlixOne Solution。运行FlixOne.API项目,您将看到以下 Swagger 文档页面:

上述截图是我们的产品 API 文档的快照,我们已经整合了 Swagger 来进行 API 文档编制。如果您愿意,您可以从此屏幕测试 API。如果 API 返回结果,则您的项目已成功设置。如果没有,请检查此项目的先决条件,并检查本章的 Git 存储库中的README.md文件。我们拥有构建新 UI 所需的一切;正如之前讨论的,我们将创建一个 Angular 应用程序,该应用程序将使用我们的产品 API。要开始,请按照以下步骤进行:

  1. 打开解决方案资源管理器。

  2. 右键单击 FlixOne Solution。

  3. 点击添加新项目。

  4. 添加新项目窗口中,选择 ASP.NET Core Web 应用程序。将其命名为 FlixOne.Web,然后单击确定。这样做后,请参考此截图:

  1. 从下一个窗口中,选择 Angular,确保您已选择了 ASP.NET Core 2.2,然后单击确定,并参考此截图:

  1. 打开解决方案资源管理器,您将找到新的FlixOne.Web项目和文件夹层次结构,看起来像这样:

  1. 从解决方案资源管理器中,右键单击FlixOne.Web项目,然后单击设置为启动项目,然后参考以下截图:

  1. 运行FlixOne.Web项目并查看输出,将看起来像以下截图:

我们已成功设置了我们的 Angular 应用程序。返回到您的 Visual Studio 并打开输出窗口。请参考以下截图:

您将在输出窗口中找到ng serve "--port" "60672";这是一个命令,告诉 Angular 应用程序监听和提供服务。从解决方案资源管理器中打开package.json文件;这个文件属于ClientApp文件夹。您会注意到"@angular/core": "6.1.10",这意味着我们的应用是基于angular6构建的。

以下是我们的product.component.html的代码(这是一个视图):

<table class='table table-striped' *ngIf="forecasts">
  <thead>
    <tr>
      <th>Name</th>
      <th>Cat. Name (C)</th>
      <th>Price(F)</th>
      <th>Desc</th>
    </tr>
  </thead>
  <tbody>
    <tr *ngFor="let forecast of forecasts">
      <td>{{ forecast.productName }}</td>
      <td>{{ forecast.categoryName }}</td>
      <td>{{ forecast.productPrice }}</td>
      <td>{{ forecast.productDescription }}</td>
    </tr>
  </tbody>
</table>

从 Visual Studio 运行应用程序,并单击产品,您将获得一个类似于此的产品列表屏幕:

在本节中,我们在 Angular 中创建了一个小型演示应用程序。

总结

本章的目的是通过讨论其原则和反应式编程模型来使您了解反应式编程。反应式是关于数据流的,我们通过示例进行了讨论。我们从第八章扩展了我们的示例,在.NET Core 中进行并发编程,在那里我们讨论了会议上的票务收集柜台的用例。

在我们讨论反应式宣言时,我们探讨了反应式系统。我们通过展示mergefiltermap操作以及流如何通过示例工作来讨论了反应式系统。此外,我们使用示例讨论了IObservable接口和 Rx 扩展。

我们继续进行了FlixOne库存应用程序,并讨论了实现产品库存数据的分页和排序的用例。最后,我们讨论了 MVVM 模式,并在 MVVM 架构上创建了一个小应用程序。

在下一章(第十一章,高级数据库设计和应用技术)中,将探讨高级数据库和应用技术,包括应用命令查询职责分离CQRS)和分类账式数据库。

问题

以下问题将帮助您巩固本章中包含的信息:

  1. 什么是流?

  2. 什么是反应式属性?

  3. 什么是反应式系统?

  4. 什么是合并两个反应式流?

  5. 什么是 MVVM 模式?

进一步阅读

要了解本章涵盖的主题,请参考以下书籍。本书将为您提供各种深入和实践性的响应式编程练习:

第十一章:高级数据库设计和应用技术

在上一章中,我们通过讨论其原则和模型来了解了响应式编程。我们还讨论并查看了响应式编程如何处理数据流的示例。

数据库设计是一项复杂的任务,需要很多耐心。在本章中,我们将讨论高级数据库和应用技术,包括应用 CQRS 和分类账式数据库。

与以前的章节类似,将进行需求收集会话,以确定最小可行产品(MVP)。在本章中,将使用几个因素来引导设计到 CQRS。我们将使用分类账式方法,其中包括增加对库存水平变化的跟踪,以及希望提供用于检索库存水平的公共 API。本章将介绍开发人员为什么使用分类账式数据库以及为什么我们应该专注于 CQRS 实现。在本章中,我们将看到为什么我们要采用 CQRS 模式。

本章将涵盖以下主题:

  • 用例讨论

  • 数据库讨论

  • 库存的分类账式数据库

  • 实施 CQRS 模式

技术要求

本章包含各种代码示例,以解释概念。代码保持简单,仅用于演示目的。大多数示例涉及使用 C#编写的.NET Core 控制台应用程序。

要运行和执行代码,Visual Studio 2019 是必需的(您也可以使用 Visual Studio 2017 来运行应用程序)。

安装 Visual Studio

要运行这些代码示例,您需要安装 Visual Studio(首选 IDE)。要做到这一点,请按照以下说明进行操作:

  1. 从以下下载链接下载 Visual Studio 2017(或 2019 版本):docs.microsoft.com/en-us/visualstudio/install/install-visual-studio

  2. 按照上述链接可访问的安装说明。可用多种选项进行 Visual Studio 安装。在这里,我们使用 Visual Studio for Windows。

设置.NET Core

如果您尚未安装.NET Core,则需要按照以下说明进行操作:

  1. 下载.NET Core for Windows:www.microsoft.com/net/download/windows

  2. 对于多个版本和相关库,请访问dotnet.microsoft.com/download/dotnet-core/2.2

安装 SQL Server

如果您尚未安装 SQL Server,则需要按照以下说明进行操作:

  1. 从以下链接下载 SQL Server:www.microsoft.com/en-in/download/details.aspx?id=1695

  2. 您可以在docs.microsoft.com/en-us/sql/ssms/download-sql-server-management-studio-ssms?view=sql-server-2017找到安装说明。

有关故障排除和更多信息,请参阅以下链接:www.blackbaud.com/files/support/infinityinstaller/content/installermaster/tkinstallsqlserver2008r2.htm

用例讨论

在本章中,我们将继续使用我们的 FlixOne 库存应用程序。在本章中,我们将讨论 CQRS 模式,并扩展我们在以前章节中开发的 Web 应用程序。

本章将继续讨论上一章中开发的 Web 应用程序。如果您跳过了上一章,请重新阅读以帮助您理解当前章节。

在本节中,我们将通过需求收集过程,然后讨论我们的 Web 应用程序的各种挑战。

项目启动

在第七章中,为 Web 应用程序实现设计模式-第二部分,我们扩展了 FlixOne 库存,并为 Web 应用程序添加了身份验证和授权。我们在考虑以下几点后扩展了应用程序:

  • 当前应用程序对所有用户开放;因此,任何用户都可以访问任何页面,甚至是受限制的页面。

  • 用户不应该访问需要访问或特殊访问权限的页面;这些页面也被称为受限制的页面或有限访问权限的页面。

  • 用户应能够根据其角色访问页面/资源。

在第十章中,响应式编程模式和技术,我们进一步扩展了我们的 FlixOne 库存应用程序,并为显示列表的所有页面添加了分页、过滤和排序。在扩展应用程序时考虑了以下几点:

  • 项目过滤:目前,用户无法按照它们的类别对项目进行过滤。为了扩展此功能,用户应能够根据类别对产品项目进行过滤。

  • 项目排序:目前,项目按照它们被添加到数据库的顺序出现。没有任何机制可以使用户根据类别(如项目名称或价格)对项目进行排序。

需求

经过多次会议和与管理层、业务分析师BA)和售前人员的讨论后,管理层决定着手处理以下高层要求:业务需求和技术需求。

业务需求

根据与利益相关者和最终用户的讨论,以及市场调查的结果,我们的业务团队列出了以下要求:

  • 产品扩展:产品正在接触到不同的用户。现在是扩展应用程序的好时机。扩展后的应用程序将更加强大。

  • 产品模型:作为库存管理应用程序,用户应该感到自由(这意味着在模型级别没有限制,没有复杂的验证),并且在用户与应用程序交互时不应有任何限制。每个屏幕和页面都应该是自解释的。

  • 数据库设计:应用程序的数据库应设计成扩展不需要花费太多时间的方式。

技术要求

满足业务需求的实际要求现已准备好进行开发。经过与业务人员的多次讨论后,我们得出以下要求:

  • 以下是首页主页的要求:

  • 应该有包含各种小部件的仪表板

  • 应该显示商店的一览图片

  • 以下是产品页面的要求:

  • 应具有添加、更新和删除产品的功能

  • 应具有添加、更新和删除产品类别的功能

FlixOne 库存管理 Web 应用程序是一个虚构的产品。我们正在创建此应用程序来讨论 Web 项目中所需/使用的各种设计模式。

挑战

尽管我们扩展了现有的 Web 应用程序,但对开发者和企业都存在各种挑战。在本节中,我们将讨论这些挑战,然后找出克服这些挑战的解决方案。

开发者面临的挑战

以下是由应用程序的重大变化引起的挑战。这也是将控制台应用程序升级为 Web 应用程序的主要扩展的结果:

  • 不支持 RESTful 服务:目前,没有支持 RESTful 服务,因为没有开发 API。

  • 有限的安全性:在当前应用程序中,只有一种机制可以限制/允许用户访问特定屏幕或应用程序模块:即登录。

企业面临的挑战

在我们采用新技术栈时,会出现以下挑战,并且代码会发生许多变化。因此,要实现最终输出需要时间,这延迟了产品,导致业务损失:

  • 客户流失:在这里,我们仍处于开发阶段,但对我们业务的需求非常高。然而,开发团队花费的时间比预期的要长,以交付产品。

  • 发布生产更新需要更多时间:目前开发工作非常耗时,这延迟了后续活动,导致生产延迟。

提供解决问题/挑战的解决方案

经过几次会议和头脑风暴,开发团队得出结论,我们必须稳定我们的基于 Web 的解决方案。为了克服这些挑战并提供解决方案,技术团队和业务团队汇聚在一起,确定了各种解决方案和要点。

以下是解决方案支持的要点:

  • 发展 RESTful web 服务——应该有一个 API 仪表板

  • 严格遵循测试驱动开发(TDD)

  • 重新设计用户界面(UI)以满足用户体验期望

数据库讨论

在开始数据库讨论之前,我们必须考虑以下几点——FlixOne 网站应用程序的整体情况:

  • 我们应用程序的一部分是库存管理,但另一部分是电子商务网站应用程序。

  • 具有挑战性的部分是我们的应用程序还将作为销售点(POS)提供服务。在这个部分/模块中,用户可以支付他们从离线柜台/门店购买的物品。

  • 对于库存部分,我们需要解决我们将采取哪种方法来计算和维护账户和交易,并确定出售任何物品的成本。

  • 为了维护库存,有各种选项可用,其中最常用的两个选项是先进先出(FIFO)和后进先出(LIFO)。

  • 大部分交易涉及财务数据,因此这些交易需要历史数据。每条记录应包含以下信息:当前值,当前更改之前的值,以及所做的更改。

  • 在维护库存的同时,我们还需要维护购买的物品。

在为任何电子商务网站应用程序设计数据库时,还有更多重要的要点。我们将限制我们的范围,以展示 FlixOne 应用程序的库存和库存管理。

数据库处理

与本书中涵盖的其他主题类似,有许多数据库,涵盖了从数据库模式的基本模式到管理数据库系统如何组合的模式。本节将涵盖两种系统模式,即在线事务处理(OLTP)和在线分析处理(OLAP)。为了进一步了解数据库设计模式,我们将更详细地探讨一种特定模式,即分类账式数据库。

数据库模式是数据库中表、视图、存储过程和其他组件的集合的另一个词。可以将其视为数据库的蓝图。

OLTP

已设计 OLTP 数据库以处理导致数据库更改的大量语句。基本上,INSERT、UPDATE 和 DELETE 语句都会导致更改,并且与 SELECT 语句的行为非常不同。OLTP 数据库已经考虑到了这一点。因为这些数据库记录更改,它们通常是主数据库或主数据库,这意味着它们是保存当前数据的存储库。

MERGE语句也被视为引起变化的语句。这是因为它提供了一个方便的语法,用于在行不存在时插入记录,并在行存在时插入更新。当行存在时,它将进行更新。MERGE语句并非所有数据库提供程序或版本都支持。

OLTP 数据库通常被设计为快速处理变更语句。这通常是通过精心规划表结构来实现的。一个简单的观点是考虑数据库表。这个表可以有用于存储数据的字段,用于高效查找数据的键,指向其他表的索引,用于响应特定情况的触发器,以及其他表结构。每一个这些结构都有性能惩罚。因此,OLTP 数据库的设计是在表上使用最少数量的结构与所需行为之间的平衡。

让我们考虑一张记录库存系统中书籍的表。每本书可能记录名称、数量、出版日期,并引用作者信息、出版商和其他相关表。我们可以在所有列上放置索引,甚至为相关表中的数据添加索引。这种方法的问题在于,每个索引都必须为引起变化的每个语句存储和维护。因此,数据库设计人员必须仔细规划和分析数据库,以确定向表中添加和不添加索引和其他结构的最佳组合。

表索引可以被视为一种虚拟查找表,它为关系数据库提供了一种更快的查找数据的方式。

OLAP

使用 OLAP 模式设计的数据库预计会有比引起变化的语句更多的SELECT语句。这些数据库通常具有一个或多个数据库的数据的综合视图。因此,这些数据库通常不是主数据库,而是用于提供与主数据库分开的报告和分析的数据库。在某些情况下,这是在与其他数据库隔离的基础设施上提供的,以便不影响运营数据库的性能。这种部署方式通常被称为数据仓库

数据仓库可以用来提供企业系统或系统集合的综合视图。传统上,数据通常通过较慢的周期性作业进行输入,以从其他系统刷新数据,但是使用现代数据库系统,这种趋势正在向近实时的整合发展。

OLTP 和 OLAP 之间的主要区别在于数据的存储和组织方式。在许多情况下,这将需要在支持特定报告场景的 OLAP 数据库中创建表或持久视图(取决于所使用的技术),并复制数据。在 OLTP 数据库中,数据的复制是不希望的,因为这样会引入需要为单个引起变化的语句维护的多个表。

分类账式数据库

会强调分类账式数据库设计,因为这是几十年来许多金融数据库中使用的模式,而且可能并不为一些开发人员所知。分类账式数据库源自会计分类账,交易被添加到文档中,并且数量和/或金额被合计以得出最终数量或金额。下表显示了苹果销售的分类账:

关于这个例子有几点需要指出。购买者信息是分别写在不同的行上,而不是擦除他们的金额并输入新的金额。以 West Country Produce 的两次购买和一次信用为例。这通常与许多数据库不同,许多数据库中单个行包含购买者信息,并有用于金额和价格的单独字段。

类似账簿的数据库通过每个交易都有单独的一行来实现这一概念,因此删除UPDATEDELETE语句,只依赖INSERT语句。这有几个好处。与账簿类似,一旦每笔交易被写入,就不能被移除或更改。如果出现错误或更改,比如对 West Country Produce 的信用,需要写入新的交易以达到期望的状态。这样做的一个有趣好处是源表现在立即提供了详细的活动日志。如果我们添加一个modified by列,我们就可以有一个全面的日志,记录是谁或什么做出了更改以及更改是什么。

这个例子是针对单条目账簿的,但在现实世界中,会使用双重账簿。不同之处在于,在双重账簿中,每笔交易都记录为一张表中的信用和另一张表中的借记。

下一个挑战是捕获表的最终或汇总版本。在这个例子中,这是已购买的苹果数量和价格。第一种方法可以使用一个SELECT语句,只需对购买者执行GROUP BY,如下所示:

SELECT Purchaser, SUM(Amount), SUM(Price)
FROM Apples
GROUP BY Purchaser

虽然这对于较小的数据大小来说是可以的,但问题在于随着行数的增加,查询的性能会随着时间的推移而下降。另一种选择是将数据聚合成另一种形式。有两种主要的方法可以实现这一点。第一种方法是在将账簿表中的信息写入另一张表(或者如果支持的话,持久视图)时同时执行这个活动,这张表以聚合形式保存数据。

持久物化视图类似于数据库视图,但视图的结果被缓存。这使我们不需要在每个请求上重新计算视图的好处,它要么定期刷新,要么在基础数据更改时刷新。

第二种方法依赖于与INSERT语句分开的另一个机制,在需要时检索聚合视图。在一些系统中,向表中写入更改并检索结果的主要场景发生得不太频繁。在这种情况下,优化数据库使得写入速度比读取速度更快,从而限制插入新记录时所需的处理量会更有意义。

下一节将讨论一个有趣的模式 CQRS,可以应用在数据库层面。这可以用在类似账簿的数据库设计中。

实施 CQRS 模式

CQRS 简单地在查询(读取)和命令(修改)之间进行分离。命令-查询分离CQS)是一种面向对象设计OOD)的方法。

CQRS 是由 Bertrand Meyer 首次提出的(en.wikipedia.org/wiki/Bertrand_Meyer)。他在 20 世纪 80 年代晚期的著作《面向对象的软件构造》中首次提到了这个术语:www.amazon.in/Object-Oriented-Software-Construction-Prentice-hall-International/dp/0136291554

CQRS 在某些场景下非常适用,并具有一些有用的因素:

  • 模型分离:在建模方面,我们能够为我们的数据模型有多个表示。清晰的分离允许选择不同的框架或技术,而不是更适合查询或命令的其他框架。可以说,这可以通过创建、读取、更新和删除CRUD)风格的实体来实现,尽管单一的数据层组件经常会出现。

  • 协作:在一些企业中,查询和命令之间的分离将有利于参与构建复杂系统的团队,特别是当一些团队更适合处理实体的不同方面时。例如,一个更关注展示的团队可以专注于查询模型,而另一个更专注于数据完整性的团队可以维护命令模型。

  • 独立可伸缩性:许多解决方案往往要求根据业务需求对模型进行更多读取或写入。

对于 CQRS,请记住命令更新数据,查询读取数据。

在使用 CQRS 时需要注意的一些重要事项如下:

  • 命令应该以异步方式放置,而不是同步操作。

  • 数据库不应该通过查询进行修改。

CQRS 通过使用单独的命令和查询简化了设计。此外,我们可以在物理上将读取数据与写入数据操作分开。在这种安排中,读取数据库可以使用单独的数据库架构,或者换句话说,我们可以说它可以使用一个专门用于查询的只读数据库。

由于数据库采用了物理分离的方法,我们可以将应用程序的 CQRS 流程可视化,如下图所示:

上述图表描述了 CQRS 应用程序的想象工作流程,其中应用程序具有用于写操作和读操作的物理分离数据库。这个想象中的应用程序是基于 RESTful Web 服务(.NET Core API)的。没有 API 直接暴露给使用这些 API 的客户端/最终用户。有一个 API 网关暴露给用户,任何应用程序的请求都将通过 API 网关进行。

API 网关为具有类似类型服务的组提供了一个入口点。你也可以使用外观模式来模拟它,这是分布式系统的一部分。

在上一个图表中,我们有以下内容:

  • 用户界面:这可以是任何客户端(使用 API 的人),Web 应用程序,桌面应用程序,移动应用程序,或者任何其他应用程序。

  • API 网关:来自用户界面的任何请求和向用户界面的任何响应都是通过 API 网关传递的。这是 CQRS 的主要部分,因为业务逻辑可以通过使用命令和持久层来合并。

  • 数据库:图表显示了两个物理分离的数据库。在实际应用中,这取决于产品的需求,你可以使用数据库进行写入和读取操作。

  • 查询是通过Read操作生成的,这些操作是数据传输对象DTOs)。

现在你可以回到用例部分,在那里我们讨论了我们的 FlixOne 库存应用程序的新功能/扩展。在这一部分,我们将使用 CQRS 模式创建一个新的 FlixOne 应用程序,其中包括之前讨论的功能。请注意,我们将首先开发 API。如果你没有安装先决条件,我建议重新访问技术要求部分,收集所有所需的软件,并将它们安装到你的机器上。如果你已经完成了先决条件,那么让我们开始按照以下步骤进行:

  1. 打开 Visual Studio。

  2. 单击文件|新建项目来创建一个新项目。

  3. 在新项目窗口中,选择 Web,然后选择 ASP.NET Core Web 应用程序。

  4. 给你的项目取一个名字。我已经为我们的项目命名为FlixOne.API,并确保解决方案名称为FlixOne

  5. 选择你的解决方案文件夹的位置,然后点击确定按钮,如下图所示:

  1. 现在你应该在新的 ASP.NET Web Core 应用程序 - FlixOne.API 屏幕上。确保在此屏幕上,选择 ASP.NET Core 2.2。从可用模板中选择 Web 应用程序(模型-视图-控制器),并取消选择 HTTPS 复选框,如下图所示:

  1. 您将看到一个默认页面出现,如下截图所示:

  1. 展开“解决方案资源管理器”,单击“显示所有文件”。您将看到 Visual Studio 创建的默认文件夹/文件。参考以下截图:

我们选择了 ASP.NET Core Web(Model-View-Controller)模板。因此,我们有默认的文件夹 Controllers,Models 和 Views。这是 Visual Studio 提供的默认模板。要检查此默认模板,请按F5并运行项目。然后,您将看到以下默认页面:

上一个截图是我们的 Web 应用程序的默认主屏幕。您可能会想这是一个网站吗?并期望在这里看到 API 文档页面而不是网页。这是因为当我们选择模板时,Visual Studio 默认添加 MVC Controller 而不是 API Controller。请注意,在 ASP.NET Core 中,MVC Controller 和 API Controller 都使用相同的 Controller Pipeline(参见 Controller 类:docs.microsoft.com/en-us/dotnet/api/microsoft.aspnetcore.mvc.controller?view=aspnetcore-2.2)。

在详细讨论 API 项目之前,让我们首先向我们的 FlixOne 解决方案添加一个新项目。要这样做,请展开“解决方案资源管理器”,右键单击解决方案名称,然后单击“添加新项目”。参考以下截图:

在“新建项目”窗口中,添加新的FlixOne.CQRS项目,然后单击OK按钮。参考以下截图:

上一个截图是“添加新项目”窗口。在其中,选择.NET Core,然后选择 Class Library(.NET Core)项目。输入名称FlixOne.CQRS,然后单击“OK”按钮。已将新项目添加到解决方案中。然后,您可以添加文件夹到新解决方案,如下截图所示:

上一个截图显示我已添加了四个新文件夹:CommandsQueriesDomainHelper。在Commands文件夹中,我有CommandHandler子文件夹。同样,对于Queries文件夹,我添加了名为HandlerQuery的子文件夹。

要开始项目,让我们首先在项目中添加两个领域实体。以下是所需的代码:

public class Product
{
    public Guid Id { get; set; }
    public string Name { get; set; }
    public string Description { get; set; }
    public string Image { get; set; }
    public decimal Price { get; set; }
}

上述代码是一个Product领域实体,具有以下属性:

  • Id:一个唯一标识符

  • Name:产品名称

  • Description:产品描述

  • Image:产品的图像

  • Price:产品的价格

我们还需要添加CommandResponse数据库。在与数据库/存储库交互时,这在确保系统获得响应方面起着重要作用。以下是CommandResponse实体模型的代码片段:

public class CommandResponse
{
    public Guid Id { get; set; }
    public bool Success { get; set; }
    public string Message { get; set; }

}

上述CommandResponse类包含以下属性:

  • Id:唯一标识符。

  • Success:具有TrueFalse的值,告诉我们操作是否成功。

  • Message:作为操作响应的消息。如果Success为 false,则此消息包含Error

现在是时候为查询添加接口了。要添加接口,请按照以下步骤进行:

  1. 从“解决方案资源管理器”中,右键单击Queries文件夹,单击“添加”,然后单击“新建项”,如下截图所示:

  1. 从“添加新项”窗口中,选择接口,命名为IQuery,然后单击“添加”按钮:

  1. 按照上述步骤,还要添加IQueryHandler接口。以下是IQuery接口的代码:
public interface IQuery<out TResponse>
{
}
  1. 上一个接口作为查询任何类型操作的骨架。这是使用TResponse类型的out参数的通用接口。

以下是我们的ProductQuery类的代码:

public class ProductQuery : IQuery<IEnumerable<Product>>
{
}

public class SingleProductQuery : IQuery<Product>
{
    public SingleProductQuery(Guid id)
    {
        Id = id;
    }

    public Guid Id { get; }

}

以下是我们的ProductQueryHandler类的代码:

public class ProductQueryHandler : IQueryHandler<ProductQuery, IEnumerable<Product>>
{
    public IEnumerable<Product> Get()
    {
        //call repository
        throw new NotImplementedException();
    }
}
public class SingleProductQueryHandler : IQueryHandler<SingleProductQuery, Product>
{
    private SingleProductQuery _productQuery;
    public SingleProductQueryHandler(SingleProductQuery productQuery)
    {
        _productQuery = productQuery;
    }

    public Product Get()
    {
        //call repository
        throw new NotImplementedException();
    }
}

以下是我们的ProductQueryHandlerFactory类的代码:

public static class ProductQueryHandlerFactory
{
    public static IQueryHandler<ProductQuery, IEnumerable<Product>> Build(ProductQuery productQuery)
    {
        return new ProductQueryHandler();
    }

    public static IQueryHandler<SingleProductQuery, Product> Build(SingleProductQuery singleProductQuery)
    {
        return  new SingleProductQueryHandler(singleProductQuery);
    }
}

类似于Query接口和Query类,我们需要为命令及其类添加接口。

在我们为产品领域实体创建了 CQRS 的时候,您可以按照这个工作流程添加更多的实体。现在,让我们继续进行FlixOne.API项目,并按照以下步骤添加一个新的 API 控制器:

  1. 从解决方案资源管理器中,右键单击Controllers文件夹。

  2. 选择添加|新项目。

  3. 选择 API 控制器类,并将其命名为ProductController;参考以下截图:

  1. 在 API 控制器中添加以下代码:
[Route("api/[controller]")]
public class ProductController : Controller
{
    // GET: api/<controller>
    [HttpGet]
    public IEnumerable<Product> Get()
    {
        var query = new ProductQuery();
        var handler = ProductQueryHandlerFactory.Build(query);
        return handler.Get();
    }

    // GET api/<controller>/5
    [HttpGet("{id}")]
    public Product Get(string id)
    {
        var query = new SingleProductQuery(id.ToValidGuid());
        var handler = ProductQueryHandlerFactory.Build(query);
        return handler.Get();
    }

以下代码是用于保存产品的:


    // POST api/<controller>
    [HttpPost]
    public IActionResult Post([FromBody] Product product)
    {
        var command = new SaveProductCommand(product);
        var handler = ProductCommandHandlerFactory.Build(command);
        var response = handler.Execute();
        if (!response.Success) return StatusCode(500, response);
        product.Id = response.Id;
        return Ok(product);

    }

以下代码是用于删除产品的:


    // DELETE api/<controller>/5
    [HttpDelete("{id}")]
    public IActionResult Delete(string id)
    {
        var command = new DeleteProductCommand(id.ToValidGuid());
        var handler = ProductCommandHandlerFactory.Build(command);
        var response = handler.Execute();
        if (!response.Success) return StatusCode(500, response);
        return Ok(response);
    }

我们已经创建了产品 API,并且在本节中不会创建 UI。为了查看我们所做的工作,我们将为我们的 API 项目添加Swagger支持。

Swagger 是一个用于文档目的的工具,并在一个屏幕上提供有关 API 端点的所有信息,您可以可视化 API 并通过设置参数进行测试。

要开始在我们的 API 项目中实现 Swagger,按照以下步骤进行:

  1. 打开 Nuget 包管理器。

  2. 转到 Nuget 包管理器|浏览并搜索Swashbuckle.ASPNETCore;参考以下截图:

  1. 打开Startup.cs文件,并将以下代码添加到ConfigureService方法中:
//Register Swagger
            services.AddSwaggerGen(swagger =>
            {
                swagger.SwaggerDoc("v1", new Info { Title = "Product APIs", Version = "v1" });
            });
  1. 现在,将以下代码添加到Configure方法中:
// Enable middleware to serve generated Swagger as a JSON endpoint.
app.UseSwagger();

// Enable middleware to serve swagger-ui (HTML, JS, CSS, etc.), specifying the Swagger JSON endpoint.
app.UseSwaggerUI(c =>
{
    c.SwaggerEndpoint("/swagger/v1/swagger.json", "Product API V1");
});

我们现在已经完成了展示应用程序中 CQRS 的强大功能的所有更改。在 Visual Studio 中按下F5,并通过访问以下 URL 打开 Swagger 文档页面:localhost:52932/swagger/(请注意,端口号52932可能会根据项目设置而有所不同)。您将看到以下 Swagger 文档页面:

在这里,您可以测试产品 API。

摘要

本章介绍了 CQRS 模式,然后我们将其实现到我们的应用程序中。本章的目的是通过数据库技术,并查看分类账式数据库如何用于库存系统。为了展示 CQRS 的强大功能,我们创建了产品 API,并添加了对 Swagger 文档的支持。

在下一章中,我们将讨论云服务,并详细了解微服务和无服务器技术。

问题

以下问题将帮助您巩固本章中包含的信息:

  1. 什么是分类账式数据库?

  2. 什么是 CQRS?

  3. 我们何时应该使用 CQRS?

第十二章:为云端编码

之前的章节探讨了模式,从较低级别的概念,如单例和工厂模式,到特定技术的模式,如数据库和 Web 应用程序的模式。这些模式对于确保解决方案的良好设计以确保可维护性和高效实施至关重要。这些模式提供了一个坚实的基础,使应用程序能够在需求变化和添加新功能时得到增强和修改。

本章从更高层次的视角来看待解决方案,以解决设计实施可靠、可扩展和安全的问题。本章中的模式通常涉及包含多个应用程序、存储库和各种可能的基础设施配置的环境。

软件行业不断发展,随之而来的是新的机遇和新的挑战。在本章中,我们将探讨云端的不同软件模式。这些模式中许多并非新鲜事物,在本地环境中已经存在。随着云优先解决方案变得普遍,这些模式由于实施不依赖本地基础设施的便利性而变得更加普遍。

云优先或云原生解决方案旨在针对云计算资源,而混合解决方案则旨在同时使用云计算资源和私人数据中心的资源。

本章定义了在构建云端解决方案时的五个关键考虑因素:

  • 可扩展性

  • 可用性

  • 安全性

  • 应用设计

  • DevOps

我们将讨论这些关键考虑因素以及它们对构建云解决方案的重要性。随着讨论这些问题,将描述不同的模式,以应对这些问题。

技术要求

本章不需要任何特殊的技术要求或源代码,因为它主要是理论性的。

在构建云端解决方案时的关键考虑因素

决定转移到云端会带来一系列问题和挑战。在本节中,我们将涵盖构建基于云的解决方案的五个关键考虑领域。虽然这些问题并非云端独有,但在转向云端时需要特别关注,因为有各种技术和解决方案可供选择。

五个主要考虑因素如下:

  • 可扩展性:这允许适应不断增长的业务的负载或流量。

  • 弹性/可用性:这确保系统在发生故障时能够优雅地处理,对用户的影响尽可能小。

  • 安全性:这确保私人和专有数据保持原样,并且免受黑客和攻击的威胁。

  • 应用设计:这指的是专门考虑云端解决方案的应用设计。

  • DevOps:这是一套支持云端解决方案开发和运行的工具和实践集合。

根据您的业务需求,您可能需要寻找一些或所有这些考虑因素的解决方案。对于您的业务来说,采用能够解决您未预料到但会成为良好备用计划的问题的解决方案提供商也是最为有利的。

在接下来的章节中,我们将详细讨论这些考虑因素以及针对它们的可用解决方案模式。

这些模式涵盖了从技术类型到架构和业务流程的各种问题,一个单一模式可能涉及多个问题。

可扩展性

可扩展性指的是为了应用程序在给定的工作负载下保持可接受的质量水平而分配和管理资源的能力。大多数云服务提供机制来增加应用程序使用的资源的质量和数量。例如,Azure 应用服务允许扩展应用服务的大小和应用服务的实例数量。

可扩展性可以被视为对有限资源的需求。资源可以是磁盘空间、RAM、带宽或软件的另一个可以量化的方面。需求可以涵盖用户数量、并发连接数量或会对资源产生约束的其他需求。随着需求的增加,应用程序需要提供资源。当需求影响应用程序的性能时,这被称为资源瓶颈。

例如,一个度量标准可能是在应用程序性能开始恶化之前可以访问应用程序的用户数量。性能可以设置为请求的平均延迟小于 2 秒。随着用户数量的增加,可以查看系统的负载,并识别影响性能的特定资源瓶颈。

工作负载

为了确定如何有效地解决扩展性问题,了解系统将承受的工作负载是很重要的。有四种主要类型的工作负载:静态、周期性、一次性和不可预测的。

静态工作负载表示系统上的持续活动水平。由于工作负载不波动,这种类型的系统不需要非常弹性的基础设施。

具有可预测工作负载变化的系统具有周期性工作负载。例如,系统在周末或应交所得税的月份周围经历活动激增。这些系统可以进行扩展以在负载增加时保持所需的质量水平,并在负载减少时进行缩减以节省成本。

一次性工作负载表示围绕特定事件设计的系统。这些系统被配置为处理事件周围的工作负载,并在不再需要时取消配置。

不可预测的工作负载通常受益于前面提到的自动扩展功能。这些系统的活动波动很大,要么业务尚未理解,要么受其他因素影响。

理解和设计基于云的应用程序以适应其工作负载类型对于保持高性能水平和降低成本都至关重要。

解决方案模式

我们有三种设计模式和一种架构模式可供选择,以使我们的系统具有可扩展性:

  • 垂直扩展

  • 水平扩展

  • 自动扩展

  • 微服务

让我们更详细地审查每一种。

垂直扩展

虽然可以向本地服务器添加物理 RAM 或额外的磁盘驱动器,但大多数云提供商支持轻松增加或减少系统的计算能力。这通常是在系统扩展时几乎没有或没有停机时间。这种类型的扩展称为垂直扩展,指的是改变资源,如 CPU 类型、RAM 的大小和质量,或磁盘的大小和质量。

垂直扩展通常被称为“扩展”,而水平扩展通常被称为“扩展”。在这种情况下,“扩展”指的是资源的大小,“扩展”指的是实例的数量。

水平扩展

水平扩展与垂直扩展不同,因为水平扩展改变的是系统的数量,而不是系统的大小。例如,Web 应用程序可能在一台具有 4GB RAM 和 2 个 CPU 的单个服务器上运行。如果将服务器的大小增加到 8GB RAM 和 4 个 CPU,那么这将是垂直扩展。但是,如果增加了两台具有相同配置的 4GB RAM 和 2 个 CPU 的服务器,那么这将是水平扩展。

水平扩展可以通过使用某种形式的负载平衡来实现,该负载平衡将请求重定向到一组系统,如下图所示:

水平扩展通常比垂直扩展更受云解决方案的青睐。这是因为一般来说,使用多个较小的虚拟机来提供相同性能的服务比使用单个大型服务器更具成本效益。

要使水平扩展最有效,确实需要支持这种类型扩展的系统设计。例如,设计时没有粘性会话和/或状态存储在服务器上的 Web 应用程序更适合水平扩展。这是因为粘性会话会导致用户的请求被路由到同一台虚拟机进行处理,随着时间的推移,虚拟机之间的路由平衡可能变得不均匀,因此效率可能不尽如人意。

有状态应用程序

有状态应用程序在服务器或存储库上维护有关活动会话的信息。

无状态应用程序

无状态应用程序设计为不需要在服务器或存储库上存储有关活动会话的信息。这允许将单个会话中的后续请求发送到任何服务器进行处理,而不仅仅是发送到整个会话的同一服务器。

有状态的 Web 应用程序需要在共享存储库中维护会话或信息。无状态的 Web 应用程序支持更具弹性的模式,因为 Web garden 或 Web farm 中的任何服务器都可以失败而不会丢失会话信息。

Web garden是一种模式,其中同一 Web 应用程序的多个副本托管在同一台服务器上,而 Web farm是一种模式,其中同一 Web 应用程序的多个副本托管在不同的服务器上。在这两种模式中,路由用于将多个副本公开为单个应用程序。

自动扩展

使用云提供商而不是本地解决方案的优势是内置的自动扩展支持。作为水平扩展的附加好处,自动扩展应用程序的能力通常是云服务的可配置功能。例如,Azure 应用服务提供了设置自动扩展配置文件的功能,允许应用程序对条件做出反应。例如,以下屏幕截图显示了一个自动扩展配置文件:

为工作日设计的配置文件将根据服务器负载增加或减少应用服务实例的数量。负载以 CPU 百分比来衡量。如果 CPU 百分比平均超过 60%,则实例数量增加到最多 10 个。同样,如果 CPU 百分比低于 30%,实例数量将减少到最少 2 个。

弹性基础设施允许资源在不需要重新部署或停机的情况下进行垂直或水平扩展。该术语实际上更多地是弹性程度,而不是指系统是否具有弹性非弹性。例如,弹性服务可以允许在不需要重新启动服务实例的情况下进行垂直和水平扩展。较不具弹性的服务可以允许在不重新启动的情况下进行水平扩展,但在更改服务器大小时需要重新启动服务。

微服务

对于微服务的含义以及它与面向服务的架构(SOA)的关系有不同的解释。在本节中,我们将微服务视为 SOA 的一种完善,而不是一种新的架构模式。微服务架构通过添加一些额外的关键原则来扩展 SOA,要求服务必须:

  • 规模小 - 因此称为

  • 围绕业务能力构建

  • 与其他服务松散耦合

  • 可以独立维护

  • 具有隔离状态

规模小

微服务将 SOA 中的服务缩小到最小可能的规模。这与我们之前看到的一些其他模式非常契合,比如《保持简单愚蠢》(KISS)和《你不会需要它》(YAGNI)来自[第二章],现代软件设计模式和原则。微服务应该只满足其要求,而不多做其他事情。

业务能力

通过围绕业务能力构建服务,我们以一种使得当业务需求发生变化时,我们的服务也会以类似的方式进行变更的方式来实现我们的实现。因此,较少可能会导致业务的一个领域的变化影响其他领域。

松散耦合

微服务应该使用技术无关的协议(如 HTTP)跨服务边界与其他服务进行交互。这使得微服务更容易集成,更重要的是,当另一个服务发生变化时,不需要重建微服务。这确实需要存在一个已知的服务合同

服务合同

服务合同是分发给其他开发团队的服务定义。Web 服务描述语言(WSDL)是一种广为人知的基于 XML 的描述服务的语言,但其他语言,如 Swagger,也非常流行。

在实施微服务时,重要的是要有一个管理变更的策略。通过具有版本化的服务合同,可以清晰地向服务的客户传达变更。

例如,用于存储图书库存的微服务的策略可能如下:

  • 每个服务将被版本化并包括 Swagger 定义。

  • 每个服务将从版本 1 开始。

  • 当进行需要更改服务合同的更改时,版本将增加 1。

  • 服务将维护最多三个版本。

  • 对服务的更改必须确保所有当前版本的行为都合适。

前面的基本策略确实有一些有趣的含义。首先,维护服务的团队必须确保更改不会破坏现有服务。这确保了新部署不会破坏其他服务,同时允许部署新功能。合同允许最多同时有三个服务处于活动状态,因此可以独立更新可靠的服务。

可以独立维护

这是微服务最显著的特点之一。使得一个微服务能够独立于其他微服务进行维护,使得企业能够在不影响其他服务的情况下管理该服务。通过管理服务,我们既包括服务的开发,也包括服务的部署。根据这一原则,微服务可以更新和部署,减少对其他服务的影响,并且以不同的变化速率进行部署。

隔离状态

隔离状态包括数据和其他可能共享的资源,包括数据库和文件。这也是微服务架构的一个显著特点。通过拥有独立的状态,我们减少了支持一个服务的数据模型的变化会影响其他服务的机会。

下图展示了更传统的 SOA 方法,多个服务使用单个数据库:

通过要求微服务具有隔离状态,我们将要求每个服务都有一个数据库,如下图所示:

这样做的好处在于每个服务可以选择最适合服务要求的技术。

优势

微服务架构确实代表了传统服务设计的转变,并且它在基于云的解决方案中表现良好。微服务的优势以及它们为什么越来越受欢迎可能并不立即明显。我们已经提到了微服务设计如何提供处理变化的优势。从技术角度来看,微服务可以在服务级别和数据库级别独立扩展。

也许不清楚的是微服务架构对业务的好处。通过拥有小型独立服务,业务可以以不同的方式来维护和开发微服务。业务现在可以选择以不同的方式托管服务,包括不同的云提供商,以最适合独立服务的方式。同样,服务的隔离性允许在开发服务时具有更大的灵活性。随着变化的发生,资源(即开发团队成员)可以根据需要分配到不同的服务,由于服务范围较小,所需的业务知识量也减少了。

弹性/可用性

弹性是应用程序处理失败的能力,而可用性是应用程序工作的时间的度量。如果一个应用程序拥有一组资源,并且即使其中一个资源变得无法操作或不可用,它仍然保持可用。

如果一个应用程序被设计成可以处理一个或多个资源失败而不会导致整个系统无法操作,这被称为优雅降级

模式既适用于隔离应用程序的元素,也适用于处理元素之间的交互,以便在发生故障时限制影响。许多与弹性相关的模式侧重于应用程序内部或与其他应用程序之间的消息传递。例如,Bulkhead 模式将流量隔离成池,以便当一个池被压倒或失败时,其他池不会受到不利影响。其他模式应用特定技术来处理消息传递,如重试策略或补偿事务。

可用性对许多基于云的应用程序来说是一个重要因素,通常可用性是根据服务级别协议SLA)来衡量的。在大多数情况下,SLA 规定了应用程序必须保持可操作的时间百分比。模式既涉及允许组件冗余,又使用技术来限制活动增加的影响。例如,基于队列的负载平衡模式使用队列来限制活动增加可能对应用程序的影响,充当调用者或客户端与应用程序或服务之间的缓冲。

弹性和可用性被确定为相关的云解决方案因素,因为通常一个具有弹性的应用程序可以实现严格的可用性 SLA。

解决方案模式

为了确保我们拥有一个具有弹性和可用性的系统,我们最好寻找一个具有特定架构的提供商。进入事件驱动架构EDA)。

EDA 是一种使用事件来驱动系统行为和活动的架构模式。它下面提供的解决方案模式将帮助我们实现预期的解决方案。

EDA

EDA 推广了松散连接的生产者和消费者的概念,其中生产者不直接了解消费者。在这种情况下,事件是指任何变化,从用户登录系统,到下订单,到进程无法成功完成。EDA 非常适合分布式系统,并允许高度可扩展的解决方案。

与 EDA 相关的模式和方法有很多,本节介绍的以下模式与 EDA 直接相关:

  • 基于队列的负载平衡

  • 发布者-订阅者

  • 优先队列

  • 补偿事务

基于队列的负载平衡

基于队列的负载平衡是一种有效的方式,可以最小化高需求对可用性的影响。通过在客户端和服务之间引入队列,我们能够限制或限制服务一次处理的请求数量。这可以实现更流畅的用户体验。以以下图表为例:

上图显示了客户端向队列提交请求进行处理,并将结果保存到表中。队列可以防止函数被突然的活动激增所压倒。

发布者-订阅者

发布者-订阅者模式指出有事件发布者和事件消费者。基本上,这是 EDA 的核心,因为发布者与消费者解耦,不关心将事件传递给消费者,只关心发布事件。事件将包含信息,用于将事件路由到感兴趣的消费者。然后消费者将注册或订阅对特定事件感兴趣:

上图显示了一个客户服务和一个订单服务。客户服务充当发布者,并在添加客户时提交事件。订单服务已订阅了新客户事件。当接收到新客户事件时,订单服务将客户信息插入其本地存储。

通过将发布者-订阅者模式引入架构中,订单服务与客户服务解耦。这样做的一个优点是它为变更提供了更灵活的架构。例如,可以引入一个新服务来向不需要添加到客户服务使用的相同存储库的解决方案添加新客户。此外,可以有多个服务订阅新客户事件。添加欢迎电子邮件可以更容易地作为新的订阅者添加,而不必将此功能构建到单个的单片解决方案中。

优先队列

另一个相关的模式是优先队列,它提供了一种处理类似事件的不同机制。使用上一节中的新客户示例,可能会有两个订阅者对新客户事件感兴趣。一个订阅者可能对大多数新客户感兴趣,而另一个订阅者可能会识别应该以不同方式处理的客户子集。例如,来自农村地区的新订阅者可能会收到一封电子邮件,其中包含有关专门的运输提供商的额外信息。

补偿事务

在分布式系统中,将命令作为事务发出并不总是切实可行或可取的。在这种情况下,事务是指管理一个或多个命令的较低级别的编程构造,将它们作为单个操作来处理,要么全部成功,要么全部失败。在某些情况下,不支持分布式事务,或者使用分布式事务的开销超过了好处。补偿事务模式是为处理这种情况而开发的。让我们以 BizTalk 协调为例:

该图显示了一个过程中的两个步骤:在订单服务中创建订单和从客户服务中扣款。该图显示了首先创建订单,然后扣除资金。如果资金扣除不成功,则订单将从订单服务中移除。

安全

安全确保应用程序不会错误地披露信息或提供超出预期使用范围的功能。安全包括恶意和意外行为。随着云应用程序的增加以及广泛使用各种身份提供者,通常很难将访问权限限制为仅批准的用户。

最终用户身份验证和授权需要设计和规划,因为较少的应用程序是独立运行的,通常会使用多个身份提供者,如 Facebook、Google 和 Microsoft。在某些情况下,模式用于直接为改进性能和可伸缩性而提供对资源的访问。此外,其他模式涉及在客户端和应用程序之间创建虚拟墙壁。

解决方案模式

随着行业的日益互联,使用外部方来对用户进行身份验证的模式变得更加普遍。联合安全模式被选择用于讨论,因为它是确保系统安全的最佳方式之一,大多数软件即服务(SaaS)平台都提供此功能。

联合安全

联合安全将用户或服务(消费者)的身份验证委托给称为身份提供者IdP)的外部方。使用联合安全的应用程序将信任 IdP 正确地对消费者进行身份验证并准确提供有关消费者或声明的详细信息。有关消费者的这些信息被呈现为令牌。这种情况的常见场景是使用 Google、Facebook 或 Microsoft 等社交 IdP 的 Web 应用程序。

联合安全可以处理各种场景,从交互式会话到身份验证后端服务或非交互式会话。另一个常见的场景是能够在一套分别托管的应用程序中提供单一的身份验证体验或单点登录SSO)。这种情况允许从安全令牌服务STS)获取单个令牌,并且在不需要重复登录过程的情况下将相同的令牌用于多个应用程序:

联合安全有两个主要目的。首先,通过拥有单一身份存储库,简化身份管理。这允许以集中和统一的方式管理身份,使得执行管理任务(如提供登录体验、忘记密码管理以及一致地撤销密码)更容易。其次,通过为用户提供类似的体验跨多个应用程序,以及只需要记住单个密码而不是多个密码,提供更好的用户体验。

有几种联合安全标准,其中两种广泛使用的是安全断言标记语言SAML)和OpenId ConnectOIDC)。SAML 比 OIDC 更早,允许使用 XML SAML 格式交换消息。OIDC 建立在 OAuth 2.0 之上,通常使用JSON Web TokenJWT)来描述安全令牌。这两种格式都支持联合安全、单点登录(SSO),许多公共 IdP(如 Facebook、Google 和 Microsoft)都支持这两种标准。

应用程序设计

应用程序的设计可以有很大的变化,并受到许多因素的影响。这些因素不仅仅是技术上的,而且受到参与构建、管理和维护应用程序的团队的影响。例如,一些模式最适合小型专门团队,而不适合较大数量的地理分散的团队。其他与设计相关的模式更好地处理不同类型的工作负载,并在特定场景中使用。其他模式是围绕变更的频率设计的,以及如何限制应用程序发布后的变更中断。

解决方案模式

几乎所有的本地模式都适用于基于云的解决方案,可以涵盖的模式范围令人震惊。缓存和 CQRS 模式之所以被选择,是因为前者是大多数 Web 应用程序采用的非常常见的模式,而后者改变了设计者构建解决方案的方式,并且非常适合其他架构模式,如 SOA 和微服务。

缓存

将从较慢的存储中检索的信息存储到更快的存储中,或者进行缓存,是几十年来编程中使用的一种技术,可以在浏览器缓存等软件和 RAM 等硬件中看到。在本章中,我们将看到三个例子:缓存旁路、写入穿透缓存和静态内容托管。

缓存旁路

缓存旁路模式可以通过在本地或更快的存储中加载频繁引用的数据来提高性能。使用此模式,应用程序负责维护缓存的状态。如下图所示:

首先,应用程序从缓存中请求信息。如果信息丢失,则从数据存储中请求。然后,应用程序使用信息更新缓存。一旦信息存储,它将从缓存中检索并在不引用较慢的数据存储的情况下使用。使用此模式,应用程序负责维护缓存,无论是在缓存未命中时,还是在数据更新时。

术语缓存未命中指的是在缓存中找不到数据。换句话说,它在缓存中丢失了。

写入穿透缓存

写入穿透缓存模式也可以像缓存旁路模式一样用于提高性能。其方法不同之处在于将缓存内容的管理从应用程序移动到缓存本身,如下图所示:

在缓存中请求一条信息。如果数据尚未加载,则从数据存储中检索信息,将其放入缓存,然后返回。如果数据已经存在,则立即返回。这种模式支持通过缓存服务传递信息的写入来更新缓存。然后,缓存服务更新保存的信息,无论是在缓存中还是在数据存储中。

静态内容托管

静态内容托管模式将媒体图像、电影和其他非动态文件等静态内容移动到专门用于快速检索的系统中。这样的专门服务称为内容传递网络CDN),它可以管理跨多个数据中心的内容分发,并将请求定向到最接近调用者的数据中心,如下图所示:

静态内容托管是 Web 应用程序的常见模式,其中从 Web 应用程序请求动态页面,页面包含静态内容的集合,例如 JavaScript 和图像,然后浏览器直接从 CDN 中检索。这是减少 Web 应用程序流量的有效方法。

命令和查询责任分离

命令和查询职责分离(CQRS)是一个很好的软件模式,我们将在更多细节上讨论它,因为它在概念上很简单,相对容易实现,但对应用程序和涉及的开发人员有着巨大的影响。该模式清晰地将影响应用程序状态的命令与仅检索数据的查询分开。简而言之,更新、添加和删除等命令在不同的服务中提供,而不会改变任何数据的查询则在不同的服务中提供。

你可能会说又是 CQRS!我们意识到我们在面向对象编程和数据库设计中使用了 CQRS 的示例。同样的原则也适用于软件开发的许多领域。我们在本节中提出 CQRS 作为服务设计的一种模式,因为它带来了一些有趣的好处,并且与微服务和反应式应用程序设计等现代模式非常契合。

CQRS 基于贝尔特兰·梅耶(Bertrand Meyer)在上世纪 80 年代末出版的《面向对象的软件构造》一书中提出的面向对象设计:se.ethz.ch/~meyer/publications/

如果我们重新访问第五章:实现设计模式-.NET Core,我们通过将库存上下文拆分为两个接口:IInventoryReadContextIInventoryWriteContext来说明这种模式。作为提醒,这些是接口:

public interface IInventoryContext : IInventoryReadContext, IInventoryWriteContext { }

public interface IInventoryReadContext
{
    Book[] GetBooks();
}

public interface IInventoryWriteContext
{
    bool AddBook(string name);
    bool UpdateQuantity(string name, int quantity);
}

正如我们所看到的,GetBooks方法与修改库存状态的AddBookUpdateQuantity方法分开。这在代码解决方案中展示了 CQRS。

相同的方法也可以应用在服务层。举例来说,如果我们使用一个用于维护库存的服务,我们会将服务分为一个用于更新库存的服务和另一个用于检索库存的服务。下图展示了这一点:

让我们首先通过探讨 CQRS 来看看在基于云的解决方案中应用时所面临的挑战。

CQRS 的挑战

使用 CQRS 模式的挑战很大:

  • 一致性

  • 采用

陈旧性是数据反映已提交数据版本的程度。在大多数情况下,数据可能会发生变化,因此,一旦读取了一部分数据,就有可能更新数据,使读取的数据与源数据不一致。这是所有分布式系统都面临的挑战,因为不可能保证向用户显示的值反映源值。当数据直接反映存储的内容时,我们可以称数据是一致的;当数据不是这样时,就被视为不一致。

在分布式系统中常用的一个术语是最终一致性。最终一致性用于表示系统最终会变得一致。换句话说,它最终会变得一致。

另一个更微妙的挑战是采用。将 CQRS 引入已建立的开发团队可能会遇到抵制,无论是来自不熟悉该模式的开发人员和设计师,还是来自业务方面对偏离当前设计模式的支持不足。

那么有什么好处呢?

为什么选择 CQRS?

以下是使用 CQRS 的三个引人注目的因素:

  • 协作

  • 模型分离

  • 独立扩展性

通过分开的服务,我们可以独立地维护、部署和扩展这些服务。这增加了开发团队之间可以实现的协作水平。

通过拥有独立的服务,我们可以使用最适合我们服务的模型。命令服务可能直接使用简单的 SQL 语句针对数据库,因为这是负责团队最熟悉的技术,而构建查询服务的团队可能会使用一个处理复杂语句针对数据库的框架。

大多数解决方案往往具有更高的读取量而不是写入量(或反之),因此根据这一标准将服务进行拆分在许多情况下是有意义的。

DevOps

通过基于云的解决方案,数据中心是远程托管的,通常您无法完全控制或访问应用程序的所有方面。在某些情况下,例如无服务器服务,基础架构被抽象化了。应用程序仍然必须公开有关运行应用程序的信息,以便用于管理和监视应用程序。用于管理和监视的模式对于应用程序的成功至关重要,因为它们既能够保持应用程序的健康运行,又能够为业务提供战略信息。

解决方案模式

随着与监控和管理解决方案相关的商业软件包的可用性,许多企业已经更好地控制和了解了他们的分布式系统。遥测和持续交付/持续集成已被选择进行更详细的覆盖,因为它们在基于云的解决方案中具有特殊价值。

遥测

随着软件行业的发展和分布式系统涉及更多的服务和应用程序,能够对系统进行集体和一致的视图已经成为一项巨大的资产。由 New Relic 和 Microsoft Application Insights 等服务推广,应用程序性能管理(APM)系统使用记录的有关应用程序和基础设施的信息,即遥测,来监视、管理性能和查看系统的可用性。在基于云的解决方案中,通常无法或不实际直接访问系统的基础设施,APM 允许将遥测发送到中央服务,然后呈现给运营和业务,如下图所示:

上图摘自 Microsoft Application Insights,提供了一个正在运行的 Web 应用程序的高层快照。一眼就可以看出,运营人员可以识别系统行为的变化并做出相应反应。

持续集成/持续部署

持续集成/持续部署(CI/CD)是一种现代开发流程,旨在通过频繁合并更改并经常部署这些更改来简化软件交付产品生命周期。CI 解决了企业软件开发中出现的问题,即多个程序员正在同一代码库上工作,或者单个产品由多个代码分支管理。

看一下下面的图表:

在上面的示例中,有三个目标环境:开发、用户验收测试(UAT)和生产。开发环境是最初的环境,所有对应用程序的更改都在此进行测试。UAT 环境由质量保证(QA)团队用于在将更改移至面向客户的环境之前验证系统是否按预期工作,如图中所示的生产环境。代码库已分为三个匹配的分支:主干,开发团队将所有更改合并到其中,UAT 用于部署到 UAT 环境,生产代码库用于部署到生产环境。

CI 模式是通过在代码库更改时创建新构建来应用的。成功构建后,会对构建运行一系列单元测试,以确保现有功能未被破坏。如果构建不成功,开发团队会进行调查,然后修复代码库或单元测试,使构建通过。

成功的构建然后被推送到目标环境。主干可能被设置为每天自动将新构建推送到集成环境,而 QA 团队要求环境中的干扰更少,因此新构建仅在办公时间结束后每周推送一次。生产可能需要手动触发以协调新版本的发布,以宣布新功能和错误修复的正式发布。

关于“持续部署”和“持续交付”这两个术语存在混淆。许多来源区分这两个术语,即部署过程是自动化的还是手动的。换句话说,持续部署需要自动化的持续交付。

导致环境之间合并的触发器,从而推送到环境中进行构建,或者发布,可能会有所不同。在我们对开发环境的示例中,有一组自动化测试会自动运行对新构建进行测试。如果测试成功,那么就会自动从主干合并到 UAT 代码库。只有在 QA 团队在 UAT 环境中签署或接受更改后,才会在 UAT 和生产代码库之间执行合并。

每个企业都会根据其特定的 SDLC 和业务需求来定制 CI/CD 流程。例如,一个面向公众的网站可能需要快速的 SDLC 以保持市场竞争力,而内部应用可能需要更保守的方法,以限制由于功能变更而导致的员工培训。

尽管如此,已经开发了一套工具套件来管理组织内的 CI/CD 流程。例如,Azure DevOps 可以通过允许构建管道来处理构建何时创建以及何时发布到环境中,包括手动和自动触发器。

总结

云开发需要仔细的规划、维护和监控,模式可以帮助实现高度可扩展、可靠和安全的解决方案。本章讨论的许多模式适用于本地应用程序,并且在云解决方案中至关重要。云优先应用程序的设计应考虑许多因素,包括可扩展性、可用性、维护、监控和安全性。

可扩展的应用程序允许在系统负载波动时保持可接受的性能水平。负载可以通过用户数量、并发进程、数据量和软件中的其他因素来衡量。横向扩展解决方案的能力需要特定类型的应用程序开发,并且是云计算中特别重要的范例。诸如基于队列的负载平衡之类的模式是确保解决方案在负载增加时保持响应的重要技术。

本章涵盖的许多模式是互补的。例如,遵循命令和查询责任分离的应用程序可能利用联合安全来提供单一登录体验,并使用事件驱动架构来处理应用程序不同组件之间的一致性。

在基于云的解决方案中,有一个几乎无穷无尽的适用模式集合,用于解决分布式系统中的不同挑战。本章介绍的模式代表了因其广度以及它们如何相互补充而被选择的一部分。请参阅参考资料,以探索适用于基于云的解决方案的其他模式。

多么不容易啊!我们已经涵盖了从面向对象编程中使用的软件设计模式到基于云的解决方案中使用的架构模式,再到用于构建成功应用程序的更高效团队和模式。尽管我们尽力涵盖了各种模式,但肯定还有一些模式可能本该被添加进来。

谢谢,Gaurav 和 Jeffrey,希望您喜欢并从阅读使用 C#和.NET Core 进行设计模式实践中获得了一些收获。请告诉我们您的想法,并与我们分享您最喜欢的模式。

问题

以下问题将让您巩固本章中包含的信息:

  1. 大多数模式是最近开发的,只适用于基于云的应用程序。真还是假?

  2. ESB 代表什么,并且可以在哪种类型的架构中使用:EDA、SOA 还是单片?

  3. 队列负载平衡主要用于 DevOps、可伸缩性还是可用性?

  4. CI/CD 的好处是什么?在全球分散的大量团队还是一个小型的本地开发团队中,它会更有益?

  5. 在遵循静态内容托管的网站中,浏览器是直接通过 CDN 检索图像和静态内容,还是 Web 应用程序代表浏览器检索信息?

进一步阅读

要了解本章涵盖的主题,请参考以下书籍。这些书籍将为您提供有关本章涵盖的各种主题的深入和实践性练习:

第十三章:其他最佳实践

到目前为止,我们已经讨论了各种模式、风格和代码。在这些讨论中,我们的目标是理解编写整洁、清晰和健壮代码的模式和实践。本附录主要将专注于实践。实践对于遵守任何规则或任何编码风格都非常重要。作为开发人员,您应该每天练习编码。根据古老的谚语,熟能生巧

这表明技能,比如玩游戏、开车、阅读或写作,并不是一下子就能掌握的。相反,我们应该随着时间和实践不断完善这些技能。例如,当你开始学开车时,你会慢慢来。你需要记住何时踩离合器,何时踩刹车,转动方向盘需要多远,等等。然而,一旦司机熟悉了开车,就不需要记住这些步骤了;它们会自然而然地出现。这是因为实践。

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

  • 用例讨论

  • 最佳实践

  • 其他设计模式

技术要求

本附录包含各种代码示例,以解释所涵盖的概念。代码保持简单,仅用于演示目的。本章中的大多数示例涉及使用 C#编写的.NET Core 控制台应用程序。

要运行和执行代码,需要满足以下先决条件:

  • Visual Studio 2019(但是,您也可以使用 Visual Studio 2017 运行应用程序)

安装 Visual Studio

要运行本章中包含的代码示例,您需要安装 Visual Studio 或更高版本。请按照以下说明操作:

  1. 从以下下载链接下载 Visual Studio:docs.microsoft.com/en-us/visualstudio/install/install-visual-studio

  2. 按照安装说明操作。

  3. Visual Studio 有多个版本可供选择。我们正在使用 Windows 版的 Visual Studio。

本章的示例代码文件可在以下链接找到:github.com/PacktPublishing/Hands-On-Design-Patterns-with-C-and-.NET-Core/tree/master/Appendix

用例讨论

简而言之,用例是业务场景的预创建或符号表示。例如,我们可以用图示/符号表示来表示我们的登录页面用例。在我们的例子中,用户正在尝试登录系统。如果登录成功,他们可以进入系统。如果失败,系统会通知用户登录尝试失败。参考以下登录用例的图表:

在上图中,用户称为User1User2User3正在尝试使用应用程序的登录功能进入系统。如果登录尝试成功,用户可以访问系统。如果不成功,应用程序会通知用户登录失败,用户无法访问系统。上图比我们实际的冗长描述要清晰得多,我们在描述这个图表。图表也是不言自明的。

UML 图

在前一节中,我们用符号表示来讨论了登录功能。您可能已经注意到了图表中使用的符号。在前一个图表中使用的符号或符号是统一建模语言的一部分。这是一种可视化我们的程序、软件甚至类的方式。

UML 中使用的符号或符号已经从 Grady Booch、James Rumbaugh、Ivar Jacobson 和 Rational Software Corporation 的工作中发展而来。

UML 图的类型

这些图表分为两个主要组:

  • 结构化 UML 图:这些强调了系统建模中必须存在的事物。该组进一步分为以下不同类型的图表:

  • 类图

  • 包图

  • 对象图

  • 组件图

  • 组合结构图

  • 部署图

  • 行为 UML 图:用于显示系统功能,包括用例、序列、协作、状态机和活动图。该组进一步分为以下不同类型的图表:

  • 活动图

  • 序列图

  • 用例图

  • 状态图

  • 通信图

  • 交互概述图

  • 时序图

最佳实践

正如我们所建立的,实践是我们日常活动中发生的习惯。在软件工程中——在这里软件是被设计而不是制造的——我们必须练习以编写高质量的代码。在软件工程中可能有更多解释最佳实践的要点。让我们讨论一下:

  • 简短但简化的代码:这是一个非常基本的事情,需要练习。开发人员应该每天使用简短但简化的代码来编写简洁的代码,并在日常生活中坚持这种实践。代码应该清晰,不重复。清晰的代码和代码简化在前几章已经涵盖过;如果您错过了这个主题,请重新查看第二章,现代软件设计模式和原则。看一下以下简洁代码的示例:
public class Math
{
    public int Add(int a, int b) => a + b;
    public float Add(float a, float b) => a + b;
    public decimal Add(decimal a, decimal b) => a + b;
}

前面的代码片段包含一个Math类,其中有三个Add方法。这些方法被编写来计算两个整数的和以及两个浮点数和十进制数的和。Add(float a, float b)Add(decimal a, decimal b)方法是Add(int a, int b)的重载方法。前面的代码示例代表了一个场景,其中要求是制作一个具有 int、float 或 decimal 数据类型输出的单个方法。

  • 单元测试:这是开发的一个组成部分,当我们想要通过编写代码来测试我们的代码时。测试驱动开发TDD)是一个应该遵循的最佳实践。我们已经在第七章中讨论了 TDD,为 Web 应用程序实现设计模式-第二部分

  • 代码一致性:如今,开发人员很少有机会独自工作。开发人员大多在团队中工作,这意味着团队中的代码一致性非常重要。代码一致性可以指代码风格。在编写程序时,开发人员应该经常使用一些推荐的实践和编码转换。

声明变量的方法有很多种。以下是变量声明的最佳示例之一:

namespace Implement
{
    public class Consume
    {
        BestPractices.Math math = new BestPractices.Math();
    }
}

在前面的代码中,我们声明了一个math变量,类型为BestPractices.Math。这里,BestPractices是我们的命名空间,Math是类。如果在代码中没有使用using指令,那么完全命名空间限定的变量是一个很好的实践。

C#语言的官方文档非常详细地描述了这些约定。您可以在这里参考:docs.microsoft.com/en-us/dotnet/csharp/programming-guide/inside-a-program/coding-conventions

  • 代码审查:犯错误是人的天性,这也会发生在开发中。代码审查是练习编写无错代码和发现代码中不可预测错误的第一步。

其他设计模式

到目前为止,我们已经涵盖了各种设计模式和原则,包括编写代码的最佳实践。本节将总结以下模式,并指导您编写高质量和健壮的代码。这些模式的详细信息和实现超出了本书的范围。

我们已经涵盖了以下模式:

  • GoF 模式

  • 设计原则

  • 软件开发生命周期模式

  • 测试驱动开发

在本书中,我们涵盖了许多主题,并开发了一个示例应用程序(控制台和 Web)。这不是世界的尽头,世界上还有更多的东西可以学习。

我们可以列出更多的模式:

  • 基于空间的架构模式基于空间的模式SBP)是通过最小化限制应用程序扩展的因素来帮助应用程序可扩展性的模式。这些模式也被称为云架构模式。我们在第十二章中已经涵盖了其中的许多内容,云编程

  • 消息模式:这些模式用于基于消息的连接两个应用程序(以数据包的形式发送)。这些数据包或消息使用逻辑路径进行传输,各种应用程序连接在这些逻辑路径上(这些逻辑路径称为通道)。可能存在一种情况,其中一个应用程序有多个消息;在这种情况下,不是所有消息都可以一次发送。在存在多个消息的情况下,一个通道可以被称为队列,并且可以在通道中排队多个消息,并且可以在同一时间点从各种应用程序中访问。

  • 领域驱动设计的其他模式-分层架构:这描述了关注点的分离,分层架构的概念就是从这里来的。在幕后,开发应用程序的基本思想是应该将其结构化为概念层。一般来说,应用程序有四个概念层:

  • 用户界面:这一层包含了用户最终交互的所有内容,这一层接受命令,然后相应地提供信息。

  • 应用层:这一层更多地涉及事务管理、数据转换等。

  • 领域层:这一层专注于领域的行为和状态。

  • 基础设施层:与存储库、适配器和框架相关的所有内容都在这里发生。

  • 容器化应用模式:在我们深入研究之前,我们应该知道容器是什么。容器是轻量级、便携的软件;它定义了软件可以运行的环境。通常,运行在容器内的软件被设计为单一用途的应用程序。对于容器化应用程序,最重要的模式如下:

  • Docker 镜像构建模式:这种模式基于 GoF 设计模式中的生成器模式,我们在第三章中讨论过,实现设计模式-基础部分 1。它只描述了设置,以便用于构建容器。除此之外,还有一种多阶段镜像构建模式,可以从单个 Dockerfile 构建多个镜像。

总结

本附录的目的是强调实践的重要性。在本章中,我们讨论了如何通过实践提高我们的技能。一旦我们掌握了这些技能,就不需要记住实现特定任务的步骤。我们涵盖并讨论了一些来自现实世界的用例,讨论了我们日常代码的最佳实践,以及可以在我们日常实践中使用的其他设计模式,以提高我们的技能。最后,我们结束了本书的最后一章,并了解到通过实践和采用各种模式,开发人员可以提高其代码质量。

问题

以下问题将帮助您巩固本附录中包含的信息:

  1. 什么是实践?从我们的日常生活和例行公事中举几个例子。

  2. 我们可以通过实践获得特定的编码技能。解释一下。

  3. 什么是测试驱动开发,它如何帮助开发人员进行实践?

进一步阅读

我们几乎已经到达了本书的结尾!在这个附录中,我们涵盖了许多与实践相关的内容。这并不是学习的终点,而只是一个开始,您还可以参考更多的书籍来进行学习和知识积累:

第十四章:评估

第一章 - .NET Core 和 C#中的面向对象编程概述

  1. 晚期和早期绑定这两个术语是指什么?

早期绑定是在源代码编译时建立的,而晚期绑定是在组件运行时建立的。

  1. C#支持多重继承吗?

不支持。原因是多重继承会导致源代码更复杂。

  1. 在 C#中,可以使用什么封装级别防止类被库外访问?

internal访问修饰符可用于将类的可见性限制为仅在库内部。

  1. 聚合和组合之间有什么区别?

两者都是关联的类型,最容易区分的方法是涉及的类是否可以在没有关联的情况下存在。在组合关联中,涉及的类具有紧密的生命周期依赖性。这意味着当一个类被删除时,相关的类也被删除。

  1. 接口可以包含属性吗?(这是一个有点棘手的问题)

接口可以定义属性,但是接口没有主体...

  1. 狗吃鱼吗?

狗很可爱,但它们吃它们能够放进嘴里的大多数东西。

第二章 - 现代软件设计模式和原则

  1. 在 SOLID 中,S 代表什么?责任是什么意思?

单一责任原则。责任可以被视为变更的原因。

  1. 围绕循环构建的 SDLC 方法是瀑布还是敏捷?

敏捷是围绕开发过程在一系列周期中进行的概念构建的。

  1. 装饰者模式是创建模式还是结构模式?

装饰者模式是一种结构模式,允许功能在类之间分配,并且特别适用于在运行时增强类。

  1. pub-sub 集成代表什么?

发布-订阅是一种有用的模式,其中进程发布消息,其他进程订阅以接收消息。

第三章 - 实现设计模式 - 基础部分 1

  1. 在为组织开发软件时,有时很难确定需求的原因是什么?

为组织开发软件存在许多挑战。例如,组织行业的变化可能导致当前需求需要进行修改。

  1. 瀑布式软件开发与敏捷软件开发相比的两个优点和缺点是什么?

瀑布式软件开发相对于敏捷软件开发提供了优势,因为它更容易理解和实现。在某些情况下,当项目的复杂性和规模较小时,瀑布式软件开发可能是比敏捷式软件开发更好的选择。然而,瀑布式软件开发不擅长处理变化,并且由于范围更大,项目完成之前需求变化的可能性更大。

  1. 在编写单元测试时,依赖注入如何帮助?

通过将依赖项注入类,类变得更容易测试,因为依赖项是明确已知且更容易访问的。

  1. 为什么以下陈述是错误的?使用 TDD,您不再需要人员测试新软件部署。

测试驱动开发通过将清晰的测试策略纳入软件开发生命周期中来提高解决方案的质量。然而,定义的测试可能不完整,因此仍然需要额外的资源来验证交付的软件。

第四章 - 实现设计模式 - 基础部分 2

  1. 提供一个示例,说明为什么使用单例不是限制对共享资源访问的好机制?

单例有意在应用程序中创建瓶颈。它也是开发人员学习使用的第一个模式之一,因此通常在不需要限制对共享资源访问的情况下使用。

  1. 以下陈述是否正确?为什么?ConcurrentDictionary 防止集合中的项目被多个线程同时更新。

对于许多 C# 开发人员来说,意识到 ConcurrentDictionary 不能防止集合中的项目被多个线程同时更新是一个痛苦的教训。ConcurrentDictionary 保护共享字典免受同时访问和修改。

  1. 什么是竞争条件,为什么应该避免?

竞争条件是指多个线程的处理顺序可能导致不同的结果。

  1. 工厂模式如何帮助简化代码?

工厂模式是在应用程序内部创建对象的有效方式。

  1. .NET Core 应用程序需要第三方 IoC 容器吗?

.NET Core 具有强大的控制反转内置到框架中。在需要时可以通过其他 IoC 容器进行增强,但不是必需的。

第五章 - 实现设计模式 - .NET Core

  1. 如果不确定要使用哪种类型的服务生命周期,最好将类注册为哪种类型?为什么?

瞬态生命周期服务在每次请求时创建。大多数类应该是轻量级、无状态的服务,因此这是最好的服务生命周期。

  1. 在 .NET Core ASP .NET 解决方案中,范围是定义为每个 Web 请求还是每个会话?

一个范围是每个 Web 请求(连接)。

  1. 在 .NET Core DI 框架中将类注册为单例会使其线程安全吗?

不,框架将为后续请求提供相同的实例,但不会使类线程安全。

  1. .NET Core DI 框架只能被其他由微软提供的 DI 框架替换是真的吗?

是的,有许多 DI 框架可以用来替代原生 DI 框架。

第六章 - 实现 Web 应用程序的设计模式 - 第一部分

  1. 什么是 Web 应用程序?

这是一个使用 Web 浏览器的程序,如果在公共网络上可用,可以从任何地方访问。它基于客户端/服务器架构,通过接收 HTTP 请求并提供 HTTP 响应来为客户端提供服务。

  1. 制作您选择的 Web 应用程序,并描述 Web 应用程序的工作的图像。

参考 FlixOne 应用程序。

  1. 什么是控制反转?

控制反转(IoC)是一个容器,用于反转或委托控制。它基于 DI 框架。.NET Core 具有内置的 IoC 容器。

  1. 什么是 UI/架构模式?您想使用哪种模式,为什么?

UI 架构模式旨在创建强大的用户界面,以使用户更好地体验应用程序。从开发人员的角度来看,MVC、MVP 和 MVVM 是流行的模式。

第七章 - 实现 Web 应用程序的设计模式 - 第二部分

  1. 认证和授权是什么?

认证是一个系统通过凭据(通常是用户 ID 和密码)验证或识别传入请求的过程。如果系统发现提供的凭据错误,那么它会通知用户(通常通过 GUI 屏幕上的消息)并终止授权过程。

授权始终在认证之后。这是一个过程,允许经过验证的用户在验证他们对特定资源或数据的访问权限后访问资源或数据。

  1. 在请求的第一级使用认证,然后允许进入受限区域的传入请求安全吗?

这并不总是安全的。作为开发人员,我们应该采取一切必要的步骤,使我们的应用程序更安全。在第一级请求认证之后,系统还应检查资源级别的权限。

  1. 您将如何证明授权始终在认证之后?

在 Web 应用程序的简单场景中,它首先通过请求登录凭据来验证用户,然后根据角色授权用户访问特定资源。

  1. 什么是测试驱动开发,为什么开发人员关心它?

测试驱动开发是一种确保代码经过测试的方法;这就像通过编写代码来测试代码。TDD 也被称为红/蓝/绿概念。开发人员应该遵循它,使他们的代码/程序在没有任何错误的情况下工作。

  1. 定义 TDD Katas。它如何帮助我们改进我们的 TDD 方法?

TDD Katas 是帮助通过实践学习编码的小场景或问题。您可以参考 Fizz Buzz Kata 的例子,开发人员应该应用编码来学习和练习 TDD。如果您想练习 TDD Katas,请参考此存储库:github.com/garora/TDD-Katas.

第八章 – .NET Core 中的并发编程

  1. 什么是并发编程?

每当事情/任务同时发生时,我们说任务是同时发生的。在我们的编程语言中,每当程序的任何部分同时运行时,它就是并发编程。

  1. 真正的并行是如何发生的?

在单个 CPU 机器上不可能实现真正的并行,因为任务不可切换,它只在具有多个 CPU(多个核心)的机器上发生。

  1. 什么是竞争条件?

更多线程可以访问相同的共享数据并以不可预测的结果进行更新的潜力可以称为竞争条件。

  1. 为什么我们应该使用ConcurrentDictionary

并发字典是一个线程安全的集合类,它存储键值对。这个类有锁语句的实现,并提供了一个线程安全的类。

第九章 – 函数式编程实践 – 一种方法

  1. 什么是函数式编程?

函数式编程是一种符号计算的方法,就像我们解决数学问题一样。任何函数式编程都是基于数学函数的。任何函数式编程风格的语言都是通过两个术语来解决问题的:要解决什么和如何解决?

  1. 函数式编程中的引用透明度是什么?

在函数式程序中,一旦我们定义了变量,它们在整个程序中不会改变其值。由于函数式程序没有赋值语句,如果我们需要存储值,就没有其他选择;相反,我们定义新变量。

  1. 什么是Pure函数?

Pure函数是通过说它们是纯净的来加强函数式编程的函数。这些函数满足两个条件:

    • 提供的参数的最终结果/输出将始终保持不变。
  • 即使调用了一百次,这些都不会影响程序的行为或应用程序的执行路径。

第十章 – 响应式编程模式和技术

  1. 什么是流?

一系列事件称为流。流可以发出三种东西:一个值,一个错误和一个完成信号。

  1. 什么是响应式属性?

当事件触发时,响应式属性是会做出反应的绑定属性。

  1. 什么是响应式系统?

根据响应式宣言,我们可以得出结论,响应式系统如下:

    • 响应式:响应式系统是基于事件的设计系统,因此这种设计方法使得这些系统能够快速响应任何请求。
  • 可扩展:响应式系统具有响应性。这些系统可以通过扩展或减少分配的资源来对可扩展性进行调整。

  • 弹性:弹性系统是指即使出现任何故障/异常,也不会停止的系统。响应式系统是以这样的方式设计的,即使出现任何异常或故障,系统也不会死掉;它仍然在工作。

  • 基于消息:任何项目的数据都代表一条消息,可以发送到特定的目的地。当消息或数据到达给定状态时,会发出一个信号事件来通知消息已经被接收。响应式系统依赖于这种消息传递。

  1. 合并两个响应流是什么意思?

合并两个响应流实际上是将两个相似或不同的响应流的元素组合成一个新的响应流。例如,如果你有stream1stream2,那么stream3 = stream1.merge(stream2),但stream3的顺序不会按顺序排列。

  1. 什么是 MVVM 模式?

模型-视图-视图模型(MVVM)是模型-视图-控制器(MVC)的变体之一,以满足现代 UI 开发方法,其中 UI 开发是设计师/UI 开发人员的核心责任,而不是应用程序开发人员。在这种开发方法中,一个更注重图形的设计师专注于使用户界面更具吸引力,可能并不关心应用程序的开发部分。通常,设计师(UI 人员)使用各种工具使用户界面更具吸引力。MVVM 的定义如下:

    • 模型:也称为领域对象,它只保存数据;没有业务逻辑、验证等。
  • 视图:这是为最终用户表示数据。

  • 视图模型:这将视图和模型分开;它的主要责任是为最终用户提供更好的东西。

第十一章 - 高级数据库设计和应用技术

  1. 什么是分类账式数据库?

这个数据库只用于插入操作;没有更新。然后,你创建一个视图,将插入聚合在一起。

  1. 什么是 CQRS?

命令查询责任分离是一种将查询(插入)和命令(更新)之间的责任分离的模式。

  1. 何时使用 CQRS?

CQRS 可以是一个适用于基于任务或事件驱动系统的良好模式,特别是当解决方案由多个应用程序组成而不是单个单片网站或应用程序时。它是一种模式而不是一种架构,因此应该在特定情况下应用,而不是在所有业务场景中应用。

第十二章 - 云编码

  1. 这是一个真实的陈述吗?大多数模式是最近开发的,只适用于基于云的应用。

不,这不是真的。随着软件开发的变化,模式一直在不断发展,但许多核心模式已存在几十年。

  1. ESB 代表什么?它可以用于哪种架构:EDA、SOA 还是单片?

它代表企业服务总线。它可以有效地用于事件驱动架构和面向服务的架构。

  1. 基于队列的负载平衡主要用于 DevOps、可伸缩性还是可用性?

可用性。基于队列的负载平衡主要用于处理负载的大幅波动,作为缓冲以减少应用程序变得不可用的机会。

  1. CI/CD 的好处是什么?在全球分散团队的大量还是单个小型团队的共同开发人员中更有益?

一般来说,CI/CD 有助于通过频繁执行合并和部署来及早识别开发生命周期中的问题。更大、更复杂的解决方案往往比更小、更简单的解决方案更有益。

  1. 在遵循静态内容托管的网站中,浏览器是直接通过 CDN 检索图像和静态内容,还是 Web 应用程序代表浏览器检索信息?

内容交付网络可以通过在多个数据中心缓存静态资源来提高性能和可用性,从而使浏览器可以直接从最近的数据中心检索内容。

附录 A - 杂项最佳实践

  1. 什么是实践?从我们的日常生活中举几个例子。

练习可能是一个或多个日常活动。要学会开车,我们应该练习驾驶。练习是一种不需要记忆的活动。我们日常生活中有很多练习的例子:一边看电视节目一边吃饭,等等。在你观看最喜欢的电视节目时吃东西并不会打乱你的节奏。

  1. 我们可以通过练习来掌握特定的编码技能。解释一下。

是的,我们可以通过练习来掌握特定的编码技能。练习需要注意力和一贯性。例如,你想学习测试驱动开发。为了做到这一点,你需要先学会它。你可以通过练习 TDD-Katas 来学习它。

  1. 什么是测试驱动开发,它如何帮助开发者练习?

测试驱动开发是一种确保代码经过测试的方法;就好像我们通过编写代码来测试代码一样。TDD 也被称为红/蓝/绿概念。开发者应该遵循它,使他们的代码/程序能够在没有任何错误的情况下运行。

posted @ 2024-05-17 17:50  绝不原创的飞龙  阅读(7)  评论(0编辑  收藏  举报