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

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

原文:zh.annas-archive.org/md5/49ca655f3c4c7af7e500ba77019890ca

译者:飞龙

协议:CC BY-NC-SA 4.0

前言

本书的目的在于向读者提供一个对现代软件开发中模式的广泛理解,并通过具体的示例深入了解。在开发解决方案时使用的模式数量庞大,而且,开发者往往在使用模式时并不自知。本书涵盖了从底层代码到在云中运行的解决方案中使用的概念性高级模式。

虽然所展示的许多模式不需要特定的语言,但本书将使用 C# 和 .NET Core 来说明其中许多模式的示例。选择 C# 和 .NET Core 是因为它们的流行度和设计,这支持从简单的控制台应用程序到大型企业的分布式系统构建解决方案。

涵盖大量模式,本书为许多模式提供了一个很好的入门,同时允许对所选集合进行更深入、更实际的方法。所涵盖的具体模式是根据它们说明模式的具体点或方面而选择的。提供了额外的资源引用,以便读者可以深入了解特定兴趣的模式。

从简单的网站到大型企业的分布式系统,合适的模式可以在成功、长期可行的解决方案和因性能差和成本高而被视为失败的解决方案之间产生差异。本书涵盖了众多模式,这些模式可以应用于构建解决方案,以应对保持商业竞争力所需的不可避免的变化,并实现现代应用所期望的鲁棒性和可靠性。

本书面向的对象

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

本书涵盖的内容

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

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

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

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

第五章,实现设计模式 - .NET Core,通过探索.NET Core 提供的模式,在 3 章和 4 章的基础上进行扩展。包括依赖注入和工厂模式在内的几个模式将使用.NET Core 框架重新审视。

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

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

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

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

第十章,响应式编程模式和技巧,通过探索用于构建响应性和可扩展网站的响应式编程模式和技巧,继续构建 .NET Core 网络应用程序开发。在本章中,探讨了响应式编程的原则,包括 Reactive 和 IObservable 模式。还讨论了不同的框架,包括流行的 .NET Rx 扩展,以及 模型-视图-视图模型MVVM)模式的说明。

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

第十二章,云编程,探讨了应用于基于云的解决方案的应用程序开发,包括可扩展性、可用性、安全性、应用程序设计和 DevOps 的五个关键关注点。解释了在基于云的解决方案中使用的显著模式,包括不同类型的扩展,以及在事件驱动架构、联邦安全、缓存和遥测中使用的模式。

附录 A,杂项最佳实践,通过涵盖额外的模式和最佳实践来总结对模式的讨论。这包括关于用例建模、最佳实践以及额外的模式,如基于空间的架构和容器化应用程序的部分。

为了充分利用本书

本书假设读者对面向对象编程 (OOP) 和 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

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

警告或重要注意事项显示如下。

小贴士和技巧显示如下。

联系我们

我们欢迎读者提供反馈。

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

勘误:尽管我们已经尽最大努力确保内容的准确性,但错误仍然可能发生。如果您在这本书中发现了错误,如果您能向我们报告这一点,我们将不胜感激。请访问www.packt.com/submit-errata,选择您的书籍,点击勘误提交表单链接,并输入详细信息。

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

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

评论

请留下评论。一旦您阅读并使用过这本书,为何不在您购买它的网站上留下评论呢?潜在读者可以查看并使用您的客观意见来做出购买决定,我们 Packt 可以了解您对我们产品的看法,我们的作者也可以看到他们对书籍的反馈。谢谢!

如需了解更多关于 Packt 的信息,请访问packt.com

第一部分:C#和.NET Core 中设计模式的基础知识

在本节中,读者将获得对设计模式的新视角。我们将学习面向对象编程(OOP)、模式、实践和 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 版)

安装 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类有一个单一的所有者,最多有三个乘客;它由四个轮子组成:

继承使用基类底部的开放三角形,并通过实线显示。以下图显示了Account基类与CheckingAccountSavingsAccount子类之间的关系:

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

本节概述了本书中使用的模型。这种风格/方法被选择是因为,希望它对大多数读者来说将是熟悉的。

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

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

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

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

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

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

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

解释面向对象编程

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

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

"程序是一系列指令,指示语言编译器执行什么操作。"

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

为了更深入地理解 OOP,我们应该提及两种其他重要的编程方法:

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

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

过程调用

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

这两种方法的主要问题是,一旦程序变得庞大,程序就难以管理。更复杂和更大的代码库的程序会拉伸这两种方法,导致难以理解和维护的应用程序。为了克服这些问题,OOP 提供了以下特性:

  • 继承

  • 封装

  • 多态

在接下来的章节中,我们将更详细地讨论这些特性。

继承、封装和多态有时被称为 OOP 的三大支柱。

在我们开始之前,让我们讨论一些在面向对象编程(OOP)中常见的结构。

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

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

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。同样,狗和猫都有特定的行为,例如,狗会吠叫,猫会喵喵叫。

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

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

在面向对象的语言,如 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对象也是如此。

关联

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

以下是一些has-a关系类型的例子:

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

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

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

让我们通过扩展之前的宠物示例并引入一个PetOwner类来在 C#中说明这些概念。PetOwner类可以关联一个或多个PetAnimal实例。由于PetAnimal类可以有无所有者而存在,这种关系是聚合关系。PetAnimalPetColor相关联,在这个系统中,只有当它与PetAnimal相关联时,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#支持可以在项目上设置的不同的访问级别。项目可以是类、类的属性或方法,或枚举:

  • 公共:这表示可以在项目外部进行访问。

  • 私有:这表示只有对象可以访问项目。

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

  • 内部:这表示只有同一组件内的对象可以访问项目。

  • 受保护的内部:这表示只有对象(以及扩展了该类的对象)可以访问同一组件内的属性或方法。

在以下图中,对PetAnimal应用了访问修饰符:

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

多态

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

  • 静态或早期绑定:这种多态发生在应用程序编译时。

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

静态多态

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

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不会覆盖基类的行为,因为狗会吃KibbleFish。猫则更为挑剔,如下面的代码所示:

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接口。

摘要

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

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

问题

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

  1. 晚绑定和早绑定这两个术语指的是什么?

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

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

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

  5. 接口可以包含属性吗?(这是一个有点狡猾的问题。)

  6. 狗吃鱼吗?

第二章:现代软件开发模式和原则

在上一章中,为了探索不同的模式,讨论了面向对象编程OOP)。由于许多模式依赖于 OOP 中的概念,因此介绍和/或回顾这些概念很重要。类之间的继承使我们能够定义一个is-a-type-of关系。这提供了更高的抽象程度。例如,使用继承可以进行诸如catanimal类型和doganimal类型的比较。封装提供了一种控制类细节可见性和访问性的方法。多态提供了使用相同接口处理不同对象的能力。通过 OOP,可以达到更高的抽象层次,提供了一种更易于管理和理解的方式来处理大型解决方案。

本章对现代软件开发中使用的不同模式进行了目录化和介绍。本书对模式的概念采取了非常宽泛的看法。在软件开发中,模式是对软件开发人员在开发过程中遇到的一般问题的任何解决方案。它们是从经验中构建的,这些经验包括哪些有效和哪些无效。此外,这些解决方案在多种情况下由众多开发者进行了试验和测试。使用模式的好处基于过去的活动,既不重复努力,又确保问题在没有引入缺陷或问题的情况下得到解决。

尤其是在考虑特定技术模式时,模式太多,一本书难以涵盖,因此本章将突出特定的模式来展示不同类型的模式。我们根据经验挑选出了最常见和最有影响力的模式。在随后的章节中,将更详细地探讨特定模式。

本章将涵盖以下主题:

  • 设计原则,包括 SOLID

  • 模式目录,包括设计模式GoF)模式和企业集成模式EIP

  • 软件开发生命周期模式

  • 解决方案开发、云开发和服务开发的模式和惯例

技术要求

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

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

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

  • .NET Core

  • SQL Server(本章使用的是 Express 版本)

安装 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)简单地说,只有在需要时才应该添加功能。在软件开发中,有时有一种趋势,即为了防止设计发生变化而进行未来化设计。这可能会产生实际上目前或未来都不需要的需求:

“总是在你需要的时候实现事物,而不是仅仅预见你需要它们的时候。”

- 罗恩·杰弗里斯

MVP - 最小可行产品

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

在第三章,实现设计模式 - 基础部分 1中,将通过一个虚构场景来展示 MVP,其中将使用该技术来限制变更范围,并在设计和需求收集阶段帮助团队集中精力。

SOLID

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

在第三章,实现设计模式 - 基础部分 1中,将更详细地介绍 SOLID 原则,通过将其应用于 C#应用程序来展示。

单一职责原则

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

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

- 罗伯特·C·马丁

开放/封闭原则

开放/封闭原则最好用面向对象编程(OOP)来描述。一个类应该通过继承作为扩展其功能的方式来进行设计。换句话说,改变是在类设计时就已经计划和考虑的。通过定义和使用类实现的接口,应用了开放/封闭原则。类对于修改是开放的,而其描述,即接口,对于修改是封闭的。

李斯克夫替换原则

能够在运行时替换对象是李斯克夫替换原则的基础。在面向对象编程中,如果一个类从基类继承或实现了一个接口,那么它可以被引用为基类或接口的对象。这可以通过一个简单的例子来更容易地描述。

我们将定义一个动物的接口,并实现两个动物,Cat(猫)和Dog(狗),如下所示:

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

然后,我们可以将Cat(猫)和Dog(狗)作为动物来引用,如下所示:

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

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

接口隔离原则

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

依赖倒置原则

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

软件模式

多年来,许多模式被汇编成目录。本节将使用两个目录作为说明。第一个目录是GoF( Gang of Four,四人帮)收集的与面向对象编程相关的模式。第二个与系统和技术的集成相关,保持技术中立。本章末尾有一些关于其他目录和资源的参考文献。

GoF 模式

可能最有影响力和最著名的面向对象模式集合来自GoF(Gang of Four,四人帮)的《设计模式:可复用面向对象软件元素》一书。书中模式的目标是在较低层次上——即对象创建和交互——而不是更大的软件架构关注点。这个集合由可以应用于特定场景的模板组成,目标是产生坚实的构建块,同时避免面向对象开发中的常见陷阱。

Erich Gamma, John Vlissides, Richard HelmRalph Johnson 被称为 GoF,因为他们 90 年代广泛影响的出版物。这本书 设计模式:可复用面向对象软件元素 已被翻译成多种语言,并包含 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;
        }
    }
}

需要指出的一点是,一旦所有要求都已完成,Car被认为其服务已完成,这由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# 的 delegateevent 关键字。delegate 关键字定义了将被调用的格式或处理程序。当数量更新时将使用的代理如下所示:

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 关键字,但希望它能说明这种模式如何被使用。这里的优点是它松散地将主题与观察者耦合起来。主题不需要了解不同的观察者,甚至不需要知道是否存在任何观察者。

企业集成模式

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

企业集成模式》,作者Gregor HohpeBobby Woolf,为各种技术下的许多集成模式提供了一个坚实的资源。这本书在讨论 EIPs 时经常被引用。本书可在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转换到B和从C转换到D。随着应用数量的增加,以及发布者可能不知道其消费者的详细信息时,这变得越来越困难。使用 CDM,AB的源应用消息被转换为一个中立的模式 X。

规范模式

经典模式有时被称为中立模式,这意味着它没有直接与源或目标系统对齐。该模式被认为是中立的。

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

在企业中,如果没有一些标准,这将成为难以管理的情况,幸运的是,已经创建了众多组织来生产并管理许多行业的标准,以下是一些例子(但还有更多!):

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

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

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

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

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

路由

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

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

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

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

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

  • 分散-收集: 处理多个消息广播并将响应聚合为单个消息的模式。

分散-收集模式是一个非常实用的模式,因为它结合了分割模式和聚合模式,是一个很好的探索例子。使用这个模式,可以模拟更复杂的业务流程。

在我们的场景中,我们将考虑小工具订购系统的履行。好消息是,有几个供应商销售小工具,但小工具的价格经常波动。那么,哪个供应商的价格最好?使用分散-收集模式,订购系统可以查询多个供应商,选择最佳价格,然后将结果返回给调用系统。

下图将展示如何使用分割模式向供应商生成多个消息:

路由等待直到收到供应商的响应。一旦收到响应,就使用聚合器模式将结果编译成单个消息发送给调用应用程序:

图片

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

集成引擎是一种支持多种集成模式的软件。集成引擎的范围可以从本地安装的服务到基于云的解决方案。其中一些更受欢迎的引擎包括微软 BizTalk、戴尔 Boomi、MuleSoft Anypoint 平台、IBM WebSphere 和 SAS 商业智能。

软件开发生命周期模式

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

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

水平瀑布 SDLC

水平瀑布方法包括项目或工作按顺序经历的不同阶段。从概念上讲,它很容易理解,并且遵循其他行业使用的模式。以下是一个不同阶段的例子:

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

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

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

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

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

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

但如果事情没有按计划进行或发生变化怎么办?瀑布 SDLC 确实有一些缺点,其中许多缺点源于其缺乏对变化的灵活性,或者在某些情况下,需要从先前步骤中获得输入。在瀑布模型中,如果出现需要从先前阶段获取信息的情况,则重复先前的阶段。这会带来几个问题。由于阶段可能会被报告,因此报告变得困难,因为已经通过一个阶段或里程碑的项目现在正在重复该阶段。这可能会促进一种猎巫的公司文化,其中努力被转向寻找责任而不是预防重复问题的措施。此外,资源可能不再可用,因为它们已经被转移到其他项目上,或者员工已经离职。

以下图表说明了问题发现得越晚,成本和时间增加的情况:

由于变化相关的成本,瀑布软件开发生命周期(SDLC)通常适合较小、变更风险较低的项目。较大和更复杂的项目增加了变更的可能性,因为需求可能会改变,或者业务驱动因素在项目期间发生变化。

敏捷 SDLC

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

这是一个简化,但将项目分解为周期的方法与瀑布模型相比有几个优点:

  • 由于范围较小,业务需求变更的影响减小。

  • 与瀑布模型相比,利益相关者可以更早地获得一个可见的、可工作的系统。虽然还不完整,但这提供了价值,因为它允许在产品早期就纳入反馈。

  • 资源可能受益,因为资源类型的变化幅度较小。

以下图表提供了两种方法的总结。

摘要

在本章中,我们讨论了在现代软件开发中使用的、在前一章中引入的主要设计模式。我们首先讨论了各种软件开发原则,如 DRY、KISS、YAGNI、MVP 和 SOLID 编程原则。然后,我们涵盖了软件开发模式,包括 GoF 和 EIPs。本章还涵盖了 SDLC 的方法,包括瀑布和敏捷。本章的目的是说明模式在软件开发的所有级别是如何被使用的。

随着软件行业的成熟,随着经验的积累、技术的增长和技术的进步,模式逐渐出现。一些模式是为了帮助 SDLC 的不同阶段而开发的。例如,在第三章,“实现设计模式 - 基础部分 1”,将探讨测试驱动开发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

  • 抽象工厂模式(Gang of Four)

  • SOLID 原则

技术要求

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

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

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

  • .NET Core

  • SQL Server(本章使用的是 Express 版本)

安装 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/Chapter3

最小可行产品

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

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

该项目是一个假设情景,其中一家名为 FlixOne 的公司希望使用库存管理应用来管理其不断增长的书籍收藏。这个新应用将由员工用于管理库存,以及由客户用于浏览和创建新订单。该应用需要具备可扩展性,并且作为业务的关键系统,计划在未来可预见的时期内使用。

公司大致分为业务用户开发团队,其中业务用户主要关注系统的功能,而开发团队则关注满足需求,以及监控系统维护性。这是一个简化;然而,组织并不一定如此整洁地组织,个人可能不适合某一分类或另一分类。例如,业务分析师BA)或领域专家SME)通常既代表业务用户也是开发团队的一员。

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

需求

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

最小可行产品(MVP)

最小可行产品(Minimum Viable Product,简称 MVP)是指一个应用可以发布的最小功能集,它仍然具有足够的用户价值。

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

MVP 在需求复杂度不完全理解或最终愿景定义不明确的情况下非常有用。然而,仍然需要保持产品愿景,以避免开发最终版本可能不需要的功能的风险。

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

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

    • 应该打印一个包含汇编版本的欢迎信息。

    • 应该循环直到接收到退出命令。

    • 如果给定的命令不成功或未被理解,则应打印一条有用的信息。

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

  • 每个命令都应该有一个简短形式,即单个字符,以及一个长形式。

  • 如果命令有附加参数:

    • 每个命令都应该按顺序输入,并通过回车键提交。

    • 每个命令都应该有一个提示 Enter {parameter}:,其中 {parameter} 是参数的名称。

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

    • 打印可用命令的摘要。

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

  • 应该有一个退出命令(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 整合到单个敏捷周期中。在每个周期的开始,都会确定新的验收标准。这包括要交付的功能以及如何在开发周期结束时验证或接受这些功能。然后,这些验收标准将被用来向项目添加测试。开发团队将构建解决方案,直到新的和现有的测试都通过,然后准备一个用于验收测试的构建。然后,将运行验收测试,如果发现任何问题,开发团队将根据失败情况定义新的测试或修改现有的测试。应用程序将再次开发,直到所有测试通过并准备新的构建。这将重复进行,直到验收测试通过。然后,应用程序将被部署,并开始新的开发周期。

以下图表展示了这种方法:

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

设置项目

在此场景中,我们将使用微软单元测试MSTest)框架。本节提供了一些使用 .NET Core 命令行界面CLI)工具创建初始项目的说明。这些步骤也可以使用 Visual Studio 或 Visual Studio Code 等集成开发环境(IDE)完成。这里提供说明是为了说明如何使用 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

以下截图显示了正在恢复的控制台模板:

图片

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

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

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

在面向对象编程,特别是在 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抽象类。还值得注意的是,除了基类InventoryCommand之外的所有类型都不是公开的(对外部程序集可见)。这一点将在本章后面的访问修饰符部分变得相关。图中另一个微妙之处在于,所有类型,除了基类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类,我们可以减少我们与用户交互方式变化的影响。随着我们的解决方案的发展,我们可能会发现界面从控制台变为网络应用程序。理论上,我们将用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命令中,IParameterisedCommand是通过在基类InventoryCommand上定义的方法实现的:

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类中使用接口而不是具体类。最后,将实现IUserInterface的对象的引用传递到InventoryCommand类的构造函数中。这种方法保护了InventoryCommand类免受IUserInterface类实现细节变化的影响,同时也提供了一个机制,以便在代码库演变过程中更容易地替换不同的IUserInterface实现。

以下是用QuitCommand说明的 DIP,这是本章的最终类版本:

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作为Interface属性对所有InventoryCommand的实现可用。

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

辅助测试用户界面

作为对 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);
}

通过实现一个辅助类,我们可以提供 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() 调用来验证的。QuitCommand 的结果是通过检查 shouldQuitwasSuccessful 布尔值是否为真来验证的。

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

摘要

本章介绍了在线书店 FlixOne 想要构建一个用于管理其库存的应用程序的场景。本章涵盖了开发团队在开发应用程序时可以使用的一系列模式和最佳实践。团队使用 MVP 来帮助将初始交付的范围控制在可管理的水平,并帮助业务专注于确定对组织最有益的需求。团队决定使用 TDD 来验证交付是否符合需求,并帮助团队衡量进度。创建了基本项目和单元测试框架 MSTest。团队还使用了 SOLID 原则来帮助以有助于可读性和代码库维护的方式结构化代码,因为应用程序的新增强功能被添加。第一个四人帮模式,即抽象工厂设计模式,被用来为所有库存命令提供一个基础。

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

问题

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

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

  2. 水晶球软件开发与敏捷软件开发相比,有哪些优点和缺点?

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

  4. 为什么以下陈述是错误的?在使用 TDD(测试驱动开发)时,你不再需要人们来测试新的软件部署。

第四章:实现设计模式 - 基础部分 2

在上一章中,我们介绍了 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 版本)

安装 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/Chapter4)。

单例模式

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

当使用示例说明时,单例模式的值更为明显。本节将从基本类开始,然后继续识别单例模式解决的问题。这些问题将被识别,然后通过单元测试对类进行更新和验证。

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

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

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

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

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

  • 存储库模式

  • 竞态条件

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

进程和线程

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

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

多个线程访问同一共享数据并可能产生不可预测结果的潜在情况可以被称为竞态条件

为了避免数据被错误地更新,需要一些限制来防止多个线程同时执行相同的逻辑块。.Net 框架支持几种机制,在单例模式中,使用 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>();
}

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

再次运行单元测试后,引入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();
}

在软件行业中,关于 mocks、stubs、fakes 以及其他用于识别和/或分类测试中使用的、不适合生产但对于单元测试是必要的类型或服务的术语之间存在一些混淆。这些依赖项可能具有与其实际对应物不同的、缺失的或相同的功能。

例如,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 类确实有一个额外的参数,数量,它是作为 GetParameters 方法的一部分确定的。

最后,通过 InternalCommand 重写方法中的存储库的 UpdateQuantity 方法更新书籍的数量。

    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 测试方法验证当运行 InventoryCommandFactoryGetCommand 方法时,返回的对象是 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 的设计中内置了 依赖注入(Dependency Injection,DI)。通常,.NET Core 应用程序的开始包含为应用程序设置 DI,这主要涉及创建服务集合。框架使用这些服务在应用程序需要时提供依赖项。服务提供了强大 控制反转(Inversion of Control,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 的构造函数。

在注册服务时,可以指定不同类型的服务生命周期。生命周期决定了类型将被如何实例化,包括瞬态(Transient)、作用域(Scoped)和单例(Singleton)。瞬态意味着每次请求时都会创建服务。作用域将在我们查看与网站相关的模式时进行介绍,特别是当服务按每个 Web 请求创建时。单例的行为类似于我们之前讨论的单例模式,也将在本章后面进行介绍。

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方法基于团队在第三章[3a038a92-9207-4232-9acd-d17cb24da6c5.xhtml]《实现设计模式 – 基础部分 1》中之前的设计。它打印一个问候语,然后循环直到用户输入退出库存命令。每个循环将执行命令,如果命令未成功,它将打印一个帮助信息:

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实例,以利用.NET Core 的内置功能。

控制台应用程序

控制台应用程序在命令行中运行时很简单,但它是一个遵循第三章[3a038a92-9207-4232-9acd-d17cb24da6c5.xhtml]《实现设计模式 – 基础部分 1》中讨论的 SOLID 原则的良好设计的代码的基础。运行时,应用程序会提供一个简单的问候语并显示帮助信息,包括命令的支持和示例:

图片

应用程序随后会循环遍历命令,直到接收到退出命令。以下截图说明了其功能:

图片

这不是一个最令人印象深刻的控制台应用程序,但它有助于说明许多原理和模式。

摘要

与第三章,“实现设计模式 - 基础部分 1”类似,本章继续描述为 FlixOne 构建库存管理控制台应用程序,以展示使用面向对象编程OOP)设计模式的实际示例。在本章中,GoF 的单一模式和工厂模式是重点。这两个模式在.NET Core 应用程序中扮演着特别重要的角色,将在接下来的章节中经常使用。本章还介绍了如何使用内置框架提供 IoC 容器。

本章以一个基于第三章,“实现设计模式 - 基础部分 1”中确定的要求的库存管理控制台应用程序结束。这些要求是两章中创建的单元测试的基础,并用于说明测试驱动开发(TDD)。通过拥有一套验证此开发阶段所需功能的测试,团队对应用程序通过用户验收测试UAT)有更高的信心。

在下一章中,我们将继续描述构建库存管理应用程序的过程。重点将从基本的 OOP 模式转移到使用.NET Core 框架实现不同的模式。例如,本章中引入的单一模式将被重构以使用IServiceCollection的能力来创建单一实例,我们还将更详细地研究其依赖注入(DI)能力。此外,应用程序将扩展以支持使用各种日志提供程序进行日志记录。

问题

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

  1. 提供一个示例,说明为什么使用单一模式不是限制对共享资源访问的好机制。

  2. 以下陈述是否正确?为什么或为什么不正确?ConcurrentDictionary防止集合中的项目同时被多个线程更新。

  3. 什么是竞态条件,为什么应该避免它?

  4. 工厂模式如何帮助简化代码?

  5. .NET Core 应用程序是否需要第三方 IoC 容器?

第五章:实现设计模式 - .NET Core

上一章通过引入额外的模式继续构建 FlixOne 库存管理应用程序。使用了更多的设计模式,包括 Singleton 和工厂模式。Singleton 模式被用来展示用于维护 FlixOne 书籍集合的 Repository 模式。工厂模式被用来进一步探索依赖注入(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 服务器(本章中使用的是 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 中,对于收到的每个网络请求都会创建一个作用域。这意味着,如果依赖项具有作用域生命周期,那么它将仅在每次网络请求中构建一次,因此,如果同一依赖项在同一个网络请求中被多次使用,它将被共享。

在本章的后面部分,我们将显式创建一个作用域,以说明作用域生命周期,并且这个概念在单元测试中与在 ASP.Net Core 应用程序中相同。

单例

在.NET Core 中,单例模式实现的方式是依赖项仅被实例化一次,就像前一章中实现的单例模式一样。类似于前一章中的单例模式,singleton类需要是线程安全的,并且只有用于创建单例类的工厂方法可以保证由单个线程调用一次。

返回 FlixOne

为了说明.NET Core 的 DI,我们需要对 FlixOne 库存管理应用程序进行一些修改。首先要做的事情将是更新之前定义的InventoryContext类,以便不再实现单例模式(因为我们将使用.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字段仍然保留。类似于前一章中定义的单例模式,这仅保证了类的实例化方式;它并不能防止方法并行访问。

由于我们的依赖注入(DI)定义在外部项目中,因此IInventoryContext接口以及InventoryContextBook类都被设置为公开。

随后,用于返回命令的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类的单例:

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类时,InventoryContext的书籍列表都是空的。这就是为什么我们需要使用单例创建上下文的原因。

让我们更新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类。

为了能够引用.NET Core 的 DI 组件,需要将Microsoft.Extensions.DependencyInjection库添加到InventoryManagementTests项目中。

不幸的是,单元测试仍然没有通过,并导致相同的错误:给定的键不在字典中。这是因为每次请求IInventoryContext时,我们都在创建一个新的 DI 框架实例。这意味着尽管我们的依赖项是单例,但每个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 实现单例模式相对简单,如本节所示。理解何时使用该模式是一个重要的概念。下一节将更详细地探讨范围的概念,以便进一步了解服务生命周期。

范围

在具有多个同时执行进程的应用中,理解服务生命周期对于功能和非功能需求都非常重要。正如前一个单元测试所示,如果没有正确设置服务生命周期,InventoryContext 没有按预期工作,并导致了一个无效的状态。同样,服务生命周期的错误使用可能导致扩展性不佳的应用。一般来说,在多进程解决方案中应避免使用锁和共享状态。

为了说明这个概念,想象一下 FlixOne 库存管理应用被提供给多个员工。现在的挑战是如何在多个应用之间执行锁定,以及如何拥有一个单一收集的状态。用我们的术语来说,这将是多个应用共享的单个 InventoryContext 类。当然,这就是为什么将我们的解决方案改为使用共享存储库(例如,数据库)或改为 Web 应用程序是有意义的,并且/或者现在详细描述这些内容在 Web 应用程序中的意义。

下面的图示展示了一个 Web 应用接收两个请求:

图片

在服务生命周期的方面,Singleton 服务生命周期将适用于所有请求,而每个请求都会收到它自己的作用域生命周期。需要注意的是,关于垃圾回收。使用 Transient 服务生命周期创建的依赖项被标记为在对象不再被引用时释放,而使用 Scope 服务生命周期创建的依赖项则不会被标记为释放,直到 Web 请求完成。此外,使用 Singleton 服务生命周期创建的依赖项则不会被标记为释放,直到应用程序结束。

此外,正如以下图示所示,重要的是要记住,.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 方法始终评估为真:

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。Transient 服务将为每个请求创建一个已注册依赖项的新实例。Scoped 服务将根据定义的作用域生成一次,而 Singleton 服务将为 DI 服务集合的整个生命周期执行一次。

由于.Net Core DI 对于自信地构建.Net Core 应用程序至关重要,因此了解其功能和限制非常重要。有效地使用 DI 以及避免重复已提供的功能同样重要。同样关键的是,了解.Net Core DI 框架的局限性以及其他 DI 框架的优势也很明智,因为在某些情况下,用第三方 DI 框架替换基本的.Net Core DI 框架可能对应用程序有益。

下一章将在前几章的基础上构建,并探讨.Net Core ASP.Net web 应用程序中的常见模式。

问题

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

  1. 如果您不确定要使用哪种服务生命周期,哪种类型最适合注册一个类?为什么?

  2. 在.Net Core ASP.Net 解决方案中,Scope 是按每个 web 请求定义,还是按每个会话定义?

  3. 在.Net Core DI 框架中将一个类注册为 Singleton 是否使其线程安全?

  4. .Net Core DI 框架只能用其他 Microsoft 提供的 DI 框架替换,这是真的吗?

第六章:实施面向 Web 应用的设计模式 - 第一部分

在本章中,我们将继续构建 FlixOne 库存管理应用程序(参见 第三章,实施设计模式基础 - 第一部分),并将讨论将控制台应用程序转换为 Web 应用程序的过程。与控制台应用程序相比,Web 应用程序应该更吸引用户;在这里,我们还将讨论为什么我们要进行这种改变。

本章将涵盖以下主题:

  • 创建 .NET Core Web 应用程序

  • 构建一个 Web 应用程序

  • 实施 CRUD 页面

如果您尚未查看前面的章节,请注意,FlixOne 库存管理 Web 应用程序是一个虚构的产品。我们创建此应用程序是为了讨论在 Web 项目中所需的各个设计模式。

技术要求

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

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

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

  • .NET Core 环境设置

  • SQL Server(本章中使用的是 Express 版本)

安装 Visual Studio

要运行这些代码示例,您需要安装 Visual Studio(2017)或更高版本,例如 2019(或者您可以使用您首选的 IDE)。为此,请按照以下步骤操作:

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

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

设置 .NET Core

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

  1. 从:www.microsoft.com/net/download/windows 下载 .NET Core。

  2. 按照安装说明,并遵循相关的库:dotnet.microsoft.com/download/dotnet-core/2.2

安装 SQL Server

如果您尚未安装 SQL Server,您需要按照以下说明操作:

  1. 从:www.microsoft.com/en-in/download/details.aspx?id=1695 下载 SQL Server。

  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.

本节旨在提供开始使用 Web 应用程序所需的基本信息。我们将在后续章节中查看更多细节。在本章中,我们将使用代码示例来阐述各种术语和部分。

完整的源代码可在以下链接获取:github.com/PacktPublishing/Hands-On-Design-Patterns-with-C-and-.NET-Core/tree/master/Chapter6.

创建一个 .Net Core Web 应用程序

在本章的开头,我们讨论了我们的 FlixOne 基于控制台的应用程序,并且业务团队已经确定了采用 Web 应用程序的各种原因。现在是时候对应用程序进行更改了。在本节中,我们将开始创建一个具有新外观和感觉的现有 FlixOne 应用程序的新用户界面。我们还将讨论所有需求和初始化。

启动项目

在我们现有的 FlixOne 控制台应用程序的基础上,管理层决定用许多新特性来翻新我们的 FlixOne 库存控制台应用程序。管理层得出结论,我们必须将现有的控制台应用程序转换为基于 Web 的解决方案。

技术团队和业务团队坐下来讨论了为什么决定废弃当前控制台应用程序的多种原因:

  • 界面不交互。

  • 应用程序并非在所有地方都可用。

  • 维护起来很复杂。

  • 随着业务的增长,需要具有更高性能和适应性的可扩展系统。

开发需求

以下列表是讨论的结果,确定的高层次需求如下:

  • 产品分类

  • 产品添加

  • 产品更新

  • 产品删除

业务对开发者的实际需求包括以下技术要求:

  • 一个着陆页或主页:这应该是一个包含各种小部件的仪表板,并显示商店的摘要。

  • 一个产品页面:应该具有添加、更新和删除产品和类别的功能。

构建一个 Web 应用程序

根据刚刚讨论的需求,我们的主要目标是把现有的控制台应用程序转换为 Web 应用程序。在这个过程中,我们将讨论各种适用于 Web 应用程序的设计模式,以及这些设计模式在 Web 应用程序背景下的重要性。

Web 应用程序及其工作原理

网络应用是客户端-服务器架构的最佳实现之一。一个网络应用可以是一小段代码、一个程序,或者是一个针对问题或业务场景的完整解决方案,在这个场景中,用户可以通过浏览器相互交流或与服务器交流。网络应用通过浏览器提供请求和响应,主要通过使用超文本传输协议HTTP)来实现。

当客户端和服务器之间发生任何通信时,会发生两件事:客户端发起请求,服务器生成响应。这种通信由 HTTP 请求和 HTTP 响应组成。更多信息,请参阅www.w3schools.com/whatis/whatis_http.asp的文档。

在下面的图中,你可以看到网络应用的整体概述及其工作方式:

图片

从这张图中,你可以很容易地看出,使用浏览器(作为客户端),你为成千上万的用户打开了大门,他们可以从世界任何地方访问网站并与你互动。有了网络应用,你和你的客户可以轻松沟通。通常,只有在捕获并存储了所有必要信息的数据时,才能实现有效的互动,这些信息对于你的业务和用户都是必需的。然后,这些信息被处理,并将结果呈现给用户。

通常,网络应用结合使用服务器端代码来处理信息的存储和检索,以及客户端脚本向用户展示信息。

网络应用需要一个网络服务器(如IISApache)来管理来自客户端(从浏览器,如前图所示)的请求。还需要一个应用服务器(如 IIS 或 Apache Tomcat)来执行请求的任务。有时还需要数据库来存储信息。

简单来说,网络服务器和应用服务器都是为了提供 HTTP 内容而设计的,但存在某些差异。网络服务器提供静态 HTTP 内容,如 HTML 页面。应用服务器除了提供静态 HTTP 内容外,还可以使用不同的编程语言提供动态内容。更多信息,请参阅stackoverflow.com/questions/936197/what-is-the-difference-between-application-server-and-web-server

我们可以详细说明网络应用的流程如下。这些被称为网络应用的五步工作流程:

  1. 客户端(浏览器)通过 HTTP(在大多数情况下)在互联网上向网络服务器发送请求。这通常是通过网络浏览器或应用程序的用户界面完成的。

  2. 请求在 Web 服务器上产生,Web 服务器将请求转发到应用服务器(对于不同的请求,可能会有不同的应用服务器)。

  3. 在应用服务器中,完成请求的任务。这可能涉及查询数据库服务器,从数据库中检索信息,处理信息,并构建结果。

  4. 生成的结果(请求的信息或处理的数据)被发送到 Web 服务器。

  5. 最后,Web 服务器将带有请求信息的响应发送回请求者(客户端),这会显示在用户的显示设备上。

以下图表展示了这五个步骤的概览:

在以下章节中,我将描述使用 模型-视图-控制器MVC)模式的 Web 应用程序的工作流程。

编写 Web 应用程序代码

到目前为止,我们已经了解了需求,并查看我们的目标,即把控制台应用程序转换成基于 Web 的平台或应用程序。在本节中,我们将使用 Visual Studio 开发实际的 Web 应用程序。

按照以下步骤使用 Visual Studio 创建 Web 应用程序:

  1. 打开 Visual Studio 实例。

  2. 点击 File|New|Project 或按 Ctrl + Shift + N,如下面的截图所示:

  1. 从新建项目窗口,选择 Web|.NET Core|ASP.NET Core Web Application。

  2. 命名它(例如,FlixOne.Web),选择位置,然后您可以更新解决方案名称。默认情况下,解决方案名称将与项目名称相同。选中 Create directory for solution 复选框。您还可以选择选中 Create new Git repository 复选框(如果您想为这个项目创建一个新的仓库,您需要一个有效的 Git 账户)。

以下截图显示了创建新项目的流程:

  1. 下一步是选择适合您的 Web 应用程序的模板和 .NET Core 版本。我们不会为这个项目启用 Docker 支持,因为我们不会使用 Docker 作为容器来部署我们的应用程序。我们只会使用 HTTP 协议,而不是 HTTPS。因此,Enable Docker Support 和 Configure HTTPS 复选框应该保持未选中状态,如下面的截图所示:

现在我们有一个完整的项目,包括我们的模板和示例代码,使用 MVC 框架。以下截图显示了到目前为止的解决方案:

架构模式是在用户界面和应用本身的设计中实现最佳实践的一种方式。它们为我们提供了针对常见问题的可重用解决方案。这些模式还允许我们轻松实现关注点的分离。

最流行的架构模式如下:

  • 模型-视图-控制器MVC

  • 模型-视图-表示器MVP

  • 模型-视图-视图模型MVVM

您可以通过按 F5 来尝试运行应用程序。以下截图显示了 Web 应用程序的默认主页:

在接下来的部分中,我将讨论 MVC 模式并创建 CRUD创建更新删除)页面以与用户交互。

实现 CRUD 页面

在本节中,我们将开始创建用于创建、更新和删除产品的功能页面。要开始,打开您的 FlixOne 解决方案,并将以下类添加到指定的文件夹中:

Models: 在解决方案的 Models 文件夹中添加以下文件:

  • Product.cs: Product 类的代码片段如下:
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、完整的 DescriptionImagePrice 和唯一的 ID,以便我们的系统能够识别它。Product 类还包括一个属于此产品的 Category ID,以及 Category 的完整定义。

为什么我们应该定义一个 virtual 属性?

在我们的 Product 类中,我们定义了一个 virtual 属性。这是因为,在 Entity FrameworkEF)中,这个属性有助于为虚拟属性创建代理。这样,属性可以支持延迟加载和更有效的更改跟踪。这意味着数据是按需提供的。EF 在您请求使用 Category 属性时加载数据。

  • Category.cs: Category 类的代码片段如下:
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.cs: ProductViewModel 类的代码片段如下:
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,它具有诸如唯一的 ProductIdProductName、完整的 ProductDescriptionProductImageProductPrice、唯一的 CategoryIdCategoryName 和完整的 CategoryDescription 等属性。

Controllers: 将以下文件添加到解决方案的 Controllers 文件夹中:

  • ProductController 负责所有与产品相关的操作。让我们看看代码和我们在本控制器中试图实现的操作:
public class ProductController : Controller
{
    private readonly IInventoryRepositry _repositry;
    public ProductController(IInventoryRepositry inventoryRepositry) => _repositry = inventoryRepositry;

...
}

在这里,我们定义了继承自 Controller 类的 ProductController。我们使用了 依赖注入,这是 ASP.NET Core MVC 框架内置的支持。

我们在第五章中详细讨论了控制反转 - .Net Core;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操作方法。这是我们的CRUD页面中Category删除操作的最终操作,如下代码所示:

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

视图: 将以下视图添加到相应的文件夹中:

  • Index.cshtml

  • Create.cshtml

  • Edit.cshtml

  • Delete.cshtml

  • Details.cshtml

上下文: 将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 服务和测试驱动开发:

第七章:实现网络应用程序的设计模式 - 第二部分

在上一章中,我们在说明不同模式的同时,将 FlixOne 库存管理控制台应用程序扩展为网络应用程序。我们还涵盖了用户界面UI)架构模式,如模型-视图-控制器MVC)、模型视图演示者MVP)等。上一章旨在讨论如 MVC 这样的模式。我们现在需要扩展现有应用程序以包含更多模式。

在本章中,我们将继续使用现有的 FlixOne 网络应用程序,并通过编写代码来扩展应用程序,以查看身份验证和授权的实现。此外,我们还将讨论测试驱动开发TDD)。

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

  • 身份验证和授权

  • 创建 .NET Core 网络测试项目

技术要求

本章包含各种代码示例,用于解释概念。代码保持简单,仅用于演示目的。大多数示例涉及一个用 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 网络应用程序

在本章中,我们将继续我们的 FlixOne 库存应用程序。在本章中,我们将讨论网络应用程序模式,并扩展我们在上一章中开发的应用程序。

本章继续上一章开发的网络应用程序。如果您跳过了上一章,请重新阅读以与本章同步。

在本节中,我们将讨论需求收集的过程,然后讨论我们之前开发的网络应用程序所面临的各种挑战。

项目启动

在第六章,“实现网络应用程序的设计模式 – 第一部分”,我们扩展了我们的 FlixOne 库存控制台应用程序并开发了一个网络应用程序。在考虑以下要点后,我们扩展了应用程序:

  • 我们的业务需要一个丰富的用户界面。

  • 新的机会需要响应式网络应用程序。

需求

在与管理层、业务分析师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 应用程序,让我们添加一个用于受限访问的屏幕:产品屏幕。在本章中,我们不会讨论如何向现有项目添加新的控制器或视图。如果你想知道我们如何将这些添加到我们的项目中,请回顾第六章,实现 Web 应用程序的设计模式 – 第一部分

我们已经向我们的项目添加了新的功能来展示具有 CRUD 操作的产品。现在,按F5并检查输出:

图片

你将看到之前截图中的输出。你可能注意到我们现在有一个名为“产品”的新菜单。

让我们浏览一下新菜单选项。点击“产品”菜单:

图片

之前的截图显示了我们的产品页面。这个页面对所有用户开放,任何人都可以无限制地查看。你可以查看并观察这个页面具有创建新产品、编辑和删除现有产品的功能。现在,想象一下这样一个场景:一个未知用户进来并删除了一个非常重要且吸引大量销售的产品。你可以想象这个场景以及这对业务有多大的阻碍。甚至可能失去客户。

在我们的场景中,我们可以以两种方式保护我们的产品页面:

  • 预先认证:在本页面上,并非所有人都可以访问“产品”链接;它仅对经过认证的请求/用户可用。

  • 后认证:在本页面上,所有人都可访问“产品”链接。然而,一旦有人请求访问页面,系统将执行认证检查。

认证行动

在本节中,我们将了解如何实现认证并使我们的网页对未经认证的请求受限。

要实现认证,我们应该采用某种机制,为我们提供一种认证用户的方法。在一般情况下,如果用户已登录,这意味着他们已经经过认证。

在我们的 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是用户的有效电子邮件 ID。它应该是一个有效的电子邮件,因为我们将在注册过程之后/期间进行验证。

  • PasswordHashPasswordSalt是基于基于哈希的消息认证码,安全哈希算法HMAC-SHA)512 的字节数组。PasswordHash属性的值是 64 字节,而PasswordSalt是 128 字节。

  • SecretKey是 Base64 编码的字符串。

  • Mobilie是有效的手机号码,它依赖于系统的有效性检查。

  • EmailTokenOTP是随机生成的一次性密码OTPs),用于验证emailId和手机号码。

  • EmailTokenDateTimeOtpDateTimedatetime数据类型的属性;它们代表为用户签发EmailTokenOTP的日期和时间。

  • IsMobileVerifiedIsEmailverified是布尔值(true/false),用于告诉系统手机号码和/或电子邮件 ID 是否已验证。

  • IsActive是一个布尔值(true/false),用于告诉系统User模型是否处于活动状态。

  • Image是图像的 Base64 编码字符串。它代表用户的个人资料图片。

我们需要将我们的新类/实体添加到我们的Context类中。让我们添加以下截图中所看到的内容:

通过在我们的Context类中添加上一行,我们可以直接使用实体框架EF)功能访问我们的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,这些 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页面/视图。此页面提供了一个表单来输入登录详细信息。这些详细信息传送到我们的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 中,但仅当用户勾选了“记住我”复选框时。系统将记住当前计算机上的登录会话,直到用户点击注销按钮。

一旦用户点击登录按钮,系统就会验证他们的登录详细信息,并将他们重定向到主页,如下面的截图所示:

你可能会在菜单中观察到文本,如Welcome 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);。这调用来自AuthManagerLogin方法:

如果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作为主键。

  • NameRole的字符串类型名称。

  • ShortName:角色的简短或缩写名称,为字符串类型。

我们需要将我们的新类/实体添加到我们的Context类中。让我们按照以下方式添加:

之前的代码提供了使用 EF 执行各种数据库操作的能力:

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

我们仅对具有AdminManager角色的用户限制ProductController。现在,尝试使用用户aroraG登录,你将看到如下截图所示的Product Listing

现在,让我们尝试使用第二个用户aroraG1登录,该用户具有Editor角色。这将抛出AccessDenied错误。有关此内容,请参阅以下截图:

以这种方式,我们可以保护我们的受限资源。有许多方法可以实现这一点。.NET Core MVC 提供了内置功能来实现这一点,你也可以以可定制的方式做到这一点。如果你不想使用这些可用的内置功能,你可以很容易地通过添加到现有代码中来制定所需功能的自定义功能。如果你想这样做,你需要从头开始。此外,如果某些功能可用,那么再次创建类似功能就没有意义了。如果你找不到可用组件的功能,那么你应该定制现有的功能/特性,而不是从头编写整个代码。

开发者应该实现一个无法被篡改的认证机制。在本节中,我们讨论了很多与认证和授权、编写代码以及创建我们的 Web 应用程序相关的内容。关于认证,我们应该使用一个好的认证机制,以确保没有人可以篡改或绕过它。你可以从以下两个设计开始:

  • 认证过滤器

  • 认证单个请求/端点

在实施前几步之后,任何通过任何模式传入的请求都应该在系统响应用户或发起调用的客户端之前进行认证和授权。这个过程主要包括以下内容:

  • 保密性:安全系统确保任何敏感数据不会被未认证和未经授权的访问请求暴露。

  • 可用性:系统中的安全措施确保系统对经过系统认证和授权的真正用户可用。

  • 完整性:在一个安全系统中,数据篡改是不可能的,因此数据是安全的。

创建 Web 测试项目

单元测试是检查代码健康状况的一种方法。这意味着如果代码有 bug(不健康),那么这将是应用程序中许多未知和不受欢迎问题的根源。为了克服这种方法,我们可以遵循 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 测试项目时需要注意的重要点如下:

  • 事实 是一个属性,用于没有参数的正常测试方法。

  • 理论 是一个属性,用于参数化测试方法。

  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 和 Parallel 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 版)

安装 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下载.NET Core for 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

现实世界中的并发

并发是我们生活的一部分:它在现实世界中存在。当我们讨论并发时,我们指的是多任务处理。

在现实世界中,我们中的许多人经常进行多任务处理。例如,我们可以在打电话的同时编写程序,我们可以在吃饭的同时看电影,我们可以在阅读乐谱的同时唱歌。有很多例子说明我们作为人类可以进行多任务处理。不必深入科学细节,我们可以将我们的大脑试图理解新事物的同时指挥身体的其他器官工作,如心脏或我们的嗅觉,视为一种多任务处理的形式。

同样的方法适用于我们的系统(计算机)。如果我们考虑今天的计算机,每台可用的计算机都有一个多核心的 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/Await –为什么阻塞是坏的?

异步编程在期望同时进行各种活动的情况下非常有用。使用async关键字,我们定义我们的方法/操作为异步。考虑以下代码片段:

internal class AsyncAwait
{
    public async Task ShowMessage()
    {
        Console.WriteLine("\tServing messages!");
        await Task.Delay(1000);
    }
}

在这里,我们有一个AsyncAwait类,它有一个async方法,ShowMessage()。这个方法只是简单地打印出将在控制台窗口中显示的消息。现在,无论何时我们在其他代码中调用/消费这个方法,该部分的代码可能会等待/保持/阻塞操作,直到ShowMessage()方法执行并完成任务。请参见以下快照:

我们之前的截图显示,我们已经为ShowMessage()方法设置了 1,000 毫秒的延迟。在这里,我们指示程序在 1,000 毫秒后完成。如果我们尝试从之前的代码中移除await,Visual Studio 将立即给出警告,要求将await放回;请参见以下快照:

await操作符的帮助下,我们使用了非阻塞的 API 调用。运行程序并查看以下输出:

我们将得到前面快照中显示的输出。

并发集合

.NET Core 框架提供了各种集合,我们可以使用 LINQ 查询。作为一个开发者,在寻找线程安全集合时,可用的选项要少得多。如果没有线程安全集合,当开发者必须执行多个操作时,可能会变得困难。在这种情况下,我们将遇到我们已经在第四章,“实现设计模式 - 基础部分 2”中讨论过的竞争条件。为了克服这种情况,我们需要使用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 和 Parallel 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);
}

上述是一个简单的ForEach循环,使用了Parallel关键字。在上述代码中,我们只是在迭代一个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

LINQ 在.NET Core 诞生之前就在.NET 世界中存在很长时间了。LINQ-to-Objects允许我们通过使用任意对象的序列来执行内存查询操作。LINQ-to-ObjectsIEnumerable<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# 语言来体验函数式编程,并更深入地探讨如何利用 C# 在 .NET Core 中进行函数式编程的概念。本章的目的是帮助您了解函数式编程是什么,以及我们如何使用 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 服务器(本章使用的是 Express 版本)

安装 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 年,Microsoft 发布了 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 函数都符合成为 Pure 函数的标准;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: 这代表产品的名称。

  • 价格: 这包含产品的实际价格。实际价格是在任何折扣、税费等之前的。

  • 折扣: 这包含折扣的百分比,例如 10 或 3. 合法的折扣率不应为负数,等于零,或超过 100%(换句话说,不应超过产品的实际成本)。

  • 数量: 这包含任何折扣、税费等之后的商品价值。

现在,让我们回到我们的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网络应用程序。高级需求如下:

  • 分页实现:目前,所有页面列表都没有分页。通过滚动屏幕上下查看具有大量页面计数的项目非常具有挑战性。

  • 折扣率:目前,没有提供添加或查看产品各种折扣率的规定。折扣率的业务规则如下:

    • 一个产品可以有多个折扣率。

    • 一个产品只能有一个有效的折扣率。

    • 有效的折扣率不应为负值,且不应超过 100%。

返回 FlixOne

在上一节中,我们讨论了增强应用程序所需的内容。在本节中,我们将实现这些要求。让我们首先回顾一下我们项目的文件结构。看看下面的快照:

图片

之前的快照展示了我们的 FlixOne 网络应用程序,其文件夹结构如下:

  • wwwroot:这是包含静态内容(如 CSS 和 jQuery 文件)的文件夹,这些内容对于 UI 项目是必需的。这个文件夹包含 Visual Studio 提供的默认模板。

  • 通用:这包含所有与业务规则相关的通用文件和操作。

  • 上下文:这包含 InventoryContext,它是一个 DBContext 类,提供了 Entity Framework Core 功能。

  • 控制器:这包含我们 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 部分讨论了 控制反转 部分。

考虑以下代码,其中我们通过利用控制反转(Inversion of control)使用 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());
    }
}

运行应用程序,从主屏幕点击产品,然后点击产品折扣列表。从这里,您将看到以下屏幕:

图片

前面的快照描述了所有可用产品的产品折扣列表。产品折扣列表有很多记录,因此需要向上或向下滚动以在屏幕上查看项目。为了处理这种困难的情况,我们应该实现分页。

策略模式和函数式编程

在本书的前四章中,我们讨论了很多模式和最佳实践。策略模式是四人帮GoF)模式中的重要模式之一。这属于行为模式类别,也被称为策略模式。这是一个通常需要通过类来实现的模式。这也是使用函数式编程实现起来较为简单的一个模式。

回到本章的理解函数式编程部分,重新考虑函数式编程的范式。高阶函数是函数式编程的重要范式之一;使用它,我们可以轻松以函数式的方式实现策略模式。

高阶函数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 提供者。

库存应用程序将通过两种主要方式扩展:通过响应变化,并讨论 模型-视图-视图模型MVVM)模式。

本章将涵盖以下主题:

  • 反应式编程的原则

  • 反应式和 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 版本)

安装 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 符号表示在流合并或数据映射操作期间发生了错误。最后,|符号表示流(或操作)已完成。

使用反应式编程来保持反应性

显然,我们之前讨论的计算属性(discussed in the previous section)不能是反应性的或代表反应式编程。反应式编程有特定的设计和技术。要体验反应式编程或保持反应性,你可以从reactivex.io/提供的文档开始,通过阅读反应式宣言(www.reactivemanifesto.org/)来体验它。

简而言之,反应式属性是在事件触发时做出反应的绑定属性。

现在,当我们处理各种大型系统/应用程序时,我们发现它们太大,无法一次性处理。这些大型系统被分割或组合成更小的系统。这些较小的单元/系统依赖于反应式属性。为了遵循反应式编程,反应式系统应用设计原则,以便这些属性可以应用于所有方法。借助这种设计/方法,我们可以创建一个可组合的系统。

根据宣言,反应式编程和反应式系统都是不同的。

在反应式宣言的基础上,我们可以得出结论,反应式系统如下:

  • 响应性:反应式系统是基于事件的系统设计;这些系统能够快速响应任何请求,并在短时间内做出反应。

  • 可伸缩性:反应式系统在本质上具有反应性。这些系统可以通过扩展或减少分配的资源来响应可伸缩率的改变。

  • 弹性:一个弹性的系统是指即使在出现故障/异常的情况下也不会停止的系统。反应式系统被设计成这样,在任何异常或故障发生时,系统永远不会死亡;它仍然在运行。

  • 基于消息的:任何数据项都代表可以发送到特定目的地的消息。当一个消息或数据项到达给定状态时,事件会发出信号通知订阅者消息已到达。反应式系统依赖于这种消息传递。

以下图表展示了反应式系统的图示:

图片

在这个图表中,反应式系统由小型系统组成,这些系统具有弹性、可扩展性、响应性和基于消息的特性。

反应式流的实际应用

到目前为止,我们已经讨论了反应式编程是一个数据流的事实。在前面的章节中,我们也讨论了流的工作方式和这些流如何及时地传输。我们看到了事件的一个例子,并讨论了反应程序中的数据流。现在,让我们继续使用相同的例子,看看两个流如何通过不同的操作工作。

在下一个例子中,我们有两个整数数据类型集合的反应式流。请注意,在本节中我们使用伪代码来解释行为以及这些数据流集合的工作方式。

以下图表表示两个可观察的流。第一个流Observer1包含数字124,而第二个流Observer2包含数字35

图片

合并两个流涉及将它们的序列元素组合成一个新的流。以下图表显示了当Observer1Observer2合并时产生的新流:

图片

上述图表仅表示流的一种形式,并不是流中元素序列的实际表示。在这个图表中,我们看到元素(数字)的顺序是12345,但在现实例子中并不一定如此。序列可能有所不同;它可能是12435,或者任何其他顺序。

过滤流就像跳过元素/记录。你可以想象 LINQ 中的Where子句,它看起来像这样:myCollection.Where(num => num <= 3);

以下图表展示了我们试图选择满足特定条件的元素的准则图示:

图片

我们正在过滤我们的流,只选择那些<=3的元素。这意味着我们跳过了元素45。在这种情况下,我们可以说过滤器是用来跳过元素或匹配特定条件的。

要理解映射流,你可以想象任何数学运算,其中你会计算序列或通过添加一些常数来增加数字。例如,如果我们有一个整数值为 3,并且我们的映射流是 +3,这意味着我们正在计算一个序列为 3 + 3 = 6。你还可以将此与 LINQ 和选择以及投影的输出相关联,如下所示:return myCollection.Select(num => num+3);

下面的图表表示流的映射:

在应用条件 <= 3 的过滤器之后,我们的流包含元素 123。此外,我们还对过滤后的流(包含元素 123)应用了 Map (+3),最终我们的流包含元素 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方法正在将数据流的两个序列合并到_observable中。Merge操作使用以下代码启动:

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 方法。我们创建这个方法来展示模式的力量。这个方法只是简单地检查有效的折扣率。如果 discount rate 无效,根据标准/条件,这个方法会抛出一个异常,并将产品名称与无效的 discount rate 一起共享。

前一个方法根据标准验证折扣率,并在标准失败时向订阅者发送有关抛出异常的通知。看看迭代块(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()方法。

下面的代码片段显示了SubscribeUnsubscribe方法:

public virtual void Subscribe(IObservable<Product> provider)
{
    if (provider != null)
        _unsubscriber = provider.Subscribe(this);
}

private void Unsubscribe() => _unsubscriber.Dispose();

前两种方法告诉系统存在一个提供者。订阅者可以订阅该提供者,或者在操作完成后取消订阅/处理它。

现在是时候展示我们的实现了,看看一些好的结果。要做到这一点,我们需要对我们的现有Product Listing页面进行一些更改,并为我们项目添加一个新视图页面。

将以下链接添加到我们的Index.cshtml页面,以便我们可以看到查看审计报告的新链接:

<a asp-action="Report">Audit Report</a>

在前面的代码片段中,我们添加了一个新的链接来显示基于我们实现的Report Action方法的审计报告,该方法是我们在ProductConstroller类中定义的。

添加此代码后,我们的产品列表页面将如下所示:

首先,让我们讨论一下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文件添加到视图 | 产品。以下代码是报告页面的部分。您可以在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 运行项目,然后在主页面上点击产品,再点击审计报告链接来运行项目。从这里,您将看到我们选定产品的审计报告,如下面的截图所示:

截图

上一张截图显示了一个简单的列表页面,显示了来自 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 应用程序模式并扩展我们在 第四章,实现设计模式 - 基础部分 2 中开发的 Web 应用程序。

本章将继续探讨上一章中讨论的网页应用。如果您跳过了上一章(第九章,函数式编程实践),请重新阅读以跟上本章的内容。

在本节中,我们将讨论需求收集的过程,然后讨论我们之前开发的网页应用在开发和业务方面所面临的挑战。

开始项目

在第七章,为网页应用实现设计模式 - 第二部分中,我们在我们的 FlixOne 库存网页应用中添加了功能。在考虑以下要点后,我们扩展了该应用:

  • 业务需要丰富的用户界面。

  • 新的机会需要响应式网页应用。

需求

经过与管理部门、业务分析师BA)和预销售人员的多次会议和讨论后,该组织的管理部门决定着手以下高级需求。

业务需求

我们的业务团队列出了以下需求:

  • 项目过滤:目前,用户无法按类别过滤项目。为了扩展列表视图功能,用户应该能够根据其相应的类别过滤产品项目。

  • 项目排序:目前,项目以它们被添加到数据库中的顺序显示。没有机制允许用户根据项目的名称、价格等对项目进行排序。

FlixOne 库存管理网页应用是一个虚构的产品。我们创建此应用是为了讨论在网页项目中需要/使用的各种设计模式。

使用过滤器、分页和排序获取库存

根据我们的业务需求,我们需要在我们的 FlixOne 库存应用中应用过滤器、分页和排序。首先,让我们开始实现排序。为此,我创建了一个项目并将此项目放在FlixOneWebExtended文件夹中。启动 Visual Studio 并打开 FlixOne 解决方案。我们将对这些列应用排序:CategoryproductNameDescriptionPrice。请注意,我们不会使用任何外部组件进行排序,但我们将创建自己的登录。

打开解决方案资源管理器,打开Controllers文件夹中的ProductController。将[FromQuery]Sort sort参数添加到Index方法中。请注意,[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 参数包含空值,则将其值设置为空,然后通过在 sort.ColName.ToLower() 中使用 switch..case 来处理它。

以下是我们 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 } 是我们实现 SortOrder 的主要方式。

运行应用程序,您将看到以下带有排序功能的 Product Listing 页面的快照:

现在,打开 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 应用程序扩展的先前的讨论中,我们应用了排序筛选;现在我们需要添加分页。为了做到这一点,我们添加了一个名为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 应用程序的特性。本节的目标是让您亲身体验一个实际运行的应用程序。我们的应用程序代码编写得可以直接应用于现实世界。通过前面的增强,我们的应用程序现在能够提供可排序、分页和筛选的产品列表。

模式和实践 – MVVM

在第六章,实现 Web 应用程序的设计模式 - 第一部分中,我们讨论了MVC模式并创建了一个基于此的应用程序。

Ken Cooper 和 Ted Peters 是 MVVM 模式发明的背后名字。在发明这个模式的时候,Ken 和 Ted 都是微软公司的架构师。他们创建这个模式是为了简化事件驱动编程的 UI。后来,这个模式在Windows Presentation FoundationWPF)和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 可以通过简单的 HTML、CSS 等实现,使用 WPF 或 Silverlight 的丰富控件。

Microsoft Silverlight 是一个帮助开发具有丰富 UI 的应用的框架。许多开发者将其视为 Adobe Flash 的替代品。2015 年 7 月,微软宣布将不再支持 Silverlight。微软在其 Build 大会上宣布了对 .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 模式可以通过其各种组件进行详细阐述如下:

  • 模型:存储数据,不关心应用中的任何业务逻辑。我更喜欢将其称为领域对象,因为它持有我们正在工作的应用的实际数据。换句话说,我们可以这样说,模型不负责使数据变得美观。例如,在我们 FlixOne 应用的产品模型中,产品模型持有各种属性的值,并通过名称、描述、类别名称、价格等来描述产品。这些属性包含产品的实际数据,但模型不负责对任何数据进行行为上的更改。例如,我们的产品模型格式化产品描述以在 UI 上看起来完美并不是其责任。另一方面,我们中的许多模型包含验证和其他计算属性。主要挑战是维护一个纯净和清洁的模型,这意味着模型应该类似于现实世界的模型。在我们的案例中,我们的 product 模型被称为清洁模型。一个清洁模型是类似于真实产品的实际属性的模型。例如,如果 Product 模型存储水果的数据,那么它应该显示如水果的颜色等属性。以下代码来自我们想象中的应用的一个模型:
export class Product {
  name: string;
  cat: string; 
  desc: string;
}

注意,前面的代码是用 Angular 编写的。我们将在下一节中详细讨论 Angular 代码,即实现 MVVM

  • 视图:这是为最终用户通过 UI 访问的数据表示。它简单地显示数据的值,这个值可能已经格式化,也可能没有。例如,我们可以在 UI 上显示折扣率为 18%,而在模型中它会被存储为 18.00。视图还可以负责行为变化。视图接受用户输入;例如,可能会有一个视图提供一个表单/屏幕来添加新产品。此外,视图可以管理用户输入,如按键、检测关键词等。它也可以是主动视图或被动视图。接受用户输入并根据用户输入操作数据模型(属性)的视图是主动视图。被动视图是没有任何操作的视图。换句话说,与模型不关联的视图是被动视图,这种视图由控制器操作。

  • 视图模型:它作为视图和模型之间的中间人工作。其责任是使展示更佳。在我们之前的例子中,视图显示折扣率为 18%,但模型中的折扣率为 18.00,视图模型的职责是将 18.00 格式化为 18%,以便视图可以显示格式化后的折扣率。

如果我们将所有讨论的点结合起来,我们可以可视化整个 MVVM 模式,如下面的图所示:

图片

上述图是 MVVM 的图形视图,它显示视图模型视图模型分开。视图模型还维护状态执行操作。这有助于视图向最终用户展示最终输出。视图是 UI,它获取数据并向最终用户展示。在下一节中,我们将使用 Angular 实现 MVVM 模式。

MVVM 实现

在上一节中,我们了解了 MVVM 模式是什么以及它是如何工作的。在本节中,我们将使用我们的 FlixOne 应用程序并使用 Angular 构建一个应用程序。为了演示 MVVM 模式,我们将使用基于 ASP.NET Core 2.2 构建的 API。

启动 Visual Studio 并从FlixOneMVVM文件夹中打开 FlixOne 解决方案。运行FlixOne.API项目,您将看到以下 Swagger 文档页面:

图片

上述截图是我们产品 API 文档的快照,其中我们集成了 Swagger 用于 API 文档。如果您愿意,您可以从这个屏幕测试 API。如果 API 返回结果,那么您的项目已成功设置。如果没有,请检查此项目的先决条件,并检查 Git 仓库中此章节的README.md文件。我们拥有构建新 UI 所需的一切;如前所述,我们将创建一个 Angular 应用程序来消费我们的产品 API。要开始,请按照以下步骤操作:

  1. 打开“解决方案资源管理器”。

  2. 右键单击 FlixOne 解决方案。

  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 安装有多种选项。在这里,我们使用 Windows 版本的 Visual Studio。

设置.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 Web 应用程序的总体情况:

  • 我们应用程序的一部分是库存管理,但另一部分是电子商务 Web 应用程序。

  • 挑战在于我们的应用程序还将作为销售点POS)。在这一部分/模块中,用户可以从线下柜台/网点支付他们所购买的物品。

  • 对于库存部分,我们需要确定我们将采取哪种方法来计算和维护账户和交易,以及确定任何售出物品的成本。

  • 为了维护库存,有多种选择,其中最常用的两种选项是先进先出FIFO)和后进先出LIFO)。

  • 大多数交易涉及财务数据,因此这些交易需要历史数据。每个记录都应该包含以下信息:当前价值,当前变化前的价值,以及所做的更改。

  • 当我们在维护库存时,我们还必须维护所购买的物品。

在为任何电子商务 Web 应用程序设计数据库时,还有更多重要的要点。我们正在限制 FlixOne 应用程序的范围,以便展示库存和库存管理。

数据库处理

与我们在本书中涵盖的其他主题类似,数据库的种类繁多,从涉及数据库模式的基本模式到规范数据库系统如何组合的模式。本节将涵盖两个系统模式,在线事务处理OLTP)和在线分析处理OLAP)。为了进一步了解数据库设计模式,我们将更详细地探讨一个特定模式,即账簿式数据库。

数据库模式是表格、视图、存储过程和其他组成数据库的组件的集合的另一种说法。将其视为数据库的蓝图

OLTP

OLTP 数据库已被设计来处理大量导致数据库变化的语句。基本上,INSERTUPDATEDELETE语句都会导致变化,并且与SELECT语句的行为非常不同。OLTP 数据库正是基于这一点设计的。因为这些数据库记录变化,它们通常是主要数据库,这意味着它们是存储当前数据的存储库。

MERGE语句也符合导致变化的语句的资格。这是因为它提供了一个方便的语法,用于在不存在行时插入记录,在存在行时更新记录。当存在行时,它将进行更新。MERGE语句并不在所有数据库提供程序或版本中得到支持。

OLTP 数据库通常设计为快速处理变化语句。这通常是通过仔细规划表结构来完成的。简单来说,可以考虑数据库表。这个表可以包含用于存储数据的字段、用于高效查找数据的键、指向其他表的索引、用于响应特定情况的触发器以及其他表结构。这些结构中的每一个都有性能惩罚。因此,OLTP 数据库的设计是在表上使用最少数量的结构与其所需行为之间取得平衡。

让我们考虑一个记录我们库存系统中书籍的表。每本书可能记录名称、数量、出版日期,并包含作者信息、出版商和其他相关表的引用。我们可以在所有列上放置索引,甚至为相关表中的数据添加索引。这种方法的问题在于,每个索引都必须为每个导致变化的语句存储和维护。数据库设计者必须仔细规划和分析数据库,以确定添加索引和其他表结构的最佳组合,同样重要的是,不添加索引和其他结构。

可以将表索引视为一个虚拟查找表,它为关系数据库提供了一种更快的数据查找方式。

OLAP

使用 OLAP 模式设计的数据库预计会有比导致变化的语句更多的SELECT语句。这些数据库通常有一个或多个数据库数据的综合视图。正因为如此,这些数据库通常不是主数据库,而是一个用于提供与主数据库分离的报表和分析的数据库。在某些情况下,这种部署是在与其他数据库隔离的基础设施上提供的,以避免影响操作数据库的性能。这种部署通常被称为数据仓库

数据仓库可以用来提供一个企业内部系统或系统集合的统一视图。数据传统上通过较慢的周期性作业从其他系统刷新数据,但现代数据库系统正趋向于接近实时地合并。

OLTP 和 OLAP 之间的主要区别在于数据的存储和组织方式。在许多情况下,这需要创建表或持久视图——取决于所使用的技术——以支持特定的报告场景并复制数据。在 OLTP 数据库中,数据复制是不希望的,因为它会引入需要维护的多个表,而这些表只针对一个引起变化的单个语句。

账簿式数据库

账簿式数据库设计将被突出显示,因为它是一种在许多金融数据库中使用了几十年的模式,并且可能一些开发者并不知道。账簿式数据库源于会计的账簿,其中交易被添加到文件中,数量和/或金额被总计,以便得出最终的数量或金额。以下表格显示了苹果销售的账簿:

图片

关于示例,有几件事情需要指出。购买者信息是单独写在不同的行上,而不是删除它们的金额并输入新的金额。考虑两个购买和一个对西乡村产品的信用。这通常与许多数据库不同,在许多数据库中,一个单独的行包含购买者信息,有单独的字段用于金额和价格。

账簿式数据库通过为每笔交易保留单独的行来采用这个概念,从而消除了UPDATEDELETE语句,并且只依赖于INSERT语句。这有几个好处。类似于账簿,一旦每笔交易被写入,就不能被删除或更改。如果发生错误或更改,例如对西乡村产品的贷方,就需要写入一笔新的交易,以便达到期望的状态。这个有趣的好处是,源表现在立即具有提供详细活动日志的价值。如果我们添加一个修改者列,我们就可以有一个全面的日志,记录谁或什么导致了更改以及更改的内容。

这个例子是一个单条目账簿,但在现实世界中,会使用双条目账簿。区别在于在双条目账簿中,每一笔交易都记录在一个表中的贷方和另一个表中的借方。

下一个挑战是捕获表的最终或汇总版本。在这个例子中,就是购买了多少苹果以及价格。第一种方法可以使用一个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)。他在 1980 年代末在其著作《面向对象软件构造》中提到了这个术语: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 网关:所有来自 UI 和响应到 UI 的请求都由 API 网关传递。这是 CQRS 的主要部分,因为可以通过使用命令和持久层来集成业务逻辑。

  • 数据库(s):该图显示了两个物理上分离的数据库。在实际应用中,这取决于产品的需求,你可以使用数据库进行写操作和读操作。

  • 查询是通过Read操作生成的,这些操作是数据传输对象DTOs)。

您现在可以回到用例部分,其中我们讨论了我们的 FlixOne 库存应用的新功能/扩展。在本节中,我们将使用 CQRS 模式创建一个具有先前讨论的功能的新 FlixOne 应用。请注意,我们将首先开发 API。如果您没有安装先决条件,我建议重新查看技术要求部分,收集所有必需的软件,并将它们安装到您的机器上。如果您已完成先决条件,那么让我们按照以下步骤开始:

  1. 打开 Visual Studio。

  2. 点击文件 | 新建项目以创建一个新的项目。

  3. 在新建项目窗口中,选择 Web,然后选择 ASP.NET Core Web 应用。

  4. 给您的项目起一个名字。我已将我们的项目命名为 FlixOne.API 并确保解决方案名称为 FlixOne

  5. 选择您的 解决方案 文件夹的位置,然后单击以下截图所示的 OK 按钮:

图片

  1. 现在,您应该在新 ASP.NET Web 核心应用程序 - FlixOne.API 界面上。确保在此界面上,您选择 ASP.NET Core 2.2。从可用的模板中选择 Web 应用程序(模型-视图-控制器),并取消选中配置 HTTPS 复选框,如以下截图所示:

图片

  1. 您将看到一个默认页面出现,如以下截图所示:

图片

  1. 展开解决方案资源管理器并单击显示所有文件。您将看到 Visual Studio 创建的默认文件夹/文件。参考以下截图:

图片

我们选择了 ASP.NET Core Web (模型-视图-控制器) 模板。因此,我们有默认的文件夹,控制器、模型和视图。这是一个 Visual Studio 提供的默认模板。要检查此默认模板,按 F5 运行项目。然后,您将看到以下默认页面:

图片

上述截图是我们 Web 应用程序的默认主页。您可能会想 这是一个网站吗? 并期望在这里看到 API 文档页面而不是网页。这是因为,当我们选择模板时,Visual Studio 默认添加 MVC 控制器而不是 API 控制器。请注意,在 ASP.NET Core 中,MVC 控制器和 API 控制器使用相同的控制器管道(请参阅控制器类:docs.microsoft.com/en-us/dotnet/api/microsoft.aspnetcore.mvc.controller?view=aspnetcore-2.2)。

在详细讨论 API 项目之前,让我们首先向我们的 FlixOne 解决方案添加一个新的项目。为此,展开解决方案资源管理器,右键单击解决方案名称,然后单击添加新项目。参考以下截图:

图片

在新项目窗口中,添加新的 FlixOne.CQRS 项目,然后单击 OK 按钮。参考以下截图:

图片

上述截图是新项目窗口。在它上面,选择 .NET Core,然后选择类库(.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为假,则此消息包含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 App Service 允许扩展 App Service 的大小和 App Service 实例的数量。

可扩展性可以被视为对有限数量资源的需求。资源可以是磁盘空间、RAM、带宽或软件的另一个可量化的方面。需求可以从用户数量、并发连接或其他会对资源产生约束的需求中产生。随着需求的增加,应用将承受压力以提供资源。当压力影响应用性能时,这被称为资源瓶颈。

例如,一个衡量标准可能是应用性能开始下降之前可以访问应用的用户数量。性能可以设定为请求的平均延迟小于 2 秒。随着用户数量的增加,可以观察系统负载,并确定影响性能的具体资源瓶颈。

工作负载

为了确定如何有效地解决可扩展性问题,了解系统将承受的工作负载非常重要。有四种主要类型的工作负载:静态、周期性、一生一次和不可预测的。

静态工作负载表示系统上的活动水平保持恒定。由于工作负载没有波动,这类系统不需要非常灵活的基础设施。

具有可预测工作负载变化系统的周期性工作负载。一个例子是周末或收入税到期月份活动激增的系统。这些系统可以在负载增加时扩展以维持所需的质量水平,在负载减少时缩减以节省成本。

一生一次的工作负载表明围绕特定事件设计的系统。这些系统被配置来处理事件周围的工作负载,一旦不再需要就取消配置。

不可预测的工作负载通常可以从前面提到的自动扩展功能中受益。这些系统活动的大幅波动可能尚未被业务理解,或受到其他因素的影响。

理解和设计基于云的应用程序以适应其工作负载类型对于保持高性能以及降低成本至关重要。

解决方案模式

我们有三种设计模式和一种架构模式可供选择,以使我们能够为系统添加可伸缩性:

  • 垂直扩展

  • 水平扩展

  • 自动扩展

  • 微服务

让我们更详细地回顾一下。

垂直扩展

虽然可以在本地服务器上添加物理 RAM 或额外的磁盘驱动器,但大多数云服务提供商支持轻松增加或减少系统计算能力的能力。这种扩展通常在系统扩展时几乎没有或没有停机时间。这种类型的扩展称为垂直扩展,指的是当 CPU 类型、RAM 的大小和质量或磁盘的大小和质量等资源发生改变时。

垂直扩展通常被称为向上扩展,而水平扩展通常被称为向外扩展。在这个上下文中,术语向上指的是资源的大小,而向外指的是实例的数量。

水平扩展

水平扩展与垂直扩展的不同之处在于,它不是改变系统的大小,而是改变涉及的系统数量。例如,一个 Web 应用程序可能运行在一个拥有 4 GB RAM 和 2 个 CPU 的单个服务器上。如果将服务器的容量增加到 8 GB RAM 和 4 个 CPU,那么这将是垂直扩展。然而,如果添加了两个配置相同的 4 GB RAM 和 2 个 CPU 的服务器,那么这将是水平扩展。

水平扩展可以通过使用某种形式的负载均衡来实现,该负载均衡将请求重定向到系统集合中,如下面的图示所示:

在云解决方案中,水平扩展通常比垂直扩展更受欢迎。这是因为,一般来说,使用几个较小的虚拟机而不是单个大型服务器来提供相同性能的度量更经济有效。

为了使水平扩展最有效,确实需要一种支持此类扩展的系统设计。例如,没有粘性会话和/或服务器上存储状态的 Web 应用程序更适合水平扩展。这是因为粘性会话会导致用户的请求被路由到同一虚拟机进行处理,随着时间的推移,虚拟机之间的路由平衡可能会变得不均匀,因此可能不是最有效的。

有状态应用程序

一个 有状态 的应用程序在服务器或存储库上维护有关活动会话的信息。

无状态应用程序

无状态 应用程序设计为不需要在服务器或存储库中存储有关活动会话的信息。这允许在单个会话中的后续请求可以发送到任何服务器进行处理,而不仅仅是整个会话期间发送到同一服务器。

设计为有状态的 Web 应用程序需要在共享存储库中维护会话或信息。无状态 Web 应用程序支持更健壮的模式,因为任何服务器在 Web 花园或 Web 农场中。这允许 Web 应用程序的单个节点失败而不会丢失会话信息。

一个 花园 式的 Web 应用程序是指多个相同的 Web 应用程序在同一服务器上托管,而一个 农场 式的 Web 应用程序是指多个相同的 Web 应用程序在不同的服务器上托管。在这两种模式中,路由都用于将多个副本暴露出来,就像它们是一个单一的应用程序一样。

自动扩展

使用云服务提供商而不是本地解决方案的优势在于内置的自动扩展支持。作为水平扩展的额外好处,自动扩展应用程序的能力通常是云服务的一个可配置功能。例如,Azure App Service 提供了设置自动扩展配置文件的能力,允许应用程序对条件做出反应。例如,以下截图显示了一个自动扩展配置文件:

为工作日设计的配置文件将根据服务器的负载增加或减少应用程序实例的数量。负载是通过 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 正确地验证消费者并提供关于消费者或声明的准确详情。关于消费者的这些信息以令牌的形式展示。这种场景的一个常见例子是使用社交 IdP(如 Google、Facebook 或 Microsoft)的 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),它能够将内容分布到多个数据中心,并将请求导向最接近调用者的数据中心,如下面的图所示:

图片

静态内容托管是网络应用中的一种常见模式,其中从网络应用请求一个动态页面,该页面包含一系列静态内容,如 JavaScript 和图像,浏览器随后直接从 CDN 检索这些内容。这是一种有效减少网络应用流量的方法。

命令和查询责任分离

命令和查询责任分离CQRS)是一个值得详细讨论的优秀软件模式,因为它在概念上简单,相对容易实现,但对应用程序和涉及的开发者都有重大影响。该模式明确地将影响应用程序状态的操作命令与仅检索数据的查询分离。简单来说,如更新、添加和删除等命令由不同的服务提供,而查询则不改变任何数据。

你可能会说 再次 CQRS!我们认识到我们已经在一个面向对象编程和数据库设计中使用了 CQRS 的例子。同样的原则也适用于软件开发的其他许多领域。我们在这个部分将 CQRS 作为服务设计模式提出,因为它带来了一些有趣的好处,并且与现代模式如微服务和反应式应用程序设计很好地结合。

CQRS 基于伯特朗·迈耶在 20 世纪 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?

以下是使用 CQRS 的三个有力因素:

  • 协作

  • 模型分离

  • 独立可伸缩性

通过独立的服务,我们可以然后独立维护、部署和扩展这些服务。这增加了我们可以在开发团队之间实现的合作水平。

通过拥有独立的服务,我们可以使用最适合我们服务模式的模型。命令服务可能直接使用简单的 SQL 语句对数据库进行操作,因为这是负责团队最熟悉的技术,而构建查询服务的团队可能使用框架来处理对数据库的复杂语句。

大多数解决方案的读取操作往往比写入操作多(或反之亦然),因此根据这一标准划分服务在许多场景中是有意义的。

DevOps

在基于云的解决方案中,数据中心是远程托管,你通常无法完全控制或访问应用程序的所有方面。在某些情况下,例如无服务器服务,基础设施被抽象化。应用程序仍然需要暴露有关运行中的应用程序的信息,这些信息可用于管理和监控应用程序。用于管理和监控的模式对于应用程序的成功至关重要,因为它既提供了保持应用程序健康运行的能力,又为业务提供了战略信息。

解决方案模式

随着与监控和管理解决方案相关的商业软件包的可用性,许多企业已经获得了对其分布式系统更好的控制和理解。遥测和持续交付/持续集成被选择进行更详细的介绍,因为它们在基于云的解决方案中具有特殊价值。

遥测

随着软件行业的演变和分布式系统涉及更多服务和应用程序,能够对系统有一个集体和一致的观点已经成为一项巨大的资产。由 New Relic 和微软应用洞察等服务普及的应用性能管理APM)系统使用有关应用程序和基础设施记录的信息,称为遥测,以监控、管理性能和查看系统的可用性。在基于云的解决方案中,通常无法或实际无法直接访问系统的基础设施,APM 允许将遥测发送到中央服务,进行处理,然后如图所示呈现给运维和业务:

图片

上述图表摘自微软应用洞察,提供了一个正在运行的 Web 应用程序的高级快照。一眼望去,运维人员可以识别系统行为的变更并据此做出反应。

持续集成/持续部署

持续集成/持续部署CI/CD)是一种现代开发流程,旨在通过频繁合并更改和经常部署这些更改来简化软件交付产品生命周期SDLC)。CI 解决了企业级软件开发中可能出现的问题,其中多个程序员正在同一个代码库上工作,或者当单个产品使用多个代码分支管理时。

看一下以下图表:

在前面的示例中,有三个目标环境:开发、用户验收测试UAT)和产品。开发环境是所有对应用程序所做的更改一起测试的初始环境。UAT 环境由质量保证QA)团队使用,以验证系统在更改移动到面向客户的环境(在图中称为产品)之前按预期工作。代码库已被分成三个匹配的分支:主干,所有开发团队的更改都合并到其中;UAT,用于部署到 UAT 环境;以及产品代码库,用于部署到产品环境。

CI 模式通过在代码库更改时创建新的构建来应用。在构建成功后,会运行一系列单元测试来确保现有功能没有被破坏。如果构建不成功,开发团队将进行调查,并修复代码库或单元测试,以便构建通过。

然后将成功的构建推送到目标环境。主干可能被设置为每天自动将新构建推送到集成环境,而 QA 团队要求减少对环境的干扰,因此新构建仅在下班后每周推送一次。产品可能需要手动触发来协调新版本发布,以便正式发布中宣布新功能和错误修复。

对于持续部署持续交付这两个术语存在混淆。许多资料将这两个术语区分开来,以确定部署过程是自动的还是手动的。换句话说,持续部署需要自动化的持续交付。

导致环境合并并因此将构建推送到环境或发布的触发器可能不同。在我们的开发环境示例中,我们有一套针对新构建自动运行的自动化测试。如果测试成功,则从主干分支自动合并到 UAT 代码库。UAT 和产品代码库之间的合并只有在 QA 团队签署或接受 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 图

在上一节中,我们通过符号表示讨论了登录功能。您可能已经注意到了图中使用的符号。前面图中使用的记法或符号是统一建模语言(Unified Modeling Language)的一部分。这是一种可视化我们的程序、软件甚至类的方法。

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

之前的代码片段包含一个具有三个Add方法的Math类。这些方法被编写来计算两个整数和两个浮点数及十进制数的和。Add(float a, float b)Add(decimal a, decimal b)方法是Add(int a, int b)的重载方法。之前的代码示例代表了一个要求实现一个方法,该方法可以输出 int、float 或 decimal 数据类型的场景。

  • 单元测试:当我们通过编写代码来测试我们的代码时,这是开发的一个基本部分。测试驱动开发TDD)是应该遵循的最佳实践之一。我们在第七章,“实现 Web 应用程序的设计模式 - 第二部分”中讨论了 TDD。

  • 代码一致性:如今,开发者独自工作的机会非常罕见。开发者大多在团队中工作,这意味着在整个团队中保持代码一致性非常重要。代码一致性可以指代码风格。有一些推荐的实践和编码约定,开发者在编写程序时应经常使用。

声明变量的方式有很多。以下是变量声明的一个最佳示例:

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)。这并不是世界的尽头,世界上还有更多东西要学习。

我们可以列出更多模式:

  • 基于空间的架构模式基于空间的模式SBPs)是通过最小化限制应用程序扩展的因素来帮助应用程序可扩展性的模式。这些模式也被称为云架构模式。我们在第十二章,为云编码中讨论了许多这样的模式。

  • 消息模式:这些模式用于根据消息(以数据包的形式发送)连接两个应用程序。这些数据包或消息通过一个逻辑路径进行传输,各种应用程序通过这个逻辑路径连接(这些逻辑路径被称为通道)。可能存在一种情况,其中一个应用程序有多个消息;在这种情况下,不是所有消息都可以一次性发送。在存在多个消息的情况下,一个通道可以被称为队列,多个消息可以在通道中排队,并且可以在同一时间从不同的应用程序访问。

  • 领域驱动设计(Domain-Driven Design)的附加模式—分层架构:这描绘了关注点的分离,其中分层架构的概念就出现了。幕后,开发应用程序的基本想法是将其结构化为概念层。一般来说,应用程序有四个概念层:

    • 用户界面层:这一层包含了所有终端用户交互的部分,这一层接受命令并相应地提供信息。

    • 应用层:这一层更偏向于事务管理、数据转换等。

    • 领域层:这一层坚持领域的行为和状态。

    • 基础设施层:所有与存储库、适配器和框架相关的事情都发生在这里。

  • 容器化应用程序模式:在我们深入探讨之前,我们应该知道什么是容器。容器是一种轻量级、可移植的软件;它定义了软件可以运行的环境。通常,运行在容器内的软件被设计为单用途应用程序。对于容器化应用程序来说,最重要的模式如下:

    • Docker 镜像构建模式:这个模式基于 GoF 设计模式中的 Builder 模式,我们在第三章,实现设计模式-基础部分 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 框架中将类注册为 Singleton 会使它线程安全吗?

不,框架将为后续请求提供相同的实例,但不会使类成为线程安全的。

  1. .NET Core DI 框架是否只能用其他 Microsoft 提供的 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 Katas 为例,开发者应该应用编码来学习和实践 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 是什么?

命令查询责任分离(Command Query Responsibility Segregation)是一种模式,它将查询(用于插入)和命令(用于更新)之间的责任进行分离。

  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 @ 2025-10-23 15:06  绝不原创的飞龙  阅读(1)  评论(0)    收藏  举报