ASP-NET-Core5-典型设计模式指南-全-

ASP.NET Core5 典型设计模式指南(全)

原文:An Atypical ASP.NET Core 5 Design Patterns Guide

协议:CC BY-NC-SA 4.0

零、序言

设计模式是软件开发中许多常见问题的一组解决方案。它们对于任何有经验的开发人员和专业人员来说都是必不可少的,他们可以设计任何规模的软件解决方案。

我们从探索基本设计模式、体系结构原则、依赖项注入和其他 ASP.NET Core 机制开始。然后,我们探索面向小块软件的组件级模式。接下来,我们将讨论应用规模的模式和技术,在这里我们将探讨更高级别的模式以及如何将应用作为一个整体进行结构。本书涵盖了许多不可避免的 GoF 模式,如策略、单例、装饰、外观和复合。这些章节是根据规模和主题组织的,允许你从一个坚实的基础开始,然后在基础上慢慢构建,就像构建一个程序一样。本书中的许多用例结合了多个设计模式来显示替代用法。它还表明,设计模式是需要使用的工具,而不是需要担心的复杂概念。最后,我们处理客户端连接点,并使 ASP.NET Core 成为可行的全堆栈替代方案。

在本书的结尾,您将能够混合和匹配设计模式,并将学习如何思考架构。这本书是学习工艺背后的推理的旅程。

这本书是给谁的

这本书是为中间软件和 web 开发人员准备的,他们对.NET 有一定的了解,希望编写灵活、可维护和健壮的代码来构建可伸缩的 web 应用。这本书假定了解 C#编程和理解 HTTP 等 web 概念。

这本书涵盖的内容

第 1 节,原则和方法

本节包含本书的基础:单元测试和 xUnit 概述,坚实的原则,以及关于如何设计软件的一些理论和示例。

第 1 章对.NET 的介绍,包含了本书的先决条件和工作原理,以及一些对软件开发人员有用的重要主题。

第 2 章测试 ASP.NET Core 应用,向您介绍单元测试的基础知识和 xUnit 测试框架,以及一些帮助编写单元测试的良好实践和方法。

第 3 章架构原则为本书中使用的关键原则奠定了架构基础,对于任何试图编写“可靠代码”的工程师来说,这些原则都是极其重要的

第 2 节,ASP.NET Core 设计

本节介绍 ASP.NET Core 特定主题,包括模型-视图-控制器(MVC)、视图模型、DTO 和一些经典设计模式。我们还深入研究依赖注入,并探索 ASP.NET Core 中某些模式作为现代软件工程支柱的演变用法。

第 4 章使用 Razor 的 MVC 模式,介绍使用 Razor 和 ASP.NET Core MVC 渲染视图的模型视图控制器和视图模型设计模式。

第 5 章Web API 的 MVC 模式将带领您进一步了解 ASP.NET Core MVC 之旅,重点关注 Web API。我们探讨了数据传输对象(DTO)模式和 API 契约。

第 6 章理解策略、抽象工厂和单例设计模式,向您介绍三种基本的四人组(GoF)设计模式的传统实现:策略、抽象工厂和单例。

第 7 章深入依赖注入带着 ASP.NET Core 依赖注入容器畅游,向您介绍现代软件开发最重要的方面之一。本章将 ASP.NET Core 与坚实的原则联系起来。一旦确定了依赖注入的基础,我们将回顾前面的三种 GoF 设计模式,并使用依赖注入重新审视它们,从而为构建可测试、灵活和可靠的软件开辟道路。

第 8 章选项和日志模式以 ASP.NET Core 相关主题为切入点进行挖掘。我们涵盖了不同的选项模式和提供给我们的抽象。我们还探讨了如何利用.NET5 中的登录功能。

第 3 节,按组件规模设计

本节重点介绍组件设计,我们将研究如何精心设计一个软件片段以实现特定目标。我们将探索更多的 GoF 模式,这些模式将帮助我们设计可靠的数据结构和组件,并通过将逻辑封装在更小的单元中简化代码的复杂性。

第 9 章结构模式向您介绍了四种新的 GoF 结构设计模式和一些变体,如透明立面和不透明立面。它还向您介绍了 Scrutor,这是一个开源项目,增加了对 decorator 依赖注入的支持。

第 10 章行为模式介绍了两种 GoF 行为设计模式,并将它们混合在一起作为对代码样本设计的最终改进而得出结论。

第 11 章理解操作结果设计模式,涵盖了操作结果设计模式的多个变体,构建了一个结果对象,使其承载的不仅仅是一个简单的结果。

第 4 节,按应用规模设计

本节向前迈进了一步,介绍了应用设计和分层、垂直切片和微服务。我们概述每种技术,确保您知道如何开始。我们还介绍了不同的组件级模式,这些模式有助于将这些体系结构样式组合在一起。

第 12 章理解分层向您介绍分层和清洁体系结构,涵盖表示层、域层、数据(持久性)层及其清洁体系结构对应层背后的主要目标,这是分层的最高点。它还强调了在过去几十年中发生的应用设计的演变,帮助您了解它从何处开始(本章的开头)和走向何处(本章的结尾)。

第 13 章对象映射器入门涵盖了对象映射(即,将一个对象复制到另一个对象),也称为翻译器模式、映射器模式和实体翻译器。本章最后介绍了 AutoMapper,一个开源库,帮助我们自动涵盖最常见的场景。

第 14 章中介和 CQRS 设计模式介绍了命令查询责任分离(CQRS)和中介模式。在介绍了这两种模式之后,我们将探索一个名为 MediatR 的开源工具,它是许多后续主题的基础。

第 15 章垂直切片架构入门,介绍垂直切片架构。它使用了我们之前探索过的许多模式和工具,以一种不同的方式组合在一起来查看应用的设计。它还引入了 FluentValidation,它被添加到 MediatR 和 AutoMapper 中。

第 16 章微服务体系结构介绍,介绍微服务、微服务是什么、微服务不是什么,并讨论一些相关模式。这是一个理论章节,介绍了许多概念,如消息队列、事件、发布-订阅和网关模式。我们还重新访问了云级别的 CQR。在本章的最后,我们将探讨容器的基础知识。

第 5 节,客户端设计

本节介绍了在开发 ASP.NET Core 5 应用时可以使用的多种 UI 模式,如 Blazor、Razor 页面和各种类型的组件。它概述了 ASP.NET Core 5 在用户界面方面提供的功能,如果您感兴趣,还将介绍其他学习途径。

第 17 章ASP.NET Core 用户界面探讨了 ASP.NET Core 5 中可供我们使用的大部分 UI 元素,如剃须刀页面、局部视图、标记帮助器、视图组件、显示模板和编辑器模板。我们还探讨了多个 C#9 特性,例如仅限 init 的属性和记录类。

第 18 章简要介绍 Blazor,快速接触 Blazor 服务器,然后探索 Blazor WebAssembly(Wasm)来完成我们的旅程,并将 C#/.NET 转换为其他 JavaScript 技术的完整堆栈替代方案。我们将探讨 Razor 组件和模型视图更新设计模式。在本章结尾,我们将介绍一些你可以开始挖掘的可能性。

充分利用这本书

你必须知道 C#和如何编程。应掌握布尔逻辑、循环和其他基本编程结构,包括面向对象编程基础知识。对 ASP.NET 有一定的了解将是有益的。了解如何阅读 UML 类图和序列图是一项资产,但不是必需的。

代码示例和资源可在 GitHub(上获得 https://net5.link/code )。存储库根目录下的README.md文件中充满了帮助您找到所需代码和资源的信息。如果你找不到任何东西,请查看README.md文件——很可能你会找到指向你所寻找信息的指针。

大多数链接都以 https://net5.link/****因此,物理副本的读者可以轻松快速地键入 URL。

在本书中,我混合使用了 VisualStudio2019(有免费版本)和 VisualStudio 代码(免费)。我建议您使用其中一个或两个。IDE 与大部分内容无关。如果你够冲动的话,你可以用记事本(我不建议这样)。除非安装.NET SDK 附带的 Visual Studio,否则可能需要安装.NET 5 SDK。SDK 附带了dotnetCLI 以及用于运行和测试程序的构建工具。我在 Windows 上开发,但您应该能够使用其他操作系统。与操作系统相关的主题非常有限,甚至不存在。该代码可以在 Windows 和 Linux 上编译。

.NET 5 支持的 Linux:https://github.com/dotnet/core/blob/master/release-notes/5.0/5.0-supported-os.md

如果您使用的是本书的数字版本,我们建议您自己键入代码或通过 GitHub 存储库访问代码(下一节提供链接)。这样做将帮助您避免与复制和粘贴代码相关的任何潜在错误。

下载示例代码文件

您可以从 GitHub 的下载本书的示例代码文件 https://net5.link/code 。如果代码有更新,它将在现有 GitHub 存储库中更新。

我们的丰富书籍和视频目录中还有其他代码包,请访问https://github.com/PacktPublishing/ 。看看他们!

使用的约定

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

Code in text:表示文本中的码字、数据库表名、文件夹名、文件名、文件扩展名、路径名、虚拟 URL、用户输入和 Twitter 句柄。下面是一个示例:“将下载的WebStorm-10*.dmg磁盘映像文件作为系统中的另一个磁盘装载。”

代码块设置如下:

html, body, #map {
 height: 100%; 
 margin: 0;
 padding: 0
}

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

[default]
exten => s,1,Dial(Zap/1|30)
exten => s,2,Voicemail(u100)
exten => s,102,Voicemail(b100)
exten => i,1,Voicemail(s0)

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

$ mkdir css
$ cd css

粗体:表示一个新术语、一个重要单词或您在屏幕上看到的单词。例如,菜单或对话框中的单词出现在文本中,如下所示。下面是一个示例:“从管理面板中选择系统信息

提示或重要提示

看起来像这样。

联系

我们欢迎读者的反馈。

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

勘误表:尽管我们已尽一切努力确保内容的准确性,但还是会出现错误。如果您在本书中发现错误,如果您能向我们报告,我们将不胜感激。请访问www.packtpub.com/support/errata,选择您的书籍,单击 errata 提交表单链接,然后输入详细信息。

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

如果您有兴趣成为一名作家:如果您对某个主题有专业知识,并且您有兴趣撰写或贡献一本书,请访问authors.packtpub.com

审查

请留下评论。一旦你阅读并使用了这本书,为什么不在你购买它的网站上留下评论呢?然后,潜在读者可以看到并使用您的无偏见意见做出购买决定,我们 Packt 可以了解您对我们产品的看法,我们的作者可以看到您对他们书籍的反馈。非常感谢。

有关 Packt 的更多信息,请访问Packt.com

一、.NET 介绍

本书的目标不是创建另一本设计模式书,而是根据规模和主题连贯地组织各章,让您从小处做起,基础坚实,然后慢慢地在顶部构建,就像构建程序一样。

我们从软件工程师的角度来探索我们正在设计的系统背后的思维过程,而不是编写一份涵盖了应用设计模式的几种方法的指南。

这不是一本神奇的食谱书,根据经验,在设计软件时没有神奇的食谱;只有你的逻辑、知识、经验和分析能力。从最后一句话开始,让我们根据你过去的成功和失败来定义经验。别担心,在你的职业生涯中你会失败,但不要因此而气馁。失败的速度越快,恢复和学习的速度就越快,从而获得成功的产品。本书中介绍的许多技巧应该可以帮助您实现这一目标。每个人都失败过,都犯过错误;你不会是第一个,当然也不会是最后一个。

高层计划如下所示:

  • 我们首先探索基本模式、体系结构原则和一些关键的 ASP.NET Core 机制。
  • 然后我们转向组件规模,探索面向软件小块的模式。
  • 接下来,我们将讨论应用规模的模式和技术,在这里我们将探讨更高级别的模式以及如何将应用作为一个整体进行结构。
  • 之后,我们处理客户端连接点,并使 ASP.NET 成为可行的全堆栈替代方案。

本书涵盖的许多主题都可以有自己的一本书。读完这本书后,您应该对在哪里继续您的软件架构之旅有很多想法。

以下是我认为值得一提的几个要点:

  • 这些章节的组织从小规模模式开始,然后是更高层次的模式,使学习曲线更容易。
  • 这本书不是给你一个食谱,而是着重于思考方面,展示了一些技术的演变,以帮助你理解为什么会发生这种演变。
  • 许多用例结合了多个设计模式来说明替代用法,旨在理解模式以及如何有效地使用它们,并表明模式不是驯服的野兽,而是一种可以使用、操纵和顺应您意愿的工具。
  • 就像在现实生活中一样,没有任何教科书上的解决方案能够解决我们所有的问题,而真正的问题总是比教科书上解释的更复杂。在本书中,我的目标是向您展示如何混合和匹配模式,以及如何思考“架构”,而不是如何遵循说明。

导言介绍了我们将在本书中探讨的不同概念,包括一些概念的复习。我们还将介绍.NET 及其工具,以及技术要求,例如源代码的位置。

本章将介绍以下主题:

  • 什么是设计模式?
  • 反模式和代码气味
  • 了解网络–请求/响应
  • 开始使用.NET

什么是设计模式?

既然你刚买了一本关于设计模式的书,我想你对它们有一些概念,但是让我们确保我们在同一页上:

抽象定义:设计模式是一种经过验证的技术,可用于解决特定问题。

在这本书中,我们应用不同的模式来解决不同的问题,以及如何利用一些开源工具来更进一步、更快!抽象的定义让人听起来很聪明,但没有比实验更好的学习方法了,设计模式也一样。

如果这个定义对你来说还没有意义,别担心。在本书的末尾,你应该有足够的信息将多个实际例子和解释与该定义联系起来,使之足够清晰。

我喜欢将编程与玩乐高®进行比较,因为你必须做的基本上是一样的:将小块拼合在一起以创建一些东西。它可以是一座城堡,一艘宇宙飞船,或者你想建造的其他东西。考虑到这种类比,设计模式是一种计划,用于组装适合一个或多个场景的解决方案;例如,塔或反应器。因此,如果你对乐高®缺乏想象力或技能,可能是因为你太年轻了,那么你的城堡可能没有其他更有经验的城堡那么好看。设计模式为您提供了这些工具,帮助您构建和粘合漂亮可靠的组件,以改进这一杰作。但是,您可以在虚拟环境中嵌套代码块并交织对象,而不是将 LEGO®块捕捉在一起!

在深入讨论更多细节之前,经过深思熟虑的设计模式应用应该会改进您的应用设计。当您设计一个小组件或整个系统时,这是正确的。但是要小心,;仅仅为了使用模式而将模式扔进组合中可能会导致相反的结果。目标是编写可读的代码来解决手头的问题,而不是使用尽可能多的模式来过度设计系统。

正如我们简要提到的,有适用于多个软件工程级别的设计模式,在本书中,我们从小型开始,并在云端规模上进行扩展!我们遵循一条平滑的学习曲线,从简单的模式和代码示例开始,这些模式和代码示例稍微弯曲了好的实践,将重点放在模式上,最后以更高级的完整堆栈主题结束,集成了多个模式和好的实践。

反模式和代码气味

反模式和代码气味是架构上的不良实践或关于可能的不良设计的提示。学习最佳实践与学习不好的实践同样重要,这是我们的起点。此外,本书中有多种反模式和代码气味,可以帮助您入门。

不幸的是,我们无法涵盖每个主题的每个细节,因此我鼓励您深入研究这些领域以及设计模式和架构原则。

反模式

反模式与设计模式相反:它是一种经过验证的有缺陷的技术,很可能会给你带来麻烦,耗费你的时间和金钱(可能会让你头疼一两天)。

先验地,反模式是一种模式,这似乎是一个好主意,这似乎是你一直在寻找的解决方案,但最终,它很可能造成弊大于利。一些反模式最初是合法的设计模式,后来被贴上反模式标签。有时,这是一个意见的问题,有时分类会受到编程语言的影响。

反模式-上帝类

一个上帝类是一个处理太多事情的类。它通常是许多其他类继承或使用的中心类;它是了解和管理系统中所有内容的类;类。另一方面,它也是一个没有人想要更新的类,并且是一个每当有人触摸它时就会破坏应用的类;这是一个邪恶的职业

解决这个问题的最好方法是将责任分开,并将它们分配到多个类,而不是只分配到一个类。我们在书中看到了如何划分责任,这有助于同时创建更健壮的软件。

如果你有一个以上帝类为核心的个人项目,从阅读这本书开始,然后尝试运用你所学的原理和模式,将该类划分为多个相互作用的较小类。尝试将这些新类组织成有凝聚力的单元、模块或程序集。

我们很快就会进入架构原则,这为责任分离等概念开辟了道路。

代码有异味

代码气味是可能出现问题的指示器。它指出了您的设计中可以从重新设计中受益的一些方面。我们可以将代码气味转化为臭味代码。

重要的是要注意,代码气味只表明问题的可能性;这并不意味着有一个;不过,它们通常是很好的指标,因此值得花时间分析软件的这一部分。

一个很好的例子是当许多注释解释方法的逻辑时。这通常意味着可以将代码拆分为更小的方法,使用专有名称生成更可读的代码,并允许您删除那些讨厌的注释。

关于注释的另一件事是它们不会进化,所以经常发生的事情是注释描述的代码发生了变化,但是注释保持不变。这会留下错误的或过时的代码块描述,这可能会导致开发人员误入歧途。

代码气味-控制狂

反模式的一个很好的例子是当使用new关键字时。这表示创建者控制新对象及其生存期的硬编码依赖关系。这也是被称为控制狂反模式的。此时,您可能想知道,在面向对象编程中不使用new关键字是怎么回事,但请放心,我们将在第 7 章中介绍并扩展控件**畸形代码气味,深入研究依赖注入 .

代码气味–长方法

我们之前过度评论的示例可能代表的后续示例是长方法。当一个方法开始扩展到超过 10 到 15 行代码时,这是一个很好的指标,表明您应该以不同的方式考虑该方法。

以下是一些可能发生的情况的示例:

  • 该方法包含在多个条件语句中交织的复杂逻辑。
  • 该方法包含一个大的switch块。
  • 这种方法做的事情太多了。
  • 该方法包含重复的代码。

要解决此问题,您可以执行以下操作:

  • 提取一个或多个私有方法。
  • 将一些代码提取到新类中。
  • 重用外部类中的代码。
  • 如果你有很多条件语句或巨大的开关块,你可以利用一种设计模式,如责任链,或 CQR,你将在第 10 章、行为模式第 14 章中学习,Mediator 和 CQRS 设计模式

通常,每个问题都有一个或多个解决方案,足以发现问题,然后找到、选择并实施解决方案。让我们在这里说清楚;包含 16 行的方法不一定需要重构;可以。请记住,代码气味仅仅是问题的指示器,不一定是问题;运用常识。

了解网络–请求/响应

在进一步研究之前,必须了解 web 的基本概念。HTTP 1.X 背后的思想是,客户端向服务器发送 HTTP 请求,然后服务器响应该客户端。如果你有 web 开发经验,这听起来可能微不足道。然而,无论您是在构建 web API、网站还是复杂的云应用,它都是最重要的 web 编程概念之一。

让我们将 HTTP 请求生存期缩短为以下内容:

  1. 通讯开始了。
  2. 客户端向服务器发送请求。
  3. 服务器接收请求。
  4. 服务器最有可能执行某些操作(执行某些代码/逻辑)。
  5. 服务器响应客户机。
  6. 通信结束。

在该周期之后,服务器不再知道客户机。此外,如果客户机发送另一个请求,则服务器不知道它先前响应了同一客户机的请求,因为HTTP 是无状态的

有一些机制可以在请求之间创建持久性,以便服务器“感知”其客户机。其中最著名的可能是饼干。

如果我们再深入一点,HTTP 请求由一个头和一个可选的主体组成。最常用的 HTTP 方法是GETPOST。由于 web API 的流行,我们还可以将PUTDELETEPATCH添加到该列表中。虽然并非每个 HTTP 方法都接受一个主体,但以下是一个列表:

下面是一个GET请求的示例(没有主体,因为GET请求不允许这样做):

GET http://www.forevolve.com/ HTTP/1.1 Host: www.forevolve.com Connection: keep-alive Upgrade-Insecure-Requests: 1 User-Agent: Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/70.0.3538.110 Safari/537.36 Accept: text/html,application/xhtml+xml,application/xml;q=0.9,img/webp,img/apng,*/*;q=0.8 Accept-Encoding: gzip, deflate Accept-Language: en-US,en;q=0.9,fr-CA;q=0.8,fr;q=0.7 Cookie: ...

HTTP 头由一系列表示客户端希望发送到服务器的元数据的键/值对组成。在这种情况下,我使用GET方法查询了我的博客,Google Chrome 在请求中附加了一些附加信息。我将Cookie头的值替换为...,因为 cookie 可能非常大,并且与此示例无关。尽管如此,cookie 还是像任何其他 HTTP 头一样来回传递。

关于 cookies 的重要提示

客户端发送 cookie,服务器在每个请求-响应周期返回 cookie。如果您来回传递太多信息,这可能会占用您的带宽或降低应用的速度。我想到了这里的好的老网页表单。

当服务器决定响应请求时,它还返回一个头和一个可选的主体,遵循与请求相同的原则。第一行表示请求的状态;是否成功。在我们的例子中,状态代码为200,表示成功。每个服务器都可以向其响应中添加更多或更少的信息,就像您在代码中所做的那样。

以下是前一个请求的响应:

HTTP/1.1 200 OK Server: GitHub.com Content-Type: text/html; charset=utf-8 Last-Modified: Wed, 03 Oct 2018 21:35:40 GMT ETag: W/"5bb5362c-f677"Access-Control-Allow-Origin: *Expires: Fri, 07 Dec 2018 02:11:07 GMT Cache-Control: max-age=600 Content-Encoding: gzip X-GitHub-Request-Id: 32CE:1953:F1022C:1350142:5C09D460 Content-Length: 10055 Accept-Ranges: bytes Date: Fri, 07 Dec 2018 02:42:05 GMT Via: 1.1 varnish Age: 35 Connection: keep-alive X-Served-By: cache-ord1737-ORD X-Cache: HIT X-Cache-Hits: 2 X-Timer: S1544150525.288285,VS0,VE0 Vary: Accept-Encoding X-Fastly-Request-ID: 98a36fb1b5642c8041b88ceace73f25caaf07746 HERE WAS THE BODY THAT WAS WAY TOO LONG TO PASTE; LOTS OF HTML!

现在浏览器已经收到服务器的响应,对于 HTML 页面,它开始呈现它。然后,对于每个资源,它向其 URI 发送另一个 HTTP 调用并加载它。资源是外部资产,如图像、JavaScript 文件、CSS 文件或字体。

笔记

浏览器可以发送的并行请求数量有限,这可能导致加载时间增加,具体取决于要加载的资源数量、资源大小以及发送到浏览器的顺序。您可能希望对此进行优化。

响应后,服务器不再知道客户端;通讯结束了。必须理解,为了在每个请求之间创建伪状态,我们需要使用外部机制。该机制可以是会话状态,也可以创建无状态应用。我建议你尽可能去无国籍状态。

综上所述,HTTP/2 更高效,支持使用相同的 TCP 连接对多个资产进行流式传输。它还允许“服务器推送”,从而实现更具状态的万维网。如果您觉得 HTTP 很有趣,那么 HTTP/2 和最新的实验性 HTTP/3 都是一个开始深入挖掘的好地方。

开始使用.NET

一点历史;团队在从头开始构建 ASP.NET Core 方面做了出色的工作,切断了与旧版本的兼容性。起初,这带来了一些问题,但这些互操作性问题通过创建.NET 标准得到了缓解。现在,随着大多数技术重新统一到.NET5 中,以及共享 BCL 的承诺,ASP.NETCore 的名称(几乎)不再,而是成为了未来。之后,微软计划每年发布一次大型的.NET 版本,所以 2021 年应该是.NET 6,以此类推。

好的方面是,架构原则和设计模式在将来应该保持相关性,并且不会与您正在使用的.NET 版本紧密耦合。对代码示例的微小更改应该足以将知识和代码迁移到新版本。

现在,让我们介绍一些有关.NET5 和.NET 生态系统的关键信息。

.NET SDK 与运行时

您可以安装在 SDK 和运行时下分组的不同二进制文件。SDK 允许您构建和运行.NET 程序,而运行时只允许您运行.NET 程序。

作为开发人员,您希望在部署环境中安装 SDK。在服务器上,您希望安装运行时。运行时更轻,而 SDK 包含更多工具,包括运行时。

.NET 5 与.NET 标准

构建.NET 项目时,有多种类型的项目,但基本上我们可以将其分为两类:

  • 应用
  • 图书馆

应用针对的是.NET 版本,如net5.0。例如 ASP.NET 应用或控制台应用。

库是一起编译的代码包,通常作为 NuGet 包在 NuGet.org 上分发。NET 标准类库项目允许在.NET Core、.NET 5+和.NET Framework 项目之间共享代码。NET 标准起到了弥合.NET Core 和.NET 框架之间兼容性差距的作用,这缓解了过渡。当.NETCore1.0 首次问世时,这并不容易。

随着.NET 5 统一了所有平台并成为统一的.NET 生态系统的未来,.NET 标准不再需要。

笔记

我相信我们会看到.NET 标准库在一段时间内继续存在。并非所有项目都会神奇地从.NET Framework 迁移到.NET 5,人们可能希望继续在两者之间共享代码。

NET 的下一个版本应该在.NET5 上构建,而.NETFramework4.X 将保持现在的状态,只接收安全补丁和少量更新。

Visual Studio 代码与 Visual Studio、命令行界面(CLI)

如何创建其中一个项目。NET Core 附带了dotnetCLI,它公开了多个命令,包括new。在终端中运行dotnet new命令将生成一个新项目。

要创建空类库,可以运行以下命令:

md MyProject cd MyProject dotnet new classlib

这将在新创建的MyProject目录中生成一个空类库。在发现可用命令及其选项时,-h选项可以派上用场。您可以使用dotnet -h查找可用的 SDK 命令,也可以使用dotnet new -h查找有关选项和可用模板的信息。

Visual Studio 代码是我最喜欢的文本编辑器。在.NET 编码中,我不太使用它,但在 CLI 时间到来时,我仍然会重新组织项目,或者用于使用文本编辑器更容易完成的任何其他任务,例如使用标记编写文档、编写 JavaScript 或 TypeScript,或者管理 JSON、YAML 或 XML 文件。要创建项目或解决方案,或使用 VS 代码添加 NuGet 包,请打开终端并使用 CLI。

至于我最喜欢的 IDE VisualStudio,它在后台使用 CLI 创建相同的项目,使其在工具之间保持一致。VisualStudio 通过 CLI 添加用户界面。

您还可以在 CLI 中创建并安装其他dotnet``new项目模板,甚至创建全局工具。这些主题超出了本书的范围。

项目模板概述

以下是已安装的模板示例(dotnet``new

Figure 1.1 – Project templates

图 1.1–项目模板

对所有模板的研究超出了本书的范围,但我想访问一些值得一提的模板,或者因为我们稍后将使用它们:

  • dotnet new console创建控制台应用。
  • dotnet new classlib创建类库。
  • dotnet new xunit创建一个 xUnit 测试项目。
  • dotnet new web创建一个空 web 项目。
  • dotnet new webapp使用预配置的Startup类创建空 web 项目。
  • dotnet new mvc脚手架 MVC 应用。
  • dotnet new webapi脚手架 web API 应用。

异步主(C#7.1)

从 C#7.1 开始,控制台应用可以具有async main方法,这非常方便,因为越来越多的代码变得异步。这项新功能允许在main()方法中直接使用await,没有任何怪癖。

在此之前,main方法的签名必须符合以下条件之一:

public static void Main() { }
public static int Main() { }
public static void Main(string[] args) { }
public static int Main(string[] args) { }

从 C#7.1 开始,我们还可以使用它们的async对应项:

public static async Task Main() { }
public static async Task<int> Main() { }
public static async Task Main(string[] args) { }
public static async Task<int> Main(string[] args) { }

现在,我们可以创建一个如下所示的控制台:

class Program
{
    static async Task Main(string[] args)
    {
        Console.WriteLine("Entering Main");
        var myService = new MyService();
        await myService.ExecuteAsync();
        Console.WriteLine("Exiting Main");
    }
}
public class MyService
{
    public Task ExecuteAsync()
    {
        Console.WriteLine("Inside MyService.ExecuteAsync()");
        return Task.CompletedTask;
    }
}

执行程序时,结果如下:

Entering Main
Inside MyService.ExecuteAsync()
Exiting Main

没什么特别的,但它允许利用await/async语言特性。

自.NET Core 以来,所有类型的应用都以Main方法开始(通常为Program.Main,包括 ASP.NET Core 5 web 应用。

运行和构建您的程序

如果您正在使用 Visual Studio,您可以随时点击播放按钮或F5运行您的应用。如果正在使用 CLI,则可以使用以下命令之一(及更多)。每一个它们也提供了不同的选项来控制它们的行为。添加带有任何命令的-h标志,以获取该命令的帮助,例如dotnet build -h

技术要求

在整本书中,我们探索并编写代码。我建议安装 Visual Studio、VisualStudio 代码,或者两者都安装,以帮助解决这一问题。

笔记

如果您愿意,您也可以使用记事本或您最喜欢的文本编辑器。我使用 VS 和 VS 代码。选择您喜欢的工具。

除非安装.NET SDK 附带的 Visual Studio,否则可能需要安装.NET 5 SDK。SDK 附带了我们前面探讨过的 CLI,以及用于运行和测试程序的构建工具。查看 GitHub 存储库的README.md文件,了解更多信息和这些资源的链接。

所有章节的源代码可在 GitHub 上下载,地址如下:https://net5.link/code

总结

在本章中,我们在设计模式、反模式和代码气味方面达到了顶峰。我们还探讨了其中的一些。然后我们转到一个关于典型 web 应用的请求/响应周期的提醒。

我们继续探索.NET 基本要素,例如 SDK 与运行时的对比,以及应用目标与.NET 标准的对比。这使我们走上了探索构建.NET 应用时所具有的各种可能性的道路。然后我们对.NET CLI 进行了进一步的研究,在那里我列出了一个基本命令列表,包括dotnet builddotnet watch run。我们还讨论了如何创建新项目。

在接下来的两章中,我们将探讨自动化测试和体系结构原则。对于任何希望构建健壮、灵活和可维护的应用的人来说,这些都是基础章节。

问题

让我们来看看几个练习题:

  1. 我们可以在GET请求中添加一个主体吗?
  2. 为什么长方法是一种代码气味?
  3. 在创建库时,.NET 标准应该是您的默认目标,这是真的吗?
  4. 什么是代码气味?

进一步阅读

以下是巩固本章所学内容的一些链接:

二、测试您的 ASP.NET Core 应用

本章重点介绍自动测试,以及它对制作更好的软件有多大帮助。它还涵盖了几种不同类型的测试,以及测试驱动开发 To1 T1(Po.T2A.TDD AutoT3)的基础。我们还概述了 ASP.NET Core 的可测试性,以及与旧的 ASP.NET MVC 应用相比,测试 ASP.NET Core 应用要容易得多。

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

  • 自动测试概述及其如何应用于 ASP.NET Core
  • 测试驱动开发(TDD)
  • 通过 ASP.NET Core 简化测试

自动测试概述及其如何应用于 ASP.NET Core

测试是开发过程中不可分割的一部分,从长远来看,自动化测试变得至关重要。您始终可以运行 ASP.NET 网站,打开浏览器,然后单击任意位置以测试您的功能。这是合法的,但以这种方式测试单个代码单元更难。另一个缺点是缺乏自动化;当您第一次使用包含几个页面、几个端点或几个功能的小应用时,手动运行这些测试可能会很快。然而,随着你的应用的增长,它会变得更加乏味,需要更长的时间,而且测试人员出错的可能性也会增加。不要误解我的意思,你总是需要真正的用户来测试你的应用,但你可能希望这些测试集中在用户体验、功能内容或你正在构建的一些实验性功能上,而不是自动化测试可能在早期发现的 bug 报告。

有多种类型的测试,开发人员在寻找新的测试方法方面非常有创造力。下面列出了三大类,它们代表了我们如何划分自动化测试;让我们称这三个家庭为:

  • 单元测试
  • 集成测试
  • 功能测试

单元测试关注于单个单元,就像测试方法的结果一样。单元测试应该是快速的,不应该依赖于任何基础设施,比如数据库。这些是您最想要的测试,因为它们运行得很快,并且每个测试都应该测试精确的代码路径。它们还应该帮助你更好地设计你的应用,因为你在测试中使用了你的代码,所以你成为了它的第一个消费者,这会导致你发现一些设计缺陷并使你的类变得更好。如果您不喜欢在测试中使用系统的方式,这是一个很好的指标,其他人都不会喜欢。

集成测试关注组件交互,例如当一个组件查询数据库时会发生什么,或者当两个组件相互交互时会发生什么。集成测试通常需要与一些基础设施进行交互,这使得它们运行速度较慢。您需要集成测试,但是您需要的集成测试比单元测试少。

笔记

我们以后会打破这条规则,所以要始终对规则和原则持批评态度;有时,折断或弯曲它们会更好。

功能测试关注应用范围内的行为,例如当用户单击特定按钮、导航到特定页面、发布表单或向某个 web API 端点发送PUT请求时会发生什么。功能测试的重点是从用户的角度,从功能的角度测试整个应用,而不是像单元测试和集成测试那样只测试部分代码。通常,功能测试应该在内存中运行,使用内存中的数据库或任何其他资源;这有助于加快速度,但您也可以在真实基础设施上运行端到端e2e)测试,以测试您的应用和部署。

还有其他类型的自动化测试和一些我们可以称之为子类型的测试。例如,我们可以进行负载测试、性能测试、e2e测试、回归测试、契约测试、渗透测试等等。您可以为几乎任何您想要验证的内容自动化测试,但是某些类型的测试更难自动化,或者比其他类型的测试更脆弱,例如 UI 测试。这就是说,如果您可以在合理的时间范围内自动化测试,那就去做吧!从长远来看,这应该是值得的。

还有一件事,不要盲目依赖代码覆盖率等指标。这些指标在你的 GitHub 项目的readme.md文件中有着可爱的徽章,但会让你偏离编写无用测试的轨道。不要误解我的意思,正确使用代码覆盖率是一个很好的衡量标准,但请记住,一个好的测试可能比一个坏的测试套件覆盖 100%的代码库要好。

编写好的测试并不容易,需要实践。

笔记

一条建议:通过添加缺失的测试用例和删除过时或无用的测试来保持测试套件的健康。考虑用例覆盖率,而不是测试覆盖了多少行代码。

既然我们已经探索了自动化测试,现在是探索 TDD 的时候了,这是一种应用这些测试概念的方法,从考虑测试开始。

测试驱动开发(TDD)

TDD 是一种开发软件的方法,它规定您应该在编写实际代码之前编写测试。因此,您可以通过遵循红-绿重构技术来反转您的开发流程。

什么是红绿重构

事情是这样的:

  1. 您编写了一个失败的测试(红色)。
  2. 您只需编写足够的代码即可通过测试(绿色)。
  3. 通过确保所有测试仍然通过,您可以重构代码以改进设计。

好的,但是重构意味着什么?

我将重构定义为(持续)改进代码而不改变其行为。

拥有一个自动化测试套件应该可以帮助你实现这个目标,并且应该可以帮助你发现什么时候你打破了某些东西。无论您是否使用 TDD,我都建议您尽可能频繁地进行重构;这有助于清理您的代码库,同时还应该帮助您摆脱一些技术债务。

好的,但是什么是技术债务

技术债务代表了您在开发功能或系统过程中缩短的所有角落。不管你怎么努力,这都会发生,因为生命就是生命,有延迟、截止日期、预算,还有各种各样的对手,比如经理、高层管理人员和驱动系统开发的外部资源;不仅仅是开发人员。好吧,老实说,我们也应该把开发人员添加到这个列表中——我们也创造了技术债务。

首先,你必须明白你不能完全避免技术债务,所以接受事实并接受它比与之抗争更好。从那时起,您可以尝试限制所产生的技术债务的数量。

限制技术债务堆积的一种方法是经常进行重构。因此,在特性评估中考虑重构时间。

另一种方法是学会与每个人合作,并将利益相关者包括在项目的构建过程中。帮助他们了解你的现实,并努力了解他们的现实。如果你想让你的项目成功,每个人都必须朝着同一个目标努力。

您可能需要不时缩短最佳实践的使用,但稍后会回来尽快修复它们。你知道,在那个重要的日期发布了关键特性之后,供应商告诉客户在咨询开发团队之前会发生什么?不幸的是,根据我的经验,这种情况确实经常发生。但是,有一些方法可以限制这些情况发生的次数。例如,参与者、部门和团队之间的高水平协作应该有所帮助,而适当的项目管理技术和善政也应该有所帮助。

提示

我意识到其中一些事情可能超出了你的控制,因此你可能不得不承受比你所希望的更多的技术债务。你也可以一直努力改变你所工作的企业文化,成为先锋;那就交给你了。

所有这些都表明,如果市场营销部门不能销售软件,那么开发它是毫无意义的,你可能最终会失业,所以让我们接受这样一个事实:妥协是强制性的。另一方面,自动化测试应该可以帮助您重构代码并优雅地摆脱债务。但是,不要让技术债务堆积得太高,否则你可能无法偿还,在某个时候,项目就开始崩溃和失败。别搞错了;生产中的项目可能会失败。交付产品并不能保证成功,我在这里谈论的是代码的质量,而不是产生的收入(我将把这一点留给您的会计部门)。

还有其他的方法,如行为驱动开发BDD)和验收测试驱动开发ATDD)。DevOps 带来了一种专注于在其持续集成和部署思想中采用自动化测试的思维方式。然而,我不会在书中写一本书,而是让你自己去写。我在这一章的末尾留下了一些参考资料,以获取关于其中一些主题的更多信息。

通过 ASP.NET Core 简化测试

ASP.NET Core 团队通过设计 ASP.NET Core 的可测试性使我们的生活更轻松;大多数测试都比 ASP.NET Core 时代之前简单得多。在内部,他们使用 xUnit 测试.NET Core 和 EF 内核,我们在本书中也使用 xUnit。xUnit 是我最喜欢的测试框架;多好的巧合啊。我们不会对所有样品采用完全 TDD 模式,因为这会使我们的重点偏离关键问题,但我尽了最大努力将尽可能多的自动化测试带到桌面上!为什么?因为可测试性通常是良好设计的标志,它允许我通过使用测试而不是文字来证明一些概念。

此外,在许多代码示例中,测试用例是使用者,使程序更轻,而无需在其上构建完整的用户界面。这使我们能够将注意力集中在正在探索的模式上,而不是分散在一些样板代码上。

如何创建 xUnit 测试项目?

要创建一个新的 xUnit 测试项目,您可以运行dotnet new xunit命令,CLI 通过创建一个包含UnitTest1类的项目来为您完成这项工作。该命令的作用与从 Visual Studio 创建新的 xUnit 项目的作用相同,如下所示:

Figure 2.1 – Create a xUnit project

图 2.1–创建一个 xUnit 项目

迅特的基本特征

在 xUnit 中,[Fact]属性是创建唯一测试用例的方式,[Theory]属性是创建数据驱动测试用例的方式。

任何没有参数的方法都可以通过使用[Fact]属性进行装饰而成为测试方法,如下所示:

public class FactTest
{
    [Fact]
    public void Should_be_equal()
    {
        var expectedValue = 2;
        var actualValue = 2;
        Assert.Equal(expectedValue, actualValue);
    }
} 

从 Visual Studio 测试资源管理器中,该测试用例如下所示:

Figure 2.2 – Test results

图 2.2–试验结果

然后,对于更复杂的测试用例,xUnit 提供了三个选项来定义[Theory]应该使用的数据:[InlineData][MemberData][ClassData]。你不仅限于一个人;你可以使用尽可能多的数据来为理论提供数据。必须确保值的数量与测试方法中定义的参数数量匹配。

[InlineData]最适合常量、文字值,如下所示:

public class InlineDataTest
{
    [Theory]
 [InlineData(1, 1)]
 [InlineData(2, 2)]
 [InlineData(5, 5)]
    public void Should_be_equal(int value1, int value2)
    {
        Assert.Equal(value1, value2);
    }
}

这将在测试资源管理器中生成三个测试用例,每个测试用例可以单独通过或失败:

Figure 2.3 – Test results

图 2.3–试验结果

[MemberData][ClassData]可以用于简化测试方法的声明,在多个测试方法中重用数据,或者封装远离测试类的数据。以下是一些[MemberData]用法的示例:

public class MemberDataTest
{
    public static IEnumerable<object[]> Data => new[]
    {
        new object[] { 1, 2, false },
        new object[] { 2, 2, true },
        new object[] { 3, 3, true },
    };
    public static TheoryData<int, int, bool> TypedData =>new TheoryData<int, int, bool>
    {
        { 3, 2, false },
        { 2, 3, false },
        { 5, 5, true },
    };
    [Theory]
 [MemberData(nameof(Data))]
 [MemberData(nameof(TypedData))]
 [MemberData(nameof(ExternalData.GetData), 10, MemberType = typeof(ExternalData))]
 [MemberData(nameof(ExternalData.TypedData), MemberType = typeof(ExternalData))]
    public void Should_be_equal(int value1, int value2, bool shouldBeEqual)
    {
        if (shouldBeEqual)
        {
            Assert.Equal(value1, value2);
        }
        else
        {
            Assert.NotEqual(value1, value2);
        }
    }
    public class ExternalData
    {
        public static IEnumerable<object[]> GetData(int start) => new[]
        {
            new object[] { start, start, true },
            new object[] { start, start + 1, false },
            new object[] { start + 1, start + 1, true },
        };
        public static TheoryData<int, int, bool> TypedData => new TheoryData<int, int, bool>
        {
            { 20, 30, false },
            { 40, 50, false },
            { 50, 50, true },
        };
    }
}

该测试用例应该产生 12 个结果。如果我们将其分解,代码首先从IEnumerable<object[]> Data属性加载三组数据,方法是使用[MemberData(nameof(data))]属性装饰测试方法。

然后,为了更清楚,我们用一个TheoryData<…>类替换IEnumerable<object[]>,使其更可读,这是我定义成员数据的首选方法。我们通过使用【MemberData(nameof(TypedData))】属性将这三组数据添加到测试方法中。

然后,又有三组数据被传递给测试方法。这些源于外部类型上的一个方法,该方法以10为参数(参数start。我们指定了方法所在的MemberType,因此 xUnit 知道在哪里查找,它由[MemberData(nameof(ExternalData.GetData), 10, MemberType = typeof(ExternalData))]属性表示。

最后,我们对由[MemberData(nameof(ExternalData.TypedData), MemberType = typeof(ExternalData))]属性表示的ExternalData.TypedData属性执行相同的操作。

运行测试时,这些[MemberData]属性在测试浏览器中产生以下结果:

Figure 2.4 – Test results

图 2.4–试验结果

这些只是我们可以使用[MemberData]属性的几个例子。

现在转到[ClassData]属性。该类从实现IEnumerable<object[]>的类或从TheoryData<…>继承的类获取数据。以下是一个例子:

public class ClassDataTest
{
 [Theory]
 [ClassData(typeof(TheoryDataClass))]
 [ClassData(typeof(TheoryTypedDataClass))]
    public void Should_be_equal(int value1, int value2, bool shouldBeEqual)
    {
        if (shouldBeEqual)
        {
            Assert.Equal(value1, value2);
        }
        else
        {
            Assert.NotEqual(value1, value2);
        }
    }
    public class TheoryDataClass : IEnumerable<object[]>
    {
        public IEnumerator<object[]> GetEnumerator()
        {
            yield return new object[] { 1, 2, false };
            yield return new object[] { 2, 2, true };
            yield return new object[] { 3, 3, true };
        }
        IEnumerator IEnumerable.GetEnumerator() => 
        GetEnumerator();
    }
    public class TheoryTypedDataClass : TheoryData<int, int, bool>
    {
        public TheoryTypedDataClass()
        {
            Add(102, 104, false);
        }
    }
}

这些与[MemberData]非常相似,但我们没有指向成员,而是指向类型。以下是测试资源管理器中的结果:

Figure 2.5 – Test Explorer

图 2.5–测试浏览器

既然[Fact][Theory]已经过时,xUnit 提供了测试夹具,允许开发人员将依赖项作为参数注入到测试类构造函数中。fixture 通过实现IClassFixture<T>接口,允许测试类的所有测试方法重用这些依赖关系。本章的最后一个代码示例显示了这一点。这对于访问数据库等代价高昂的依赖项非常有用;创建一次,使用多次。

您还可以使用ICollectionFixture<T>[Collection][CollectionDefinition]在多个测试类之间共享一个夹具(依赖项)。我们不会在这里深入讨论细节,但是如果你需要类似的东西,你会知道去哪里找。

最后,如果您使用其他测试框架,您可能会遇到设置拆卸方法。在 xUnit 中,没有特定的属性或机制来处理设置和拆卸代码。相反,xUnit 使用现有的 OOP 概念:

  • 要设置测试,请使用类构造函数。
  • 要拆除(清理)您的测试,请实施IDisposable,并在那里处置您的资源。

就是这样,xUnit 非常简单,但功能强大,这就是几年前我采用它作为主要测试框架的主要原因,也是我在本书中选择它的原因。现在让我们看看如何组织这些测试。

如何组织考试

在解决方案中组织测试项目有很多方法。我喜欢的一个方法是为解决方案中的每个项目创建一个单元测试项目、一个或多个集成测试项目和一个功能测试项目。由于我们大多数时候都有更多的单元测试,并且单元测试与单个代码单元直接相关,所以将它们组织成一对一的关系是有意义的。然后,我们应该有较少的集成和功能测试,因此单个项目应该足以保持代码的条理化。

笔记

有些人可能会建议为每个解决方案创建一个单独的单元测试项目,而不是为每个项目创建一个。这种方法可以节省发现时间。我认为,对于大多数解决方案来说,这只是偏好的问题。如果你有一个更喜欢的方式来组织你的,无论如何,用那个方法代替!也就是说,我发现每个组件一个单元测试项目更便于移植和导航。

大多数时候,在解决方案级别,我在src目录中创建我的应用及其相关库,并在test目录中创建我的测试项目,如下所示:

Figure 2.6 – The Automated Testing Solution Explorer, displaying how the projects are organized

图 2.6–自动测试解决方案资源管理器,显示了项目的组织方式

自动测试解决方案中,我没有任何集成测试,所以我没有创建集成测试项目。根据我的方法,我可以命名一个集成测试MyApp.IntegrationTests

我发现有助于使测试和代码完美对齐的另一个细节是,在与测试主题相同的命名空间中创建单元测试。为了在 Visual Studio 中更方便,您可以在测试项目中创建新类时更改 Visual Studio 使用的默认命名空间,方法是向测试项目文件(*.csprojPropertyGroup中添加<RootNamespace>[Project under test namespace]</RootNamespace>,如下所示:

<PropertyGroup>  <TargetFramework>net5.0</TargetFramework>  <IsPackable>false</IsPackable>  <RootNamespace>MyApp</RootNamespace></PropertyGroup>

然后我将我的测试类命名为[class under test]Test.cs,并在与原始项目相同的目录中创建它们,如下所示:

Figure 2.7 – The Automated Testing Solution Explorer, displaying how tests are organized

图 2.7–自动测试解决方案资源管理器,显示了测试的组织方式

当你遵循这个简单的规则时,发现测试是微不足道的。有时,集成测试或功能测试不可能做到这一点;在这些情况下,请使用规范帮助您创建对测试有意义的清晰命名约定。记住,我们正在测试用例。

最后,对于每个类,我为从嵌套类的父类继承的每个方法嵌套一个测试类,然后使用[Fact][Theory]属性在其中创建测试用例。这有助于通过方法高效地组织测试,并以如下所示的测试层次结构结束:

namespace MyApp.Controllers {    public class ValuesControllerTest     {        public class Get : ValuesControllerTest         {            [Fact]            public void Should_return_the_expected_strings()            {                // Arrange                 var sut = new ValuesController();                // Act                 var result = sut.Get();                // Assert                 Assert.Collection(result.Value,                    x => Assert.Equal("value1", x),                    x => Assert.Equal("value2", x)                );            }        }    }}

该技术允许您逐步设置测试。例如,您可以创建顶级私有模拟;然后,对于每个方法,您可以修改设置或创建其他私有测试元素,然后您可以在测试方法内部对每个测试用例执行相同的操作。不过,不要过于强调可重用性;它会使测试很难被外部的人理解,比如评审员或其他需要在那里进行测试的开发人员。单元测试应该保持清晰、小且易于阅读。

怎么比较容易?

微软从头开始构建.NETCore(现在的.NET5),所以他们修复和改进了很多东西,我无法在此一一列举,包括可测试性。并不是每件事都是完美的,但它比以往任何时候都要好。

让我们从讨论ProgramStartup类开始。这两个类是定义应用如何引导及其组成的地方。基于该模型,ASP.NET Core 团队创建了一个测试服务器类,允许您在内存中运行应用。

他们还在.NETCore2.1 中添加了WebApplicationFactory<TEntry>,使集成和功能测试更加容易。使用该类,您可以在内存中启动 ASP.NET Core 应用,并使用提供的HttpClient查询它。所有这些都只需要几行代码。有一些扩展点可以配置它,比如用 mock、stub 或您可能需要的任何其他特定于测试的元素替换实现。TEntry泛型参数应该是测试项目的StartupProgram类。

我在Automated Testing项目中创建了几个测试用例,公开了这个功能:

namespace FunctionalTests.Controllers
{
    public class ValuesControllerTest : IClassFixture<WebApplicationFactory<Startup>>
    {
        private readonly HttpClient _httpClient;
        public ValuesControllerTest(WebApplicationFactory<Startup> webApplicationFactory)
        {
            _httpClient = webApplicationFactory.CreateClient();
        }

在这里,我们通过实现IClassFixture<T>接口将WebApplicationFactory<Startup>对象注入构造函数。我们可以使用工厂来配置测试服务器,但由于这里不需要它,我们只能在配置为连接到运行应用的内存中测试服务器的HttpClient上保留一个引用。

        public class Get : ValuesControllerTest
        {
            public Get(WebApplicationFactory<Startup> webApplicationFactory) : base(webApplicationFactory) { }
            [Fact]
            public async Task Should_respond_a_status_200_OK()
            {
                // Act
                var result = await_httpClient.GetAsync("/api/values");
                // Assert
                Assert.Equal(HttpStatusCode.OK, result.StatusCode);
            }

在前面的测试用例中,我们使用测试HttpClient来查询 http://localhost/api/values URI,可通过内存服务器访问。然后我们测试 HTTP 响应的状态代码以确保它是成功的(200 OK

            [Fact]
            public async Task Should_respond_the_expected_strings()
            {
                // Act
                var result = await_httpClient.GetAsync("/api/values");
                // Assert
                var contentText = await result.Content.ReadAsStringAsync();
 var content = JsonSerializer.Deserialize<string[]> (contentText);
                Assert.Collection(content,
                    x => Assert.Equal("value1", x),
                    x => Assert.Equal("value2", x)
                );
            }
        }
    }
}

最后一个测试执行相同的操作,但将主体内容反序列化为string[],以确保值与我们预期的值相同。如果您以前使用过HttpClient,您应该非常熟悉它。

运行这些测试时,内存中的 web 服务器启动;然后,HTTP 请求被发送到该服务器,测试整个应用。在这种情况下,测试很简单,但是您也可以创建更复杂的测试用例。

您可以在 Visual Studio 中运行.NET 5 测试,也可以通过运行dotnet test命令来使用 CLI。在 VS 代码中,您可以使用 CLI 或查找扩展来帮助您完成测试运行。

总结

在本章中,我们介绍了自动化测试,如单元测试、集成测试和功能测试。我们快速查看了 xUnit,这是我们在本书中使用的测试框架,以及一种组织测试的方法。然后,我们了解了 ASP.NET Core 如何通过允许我们在内存中装载和运行 ASP.NET Core 应用,使测试 web 应用比以往任何时候都更容易。

本书中有许多 xUnit 测试,因此您将在此过程中变得更加熟悉。如果你觉得有必要的话,我还建议你深入研究一下。

现在,我们已经讨论了测试,我们准备探索一些架构原则,这些原则将导致单元测试和 TDD,同时增强程序的可测试性。这些是现代软件工程的一个关键部分,与自动化测试结合得很好。

问题

让我们来看看几个练习题:

  1. 在 TDD 中,您在要测试的代码之前编写测试,这是真的吗?
  2. 单元测试的作用是什么?
  3. 单元测试能有多大?
  4. 当受试者必须访问数据库时,通常使用哪种类型的测试?
  5. 是否需要进行 TDD?

进一步阅读

以下是我们在本章学到的一些链接:

  • 许妮特:https://xunit.net/
  • 如果您使用 VisualStudio,我有一些方便的代码片段可以帮助您提高工作效率。它们可在 GitHub 上获得:https://net5.link/5TbY
  • 什么是 DevOps?https://net5.link/BPcr
  • 下面这本书是我信任的人向我推荐的 DevOps/DevSecOps 资源。我自己没有读过,但已经浏览了一下内容,这似乎是一个开始的好资源:https://net5.link/X6bW

三、架构原则

本章重点介绍基本的体系结构原则,而不是设计模式。这背后的原因很简单:这些原则是现代软件工程的基础。此外,我们在整本书中都应用了这些原则,以确保到最后,我们能够编写更好的代码并做出更好的软件设计决策。

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

  • 坚实的原则及其重要性
  • 干燥原理
  • 关注点分离原则

坚实的原则

SOLID 是一个首字母缩略词,代表五条原则,它们扩展了 OOP 的基本概念:抽象、封装、继承和多态性。他们增加了关于做什么和如何做的更多细节,指导开发人员进行更健壮的设计。

同样重要的是要注意,它们是要不惜一切代价遵守的原则,而不是规则。根据您正在构建的内容权衡成本。如果您正在构建一个小型工具,那么与设计一个业务关键型应用相比,缩短它的时间是可以的。对于后一种情况,你可能想考虑更严格一些。然而,不管应用的大小,遵循它们通常是一个好主意,这是在开始深入研究设计模式之前在这里介绍它们的主要原因。

实心首字母缩略词表示以下内容:

  • S单一责任原则
  • O笔/闭合原理
  • L伊斯科夫代换原理
  • I界面分离原理
  • D依附倒置原理

通过遵循这些原则,您的系统应该更易于测试和维护。

单一责任原则(SRP)

本质上,SRP 意味着一个类应该承担一个且只有一个责任,这让我引述如下:

“一个类改变的原因不应该超过一个。”

–罗伯特·C·马丁

好吧,但是为什么呢?在给出答案之前,我希望您考虑一下每次添加、更新或删除规范的情况。然后,想一想,如果系统中的每个类都只有一个责任:一个改变的理由,那么事情会变得多么容易。

我不知道你是否清楚地看到了这一点,但我可以在脑海中想出一些可以从这一原则中获益的项目。软件可维护性问题可能是由程序员、经理或两者共同造成的。我认为没有什么是黑色或白色的,大多数情况是灰色的;有时,它是深灰色或浅灰色,但仍然是灰色。在设计软件时,这种绝对性的缺失也是真实的:尽力而为,从错误中吸取教训,保持谦虚。

让我们回顾一下这一原则存在的原因:

  • 应用生来就是要改变的。
  • 使我们的类更加可重用,并创建更加灵活的系统。
  • 帮助维护应用。由于您知道一个类在更新它之前所做的唯一一件事,因此您可以快速预见它对系统的影响,这与承担许多责任的类不同,更新一个类可能会破坏一个或多个其他部分。
  • 使我们的类更具可读性。更少的责任导致更少的代码,更少的代码更容易在几秒钟内可视化,从而更快地理解该软件。

让我们在行动中尝试一下。我写了一些违反一些原则的可怕代码,包括 SRP。让我们从分析代码开始,对其进行部分修复,使其不再违反 SRP。

下面的是写得很差的代码的一个例子:

public class Book
{
    public int Id { get; set; }
    public string Title { get; set; }
    private static int _lastId = 0;
    public static List<Book> Books { get; }
    public static int NextId => ++_lastId;
    static Book()
    {
        Books = new List<Book>
        {
            new Book	
            {
                Id = NextId,
                Title = "Some cool computer book"
            }
        };
    }
    public Book(int? id = null)
    {
        Id = id ?? default(int);
    }
    public void Save()
    {
        // Create the book if it does not exist, 
        // otherwise, find its index and replace it 
        // by the current object.
        if (Books.Any(x => x.Id == Id))
        {
            var index = Books.FindIndex(x => x.Id == Id);
            Books[index] = this;
        }
        else
        {
            Books.Add(this);
        }
    }
    public void Load()
    {
        // Validate that an Id is set
        if (Id == default(int))
        {
            throw new Exception("You must set the Id to the Book Id you want to load.");
        }
        // Get the book
        var book = Books.FirstOrDefault(x => x.Id == Id);
        // Make sure it exist
        if (book == null)
        {
            throw new Exception("This book does not exist");
        }
        // Copy the book properties to the current object
        Id = book.Id; // this should already be set
        Title = book.Title;
    }
    public void Display()
    {
        Console.WriteLine($"Book: {Title} ({Id})");
    }
}

该类包含该超小型控制台应用的所有职责。还有Program类,它包含一个快速而肮脏的用户界面,Book类的使用者。

该计划提供以下选项:

图 3.1–程序的用户界面

让我们看一下的代码:

class Program
{
    static void Main(string[] args)
    {
        // ...
    }

我省略了Main方法代码,因为它只是一个包含Console.WriteLine调用的大型switch。当用户做出选择时,它将用户输入分派给其他方法(稍后解释)。参见https://net5.link/jpxa 了解更多关于Main方法的信息。接下来,当用户选择 1 时调用的方法:

    private static void FetchAndDisplayBook()
    {
        var book = new Book(id: 1);
        book.Load();
        book.Display();
    }

FetchAndDisplayBook()方法加载的id等于1book实例,并在控制台中显示。接下来,当用户选择 2 时调用的方法:

    private static void FailToFetchBook()
    {
        var book = new Book();
        book.Load(); // Exception: You must set the Id to the Book Id you want to load.
        book.Display();
    }

FailToFetchBook()方法加载book实例而不指定id,导致加载数据时抛出异常;参考Book.Load()方法(前面的代码块,第一个突出显示)。接下来,当用户选择 3 时调用的方法:

    private static void BookDoesNotExist()
    {
        var book = new Book(id: 999);
        book.Load();
        book.Display();
    }

BookDoesNotExist()方法加载一个不存在的book实例,导致加载数据时抛出异常;参考Book.Load()方法(前面的代码块,第二个突出显示)。接下来,当用户选择 4 时调用的方法:

    private static void CreateOutOfOrderBook()
    {
        var book = new Book
        {
            Id = 4, // this value is not enforced by anything and will be overriden at some point.
            Title = "Some out of order book"
        };
        book.Save();
        book.Display();
    }

CreateOutOfOrderBook()方法创建一个手动指定idbook实例。该 ID 可以被Book类的自动增量机制覆盖。这些行为是程序设计中问题的良好指标。接下来,当用户选择 5 时调用的方法:

    private static void DisplayTheBookSomewhereElse()
    {
        Console.WriteLine("Oops! Can't do that, the Display method only write to the \"Console\".");
    }

DisplayTheBookSomewhereElse()方法指出了该设计的另一个问题。由于Book类拥有显示机制,我们不能在控制台以外的任何地方显示书籍;参考Book.Display()方法。接下来,当用户选择 6 时调用的方法:

private static void CreateBook()
    {
        Console.Clear();
        Console.WriteLine("Please enter the book title: ");
        var title = Console.ReadLine();
        var book = new Book { Id = Book.NextId, Title = 
        title };
        book.Save();
    }

CreateBook()方法让我们创作新书。它使用Book.NextId静态属性,即增加Id。这破坏了封装并将创建逻辑泄露给消费者,这是另一个与设计相关的问题,我们将在稍后修复。接下来,当用户选择 7 时调用的方法:

    private static void ListAllBooks()
    {
        foreach (var book in Book.Books)
        {
            book.Display();
        }
    }
}

ListAllBooks()方法显示我们在程序中创建的所有书籍。

在进一步讨论之前,我希望你们思考一下Book类中的错误以及违反 SRP 的责任有多少。完成后,请继续阅读。

好的,让我们从隔离功能开始:

  • 该类是一个数据结构,表示一本书(IdTitle)。
  • 它保存和加载数据,包括保留所有现有书籍的列表(BooksSave()Load())。
  • 它通过公开NextId 属性来“管理”自动递增的 ID,该属性将该功能破解到程序中。
  • 它扮演演示者的角色,使用其Display()方法在控制台中输出一本书。

从这四点中,我们可以提取哪些角色?

  • 这是一本书。
  • 它进行数据访问(管理数据)。
  • 它通过在控制台中输出自己来向用户呈现书籍。

这三个元素是责任,这是划分Book类的一个很好的起点。让我们看看这三个类:

  • 我们可以保留Book类,并使其成为表示一本书的简单数据结构。
  • 我们可以创建一个BookStore类,其角色是访问数据。
  • 我们可以创建一个BookPresenter类,在控制台上输出(呈现)一本书。

以下是这三门课:

public class Book
{
    public int Id { get; set; }
    public string Title { get; set; }
}
public class BookStore
{
    private static int _lastId = 0;
    private static List<Book> _books;
    public static int NextId => ++_lastId;
    static BookStore()
    {
        _books = new List<Book>
            {
                new Book
                {
                    Id = NextId,
                    Title = "Some cool computer book"
                }
            };
    }
    public IEnumerable<Book> Books => _books;
    public void Save(Book book)
    {
        // Create the book when it does not exist, 
        // otherwise, find its index and replace it 
        // by the specified book.
        if (_books.Any(x => x.Id == book.Id))
        {
            var index = _books.FindIndex(x => x.Id == book.Id);
            _books[index] = book;
        }
        else
        {
            _books.Add(book);
        }
    }
    public Book Load(int bookId)
    {
        return _books.FirstOrDefault(x => x.Id == bookId);
    }
}
public class BookPresenter
{
    public void Display(Book book)
    {
        Console.WriteLine($"Book: {book.Title} ({book.Id})");
    }
}

这还不能解决所有问题,但至少是一个好的开始。通过提取责任,我们实现了以下目标:

  • FailToFetchBook()用例已经固定(参见Load()方法)。
  • 现在取书更优雅、更直观。
  • 我们还打开了一个关于DisplayTheBookSomewhereElse()用例的可能性(稍后将重新讨论。

从 SRP 的角度来看,我们仍然有一两个问题:

  • 自动递增的 ID 仍然公开,并且BookStore没有管理它。
  • Save()方法是处理书籍的创建和更新,这似乎是两个职责,而不是一个。

在接下来的更新中,我们将重点关注这两个共享协同效应的问题,使它们更容易单独修复,而不是一起修复(在方法之间划分责任)。

我们将要做的是:

  1. 隐藏BookStore.NextId属性以修复封装(不是 SRP,但它是必需的)。
  2. BookStore.Save()方法分为Create()Replace()两种方法。
  3. 更新我们的用户界面:Program.cs

隐藏NextId属性后,我们需要将该特性移动到BookStore类中。最符合逻辑的地方应该是Save()方法(尚未一分为二),因为我们希望每本新书都有一个新的唯一标识符。以下是变化:

public class BookStore
{
    ...
    private static int NextId => ++_lastId;
    ...
    public void Save(Book book)
    {
        ...
        else
        {
            book.Id = NextId;
            _books.Add(book);
        }
    }
}

自动递增的标识符仍然是一个不成熟的特性。为了进一步改进它,让我们将Save()方法分成两部分。通过查看生成的代码,我们可以想象处理这两个用例更容易编写。对于将来可能接触到该代码的任何开发人员来说,它也更易于阅读和使用。你自己看看:

public void Create(Book book)
{
    if (book.Id != default(int))
    {
        throw new Exception("A new book cannot be created with an id.");
    }
    book.Id = NextId;
    _books.Add(book);
}
public void Replace(Book book)
{
    if (_books.Any(x => x.Id == book.Id))
    {
        throw new Exception($"Book {book.Id} does not exist!");
    }
    var index = _books.FindIndex(x => x.Id == book.Id);
    _books[index] = book;
}

现在我们开始有所进展。我们已经成功地将职责划分为三类,并且还将Save()方法划分为三类,这样两种方法都只处理一个操作。

Program方法现在看起来如下:

private static readonly BookStore _bookStore = new BookStore();
private static readonly BookPresenter _bookPresenter = new BookPresenter();
//...
private static void FetchAndDisplayBook()
{
    var book = _bookStore.Load(1);
    _bookPresenter.Display(book);
}
private static void FailToFetchBook()
{
    // This cannot happen anymore, this has been fixed automatically.
}
private static void BookDoesNotExist()
{
    var book = _bookStore.Load(999);
    if (book == null)
    {
        // Book does not exist
    }
}
private static void CreateOutOfOrderBook()
{
    var book = new Book
    {
        Id = 4, 
        Title = "Some out of order book"
    };
    _bookStore.Create(book); // Exception: A new book cannot be created with an id.
    _bookPresenter.Display(book);
} 
private static void DisplayTheBookSomewhereElse()
{
    Console.WriteLine("This is now possible, but we need a new Presenter; not 100% there yet!");
}
private static void CreateBook()
{
    Console.Clear();
    Console.WriteLine("Please enter the book title: ");
    var title = Console.ReadLine();
    var book = new Book { Title = title };
    _bookStore.Create(book);
}
private static void ListAllBooks()
{
    foreach (var book in _bookStore.Books)
    {
        _bookPresenter.Display(book);
    }
}

除了自动修正FailToFetchBook方法外,我发现代码更容易阅读。让我们比较一下FetchAndDisplayBook方法:

// Old
private static void FetchAndDisplayBook()
{
    var book = new Book(id: 1);
    book.Load();
    book.Display();
}
// New
private static void FetchAndDisplayBook()
{
    var book = _bookStore.Load(1);
    _bookPresenter.Display(book);
}

结论

在考虑 SRP 时,有一件事需要注意,那就是不要过多地划分类。系统中的类越多,组装系统就越复杂,调试或遵循执行路径就越困难。另一方面,许多分离良好的职责应该会导致一个更好、更可测试的系统。

不幸的是,如何描述“一个原因”或“一个单一的责任”是不可能定义的,我在这里没有一个硬性的指导方针。在没有其他人帮助您解决设计难题的情况下,如果您有疑问,我建议您将其拆分。

当您不知道如何命名元素时,可以很好地指示 SRP 冲突。这通常是一个很好的指针,元素不应该驻留在那里,应该被提取,或者应该被分割成多个较小的部分。

另一个好的指标是当一个方法变得太大时,可以选择使用许多if语句或循环。在这种情况下,您应该将该方法拆分为多个较小的方法。这将使代码更易于阅读,并使初始方法的主体更干净。它还经常帮助你摆脱无用的评论。请清楚地命名您的私有方法,否则将不利于可读性(请参见命名示例部分)。

你怎么知道一个班级什么时候变得太少了?再一次,你不会喜欢它的。我也没有任何硬性的指导方针。但是,如果您正在查看您的系统,并且您的所有类中只有一个方法,那么您可能滥用了 SRP。也就是说,我并不是说只有一个方法的类是错误的。

命名示例

在这个示例中,我们提取了一些方法,通过将 SRP 应用到下面的 long 方法中,来了解命名这些方法是如何很好地提高可读性的。让我们先看看 AUTT1 类的 AUTT0.方法:

namespace ClearName
{
    public class OneMethodExampleService : IExampleService
    {
        private readonly IEnumerable<string> _data;
        private static readonly Random _random = new Random();
        public OneMethodExampleService(IEnumerable<string> data)
        {
            _data = data ?? throw new ArgumentNullException(nameof(data));
        }
        public RandomResult RandomizeOneString()
        {
            // Find the upper bound
            var upperBound = _data.Count();
            // Randomly select the index of the string to return
            var index = _random.Next(0, upperBound);
            // Shuffle the elements to add more randomness
            var shuffledList = _data
                .Select(value => new { Value = value, Order = _random.NextDouble() })
                .OrderBy(x => x.Order)
                .Select(x => x.Value)
            ;
            // Return the randomly selected element
            var randomString = shuffledList.ElementAt(index);
            return new RandomResult(randomString, index, shuffledList);
        }
    }
}

有了所有的注释,我们可以分离出一些需要进行的操作,以便找到那个随机字符串。这些行动如下:

  • 找到_data字段的上界(anIEnumerable<string>
  • 生成下一个随机索引,我们的项目应该在该索引处执行。
  • 洗牌项目列表以添加更多随机性。
  • 返回结果,包括索引和无序数据,以便于以后显示。

一旦我们隔离了这些操作,我们就可以为每个操作提取一个方法。

笔记

我不建议系统地提取单行方法,因为它会创建大量不一定有用的代码。也就是说,如果您发现提取一行方法可以使代码更具可读性,那么一定要这样做。

考虑到这一点,我们可以提取一个方法并使其更易于阅读,就像CleanExampleService类中的方法一样。这样做导致在类中应用 SRP 以提高可读性,如我们在这里所见:

public RandomResult RandomizeOneString()
{
    var upperBound = _data.Count();
    var index = _random.Next(0, upperBound);
    var shuffledData = ShuffleData();
    var randomString = shuffledData.ElementAt(index);
    return new RandomResult(randomString, index, shuffledData);
}

在这个生成的方法中,我们甚至删除了注释,因此通过从RandomizeOneString方法中提取洗牌责任,我们使代码比以前更易于阅读。此外,通过使用描述性变量名,可以更容易地使用该方法,而无需注释。

我们只研究了代码的一小部分,但是完整的代码示例可以在 GitHub 上获得。在Startup.cs中,您可以注释掉第一行#define USE_CLEAN_SERVICE,使用OneMethodExampleService类代替CleanExampleService;他们做同样的事情。

完整样本也展示了 ISP 和 DIP 的作用;我们很快就会讨论这两个原则。阅读本章后,您应该回到这个示例(完整的源代码)。

开/关原理(OCP)

让我们引用 BertrandMeyer 的话开始本节:

软件实体(类、模块、函数等)应为扩展打开,但为修改关闭

好的,但这意味着什么,你可以问自己?这意味着类的行为应该可以从外部(也称为调用方代码)进行更新。与手动重写类内方法的代码不同,您应该能够从外部更改类行为,而不必更改代码本身。

实现这一点的最佳方法是使用多个设计良好的代码单元组装应用,并使用依赖项注入将其缝合在一起。

为了说明这一点,让我们玩一个忍者、一把剑和一个 shuriken;小心,这里的地面很危险!

以下是所有示例中使用的IAttackable接口:

public interface IAttackable { }

让我们从一个不遵循 OCP 的示例开始:

   public class Ninja : IAttackable
   {
       //…
       public AttackResult Attack(IAttackable target)
       {
           if (IsCloseRange(target))
           {
               return new AttackResult(new Sword(), this, target);
           }
           else
           {
               return new AttackResult(new Shuriken(), this, target);
           }
       }
        //…
    }

在本例中,我们可以看到Ninja类根据其目标距离选择武器。这背后的想法并没有错,但当我们向忍者的武器库添加新武器时会发生什么?此时,我们需要打开Ninja类并更新其Attack方法。

让我们通过外部设置武器和抽象Attack方法来重新思考Ninja类。我们可以在内部管理装备的武器,但为了简单起见,我们是从消费代码管理它。

我们的空壳现在看起来像这样:

public class Ninja : IAttackable
{
    public Weapon EquippedWeapon { get; set; }
    // ...
    public AttackResult Attack(IAttackable target)
    {
        throw new NotImplementedException();
    }
    // ...
}

现在,忍者的攻击与他装备的武器直接相关;例如,投掷一把舒利肯剑,同时使用一把剑进行近距离打击。OCP 规定攻击应该在其他地方处理,允许在不改变代码的情况下修改忍者的行为。

我们想要做的就是所谓的组合,而实现这一点的最佳方式就是策略模式,我们在第 6 章中对其进行了更详细的探讨,了解了策略、抽象工厂和单体设计模式以及第 7 章深入依赖注入。现在,让我们忘掉这些细节,让我们玩一些遵循 OCP 的代码。

Attack方法如下:

public AttackResult Attack(IAttackable target)
{
    return new AttackResult(EquippedWeapon, this, target);
}

它现在做的和最初一样,但是我们可以添加武器,设置EquippedWeapon属性,程序应该使用新武器;不再需要为此更改Ninja类。

好吧,让我们诚实点;那代码什么都不做。它只允许我们打印出程序中发生的事情,并展示如何在不修改类本身的情况下修改行为。然而,我们可以从那里开始创建一个小的忍者游戏。我们可以管理忍者的位置来计算他们之间的实际距离,并强制每个武器的最小和最大射程,但这远远超出了本节的范围。

现在,让我们来看看 AutoT0.类。我们可以看到它是一个不包含任何行为的小数据结构。我们的程序使用它来输出结果:

public class AttackResult
{
    public Weapon Weapon { get; }
    public IAttackable Attacker { get; }
    public IAttackable Target { get; }
    public AttackResult(Weapon weapon, IAttackable attacker, IAttackable target)
    {
        Weapon = weapon;
        Attacker = attacker;
        Target = target;
    }
}

Startup类中的程序代码显示为如下:

// Setup the response
context.Response.ContentType = "text/html";
// Create actors
var target = new Ninja("The Unseen Mirage");
var ninja = new Ninja("The Blue Phantom");
// First attack (Sword)
ninja.EquippedWeapon = new Sword();
var result = ninja.Attack(target);
await PrintAttackResult(result);
// Second attack (Shuriken)
ninja.EquippedWeapon = new Shuriken();
var result2 = ninja.Attack(target);
await PrintAttackResult(result2);
// Write the outcome of an AttackResult to the response stream
async Task PrintAttackResult(AttackResult attackResult)
{
    await context.Response.WriteAsync($"'{attackResult.Attacker}' attacked '{attackResult.Target}' using'{attackResult.Weapon}'!<br>");
}

运行程序时,使用dotnet run命令,浏览到https ://localhost:5001/时,我们应该有以下输出:

'The Blue Phantom' attacked 'The Unseen Mirage' using 'Sword'!
'The Blue Phantom' attacked 'The Unseen Mirage' using 'Shuriken'!

在更复杂的应用中,组合和依赖项注入相结合将允许从一个地方(称为组合根)将行为更改应用到整个程序,而无需更改现有代码;“开放供扩展,关闭供修改。”要添加新武器,我们可以创建新类,而不需要修改任何现有类。

所有这些新术语一开始都可能是压倒性的,但我们将在后续章节中更详细地介绍它们,并在本书中广泛使用这些技巧。

一点历史

OCP 的第一次出现是在 1988 年,当时它指的是继承,从那时起 OOP 已经进化了很多。大多数情况下,您应该选择组合而不是继承。继承仍然是一个有用的概念,但在使用它时应该小心;这是一个容易被误用的概念,在类之间创建了直接耦合。

利斯科夫替代原理(LSP)

LSP 起源于 80 年代末的芭芭拉·利斯科夫(Barbara Liskov),并在 90 年代被利斯科夫和珍妮特·荣格(Jeannette Wing)重新审视,以创造出我们今天所知道和使用的原则。它也类似于 Bertrand Meyer 的合同设计

LSP 的重点是保持子类型行为,这将导致系统的稳定性。在继续之前,让我们先从 Wing 和 Liskov 引入的正式定义开始:

假设是关于 T 类型的对象的可证明属性。那么,对于 S 类型的对象应该为真,其中 S 是 T 的子类型。

这意味着您应该能够将 T 类型的对象与 S 类型的对象交换,其中 S 是 T 的子类型,而不会破坏程序的正确性。

如果不付出一些努力,你就不能违反 C#中的以下规则,但它们仍然值得一提:

  • 子类型中方法参数的逆变。
  • 子类型中返回类型的协方差。

打破逆差的一种方法是测试特定的子类型,例如:

public void SomeMethod(SomeType input)
{
    if (input is SomeSubType)
    // …
}

打破协方差的一种方法是将超类型作为子类型返回,这需要开发人员进行一些工作。

然后,为了证明子类型的正确性,我们必须遵循以下几条规则:

  • 在超类型中实现的任何先决条件都应该在其子类型中产生相同的结果,但子类型对它的要求可以不那么严格,永远不会更严格。
  • 在超类型中实现的任何后置条件都应该在其子类型中产生相同的结果,但是子类型可以对其更严格,而不是更少。
  • 子类型必须保持超类型的不变性;换句话说,超类型的行为不能改变。

最后,我们必须将“历史约束”添加到该规则列表中,这表明在超类型中发生的事情必须仍然发生在子类型中。虽然子类型可以添加新的属性和方法(换句话说,新的行为),但它们不能以任何新的方式修改超类型状态。

好的,在这一点上,你觉得这相当复杂是对的。请放心,这是这些原则中不太重要的,但更为复杂,我们正在尽可能远离继承,因此这不应该经常适用。

也就是说,我将通过执行以下操作来恢复之前的所有复杂性:

  • 在子类型中,添加新行为;不要改变现有的。

您应该能够通过一个子类交换一个类,而不会破坏任何东西。

需要注意的是,“不破坏任何东西”包括不在子类型中抛出新异常。父类型引发的子类型异常是可以接受的,因为现有代码应该已经处理了这些异常,并且如果子类型异常已经处理了,那么就捕获它们。

作为旁注,在开始处理 LSP 之前,先应用继承中的“is-a”规则;如果子类型不是超类型,则不要使用继承。

做一个乐高®的类比:LSP 就像用一个 4x2 的蓝色积木替换一个 4x2 的绿色积木:结构的完整性和积木的作用都没有改变,只是改变了它的颜色。

提示

实施这些行为约束的一个很好的方法是自动化测试。您可以编写一个测试套件,并针对特定超类型的所有子类运行它,以确保保留行为。

让我们跳进一些代码,在实践中将其可视化。

项目-Hallofame

现在,让我们看看这在代码中是什么样子。对于这一个,我们探索一个我们正在开发的虚拟游戏的名人堂功能。

功能描述:游戏应该累积在游戏期间杀死的敌人的数量,如果你杀死了至少 100 个敌人,你的忍者应该到达名人堂。名人堂应该从最好的分数排列到最差的分数。

我们创建了以下自动测试来执行这些规则,sut(测试对象)的类型为HallOfFame。下面是HallOfFame类的空实现:

public class HallOfFame
{
    public virtual void Add(Ninja ninja)
        => throw new NotImplementedException();
    public virtual IEnumerable<Ninja> Members
        => throw new NotImplementedException();
}

笔记

我没有遵循我在上一章中提到的约定,因为我需要继承来重用代码的三个版本的测试套件。使用嵌套类不可能做到这一点。

Add()方法应该添加杀死 100 以上敌人的忍者:

public static TheoryData<Ninja> NinjaWithAtLeast100Kills => new TheoryData<Ninja>
{
    new Ninja { Kills = 100 },
    new Ninja { Kills = 101 },
    new Ninja { Kills = 200 },
};
[Theory]
[MemberData(nameof(NinjaWithAtLeast100Kills))]
public void Add_should_add_the_specified_ninja(Ninja expectedNinja)
{
    // Act
    sut.Add(expectedNinja);
    // Assert
    Assert.Collection(sut.Members,
        ninja => Assert.Same(expectedNinja, ninja)
    );
}

Add()方法不应多次添加忍者:

[Fact]
public void Add_should_not_add_existing_ninja()
{
    // Arrange
    var expectedNinja = new Ninja { Kills = 200 };
    // Act
    sut.Add(expectedNinja);
    sut.Add(expectedNinja);
    // Assert
    Assert.Collection(sut.Members,
        ninja => Assert.Same(expectedNinja, ninja)
    );
}

Add()方法应验证忍者在将其添加到被测HallOfFame实例的Members集合之前至少有 100 次死亡:

[Fact]
public void Add_should_not_add_ninja_with_less_than_100_kills()
{
    // Arrange
    var ninja = new Ninja { Kills = 99 };
    // Act
    sut.Add(ninja);
    // Assert
    Assert.Empty(sut.Members);
}

HallOfFame类的Members属性应返回其忍者,按其击杀次数排序,从最多到最少:

[Fact]
public void Members_should_return_ninja_ordered_by_kills_desc()
{
    // Arrange
    sut.Add(new Ninja { Kills = 100 });
    sut.Add(new Ninja { Kills = 150 });
    sut.Add(new Ninja { Kills = 200 });
    // Act
    var result = sut.Members;
    // Assert
    Assert.Collection(result,
        ninja => Assert.Equal(200, ninja.Kills),
        ninja => Assert.Equal(150, ninja.Kills),
        ninja => Assert.Equal(100, ninja.Kills)
    );
}

HallOfFame类的实现如下所示:

public class HallOfFame
{
    protected HashSet<Ninja> InternalMembers { get; } = new HashSet<Ninja>();
    public virtual void Add(Ninja ninja)
    {
        if (InternalMembers.Contains(ninja))
        {
            return;
        }
        if (ninja.Kills >= 100)
        {
            InternalMembers.Add(ninja);
        }
    }
    public virtual IEnumerable<Ninja> Members
        => new ReadOnlyCollection<Ninja>(
            InternalMembers
                .OrderByDescending(x => x.Kills)
                .ToArray()
        );
}

现在,我们已经完成了我们的功能和推动我们的变化,我们演示名人堂给我们的客户。

更新 1

在演示之后,一个想法出现了:为什么不为没有资格进入名人堂的玩家添加英雄堂?经过审议,我们决定实施该功能。

功能描述:游戏应该累积游戏中被杀死的敌人的数量(已经完成),并将所有忍者添加到英雄大厅中,无论分数如何。结果应该先按最佳分数排序,按降序排列,每个忍者只能出现一次。

快速实现此功能的第一个想法是重用名人堂代码。第一步,我们决定创建一个继承HallOfFame类的HallOfHeroes类,并重写Add()方法以支持新规范。

经过思考,你认为这种改变会打破 LSP 吗?

在给出答案之前,让我们来看看这个 T0 T0 类:

namespace LSP.Examples.Update1
{
    public class HallOfHeroes : HallOfFame
    {
        public override void Add(Ninja ninja)
        {
            if (InternalMembers.Contains(ninja))
            {
                return;
            }
            InternalMembers.Add(ninja);
        }
    }
}

由于 LSP 声明子类可以对前提条件不那么严格,因此删除 kill 前提条件的数量应该是可以接受的。

现在,如果我们使用HallOfHeroes来运行为HallOfFame构建的测试,唯一失败的测试是与我们的前提条件相关的测试,因此子类没有改变任何行为,所有用例仍然有效。

为了更有效地测试我们的特性,我们可以将所有共享测试封装到一个基类中,但只为HallOfFame测试保留Add_should_not_add_ninja_with_less_than_100_kills

有了它来验证我们的代码,我们就可以开始探索 LSP 的作用,因为我们可以在程序需要HallOfFame实例的地方使用HallOfHeroes的实例,而不会破坏它。

以下是完整的BaseLSPTest课程:

namespace LSP.Examples
{
    public abstract class BaseLSPTest
    {
        protected abstract HallOfFame sut { get; }
        public static TheoryData<Ninja> NinjaWithAtLeast100Kills => new TheoryData<Ninja>
        {
            new Ninja { Kills = 100 },
            new Ninja { Kills = 101 },
            new Ninja { Kills = 200 },
        };
        [Fact]
        public void Add_should_not_add_existing_ninja()
        {
            // Arrange
            var expectedNinja = new Ninja { Kills = 200 };
            // Act
            sut.Add(expectedNinja);
            sut.Add(expectedNinja);
            // Assert
            Assert.Collection(sut.Members,
                ninja => Assert.Same(expectedNinja, ninja)
            );
        }
        [Theory]
        [MemberData(nameof(NinjaWithAtLeast100Kills))]
        public void Add_should_add_the_specified_ninja(Ninja expectedNinja)
        {
            // Act
            sut.Add(expectedNinja);
            // Assert
            Assert.Collection(sut.Members,
                ninja => Assert.Same(expectedNinja, ninja)
            );
        }
        [Fact]
        public void Members_should_return_ninja_ordered_by_kills_desc()
        {
            // Arrange
            sut.Add(new Ninja { Kills = 100 });
            sut.Add(new Ninja { Kills = 150 });
            sut.Add(new Ninja { Kills = 200 });
            // Act
            var result = sut.Members;
            // Assert
            Assert.Collection(result,
                ninja => Assert.Equal(200, ninja.Kills),
                ninja => Assert.Equal(150, ninja.Kills),
                ninja => Assert.Equal(100, ninja.Kills)
            );
        }
    }
}

HallOfFameTest类比类简单,如下所示:

using LSP.Models;
using Xunit;
namespace LSP.Examples
{
    public class HallOfFameTest : BaseLSPTest
    {
        protected override HallOfFame sut { get; } = new HallOfFame();
        [Fact]
        public void Add_should_not_add_ninja_with_less_than_100_kills()
        {
            // Arrange
            var ninja = new Ninja { Kills = 99 };
            // Act
            sut.Add(ninja);
            // Assert
            Assert.Empty(sut.Members);
        }
    }
}

最后,HallOfHeroesTest类的几乎为空:

using LSP.Models;
namespace LSP.Examples.Update1
{
    public class HallOfHeroesTest : BaseLSPTest
    {
        protected override HallOfFame sut { get; } = new HallOfHeroes();
    }
}

这项新功能已经实现,但我们还没有完成。

更新 2

后来,游戏使用了这些类。然而,另一位开发人员 Joe 决定在新功能中使用HallOfHeroes,但他需要知道何时添加了重复的忍者,因此他决定用throw new DuplicateNinjaException()替换return;语句。他为自己的长相感到自豪,并向团队展示了这一点。

你认为乔的更新会破坏 LSP 吗?

更改后,该类如下所示:

using LSP.Models;
using System;
namespace LSP.Examples.Update2
{
    public class HallOfHeroes : HallOfFame
    {
        public override void Add(Ninja ninja)
        {
            if (InternalMembers.Contains(ninja))
            {
                throw new DuplicateNinjaException();
            }
            InternalMembers.Add(ninja);
        }
    }
    public class DuplicateNinjaException : Exception
    {
        public DuplicateNinjaException()
            : base("Cannot add the same ninja twice!") { }
    }
}

是的,它违反了 LSP。此外,如果我们的工程师已经运行了测试,那么很明显有一个测试失败了!

你认为什么违反了 LSP?

所有现有代码都不希望HallOfFame实例在任何地方抛出DuplicateNinjaException,因此可能会造成运行时崩溃,可能会破坏游戏。根据 LSP,禁止在子类中抛出新异常。

更新 3

为了纠正错误并使符合 LSP,我们的工程师决定在HallOfHeroes类中添加AddingDuplicateNinja事件,然后订阅该事件,而不是捕获DuplicateNinjaException

这能解决之前的 LSP 违规问题吗?

更新后的代码如下所示:

using LSP.Models;
using System;
namespace LSP.Examples.Update3
{
    public class HallOfHeroes : HallOfFame
    {
        public event EventHandler<AddingDuplicateNinjaEventArgs> AddingDuplicateNinja;
        public override void Add(Ninja ninja)
        {
            if (InternalMembers.Contains(ninja))
            {
                OnAddingDuplicateNinja(new AddingDuplicateNinjaEventArgs(ninja));
                return;
            }
            InternalMembers.Add(ninja);
        }
        protected virtual void OnAddingDuplicateNinja(AddingDuplicateNinjaEventArgs e)
        {
            AddingDuplicateNinja?.Invoke(this, e);
        }
    }
    public class AddingDuplicateNinjaEventArgs : EventArgs
    {
        public Ninja DuplicatedNinja { get; }
        public AddingDuplicateNinjaEventArgs(Ninja ninja)
        {
            DuplicatedNinja = ninja ?? throw new ArgumentNullException(nameof(ninja));
        }
    }
}

是的,该修复允许现有代码在添加 Joe 所需的新功能的同时平稳运行。发布event而不是抛出Exception只是解决我们虚构问题的一种方法。在现实生活中,您应该选择最适合您的问题的解决方案。

上一个示例的重要部分是引入一个新的异常类型似乎是无害的,但会造成很大的危害。其他违反 LSP 的情况也是如此。

结论

再一次,这只是一项原则,而不是一项法律。一个很好的提示是将 LSP 的违反视为代码气味。从这里开始,进行一些分析,看看您是否有设计问题以及可能产生的影响。在个案的基础上使用你的分析技能,并得出结论,在特定情况下,打破 LSP 是否可以接受。

我认为我们可以也将这一原则命名为向后兼容性原则,因为之前以某种方式工作的所有东西在替换后都应该至少以同样的方式工作,这就是为什么这一原则很重要。

我们越是进步,就越是远离继承,就越不需要这个原则。请不要误解我的意思,如果您使用继承,请尽最大努力应用 LSP,这样做很可能会给您带来回报。

接口隔离原则(ISP)

让我们从罗伯特·C·马丁的另一句名言开始:

“许多特定于客户端的接口比一个通用接口要好。”

这是什么意思?它的意思是:

  • 您应该创建接口。
  • 您应该更加重视小型接口。
  • 您不应该尝试创建一个多用途接口,作为“一个管理所有接口的接口”

接口在这里可以指类接口(类的所有公开元素),但我更喜欢关注 C#接口,因为在本书中我们经常使用它们。如果你知道 C++,你可以看到一个接口作为头文件。

什么是接口?

接口是 C#box 中最有用的工具之一,可以创建灵活且可维护的软件。我将试图给你一个接口是什么的清晰定义,但别担心;从解释中很难理解和掌握接口的威力。

  • 接口的作用是定义内聚契约(公共方法、属性和事件);接口中没有代码;这只是一份合同。
  • 一个接口应该是小型的(ISP),它的成员应该朝着一个共同的目标(内聚)对齐,并分担一个单一的责任(SRP)。
  • 在 C#中,一个类可以实现多个接口,通过这样做,一个类可以公开这些公共契约的多个,或者更准确地说,可以作为它们中的任何一个(多态性)。

老实说,这个定义仍然有点抽象,但请放心,我们在本书中大量使用接口,因此到最后,接口不应该为您保留很多秘密。

另一个更基本的问题

类不从接口继承;它实现了一个接口。但是,一个接口可以从另一个接口继承。

项目–门锁

查看合同的一种方式是将其视为钥匙和锁。每把钥匙配一把锁,这是基于一个特定的合同,合同规定了钥匙的工作方式。如果我们有多个锁遵循相同的合同,一把钥匙应该适合所有这些锁,而多把钥匙也可以适合相同的锁,只要它们是相同的。

界面背后的理念是相同的;接口描述什么是可用的,实现决定它是如何做的,让使用者期望行为(通过遵循契约)发生,而忽略它在后台(通过实现)是如何做的。

我们的关键合同如下所示:

/// <summary>
/// Represents a key that can open zero or more locks.
/// </summary>
public interface IKey
{
    /// <summary>
    /// Gets the key's signature.
    /// This should be used by <see cref="ILock"/> to decide whether or not the key matches the lock.
    /// </summary>
    string Signature { get; }
}

而我们的锁合同是这样出现的:

/// <summary>
/// Represents a lock than can be opened by zero or more keys.
/// </summary>
public interface ILock
{
    /// <summary>
    /// Gets if the lock is locked or not.
    /// </summary>
    bool IsLocked { get; }
    /// <summary>
    /// Locks the lock using the specified <see cref="IKey"/>.
    /// </summary>
    /// <param name="key">The <see cref="IKey"/> used to lock the lock.</param>
    /// <exception cref="KeyDoesNotMatchException">The <see cref="Exception"/> that is thrown when the specified <see cref="IKey"/> does not match the <see cref="ILock"/>.</exception>
    void Lock(IKey key);
    /// <summary>
    /// Unlocks the lock using the specified <see cref="IKey"/>.
    /// </summary>
    /// <param name="key">The <see cref="IKey"/> used to unlock the lock.</param>
    /// <exception cref="KeyDoesNotMatchException">The <see cref="Exception"/> that is thrown when the specified <see cref="IKey"/> does not match the <see cref="ILock"/>.</exception>
    void Unlock(IKey key);
    /// <summary>
    /// Validate that the key's <see cref="IKey.Signature"/> match the lock.
    /// </summary>
    /// <param name="key">The <see cref="IKey"/> to validate.</param>
    /// <returns><c>true</c> if the key's <see cref="IKey.Signature"/> match the lock; otherwise <c>false</c>.</returns>
    bool DoesMatch(IKey key);
}

正如您所看到的,合同是明确的,并且添加了一些细节来描述在使用它或在实施它时预期会发生什么。

笔记

这些规范可以通过验证实现是否尊重其契约来帮助实施 LSP 的扩展视图,从而允许使用者安全地使用接口的任何实现。

请注意,很少像我这样在接口级别定义异常。在我们的案例中,我觉得这样做更有意义,使合同的描述清晰明了,而不是返回可能误导的bool。此外,返回bool会导致对故障源缺乏反馈。我们可以返回一个对象或选择另一个解决方案,但这会给代码示例增加不必要的复杂性。在本书的后面部分,我们正在探索类似问题的替代方案。

让我们来看看基本的密钥和锁实现。

基本实现

物理钥匙和锁易于可视化。钥匙有凹口和脊,长度和厚度由特定的材料制成,使其具有颜色,等等。另一方面,锁由销和弹簧组成。当您将正确的钥匙插入正确的锁时,您可以锁定或解锁它。

在我们的例子中,为了保持简单,我们使用IKey接口的Signature属性来表示物理密钥的属性,而锁可以按照自己的意愿处理密钥。

我们最基本的密钥和锁实现如下所示:

public class BasicKey : IKey
{
    public BasicKey(string signature)
    {
        Signature = signature ?? throw new ArgumentNullException(nameof(signature));
    }
    public string Signature { get; }
}
public class BasicLock : ILock
{
    private readonly string _expectedSignature;
    public BasicLock(string expectedSignature)
    {
        _expectedSignature = expectedSignature ?? throw new ArgumentNullException(nameof(expectedSignature));
    }
    public bool IsLocked { get; private set; }
    public bool DoesMatch(IKey key)
    {
        return key.Signature.Equals(_expectedSignature);
    }
    public void Lock(IKey key)
    {
        if (!DoesMatch(key))
        {
            throw new KeyDoesNotMatchException(key);
        }
        IsLocked = true;
    }
    public void Unlock(IKey key)
    {
        if (!DoesMatch(key))
        {
            throw new KeyDoesNotMatchException(key);
        }
        IsLocked = false;
    }
}

如您所见,这些实现正在执行接口及其///注释所描述的操作,使用名为_expectedSignature的私有字段验证密钥的签名。

为了简洁起见,我没有在这里复制所有的测试,但是这个示例的大部分代码都包含在单元测试中,您可以在 GitHub 上浏览或本地克隆。下面是一个例子,涵盖了DoesMatch方法的规范:

using Xunit;
namespace DoorLock
{
    public class BasicLockTest
    {
        private readonly IKey _workingKey;
        private readonly IKey _invalidKey;
        private readonly BasicLock sut;
        public BasicLockTest()
        {
            sut = new BasicLock("WorkingKey");
            _invalidKey = new BasicKey("InvalidKey");
            _workingKey = new BasicKey("WorkingKey");
        }
        public class DoesMatch : BasicLockTest
        {
            [Fact]
            public void Should_return_true_when_the_key_matches_the_lock()
            {
                // Act
                var result = sut.DoesMatch(_workingKey);
                // Assert
                Assert.True(result, "The key should match the lock.");
            }
            [Fact]
            public void Should_return_false_when_the_key_does_not_match_the_lock()
            {
                // Act
                var result = sut.DoesMatch(_invalidKey);
                // Assert
                Assert.False(result, "The key should not match the lock.");
            }
        }
        //...
    }
}

我们可以看到,DoesMatch的测试是接口///注释的直接表示:

<returns><c>true</c> if the key's <see cref="IKey.Signature"/> match the lock; otherwise <c>false</c>.</returns>.

在深入研究 ISP 之前,让我们先了解更多的实现。

多锁实现

为了证明小型、定义良好的接口很重要,让我们实现一种特殊类型的锁:MultiLock类包含其他锁:

public class MultiLock : ILock
{
    private readonly List<ILock> _locks;
    public MultiLock(List<ILock> locks)
    {
        _locks = locks ?? throw new ArgumentNullException(nameof(locks));
    }
    public MultiLock(params ILock[] locks)
        : this(new List<ILock>(locks))
    {
        if (locks == null) { throw new ArgumentNullException(nameof(locks)); }
    }
    public bool IsLocked => _locks.Any(@ lock => @ lock.IsLocked);
    public bool DoesMatch(IKey key)
    {
        return _locks.Any(@ lock => @ lock.DoesMatch(key));
    }
    public void Lock(IKey key)
    {
        if (!DoesMatch(key))
        {
            throw new KeyDoesNotMatchException(key);
        }
        _locks
            .Where(@ lock => @ lock.DoesMatch(key))
            .ToList()
            .ForEach(@ lock => @ lock.Lock(key));
    }
    public void Unlock(IKey key)
    {
        if (!DoesMatch(key))
        {
            throw new KeyDoesNotMatchException(key);
        }
        _locks
            .Where(@ lock => @ lock.DoesMatch(key))
            .ToList()
            .ForEach(@ lock => @ lock.Unlock(key));
    }
}

这个新类允许使用者创建一个由其他锁组成的锁。MultiLock保持锁定状态,直到所有锁解锁,并在任何锁锁定后立即锁定。

作为旁注

MultiLock类实现了本书后面讨论的复合设计模式。

撬锁

现在我们有了安全锁,有人需要创建一个撬锁,但是我们将如何创建它?你认为撬锁是一种IKey吗?

也许是另一种设计;在我们这里,不,撬锁不是钥匙。因此,与其包装IKey接口的使用,不如创建一个定义 picklock 的IPicklock接口:

/// <summary>
/// Represent a tool that can be used to pick a lock.
/// </summary>
public interface IPicklock
{
    /// <summary>
    /// Create a key that fits the specified <see cref="ILock"/>.
    /// </summary>
    /// <param name="lock">The lock to pick.</param>
    /// <returns>The key that fits the specified <see cref="ILock"/>.</returns>
    /// <exception cref="ImpossibleToPickTheLockException">
    /// The <see cref="Exception"/> that is thrown when a lock cannot be picked using the current <see cref="IPicklock"/>.
    /// </exception>
    IKey CreateMatchingKeyFor(ILock @lock);
    /// <summary>
    /// Unlock the specified <see cref="ILock"/>.
    /// </summary>
    /// <param name="lock">The lock to pick.</param>
    /// <exception cref="ImpossibleToPickTheLockException">
/// The <see cref="Exception"/> that is thrown when a 
lock cannot be picked using the current <see 
        cref="IPicklock"/>.
    /// </exception>
    void Pick(ILock @lock);
}

再一次,我在界面上使用///编写规范,包括异常。

初始实现基于IKey.Signature的集合。该集合被注入构造函数,以便我们可以重用我们的 picklock。我们可以将其视为预定义的钥匙集合,一种钥匙环:

public class PredefinedPicklock : IPicklock
{
    private readonly string[] _signatures;
    public PredefinedPicklock(string[] signatures)
    {
        _signatures = signatures ?? throw new ArgumentNullException(nameof(signatures));
    }
    public IKey CreateMatchingKeyFor(ILock @lock)
    {
        var key = new FakeKey();
        foreach (var signature in _signatures)
        {
            key.Signature = signature;
            if (@ lock.DoesMatch(key))
            {
                return key;
            }
        }
        throw new ImpossibleToPickTheLockException(@lock);
    }
    public void Pick(ILock @lock)
    {
        var key = new FakeKey();
        foreach (var signature in _signatures)
        {
            key.Signature = signature;
            if (@ lock.DoesMatch(key))
            {
                @ lock.Unlock(key);
                if (!@ lock.IsLocked)
                {
                    return;
                }
            }
        }
        throw new ImpossibleToPickTheLockException(@lock);
    }
    private class FakeKey : IKey
    {
        public string Signature { get; set; }
    }
}

从该示例中,我们可以看到名为FakeKeyIKey的私有实现。我们在PredefinedPicklock类中使用该实现来模拟一个键,并向我们试图选取的ILock实例发送一个IKey.Signature。不幸的是,PredefinedPicklock的功能非常有限。

从这个示例开始显示接口的强度。如果我们看一看名为Should_unlock_the_specified_ILockPick测试方法,我们可以看到如何利用ILock接口的使用,在不知道它在测试用例中的情况下,针对不同类型的锁进行测试:

using Xunit;
namespace DoorLock
{
    public class PredefinedPicklockTest
    {
        //...
        public class Pick : PredefinedPicklockTest
        {
            public static TheoryData<ILock> PickableLocks = new TheoryData<ILock>
            {
                new BasicLock("key1", isLocked: true),
                new MultiLock(
                    new BasicLock("key2", isLocked: true), 
                    new BasicLock("key3", isLocked: true)
                ),
                new MultiLock(
                    new BasicLock("key2", isLocked: true),
                    new MultiLock(
                        new BasicLock("key1", isLocked: true),
                        new BasicLock("key3", isLocked: true)
                    )
                )
            };
            [Theory]
            [MemberData(nameof(PickableLocks))]
            public void Should_unlock_the_specified_ILock(ILock @lock)
            {
                // Arrange
                Assert.True(@ lock.IsLocked, "The lock should be locked.");
                var sut = new PredefinedPicklock(new[] { "key1", "key2", "key3" });
                // Act
                sut.Pick(@lock);
                // Assert
                Assert.False(@ lock.IsLocked, "The lock should be unlocked.");
            }
            //...
        }
    }
}

这只是开始。通过使用接口,我们可以不费吹灰之力地增加灵活性。我们可以将这个示例扩展一段时间,比如创建一个尝试自动生成IKey签名的BruteForcePickLock实现。最后一个想法对你来说可能是一个有用的练习。

合同测试

在继续之前,我想先看看ContractsTests课程。该课程包含我们对钥匙和门的初步评估:

using System.Collections.Generic;
using Xunit;
namespace DoorLock
{
    public class ContractsTests
    {
        [Fact]
        public void A_single_key_should_fit_multiple_locks_expecting_the_same_signature()
        {
            IKey key = new BasicKey("key1");
            LockAndAssertResult(new BasicLock("key1"));
            LockAndAssertResult(new BasicLock("key1"));
            LockAndAssertResult(new MultiLock(new List<ILock> {
                new BasicLock("key1"),
                new BasicLock("key1")
            }));
            void LockAndAssertResult(ILock @lock)
            {
                var result = @ lock.DoesMatch(key);
                Assert.True(result, $"The key '{key.Signature}' should fit the lock");
            }
        }
        [Fact]
        public void Multiple_keys_with_the_same_signature_should_fit_the_same_lock()
        {
            ILock @lock = new BasicLock("key1");
            var picklock = new PredefinedPicklock(new[] { "key1" });
            var fakeKey = picklock.CreateMatchingKeyFor(@lock);
            LockAndAssertResult(new BasicKey("key1"));
            LockAndAssertResult(new BasicKey("key1"));
            LockAndAssertResult(fakeKey);
            void LockAndAssertResult(IKey key)
            {
                var result = @ lock.DoesMatch(key);
                Assert.True(result, $"The key '{key.Signature}' should fit the lock");
            }
        }
    }
}

在这两种测试方法中,我们可以看到接口的可重用性和多功能性。无论锁是什么,我们都可以从它的接口推断出它的用法,减少重复代码。

在一个程序中,正如我们在本书中所探讨的,我们可以出于多种原因利用这些接口,包括依赖注入。

笔记

如果您想知道我如何在方法中编写方法,我们将在第 4 章中讨论表达式体函数成员(C#6),使用 Razor的 MVC 模式。

本例的结论

既然我们已经讨论了所有这些,为什么更小的接口更好?让我们首先将所有接口合并为一个,如下所示:

public interface IMixedInterface
{
    IKey CreateMatchingKeyFor(ILock @lock);
    void Pick(ILock @lock);
    string Signature { get; }
    bool IsLocked { get; }
    void Lock(IKey key);
    void Unlock(IKey key);
    bool DoesMatch(IKey key);
}

当你看到它时,界面告诉你什么?就我个人而言,它告诉我,这里有太多的责任,我很难围绕它建立一个系统。

该接口的主要问题是,每个门都是钥匙和撬锁,因此没有意义。通过拆分接口,可以更轻松地实现系统中不同的、更小的部分,而不必妥协。如果我们想要一个钥匙同时也是一个撬锁,我们可以实现IKeyIPicklock接口,但不要求所有钥匙都是撬锁。

让我们跳到另一个例子来添加更多透视图。

项目-库

上下文:我们正在构建一个用户具有不同角色的 web 应用;有些是管理员,有些只是在使用应用。管理员可以读取和写入系统中的所有数据,而普通用户只能读取。在 UI 方面,有两个不同的部分:公共 UI 和管理面板。

由于不允许用户编写数据,我们不想在那里公开这些方法,以防某些开发人员在某个时候决定使用它们。我们不希望未使用的代码停留在不应该使用该代码的地方。另一方面,我们也不想创建两个类,一个读类和一个写类;我们宁愿只保留一个数据访问类,这样应该更易于维护。

要做到这一点,我们首先通过提取接口来重构早期的BookStore类。为了提高可读性,我们将Load()方法重命名为Find(),然后添加Remove()方法;以前不见了。新界面如下所示:

public interface IBookStore
{
    IEnumerable<Book> Books { get; }
    Book Find(int bookId);
    void Create(Book book);
    void Replace(Book book);
    void Remove(Book book);
}

然后,为了确保使用者不能从外部修改我们的IBookStore实例(封装),我们还需要更新BookStore类的Books属性,以直接返回类型ReadOnlyCollection<Book>,而不是_books字段。这并不影响接口,只影响我们的实现,但它也允许我介绍这个概念。

笔记

System.Collections.ObjectModel命名空间包含几个只读类:

a) ReadOnlyCollection<T>

b) ReadOnlyDictionary<TKey,TValue>

c) ReadOnlyObservableCollection<T>

对于向客户机公开数据而不允许客户机修改数据,这些工具非常有用。在我们的示例中,IEnumerable<Book>实例属于ReadOnlyCollection<Book>类型。我们本可以继续返回我们的内部List<Book>,但一些聪明的开发人员可能会发现这一点,将IEnumerable<Book>转换为List<Book>,并向其添加一些书籍,从而打破封装!

现在我们来看更新的BookStore类:

public class BookStore : IBookStore
{
    private static int _lastId = 0;
    private static List<Book> _books;
    private static int NextId => ++_lastId;
    static BookStore()
    {
        _books = new List<Book>
        {
            new Book
            {
                Id = NextId,
                Title = "Some cool computer book"
            }
        };
    }
    public IEnumerable<Book> Books => new ReadOnlyCollection<Book>(_books);
    public Book Find(int bookId)
    {
        return _books.FirstOrDefault(x => x.Id == bookId);
    }
    public void Create(Book book)
    {
        if (book.Id != default(int))
        {
            throw new Exception("A new book cannot be created with an id.");
        }
        book.Id = NextId;
        _books.Add(book);
    }
    public void Replace(Book book)
    {
        if (_books.Any(x => x.Id == book.Id))
        {
            throw new Exception($"Book {book.Id} does not exist!");
        }
        var index = _books.FindIndex(x => x.Id == book.Id);
        _books[index] = book;
    }
    public void Remove(Book book)
    {
        if (_books.Any(x => x.Id == book.Id))
        {
            throw new Exception($"Book {book.Id} does not exist!");
        }
        var index = _books.FindIndex(x => x.Id == book.Id);
        _books.RemoveAt(index);
    }
}

在查看代码时,如果我们在公共 UI 中公开接口,那么我们也会公开写接口,这是我们想要避免的。

为了解决我们的设计问题,我们可以使用 ISP,通过将IBookStore接口分为两部分启动:IBookReaderIBookWriter

public interface IBookReader
{
    IEnumerable<Book> Books { get; }
    Book Find(int bookId);
}
public interface IBookWriter
{
    void Create(Book book);
    void Replace(Book book);
    void Remove(Book book);
}

通过遵循 ISP,我们甚至可以将IBookWriter分为三个接口:IBookCreatorIBookReplacerIBookRemover。警告一句,我们必须小心,因为这样做的超粒度接口分离可能会在系统中造成相当大的混乱,但也可能非常有益,这取决于上下文和您的目标。

提示

所以,我给你一点建议。注意不要盲目地过度使用这一原则,要考虑到内聚性和您试图构建的内容,而不是盲目地考虑接口的粒度。界面越精细,系统就越灵活,但请记住,灵活性是有成本的,而且成本很快就会变得非常高。

现在,我们需要更新我们的BookStore类。首先,我们必须实现两个新接口:

public class BookStore : IBookReader, IBookWriter
{
    // ...
}

那很容易!有了新的BookStore类,我们可以像这样独立使用IBookReaderIBookWriter

IBookReader reader = new BookStore();
IBookWriter writer = new BookStore();
// ...
var book3 = reader.Find(3);
// ...
writer.Create(new Book { Title = "Some nice new title!" });
// ...

让我们关注readerwriter变量。在公共方面,我们现在只能使用IBookReader接口,将BookStore实现隐藏在接口后面。在管理员端,我们可以使用这两个接口来管理书籍。

结论

为了恢复 ISP 背后的想法,如果您有多个较小的接口,则更容易重用它们,并且只公开您需要的功能,而不是公开不需要的 API。这就是目标:只依赖于您使用的接口。此外,有了多个专门的接口,如果需要的话,通过实现多个接口更容易组成更大的部分。如果我们将其与相反的情况进行比较,那么如果在一个大接口的实现中不需要方法,我们就无法从中删除方法。

如果你还没有看到所有的好处,不要担心。一旦我们讨论了下一个原理、DIP 和依赖注入,所有的部分都应该结合在一起。通过所有这些,我们可以以一种优雅且易于管理的方式实现充分的接口隔离。

相关性反转原理(DIP)

还有一段引用自罗伯特·C·马丁(Robert C.Martin)(包括维基百科的隐含上下文):

人们应该“依赖抽象,而不是具体。”

在上一节中,我向您介绍了与 SRP 和 ISP 的接口。界面是我们坚实武器库的关键元素之一!此外,使用界面是接近倾角的最佳方式。当然,抽象类也是抽象的,但根据经验,您应该尽可能依赖接口。

你可能会想,为什么不使用抽象类呢?abstract class是一个抽象,但不是 100%抽象,如果是,你应该用一个接口替换它。抽象类用于封装默认行为,然后可以在子类中继承这些行为。它们很有用,但接口更灵活、更强大,更适合设计合同。

此外,在编写单元测试时,使用接口可以为您节省数不清的艰难和复杂的工作时间。如果您正在构建一个其他人使用的框架或库,这一点就更加正确了。在这种情况下,请友好地为您的消费者提供接口。

所有这些关于接口的讨论都很好,但是依赖关系如何才能逆转呢?让我们从比较直接依赖和反向依赖开始。

直接相关

如果我们有一个使用Weapon实例的Ninja类,依赖关系图应该是这样的,因为Ninja直接依赖于Weapon类:

Figure 3.2 – Direct dependency schema

图 3.2–直接依赖模式

反向依赖

如果我们通过引入抽象来反转依赖关系,Ninja类将只依赖于新的IWeapon接口。这样做使我们能够灵活地改变武器类型,而不会影响系统的稳定性,也不会改变Ninja等级,特别是如果我们也遵循 OCP。间接地,Ninja仍然使用Weapon类实例,从而打破了直接依赖关系。

以下是更新后的模式:

Figure 3.3 – Indirect dependency schema

图 3.3——间接依赖模式

利用倾角反演子系统

更进一步说,您还可以通过创建两个或多个组件来隔离和解耦完整的子系统:

  1. 仅包含接口的抽象程序集。
  2. 包含来自第一个程序集的协定实现的一个或多个其他程序集。

在.NET 中有多个这样的示例,例如Microsoft.Extensions.DependencyInjection.AbstractionsMicrosoft.Extensions.DependencyInjection程序集。我们还在第 12 章理解分层中探讨这一概念。

在跳进更多代码之前,让我们看看另一个表示这个思想的模式。这一次,它与从数据库本身提取数据访问有关(我们稍后还会进一步讨论):

图 3.4–表示如何通过反转依赖关系打破紧密耦合的图表

在图中,App包直接依赖于Abstractions包,有两种实现:LocalSql。从那里,我们应该能够在不破坏我们的App的情况下,将一个实现替换为另一个实现。原因是我们依赖于抽象,并使用这些抽象对应用进行编码。无论使用什么实现,程序都应该运行良好。

我最近在基于微服务的应用中设计的另一个示例是发布-订阅(pub-sub)通信库。微服务使用一些抽象,并且有一个或多个可交换的实现,因此一个微服务可以使用提供者,而另一个微服务可以使用另一个提供者而不直接依赖它。我们在第 16 章【微服务架构简介】中讨论了发布子模式和微服务架构。在此之前,请将微服务视为一个应用。

包装

这里描述的包可以是名称空间,也可以是程序集。通过围绕程序集划分职责,它可以只加载需要加载的实现。例如,一个应用可以加载“本地”程序集,另一个应用可以加载“SQL”程序集,而第三个应用可以同时加载这两个程序集。

项目-依赖倒置

上下文:我们刚刚了解了 DIP,希望将其应用到我们的书店应用中。由于我们还没有任何真正的用户界面,我们相信创建多个可重用的程序集是有意义的,我们的 ASP.NET Core 应用可以在以后使用这些程序集,从而允许我们交换一个 GUI 和另一个 GUI。同时,我们将使用一个小型控制台应用测试我们的代码。

有三个项目:

  • GUI:控制台应用

  • 核心:应用逻辑

  • Data: the data access

    分层

    这个概念被称为分层。稍后我们将更深入地访问分层。现在,您可以将其视为将责任划分为不同的程序集。

使用经典的依赖关系层次结构,我们将得到以下依赖关系图:

Figure 3.5 – Diagram representing assemblies that directly depend on the next assembly

图 3.5-表示直接依赖于下一个组件的组件的图表

这不是很灵活,因为所有组件都直接链接到下一条生产线,从而在它们之间建立了牢固、牢不可破的纽带。现在让我们使用 DIP 重新讨论这个问题。

笔记

为了保持简单并只关注代码的一部分,我只抽象了程序的数据部分。在本书中,我们将进一步深入探讨依赖项反转,以及依赖项注入。

现在,请关注DIP.DataDIP.Data.InMemory以及本章的代码示例。

解决方案中有四个项目;三个库和一个控制台。他们的目标如下:

  • DIP.Console是入口点,程序。它的角色是编写和运行应用。它使用DIP.Core并定义应该使用什么实现来覆盖DIP.Data接口,在本例中为DIP.Data.InMemory
  • DIP.Core是程序核心,共享逻辑。它唯一的依赖项是DIP.Data,抽象掉了实现。
  • DIP.Data包含持久化接口:IBookReaderIBookWriter。它还包含数据模型(Book类)。
  • DIP.Data.InMemoryDIP.Data的具体实现。

为了可视化程序集的关系,让我们来看看下面的图表:

Figure 3.6 – Diagram representing assemblies that invert the dependency flow,  breaking coupling between DIP.Core and DIP.Data.InMemory

图 3.6–表示反转依赖流、断开 DIP.Core 和 DIP.Data.InMemory 之间耦合的组件的图表

如果我们从开始看Core项目的PublicService类,我们可以看到它只依赖于Data项目的IBookReader接口:

public class PublicService
{
    public IBookReader _bookReader;
    public Task<IEnumerable<Book>> FindAllAsync()
    {
        return Task.FromResult(_bookReader.Books);
    }
    public Task<Book> FindAsync(int bookId)
    {
        var book = _bookReader.Find(bookId);
        return Task.FromResult(book);
    }
}

PublicService类定义了一些使用IBookReader抽象来查询书籍的方法。PublicService扮演消费者角色,不知道任何具体类别。即使我们愿意,也无法从该项目访问实现。我们成功了;我们扭转了依赖关系。是的,就这么简单。

笔记

拥有_bookReader这样的公共字段会破坏封装,所以不要在项目中这样做。我只是想把例子的重点放在下降上。我们将在后面看到如何使用良好的实践来利用 DIP,包括利用依赖注入。

没有任何具体的实现,接口什么也做不了,因此 DIP 的另一部分是通过定义支持这些抽象的实现来配置消费者。为了帮助我们,让我们在Program内部创建一个名为Composer的私有类来集中 DIP 的这一步。

也就是说,在实际项目中,你通常不想做什么,但是在我们覆盖依赖注入之前,我们必须依靠一种更为手动的方法,所以让我们来看看这个轻版本,关注于 To.T0TA:

private static class Composer
{
    private readonly static BookStore BookStore = new 
    BookStore();
    // ...
    public static PublicService CreatePublicService()
    {
        return new PublicService
        {
            _bookReader = BookStore
        };
    }
}

CreatePublicService()方法负责构建PublicService实例。在其中,我们将具体类的一个实例BookStore分配给public IBookReader _bookReader;字段,使PublicService不知道其_bookReader 实现。

您是否注意到公共服务类中存在任何违反 DIP 的情况?

是的,PublicService是具体的,Program直接使用它。这违反了 DIP。如果您想尝试依赖项反转,您可以在项目中修复此冲突;编码永远是最好的学习方式!

这个小示例演示了如何反转依赖关系,确保:

  • 代码总是依赖于抽象(接口)。
  • 项目也依赖于抽象(依赖于DIP.Data而不是DIP.Data.InMemory

结论

这个原则的结论与下一步发生的事情密切相关(见下一节)。然而,这个想法是依赖于抽象(接口或抽象类)。尽量坚持使用接口。它们比抽象类更灵活。

根据具体情况,类之间会产生紧密耦合,从而导致系统更难维护。从长远来看,依赖关系之间的内聚对于耦合是否会帮助或伤害您起着至关重要的作用。稍后再谈。

下一步是什么?

单词依赖注入出现了几次,你可能对此感到好奇,所以我们来看看它是什么。依赖注入,或控制反转IoC),是一种机制(概念),是 ASP.NET Core 的一级公民。它允许您将抽象映射到实现,当您需要一个新类型时,整个对象树将根据您的配置自动创建。一旦你习惯了,你就不能后退;但是要小心挑战,因为你可能需要“忘却”一部分你所知道的来接受这项新技术。

说够了。在对依赖项注入过于兴奋之前,让我们先看一下最后几节。我们将从第 7 章开始这段旅程,深入研究依赖注入

其他重要原则

在进一步讨论之前,我还发现了另外两个原则:

  • 关注点分离
  • 不要重复你自己(干)

当然,在阅读了坚实的原则之后,你可能会发现这些更基本,但它们仍然是我们刚刚学到的东西的补充。

笔记

还有很多其他的原则,有些你可能已经知道,有些你以后很可能会了解,但在某个时候,我必须选择主题,否则就要写一本百科全书大小的书。

关注点分离

其思想是将软件分成逻辑块,每个逻辑块都是一个关注点;这可以从将程序分解为模块到将 SRP 应用于某些子系统。这可以应用于任何编程范例。如何封装特定关注点取决于范式和关注点的级别。级别越高,解决方案的范围越广;级别越低,颗粒越大。

例如,以下内容适用:

  • 通过使用面向方面编程AOP,我们可以将安全性或日志记录视为横切关注点,将代码封装在方面中。
  • 通过使用面向对象编程OOP,我们还可以将安全性或日志记录视为一个交叉关注点,将共享逻辑封装在 ASP.NET 过滤器中。
  • 通过再次使用 OOP,我们可以将 web 页面的呈现和 HTTP 请求的处理视为两个关注点,从而形成 MVC 模式;视图“呈现”页面,而控制器处理 HTTP 请求。
  • 通过使用任何范式,我们都可以将添加扩展点视为一个关注点,从而实现基于插件的设计。
  • 再次使用任何范例,我们都可以将负责将一个对象复制到另一个对象的组件视为关注点。相反,另一个组件的职责可能是通过遵循一些规则来高效地编排这些拷贝,例如限制并行发生的拷贝量、将溢出操作排队等等。

正如你在这些例子中所注意到的,一个问题可以是一个重要的问题,也可以是一个微小的细节;但是,当你把软件划分成碎片来创建有凝聚力的单元时,必须考虑关注点。良好的关注点分离应有助于您创建模块化设计,并帮助您更有效地面对设计困境。

不要重复你自己(干)

好的,这个原则的名称是不言自明的,而且,正如我们已经在 SRP 和 OCP 中看到的,我们可以而且应该将逻辑扩展并封装到更小的单元中,以实现可重用性和更低的维护成本。

干式原理通过以下说明,或多或少以另一种方式进行解释:

当系统中存在重复的逻辑时,将其封装并在多个位置重新使用新的封装

目标是避免在规范更改时进行多次更改。为什么?避免忘记制作,或避免在程序中产生不一致和错误。

通过关注点重新组合重复逻辑非常重要,而不仅仅是通过多个代码块的外观。让我们看看前一个示例的Program类中的这两个方法:

private static async Task PublicAppAsync()
{
    var publicService = Composer.CreatePublicService();
    var books = await publicService.FindAllAsync();
    foreach (var book in books)
    {
        presenter.Display(book);
    }
}
private static async Task AdminAppAsync()
{
    var adminService = Composer.CreateAdminService();
    var books = await adminService.FindAllAsync();
    foreach (var book in books)
    {
        presenter.Display(book);
    }
}

代码非常相似,但是试图从中提取一个方法是错误的。为什么?因为公共程序和管理程序可能有不同的更改原因(例如,在管理面板中添加过滤器,但在公共部分中不添加过滤器)。

但是,我们可以在 presenter 类中创建一个 display 方法来处理书籍集合,用一个presenter.Display(books)调用替换foreach循环。然后,我们可以将这两种方法移出Program,而不会产生太大影响。将来,这将允许我们支持多个实现,一个用于管理员,一个用于公共用户,以增加灵活性。

提示

我已经告诉过你了,但我们又来了。当您不知道如何命名类或方法时,您可能隔离了一个无效或不完整的关注点。这是一个很好的指标,你应该回到绘图板。

总结

在本章中,我们介绍了许多体系结构原则。我们从探索五个坚实的原则及其在现代软件工程中的重要性开始,然后跳到干涸和分离关注点原则,这为混合添加了更多的指导。通过遵循这些原则,您应该能够构建更好、更易于维护的软件。正如我们也谈到的,原则只是原则,而不是法律。你必须始终小心不要滥用它们,这样它们才是有益的,而不是有害的。环境总是很重要的;内部工具和关键业务应用需要不同程度的修补。尽量不要过度设计每件事。

有了我们工具箱中的所有这些原则,我们现在准备跳入设计模式,进一步提高我们的设计水平!在接下来的几章中,我们将探讨如何实现一些最常用的 GoF 模式,以及如何在依赖注入的另一个层次上应用这些模式。依赖注入将帮助我们在日常设计中遵循坚实的原则,但在此之前,在接下来的两章中,我们将探讨 ASP.NET Core MVC。

问题

让我们来看看几个练习题:

  1. 固体首字母缩略词代表了多少原则?
  2. 当遵循坚实的原则时,想法是创建更大的组件,每个组件都可以通过创建上帝大小的类来管理程序的更多元素,这是真的吗?
  3. 通过遵循 DRY 原则,您希望从任何地方消除所有代码重复,而不管源代码如何,并将代码封装到可重用组件中。这个肯定正确吗?
  4. ISP 告诉我们创建多个较小的接口比创建一个较大的接口好,这是真的吗?
  5. 什么原则告诉我们,创建多个处理单个职责的较小类比创建一个处理多个职责的类更好?

四、使用 Razor 的 MVC 模式

模型-视图-控制器(MVC)模式可能是用于显示 web 用户界面的最广泛适用的体系结构模式之一。为什么?因为它几乎完美地匹配了 HTTP 和 web 背后的概念,特别是对于典型的服务器呈现的 web 应用。对于面向页面的应用,Razor Pages 也可能是这一主张的竞争者。

从旧的 ASP.NET MVC 到 ASP.NET Core,MVC 框架比以前更干净、更精简、更快、更灵活。此外,依赖项注入现在是 ASP.NET 的核心,这有助于利用它的强大功能。我们将在第 7 章深入讨论依赖注入中更深入地介绍依赖注入。

MVC 现在是一个选择加入的功能,就像其他的功能一样。您可以选择使用 MVC、Razor 页面或 web API,并仅使用几个语句对它们进行配置。ASP.NET 管道基于一系列中间件,这些中间件可用于处理交叉问题,如身份验证和路由。

本章对于希望创建 ASP.NET Core 5 MVC 应用的任何开发人员都是必不可少的。本章中学习的这些技术和模式贯穿全书。我们正在构建 web API 而不是 Razor,但 MVC 框架仍然是这两者的支柱。

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

  • 模型-视图-控制器设计模式
  • 使用 Razor 的 MVC
  • 视图模型设计模式

模型-视图-控制器设计模式

当使用 ASP.NET Core 5 MVC 时,开发人员可以构建两种类型的应用。

  • 第一个用途是显示 web 用户界面,使用经典的客户机-服务器应用模型,其中页面由服务器组成。然后将结果发送回客户端。要构建这种类型的应用,您可以利用 Razor,它允许开发人员混合使用 C#和 HTML 来优雅地构建丰富的用户界面。在我看来,Razor 是在 2011 年 ASP.NETMVC3 问世时让我首先接受 MVC 的技术。
  • MVC 的第二个用途是构建 web API。在 web API 中,表示(或视图)成为数据契约而不是用户界面。合同由预期的输入和输出定义,就像任何 API 一样。最显著的区别是 web API 是通过网络调用的,并充当远程 API。本质上,输入和输出是序列化的数据结构,通常是 JSON 或 XML,与 HTTP 动词(如GETPOST)混合在一起。更多信息请参见第 5 章Web API 的 MVC 模式

ASP.NET Core 5 MVC 的许多优点之一是,您可以在同一个应用中混合使用这两种技术,而无需任何额外的努力。例如,您可以在同一个项目中构建一个完整的网站和一个成熟的 web API。

使用剃须刀的 MVC

让我们来探索第一种应用,即经典的服务器呈现的 web 用户界面。在这种使用 MVC 的应用中,您将应用分为三个不同的部分,每个部分都有一个单独的职责:

  • 模型:模型表示一个数据结构,表示我们试图建模的领域。
  • 视图:视图的职责是向用户展示一个模型,在我们的例子中,它是一个 web 用户界面,主要是 HTML、CSS 和 JavaScript。
  • 控制器:控制器是 MVC 的关键组件。它在用户的请求和响应之间起协调作用。控制器的代码应保持最少,且不应包含复杂的逻辑或操作。控制员的主要职责是处理请求并发送响应。控制器是一个 HTTP 网桥。

如果我们把所有这些放在一起,控制器就是每个请求的入口点,视图构成响应(UI),两者共享模型。该模型由控制器获取或操作,然后发送到视图进行渲染。然后,用户会在浏览器中看到请求的资源。

下图说明了 MVC 的概念:

图 4.1–MVC 工作流程

我们可以将图 4.1 解释为:

  1. 用户请求一个 HTTP 资源(路由到控制器的动作)。
  2. 控制器读取或更新视图要使用的模型。
  3. 然后,控制器将模型分派到视图进行渲染。
  4. 视图使用模型来呈现 HTML 页面。
  5. 该呈现页面通过 HTTP 发送给用户。
  6. 与任何其他网页一样,用户的浏览器显示该页面;它毕竟只是 HTML。

接下来,我们将研究 ASP.NET Core MVC 本身、默认情况下目录的组织方式、控制器以及路由工作方式。然后,在使用视图模型改进这些默认特性之前,我们将深入研究一些代码。

目录结构

默认的 ASP.NET Core MVC 目录结构非常明确。有一个Controllers目录、一个Models目录和一个Views目录。按照惯例,我们在Controllers目录中创建控制器,在Models目录中创建模型,在Views目录中创建视图。

也就是说,Views目录有点不同。为了使项目井然有序,每个控制器在Views下都有自己的子目录。例如,HomeController的视图位于Views/Home目录中。

Views/Shared目录是一种特殊情况。该子目录中的视图可由所有其他视图和控制器访问;它们是共同的观点。我们通常在该目录中创建全局视图,如布局、菜单和其他类似元素。

控制器的结构

创建控制器最简单的方法是创建一个继承自Microsoft.AspNetCore.Mvc.Controller的类。按照惯例,该类的名称以Controller作为后缀,并在Controllers目录中创建该类。这不是强制性的。

该基类添加了返回适当视图所需的所有实用程序方法,如View()方法。

一旦你有了一个控制器类,你需要添加动作。操作是表示用户可以执行的操作的公共方法。

更准确地说,以下定义了控制器:

  • 控制器公开一个或多个操作。
  • 一个操作可以接受零个或多个输入参数。
  • 操作可以返回零或一个输出值。
  • 操作是处理实际请求的操作。
  • 我们可以在同一个控制器下对内聚动作进行分组,从而创建一个单元。

例如,下面表示包含单个Index动作的HomeController类:

public class HomeController : Controller
{
    public IActionResult Index() => View();
}

向服务器发送GET /请求时,默认调用HomeController.Index动作。在这种情况下,它返回Home/Index.cshtml视图,无需进一步处理或任何模型操作。现在让我们来探究原因。

默认路由

为了知道哪个控制器应该处理特定的 HTTP 请求,ASP.NET Core 5 有一个路由机制,允许开发人员定义一个或多个路由。路由是映射到 C#代码的 URI 模板。这些定义是在Startup类的Configure()方法中创建的。默认情况下,MVC 定义以下模式:"{controller=Home}/{action=Index}/{id?}"

第一个片段是关于控制器的,适用于以下内容:

  • {controller}将请求映射到{controller}Controller类。例如,Home将映射到HomeController类。
  • {controller=Home}表示HomeController类为默认控制器,如果没有提供{controller}则使用。

第二个片段是关于动作的:

  • {action}将请求映射到控制器方法(操作)。

  • Like its controller counterpart, {action=Index} means that the Index method is the default action. For example, if we had a ProductsController class in our application, making a GET request to https://somedomain.tld/Products would make MVC invoke the ProductsController.Index() method. As a side note, TLD means top-level domain, such as .com, .net, and .org.

    笔记

    CRUD控制器(创建、读取、更新、删除中,Index是您通常定义列表的地方。

最后一个片段是关于一个可选id参数:

  • {id}表示动作名称后面的任何值都映射到该动作方法名为id的参数。
  • {id?}中的?表示该参数是可选的。

让我们看看一些例子来总结我们对默认路由模板的研究:

  • 调用/Some/Action将映射到SomeController.Action()

  • 调用/Some将映射到SomeController.Index()

  • 调用/将映射到HomeController.Index()

  • Calling /Some/Action/123 would map to SomeController.Action(123)

    端点路由

    从 ASP.NETCore2.2 开始,团队引入了一个新的路由系统,名为 EndpointRouting。如果您对高级路由或扩展当前路由系统感兴趣,这将是一个很好的起点。

项目:MVC

让我们探索一下使用 VS 代码和.NET CLI 创建的一些代码。该项目旨在访问某些 MVC 概念。

提醒

要生成新的 MVC 项目,您可以执行dotnet new mvc命令或dotnet new web命令,具体取决于您希望在项目中使用多少样板代码。在本例中,我使用了dotnet new web并自己添加了这些文件来创建一个更精简的项目,但这两种方法都是可行的。

HomeController类定义了以下操作。这应该让您概括了解我们可以使用 ASP.NET Core 5 MVC 做什么:

  • Index
  • ActionWithoutResult
  • ActionWithoutResultV2Async
  • DownloadTheImageAsync
  • ActionWithSomeInput
  • ActionWithSomeInputAndAModel

指数

Index()方法是默认操作,在本例中是主页。它返回一个包含一些 HTML 的视图。视图使用其他操作生成 URL。该视图在Views/Home/Index.cshtml文件中定义。

加载视图的 C#代码如下所示:

public IActionResult Index() => View();

在前面的代码片段中,Controller.View()方法告诉 ASP.NET Core 呈现与当前操作关联的默认视图。默认加载Views/[controller]/[action].cshtml,其中[controller]为不带Controller后缀的控制器名称,[action]为动作方法名称。

视图本身如下所示:

@{
    ViewData["Title"] = "Home Page";
    var imageUri = Url.Action("ActionWithoutResultV2");
}
<p>Use the left menu to navigate through the examples.</p>
<h2>The result of ActionWithoutResultV2</h2>
<figure>
    <img src="@imageUri" alt="ASP.NET Core logo" />
    <figcaption>
        The result of <em>ActionWithoutResultV2</em> displayed using the following HTML markup:
        <code>&lt;img src="@imageUri" alt="ASP.NET Core logo" /></code>.
    </figcaption>
</figure>
<p>You can download the image by clicking <a href="@Url.Action("DownloadTheImage")">here</a>.</p>

稍后我们将讨论下载和图像链接,但除此之外,该视图仅包含使用 Razor 编写的基本 HTML。

表达式体函数成员(C#6)

在前面的示例中,我们使用了一个表达式体函数成员,这是一个 C#6 特性。它允许使用 arrow 操作符编写没有大括号的单语句方法。

在 C#6 之前,public IActionResult Index() => View();应该是这样写的:

public IActionResult Index() 
{ 
    return View(); 
}

箭头运算符也可以应用于只读属性,如下所示:

public int SomeProp => 123; 

与之前更明确的渲染方法不同:

public int SomeProp
{
    get
    {
        return 123;
    }
}

无结果的行动

ActionWithoutResult()方法没有任何作用。但是如果您使用浏览器导航到/Home/ActionWithoutResult,它会显示一个空白页面,其中 HTTP 状态代码为200 OK,这意味着操作已成功执行。

这不是非常以用户界面为中心的应用,但 ASP.NET Core 5 支持这种情况:

public void ActionWithoutResult()
{
    var youCanSetABreakpointHere = "";
}

没有与此操作关联的视图。接下来,我们将为这种类型的操作添加一些有用性。

没有结果的操作 v2async

作为一个更复杂的例子,我们可以使用这种类型的操作手动写入 HTTP 响应流。我们可以从磁盘加载文件,在内存中进行一些修改,并将更新后的版本写入输出流,而无需将其保存回磁盘。例如,我们可以更新动态呈现的图像,或者在发送给用户之前预先填充 PDF 表单。

以下是一个例子:

public async Task ActionWithoutResultV2Async()
{
    var filePath = Path.Combine(
        Directory.GetCurrentDirectory(), 
        "wwwroimg/netcore-logo.png"
    );
    var bytes = System.IO.File.ReadAllBytes(filePath);
    //
    // Do some processing here
    //
    Response.ContentLength = bytes.Length;
    Response.ContentType = "img/png";
    await Response.Body.WriteAsync(bytes, 0, bytes.Length);
}

前面的代码加载一个图像,然后使用 HTTP 手动将其发送到客户端;没有涉及 MVC 魔术。然后,我们可以在 Razor 页面或视图中使用以下标记加载该图像:

<img src="@Url.Action("ActionWithoutResultV2")" />

这里有两个很容易忽略的重要细节:

  1. 从.NET Core 3.0 开始,我们不能做Response.Body.Write(…);我们必须改用Response.Body.WriteAsync(…)(因此使用异步方法)。
  2. 调用Url.Action(…)助手时,必须删除Async后缀。

通过使用一个没有结果的动作,我们可以强制下载图像或做许多其他有趣的事情。如果您还不熟悉 HTTP,我建议您学习它。它应该帮助您了解可以做什么以及如何做。有时,您不需要 MVC 的全部功能,一些用例可以通过使用较低级别的 API 来简化,例如通过ControllerBase.Response属性提供的HttpResponse类。

路由(端点到委托)

当您需要此类操作时,您还可以通过将 URI 映射到委托来手动定义端点,而无需创建控制器。你甚至不需要 MVC。

这种技术的一个缺点是,您需要手动执行模型绑定和其他所有操作,因为您的委托不是在 MVC 管道中运行的,因此没有 MVC 魔力。

以下示例定义了两个GET端点;一个带路由参数,一个不带:

public class Startup
{
    public void ConfigureServices(IServiceCollection services) { }
    public void Configure(IApplicationBuilder app)
    {
        app.UseRouting();
        app.UseEndpoints(endpoints =>
        {
            endpoints.MapGet("/", async context =>
            {
                await context.Response.WriteAsync("Hello World!");
            });
            endpoints.MapGet("/echo/{content}", async context =>
            {
                var text = context.Request.Path.Value.Replace("/echo/", "");
                await context.Response.WriteAsync(text);
            });
        });
    }
}

对于小型应用、演示、微服务等,这是一个非常值得研究的特性。如果你感兴趣的话,我建议创建一个类似的应用来使用它。

下载图像异步

作为后续示例,要下载之前由ActionWithoutResultV2Async操作呈现的图像,只需添加一个Content-Disposition头(即 HTTP,而不是 MVC/.NET)。新方法如下所示:

public async Task DownloadTheImageAsync()
{
    var filePath = Path.Combine(
        Directory.GetCurrentDirectory(),
        "wwwroimg/netcore-logo.png"
    );
    var bytes = System.IO.File.ReadAllBytes(filePath);
    //
    // Do some processing here
    //
    Response.ContentLength = bytes.Length;
    Response.ContentType = "img/png";
    Response.Headers.Add("Content-Disposition", "attachment; filename=\"netcore.png\"");
    await Response.Body.WriteAsync(bytes, 0, bytes.Length);
}

现在,如果您从页面调用该操作,浏览器会提示您下载文件,而不是显示文件。

以下是主页上的一个示例:

<p>You can download the image by clicking <a href="@Url.Action("DownloadTheImage")">here</a>.</p>

如前一个示例所述,它使用 HTTP 的基本元素。

带有某些输入的操作

ActionWithSomeInput方法具有一个id参数,并呈现一个简单视图,该数字显示在页面上。操作代码如下所示:

public IActionResult ActionWithSomeInput(int id)
{
    var model = id;
    return View(model);
}

该视图的代码如下所示:

@model int
@{
    ViewData["title"] = "Action with some input";
}
<h2>This action has an input</h2>
<p>The input was: @Model</p>

当从菜单(/Home/ActionWithSomeInput/123中点击链接时,我们可以看到 MVC 模式正在运行:

  1. MVC 正在将调用路由到HomeController,请求ActionWithSomeInput操作,并将值123绑定到id参数。

  2. In ActionWithSomeInput, the id parameter is assigned to the model variable.

    这一步只是显式的,并证明我们正在向视图传递一个模型。我们本可以将id参数直接传递给View方法。例如,我们可以将操作简化如下:public IActionResult ActionWithSomeInput(int id) => View(id);

  3. 然后,model变量被发送到ActionWithSomeInput视图进行渲染,并将结果返回给用户。

  4. Then, MVC renders the view, ActionWithSomeInput.cshtml, by means of the following:

    a) 隐式使用ViewStart.cshtml中设置的默认布局(Shared/_Layout.cshtml

    b) 它使用输入的model,使用视图顶部的@model int指令键入。

    c) 使用@Model属性在页面中呈现模型的值;注意mm的不同外壳(见下文)。

    @Model 和@Model

    ModelMicrosoft.AspNetCore.Mvc.Razor.RazorPage<TModel>中的TModel类型的属性。ASP.NET Core MVC 中的每个视图都继承自RazorPage,这就是所有这些“神奇属性”的来源。@model是一个 Razor 指令,允许我们强烈地键入视图(换句话说,TModel类型参数)。我强烈建议您键入所有视图,除非没有办法这样做,或者它没有使用模型(如Home/Index)。在后台,Razor 文件被编译成 C#类,这解释了@model指令如何成为TModel泛型参数的值。

与 SomeInputDaModel 的操作

作为最后一个动作方法,ActionWithSomeInputAndAModel以一个名为id的整数参数作为输入,但它没有直接发送到视图,而是发送了一个SomeModel实例。这是对真实应用的更好模拟,其中对象用于共享和持久化信息,例如使用唯一标识符(一个id参数)从数据库加载记录,然后将该数据发送到视图进行渲染(在这种情况下,没有数据库):

public IActionResult ActionWithSomeInputAndAModel(int id)
{
    var model = new SomeModel
    {
        SelectedId = id,
        Title = "This title was set in HomeController!"
    };
    return View(model);
}

调用此操作时,流与上一个操作相同。唯一的例外是模型是对象而不是整数。简化流程如下:

  1. HTTP 请求被路由到控制器的操作。
  2. 控制器操纵模型(在本例中,创建模型)。
  3. 控制器将模型分派到视图。
  4. 视图引擎呈现页面。
  5. 页面将发送回请求它的客户端。

以下是显示模型的TitleSelectedId属性的视图:

@model MVC.Models.SomeModel
@{
    ViewData["title"] = Model.Title;
}
<h2>This action has an input and uses a model</h2>
<p>The input was: @Model.SelectedId</p>

从前面的代码中,我们可以看到我们正在使用Model属性访问我们在操作中传递给View方法的匿名对象。

结论

我们可以在本书的其余部分讨论 MVC,但我们没有抓住要点。本章和下一章的目的是尽可能多地介绍各种可能性,以帮助您理解 MVC 模式和 ASP.NET Core,但没有太深入的内容,只是一个概述。

我们讨论了不同类型的操作和基本的路由机制,应该足够继续了。我们在整本书中都使用 ASP.NETCore5,所以不用担心,我们将在不同的上下文中介绍许多其他方面。接下来,我们稍微改进一下 MVC 模式。

视图模型设计模式

视图模型模式用于使用 Razor 构建服务器呈现的 web 应用,并可应用于其他技术。通常,从数据源访问数据,然后基于该数据渲染视图。这就是视图模型发挥作用的地方。您不需要将原始数据直接发送到视图,而是将所需数据复制到另一个只包含呈现该视图所需信息的类,仅此而已。

使用这种技术,您甚至可以基于多个数据模型组合一个复杂的视图模型,添加过滤、排序等,而无需更改数据或域模型。这些特性以表示为中心,因此,视图模型的责任是满足视图在信息表示方面的要求,这符合我们在第 3 章中探讨的单一责任原则架构原理

我们甚至可以将最后一个示例ActionWithSomeInputAndAModel看作是视图模型模式的粗略实现。您得到一个int作为输入,为视图输出一个模型,视图模型

目标

视图模型模式的目标是创建一个特定于视图的模型,将软件的其他部分与视图分离。根据经验,您希望每个视图都使用自己的视图模型类进行强类型化,以确保视图彼此不耦合,从而从长远来看可能会导致维护问题。

视图模型允许开发人员以特定的格式收集数据,并以更适合该特定视图渲染的另一种格式将其发送到视图。这提高了应用的可测试性,从而提高了总体代码质量和稳定性。

设计

下面是一个支持视图模型的修订版 MVC 工作流:

图 4.2–具有视图模型的 MVC 工作流

我们可以将图 4.2 解释为:

  1. 用户请求 HTTP 资源(路由到控制器的操作)。
  2. 控制器读取或更新模型。
  3. 控制器创建视图模型(或使用在别处创建的视图模型)。
  4. 控制器将该数据结构分派给视图进行渲染。
  5. 视图使用视图模型呈现 HTML 页面。
  6. 该呈现页面通过 HTTP 发送给用户。
  7. 浏览器会像显示任何其他网页一样显示该网页。

您是否注意到模型现在已与视图解耦?

项目:查看模型(学生列表)

上下文:我们必须建立一份学生名单。每个列表项必须显示学生的姓名和学生注册的班级数量。

我们的平面设计师设计了以下带徽章的 Bootstrap 3 列表:

Figure 4.3 – Students list with their number of classes

图 4.3–学生名单及其班级数量

为了保持简单并创建原型,我们通过StudentService类加载内存中的数据。

请务必记住,视图模型必须仅包含显示视图所需的信息。位于StudentListViewModels.cs中的视图模型类如下所示:

public class StudentListViewModel
{
    public IEnumerable<StudentListItemViewModel> Students { get; set; }
}
public class StudentListItemViewModel
{
    public int Id { get; set; }
    public string Name { get; set; }
    public int ClassCount { get; set; }
}

这是在同一个文件中保留多个类有意义的场景之一,因此使用复数文件名。也就是说,如果您不能忍受在一个文件中包含多个类,那么可以在您的项目中将它们拆分。

笔记

在更大的应用中,我们可以创建子目录或使用名称空间来保持视图模型类名称的唯一性和组织性,例如,/Models/Students/ListViewModels.cs

另一种选择是在/ViewModels/目录中创建视图模型,而不是使用默认的/Models/目录。

我们还可以将视图模型类创建为其控制器下的嵌套类。例如,StudentsController类可以有一个嵌套的ListViewModel类,可以这样调用:StudentsController.ListViewModel

这些都是有效的选择。再一次,做你喜欢做的,最适合你的项目。

StudentController类是关键元素,具有处理GET请求的Index操作。对于每个请求,它将获取学生,创建视图模型,然后将其分派到视图。以下是控制器代码:

public class StudentsController : Controller
{
    private readonly StudentService _studentService = new StudentService();
    public async Task<IActionResult> Index()
    {
        // Get data from the datastore
        var students = await _studentService.ReadAllAsync();
        // Create the View Model, based on the data
        var viewModel = new StudentListViewModel
        {
            Students = students.Select(student => new StudentListItemViewModel
            {
                Id = student.Id,
                Name = student.Name,
                ClassCount = student.Classes.Count()
            })
        };
        // Return the View
        return View(viewModel);
    }
}

视图将学生呈现为引导list-group,使用徽章显示ClassCount属性,如我们最初的规范所定义。

@model StudentListViewModel
@{
    ViewData["Title"] = "Students";}
<h2>Students list</h2>
<ul class="list-group">
    @foreach (var item in Model.Students)
    {
        <li class="list-group-item">
            <span class="badge">@Html.DisplayFor(modelItem => item.ClassCount)</span>
            @ Html.DisplayFor(modelItem => item.Name)
        </li>
    }
</ul>

通过前面几行代码和视图模型模式,我们使用中间类StudentListViewModelStudent模型与视图解耦,中间类由StudentListItemViewModel列表组成。此外,我们通过将Student.Classes属性替换为StudentListItemViewModel.ClassCount属性来限制传递给视图的信息量,该属性仅包含呈现视图所需的信息(学生所在的班级数量)。

项目:视图模型(学生表单)

背景:既然我们有了一份清单,公司里的一些聪明人认为能够创造和更新学生是个好主意;有道理,对吧?

要做到这一点,我们必须做到以下几点:

  1. 创建一个名为CreateGET动作。
  2. 创建一个处理学生创建的POST操作。
  3. 添加一个Create视图。
  4. 添加名为CreateStudentViewModel的视图模型。
  5. Edit视图重复步骤 1 至 4。
  6. 向列表中添加一些导航链接。

经过一番思考,我们认为对两个视图重用相同的表单会更好。为了简单起见,我们的学生模型是最小的,但在实际应用中,情况很可能不是这样。因此,从长远来看,如果两种表单相同,提取该表单将有助于我们进行维护。

笔记

在另一种情况下,如果两个表单不同,我建议您将它们分开,以避免在更新另一个表单时破坏其中一个表单。

从技术角度来看,这需要以下几点:

  • 两个视图共享的局部视图。
  • CreateEdit视图模型共享的StudentFormViewModel类。

从这里开始,要构建视图模型,我们有两个选项:

  • 遗产
  • 作文

与软件工程世界中的一切一样,这两种选择都有其优点和缺点。作文是最灵活的技巧,也是整本书中使用最广泛的技巧。当面临困境时,我建议组合而不是继承。也就是说,继承也是一种有效的选择,这就是我决定演示这两种技术的原因。我们将首先使用继承实现Create视图,然后使用组合实现Edit视图。让我们看看差异,从视图模型类开始。

CreateStudentViewModel类继承自StudentFormViewModel(继承):

// StudentFormViewModels.cs
public class CreateStudentViewModel : StudentFormViewModel { }

EditStudentViewModel类的属性改为StudentFormViewModel类型(组合):

public class EditStudentViewModel
{
    public int Id { get; set; }
    public IEnumerable<string> Classes { get; set; }
    public StudentFormViewModel Form { get; set; }
}

StudentFormViewModel类表示我们在CreateEdit视图之间共享的形式本身:

public class StudentFormViewModel
{
    public string Name { get; set; }
}

接下来,让我们来看看中的StudentsController``GET动作:

public IActionResult Create()
{
    return View();
}
public async Task<IActionResult> Edit(int id)
{
    var student = await _studentService.ReadOneAsync(id);
    var viewModel = new EditStudentViewModel
    {
        Id = student.Id,
        Form = new StudentFormViewModel
        {
            Name = student.Name,
        },
        Classes = student.Classes.Select(x => x.Name)
    };
    return View(viewModel);
}

在前面的代码中,Create操作返回一个空视图模型。Edit操作从数据库加载一个学生,并在其视图模型中复制所需信息,然后将该视图模型发送到编辑视图。接下来,让我们来看看这些观点,从第 4 部分到第 5 部分开始。

局部视图是一个较小的视图,可以作为其他视图的一部分进行渲染。默认情况下,Razor 将视图的Model属性传递给局部视图。在这种情况下,由于CreateStudentViewModel是从StudentFormViewModel继承而来的,所以模型作为StudentFormViewModel(多态性)直接发送到_CreateOrEdit

// Create.cshtml
@model CreateStudentViewModel
...
<partial name="_CreateOrEdit" />
...

我们将在第 17 章ASP.NET Core 用户界面中进一步探讨部分视图。

提醒

多态性是面向对象编程背后的核心概念之一。它表示将给定类型的对象视为另一类型实例的能力。例如,ClassA的所有子类(比如ClassBClassC)都可以用作ClassA和它们自己。所以,ClassB是一个ClassB和一个ClassA

对于Edit视图,由于EditStudentViewModel不是从StudentFormViewModel继承的,而是有一个名为Form的类型属性,我们需要指定作为使用for属性的结果,如下所示:

// Edit.cshtml
@model EditStudentViewModel
...
<partial name="_CreateOrEdit" for="Form" />
...

for属性允许我们指定要传递给局部视图的模型;在我们的例子中,EditStudentViewModel.Form属性。

接下来是部分视图的内容:

// _CreateOrEdit.cshtml
@model StudentFormViewModel
<div class="form-group">
    <label asp-for="Name" class="control-label"></label>
    <input asp-for="Name" class="form-control" />
    <span asp-validation-for="Name" class="text-
     danger"></span>
</div>

正如您在前面的代码中所看到的,无论选择哪个选项,它都不会更改部分视图本身,只会更改使用者(在本例中,CreateEdit视图以及视图模型的结构)。消费者必须适应_CreateOrEdit部分视图定义的模型。如果存在不匹配,则在运行时抛出错误。这意味着您可以灵活地在应用中使用其中一种或两种技术。选择最适合你需要的。

在这个精确的用例中,需要注意的是,CreateEdit页面在表单的共享部分上紧密耦合。只要两种形式完全相同,就非常方便。对于大多数使用 CRUD 操作的面向数据的用户界面,我喜欢重用这样的表单,因为它有助于节省时间。对于简单的视图模型,我会选择继承,因为在这种情况下我们不必编写for属性。对于包含许多需要局部视图的属性的更复杂或模块化的视图模型,我将首先探索组合,以确保所有局部视图都使用for属性(线性)。这里的目标是演示各种可能性,以便您可以在不同的场景中使用一种或另一种技术。

组合与继承

就这一点而言,我想说,一开始遗传更自然,但从长远来看,它可能更难维持。构图是两种技巧中最灵活的一种。虽然组合带来了更可重用和更灵活的设计,但它也带来了复杂性,特别是当抽象发挥作用时。构图有助于遵循坚实的原则。

使用继承时,必须确保子类是父类。不要继承不相关的类来重用它的某些元素或某些实现细节。我们将在后面的章节中更多地讨论构图。

结论

如果您仍然不确定或对这种模式感到困惑,我们将在本书中使用视图模型和其他类似的概念。也就是说,当您选择一种模式而不是另一种模式时,有必要审查特定项目或功能的需求,因为它决定了您的选择是否合理。

例如,如果以下内容适用于您的项目:

  • 它是一个简单的数据驱动用户界面,与数据库紧密耦合。
  • 它没有逻辑。
  • 它不会进化。

在这种情况下,视图模型的使用可能只会增加项目的开发时间,而 VisualStudio 几乎可以为您构建所有这些。当您需要视图模型时,例如在创建仪表板或一些更复杂的视图时,没有任何东西可以阻止您使用一两个视图模型。

对于更复杂的项目,我建议默认使用视图模型。如果您对这种模式仍然不确定或困惑,我们将在本书后面探讨多种方法来构建作为一个整体的应用,这将对您有所帮助。

总结

在本章中,我们探讨了 ASP.NET Core 5 MVC 的一部分,它允许我们使用 Razor 和 C#创建丰富的 web 用户界面。

我们看到了如何使用视图模型将模型与表示分离。视图模型是围绕视图或局部视图精心构建的类。例如,与其将数据模型传递给视图并让视图进行一些计算,不如在服务器端进行计算,只将结果传递给视图。这样,视图只有一个职责:显示用户界面、页面。

最后,我们阐述了这样一个事实,即必须减少系统中组件的紧密耦合,这符合坚实的原则。

在接下来的几章中,我们将探讨与 MVC 和视图模型模式相对应的 web API。我们还将介绍我们的第一个四人小组GoF)设计模式,并深入研究 ASP.NET Core 5 依赖项注入。所有这些都将进一步推动我们设计更好的应用。

问题

让我们来看看几个练习题:

  1. 控制器在 MVC 模式中扮演什么角色?
  2. 什么 Razor 指令指示视图接受的模型的类型?
  3. 一个视图模型应该与多少视图相关联?
  4. 视图模型能为系统增加灵活性吗?
  5. 视图模型能否增加系统的稳健性?

进一步阅读

ASP.NET Core 中的路由:https://net5.link/YHVJ

五、Web API 的 MVC 模式

正如我们在前一章中看到的,模型-视图-控制器模式可能是用于显示 web 用户界面的最广泛适用的体系结构模式之一,因为它几乎完美地匹配了 HTTP 和 web 背后的概念。

在本章中,我们将介绍 ASP.NET Core 的 web API 版本,它是大多数现代技术堆栈的关键部分。此外,我们在本书中使用了这些技术和本章中学习的模式。避免使用用户界面使代码更容易理解。Web API 用于各种类型和规模的项目,从微服务到移动应用。

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

  • REST 概述
  • 剖析 webapi
  • C 语言的几个特点
  • 数据传输对象(DTO)设计模式
  • API 合同

REST 概述

REST表示状态传输是一种创建基于互联网的服务的方法,称为 web 服务或 web API,通常使用 HTTP 作为其传输协议。它允许重用众所周知的 HTTP 规范,而不是重新创建交换数据的新方法。例如,返回 HTTP 状态代码 200OK 表示成功,而 400BAD 请求表示失败。

简言之,我们可以说:

  • 每个 HTTP 端点都是一个资源。

  • 每个资源都可以独立保护。

  • Calling the same resource twice should result in the same operation executed twice.

    例如,执行两个POST /entities将产生两个新实体,而获取GET /entities/some-id将返回同一实体两次。

  • 该服务应该是无状态的,这意味着它不会在请求之间保留有关客户端的信息。

  • 来自 RESTful 服务的响应应该是可缓存的;您应该能够使用 HTTP 标准控制该缓存。

我们可以在这里讨论多个其他元素,但这些是最基本的元素,可以让新手初步了解什么是 RESTful 服务。

我们可以写一整本专门介绍 web API 的书,但我将把这本书的信息控制在最低限度,刚好可以开始。然而,这里有一些关于 web API 中使用的 HTTP 方法和状态代码的广泛适用的指导,应该可以帮助您启动。

请求 HTTP 方法

所使用的 HTTP 方法是 web API 端点可以执行的操作的良好指标。这有助于明确目的。下面列出了最常用的方法、它们的用途以及它们的预期成功状态代码:

响应状态码

HTTP 状态码是将发生的事情传输回 web API 消费者的方式。下表说明了在探索状态代码的预定义组之前最常见的组:

如果您再看一看上一张表,您可能会注意到,涉及类似主题的状态代码被分组在同一个“第一百个”下,例如:

  • 1XX 状态码(上表中省略)表示信息延续结果,通常由服务器自动处理,如100 延续101 交换协议
  • 2XX 是成功的结果。
  • 3XX 与重定向相关。
  • 4XX 是请求错误(来自客户端),通常由用户引入,例如空的必填字段。
  • 5XX 是客户端无法处理的服务器端错误。

如果您不熟悉 REST,并且对 web API 感兴趣,我建议您在完成本书或构建一些项目后阅读更多关于该主题的内容,以便自己掌握它。

对 web API 的剖析

现在我们已经在第 4 章中探讨了 ASP.NET MVC 的基础知识,使用 Razor 的 MVC 模式以及呈现网页的方法,现在是跳转到 web API 并返回数据而不是用户界面的时候了。在过去的几年里,web API 的数量从几个激增到了数百万个;现在每个人都这样做。在这种情况下,我不认为这是因为人们盲目地追随一种趋势,而是基于使 web API 如此吸引人的充分理由,例如:

  • 这是系统间共享数据的有效方式。
  • 通过使用通用语言(如 JSON 或 XML)进行对话,它允许技术之间的互操作性。
  • 它允许您的后端集中并与多个前端(如移动、桌面和 web 应用)共享。

这些原因使得重用后端系统和与多个用户界面或其他后端系统共享它们变得更加容易。例如,想想你知道的任何移动应用;他们很可能要维护一个 iOS 应用、一个 Android 应用和一个 web 应用。在这种情况下,共享后端是节省时间和金钱的最佳方式。

我更新了 MVC 图以表示 web API 的流程:

Figure 5.1 – Workflow of a web API

图 5.1–web API 的工作流程

呈现用户界面和构建 web API 之间只有一些区别:

  • API 不向浏览器发送 HTML,而是输出序列化的数据结构。
  • 客户机通常希望使用数据,而不是直接显示数据。

基于此图,我们将模型直接发送给客户机。在大多数情况下,我们不想这样做。相反,我们只想以我们想要的格式发送我们需要的部分数据。接下来我们将用 DTO 模式讨论这个问题,但首先,让我们深入了解 ASP.NET Core 5 web API。

设置 web API

要使用 ASP.NET Core 5 MVC 的全部功能,而不加载 Razor 引擎和其他视图相关组件,在Startup类中,最低要求如下:

public class Startup
{
    public void ConfigureServices(IServiceCollection services)
    {
        services.AddControllers();
    }
    public void Configure(IApplicationBuilder app)
    {
        app.UseRouting();
        app.UseEndpoints(endpoints =>
        {
            endpoints.MapControllers();
        });
    }
}

AddControllers()扩展方法添加与 web API 控制器相关的类,而不需要使用 Razor 页面或呈现视图所需的额外元素。

然后,我们像在任何 MVC 项目中一样添加路由中间件和端点路由。然而,我们感兴趣的部分是endpoints.MapControllers();扩展方法。它支持使用基于属性的路由(见下文)。就这样,;我们不需要更多的服务。从那里,我们可以添加控制器并开始编码。

与 ASP.NET Core 2.xAddMvc()方法相比,注册分离是一个显著的改进。以前,我们必须向IServiceCollection注册 web API、Razor 页面和 MVC。现在,我们可以挑选我们想要的 ASP.NET 的哪一部分。接下来,我们将探索一种使用属性将 HTTP 请求路由到代码的方法。

属性路由

属性路由允许将 HTTP 请求映射到控制器。控制器继承自ControllerBase或用[ApiController]属性修饰。与 MVC 一样,控制器随后执行动作。

为了涵盖属性路由,让我们使用以下控制器模板:

[Route("api/[controller]")]
[ApiController]
public class ValuesController : ControllerBase
{
    [HttpGet]
    public ActionResult<IEnumerable<string>> Get()
        => new string[] { "value1", "value2" };
    [HttpGet("{id}")]
    public ActionResult<string> Get(int id) => "value";
    // POST api/values
    [HttpPost]
    public void Post([FromBody] string value){}
    [HttpPut("{id}")]
    public void Put(int id, [FromBody] string value){}
    [HttpDelete("{id}")]
    public void Delete(int id){}
}

根据该模板,我们在控制器中使用RouteAttributeHttp[Method]Attribute。这些属性定义了用户应该查询什么以访问特定资源。

RouteAttribute允许您定义适用于所有 HTTP 方法的路由模式,类似于我们在上一章中使用的模式。Http[Method]Attribute定义了一个特定的 HTTP 方法,并提供了设置可选路由模式的可能性。这些属性非常有助于创建简洁明了的 URI,同时使路由靠近控制器。

根据代码,[Route("api/[controller]")]表示可通过api/values访问该控制器的动作(与 MVC 一样忽略Controller后缀)。然后,其他属性告诉 ASP.NET 将特定请求映射到特定方法。例如,[HttpGet]告诉 ASP.NETGET /api/values应该映射到Get()方法。[HttpGet("{id}")]属性告诉路由引擎GET /api/values/1请求应该路由到Get(int id)方法。两者都映射了GET方法,但id参数有助于区分它们;它是鉴别器。其他属性也在做同样的事情,但针对的是不同的 HTTP 方法。

FromBody属性告诉模型绑定器查看 HTTP 请求主体中的该值。其他属性告诉模型绑定器在获取装饰值时要查看的方向。名单如下:

  • FromBody,查看主体并根据Content-Type选择格式化程序。
  • FromForm,查看表单集合(已发布表单值)。
  • FromHeader,查看 HTTP 头。
  • FromQuery,查看查询字符串。
  • FromRoute查看 MVC 路线数据。
  • FromServices,从依赖注入容器注入服务。

如果我们回顾一下ValuesController,它定义了以下端点:

在设计 web API 时,指向端点的 URI 应该清晰简洁,使消费者更容易发现和学习您的 API。这些规则在某种意义上类似于手工制作软件:您希望根据关注点(通常是层次结构)对资源进行分组,从而创建一个易于浏览和使用的内聚 URI 空间。客户端必须能够轻松理解端点背后的逻辑。将端点视为 web API 的用户。我甚至会将这一建议扩展到任何 API,始终考虑代码的使用者,以创建尽可能最好的 API。

接下来,我们将探索从端点返回值的不同方法,以响应 HTTP 请求。

返回值

使用 ASP.NET Core 5 时,有多种方式向客户端返回数据。这里我们看两种不同的退货类型:

  • ActionResult<TValue>
  • IActionResult

然后,我们将探讨如何使用相同的技术异步返回值。

行动结果

当动作指定返回类型ActionResult<TValue>时,使用ControllerBase类提供的方法,如Ok()NotFound()BadRequest()返回序列化对象。

以下代码反映了这一点:

[HttpGet("ActionResultMyResult/{input}")]
public ActionResult<MyResult> GetActionResultMyResult(int input)
{
    return Ok(new MyResult
    {
        Input = input,
        Value = nameof(GetActionResultMyResult)
    });
}

https :// localhost:5010/ActionResultMyResult/1的输出如下:

{
    "input": 1,
    "value": "GetActionResultMyResult"
}

您也可以直接返回TValue,无需调用Ok()方法,如下:

[HttpGet("GetMyResult/{input}")]
public ActionResult<MyResult> GetMyResult(int input)
{
    return new MyResult
    {
        Input = input,
        Value = nameof(GetMyResult)
    };
}

“魔力”是使用类转换运算符编程的(参见C#功能部分)。导航到https :// localhost: 5010 /GetMyResult/1应输出以下结果:

{
    "input": 1,
    "value": "GetMyResult"
}

IActionResult

您也可以使用一个更通用的返回类型IActionResult接口。这样做可以让您有机会使用在ControllerBase类上定义的方法,例如Ok()NotFound()BadRequest()。但是,不能直接返回对象。

以下代码反映了Ok()方法的用法:

[HttpGet("ActionResultMyResult/{input}")]
public IActionResult GetIActionResult(int input)
{
    return Ok(new MyResult
    {
        Input = input,
        Value = "GetActionResultMyResult"
    });
}

https :// localhost:5010/GetIActionResult/1的输出如下:

{
    "input": 1,
    "value": "GetIActionResult"
}

接下来,我们将探讨如何在从端点返回值时利用async/awaitC#特性。

异步返回值

为了保持简单,所有不需要异步处理的示例都直接返回值。在未来的一些示例中,我们将使用异步返回值。每当您要执行异步任务时,必须使用异步控制器操作。要做到这一点,只需返回Task<T>ValueTask<T>即可,其中T实现IActionResult接口或我们之前探讨过的另一种可能的返回类型。下面是一个例子:

public async Task<IActionResult> GetAsync()
{
    var result = await SomeAsynMethod();
    return Ok(result);
}

NET 中的async/await模式将提高应用的性能。简而言之,当任务等待资源响应时,它将处理其他任务。总之,您将能够使用相同的硬件服务更多的请求。

另一个微妙之处是CancellationToken。我认为官方文件非常准确,所以我在这里引用:

CancellationToken 支持线程、线程池工作项或任务对象之间的协作取消

简而言之,取消请求,并取消所有正在运行的任务。使用 ASP.NET,您只需在任何操作中插入一个CancellationToken。以下是一个例子:

public async Task<IActionResult> GetAsync(CancellationToken cancellationToken)
{
    var result = await SomeAsynMethod(cancellationToken);
    return Ok(result);
}

在该代码中,我们注入令牌,然后将其传递给方法,该方法也可以将其传递给其底层异步操作,等等。尽可能使用CancellationToken

接下来,在进入数据传输对象模式之前,我们将探索一些 C#特性,以将 API 的模型与域模型分离。我们将在第 12 章理解分层中深入挖掘模型、数据和领域。

C#特征

这一小节深入探讨了上一节中简要讨论的类转换运算符。然后我们探索局部函数,允许我们在方法内部创建函数。这些是我们可以在任何地方使用的通用特性,而不仅仅是在构建 web API 时。

类转换运算符(C#)

类转换运算符是用户定义的函数,用于隐式或显式地将一种类型转换为另一种类型。许多内置类型都提供这种转换,例如在不进行任何强制转换或方法调用的情况下将int转换为long

int var1 = 5;
long var2 = var1; // This is possible due to a class conversion operator

接下来是一个自定义转换的示例。我们将一个string转换为一个SomeGenericClass<string>类的实例,而不使用强制转换:

using System;
using Xunit;
namespace ConversionOperator
{
    public class SomeGenericClass<T>
    {
        public T Value { get; set; }
        public static implicit operator 
 SomeGenericClass<T>(T value)
 {
 return new SomeGenericClass<T>
 {
 Value = value
 };
 }
    }

SomeGenericClass<T>定义一个名为Value的泛型属性,可以设置为任何类型。突出显示的代码块是转换运算符,允许在不使用强制转换的情况下从类型T转换为SomeGenericClass<T>。下面我们来看一下结果:

    public class Tests
    {
        [Fact]
        public void Value_should_be_set_implicitly()
        {
            var value = "Test";
 SomeGenericClass<string> result = value;
            Assert.Equal("Test", result.Value);
        }

第一个测试方法使用我们刚刚检查过的转换操作符将一个string转换为SomeGenericClass<string>类的一个实例。它适用于方法甚至泛型,下一个测试方法将向您展示:

        [Fact]
        public void Value_should_be_set_implicitly_using_local_function()
        {
            var result1 = GetValue("Test");
            Assert.Equal("Test", result1.Value);
            var result2 = GetValue(123);
            Assert.Equal(123, result2.Value);
            static SomeGenericClass<T> GetValue<T>(T value)
            {
 return value;
            }
        }
    }
}

前面的代码隐式地将string转换为SomeGenericClass<string>对象,然后将int转换为SomeGenericClass<int>对象。这就是动作结果转换的方式,就像我们在突出显示的行中所做的那样。我们直接将T类型的值作为SomeGenericClass<T>类的实例返回。

这并不是本书最重要的主题,但是如果你对.NET 是如何进行隐式转换感到好奇的话,这就是为什么。现在您知道,当您需要这种行为时,可以在类中实现自定义转换运算符。

局部函数(C#7)和静态局部函数(C#8)

在之前的示例中,我们使用了一个静态局部函数(C#8 新增)来演示类转换运算符。

本地函数可以在方法、构造函数、属性访问器、事件访问器、匿名方法、lambda 表达式、终结器和其他本地函数中定义。这些函数是其包含成员的私有函数。它们非常有用,可以使代码更加明确和自解释,而不会污染类本身,使它们保持在消费成员的范围内。本地函数可以访问声明成员的变量和参数,如下所示:

[Fact]
public void With_no_parameter_accessing_outer_scope()
{
    var x = 1;
    var y = 2;
    var z = Add();
    Assert.Equal(3, z);
    x = 2;
    y = 3;
    var n = Add();
    Assert.Equal(5, n);
    int Add()
    {
        return x + y;
    }
}

这不是一个健壮的函数,代码也没有那么优雅,但它展示了本地函数如何访问其父作用域的成员。以下代码块显示了一个混合:

[Fact]
public void With_one_parameter_accessing_outer_scope()
{
    var x = 1;
    var z = Add(2);
    Assert.Equal(3, z);
    x = 2;
    var n = Add(3);
    Assert.Equal(5, n);
    int Add(int y)
    {
        return x + y;
    }
}

该块显示如何来传递参数,以及局部函数如何仍然可以使用其外部作用域的变量来改变其结果。现在,如果我们想要一个独立的函数,与它的外部作用域分离,我们可以编写以下代码:

[Fact]
public void With_two_parameters_not_accessing_outer_scope()
{
    var a = Add(1, 2);
    Assert.Equal(3, a);
    var b = Add(2, 3);
    Assert.Equal(5, b);
    int Add(int x, int y)
    {
        return x + y;
    }
}

该代码比我们的其他代码更清晰、更明确。但它仍然允许稍后修改它并使用外部范围(没有说明限制访问外部范围的意图),如下所示:

[Fact]
public void With_two_parameters_accessing_outer_scope()
{
    var z = 5;
    var a = Add(1, 2);
    Assert.Equal(8, a);
    var b = Add(2, 3);
    Assert.Equal(10, b);
    int Add(int x, int y)
    {
        return x + y + z;
    }
}

为了阐明这一意图,静态本地功能起到了的作用。它们删除了访问封闭范围变量的选项,并用static关键字清楚地说明了这一点。以下的是之前功能的静态等价物:

[Fact]
public void With_two_parameters()
{
    var a = Add(1, 2);
    Assert.Equal(3, a);
    var b = Add(2, 3);
    Assert.Equal(5, b);
    static int Add(int x, int y)
    {
        return x + y;
    }
}

然后,有了明确的定义,更新后的版本可以变成以下版本,从而保持本地功能的独立性:

[Fact]
public void With_three_parameters()
{
    var c = 5;
    var a = Add(1, 2, c);
    Assert.Equal(8, a);
    var b = Add(2, 3, c);
    Assert.Equal(10, b);
    static int Add(int x, int y, int z)
    {
        return x + y + z;
    }
}

没有什么可以阻止某人删除static修饰符,也许是一次很好的代码审查,但至少没有人可以说意图不够清楚。

使用封闭的范围有时可能有用,但我更愿意尽可能避免这种情况,原因与我尽最大努力避免全局内容相同:代码会变得更混乱、更快。

总而言之,我们可以通过在另一个受支持的成员中声明本地函数来创建本地函数,而无需指定任何访问修饰符(publicprivate等等)。该函数可以访问其声明范围、公开参数,并执行方法可以执行的几乎所有操作,包括作为asyncunsafe。然后是 C#8,它添加了将本地函数定义为static的选项,阻止了对其外部范围的访问,并明确说明了独立、独立、私有本地函数的意图。

现在我们已经对这些 C#特性有了初步了解,现在是时候回到 web API 并探索数据传输对象

数据传输对象设计模式

数据传输****对象DTO模式)与视图模型模式等效,但用于 web API。我们的目标不是视图,而是 web API 端点的消费者。

目标

目标是限制和控制端点的输入和输出到我们需要的数据,将 API 的契约与应用的内部工作分离。DTO 应该使我们能够在不考虑底层数据结构的情况下定义 API,让我们可以选择按照自己的方式设计 web 服务。更准确地说,我们可以按照我们希望消费者与他们互动的方式来制作它们。因此,无论底层系统是什么,我们都可以使用 DTO 来设计易于使用和维护的端点。另一个可能的结果是通过限制 API 传输的信息量来节省带宽。

设计

让我们从分析一个模式开始,您可能会发现它与我们在访问视图模型时看到的模式类似:

图 5.2–带 DTO 的 MVC 工作流

视图模型和 DTO 之间相同的几个差异也可以应用于此,遵循相同的思想:将域与视图分离。这些设计模式是相同的,但一个是针对视图的,另一个是针对 web 服务的输入和输出的。

项目–DTO

上下文:在一个新的应用中,我们的用户体验专家认为,在一个新的仪表板上显示客户合同的统计数据将是一个非常好的主意,这将为管理者节省大量时间。除此之外,当用户单击客户机时,我们的用户体验专家决定最好显示客户机的全部详细信息,以防经理需要深入挖掘该客户机的数据。

该系统由查询单个 web API 的多个用户界面组成。然而,我们只关注系统的后端部分。

在设计新功能的圆桌会议之后,以下是对两个新端点的要求:

第一个端点应返回包含以下信息的客户端列表:

[
    {
        "id": 0,
        "name": "...",
        "totalNumberOfContracts": 0,
        "numberOfOpenContracts": 0
    }
]

第二个端点应返回指定的客户机及其契约的完整列表。我们需要以以下格式提供信息:

{
    "id": 0,
    "name": "...",
    "contracts": [
        {
            "id": 0,
            "name": "...",
            "description": "...",
            "workTotal": 0,
            "workDone": 0,
            "workState": "Completed",
            "primaryContactFirstname": "...",
            "primaryContactLastname": "...",
            "primaryContactEmail": "..."
        }
    ]
}

我们的数据结构如下:

public class Client
{
    public int Id { get; set; }
    public string Name { get; set; }
    public List<Contract> Contracts { get; set; }
}
public class Contract
{
    public int Id { get; set; }
    public string Name { get; set; }
    public string Description { get; set; }
    public ContractWork Work { get; set; }
    public ContactInformation PrimaryContact { get; set; }
}
public class ContractWork
{
    public int Total { get; set; }
    public int Done { get; set; }
    public WorkState State => 
        Done == 0 ? WorkState.New : 
        Done == Total ? WorkState.Completed : 
        WorkState.InProgress;
}
public enum WorkState
{
    New,
    InProgress,
    Completed
}
public class ContactInformation
{
    public string Firstname { get; set; }
    public string Lastname { get; set; }
    public string Email { get; set; }
}

作为一名优秀的开发人员,我们从分析特性开始。问题很快就出现了:我们拥有的数据和我们需要提供给 UI 的数据是不同的。

如果要直接使用数据结构,用户界面必须发出多个 HTTP 请求来构建仪表板。这会将逻辑推送到 UI,甚至可能复制它。这可能会让维护变得单调乏味,特别是如果我们添加其他用户界面的话。

解决方案:我们需要在 web API 中创建两个专门的资源来运行计算并只返回所需的数据。

笔记

为了保持它的简单性并从控制器中抽象出数据访问逻辑,我们将该代码移动到ClientRepository类,该类向控制器提供静态数据,此处省略。

对于第一个端点,我们创建一个名为ClientSummaryDto的新类,该类保存两个统计信息和客户机信息:

public class ClientSummaryDto
{
    [JsonPropertyName("id")]
    public int Id { get; set; }
    [JsonPropertyName("name")]
    public string Name { get; set; }
    [JsonPropertyName("totalNumberOfContracts")]
    public int TotalNumberOfContracts { get; set; }
    [JsonPropertyName("numberOfOpenContracts")]
    public int NumberOfOpenContracts { get; set; }
}

属性由JsonPropertyNameAttribute修饰,以明确定义序列化的属性名称。这是 DTO 模式的优点之一。由于 DTO 与我们的其他对象无关,因此我们可以在不影响数据源的情况下对其进行操作,从而降低发生不可预见的后果(例如更新 DTO 和破坏数据库)的可能性。

例如,在ClientSummaryDto中添加JsonPropertyNameAttributeClient类没有影响。

返回数据的操作,代表我们的第一个端点,如下所示:

[HttpGet]
public ActionResult<IEnumerable<ClientSummaryDto>> Get()
{
    var clients = _clientRepository.ReadAll();
    var dto = clients.Select(client => new ClientSummaryDto
    {
        Id = client.Id,
        Name = client.Name,
        TotalNumberOfContracts = client.Contracts.Count,
        NumberOfOpenContracts = client.Contracts.Count(x => x.Work.State != WorkState.Completed)
    }).ToArray();
    return dto;
}

发生的情况是:

  1. 我们从ClientRepository实例读取数据(可能来自数据库)。
  2. 我们将其转换为 DTO 对象数组(将数据复制到新对象中)。
  3. 我们将该 DTO 返回给客户机。

如果我们运行应用并导航到GET /api/clients,我们将看到以下输出:

[
    {
        "id": 1,
        "name": "Jonny Boy Inc.",
        "totalNumberOfContracts": 2,
        "numberOfOpenContracts": 1
    },
    {
        "id": 2,
        "name": "Some mega-corporation",
        "totalNumberOfContracts": 1,
        "numberOfOpenContracts": 1
    }
]

既然第一个端点正在工作,那么让我们攻击第二个端点。根据我们的需求,我们需要为此创建两个类:

public class ClientDetailsDto
{
    [JsonPropertyName("id")]
    public int Id { get; set; }
    [JsonPropertyName("name")]
    public string Name { get; set; }
    [JsonPropertyName("contracts")]
    public IEnumerable<ContractDetailsDto> Contracts { get; set; }
}
public class ContractDetailsDto
{
    [JsonPropertyName("id")]
    public int Id { get; set; }
    [JsonPropertyName("name")]
    public string Name { get; set; }
    [JsonPropertyName("description")]
    public string Description { get; set; }
    [JsonPropertyName("workTotal")]
    public int WorkTotal { get; set; }
    [JsonPropertyName("workDone")]
    public int WorkDone { get; set; }
    [JsonPropertyName("workState")]
    [JsonConverter(typeof(JsonStringEnumConverter))]
    public WorkState WorkState { get; set; }
    [JsonPropertyName("primaryContactFirstname")]
    public string PrimaryContactFirstname { get; set; }
    [JsonPropertyName("primaryContactLastname")]
    public string PrimaryContactLastname { get; set; }
    [JsonPropertyName("primaryContactEmail")]
    public string PrimaryContactEmail { get; set; }
}

再一次,我们用属性修饰属性以控制输出,而不影响数据模型类。

我们使用ContractDetailsDto.WorkState属性上的属性告诉序列化程序WorkState枚举应该序列化为string而不是数字索引。

笔记

过去,ASP.NET Core 使用 JSON.NET 作为底层 JSON 序列化程序。自.NET Core 3.0 以来,他们添加了System.Text.Json名称空间,其中包含一个全新的序列化程序。新的序列化程序速度更快,但功能更少。如果您需要 JSON.NET 功能,或者出于兼容性原因,可以通过引用Microsoft.AspNetCore.Mvc.NewtonsoftJsonNuGet 包来使用它。然后,在IMvcBuilder对象上添加对AddNewtonsoftJson()扩展方法的调用,如services.AddControllers().AddNewtonsoftJson();

现在我们有一个数据结构来表示我们的 DTO,让我们看一下控制器的代码:

// GET api/clients/1
[HttpGet("{id}")]
public IActionResult Get(int id)
{
    var client = _clientRepository.ReadOne(id);
    if (client == default(Client))
    {
        return NotFound();
    }
    var dto = new ClientDetailsDto
    {
        Id = client.Id,
        Name = client.Name,
        Contracts = client.Contracts.Select(contract => new ContractDetailsDto
        {
            Id = contract.Id,
            Name = contract.Name,
            Description = contract.Description,
            // Flattening PrimaryContact
            PrimaryContactEmail = contract.PrimaryContact.Email,
            PrimaryContactFirstname = contract.PrimaryContact.Firstname,
            PrimaryContactLastname = contract.PrimaryContact.Lastname,
            // Flattening Work
            WorkDone = contract.Work.Done,
            WorkState = contract.Work.State,
            WorkTotal = contract.Work.Total
        })
    };
    return Ok(dto);
}

正如您可能已经注意到的,此操作返回一个IActionResult而不是一个ActionResult<ClientDetailsDto>;这是为了向你展示我们之前看到的可能性。

该操作将Client的细节平坦化为ClientDetailsDto,如果Client不存在,则返回404 Not Found

如果我们运行应用并导航到GET /api/clients/2,我们应该有以下输出:

{
    "id": 2,
    "name": "Some mega-corporation",
    "contracts": [
        {
            "id": 3,
            "name": "Huge contract",
            "description": "This is a huge contract of Some mega-corporation.",
            "workTotal": 15000,
            "workDone": 0,
            "workState": "New",
            "primaryContactFirstname": "Kory",
            "primaryContactLastname": "O'Neill",
            "primaryContactEmail": "kory.oneill@ megacorp.com"
        }
    ]
}

瞧!我们的小应用正在按预期工作,而且没有付出太多努力。我们获取了一些数据,将其转换为不同的格式,计算了一些统计数据,展平了一些对象,并将其序列化为 JSON,以便消费者可以开始使用这两个端点。所有这些都是在没有对初始模型进行任何修改的情况下完成的,而是通过创建 DTO 实现的。

笔记

在一个更重要的项目中,我建议将尽可能多的逻辑移出控制器,因为我们不想打破单一责任原则。然而,将模型扁平化为 DTO 可以说是控制器的责任。我们也可以使用 AutoMapper 来实现这一点。更多信息请参见第 13 章开始使用对象映射器

可以将控制器看作 HTTP 和应用逻辑之间的桥梁,或者如果您愿意,可以将其看作一个非常薄的层,允许用户通过 HTTP 访问您的软件。

既然我们已经研究了 DTO,那么让我们深入挖掘并讨论定义 web API 的 API 契约。

API 合同

API 合同是 web API 的定义。像任何标准 API 一样,消费者应该知道如何调用端点,以及期望从中得到什么。每个端点都应该有一个签名,就像一个方法一样,并且应该强制执行该签名。

使用 DTO 作为输入和输出使它们成为契约的一部分,为它们增加更多的价值,锁定契约,而不是使用更易波动的模型,在系统的多个部分共享。从这一点开始,DTO 不仅仅是一个简单的“用于传输数据的对象”。它成为合同不可分割的一部分,DTO 更改的唯一原因与该合同直接相关。

现在我们已经了解了 API 契约的概念,让我们看看如何共享定义 API 的契约。对于团队合作来说,沟通是关键,系统协作也是如此。因此,API 的使用者应该能够访问契约,以便更有效地使用公开的资源。

为此,我们可以采取以下措施:

  • 打开任何文本编辑器,如 MS Word 或记事本,并开始编写描述 web API 的文档;这可能是最乏味、最不灵活的方法。

  • 使用现有标准,例如 OpenAPI 规范(以前称为 Swagger)。

  • Use any other tools that fit our requirements.

    提示

    我喜欢使用 Postman 来构建 web API 的文档,描述合同。Postman 还允许编写测试,这些测试被组织到集合和文件夹中,并且可以与其他人共享或公开。工具不是排他性的,使用多个工具可以带来更大的生产率提高。我建议您尽可能探索现有的和新的工具。

有些人在定义 API 合同时甚至更进一步,但这又取决于每个项目、您的团队、团队或您所在的公司。现在,让我们保持最低限度,将 API 契约定义为 API 曲面:其输入和输出。

分析 DTO 样品

从开发人员的角度来看,契约是与 URI 和 HTTP 方法关联的模型。例如,如果我们从前面的代码示例中剖析ClientsController,我们将得到以下两个端点:

  1. 阅读所有客户端。
  2. 阅读一个客户。

“读取所有客户端”使用GET方法并侦听api/clientsURI。它没有输入参数,返回一个集合ClientSummaryDto

“读取一个客户机”也使用GET方法,但侦听api/clients/{id}URI。两个GET动作之间的鉴别器是id参数。成功时,操作返回一个ClientDetailsDto实例。

这些是以文本格式定义 API 的合同。这不是分享这些信息的最技术性的方式,但它应该有助于理解这个想法。尽管如此,当你不能用口语解释一个想法时,这可能表明你的分析或理解是不完整的。

项目——OpenAPI

基于前面的代码示例和我们的快速分析,让我们看看生成 OpenAPI 规范文档有多容易。多个工具允许自动生成 OpenAPI 规范。其中最常见的两种是虚张声势和自命不凡。在本示例中,我们将使用后者,因为入门所需的代码量很小。

提示

我绝不会告诉你 NSwag 比 Swashback 好,相反,我建议你看看这两个,然后自己决定。

默认情况下,.NET5 模板现在包括斜扣卫浴。所以作为奖励,你不需要做任何事情就可以准备好。

基于 DTO 示例的副本,要使用 NSwag 创建 OpenAPI 文档,我们需要执行以下操作:

  1. 通过运行dotnet add package``NSwag.AspNetCore或使用 VS 软件包管理器安装NSwag.AspNetCoreNuGet 软件包。
  2. 通过调用services.AddSwaggerDocument();扩展方法将依赖项添加到容器中。
  3. 通过调用app.UseOpenApi();扩展方法配置生成 OpenAPI 文档的中间件。
  4. 通过调用app.UseSwaggerUi3();扩展方法,可以选择配置通过 OpenAPI 文档生成用户界面的中间件。

只有这几行代码,当运行项目并导航到/swagger时,我们应该访问以下 UI:

图 5.3–使用 NSwag 生成的招摇过市用户界面

然后,通过导航到/swagger/v1/swagger.json,我们可以查阅生成的 OpenAPI 文档。简而言之,我不会在这里复制整个 JSON 文档。让我们只研究相关部分,从 DTO 的描述符开始:

"ClientSummaryDto": {
  "type": "object",
  "required": ["id", "totalNumberOfContracts", "numberOfOpenContracts"],
  "properties": {
    "id": {
      "type": "integer",
      "format": "int32"
    },
    "name": {
      "type": "string"
    },
    "totalNumberOfContracts": {
      "type": "integer",
      "format": "int32"
    },
    "numberOfOpenContracts": {
      "type": "integer",
      "format": "int32"
    }
  }
}

这是我们 C#类的一个清晰的表示:

public class ClientSummaryDto
{
    [JsonPropertyName("id")]
    public int Id { get; set; }
    [JsonPropertyName("name")]
    public string Name { get; set; }
    [JsonPropertyName("totalNumberOfContracts")]
    public int TotalNumberOfContracts { get; set; }
    [JsonPropertyName("numberOfOpenContracts")]
    public int NumberOfOpenContracts { get; set; }
}

然后,这里是“读取所有客户端”端点,它返回一个ClientSummaryDto数组:

"/api/Clients": {
  "get": {
    "tags": ["Clients"],
    "operationId": "Clients_GetAll",
    "produces": ["text/plain", "application/json", "text/json"],
    "responses": {
      "200": {
        "x-nullable": false,
        "description": "",
        "schema": {
          "type": "array",
          "items": {
            "$ref": "#/definitions/ClientSummaryDto"
          }
        }
      }
    }
  }
}

所有这些都是在几行代码中为我们生成的。有多个扩展点,扩展这些定义的方法很多,但是 OpenAPI 规范超出了本书的范围。

最后需要注意的是,由于我们在“read One client”端点中使用了IActionResult,OpenAPI 中间件无法理解该操作自动返回的内容。响应仅限于以下描述,非常不清楚:

"responses": {
  "200": {
    "x-nullable": true,
    "description": "",
    "schema": {
      "type": "file"
    }
  }
}

ASP.NET Core 5 提供了一种机制,NSwag 等生成器可以利用该机制缓解此问题(即,ApiExplorer。例如,通过向动作添加两个ProducesResponseType属性,OpenAPI 生成器现在知道要生成什么。以下是装饰方法:

[HttpGet("{id}")]
[ProducesResponseType(StatusCodes.Status200OK, Type = typeof(ClientDetailsDto))]
[ProducesResponseType(StatusCodes.Status404NotFound)]
public IActionResult Get(int id)
// …

OpenAPI 规范现在更清晰了。以下是该端点的更新responses对象:

"responses": {
  "200": {
    "x-nullable": false,
    "description": "",
    "schema": {
      "$ref": "#/definitions/ClientDetailsDto"
    }
  },
  "404": {
    "x-nullable": false,
    "description": "",
    "schema": {
      "$ref": "#/definitions/ProblemDetails"
    }
  }
}

definitions节点下也创建了缺失的对象,描述了ClientDetailsDto类。这里还明确定义了一个新的404响应,返回默认的ProblemDetails对象。

请记住,大多数元素都是可自定义的。ASP.NET Core 5 还支持使用约定描述 API 的 API 约定,而不是逐个修饰每个操作。如果您正在构建 CRUD API 或者可以遵循预定义的一组约定,那么这可以节省大量时间。您还可以使用ProducesResponseType属性装饰控制器,声明所有操作都可以返回该响应。我们将不再深入探讨 OpenAPI 主题,但了解这一点将有助于您在需要 OpenAPI 文档时开始使用。也可以随意探索其他工具。我选择 NSwag 是因为它简单,但通常使用招摇。也许另一种工具更适合您的需要。

项目-API 合同

上下文:我们正在构建多个.NET 应用,我们希望在项目之间共享我们的合同类(DTO),这样我们就不必在每个项目中复制/粘贴它们。我们知道,最终将有多个客户端与单个后端 web API 通信。

为了实现我们的目标,我们必须将契约类移动到外部程序集中。类库的最佳目标是.NET 标准,将我们的类库用于.NET 5、较旧的.NET Core,甚至.NET Framework 项目。

对于此场景,我们创建了以下项目:

  • My.Api,引用My.Api.Contracts并公开与数据传输对象代码样本相同的动作。

  • My.Api.Contracts,其中包含 DTO。

  • My.Client, which references My.Api.Contracts and queries My.Api using an instance of HttpClient.

    笔记

    我发现这些类型的程序集的名称[name of the api project].Contracts是明确的,但再一次,这是个人偏好。您也可以选择[name of the api``project].DTOs[name of the api project].DTO,或者您喜欢的任何其他名称。命名时,重要的是确保名称清晰,即使不太了解项目。

依赖关系图如下所示:

Figure 5.4 – Dependencies between assemblies; sharing the contracts

图 5.4-组件之间的依赖关系;共享合同

由于表示数据契约的所有类都在共享的My.Api.Contracts库中,My.Client可以在查询My.Api时直接序列化。另一方面,可以反序列化相同的类,使得此设计非常适合在项目之间重用 DTO。

这里没有太多代码要显示,因为它与上一个示例相同,但被拆分为多个程序集。您可以随时查看并运行完整的示例来探索设计:https://net5.link/HYCY

以下屏幕截图显示了新的解决方案结构和项目之间的依赖关系:

Figure 5.5 – Visual Studio Solution Explorer exposing dependencies between projects

图 5.5–Visual Studio 解决方案资源管理器显示项目之间的依赖关系

但是,在继续之前,我将My.Client.Program类粘贴在这里,这是唯一可以可视化的新代码:

namespace My.Client
{
    public class Program
    {
        private static readonly HttpClient http = new HttpClient();
        static async Task Main(string[] args)
        {
            var uri = "https :// localhost:5002/api/clients";
            // Read all summaries
            WriteTitle("All clients summaries");
            var clients = await FetchAndWriteFormattedJson <ClientSummaryDto[]>(uri);
            // Read all details
            foreach (var summary in clients)
            {
                WriteTitle($"Details of {summary.Name} (id: {summary.Id})");
                await FetchAndWriteFormattedJson <ClientDetailsDto>($"{uri}/{summary.Id}");
            }
            Console.ReadLine();
        }
        private static async Task<TContract> FetchAndWriteFormattedJson<TContract>(string uri)
        {
            var response = await http.GetStringAsync(uri); var deserializedObject = JsonSerializer.Deserialize<TContract> (response);
            var formattedJson = JsonSerializer.Serialize (deserializedObject, new JsonSerializerOptions { WriteIndented = true });
            Console.WriteLine(formattedJson);
            return deserializedObject;
        }
        private static void WriteTitle(string title)
        {
            var initialColor = Console.ForegroundColor;
            Console.ForegroundColor = ConsoleColor.Yellow;
            Console.WriteLine();
            Console.WriteLine(title);
            Console.ForegroundColor = initialColor;
        }
    }
}

该代码读取所有客户端并将输出写入控制台。然后,它逐个查询每个客户端的详细信息,并将这些详细信息输出到控制台。

结果是:

Figure 5.6 – All clients displayed in the console as JSON

图 5.6–控制台中显示为 JSON 的所有客户端

如果不共享 DTO,我们将需要复制该信息,并且需要维护的 DTO 数量将增加一倍。

作为一个小小的警告,这并不是解决所有问题的灵丹妙药。共享意味着耦合,这意味着您可以通过更新另一部分的契约来破坏应用的一部分。复制 DTO 的一个好处是,在更改合同之前,由于所需的工作量,您必须更认真地考虑它。您需要手动更新多个项目,而不是在 VisualStudio 中点击F2来重命名属性,或者通过添加或删除属性并假设其他一切仍然有效。最重要的是,通过手动更新每个项目,它迫使您分析更改的影响,从而避免 bug。一个可靠的测试套件应该有助于这些变化。

尽管如此,这是一种在项目之间共享 DTO 的好方法。与所有事情一样,不要每次都盲目地选择这个解决方案,而是首先权衡利弊。

创意——创建类型化客户端库

对之前代码示例的扩展可以是创建另一个扮演类型化 HTTP 客户端角色的项目。然后,该项目将插入到My.ClientMy.Api.Contract之间。通过采用相同的命名方式,我们可以将其命名为My.Api.Client。它将引用My.Api.Contract并公开更多定义的方法,例如http.Clients.ReadAllAsync()http.Clients.ReadOneAsync(id),例如:

Figure 5.7 – Multiple projects using the API Client to communicate with the API

图 5.7–使用 API 客户端与 API 通信的多个项目

这将允许在多个应用中重用My.Api.Client,甚至为My.Api分发 SDK。

如果您希望练习,这可能是一个很好的项目:创建一个通过 HTTP 查询 web API 的类型化库。

在这种情况下,我们只有一个控制器,但假设我们有更多控制器,例如以下两个控制器:

  • 客户
  • 合同

我们的类型化客户端界面可以如下所示:

public interface IMyApiClient
{
    IClientsClient Clients { get; }
    IContractsClient Contracts { get; }
}
public interface IClientsClient
{
    Task<IEnumerable<ClientSummaryDto>> ReadAllAsync();
    Task<ClientDetailsDto> ReadOneAsync(int clientId);
    // ...
}
public interface IContractsClient
{
    // ...
}

如果你不太确定,别担心。我们将在后面更多地讨论接口,并介绍依赖注入和策略模式等技术,这将帮助您设计这样一个类型化的客户机。

最后一个观察结果

我不知道您是否注意到,在那些最后的设计中,通过共享 DTO,我们能够共享我们的 API 契约,而不向其他应用公开我们的域模型。想想看;如果我们不使用 DTO,我们会直接公开我们的内部类。由于多个应用直接依赖于这些内部类,因此在模型演化时,这可能会导致摩擦。现在,由于 DTO 屏蔽了我们的系统,我们可以共享一个模型,但将内部数据模型隐藏在外部世界之外,从而从长远来看更容易维护我们的 API。

如前所述,DTO 是共享的,但只要我们不更改它们,我们就可以更改数据建模的方式,而不会对外部使用者产生任何影响。

总结

在本章中,我们探讨了如何利用 web API 并创建 web 服务,这些 web 服务公开 REST 端点以通过 HTTP 共享数据。我们还了解了如何使用 DTO 将模型与“表示”分离。

DTO 等同于视图模型,但适用于 web 服务。它们是围绕特定资源(HTTP 端点)精心编制的类。DTO 不需要将原始数据返回给客户端,而是可以封装计算结果、限制公开属性的数量、聚合结果并展平数据结构,从而仔细地构建代表其端点输入和输出的 API 契约。

然后,我们通过定义 DTO 是 API 契约的一部分,沿着这条路径进一步挖掘。契约是我们的 web API 的定义,因此它的消费者知道如何与之通信。我们还研究了在.NET 项目之间共享 DTO。

最后,我们确定必须将组件与系统分离,这是前几章的后续内容,在前几章中,我们探讨了体系结构原则、自动化测试等。

在接下来的两章中,我们将探索我们的第一个四人帮GoF)设计模式,并深入研究 ASP.NET Core依赖注入DI系统。所有这些都将帮助我们继续走我们开始的道路:设计更好的软件

问题

让我们看几个练习问题:

  1. 在 RESTAPI 中,创建实体后发送的最常见状态代码是什么?
  2. 什么属性告诉 ASP.NET 将请求正文的数据绑定到参数?
  3. 如果您想从服务器读取数据,您会使用什么 HTTP 方法?
  4. DTO 能否为系统增加灵活性和健壮性?
  5. DTO 是 API 合同的一部分吗?

进一步阅读

以下是我们在本章所学知识的基础上建立的链接:

六、理解策略、抽象工厂和单例设计模式

本章使用 GoF 中一些经典、简单但功能强大的设计模式探索对象创建。这些模式允许开发人员封装行为、集中对象创建、增加设计灵活性或控制对象生命周期。此外,它们很可能在您将来直接或间接构建的每个软件中使用。

戈夫

ErichGamma、Richard Helm、Ralph Johnson 和 John Vlissides 是设计模式:可重用面向对象软件的元素(1994)的作者,也称为GoFGoF。在那本书中,他们介绍了 23 种设计模式,其中一些我们将在本书中介绍。

为什么它们那么重要?因为它们是健壮对象组合的构建块,有助于创建灵活性和可靠性。此外,在第 7 章深入依赖注入中,我们将利用依赖注入使这些模式更加强大!

但首先要做的是。本章将介绍以下主题:

  • 战略设计模式
  • 简单介绍几个 C#特性
  • 抽象工厂设计模式
  • 单例设计模式

战略设计模式

策略模式是一种行为设计模式,允许我们在运行时更改对象行为。我们也可以使用此模式来组合复杂的对象树,并依靠它来遵循打开/关闭原则OCP),而无需太多努力。作为最后一点的后续,战略模式在组合而非继承思维方式中起着重要作用。在本章中,我们重点关注战略模式的行为部分。在下一章中,我们将介绍如何使用策略模式动态组合系统。

目标

策略模式的目标是从需要它的宿主类(上下文)中提取算法(策略)。这允许消费者决定在运行时使用的策略(算法)。

例如,我们可以设计一个从两种不同类型的数据库获取数据的系统。然后,我们可以对该数据应用相同的逻辑,并使用相同的用户界面来显示它。为了实现这一点,我们可以使用策略模式创建两个策略,一个名为FetchDataFromSql,另一个名为FetchDataFromCosmosDb。然后我们可以在context类中插入运行时需要的策略。这样,当消费者呼叫context时,context不需要知道数据来自何处、数据是如何获取的或使用了什么策略;它只获取需要工作的内容,将获取职责委托给抽象的策略。

设计

在进一步的解释之前,让我们来看看下面的类图:

图 6.1–策略模式类图

战略模式的组成部分如下:

  • Context是将一个或多个操作委托给IStrategy实现的类。
  • IStrategy是定义策略的接口。
  • ConcreteStrategy1ConcreteStrategy2代表IStrategy接口的一个或多个不同的具体实现。

在下图中,我们将探讨运行时发生的情况。actor表示使用Context对象的任何代码。

图 6.2–策略模式序列图

当使用者调用Context.SomeOperation()方法时,它不知道执行了哪个实现,这是该模式的一个重要部分。Context也不应该知道正在使用的策略。它应该通过接口执行它,而不需要知道超过该点的实现。这就是策略模式的优势:它将实现从Context和消费者两方面抽象出来。

笔记

我们甚至可以概括最后一句话,并将其扩展到任何接口的使用。使用接口通过依赖抽象消除了使用者和实现之间的联系。

项目:战略

上下文:我们想用不同的策略对集合进行排序。最初,我们希望支持按升序或降序对列表中的元素进行排序。

为了实现这一目标,我们需要实施以下构建模块:

  • 上下文SortableCollection类。

  • 策略ISortStrategy接口。

  • The concrete strategies are:

    a) SortAscendingStrategy

    b) SortDescendingStrategy

consumer 是一个小程序,允许用户选择策略、对集合排序和显示项目。让我们从ISortStrategy界面开始:

public interface ISortStrategy
{
    IOrderedEnumerable<string> Sort(IEnumerable<string> input);
} 

该接口只包含一个方法,该方法需要字符串集合作为输入,并返回有序的字符串集合。现在让我们检查两个实现:

public class SortAscendingStrategy : ISortStrategy
{
    public IOrderedEnumerable<string> Sort(IEnumerable<string> input) 
        => input.OrderBy(x => x);
}
public class SortDescendingStrategy : ISortStrategy
{
    public IOrderedEnumerable<string> Sort(IEnumerable<string> input) 
        => input.OrderByDescending(x => x);
}

两种实现都非常简单,使用 LINQ 对输入进行排序并直接返回结果。这两种实现都使用表达体方法,我们在第 4 章中提到了使用 Razor的 MVC 模式。

提示

当使用表达体方法时,请确保不会使同事难以阅读该方法。

下一个要检查的构件是SortableCollection类。它本身不是一个集合(它没有实现IEnumerable或其他集合接口),但它由项目组成,可以使用ISortStrategy对它们进行排序,如下所示:

public sealed class SortableCollection
{
    public ISortStrategy SortStrategy { get; set; }
    public IEnumerable<string> Items { get; private set; }
    public SortableCollection(IEnumerable<string> items)
    {
        Items = items;
    }
    public void Sort()
    {
        if (SortStrategy == null)
        {
            throw new NullReferenceException("Sort strategy not found.");
        }
        Items = SortStrategy.Sort(Items);
    }
}

这个类是迄今为止最复杂的一个,所以让我们更深入地看一看:

  • SortStrategy属性包含对ISortStrategy实现的引用(可以是null
  • Items属性包含对SortableCollection类中包含的字符串集合的引用。
  • 我们在创建SortableCollection实例时,通过其构造函数设置初始IEnumerable<string>
  • Sort方法使用当前SortStrategyItems进行排序。当没有策略设置时,抛出一个NullReferenceException

有了这些代码,我们可以看到战略模式在起作用。SortStrategy属性表示当前算法,与ISortStrategy契约有关,该契约可在运行时更新。SortableCollection.Sort()方法将工作委托给ISortStrategy实施(具体策略)。因此,更改SortStrategy属性的值会导致Sort()方法的行为发生变化,这使得该模式非常强大但简单。

让我们看看MyConsumerApp,这是一个使用前面代码的控制台应用:

public class Program
{
    private static readonly SortableCollection _data = new SortableCollection(new[] { "Lorem", "ipsum", "dolor", "sit", "amet." });

_data实例表示上下文,这是我们可排序的项集合。接下来,一个空的Main方法:

    public static void Main(string[] args) { /*...*/ }

为了将重点放在模式上,我去掉了控制台逻辑,这与现在无关。

    private static string SetSortAsc()
    {
        _data.SortStrategy = new SortAscendingStrategy();
        return "The sort strategy is now Ascending!";
    }

前面的方法将策略设置为SortAscendingStrategy的新实例。

    private static string SetSortDesc()
    {
        _data.SortStrategy = new SortDescendingStrategy();
        return "The sort strategy is now Descending!";
    }

前面的方法将策略设置为SortDescendingStrategy的新实例。

    private static string SortData()
    {
        try
        {
            _data.Sort();
            return "Data sorted!";
        }
        catch (NullReferenceException ex)
        {
            return ex.Message;
        }
    }

SortData方法调用Sort()方法,该方法将调用委托给可选的ISortStrategy实现。

    private static string PrintCollection()
    {
        var sb = new StringBuilder();
        foreach (var item in _data.Items)
        {
            sb.AppendLine(item);
        }
        return sb.ToString();
    }
}

最后一个方法在控制台中显示集合,以直观地验证代码的正确性。

运行程序时,会出现以下菜单:

图 6.3–显示选项菜单的输出

当用户选择一个选项时,程序调用适当的方法,如前所述。

执行程序时,如果显示项目(1),它们将按初始顺序显示。如果分配策略(3 或 4),对集合(2)进行排序,然后再次显示列表,则顺序将发生更改,并且根据所选算法的不同而有所不同。

选择以下选项时,让我们分析事件的顺序:

  1. 选择排序提升策略(3

  2. Sort the collection (2).

    接下来,是一个表示以下内容的序列图:

图 6.4–使用“升序排序”策略对项目进行排序的序列图(选项 3 和选项 2)

前面的图显示了Program创建策略并将其分配给SortableCollection。然后,当Program调用Sort()方法时,SortableCollection实例将排序计算委托给SortAscendingStrategy类实现的底层算法,也称为策略

从模式的角度来看,SortableCollection类,也称上下文,负责保持当前策略并使用它。

结论

策略设计模式非常有效地将责任委托给其他对象。它还允许拥有一个丰富的界面(上下文),其中包含在程序执行过程中可能发生变化的行为。

战略不必直接暴露;它也可以是类的私有,向外部世界(消费者)隐藏它的存在;我们将在下一章对此进行更多讨论。同时,战略模式非常有助于我们遵循坚实的原则:

  • S:它有助于将职责提取到外部类,并在以后交替使用它们。
  • O:通过在运行时更改当前策略,允许在不更新代码的情况下扩展类。
  • L:不依赖继承。此外,它在组合重于继承原则中扮演着重要角色,帮助我们完全避免继承,同时也避免了 LSP。
  • I:通过创建基于精益和专注界面的小型战略,该战略模式是尊重 ISP 的极好促成因素。
  • D:依赖项的创建从使用策略的类(上下文)移动到类的使用者。这使得上下文依赖于抽象而不是实现,从而颠倒了控制流。

在进入抽象工厂模式之前,我们将了解一些 C#特性,以帮助编写更干净的代码。

简单介绍几个 C#功能

让我们回到策略模式代码示例的Main方法。在那里,我使用了一些更新的 C#特性。我省略了那里的实现,因为它与模式本身无关,但下面是缺失的代码,用于分析它:

public static void Main(string[] args)
{
    string input = default;
    do
    {
        Console.Clear();
        Console.WriteLine("Options:");
        Console.WriteLine("1: Display the items");
        Console.WriteLine("2: Sort the collection");
        Console.WriteLine("3: Select the sort ascending strategy");
        Console.WriteLine("4: Select the sort descending strategy");
        Console.WriteLine("0: Exit");
        Console.WriteLine("--------------------------------------");
        Console.WriteLine("Please make a selection: ");
        input = Console.ReadLine();
        Console.Clear();
        var output = input switch
 {
 "1" => PrintCollection(),
 "2" => SortData(),
 "3" => SetSortAsc(),
 "4" => SetSortDesc(),
 "0" => "Exiting",
 _   => "Invalid input!"
 };
        Console.WriteLine(output);
        Console.WriteLine("Press **enter** to continue.");
        Console.ReadLine();
    } while (input != "0");
}

默认文字表达式(C#7.1)

探索的第一个 C#特性是在 C#7.1 中引入的,被称为默认字面表达式。它允许我们减少使用默认值表达式所需的代码量。

在此之前,我们需要写以下内容:

string input = default(string);

或者这个:

var input = default(string);

现在,我们可以这样写:

string input = default;

它对于可选参数非常有用,例如:

public void SomeMethod(string input1, string input2 = default)
{
    // …
}

在该代码块中,我们可以向该方法传递一个或两个参数。当我们省略input2参数时,它被实例化为default(string)string的默认值为null

开关表达式(C#8)

要探索的第二个 C#特性是在 C#8 中引入的,名为开关表达式。在此之前,我们需要写以下内容:

string output = default;
switch (input)
{
    case "1":
        output = PrintCollection();
        break;
    case "2":
        output = SortData();
        break;
    case "3":
        output = SetSortAsc();
        break;
    case "4":
        output = SetSortDesc();
        break;
    case "0":
        output = "Exiting";
        break;
    default:
        output = "Invalid input!";
        break;
}

现在,我们可以这样写:

var output = input switch
{
    "1" => PrintCollection(),
    "2" => SortData(),
    "3" => SetSortAsc(),
    "4" => SetSortDesc(),
    "0" => "Exiting",
    _   => "Invalid input!"
};

这使得代码更短、更简单。一旦你习惯了,我发现这种新方法更容易阅读。您可以将开关表达式想象为switch返回一个值。

丢弃(C#7)

丢弃是我们将在这里探讨的最后一个 C#功能。它是在公元七世纪引入的。在这种情况下,它变成了switchdefault情况(见突出显示的行):

var output = input switch
{
    "1" => PrintCollection(),
    "2" => SortData(),
    "3" => SetSortAsc(),
    "4" => SetSortDesc(),
    "0" => "Exiting",
 _   => "Invalid input!"
};

丢弃(_)也可用于其他场景。它是一个不能使用的特殊变量,一个占位符,就像一个不存在的变量一样。通过使用丢弃,您不会为该变量分配内存,这有助于优化您的应用。

当解构仅使用其部分成员的元组时,它也很有用。当使用您不想使用的out参数调用方法时,它也非常方便,例如:

if (bool.TryParse("true", out _))
{
    /* ... */
}

在最后一个代码块中,我们只想在输入是布尔值的情况下执行一些操作,但我们不使用布尔值本身,这对于丢弃变量来说是一个很好的方案。

我现在跳过元组,因为我们将在下一章中讨论它们。

抽象工厂设计模式

抽象工厂设计模式是 GoF 的一种创造性设计模式。我们使用创造模式来创建其他对象,工厂是一种非常流行的方式。

目标

抽象工厂模式用于抽象对象族的创建。它通常意味着在该族中创建多个对象类型。族是一组相关或从属对象(类)。

让我们考虑一下如何创建车辆。车辆有多种类型,每种类型都有多种型号。我们可以使用抽象工厂模式,使我们的生活更容易这种类型的场景。

笔记

还有工厂方法模式,其重点是创建单一类型的对象,而不是族。我们在这里只讨论抽象工厂,但在本书后面部分我们使用了其他类型的工厂。

设计

使用抽象工厂,消费者要求一个抽象对象并得到一个。工厂是一个抽象,产生的对象也是抽象,将对象创建与消费者分离。这还允许我们添加或删除对象族,而不会影响使用者。

如果我们考虑一下汽车,我们就有能力制造每种类型汽车的低档和高档版本。让我们来看一个表示这个的类图:

图 6.5-抽象工厂类图

在图中,我们有以下内容:

  • IVehicleFactory是一个抽象工厂,定义了两种方法:一种是创建ICar类型的汽车,另一种是创建IBike类型的自行车。
  • HighGradeVehicleFactory是抽象工厂的一个实现,处理高级车辆创建。此混凝土工厂返回类型为HighGradeCarHighGradeBike的实例。
  • LowGradeVehicleFactory是我们抽象工厂的一个实现,处理低级车辆的创建。此混凝土工厂返回类型为LowGradeCarLowGradeBike的实例。
  • LowGradeCarHighGradeCarICar的两种实现方式。
  • LowGradeBikeHighGradeBikeIBike的两种实现方式。

根据该图,消费者使用IVehicleFactory接口,不应该知道下面使用的具体工厂,从而抽象出车辆创建过程。

项目名称:AbstractVehicleFactory

上下文:我们需要支持创建多种类型的车辆。我们还需要能够在不影响系统的情况下添加可用的新类型。首先,我们只支持高档和低档车辆。此外,该计划只支持汽车和自行车的创作。

为了我们的演示,车辆只是空的类和接口:

public interface ICar { }
public interface IBike { }
public class LowGradeCar : ICar { }
public class LowGradeBike : IBike { }
public class HighGradeCar : ICar { }
public class HighGradeBike : IBike { }

现在让我们看看我们要研究的部分——工厂:

public interface IVehicleFactory
{
    ICar CreateCar();
    IBike CreateBike();
}
public class LowGradeVehicleFactory : IVehicleFactory
{
    public IBike CreateBike() => new LowGradeBike();
    public ICar CreateCar() => new LowGradeCar();
}
public class HighGradeVehicleFactory : IVehicleFactory
{
    public IBike CreateBike() => new HighGradeBike();
    public ICar CreateCar() => new HighGradeCar();
}

工厂是能够很好地描述模式的简单实现:

  • LowGradeVehicleFactory打造低档车。
  • HighGradeVehicleFactory打造高档车。

消费者是一个 xUnit 测试项目。单元测试通常是您的第一个消费者,尤其是在您进行 TDD 时。

AbstractFactoryBaseTestData类封装了我们的一些测试数据类的实用程序,与我们的模式研究无关。尽管如此,它手头有所有的代码是有用的,而且它是一个非常小的类;让我们从这里开始:

public abstract class AbstractFactoryBaseTestData : IEnumerable<object[]>
{
    private readonly TheoryData<IVehicleFactory, Type> _data = new TheoryData<IVehicleFactory, Type>();
    protected void AddTestData<TConcreteFactory, TExpectedVehicle>() 
        where TConcreteFactory : IVehicleFactory, new()
    {
        _data.Add(new TConcreteFactory(), typeof(TExpectedVehicle));
    }
    public IEnumerator<object[]> GetEnumerator() => _data.GetEnumerator();
    IEnumerator IEnumerable.GetEnumerator() => GetEnumerator();
}

这个类是一个IEnumerable<object[]>类,它有一个TheoryData<T1, T2>类的私人集合,还有一个AddTestData<TConcreteFactory, TExpectedVehicle>()类用来为我们的理论提供素材的方法。

让我们看一下 To.T0.具体测试类 ALE T1 及其理论:

public class AbstractFactoryTest
{
    [Theory]
    [ClassData(typeof(AbstractFactoryTestCars))]
    public void Should_create_a_Car_of_the_specified_type(IVehicleFactory vehicleFactory, Type expectedCarType)
    {
        // Act
        ICar result = vehicleFactory.CreateCar();
        // Assert
        Assert.IsType(expectedCarType, result);
    }
    [Theory]
    [ClassData(typeof(AbstractFactoryTestBikes))]
    public void Should_create_a_Bike_of_the_specified_type(IVehicleFactory vehicleFactory, Type expectedBikeType)
    {
        // Act
        IBike result = vehicleFactory.CreateBike();
        // Assert
        Assert.IsType(expectedBikeType, result);
    }
}

在前面的代码中,我们有两种理论,每种理论都使用由[ClassData(...)]属性定义的类中包含的数据(请参见突出显示的代码)。测试运行程序使用该数据填充测试方法参数的值。因此,测试运行程序对每组数据执行一次测试。在这种情况下,每个方法运行两次(下面将介绍测试数据)。

每种试验方法的执行如下:

  1. 我们使用抽象工厂IVehicleFactory vehicleFactory创建ICarIBike实例。

  2. We test that instance against the expected concrete type to ensure it is the right type; that type is specified by Type expectedCarType or Type expectedBikeType, depending on the test method.

    笔记

    我使用ICarIBike来输入变量,而不是var,以使result变量的类型更清晰。在另一种情况下,我会使用var来代替。

现在来看Theory数据:

public class AbstractFactoryTestCars : AbstractFactoryBaseTestData
{
    public AbstractFactoryTestCars()
    {
        AddTestData<LowGradeVehicleFactory, LowGradeCar>();
        AddTestData<HighGradeVehicleFactory, HighGradeCar>();
    }
}
public class AbstractFactoryTestBikes : AbstractFactoryBaseTestData
{
    public AbstractFactoryTestBikes()
    {
        AddTestData<LowGradeVehicleFactory, LowGradeBike>();
        AddTestData<HighGradeVehicleFactory, HighGradeBike>();
    }
}

抽象了实现细节后,代码就简单明了了。如果我们仔细观察AbstractFactoryTestCars类,它会创建两组测试数据:

  • LowGradeVehicleFactory表示应该创建一个LowGradeCar实例。
  • 一个应该创建一个HighGradeCar实例的HighGradeVehicleFactory

AbstractFactoryTestBikes数据的也是如此:

  • 一个应该创建一个LowGradeBike实例的LowGradeVehicleFactory
  • 一个应该创建一个HighGradeBike实例的HighGradeVehicleFactory

我们现在有四个测试。使用以下参数执行两次自行车测试(Vehicles.AbstractFactoryTest.Should_create_a_Bike_of_the_specified_type):

(vehicleFactory: HighGradeVehicleFactory { }, expectedBikeType: typeof(Vehicles.Models.HighGradeBike))
(vehicleFactory: LowGradeVehicleFactory { }, expectedBikeType: typeof(Vehicles.Models.LowGradeBike))

以及使用以下参数执行的两个汽车试验(Vehicles.AbstractFactoryTest.Should_create_a_Car_of_the_specified_type):

(vehicleFactory: HighGradeVehicleFactory { }, expectedCarType: typeof(Vehicles.Models.HighGradeCar))
(vehicleFactory: LowGradeVehicleFactory { }, expectedCarType: typeof(Vehicles.Models.LowGradeCar))

如果我们检查测试的执行,两种测试方法都不知道类型。他们使用抽象工厂(IVehicleFactory并根据预期类型测试result

在一个真正的程序中,我们将使用ICarIBike实例来执行一些逻辑、计算统计数据,或执行与该程序相关的任何操作。也许那可能是一个赛车游戏或富人的车库管理系统,谁知道呢!

这个项目的重要部分是对象创建过程的抽象。消费者代码不知道这些实现。

项目:米德尔顿汽车厂

为了证明我们设计的灵活性,在抽象工厂模式的基础上,让添加一个名为MiddleEndVehicleFactory的新混凝土工厂。该工厂应该返回一个MiddleEndCarMiddleEndBike实例。再一次,汽车和自行车只是空课(当然,在你的程序中,它们会做一些事情):

public class MiddleGradeCar : ICar { }
public class MiddleGradeBike : IBike { }

新的MiddleEndVehicleFactory看起来与其他两款基本相同:

public class MiddleEndVehicleFactory : IVehicleFactory
{
    public IBike CreateBike() => new MiddleGradeBike();
    public ICar CreateCar() => new MiddleGradeCar();
}

对于测试类,我们不需要更新测试方法(消费者);我们只需更新设置以添加新的测试数据(请参见粗体显示的行):

public class AbstractFactoryTestCars : AbstractFactoryBaseTestData
{
    public AbstractFactoryTestCars()
    {
        AddTestData<LowGradeVehicleFactory, LowGradeCar>();
        AddTestData<HighGradeVehicleFactory, HighGradeCar>();
        AddTestData<MiddleEndVehicleFactory, MiddleGradeCar>();
    }
}
public class AbstractFactoryTestBikes : AbstractFactoryBaseTestData
{
    public AbstractFactoryTestBikes()
    {
        AddTestData<LowGradeVehicleFactory, LowGradeBike>();
        AddTestData<HighGradeVehicleFactory, HighGradeBike>();
        AddTestData<MiddleEndVehicleFactory, MiddleGradeBike>();
    }
}

如果我们运行测试,我们现在有六个通过测试(两个理论,每个理论有三个测试用例)。因此,在不更新消费者(T0)等级的情况下,我们能够添加一个新的汽车系列,即中端汽车和自行车;抽象工厂模式的奇妙之处令人赞叹!

结论

抽象工厂是一种优秀的模式,可以抽象出对象族的创建,隔离每个族及其具体实现,让消费者不知道(解耦)在运行时创建的族。

在下一章中我们将更多地讨论工厂;同时,让我们看看抽象工厂模式如何帮助我们遵循的固体原则:

  • S:每个混凝土工厂都有创建一个对象族的唯一责任。您可以将抽象工厂与其他创作模式相结合,例如原型构建器模式,以满足更复杂的创作需求。
  • O:用户开放扩展,关闭修改;正如我们在“扩展”示例中所做的那样,我们可以添加新族,而无需修改使用它的代码。
  • L:我们的目标是合成,因此不需要任何继承,隐式放弃了 LSP 的需要。如果在设计中使用抽象类,则需要密切关注创建新抽象工厂时可能出现的兼容性问题。
  • I:通过提取一个创建其他对象的抽象,它使该接口非常专注于一个任务,这与 ISP 一致,以最小的成本创建灵活性。
  • D:通过仅依赖于接口,消费者不知道其使用的具体类型。

单件设计模式

单例设计模式允许创建和重用类的单个实例。我们可以使用静态类来实现几乎相同的目标,但是使用静态类并不是所有的事情都是可行的。例如,不能使用静态类实现接口或将实例作为参数传递;不能传递静态类,只能直接使用它们。

在我看来,C#中的单例模式是一种反模式。除非我不能依赖依赖依赖注入,否则我看不出这种模式如何发挥作用。这就是说,这是一部经典之作,所以让我们从研究它开始,然后在下一章中讨论更好的替代方案。

以下是我们讨论这种模式的几个原因:

  • 在下一章中,它将转换为单例范围。
  • 在不知道它的情况下,您无法找到它,也无法尝试删除它——或者避免使用它。
  • 这是一个需要探索的简单模式,并导致其他模式,如环境语境模式。

目标

Singleton 模式将类的实例数限制为一个。然后,想法是随后重用相同的实例。单例封装了对象逻辑本身及其创建逻辑。例如,单例模式可以降低实例化内存占用大的对象的成本,因为它只实例化一次。

你能想出一个可靠的原则,在那里被打破吗?

设计

此设计模式简单明了,仅限于一个类。让我们从类图开始:

Figure 6.6 – Singleton pattern class diagram

图 6.6–单例模式类图

Singleton类由以下内容组成:

  • 保存其唯一实例的私有静态字段。

  • 创建或返回唯一实例的公共静态Create()方法。

  • A private constructor, so external code cannot instantiate it without passing by the Create method.

    笔记

    您可以将Create()方法命名为任何名称,甚至可以将其删除,我们将在下一个示例中看到。我们可以将其命名为GetInstance(),也可以是一个名为Instance的静态属性,或者具有任何其他相关名称。

现在,在代码中,它可以转换为以下内容:

public class MySingleton
{
    private static MySingleton _instance;
    private MySingleton() { }
    public static MySingleton Create()
    {
        if(_instance == default(MySingleton))
        {
            _instance = new MySingleton();
        }
        return _instance;
    }
}

我们可以在下面的单元测试中看到,MySingleton.Create()总是返回相同的实例:

public class MySingletonTest
{
    [Fact]
    public void Create_should_always_return_the_same_instance()
    {
        var first = MySingleton.Create();
        var second = MySingleton.Create();
        Assert.Same(first, second);
    }
}

瞧!我们有一个工作的单例模式,它非常简单——可能是我能想到的最简单的设计模式。

下面是引擎盖下发生的事情:

  1. 消费者第一次调用MySingleton.Create()时,会创建MySingleton的第一个实例。因为唯一的构造函数是private,所以只能从内部创建。您不能从类外部实例化MySingleton(使用new MySingleton(),因为没有公共构造函数。
  2. 然后将第一个实例保存到_instance字段以供将来使用。
  3. 当使用者第二次调用MySingleton.Create()时,它返回_instance字段,重用类的上一个(也是唯一一个)实例。

如果您希望您的单例是线程安全的,您可能希望lock实例创建,如下所示:

public class MySingletonWithLock
{
    private readonly static object _myLock = new object();
    private static MySingletonWithLock _instance;
    private MySingletonWithLock() { }
    public static MySingletonWithLock Create()
    {
        lock (_myLock)
        {
            if (_instance == default(MySingletonWithLock))
            {
                _instance = new MySingletonWithLock();
            }
        }
        return _instance;
    }
}

另一种(更好的)方式

以前,我们使用了实现单例模式的“漫长道路”,必须实现线程安全机制。现在,经典已经过去了。我们可以将其缩短以摆脱Create()方法,如下所示:

public class MySimpleSingleton
{
    public static MySimpleSingleton Instance { get; } = new MySimpleSingleton();
    private MySimpleSingleton() { }
}

这样,您就可以通过单例实例的Instance属性直接使用它,如下所示:

MySimpleSingleton.Instance.SomeOperation();

我们可以通过执行以下测试方法来证明该声明的正确性:

[Fact]
public void Create_should_always_return_the_same_instance()
{
    var first = MySimpleSingleton.Instance;
    var second = MySimpleSingleton.Instance;
    Assert.Same(first, second);
}

通过这样做,我们的单例变得线程安全,因为属性初始值设定项创建单例实例,而不是将其嵌套在if语句中。通常最好尽可能将职责委托给语言或框架。

当心箭头操作符

使用箭头操作符=>初始化Instance属性可能很有诱惑力,比如:public static MySimpleSingleton Instance => new MySimpleSingleton();,但这样做每次都会返回一个新实例。这将破坏我们想要实现的目标。另一方面,属性初始值设定项只运行一次。

使用静态构造函数也是一种有效的线程安全的替代方法,再次将任务委托给该语言。

代码气味:环境背景

单例模式的最后实现将我们引向环境上下文模式。我们甚至可以将环境上下文称为反模式,但我们只需声明它是一种相应的代码气味。

出于多种原因,我不喜欢周围的环境。首先,我尽我所能远离任何全球性的事物。Globals 一开始非常方便,因为它们易于使用。它们总是在那里,需要时随时可以使用:简单。然而,它们在灵活性和可测试性方面会带来许多缺点。

使用环境上下文时,会发生以下情况:

  • 系统很可能会变得不太灵活。全局对象更难替换,并且不能轻松地替换为另一个对象。并且实现不能基于其使用者而有所不同。

  • 全局对象更难模拟,这可能导致系统更难测试

  • 系统可能变得更脆;例如,如果系统的某个部分弄乱了全局对象,可能会对系统的其他部分产生意外的后果,并且您可能很难找到这些错误的根本原因。

  • Another thing that does not help is the lack of isolation since consumers are usually directly coupled with the ambient context. Not being able to isolate components from those global objects can be a hassle, as stated in the previous points.

    有趣的事实

    很多年前,在 JavaScript 框架时代之前,我修复了一个系统中的一个 bug,由于一个细微的错误,某个函数重写了undefined的值。这是一个很好的例子,说明了全局变量如何影响整个系统并使其更脆弱。C#中的环境上下文和单例模式也是如此;全球化可能是危险和令人讨厌的。

    请放心,现在浏览器不会让开发者更新undefined的值,但在当时,这是可能的。

现在我们已经讨论了 globals,a****mbient context是一个全局实例,通常通过静态属性提供。环境上下文模式并不是纯粹的邪恶,但它是一种代码气味,闻起来很难闻。在.NET Framework 中有一些示例,例如System.Threading.Thread.CurrentPrincipalSystem.Threading.Thread.CurrentThread。在最后一种情况下,CurrentThread是限定范围的,而不是像CurrentPrincipal那样纯粹是全局的。环境上下文不必是单例,但大多数情况下都是这样。创建有范围的环境上下文比较困难,超出了本书的范围。

环境上下文模式是好还是坏?我两个都要去!它之所以有用,主要是因为它的方便性和易用性,而它通常是全球性的。大多数时候,它可以而且应该设计得不同,以减少全球化带来的弊端。

实现环境上下文的方法有很多;它可以比一个简单的单例更复杂,并且可以针对另一个比单个全局实例更动态的范围。但是,为了保持简洁明了,我们只关注环境上下文的单例版本,如下所示:

public class MyAmbientContext
{
    public static MyAmbientContext Current { get; } = new MyAmbientContext();
    private MyAmbientContext() { }
    public void WriteSomething(string something)
    {
        Console.WriteLine($"This is your something: {something}");
    }
}

该代码与MySimpleSingleton类完全相同,但有一些细微的变化:

  • Instance名为Current
  • WriteSomething方法是新的,但与环境上下文模式本身无关;这只是为了让全班做点什么。

如果我们看一下下面的测试方法,我们可以看到我们通过调用MyAmbientContext.Current来使用环境上下文,就像我们对上一个单例实现所做的那样:

[Fact]
public void Should_echo_the_inputted_text_to_the_console()
{
    // Arrange (make the console write to a StringBuilder 
    // instead of the actual console)
    var expectedText = "This is your something: Hello World!" + Environment.NewLine;
    var sb = new StringBuilder();
    using (var writer = new StringWriter(sb))
    {
        Console.SetOut(writer);
        // Act
        MyAmbientContext.Current.WriteSomething("Hello World!");
    }
    // Assert
    var actualText = sb.ToString();
    Assert.Equal(expectedText, actualText);
}

该属性可以包括一个公共 setter(public static MyAmbientContext Current { get; set; }),并且可以支持更复杂的机制。和往常一样,构建正确的类来公开正确的行为取决于您和您的规范。

结束这段插曲:尝试避免环境上下文,而是使用实例类。在下一章中,我们将看到如何使用依赖项注入将单个实例替换为类的单个实例。这为我们提供了一个比单例模式更灵活的选择。

结论

Singleton 模式允许在程序的整个生命周期中创建一个类的单个实例。它利用private static字段和private构造函数实现其目标,通过public static方法或属性公开实例化。我们可以使用字段初始值设定项、Create方法本身、静态构造函数或任何其他有效的 C#选项来封装初始化逻辑。

现在让我们看看单例模式如何帮助我们(而不是)遵循坚实的原则:

  • S: The singleton violates this principle because it has two clear responsibilities:

    a) 它与任何其他类一样,承担着创建它所要承担的责任(这里没有说明)。

    b) 它有责任创建和管理自己(生命周期管理)。

  • O:单身模式也违反了这一原则。它强制执行单个静态实例,并由其自身锁定到位,这限制了可扩展性。必须修改该类以进行更新,如果不更改代码,则无法扩展该类。

  • L:没有直接涉及的继承,这是唯一的优点。

  • I:不涉及接口,违反此原则。

  • D:单身阶级有着坚如磐石的自我控制力。它还建议直接使用它的静态属性(或方法),而不使用抽象,用大锤打破倾斜。

正如您所看到的,除了 LSP 之外,Singleton 模式确实违反了所有坚实的原则,应该谨慎使用。一个类只有一个实例并且总是使用同一个实例是一个合理的概念。然而,我们将在下一章中看到如何正确地做到这一点,并将我引向以下建议:不要使用单例模式,如果您在某些地方看到它被使用,请尝试重构它。另一个好主意是尽可能避免使用static成员,因为它们会创建全局元素,从而降低系统的灵活性和脆弱性。在某些情况下,static成员是值得使用的,但请尽量减少其数量。在编码之前,问问自己static成员或类是否可以替换为其他成员或类。

有些人可能会争辩说,单例设计模式是一种合法的做事方式。然而,在 ASP.NETCore5 中,我不能同意他们的观点:我们有一个强大的机制来做不同的事情,叫做依赖注入。当使用其他技术时,可能是这样,但不能使用.NET。

总结

在本章中,我们探讨了我们的第一个 GoF 设计模式。这些模式揭示了软件工程的一些基本基础,不一定是模式本身,而是它们背后的概念:

  • 策略模式是一种行为模式,我们用它来组成未来的大多数课程。它允许在运行时交换行为,方法是使用较小的片段组成一个对象并根据接口编码,遵循可靠的原则。
  • 抽象工厂模式带来了抽象对象创建的思想,从而更好地分离关注点。更具体地说,它旨在抽象对象族的创建并遵循实体原则。
  • 即使我们将其定义为反模式,单例模式也会将应用级对象带到表中。它允许创建一个对象的单个实例,该实例在程序的整个生命周期内都有效。这种模式本身违反了最坚实的原则。

我们还了解了环境上下文代码的气味,它用于创建无处不在的实体。它通常作为单例实现,是一个全局对象,通常使用static修饰符定义。

在下一章中,我们将最终跳转到依赖注入,看看它如何帮助我们构建复杂但可维护的系统。我们还将回顾该策略、工厂和单例模式,以了解如何在面向依赖项注入的上下文中使用它们,以及它们的真正功能。

问题

让我们来看看几个练习题:

  1. 为什么战略模式是一种行为模式?
  2. 我们如何定义创造模式的目标?
  3. 如果我编写代码public MyType MyProp => new MyType();,并调用属性两次(var v1 = MyProp; var v2 = MyProp;,那么v1v2是同一个实例还是两个不同的实例?
  4. 抽象工厂模式允许我们在不修改现有代码的情况下添加新的元素族,这是真的吗?
  5. 为什么单身模式是反模式?

七、深入研究依赖注入

在本章中,我们将探讨 ASP.NET Core 5 依赖项注入系统,以及如何有效地利用它、它的限制和它的功能。我们还将介绍如何使用依赖项注入组合对象、控制反转的含义以及如何使用内置的依赖项注入容器。我们还将介绍依赖注入背后的概念。我们还将使用依赖项注入重新讨论前三个 GoF 设计模式。本章对您进入现代应用设计的旅程至关重要。

本章将介绍以下主题:

  • 什么是依赖注入?
  • 重新审视战略模式
  • 重温单身模式
  • 了解服务定位器模式
  • 重温工厂模式

什么是依赖注入?

依赖注入****DI是应用控制反转****IoC原理的一种方式。我们可以把 IoC 看作是依赖倒置原理的一个更广泛的版本(实数形式的 D)。

DI 背后的思想是将依赖项的创建从对象本身移动到程序的入口点(合成根)。通过这种方式,我们可以将依赖项的管理委托给一个 IoC 容器(也称为 DI 容器),该容器完成繁重的工作。

例如,对象A不应该知道它正在使用的对象BA应该使用B实现的接口I,并且B应该在运行时解析和注入。

让我们分解一下:

  • 对象A应该依赖于接口I而不是具体化B
  • 注入到A中的实例B应该在运行时由 IoC 容器解析。
  • A不应意识到B的存在。
  • A不应控制B的寿命。

为了全力打造乐高®,我们可以将 IoC 视为绘制一个城堡的计划:您绘制、制作或购买积木,然后按下开始按钮,积木按照您的计划自行组装。按照这一逻辑,您可以创建一个侧面画有独角兽的新 4x4 块,更新您的计划,然后按下重新启动按钮,用插入的新块重建城堡,替换旧块,而不会影响城堡的结构完整性。通过遵守 4x4 区块合同,所有内容都应该是可更新的,而不会影响城堡的其他部分。

按照这个想法,如果我们需要一个接一个地管理每个乐高积木,它会变得非常复杂,非常快!因此,在一个项目中手动管理所有依赖项将是非常繁琐和容易出错的,即使是在最小的程序中也是如此。为了帮助我们解决这个问题,IoC 容器开始发挥作用。

笔记

DI 容器或 IoC 容器是同一件事——它们只是人们使用的不同词汇。在现实生活中,我可以交替使用这两种方法,但我会尽最大努力坚持在本书中使用 IoC 容器。

我选择术语“IoC 容器”是因为它似乎比“DI 容器”更准确。IoC 是概念(原则),而 DI 是一种反转控制流的方法(应用 IoC)。例如,您可以通过使用容器在运行时注入依赖项(执行 DI)来应用 IoC 原则(反转流)。

IoC 容器的作用是为您管理对象。您配置它们,然后当您请求一些抽象时,相关的实现由容器解决。此外,依赖项的生存期也由容器管理,让您的类只做一件事:设计它们的工作,而不考虑它们的依赖项、实现或生存期!

归根结底,IoC 容器是一个 DI 框架,它为您进行自动布线。我们可以看到依赖注入如下所示:

  1. 依赖项的使用者陈述其对一个或多个依赖项的需求。
  2. IoC 容器在创建使用者时注入该依赖关系(实现),在运行时满足其需求。

接下来,我们将探索不同的 DI 领域:在哪里配置容器、可用的选项,以及一种通用的面向对象技术(现在是一种代码味道)。

成分根

DI 背后的第一个概念之一是合成根。组合根是您告诉容器您的依赖关系的地方:您组合依赖关系树的地方。合成根应尽可能接近程序的起点。

在 ASP.NET Core 5 中,它位于Program.csStartup.cs或两者中。

笔记

作为 LEGO®的类比,构图根可以是您绘制计划的纸页。

ASP.NET Core 5 应用的起点是Program类。在这里,您可以使用IHostBuilder接口上提供的ConfigureServices扩展方法来配置您的服务,如下所示:

public class Program
{
    public static void Main(string[] args)
    {
        CreateHostBuilder(args).Build().Run();
    }
    public static IHostBuilder CreateHostBuilder(string[] args) =>
        Host.CreateDefaultBuilder(args)
            .ConfigureServices(services =>
            {
                // This could be the composition root
            })
            .ConfigureWebHostDefaults(webBuilder =>
            {
                webBuilder.UseStartup<Startup>();
            });
}

使用IHostBuilder配置服务在某些场景中非常有用,包括配置测试主机或小型微服务。

但是,您通常希望在Program类中构建主机并运行程序,同时将 ASP.NET Core 5 的组成委托给Startup类,后者是距离入口点第二近的位置。该类通常在创建新项目时为您生成。也就是说,这两个位置都是有效的合成根:

public class Startup
{
    public void ConfigureServices(IServiceCollection services)
    {
        // This could be the composition root 
    }
    // ...
}

Startup类是大多数应用的合成根,您最有可能在这里配置 DI 容器和 ASP.NET Core 5 管道。也可以同时使用这两种方法,如下所示:

Host.CreateDefaultBuilder(args)
    .ConfigureServices(services =>
    {
        services.AddSingleton<Dependency1>();
    })
    .ConfigureWebHostDefaults(webBuilder =>
    {
        webBuilder.UseStartup<Startup>();
    });
//...
public void ConfigureServices(IServiceCollection services)
{
    services.AddSingleton<Dependency2>();
    services.AddSingleton<Dependency3>();
}

笔记

Startup类中,您可以为每个环境(DevelopmentStagingProduction创建特定的方法来有效地组合应用。您甚至可以创建多个启动类。有关此强大功能的更多信息,请参阅官方文档。

务必记住,您的程序的合成应该在合成根目录中完成。这样就不需要在你的代码库中散布那些讨厌的new关键字,也不需要承担它们带来的所有责任。它将应用的组成集中到该位置(即,创建组装 LEGO®积木的计划)。

扩展 IServiceCollection

正如我们刚才看到的,您应该在组合根目录中注册依赖项,但您仍然可以组织您的注册代码。例如,您可以将应用的合成拆分为多个方法或类,然后从合成根调用它们。您还可以使用自动发现机制来自动注册某些服务;我们将在后面的章节中使用这样做的包。

笔记

关键部分仍然是集中程序组成。

例如,ASP.NET Core 5 和其他流行库的大多数功能都提供了一个或多个Add[Feature name]()扩展方法来管理其依赖项的注册,允许您通过一个方法调用注册“依赖项包”。这对于将程序组合组织成更小、更具凝聚力的单元(如按功能)非常有用。

旁注

只要特性保持内聚性,它就具有适当的大小。如果您的功能变得太大,做了太多的事情,或者开始与其他功能共享依赖项,那么在失去对它的控制之前,可能是重新设计的时候了。这通常是非期望耦合的良好指示器。

通过使用扩展方法,它相当容易实现。根据经验,您应该执行以下操作:

  1. 创建一个名为[subject]Extensions的静态类。
  2. 根据微软的建议,在Microsoft.Extensions.DependencyInjection名称空间中创建该类(与IServiceCollection相同)。
  3. 从那里,创建您的IServiceCollection扩展方法。除非您需要归还其他物品,请务必归还扩展的IServiceCollection;这允许链接方法调用。

例如,如果我的特性被命名为Demo Feature,我会编写以下扩展方法:

using CompositionRoot.DemoFeature;
namespace Microsoft.Extensions.DependencyInjection
{
    public static class DemoFeatureExtensions
    {
        public static IServiceCollection AddDemoFeature(this IServiceCollection services)
        {
            return services
                .AddSingleton<MyFeature>()
                .AddSingleton<IMyFeatureDependency, MyFeatureDependency>()
            ;
        }
    }
}

然后,要使用它,我们可以在合成根中输入以下内容:

public void ConfigureServices(IServiceCollection services)
{
    services.AddDemoFeature();
}

如果您不熟悉扩展方法,它们可以方便地扩展现有代码(我们刚刚做的)。例如,您可以构建一个复杂的库和一组易于使用的扩展方法,允许消费者轻松地学习和使用您的库,同时最大限度地保留高级选项和定制机会;想想 ASP.NET Core 5 MVC 或System.Linq

对象生命周期

我已经谈过几次了:没有了new;这段时间结束了!从现在起,DI 容器应该为我们完成与实例化和管理对象相关的大部分工作。

然而,在尝试这一点之前,我们需要讨论最后一件事:对象生存期。当您使用new关键字手动创建实例时,您将在该对象上创建一个保持;你知道什么时候创造它们,什么时候毁灭它们。这样就没有机会从外部控制这些对象,增强它们,拦截它们,或者将它们替换为另一个实现。这被称为控制怪胎反模式或代码气味,在代码气味:控制怪胎一节中解释。

在使用 DI 时,您需要忘记控制对象,开始考虑使用依赖项——更明确地说,使用它们的接口。在 ASP.NET Core 5 中,有三种可能的生存期供选择:

从现在起,我们将使用这三个作用域中的一个来管理大多数对象。以下是一些问题,可帮助您选择:

  • 我需要依赖项的单个实例吗?对使用单子寿命。
  • 我是否需要通过 HTTP 请求共享依赖项的单个实例?对使用在范围内的寿命。
  • 我每次都需要一个新的依赖实例吗?对使用瞬态寿命。

如果您需要更复杂的生存期,您可能需要将内置容器交换到第三方容器(请参见使用外部 IoC 容器部分)或在组合根目录中手动创建依赖关系树。

笔记

一种更通用的对象生存期方法是将组件设计为单例。如果你不能,那就选择范围内的。如果范围内的也不可能,则选择瞬态。通过这种方式,可以最大限度地重用实例,降低创建对象的开销,降低将这些对象保留在内存中的内存成本,并降低删除未使用实例所需的垃圾收集量。

例如,长寿命的对象每隔一段时间只被垃圾收集器检查一次,而长寿命的对象通常被扫描和处理。

前面的三个示例有多种变体,但生命周期仍然存在。我们在整本书中使用内置容器及其许多注册方法,因此您应该在最后熟悉它。该系统提供了良好的可发现性,因此您可以使用 IntelliSense 或通过阅读文档来探索各种可能性。

代码气味:控制狂

我们已经说过使用new关键字是一种代码气味甚至是反模式。但是暂时不要禁止new关键字。相反,每次使用它时,都要问问自己,使用new关键字实例化的对象是否是可以由容器管理并注入的依赖项。为了帮助解决这个问题,我从 MarkSeemann 的书中借用了两个术语,即.NET中的依赖注入;名字控制狂也来自那本书。他描述了以下两类依赖关系:

  • 稳定依赖
  • 易失性依赖

稳定依赖项是依赖项,当发布新版本的应用时,它不应破坏应用。他们应该使用确定性算法(输入 X 应该总是产生输出 Y;也就是说,关于 LSP),并且你不应该期望在将来用其他东西来改变它们。我要说的是,大多数数据结构都可以归入这一类:DTO、ViewModels、List<T>等等。当对象属于此类别时,仍然可以使用new关键字实例化对象;这是可以接受的,因为他们不太可能打破任何东西,也不会改变。但是要小心,因为预见依赖关系是否可能改变是非常困难的,甚至是不可能的,因为我们无法确定未来会提供什么。例如,作为.NET 5 一部分的元素可以被视为稳定的依赖项。

易失性依赖项是可以更改的依赖项、可以交换的行为或您可能想要扩展的元素。基本上,您为程序创建的大多数类,如数据访问和业务逻辑类。这些是您不应该再使用new关键字实例化的依赖项。打破实现之间紧密耦合的主要方法是依赖接口。

结束这段插曲:不要再做一个控制狂了,那些日子已经过去了!

提示

当有疑问时,注入依赖项,而不是使用new关键字。

接下来,我们将简要地探讨 ASP.NET Core 扩展点,然后再回顾三种设计模式,但这次是通过依赖注入。

使用外部 IoC 容器

ASP.NET Core 5 提供了一个现成的可扩展内置 IoC 容器。它不是最强大的 IoC 容器,因为它缺少一些高级功能,但它可以为大多数应用完成这项工作。放心吧,如果没有,你可以换另一个。如果您习惯于使用另一个 IoC 容器,并且希望坚持使用它,或者需要缺少高级功能,那么您可能会希望这样做。

从今天起,微软建议首先使用内置容器。如果您不能提前知道您将需要的所有 DI 功能,我将采用以下策略:

  1. 使用内置容器。
  2. 当无法使用它时,请查看您的设计,看看是否可以重新设计功能以使用内置容器。这有助于简化设计,同时有助于长期维护软件。
  3. 如果无法实现您的目标,则将其替换为另一个 IoC 容器。

假设容器支持它,交换就非常简单。您需要更新Startup类的ConfigureServices方法返回IServiceProvider(而不是void,如下所示:

public IServiceProvider ConfigureServices(IServiceCollection services)
{
    // Build and return the custom IServiceProvider here
}

然后,在方法内部,可以构建服务提供者,编写应用,然后返回它。正如我所感觉到的,您还不想实现自己的 IoC 容器(甚至永远都不想实现),不用担心,已经存在多种类型的第三方集成。以下是一个非详尽的列表:

  • 自动传真
  • 德赖奥
  • 优美
  • 光注入
  • 拉马尔
  • 储藏箱
  • 团结一致

一些库扩展默认容器并向其添加功能,这是我们在第 9 章结构模式中探讨的一个选项。

接下来,我们将重新讨论 Strategy 模式,它将成为组成应用和增加系统灵活性的主要工具。

重新审视战略模式

在本节中,我们将利用策略模式组合复杂的对象树,并使用 DI 动态创建这些实例,而无需使用new关键字,从而摆脱控制怪胎,转而编写 DI 就绪代码。

Strategy 模式是一种行为设计模式,我们可以在运行时使用它来组合对象树,从而允许额外的灵活性和对对象行为的控制。使用 Strategy 模式组合对象应该使类更易于测试和维护,并使我们走上一条坚实的道路。

从现在起,我们希望组合对象并将继承量降至最低。我们称之为原则组合优于继承。目标是将依赖项(组合)注入到当前类中,而不是依赖于基类特性(继承)。此外,这允许在外部类(SRP/ISP)中提取行为,然后通过接口(DIP)在一个或多个其他类(组合)中重用。

以下列表介绍了将依赖项注入对象的最常用方法:

  • 构造函数注入
  • 属性注入
  • 方法注入

我们还可以直接要求容器解析依赖关系,即服务定位器(反)模式。我们将在本章后面探讨服务定位器模式。

让我们看看一些理论,然后跳转到代码中,看看 DI 在起作用。

构造函数注入

构造函数注入包括将依赖项注入构造函数,作为参数。这是迄今为止最流行和最受欢迎的技术。构造函数注入有助于注入所需依赖项;您可以添加空检查以确保,也被称为保护条款(请参阅向 HomeController添加保护条款一节)。

财产注入

内置 IoC 容器不支持属性注入开箱即用。其概念是将可选依赖项注入属性。大多数情况下,您希望避免这样做,因此 ASP.NETCore 将此项从内置容器中删除也不错。您通常可以通过稍微修改设计来删除属性注入需求,从而获得更好的设计。如果无法避免使用属性注入,则必须使用第三方容器。

然而,从高层次的角度来看,容器将执行以下操作:

  1. 创建类的新实例,并将所有必需的依赖项注入构造函数。
  2. 通过扫描属性(可以是属性、上下文绑定或其他内容)查找扩展点。
  3. 对于每个扩展点,注入(设置)一个依赖项,保持未配置的属性不变,从而定义可选依赖项。

对于之前关于缺乏支持的声明,有几个例外:

  • Razor 组件(Blazor)通过使用[Inject]属性支持属性注入。
  • Razor 包含@inject指令,该指令生成一个属性来保存依赖项(ASP.NET Core 管理注入依赖项)。

我们不能称之为属性注入本身,因为它们不是可选的,而是必需的,@inject指令更多的是生成代码,而不是执行 DI。它们更多的是内部解决方案,而不是“不动产”注入。也就是说,这与.NET5 的属性注入非常接近。

提示

我建议以构造函数注入为目标。没有财产注入应该不会给你带来任何问题。

方法注射

ASP.NET Core 仅在少数位置支持方法注入,如控制器的动作(方法)Startup类、中间件的InvokeInvokeAsync方法。如果您不做一些工作,就不能在类中自由地使用方法注入。

方法注入还用于将可选依赖项注入类中。我们还可以在运行时使用空检查或任何其他必需的逻辑来验证它们。

提示

我建议尽可能瞄准构造函数注入。只有当方法注入是唯一的方法或者它添加了一些东西时,才依赖它。例如,在控制器中,在唯一需要临时服务的操作(而不是构造函数)中注入临时服务可以节省大量无用的对象实例化,并通过这样做提高性能(更少的实例化和更少的垃圾收集)。

项目:战略

在 Strategy 项目中,我们使用策略模式和构造函数注入向HomeController类添加(组合)一个IHomeService依赖项。

目标是将类型为IHomeService的依赖项注入HomeController类。然后将视图模型发送到视图以呈现页面。

服务是这样的:

namespace Strategy.Services
{
    public interface IHomeService
    {
        IEnumerable<string> GetHomePageData();
    }
    public class HomeService : IHomeService
    {
        public IEnumerable<string> GetHomePageData()
        {
            yield return "Lorem";
            yield return "ipsum";
            yield return "dolor";
            yield return "sit";
            yield return "amet";
        }
    }
}

IHomeService接口是我们希望HomeController类具有的依赖项。HomeService类是我们在实例化HomeController时想要注入的实现,它反转了依赖流。

为此,我们使用构造函数注入将注入到控制器中。在文本上,我们做了以下工作:

  1. HomeController类中创建一个私有IHomeService字段。
  2. 创建一个参数类型为IHomeServiceHomeController构造函数。
  3. 将参数指定给字段。

在代码中,它如下所示:

using Strategy.Services;
namespace Strategy.Controllers
{
    public class HomeController : Controller
    {
        private readonly IHomeService _homeService;
        public HomeController(IHomeService homeService)
        {
            _homeService = homeService;
        }
        // ...
    }
}

使用private readonly字段有两个好处:

  • 它们是private,因此您不会将依赖项暴露在类之外(封装)。
  • 它们是readonly,所以只能设置一次。在构造函数注入的情况下,这确保了由private字段引用的注入依赖项不会被类的其他部分更改。

如果我们现在运行应用,我们会得到以下错误:

InvalidOperationException: Unable to resolve service for type 'Strategy.Services.IHomeService' while attempting to activate 'Strategy.Controllers.HomeController'.

这个错误告诉我们,我们忘记了一些重要的事情:告诉容器依赖关系。

为此,我们需要将IHomeService的注入映射到HomeService的实例。由于类的性质,我们可以安全地使用单例生存期(一个实例)。使用提供的扩展方法,在 composition 根目录中,我们只需要添加以下行:

public void ConfigureServices(IServiceCollection services)
{
    services.AddSingleton<IHomeService, HomeService>();
    //...
}

现在,如果我们重新运行应用,主页应该加载。这告诉 ASP.NET 在类依赖于IHomeService接口时注入HomeService实例。

我们刚刚使用 ASP.NET Core 5 完成了构造函数注入的第一个实现——就这么简单。

要回顾构造函数注入,我们需要执行以下操作:

  1. 创建依赖项及其接口。

  2. 通过构造函数将该依赖项注入到另一个类中。

  3. Create a binding that tells the container how to handle the dependency.

    笔记

    我们也可以直接注入类,但在您掌握了坚实的原则之前,我建议您坚持注入接口。

添加视图模型

现在我们已经注入了包含要在HomeController类中显示的数据的服务,我们需要显示它。为了实现这一点,我们决定使用视图模型模式。视图模型的目标是创建以视图为中心的模型,然后使用该模型渲染该视图。

以下是我们需要做的:

  1. 创建一个视图模型类(HomePageViewModel
  2. 更新Home/Index视图以使用视图模型并显示其包含的信息。
  3. 创建HomePageViewModel实例并从控制器发送到视图。

HomePageViewModel类公开了SomeData属性,并期望在实例化时注入数据;代码如下所示:

namespace Strategy.Models
{
    public class HomePageViewModel
    {
        public IEnumerable<string> SomeData { get; }
        public HomePageViewModel(IEnumerable<string> someData)
        {
            SomeData = someData;
        }
    }
}

这是构造函数注入的另一个例子。

然后,经过几次更新,Views/Home/Index.cshtml视图如下所示:

@model HomePageViewModel
@{
    ViewData["Title"] = "Home Page";
}
<div class="text-center">
    <h1 class="display-4">Welcome</h1>
    <p>Here are your data:</p>
    <ul class="list-group">
    @foreach (var item in Model.SomeData)
    {
        <li class="list-group-item">@item</li>
    }
    </ul>
</div>

现在我们需要将HomePageViewModel的一个实例传递给视图。我们在Index行动中就是这样做的:

public IActionResult Index()
{
    var data = _homeService.GetHomePageData();
    var viewModel = new HomePageViewModel(data);
    return View(viewModel);
}

在该代码中,我们使用_homeService字段通过IHomeService接口检索数据。重要的是要注意,在这一点上,控制器不知道实现;它仅取决于合同(接口)。然后我们使用该数据创建HomePageViewModel类。最后,我们将HomePageViewModel的实例发送到视图进行渲染。

笔记

您可能已经注意到,我在这里使用了new关键字。在这种情况下,我发现在控制器的操作中实例化视图模型是可以接受的。然而,我们可以使用方法注入或任何其他技术来帮助创建对象,例如工厂。

向 HomeController 添加保护子句

我们已经说明了构造函数注入是可靠的,用于注入所需的依赖项。然而,上一个代码示例中有一件事让我感到困扰:没有任何东西可以保证homeService不是空的。

我们可以在Index方法中检查空值,如下所示:

public IActionResult Index()
{
    var data = _homeService?.GetHomePageData();
    var viewModel = new HomePageViewModel(data);
    return View(viewModel);
}

但随着控制器的增长,我们可能会在多个位置多次为该依赖项编写空检查。然后我们应该在视图中执行相同的空检查。否则,我们将循环一个null值,这是不好的。

为了避免逻辑的重复,以及同时可能出现的错误数量,我们可以添加一个保护子句

guard 子句的作用正如其名称所暗示的那样:它保护无效值。大多数情况下,它防止空。当您将一个null依赖项传递给一个对象时,测试该参数的 guard 子句应该抛出一个ArgumentNullException

通过使用 C#7 中的throw表达式,我们可以简单地写下:

public HomeController(IHomeService homeService)
{
    _homeService = homeService ?? throw new ArgumentNullException(nameof(homeService));
}

homeServicenull时抛出ArgumentNullException;否则,将homeService参数值赋给_homeService字段。

重要提示

如果内置容器在类的实例化过程中能够满足所有依赖项,则会自动抛出异常(如HomeController。也就是说,这并不意味着所有第三方容器的行为都是一样的。此外,这并不能防止您将null传递给手动实例化的实例(即使我们应该使用 DI,也不意味着它不会发生)。作为一个优先事项,我喜欢添加它们,但它们不是必需的。

抛出表达式(C#7)

这个特性允许我们将throw语句用作表达式,从而使我们能够在 null 合并运算符的右侧抛出异常。

throw表达式之前,写 guard 子句的好方法如下:

public HomeController(IHomeService homeService)
{
    if (homeService == null) 
    { 
        throw new ArgumentNullException (nameof(homeService));
    }
    _homeService = homeService;
}

首先,我们检查null,如果homeServicenull,我们抛出一个ArgumentNullException;否则,我们将值指定给字段。

现在,使用throw表达式,我们可以编写前面的代码,这里再次概述:

public HomeController(IHomeService homeService)
{
    _homeService = homeService ?? throw new ArgumentNullException(nameof(homeService));
}

??运算符是一个二进制运算符。在result = left ?? right块中,??操作符表示如果left值为null,则使用right值。如果left值为not null,则使用left值。

过去,我们不能从右侧抛出异常(它是一个语句),但现在我们可以(它是一个表达式)。

在其他情况下,如果您还不熟悉空合并运算符,我们也可以使用??如下:

public string ValueOrDefault(string value, string defaultValue)
{
    return value ?? defaultValue;
}

valuenull时,该方法返回defaultValue;否则返回value——非常方便。

向 HomePageViewModel 添加保护子句

现在让我们在HomePageViewModel类中添加一个 guard 子句作为:

public HomePageViewModel(IEnumerable<string> someData)
{
    SomeData = someData ?? throw new
 ArgumentNullException(nameof(someData));
}

瞧!我们现在拥有了呈现主页所需的一切。更重要的是,我们在不直接将HomeController类与HomeService耦合的情况下实现了这一点。相反,我们只依赖于IHomeService接口,一个合同。通过将合成集中到合成根目录中,我们可以通过交换Startup类中的IHomeService实现来更改生成的主页,而不会影响控制器或视图。

我邀请您创建另一个实现了IHomeService的类,并将Startup类中的映射从HomeService更改为新的类,看看更改主页列表有多容易。更进一步说,您可以将您的实现连接到数据库、Azure 表、Redis、JSON 文件或您可以想到的任何其他数据源。

接下来,我们将重新讨论一个现在是反模式的设计模式,同时探索取代它的单例生命周期。

重温单身模式

单身模式已经过时,违背了坚实的原则,我们用一生来取代它,正如我们已经看到的那样。本节将探讨该生存期并重新创建良好的旧应用状态,它只不过是一个单例范围的字典。

这里我们将探讨两个例子;一个是关于应用状态的,以防您想知道该功能消失在哪里。然后,Wishlist 项目还使用单例生存期来提供应用级功能。还有一些单元测试可以利用可测试性并允许安全重构。

申请状态

如果您使用.NET Framework 编写了 ASP.NET,或者使用 VBScript 编写了“好”的经典 ASP,您可能还记得应用的状态。如果没有,则应用状态是一个键/值字典,允许您在应用中全局存储数据,并在所有会话和请求之间共享。这是 ASP 一直拥有的东西之一,而其他语言,如 PHP,没有(或不容易允许)。

例如,我记得用经典的 ASP/VBScript 设计了一个通用的可重用类型的购物车系统。VBScript 不是强类型语言,面向对象的能力有限。购物车字段和类型是在应用级别定义的(每个应用一次),然后每个用户都有自己的“实例”,其中包含其“私人购物车”中的产品(每个会话创建一次)。

在 ASP.NET Core 5 中,没有更多的Application字典。为了实现相同的目标,可以使用静态类或静态成员,这不是最好的方法;请记住,全局对象(static使您的应用更难测试,灵活性也更低。我们还可以使用单例模式或创建环境上下文,这将允许我们创建对象的应用级实例。我们甚至可以将其与工厂混合,以创建最终用户购物车,但我们不会;这些也不是最好的解决方案。

另一种方法是使用 ASP.NET Core 5 缓存机制之一,内存缓存或分布式缓存,但这是一种延伸。

我们也可以保存数据库中的所有内容,以便在访问之间持久保存购物车,但这与应用状态无关,需要用户帐户,因此我们也不会这样做。

我们可以使用 cookies、本地存储或任何其他现代机制在客户端保存购物车,以将数据保存到用户的计算机上。然而,与使用数据库相比,我们可以从应用状态中获得更多信息。

对于大多数需要类似于应用状态的特性的情况,最好的方法是创建一个标准类和一个接口,然后在容器中以单例生存期注册绑定。最后,使用构造函数注入将它注入到需要它的组件中。这样做可以模拟依赖项并更改实现,而不必涉及代码,只需涉及组合根。

笔记

有时,最好的解决方案不是技术复杂的解决方案或面向设计模式的解决方案;最好的解决方案往往是最简单的。更少的代码意味着更少的维护和测试,从而使应用更简单。

项目:应用状态

让我们实现一个模拟应用状态的小程序。API 是一个具有两个实现的单一接口。该程序还通过 HTTP 公开部分 API,允许用户获取或设置与指定密钥关联的值。我们使用单例生存期来确保数据在所有请求之间共享。

界面如下所示:

public interface IApplicationState
{
    TItem Get<TItem>(string key);
    bool Has<TItem>(string key);
    void Set<TItem>(string key, TItem value);
}

我们可以获取与键关联的值,将值与键(set)关联,并验证键是否存在。

Startup类包含负责处理 HTTP 请求的代码。它不是使用 MVC,而是使用原始请求管理。我们对所有这些代码都不感兴趣,但如果您想尝试它,我邀请您运行它。这两个实现可以通过注释或取消注释Startup.cs文件的第一行#define USE_MEMORY_CACHE来交换,这改变了ConfigureServices方法中编译代码的方式:

    public void ConfigureServices(IServiceCollection services)
    {
#if USE_MEMORY_CACHE
        services.AddMemoryCache();
        services.AddSingleton<IApplicationState, ApplicationMemoryCache>();
#else
        services.AddSingleton<IApplicationState, ApplicationDictionary>();
#endif
    }

第一个实现使用内存缓存系统。首先,我觉得向你们展示这一点很有教育意义。第二,我们将缓存系统隐藏在我们的实现后面,这也是有教育意义的。最后,我们需要两个实现,使用缓存系统是一个非常简单的实现。

以下是ApplicationMemoryCache课程:

public class ApplicationMemoryCache : IApplicationState
{
    private readonly IMemoryCache _memoryCache;
    public ApplicationMemoryCache(IMemoryCache memoryCache)
    {
        _memoryCache = memoryCache ?? throw new ArgumentNullException(nameof(memoryCache));
    }
    public TItem Get<TItem>(string key)
    {
        return _memoryCache.Get<TItem>(key);
    }
    public bool Has<TItem>(string key)
    {
        return _memoryCache.TryGetValue<TItem>(key, out _);
    }
    public void Set<TItem>(string key, TItem value)
    {
        _memoryCache.Set(key, value);
    }
}

笔记

ApplicationMemoryCache类是IMemoryCache之上的一个薄层,隐藏了实现细节。这种类型的类称为 façade。我们将在第 9 章结构模式中详细介绍立面设计模式。

第二个实现是使用Dictionary<string, object>存储应用状态数据。ApplicationDictionary类几乎和ApplicationMemoryCache一样简单:

public class ApplicationDictionary : IApplicationState
{
    private readonly Dictionary<string, object> _memoryCache = new Dictionary<string, object>();
    public TItem Get<TItem>(string key)
    {
        if (!Has<TItem>(key))
        {
            return default;
        }
        return (TItem)_memoryCache[key];
    }
    public bool Has<TItem>(string key)
    {
        return _memoryCache.ContainsKey(key) && _memoryCache[key] is TItem;
    }
    public void Set<TItem>(string key, TItem value)
    {
        _memoryCache[key] = value;
    }
}

我们现在可以使用两个实现中的任何一个,而不会影响程序的其余部分。这证明了 DI 在依赖关系管理方面的优势。此外,我们从组合根控制依赖项的生存期。

如果我们将IApplicationState接口用于另一个类,比如SomeConsumer,其用法可能类似于以下内容:

namespace ApplicationState
{
    public class SomeConsumer
    {
        private readonly IApplicationState _myApplicationWideService;
        public SomeConsumer(IApplicationState myApplicationWideService)
        {
            _myApplicationWideService = myApplicationWideService ?? throw new ArgumentNullException(nameof(myApplicationWideService));
        }
        public void Execute()
        {
            if (_myApplicationWideService.Has <string>("some-key"))
            {
                var someValue = _myApplicationWideService.Get <string>("some-key");
                // Do something with someValue
            }
            // Do something else like:
            _myApplicationWideService.Set("some-key", "some-value");
            // More logic here
        }
    }
}

在该代码中,SomeConsumer仅依赖于IApplicationState接口,而不依赖于IMemoryCacheDictionary<string, object>。使用 DI 允许我们通过反转依赖项的控制来隐藏实现。它还打破了混凝土之间的直接耦合,针对接口编程(DIP)。

下面是一个图表,说明了我们的应用状态系统,使我们更容易在视觉上注意到它是如何打破耦合的:

Figure 7.1 – DI-oriented diagram representing the application state system

图 7.1–表示应用状态系统的面向 DI 的图

从这个示例中,让我们记住,单例生存期允许我们在请求之间重用对象,并在应用范围内共享它们。此外,在接口后面隐藏实现细节可以提高设计的灵活性。

默认文字表达式(C#7.1)

在上一个示例中,我们以 C#7.1 的新方式使用了default操作符。default运算符允许我们将变量初始化为其默认值,通常为null。在过去,我们需要将一个参数传递给默认运算符,如:int someVariable = default(int);,它将相当于int someVariable = 0;,因为0int的默认值。

从 C#7.1 开始,我们可以使用默认的文字表达式,这允许我们执行以下操作:

  • 将变量初始化为其默认值。
  • 设置可选方法参数的默认值。
  • 为方法调用提供默认参数值。
  • return语句或表达式体成员(C#6 和 7 中引入的箭头=>运算符)中返回默认值。

下面是一个涵盖这些用例的示例:

public class DefaultLiteralExpression<T>
{
    public void Main()
    {
        // Initialize a variable to its default value
        T myVariable = default;
        var defaultResult1 = SomeMethod();
        // Provide a default argument value to a method call
        var defaultResult2 = SomeOtherMethod(myVariable, default);
    }
    // Set the default value of an optional method parameter
    public object SomeMethod(T input = default)
    {
        // Return a default value in a return statement 
        return default;
    }
    // Return a default value in an expression-bodied member
    public object SomeOtherMethod(T input, int i) => default;
}

我们在示例中使用了泛型T类型参数,但它可以是任何类型。对于复杂的泛型类型,例如Func<T>Func<T1, T2>或元组,默认的文本表达式变得非常方便。

由于我们将在下一个代码示例后讨论元组,因此我们将不深入讨论元组的更多细节,但下面是一个很好的示例,说明使用默认文字表达式返回元组并默认其三个组件是多么简单:

public (object, string, bool) MethodThatReturnATuple()
{
    return default;
}

项目:愿望清单

让我们进入另一个示例来说明单例生存期和依赖注入的使用。看到 DI 的运行应该有助于理解它,然后利用它来创建可靠的软件。

上下文:该应用是一个站点范围的愿望列表,用户可以在其中添加项目。项目应每 30 秒过期一次。当用户添加现有项目时,系统应增加计数并重置项目的过期时间。这样一来,热门商品在榜单上停留的时间就更长了,从而名列前茅。系统显示时应按计数(最高优先)对项目进行排序。

笔记

30 秒是非常快的,但我确信在运行应用时,您不希望在项目过期之前等待数天。

该程序是一个小型 web API,公开了两个端点:

  • 将项目添加到愿望列表中(POST
  • 阅读愿望清单(GET

愿望列表应该在 singleton 范围内,因此所有用户共享同一个实例。为了简单起见,愿望列表只存储项目的名称。

我们的愿望列表界面如下所示:

public interface IWishList
{
    Task<WishListItem> AddOrRefreshAsync(string itemName);
    Task<IEnumerable<WishListItem>> AllAsync();
}
public class WishListItem
{
    public int Count { get; set; }
    public DateTimeOffset Expiration { get; set; }
    public string Name { get; set; }
}

我们有两个操作,通过使它们异步(返回一个Task<T>),我们可以实现另一个依赖远程系统的版本,例如数据库,而不是内存存储。

笔记

试图预见未来通常不是一个好主意,但设计可等待的 API 通常是一个安全的选择。除此之外,我建议您遵守规范和用例。当您试图解决尚不存在的问题时,通常会编写大量无用的代码,导致额外的不必要的维护和测试时间。

WishListItem类为IWishList合同的部分;这就是模型。

最后一个管道是WishListController,它处理 HTTP 请求:

[Route("/")]
public class WishListController : ControllerBase
{
    private readonly IWishList _wishList;
    public WishListController(IWishList wishList)
    {
        _wishList = wishList ?? throw new ArgumentNullException(nameof(wishList));
    }
    [HttpGet]
    public async Task<IActionResult> GetAsync()
    {
        var items = await _wishList.AllAsync();
        return Ok(items);
    }
    [HttpPost]
    public async Task<IActionResult> PostAsync([FromBody,Required]CreateItem newItem)
    {
        if (!ModelState.IsValid)
        {
            return BadRequest(ModelState);
        }
        var item = await _wishList.AddOrRefreshAsync(newItem.Name);
        return Created("/", item);
    }
    public class CreateItem
    {
        [Required]
        public string Name { get; set; }
    }
}

控制器主要将逻辑委托给注入的IWishList实现。它还验证了定义为嵌套类的POST模型,以保持其简单性。

为了帮助我们实现类InMemoryWishList类,我们开始编写一些测试来支持我们的规范。由于在测试中很难配置静态成员(还记得 globals 吗?),我们从 ASP.NET Core 内存缓存中借用了一个概念,并创建了一个ISystemClock接口,将静态调用抽象为DateTimeOffset.UtcNow。这样,我们可以在测试中编程UtcNow的值,以创建过期项目。

单元测试文件相当长,因此下面是大纲:

namespace Wishlist
{
    public class InMemoryWishListTest
    {
        // ...
        public class AddOrRefreshAsync : InMemoryWishListTest
        {
            [Fact]
            public async Task Should_create_new_item() { /* ... */ }
            [Fact]
            public async Task Should_increment_Count_of_an_existing_item() { /* ... */ }
            [Fact]
            public async Task Should_set_the_new_Expiration_date_of_an_existing_item() { /* ... */ }
            [Fact]
            public async Task Should_set_the_Count
            _of_expired_items_to_1() { /* ... */ }
            [Fact]
            public async Task Should_remove_expired_items() { /* ... */ }
        }
        public class AllAsync : InMemoryWishListTest
        {
            [Fact]
            public async Task Should_return_items_ordered_by_Count_Descending() { /* ... */ }
            [Fact]
            public async Task Should_not_return_expired_items() { /* ... */ }
        }
        // ...
    }
}

让我们分析一下代码(参见 GitHub 上的源代码:https://net5.link/code 。我们模拟了ISystemClock接口,并根据每个测试用例对其进行编程,以获得所需的结果。我们还编写了一些辅助方法,使测试更易于阅读。这些助手使用元组返回多个值;有关元组的更多信息,请参见元组(C#7+)部分。

既然我们有了那些未通过测试的人,我们就可以开始实施InMemoryWishList。以下是使所有测试通过的实现概要:

public class InMemoryWishList : IWishList
{
    private readonly InMemoryWishListOptions _options; 
    private readonly Dictionary<string, InternalItem> _items;
    public InMemoryWishList(InMemoryWishListOptions options)
    {
        _options = options ?? throw new ArgumentNullException(nameof(options));
        _items = new Dictionary<string, InternalItem>();
    }
    public Task<WishListItem> AddOrRefreshAsync(string itemName)
    {
        // ...
    }
    public Task<IEnumerable<WishListItem>> AllAsync()
    {
        // ...
    }
    private class InternalItem
    {
        public int Count { get; set; }
        public DateTimeOffset Expiration { get; set; }
    }
}

笔记

请参考 GitHub 中的完整代码以了解省略的两个方法,因为它们很长:https://net5.link/code

InMemoryWishList在内部使用Dictionary<string, InternalItem>来存储项目。AllAsync方法过滤过期项目,AddOrRefreshAsync方法删除过期项目。该实现不是线程安全的,多个同时AddOrRefreshAsync调用可能会产生错误结果。

代码不一定是所有代码中最优雅的,因此我们可以使用我们的测试来重构它。

运动

我邀请您重构InMemoryWishList类的两个方法;你有测试来帮助你。我自己花了几分钟重构它,并将其保存为InMemoryWishListRefactored。您还可以取消注释InMemoryWishListTest.cs的第一行来测试该类,而不是主类。我的重构只是一种使代码更干净的方法;我把它放在那里是为了给你一些想法。这不是唯一的方法,也不是写那门课的最佳方法(“最佳方法”是主观的)。它也不是线程安全的。

回到依赖注入,使用户之间共享愿望列表的行位于合成根目录中。是的,只有高亮显示的行才能在创建多个实例和单个共享实例之间产生差异:

public void ConfigureServices(IServiceCollection services)
{
    services
        .ConfigureOptions<InMemoryWishListOptions>()
        .AddTransient<IValidateOptions	<InMemoryWishListOptions>, InMemoryWishListOptions>()
        .AddSingleton(serviceProvider => serviceProvider.GetRequiredService<IOptions <InMemoryWishListOptions>>().Value)
    ;
    services.AddSingleton<IWishList, InMemoryWishList>();
    services.AddControllers();
}

通过将生存期设置为 singleton,您可以打开多个浏览器并共享愿望列表。对于POSTAPI,我建议使用 Postman 或任何其他比浏览器更合适的工具。从控制台,您可以使用curlInvoke-WebRequest,具体取决于您的操作系统。GitHub 存储库中有一些说明,还有一个指向 Postman 集合的链接,该集合包含测试 Wishlist API 的 HTTP 请求。

就这样!所有这些代码都是为了演示一行代码可以做什么,我们有一个工作程序,尽管它很小。

如果您想知道IConfigureOptionsIValidateOptionsIOptions来自何方,我们将在下一章更深入地介绍期权模式。

接下来,我们将探讨元组,以防您不熟悉该 C#特性。

元组(C#7+)

元组是一种类型,允许从方法返回多个值,或在变量中存储多个值,而无需声明类型和使用dynamic类型。自 C#7 以来,元组支持有了很大的改进。

笔记

在某些情况下,使用dynamic对象是可以的,但请注意,它可能会降低性能并增加由于缺少类型而引发的运行时异常数。编译时错误可以立即修复,而无需等待它们在运行时出现,或者更糟的是,由用户报告。此外,dynamic对象带来了有限的工具支持,使得发现对象可以做什么变得更加困难,并且更容易出错(没有类型可以自动完成)。

C#语言添加了关于元组的语法糖,使代码更清晰、更易于阅读。微软称为轻量级语法

如果您以前使用过Tuple类型,您知道Tuple成员是通过Item1Item2ItemN字段访问的。这种较新的语法允许我们从代码库中删除这些字段,并用用户定义的名称替换它们。如果您从未听说过元组,我们将立即探索它们。

让我们直接跳到几个样本中,编码为 xUnit 测试。第一个演示了如何创建一个未命名的元组,并使用前面提到的Item1Item2ItemN访问其字段:

[Fact]
public void Unnamed()
{
    var unnamed = ("some", "value", 322);
    Assert.Equal("some", unnamed.Item1);
    Assert.Equal("value", unnamed.Item2);
    Assert.Equal(322, unnamed.Item3);
}

然后,我们可以创建一个命名元组–当您不喜欢 1、2、3 字段时,它非常有用:

[Fact]
public void Named()
{
    var named = (name: "Foo", age: 23);
    Assert.Equal("Foo", named.name);
    Assert.Equal(23, named.age);
}

由于编译器完成了大部分命名,即使 IntelliSense 没有向您显示,我们仍然可以访问这些 1、2、3 字段:

[Fact]
public void Named_equals_Unnamed()
{
    var named = (name: "Foo", age: 23);
    Assert.Equal(named.name, named.Item1);
    Assert.Equal(named.age, named.Item2);
}

此外,我们可以使用名称“神奇地”跟随的变量创建命名元组:

[Fact]
public void ProjectionInitializers()
{
    var name = "Foo";
    var age = 23;
    var projected = (name, age);
    Assert.Equal("Foo", projected.name);
    Assert.Equal(23, projected.age);
}

由于值存储在这 1、2、3 个字段中,并且编译器生成了程序员友好的名称,因此相等性基于字段顺序,而不是字段名称。部分原因是,比较两个元组是否相等非常简单:

[Fact]
public void TuplesEquality()
{
    var named1 = (name: "Foo", age: 23);
    var named2 = (name: "Foo", age: 23);
    var namedDifferently = (Whatever: "Foo", bar: 23);
    var unnamed1 = ("Foo", 23);
    var unnamed2 = ("Foo", 23);
    Assert.Equal(named1, unnamed1);
    Assert.Equal(named1, named2);
    Assert.Equal(unnamed1, unnamed2);
    Assert.Equal(named1, namedDifferently);
}

如果您不想使用.符号访问元组的成员,我们还可以将它们分解为变量:

[Fact]
public void Deconstruction()
{
    var tuple = (name: "Foo", age: 23);
    var (name, age) = tuple;
    Assert.Equal("Foo", name);
    Assert.Equal(23, age);
}

方法还可以返回元组,使用方法与我们在前面示例中看到的相同:

[Fact]
public void MethodReturnValue()
{
    var tuple1 = CreateTuple1();
    var tuple2 = CreateTuple2();
    Assert.Equal(tuple1, tuple2);
    static (string name, int age) CreateTuple1()
    {
        return (name: "Foo", age: 23);
    }
    static (string name, int age) CreateTuple2() 
        => (name: "Foo", age: 23);
}

这些方法是内联的,但普通方法也是如此。

为了结束关于元组的这段插曲,我建议在导出的公共 API(例如,共享库)上避免使用元组。但是,它们可以在内部方便地为代码助手编写代码,而无需创建只保存数据并使用一次或几次的类。

我认为元组是对.NET 的一个很好的补充,但出于许多原因,我更喜欢公共 API 上完全定义的类型。第一个原因是封装;元组的成员是字段,这破坏了封装。然后,准确地命名作为 API(契约/接口)一部分的类是至关重要的。

提示

当您找不到类型的详尽名称时,可能会出现一些业务需求模糊、正在开发的内容不完全是所需的内容、或者领域语言不清楚的情况。有时候,你的想象力只是让你失望,但这一次没关系——它发生了。

接下来,我们将探索服务定位器反模式/代码气味。

了解服务定位器模式

服务定位器是一种反模式,它将 IoC 原则还原为其控制怪胎根源。唯一的区别是使用 IoC 容器来构建依赖关系树,而不是new关键字。

这种模式在 ASP.NET 中有一些用途,有些人可能会认为使用服务定位器模式是有原因的,但这种情况应该很少发生或永远不会发生。因此,在本书中,我们将服务定位器称为代码气味,而不是反模式

DI 容器在内部使用服务定位器模式来查找依赖项,这是使用它的正确方法。在您的应用中,您希望避免注入IServiceProvider以从中获得所需的依赖项,这将恢复到经典的控制流。

我强烈建议不要使用服务定位器,除非您知道自己在做什么,并且没有其他选择。

服务定位器的一个很好的用途是迁移一个太大而无法重写的遗留系统。因此,您可以使用 DI 构建新代码,并使用服务定位器模式更新遗留代码,从而允许两个系统共存或根据您的目标将一个系统迁移到另一个系统。

项目:ServiceLocator

避免某些事情的最好方法是了解它,所以让我们看看如何使用IServiceProvider来实现服务定位器模式以查找依赖项:

public class MyController : ControllerBase
{
    private readonly IServiceProvider _serviceProvider;
    public MyController(IServiceProvider serviceProvider)
    {
        _serviceProvider = serviceProvider ?? throw new ArgumentNullException(nameof(serviceProvider));
    }
    [Route("/")]
    public IActionResult Get()
    {
        var myService = _serviceProvider.GetService<IMyService>();
        myService.Execute();
        return Ok("Success!");
    }
}

在代码示例中,我们不是将IMyService注入构造函数,而是注入IServiceProvider。然后,我们使用它在Get()方法中找到IMyService。这样做会将创建对象的责任从容器转移到类(在本例中为MyController)。MyController不应意识到IServiceProvider,应让容器在不受干扰的情况下完成其工作。

会出什么问题?让我们从IMyService实现IDisposable,并在Get()方法中添加一条using语句。这说明了在容器之外控制依赖项的生命会产生什么样的问题。

MyServiceImplementation看起来像这样,模拟使用一些外部的、一次性的依赖项:

public class MyServiceImplementation : IMyService
{
    public bool IsDisposed { get; private set; }
    public void Dispose() => IsDisposed = true;
    public void Execute()
    {
        if (IsDisposed)
        {
            throw new NullReferenceException("Some dependencies has been disposed.");
        }
    }
}

然后,通过更新MyController来处置IMyService,我们制造了一些不稳定性:

[Route("/")]
public IActionResult Get()
{
    using (var myService = _serviceProvider.GetService<IMyService>())
    {
        myService.Execute();
        return Ok("Success!");
    }
}

如果我们运行应用并导航到/,一切都会按预期进行。如果我们重新加载页面,我们会得到一个错误,Execute()抛出,因为我们在上一次调用中调用了Dispose()MyController不应该控制其注入的依赖项,这是我在这里试图强调的一点:让容器控制依赖项的生存期,而不是试图成为一个控制怪胎。使用服务定位器模式打开了通向这些错误行为的途径,从长远来看,这些错误行为很可能造成弊大于利。

此外,尽管 ASP.NET Core 5 容器本身不支持此功能,但在使用服务定位器时,我们失去了在上下文中注入依赖项的能力,因为使用者控制其依赖项。我所说的语境是什么意思?可以将实例A注入一个类,但将实例B注入另一个类。

项目:服务定位固定

为了修复我们的控制器,我们需要离开服务定位器,转而注入依赖项。在示例中,我们解决了以下问题:

  • 方法注入
  • 构造函数注入

实现方法注入

让我们从开始使用方法注入来演示它的用法:

public class MethodInjectionController : ControllerBase
{
    [Route("/method-injection")]
    public IActionResult GetUsingMethodInjection([FromServices]IMyService myService)
    {
        if (myService == null) { throw new ArgumentNullException(nameof(myService)); }
        myService.Execute();
        return Ok("Success!");
    }
}

让我们分析一下代码:

  • FromServicesAttribute类告诉模型绑定器方法注入。我们可以在任何操作中注入零个或多个服务,方法是用[FromServices]修饰其参数。

  • 我们增加了保护条款,以保护我们免受nulls的影响。

  • Finally, we kept the original code.

    笔记

    这样的方法注入对于具有多个动作的控制器很有用,但只在其中一个动作中使用IMyService

实现构造函数注入

让我们继续使用构造函数注入实现相同的解决方案。我们的新控制器如下所示:

public class ConstructorInjectionController : ControllerBase
{
    private readonly IMyService _myService;
    public ConstructorInjectionController(IMyService myService)
    {
        _myService = myService ?? throw new ArgumentNullException(nameof(myService));
    }
    [Route("/constructor-injection")]
    public IActionResult GetUsingConstructorInjection()
    {
        _myService.Execute();
        return Ok("Success!"); 
    }
}

当使用构造函数注入时,在类实例化时,我们强制IMyService不为 null。因为它是一个类成员,所以在一个动作中调用它的Dispose()方法就不那么容易了,把责任留给容器(应该如此)。

这两种技术都是可接受的服务定位器反模式的替代品。让我们分析一下代码:

  • 我们实施了战略模式。
  • 我们使用构造函数注入。
  • 我们增加了一个保护条款。
  • 我们将操作简化为它应该做的事情,将原始代码简化到最低限度。

结论

大多数情况下,通过遵循服务定位器反模式,我们只会隐藏我们正在控制对象,而不是解耦我们的组件。最后一个示例演示了在处理对象时出现的问题,使用构造函数注入可能会出现这种情况。然而,当考虑它时,处理我们创建的对象比处理注入的对象更具诱惑力。

此外,服务定位器将控制权从容器移开,并将其移入使用者,这与 OCP 背道而驰。应该能够通过更新组合根的绑定来更新使用者。在这种情况下,我们可以更改绑定,它将起作用。在更高级的情况下,当需要上下文注入时,我们很难将两个实现绑定到同一个接口,每个接口对应一个上下文;这甚至可能是不可能的。

这种反模式也使测试复杂化;在对类进行单元测试时,需要模拟返回模拟服务的容器,而不是只模拟服务。

有一个地方我可以看到它的使用是合理的,那就是在组合根中,因为绑定是在那里定义的,有时候,特别是使用内置容器,我们无法避免它。另一个地方是向容器添加功能的库。除此之外,尽量离我远点!

当心

将服务定位器移到别处不会使其消失;它只会像任何依赖项一样移动它。

重温工厂模式

在策略模式示例中,我们实现了一个解决方案,该解决方案使用new关键字实例化了一个HomePageViewModel。虽然这样做是可以接受的,但我们可以使用方法注入,与工厂的使用混合使用。当构建对象时,工厂模式是方便的工具。让我们看看使用工厂的一些改写来探索一些可能性。

工厂混合注射法

我们的第一个选择是将视图模型直接注入到我们的方法中,而不是注入IHomeService。为了实现这一点,让我们像这样重写HomeController类:

public class HomeController : Controller
{
    public IActionResult Index([FromServices]HomePageViewModel viewModel)
    {
        return View(viewModel);
    }
    // Omitted Privacy() and Error()
}

FromServicesAttribute类告诉 ASP.NET Core 管道将HomePageViewModel的实例直接注入该方法。

现在我们已经对消费者进行了编码,我们需要告诉容器如何向我们提供该视图模型。为此,我们使用工厂而不是静态绑定,如下所示:

public void ConfigureServices(IServiceCollection services)
{
    services.AddSingleton<IHomeService, HomeService>();
    services.AddTransient(serviceProvider => {
 var homeService = serviceProvider.GetService<IHomeService>();
 var data = homeService.GetHomePageData();
 return new HomePageViewModel(data);
 });
}

在前面的代码中,我们使用了AddTransient()扩展方法的另一个重载,并将Func<IServiceProvider, TService> implementationFactory作为参数传递。突出显示的代码代表我们的工厂。该工厂被实现为一个服务定位器,使用IServiceProvider实例创建IHomeService依赖项,我们使用该依赖项实例化HomePageViewModel

我们在这里使用的是new关键字,但这是错误的吗?组合根是应该创建(或配置)元素的地方,因此可以在那里实例化对象,就像使用服务定位器模式一样。然而,你应该尽可能避免它。使用默认容器比使用功能齐全的第三方容器更难避免,但在许多情况下我们可以避免。

我们还可以创建一个 factory 类来保持我们的合成根目录干净(请参阅HomeViewModelFactory代码示例)。虽然这是真的,但我们只会移动代码,从而增加程序的复杂性。这就是为什么在控制器的操作中创建视图模型可以减少不必要的复杂性的原因。

此外,在大多数情况下,在动作中创建视图模型不应对程序的可维护性产生负面影响,因为视图模型绑定到单个视图,由单个动作控制,从而形成一对一的关系。

最后,它的实现成本更低。它也比在代码中漫游更容易理解,以找到绑定的作用。

最大的缩减是可测试性;从测试用例中注入我们想要的数据比处理硬编码对象创建更容易。

工厂的使用可以考虑多个场景,因此我们在下面的示例中介绍,一石二鸟。到目前为止,我们看到了以下情况:

  • 我们可以在 composition 根目录中创建一个工厂来管理依赖项的创建(aFunc<IServiceProviderTService>
  • 方法注入的一个例子。
  • 有时,应该使用new关键字,而不是试图实现更复杂的代码,最终只会解决问题,导致错误的解耦,增加复杂性。

HomeViewModelFactory

对于更复杂的场景,或者为了清理Startup类,我们可以创建一个类来处理工厂逻辑。我们还可以创建一个类和一个接口,以便在其他类中直接使用工厂。这种方法仅在您需要依赖项时才方便地创建依赖项;这也称为延迟加载。延迟加载意味着仅在需要时创建对象,推迟使用时(或首次使用时)创建对象的开销。

笔记

有一个现有的Lazy<T>类可以帮助延迟加载,但这不是本代码示例的重点。想法是一样的:我们只在第一次需要的时候创建对象。

除非您需要在多个位置重用该创建逻辑,否则创建工厂类可能不值得。然而,这是一个非常方便的模式,值得记住。下面是如何实现返回PrivacyViewModel实例的工厂:

public interface IHomeViewModelFactory
{
    PrivacyViewModel CreatePrivacyViewModel();
}
public class HomeViewModelFactory : IHomeViewModelFactory
{
    public PrivacyViewModel CreatePrivacyViewModel() => new PrivacyViewModel
    {
        Title = "Privacy Policy (from 
        IHomeViewModelFactory)",
        Content = new HtmlString("<p>Use this page to detail your site's privacy policy.</p>")
    };
}

前面的代码将PrivacyViewModel实例的创建封装到HomeViewModelFactory中。代码非常基本,它创建了一个PrivacyViewModel类的实例,并用硬编码的值填充其属性。

现在要使用新工厂,我们需要更新控制器。为此,我们使用构造函数注入将IHomeViewModelFactory注入HomeController,然后从Privacy()动作方法中使用,如下所示:

private readonly IHomeViewModelFactory _homeViewModelFactory;
public HomeController(IHomeViewModelFactory homeViewModelFactory)
{
    _homeViewModelFactory = homeViewModelFactory ?? throw new ArgumentNullException(nameof(homeViewModelFactory));
}
// ...
public IActionResult Privacy()
{
    var viewModel = _homeViewModelFactory.CreatePrivacyViewModel();
    return View(viewModel);
}

前面的代码清晰、简单且易于测试。

通过使用这种技术,我们不限于一种方法。我们可以在同一个工厂中编写多个方法,每个方法封装自己的创作逻辑。我们还可以将其他对象传递给Create[object to create]()方法,如下所示:

public HomePageViewModel CreateHomePageViewModel(IEnumerable<string> someData)
{
    return new HomePageViewModel(someData);
}

当你想到它的时候,可能性几乎是无限的,所以现在你已经看到了一些实际操作,当你需要将一些带有复杂实例化逻辑的类注入到其他对象中时,你可能会发现工厂的其他用途。

您还可以将依赖项注入工厂,并将它们用于复杂的实例化逻辑。

请记住,在代码库中移动代码不会使代码、逻辑、依赖项或耦合消失

根据经验,在控制器操作中创建视图模型是可以接受的。包含逻辑的类是我们想要注入并打破紧密耦合的类。

总结

本章介绍了依赖注入的基础知识,以及如何利用它来遵循控制反转原理帮助下的依赖反转原理

然后,我们回顾了策略设计模式,并了解了如何使用它创建一个灵活的、DI 就绪的系统。我们还回顾了单例模式,看到我们可以在容器中配置依赖项时使用单例生存期在系统范围内注入相同的实例。我们终于看到了如何利用工厂来处理复杂的对象创建逻辑。

我们还讨论了移动代码、改进的幻觉和工程成本。我们看到,当构造逻辑足够简单时,new关键字可以帮助降低复杂性,并且可以节省时间和金钱。

另一方面,我们还探索了一些处理对象创建复杂性的技术,以创建可维护和可测试的程序,例如工厂,并摆脱控制怪胎代码的味道。

在这些重要主题之间,我们还访问了保护子句,以保护注入的依赖项不受null的影响。这样,我们就可以使用构造函数注入来要求一些必需的服务,并从类方法中使用它们,而无需每次对null进行测试。

我们探讨了服务定位器(反)模式如何有害,以及如何从组合根中利用它来动态创建复杂对象。我们还讨论了为什么要使其使用频率尽可能接近“从不”。

了解这些模式和代码气味对于在程序规模不断增长时保持系统的可维护性非常重要。

此外,我们还研究了 C#语言中一些较新的元素,如元组、默认文字表达式和抛出表达式。

对于需要复杂 DI 逻辑、条件注入、多作用域、自动实现工厂和其他高级功能的程序,我们发现可以使用第三方容器而不是内置容器。

在接下来的部分中,我们将探讨一些工具,这些工具将功能添加到默认容器中,从而减少将其替换为其他容器的需要。如果您正在构建多个较小的项目(微服务),而不是一个较大的项目(整体),这可能会使您不再需要这些额外的功能,但没有什么是免费的,而且一切都是有成本的;更多信息请参见第 16 章微服务架构简介

在下一章中,我们将探讨选项和日志记录模式。这些 ASP.NET Core 模式的目的是在管理此类常见问题时减轻我们的负担。

问题

让我们来看看几个练习题:

  1. 我们可以分配给 ASP.NET Core 中的对象的三个依赖项注入生命周期是什么?
  2. 作文的词根是什么?
  3. 在实例化易失性依赖项时,我们是否应该避免使用new关键字?
  4. 在本章中,我们重温的有助于组合对象以消除继承的模式是什么?
  5. 服务定位器模式是设计模式、代码气味还是反模式?

进一步阅读

以下是我们在本章学到的一些链接:

八、选项和日志模式

在本章中,我们将介绍特定于.NET 的模式,如选项模式和日志记录。我们将探索这些抽象,同时将其保持在我们使用它们的水平,而不是掌握它们的每个方面。阅读本章后,您应该知道如何利用.NET 5 选项和设置基础架构,以及如何编写应用日志。我们还将简要探讨如何定制这些系统。

本章将介绍以下主题:

  • 选项模式概述
  • 熟悉.NET 日志抽象

让我们开始吧!

期权模式概述

在 ASP.NET 5 中,我们可以使用预定义的机制来提高应用设置的使用率。这些允许我们将配置划分为多个较小的对象,在启动流程的多个阶段对它们进行配置,验证它们,甚至以最小的工作量观察运行时的更改。

选项模式的目标是在运行时使用设置,允许在不更改代码的情况下更改应用。设置可以是简单的stringbool、数据库连接字符串或包含整个子系统配置的复杂对象。

本节探讨 ASP.NET Core 提供的不同工具,用于管理、注入配置和选项,并将其加载到我们的程序中。我们将处理不同的场景,从普通场景到更高级场景。

开始

ASP.NET Core 5 中的选项模式允许我们无缝地从多个源加载设置。我们可以在创建IHostBuilder时自定义这些源,甚至可以使用通过调用Host.CreateDefaultBuilder(args)设置的默认源。按顺序排列的默认源如下:

  1. appsettings.json
  2. appsettings.{Environment}.json
  3. 用户机密;这些仅在环境为Development时加载。
  4. 环境变量。
  5. 命令行参数。

顺序也非常重要,因为最后一个要加载的值会覆盖任何以前的值。例如,您可以在appsettings.json中设置一个值,并在appsettings.Staging.json中重写该值,方法是重新定义该文件、用户机密或环境变量中的值,或者在运行应用时将其作为命令行参数传递。

设置的使用方式主要有四种:IOptionsMonitor<TOptions>IOptionsFactory<TOptions>IOptionsSnapshot<TOptions>IOptions<TOptions>。在所有这些情况下,我们可以将该依赖项注入到类中以使用可用的设置。TOptions是表示我们要访问的设置的类型。

正如下面三个注释中提到的,如果您没有配置 options 类,框架通常会返回一个空的 options 类实例。我们将在下一小节中学习如何正确配置选项,但请记住,在 options 类中使用属性初始值设定项也是确保使用某些默认值的一种好方法。不要对根据环境(开发、登台或生产)更改的默认值或连接字符串和密码等机密使用初始值设定项。您还可以使用常量将这些默认值集中在代码库中的某个位置(使它们更易于维护)。尽管如此,适当的配置和验证始终是首选,但两者的结合可以增加一个安全网。

基于MyListOption类,由于int的默认值为 0,因此每页显示的默认项数为 0,导致列表为空。但是,我们可以使用属性初始值设定项对此进行配置,如下例所示:

public class MyListOption
{
    public int ItemsPerPage { get; set; } = 20;
}

现在每页显示的默认项目数为 20。

笔记

第 8 章选项和日志模式的源代码中,我在CommonScenarios.Tests项目中包含了一些测试,这些测试断言了不同选项接口的生命周期。为了简洁起见,我这里没有包含这段代码,但它通过单元测试描述了不同选项的行为。参见https://net5.link/T8Ro 了解更多信息。

接下来,我们将探讨.NET5 提供的不同接口。

IOptionsMonitor

这个接口是所有接口中最通用的。它允许我们接收有关重新加载配置的通知。它还支持缓存,可以有多个配置,每个配置都与一个名称(命名配置)关联。注入的IOptionsMonitor<TOptions>实例总是相同的(单例生存期。它还通过其Value属性支持默认设置(无名称)。

笔记

如果您只配置了命名选项或根本没有配置实例,则会收到一个空的TOptions实例(new TOptions()

IOPTIONS 工厂

这个界面是一个工厂,正如我们在第 6 章中看到的,理解策略、抽象工厂和单例设计模式第 7 章深入到依赖注入。我们使用工厂来创建实例;这个也一样。除非是绝对必要的,否则我建议改为使用IOptionsMonitor<TOptions>IOptionsSnapshot<TOptions>

这个工厂的一个用途可能是创建多个设置实例,同时只注入一个依赖项,但这听起来更像是一个设计缺陷,而不是解决方案。然而,这在某些罕见的情况下可能有用。为什么是缺陷?您将恢复到控制依赖项,而不是执行 IoC。

其工作原理很简单:每次您请求一个工厂(瞬态寿命)时,都会创建一个新的工厂,每次您调用其Create(name)方法(瞬态寿命时,每个工厂都会创建一个新的选项实例。

笔记

如果您在调用factory.Create(Options.DefaultName)时只配置了命名选项,或者根本没有配置实例,那么您将收到一个空的TOptions实例(new TOptions())。

Options.DefaultName是非命名选项的名称;这通常由框架为您处理。

IOptionsSnapshot

当您需要设置的快照时,此界面非常有用,并且每个请求创建一次作用域生存期。我们也可以使用它来获取命名选项,例如IOptionsMonitor<TOptions>。我们可以使用CurrentValue属性访问默认选项。

笔记

如果只配置了命名选项或根本没有配置实例,则会收到一个空的TOptions实例(new TOptions()

选项

此接口是添加到 ASP.NET Core 的第一个接口。它不支持高级方案,例如快照和监视器的功能。无论何时您请求IOptions<TOptions>,您都会得到相同的实例(单例生存期

笔记

IOptions<TOptions>不支持命名选项,只能访问默认选项。

项目-公共场合

第一个示例涵盖多个基本用例,例如注入选项、使用命名选项以及在设置中存储选项值。

常用

在第一个用例中,我们将学习如何使用IOptions<TOptions>。我们还将定义多个其他场景的共同点。让我们一步一步地回顾一下我们将要做的事情:

  1. 为我们的服务创建一个名为IMyNameService的接口。
  2. 创建选项类。
  3. 针对该接口编写一些单元测试代码。我们将为每个实现重用这些测试。
  4. 为我们的第一个实现编写代码。
  5. 对它运行我们的测试。

我们的界面非常简单,如下所示:

public interface IMyNameService
{
    string GetName(bool someCondition);
}

它包含一个GetName方法,该方法将someCondition作为参数并返回一个字符串,从中我们可以预期其内容是一个名称。

接下来,我们创建两个选项类:一个用于此场景,另一个用于其他场景。我们将在其他场景中使用的类如下所示:

public class MyOptions
{
    public string Name { get; set; }
}

我们将在本场景中使用的类如下:

public class MyDoubleNameOptions
{
    public string FirstName { get; set; }
    public string SecondName { get; set; }
}

笔记

我在这里展示了这两个类,以节省以后的空间,这样我就不必再复制相同的测试,而只需做一个小的更改。此外,它们是小而简单的类。

现在,作为 TDD 的实践者,让我们看看作为初始代码使用者的单元测试。我们的简单说明是当someConditiontrue时,GetName返回Option1Name的值,而当someConditionfalse时,GetName返回Option2Name的值。

笔记

Option1NameOption2Name是两个包含不相关值的常数(但不同的值,以便我们可以比较它们的输出)。

让我们从单元测试开始:

public abstract class MyNameServiceTest<TMyNameService>
    where TMyNameService : class, IMyNameService
{
    protected readonly IMyNameService _sut;
    public const string Option1Name = "Options 1";
    public const string Option2Name = "Options 2";
    public MyNameServiceTest()
    {
        var services = new ServiceCollection();
        services.AddTransient<IMyNameService, TMyNameService>();
        services.Configure<MyOptions>("Options1", myOptions =>
        {
            myOptions.Name = Option1Name;
        });
        services.Configure<MyOptions>("Options2", myOptions =>
        {
            myOptions.Name = Option2Name;
        });
        services.Configure<MyDoubleNameOptions>(options =>
        {
            options.FirstName = Option1Name;
            options.SecondName = Option2Name;
        });
        _sut = services.BuildServiceProvider()
            .GetRequiredService<IMyNameService>();
    }

让我们分析一下测试课程的第一部分:

  • 我们的测试类是抽象的和泛型的,使它成为所有IMyNameService测试的基类。

  • 我们使用泛型TMyNameService类型作为实现,创建了测试中的主题。

  • We configured two named MyOptions and one MyDoubleNameOptions. These are injected into the service's implementations; more on that later. In this case, we have configured the options' properties manually. In programs, we usually move those values to configuration files or other providers, such as appsettings.json; more on that later.

    笔记

    每个选项的名称作为参数传递给services.Configure<MyOptions>("name", ...)方法。

然后,我们需要创建前面讨论过的两个测试用例:

    [Fact]
    public void GetName_should_return_Name_from_options1_when_someCondition_is_true()
    {
        var result = _sut.GetName(true);
        Assert.Equal(Option1Name, result);
    }
    [Fact]
    public void GetName_should_return_Name_from_options2_when_someCondition_is_false()
    {
        var result = _sut.GetName(false);
        Assert.Equal(Option2Name, result);
    }
}

现在,让我们创建一个名为MyNameServiceUsingDoubleNameOptions的实现。它使用IOptions<MyDoubleNameOptions>接口,这使它成为我们可以使用的最简单的实现:

public class MyNameServiceUsingDoubleNameOptions : IMyNameService
{
    private readonly MyDoubleNameOptions _options;
    public MyNameServiceUsingDoubleNameOptions (IOptions<MyDoubleNameOptions> options)
    {
        _options = options.Value;
    }
    public string GetName(bool someCondition)
    {
        return someCondition ? _options.FirstName : _options.SecondName;
    }
}

这是一个相当简单的实现;我们将IOptions<MyDoubleNameOptions>注入构造函数,并使用第三级运算符从选项返回第一个或第二个名称。现在我们有了可重用的测试和MyNameServiceUsingDoubleNameOptions类,我们可以添加具体的测试类,它针对我们的实现运行实际的测试:

public class MyNameServiceUsingDoubleNameOptionsTest : MyNameServiceTest<MyNameServiceUsingDoubleNameOptions> { }

是的,就是这样;所有管道都已在基础测试中完成。如果我们运行测试,一切都应该是绿色的!

命名选项

使用选项模式,我们可以注册相同类型的多个实例并按名称访问它们。

笔记

这样做打破了控制反转、依赖反转和打开/关闭原则,将依赖项的创建控制权返还给消费类,而不是将责任从消费类转移到合成根。

由于.NET 团队认为实现命名选项是合适的,所以我们将在这里介绍它。通过动态访问要创建的选项的名称,而不是在构造函数中硬编码魔术字符串,我们可以使用此模式构建动态应用,而不损害任何原则。例如,这可以使用数据库或其他一些设置。

该功能本身并没有错,但在使用过程中可能会出现问题。

对于这个示例,我们将创建三个不同的实现:一个使用IOptionsFactory<MyOptions>,一个使用IOptionsMonitor<MyOptions>,一个使用IOptionsSnapshot<MyOptions>。这三个测试都使用我们在上一个示例中创建的测试套件。让我们看一下代码:

public class MyNameServiceUsingNamedOptionsFactory : IMyNameService
{
    private readonly MyOptions _options1;
    private readonly MyOptions _options2;
    public MyNameServiceUsingNamedOptionsFactory (IOptionsFactory<MyOptions> myOptions)
    {
        _options1 = myOptions.Create("Options1");
        _options2 = myOptions.Create("Options2");
    }
    public string GetName(bool someCondition)
    {
        return someCondition ? _options1.Name : _options2.Name;
    }
}
public class MyNameServiceUsingNamedOptionsMonitor : IMyNameService
{
    private readonly MyOptions _options1;
    private readonly MyOptions _options2;
    public MyNameServiceUsingNamedOptionsMonitor (IOptionsMonitor<MyOptions> myOptions)
    {
        _options1 = myOptions.Get("Options1");
        _options2 = myOptions.Get("Options2");
    }
    public string GetName(bool someCondition)
    {
        return someCondition ? _options1.Name : _options2.Name;
    }
}
public class MyNameServiceUsingNamedOptionsSnapshot : IMyNameService
{
    private readonly MyOptions _options1;
    private readonly MyOptions _options2;
    public MyNameServiceUsingNamedOptionsSnapshot (IOptionsSnapshot<MyOptions> myOptions)
    {
        _options1 = myOptions.Get("Options1");
        _options2 = myOptions.Get("Options2");
    }
    public string GetName(bool someCondition)
    {
        return someCondition ? _options1.Name : _options2.Name;
    }
}

这三个类非常相似,除了它们的构造函数;每个人都期望有不同的依赖关系。

笔记

我关于魔术弦的笔记现在可能更有意义了;每个类使用硬编码名称请求一组特定的选项;就是一根魔弦。这样做限制了我们从组合根更改任何单个类中的注入选项的能力。要进行更改,我们需要打开该类,更改神奇字符串,保存该类,然后重新编译。此外,使用该工具不会自动重构字符串,从而导致不一致和运行时错误。所以,总而言之,魔术弦更难维护,应该尽量避免。

接下来我们需要,创建以下三个简单类:

public class MyNameServiceUsingNamedOptionsFactoryTest : MyNameServiceTest<MyNameServiceUsingNamedOptionsFactory> {}
public class MyNameServiceUsingNamedOptionsMonitorTest : MyNameServiceTest<MyNameServiceUsingNamedOptionsMonitor> {}
public class MyNameServiceUsingNamedOptionsSnapshotTest : MyNameServiceTest<MyNameServiceUsingNamedOptionsSnapshot> {}

运行这些测试证明我们的三个新类尊重我们的用例。有了这些,我们创建了多个使用命名选项的类。

使用设置

既然我们已经探索了如何手动创建选项,那么让我们来探索如何使用appsettings.json在 ASP.NET Core 5 应用中实现这一点。

appsettings.json文件允许您使用 JSON 语法定义任意设置,并根据需要进行结构化。与 ASP.NET Core 之前的web.config文件中的键/值设置相比,这是一个很大的改进。现在,您可以定义复杂的对象层次结构,从而实现更好的组织。您也不需要编写复杂的管道代码,就像创建自定义web.config部分一样。

以下是我们的 JSON 设置:

{
  "options1": {
    "name": "Options 1"
  },
  "options2": {
    "name": "Options 2"
  },
  "myDoubleNameOptions": {
    "firstName": "Options 1",
    "secondName": "Options 2"
  }
}

这里的数据结构与我们之前定义的类相同;也就是说,MyOptionsMyDoubleNameOptions。这是因为我们将要使用提供的选项实用程序将这些设置加载(反序列化)到我们的类中。

Startup.ConfigureServices方法中,我们可以添加以下内容:

services.Configure<MyOptions>(
  "Options1", 
  _configuration.GetSection("options1")
);
services.Configure<MyOptions>(
  "Options2", 
  _configuration.GetSection("options2")
); 
services.Configure<MyDoubleNameOptions>(
  _configuration.GetSection("myDoubleNameOptions")
);

我们在这里使用两种不同的扩展方法,而不是像以前那样手动配置选项。_configuration字段为IConfiguration类型,允许我们访问整个配置。调用_configuration.GetSection("...")会给我们提供另一个IConfiguration对象,更准确地说,是一个IConfigurationSection对象,其中键与我们的设置匹配。

笔记

如果您需要访问某个分区,您可以使用:标志。例如,对于类似于{ "object1": { "object2": {} } }的配置,您可以GetSection("object1:object2")直接获取嵌套的配置对象。

之后,我们需要注册我们的服务。在这种情况下,我们需要使用以下代码:

services.AddTransient<MyNameServiceUsingDoubleNameOptions>();
services.AddTransient<MyNameServiceUsingNamedOptionsFactory>();
services.AddTransient<MyNameServiceUsingNamedOptionsMonitor>();
services.AddTransient<MyNameServiceUsingNamedOptionsSnapshot>();

然后,我们可以在任何地方注入这些依赖项;对于这个例子,我决定使用MapGet扩展方法来创建一个简单的端点:

endpoints.MapGet("/{serviceName}/{someCondition?}", async context =>
{
    var serviceName = (string)context.Request.RouteValues["serviceName"];
    if (bool.TryParse((string)context.Request .RouteValues["someCondition"], out var someCondition))
    {
        var myNameService = GetMyNameService(serviceName);
        var name = myNameService.GetName(someCondition);
        context.Response.ContentType = "application/json";
        await context.Response.WriteAsync("{");
        await context.Response.WriteAsync("\"name\":");
        await context.Response.WriteAsync($"\"{name}\"");
        await context.Response.WriteAsync("}");
    }
    IMyNameService GetMyNameService(string serviceName) => serviceName
    switch
    {
        "factory" => context.RequestServices
            .GetRequiredService<MyNameServiceUsingNamed OptionsFactory>(),
        "monitor" => context.RequestServices
            .GetRequiredService<MyNameServiceUsingNamed OptionsMonitor>(),
        "snapshot" => context.RequestServices
            .GetRequiredService<MyNameServiceUsingNamed OptionsSnapshot>(),
        "options" => context.RequestServices
            .GetRequiredService<MyNameServiceUsingDoubleName Options>(),
        _ => throw new NotSupportedException($"The service named '{serviceName}' is not supported."),
    };
});

笔记

我还添加了一个端点,当用户调用GET /时,该端点会以链接列表进行响应。我省略了代码,因为它与示例无关,但在运行它时很方便。如果您的浏览器中有一个插件为您格式化 JSON 字符串,那么链接应该是可点击的。为此,我在 Chrome 和 Edge on Chrome 中使用了JSON 查看器,这是一个开源项目。

就这样!我们已经使用几行代码将 JSON 中的选项加载到我们的对象中。您可以随意运行代码、添加断点并探索应用的行为方式。

项目-选项配置

现在我们已经介绍了一些简单的场景,让我们来探讨一些更高级的可能性,例如创建有助于配置、初始化和验证选项的类型。

配置选项

我们可以创建实现IConfigureOptions<TOptions>的类型,然后将这些实现注册为服务,以动态配置我们的选项。

笔记

我们也可以为命名的选项实现IConfigureNamedOptions<TOptions>

首先,让我们为我们的小节目奠定基础:

  1. 第一个构建块是我们要配置的选项类:

    public class ConfigureMeOptions
    {
        public string Title { get; set; }
        public IEnumerable<string> Lines { get; set; }
    }
    
  2. 现在,让我们定义我们的应用设置(appsettings.json):

    {
      "configureMe": {
        "title": "Configure Me!",
        "lines": [
          "This comes from appsettings!"
        ]
      }
    }
    
  3. Next, let's make an endpoint that accesses the settings, serializes the result to a JSON string, and then writes it to the response stream:

    endpoints.MapGet("/configure-me", async context =>
    {
        var options = context.RequestServices
            .GetRequiredService<IOptionsMonitor<ConfigureMe Options>>();
        var json = JsonSerializer.Serialize(options);
        context.Response.ContentType = "application/json";
        await context.Response.WriteAsync(json);
    });
    

    此端点显示最新选项,即使我们在不更改代码或重新启动服务器的情况下更改appsettings.json的内容。

  4. 在运行程序之前,我们需要告诉 ASP.NET 这些设置:

    services.Configure<ConfigureMeOptions>(_configuration.GetSection("configureMe"));
    
  5. Now, when running the program and navigating to /configure-me, we should see the following:

    {
      "CurrentValue": {
        "Title": "Configure Me!",
        "Lines": [
          "This comes from appsettings!"
        ]
      }
    }
    

    CurrentValue为属性名称,可从IOptionsMonitor<TOptions>访问。除此之外,代码的其余部分看起来与我们在appsettings.json文件中配置的值非常相似。唯一的例外是套管;我们没有配置序列化程序,因此它使用了我们属性的大小写(帕斯卡大小写上驼峰大小写)。

  6. To take care of this, we can pass an instance of JsonSerializerOptions to the Serialize method, like this:

    var json = JsonSerializer.Serialize(
        options, 
        new JsonSerializerOptions { 
            PropertyNamingPolicy = JsonNamingPolicy.CamelCase 
        }
    );
    

    笔记

    驼色大小写(也称为下驼色大小写)是一个广泛接受的 JSON 和 JavaScript 标准。

i 配置选项

现在,让我们在另一个类中配置选项。该类型将在我们的选项中动态添加一行。

为此,我们可以创建一个实现IConfigureOptions<TOptions>接口的类,如下所示:

public class Configure1ConfigureMeOptions : IConfigureOptions<ConfigureMeOptions>
{
    public void Configure(ConfigureMeOptions options)
    {
        options.Lines = options.Lines.Append("Added line 1!");
    }
}

现在,我们必须在服务集合中注册它:

services.AddSingleton<IConfigureOptions<ConfigureMeOptions>, Configure1ConfigureMeOptions>();

从此处导航到/configure-me应输出以下内容:

{
  "currentValue": {
    "title": "Configure Me!",
    "lines": [
      "This comes from appsettings!",
      "Added line 1!"
    ]
  }
}

瞧,我们得到了一个几乎不费吹灰之力的完美结果。这会带来很多可能性!实现IConfigureOptions<TOptions>是配置选项类默认值的最佳方式。

多个 IConfigureOptions

您可能会发现前面的示例很有趣,但我们才刚刚开始!我们可以为同一个TOptions注册多个IConfigureOptions<TOptions>,这与注册一个新绑定一样简单。出于我们的目的,让我们创建另一个类,将另一行添加到数组中,因为这允许我们跟踪背景中发生的事情:

public class Configure2ConfigureMeOptions : IConfigureOptions<ConfigureMeOptions>
{
    public void Configure(ConfigureMeOptions options)
    {
        options.Lines = options.Lines.Append("Added line 2!");
    }
}

现在,我们可以注册它:

services.AddSingleton<IConfigureOptions<ConfigureMeOptions>, Configure2ConfigureMeOptions>();

新的输出应如下所示:

{
  "currentValue": {
    "title": "Configure Me!",
    "lines": [
      "This comes from appsettings!",
      "Added line 1!",
      "Added line 2!"
    ]
  }
}

需要注意的是,已向IServiceCollection注册的依赖项是有序的,因此通过交换Configure1ConfigureMeOptionsConfigure2ConfigureMeOptions的注册,我们将得到以下输出:

{
  "currentValue": {
    "title": "Configure Me!",
    "lines": [
      "This comes from appsettings!",
      "Added line 2!",
      "Added line 1!"
    ]
  }
}

很好,对吗?

更多配置

配置选项还有其他方式;例如:

  • 我们可以多次调用Configure扩展方法。
  • ConfigureAll允许我们在任何给定TOptions的所有选项上运行配置。这主要用于配置命名选项,但未命名选项只是与默认名称关联的命名选项,因此在我们的示例中,这也适用。
  • PostConfigurePostConfigureAll分别是ConfigureConfigureAll的等价物,但它们在初始化过程结束时运行。

通过向合成根添加以下内容,

services.Configure<ConfigureMeOptions>(options =>
{
    options.Lines = options.Lines.Append("Another Configure call");
});
services.PostConfigure<ConfigureMeOptions>(options =>
{
    options.Lines = options.Lines.Append("What about PostConfigure?");
});
services.PostConfigureAll<ConfigureMeOptions>(options =>
{
    options.Lines = options.Lines.Append("Did you forgot about PostConfigureAll?");
});
services.ConfigureAll<ConfigureMeOptions>(options =>
{
    options.Lines = options.Lines.Append("Or ConfigureAll?");
});

我们应该以以下输出结束:

{
  "currentValue": {
    "title": "Configure Me!",
    "lines": [
      "This comes from appsettings!",
      "Added line 1!",
      "Added line 2!",
      "Another Configure call",
      "Or ConfigureAll?",
      "What about PostConfigure?",
      "Did you forgot about PostConfigureAll?"
    ]
  }
}

注册顺序在这里很重要,因为在后台,框架正在创建实现IConfigureOptions<ConfigureMeOptions>的实例,并向服务集合注册它们。

post 配置有点违反规则,因为它们遵循配置方法运行,但它们之间的顺序也很重要。如果选项配置仍然不清楚,请使用此示例。我发现实验是学习和提高的最好方法之一。

项目–选项验证

另一个特性是在创建TOptions对象时运行验证代码。此代码保证在第一次创建选项时运行,并且不考虑后续的选项修改。如果生存期是短暂的,则每次获取选项对象时都会运行验证。如果其生存期是限定范围的,则它在每个范围内运行一次(最有可能是在每个 HTTP 请求中)。如果它的生存期是单例的,那么它将为每个应用运行一次。

为了验证我们的选项,我们可以创建实现IValidateOptions<TOptions>接口的验证类型,或者使用[Required]等数据注释。

数据注释

让我们从开始,使用System.ComponentModel.DataAnnotations类型用验证属性装饰我们的选项。为了演示这一点,让我们看两个小测试:

using System.ComponentModel.DataAnnotations;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Options;
using Xunit;
namespace OptionsValidation
{
    public class ValidateOptionsWithDataAnnotations
    {
        [Fact]
        public void Should_pass_validation()
        {
            var services = new ServiceCollection();
            services.AddOptions<Options>()
                .Configure(o => o.MyImportantProperty = "Some important value")
                .ValidateDataAnnotations();
            var serviceProvider = services.BuildServiceProvider();
            var options = serviceProvider.GetService <IOptionsMonitor<Options>>();
            Assert.Equal("Some important value", options.CurrentValue.MyImportantProperty);
        }
        [Fact]
        public void Should_fail_validation()
        {
            var services = new ServiceCollection();
            services.AddOptions<Options>()
                .ValidateDataAnnotations();
            var serviceProvider = services.BuildServiceProvider();
            var options = serviceProvider.GetService <IOptionsMonitor<Options>>();
            var error = Assert.Throws<OptionsValidationException>(() => options.CurrentValue);
            Assert.Collection(error.Failures,
                f => Assert.Equal("DataAnnotation validation failed for members: 'MyImportantProperty' with the error: 'The MyImportantProperty field is required.'.", f)
            );
        }
        private class Options
        {
            [Required]
            public string MyImportantProperty { get; set; }
        }
    }
}

从这些测试中,我们可以看到设置MyImportantProperty允许我们使用选项对象,而不设置它会抛出一个OptionsValidationException,提醒我们错误。

笔记

在撰写本文时,不可能进行即时验证(启动时失败),但正在考虑中。

因此,验证在第一次使用选项值时运行,而不是在注入选项时运行。例如,在IOptionsMonitor<TOptions>的情况下,在检索CurrentValue属性时运行验证。

为了让.NET 验证选项上的数据注释,我们必须调用ValidateDataAnnotations扩展方法,该方法可从Microsoft.Extensions.Options.DataAnnotations程序集中获得,如下所示:

services.AddOptions<Options>().ValidateDataAnnotations();

就这样–.NET 从那里为我们完成了任务。

验证类型

为了实现选项(选项验证器)的验证类型,我们可以创建一个实现一个或多个IValidateOptions<TOptions>接口的类。一个类型可以验证多个选项,多个类型可以验证相同的选项,因此可能的组合应该涵盖所有可能的用例。

使用自定义类并不比使用数据注释困难。然而,它允许我们将验证问题从 options 类本身移除,并编写更复杂的验证逻辑。你应该选择对你的项目更有意义的方式。

以下是如何通过代码执行此操作:

using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Options;
using Xunit;
namespace OptionsValidation
{
    public class ValidateOptionsWithTypes
    {
        [Fact]
        public void Should_pass_validation()
        {
            var services = new ServiceCollection();
            services.AddSingleton<IValidateOptions<Options>, OptionsValidator>();
            services.AddOptions<Options>()
                .Configure(o => o.MyImportantProperty = "Some important value");
            var serviceProvider = services.BuildServiceProvider();
            var options = serviceProvider.GetService <IOptionsMonitor<Options>>();
            Assert.Equal("Some important value", options.CurrentValue.MyImportantProperty);
        }
        [Fact]
        public void Should_fail_validation()
        {
            var services = new ServiceCollection();
            services.AddSingleton<IValidateOptions<Options>, OptionsValidator>();
            services.AddOptions<Options>();
            var serviceProvider = services.BuildServiceProvider();
            var options = serviceProvider.GetService <IOptionsMonitor<Options>>();
            var error = Assert.Throws<OptionsValidationException>(() => options.CurrentValue);
            Assert.Collection(error.Failures,
                f => Assert.Equal("'MyImportantProperty' is required.", f)
            );
        }
        private class Options
        {
            public string MyImportantProperty { get; set; }
        }
        private class OptionsValidator : IValidateOptions<Options>
        {
            public ValidateOptionsResult Validate(string name, Options options)
            {
                if (string.IsNullOrEmpty(options.MyImportantProperty))
                {
                    return ValidateOptionsResult.Fail ("'MyImportantProperty' is required.");
                }
                return ValidateOptionsResult.Success;
            }
        }
    }
}

正如您所看到的,这个与我们在前面的示例中使用的选项相同,没有数据注释,并且两个测试用例也非常相似。不同之处在于,我们没有使用[Required]属性,而是创建了OptionsValidator类,其中包含验证逻辑。

OptionsValidator实现IValidateOptions<Options>,只包含ValidateOptionsResult Validate(string name, Options options)方法。此方法允许验证命名选项和默认选项。name参数表示选项的名称。在我们的例子中,我们实现了所有选项所需的逻辑。ValidateOptionsResult类公开了一些成员来帮助我们,例如SuccessSkip字段,以及两个Fail()方法。

ValidateOptionsResult.Success表示成功。ValidateOptionsResult.Skip表示验证器没有验证选项,很可能是因为它只验证某些命名的选项,而不是给定的选项。对于失败,我们可以通过调用ValidateOptionsResult.Fail(message)ValidateOptionsResult.Fail(messages)以单个消息或消息集合失败。

下一步是在 IoC 容器中提供验证器。在我们的例子中,我们可以使用一个简单的services.AddSingleton<IValidateOptions<Options>, OptionsValidator>()调用来实现这一点,但我们也可以扫描一个或多个程序集以“自动”注册所有验证器

然后,与数据注释一样,当我们第一次使用选项时,将针对该实例执行验证。

直接注入选项

关于.NET 选项模式,我唯一的负面观点是我们需要将代码绑定到框架的接口,这意味着我们需要注入IOptionsMonitor<Options>而不是Options。我更愿意直接注入Options,从组合根控制其生存期,而不是让类本身控制其依赖项。我有点反控制狂,我知道。

碰巧我们可以用一个小技巧很容易地绕过这个问题。在这里,我们需要做两件事:

  1. 设置选项模式,如本章前面所示。
  2. 创建一个依赖项绑定,告诉容器如何直接注入我们想要的 options 类。

下面的代码执行与前面的示例相同的操作,并使用作用域来演示作用域:

using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Options;
using Xunit;
namespace OptionsValidation
{
    public class ByPassingInterfaces
    {
        [Fact]
        public void Should_support_any_scope()
        {
            var services = new ServiceCollection();
            services.AddOptions<Options>()
                .Configure(o => o.MyImportantProperty = "Some important value");

这里我们注册Options类,然后配置MyImportantProperty的默认值。

            services.AddScoped(serviceProvider => serviceProvider.GetService <IOptionsSnapshot<Options>>().Value);

在前面的代码块中,我们使用工厂方法注册了Options类。这样,我们就可以直接注入Options类(具有作用域生存期)。我们从该委托(突出显示的代码)控制创建和生存期。

瞧,我们现在可以直接将Options注入到我们的系统中,而无需将我们的类与任何特定于.NET 的选项接口绑定。接下来,我们测试一下:

            var serviceProvider = services.BuildServiceProvider();
            using var scope1 = serviceProvider.CreateScope();
            var options1 = scope1.ServiceProvider. GetService<Options>();
            var options2 = scope1.ServiceProvider. GetService<Options>();
            Assert.Same(options1, options2);

前面的代码断言从同一范围获取的两个实例是相同的。

            using var scope2 = serviceProvider.CreateScope();
            var options3 = scope2.ServiceProvider. GetService<Options>();
            Assert.NotSame(options2, options3);
        }

前面的代码断言来自两个不同作用域的选项不相同。

        private class Options
        {
            public string MyImportantProperty { get; set; }
        }
    }
}

最后,我们有了Options类,它允许我们编写这些测试。这里没有什么特别的。

对于现有系统来说,这也是一个很好的解决方案,假设系统DI 就绪,我们无需更新其代码即可从选项模式中获益。我们还可以使用此技巧编译不依赖于Microsoft.Extensions.Options的程序集。

结论

选项模式是一种注入选项的好方法,这样我们就可以配置我们的应用。我们看到我们可以在不同的选项中进行选择:

  • IOptionsMonitor<TOptions>IOptions<TOptions>适用于单身人士
  • IOptionsSnapshot<TOptions>适用于范围
  • IOptionsFactory<TOptions>用于瞬态

我们还介绍了配置和验证选项类的多种方法。总而言之,这些.NET 选项为我们提供了非常灵活的方法,可以将选项注入到我们的系统中,我强烈建议您今天就开始使用它们,如果您还没有使用它们的话。这可以帮助您测试您的系统,还可以更轻松地管理应用的更改。

熟悉.NET 日志摘要

.NET Core 对.NET 框架的另一个改进是它的日志抽象。新的统一系统不再依赖于第三方库,而是提供了干净的界面,这些界面由一种灵活而健壮的机制支持,有助于实现登录到应用。它还支持通过该抽象简化的第三方库。在我们更详细地研究实现之前,让我们先谈谈日志记录。

关于伐木

日志记录是将消息写入日志并对信息进行编目以备将来使用的实践。这些信息可用于调试错误、跟踪操作、分析使用情况,或创造性人员提出的任何其他理由。日志记录是一个交叉关注点,这意味着它适用于应用的每一部分。我们将在第 12 章理解分层中讨论层,但在此之前,我们只能说一个横切关注点影响所有层,不能集中在一个层中。

日志由日志条目组成。我们可以将每个日志条目视为程序执行期间发生的事件。然后将这些事件写入日志。此日志可以是文件、远程系统、简单的stdout或多个目标的组合。

在创建日志条目时,我们还必须考虑该日志条目的级别。在某种程度上,该级别表示我们要记录的消息的类型重要级别。它还可以用来过滤那些日志。TraceErrorDebug是日志条目级别的示例。这些级别在Microsoft.Extensions.Logging.LogLevel枚举中定义。

日志条目的另一个重要方面是它的结构。您可以记录单个字符串。团队中的每个人都可以用自己的方式记录单个字符串。但是当有人搜索信息时会发生什么呢?混乱接踵而至!有一种压力,就是找不到那个人要找的东西,还有那个人对原木结构的不满。解决这个问题的一种方法是使用结构化日志记录。它简单而复杂;您需要创建一个每个日志条目都遵循的结构。这种结构可能或多或少复杂。它可以序列化为 JSON。重要的是日志条目是结构化的。我们这里不讨论这个主题,但如果您必须决定日志策略,我建议您首先深入研究结构化日志。如果你是一个团队的一员,那么其他人可能已经这样做了。如果不是这样的话,你可以随时提出来。持续改进是生活的一个关键方面。

我们可以写一整本关于日志记录、最佳日志记录实践、结构化日志记录和分布式日志记录的书,但本章的目的是学习如何使用.NET 日志记录抽象。

写日志

首先,日志记录系统是基于提供商的,这意味着您必须注册一个或多个ILoggerProvider,如果您希望您的日志条目进入某个地方。默认情况下,调用Host.CreateDefaultBuilder(args)时,注册控制台调试事件源事件日志(仅限 Windows)提供程序,但此列表可以修改。如果需要,可以添加和删除提供程序。CreateDefaultBuilder还注册在应用中使用日志记录所需的依赖项。

在查看代码之前,让我们先了解如何创建日志条目,这是日志记录背后的目标。要创建条目,我们可以使用以下接口之一:ILoggerILogger<T>ILoggerFactory。让我们更详细地看一看:

  • ILogger是基础抽象。
  • ILogger<T>使用T自动创建日志类别
  • ILoggerFactory允许我们使用自定义类别名称创建ILogger。我们不会在这里探讨这个问题。

下面是更为常用的模式,包括注入ILogger<T>接口并在使用前将其存储在ILogger字段中,如下所示:

public class Service : IService
{
    private readonly ILogger _logger;
    public Service(ILogger<Service> logger)
    {
        _logger = logger ?? throw new ArgumentNullException(nameof(logger));
    }
    public void Execute()
    {
        _logger.LogInformation("Service.Execute()");
    }
}

在前面的代码中,Service类中有私有的ILogger _logger字段。我们注入一个存储在该字段中的ILogger<Service> logger,然后在Execute方法中使用该成员将信息级消息写入日志。

为了测试这一点,我加载了一个我创建的小库,它提供了额外的日志提供程序用于测试:

namespace Logging
{
    public class BaseAbstractions
    {
        [Fact]
        public void Should_log_the_Service_Execute_line()
        {
            // Arrange
            var lines = new List<string>();
            var args = new string[0];
            var host = Host.CreateDefaultBuilder(args)
                .ConfigureLogging(loggingBuilder =>
 {
 loggingBuilder.ClearProviders();
 loggingBuilder.AddAssertableLogger(lines);
 })
                .ConfigureServices(services =>
                {
                    services.AddSingleton<IService, Service>();
                })
                .Build();
            var service = host.Services. GetRequiredService<IService>();

在测试的Arrange阶段,我们创建了一些变量,配置了IHost,得到了一个我们想要测试的IService实例(代码在测试用例之后)。

在突出显示的代码中,我们使用ClearProviders方法删除了所有提供程序,然后从加载的库中使用AddAssertableLogger扩展方法添加新的提供程序。如果我们愿意,我们可以添加一个新的提供者,但我想向您展示如何删除现有的提供者,以便我们可以从头开始。这是你将来可能需要的东西。

笔记

我加载的库在 NuGet 上可用,名为ForEvolve.Testing.Logging,但这与理解日志抽象无关。

通常,IHost是在Program类中构建的,可以在Program类中定制。也可以在这里加载您选择的第三方库。既然我们已经讨论了这一点,让我们继续测试用例:

            // Act
            service.Execute();
            // Assert
            Assert.Collection(lines,
                line => Assert.Equal("Service.Execute()", line)
            );
        }

Act阶段,我们调用我们服务的Execute方法。这个方法应该记录一行(请参阅下面的代码)。然后,我们断言该行写入了lines列表中(这就是AssertableLogger所做的;也就是说,它写入了List<string>。接下来,我们有其他构建块:

        public interface IService
        {
            void Execute();
        }
        public class Service : IService
        {
            private readonly ILogger _logger;
            public Service(ILogger<Service> logger)
            {
                _logger = logger ?? throw new ArgumentNullException(nameof(logger));
            }
            public void Execute()
            {
                _logger.LogInformation("Service.Execute()");
            }
        }
    }
}

Service类是ILogger<Service>的简单使用者,并使用该ILogger。您可以对任何要添加日志记录支持的类执行相同的操作。通过该类的名称更改Service

总而言之,这个测试用例允许我们在 ASP.NET Core 5 中实现最常用的日志模式,并添加一个自定义提供程序,以确保我们记录了正确的信息。由于并非所有日志项都是相同的,我们接下来将研究日志级别

日志级别

我们使用LogInformation方法记录信息消息,但也有其他级别,如下表所示:

优化

在我领导的一个项目中,我们对使用 ASP.NETCore 记录简单和复杂消息的多种方法进行了基准测试,因为我们希望建立明确的指导原则。当消息被记录时,我们无法得出一个公平的结论(差异太大),但当消息没有记录时,我们观察到一个常数。基于这一结论,我建议使用以下构造来记录TraceDebug消息,而不是插值、string.Format或其他方式。以下是不写入日志项的最快方法:

_logger.LogTrace("Some: {variable}", variable);
// Or
_logger.LogTrace("Some: {0}", variable);

当日志级别被禁用时,例如生产中的Trace,您只需支付方法调用的费用,因为没有对您的日志条目进行处理。另一方面,如果我们使用插值,处理就完成了,因此一个参数被传递给Log[Level]方法,导致每个日志条目的处理能力成本更高。

日志记录提供程序

为了让您了解可能的内置日志提供商,以下是官方文档中的列表(请参阅本章末尾的进一步阅读部分):

  • 安慰
  • 调试
  • 事件源
  • 事件日志(仅限 Windows)
  • 应用照明

以下是第三方日志提供商的列表,也来自官方文档:

  • 埃尔玛·伊奥
  • 盖尔夫
  • JSNLog
  • KissLog.net
  • Log4Net
  • 伐木工人
  • NLog
  • 哨兵
  • 塞里洛格
  • 堆垛机

现在,如果您需要其中任何一个,或者您最喜欢的日志库是前面列表的一部分,那么您知道可以使用它。如果不是,可能它支持 ASP.NET Core 5,但在我查阅它时,它不是文档的一部分。

配置日志记录

与大多数 ASP.NET Core 5 一样,我们可以配置日志记录。调用Host.CreateDefaultBuilder(args)时创建的默认主机为我们做了很多事情。正如我们前面看到的,它注册了许多配置提供程序,并且还加载了配置的Logging部分。默认情况下,appsettings.json文件中存在该节。与所有配置一样,它是累积的,因此我们可以在另一个配置中重新定义它的一部分。如果不使用默认生成器,则必须自己配置日志记录;请注意所涉及的额外工作。

我不想在这方面花费太多的页面,但很高兴知道您可以自定义您正在记录的内容的最低级别。您还可以使用转换文件(如appsettings.Development.json)根据环境自定义这些级别。例如,您可以在appsettings.json中定义默认值,然后在appsettings.Development.json中更新一些用于开发的设置,在appsettings.Production.json中更改生产设置,然后在appsettings.Staging.json中更改暂存设置,在appsettings.Testing.json中添加一些测试设置。

在我们继续之前,让我们看一下默认设置:

{
  "Logging": {
    "LogLevel": {
      "Default": "Information",
      "Microsoft": "Warning",
      "Microsoft.Hosting.Lifetime": "Information"
    }
  }
}

我们可以为每个类别定义默认级别(使用Logging:LogLevel:Default)和自定义级别(例如Logging:LogLevel:Microsoft)。然后,我们可以使用配置或代码按提供程序筛选要记录的内容。在配置中,我们可以将控制台提供程序的默认级别更改为Trace,如下所示:

{
  "Logging": {
    "LogLevel": {
      "Default": "Information",
      "Microsoft": "Warning",
      "Microsoft.Hosting.Lifetime": "Information"
    },
    "Console": {
 "LogLevel": {
 "Default": "Trace"
 }
 }
  }
}

我们保留了相同的默认值,但在Logging:Console部分(见突出显示的代码)添加了一个默认值LogLevel。我们可以在这里定义更多的设置,但这超出了本书的范围。

然后,我们可以使用AddFilter扩展方法中的一种,如下实验测试代码所示:

[Fact]
public void Should_filter_logs_by_provider()
{
    // Arrange
    var lines = new List<string>();
    var args = new string[0];
    var host = Host.CreateDefaultBuilder(args)
        .ConfigureLogging(loggingBuilder =>
        {
            loggingBuilder.ClearProviders();
            loggingBuilder.AddConsole();
            loggingBuilder.AddAssertableLogger(lines);
            loggingBuilder.AddxUnitTestOutput(_output);
            loggingBuilder.AddFilter<XunitTestOutputLoggerProvider>(
 level => level >= LogLevel.Warning
 );
        })
        .ConfigureServices(services =>
        {
            services.AddSingleton<IService, Service>();
        })
        .Build();
    var service = host.Services.GetRequiredService<IService>();
    // Act
    service.Execute();
    // Assert
    Assert.Collection(lines,
        line => Assert.Equal("[info] Service.Execute()", line),
        line => Assert.Equal("[warning] Service.Execute()", line)
    );
}
public class Service : IService
{
    //...
    public void Execute()
    {
        _logger.LogInformation("[info] Service.Execute()");
        _logger.LogWarning("[warning] Service.Execute()");
    }
}

在这里,我们添加了三个提供者:控制台和两个测试提供者——一个记录到列表,另一个记录到 xUnit 输出。然后,我们告诉系统从XunitTestOutputLoggerProvider中过滤掉所有不属于Warning的内容(参见突出显示的代码);其他供应商不受影响。

您现在有两个选项:

  • 密码
  • 配置

所有这些应该足以让你开始。

结论

日志记录非常重要,ASP.NET Core 为我们提供了各种独立于第三方库进行日志记录的方法,同时允许我们使用我们最喜欢的日志记录框架。我们可以自定义日志的编写和分类方式。我们可以使用零个或多个日志提供程序。我们还可以创建自定义日志记录提供程序。最后,我们可以使用配置或代码来过滤日志等等。

您必须记住的是最有用的日志记录模式:

  1. 注入一个ILogger<T>,其中T是记录器被注入的类的类型。T成为类别。
  2. 将该记录器的引用保存到private readonly ILogger字段中。
  3. 在方法中使用该记录器以使用适当的日志级别记录消息。

总结

.NETCore 添加了许多功能,例如配置和日志记录,这些功能现在是.NET5 的一部分。与旧的.NET Framework API 相比,新的 API 更好,提供了很多价值。大多数样板代码都不见了,几乎所有东西都是基于选择加入的。

选项允许我们从多个源加载和组合配置,同时通过简单的 C#对象在系统中轻松使用这些配置。它消除了web.config之前配置的麻烦,使其易于使用。创建自定义web.config节不需要更复杂的样板代码;只需在appsettings.json中添加一个 JSON 对象,告诉系统要加载哪个部分,应该是什么类型,瞧——您有自己的强类型选项!同样的简单性也适用于消费设置:注入所需的接口或类本身并使用它。有了这些,你就可以开始跑步了;不再有静态ConfigurationManager或其他难以测试的结构。

伐木也是一个很好的补充;它允许我们标准化日志记录机制,使我们的系统更易于长期维护。例如,如果您想要使用新的第三方库,甚至是定制库,您可以将提供者加载到您的Program中,整个系统将进行调整并开始使用它,而无需进行任何进一步的更改。这就是精心设计的抽象应该带给系统的东西。

本章结束本书以 ASP.NET Core 5 为中心的第二部分。在接下来的三章中,我们将探讨设计模式,以设计灵活和健壮的组件。

问题

让我们来看看几个练习题:

  1. IOptionsMonitor<TOptions>的寿命是多少?
  2. IOptionsSnapshot<TOptions>的寿命是多少?
  3. IOptionsFactory<TOptions>的寿命是多少?
  4. 我们可以同时将日志条目写入控制台和文件吗?
  5. 我们应该在生产环境中记录跟踪和调试级别的日志条目,这是真的吗?

进一步阅读

以下是我们在本章学到的一些链接:

九、结构模式

在本章中,我们将探讨著名的四人帮(GoF)的四种设计模式。这些是用于创建复杂、灵活和细粒度类的结构模式。

本章将介绍以下主题:

  • 实现 Decorator 设计模式
  • 实现复合设计模式
  • 实现适配器设计模式
  • 实现 Façade 设计模式

让我们开始吧!

实现装饰设计模式

Decorator 模式允许我们在运行时扩展对象,同时保持职责分离。这是一个简单但强大的模式。在本节中,我们将探讨如何以传统方式实现此模式,以及如何利用名为 Scrutor 的开源工具帮助我们使用.NET5 创建强大的 DI 就绪装饰器。

目标

装饰者的目标是在运行时扩展现有对象,而不更改其代码。此外,被修饰的对象不应该意识到它正在被修饰,这使得它成为需要进化的长寿命或复杂系统的最佳候选对象。此模式适用于所有尺寸的系统。

我经常使用这种模式来增加灵活性,并为程序创造适应性,几乎不需要任何成本。此外,小型类更容易测试,因此 Decorator 模式在混合中增加了易测试性,因此值得掌握。

Decorator 模式使得将单个职责封装到多个类中变得更容易,而不是将多个职责封装到单个类中。

设计

装饰器类必须同时实现和使用装饰的类正在实现的接口。让我们从一个非装饰性的类设计开始,一步一步地看:

Figure 9.1 – A class diagram representing the ComponentA class implementing the IComponent

图 9.1–表示实现 IComponent 接口的 ComponentA 类的类图

在上图中,我们有以下组件:

  • 调用IComponentOperation()方法的客户端。
  • ComponentA,实现IComponent接口。

这转化为以下序列图:

Figure 9.2 – A sequence diagram showing a consumer calling the Operation method of the ComponentA class

图 9.2–显示调用 ComponentA 类的操作方法的使用者的序列图

现在,假设我们想在ComponentA中添加一些新行为,但仅在某些情况下。在其他情况下,我们希望保持最初的行为。为此,我们可以选择 Decorator 模式并按如下方式实现它:

Figure 9.3 – Decorator class diagram

图 9.3–装饰器类图

我们没有修改ComponentA类,而是创建了DecoratorA,它也实现了IComponent。这样,Client就可以使用DecoratorA的一个实例而不是ComponentA,并且可以访问新的行为,而不会影响ComponentA的其他消费者。然后,为了避免重写整个组件,在创建新的DecoratorA实例时注入IComponent的实现(构造函数注入)。这个新实例存储在component字段中,由Operation()方法使用(隐式使用策略模式)。

我们可以这样翻译更新后的序列:

Figure 9.4 – Decorator sequence diagram

图 9.4–装饰器序列图

在上图中,Client不是直接调用ComponentA,而是调用DecoratorA,后者反过来调用ComponentA。最后,DecoratorA通过调用其私有方法进行后处理;也就是说,AddBehaviorA()

您实现行为更改或其内部状态的方式与模式无关,这就是我从类图中排除AddBehaviorA()方法的原因。然而,我在下一个中添加了它来澄清这个想法,因为我们在混合中添加了第二个装饰师,希望它更容易遵循。

在我们开始编写代码之前,为了向您展示 Decorator 模式的强大功能,请知道:我们可以链接 Decorator!因为我们的 decorator 依赖于接口(而不是实现),所以我们可以在DecoratorA内部注入另一个 decorator,比如说DecoratorB(反之亦然)。然后,我们可以创建一个无限的规则链来相互装饰,从而产生一个非常强大但简单的设计。

让我们看看下面的类图,它代表我们的链接示例:

Figure 9.5 – Decorator class diagram, including two decorators

图 9.5–装饰器类图,包括两个装饰器

在这里,我们创建了DecoratorB类,它看起来与DecoratorA非常相似,但有一个私有的AddBehaviorB()方法,而不是AddBehaviorA()。让我们来看看这个序列图:

图 9.6–两个嵌套装饰器的序列图

有了这些,我们开始看到装饰师的力量。在上图中,我们可以评估ComponentA的行为已经改变了两次,而Client却不知道。所有这些类都不知道链中的下一个IComponent。他们甚至不知道他们正在被装饰。他们只在计划中扮演自己的角色——仅此而已。

同样重要的是要注意,装饰器的力量在于它对接口的依赖,而不是对具体化的依赖,从而使其可重用。基于这一事实,我们可以交换DecoratorADecoratorB来颠倒新行为的应用顺序,而不必触及代码本身。

项目:添加行为

为了帮助可视化装饰器模式,让我们实现上一个示例,它添加了一些任意的行为。每个Operation()方法返回一个字符串,然后输出到响应流。这不是幻想,但它显示了这是如何工作的。

首先,让我们看看 Apple T0T 类、AuthT1 接口和 AuthT2R 类。Client使用IComponent接口,输出结果:

public class Client
{
    private readonly IComponent _component;
    public Client(IComponent component)
    {
        _component = component ?? throw new ArgumentNullException(nameof(component));
    }
    public Task ExecuteAsync(HttpContext context)
    {
        var result = _component.Operation();
        return context.Response.WriteAsync($"Operation: {result}");
    }
}
public interface IComponent
{
    string Operation();
}
public class ComponentA : IComponent
{
    public string Operation()
    {
        return "Hello from ComponentA";
    }
}

IComponent接口只说明一个实现应该有一个返回stringOperation()方法。Client类在创建时接受IComponent依赖项,并在稍后的ExecuteAsync()方法中使用它。ExecuteAsync()只是将Operation()的结果写入响应流。

现在,我们有了自己的 To.T0 类,让我们看看构图根:

public class Startup
{
    public void ConfigureServices(IServiceCollection services)
    {
        services
            .AddSingleton<Client>()
            .AddSingleton<IComponent, ComponentA>()
            ;
    }
    public void Configure(IApplicationBuilder app, IHostingEnvironment env, Client client)
    {
        app.Run(async (context) => await client.ExecuteAsync(context));
    }
}

ConfigureServices()方法中,我们告诉容器Client具有单态生存期。然后,我们将ComponentA注册为IComponent的实现,同样具有单态生存期。在Configure()方法中,我们注入一个Client实例,并在 HTTP 请求发生时调用其ExecuteAsync()方法—任何请求。

此时,运行应用(从任何 URL)将产生以下输出:

Operation: Hello from ComponentA

这是因为系统在Client构造函数中注入了ComponentA类型的IComponent接口实现,所以客户端输出Operation:,然后是ComponentA的结果,即Hello from ComponentA,从而得到最终结果。

装饰师 A

在这里,我们希望修改响应而不触摸ComponentA。为此,我们创建一个名为DecoratorA的装饰器,将Operation()结果包装成<DecoratorA>标记:

public class DecoratorA : IComponent
{
    private readonly IComponent _component;
    public DecoratorA(IComponent component)
    {
        _component = component ?? throw new ArgumentNullException(nameof(component));
    }
    public string Operation()
    {
        var result = _component.Operation();
        return $"<DecoratorA>{result}</DecoratorA>";
    }
}

DecoratorA取决于IComponent的实现。它在Operation()方法中使用该IComponent,并将其结果封装在类似 HTML(XML)的标记中。

现在我们有了一个 decorator,要更改程序,我们需要告诉 IoC 容器在注入IComponent接口时发送DecoratorA而不是ComponentA的实例。

DecoratorA应装修ComponentA;或者更准确地说,ComponentA应该注射到DecoratorA中。

为此,我们可以采取以下措施:

public void ConfigureServices(IServiceCollection services)
{
    services
        .AddSingleton<Client>()
        .AddSingleton<IComponent>(serviceProvider => new DecoratorA(new ComponentA()));
}

在这里,我们告诉 ASP.NET Core 注入一个DecoratorA实例,该实例在注入IComponent接口时装饰了一个ComponentA实例。运行应用时,我们应该在浏览器中看到以下结果:

Operation: <DecoratorA>Hello from ComponentA</DecoratorA>

笔记

您可能已经注意到这里有几个new关键字,但即使它不是很优雅,我们也可以在合成根目录中手动创建新实例,而不会影响应用的健康。稍后我们将学习如何去除其中一些。

装饰师 B

现在我们有了一个装饰器,是时候创建第二个装饰器来展示链接装饰器的力量了。

上下文:在某种程度上,我们需要再次包装该内容,但我们不想修改任何现有类。为了实现这一点,我们得出结论,创建第二个装饰器将是完美的,因此我们创建了以下DecoratorB类:

public class DecoratorB : IComponent
{
    private readonly IComponent _component;
    public DecoratorB(IComponent component)
    {
        _component = component ?? throw new ArgumentNullException(nameof(component));
    }
    public string Operation()
    {
        var result = _component.Operation();
        return $"<DecoratorB>{result}</DecoratorB>";
    }
}

它与DecoratorA非常相似,但类似 HTML 的标记是DecoratorB。这里重要且相似的部分是,我们总是依赖于IComponent抽象,而不是任何具体的类。这给了我们装饰任何IComponent的灵活性,这使我们能够链接装饰者。

要完成此示例,我们需要将合成根更新为以下内容:

public void ConfigureServices(IServiceCollection services)
{
    services
        .AddSingleton<Client>()
        .AddSingleton<IComponent>(serviceProvider => new DecoratorB(new DecoratorA(new ComponentA())));
}

现在,我们可以用DecoratorB来装饰DecoratorA,而DecoratorB又反过来装饰ComponentA。在运行应用时,我们应该看到以下输出:

Operation: <DecoratorB><DecoratorA>Hello from ComponentA</DecoratorA></DecoratorB>

瞧!这些修饰符允许我们修改ComponentA的行为,而不会对代码产生影响。然而,当我们实例化彼此内部的多个依赖项时,我们的合成根开始变得混乱。这可能会使我们的应用更难维护,代码也变得更难阅读。如果 decorator 也依赖于其他类,情况会更糟。

如前所述,您可以使用装饰器来更改对象的行为或状态;要有创意。例如,您可以创建一个查询远程资源的类(比如通过 HTTP),然后用一个小组件来修饰该类,该组件管理结果的内存缓存,从而限制到远程服务器的往返。然后,您可以创建另一个 decorator 来监视查询这些资源所需的时间,然后将其记录到某个地方。如果你想练习的话,这可能是一个很好的编码练习。

项目:使用 Scrutor 的装饰师

目标是简化我们创建的系统的组成。为了实现这一点,我们将使用Scrutor,这是一个开放源代码库,允许我们这样做。

我们需要做的第一件事是使用 VisualStudio 或 CLI 加载 NuGet 包。使用 CLI 时,运行以下命令:

dotnet add package Scrutor

Scrutor 安装完成后,可以使用IServiceCollection上的Decorate<TService, TDecorator>()扩展方法添加装饰器;这是一个奇妙的小工具。

通过使用 Scrutor,我们可以更新以下混乱行:

.AddSingleton<IComponent>(serviceProvider => new DecoratorB(new DecoratorA(new ComponentA())))

并将其转换为以下三条更优雅的线条:

services
    .AddSingleton<Client>()
 .AddSingleton<IComponent, ComponentA>()
 .Decorate<IComponent, DecoratorA>()
 .Decorate<IComponent, DecoratorB>()
    ;

好啊这里发生了什么事?

我们将ComponentA注册为IComponent的实现,具有单例生存期。

然后,通过使用 Scrutor,我们告诉 IoC 容器重写第一个绑定,并用一个实例DecoratorA来修饰已经注册的IComponentComponentA。然后,我们通过告诉 IoC 容器返回一个DecoratorB实例来覆盖第二个绑定,该实例修饰了IComponent的最后一个已知绑定(DecoratorA

结果与我们之前所做的相同,但现在用更优雅、更灵活的方式编写。IoC 容器注入具有单态生存期的以下等价物instance

var instance = new DecoratorB(new DecoratorA(new ComponentA()));

笔记

为什么我说它更优雅、更灵活?这是一个简单的例子,但如果我们开始向这些类添加其他依赖项,它可能很快就会变成一个复杂的代码块,这可能会变成维护噩梦,变得非常难以阅读,并手动管理生命周期。当然,如果系统很简单,您总是可以在不加载外部库的情况下手动实例化装饰器。使用方法封装系统某些部分的初始化也是一种选择。

只要有可能,保持代码简单。使用 Scrutor 是实现这一点的一种方法。代码简单性从长远来看是有帮助的,因为它更容易阅读和理解,即使对于其他人来说也是如此。始终考虑其他人可能会维护您的代码。

为了验证两个程序在有或没有 Scrutor 的情况下表现相同,以下集成测试将对两个项目运行并确保其正确性,请参见StartupTest.cshttps://net5.link/QzwS

[Fact]
public async Task Should_return_a_double_decorated_string()
{
    // Arrange
    var client = _webApplicationFactory.CreateClient();
    // Act
    var response = await client.GetAsync("/");
    // Assert
    response.EnsureSuccessStatusCode();
    var body = await response.Content.ReadAsStringAsync();
    Assert.Equal(
        "Operation: <DecoratorB><DecoratorA>Hello from ComponentA</DecoratorA></DecoratorB>", 
        body
    );
}

前面的测试向内存中运行的应用之一发送 HTTP 请求,并将服务器响应与预期值进行比较。由于两个项目都应该有相同的输出,所以该测试在DecoratorPlainStartupTestDecoratorScrutorStartupTest中都被重用。

Scrutor

您也可以使用 Scrutor(进行组装扫描 https://net5.link/xvfS ),允许您执行自动依赖项注册。这超出了本章的范围,但值得研究。Scrutor 允许您在更复杂的场景中使用内置的 IoC 容器,从而推迟了用第三方容器替换它的需要。

结论

Decorator 模式是最简单但最强大的设计模式之一。它在不修改现有类的情况下扩展现有类。此外,如果您不需要通过Y来修饰X的所有实例,那么您可以封装小的逻辑块,然后创建复杂的、细粒度的对象树来满足不同的需求;这甚至可以在运行时修改。

装饰图案帮助我们与坚实的原则保持一致,如下所示:

  • S:Decorator 模式建议创建小类,将行为添加到其他类中,从而分离责任。
  • O:装饰程序在不修改其他类的情况下向其他类添加行为,这几乎就是 OCP 的字面定义。
  • L:不适用。
  • I:通过遵循 ISP,应该可以很容易地为您的特定需求创建装饰器。如果您的接口太复杂,承担了太多的责任,那么使用 decorator 可能会更困难。很难创建一个装饰师,这是一个很好的迹象,表明设计出了问题。隔离良好的界面应易于装饰。
  • D:使用抽象是装饰师力量的关键。

实现复合设计模式

复合设计模式是另一种结构 GoF 模式,帮助我们管理复杂的对象结构。

目标

复合模式背后的目标是创建一个层次化的数据结构,您不需要将元素组与单个元素区分开来。您可以将其视为一种构建具有自我管理节点的图形或树的方法。

设计

设计简单明了;我们有组件复合材料。两者都实现了一个定义共享操作的公共接口。组件为单节点,复合组件的集合。让我们来看一个图表:

图 9.7——复合类图

在上图中,Client依赖于IComponent接口。通过这样做,它不知道它正在使用的实现;它可能是一个ComponentComposite。然后,我们有两个实现:

  • Component表示单个元素;一片叶子。
  • Composite表示IComponent的集合。Composite对象使用其子对象来管理层次结构的复杂性,方法是将流程的一部分委托给它们。

这三个部分组合在一起,形成复合设计模式。考虑到可以将CompositeComponent添加为其他Composite对象的子对象,因此可以毫不费力地创建复杂、非线性和自我管理的数据结构。

笔记

您不限于Component的一个实现和Composite的一个实现;根据您的用例,您可以根据需要创建任意数量的IComponent实现。然后,您可以混合和匹配它们,使您的数据结构符合您的需要。我们将在第 17 章ASP.NET Core 用户界面中探讨如何显示复杂复合。

项目:书店

让我们重温一下我们在第三章架构原则中建造的书店。

上下文:商店经营得很好,我们的小程序已经不够了。我们虚构的公司现在拥有多个商店,因此他们需要将这些商店划分为多个部分,并需要管理书籍集和单个书籍。在收集了几分钟的信息之后,我们意识到它们可以有一组集合、子集合,可能还有子集合,所以我们需要一个灵活的设计。

让我们使用复合模式来解决这个问题。我们旨在构建的用户界面如下所示:

图 9.8–在浏览器中呈现的书店项目用户界面

首先,让我们看一下接口,它是复合模式的主要构建块:

public interface IComponent
{
    void Add(IComponent bookComponent);
    void Remove(IComponent bookComponent);
    string Display();
    int Count();
    string Type { get; }
}

此接口定义了以下内容:

  • Add()Remove()子组件。
  • Display()当前组件。
  • Count()当前组件中可用的图书数量。
  • 知道组件的Type(显示在卡片的页脚中)。

从那里,我们需要组件。首先,让我们关注BookComposite类,它抽象了大部分复合逻辑:

public abstract class BookComposite : IComponent
{
    protected readonly List<IComponent> children;
    public BookComposite(string name)
    {
        Name = name ?? throw new ArgumentNullException(nameof(name));
        children = new List<IComponent>();
    }
    public string Name { get; }
    public virtual string Type => GetType().Name;
    protected abstract string HeadingTagName { get; }
    public virtual void Add(IComponent bookComponent)
    {
        children.Add(bookComponent);
    }
    public virtual int Count()
    {
        return children.Sum(child => child.Count());
    }
    public virtual string Display()
    {
        var sb = new StringBuilder();
        sb.Append("<section class=\"card\">");
        AppendHeader(sb);
        AppendBody(sb);
        AppendFooter(sb);
        sb.Append("</section>");
        return sb.ToString();
    }
    private void AppendHeader(StringBuilder sb)
    {
        sb.Append($"<{HeadingTagName} class=\"card-header\">");
        sb.Append(Name);
        sb.Append($"<span class=\"badge badge-secondary float-right\">{Count()} books</span>");
        sb.Append($"</{HeadingTagName}>");
    }
    private void AppendBody(StringBuilder sb)
    {
        sb.Append($"<ul class=\"list-group list-group-flush\">");
        children.ForEach(child =>
        {
            sb.Append($"<li class=\"list-group-item\">");
            sb.Append(child.Display());
            sb.Append("</li>");
        });
        sb.Append("</ul>");
    }
    private void AppendFooter(StringBuilder sb)
    {
        sb.Append("<div class=\"card-footer text-muted\">");
        sb.Append($"<small class=\"text-muted text-right\">{Type}</small>");
        sb.Append("</div>");
    }
    public virtual void Remove(IComponent bookComponent)
    {
        children.Remove(bookComponent);
    }
}

笔记

在本例中,为了关注复合模式,IComponent的实现处理其数据的呈现方式。然而,大多数时候,我不建议这样做。为什么?因为我们给这些类赋予了太多的责任,因为我们将它们与 HTML 语言紧密耦合。这使得组件更难重用。想想 SRP。我们将在后续章节中重新讨论这些概念并解决此问题。

BookComposite类实现了以下共享特性:

  • 儿童管理。

  • 设置复合对象的Name属性。

  • 自动查找其派生类的Type名称。

  • 计算孩子的数量(暗指孩子的孩子)。

  • Displaying the composite and its children. The protected abstract string HeadingTagName { get; } property is used to set a different heading tag per sub-class.

    笔记

    children.Sum(child =>``child.Count());表达式中使用 LINQSum()扩展方法可以替换更复杂的for循环和累加器变量。

现在,让我们来看一个更复杂的复合示例。通过创建多个类,我们可以明确我们的职责。在真实场景中,我们可能需要处理的不仅仅是名称和计数。此外,它还显示了复合模式的灵活性。

以下是代表我们书店的完整层次结构:

图 9.9–书店项目的继承层次结构

BookComposite项下,我们有以下内容:

  • Corporation,代表拥有多家门店的公司。然而,它不仅限于拥有商店;一家公司可以拥有其他公司和商店,或者任何其他IComponent的所有权。
  • Store,代表一家书店。
  • Section,这是书店的一部分,或是一类书籍。
  • Set,这是一套书,如三部曲。

所有这些都可以由任何IComponent组成,这使得它成为一个非常灵活的数据结构——在这种情况下可能甚至过于灵活。在我们继续之前,让我们来看看这些 Ty1 T1 子类的代码:

public class Corporation : BookComposite
{
    public Corporation(string name) : base(name) { }
    protected override string HeadingTagName => "h1";
}
public class Store : BookComposite
{
    public Store(string name) : base(name) { }
    protected override string HeadingTagName => "h2";
}
public class Section : BookComposite
{
    public Section(string name) : base(name) { }
    protected override string HeadingTagName => "h3";
}
public class Set : BookComposite
{
    public Set(string name, params IComponent[] books)
        : base(name)
    {
        foreach (var book in books)
            Add(book);
    }
    protected override string HeadingTagName => "h4";
}

如您所见,代码是简单明了的;子类继承自BookComposite,后者完成大部分工作,只需指定HeadingTagName属性的值。Set不同,允许我们在其构造函数中注入其他IComponent对象。这在以后组装树时会很方便(提示:一个书集包含多本书)。

我们复合模式实现的最后一部分是Book类:

public class Book : IComponent
{
    public Book(string title)
    {
        Title = title ?? throw new ArgumentNullException(nameof(title));
    }
    public string Title { get; set; }
    public string Type => "Book";
    public int Count() => 1;
    public string Display() => $"{Title} <small class=\"text-muted\">({Type})</small>";
    public void Add(IComponent bookComponent) => throw new NotSupportedException();
    public void Remove(IComponent bookComponent) => throw new NotSupportedException();
}

Book类有点不同,因为它不是其他对象的集合,而是单个节点。让我们看一下区别:

  • 它有一个Title属性,而不是NameIComponent接口中没有定义如何命名组件,所以我们可以随心所欲;在这种情况下,一本书有一个标题,而不是名字。
  • 它返回"Book"作为其Type属性的值。
  • 它告诉调用者,抛出异常不支持Add()Remove()操作。
  • 它的Count()方法总是返回 1,因为它是一本书;这是叶子。
  • Display()方法也简单得多,因为它只需要处理自己;没有孩子。

在我们进入程序之前,让我们先看看最后一部分,它是用来帮助封装数据结构的创建的。这不是复合模式的一部分,但是现在我们知道了工厂是什么,我们可以使用它来封装数据结构的创建逻辑。factory 界面如下所示:

public interface ICorporationFactory
{
    Corporation Create();
}

ICorporationFactory的默认具体实现是DefaultCorporationFactory,它创建了以下结构(可视化表示;参见https://net5.link/2Vqw 对于全尺寸图像):

图 9.10–DefaultCorporationFactory 类创建的数据的部分表示形式

如果我们仔细观察,我们可以看到结构是非线性的。有节、子节、集合和子集。如果我们愿意,我们甚至可以直接将书籍添加到商店中。整个结构是使用我们在DefaultCorporationFactory中的复合模型定义的。

为了简单起见,让我们关注 West Store 的小说部分:

图 9.11–West Store 数据的虚构部分

在西部商店里,我们有一个栏目,里面有一本虚构的书和一个叫做科幻小说的栏目。在科幻小说中,还有另一本虚构的书和一套名为《星球大战》的书。三个子集代表《星球大战》系列下的三个三部曲,显示了设计的灵活性。

让我们来看看在隔离中构建那个部分的代码。请随时查阅完整的源代码以获取完整的示例(https://net5.link/DD8e 。代码如下:

public class DefaultCorporationFactory : ICorporationFactory {
    public Corporation Create()
    {
        var corporation = new Corporation("My Huge Book Store Company!");
        corporation.Add(CreateEastStore());
        corporation.Add(CreateWestStore());
        return corporation;
    }
    private IComponent CreateWestStore()
    {
        var store = new Store("West Store");
        store.Add(CreateFictionSection());
        store.Add(CreateFantasySection());
        store.Add(CreateAdventureSection());
        return store;
    }
    private IComponent CreateFictionSection()
    {
        var section = new Section("Fiction");
        section.Add(new Book("Some alien cowboy"));
        section.Add(CreateScienceFictionSection());
        return section;
    }
    private IComponent CreateScienceFictionSection()
    {
        var section = new Section("Science Fiction");
        section.Add(new Book("Some weird adventure in space"));
        section.Add(new Set(
            "Star Wars",
            new Set(
                "Prequel trilogy",
                new Book("Episode I: The Phantom Menace"),
                new Book("Episode II: Attack of the Clones"),
                new Book("Episode III: Revenge of the Sith")
            ),
            new Set(
                "Original trilogy",
                new Book("Episode IV: A New Hope"),
                new Book("Episode V: The Empire Strikes Back"),
                new Book("Episode VI: Return of the Jedi")
            ),
            new Set(
                "Sequel trilogy",
                new Book("Episode VII: The Force Awakens"),
                new Book("Episode VIII: The Last Jedi"),
                new Book("Episode IX: The Rise of Skywalker")
            )
        ));
        return section;
    }
    // ...
}

我发现前面的代码非常详尽,这部分数据结构的创建非常清晰。现在我们已经阅读了工厂的部分代码,让我们回到复合模式,学习如何显示它。简而言之,我们只需要调用复合模型根节点的Display()方法,如下所示:

public class Startup
{
    public void ConfigureServices(IServiceCollection services)
    {
        services.AddSingleton<ICorporationFactory, 
        DefaultCorporationFactory>();
    }
    public void Configure(IApplicationBuilder app, IHostingEnvironment env, ICorporationFactory corporationFactory)
    {
        if (env.IsDevelopment())
        {
            app.UseDeveloperExceptionPage();
        }
        app.Run(async (context) =>
        {
            var compositeDataStructure = corporationFactory.Create();
 var output = compositeDataStructure.Display();
            context.Response.Headers.Add("Content-Type", "text/html; charset=utf-8");
            await context.Response.WriteAsync("[removed for brevity]");
            await context.Response.WriteAsync(output);
            await context.Response.WriteAsync("[removed for brevity]");
        });
    }
}

让我们来看看前面代码中所发生的事情:

  • ConfigureServices()中,我们告诉 IoC 容器将DefaultCorporationFactory类绑定到ICorporationFactory接口。
  • Configure()中,我们正在注入ICorporationFactory的实现以构建数据结构。
  • 在为每个请求执行的app.Run()中,我们显示数据结构。这是表示复合模式实现的使用者的部分。

现在,让我们更详细地分析一下app.Run()的内容:

  1. 我们使用注入的ICorporationFactory corporationFactory参数来创建数据结构。
  2. 我们调用Display()方法生成输出;突出显示的行。这就是合成魔法发生的地方。
  3. 最后,我们通过调用await context.Response.WriteAsync(output);将输出写入响应流。

复合模式允许我们在一个小方法调用中呈现复杂的数据结构。由于每个组件都以一种自主的方式处理自己,因此处理这种复杂性的负担就从消费者身上消除了。

在另一种情况下,我们可以使用数据,而不是盲目地显示数据;我们还可以实现一种浏览该数据或任何其他可能想到的用例的方法。在这个代码示例中,我特意添加了一点复杂性,以便我们可以在更复杂的场景中试验复合模式,同时尽可能远离代码中的无关细节。

我鼓励您使用现有的数据结构,以便理解模式。您还可以尝试添加一个Movie类来管理电影;书店必须使其活动多样化。您还可以将电影与书籍区分开来,这样客户就不会感到困惑。书店也可以有实体书和数字书。

如果在所有这些之后,您仍然在寻找更多,那么尝试从头开始构建一个新的应用,并使用复合模式来创建、管理和显示多级菜单。

结论

复合模式在构建、管理和维护复杂的非线性数据结构方面非常有效。它的力量主要在于它的自我管理能力。每个节点、组件或组合都负责自己的逻辑,几乎不给组合的使用者留下任何工作。

使用复合图案有助于我们通过以下方式遵循固体原则:

  • S:帮助将复杂数据结构中的多个元素划分为小类,以划分职责。
  • O:通过允许我们“混合和匹配”不同的IComponent实现,复合模式允许我们扩展数据结构,而不会影响其他现有类。例如,您可以创建一个实现IComponent的新类并立即开始使用它,而无需修改任何其他组件类。
  • L:不适用。
  • I:当单个项目执行只影响集合的操作时,复合模式可能会违反 ISP。
  • D:所有复合模式参与者都完全依赖于IComponent

实现适配器设计模式

适配器模式是另一种结构 GoF 模式,有助于使一个类的 API 适应另一个接口的 API。

目标

适配器的目标是插入一个不符合预期契约的组件,并对其进行调整,使其符合预期契约。当您无法更改适配器的代码或不想更改代码时,适配器会派上用场。

设计

将适配器想象为电源插座的通用适配器;通过将北美设备连接到适配器,然后将其连接到电源插座,可以将其连接到欧洲插座。适配器设计模式正是这样做的,但是对于 API 来说。

让我们从下图开始:

图 9.12–适配器类图

在上图中,我们有以下参与者:

  • ITarget,这是保存我们想要(或必须)使用的合同的接口。
  • Adaptee,这是我们想要使用的不符合ITarget的混凝土构件。
  • Adapter,将Adaptee类适配ITarget接口。

还有第二种实现适配器模式的方法,它意味着继承。如果您可以选择组合,那么就选择组合,但是如果您需要访问protected方法或Adaptee的其他内部状态,那么您可以选择继承,如下所示:

图 9.13–继承适配器的适配器类图

演员是一样的,但不是用Adaptee创作Adapter,而是Adapter继承了Adaptee。这使得Adapter同时成为AdapteeITarget

项目:迎宾员

上下文:我们已经编写了一个非常复杂的问候系统,我们希望在新的程序中重用它。但是,它的界面与新设计不匹配,我们无法修改它,因为其他系统使用该问候系统。

为了解决这个问题,我们决定应用适配器模式。这是外部迎宾员的代码(ExternalGreeter),以及新系统中使用的新接口(IGreeter)。此代码不得直接修改ExternalGreeter类,以防止在其他系统中发生任何破坏性更改:

public interface IGreeter
{
    string Greeting();
}
public class ExternalGreeter
{
    public string GreetByName(string name)
    {
        return $"Adaptee says: hi {name}!";
    }
}

接下来是如何调整外部迎宾员以满足最新要求:

public class ExternalGreeterAdapter : IGreeter
{
    private readonly ExternalGreeter _adaptee;
    public ExternalGreeterAdapter(ExternalGreeter adaptee)
    {
        _adaptee = adaptee ?? throw new ArgumentNullException(nameof(adaptee));
    }
    public string Greeting()
    {
        return _adaptee.GreetByName("System");
    }
}

在上述代码中,参与者如下所示:

  • IGreeter,表示ITarget。这是我们想要使用的接口。
  • ExternalGreeter,表示Adaptee。这是已经包含所有已编程和测试的逻辑的外部组件。这可能位于外部组件中,甚至可能使用 NuGet 加载。
  • ExternalGreeterAdapter,表示Adapter。适配器的工作就是在这里完成的:ExternalGreeterAdapter.Greeting()调用ExternalGreeter.GreetByName("System"),它实现了问候逻辑。

现在,我们可以调用IGreeter.Greeting()方法并得到ExternalGreeter.GreetByName("System")调用的结果。有了它,我们可以重用现有的逻辑。我们可以通过模拟IGreeter接口来测试任何IGreeter消费者,而不必关心ExternalGreeterAdapter类。

我必须承认,本例中的“复杂逻辑”非常简单,但我们在这里讨论的是适配器模式,而不是虚构的业务逻辑。现在,让我们来看看消费者:

public class Startup
{
    public void ConfigureServices(IServiceCollection services)
    {
        services.AddSingleton<ExternalGreeter>();
        services.AddSingleton<IGreeter, ExternalGreeterAdapter>();
    }
    public void Configure(IApplicationBuilder app, IHostingEnvironment env, IGreeter greeter)
    {
        app.Run(async (context) =>
        {
            var greeting = greeter.Greeting();
            await context.Response.WriteAsync(greeting);
        });
    }
}

在前面的代码中,我们通过指定每次请求IGreeter接口时都应提供相同的ExternalGreeterAdapter实例来编写我们的应用。我们还告诉容器在被请求时提供一个ExternalGreeter实例(在本例中,它被注入ExternalGreeterAdapter)。

然后,消费者(图中的客户机app.Run()委托人(参见突出显示的代码)。IGreeter作为Configure方法的参数注入。然后,它对注入的实例调用Greeting方法。最后,它将问候语输出到响应流。

下图显示了此系统中发生的情况:

图 9.14–迎宾系统顺序图

瞧!我们已将类调整为IGreeter接口,所需的工作量很小。

结论

适配器模式是另一种提供灵活性的简单模式。有了它,我们可以使用旧的或不合格的组件,而无需重写它们。当然,根据ITargetAdaptee接口的不同,您可能需要或多或少地编写Adapter类的代码。

现在,让我们了解适配器模式如何帮助我们遵循坚实原则:

  • S:适配器模式只有一个职责:使一个接口与另一个接口协同工作。
  • O:适配器模式允许我们修改被适配器的接口,而无需修改其代码。
  • L:对于适配器模式,继承不太重要,所以这一原则同样不适用。如果Adapter继承自Adaptee,则目标是更改其接口,而不是其行为,这应该符合 LSP。
  • I:因为我们可以将Adapter类视为ITarget的目的的一种手段,所以适配器模式直接取决于ITarget的设计,对其没有直接影响(好或坏)。对于这个原则,您唯一关心的是设计好ITarget,使其遵循 ISP,ISP 不是模式的一部分,而是使用模式的原因。
  • D:适配器模式只引入了ITarget接口的一个实现。即使适配器依赖于一个具体的类,它的目标也是通过将其调整到ITarget接口来打破对该外部组件的直接依赖。

实施立面设计模式

立面模式是另一种结构 GoF 模式,类似于适配器模式。它在一个或多个子系统之间创建墙(立面)。适配器和 façade 之间的最大区别在于,façade 不是将一个接口调整到另一个接口,而是简化了子系统的使用,通常是通过使用该子系统的多个类。

笔记

同样的想法也可以应用于屏蔽一个或多个程序,但在这种情况下,立面被称为网关——更多关于第 16 章微服务架构简介中的内容。

Façade 模式是一种非常有用的模式,可以适应多种情况。

目标

Façade 模式的目标是通过提供比子系统本身更易于使用的接口来简化一个或多个子系统的使用,从而保护消费者免受这种复杂性的影响。

设计

我们可以创建表示多个子系统的多个图表,但让我们在这里保持简单。请记住,您可以将下图所示的单个子系统替换为需要调整的任意多个子系统:

图 9.15–表示隐藏复杂子系统的 Façade 对象的类图

正如我们所看到的,FaçadeClient和子系统之间起着中介作用,简化了它的使用。让我们将其视为一个序列图:

图 9.16–表示与复杂子系统交互的立面对象的序列图

在上图中,Client调用Façade一次,Façade对不同的类进行多次调用。

有多种方式实现外观:

  • 不透明立面:在此表单中,Façade类位于子系统内部。子系统的所有其他类都有一个internal可见性修饰符。这样,只有子系统内的类才能与其他内部类交互,迫使使用者使用Façade类。
  • 透明立面:在形式中,类别可以有public修改器,允许消费者直接使用或使用Façade类别。这样,我们可以在子系统内部或外部创建Façade类。
  • 静态立面:此表单中Façade类为static。我们可以将静态立面实现为不透明透明。我不推荐这种方法,因为全局(static元素往往会限制灵活性和可测试性。

项目:立面

在本例中,我们将处理三个 C#项目:

  • 一个空的 ASP.NET Core 5 应用,正在使用路由公开四个 HTTP 端点。两个端点指向OpaqueFacadeSubSystem,另两个端点指向TransparentFacadeSubSystem。这是我们的Client,我们的消费者。
  • OpaqueFacadeSubSystem类库实现了一个不透明的外观
  • TransparentFacadeSubSystem类库实现了透明立面

*让我们从类库开始。

笔记

为了遵循坚实的原则,添加一些表示子系统元素的接口似乎是合适的。在接下来的章节中,我们将探讨如何组织抽象以使其更具可重用性,但目前,抽象和实现都在同一个程序集中。

不透明立面

在这个集合中,只有立面是公共的;所有其他类都是内部类,这意味着它们对外部世界是隐藏的。在大多数情况下,这并不理想;隐藏所有内容会降低子系统的灵活性和扩展难度。

但是,在某些情况下,您可能希望控制对内部 API 的访问。这可能是因为它们不够成熟,您不希望任何第三方依赖它们,或者是因为您认为适合您的特定用例的任何其他原因。

让我们先看一下以下子系统代码:

// An added interface for flexibility
public interface IOpaqueFacade
{
    string ExecuteOperationA();
    string ExecuteOperationB();
}
// A hidden component
internal class ComponentA
{
    public string OperationA() => "Component A, Operation A";
    public string OperationB() => "Component A, Operation B";
}
// A hidden component
internal class ComponentB
{
    public string OperationC() => "Component B, Operation C";
    public string OperationD() => "Component B, Operation D";
}
// A hidden component
internal class ComponentC
{
    public string OperationE() => "Component C, Operation E";
    public string OperationF() => "Component C, Operation F";
}
// The opaque façade using the other hidden components
public class OpaqueFacade : IOpaqueFacade
{
    private readonly ComponentA _componentA;
    private readonly ComponentB _componentB;
    private readonly ComponentC _componentC;
    // Using constructor injection
    internal OpaqueFacade(ComponentA componentA, ComponentB componentB, ComponentC componentC)
    {
        _componentA = componentA ?? throw new ArgumentNullException(nameof(componentA));
        _componentB = componentB ?? throw new ArgumentNullException(nameof(componentB));
        _componentC = componentC ?? throw new ArgumentNullException(nameof(componentC));
    }
    public string ExecuteOperationA()
    {
        return new StringBuilder()
            .AppendLine(_componentA.OperationA())
            .AppendLine(_componentA.OperationB())
            .AppendLine(_componentB.OperationD())
            .AppendLine(_componentC.OperationE())
            .ToString();
    }
    public string ExecuteOperationB()
    {
        return new StringBuilder()
            .AppendLine(_componentB.OperationC())
            .AppendLine(_componentB.OperationD())
            .AppendLine(_componentC.OperationF())
            .ToString();
    }
}

正如您所看到的,OpaqueFacade类直接与ComponentAComponentBComponentC耦合。提取任何internal接口都没有意义,因为子系统无论如何都是不可扩展的。我们本可以这样做以提供某种内部灵活性,但在这种情况下,这样做没有任何好处。

除了这个耦合,ComponentAComponentBComponentC分别定义了两个方法,它们返回一个描述其源的字符串。有了这些代码,我们就可以观察到发生了什么,以及最终结果是如何组成的。

OpaqueFacade还公开了两种方法,但每种方法都使用底层子系统的组件来编写不同的消息。这是立面的经典用法;façade 以或多或少复杂的方式查询其他对象,然后对结果进行处理,从而消除了调用方了解子系统的负担。

此外,为了针对 IoC 容器注册OpaqueFacadeSubSystem立面,我们需要一些“魔法”来克服internal可见性修饰符。为了解决这个问题,我添加了以下注册依赖项的扩展方法,正如我们在第 7 章深入研究依赖项注入中所探讨的。使用者应用可以访问扩展方法:

public static class StartupExtensions
{
    public static IServiceCollection AddOpaqueFacadeSubSystem(this IServiceCollection services)
    {
        services.AddSingleton<IOpaqueFacade>(serviceProvider 
            => new OpaqueFacade(new ComponentA(), new ComponentB(), new ComponentC()));
        return services;
    }
}

接下来是透明的外观实现。

透明立面

透明的外观是最灵活的外观类型,非常适合利用依赖注入的系统。这个实现与不透明的外观非常相似,但是public可见性修饰符改变了使用者访问类库元素的方式。对于这个系统,值得添加接口,以允许子系统的使用者在需要时扩展它。

首先,让我们看一下抽象:

namespace TransparentFacadeSubSystem.Abstractions
{
    public interface ITransparentFacade
    {
        string ExecuteOperationA();
        string ExecuteOperationB();
    }
    public interface IComponentA
    {
        string OperationA();
        string OperationB();
    }
    public interface IComponentB
    {
        string OperationC();
        string OperationD();
    }
    public interface IComponentC
    {
        string OperationE();
        string OperationF();
    }
}

该子系统的 API 与不透明立面相同。唯一的区别是我们如何使用子系统并扩展它(从消费者的角度)。实现也基本相同,但是类实现接口,并且是public;粗体元素表示已进行的更改:

namespace TransparentFacadeSubSystem
{
    public class ComponentA : IComponentA
    {
        public string OperationA() => "Component A, Operation A";
        public string OperationB() => "Component A, Operation B";
    }
    public class ComponentB : IComponentB
    {
        public string OperationC() => "Component B, Operation C";
        public string OperationD() => "Component B, Operation D";
    }
    public class ComponentC : IComponentC
    {
        public string OperationE() => "Component C, Operation E";
        public string OperationF() => "Component C, Operation F";
    }
    public class TransparentFacade : ITransparentFacade
    {
        private readonly IComponentA _componentA;
        private readonly IComponentB _componentB;
        private readonly IComponentC _componentC;
        public TransparentFacade(IComponentA componentA, IComponentB componentB, IComponentC componentC)
        {
            _componentA = componentA ?? throw new ArgumentNullException(nameof(componentA));
            _componentB = componentB ?? throw new ArgumentNullException(nameof(componentB));
            _componentC = componentC ?? throw new ArgumentNullException(nameof(componentC));
        }
        public string ExecuteOperationA()
        {
            return new StringBuilder()
                .AppendLine(_componentA.OperationA())
                .AppendLine(_componentA.OperationB())
                .AppendLine(_componentB.OperationD())
                .AppendLine(_componentC.OperationE())
                .ToString();
        }
        public string ExecuteOperationB()
        {
            return new StringBuilder()
                .AppendLine(_componentB.OperationC())
                .AppendLine(_componentB.OperationD())
                .AppendLine(_componentC.OperationF())
                .ToString();
        }
    }
}

在继续之前,请注意以下扩展方法也是为了简化子系统的使用而创建的。但是,这里定义的所有内容都可以被覆盖(不透明立面的情况并非如此):

public static class StartupExtensions
{
    public static IServiceCollection AddTransparentFacadeSubSystem(this IServiceCollection services)
    {
        services.AddSingleton<ITransparentFacade, TransparentFacade>();
        services.AddSingleton<IComponentA, ComponentA>();
        services.AddSingleton<IComponentB, ComponentB>();
        services.AddSingleton<IComponentC, ComponentC>();
        return services;
    }
}

如您所见,所有的new元素都消失了,并被简单的依赖项注册(在本例中为单例生存期)所取代。这些微小的差异为您提供了工具,可以重新实现子系统的任何部分,如果您愿意的话;有关示例,请参见以下小节。

除了这些差异,透明的立面与不透明的立面起着相同的作用,输出相同的结果。

节目

现在,让我们分析一下客户端,它是一个小型的 ASP.NETCore5 应用,将 HTTP 请求转发给 façades。

第一步是在合成根目录中注册依赖项,如下所示:

public void ConfigureServices(IServiceCollection services)
{
    services
        .AddRouting()
        .AddOpaqueFacadeSubSystem()
 .AddTransparentFacadeSubSystem()
        ;
}

笔记

使用这些扩展方法,应用根非常干净,很难知道我们在 IoC 容器中注册了两个子系统。这是保持代码有序和干净的好方法,尤其是在构建类库时。

现在所有的都已注册,我们需要做的第二件事是将这些 HTTP 请求路由到 façades。让我们先看一下代码:

public void Configure(IApplicationBuilder app, IOpaqueFacade opaqueFacade, ITransparentFacade transparentFacade)
{
    app.UseRouter(routeBuilder =>
    {
        routeBuilder.MapGet("/opaque/a", async context =>
        {
            var result = opaqueFacade.ExecuteOperationA();
            await context.Response.WriteAsync(result);
        });
        routeBuilder.MapGet("/opaque/b", async context =>
        {
            var result = opaqueFacade.ExecuteOperationB();
            await context.Response.WriteAsync(result);
        });
        routeBuilder.MapGet("/transparent/a", async context =>
        {
            var result = transparentFacade.ExecuteOperationA();
            await context.Response.WriteAsync(result);
        });
        routeBuilder.MapGet("/transparent/b", async context =>
        {
            var result = transparentFacade.ExecuteOperationB();
            await context.Response.WriteAsync(result);
        });
    });
}

在前面的块中(参见突出显示的代码),发生了以下情况:

  1. 依赖项被注入到Configure()方法中。
  2. 使用路由,我们定义了四条路由,每条路由将请求分配给 façade 的一个方法。

如果运行程序并导航到https ://localhost:9004/transparent/a,页面应显示以下内容:

Figure 9.17 – The result of executing the ExecuteOperationA method of the ITransparentFacade instance

图 9.17–执行 ITransparentFacade 实例的 ExecuteOperationA 方法的结果

发生的事情位于学员内部。它使用注入的ITransparentFacade服务并调用其ExecuteOperationA()方法,然后将result变量输出到响应流。

现在,让我们来定义ITransparentFacade是如何组成的:

  • ITransparentFacadeTransparentFacade的一个实例。
  • 我们在TransparentFacade类中注入IComponentAIComponentBIComponentC
  • 这些依赖关系分别是ComponentAComponentBComponentC的实例。

从视觉上看,发生以下流程:

图 9.18–消费者执行 ExecuteOperationA 方法时发生的调用层次结构表示

在上图中,我们可以看到立面的屏蔽效果,以及它如何让消费者的生活更轻松。这里,有一个电话而不是四个。

笔记

使用依赖注入最困难的部分之一是它的抽象性。如果您不确定所有这些组件是如何组装的,请在 VisualStudio 中添加一个断点(比如在var result = transparentFacade.ExecuteOperationA()行),并在调试模式下运行应用。从那里,进入每个方法调用。这将有助于你了解发生了什么。使用调试器查找具体类型及其状态有助于查找有关系统的详细信息或诊断错误。

要使用进入,您可以使用以下按钮或点击F11

Figure 9.19 – The Visual Studio Step Into (F11) button

图 9.19–Visual Studio 单步执行(F11)按钮

接下来,我们在不更改组件代码的情况下更新结果。

行动上的灵活性

现在,让我们看看增加了透明立面的灵活性。

上下文:我们想改变TransparentFacade类的行为。此时,端点transparent/b的结果如下:

Figure 9.20 – The result of executing the ExecuteOperationB method of the ITransparentFacade instance

图 9.20–执行 ITransparentFacade 实例的 ExecuteOperationB 方法的结果

我们希望将其更改为以下内容:

Figure 9.21 – The expected result once the change has been made

图 9.21–变更后的预期结果

我们也希望在不修改ComponentB的情况下实现这个结果。为此,我们执行了以下步骤:

  1. 创建以下类:

    public class UpdatedComponentB : IComponentB
    {
        public string OperationC() => "Flexibility";
        public string OperationD() => "Design Pattern";
    }
    
  2. 告诉 IoC 容器,如下所示:

    services
        .AddRouting()
        .AddOpaqueFacadeSubSystem()
        .AddTransparentFacadeSubSystem()
        .AddSingleton<IComponentB, UpdatedComponentB>()
    ;
    
  3. From there, if you run the program, you should see the desired result!

    笔记

    第二次添加依赖项会使容器解析该依赖项,从而覆盖第一次。但是,这两个注册仍然存在于服务集合中;例如,在IServiceProvider上调用GetServices<IComponentB>()将返回两个依赖项。不要混淆GetServices()GetService()方法(复数与单数);一个返回集合,而另一个返回单个实例。该实例始终是最后一个注册的实例。

就这样!我们更新了系统,没有对其进行修改。这就是依赖注入在围绕它设计程序时可以为您做的事情。

另类立面模式

另一种选择是通过使用public可见性修饰符(所有接口)公开抽象,同时将实现隐藏在internal可见性修饰符下,从而在透明立面和不透明立面之间创建混合体。这种混合设计在控制和灵活性之间提供了恰当的平衡。

另一种选择是在子系统外部创建立面。在前面的示例中,我们在类库中创建了立面,但这不是强制性的;façade 只是一个类,它在您的系统和一个或多个子系统之间创建了一个可访问的墙。它应该放在你认为合适的地方。当您不控制子系统的源代码时(例如,如果您只能访问二进制文件),创建这样的外部外观将特别有用。这也可以用于在同一子系统上创建特定于项目的外观,为您提供额外的灵活性,而不会使子系统与多个外观混淆,从而将维护成本从子系统转移到使用它们的客户端应用。

这一个更像是一个注释,而不是一个备选方案:您不需要为每个子系统创建一个程序集。我这样做是因为它帮助我在示例中向您解释了不同的概念,但是您可以在同一个组件中创建多个子系统。您甚至可以创建一个包含所有子系统、立面和客户机代码(都在一个项目中)的单一程序集。

结论

Façade 模式便于简化消费者的生活,因为它允许您将子系统的实现细节隐藏在墙后面。它有多种口味;最突出的两个方面是:

  • 透明立面,通过暴露至少一部分子系统来提高灵活性。
  • 不透明立面,通过隐藏大部分子系统来控制访问。

现在,让我们看看透明立面模式如何帮助我们遵循坚实原则:

  • S:精心设计的透明立面通过隐藏过于复杂的子系统或内部实现细节,为其客户提供一套连贯的功能,从而达到了这一目的。
  • O:一个设计良好的透明立面及其底层子系统的组件可以在不直接修改的情况下进行扩展,正如我们在活动中的灵活性部分所看到的。
  • L:不适用。
  • I:通过公开使用不同较小对象实现小接口的立面,我们可以说分离是在立面和组件级别完成的。
  • D:Façade 模式没有指定任何关于接口的内容,因此由开发人员通过使用其他模式、原则和最佳实践来实施这一原则。

最后,让我们看看不透明立面图案如何帮助我们遵循实心原则:

  • S:精心设计的不透明立面通过隐藏过于复杂的子系统或内部实现细节,为客户提供一套连贯的功能,从而达到了这一目的。
  • O:通过隐藏子系统,不透明的立面限制了我们扩展它的能力。然而,我们可以实现一个混合外观来帮助实现这一点。
  • L:不适用。
  • I不透明的立面既不会帮助也不会削弱我们应用 ISP 的能力。
  • D:Façade 模式没有指定任何关于接口的内容,因此由开发人员通过使用其他模式、原则和最佳实践来实施这一原则。

总结

在本章中,我们介绍了多种基本的 GoF 结构设计模式。它们帮助我们从外部扩展系统,而不修改实际的类,通过动态组合对象图来实现更高程度的内聚。

我们从 Decorator 模式开始,该模式在运行时通过内部使用其他对象来扩展它们。装饰器也可以链接,允许更大的灵活性(装饰其他装饰器)。我们还使用了一个名为 Scrutor 的开源工具来简化装饰器与内置 ASP.NET Core 5 依赖项注入系统的使用。

然后,我们介绍了复合模式,它允许我们创建复杂而灵活的数据结构。为了让消费者的生活更轻松,组合将导航责任委托给每个组件。

之后,我们介绍了适配器模式,它允许我们将一个对象适配到另一个接口。当我们需要调整我们无法控制的外部系统的组件时,这种模式非常有用。

最后,我们深入研究了 Façade 模式,它与 Adapter 模式类似,但在子系统级别。它允许我们在一个或多个子系统前面创建一堵墙,从而简化其使用。它还可用于向其使用者隐藏子系统。

在下一章中,我们将探讨两种 GoF 行为设计模式:模板法和责任链设计模式。

问题

以下是一些修订问题:

  1. 我们可以用另一个装饰师来装饰一个装饰师吗?
  2. 说出复合模式的优点之一。
  3. 我们是否可以使用适配器模式将旧 API 迁移到新系统,以便在重写它之前调整它的 API?
  4. 我们为什么要使用立面?
  5. 适配器和立面模式之间有什么区别?

进一步阅读

十、行为模式

在本章中,我们将从著名的四人帮GoF中探索两种新的设计模式。这些是行为模式,这意味着它们有助于简化系统行为的管理。

本章将介绍以下主题:

  • 实现模板方法模式
  • 实施责任链模式
  • 如何混合两者

实现模板方法模式

模板方法是一种 GoF 行为模式,使用继承在基类及其子类之间共享代码。这是一个非常强大但简单的设计模式。

目标

模板方法模式的目标是将算法的概要封装在基类中,同时保留该算法的某些部分供子类修改。

设计

正如前面提到的,设计简单,但可扩展。首先,我们需要定义一个包含TemplateMethod()的基类,然后定义一个或多个需要由其子类(abstract实现的子操作,或者可以重写的子操作(virtual。使用 UML,它看起来像这样:

图 10.1–表示模板方法模式的类图

这是怎么回事?

  • AbstractClass实现共享代码:算法。

  • ConcreteClass实现算法的具体部分。

  • Client calls the TemplateMethod(), which calls the subclass implementation of one or more specific algorithm elements.

    笔记

    我们还可以从AbstractClass中提取接口,以允许更大的灵活性,但这超出了模板方法模式的范围。

现在让我们深入了解一些代码,看看模板方法模式的作用。

项目-建造搜索机

让我们从一个简单的经典示例开始,演示模板方法的工作原理。

上下文:我们希望根据要搜索的集合使用不同的搜索算法。对集合进行排序时,我们希望使用二进制搜索,但如果不是,则希望使用线性搜索。

让我们首先按类(参与者)分解完整的代码列表:

namespace TemplateMethod
{
    public class Startup
    {
        public void ConfigureServices(IServiceCollection services)
        {
            services.AddSingleton<SearchMachine>(x => new LinearSearchMachine(1, 10, 5, 2, 123, 333, 4));
            services.AddSingleton<SearchMachine>(x => new BinarySearchMachine(1, 2, 3, 4, 5, 6, 7, 8, 9, 10));
        }
        public void Configure(IApplicationBuilder app, IHostingEnvironment env, IEnumerable<SearchMachine> searchMachines)
        {
            app.Run(async (context) =>
            {
                context.Response.ContentType = "text/html";
                var elementsToFind = new int[] { 1, 10, 11 };
                await context.WriteLineAsync("<pre>");
                foreach (var searchMachine in searchMachines)
                {
                    var heading = $"Current search machine is {searchMachine.GetType().Name}";
                    await context.WriteLineAsync("".PadRight(heading.Length, '='));
                    await context.WriteLineAsync(heading);
                    foreach (var value in elementsToFind)
                    {
                        var index = searchMachine.IndexOf (value);
                        var wasFound = index.HasValue;
                        if (wasFound)
                        {
                            await context.WriteLineAsync($"The element '{value}' was found at index {index.Value}.");
                        }
                        else
                        {
                            await context.WriteLineAsync ($"The element '{value}' was not found.");
                        }
                    }
                }
                await context.WriteLineAsync("</pre>");
            });
        }
    }
    internal static class HttpContextExtensions
    {
        public static async Task WriteLineAsync(this HttpContext context, string text)
        {
            await context.Response.WriteAsync(text);
            await context.Response.WriteAsync (Environment.NewLine);
        }
    }

Startup类是消费者,即ClientHttpContextExtensions类是一个与模板方法无关的助手。在Startup类中,我们配置了两个SearchMachine服务(即AbstractClass。一个作为LinearSearchMachine类的实例,另一个作为BinarySearchMachine类的实例。两个实例都使用不同的数字集合进行初始化。

然后,我们将所有注册的SearchMachine服务注入Configure方法(突出显示的代码)。然后我们注册一个委托来处理 HTTP 请求。该处理程序迭代所有SearchMachine实例,并在输出结果之前尝试查找elementsToFind数组的所有元素。

接下来是AbstractClassSearchMachine类本身:

    public abstract class SearchMachine
    {
        protected int[] Values { get; }
        protected SearchMachine(params int[] values)
        {
            Values = values ?? throw new ArgumentNullException(nameof(values));
        }
        public int? IndexOf(int value)
        {
            var result = Find(value);
            if (result < 0) { return null; }
            return result;
        }
        public abstract int Find(int value);
    }

SearchMachine类表示AbstractClass。它公开了IndexOf()模板方法,该方法使用abstract``Find()方法表示的所需钩子(请参见突出显示的代码)。钩子是必需的,因为每个子类都必须实现该方法,从而使该方法成为必需的扩展点(或钩子)。

接下来,我们将探讨ConcreteClass类的第一个实现LinearSearchMachine类:

    public class LinearSearchMachine : SearchMachine
    {
        public LinearSearchMachine(params int[] values)
            : base(values) { }
        public override int Find(int value)
        {
            var index = 0;
            foreach (var item in Values)
            {
                if (item == value) { return index; }
                index++;
            }
            return -1;
        }
    }

LinearSearchMachine类是ConcreteClass类,表示SearchMachine使用的线性搜索实现。这是由Find方法实现的算法的一部分。

最后,我们进入BinarySearchMachine课程:

    public class BinarySearchMachine : SearchMachine
    {
        public BinarySearchMachine(params int[] values)
            : base(values) { }
        public override int Find(int value)
        {
            return Array.BinarySearch(Values, value);
        }
    }
}

BinarySearchMachine类是ConcreteClass类,代表SearchMachine的二进制搜索实现。正如您可能已经注意到的,我们跳过了二进制搜索算法的实现,将其委托给Array.BinarySearch内置方法。感谢.NET 团队!

重要的

要使二进制搜索算法工作,必须对集合进行排序。

现在我们已经定义了参与者并探索了代码,让我们看看client中发生了什么:

  1. client使用注册的SearchMachine实例并搜索一组值(1、10 和 11)。
  2. 完成后,client向用户显示是否找到了号码。

在这种情况下,当找不到值时,模板方法返回null,而实现类使用其Find方法返回负数。

通过运行程序,我们得到以下输出:

=============================================
Current search machine is LinearSearchMachine
The element '1' was found at index 0.
The element '10' was found at index 1.
The element '11' was not found.
=============================================
Current search machine is BinarySearchMachine
The element '1' was found at index 0.
The element '10' was found at index 9.
The element '11' was not found.

瞧!我们已经介绍了模板方法,就这么简单。我们可以在基类中添加一个或多个虚拟方法,这些方法将成为可选的扩展点,由子类实现或不实现,以增加灵活性。这将允许支持更复杂、更通用的场景。

结论

模板方法是一种功能强大且易于实现的设计模式,允许子类在实现(abstract或重写(virtual部分子部分时重用算法的框架。

现在,让我们看看模板方法模式如何帮助我们遵循坚实的原则:

  • S:模板方法将特定于算法的代码部分推送到子类,同时将核心算法保留在基类中。通过这样做,它遵循单一责任原则SRP),分配责任。
  • O:打开扩展钩子,打开扩展模板(允许子类扩展),关闭修改模板(不需要修改基类,因为子类可以扩展)。
  • L:由于子类是实现,没有基本行为来确保它们在子类中工作相同,所以在实现模板方法时遵循Liskov 替换原则LSP)应该不是问题。这就是说,这个原则是很棘手的,因此有可能创建一个子类(或子类的子类)来破坏 LSP,从而改变程序逻辑。在使用模板方法模式时,请注意这一原则。
  • I:只要基类实现尽可能最小的内聚面,使用模板方法模式就不会对程序产生负面影响。
  • D:模板方法是基于抽象的,所以只要消费者依赖于该抽象,就应该有助于符合依赖倒置原则DIP)。

现在我们将讨论责任链设计模式,以及如何将两者结合起来以改进我们的项目。

实施责任链模式

责任链是一种 GoF 行为模式,用于链接类以有效地处理复杂场景,只需付出有限的努力。同样,我们的目标是把一个复杂的问题分解成多个更小的单元。

目标

责任链模式背后的目标是链接多个处理程序,每个处理程序解决有限数量的问题。如果处理程序无法解决特定问题,它会将解决方案传递给链的下一个处理程序。可能有一个默认处理程序在链的末尾执行某些逻辑,例如抛出异常(例如,OperationNotHandledException),或者有一个处理程序确保相反的情况(换句话说,没有发生任何事情,特别是没有异常)。

设计

最基本的责任链始于定义一个处理请求的接口(IHandler。然后我们添加处理一个或多个场景的类(Handler1Handler2

图 10.2–表示责任链模式的类图

提示

创建责任链时,您可以对处理程序进行排序,以便请求最多的处理程序更靠近链的开头,请求最少的处理程序更靠近链的结尾。这有助于限制每个请求在到达正确的处理程序之前访问的“链链接”数量。

责任链和许多其他模式之间的一个巨大区别是没有中央调度员知道处理者;所有处理程序都是独立的。消费者收到一个处理程序并告诉它处理请求,因此不再复杂。每个处理程序也很简单,不管是否处理请求,然后将其传递给链中的下一个处理程序。足够的理论。让我们看一些代码。

项目-消息解释器

上下文:我们需要创建消息传递应用的接收端,每个消息都是唯一的,因此不可能创建一个单独的算法来处理所有消息。

在分析了问题之后,我们决定构建一个责任链,每个处理程序都可以管理一条消息。这个图案看起来非常完美!

出身背景

这个项目是基于我几年前建立的东西。由于带宽有限,物理(IoT)设备正在发送字节(消息)。然后,在 web 应用中,我们必须将这些字节与实际值关联起来。每条消息都有相同的标题,但正文不同。标头在基本处理程序(模板方法)中处理,链中的每个处理程序都在管理不同的消息类型。对于当前的示例,我们保持它比解析字节更简单,但概念是相同的。

对于我们的演示应用,消息如下所示:

public class Message
{
    public string Name { get; set; }
    public string Payload { get; set; }
}

Name属性用作鉴别器来区分消息,每个处理程序的职责是对Payload属性进行处理。我们不会对有效负载做任何事情,因为它与模式本身无关,但从概念上讲,这就是应该发生的事情。

处理程序也非常简单:

public interface IMessageHandler
{
    void Handle(Message message);
}

处理程序只能处理消息。我们的初始应用可以处理以下消息:

  • AlarmTriggeredHandler类处理AlarmTriggered消息。
  • AlarmPausedHandler类处理AlarmPaused消息。
  • AlarmStoppedHandler类处理AlarmStopped消息。

这三个处理程序非常相似,并且有很多相同的逻辑,但是我们稍后会解决这个问题。同时,我们有以下几点:

public class AlarmTriggeredHandler : IMessageHandler
{
 private readonly IMessageHandler _next;
    public AlarmTriggeredHandler(IMessageHandler next = null)
    {
        _next = next;
    }
    public void Handle(Message message)
    {
        if (message.Name == "AlarmTriggered")
        {
            // Do something cleaver with the Payload
        }
        else if (_next != null)
        {
            _next.Handle(message);
        }
    }
}
public class AlarmPausedHandler : IMessageHandler
{
 private readonly IMessageHandler _next;
    public AlarmPausedHandler(IMessageHandler next = null)
    {
        _next = next;
    }
    public void Handle(Message message)
    {
        if (message.Name == "AlarmPaused")
        {
            // Do something cleaver with the Payload
        }
        else if (_next != null)
        {
            _next.Handle(message);
        }
    }
}
public class AlarmStoppedHandler : IMessageHandler
{
 private readonly IMessageHandler _next;
    public AlarmStoppedHandler(IMessageHandler next = null)
    {
        _next = next;
    }
    public void Handle(Message message)
    {
        if (message.Name == "AlarmStopped")
        {
            // Do something cleaver with the Payload
        }
        else if (_next != null)
        {
            _next.Handle(message);
        }
    }
}

每个处理程序做两件事:

  1. 它允许将可选的“下一个处理程序”注入其构造函数(在代码中突出显示)。
  2. 它只处理它知道的请求,将其他请求委托给链中的下一个处理程序。

在这种情况下,如果下一个处理程序为 null,则不会发生任何事情。在实际场景中,您可能希望知道缺少处理程序或消息无效。让我们添加第四个处理程序,通知我们无效请求:

public class DefaultHandler : IMessageHandler
{
    public void Handle(Message message)
    {
        throw new NotSupportedException($"Messages named '{message.Name}' are not supported.");
    }
}

新的默认处理程序抛出一个异常,通知消费者有关错误的责任链。

笔记

我们可以创建自定义异常,以便更容易区分系统错误和应用错误。但有时,抛出一个系统异常就足够了。例如,这里有一些异常经常被抛出,就像NotSupportedExceptionNotImplementedExceptionArgumentNullException一样。

让我们使用Startup作为客户端,使用 HTTP 请求来构建消息。通常,使用GET方法读取数据,而使用POSTPUTPATCH等其他方法创建、替换或更新数据。出于测试目的,使用GET向我们的责任链发送任意数据更容易(不要在你的应用中这样做),因此我们在这一点上作弊:

public class Startup
{
    public void ConfigureServices(IServiceCollection services)
    {
        // Create the chain of responsibility, 
        // ordered by the most called handler (or the one 
        // that must be executed the faster)
        // to the less called handler (or the one that can
        // take more time to be executed), 
        // followed by the DefaultHandler.
        services.AddSingleton<IMessageHandler>(new AlarmTriggeredHandler(new AlarmPausedHandler(new AlarmStoppedHandler(new DefaultHandler()))));
    }
    public void Configure(IApplicationBuilder app, IHostingEnvironment env, IMessageHandler messageHandler)
    {
        app.Run(async (context) =>
        {
            var message = new Message
            {
                Name = context.Request.Query["name"],
                Payload = context.Request.Query["payload"]
            };
            try
            {
                // Send the message into the chain of responsibility
                messageHandler.Handle(message);
                await context.Response.WriteAsync($"Message '{message.Name}' handled successfully.");
            }
            catch (NotSupportedException ex)
            {
                await context.Response.WriteAsync (ex.Message);
            }
        });
    }
}

Startup中,我们通过将以下实例注册为IMessageHandler的单例,在ConfigureServices方法中手动创建责任链:

new AlarmTriggeredHandler(
    new AlarmPausedHandler(
        new AlarmStoppedHandler(
            new DefaultHandler())))

在该代码中,每个处理程序都被手动地注入到前面的构造函数中(使用new关键字创建)。

然后,在Configure方法中,我们注入IMessageHandler messageHandler实例,对于每个请求,我们执行以下操作:

  1. 根据查询字符串创建一个Message
  2. 将该消息传递给责任链的第一个处理程序(注入Configure方法):messageHandler.Handle(message);
  3. 在抛出NotSupportedException时写入错误消息,否则写入成功消息。

如果我们运行应用,我们将获得以下消息:

URL: https ://localhost:10001/
Messages named '' are not supported.

通过指定有效名称,如AlarmTriggered,我们应该得到以下结果:

URL: https ://localhost:10001/?name=AlarmTriggered
Message 'AlarmTriggered' handled successfully.

通过指定一个无效的名称,例如SomeUnhandledMessageName,我们应该得到以下结果:

URL: https ://localhost:10001/?name=SomeUnhandledMessageName
Messages named 'SomeUnhandledMessageName' are not supported.

瞧。我们建立了一个简单的责任链来处理信息。接下来,让我们使用模板方法和责任链模式来封装处理程序的重复逻辑。

项目-改进的消息解释器

既然我们已经了解了责任链和模板方法模式,现在是干燥处理程序的时候了,方法是使用模板方法模式将共享逻辑提取到抽象基类中,并为子类提供扩展点。

干的

我们在第三章架构原则中介绍了D关于RepeatY我们自己的原则。

好的,那么复制了什么?

  • next处理程序注入代码已经被复制,并且作为模式的重要部分,可以封装到基类中。
  • 测试当前处理程序是否可以处理消息的逻辑已被复制。

新的基类如下所示:

public abstract class MessageHandlerBase : IMessageHandler
{
    private readonly IMessageHandler _next;
    public MessageHandlerBase(IMessageHandler next = null)
    {
        _next = next;
    }
    public void Handle(Message message)
    {
        if (CanHandle(message))
        {
            Process(message);
        }
        else if (HasNext())
        {
            _next.Handle(message);
        }
    }
    private bool HasNext()
    {
        return _next != null;
    }
    protected virtual bool CanHandle(Message message)
    {
        return message.Name == HandledMessageName;
    }
    protected abstract string HandledMessageName { get; }
    protected abstract void Process(Message message);
}

基于这些变化,模板方法是什么,扩展点(钩子)是什么?

MessageHandlerBase类增加了Handle模板方法。该模板方法的算法比以前更易于阅读。然后,MessageHandlerBase公开了以下扩展点:

  • CanHandleMessage(Message message)测试HandledMessageName是否等于message.Name。如果处理程序需要更复杂的比较逻辑,则可以重写此操作。
  • HandledMessageName必须由所有子类实现,驱动CanHandleMessage的默认逻辑。
  • Process(Message message)必须由所有子类实现,允许它们针对消息运行逻辑。

现在让我们看一下三个简化的报警处理程序:

public class AlarmTriggeredHandler : MessageHandlerBase
{
    protected override string HandledMessageName => "AlarmTriggered";
    public AlarmTriggeredHandler(IMessageHandler next = null) : base(next) { }
    protected override void Process(Message message)
    {
        // Do something clever with the Payload
    }
}
public class AlarmPausedHandler : MessageHandlerBase
{
    protected override string HandledMessageName => "AlarmPaused";
    public AlarmPausedHandler(IMessageHandler next = null) : base(next) { }
    protected override void Process(Message message)
    {
        // Do something clever with the Payload
    }
}
public class AlarmStoppedHandler : MessageHandlerBase
{
    protected override string HandledMessageName => "AlarmStopped";
    public AlarmStoppedHandler(IMessageHandler next = null) : base(next) { }
    protected override void Process(Message message)
    {
        // Do something clever with the Payload
    }
}

正如我们从更新的报警处理程序中看到的,它们现在仅限于一项职责:处理它们可以处理的消息。相比之下,MessageHandlerBase现在处理责任链的管道。

通过混合这两种模式,我们创建了一个复杂的消息传递系统,将责任划分为处理程序。每个消息有一个处理程序,链逻辑被推送到基类中。这样一个系统的美妙之处在于,我们不必同时考虑所有的信息;我们可以一次只关注一条信息。在处理一种新类型的消息时,我们可以专注于该精确消息并实现其处理程序,而忽略N其他类型。消费者也可能是超级哑巴,在不知道责任链的情况下将请求发送到管道中,就像魔术一样,正确的处理者将占上风!

项目——最终的、更细粒度的设计

在上一个示例中,我们使用HandledMessageNameCanHandleMessage来确定处理程序是否可以处理请求。该代码有一个问题:如果子类决定重写CanHandleMessage,然后决定不再需要HandledMessageName,那么我们的系统中就会有一个挥之不去的、未使用的属性。

笔记

还有更糟糕的情况,但我们在这里讨论的是组件设计,所以为什么不把系统推向更好的设计呢。

解决此问题的一个解决方案是创建更细粒度的类层次结构,如下所示:

Figure 10.3 – Class diagram representing the design of the finer-grained project  that implements the Chain of Responsibility and Template Method patterns

图 10.3–表示实现责任链和模板方法模式的细粒度项目设计的类图

这看起来比实际情况要复杂得多,真的。在深入研究实际操作之前,让我们看看重构代码:

namespace FinalChainOfResponsibility
{
    public interface IMessageHandler
    {
        void Handle(Message message);
    }
    public abstract class MessageHandlerBase : IMessageHandler
    {
        private readonly IMessageHandler _next;
        public MessageHandlerBase(IMessageHandler next = null)
        {
            _next = next;
        }
        public void Handle(Message message)
        {
            if (CanHandle(message))
            {
                Process(message);
            }
            else if (HasNext())
            {
                _next.Handle(message);
            }
        }
        private bool HasNext()
        {
            return _next != null;
        }
        protected abstract bool CanHandle(Message message);
        protected abstract void Process(Message message);
    }
    public abstract class SingleMessageHandlerBase : MessageHandlerBase
    {
        public SingleMessageHandlerBase(IMessageHandler next = null)
            : base(next) { }
        protected override bool CanHandle(Message message)
        {
            return message.Name == HandledMessageName;
        }
        protected abstract string HandledMessageName { get; }
    }
    public abstract class MultipleMessageHandlerBase : MessageHandlerBase
    {
        public MultipleMessageHandlerBase(IMessageHandler next = null)
            : base(next) { }
        protected override bool CanHandle(Message message)
        {
            return HandledMessagesName.Contains (message.Name);
        }
        protected abstract string[] HandledMessagesName { get; }
    }
}

省略了AlarmPausedHandlerAlarmStoppedHandlerAlarmTriggeredHandler类,现在继承自SingleMessageHandlerBase而不是MessageHandlerBase,但其他内容没有改变。DefaultHandler也没有改变。出于演示目的,我添加了SomeMultiHandler,它模拟了一个可以处理"Foo""Bar""Baz"消息的消息处理程序。这一个看起来如下所示:

namespace FinalChainOfResponsibility
{
    public class SomeMultiHandler : MultipleMessageHandlerBase
    {
        public SomeMultiHandler(IMessageHandler next = null)
            : base(next) { }
        protected override string[] HandledMessagesName
            => new string[] { "Foo", "Bar", "Baz" };
        protected override void Process(Message message)
        {
            // Do something cleaver with the Payload
        }
    }
}

现在我们已经看到了类层次结构的代码和 UML 表示,让我们分析新结构的参与者:

  • MessageHandlerBase manages the chain of responsibility by handling the next handler logic and by exposing two hooks (Template Method pattern) for subclasses to extend:

    a) bool CanHandle(Message message)

    b) void Process(Message message)

  • SingleMessageHandlerBase继承MessageHandlerBase并实现overridebool CanHandle(Message message)方法。它实现了与之相关的逻辑,并添加了子类必须定义的abstract string HandledMessageName { get; }属性(override),以便CanHandle方法工作(一个必需的扩展点)。

  • SingleMessageHandlerBase的子类实现HandledMessageName属性,该属性返回它们可以处理的消息名称,并通过重写void Process(Message message)方法实现处理逻辑。

  • MultipleMessageHandlerBaseSingleMessageHandlerBase相同,但它使用字符串数组而不是单个字符串,支持多个处理程序名称。

这听起来可能很复杂,但我们所做的是允许扩展性,而不需要在过程中保留任何不必要的代码,让每个类都有一个单独的责任:

  • MessageHandlerBase手柄_next
  • SingleMessageHandlerBase处理处理程序的CanHandle方法,只支持一条消息。
  • MultipleMessageHandlerBase处理支持多条消息的处理程序的CanHandle方法。
  • 其他类必须实现其版本的void Process(Message message)

瞧!这是另一个示例,展示了模板方法和责任链模式组合在一起的优势。最后一个示例还强调了 SRP 的重要性,它允许更大的灵活性,同时保持代码的可靠性和可维护性。

这种设计的另一个优点是顶部的接口。任何不符合类层次结构的东西都可以直接从接口实现,而不是试图从不适当的结构中调整逻辑,欺骗代码来执行您的命令,通常会导致难以维护的半生不熟的解决方案。

结论

责任链是另一个伟大的 GoF 模式。它允许将一个大的问题划分为更小的有凝聚力的单元,每个单元只做一项工作:处理其特定的请求。与模板方法模式相结合,处理链会变得更加简单,使每个部分更接近单个职责。

现在,让我们看看责任链模式如何帮助我们遵循坚实的原则:

  • S:责任链正是针对这一原则,使其成为完美的 SRP 倡导者:单一逻辑单元!
  • O:责任链只通过改变链的组成,允许在不接触代码的情况下添加、删除和移动处理程序。
  • L:不适用。
  • I:通过创建具有多个处理程序(实现)的小型接口,责任链应该对 ISP 有所帮助。处理程序接口不限于单个方法;它可以公开多种方法,只要它们的目标是相同的责任。凝聚力是关键。
  • D:通过使用 handler 接口,链中的任何元素或消费者都不依赖于特定的 handler;它们只依赖于接口。

总结

在本章中,我们介绍了两种 GoF 行为模式。这些模式可以帮助我们创建一个灵活但易于维护的系统。正如其名称所述,行为模式旨在将应用行为封装到内聚的软件片段中。

首先,我们看一下模板方法,它允许我们将算法的核心封装在基类中。然后,它允许其子类“填补空白”,并在预定义的位置扩展该算法。这些位置可以是必需的(abstract)或可选的(virtual)。

然后,我们探索了责任链模式,它打开了将多个小处理程序链接到处理链中的可能性,在链的开头输入要处理的消息,并等待一个或多个处理程序针对该消息执行与该消息相关的实际逻辑。这是一个重要的细微差别:您不必在第一个处理程序处停止链的执行。在某些情况下,责任链可能更像一条管道,而不是一条消息与一个处理程序的明确关联。

最后,使用 TemplateMethod 模式封装责任链的链接逻辑使我们在不牺牲任何代价的情况下实现了一个更简单的实现。

在下一章中,我们将深入研究操作结果设计模式,以发现管理返回值的有效方法。

问题

让我们来看看几个练习题:

  1. 在实现模板方法设计模式时,我们是否只能添加一个abstract方法?
  2. 我们可以将策略模式与模板方法模式结合使用吗?
  3. 在责任链中有 32 个处理者的限制是真的吗?
  4. 在责任链中,多个处理程序能否处理相同的消息?
  5. 模板方法可以以何种方式帮助实现责任链模式?

十一、了解操作结果设计模式

在本章中,我们将从简单到更复杂的案例探索操作结果模式。操作结果旨在向调用者传达操作的成功或失败。它还允许该操作向调用者返回一个值和一条或多条消息。

想象一下,在任何系统中,您都希望显示用户友好的错误消息,获得一些小的速度增益,甚至可以轻松明确地处理故障。操作结果设计模式可以帮助您实现这些目标。使用它的一种方法是作为远程操作的结果,例如在查询远程 web 服务之后。

本章将介绍以下主题:

  • 操作结果设计模式基础
  • 返回值的操作结果设计模式
  • 返回错误消息的操作结果设计模式
  • 返回具有严重性级别的消息的操作结果设计模式
  • 使用子类和静态工厂方法更好地隔离成功和失败

目标

操作结果模式的作用是为操作(方法)提供返回复杂结果(对象)的可能性,这允许消费者:

  • [必须]访问操作的成功指示器(即操作是否成功)。
  • [可选]如果存在操作结果(方法的返回值),则访问操作结果。
  • [可选]在操作未成功的情况下,访问故障原因(错误消息)。
  • [可选]访问记录操作结果的其他信息。这可以是简单的消息列表,也可以是复杂的多个属性。

这可以更进一步,例如返回故障的严重性或为特定用例添加任何其他相关信息。成功指示器可以是二进制(truefalse,也可以有两种以上的状态,如成功、部分成功和失败。你的想象力(和需求)是你的极限!

提示

首先关注你的需求,然后运用你的想象力找到最好的解决方案。软件工程不仅仅是应用别人告诉你的技术。这是一门艺术!唯一的区别是你正在制作软件,而不是绘画或木工。而且大多数人不会看到这些艺术(代码)。

设计

当操作失败时,很容易依赖于抛出异常。但是,当您不想或不能使用异常时,操作结果模式是组件之间通信成功或失败的另一种方式。

为了有效地使用,方法必须返回一个包含目标部分中显示的一个或多个元素的对象。根据经验,返回操作结果的方法不应引发异常。通过这种方式,用户不必处理操作结果本身以外的任何事情。对于特殊情况,您可以允许抛出异常,但在这一点上,这将是一个基于明确规范的判断调用或面临实际问题。

在看了描述此模式最简单形式的基本序列图(适用于所有示例)后,让我们跳入代码并探索多个较小的示例,而不是遍历所有可能的 UML 图:

图 11.1–运行结果设计模式的序列图

正如我们从图中看到的,一个操作返回一个结果(一个对象),然后调用方可以处理该结果。下面的示例介绍了结果对象中可以包含的内容。

项目-实施不同的操作结果模式

在这个项目中,使用者将 HTTP 请求路由到正确的处理程序。我们正在逐个访问这些处理程序,这将帮助我们实现从简单到更复杂的操作结果。这将向您展示实现操作结果模式的许多可选方法,以帮助您理解它,使其成为您自己的模式,并根据项目的需要实现它。

消费者

所有示例中的消费者都是Startup类。以下代码将请求路由到处理程序:

app.UseRouter(builder =>
{
    builder.MapGet("/simplest-form", SimplestFormHandler);
    builder.MapGet("/single-error", SingleErrorHandler);
    builder.MapGet("/single-error-with-value", SingleErrorWithValueHandler);
    builder.MapGet("/multiple-errors-with-value", MultipleErrorsWithValueHandler);
    builder.MapGet("/multiple-errors-with-value-and-severity", MultipleErrorsWithValueAndSeverityHandler);
    builder.MapGet("/static-factory-methods", StaticFactoryMethodHandler);
});

接下来,我们逐一介绍每个处理程序。

最简单的形式

下图表示操作结果模式的最简单形式:

Figure 11.2 – Class diagram of the Operation Result design pattern

图 11.2–运行结果设计模式的类图

我们可以将该类图转换为以下代码块:

namespace OperationResult
{
    public class Startup
    {
        // ...
        private async Task SimplestFormHandler(HttpRequest request, HttpResponse response, RouteData data)
        {
            // Create an instance of the class that contains the operation
            var executor = new SimplestForm.Executor();
            // Execute the operation and handle its result
            var result = executor.Operation();
            if (result.Succeeded)
            {
                // Handle the success
                await response.WriteAsync("Operation succeeded");
            }
            else
            {
                // Handle the failure
                await response.WriteAsync("Operation failed");
            }
        }
    }
}

前面的代码处理/simplest-formHTTP 请求。它是操作的消费者。

namespace OperationResult.SimplestForm
{
    public class Executor
    {
        public OperationResult Operation()
        {
            // Randomize the success indicator
            // This should be real logic
            var randomNumber = new Random().Next(100);
            var success = randomNumber % 2 == 0;
            // Return the operation result
            return new OperationResult(success);
        }
    }
    public class OperationResult
    {
        public OperationResult(bool succeeded)
        {
            Succeeded = succeeded;
        }
        public bool Succeeded { get; }
    }
}

Executor类使用Operation方法实现要执行的操作。该方法返回OperationResult类的一个实例。实现基于随机数。有时成功,有时失败。您通常会在该方法中编写一些应用逻辑。

OperationResult类表示操作的结果。在本例中,它是一个简单的只读布尔值,存储在Succeeded属性中。

在这种形式中,Operation()方法返回boolOperationResult实例之间的差异很小,但仍然存在。通过返回一个OperationResult对象,您可以随着时间的推移扩展返回值,并将其添加到bool对象中,这在不更新所有使用者的情况下是无法实现的。

单一错误消息

现在我们知道手术是否成功,我们想知道哪里出了问题。为此,我们可以向OperationResult类添加一个ErrorMessage属性。通过这样做,我们不再需要设置操作是否成功;我们可以用ErrorMessage属性来计算。

这一改进背后的逻辑如下:

  • 没有错误消息时,操作成功。
  • 出现错误消息时,操作失败。

实现此逻辑的OperationResult如下所示:

namespace OperationResult.SingleError
{
    public class OperationResult
    {
        public OperationResult() { }
        public OperationResult(string errorMessage)
        {
            ErrorMessage = errorMessage ?? throw new ArgumentNullException(nameof(errorMessage));
        }
        public bool Succeeded => string.IsNullOrWhiteSpace(ErrorMessage);
        public string ErrorMessage { get; }
    }
}

在前面的代码中,我们有以下内容:

  • Two constructors:

    a) 处理成功的无参数构造函数。

    b) 将错误消息作为处理错误的参数的构造函数。

  • 检查ErrorMessageSucceeded属性。

  • 包含可选错误消息的ErrorMessage属性。

该操作的执行者看起来类似,但使用新的构造函数,设置错误消息,而不是直接设置成功指示器:

namespace OperationResult.SingleError
{
    public class Executor
    {
        public OperationResult Operation()
        {
            // Randomize the success indicator
            // This should be real logic
            var randomNumber = new Random().Next(100);
            var success = randomNumber % 2 == 0;
            // Return the operation result
            return success 
 ? new OperationResult() 
 : new OperationResult($"Something went wrong with the number '{randomNumber}'.");
        }
    }
}

消费代码与上一个示例中相同,但在响应输出中写入错误消息,而不是一般故障字符串:

namespace OperationResult
{
    public class Startup
    {
        // ...
        private async Task SingleErrorHandler(HttpRequest request, HttpResponse response, RouteData data)
        {
            // Create an instance of the class that contains the operation
            var executor = new SingleError.Executor();
            // Execute the operation and handle its result
            var result = executor.Operation();
            if (result.Succeeded)
            {
                // Handle the success
                await response.WriteAsync("Operation succeeded");
            }
            else
            {
                // Handle the failure
 await response.WriteAsync (result.ErrorMessage);
            }
        }
    }
}

在查看该示例时,我们可以开始理解操作结果模式的有用性。它使我们更远离简单的成功指标,它看起来像一个过于复杂的布尔值。这并不是我们探索的终点,因为可以在更复杂的场景中设计和使用更多的表单。

增加一个返回值

既然我们有了失败的原因,我们可能希望操作返回一个值。为了实现这一点,让我们从上一个示例开始,在OperationResult类中添加一个Value属性,如下所示(我们在第 17 章ASP.NET Core 用户界面中介绍了仅初始化属性

namespace OperationResult.SingleErrorWithValue
{
    public class OperationResult
    {
        // ...
        public int? Value { get; init; }
    }
}

操作也非常类似,但我们正在使用对象初始值设定项设置Value

namespace OperationResult.SingleErrorWithValue
{
    public class Executor
    {
        public OperationResult Operation()
        {
            // Randomize the success indicator
            // This should be real logic
            var randomNumber = new Random().Next(100);
            var success = randomNumber % 2 == 0;meet
            // Return the operation result
            return success
                ? new OperationResult { Value = 
 randomNumber }
                : new OperationResult($"Something went wrong with the number '{randomNumber}'.")
 {
 Value = randomNumber
 };
        }
    }
}

有了后,消费者可以按如下方式使用Value

namespace OperationResult
{
    public class Startup
    {
        // ...
        private async Task SingleErrorWithValueHandler (HttpRequest request, HttpResponse response, RouteData data)
        {
            // Create an instance of the class that contains the operation
            var executor = new SingleErrorWithValue.Executor();
            // Execute the operation and handle its result
            var result = executor.Operation();
            if (result.Succeeded)
            {
                // Handle the success
 await response.WriteAsync($"Operation succeeded with a value of '{result.Value} '.");
            }
            else
            {
                // Handle the failure
                await response.WriteAsync (result.ErrorMessage);
            }
        }
    }
}

从这个示例中我们可以看到,当操作失败时,我们可以显示相关的错误消息,当操作成功时(甚至在这种情况下失败时),我们可以使用返回值,所有这些都不会引发异常。有了这一点,操作结果模式的力量开始显现。我们还没有完成,所以让我们跳到下一个进化。

多条错误消息

现在我们已经到了可以将ValueErrorMessage传输给操作使用者的地步,但是传输多个错误(例如验证错误)呢?为此,我们可以将我们的ErrorMessage属性转换为IEnumerable<string>并添加管理消息的方法:

namespace OperationResult.MultipleErrorsWithValue
{
    public class OperationResult
    {
        private readonly List<string> _errors;
        public OperationResult(params string[] errors)
        {
            _errors = new List<string>(errors ?? Enumerable.Empty<string>());
        }
        public bool Succeeded => !HasErrors();
        public int? Value { get; set; }
        public IEnumerable<string> Errors => new ReadOnlyCollection<string>(_errors);
        public bool HasErrors()
        {
            return Errors?.Count() > 0;
        }
        public void AddError(string message)
        {
            _errors.Add(message);
        }
    }
}

让我们先看看前面的代码中的新部分,然后再继续:

  • 错误现在存储在List<string> _errors中,并通过IEnumerable<string>接口下隐藏的ReadOnlyCollection<string>实例返回给消费者。ReadOnlyCollection<string>实例拒绝从外部更改集合,例如,假设消费者足够聪明,可以将IEnumerable<string>转换为List<string>

  • Succeeded属性已更新,以说明集合而不是单个消息,并遵循相同的逻辑。

  • 为方便起见,增加了HasErrors方法。

  • The AddError method allows adding errors after the instance creation, which could happen in more complex scenarios, such as multi-step operations where parts could fail without the operation itself failing.

    笔记

    对于结果的额外控制,AddError方法应隐藏在操作外部。这样,使用者就不能在操作完成后向结果中添加错误。当然,所需的控制级别取决于每个特定场景。对此进行编码的一种方法是返回一个不包含该方法的接口,而不是返回包含该方法的具体类型。

现在更新了操作结果,操作本身可以保持不变,但我们可以在结果中添加多个错误。消费者必须处理这一细微差异,并支持多个错误。

让我们来看看这个代码:

namespace OperationResult
{
    public class Startup
    {
        private async Task MultipleErrorsWithValueHandler(HttpRequest request, HttpResponse response, RouteData data)
        {
            // Create an instance of the class that contains the operation
            var executor = new MultipleErrorsWithValue.Executor();
            // Execute the operation and handle its result
            var result = executor.Operation();
            if (result.Succeeded)
            {
                // Handle the success
                await response.WriteAsync($"Operation succeeded with a value of '{result.Value}'.");
            }
            else
            {
                // Handle the failure
 var json = JsonSerializer.Serialize (result.Errors);
                response.Headers["ContentType"] = "application/json";
                await response.WriteAsync(json);
            }
        }
    }
}

代码将IEnumerable<string> Errors属性序列化为 JSON,然后将其输出到客户端,以帮助可视化集合。

提示

当操作成功时返回一个plain/text字符串,当操作失败时返回一个application/json数组通常不是一个好主意。我建议不要在实际应用中执行类似的操作。返回 JSON 或纯文本。尽量不要在单个端点中混合内容类型。在大多数情况下,混合内容类型只会产生可避免的复杂性。我们可以说,读取content-type和状态码头就足以知道服务器返回了什么,这就是 HTTP 规范中这些头的用途。但是,即使这是真的,您的开发伙伴在使用您的 API 时总是能够期望相同的内容类型也要容易得多。

在设计系统契约时,一致性和一致性通常优于不一致性、模糊性和差异性。

我们的操作结果模式实现越来越好,但仍然缺少一些特性。这些特性之一是传播非错误消息的可能性,例如信息消息和警告,我们将在下一步实现这些功能。

增加消息严重性

既然我们的操作结果结构已经具体化,让我们更新我们的上一次迭代以支持消息严重性。

首先,我们需要一个严重性指标。安是这类工作的好人选,但可能是别的。让我们把它命名为OperationResultSeverity

然后我们需要一个消息类来封装消息和严重性级别;让我们把那个类命名为OperationResultMessage。新代码如下所示:

namespace OperationResult.WithSeverity
{
    public class OperationResultMessage
    {
        public OperationResultMessage(string message, OperationResultSeverity severity)
        {
            Message = message ?? throw new ArgumentNullException(nameof(message));
            Severity = severity;
        }
        public string Message { get; }
        public OperationResultSeverity Severity { get; }
    }
    public enum OperationResultSeverity
    {
        Information = 0,
        Warning = 1,
        Error = 2
    }
}

正如您所看到的,我们有一个简单的数据结构来替换我们的string消息。

然后我们需要更新OperationResult类以使用新的OperationResultMessage类。我们需要确保只有在没有OperationResultSeverity.Error的情况下,操作结果才显示成功,允许其传输OperationResultSeverity.InformationOperationResultSeverity.Warnings消息:

namespace OperationResult.WithSeverity
{
    public class OperationResult
    {
        private readonly List<OperationResultMessage> _messages;
        public OperationResult(params OperationResultMessage[] errors)
        {
            _messages = new List<OperationResultMessage> (errors ?? Enumerable.Empty <OperationResultMessage>());
        }
        public bool Succeeded => !HasErrors();
        public int? Value { get; init; }
        public IEnumerable<OperationResultMessage> Messages
            => new ReadOnlyCollection <OperationResultMessage>(_messages);
        public bool HasErrors()
        {
 return FindErrors().Count() > 0;
        }
        public void AddMessage(OperationResultMessage message)
        {
            _messages.Add(message);
        }
 private IEnumerable<OperationResultMessage> FindErrors()
 => _messages.Where(x => x.Severity == OperationResultSeverity.Error);
    }
}

高亮显示的行表示仅当_messages列表中不存在错误时设置成功状态的新逻辑。

有了这一点,Executor类也需要改进。

那么让我们看看Executor类的新版本:

namespace OperationResult.WithSeverity
{
    public class Executor
    {
        public OperationResult Operation()
        {
            // Randomize the success indicator
            // This should be real logic
            var randomNumber = new Random().Next(100);
            var success = randomNumber % 2 == 0;
            // Some information message
            var information = new OperationResultMessage(
                "This should be very informative!",
                OperationResultSeverity.Information
            );
            // Return the operation result
            if (success)
            {
                var warning = new OperationResultMessage(
                    "Something went wrong, but we will try again later automatically until it works!",
                    OperationResultSeverity.Warning
                );
                return new OperationResult(information, warning) { Value = randomNumber };
            }
            else
            {
                var error = new OperationResultMessage(
                    $"Something went wrong with the number '{randomNumber}'.",
                    OperationResultSeverity.Error
                );
                return new OperationResult(information, error) { Value = randomNumber };
            }
        }
    }
}

正如您可能已经注意到的,我们删除了第三个操作符,以使代码更易于阅读。

提示

您应该始终致力于编写易于阅读的代码。使用语言特性是可以的,但是将语句嵌套在单行语句之上有其局限性,很快就会变得一团糟。

在最后一个代码块中,成功和失败都返回两条消息:

  • 成功时,消息为信息消息和警告。
  • 如果失败,则消息为信息消息和错误。

从使用者的角度(请参阅下面的代码),我们现在只将结果序列化到输出,以清楚地显示结果。以下是使用此新操作的/multiple-errors-with-value-and-severity endpoint委托:

namespace OperationResult
{
    public class Startup
    {
        // ...
        private async Task MultipleErrorsWithValueAndSeverityHandler(HttpRequest request, HttpResponse response, RouteData data)
        {
            // Create an instance of the class that contains the operation
            var executor = new WithSeverity.Executor();
            // Execute the operation and handle its result
            var result = executor.Operation();
            if (result.Succeeded)
            {
                // Handle the success
            }
            else
            {
                // Handle the failure
            }
            var json = JsonSerializer.Serialize(result);
            response.Headers["ContentType"] = "application/json";
            await response.WriteAsync(json);
        }
    }
}

正如您所看到的,它仍然很容易使用,但现在增加了更多的灵活性。我们可以处理不同类型的消息,例如向用户显示消息、重试操作等等。

目前,如果运行应用并调用该端点,成功的调用将返回一个 JSON 字符串,如下所示:

{
    "Succeeded": true,
    "Value": 86,
    "Messages": [
        {
            "Message": "This should be very informative!",
            "Severity": 0
        },
        {
            "Message": "Something went wrong, but we will try again later automatically until it works!",
            "Severity": 1
        }
    ]
}

失败应返回如下所示的 JSON 字符串:

{
    "Succeeded": false,
    "Value": 87,
    "Messages": [
        {
            "Message": "This should be very informative!",
            "Severity": 0
        },
        {
            "Message": "Something went wrong with the 
             number '87'.",
            "Severity": 2
        }
    ]
}

改进此设计的另一个想法是添加一个Status属性,该属性根据每条消息的严重性级别返回复杂的成功结果。为此,我们可以创建另一个enum

public enum OperationStatus{ Success, Failure, PartialSuccess}

然后我们可以通过OperationResult类上名为Status的新属性访问它。有了这一点,消费者可以处理部分成功,而无需深入挖掘信息本身。我会让你自己玩这个。

现在我们已经将我们的简单示例扩展到这里,如果我们希望Value是可选的,会发生什么?为此,我们可以创建多个操作结果类,每个类都包含或多或少的信息(属性);让我们下一步试试。

子类和工厂

在这个迭代中,我们保留了所有的属性,但是我们改变了实例化OperationResult对象的方式。

一个静态工厂方法只不过是一个负责创建对象的静态方法。正如您将要看到的,它可以变得方便易用。和往常一样,我再怎么强调也不过分:在设计静态的东西时要小心,否则以后它可能会困扰你。

让我们从一些已经访问过的代码开始:

namespace OperationResult.StaticFactoryMethod
{
    public class OperationResultMessage
    {
        public OperationResultMessage(string message, OperationResultSeverity severity)
        {
            Message = message ?? throw new ArgumentNullException(nameof(message));
            Severity = severity;
        }
        public string Message { get; }
        public OperationResultSeverity Severity { get; }
    }
    public enum OperationResultSeverity
    {
        Information = 0,
        Warning = 1,
        Error = 2
    }
}

前面的代码与我们以前使用的代码相同。在下面的代码块中,计算操作的成功或失败结果时不考虑严重性。相反,我们创建了一个包含两个子类的抽象OperationResult类:

  • SuccessfulOperationResult,表示操作成功。
  • FailedOperationResult,表示操作失败。

然后下一步是通过创建两个静态工厂方法来强制使用专门设计的类:

  • public static OperationResult Success(),返回一个SuccessfulOperationResult
  • public static OperationResult Failure(params OperationResultMessage[] errors),返回一个FailedOperationResult

这样做将决定操作是否成功的责任从OperationResult类本身转移到Operation方法。

以下代码块显示了新的OperationResult实现(静态工厂突出显示):

namespace OperationResult.StaticFactoryMethod
{
    public abstract class OperationResult
    {
        private OperationResult() { }
        public abstract bool Succeeded { get; }
        public virtual int? Value { get; init; }
        public abstract IEnumerable<OperationResultMessage> Messages { get; }
 public static OperationResult Success(int? value = null)
 {
 return new SuccessfulOperationResult { Value = value };
 }
 public static OperationResult Failure(params OperationResultMessage[] errors)
 {
 return new FailedOperationResult(errors);
 }
        public sealed class SuccessfulOperationResult : OperationResult
        {
            public override bool Succeeded => true;
            public override IEnumerable <OperationResultMessage> Messages 
                => Enumerable.Empty <OperationResultMessage>();
        }
        public sealed class FailedOperationResult : OperationResult
        {
            private readonly List<OperationResultMessage> _messages;
            public FailedOperationResult(params OperationResultMessage[] errors)
            {
                _messages = new List<OperationResultMessage>(errors ?? Enumerable.Empty<OperationResultMessage>());
            }
            public override bool Succeeded => false;
            public override IEnumerable <OperationResultMessage> Messages
                => new ReadOnlyCollection <OperationResultMessage>(_messages);
        }
    }
}

在分析代码后,有两个密切相关的特殊性:

  • OperationResult类有一个私有构造函数。
  • SuccessfulOperationResultFailedOperationResult类都嵌套在OperationResult中并从中继承。

嵌套类是从OperationResult类继承的唯一方法,因为作为类的成员,嵌套类可以访问其私有成员,包括构造函数。否则,无法从OperationResult继承。

从本书开始,我已经多次重复了灵活性;但你并不总是想要灵活性。有时,您希望控制您公开的内容以及您允许消费者做的事情。

在这种特定的情况下,我们可以使用受保护的构造函数,或者我们可以实现一种更奇特的方法来实例化成功和失败实例。然而,我决定利用这个机会向您展示如何在适当的位置锁定实现,从而使它不可能通过从外部继承进行扩展。我们本可以在类中构建机制以允许受控的可扩展性,但对于这一个,让我们将其严格锁定!

从这里开始,唯一缺少的部分就是操作本身和使用该操作的客户端。让我们先看看操作:

namespace OperationResult.StaticFactoryMethod
{
    public class Executor
    {
        public OperationResult Operation()
        {
            // Randomize the success indicator
            // This should be real logic
            var randomNumber = new Random().Next(100);
            var success = randomNumber % 2 == 0;
            // Return the operation result
            if (success)
            {
                return OperationResult.Success(randomNumber);
            }
            else
            {
                var error = new OperationResultMessage(
                    $"Something went wrong with the number '{randomNumber}'.",
                    OperationResultSeverity.Error
                );
                return OperationResult.Failure(error);
            }
        }
    }
}

前面代码块中突出显示的两行显示了这一新改进的优雅。我发现这段代码很容易阅读,这是我的目标。我们现在有两种方法可以清楚地定义我们在使用它们时的意图:SuccessFailure

消费者使用的代码与我们之前在其他示例中看到的代码相同,因此我将在这里省略它。

利与弊

下面是操作结果设计模式的一些优点和缺点。

优势

它比抛出一个Exception更显式,因为操作结果类型被显式指定为方法的返回类型。这比知道操作及其依赖项可以引发什么类型的异常更为明显。

另一个优点是执行速度快;返回对象比引发异常更快。没那么快,但还是快了。

缺点

使用操作结果比抛出异常更复杂,因为我们必须手动将其传播到调用堆栈(也称为被调用方返回并由调用方处理)。如果操作结果必须上升到多个级别,则尤其如此,这可能是不使用模式的指示。

很容易公开并非在所有场景中都使用的成员,创建一个大于需要的 API 表面,其中某些部分仅在某些情况下使用。但是,在这和花费无数时间设计完美系统之间,有时暴露int? Value { get; }财产可能是一个更可行的选择。从那里,您可以选择将曲面缩小到最小。运用你的想象力和设计技巧来克服这些挑战!

总结

在本章中,我们访问了操作结果模式的多种形式,从增广布尔型到包含消息、值和成功指标的复杂数据结构。我们还探索了静态工厂和私有构造函数来控制外部访问。此外,在所有这些探索之后,我们可以得出结论,围绕操作结果模式几乎有无限的可能性。每个特定的用例都应该说明如何实现它。从这里开始,我相信您有足够的关于模式的信息,可以自己探索更多的可能性,我强烈鼓励您这样做。

此时,我们通常会探索操作结果模式如何帮助我们遵循坚实的原则。但是,它太依赖于实现,因此这里有几点:

  • OperationResult类封装结果,从其他系统的组件(SRP)中提取责任。
  • 在多个示例中,我们使用Value属性违反了 ISP。这是次要的,可以用不同的方法来完成,这可能会导致更复杂的设计。
  • 我们可以将操作结果视图模型DTO进行比较,但通过操作(方法)返回。从那个里,我们可以添加一个抽象,或者继续返回一个具体的类,我们可以认为这违反了 DIP。
  • 当优势超过了这两种违规行为的轻微和有限影响时,我不介意让它们溜走(原则是理想、规则,而不是法律)。

本章总结了组件级部分的设计,并引出了应用级部分的设计,我们将在其中探索更高层次的设计模式。

问题

让我们来看看几个练习题:

  1. 在执行异步调用(如 HTTP 请求)时返回操作结果是一个好主意吗?
  2. 我们使用静态方法实现的模式的名称是什么?
  3. 返回操作结果是否比引发异常更快?

进一步阅读

以下是我们在本章中学习的一些链接:

  • 我博客上的一篇关于异常的文章(标题:异常入门指南**基础知识https://net5.link/PpEm
  • 我博客上的一篇关于操作结果的文章(标题:操作结果**设计模式https://net5.link/4o2q
  • 我有一个操作结果模式的通用开源实现,允许您通过向项目中添加 NuGet 包来使用它:https://net5.link/FeGZ

十二、理解分层

在本章中,我们将探讨分层背后的固有概念。分层是一种通过将主要关注点封装到层中来组织计算机系统的流行方式。这些关注点与“计算机职业”如“数据访问”相关,而不是与“库存”等“业务关注点”相关。理解分层背后的概念至关重要,因为其他概念都是从中产生的。

我们将从探索分层背后的最初想法开始本章。然后,我们将探索可以帮助我们解决不同问题的替代方案。我们将同时使用一个贫血模型和一个丰富的模型,并揭示两者的优缺点。最后,我们将快速探索干净的架构,这就是我所说的分层演进。

本章将介绍以下主题:

  • 分层介绍
  • 公共层的责任
  • 抽象数据层
  • 共享富模型
  • 清洁架构

让我们开始吧!

分层介绍

现在我们已经探索了一些设计模式,并稍微使用了 ASP.NET Core 5,是时候跳入分层了。在大多数计算机系统中,都有层次。为什么?因为这是一种将逻辑单元划分和组织在一起的有效方法。我们可以在概念上将层表示为软件的水平块,每个层封装一个关注点。

让我们从一个经典的三层应用设计开始:

图 12.1–经典的三层应用设计

表示层表示用户可以通过交互到达的任何用户界面。在我们的例子中,它可能是一个 ASP.NETCore5Web 应用。然而,从 WPF 到 WinForms 再到 Android,任何东西都可能是有效的非 web 表示层替代方案。

域层代表业务规则驱动的核心逻辑;这是应用应该解决的问题的解决方案。域层也称为业务逻辑层(BLL)。

数据层代表数据和应用之间的桥梁。数据可以存储在 SQL Server 数据库、托管在别处的数据库、托管在云中的 NoSQL 数据库、所有这些数据库的组合,或者任何适合业务需要的其他数据库中。数据层也称为数据访问层(DAL)和持久层

让我们跳到一个例子。假设用户已经过身份验证和授权,下面是当他们想要在书店应用中创建一本书时发生的情况,该应用是使用这三个层构建的:

  1. 用户通过向服务器发送GET请求来请求页面。
  2. 服务器处理该GET请求(表示层,然后将页面返回给用户。
  3. 用户填写表单并向服务器发送POST请求。
  4. 服务器处理POST请求(表示层),然后发送到域层进行处理。
  5. 域层执行创建书籍例程,然后通知数据层保存数据。
  6. 然后,服务器将相应的响应返回给用户,很可能是一个包含书籍列表的页面和一条消息,告诉用户操作成功。

按照经典的分层架构,一个层只能与堆栈中的下一层对话。展示对话,域与数据对话,依此类推。重要的是每一层必须是独立的和隔离的,以限制紧密耦合

此外,每一层都应该有自己的模型。例如,视图模型不应该发送到;在那里只能使用域对象。反之亦然:将自己的对象返回给表示层,因此表示层不应该将它们泄漏给视图,而是将所需信息组织到视图模型中。

以下是一个可视示例:

图 12.2–表示各层如何相互作用的示意图

让我们从优势开始,分析分层的优缺点:

  • 了解层的用途可以很容易地理解其元素对应用的作用。例如,很容易猜测数据层的组件在某处读取或写入了一些数据。

  • 它创建了一个围绕单个关注点构建的内聚单元。例如,我们的数据层不应该呈现任何用户界面;它应该坚持访问数据。

  • 它允许我们将该层与系统的其余部分(其他层)解耦。您可以在不知道其他层的情况下隔离和使用层。例如,假设您的任务是优化数据访问层中的查询。在这种情况下,您不需要知道最终向用户显示数据的用户界面。您只需关注该元素,对其进行优化、单独测试,然后发布该层或重新部署应用。

  • Like any other isolated unit, it should be possible to reuse a layer. For example, we could reuse our data access layer in another application that needs to query the same database for a different purpose (a different domain layer).

    提示

    从理论上讲,某些层比其他层更容易重用,可重用性可以增加或多或少的价值,这取决于您正在构建的软件。我从未见过一个层在实践中被完整地重用,我也很少听说或读到有人这样做——每次都是以一种不那么可重用的情况结束。

    根据我的经验,我强烈建议不要过分追求可重用性,因为它不是一个能够为应用增加价值的精确规范。通过限制你过度工程化的努力,你和你的雇主可以节省大量的时间和金钱。我们决不能忘记,我们的工作是创造价值。

    根据经验,做需要做的事,不要做得更多,但要做好。

好,现在,让我们看看缺点:

  • 通过水平分割您的软件(分为多个层),每个功能跨越所有层。这通常会导致层之间的级联更改。例如,如果我们决定向书店数据库添加一个字段,我们需要更新数据库、访问它的代码(数据层、业务逻辑(域层)和用户界面(表示层)。对于不稳定的规格或低预算项目,这可能会变得很痛苦!新来者也可能很难实现跨越所有层的完整堆栈功能。
  • 由于一个层直接依赖于它下面的层,如果不引入抽象层或引用表示层的较低层,就不可能使用依赖注入。例如,如果域层依赖于数据层,则更改数据层将需要重写,从而重写从数据的所有耦合。暂时不要担心这个;这是一个相当容易克服的挑战。
  • 由于每个层都拥有其实体,因此添加的层越多,实体的副本就越多,从而导致性能损失。例如,表示层获取视图模型并将其复制到域对象。然后,域层将其复制到数据对象。最后,数据层将其转换为 SQL,以将其保存到数据库(例如 SQL Server)。从数据库读取数据时,情况也正好相反。

稍后,我们将探索克服其中一些缺点的方法。在此之前,我想指出,即使三层可能是最流行的层数,我们也可以根据需要创建任意数量的层;我们不限于三层。

我强烈建议你不要做我们刚刚探索过的事情。这是一种古老的、更基本的分层方式。在本章中,我们将看到我们可以对这个分层系统进行的多项改进,所以在得出结论之前,请继续阅读。我决定从一开始就探索分层,以防您必须使用这种应用。此外,研究它的时间演变,修复一些缺陷,并添加选项应该有助于您理解这些概念,而不仅仅是知道一种单一的做事方式。理解模式是软件架构的关键,而不仅仅是学习应用它们。

分层

现在我们已经讨论了层,并将其视为职责的大水平部分,我们可以通过垂直分割这些大的部分,创建多个较小的层,从而更细粒度地组织我们的应用。这可以帮助我们按功能或边界上下文组织应用。它还允许我们使用相同的构建块组合多个不同的用户界面,这比重用巨大的层要容易。

以下是这一想法的概念表示:

图 12.3–使用较小的部分共享层组织多个应用

我们可以将一个应用拆分为多个功能(垂直)并将每个功能划分为多个层(水平)。根据前面的图表,我们将这些功能命名为:

  • 库存管理
  • 网上购物
  • 其他

因此,我们可以将在线购物领域和数据层引入到我们的购物 web API 中,而无需将其他所有内容都带进来。此外,我们可以将在线购物域层引入移动应用,并将其数据层交换为另一个与 web API 对话的数据层。

我们还可以将我们的 web API 用作一个简单的数据访问应用,并附加不同的逻辑,同时将购物数据层保持在底层。

我们可能会得到以下重新组合的应用(这只是一种可能的结果):

Figure 12.4 – Organizing multiple applications using smaller partially shared layers

图 12.4–使用较小的部分共享层组织多个应用

这些只是我们可以在概念上使用层做的例子。但是,要记住的最重要的事情不是图表的布局方式,而是您正在构建的应用的规范。只有这些规格和良好的分析才能帮助您为该确切问题创建最佳设计。在这里,我使用了一个假设的购物示例,但它可能是任何东西。

通过垂直分割巨大的水平切片,每个片段都变得更易于重用和共享。这种改进可以产生有趣的结果,特别是如果您有多个前端应用或计划将来从单一应用迁移出去。

层与层与组件

到目前为止,在本章中,我们一直在讨论层,而没有讨论如何将层转换为代码。在我们开始讨论这个话题之前,我想先谈谈。你可能在以前的某个地方见过术语3 层架构,或者读过人们谈论,甚至可能在同义词相同的上下文中交换它们。然而,它们并不相同。让我们来看一看:

  • 物理层;每个都可以部署在自己的机器上。例如,您可以有一个数据库服务器、一个托管包含业务逻辑的 web API 的服务器(T7 域)和另一个服务于角度应用的服务器(表示);这是三个层次(三个不同的机器),每个层次可以独立扩展。
  • 逻辑;每个只是代码的逻辑组织,关注点以分层的方式组织和划分。例如,您可以在 VisualStudio 中创建一个或多个项目,并将代码组织为三个层。然后,部署应用,所有这些层一起部署在同一台服务器上。这将是一层和三层。

现在,我们已经讨论了关于 To.To.To.Tyl T1,让我们来看看一个 To.t2 层。您不必将图层拆分为不同的组件;您可以让三个层驻留在同一个组件中。当所有代码都在同一个项目中时,创建不需要的耦合可能会更容易,但由于一些严格性、一些规则和一些约定,这是一个可行的选择。也就是说,将每一层移动到一个组件并不一定会使应用更好;这些层(或程序集)中的每个层内的代码也可能混合在一起,并与系统的其他部分耦合在一起。

那么,什么是装配?

组件通常被编译成.dll.exe文件;您可以直接编译和使用它们。您还可以将它们部署为 NuGet 包,并从NuGet.org或您选择的自定义 NuGet 存储库中使用它们。但层与组件或层与组件之间不存在一对一的关系;程序集只是编译代码的可消耗单元:库或程序。

别误会我的意思;可以为每个图层创建组件。这样做没有错,但这样做并不意味着层之间没有紧密耦合。层只是组织的逻辑单元。

做还是不做一个纯粹主义者?

在您的日常工作中,您可能并不总是需要域层在您的数据前面创建一堵墙。也许你只是没有时间和金钱,或者这不值得去做。

获取数据并将其呈现出来通常可以很好地工作,特别是对于简单的数据驱动应用,这些应用只是数据库上的用户界面,就像许多内部工具一样。

“做还是不做一个纯粹主义者”问题的答案是,根据我的经验:这要视情况而定!

好啊你可能不喜欢我现在的回答,但我会尽我所能不让你失望,我会更具体。这取决于以下因素:

  • The project; for example:

    a) 领域密集型或逻辑密集型项目应受益于领域层,帮助您集中这些位,以提高可重用性和可维护性。

    b) 数据管理项目往往逻辑性较差。由于通常只是从表示数据的隧道,因此通常可以在不添加域层的情况下构建它们。这些系统通常可以简化为两层:数据表示

  • 你的团队;例如,一个高技能的团队可能倾向于更有效地使用先进的概念和模式,而由于团队中能够支持他们的经验丰富的工程师的数量,新手的学习曲线应该更容易。这并不意味着技能较低的团队应该瞄准较低的目标;相反,它可能只是更难或需要更长的时间开始。单独分析每个项目,并找到相应的最佳模式来驱动它们。

  • 你的老板;如果您所在的公司向您和您的团队施加压力,要求您在创纪录的时间内交付复杂的应用,而没有人告诉您的老板这是不可能的,那么您可能需要走捷径,并享受许多维护难题,如系统崩溃、部署困难等等。也就是说,如果这是不可避免的,对于这些类型的项目,我会选择一个非常简单的设计,它不以可重用性为目标——以低到平均水平的可测试性和只工作的代码为目标。我还建议您继续阅读并尝试垂直切片架构

  • 你的预算;同样,这通常取决于销售应用和功能的人员。我经常看到无法兑现的承诺,但无论如何都要付出很多努力、额外的时间和捷径才能兑现。当沿着这条路走下去的时候,要记住的是,在某个时刻,累积的技术债务并没有回报,它只会变得更糟。

  • 观众;使用该软件的人会对你构建它的方式产生重大影响:问问他们。例如,如果您正在为您的开发伙伴构建一个工具,那么您可能会在许多方面走捷径,而这些捷径对于其他技术水平较低的用户来说是无法做到的。另一方面,如果您的应用针对多个客户端(web、移动等),那么隔离应用的组件并关注可重用性可能是一个成功的设计。

  • 预期质量;根据您是在构建一个原型还是一个高质量的软件,您不应该以同样的方式解决问题。原型不进行测试也不遵循最佳实践是可以接受的,但我建议生产质量应用采用相反的方法。

  • Any other things that life throws at you; yes, life is unpredictable, and no one can cover every possible scenario in a book, so just keep the following in mind when building your next piece of software:

    a) 不要过度设计你的应用。

    b) 根据雅格尼原则您不需要它,只实现您需要的功能,而不是更多。

我希望你觉得这个列举足够好,并且在你职业生涯的某个阶段会有所帮助。现在,让我们进一步了解层以及如何在层之间共享模型。

共享模型

将模型从一层复制到另一层的另一种替代方法是在多个层之间共享一个模型,通常作为一个组件。从视觉上看,它是这样的:

Figure 12.5 – Sharing a model between all three layers

图 12.5–在所有三层之间共享模型

正如我们前面讨论的,每件事都有利弊。这样做有助于在开始时节省一些时间,而且随着项目的推进和变得更大、更复杂,很可能会成为一个难点。

假设您觉得共享一个模型对于您的应用来说是值得的。在这种情况下,您还可以在表示层使用视图模型DTO来控制并保持应用的输入和输出与域/数据模型的解耦。这可以表示为:

Figure 12.6 – Sharing a model between the domain and data layers

图 12.6-在域和数据层之间共享模型

通过这样做,您可以通过在域和数据层之间共享模型来节省一些时间。好的方面是,通过将共享模型隐藏在表示层下,从长远来看,您很可能会避开许多问题,从而在质量和开发时间之间达成了很好的折衷。此外,由于表示层将应用与外部世界隔离开来,因此可以在不影响用户的情况下重新编写或重写其他层。

视图模型DTO是成功程序和开发人员心智健全的关键要素;他们应该可以为长期运行的项目省去很多麻烦。在本节后面,我们将通过CQR重新讨论和探索控制输入和输出的概念,其中输入变成命令查询。同时,让我们探讨一下中小企业(SME)的现实情况。

中小企业的现实

数据驱动的程序是我在企业中经常看到的软件类型。每个公司都需要内部工具,许多公司昨天也需要。原因很简单,;每个公司都是独一无二的。由于其独特的商业模式、领导力或员工,它还需要独特的工具来帮助其日常操作。通常,这些小工具是数据库上的简单用户界面,控制对该数据的访问。在这些情况下,您不需要过度设计的解决方案,只要每个人都知道该工具的发展不会超出它的范围:一个小型工具。

在现实生活中,这一点很难向非程序员和经理解释。这是因为他们倾向于将复杂用例视为易于实现,而将简单用例视为难以实现。这是正常的;他们不知道,我们都不知道。我们工作的一大部分是教育人们。向决策者提供有关小型工具质量或大型业务应用质量差异的建议应该会有所帮助。通过教育和与利益相关者合作,他们可以了解情况并与您一起做出决定,从而提高项目质量,满足每个人的期望。这也可以从两方面减少“这不是我的错综合症”。

我发现,让客户(决策者)沉浸在决策过程中,让他们遵循开发周期,有助于他们了解项目背后的现实,也有助于双方保持快乐并变得更加满意。管理层得不到他们想要的并不比你在无法达到的最后期限上压力过大要好。

话虽如此,我们的教育部分并不以决策者而结束。向同事传授新的工具和技术也是提高团队、同事和自己的主要途径。解释概念并不总是像听起来那么容易。

然而,数据驱动的程序可能很难避免,特别是如果你是为中小企业工作的话,那么请尝试从中获得最佳效果。另一个小贴士是要记住,有一天,会有人来维护这些小工具。把那个人想象成你,想想你希望有什么指导方针或文件来帮助你。我也不是说要过度文档化项目,因为文档常常与代码不同步,成为一个问题而不是解决方案。然而,在项目根目录下的一个简单的README.md文件,解释如何构建程序、运行程序以及一些通用的指导方针,可能会非常有益。总是想着像这样的文档,你就是阅读它的人。大多数人不喜欢为了理解一些简单的东西而阅读一个又一个小时的文档,所以要保持简单。

在数据库上构建立面时,您希望保持简单。此外,您应该明确指出,它不应该超越该角色。一种方法是使用实体框架核心作为数据层,构建一个 MVC 应用作为表示层,屏蔽数据库。如果需要访问控制,您可以使用内置的身份验证和授权提供程序,使用属性、基于角色、基于策略或任何其他对您的工具有意义的方式,并允许您以需要的方式控制对数据的访问。

保持简单应该可以帮助您在更短的时间内构建更多的工具,让每个人都感到高兴。最有可能的是,提高你的非技术同事的生产力,这将为公司带来更多的利润,也可能带来繁荣,这将意味着你有更多的工作要做;这应该是一个双赢的局面。

从分层的角度来看,使用我前面的示例,最终将有两个层共享数据模型:

Figure 12.7 – A façade-like presentation layer over a database application's design

图 12.7–数据库应用设计上类似于立面的表示层

没有什么能阻止你为更复杂的视图到处创建一个视图模型,但关键是要将逻辑的复杂性保持在最低限度。否则,您可能会发现,有时从头开始重写程序比修复程序花费的时间要少。此外,没有什么能阻止您使用其他可用的演示工具和组件;我们将在本书后面探讨这个选项。

在开发主应用时将此数据驱动体系结构用作临时应用也是一个很好的解决方案。构建它只需要一小部分时间,用户可以立即访问它。您甚至可以从中获得一些反馈,这允许您在实际(未来)应用中实现错误之前修复任何错误。

现在,让我们进入一些代码。在完成本章的分层之前,我们有很多层次和技术需要探索。

公共层的职责

在本节中,我们将更深入地探索每个最常用的层。我们不会深入研究每一个问题,因为有无数的在线资源和其他涵盖这些问题的书籍。然而,我仍然想概述每一个,因为我希望,这将帮助您理解分层背后的基本思想。

我已经建立了一个小项目,探索分层背后的基本思想,并确保它易于遵循。该项目考虑到以下方面:

  • 上市产品。
  • 添加和删除库存(库存)。

我们将在本章和接下来的两章中反复讨论这个项目。此外,由于我们正在探索两种类型的域模型,解决方案包括以下项目(层):

  • 底部的共享数据层。
  • 贫血域层和依赖于该域层的呈现层。
  • 丰富的域层和依赖于该域层的表示层。

以下是这些层的视觉表示:

图 12.8–项目(层)关系的表示

两个表示层使用相同的 HTTP 端口,因此不能同时启动它们。我故意这么做是为了让 Postman 测试在两个应用上运行而不需要参数化。有关邮递员测试(的更多信息,请查看源代码的README.md文件 https://net5.link/code

笔记

两个表示层都是相同的,但一个使用富域层,而另一个使用贫血层。我本可以只创建一个,使用接口作为共享两个域层的技巧,甚至是预处理器指令,但这会导致更混乱的代码库。

由于数据层独立于域和表示层,因此可以在两个域层中重用它,这就是分层的要点。

介绍

表示层可能是最容易理解的层,因为它是我们能看到的唯一层:用户界面。但是,在 REST、OData、GraphQL 或其他类型的 web 服务的情况下,表示层也可以是数据契约。表示层是用户用来访问程序的层。

作为另一个示例,CLI 程序可以是表示层。您在终端中写入命令,CLI 将这些命令发送到其域层,该层执行所需的业务逻辑。

让我们从示例的第一次迭代开始。其中,我们有两个控制器和三个 HTTP 端点:

  • GET https ://localhost:12001/products列出所有产品。

  • POST https ://localhost:12001/products/{productId}/add-stocks将产品添加到库存中。

  • POST https ://localhost:12001/products/{productId}/remove-stocks removes products from the inventory.

    笔记

    两个演示项目之间唯一不同的行是一个using语句和根命名空间。一个项目为using AnemicDomainLayer;,另一个项目为using RichDomainLayer;(代码块中省略)。根命名空间分别为AnemicPresentationLayerRichPresentationLayer

让我们来探索StocksController课程,它比ProductsController更吸引我们:

namespace RichPresentationLayer.Controllers
{
    [ApiController]
    [Route("products/{productId}/")]
    public class StocksController : ControllerBase
    {
 private readonly IStockService _stockService;
        public StocksController(IStockService stockService)
        {
            _stockService = stockService ?? throw new ArgumentNullException(nameof(stockService));
        }

前面的代码在StocksController中注入IStockService,然后我们在下面两个代码块中使用它来执行业务逻辑,比如从库存中添加或删除库存:

        [HttpPost("add-stocks")]
        public ActionResult<StockLevel> Add(int productId, [FromBody]AddStocksCommand command)
        {
            var quantityInStock = _stockService.AddStock(productId, command.Amount);
            var stockLevel = new StockLevel(quantityInStock);
            return Ok(stockLevel);
        }

前面的代码使用IStockService接口,根据AddStocksCommandDTO 的值增加存货。然后使用StockLevelDTO 返回更新后的股票价值。两个 DTO 在remove-stock动作后的列表末尾描述:

        [HttpPost("remove-stocks")]
        public ActionResult<StockLevel> Remove(int productId, [FromBody]RemoveStocksCommand command)
        {
            try
            {
                var quantityInStock = _stockService.RemoveStock(productId, command.Amount);
                var stockLevel = new StockLevel(quantityInStock);
                return Ok(stockLevel);
            }
            catch (NotEnoughStockException ex)
            {
                return Conflict(new
                {
                    ex.Message,
                    ex.AmountToRemove,
                    ex.QuantityInStock
                });
            }
        }

除了使用RemoveStocksCommandDTO 并将更新后的库存量复制到StockLevel实例之外,Remove方法还包含一小段逻辑。当抛出一个NotEnoughStockException时,它将 HTTP 状态代码从200更改为409。我们在第 5 章中探讨了 HTTP 状态码,即 Web API的 MVC 模式。简而言之,它允许消费者了解错误的性质。

Exception映射到 HTTP 状态代码可能是控制器职责的一部分。但是,如果您希望集中化过滤器和中间件并避免管理每个控制器中的异常,则过滤器和中间件可以处理此类逻辑(有关更多信息,请参阅进一步阅读部分):

        public class AddStocksCommand
        {
            public int Amount { get; set; }
        }
        public class RemoveStocksCommand
        {
            public int Amount { get; set; }
        }
        public class StockLevel
        {
            public StockLevel(int quantityInStock)
            {
                QuantityInStock = quantityInStock;
            }
            public int QuantityInStock { get; set; }
        }
    }
}

最后,有两个输入 DTO 和一个输出 DTO。输入为AddStocksCommandRemoveStocksCommand,它们表示我们POST向控制器发送的数据(有关请求的更多信息,请参阅邮递员收集)。输出为StockLevel类,表示物料的当前库存水平。一旦我们更新了产品的数量,就会返回。

在前面的控制器代码中,我们可以看到表示层的作用。它从 HTTP 请求收集信息,并以域期望的方式格式化这些信息。域完成其工作后,会将结果转换回 HTTP。

我怎么强调都不过分:控制器应该保持精简,因为它们是 HTTP 和域层之间的简单转换器。以下是这方面的视觉表现:

Figure 12.9 – The flow of an HTTP request through a controller

图 12.9–通过控制器的 HTTP 请求流

控制器的职责是将 HTTP 请求映射到域,然后将域的输出映射回 HTTP。

现在,让我们看看 Apple T0.域层 AuthT1,看看这些调用在哪里。

域层是软件的价值所在;这也是大部分复杂性所在。域层是您业务逻辑规则的所在地。

不幸的是,销售用户界面比销售域层更容易。这是有意义的,因为这是用户连接到域的方式;这就是他们看到的。但是,作为一名开发人员,重要的是要记住,负责解决问题和自动化解决方案。表示层仅用于将用户的操作链接到

关于如何构建该层,有两大观点:

  • 使用丰富的模型。
  • 使用贫血模型。

无论您选择哪一个,域层通常是围绕域模型构建的。领域驱动设计DDD)可以用于构建该模型及其周围的程序。精心设计的模型应有助于简化程序的维护。执行 DDD 不是强制性的,没有 DDD 您可以达到所需的正确性级别。

另一个难题是是将域模型直接持久化到数据库中,还是使用中间数据模型。我们将在数据一节中更详细地讨论这一点,但我们现在使用的是单独的数据模型(增量步骤)。

回到域层,我们可以使用多年来出现的一种模式,这种模式得到越来越多的采用,使用服务层屏蔽域模型,将域层分为两个不同的部分。

服务层

服务层屏蔽域模型,封装域逻辑。服务层通常设计为高度可重用,协调与模型或外部资源(如数据库)的复杂交互。然后,多个组件可以在对模型了解有限的情况下使用服务层:

Figure: 12.10 – Service layer relationships with other layers

图:12.10–服务层与其他层的关系

基于前面的图,表示层服务层对话,后者管理域模型并实现业务逻辑。

服务层包含服务,服务是与域模型数据层域对象交互的类。

我们可以将服务分为两类:域服务应用服务

域服务就是我们在这里讨论的服务。它们包含域逻辑并允许使用者读取或写入数据。它们访问并改变域模型。

应用服务是与域无关的服务,如发送电子邮件的类/接口[I]EmailService。它们可能存在于域层,也可能不存在于域层,这取决于它们的用途。由于它们不绑定到域(没有域逻辑),因此明智的做法是将它们提取到库中,以便您可以在域和其他层之间或与其他应用共享它们(为什么要为每个项目重写电子邮件服务,对吗?)。

与其他层一样,您的服务层可以公开自己的模型,保护其使用者不受域模型(内部)更改的影响。换句话说,服务层应该只公开其契约和接口(关键字:shield)。服务层是外观的一种形式。

有很多方法可以解释这一层,我将尝试以一种浓缩的方式尽可能多地解释(从简单到复杂)。让我们开始:

  1. The classes and interfaces of the service layer could be part of the domain layer's assembly, created in a Services directory, for example.

    这是不太可重用的,但它为将来共享服务铺平了道路,而无需首先管理多个项目。它需要严谨,不依赖于你不应该依赖的东西。

  2. The service layer could be an assembly containing interfaces and implementation.

    这是可重用性和维护时间之间的一个巨大折衷。很可能您永远不需要两个实现(请参阅下一点),因为服务与逻辑相关联,而逻辑构成了域。您甚至可以隐藏实现,就像我们在第 9 章结构模式中对不透明立面所做的那样。

  3. The service layer could be divided into two assemblies – one containing abstractions (referenced by consumers) and one containing implementations.

    对于大多数项目来说,这简直是过火和无用,但我将把选择权留给你;也许您正在构建一个需要这种抽象级别的项目。

  4. 服务层可以是实际的 web 服务层(例如 web API)。

  5. A web service tier using a service layer assembly.

    这是点24的组合。作为程序集的服务层,可以按原样使用,并在其顶部使用 web API,将其部分或全部功能公开给外部系统。

按照惯例,人们通常在服务类后面加上Service,例如ProductServiceInventoryService;接口(IProductServiceIInventoryService也是如此)。

无论您选择哪种技术,请记住,服务层包含域逻辑,并屏蔽了域模型的直接访问。

在我们损坏细节之前,让我们来看看两个主要的方法来思考域模型。以下代码表示每个域层项目公开的服务接口。这些接口表示表示表示与域之间的链接,根据域模型类型封装了或多或少的职责。有两个具有相同签名的不同接口,每个项目一个:

public interface IProductService
{
    IEnumerable<Product> All();
}

IProductService只公开一个返回系统所有Product实例的方法。

public interface IStockService
{
    int AddStock(int productId, int amount);
    int RemoveStock(int productId, int amount);
}

IStockService公开了两种方法:一种是添加库存,另一种是从库存中移除库存。两者都返回更新的库存水平。

让我们首先探索富域模型,看看这些服务是如何实现的。

富域模型

富域模型更面向对象,在术语“最纯粹”的意义上,并且将域逻辑封装为模型的一部分,包含在方法中。例如,下面的类是Product类的富版本:

namespace RichDomainLayer
{
    public class Product
    {
        public Product(string name, int quantityInStock)
        {
            Name = name ?? throw new ArgumentNullException(nameof(name));
            QuantityInStock = quantityInStock;
        }
        public Product(int id, string name, int quantityInStock)
            : this(name, quantityInStock)
        {
            Id = id;
        }

前面两个构造函数在创建Product实例时,强制使用者提供所需的数据。这为我们提供了一个额外的控制层来控制可以进入的内容。

        public int Id { get; private set; }
        public string Name { get; private set; }
        public int QuantityInStock { get; private set; }

为了迫使消费者使用以下方法,所有设置器都已设置为private,因此无法从外部进行更改。

        public void AddStock(int amount)
        {
            QuantityInStock += amount;
        }
        public void RemoveStock(int amount)
        {
            if (amount > QuantityInStock)
            {
                throw new NotEnoughStockException(QuantityInStock, amount);
            }
            QuantityInStock -= amount;
        }
    }
}

最后,我们有AddStockRemoveStock方法。它们封装了与我们产品相关的逻辑。他们管理添加和删除该产品库存的过程。当然,在这种情况下,我们只增加和减少属性的值,但在更复杂的模型中,概念是相同的。

屏蔽该模型的服务层由以下代码表示:

namespace RichDomainLayer
{
    public class StockService : IStockService
    {
        private readonly ProductContext _db;
        public StockService(ProductContext db)
        {
            _db = db ?? throw new ArgumentNullException(nameof(db));
        }
        public int AddStock(int productId, int amount)
        {
            var data = _db.Products.Find(productId);
            var product = new Product(
                id: data.Id,
                name: data.Name,
                quantityInStock: data.QuantityInStock
            );
            product.AddStock(amount);
            data.QuantityInStock = product.QuantityInStock;
            _db.SaveChanges();
            return product.QuantityInStock;
        }
        public int RemoveStock(int productId, int amount)
        {
            var data = _db.Products.Find(productId);
            var product = new Product(
                id: data.Id,
                name: data.Name,
                quantityInStock: data.QuantityInStock
            );
            product.RemoveStock(amount);
            data.QuantityInStock = product.QuantityInStock;
            _db.SaveChanges();
            return product.QuantityInStock;
        }
    }
}

在前面的服务代码中,数据对象和业务对象之间存在映射。然后,我们调用重构的域模型来添加或删除一些库存,然后将更新的实体保存回数据库,并将更新的数量返回给消费者(稍后我们将对此进行简化)。

rich 模型的流程通常如下:

  1. 重建模型(从数据库加载模型并将其映射到对象)。
  2. 使用模型中嵌入的逻辑。
  3. (可选)将更改保存回数据库(将对象保存并映射到数据库)。

现在,让我们来看看优点和缺点。

利与弊

这种方法的最大优点是所有的逻辑都内置在模型中。它非常以领域为中心,因为操作是作为方法在模型实体上编程的。此外,它达到了面向对象设计背后的基本思想,即行为应该是对象的一部分,使它们成为现实生活中对应对象的虚拟表示。

最大的缺点是单一类别的责任累积。即使面向对象的设计告诉我们将逻辑放入对象中,这并不意味着它总是一个好主意。如果灵活性对您的系统很重要,那么将逻辑硬编码到域模型中可能会妨碍您在不更改代码本身的情况下使业务规则不断发展的能力(仍然可以这样做)。如果域的构建是健壮的和固定的,那么丰富的模型可能是您的项目的好选择。

这种方法的一个相对缺点是,向域模型中注入依赖项可能比向服务等其他对象中注入依赖项更难,这可能会再次降低灵活性或增加创建模型的复杂性。

如果您正在构建一个有状态的应用,其中域模型在内存中的驻留时间比 HTTP 请求的时间长,那么富域模型可能会对您有所帮助。还有其他模式,例如模型视图视图模型MVVM)和模型视图更新MVU)来帮助您实现这一点。我们将在第 18 章中讨论 MVU,简要介绍 Blazor

如果您相信您的应用将从将数据和逻辑保持在一起中受益,那么对于您的项目来说,一个丰富的域模型很可能是一个好主意。

如果您的程序是围绕您的域模型构建的,那么将这些类直接持久化到您的数据库可能比使用另一个模型更好。如果您使用的是对象关系映射器(ORM),比如 EntityFrameworkCore,您甚至可以配置模型的持久化方式。使用文档数据库,如 CosmosDB、Firebase、MongoDB、Azure Blob 存储或任何其他文档数据库,可以非常轻松地将复杂模型存储为单个文档,而不是表集合(这也适用于贫血模型)。

正如您可能已经注意到的,在这一部分中有很多“如果”,因为我认为对于富模型是否更好,没有一个绝对的答案。这更多的是一个问题,它是否更好地为您的具体情况比整体。你还需要考虑你的个人喜好。

在这里,经验很可能是您最好的盟友,所以我建议您编写代码,编写代码,编写更多的应用,以获得这种经验。

贫血域模型

贫血领域模型不包含方法,而是由哑数据结构组成,主要只包含 getter 和 setter。我们以前使用的相同产品的外观如下所示:

namespace AnemicDomainLayer
{
    public class Product
    {
        public int Id { get; set; }
        public string Name { get; set; }
        public int QuantityInStock { get; set; }
    }
}

如您所见,类中没有更多的方法,只有三个带有公共 setter 的属性。我们将该逻辑转移到服务层。该模型现在是一个简单的数据结构。以下是更新后的StockService类:

namespace AnemicDomainLayer
{
    public class StockService : IStockService
    {
        private readonly ProductContext _db;
        public StockService(ProductContext db)
        {
            _db = db ?? throw new ArgumentNullException(nameof(db));
        }
        public int AddStock(int productId, int amount)
        {
            var product = _db.Products.Find(productId);
            product.QuantityInStock += amount;
            _db.SaveChanges();
            return product.QuantityInStock;
        }
        public int RemoveStock(int productId, int amount)
        {
            var product = _db.Products.Find(productId);
            if (amount > product.QuantityInStock)
 {
 throw new NotEnoughStockException(product.QuantityInStock, amount);
 }
            product.QuantityInStock -= amount;
            _db.SaveChanges();
            return product.QuantityInStock;
        }
    }
}

我们的代码与前面的代码几乎相同,但是在服务中处理的逻辑不同。贫血模型流量通常如下所示:

  1. 重建模型(从数据库加载模型并将其映射到对象)。
  2. 在将模型用作简单数据结构时处理逻辑。
  3. 或者,将更改保存回数据库(将对象保存并映射到数据库)。

现在,让我们看看它的优点和缺点。

利与弊

对于贫血模型,将操作与数据分离可以帮助我们增加系统的灵活性。但是,由于外部参与者(服务)正在修改模型,而不是管理模型本身,因此在任何给定的时间强制执行模型的状态可能会更加困难。

将逻辑封装到更小的单元中,可以更容易地管理每个单元。将这些依赖项注入服务类比将它们注入实体本身更容易。拥有更多更小的代码单元会使系统对新手更加恐惧,因为它的理解可能更复杂,因为它有更多的运动组件。另一方面,如果系统是围绕定义良好的抽象构建的,那么可以更容易地单独测试每个单元。

然而,测试可能会有很大的不同。对于我们的富模型,我们可以单独测试规则(参见RichDomainLayer.Tests/ProductTest.cs),并在另一个测试中测试持久性(参见RichDomainLayer.Tests/StockServiceTest.cs);见https://net5.link/FP7q 。我们称之为坚持无知。它允许我们单独测试业务规则。以下是Product模型单元测试文件,该文件展示了将该概念付诸实施的过程:

namespace RichDomainLayer
{
    public class ProductTest
    {
        public class AddStock : ProductTest
        {
            [Fact]
            public void Should_add_the_specified_amount_to_QuantityInStock()
            {
                // Arrange
                var sut = new Product("Product 1", quantityInStock: 0);
                // Act
                sut.AddStock(2);
                // Assert
                Assert.Equal(2, sut.QuantityInStock);
            }
        }
// …
    }
}

前面的代码显示了Product.AddStock方法测试,只测试业务逻辑规则。接下来,我们将对StockService类进行一些集成测试:

namespace RichDomainLayer
{
    public class StockServiceTest
    {
        private readonly DbContextOptionsBuilder _builder=...; 
        public class AddStock : StockServiceTest
        {
            [Fact]
            public void Should_add_the_specified_amount_to_QuantityInStock()
            {
                // Arrange
                using var arrangeContext = new ProductContext(_builder.Options);
                using var actContext = new ProductContext(_builder.Options);
                using var assertContext = new ProductContext(_builder.Options);
                arrangeContext.Products.Add(new() { Name = "Product 1", QuantityInStock = 1 });
                arrangeContext.SaveChanges();
                var sut = new StockService(actContext);
                // Act
                var quantityInStock = sut.AddStock(productId: 1, amount: 2);
                // Assert
                Assert.Equal(3, quantityInStock);
                var actual = assertContext.Products.Find(1);
                Assert.Equal(3, actual.QuantityInStock);
            }
        }
// …
    }
}

上面的代码显示了持久性测试,确保StockService在执行AddStock操作后将实体持久化到数据库中。

在我们的贫血模型中,我们需要在同一测试中测试规则和持续性,将单元测试转换为集成测试(参见AnemicDomainLayer.Tests/StockServiceTest.cs

namespace AnemicDomainLayer
{
    public class StockServiceTest
    {
        private readonly DbContextOptionsBuilder _builder = ...;
        public class AddStock : StockServiceTest
        {
            [Fact]
            public void Should_add_the_specified_amount_to_QuantityInStock()
            {
                // Arrange
                using var arrangeContext = new ProductContext(_builder.Options);
                using var actContext = new ProductContext(_builder.Options);
                using var assertContext = new ProductContext(_builder.Options);
                arrangeContext.Products.Add(new() { Name = "Product 1", QuantityInStock = 1 });
                arrangeContext.SaveChanges();
                var sut = new StockService(actContext);
                // Act
                var quantityInStock = sut.AddStock(1, 9);
                // Assert
                Assert.Equal(10, quantityInStock);
                var actual = assertContext.Products.Find(1);
                Assert.Equal(10, actual.QuantityInStock);
            }
        }
// ...
    }
}

前面的代码与富模型集成测试几乎相同。它同时测试逻辑和持久性。我们可以使用不同的技术简化贫血模型整合测试。我们将在本章及后续章节中介绍其中的一些内容。尽管如此,所有这些都是选择的问题;有三个集成测试来满足您的需求可能比三个单元测试和两个集成测试更好。通常情况下,越少越好。请记住,您不是按照编写的代码行获得报酬,而是为了交付结果。对于复杂的业务规则,我强烈建议自己封装规则。这允许您编写许多单元测试,这些测试涵盖了所有场景,而无需数据库。

总而言之,如果遵循相同的严格的领域分析过程,贫血模型应该与丰富领域模型一样复杂。最大的区别应该是方法所在的位置;也就是说,在什么课上。

对于无状态系统,比如 RESTful API,贫血模型是一个不错的选择。因为您必须为每个请求重新创建模型的状态,所以这可以为您提供一种方法,在较小的类中重新创建模型的较小部分,并针对每个用例分别进行优化。无状态系统往往需要一种比纯面向对象的思维更为程序化的思维方式,这使得贫乏的模型成为这方面的最佳候选者。

笔记

我个人喜欢服务层后面的贫血模型,但有些人不同意我的观点。和往常一样,我建议您选择您认为最适合您正在构建的系统的方法,而不是根据其他人对另一个系统的看法来做一些事情。

另一个好的技巧是让重构自上而下地流向正确的位置。例如,如果您觉得一个方法绑定到一个实体,那么没有什么可以阻止您将该逻辑片段移动到该实体而不是服务类中。如果一个服务更合适,那么将逻辑移到一个服务类。这种方法有助于垂直切片体系结构,我们将在第 15 章中介绍垂直切片体系结构入门。

现在,让我们看看数据层。

数据

数据层是持久性代码的所在。在大多数程序中,我们需要某种持久性来存储应用数据,通常是数据库。谈到数据层时,会想到几种模式,包括工作单元和存储库模式,它们都是非常常见的模式。我们将在本小节末尾简要介绍这两种模式。

我们可以保持我们的域模型不变,或者创建更适合存储的数据模型。例如,在面向对象的世界中,多对多关系不是一件事情,而它是从关系数据库的角度来看的。您可以像查看视图模型DTO一样查看数据模型,但对于数据。数据模型是数据存储在您的数据存储中的方式;也就是说,你如何建模你的数据。在一个经典的分层项目中,您别无选择,只能拥有一个数据模型。但是,随着我们继续探索其他选项,您可能会找到更好的解决方案。

我使用实体框架核心EF 核心)作为本项目的对象关系映射器ORM)。ORM是一款软件,可以自动将对象转换为 SQL 等数据库语言。在EF Core的情况下,它允许我们在多个提供者之间进行选择,从 SQL 到 Cosmos DB,在内存中传递。EF Core 的伟大之处在于它已经为我们实现了工作单元存储库模式。在我们的例子中,我们使用内存提供程序来缩短设置时间。对于 SQL Server 数据库,我们可以使用迁移来维护基于数据模型的数据库模式。

笔记

如果您以前使用过 EF6 和恐惧实体框架,请知道他们从错误中吸取了教训,并且 EF Core 更轻、更快、更易于测试。请随时再试一次。它远非完美,但在许多场景中都能很好地工作。如果您想完全控制您的 SQL 代码,请寻找 Dapper(不要与 Dapr 混淆)。

这个项目的数据层非常薄,所以我们先来看看它的整体情况:

namespace DataLayer
{
    public class Product
    {
        public int Id { get; set; }
        public string Name { get; set; }
        public int QuantityInStock { get; set; }
    }
    public class ProductContext : DbContext
    {
        public ProductContext(DbContextOptions options)
            : base(options) { }
        public DbSet<Product> Products { get; set; }
    }
}

在前面的代码中,Product类是数据模型,而ProductContext类是工作单元DbSet<Product>是一个Product存储库。我们之前研究的服务层已经使用了ProductContextProduct类。

我不想详细介绍这些模式,但它们非常重要,值得一看。我写了一个关于存储库模式的多部分系列文章(参见进一步阅读部分)。在使用 EF Core 时,您不必处理这种级别的细节,这可能是一件好事。

同时,让我们至少研究一下他们的目标,这样你就知道他们的目的是什么,如果有时间你需要写这样的组件,你就知道去哪里找。

仓库模式

存储库模式的目标是允许消费者以面向对象的方式查询数据库。通常,这意味着对象的内存缓存以及动态过滤数据的可能性。EF Core 用一个DbSet<T>表示这个概念,并使用 LINQ 和IQueryable<T>接口提供动态过滤。

人们还使用术语repository来表示表数据网关模式,这是另一种模式,它模拟了一个类,让我们可以访问数据库中的单个表,并提供对操作的访问,例如创建、更新、删除和从该数据库表中获取实体。这两种模式都来自企业应用架构的模式。

例如,我们可以使用DbSet<Product>将新产品添加到数据库中,如下所示:

db.Products.Add(new Product
{
    Id = 1,
    Name = "Banana",
    QuantityInStock = 50
});

对于查询部分,我们使用它来查找单个产品,如下所示:

var product = _db.Products.Find(productId);

但是,我们可以使用 LINQ 来代替:

_db.Products.Single(x => x.Id == productId);

这些是存储库应该提供的类查询功能,EF Core 无缝支持这些功能,将 LINQ 转换为配置的提供者期望值,如 SQL。

ProductServiceAll()方法(来自AnemicDomainLayer获取所有产品,并使用 LINQ 将数据投影到域对象:

public IEnumerable<Product> All()
{
    return _db.Products.Select(p => new Product
    {
        Id = p.Id,
        Name = p.Name,
        QuantityInStock = p.QuantityInStock
    });
}

我们还可以在此使用 LINQ 进一步进行滤波;例如,通过查询所有缺货的产品:

var outOfStockProducts = _db.Products
    .Where(p => p.QuantityInStock == 0);

我们也可以允许有误差,比如:

var mostLikelyOutOfStockProducts = _db.Products
    .Where(p => p.QuantityInStock < 3);

在此基础上,我们探讨了 EF Core 如何实现存储库模式以及如何使用它。这需要大量的工作来实现与 EF 核心功能一致的自定义存储库。

这对于存储库模式来说已经足够了。现在,在回到分层之前,让我们先来概述一下工作单元模式

工作单位模式

u****工作单元跟踪事务的对象表示。换句话说,它管理应该创建、更新和删除的对象的注册表。它允许我们在一个事务中组合多个更改(一个数据库调用),这比每次更改时调用数据库有多个优势。这里有两个:

  • 首先,它可以加快数据访问速度;调用数据库很慢,因此限制调用和连接的数量可以提高性能。
  • 其次,运行事务而不是单个操作允许我们在一个操作失败时回滚所有操作,或者在所有操作成功时提交整个事务。

在 EF 核心中,该模式由DbContext类及其底层类型(如DatabaseFacadeChangeTracker类)实现。

在我们的小应用中,我们没有利用事务,但概念保持不变。让我们看看贫血StockService的例子,了解工作单元内部发生的情况:

var product = _db.Products.Find(productId);
product.QuantityInStock += amount;
_db.SaveChanges();

前面的代码执行以下操作:

  1. 查询数据库中的单个实体。
  2. 更改QuantityInStock属性的值。
  3. 将更改保留回数据库。

事实上,所发生的情况更接近于以下情况:

  1. We ask EF Core for a single entity through the ProductContext (a unit of work), which exposes the DbSet<Product> property (the product repository). Under the hood, EF Core does the following:\

    a) 查询数据库。

    b) 缓存实体。

    c) 已开始跟踪该实体的更改。

    d) 把它还给我们。

  2. 我们更改了QuantityInStock财产的价值;EF Core 检测到更改并将对象标记为脏。

  3. 我们告诉工作单元保存其跟踪的更改,将脏产品保存回数据库。

在更复杂的场景中,我们可以编写以下代码:

_db.Products.Add(newProduct);
_db.Products.Remove(productToDelete);
product.Name = "New product name";
_db.SaveChanges();

这里,SaveChanges()方法触发保存三个操作,而不是逐个发送。您可以使用DbContextDatabase属性控制数据库事务(有关更多信息,请参阅进一步阅读部分)。

现在我们已经探索了工作单元,我们可以自己实现一个。这会为我们的应用增加价值吗?可能不会。如果您想在 EF Core 上构建一个定制的工作单元或一个包装器,那么有大量现有资源可以指导您。除非您想进行实验或需要定制的工作单元存储库(这是可能的),否则我建议不要这样做。记住:只做你的程序正确所需的事情

提示

当我说只做需要做的时,不要误解我;野生工程的努力和实验是一个伟大的探索方式,我鼓励你这样做。但是,我建议您并行进行,这样您就可以进行创新、学习,甚至可以在以后将这些知识迁移到您的应用中,而不是浪费时间和破坏东西。如果您使用的是 Git,那么创建一个实验分支将是一个很好的方法。然后,当实验不起作用时,可以将其删除,但如果分支产生积极结果,则可以合并分支。

现在,让我们回到分层,继续以健壮、灵活和高效的方式使用层。

抽象数据层

在本节中,我们将实现一个抽象数据层,以探索存储库接口。这种类型的抽象非常有用,并且更接近于干净的体系结构

让我们从问题开始:域层是逻辑所在,UI是用户与之间的链接,暴露了中内置的特性。另一方面,数据层应该是盲目使用的一个实现细节。数据层包含知道数据存储位置的代码,应该与无关,但间接依赖于此。

解决方案是通过创建一个额外的抽象层,打破数据持久化实现之间的紧密耦合,如下图所示:

图 12.11–用数据抽象层替换数据(持久性)层

新规则:只有接口和模型类进入数据抽象层。这个新的层现在定义了我们的数据访问 API,除了公开一组接口之外什么也不做——一个契约。

然后,我们可以基于该层创建一个或多个数据实现。在我们的例子中,我们正在实现数据抽象层的 EF 核心版本。抽象和实现之间的链接是通过依赖项注入绑定完成的,这些绑定在组合根中定义。

新的依赖关系树如下所示:

图 12.12–各层之间的关系

表示层引用数据实现层的唯一原因是创建 DI 绑定。我们需要这些绑定来在创建类时注入正确的实现。除此之外,表示层不得使用数据层的抽象和实现

例如,当使用者请求实现IProductRepository接口的对象时,我们的程序可以注入EFCore.ProductRepository类的实例。

下面是使用IProductRepositoryProductService类的一部分:

public class ProductService : IProductService
{
    private readonly IProductRepository _repository;
    public ProductService(IProductRepository repository)
    {
        _repository = repository ?? throw new
ArgumentNullException(nameof(repository));
    }
    // …
}

ProductService只依赖于IProductRepository抽象,不关心注入了什么实现。

抽象层是关于组织代码而不是编写新代码。图 12.13 显示了项目之间的依赖关系,如图所示(图 12.12)。文本上,依赖项如下所示:

  • DataLayer不依赖任何内容,包含抽象和数据模型。
  • DataLayer.EFCore依赖于DataLayer并使用 EF 核心实现其抽象,EF 核心是从DomainLayer的具体消费者中抽象出来的。
  • DomainLayer仅依赖于DataLayer抽象。
  • PresentationLayer仅依赖于DomainLayer而加载DataLayer.EFCore以进行依赖注入。

图 12.13–突出显示项目(层)之间依赖关系的解决方案资源管理器窗口

有了这个更新的架构,我们在遵循依赖倒置原则的同时倒置DataLayer的依赖流。我们还切断了对 EF 核心的依赖,允许我们实现一个新的DataLayer并相互交换,而不会影响我们的代码。正如我前面提到的,我可以预见,对我们大多数人来说,层交换不会很快发生。尽管如此,这是分层演化的一个重要部分。此外,该技术可以应用于任何层(或项目),而不仅仅是数据层,因此了解如何反转依赖流非常重要。这也适用于单个项目;我们不需要只依赖于抽象的四个项目——我们可以将所有代码复制到一个项目中,并且依赖流是相同的。

即使将项目拆分为多个程序集不是强制性的,也可以出于另一个原因,例如在多个项目之间共享代码。显然,拥有一个大项目并不总是理想的,也可能有缺点。我在这里说的是,只要依赖于正确的抽象,您的系统就可以是松散耦合的、可测试的,并反转依赖流。例如,不要在表示层中使用数据接口,也不要在数据层中使用域服务。

我在本书中保留了抽象数据层项目的代码,因为它与前面的示例几乎相同,并且使用计算机和完整的源代码比使用书籍更容易比较项目结构。参见https://net5.link/XzgfAbstractDataLayers项目源代码。也就是说,以下是帮助您找到它们的更改列表:

  • 我们增加了DataLayer.EFCore项目。
  • 项目之间的依赖关系发生了变化,如前所述。
  • 域层只包含一个贫血模型和一组服务。

接下来,让我们探讨如何共享和持久化富域模型。这将引导我们走向干净的架构。

共享富模式

在本节中,我们将介绍一个共享模型的分层解决方案。然后,我们将把本章中提供的概念组合成一个干净的体系结构。我们在本章开头看到了一个类似的图表,这就是我们在这里构建的:数据层之间的共享模型。在上一个项目中,数据抽象层拥有数据模型,而域层拥有域模型

在这个架构备选方案中,我们在两个层之间共享模型;也就是说,我们使用的是丰富的产品模型。这是一个直观的表示:

图 12.14–表示共享丰富模型的图表

目标是直接持久化域模型并跳过从域层数据层的复制。

我们也可以对贫血模型进行此操作,但它非常适合丰富的模型。使用富域模型,您将重建模型的工作委托给 ORM;然后,您可以立即开始调用该富域模型上的方法。

与上一个项目相比没有太大变化,特别是在.NET Core 引入的可传递依赖项恢复的情况下。数据抽象层现在只包含数据访问抽象,例如存储库,并且它引用了新的SharedModels项目。

从概念上讲,它清理了一些事情:

  • 数据抽象层的唯一职责是包含数据访问抽象。
  • 对于富模型,域层的唯一职责是实现不属于富模型的域服务和域逻辑。
  • 在贫血模型的情况下,域层的职责是封装所有域逻辑。
  • SharedModels项目包含实体本身;他们是贫血还是富有并不重要。

与第一个项目相比,使用共享的富模型会导致非常精益的服务。下面是对StockService类的重写:

namespace DomainLayer.Services
{
    public class StockService : IStockService
    {
        private readonly IProductRepository _repository;
        public StockService(IProductRepository repository)
        {
            _repository = repository ?? throw new ArgumentNullException(nameof(repository));
        }

这里,我们正在注入IProductRepository接口的一个实现,我们将在接下来的两种方法中使用该接口。

        public int AddStock(int productId, int amount)
        {
            var product = _repository.FindById(productId);
            product.AddStock(amount);
            _repository.Update(product);
            return product.QuantityInStock;
        }

乐趣从前面的代码开始,该代码执行以下操作:

  1. 存储库重新创建包含逻辑的产品(模型)。
  2. 我们使用该模型来调用AddStock方法。
  3. 我们告诉存储库更新产品。
  4. 我们将更新后的产品的QuantityInStock返回给服务的消费者。
        public int RemoveStock(int productId, int amount)
        {
            var product = _repository.FindById(productId);
            product.RemoveStock(amount);
            _repository.Update(product);
            return product.QuantityInStock;
        }
    }
}

RemoveStock采用了与AddStock方法相同的逻辑,但我们称之为Product.RemoveStock方法。

将逻辑推进到模型中并不总是可能的或可取的,这就是为什么我们正在探索多种类型的领域模型以及共享它们的方法。为了做出一个好的设计,它通常是关于选项的,并就每个场景使用什么选项做出正确的决定。

接下来,ProductService成为存储库前面的一个立面:

namespace DomainLayer.Services
{
    public class ProductService : IProductService
    {
        private readonly IProductRepository _repository;
        public ProductService(IProductRepository repository)
        {
            _repository = repository ?? throw new ArgumentNullException(nameof(repository));
        }
        public IEnumerable<Product> All()
        {
            return _repository.All();
        }
    }
}

代码的其余部分是相同的,但是类被移动了一点。请随意浏览源代码(https://net5.link/izwK ),并与其他项目进行比较。最好的学习方法是练习,因此可以使用示例、添加功能、更新当前功能、删除内容,甚至构建自己的项目。理解这些概念有助于将它们应用于不同的场景,有时会创建意外但高效的结构。

现在,让我们看看分层的另一个演变:干净的体系结构。

洁净架构

现在我们已经介绍了许多分层方法,是时候将它们结合到干净的体系结构中了,也称为六边形体系结构、洋葱型体系结构、端口和适配器等。干净的体系结构是层次的演变,但和我们刚刚构建的非常相似。Clean 架构建议使用UI核心基础设施,而不是表示、域和数据(或持久性)。

如前所述,我们可以设计一个层,使其包含抽象或实现。然后,当实现仅依赖于抽象时,这就颠倒了依赖流。Clean 架构强调这些层,但有自己的一套关于如何组织它们的指导。

我们还探讨了将层分解为更小的层(或多个项目)的理论概念,从而创建更易于移植和重用的“断裂层”。清洁体系结构在基础架构层级别利用了这一概念。

这可能有很多观点和变体,有很多名字,所以我会尽量做到概括性,同时保留本质。通过这样做,如果您对这种类型的体系结构感兴趣,您将能够选择一种资源,并按照您喜欢的风格进行深入挖掘。

让我们来看一个类似于我们可以在网上找到的图表:

图 12.15–表示最基本清洁架构布局的图表

从这样的分层图来看,前面的图可能如下所示:

图 12.16–先前清洁体系结构图的两层视图

从这里,根据您选择的方法,可以将这些层拆分为多个子层。大家都同意的一点是核心层可以分为实体用例,如下所示:

图 12.17-广泛清洁架构布局图

因为科技行业的人都是有创造力的,所以很多东西都有很多名字,但概念都是一样的。从类似分层图的角度来看,该图可以如下所示:

Figure 12.18 – A layer-like view of the previous Clean Architecture diagram

图 12.18–先前清洁体系结构图的类似于层的视图

基础架构层是概念性的,可以表示多个项目,例如包含 EF 核心实现的基础架构程序集和表示 web UI 的网站项目。我们还可以向基础架构层添加更多项目。

清洁体系结构的依赖规则规定,依赖只能指向内部,从外层到内层。这意味着抽象存在于它的内部,而具体存在于它的外部。根据我的分层图,内部转换为向下。这意味着一个层可以使用其任何直接或可传递的依赖项,这意味着基础设施可以使用核心和实体。

干净的体系结构遵循我们从本书开始就讨论过的所有原则,例如使用抽象、依赖项反转和关注点分离来解耦我们的实现。这些实现使用依赖项注入(这不是强制性的,但应该会有所帮助)粘在抽象之上。

我总是发现那些圆图有点令人困惑,因此我对一个更新的、更线性的图的看法如下:

图 12.19——清洁体系结构公共元素的两层视图

现在,让我们从核心层开始,使用干净的体系结构重新审视我们的分层应用。核心项目包含域模型、用例和实现这些用例所需的接口。

此处不应访问任何外部资源:无数据库调用、无磁盘访问和 HTTP 请求。该层包含公开此类交互的接口,但实现必须位于基础设施层

从实体开始,我们有一个Product类,它与我们在以前的实现中使用的相同:

namespace Core.Entities
{
    public class Product
    {
        public int Id { get; set; }
        public string Name { get; set; }
        public int QuantityInStock { get; set; }
    }
}

然后,我们有一个存储库界面,它公开了我们存储和检索这些产品的方式:

namespace Core.Interfaces
{
    public interface IProductRepository
    {
        Ienumerable<Product> All();
        Product FindById(int productId);
        void Update(Product product);
        void Insert(Product product);
        void DeleteById(int productId);
    }
}

然后,除了NotEnoughStockException,我们还有两个用例:AddStocksRemoveStocks。每个用例封装了自己的逻辑。我们本来可以移植该服务,但这种用例方法非常简洁,使我们更接近垂直切片架构(更多信息,请参见第 15 章【垂直切片架构入门】)。让我们从更复杂的问题开始;即RemoveStocks

namespace Core.UseCases
{
    public class RemoveStocks
    {
        private readonly IproductRepository _productRepository;
        public RemoveStocks(IproductRepository productRepository)
        {
            _productRepository = productRepository ?? throw new ArgumentNullException(nameof(productRepository));
        }
        public int Handle(int productId, int amount)
 {
 var product = _productRepository .FindById(productId);
 if (amount > product.QuantityInStock)
 {
 throw new NotEnoughStockException (product.QuantityInStock, amount);
 }
 product.QuantityInStock -= amount;
 _productRepository.Update(product);
 return product.QuantityInStock;
 }
    }
}

同样,前面的代码与前面的示例非常相似。我们正在注入IProductRepository,然后在使用IProductRepository访问数据的同时,以Handle方法迁移另一个样本的逻辑。Handle方法将productIdamount作为输入,并返回更新的数量。这很简单——类有一个职责,所有的逻辑都在那里;这就是我们所需要的。

AddStock用例是按照相同的模式构建的。作为参考,以下是其Handle方法:

public int Handle(int productId, int amount)
{
    var product = _productRepository.FindById(productId);
    product.QuantityInStock += amount;
    _productRepository.Update(product);
    return product.QuantityInStock;
}

现在我们已经有了这些业务规则,让我们看看如何从StocksController开始使用它们。这两个用例都封装在各自的类中,因此我们可以使用方法注入并将用例直接注入到动作方法中(这不是强制性的,也不是干净体系结构的一部分;这只是一种感觉自然的可能性):

[HttpPost("remove-stocks")]
public ActionResult<StockLevel> Remove(
    int productId,
    [FromBody] RemoveStocksCommand command,
    [FromServices] RemoveStocks useCase
)
{
    try
    {
        var quantityInStock = useCase.Handle(productId, command.Amount);
        var stockLevel = new StockLevel(quantityInStock);
        return Ok(stockLevel);
    }
    catch (NotEnoughStockException ex)
    {
        return Conflict(new
        {
            ex.Message,
            ex.AmountToRemove,
            ex.QuantityInStock
        });
    }
}

这与我们之前使用的代码相同,但我们称之为useCase.Handle,而不是_stockService.RemoveStock。这同样适用于AddStocks用例,如下所示:

[HttpPost("add-stocks")]
public ActionResult<StockLevel> Add(
    int productId,
    [FromBody] AddStocksCommand command,
    [FromServices] AddStocks useCase
)
{
    var quantityInStock = useCase.Handle(productId, command.Amount);
    var stockLevel = new StockLevel(quantityInStock);
    return Ok(stockLevel);
}

在组合根中,我们必须用以下内容替换IProductServiceIStockService的绑定:

services.AddScoped<AddStocks>();
services.AddScoped<RemoveStocks>();

正如您可能已经注意到的,我们没有这些用例的接口。因为它们是我们从基础架构层按原样使用的具体业务规则,所以不需要接口。没有什么可抽象的。

现在,让我们来看看基础设施。正如我们前面所看到的,我们可以有数据模型,也可以没有数据模型,但是由于实体位于一个更内向的层中,因此基础架构层的任何项目都可以直接使用它们。此外,我们通常不需要不同的数据模型,因此持久化实体可以直接保存一些映射,并消除无用的复杂性。

我们可以继续我们在共享富模型项目中启动的相同路径,该项目的设计接近于干净的体系结构。

知道我们直接持久化域实体,我们的ProductRepository实现很简单。没有映射,看起来是这样的:

namespace Infrastructure.Data.Repositories
{
    public class ProductRepository : IProductRepository
    {
        private readonly ProductContext _db;
        public ProductRepository(ProductContext db)
        {
            _db = db ?? throw new ArgumentNullException(nameof(db));
        }
        public IEnumerable<Product> All()
        {
            return _db.Products;
        }
        public void DeleteById(int productId)
        {
            var product = _db.Products.Find(productId);
            _db.Products.Remove(product);
            _db.SaveChanges();
        }
        public Product FindById(int productId)
        {
            var product = _db.Products.Find(productId);
            return product;
        }
        public void Insert(Product product)
        {
            _db.Products.Add(product);
            _db.SaveChanges();
        }
        public void Update(Product product)
        {
            _db.Entry(product).State = EntityState.Modified;
            _db.SaveChanges();
        }
    }
}

前面的代码非常干净;它接受实体并将其持久化。由于实体是系统的核心,因此不需要更多副本或数据模型。

提示

假设您需要使用与域模型不同的结构来保存数据。特定于数据的类应该存在于实现持久性的项目中,持久性位于基础架构层。这意味着其他层不应该意识到这一点,这不会在其他层和数据库之间产生紧密耦合,同时也不会成为持久性问题的解决方案。

这就是系统的股票部分;我们拥有与之前相同的用例,遵循相同的 WebAPI 合同,但使用干净的体系结构。

对于“获取所有产品”用例,我决定将IProductRepository直接注入控制器。由于 UI(基础设施层可以依赖于核心层,我们可以在技术上这样做,因为该层的流指向内部。

笔记

web 上有许多代码示例表明了这种方法。我的看法是,只要该特性是数据驱动的,非常接近数据库,并且没有业务逻辑,就可以接受;编写空 shell 和增加复杂性很少有帮助。但是,一旦涉及到业务逻辑,就创建一个服务、一个用例或您认为该场景所必需的任何其他域实体。不要将业务逻辑打包到控制器中。

接下来是ProductsController,它消耗了那个“用例”:

namespace Web.Controllers
{
    [ApiController]
    [Route("[controller]")]
    public class ProductsController : ControllerBase
    {
        // ...
        [HttpGet]
        public ActionResult<IEnumerable<ProductDetails>> Get()
        {
            var products = _productRepository
 .All()
 .Select(p => new ProductDetails(
 id: p.Id,
 name: p.Name,
 quantityInStock: p.QuantityInStock
 ));
 return Ok(products);
        }
        public class ProductDetails
        {
            // omitted for brevity
        }
    }
}

这里,我们有有限的逻辑将域模型复制到 DTO,并向消费者返回一个200 OK。在本例中,控制器遵循其角色,并在 HTTP 和业务逻辑之间起桥梁作用。

然而,你应该分析你的需求和规格;每个项目都是不同的,所以从您的控制器加载存储库对于一个场景来说已经足够好了,而对于另一个场景则根本不好。

总而言之,清洁体系结构是一种经过验证的构建应用的模式。这从根本上说是分层的演变。有许多变体可以帮助您管理用例、实体和基础设施;然而,我们将不在这里讨论这些问题。如果您认为这非常适合您、您的团队、您的项目或您的组织,请随意深入挖掘并采用此模式。在随后的章节中,我们将探讨一些模式,如 CQR、发布-订阅和域事件,它们可以与干净的体系结构一起使用,以增加更多的灵活性和健壮性,这在系统规模或复杂性增加时特别有用。

总结

在设计应用时,分层是最常用的体系结构技术之一。一个应用通常分为三个不同的层,每个层管理一个单独的职责。三个最流行的层是表示数据。您不限于三个层,可以将每个层拆分为更小的层(或同一概念层中的更小部分)。这允许您创建可组合、可管理和可维护的应用。此外,您可以创建抽象层来反转依赖流,并将接口与实现分离,正如我们在抽象数据层项目中看到的那样。您可以直接持久化域实体,也可以为数据层创建独立的模型。您还可以使用贫血模型(无逻辑或方法)或丰富模型(包含实体相关逻辑)。

从那时起,从分层中诞生了干净的体系结构,它提供了关于如何将应用组织成同心层的指导,通常将应用划分为用例。

让我们看看这种方法如何帮助我们在应用规模上实现坚实原则:

  • S:分层让我们朝着横向分工的方向前进,每一层都围绕一个单一的宏观关注点。因此,分层的目标是责任分离。
  • O:不适用。
  • L:不适用。
  • I:基于特征(或特征的内聚组)分割层是将系统分割成更小块(接口)的一种方法。
  • D:抽象层直接导致依赖流的反转,而经典的分层则相反。

在下一章中,我们将学习如何使用对象映射器模式集中复制对象(模型)的逻辑,并使用开源工具帮助我们跳过实现(这也称为生产惰性)。

问题

让我们来看看几个练习题:

  1. 在创建分层应用时,我们必须有表示层、域层和数据层,这是真的吗?
  2. 富领域模型比贫乏领域模型好吗?
  3. EF Core 是否实现了存储库和工作单元模式?
  4. 我们需要在数据层中使用 ORM 吗?
  5. 在干净的体系结构中,一个层可以访问任何内部层吗?

进一步阅读

以下是一些链接,可以帮助您在本章中学习的基础上继续学习:

  • ExceptionMapper是对Exception做出反应的 ASP.NET Core 中间件。您可以将某些异常类型映射到 HTTP 状态码等。这是我在 2020 年创建的开源项目之一:https://net5.link/i8jb
  • Dapper是一款简单但功能强大的.NET ORM,由 Stack Overflow 的人制作。如果您喜欢编写 SQL,但不喜欢将数据映射到对象,则此 ORM 可能适合您:https://net5.link/pTYs
  • 我在 2017 年写的一篇文章,讨论了存储库模式;也就是说,设计模式:ASP.NET Core Web API、服务和存储库|第 5 部分:存储库、存储库和集成测试https://net5.link/D53Z
  • 实体框架核心-使用交易https://net5.link/gxwD

十三、开始使用对象映射器

在本章中,我们将探讨对象映射。正如我们在上一章中所看到的,使用层通常会导致将模型从一个层复制到另一个层。对象映射器解决了这个问题。我们首先来看一下手动实现对象映射器。然后,我们将改进我们的设计。最后,我将向您介绍一个开源工具,它帮助我们生成业务价值,而不是编写映射代码。

本章将介绍以下主题:

  • 对象映射概述
  • 实现一个对象映射器,并探索几个备选方案
  • 使用服务定位器模式在我们的地图绘制者面前创建服务
  • 使用AutoMapper将一个对象映射到另一个对象,替换我们自制的代码

对象映射概述

什么是对象映射?简而言之,它是将一个对象的属性值复制到另一个对象的属性中的操作。但有时,属性的名称不匹配;对象层次可能需要展平,等等。正如我们在前一章中看到的,每个层都可以拥有自己的模型,这可能是一件好事,但这是以将对象从一层复制到另一层为代价的。我们也可以在层之间共享模型,但在某些时候我们需要某种映射。即使只是将您的模型映射到 DTO 或视图模型,这几乎是不可避免的,除非您正在构建一个小型应用,但即使如此,您可能想要或需要 DTO 和视图模型。

笔记

请记住,DTO 定义了 API 的契约。拥有独立的合同类可以帮助您维护系统,让您选择何时修改它们。如果跳过该部分,每次更改模型时,它都会自动更新端点的契约,可能会破坏某些客户端。此外,如果您直接输入模型,恶意用户可能会尝试绑定不应该绑定的属性值,从而导致潜在的安全问题。

在以前的项目中,我们手动实例化了发生转换的对象,复制了映射逻辑,并向进行映射的类添加了额外的职责。为了解决这个问题,我们将把映射逻辑提取到其他组件中。

目标

对象映射器的目标是将一个对象的属性值复制到另一个对象的属性中。它将映射逻辑封装在映射发生的位置之外。如果两个对象不遵循相同的结构,映射器还负责将值从原始格式转换为目标格式。例如,我们可以将对象层次结构展平。

设计

我们可以以多种方式进行设计,但我们可以用以下方式表示基本设计:

Figure 13.1 – Basic design of the object mapper

图 13.1–对象映射器的基本设计

从图中,消费者使用IMapper接口将Type1的对象映射到Type2的对象。这不是非常可重用的,但它说明了这个概念。通过使用泛型的强大功能,我们可以将简单的设计升级到更可重用的版本:

Figure 13.2 – Updating the design of the object pattern

图 13.2–更新对象模式的设计

我们现在可以实现IMapper<TSource, TDestination>接口,并为每个映射规则创建一个类,或者创建一个实现多个映射规则的类。例如,我们可以在同一个类中实现Type1Type2Type2Type1的映射。

我们还可以使用以下设计并使用一个方法创建IMapper接口,该方法处理应用的所有映射:

图 13.3–使用单个 IMapper 作为入口点的对象映射

最后一种设计的最大优点是易于使用。我们总是为每种类型的映射注入一个IMapper而不是一个IMapper<TSource, TDestination>,这将减少依赖项的数量和使用这种映射器的复杂性。最大的缺点可能是实现的复杂性,但正如我们即将发现的那样,我们可以从等式中去掉这一点。

您可以使用想象中允许的任何方式实现对象映射,但需要记住的关键部分是映射者有责任将一个对象映射到另一个对象。映射程序不应该做一些疯狂的事情,比如从数据库加载数据等等。它应该将一个对象的值复制到另一个对象中;就这样。

让我们跳转到一些代码中,以更深入地探索每个项目的设计。

项目:Mapper

此项目是上一章中干净架构代码的更新版本,其中包含数据模型域模型。我使用该版本展示了数据到域和域到 DTO 的映射。如果您不需要数据模型,请不要创建数据模型,尤其是使用干净的体系结构。尽管如此,我们的目标是展示设计的多功能性,并将实体映射封装到映射器类中,以从存储库和控制器中提取该逻辑。

首先,我们需要一个驻留在Core项目中的接口,以便其他项目可以实现它们需要的映射。让我们采用我们看到的第二种设计:

namespace Core.Interfaces
{
    public interface IMapper<TSource, TDestination>
    {
        TDestination Map(TSource entity);
    }
}

通过该接口,我们可以从创建数据映射器开始。因为我们正在将Data.Models.Product映射到Core.Entities.Product,反之亦然,所以我选择了一个类来实现这两个映射:

namespace Infrastructure.Data.Mappers
{
    public class ProductMapper : IMapper<Data.Models.Product, Core.Entities.Product>, IMapper<Core.Entities.Product, Data.Models.Product>
    {
        public Core.Entities.Product Map(Data.Models.Product entity)
        {
            return new Core.Entities.Product
            {
                Id = entity.Id,
                Name = entity.Name,
                QuantityInStock = entity.QuantityInStock
            };
        }
        public Data.Models.Product Map(Core.Entities.Product entity)
        {
            return new Data.Models.Product
            {
                Id = entity.Id,
                Name = entity.Name,
                QuantityInStock = entity.QuantityInStock
            };
        }
    }
}

这些是简单的方法,可以将一个对象的属性值复制到另一个对象中。尽管如此,现在我们已经完成了,我们准备更新ProductRepository类以使用新的映射器:

namespace Infrastructure.Data.Repositories
{
    public class ProductRepository : IProductRepository
    {
        private readonly ProductContext _db;
        private readonly IMapper<Data.Models.Product, Core.Entities.Product> _dataToEntityMapper;
 private readonly IMapper<Core.Entities.Product, Data.Models.Product> _entityToDataMapper;
        public ProductRepository(ProductContext db, IMapper<Data.Models.Product, Core.Entities.Product> productMapper, IMapper<Core.Entities.Product, Data.Models.Product> entityToDataMapper)
        {
            _db = db ?? throw new ArgumentNullException(nameof(db));
            _dataToEntityMapper = productMapper ?? throw new ArgumentNullException (nameof(productMapper));
 _entityToDataMapper = entityToDataMapper ?? throw new ArgumentNullException (nameof(entityToDataMapper));
        }

在前面的代码中,我们已经注入了所需的两个映射器。即使我们创建了一个实现两个接口的类,使用者(ProductRepository)也不知道这一点。接下来是使用映射器的方法:

        public IEnumerable<Core.Entities.Product> All()
        {
            return _db.Products.Select(p => _dataToEntityMapper.Map(p));
        }
        public void DeleteById(int productId)
        {
            var product = _db.Products.Find(productId);
            _db.Products.Remove(product);
            _db.SaveChanges();
        }
        public Core.Entities.Product FindById(int productId)
        {
            var product = _db.Products.Find(productId);
            return _dataToEntityMapper.Map(product);
        }
        public void Insert(Core.Entities.Product product)
        {
            var data = _entityToDataMapper.Map(product);
            _db.Products.Add(data);
            _db.SaveChanges();
        }
        public void Update(Core.Entities.Product product)
        {
            var data = _db.Products.Find(product.Id);
            data.Name = product.Name;
            data.QuantityInStock = product.QuantityInStock;
            _db.SaveChanges();
        }
    }
}

然后,类使用映射器替换其复制逻辑(前面突出显示的行)。这简化了存储库,将映射责任转移到 mapper 对象中,而不是向单一责任原则(SRP–实体中的“S”)迈进了一步。

然后将相同的原理应用于 web 应用,创建ProductMapperStockMapper,并更新控制器以使用它们。为了简洁起见,我省略了代码,但基本上是一样的。请看 GitHub(上的代码 https://net5.link/rZdN )。

唯一缺少的部分是 compositionroot,在这里我们将映射器实现与IMapper<TSource, TDestination>接口绑定。数据绑定如下所示:

services.AddSingleton<IMapper<Infrastructure.Data.Models.Product, Core.Entities.Product>, Infrastructure.Data.Mappers.ProductMapper>();
services.AddSingleton<IMapper<Core.Entities.Product, Infrastructure.Data.Models.Product>, Infrastructure.Data.Mappers.ProductMapper>();

因为ProductMapper实现了两个接口,所以我们将它们都绑定到该类。这是抽象的美之一;ProductRepository请求两个映射器,但两次收到相同的ProductMapper实例,甚至都不知道。

笔记

是的,我是故意的。这证明我们可以按照自己的意愿编写应用,而不会影响消费者。根据依赖倒置原则,这是通过依赖抽象而不是具体化来实现的。此外,根据接口隔离原则(ISP–实体中的“I”)将接口划分为小接口,这使得这种情况成为可能。最后,使用依赖注入DI的力量将所有这些片段重新组合在一起。

我希望你们开始明白我的想法,因为我们正在把越来越多的东西放在一起。

代码气味:依赖项太多

从长远来看,使用这种映射可能会变得单调乏味,我们会很快看到这样的场景,比如将三个或更多映射器注入到一个控制器中。该控制器很可能已经具有其他依赖项,从而导致四个或更多依赖项。

这将升起以下旗帜:

  • 那班学生是否做得太多,承担的责任太多?

在这种情况下,不是真的,但是我们的细粒度接口会污染我们的控制器,使其对映射器产生大量依赖,这是不理想的,并且使我们的代码更难阅读。

如果你好奇的话,下面是我是如何想出第三个数字的:

  • EntityDTOGetOneGetAllInsertUpdate、可能还有Delete的映射)
  • DTOEntityInsert的映射
  • DTOEntityUpdate的映射

根据经验,您希望将依赖项的数量限制为三个或更少。超过这个数字,问问你自己,这门课是否有问题;它是否有太多的责任?拥有四个以上的依赖关系本身并不坏;这只是一个指标,你应该重新考虑设计的某些部分。如果没有问题,保持在 4、5 或 10;没关系。

如果您不希望有那么多依赖项,那么可以提取封装两个或多个依赖项的服务聚合,然后注入该聚合。请注意,移动依赖项并不能解决任何问题;如果一开始有问题,它只是把问题转移到别处。不过,使用聚合可以提高代码的可读性。

不要盲目地移动依赖项,而是分析问题,看看是否可以创建具有实际逻辑的类,这些逻辑可以做一些有用的事情来减少依赖项的数量。

模式-聚合服务

即使聚合服务不是一种神奇的问题解决模式,它也是向另一个类注入大量依赖项的可行替代方案。它的目标是聚合另一个类中的许多依赖项,以减少其他类中注入的服务的数量,将依赖项分组在一起。管理总量的方法是按关注点或责任对这些问题进行分组。仅仅为了另一个服务而将一堆公开的服务放在另一个服务中很少是可行的;以凝聚为目标。

笔记

创建一个或多个公开其他服务的中央聚合服务是在项目中实现服务(接口)发现的一种好方法。我不是要你把所有的东西都直接放在一起。然而,如果服务的发现是您项目中的一个问题,或者您和您的团队发现它很困难,那么这是一个值得研究的可能性。

下面是一个假设映射聚合的示例,用于减少我们假想的 CRUD 控制器的依赖性:

public interface IProductMappers
{
    IMapper<Product, ProductDetails> EntityToDto { get; }
    IMapper<InsertProduct, Product> InsertDtoToEntity { get; }
    IMapper<UpdateProduct, Product> UpdateDtoToEntity { get; }
}
public class ProductMappers : IProductMappers
{
    public ProductMappers(IMapper<Product, ProductDetails> entityToDto, IMapper<InsertProduct, Product> insertDtoToEntity, IMapper<UpdateProduct, Product> updateDtoToEntity)
    {
        EntityToDto = entityToDto ?? throw new ArgumentNullException(nameof(entityToDto));
        InsertDtoToEntity = insertDtoToEntity ?? throw new ArgumentNullException(nameof(insertDtoToEntity));
        UpdateDtoToEntity = updateDtoToEntity ?? throw new ArgumentNullException(nameof(updateDtoToEntity));
    }
    public IMapper<Product, ProductDetails> EntityToDto { get; }
    public IMapper<InsertProduct, Product> InsertDtoToEntity { get; }
    public IMapper<UpdateProduct, Product> UpdateDtoToEntity { get; }
}
public class ProductsController : ControllerBase
{
    private readonly IProductMappers _mapper;
    // …
    public ProductDetails Method()
    {
        var product = default(Product);
        var dto = _mapper.EntityToDto.Map(product);
        return dto;
    }
}

从这个示例中,IProductMappers聚合可以理解为它重新组合了ProductsController类中使用的所有映射器。它只负责将与ProductsController相关的域对象映射到 DTO,反之亦然。您可以使用任何东西创建聚合,而不仅仅是映射器。这是 DI 密集型应用中相当常见的模式。

笔记

只要聚合服务不可能更改并且不实现任何逻辑,我们就可以省略接口并直接注入具体类型。因为我们在这里主要关注坚实的原则,所以我决定包括接口(这本身并不是一件坏事)。没有接口的一个优点是,使用混凝土类型可以降低单元测试中模拟聚合的复杂性。只要你不试着把逻辑放进去,我看不出有什么缺点。

图案-映射立面

我们可以创建一个映射 façade 而不是聚合,而不是在前面的案例中所做的。使用立面的代码更加优雅。façade 的职责与聚合相同,但它实现了接口,而不是公开属性。

以下是一个例子:

public interface IProductMapperService : IMapper<Product, ProductDetails>, IMapper<InsertProduct, Product>, IMapper<UpdateProduct, Product>
{
}
public class ProductMapperService : IProductMapperService
{
    private readonly IMapper<Product, ProductDetails> _entityToDto;
    private readonly IMapper<InsertProduct, Product> _insertDtoToEntity;
    private readonly IMapper<UpdateProduct, Product> _updateDtoToEntity;
    // ...
    public ProductDetails Map(Product entity)
    {
        return _entityToDto.Map(entity);
    }
    public Product Map(InsertProduct dto)
    {
        return _insertDtoToEntity.Map(dto);
    }
    public Product Map(UpdateProduct dto)
    {
        return _updateDtoToEntity.Map(dto);
    }
}
public class ProductsController : ControllerBase
{
    private readonly IProductMapperService _mapper;
    // ...
    public ProductDetails Method()
    {
        var product = default(Product);
        var dto = _mapper.Map(product);
        return dto;
    }
}

ProductsController的角度来看,我总是觉得写_mapper.Map(…)而不是_mapper.SomeMapper.Map(…)更干净。控制器不想知道什么映射器正在进行什么映射;它唯一想要的就是映射需要映射的内容。

现在,我们已经介绍了一些映射选项,并探讨了太多依赖项代码的味道,现在是时候继续我们的对象映射之旅了,我们将使用“基于类固醇的映射外观”

项目-测绘服务

目标是使用通用接口简化 mapper façade 的实现。

我们将使用第三个图表来实现这一目标。这里有一个提醒:

Figure 13.4 – Object mapping using a single IMapper interface

图 13.4–使用单个 IMapper 接口的对象映射

我没有将接口命名为IMapper,而是发现IMappingService更合适,因为它没有映射任何东西;它是一个调度器,将映射请求发送给正确的映射程序。让我们来看一看:

namespace Core.Interfaces
{
    public interface IMappingService
    {
        TDestination Map<TSource, TDestination>(TSource entity);
    }
}

这个界面是不言自明的;它将任意TSource映射到任意TDestination

在实现端,我们正在利用服务定位器模式,所以我将实现称为ServiceLocatorMappingService

namespace Web.Services
{
    public class ServiceLocatorMappingService : IMappingService
    {
        private readonly IServiceProvider _serviceProvider;
        public ServiceLocatorMappingService(IServiceProvider serviceProvider)
        {
            _serviceProvider = serviceProvider ?? throw new ArgumentNullException(nameof(serviceProvider));
        }
        public TDestination Map<TSource, TDestination>(TSource entity)
        {
            var mapper = _serviceProvider.GetService<IMapper<TSource, TDestination>>();
            if (mapper == null)
            {
                throw new MapperNotFoundException (typeof(TSource), typeof(TDestination));
            }
            return mapper.Map(entity);
        }
    }
}

逻辑很简单:

  • 找到合适的IMapper<TSource, TDestination> 服务,然后用它映射实体。
  • 如果你找不到,扔一个MapperNotFoundException

设计的关键是将映射器注册到 IoC 容器中,而不是服务本身。然后,我们使用映射器,而不知道它们中的每一个,如前一个示例中所示。ServiceLocatorMappingService类不知道任何映射器;它只是在需要时动态地请求一个。

提示

我不太喜欢应用代码中的服务定位器模式。服务定位器是一种代码气味,有时甚至更糟,是一种反模式。然而,有时它会派上用场,就像在这种情况下一样。我们并不是在试图欺骗依赖注入;相反,我们利用了它的力量。此外,该服务位置需要在某处完成。通常,我更喜欢让框架为我做这件事,但在本例中,我们明确地做了,这很好。

当以某种方式获取依赖项时,服务定位器的使用是错误的,这种方式消除了从组合根控制程序组合的可能性。这将违反国际奥委会的原则。

在这种情况下,我们从 IoC 容器动态加载映射器,但是这限制了容器控制注入内容的能力,但是对于这种类型的实现来说,这是可以接受的。

现在,我们可以在需要映射的任何地方注入该服务,然后直接使用它。我们已经注册了映射程序,所以我们只需要将IMappingService绑定到其ServiceLocatorMappingService实现并更新使用者。

如果我们看一下ProductRepository的新版本,我们现在有以下内容:

namespace Infrastructure.Data.Repositories
{
    public class ProductRepository : IProductRepository
    {
        private readonly ProductContext _db;
 private readonly IMappingService _mappingService;
        public ProductRepository(ProductContext db, IMappingService mappingService)
        {
            _db = db ?? throw new ArgumentNullException(nameof(db));
            _mappingService = mappingService ?? throw new ArgumentNullException(nameof(mappingService));
        }

这里,与上一个示例不同,我们注入了单个服务,而不是每个映射器注入一个服务:

        public IEnumerable<Product> All()
        {
            return _db.Products.Select(p => _mappingService.Map<Models.Product, Product>(p));
        }
        // ...
        public Product FindById(int productId)
        {
            var product = _db.Products.Find(productId);
            return _mappingService.Map<Models.Product, Product>(product);
        }
        public void Insert(Product product)
        {
            var data = _mappingService.Map<Product, Models.Product>(product);
            _db.Products.Add(data);
            _db.SaveChanges();
        }
        //...
    }
}

这与之前的示例非常相似,但我们用新服务(突出显示的行)替换了映射器。最后一件是 DI 装订:

services.AddSingleton<IMappingService, ServiceLocatorMappingService>();

就这样;我们现在有了一个通用映射服务,它将映射委托给我们在 IoC 容器中注册的任何映射器。

笔记

我使用单态生存期是因为ServiceLocatorMappingService 没有状态;每次都可以重用它,而不会对映射逻辑产生任何影响。

最好的部分是,这还不是对象映射的最高点。我们有一个工具要探索,名为 AutoMapper。

项目-汽车制造商

我们刚刚介绍了实现对象映射的不同方法,但这里我们将利用一个名为 AutoMapper 的开源工具,它为我们实现对象映射,而不是我们自己实现对象映射。

如果有一个工具已经做到了这一点,为什么还要费心去学习这些呢?这样做有几个原因:

  • 理解这些概念很重要;您并不总是需要像 AutoMapper 这样的成熟工具。
  • 它使我们有机会涵盖我们应用于映射器的多种模式,这些模式也可以应用于其他任何具有不同职责的组件。总之,在这个对象映射过程中,您应该已经学习了多种新技术。
  • 最后,我们更深入地研究了应用坚实的原则来编写更好的程序。

该项目也是 Clean 架构示例的副本。这个项目与其他项目最大的区别在于,我们不需要定义任何接口,因为 AutoMapper 公开了一个包含我们需要的所有方法的IMapper接口,等等。

要安装 AutoMapper,您可以使用 CLI(dotnet add package AutoMapper)、Visual Studio 的 NuGet 软件包管理器或手动更新您的.csproj来加载AutoMapperNuGet 软件包。

定义映射器的最佳方法是使用 AutoMapper 的配置文件机制。概要文件是从AutoMapper.Profile继承的简单类,包含从一个对象到另一个对象的映射。我们可以使用与前面类似的分组,但不实现接口。

最后,我们可以使用AutoMapper.Extensions.Microsoft.DependencyInjection包扫描一个或多个程序集,将所有配置文件加载到 AutoMapper 中,而不是手动注册配置文件。

AutoMapper 的功能远不止这些,但它有足够的在线资源,包括官方文档,帮助您深入挖掘该工具。

基础设施项目中,我们需要将Data.Models.Product映射到Entities.Product,反之亦然。我们可以在我们命名为ProductProfile的配置文件中实现这一点:

namespace Infrastructure.Data.Mappers
{
    public class ProductProfile : Profile
    {
        public ProductProfile()
        {
            CreateMap<Data.Models.Product, Core.Entities.Product>().ReverseMap();
        }
    }
}

AutoMapper 中的配置文件只是一个类,您可以在构造函数中创建映射。Profile类为您添加了所需的方法,例如CreateMap方法。那有什么用?

CreateMap<Data.Models.Product, Core.Entities.Product>()告诉 AutoMapper 注册一个将Data.Models.Product映射到Core.Entities.Product的映射器。然后ReverseMap()方法告诉 AutoMapper 反转该地图,因此从Core.Entities.ProductData.Models.Product。这就是我们现在所需要的,因为 AutoMapper 使用约定映射属性,并且我们的两个类都具有相同名称的相同属性集。

Web项目的角度来看,我们也需要一些映射器,我将其分为以下两个配置文件:

namespace Web.Mappers
{
    public class StocksProfile : Profile
    {
        public StocksProfile()
        {
            CreateMap<Product, StocksController.StockLevel>();
        }
    }
    public class ProductProfile : Profile
    {
        public ProductProfile()
        {
            CreateMap<Product, ProductsController.ProductDetails>();
        }
    }
}

我们本可以将它们合并为一个,但我决定不合并,因为我觉得每个控制器一个配置文件是有意义的,尤其是随着应用的增长。

下面是一个示例,说明了单个配置文件中的多个贴图:

namespace Web.Mappers
{
    public class ProductProfile : Profile
    {
        public ProductProfile()
        {
            CreateMap<Product, ProductsController.ProductDetails>();
            CreateMap<Product, StocksController.StockLevel>();
        }
    }
}

要扫描来自合成根的概要文件,我们可以使用AddAutoMapper扩展方法之一(来自AutoMapper.Extensions.Microsoft.DependencyInjection包):

services.AddAutoMapper(
    GetType().Assembly,
    typeof(Infrastructure.Data. Mappers.ProductProfile).Assembly
);

该方法接受一个params Assembly[] assemblies参数,这意味着我们可以向它传递一个数组或多个Assembly实例。

第一个AssemblyWeb组件,通过调用GetType().AssemblyStartup类获取(该代码在Startup.ConfigureServices方法中)。从那里,AutoMapper 应该可以找到StocksProfileProductProfile类。

第二个Assembly基础设施组件,使用Data.Mappers.ProductProfile类获取。需要注意的是,该组件中的任何类型都会为我们提供关于Infrastructure组件的参考;不需要找到继承自Profile的类。从那里,AutoMapper 应该可以找到Data.Mappers.ProductProfile类。

扫描像这样的类型的好处在于,一旦您向 IoC 容器注册 AutoMapper,您就可以在任何已注册的程序集中添加配置文件,它们会自动加载;除了编写有用的代码,以后不需要做任何其他事情。扫描组件也鼓励按照惯例进行组合,从而使其更易于长期维护。程序集扫描的缺点是,当某些内容未注册时,很难进行调试。也很难发现注册模块有什么问题。

现在我们已经创建了配置文件并在 IoC 容器中注册了它们,现在是使用 AutoMapper 的时候了。让我们来看看 AutoT0.类:

namespace Infrastructure.Data.Repositories
{
    public class ProductRepository : IProductRepository
    {
        private readonly ProductContext _db;
        private readonly IMapper _mapper;
        public ProductRepository(ProductContext db, IMapper mapper)
        {
            _db = db ?? throw new ArgumentNullException(nameof(db));
            _mapper = mapper ?? throw new ArgumentNullException(nameof(mapper));
        }

在前面的代码中,我们注入了 AutoMapper 的IMapper接口。

        public IEnumerable<Product> All()
        {
#if USE_PROJECT_TO
            // Transposed to a Select() possibly optimal in some cases; previously known as "Queryable Extensions".
            return _mapper.ProjectTo<Product>(_db.Products);
#else
            // Manual Mapping (query the whole object, then map it; could lead to "over-querying" the database)
            return _db.Products.Select(p => _mapper.Map<Product>(p));
#endif
        }

All方法(前面的代码块)公开了我后面描述的两种映射集合的方法。接下来是另外两个更新的方法:

        // ...
        public Product FindById(int productId)
        {
            var product = _db.Products.Find(productId);
            return _mapper.Map<Product>(product);
        }
        public void Insert(Product product)
        {
            var data = _mapper.Map<Models.Product>(product);
            _db.Products.Add(data);
            _db.SaveChanges();
        }
        // ...
    }
}

正如你所看到的,它与其他选项非常相似;我们注入一个IMapper,然后使用它映射实体。唯一代码多一点的方法是All()方法。原因是我添加了两种方法来映射集合。

第一种方式是使用IQueryable接口限制查询字段数量的ProjectTo<TDestination>()方法。在我们的例子中,这不会改变什么,因为我们需要整个实体。建议 EF 使用该方法。

All()方法应简单如下:

public IEnumerable<Product> All()
{
    return _mapper.ProjectTo<Product>(_db.Products);
}

第二种方法直接使用Map方法,正如我们在实现中所做的那样,并在投影中使用,如下所示:

public IEnumerable<Product> All()
{
    return _db.Products.Select(p => _mapper.Map<Product>(p));
}

所有其他案例都非常简单,使用 AutoMapper 的Map方法。两个控制器执行相同的操作,如下所示:

namespace Web.Controllers
{
    [ApiController]
    [Route("[controller]")]
    public class ProductsController : ControllerBase
    {
        private readonly IProductRepository _productRepository;
        private readonly IMapper _mapper;
        public ProductsController(IProductRepository productRepository, IMapper mapper)
        {
            _productRepository = productRepository ?? throw new ArgumentNullException (nameof(productRepository));
            _mapper = mapper ?? throw new ArgumentNullException(nameof(mapper));
        }
        [HttpGet]
        public ActionResult<IEnumerable<ProductDetails>> Get()
        {
            var products = _productRepository
                .All()
                .Select(p => _mapper.Map<ProductDetails>(p));
            return Ok(products);
        }
        // ...
    }

下面是使用AutoMapper将域实体映射到 DTO 的StocksController(突出显示的行):

    [ApiController]
    [Route("products/{productId}/")]
    public class StocksController : ControllerBase
    {
 private readonly IMapper _mapper;
        public StocksController(IMapper mapper)
        {
            _mapper = mapper ?? throw new ArgumentNullException(nameof(mapper));
        }
        [HttpPost("add-stocks")]
        public ActionResult<StockLevel> Add(
            int productId,
            [FromBody] AddStocksCommand command,
            [FromServices] AddStocks useCase
        )
        {
            var product = useCase.Handle(productId, command.Amount);
            var stockLevel = _mapper.Map<StockLevel>(product);
            return Ok(stockLevel);
        }
        [HttpPost("remove-stocks")]
        public ActionResult<StockLevel> Remove(
            int productId,
            [FromBody] RemoveStocksCommand command,
            [FromServices] RemoveStocks useCase
        )
        {
            try
            {
                var product = useCase.Handle(productId, command.Amount);
                var stockLevel = _mapper.Map<StockLevel>(product);
                return Ok(stockLevel);
            }
            catch (NotEnoughStockException ex)
            {
                return Conflict(new
                {
                    ex.Message,
                    ex.AmountToRemove,
                    ex.QuantityInStock
                });
            }
        }
        // ...
    }
}

我想补充的最后一个细节是,我们可以在应用启动时断言映射器配置是否有效。这不会指向缺少映射器,但会验证已注册映射器的配置是否正确。在Startup类中,您可以编写以下代码:

public void Configure(IApplicationBuilder app, IWebHostEnvironment env, IMapper mapper)
{
    // ...
    mapper.ConfigurationProvider.AssertConfigurationIsValid();
    // ...
}

这就结束了汽车制造商项目。此时,您应该开始熟悉对象映射。

我建议您在项目需要对象映射时,评估 AutoMapper 是否是适合该工作的工具。如果不是,则始终可以加载其他工具或实现自己的映射逻辑。

最后说明

AutoMapper 是基于约定的,它自己做很多事情,而不需要我们开发人员的任何配置。它还基于配置,缓存转换以提高性能。我们还可以创建型转换器、值分解器、值转换器、等。AutoMapper 让我们远离编写无聊的映射代码,我还没有找到更好的工具。

总结

在大多数情况下,对象映射是可以避免的现实。但是,正如我们在本章中所看到的,有几种实现对象映射的方法,将这种责任从应用的其他组件中移开。其中一种方法是 AutoMapper,这是一种为我们提供的开源工具,它为我们提供了许多配置对象映射的选项。

现在让我们看看对象映射如何帮助我们遵循实体原则:

  • S:它有助于从其他类中提取映射责任,将映射逻辑封装到映射器对象或自动映射配置文件中。
  • O:通过注入映射器,我们可以更改映射逻辑,而无需更改其使用者的代码。
  • L:不适用。
  • I:我们看到了将映射程序划分为更小接口的不同方法。AutoMapper 也不例外;它公开了IMapper接口,并在引擎盖下使用其他接口和实现。
  • D:我们所有的代码都只依赖于接口,将实现的绑定移动到组合根。

现在我们已经完成了对象映射,在下一章中,我们将探索中介体CQRS模式。然后,我们将结合我们的知识学习一种新的应用级体系结构,名为垂直切片体系结构

问题

让我们来看看几个练习题:

  1. 注入一个聚合服务而不是多个服务真的会让我们的系统变得更好吗?
  2. 使用映射器帮助我们将责任从消费者提取到映射器类,这是真的吗?
  3. 您是否应该始终使用 AutoMapper?
  4. 当使用 AutoMapper 时,是否应该将映射代码封装到配置文件中?
  5. 有多少依赖项应该开始升起一个标志,告诉您将太多的依赖项注入到一个类中?

进一步阅读

以下是我们在本章学到的一些链接:

  • 如果你想要更多的对象映射,我在 2017 年写了一篇文章,题目是设计模式:ASP.NET Core Web API、服务和存储库【第 9 部分:NinjaMappingService 和 Façade 模式https://net5.link/hxYf
  • AutoMapper 官方网站:https://net5.link/5AUZ
  • AutoMapper 使用指南是一个很好的做/不做列表,帮助您正确使用 AutoMapper,由图书馆作者撰写:https://net5.link/tTKg

十四、中介和 CQRS 设计模式

在本章中,我们将探讨我们将在下一章中使用的关于垂直切片架构的构建块。我们首先简要介绍垂直切片体系结构,让您了解最终目标,了解我们的发展方向。然后我们探索了中介设计模式,它在我们的应用的组件之间扮演着中间人的角色。这就引出了命令查询责任分离CQRS模式),它描述了如何组织我们的逻辑。最后,为了将所有这些结合起来,我们将探索 MediatR,一种中介设计模式的开源实现。

本章将介绍以下主题:

  • 垂直切片体系结构的高级概述
  • 实现中介模式
  • 实现 CQRS 模式
  • 使用 MediatR 作为中介

让我们从最终目标开始。

垂直切片架构的高层概述

在开始之前,让我们看看本章和下一章的最终目标。通过这种方式,在本章中更容易跟踪实现该目标的进度。

正如我们刚刚在关于分层的章节中所述,层基于共享责任将类分组在一起。因此,包含数据访问代码的类是数据访问层(或基础结构)的一部分。在图中,层通常由水平切片表示,如下所示:

Figure 14.1 – Diagram representing layers as horizontal slices

图 14.1–将层表示为水平切片的图

“垂直切片”的名字就是由此而来的;垂直切片表示每个图层中创建特定特征的部分。因此,我们不是将应用划分为各个层,而是将其按功能进行划分。特性管理其数据访问代码、域逻辑,甚至可能管理其表示代码。通过这样做,我们可以将各个功能彼此分离,但要将每个功能的组件紧密地连接在一起。当我们使用分层添加、更新或删除特征时,我们会对一个或多个层进行更改。不幸的是,“一个或多个层”经常被翻译成“所有层”。对于垂直切片,所有功能都被隔离,允许我们独立设计它们。

从分层的角度来看,这就像将您对软件的思考方式翻转到 90 度的角度:

Figure 14.2 – Diagram representing a vertical slice crossing all layers

图 14.2–表示穿过所有层的垂直切片的示意图

垂直切片架构没有规定CQR中介模式或中介模式的使用,但这些工具和模式可以很好地结合在一起,正如我们在下一章中看到的。

我们的目标是将功能封装在一起,使用 CQR 将应用划分为请求(命令和查询),并使用 MediatR 作为 CQR 管道的中介,将各个部分彼此分离。

您现在知道了计划——我们稍后将探索垂直切片架构;同时,让我们从中介设计模式开始。

实现中介模式

介体模式是另一种 GoF 设计模式,它控制对象之间的交互方式(使之成为一种行为模式)。

目标

调解人的角色是管理对象(同事)之间的通信。这些同事不应该直接交流,而应该使用调解人。调解人有助于打破这些同事之间的紧密联系。

简而言之,调解人是同事之间传递信息的中间人

设计

让我们从一些 UML 图开始。从一个非常高的层次来看,调解员模式由调解员和同事组成:

Figure 14.3 – Class diagram representing the Mediator pattern

图 14.3–表示中介模式的类图

当系统中的对象希望向一个或多个同事发送消息时,它将使用中介。以下是它的工作原理示例:

Figure 14.4 – Sequence diagram of a mediator relaying messages to colleagues

图 14.4–调解人向同事转发消息的序列图

这对同事也有好处;要相互交谈,同事还必须使用调解人。我们可以按如下方式更新图表:

Figure 14.5 – Class diagram representing the Mediator pattern including colleagues' collaboration

图 14.5–表示中介模式(包括同事协作)的类图

在这个图中,ConcreteColleague1既是一个同事,也是调解人的消费者。例如,该同事可以使用中介向另一位同事发送消息,如下所示:

Figure 14.6 – Sequence diagram representing colleague1 communicating  with colleague2 through the mediator

图 14.6–表示通过中介器与 CollegUE2 通信的 CollegUE1 的序列图

从调解人的角度来看,它的实现很可能包含一组同事进行交流,如下所示:

图 14.7–表示一个简单假设的具体中介实现的类图

所有这些 UML 图都很有用,但已经足够了;现在是看一些代码的时候了。

项目-调解人(IMediator)

中介项目表示使用中介模式的简化聊天系统。让我们从界面开始:

namespace Mediator
{
    public interface IMediator
    {
        void Send(Message message);
    }
    public interface IColleague
    {
        string Name { get; }
        void ReceiveMessage(Message message);
    }
    public class Message
    {
        public Message(IColleague from, string content)
        {
            Sender = from ?? throw new ArgumentNullException(nameof(from));
            Content = content ?? throw new ArgumentNullException(nameof(content));
        }
        public IColleague Sender { get; }
        public string Content { get; }
    }
}

系统由以下部分组成:

  • IMediator,它发送消息。
  • IColleague,它接收消息并具有Name属性(用于输出内容)。
  • Message类,表示IColleague实现发送的消息。

现在来看看IMediator接口的实现。ConcreteMediator不加区别地向所有IColleague实例广播消息:

public class ConcreteMediator : IMediator
{
    private readonly List<IColleague> _colleagues;
    public ConcreteMediator(params IColleague[] colleagues)
    {
        if (colleagues == null) { throw new ArgumentNullException(nameof(colleagues)); }
        _colleagues = new List<IColleague>(colleagues);
    }
    public void Send(Message message)
    {
        foreach (var colleague in _colleagues)
        {
            colleague.ReceiveMessage(message);
        }
    }
}

调解人很简单;它将收到的所有消息转发给它认识的每一位同事。模式的最后一部分是ConcreteColleague,它将消息委托给IMessageWriter<TMessage>接口:

public class ConcreteColleague : IColleague
{
    private readonly IMessageWriter<Message> _messageWriter;
    public ConcreteColleague(string name, IMessageWriter<Message> messageWriter)
    {
        Name = name ?? throw new ArgumentNullException(nameof(name));
        _messageWriter = messageWriter ?? throw new ArgumentNullException(nameof(messageWriter));
    }
    public string Name { get; }
    public void ReceiveMessage(Message message)
    {
        _messageWriter.Write(message);
    }
}

那个类再简单不过了:它在创建时使用一个名称和IMessageWriter<TMessage>实现,然后存储一个引用供将来使用。

IMessageWriter<TMessage>界面充当演示者,控制消息的显示方式,与中介模式无关。然而,这是管理ConcreteColleague对象如何处理消息的一种很好的方法。代码如下:

namespace Mediator
{
    public interface IMessageWriter<TMessage>
    {
        void Write(TMessage message);
    }
}

我们现在就用那个聊天系统吧。系统的使用者正在进行以下集成测试:

public class MediatorTest
{
    [Fact]
    public void Send_a_message_to_all_colleagues()
    {
        // Arrange
        var (millerWriter, miller) = CreateConcreteColleague("Miller");
        var (orazioWriter, orazio) = CreateConcreteColleague("Orazio");
        var (fletcherWriter, fletcher) = CreateConcreteColleague("Fletcher");

测试首先定义了三名同事,他们有自己的TestMessageWriter实现(名字是随机生成的)。

        var mediator = new ConcreteMediator(miller, orazio, fletcher);
        var expectedOutput = @"[Miller]: Hey everyone!
[Orazio]: What's up Miller?
[Fletcher]: Hey Miller!
";

在前面的Arrange块的第二部分中,我们创建了被测对象(mediator),并注册了三名同事。在Arrange块的末尾,我们还定义了测试的预期输出。需要注意的是,我们控制TestMessageWriter实现的输出(定义在MediatorTest类末尾)。

        // Act
        mediator.Send(new Message(
            from: miller,
            content: "Hey everyone!"
        ));
        mediator.Send(new Message(
            from: orazio,
            content: "What's up Miller?"
        ));
        mediator.Send(new Message(
            from: fletcher,
            content: "Hey Miller!"
        ));

在前面的Act块中,我们按照预期顺序通过mediator发送三条消息。

        // Assert
        Assert.Equal(expectedOutput, millerWriter.Output.ToString());
        Assert.Equal(expectedOutput, orazioWriter.Output.ToString());
        Assert.Equal(expectedOutput, fletcherWriter.Output.ToString());
    }

Assert块中,我们确保所有同事都收到了所有消息。

    private (TestMessageWriter, ConcreteColleague) CreateConcreteColleague(string name)
    {
        var messageWriter = new TestMessageWriter();
        var concreateColleague = new ConcreteColleague(name, messageWriter);
        return (messageWriter, concreateColleague);
    }

CreateConcreteColleague方法是一种辅助方法,它封装了同事的创建,使我们能够编写测试的Arrange部分中使用的一行声明。

    private class TestMessageWriter : IMessageWriter<Message>
    {
        public StringBuilder Output { get; } = new StringBuilder();
        public void Write(Message message)
        {
            Output.AppendLine($"[{message.Sender.Name}]: {message.Content}");
        }
    }
}

最后,TestMessageWriter类将消息写入StringBuilder中,使得断言输出变得容易。如果我们要为此构建一个 GUI,我们可以编写一个IMessageWriter<Message>的实现来写入该 GUI;例如,在 web UI 的情况下,可以使用信号器

为了总结示例,使用者(单元测试)通过中介向同事发送消息。这些消息写在每个TestMessageWriterStringBuilder实例中。最后,我们断言所有同事都收到了预期的消息。这说明了 Mediator 设计模式的基本思想。

从理论上讲,同事应该通过调解员进行沟通,因此没有调解员,调解员模式就不完整。让我们实现一个聊天室来解决这个概念。

项目-调解人(iChatrom)

在上一个代码示例中,类是以中介模式参与者命名的,如图 14.7 所示。虽然这个示例非常类似,但它使用特定于域的名称,并实现了更多的方法来管理系统。让我们从抽象开始:

namespace Mediator
{
    public interface IChatRoom
    {
        void Join(IParticipant participant);
        void Send(ChatMessage message);
    }

IChatRoom接口是中介,它定义了两种方法而不是一种:

  • Join,允许IParticipant加入IChatRoom
  • Send,向其他人发送消息。
    public interface IParticipant
    {
        string Name { get; }
        void Send(string message);
        void ReceiveMessage(ChatMessage message);
        void ChatRoomJoined(IChatRoom chatRoom);
    }

IParticipant接口还有几个方法:

  • Send,发送消息。
  • ReceiveMessage,接收来自其他IParticipant对象的消息。
  • ChatRoomJoined,确认IParticipant对象已成功加入聊天室。
    public class ChatMessage
    {
        public ChatMessage(IParticipant from, string content)
        {
            Sender = from ?? throw new ArgumentNullException(nameof(from));
            Content = content ?? throw new ArgumentNullException(nameof(content));
        }
        public IParticipant Sender { get; }
        public string Content { get; }
    }
}

ChatMessage与前面的Message类相同,只是引用了IParticipant而不是IColleague

现在让我们看一下的IParticipant实现:

public class User : IParticipant
{
    private IChatRoom _chatRoom;
    private readonly IMessageWriter<ChatMessage> _messageWriter;
    public User(IMessageWriter<ChatMessage> messageWriter, string name)
    {
        _messageWriter = messageWriter ?? throw new ArgumentNullException(nameof(messageWriter));
        Name = name ?? throw new ArgumentNullException(nameof(name));
    }
    public string Name { get; }
    public void ChatRoomJoined(IChatRoom chatRoom)
    {
        _chatRoom = chatRoom;
    }
    public void ReceiveMessage(ChatMessage message)
    {
        _messageWriter.Write(message);
    }
    public void Send(string message)
    {
        _chatRoom.Send(new ChatMessage(this, message));
    }
}

User类代表我们的默认IParticipant。一个User实例只能在一个IChatRoom中聊天;这是在调用ChatRoomJoined方法时设置的。当它收到一条消息时,它将其委托给它的IMessageWriter<ChatMessage>。最后,User实例可以通过将消息委托给中介(IChatRoom)来发送消息。

我们可以创建一个ModeratorAdministratorSystemAlerts或我们认为合适的任何其他IParticipant实现,但不在本示例中。我将把这留给你们来试验中介模式。

现在来看IChatRoom实施:

public class ChatRoom : IChatRoom
{
    private readonly List<IParticipant> _participants = new List<IParticipant>();
    public void Join(IParticipant participant)
    {
        _participants.Add(participant);
        participant.ChatRoomJoined(this);
        Send(new ChatMessage(participant, "Has joined the channel"));
    }
    public void Send(ChatMessage message)
    {
        _participants.ForEach(participant => participant.ReceiveMessage(message));
    }
}

ChatRoom甚至比User更苗条;它允许IParticipant加入并向所有注册参与者发送ChatMessage。加入ChatRoom时,它会在IParticipant上保留一个引用,告诉IParticipant它已成功加入,然后向所有参与者发送ChatMessage通知新加入者。

就这样,;我们有一个经典的中介实现。在移动到下一节之前,让我们看一看在 To.T1A.的 Ty0T0.实例,这是另一个集成测试:

public class ChatRoomTest
{
    [Fact]
    public void ChatRoom_participants_should_send_and_receive_messages()
    {
        // Arrange
        var (kingChat, king) = CreateTestUser("King");
        var (kelleyChat, kelley) = CreateTestUser("Kelley");
        var (daveenChat, daveen) = CreateTestUser("Daveen");
        var (rutterChat, _) = CreateTestUser("Rutter");
        var chatroom = new ChatRoom();

我们在Arrange部分创建了四个用户及其各自的TestMessageWriter实例,与之前一样(名称也是随机生成的)。

        // Act
        chatroom.Join(king);
        chatroom.Join(kelley);
        king.Send("Hey!");
        kelley.Send("What's up King?");
        chatroom.Join(daveen);
        king.Send("Everything is great, I joined the CrazyChatRoom!");
        daveen.Send("Hey King!");
        king.Send("Hey Daveen");

Act块中,我们的测试用户加入chatroom实例并向其中发送消息。

        // Assert
        Assert.Empty(rutterChat.Output.ToString());

由于 Rutter 没有加入聊天室,我们不希望收到任何消息。

        Assert.Equal(@"[King]: Has joined the channel
[Kelley]: Has joined the channel
[King]: Hey!
[Kelley]: What's up King?
[Daveen]: Has joined the channel
[King]: Everything is great, I joined the CrazyChatRoom!
[Daveen]: Hey King!
[King]: Hey Daveen
", kingChat.Output.ToString());

由于 King 是第一个加入该频道的人,因此预计他会收到所有消息。

        Assert.Equal(@"[Kelley]: Has joined the channel
[King]: Hey!
[Kelley]: What's up King?
[Daveen]: Has joined the channel
[King]: Everything is great, I joined the CrazyChatRoom!
[Daveen]: Hey King!
[King]: Hey Daveen
", kelleyChat.Output.ToString());

Kelley 是第二个加入聊天室的用户,因此输出包含几乎所有的消息。

        Assert.Equal(@"[Daveen]: Has joined the channel
[King]: Everything is great, I joined the CrazyChatRoom!
[Daveen]: Hey King!
[King]: Hey Daveen
", daveenChat.Output.ToString());
    }

在金和凯利交换了几句话后,达文加入了进来,因此谈话预计将在稍后开始。

    private (TestMessageWriter, User) CreateTestUser(string name)
    {
        var writer = new TestMessageWriter();
        var user = new User(writer, name);
        return (writer, user);
    }

CreateTestUser方法有助于简化测试用例的Arrange部分,与之前类似。

    private class TestMessageWriter : IMessageWriter<ChatMessage>
    {
        public StringBuilder Output { get; } = new StringBuilder();
        public void Write(ChatMessage message)
        {
            Output.AppendLine($"[{message.Sender.Name}]: {message.Content}");
        }
    }
}

TestMessageWriter实现与前面的示例相同,在StringBuilder实例中累积消息。

为了总结测试用例,我们有四个用户;其中三人在不同的时间加入了同一聊天室,聊了一会儿。每个人的输出都不同,因为您现在加入的时间很重要。

结论

正如我们在前面两个项目中所探讨的,一个中介允许我们解耦系统的组件。调解人是同事之间的中间人,在小型聊天室样本中为我们提供了很好的服务。

现在让我们看看中介模式如何帮助我们遵循坚实的原则:

  • S:调解人从同事身上提取沟通责任。
  • O:通过中介传递消息,我们可以创建新同事,改变现有同事的行为,而不会影响其他同事。如果我们需要一位新同事;我们可以向调解人登记,瞧!另一方面,如果我们需要新的中介行为,我们总是可以实现新的中介并保留现有同事的实现。
  • L:不适用。
  • I:系统分为多个小接口(IMediatorIColleague)。
  • D:中介模式的所有参与者都完全依赖于其他接口。

接下来,我们将探讨 CQR,它将允许我们清楚地分离命令和查询,从而形成一个更易于维护的应用。

实施 CQRS 模式

CQRS代表命令查询责任分离的。我们可以通过两种方式应用 CQR:

  • 将请求划分为命令和查询。
  • 将 CQRS 概念应用到更高的层次,从而形成分布式系统。

在本章中,我们将继续使用第一个定义,但我们将在第 16 章《微服务架构简介》中讨论第二个定义。

目标

目标是将所有请求分为两类:命令和查询。

命令会改变应用的状态。例如,创建、更新和删除实体都是命令。命令不返回值。

另一方面,查询读取应用的状态,但从不更改它。例如,读取订单、读取订单历史记录和检索用户配置文件都是查询。

通过执行这个划分,我们在 mutator 和 accessor 请求之间创建了一个清晰的关注点分离。

设计

没有明确的设计,但对我们来说,命令流程应该如下所示:

Figure 14.8 – Sequence diagram representing the abstract flow of a command

图 14.8–表示命令抽象流的序列图

使用者创建一个命令对象并将其发送给命令处理程序,然后命令处理程序将其应用于应用。在本例中,我将其命名为Entities,但它可以通过 HTTP 向数据库或 web API 调用发送 SQLUPDATE命令;实施细节并不重要。

查询的概念与相同,但它返回一个值。非常重要的是,查询不能更改应用的状态,而是查询数据,如下所示:

Figure 14.9 – Sequence diagram representing the abstract flow of a query

图 14.9–表示查询抽象流的序列图

与命令类似,使用者创建一个查询对象并将其发送给处理程序,然后处理程序执行一些逻辑来检索和返回请求的数据。您可以将Entities替换为处理程序查询数据所需的任何内容。

说够了——让我们看一个 CQRS 项目。

项目名称:CQRS

上下文:我们需要构建一个改进版的聊天系统。旧系统运行得很好,我们现在需要扩大它的规模。调解人对我们很有帮助,所以我们保留了那个部分,我们选择了 CQR 来帮助我们完成这个新的、改进的设计。过去参与者仅限于一个聊天室,但现在参与者应该能够同时在多个聊天室聊天。

新系统由三个命令和两个查询组成:

  • 参与者必须能够加入聊天室。
  • 参与者必须能够离开聊天室。
  • 参与者必须能够向聊天室发送消息。
  • 参与者必须能够获得加入聊天室的参与者列表。
  • 参与者必须能够从聊天室检索现有消息。

前三个是命令,后两个是查询。该系统由大量使用 C#泛型的中介支持,如下所示:

public interface IMediator
{
    TReturn Send<TQuery, TReturn>(TQuery query)
        where TQuery: IQuery<TReturn>;
    void Send<TCommand>(TCommand command)
        where TCommand : ICommand;
    void Register<TCommand>(ICommandHandler<TCommand> commandHandler)
        where TCommand : ICommand;
    void Register<TQuery, TReturn>(IQueryHandler<TQuery, TReturn> commandHandler)
        where TQuery : IQuery<TReturn>;
}
public interface ICommand { }
public interface ICommandHandler<TCommand>
    where TCommand : ICommand
{
    void Handle(TCommand command);
}
public interface IQuery<TReturn> { }
public interface IQueryHandler<TQuery, TReturn>
    where TQuery : IQuery<TReturn>
{
    TReturn Handle(TQuery query);
}

如果您不熟悉泛型,这可能会让人望而生畏,但代码要比看起来简单得多。首先,我们有两个空接口:ICommandIQuery<TReturn>。我们可以省略它们,但它们有助于识别命令和查询;它们有助于描述我们的意图。

然后我们有两个接口来处理命令或查询。让我们从要处理的每种命令类型要实现的接口开始:

public interface ICommandHandler<TCommand>
    where TCommand : ICommand
{
    void Handle(TCommand command);
}

该接口定义了一个将命令作为参数的Handle方法。泛型参数TCommand表示实现接口的类处理的命令类型。查询处理程序接口相同,但它也指定了返回值:

public interface IQueryHandler<TQuery, TReturn>
    where TQuery : IQuery<TReturn>
{
    TReturn Handle(TQuery query);
}

中介抽象允许使用我们刚刚探索过的通用接口注册命令和查询处理程序。它还支持发送命令和查询。然后我们有ChatMessage,它类似于最后两个样本(增加了创建日期):

public class ChatMessage
{
    public ChatMessage(IParticipant sender, string message)
    {
        Sender = sender ?? throw new ArgumentNullException(nameof(sender));
        Message = message ?? throw new ArgumentNullException(nameof(message));
        Date = DateTime.UtcNow;
    }
    public DateTime Date { get; }
    public IParticipant Sender { get; }
    public string Message { get; }
}

随后是更新的IParticipant界面:

public interface IParticipant
{
    string Name { get; }
    void Join(IChatRoom chatRoom);
    void Leave(IChatRoom chatRoom);
    void SendMessageTo(IChatRoom chatRoom, string message);
    void NewMessageReceivedFrom(IChatRoom chatRoom, ChatMessage message);
    IEnumerable<IParticipant> ListParticipantsOf(IChatRoom chatRoom);
    IEnumerable<ChatMessage> ListMessagesOf(IChatRoom chatRoom);
}

IParticipant接口的所有方法都接受一个IChatRoom参数,支持多个聊天室。接下来,更新的IChatRoom界面:

public interface IChatRoom
{
    string Name { get; }
    void Add(IParticipant participant);
    void Remove(IParticipant participant);
    IEnumerable<IParticipant> ListParticipants();
    void Add(ChatMessage message);
    IEnumerable<ChatMessage> ListMessages();
}

在进入命令和聊天本身之前,让我们来看看 AutoT0.类:

public class Mediator : IMediator
{
    private readonly HandlerDictionary _handlers = new 
    HandlerDictionary();
    public void Register<TCommand>(ICommandHandler<TCommand> commandHandler)
        where TCommand : ICommand
    {
        _handlers.AddHandler(commandHandler);
    }
    public void Register<TQuery, TReturn> (IQueryHandler<TQuery, TReturn> commandHandler)
        where TQuery : IQuery<TReturn>
    {
        _handlers.AddHandler(commandHandler);
    }
    public TReturn Send<TQuery, TReturn>(TQuery query)
        where TQuery : IQuery<TReturn>
    {
        var handler = _handlers.Find<TQuery, TReturn>();
        return handler.Handle(query);
    }
    public void Send<TCommand>(TCommand command)
        where TCommand : ICommand
    {
        var handlers = _handlers.FindAll<TCommand>();
        foreach (var handler in handlers)
        {
            handler.Handle(command);
        }
    }
}

Mediator类支持注册命令和查询,以及向处理程序发送查询或向零个或多个处理程序发送命令。

笔记

我省略了HandlerDictionary的实现,因为它没有添加任何内容,只是一个实现细节。在 GitHub(上提供 https://net5.link/CWCe

现在转到命令。为了保持整洁,我将命令和处理程序组合在一起,但您可以使用另一种方法对您的命令和处理程序进行分类:

public class JoinChatRoom
{
    public class Command : ICommand
    {
        public Command(IChatRoom chatRoom, IParticipant requester)
        {
            ChatRoom = chatRoom ?? throw new ArgumentNullException(nameof(chatRoom));
            Requester = requester ?? throw new ArgumentNullException(nameof(requester));
        }
        public IChatRoom ChatRoom { get; }
        public IParticipant Requester { get; }
    }
    public class Handler : ICommandHandler<Command>
    {
        public void Handle(Command command)
        {
            command.ChatRoom.Add(command.Requester);
        }
    }
}

JoinChatRoom.Command类表示命令本身,一种携带命令数据的数据结构。JoinChatRoom.Handler类处理该类型的命令。执行时,它将指定的IParticipantChatRoomRequester属性添加到指定的IChatRoom。下一个命令:

public class LeaveChatRoom
{
    public class Command : ICommand
    {
        public Command(IChatRoom chatRoom, IParticipant requester)
        {
            ChatRoom = chatRoom ?? throw new ArgumentNullException(nameof(chatRoom));
            Requester = requester ?? throw new ArgumentNullException(nameof(requester));
        }
        public IChatRoom ChatRoom { get; }
        public IParticipant Requester { get; }
    }
    public class Handler : ICommandHandler<Command>
    {
        public void Handle(Command command)
        {
            command.ChatRoom.Remove(command.Requester);
        }
    }
}

该代码表示与JoinChatRoom命令完全相反的,即LeaveChatRoom处理程序从指定的IChatRoom中删除IParticipant。到下一个命令:

public class SendChatMessage
{
    public class Command : ICommand
    {
        public Command(IChatRoom chatRoom, ChatMessage message)
        {
            ChatRoom = chatRoom ?? throw new ArgumentNullException(nameof(chatRoom));
            Message = message ?? throw new ArgumentNullException(nameof(message));
        }
        public IChatRoom ChatRoom { get; }
        public ChatMessage Message { get; }
    }
    public class Handler : ICommandHandler<Command>
    {
        public void Handle(Command command)
        {
            command.ChatRoom.Add(command.Message);
            foreach (var participant in command.ChatRoom.ListParticipants())
            {
                participant.NewMessageReceivedFrom(command.ChatRoom, command.Message);
            }
        }
    }
}

另一方面,SendChatMessage命令处理两件事:

  • 它将指定的Message添加到IChatRoom(现在它只是一个跟踪用户和过去消息的数据结构)。
  • 它还将指定的Message发送给加入该IChatRoom的所有IParticipant

我们开始看到许多较小的部分相互作用,以创建一个更发达的系统。但我们还没有完成;让我们看一下这两个查询,然后是聊天实现:

public class ListParticipants
{
    public class Query : IQuery<IEnumerable<IParticipant>>
    {
        public Query(IChatRoom chatRoom, IParticipant requester)
        {
            Requester = requester ?? throw new ArgumentNullException(nameof(requester));
            ChatRoom = chatRoom ?? throw new ArgumentNullException(nameof(chatRoom));
        }
        public IParticipant Requester { get; }
        public IChatRoom ChatRoom { get; }
    }
    public class Handler : IQueryHandler<Query, IEnumerable<IParticipant>>
    {
        public IEnumerable<IParticipant> Handle(Query query)
        {
            return query.ChatRoom.ListParticipants();
        }
    }
}

ListParticipants查询的处理程序使用指定的IChatRoom并返回其参与者。现在,转到最后一个查询:

public class ListMessages
{
    public class Query : IQuery<IEnumerable<ChatMessage>>
    {
        public Query(IChatRoom chatRoom, IParticipant requester)
        {
            Requester = requester ?? throw new ArgumentNullException(nameof(requester));
            ChatRoom = chatRoom ?? throw new ArgumentNullException(nameof(chatRoom));
        }
        public IParticipant Requester { get; }
        public IChatRoom ChatRoom { get; }
    }
    public class Handler : IQueryHandler<Query, IEnumerable<ChatMessage>>
    {
        public IEnumerable<ChatMessage> Handle(Query query)
        {
            return query.ChatRoom.ListMessages();
        }
    }
}

ListMessages查询的处理程序使用指定的IChatRoom实例并返回其消息。

笔记

所有的命令和查询都引用了IParticipant,因此我们可以强制执行诸如“IParticipant必须在发送消息之前加入通道”之类的规则。为了保持代码简单,我决定省略这些细节,但如果您愿意,可以随意添加这些特性。

接下来,让我们来看看 AuthT0p 类,它是一个简单的数据结构,它包含聊天室的状态:

public class ChatRoom : IChatRoom
{
    private readonly List<IParticipant> _participants = new List<IParticipant>();
    private readonly List<ChatMessage> _chatMessages = new	List<ChatMessage>();
    public ChatRoom(string name)
    {
        Name = name ?? throw new ArgumentNullException(nameof(name));
    }
    public string Name { get; }
    public void Add(IParticipant participant)
    {
        _participants.Add(participant);
    }
    public void Add(ChatMessage message)
    {
        _chatMessages.Add(message);
    }
    public IEnumerable<ChatMessage> ListMessages()
    {
        return _chatMessages.AsReadOnly();
    }
    public IEnumerable<IParticipant> ListParticipants()
    {
        return _participants.AsReadOnly();
    }
    public void Remove(IParticipant participant)
    {
        _participants.Remove(participant);
    }
}

如果我们再看一看ChatRoom类,它有一个Name属性,它包含一个IParticipant实例列表和一个ChatMessage实例列表。ListMessages()ListParticipants()都返回列表AsReadOnly(),因此聪明的程序员无法从外部改变ChatRoom的状态。就是这样,新的ChatRoom类是其底层依赖项的一个外表。

最后,Participant类可能是这个系统中最激动人心的部分,因为它大量使用了我们的中介CQRS实现:

public class Participant : IParticipant
{
    private readonly IMediator _mediator;
    private readonly IMessageWriter _messageWriter;
    public Participant(IMediator mediator, string name, IMessageWriter messageWriter)
    {
        _mediator = mediator ?? throw new ArgumentNullException(nameof(mediator));
        Name = name ?? throw new ArgumentNullException(nameof(name));
        _messageWriter = messageWriter ?? throw new ArgumentNullException(nameof(messageWriter));
    }
    public string Name { get; }
    public void Join(IChatRoom chatRoom)
    {
        _mediator.Send(new JoinChatRoom.Command(chatRoom, this));
    }
    public void Leave(IChatRoom chatRoom)
    {
        _mediator.Send(new LeaveChatRoom.Command(chatRoom, this));
    }
    public IEnumerable<ChatMessage> ListMessagesOf(IChatRoom chatRoom)
    {
        return _mediator.Send<ListMessages.Query, IEnumerable<ChatMessage>>(new ListMessages.Query(chatRoom, this));
    }
    public IEnumerable<IParticipant> ListParticipantsOf(IChatRoom chatRoom)
    {
        return _mediator.Send<ListParticipants.Query, IEnumerable<IParticipant>>(new ListParticipants.Query(chatRoom, this));
    }
    public void NewMessageReceivedFrom(IChatRoom chatRoom, ChatMessage message)
    {
        _messageWriter.Write(chatRoom, message);
    }
    public void SendMessageTo(IChatRoom chatRoom, string message)
    {
        _mediator.Send(new SendChatMessage.Command (chatRoom, new ChatMessage(this, message)));
    }
}

Participant类的每个方法,除了NewMessageReceivedFrom之外,都通过IMediator发送命令或查询,打破了Participant与系统操作(即命令和查询)的紧密耦合。如果我们仔细想想,Participant类也是其底层依赖关系的一个简单外表,将大部分工作委托给中介。

现在,让我们看一看,当所有东西放在一起时,它是如何工作的。我将几个测试用例组合在一起;以下是共享设置代码:

public class ChatRoomTest
{
    private readonly IMediator _mediator = new Mediator();
    private readonly TestMessageWriter _reagenMessageWriter = new TestMessageWriter();
    private readonly TestMessageWriter _garnerMessageWriter = new TestMessageWriter();
    private readonly TestMessageWriter _corneliaMessageWriter = new TestMessageWriter();
    private readonly IChatRoom _room1 = new ChatRoom("Room 1");
    private readonly IChatRoom _room2 = new ChatRoom("Room 2");
    private readonly IParticipant _reagen;
    private readonly IParticipant _garner;
    private readonly IParticipant _cornelia;
    public ChatRoomTest()
    {
        _mediator.Register(new JoinChatRoom.Handler());
        _mediator.Register(new LeaveChatRoom.Handler());
        _mediator.Register(new SendChatMessage.Handler());
        _mediator.Register(new ListParticipants.Handler());
        _mediator.Register(new ListMessages.Handler());
        _reagen = new Participant(_mediator, "Reagen",	_reagenMessageWriter);
        _garner = new Participant(_mediator, "Garner", 	_garnerMessageWriter);
        _cornelia = new Participant(_mediator, "Cornelia",	_corneliaMessageWriter);
    }
    // ...
}

我们的测试程序由以下部分组成:

  • 一个IMediator,使所有同事能够相互交流。
  • 两个IChatRoom实例。
  • 三个IParticipant实例及其TestMessageWriter

在构造函数中,所有处理程序都注册到Mediator实例中,因此它知道如何处理命令和查询。参与者的姓名是随机生成的。以下是第一个测试用例:

[Fact]
public void A_participant_should_be_able_to_list_the_participants_that_joined_a_chatroom()
{
    _reagen.Join(_room1);
    _reagen.Join(_room2);
    _garner.Join(_room1);
    _cornelia.Join(_room2);
    var room1Participants = _reagen.ListParticipantsOf(_room1);
    Assert.Collection(room1Participants,
        p => Assert.Same(_reagen, p),
        p => Assert.Same(_garner, p)
    );
}

在那个测试用例中,Reagen 和 Garner 加入房间 1,Reagen 和 Cornelia 加入房间 2。然后,Reagen 从房间 1 请求参与者列表,该房间输出 Reagen 和 Garner。代码易于理解和使用。在幕后,它通过中介使用命令和查询,这打破了同事之间的紧密耦合。下面是一个序列图,表示参与者加入聊天室时发生的情况:

Figure 14.10 – Sequence diagram representing the flow of a participant (p) joining a chatroom (c)

图 14.10–表示参与者(p)加入聊天室(c)的流程的序列图

  1. 参与者(p创建一个JoinChatRoom命令(joinCmd
  2. p通过调解人(m发送joinCmd
  3. m找到joinCmd并发送给其处理人(handler
  4. handler执行逻辑(将p添加到聊天室)。
  5. joinCmd之后不再存在;命令是短暂的。

这意味着Participant从未与ChatRoom或其他参与者直接互动。

当参与者请求聊天室的参与者列表时,会发生类似的工作流:

Figure 14.11 – Sequence diagram representing the flow of a participant (p) requesting the list of participants of a chatroom (c)

图 14.11–表示参与者(p)请求聊天室参与者列表的流程的序列图(c)

  1. Participantp创建一个ListParticipants查询(listQuery
  2. p通过调解人(m发送listQuery
  3. m查找查询并将其发送给其处理程序(handler
  4. handler执行逻辑(列出聊天室的参与者)。
  5. listQuery之后不再存在;查询也是短暂的。

再次说明,Participant不会直接与ChatRoom交互。

下面是另一个测试用例,其中Participant向聊天室发送消息,另一个Participant接收该消息:

[Fact]
public void A_participant_should_receive_new_messages()
{
    _reagen.Join(_room1);
    _garner.Join(_room1);
    _garner.Join(_room2);
    _reagen.SendMessageTo(_room1, "Hello!");
    Assert.Collection(_garnerMessageWriter.Output,
        line =>
        {
            Assert.Equal(_room1, line.chatRoom);
            Assert.Equal(_reagen, line.message.Sender);
            Assert.Equal("Hello!", line.message.Message);
        }
    );
}

在那个测试案例中,里根加入了房间 1,而加纳加入了房间 1 和房间 2。然后里根向 1 号房间发送了一条信息,我们确认加纳收到过一次。SendMessageTo工作流与我们看到的另一个工作流非常相似,但具有更复杂的命令处理程序:

Figure 14.12 – Sequence diagram representing the flow of a participant (p) sending a message (msg) to a chatroom (c)

图 14.12–表示参与者(p)向聊天室(c)发送消息(msg)的流程的序列图

从该图中,我们可以观察到逻辑被推送到了ChatMessage.Handler类。所有其他参与者在相互了解有限的情况下一起工作(见甚至互不了解)。

这说明了 CQRS 如何与调解人合作:

  1. 创建命令(或查询)。
  2. 通过中介发送该命令。
  3. 让一个或多个处理程序执行该命令的逻辑。

您可以探索其他测试用例,以熟悉程序和概念。

笔记

您可以在 VisualStudio 中调试测试;使用断点结合步进(F11)步过(F10)探索样本。

我还创建了一个ChatModerator实例,当一条消息包含badWords集合中的一个单词时,它会在“主持人聊天室”中发送一条消息。该测试用例为每个SendChatMessage.Command执行多个处理程序。我将让您自己探索这些其他测试用例。

代码气味–标记接口

我们在代码示例中使用了空的ICommandIQuery<TReturn>接口,使代码更加明确和自描述性。空接口是某种可能出错的迹象:代码气味。我们称这些标记接口

在我们的例子中,它们有助于识别命令和查询,但它们是空的,不添加任何内容。我们可以在不影响系统的情况下丢弃它们。另一方面,我们没有表演任何魔术或违反任何原则,因此拥有它们似乎是可以的;它们有助于定义意图。

下面是两个标记接口的示例。

元数据

标记可以用于定义元数据。一个类“实现”了空接口,一些使用者稍后会使用它做一些事情。它可以是特定类型的程序集扫描、策略选择或其他内容。

尝试使用自定义属性,而不是创建标记接口来添加元数据。属性背后的思想是向类或成员添加元数据。另一方面,存在用于创建契约的接口,它们应该定义至少一个成员;空合同就像一张白纸。

在真实的场景中,您可能需要考虑一个与另一个的成本。标记实现起来非常便宜,但可能违反坚实的原则。如果框架已经实现或支持了该机制,那么实现属性的成本可能与实现属性的成本一样低,但是如果需要手工编程,那么实现属性的成本可能比实现标记接口的成本要高得多。在做决定时,你必须始终将金钱、时间和技能作为关键因素进行评估。

依赖项标识符

如果您需要标记在特定类中注入特定的依赖项,那么您很可能是在欺骗控制反转。您应该找到一种方法来实现相同的目标,而不是使用依赖项注入,例如通过上下文注入依赖项。

让我们从以下界面开始:

public interface IStrategy
{
    string Execute();
}

在我们的程序中,我们有两个实现和两个标记,每个实现一个:

public interface IStrategyA : IStrategy { }
public interface IStrategyB : IStrategy { }
public class StrategyA : IStrategyA
{
    public string Execute() => "StrategyA";
}
public class StrategyB : IStrategyB
{
    public string Execute() => "StrategyB";
}

代码是赤裸裸的,但所有构建块都存在:

  • StrategyA实现了IStrategyA,继承自IStrategy
  • StrategyB实现了IStrategyB,继承自IStrategy
  • IStrategyAIStrategyB都是空的标记接口。

现在,消费者需要使用这两种策略,消费者请求标记,而不是从合成根控制依赖关系:

public class Consumer
{
    public IStrategyA StrategyA { get; }
    public IStrategyB StrategyB { get; }
    public Consumer(IStrategyA strategyA, IStrategyB strategyB)
    {
        StrategyA = strategyA ?? throw new ArgumentNullException(nameof(strategyA));
        StrategyB = strategyB ?? throw new ArgumentNullException(nameof(strategyB));
    }
}

在这种情况下,Consumer类通过属性公开策略,以便稍后断言其组合,但在非演示场景中,它很可能直接使用它们。让我们通过构建依赖关系树,模拟组合根,然后断言使用者属性的值来测试这一点:

[Fact]
public void ConsumerTest()
{
    // Arrange
    var serviceProvider = new ServiceCollection()
        .AddSingleton<IStrategyA, StrategyA>()
        .AddSingleton<IStrategyB, StrategyB>()
        .AddSingleton<Consumer>()
        .BuildServiceProvider();
    // Act
    var consumer = serviceProvider.GetService<Consumer>();
    // Assert
    Assert.IsType<StrategyA>(consumer.StrategyA);
    Assert.IsType<StrategyB>(consumer.StrategyB);
}

这两个属性都是期望的类型,但这不是问题所在。Consumer通过注入标记 A 和 B 而不是两个IStrategy实例来控制要使用的依赖项以及何时使用它们。因此,我们无法从组合根控制依赖关系树。例如,我们不能将IStrategyA更改为IStrategyBIStrategyB更改为IStrategyA,也不能注入两个IStrategyB实例或两个IStrategyA实例,甚至不能创建一个IStrategyC实例来替换IStrategyAIStrategyB

我们如何解决这个问题?让我们先删除标记,然后注入两个IStrategy实例。完成此操作后,我们将得到以下对象结构:

public class StrategyA : IStrategy
{
    public string Execute() => "StrategyA";
}
public class StrategyB : IStrategy
{
    public string Execute() => "StrategyB";
}
public class Consumer
{
    public IStrategy StrategyA { get; }
    public IStrategy StrategyB { get; }
    public Consumer(IStrategy strategyA, IStrategy strategyB)
    {
        StrategyA = strategyA ?? throw new ArgumentNullException(nameof(strategyA));
        StrategyB = strategyB ?? throw new ArgumentNullException(nameof(strategyB));
    }
}

在新的实现中,Consumer类不再控制叙述,而作文责任又回到了作文根。不幸的是,无法使用默认的依赖项注入容器进行上下文注入,我不想进入第三方框架。但还没有失去一切;我们可以使用工厂帮助 ASP.NET 构建Consumer实例,如下所示:

// Arrange
var serviceProvider = new ServiceCollection()
    .AddSingleton<StrategyA>()
    .AddSingleton<StrategyB>()
    .AddSingleton(serviceProvider =>
    {
        var strategyA = serviceProvider.GetService <StrategyA>();
        var strategyB = serviceProvider.GetService <StrategyB>();
        return new Consumer(strategyA, strategyB);
    })
    .BuildServiceProvider();
// Act
var consumer = serviceProvider.GetService<Consumer>();
// Assert
Assert.IsType<StrategyA>(consumer.StrategyA);
Assert.IsType<StrategyB>(consumer.StrategyB);

从那时起,我们控制程序的组成,我们可以通过 B 交换 A 或做任何我们想做的事情,只要实现尊重IStrategy合同。

总之,使用标记而不是上下文注入打破了控制原则的倒置,使消费者控制其依赖性。这与使用new关键字实例化对象非常接近。反转依赖项控件很容易,即使使用默认容器也是如此。如果您需要在上下文中注入依赖项,我在 2020 年启动了一个开源项目来实现这一点。参见进一步阅读章节。

结论

CQRS 建议将程序的操作分为命令查询。命令改变数据,查询返回数据。我们可以应用中介模式作为发送这些命令和查询的方式,这打破了该软件各部分之间的紧密耦合。

由于存在多个类,因此以这种方式使用 CQR 时,您可能会发现代码库更具威胁性。但是,请记住,这些类中的每一个都做得较少(只有一个职责),这使得它们比具有许多职责的更大的类更容易测试。

现在让我们看看 CQR 如何帮助我们遵循坚实的原则:

  • S:将应用划分为命令、查询和处理程序,使我们能够将单个职责封装到不同的类中。
  • O:CQRS 帮助扩展软件,而无需修改现有代码,例如添加处理程序和创建新命令。
  • L:不适用。
  • I:CQRS 使得创建多个小型接口变得更容易,并且在命令、查询和它们各自的处理程序之间有明确的区别。
  • D:不适用。

既然我们已经探索了 CQR 和中介模式,现在是时候偷懒一下,看看一个可以帮我们省去一些麻烦的工具了。

使用 MediatR 作为中介

在本节中,我们将探讨 MediatR,一种开源中介实现。什么是 MediatR?让我们从其 GitHub 存储库中的制造商描述开始,该存储库将其命名为:

在.NET 中实现简单、无恶意的中介程序

MediatR 是一个简单但功能强大的工具,用于通过消息传递进行进程内通信。它支持通过命令、查询、通知和事件的请求/响应流(同步和异步)。这就是自述文件所说的。

您可以使用.NET CLI:dotnet add package MediatR安装 NuGet 软件包。

现在我已经快速介绍了该工具,我们将探索干净体系结构示例的迁移,但是使用 MediatR 将StocksController请求发送到核心用例中。让我们跳转到代码中,看看它是如何工作的。

项目-使用 MediatR 清洁架构

背景:我们想打破我们在第 12 章中构建的清洁架构项目中的一些耦合,通过利用中介模式和CQRS启发的方法来理解分层

CleanArchitectureWithoutDataModel解决方案已经很可靠,但 MediatR 将为以后更好的事情铺平道路。在这个更新的示例中引入了另一个主要更改:从控制器到存储库,所有内容现在都是异步的。多亏了 C#5.0async/await编程模型,这里和那里的代码都有细微的变化。

笔记

我建议您在任何时候都可以使用异步,除非有什么东西阻止您这样做,否则几乎总是这样。对于访问外部资源的代码尤其如此。这样做应该会让您几乎不费吹灰之力地获得性能提升。

当运行异步任务时,该任务不会阻塞它运行的线程。此外,对于长时间运行的操作,该线程可以在等待时执行其他工作(例如,在 HTTP 请求和响应之间)。拥有一个异步 web 应用应该可以增加它可以处理的并发请求的数量,而无需您进行任何复杂的工作。那么,为什么不呢?

我选择不将前面的示例作为异步的,以使它们更专注于我们正在研究的内容,并避免一些噪音。现在我们已经知道了所有这些,是时候把它提高一个档次了,重新审视同样的概念,看看一个新的工具,以及异步应用中的所有这些。

以下是更新的IProductRepository

namespace Core.Interfaces
{
    public interface IProductRepository
    {
        Task<IEnumerable<Product>> AllAsync(CancellationToken cancellationToken);
        Task<Product> FindByIdAsync(int productId, CancellationToken cancellationToken);
        Task UpdateAsync(Product product, CancellationToken cancellationToken);
        Task InsertAsync(Product product, CancellationToken cancellationToken);
        Task DeleteByIdAsync(int productId, CancellationToken cancellationToken);
    }
}

接下来是实现,这非常类似,因为 Entity Framework Core 公开了同步和异步操作:

namespace Infrastructure.Data.Repositories
{
    public class ProductRepository : IProductRepository
    {
        private readonly ProductContext _db;
        public ProductRepository(ProductContext db)
        {
            _db = db ?? throw new ArgumentNullException(nameof(db));
        }
        public async Task<IEnumerable<Product>> AllAsync(CancellationToken cancellationToken)
        {
            var products = await _db.Products.ToArrayAsync(cancellationToken);
            return products;
        }
        public async Task DeleteByIdAsync(int productId, CancellationToken cancellationToken)
        {
            var product = await _db.Products.FindAsync(productId, cancellationToken);
            _db.Products.Remove(product);
            await _db.SaveChangesAsync(cancellationToken);
        }
        public async Task<Product> FindByIdAsync(int productId, CancellationToken cancellationToken)
        {
            var product = await _db.Products.FindAsync(productId, cancellationToken);
            return product;
        }
        public async Task InsertAsync(Product product, CancellationToken cancellationToken)
        {
            _db.Products.Add(product);
            await _db.SaveChangesAsync(cancellationToken);
        }
        public async Task UpdateAsync(Product product, CancellationToken cancellationToken)
        {
            _db.Entry(product).State = EntityState.Modified;
            await _db.SaveChangesAsync(cancellationToken);
        }
    }
}

在前面的代码中,我们使用实体框架核心将域实体直接持久化到数据库。我们已经探索了许多组织层的方法,但在许多情况下,这一方法在简单性和效率方面最有意义。

回到 MediatR:首先,我们在 web 项目中安装了MediatR.Extensions.Microsoft.DependencyInjectionNuGet 包。该包添加了一个 helper 方法,用于扫描一个或多个程序集以查找 MediatR 处理程序、预处理器和后处理器。它将这些添加到具有瞬时生存期的 IoC 容器中。

有了这个包,在Startup中,我们可以做到:

services.AddMediatR(typeof(NotEnoughStockException).Assembly);

注意,NotEnoughStockException类是核心项目的一部分。我们也可以在这里指定多个程序集;从 MediatR 的 8.0.0 版开始,该方法有五个重载。

MediatR 公开两种类型的消息:请求/响应和通知。第一个模型执行单个处理程序,而第二个模型允许多个处理程序处理每条消息。请求/响应模型非常适合命令和查询,而通知更适合作为应用发布-订阅模式的基于事件的模型。我们将在第 16 章微服务架构简介中介绍发布-订阅模式。

现在一切都“神奇地”注册了,我们可以从更新用例开始。这两个用例都期望将int productIdint amount作为参数,并返回int。我们先来看看更新后的AddStocks代码:

namespace Core.UseCases
{
    public class AddStocks
    {
        public class Command : IRequest<int>
        {
            public int ProductId { get; set; }
            public int Amount { get; set; }
        }
        public class Handler : IRequestHandler<Command, int>
        {
            private readonly IProductRepository 		_productRepository;
            public Handler(IProductRepository productRepository)
            {
                _productRepository = productRepository ?? throw new ArgumentNullException (nameof(productRepository));
            }
            public async Task<int> Handle(Command request, CancellationToken cancellationToken)
            {
                var product = await _productRepository. FindByIdAsync(request.ProductId, cancellationToken);
                product.QuantityInStock += request.Amount;
                await _productRepository. UpdateAsync(product, cancellationToken);
                return product.QuantityInStock;
            }
        }
    }
}

让我们跳到右侧的RemoveStocks用例,它非常相似:

namespace Core.UseCases
{
    public class RemoveStocks
    {
        public class Command : IRequest<int>
        {
            public int ProductId { get; set; }
            public int Amount { get; set; }
        }
        public class Handler : IRequestHandler<Command, int>
        {
            private readonly IProductRepository 		_productRepository;
            public Handler(IProductRepository productRepository)
            {
                _productRepository = productRepository ?? throw new ArgumentNullException (nameof(productRepository));
            }
            public async Task<int> Handle(Command request, CancellationToken cancellationToken)
            {
                var product = await _productRepository. FindByIdAsync(request.ProductId, cancellationToken);
                if (request.Amount > product.QuantityInStock)
                {
                    throw new NotEnoughStockException (product.QuantityInStock, request.Amount);
                }
                product.QuantityInStock -= request.Amount;
                await _productRepository. UpdateAsync(product, cancellationToken);
                return product.QuantityInStock;
            }
        }
    }
}

正如您在代码中可能注意到的,我选择了与 CQRS 示例相同的模式来构建命令,因此每个用例都有一个包含两个嵌套类的类:CommandHandler。我发现这种结构可以生成非常干净的代码。不幸的是,我注意到工具有时不喜欢这样。

通过使用请求/响应模型,命令(或查询)成为请求,并且必须实现IRequest<TResponse>接口。处理程序必须实现IRequestHandler<TRequest, TResponse>接口。对于不返回任何内容的命令(void,我们也可以实现IRequestIRequestHandler<TRequest>接口。

笔记

MediatR 中有更多选项,文档也足够完整,您可以自己深入挖掘。并不是说我不想,但我必须限制我谈论的主题,否则就要冒着在百科全书中重写互联网的风险。

让我们分析一下AddStocks用例的结构。以下是旧代码作为参考:

public class AddStocks
{
    private readonly IProductRepository _productRepository;
    public AddStocks(IProductRepository productRepository)
    {
        _productRepository = productRepository ?? throw new ArgumentNullException(nameof(productRepository));
    }
    public int Handle(int productId, int amount)
    {
        var product = _productRepository. FindById(productId);
        product.QuantityInStock += amount;
        _productRepository.Update(product);
        return product.QuantityInStock;
    }
}

第一个区别是我们将松散的参数(int productIdint amount移动到Command类中,该类封装了整个请求:

public class Command : IRequest<int>
{
    public int ProductId { get; set; }
    public int Amount { get; set; }
}

然后Command类通过实现IRequest<TResponse>接口来指定处理程序的期望返回值,其中TResponseint。这在通过 MediatR 发送请求时为我们提供了一个类型化响应。这不是“纯 CQRS”,因为命令处理程序返回一个表示更新的QuantityInStock的整数。我们可以称之为优化,因为在这种情况下,执行一个命令和一个查询将是过分的(可能导致两个数据库调用)。此外,我们正在使用类似于 CQRS 的方法探索 MediatR,这对于进程内通信来说是非常好的。

然后Handler类与CleanArchitectureWithoutDataModel解决方案中的AddStocks类几乎相同,但它调用存储库的异步方法,并将CancellationToken的实例传递给它们。

public class Handler : IRequestHandler<Command, int>
{
    // ...
    public async Task<int> Handle(Command request, CancellationToken cancellationToken)
    {
        var product = await _productRepository. FindByIdAsync(request.ProductId, cancellationToken);
        product.QuantityInStock += request.Amount;
        await _productRepository.UpdateAsync(product, cancellationToken);
        return product.QuantityInStock;
    }
}

笔记

取消异步操作时可使用CancellationToken。在示例中,我们将该令牌传递给实体框架核心。在另一种情况下,我们还可以实现自己的取消逻辑。这超出了当前示例的范围,但值得了解。我强烈建议你在任何时候都把这个代币传给别人。

RemoveStocks用例遵循相同的模式,因此为了避免重复,我省略了对它的讨论。相反,让我们看看StocksController中这些用例的使用情况:

namespace Web.Controllers
{
    [ApiController]
    [Route("products/{productId}/")]
    public class StocksController : ControllerBase
    {
        private readonly IMediator _mediator;
        public StocksController(IMediator mediator)
        {
            _mediator = mediator ?? throw new ArgumentNullException(nameof(mediator));
        }

这里我们在构造函数中注入IMediator,因为我们在所有方法中都使用它。以前,我们为每个动作注入了不同的用例,我们用命令对象替换了它。我们还让 ASP.NET 注入一个CancellationToken,并将其传递给 MediatR:

        [HttpPost("add-stocks")]
        public async Task<ActionResult<StockLevel>> AddAsync(
            int productId,
            [FromBody] AddStocks.Command command, CancellationToken cancellationToken
        )
        {
            command.ProductId = productId;
            var quantityInStock = await _mediator.Send(command, cancellationToken);
            var stockLevel = new 
            StockLevel(quantityInStock);
            return Ok(stockLevel);
        }

在这里,我们让模型绑定器将 HTTP 请求中的数据加载到对象中,并通过_mediator执行Send命令。然后我们将结果映射到StockLevelDTO,然后返回其值和 HTTP 状态码200``OK。在注释之后,我们看一下 remove stock 操作。

笔记

默认模型绑定器无法从多个源加载数据。因此,我们必须注入productId并手动将其值分配给command.ProductId属性。即使两个值都可以从主体中获取,该端点的资源标识符也会变得不那么详尽(URI 中没有productId)。

        [HttpPost("remove-stocks")]
        public async Task<ActionResult<StockLevel>> RemoveAsync(
            int productId,
            [FromBody] RemoveStocks.Command command,  CancellationToken cancellationToken
        )
        {
            try
            {
                command.ProductId = productId;
                var quantityInStock = await _mediator.Send(command, cancellationToken);
                var stockLevel = new StockLevel(quantityInStock);
                return Ok(stockLevel);
            }
            catch (NotEnoughStockException ex)
            {
                return Conflict(new
                {
                    ex.Message,
                    ex.AmountToRemove,
                    ex.QuantityInStock
                });
            }
        }

前面的方法执行相同的操作,但改为发送RemoveStocks.Command。如果抛出了NotEnoughStockException,则返回Conflict。异常可以通过 ASP.NET Core 中间件、MVC 过滤器或扩展 MediatR 管道来处理。

        public class StockLevel
        {
            public StockLevel(int quantityInStock)
            {
                QuantityInStock = quantityInStock;
            }
            public int QuantityInStock { get; }
        }
    }
}

最后,我们还有StockLevelDTO,它没有改变。

关于 DTO 的说明

现在,使用 C#9 记录,我们可以将 DTO 转换为记录类。例如,StockLevel类将如下所示:public record StockLevel(int QuantityInStock);

我们在第 17 章ASP.NET Core 用户界面中讨论记录。

总之,它的代码与以前几乎相同,但我们使用 MediatR,所有代码现在都是异步的。以下是Add方法的先前实现,以进行比较:

[HttpPost("add-stocks")]
public ActionResult<StockLevel> Add(
    int productId,
    [FromBody] AddStocksCommand command,
    [FromServices] AddStocks useCase
)
{
    var quantityInStock = useCase.Handle(productId, command.Amount);
    var stockLevel = new StockLevel(quantityInStock);
    return Ok(stockLevel);
}

结论

使用 MediatR,我们将 CQRS 启发的管道和中介模式的强大功能打包到一个干净的体系结构应用中。我们能够打破控制器和用例处理程序之间的耦合。一个简单的 DTO(如命令对象)会使控制器不知道处理程序,从而使 MediatR 成为命令及其处理程序之间的中间人。因此,处理程序可以在不影响控制器的情况下进行更改。此外,我们可以使用IRequestPreProcessorIRequestPostProcessorIRequestExceptionHandler配置命令和处理程序之间的更多交互。正如预期的那样,我们现在从合成根控制更多的元素。

MediatR 帮助我们遵循与 Mediator 和 CQRS 模式组合相同的坚实原则。唯一的缺点(如果有)是用例现在也控制 API 的输入契约。命令对象现在是输入 DTO。如果这是项目中必须避免的事情,则可以在 action 方法中输入 DTO,创建 command 对象,然后将其发送到 MediatR。

总结

在本章中,我们研究了中介模式。这种模式允许我们切断合作者之间的联系,调解他们之间的沟通。然后我们攻击了 CQRS 模式,该模式建议将软件行为划分为命令和查询。这两种模式是切断组件之间紧密耦合的工具。

然后,我们更新了一个干净的体系结构项目,以使用 MediatR,这是一个面向 CQRS 的开源通用中介实现。可能的用途比我们探索的要多得多,但这仍然是一个很好的开始。这是我们探索打破紧密耦合并将系统划分为更小部分的技术的另一章的结尾。

所有这些构建块将引导我们进入下一章,在这里我们将把所有这些模式和工具拼凑在一起,以探索垂直切片架构。

问题

让我们来看看几个练习题:

  1. 我们可以在同事内部使用调解人给另一位同事打电话吗?
  2. 在 CQRS 中,命令能否返回值?
  3. MediatR 的价格是多少?
  4. 设想一个设计有一个标记接口,用于向某些类添加元数据。你认为你应该重新审视一下这个设计吗?

进一步阅读

以下是我们在本章中所学内容的几个链接:

  • MediatR:https://net5.link/ZQap

  • To get rid of setting ProductId manually in the Clean Architecture with MediatR project, you can use the open source project HybridModelBinding or read the official documentation about custom model binding and implement your own:

    a) ASP.NET Core 中的自定义模型绑定:https://net5.link/65pb

    b) HybridModelBinding(GitHub 上的开源项目):https://net5.link/EyKK

  • ForEvolve.DependencyInjection是我的一个开源项目,增加了对上下文依赖注入等的支持:https://net5.link/myW8

十五、垂直切片架构入门

在本章中,我们将探讨垂直切片体系结构,它将一个特性的所有元素移回到一起。这几乎与分层相反,但并非完全相反。垂直切片体系结构还为我们提供了请求之间的清晰分离,导致隐式的命令查询责任分离(CQRS)设计。我们使用 MediatR 将所有这些整合在一起,我们在上一章中对此进行了探讨。

本章将介绍以下主题:

  • 垂直切片结构
  • 一个使用垂直切片架构的小项目
  • 继续你的旅程:一些提示和技巧

垂直切片架构

正如在上一章开头所说的,垂直切片将所有水平关注点组合在一起,以封装一个特性,而不是水平地分离一个应用。以下是一个图表,说明了:

Figure 15.1 – Diagram representing a vertical slice crossing all layers

图 15.1–表示穿过所有层的垂直切片的示意图

吉米·博加德(Jimmy Bogard)是这类架构的先驱,他经常推广这类架构,他说:

[目标是]最小化片之间的耦合并最大化片内的耦合。

这是什么意思?让我们把这句话分成两个不同的点:

  • “最大限度地减少片之间的耦合”(改进的可维护性、松散耦合)
  • “最大化切片内的耦合”(内聚)

我们可以将前者视为:与其将代码分散在多个层次上,沿途可能会有多余的抽象,不如将代码重新组合在一起。这有助于将紧密耦合保持在一个垂直切片中,以创建一个内聚的代码单元,用于一个目的:处理特性的逻辑。

我们可以将后者视为:一个垂直切片不应依赖于另一个。记住这一点,当您修改垂直切片时,您不必担心对其他切片的影响,因为耦合是最小的。

然后,我们可以围绕您试图解决的业务问题来创建软件,而不是围绕您的客户不感兴趣的开发人员的问题(例如数据访问)。

有哪些优点和缺点?

从正面来看,我们有以下几点:

  • 我们减少了特性之间的耦合,使得在这样的项目上工作更容易。我们只需要考虑单个垂直切片,而不是N层,通过将代码集中在一个共享关注点周围来提高可维护性

  • 我们可以选择每个垂直切片如何与它们所需的外部资源交互,而不必考虑其他切片。这增加了灵活性,因为一个片可以使用 T-SQL,而另一个片使用 EF 核心。

  • 我们可以从几行代码(Martin Fowler 的企业应用架构模式中描述为事务脚本开始,而无需过度设计或过度工程。然后,当需要时,我们可以重构我们的方式以获得更好的设计,模式开始出现,从而加快上市时间。

  • 每个垂直切片应该精确地包含正确所需的代码量,而不是更多,也不是更少。这导致了一个更健壮的代码库(更少的代码意味着更少的无关代码)。

  • 新用户更容易在现有系统中找到自己的方法,因为每个功能都是独立的,其代码都是分组的,从而加快了的登录时间

  • All that you already know still applies.

    提示

    根据我的经验,功能往往从小事做起,并随着时间的推移而增长。在使用软件时,用户通常会发现他们真正需要什么,更新他们认为最初需要的工作流程,从而导致软件的更改。我希望有许多项目是使用垂直切片架构而不是分层构建的。

现在有一些缺点:

  • 如果你已经习惯了分层,那么你可能需要花一些时间来思考它,从而进入一个适应期,学习一种新的思考软件的方式。

  • It is a "newer" type of architecture, and people don't like change.

    笔记

    另一件我通过艰苦的方式学到的事情是拥抱变化。我认为我没有看到一个项目像预期的那样结束。每个人在使用软件时都会找出业务流程中缺失的部分。这就引出了以下建议:尽快发布,并让您的客户尽快使用该软件。使用垂直切片体系结构可以更容易地实现这一建议,因为您正在为客户构建价值,而不是或多或少有用的抽象和层。

    在我职业生涯的开始,当规格发生变化时,我很沮丧,我认为更好的计划可以解决这个问题。有时更好的计划会有所帮助,但有时,客户只是不知道,不得不尝试应用来解决它。我在这里的建议是,当规范发生变化时,不要感到沮丧,即使这意味着要重新编写软件的一部分,而这一部分最初花了你几天或更多的时间编写代码;这将一直发生。相反,要学会接受这一点,并通过帮助客户了解他们的需求,找到减少这种情况发生次数的方法。

以下几点是可能成为正面的负面因素:

  • 如果您习惯于在筒仓中工作,那么根据关注点分配任务可能会比较困难(例如,数据员在做数据工作)。但最终,这应该是一种优势;团队中的每一个人(或多个团队)都应该更紧密地合作,从而带来更多的学习和协作,并可能建立一个新的跨职能团队(这绝对是一件好事)。

  • Refactoring: You need refactoring skills. Over time, most systems need some refactoring. That can be caused by changes in the requirements, or due to technical debt. No matter the reason, if you don't, you may very well end up with a Big Ball of Mud. Writing isolated code at first then refactoring to patterns is a crucial part of Vertical Slice Architecture. That's one of the best ways to keep cohesion high inside a slice and coupling as low as possible between slices.

    笔记

    开始重构业务逻辑的一种方法是将逻辑推入域模型,创建一个丰富的域模型。您还可以使用其他设计模式和技术来微调代码并使其更易于维护,例如通过创建服务甚至层。层不必穿过所有垂直切片;它只能跨越其中的一个子集。与其他应用级模式(如分层)相比,垂直切片体系结构规则更少,从而使您的终端有更多的选择。您可以在垂直切片中使用所有设计模式、原则和最佳实践,而无需将这些选择导出到其他垂直切片。

如何将项目组织到垂直切片体系结构中?不幸的是,没有明确的答案;这就像设计软件时的一切:这取决于。我们将在下一个项目中探索一种方法,但您可以根据自己的需要组织项目。然后我们将深入研究重构和组织。在此之前,让我们快速了解一下大泥球反模式。

反模式:大泥球

Big Ball of Mud描述了一个系统,结果很糟糕,或者从未设计过。有时一个系统一开始很好,但由于压力、不稳定的需求、不可能的截止日期、糟糕的做法或任何其他原因而演变成一个大泥球。大泥球通常被称为意大利面代码,意思几乎相同。

这就是这个反模式;它只是一个不可维护的代码库或一个很难维护的代码库。接下来,我们将进入垂直切片体系结构项目。

项目:垂直切片架构

上下文:我们对分层越来越厌倦,我们被要求使用垂直切片架构重建我们的小型演示店。

下面是一个更新的图表,显示了项目在概念上是如何组织的:

Figure 15.2 – Diagram representing the organization of the project

图 15.2——代表项目组织的图表

每个垂直框是一个用例(或切片),而每个水平箭头是一个横切关注点或一些共享组件。这是一个小项目,因此数据访问代码(DbContextProduct模型在所有用例之间共享。这种共享与垂直切片架构无关,但作为一个小项目,很难将其进一步拆分。我将在本节末尾详细介绍。

以下是演员:

  • ProductsController是管理产品的 web API 入口点。
  • StocksController是管理库存(添加或删除库存)的 web API 入口点。
  • AddStocksRemoveStocksListAllProducts是我们在项目中复制的相同用例。
  • 持续层由一个 EF 核心DbContext组成,该核心持续Product模型。

我们可以在垂直切片的基础上添加其他横切关注点,例如授权、错误管理和日志记录等。我们将在此示例中只探讨验证。

接下来,让我们来看看项目是如何组织的。

项目组织

以下是我们组织项目的方式:

  • Data目录包含 EF 核心相关类。

  • Features目录包含这些功能。每个子文件夹都包含其底层用例(垂直切片)。

  • Each use case is self-contained and exposes the following classes:

    a) Command表示 MediatR 请求。

    b) Result是该请求的返回值。

    c) MapperProfile指示 AutoMapper 如何映射与用例相关的对象。

    d) Validator包含验证Command对象的验证规则。

    e) Handler包含用例逻辑:如何处理请求。

  • Models目录包含域模型。

Figure 15.3 – Solution Explorer view of the file organization

图 15.3–文件组织的解决方案资源管理器视图

在这个项目中,每个用例都有一个MapperProfile类,但是我们可以为每个特性共享一个,将MapperProfile类移动到与控制器相同的级别。

在这个项目中,我们添加了请求验证。为了实现这一点,我们正在使用FluentValidation。您也可以使用System.ComponentModel.DataAnnotations或任何其他您想要的验证系统。FluentValidation 的优点在于,它很容易将验证保持在垂直部分内,但不在要验证的类内(例如,与DataAnnotations相比)。而且,它易于测试和扩展。

与其他工具一样,FluentValidation 可以使用以下行(突出显示)扫描程序集以查找验证器:

var currentAssembly = GetType().Assembly;
services.AddAutoMapper(currentAssembly);
services.AddMediatR(currentAssembly);
services.AddDependencyInjectionModules(currentAssembly);
services
    .AddControllers()
    .AddFluentValidation(config => config.RegisterValidatorsFromAssembly(currentAssembly));

验证器本身是每个垂直切片的一部分。接下来我们来看看这些特性。

探索特性

在本小节中,我们将探讨RemoveStocks特性。我们在前面的示例中使用了相同的逻辑,但组织方式不同(这在很大程度上是一种体系结构风格和另一种体系结构风格之间的差异)。让我们看一下代码,我在每个块后面描述:

namespace VerticalApp.Features.Stocks
{
    public class RemoveStocks
    {

RemoveStocks类包含多个嵌套类,以帮助组织我们的功能,并避免命名冲突带来的麻烦。

        public class Command : IRequest<Result>
        {
            public int ProductId { get; set; }
            public int Amount { get; set; }
        }

Command类是用例的输入:请求。请求包含执行操作(即从库存中删除库存)所需的所有内容。IRequest<TResult>接口告诉 MediatRCommand类是一个请求,应该路由到它的处理程序。Result类(如下所示)是该处理程序的返回值:

        public class Result
        {
            public int QuantityInStock { get; set; }
        }

Result类表示用例的输出。这就是处理程序将返回的内容。

        public class MapperProfile : Profile
        {
            public MapperProfile()
            {
                CreateMap<Product, Result>();
            }
        }

映射器配置文件是可选的,但它允许封装与用例相关的 AutoMapper映射。在前面的代码中,我们注册了从Product实例到Result实例的映射。

        public class Validator : AbstractValidator<Command>
        {
            public Validator()
            {
                RuleFor(x => x.Amount).GreaterThan(0);
            }
        }

验证器是可选的,但允许在输入到达处理程序之前验证输入(Command。为了实现这一点,我们需要实现一个添加到 MediatR 管道中的IPipelineBehavior<TRequest, TResponse>接口(在完成RemoveStock功能之后)。接下来是Handler类,它实现了用例逻辑:

        public class Handler : IRequestHandler<Command, Result>
        {
            private readonly ProductContext _db;
            private readonly IMapper _mapper;
            public Handler(ProductContext db, IMapper mapper)
            {
                _db = db ?? throw new ArgumentNullException(nameof(db));
                _mapper = mapper ?? throw new ArgumentNullException(nameof(mapper));
            }
            public async Task<Result> Handle(Command request, CancellationToken cancellationToken)
            {
                var product = await _db.Products.FindAsync(request.ProductId);
                if (request.Amount > product.QuantityInStock)
                {
                    throw new NotEnoughStockException(product.QuantityInStock, request.Amount);
                }
                product.QuantityInStock -= request.Amount;
                await _db.SaveChangesAsync();
                var result = _mapper.Map<Result>(product);
                return result;
            }
        }
    }
}

Handler类继承自IRequestHandler<Command, Result>,后者将其链接到Command类。它实现了与之前实现相同的逻辑,从第 12 章理解分层开始。

总之,RemoveStocks类包含该特定用例所需的所有子类。作为提醒,现在我们已经阅读了代码,每个用例的片段如下所示:

  • Command为输入 DTO。
  • Result是输出 DTO。
  • MapperProfile是将 DTO 映射到域模型(反之亦然)的 AutoMapper 配置文件。
  • Validator验证CommandDTO(输入)。
  • Handler封装了用例逻辑。

现在让我们看一下类,它将 HTTP 请求转换为 MediatR 管道:

namespace VerticalApp.Features.Stocks
{
    [ApiController]
    [Route("products/{productId}/")]
    public class StocksController : ControllerBase
    {
        private readonly IMediator _mediator;
        public StocksController(IMediator mediator)
        {
            _mediator = mediator ?? throw new ArgumentNullException(nameof(mediator));
        }

我们在控制器中注入一个IMediator实现,因为我们在接下来的所有操作中都使用它。

        [HttpPost("add-stocks")]
        public async Task<ActionResult<AddStocks.Result>> AddAsync(
            int productId,
            [FromBody] AddStocks.Command command
        )
        {
            command.ProductId = productId;
            var result = await _mediator.Send(command);
            return Ok(result);
        }

在前面的代码中,我们从 body 读取一个AddStocks.Command实例的内容,然后根据第 12 章理解分层中讨论的原因设置ProductId,最终将command对象发送到 MediatR 管道中。从那里,MediatR 将请求路由到我们几页前研究过的处理程序,然后返回带有 HTTP200 OK状态代码的操作结果。

        [HttpPost("remove-stocks")]
        public async Task<ActionResult<RemoveStocks.Result>> RemoveAsync(
            int productId,
            [FromBody] RemoveStocks.Command command
        )
        {
            try
            {
                command.ProductId = productId;
                var result = await _mediator.Send(command);
                return Ok(result);
            }
            catch (NotEnoughStockException ex)
            {
                return Conflict(new
                {
                    ex.Message,
                    ex.AmountToRemove,
                    ex.QuantityInStock
                });
            }
        }
    }
}

remove-stocks动作与add-stocks动作具有相同的逻辑,并添加了try/catch块(与此代码之前的实现类似)。

前面的代码和以前的实现之间的一个区别是我们将 DTO 移动到垂直切片本身(突出显示的行)。每个垂直片定义该特性的输入、逻辑和输出,如下所示:

Figure 15.4 – Diagram representing the three primary pieces of a vertical slice

图 15.4–表示垂直切片的三个主要部分的示意图

当我们添加输入验证时,我们有以下内容:

Figure 15.5 – Diagram representing the three primary pieces of a vertical slice, with added validation

图 15.5——表示垂直切片的三个主要部分的图表,添加了验证

总之,控制器的代码很薄,在 HTTP 和我们的域之间创建了一个很小的层,将 HTTP 请求映射到 MediatR 管道,并将响应映射回 HTTP。对于productIdtry/catch块,我们仍然有额外的行,但是我们可以使用定制的模型绑定器(请参阅本章末尾的一些附加资源)来消除这些行。

有了这些,现在就可以直接向项目中添加新功能了。从视觉上看,我们最终得到以下垂直切片(粗体)、可能的扩展(普通)和共享类(斜体):

Figure 15.6 – Diagram representing the project and possible extensions related to product management

图 15.6–表示项目和与产品管理相关的可能扩展的图表

接下来,我们添加缺少的部分以使用这些IValidator实现。

请求验证

我们现在有了大部分代码来运行我们的小项目。但是,我们的 MediatR 管道中仍然没有验证,只有验证器。幸运的是,MediatR 有一个IPipelineBehavior<in TRequest, TResponse>接口,允许我们扩展请求管道。它的工作原理类似于 MVC 过滤器。说到这里,我们还需要一个过滤器来控制发生验证错误时的 HTTP 响应。这将允许我们将验证逻辑封装在两个小类中。这两个类将拦截并处理任何特性引发的所有验证异常。

让我们从一个高级视图开始:

  1. HTTP 请求通过 ASP.NET MVC 管道到达控制器。
  2. 控制器发送通过 MediatR 管道的命令:

Figure 15.7 – High-level flow of a successful HTTP request

图 15.7–成功 HTTP 请求的高级流

我们要做的是:

  1. 在 MVC 管道中(在图的过滤器部分)添加一个捕捉ValidationException(来自 FluentValidation)的IExceptionFilter
  2. 添加一个 MediatRIPipelineBehavior来验证请求,并在请求验证失败时抛出一个ValidationException(在图的行为部分)。

添加这两个部分后,我们的请求流将变成这样:

Figure 15.8 – Request flow including request validation details

图 15.8–包括请求验证详细信息的请求流

  1. 用户发送 HTTP 请求。

  2. 控制器通过中介器发送命令。

  3. 中介通过其管道运行请求。

  4. IPipelineBehavior实现验证请求。

  5. If the request is valid, the following occurs:

    a) 请求继续通过 MediatR 管道,直到到达处理程序。

    b) Handler被执行。

    c) Handler返回一个Result实例。

    d) 控制器将该Result对象转换为OkObjectResult对象。

  6. If the validation of the request fails, the following occurs:

    a) IPipelineBehavior实现抛出一个ValidationException

    b) IActionFilter实现捕获并处理异常。

    c) 过滤器将动作结果设置为BadRequestObjectResult

  7. MVC 将生成的转换为200 OK(成功)或400 BadRequest(验证失败)响应,并将生成的对象序列化到响应体中。

现在我们已经了解了这些变化的理论方面,让我们从编码IPipelineBehavior实现开始。我将其命名为ThrowFluentValidationExceptionBehavior,因为它抛出一个ValidationException(来自 FluentValidation),它是一个 MediatR 行为:

namespace VerticalApp
{
    public class ThrowFluentValidationExceptionBehavior<TRequest, TResponse> : IPipelineBehavior<TRequest, TResponse>
        where TRequest : IBaseRequest

我们从实现IPipelineBehavior<TRequest, TResponse>接口开始。我们的类将这两个通用参数转发到IPipelineBehavior接口,以服务于所有类型的请求,只要请求实现IBaseRequestCommand类实现的IRequest<out TResponse>接口继承自IBaseRequest

    {
        private readonly IEnumerable<IValidator<TRequest>> _validators;
        public ThrowFluentValidationExceptionBehavior (IEnumerable<IValidator<TRequest>> validators)
        {
            _validators = validators ?? throw new ArgumentNullException(nameof(validators));
        }

这是魔法的一部分;通过注入IValidator<TRequest>列表,我们的行为将能够访问当前请求(任何类型的请求)的验证器。

        public Task<TResponse> Handle(TRequest request, CancellationToken cancellationToken, RequestHandlerDelegate<TResponse> next)
        {
            var failures = _validators
                .Select(v => v.Validate(request))
                .SelectMany(r => r.Errors);
            if (failures.Any())
            {
                throw new ValidationException(failures);
            }
            return next();
        }
    }
}

最后,在Handle方法中,我们运行所有验证器(参见突出显示的代码),并将错误投射到failures变量中。如果有任何失败,它抛出一个包含所有失败的ValidationException。如果验证成功,它将返回管道的下一个元素。这个概念类似于我们在第 10 章行为模式中探讨的责任链模式。

接下来,为了让它工作,我们必须在合成根中注册它。因为我们不想为我们项目中的每个特性注册它,所以我们将它注册为一个开放泛型类型,如下所示(在Startup类中):

services.AddSingleton(
    typeof(IPipelineBehavior<,>),
    typeof(ThrowFluentValidationExceptionBehavior<,>)
);

这段代码的意思是:“为所有请求在管道中添加并引用ThrowFluentValidationExceptionBehavior的实例。”因此,无论请求的类型如何,我们的行为每次都会运行。

如果我们运行代码,我们会得到以下错误,这是不优雅的:

Figure 15.9 – The result of the ThrowFluentValidationExceptionBehavior without the MVC filter

图 15.9–ThrowFluentValidationExceptionBehavior(不带 MVC 过滤器)的结果

为了管理 MVC 输出这些异常的方式,我们可以创建一个IExceptionFilter并将其添加到管道中。我决定将其命名为FluentValidationExceptionFilter,因为它是一个异常过滤器,用于处理FluentValidation.ValidationException类型的异常。该类如下所示:

namespace VerticalApp
{
    public class FluentValidationExceptionFilter : IExceptionFilter
    {
        public void OnException(ExceptionContext context)
        {
            if (context.Exception is ValidationException ex)
            {
                context.Result = new BadRequestObjectResult(new
                {
                    ex.Message,
                    ex.Errors,
                });
                context.ExceptionHandled = true;
            }
        }
    }
}

前面的代码验证Exception属性(当前异常)的值是否为ValidationException。如果是,则将Result属性的值设置为BadRequestObjectResult的实例。它创建了一个匿名对象,其两个属性直接取自ValidationException对象:MessageErrorsMessage是错误消息,ErrorsValidationFailure对象的集合。

之后,它将ExceptionHandled属性设置为 true,这样 MVC 就知道异常已被处理,不再关心它,就像它从未发生过一样。这几行代码相当于从控制器操作返回一个BadRequest(new {...}),但对所有控制器的操作都是全局应用的。

最后一步:我们必须将它注册到 MVC 管道中,以便使用它。在Startup类中,我们将空的services.AddControllers()方法调用替换为以下内容:

services.AddControllers(options => options
    .Filters.Add<FluentValidationExceptionFilter>())

这将我们新的过滤器添加到 MVC 管道中。从现在起,无论何时发生未处理的异常,都将执行我们的筛选器。

现在,如果我们运行一个不应该通过验证的请求(例如添加 0 个新股票,我们会得到以下结果:

Figure 15.10 – The result of the ThrowFluentValidationExceptionBehavior handled by the FluentValidationExceptionFilter

图 15.10–FluentValidationExceptionFilter 处理 ThrowFluentValidationExceptionBehavior 的结果

这更优雅,客户更容易处理。您还可以自定义在IPipelineBehavior接口实现中抛出的异常以及在IExceptionFilter实现中序列化的对象。您还可以在基于非 MediatR 的项目中利用IExceptionFilter接口的自定义实现,因为它是 MVC。还有其他类型的过滤器。过滤器非常擅长处理 MVC 中的横切关注点。

接下来,我们将探讨一些测试。我不会测试整个应用,但我将介绍测试垂直切片体系结构相对于其他体系结构类型的一些优势。

测试

对于这个项目,我为每个用例结果编写了一个集成测试,这减少了所需的单元测试数量,同时提高了系统的可信度。为什么?因为我们正在测试特性本身,而不是独立地测试许多抽象部分。我们还可以添加任意数量的单元测试。我不是要你停止编写单元测试;相反,我认为这种方法可以帮助您编写更少但更好的面向特性的测试,从而减少对模拟重型单元测试的需求。

让我们看一下股票的使用情况:用例测试:

namespace VerticalApp.Features.Stocks
{
    public class StocksTest : BaseIntegrationTest
    {
        public StocksTest()
            : base(databaseName: "StocksTest") { }

BaseIntegrationTest类封装了依赖注入和数据库种子逻辑的样板代码。出于简洁的原因,我将省略它,但是您可以在 GitHub 存储库(中)中查阅完整的源代码 https://net5.link/DfSf

        protected async override Task SeedAsync(ProductContext db)
        {
            await db.Products.AddAsync(new Product
            {
                Id = 4,
                Name = "Ghost Pepper",
                QuantityInStock = 10
            });
            await db.Products.AddAsync(new Product
            {
                Id = 5,
                Name = "Carolina Reaper",
                QuantityInStock = 10
            });
            await db.SaveChangesAsync();
        }

SeedAsync方法中,我们在内存测试数据库中插入两个产品。

        public class AddStocksTest : StocksTest{...}
        public class RemoveStocksTest : StocksTest
        {
            private const int _productId = 5;
            [Fact]
            public async Task Should_decrement_ QuantityInStock_by_the_specified_amount()
            {
                // Arrange
                var serviceProvider = _services.BuildServiceProvider();
                using var scope = serviceProvider.CreateScope();
                var mediator = scope.ServiceProvider. GetRequiredService<IMediator>();
                // Act
                var result = await mediator.Send(new RemoveStocks.Command
                {
                    ProductId = _productId,
                    Amount = 10
                });
                // Assert
                using var assertScope = serviceProvider.CreateScope();
                var db = assertScope.ServiceProvider. GetRequiredService<ProductContext>();
                var peppers = await db.Products.FindAsync(	_productId);
                Assert.Equal(0, peppers.QuantityInStock);
            }
            [Fact]
            public async Task Should_throw_a_NotEnoughStockException_when_the_resulting_QuantityInStock_would_be_less_than_zero()
            {
                // Arrange
                using var scope = _services. BuildServiceProvider().CreateScope();
                var mediator = scope.ServiceProvider. GetRequiredService<IMediator>();
                // Act & Assert
                await Assert.ThrowsAsync<NotEnoughStockException>(() => mediator.Send(new RemoveStocks.Command
                {
                    ProductId = _productId,
                    Amount = 11
                }));
            }
        }
    }
}

RemoveStocksTest类包含两个测试用例:

  • 应按规定数量减少QuantityInStock
  • 当结果QuantityInStock小于零时,应抛出一个NotEnoughStockException

排列阶段,测试方法从 IoC 容器获取服务,创建ServiceProvider实例,然后创建模拟 HTTP 请求范围的范围。从这个范围来看,两个测试用例都得到了一个IMediator实例。

然后,在Act阶段,两个测试都向IMediator发送命令,就像控制器一样,在这个过程中测试整个管道。

断言阶段,第一个测试创建一个新的作用域,以确保它接收到ProductContext类的新实例,并且查询不会返回一些未提交的 EF 核心实体。然后验证保存的数据是否正确。第二个测试用例验证了Handler抛出了NotEnoughStockException

就这样,;使用少量的代码,我们测试了两个 Stock 用例的三个主要逻辑路径。我们还可以通过向控制器发送 HTTP 请求来测试整个 web API。另一方面,我们可以通过模拟IMediator接口对控制器进行单元测试。您的测试策略取决于您的需求,但是从IMediator进行测试将适用于使用 MediatR 的任何类型的应用,这就是我选择该策略的原因。

接下来,我们将介绍一些技巧和流程,以开始使用更大的应用。这些都是我找到工作的方法,也很适合你。把对你有用的东西拿走,剩下的留着;我们都不同,工作也不同。

继续你的旅程

以前的项目很小。它有一个作为数据层的共享模型,因为该模型只由一个类组成。当你正在构建一个更大的应用时,你很可能会拥有不止一个类,因此我将尝试为你提供一个处理更大应用的良好起点。其思想是创建尽可能小的片段,尽可能限制与其他片段的交互,然后将代码重构为更好的代码。我们不能移除耦合,所以我们需要组织它。

下面是一个我们可以称之为“从小处着手,重构”的工作流:

  1. 编写涵盖您的功能(输入和输出)的合同。
  2. 使用这些契约编写一个或多个涵盖您的功能的集成测试;QueryCommand类(IRequest类)作为输入,Result类作为输出。
  3. 实现您的HandlerValidatorMapperProfile和任何其他需要编码的位。在这一点上,代码可能是一个巨大的Handler;没关系。
  4. 一旦你的集成测试通过,通过分解你的巨型Handler.Handle方法重构代码(如果需要的话)。
  5. 确保你的测试仍然通过。

步骤 2中,您可能还希望将验证规则作为单元测试进行测试。从单元测试中测试多个组合和场景更容易、更快,而且您不需要为此访问数据库。这同样适用于系统中未绑定到外部资源的任何其他部分。

步骤 4中,您可能会发现特征之间存在重复的逻辑。如果是这样的话,现在是时候将这种逻辑封装到其他地方,一个共享的地方。这可以是在模型中创建一个方法,创建一个服务类,或者您知道的任何其他模式和技术,它们可能会解决您的逻辑复制问题。从孤立的特性和提取共享逻辑将帮助您设计应用。您希望将该共享逻辑推到处理程序之外,而不是反过来(一旦您拥有了该共享逻辑,您就可以在需要的地方使用它)。在这里,我想强调共享逻辑,这意味着一个业务规则。当业务规则更改时,该业务规则的所有使用者也必须更改其行为。避免共享类似代码,但要共享业务规则。

设计软件时,最重要的是关注功能需求,而不是技术需求。你的客户和用户不关心技术方面的东西;他们需要结果、新功能、错误修复和改进。同时,当心技术债务,不要跳过重构步骤,否则您的项目可能会陷入麻烦。该建议也适用于所有类型的体系结构。

另一条建议是让所有代码尽可能靠近垂直切片。您不必将用例的所有类都保存在一个文件中,但我觉得这很有帮助。您还可以创建一个文件夹层次结构,其中较深的级别共享以前的级别。例如,我最近在一个与发货相关的 MVC 应用中实现了一个工作流。创建过程分为多个步骤。因此,我最终得到了如下层次结构(目录为粗体):

Figure 15.11 – The organizational hierarchy of directories and elements

图 15.11–目录和元素的组织层次结构

最初,我只是一个接一个地编写所有处理程序,然后我看到模式出现,所以我采用了共享逻辑并将其封装到共享类中。然后我开始在上层重用一些异常,所以我将它们从Features/Shipments/Create文件夹/名称空间上移到Features/Shipments文件夹/名称空间。我还提取了一个服务类来管理多个用例和更多用例之间的共享逻辑(我将跳过所有细节,因为它们是不相关的)。最后,我只有我需要的代码,没有重复的逻辑,协作者(类、接口)彼此非常接近。我只在 IoC 容器中注册了三个接口,其中两个与 PDF 生成相关。特性之间的耦合是最小的,而系统的某些部分是协同工作的(内聚)。此外,与系统的其他部分几乎没有耦合。如果我们将这一结果与另一种类型的架构(如分层)进行比较,我很可能需要更多的抽象,如存储库、服务等;垂直切片架构的最终结果更简单。

这里的关键点是独立编写处理程序,尽可能地组织它们,关注共享逻辑和新兴模式,提取和封装该逻辑,并尝试限制用例和切片之间的交互。

现在,什么是切片?就我个人而言,我将切片视为复合材料。每个Features/Shipments/Create/[*]Handler都是一片。当放在一起时,它们组成Features/Shipments/Create切片(一个更大的切片)。然后,Features/Shipments中的所有切片都变成另一个大切片,导致如下结果:

Figure 15.12 – A diagram displaying a top-down coupling structure where smaller parts (top) depend on bigger parts (middle) of complex features (bottom) based on their cohesion with one another (vertically)

图 15.12–一个显示自上而下耦合结构的图,其中较小的组件(顶部)依赖于复杂特征(底部)的较大组件(中间),这是基于它们彼此之间的粘合力(垂直)

步骤 1内部存在强耦合,其他步骤之间耦合有限;他们共享一些创建代码作为创建片段的一部分。创建列表明细也共享一些代码,但方式有限;它们都是装运切片的一部分,并访问或操作同一实体:一个或多个装运。最后,发货切片与其他功能没有代码共享(或很少共享)。

好的,这是我对切片的定义和我对切片的看法;也许其他人对此有其他观点,这很好。我发现通过遵循我刚才描述的模式,我最终得到了有限的耦合和最大的内聚。

总结

在本章中,我们概述了垂直切片体系结构,它将层翻转 90°。垂直切片体系结构是通过依赖开发人员的技能和判断,从等式中获取多余的抽象和规则,编写最小的代码以产生最大的价值。

重构是垂直切片体系结构项目中的一个关键因素;成功或失败很可能取决于它。所有模式(包括层)都可以与垂直切片体系结构结合使用。与分层相比,它有很多优点,几乎没有缺点。在筒仓中工作的团队(水平团队)可能需要重新考虑这一点,然后再切换到垂直切片架构,并创建多功能团队(垂直团队)。

使用垂直切片架构,我们用命令和查询(CQR)取代了低值抽象。然后使用中介模式(由 MediatR 帮助)将它们路由到各自的Handler。这允许封装业务逻辑并将其与调用方(示例中的控制器)分离。这些命令和查询确保域逻辑的每一位都集中在一个位置。

我们可以使用经典的 MVC 过滤器、ASP.NET 中间件或 MediatRIPipelineBehavior封装横切关注点,具体取决于我们希望在何处处理该关注点。我们还可以使用其中的许多选项实现复合解决方案,就像我们在处理验证的代码示例中所做的那样。

通过使用集成测试测试每个垂直切片,我们可以显著减少测试所需的模拟数量。这还可以显著减少单元测试的数量,测试特性而不是模拟的代码单元。我们的重点应该放在生成特性上,而不是查询基础设施或代码本身背后的细节(好的,这也很重要)。

笔记

需要注意的是,您仍然可以根据需要编写尽可能多的单元测试;垂直切片架构中的任何内容都不会阻止您这样做。这是优点之一:使用您正在处理的切片中的所有知识,而无需将其全局导出到其他切片。

总而言之,我们探索了一种现代的方法来设计一个与敏捷开发很好结合的应用,并帮助您的客户创造价值。

现在,让我们看看垂直切片架构如何帮助我们遵循坚实的原则:

  • S:每个垂直层面(特征)成为一个整体变化的内聚单元,导致每个特征的责任分离。基于 CQRS 启发的方法,每个特性将应用的复杂性分解为命令和查询,从而产生多个小部分。每一件都处理一部分过程。例如,我们可以定义一个输入、一个验证器、一个映射器配置文件、一个处理程序、一个结果、一个 HTTP 网桥(控制器),以及我们制作切片所需的任意多个片段。
  • O:我们可以通过扩展 ASP.NET、MVC 或 MediatR 管道来全面增强系统。这些特性本身可以设计为一体式,对 OCP 的直接影响有限。
  • L:不适用。
  • I:通过以领域为中心的用例单元来组织特征,我们最终得到了许多特定于客户端的组件,而不是像层这样的通用元素。
  • D:一个片的所有部分都只依赖于接口,并通过依赖注入绑定在一起。此外,通过从系统中删除不太有用的抽象,我们简化了它,使它更易于维护和简洁。通过让如此多的功能片段彼此靠近,系统变得更易于维护,同时提高了可发现性。

在下一章中,我们将探讨另一种体系结构风格,并讨论微服务。

问题

让我们来看看几个练习题:

  1. 我们可以在垂直切片中使用哪些设计模式?
  2. 当使用垂直切片体系结构时,您必须选择一个 ORM 并坚持使用它,例如数据层,这是真的吗?
  3. 如果你不重构你的代码并长期支付技术债务,那么会发生什么?
  4. 我们是否可以在其他类型的应用中使用行为和 MVC 过滤器来处理横切关注点,或者它们是由垂直切片体系结构启用的?
  5. 衔接意味着什么?
  6. 紧密耦合意味着什么?

进一步阅读

以下是我们在本章中所学内容的几个链接:

  • For UI implementations, you can look at how Jimmy Bogard upgraded ContosoUniversity:

    a) 基于 ASP.NET Core 与.NET Core 的 ContosoUniversityhttps://net5.link/UXnr

    b) ASP.NET Core 上的 Contosuniversity 3.1.NET Core 和 Razor 页面https://net5.link/6Lbo

  • FluentValidationhttps://net5.link/xXgp

  • ExceptionMapper 是我的一个开源项目,它是一个 ASP.NET Core 中间件,可以响应异常。您可以将某些异常类型映射到 HTTP状态码,自动序列化为 JSONProblemDetails,以此类推:https://net5.link/dtRi

  • 自动制版机https://net5.link/5AUZ

  • MediatRhttps://net5.link/ZQap

  • To avoid setting ProductId manually in the Vertical Slice project, you can use the open source HybridModelBinding project, or read the official documentation about custom model binding and implement your own:

    a) ASP.NET Core 中的自定义模型绑定https://net5.link/65pb

    b) 杂交模式结合https://net5.link/EyKK

十六、微服务架构简介

本章是关于应用设计的最后一章。它涵盖了一些基本的微服务体系结构概念。本章旨在让您了解这些原则,并让您对微服务体系结构有一个很好的了解。

本章的目的是为您概述有关微服务的概念,这将有助于您做出明智的决定,决定是否应采用微服务体系结构。单片体系结构模式,如垂直切片和干净体系结构,仍然值得了解,因为您可以将它们应用于单个微服务。别担心——从本书开始,你所学到的所有知识都不会被放弃,仍然是值得的。

本章将介绍以下主题:

  • 什么是微服务?
  • 开始使用消息队列
  • 事件概述
  • 实现发布-订阅模式
  • 介绍网关模式
  • 重温 CQRS 模式
  • 容器概述

让我们开始吧!

什么是微服务?

除了作为一个时髦词,微服务还代表一个被划分为多个较小应用的应用。每个应用或微服务都和其他应用交互以创建一个可扩展的系统。

以下是构建微服务时需要牢记的几个原则:

  • 每个微服务都应该是一个有凝聚力的业务单元。
  • 每个微服务都应该拥有自己的数据。
  • 每个微服务都应该独立于其他微服务。

此外,到目前为止,我们所研究的一切——也就是设计软件的其他原则——都适用于微服务,但规模有所不同。例如,您不希望微服务之间紧密耦合(通过微服务独立性解决),但这种耦合是不可避免的(就像任何代码一样)。有许多方法可以解决这个问题,例如发布-订阅模式。

关于如何设计微服务、如何划分它们、它们应该有多大以及将什么放在哪里,没有硬性规定。话虽如此,我将为您奠定一些基础,帮助您开始并将您的旅程定位到微服务。

有凝聚力的业务单位

微服务应该承担单一的业务责任。始终在设计系统时考虑域,这将有助于将应用划分为多个部分。如果你知道领域驱动设计DDD),一个微服务很可能代表一个有界上下文,这反过来就是我所说的内聚业务单元

即使一个微服务的名称中有,将逻辑操作分组在其下比瞄准微规模更为重要。别误会我的意思;如果你的单位很小,那就更好了。但是,假设您将一个业务单元拆分为多个较小的部分,而不是将其保持在一起(破坏内聚)。

在这种情况下,您可能会在系统中引入无用的聊天(微服务之间的耦合)。这可能会导致性能下降,导致系统更难调试、测试、维护、监视和部署。

尝试将 SRP 应用于您的微服务:微服务应该只有一个更改的理由,除非您有很好的理由这样做。

拥有自己的数据

每个微服务都是其业务块(有界上下文;其数据)真实性的来源。它应该拥有这些数据,而不应该直接在数据库级别与其他微服务共享这些数据。微服务应该通过自身(例如 web API/HTTP)或其他机制(例如集成事件)共享其数据。

例如,在关系数据库中,决不能从两个不同的微服务访问表。如果第二个微服务需要一些相同的数据,它可以创建自己的缓存、复制数据或查询该数据的所有者,但不能直接访问数据库;从未

这种数据所有权概念可能是微服务体系结构中最关键的部分,并导致了微服务的独立性。如果做不到这一点,很可能会导致大量问题。

独立性

此时,我们有了微服务,它们是拥有数据的内聚业务单元。这定义了独立性

这种独立性为系统提供的是可扩展的能力,同时对其他微服务的影响最小甚至没有影响。每个微服务也可以独立扩展,而不需要扩展整个系统。此外,当业务需求增长时,该领域的每个部分都可以独立增长。

此外,您可以在不影响其他微服务的情况下更新一个微服务,甚至可以在不停止整个系统的情况下使一个微服务脱机。

当然,微服务必须彼此交互,但它们的交互方式应该定义系统运行的好坏。有点像垂直切片架构,您不局限于使用一组架构模式;您可以独立地为每个微服务做出特定的决策。例如,您可以选择两个微服务之间的通信方式,而不是两个其他微服务之间的通信方式。您甚至可以为每个微服务使用不同的编程语言。

提示

我建议小型企业和组织使用一种或几种编程语言,因为开发人员可能较少,而且每种语言都有更多的工作要做。根据我的经验,您希望在人们离开时确保业务连续性,并确保您可以替换他们,而不会因为某些地方使用的模糊技术(或太多的技术)而使船沉没。

现在我们已经定义了基础知识,让我们开始讨论微服务的不同通信方式。我们将首先探索调解微服务之间通信的方法。然后,我们将学习如何屏蔽和隐藏微服务集群的复杂性。之后,我们将深入研究 CQRS 模式,并提供一个概念性的无服务器示例。最后,我们将通过提供容器的概述来结束本文,容器使我们能够更有效地部署整个或部分微服务集群。

开始使用消息队列

消息队列只不过是我们用来发送有序消息的队列。队列在先进先出先进先出的基础上工作。如果我们的应用在单个进程中运行,我们可以使用一个或多个Queue<T>在组件之间发送消息,或者使用ConcurrentQueue<T>在线程之间发送消息。此外,队列可以由独立的程序管理,以分布式方式(在应用或微服务之间)发送消息。

分布式消息队列可以在混合中添加更多或更少的功能,这对于云程序来说尤其如此,因为云程序必须比单个服务器处理更多级别的故障。其中一个特性是死信队列,它将不符合某些条件的消息存储在另一个队列中。例如,如果目标队列已满,则可以将消息发送到死信队列

存在许多消息传递队列协议;一些是专有的,而另一些是开源的。一些消息队列是基于云的,并作为服务使用,例如 Azure service Bus 和 Amazon Simple Queue service。其他的则是开源的,可以部署到云端或内部部署,比如 ApacheActiveMQ。

如果您需要按顺序处理消息,并希望每次将每条消息传递给一个收件人,那么消息队列似乎是正确的选择。否则,发布订阅模式可能更适合您。

下面是一个基本示例,说明了我们刚才讨论的内容:

Figure 16.1 – A publisher that enqueues a message with a subscriber that dequeues it

图 16.1–将消息排队的发布者与将消息排队的订阅者

举一个更具体的例子,假设我们希望我们的用户注册过程是分布式的。当用户注册时,我们要执行以下操作:

  1. 发送电子邮件确认。
  2. 处理他们的图片并保存一个或多个缩略图。
  3. 向他们的应用内邮箱发送入职消息。

为了依次实现这一点,我们可以执行以下操作:

Figure 16.2 – A process flow that sequentially executes three operations that happen after a user creates an account

图 16.2–在用户创建帐户后顺序执行三个操作的流程

在这种情况下,如果进程在进程缩略图操作期间崩溃,用户将不会收到入职消息。另一个缺点是,要在进程缩略图发送入职消息之间插入新操作,我们必须修改进程缩略图发送入职消息操作(紧密耦合)。

如果顺序不重要,我们可以在用户创建之后,将身份验证服务器的所有消息排队,如下所示:

Figure 16.3 – The Auth Server is queuing the operations sequentially while  different processes execute them in parallel

图 16.3–当不同进程并行执行操作时,身份验证服务器按顺序对操作进行排队

这个过程更好,但是身份验证服务器现在控制着一旦创建了新用户应该发生的事情。在前面的工作流中,身份验证服务器正在排队等待一个事件,该事件告知系统新用户已注册。但是,现在,它必须了解按顺序对每个操作排队的后处理工作流。这样做本身并没有错,而且在深入研究代码时更容易理解,但它会在身份验证服务器知道外部进程的服务之间创建更紧密的耦合。根据 SRP,我看不出身份验证/授权服务器除了负责身份验证、授权和管理数据之外,还应该负责其他任何事情。

如果我们从那里继续并希望在两个现有步骤之间添加新操作,我们只需修改身份验证服务器,这比前面的工作流更不容易出错。

如果我们想要两全其美,我们可以使用发布-订阅模式,我们将在下面介绍。我们将在这里重温这个例子。

结论

如果您需要消息按其排队顺序依次传递,那么队列可能是正确的工具。我们探索的示例从一开始就“注定要失败”,但它允许我们探索设计系统背后的思考过程。有时,第一个想法不是最好的,可以通过探索新的做事方式或学习新技能来改进。对他人的想法持开放态度也能带来更好的解决方案。

现在,让我们看看消息队列如何帮助我们在应用规模上遵循坚实的原则:

  • S:帮助在应用或组件之间集中和划分职责,而不让它们直接相互了解,从而打破紧密耦合。
  • O:允许我们在对方不知道的情况下更改消息生产者或订阅者的行为。
  • L:不适用。
  • I:每一条消息和处理程序都可以根据需要而变小,而在更大的事物方案中,微服务相互作用以解决更大的问题。
  • D:由于不知道其他依赖关系(即打破微服务之间的紧密耦合),微服务依赖于消息(抽象),而不是具体化(其他微服务的 API)。

一个缺点是消息排队和处理消息之间存在延迟。我们将在后续章节中更详细地讨论延迟和延迟。

事件概述

我们在上一节中讨论了消息。现在,我们将看到这些消息变成事件。所以,在我们了解酒吧子模式之前,让我们先深入了解一下。

事件是代表过去发生的事情的信息。

我们可以使用事件将复杂系统划分为更小的部分,或者让多个系统在不产生紧密耦合的情况下相互通信。这些系统可以是子系统或外部应用,如微服务。

我们可以将事件分为两大类:

  • 域事件
  • 整合活动

让我们详细看看每一个。

域事件

域事件是基于 DDD 的术语,表示域中的事件。此事件随后可能触发后续执行的其他逻辑。它允许将一个复杂的过程划分为多个较小的过程。这些事件可以按顺序执行,也可以“触发并忘记”。域事件在干净的体系结构中运行良好,可以用于简化复杂的域。我们可以使用 MediatR 发布域事件。

整合活动

集成事件是事件,就像域事件一样,但用于将消息传播到外部系统,很可能是进程外的。例如,可以是一个或多个其他微服务,例如在发送new user registered事件消息时。

我们使用 MessageBroker(参见下一节)发布集成事件。我们还可以使用消息队列按顺序发布这些事件。

现在,让我们看看发布-订阅模式是关于什么的。

实现发布-订阅模式

发布-订阅模式(Pub-Sub)与我们使用MediatR和所做的非常相似,我们在消息队列入门一节中探讨了。但是,我们不是将一条消息发送给一个处理程序(或将消息排队),而是将一条消息(或事件)发布(发送)给零个或多个订阅者(处理程序)。此外,出版商不知道订阅者;它只发送信息,希望得到最好的结果(也称为火与忘)。

我们可以通过消息代理在过程中或分布式系统中使用发布订阅。消息代理负责将消息传递给订阅者。这是微服务和其他类型的分布式系统的发展方向,因为它们不是在单个进程中运行的。

与其他沟通方式相比,这种模式有许多优势。例如,我们可以通过重放系统中发生的事件(消息)来重新创建数据库的状态,从而产生事件源模式。稍后再谈。

设计取决于用于传递消息的技术和该系统的配置。例如,您可以使用MQTT物联网物联网设备发送消息,并将其配置为保留在每个主题上发送的最后一条消息。这样,当设备连接到某个主题时,它会收到最后一条消息。您还可以配置一个卡夫卡代理,该代理保存了很长的消息历史记录,并在新系统连接到它时请求所有消息。所有这些都取决于您的需求和要求。

MQTT 与 apachekafka

如果你想知道 MQTT 是什么,这里是他们网站的一段引文 https://net5.link/mqtt

“MQTT 是物联网(IoT)的 OASIS 标准消息传输协议。它被设计为一种极其轻量级的发布/订阅消息传输[…]”“

这里有一段引用自阿帕奇·卡夫卡的网站https://net5.link/kafka

“ApacheKafka 是一个开源的分布式事件流平台[…]”


我们不能涵盖遵循每个协议的每个系统的每个场景。因此,我将重点介绍 Pub-Sub 设计模式背后的一些共享概念,以便您了解如何开始。然后,你可以深入研究你想要(或需要)使用的特定技术。

要接收消息,订阅者必须订阅主题(或主题的等效内容):

Figure 16.4 – A subscriber subscribes to a pub-sub topic

图 16.4–订阅者订阅发布子主题

发布子模式的第二部分是发布消息,如下所示:

Figure 16.5 – A publisher is sending a message to the message broker. The broker then forwards that message to N subscribers, where N can be zero or more

图 16.5–发布者正在向 MessageBroker 发送消息。然后,代理将该消息转发给N订户,其中N可以是零或更多

这里有许多依赖于代理和协议的抽象细节。但是,以下是发布-订阅模式背后的两个主要概念:

  • 发布者将消息发布到主题。

  • Subscribers subscribe to topics to receive messages.

    笔记

    例如,这里没有说明的一个关键实现细节是安全性。在大多数系统中,安全性是强制性的,并且不是每个子系统或设备都可以访问所有主题。

发布者和订阅者可以是任何系统的任何部分。例如,许多 Microsoft Azure 服务都是发布者(例如 Blob 存储)。然后,您可以让其他 Azure 服务(例如 Azure 功能)订阅这些事件以对其作出反应。

您还可以在应用中使用发布-订阅模式——无需使用云资源;这甚至可以在同一个过程中完成。

发布-订阅模式最显著的优点是能够打破系统之间的紧密耦合。一个系统可以发布事件,而其他系统则在系统彼此不了解的情况下使用这些事件。

这种松耦合导致了可伸缩性,每个系统都可以独立伸缩,并且可以使用所需的资源量并行处理消息。向工作流中添加新流程也更容易,因为系统不知道其他流程。要添加对事件做出反应的新流程,您只需创建一个新的微服务,部署它,然后开始侦听一个或多个事件并对其进行处理。

另一方面,MessageBroker 可能成为应用的单点故障,必须进行适当的配置。对于每种类型的消息,考虑最佳的消息传递策略也是必不可少的。策略的一个示例是确保关键消息的交付,同时延迟时间敏感度较低的消息,并在负载激增期间丢弃不重要的消息。

如果我们使用发布-订阅重温上一个示例,我们将得到以下简化的工作流程:

Figure 16.6 – The Auth Server is publishing an event representing the creation of a new user. The broker then forwards that message to the three subscribers that are then executing their tasks in parallel

图 16.6–Auth Server 正在发布表示创建新用户的事件。然后,代理将该消息转发给三个订阅者,这三个订阅者随后并行执行其任务

基于此工作流,我们将身份验证服务器与注册后流程解耦。身份验证服务器不知道工作流,各个服务也不知道彼此。此外,如果我们想添加一个新任务,我们只需要创建或更新一个微服务,然后将其订阅到正确的主题(在本例中,是“新用户注册”主题)。

当前系统不支持同步,也不处理进程故障或重试,但这是一个良好的开端,因为我们结合了消息队列示例的优点,而忽略了缺点。

消息代理

消息代理是一个允许我们发送(发布消息)和接收(订阅消息的程序。它在规模上扮演中介角色,允许多个应用在不相互了解的情况下相互对话(松散耦合。消息代理通常是实现发布-订阅模式的任何基于事件的分布式系统的中心部分。

一个应用(发布者)通常向主题发布消息,而其他应用(订阅者从这些主题接收消息。主题的概念可能因协议或系统而异,但我所知道的所有系统都有某种类似主题的概念,可以将消息路由到正确的位置。例如,您可以使用 Kafka 发布到Devices主题,但可以使用 MQTT 发布到devices/abc-123/do-something

MessageBroker 负责将消息转发给已注册的收件人。这些消息的生存期可能因代理而异,甚至每个消息的生存期也可能不同。

有多个消息代理使用不同的协议。有些代理是基于云的,比如 Azure 事件网格。其他代理是轻量级的,更适合 IoT,例如 Eclipse Mosquito/MQTT。与 MQTT 相比,其他版本更健壮,并允许高速数据流,如 ApacheKafka。

使用什么 MessageBroker 应该基于您正在构建的软件的需求。此外,您不限于一个经纪人。没有什么能阻止您选择一个 message broker 来处理您的微服务之间的对话,并使用另一个来处理您的系统和外部物联网设备之间的对话。如果您正在 Azure 中构建一个系统,想要实现无服务器,或者更喜欢付费购买可扩展且无需投入维护时间的 SaaS 组件,那么您可以利用 Azure 服务,如事件网格、服务总线和队列存储。

事件来源模式

现在我们已经探索了发布-订阅模式,了解了什么是事件,并讨论了事件代理,现在是探索如何重播应用的状态的时候了。为此,我们可以遵循事件源模式

事件来源背后的想法是存储事件的时间顺序列表,而不是单个实体,在该实体中,事件集合成为真相的来源。这样,每个操作都以正确的顺序保存,有助于提高并发性。此外,我们可以重播所有这些事件,以在新应用中生成对象的实际状态,从而使我们能够更轻松地部署新的微服务。

不只是存储数据,如果系统使用事件代理进行传播,其他系统可以将部分数据缓存为一个或多个物化视图

物化视图

物化视图是为特定目的而创建和存储的模型。数据可以来自一个或多个源,从而在查询该数据时提高了性能。例如,应用返回物化视图,而不是查询多个其他系统以获取数据。您可以将物化视图视为缓存实体,微服务将其存储在自己的数据库中。

事件源的缺点之一是数据一致性。从服务向存储添加事件到所有其他系统更新其物化视图之间,存在不可避免的延迟。这导致了最终的一致性

最终一致性

最终一致性意味着数据将在未来某个时间点保持一致,但不会完全一致。延迟可以从几毫秒到更长的时间,但目标通常是使延迟尽可能小。

另一个缺点是,与查询单个数据库的单个应用相比,创建这样一个系统非常复杂。与微服务体系结构一样,事件来源不仅仅是彩虹和独角兽。它的价格是:操作复杂性

操作复杂性

在微服务体系结构中,每一块都更小,但将它们粘合在一起需要成本。例如,支持微服务的基础设施比一块巨石(一个应用和一个数据库)更复杂。活动来源也是如此;所有应用都必须订阅一个或多个事件、缓存数据(物化视图)、发布事件等。这种操作复杂性表示复杂性从应用代码转移到操作基础架构。换句话说,与包含所有代码的单个应用相比,它需要更多的工作来部署和维护多个微服务和数据库,以及应对这些外部系统之间可能存在的网络通信不稳定性。巨石很简单:它们可以工作,也可以不工作;他们很少部分工作。

事件源的一个关键方面是将新事件追加到存储中,并且从不更改现有事件(仅追加)。简而言之,使用 Pub-Sub 模式进行通信的微服务发布事件、订阅主题并生成物化视图以服务于其客户机。

示例

让我们来探索一个例子,看看如果我们把刚学过的东西混在一起会发生什么。上下文:我们需要构建一个管理物联网设备的软件。我们首先创建两个微服务:

  • 处理物联网设备 twin 的数据(即设备的数字表示)的设备,我们将其命名为DeviceTwin
  • 管理我们命名为Networking的物联网设备(即如何到达设备)的网络相关信息的一个。

作为视觉参考,最终系统可以如下所示:

Figure 16.7 – Three microservices communicating using the Publish-Subscribe pattern

图 16.7–使用发布-订阅模式进行通信的三个微服务

以下是用户交互和发布的事件:

  1. A user creates a twin in the system named Device 1. DeviceTwin saves the data and publishes the DeviceTwinCreated event with the following payload:

    {
        "id": "some id",
        "name": "Device 1",
        "other": "properties go here..."
    }
    

    同时,Networking微服务需要知道设备是何时创建的,因此它订阅了DeviceTwinCreated事件。创建新设备时,它会在其数据库中为该设备创建默认网络信息;默认值为unknown。通过这种方式,Networking微服务知道哪些设备存在或不存在:

    Figure 16.8 – A workflow representing the creation of a device twin and its  default networking information

    图 16.8–表示创建双设备及其默认网络信息的工作流

  2. A user then updates the networking information of that device and sets it to MQTT. Networking saves the data and publishes the NetworkingInfoUpdated event with the following payload:

    {
        "deviceId": "some id",
        "type": "MQTT",
        "other": "networking properties..."
    }
    

    如下图所示:

    Figure 16.9 – A workflow representing updating the networking type of a device

    图 16.9–表示更新设备网络类型的工作流

  3. A user changes the display name of the device to Kitchen Thermostat, which is more relevant. DeviceTwin saves the data and publishes the DeviceTwinUpdated event with the following payload. The payload uses JSON patch to publish only the differences instead of the whole object (see the Further reading section for more information):

    {
        "id": "some id",
        "patches": [
            { "op": "replace", "path": "/name", "value": "Kitchen Thermostat" },
        ]
    }
    

    如下图所示:

Figure 16.10 – A workflow representing a user updating the name of the device to Kitchen Thermostat

图 16.10–表示用户将设备名称更新为厨房恒温器的工作流

比如说,一个团队设计并完成了一项新的微服务,该服务在物理位置组织设备。这允许用户在地图上可视化他们设备的位置,比如他们家的地图。

团队将该微服务命名为DeviceLocationDeviceLocation订阅所有三个事件来管理其物化的视图,如下所示:

  • 接收到DeviceTwinCreated事件时,保存其唯一标识符和显示名称。
  • 接收到NetworkingInfoUpdated事件时,保存通信类型(HTTP、MQTT 等)。
  • 当接收到DeviceTwinUpdated事件时,更新设备的显示名称。

当服务第一次部署时,设置为从头开始回放所有事件(事件来源;发生的情况如下:

  1. DeviceLocation receives the DeviceTwinCreated event and creates the following model for that object:

    {
        "device": {
            "id": "some id",
            "name": "Device 1"
        },
        "networking": {},
        "location": {...}
    }
    

    下图显示了这一点:

    Figure 16.11 – The DeviceLocation microservice replaying the DeviceTwinCreated event to create its materialized view of the device twin

    图 16.11–DeviceLocation microservice 重播 DeviceWinCreated 事件以创建设备的物化视图

  2. DeviceLocation receives the NetworkingInfoUpdated event, which updates the networking type to MQTT, leading to the following:

    {
        "device": {
            "id": "some id",
            "name": "Device 1"
        },
        "networking": {
            "type": "MQTT"
        },
        "location": {...}
    }
    

    下图显示了这一点:

    Figure 16.12 – The DeviceLocation microservice replaying the NetworkingInfoUpdated event to update its materialized view of the device twin

    图 16.12–DeviceLocation microservice 重播 NetworkingInFounded 事件以更新其设备的物化视图

  3. DeviceLocation receives the DeviceTwinUpdated event, updating the device's name. The final model looks like this:

    {
        "device": {
            "id": "some id",
            "name": "Kitchen Thermostat"
        },
        "networking": {
            "type": "MQTT"
        },
        "location": {...}
    }
    

    下图显示了这一点:

Figure 16.13 – The DeviceLocation microservice replaying the DeviceTwinUpdated event to update its materialized view of the device twin

图 16.13–DeviceLocation microservice 重播 DeviceWinUpdate 事件以更新设备的物化视图

从那里,DeviceLocation微服务被初始化并准备就绪。用户可以在地图上设置厨房恒温器的位置,或者继续玩系统的其他部分。当用户向DeviceLocation微服务查询Kitchen Thermostat相关信息时,显示物化视图,该视图包含所有需要的信息,无需发送外部请求。

考虑到这一点,我们可以生成DeviceLocation或其他微服务的新实例,它们可以从过去的事件生成物化视图——所有这些都非常有限,甚至不知道其他微服务。在这种类型的体系结构中,微服务只能了解事件,而不能了解其他微服务。微服务处理事件的方式应该只与该微服务相关,而不与其他微服务相关。这同样适用于出版商和订阅者。

此示例演示了事件源模式、集成事件、物化视图、消息代理的使用以及发布-订阅模式。

相比之下,使用直接通信(HTTP、gRPC 等)将如下所示:

Figure 16.14 – Three microservices communicating directly with one another

图 16.14–三个相互直接通信的微服务

如果我们比较两种方法,通过查看第一个图(图 16.7,我们可以看到 MessageBroker 扮演中介的角色,打破了微服务之间的直接耦合。通过查看前面的图(图 16.14,我们可以看到微服务之间的紧密耦合,DeviceLocation需要直接调用DeviceTwinNetworking来构建其物化视图的等价物。此外,DeviceLocation将一个呼叫转换为三个,因为Networking还必须与DeviceTwin通话。

假设最终一致性不是一个选项,或者发布-订阅模式无法应用,或者很难应用于您的场景。在这种情况下,微服务可以直接相互调用。他们可以使用 HTTP、RPC 或最适合特定系统需要的任何其他方法来实现这一点。

在这本书中我不会涉及这个话题,但是当直接调用微服务时,有一件事要小心,那就是间接调用链可能会快速冒泡。你不希望你的微服务创建一个超深的呼叫链,否则你的系统很可能会变得非常慢,非常快。这里有一个抽象的例子,说明我的意思。图表通常比文字更好:

Figure 16.15 – A user calling microservice A, which then triggers a chain reaction of subsequent calls, leading to disastrous performance

图 16.15–用户调用 microservice A,然后触发后续调用的连锁反应,导致灾难性性能

根据前面的图,让我们考虑一下失败。如果 microservice C 脱机,整个请求将以错误结束。当然,我们可以设置重试策略来创建更健壮、更稳定的系统,但这也意味着初始调用和响应之间的延迟更长。这比系统崩溃要好,但仍远未达到最佳状态。

结论

发布-订阅模式使用事件来解耦应用的各个部分。在微服务体系结构中,我们可以使用消息代理和集成事件来允许微服务相互通信。然后,我们可以利用事件源模式来持久化这些事件,允许新的微服务通过重播过去的事件来填充其数据库。

这些模式在一起可能非常强大,但也可能需要时间来实现。云提供商也提供类似的服务。与构建自己的基础设施相比,这些可以更快地开始。如果构建服务器是您的事情,那么您可以使用开源软件“经济地”构建堆栈。

现在,让我们看看发布-订阅模式如何帮助我们在应用规模上遵循坚实的原则:

  • S:帮助在应用或组件之间集中和划分职责,而不让它们直接相互了解,从而打破紧密耦合。
  • O:允许我们改变发布者和订阅者的行为方式,而不会直接影响其他微服务(打破它们之间的紧密耦合)。
  • L:不适用。
  • I:每个事件(抽象/契约)都可以根据需要设置为最小。
  • D:微服务依赖于事件(抽象)而不是具体化(其他微服务),打破了它们之间的紧密耦合。

正如您可能已经注意到的,它们与消息队列非常相似;唯一的区别在于信息的读取方式:

  • 一次一个,排队。

  • Up to multiple at the same time with the Pub-Sub pattern.

    观测器设计模式

    我自愿将观察者模式保留在本书之外,因为我们在.NET 中很少需要它。C#提供多播事件,在大多数情况下,这些事件能够很好地替代观察者模式。如果你不知道观察者模式,别担心——很可能你永远都不会需要它。不过,如果您已经知道 Observer 模式,那么下面是它与 Pub-Sub 模式之间的区别。

    在观察者模式中,主体保留其观察者的列表,从而直接了解其存在。具体的观察者也经常了解这个主题,这会导致对其他实体的更多了解,从而导致更多的耦合。

    在 Pub-Sub 模式中,发布者不知道订阅者;它只知道 MessageBroker。订阅者也不知道发布者,只知道消息代理。发布者和订阅者仅通过其发布或接收的消息的数据契约进行链接。

    我们可以将发布子模式视为观察者模式的分布式演化,或者更准确地说,就像向观察者模式添加中介一样。

接下来,我们将通过访问一种新的Façade:即网关,探索一些直接调用其他微服务的模式。

引入网关模式

在构建面向微服务的系统时,服务的数量随着功能的数量而增长;系统越大,您将拥有越多的微服务。当您考虑必须与这样一个系统交互的用户界面时,这可能会变得单调、复杂和低效(从开发角度和速度角度)。网关可以帮助我们实现以下目标:

  • 通过将请求路由到适当的服务来隐藏复杂性。
  • 通过聚合响应隐藏复杂性,将一个外部请求转换为多个内部请求。
  • 通过仅公开客户端需要的功能子集来隐藏复杂性。
  • 将外部请求转换为内部使用的另一个协议。

网关还可以集中不同的进程,例如记录和缓存请求、验证和授权用户和客户端、强制执行请求速率限制以及其他类似的策略。

您可以将网关视为立面,但它不是程序中的一个类,而是自己的程序,屏蔽其他程序。网关模式有多种变体,我们将探讨其中的许多变体。

无论您需要哪种类型的网关,您都可以自己编写或利用现有工具来加快开发过程。

提示

请注意,自制网关版本 1.0 很有可能比经验证的解决方案有更多的缺陷。本技巧不仅适用于网关,也适用于大多数复杂系统。也就是说,有时候,没有一个经过验证的解决方案可以完全满足我们的需求,我们必须自己编写代码,这才是真正的乐趣开始!

一个可以帮助你的开源项目是 Ocelot(https://net5.link/UwiY 。它是一个用.NET Core 编写的应用网关,支持我们期望从网关获得的许多东西。您可以使用配置或编写自定义代码来路由请求,以创建高级路由规则。因为它是开源的,所以如果需要的话,您可以对它进行贡献、分叉和探索源代码。

网关是一个反向代理,用于获取客户端请求的信息。这些信息可能来自一个或多个资源,可能位于一个或多个服务器上。微软正在开发一个名为 YARP 的反向代理,它也是开源的(https://net5.link/ 雅普)。在撰写本文时,它处于预览阶段,但我建议您看一看,因为他们似乎在为微软的内部团队构建它,所以它很可能会随着时间的推移而不断发展和维护。

现在,让我们探讨几种类型的网关。

网关路由模式

我们可以使用这种模式通过网关将请求路由到适当的服务来隐藏系统的复杂性。

例如,假设我们有两个微服务:一个保存设备数据,另一个管理设备位置。我们希望显示特定设备的最新已知位置(id=102,并显示其名称和型号。

为了实现这一点,用户请求 web 页面,然后 web 页面调用两个服务(参见下图)。DeviceTwin微服务可从 service1.domain.com 访问,位置微服务可从 service2.domain.com 访问。从那时起,web 应用必须跟踪哪些服务使用哪些域名。当我们添加更多的微服务时,UI 必须处理更多的复杂性。此外,如果在某个时刻,我们决定将service1更改为device-twins,将service2更改为location,我们还需要更新 web 应用。如果只有一个用户界面,它仍然没有那么糟糕,但是如果您有多个用户界面,这意味着每个用户界面都必须处理这种复杂性。

此外,如果我们想在专用网络中隐藏微服务,除非所有用户界面也是该专用网络的一部分,否则是不可能的:

Figure 16.16 – A web application and a mobile app that are calling two microservices directly

图 16.16–直接调用两个微服务的 web 应用和移动应用

为了解决其中一些问题,我们可以实现一个网关来为我们进行路由。这样,用户界面不需要知道哪些服务可以通过哪些 DNS 访问,而只需要知道网关:

Figure 16.17 – A web application and a mobile app that are calling two microservices through  a gateway application

图 16.17–通过网关应用调用两个微服务的 web 应用和移动应用

当然,这会带来一些可能的问题,因为您的网关可能会成为单点故障。您可以考虑使用负载均衡器确保您有足够强的可用性和足够快的性能。由于所有请求都通过网关,因此您可能需要在某个点上对其进行放大。

您还应该通过实现不同的弹性模式,例如重试断路器,确保您的网关支持故障。随着您部署的微服务数量和发送到这些微服务的请求数量的增加,在网关的另一端发生错误的可能性也会增加。

您还可以使用路由网关来重新路由 URI,以创建更易于使用的 URI 模式。您还可以重新路由端口;添加、更新或删除 HTTP 头;还有更多。让我们探讨相同的示例,但使用不同的 URI。让我们假设如下:

UI 开发人员很难记住什么端口导致了什么微服务,什么在做什么(谁会责怪他们?)。此外,我们不能像以前那样传输请求(只路由域)。我们可以使用网关作为一种方式,为开发人员创建令人难忘的 URI 模式,供他们使用,如下所示:

如您所见,我们将端口从等式中去掉,以创建可用、有意义且易于记忆的 URI。

但是,我们仍然向网关发出两个请求以显示一条信息(设备的位置及其名称/型号),这将引导我们进入下一个网关模式。

网关聚合模式

我们可以赋予网关的另一个角色是允许它聚合请求,以向系统消费者隐藏复杂性。

继续前面的示例,我们有两个 UI 应用,它们包含一个功能,在使用设备名称/型号识别设备之前,在地图上显示设备的位置。要实现这一点,他们必须调用设备 twin 端点以获取设备的名称和型号,以及位置端点以获取其最后一个已知位置。因此,显示一个小方框的两个请求乘以两个 UI,意味着一个简单功能需要维护四个请求。如果我们进行推断,我们最终可能会为一些功能管理几乎无穷无尽的 HTTP 请求。

下图显示了我们当前状态下的功能:

Figure 16.18 – A web application and a mobile app that are calling two microservices through a gateway application

图 16.18–通过网关应用调用两个微服务的 web 应用和移动应用

为了解决这个问题,我们可以应用网关聚合模式来简化 UI,并将管理这些细节的责任转移到网关上。

通过应用网关聚合模式,我们最终得到以下简化流程:

Figure 16.19 – A gateway that aggregates the response of two requests to serve a single request from both a web application and a mobile app

图 16.19–一个网关,聚合两个请求的响应,以服务来自 web 应用和移动应用的单个请求

接下来,让我们按顺序探讨发生的步骤。在这里,我们可以看到 Web 应用发出一个请求,而网关发出两个调用。在下图中,请求是串行发送的,但我们可以并行发送它们以加快速度:

Figure 16.20 – The order in which the requests take place

图 16.20–请求发生的顺序

与路由网关一样,聚合网关可能会成为应用的瓶颈和单点故障,因此要小心这一点。

另一个需要注意的要点是网关和内部 API 之间的延迟。如果延迟太高,您的客户端将等待每个响应。因此,在与之交互的微服务附近部署网关可能对系统的性能至关重要。

网关可以实现缓存以提高性能,从而使子请求每隔一段时间只发送一次。

前端图案的后端

前端模式的后端是网关模式的另一个变体。使用后端作为前端,而不是构建通用网关,您可以为每个用户界面(或与系统交互的应用)构建网关,从而降低复杂性。此外,它允许细粒度地控制暴露的端点。它消除了在对应用 A 进行更改时应用 B 被破坏的可能性。这种模式可以实现许多优化,例如只发送每次呼叫所需的数据,而不发送只有少数应用正在使用的数据,从而节省了一些带宽。

假设我们的 Web 应用需要显示更多关于设备的数据。为了实现这一点,我们需要更改端点,并将额外的信息发送到移动应用。然而,移动应用不需要这些信息,因为它的屏幕上没有空间显示这些信息。这是一个更新的图表,它将单个网关替换为两个网关,每个前端一个。

Figure 16.21 – Two backends for frontends gateways; one for the Web App and one for the Mobile App

图 16.21–前端网关的两个后端;一个用于 Web 应用,一个用于移动应用

通过这样做,我们现在可以为每个前端开发特定的功能,而不会影响其他前端。每个网关现在都将其特定前端与系统的其余部分和另一个前端隔离开来。

同样,前端的后端模式是一个网关。与网关模式的其他变体一样,它可能成为其前端和单点故障的瓶颈。好消息是,前端网关的一个后端中断将影响限制在单个前端,从而保护其他前端不受停机的影响。

混合匹配网关

现在我们已经探索了网关模式的三种变体,重要的是要注意,我们可以在代码库级别或作为多个微服务进行混合和匹配。

例如,可以为单个客户端构建网关(前端为后端),执行简单路由,并聚合结果。

我们还可以将它们作为不同的应用进行混合,例如,将多个前端网关后端放在更通用的网关前面,以简化前端后端的开发和维护。

注意每一跳都有代价。在客户端和微服务之间添加的内容越多,这些客户端接收响应所需的时间(延迟)就越长。当然,您可以将机制放在适当位置以降低开销,例如缓存或非 HTTP 协议(如 GRPC),但您仍然必须考虑它。这适用于一切,而不仅仅是大门。

下面是一个示例,说明了这一点:

Figure 16.22 – A mix of the Gateway patterns

图 16.22——网关模式的组合

正如您可能猜到的,通用网关是所有应用的单一故障点,而同时,前端网关的每个后端都是其特定客户端的故障点。

服务网

服务网格是帮助微服务相互通信的替代方案。它是应用之外的一个层,代理服务之间的通信。这些代理被注入每个服务的顶部,称为侧车。服务网格还可以帮助实现分布式跟踪、检测和系统弹性。如果您的系统需要服务到服务的通信,那么服务网格将是一个很好的查找位置。

结论

网关是一个外表或反向代理,屏蔽或简化对一个或多个其他服务的访问。在本节中,我们探讨了以下内容:

  • 路由:将请求从 a 点转发到 B 点。
  • 聚合:将多个子请求的结果合并成一个响应。
  • 前端后端:用于与前端的一对一关系。

我们可以像任何其他模式一样使用微服务模式,并将其混合和匹配。只需考虑优点,也可以考虑它们带来的缺点。如果你能和他们一起生活,那么,你就有了自己的解决方案。

网关往往是单一的故障点,所以这是一个需要考虑的问题。此外,我们还必须考虑调用另一个服务的服务所添加的延迟,因为这样会减慢响应时间。

总而言之,网关是简化消费微服务的绝佳工具。它们还允许隐藏其背后的微服务拓扑,甚至可能隔离在专用网络中。他们还可以处理跨领域的问题,如安全问题。

现在,让我们看看网关如何帮助我们在应用规模上遵循坚实的原则:

  • S:网关可以处理路由、聚合和其他逻辑,否则这些逻辑将在不同的组件或应用中实现。

  • O: I see many ways to attack this one, but here are two takes on this:

    a) 在外部,网关可以将其子请求重新路由到新的 URI,而其消费者不知道,只要其契约不变。

    b) 在内部,网关可以从配置中加载其规则,从而允许它在不更新代码的情况下进行更改。

  • L:我们可以看到前面的点(b)是没有改变应用的正确性。

  • I:因为前端网关的后端服务于单个前端系统。这意味着每个前端系统有一个合同(接口),从而产生多个较小的接口,而不是一个大型通用网关。

  • D:我们可以将网关视为一种抽象,隐藏了真正的微服务(实现)。

现在,让我们在分布式规模上重温 CQR。

重温 CQRS 模式

命令查询责任分离CQRS),在第 14 章中探讨,中介和 CQRS 设计模式,应用了命令查询分离(【T13 CQS】原则。与我们在第 14 章、Mediator 和 CQR 设计模式中看到的相比,我们可以使用微服务或无服务器计算进一步推动 CQR。我们可以通过使用多个微服务和数据源来进一步划分命令和查询,而不是简单地在命令和查询之间创建明确的分隔。

CQS是一个原则,说明方法应该返回数据或修改数据,但不能同时返回数据和修改数据。另一方面,CQRS建议使用一个模型读取数据,使用一个模型变异数据。

无服务器计算是一种云执行模型,云提供商根据使用情况管理服务器并按需分配资源。无服务器资源属于平台即服务(PaaS)产品。

让我们再次以物联网为例;在前面的示例中,我们查询的是设备的最后一个已知位置,但是更新该位置的设备又如何呢?这可能意味着每分钟推送许多更新。为了解决这个问题,我们将使用 CQR。我们将重点关注两项业务:

  • 正在更新设备位置。
  • 读取设备的最后一个已知位置。

简单地说,我们有一个Read Location微服务、一个Write Location微服务和两个数据库。请记住,每个微服务都应该拥有自己的数据。这样,用户可以通过读取微服务(查询模型)访问最后一个已知的设备位置,而设备可以准时将其当前位置发送到写入微服务(命令模型)。通过这样做,我们将读取和写入数据的负载分开,因为这两种数据都以不同的频率出现:

Figure 16.23 – Microservices that apply CQRS to divide the reads and writes of a device's location

图 16.23–应用 CQR 划分设备位置读写的微服务

这是一个简化版本,但基本上,读取是查询,而写入是命令。向写数据库添加新值后,我们更新读数据库的方式取决于我们正在使用/构建的系统。这种体系结构中的一个基本要素是,根据 CQRS 模式,命令不应返回值,从而启用“触发并忘记”场景。有了这条规则,消费者不必等到命令完成后再做其他事情。

一种方法是利用现有的云基础设施,如 Azure 功能和表存储。让我们使用以下组件重新讨论此示例:

Figure 16.24 – Using Azure services to manage a CQRS implementation

图 16.24–使用 Azure 服务管理 CQRS 实现

上图说明了以下内容:

  1. 设备每隔T次将其位置发送到 Azure 功能 1。
  2. Azure 函数 1 然后将LocationAdded事件发布到事件代理,它也是一个事件存储(Write DB)。
  3. LocationAdded事件的所有订阅者现在都可以适当地处理该事件;在本例中,Azure 函数 2。
  4. Azure Function 2 更新设备在 Read DB 中的最后一个已知位置。
  5. 任何后续查询都将导致获取新位置。

这是最终一致性的一个很好的示例。在步骤 14之间读取最后一个已知位置的任何调用都将获取旧值(系统正在处理新值)。

在前面的示例中,MessageBroker 也是事件存储区,但我们可以将事件存储在其他位置,例如 Azure 存储表或时间序列数据库中。此外,出于多种原因,我抽象了这个组件,包括有多种方式在 Azure 中发布事件,以及有多种方式使用第三方组件(开源和专有)。

时间序列数据库

时间序列数据库针对临时查询和存储数据进行了优化,您总是在不更新旧记录的情况下追加新记录。这种 NoSQL 数据库对于时间密集型的使用非常有用。

我们再次使用发布-订阅模式来启动另一个场景。假设事件永远保持不变,前面的示例也可以支持事件源。此外,新服务可以订阅LocationAdded事件,而不会影响已部署的代码。例如,我们可以创建一个 signarmicroservice,将更新推送到客户端。它与 CQRS 无关,但它与我们迄今为止所探索的一切都很好地结合在一起,因此这里是一个更新的图表:

Figure 16.25 – Adding a SignalR service as a new subscriber without impacting the  other part of the system

图 16.25-在不影响系统其他部分的情况下,将信号服务添加为新用户

信号器微服务可以是自定义代码或 Azure 信号器服务(由另一个 Azure 功能支持);没关系。这一想法是为了说明,将新服务与 pub-sub 模型混合使用更容易,而不是通过一个整体或更传统的方式在微服务之间提供通信。

正如您所看到的,微服务系统添加了越来越多的小组件,这些组件通过一个或多个消息代理间接地相互连接。维护、诊断和调试此类系统比使用单个应用更困难;这就是我们前面提到的操作复杂性。然而,容器可以帮助部署和维护这样的系统,这是我们的下一个主题。

从 ASP.NET Core 3.0 开始,ASP.NET 团队在分布式跟踪上投入了大量精力。分布式跟踪对于发现与从一个程序流向另一个程序(如微服务)的事件相关的故障和瓶颈是必要的。如果出现错误,跟踪用户是如何隔离错误、重现错误,然后修复错误的,这一点很重要。独立的部分越多,就越难实现这种追踪。这超出了本书的范围,但现在您又了解了一个 ASP.NET Core 5 特性。

结论

CQRS 有助于明确划分查询和命令,并有助于独立封装和隔离每个逻辑块。当我们将这一概念与无服务器计算或微服务体系结构相结合时,它允许我们独立扩展读写。我们还可以使用不同的数据库,为我们提供系统每个部分所需的数据速率所需的工具(例如,频繁写入和偶尔读取)。

现在,让我们看看 CQRS 如何帮助我们在应用规模上遵循坚实的原则:

  • S:将应用划分为较小的读写应用(或函数)倾向于将单个职责封装到不同的程序中。
  • O:CQRS 与无服务器计算或微服务相结合,有助于扩展软件,而无需我们通过添加、删除或替换应用来修改现有代码。
  • L:不适用。
  • I:命令和查询之间有明确的区别,创建多个小接口(或程序)应该比创建一个更大的接口更容易。
  • D:不适用。

接下来,我们将探索容器,它帮助我们开发和部署微服务。

集装箱概述

容器是虚拟化的一种演进。使用容器,我们可以虚拟化应用而不是机器。为了共享资源,我们可以利用虚拟机或物理机。容器包含容器化应用运行所需的所有内容,包括操作系统。

容器可以帮助我们设置环境,确保应用在环境(本地、暂存和生产)之间移动时的正确性,等等。通过将所有内容打包到单个容器映像中,我们的应用变得比以往任何时候都更具可移植性;容器的另一个好处是可以配置容器之间的网络和关系。此外,容器是轻量级的,允许我们在几秒钟内创建一个新的容器,从而实现按需提供资源,这些资源可以随着流量峰值而增大,然后在需求减少时缩小。

容器可能非常抽象,乍一看似乎非常复杂。然而,如今,这些工具已经成熟和改进,使得理解和调试容器比以往任何时候都更容易,但这仍然是一条陡峭的学习曲线。好处是,一旦掌握了它,就很难回到非容器化应用。

在本节中,我们将探讨与容器相关的以下主题:

  • Docker,这是一个容器引擎。
  • Docker Compose,它允许我们编写复杂的 Docker 应用。
  • 编排,这是管理复杂的容器化应用的概念。在本节中,我们将探讨两个编排器。
  • 最后,我们将讨论扩展,这是使用容器和微服务的一个关键点,其中每个微服务都可以独立扩展。

让我们从 Docker 开始。

码头工人

Docker 是迄今为止最受欢迎的集装箱引擎。现在开始很容易,而掌握它则是另一回事。您可以在 Linux 或 Windows 上使用 Docker,甚至在 Windows 上的 Linux 上使用 WSL 或 WSL2。入门页面(请参阅进一步阅读)介绍了如何安装 Docker 以及 Docker Hub 是什么。

以下是我们将要讨论的内容:

  • Docker 桌面
  • 码头中心
  • Docker 图像
  • 码头集装箱
  • Dockerfile
  • . dockerignore

Docker Desktop是允许您在本地运行容器的运行时(您必须先安装它)。它还附带了dockerdocker-composeCLI。

Docker Hub是一个基于的网络存储库,您可以在其中发布、共享和下载Docker 图片

码头形象是计划建造码头集装箱。对于熟悉 Norton ghost 或虚拟机映像的人来说,它类似于鬼映像。

Docker 容器为运行的Docker 镜像;基本上,正在运行的应用。您可以运行一个映像的多个实例(容器)。

Dockerfile是描述Docker 图像构建过程的文本文件。

Adockerignore文件的工作方式类似于.gitignore文件,允许您通过ADDCOPY指令排除某些文件被复制到图像中。

Docker Compose是一个实用程序,允许您构建复杂的拓扑结构,包括多个 Docker 映像、公用和专用网络、卷等。Docker Compose使用 YAML 文件作为配置(默认为docker-compose.yml,并使用docker-composeCLI 运行。

关于这些概念,VisualStudio 和 VisualStudio 代码都有非常有用的工具来帮助 Docker。此外,较新的 Docker 桌面用户界面非常方便,包括仪表板和设置。2020 年对于 Docker 工具来说是伟大的一年。

基本思路如下:

  1. 安装 Docker 和其他先决条件(只需执行一次)。
  2. 为每个应用创建一个Dockerfile
  3. 创建一个docker-compose.yml文件,将多个应用作为一个整体进行管理(可选)。
  4. 将图像部署到图像存储库(本地、Docker Hub 或任何云提供商)。
  5. 将图像作为容器运行(使用 Docker、容器即服务、Kubernetes 或其他工具)。

要从 Visual Studio 创建 Dockerfile,请执行以下操作:

  1. 右键单击要停靠的项目。
  2. 在上下文菜单中,选择添加>Docker 支持
  3. 选择 Linux 或 Windows。

在 Ubuntu-18.04(WSL2)上运行的名为 WebApp 的 web 项目生成的 Dockerfile 如下所示:

FROM mcr.microsoft.com/dotnet/aspnet:5.0-buster-slim AS base
WORKDIR /app
EXPOSE 80
EXPOSE 443
FROM mcr.microsoft.com/dotnet/sdk:5.0-buster-slim AS build
WORKDIR /src
COPY ["WebApp/WebApp.csproj", "WebApp/"]
RUN dotnet restore "WebApp/WebApp.csproj"
COPY . .
WORKDIR "/src/WebApp"
RUN dotnet build "WebApp.csproj" -c Release -o /app/build
FROM build AS publish
RUN dotnet publish "WebApp.csproj" -c Release -o /app/publish
FROM base AS final
WORKDIR /app
COPY --from=publish /app/publish .
ENTRYPOINT ["dotnet", "WebApp.dll"]

在 Docker 工具上工作的微软工程师利用 Docker 功能名为多级构建来生成优化层,确保最终图像尽可能小,同时在构建过程中仍然可以访问.NET SDK(请参见突出显示的行,并找到基础建造发布最终阶段)。

VisualStudio 提示

当使用 Visual Studio 工具运行和调试 Docker 容器时,Visual Studio 只使用第一个阶段,因此,如果在启动期间必须运行逻辑或任何其他依赖项,例如需要在 micro Linux 发行版中安装的字体,则必须将该逻辑放入base层。

Dockerfile 的一个重要部分是FROM [base image] AS [alias]指令。FROM加载现有 Docker 映像作为其基础映像。它类似于从类继承;我们从整个基础图像继承。然后,我们可以向该图像添加更多内容,创建我们自己的新图像。此外,每个FROM定义了多阶段构建的新阶段。

.NET SDK 与运行时映像

在前面的 Dockerfile 中,第一阶段包含.NET 运行时(mcr.microsoft.com/dotnet/aspnet:5.0-buster-slim,第二阶段包含 SDK(mcr.microsoft.com/dotnet/SDK:5.0-buster-slim)。这打开了使用 SDK 在 Docker 内部构建应用并在仅包含运行时的映像(stage)中发布应用的可能性。运行时比完整的 SDK 轻得多。生成的图像越小,下载和启动新容器的速度就越快。

WORKDIR指令指定执行上下文的目录,其他指令从该目录执行,如RUNCOPYENTRYPOINTCOPY做你认为它做的事;它将文件从计算机复制到映像。

RUN在容器的操作系统中执行指定的命令。例如,它可以是一个简单的dotnet build命令,也可以是一个更复杂的命令,或者是一系列下载并安装.NETSDK 的命令。

EXPOSE告诉 Docker 应用正在侦听哪些端口。还必须使用-p(一个端口)或-P(所有暴露端口)标志打开这些端口。不要忘记,您的应用必须侦听您公开的端口;否则,在查询容器时不会发生任何事情。我们还可以打开并映射docker-compose.yml文件中的端口。

ENTRYPOINT表示启动容器时运行的可执行文件。在本例中,它是dotnet WebApp.dll,它使用.NET CLI 运行 web 应用。

更多信息

有关 Microsoft 构建 DockerFile 的方式的更多信息,请参阅https://net5.link/L2PD

有关 Dockerfiles 语法的更多信息,请参阅https://net5.link/j1Kv

Docker 附带了一个 CLI,这可能需要一本书来描述,但这里有一些有用的命令。我认为这些片段将帮助您开始学习 Docker。

docker build允许您创建图像。--rm标志删除中间容器,-f标志指向 Dockerfile,-t标志允许您指定标记(用于识别和运行图像)。下面是一个例子(结尾的.很重要,代表当前目录):

docker build --rm -f "WebApp/Dockerfile" -t webapp:latest .

docker run允许您基于图像启动容器。如果不希望附加外壳,可以使用-d标志在后台运行容器(分离模式)。当容器退出时,--rm标志将移除容器,这在开发时非常有用。以下是一个例子:

docker run --rm -d -p 443:8443/tcp -p 80:8080/tcp webapp:latest

指定tcp是可选的,是映射端口时的默认值。也可以用另一个值代替,如udp--name标志可以方便以后按名称访问容器;例如:

docker run --rm -d -p 8080:80 --name MyWebApp webapp:latest

docker image ls列出所有 Docker 图像,docker ps列出所有正在运行的图像(容器)。docker stop停止运行的容器,而docker rm移除停止的容器。例如,我们可以使用以下命令启动、停止并移除容器:

docker run -d -p 8080:80 --name MyWebApp webapp:latest
docker stop MyWebApp
docker rm MyWebApp

对于一个未命名的容器,我们需要使用它的 ID(运行docker ps来查找正在运行的容器的 ID)。我们可以通过ID停止并移除容器,如下所示:

docker stop 0d5bffe4071f
docker rm 0d5bffe4071f

您可以使用dockerCLI 和docker-compose标记您的容器。然后,您可以将这些标签用于许多有用的事情,例如过滤。我们可以在执行docker build标记容器时使用-l选项。我们还可以在执行docker ps命令时使用--filter "label=[label to filter here]"选项来过滤共享标签的运行容器;例如:

docker run -d -p 8080:80 --name MyWebApp -l webapp webapp:latest
docker ps --filter "label=webapp"

在这里,我们将介绍docker ps命令的另外两个选项。第一个是-a标志,它可以方便地列出已停止的容器(例如当它们崩溃或未正确启动时)。第二个是-q标志,它只输出 ID(这对于链接命令很有用)。例如,如果要停止所有标记为webapp的容器,可以运行以下命令(在 bash 和 PowerShell 中):

docker stop $(docker ps --filter "label=webapp" -a -q)

现在已经有足够的 Docker CLI 命令了;让我们窥视一下吧。

码头工人

Docker Compose 允许您创建一个复杂的系统,并通过创建一个或多个 YAML 文件将多个应用链接在一起。VisualStudio 和 VisualStudio 代码都提供了可以帮助您创建和编辑docker-compose文件的工具,这在您刚开始工作时非常有用。VisualStudio 创建两个补充文件:默认的docker-compose.yml文件和一个用于本地重写的文件docker-compose.override.yml。在使用docker-composeCLI 时,您可以使用任意数量的文件,以便为暂存生产等定义覆盖。以下是该docker-compose.yml文件的一个稍加修改的版本:

version: '3.4'
services:
    webapp:
        image: ${DOCKER_REGISTRY-}webapp
        build:
            context: .
            dockerfile: WebApp/Dockerfile
        container_name: MyWebApp
        ports:
            - '8080:80'
        labels:
            - webapp

这个文件与我们运行容器时执行的上一个命令相同;它映射端口,添加webapp标签,将容器命名为MyWebApp,并使用WebApp/Dockerfile

要构建图像,我们可以使用docker-compose build命令。--no-cache标志便于确保我们重建图像;缓存有时是一种痛苦。--force-rm标志的作用类似于docker build --rm标志,可移除中间容器。下面的命令使用docker-compose.yml文件和compose.override.yml文件(如果存在)组合来构建图像:

docker-compose build --no-cache --force-rm

要指定某些文件及其应用顺序,我们可以使用-f选项,如下所示:

docker-compose -f docker-compose.yml build --no-cache --force-rm

需要注意的是,-f选项必须位于build之前的,而不是之后,就像其他选项一样。也可以指定多个文件,如下所示:

docker-compose -f docker-compose.yml -f another-docker-compose-file.yml build --no-cache --force-rm

要运行(启动)系统,我们可以使用docker-compose up。与build命令一样,我们可以使用up命令之前的-f选项指定一个或多个文件。您还可以使用-d标志在分离模式下运行容器,并在启动之前使用--build标志构建图像。以下是一个例子:

docker-compose -f docker-compose.yml up --build -d

最后,要取下系统,我们可以使用docker-compose down命令,它也支持-f选项,如下所示:

docker-compose -f docker-compose.yml down

现在我们已经研究了所有这些命令,让我们向docker-compose.yml文件添加一个 SQL Server 实例,并使我们的 Web 应用依赖于它。实现这一点非常简单,只需将服务添加到docker-compose.yml文件中,并指定我们的 WebAppdepends_on即可:

version: '3.4'
services:
    webapp:
        image: webapp:latest
        build:
            context: .
            dockerfile: WebApp/Dockerfile
        container_name: MyWebApp
        ports:
            - '8080:80'
        labels:
            - webapp
        depends_on:
            - sql-server
    sql-server:
        image: 'mcr.microsoft.com/mssql/server'
        ports:
            - '1433:1433'
        environment:
            SA_PASSWORD: Some_Super_Strong_Password_123
            ACCEPT_EULA: 'Y'
        labels:
            - db

WebApp 可以使用以下连接字符串:

Server=sql-server;Database=[database name here];User=sa;Password=Some_Super_Strong_Password_123;

连接字符串中的服务器名称(突出显示的)与docker-compose.yml文件中的服务名称(突出显示的)匹配。这是因为Docker Compose会根据服务名称自动创建 DNS 条目。这些 DNS 可以从其他容器访问。

现在我们已经创建了一个连接字符串,我们不想在docker-compose.yml文件中输入密码,这样我们就不会意外地将该值提交到 Git 中。我们可以通过多种方式来实现这一点,比如通过将环境变量传递给docker/docker-compose命令,但我们将创建一个.env文件。

提示

使用 Git 时,将.env文件添加到.gitignore文件中,这样就不会将其提交到存储库中。此外,不要忘记记录应该放在某个地方的值,没有秘密值,这样您的同事就可以创建和更新他们的个人.env文件。例如,您可以创建一个包含键但不包含敏感值的.env.template文件,并将该文件签入 Git。

docker-compose.yml文件的同一级别,如果我们添加.env文件,我们可以重用我们在其中定义的环境变量,如下所示:

.env:

# Don't commit this file in Git
SQL_SERVER_SA_PASSWORD=Some_Super_Strong_Password_123
SQL_SERVER_CONNECTION_STRING=Server=sql-server;Database=webapp;User=sa;Password=Some_Super_Strong_Password_123;

docker-compose.yml:

version: '3.4'
services:
    webapp:
        image: webapp:latest
        build:
            context: .
            dockerfile: WebApp/Dockerfile
        container_name: MyWebApp
        ports:
            - '8080:80'
        environment:
 - ConnectionString=${SQL_SERVER_CONNECTION_STRING}
        labels:
            - webapp
        depends_on:
            - sql-server
    sql-server:
        image: 'mcr.microsoft.com/mssql/server'
        ports:
            - '1433:1433'
        environment:
 SA_PASSWORD: ${SQL_SERVER_SA_PASSWORD}
            ACCEPT_EULA: 'Y'
        labels:
            - db

使用这两个文件,当运行docker-compose up时,两个容器启动:一个 SQL Server 和一个连接到该 SQL Server 的 ASP.NET Core 5 web 应用。此外,我们打开并将端口1433映射到自身,允许我们使用 SQL Management Studio 或您选择的工具连接到该容器。

提示

端口1433是默认的 SQL Server 端口。在生产过程中,不要让端口1433处于打开状态。打开的攻击向量越少,入侵者入侵系统的难度就越大。

从.NET 5 的角度来看(在 web 应用内部),我们可以访问连接字符串,就像我们可以访问任何其他设置一样(_configuration属于注入Startup类的IConfiguration

var connectionString = _configuration.GetValue<string>("ConnectionString");

我们也可以从那里加载实体框架核心上下文,如下所示:

services.AddDbContext<MyDbContext>(options => options.UseSqlServer(connectionString))

现在我们更详细地研究了 DOCKER,让我们来看看一个可以用来管理生产环境的工具。

编曲

一旦我们有了集装箱化的微服务应用,我们就需要部署它。挑战从单个应用中的功能数量转移到要部署、维护和协调的应用数量。

每个云提供商都有自己的产品,可以是无服务器的,比如Azure 容器实例ACI)和Azure Kubernetes 服务AKS)。您还可以在云中或本地维护自己的虚拟机VM)。

Kubernetes 是最受欢迎的容器编曲。它允许您部署、管理和扩展容器。Kubernetes 可以帮助您管理多个虚拟机、添加负载平衡、监视容器、根据需求自动扩展等等。

K8s

Kubernetes也称为K8s,是“K”的缩写,其他 8 个字母,然后是“s”。K8s 发音为K-eights

使用 Docker Compose 时,可以帮助您开始使用 K8s 的工具是Komposehttps://net5.link/NKqu )。这是一个开源项目,将 docker compose YAML 文件转换为 K8s YAML 文件。通过运行以下命令,此过程也可以在连续集成CI管道)中实现自动化:

kompose convert -f docker-compose.yaml

有很多工具可以帮助编排和部署容器,但我们不能在这里全部介绍。这也是为什么我一直保持这个部分尽可能的精简;我不想让你被那些可能变得无关紧要或你永远不会使用的工具的信息淹没。相反,我认为重要的是奠定一些基础来帮助你开始。在探索 K8s 术语之前,让我们先从 Tye 项目开始。

Tye 项目

项目类型(https://net5.link/tye 是一个开源项目,由微软员工 David Fowler、Glenn von Breadmeister、Justin Kotalik 和 Ryan Nowak 发起。NET 基金会现在赞助这个项目。以下是他们的自述说明:

“Tye 是一种开发人员工具,它使微服务和分布式应用的开发、测试和部署变得更加容易。Tye 项目包括一个本地编排器,使微服务的开发更加容易,并且能够以最少的配置将微服务部署到 Kubernetes。”

如果你不看 Build2020,很多人都会称赞这个工具,所以我想我会在这本书中加入一个简短的介绍,让你知道它的存在。

简而言之,Tye 是另一个基于 YAML 的工具,它允许您为分布式应用编写多个程序(这是我最初的想法)。现在,我将 Tye 视为一种用于简化分布式.NET 应用开发、初始安装成本及其部署的工具。

在其功能中,Tye 提供以下功能:

  • 显示应用和服务的仪表板。
  • 允许您加载 Docker 图像。
  • 代理服务器,允许您轻松配置入口,完成路由网关的工作。
  • 启用分布式跟踪
  • 启用服务发现
  • 允许您连接到日志聚合系统,如弹性堆栈序列
  • 允许您部署到 Kubernetes。
  • 允许您部署到云提供商,如 AKS。
  • 还有更多!

我只和泰伊玩了一点,但听起来很有希望。例如,我启动了一个现有的解决方案,只执行了dotnet tye命令,没有任何额外的配置(tye是通过 NuGet 安装的全局工具)。该解决方案包含大约 15 个 docker compose 文件、15 个 docker 文件和大多数已启动的容器。自从 Tye 成立以来,我就一直在远处看着它,但我的目标是尝试更多。很有可能当你读到这篇文章的时候,Tye 项目已经更加成熟了,所以我想告诉你这是一个好主意。它正在积极开发中。

接下来,让我们看看库伯内特斯!

库伯内特斯

在本节中,我们将讨论与 Kubernetes 相关的一些概念,以便您知道,如果您开始阅读更多关于 K8s 的内容,那么最基本的情况是什么。集群是一组节点。节点是包含豆荚的机器(物理或虚拟)。集装箱在吊舱内运行。豆荚易挥发;引用官方文件:

“[豆荚]是生的,死了也不会复活。”

服务通过识别为资源提供服务的一组吊舱来进行救援,比如设备定位微服务。服务是 Kubernetes 内部运行的应用的概念标识符,因此外部客户机不必知道 POD 一直在生成和销毁。入口暴露集群之外的服务。是存储文件的目录。它们的寿命比容器长,但与相关的荚一起死亡。持久卷PV是用于在集群中存储文件的专用资源。PVs 可以在网络文件系统NFS)、iSCSI 或云存储系统上进行配置。请注意,保存在容器中的文件的生存期与该容器的生存期相关联。

提示

容器随时可能被销毁,因此不要在容器内存储重要文件;否则,你将失去它们。每个容器都有自己的文件系统,因此文件不会在它们之间共享,即使两个容器来自同一个映像。根据需要使用卷或 PV。

我知道这一小节包含许多概念,同时也没有太多的信息。然而,我认为这个关于库伯内特斯的高级概述足以节省您阅读和破译来自不同来源的信息的时间。你可以在以后再回到这一章。

结垢

每个人都在谈论缩放;所有的云提供商都在出售自动扩展和几乎无限的扩展功能,但这意味着什么呢,微服务?

让我们回到我们的物联网示例。假设有如此多的设备发送有关其位置的实时信息,服务器需要更多的电源来运行位置微服务。我们在这里可以做的是给服务器(CPU 和 RAM)注入更多的能量,这就是所谓的垂直伸缩。然后,在某一点上,当单个服务器不够时,我们可以添加更多服务器,这称为水平扩展。然而,更多的服务器意味着在所有这些服务器上运行应用的多个实例。使用容器和编排器(如 Kubernetes)可以在需求足够高时创建容器,然后在需求恢复正常时删除它们。此外,我们可以运行最少数量的实例,以便在一个实例崩溃时,在崩溃的实例重新启动时(更准确地说,在新实例启动时,它将被删除),始终有一个或多个其他实例运行以服务请求。

当同一应用的多个实例同时运行时,需要将请求路由到正确的节点(服务器)。为了实现这一点,托管该应用的所有节点都必须有一个公共入口点。没有一个使用者可以负责到达他们想要的实例,否则会造成混乱(并且无法管理)。为了解决这个问题,我们可以使用负载平衡器来平衡运行在不同服务器上的不同 nt 应用之间的负载。

负载平衡器是一种路由网关;它接受请求并将其路由到正确的服务器,同时管理服务器之间的负载。我们将不详细介绍所有这些的实现细节,但这里有一个上下文关系图来表示这一点:

Figure 16.26 – A device that sends its location to a Kubernetes cluster. Then, a load balancer dispatches the request to the right instance of the DeviceLocation microservice

图 16.26–将其位置发送到 Kubernetes 群集的设备。然后,负载平衡器将请求发送到 DeviceLocation microservice 的正确实例

这有点简化,但它显示了负载平衡背后的思想:

  1. 请求进入集群并到达负载平衡器。
  2. 负载平衡器将请求发送到DeviceLocation microservice的相应实例。

一些负载平衡器还可以在后续调用后将同一服务器服务于同一客户机,从而使有状态应用更加可靠。

结论

容器对于创建可移植应用非常有用。Docker Compose 和 orchestration 程序在编写和部署复杂应用时非常方便。所有这些都导致比以往任何时候都更容易扩展系统的各个部分。

现在,让我们看看容器如何帮助我们在应用规模上遵循固体原则:

  • S:他们帮助我们协调、测试和部署更小的微服务,并承担一项责任。
  • O:它们通过添加和移除容器而不停止应用来帮助我们改变系统的行为。
  • L:不适用。
  • I:它们帮助我们协调、测试和部署更小的微服务(更小的应用公开更小的公共契约/接口)。
  • D:不适用。

总结

微服务架构与我们在本书中介绍的其他内容以及我们如何构建巨石有所不同。我们将其拆分为多个较小的应用,称之为微服务,而不是一个大型应用。微服务必须彼此分离;否则,我们将面临与紧耦合类(乘以无穷大)相关的可能问题。

我们可以利用发布-订阅设计模式来解耦微服务,同时通过事件保持它们的连接。消息代理是发送这些消息的软件。我们可以使用事件源在任何时间点重新创建应用的状态,包括生成新容器时。我们可以使用应用网关来保护客户端不受微服务集群复杂性的影响,并且只公开服务的一个子集。

我们还研究了如何在 CQRS 设计模式的基础上实现对相同实体的读写分离,从而允许我们独立地扩展查询和命令。我们还研究了如何使用无服务器资源来创建这种系统。

然后,我们概述了有关容器、Docker、Kubernetes 和缩放的概念。因为我们可以写很多关于这些的书,所以我们只看了一下微服务可以做什么。

另一方面,微服务是有成本的,并不打算取代现有的一切。对于许多项目来说,建造一座独石仍然是一个好主意。另一个解决方案是从一个整体开始,并在扩展时将其迁移到微服务。这使我们能够更快地开发应用(monolith)。与将新功能添加到微服务应用相比,向一块巨石添加新功能也更容易。大多数情况下,整体式应用中的错误成本低于微服务应用中的错误成本。您还可以计划将来向微服务的迁移,这将实现两个方面的最佳效果,同时保持较低的操作复杂性。例如,您可以通过 monolith 中的 MediatR 通知来利用发布-订阅模式,并在稍后将系统迁移到 microservices 体系结构时(如果需要的话)将事件调度责任迁移到 message broker。

我不想让你放弃微服务架构,但我只是想确保你在盲目投入之前权衡一下这样一个系统的利弊。您的团队的技能水平以及他们学习新技术的能力也可能会影响加入微服务行列的成本。

DevOps(开发[Dev]和 IT 操作[Ops])和DevSecOps(为 DevOps 组合添加安全性)在构建微服务时是必不可少的,我们在本书中没有介绍。它们带来了部署自动化、自动化质量检查、自动合成等功能。否则,您的项目将很难部署和维护。

当您需要扩展、想要无服务器或在多个团队之间分担责任时,微服务非常有用,但请记住操作成本。

本章总结了本书的应用范围部分。接下来,我们将探讨 ASP.NETCore5 提供的用户界面选项,包括 Blazor 和 MVU 模式。

问题

让我们来看看几个练习题:

  1. 消息队列发布子模型之间最显著的区别是什么?
  2. 什么是事件来源
  3. 一个应用网关可以同时是路由网关聚合网关吗?
  4. 真正的 CQR 需要使用无服务器云基础设施,这是真的吗?
  5. 构建 MicroService 应用时是否需要使用容器?

进一步阅读

以下几个链接将帮助您在本章所学知识的基础上进一步发展:

十七、ASP.NET Core 用户界面

在本章中,我们将探讨使用 ASP.NET Core 5 及其广泛的产品创建用户界面的不同方法。我们有 MVC、Razor Pages 和 Blazor(第 18 章简要介绍 Blazor作为宏模型。然后我们有部分视图、视图组件、标记帮助器、显示模板、编辑器模板和 Razor 组件来微观管理 UI。此外,.NET5 生态系统还包括用于构建 UI 的其他非 web 技术,如 WinForm、WPF、UWP 和 Xamarin。

本章的目标不是涵盖所有这些元素和技术的各个方面,而是制定一个计划,解释它们的作用和使用方法。

本章将介绍以下主题:

  • 熟悉剃须刀页面
  • 组织用户界面
  • C#9 特征
  • 显示和编辑模板

熟悉剃须刀页面

顾名思义,Razor Pages 是一种服务器端呈现 web 内容的方式,按页面组织。这非常适用于 web,因为人们访问页面,而不是控制器。Razor Pages 在引擎盖下与 MVC 共享许多组件。

如果您想知道使用 MVC 或 Razor 页面是否最适合您的项目,请扪心自问,将项目组织成页面是否更适合您的场景。如果是的话,请翻页;否则,选择其他项目,例如 MVC 或 SPA。我们也可以在同一个应用中同时使用 Razor 页面和 MVC,因此不需要只选择一个。

使用 Razor 页面与 MVC 非常相似。在Startup.ConfigureServices方法中,我们可以调用services.AddRazorPages();扩展方法,而不是services.AddControllersWithViews();services.AddControllers();

同样的情况也适用于Startup.Configure方法,其中我们必须使用endpoints.MapRazorPages();方法映射路径。

其他中间产品的用途是相同的。以下是Startup的一个示例:

public class Startup
{
    public void ConfigureServices(IServiceCollection services)
    {
        services.AddRazorPages();
    }
    public void Configure(IApplicationBuilder app, IWebHostEnvironment env)
    {
        if (env.IsDevelopment())
        {
            app.UseDeveloperExceptionPage();
        }
        else
        {
            app.UseExceptionHandler("/Error");
            app.UseHsts();
        }
        app.UseHttpsRedirection();
        app.UseStaticFiles();
        app.UseRouting()
        app.UseAuthorization();
        app.UseEndpoints(endpoints =>
        {
            endpoints.MapRazorPages();
        });
    }
}

通过突出显示的两行,ASP.NET 为我们处理路由和模型绑定,就像处理 MVC 一样。

我们可以使用webapp项目模板创建 Razor Pages 项目:

dotnet new webapp

设计

每个页面可以处理一个或多个GETPOST方法。其思想是每个页面都是自给自足的(SRP)。首先,页面由两部分组成:视图和模型。模型必须继承自PageModel。视图必须使用@model指令链接到其页面模型,@page指令告诉 ASP.NET 它是一个 Razor 页面,而不仅仅是一个 MVC 视图。

以下是该关系的可视化表示:

Figure 17.1 – Diagram representing a Razor page

图 17.1–表示剃须刀页面的示意图

下面是我使用 VisualStudio 搭建的一个示例。@page@model指令在以下代码段中突出显示:

页面\Employees\Create.cshtml

@page
@model PageController.Pages.Employees.CreateModel
@{
    ViewData["Title"] = "Create";
}
<h1>Create</h1>
<h4>Employee</h4>
<hr />
<div class="row">
    <div class="col-md-4">
        <form method="post">
            <div asp-validation-summary="ModelOnly" class="text-danger"></div>
            <div class="form-group">
                <label asp-for="Employee.FirstName" class="control-label"></label>
                <input asp-for="Employee.FirstName" class="form-control" />
                <span asp-validation-for="Employee.FirstName" class="text-danger"></span>
            </div>
            <div class="form-group">
                <label asp-for="Employee.LastName" class="control-label"></label>
                <input asp-for="Employee.LastName" class="form-control" />
                <span asp-validation-for="Employee.LastName" class="text-danger"></span>
            </div>
            <div class="form-group">
                <input type="submit" value="Create" class="btn btn-primary" />
            </div>
        </form>
    </div>
</div>
<div>
    <a asp-page="Index">Back to List</a>
</div>
@section Scripts {
    @{await Html.RenderPartialAsync ("_ValidationScriptsPartial");}
}

接下来的是PageModel,我们在这里讨论:

Pages\Employees\Create.cshtml.cs

namespace PageController.Pages.Employees
{
    public class CreateModel : PageModel
    {
        private readonly EmployeeDbContext _context;
        public CreateModel(EmployeeDbContext context)
        {
            _context = context;
        }
        public IActionResult OnGet()
        {
            return Page();
        }
        [BindProperty]
        public Employee Employee { get; set; }
        public async Task<IActionResult> OnPostAsync()
        {
            if (!ModelState.IsValid)
            {
                return Page();
            }
            _context.Employees.Add(Employee);
            await _context.SaveChangesAsync();
            return RedirectToPage("./Index");
        }
    }
}

从该代码中,我们可以看到两个部分:视图和模型。在PageModel代码中,[BindProperty]属性告诉 ASP.NET 将表单 post 绑定到Employee属性。这相当于 MVC 操作,如下所示:

[HttpPost]
public Task<IActionResult> MyAction([FromForm] Employee employee) {…}

从视觉上看,请求页面的用户如下所示:

Figure 17.2 – User requesting a page

图 17.2–请求页面的用户

默认情况下,页面位于Pages目录下,而不是Views目录下。MVC 的布局机制也可用于 Razor 页面。默认布局在Pages/Shared/_Layout.cshtml文件中;Pages/_ViewStart.cshtmlPages/_ViewImports.cshtml文件与 MVC 等效文件的作用相同,只是用于 Razor 页面。

路由

在 MVC 中,我们可以通过创建全局路由模式或使用属性来控制路由。在 Razor 页面中,我们还可以控制这些路径。默认情况下,路由系统会根据文件系统自动创建路由,从而加快我们的启动速度。要使用自动路由,我们只需要在页面顶部包含@page指令,ASP.NET Core 为我们发挥了神奇的作用。

Razor Pages 使用的路由系统简单但功能强大。默认模式是页面的位置,没有Pages文件夹和.cshtml扩展名, Index.cshtml页面是可选的(如 MVC 的Index视图)。与其没完没了地解释,不如看一些例子:

在 Razor 页面中,路由系统根据 URL 选择要显示的页面。

我们还可以用自定义路由替换这些默认路由。替换页面默认路由的方法是在@page指令后提供路由模板,如@page "/some-route"。该页面现在处理/some-routeURL,而不是默认 URL。该模板支持与 MVC 管线相同的功能,包括参数和约束。

涵盖 Razor 页面的各个方面超出了本书的范围,但我鼓励您深入研究构建网站和 web 应用的方法。它令人愉快,功能强大,有时比 MVC 更简单。

但是,如果您需要除GETPOST之外的其他 HTTP 方法,Razor 页面可能不适合您。

结论

Razor 页面是一个很好的选择,当你想用页面而不是控制器来组织你的网站或 web 应用时。支持 MVC 的许多功能,例如验证(ModelStateValidationSummary)、路由系统、模型绑定、标记帮助器等。

现在让我们看看刮胡刀页面如何帮助我们遵循坚实的原则:

  • S:每个PageModel负责一个页面,这是剃须刀页面的关键点。

  • O:不适用。

  • L:不适用。

  • I:不适用。

  • D: N/A.

    单一责任原则

    人们可以将剃须刀页面的“单一责任”视为多重责任。它同时处理读取和写入;页面模型;HTTP 请求;并且可以使用 HTTP 响应。

    请记住,Razor 页面的目标是管理页面。这是一个单一的责任。责任并不能转化为一次操作。这就是说,如果您认为其中一个 Razor 页面中的代码太多,可以通过提取这些职责的一部分并将其委托给其他组件来帮助减轻这种负担,从而使PageModel的职责和代码更少。例如,您可以使用 MediatR 在别处提取业务逻辑。

我们可以将 Razor 页面视为一个简化的页面控制器(企业应用架构(PoEAA)的 Martin Fowler 模式)。为什么要简化?因为 ASP.NET 为我们完成了页面控制器的大部分工作,只剩下模型(域逻辑)和视图来实现。更多信息,请参见进一步阅读部分。

现在我们已经了解了 Razor 页面和 MVC,现在是时候探索 ASP.NET Core 5 为我们提供的用于组织 UI 的选项了。

组织用户界面

在本节中,我们将探讨三个选项:

  • 局部视图到封装了可重用的 UI 组件。
  • 标记助手使我们能够编写类似于 Razor 的 HTML 代码,而不是类似于 C 的语法。
  • 视图组件允许用一个或多个视图封装逻辑以创建可重用组件。

请记住,我们可以在 MVC 和 Razor 页面中使用这些选项。

局部视图

局部视图是在cshtml文件(剃刀文件)中创建的视图的一部分。局部视图的内容(标记)在<partial>标记辅助程序或@ Html.PartialAsync()方法包含的位置呈现。ASP.NET 在 MVC 中引入了这个概念,因此出现了视图。对于 Razor 页面,您可以将部分视图视为部分页面。

我们可以在项目中的任何地方放置局部视图文件,但我建议将它们放在使用它们的视图附近。您也可以将它们保存在Shared文件夹中。根据经验,局部视图的文件名以_开头,如_CreateOrEditForm.cshtml

局部视图擅长简化复杂视图,并在多个其他视图中重用部分 UI。下面是一个有助于简化_Layout.cshtml文件的示例:

<div class="container">
    @ Html.PartialAsync("_CookieConsentPartial")
    <partial name="_CookieConsentPartial" />
    <main role="main" class="pb-3">
        @RenderBody()
    </main>
</div>

两条高亮显示的线做的是相同的事情,因此只需要一条。这是加载局部视图的两种代码样式;挑一个你喜欢的。本例在页面的该位置插入Pages/Shared/_CookieConsentPartial.cshtml文件的内容。部分视图与 ASP 和 PHP 中旧的包含非常相似,但它们不能直接访问调用方的作用域(这是一件非常好的事情)。

默认情况下,Model属性的当前值被发送到局部视图,但也可以发送不同的模型,如下所示:

@{
    var myModel = "My Model";
}
@ Html.PartialAsync("_SomePartialView", myModel)
<partial name="_SomePartialView" model="myModel" />

在本例中,myModel是一个字符串,但也可以是任何类型的对象。

局部视图比包含的更加健壮,并增加了灵活性。现在让我们深入研究一些代码。

项目:共享表单

部分视图的一个可能性是共享表示代码。在 CRUD 项目中,创建和编辑表单通常非常相似,因此我们可以利用局部视图简化此类复制维护。这类似于第 4 章中实施的项目,MVC 模式使用 Razor但使用 Razor 页面代替 MVC。

此项目的初始 Razor 代码已由 Visual Studio 基于以下Employee类构建:

namespace PageController.Data.Models
{
    public class Employee
    {
        public int Id { get; set; }
        [Required]
        [StringLength(50)]
        public string FirstName { get; set; }
        [Required]
        [StringLength(50)]
        public string LastName { get; set; }
    }
}

接下来,我们将探索一种集中两个页面共享的表单的方法,以增强模块的可维护性。首先,我们必须提取CreateModelEditModel的共享部分,以便表单可以使用它。ICreateOrEditModel接口包含该共享合同:

页面/Employees/icreatoreditmodel.cs

public interface ICreateOrEditModel
{
    Employee Employee { get; set; }
}

那么CreateModelEditModel都必须实现:

页面/Employees/Create.cshtml.cs

public class CreateModel : PageModel, ICreateOrEditModel
{
    ...
    [BindProperty]
    public Employee Employee { get; set; }
    ...
}

页面/Employees/Edit.cshtml.cs

public class EditModel : PageModel, ICreateOrEditModel
{
    ...
    [BindProperty]
    public Employee Employee { get; set; }
    ...
}

然后我们可以隔离表单的共享部分,并将其移动到_Form.cshtml部分视图(您可以根据需要命名它):

页面/Employees/_Form.cshtml

@model ICreateOrEditModel
<div class="form-group">
    <label asp-for="Employee.FirstName" class="control-label"></label>
    <input asp-for="Employee.FirstName" class="form-control" />
    <span asp-validation-for="Employee.FirstName" class="text-danger"></span>
</div>
<div class="form-group">
    <label asp-for="Employee.LastName" class="control-label"></label>
    <input asp-for="Employee.LastName" class="form-control" />
    <span asp-validation-for="Employee.LastName" class="text-danger"></span>
</div>

在前面的代码中,我们使用ICreateOrEditModel接口作为@model,因此我们可以访问创建和编辑页面模型的Employee属性。然后,我们可以在创建和编辑页面中包含该部分视图:

页面/Employees/Create.cshtml

@page
@model PageController.Pages.Employees.CreateModel
...
<div class="row">
    <div class="col-md-4">
        <form method="post">
            <div asp-validation-summary="ModelOnly" class="text-danger"></div>
            <partial name="_Form" />
            <div class="form-group">
                <input type="submit" value="Create" class="btn btn-primary" />
            </div>
        </form>
    </div>
</div>

页面/Employees/Edit.cshtml

@page
@model PageController.Pages.Employees.EditModel
...
<div class="row">
    <div class="col-md-4">
        <form method="post">
            <input type="hidden" asp-for="Employee.Id" />
            <div asp-validation-summary="ModelOnly" class="text-danger"></div>
            <partial name="_Form" />
            <div class="form-group">
                <input type="submit" value="Save" class="btn btn-primary" />
            </div>
        </form>
    </div>
</div>

有了它,我们就有一个表单要维护,两个页面做不同的事情。在这种情况下,表单很简单,但在更实体的情况下,这可以节省大量时间。

请注意,局部视图中的复杂逻辑可能比使用它节省的时间更成问题。如果您在局部视图中看到条件代码的数量增加,我建议您调查是否有其他技术/模式会更好。摆脱局部视图或创建在较少位置使用的多个局部视图也可能是解决方案。有时我们认为分享是一个好主意,但事实证明并非如此。当这种情况发生时,承认你的失败,然后解决问题。

结论

局部视图是重用 UI 部分或将复杂屏幕划分为更小、更易于管理的元素的好方法。部分视图是封装 UI 块的最基本方式。当显示逻辑受到限制时使用它们;对于更高级的用例,我们将在下面的小节中探讨其他选项。

现在让我们看看局部视图如何帮助我们遵循坚实的原则:

  • S:将 UI 的可管理部分提取到部分视图中,可以封装类似组件的视图,每个视图管理一个单独的显示责任。
  • O:不适用。
  • L:不适用。
  • I:不适用。
  • D:不适用。

标签助手

标记帮助程序是服务器端帮助程序,允许开发人员在 Razor 视图中编写更多类似 HTML 的代码,从而减少视图中混合的类似 C 的代码量。在上一个示例中,我们使用了标记助手,可能是在不知道的情况下。

让我们从第二次查看_Form.cshtml文件开始:

页面/Employees/_Form.cshtml

@model ICreateOrEditModel
<div class="form-group">
    <label asp-for="Employee.FirstName" class="control-label"></label>
    <input asp-for="Employee.FirstName" class="form-control" />
    <span asp-validation-for="Employee.FirstName" class="text-danger"></span>
</div>
<div class="form-group">
    <label asp-for="Employee.LastName" class="control-label"></label>
    <input asp-for="Employee.LastName" class="form-control" />
    <span asp-validation-for="Employee.LastName" class="text-danger"></span>
</div>

在这个局部视图中,我们使用内置的 ASP.NET 标记帮助程序来增强 HTMLlabelinputspan标记。asp-*属性用于设置某些内置标记助手属性的值。

例如,标签标签帮助器根据其asp-for属性自动生成 HTMLfor属性的值。此外,如果模型属性由一个属性修饰,它将基于属性名或其属性名生成标签文本。

要使用 HTML 帮助程序获得相同的输出,局部视图如下所示:

Pages/Employees/_Form-HtmlHelpers.cshtml

@model ICreateOrEditModel
<div class="form-group">
    @ Html.LabelFor(x => x.Employee.FirstName, new { @class = "control-label" })
    @ Html.TextBoxFor(x => x.Employee.FirstName, new { @class = "form-control" })
    @ Html.ValidationMessageFor(x => x.Employee.FirstName, null, new { @class = "text-danger" })
</div>
<div class="form-group">
    @ Html.LabelFor(x => x.Employee.LastName, new { @class = "control-label" })
    @ Html.TextBoxFor(x => x.Employee.LastName, new { @class = "form-control" })
    @ Html.ValidationMessageFor(x => x.Employee.LastName, null, new { @class = "text-danger" })
</div>

在这两种情况下,FirstName表单组呈现为以下 HTML:

<div class="form-group">
    <label class="control-label" for="Employee_FirstName">FirstName</label>
    <input class="form-control valid" type="text" data-val="true" data-val-length="The field FirstName must be a string with a maximum length of 50." data-val-length-max="50" data-val-required="The FirstName field is required." id="Employee_FirstName" maxlength="50" name="Employee.FirstName" value="Bob" aria-describedby="Employee_FirstName-error" aria-invalid="false">
    <span class="text-danger field-validation-valid" data-valmsg-for="Employee.FirstName" data-valmsg-replace="true"></span>
</div>

我发现使用 TagHelpers 比使用旧的 HTML Helpers(C#)更优雅,但这是我个人的偏好。然而,我们可以选择并混合这两种选择。

内置标记助手

ASP.NET Core 中有许多内置的标签助手。有些可以根据环境(生产或开发)帮助加载不同的元素;其他帮助构建<a>标签的href属性;还有更多。

让我们快速概述一下现有的内容。如果您想在以后了解更多信息,那么官方文档会越来越好,因为 Microsoft 已经将其开源。我在本章末尾的进一步阅读部分添加了一些链接。之后,我们将探索如何创建自定义标记帮助器。

锚定标记辅助对象

锚定标记帮助器增强<a>标记,根据控制器动作或剃须刀页面生成href属性。

以下是 Razor 页面的几个示例:

这与 MVC 控制器类似:

锚定标记帮助器在创建类似 HTML 的链接时非常有用,在没有匿名对象和转义字符的情况下设置class属性非常方便(如果您以前使用过 HTML 帮助器,您就会知道我的意思)。

asp-route-id属性与其他属性略有不同。asp-route-*属性允许指定参数的值,例如id。所以如果GET动作是这样的:

[HttpGet]
public IActionResult Details(int something){ ... }

您需要一个指定something参数的链接,可以这样声明:

<a asp-controller="Employees" asp-action="Details" asp-route-something="123">...</a>

这个标记帮助器后面还有很多选项,我们在这里不介绍,但是通过我们所做的,您知道您可以利用 ASP.NET 生成基于页面和控制器的链接。

链接标记辅助对象

链接标记帮助器允许您定义回退 CSShref,以防主 CSS 未加载(即,如果 CDN 关闭)。以下是 Razor Pages 模板中的一个示例:

<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/twitter-bootstrap/4.1.3/css/bootstrap.min.css"
        asp-fallback-href="~/lib/bootstrap/dist/css/bootstrap.min.css"
        asp-fallback-test-class="sr-only" 
        asp-fallback-test-property="position" 
        asp-fallback-test-value="absolute"
        crossorigin="anonymous"
        integrity="sha256-eSi1q2PG6J7g7ib17yAaWMcrr5GrtohYChqibrV7PBE=" />

ASP.NET 根据指定的asp-fallback-test-*属性呈现所需的 HTML 和 JavaScript,以测试是否加载了 CSS。如果不是,则将其交换为在asp-fallback-href属性中指定的一个。

脚本标记辅助程序

脚本标记助手允许您定义一个回退 JavaScript 文件,以防主文件未加载(即,如果 CDN 关闭)。以下是 Razor Pages 模板中的一个示例:

<script src="https://cdnjs.cloudflare.com/ajax/libs/twitter-bootstrap/4.1.3/js/bootstrap.bundle.min.js"
        asp-fallback-src="~/lib/bootstrap/dist/js/bootstrap.bundle.min.js"
        asp-fallback-test="window.jQuery && window.jQuery.fn && window.jQuery.fn.modal"
        crossorigin="anonymous"
        integrity="sha256-E/V4cWE4qvAeO5MOhjtGtqDzPndRO1LBk8lJ/PR7CA4=">
</script>

ASP.NET 根据指定的asp-fallback-test属性呈现所需的 HTML 和 JavaScript,以测试脚本是否已加载。如果未加载脚本,浏览器会将源代码替换为在asp-fallback-href属性中指定的源代码。这与<link>标签相同,但与<script>标签相同。

环境标记帮助器

环境标签助手允许仅针对特定环境呈现 UI 的某些部分。例如,您只能在Development中呈现一些调试信息。

环境标记帮助器也是对<link><script>标记帮助器的一个很好的补充,允许我们在生产环境中开发和托管 CDN 的小型文件时加载本地的非小型脚本。

我们可以通过分别使用includeexclude属性包含或排除环境来定义目标环境。这些属性的值可以是单个环境名称或逗号分隔的列表。以下是一些例子:

<environment include="Development">
    <div>Development Content.</div>
</environment>

前面的代码片段仅在环境为Development时显示<div>

<environment exclude="Development">
    <div>Content not to display in Development.</div>
</environment>

前面的代码段显示了除Development之外的所有环境的<div>

<environment include="Staging,Production">
    <div>Staging and Production content.</div>
</environment>

前面的代码片段仅针对StagingProduction环境显示<div>

<environment exclude="Staging,Production">
    <div>Content not to display in Staging nor Production.</div>
</environment>

前面的片段显示除StagingProduction之外的所有环境的<div>

缓存标记帮助程序

ASP.NETCore 还提供以下与缓存相关的标记帮助程序:

  • 缓存标记帮助器
  • 分布式缓存标记帮助器
  • 图像标记辅助对象

默认情况下,缓存标记帮助程序允许缓存视图的一部分 20 分钟,并利用 ASP.NET Core 缓存提供程序机制。一个基本示例是缓存一个随机数,如下所示:

<cache>@(new Random().Next())</cache>

还可以设置多个属性来控制缓存失效的方式及其目标。例如,我们可能希望缓存对用户的问候语,但如果我们编写以下内容,所有用户都会看到触发缓存的第一个用户的问候语:

<cache>Hello @this.User.Identity.Name!</cache>

要解决该问题,我们可以将vary-by-user属性设置为true

<cache vary-by-user="true">
    Hello @ this.User.Identity.Name!
</cache>

在其他情况下可以使用多个其他vary-by-*属性,例如vary-by-headervary-by-queryvary-by-routevary-by-cookie

为了控制缓存失效的方式,我们可以将expires-on属性设置为DateTime对象,或者将expires-afterexpires-sliding属性设置为TimeSpan对象。

如果这还不够,ASP.NET Core 还提供一个分布式缓存标记****帮助器,它利用您注册的IDistributedCache实现。必须在Startup类中配置分布式缓存提供程序,否则标记帮助程序将使用内存提供程序。您还必须通过设置name属性为每个元素指定一个唯一的键。其他属性与缓存标记辅助对象相同。

最后一个与缓存相关的标记辅助对象是图像标记辅助对象。该标记帮助器允许在图像更改时使其无效。为此,ASP.NET 将一个版本附加到其增强的<img>标记上,当文件更改时,该标记将失效。

由于图像标签助手增强了<img>标签,因此这里没有新标签。若要使用此功能,您必须在具有以下src属性的<img>标记上将asp-append-version属性设置为true

<img src=img/some-picture.jpg" asp-append-version="true">

在使用这三个标记帮助器中的一个或多个时,缓存部分视图比以往任何时候都要容易,但缓存本身就是一个主题,我不想在这里深入探讨。

表单标记帮助程序

ASP.NET Core 在创建表单时提供多个标记帮助程序。由于表单是收集用户输入的方式,因此它们非常重要。在这里,我们首先介绍表单标记帮助器,它扩展了<form>HTML 标记。

它的第一个优点是自动呈现input[name="__RequestVerificationToken"]元素,以防止跨站点请求伪造(CSRF 或 XSRF)。Razor Pages 自动进行验证,但MVC 不进行。要在使用 MVC 时启用 XSRF/CSRF 保护,我们需要使用[ValidateAntiForgeryToken]属性装饰动作或控制器。

第二个优点是有助于路由。当路由时间到来时,表单标记帮助器公开与锚定标记帮助器相同的属性,如asp-controllerasp-actionasp-pageasp-route-*属性。

要提交表单,您可以像任何普通 HTML<form>标记一样进行:使用button[type="submit"]input[type="submit"]。我们还可以使用相同的路由属性在不同的按钮上设置不同的操作。

接下来,让我们来探索前面我们看到的输入标记助手。输入标记帮助器的关键属性是asp-for。将其设置为视图模型的属性时,ASP.NET 会自动生成<input>标记的name及其value、验证信息以及该输入的type。例如,bool呈现为input[type=checkbox],而string呈现为input[type=text]。我们可以使用数据注释来装饰视图模型,以控制要生成的输入类型,如[EmailAddress][Url][DataType(DataType.*)]

提示

当您的模型上有一个表示集合的属性时,您应该使用for循环(而不是foreach循环)来生成表单。否则,在许多情况下,ASP.NET Core 将无法正确呈现元素,并且在发布表单后,您将在服务器上收到这些字段的null值。下面是一个有效的示例:

@for (var i = 0; i < Model.Destinations.Count; i++)

{

<input type="text" asp-for="@destinations[i].Name" class="control-label"></label>

<input type="text" asp-for="@destinations[i].Name" class="form-control" />

}

增强 HTML 标记的标记帮助程序的另一个优点是所有标准 HTML 属性都是可用的。因此,当您想要为正在编辑的模型的Id属性创建input[type=hidden]时,您可以直接设置type属性并覆盖默认值,如下所示:

<input type="hidden" asp-for="Employee.Id" />

然后我们有这个文本区域标记助手,它生成一个<textarea>标记,如下所示:

<textarea asp-for="Employee.Description"></textarea>

然后是帮助呈现<label>标记的标签标记助手,如下所示:

<label asp-for="Employee.Description"></label>

最后,选择标记帮助器帮助使用其asp-items属性中指定的值呈现<select>标记。这些物品必须是IEnumerable<SelectListItem>集合。asp-for属性的作用与其他标记帮助程序相同。以下是手动生成的绑定到ModelSomeProperty属性的项列表的示例:

@{
    var items = new[]
    {
        new SelectListItem("Make a selection", ""),
        new SelectListItem("Choice 1", "1"),
        new SelectListItem("Choice 2", "2"),
        new SelectListItem("Choice 3", "3"),
    };
}
<select asp-items="items" asp-for="SomeProperty"></select>

提示:enum

您可以使用Html.GetEnumSelectList<TEnum>()方法从enum生成列表,其中TEnum是您的enum类型。生成的<option>标记的数值等于enum元素的值,其文本设置为enum元素的文本表示,如<option value="2">SecondOption</option>

要自定义每个选项的文本,您可以使用属性装饰您的enum成员,如[Display(Name = "Second option")]属性,它将呈现<option value="2">Second option</option>,从而提高可读性。下面是一个例子:

public enum MyEnum {

[Display(Name = "Second option")]

SecondOption = 2

}

为了结束这一小节,我们还有两个表单相关的标记帮助器要介绍,验证消息标记帮助器验证摘要标记帮助器。它们的存在是为了帮助验证客户端的表单输入。

验证摘要标记帮助器用于显示ModelState属性(ModelStateDictionary的错误消息列表。该属性在大多数 MVC 和 Razor 页面相关的基类中都是可访问的,例如PageModelPageBaseControllerBaseActionContext(在 MVC 视图中可从RazorPageBase.ViewContext访问)。以下代码创建验证摘要:

<div asp-validation-summary="ModelOnly" class="text-danger"></div>

asp-validation-summary属性的值可以是NoneModelOnlyAll

  • None表示不显示汇总。
  • ModelOnly表示只有与模型属性无关的错误才会显示在验证摘要中(如果你问我,这个名称与直觉相反)。
  • All表示所有错误,包括属性错误,将显示在验证摘要中。

如果您正在为属性使用验证消息标记帮助器,我建议将该值设置为ModelOnly,这将允许从页面或操作发送自定义验证消息,而无需在页面上复制模型的消息。

验证消息标记帮助程序允许我们显示单个属性的错误消息。通常,它们会显示在它们所表示的元素附近,但不必如此。以下是一个例子:

<span asp-validation-for="Employee.FirstName" class="text-danger"></span>

asp-validation-for属性充当asp-for属性,但告诉元素它是用于验证目的,而不是创建表单输入。如果属性(本例中为Employee.FirstName)无效,则显示错误消息;否则,情况并非如此。

class="text-danger"是一个引导类,它将文本设置为红色。

如果我们再看一次上一节的例子,我们将看到以下 Razor 代码(第一个块)被呈现为以下 HTML 代码(第二个块),Razor 代码高亮显示被转换为 HTML 代码高亮显示:

<div class="form-group">
    <label asp-for="Employee.FirstName" class="control-label"></label>
    <input asp-for="Employee.FirstName" class="form-control" />
    <span asp-validation-for="Employee.FirstName" class="text-danger"></span>
</div>
<div class="form-group">
    <label class="control-label"for="Employee_FirstName">FirstName</label>
    <input class="form-control" type="text" data-val="true" data-val-length="The field FirstName must be a string with a maximum length of 50." data-val-length-max="50" data-val-required="The FirstName field is required." id="Employee_FirstName" maxlength="50" name="Employee.FirstName" value="">
    <span class="text-danger field-validation-valid" data-valmsg-for="Employee.FirstName" data-valmsg-replace="true"></span>
</div>

验证属性(data-val-lengthdata-val-length-maxdata-val-requiredmaxlengthtype属性来自Employee.FirstName属性,即定义如下:

[Required]
[StringLength(50)]
public string FirstName { get; set; }

总之,ASP.NET Core 提供的表单标记帮助程序非常方便、快速地制作可读的表单,并且具有丰富的功能。

部分标记辅助对象

在前面关于部分视图的小节中,我们已经使用了部分标记帮助器,但这里还有几个用例。最琐碎的一个意味着只设置name属性,就像我们之前所做的那样:

<partial name="_Form" />

我们也可以指定路径而不是名称,如下所示:

<partial name="Employees/_PieceOfUI" />

这将从以下三个文件之一加载_PieceOfUI.cshtml局部视图:/Pages/Employees/_PieceOfUI.cshtml/Pages/Shared/Employees/_PieceOfUI.cshtml/Views/Shared/Employees/_PieceOfUI.cshtml

我们还可以使用model属性将自定义模型传递给局部视图,如下所示:

Pages/Employees/PieceofIViewModel.cs

public record PieceOfUIViewModel(bool GenerateRandomNumber);

PieceOfUIViewModel记录类是我们传递给PieceOfUI部分视图的视图模型,如下所示。记录是一个新的 C#9 特性,我们将在本章下一节中探讨。现在,将PieceOfUIViewModel视为一个具有名为GenerateRandomNumber的只读属性的类。

Pages/Employees/_.cshtml

@model PieceOfUIViewModel
Piece of UI
@if (Model.GenerateRandomNumber) {
    <text>| </text>
    @(new Random().Next())
}

前面的 Razor 代码是我们在下一个块中渲染的局部视图:

页面/Shared/_Layout.cshtml

@using PageController.Pages.Employees
…
<partial name="Employees/_PieceOfUI" model="new PieceOfUIViewModel(true)" />
…

在该示例中,我们将一个PieceOfUIViewModel实例传递给局部视图,局部视图根据GenerateRandomNumber属性的值依次呈现一个随机数(truefalse

for属性允许类似的行为,但通过模型本身。如果我们返回到共享表单,但是创建了一个新的部分视图,而不需要实现任何接口,那么我们可能会得到以下代码:

_FormFor.cshtml

@using PageController.Data.Models
@model Employee
<div class="form-group">
    <label asp-for="FirstName" class="control-label"></label>
    <input asp-for="FirstName" class="form-control" />
    <span asp-validation-for="FirstName" class="text-danger"></span>
</div>
<div class="form-group">
    <label asp-for="LastName" class="control-label"></label>
    <input asp-for="LastName" class="form-control" />
    <span asp-validation-for="LastName" class="text-danger"></span>
</div>

接下来,两个视图使用的代码:

Create.cshtml 和 Edit.cshtml

…
<partial name="_FormFor" for="Employee" />
…

即使部分视图不知道原始Model上的Employee属性,它仍然呈现相同的形式,因为for属性为我们保留了该上下文。

最后一个属性是view-data,允许我们将ViewDataDictionary实例传递给局部视图。我建议坚持使用全类型对象,而不是使用字典和神奇的字符串,但是如果有一天你需要它来处理一些模糊的情况,那么,你知道该属性是存在的。

组件标记辅助对象

组件标签助手用于将剃须刀组件呈现到 MVC 或剃须刀页面应用中。我们将在第 18 章中探讨剃须刀组件,简要介绍 Blazor,并简要探讨这个标签助手。

创建自定义标记辅助对象

现在我们已经快速浏览了内置的标签助手,我们也可以很容易地创建自己的标签助手。我们有两个选择;我们可以扩展现有标记或创建新标记。

在本例中,我们正在创建<pluralize>标记。其目的是替换如下代码:

<p class="card-text">
    @ Model.Count
    @(Model.Count > 1 ? "Employees" : "Employee")
</p>

代码如下:

<p class="card-text">
    <pluralize count="Model.Count" singular="{0} Employee" plural="{0} Employees" />
</p>

与第一个块相比,该代码的上下文切换更少,因为整个块现在看起来像 HTML。

副作用

与填充三级操作符的 UI 相比,使用<pluralize>标记助手构建的 UI 也更容易本地化。作为快速更改,我们可以在使用string.Format()格式化SingularPlural属性之前,在PluralizeTagHelper类中插入IStringLocalizer<T>来本地化SingularPlural属性的内容。

别误会我的意思:我不是要你停止在你的观点中写 C;我只是指出了与使用普通 C#相比,这种方法的另一个可能优势。

对于该组件,我们需要创建一个PluralizeTagHelper类,并将其保存到TagHelpers目录中。标记助手必须实现ITagHelper接口,但也可以从TagHelper类继承。我们选择的是TagHelper类,它公开了一个我们可以使用的同步Process方法。

笔记

TagHelper类只不过是添加了Process方法来覆盖和ITagHelper接口的默认空实现。

我们正在编程的PluralizeTagHelper类如下所示:

namespace PageController.TagHelpers
{
    [HtmlTargetElement("pluralize", TagStructure = TagStructure.WithoutEndTag)]

这个属性告诉 Razor 我们正在扩展<pluralize>标记,我们可以省略 end 标记,将其写成<pluralize />而不是<pluralize></pluralize>

    public class PluralizeTagHelper : TagHelper
    {
        public int Count { get; set; }
        public string Singular { get; set; }
        public string Plural { get; set; }

属性的名称直接转换为烤肉串案例格式的属性。因此,Singular转换为singular,而名为ComplexAttributeName的属性将转换为complex-attribute-name

        public override void Process(TagHelperContext context, TagHelperOutput output)
        {
            var text = Count > 1 ? Plural : Singular;
            text = string.Format(text, Count);

前面的代码是选择是显示文本的单数还是复数版本的逻辑。

            output.TagName = null;

通过将TagName属性设置为null,我们确保 Razor 不会呈现<pluralize>标记中的内容;我们只想生成文本。

            output.Content.SetContent(text);
        }
    }
}

最后,我们使用TagHelperContent类的SetContent方法设置想要输出的值。TagHelperContent类公开了多个其他方法来附加和设置标记帮助器的内容。

像任何其他标记助手一样,我们需要注册它。我们将在项目:可重用员工计数中注册项目的所有标签助手,只需几页。

加载显示 Pluralize 标记帮助器的页面时,我们将得到以下输出:

# When count = 0
0 Employee
# When count = 1
1 Employee
# When count = 2
2 Employees

这次就这样了。当然,我们可以创建更复杂的标记助手,但我将把它留给您和您的项目。

提示

您还可以从NuGet.org下载现有的标签帮助程序,并将您的标签帮助程序作为 NuGet 软件包发布在NuGet.org(或您选择的第三方服务)上。

下面让我们看一下 To.T0.标签辅助组件 T1。

创建 RSS 提要标记 HelperComponent

上下文:我们希望在不改变_Layout.cshtml文件的情况下,在每个页面的<head>中动态添加一个<link>标签。预期的输出如下所示:

<link href="/feed.xml" type="application/atom+xml" rel="alternate" title="Chapter 17 Code Samples App">

我们可以通过实现一个ITagHelperComponent接口来实现,也可以从TagHelperComponent继承。我们将做后者。

我们先来看看RssFeedTagHelperComponent类及其选项:

namespace PageController.TagHelpers
{
    public class RssFeedTagHelperComponentOptions
    {
        public string Href { get; set; } = "/feed.xml";
        public string Type { get; set; } = "application/atom+xml";
        public string Rel { get; set; } = "alternate";
        public string Title { get; set; };
    }

为了方便起见,RssFeedTagHelperComponentOptions类包含一些属性,这些属性的默认值是关于要写入<link>标记的内容。

接下来,RssFeedTagHelperComponent看起来像这样:

    public class RssFeedTagHelperComponent : TagHelperComponent
    {
        private readonly RssFeedTagHelperComponentOptions 	_options;
        public RssFeedTagHelperComponent(RssFeedTagHelperComponentOptions options)
        {
            _options = options ?? throw new ArgumentNullException(nameof(options));
        }
        public override void Process(TagHelperContext context, TagHelperOutput output)

Process方法是魔法发生的地方。如果要运行异步代码,也可以在ITagHelperComponent.ProcessAsync方法中使用。

        {
            if (context.TagName == "head")

标记辅助组件可以扩展两个部分:<head><body>。在这里,我们想在<head>中添加内容,所以我们正在寻找它。

            {
                output.PostContent.AppendHtml(
                    $@"<link href=""{_options.Href}"" type=""{_options.Type}"" rel=""{_options.Rel}"" title=""{_options.Title}"">"
                );
            }
        }
    }
}

最后,我们使用 options 对象将<link>标记本身附加到<head>

该代码本身不起任何作用;为了让它运行,我们需要告诉 ASP.NET。要做到这一点,我们有多种选择,但作为依赖注入的忠实粉丝,这就是我在这里选择的方式。

Startup类中,我们必须注册RssFeedTagHelperComponentOptionsRssFeedTagHelperComponent类。让我们从选项开始,这是一个设计选择,和ITagHelperComponent本身无关:

services.Configure<RssFeedTagHelperComponentOptions>(Configuration.GetSection("RssFeed"));
services.AddSingleton(sp => sp.GetRequiredService<IOptionsMonitor <RssFeedTagHelperComponentOptions>>().CurrentValue);

在这里,我决定利用选项模式,它允许覆盖任何配置源(如appsettings.json文件)中的默认值。然后我需要一个原始的RssFeedTagHelperComponentOptions,所以我按原样注册了它(有关此解决方案的更多信息,请参见第 8 章选项和日志模式)。

现在我们的期权已经注册,我们可以将RssFeedTagHelperComponent注册为ITagHelperComponent。由于组件是无状态的,我们可以将其注册为单例,如下所示:

services.AddSingleton<ITagHelperComponent, RssFeedTagHelperComponent>();

就这样。当加载任何页面时,<link>标记将添加到<head>中,并带有我们定义的选项!这就是 ASP.NET Core 可扩展性的魔力!

当我们思考它时,选择是无止境的;我们可以让组件自行注册 CSS 文件,甚至缩小<head><body>或两者。以下是小型过滤器的示例:

    public class MinifierTagHelperComponent : TagHelperComponent
    {
        public override int Order => int.MaxValue;
        public async override Task ProcessAsync(TagHelperContext context, TagHelperOutput output)
        {
            var childContent = await output.GetChildContentAsync();
            var content = childContent.GetContent();
            var result = Minify(content);
            output.Content.SetHtmlContent(result);
        }
        private static string Minify(string input) { ... }
    }
}

这很可能不是进行缩小的最佳方式(我没有对其进行基准测试),但我之所以构建它,是因为它在我脑海中闪过,并作为激发你想象力的第二个例子。所有代码都以 Git(格式提供 https://net5.link/EcSc

更多信息

ITagHelper接口继承自ITagHelperComponent,因此您可以在技术上创建一个标记帮助器,通过将两个方法组合到一个类中,向页面的<head><body>添加资源。

结论

标记助手是创建新的类似 HTML 的标记或扩展现有标记的好方法,可以减少在 Razor 代码中在 C#和 HTML 之间切换上下文的摩擦。ASP.NET Core 中包含了现有的标记帮助程序,您可能在不知情的情况下使用了一些标记帮助程序。

现在让我们看看创建标记助手如何帮助我们遵循坚实的原则:

  • S:标签助手可以帮助我们将标签相关逻辑封装到可重用的代码片段中。
  • O:不适用。
  • L:不适用。
  • I:不适用。
  • D:不适用。

查看组件

现在来看一个新概念:视图组件。视图组件是局部视图和控制器动作的混合。它由两部分组成:

  • 继承自ViewComponent或用[ViewComponent]属性修饰的类。此类包含逻辑。
  • 一个或多个cshtml视图。这些视图知道如何渲染组件。

有多种方法可以组织构成视图组件的文件。由于我更喜欢与功能相关的文件靠得很近,所以我喜欢看到所有类都与视图本身位于同一目录中(我们称之为垂直切片),如下所示:

Figure 17.3: A way to organize View components keeping all files together

图 17.3:组织视图组件并将所有文件保存在一起的方法

项目:可重用员工计数

上下文:我们想在同一个 Razor Pages 项目中创建一个视图组件。该组件应显示系统数据库中可用的员工数量。组件应始终可见。

小组件是一个引导卡,如下所示:

Figure 17.4: The result, rendered in a browser, of the employee count view component

图 17.4:employee count 视图组件在浏览器中呈现的结果

我决定从ViewComponent继承视图组件,这样我就可以利用助手方法,比如View()。对于EmployeeCountViewModel,我决定利用记录类(参见**记录类(C#9】部分)。视图模型公开了一个Count属性:

*页面/组件/EmployeeCount/EmployeeCountViewModel.cs

public record EmployeeCountViewModel(int Count);

EmployeeCountViewModel类实际上与具有public int Count { get; }属性的类相同:

页面/组件/EmployeeCount/Default.cshtml

@model PageController.Pages.Shared.Components.EmployeeCount.EmployeeCountViewModel
<div class="card">
    <div class="card-body">
        <h5 class="card-title">Employee Count</h5>
        <p class="card-text">
            @ Model.Count
            @(Model.Count > 1 ? "Employees" : "Employee")
        </p>
        <a asp-page="Employees/Index" class="card-link">Employees list</a>
    </div>
</div>

正如我们在第 4 章视图模型设计模式部分中所看到的,使用 Razor的 MVC 模式,我们注入了一个为该视图特制的模型,这是我们视图组件的默认视图,然后我们使用它渲染组件。现在,转到视图组件:

Pages/Components/EmployeeCount/EmployeeCountViewComponent.cs

public class EmployeeCountViewComponent : ViewComponent
{
    private readonly EmployeeDbContext _context;
    public EmployeeCountViewComponent(EmployeeDbContext context)
    {
        _context = context ?? throw new ArgumentNullException(nameof(context));
    }

在这里,我们注入EmployeeDbContext,这样我们就可以按照InvokeAsync的方法将员工数记下来:

    public async Task<IViewComponentResult> InvokeAsync()
    {
        var count = await _context.Employees.CountAsync();
        return View(new EmployeeCountViewModel(count));
    }
}

视图组件的逻辑必须放在返回Task<IViewComponentResult>InvokeAsync方法或返回IViewComponentResultInvoke方法中。在我们的例子中,我们访问一个数据库,所以我们最好在等待数据库时不要阻塞资源。然后,与控制器操作类似,我们使用ViewComponent基类的View<TModel>(TModel model)方法返回包含EmployeeCountViewModel实例的ViewViewComponentResult

要渲染视图组件,我们可以使用Component.InvokeAsync()扩展方法,如下所示:

@await Component.InvokeAsync("EmployeeCount")

视图组件的名称必须排除ViewComponent后缀。

对于更易于重构的方法,我们还可以传递类型而不是名称:

@await Component.InvokeAsync(typeof(PageController.Pages.Shared.Components.EmployeeCount.EmployeeCountViewComponent))

我们还可以使用标记帮助器来调用视图组件。为此,我们可以通过在_ViewImports.cshtml文件中添加以下行,将所有视图组件注册为标记帮助程序:

@addTagHelper *, PageController

PageController是扫描视图组件的组件名称(项目名称)。

然后我们可以使用<vc:[view-component-name]></vc:[view-component-name]>标记帮助器,如下所示:

<vc:employee-count></vc:employee-count>

vc是可以覆盖的默认前缀。

我们可以通过视图组件实现很多事情,包括将参数传递给InvokeAsync方法,因此如果您需要一些参数,这是可能的。此外,借助视图组件和依赖注入功能,我们可以创建功能强大的 UI 组件,这些组件可以重用并封装复杂的逻辑,从而实现更易于维护的应用。我们也可以独立注册组件,无需一次注册所有组件。

结论

视图组件是部分视图和控制器操作的混合体,可能具有类似于标记辅助程序的语法。它们支持依赖项注入以实现可扩展性,但在其他方面受到限制。当使用标记帮助器语法时,它们不支持可选参数,仅当使用Component.InvokeAsync()方法时才支持。我们可以保存视图的默认位置是有限的,但如果需要可以扩展。

简而言之,如果您想要一个类似控制器的 UI,它具有逻辑或需要访问外部资源,那么视图组件可能是您的正确选择。另一方面,如果您想创建可组合的 UI,Razor 组件可能更适合(我们将在第 18 章中的了解 Razor 组件一节中介绍这些组件,简要介绍 Blazor

现在让我们看看创建视图组件如何帮助我们遵循坚实的原则:

  • S:视图组件帮助我们将 UI 逻辑片段提取到独立的组件中。
  • O:不适用。
  • L:不适用。
  • I:不适用。
  • D:不适用。

接下来,我们将探索 C#9 的一些新特性。

C#9 特征

在本节中,我们将访问的以下新功能:

  • 顶级声明
  • 目标类型的new表达式
  • 仅初始化属性
  • 记录类

我们将使用顶层语句简化一些代码示例,从而生成一个具有较少样板代码的代码文件。然后我们将深入研究new表达式,这些表达式允许以较少的键入创建新实例。仅限 init 的属性是本章中使用的记录类的主干,是第 18 章对 Blazor的简要介绍中介绍的 MVU 示例的基础。

高层声明(C#9)

从 C#9 开始,可以在声明名称空间和其他成员之前编写语句。这些语句被编译为发出的Program.Main方法。

对于顶级语句,最小的.NET“Hello World”程序现在如下所示:

using System;
Console.WriteLine("Hello world!");

不幸的是,我们还需要运行一个项目,因此我们必须创建一个包含以下内容的.csproj文件:

<Project Sdk="Microsoft.NET.Sdk">
    <PropertyGroup>
        <TargetFramework>net5.0</TargetFramework>
        <OutputType>Exe</OutputType>
    </PropertyGroup>
</Project>

从那里,我们可以使用.NET CLI 来dotnet run应用。

我们还可以像在任何控制台应用中一样声明类等其他成员并使用它们。类必须在顶级代码之后声明。

顶级语句是 C 语言入门的一个很好的特性,可以通过删除样板代码来编写代码示例。起初我有点反对这个想法,但现在我看到了很多可能性。

目标类型新表达式(C#9)

目标类型的new表达式是初始化类型的一种新方式。回到时代,C#3 引入了var关键字,这对于处理泛型类型、LINQ 返回值等变得非常方便(我记得很高兴地接受了这个新构造)。

这个新的 C#特性与var关键字相反,它允许我们调用已知类型的构造函数,如下所示:

using System;
using System.Collections.Generic;
List<string> list1 = new();
List<string> list2 = new(10);
List<string> list3 = new(capacity: 10);
var obj = new MyClass(new());
AnotherClass anotherObj = new() { Name = "My Name" };
public class MyClass {
    public MyClass(AnotherClass property) 
    => Property = property;
    public AnotherClass Property { get; }
}
public class AnotherClass {
    public string Name{ get; init; }
}

第一个突出显示显示了在已知类型时使用new()关键字并省略类型名称创建新对象的能力。第二个列表也是以同样的方式创建的,但是我们将参数10传递给了它的构造函数。第三个列表使用相同的方法,但明确指定了参数名,就像我们可以使用任何标准构造函数一样。

变量obj是显式创建的,但new()用于创建AnotherClass的实例,因为参数类型已知,所以会进行推断。

最后一个示例演示了类初始值设定项的使用。正如您可能已经注意到的,类有一个只包含 init 的属性,这是我们的下一个主题。

我可以看到目标类型的new表达式简化了许多代码库。我开始使用它们,它们是 C#9.0 的伟大补充之一。

仅初始属性(C#9)

仅初始化属性是只读属性,可以使用类初始值设定项初始化。在此之前,只读属性只能在构造函数中初始化或使用属性初始值设定项(如public int SomeProp { get; } = 2;)。

例如,让我们来使用一个保持计数器状态的类。使用只读属性时,我们将拥有以下类:

public class Counter
{
    public int Count { get; }
}

如果没有构造函数,Count属性不可能初始化,所以我们不能像这样初始化实例:

var counter = new Counter { Count = 2 };

这就是只支持 init 属性的用例。我们可以通过使用init关键字重写Counter类来利用它,如下所示:

public class Counter
{
    public int Count { get; init; }
}

仅初始化属性使开发人员能够创建使用类初始值设定项可设置的不可变属性。它们也是记录类的组成部分。

记录类(C#9)

记录类仅使用 init 属性,并允许引用类型(类)不可变。改变record的唯一方法是创建一个新的。让我们将Counter类转换为record

public record Counter
{
    public int Count { get; init; }
}

是的,这就像用record关键字替换class关键字一样简单。

但这还不是全部:

  • 我们可以简化记录创建。
  • 我们还可以使用with关键字简化记录的“变异”(创建变异副本而不触及初始副本)。
  • 记录支持解构,就像元组类型一样。
  • .NET 自动执行EqualsGetHashCode方法。这两种方法比较属性的值,而不是对象的引用。这意味着具有相同值的两个不同实例将相等。

总之,这意味着我们最终得到了一个不可变的引用类型(class),其行为类似于一个值类型(struct),没有拷贝分配成本。

简化记录创建

如果我们不想在创建实例时使用类初始值设定项,我们可以将记录的代码简化为:

public record Counter(int Count);

这个语法让我想起了类型脚本,您可以在构造函数中定义属性和字段,它们可以自动实现,而无需手动执行。然后,我们可以像其他任何类一样创建一个新实例:

var counter = new Counter(2);
Console.WriteLine($"Count: {counter.Count}");

运行该代码将在控制台中输出Count: 2。我们还可以向 record 类添加方法:

public record Counter(int Count)
{
    public bool CanCount() => true;
}

你可以用一个记录做任何事情,就像你用一个类做的一样,等等。

带关键字的

with关键字允许我们创建一个记录副本,只设置我们想要更改的属性,保持其他值不变。让我们看看下面的代码(利用 C 9 级顶级语句):

using System;
var initialDate = DateTime.UtcNow.AddMinutes(-1);
var initialForecast = new Forecast(initialDate, 20, "Sunny");
var currentForecast = initialForecast with { Date = DateTime.UtcNow };
Console.WriteLine(initialForecast);
Console.WriteLine(currentForecast);
public record Forecast(DateTime Date, int TemperatureC, string Summary)
{
    public int TemperatureF => 32 + (int)(TemperatureC / 
    0.5556);
}

当我们执行该代码时,结果与此类似:

Forecast { Date = 9/22/2020 12:04:20 AM, TemperatureC = 20, Summary = Sunny, TemperatureF = 67 }
Forecast { Date = 9/22/2020 12:05:20 AM, TemperatureC = 20, Summary = Sunny, TemperatureF = 67 }

with关键字的幂允许我们创建initialForecast记录的副本,并且只更改Date属性的值。

with关键字是该语言中引人注目的补充。

解构

我们可以Tuple一样自动解构record类:

using System;
var current = new Forecast(DateTime.UtcNow, 20, "Sunny");
var (date, temperatureC, summary) = current;
Console.WriteLine($"date: {date}");
Console.WriteLine($"temperatureC: {temperatureC}");
Console.WriteLine($"summary: {summary}");
public record Forecast(DateTime Date, int TemperatureC, string Summary)
{
    public int TemperatureF => 32 + (int)(TemperatureC / 0.5556);
}

默认情况下,所有位置成员(在构造函数中定义)都是可解构的。在该示例中,我们无法使用解构来访问TemperatureF属性,因为它不是位置成员。

我们可以通过实现一个或多个Deconstruct方法来创建自定义解构器,该方法公开我们想要解构的属性的out参数,如下所示:

using System;
var current = new Forecast(DateTime.UtcNow, 20, "Sunny");
var (date, temperatureC, summary, temperatureF) = current;
Console.WriteLine($"date: {date}");
Console.WriteLine($"temperatureC: {temperatureC}");
Console.WriteLine($"summary: {summary}");
Console.WriteLine($"temperatureF: {temperatureF}");
public record Forecast(DateTime Date, int TemperatureC, string Summary)
{
    public int TemperatureF => 32 + (int)(TemperatureC / 0.5556);
    public void Deconstruct(out DateTime date, out int temperatureC, out string summary, out int temperatureF) 
 => (date, temperatureC, summary, temperatureF) = (Date, TemperatureC, Summary, TemperatureF);
}

通过更新的样本,我们还可以在解构记录时访问TemperatureF属性的值。

平等比较

如前所述,两条记录之间的默认比较是通过其值而不是其内存地址进行的,因此具有相同值的两个不同实例是相等的。以下代码证明了这一点:

using System;
var employee1 = new Employee("Johnny", "Mnemonic");
var employee2 = new Employee("Clark", "Kent");
var employee3 = new Employee("Johnny", "Mnemonic");
Console.WriteLine($"Does '{employee1}' equals '{employee2}'? {employee1 == employee2}");
Console.WriteLine($"Does '{employee1}' equals '{employee3}'? {employee1 == employee3}");
Console.WriteLine($"Does '{employee2}' equals '{employee3}'? {employee2 == employee3}");
public record Employee(string FirstName, string LastName);

运行该代码时,输出如下所示:

'Employee { FirstName = Johnny, LastName = Mnemonic }' equals 'Employee { FirstName = Clark, LastName = Kent }'? False
'Employee { FirstName = Johnny, LastName = Mnemonic }' equals 'Employee { FirstName = Johnny, LastName = Mnemonic }'? True
'Employee { FirstName = Clark, LastName = Kent }' equals 'Employee { FirstName = Johnny, LastName = Mnemonic }'? False 

在该示例中,即使employee1employee3是两个不同的对象,当我们使用employee1 == employee3对它们进行比较时,结果是true,证明比较的是值,而不是实例。

您可能没有注意到,但是记录类的ToString()方法返回了对其数据的开发人员友好的表示。当使用字符串插值时,对象的ToString()方法被隐式调用,就像前面的代码一样,因此是完整的输出。

另一方面,如果想知道它们是否是同一个实例,可以使用如下Object.ReferenceEquals()方法:

Console.WriteLine($"Is 'employee1' the same as 'employee3'? {Object.ReferenceEquals(employee1, employee3)}");

这将输出以下内容:

Is 'employee1' the same as 'employee3'? False

结论

记录类是一个伟大的新添加,它允许在几个按键中创建不可变的类型。此外,它们支持解构并实现平等比较,即比较财产的价值,而不是实例是否相同,从而在许多情况下简化了我们的生活。

当人们更喜欢类初始值设定项而不是构造函数时,仅 Init 属性对于常规类也是有益的。

意见

我认为记录类对于遵循 Redux(JavaScript)和 Model-View-Update(MVU)等模式的单向绑定 ui 来说可能很方便。我在 C#9.0/.NET5 预览版中遵循了这些原则,得到了非常优雅的 MVU 结果,我将其封装在 GitHub 上的一个开源库中。在下一章中,我们将使用该库来实现 MVU 模式。

记录也很适合视图模型和 DTO。这些都是短暂的对象,通常不会发生变异。

接下来,我们将探索显示和编辑器模板,以将 UI 的某些部分附加到类型。

显示和编辑模板

在本节中,我们将了解如何使用显示和编辑器模板将 UI 划分为面向模型的局部视图。从.NET Framework 上的 MVC 开始,这些工具就可以使用,对于 ASP.NET Core 来说并不陌生。不幸的是,它们常常被遗忘或忽视,而代价是出现了全新的事物。

显示模板是覆盖给定类型的默认渲染模板的 Razor 视图。编辑器模板相同,但会覆盖给定类型的编辑器视图。

每种类型都可以有一个显示模板和一个编辑器模板。它们还按层次结构存储,以便每种类型都可以具有全局共享的模板以及每个区域、控制器、分区或页面的特定模板。在一个复杂的应用中,这可以非常方便地覆盖应用特定部分的单个模板。

必须在DisplayTemplates目录中创建显示模板,必须在EditorTemplates目录中创建编辑模板。这些目录可以放置在不同的级别。此外,目录结构取决于您使用的是 MVC 还是 Razor 页面。

ASP.NET 按优先级从更具体到更一般的顺序加载它们。这允许我们创建一个共享模板,在所有页面或所有控制器之间共享,然后为特定控制器或特定页面覆盖它。

对于 MVC,加载顺序如下:

  1. Views/[some controller]/DisplayTemplates
  2. Views/Shared/DisplayTemplates

对于 Razor 页面,加载顺序如下:

  1. Pages/[some directory]/DisplayTemplates

  2. Pages/Shared/DisplayTemplates

    笔记

    同样的逻辑也适用于各个领域;MVC 像搜索任何其他视图一样搜索显示和编辑器模板。

显示模板和编辑器模板都是带有指向其所用类型的@ model指令的.cshtml文件。例如,Views/Shared/DisplayTemplates/SomeModel.cshtml应该在顶部有一个@ model SomeModel指令。Views/Shared/EditorTemplates/SomeModel.cshtml也是如此。

让我们从显示模板开始。

显示模板

让我们使用 CRUDUI 来管理我们为本节再次搭建的员工。参见TransformTemplateView项目。

上下文:我们想将员工在DetailsDelete页面中的显示方式进行封装。我们决定使用显示模板,而不是创建局部视图。

我们不希望该模板在其他地方使用,因此我们专门在Pages/Employees目录中创建它。让我们从该显示模板开始:

页面/Employee/DisplayTemplates/Employee.cshtml

@model Data.Models.Employee
<dl class="row">
    <dt class="col-sm-2">
        @ Html.DisplayNameFor(model => model.FirstName)
    </dt>
    <dd class="col-sm-10">
        @ Html.DisplayFor(model => model.FirstName)
    </dd>
    <dt class="col-sm-2">
        @ Html.DisplayNameFor(model => model.LastName)
    </dt>
    <dd class="col-sm-10">
        @ Html.DisplayFor(model => model.LastName)
    </dd>
</dl>

那个文件是我们的脚手架文件的副本。要呈现显示模板,我们必须调用@ Html.DisplayFor()扩展方法之一。在 details 和 delete 视图中,我们可以用@ Html.DisplayFor(x => x.Employee)替换旧代码。从那里,ASP.NET Core 的渲染引擎将找到模板并进行渲染(非常简单)。

接下来,我们看一下使用该显示模板的两个页面:

页面/Employees/Details.cshtml

@page
@model TransformTemplateView.Pages.Employees.DetailsModel
@{
    ViewData["Title"] = "Details";
}
<h1>Details</h1>
<div>
    <h4>Employee</h4>
    <hr />
 @ Html.DisplayFor(x => x.Employee)
</div>
<div>
    <a asp-page="./Edit" asp-route-id="@ Model.Employee.Id">Edit</a> |
    <a asp-page="./Index">Back to List</a>
</div>

页面/Employees/Delete.cshtml

@page
@model TransformTemplateView.Pages.Employees.DeleteModel
@{
    ViewData["Title"] = "Delete";
}
<h1>Delete</h1>
<h3>Are you sure you want to delete this?</h3>
<div>
    <h4>Employee</h4>
    <hr />
 @ Html.DisplayFor(x => x.Employee)
    <form method="post">
        <input type="hidden" asp-for="Employee.Id" />
        <input type="submit" value="Delete" class="btn btn-danger" /> |
        <a asp-page="./Index">Back to List</a>
    </form>
</div>

就这样,我们将员工的显示集中到一个cshtml文件中,根据其类型自动定位和加载,而不是像string那样的局部视图。但事实并非如此——在概述编辑器模板之后,我们将看到显示模板比这更强大。

编辑器模板

e****编辑模板的工作方式与显示模板的工作方式相同,因此让我们重建与使用局部视图相同的内容,但使用编辑器模板。

提醒:我们希望封装Employee表单,并在CreateEdit视图中重用它。

再一次,我们不希望该模板在其他地方使用,因此我们在同一级别的Pages/Employees下创建它。让我们看一下代码:

Pages/Employees/EditorTemplates/Employee.cshtml

@model Data.Models.Employee
<div class="form-group">
    <label asp-for="FirstName" class="control-label"></label>
    <input asp-for="FirstName" class="form-control" />
    <span asp-validation-for="FirstName" class="text-danger"></span>
</div>
<div class="form-group">
    <label asp-for="LastName" class="control-label"></label>
    <input asp-for="LastName" class="form-control" />
    <span asp-validation-for="LastName" class="text-danger"></span>
</div>

这与我们在上一个示例中创建的局部视图相同。重要的是要记住,显示和编辑器模板是围绕一个类型设计的,在本例中为Employee类。

要让 ASP.NET Core 为模型创建编辑器,我们可以使用@ Html.EditorFor()扩展方法重载之一。在CreateEdit视图中,我们都将表单替换为对@ Html.EditorFor(m => m.Employee)的调用:

页面/Employees/Create.cshtml

@model TransformTemplateView.Pages.Employees.CreateModel
@{
    ViewData["Title"] = "Create";
}
<h1>Create</h1>
<h4>Employee</h4>
<hr />
<div class="row">
    <div class="col-md-4">
        <form method="post">
            <div asp-validation-summary="ModelOnly" class="text-danger"></div>
 @ Html.EditorFor(m => m.Employee)
            <div class="form-group">
                <input type="submit" value="Create" class="btn btn-primary" />
            </div>
        </form>
    </div>
</div>

页面/Employees/Edit.cshtml

@page
@model TransformTemplateView.Pages.Employees.EditModel
@{
    ViewData["Title"] = "Edit";
}
<h1>Edit</h1>
<h4>Employee</h4>
<hr />
<div class="row">
    <div class="col-md-4">
        <form method="post">
            <div asp-validation-summary="ModelOnly" class="text-danger"></div>
            <input type="hidden" asp-for="Employee.Id" />
 @ Html.EditorFor(m => m.Employee)
            <div class="form-group">
                <input type="submit" value="Save" class="btn btn-primary" />
            </div>
        </form>
    </div>
</div>

就像显示模板一样,这是我们唯一需要做的事情。运行项目时,创建和编辑页面都使用相同的表单,专门为Employee类设计。

在下一个示例中,我们将探讨显示模板的功能。请记住,使用编辑器模板可以实现同样的效果。

项目:复合书店重访

上下文:我们想回顾一下我们如何在第 3 章架构原则第 9 章结构模式中展示我们之前构建的复合书店 UI。目标是从类中获取显示逻辑,将它们与 HTML 输出分离。

有什么能比显示模板更好地封装这些小的 UI 块呢?

让我们首先检查要采取的步骤:

  1. 更新模型类。
  2. 创建视图并将呈现逻辑传输到那里(HTML)。

让我们从更新模型类开始:

型号/*.cs

namespace TransformTemplateView.Models
{
    public interface IComponent
    {
        void Add(IComponent bookComponent);
        void Remove(IComponent bookComponent);
        int Count();
    }

首先,我们从IComponent中删除了Display方法和Type属性。两者都用于显示IComponent实例。

    public class Book : IComponent
    {
        public Book(string title)
        {
            Title = title ?? throw new ArgumentNullException(nameof(title));
        }
        public string Title { get; set; }
        public int Count() => 1;
        public void Add(IComponent bookComponent) => throw new NotSupportedException();
        public void Remove(IComponent bookComponent) => throw new NotSupportedException();
    }

然后我们对Book类做了同样的(两个成员都是IComponent接口的一部分)。

    public abstract class BookComposite : IComponent
    {
        protected readonly List<IComponent> children;
        public BookComposite(string name)
        {
            Name = name ?? throw new ArgumentNullException(nameof(name));
            children = new List<IComponent>();
        }
        public string Name { get; }
        public virtual ReadOnlyCollection<IComponent> Components => new ReadOnlyCollection<IComponent>(children);
        public virtual string Type => GetType().Name;
        public virtual void Add(IComponent bookComponent) => children.Add(bookComponent);
        public virtual int Count() => children.Sum(child => child.Count());
        public virtual void Remove(IComponent bookComponent) => children.Remove(bookComponent);
        public virtual void AddRange(IComponent[] components) => children.AddRange(components);
    }

然后,我们从BookComposite中剥离了所有显示代码,并添加了一个名为Components的属性,该属性将其子项暴露给显示模板。

下面的CorporationSectionStore类只是组织类型,因为我们将书店逻辑保持在最低限度,以探索模式和功能,而不是假冒商店的商业模式:

    public class Corporation : BookComposite
    {
        public Corporation(string name) : base(name) { }
    }
    public class Section : BookComposite
    {
        public Section(string name) : base(name) { }
    }
    public class Store : BookComposite
    {
        public Store(string name) : base(name) { }
    }

Set课程有点不同。它是一种组织类型,但需要一些书籍(请参见此处构造函数的books参数):

    public class Set : BookComposite
    {
        public Set(string name, params IComponent[] books)
            : base(name)
        {
            AddRange(books);
        }
    }
}

该代码代表了我们的第一步,在概念上与原始代码非常相似,没有显示逻辑。

现在,为了创建新的、更新的显示代码,我们创建以下三个 Razor 文件:

  1. 剃须刀页面本身,当客户端请求时显示在Pages/BookStore/Index.chhtml文件中。
  2. 用于呈现书籍的视图模板,位于Pages/BookStore/DisplayTemplates/Book.chhtml文件中。
  3. 用于在Pages/BookStore/DisplayTemplates/BookComposite.chhtml文件中渲染所有其他BookComposite对象的视图模板

让我们看看文件是如何组织的,然后看看代码:

Figure 17.5: Solution Explorer's view of the revised BookStore display templates and Index page

图 17.5:SolutionExplorer 的修订版书店显示模板和索引页面视图

让我们从页面模型开始:

Pages/BookStore/Index.cshtml.cs

using Microsoft.AspNetCore.Mvc.RazorPages;
using System;
using System.Collections.ObjectModel;
using TransformTemplateView.Models;
using TransformTemplateView.Services;
namespace TransformTemplateView.Pages.BookStore
{
    public class IndexModel : PageModel
    {
        private readonly ICorporationFactory _corporationFactory;
        public IndexModel(ICorporationFactory corporationFactory)
        {
            _corporationFactory = corporationFactory ?? throw new ArgumentNullException(nameof(corporationFactory));
        }

首先,我们使用构造函数注入获得ICorporationFactory的访问权限。

        public ReadOnlyCollection<IComponent> Components { get; private set; }

然后我们公开视图呈现页面所需的IComponent集合。

        public void OnGet()
        {
            var corporation = _corporationFactory.Create();
            Components = new ReadOnlyCollection<IComponent>(new IComponent[] { corporation });
        }
    }
}

最后,当有人向该页面发送GET请求时,它通过调用_corporationFactory.Create()方法来构建ReadOnlyCollection<IComponent>实例。

接下来是页面的视图:

Pages/BookStore/Index.cshtml

@page
@model TransformTemplateView.Pages.BookStore.IndexModel
@{
    ViewData["Title"] = "My BookStore";
}
<section class="card">
    <h1 class="card-header">@ViewData["Title"]</h1>
    <ul class="list-group list-group-flush">
 @ Html.DisplayFor(x => x.Components)
    </ul>
</section>

这个标记创建了一个 bootstrap4.card来保存我们的书店数据。该视图的关键是DisplayFor()调用(突出显示)。

由于我们的PageModelComponents属性是实现继承自IEnumerableIEnumerable<T>ReadOnlyCollection<T>,ASP.NET Core 循环并呈现Components集合的所有元素。在我们的例子中,这只是一个Corporation对象,但它可能更多。

对于这些元素中的每一个,ASP.NET Core 都会尝试为该类型找到正确的显示模板。因为我们没有Corporation模板,所以它沿着继承链向上查找BookComposite模板并呈现元素。现在让我们看看那些模板,从BookComposite开始:

Pages/BookStore/DisplayTemplates/BookComposite.cshtml

@model BookComposite
<li class="list-group-item">
    <section class="card">
        <h5 class="card-header">
            @ Model.Name
            <span class="badge badge-secondary float-right">@Model.Count()</span>
        </h5>
        <ul class="list-group list-group-flush">
 @ Html.DisplayFor(x => x.Components)
        </ul>
        <div class="card-footer text-muted">
            <small class="text-muted text-
            right">@ Model.Type</small>
        </div>
    </section>
</li>

@model BookComposite指令指示框架知道如何呈现类型。

模板在.list-group-item中呈现引导 4.card。由于页面在<ul class="list-group list-group-flush">中呈现Components,这些<li>元素将构成一个漂亮的 UI。

该模板执行与页面相同的操作并调用@ Html.DisplayFor(x => x.Components),这允许呈现实现IComponent接口的任何类型。

集锦

这就是显示模板的威力所在;有了它们,我们可以轻松构建一个复杂的基于模型的递归 UI。

更详细地说,发生的情况如下:

  1. ReadOnlyCollection<T>实现了IEnumerable<T>,因此 ASP.NET 循环并呈现其所有内容。在我们的例子中,这是一个包含两个Store实例的集合。
  2. 对于每个元素,ASP.NET 都会尝试为该类型找到正确的显示模板。因为我们没有Store模板,所以它沿着继承链向上查找BookComposite模板并呈现元素。
  3. 然后,对于每个Store,它呈现其子对象;在我们的例子中,SectionSet的实例使用BookComposite模板(我们没有SetSection模板)。
  4. 从这些SectionSet对象中,Book对象使用Book模板进行渲染(我们将要查看),而其他非书本对象使用BookComposite模板进行渲染。

让我们从呈现Book实例的 Razor 代码开始:

Pages/BookStore/DisplayTemplates/Book.cshtml

@model Book
<li class="list-group-item">
    @ Model.Title
    <small class="text-muted">(Book)</small>
</li>

Book模板是树的一片叶子,显示Book的详细信息,仅此而已(由@model Book指令指示)。

如果我们将该代码与我们拥有的初始模型进行比较,它是非常相似的。BookComposite模板也非常类似于我们在BookComposite.Display()方法中构建的模板。

最显著的区别是编写演示代码所需的难度。使用StringBuilder呈现一个小元素是可行的,但呈现一个复杂的网页可能会变得单调乏味。显示模板允许我们通过 IntelliSense 和工具支持非常轻松地编写相同的代码。

重要提示

显示模板和编辑器模板是创建面向类型的 UI 设计(面向模型)的极好方法。

如果我们取BookStore的一个子集,背景中发生的情况如下:

Figure 17.6: A subset of the rendering flow done by ASP.NET, based on our display templates

图 17.6:ASP.NET 根据我们的显示模板完成的渲染流的子集

这就完成了我们的示例。通过这几行代码,我们能够呈现一个支持非线性复合数据结构的复杂 UI。我们可以通过呈现不同的类来扩展该 UI,例如为Corporation对象添加徽标或为BookSet对象添加封面图像。

结论

正如我们在本章中所探讨的,我们发现了许多呈现页面组件和部分的方法,但显示和编辑器模板是经常被忽略的方便方法。我们可以轻松呈现复杂的多态 UI。

现在让我们看看这个方法如何帮助我们遵循坚实的原则:

  • S:通过从模型中提取对象的渲染,我们将两个职责划分为两个不同的部分。

  • O:通过管理 UI 中独立的、与类型相关的部分,我们可以改变类型的呈现方式,而不会直接影响它或它的使用者。

  • L:不适用。

  • I:不适用。

  • D: N/A.

    笔记

    我们可以从变换视图模式中将显示和编辑器模板视为变压器。我们还可以将Razor视为模板视图模式的实现。Martin Fowler 在 2002 年出版的《企业应用体系结构模式》(PoEAA)一书中介绍了这些模式。更多信息,请参见进一步阅读部分。

总结

在本章中,我们探讨了Razor 页面,它允许我们通过页面而不是控制器来组织 web 应用。Razor Pages 使用与MVC相同的工具。这两种技术也可以结合使用,允许您使用 Razor 页面构建应用的部分,使用 MVC 构建其他部分。

然后我们解决了部分视图,它允许重用 UI 的部分,并将复杂的 UI 分解为更小的部分。当我们有复杂的逻辑时,我们可以从局部视图转移到视图组件,一个类似于控制器动作的视图。我们还处理了标记帮助程序以创建可重用的 UI 组件或扩展现有的 HTML 元素,或仅使用内置元素。

我们探索了多个新的C#9 特性,从顶级语句到目标类型的新表达式、仅初始化的属性以及新的记录类。然后,我们深入研究了记录类,它提供了许多作为不可变引用类型的可能性。

最后,我们探索了另一种将 UI 划分为更小部分的方法,但这次是围绕模型类本身进行的。显示和编辑模板使我们能够动态构建基于模型的 UI,用于显示和修改。

有了这些,我们几乎了解了 ASP.NETCore 在 web UI 方面提供的一切,但我们还没有完成;在下一章中,我们仍然需要探索 Blazor,以完成进入.NET5 的完整堆栈之旅。

问题

让我们来看看几个练习题:

  1. 剃须刀页面有什么用?
  2. 当使用Razor Pages时,我们是否可以访问 MVC 的模型绑定、模型验证和路由?
  3. 我们可以使用局部视图来查询数据库吗?
  4. 我们可以使用标签助手扩展现有标签吗?
  5. 我们可以使用视图组件查询数据库吗?
  6. 是否编译以下代码:List<string> list = new();
  7. 是否编译以下代码:public class MyDTO(int Id, string Name);
  8. 一个类可以有多少个显示模板?
  9. 我们如何链接(或关联)显示器或编辑器模板?

进一步阅读

十八、Blazor 简介

在本章中,我们将介绍 Blazor。Blazor 是街区中启用完整堆栈.NET 的新成员。Blazor 是一项伟大的技术。它仍然相对较新,但在实验阶段、首次正式发布和当前状态之间有了显著的改进。在大约两年的时间里,它从遥远的未来变成了现实。丹尼尔·罗斯很可能是那个时期最狂热的布道者。有一段时间,Blazor 是我唯一听说的东西(或者可能是互联网在监视我)。

有趣的事实

回到过去,我们可以将服务器端 JavaScript 与经典 ASP 结合使用,使经典 ASP 成为第一个完整堆栈技术(据我所知)。

Blazor 是两件事:

  • 它是一个客户端单页应用SPA框架,将.NET 编译到WebAssemblyWasm)。

  • It is a client-server link over SignalR that acts as a modern UpdatePanel with superpowers.

    一点历史

    如果你不知道什么是UpdatePanel,你就不会错过太多。它是与.NET Framework 3.5 一起发布的 ASP.NET Web 表单控件,帮助“自动”运行 AJAX 调用

Blazor 还附带了和剃须刀组件(Blazor 组件为什么没有?我不知道)。它有一些围绕着它的实验项目,还有一个不断增长的图书馆生态系统,可以通过 NuGet 访问。

现在我已经介绍了 Blazor 的高级概述,本章将介绍以下主题:

  • Blazor 服务器概述
  • Blazor WebAssembly 概述
  • 熟悉剃须刀组件
  • 模型视图更新模式
  • 一个混合的 Blazor 特征

Blazor 服务器概述

Blazor 服务器是一个 ASP.NET Core web 应用,最初向浏览器发送页面。然后,浏览器通过信号器连接更新部分 UI。该应用成为一个自动化的 AJAX 客户端服务器应用。它是经典 web 应用和 SPA 模型的混合体,客户端从服务器加载要更新的 UI 片段。因此,客户端的处理更少,服务器的处理更多。由于您必须等待服务器响应(步骤 24),因此也可能存在短暂的延迟(延迟);例如:

  1. 单击浏览器中的按钮。
  2. 该操作通过信号器发送到服务器。
  3. 服务器处理该操作。
  4. 服务器将 HTML 差异返回到浏览器。
  5. 浏览器使用该差异更新 UI。

为了进行区分(步骤 4,服务器保存应用状态图。它使用组件构造该图,组件转换为文档对象模型(DOM)节点。

Blazor 服务器生成有状态的应用,这些应用必须跟踪所有访问者的当前状态。它可能很难扩展,或者在云托管方面会花费很多钱。我不想你现在就放弃这个选择;该模型可能适合应用的需要。此外,根据许多因素,支付更多的主机费用可以节省开发成本。

免责声明

我还没有使用 Blazor 服务器部署任何应用。这让我想到了一个改进的网页表单重拍太多,我害怕进入。这可能只是我的问题,但有状态服务器中的“神奇”信号器连接、延迟和处理的所有事情对我来说都不太好。我可能错了。我建议你自己做实验、研究和判断。我甚至可能在将来改变主意;这项技术还很年轻。

要创建 Blazor 服务器项目,可以运行dotnet new blazorserver命令。Blazor 服务器就是这样。

接下来,我们将研究 Blazor WebAssembly,这是一种更具前景的方式(我的观点也是如此)。

Blazor WebAssembly 概述

在进入Blazor WebAssembly之前,让我们先看看WebAssembly本身。WebAssembly(Wasm)允许浏览器运行非 JavaScript 代码(如 C#和 C++)。Wasm 是一个开放标准,所以它不是微软唯一的产品。Wasm 在客户端机器上的沙盒环境中运行,接近本机速度(这是目标),强制执行浏览器安全策略。Wasm 二进制文件可以与 JavaScript 交互。

正如您在最后一段中“预见到”的那样,Blazor WebAssembly 就是要在浏览器中运行.NET!最酷的是它遵循标准。这不像在 Internet Explorer 中运行 VBScript(哦,我不怀念那段时间)。我认为,从长远来看,微软拥抱开放标准、开放源代码和世界其他地区的新愿景将对我们开发者非常有益。

但这是怎么回事?像 Blazor 服务器和其他 SPA 一样,我们使用组件构建应用。组件是一个 UI,可以小到一个按钮,也可以大到一个页面。然后,当客户端请求我们的应用时,会发生以下情况:

  1. 服务器发送一个或多或少的空 shell(HTML)。
  2. 浏览器下载外部资源(JS、CSS 和图像)。
  3. 浏览器将显示应用。

到目前为止,这与任何其他网页都是一样的体验。区别在于,当用户执行某个操作(例如单击按钮)时,该操作由客户端执行。当然,客户端可以调用远程资源,就像在 React、Angular 或 Vue 中使用 JavaScript 一样。然而,这里重要的一点是你不必这么做。您可以使用 C#和.NET 控制客户端上的用户界面。

Blazor Wasm 的一个显著优势是托管:编译后的 Blazor Wasm 工件只是静态资源,因此您可以在云中几乎免费托管您的 web 应用(例如,提供 Azure Blob 存储和内容交付网络(CDN))。

这带来了另一个优势:可伸缩性。因为每个客户端都运行前端,所以您不需要仅扩展静态资产的交付。

另一方面,如果您愿意,您也可以使用服务器端 ASP.NET 应用预渲染 Blazor Wasm 应用。这导致客户机的初始加载时间加快,但托管成本增加。

然而,有一个明显的缺点:它在.NET 上运行。但我为什么要这么说?那是亵渎神明,对吗?嗯,浏览器必须下载.NET 运行时的 Wasm 版本,这是一个庞大的版本。幸运的是,微软的工作人员正在研究一种修剪未使用部分的方法,因此浏览器只下载所需的部分。Blazor 还支持延迟加载 Wasm 程序集,因此客户端不需要一次下载所有内容。也就是说,总的来说,最小下载大小仍然在 2MB 左右。有了高速互联网,2MB 的容量很小,下载速度也很快,但对于生活在偏远地区的人们来说,下载速度可能会稍长一些。所以,在做出选择之前,先考虑一下你的听众。

要创建 Blazor Wasm 项目,可以运行dotnet new blazorwasm命令。

接下来,我们将探索 Razor 组件,看看 Blazor 提供了什么。

熟悉剃须刀组件

Blazor Wasm 中的所有内容都是剃刀组件,包括应用本身,它被定义为根组件。在Program.cs文件中,该根组件注册如下:

builder.RootComponents.Add<App>("#app");

App类型来自App.razor组件(稍后我们将介绍组件如何工作),字符串"#app"是 CSS 选择器。wwwroot/index.html文件包含一个<div id="app">Loading...</div>元素,一旦应用初始化,该元素将被 BlazorApp组件替换。#app是识别具有id="app"属性的元素的 CSS 选择器。wwwroot/index.html静态文件是提供给客户端的默认页面;这是 Blazor 应用的起点。它包含页面的基本 HTML 结构,包括脚本和 CSS。这就是 Blazor 应用的加载方式。

App.razor文件定义了一个Router组件,将请求路由到正确的页面。当页面存在时,Router组件呈现Found子组件。当页面不存在时,显示NotFound子组件。以下是App.razor文件的默认内容:

<Router AppAssembly="@ typeof(Program).Assembly">
    <Found Context="routeData">
        <RouteView RouteData="@routeData" DefaultLayout="@typeof(MainLayout)" />
    </Found>
    <NotFound>
        <LayoutView Layout="@typeof(MainLayout)">
            <p>Sorry, there's nothing at this address.</p>
        </LayoutView>
    </NotFound>
</Router>

页面剃须刀组件,顶部有@page "/some-uri"指令,类似于剃须刀页面。您可以使用与 Razor 页面相同的大部分(如果不是全部的话)来组成这些路由。

Razor 组件是实现IComponent接口的 C#类。您也可以从ComponentBase继承,它为您实现了以下接口:IComponentIHandleEventIHandleAfterRender。所有这些都存在于Microsoft.AspNetCore.Components名称空间中。

接下来,我们将了解如何创建 Razor 组件。

创建剃须刀组件

与 Razor 页面和视图组件不同,您可以在项目中的任何位置创建组件。我喜欢在Pages目录下创建我的页面,这样更容易找到页面。然后,您可以在任何适合的地方创建非页面组件。

有三种方法可以创建组件:

  • 仅使用 C#。
  • 只使用剃须刀。
  • 混合使用 C#(代码隐藏)和 Razor。

您不必为整个应用只选择一种方式;您可以根据每个组件进行选择。不管怎样,这三种方法最终都被编译成一个 C#类。让我们来看一下组织组件的三种方法。

C#-仅限

C#-只有组件与创建类一样简单。在下面的示例中,我们继承自ComponentBase,但我们只能实现我们需要的接口。

这是我们的第一部分:

CSharpOnlyComponent.cs

namespace WASM
{
    public class CSharpOnlyComponent : ComponentBase
    {
        [Parameter]
        public string Text { get; set; }

Parameter属性允许在使用组件时设置Text属性的值。正式成为一个组件参数。一旦我们完成了这个课程,我们就会看到它的作用。

接下来,BuildRenderTree方法负责呈现我们的组件:

        protected override void BuildRenderTree(RenderTreeBuilder builder)
        {
            builder.OpenElement(0, "h1");
            builder.AddAttribute(1, "class", "hello-world");
            builder.AddContent(2, Text);
            builder.CloseElement();
        }
    }
}

通过重写此方法,我们控制渲染树。这些更改最终转化为 DOM 更改。在这里,我们用一个hello-world类创建一个H1元素。其中是一个文本节点,包含Text属性的值。

序列号

序列号(BuildRenderTree方法中的012在内部用于生成差异树,.NET 用于更新 DOM。建议手动编写这些代码,以避免更复杂的代码(如条件逻辑块)中的性能下降。更多信息,请参见进一步阅读部分中的ASP.NET Core Blazor advanced scenarios链接。

现在,在Pages/Index.razor页面中,我们可以使用如下组件:

Pages/Index.razor

@page "/"
<CSharpOnlyComponent Text="Hello World from C#" />

类的名称成为其标记的名称。这是自动的;我们没有办法让它发生。我们可以将用Parameter属性标识的属性的值设置为 HTML 属性。在本例中,我们将Text属性的值设置为Hello World from C#。我们可以使用该属性标记多个属性,并像使用任何普通 HTML 属性一样使用它们。

呈现页面时,我们的组件呈现为以下 HTML:

<h1 class="hello-world">Hello World from C#</h1>

通过这几行代码,我们创建了第一个 Razor 组件。接下来,我们将使用 Razor-only 语法创建一个类似的组件。

只有剃刀

仅 Razor 组件在.razor文件中创建。它们被编译成一个 C#类。该类的默认名称空间取决于创建它的目录结构。例如,在./Dir/Dir2/MyComponent.razor文件中创建的组件在[Root Namespace].Dir.Dir2命名空间中生成MyComponent类。让我们看一些代码:

RazorOnlyComponent.razor

<h2 class="hello-world">@Text</h2>
@code{
    [Parameter]
    public string Text { get; set; }
}

如果你喜欢剃须刀,你可能已经喜欢这个了。该清单非常简单,允许我们编写与前一个相同的组件,但更加精简。在@code{}块中,我们可以添加属性、字段、方法,以及我们在普通类中所能添加的几乎任何东西,包括其他类。如果需要,我们也可以覆盖那里的ComponentBase方法。我们可以像使用其他组件一样使用组件;参数也是如此。

接下来是使用RazorOnlyComponent的页面:

Pages/Index.razor

@page "/"
<CSharpOnlyComponent Text="Hello World from C#" />
<RazorOnlyComponent Text="Hello World from Razor" />

渲染也非常相似,但我们选择了H2而不是H1

<h2 class="hello-world">Hello World from Razor</h2>

通过这几行代码,我们创建了第二个组件。接下来,我们将创建这两种样式的混合。

代码隐藏

这个第三个模型可以将 C#代码(称为代码隐藏)与 Razor 代码分开。这种混合的对应物利用部分类实现与其他类相同的功能,并生成一个 C#类。

为此,我们需要两个文件:

  • [component name].razor
  • [component name].razor.cs

让我们第三次重做上一个组件,但这次呈现一个H3。让我们从 Razor 代码开始:

CodeBehindComponent.razor

<h3 class="hello-world">@Text</h3>

这段代码几乎不能再精简了;我们有一个H3标记,其内容是Text属性。此模型中的.razor文件取代了BuildRenderTree方法。编译器将 Razor 代码翻译成 C#,为我们生成BuildRenderTree方法的内容。

Text参数是在以下代码隐藏文件中定义的:

CodeBehindComponent.razor.cs

public partial class CodeBehindComponent
{
    [Parameter]
    public string Text { get; set; }
}

它与前两个示例的代码相同–我们只是将其分为两个文件。关键是partial class。它允许从多个文件中编译单个class。在本例中,有我们的partial class和自动生成的CodeBehindComponent.razor文件。我们可以像其他两个一样使用CodeBehindComponent

接下来是使用CodeBehindComponent的页面:

Pages/Index.razor

@page "/"
<CSharpOnlyComponent Text="Hello World from C#" />
<RazorOnlyComponent Text="Hello World from Razor" />
<CodeBehindComponent Text="Hello World from Code-Behind" />

呈现方式与其他方式相同,但内容不同的H3

<h3 class="hello-world">Hello World from Code-Behind</h3>

在以下两方面使用代码隐藏非常有用:

  • 保持你的.razor文件没有 C 代码。
  • 获得更好的工具支持。

.razor文件的工具往往会不时在我们身上爆炸,包括奇怪的 bug,或者提供一半的支持。似乎在单个文件中处理 HTML、C#和 Razor 并不像听起来那么容易。更积极的方面是,它正在变得更好,因此我只能看到未来更稳定的工具。我可以看到我自己写一个组件的所有代码在一个单一的 Tyt1 文件中,如果该工具是与 C.*工具(在许多情况下)相当。这将导致更少的文件和更接近组件的所有部分(导致更好的可维护性)。

接下来,我们将看一看如何使用 CSS 对组件进行蒙皮,但有一点扭曲…

CSS 隔离

与其他 SPA 一样,Blazor 允许我们创建范围为组件的 CSS 样式。这意味着我们不必担心命名冲突。

不幸的是,这似乎不适用于仅使用 C#的组件,因此我们将仅对三个组件中的两个进行蒙皮。它们都有相同的 CSS 类(hello-world。我们将通过定义简单的.hello-worldCSS 选择器来改变他们文本的颜色,这两个选择器都是如此。

为了实现这一点,我们必须创建一个以我们的组件命名的.razor.css文件。以下两个文件表示我们刚刚构建的RazorOnlyComponentCodeBehindComponent的一个 CSS 文件:

RazorOnlyComponent.razor.css

.hello-world {
    color: red;
}

CodeBehindComponent.razor.css

.hello-world {
    color: aqua;
}

从这两个文件中可以看出,它们使用不同的颜色定义了相同的.hello-world选择器。

wwwroot/index.html文件中dotnet new blazorwasm模板增加了以下行:

<link href="[name of the project].styles.css" rel="stylesheet" />

该行将捆绑组件特定样式链接到页面中。是的,你读过捆绑的。Blazor CSS 隔离功能还将所有这些样式捆绑到一个.css文件中,因此浏览器只加载一个文件。

如果我们加载页面,我们会看到以下内容(没有布局):

Figure 18.1 – Output after loading the page

图 18.1–加载页面后的输出

所以成功了!但是怎么做呢?Blazor 在每个 HTML 元素上自动生成随机属性,并在生成的 CSS 中使用这些属性。让我们首先看看 HTML 输出:

<h1 class="hello-world">Hello World from C#</h1>
<h2 class="hello-world" b-cjkj1dpci4>Hello World from Razor</h2>
<h3 class="hello-world" b-0gygcymdih>Hello World from Code-Behind</h3>

这两个突出显示的属性是“神奇”链接。现在,使用以下 CSS 代码,您应该了解它们的用法以及生成它们的原因:

/* /CodeBehindComponent.razor.rz.scp.css */
.hello-world[b-0gygcymdih] {
    color: aqua;
}
/* /RazorOnlyComponent.razor.rz.scp.css */
.hello-world[b-cjkj1dpci4] {
    color: red;
}

如果您不太熟悉 CSS,[...]是一个属性选择器。它允许您执行各种操作,包括选择具有指定属性的元素(如本例所示)。这就是我们需要的。第一个选择器意味着具有hello-world类和名为b-0gygcymdih的属性的所有元素的颜色都应更新为aqua。第二个选择器是相同的,但用于名为b-cjkj1dpci4的属性的元素。

有了这种模式,我们就可以定义组件范围的样式,并且具有很高的可信度,即它们不会与其他组件的样式冲突。

接下来,让我们探讨这些组件的生命周期。

组件生命周期

组件(包括根组件)必须呈现为 DOM 元素,以便浏览器显示它们。随后发生的任何更改也是如此。组件的生命周期由两个不同的阶段组成:

  1. 首次渲染组件时的初始渲染。
  2. 重新渲染,当组件因更改而需要渲染时。

在第一次渲染期间,如果我们去掉重复的同步/异步方法,Razor 组件的生命周期如下所示:

Figure 18.2 – Lifecycle of a Razor component

图 18.2–剃须刀组件的生命周期

  1. 创建组件的实例。
  2. 调用SetParametersAsync方法。
  3. 调用OnInitialized方法。
  4. 调用OnInitializedAsync方法。
  5. 调用OnParametersSet方法。
  6. 调用OnParametersSetAsync方法。
  7. 调用BuildRenderTree方法(渲染组件)。
  8. 调用OnAfterRender(firstRender: true)方法。
  9. 调用OnAfterRenderAsync(firstRender: true)方法。

在重新渲染期间,如果我们去掉重复的同步/异步方法,Razor 组件的生命周期将更加精简,如下所示:

Figure 18.3 – Re-rendered version of a Razor component life cycle

图 18.3–剃须刀组件生命周期的重新呈现版本

  1. ShouldRender方法被调用为。如果返回false,则流程在此停止。如果为true,则循环继续。

  2. 调用BuildRenderTree方法(组件被重新渲染)。

  3. 调用OnAfterRender(firstRender: false)方法。

  4. The OnAfterRenderAsync(firstRender: false) method is called.

    笔记

    如果您以前使用过 Web 表单,并且害怕 Blazor 生命周期的复杂性,请不要担心。它更精简,不包含任何回发。它们是两种不同的技术。Microsoft 试图将 Blazor 作为从 Web 表单迁移的下一个逻辑步骤(这在.NET 的实际状态下是有意义的),但我看到的唯一显著相似之处是 Blazor 的组件模型,它接近 Web 表单的控制模型。因此,如果您不再使用 Web 表单,请不要害怕查看 Blazor;它们不一样–Blazor>Web 表单。

我在 WASM 项目中创建了一个名为LifeCycleObserver的组件。该组件将其生命周期信息输出到控制台。这让我想到了以下技巧:Console.WriteLine在浏览器控制台中写入,如下所示:

Figure 18.4 – The browser debug console displaying the life cycle of the LifeCycleObserver component

图 18.4–显示 LifeCycleObserver 组件生命周期的浏览器调试控制台

接下来,我们将了解事件处理以及如何与组件交互。

事件处理

到目前为止,我们已经展示了使用三种不同技术构建的相同组件。现在是时候与组件交互并了解其工作原理了。HTML 中有多个事件可以使用 JavaScript 处理。在 Blazor 中,我们可以使用 C#来处理大多数问题。

我稍微修改了生成的项目附带的FetchData.razor页面组件,向您展示了两种不同的事件处理程序模式:

  • 没有经过辩论。
  • 有争论。

这两种方法都调用async方法,但同步方法也可以这样做。现在让我们来研究一下这段代码。我将跳过一些不相关的标记,例如H1P标记,只关注真正的代码,首先是:

@page "/fetchdata"
@inject HttpClient Http

在文件的顶部,我留下了HttpClient@page指令的注入。这些允许我们分别在导航到/fetchdataURL 和通过 HTTP 查询资源时访问页面。HttpClientProgram.cs文件(合成根目录)中配置。然后,我添加了几个按钮进行交互。以下是第一点:

<button class="btn btn-primary mb-4" @onclick="RefreshAsync">Refresh</button>

此代码示例的所有按钮都有一个@onclick属性。该属性用于对点击事件做出反应,比如 HTMLonclick属性和 JavaScript"click"``EventListener。该按钮将点击事件委托给RefreshAsync方法:

private Task RefreshAsync() => LoadWeatherAsync();
private async Task LoadWeatherAsync(int? index = null)
{
    var uri = index == null ? _uriList.Next() : _uriList[index.Value];
    forecasts = await Http.GetFromJsonAsync<WeatherForecast[]>(uri);
}

然后刷新方法调用LoadWeatherAsync(index: null)方法,该方法反过来查询返回WeatherForecast数组的资源。预测是位于wwwroot/sample-data目录中的三个静态 JSON 文件。_uriListCycle类的一个实例,该类循环遍历一系列字符串。其代码很简单,但有助于以面向对象的方式简化页面的其余部分:

private class Cycle
{
    private int _currentIndex = -1;
    private string[] _uris;
    public Cycle(params string[] uris) => _uris = uris;
    public string Next() => _uris[++_currentIndex % _uris.Length];
}

当天气预报发生变化时(当我们点击刷新按钮时),组件将自动重新加载,从而更新天气预报表。

我们还可以像在 JavaScript 中一样访问事件参数。如果单击,我们可以访问与事件关联的MouseEventArgs实例。下面是一个显示可能用法的快速示例:

<button class="btn btn-primary mb-4" @onclick="DisplayXY">Display (X, Y)</button>
public void DisplayXY(MouseEventArgs e)
{
    Console.WriteLine($"DOM(x, y): ({e.ClientX}, {e.ClientY}) | Button(x, y): ({e.OffsetX}, {e.OffsetY}) | Screen(x, y): ({e.ScreenX}, {e.ScreenY})");
}

在该代码中,@onclick属性的使用方式与前面相同,但DisplayXY方法需要一个MouseEventArgs作为参数。MouseEventArgs参数由 Blazor 自动提供。然后,该方法将在浏览器的 DevTools 控制台(F12基于 Chromium 的浏览器)中输出鼠标位置,如下所示:

DOM(x, y): (921, 175) | Button(x, y): (119, 4) | Screen(x, y): (-999, 246)
DOM(x, y): (809, 197) | Button(x, y): (7, 26) | Screen(x, y): (-1111, 268)

为了生成这些坐标,我单击了按钮的右上角,然后单击了左下角。从 x 屏的负位置可以推断,我的浏览器在我的左显示器上。

另一种可能是使用 lambda 表达式作为内联事件处理程序。这些 lambda 表达式也可以调用方法。以下是一个例子:

<button class="btn btn-primary mb-4" @onclick="@(e => Console.WriteLine($"DOM(x, y): ({e.ClientX}, {e.ClientY})"))">Lamdba (X, Y)</button>

该按钮仅输出客户端(x,y)坐标,以提高可读性。

下面是我们对事件处理的概述。接下来,我们将研究另一种方法来管理组件的状态,而不是每个组件做自己的事情。

模型视图更新模式

除非你从未听说过 React,否则你很可能听说过 Redux。Redux 是一个遵循模型视图更新MVU模式)的库。MVU 来自 Elm 体系结构。如果您不了解 Elm,请引用他们的文档:

Elm 是一种编译为 JavaScript 的函数式语言。

接下来,让我们看看 MVU 背后的目标是什么。不管我们叫它什么,它都是相同的模式。

目标

MVU 的目标是简化应用的状态管理。如果您在过去构建了一个有状态的 UI,您可能知道管理应用的状态会变得很困难。MVU 从等式中去掉了双向绑定的复杂性,并将其替换为线性单向流。它还通过用不可变状态替换突变,将状态更新移动到纯函数,从而消除突变。

设计

MVU 模式是一个单向数据流,将动作路由到更新功能。更新功能必须是。纯函数是确定性无副作用。模型为状态。状态是不可变的。一个状态必须有一个初始状态视图是知道如何显示状态的代码。

根据技术的不同,有许多同义词。让我们深入了解更多细节。一开始听起来可能让人困惑,但别担心,没那么糟糕。

一个动作在 MediatR 中被称为命令请求。它在 Redux 中称为动作,在 Elm 中称为消息。我将使用动作动作相当于我们在 CQRS 示例中使用的命令。MVU 中没有查询的概念,因为视图总是呈现当前状态。

一个更新函数在 MediatR 中被称为处理程序。在 Redux 中称为减速器,在 Elm 中称为更新。我将使用减速器减速机是一个纯函数,对于任何给定的输入,它总是返回相同的输出(这是确定性的)。纯功能必须对外部参与者没有影响(没有副作用)。因此,外部变量无突变,输入值无突变:无副作用。纯函数的一个显著优点是测试。基于给定的输入很容易断言它们的输出值,因为它们是确定性的。

视图是 React and Blazor 中的组件,是 Elm 中的视图功能。我将主要使用视图,因为组件可能模棱两可,很容易与 Razor 组件、视图组件或简单的 UI 组件概念混淆。

模型或状态不能更改,必须是不可变的。每次状态更改时,都会创建状态的更改副本。然后,当前状态变为该副本。Elm 将状态称为模式;它是 Redux 中的状态。我们使用术语状态,因为我发现它更好地定义了意图。

以下是表示此单向数据流的图表:

Figure 18.5 – Unidirectional data flow chart

图 18.5–单向数据流程图

  1. 当应用启动时,状态被初始化。该初始状态成为当前状态。
  2. 当前状态更改将触发要呈现的 UI。
  3. 当发生交互时,如用户点击按钮,则向减速器发送动作
  4. 减速器创建更新状态的实例
  5. 新状态取代当前状态。
  6. 事件发生时,返回当前列表的步骤**2

一开始你可能很难理解这一点。像所有的新事物一样,我们必须花时间在大脑中创造新的路径,以充分获得某些东西。别担心;我们即将看到这一行动。

总而言之,这是直截了当的;水流只有一个方向。只要状态发生更改,就会重新渲染组件。由于状态是不可变的,我们不能直接改变它们,所以我们必须经过还原器。

项目:柜台

对于这个项目,我们将使用我在 2020 年试验 C#9 记录类时创建的开源库。因为记录是不可变的,所以它是表示 MVU 状态的完美候选。此外,它允许在有限的空间内简化我们的示例。

笔记

有多个类似的库,但它们都是在 C#9 之前创建的,因此没有直接的记录支持。

上下文:我们正在构建一个计数器页来递增和递减一个值。

我知道这听起来不是很令人兴奋,但由于许多 MVU 库展示了一个以及 Blazor 本身,我相信这将是一个很好的方式来比较它和其他。

首先,我们需要通过加载StateR.BlazorNuGet 包来安装库。在本例中,我们使用的是预发布版本。

Redux 开发工具

我还在项目中安装了StateR.Blazor.ExperimentsNuGet 包。该项目有一些实验性功能,包括 Redux DevTools 连接器。Redux DevTools 是一个允许跟踪状态和操作的浏览器扩展。它还允许州与州之间的时间旅行。

接下来,让我们对Counter特性进行编码:

功能/计数器.cs

using StateR;
using StateR.Reducers;
namespace WASM.Features
{
    public class Counter
    {
        public record State(int Count) : StateBase;

State记录是我们的状态。它暴露了一个init-only属性。继承自StateBase,为空记录。StateBase作为通用约束,直到 C#支持。状态器中的状态必须是记录;这是强制性的。

        public class InitialState : IInitialState<State>
        {
            public State Value => new(0);
        }

InitialState类表示State记录的初始状态。它通过实现IInitialState<State>接口告诉 StateR。

        public record Increment : IAction;
        public record Decrement : IAction;

在这里,我们宣布两个动作。它们是记录,但也可以是类。成为记录不是一项要求,而是编写更少代码的捷径。在 StateR 中,操作必须实现IAction接口。

        public class Reducers : IReducer<Increment, State>, IReducer<Decrement, State>

Reducers类实现处理动作纯函数。在 StateR 中,减速器必须实现IReducer<TAction, TState>接口。TAction必须是IAction,且TState必须是StateBase。接口只定义了一个输入TActionTState并输出更新后的TStateReduce方法:

        {
            public State Reduce(Increment action, State state)
                => state with { Count = state.Count + 1 };

Increment减速机返回一份State的副本,其Count递增 1。

            public State Reduce(Decrement action, State state)
                => state with { Count = state.Count - 1 };
        }
    }
}

最后,Decrement减速机返回一份State的副本,其Count减 1。

我发现,使用带有表达式的可以生成非常干净的代码,特别是当State记录有多个属性时。此外,记录类强制执行状态的不变性,这与 MVU 模式一致。

这就是我们需要涵盖模型(状态)和更新(操作/还原器)的全部内容。现在转到视图(组件)部分。该视图是以下 Razor 组件:

功能/反视图.razor

@page "/mvu-counter"
@inherits StatorComponent
@inject IState<Counter.State> State
<h1>MVU Counter</h1>
<p>Current count: @State.Current.Count</p>
<button class="btn btn-primary" @onclick="() => DispatchAsync(new Counter.Increment())">+</button>
<button class="btn btn-primary" @onclick="() => DispatchAsync(new Counter.Decrement())">-</button>

这里只有几行,但有很多事情要讨论。首先,Razor 组件可以通过/mvu-counterURL 访问。

然后,它继承自StatorComponent。这不是必需的,但很方便。StatorComponent类为我们实现了一些功能,包括在IState<TState>属性更改时管理组件的重新呈现。

这就引出了下一行,注入一个可以通过State属性访问的IState<Counter.State>接口实现。该接口封装了TState实例,并通过其Current属性提供对当前状态的访问。@inject指令在剃须刀组件中启用属性注入

接下来,我们显示页面。@ State.Current.Count表示当前计数。下面是两个按钮。两者都有一个调用 lambda 表达式的@onclick属性。DispatchAsync方法来自StatorComponent。顾名思义,它通过 StateR 管道发送动作。类似于 MediatRSendPublish方法。

每个按钮分配不同的动作;一个是Counter.Increment,另一个是Counter.Decrement。StateR 知道减速器,并将操作发送给相应的减速器。

该代码创建了一个集中式状态,并使用 MVU 模式对其进行管理。如果我们在其他地方需要Counter.State,我们只需要注入它,就像我们在这里所做的那样,相同的状态将在多个组件或类之间共享。在本例中,我们在 Razor 组件中注入了状态,但我们也可以在任何代码中使用相同的模式。

还有一件事:我们需要初始化 StateR。为此,在Program.cs文件中,我们需要这样注册它:

using StateR;
using StateR.Blazor.ReduxDevTools; // Optional
// ...
builder.Services
    .AddStateR(typeof(Program).Assembly)
    .AddReduxDevTools() // Optional
    .Apply()
;

builder.Services属性是IServiceCollection属性。AddStateR方法创建IStatorBuilder并注册 StateR 的静态依赖项。

然后可选的AddReduxDevTools方法调用将 StateR 链接到我前面提到的Redux DevTools浏览器插件。这有助于从浏览器调试应用。可以在此处添加其他可选机制。开发人员还可以编写自己的扩展来添加缺少的或特定于项目的特性。StateR 是基于 DI 的。

最后,Apply方法通过扫描指定的程序集以获取它可以处理的每种类型来初始化 StateR。在本例中,我们只扫描 Wasm 应用集(突出显示)。初始化是一个两阶段的过程,通过Apply方法调用完成。

有了它,我们就可以运行应用并使用计数器了。我希望您喜欢这款带有 StateR 的 Redux/MVU。如果有,请随意使用。如果您发现缺少的功能、bug、性能问题,或者希望分享您的想法,请在 GitHub 上打开一个问题(https://net5.link/Z7Ej )。

结论

MVU 模式使用模型来表示应用的当前状态视图呈现的是模型。为了更新模型,一个动作被发送到一个纯函数(一个减速机,该减速机返回新的状态。该更改将触发视图重新渲染。

MVU 的单向流降低了状态管理的复杂性。

现在让我们看看 MVU 模式如何帮助我们遵循坚实的原则:

  • S:模式的每个部分(状态、视图和还原器)都有自己的职责。
  • O:我们可以在不影响现有元素的情况下添加新元素。例如,添加新操作不会影响现有的减速器。
  • L:不适用
  • I:通过分离责任,模式的每个部分都隐含着更小的表面(接口)。
  • D:这取决于您如何实现它。基于我们使用 StateR 所做的工作,我们只依赖于接口和 DTO(状态和动作)。

接下来,我们将快速浏览一下 Blazor 的其他信息,让您了解如果您想开始使用 Blazor,可以使用哪些工具。

Blazor 特征的混合

您的 Blazor 之旅刚刚开始,Blazor 的特性比我们介绍的更多。下面是一些更多的可能性,让您对这些选项略知一二。

您可以使用Component标记帮助器将 Razor 组件与 MVC 和 Razor 页面集成。执行此操作时,您还可以通过将render-mode属性设置为Static来预渲染应用(“T1”)组件),从而加快初始渲染时间。预渲染还可用于改进搜索引擎优化(SEO)。“缺点”是需要 ASP.NET Core 服务器来执行预渲染逻辑。

完整堆栈 C#的另一个有趣之处是在客户端和服务器之间共享代码。假设我们有一个 web API 和一个 Blazor Wasm 应用;我们可以创建第三个项目,一个类库,并在两者之间共享 DTO(API 契约)。

在我们的组件中,我们还可以通过向该组件添加一个名为ChildContentRenderFragment参数,在开始标记和结束标记之间允许任意 HTML。我们还可以捕获任意参数并在组件的 HTML 元素上显示它们。下面是结合这两个功能的示例:

剃须刀

<div class="@($"card {Class}")" @attributes="Attributes">
    <div class="card-body">
 @ChildContent
    </div>
</div>
@code{
 [Parameter]
 public RenderFragment ChildContent { get; set; }
    [Parameter(CaptureUnmatchedValues = true)]
    public Dictionary<string, object> Attributes { get; set; }
    [Parameter]
    public string Class { get; set; }
}

Card组件呈现引导卡,并允许消费者在其上设置他们想要的任何属性。<Card></Card>标记之间的内容可以是任何内容。该内容在div.card-body中呈现。高亮显示的行表示该子内容。

Class参数是一个解决方案,允许使用者在强制card类存在的同时添加 CSS 类。Attributes参数通过将Parameter属性的CaptureUnmatchedValues属性设置为true而成为一个包罗万象的参数。

下面是一个使用Card组件的示例:

Pages/Index.razor

<Card style="width: 25%;" class="mt-4">
    <h5 class="card-title">Card title</h5>
    <h6 class="card-subtitle mb-2 text-muted">Card subtitle</h6>
    <p class="card-text">Some quick example text to build on the card title and make up the bulk of the card's content.</p>
    <a href="#" class="card-link">Card link</a>
    <a href="#" class="card-link">Another link</a>
</Card>

我们可以看到,Card组件(突出显示的行)填充了任意 HTML(来自官方引导文档)。还指定了两个属性,一个是style和一个是class

以下是渲染结果:

<div class="card mt-4" style="width: 25%;">
 <div class="card-body">
        <h5 class="card-title">Card title</h5>
        <h6 class="card-subtitle mb-2 text-muted">Card subtitle</h6>
        <p class="card-text">Some quick example text to build on the card title and make up the bulk of the card's content.</p>
        <a href="#" class="card-link">Card link</a>
        <a href="#" class="card-link">Another link</a>
    </div>
</div>

突出显示的线表示Card组件。其他一切都是ChildContent。我们还可以注意到属性 splatting 添加了style属性。Class属性将mt-4类追加到card。以下是它在浏览器中的外观:

Figure 18.6 – The Card component rendered in a browser

图 18.6–在浏览器中呈现的卡组件

另一个内置组件是Virtualize组件。它允许减少渲染项目的数量,使其仅在屏幕上可见。还可以控制渲染的屏幕外元素的数量,以降低滚动时渲染元素的频率。

正如我们在反项目中看到的,Blazor 完全支持依赖注入。对我来说,这是一个要求。这也是为什么我学习 Angular 2 时,它出来,而不是反应或 Vue。Blazor 的 DI 支持比我目前看到的所有 JavaScript IoC 容器都要好得多,所以这是一个主要的好处。

Blazor 中还有许多其他内置功能,包括EditForm组件、验证支持和ValidationSummary组件,正如您在任何 MVC 或 Razor Pages 应用中所期望的那样,但客户端除外。

作为一个快速提示,如果需要强制渲染组件,可以从ComponentBase调用StateHasChanged方法。

正如本章前面提到的,.NET 代码可以与 JavaScript 交互,反之亦然。要从 C#执行 JavaScript 代码,请插入并使用IJSRuntime接口。要从 JavaScript 执行 C#代码,请使用DotNet.invokeMethodDotNet.invokeMethodAsync函数。C#方法必须是public static并用JSInvokable属性修饰。C#和 JavaScript 还有多种其他交互方式,包括非静态方法。通过支持这一点,人们可以围绕 JavaScript 库构建包装器或按原样使用 JavaScript 库。这也意味着我们可以实现 Blazor 在 JavaScript 中不支持的功能,甚至可以在 Blazor 在某个方面速度较慢的情况下用 JavaScript 编写浏览器优化代码。

您甚至可以在画布上使用 JavaScript 包装器(如BlazorCanvas)或成熟的游戏引擎(如WaveEngine)编写 2D 和 3D 游戏。

我能想到的最后一点附加信息是一个名为Blazor 移动绑定的实验项目。该项目是微软的一项实验,它允许 Blazor 在手机应用中运行。它通过使用 Razor 组件包装 Xamarin 表单控件来实现本机性能。它还支持在WebView控件中加载 Blazor Wasm,从而在移动和 web 应用之间实现更好的可重用性,但要以性能为代价。

我在进一步阅读部分留下了一长串链接,以补充本章的信息。

总结

Blazor 是一项伟大的新技术,可以将 C#和.NET 提升到一个全新的水平。在它目前的状态下,用它来开发应用已经足够好了。主要有两种模式;服务器和 WebAssembly。

Blazor 服务器通过信号器连接将客户端与服务器连接起来,允许服务器在需要时(例如当用户执行某个操作时)将更新推送到客户端。BlazorWebAssembly(Wasm)是一个.NETSPA 框架,它将 C#编译成 WebAssembly。它允许.NET 代码在浏览器中运行。我们可以使用IJSRuntime与 JavaScript 交互,反之亦然。

Blazor 是基于组件的,这意味着 Blazor 中的每个 UI 都是一个组件,包括页面。我们探索了三种创建组件的方法:仅使用 C#和 Razor,以及将 C#和 Razor 组合在两个不同文件中的混合方法。组件也可以有自己的独立 CSS,而无需担心冲突。

我们探讨了剃须刀组件的生命周期,它非常简单但功能强大。我们还研究了如何处理事件以及如何应对这些事件。

然后我们深入研究了 MVU 模式,它非常适合 Blazor 这样的有状态用户界面。我们使用了一个开源库,并利用 C#9.0 记录类实现了一个基本示例。

最后,我们来看看 Blazor 提供的其他可能性。

我将以个人观点结束本章。我希望看到类似 Blazor 的模型成为在.NET 中构建用户界面的统一方式。我更喜欢用 Razor 的方式编写 UI 代码,而不是用 XAML 编写 UI 代码。

问题

让我们来看看几个练习题:

  1. Blazor Wasm 被编译成 JavaScript 是真的吗?
  2. 在创建剃须刀组件的三种方法中,哪一种是最好的方法?
  3. MVU 模式的三个部分是什么?
  4. 在 MVU 模式中,是否建议使用双向绑定?
  5. Blazor 可以与 JavaScript 交互吗?

进一步阅读

以下是我们在本章中所学内容的几个链接:

我上一次做 2D/3D 开发是在 XNA 还很流行的时候。在学校项目中,我还使用了 OGR3D 在 C++中。也就是说,我在本章中谈到了 2D 和 3D 游戏,因此我为感兴趣的人找到了一些资源:

结束只是一个新的开始

这可能是本书的结尾,但也是您进入软件架构和设计之旅的开始。无论你是谁,我希望你能发现这是一个关于设计模式和如何设计可靠 web 应用的全新视角。根据您的目标和当前情况,您可能希望更深入地探索一个或多个应用规模的设计模式,开始下一个个人项目,开始一项业务,申请一份新工作,或者同时进行所有这些工作。无论你的目标是什么,请记住,设计软件是技术性的,也是艺术性的。很少有一种实现特性的正确方法,但是有多种可以接受的方法。经验是你最好的朋友,所以继续编程,从错误中吸取教训,继续前进。记住,我们生来几乎一无所知,所以不知道是意料之中的事;我们需要学习。请向您的队友提问,向他们学习,并与他人分享您的知识。

现在这本书已经完成了,我将继续写博客帖子,这样你就可以在那里学到新东西了(https://net5.link/blog 。请随时在社交媒体上联系我,比如 Twitter@ CarlHugoM)https://net5.link/twit )。我希望你觉得这本书很有教育意义,很平易近人,而且你学到了很多东西。祝你事业成功。

十九、答案

各章练习问题的答案如下:

第一章

  1. 长方法是指一种方法处理了太多的责任,应该进行拆分。
  2. 对通过以.NET 标准为目标,您可以达到多个运行时版本,包括.NET Core 和.NET 框架。
  3. 代码气味代表一个潜在的设计缺陷,可以从重写中获益。

第二章

  1. 是的,这是真的。
  2. 测试代码单元,如方法的逻辑代码路径。
  3. 3.尽可能小。单元测试旨在孤立地测试尽可能最小的代码单元。
  4. 集成测试通常用于此类任务。
  5. 不,有多种编写代码的方法,TDD 只是其中之一。

第三章

  1. 五点:S.O.L.I.D。(SRP、OCP、LSP、ISP 和 DIP)。
  2. 不,想法正好相反:创建更小的组件。
  3. 不,您希望封装相似的逻辑,而不是外观相似的代码块。
  4. 是的,重用较小的组件比适应较大的组件更容易。
  5. 这是 SRP,但关注点分离原则也指出了这一点。

第四章

  1. 控制器操纵模型并选择视图进行渲染。
  2. @model指令。
  3. 视图模型应该与视图有一对一的关系。

第五章

  1. 201 已创建。
  2. 2.[FromBody]属性。
  3. GET方法。
  4. 是的,这些正是 DTO 的目标:将insOUT模型解耦。

第六章

  1. 它有助于管理运行时的行为,例如在程序中间更改算法。
  2. 创建模式负责创建对象。
  3. v1v2是两个不同的实例。每次调用属性的 getter 时,都会执行 arrow 操作符旁边的代码。
  4. 是的,这是真的。这是该模式的主要目标,正如我们在MiddleEndVehicleFactor代码示例中演示的那样。
  5. 单例模式违反了坚实的原则,鼓励使用全局(静态)变量。

第七章

  1. 瞬态、作用域、单态。
  2. 组合根目录包含描述如何组合程序的代码——抽象和实现之间的所有注册和绑定。
  3. 是的,这是真的。应注入易失性依赖项,而不是实例化。
  4. 战略模式。
  5. 服务定位器模式是所有三种模式。它是 DI 库在内部使用的一种设计模式,但在应用代码中成为一种代码气味。如果被误用,这是一种反模式,与直接使用new关键字具有相同的缺点。

第八章

  1. 辛格尔顿。
  2. 范围。
  3. 转瞬即逝的
  4. 是的,您可以根据需要配置任意多个提供程序。一个用于控制台,另一个用于将条目附加到文件。
  5. 不,您不应该在生产中记录跟踪级别的条目。调试问题时,应该只记录调试级别的条目。

第九章

  1. 是的,我们可以通过仅依赖接口来装饰装饰器,因为它们只是接口的另一个实现,仅此而已。
  2. 当涉及到管理复杂性时,复合模式增加了简单性。
  3. 是的,我们可以用一个适配器。
  4. 我们通常使用立面来简化一个或多个子系统的使用,在它们前面创建一堵墙。
  5. Adapter 和 Façade 设计模式几乎相同,但适用于不同的场景。适配器模式将一个 API 适配到另一个 API,而 Façade 模式公开了一个统一或简化的 API,隐藏了一个或多个复杂的子系统。

第十章

  1. 错误的您可以创建任意数量的abstract(必需)或virtual(可选)扩展点,只要类与单个职责相衔接。
  2. 是的,没有理由不这样做。
  3. 不,没有比任何其他代码更大的限制。
  4. 是的,每个消息可以有一个处理程序,或者每个消息可以有多个处理程序。这取决于您和您的要求。
  5. 它有助于在班级之间划分职责。

第 11 章

  1. 对实际上,HttpMessageInvoker.Send 方法返回的HttpResponseMessage实例就是一个操作结果。HttpClient继承自HttpMessageInvoker并公开了其他不同的方法,这些方法也返回HttpResponseMessage的实例。
  2. 我们实现了两种静态工厂方法
  3. 是的,返回对象比抛出异常更快。

第 12 章

  1. 不,您可以根据需要拥有任意多的层,并且可以根据需要命名和组织它们。
  2. 不,两者都有各自的位置、优点和缺点。
  3. DbContext是工作单元模式的实现。DbSet<T>是存储库模式的一个实现。
  4. 不,您可以按任何方式查询任何系统。例如,您可以使用 ADO.NET 查询关系数据库,使用DataReader手动创建对象,使用DataSet跟踪更改,或者执行任何符合您需要的操作。尽管如此,ORMs 还是非常方便。
  5. 对层永远不能访问外部层,只能访问内部层。

第 13 章

  1. 是的,可以,但不一定。移动依赖项并不能修复设计缺陷;它只是将这些缺陷转移到其他地方。
  2. 是的,制图员应该帮助我们遵循 SRP。
  3. 不,它可能不适用于所有场景。例如,当映射逻辑变得复杂时,请考虑不使用 AutoMapper。
  4. 是的,使用概要文件来连贯地组织映射规则。
  5. 四个或更多。再一次,这只是一个指导方针;在一个类中注入四个服务是可以接受的。

第 14 章

  1. 是的,你可以。这就是调解模式的目标:调解同事之间的沟通。
  2. 在 CQRS 的原始意义上:不,命令不能返回值。其思想是,查询读取数据,而命令改变数据。在 CQR 更宽松的意义上,是的,一个命令可以返回一个值。例如,没有任何东西可以阻止 create 命令部分或全部返回所创建的实体。您总是可以用一点模块化来换取一点性能。
  3. MediatR 是一个根据 Apache 许可证 2.0 授权的免费开源项目。
  4. 是的,你应该;使用标记接口添加元数据通常是错误的。然而,您应该单独分析每个用例,在得出结论之前考虑利弊。

第 15 章

  1. 您知道的任何可以帮助您实现解决方案的模式和技术。这就是它的美:你不受限制;只有你自己。
  2. 不,您可以在每个垂直切片内为作业选择最佳工具;你甚至不需要图层。
  3. 应用很可能会变成一个大泥球,很难维护,这对你的压力水平不好。
  4. 我们可以在任何 ASP.NET MVC 应用中创建 MVC 过滤器。我们可以在任何使用 MediatR 的应用中使用行为来扩充 MediatR 管道。我们还可以在非 MVC 应用中实现 ASP.NET 中间件,或者在进入 MVC 管道之前执行代码。
  5. 内聚性是指应作为一个统一的整体共同工作的要素。
  6. 紧密耦合描述了不能独立改变的元素;这直接依赖于彼此。

第 16 章

  1. 消息队列获取一条消息,并有一个订阅者将其出列。如果没有任何东西使消息出列,它将无限期地留在队列中(FIFO 模型)。发布子模型获取消息并将其发送给零个或多个订阅者。
  2. 事件源是按时间顺序累积系统中发生的事件的过程。它允许您通过重播这些事件来重新创建应用的状态。
  3. 是的,您可以混合网关模式(或子模式)。
  4. 不,如果愿意,您可以在本地部署微应用(微服务)。此外,在第 14 章,Mediator 和 CQRS 设计模式中,我们发现我们甚至可以在单个应用中使用 CQRS。
  5. 不,如果愿意,可以不使用容器部署微服务。在任何情况下,容器都很可能为您省去许多麻烦(并创建新的麻烦)。

第 17 章

  1. Razor Pages 最擅长创建面向 web 页面的应用。
  2. 是的,我们可以访问与 MVC 基本相同的内容。
  3. 从技术上讲,是的,您可以,但您应该只使用局部视图来呈现部分 UI 和 UI 逻辑,而不是域逻辑和数据库查询。
  4. 是的,你可以。您还可以创建新标记。
  5. 是的,你可以。视图组件类似于渲染一个或多个视图的基于组件的单动作控制器。
  6. 是的,它可以编译。它使用 C#9 中引入的目标类型new表达式
  7. 不,没有。如果我们将class关键字替换为record关键字,它将编译,如下所示:public record MyDTO(int Id, string Name);.
  8. 一个类可以有与视图/页面层次结构中的级别一样多的显示模板。
  9. 显示模板和编辑器模板与类型直接相关。

第 18 章

  1. 否,它被编译为 WebAssembly。
  2. 没有一个这三个选项都是可以接受的,这取决于你在建什么,和谁一起建。
  3. 模型(状态)–视图(组件)–更新(减速器)。
  4. 不,MVU 模式是关于一个单向的数据流,以简化状态管理。
  5. 对 Blazor 可以与 JavaScript 交互,反之亦然。

第一部分:原则和方法

本节重点介绍我们在本书中使用的体系结构原则和开发方法。这些介绍性章节对于做出伟大的架构决策至关重要。

我们首先看看如何处理这本书本身,探索先决条件,并看到一些有用的主题。然后,我们将介绍自动测试和 xUnit,最后将介绍体系结构原则,从中我们开始学习现代软件工程的基础知识。

本节包括以下章节:

第二部分:ASP.NET Core 设计

本节介绍 ASP.NET Core 5模型视图控制器MVC)及其对应的 web API。我们探索 Razor 页面、MVC 和基于 HTTP 的 RESTful 服务。然后,我们将探讨一些更高级的技术,这些技术用于进一步推动 MVC 模式,例如视图模型和数据传输对象。

之后,我们深入研究一些经典的设计模式,让我们热身。最后,我们使用依赖注入将这些模式提升到一个新的层次,依赖注入是现代 ASP.NET 应用的核心。所有这些主题都列出了我们将在本书结束前以及在您作为 ASP.NET 开发人员的职业生涯的剩余时间内建立的基础知识。

最后,我们深入研究了一些特定于 ASP.NET 的模式,如选项模式和.NET 日志抽象。

本节包括以下章节:

第三部分:组件级别的设计

本节重点介绍组件设计,我们将研究如何精心设计一个软件片段以实现特定目标。我们通过探索一些结构化的 GoF 模式来帮助设计可靠的数据结构和组件。通过将逻辑封装在更小的单元中,它们也有助于简化代码的复杂性。

我们继续介绍两种行为模式,它们有助于管理共享逻辑或简化管理复杂逻辑所需的工作。在本节的结尾,我们将探讨如何在组件之间传输有关操作错误和成功的结构化信息。

本节包括以下章节:

第四部分:应用界别的设计

在本节中,我们将进入应用设计领域。我们不关注应用的一小部分,而是关注如何设计应用本身。我们从研究分层开始,它揭示了应用设计的基础,在转向分层的发展之前,我们将重点放在分层应用中使用的三个最常见的层上。我们探索了两种建模领域模型的方法。然后,我们探索一种方法来封装和降低分层和模型复制的负担,然后再转向新的体系结构样式,如垂直切片和微服务。

这些章节中的每一章都可以自己编写一本书,因此我们将在更高的层次上对它们进行探讨,帮助您在选择架构风格的时候做出更明智的决定。本节是进一步阅读的起点,同时仍然充满了有用的内容、模式、技巧和技术,可直接用于日常项目中。

目标是覆盖尽可能多的应用级模式。原因是,对许多技术了解一点有助于为手头的工作选择正确的方法,而不是每次都选择相同的方法。当你知道从哪里开始时,在某件事情上做得更好会更容易,但如果你不知道有什么选择,这是不可能的。

本节包括以下章节:

第五部分:客户端设计

在本节中,我们将探讨 ASP.NET Core 5 为构建用户界面(程序的客户端方面)提供的选项。我们深入研究了 ASP.NET Razor 页面提供的各种可能性,以及将 UI 划分为更小、更易于重用的组件的多种方法。大多数内容适用于 Razor 页面和 MVC。我们还学习了许多新的强大的 C#9 特性,这些特性改变了我们编写.NET 代码的方式。最后,我们将介绍一种面向类型的方法来构建复杂的 UI。

之后,我们继续讨论 Blazor,它使我们能够构建完整堆栈的.NET 程序。我们快速探索 Blazor 服务器并深入研究 Blazor WebAssembly,一个.NETSPA 框架。我们研究了创建 Razor 组件的不同方法,并探讨了模型视图更新(MVU)模式。我们用 Blazor 的一系列功能来完成这一部分,我无法在本书中详细介绍这些功能,但我将为您提供一个提纲和许多指导,帮助您开始 Blazor 之旅。

本节包括以下章节:

posted @ 2025-10-21 10:42  绝不原创的飞龙  阅读(8)  评论(0)    收藏  举报