ASP-NET-Core-应用架构-全-
ASP.NET Core 应用架构(全)
原文:
zh.annas-archive.org/md5/ab4d49f9725121832839b6070a1b3714译者:飞龙
第一章:1 引言
在你开始之前:加入我们的 Discord 书社区
直接向作者本人提供反馈,并在我们的 Discord 服务器上与其他早期读者聊天(在“architecting-aspnet-core-apps-3e”频道下找到,位于“EARLY ACCESS SUBSCRIPTION”下)。

这本书的目标不是再创作一本设计模式的书;相反,章节是按照规模和主题组织的,让你可以从一个坚实的基础开始,逐步构建,就像你构建一个程序一样。我们不会提供关于应用设计模式的一些方法的指南,而是从软件工程师的角度探索我们正在设计的系统背后的思维过程。这不是一本魔法食谱书;从经验来看,在软件设计时没有魔法食谱;只有你的逻辑、知识、经验和分析技能。让我们把“经验”定义为你过去的成功和失败。而且不用担心,你在职业生涯中会失败,但不要因此气馁。你失败得越快,你恢复和学习得就越快,最终导致成功的产品。这本书中涵盖的许多技术应该能帮助你取得成功。每个人都会失败和犯错误;你不是第一个,也绝对不会是最后一个。用罗斯福的一句名言来概括:从未失败的人是那些从未做过任何事情的人。在更高层次上:
-
这本书探讨了基本模式、单元测试、架构原则和一些 ASP.NET Core 机制。
-
然后,我们转向组件规模,探索面向小块软件和单个单元的模式。
-
之后,我们将转向应用规模的模式和技术,探索如何构建应用程序。
-
书中涵盖的一些主题可能足以写一本书,所以在这本书之后,你应该有很多关于继续你的软件架构之旅的想法。
下面是关于这本书的一些值得注意的要点:
-
章节的组织是从小规模模式开始,然后逐步过渡到更高级的模式,使学习曲线更容易。
-
这本书不是提供食谱,而是关注事物背后的思考,并展示一些技术的演变,帮助你理解为什么发生了转变。
-
许多用例结合了多个设计模式来展示不同的用法,这样你可以有效地理解和使用这些模式。这也表明,设计模式不是需要驯服的野兽,而是可以用来使用、操纵和按照你的意愿弯曲的工具。
-
就像现实生活中一样,没有教科书上的解决方案可以解决我们所有的问题;现实问题总是比教科书上解释的更复杂。在这本书中,我旨在向你展示如何混合和匹配模式来思考“架构”,而不是给你一步一步的指令来复制。
引言章节的其余部分介绍了本书中探讨的概念,包括对一些观念的复习。我们还涉及了 .NET、其工具和一些技术要求。在本章中,我们涵盖了以下主题:
-
什么是设计模式?
-
反模式与代码异味。
-
理解网络 – 请求/响应。
-
开始使用 .NET。
什么是设计模式?
由于你刚刚购买了一本关于设计模式的书籍,我猜你对设计模式有一定的了解,但让我们确保我们处于同一页面上。抽象定义:设计模式是一种经过验证的技术,我们可以用它来解决特定的问题。在这本书中,我们应用不同的模式来解决各种问题,并利用一些开源工具来更快地前进!抽象定义让人听起来很聪明,但理解概念需要更多的实践,而通过实验来学习是最好的方法,设计模式也不例外。如果那个定义对你来说还不清楚,不要担心。到书的结尾,你应该有足够的信息将多个实际例子和解释与那个定义联系起来,使其变得非常清晰。我喜欢将编程比作玩 LEGO®,因为你要做的事情非常相似:把小部件放在一起,创造出更大的东西。因此,如果你缺乏想象力或技能,可能是因为你还太小,你的城堡可能看起来不如有更多经验的人那么好。带着这个类比,设计模式是一个组装解决方案的计划,该方案适合一个或多个场景,就像城堡的塔楼一样。一旦你设计了一个单独的塔楼,你就可以通过遵循相同的步骤来建造多个。设计模式充当那个塔楼计划,并为你提供组装可靠部件的工具,以改善你的杰作(程序)。然而,你并不是简单地拼接 LEGO®积木,而是在虚拟环境中嵌套代码块和交织对象!在更详细地介绍之前,精心设计的应用设计模式应该会改善你的应用程序设计。这无论是在设计一个小组件还是整个系统时都是正确的。然而,请注意:只是为了使用而将模式混合在一起可能会导致相反的结果:过度设计。相反,目标是编写尽可能少的可读代码来解决你的问题或自动化你的流程。正如我们简要提到的,设计模式适用于不同的软件工程级别,在这本书中,我们从小的开始,逐渐扩展到云规模!我们遵循一个平稳的学习曲线,从更简单的模式和代码示例开始,这些示例将良好的实践弯曲以关注模式——最后以更高级的主题和良好的实践结束。当然,有些主题是概述而不是深入探讨,比如自动化测试,因为没有人能在一本书中涵盖所有内容。尽管如此,我已经尽我所能提供尽可能多的关于架构相关主题的信息,以确保为你打下坚实的基础,以便你能从更高级的主题中获得尽可能多的东西,并且我真诚地希望你会觉得这本书是一本有帮助且有趣的读物。让我们从设计模式的反面开始,因为识别做事的错误方式对于避免犯这些错误或在你看到它们时纠正它们是至关重要的。当然,知道如何使用设计模式克服特定问题也是至关重要的。
反模式和代码异味
反模式和代码异味是糟糕的架构实践或关于可能不良设计的提示。了解最佳实践与了解不良实践同样重要,这正是我们开始的地方。本书突出了多个反模式和代码异味,以帮助您入门。接下来,我们将简要探讨前几个。
反模式
反模式是与设计模式相反的概念:它是一种已被证明有缺陷的技术,很可能会给您带来麻烦,浪费您的时间和金钱(可能还会让您头疼)。反模式是一种看似不错且似乎就是您所寻找的解决方案的模式,但它带来的危害大于好处。一些反模式最初是合法的设计模式,后来被标记为反模式。有时,这取决于个人观点,有时分类可能受到编程语言或技术的影響。接下来,让我们看一个例子。本书中还将探讨其他一些反模式。
反模式 – 上帝类
上帝类是一个处理太多事物的类。通常,这个类充当一个中心实体,许多其他类在应用程序中继承或使用它;它是系统中知道和管理一切的那个类;它是那个类。另一方面,它也是没有人愿意更新的类,每次有人触摸它时都会破坏应用程序:它是一个邪恶的类!修复这个问题的最佳方法是分离责任并将它们分配给多个类,而不是将它们集中在单个类中。本书将探讨如何在全书中划分责任,这有助于创建更健壮的软件。如果您有一个以上帝类为核心的个人项目,请先阅读本书,然后尝试将您学到的原则和模式应用到将那个类划分为多个较小的类,这些类可以相互交互。尝试将这些新类组织成统一的单元、模块或组件。为了帮助修复上帝类,我们在第三章“架构原则”中深入探讨了架构原则,为责任分离等概念打开了道路。
代码异味
代码异味是一个可能存在问题的指示器。它指向你的设计中可能需要重新设计的区域。当我们说“代码异味”时,我们指的是“有臭味的代码”或“看起来不对劲的代码”。需要注意的是,代码异味仅表明可能存在问题;并不意味着问题确实存在。代码异味通常是很好的指示器,因此分析你软件的“有异味”部分是值得的。一个很好的例子是,当一个方法需要许多注释来解释其逻辑时。这通常意味着代码可以被拆分成具有适当名称的小方法,从而产生更易读的代码,并允许你摆脱那些讨厌的注释。关于注释的另一个注意事项是,它们不会演变,所以经常发生的情况是,注释所描述的代码发生了变化,但注释保持不变。这会导致一个错误的或过时的代码块描述,可能会误导开发者。同样,这也适用于方法名称。有时,方法的名字和主体讲述的是不同的故事,导致相同的问题。尽管如此,这种情况比孤儿或过时的注释发生的频率要低,因为程序员在阅读和编写代码方面通常比口头注释做得更好。然而,在阅读、编写或审查代码时,请记住这一点。
代码异味 – 控制狂
代码异味的绝佳例子是使用new关键字。这表明存在硬编码的依赖关系,其中创建者控制新对象及其生命周期。这也被称为控制狂反模式,但我更喜欢将其视为代码异味而不是反模式,因为new关键字本身并不是错误的。在这个时候,你可能想知道在面向对象编程中如何不使用new关键字,但请放心,我们将在第七章“深入依赖注入”中介绍这一点,并扩展控制狂代码异味。
代码异味 – 长方法
长方法代码异味是指方法扩展到 10 到 15 行代码以上。这是一个很好的迹象,表明你应该以不同的方式考虑该方法。具有分隔多个代码块的注释是方法可能太长的良好指标。以下是一些可能的情况示例:
-
该方法包含在多个条件语句中交织的复杂逻辑。
-
该方法包含一个大的
switch块。 -
该方法做了太多事情。
-
该方法包含代码重复。
为了解决这个问题,你可以做以下几件事:
-
提取一个或多个私有方法。
-
将一些代码提取到新的类中。
-
重复使用外部类中的代码。
-
如果你有很多条件语句或巨大的
switch块,你可以利用设计模式,如责任链模式或 CQRS,你将在第十章“行为模式”和第十四章“中介和 CQRS 设计模式”中了解到这些。
通常,每个问题都有一个或多个解决方案;你需要找出问题,然后找到、选择并实现其中一个解决方案。让我们明确一点:包含 16 行的方法不一定需要重构;它可能是可以接受的。记住,代码异味表明可能存在问题,并不一定意味着必然存在问题——运用常识。
理解 Web – 请求/响应
在继续之前,理解 Web 的基本概念是至关重要的。HTTP 1.X 背后的想法是客户端向服务器发送 HTTP 请求,然后服务器响应该客户端。如果你有 Web 开发经验,这听起来可能很平凡。然而,无论你是构建 Web API、网站还是复杂的云应用,这都是最重要的 Web 编程概念之一。让我们将 HTTP 请求的生命周期简化为以下:
-
通信开始。
-
客户端向服务器发送请求。
-
服务器接收请求。
-
服务器对请求进行一些操作,比如执行代码/逻辑。
-
服务器响应客户端。
-
通信结束。
在那个周期之后,服务器不再知道客户端的存在。此外,如果客户端发送另一个请求,服务器也不会意识到它之前已经对同一个客户端的请求做出了响应,因为HTTP 是无状态的。有机制可以在服务器之间创建请求的持久感,使其“知道”其客户端。其中最著名的是 cookies。如果我们深入挖掘,一个 HTTP 请求由一个头部和一个可选的正文组成。然后,使用特定的方法发送请求。最常见的 HTTP 方法有GET和POST。在此基础上,广泛用于 Web API 的,我们还可以添加PUT、DELETE和PATCH到这个列表中。尽管不是每个 HTTP 方法都接受正文,可以响应正文,或者应该是幂等的,这里有一个快速参考表:
| 方法 | 请求有正文 | 响应有正文 | 幂等 |
|---|---|---|---|
GET |
否* | 是 | 是 |
POST |
是 | 是 | 否 |
PUT |
是 | 否 | 是 |
PATCH |
是 | 是 | 否 |
DELETE |
可能 | 可能 | 是 |
- 使用
GET请求发送正文并不是 HTTP 规范所禁止的,但这种请求的语义也没有定义。最好避免发送带有正文的GET请求。
一个幂等请求是一个无论发送一次还是多次都会产生相同结果的请求。例如,发送相同的POST请求多次应该创建多个类似的实体,而发送相同的DELETE请求多次应该删除单个实体。幂等请求的状态码可能不同,但服务器状态应该保持不变。我们在第四章,模型-视图-控制器中更深入地探讨了这些概念。以下是一个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,image/webp,image/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 就像任何其他 HTTP 头部一样被来回传递。
关于 cookie 的重要说明
客户端发送 cookie,服务器在每次请求-响应周期中返回它们。如果您在来回传递太多信息(cookie 或其他)时,这可能会杀死您的带宽或减慢您的应用程序。一个很好的例子是序列化的身份 cookie 非常大。
另一个例子,与 cookie 无关,但造成了这样的来回,是那个古老的 Web Forms
ViewState。这是一个与每个请求一起发送的隐藏字段。如果未经检查,该字段可能会变得非常大。现在,随着高速互联网的出现,很容易忘记这些问题,但它们可以显著影响慢速网络上的用户体验。
当服务器决定响应请求时,它会返回一个头部和一个可选的正文,遵循与请求相同的原理。第一行指示请求的状态:是否成功。在我们的例子中,状态码是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
<Response body truncated for brevity>
现在浏览器已经收到服务器的响应,它会渲染 HTML 网页。然后,对于每个资源,它会向其 URI 发送另一个 HTTP 请求并加载它。资源是一个外部资产,例如图片、JavaScript 文件、CSS 文件或字体。在响应之后,服务器就不再知道客户端了;通信已经结束。理解这一点很重要,为了在每次请求之间创建一个伪状态,我们需要使用外部机制。这种机制可以是利用 cookie 的会话状态,简单地使用 cookie,或者某些其他 ASP.NET Core 机制,或者我们可以创建一个无状态应用程序。我建议尽可能使用无状态应用程序。我们在书中主要编写无状态应用程序。
注意
如果你想了解更多关于会话和状态管理的信息,我在章节末尾的进一步阅读部分留下了一个链接。
如你所想,互联网的骨架是其网络栈。超文本传输协议(HTTP)是该栈的最高层(第 7 层)。HTTP 是建立在传输控制协议(TCP)之上的应用层。TCP(第 4 层)是传输层,它定义了数据如何在网络上传输(例如,数据的传输、传输的数据量以及错误检查)。TCP 使用 互联网协议(IP)层来到达它试图与之通信的计算机。IP(第 3 层)代表网络层,它处理数据包的 IP 寻址。数据包是传输线上传输的数据块。我们可以直接从源机器向目标机器发送大文件,但这并不实用,因此网络栈将大项目分解成更小的数据包。例如,源机器将文件分解成多个数据包,将它们发送到目标机器,然后目标机器将它们重新组装成源文件。这个过程允许多个发送者使用相同的线路,而不是等待第一次传输完成。如果一个数据包在传输过程中丢失,源机器也可以只将那个数据包发送回目标机器。放心,你不需要理解网络背后的每一个细节来编写网络应用程序,但了解 HTTP 使用 TCP/IP 并将大负载分成小数据包总是好的。此外,HTTP/1 限制了浏览器可以同时打开的并行请求数量。这些知识可以帮助你优化你的应用程序。例如,要加载的资产数量、它们的大小以及它们发送到浏览器的顺序可能会增加页面加载时间、感知的页面加载时间或绘制时间。为了总结这个主题,而不深入探讨网络,HTTP/1 虽然较旧,但却是基础性的。HTTP/2 更高效,并支持使用相同的 TCP 连接流式传输多个资产。它还允许服务器在客户端请求资源之前向客户端发送资产,这被称为服务器推送。如果你对 HTTP 感兴趣,HTTP/2 是一个很好的开始深入研究的地方,以及使用 QUIC 传输协议而不是 HTTP 的 HTTP/3 提议标准(RFC 9114)。ASP.NET Core 7.0+支持 HTTP/3,这在 ASP.NET Core 8.0 中默认启用。接下来,让我们快速探索.NET。
开始使用 .NET
一点历史:.NET Framework 1.0 首次于 2002 年发布。.NET 是一个托管框架,将您的代码编译成名为 中间语言(IL)的 Microsoft 中间语言(MSIL)。然后,该 IL 代码被编译成本地代码,并由 公共语言运行时(CLR)执行。现在,CLR 简单地被称为 .NET 运行时。在发布了几个版本的 .NET Framework 之后,微软从未兑现过可互操作堆栈的承诺。此外,许多缺陷被构建到 .NET Framework 的核心中,使其与 Windows 紧密绑定。Mono,一个开源项目,由社区开发,以使 .NET 代码能够在非 Windows 操作系统上运行。Mono 被用于并由微软在 2016 年收购的 Xamarin 支持。Mono 使 .NET 代码能够在 Android 和 iOS 等其他操作系统上运行。后来,微软开始开发官方的跨平台 .NET SDK 和运行时,它们将其命名为 .NET Core。.NET 团队从零开始构建了 ASP.NET Core,完全切断了与旧版 .NET Framework 版本的兼容性。这最初带来了一些问题,但 .NET Standard 缓解了旧 .NET 和新 .NET 之间的互操作性难题。经过多年的改进和两个并行的主要版本(Core 和 Framework),微软将大多数 .NET 技术统一到了 .NET 5+,并实现了共享的 基类库(BCL)的承诺。随着 .NET 5 的发布,.NET Core 简单地成为了 .NET,而 ASP.NET Core 仍然是 ASP.NET Core。没有 .NET “Core” 4,以避免与 .NET Framework 4.X 产生任何潜在的混淆。现在,.NET 每年都会发布新的主要版本。偶数版本是 长期支持(LTS)版本,提供 3 年的免费支持,而奇数版本(当前版本)仅提供 18 个月的免费支持。这本书的亮点在于,涵盖的架构原则和设计模式在未来应该仍然相关,并且与您使用的 .NET 版本没有紧密耦合。代码示例的微小更改应该足以将您的知识和代码迁移到新版本。接下来,我们将介绍一些关于 .NET 生态系统的关键信息。
.NET SDK 与运行时对比
您可以安装属于 SDK 和运行时分组的不同二进制文件。SDK 允许您构建和运行 .NET 程序,而运行时仅允许您运行 .NET 程序。作为一名开发者,您希望在您的部署环境中安装 SDK。在服务器上,您希望安装运行时。运行时更轻量,而 SDK 包含更多工具,包括运行时。
.NET 5+ 与 .NET Standard 对比
当构建 .NET 项目时,有多种项目类型,但基本上,我们可以将它们分为两大类:
-
应用程序
-
库
应用程序针对.NET 的一个版本,例如net5.0和net6.0。例如,ASP.NET 应用程序或控制台应用程序就是这样的例子。库是一组编译在一起的代码包,通常以 NuGet 包的形式分发。.NET Standard 类库项目允许在.NET 5+和.NET Framework 项目之间共享代码。.NET Standard 的出现是为了弥合.NET Core 和.NET Framework 之间的兼容性差距,这简化了过渡过程。当.NET Core 1.0 首次发布时,事情并不容易。随着.NET 5 统一所有平台并成为统一.NET 生态系统的未来,.NET Standard 就不再需要了。此外,应用程序和库的作者应该针对基础目标框架标识符(TFM),例如,net8.0。在需要时,您也可以针对netstandard2.0或netstandard2.1,例如,与.NET Framework 共享代码。Microsoft 还引入了与.NET 5+相关的特定于操作系统的 TFM,允许代码使用特定于操作系统的 API,如net8.0-android和net8.0-tvos。在需要时,您也可以针对多个 TFM。
注意
我相信我们还将看到.NET Standard 库存在一段时间。并非所有项目都能神奇地从.NET Framework 迁移到.NET 5+,人们将希望继续在这两个之间共享代码。
.NET 的下一个版本是在.NET 5+之上构建的,而.NET Framework 4.X 将保持现状,只接收安全补丁和较小更新。例如,.NET 8 是在.NET 7 之上构建的,迭代.NET 6 和 5。接下来,让我们看看一些工具和代码编辑器。
Visual Studio Code 与 Visual Studio 以及命令行界面
如何创建这样的项目?.NET Core 附带dotnet命令行界面(CLI),它公开了多个命令,包括new。在终端中运行dotnet new命令将生成一个新的项目。要创建一个空的类库,我们可以运行以下命令:
md MyProject
cd MyProject
dotnet new classlib
这将在新创建的 MyProject 目录中生成一个空白的类库。-h 选项有助于发现可用的命令及其选项。例如,你可以使用 dotnet -h 来查找可用的 SDK 命令,或者使用 dotnet new -h 来了解选项和可用的模板。.NET 现在拥有 dotnet CLI 真是太棒了。CLI 允许我们在本地开发或通过任何其他过程时,在持续集成(CI)管道中自动化我们的工作流程。CLI 还使得编写任何人都可以遵循的文档变得更加容易;在终端中输入几个命令要比安装像 Visual Studio 和模拟器这样的程序要容易和快得多。Visual Studio Code 是我最喜欢的文本编辑器。我并不经常用它来编写 .NET 代码,但我在需要重新组织项目时,或者在 CLI 时间,或者完成任何其他更适合使用文本编辑器完成的任务时,比如使用 Markdown 编写文档、编写 JavaScript 或 TypeScript,或者管理 JSON、YAML 或 XML 文件时,还是会使用它。要创建 C# 项目、Visual Studio 解决方案或使用 Visual Studio Code 添加 NuGet 包,请打开终端并使用 CLI。至于 Visual Studio,我最喜欢的 C# 集成开发环境,它使用 CLI 在底层创建相同的项目,使得工具之间保持一致,只是在 dotnet new CLI 命令之上添加了用户界面。你可以在 CLI 中创建和安装额外的 dotnet new 项目模板,甚至创建全局工具。你也可以使用你更喜欢的其他代码编辑器或 IDE。这些主题超出了本书的范围。
项目模板概述
这里是已安装的模板示例(dotnet new --list):

图 1.1:项目模板
对所有模板的研究超出了本书的范围,但我想要简要介绍一些值得注意的模板,其中一些我们将在后面使用:
-
dotnet new console创建一个控制台应用程序 -
dotnet new classlib创建一个类库 -
dotnet new xunit创建一个 xUnit 测试项目 -
dotnet new web创建一个空白的 Web 项目 -
dotnet new mvc搭建一个 MVC 应用程序 -
dotnet new webapi搭建一个 Web API 应用程序
运行和构建你的程序
如果你使用的是 Visual Studio,你可以始终点击播放按钮,或 F5,来运行你的应用程序。如果你使用的是 CLI,你可以使用以下命令(以及更多)。每个命令都提供了不同的选项来控制其行为。在任意命令中添加 -h 标志可以获取该命令的帮助信息,例如 dotnet build -h:
| 命令 | 描述 |
|---|---|
dotnet restore |
基于当前目录中存在的 .csproj 或 .sln 文件恢复依赖项(即 NuGet 包)。 |
dotnet build |
基于当前目录中存在的 .csproj 或 .sln 文件构建应用程序。它隐式地首先运行 restore 命令。 |
dotnet run |
基于当前目录中存在的 .csproj 文件运行当前应用程序。它隐式地首先运行 build 和 restore 命令。 |
dotnet watch run |
监视文件更改。当文件发生更改时,CLI 使用热重载功能更新该文件的代码。如果无法这样做,它将重新构建应用程序然后重新运行(相当于再次执行 run 命令)。如果是 Web 应用程序,页面应自动刷新。 |
dotnet test |
基于当前目录中存在的 .csproj 或 .sln 文件运行测试。它隐式地首先运行 build 和 restore 命令。我们将在下一章介绍测试。 |
dotnet watch test |
监视文件更改。当文件发生更改时,CLI 重新运行测试(相当于再次执行 test 命令)。 |
dotnet publish |
基于当前目录中存在的 .csproj 或 .sln 文件将当前应用程序发布到目录或远程位置,例如托管提供商。它隐式地首先运行 build 和 restore 命令。 |
dotnet pack |
基于当前目录中存在的 .csproj 或 .sln 文件创建一个 NuGet 包。它隐式地首先运行 build 和 restore 命令。您不需要 .nuspec 文件。 |
dotnet clean |
根据当前目录中存在的 .csproj 或 .sln 文件清理项目或解决方案的构建输出。 |
技术要求
在整本书中,我们将探索并编写代码。我建议安装 Visual Studio、Visual Studio Code 或两者都安装,以帮助完成这项工作。我使用 Visual Studio 和 Visual Studio Code。其他替代方案包括 Visual Studio for Mac、Riders 或您选择的任何其他文本编辑器。除非您安装了包含 .NET SDK 的 Visual Studio,否则可能需要单独安装 SDK。SDK 包含我们之前探索的 CLI 以及运行和测试您的程序所需的构建工具。有关更多信息以及这些资源的链接,请查看 GitHub 仓库中的 README.md 文件。所有章节的源代码都可以在以下地址下载:adpg.link/net6。
摘要
本章探讨了设计模式、反模式和代码异味。我们还探索了其中的一些。然后,我们回顾了典型 Web 应用程序的请求/响应周期。接着,我们继续探索 .NET 的基本知识,例如 SDK 与运行时以及应用程序目标与 .NET Standard。然后,我们更深入地研究了 .NET CLI,其中我列出了一些基本命令,包括 dotnet build 和 dotnet watch run。我们还介绍了如何创建新项目。这使我们能够探索在构建我们的 .NET 应用程序时具有的不同可能性。在接下来的两章中,我们将探讨自动化测试和架构原则。这些是构建健壮、灵活和可维护应用程序的基础章节。
问题
让我们看看一些练习题:
-
我们能否给一个
GET请求添加一个主体? -
为什么长方法是代码异味?
-
创建库时,.NET Standard 是否应该是你的默认目标?
-
代码异味是什么?
进一步阅读
这里有一些链接,可以帮助巩固本章学到的内容:
-
.NET 版本概述:
adpg.link/n52L -
.NET CLI 概述:
adpg.link/Lzx3 -
dotnetnew的定制模板:adpg.link/74i2 -
ASP.NET Core 中的会话和状态管理:
adpg.link/Xzgf
第二章:2 自动化测试
在开始之前:加入我们的 Discord 书籍社区
直接向作者本人提供反馈,并在我们的 Discord 服务器上与其他早期读者聊天(在“architecting-aspnet-core-apps-3e”频道下找到“EARLY ACCESS SUBSCRIPTION”)。

本章重点介绍自动化测试及其在构建更好软件方面的帮助。它还涵盖了几种不同类型的测试和测试驱动开发(TDD)的基础。我们还概述了 ASP.NET Core 的可测试性以及测试 ASP.NET Core 应用程序比测试旧版 ASP.NET MVC 应用程序容易多少。本章概述了自动化测试、其原则、xUnit、采样测试值的方法等。虽然其他书籍对此主题有更深入的探讨,但本章涵盖了自动化测试的基础方面。我们在整本书中使用了这些内容,本章确保你有一个足够坚实的基础来理解示例。在本章中,我们涵盖了以下主题:
-
自动化测试概述
-
测试 .NET 应用程序
-
重要的测试原则
自动化测试简介
测试是开发过程中的一个重要部分,自动化测试在长期来看变得至关重要。你总是可以运行你的 ASP.NET Core 网站,打开浏览器,点击各个地方来测试你的功能。这是一个合法的方法,但这样测试单个规则或更复杂的算法会更困难。另一个缺点是缺乏自动化;当你刚开始使用包含几个页面、端点或功能的小型应用程序时,手动执行这些测试可能很快。然而,随着你的应用程序增长,这变得更加繁琐,耗时更长,并且增加了犯错的几率。当然,你总是需要真实用户来测试你的应用程序,但你希望这些测试专注于 UX、内容或你正在构建的一些实验性功能,而不是自动化测试本可以早期捕获的错误报告。测试领域有多种测试类型和技术。以下是一个列表,列出了三个广泛的类别,代表了我们可以从代码正确性角度如何划分自动化测试:
-
单元测试
-
集成测试
-
端到端(E2E)测试
通常,你希望有一系列的测试,包括快速的单元测试来测试你的算法,较慢的测试来确保组件之间的集成正确,以及慢速的端到端测试来确保整个系统的正确性。测试金字塔是一种很好的方式来解释自动化测试的一些概念。你希望根据测试的复杂性和执行速度的不同,拥有不同粒度的测试和不同数量的测试。以下测试金字塔展示了上述三种类型的测试。然而,我们也可以在其中添加其他类型的测试。此外,这只是一个抽象的指导方针,给你一个大致的概念。最重要的方面是投资回报率(ROI)和执行速度。如果你能写一个覆盖范围广且足够快的集成测试,这可能比多个单元测试更有价值。

图 2.1:测试金字塔
我无法强调这一点;测试的执行速度对于快速获得反馈并立即知道你的代码更改破坏了什么至关重要。分层不同类型的测试允许你经常只执行最快的子集,偶尔执行不那么快的测试,以及很少执行非常慢的测试。如果你的测试套件足够快,你甚至不必担心它。然而,如果你有很多需要数小时运行的手动或端到端 UI 测试,那又是另一回事(这可能花费大量资金)。
最后,除了使用测试运行器运行测试,如 Visual Studio、VS Code 或 CLI 之外,确保代码质量并利用自动化测试的一个好方法是,在 CI 管道中运行它们,验证代码更改以发现问题。从技术角度来看,当.NET Core 处于预发布阶段时,我发现.NET 团队正在使用 xUnit 来测试他们的代码,并且它是唯一可用的测试框架。自从那时起,xUnit 就成了我最喜欢的测试框架,我们在整本书中都使用了它。此外,ASP.NET Core 团队通过为可测试性设计 ASP.NET Core,使我们的生活变得更简单;测试比以前更容易了。为什么在一本架构书中要讨论测试?因为可测试性是良好设计的标志。它还允许我们用测试而不是用文字来证明一些概念。在许多代码示例中,测试用例是消费者,这使得程序更轻量,无需构建整个用户界面,而是专注于我们正在探索的模式,而不是让我们的注意力分散在一些样板 UI 代码上。
为了确保我们不偏离主题,我们在书中适度地使用自动化测试,但我强烈建议你继续学习它,因为它将有助于提高你的代码和设计质量。
现在我们已经涵盖了所有这些,让我们来探索这三种类型的测试,从单元测试开始。
单元测试
单元测试关注于单个单元,比如测试一个方法的结果。单元测试应该是快速的,并且不依赖于任何基础设施,例如数据库。这些是你最想要的测试类型,因为它们运行速度快,每个测试都精确地测试一个代码路径。它们还应该帮助你更好地设计应用程序,因为你在测试中使用了你的代码,所以你成为了它的第一个消费者,这有助于你发现一些设计缺陷,并使你的代码更加完善。如果你不喜欢在测试中使用你的代码,那是一个很好的迹象,表明其他人也不会这样做。单元测试应该专注于测试算法(输入和输出)和领域逻辑,而不是代码本身;你编写代码的方式不应该影响测试的意图。例如,你正在测试一个Purchase方法是否执行了购买一个或多个项目的逻辑,而不是测试你在这个方法内部创建了变量X、Y或Z。
如果你发现这很有挑战性,请不要气馁;编写一个好的测试套件并不像听起来那么简单。
集成测试
集成测试关注于组件之间的交互,例如当组件查询数据库时会发生什么,或者当两个组件相互交互时会发生什么。集成测试通常需要一些基础设施来与之交互,这使得它们的运行速度较慢。按照经典的测试模型,你想要集成测试,但比单元测试要少。集成测试可以非常接近端到端测试,但不需要使用类似生产环境的测试环境。
我们稍后会打破测试金字塔规则,所以始终对规则和原则持批判态度;有时,打破或弯曲它们可能更好。例如,一个良好的集成测试可能比N个单元测试更有价值;在编写测试时不要忽视这个事实。另见灰盒测试。
端到端测试
端到端测试关注于应用程序的全局行为,例如当用户点击一个特定的按钮、导航到特定的页面、提交一个表单或向某个 Web API 端点发送一个PUT请求时会发生什么。端到端测试通常在基础设施上运行,以测试你的应用程序和部署。
其他类型的测试
有其他类型的自动化测试。例如,我们可以进行负载测试、性能测试、回归测试、契约测试、渗透测试、功能测试、冒烟测试等等。你可以自动化验证任何你想要验证的内容,但有些测试自动化起来更具挑战性,或者比其他测试更脆弱,例如 UI 测试。
如果你可以在合理的时间内自动化一个测试,考虑一下投资回报率:去做吧!从长远来看,这应该会带来回报。
还有一点;不要盲目依赖诸如代码覆盖率之类的指标。这些指标在你的 GitHub 项目的readme.md文件中可以成为可爱的徽章,但可能会让你偏离轨道,导致你编写无用的测试。请别误会,代码覆盖率是一个很好的指标,当正确使用时,但它记得一条好的测试胜过覆盖你代码库 100%的糟糕测试套件。如果你在使用代码覆盖率,确保你和你的团队没有在玩弄系统。编写好的测试并不容易,需要通过实践来掌握。
一条建议:通过添加缺失的测试用例和删除过时或无用的测试来保持你的测试套件健康。考虑用例覆盖率,而不是你的测试覆盖了多少行代码。
在继续测试样式之前,让我们先检查一个假设的系统,并探索一种更有效的方式来测试它。
选择合适的测试风格
接下来是一个假设系统的依赖图。我们使用这张图来选择程序每个部分最具有意义的测试类型。在现实生活中,这张图很可能会在你的脑海中,但我在这个例子中把它画出来了。在我解释其内容之前,让我们先检查这张图:

图 2.2:假设系统的依赖图
在图中,Actor可以是用户或其他系统。Presentation是系统的一部分,Actor与之交互并将请求转发给系统本身(这可能是一个用户界面)。D1是一个组件,它必须根据用户输入决定下一步要做什么。C1到C6是系统的其他组件(例如,可以是类)。DB是一个数据库。D1 必须在三条代码路径中选择:与组件 C1、C4 或 C6 交互。这种逻辑通常是一个很好的单元测试主题,确保算法根据输入参数产生正确的结果。为什么选择单元测试?我们可以快速测试多个场景、边缘情况、越界数据情况等。我们通常在这个类型的测试中模拟依赖项,并断言被测试的主题在期望的组件上做出了预期的调用。然后,如果我们看看其他代码路径,我们可以为组件 C1 编写一个或多个集成测试,一次性测试整个链(C1、C5 和 C3),而不是为每个组件编写多个重量级的模拟单元测试。如果需要在组件 C1、C5 或 C3 中测试任何逻辑,我们总是可以添加一些单元测试;这就是它们的作用。最后,C4 和 C6 都使用 C2。根据代码(我们这里没有),我们可以为 C4 和 C6 编写集成测试,同时测试 C2。另一种方法是单元测试 C4 和 C6,然后编写 C2 和数据库之间的集成测试。如果 C2 没有逻辑,后者可能是最好和最快的,而前者很可能会在持续交付模型中给出更有信心测试套件的结果。当有这个选项时,我建议评估编写更少的具有意义的集成测试的可能性,这些测试断言用例套件中用例的正确性,而不是编写大量的重量级模拟单元测试。记住,始终要考虑执行速度。这看起来可能“违反”了测试金字塔,但它吗?如果你花更少的时间(从而降低成本)测试更多的用例(增加更多的价值),对我来说这似乎是一个胜利。此外,我们不应忘记,模拟依赖项往往会让你浪费时间与框架或其他库作斗争,而不是测试有意义的东西,这可能会随着时间的推移增加维护成本。现在我们已经探讨了自动化测试的基础,是时候探索测试方法和 TDD 了,这是一种应用这些测试概念的方法。
测试方法
测试有多种方法,例如 行为驱动开发(BDD)、验收测试驱动开发(ATDD)和测试驱动开发(TDD)。DevOps 文化带来了一种心态,它拥抱与 持续集成(CI)和 持续部署(CD)理念一致的自动化测试。我们可以通过一个强大且健康的测试套件来实现 CD,这为我们提供了对代码的高度信心,足够高以至于在所有测试通过时可以部署程序,而不必担心引入错误。
TDD
TDD 是一种软件开发方法,它指出你应该在编写实际代码之前编写一个或多个测试。简而言之,你通过遵循 红-绿-重构 技术来反转你的开发流程,其过程如下:
-
你编写一个失败的测试(红色)。
-
你只需编写足够的代码来使你的测试通过(绿色)。
-
你重构这段代码,通过确保所有测试通过来改进设计。
我们将在下一节探讨 重构 的含义。
ATDD
ATDD 与 TDD 类似,但侧重于验收(或功能)测试,而不是软件单元,并涉及多个参与者,如客户、开发人员和测试人员。
BDD
BDD 是源自 TDD 和 ATDD 的另一种补充技术。BDD 侧重于使用口语化语言来制定围绕应用程序行为的测试用例,并涉及多个参与者,如客户、开发人员和测试人员。此外,BDD 实践者经常利用 给定-当-然后 语法来规范化他们的测试用例。正因为如此,BDD 的输出以人类可读的格式呈现,允许利益相关者查阅此类工件。给定-当-然后模板定义了描述用户故事或验收测试行为的方式,如下所示:
-
给定 一个或多个先决条件(上下文)
-
当 发生某些事情(行为)
-
然后 预期一个或多个可观察的变化(可衡量的副作用)
ATDD 和 BDD 是深入挖掘的绝佳领域,可以帮助设计更好的应用程序;定义精确的用户中心规格可以帮助构建所需的内容,更好地进行优先级排序,并改善各方之间的沟通。为了简单起见,我们在书中坚持单元测试、集成测试和一点 TDD。不过,让我们回到主要轨道上,并定义重构。
重构
重构是关于(持续)改进代码而不改变其行为。一个自动化的测试套件应该帮助你实现这一目标,并帮助你发现何时破坏了某些东西。无论你是否做 TDD,我都建议尽可能多地重构;这有助于清理你的代码库,同时也有助于你消除一些技术债务。好的,但什么是 技术债务?
技术债务
技术债务代表在开发功能或系统时你省略的角落。无论你多么努力,这都是不可避免的,因为生活就是生活,会有延误、截止日期、预算和人员,包括开发者(是的,那也包括我和你)。最关键的是理解你无法完全避免技术债务,所以最好是接受这个事实,学会与之共存而不是与之抗争。从那时起,你只能尝试限制你或其他人产生的技术债务量,并确保在每个冲刺(或适合你项目/团队/流程的时间单位)中始终重构其中的一部分。限制技术债务积累的一种方法就是经常重构代码。因此,将重构时间纳入你的时间估算中。另一种方法是提高所有相关方的协作。如果你想使你的项目成功,每个人都必须朝着同一个目标努力。有时,由于外部因素如人员或时间限制,你可能会缩短最佳实践的运用。关键是尽快回来偿还那笔技术债务,而自动化测试正是帮助你重构代码和优雅地消除债务的工具。根据你工作场所的大小,你与那个决策之间的人数会有多或少。
其中一些事情可能超出了你的控制范围,所以你可能不得不忍受比你希望更多的技术债务。然而,即使事情超出了你的控制范围,也没有什么能阻止你成为先驱并致力于改善企业的文化。不要害怕成为变革的代理人并带头。
然而,不要让技术债务积累得太高,否则你可能无法偿还,到某个时候,项目就开始破裂和失败。不要误解;一个在生产中的项目可能就是失败的。交付产品并不能保证成功,我在这里谈论的是代码的质量,而不是产生的收入(我将那留给其他人来评估)。接下来,我们来看看不同的测试编写方式,这需要更多或更少的对代码内部工作的了解。
测试技术
在这里,我们来看看不同的测试方法。我们应该了解代码吗?我们应该测试用户输入并将其与系统结果进行比较吗?如何确定合适的值样本?让我们从白盒测试开始。
白盒测试
白盒测试是一种软件测试技术,它使用对软件内部结构的了解来设计测试。我们可以使用白盒测试来发现软件逻辑、数据结构和算法中的缺陷。
这种测试也被称为清晰盒测试、开放盒测试、透明盒测试、玻璃盒测试和基于代码的测试。
白盒测试的另一个好处是它可以帮助优化代码。通过审查代码来编写测试,开发者可以识别并改进低效的代码结构,从而提高整体软件性能。开发者还可以在测试代码时发现架构问题,从而改进应用程序设计。
白盒测试涵盖了大多数单元和集成测试。
接下来,我们来看黑盒测试,它是白盒测试的对立面。
黑盒测试
黑盒测试是一种软件测试方法,测试人员检查应用程序的功能,而不了解其内部结构或实现细节。这种测试形式仅关注受测系统的输入和输出,将软件视为一个“黑盒”,我们无法窥视。黑盒测试的主要目标是根据需求或用户故事评估系统行为是否符合预期结果。编写测试的开发者不需要了解代码库或用于构建软件的技术栈。我们可以使用黑盒测试来评估多种类型的需求的正确性,例如:
-
功能测试:这种测试与软件的功能需求相关,强调系统做什么,即行为验证。
-
非功能性测试:这种测试与非功能性需求相关,如性能、可用性、可靠性和安全性,即性能评估。
-
回归测试:这种测试确保新代码不会破坏现有功能,即变更影响。
接下来,让我们探索白盒测试和黑盒测试之间的混合技术。
灰盒测试
灰盒测试是白盒测试和黑盒测试的结合。测试人员只需要了解应用内部工作的一部分,并使用软件的内部结构和外部行为相结合来构建他们的测试。我们在第十六章中实现了灰盒测试用例,即请求-端点-响应(REPR)。同时,让我们比较一下这三种技术。
白盒测试与黑盒测试及灰盒测试的比较
为了进行简洁的比较,这里有一个表格,比较了三种主要技术:
| 特性 | 白盒测试 | 黑盒测试 | 灰盒测试 |
|---|---|---|---|
| 定义 | 基于软件内部设计的测试 | 基于软件行为和功能的测试 | 结合软件内部设计和行为的测试 |
| 是否需要了解代码 | 是 | 否 | 是 |
| 发现的缺陷类型 | 逻辑、数据结构、架构和性能问题 | 功能性、可用性、性能和安全问题 | 大多数类型的问题 |
| 每个测试的覆盖率 | 小;针对单元 | 大;针对用例 | 大;范围可变 |
| 测试人员 | 通常由开发者执行。 | 测试人员可以编写测试,而无需了解应用程序内部结构的特定技术知识。 | 开发者可以编写测试,同时测试人员也可以在了解一些代码知识的情况下进行。 |
| 何时使用每种风格? | 编写单元测试以验证复杂算法或基于许多输入产生多个结果的代码。这些测试通常运行速度很快,因此你可以有很多这样的测试。 | 如果你有特定的场景想要测试,比如 UI 测试,或者如果你的组织中将测试人员和开发者视为两个不同的角色,那么就编写测试。这些测试通常运行得最慢,需要你部署应用程序以进行测试。你希望尽可能少地编写测试,以改善反馈时间。 | 编写测试以避免编写黑盒或白盒测试。分层测试以尽可能少地使用测试覆盖尽可能多的内容。根据应用程序的架构,这种类型的测试可以为许多场景产生最佳结果。 |
让我们总结一下,并探讨每种技术的优缺点。
结论
白盒测试包括单元测试和集成测试。这些测试运行速度快,开发者使用它们来改进代码和测试复杂算法。然而,编写大量此类测试需要花费时间。由于与代码的紧密耦合,编写与代码本身紧密耦合的脆弱测试更容易,这增加了此类测试套件的维护成本。这也使得应用在测试可访问性的名义下过度工程化。黑盒测试涵盖了不同类型的测试,这些测试倾向于端到端测试。由于测试针对系统的外部表面,当系统发生变化时,它们不太可能中断。此外,它们在测试行为方面非常出色,并且由于每个测试都测试一个端到端用例,因此我们需要更少的测试,这导致编写时间和维护成本降低。测试整个系统有缺点,包括执行每个测试的缓慢,因此将黑盒测试与其他类型的测试相结合非常重要,以找到测试数量、测试用例覆盖率和测试执行速度之间的正确平衡。灰盒测试是两种其他测试的绝佳混合;你可以将软件的任何部分视为黑盒,利用你的内部知识来模拟或存根测试用例的部分(例如,断言系统是否在数据库中持久化记录),并更有效地测试端到端场景。它带来了两者的最佳之处,在显著减少测试数量的同时,大大增加了每个测试用例的测试面。然而,在较小的单元上进行灰盒测试或过度模拟系统可能会产生与白盒测试相同的缺点。集成测试或几乎端到端测试是灰盒测试的良好候选者。我们在第十六章,请求-端点-响应(REPR)中实现了灰盒测试用例。同时,让我们探索一些技术,通过应用不同的技术,如测试一小部分值以通过编写最优数量的测试来断言程序的正确性,来帮助优化我们的测试用例创建。
测试用例创建
存在多种方法可以将测试用例分解和创建,以帮助以最小的测试数量找到软件缺陷。以下是一些技术,可以帮助最小化测试数量同时最大化测试覆盖率:
-
等价类划分
-
边界值分析
-
决策表测试
-
状态转换测试
-
用例测试
我从理论上介绍这些技术。它们适用于所有类型的测试,并应有助于你编写更好的测试套件。让我们快速看一下每个技术。
等价类划分
这种技术将软件的输入数据划分为不同的等价数据类,然后对这些类进行测试,而不是对单个输入进行测试。等价数据类意味着该分区集中的所有值都应该导致相同的结果或产生相同的结果。这样做可以显著减少测试的数量。例如,考虑一个接受介于 1 到 100(包含)之间的整数值的应用程序。使用等价分区,我们可以将输入数据划分为两个等价类:
-
有效
-
无效
为了更精确,我们可以进一步将其划分为三个等价类:
-
第 1 类:小于 1(无效)
-
第 2 类:介于 1 和 100 之间(有效)
-
第 3 类:大于 100(无效)
然后,我们可以编写三个测试,从每个类别中选取一个代表(例如,0、50 和 101)来创建我们的测试用例。这样做确保了广泛的覆盖范围,同时测试用例数量最少,使我们的测试过程更加高效。
边界值分析
这种技术侧重于输入域边界的值,而不是中心。这种技术基于这样一个原则,即错误最有可能发生在输入域的边界。输入域代表系统所有可能的输入集合。边界是输入域的边缘,代表最小和最大值。例如,如果我们期望一个函数接受介于 1 到 100(包含)之间的整数,边界值将是 1 和 100。使用边界值分析,我们会为这些值、边界外的值(如 0 和 101)以及边界内的值(如 2 和 99)创建测试用例。边界值分析是一种非常高效的测试技术,它以相对较少的测试用例提供了良好的覆盖范围。然而,它不适合查找边界内的错误或复杂逻辑错误。边界值分析应该在等价分区和决策表测试等其他测试方法之上使用,以确保软件尽可能无缺陷。
决策表测试
这种技术使用决策表来设计测试用例。决策表是一个显示所有可能的输入值组合及其对应输出的表格。对于可以用表格格式表示的复杂业务规则来说,它非常方便,使测试人员能够识别缺失和多余的测试用例。例如,我们的系统只允许具有有效用户名和密码的用户访问。此外,当系统处于维护状态时,系统拒绝用户访问。决策表将包含三个条件(用户名、密码和维护)和一个动作(允许访问)。表格将列出这些条件的所有可能组合以及每个组合的预期动作。以下是一个示例:
| 有效用户名 | 有效密码 | 系统处于维护状态 | 允许访问 |
|---|---|---|---|
| 是 | 是 | 否 | 是 |
| 是 | 是 | 是 | 否 |
| 正确 | 错误 | 错误 | 否 |
| 正确 | 错误 | 正确 | 否 |
| 错误 | 正确 | 错误 | 否 |
| 错误 | 正确 | 正确 | 否 |
| 错误 | 错误 | 错误 | 否 |
| 错误 | 错误 | 正确 | 否 |
决策表测试的主要优势在于它确保我们测试了所有可能的输入组合。然而,当系统具有许多输入条件时,它可能会变得复杂且难以管理,因为规则的数量(因此是测试用例的数量)会随着条件的数量呈指数增长。
状态转换测试
我们通常使用状态转换测试来测试具有状态机的软件,因为它测试了不同的系统状态及其转换。这对于系统行为可以根据其当前状态改变的情况非常有用。例如,具有“已登录”或“已登出”等状态的程序。为了执行状态转换测试,我们需要确定系统的状态以及状态之间的可能转换。对于每个转换,我们需要创建一个测试用例。测试用例应该使用指定的输入值测试软件,并验证软件是否转换到正确的状态。例如,处于“已登录”状态的用户在登出后必须转换到“已登出”状态。状态转换测试的主要优势在于它测试了事件序列,而不仅仅是单个事件,这可能会揭示在单独测试每个事件时未发现的缺陷。然而,对于具有许多状态和转换的系统,状态转换测试可能会变得复杂且耗时。
用例测试
这种技术通过验证系统在用户以特定方式使用时是否按预期行为来验证系统。用例可以具有正式的描述,可以是用户故事,或者采取任何适合您需求的其他形式。用例涉及一个或多个参与者执行步骤或采取应产生特定结果的操作。用例可以包括输入和预期输出。例如,当“已登录”状态的用户(参与者)点击“登出”按钮(操作),然后导航到个人资料页面(操作)时,系统拒绝访问页面并将用户重定向到登录页面,显示错误消息(预期行为)。用例测试是一种系统化和结构化的测试方法,有助于识别软件功能中的缺陷。它非常以用户为中心,确保软件满足用户的需求。然而,为复杂的用例创建测试用例可能会很困难。在用户界面的情况下,执行用例端到端测试的时间可能会很长,尤其是随着测试数量的增加。
将您的测试用例视为要测试的功能,无论是使用正式的用例还是只是在餐巾纸上写的一行字,都是一个很好的思考方式。关键是测试行为,而不是代码。
现在我们已经探讨了这些技术,是时候介绍 xUnit 库、编写测试的方法以及书中如何编写测试了。让我们先创建一个测试项目。
如何创建 xUnit 测试项目
要创建一个新的 xUnit 测试项目,你可以运行 dotnet new xunit 命令,CLI 会为你创建一个包含 UnitTest1 类的项目。这个命令与从 Visual Studio 创建一个新的 xUnit 项目做的是同样的事情。对于单元测试项目,将项目命名为你想要测试的项目名称,并在其后添加 .Tests。例如,MyProject 将会有一个与之关联的 MyProject.Tests 项目。我们将在下面的 组织你的测试 部分中探讨更多细节。模板已经定义了所有必需的 NuGet 包,因此你可以在将测试项目添加到你的测试项目后立即开始测试。
你也可以使用 CLI 的
dotnet add reference命令添加项目引用。假设我们位于./test/MyProject.Tests目录,并且我们想要引用的项目文件位于./src/MyProject目录;我们可以执行以下命令来添加引用:
dotnet add reference ../../src/MyProject.csproj.
接下来,我们将探讨一些 xUnit 特性,这将使我们能够编写测试用例。
关键的 xUnit 特性
在 xUnit 中,[Fact] 属性是用来创建独特测试用例的方式,而 [Theory] 属性是用来创建数据驱动测试用例的方式。让我们从事实开始,这是编写测试用例最简单的方式。
事实
任何没有参数的方法都可以通过添加 [Fact] 属性来成为一个测试方法,就像这样:
public class FactTest
{
[Fact]
public void Should_be_equal()
{
var expectedValue = 2;
var actualValue = 2;
Assert.Equal(expectedValue, actualValue);
}
}
当测试代码需要时,你还可以用事实属性装饰异步方法:
public class AsyncFactTest
{
[Fact]
public async Task Should_be_equal()
{
var expectedValue = 2;
var actualValue = 2;
await Task.Yield();
Assert.Equal(expectedValue, actualValue);
}
}
在前面的代码中,高亮显示的行在概念上表示一个异步操作,它所做的只是允许使用 async/await 关键字。当我们从 Visual Studio 的测试资源管理器运行测试时,测试运行结果看起来像这样:

图 2.3:Visual Studio 中的测试结果
你可能已经从截图注意到测试类嵌套在 xUnitFeaturesTest 类中,它是 MyApp 命名空间的一部分,并且位于 MyApp.Tests 项目下。我们将在本章后面探讨这些细节。运行 dotnet test CLI 命令应该会产生类似于以下的结果:
Passed! - Failed: 0, Passed: 23, Skipped: 0, Total: 23, Duration: 22 ms - MyApp.Tests.dll (net8.0)
从前面的输出中我们可以看出,所有测试都通过了,没有失败的,也没有跳过的。使用 xUnit 创建测试用例就这么简单。
学习 CLI 对于创建和调试 CI/CD 管道非常有帮助,你可以在任何脚本(如 bash 和 PowerShell)中使用它们,就像使用
dotnet test命令一样。
你有没有注意到测试代码中的 Assert 关键字?如果你不熟悉它,我们将在下一节探讨断言。
断言
断言是一种检查特定条件是否为 true 或 false 的声明。如果条件为 true,则测试通过。如果条件为 false,则测试失败,表明被测试的主题存在问题。让我们探讨一些断言正确性的方法。在本节中,我们使用基本的 xUnit 功能,但如果你有选择的断言库,你也可以引入。
在 xUnit 中,断言在失败时抛出异常,但你可能甚至都没有意识到这一点。你不必处理这些异常;这是将失败结果传播到测试运行器的机制。
我们不会探索所有可能性,但让我们从以下共享部分开始:
public class AssertionTest
{
[Fact]
public void Exploring_xUnit_assertions()
{
object obj1 = new MyClass { Name = "Object 1" };
object obj2 = new MyClass { Name = "Object 1" };
object obj3 = obj1;
object? obj4 = default(MyClass);
//
// Omitted assertions
//
static void OperationThatThrows(string name)
{
throw new SomeCustomException { Name = name };
}
}
private record class MyClass
{
public string? Name { get; set; }
}
private class SomeCustomException : Exception
{
public string? Name { get; set; }
}
}
前面的两个记录类、OperationThatThrows 方法和变量是测试中使用的实用工具,帮助我们与 xUnit 断言互动。出于探索目的,变量是 object 类型,但你可以在测试用例中使用任何类型。我省略了即将看到的断言代码,以使代码更简洁。以下两个断言非常明确:
Assert.Equal(expected: 2, actual: 2);
Assert.NotEqual(expected: 2, actual: 1);
第一个比较实际值是否等于预期值,而第二个比较两个值是否不同。Assert.Equal 可能是使用最广泛的断言方法。
作为一条经验法则,断言相等(
Equal)比断言值不同(NotEqual)更好。除了少数罕见的情况外,断言相等会产生更一致的结果,并关闭遗漏缺陷的大门。
接下来的两个断言与相等断言非常相似,但断言对象是否是同一个实例或不是(同一个实例意味着相同的引用):
Assert.Same(obj1, obj3);
Assert.NotSame(obj2, obj3);
下一个断言验证两个对象是否相等。由于我们使用的是记录类,这使我们变得非常容易;obj1 和 obj2 不是同一个实例(两个实例),但它们是相等的(有关记录类的更多信息,请参阅 附录 A):
Assert.Equal(obj1, obj2);
下面的两个断言非常相似,断言值是否为 null 或不是:
Assert.Null(obj4);
Assert.NotNull(obj3);
下一个断言检查 obj1 是否为 MyClass 类型,然后返回将参数(obj1)转换为断言类型(MyClass)的参数。如果类型不正确,IsType 方法将抛出异常:
var instanceOfMyClass = Assert.IsType<MyClass>(obj1);
然后我们重用 Assert.Equal 方法来验证 Name 属性的值是否符合我们的预期:
Assert.Equal(expected: "Object 1", actual: instanceOfMyClass.Name);
以下代码块断言 testCode 参数抛出 SomeCustomException 类型的异常:
var exception = Assert.Throws<SomeCustomException>(
testCode: () => OperationThatThrows("Toto")
);
testCode 参数执行我们最初看到的 OperationThatThrows 内联函数。Throws 方法允许我们通过返回指定类型的异常来测试一些异常属性。这里发生的行为与 IsType 方法相同;如果异常类型不正确或未抛出异常,Throws 方法将使测试失败。
确保不仅抛出了正确的异常类型,而且异常还携带了正确的值,这是一个好主意。
以下行断言 Name 属性的值是我们期望的,确保我们的程序会传播正确的异常:
Assert.Equal(expected: "Toto", actual: exception.Name);
我们介绍了一些断言方法,但 xUnit 中还有许多其他方法,如 Collection、Contains、False 和 True 方法。我们在整本书中使用了许多断言,所以如果这些仍然不清楚,你将了解更多关于它们的信息。接下来,让我们看看使用理论的驱动数据测试案例。
理论
对于更复杂的测试案例,我们可以使用理论。一个理论包含两个部分:
-
一个标记方法为理论的
[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);
}
}
那个测试方法在测试资源管理器中产生了三个测试案例,每个案例可以单独通过或失败。当然,由于 1 等于 1,2 等于 2,5 等于 5,所有三个测试案例都通过了,如下所示:

图 2.4:内联数据理论测试结果
我们还可以使用 [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 个结果。如果我们分解它,代码首先通过使用 [MemberData(nameof(Data))] 属性装饰测试方法来从 Data 属性加载三组数据。这就是如何从测试方法声明的类成员中加载数据。然后,第二个属性与 Data 属性非常相似,但将 IEnumerable<object[]> 替换为 TheoryData<…> 类,使其更易于阅读和类型安全。像第一个属性一样,我们通过使用 [MemberData(nameof(TypedData))] 属性装饰它来将这三组数据提供给测试方法。再次强调,它也是测试类的一部分。
我强烈建议默认使用
TheoryData<…>。
第三个数据将三组更多数据传递给测试方法。然而,这些数据源自 ExternalData 类的 GetData 方法,在执行过程中传递 10 作为参数(即 start 参数)。为了做到这一点,我们必须指定方法所在的位置的 MemberType 实例,以便 xUnit 知道在哪里查找。在这种情况下,我们将参数 10 作为 MemberData 构造函数的第二个参数传递。然而,在其他情况下,你可以传递零个或多个参数。最后,我们对 ExternalData.TypedData 属性也做了同样的处理,该属性由 [MemberData(nameof(ExternalData.TypedData), MemberType = typeof(ExternalData))] 属性表示。再次强调,唯一的区别是属性使用 TheoryData 而不是 IEnumerable<object[]> 来定义,这使得其意图更加明确。在运行测试时,由 [MemberData] 属性提供的数据被组合,在测试资源管理器中产生以下结果:

图 2.5:成员数据理论测试结果
这些只是我们使用 [MemberData] 属性所能做到的一小部分示例。
我明白这有很多浓缩的信息,但目标是涵盖足够的内容以帮助你入门。我不期望你通过阅读这一章就能成为 xUnit 的专家。
最后但同样重要的是,[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] 非常相似,但我们指向的是类型而不是成员。在 TheoryDataClass 中,实现 IEnumerable<object[]> 接口使得轻松 yield return 结果变得容易。另一方面,在 TheoryTypedDataClass 类中,通过继承 TheoryData,我们可以利用类似列表的 Add 方法。再次强调,我发现从 TheoryData 继承更明确,但无论如何,它们都可以与 xUnit 一起工作。你有许多选择,所以请选择最适合你用例的最佳选项。以下是测试资源管理器中的结果,与其它属性非常相似:

图 2.6:测试资源管理器
理论部分就到这里——接下来,在组织我们的测试之前,还有一些最后的话要说。
结束语
现在事实、理论和断言都已解决,xUnit 提供了其他机制,允许开发者在测试类中注入依赖项。这些被称为固定装置(Fixtures)。固定装置通过实现 IClassFixture<T> 接口,允许所有测试方法的测试类重用依赖项。固定装置对于昂贵的依赖项,如创建内存数据库,非常有帮助。有了固定装置,你可以创建依赖项一次,并多次使用它。《MyApp.IntegrationTests》项目中的 ValuesControllerTest 类展示了这一点。重要的是要注意,xUnit 为每次测试运行创建测试类的实例,所以如果你不使用固定装置,你的依赖项每次都会被重新创建。你还可以通过使用 ICollectionFixture<T>、[Collection] 和 [CollectionDefinition] 在多个测试类之间共享固定装置提供的依赖项。我们不会在这里深入细节,但至少你知道这是可能的,并且当你需要类似的东西时,你知道要查找哪些类型。最后,如果你使用过其他测试框架,你可能遇到过设置和清理方法。在 xUnit 中,没有特定的属性或机制来处理设置和清理代码。相反,xUnit 使用现有的面向对象(OOP)概念:
-
要设置你的测试,请使用类构造函数。
-
为了拆解(清理)你的测试,实现
IDisposable或IAsyncDisposable并在那里释放你的资源。
就这样,xUnit 非常简单且强大,这就是为什么我在几年前将其作为我的主要测试框架,并选择它作为这本书的测试框架。接下来,我们将学习如何编写可读的测试方法。
安排(Arrange)、行动(Act)、断言(Assert)
安排(Arrange)、行动(Act)、断言(Assert)(AAA 或 3A)是编写可读测试的知名方法。这种技术允许你清楚地定义你的设置(安排)、被测试的操作(行动)以及你的断言(断言)。使用这种技术的有效方法之一是首先在测试用例中以注释的形式编写 3A,然后在这之间编写测试代码。以下是一个示例:
[Fact]
public void Should_be_equals()
{
// Arrange
var a = 1;
var b = 2;
var expectedResult = 3;
// Act
var result = a + b;
// Assert
Assert.Equal(expectedResult, result);
}
当然,那个测试用例不能失败,但三个块可以通过 3A 注释轻松识别。一般来说,你希望你的单元测试的 Act 块只有一行,这样测试焦点就清晰了。如果你需要多于一行,那么测试或设计中可能存在问题。
当测试非常小(只有几行)时,删除注释可能有助于可读性。此外,当你不需要在测试用例中进行任何设置时,删除 Arrange 注释可以进一步提高其可读性。
接下来,我们将学习如何将测试组织到项目中、目录和文件中。
组织你的测试
在解决方案内部组织测试项目有许多方法,我倾向于为解决方案中的每个项目创建一个单元测试项目,以及一个或多个集成测试项目。单元测试直接关联到单个代码单元,无论是方法还是类。将单元测试项目与其相应的代码项目(程序集)关联起来非常直接,从而形成一对一的关系。每个程序集一个单元测试项目使它们易于携带、易于导航,当解决方案规模扩大时更是如此。
如果你有一个与我们书中所做不同的组织方式,请务必使用那种方法。
另一方面,集成测试可以跨越多个项目,因此制定一个适用于所有场景的单一规则具有挑战性。通常情况下,每个解决方案一个集成测试项目就足够了。有时,根据上下文,我们可能需要不止一个。
我建议从创建一个集成测试项目开始,并在开发过程中根据需要添加更多,而不是在开始之前过度思考。相信你的判断;你总是可以根据项目的发展改变结构。
在解决方案级别按文件夹创建应用及其相关库到src目录中,有助于将实际解决方案代码与在test目录下创建的测试项目隔离开来,如下所示:

图 2.7:自动测试解决方案资源管理器,显示项目组织方式
这是在.NET 世界中组织解决方案的一个众所周知且有效的方法。
有时,这样做可能不可行或不希望这么做。一个这样的用例是在单个解决方案下编写多个微服务。在这种情况下,你可能希望测试与微服务更接近,而不是在
src和test文件夹之间分割。因此,你可以按微服务组织解决方案,例如每个微服务一个目录,包含所有项目,包括测试。
现在我们来深入探讨单元测试的组织。
单元测试
你如何组织测试项目可能会在搜索测试或使其易于找到之间产生很大差异。让我们看看不同的方面,从命名空间到测试代码本身。
命名空间
我发现当创建单元测试时,在测试项目中创建与被测试主题相同的命名空间很方便。这有助于使测试和代码对齐,而无需添加任何额外的using语句。为了在创建文件时更容易,你可以通过在测试项目文件的PropertyGroup(*.csproj)中添加<RootNamespace>[Project under test namespace]</RootNamespace>来更改 Visual Studio 创建新类时使用的默认命名空间,如下所示:
<PropertyGroup>
...
<RootNamespace>MyApp</RootNamespace>
</PropertyGroup>
测试类名称
按照惯例,我将测试类命名为[被测试类]Test.cs,并将它们创建在原始项目的同一目录下。遵循这个简单的规则,查找测试变得容易,因为测试代码与被测试代码在文件树中的位置相同,但属于两个不同的项目。

图 2.8:自动测试解决方案资源管理器,显示测试的组织方式
测试类内的测试代码
对于测试代码本身,我遵循一个类似于以下的多级结构:
-
一个测试类的名称与被测试类相同。
-
对于被测试类中的每个方法,创建一个嵌套测试类。
-
对于被测试方法的每个测试用例,创建一个测试方法。
这种技术通过测试用例组织测试,同时保持清晰的层次结构,导致以下层次结构:
-
被测试类
-
被测试的方法
-
使用该方法的测试用例
在代码中,这转化为以下:
namespace MyApp.IntegrationTests.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)
);
}
}
}
这个惯例允许你逐步设置测试。例如,通过从内部类(这里的Get嵌套类)继承外部类(ValuesControllerTest类),你可以创建顶级私有模拟或所有嵌套类和测试方法共享的类。然后,对于要测试的每个方法,你可以在嵌套类中修改设置或创建其他私有测试元素。最后,你可以在测试方法内部为每个测试用例进行更多配置(这里的Should_return_the_expected_strings方法)。
不要在测试类内部过度追求可复用性,因为这可能会使测试对外部观察者(如审阅者或需要在那里工作的其他开发者)更难阅读。单元测试应保持专注、小巧且易于阅读:一个代码单元测试另一个代码单元。过多的可复用性可能会导致脆弱的测试套件。
既然我们已经探讨了组织单元测试的方法,让我们来看看集成测试。
集成测试
集成测试的组织比较困难,因为它们依赖于多个单元,可以跨越项目边界,并与各种依赖项交互。对于大多数简单解决方案,我们可以创建一个集成测试项目;对于更复杂的场景,可能需要创建多个。在创建一个集成测试项目时,你可以将其命名为 IntegrationTests,或者以测试的入口点为起点,例如 REST API 项目,并命名为 [API 项目的名称].IntegrationTests。在这个阶段,如何命名集成测试项目取决于你的解决方案结构和意图。当你需要多个集成测试项目时,你可以遵循与单元测试类似的约定,将你的集成测试项目一对一地关联:[被测试的项目].IntegrationTests。在这些项目中,具体取决于你想要如何解决问题以及解决方案本身的架构。首先,确定要测试的功能。以模仿你的需求的方式来命名测试类,将这些组织到子文件夹中(可能是一个类别或一组需求),并将测试用例作为方法编写。你还可以利用嵌套类,就像我们在单元测试中做的那样。
我们在整本书中都编写了测试,所以如果你现在还不清楚,你将会有很多例子来理解所有这些内容。
接下来,我们通过利用 ASP.NET Core 的功能来实现一个集成测试。
编写 ASP.NET Core 集成测试
当微软从头开始构建 ASP.NET Core 时,他们修复和改进了许多事情,我无法在这里一一列举,包括可测试性。如今,有两种方式来构建 .NET 程序:
-
经典的 ASP.NET Core
Program和Startup类。这个模型可能在现有的项目中找到(在 .NET 6 之前创建的项目)。 -
.NET 6 中引入的最小托管模型。如果你了解 Node.js,这可能会让你感到熟悉,因为这个模型鼓励你在 Program.cs 文件中通过使用顶层语句来编写启动代码。你很可能会在新的项目中找到这个模型(在 .NET 6 发布之后创建的项目)。
无论你如何编写程序,那里都是定义应用程序的组成和启动的地方。此外,我们可以利用相同的测试工具,实现更多或更少的无缝集成。在 Web 应用程序的情况下,我们的集成测试范围通常是通过 HTTP 调用控制器的端点并断言响应。幸运的是,在 .NET Core 2.1 中,.NET 团队添加了 WebApplicationFactory<TEntry> 类,这使得 Web 应用程序的集成测试变得更加容易。使用这个类,我们可以在内存中启动一个 ASP.NET Core 应用程序,并用提供的 HttpClient 在几行代码中查询它。测试类还提供了配置服务器的扩展点,例如用模拟、存根或其他特定于测试的元素替换实现。让我们从一个经典的 Web 应用程序测试开始启动。
经典 Web 应用程序
在一个经典的 ASP.NET Core 应用程序中,WebApplicationFactory<TEntry>类的TEntry泛型参数通常是您正在测试的项目中的Startup或Program类。
测试用例位于
MyApp.IntegrationTests项目下的Automated Testing解决方案中。
让我们先看看测试代码的结构,然后再将其分解:
namespace MyApp.IntegrationTests.Controllers;
public class ValuesControllerTest : IClassFixture<WebApplicationFactory<Startup>>
{
private readonly HttpClient _httpClient;
public ValuesControllerTest(
WebApplicationFactory<Startup> webApplicationFactory)
{
_httpClient = webApplicationFactory.CreateClient();
}
public class Get : ValuesControllerTest
{
public Get(WebApplicationFactory<Startup> webApplicationFactory)
: base(webApplicationFactory) { }
[Fact]
public async Task Should_respond_a_status_200_OK()
{
// Omitted Test Case 1
}
[Fact]
public async Task Should_respond_the_expected_strings()
{
// Omitted Test Case 2
}
}
}
上述代码中与我们相关的一部分是如何获取WebApplicationFactory<Startup>类的实例。我们通过实现IClassFixture<T>接口(一个 xUnit 特性)将WebApplicationFactory<Startup>对象注入构造函数。我们也可以使用工厂来配置测试服务器,但在这里我们不需要,所以我们只能保留对预配置为连接到内存测试服务器的HttpClient的引用。然后,我们可能已经注意到我们有一个嵌套的Get类,它继承自ValuesControllerTest类。Get类包含测试用例。通过继承ValuesControllerTest类,我们可以利用即将看到的测试用例中的_httpClient字段。在第一个测试用例中,我们使用HttpClient查询通过内存服务器可访问的http://localhost/api/values URI,并断言 HTTP 响应的状态码为成功(200 OK):
[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);
}
第二个测试用例也向内存中的服务器发送 HTTP 请求,但将正文内容反序列化为 string[]以确保值与预期相同,而不是验证状态码:
[Fact]
public async Task Should_respond_the_expected_strings()
{
// Act
var result = await _httpClient
.GetFromJsonAsync<string[]>("/api/values");
// Assert
Assert.Collection(result,
x => Assert.Equal("value1", x),
x => Assert.Equal("value2", x)
);
}
如您可能已从测试用例中注意到,
WebApplicationFactory已为我们预先配置了BaseAddress属性,因此我们不需要在请求前加上http://localhost。
当运行这些测试时,一个内存中的 Web 服务器会启动。然后,向该服务器发送 HTTP 请求,以测试整个应用程序。在这个例子中,测试很简单,但在更复杂的程序中,您可以创建更复杂的测试用例。接下来,我们将探讨如何为最小 API 做同样的事情。
最小化托管
不幸的是,我们必须使用一种解决方案来使Program类在使用最小化托管时可以被发现。让我们探索一些利用最小 API 的解决方案,让您可以选择您喜欢的方案。
第一种解决方案
第一种解决方案是使用程序集中的任何其他类作为WebApplicationFactory<TEntryPoint>的TEntryPoint,而不是Program或Startup类。这使得WebApplicationFactory的行为稍微不那么明确,但这只是所有。由于我倾向于喜欢可读的代码,我不推荐这样做。
第二种解决方案
第二种解决方案是在Program.cs文件的底部(或项目的任何其他位置)添加一行,将自动生成的Program类可见性从internal更改为public。以下是添加了该行的完整Program.cs文件(已突出显示):
var builder = WebApplication.CreateBuilder(args);
var app = builder.Build();
app.MapGet("/", () => "Hello World!");
app.Run();
public partial class Program { }
然后,测试用例与之前探索的经典 Web 应用程序的测试用例非常相似。唯一的区别是程序本身,两个程序都不做相同的事情。
namespace MyMinimalApiApp;
public class ProgramTest : IClassFixture<WebApplicationFactory<Program>>
{
private readonly HttpClient _httpClient;
public ProgramTest(
WebApplicationFactory<Program> webApplicationFactory)
{
_httpClient = webApplicationFactory.CreateClient();
}
public class Get : ProgramTest
{
public Get(WebApplicationFactory<Program> webApplicationFactory)
: base(webApplicationFactory) { }
[Fact]
public async Task Should_respond_a_status_200_OK()
{
// Act
var result = await _httpClient.GetAsync("/");
// Assert
Assert.Equal(HttpStatusCode.OK, result.StatusCode);
}
[Fact]
public async Task Should_respond_hello_world()
{
// Act
var result = await _httpClient.GetAsync("/");
// Assert
var contentText = await result.Content.ReadAsStringAsync();
Assert.Equal("Hello World!", contentText);
}
}
}
唯一的改变是预期的结果,因为端点返回的是text/plain字符串Hello World!,而不是作为 JSON 序列化的字符串集合。如果两个端点产生相同的结果,测试用例将是相同的。
第三个解决方案
第三个解决方案是手动实例化WebApplicationFactory而不是利用固定值。我们可以使用Program类,这需要通过在Program.cs文件中添加以下行来更改其可见性:
public partial class Program { }
然而,我们不是通过使用IClassFixture接口注入实例,而是手动实例化工厂。为了确保我们能够释放WebApplicationFactory实例,我们还实现了IAsyncDisposable接口。以下是一个完整的示例,它与之前的解决方案非常相似:
namespace MyMinimalApiApp;
public class ProgramTestWithoutFixture : IAsyncDisposable
{
private readonly WebApplicationFactory<Program> _webApplicationFactory;
private readonly HttpClient _httpClient;
public ProgramTestWithoutFixture()
{
_webApplicationFactory = new WebApplicationFactory<Program>();
_httpClient = _webApplicationFactory.CreateClient();
}
public ValueTask DisposeAsync()
{
return ((IAsyncDisposable)_webApplicationFactory)
.DisposeAsync();
}
// Omitted nested Get class
}
我省略了前面的代码块中的测试用例,因为它们与之前的解决方案相同。完整的源代码可在 GitHub 上找到:adpg.link/vzkr。
使用类固定值更高效,因为工厂和服务器在每个测试运行中只创建一次,而不是为每个测试方法重新创建。
创建测试应用程序
最后,我们可以创建一个专门类来手动实例化WebApplicationFactory。它利用了其他解决方案,但使测试用例更易于阅读。通过将测试应用程序的设置封装在类中,你将在大多数情况下提高代码的可重用性和维护成本。首先,我们需要通过在Project.cs文件中添加以下行来更改Program类的可见性:
public partial class Program { }
现在我们可以在不需要允许测试项目内部可见性的情况下访问Program类,我们可以创建我们的测试应用程序如下:
namespace MyMinimalApiApp;
public class MyTestApplication : WebApplicationFactory<Program> {}
最后,我们可以重用相同的代码来测试我们的程序,但实例化MyTestApplication而不是WebApplicationFactory<Program>,如下面的代码所示:
namespace MyMinimalApiApp;
public class MyTestApplicationTest
{
public class Get : ProgramTestWithoutFixture
{
[Fact]
public async Task Should_respond_a_status_200_OK()
{
// Arrange
await using var app = new MyTestApplication();
var httpClient = app.CreateClient();
// Act
var result = await httpClient.GetAsync("/");
// Assert
Assert.Equal(HttpStatusCode.OK, result.StatusCode);
}
}
}
你也可以利用固定值,但为了简单起见,我决定向你展示如何手动实例化我们的新测试应用程序。就这样。我们已经以简单而优雅的方式涵盖了多种解决集成测试最小 API 的方法。接下来,在下一章转向架构原则之前,我们将探讨一些测试原则。
重要的测试原则
在编写测试时,要记住的是测试用例,而不是代码本身;我们测试的是功能的正确性,而不是代码的正确性。当然,如果功能的预期结果是正确的,这也意味着代码库是正确的。然而,并非总是相反;正确的代码可能产生错误的结果。此外,请记住,编写代码需要花钱,而功能则能带来价值。为了帮助这一点,测试需求应围绕输入和输出展开。当特定的值输入到你的测试对象中时,你期望得到特定的输出值。无论你是测试一个简单的Add方法,其中输入是两个或更多数字,输出是这些数字的总和,还是更复杂的功能,其中输入来自表单,输出是记录在数据库中持久化的,大多数情况下,我们是在测试输入产生了输出或结果。另一个概念是将这些单元分为查询或命令。无论你如何组织你的代码,从简单的单文件应用程序到基于微服务架构的 Netflix 克隆,所有简单或复合操作都是查询或命令。以这种方式思考系统应该有助于你测试输入和输出。我们在几个章节中讨论了查询和命令,所以继续阅读以了解更多。现在我们已经阐述了这一点,如果一个单元必须执行多个操作,比如从数据库中读取,然后发送多个命令,怎么办?你可以创建和测试多个较小的单元(单个操作),以及另一个协调这些构建块的单元,这样你可以单独测试每个部分。我们在整本书中探讨了如何实现这一点。总的来说,在编写自动化测试时:
-
如果有疑问,我们将根据其输入参数断言正在测试的单元的输出。
-
在命令的情况下,我们将根据其输入参数断言正在测试的单元的结果。
我们在整本书中探讨了多种技术,帮助你达到这种分离程度,从下一章的建筑原则开始。
摘要
本章涵盖了自动化测试,如单元测试和集成测试。我们还简要介绍了端到端测试,但在仅几页的篇幅内涵盖它是不可行的。尽管如此,如何编写集成测试也可以用于端到端测试,尤其是在 REST API 领域。我们从宏观角度探讨了不同的测试方法,处理了技术债务,并探讨了多种测试技术,如黑盒测试、白盒测试和灰盒测试。我们还简要介绍了几种选择测试值的形式方法,如等价类划分和边界值分析。然后我们探讨了 xUnit,这是本书中使用的测试框架以及组织测试的方法。我们探讨了如何选择正确的测试类型以及关于为每种测试类型选择正确数量的指南。然后我们看到了如何通过在内存中运行来轻松测试我们的 ASP.NET Core Web 应用程序。最后,我们探讨了应该指导你编写可测试、灵活和可靠的程序的高级概念。现在我们已经讨论了测试,我们准备探索一些架构原则,以帮助我们提高程序的测试性。这些是现代软件工程的关键部分,与自动化测试相辅相成。
问题
让我们看看几个练习题:
-
在 TDD 中,你是在编写要测试的代码之前编写测试的吗?
-
单元测试的作用是什么?
-
单元测试可以有多大?
-
当被测试的主题需要访问数据库时,通常使用哪种类型的测试?
-
进行 TDD 是必需的吗?
-
进行黑盒测试需要了解应用程序的内部工作原理吗?
进一步阅读
这里有一些链接,可以帮助我们巩固本章所学的内容:
-
xUnit:
xunit.net/ -
如果你使用 Visual Studio,我有一些实用的代码片段可以帮助提高生产力。它们可以在 GitHub 上找到:
adpg.link/5TbY
第三章:3 个架构原则
在开始之前:加入我们的 Discord 书籍社区
直接向作者本人提供反馈,并在我们的 Discord 服务器上与其他早期读者聊天(在“architecting-aspnet-core-apps-3e”频道下找到,位于“EARLY ACCESS SUBSCRIPTION”下)。

本章深入探讨了基本架构原则:当代软件开发实践支柱。这些原则帮助我们创建灵活、健壮、可测试和可维护的代码。我们可以使用这些原则来激发批判性思维,培养我们评估权衡、预测潜在问题以及通过影响我们的决策过程和帮助我们的设计选择来创建经得起时间考验的解决方案。在我们踏上这段旅程时,我们会在整本书中不断参考这些原则,特别是 SOLID 原则,这些原则提高了我们构建灵活和健壮软件系统的能力。在本章中,我们将涵盖以下主题:
-
关注点分离(SoC)原则
-
DRY 原则
-
KISS 原则
-
SOLID 原则
我们还修订了以下概念:
-
协方差
-
逆变
-
接口
关注点分离(SoC)
如其名所示,这个想法是将我们的软件分成逻辑块,每个块代表一个关注点。一个“关注点”指的是程序的一个特定方面。它是在系统中服务于特定目的的特定兴趣或焦点。关注点可以是数据管理这样广泛的,也可以是用户身份验证这样具体的,甚至更具体,如将对象复制到另一个对象中。关注点分离原则建议每个关注点都应该被隔离并单独管理,以提高系统的可维护性、模块化和可理解性。
关注点分离(SoC)原则适用于所有编程范式。简而言之,这个原则意味着将程序分解为正确的部分。例如,模块、子系统和微服务是宏观部分,而类和方法是更小的部分。
通过正确地分离关注点,我们可以防止一个区域的变化影响其他区域,允许更高效的代码重用,并使独立理解和管理工作系统的不同部分更容易。以下是一些示例:
-
安全性和日志记录是跨切面关注点。
-
渲染用户界面是一个关注点。
-
处理 HTTP 请求是一个关注点。
-
将对象复制到另一个对象中是一个关注点。
-
协调分布式工作流是一个关注点。
在转向 DRY 原则之前,考虑在将软件划分为部分以创建连贯单元时的关注点是至关重要的。良好的关注点分离有助于创建模块化设计,更有效地面对设计难题,从而实现可维护的应用程序。
不要重复自己(DRY)
DRY 原则倡导关注点分离原则,并旨在消除代码中的冗余。它提倡每个知识或逻辑片段在系统中应有一个单一、明确的表示。因此,当你系统中存在重复的逻辑时,将其封装,并在多个地方重用这个新的封装。如果你发现自己多处编写相同的或类似的代码,将那段代码重构为一个可重用的组件。利用函数、类、模块或其他抽象来重构代码。遵循 DRY 原则可以使代码更易于维护、更少出错、更容易修改,因为逻辑更改或错误修复只需要在一个地方进行,从而降低了引入错误或不一致的可能性。然而,必须根据关注点重新组合重复的逻辑,而不仅仅是根据代码本身的相似性。让我们看看这两个类:
public class AdminApp
{
public async Task DisplayListAsync(
IBookService bookService,
IBookPresenter presenter)
{
var books = await bookService.FindAllAsync();
foreach (var book in books)
{
await presenter.DisplayAsync(book);
}
}
}
public class PublicApp
{
public async Task DisplayListAsync(
IBookService bookService,
IBookPresenter presenter)
{
var books = await bookService.FindAllAsync();
foreach (var book in books)
{
await presenter.DisplayAsync(book);
}
}
}
代码非常相似,但封装单个类或方法可能是一个错误。为什么?保持两个独立的类更合理,因为管理程序与公共程序相比可能有不同的修改原因。然而,将列表逻辑封装到IBookPresenter接口中是有意义的。如果需要,它将允许我们对两种类型的用户做出不同的反应,例如过滤管理面板列表,但在公共部分做不同的事情。实现这一点的其中一种方法是将foreach循环替换为presenter的DisplayListAsync(books)调用,如下面突出显示的代码所示:
public class AdminApp
{
public async Task DisplayListAsync(
IBookService bookService,
IBookPresenter presenter)
{
var books = await bookService.FindAllAsync();
// We could filter the list here
await presenter.DisplayListAsync(books);
}
}
public class PublicApp
{
public async Task DisplayListAsync(
IBookService bookService,
IBookPresenter presenter)
{
var books = await bookService.FindAllAsync();
await presenter.DisplayListAsync(books);
}
}
这些简单实现之外还有更多内容可以讨论,比如支持多个接口实现以增加灵活性,但让我们把一些主题留到书的后半部分。
当你不知道如何命名一个类或方法时,你可能已经发现了关注点分离的问题。这是一个很好的迹象,表明你应该回到起点。然而,命名是困难的,所以有时候,就是这样。
在遵循关注点分离原则的同时保持我们的代码 DRY 是至关重要的。否则,可能看似不错的举动可能会变成一场噩梦。
简单,愚蠢(KISS)
这是一个简单直接的原则,但也是最重要的原则之一。就像在现实世界中一样,移动部件越多,出问题的机会就越多。这个原则是一种设计哲学,主张设计简单化。它强调系统在保持简单而不是变得复杂时工作得最好。追求简单可能涉及编写更短的方法或函数、最小化参数数量、避免过度设计,并选择最简单的解决方案来解决问题。添加接口、抽象层和复杂的对象层次结构会增加复杂性,但这些附加的好处是否比底层复杂性更好?如果是的话,它们是值得的;如果不是,它们就不值得。
作为指导原则,当你可以用更少的复杂性编写相同的程序时,就去做吧。这也是为什么预测未来需求有时可能会产生不利影响,因为它可能会无意中将不必要的复杂性注入到你的代码库中,而这些特性可能永远不会实现。
我们在书中学习设计模式,并使用它们来设计系统。我们学习如何将高度工程化应用于我们的代码,如果在不正确的环境中进行,可能会导致过度工程化。在书的结尾,我们在探讨垂直切片架构和请求-端点-响应(REPR)模式时,回到了 KISS 原则。接下来,我们深入探讨 SOLID 原则,这是灵活软件设计的关键。
SOLID 原则
SOLID 是一个代表五个原则的缩写,这些原则扩展了基本面向对象编程(OOP)概念——抽象、封装、继承和多态。它们提供了更多关于做什么和如何做的细节,指导开发者走向更稳健和灵活的设计。重要的是要记住,这些只是指导原则,不是你必须遵循的规则,无论什么情况。考虑一下对你具体项目有意义的事情。如果你正在构建一个小工具,可能不需要像构建关键业务应用那样严格遵循这些原则。在业务关键应用的情况下,可能更接近地遵循它们是个好主意。然而,无论你的应用大小如何,通常遵循它们都是明智之举。这就是为什么我们在深入研究设计模式之前讨论它们。SOLID 缩写代表以下内容:
-
单一职责原则
-
开放/封闭原则
-
里氏替换原则
-
接口隔离原则
-
依赖倒置原则
通过遵循这些原则,你的系统应该变得更容易测试和维护。
单一职责原则(SRP)
实质上,SRP 意味着一个类应该只持有一个,且仅有一个职责,这让我想到了以下引言:
“一个类不应该有超过一个改变的理由。”——罗伯特·C·马丁,单一职责原则的创始人
好的,但为什么?在回答这个问题之前,花一点时间回忆一下你曾经参与过的项目,其中有人在一开始就改变了要求。我想起了几个可以从这个原则中受益的项目。现在,想象一下如果系统的每个部分都只有一个任务:一个改变的理由,那会简单多少。
软件可维护性问题可能是由技术和非技术人员共同造成的。没有什么是纯粹的黑或白——大多数事情都是灰色的一抹。软件设计也是如此:总是尽力而为,从错误中学习,保持谦逊(即持续改进)。
通过理解应用程序天生就会变化,当这种情况发生时,你会感觉更好,而 SRP 有助于减轻变化的影响。例如,它有助于使我们的类更易于阅读和重用,并创建更灵活、更易于维护的系统。此外,当一个类只做一件事时,更容易看到变化将如何影响系统,这对于复杂的类来说更具挑战性,因为一个变化可能会破坏其他部分。此外,职责越少,代码越少。代码越少,就越容易理解,帮助你更快地掌握软件的这一部分。让我们在实际行动中尝试一下。
项目 – 单一职责
首先,我们来看看两个代码示例中使用的Product类。该类代表一个简单的虚构产品:
public record class Product(int Id, string Name);
代码示例没有实现,因为它与理解 SRP 无关。我们专注于类 API。请假设我们使用您最喜欢的数据库实现了数据访问逻辑。
以下类违反了 SRP:
namespace BeforeSRP;
public class ProductRepository
{
public ValueTask<Product> GetOnePublicProductAsync(int productId)
=> throw new NotImplementedException();
public ValueTask<Product> GetOnePrivateProductAsync(int productId)
=> throw new NotImplementedException();
public ValueTask<IEnumerable<Product>> GetAllPublicProductsAsync()
=> throw new NotImplementedException();
public ValueTask<IEnumerable<Product>> GetAllPrivateProductsAsync()
=> throw new NotImplementedException();
public ValueTask CreateAsync(Product product)
=> throw new NotImplementedException();
public ValueTask UpdateAsync(Product product)
=> throw new NotImplementedException();
public ValueTask DeleteAsync(Product product)
=> throw new NotImplementedException();
}
前面的类中哪些不符合 SRP?通过阅读方法的名称,我们可以提取两个职责:
-
处理公共产品(高亮代码)。
-
处理私有产品。
ProductRepository类混合了公共和私有产品逻辑。仅从该 API 来看,就有许多可能导致错误并泄露受限数据给公共用户的情况。这也因为该类向面向公众的消费者公开了私有逻辑;其他人可能会犯错。现在我们已经确定了职责,我们可以重新思考这个类。我们知道它有两个职责,所以将类拆分为两个听起来是一个很好的第一步。让我们从提取公共 API 开始:
namespace AfterSRP;
public class PublicProductReader
{
public ValueTask<IEnumerable<Product>> GetAllAsync()
=> throw new NotImplementedException();
public ValueTask<Product> GetOneAsync(int productId)
=> throw new NotImplementedException();
}
现在的PublicProductReader类只包含两个方法:GetAllAsync和GetOneAsync。当阅读类的名称及其方法时,很明显该类只处理公共产品数据。通过降低类的复杂性,我们使其更容易理解。接下来,让我们对私有产品做同样的处理:
namespace AfterSRP;
public class PrivateProductRepository
{
public ValueTask<IEnumerable<Product>> GetAllAsync()
=> throw new NotImplementedException();
public ValueTask<Product> GetOneAsync(int productId)
=> throw new NotImplementedException();
public ValueTask CreateAsync(Product product)
=> throw new NotImplementedException();
public ValueTask DeleteAsync(Product product)
=> throw new NotImplementedException();
public ValueTask UpdateAsync(Product product)
=> throw new NotImplementedException();
}
PrivateProductRepository 类遵循相同的模式。它包括读取方法,方法名与 PublicProductReader 类相同,以及只有具有私有访问权限的用户才能使用的修改方法。通过将初始类拆分为两个,我们提高了代码的可读性、灵活性和安全性。然而,在使用单一责任原则(SRP)时,需要注意的一点是不要过度拆分类。系统中的类越多,组装系统可能变得越复杂,调试和跟踪执行路径可能越困难。另一方面,许多职责分离良好的系统应该导致更好的、更易于测试的系统。很难定义一个硬性规则来定义“一个原因”或“单一责任”。然而,作为一个经验法则,目标是将围绕其责任的单个类中的功能集打包在一起。你应该去除任何多余的逻辑并添加缺失的部分。SRP 违规的一个良好指标是当你不知道如何命名一个元素时,这表明该元素不应该位于那里,你应该将其提取出来,或者将其拆分为多个更小的部分。
为变量、方法、类和其他元素使用精确的名称非常重要,不应被忽视。
另一个良好指标是当一个方法变得太大时,可能包含许多 if 语句或循环。在这种情况下,你可以将那个方法拆分为多个更小的方法、类或其他适合你要求的构造。这应该使代码更容易阅读,并使初始方法体更清晰。这通常也有助于你去除无用的注释并提高可测试性。接下来,我们将探讨如何在不修改代码的情况下更改行为,但在那之前,让我们看看接口。
开闭原则(OCP)
让我们从 1988 年首次将“开闭原则”这个术语写入的人,伯特兰·梅耶(Bertrand Meyer)的一句话开始这个部分:
“软件实体(类、模块、函数等)应该对扩展开放,但对修改封闭。”
好吧,但这意味着什么?这意味着你应该能够在不更改代码的情况下从外部更改类的行为。从历史的角度来看,1988 年 OCP(开闭原则)首次出现时指的是继承,自那时起面向对象编程已经发展了很多。继承仍然有用,但你应该小心,因为它很容易被误用。继承在类之间创建直接的耦合。大多数时候,你应该选择组合而不是继承。
“组合优于继承”是一个原则,它建议通过组合简单、灵活的部件(组合)来构建对象,而不是通过从更大的、更复杂的对象继承属性(继承)。
想象一下用乐高®积木来建造。如果你将小积木组合起来(组合),而不是试图改变一个已经具有固定形状的大积木(继承),那么建造和调整你的创作会更容易。
同时,我们探索了业务流程的三个版本,以说明 OCP。
项目 – 开放封闭
首先,我们来看看代码示例中使用的 Entity 和 EntityRepository 类:
public record class Entity();
public class EntityRepository
{
public virtual Task CreateAsync(Entity entity)
=> throw new NotImplementedException();
}
Entity 类代表一个没有属性的简单虚构实体;可以将其视为任何你想要的东西。EntityRepository 类有一个单一的 CreateAsync 方法,用于将一个 Entity 实例插入到数据库中(如果已实现)。
代码示例中包含很少的实现细节,因为它与理解 OCP 无关。请假设我们使用你喜欢的数据库实现了
CreateAsync逻辑。
对于其余的示例,我们重构了 EntityService 类,从一个继承 EntityRepository 类的版本开始,打破了 OCP:
namespace OCP.NoComposability;
public class EntityService : EntityRepository
{
public async Task ComplexBusinessProcessAsync(Entity entity)
{
// Do some complex things here
await CreateAsync(entity);
// Do more complex things here
}
}
如命名空间所暗示的,前面的 EntityService 类没有提供可组合性。此外,我们将其与 EntityRepository 类紧密耦合。由于我们刚刚覆盖了“组合优于继承”的原则,我们可以快速隔离问题:继承。作为解决这个混乱的下一步,让我们提取一个私有 _repository 字段来保存一个 EntityRepository 实例,如下所示:
namespace OCP.Composability;
public class EntityService
{
private readonly EntityRepository _repository
= new EntityRepository();
public async Task ComplexBusinessProcessAsync(Entity entity)
{
// Do some complex things here
await _repository.CreateAsync(entity);
// Do more complex things here
}
}
现在,EntityService 由一个 EntityRepository 实例组成,并且不再有继承。然而,我们仍然紧密耦合了这两个类,并且不改变其代码就无法以这种方式改变 EntityService 的行为。为了解决我们最后的问题,我们可以在设置我们的私有字段的地方注入一个 EntityRepository 实例,如下所示:
namespace OCP.DependencyInjection;
public class EntityService
{
private readonly EntityRepository _repository;
public EntityService(EntityRepository repository)
{
_repository = repository;
}
public async Task ComplexBusinessProcessAsync(Entity entity)
{
// Do some complex things here
await _repository.CreateAsync(entity);
// Do more complex things here
}
}
通过前面的更改,我们打破了 EntityService 和 EntityRepository 类之间的紧密耦合。我们还可以通过决定将哪个 EntityRepository 类的实例注入到 EntityService 构造函数中来从外部控制 EntityService 类的行为。我们甚至可以通过利用抽象而不是具体类来更进一步,并在覆盖 DIP 的同时探索这一点。正如我们刚刚探索的,OCP 是一个超级强大、简单且允许从外部控制对象的原则。例如,我们可以创建两个具有不同 EntityRepository 实例的 EntityService 类实例,这些实例连接到不同的数据库。以下是一个粗略的示例:
using OCP;
using OCP.DependencyInjection;
// Create the entity in database 1
var repository1 = new EntityRepository(/* connection string 1 */);
var service1 = new EntityService(repository1);
// Create the entity in database 2
var repository2 = new EntityRepository(/* connection string 2 */);
var service2 = new EntityService(repository2);
// Save an entity in two different databases
var entity = new Entity();
await service1.ComplexBusinessProcessAsync(entity);
await service2.ComplexBusinessProcessAsync(entity);
在前面的代码中,假设我们实现了 EntityRepository 类并配置了 repository1 和 repository2,那么在 service1 和 service2 上执行 ComplexBusinessProcessAsync 方法的结果将在两个不同的数据库中创建实体。两个实例之间的行为变化是在不改变 EntityService 类代码的情况下发生的;组合:1,继承:0。
我们在第五章,策略、抽象工厂和单例中探讨了策略模式——实现 OCP 的最佳方式。在第六章,依赖注入中,我们重新审视了该模式,并学习了如何使用依赖注入将程序精心设计的组件组装在一起。
接下来,我们将探索我们将其视为五个中最复杂的一个,但我们将使用得最少的原则。
李斯克夫替换原则(LSP)
李斯克夫替换原则(LSP)指出,在一个程序中,如果我们用一个子类(子类型)的实例替换一个超类(超类型)的实例,程序不应该崩溃或出现意外的行为。想象我们有一个名为Bird的基类,它有一个名为Fly的函数,我们添加了Eagle和Penguin子类。由于企鹅不能飞,用Penguin子类的实例替换Bird类的实例可能会出现问题,因为程序期望所有鸟类都能飞。所以,根据 LSP,我们的子类应该表现出使程序仍然可以正确工作的行为,即使它不知道正在使用哪个子类,从而保持系统稳定性。在继续讨论 LSP 之前,让我们看看协变和逆变。
协变和逆变
我们不会深入探讨这一点,所以不会偏离 LSP 太远,但既然正式定义提到了它们,我们至少要理解这些。协变和逆变代表特定的多态场景。它们允许引用类型隐式地转换为其他类型。它们适用于泛型类型参数、委托和数组类型。你可能永远不会需要记住这一点,因为其中大部分是隐式的,但这里有一个概述:
-
协变(
out)使我们能够使用更派生的类型(子类型)而不是超类型。协变通常适用于方法返回类型。例如,如果一个基类方法返回一个类的实例,那么派生类中等效的方法可以返回子类的实例。 -
逆变(
in)是相反的情况。它允许使用较少派生的类型(超类型)代替子类型。逆变通常适用于方法参数类型。如果一个基类的方法接受特定类的参数,那么派生类中等效的方法可以接受超类的参数。
让我们通过一些代码来更好地理解这一点,从我们使用的模型开始:
public record class Weapon { }
public record class Sword : Weapon { }
public record class TwoHandedSword : Sword { }
简单的类层次结构,我们有一个TwoHandedSword类,它继承自Sword类,而Sword类继承自Weapon类。
协变
为了演示协变,我们利用以下泛型接口:
public interface ICovariant<out T>
{
T Get();
}
在 C#中,out修饰符,高亮显示的代码,明确指定了泛型参数T是协变的。协变适用于返回类型,因此Get方法返回泛型类型T。在测试之前,我们需要一个实现。这里有一个简单的实现:
public class SwordGetter : ICovariant<Sword>
{
private static readonly Sword _instance = new();
public Sword Get() => _instance;
}
突出的代码,代表 T 参数,其类型为 Sword,是 Weapon 的子类。由于协变意味着你可以 返回(输出)子类的实例作为其超类型,使用 Sword 子类允许你用 Weapon 超类型来探索这一点。以下是一个演示协变的 xUnit 事实:
[Fact]
public void Generic_Covariance_tests()
{
ICovariant<Sword> swordGetter = new SwordGetter();
ICovariant<Weapon> weaponGetter = swordGetter;
Assert.Same(swordGetter, weaponGetter);
Sword sword = swordGetter.Get();
Weapon weapon = weaponGetter.Get();
var isSwordASword = Assert.IsType<Sword>(sword);
var isWeaponASword = Assert.IsType<Sword>(weapon);
Assert.NotNull(isSwordASword);
Assert.NotNull(isWeaponASword);
}
突出的行代表协变,显示我们可以隐式地将 ICovariant<Sword> 子类型转换为 ICovariant<Weapon> 超类型。随后的代码展示了这种多态变化会发生什么。例如,weaponGetter 对象的 Get 方法返回 Weapon 类型,而不是 Sword,即使底层实例是 SwordGetter 对象。然而,实际上这个 Weapon 是一个 Sword,正如断言所证明的。接下来,让我们探索逆变。
逆变
为了演示协变,我们利用以下泛型接口:
public interface IContravariant<in T>
{
void Set(T value);
}
在 C# 中,in 修饰符,突出的代码,明确指定泛型参数 T 是逆变的。逆变适用于输入类型,因此 Set 方法接受泛型类型 T 作为参数。在测试之前,我们需要一个实现。以下是一个简单的实现:
public class WeaponSetter : IContravariant<Weapon>
{
private Weapon? _weapon;
public void Set(Weapon value)
=> _weapon = value;
}
突出的代码,代表 T 参数,其类型为 Weapon,是我们模型中最顶层的类;其他类都从中派生。由于逆变意味着你可以 输入子类的实例作为其超类型,使用 Weapon 超类型允许你用 Sword 和 TwoHandedSword 子类型来探索这一点。以下是一个演示逆变的 xUnit 事实:
[Fact]
public void Generic_Contravariance_tests()
{
IContravariant<Weapon> weaponSetter = new WeaponSetter();
IContravariant<Sword> swordSetter = weaponSetter;
Assert.Same(swordSetter, weaponSetter);
// Contravariance: Weapon > Sword > TwoHandedSword
weaponSetter.Set(new Weapon());
weaponSetter.Set(new Sword());
weaponSetter.Set(new TwoHandedSword());
// Contravariance: Sword > TwoHandedSword
swordSetter.Set(new Sword());
swordSetter.Set(new TwoHandedSword());
}
突出的行代表逆变。我们可以隐式地将 IContravariant<Weapon> 超类型转换为 IContravariant<Sword> 子类型。随后的代码展示了这种多态变化会发生什么。例如,weaponSetter 对象的 Set 方法可以接受 Weapon、Sword 或 TwoHandedSword 实例,因为它们都是 Weapon 类型的子类型(或者本身就是 Weapon 类型)。同样,swordSetter 实例也接受 Sword 或 TwoHandedSword 实例,从继承层次结构中的 Sword 类型开始,因为编译器认为 swordSetter 实例是 IContravariant<Sword> 类型,即使其底层实现是 WeaponSetter 类型。编写以下代码会导致编译错误:
swordSetter.Set(new Weapon());
错误是:
Cannot convert from Variance.Weapon to Variance.Sword.
这意味着对于编译器来说,swordSetter 的类型是 IContravariant<Sword>,而不是 IContravariant<Weapon>。
注意
我在 进一步阅读 部分留下了一个链接,解释了协变和逆变,如果你想了解更多信息,因为我们在这里只介绍了基础知识。
现在我们已经了解了协变和逆变,我们可以探索 LSP 的正式版本。
LSP 的解释
LSP 是由 Barbara Liskov 在 20 世纪 80 年代末提出的,并在 90 年代由 Liskov 和 Jeannette Wing 重新审视,以创建我们今天所知道和使用的原则。它也与 Bertrand Meyer 的设计合同相似。接下来,让我们看看正式的子类型要求定义:
设
是关于类型 T 的对象 x 的一个可证明的性质。那么,
对于类型 S 的对象 y 应该是正确的,其中 S 是 T 的子类型。
简而言之,如果S是T的子类型,我们可以用S类型的对象替换T类型的对象,而不改变程序预期的任何行为(正确性)。LSP 增加了以下签名要求:
-
子类型中方法参数必须是逆变。
-
子类型中方法返回类型必须是协变的。
-
你不能在子类型中抛出一种新的异常类型。
在 C#中,不费劲就违反前两条规则是困难的。
在子类型中抛出一种新的异常类型也被视为改变行为。然而,你可以在子类型中抛出子类型异常,因为现有的消费者可以处理它们。
LSP 还增加了以下行为条件:
| 条件 | 示例 |
|---|---|
| 在超类型中实现的任何前置条件都应该在子类型中产生相同的结果,但子类型可以对此不那么严格,决不能更严格。 | 如果超类型验证一个参数不能为null,则子类型可以移除该验证,但不能添加更严格的验证规则。 |
| 在超类型中实现的任何后置条件都应该在子类型中产生相同的结果,但子类型可以对此更加严格,决不能更宽松。 | 如果超类型永远不会返回null,则子类型也不应该返回null,否则会破坏未测试null的对象消费者。如果超类型不保证返回的值不能为null,则子类型可以决定永远不返回null,这样两个实例就可以互换。 |
| 子类型必须保持超类型的不可变性。 | 子类型必须通过为超类型编写的所有测试,因此它们之间没有差异(它们不变化/它们反应相同)。 |
| 历史约束规定,在超类型中发生的事情必须在子类型中发生,而且你不能改变这一点。 | 子类型可以添加新的属性(状态)和方法(行为)。子类型不得以任何新的方式修改超类型状态。 |
表 3.1:LSP 行为条件
好吧,在这个时候,你可能会觉得这相当复杂。但请放心,这是那些原则中不那么重要的一个,因为我们正在尽可能远离继承,所以 LSP 不应该经常适用。
我们可以将 LSP 总结为:
在你的子类型中添加新的行为和状态;不要改变现有的行为。
简而言之,应用 LSP(Liskov 替换原则)允许我们替换一个类的实例为其子类实例,而不会破坏任何东西。用乐高®的类比来说:LSP 就像用一个带有贴纸的 4x2 积木替换一个普通的 4x2 积木:结构完整性以及积木的角色都没有改变;新的积木只是有一个新的贴纸状态。
提示
强制执行这些行为约束的一个绝佳方法是自动化测试。你可以编写一个测试套件,并对其针对特定超类所有子类的运行进行测试,以强制保持行为的一致性。
让我们通过一些代码来直观地展示这一点。
项目 – Liskov 替换原则
为了演示 LSP,我们将探索一些场景。每个场景都是一个遵循相同结构的测试类:
namespace LiskovSubstitution;
public class TestClassName
{
public static TheoryData<SuperClass> InstancesThatThrowsSuperExceptions = new TheoryData<SuperClass>()
{
new SuperClass(),
new SubClassOk(),
new SubClassBreak(),
};
[Theory]
[MemberData(nameof(InstancesThatThrowsSuperExceptions))]
public void Test_method_name(SuperClass sut)
{
// Scenario
}
// Other classes, like SuperClass, SubClassOk,
// and SubClassBreak
}
在前面的代码结构中,高亮显示的代码为每个测试而改变。设置很简单;我使用测试方法来模拟程序可能执行的代码,仅通过在三个不同的类上运行相同的代码三次,每个理论就会失败一次:
-
初始测试通过
-
子类型尊重 LSP 的测试通过
-
子类型违反 LSP 的测试失败。
参数
sut是测试对象,这是一个众所周知的缩写。
当然,我们无法探索所有场景,所以我选择了三个;让我们检查第一个。
场景 1:ExceptionTest
这个场景探讨了当子类型抛出新的异常类型时可能发生的情况。以下代码是测试对象的消费者:
try
{
sut.Do();
}
catch (SuperException ex)
{
// Some code
}
上述代码非常标准。我们使用 try-catch 块封装了某些代码(Do方法)的执行,以处理特定的异常。初始的测试对象(SuperClass)模拟在Do方法执行过程中抛出类型为SuperException的异常。当我们执行代码时,try-catch 块捕获了SuperException,一切按计划进行。以下是代码:
public class SuperClass
{
public virtual void Do()
=> throw new SuperException();
}
public class SuperException : Exception { }
接下来,SubClassOk类模拟执行过程的变化,并抛出一个继承自SuperException类的SubException。当我们执行代码时,try-catch 块捕获了SubException,因为它属于SuperException的子类型,一切按计划进行。以下是代码:
public class SubClassOk : SuperClass
{
public override void Do()
=> throw new SubException();
}
public class SubException : SuperException { }
最后,SubClassBreak类模拟抛出AnotherException,这是一种新的异常类型。当我们执行代码时,程序意外地停止,因为我们没有为这种情况设计 try-catch 块。以下是代码:
public class SubClassBreak : SuperClass
{
public override void Do()
=> throw new AnotherException();
}
public class AnotherException : Exception { }
所以,尽管听起来可能很平凡,抛出那个异常会破坏程序,违反 LSP。
场景 2:PreconditionTest
这个场景探讨了在超类中实现的任何前置条件都应该在子类型中产生相同的结果,但子类型对此可以不那么严格,永远不能更严格。以下代码是测试对象的消费者:
var value = 5;
var result = sut.IsValid(value);
Console.WriteLine($"Do something with {result}");
上述代码非常标准。我们有一个 value 变量,它可能来自任何地方。然后我们将其传递给 IsValid 方法。最后,我们对 result 做些处理;在这种情况下,我们向控制台写入一行。最初要测试的主题(SuperClass)模拟存在一个前提条件,强制值必须是正数。以下是代码:
public class SuperClass
{
public virtual bool IsValid(int value)
{
if (value < 0)
{
throw new ArgumentException(
"Value must be positive.",
nameof(value)
);
}
return true;
}
}
接下来,SubClassOk 类模拟执行发生变化,并容忍高达 -10 的负值。在执行代码时,一切正常,因为前提条件不那么严格。以下是代码:
public class SubClassOk : SuperClass
{
public override bool IsValid(int value)
{
if (value < -10)
{
throw new ArgumentException(
"Value must be greater or equal to -10.",
nameof(value)
);
}
return true;
}
}
最后,SubClassBreak 类模拟执行发生变化,并限制使用小于 10 的值。在执行代码时,它崩溃了,因为我们没有预料到这个错误;前提条件比 SuperClass 更严格。以下是代码:
public class SubClassBreak : SuperClass
{
public override bool IsValid(int value)
{
if (value < 10) // Break LSP
{
throw new ArgumentException(
"Value must be greater than 10.",
nameof(value)
);
}
return true;
}
}
这又是简单变化如何破坏其消费者和 LSP 的一个例子。当然,这是一个过于简化的例子,只关注前提条件,但同样的情况也适用于更复杂的场景。编码就像玩积木。
场景 3:后置条件测试
这个场景探讨了在超类型中实现的后置条件应该在子类型中产生相同的结果,但子类型可以对此更加严格,决不能更宽松。以下代码是受测试主题的消费者:
var value = 5;
var result = sut.Do(value);
Console.WriteLine($"Do something with {result.Value}");
上述代码非常标准,并且与第二个场景非常相似。我们有一个 value 变量,它可能来自任何地方。然后我们将其传递给 Do 方法。最后,我们对 result 做些处理;在这种情况下,我们向控制台写入一行。Do 方法返回一个 Model 类的实例,该类只有一个 Value 属性。以下是代码:
public record class Model(int Value);
最初要测试的主题(SuperClass)模拟在执行过程中某个时刻返回一个 Model 实例,并将 Value 属性的值设置为 value 参数的值。以下是代码:
public class SuperClass
{
public virtual Model Do(int value)
{
return new(value);
}
}
接下来,SubClassOk 类模拟执行发生了变化,并返回一个 SubModel 实例。SubModel 类继承自 Model 类并添加了一个 DoCount 属性。在执行代码时,一切正常,因为输出是不变的(SubModel 是 Model 的一种,表现相同)。以下是代码:
public class SubClassOk : SuperClass
{
private int _doCount = 0;
public override Model Do(int value)
{
var baseModel = base.Do(value);
return new SubModel(baseModel.Value, ++_doCount);
}
}
public record class SubModel(int Value, int DoCount) : Model(Value);
最后,SubClassBreak 类模拟执行发生变化,并在 value 参数的值为 5 时返回 null。在执行代码时,当在 Console.WriteLine 调用中发生的插值过程中访问 Value 属性时,会抛出 NullReferenceException 异常。以下是代码:
public class SubClassBreak : SuperClass
{
public override Model Do(int value)
{
if (value == 5)
{
return null;
}
return base.Do(value);
}
}
这个最后的场景再次展示了简单的更改如何破坏我们的程序。当然,这只是一个过于简化的例子,只关注后置条件和历史约束,但同样的情况也适用于更复杂的情况。关于历史约束呢?我们通过创建_doCount字段向SubClassOk类添加了一个新的状态元素。此外,通过添加SubModel类,我们将DoCount属性添加到了返回类型中。这个字段和属性在超类型中不存在,并且它们没有改变其行为:LSP 遵循!
结论
LSP 的关键思想是,超类型的消费者应该对它是在与超类型的一个实例还是子类型的一个实例交互保持无知。我们也可以把这个原则称为向后兼容性原则,因为之前所有的工作方式在替换后必须至少保持相同,这就是为什么这个原则是至关重要的。再次强调,这只是一个原则,而不是法律。你还可以将 LSP 的违反视为代码异味。从那里,分析你是否有一个设计问题及其影响。根据具体情况运用你的分析技能,并得出是否可以接受在该特定情况下违反 LSP 的结论。有时你可能想要改变程序的行为并违反 LSP,但要注意你可能会破坏你没有考虑到的某些执行路径,并引入缺陷。随着我们不断进步,我们越来越远离继承,也就越来越不需要担心这个原则。然而,如果你使用继承并希望确保你的子类型不会破坏程序:应用 LSP,你将因为提高生产无缺陷、向后兼容更改的机会而得到回报。让我们接下来看看 ISP。
接口分离原则(ISP)
让我们从罗伯特·C·马丁的另一个著名引言开始:
“许多特定客户端的接口比一个通用接口更好。”
这是什么意思?这意味着以下内容:
-
你应该创建接口。
-
你应该更重视小型接口。
-
你不应该创建多功能接口。
你可以将多功能接口视为“统治一切接口”或是在第一章,简介中引入的上帝类。
接口可以指类接口(类的公共成员)或 C#接口。我们在书中主要关注 C#接口,因为我们广泛地使用它们。此外,C#接口非常强大。说到接口,在我们深入代码之前,让我们快速了解一下它们。
什么是接口?
接口是 C#工具箱中创建灵活和可维护软件的最有价值的工具之一。一开始理解并掌握接口的力量可能很困难,尤其是从解释的角度来看,所以如果你还没有理解,请不要担心;你将在整本书中看到很多实际应用。
您可以看到一个接口允许一个类模仿不同的东西(API),将多态性提升到新的层次。
接下来是一些概述接口的更多细节:
-
接口的作用是定义一个统一的契约(公共方法、属性和事件)。在其理论形式中,接口不包含任何代码;它只是一个契约。在实践中,自从 C# 8 以来,我们可以在接口中创建默认实现,这有助于限制库中的破坏性更改(例如,在不破坏实现该接口的任何类的情况下向接口添加方法)。
-
接口应该是小的(ISP),其成员应朝向一个共同的目标(内聚)并承担单一责任(SRP)。
-
在 C# 中,一个类可以实现多个接口,暴露多个这些公共契约,或者更准确地说,可以是它们中的任何和所有。通过利用多态性,我们可以将一个类消费为它实现的任何接口,或者如果它继承自另一个类,则是其超类型。
一个类不继承自接口;它实现了接口。然而,一个接口可以继承自另一个接口。
现在,让我们在刷新了我们的记忆之后,探索一下 ISP 示例。
项目 – 接口隔离
在这个项目中,我们以 SRP 示例中的相同类开始,但从中提取了 ProductRepository 类的接口。让我们先看看 Product 类作为提醒,它代表一个简单的虚构产品:
public record class Product(int Id, string Name);
代码示例没有实现,因为它与理解 ISP 无关。我们专注于接口。请假设我们使用您喜欢的数据库实现了数据访问逻辑。
现在,让我们看看从 ProductRepository 类中提取的接口:
namespace InterfaceSegregation.Before;
public interface IProductRepository
{
public ValueTask<IEnumerable<Product>> GetAllPublicProductAsync();
public ValueTask<IEnumerable<Product>> GetAllPrivateProductAsync();
public ValueTask<Product> GetOnePublicProductAsync(int productId);
public ValueTask<Product> GetOnePrivateProductAsync(int productId);
public ValueTask CreateAsync(Product product);
public ValueTask UpdateAsync(Product product);
public ValueTask DeleteAsync(Product product);
}
在这一点上,IProductRepository 接口与 ProductRepository 类之前以相同的方式违反了 SRP 和 ISP。我们之前已经确定了 SRP 问题,但还没有达到提取接口的阶段。
ProductRepository类实现了IProductRepository接口,并且与 SRP 示例相同(所有方法throw new NotImplementedException())。
在 SRP 示例中,我们确定了以下责任:
-
处理公共产品。
-
处理私有产品。
根据我们之前的分析,我们有两个功能需求(公共和私有访问)。进一步挖掘后,我们还可以识别出五种不同的数据库操作。以下是结果表格:
| 公共 | 私有 | |
|---|---|---|
| 读取单个产品 | 是 | 是 |
| 读取所有产品 | 是 | 是 |
| 创建产品 | 否 | 是 |
| 更新产品 | 否 | 是 |
| 删除产品 | 否 | 是 |
表 3.3:一个表格,显示了软件需要做什么(功能需求)以及数据库中需要发生什么(数据库操作需求)。
我们可以从表 3.3 中提取以下数据库操作系列:
-
读取产品(读取一个,读取所有)。
-
写入或修改产品(创建、更新、删除)。
基于更深入的分析,我们可以提取代表数据库操作的IProductReader和IProductWriter接口。然后我们可以创建PublicProductReader和PrivateProductRepository类来实现我们的功能需求。让我们从IProductReader接口开始:
namespace InterfaceSegregation.After;
public interface IProductReader
{
public ValueTask<IEnumerable<Product>> GetAllAsync();
public ValueTask<Product> GetOneAsync(int productId);
}
通过这个接口,我们涵盖了读取一个产品和读取所有产品的使用场景。接下来,IProductWriter接口涵盖了其他三个数据库操作:
namespace InterfaceSegregation.After;
public interface IProductWriter
{
public ValueTask CreateAsync(Product product);
public ValueTask UpdateAsync(Product product);
public ValueTask DeleteAsync(Product product);
}
我们可以使用前面的接口来涵盖所有数据库使用场景。接下来,让我们创建PublicProductReader类:
namespace InterfaceSegregation.After;
public class PublicProductReader : IProductReader
{
public ValueTask<IEnumerable<Product>> GetAllAsync()
=> throw new NotImplementedException();
public ValueTask<Product> GetOneAsync(int productId)
=> throw new NotImplementedException();
}
在前面的代码中,PublicProductReader只实现了IProductReader接口,涵盖了已识别的场景。我们在探索 ISP 的优势之前,先来处理PrivateProductRepository类:
namespace InterfaceSegregation.After;
public class PrivateProductRepository : IProductReader, IProductWriter
{
public ValueTask<IEnumerable<Product>> GetAllAsync()
=> throw new NotImplementedException();
public ValueTask<Product> GetOneAsync(int productId)
=> throw new NotImplementedException();
public ValueTask CreateAsync(Product product)
=> throw new NotImplementedException();
public ValueTask DeleteAsync(Product product)
=> throw new NotImplementedException();
public ValueTask UpdateAsync(Product product)
=> throw new NotImplementedException();
}
在前面的代码中,PrivateProductRepository类实现了IProductReader和IProductWriter接口,涵盖了所有数据库需求。现在我们已经涵盖了构建块,让我们探索这能做什么。以下是Program.cs文件:
using InterfaceSegregation.After;
var publicProductReader = new PublicProductReader();
var privateProductRepository = new PrivateProductRepository();
ReadProducts(publicProductReader);
ReadProducts(privateProductRepository);
// Error: Cannot convert from PublicProductReader to IProductWriter
// ModifyProducts(publicProductReader); // Invalid
WriteProducts(privateProductRepository);
ReadAndWriteProducts(privateProductRepository, privateProductRepository);
ReadAndWriteProducts(publicProductReader, privateProductRepository);
void ReadProducts(IProductReader productReader)
{
Console.WriteLine(
"Reading from {0}.",
productReader.GetType().Name
);
}
void WriteProducts(IProductWriter productWriter)
{
Console.WriteLine(
"Writing to {0}.",
productWriter.GetType().Name
);
}
void ReadAndWriteProducts(IProductReader productReader, IProductWriter productWriter)
{
Console.WriteLine(
"Reading from {0} and writing to {1}.",
productReader.GetType().Name,
productWriter.GetType().Name
);
}
从前面的代码中,ReadProducts、ModifyProducts和ReadAndUpdateProducts方法在控制台输出消息,以展示应用 ISP 的优势。《publicProductReader(PublicProductReader的实例)和privateProductRepository(PrivateProductRepository`的实例)变量被传递到方法中,以展示我们可以在当前设计中做什么,以及不能做什么。在我们深入探讨之前,当我们执行程序时,我们获得以下输出:
Reading from PublicProductReader.
Reading from PrivateProductRepository.
Writing to PrivateProductRepository.
Reading from PrivateProductRepository and writing to PrivateProductRepository.
Reading from PublicProductReader and writing to PrivateProductRepository.
第一个操作
以下代码表示第一个操作:
ReadProducts(publicProductReader);
ReadProducts(privateProductRepository);
由于PublicProductReader和PrivateProductRepository类实现了IProductReader接口,因此ReadProducts方法接受它们,导致以下输出:
Reading from PublicProductReader.
Reading from PrivateProductRepository.
这意味着我们可以集中一些代码,从这两个实现中读取,而无需更改它们。
第二个操作
以下代码表示第二个操作:
WriteProducts(privateProductRepository);
由于只有PrivateProductRepository类实现了IProductWriter接口,因此WriteProducts方法只接受privateProductRepository变量,并输出以下内容:
Writing to PrivateProductRepository.
这是良好分离的接口和责任的一个优点;如果我们尝试执行以下行,编译器会显示错误,指出我们“不能从 PublicProductReader 转换为 IProductWriter”:
ModifyProducts(publicProductReader);
这个错误是有意义的,因为 PublicProductReader 没有实现IProductWriter接口。
第三个操作
以下代码表示第三个操作:
ReadAndWriteProducts(
privateProductRepository,
privateProductRepository
);
ReadAndWriteProducts(
publicProductReader,
privateProductRepository
);
让我们分别分析对ReadAndWriteProducts方法的两次调用,但在那之前,让我们看看控制台输出:
Reading from PrivateProductRepository and writing to PrivateProductRepository.
Reading from PublicProductReader and writing to PrivateProductRepository.
第一次执行读取和写入到PrivateProductRepository实例,这是可能的,因为它实现了IProductReader和IProductWriter接口。然而,第二次调用是从公共读取器读取,但使用私有写入器写入。最后一个示例展示了接口隔离原则(ISP)的力量,特别是当与单一职责原则(SRP)结合使用时,以及正确隔离我们的接口并针对程序用例设计代码时,如何轻松地交换一个部分为另一个部分。
你不应该将所有存储库都分为读取器和写入器;这个示例只是为了展示一些可能性。始终为你的程序规格设计你的程序。
结论
总结接口隔离原则(ISP)背后的思想,如果你有多个较小的接口,更容易重用它们,并且只暴露你需要的功能,而不是暴露程序不需要的部分的 API。此外,通过按需实现它们,比从大接口中删除不需要的方法更容易使用多个专业接口来组合更大的部分。
主要的收获是只依赖于你消费的接口。
如果你还没有看到所有的好处,请不要担心。随着我们继续到最后一个 SOLID 原则,即依赖注入,书的其余部分,以及你练习应用 SOLID 原则,所有的部分都应该逐渐整合在一起。
就像单一职责原则(SRP)一样,要小心不要无意识地过度使用接口隔离原则(ISP)。考虑内聚性和你试图实现的目标,而不是接口可以变得多细粒度。你的接口越细粒度,你的系统将越灵活,但记住灵活性是有代价的,这个代价可能会很快变得非常高。例如,你的高度灵活的系统可能非常难以导航和理解,从而增加了在项目上工作的认知负荷。
接下来,我们将探讨 SOLID 原则中的最后一个原则。
依赖倒置原则(DIP)
依赖倒置原则(DIP)提供了灵活性、可测试性和模块化,通过减少类或模块之间的紧密耦合。让我们继续引用罗伯特·C·马丁(包括维基百科中的隐含上下文):
“人们应该‘依赖于抽象,而不是具体实现。’”
在上一节中,我们探讨了接口(抽象),这是我们 SOLID 工具箱中的一个关键元素,使用接口是接近依赖倒置原则(DIP)的最佳方式。
你想知道为什么不使用抽象类吗?虽然它们在提供继承的默认行为方面很有帮助,但它们并不完全抽象。如果一个是,最好使用接口。
接口更加灵活和强大,作为系统各部分之间的契约。它们还允许一个类实现多个接口,从而提高灵活性。然而,不要无意识地丢弃抽象类。实际上,不要无意识地丢弃任何东西。
暴露接口可以在编写单元测试时节省无数小时寻找复杂解决方案的时间。当构建其他人使用的框架或库时,这一点更是如此。在这种情况下,请更加注意为您的消费者提供接口,以便在必要时进行模拟。再次讨论接口的话题是很好的,但我们如何反转依赖关系流呢?剧透一下:接口!让我们首先比较直接依赖和反转依赖。
直接依赖
当一段特定的代码(如类或模块)直接依赖于另一个时,就会发生直接依赖。例如,如果类 A 使用类 B 的方法,那么类 A 就直接依赖于类 B,这在传统编程中是一个典型的场景。假设我们有一个SomeService类,在生产中使用SqlDataPersistence类,而在开发和测试期间使用LocalDataPersistence类。如果不反转依赖关系,我们最终会得到以下 UML 依赖图:

图 3.2:直接依赖图架构
在先前的系统中,我们无法通过CosmosDbDataPersistence类(不在图中)更改SqlDataPersistence或LocalDataPersistence类,而不会影响SomeService类。我们称这种直接的依赖为紧密耦合。
反转依赖
当高级模块(提供复杂逻辑)独立于低级模块(提供基本、基础操作)时,就会发生反转依赖。我们可以通过在模块之间引入抽象(如接口)来实现这一点。这意味着,而不是类 A 直接依赖于类 B,类 A 将依赖于类 B 实现的抽象。以下是改进直接依赖示例的更新架构:

图 3.3:间接依赖图架构
在先前的图中,我们通过确保SomeService类只依赖于IDataPersistance接口(抽象)来实现依赖关系的反转,该接口由SqlDataPersistence和LocalDataPersistence类实现。然后我们可以使用CosmosDbDataPersistence类(不在图中),而不会影响SomeService类。我们称这种反转的依赖为松耦合。现在我们已经讨论了如何反转类的依赖关系流,我们来看看如何反转子系统。
直接子系统依赖
先前的直接依赖示例按包划分,存在相同的问题,如下所示:

图 3.3:按包划分的直接依赖图
Core包依赖于SQL和Local包,导致紧密耦合。
包通常代表程序集或命名空间。然而,围绕程序集划分责任允许只加载程序需要的实现。例如,一个程序可以加载
Local程序集,另一个可以加载SQL程序集,第三个可以加载两者。
话已说尽;让我们反转这些子系统的依赖流。
逆子系统依赖
我们讨论了模块和包,但逆依赖示例图说明了类。采用类似的方法,我们可以通过将代码安排在单独的程序集中来减少子系统之间的依赖,创建更灵活的程序。这样,我们可以在软件中实现松散耦合和改进的模块化。为了继续逆依赖示例,我们可以做以下操作:
-
创建一个只包含接口的抽象程序集。
-
创建其他包含第一个包中合约实现的程序集。
-
创建通过抽象程序集消费代码的程序集。
在 .NET 中有多个这样的例子,例如
Microsoft.Extensions.DependencyInjection.Abstractions和Microsoft.Extensions.DependencyInjection程序集。我们将在第十二章 分层和清洁架构 中进一步探讨这个概念。
然后,如果我们把逆依赖示例分为多个包,它看起来会像以下这样:

图 3.4:分为多个包的逆依赖示例
在图中,Core 包直接依赖于 Abstractions 包,同时有两个实现可用:Local 和 Sql。由于我们只依赖于抽象,我们可以互换一个实现,而不会影响 Core,程序将正常运行,除非实现本身有问题(但这与 DIP 没有关系)。我们还可以创建一个新的 CosmosDb 包和一个实现 IDataPersistence 接口的 CosmosDbDataPersistence 类,然后在 Core 中使用它而不会破坏任何东西。为什么?因为我们只直接依赖于抽象,导致系统耦合度低。接下来,我们将深入研究一些代码。
项目 – 依赖反转
在本节中,我们将代码中前面的逆依赖示例迭代翻译成中文。我们创建了以下程序集以与前面的图一致:
-
App是一个控制台应用程序,它引用所有项目以展示不同的用例。 -
Core是一个依赖于Abstractions包的类库。 -
Abstractions是一个包含IDataPersistence接口的类库。 -
Sql和Local是引用Abstractions项目并实现IDataPersistence接口的类库。
代码示例中包含很少的实现细节,因为它与理解 DIP 无关。请假设我们使用您喜欢的内存和 SQL 数据库实现了
Persist方法的逻辑。
在视觉上,包之间的关系看起来如下:

图 3.5:包及其关系的视觉表示
在代码上,我们的抽象包含一个 Persist 方法,我们用它来展示 DIP:
namespace Abstractions;
public interface IDataPersistence
{
void Persist();
}
接下来,LocalDataPersistence 类依赖于 Abstractions 包并向控制台输出一行信息,使我们能够追踪系统中的发生情况:
using Abstractions;
namespace Local;
public class LocalDataPersistence : IDataPersistence
{
public void Persist()
{
Console.WriteLine("Data persisted by LocalDataPersistence.");
}
}
接下来,SqlDataPersistence 类与 LocalDataPersistence 类非常相似;它依赖于 Abstractions 包并在控制台输出一行信息,使我们能够追踪系统中的发生情况:
using Abstractions;
namespace Sql;
public class SqlDataPersistence : IDataPersistence
{
public void Persist()
{
Console.WriteLine("Data persisted by SqlDataPersistence.");
}
}
在我们到达程序流程之前,我们仍然需要查看 SomeService 类,它依赖于 Abstractions 包:
using Abstractions;
namespace App;
public class SomeService
{
public void Operation(IDataPersistence someDataPersistence)
{
Console.WriteLine("Beginning SomeService.Operation.");
someDataPersistence.Persist();
Console.WriteLine("SomeService.Operation has ended.");
}
}
突出的代码显示 SomeService 类调用了提供的 IDataPersistence 接口实现中的 Persist 方法。SomeService 类并不知道数据去往何处。在完整实现的情况下,someDataPersistence 实例负责数据将持久化到何处。除此之外,Operation 方法将行写入控制台,以便我们追踪发生的情况。现在从 App 包,Program.cs 文件包含以下代码:
using Core;
using Local;
using Sql;
var sqlDataPersistence = new SqlDataPersistence();
var localDataPersistence = new LocalDataPersistence();
var service = new SomeService();
service.Operation(localDataPersistence);
service.Operation(sqlDataPersistence);
在前面的代码中,我们创建了 SqlDataPersistence 和 LocalDataPersistence 实例。这样做迫使我们依赖于这两个包,但我们可以选择其他方式。然后我们创建 SomeService 类的一个实例。然后我们依次将两个 IDataPersistence 实现传递给 Operation 方法。当我们执行程序时,我们得到以下输出:
Beginning SomeService.Operation.
Data persisted by LocalDataPersistence.
SomeService.Operation has ended.
Beginning SomeService.Operation.
Data persisted by SqlDataPersistence.
SomeService.Operation has ended.
前面的终端输出的一半代表对 Operation 方法的第一次调用,其中我们传递了 LocalDataPersistence 实例。另一半代表第二次调用,其中我们传递了 SqlDataPersistence 实例。突出的行显示,依赖于接口允许我们改变这种行为(OCP)。此外,我们可以创建一个 CosmosDb 包,从 App 包中引用它,然后将 CosmosDbDataPersistence 类的实例传递给 Operation 方法,而 Core 包将不知道这一点。为什么?因为我们反转了依赖流,创建了一个松散耦合的系统。我们甚至进行了一些 依赖注入。
依赖注入,或控制反转(IoC),是 ASP.NET Core 中的一个设计原则,它是一个一等公民。它允许我们将抽象映射到实现,当我们需要一个新的类型时,整个对象树会根据我们的配置自动创建。我们在第七章,依赖注入中开始这段旅程。
结论
核心思想是依赖于抽象。接口是纯合约,这使得它们比抽象类更灵活。抽象类仍然很有帮助,我们在书中探讨了利用它们的方法。依赖于实现(类)会在类之间创建紧密耦合,这会导致一个更难维护的系统。你的依赖之间的内聚性对于耦合在长期内是帮助还是伤害至关重要。不要无意识地到处丢弃具体类型。
摘要
在本章中,我们涵盖了众多架构原则。我们首先探索了 DRY、KISS 和关注点分离原则,然后学习了 SOLID 原则及其在现代软件工程中的重要性。通过遵循这些原则,你应该能够构建更好、更易于维护的软件。正如我们也提到的,原则只是原则,而不是法律。你必须始终小心不要滥用它们,以确保它们是有益的而不是有害的。上下文始终至关重要;内部工具和关键业务应用需要不同级别的调整。本章的关键要点是:
-
不要过度设计你的解决方案(KISS)。
-
封装并重用业务逻辑(DRY)。
-
围绕关注点和责任组织元素(SoC/SRP)。
-
以可组合性(OCP)为目标。
-
支持向后兼容性(LSP)。
-
编写粒度化的接口/合约(ISP)。
-
依赖于抽象并反转依赖关系(DIP)。
在我们的工具箱中拥有所有这些原则后,我们准备好跳入设计模式,并将我们的设计水平提升一步,下一章将涵盖在 ASP.NET Core REST API 上下文中的 MVC 模式。之后,在接下来的几章中,我们将探讨如何实现一些最常用的四人帮(GoF)模式,然后讨论如何通过依赖注入在另一个层面上应用它们。
问题
让我们看看几个实践问题:
-
SOLID 这个缩写代表了多少个原则?
-
当遵循 SOLID 原则时,是否意味着要创建更大的组件,每个组件可以通过创建神级类来管理程序中的更多元素?
-
通过遵循 DRY 原则,你希望从任何地方移除所有代码重复,并将其封装到可重用的组件中。这种肯定是否正确?
-
ISP 是否告诉我们,创建多个较小的接口比创建一个大的接口更好?
-
哪个原则告诉我们,创建多个处理单个职责的小类比一个类处理多个职责更好?
进一步阅读
- 可变性和逆变(C#):
adpg.link/BxBG
第四章:4 个 REST API
在开始之前:加入我们的 Discord 书籍社区
直接向作者本人提供反馈,并在我们的 Discord 服务器上与其他早期读者聊天(在“EARLY ACCESS SUBSCRIPTION”下找到“architecting-aspnet-core-apps-3e”频道)。

本章深入探讨了网络应用程序通信的核心——REST API。在当今互联的数字世界中,不同应用程序之间的有效通信至关重要,RESTful API 在促进这种交互中发挥着关键作用。我们首先探索网络的基石:HTTP 协议。我们简要介绍了核心 HTTP 方法,如 GET、POST、PUT 和 DELETE,以了解它们如何在 RESTful 环境中执行 CRUD(创建、读取、更新、删除)操作。然后,我们将注意力转向 HTTP 状态码——系统通知客户端其请求状态的方式,以及 HTTP 头部。由于 API 会发展,而管理这些变化而不影响现有客户端是一个重大挑战,因此我们探讨了不同的 API 版本策略及其各自的权衡。然后,我们学习了数据传输对象(DTO)模式。将数据打包到 DTO 中可以提供许多好处,从减少调用次数到更好的封装和在网络发送数据时的性能提升。最后,我们还探讨了定义清晰且健壮的 API 合同的重要性,这确保了 API 的稳定性。我们讨论了设计和记录这些合同的技术,确保它们作为 API 消费者的实用指南。到本章结束时,您将了解 REST API 的工作原理,并准备好开始使用 ASP.NET Core 构建一些,随着我们进入下一章的架构之旅,我们将继续前进。在本章中,我们涵盖了以下主题:
-
REST & HTTP
-
数据传输对象 (DTO)
-
API 合同
让我们从 REST 开始。
REST & HTTP
REST,或表示性状态转移,是一种创建基于互联网的服务的方法,称为网络服务、网络 API、REST API 或 RESTful API。这些服务通常使用 HTTP 作为其传输协议。REST 重新使用已知的 HTTP 规范,而不是重新创建交换数据的新方法。例如,返回 HTTP 状态码 200 OK 表示成功,而 400 Bad Request 表示失败。以下是一些定义特征:
-
无状态性: 在 RESTful 系统中,每个客户端到服务器的请求都应该包含服务器理解并执行它所需的所有细节。服务器不保留关于客户端最近 HTTP 请求的任何信息。这提高了可靠性和可伸缩性。
-
缓存功能: 客户端应该能够缓存响应以提高性能。
-
简单性和解耦:REST 使用 HTTP 确保简化和解耦的架构。这使得 REST API 的开发、维护和扩展变得更容易,并促进了它们的用法。
-
资源可识别性:每个 REST API 端点是独特的资源,使我们能够分别保护系统的每一部分。
-
接口作为契约:REST API 层作为交换契约或抽象。它有效地隐藏了后端系统的底层实现,促进了简化的交互。
虽然我们可以深入探讨 REST API 的复杂性,但前面的特性作为基础知识,提供了足够的知识以开始使用 RESTful 服务。在掌握了这些基本知识后,让我们将重点转向理解 REST API 如何利用 HTTP 的力量。
HTTP 方法
HTTP 方法,也称为动词,定义了客户端在 RESTful API 中可以对资源执行的操作类型。每种方法代表一个特定的操作,定义了端点对资源的意图。以下是常用方法的列表、它们的作用以及预期的成功状态码:
| 方法 | 典型角色 | 成功状态码 |
|---|---|---|
GET |
获取资源(读取数据) | 200 OK |
POST |
创建新资源 | 201 CREATED |
PUT |
替换资源 | 200 OK 或 204 No Content |
DELETE |
删除资源 | 200 OK 或 204 No Content |
PATCH |
部分更新资源 | 200 OK |
接下来,我们将探讨常用的状态码。
HTTP 状态码
HTTP 状态码是 HTTP 响的一部分,并向客户端提供有关其请求成功或失败的信息;涉及类似主题的状态码被分组在相同的广泛“百位”类别下:
-
1XX(信息性) 状态码表示请求已接收且处理正在进行中,例如 100 Continue 和 101 Switching Protocols。 -
2XX(成功) 状态码表示请求已成功接收。 -
3XX(重定向) 状态码表示客户端必须采取进一步操作以完成重定向请求。 -
4XX(客户端错误) 状态码表示客户端方面的错误,例如验证错误。例如,客户端发送了一个空白的必填字段。 -
5XX(服务器错误) 状态码表示服务器未能满足显然有效的请求,并且客户端无法对此做出任何反应(重试请求不是选项)。
下表解释了一些最常见的状态码:
| 状态码 | 角色 |
|---|---|
| 200 OK | 表示请求已成功。它通常包括与资源相关的数据,位于响应体中。 |
| 201 CREATED | 表示请求已成功,系统已创建资源。它还应包括一个指向新创建资源的 Location HTTP 标头,并且可以在响应体中包含新实体。 |
| 202 ACCEPTED | 表示请求已被接受进行处理,但尚未处理。我们使用此代码进行异步操作。在一个事件驱动系统中(见第十七章,微服务架构简介),这可能意味着已发布事件,当前资源已完成其工作(发布了事件),但为了了解更多信息,客户端需要联系另一个资源,等待通知,只是等待,或者无法知道。 |
| 204 NO CONTENT | 表示请求已成功,但响应体中没有内容。 |
| 302 FOUND | 表示请求的资源临时位于Location头中指定的不同 URL 下。我们通常使用此状态码进行重定向。 |
| 400 BAD REQUEST | 表示服务器无法理解或处理请求。这通常与验证错误有关,如输入错误或缺少字段。 |
| 401 UNAUTHORIZED | 表示请求需要用户身份验证才能访问资源。 |
| 403 FORBIDDEN | 表示服务器理解了请求,但拒绝授权它。这通常意味着客户端缺乏对资源的访问权限(授权)。 |
| 404 NOT FOUND | 表示资源不存在或未找到。REST API 通常从有效端点返回此状态码。 |
| 409 CONFLICT | 表示由于与资源的当前状态冲突,服务器无法完成请求。一个典型场景是实体在其读取操作(GET)和当前更新操作(PUT)之间已更改。 |
| 500 INTERNAL SERVER ERROR | 表示服务器端发生未处理的错误,阻止其满足请求。 |
现在我们已经介绍了 HTTP 方法和状态码,我们来看看如何在不同客户端和服务器之间传递更多的元数据。
HTTP 头
REST API 利用 HTTP 头传输客户端信息并描述其选项和功能。头是请求和响应的一部分。一个著名的头是Location头,我们用它用于不同的目的。例如:
-
在创建实体(
201 Created)后,Location头应指向客户端可以访问该新实体的GET端点。 -
在启动异步操作(
202 Accepted)后,Location头可能指向状态端点,您可以在其中轮询操作的状态(是否已完成、失败或仍在进行中)。 -
当服务器想要指示客户端加载另一个页面(重定向)时,
Location头包含目标 URL。以下状态码是重定向中最常见的:301 Moved Permanently、302 Found、303 See Other、307 Temporary Redirect和308 Permanent Redirect。
Retry-After 头部在与 202 Accepted、301 Moved Permanently、429 Too Many Requests 或 503 Service Unavailable 混合使用时也很有用。ETag 头部标识实体的版本,可以与 If-Match 一起使用以避免空中碰撞。ETag 和 If-Match 头部形成一种乐观并发方法,防止请求二在同时发生或不是按预期顺序发生时覆盖请求一所做的更改;即管理冲突的一种方式。我们可以添加以下内容作为描述 REST 端点的 HTTP 头部的示例:Allow、Authorization 和 Cache-Control。列表非常长,在这里列举所有 HTTP 头部对任何人都没有帮助。
这应该足够作为理论来让你开始学习 HTTP 和 REST。如果你还想了解更多,我在章节末尾的“进一步阅读”部分留下了关于 HTTP 的 MDN 网络文档的链接。
接下来,我们来看看版本控制,因为没有什么是一成不变的;业务需求会变化,API 必须随之发展。
版本控制
版本控制是 REST API 的一个关键方面。无论 API 的版本是长期存在还是临时(例如,在旧端点退役周期中),管道的两端都必须知道期望什么;要遵守的 API 合同。除非你是唯一的消费者,否则你需要一种方法让 API 客户端在合同更改时查询特定的 API 版本。本节探讨了关于我们的版本控制策略的一些思考方式。
默认版本策略
当对 API 进行版本化时,首先要考虑的是默认策略。如果没有指定版本会发生什么?端点会返回错误、第一个版本还是最新版本?如果 API 返回错误,你应该从一开始就实施这种版本化策略,这样客户端就知道需要版本。在这种情况下,实际上没有真正的缺点。另一方面,在事后实施这种策略将破坏所有未指定版本号的客户端,这可能不是让消费者满意的最佳方式。另一方面,总是返回第一个版本是一种很好的方法,可以保持向后兼容性。你可以添加更多端点版本,而不会破坏你的消费者。相反的方式是总是返回最新版本。对于消费者来说,这意味着指定要消费的版本或保持最新或中断,这可能不是提供给消费者的最佳用户体验。尽管如此,许多人选择了这种默认策略。另一种选择是选择任何版本作为 API 的默认基线(例如版本 3.2)或甚至为每个端点选择不同的版本。比如说,你默认为 3.2,然后部署 4.0。由于客户端必须选择加入以访问新 API,它们不会自动中断,并且将有时间根据他们自己的路线图从 3.2 更新到 4.0。这是在向前推进破坏性更改之前将已知和稳定的 API 版本作为默认策略的好方法。
无论你选择什么,都要权衡利弊,仔细思考。
接下来,我们将探讨定义这些版本的方法。
版本化策略
当然,有多个方法来思考这个问题。你可以利用 URL 模式来定义和包含 API 版本,例如https://localhost/v2/some-entities。这种策略更容易从浏览器中查询,使得一眼就能知道版本,但端点不再指向唯一的资源(REST 的一个关键原则),因为每个资源都有一个端点对应每个版本。尽管如此,这种版本化 API 的方式被广泛使用,并且是最受欢迎的方法之一,如果不是最受欢迎的方法,即使它违反了其核心原则(有争议)。另一种方式是使用 HTTP 头。你可以使用自定义头,如api-version或Accept-version,例如,或者标准的Accept头。这种方式允许资源有唯一的端点(URI),同时使每个实体有多个版本(描述相同实体的每个 API 合约的多个版本)。例如,客户端可以在调用端点时指定 HTTP 头,如下所示(自定义头):
GET https://localhost/some-entities
Accept-version: v2
或者像下面这样,通过利用Accept头进行内容协商:
GET https://localhost/some-entities
Accept: application/vnd.api.v2+json
不同的人在使用
Accept头时使用不同的值,例如:
-
application/vnd.myapplication.v2+json -
application/vnd.myapplication.entity.v2+json -
application/vnd.myapplication.api+json; version=2 -
application/json; version=2
无论你使用哪种方式,你很可能会在某些时候需要对 API 进行版本控制。有些人强烈支持一种方式或另一种方式,但最终,你应该根据具体情况决定哪种方式最适合你的需求和能力:简单性、正式性或两者的结合。
总结
通过一个方法(动词),客户端(和端点)可以表达创建、更新、读取或删除实体的意图。通过状态码,端点可以告诉客户端操作的状态。通过添加 HTTP 头,客户端和服务器可以向请求或响应添加更多元数据。最后,通过添加版本控制,REST API 可以在不破坏现有客户端的同时,给他们提供消费特定版本的选择。根据我们刚才所讨论的,你应该有足够的知识来跟随本书中的示例,并在构建 REST API 的过程中构建一些 API。
数据传输对象(DTO)
数据传输对象(DTO)设计模式是一种在类似 REST API 这样的面向服务的架构中管理和传输数据的稳健方法。DTO 模式是关于组织数据以最优地将其传递给 API 客户端。DTO 是 API 合同的一个组成部分,我们将在下一部分进行探讨。
目标
DTO 的目标是通过松散耦合暴露的 API 表面与应用程序的内部工作方式来控制端点的输入和输出。DTO 赋予我们以我们希望消费者与之交互的方式构建我们的网络服务。因此,无论底层系统如何,我们都可以使用 DTO 来设计更容易消费、维护和演化的端点。
设计
每个 DTO 代表一个具有所有必要属性的实体。该实体可以是输入或输出,并允许构建客户端和 API 之间的交互。DTO 通过增加一个抽象层,将我们的领域与通过 API 暴露的数据松散耦合。这允许我们在不影响 API 消费者暴露的数据的情况下更改底层领域模型,反之亦然。另一种使用 DTO 的方法是将相关的信息片段打包在一起,允许客户端通过单个调用获取所有必要的数据,从而消除多次请求的需要。基于 REST 和 HTTP,请求的流程如下:一个 HTTP 请求进来,执行一些代码(领域逻辑),然后一个 HTTP 响应返回给客户端。以下图表表示了这个流程:

图 4.1:一个 HTTP 请求进入并离开 REST API 端点。
现在,如果我们用 DTO 替换那个流程中的 HTTP,我们可以看到 DTO 可以作为数据合同的一部分,作为输入或输出:

图 4.2:一个输入 DTO 撞击某些领域逻辑,然后端点返回一个输出 DTO
如何将 HTTP 请求变成一个对象?大多数时候:
-
我们对输入使用反序列化或数据绑定。
-
我们对输出使用序列化。
让我们看看几个例子。
概念示例
概念上讲,假设我们正在构建一个允许人们注册活动的网络应用程序。我们接下来探索两个用例。
活动注册
我们正在探索的第一个场景是用户注册活动。活动是系统中的某种事件。我们使用外部支付网关,因此我们的应用程序永远不会处理财务数据。尽管如此,我们必须将交易数据发送到我们的后端以关联和完成支付。以下图表示例描述了工作流程:

图 4.3:活动注册流程中涉及的 DTO。
请求体可能看起来如下 JSON 片段:
{
"registrant": {
"firstname": "John",
"lastname": "Doe"
},
"activity": {
"id": 123,
"seats": 2
},
"payment": {
"nonce": "abc123"
}
}
接下来,以下 JSON 片段可能代表响应体:
{
"status": "Success",
"numberOfSeats": 2,
"activityId": 123,
"activityDate": "2023-06-03T20:00:00"
}
当然,这是一个非常轻量级的注册系统版本。目标是展示:
-
三个实体作为 HTTP POST 请求进入(注册者、活动和支付信息)。
-
系统执行了一些业务逻辑,将人员注册到活动中,并完成财务交易。
-
API 向客户端返回了混合信息。
这种模式便于只输入和输出所需的内容。如果您正在设计消费 API 的用户界面,输出一个经过深思熟虑的 DTO 可以确保 UI 只需读取来自服务器的响应即可渲染下一屏幕,从而节省 UI 获取更多数据,加快处理速度,并提高用户体验。
我们接下来探索获取活动注册信息。
获取活动注册详情
在同一系统中,用户想要使用前面的过程查看他注册的活动详情。在这种情况下,流程如下:
-
客户通过
GET请求发送注册标识符。 -
系统获取注册者信息、活动信息和用户为该活动预留的座位数。
-
服务器将数据返回给客户端。
以下图示直观地表示了用例:

图 4.4:获取已注册活动相关信息的 DTO。
在这种情况下,输入将是 URL 的一部分,例如 /registrations/123。输出将是响应体的一部分,可能看起来如下:
{
"registrant": {
"firstname": "John",
"lastname": "Doe"
},
"activity": {
"id": 123,
"name": "Super Cool Show",
"date": "2023-06-03T20:00:00"
},
"numberOfSeats": 2
}
通过使用精心设计的输出 DTO 创建该端点,我们将三个 HTTP 请求压缩成了一个:注册者、活动和注册(座位数)。这种强大的技术适用于任何技术,而不仅仅是 ASP.NET Core,并允许我们设计 API,而不需要直接将客户端连接到我们的数据(松散耦合)。
结论
数据传输对象(DTO)允许我们设计一个具有专用输入和输出的 API 端点,而不是暴露领域或数据模型。这些 DTO 保护我们的内部业务逻辑,这提高了我们设计 API 的能力,并有助于使它们更加安全。
通过定义 DTO,我们可以避免恶意行为者尝试绑定他不应访问的数据。例如,当使用仅包含
username和password属性的输入“登录 DTO”时,恶意用户无法尝试绑定我们领域和数据库中可用的IsAdmin字段。还有其他方法可以减轻这种攻击,但它们超出了本章的范围。然而,DTO 是减轻这种攻击向量的绝佳候选者。
展示层与领域之间的这种分离是一个关键元素,它导致我们拥有多个独立的组件,而不是一个更大、更脆弱的组件,或者将内部数据结构泄露给使用 API 的客户端。我们在接下来的几章中探讨构建 API,并在第四部分,设计应用规模中更深入地探讨一些主题。使用 DTO 模式可以帮助我们以下列方式遵循 SOLID 原则:
-
S: DTO(数据传输对象)在领域逻辑或数据与 API 契约之间建立了清晰的边界,将一个模型划分为几个不同的职责,以帮助保持事物的隔离。
-
O: N/A
-
L: N/A
-
I: DTO 是一个更小、专门定制的模型,它服务于一个明确的目的。有了 DTO,我们现在有两个模型(领域和 API 契约)和几个类(输入 DTO、输出 DTO 和领域或数据实体),而不是一个通用的模型(只有领域或数据实体)。
-
D: N/A
接下来,我们将探讨如何将我们迄今为止探索的各个部分粘合到 API 契约中。
API 契约
API 合约作为一项基本蓝图,概述了您的 API 与其消费者之间的互动规则。这包括可用的端点、它们支持的 HTTP 方法、预期的请求格式以及可能的响应结构,包括 HTTP 状态码。这些合约提供了清晰性、健壮性、一致性和互操作性,促进了无论使用何种语言构建的系统之间的无缝交互。此外,良好的 API 合约文档是一个可靠的参考指南,帮助开发者有效地理解和利用您的 API。因此,设计全面且清晰的 API 合约对于构建高质量、可维护和用户友好的 API 至关重要。API 合约描述了一个 REST API,因此消费者应该知道如何调用端点以及可以期待得到什么回报。端点所做的事情或它提供的功能仅通过阅读其合约就可以明确。REST API 中的每个端点至少应提供以下签名:
-
一个统一资源标识符(URI),指示如何访问它。
-
一个描述它执行操作类型的 HTTP 方法。
-
一个输入定义,说明操作发生所需的条件。例如,输入可以是 HTTP 体、URL 参数、查询参数、HTTP 头,甚至它们的组合。
-
一个输出定义,说明客户端应该期待什么。客户端应该期待多个输出定义,因为端点在请求成功或失败时不会返回相同的信息。
端点的输入和输出通常是 DTO(数据传输对象),这使得 DTO 变得更加重要。
定义 API 合约有多种方式。例如,为了定义一个 API 合约,我们可以做以下事情:
-
打开任何文本编辑器,例如 MS Word 或记事本,开始编写描述我们的 Web API 的文档;这可能是最繁琐且最不灵活的方法。我不推荐这种方法,有多个原因。
-
在 Markdown 文件中编写规范并将其保存到您的项目 Git 仓库中以便易于发现。这与 MS Word 非常相似,但更易于所有团队成员消费。这种方法比 Word 更好,但还不是最佳选择,因为当 API 发生变化时,您需要手动更新这些文件。
-
使用现有的标准,例如 OpenAPI 规范(以前称为 Swagger)。这种技术意味着有一个学习曲线,但结果应该更容易消费。此外,许多工具允许我们使用 OpenAPI 规范创建自动化。这种方法开始消除手动干预的需求。
-
使用以代码优先的方法和 ASP.NET Core 工具从您的代码中提取 OpenAPI 规范。
-
使用任何其他符合我们要求的工具。
提示
Postman 是一个构建 Web API 文档、测试套件以及实验 API 的绝佳工具。它支持 OpenAPI 规范,允许您创建模拟服务器,支持环境,等等。
无论使用什么工具,在如何设计 REST API 的 API 合约方面,有两个主要趋势:
-
首先设计合约,然后构建 API(合约优先)。
-
构建 API,然后从代码中提取合约(代码优先)。
为了设计合约优先,必须采用一个工具来编写规范,然后根据规范编写 API。
我在下面的进一步阅读部分留下了一个关于 Open API 的链接。
另一方面,为了使用代码优先方法并自动从 API 中提取 OpenAPI 规范,我们必须确保我们的端点可以被.NET 的ApiExplorer发现。无论您如何操作,在 ASP.NET Core 中,我们使用类和结构来表示我们的 REST API 的数据合约;无论是在编写 API 合约之前还是之后,这都不重要。由于我更喜欢将 C#转换为 YAML 或 JSON,我们接下来探讨如何利用 Swagger 以代码优先的方式生成数据合约。
代码优先 API 合约
在这个示例中,我们有一个包含两个端点的微小 API:
-
读取指定的实体。
-
创建一个新的实体。
代码做得不多,只返回假数据,但足以探索其数据合约。
记住,代码就像玩乐高®积木,但我们通过连接许多一起使用的微小模式来创建我们的软件和创造价值。理解和学习这项技能将使你超越仅仅能够使用一些现成的魔法食谱,这限制了你对人们分享给你的内容的依赖。
在这个示例中,我们使用 OpenAPI 规范来描述我们的 API。为了节省我们编写 JSON 并采用代码优先的方式,我们利用 SwaggerGen 包。
要使用 SwaggerGen,我们必须安装
Swashbuckle.AspNetCore.SwaggerGenNuGet 包。
这是Program.cs文件,没有端点,展示了如何利用 SwaggerGen:
var builder = WebApplication.CreateBuilder(args);
builder.Services.AddEndpointsApiExplorer();
builder.Services.AddSwaggerGen();
var app = builder.Build();
app.UseSwagger();
// Omitted endpoints
app.Run();
突出的行是我们必须做的唯一事情,以便在项目中使用 SwaggerGen,它将为我们生成 OpenAPI 规范中的 API 合约。JSON 文件非常长(113 行),所以我只在书中粘贴了一些片段以供清晰。然而,您可以导航到/swagger/v1/swagger.json URL 以访问完整的 JSON 代码或打开项目中的swagger.json文件。
我在项目中创建了
swagger.json文件以方便使用。工具不会生成物理文件。
让我们看看那些端点。
第一个端点
允许客户端读取实体的第一个端点的代码如下:
app.MapGet(
"/{id:int}",
(int id) => new ReadOneDto(
id,
"John Doe"
)
);
public record class ReadOneDto(int Id, string Name);
这里是我们可以从前面的代码中提取的 API 合约:
| 合约段 | 值 |
|---|---|
| HTTP 方法 | GET |
| URI | /id(例如,/123) |
| 输入 | id参数 |
| 输出 | ReadOneDto类的实例。 |
发送以下 HTTP 请求(您可以使用ReadOneEntity.http文件)将产生以下输出:
GET /123 HTTP/1.1
Accept: application/json
Host: localhost:7000
Connection: keep-alive
简化后的响应是:
HTTP/1.1 200 OK
Content-Type: application/json; charset=utf-8
Date: Sat, 03 Jun 2023 17:41:42 GMT
Server: Kestrel
Alt-Svc: h3=":7000"; ma=86400
Transfer-Encoding: chunked
{"id":123,"name":"John Doe"}
如我们所见,当我们查询 API 以获取 id=123 的实体时,端点返回该实体,状态码为 200 OK,响应体是 ReadOneDto 类的序列化实例。
.http文件是 VS 2022 的新功能,允许我们从 VS 本身编写和执行 HTTP 请求。如果您想了解更多信息,我在 进一步阅读 部分留下了一个链接。
SwaggerGen 为第一个端点生成了以下 OpenAPI 规范:
"/{id}": {
"get": {
"parameters": [
{
"name": "id",
"in": "path",
"required": true,
"schema": {
"type": "integer",
"format": "int32"
}
}
],
"responses": {
"200": {
"description": "Success",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/ReadOneDto"
}
}
}
}
}
}
},
那段代码描述了端点并引用了我们的输出模型(高亮行)。模式在 JSON 文件的底部。以下是表示 ReadOneDto 的模式:
"ReadOneDto": {
"type": "object",
"properties": {
"id": {
"type": "integer",
"format": "int32"
},
"name": {
"type": "string",
"nullable": true
}
},
"additionalProperties": false
}
如高亮行所示,该模式有一个 name 属性,类型为 string,一个 id 属性,类型为 integer,与我们的 ReadOneDto 类相同。幸运的是,我们不需要编写那个 JSON,因为工具根据我们的代码生成它。接下来,我们看看第二个端点。
第二个端点
第二个端点的代码允许客户端创建实体如下所示:
app.MapPost(
"/",
(CreateDto input) => new CreatedDto(
Random.Shared.Next(int.MaxValue),
input.Name
)
);
public record class CreateDto(string Name);
public record class CreatedDto(int Id, string Name);
以下是我们可以从前面的代码中提取的 API 合同:
| 合同段 | 值 |
|---|---|
| HTTP 方法 | POST |
| URI | / |
| 输入 | CreateDto 类的一个实例。 |
| 输出 | CreatedDto 类的一个实例。 |
发送以下 HTTP 请求(您可以使用 CreateEntity.http 文件)将产生以下输出:
POST / HTTP/1.1
Content-Type: application/json
Host: localhost:7000
Accept: application/json
Connection: keep-alive
Content-Length: 28
{
"name": "Jane Doe"
}
简化后的响应如下:
HTTP/1.1 200 OK
Content-Type: application/json; charset=utf-8
Date: Sat, 03 Jun 2023 17:59:25 GMT
Server: Kestrel
Alt-Svc: h3=":7000"; ma=86400
Transfer-Encoding: chunked
{"id":1624444431,"name":"Jane Doe"}
如前所述的请求所示,客户端发送了 CreateDto 类的序列化实例,将名称设置为 Jane Doe,并收到了相同的实体,但具有数字 id 属性(CreatedDto 类的一个实例)。我们的端点 OpenAPI 规范如下所示:
"/": {
"post": {
"requestBody": {
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/CreateDto"
}
}
},
"required": true
},
"responses": {
"200": {
"description": "Success",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/CreatedDto"
}
}
}
}
}
}
}
输入和输出模式如下:
"CreateDto": {
"type": "object",
"properties": {
"name": {
"type": "string",
"nullable": true
}
},
"additionalProperties": false
},
"CreatedDto": {
"type": "object",
"properties": {
"id": {
"type": "integer",
"format": "int32"
},
"name": {
"type": "string",
"nullable": true
}
},
"additionalProperties": false
},
与第一个端点类似,SwaggerGen 将我们的 C# 类转换为 OpenAPI 规范。让我们总结一下。
总结
一些 ASP.NET Core 模板预配置了 SwaggerGen。它还包含 Swagger UI,允许您从您的应用程序中直观地探索 API 合同,甚至查询它。NSwag 是另一个提供类似功能的工具。大量的在线文档展示了如何利用这些工具。除了探索工具之外,我们还定义了 API 合同是基本的,并促进了健壮性和可靠性。每个端点都有以下部分作为整体 API 合同的一部分:
-
它可访问的 URI。
-
最佳定义操作的 HTTP 方法。
-
输入。
-
一个或多个输出。
通过组合不同的 HTTP 方法和输入,单个 URI 可以指向多个端点。例如,
GET /api/entities可能返回实体列表,而POST /api/entities可能创建一个新的实体。使用实体的复数形式作为约定被许多人所采用。
我们接下来探讨数据传输对象,以增加对该模式的清晰度。
摘要
REST API 促进了当今互联数字世界中应用程序之间的通信。我们探讨了 HTTP 协议、HTTP 方法、HTTP 状态码和 HTTP 头部信息。然后我们探讨了 API 版本控制、数据传输对象(DTOs)以及 API 合约的重要性。以下是一些关键要点:
-
REST 与 HTTP:REST API 是网络应用通信的核心。它们使用 HTTP 作为传输协议,利用其方法、状态码和头部信息来促进不同应用之间的交互。
-
HTTP 方法:HTTP 方法或动词(GET、POST、PUT、DELETE、PATCH)定义了客户端在 RESTful API 中对资源可以执行的操作类型。理解这些方法是执行 CRUD 操作的关键。
-
HTTP 状态码和头部信息:HTTP 状态码通知客户端其请求的成功或失败。HTTP 头部传输额外的信息,并描述客户端的选项和能力。这两者都是 HTTP 通信的基本组成部分。
-
版本控制:在不干扰现有客户端的情况下管理 API 的变化是一个重大挑战。不同的 API 版本控制策略可以帮助解决这个问题,但每种策略都有其自身的权衡。
-
数据传输对象 (DTO): DTOs 将数据打包成一种格式,可以提供许多好处,包括减少 HTTP 调用次数、提高封装性,以及在通过网络发送数据时提升性能。
-
API 合约:清晰且健壮的 API 合约确保 API 的稳定性。它们作为 API 与其消费者之间交互的蓝图,概述了可用的端点、支持的 HTTP 方法、预期的请求格式和可能的响应结构。
-
实际应用:理解这些概念不仅从理论上重要,而且在使用 ASP.NET Core 或任何其他类似技术构建和操作 REST API 时也非常实用。
到现在为止,你应该对 REST API 有了扎实的理解,并准备好探索如何使用 ASP.NET Core 实现一个 API。ASP.NET Core 使得使用 MVC 或最小 API 编写 REST API 变得轻而易举。MVC 是一个广泛使用的模式,几乎无法避免。然而,新的最小 API 模型使过程更加精简。此外,通过应用模式如请求-端点-响应(REPR)或垂直切片架构,我们可以按功能而不是按层组织我们的 API,从而提高组织效率。我们将在第四部分:应用模式中介绍这些主题。接下来,我们将探讨使用 ASP.NET Core 进行设计,从最小 API 开始。
问题
让我们看看几个练习题:
-
在创建实体后,REST API 中最常见的状态码是什么?
-
如果你引入一个默认策略,在没有指定版本时返回最低版本,这会破坏现有的客户端吗?
-
如果你想从服务器读取数据,你会使用哪种 HTTP 方法?
-
DTOs 能否为系统增加灵活性和健壮性?
-
DTOs 是 API 合约的一部分吗?
进一步阅读
这里有一些链接,可以帮助我们巩固本章所学的内容:
-
HTTP 请求方法(MDN):
adpg.link/MFWb -
HTTP 响应状态码(MDN):
adpg.link/34Jq -
HTTP 头部(MDN):
adpg.link/Hx55 -
在 Visual Studio 2022 中使用.http 文件:
adpg.link/cbhv -
OpenAPI 规范:
adpg.link/M4Uz
答案
-
一个 API 在创建新实体后通常会返回状态码
201 Created。 -
不,它不会破坏客户端,因为它们要么在使用最低的 API 版本,要么已经指定了特定的版本。
-
我们通常使用 HTTP GET 方法从 REST API 读取数据。
-
是的,数据传输对象(DTOs)可以为系统增加灵活性和健壮性。它们允许你精确控制向客户端暴露的数据,并且可以减少需要通过网络发送的不必要数据量。
-
是的,DTOs 是 API 合约的一部分。它们定义了客户端和服务器之间交换的数据格式,确保双方都能理解发送和接收的数据。
第五章:5 最小化 API
在开始之前:加入我们的 Discord 书籍社区
直接向作者本人提供反馈,并在我们的 Discord 服务器上与其他早期读者聊天(在“EARLY ACCESS SUBSCRIPTION”下找到“architecting-aspnet-core-apps-3e”频道)。

本章涵盖了最小化 API,这是一种简化设置和运行.NET 应用程序的方法。我们探讨了使最小化托管和最小化 API 成为 ASP.NET Core 关键更新的原因,我们揭示了创建 API 的简洁性。我们涵盖了 ASP.NET Core 最小化 API 带来的许多可能性,例如如何配置、自定义和组织这些端点。我们还探讨了使用数据传输对象(DTOs)与最小化 API 结合使用,将简洁性与有效的数据管理相结合,有效地构建 API 合同。受其他技术启发,这些主题为.NET 世界带来了新的视角,使我们能够在不牺牲弹性的情况下构建精简且性能良好的 API。在本章中,我们涵盖了以下主题:
-
顶级语句
-
最小化托管
-
最小化 API
-
使用数据传输对象(DTOs)与最小化 API
让我们从顶级语句开始。
顶级语句
.NET 团队在.NET 5 和 C# 9 中向语言引入了顶级语句。从那时起,在声明命名空间和其他成员之前编写语句成为可能。在底层,编译器将这些语句输出到Program.Main方法。使用顶级语句,一个最小的.NET“Hello World”控制台程序看起来像这样(Program.cs):
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应用程序,并在程序终止前在控制台输出以下内容:
Hello world!
在这样的语句之上,我们还可以声明其他成员,如类,并在我们的应用程序中使用它们。然而,我们必须在顶级代码的末尾声明类。
注意,顶级语句代码不属于任何命名空间,建议在命名空间中创建类,因此您应该将
Program.cs文件中进行的声明数量限制在其内部工作内部,如果有的话。
顶级语句非常适合开始使用 C#,编写代码示例,并删除样板代码。
在前面的 C#代码中(
using System;),当启用隐式使用功能时,该功能是.NET 6+项目的默认设置,高亮显示的行是不必要的。模板将以下行添加到.csproj文件中:
<ImplicitUsings>enable</ImplicitUsings>
接下来,我们将探讨使用顶级语句构建的最小化托管模型。
最小化托管
.NET 6 引入了最小化托管模型。它将 Startup 和 Program 类合并为一个 Program.cs 文件。它利用顶层语句来最小化启动应用程序所需的样板代码。它还使用 全局使用指令 和 隐式使用 功能进一步减少样板代码。此模型只需要一个文件,包含以下三行代码即可创建 Web 应用程序:
var builder = WebApplication.CreateBuilder(args);
var app = builder.Build();
app.Run();
让我们称这种方法比之前更精简。当然,前面的代码启动了一个什么也不做的应用程序,但要在之前做同样的事情,可能需要数十行代码。最小化托管代码分为两部分:
-
我们使用的 Web 应用程序构建器 来配置应用程序、注册服务、设置、环境、日志等(高亮显示的代码)。
-
我们使用的 Web 应用程序 来配置 HTTP 管道和路由(非高亮显示的行)。
这种简化的模型导致了我们接下来要探讨的最小化 API。
最小化 API
ASP.NET Core 的最小化 API 建立在最小化托管模型之上,为构建 Web 应用程序提供了一种精简的方法。高度受 Node.js 启发,它们通过减少样板代码来简化 API 的开发。通过强调简单性和性能,它们提高了可读性和可维护性。它们非常适合微服务架构和旨在保持精简的应用程序。
您也可以使用最小化 API 构建大型应用程序;这里的“最小化”指的是它们精简的方法,而不是您可以使用它们创建的应用程序类型。
这种最小化方法在功能上略有妥协,但提高了灵活性和速度,确保您对 API 的行为有完全的控制权,同时保持项目精简高效。最小化 API 包含大多数应用程序所需的必要功能,如模型绑定、依赖注入、过滤器以及路由到代理模型。如果您需要 MVC 的所有功能,您仍然可以选择使用 MVC。您甚至可以使用两者;这并不是非此即彼。
我们在 第六章 中探讨了模型-视图-控制器 (MVC) 模式,MVC。
让我们看看如何映射路由。
映射路由到代理
它是如何工作的?最小化 API 带来了多个扩展方法来配置 HTTP 管道和配置端点。我们可以使用这些方法将路由(URL 模式)映射到 RequestDelegate 代理。我们可以使用以下方法来映射不同的 HTTP 方法:
| 方法 | 描述 |
|---|---|
MapGet |
将 GET 请求映射到 RequestDelegate。 |
MapPost |
将 POST 请求映射到 RequestDelegate。 |
MapPut |
将 PUT 请求映射到 RequestDelegate。 |
MapDelete |
将 DELETE 请求映射到 RequestDelegate。 |
MapMethods |
将路由模式和多个 HTTP 方法映射到 RequestDelegate。 |
Map |
将路由模式映射到 RequestDelegate。 |
MapFallback |
将回退 RequestDelegate 映射到没有其他路由匹配时运行的。 |
MapGroup |
允许配置适用于该组下定义的所有端点的路由模式属性。 |
表 5.1:映射路由到委托的扩展方法。
这里有一个最小 GET 示例:
app.MapGet("minimal-endpoint-inline", () => "GET!");
当执行程序时,导航到 /minimal-endpoint-inline URI 将请求路由到已注册的 RequestDelegate(高亮代码),它输出以下字符串:
GET!
如此简单,我们就可以将请求路由到委托并创建端点。
在注册端点的基础上,我们还可以像任何其他 ASP.NET Core 应用程序一样注册中间件。此外,内置的中间件,如身份验证和 CORS,与最小 API 的工作方式相同。
接下来,我们将探索配置端点的方法,这样我们就可以创建比返回字面字符串的端点更好的 API。
配置端点
现在我们知道了,使用最小 API,我们将路由映射到委托,并且我们已经了解了一些实现此目的的方法,让我们探索如何注册委托:
-
内联,就像前面的示例一样。
-
使用方法。
要内联声明委托,我们可以这样做:
app.MapGet("minimal-endpoint-inline", () => "GET!");
要使用方法,我们可以这样做:
app.MapGet("minimal-endpoint-method", MyMethod);
void MyMethod() { }
当启用时,ASP.NET Core 将包含方法的类名注册到
ApiExplorer作为标签。我们将在本章的后面进一步探讨元数据。
本章中探讨的所有概念都适用于两种注册委托的方式。让我们首先研究如何在端点中输入数据。
输入
端点很少没有参数(没有输入值)。类似于 MVC 的最小 API 支持广泛的绑定来源。绑定来源表示将 HTTP 请求转换为强类型 C# 对象的过程,该对象作为参数输入。大多数参数绑定都是隐式的,但如果你需要显式绑定参数,以下是一些支持的绑定来源:
| 源 | 属性 | 描述 |
|---|---|---|
Route |
[FromRoute] |
绑定与参数名称匹配的路由值。 |
Query |
[FromQuery] |
绑定与参数名称匹配的查询字符串值。 |
Header |
[FromHeader] |
绑定与参数名称匹配的 HTTP 头部值。 |
Body |
[FromBody] |
将请求的 JSON 主体绑定到参数的类型。 |
Form |
[FromForm] |
绑定与参数名称匹配的表单值。 |
Services |
[FromServices] |
从 ASP.NET Core 依赖注入容器中注入服务。 |
Custom |
[AsParameters] |
将表单值绑定到类型。匹配发生在表单键和属性名称之间。 |
表 5.2:支持的绑定来源
接下来是一个演示,我们将路由中的 id 参数(高亮代码)隐式绑定到委托中的参数:
app.MapGet(
"minimal-endpoint-input-route-implicit/{id}",
(int id) => $"The id was {id}."
);
在大多数情况下,绑定是隐式的。但是,你可以像这样显式绑定委托的参数:
app.MapGet(
"minimal-endpoint-input-route-explicit/{id}",
([FromRoute] int id) => $"The id was {id}."
);
我们还可以隐式地将依赖项注入到我们的委托中,甚至可以与路由参数混合使用,如下所示:
app.MapGet(
"minimal-endpoint-input-service/{value}",
(string value, SomeInternalService service)
=> service.Respond(value)
);
public class SomeInternalService {
public string Respond(string value)
=> $"The value was {value}";
}
按照这种模式,我们可以为我们的端点输入数据提供无限的可能性。
如果您不熟悉依赖注入(DI),我们将在第八章 依赖注入 中更深入地探讨 DI。同时,请记住,我们可以将对象绑定到参数,无论它们是 DTO 还是服务。
此外,ASP.NET Core 还为我们提供了一些特殊类型,我们将在下面进行探讨。
特殊类型
我们可以将以下对象作为参数注入到我们的委托中,ASP.NET Core 会为我们管理它们:
| 类 | 描述 |
|---|---|
HttpContext |
HttpContext 包含了所有当前的 HTTP 请求和响应细节。HttpContext 暴露了我们正在探讨的所有其他特殊类型,因此如果您需要多个类型,可以直接注入 HttpContext 以减少参数数量。 |
HttpRequest |
我们可以使用 HttpRequest 在当前请求上执行基本的 HTTP 操作,例如手动查询参数并绕过 ASP.NET Core 数据绑定机制。与 HttpContext.Request 属性相同。 |
HttpResponse |
与 HttpRequest 类似,我们可以利用 HttpResponse 对象在 HTTP 响应上执行手动操作,例如直接写入响应流、手动管理 HTTP 头等。与 HttpContext.Response 属性相同。 |
CancellationToken |
将取消标记传递给异步操作是一种推荐的做法。在这种情况下,它允许在请求被取消时取消操作。与 HttpContext.RequestAborted 属性相同。 |
ClaimsPrincipal |
要访问当前用户,我们可以注入一个 ClaimsPrincipal 实例。与 HttpContext.User 属性相同。 |
表 5.3:特殊 HTTP 类型
以下是一个示例,其中两个端点写入响应流,一个使用 HttpContext,另一个使用 HttpResponse 对象:
app.MapGet(
"minimal-endpoint-input-HttpContext/",
(HttpContext context)
=> context.Response.WriteAsync("HttpContext!")
);
app.MapGet(
"minimal-endpoint-input-HttpResponse/",
(HttpResponse response)
=> response.WriteAsync("HttpResponse!")
);
我们可以将这些特殊类型视为任何其他绑定,并与其他类型(如路由值和服务)无缝集成。我们将在下一节介绍数据绑定的最后一部分。
自定义绑定
我们可以手动将请求数据绑定到自定义类的实例。我们可以通过以下方式实现:
-
创建一个静态的
TryParse方法,用于解析来自路由、查询或头值的字符串。 -
创建一个静态的
BindAsync方法,直接使用HttpContext控制绑定过程。
我们必须在打算使用 HTTP 请求数据的类中编写这些静态方法。我们将在下面探讨这两种场景。
手动解析
TryParse 方法接受一个字符串和一个自身类型的 out 参数。框架使用该方法将值解析为所需类型。解析 API 支持实现以下方法之一:
public static bool TryParse(string value, TSelf out result);
public static bool TryParse(string value, IFormatProvider provider, TSelf out result);
实现
IParsable<TSelf>接口提供了适当的TryParse方法。
这里有一个示例,它解析了经纬度坐标:
app.MapGet(
"minimal-endpoint-input-Coordinate/",
(Coordinate coordinate) => coordinate
);
public class Coordinate : IParsable<Coordinate>
{
public double Latitude { get; set; }
public double Longitude { get; set; }
public static Coordinate Parse(
string value,
IFormatProvider? provider)
{
if (TryParse(value, provider, out var result))
{
return result;
}
throw new ArgumentException(
"Cannot parse the value into a Coordinate.",
nameof(value)
);
}
public static bool TryParse(
[NotNullWhen(true)] string? s,
IFormatProvider? provider,
[MaybeNullWhen(false)] out Coordinate result)
{
var segments = s?.Split(
',',
StringSplitOptions.TrimEntries |
StringSplitOptions.RemoveEmptyEntries
);
if (segments?.Length == 2)
{
var latitudeIsValid = double.TryParse(
segments[0],
out var latitude
);
var longitudeIsValid = double.TryParse(
segments[1],
out var longitude
);
if (latitudeIsValid && longitudeIsValid)
{
result = new() {
Latitude = latitude,
Longitude = longitude
};
return true;
}
}
result = null;
return false;
}
}
在前面的代码中,端点返回了 Coordinate 类的 JSON 表示形式,而 TryParse 方法将输入字符串解析为 Coordinate 对象。
Coordinate类的Parse方法来自IParsable<TSelf>接口,对于模型绑定不是必需的。
例如,如果我们请求以下 URI:
/minimal-endpoint-input-Coordinate?coordinate=45.501690%2C%20-73.567253
端点返回:
{
"latitude": 45.50169,
"longitude": -73.567253
}
将字符串解析为对象是简单场景的一个可行选择。然而,更复杂的场景需要另一种技术,我们将在下一部分探讨。
手动绑定
BindAsync 方法接受一个 HttpContext 和一个 ParameterInfo 参数,并返回一个 ValueTask<TSelf>,其中 TSelf 是我们进行数据绑定的类型。HttpContext 表示数据源(HTTP 请求),而 ParameterInfo 表示代表参数的委托,我们可以从中获取一些信息,比如它的名称。数据绑定 API 支持实现以下方法之一:
public static ValueTask<TSelf?> BindAsync(HttpContext context, ParameterInfo parameter);
public static ValueTask<TSelf?> BindAsync(HttpContext context);
实现
IBindableFromHttpContext<TSelf>接口提供了适当的BindAsync方法。
这里有一个示例,它将 Person 从 HTTP 请求的查询参数中绑定:
app.MapGet(
"minimal-endpoint-input-Person/",
(Person person) => person
);
public class Person : IBindableFromHttpContext<Person>
{
public required string Name { get; set; }
public required DateOnly Birthday { get; set; }
public static ValueTask<Person?> BindAsync(
HttpContext context,
ParameterInfo parameter)
{
var name = context.Request.Query["name"].Single();
var birthdayIsValid = DateOnly.TryParse(
context.Request.Query["birthday"],
out var birthday
);
if (name is not null && birthdayIsValid) {
var person = new Person() {
Name = name,
Birthday = birthday
};
return ValueTask.FromResult(person)!;
}
return ValueTask.FromResult(default(Person));
}
}
上一段代码返回了人的 JSON 表示形式。例如,如果我们请求以下 URI:
/minimal-endpoint-input-Person?name=John%20Doe&birthday=2023-06-14
端点返回:
{
"name": "John Doe",
"birthday": "2023-06-14"
}
如我们所见,BindAsync 方法比 TryParse 方法强大得多,因为我们可以使用 HttpContext 访问更广泛的选择,从而允许我们覆盖更复杂的使用场景。然而,在这种情况下,我们可以利用 [AsParameters] 属性来实现相同的结果,并从查询中获取数据,而无需手动编写数据绑定代码。这是一个探索该属性的好机会;以下是相同代码的更新版本:
app.MapGet(
"minimal-endpoint-input-Person2/",
([AsParameters] Person2 person) => person
);
public class Person2
{
public required string Name { get; set; }
public required DateOnly Birthday { get; set; }
}
就这样;AsParameters 属性为我们完成了工作!现在我们已经涵盖了从 HTTP 请求的不同位置读取输入值,是时候探索如何输出结果了。
输出
有几种方法可以从我们的委托中输出数据:
-
返回一个可序列化的对象。
-
返回一个
IResult实现的实例。 -
返回一个
Results<TResult1, TResult2, …, TResultN>,其中TResult泛型参数表示端点可以返回的不同IResult实现。
我们将在下一部分探讨那些可能性。
可序列化对象
第一种方法是返回一个可序列化的对象,就像我们在关于输入的上一节中所做的那样。ASP.NET Core 将对象序列化为 JSON 字符串,并将 Content-Type 标头设置为 application/json。这是最简单的方法,但也是灵活性最低的方法。例如,以下代码:
app.MapGet(
"minimal-endpoint-output-coordinate/",
() => new Coordinate {
Latitude = 43.653225,
Longitude = -79.383186
}
);
输出以下 JSON 字符串:
{
"latitude": 43.653225,
"longitude": -79.383186
}
这种方法的缺点是我们无法控制状态码,也无法从端点返回多个不同的结果。例如,如果端点在一个情况下返回 200 OK,在另一个情况下返回 404 Not Found。为了帮助我们,我们将探讨 IResult 抽象。
IResult
下一个选项是返回 IResult 接口。我们可以利用来自 Microsoft.AspNetCore.Http 命名空间的 Results 或 TypedResults 类来实现这一点。
我建议默认使用 .NET 7 引入的
TypedResults。
这两个之间的主要区别在于 Results 类中的方法返回 IResult,而 TypedResults 类中的方法返回 IResult 接口的类型化实现。这种区别可能听起来微不足道,但它改变了 API 探索器可发现性的方方面面。API 探索器无法自动发现前者的 API 合同,而可以自动发现后者。这是可能的,因为编译器可以推断返回类型,但在返回多个结果类型时会产生挑战。
这个选择会影响你需要投入多少工作量来获得精心制作的 OpenAPI 规范(自动或不那么自动)。
以下两个端点明确指出结果是 200 OK,每个端点使用一个类:
app.MapGet(
"minimal-endpoint-output-coordinate-ok1/",
() => Results.Ok(new Coordinate {
Latitude = 43.653225,
Longitude = -79.383186
})
);
app.MapGet(
"minimal-endpoint-output-coordinate-ok2/",
() => TypedResults.Ok(new Coordinate {
Latitude = 43.653225,
Longitude = -79.383186
})
);
当查看生成的 OpenAPI 规范时,第一个端点没有返回值,而其他端点有一个模仿我们 C# 类的 Coordinate 定义。接下来,我们更深入地探讨 TypedResults 类。
类型化结果
我们可以使用 TypedResults 类的方法生成强类型输出。它们允许我们控制输出,同时通知 ASP.NET Core 有关具体信息,如状态码和返回类型。
请注意,为了简化,我已省略变体和重载,仅关注表中每个方法的本质。
让我们从成功状态码开始,其中 200 OK 状态码可能是最常见的:
| 方法 | 描述 |
|---|---|
Accepted |
生成一个 202 Accepted 响应,表示异步过程的开始。 |
Created |
生成一个 201 Created 响应,表示系统创建了实体,实体的位置以及实体本身。 |
Ok |
生成一个 200 OK 响应,表示操作成功。 |
表 5.4:TypedResults 成功状态码方法。
在成功的基础上,我们必须知道如何向客户端报告错误。例如,400 Bad Request 和 404 Not Found 非常常见,用于指出请求中的问题。以下表格包含帮助向客户端指示此类问题的方法:
| 方法 | 描述 |
|---|---|
BadRequest |
生成一个 400 Bad Request 响应,表示客户端请求存在问题,通常是验证错误。 |
Conflict |
生成 409 Conflict 响应,表示在处理请求时发生了冲突,通常是一个并发错误。 |
NotFound |
生成 404 Not Found 响应,表示未找到资源。 |
Problem |
生成遵循由 RFC7807 定义的 问题详情 结构的响应,提供错误的标准封装。我们可以修改默认为 500 内部服务器错误 的状态码。 |
UnprocessableEntity |
生成 422 Unprocessable Content 响应,表示服务器理解请求的内容类型和语法是正确的,但不能处理指令或实体。 |
ValidationProblem |
生成遵循由 RFC7807 定义的 问题详情 结构的 400 Bad Request 响应。我们可以使用此方法向客户端传达输入验证问题。 |
表 5.5:TypedResults 问题状态码。
利用 问题详情 结构通过选择标准而不是自定义返回 API 错误的方式,提高了我们 API 的互操作性。
在 API 中,与常规 Web 应用相比,向客户端发送重定向较为罕见,然而,在需要时,我们可以使用以下方法之一将客户端重定向到另一个 URL:
| 方法 | 描述 |
|---|---|
LocalRedirect |
根据指定的参数生成 301 Moved Permanently、302 Found、307 Temporary Redirect 或 308 Permanent Redirect。如果 URL 不是本地 URL,此方法将在运行时抛出异常,这是一个确保动态生成的 URL 不会将用户带离的绝佳选项。例如,当 URL 使用用户输入组成时。 |
Redirect |
根据指定的参数生成 301 Moved Permanently、302 Found、307 Temporary Redirect 或 308 Permanent Redirect。 |
表 5.6:TypedResults 重定向状态码。
向客户端发送文件是另一个有用的功能;例如,API 可以使用授权来保护文件。以下表格展示了几个发送文件到客户端的辅助方法:
| 方法 | 描述 |
|---|---|
File |
将文件内容写入响应流。File 方法是 Bytes 和 Stream 方法的别名。我们很快就会看到这些方法。 |
PhysicalFile |
将物理文件的内容写入响应,使用绝对或相对路径。注意:不要将此方法暴露给原始用户输入,因为它可以读取位于 Web 内容根目录之外的文件。因此,恶意行为者可以构建一个请求来访问受限制的文件。 |
VirtualFile |
使用绝对或相对路径将物理文件的内容写入响应。此方法将文件的位置限制在 Web 内容根目录,在处理用户输入时更安全。 |
表 5.7:下载文件的 TypedResults 方法。
在我们已经探讨的方法之上,以下表格列出了直接以原始格式处理内容的方法。当您需要更多控制内容时,这些内容处理方法会变得非常有用:
| 方法 | 描述 |
|---|---|
Bytes |
将字节数组或 ReadOnlyMemory<byte> 内容直接写入响应。默认情况下,它向客户端发送 application/octet-stream MIME 类型,但这种默认行为可以根据需要自定义。 |
Content |
将指定的内容 string 写入响应流。默认情况下,它向客户端发送 text/plain MIME 类型,但这种默认行为可以根据需要自定义。 |
Json |
将指定的对象序列化为 JSON。默认情况下,它向客户端发送带有 200 OK 状态码的 application/json MIME 类型,但这些默认行为可以根据需要自定义。与其他方法(如 Ok 方法)相比,主要优势是它允许我们使用非默认的 JsonSerializerOptions 类实例来配置响应的序列化。 |
NoContent |
产生一个空的 204 No Content 响应。 |
StatusCode |
产生一个带有指定状态码的空响应。 |
Stream |
允许从另一个 Stream 直接写入响应流。默认情况下,它向客户端发送 application/octet-stream MIME 类型,但这种默认行为可以根据需要自定义。此方法高度可定制,默认返回 200 OK 状态码,并支持产生状态码 206 Partial Content 或 416 Range Not Satisfiable 的范围请求。 |
Text |
将内容字符串写入 HTTP 响应。默认情况下,它向客户端发送 text/plain MIME 类型,但这种默认行为可以根据需要自定义,以及文本编码。 |
表 5.8:TypedResults 原始内容处理方法。
application/octet-streamMIME 类型表明响应体是一个未指定类型的文件,这通常会导致浏览器下载文件。
最后,我们可以利用 TypedResults 类的以下方法来创建安全流程。这些方法中的大多数依赖于 IAuthenticationService 接口的当前实现,这决定了它们的行为:
| 方法 | 描述 |
|---|---|
Challenge |
当未经身份验证的用户请求需要身份验证的端点时,启动身份验证挑战。 |
Forbid |
当经过身份验证的用户尝试访问他们没有必要权限的资源时调用。默认情况下,它会产生一个 403 Forbidden 响应,但具体行为可能因特定的身份验证方案而异。 |
SignIn |
根据指定的身份验证方案开始用户的登录过程。 |
SignOut |
为给定的身份验证方案启动注销过程。 |
Unauthorized |
产生一个 401 Unauthorized 响应。 |
表 5.9:TypedResults 安全相关方法。
现在我们已经涵盖了 TypedResults 的可能性,接下来我们将探讨如何返回这些类型化的结果。
返回多个类型化结果
虽然返回单个类型化的结果是有帮助的,但能够产生多个结果的能力与实际场景更加契合。正如我们之前所探讨的,我们可以返回多个 IResult 对象,但我们被限制在只能返回单个类型化的结果。这种限制源于编译器无法识别共享接口,并从类型化结果中推断出 IResult 返回类型。即使编译器能够做到,这也不会提高可发现性。为了克服这一限制,.NET 7 引入了 Results<T1, TN> 类型,允许我们返回多达六个不同的类型化结果。以下是一个示例,当随机数是偶数时返回 200 OK,当它是奇数时返回 209 Conflict:
app.MapGet(
"multiple-TypedResults/",
Results<Ok, Conflict> ()
=> Random.Shared.Next(0, 100) % 2 == 0
? TypedResults.Ok()
: TypedResults.Conflict()
);
这也适用于类似的方法:
app.MapGet(
"multiple-TypedResults-delegate/",
MultipleResultsDelegate
);
Results<Ok, Conflict> MultipleResultsDelegate()
{
return Random.Shared.Next(0, 100) % 2 == 0
? TypedResults.Ok()
: TypedResults.Conflict();
}
采用这种方法可以增强 API 探索器对 API 的理解,从而允许像 Swagger 和 Swagger UI 这样的库自动生成更准确和详细的 API 文档。接下来,我们将探讨如何向端点添加更多元数据。
元数据
有时,仅仅依赖自动元数据是不够的。这就是为什么 ASP.NET Core 提供了不同的辅助方法来微调我们端点的元数据。我们可以使用大多数辅助方法与组和路由一起使用。在组的情况下,元数据会级联到其子项,无论它是另一个组还是路由。然而,子项可以通过更改元数据来覆盖继承的值。以下是一些辅助方法和它们的使用示例的部分列表:
| 方法 | 描述 |
|---|---|
Accepts |
指定支持的请求内容类型。仅适用于路由。 |
AllowAnonymous |
允许匿名用户访问端点。 |
CacheOutput |
为端点添加输出缓存策略。 |
DisableRateLimiting |
关闭端点上的速率限制功能。 |
ExcludeFromDescription |
将项目排除在 API 探索器数据之外。 |
Produces``ProducesProblem``ProducesValidationProblem |
描述一个响应,包括其类型、内容类型和状态码。仅适用于路由。 |
RequireAuthorization |
指定只有授权用户可以访问端点。我们可以通过使用重载之一来更精细地指定。例如,我们可以指定策略名称或 AuthorizationPolicy 实例。您必须配置授权才能使其工作。 |
RequireCors |
指定端点必须遵循 CORS 策略。您必须配置 CORS 才能使其工作。 |
RequireRateLimiting |
为端点添加速率限制策略。您必须配置速率限制才能使其工作。 |
WithDescription |
描述路由。当用于组时,描述会级联到该组中的所有路由。 |
WithName |
为路由分配一个名称。我们可以使用此名称来识别路由,该名称必须是唯一的。例如,我们可以使用路由名称与 LinkGenerator 类一起生成该端点的 URL。 |
WithOpenApi |
确保构建器添加与端点兼容的元数据,以便像 Swagger 这样的工具可以生成 Open API 规范。我们还可以使用此方法来配置操作和参数,而不是其他扩展方法。您必须调用此方法才能使许多其他方法正常工作。 |
WithSummary |
为路由添加摘要。当在组中使用时,摘要会级联到该组内的所有路由。 |
WithTags |
为路由添加标签。当在组中使用时,标签会级联到该组内的所有路由。 |
表 5.10:元数据辅助方法。
让我们看看一个创建组、标记它,并确保该组下所有路由的元数据都能被 SwaggerGen(API 浏览器)收集的例子:
var metadataGroup = app
.MapGroup("minimal-endpoint-metadata")
.WithTags("Metadata Endpoints")
.WithOpenApi()
;
我们现在可以使用 metadataGroup 定义该组上的端点,就像它是 app 变量一样。接下来,我们创建一个名为 "Named Endpoint" 的端点,并使用 WithOpenApi 方法描述它,包括将其标记为已弃用:
const string NamedEndpointName = "Named Endpoint";
metadataGroup
.MapGet(
"with-name",
() => $"Endpoint with name '{NamedEndpointName}'."
)
.WithName(NamedEndpointName)
.WithOpenApi(operation => {
operation.Description = "An endpoint that returns its name.";
operation.Summary = $"Endpoint named '{NamedEndpointName}'.";
operation.Deprecated = true;
return operation;
})
;
接下来,我们根据前面的命名端点生成一个 URL,我们使用 WithDescription 方法描述端点,并将元数据添加到 endpointName 参数中,包括一个示例。我们再次利用 WithOpenApi 方法:
metadataGroup
.MapGet(
"url-of-named-endpoint/{endpointName?}",
(string? endpointName, LinkGenerator linker) => {
var name = endpointName ?? NamedEndpointName;
return new {
name,
uri = linker.GetPathByName(name)
};
}
)
.WithDescription("Return the URL of the specified named endpoint.")
.WithOpenApi(operation => {
var endpointName = operation.Parameters[0];
endpointName.Description = "The name of the endpoint to get the URL for.";
endpointName.AllowEmptyValue = true;
endpointName.Example = new OpenApiString(NamedEndpointName);
return operation;
})
;
当请求前面的端点时,我们得到指定路由的 URL。默认情况下,我们得到 "Named Endpoint" 路由的 URL——我们唯一的命名路由——以下 JSON 格式:
{
"name": "Named Endpoint",
"uri": "/minimal-endpoint-metadata/with-name"
}
作为最后一个例子,我们可以使用 ExcludeFromDescription 方法从元数据中排除一个路由:
metadataGroup
.MapGet("excluded-from-open-api", () => { })
.ExcludeFromDescription()
;
当查看 Swagger UI 时,我们可以看到以下部分代表我们刚刚定义的组:

图 5.1:Swagger UI “元数据端点”路由组的截图。
在前面的截图中,我们可以看到两个端点,并且正如预期的那样,第三个端点被排除了。第一个路由被标记为已弃用并显示摘要。当我们打开它时,我们看到一个警告、一个描述、没有参数,以及一个带有 text/plain MIME 类型的 200 OK 响应。
我省略了第一个端点的 Swagger UI 截图,因为它没有添加太多内容,而且难以阅读。更好的做法是运行
Minimal.API程序并导航到/swagger/index.htmlURL。
第二个路由没有摘要,但当我们打开它时,我们有一个描述。我们为 endpointName 参数添加的元数据就在那里,最有趣的是,示例变成了默认值:

图 5.2:Swagger UI 的截图,展示了 endpointName 参数的元数据。
Swagger UI 在开发期间手动调用我们的 API 或利用其他兼容 Open API 的工具时非常有用。例如,我们可以根据 Open API 规范生成代码,如 TypeScript 客户端。接下来,我们将探讨如何配置最小 API JSON 序列化器。
配置 JSON 序列化
在 ASP.NET Core 中,我们可以全局自定义 JSON 序列化器或为特定场景创建一个新的序列化器。要更改默认序列化器的行为,我们可以调用 ConfigureHttpJsonOptions 方法,该方法配置 JsonOptions 对象。从那里,我们可以更改选项如下:
builder.Services.ConfigureHttpJsonOptions(options => {
options.SerializerOptions.PropertyNamingPolicy = JsonNamingPolicy.KebabCaseLower;
});
在 Program.cs 文件中的前面代码,我们告诉序列化器按照 lower-case-kebab 命名约定序列化属性名称。以下是一个示例:
| 端点 | 响应 |
|---|---|
| jsonGroup.MapGet("kebab-person/",() => new {FirstName = "John",LastName = "Doe"}); |
表 5.11:展示 JsonNamingPolicy.KebabCaseLower 策略的输出。
我们可以通过使用 TypedResults.Json 方法并指定 JsonSerializerOptions 的一个实例来实现特定端点的相同效果,同时保留默认的序列化选项。以下代码实现了相同的结果:
var kebabSerializer = new JsonSerializerOptions(JsonSerializerDefaults.Web)
{
PropertyNamingPolicy = JsonNamingPolicy.KebabCaseLower
};
jsonGroup.MapGet(
"kebab-person/",
() => TypedResults.Json(new {
FirstName = "John",
LastName = "Doe"
}, kebabSerializer)
);
我突出了此代码与上一个示例之间的变化。首先,我们创建 JsonSerializerOptions 类的一个实例。为了简化配置,我们通过将 JsonSerializerDefaults.Web 参数传递给构造函数来从默认的 Web 序列化值开始。然后,在对象初始化器中,我们将 PropertyNamingPolicy 属性的值设置为 JsonNamingPolicy.KebabCaseLower,这最终得到与之前相同的结果。最后,为了使用我们的选项,我们将 kebabSerializer 变量作为 TypedResults.Json 方法的第二个参数传递。
将枚举序列化为字符串
我经常发现自己需要更改配置以输出 enum 值的字符串表示形式。为此,我们必须注册 JsonStringEnumConverter 类的一个实例。之后,枚举将被序列化为字符串。以下是一个示例:
var enumSerializer = new JsonSerializerOptions(JsonSerializerDefaults.Web);
enumSerializer.Converters.Add(new JsonStringEnumConverter());
jsonGroup.MapGet(
"enum-as-string/",
() => TypedResults.Json(new {
FirstName = "John",
LastName = "Doe",
Rating = Rating.Good,
}, enumSerializer)
);
当执行前面的代码时,我们得到以下结果:
{
"firstName": "John",
"lastName": "Doe",
"rating": "Good"
}
使用默认选项将产生以下结果:
{
"firstName": "John",
"lastName": "Doe",
"rating": 2
}
我通常全局更改此选项,因为使用人类可读的值而不是数字更明确,更容易被人理解,也更容易被客户端(机器)利用。
许多其他选项可用于调整序列化器,但我们不能在此处全部探索。接下来,我们将查看端点过滤器。
利用端点过滤器
ASP.NET Core 7.0 增加了注册端点过滤器的能力。这样,我们可以在端点之间封装和重用跨切面关注点和逻辑。
重复使用的例子之一是我更喜欢 FluentValidation 而不是.NET 属性,因此我创建了一个开源项目,实现了一个将 Minimal APIs 与 FluentValidation 关联的过滤器。然后,通过引用 NuGet 包,我可以跨项目重用该过滤器。我们在第四部分:应用模式中探讨了 FluentValidation,并在进一步阅读部分留下了该项目的链接。
它是如何工作的?
-
我们可以内联注册端点过滤器或创建一个类。
-
我们可以使用
AddEndpointFilter方法将过滤器添加到端点或组。 -
当向一个组添加过滤器时,它将应用于所有子元素。
-
我们可以为每个端点或组添加多个过滤器。
-
ASP.NET Core 按照它们注册的顺序执行过滤器。
让我们看看一个简单的内联过滤器:
inlineGroup
.MapGet("basic", () => { })
.AddEndpointFilter((context, next) =>
{
return next(context);
});
突出的代码表示了以委托形式存在的过滤器。过滤器什么也不做,只是执行链中的下一个委托,在这种情况下,就是端点委托本身。在下面的示例中,我们使用Rating枚举,并在端点中只接受正评分。为了实现这一点,我们添加了一个在到达端点之前验证输入值的过滤器:
public enum Rating
{
Bad = 0,
Ok,
Good,
Amazing
}
// ...
inlineGroup
.MapGet("good-rating/{rating}", (Rating rating)
=> TypedResults.Ok(new { Rating = rating }))
.AddEndpointFilter(async (context, next) =>
{
var rating = context.GetArgument<Rating>(0);
if (rating == Rating.Bad)
{
return TypedResults.Problem(
detail: "This endpoint is biased and only accepts positive ratings.",
statusCode: StatusCodes.Status400BadRequest
);
}
return await next(context);
});
从过滤器中,我们利用EndpointFilterFactoryContext访问评分参数。然后,代码验证评分不是Bad。如果评分是Bad,则过滤器立即返回一个带有400 Bad Request状态码的问题详情,在调用端点委托之前返回。否则,执行端点代码。你可能想知道编写这种代码有多有用;好吧,这个案例纯粹是教育性的,在现实世界的场景中并不那么有用。我们可以在端点委托中直接验证参数,从而节省通过索引访问它的麻烦。尽管如此,它展示了过滤器是如何工作的,这样你就可以利用这些知识构建有用的现实生活过滤器;记住,编码就像玩 LEGO®积木。为了在这个基础上进行改进并使前面的示例可重用,我们可以将过滤器逻辑提取到类中,并将其应用于多个端点。我们还可以将内联实现移动到组中,使其影响所有子元素。让我们看看如何将我们的内联过滤器变成一个类。过滤器类必须实现IEndpointFilter接口。以下是GoodRatingFilter类中前面逻辑的重新实现,以及使用它的两个端点:
filterGroup
.MapGet("good-rating/{rating}", (Rating rating)
=> TypedResults.Ok(new { Rating = rating }))
.AddEndpointFilter<GoodRatingFilter>();
;
filterGroup
.MapPut("good-rating/{rating}", (Rating rating)
=> TypedResults.Ok(new { Rating = rating }))
.AddEndpointFilter<GoodRatingFilter>();
;
public class GoodRatingFilter : IEndpointFilter
{
public async ValueTask<object?> InvokeAsync(
EndpointFilterInvocationContext context,
EndpointFilterDelegate next)
{
var rating = context.GetArgument<Rating>(0);
if (rating == Rating.Bad)
{
return TypedResults.Problem(
detail: "This endpoint is biased and only accepts positive ratings.",
statusCode: StatusCodes.Status400BadRequest
);
}
return await next(context);
}
}
GoodRatingFilter类的InvokeAsync方法代码与内联版本相同。然而,我们使用了它两次,保持了我们的代码 DRY(Don't Repeat Yourself)。在过滤器中封装逻辑片段可以非常有用,无论是输入验证、日志记录、异常处理还是其他场景。而且这还不是全部;关于过滤器,我们还有另一件事要探索。
利用端点过滤器工厂
我们可以使用端点过滤器工厂在 ASP.NET Core 构建端点(创建 RequestDelegate)之前运行代码。然后,从工厂中,我们可以控制过滤器的创建本身。
我们在 第七章 中探讨了工厂模式。
以下代码注册了一个端点过滤器工厂:
inlineGroup
.MapGet("endpoint-filter-factory", () => "RAW")
.AddEndpointFilterFactory((filterFactoryContext, next) =>
{
// Building RequestDelegate code here.
var logger = filterFactoryContext.ApplicationServices
.GetRequiredService<ILoggerFactory>()
.CreateLogger("endpoint-filter-factory");
logger.LogInformation("Code that runs when ASP.NET Core builds the RequestDelegate");
// Returns the EndpointFilterDelegate ASP.NET Core executes as part of the pipeline.
return async invocationContext =>
{
logger.LogInformation("Code that ASP.NET Core executes as part of the pipeline");
// Filter code here
return await next(invocationContext);
};
});
上一段代码添加了一个端点过滤器工厂,该工厂将一些信息记录到控制台,这允许我们跟踪正在发生的事情。高亮代码代表过滤器本身。例如,我们可以在那里编写与 GoodRatingFilter 类相同的代码。接下来,让我们看看当我们执行程序并加载端点五次时会发生什么:
[11:22:56.673] info: Microsoft.Hosting.Lifetime[14]
Now listening on: https://localhost:7298
[11:22:56.698] info: Microsoft.Hosting.Lifetime[14]
Now listening on: http://localhost:5085
[11:22:56.702] info: Microsoft.Hosting.Lifetime[0]
Application started. Press Ctrl+C to shut down.
[11:22:56.705] info: Microsoft.Hosting.Lifetime[0]
Hosting environment: Development
[11:22:56.708] info: Microsoft.Hosting.Lifetime[0]
Content root path: .../C05/Minimal.API
[11:23:28.349] info: endpoint-filter-factory[0]
Code that runs when ASP.NET Core builds the RequestDelegate
[11:23:45.181] info: endpoint-filter-factory[0]
Code that runs when ASP.NET Core builds the RequestDelegate
[11:24:56.043] info: endpoint-filter-factory[0]
Code that ASP.NET Core executes as part of the pipeline
[11:24:57.439] info: endpoint-filter-factory[0]
Code that ASP.NET Core executes as part of the pipeline
[11:24:58.443] info: endpoint-filter-factory[0]
Code that ASP.NET Core executes as part of the pipeline
[11:24:59.262] info: endpoint-filter-factory[0]
Code that ASP.NET Core executes as part of the pipeline
[11:25:00.154] info: endpoint-filter-factory[0]
Code that ASP.NET Core executes as part of the pipeline
以下是从前面的输出中发生的事情:
-
API 开始(前 10 行)。
-
ASP.NET Core 在从
EndpointRoutingMiddleware(下一行)构建RequestDelegate时执行工厂代码。 -
SwaggerGen,使用ApiExplorer,也从SwaggerMiddleware中执行相同的操作,因此有第二次工厂调用(下一行)。 -
之后,ASP.NET Core 只在请求期间执行过滤器——在这种情况下,五次(最后 10 行)。
现在我们已经看到了它是如何运行的,现在是时候学习它是如何工作的了。
如果你不理解
GetRequiredService方法或ILoggerFactory接口的工作原理,请不要担心;我们在 第八章、依赖注入 和 第十章、日志记录 中探讨了这些主题。
我们首先使用 AddEndpointFilterFactory 方法注册端点过滤器工厂,这适用于组和单个路由(我们将在下一节深入探讨组)。工厂委托的类型为 Func<EndpointFilterFactoryContext, EndpointFilterDelegate, EndpointFilterDelegate>。在委托内部,使用名为 filterFactoryContext 的 EndpointFilterFactoryContext 参数,我们可以访问以下对象:
-
ApplicationServices属性提供了对IServiceProvider接口的访问,允许我们从容器中提取服务,如使用ILoggerFactory接口所示。 -
MethodInfo属性提供了一个MethodInfo对象,允许调用者访问我们添加的端点过滤器工厂。此对象封装了反射数据,包括类型、泛型参数、属性等。
最后,工厂委托返回 ASP.NET Core 在请求击中端点(s)时执行的过滤器。在这种情况下,过滤器如下:
async invocationContext =>
{
logger.LogInformation("Code that ASP.NET Core executes as part of the pipeline");
return await next(invocationContext);
};
next 参数(高亮代码)代表下一个过滤器或端点本身——在这里它与任何端点过滤器的工作方式相同。不调用 next 参数意味着 ASP.NET Core 将永远不会执行端点代码,这是一种控制应用程序流程的方式。
为了使端点过滤器工厂更具可重用性,我们可以创建一个类、一个扩展方法,或者返回一个现有的过滤器类。我们还可以结合这些方法来构建一个更易于测试和遵循 DRY(不要重复自己)原则的端点过滤器工厂的实现。虽然我们不会在这个上下文中深入探讨这些具体方法,但到本书结束时,你应该已经获得了足够的知识,可以自己完成这些任务。
接下来,我们来看看如何组织端点。
组织端点
使用MapGroup方法对端点进行分组是一种有效的组织策略。然而,直接在Program.cs文件中定义所有路由可能会导致文件很长,难以导航。为了减轻这种情况,我们可以将这些端点组安排在单独的类中,并创建一个扩展方法将这些端点添加到IEndpointRouteBuilder。我们还可以将这些组,甚至多个组,封装在另一个程序集中,然后从 API 中加载。
我们在第四部分:应用模式中探讨了设计应用程序的方法,包括在第十八章的请求-端点-响应(REPR)和第二十章的模块化单体。
让我们从简单的组开始。
MapGroup
创建组是我们组织 API 路由的第一项工具。它具有以下优势:
-
我们可以为组的子项创建一个共享的 URL 前缀。
-
我们可以为组的子项添加适用的元数据。
-
我们可以为组的子项添加端点过滤器。
这里是一个配置这三个项目的示例组:
// Create a reusable logger
var loggerFactory = app.ServiceProvider
.GetRequiredService<ILoggerFactory>();
var groupLogger = loggerFactory
.CreateLogger("organizing-endpoints");
// Create the group
var group = app
.MapGroup("organizing-endpoints")
.WithTags("Organizing Endpoints")
.AddEndpointFilter(async (context, next) => {
groupLogger.LogTrace("Entering organizing-endpoints");
// Omited argument logging
var result = await next(context);
groupLogger.LogTrace("Exiting organizing-endpoints");
return result;
})
;
// Map endpoints in the group
group.MapGet("demo/", ()
=> "GET endpoint from the organizing-endpoints group.");
group.MapGet("demo/{id}", (int id)
=> $"GET {id} endpoint from the organizing-endpoints group.");
突出的代码执行以下操作:
-
配置
organizing-endpointsURL 前缀。 -
添加
Organizing Endpoints标签(元数据)。 -
添加一个内联过滤器,记录关于请求的信息。
我们可以通过以下 URL 访问端点:
-
/organizing-endpoints/demo -
/organizing-endpoints/demo/123
如以下 Swagger UI 截图所示,这两个端点被正确标记:

图 5.3:组织端点标签下的两个端点
然后,在请求了这两个端点之后,我们得到了以下日志摘录:
[23:55:01.516] trce: organizing-endpoints[0]
Entering organizing-endpoints
[23:55:01.516] trce: organizing-endpoints[0]
Exiting organizing-endpoints
[23:55:06.028] trce: organizing-endpoints[0]
Entering organizing-endpoints
[23:55:06.028] dbug: organizing-endpoints[0]
Argument 1: Int32 = 123
[23:55:06.028] trce: organizing-endpoints[0]
Exiting organizing-endpoints
如所示,利用组进行共享配置简化了设置诸如授权规则、缓存等方面的工作流程。通过采用这种方法,我们遵循了 DRY(不要重复自己)原则,提高了代码的可维护性。接下来,我们将端点映射封装到类中。
创建一个自定义的 Map 扩展方法
现在我们已经探讨了如何创建组,是时候将端点从Program.cs文件中移除了。一种方法是通过创建一个扩展方法来注册路由。为了实现这一点,我们必须扩展IEndpointRouteBuilder接口,如下所示:
namespace Minimal.API;
public static class OrganizingEndpoints
{
public static void MapOrganizingEndpoints(
this IEndpointRouteBuilder app)
{
// Map endpoints and groups here
}
}
然后,我们必须在Program.cs文件中调用我们的扩展方法,如下所示:
var builder = WebApplication.CreateBuilder(args);
var app = builder.Build();
app.MapOrganizingEndpoints();
app.Run();
有了这个,我们就看到了一个简单技术如何允许我们将路由分组在一起,为我们提供了组织 API 的有组织方式。我们可以在扩展方法中返回IEndpointRouteBuilder而不是void来改进这种技术,这使我们的扩展“流畅”,如下所示:
namespace Minimal.API;
public static class OrganizingEndpoints
{
public static IEndpointRouteBuilder MapOrganizingEndpointsFluently(
this IEndpointRouteBuilder app)
{
var group = app
.MapGroup("organizing-endpoints-fluently")
.WithTags("Organizing Fluent Endpoints")
;
// Map endpoints and groups here
return app;
}
}
然后,从Program.cs文件中,我们可以像以下这样在“单行代码”中调用多个映射:
app
.MapOrganizingEndpointsFluently()
.MapOrganizingEndpoints()
;
创建流畅的 API 非常方便,尤其是在这种情况下。
这种技术允许你为任何东西创建流畅的 API,而不仅仅是注册路由。
这种模式还存在另一种值得注意的变体。而不是返回IEndpointRouteBuilder,扩展方法可以返回RouteGroupBuilder,从而授予调用者访问该组本身的权限。以下是一个示例:
namespace Minimal.API;
public static class OrganizingEndpoints
{
public static RouteGroupBuilder MapOrganizingEndpointsComposable(
this IEndpointRouteBuilder app)
{
var group = app
.MapGroup("organizing-endpoints-composable")
.WithTags("Organizing Composable Endpoints")
;
// Map endpoints and groups here
return group;
}
}
我们可以使用这种方法通过将注册拆分为多个文件来创建一个复杂的路由和组层次结构。第二个版本是最常见的方式。它不将组暴露给外部(封装),并允许流畅地链式调用其他方法。好了,现在我们知道了基本扩展方法如何帮助我们组织端点。接下来,我们将探讨如何将这些扩展方法移动到类库中。
类库
这种最后的技术使我们能够使用之前探索的技术从类库中创建和注册路由。首先,我们必须创建一个类库项目,我们可以使用dotnet new classlib CLI 命令来完成。不幸的是,类库项目无法访问我们需要的所有内容,例如IEndpointRouteBuilder接口。好消息是,改变这一事实非常简单。我们只需在csproj文件中的ItemGroup元素中添加一个FrameworkReference元素,如下所示:
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net8.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
</PropertyGroup>
<ItemGroup>
<FrameworkReference Include="Microsoft.AspNetCore.App" />
</ItemGroup>
</Project>
这个小小的添加使我们拥有了创建一个启用 ASP.NET Core 的库所需的一切,包括映射端点!将前面的 C#代码转移到这个类库项目中应该会产生与在 Web 应用程序中相同的功能结果。
我在本章和下一章探讨的解决方案的
Shared项目中使用了这种技术。如果你好奇,完整的源代码可在 GitHub 上找到。
接下来,我们将 Minimal APIs 和 DTOs 结合起来。
使用 Minimal APIs 与数据传输对象
本节探讨了利用数据传输对象(DTO)模式与 Minimal APIs。
本节与我们在第六章“MVC”中探讨的内容相同,但是在 Minimal APIs 的背景下。此外,这两个代码项目是同一个 Visual Studio 解决方案的一部分,以便于比较两种实现方式。
目标
作为提醒,DTOs(数据传输对象)旨在通过将 API 合约与应用程序的内部工作解耦来控制端点的输入和输出。DTOs 使我们能够定义我们的 API,而不必考虑底层的数据结构,从而让我们能够按照自己的意愿构建 REST API。
我们在第四章,REST API中更深入地讨论了 REST API 和 DTOs。
其他可能的目的是通过限制 API 传输的信息量、简化数据结构或添加跨多个实体的 API 功能来节省带宽。
设计
让我们先分析一个图表,展示最小化 API 如何与 DTOs 协同工作:

图 5.4:一个输入 DTO 触发了某些领域逻辑,然后端点返回一个输出 DTO
DTOs 允许将领域(3)与请求(1)和响应(5)解耦。这种模型使我们能够独立于领域来管理 REST API 的输入和输出。以下是流程:
-
客户端向服务器发送请求。
-
ASP.NET Core 利用其数据绑定和解析机制将 HTTP 请求的信息转换为 C#(输入 DTO)。
-
端点执行其应有的操作。
-
ASP.NET Core 将输出 DTO 序列化到 HTTP 响应中。
-
客户接收并处理响应。
让我们通过一些代码来更好地理解这个概念。
项目 – 最小化 API
*此代码示例与下一章相同,但使用最小化 API 而不是 MVC 框架上下文:我们必须构建一个应用程序来管理客户和合同。我们必须跟踪每个合同的状态,并在业务需要联系客户时有一个主要联系人。最后,我们必须在仪表板上显示每个客户的合同数量和已打开合同数量。模型如下:
namespace Shared.Models;
public record class Customer(
int Id,
string Name,
List<Contract> Contracts
);
public record class Contract(
int Id,
string Name,
string Description,
WorkStatus Status,
ContactInformation PrimaryContact
);
public record class WorkStatus(int TotalWork, int WorkDone)
{
public WorkState State =>
WorkDone == 0 ? WorkState.New :
WorkDone == TotalWork ? WorkState.Completed :
WorkState.InProgress;
}
public record class ContactInformation(
string FirstName,
string LastName,
string Email
);
public enum WorkState
{
New,
InProgress,
Completed
}
上述代码很简单。唯一的逻辑是WorkStatus.State属性,当该合同上的工作尚未开始时返回WorkState.New,当所有工作都已完成时返回WorkState.Completed,否则返回WorkState.InProgress。端点(CustomersEndpoints.cs)利用ICustomerRepository接口来模拟数据库操作。实现并不重要。它使用List<Customer>作为数据库。以下是允许查询和更新数据的接口:
using Shared.Models;
namespace Shared.Data;
public interface ICustomerRepository
{
Task<IEnumerable<Customer>> AllAsync(
CancellationToken cancellationToken);
Task<Customer> CreateAsync(
Customer customer,
CancellationToken cancellationToken);
Task<Customer?> DeleteAsync(
int customerId,
CancellationToken cancellationToken);
Task<Customer?> FindAsync(
int customerId,
CancellationToken cancellationToken);
Task<Customer?> UpdateAsync(
Customer customer,
CancellationToken cancellationToken);
}
现在我们已经了解了底层基础,我们将探讨不利用 DTOs 的 CRUD 端点。
原始 CRUD 端点
如果我们创建 CRUD 端点直接管理客户,可能会出现许多问题(参见CustomersEndpoints.cs)。首先,客户端的一个小错误可能会删除多个数据点。例如,如果客户端在PUT操作中忘记发送合同,那么将删除与该客户关联的所有合同。以下是控制器代码:
// PUT raw/customers/1
group.MapPut("/{customerId}", async (int customerId, Customer input, ICustomerRepository customerRepository, CancellationToken cancellationToken) =>
{
var updatedCustomer = await customerRepository.UpdateAsync(
input,
cancellationToken
);
if (updatedCustomer == null)
{
return Results.NotFound();
}
return Results.Ok(updatedCustomer);
});
突出的代码表示客户更新。因此,为了错误地删除所有合同,客户端可以发送以下 HTTP 请求(来自Minimal.API.http文件):
PUT {{Minimal.API.BaseAddress}}/customers/1
Content-Type: application/json
{
"id": 1,
"name": "Some new name",
"contracts": []
}
该请求将导致以下响应实体:
{
"id": 1,
"name": "Some new name",
"contracts": []
}
然而,之前该客户已有合同(在我们启动应用程序时生成)。以下是原始数据:
{
"id": 1,
"name": "Jonny Boy Inc.",
"contracts": [
{
"id": 1,
"name": "First contract",
"description": "This is the first contract.",
"status": {
"totalWork": 100,
"workDone": 100,
"state": "Completed"
},
"primaryContact": {
"firstName": "John",
"lastName": "Doe",
"email": "john.doe@jonnyboy.com"
}
},
{
"id": 2,
"name": "Some other contract",
"description": "This is another contract.",
"status": {
"totalWork": 100,
"workDone": 25,
"state": "InProgress"
},
"primaryContact": {
"firstName": "Jane",
"lastName": "Doe",
"email": "jane.doe@jonnyboy.com"
}
}
]
}
如我们所见,通过直接公开我们的实体,我们给了 API 的消费者很多权力。这个设计的问题之一是仪表板。用户界面将不得不计算关于合同的统计数据。此外,如果我们随着时间的推移实现分页显示合同,用户界面可能会变得越来越复杂,甚至可能过度查询数据库,从而阻碍我们的性能。
我实现了整个 API,它可在 GitHub 上找到,但没有 UI。
接下来,我们将探讨如何使用 DTO 修复这两个用例。
DTO 启用的端点
为了解决我们的问题,我们使用 DTO 重新实现了端点。这些端点使用方法而不是内联委托,并返回 Results<T1, T2, …> 而不是 IResult。所以,让我们从端点的声明开始:
var group = routes
.MapGroup("/dto/customers")
.WithTags("Customer DTO")
.WithOpenApi()
;
group.MapGet("/", GetCustomersSummaryAsync)
.WithName("GetAllCustomersSummary");
group.MapGet("/{customerId}", GetCustomerDetailsAsync)
.WithName("GetCustomerDetailsById");
group.MapPut("/{customerId}", UpdateCustomerAsync)
.WithName("UpdateCustomerWithDto");
group.MapPost("/", CreateCustomerAsync)
.WithName("CreateCustomerWithDto");
group.MapDelete("/{customerId}", DeleteCustomerAsync)
.WithName("DeleteCustomerWithDto");
接下来,为了更容易地跟踪,这里提供了所有 DTO 作为参考:
namespace Shared.DTO;
public record class ContractDetails(
int Id,
string Name,
string Description,
int StatusTotalWork,
int StatusWorkDone,
string StatusWorkState,
string PrimaryContactFirstName,
string PrimaryContactLastName,
string PrimaryContactEmail
);
public record class CustomerDetails(
int Id,
string Name,
IEnumerable<ContractDetails> Contracts
);
public record class CustomerSummary(
int Id,
string Name,
int TotalNumberOfContracts,
int NumberOfOpenContracts
);
public record class CreateCustomer(string Name);
public record class UpdateCustomer(string Name);
首先,让我们修复我们的更新问题,从使用 DTO 重新实现更新端点开始(参见 DTOEndpoints.cs 文件):
// PUT dto/customers/1
private static async Task<Results<
Ok<CustomerDetails>,
NotFound,
Conflict
>> UpdateCustomerAsync(
int customerId,
UpdateCustomer input,
ICustomerRepository customerRepository,
CancellationToken cancellationToken)
{
// Get the customer
var customer = await customerRepository.FindAsync(
customerId,
cancellationToken
);
if (customer == null)
{
return TypedResults.NotFound();
}
// Update the customer's name using the UpdateCustomer DTO
var updatedCustomer = await customerRepository.UpdateAsync(
customer with { Name = input.Name },
cancellationToken
);
if (updatedCustomer == null)
{
return TypedResults.Conflict();
}
// Map the updated customer to a CustomerDetails DTO
var dto = MapCustomerToCustomerDetails(updatedCustomer);
// Return the DTO
return TypedResults.Ok(dto);
}
在前面的代码中,主要的不同之处在于(突出显示):
-
现在请求体绑定到
UpdateCustomer类而不是Customer本身。 -
当操作成功时,动作方法返回
CustomerDetails类的实例而不是Customer本身。
然而,我们可以看到端点中的代码比以前多了。这是因为现在它处理了变化,而不是客户端。动作现在执行:
-
从数据库加载数据。
-
确保实体存在。
-
使用输入 DTO 更新数据,限制客户端只能修改属性子集。
-
进行更新。
-
确保实体仍然存在(处理冲突)。
-
将客户复制到输出 DTO 并返回它。
通过这样做,我们现在控制了客户端在通过输入 DTO (UpdateCustomer) 发送 PUT 请求时可以做什么。此外,我们将计算统计数据的逻辑封装在服务器上。我们通过输出 DTO (CustomerDetails) 隐藏了计算过程,这降低了用户界面的复杂性,并允许我们在不影响任何客户端的情况下提高性能(松耦合)。此外,我们现在使用 customerId 参数。如果我们发送与之前相同的 HTTP 请求,发送比我们接受更多的数据,只有客户的名字会改变。除此之外,我们还得到了显示客户统计数据所需的所有数据。以下是一个响应示例:
{
"id": 1,
"name": "Some new name",
"contracts": [
{
"id": 1,
"name": "First contract",
"description": "This is the first contract.",
"statusTotalWork": 100,
"statusWorkDone": 100,
"statusWorkState": "Completed",
"primaryContactFirstName": "John",
"primaryContactLastName": "Doe",
"primaryContactEmail": "john.doe@jonnyboy.com"
},
{
"id": 2,
"name": "Some other contract",
"description": "This is another contract.",
"statusTotalWork": 100,
"statusWorkDone": 25,
"statusWorkState": "InProgress",
"primaryContactFirstName": "Jane",
"primaryContactLastName": "Doe",
"primaryContactEmail": "jane.doe@jonnyboy.com"
}
]
}
如前所述的响应所示,只有客户的名字改变了,但我们现在收到了 statusWorkDone 和 statusTotalWork 字段。最后,我们简化了数据结构。
DTO 是简化数据结构的好资源,但你不必这样做。你必须始终为特定用例设计你的系统,包括 DTO 和数据合约。
对于仪表板而言,“获取所有客户”端点通过执行类似操作来实现这一点。它输出一个CustomerSummary对象集合,而不是客户本身。在这种情况下,端点执行计算并将实体的相关属性复制到 DTO 中。以下是代码:
// GET: dto/customers
private static async Task<Ok<IEnumerable<CustomerSummary>>> GetCustomersSummaryAsync(
ICustomerRepository customerRepository,
CancellationToken cancellationToken)
{
// Get all customers
var customers = await customerRepository
.AllAsync(cancellationToken);
// Map customers to CustomerSummary DTOs
var customersSummary = customers.Select(customer => new CustomerSummary(
Id: customer.Id,
Name: customer.Name,
TotalNumberOfContracts: customer.Contracts.Count,
NumberOfOpenContracts: customer.Contracts
.Count(x => x.Status.State != WorkState.Completed)
));
// Return the DTOs
return TypedResults.Ok(customersSummary);
}
在前面的代码中,动作方法:
-
读取实体
-
创建 DTO 并计算开放合同的数量。
-
返回 DTO。
如此简单,我们现在封装了服务器上的计算。
您应该根据您的实际数据源优化此类代码。在这种情况下,一个
staticList<T>具有低延迟。然而,查询整个数据库以获取计数可能会成为瓶颈。
调用端点会产生以下结果:
[
{
"id": 1,
"name": "Some new name",
"totalNumberOfContracts": 2,
"numberOfOpenContracts": 1
},
{
"id": 2,
"name": "Some mega-corporation",
"totalNumberOfContracts": 1,
"numberOfOpenContracts": 1
}
]
现在构建我们的仪表板变得非常简单。我们可以查询该端点一次,并在 UI 中显示数据。UI 将计算卸载到了后端。
用户界面通常比 API 更复杂,因为它们是状态化的。因此,将尽可能多的复杂性卸载到后端有助于。您可以使用后端-for-前端(BFF)来帮助完成这项任务。我们在第十九章:微服务架构简介中探讨了分层 API 的方法,包括 BFF 模式。
最后,您可以使用MVC.API.DTO.http文件中的 HTTP 请求来玩转 API。我使用类似的技术实现了所有端点。如果您的端点变得过于复杂,将它们封装到其他类中是一种良好的做法。我们在第四部分:应用程序模式中探讨了多种组织应用程序代码的技术。
结论
数据传输对象允许我们设计一个具有特定数据契约(输入和输出)的 API 端点,而不是暴露领域或数据模型。这种表示层与领域之间的分离是导致拥有多个独立组件而不是更大、更脆弱组件的关键元素。我们使用 DTO 来控制端点的输入和输出,从而让我们对客户端可以做什么或接收什么有更多的控制。使用数据传输对象模式有助于我们以下列方式遵循 SOLID 原则:
-
S:DTO 在领域或数据模型与 API 契约之间添加了清晰的边界。此外,拥有输入和输出 DTO 有助于进一步分离责任。
-
O:N/A
-
L:N/A
-
I:DTO(数据传输对象)是一个小型、专门定制的数据契约(抽象),在 API 契约中有明确的目的。
-
D:由于那些较小的接口(ISP),DTO 允许在不影响客户端的情况下更改端点的实现细节,因为它们只依赖于 API 契约(抽象)。
您现在应该理解 DTO 的附加价值以及它们在 API 契约中扮演的角色。最后,您应该有一个关于最小 API 可能性的坚实基础。
摘要
在本章中,我们探讨了 ASP.NET Core 最小化 API 及其与 DTO 模式的集成。最小化 API 通过减少样板代码简化了 Web 应用程序的开发。DTO 模式帮助我们解耦 API 合同与应用程序的内部工作,允许我们在构建 REST API 时具有灵活性。DTO 还可以节省带宽并简化或更改数据结构。直接暴露其领域或数据实体的端点可能导致问题,而启用 DTO 的端点则提供了更好的数据交换控制。我们还讨论了多个最小化 API 方面,包括输入绑定、输出数据、元数据、JSON 序列化、端点过滤器以及端点组织。有了这些基础知识,我们可以开始设计 ASP.NET Core 最小化 API。
关于最小化 API 及其提供的功能,您可以访问官方文档中的最小化 API 快速参考页面:
adpg.link/S47i
在下一章中,我们将以 ASP.NET Core MVC 的上下文重新审视同样的概念。
问题
让我们看看几个练习问题:
-
如何使用最小化 API 将不同的 HTTP 请求映射到委托?
-
我们能否在最小化 API 中使用中间件?
-
您能列出至少两个最小化 API 支持的绑定源吗?
-
使用
Results和TypedResults类有什么区别? -
端点过滤器的作用是什么?
进一步阅读
这里有一些链接,可以帮助我们巩固本章所学的内容:
-
最小化 API 快速参考:
adpg.link/S47i -
HTTP API 的问题详情(RFC7807):
adpg.link/1hpM -
FluentValidation.AspNetCore.Http:
adpg.link/sRtU
答案
-
最小化 API 提供了如
MapGet、MapPost、MapPut和MapDelete等扩展方法,用于配置 HTTP 管道并将特定的 HTTP 请求映射到委托。 -
是的,我们可以在最小化 API 中使用中间件,就像任何其他 ASP.NET Core 应用程序一样。
-
最小化 API 支持各种绑定源,包括
Route、Query、Header、Body、Form和Services。 -
Results类中的方法返回IResult,而TypedResults类中的方法返回IResult接口的特定类型实现。这种差异很重要,因为 API 浏览器可以从类型化的结果(TypedResults方法)自动发现 API 合同,但不能从通用的IResult接口(Results方法)中自动发现。 -
端点过滤器允许在端点之间封装和重用跨切面逻辑。例如,它们对于输入验证、日志记录、异常处理和促进代码重用非常有帮助。
第六章:6 模型-视图-控制器
在开始之前:加入我们的 Discord 书籍社区
直接向作者本人提供反馈,并在我们的 Discord 服务器上与其他早期读者聊天(在“architecting-aspnet-core-apps-3e”频道下找到,属于早期访问订阅)。

本章深入探讨了模型-视图-控制器(MVC)设计模式,这是现代软件架构的基石,它直观地将代码围绕实体组织。MVC 适用于 CRUD 操作或利用 Minimal APIs 中不可用的高级功能。MVC 模式将应用程序划分为三个相互关联的部分:模型、视图和控制器。
-
模型,代表我们的数据和业务逻辑。
-
视图,即面向用户的组件。
-
控制器,作为中介,在模型和视图之间进行交互。
重视关注点分离的 MVC 模式是创建可扩展和健壮 Web 应用的有效模式。在 ASP.NET Core 的背景下,MVC 多年来提供了一种高效构建应用程序的实际方法。虽然我们在第四章,REST API 中讨论了 REST API,但本章提供了如何使用 MVC 创建 REST API 的见解。我们还讨论了在此框架中使用数据传输对象(DTOs)。在本章中,我们涵盖了以下主题:
-
模型-视图-控制器设计模式
-
使用 MVC 和 DTOs
我们的最终目标是编写干净、可维护和可扩展的代码;ASP.NET Core MVC 框架是实现这一目标的首选工具。让我们深入探讨吧!
模型视图控制器设计模式
现在我们已经探讨了 REST 和 Minimal APIs 的基础知识,是时候探索 MVC 模式来构建 ASP.NET Core REST API 了。模型-视图-控制器(MVC)是常用于 Web 开发的设计模式。它在 ASP.NET 中构建 REST API 的历史很长,并且被许多人广泛使用和赞誉。这个模式将应用程序划分为三个相互关联的组件:模型、视图和控制器。在 MVC 中,视图以前代表用户界面。然而,在我们的案例中,视图是一个数据合约,反映了 REST API 的数据导向特性。
以这种方式划分责任与第三章,架构原则 中探讨的单一职责原则(SRP)相一致。然而,这并不是使用 ASP.NET Core 构建 REST API 的唯一方法,正如我们在第五章,Minimal APIs 中所看到的。
新的极简 API 模型与请求-端点-响应(REPR)模式相结合,可以使构建 REST API 更加精简。我们在第十八章,请求-端点-响应(REPR) 中介绍了这个模式。我们可以将 REPR 视为 ASP.NET Core Razor Pages 对页面导向型 Web 应用程序的作用,但它是针对 REST API 的。
我们通常围绕实体设计 MVC 应用程序,每个实体都有一个控制器来协调其端点。我们称之为 CRUD 控制器。然而,你可以根据你的需求设计你的控制器。在过去几十年里,REST API 的数量激增到了数以亿计;如今,每个人都在构建 API,这不仅仅是因为人们跟风,而是基于良好的理由。REST API 已经从根本上改变了系统之间的通信方式,提供了各种使它们在现代软件架构中不可或缺的好处。以下是几个有助于其广泛吸引力的关键因素:
-
数据效率:REST API 促进不同系统之间高效的数据共享,促进无缝的互联互通。
-
通用通信:REST API 利用普遍认可的数据格式,如 JSON 或 XML,确保广泛的兼容性和互操作性。
-
后端集中化:REST API 使后端能够作为中央枢纽,支持多个前端平台,包括移动、桌面和 Web 应用程序。
-
分层后端:REST API 促进后端的分层,允许创建提供基本功能的基础、低级 API。这些 API 可以被更高层次、以产品为中心的 API 消费,这些 API 提供了专门的功能,从而促进灵活和模块化的后端架构。
-
安全措施:REST API 可以作为网关,提供安全措施以保护下游系统,并确保数据访问得到适当的监管——这是分层 API 的一个好例子。
-
封装:REST API 允许将特定的逻辑单元封装到可重用、独立的模块中,这通常会导致更干净、更易于维护的代码。
-
可扩展性:由于它们是无状态的,REST API 更容易扩展以适应不断增长的负载。
这些优势极大地促进了后端系统在各种用户界面或甚至其他后端服务中的重用。例如,考虑一个典型的需要支持 iOS、Android 和 Web 平台的移动应用程序。通过利用通过 REST API 共享的后端,开发团队能够简化他们的工作,节省大量时间和成本。这种共享后端方法确保了平台之间的一致性,同时减少了维护多个代码库的复杂性。
我们在 第十九章,微服务架构简介 中探讨了不同的此类模式。
目标
在 REST API 的背景下,MVC 模式旨在通过将其分解为三个独立的、相互作用的组件来简化实体的管理过程。开发者不必与难以测试的大而臃肿的代码块作斗争,而是与更小的单元一起工作,这增强了可维护性并促进了高效的测试。这种模块化导致的功能小块更易于维护和测试。
设计
MVC 将应用程序分为三个不同的部分,每个部分都有单一的责任:
-
模型:模型代表我们正在建模的数据和业务逻辑。
-
视图:视图代表用户所看到的内容。在 REST API 的上下文中,这通常是一个序列化的数据结构。
-
控制器:控制器代表 MVC 的关键组件。它协调客户端请求和服务器响应之间的流程。控制器的主要角色是充当 HTTP 桥接器。本质上,控制器促进了系统内外部的通信。
控制器的代码应保持简洁,不包含复杂逻辑,作为客户端和领域之间的薄层。
我们在 第十四章,分层和清洁架构 中探讨了不同的观点。
下面是一个表示 REST API MVC 流的图:

图 6.1:使用 MVC 的 REST API 工作流程
在前面的图中,我们直接将模型发送到客户端。在大多数情况下,这并不理想。我们通常更喜欢只发送必要的数据部分,并按照我们的要求进行格式化。我们可以通过利用数据传输对象(DTO)模式来设计健壮的 API 合同来实现这一点。但在我们深入探讨之前,让我们首先了解 ASP.NET Core MVC 的基础知识。
ASP.NET Core Web API 的解剖结构
在 .NET 中创建 REST API 项目有许多方法,包括 dotnet new webapi CLI 命令,该命令也可从 Visual Studio 的 UI 中访问。接下来,我们将探索 MVC 框架的一些组件,从入口点开始。
入口点
第一部分是入口点:Program.cs 文件。自 .NET 6 以来,默认情况下不再有 Startup 类,编译器自动生成 Program 类。正如前一章所探讨的,使用最小托管模型会导致 Program.cs 文件简化,并减少样板代码。以下是一个示例:
using Shared;
using System.Text.Json.Serialization;
var builder = WebApplication.CreateBuilder(args);
builder.Services.AddCustomerRepository();
builder.Services
.AddControllers()
.AddJsonOptions(options => options
.JsonSerializerOptions
.Converters
.Add(new JsonStringEnumConverter())
)
;
builder.Services.AddEndpointsApiExplorer();
builder.Services.AddSwaggerGen();
var app = builder.Build();
if (app.Environment.IsDevelopment())
{
app.UseSwagger();
app.UseDarkSwaggerUI();
}
app.MapControllers();
app.InitializeSharedDataStore();
app.Run();
在前面的 Program.cs 文件中,突出显示的行标识了启用 ASP.NET Core MVC 所需的最小代码。其余部分与 Minimal APIs 代码非常相似。
目录结构
默认目录结构包含一个 Controllers 文件夹来托管控制器。在此基础上,我们可以创建一个 Models 文件夹来存储模型类,或者使用任何其他结构。
虽然控制器通常存放在
Controllers目录中以进行组织,但这种约定更多的是为了开发者的便利,而不是严格的要求。ASP.NET Core 对文件的存储位置不感兴趣,为我们提供了灵活性,可以根据我们的需求来构建项目结构。第四部分,应用模式 探讨了许多设计应用程序的方法。
接下来,我们将探讨这个模式的核心部分——控制器。
控制器
创建控制器最简单的方法是创建一个继承自ControllerBase的类。然而,尽管ControllerBase添加了许多实用方法,但唯一的要求是使用[ApiController]属性装饰控制器类。
按照惯例,我们用复数形式编写控制器名称,并在其后加上
Controller后缀。例如,如果控制器与Employee实体相关,我们将其命名为EmployeesController,默认情况下,这将导致一个易于理解的优秀 URL 模式:
-
获取所有员工:
/employees -
获取特定员工:
/employees/{id} -
等等。
一旦我们有了控制器类,我们必须添加动作。动作是公共方法,代表客户端可以执行的操作。每个动作代表一个 HTTP 端点。更精确地说,以下定义了一个控制器:
-
控制器公开一个或多个动作。
-
一个动作可以接受零个或多个输入参数。
-
一个动作可以返回零个或一个输出值。
-
动作处理 HTTP 请求。
我们应该将具有凝聚力的动作放在同一个控制器下,从而创建一个松散耦合的单元。
例如,以下表示包含单个Get动作的SomeController类:
[Route("api/[controller]")]
[ApiController]
public class SomeController : ControllerBase
{
[HttpGet]
public IActionResult Get() => Ok();
}
前面的Get方法(动作)向客户端返回一个空的200 OK响应。我们可以通过/api/some URI 访问端点。从那里,我们可以添加更多动作。
ControllerBase类为我们提供了与 Minimal APIsTypedResults类相同的许多实用方法的访问权限。
接下来,我们看看返回值。
返回值
构建 REST API 旨在向客户端返回数据并执行远程操作。大部分的管道工作都由 ASP.NET Core 代码为我们完成,包括序列化。
大部分 ASP.NET Core 管道都是可定制的,这超出了本章的范围。
在返回值之前,让我们看看ControllerBase类提供的几个有用的辅助方法:
| 方法 | 描述 |
|---|---|
StatusCode |
产生一个带有指定状态码的空响应。我们可以选择性地包含一个用于序列化到响应体的第二个参数。 |
Ok |
产生一个200 OK响应,表示操作成功。我们可以选择性地包含一个用于序列化到响应体的第二个参数。 |
Created |
产生一个201 Created响应,表示系统创建了实体。我们可以选择性地指定读取实体的位置和实体本身作为参数。《CreatedAtAction》和《CreatedAtRoute》方法为我们提供了组合位置值的选项。 |
NoContent |
产生一个空的204 No Content响应。 |
NotFound |
产生一个404 Not Found响应,表示未找到资源。 |
BadRequest |
产生一个400 Bad Request响应,表示客户端请求存在问题,通常是验证错误。 |
Redirect |
返回一个 302 Found 响应,接受 Location URL 作为参数。不同的 Redirect* 方法会产生 301 Moved Permanently、307 Temporary Redirect 和 308 Permanent Redirect 响应。 |
Accepted |
返回一个 202 Accepted 响应,表示异步过程的开始。我们可以选择指定客户端可以查询以了解异步操作状态的位置。我们还可以选择指定要序列化到响应体中的对象。《AcceptedAtAction》和《AcceptedAtRoute》方法为我们提供了组合位置值的选项。 |
Conflict |
返回一个 409 Conflict 响应,表示在处理请求时发生了冲突,通常是并发错误。 |
表 6.1:ControllerBase 类中产生 IActionResult 的方法子集。
ControllerBase类中的其他方法可以使用 IntelliSense(代码补全)或在官方文档中自行发现。我们涵盖的 第五章,最小化 API 中的大多数,如果不是全部,也都可以用于控制器。
使用辅助方法的优势在于利用 ASP.NET Core MVC 机制,使我们的工作更轻松。然而,您可以使用像 HttpContext 这样的低级 API 手动管理 HTTP 响应,或者创建实现 IActionResult 接口的自定义类,将自定义响应类钩入 MVC 管道。现在让我们看看我们可以用来向客户端返回数据的多重方式:
| 返回类型 | 描述 |
|---|---|
void |
我们可以返回 void 并使用 HttpContext 类手动管理 HTTP 响应。这是最底层和最复杂的方法。 |
TModel |
我们可以直接返回模型,ASP.NET Core 将对其进行序列化。这种方法的缺点是我们无法控制状态码,也无法从操作中返回多个不同的结果。 |
ActionResult/IActionResult |
我们可以返回这两个抽象之一。具体的结果可以有多种形式,具体取决于操作方法返回的实现。然而,这样做会使我们的 API 对像 SwaggerGen 这样的工具的自动发现性降低。 |
ActionResult<TModel> |
我们可以直接返回 TModel 和其他结果,如 NotFoundResult 或 BadRequestResult。这是最灵活的方法,使 API 对 ApiExplorer 来说最具可发现性。 |
表 6.2:返回数据的多重方式。
我们从一个示例开始,其中操作通过利用 Ok 方法(高亮代码)返回 Model 类的实例:
using Microsoft.AspNetCore.Mvc;
namespace MVC.API.Controllers;
[Route("api/[controller]")]
[ApiController]
public class ValuesController : ControllerBase
{
[HttpGet("IActionResult")]
public IActionResult InterfaceAction()
=> Ok(new Model(nameof(InterfaceAction)));
[HttpGet("ActionResult")]
public ActionResult ClassAction()
=> Ok(new Model(nameof(ClassAction)));
// ...
public record class Model(string Name);
}
前述代码的问题在于 API 的可发现性。《ApiExplorer》无法知道端点返回的内容。《ApiExplorer》将操作描述为返回 200 OK,但不知道 Model 类。为了克服这一限制,我们可以使用 ProducesResponseType 属性装饰我们的操作,有效地绕过限制,如下所示:
[ProducesResponseType(typeof(Model), StatusCodes.Status200OK)]
public IActionResult InterfaceAction() { ... }
在前面的代码中,我们将返回类型指定为第一个参数,将状态码指定为第二个参数。使用 StatusCodes 类的常量是引用标准状态码的便捷方式。我们可以用多个 ProducesResponseType 属性装饰每个操作,以定义不同的状态,例如 404 和 400。
在 ASP.NET Core MVC 中,我们还可以定义应用于我们控制器的约定,允许我们定义这些约定一次,并在整个应用程序中重用它们。我在 进一步阅读 部分留下了一个链接。
接下来,我们将探讨如何直接返回 Model 实例。ApiExplorer 可以通过这种方式发现方法的返回值,因此我们不需要使用 ProducesResponseType 属性:
[HttpGet("DirectModel")]
public Model DirectModel()
=> new Model(nameof(DirectModel));
接下来,多亏了 类转换运算符(更多信息请参阅 附录 A),我们也可以用 ActionResult<T> 做同样的事情,如下所示:
[HttpGet("ActionResultT")]
public ActionResult<Model> ActionResultT()
=> new Model(nameof(ActionResultT));
使用 ActionResult<T> 的主要好处是返回其他类型的结果。以下是一个示例,展示了方法返回 Ok 或 NotFound 的情况:
[HttpGet("MultipleResults")]
public ActionResult<Model> MultipleResults()
{
var condition = Random.Shared
.GetItems(new[] { true, false }, 1)
.First();
return condition
? Ok(new Model(nameof(MultipleResults)))
: NotFound();
}
然而,ApiExplorer 并不知道 404 Not Found,因此我们必须使用 ProducesResponseType 属性来记录它。
当方法体是异步的时,我们可以从操作方法返回
Task<T>或ValueTask<T>。这样做可以让您从控制器中编写异步/等待代码。我强烈建议尽可能返回
Task<T>或ValueTask<T>,因为这允许您的 REST API 使用相同的资源轻松处理更多请求。如今,库中非 Task 基础的方法很少见,所以您可能几乎没有选择。
我们学习了多种从操作返回值的方法。在功能支持方面,ActionResult<T> 类是最灵活的。另一方面,IActionResult 是最抽象的。接下来,我们将探讨如何将请求路由到这些操作方法。
属性路由
属性路由将 HTTP 请求映射到控制器操作。这些属性装饰控制器和操作以创建完整的路由。我们已经使用了一些这些属性。尽管如此,让我们来看看这些属性:
namespace MVC.API.Controllers.Empty;
[Route("empty/[controller]")]
[ApiController]
public class CustomersController : ControllerBase
{
[HttpGet]
public Task<IEnumerable<Customer>> GetAllAsync(
ICustomerRepository customerRepository)
=> throw new NotImplementedException();
[HttpGet("{id}")]
public Task<ActionResult<Customer>> GetOneAsync(
int id, ICustomerRepository customerRepository)
=> throw new NotImplementedException();
[HttpPost]
public Task<ActionResult> PostAsync(
[FromBody] Customer value, ICustomerRepository customerRepository)
=> throw new NotImplementedException();
[HttpPut("{id}")]
public Task<ActionResult<Customer>> PutAsync(
int id, [FromBody] Customer value,
ICustomerRepository customerRepository)
=> throw new NotImplementedException();
[HttpDelete("{id}")]
public Task<ActionResult<Customer>> DeleteAsync(
int id, ICustomerRepository customerRepository)
=> throw new NotImplementedException();
}
Route 属性和 Http[Method] 属性定义了用户应该查询什么以到达特定的资源。Route 属性允许我们定义一个路由模式,该模式适用于装饰的控制器下的所有 HTTP 方法。Http[Method] 属性确定用于到达该操作方法的 HTTP 方法。它们还提供了设置可选和可添加的路由模式的可能性,以处理更复杂的路由,包括指定路由参数。这些属性在构建简洁清晰的 URL 同时保持路由系统靠近控制器方面很有益。所有路由都必须是唯一的。根据代码,[Route("empty/[controller]")] 表示此控制器的操作可以通过 empty/customers(MVC 忽略 Controller 后缀)访问。然后,其他属性告诉 ASP.NET 将特定请求映射到特定方法:
| 路由属性 | HTTP 方法 | URL |
|---|---|---|
HttpGet |
GET |
empty/customers |
HttpGet("{id}") |
GET |
empty/customers/{id} |
HttpPost |
POST |
empty/customers |
HttpPut("{id}") |
PUT |
empty/customers/{id} |
HttpDelete("{id}") |
DELETE |
empty/customers/{id} |
表 6.3:示例控制器中的路由属性及其最终 URL
从前面的表中我们可以看出,只要 URL 是唯一的,我们甚至可以使用相同的属性为多个操作。在这种情况下,id 参数是 GET 区分符。接下来,我们可以使用 FromBody 属性来告诉模型绑定器使用 HTTP 请求体来获取该参数的值。有许多这样的属性;以下是一个列表:
| 属性 | 描述 |
|---|---|
FromBody |
将请求的 JSON 主体绑定到参数的类型。 |
FromForm |
绑定与参数名称匹配的表单值。 |
FromHeader |
绑定与参数名称匹配的 HTTP 标头值。 |
FromQuery |
绑定与参数名称匹配的查询字符串值。 |
FromRoute |
绑定与参数名称匹配的路由值。 |
FromServices |
从 ASP.NET Core 依赖注入容器中注入服务。 |
表 6.4:MVC 绑定源
ASP.NET Core MVC 进行了许多隐式绑定,因此您不必总是需要用属性装饰所有参数。例如,.NET 在代码示例中注入了我们需要的服务,我们从未使用过
FromServices属性。同样,FromRoute属性也是如此。
现在,如果我们回顾一下 CustomersController,路由映射看起来如下(我排除了与路由无关的代码以提高可读性):
| URL | Action/Method |
|---|---|
GET empty/customers |
GetAllAsync() |
GET empty/customers/{id} |
GetOneAsync(int id) |
POST empty/customers |
PostAsync([FromBody] Customer value) |
PUT empty/customers/{id} |
PutAsync(int id, [FromBody] Customer value) |
DELETE empty/customers/{id} |
DeleteAsync(int id) |
表 6.5:URL 与其相应操作方法之间的映射
在设计 REST API 时,通向我们的端点的 URL 应该是清晰简洁的,这样消费者就可以轻松发现和学习。通过按责任(关注点)分层组织我们的资源并创建一个统一的 URL 空间,有助于实现这一目标。消费者(即其他开发者)应该能够轻松理解端点背后的逻辑。想想看,如果你是 REST API 的消费者,你会如何看待你的端点。我甚至会将这个建议扩展到任何 API;始终考虑你的代码的消费者,以创建最佳可能的 API。
结论
本节探讨了 MVC 模式,如何创建控制器和操作方法,以及如何将请求路由到这些操作。我们本可以继续讨论 MVC,但那样会偏离主题。我们在这里涵盖的功能子集应该足以填补你可能存在的理论空白,并允许你理解利用 ASP.NET Core MVC 的代码示例。使用 MVC 模式有助于我们以下列方式遵循 SOLID 原则:
-
S:MVC 模式将数据结构的渲染分为三个不同的角色。框架处理大部分序列化部分(视图),只留下两个部分需要我们管理:模型和控制器。
-
O:N/A
-
L:N/A
-
I:每个控制器处理功能的一个子集,并代表对系统的一个较小接口。MVC 使得系统比所有路由只有一个入口点(如单个控制器)更容易管理。
-
D:N/A
接下来,我们探讨数据传输对象(DTO)模式,以隔离 API 的模型和领域。
使用 MVC 与 DTOs
本节探讨了利用 MVC 框架中的数据传输对象(DTO)模式。
本节与我们在第五章,最小 API中探讨的内容相同,但是在 MVC 的背景下。此外,这两个代码项目是同一个 Visual Studio 解决方案的一部分,以便于比较两种实现。
目标
作为提醒,DTOs(数据传输对象)旨在通过将 API 合同与应用程序的内部工作解耦来控制端点的输入和输出。DTOs 使我们能够定义我们的 API,而不必考虑底层的数据结构,从而让我们能够按照自己的意愿构建 REST API。
我们在第四章,REST API中更深入地讨论了 REST API 和 DTOs。
其他可能的目标是通过限制 API 传输的信息量来节省带宽,简化数据结构,或添加跨多个实体的 API 功能。
设计
让我们从分析一个扩展 MVC 以与 DTOs 一起工作的图表开始:

图 6.2:带有 DTO 的 MVC 工作流程
DTO 允许将领域(数据)与视图解耦,并使我们能够独立于领域管理 REST API 的输入和输出。控制器仍然操作领域模型,但返回一个序列化的 DTO。
项目 – MVC API
此代码示例与上一章相同,但使用 MVC 框架而不是 Minimal APIs。上下文:我们必须构建一个用于管理客户和合同的应用程序。我们必须跟踪每个合同的状态,并在业务需要联系客户时有一个主要联系人。最后,我们必须在仪表板上显示每个客户的合同数量和已打开合同数量。作为提醒,模型如下:
namespace Shared.Models;
public record class Customer(
int Id,
string Name,
List<Contract> Contracts
);
public record class Contract(
int Id,
string Name,
string Description,
WorkStatus Status,
ContactInformation PrimaryContact
);
public record class WorkStatus(int TotalWork, int WorkDone)
{
public WorkState State =>
WorkDone == 0 ? WorkState.New :
WorkDone == TotalWork ? WorkState.Completed :
WorkState.InProgress;
}
public record class ContactInformation(
string FirstName,
string LastName,
string Email
);
public enum WorkState
{
New,
InProgress,
Completed
}
前面的代码很简单。唯一的逻辑是 WorkStatus.State 属性,当该合同上的工作尚未开始时返回 WorkState.New,当所有工作都已完成时返回 WorkState.Completed,否则返回 WorkState.InProgress。控制器利用 ICustomerRepository 接口来模拟数据库操作。实现并不重要。它使用 List<Customer> 作为数据库。以下是允许查询和更新数据的接口:
using Shared.Models;
namespace Shared.Data;
public interface ICustomerRepository
{
Task<IEnumerable<Customer>> AllAsync(
CancellationToken cancellationToken);
Task<Customer> CreateAsync(
Customer customer,
CancellationToken cancellationToken);
Task<Customer?> DeleteAsync(
int customerId,
CancellationToken cancellationToken);
Task<Customer?> FindAsync(
int customerId,
CancellationToken cancellationToken);
Task<Customer?> UpdateAsync(
Customer customer,
CancellationToken cancellationToken);
}
现在我们已经了解了底层基础,我们来探讨一个不利用 DTO 的 CRUD 控制器。
原始 CRUD 控制器
如果我们创建一个 CRUD 控制器来直接管理客户,可能会出现许多问题(参见 RawCustomersController.cs)。首先,客户端的一个小错误可能会删除几个数据点。例如,如果客户端在 PUT 操作中忘记发送合同,那么就会删除与该客户关联的所有合同。以下是控制器代码:
// PUT raw/customers/1
[HttpPut("{id}")]
public async Task<ActionResult<Customer>> PutAsync(
int id,
[FromBody] Customer value,
ICustomerRepository customerRepository)
{
var customer = await customerRepository.UpdateAsync(
value,
HttpContext.RequestAborted);
if (customer == null)
{
return NotFound();
}
return customer;
}
突出显示的代码代表客户更新。因此,为了错误地删除所有合同,客户端可以发送以下 HTTP 请求(来自 MVC.API.http 文件):
PUT {{MVC.API.BaseAddress}}/customers/1
Content-Type: application/json
{
"id": 1,
"name": "Some new name",
"contracts": []
}
该请求将导致以下响应实体:
{
"id": 1,
"name": "Some new name",
"contracts": []
}
然而,之前那个客户已经有了合同(在我们开始应用程序时创建的)。以下是原始数据:
{
"id": 1,
"name": "Jonny Boy Inc.",
"contracts": [
{
"id": 1,
"name": "First contract",
"description": "This is the first contract.",
"status": {
"totalWork": 100,
"workDone": 100,
"state": "Completed"
},
"primaryContact": {
"firstName": "John",
"lastName": "Doe",
"email": "john.doe@jonnyboy.com"
}
},
{
"id": 2,
"name": "Some other contract",
"description": "This is another contract.",
"status": {
"totalWork": 100,
"workDone": 25,
"state": "InProgress"
},
"primaryContact": {
"firstName": "Jane",
"lastName": "Doe",
"email": "jane.doe@jonnyboy.com"
}
}
]
}
如我们所见,通过直接公开我们的实体,我们给了 API 的消费者很多权力。这种设计的一个问题是仪表板。用户界面将不得不计算合同的统计数据。此外,如果我们实现分页合同,用户界面可能会变得越来越复杂,甚至可能过度查询数据库,从而阻碍我们的性能。
我实现了整个 API,它可在 GitHub 上找到,但没有 UI。
接下来,我们将探讨如何使用 DTO 修复这两个用例。
DTO 控制器
为了解决我们的问题,我们使用 DTO 重新实现了控制器。为了更容易地跟踪,以下是所有 DTO 的参考:
namespace Shared.DTO;
public record class ContractDetails(
int Id,
string Name,
string Description,
int StatusTotalWork,
int StatusWorkDone,
string StatusWorkState,
string PrimaryContactFirstName,
string PrimaryContactLastName,
string PrimaryContactEmail
);
public record class CustomerDetails(
int Id,
string Name,
IEnumerable<ContractDetails> Contracts
);
public record class CustomerSummary(
int Id,
string Name,
int TotalNumberOfContracts,
int NumberOfOpenContracts
);
public record class CreateCustomer(string Name);
public record class UpdateCustomer(string Name);
首先,让我们修复我们的更新问题,从重新实现利用 DTO 的更新端点开始(参见 DTOCustomersController.cs 文件):
// PUT dto/customers/1
[HttpPut("{customerId}")]
public async Task<ActionResult<CustomerDetails>> PutAsync(
int customerId,
[FromBody] UpdateCustomer input,
ICustomerRepository customerRepository)
{
// Get the customer
var customer = await customerRepository.FindAsync(
customerId,
HttpContext.RequestAborted
);
if (customer == null)
{
return NotFound();
}
// Update the customer's name using the UpdateCustomer DTO
var updatedCustomer = await customerRepository.UpdateAsync(
customer with { Name = input.Name },
HttpContext.RequestAborted
);
if (updatedCustomer == null)
{
return Conflict();
}
// Map the updated customer to a CustomerDetails DTO
var dto = MapCustomerToCustomerDetails(updatedCustomer);
// Return the DTO
return dto;
}
在前面的代码中,主要的不同点在于(突出显示):
-
现在请求体绑定到
UpdateCustomer类,而不是Customer本身。 -
动作方法返回
CustomerDetails类的实例,而不是Customer本身。
然而,我们在控制器操作中看到的代码比以前更多。这是因为控制器现在处理数据变化,而不是客户端。现在的操作包括:
-
从数据库加载数据。
-
确保实体存在。
-
使用输入 DTO 更新数据,限制客户端只能访问属性子集。
-
继续更新。
-
确保实体仍然存在(处理冲突)。
-
将客户复制到输出 DTO 并返回。
通过这样做,我们现在控制客户端在通过输入 DTO(UpdateCustomer)发送PUT请求时可以做什么。此外,我们将计算统计数据的逻辑封装在服务器上。我们将计算隐藏在输出 DTO(CustomerDetails)后面,这降低了用户界面的复杂性,并允许我们在不影响任何客户端的情况下提高性能(松散耦合)。此外,我们现在使用customerId参数。如果我们发送与以前相同的 HTTP 请求,发送比我们接受更多的数据,只有客户的名字会改变。更重要的是,我们得到了显示客户统计所需的所有数据。以下是一个响应示例:
{
"id": 1,
"name": "Some new name",
"contracts": [
{
"id": 1,
"name": "First contract",
"description": "This is the first contract.",
"statusTotalWork": 100,
"statusWorkDone": 100,
"statusWorkState": "Completed",
"primaryContactFirstName": "John",
"primaryContactLastName": "Doe",
"primaryContactEmail": "john.doe@jonnyboy.com"
},
{
"id": 2,
"name": "Some other contract",
"description": "This is another contract.",
"statusTotalWork": 100,
"statusWorkDone": 25,
"statusWorkState": "InProgress",
"primaryContactFirstName": "Jane",
"primaryContactLastName": "Doe",
"primaryContactEmail": "jane.doe@jonnyboy.com"
}
]
}
如前所述的响应所示,只有客户的名字改变了,但我们现在收到了statusWorkDone和statusTotalWork字段。最后,我们简化了数据结构。
DTOs 是简化数据结构的绝佳资源,但您不必这样做。您必须始终为特定用例设计系统,包括 DTO 和数据合约。
至于仪表板,“获取所有客户”端点通过执行类似操作来实现这一点。它输出CustomerSummary对象集合,而不是客户本身。在这种情况下,控制器执行计算并将实体的相关属性复制到 DTO 中。以下是代码:
// GET: dto/customers
[HttpGet]
public async Task<IEnumerable<CustomerSummary>> GetAllAsync(
ICustomerRepository customerRepository)
{
// Get all customers
var customers = await customerRepository.AllAsync(
HttpContext.RequestAborted
);
// Map customers to CustomerSummary DTOs
var customersSummary = customers
.Select(customer => new CustomerSummary(
Id: customer.Id,
Name: customer.Name,
TotalNumberOfContracts: customer.Contracts.Count,
NumberOfOpenContracts: customer.Contracts.Count(x => x.Status.State != WorkState.Completed)
))
;
// Return the DTOs
return customersSummary;
}
在前面的代码中,动作方法:
-
读取实体
-
创建 DTOs 并计算未完成合同的数量。
-
返回 DTOs。
就这样,我们现在在服务器上封装了计算。
您应根据实际数据源优化此类代码。在这种情况下,
staticList<T>具有低延迟。然而,查询整个数据库以获取计数可能会成为瓶颈。
调用端点会产生以下结果:
[
{
"id": 1,
"name": "Some new name",
"totalNumberOfContracts": 2,
"numberOfOpenContracts": 1
},
{
"id": 2,
"name": "Some mega-corporation",
"totalNumberOfContracts": 1,
"numberOfOpenContracts": 1
}
]
现在构建我们的仪表板变得超级简单。我们可以查询该端点一次,并在 UI 中显示数据。UI 将计算任务卸载到后端。
用户界面通常比 API 更复杂,因为它们是状态化的。因此,将尽可能多的复杂性卸载到后端有助于。您可以使用后端-for-前端(BFF)来帮助完成这项任务。我们在第十九章“微服务架构简介”中探讨了分层 API 的方法,包括 BFF 模式。
最后,您可以使用MVC.API.DTO.http文件中的 HTTP 请求来玩转 API。我使用类似的技术实现了所有端点。如果您的控制器逻辑变得过于复杂,将它们封装到其他类中是一种良好的做法。我们在第四部分:应用模式中探讨了组织应用程序代码的许多技术。
结论
数据传输对象允许我们设计一个具有特定数据契约(输入和输出)的 API 端点,而不是暴露领域或数据模型。这种表示层和领域之间的分离是导致拥有多个独立组件而不是更大、更脆弱的一个的关键元素。使用 DTO 来控制输入和输出,使我们能够更好地控制客户端可以做什么或接收什么。使用数据传输对象模式有助于我们以下列方式遵循 SOLID 原则:
-
S: DTO 在领域模型和 API 契约之间添加了清晰的边界。此外,拥有输入和输出 DTO 有助于进一步分离责任。
-
O: N/A
-
L: N/A
-
I: 一个 DTO 是一个小型、专门定制的数据契约(抽象),在 API 契约中有明确的目的。
-
D: 由于那些较小的接口(ISP),DTO 允许在不影响客户端的情况下更改端点的实现细节,因为它们只依赖于 API 契约(一个抽象)。
您已经学习了 DTO 的附加价值,它们在 API 契约中的作用,以及 ASP.NET Core MVC 框架。
摘要
本章探讨了模型-视图-控制器(MVC)设计模式,这是 ASP.NET 生态系统中的一个成熟框架,它比其较新的最小 API 对等体提供了更多高级功能。最小 API 并不是与 MVC 竞争;我们可以将它们一起使用。MVC 模式强调关注点的分离,使其成为创建可维护、可扩展和健壮的 Web 应用的成熟模式。我们将 MVC 模式分解为其三个核心组件:模型、视图和控制器。模型表示数据和业务逻辑,视图是面向用户的组件(序列化的数据结构),控制器作为中介,在模型和视图之间进行调解。我们还讨论了使用数据传输对象(DTO)以我们需要的格式打包数据,提供了许多好处,包括灵活性、效率、封装和性能提升。DTO 是 API 契约的关键部分。现在我们已经探讨了原则和方法,是时候继续我们的学习,并解决更多设计模式和功能了。接下来的两章将探讨我们的第一个四人帮(GoF)设计模式,并深入探讨 ASP.NET Core 依赖注入(DI)。所有这些都将帮助我们继续我们开始的道路:学习设计更好软件的工具。
问题
让我们看看几个练习问题:
-
MVC 设计模式的三个组件是什么?
-
在 MVC 模式中,控制器的作用是什么?
-
数据传输对象 (DTO) 是什么,为什么它们很重要?
-
MVC 模式如何有助于提高应用程序的可维护性?
-
MVC 中的属性路由是如何工作的?
进一步阅读
以下是一些链接,可以帮助我们巩固本章所学的内容:
-
使用 Web API 规范:
adpg.link/ioKV -
开始使用 Swashbuckle 和 ASP.NET Core:
adpg.link/ETja
答案
-
MVC 设计模式的三个组件是模型、视图和控制器。
-
在 MVC 模式中,控制器充当中间件,在模型和视图之间进行交互调解。
-
我们使用数据传输对象 (DTO) 将数据打包成一种格式,它提供了许多好处,包括高效的数据共享、封装和改进的可维护性。
-
MVC 模式通过分离关注点来提高应用程序的可维护性。每个组件(模型、视图、控制器)都有特定的角色和责任,这使得代码更容易管理、测试和扩展。这种分离使得对一个组件的更改对其他组件的影响最小化。
-
在 MVC 中,属性路由将 HTTP 请求映射到控制器操作。这些属性装饰控制器和操作以创建完整的路由。
第七章:7 策略模式、抽象工厂模式和单例设计模式
在开始之前:加入我们的 Discord 书籍社区
直接向作者本人提供反馈,并在我们的 Discord 服务器上与其他早期读者聊天(在“architecting-aspnet-core-apps-3e”频道下找到,属于早期访问订阅)。

本章探讨了使用四人帮(GoF)的一些经典、简单且强大的设计模式来创建对象。这些模式允许开发者封装和重用行为,集中对象创建,增加设计的灵活性,或控制对象的生命周期。此外,你很可能会在将来直接或间接构建的所有软件中使用其中的一些。
GoF
Erich Gamma、Richard Helm、Ralph Johnson 和 John Vlissides 是《设计模式:可复用面向对象软件元素》(1994 年)的作者,该书被称为四人帮(GoF)。在这本书中,他们介绍了 23 种设计模式,其中一些我们在本书中重新审视。
为什么它们如此重要?因为它们是健壮对象组合的构建块,有助于创建灵活性和可靠性。此外,在第八章,依赖注入中,我们将这些模式变得更加强大!但首先,在本章中,我们将介绍以下主题:
-
策略设计模式
-
抽象工厂设计模式
-
单例设计模式
策略设计模式
策略模式是一种行为设计模式,它允许我们在运行时改变对象的行为。我们还可以使用这种模式来组合复杂的对象树,并依靠它来遵循开闭原则(OCP)而无需太多努力。此外,它在组合优于继承的思维方式中发挥着重要作用。在本章中,我们重点关注策略模式的行为部分。下一章将介绍如何动态地使用策略模式来组合系统。
目标
策略模式的目的是从需要它的宿主类(上下文或消费者)中提取算法(策略)。这允许消费者在运行时决定使用哪种策略(算法)。例如,我们可以设计一个系统,从两种不同类型的数据库中获取数据。然后我们可以应用相同的逻辑处理这些数据,并使用相同的用户界面来显示它们。为了实现这一点,我们可以使用策略模式创建两个策略,一个命名为FetchDataFromSql,另一个命名为FetchDataFromCosmosDb。然后我们可以在运行时将所需的策略插入到context类中。这样,当消费者调用context时,context不需要知道数据来自哪里,如何获取,或正在使用什么策略;它只需获取它需要工作的部分,将获取数据的责任委托给一个抽象的策略(接口)。
设计
在进行任何进一步解释之前,让我们看一下以下类图:

图 7.1:策略模式类图
根据前面的图,策略模式的构建块如下:
-
Context是一个依赖于IStrategy接口并利用IStrategy接口的实现来执行ExecuteAlgo方法的类。 -
IStrategy是定义策略 API 的接口。 -
ConcreteStrategy1和ConcreteStrategy2代表了IStrategy接口的一个或多个不同的具体实现。
在以下图中,我们探索了运行时发生的情况。演员代表任何消耗 Context 对象的代码。

图 7.2:策略模式序列图
当消费者调用 Context.SomeOperation() 方法时,它不知道执行的是哪个实现,这是此模式的一个基本部分。Context 类也不应该知道它使用的策略。它应该通过接口运行策略,而对实现一无所知。这就是策略模式的优势:它将实现从 Context 类及其消费者中抽象出来。正因为如此,我们可以在对象创建期间或运行时更改策略,而对象不知道,可以即时改变其行为。
注意
我们甚至可以将最后一句推广到任何接口。依赖于接口打破了消费者和实现之间的联系,通过依赖这个抽象来实现。
项目 – 策略
上下文:我们希望以不同的方式对集合进行排序,最终甚至使用不同的排序算法(示例范围之外,但可能)。最初,我们希望支持按升序或降序对任何集合的元素进行排序。为了实现这一点,我们需要实现以下构建块:
-
Context是SortableCollection类。 -
IStrategy是ISortStrategy接口。 -
具体的策略如下:
-
SortAscendingStrategy -
SortDescendingStrategy
消费者是一个小的 REST API,允许用户更改策略、排序集合并显示项目。让我们从 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)对输入进行排序并直接返回结果。
提示
在使用表达式主体方法时,请确保您不会通过创建非常复杂的单行代码使方法对同事(或未来的您)更难阅读。编写多行代码通常会使代码更容易阅读。
下一个要检查的构建块是 SortableCollection 类。它由多个字符串项目(Items 属性)组成,可以使用 ISortStrategy 对它们进行排序。在此基础上,它通过其 Items 属性实现了 IEnumerable<string> 接口,使其可迭代。以下是该类的代码:
using System.Collections;
using System.Collections.Immutable;
namespace MySortingMachine;
public sealed class SortableCollection : IEnumerable<string>
{
private ISortStrategy _sortStrategy;
private ImmutableArray<string> _items;
public IEnumerable<string> Items => _items;
public SortableCollection(IEnumerable<string> items)
{
_items = items.ToImmutableArray();
_sortStrategy = new SortAscendingStrategy();
}
public void SetSortStrategy(ISortStrategy strategy)
=> _sortStrategy = strategy;
public void Sort()
{
_items = _sortStrategy
.Sort(Items)
.ToImmutableArray()
;
}
public IEnumerator<string> GetEnumerator()
=> Items.GetEnumerator();
IEnumerator IEnumerable.GetEnumerator()
=> ((IEnumerable)Items).GetEnumerator();
}
SortableCollection 类是目前最复杂的一个,所以让我们更深入地了解一下:
-
_sortStrategy字段引用了算法:一个ISortStrategy实现。 -
_items字段引用了字符串本身。 -
Items属性将字符串暴露给类的消费者。 -
构造函数使用
items参数初始化Items属性,并设置默认的排序策略。 -
SetSortStrategy方法允许消费者在运行时更改策略。 -
Sort方法使用_sortStrategy字段对项目进行排序。 -
两个
GetEnumerator方法代表了IEnumerable<string>接口的实现,并通过Items属性使类可枚举。
通过这段代码,我们可以看到策略模式的作用。_sortStrategy 字段代表了当前的算法,遵守 ISortStrategy 合同,可以通过 SetSortStrategy 方法在运行时更新。Sort 方法将工作委托给 ISortStrategy 实现(具体策略)。因此,更改 _sortStrategy 字段的值会导致 Sort 方法的行为改变,使这个模式非常强大且简单。高亮显示的代码代表了这种模式。
_items字段是一个ImmutableArray<string>,这使得从外部更改列表变得不可能。例如,消费者不能将List<string>传递给构造函数,然后稍后更改它。不可变性有许多优点。
让我们通过查看 Consumer.API 项目来实验一下,这是一个使用之前代码的 REST API 应用程序。接下来是 Program.cs 文件的分解:
using MySortingMachine;
SortableCollection data = new(new[] {
"Lorem", "ipsum", "dolor", "sit", "amet." });
data 成员是上下文,我们的可排序项目集合。接下来,我们看看创建应用程序和将 enum 值序列化为字符串的一些样板代码:
var builder = WebApplication.CreateBuilder(args);
builder.Services.ConfigureHttpJsonOptions(options => {
options.SerializerOptions.Converters
.Add(new JsonStringEnumConverter());
});
var app = builder.Build();
最后,最后一部分代表上下文的消费者:
app.MapGet("/", () => data);
app.MapPut("/", (ReplaceSortStrategy sortStrategy) =>
{
ISortStrategy strategy = sortStrategy.SortOrder == SortOrder.Ascending
? new SortAscendingStrategy()
: new SortDescendingStrategy();
data.SetSortStrategy(strategy);
data.Sort();
return data;
});
app.Run();
public enum SortOrder
{
Ascending,
Descending
}
public record class ReplaceSortStrategy(SortOrder SortOrder);
在前面的代码中,我们声明了以下端点:
-
当客户端发送
GET请求时,第一个端点返回data对象。 -
第二个端点允许在客户端发送 PUT 请求时根据
SortOrder枚举更改排序策略。一旦策略被修改,它就会对集合进行排序并返回排序后的数据。
高亮显示的代码代表了策略模式的实现。
ReplaceSortStrategy类是一个输入 DTO。与SortOrder枚举结合使用,它们代表了第二个端点的数据合约。
当我们运行 API 并请求第一个端点时,它响应以下 JSON 主体:
[
"Lorem",
"ipsum",
"dolor",
"sit",
"amet."
]
正如我们所见,项目按照我们设置的顺序排列,因为代码从未调用 Sort 方法。接下来,让我们向 API 发送以下 HTTP 请求以更改排序策略为“降序”:
PUT https://localhost:7280/
Content-Type: application/json
{
"sortOrder": "Descending"
}
执行后,端点响应以下 JSON 数据:
[
"sit",
"Lorem",
"ipsum",
"dolor",
"amet."
]
从内容中我们可以看出,排序算法是有效的。之后,如果我们查询 GET 端点,列表将保持相同的顺序。接下来,让我们通过序列图来查看这个用例:

图 7.3:使用“降序排序策略”对项目进行排序的序列图
上述图示显示了 Program 使用其 SetSortStrategy 方法创建一个策略并将其分配给 SortableCollection。然后,当 Program 调用 Sort() 方法时,SortableCollection 实例将排序计算委派给 ISortStrategy 接口的底层实现。该实现是 SortDescendingStrategy 类(即 策略),这是 Program 在开始时设置的。
发送另一个
PUT请求,但指定Ascending排序顺序,结果相似,但项目将按字母顺序排序。HTTP 请求在
Consumer.API.http文件中可用。
从策略模式的角度来看,SortableCollection 类(即 上下文)负责引用和使用当前策略。
结论
策略设计模式在委派责任给其他对象方面非常有效,允许你在保持使用简单的同时,将算法的责任转交给其他对象。它还允许拥有一个丰富的接口(上下文),其行为可以在运行时改变。正如我们所见,策略模式在帮助我们遵循 SOLID 原则方面表现出色:
-
S:它有助于从外部类中提取责任,并可以互换使用。
-
O:它允许在运行时通过更改当前策略来扩展类,而不需要更新其代码,这几乎就是 OCP 的实际定义。
-
L:它不依赖于继承。此外,它在 组合优于继承原则 中扮演着重要角色,帮助我们避免完全使用继承和 LSP。
-
I:通过创建基于精简和专注接口的小型策略,策略模式是 ISP 的优秀推动者。
-
D:依赖关系的创建从使用策略(上下文)的类移动到类的消费者。这使得上下文依赖于抽象而不是实现,反转了控制流。
C# 特性
如果你注意到了你不太熟悉的 C# 特性,附录 A 简要解释了许多它们。
接下来,让我们探索抽象工厂模式。
抽象工厂设计模式
抽象工厂设计模式是 GoF 中的一个创建型设计模式。我们使用创建型模式来创建其他对象,而工厂是做这件事的一种非常流行的方式。策略模式是依赖注入的骨干,它使得复杂对象树的组合成为可能,而工厂用于创建一些依赖注入库无法自动组装的复杂对象。关于这一点,将在下一章中详细介绍。
目标
抽象工厂模式用于抽象一组对象的创建。它通常意味着在该系列中创建多个对象类型。一个系列是一组相关或依赖的对象(类)。让我们考虑创建汽车车辆。有多种车辆类型,每种类型都有多种型号和品牌。我们可以使用抽象工厂模式来模拟这种场景。
注意
工厂方法模式也侧重于创建单一类型的对象,而不是一个系列。我们在这里只介绍抽象工厂,但在本书的后面部分我们将使用其他类型的工厂。
设计
使用抽象工厂模式,消费者请求一个抽象对象并得到一个。工厂是一个抽象,产生的对象也是抽象的,将对象的创建与其消费者解耦。这允许在不影响消费者的情况下添加或删除一起产生的对象系列(所有参与者都通过抽象进行通信)。在我们的例子中,这个系列(工厂可以生产的对象集合)由一辆汽车和一辆自行车组成,每个工厂(系列)必须生产这两个对象。如果我们考虑车辆,我们可能有能力创建每种车辆类型的低端和高端型号。以下是使用抽象工厂模式实现这一点的示意图:

图 7.4:抽象工厂类图
在图中,我们有以下元素:
-
IVehicleFactory接口代表抽象工厂。它定义了两个方法:一个用于创建ICar类型的汽车,另一个用于创建IBike类型的自行车。 -
HighEndVehicleFactory类是一个实现了IVehicleFactory接口的具体工厂。它处理高端车辆型号的创建,并且其方法返回HighEndCar或HighEndBike实例。 -
LowEndVehicleFactory是一个实现了IVehicleFactory接口的第二个具体工厂。它处理低端车辆型号的创建,并且其方法返回LowEndCar或LowEndBike实例。 -
LowEndCar和HighEndCar是ICar的两种实现。 -
LowEndBike和HighEndBike是IBike的两种实现。
根据该图,消费者通过 IVehicleFactory 接口使用具体工厂,并且不应该意识到底层使用的实现。应用此模式抽象了车辆创建过程。
项目 - 抽象工厂
上下文:我们需要支持创建多种车型。我们还需要能够在车型可用时添加新模型,而不会影响系统。起初,我们只支持高端和低端车型,但我们知道这迟早会改变。程序必须只支持创建汽车和自行车。为了我们的演示,车辆只是空的类和接口,因为学习如何建模车辆不是理解该模式所必需的;那将是噪音。以下代码代表这些实体:
public interface ICar { }
public interface IBike { }
public class LowEndCar : ICar { }
public class LowEndBike : IBike { }
public class HighEndCar : ICar { }
public class HighEndBike : IBike { }
接下来,我们看看我们想要研究的部分——工厂:
public interface IVehicleFactory
{
ICar CreateCar();
IBike CreateBike();
}
public class LowEndVehicleFactory : IVehicleFactory
{
public IBike CreateBike() => new LowEndBike();
public ICar CreateCar() => new LowEndCar();
}
public class HighEndVehicleFactory : IVehicleFactory
{
public IBike CreateBike() => new HighEndBike();
public ICar CreateCar() => new HighEndCar();
}
这些工厂是简单的实现,很好地描述了该模式:
-
LowEndVehicleFactory创建低端车型。 -
HighEndVehicleFactory创建高端车型。
该代码的消费者是一个 xUnit 测试项目。单元测试通常是你的第一个消费者,特别是如果你在做 测试驱动开发(TDD)。为了使测试更容易,我创建了以下基类测试:
using Xunit;
namespace Vehicles;
public abstract class BaseAbstractFactoryTest<TConcreteFactory, TExpectedCar, TExpectedBike>
where TConcreteFactory : IVehicleFactory, new()
{
// Test methods here
}
该类的关键是以下泛型参数:
-
TConcreteFactory泛型参数代表我们想要测试的具体工厂的类型。它的泛型约束指定它必须实现IVehicleFactory接口并且有一个无参构造函数。 -
TExpectedCar泛型参数代表我们从CreateCar方法期望的ICar类型。 -
TExpectedBike泛型参数代表我们从CreateBike方法期望的IBike类型。
该类包含的第一个测试方法是以下内容:
[Fact]
public void Should_create_a_ICar_of_type_TExpectedCar()
{
// Arrange
IVehicleFactory vehicleFactory = new TConcreteFactory();
var expectedCarType = typeof(TExpectedCar);
// Act
ICar result = vehicleFactory.CreateCar();
// Assert
Assert.IsType(expectedCarType, result);
}
前面的测试方法使用 TConcreteFactory 泛型参数创建一个车辆工厂,然后使用该工厂创建一辆车。最后,它断言 ICar 实例是期望的类型。第二个测试方法包含以下内容:
[Fact]
public void Should_create_a_IBike_of_type_TExpectedBike()
{
// Arrange
IVehicleFactory vehicleFactory = new TConcreteFactory();
var expectedBikeType = typeof(TExpectedBike);
// Act
IBike result = vehicleFactory.CreateBike();
// Assert
Assert.IsType(expectedBikeType, result);
}
前面的测试方法非常相似,使用 TConcreteFactory 泛型参数创建一个车辆工厂,但然后用该工厂创建一辆自行车而不是汽车。最后,它断言 IBike 实例是期望的类型。
我使用
ICar和IBike接口来对变量进行类型化,而不是var,以明确result变量的类型。在另一个上下文中,我会使用var。同样适用于IVehicleFactory接口。
现在,为了测试低端工厂,我们声明以下测试类:
namespace Vehicles.LowEnd;
public class LowEndVehicleFactoryTest : BaseAbstractFactoryTest<LowEndVehicleFactory, LowEndCar, LowEndBike>
{
}
该类仅依赖于 BaseAbstractFactoryTest 类并指定了要测试的类型(高亮显示)。接下来,为了测试高端工厂,我们声明以下测试类:
namespace Vehicles.HighEnd;
public class HighEndVehicleFactoryTest : BaseAbstractFactoryTest<HighEndVehicleFactory, HighEndCar, HighEndBike>
{
}
与低端工厂类似,该类依赖于 BaseAbstractFactoryTest 类并指定了要测试的类型(高亮显示)。
在更复杂的场景中,如果我们不能使用
new()泛型约束,我们可以利用 IoC 容器来创建TConcreteFactory的实例,并可选地模拟其依赖项。
使用那段测试代码,我们创建了以下两组两个测试:
-
一个应该创建
LowEndCar实例的LowEndVehicleFactory类。 -
一个应该创建
LowEndBike实例的LowEndVehicleFactory类。 -
一个应该创建
HighEndCar实例的HighEndVehicleFactory类。 -
一个应该创建
HighEndBike实例的HighEndVehicleFactory类。
现在我们有四个测试:两个针对自行车,两个针对汽车。如果我们回顾测试的执行情况,两个测试方法都不了解类型。它们使用抽象工厂(IVehicleFactory)并测试 result 是否符合预期类型,而不了解它们正在测试什么,只知道抽象。这显示了消费者(测试)和工厂之间的耦合是多么松散。
在现实世界的程序中,我们会使用
ICar或IBike实例根据规格执行相关操作。这可能是一个赛车游戏或富人的车库管理系统;谁知道呢!
这个项目的关键部分是对象创建过程的抽象化。测试代码(消费者)并不了解实现。接下来,我们扩展我们的实现。
项目 – 中端车辆工厂
为了证明我们基于抽象工厂模式的设计的灵活性,让我们添加一个新的具体工厂,命名为 MidRangeVehicleFactory。该工厂应返回一个 MidRangeCar 或 MidRangeBike 实例。再次强调,汽车和自行车只是空的类(当然,在你的程序中,它们将执行某些操作):
public class MiddleGradeCar : ICar { }
public class MiddleGradeBike : IBike { }
新的 MidRangeVehicleFactory 看起来几乎与另外两个相同:
public class MidRangeVehicleFactory : IVehicleFactory
{
public IBike CreateBike() => new MiddleGradeBike();
public ICar CreateCar() => new MiddleGradeCar();
}
现在,为了测试中端工厂,我们声明以下测试类:
namespace Vehicles.MidRange;
public class MidRangeVehicleFactoryTest : BaseAbstractFactoryTest<MidRangeVehicleFactory, MidRangeCar, MidRangeBike>
{
}
就像低端和高端工厂一样,中端测试类依赖于 BaseAbstractFactoryTest 类,并指定要测试的类型(突出显示)。如果我们运行测试,我们现在有以下六个通过测试:

图 7.5:Visual Studio 测试资源管理器展示了六个通过测试。
因此,在未更新消费者(AbstractFactoryTest 类)的情况下,我们添加了一个新的车辆系列,即中端汽车和自行车;感谢抽象工厂模式为我们带来了这样的奇妙功能!
抽象工厂的影响
在结论之前,如果我们不是使用抽象工厂(在过程中破坏了 ISP)而是将所有内容打包到一个大接口中,会发生什么?我们可以创建以下类似接口:
public interface ILargeVehicleFactory
{
HighEndBike CreateHighEndBike();
HighEndCar CreateHighEndCar();
LowEndBike CreateLowEndBike();
LowEndCar CreateLowEndCar();
}
正如我们所见,前面的接口包含四个具体方法,看起来很温和。然而,该代码的消费者将与这些具体方法紧密耦合。例如,要改变消费者行为,我们需要更新其代码,比如将调用从CreateHighEndBike更改为CreateLowEndBike,这打破了 OCP。另一方面,使用工厂方法,我们可以为消费者设置不同的工厂以产生不同的结果,这把灵活性从对象本身移出,变成了对象图组合的问题(更多内容将在下一章中讨论)。此外,当我们想添加中端车辆时,我们必须更新ILargeVehicleFactory接口,这变成了一个破坏性变更(ILargeVehicleFactory的实现必须更新)。以下是一个两个新方法的示例:
public interface ILargeVehicleFactory
{
HighEndBike CreateHighEndBike();
HighEndCar CreateHighEndCar();
LowEndBike CreateLowEndBike();
LowEndCar CreateLowEndCar();
MidRangeBike CreateMidRangeBike();
MidRangeCar CreateMidRangeCar();
}
从那里,一旦实现更新,如果我们想消费新的中端车辆,我们需要打开每个消费者类并应用那里的更改,这再次打破了 OCP。
最关键的部分是理解和看到耦合及其影响。有时,将一个或多个类紧密耦合在一起是可以接受的,因为我们并不总是需要 SOLID 原则和一些设计模式所能带来的额外灵活性。
在探索本章的最后一个设计模式之前,让我们先总结一下。
结论
抽象工厂模式非常适合抽象对象家族的创建,隔离每个家族及其具体实现,使消费者对工厂在运行时创建的家族一无所知。我们将在下一章中更多地讨论工厂;同时,让我们看看抽象工厂模式如何帮助我们遵循SOLID原则:
-
S: 每个具体工厂只负责创建一组对象。您可以将抽象工厂与其他创建型模式结合使用,例如原型模式和建造者模式,以满足更复杂的创建需求。
-
O: 我们可以创建新的对象家族,如中端车辆,而不会破坏现有的客户端代码。
-
L: 我们的目标是组合,因此不需要任何继承,这隐含地摒弃了 LSP 的需求。如果你在设计中使用抽象类,你必须确保在创建新的抽象工厂时不要破坏 LSP。
-
I: 从具有许多实现的抽象中提取一个小抽象,其中每个具体工厂专注于一个家族,这使得该接口非常专注于一项任务,而不是拥有一个暴露所有类型产品的庞大接口(如
ILargeVehicleFactory接口)。 -
D: 通过仅依赖于接口,消费者对其使用的具体类型一无所知。
接下来,我们将探索本章的最后一个设计模式。
单例设计模式
单例设计模式允许创建和重用类的单个实例。我们可以使用静态类来实现几乎相同的目标,但不是所有事情都可以使用静态类完成。例如,静态类不能实现接口。我们不能将静态类的实例作为参数传递,因为没有实例。我们只能直接使用静态类,这每次都会导致紧密耦合。在 C#中,单例模式是一个反模式,我们应该很少使用它,如果必须使用,则使用依赖注入代替。话虽如此,它是一个值得学习的经典设计模式,至少可以避免实现它。我们将在下一章探索一个更好的替代方案。以下是为什么我们要介绍这个模式的一些原因:
-
它将在下一章中转化为单例范围。
-
除非你知道它的存在,否则你无法定位它,尝试删除它或避免使用它。
-
这是一个简单的模式可以探索。
-
它导致其他模式,例如环境上下文模式。
目标
单例模式限制一个类的实例数量为单个。然后,想法是随后重用相同的实例。单例封装了对象逻辑本身及其创建逻辑。例如,单例模式可以降低实例化具有大内存占用对象的成本,因为程序只实例化一次。你能想到一个在这里被破坏的 SOLID 原则吗?单例模式提倡一个对象必须有两个职责,这违反了单一职责原则(SRP)。单例既是对象本身也是它自己的工厂。
设计
这种设计模式很简单,仅限于一个类。让我们从类图开始:

图 7.6:单例模式类图
Singleton 类由以下部分组成:
-
一个私有静态字段,用于保存其唯一的实例。
-
一个公共静态
Create()方法,用于创建或返回唯一的实例。 -
一个私有构造函数,因此外部代码不能通过
Create方法之外的方式实例化它。
你可以给
Create()方法命名,甚至可以去掉它,就像我们在下一个例子中看到的那样。我们可以将其命名为GetInstance(),或者它可以是名为Instance的静态属性,或者可以有任何其他相关的名称。
我们可以将前面的图转换为以下代码:
public class MySingleton
{
private static MySingleton? _instance;
private MySingleton() { }
public static MySingleton Create()
{
_instance ??= new MySingleton();
return _instance;
}
}
空合并赋值运算符
??=仅在_instance成员为null时才分配MySingleton的新实例。这一行等同于编写以下 if 语句:
if (_instance == null)
{
_instance = new MySingleton();
}
在更深入讨论代码之前,让我们探索我们新类的行为。我们可以在以下单元测试中看到,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);
}
}
哇!我们得到了一个工作的单例模式,它极其简单——可能是我能想到的最简单的设计模式。以下是底层发生的事情:
-
当消费者第一次调用
MySingleton.Create()时,它创建了MySingleton的第一个实例。由于构造函数是private的,它只能从内部创建。 -
然后
Create方法将第一个实例持久化到_instance字段,以供将来使用。 -
当消费者第二次调用
MySingleton.Create()时,它返回_instance字段,重新使用类的先前(也是唯一)的实例。
现在我们已经理解了逻辑,该设计存在一个潜在的问题:它不是线程安全的。如果我们想让我们的单例是线程安全的,我们可以像这样锁定实例创建:
public class MySingletonWithLock
{
private static readonly object _myLock = new();
private static MySingletonWithLock? _instance;
private MySingletonWithLock() { }
public static MySingletonWithLock Create()
{
lock (_myLock)
{
_instance ??= new MySingletonWithLock();
}
return _instance;
}
}
在前面的代码中,我们确保两个线程不会同时尝试访问Create方法,以确保它们不会得到不同的实例。接下来,我们通过缩短代码来改进我们的线程安全示例。
另一种(更好的)方法
以前,我们使用实现单例模式的“长方法”,并不得不实现一个线程安全的机制。现在,那个经典的方法已经过去了。我们可以缩短代码,甚至可以像这样移除Create()方法:
public class MySimpleSingleton
{
public static MySimpleSingleton Instance { get; } = new MySimpleSingleton();
private MySimpleSingleton() { }
}
前面的代码依赖于静态初始化器来确保只创建一个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);
}
通常最好是在可能的情况下将责任委托给语言或框架,就像我们在这里使用属性初始化器所做的那样。使用静态构造函数也是一个有效且线程安全的替代方案,再次将任务委托给语言特性。
小心箭头操作符。
可能会诱使您使用箭头操作符
=>来初始化Instance属性,如下所示:public static MySimpleSingleton Instance => new MySimpleSingleton();,但这样做会每次都返回一个新的实例。这将违背我们想要实现的目的。另一方面,属性初始化器只运行一次。箭头操作符使
Instance属性成为一个表达式成员,相当于创建以下获取器:get { return new MySimpleSingleton(); }。您可以查阅附录 A了解更多关于表达式体语句的信息。
在我们结束本章之前,单例(反)模式也导致代码异味。
代码异味 - 环境上下文
那次对单例模式的实现让我们转向了环境上下文模式。我们甚至可以称环境上下文为反模式,但让我们只是指出它是一个有后果的代码异味。我不建议使用环境上下文,有多个原因。首先,我尽力避免任何全局的东西;环境上下文是一种全局状态。全局变量,如 C#中的静态成员,看起来非常方便,因为它们易于访问和使用。它们总是存在,并且无论何时需要都可以访问:方便。然而,它们在灵活性和可测试性方面带来了许多缺点。在使用环境上下文时,以下情况会发生:
-
紧密耦合:全局状态导致系统灵活性降低;消费者与环境上下文紧密耦合。
-
测试难度:全局对象更难替换,我们无法轻易地用其他对象(如模拟对象)替换它们。
-
不可预见的影响:如果系统的一部分破坏了你的全局状态,这可能会对系统的其他部分产生意外的后果,并且你可能难以找出这些错误的根本原因。
-
潜在误用:开发者可能会被诱惑将非全局关注点添加到环境上下文中,从而导致组件膨胀。
有趣的事实
多年前,在 JavaScript 框架时代之前,我修复了一个系统中的错误,其中一个函数由于一个微小的错误而覆盖了
undefined的值。这是一个很好的例子,说明了全局变量如何影响你的整个系统,并使其更加脆弱。同样,这也适用于 C#中的环境上下文和单例模式;全局变量可能是危险的并且令人烦恼。请放心,如今,浏览器不会让开发者更新
undefined的值,但那时是可能的。
现在我们已经讨论了全局对象,环境上下文是一个全局实例,通常通过静态属性提供。环境上下文模式可以带来好处,但它是一个令人不快的代码异味。
在.NET Framework 中,有一些例子,如
System.Threading.Thread.CurrentPrincipal和System.Threading.Thread.CurrentThread,它们的范围是线程级别的,而不是像大多数静态成员那样纯粹是全局的。环境上下文不一定是单例,但大多数情况下它们是单例。创建非全局(线程级别)的环境上下文更困难,需要更多的工作。
环境上下文模式是好是坏?我会两者都接受!它之所以有用,主要是因为它的便利性和易用性。大多数情况下,它可以通过不同的设计来减少其缺点。实现环境上下文的方法有很多,但为了简洁明了,我们只关注环境上下文的单例版本。以下代码是一个很好的例子:
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方法虽然新,但与 Ambient Context 模式本身无关;它只是让类做些事情。
如果我们看看接下来的测试方法,我们可以看到我们通过调用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);
}
属性可能包括一个公共设置器或支持更复杂的逻辑。构建正确的类和暴露正确的行为取决于你和你自己的规范。为了结束这个插曲,避免使用环境上下文,并使用可实例化的类。我们将在下一章中看到如何使用依赖注入来替换单例,这为我们提供了一个比单例模式更灵活的替代方案。我们还可以为每个 HTTP 请求创建一个单例实例,这既节省了我们编写代码的麻烦,又消除了其缺点。
结论
单例模式允许在整个程序生命周期中创建一个类的单个实例。它利用一个private static字段和一个private构造函数来实现其目标,通过一个public static方法或属性来暴露实例化。我们可以使用字段初始化器、Create方法本身、静态构造函数或任何其他有效的 C#选项来封装初始化逻辑。现在让我们看看单例模式如何帮助我们(或不)遵循 SOLID 原则:
-
S: 单例违反了这个原则,因为它有两个明显的责任:
-
它承担了它被创建的责任(此处未展示),就像任何其他类一样。
-
它承担了创建和管理自己的责任(生命周期管理)。
-
-
O: 单例模式也违反了这个原则。它强制执行一个单一的静态实例,由自己锁定在位置上,这限制了可扩展性。在不改变其代码的情况下,无法扩展这个类。
-
L: 没有直接涉及继承,这是唯一的优点。
-
I: 没有涉及 C#接口,这违反了这个原则。然而,我们可以看看类接口,这样构建一个小型的目标单例实例就会满足这个原则。
-
D: 单例类对自己有坚如磐石的控制。它还建议直接使用其静态属性(或方法),而不使用抽象,用大锤打破 DIP 原则。
正如你所见,单例模式违反了所有 SOLID 原则,除了 LSP,应该谨慎使用。一个类只有一个实例,并且总是使用这个相同的实例是一个常见的概念。然而,我们在下一章中会探讨更合适的方法来做这件事,这让我得出以下建议:不要使用单例模式,如果你看到它被使用,尝试重构它。
我建议避免创建全局状态的静态成员,这是一个通用的良好实践。它们可以使您的系统更不灵活且更脆弱。在某些情况下,
static成员是值得使用的,但尽量将它们的数量保持在最低。在编码之前,问问自己是否可以用其他东西替换那个static成员或类。
有些人可能会争论,单例设计模式是做事的合法方式。然而,在 ASP.NET Core 中,我恐怕不得不表示不同意:我们有一个强大的机制来做不同的事情,称为依赖注入。当使用其他技术时,也许可以,但不是在现代化的.NET 中。
摘要
在本章中,我们探讨了我们的第一个 GoF 设计模式。这些模式揭示了软件工程的一些基本概念,不一定是指模式本身,而是它们背后的概念:
-
策略模式是我们用来组合我们未来大多数类的行为模式。它允许通过组合小对象和针对接口进行编码,在运行时交换行为,遵循 SOLID 原则。
-
抽象工厂模式带来了抽象对象创建的想法,从而实现了关注点的更好分离。更具体地说,它旨在抽象对象系列的创建并遵循 SOLID 原则。
-
即使我们将它定义为反模式,单例模式也将应用程序级别的对象带到桌面上。它允许创建一个在整个程序生命周期中存在的对象的单个实例。该模式违反了大多数 SOLID 原则。
我们还看到了环境上下文代码异味,它用于创建一个无处不在的实体,可以从任何地方访问。它通常实现为单例,并将全局状态对象带到程序中。下一章将探讨依赖注入如何帮助我们构建复杂且可维护的系统。我们还回顾了策略、工厂和单例模式,看看如何在依赖注入导向的上下文中使用它们,以及它们实际上有多强大。
问题
让我们看看几个练习问题:
-
为什么策略模式是一个行为模式?
-
我们如何定义创建型模式的目标?
-
如果我编写了代码
public MyType MyProp => new MyType();,并且我两次调用该属性(var v1 = MyProp; var v2 = MyProp;),v1和v2是同一个实例还是两个不同的实例? -
抽象工厂模式是否允许我们在不修改现有消费代码的情况下添加新元素系列?
-
为什么单例模式是一个反模式?
答案
-
它有助于在运行时管理行为,例如在程序运行过程中更改算法。
-
创建型模式负责创建对象。
-
v1和v2是两个不同的实例。箭头操作符右侧的代码每次调用属性的 getter 时都会执行。 -
是的,这是该模式的主要目标,正如我们在
MidRangeVehicleFactory代码示例中所展示的。 -
单例模式违反了 SOLID 原则,并鼓励使用全局(静态)状态对象。我们大多数情况下都可以避免使用这种模式。
第八章:8 依赖注入
在开始之前:加入我们的 Discord 书籍社区
直接向作者本人提供反馈,并在我们的 Discord 服务器上与其他早期读者聊天(在“architecting-aspnet-core-apps-3e”频道下找到,属于早期访问订阅)。

本章探讨了 ASP.NET Core 依赖注入(DI)系统,如何高效地利用它,以及其局限性和能力。我们学习使用 DI 来组合对象,并深入研究控制反转(IoC)原则。在我们遍历内置 DI 容器时,我们探索其特性和潜在用途。除了实际示例之外,我们还为依赖注入奠定概念基础,以了解其目的、其好处以及它解决的问题,并为本书的其余部分奠定基础,因为我们严重依赖 DI。然后,我们回到我们遇到的前三个四人帮(GoF)设计模式,但这次是通过依赖注入的视角。通过使用 DI 重构这些模式,我们获得了对这个强大设计工具如何影响我们软件的结构和灵活性的更全面的理解。依赖注入是您通往掌握现代应用程序设计的基石,它在开发高效、可适应、可测试和可维护的软件中发挥着变革性的作用。在本章中,我们涵盖了以下主题:
-
什么是依赖注入?
-
重新审视策略模式
-
理解守卫子句
-
重新审视单例模式
-
理解服务定位器模式
-
重新审视工厂模式
什么是依赖注入?
依赖注入(DI)是应用控制反转(IoC)原则的一种方式。IoC 是依赖倒置原则(SOLID 中的 D)的更广泛版本。依赖注入背后的想法是将依赖的创建从对象本身转移到组合根。这样,我们可以将依赖的管理委托给一个IoC 容器,它来完成繁重的工作。
IoC 容器和 DI 容器是同一件事——只是人们用的不同词汇。我在现实生活中两者都交替使用,但在书中我坚持使用 IoC 容器,因为它似乎比 DI 容器更准确。
IoC 是一个概念(原则),而 DI 是一种反转控制流(应用 IoC)的方式。例如,通过使用容器在运行时注入依赖(执行 DI)来应用 IoC 原则(反转流)。您可以根据需要使用任何或两者。
接下来,我们定义组合根。
组合根
DI 背后的一个关键概念是组合根。组合根是我们告诉容器我们的依赖关系及其预期生命周期的位置:我们构建依赖关系树的地方。组合根应尽可能接近程序的起点,因此从 ASP.NET Core 6 开始,组合根位于 Program.cs 文件中。在之前的版本中,它位于 Program 或 Startup 类中。接下来,我们将探讨如何利用 DI 创建高度适应性的系统。
追求适应性
要通过 DI 实现高度的灵活性,我们可以应用以下公式,由 SOLID 原则驱动:对象 A 不应了解它所使用的对象 B。相反,A 应该使用由 B 实现的接口 I,B 应在运行时解决并注入。让我们分解一下:
-
对象
A应依赖接口I而不是具体的类型B。 -
实例
B注入到A中,应在运行时由 IoC 容器解决。 -
A不应意识到B的存在。 -
A不应控制B的生命周期。
我们也可以直接注入对象,而不通过接口传递。这完全取决于我们注入的内容、上下文以及我们的需求。本书中我们探讨了多个用例,以帮助您理解 DI。
接下来,我们将这个方程式转化为一个类比,以帮助解释使用容器的理由。
理解 IoC 容器的使用
为了更好地理解 IoC 容器的使用并围绕之前的适应性概念创建一个图像,让我们从一个乐高®类比开始,其中 IoC 是绘制建造乐高®城堡计划的等价物:
-
我们绘制计划
-
我们收集模块
-
我们按下假设的机器人建造者的启动按钮
-
机器人按照我们的计划组装模块
-
城堡已建成
通过遵循这个逻辑,我们可以创建一个新的 4x4 模块,其侧面画有独角兽(具体类型),更新计划(组合根),然后按下重启按钮,将新模块插入其中,替换旧的模块,而不影响城堡的结构完整性(程序)。只要我们尊重 4x4 模块合同(接口),一切都可以更新,而不会影响城堡的其他部分,从而实现极大的灵活性。遵循这个想法,如果我们需要逐个管理每个乐高®模块,这会迅速变得极其复杂!因此,在项目中手动管理所有依赖关系将非常繁琐且容易出错,即使在最小的程序中也是如此。这种情况就是 IoC 容器(假设的机器人建造者)发挥作用的时候。
IoC 容器的角色
IoC 容器为我们管理对象。我们对其进行配置,然后,当我们请求一个服务时,容器解析并注入它。除此之外,容器还管理依赖的生命周期,让我们的类只做一件事,即它们被设计来做的任务。不再需要考虑它们的依赖!总之,IoC 容器是一个 DI 框架,为我们自动连接。我们可以这样理解依赖注入:
-
依赖的消费者声明其对一个或多个依赖(契约)的需求。
-
IoC 容器在创建消费者时注入该依赖(实现),在运行时满足其需求。
接下来,我们将探讨一个依赖注入可以帮助我们避免的代码异味。
代码异味 – 控制狂
控制狂是一种代码异味,甚至是一种反模式,它禁止我们使用new关键字。是的,使用new关键字就是代码异味!以下代码是错误的,无法利用 DI:
namespace CompositionRoot.ControlFreak;
public class Consumer
{
public void Do()
{
var dependency = new Dependency();
dependency.Operation();
}
}
public class Dependency
{
public void Operation()
=> throw new NotImplementedException();
}
高亮行显示了反模式的作用。为了使 Consumer 类能够使用依赖注入,我们可以像以下这样更新它:
public class Consumer
{
private readonly Dependency _dependency;
public DIEnabledConsumer(Dependency dependency)
{
_dependency = dependency;
}
public void Do()
{
_dependency.Operation();
}
}
上述代码去掉了new关键字,现在可以修改。高亮行表示我们在本章随后探讨的构造函数注入模式。不过,现在还不要禁止使用new关键字。相反,每次你使用它时,都要问问自己,你使用new关键字实例化的对象是否是容器可以管理的依赖,并可以注入。为了帮助做到这一点,我从 Mark Seemann 的《.NET 依赖注入》一书中借用了两个术语;控制狂这个名字也来自那本书。他描述了以下两种依赖类别:
-
稳定依赖
-
易变依赖
接下来,我将谈谈如何定义它们。
稳定依赖
稳定依赖在发布新版本时不应破坏我们的应用程序。它们应使用确定性算法(输入X应始终产生输出Y),并且你不应该期望将来用其他东西来改变它们。
大多数没有行为的数据库结构,如数据传输对象(DTOs),都属于这一类。你也可以将.NET BCL 视为稳定的依赖。
当它们属于这一类别时,我们仍然可以使用new关键字来实例化对象,因为依赖是稳定的,并且不太可能因为变化而破坏任何东西。接下来,我们来看看它们的对立面。
易变依赖
易变依赖可能在运行时发生变化,例如具有上下文行为的可扩展元素。它们也可能因为各种原因(如新功能开发)而可能发生变化。
我们创建的大多数类,如数据访问和业务逻辑代码,都是易变依赖。
打破类之间紧密耦合的主要方式是依赖接口和依赖注入,并且不再使用new关键字来实例化那些易变的依赖项。易变的依赖项是依赖注入成为构建灵活、可测试和可维护的软件的关键。
结论
为了总结这个插曲:别再当控制狂了;那些日子已经过去了!
如果有疑问,请注入依赖项而不是使用
new关键字。
接下来,我们将探讨我们可以分配给我们的易变依赖项的可用的生命周期。
对象生命周期
既然我们明白我们不应再使用new关键字,我们需要一种创建这些类的方法。从现在起,IoC 容器将扮演这个角色,并为我们管理对象实例化和它们的生命周期。
对象的生命周期是什么?
当我们手动创建实例时,使用new关键字,我们会对该对象产生依赖;我们知道何时创建它以及何时结束其生命周期。这就是对象的生命周期。当然,使用new关键字不会给我们留下从外部控制这些对象、增强它们、拦截它们或用另一个实现替换它们的机会——如前文所述的代码异味 - 控制狂部分。
.NET 对象生命周期
使用依赖注入,我们需要忘记控制对象,开始考虑使用依赖项,或者更明确地说,依赖它们的接口。在 ASP.NET Core 中,有三种可能的生命周期可供选择:
| 生命周期 | 描述 |
|---|---|
| 瞬态 | 容器每次都会创建一个新的实例。 |
| 作用域 | 容器为每个 HTTP 请求创建一个实例并重用它。在某些罕见情况下,我们还可以创建自定义作用域。 |
| 单例 | 容器为该依赖项创建单个实例,并始终重用该唯一对象。 |
表 8.1:对象生命周期描述
我们现在可以使用这三种作用域之一来管理我们的易变依赖项。以下是一些帮助你选择的问题:
-
是否需要我为我的依赖项创建单个实例?是的?使用单例生命周期。
-
是否需要我在整个 HTTP 请求中共享我的依赖项的单个实例?是的?使用作用域生命周期。
-
每次是否需要我为我的依赖项创建一个新的实例?是的?使用瞬态生命周期。
对象生命周期的通用方法是设计组件为单例。当不可能时,我们选择作用域。当作用域也不可能时,选择瞬态。这样,我们最大化实例重用,降低创建对象的开销,降低保持这些对象在内存中的内存成本,并降低移除未使用实例所需的垃圾回收量。
例如,我们可以不加思考地选择单例用于无状态对象,这些对象最容易维护且不太可能出错。
对于多个消费者使用相同实例的状态对象,如果生命周期是 singleton 或 scoped,我们必须确保对象是线程安全的,因为多个消费者可能会同时尝试访问它。
在选择生命周期时,需要考虑的一个基本方面是状态对象的消费者。例如,如果我们加载与当前用户相关的数据,我们必须确保数据不会泄露到其他用户。为此,我们可以将那个对象的生命周期定义为 scoped,这限制于单个 HTTP 请求。如果我们不希望在多个消费者之间重用该状态,我们可以选择 transient 生命周期以确保每个消费者都得到自己的实例。
这如何转化为代码?.NET 提供了多个扩展方法来帮助我们配置对象的生命周期,如 AddTransient、AddScoped 和 AddSingleton,它们明确声明了它们的生命周期。
我们在整本书中使用了内置的容器,并对其许多注册方法进行了使用,因此你应该很快就能熟悉它。它具有良好的可发现性,因此你可以在编写代码或阅读文档时使用 IntelliSense 来探索其可能性。
接下来,我们使用这些方法并探索如何使用容器注册依赖项。
注册我们的依赖项
在 ASP.NET Core 中,我们在 Program.cs 文件中注册我们的依赖项,该文件代表组合根。由于最小托管模型,WebApplicationBuilder 提供了 Services 属性,我们可以用它将依赖项添加到容器中。之后,.NET 在构建 WebApplication 实例时创建容器。以下是一个展示这一概念的简化 Program.cs 文件:
var builder = WebApplication.CreateBuilder(args);
// Register dependencies
var app = builder.Build();
// The IoC container is now available
app.Run();
然后,我们使用 builder.Services 属性在 IServiceCollection 实现中注册我们的依赖项。以下是一些注册依赖项的示例:
builder.Services.AddSingleton<Dependency1>();
builder.Services.AddSingleton<Dependency2>();
builder.Services.AddSingleton<Dependency3>();
之前的代码使用单例生命周期注册了依赖项,因此每次请求时我们都得到相同的实例。
记住要在组合根中组合程序。这消除了在代码库中散布的
new关键字的需求,以及随之而来的所有紧密耦合。此外,它将应用程序的组合集中到那个位置,创建组装乐高®积木的计划。
正如你现在可能正在想的那样,这可能导致在单个位置出现大量的注册语句,而且你是正确的;在几乎任何应用程序中维护这样的组合根都是一个挑战。为了解决这个问题,我们引入了一种优雅的方式来封装注册代码,确保它保持可管理。
优雅地注册你的功能
正如我们刚刚发现的,虽然我们应该在组合根中注册依赖项,但我们也可以以结构化的方式安排我们的注册代码。例如,我们可以将应用程序的组合分解成几个方法或类,并从我们的组合根中调用它们。另一种策略是使用自动发现系统来自动注册某些服务。
关键部分是将程序组成集中在一个地方。
在 ASP.NET Core 中,一个常见的模式是拥有像 Add[功能名称] 这样的特殊方法。这些方法注册它们的依赖项,让我们只需一个方法调用就能添加一组依赖项。这种模式方便将程序组成分解成更小、更容易处理的部件,如单个功能。这也使得组合根更加易于阅读。
只要功能保持内聚,其大小就是正确的。如果你的功能变得太大,做了太多事情,或者开始与其他功能共享依赖项,那么在失去控制之前可能需要重新设计。这通常是不希望耦合的好指标。
要实现此模式,我们使用扩展方法,使其变得简单。以下是一个指南:
-
在
Microsoft.Extensions.DependencyInjection命名空间中创建一个名为[subject]Extensions的静态类。 -
创建一个返回
IServiceCollection接口的扩展方法,这允许方法调用链式调用。
根据微软的建议,我们应该在我们扩展的元素所在的命名空间中创建该类。在我们的例子中,
IServiceCollection接口位于Microsoft.Extensions.DependencyInjection命名空间中。
当然,这并非强制性的,我们可以根据我们的需求调整此过程。例如,如果我们想消费者隐式地添加 using 语句,我们可以将类定义在另一个命名空间中。我们还可以在注册过程可以继续到第一个方法之后返回其他内容,例如构建器接口。
构建器接口用于配置更复杂的功能,如 ASP.NET Core MVC。例如,
AddControllers扩展方法返回一个IMvcBuilder接口,该接口包含一个PartManager属性。此外,一些扩展方法针对IMvcBuilder接口,允许通过首先注册它来进一步配置功能;也就是说,在调用AddControllers之前不能配置IMvcBuilder。你还可以设计你的功能,以便在需要时利用该模式。
让我们探索一个演示。
项目 – 注册演示功能
让我们探索注册演示功能的依赖项。该功能包含以下代码:
namespace CompositionRoot.DemoFeature;
public class MyFeature
{
private readonly IMyFeatureDependency _myFeatureDependency;
public MyFeature(IMyFeatureDependency myFeatureDependency)
{
_myFeatureDependency = myFeatureDependency;
}
public void Operation()
{
// use _myFeatureDependency
}
}
public interface IMyFeatureDependency { }
public class MyFeatureDependency : IMyFeatureDependency { }
如我们所见,没有复杂的东西,只有两个空类和一个接口。记住,我们正在探索依赖项的注册,而不是它们要做什么或能做什么——现在。现在,我们希望当依赖项请求IMyFeatureDependency接口作为MyFeature类所做的那样时,容器能够提供一个MyFeatureDependency类的实例。我们希望它是单例生命周期。为了实现这一点,在Program.cs文件中,我们可以编写以下代码:
builder.Services.AddSingleton<MyFeature>();
builder.Services.AddSingleton<IMyFeatureDependency, MyFeatureDependency>();
我们也可以将两个方法调用链式调用:
builder.Services
.AddSingleton<MyFeature>()
.AddSingleton<IMyFeatureDependency, MyFeatureDependency>()
;
然而,这还不是优雅的。我们想要实现的是这个:
builder.Services.AddDemoFeature();
为了构建这个注册方法,我们可以编写以下扩展方法:
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>()
;
}
}
如同所强调的,注册方式相同,但使用的是services参数,这是扩展类型,而不是builder.Services(builder在这个类中不存在,但services参数与builder.Services属性是同一个对象)。如果你不熟悉扩展方法,它们对于扩展现有类非常有用,就像我们刚才做的那样。除了在静态类内部有静态方法之外,第一个参数旁边的this关键字决定了它是否是一个扩展方法。例如,我们可以构建一套扩展方法,这些方法可以构建易于使用的复杂库。想想System.Linq这样的系统。现在,我们学习了依赖注入的基础知识,在重新访问策略设计模式之前,还有最后一件事要介绍。
使用外部 IoC 容器
ASP.NET Core 提供了一个开箱即用的可扩展内置 IoC 容器。它不是最强大的 IoC 容器,因为它缺少一些高级功能,但它对大多数应用程序来说都能完成任务。请放心,如果需要,我们可以将其更改为另一个。如果你习惯于使用另一个 IoC 容器并希望继续使用它,你可能也想这样做。以下是我推荐的策略:
-
按照微软的建议,使用内置容器。
-
当你不能用它实现某事时,看看你的设计,看看你是否可以重新设计你的功能以与内置容器一起工作并简化你的设计。
-
如果无法实现你的目标,看看是否可以通过使用现有库扩展默认容器或自己编写功能来实现。
-
如果仍然不可能,尝试将其替换为另一个 IoC 容器。
假设容器支持它,替换它非常简单。第三方容器必须实现IServiceProviderFactory<TContainerBuilder>接口。然后,在Program.cs文件中,我们必须使用UseServiceProviderFactory<TContainerBuilder>方法注册该工厂,如下所示:
var builder = WebApplication.CreateBuilder(args);
builder.Host.UseServiceProviderFactory<ContainerBuilder>(new ContainerBuilderFactory());
在这个例子中,ContainerBuilder 和 ContainerBuilderFactory 类只是 ASP.NET Core 的包装器,但您选择的第三方容器应该提供这些类型。我建议您访问他们的文档以了解更多信息。一旦该工厂注册成功,我们就可以使用 ConfigureContainer<TContainerBuilder> 方法来配置容器,并像通常一样注册我们的依赖项,如下所示:
builder.Host.ConfigureContainer<ContainerBuilder>((context, builder) =>
{
builder.Services.AddSingleton<Dependency1>();
builder.Services.AddSingleton<Dependency2>();
builder.Services.AddSingleton<Dependency3>();
});
这就是唯一的区别;Program.cs 文件的其他部分保持不变。正如我感觉到您可能不想实现自己的 IoC 容器,已经存在多个第三方集成。以下是从官方文档中摘取的非详尽列表:
-
Autofac
-
DryIoc
-
优雅
-
LightInject
-
Lamar
-
Stashbox
-
简单注入器
除了完全替换容器之外,一些库还扩展了默认容器并添加了功能。我们在第十一章,结构型模式中探讨了这一选项。现在我们已经涵盖了大部分理论,我们重新审视策略模式,将其作为组合应用程序和增加系统灵活性的主要工具。
重新审视策略模式
在本节中,我们利用策略模式来组合复杂对象树,并使用 DI 动态创建这些实例,而不使用 new 关键字,从而摆脱控制狂,转向编写 DI 准备好的代码。策略模式是一种行为设计模式,我们可以在运行时使用它来组合对象树,允许额外的灵活性和对对象行为的控制。使用策略模式组合我们的对象使我们的类更小,更容易测试和维护,并使我们走上 SOLID 路径。从现在开始,我们想要组合对象并将继承量降到最低。我们称之为组合优于继承的原则。目标是向当前类注入依赖(组合)而不是依赖于基类功能(继承)。此外,这种方法使我们能够将行为提取出来并放置在单独的类中,遵循单一职责原则(SRP)和接口隔离原则(ISP)。我们可以通过它们的接口在多个不同的类中重用这些行为,体现依赖倒置原则(DIP)。这种策略促进了代码重用和组合。以下列表涵盖了将依赖注入对象的最流行方式,使我们能够通过组合我们的对象从外部控制它们的行为:
-
构造函数注入
-
属性注入
-
方法注入
我们还可以直接从容器中获取依赖项。这被称为服务定位器(反)模式。我们将在本章后面探讨服务定位器模式。
让我们看看一些理论,然后跳入代码,看看依赖注入(DI)的实际应用。
构造函数注入
构造函数注入是指将依赖项作为参数注入构造函数中。这是迄今为止最受欢迎和首选的技术。构造函数注入对于注入必需依赖项很有用;你可以添加空值检查以确保这一点,也称为保护子句(见添加保护子句部分)。
属性注入
内置的 IoC 容器默认不支持属性注入。其概念是将可选依赖项注入到属性中。大多数情况下,你希望避免这样做,因为属性注入会导致可选依赖项,进而导致可空属性、更多的空值检查,以及通常可以避免的代码复杂性。所以当我们考虑这一点时,ASP.NET Core 没有将其包含在内置容器中是件好事。通常,你可以通过重新设计你的设计来移除属性注入的要求,从而得到更好的设计。如果你无法避免使用属性注入,可以使用第三方容器或找到一种方法自己构建依赖项树(可能利用其中一个工厂模式)。然而,从高层次的角度来看,容器会做类似这样的事情:
-
创建类的新的实例并将所有必需的依赖项注入到构造函数中。
-
通过扫描属性(这可能包括属性、上下文绑定或其他内容)来查找扩展点。
-
对于每个扩展点,注入(设置)一个依赖项,保持未配置的属性不变,因此其定义是一个可选依赖项。
对于之前关于不支持的说法,有几个例外:
-
Razor 组件(Blazor)支持使用
[Inject]属性进行属性注入。 -
Razor 包含
@inject指令,该指令生成一个用于持有依赖项的属性(ASP.NET Core 能够注入它)。
我们不能直接称之为属性注入,因为它们不是可选的,而是必需的,@inject 指令更多的是关于生成代码而不是进行依赖注入。它们更多的是关于内部解决方案而不是“真正的”属性注入。这就是.NET 在属性注入方面所能达到的极限。
我建议以构造函数注入为目标。没有属性注入不应该给你带来任何问题。通常,我们对属性注入的需求源于设计策略或我们正在使用的框架中不太理想的设计选择。
接下来,我们来看方法注入。
方法注入
ASP.NET Core 仅在少数位置支持方法注入,例如在控制器操作(方法)、Startup 类(如果你使用的是预-.NET 6 托管模型)以及中间件的 Invoke 或 InvokeAsync 方法中。我们无法在不进行一些工作的情况下自由地在我们的类中使用方法注入。方法注入也用于将可选依赖项注入到类中。我们还可以使用空值检查或其他任何必需的逻辑在运行时验证这些依赖项。
我建议尽可能使用构造函数注入。我们应该只在没有其他选择或它能为我们的设计带来额外价值时才求助于方法注入。
例如,在一个控制器中,在只有一个操作需要它的情况下注入一个瞬态服务,而不是使用构造函数注入,可以节省大量的无用对象实例化,并通过这样做提高性能(更少的实例化和更少的垃圾回收)。这也可以减少单个类拥有的类级别依赖项的数量。
将依赖手动注入方法作为参数是有效的。以下是一个例子,从类开始:
namespace CompositionRoot.ManualMethodInjection;
public class Subject
{
public int Operation(Context context)
{
// ...
return context.Number;
}
}
public class Context
{
public required int Number { get; init; }
}
上述代码表示的是 Subject 类,它从其 Operation 方法中获取 Context 实例。然后返回其 Number 属性的值。
这个例子遵循了将
HttpContext注入到端点委托的类似模式。在那个例子中,HttpContext代表当前的 HTTP 请求。在我们的情况下,Context只包含我们在消费代码中使用的任意数字。
为了测试我们的代码是否按预期工作,我们可以编写以下测试:
[Fact]
public void Should_return_the_value_of_the_Context_Number_property()
{
// Arrange
var subject = new Subject();
var context = new Context { Number = 44 };
// Act
var result = subject.Operation(context);
// Assert
Assert.Equal(44, result);
}
当我们运行测试时,它工作正常。我们成功地将 context 注入到 subject 中。现在为了模拟一个更复杂的系统,让我们看看一个更动态地执行相同操作的原理:
[Theory]
[MemberData(nameof(GetData))]
public void Showcase_manual_method_injection(
Subject subject, Context context, int expectedNumber)
{
// Manually injecting the context into the
// Operation method of the subject.
var number = subject.Operation(context);
// Validate that we got the specified context.
Assert.Equal(expectedNumber, number);
}
上述代码展示了相同的概念,但 xUnit 将依赖注入到方法中,这更接近实际程序中可能发生的情况。记住,我们想要从我们的生活中移除 new 关键字!
实现的其余部分并不重要。我仅仅拼凑了这个模拟来展示这个场景。一个有趣的细节是,
Subject总是相同的(单例),而Context总是不同的(瞬态),导致每次的结果都不同(Context { Number = 0 },Context { Number = 1 },和Context { Number = 2 })。
在探索了如何注入依赖之后,我们准备好动手进行实际编码。
项目 – 策略
在策略项目中,我们深入研究了各种注入依赖的方法,从控制狂热方法过渡到 SOLID 方法。通过这次探索,我们评估了每种技术的优缺点。该项目以旅行社的位置 API 为形式,最初只返回硬编码的城市。我们在不同的控制器中实现了相同的端点五次,以方便比较和追踪进展。除了一个之外,每个控制器都是成对的。这些对包括一个使用内存服务(开发)的基控制器和一个模拟 SQL 数据库(生产)的更新控制器。以下是每个控制器的分解:
-
ControlFreakLocationsController使用new关键字实例化了InMemoryLocationService类。 -
ControlFreakUpdatedLocationsController使用new关键字实例化SqlLocationService类及其依赖项。 -
InjectImplementationLocationsController利用构造函数注入从容器中获取InMemoryLocationService类的实例。 -
InjectImplementationUpdatedLocationsController利用构造函数注入从容器中获取SqlLocationService类的实例。 -
InjectAbstractionLocationsController利用依赖注入和接口让消费者在运行时改变其行为。
控制器共享相同的构建块;让我们从这里开始。
共享构建块
Location数据结构如下所示:
namespace Strategy.Models;
public record class Location(int Id, string Name, string CountryCode);
控制器返回的LocationSummary DTO 如下所示:
namespace Strategy.Controllers;
public record class LocationSummary(int Id, string Name);
服务接口如下,并且只有一个返回一个或多个Location对象的方法:
using Strategy.Models;
namespace Strategy.Services;
public interface ILocationService
{
Task<IEnumerable<Location>> FetchAllAsync(CancellationToken cancellationToken);
}
此接口的两个实现是一个内存版本,用于开发时使用,以及一个 SQL 版本,用于部署(为了简单起见,我们将其称为生产)。内存服务返回一个预定义的城市列表:
using Strategy.Models;
namespace Strategy.Services;
public class InMemoryLocationService : ILocationService
{
public async Task<IEnumerable<Location>> FetchAllAsync(CancellationToken cancellationToken)
{
await Task.Delay(Random.Shared.Next(1, 100), cancellationToken);
return new Location[] {
new Location(1, "Paris", "FR"),
new Location(2, "New York City", "US"),
new Location(3, "Tokyo", "JP"),
new Location(4, "Rome", "IT"),
new Location(5, "Sydney", "AU"),
new Location(6, "Cape Town", "ZA"),
new Location(7, "Istanbul", "TR"),
new Location(8, "Bangkok", "TH"),
new Location(9, "Rio de Janeiro", "BR"),
new Location(10, "Toronto", "CA"),
};
}
}
SQL 实现使用IDatabase接口来访问数据:
using Strategy.Data;
using Strategy.Models;
namespace Strategy.Services;
public class SqlLocationService : ILocationService
{
private readonly IDatabase _database;
public SqlLocationService(IDatabase database) {
_database = database;
}
public Task<IEnumerable<Location>> FetchAllAsync(CancellationToken cancellationToken) {
return _database.ReadManyAsync<Location>(
"SELECT * FROM Location",
cancellationToken
);
}
}
那个数据库访问接口如下所示:
namespace Strategy.Data;
public interface IDatabase
{
Task<IEnumerable<T>> ReadManyAsync<T>(string sql, CancellationToken cancellationToken);
}
在项目本身中,IDatabase接口只有NotImplementedDatabase实现,当其ReadManyAsync方法被调用时抛出NotImplementedException:
namespace Strategy.Data;
public class NotImplementedDatabase : IDatabase
{
public Task<IEnumerable<T>> ReadManyAsync<T>(string sql, CancellationToken cancellationToken)
=> throw new NotImplementedException();
}
由于目标不是学习数据库访问,我在 xUnit 测试中使用控制器和
SqlLocationService类模拟了这部分。
通过这些共享的部分,我们可以从前两个控制器开始。
控制狂控制器
这第一个版本的代码展示了当需要更新应用程序时,使用new关键字创建依赖项所带来的缺乏灵活性。以下是利用内存集合的初始控制器:
using Microsoft.AspNetCore.Mvc;
using Strategy.Services;
namespace Strategy.Controllers;
[Route("travel/[controller]")]
[ApiController]
public class ControlFreakLocationsController : ControllerBase
{
[HttpGet]
public async Task<IEnumerable<LocationSummary>> GetAsync(CancellationToken cancellationToken)
{
var locationService = new InMemoryLocationService();
var locations = await locationService
.FetchAllAsync(cancellationToken);
return locations
.Select(l => new LocationSummary(l.Id, l.Name));
}
}
执行此代码有效,并返回InMemoryLocationService类的FetchAllAsync方法返回的Location对象的LocationSummary等效对象。然而,要将InMemoryLocationService更改为SqlLocationService,必须像这样更改代码:
public class ControlFreakUpdatedLocationsController : ControllerBase
{
[HttpGet]
public async Task<IEnumerable<LocationSummary>> GetAsync(CancellationToken cancellationToken)
{
var database = new NotImplementedDatabase();
var locationService = new SqlLocationService(database);
var locations = await locationService.FetchAllAsync(cancellationToken);
return locations.Select(l => new LocationSummary(l.Id, l.Name));
}
}
两个代码块中的更改被突出显示。我们也可以创建一个 if 语句有条件地加载一个或另一个,但将此扩展到整个系统会导致大量重复。优点:
- 理解代码及其使用的对象很容易。
缺点:
-
控制器与其依赖项紧密耦合,导致缺乏灵活性。
-
从
InMemoryLocationService到SqlLocationService的转换需要更新代码。
让我们通过下一个控制器对来改进这个设计。
在控制器中注入实现
代码库的第二个版本通过利用依赖注入提高了灵活性。在以下控制器中,我们在构造函数中注入InMemoryLocationService类:
using Microsoft.AspNetCore.Mvc;
using Strategy.Services;
namespace Strategy.Controllers;
[Route("travel/[controller]")]
[ApiController]
public class InjectImplementationLocationsController : ControllerBase
{
private readonly InMemoryLocationService _locationService;
public InjectImplementationLocationsController(
InMemoryLocationService locationService)
{
_locationService = locationService;
}
[HttpGet]
public async Task<IEnumerable<LocationSummary>> GetAsync(CancellationToken cancellationToken)
{
var locations = await _locationService.FetchAllAsync(cancellationToken);
return locations.Select(l => new LocationSummary(l.Id, l.Name));
}
}
假设InMemoryLocationService类已注册到容器中,运行此代码将产生与控制狂版本相同的结果,并返回内存中的城市。
要将类注册到容器中,我们可以执行以下操作:
builder.Services.AddSingleton<InMemoryLocationService>();
不幸的是,要更改服务为SqlLocationService,我们需要再次更改代码。然而,这次我们只需要更改构造函数注入代码,如下所示:
public class InjectImplementationUpdatedLocationsController : ControllerBase
{
private readonly SqlLocationService _locationService;
public InjectImplementationUpdatedLocationsController(SqlLocationService locationService)
{
_locationService = locationService;
}
// ...
}
这又是一个不理想的结果。优点:
-
理解代码及其使用的对象很容易。
-
使用构造函数注入允许在一个地方更改依赖项,并且所有方法都会得到它(假设我们有多于一个方法)。
-
我们可以在不更改代码的情况下注入子类。
缺点:
-
控制器与其依赖项紧密耦合,导致缺乏灵活性。
-
从
InMemoryLocationService到SqlLocationService需要更新代码。
我们正在取得进展,但仍需最后一步来使该控制器灵活。
在控制器中注入抽象
在这个最后的控制器中,我们利用 SOLID 原则、构造函数注入以及固有的策略模式来构建一个可以从外部更改的控制器。要使代码灵活,我们只需注入接口而不是其实例,如下所示:
using Microsoft.AspNetCore.Mvc;
using Strategy.Services;
namespace Strategy.Controllers;
[Route("travel/[controller]")]
[ApiController]
public class InjectAbstractionLocationsController : ControllerBase
{
private readonly ILocationService _locationService;
public InjectAbstractionLocationsController(ILocationService locationService)
{
_locationService = locationService;
}
[HttpGet]
public async Task<IEnumerable<LocationSummary>> GetAsync(CancellationToken cancellationToken)
{
var locations = await _locationService.FetchAllAsync(cancellationToken);
return locations.Select(l => new LocationSummary(l.Id, l.Name));
}
}
突出的行展示了变化。注入ILocationService接口让我们能够控制是否注入InMemoryLocationService类的实例、SqlLocationService类的实例,或者我们想要的任何其他实现。这是我们能够获得的最灵活的可能性。优点:
-
使用构造函数注入允许在一个地方更改依赖项,并且所有方法都会得到它(假设我们有多于一个方法)。
-
注入
ILocationService接口允许我们注入其任何实现,而无需更改代码。 -
由于
ILocationService接口,控制器与其依赖项松散耦合。
缺点:
- 由于依赖项在运行时解析,理解控制器使用的对象更困难。然而,这迫使我们针对接口编程(这是好事)。
让我们看看这种灵活性在实际中的应用。
构建 InjectAbstractionLocationsController
我创建了一些 xUnit 测试来探索可能性,这使得手动创建类变得容易。
我使用了 Moq 来模拟实现。如果您不熟悉 Moq 并想了解更多信息,我在进一步阅读部分留下了一个链接。
两个测试引用以下成员,一个静态的Location对象:
public static Location ExpectedLocation { get; }
= new Location(11, "Montréal", "CA");
测试用例不是为了评估我们代码的正确性,而是为了探索以不同的方式组合控制器有多容易。让我们探索第一个测试用例。
Mock_the_IDatabase
第一个是集成测试,它将SqlLocationService类的实例注入到控制器中并模拟数据库。模拟数据库返回一个包含一个项目的集合。该项目是ExpectedLocation属性引用的Location实例。以下是那段代码:
var databaseMock = new Mock<IDatabase>();
databaseMock.Setup(x => x.ReadManyAsync<Location>(It.IsAny<string>(), It.IsAny<CancellationToken>()))
.ReturnsAsync(() => new Location[] { ExpectedLocation })
;
var sqlLocationService = new SqlLocationService(
databaseMock.Object);
var sqlController = new InjectAbstractionLocationsController(
sqlLocationService);
前面的代码展示了我们如何通过InjectAbstractionLocationsController的设计来控制注入到类中的依赖项。对于其他四个控制器版本,我们无法说同样的话。接下来,我们调用GetAsync方法来验证一切是否按预期工作:
var result = await sqlController.GetAsync(CancellationToken.None);
最后,让我们验证我们是否收到了那个包含一个对象的集合:
Assert.Collection(result,
location =>
{
Assert.Equal(ExpectedLocation.Id, location.Id);
Assert.Equal(ExpectedLocation.Name, location.Name);
}
);
可选的,或者相反,我们可以验证调用数据库模拟的服务,如下所示:
databaseMock.Verify(x => x
.ReadManyAsync<Location>(
It.IsAny<string>(),
It.IsAny<CancellationToken>()
),
Times.Once()
);
Moq 库中有很多有用的功能。
验证代码的正确性对于本例来说并不重要。关键是要理解控制器的组成,以下图表展示了这一点:

图 8.1:模拟 IDatabase 接口的测试中控制器的组成
如我们从图中可以看出,类依赖于接口,我们在构建它们时注入实现。接下来的两个测试比这个简单,只依赖于ILocationService。让我们探索第二个。
Use_the_InMemoryLocationService
接下来,我们使用内存中的位置服务以这种方式组合控制器:
var inMemoryLocationService = new InMemoryLocationService();
var devController = new InjectAbstractionLocationsController(
inMemoryLocationService);
如我们从前面的代码中可以看出,我们向控制器注入了不同的服务,改变了其行为。这次,在调用GetAsync方法后,控制器从InMemoryLocationService返回了十个Location对象。我们对象树的视觉表示如下:

图 8.2:注入 InMemoryLocationService 实例的测试中控制器的组成。
对于前面的测试用例,编写断言更困难,因为我们注入了InMemoryLocationService类的实例,这将结果与其实现绑定。因此,我们在这里不会查看那段代码。尽管如此,我们成功地以不同的方式组合了控制器。让我们看看最后一个测试用例。
Mock_the_ILocationService
最后一个单元测试直接模拟ILocationService。模拟服务返回一个包含一个项目的集合。该项目是ExpectedLocation属性引用的Location实例。以下是那段代码:
var locationServiceMock = new Mock<ILocationService>();
locationServiceMock.Setup(x => x.FetchAllAsync(It.IsAny<CancellationToken>())).ReturnsAsync(() => new Location[] { ExpectedLocation });
var testController = new InjectAbstractionLocationsController(
locationServiceMock.Object);
当执行GetAsync方法时,我们得到与第一个测试用例相同的结果:一个包含单个测试Location对象的集合。我们可以通过比较以下值来断言方法的正确性:
Assert.Collection(result,
location =>
{
Assert.Equal(ExpectedLocation.Id, location.Id);
Assert.Equal(ExpectedLocation.Name, location.Name);
}
);
我们还可以利用 Moq 来验证控制器是否使用了以下代码调用了FetchAllAsync方法:
locationServiceMock.Verify(x => x
.FetchAllAsync(It.IsAny<CancellationToken>()),
Times.Once()
);
该对象树与之前的图非常相似,但我们伪造了服务实现,这使得这是一个真正的单元测试:

图 8.3:模拟 ILocationService 接口的测试中控制器的组合
正如我们在本项目中探讨的那样,通过正确的设计和依赖注入,我们可以轻松地使用相同的构建块组合不同的对象树。然而,如果设计不当,则很难甚至不可能在不更改代码的情况下做到这一点。
如您可能已注意到的,我们在控制器中使用了
new关键字来实例化 DTO。DTOs 是稳定的依赖项。我们还在第十五章,对象映射器、聚合服务和外观中探讨了对象映射器,这是将一个对象复制到另一个对象中的逻辑封装起来的方法。
在我们下一个主题之前,让我们先总结一下。
结论
在本节中,我们看到策略模式从简单的 GoF 行为模式转变为依赖注入的基石。我们探讨了注入依赖的不同方法,重点放在构造函数注入上。构造函数注入是最常用的方法,因为它注入了所需的依赖项,这是我们最想要的。方法注入允许在无法访问该信息的方法中注入算法、共享状态或上下文。我们可以使用属性注入来注入可选依赖项,这种情况很少发生。您可以将可选依赖项视为代码异味,因为如果类有一个可选的角色要扮演,它也有一个主要角色,从而导致双重职责。此外,如果角色是可选的,将其移动到另一个类或重新思考该特定区域的系统设计可能更好。为了练习您刚刚学到的内容,您可以将代码示例连接到真实的数据库、Azure Table、Redis、JSON 文件或其他数据源——提示:编写实现ILocationService接口的代码类。
正如我们所讨论的,我们可以直接将类注入到其他类中。这样做没有问题。然而,我建议在您对本书中涵盖的不同架构原则和模式有信心之前,将接口作为您的初始注入方法。
接下来,我们将探讨守卫子句。
理解守卫子句
保护子句表示代码在执行方法之前必须满足的条件。本质上,它是一种“保护”代码,如果某些条件不满足,则阻止方法的继续执行。在大多数情况下,保护子句是在方法的开头实现的,以便在方法执行所需条件不满足时尽早抛出异常。抛出异常允许调用者捕获错误,而无需实现更复杂的通信机制。我们之前已经提到,我们使用构造函数注入来可靠地注入所需的依赖项。然而,没有任何东西可以完全保证依赖项不是 null。确保依赖项不是 null 是最常见的保护子句之一,实现起来非常简单。例如,我们可以在控制器中检查 null,通过替换以下代码:
_locationService = locationService;
为以下代码:
_locationService = locationService ?? throw new ArgumentNullException(nameof(locationService));
上一段代码使用了 C# 7 的 throw 表达式(更多信息请见 附录 A)。ArgumentNullException 类型使得 locationService 参数为 null 的情形变得明显。因此,如果 locationService 参数为 null,则会抛出 ArgumentNullException;否则,locationService 参数会被分配给 _locationService 成员。当然,随着可空引用类型的引入(更多信息请见 附录 A),接收 null 参数的可能性降低了,但仍有可能发生。
内置容器在类(如控制器)实例化过程中无法满足所有依赖项时,会自动抛出异常。但这并不意味着所有第三方容器的行为都相同。
此外,这并不能保护你免受将
null传递给手动实例化的类的侵害,也不能保证方法不会接收到null值。我建议即使现在不那么强制,也要添加保护措施。工具可以为我们处理大部分工作,从而只产生微小的开销。此外,如果你正在编写其他项目使用的代码,例如库,那么添加保护措施就更加重要了,因为没有保证代码的消费者已经启用了可空引用类型检查。
当我们需要验证一个参数而不需要赋值时,例如大多数构造函数的参数,我们可以使用以下辅助工具,BCL 会为我们处理检查:
ArgumentNullException.ThrowIfNull(locationService);
当我们需要验证一个字符串并确保它不为空时,我们可以使用以下替代方法:ArgumentException.ThrowIfNullOrEmpty(name);当然,我们总是可以回退到 if 语句来验证参数。在这样做的时候,我们必须确保我们抛出相关的异常。如果没有相关的异常,我们可以创建一个。创建自定义异常是编写可管理应用程序的好方法。接下来,我们在探索单例生命周期时回顾(反)模式。
回顾 Singleton 模式
单例模式已经过时,违反了 SOLID 原则,我们已经用生命周期来替代它,正如我们之前所看到的。本节将探讨这个生命周期,并重新创建那个古老的应用状态,它不过是一个单例作用域的字典。我们探讨了两个例子:一个关于应用状态,以防你好奇那个功能去哪里了。然后,Wishlist 项目也使用单例生命周期来提供应用级别的功能。还有一些单元测试,用于测试可测试性和允许安全重构。
项目 – 应用状态
如果你使用.NET Framework 编程 ASP.NET 或者使用“好”旧的经典 ASP 与 VBScript 编程,你可能还记得应用状态。如果你不记得,应用状态是一个键/值字典,允许你在应用程序中全局存储数据,在所有会话和请求之间共享。这是 ASP 一直拥有的,而其他语言,如 PHP,则没有(或者不容易允许)。例如,我记得设计了一个通用的可重用类型购物车系统,使用经典 ASP/VBScript。VBScript 不是一个强类型语言,并且具有有限的面向对象功能。购物车字段和类型在应用级别定义(每个应用一次),然后每个用户都有自己的“实例”,包含他们“私人购物车”中的产品(每个会话创建一次)。在 ASP.NET Core 中,不再有Application字典。为了达到相同的目标,你可以使用静态类或静态成员,但这不是最佳方法;记住,全局对象(static)会使你的应用程序更难测试且更不灵活。我们还可以使用单例模式或创建一个环境上下文,允许我们创建一个对象的应用级别实例。我们甚至可以将其与工厂混合来创建最终用户的购物车,但我们不会这么做;这些也不是最佳解决方案。另一种方法可能是使用 ASP.NET Core 的缓存机制之一,如内存缓存或分布式缓存,但这有些牵强。我们还可以将购物车保存在客户端,使用 cookies、本地存储或其他任何现代机制在用户的计算机上保存数据。然而,这将比使用数据库更远离应用状态。对于大多数需要类似应用状态功能的情况,最佳方法可能是创建一个标准类和一个接口,然后在容器中将绑定注册为单例生命周期。最后,通过构造函数注入将其注入到需要它的组件中。这样做允许模拟依赖关系,在不接触代码但接触组合根的情况下更改实现。
有时候,最好的解决方案不是技术复杂的解决方案或面向设计模式的解决方案;最好的解决方案通常是简单的。更少的代码意味着更少的维护和更少的测试,从而产生更简单的应用程序。
让我们实现一个小程序来模拟应用程序状态。API 是一个具有两个实现的单一接口。程序还通过 HTTP 公开了 API 的一部分,允许用户获取或设置与指定键关联的值。我们使用单例生命周期来确保数据在所有请求之间共享。接口看起来如下:
public interface IApplicationState
{
TItem? Get<TItem>(string key);
bool Has<TItem>(string key);
void Set<TItem>(string key, TItem value) where TItem : notnull;
}
我们可以获取与键关联的值,将值与键关联(设置),并验证键是否存在。《Program.cs》文件包含处理 HTTP 请求的代码。我们可以通过注释或取消注释Program.cs文件的第一行来交换实现,该行是#define USE_MEMORY_CACHE。这改变了依赖注册,如下面的代码所示:
var builder = WebApplication.CreateBuilder(args);
#if USE_MEMORY_CACHE
builder.Services.AddMemoryCache();
builder.Services.AddSingleton<IApplicationState, ApplicationMemoryCache>();
#else
builder.Services.AddSingleton<IApplicationState,
ApplicationDictionary>();
#endif
var app = builder.Build();
app.MapGet("/", (IApplicationState myAppState, string key) =>
{
var value = myAppState.Get<string>(key);
return $"{key} = {value ?? "null"}";
});
app.MapPost("/", (IApplicationState myAppState, SetAppState dto) =>
{
myAppState.Set(dto.Key, dto.Value);
return $"{dto.Key} = {dto.Value}";
});
app.Run();
public record class SetAppState(string Key, string Value);
现在让我们探索第一种实现。
第一种实现
第一种实现使用了内存缓存系统,我认为向您展示这一点是有教育意义的。在内存中缓存数据可能是您需要尽早而不是稍后去做的事情。然而,我们在实现中隐藏了缓存系统,这也是有教育意义的。下面是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的一个薄包装,隐藏了实现细节。这样的包装类似于我们在第十一章中探讨的外观模式和适配器模式。
这个简单的类和我们的组合根中的两行代码使其成为一个应用级别的键值存储;已经完成了!现在让我们探索第二种实现。
第二种实现
第二种实现使用ConcurrentDictionary<string, object>来存储应用程序状态数据并确保线程安全,因为多个用户可能同时使用应用程序状态。ApplicationDictionary类几乎和ApplicationMemoryCache一样简单:
using System.Collections.Concurrent;
namespace ApplicationState;
public class ApplicationDictionary : IApplicationState
{
private readonly ConcurrentDictionary<string, object> _memoryCache = new();
public TItem? Get<TItem>(string key)
{
return _memoryCache.TryGetValue(key, out var item)
? (TItem)item
: default;
}
public bool Has<TItem>(string key)
{
return _memoryCache.TryGetValue(key, out var item) && item is TItem;
}
public void Set<TItem>(string key, TItem value)
where TItem : notnull
{
_memoryCache.AddOrUpdate(key, value, (k, v) => value);
}
}
上述代码利用TryGetValue和AddOrUpdate方法确保线程安全,同时将逻辑保持到最小,并确保我们避免编码错误。
你能发现这个设计中可能引起一些问题的缺陷吗?请参见项目部分末尾的解决方案。
让我们探索如何使用这些实现。
使用实现
我们现在可以使用这两种实现中的任何一种,而不会影响程序的其余部分。这展示了 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 接口,而不是 ApplicationDictionary 或 ApplicationMemoryCache,更不用说 IMemoryCache 或 ConcurrentDictionary<string, object>。使用 DI 允许我们通过反转依赖关系流来隐藏实现。它还打破了具体实现的直接耦合。这种方法还促进了按照依赖倒置原则(DIP)推荐的方式针对接口进行编程,并有助于创建符合开放/封闭原则(OCP)的开放/封闭类。以下是说明我们的应用状态系统的图,使它更直观地显示如何打破耦合:

图 8.2:表示应用状态系统的 DI 导向图
从这个示例中,让我们记住单例生命周期允许我们在请求之间重用对象并在应用程序范围内共享它们。此外,在接口后面隐藏实现细节可以提高我们设计的灵活性。重要的是要注意,单例作用域仅在单个进程中有效,因此你不能完全依赖内存机制来支持跨多个服务器的更大应用程序。我们可以使用 IDistributedCache 接口来规避这一限制,并将我们的应用状态系统持久化到持久化缓存工具,如 Redis。
缺陷:如果我们仔细查看
Has<TItem>方法,它仅在存在指定键的条目并且具有正确类型时才返回true。因此,我们可以在不知道它存在的情况下覆盖不同类型的条目。例如,
ConsumerA为键K设置了类型为A的项。在代码的其他地方,ConsumerB检查是否存在类型为B的项。由于类型不同,方法返回false。ConsumerB用类型为B的对象覆盖了K的值。以下是表示这一点的代码:
// Arrange
var sp = new ServiceCollection()
.AddSingleton<IApplicationState, ApplicationDictionary>()
.BuildServiceProvider()
;
// Step 1: Consumer A sets a string
var consumerA = sp.GetRequiredService<IApplicationState>();
consumerA.Set("K", "A");
Assert.True(consumerA.Has<string>("K")); // true
// Step 2: Consumer B overrides the value with an int
var consumerB = sp.GetRequiredService<IApplicationState>();
if (!consumerB.Has<int>("K")) // Oops, key K exists but it's of type string, not int
{
consumerB.Set("K", 123);
}
Assert.True(consumerB.Has<int>("K")); // true
// Consumer A is broken!
Assert.False(consumerA.Has<string>("K")); // false
改进设计以支持此类场景可能是一个好的实践练习。例如,你可以从
Has方法中移除TItem类型,或者更好,允许在相同键下存储多个项,只要它们的类型不同。
现在我们来探索下一个项目。
项目 – 愿望清单
让我们再举一个示例来展示如何使用单例生命周期和 DI。看到 DI 的实际应用应该有助于理解它,然后利用它来创建 SOLID 软件。背景:该应用程序是一个网站范围的愿望清单,用户可以添加项目。项目每 30 秒过期。当用户添加一个现有项目时,系统必须增加计数并重置项目的过期时间。这样,热门项目就能在列表上停留更长时间,达到顶部。当显示时,系统必须按计数(计数最高优先)对项目进行排序。
30 秒的过期时间非常快,但我确信你不想在运行应用程序时等待几天才让项目过期。这是一个测试配置。
该程序是一个小巧的 Web API,公开了两个端点:
-
向愿望清单添加项目(
POST)。 -
读取愿望清单(
GET)。
愿望清单接口看起来像这样:
public interface IWishList
{
Task<WishListItem> AddOrRefreshAsync(string itemName);
Task<IEnumerable<WishListItem>> AllAsync();
}
public record class WishListItem(string Name, int Count, DateTimeOffset Expiration);
这两个操作都在那里,通过使它们异步(返回 Task<T>),我们可以实现另一个版本,该版本依赖于远程系统,例如数据库,而不是内存存储。然后,WishListItem 记录类是 IWishList 合同的一部分;它是模型。为了保持简单,愿望清单只存储项目的名称。
注意
预测未来通常不是一个好主意,但设计可等待的 API 通常是一个安全的赌注。除此之外,我建议你坚持使用最简单的代码来满足程序的需求(KISS)。当你试图解决尚未存在的问题时,你通常会编写大量的无用代码,导致额外的维护和测试时间。
在组合根中,我们必须在单例作用域(突出显示)中提供 IWishList 实现实例,以便所有请求共享相同的实例。让我们从 Program.cs 文件的第一个部分开始:
var builder = WebApplication.CreateBuilder(args);
builder.Services
.ConfigureOptions<InMemoryWishListOptions>()
.AddTransient<IValidateOptions<InMemoryWishListOptions>, InMemoryWishListOptions>()
.AddSingleton(serviceProvider => serviceProvider.GetRequiredService<IOptions<InMemoryWishListOptions>>().Value)
// The singleton registration
.AddSingleton<IWishList, InMemoryWishList>()
;
如果你想知道
IConfigureOptions、IValidateOptions和IOptions从哪里来,我们在第九章 Options, Settings, and Configuration 中介绍了 ASP.NET Core Options 模式。
现在让我们看看 Program.cs 文件的第二部分,它包含处理 HTTP 请求的最小 API 代码:
var app = builder.Build();
app.MapGet("/", async (IWishList wishList) =>
await wishList.AllAsync());
app.MapPost("/", async (IWishList wishList, CreateItem? newItem) =>
{
if (newItem?.Name == null)
{
return Results.BadRequest();
}
var item = await wishList.AddOrRefreshAsync(newItem.Name);
return Results.Created("/", item);
});
app.Run();
public record class CreateItem(string? Name);
GET 端点将逻辑委托给注入的 IWishList 实现,并返回结果,而 POST 端点在将逻辑委托给愿望清单之前验证 CreateItem DTO。为了帮助我们实现 InMemoryWishList 类,我们首先编写了一些测试来支持我们的规范。由于静态成员在测试中难以配置(记得全局变量?),我借鉴了 ASP.NET Core 内存缓存的概念,创建了一个 ISystemClock 接口,该接口抽象掉了对 DateTimeOffset.UtcNow 或 DateTime.UtcNow 的静态调用。这样,我们可以在测试中编程 UtcNow 的值来创建已过期的项目。以下是时钟接口和实现:
namespace Wishlist.Internal;
public interface ISystemClock
{
DateTimeOffset UtcNow { get; }
}
public class SystemClock : ISystemClock
{
public DateTimeOffset UtcNow => DateTimeOffset.UtcNow;
}
.NET 8 在
System命名空间中添加了一个新的TimeProvider类,但这在这里对我们帮助不大。然而,如果我们想利用该 API,我们可以将 SystemClock 更新为以下内容:
public class CustomClock : ISystemClock
{
private readonly TimeProvider _timeProvider;
public CustomClock(TimeProvider timeProvider)
{
_timeProvider = timeProvider ?? throw new ArgumentNullException(nameof(timeProvider));
}
public DateTimeOffset UtcNow => _timeProvider.GetUtcNow();
}
那段代码利用了新的 API,但我们将坚持我们的简单实现。
让我们接下来看看单元测试的大纲,因为整个代码会占用很多页面,且价值不高:
namespace Wishlist;
public class InMemoryWishListTest
{
// Constructor and private fields omitted
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();
}
// Private helper methods omitted
}
完整的源代码位于 GitHub 上:
adpg.link/ywy8.
在测试类中,我们可以模拟ISystemClock接口并编程它根据每个测试用例获取期望的结果。我们还可以编程一些辅助方法来使测试更容易阅读。这些辅助方法使用元组来返回多个值(更多信息请见附录 A关于语言特性的说明)。以下是模拟字段:
private readonly Mock<ISystemClock> _systemClockMock = new();
这里是一个设置时钟为当前时间并将ExpectedExpiryTime设置为稍后时间的辅助方法示例(UtcNow + ExpirationInSeconds之后):
private (DateTimeOffset UtcNow, DateTimeOffset ExpectedExpiryTime) SetUtcNow()
{
var utcNow = DateTimeOffset.UtcNow;
_systemClockMock.Setup(x => x.UtcNow).Returns(utcNow);
var expectedExpiryTime = utcNow.AddSeconds(_options.ExpirationInSeconds);
return (utcNow, expectedExpiryTime);
}
这里是一个设置时钟和ExpectedExpiryTime为过去时间的另一个辅助方法示例(时钟的ExpirationInSeconds为两倍时间,而ExpectedExpiryTime的ExpirationInSeconds为一次):
private (DateTimeOffset UtcNow, DateTimeOffset ExpectedExpiryTime) SetUtcNowToExpired()
{
var delay = -(_options.ExpirationInSeconds * 2);
var utcNow = DateTimeOffset.UtcNow.AddSeconds(delay);
_systemClockMock.Setup(x => x.UtcNow).Returns(utcNow);
var expectedExpiryTime = utcNow.AddSeconds(_options.ExpirationInSeconds);
return (utcNow, expectedExpiryTime);
}
现在我们有五个测试覆盖了AddOrRefreshAsync方法,还有两个覆盖了AllAsync方法。现在我们有了这些失败的测试,下面是InMemoryWishList类的实现:
namespace Wishlist;
public class InMemoryWishList : IWishList
{
private readonly InMemoryWishListOptions _options;
private readonly ConcurrentDictionary<string, InternalItem> _items = new();
public InMemoryWishList(InMemoryWishListOptions options)
{
_options = options ?? throw new ArgumentNullException(nameof(options));
}
public Task<WishListItem> AddOrRefreshAsync(string itemName)
{
var expirationTime = _options.SystemClock.UtcNow.AddSeconds(_options.ExpirationInSeconds);
_items
.Where(x => x.Value.Expiration < _options.SystemClock.UtcNow)
.Select(x => x.Key)
.ToList()
.ForEach(key => _items.Remove(key, out _))
;
var item = _items.AddOrUpdate(
itemName,
new InternalItem(Count: 1, Expiration: expirationTime),
(string key, InternalItem item) => item with {
Count = item.Count + 1,
Expiration = expirationTime
}
);
var wishlistItem = new WishListItem(
Name: itemName,
Count: item.Count,
Expiration: item.Expiration
);
return Task.FromResult(wishlistItem);
}
public Task<IEnumerable<WishListItem>> AllAsync()
{
var items = _items
.Where(x => x.Value.Expiration >= _options.SystemClock.UtcNow)
.Select(x => new WishListItem(
Name: x.Key,
Count: x.Value.Count,
Expiration: x.Value.Expiration
))
.OrderByDescending(x => x.Count)
.AsEnumerable()
;
return Task.FromResult(items);
}
private record class InternalItem(int Count, DateTimeOffset Expiration);
}
InMemoryWishList类内部使用ConcurrentDictionary<string, InternalItem>来存储项目并使愿望清单线程安全。它还使用with表达式来操作和复制InternalItem记录类。《AllAsync》方法过滤掉已过期的项目,而《AddOrRefreshAsync》方法移除已过期的项目。这或许不是最先进的逻辑,但足以解决问题。
你可能已经注意到代码并不十分优雅,我故意这样留下。在使用测试套件时,我邀请你重构
InMemoryWishList类的方法以使其更易于阅读。我花了几分钟时间自己重构它,并将其保存为
InMemoryWishListRefactored。你还可以取消注释InMemoryWishListTest.cs的第一行来测试那个类而不是主类。我的重构是为了使代码更整洁,给你一些想法。这并不是编写该类的唯一方式,也不是最佳方式(“最佳方式”是主观的)。最后,为了可读性和性能进行优化通常是两件非常不同的事情。
返回到 DI,实现用户间共享愿望清单的行就在我们之前探索过的组合根中。作为一个参考,这里就是:
builder.Services.AddSingleton<IWishList, InMemoryWishList>();
是的,只有那一行代码在创建多个实例和单个共享实例之间做出了所有区别。将生命周期设置为 Singleton 允许你在多个浏览器中共享愿望清单。
要向 API
POST,我建议使用项目中的Wishlist.http文件或书中附带的可用于 Postman 的集合(adpg.link/postman6)。该集合已经包含了你可以批量或单独执行的多个请求。你也可以使用我添加到项目中的 Swagger UI。
就这样!所有这些代码都是为了演示组合根中的一行代码能做什么,我们就得到了一个工作程序,尽管它可能非常小巧。
结论
本节探讨了用具有单例生命周期的标准可实例化类替换经典的单例模式。我们回顾了旧的应用程序状态,了解到它已经不再存在,并实现了两个版本。我们不再需要它,但这是一个了解单例的好方法。然后,我们以愿望清单系统作为第二个示例。我们得出结论,整个系统之所以能正常工作,是因为有一个单一的根组件:调用 AddSingleton 方法。更改这一行可能会极大地改变系统的行为,使其无法使用。从现在起,你可以将单例模式视为 .NET 中的反模式,除非你有充分的理由来实现它,否则你应该坚持使用正常类和依赖注入。这样做将创建责任从单例类转移到组合根,这是组合根的责任,使类只承担一个责任。接下来,我们将探讨服务定位器反模式/代码异味。
理解服务定位器模式
服务定位器是一种反模式,它将 IoC 原则退回到其控制狂根源。唯一的区别是使用 IoC 容器来构建依赖树,而不是使用 new 关键字。在 ASP.NET 中有一些使用这种模式的例子,我们可能会争论使用服务定位器模式有一些理由,但在大多数应用程序中,它应该很少或从不发生。因此,让我们将服务定位器模式称为 代码异味 而不是 反模式。我强烈建议除非你知道你不会创建隐藏的耦合或没有其他选择,否则不要使用服务定位器模式。作为一个经验法则,你想要避免在你的应用程序代码库中注入 IServiceProvider。这样做会回到经典的控制流,并违背了依赖注入的目的。服务定位器的一个良好用途可能是迁移一个太大而无法重写的遗留系统。因此,你可以使用依赖注入构建新代码,并使用服务定位器模式更新遗留代码,使两个系统可以共存或根据你的目标迁移一个到另一个。动态获取依赖项是服务定位器模式的另一个潜在用途;我们将在第十五章,对象映射器、聚合服务和外观中探讨这一点。现在,我们不再拖延,直接进入更多的代码。
项目 – 服务定位器
避免某事最好的方法就是了解它,所以让我们看看如何使用 IServiceProvider 来实现服务定位器模式以查找依赖。我们想要使用的服务是 IMyService 的一个实现。让我们从接口开始:
namespace ServiceLocator;
public interface IMyService : IDisposable
{
void Execute();
}
该接口继承自 IDisposable 接口,并包含一个单一的 Execute 方法。以下是实现,它所做的只是如果实例已被销毁则抛出异常(我们稍后会利用这一点):
namespace ServiceLocator;
public class MyServiceImplementation : IMyService
{
private bool _isDisposed = false;
public void Dispose() => _isDisposed = true;
public void Execute()
{
if (_isDisposed)
{
throw new NullReferenceException("Some dependencies have been disposed.");
}
}
}
然后,让我们添加一个实现服务定位器模式的控制器:
namespace ServiceLocator;
public class MyController : ControllerBase
{
private readonly IServiceProvider _serviceProvider;
public MyController(IServiceProvider serviceProvider)
{
_serviceProvider = serviceProvider ?? throw new ArgumentNullException(nameof(serviceProvider));
}
[Route("/service-locator")]
public IActionResult Get()
{
using var myService = _serviceProvider
.GetRequiredService<IMyService>();
myService.Execute();
return Ok("Success!");
}
}
在前面的代码中,我们不是将 IMyService 注入到构造函数中,而是注入了 IServiceProvider。然后,我们使用它(高亮行)来定位 IMyService 实例。这样做将创建对象的责任从容器转移到了消费者(在这个例子中是 MyController)。MyController 不应该知道 IServiceProvider,而应该让容器在其不干扰的情况下完成工作。可能会出什么问题?如果我们运行应用程序并导航到 /service-locator,一切都会按预期工作。然而,如果我们重新加载页面,Execute() 方法会抛出一个错误,因为我们之前在请求期间调用了 Dispose()。MyController 不应该控制其注入的依赖项,这正是我想强调的点:让容器控制依赖项的生命周期,而不是试图成为一个控制狂。使用服务定位器模式会开启通往这些错误行为的途径,从长远来看可能会造成比好处更多的伤害。此外,尽管 ASP.NET Core 容器本身不支持这一点,但在使用服务定位器模式时,我们失去了根据上下文注入依赖项的能力,因为消费者控制其依赖项。我说的上下文是什么意思?让我们假设我们有两个类,A 和 B,它们实现了接口 I。我们可以将 A 的一个实例注入到 Consumer1 中,但将 B 的一个实例注入到 Consumer2 中。在探索修复方法之前,这是驱动这个程序的 Program.cs 代码:
var builder = WebApplication.CreateBuilder(args);
builder.Services
.AddSingleton<IMyService, MyServiceImplementation>()
.AddControllers()
;
var app = builder.Build();
app.MapControllers();
app.Run();
前面的代码启用了控制器支持并注册了我们的服务。为了修复控制器,我们必须删除使用语句,或者更好的方法是:远离服务定位器模式并注入我们的依赖项。当然,你正在阅读一个依赖注入章节,所以我选择了远离服务定位器模式。下面是我们将要解决的问题:
-
方法注入
-
构造函数注入
-
最小化 API
让我们从方法注入开始。
实现方法注入
以下控制器使用 方法注入 而不是服务定位器模式。这是演示这一点的代码:
public class MethodInjectionController : ControllerBase
{
[Route("/method-injection")]
public IActionResult GetUsingMethodInjection([FromServices] IMyService myService)
{
ArgumentNullException.ThrowIfNull(myService, nameof(myService));
myService.Execute();
return Ok("Success!");
}
}
让我们分析一下代码:
-
FromServicesAttribute类告诉模型绑定器关于方法注入的信息。 -
我们添加了一个保护子句来保护我们免受
null的侵害。 -
最后,我们保留了原始代码,除了
using语句。
当控制器有多个操作但只有一个使用服务时,这种方法注入很方便。
让我们重新探索构造函数注入。
实现构造函数注入
在这个阶段,你应该已经熟悉了构造函数注入。尽管如此,接下来是迁移到构造函数注入后的控制器代码:
namespace ServiceLocator;
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()方法的可能性更小,将此责任留给容器(正如它应该做的那样)。在考虑下一个可能性之前,让我们分析一下代码:
-
我们使用构造函数注入实现了策略模式。
-
我们添加了一个守卫子句以确保在运行时不会出现
null值。 -
我们将操作简化到了最基本的形式。
这两种技术都是服务定位器模式的可接受替代方案。
实现最小 API
当然,我们也可以用最小 API 做同样的事情。以下是该端点的代码:
app.MapGet("/minimal-api", (IMyService myService) =>
{
myService.Execute();
return "Success!";
});
那段代码与没有守卫子句的方法注入示例做的是同样的事情,因为我省略了守卫子句,因为不太可能外部消费者会将其注入为null:端点是直接传递给MapGet方法的委托。重构服务定位器模式通常就像这样简单。
结论
大多数时候,通过遵循服务定位器反模式,我们只是隐藏了我们在控制对象而不是解耦我们的组件。代码示例演示了在销毁对象时可能出现的问题,这也可能发生在构造函数注入的情况下。然而,当我们思考这个问题时,销毁我们创建的对象比销毁我们注入的对象更有诱惑力。此外,服务定位器将控制权从容器移走,转移到消费者,违反了开放封闭原则。你应该能够通过更新组合根的绑定来更新消费者。在示例代码的情况下,我们可以更改绑定,并且它将正常工作。在更复杂的情况下,当需要上下文注入时,将两个实现绑定到同一个接口将变得困难。
IoC 容器负责编织程序的线程,将其各个部分连接在一起,其中每个独立的部件应该尽可能对其他部件一无所知。
在此之上,服务定位器模式使测试变得复杂。当对类进行单元测试时,你必须模拟一个返回模拟服务的容器,而不是只模拟服务。我可以看到其使用有理可据的地方是在组合根,其中定义了绑定,有时,特别是当使用内置容器时,我们无法避免它来弥补缺乏高级功能。另一个好地方是添加功能到容器的库。除此之外,尽量远离!
小心
将服务定位器移动到其他地方并不会让它消失;它只是将其移动,就像任何依赖项一样。然而,将其移动到组合根可以提高该代码的可维护性并消除紧密耦合。
接下来,我们回顾本章的第三种也是最后一种模式。
回顾工厂模式
工厂创建其他对象;它就像一个现实世界的工厂。我们在上一章探讨了如何利用抽象工厂模式创建对象系列。一个工厂可以像具有一个或多个Create[Object]方法的接口一样简单,或者甚至更简单,只是一个简单的委托。在本节中,我们探索一个以 DI 为导向的简单工厂。我们是在策略模式示例的基础上构建的。在那个例子中,我们编写了两个实现ILocationService接口的类。组合根使用#define预处理器指令来告诉编译器要编译哪些绑定。在这个版本中,我们希望在运行时选择实现。
不编译我们不需要的代码有很多好处,包括安全性(降低攻击面)。在这种情况下,我们只是使用一种适用于许多场景的替代策略。
为了实现我们的新目标,我们可以将ILocationService接口的构建逻辑提取到一个工厂中。
项目 – 工厂
在项目中,从策略项目复制过来,我们首先将InjectAbstractionLocationsController类重命名为LocationsController。然后我们可以删除其他控制器。现在,我们想要更改ILocationService绑定以反映以下逻辑:
-
在开发应用程序时,我们使用
InMemoryLocationService类。 -
当部署到任何环境时,我们必须使用
SqlLocationService类。
为了实现这一点,我们使用WebApplicationBuilder对象的Environment属性。该属性的类型为IWebHostEnvironment,包含一些有用的属性,如EnvironmentName,.NET 还添加了扩展方法,如当EnvironmentName等于Development时返回 true 的IsDevelopment方法。以下是Program.cs文件中的代码:
using Factory.Data;
using Factory.Services;
var builder = WebApplication.CreateBuilder(args);
builder.Services.AddControllers();
builder.Services.AddSingleton<ILocationService>(sp =>
{
if (builder.Environment.IsDevelopment())
{
return new InMemoryLocationService();
}
return new SqlLocationService(new NotImplementedDatabase());
});
var app = builder.Build();
app.MapControllers();
app.Run();
上述代码相当直接;它注册了一个委托作为工厂,根据 ASP.NET Core 的Environment构建适当的服务。
我们在这里使用
new关键字,但这错了吗?组合根是我们创建或配置元素的地方,因此在那里实例化对象是正确的,就像使用服务定位器模式一样。最好尽可能避免使用new关键字和服务定位器模式,但使用默认容器比使用功能齐全的第三方容器更困难。尽管如此,我们可以在许多情况下避免这样做,即使我们必须使用new关键字和服务定位器模式,我们通常也不需要第三方容器。
当我们运行程序时,根据我们添加到工厂中的逻辑,正确的实例被注入到控制器中。流程类似于以下内容:
-
应用程序启动。
-
客户端向控制器发送 HTTP 请求(
GET /travel/locations)。 -
ASP.NET Core 创建控制器并利用 IoC 容器注入
ILocationService依赖项。 -
我们的工厂根据当前环境创建正确的实例。
-
动作方法运行,客户端接收响应。
我们也可以创建一个工厂类和一个接口,就像前一章所探讨的那样。然而,在这种情况下,这可能会只是增加噪音。
需要记住的一个重要事情是,在代码库中移动代码并不能使那些代码、逻辑、依赖或耦合消失。编写一个工厂并不能解决你所有的设计问题。此外,增加更多的复杂性会给你的项目带来成本,所以无论是否使用工厂,每次你尝试打破紧密耦合或移除依赖时,确保你不是只是在将责任转移到别处或过度设计你的解决方案。
当然,为了保持我们的组件根目录干净,我们可以创建一个扩展方法来进行注册,就像一个AddLocationService方法。我将把这个任务留给你去尝试,寻找其他改进项目的方法,或者甚至改进你自己的项目。当你思考工厂模式时,可能性几乎是无限的。现在你已经看到一些实际应用,你可能会在其他场景中找到工厂模式的使用,比如在将具有复杂实例化逻辑的类注入其他对象时。
摘要
本章深入探讨了依赖注入,理解其在构建可适应系统中的关键作用。我们学习了 DI 如何应用控制反转原则,将依赖创建从对象转移到组合根。我们探讨了 IoC 容器在对象管理、服务解析和注入以及依赖生命周期管理中的作用。我们解决了控制狂反模式,提倡使用依赖注入而不是使用new关键字。我们回顾了策略模式,并探讨了如何将其与依赖注入结合使用以组合复杂对象树。我们学习了组合优于继承的原则,这鼓励我们将依赖注入到类中,而不是依赖于基类特性和继承。我们探讨了将依赖注入到对象中的不同方法,包括构造函数注入、属性注入和方法注入。我们了解到守卫子句是在方法执行之前必须满足的条件,通常用于防止空依赖。我们探讨了如何实现守卫子句。我们还讨论了添加守卫子句的重要性,因为可空引用类型检查在运行时并不提供任何保证。我们回顾了单例模式,并探讨了如何用生命周期替换它。我们探讨了两个利用单例生命周期提供应用程序级功能的示例。我们深入探讨了服务定位器模式,通常被认为是一个反模式,因为它可以创建隐藏耦合并逆转控制反转原则。我们了解到避免使用服务定位器模式通常是最好的。我们探讨了如何实现服务定位器模式,并讨论了可能出现的潜在问题。我们回顾了工厂模式,并学习了如何构建一个简单、以依赖注入为导向的工厂,以替换 IoC 容器的对象创建逻辑。以下是本章的主要内容:
-
依赖注入是一种应用控制反转原则的技术,用于有效管理依赖关系和生命周期控制。
-
IoC 容器解析和管理依赖关系,提供不同级别的对象行为控制。
-
我们可以将依赖分为稳定和易变两类,后者正是依赖注入的合理性所在。
-
服务的生命周期是瞬时的、作用域的或单例的。
-
依赖注入使我们能够避免控制狂反模式,并停止使用
new关键字创建对象,从而提高灵活性和可测试性。 -
服务定位器模式经常创建隐藏耦合,应该避免,但在组合根中除外。
-
组合根是我们将服务绑定注册到 IoC 容器的地方;在
Program.cs文件中。 -
使用策略模式和构造函数注入组合对象有助于处理复杂对象树,强调了组合优于继承的原则。
-
除了构造函数注入之外,还有方法注入和属性注入,这些支持较少。最好优先考虑构造函数注入。
-
守卫子句保护方法执行不受未满足条件的影响。
-
比起单例模式,更好的做法是将类和接口绑定到容器中的单例生命周期。
-
工厂模式是创建具有复杂实例化逻辑的对象的理想选择。
-
移动代码并不能消除依赖或耦合;不要过度设计解决方案是很重要的。
在后续章节中,我们探讨添加功能到默认内置容器的工具。同时,在下一章中,我们将探讨选项、设置和配置。这些 ASP.NET Core 模式旨在使我们在管理此类常见问题时更加轻松。
问题
让我们看看几个练习题:
-
我们可以在 ASP.NET Core 中分配给对象的三个 DI 生命周期是什么?
-
组合根的作用是什么?
-
我们是否应该避免使用
new关键字来实例化易变依赖项? -
我们在本章中重新审视的,帮助组合对象以消除继承的模式是什么?
-
服务定位器模式是设计模式、代码异味还是反模式?
-
什么是组合优于继承的原则?
进一步阅读
这里有一些链接,可以帮助我们巩固本章所学的内容:
-
Moq:
adpg.link/XZv8 -
如果你需要更多选项,例如上下文注入,你可以查看我构建的一个开源库。它增加了对新场景的支持:
adpg.link/S3aT -
官方文档,默认服务容器替换:
adpg.link/5ZoG
答案
-
临时、作用域、单例。
-
组合根包含描述如何组合程序对象图的代码——类型绑定。
-
是的,这是真的。易变依赖项应该通过注入而不是实例化。
-
策略模式。
-
服务定位器模式是三者兼而有之。它是一个由 DI 库内部使用的模式,但在应用程序代码中却成为了一种代码异味。如果误用,它将是一个与直接使用
new关键字相同的反模式。 -
组合优于继承的原则鼓励我们将依赖项注入到类中,并使用它们而不是依赖于基类功能和继承。这种方法促进了灵活性和代码重用。它还否定了对 LSP 的需求。
第九章:9 选项、设置和配置
在开始之前:加入我们的 Discord 书籍社区
直接向作者本人提供反馈,并在我们的 Discord 服务器上与其他早期读者聊天(在“EARLY ACCESS SUBSCRIPTION”下找到“architecting-aspnet-core-apps-3e”频道)。

本章介绍了 .NET 选项模式,这是任何应用程序的构建块。.NET Core 引入了新的预定义机制来增强 ASP.NET Core 应用程序可用的应用程序设置的用法。这些机制允许我们将配置分成多个更小的对象,在启动流程的各个阶段对其进行配置,验证它们,甚至以最小的努力监视运行时更改。
新的选项系统将
ConfigurationManager类重新定位为内部组件。我们不能再像旧 .NET Framework 时代的静态方法那样使用它。新的模式和机制有助于避免无用的耦合,增加我们的设计灵活性,并且是 DI 原生的。该系统也更容易扩展。
选项模式的目标是在运行时使用设置,允许在不更改代码的情况下对应用程序进行更改。设置可能只是一个 string、一个 bool、一个数据库连接字符串,或者是一个包含整个子系统配置的复杂对象。本章深入探讨了我们可以用于管理、注入和加载配置和选项到我们的 ASP.NET Core 应用程序的各种工具和方法。我们的旅程涵盖了从常见场景到更复杂用例的广泛范围。在本章结束时,您将了解如何利用 .NET 选项和设置基础设施。在本章中,我们涵盖了以下主题:
-
加载配置
-
学习构建块
-
探索常见使用场景
-
学习选项配置
-
验证我们的选项对象
-
使用 FluentValidation 验证选项
-
直接注入选项对象——一种解决方案
-
集中配置以简化管理
-
使用配置绑定源生成器
-
使用选项验证源生成器
-
使用选项验证源生成器
让我们开始吧!
加载配置
ASP.NET Core 允许我们无缝地从多个来源加载设置。我们可以从 WebApplicationBuilder 中自定义这些来源,或者通过调用 WebApplication.CreateBuilder(args) 方法使用默认设置。默认来源按顺序如下:
-
appsettings.json -
appsettings.{Environment}.json -
用户密钥;这些仅在环境为
Development时加载 -
环境变量
-
命令行参数
顺序至关重要,因为最后加载的值会覆盖之前的值。例如,你可以在 appsettings.json 中设置一个值,然后通过在该文件中重新定义该值、用户密钥、环境变量或在你运行应用程序时将其作为命令行参数传递来在 appsettings.Staging.json 中覆盖它。
你可以按自己的意愿命名环境,但默认情况下,ASP.NET Core 为
Development、Staging和Production提供了内置的辅助方法。
在默认提供者之上,我们可以注册其他配置源,例如 AddIniFile、AddInMemoryCollection 和 AddXmlFile。我们还可以加载 NuGet 包来安装自定义提供者,例如 Azure KeyVault 和 Azure App Configuration,以将机密和配置管理集中到 Azure 云中。这些配置提供者的最有趣之处在于,无论来源如何,都不会影响设置的消费,只会影响组合根。这意味着我们可以开始以某种方式加载设置,然后稍后改变主意或为开发和生产使用不同的策略,而这些都不会影响代码库,只会影响组合根。我们将在下一部分探索一些构建块。
学习构建块
使用设置有四个主要接口:IOptionsMonitor<TOptions>、IOptionsFactory<TOptions>、IOptionsSnapshot<TOptions> 和 IOptions<TOptions>。我们必须将这个依赖注入到类中才能使用可用的设置。TOptions 是代表我们想要访问的设置的类型。如果你没有配置它,框架会返回你的选项类的空实例。我们将在下一小节中学习如何正确配置选项;同时,记住在选项类内部使用属性初始化器也可以确保某些默认值被使用。你还可以使用常量将那些默认值集中存储在代码库的某个位置(使它们更容易维护)。然而,适当的配置和验证始终是首选的,但两者结合可以增加一层安全网。不要使用初始化器或常量来设置基于环境(开发、预发布或生产)变化的默认值,或者用于连接字符串和密码等机密信息。
你应该始终将机密信息从 Git 历史记录中移除,无论是从 C# 代码中还是从设置文件中。在本地使用 ASP.NET Core 机密信息,并在预发布和生产环境中使用 Azure KeyVault 等机密存储库。
如果我们创建以下类,由于 int 的默认值为 0,每页显示的默认项目数将是 0,从而导致空列表。
public class MyListOption
{
public int ItemsPerPage { get; set; }
}
然而,我们可以使用属性初始化器来配置它,如下所示:
public class MyListOption
{
public int ItemsPerPage { get; set; } = 20;
}
每页显示的默认项目数现在是 20。
在本章的源代码中,我在
CommonScenarios.Tests项目中包含了一些测试,以断言不同选项接口的生命周期。为了简洁,我没有在这里包含此代码,但它通过单元测试描述了不同选项的行为。更多信息请见adpg.link/AXa5。
每个接口提供的服务具有不同的 DI 生命周期和其他功能。以下表格揭示了其中的一些功能:
| 接口 | 生命周期 | 支持命名选项 | 支持变更通知 |
|---|---|---|---|
IOptionsMonitor<TOptions> |
单例 | 是 | 是 |
IOptionsFactory<TOptions> |
原型 | 是 | 否 |
IOptionsSnapshot<TOptions> |
作用域 | 是 | 否 |
IOptions<TOptions> |
单例 | 否 | 否 |
表 9.1:不同的选项接口、它们的 DI 生命周期和其他功能支持。
接下来,我们将更深入地探讨这些接口。
IOptionsMonitor
此接口是其中最灵活的一个:
-
它支持接收有关重新加载配置的通知(例如设置文件更改时)。
-
它支持缓存。
-
它支持命名配置(通过名称识别多个不同的
TOptions)。 -
注入的
IOptionsMonitor<TOptions>实例始终相同(单例生命周期)。 -
它通过其
Value属性支持未命名的默认设置。
如果我们只配置了命名选项或根本未配置实例,消费者将接收到一个空的
TOptions实例 (new TOptions()).
IOptionsFactory
此接口是一个工厂,正如我们在第七章,策略、抽象工厂和单例,以及第八章,依赖注入中看到的,我们使用工厂来创建实例;此接口并无不同。
除非必要,我建议坚持使用
IOptionsMonitor<TOptions>或IOptionsSnapshot<TOptions>。
工厂的工作原理很简单:每次你请求一个新工厂时(原型生命周期),容器都会创建一个新的工厂;每次你调用其 Create(name) 方法时,工厂都会创建一个新的选项实例(原型生命周期)。要获取默认实例(非命名选项),你可以使用 Options.DefaultName 字段或传递一个空字符串;这通常由框架为你处理。
如果我们只配置了命名选项或根本未配置实例,在调用
factory.Create(Options.DefaultName)后,消费者将接收到一个空的TOptions实例 (new TOptions())。
IOptionsSnapshot
当你需要获取 HTTP 请求持续期间的设置快照时,此接口非常有用。
-
容器为每个请求创建一个实例(作用域生命周期)。
-
它支持命名配置。
-
它通过其
CurrentValue属性支持未命名的默认设置。
如果我们只配置了命名选项或根本未配置实例,在调用
factory.Create(Options.DefaultName)后,消费者将接收到一个空的TOptions实例 (new TOptions()).
IOptions
此接口是第一个添加到 ASP.NET Core 的。
-
它不支持像快照和监控这样的高级场景。
-
每次您请求一个
IOptions<TOptions>实例时,您都会得到相同的实例(单例生命周期)。
IOptions<TOptions>不支持命名选项,因此您只能访问默认实例。
现在我们已经了解了构建块,我们将深入研究一些代码来探索利用这些接口。
项目 – CommonScenarios
这个第一个示例涵盖了多个基本用例,例如注入选项、使用命名选项以及将选项值存储在设置中。让我们从共享构建块开始。
手动配置
在组合根中,我们可以手动配置选项,这对于配置 ASP.NET Core MVC、JSON 序列化器、框架的其他部分或我们自己的定制选项非常有用。以下是我们在代码中使用的第一个选项类,它只包含一个Name属性:
namespace CommonScenarios;
public class MyOptions
{
public string? Name { get; set; }
}
在组合根中,我们可以使用扩展IServiceCollection接口的Configure扩展方法来实现这一点。以下是如何设置MyOptions类的默认选项:
builder.Services.Configure<MyOptions>(myOptions =>
{
myOptions.Name = "Default Option";
});
使用那段代码,如果我们将该选项实例注入到一个类中,Name属性的值将是Default Options。接下来,我们将探讨从非硬编码配置源加载设置。
使用设置文件
从文件中加载配置通常比在 C#中硬编码值更方便。此外,该机制允许使用不同的来源覆盖配置,从而带来更多的优势。要从appsettings.json文件中加载MyOptions,我们首先必须获取配置部分,然后配置选项,如下所示:
var defaultOptionsSection = builder.Configuration
.GetSection("defaultOptions");
builder.Services
.Configure<MyOptions>(defaultOptionsSection);
上一段代码从 appsettings.json 文件中加载以下数据:
{
"defaultOptions": {
"name": "Default Options"
}
}
defaultOptions部分映射到 JSON 文件中具有相同键的对象(高亮代码)。defaultOptions部分的name属性对应于MyOptions类的Name属性。那段代码与前面的硬编码版本做的是同样的事情。然而,以这种方式手动加载部分允许我们为不同的命名选项加载不同的部分。或者,我们也可以使用Bind方法将配置部分“绑定”到现有对象,如下所示:
var options = new MyOptions();
builder.Configuration.GetSection("options1").Bind(options);
那段代码加载了设置并将它们分配给对象的属性,将设置键与属性名称匹配。然而,这并没有将对象添加到 IoC 容器中。为了克服这个问题,如果我们不想手动注册依赖项并且不需要该对象,我们可以使用OptionsBuilder<TOptions>中的Bind或BindConfiguration方法。我们使用AddOptions方法创建该对象,就像Bind一样:
builder.Services.AddOptions<MyOptions>("Options3")
.Bind(builder.Configuration.GetSection("options3"));
之前的代码使用GetSection方法(已突出显示)加载options3配置部分,然后通过Bind方法将此值绑定到名称Options3。这将在容器中注册MyOptions的命名实例。我们稍后会深入探讨命名选项。再次,我们可以通过使用BindConfiguration方法来跳过GetSection方法的使用,如下所示:
builder.Services.AddOptions<MyOptions>("Options4")
.BindConfiguration("options4");
前面的代码从options4部分加载设置,然后注册这个新设置到 IoC 容器中。这只是我们可以利用 ASP.NET Core 选项模式和配置系统的不同方式的一个子集。现在我们知道了如何配置选项,是时候使用它们了。
注入选项
让我们从学习如何利用IOptions<TOptions>接口开始,这是从.NET Core 中出现的第一个也是最简单的接口。为了尝试这个,让我们创建一个端点并将IOptions<MyOptions>接口作为参数注入:
app.MapGet(
"/my-options/",
(IOptions<MyOptions> options) => options.Value
);
在前面的代码中,Value属性返回配置的值,如下所示,序列化为 JSON:
{
"name": "Default Options"
}
哇!我们还可以使用构造函数注入或我们知道的任何其他方法来使用选项对象的值。接下来,我们将探讨配置相同选项类的多个实例。
命名选项
现在,让我们通过配置MyOptions类的两个更多实例来探索命名选项。概念是将选项的配置与一个名称关联起来。一旦完成,我们就可以请求所需的配置。
不幸的是,我们探索命名选项和大多数在线示例都违反了控制反转原则。
为什么?通过注入一个直接与生存期相关的接口,消费者类控制了依赖关系的那一部分。
请放心,我们将在本章末尾重新探讨这个问题。
首先,在appsettings.json文件中,让我们添加突出显示的部分:
{
"defaultOptions": {
"name": "Default Options"
},
"options1": {
"name": "Options 1"
},
"options2": {
"name": "Options 2"
}
}
现在我们有了这些配置,让我们在Program.cs文件中通过添加以下行来配置它们:
builder.Services.Configure<MyOptions>(
"Options1",
builder.Configuration.GetSection("options1")
);
builder.Services.Configure<MyOptions>(
"Options2",
builder.Configuration.GetSection("options2")
);
在前面的代码中,突出显示的字符串代表我们正在配置的选项名称。我们将每个配置部分与一个命名实例关联。现在,为了消费这些命名选项,我们有多种选择。我们可以注入IOptionsFactory<MyOptions>、IOptionsMonitor<MyOptions>或IOptionsSnapshot<MyOptions>接口。最终的选择取决于选项消费者所需的生存期。然而,在我们的情况下,我们使用所有这些以确保我们探索了它们。
IOptionsFactory
让我们从创建一个注入工厂的端点开始:
app.MapGet(
"/factory/{name}",
(string name, IOptionsFactory<MyOptions> factory)
=> factory.Create(name)
);
工厂接口迫使我们传入一个对我们方便的名称。当我们执行程序时,端点根据指定的名称提供选项。例如,当我们发送以下请求时:
GET https://localhost:8001/factory/Options1
端点返回以下 JSON:
{
"name": "Options 1"
}
如果我们传递 Options2,我们将得到以下 JSON:
{
"name": "Options 2"
}
如此简单,我们现在可以在三种不同的选项之间进行选择。当然,再次利用我们知道的任何其他技术,比如构造函数注入。让我们探索下一个接口。
IOptionsMonitor
当我们需要命名选项时,我们使用 IOptionsMonitor 接口的方式与 IOptionsFactory 接口类似。所以,让我们先创建一个类似的端点:
app.MapGet(
"/monitor/{name}",
(string name, IOptionsMonitor<MyOptions> monitor)
=> monitor.Get(name)
);
上述代码几乎与工厂代码相同,但 IOptionsMonitor 接口暴露了一个 Get 方法而不是 Create 方法。这在语义上表达了代码是获取一个选项实例(单例)而不是创建一个新的(瞬时的)。同样,如果我们发送以下请求:
GET https://localhost:8001/monitor/Options2
服务器返回以下 JSON:
{
"name": "Options 2"
}
一个区别是我们可以访问默认选项;以下是方法:
app.MapGet(
"/monitor",
(IOptionsMonitor<MyOptions> monitor)
=> monitor.CurrentValue
);
在前面的代码中,CurrentValue 属性返回默认选项。因此,当调用此端点时,我们应该收到以下 JSON:
{
"name": "Default Options"
}
如此简单,我们既可以访问默认值,也可以访问命名值。在接下来覆盖 IOptionsSnapshot 接口之后,我们再探索 IOptionsMonitor 接口支持的另一个场景。
IOptionsSnapshot
IOptionsSnapshot 接口继承自 IOptions 接口,贡献了其 Value 属性,并且还提供了一个 Get 方法(作用域生命周期),该方法的工作方式类似于 IOptionsMonitor 接口。让我们从第一个端点开始:
app.MapGet(
"/snapshot",
(IOptionsSnapshot<MyOptions> snapshot)
=> snapshot.Value
);
之前提到的端点返回以下默认选项:
{
"name": "Default Options"
}
然后以下参数化端点返回指定的命名选项:
app.MapGet(
"/snapshot/{name}",
(string name, IOptionsSnapshot<MyOptions> snapshot)
=> snapshot.Get(name)
);
假设我们传递的名称是 Options1,那么端点将返回以下选项:
{
"name": "Options 1"
}
我们就完成了。使用选项非常简单,因为 .NET 为我们做了大部分工作。配置选项类也是如此。但是等等,我们的探索还没有结束!接下来,我们将深入了解在运行时重新加载选项的过程。
运行时重新加载选项
ASP.NET Core 选项的一个迷人之处在于,当有人更新配置文件,如 appsettings.json 时,系统会重新加载选项的值。要尝试它,你可以:
-
运行程序。
-
使用
CommonScenarios.http文件中可用的请求查询端点。 -
在
appsettings.json文件中更改该选项的值并保存文件。 -
再次查询相同的端点,你应该会看到更新的值。
这是一个即用型功能。然而,系统会重建选项实例,这不会更新先前实例上的引用。好消息是我们可以挂钩到系统中并响应变化。对于大多数场景,我们不需要手动检查变化,因为CurrentValue属性的值会自动更新。然而,如果你直接引用该值,这个机制可能很有用。在这种情况下,我们有一个发送电子邮件的通知服务。SMTP 客户端的配置是设置。在这种情况下,我们只有SenderEmailAddress,因为发送实际的电子邮件是不必要的。我们正在控制台中记录通知,这样我们可以看到配置更改实时出现。让我们从EmailOptions类开始:
namespace CommonScenarios.Reload;
public class EmailOptions
{
public string? SenderEmailAddress { get; set; }
}
接下来,我们来看NotificationService类本身。让我们从它的第一个迭代开始:
namespace CommonScenarios.Reload;
public class NotificationService
{
private EmailOptions _emailOptions;
private readonly ILogger _logger;
public NotificationService(IOptionsMonitor<EmailOptions> emailOptionsMonitor, ILogger<NotificationService> logger)
{
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
ArgumentNullException.ThrowIfNull(emailOptionsMonitor);
_emailOptions = emailOptionsMonitor.CurrentValue;
}
public Task NotifyAsync(string to)
{
_logger.LogInformation(
"Notification sent by '{SenderEmailAddress}' to '{to}'.",
_emailOptions.SenderEmailAddress,
to
);
return Task.CompletedTask;
}
}
在前面的代码中,类在创建时持有对EmailOptions类的引用(高亮行)。NotifyAsync方法在控制台写入一条信息消息然后返回。
我们将在下一章探讨日志记录。
因为NotificationService类具有单例生命周期并且引用了选项类本身,如果我们更改配置,值将不会更新,因为系统会使用更新的配置重新创建一个新的实例。以下是服务注册方法:
public static WebApplicationBuilder AddNotificationService(
this WebApplicationBuilder builder)
{
builder.Services.Configure<EmailOptions>(builder.Configuration
.GetSection(nameof(EmailOptions)));
builder.Services.AddSingleton<NotificationService>();
return builder;
}
如何解决这个问题?在这种情况下,我们可以通过引用IOptionsMonitor接口而不是它的CurrentValue属性来修复这个问题。然而,如果你面临一个不可能的场景,我们可以利用IOptionsMonitor接口的OnChange方法。在构造函数中,我们可以添加以下代码:
emailOptionsMonitor.OnChange((options) =>_emailOptions = options);
使用这段代码,当appsettings.json文件更改时,代码会更新_emailOptions字段。就像这样,我们重新激活了重新加载功能。
还有一点,
OnChange方法返回一个IDisposable,我们可以通过它来停止监听变化。我在源代码中实现了两个额外的函数:StartListeningForChanges和StopListeningForChanges,以及三个端点,一个用于发送通知,一个用于停止监听变化,另一个用于再次开始监听变化。
现在我们知道了如何使用选项,让我们探索配置它们的额外方法。
项目 – 选项配置
现在我们已经涵盖了基本的使用场景,让我们来探讨一些更高级的可能性,例如创建用于配置、初始化和验证我们选项的类型。我们首先配置选项,这分为两个阶段:
-
配置阶段。
-
配置后阶段。
简而言之,配置后阶段在处理过程中稍后发生。这是一个很好的地方,可以强制某些值以某种方式配置,或者覆盖配置,例如在集成测试中。要配置一个选项类,我们有多种选择,从以下接口开始:
| 接口 | 描述 |
|---|---|
IConfigureOptions<TOptions> |
配置默认的TOptions类型。 |
IConfigureNamedOptions<TOptions> |
配置默认和命名的TOptions类型。 |
IPostConfigureOptions<TOptions> |
在配置后阶段配置默认和命名的TOptions类型。 |
表 9.2:配置选项类接口。
如果配置类实现了
IConfigureOptions和IConfigureNamedOptions接口,则IConfigureNamedOptions接口将具有优先权,并且IConfigureOptions接口的Configure方法将不会执行。您可以使用
IConfigureNamedOptions接口的Configure方法配置默认实例;选项的名称将为空(等于成员Options.DefaultName)。
我们还可以利用以下扩展IServiceCollection接口的方法:
| 方法 | 描述 |
|---|---|
Configure<TOptions> |
内联或从配置部分配置默认和命名的TOptions类型。 |
ConfigureAll<TOptions> |
内联配置TOptions类型的所有选项。 |
PostConfigure<TOptions> |
在配置后阶段内联配置默认和命名的TOptions类型。 |
PostConfigureAll<TOptions> |
在配置后阶段内联配置TOptions类型的所有选项。 |
表 9.3:配置方法。
正如我们将要看到的,注册顺序非常重要。配置器按照注册顺序执行。每个阶段都是独立的;因此,我们安排配置和配置后阶段的顺序不会相互影响。首先,我们必须为我们的小程序打下基础。
创建程序
在创建一个空的 Web 应用程序后,第一个构建块是创建我们想要配置的选项类:
namespace OptionsConfiguration;
public class ConfigureMeOptions
{
public string? Title { get; set; }
public IEnumerable<string> Lines { get; set; } = Enumerable.Empty<string>();
}
我们使用Lines属性作为跟踪桶。我们向其中添加行以直观地确认配置器的执行顺序。接下来,我们在appsettings.json文件中定义应用程序设置:
{
"configureMe": {
"title": "Configure Me!",
"lines": [
"appsettings.json"
]
}
}
我们使用配置作为起点。它定义了Title属性的值,并为Lines属性添加了一行,使我们能够追踪其执行顺序。接下来,我们需要一个端点来访问设置,将结果序列化为 JSON 字符串,然后写入响应流:
app.MapGet(
"/configure-me",
(IOptionsMonitor<ConfigureMeOptions> options) => new {
DefaultInstance = options.CurrentValue,
NamedInstance = options.Get(NamedInstance)
}
);
通过调用此端点,我们可以咨询即将创建的默认和命名实例的值。
ASP.NET Core 在首次请求选项时配置选项。在这种情况下,当首次调用
/configure-me端点时,ConfigureMeOptions类的两个实例都被配置。
如果我们现在运行程序,我们将得到两个空实例,所以在做那之前,我们需要告诉 ASP.NET 关于我们添加到appsettings.json文件的configureMe配置部分。
配置选项
我们想要两个不同的选项来测试许多可能性:
-
一个默认选项(未命名)
-
一个命名实例。
为了实现这一点,我们必须在Program.cs文件中添加以下行:
const string NamedInstance = "MyNamedInstance";
builder.Services
.Configure<ConfigureMeOptions>(builder.Configuration
.GetSection("configureMe"))
.Configure<ConfigureMeOptions>(NamedInstance, builder.Configuration
.GetSection("configureMe"))
;
之前的代码注册了一个默认实例(高亮代码)和一个命名实例。这两个实例都使用configureMe配置部分,因此以相同的初始值开始,正如我们在运行项目时可以看到的那样:
{
"defaultInstance": {
"title": "Configure Me!",
"lines": [
"appsettings.json"
]
},
"namedInstance": {
"title": "Configure Me!",
"lines": [
"appsettings.json"
]
}
}
defaultInstance和namedInstance属性是自解释的,并与其相应的选项实例相关。现在我们已经完成了我们的构建块,我们准备探索IConfigureOptions<TOptions>接口。
实现配置器对象
我们可以将配置逻辑封装到类中,以应用单一责任原则(SRP)。为此,我们必须实现一个接口并创建与 IoC 容器的绑定。首先,我们必须创建一个名为ConfigureAllConfigureMeOptions的类,该类配置所有ConfigureMeOptions实例;默认和命名:
namespace OptionsConfiguration;
public class ConfigureAllConfigureMeOptions : IConfigureNamedOptions<ConfigureMeOptions>
{
public void Configure(string? name, ConfigureMeOptions options)
{
options.Lines = options.Lines.Append(
$"ConfigureAll:Configure name: {name}");
if (name != Options.DefaultName)
{
options.Lines = options.Lines.Append(
$"ConfigureAll:Configure Not Default: {name}");
}
}
public void Configure(ConfigureMeOptions options)
=> Configure(Options.DefaultName, options);
}
在前面的代码中,我们实现了接口(高亮代码),其中包含两个方法。第二个Configure方法永远不会被调用,但以防万一,如果发生,我们可以简单地将调用重定向到另一个方法。第一个Configure方法(高亮)的正文向所有选项添加一行,当选项不是默认选项时,再添加一行。
而不是测试选项是否不是默认选项(
name != Options.DefaultName),你可以检查选项名称或使用switch根据名称配置特定选项。
我们可以向 IoC 容器告知此代码,因此 ASP.NET Core 将按以下方式执行它:
builder.Services.AddSingleton<IConfigureOptions<ConfigureMeOptions>, ConfigureAllConfigureMeOptions>();
现在有了这个绑定,ASP.NET Core 将在我们第一次请求端点时运行我们的代码。以下是结果:
{
"defaultInstance": {
"title": "Configure Me!",
"lines": [
"appsettings.json",
"ConfigureAll:Configure name: "
]
},
"namedInstance": {
"title": "Configure Me!",
"lines": [
"appsettings.json",
"ConfigureAll:Configure name: MyNamedInstance",
"ConfigureAll:Configure Not Default: MyNamedInstance"
]
}
}
从那个 JSON 输出中我们可以看到,配置器已运行并向每个实例添加了预期的行。
重要的是要注意,即使你实现了
IConfigureNamedOptions<TOptions>接口,你也必须将IConfigureOptions<TOptions>绑定到你的配置类。
哇!我们得到了一个整洁的结果,几乎不需要任何努力。这可以带来许多可能性!实现IConfigureOptions<TOptions>可能是配置选项类默认值的最好方法。接下来,我们将后配置添加到混合中!
添加后配置
我们必须采取类似的路径来添加后配置值,但实现IPostConfigureOptions<TOptions>接口。为了实现这一点,我们将更新ConfigureAllConfigureMeOptions类以实现该接口:
namespace OptionsConfiguration;
public class ConfigureAllConfigureMeOptions :
IPostConfigureOptions<ConfigureMeOptions>,
IConfigureNamedOptions<ConfigureMeOptions>
{
// Omitted previous code
public void PostConfigure(string? name, ConfigureMeOptions options)
{
options.Lines = options.Lines.Append(
$"ConfigureAll:PostConfigure name: {name}");
}
}
在前面的代码中,我们实现了接口(高亮行)。PostConfigure方法简单地向Lines属性添加一行。为了将其注册到 IoC 容器中,我们必须添加以下行:
builder.Services.AddSingleton<IPostConfigureOptions<ConfigureMeOptions>, ConfigureAllConfigureMeOptions>();
最大的不同之处在于,这发生在配置后阶段,独立于初始配置阶段。现在执行应用程序会导致以下结果:
{
"defaultInstance": {
"title": "Configure Me!",
"lines": [
"appsettings.json",
"ConfigureAll:Configure name: ",
"ConfigureAll:PostConfigure name: "
]
},
"namedInstance": {
"title": "Configure Me!",
"lines": [
"appsettings.json",
"ConfigureAll:Configure name: MyNamedInstance",
"ConfigureAll:Configure Not Default: MyNamedInstance",
"ConfigureAll:PostConfigure name: MyNamedInstance"
]
}
}
在前面的 JSON 中,高亮行代表我们添加到末尾的配置后代码。你可能会想,当然,它是最后一行;它是我们最后注册的代码,这是一个合理的假设。然而,这里是完整的注册代码,它清楚地显示了IPostConfigureOptions<TOptions>接口首先注册(高亮),证明配置后代码是最后运行的:
builder.Services
.AddSingleton<IPostConfigureOptions<ConfigureMeOptions>, ConfigureAllConfigureMeOptions>()
.Configure<ConfigureMeOptions>(builder.Configuration
.GetSection("configureMe"))
.Configure<ConfigureMeOptions>(NamedInstance, builder.Configuration
.GetSection("configureMe"))
.AddSingleton<IConfigureOptions<ConfigureMeOptions>, ConfigureAllConfigureMeOptions>()
;
接下来,我们创建第二个配置类。
使用多个配置器对象
ASP.NET Core 选项模式的一个非常有趣的概念是,我们可以注册尽可能多的配置类。这创造了多种可能性,包括来自一个或多个程序集的代码配置相同的选项类。现在我们知道了它是如何工作的,让我们添加ConfigureMoreConfigureMeOptions类,它也会向Lines属性添加一行:
namespace OptionsConfiguration;
public class ConfigureMoreConfigureMeOptions : IConfigureOptions<ConfigureMeOptions>
{
public void Configure(ConfigureMeOptions options)
{
options.Lines = options.Lines.Append("ConfigureMore:Configure");
}
}
这次,我们希望这个类只增强默认实例,因此它实现了IConfigureOptions
builder.Services.AddSingleton<IConfigureOptions<ConfigureMeOptions>, ConfigureMoreConfigureMeOptions>();
如我们所见,这是一个相同的绑定,但指向的是ConfigureMoreConfigureMeOptions类而不是ConfigureAllConfigureMeOptions类。执行应用程序并查询端点会输出以下 JSON:
{
"defaultInstance": {
"title": "Configure Me!",
"lines": [
"appsettings.json",
"ConfigureAll:Configure name: ",
"ConfigureMore:Configure",
"ConfigureAll:PostConfigure name: "
]
},
"namedInstance": {
"title": "Configure Me!",
"lines": [
"appsettings.json",
"ConfigureAll:Configure name: MyNamedInstance",
"ConfigureAll:Configure Not Default: MyNamedInstance",
"ConfigureAll:PostConfigure name: MyNamedInstance"
]
}
}
前面的 JSON 显示了我们的新类添加到仅默认实例的行(高亮),在配置后选项之前。可能性很大,对吧?代码可以贡献配置对象并将它们注册在两个阶段之一以配置选项对象。接下来,我们将探索更多可能性。
探索其他配置可能性
我们可以将这些配置类与扩展方法混合使用。例如:
-
我们可以多次调用
Configure和PostConfigure方法。 -
我们可以调用
ConfigureAll和PostConfigureAll方法来配置给定TOptions的所有选项。
在这里,我们使用PostConfigure方法来演示。让我们添加以下两行代码(高亮):
const string NamedInstance = "MyNamedInstance";
var builder = WebApplication.CreateBuilder(args);
builder.Services.PostConfigure<ConfigureMeOptions>(
NamedInstance,
x => x.Lines = x.Lines.Append("Inline PostConfigure Before")
);
builder.Services
.AddSingleton<IPostConfigureOptions<ConfigureMeOptions>, ConfigureAllConfigureMeOptions>()
.Configure<ConfigureMeOptions>(builder.Configuration
.GetSection("configureMe"))
.Configure<ConfigureMeOptions>(NamedInstance, builder.Configuration
.GetSection("configureMe"))
.AddSingleton<IConfigureOptions<ConfigureMeOptions>, ConfigureAllConfigureMeOptions>()
//.AddSingleton<IConfigureNamedOptions<ConfigureMeOptions>, ConfigureAllConfigureMeOptions>()
.AddSingleton<IConfigureOptions<ConfigureMeOptions>, ConfigureMoreConfigureMeOptions>()
;
builder.Services.PostConfigure<ConfigureMeOptions>(
NamedInstance,
x => x.Lines = x.Lines.Append("Inline PostConfigure After")
);
// ...
上述代码注册了两个针对我们命名实例的配置委托。它们都在配置后阶段运行。所以运行应用程序并访问端点显示了所有行添加的顺序:
{
"defaultInstance": {
"title": "Configure Me!",
"lines": [
"appsettings.json",
"ConfigureAll:Configure name: ",
"ConfigureMore:Configure",
"ConfigureAll:PostConfigure name: "
]
},
"namedInstance": {
"title": "Configure Me!",
"lines": [
"appsettings.json",
"ConfigureAll:Configure name: MyNamedInstance",
"ConfigureAll:Configure Not Default: MyNamedInstance",
"Inline PostConfigure Before",
"ConfigureAll:PostConfigure name: MyNamedInstance",
"Inline PostConfigure After"
]
}
}
在前面的 JSON 中,我们可以看到两个高亮行是我们刚刚添加的,按顺序加载,并且没有应用到默认选项上。
还有一种可能性,它来自验证 API。这很可能是无意中产生的副作用,但不管怎样它还是有效果的。
以下代码在配置后阶段添加了
"Inline Validate"行:
builder.Services.AddOptions<ConfigureMeOptions>().Validate(options =>
{
// Validate was not intended for this, but it works nonetheless...
options.Lines = options.Lines.Append("Inline Validate");
return true;
});
在关注点分离方面,我们应该避免这样做。然而,了解这一点可能有助于你有一天解决配置顺序问题。
现在我们知道了选项接口类型、它们的生命周期以及配置它们值的各种方法,现在是时候验证它们并在我们的程序中强制执行一定程度的完整性。
项目 – 选项验证
另一个开箱即用的功能是选项验证,它允许我们在创建 TOptions 对象时运行验证代码。验证代码保证在创建选项时第一次运行,并且不考虑后续的选项修改。根据你的选项对象的生存期,验证可能会运行或不会运行。例如:
| 接口 | 生命周期 | 验证 |
|---|---|---|
IOptionsMonitor<TOptions> |
单例 | 一次性验证选项。 |
IOptionsFactory<TOptions> |
委托 | 每次代码调用 Create 方法时验证选项。 |
IOptionsSnapshot<TOptions> |
范围 | 每个 HTTP 请求(每个范围)一次性验证选项。 |
IOptions<TOptions> |
单例 | 一次性验证选项。 |
表 9.4:验证对选项生命期的影响。
如果你想看到这个动作,我在
ValidateLifetime.cs文件中写了三个测试用例。
我们可以创建验证类型来验证选项类。它们必须实现 IValidateOptions<TOptions> 接口或使用如 [Required] 之类的数据注释。实现接口的工作方式与选项配置非常相似。首先,让我们看看如何在程序启动时强制进行验证。
急切验证
急切验证已添加到 .NET 6 中,并允许在启动时以快速失败的心态捕获配置错误的选项。Microsoft.Extensions.Hosting 程序集向 OptionsBuilder<TOptions> 类型添加了 ValidateOnStart 扩展方法。有几种使用方法,包括以下方法,它将配置部分绑定到选项类:
services.AddOptions<Options>()
.Configure(o => /* Omitted configuration code */)
.ValidateOnStart()
;
突出的那一行是我们需要在启动时应用验证规则的全部内容。我建议将其作为你的新默认设置,这样你就可以知道在启动时而不是在运行时晚些时候配置了选项,从而限制意外问题的发生。现在我们知道了这一点,让我们看看如何配置选项验证。
数据注释
让我们从使用 System.ComponentModel.DataAnnotations 类型用验证属性装饰我们的选项开始,以激活此功能。这也与急切验证一起使用,通过链式调用这两个方法。
如果你对
DataAnnotations不熟悉,它们是用于验证 EF Core 和 MVC 模型类的属性。别担心,它们非常明确,所以你应该能理解代码。
为了演示这一点,让我们看看两个小型测试的框架:
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Options;
using System.ComponentModel.DataAnnotations;
using Xunit;
namespace OptionsValidation;
public class ValidateOptionsWithDataAnnotations
{
[Fact]
public void Should_pass_validation() { /*omitted*/ }
[Fact]
public void Should_fail_validation() { /*omitted*/ }
private class Options
{
[Required]
public string? MyImportantProperty { get; set; }
}
}
前面的代码显示,Options 类的 MyImportantProperty 属性是必需的,不能为 null(突出显示的行)。接下来,我们看看测试用例。第一个测试是期望验证通过:
[Fact]
public void Should_pass_validation()
{
// Arrange
var services = new ServiceCollection();
services.AddOptions<Options>()
.Configure(o => o.MyImportantProperty = "A value")
.ValidateDataAnnotations()
.ValidateOnStart() // eager validation
;
var serviceProvider = services.BuildServiceProvider();
var options = serviceProvider
.GetRequiredService<IOptionsMonitor<Options>>();
// Act & Assert
Assert.Equal(
"Some important value",
options.CurrentValue.MyImportantProperty
);
}
测试模拟了一个程序的执行,其中 IoC 容器创建选项类,并且它的消费者(测试)利用它。高亮行将属性设置为 "A value",使验证通过。代码还启用了贪婪验证(ValidateOnStart),在数据注释验证(ValidateDataAnnotations)之上。第二个测试期望验证失败:
[Fact]
public void Should_fail_validation()
{
// Arrange
var services = new ServiceCollection();
services.AddOptions<Options>()
.ValidateDataAnnotations()
.ValidateOnStart() // eager validation
;
var serviceProvider = services.BuildServiceProvider();
// Act & Assert
var error = Assert.Throws<OptionsValidationException>(
() => options.CurrentValue);
Assert.Collection(error.Failures,
f => Assert.Equal("DataAnnotation validation failed for 'Options' members: 'MyImportantProperty' with the error: 'The MyImportantProperty field is required.'.", f)
);
);
}
在前面的代码中,MyImportantProperty 永远没有被设置(高亮代码),导致验证失败并抛出 OptionsValidationException。测试模拟捕获该异常。
贪婪验证在测试中不起作用,因为它不是一个 ASP.NET Core 程序,而是 xUnit 测试用例(Facts)。
就这样——.NET 为我们完成了这项工作,并使用数据注释验证我们的 Options 类实例,就像你在使用 EF Core 或 MVC 模型时可以做到的那样。接下来,我们将探讨如何创建验证类来手动验证我们的选项对象。
验证类型
要实现选项验证类型或选项验证器,我们可以创建一个类,该类实现一个或多个 IValidateOptions<TOptions> 接口。一个类型可以验证多个选项,多个类型可以验证相同的选项,因此可能的组合应该覆盖所有用例。使用自定义类并不比使用数据注释更难。然而,它允许我们将验证关注点从选项类和代码中移除,并编写更复杂的验证逻辑。你应该选择对你项目最有意义的方法。
除了个人偏好之外,假设你使用了一个带有选项的第三方库。你将这个库加载到你的应用程序中,并期望配置以某种方式。你可以创建一个类来验证该库提供的选项类是否已适当地配置为适用于你的应用程序,甚至可以在启动时进行验证。
你不能使用数据注释来验证,因为你无法控制代码。此外,它不是适用于所有消费者的通用验证,而是针对那个特定应用程序的特定验证。
让我们从测试类的骨架开始:
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Options;
using Xunit;
namespace OptionsValidation;
public class ValidateOptionsWithTypes
{
[Fact]
public void Should_pass_validation() {}
[Fact]
public void Should_fail_validation() {}
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;
}
}
}
在前面的代码中,我们有Options类,它类似于前面的示例,但没有数据注释。区别在于,我们不是使用[Required]属性,而是创建了一个包含验证逻辑的OptionsValidator类(突出显示)。OptionsValidator实现了IValidateOptions<Options>接口,它只包含一个Validate方法。这个方法允许验证命名和默认选项。name参数表示选项的名称。在我们的例子中,我们实现了所有选项的必需逻辑。ValidateOptionsResult类公开了一些成员来帮助我们,例如Success和Skip字段,以及两个Fail()方法。ValidateOptionsResult.Success表示成功。ValidateOptionsResult.Skip表示验证器没有验证选项,这很可能是由于它只验证某些命名选项,但没有验证给定的选项。《ValidateOptionsResult.Fail(message)》和《ValidateOptionsResult.Fail(messages)》方法接受一个消息或消息集合作为参数。为了使这可行,我们必须使验证器对 IoC 容器可用,就像我们对选项配置所做的那样。接下来,我们探索两个测试用例,这两个测试用例与数据注释示例非常相似。以下是第一个通过验证的测试用例:
[Fact]
public void Should_pass_validation()
{
// Arrange
var services = new ServiceCollection();
services.AddSingleton<IValidateOptions<Options>, OptionsValidator>();
services.AddOptions<Options>()
.Configure(o => o.MyImportantProperty = "A value")
.ValidateOnStart()
;
var serviceProvider = services.BuildServiceProvider();
// Act & Assert
var options = serviceProvider
.GetRequiredService<IOptionsMonitor<Options>>();
Assert.Equal(
"A value",
options.CurrentValue.MyImportantProperty
);
}
测试用例模拟了一个正确配置MyImportantProperty的应用程序,它通过了验证。突出显示的行显示了如何注册验证器类。其余的由框架在使用选项类时完成。接下来,我们探索一个失败的验证测试用例:
[Fact]
public void Should_fail_validation()
{
// Arrange
var services = new ServiceCollection();
services.AddSingleton<IValidateOptions<Options>, OptionsValidator>();
services.AddOptions<Options>().ValidateOnStart();
var serviceProvider = services.BuildServiceProvider();
// Act & Assert
var options = serviceProvider
.GetRequiredService<IOptionsMonitor<Options>>();
var error = Assert.Throws<OptionsValidationException>(
() => options.CurrentValue);
Assert.Collection(error.Failures,
f => Assert.Equal("'MyImportantProperty' is required.", f)
);
}
该测试模拟了一个程序,其中Options类未正确配置。当访问选项对象时,框架构建类并验证它,由于验证规则(突出显示的行),抛出OptionsValidationException。使用类型来验证选项在你不希望使用数据注释、不能使用数据注释或需要实现某些逻辑,而这些逻辑在方法中比用属性更容易实现时很有用。接下来,我们简要看看如何利用 FluentValidation 中的选项。
项目 – OptionsValidationFluentValidation
在这个项目中,我们使用 FluentValidation 来验证选项类。FluentValidation 是一个流行的开源库,它提供了一个与数据注释不同的验证框架。我们将在第十五章“使用垂直切片架构入门”中更深入地探讨 FluentValidation,但这不应阻碍你跟随这个示例。在这里,我想向你展示如何利用我们迄今为止学到的几个模式,只用几行代码就实现这一点。在这个微型项目中,我们利用:
-
依赖注入
-
策略设计模式
-
选项模式
-
选项验证:验证类型
-
选项验证:急切验证
让我们从选项类本身开始:
public class MyOptions
{
public string? Name { get; set; }
}
选项类非常简单,只包含一个可空的 Name 属性。接下来,让我们看看 FluentValidation 验证器,它验证 Name 属性不为空:
public class MyOptionsValidator : AbstractValidator<MyOptions>
{
public MyOptionsValidator()
{
RuleFor(x => x.Name).NotEmpty();
}
}
如果你之前从未使用过 FluentValidation,AbstractValidator<T> 类实现了 IValidator<T> 接口,并添加了如 RuleFor 这样的实用方法。MyOptionsValidator 类包含了验证规则。为了使 ASP.NET Core 使用 FluentValidation 验证 MyOptions 实例,我们像上一个示例中那样实现了一个 IValidateOptions<TOptions> 接口,将其中的验证器注入其中,然后利用它来确保 MyOptions 对象的有效性。这个 IValidateOptions 接口的实现,在 FluentValidation 功能和 ASP.NET Core 选项验证之间建立了一个桥梁。以下是一个通用的类实现,它可以用于任何类型的选项:
public class FluentValidateOptions<TOptions> : IValidateOptions<TOptions>
where TOptions : class
{
private readonly IValidator<TOptions> _validator;
public FluentValidateOptions(IValidator<TOptions> validator)
{
_validator = validator;
}
public ValidateOptionsResult Validate(string name, TOptions options)
{
var validationResult = _validator.Validate(options);
if (validationResult.IsValid)
{
return ValidateOptionsResult.Success;
}
var errorMessages = validationResult.Errors.Select(x => x.ErrorMessage);
return ValidateOptionsResult.Fail(errorMessages);
}
}
在前面的代码中,FluentValidateOptions<TOptions> 类通过在 Validate 方法中使用 FluentValidation,将 IValidateOptions<TOptions> 接口适配到 IValidator<TOptions> 接口。简而言之,我们使用一个系统的输出作为另一个系统的输入。
这种类型的适配被称为适配器设计模式。我们将在下一章探讨适配器模式。
现在我们已经拥有了所有构建块,让我们看看组合根:
using FluentValidation;
using Microsoft.Extensions.Options;
var builder = WebApplication.CreateBuilder(args);
builder.Services
.AddSingleton<IValidator<MyOptions>, MyOptionsValidator>()
.AddSingleton<IValidateOptions<MyOptions>, FluentValidateOptions<MyOptions>>()
;
builder.Services
.AddOptions<MyOptions>()
.ValidateOnStart()
;
var app = builder.Build();
app.MapGet("/", () => "Hello World!");
app.Run();
突出的代码是这个系统的关键:
-
它注册了包含验证规则的 FluentValidation
MyOptionsValidator。 -
它注册了通用的
FluentValidateOptions实例,因此 .NET 使用它来验证MyOptions类。 -
在底层,
FluentValidateOptions类使用MyOptionsValidator来内部验证选项。
当运行程序时,控制台输出了预期的以下错误:
Hosting failed to start
Unhandled exception. Microsoft.Extensions.Options.OptionsValidationException: 'Name' must not be empty.
[...]
对于一个简单的必填字段来说,这可能会显得有些麻烦;然而,FluentValidateOptions<TOptions> 是可重用的。我们还可以扫描一个或多个程序集,以自动将验证器注册到 IoC 容器中。现在我们已经探索了许多配置和验证选项对象的方法,是时候看看如何直接注入选项类了,无论是出于选择还是为了解决库功能问题。
解决方案 – 直接注入选项
关于 .NET 选项模式唯一的缺点是我们必须将我们的代码绑定到框架的接口上。我们必须注入一个接口,如 IOptionsMonitor<Options> 而不是 Options 类本身。通过让消费者选择接口,我们让他们控制选项的生命周期,这打破了控制反转、依赖反转和开闭原则。我们应该将这个责任从消费者移到组合根。
正如我们在本章开头所探讨的,
IOptions、IOptionsFactory、IOptionsMonitor和IOptionsSnapshot接口定义了选项对象的生存周期。
在大多数情况下,我更喜欢直接注入Options,从组合根控制其生命周期,而不是让类本身控制其依赖关系。我知道我有点儿反控制狂。此外,直接使用Options类编写测试,而不是模拟像IOptionsSnapshot这样的接口,要简单得多。碰巧我们可以用以下两个部分的小技巧轻松绕过这个问题:
-
正常设置选项类,如本章所探讨的那样。
-
创建一个依赖绑定,指示容器直接使用选项模式注入选项类。
OptionsValidation项目中的ByPassingInterfaces类的 xUnit 测试演示了这一点。以下是该测试类的框架:
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Options;
using Xunit;
namespace OptionsValidation;
public class ByPassingInterfaces
{
[Fact]
public void Should_support_any_scope() { /*...*/ }
private class Options
{
public string? Name { get; set; }
}
}
前面的Options类只有一个Name属性。我们将在下一个测试用例中用它来探索这个解决方案:
[Fact]
public void Should_support_any_scope()
{
// Arrange
var services = new ServiceCollection();
services.AddOptions<Options>()
.Configure(o => o.Name = "John Doe");
services.AddScoped(serviceProvider => {
var snapshot = serviceProvider
.GetRequiredService<IOptionsSnapshot<Options>>();
return snapshot.Value;
});
var serviceProvider = services.BuildServiceProvider();
// Act & Assert
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);
}
在前面的代码块中,我们使用工厂方法注册了Options类。这样,我们可以直接注入Options类(具有作用域生命周期)。此外,现在委托控制了Options类的创建和生命周期(高亮代码)。就这样,这个解决方案允许我们直接将Options注入到我们的系统中,而不需要将我们的类与任何.NET 特定的选项接口绑定。
通过
IOptionsSnapshot<TOptions>接口消费选项会导致具有作用域的生命周期。
测试的行为与断言部分通过创建两个作用域并确保每个作用域返回不同的实例(在作用域内返回相同的实例)来验证设置的正确性。例如,options1和options2都来自scope1,所以它们应该是相同的。另一方面,options3来自scope2,所以它应该与options1和options2不同。这个解决方案也适用于可以从选项模式中受益但无需更新其代码的现有系统——假设系统已经准备好依赖注入。我们还可以使用这个技巧来编译一个不依赖于Microsoft.Extensions.Options的程序集。通过使用这个技巧,我们可以从组合根设置选项的生命周期,这是一种更经典的依赖注入流程。要更改生命周期,可以使用不同的接口,如IOptionsMonitor或IOptionsFactory。接下来,我们将探讨一种组织所有这些代码的方法。
项目 – 集中配置
创建类和类非常面向对象,遵循单一职责原则等。然而,将职责划分为编程关注点并不总是导致最容易理解的代码,因为它会创建很多类和文件,通常分布在多个层和更多。一个替代方案是将初始化和验证与选项类本身重新组合,将多个职责转移到单一的一个:一个端到端选项类。在这个例子中,我们探讨了ProxyOptions类,它携带服务的名称和代理服务应该缓存项的秒数。我们希望为CacheTimeInSeconds属性设置默认值,并验证Name属性不为空。另一方面,我们不希望该类的消费者能够访问任何其他方法,如Configure或Validate。为了实现这一点,我们可以明确实现接口,隐藏它们从ProxyOptions中,但向接口的消费者展示。例如,将ProxyOptions类绑定到IValidateOptions<ProxyOptions>接口,通过IValidateOptions<ProxyOptions>接口给消费者访问Validate方法。在代码中解释这一点应该更简单;以下是该类的代码:
using Microsoft.Extensions.Options;
namespace CentralizingConfiguration;
public class ProxyOptions : IConfigureOptions<ProxyOptions>, IValidateOptions<ProxyOptions>
{
public static readonly int DefaultCacheTimeInSeconds = 60;
public string? Name { get; set; }
public int CacheTimeInSeconds { get; set; }
void IConfigureOptions<ProxyOptions>.Configure(
ProxyOptions options)
{
options.CacheTimeInSeconds = DefaultCacheTimeInSeconds;
}
ValidateOptionsResult IValidateOptions<ProxyOptions>.Validate(
string? name, ProxyOptions options)
{
if (string.IsNullOrWhiteSpace(options.Name))
{
return ValidateOptionsResult.Fail(
"The 'Name' property is required.");
}
return ValidateOptionsResult.Success;
}
}
之前的代码明确实现了IConfigureOptions<ProxyOptions>和IValidateOptions<ProxyOptions>接口(已突出显示),通过省略可见性修饰符并在方法名称前加上接口名称来实现,如下所示:
ValidateOptionsResult IValidateOptions<ProxyOptions>.Validate(...)
现在,为了利用它,我们必须像这样将其注册到 IoC 容器中:
builder.Services
.AddSingleton<IConfigureOptions<ProxyOptions>, ProxyOptions>()
.AddSingleton<IValidateOptions<ProxyOptions>, ProxyOptions>()
.AddSingleton(sp => sp
.GetRequiredService<IOptions<ProxyOptions>>()
.Value
)
.Configure<ProxyOptions>(options
=> options.Name = "High-speed proxy")
.AddOptions<ProxyOptions>()
.ValidateOnStart()
;
在前面的代码中,我们结合了许多我们探讨的概念,如:
-
注册选项类
-
使用绕过方法直接访问
ProxyOptions类 -
通过内联和配置器类配置选项
-
利用验证类
-
通过在启动时急切加载我们的选项来强制执行验证。
如果您取消注释突出显示的行,应用程序将在启动时抛出异常。
应用程序中定义的唯一端点是以下内容:
app.MapGet("/", (ProxyOptions options) => options);
当我们运行应用程序时,我们得到以下输出:
{
"name": "High-speed proxy",
"cacheTimeInSeconds": 60
}
如预期的那样,cacheTimeInSeconds属性的值等于DefaultCacheTimeInSeconds字段的值,而name属性的值是我们配置在Program.cs文件中的值。当在您最喜欢的 IDE 中使用 IntelliSense 功能时(这里我使用的是 Visual Studio 2022),我们只能看到属性,没有方法:

图 9.1:VS IntelliSense 未显示显式实现的接口成员。
就这样;我们完成了这种组织技术。
为了保持组合的简洁性,我们可以将绑定封装在一个扩展方法中,甚至更好的是,让这个扩展方法注册整个代理功能。例如,
services.AddProxyService()。我会留给你自己练习这个,因为我们已经探索了这个。
接下来,我们探索代码生成器!
使用配置绑定源生成器
.NET 8 引入了一个 配置绑定源生成器,它为默认的基于反射的实现提供了一种替代方案。简单来说,选项类属性的名称和设置密钥现在是硬编码的,加速了配置检索。
注意,设置密钥是区分大小写的,并且与 C# 类属性名称一对一映射,与非生成代码不同。
使用原生 AOT 部署(提前编译到原生代码)或裁剪自包含部署以仅发送当前使用的位的应用程序默认利用此选项。
原生 AOT 部署模型将代码编译到单个运行时环境,如 Windows x64。由于代码已经编译为目标环境的原生版本,因此不需要即时 (JIT) 编译器。AOT 部署是自包含的,并且不需要 .NET 运行时才能工作。
我们可以在您的 csproj 文件中使用 EnableConfigurationBindingGenerator 属性手动激活或停用生成器:
<PropertyGroup>
<EnableConfigurationBindingGenerator>true</EnableConfigurationBindingGenerator>
</PropertyGroup>
现在生成器已启用,让我们看看它是如何工作的。生成器会查找一些选项,包括 Configure 和 Bind 方法。然后生成绑定代码。
项目 – 配置生成器:第一部分
在项目的第一部分中,我们创建一个选项类,并将其注册到 IoC 容器中,以便通过 API 端点使用它。我们使用以下选项类:
namespace ConfigurationGenerators;
public class MyOptions
{
public string? Name { get; set; }
}
在 Program.cs 文件中,我们可以像这样使用源生成器:
builder.Services
.AddOptions<MyOptions>()
.BindConfiguration("MyOptions")
;
正如您可能已经注意到的,前面的代码与我们之前使用的相同,并且执行您期望的操作,但新的源生成器在底层生成代码——没有功能或使用上的变化。让我们探索另一个源生成器。
使用选项验证源生成器
.NET 8 引入了 选项验证源生成器,它根据数据注释生成验证代码。这个想法与配置绑定源生成器类似,但用于验证代码。为了利用验证生成器,我们必须在 Microsoft.Extensions.Options.DataAnnotations 包上添加引用。之后,我们必须:
-
创建一个空的验证器类。
-
确保类是
partial。 -
实现
IValidateOptions<TOptions>接口(但不实现方法)。 -
使用
[OptionsValidator]属性装饰验证器类。 -
将验证器类注册到容器中。
这个过程听起来很复杂,但在代码中要简单得多;现在让我们看看。
项目 – 配置生成器:第二部分
在项目的第二部分中,我们继续构建前面的组件,并为我们的 MyOptions 类添加验证。当然,我们还想测试新的源生成器。以下是更新的 MyOptions 类:
using System.ComponentModel.DataAnnotations;
namespace ConfigurationGenerators;
public class MyOptions
{
[Required]
public string? Name { get; set; }
}
突出的行代表更改。我们想要确保Name属性不为空。现在我们已经更新了我们的选项类,让我们创建以下验证器类:
using Microsoft.Extensions.Options;
namespace ConfigurationGenerators;
[OptionsValidator]
public partial class MyOptionsValidator : IValidateOptions<MyOptions>
{
}
上一段代码是一个空壳,为代码生成器准备了这个类。[OptionsValidator]属性代表生成器钩子(即这是生成器正在寻找的标志)。有了这段代码,我们就完成了步骤 1 到 4;比英语简单,对吧?现在,对于最后一步,我们像平常一样注册我们的验证器:
builder.Services.AddSingleton<IValidateOptions<MyOptions>, MyOptionsValidator>();
为了测试这一点,让我们在appsettings.json文件中添加一个绑定到以下配置部分的valid命名选项实例:
{
"MyOptions": {
"Name": "Options name"
}
}
这是我们在Program.cs文件中绑定它的方式:
builder.Services
.AddOptions<MyOptions>("valid")
.BindConfiguration("MyOptions")
.ValidateOnStart()
;
上一段代码注册了valid命名选项,将其绑定到配置部分MyOptions,并在应用程序启动时进行验证。
其他注册命名选项的方法也有效。我仅出于方便目的使用这种方法。
如果我们在运行时检查选项的内容,它应该是我们预期的;与我们在本章中探索的内容没有不同:
{
"name": "Options name"
}
到目前为止,程序应该已经开始。接下来,为了测试这一点,让我们添加另一个命名选项类,但这次是一个无效的类。我们不会在appsettings.json文件中做任何更改,并添加以下注册代码:
builder.Services
.AddOptions<MyOptions>("invalid")
.BindConfiguration("MissingSection")
.ValidateOnStart()
;
上一段代码将一个缺失的部分绑定到invalid命名选项,使Name属性等于null。这个对象将无法通过我们的验证,因为Name属性是必需的。如果我们现在运行应用程序,我们会得到以下消息:
Hosting failed to start
Microsoft.Extensions.Options.OptionsValidationException: Name: The invalid.Name field is required.
从那个错误中,我们知道验证按预期工作。我们并不总是因为我们的应用程序无法启动而感到高兴,但这次就是这样。代码生成到此结束,它的行为相同,但底层的代码是不同的,这使 AOT 和 trimming 等技术能够更好地支持不依赖反射机制的机制。此外,代码生成应该会加快程序执行速度,因为行为是硬编码的,而不是依赖于动态的基于反射的方法。接下来,让我们深入了解.NET 8 中引入的另一个类。
使用 ValidateOptionsResultBuilder 类
ValidateOptionsResultBuilder是.NET 8 中的一个新类型。它允许动态累积验证错误并创建一个表示其当前状态的ValidateOptionsResult对象。其基本用法非常简单,正如我们即将看到的。
项目 - ValidateOptionsResultBuilder
在这个项目中,我们正在验证MyOptions对象。该类型有多个验证规则,我们想要确保在第一个规则验证失败后不会停止,这样消费者就可以一次性知道所有错误。为了实现这一点,我们决定使用ValidateOptionsResultBuilder类。让我们从选项类开始:
namespace ValidateOptionsResultBuilder;
public class MyOptions
{
public string? Prop1 { get; set; }
public string? Prop2 { get; set; }
}
接下来,让我们实现一个验证器类,强制两个属性都不为空:
using Microsoft.Extensions.Options;
namespace ValidateOptionsResultBuilder;
public class SimpleMyOptionsValidator : IValidateOptions<MyOptions>
{
public ValidateOptionsResult Validate(string? name, MyOptions options)
{
var builder = new Microsoft.Extensions.Options.ValidateOptionsResultBuilder();
if (string.IsNullOrEmpty(options.Prop1))
{
builder.AddError(
"The value cannot be empty.",
nameof(options.Prop1)
);
}
if (string.IsNullOrEmpty(options.Prop2))
{
builder.AddError(
"The value cannot be empty.",
nameof(options.Prop2)
);
}
return builder.Build();
}
}
在前面的代码中,我们创建了一个 ValidateOptionsResultBuilder 对象,向其中添加错误,然后通过利用其 Build 方法返回 SimpleMyOptionsValidator 类的实例。ValidateOptionsResultBuilder 类的使用被突出显示。接下来,为了测试这一点,我们必须注册选项。让我们也创建一个端点。以下是 Program.cs 文件:
using ValidateOptionsResultBuilder;
using Microsoft.Extensions.Options;
var builder = WebApplication.CreateBuilder(args);
builder.Services
.AddSingleton<IValidateOptions<MyOptions>, SimpleMyOptionsValidator>()
.AddOptions<MyOptions>("simple")
.BindConfiguration("SimpleMyOptions")
.ValidateOnStart()
;
var app = builder.Build();
app.MapGet("/", (IOptionsFactory<MyOptions> factory) => new
{
simple = factory.Create("simple")
});
app.Run();
在整个关于选项模式的章节之后,前面的代码可以说是再正常不过了。我们注册了选项类、验证器并创建了一个端点。当我们调用端点时,我们得到以下结果:
Hosting failed to start
Microsoft.Extensions.Options.OptionsValidationException: Property Prop1: The value cannot be empty.; Property Prop2: The value cannot be empty.
如预期的那样,应用程序未能启动,因为 MyOptions 类的验证失败。一个不同之处在于,我们有两个合并的错误消息而不是一个。作为一个参考,一个没有使用 ValidateOptionsResultBuilder 类执行相同操作的验证器看起来像这样:
using Microsoft.Extensions.Options;
namespace ValidateOptionsResultBuilder;
public class ClassicMyOptionsValidator : IValidateOptions<MyOptions>
{
public ValidateOptionsResult Validate(string? name, MyOptions options)
{
if (string.IsNullOrEmpty(options.Prop1))
{
return ValidateOptionsResult.Fail(
$"Property {nameof(options.Prop1)}: The value cannot be empty."
);
}
if (string.IsNullOrEmpty(options.Prop2))
{
return ValidateOptionsResult.Fail(
$"Property {nameof(options.Prop2)}: The value cannot be empty."
);
}
return ValidateOptionsResult.Success;
}
}
突出的代码代表了标准流程,在 SimpleMyOptionsValidator 类中使用 ValidateOptionsResultBuilder 类型时会被替换。这标志着我们项目的结束。虽然没有什么特别复杂的,但它是一个很好的补充,有助于累积多个错误消息。除此之外,ValidateOptionsResultBuilder 类型还可以累积 ValidationResult 和 ValidateOptionsResult 对象,这可能导致更复杂的系统,例如从多个验证器收集结果。我会让你自己尝试这个。在我们跳入 ASP.NET Core 日志记录之前,让我们回顾一下本章内容。
摘要
本章探讨了选项模式(Options pattern),这是一种强大的工具,使我们能够配置我们的 ASP.NET Core 应用程序。它使我们能够在不更改代码的情况下更改应用程序。这种能力甚至允许应用程序在配置文件更新时在运行时重新加载选项,而无需停机。我们学习了从多个来源加载设置,并且最后加载的来源将覆盖之前的值。我们发现以下接口可以访问设置,并了解到接口的选择会影响选项对象的生存期:
-
IOptionsMonitor<TOptions> -
IOptionsFactory<TOptions> -
IOptionsSnapshot<TOptions> -
IOptions<TOptions>
我们深入探讨了在组合根中手动配置选项以及从设置文件中加载它们的方法。我们还学习了如何将选项注入到类中,并使用命名选项配置相同选项类型的多个实例。我们探讨了将配置逻辑封装到类中以应用单一职责原则(SRP)。我们通过实现以下接口实现了这一点:
-
IConfigureOptions<TOptions> -
IConfigureNamedOptions<TOptions> -
IPostConfigureOptions<TOptions>
我们还了解到,我们可以使用 Configure 和 PostConfigure 方法将配置类与内联配置混合使用,并且配置器的注册顺序至关重要,因为它们是按照注册顺序执行的。我们还深入研究了选项验证。我们了解到,选项对象验证的频率取决于所使用的选项接口的生命周期。我们还发现了急切验证的概念,它允许我们在启动时捕获配置错误的选项类。我们学会了使用数据注释来用验证属性(如 [Required])装饰我们的选项。我们可以创建验证类来验证我们的选项对象,以处理更复杂的情况。这些验证类必须实现 IValidateOptions<TOptions> 接口。我们还学会了如何将其他验证框架(如 FluentValidation)桥接起来,以补充开箱即用的功能或适应你对不同验证框架的喜好。我们探索了一个解决方案,允许我们直接将选项类注入到它们的消费者中。这样做允许我们从组合根处控制它们的生命周期,而不是让消费它们的类型控制它们的生命周期。这种方法与依赖注入和反转控制原则更一致。这也使得测试这些类更容易。最后,我们研究了 .NET 8 代码生成器,它改变了处理选项的方式,但不会影响我们使用选项模式的方式。我们还探索了在 .NET 8 中引入的 ValidateOptionsResultBuilder 类型。选项模式帮助我们遵守 SOLID 原则,如下所示:
-
S: 选项模式将管理设置划分为多个部分,每个部分都承担单一职责。将未管理的设置加载到强类型类中是一个职责,使用类验证选项是另一个职责,从多个独立来源配置选项又是另一个职责。
另一方面,我发现数据注释验证在选项类中将两个职责混合在一起,违背了这一原则。如果你喜欢数据注释,我不希望你因为使用它们而受阻。
数据注释似乎可以提高开发速度,但会使测试验证规则更困难。例如,测试返回
ValidateOptionsResult对象的Validate方法比属性更容易。 -
O: 不同的
IOptions*<Toptions>接口通过迫使消费者决定选项应该具有什么生命周期和功能来违反这一原则。要更改依赖项的生命周期,我们必须在使用这些接口时更新消费类。另一方面,我们探索了一个简单且灵活的解决方案,允许我们在许多场景中绕过这个问题,直接注入选项,再次反转依赖项流,导致开放/关闭消费者。 -
L: 无内容
-
I:
IValidateOptions<TOptions>和IConfigureOptions<TOptions>接口是分离系统为更小接口的很好例子,每个接口只有一个单一的目的。 -
D:选项框架围绕接口构建,允许我们依赖抽象。
再次强调,
IOptions*<Toptions>接口是这一规则的例外。即使它们是接口,它们也使我们依赖于实现细节,如选项的生命周期。在这种情况下,我认为直接注入选项对象(一个数据契约)比注入这些接口更有益。
接下来,我们将探索 .NET 日志记录,这是构建应用程序的另一个非常重要的方面;良好的可追溯性在观察或调试应用程序时可以起到决定性作用。
问题
让我们看看几个练习问题:
-
命名一个可以用来注入设置类的接口。
-
请命名 ASP.NET Core 在配置选项时使用的两个阶段。
-
我们注册配置对象和内联委托的顺序有多重要?
-
我们能否注册多个配置类?
-
贪婪验证是什么,为什么你应该使用它?
-
我们必须实现哪个接口来创建一个验证类?
进一步阅读
这里有一些链接,可以帮助我们巩固本章所学的内容:
-
ASP.NET Core 中的选项模式(官方文档):
adpg.link/RTGc -
快速入门:使用 Azure App Configuration 创建 ASP.NET Core 应用:
adpg.link/qhLV -
生产环境中的密钥存储与 Azure Key Vault:
adpg.link/Y5D7
答案
-
我们可以使用以下接口之一:
IOptionsMonitor<TOptions>、IOptionsFactory<TOptions>、IOptionsSnapshot<TOptions>或IOptions<TOptions>。 -
配置和后配置阶段。
-
配置器按照它们的注册顺序执行,因此它们的顺序至关重要。
-
是的,我们可以注册尽可能多的配置类。
-
贪婪验证允许在启动时捕获配置错误的选项,这可以节省运行时问题。
-
我们必须实现
IValidateOptions<TOptions>接口。
第十章:10 日志模式
在开始之前:加入我们的 Discord 书籍社区
直接向作者本人提供反馈,并在我们的 Discord 服务器上与其他早期读者聊天(在“EARLY ACCESS SUBSCRIPTION”下找到“architecting-aspnet-core-apps-3e”频道)。

本章涵盖了.NET 特定的功能,并结束了为 ASP.NET Core 设计部分。带有一些模式的日志功能是大多数应用程序需要的另一个构建块:内置的 ASP.NET Core。我们在不试图掌握每个方面的同时,亲手探索这个系统。日志是应用程序开发的一个关键方面,它服务于各种目的,如调试错误、跟踪操作、分析使用情况等。我们在这里探索的日志抽象是.NET Core 相对于.NET Framework 的另一个改进。我们不再依赖于第三方库,新的统一系统提供了一个清洁的接口,由一个灵活且健壮的机制支持,这有助于将日志集成到我们的应用程序中。在本章结束时,你将了解什么是日志以及如何编写应用程序日志。在本章中,我们将涵盖以下主题:
-
关于日志
-
编写日志
-
日志级别
-
日志提供者
-
配置日志
-
结构化日志
让我们先来探索一下什么是日志。
关于日志
记录日志是将消息写入日志并编制信息以备后用的实践。这些信息可以用于调试错误、追踪操作、分析使用情况或任何我们可以想到的其他原因。日志记录是一个横切关注点,这意味着它适用于你应用程序的每一部分。我们在第十四章,分层与清洁架构中讨论了层,但在此之前,我们只需说横切关注点影响所有层,不能仅集中在一个层;它影响了一切。日志由日志条目组成。我们可以将每个日志条目视为程序执行期间发生的事件。这些事件随后被写入日志。这个日志可以是文件、远程系统、stdout或多个目的地的组合。在创建日志条目时,我们还必须考虑该日志条目的严重性。从某种意义上说,这种严重性级别代表了我们要记录的消息类型或重要性的级别。我们还可以用它来过滤这些日志。"跟踪"、"错误"和"调试"是日志条目级别的例子。这些级别在Microsoft.Extensions.Logging.LogLevel枚举中定义。日志条目的另一个重要方面是其结构。你可以记录单个字符串。你团队中的每个人都可以以自己的方式记录单个字符串。但当你需要搜索信息时会发生什么?混乱随之而来!有找不到那个人正在寻找的东西的压力,以及同样的人体验到的日志结构的令人不快。解决这个问题的一种方法是通过使用结构化日志记录。它简单而又复杂;你必须为所有日志条目创建一个程序遵循的结构。这个结构可能更复杂或更简单,或者可以序列化为 JSON。重要的是日志条目是有结构的。我们不会在这里深入探讨这个主题,但如果你必须决定日志策略,我建议首先深入了解结构化日志记录。如果你是团队的一员,那么很可能会有人已经做了。如果不是这样,你总是可以提出这个话题。持续改进是生活的一个关键方面。我们本可以写一本关于日志、最佳日志实践、结构化日志记录和分布式跟踪的书,但本章旨在教你如何使用.NET 日志抽象。
编写日志
首先,日志系统是基于提供程序的,这意味着如果我们想让我们的日志条目有地方记录,我们必须注册一个或多个 ILoggerProvider 实例。默认情况下,当调用 WebApplication.CreateBuilder(args) 时,它会注册控制台、调试、事件源和事件日志(仅限 Windows)提供程序,但我们可以修改这个列表。如果需要,您可以添加和删除提供程序。使用日志系统的必需依赖项也作为此过程的一部分进行注册。在我们查看代码之前,让我们学习如何创建日志条目,这是日志记录的目标。要创建条目,我们可以使用以下接口之一:ILogger、ILogger<T> 或 ILoggerFactory。让我们更详细地看看它们:
| 接口 | 描述 |
|---|---|
ILogger |
允许我们执行日志记录操作的基本类型。 |
ILogger<T> |
允许我们执行日志记录操作的基本类型。从 ILogger 接口继承。系统使用泛型参数 T 作为日志条目的 类别。 |
ILoggerFactory |
一个允许创建 ILogger 对象并手动指定类别名称(作为字符串)的工厂接口。 |
表 10.1:日志接口。
以下代码表示最常用的模式,该模式包括注入一个 ILogger<T> 接口并将其存储在一个 ILogger 字段中,然后再使用它,如下所示:
public class Service : IService
{
private readonly ILogger _logger;
public Service(ILogger<Service> logger)
{
_logger = logger;
}
public void Execute()
{
_logger.LogInformation("Service.Execute()");
}
}
前面的 Service 类有一个私有 _logger 字段。它接受一个 ILogger<Service> 日志记录器作为参数并将其存储在该字段中。它使用该字段在 Execute 方法中向日志写入信息级别的消息。《IService》接口非常简单,仅公开一个 Execute 方法用于测试目的:
public interface IService
{
void Execute();
}
我加载了一个我创建的小型库来测试这个功能,为测试目的提供了额外的日志提供程序。有了这个,我们创建了一个泛型宿主 (IHost),因为我们不需要在测试中使用 WebApplication,然后我们进行配置:
namespace Logging;
public class BaseAbstractions
{
[Fact]
public void Should_log_the_Service_Execute_line()
{
// Arrange
var lines = new List<string>();
var host = Host.CreateDefaultBuilder()
.ConfigureLogging(loggingBuilder =>
{
loggingBuilder.ClearProviders();
loggingBuilder.AddAssertableLogger(lines);
})
.ConfigureServices(services =>
{
services.AddSingleton<Service>();
})
.Build();
var service = host.Services.GetRequiredService<Service>();
// Act
service.Execute();
// Assert
Assert.Collection(lines,
line => Assert.Equal("Service.Execute()", line)
);
}
// Omitted other members
}
在测试的 Arrange 阶段,我们创建了一些变量,配置 IHost,并获取我们想要用于测试我们编写的日志功能的 Service 类的实例。突出显示的代码使用 ClearProviders 方法删除了所有提供程序。然后它使用 AddAssertableLogger 扩展方法添加了一个新的提供程序。扩展方法来自我们加载的库。如果我们想添加一个新的提供程序,我们也可以这样做,但我想要展示如何删除现有的提供程序,这样我们就可以从一张干净的纸开始。这可能是你将来需要的东西。
我加载的库可在 NuGet 上找到,名称为
ForEvolve.Testing.Logging,但您不需要理解任何这些内容就能理解日志抽象和示例。
在Act阶段,我们调用我们服务的Execute方法。该方法将一行记录到在实例化时注入的ILogger实现中。然后,我们断言该行被写入到lines列表中(这就是AssertableLogger的作用;它将内容写入到List<string>)。在 ASP.NET Core 应用程序中,所有这些日志默认都会输出到控制台。日志记录是了解应用程序运行时后台发生情况的好方法。《Service》类是一个简单的ILogger<Service>消费者。你可以为任何你想添加日志支持的类做同样的事情。通过将Service替换为那个类名来为你的类配置一个日志记录器。这个泛型参数成为写入日志条目时的日志记录器类别名称。由于 ASP.NET Core 使用WebApplication而不是通用IHost,以下是使用该结构的相同测试代码:
[Fact]
public void Should_log_the_Service_Execute_line_using_WebApplication()
{
// Arrange
var lines = new List<string>();
var builder = WebApplication.CreateBuilder();
builder.Logging.ClearProviders()
.AddAssertableLogger(lines);
builder.Services.AddSingleton<IService, Service>();
var app = builder.Build();
var service = app.Services.GetRequiredService<IService>();
// Act
service.Execute();
// Assert
Assert.Collection(lines,
line => Assert.Equal("Service.Execute()", line)
);
}
我在前面代码中高亮显示了变更。简而言之,在通用宿主上使用的扩展方法已经被WebApplicationBuilder的属性如Logging和Services所替代。最后,Create方法创建了一个WebApplication而不是IHost,这与Program.cs文件中的情况完全一样。总结来说,这些测试案例使我们能够实现 ASP.NET Core 中最常用的日志记录模式,并添加一个自定义提供程序以确保我们记录了正确的信息。日志记录是必不可少的,它为生产系统提供了可见性。除非你是唯一使用系统的人,否则没有日志,你不知道系统中发生了什么,这是非常不可能的。你还可以记录基础设施中发生的事情,并对这些日志流进行实时安全分析,以快速识别安全漏洞、入侵尝试或系统故障。这些主题超出了本书的范围,但拥有强大的应用程序级日志记录能力只能有助于你的整体日志记录策略。在继续下一个主题之前,让我们探索一个利用ILoggerFactory接口的示例。代码设置了一个自定义类别名称,并使用创建的ILogger实例记录一条消息。这与前面的例子非常相似。以下是整个代码:
namespace Logging;
public class LoggerFactoryExploration
{
private readonly ITestOutputHelper _output;
public LoggerFactoryExploration(ITestOutputHelper output)
{
_output = output ?? throw new ArgumentNullException(nameof(output));
}
[Fact]
public void Create_a_ILoggerFactory()
{
// Arrange
var lines = new List<string>();
var host = Host.CreateDefaultBuilder()
.ConfigureLogging(loggingBuilder => loggingBuilder
.AddAssertableLogger(lines)
.AddxUnitTestOutput(_output))
.ConfigureServices(services => services.AddSingleton<Service>())
.Build()
;
var service = host.Services.GetRequiredService<Service>();
// Act
service.Execute();
// Assert
Assert.Collection(lines,
line => Assert.Equal("LogInformation like any ILogger<T>.", line)
);
}
public class Service
{
private readonly ILogger _logger;
public Service(ILoggerFactory loggerFactory)
{
ArgumentNullException.ThrowIfNull(loggerFactory);
_logger = loggerFactory.CreateLogger("My Service");
}
public void Execute()
{
_logger.LogInformation("LogInformation like any ILogger<T>.");
}
}
}
前面的代码应该看起来非常熟悉。让我们关注高亮显示的行,它们与当前模式相关:
-
我们将
ILoggerFactory接口注入到Service类的构造函数中(而不是ILogger<Service>)。 -
我们使用
"My Service"这个类别名称创建了一个ILogger实例。 -
我们将日志记录器分配给
_logger字段。 -
然后,我们在
Execute方法中使用那个ILogger。
作为一项经验法则,我建议默认使用 ILogger<T> 接口。如果不可能,或者如果您需要为日志条目设置更动态的类别名称,则利用 ILoggerFactory。默认情况下,使用 ILogger<T> 时,类别名称是 T 参数,应该是创建日志条目的类的名称。ILoggerFactory 接口更像是内部组件,而不是为我们消费的组件;尽管如此,它存在并满足某些用例。
注意
在前面的示例中,
ITestOutputHelper接口是Xunit.Abstractions集合的一部分。它允许我们将行作为 标准输出 写入测试日志。该输出在 Visual Studio 测试资源管理器中可用。
现在我们已经介绍了如何编写日志条目,现在是时候学习如何管理它们的严重性了。
日志级别
在前面的示例中,我们使用了 LogInformation 方法来记录信息消息,但还有其他级别,如下表所示:
| 级别 | 方法 | 描述 | 生产 |
|---|---|---|---|
| 跟踪 | LogTrace |
这用于捕获有关程序、执行速度和调试的详细信息。您还可以在跟踪时记录敏感信息。 | 禁用。 |
| 调试 | LogDebug |
这用于记录调试和开发信息。 | 除非故障排除,否则禁用。 |
| 信息 | LogInformation |
这用于跟踪应用程序的流程。系统中发生的正常事件通常是信息级别的事件,例如系统启动、系统停止和用户登录。 | 启用。 |
| 警告 | LogWarning |
这用于记录应用程序流程中的异常行为,不会导致程序停止,但可能需要调查;例如,已处理的异常、失败的网络调用和访问不存在的资源。 | 启用。 |
| 错误 | LogError |
这用于记录应用程序流程中的错误,不会导致应用程序停止。错误通常必须进行调查。例如,当前操作失败和无法处理的异常。 | 启用。 |
| 严重 | LogCritical |
这用于记录需要立即注意的错误,表示灾难性状态。程序很可能会停止,应用程序的完整性可能受到损害;例如,硬盘已满,服务器内存不足,或数据库处于死锁状态。 | 启用,某些警报可以配置为自动触发。 |
表 10.2:日志条目级别
如前表所述,每个日志级别都服务于一个或多个目的。这些日志级别告诉记录器日志条目的严重性。然后,我们可以配置系统只记录至少达到一定级别的条目,例如,不要在生产日志中填充跟踪和调试条目。在我领导的项目中,我们使用 ASP.NET Core 对记录简单和复杂消息的多种方法进行了基准测试,以围绕这一点建立清晰和优化的指南。当消息被记录时,由于基准测试运行之间的大时间差异,我们无法得出公平的结论。然而,当消息没有被记录时(例如,配置了最小日志级别为debug的trace日志),我们观察到一种恒定的趋势。基于这个结论,我建议使用以下结构而不是插值、string.Format或其他方式来记录Trace和Debug消息。这可能听起来很奇怪,为了优化“不记录某些内容”,但如果你这么想,这些日志条目将在生产中被跳过,因此优化它们将节省你的生产应用在这里和那里的一些毫秒计算时间。此外,这并不更难或更耗时,所以这只是一个好习惯。让我们看看不写入日志条目的最快方式:
_logger.LogTrace("Some: {variable}", variable);
// Or
_logger.LogTrace("Some: {0}", variable);
当日志级别被禁用时,例如在生产环境中,你只需支付方法调用的代价,因为你的日志条目没有进行任何处理。另一方面,如果我们使用插值,则会进行处理,因此一个参数被传递给Log[Level]方法,导致每个日志条目的处理能力成本更高。以下是一个插值的例子(也就是不要这样做):
_logger.LogTrace($"Some: {variable}");
对于警告及以上级别,你可以保持良好的习惯,使用相同的技巧或其他方法,因为我们知道这些行无论如何都会被记录。因此,在代码中使用插值或让记录器稍后执行应该会产生类似的结果。
最后一点。我建议在真正需要之前不要试图过度优化你的代码。在不需要优化的东西上投入大量精力进行优化的行为被称为过早优化。理念是提前优化到足够的程度,并在发现真正的问题时修复性能。
现在我们知道了.NET 为我们提供的日志级别,让我们来看看日志提供程序。
日志提供程序
为了让你了解可能的内置日志提供程序,以下是官方文档中的一个列表(请参阅本章末尾的进一步阅读部分):
-
Console
-
Debug
-
EventSource
-
EventLog(仅限 Windows)
-
ApplicationInsights
以下是一个第三方日志提供程序的列表,也来自官方文档:
-
elmah.io
-
Gelf
-
JSNLog
-
KissLog.net
-
Log4Net
-
NLog
-
PLogger
-
Sentry
-
Serilog
-
Stackdriver
现在,如果你需要这些中的任何一个,或者你喜欢的日志库是前面列表的一部分,你知道你可以使用它。如果不是,也许它支持 ASP.NET Core,但在咨询时它并未包含在文档中。接下来,让我们学习如何配置日志系统。
配置日志记录
与 ASP.NET Core 的大多数功能一样,我们可以配置日志记录。默认的WebApplicationBuilder为我们做了很多工作,但如果我们想调整默认设置,我们也可以做到。除此之外,系统会加载配置中的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"
}
}
}
我们可以定义默认级别(使用Logging:LogLevel:Default)和每个类别(例如Logging:LogLevel:Microsoft)的自定义级别,代表基本命名空间。例如,从该配置文件中,最小级别是Information,而属于Microsoft或Microsoft.*命名空间的每个项目都有最小级别为Warning。这允许在运行应用程序时移除噪音。我们还可以利用这些配置通过降低日志级别到Debug或Trace来调试应用程序的某些部分,仅针对项目子集(例如来自一个或多个命名空间的项目)。我们还可以根据提供者基础使用配置或代码来过滤我们想要记录的内容。在配置文件中,我们可以将控制台提供者的默认级别更改为Trace,如下所示:
{
"Logging": {
"LogLevel": {
"Default": "Information",
"Microsoft": "Warning"
},
"Console": {
"LogLevel": {
"Default": "Trace"
}
}
}
}
我们保留了相同的默认值,但添加了Logging:Console部分(见高亮代码),并将默认LogLevel设置为Trace。我们可以定义我们需要的任何设置。而不是配置,我们可以使用AddFilter扩展方法,如下面的实验测试代码所示,或者与配置一起使用。以下是记录数据的消费者类:
public class Service
{
private readonly ILogger _logger;
public Service(ILogger<Service> logger)
{
_logger = logger;
}
public void Execute()
{
_logger.LogInformation("[info] Service.Execute()");
_logger.LogWarning("[warning] Service.Execute()");
}
}
前面的类与其他我们在本章中使用的类类似,但使用两种不同的级别记录消息:Information和Warning。以下是一个测试用例,其中我们利用了AddFilter方法:
[Fact]
public void Should_filter_logs_by_provider()
{
// Arrange
var lines = new List<string>();
var host = Host.CreateDefaultBuilder()
.ConfigureLogging(loggingBuilder =>
{
loggingBuilder.ClearProviders();
loggingBuilder.AddConsole();
loggingBuilder.AddAssertableLogger(lines);
loggingBuilder.AddxUnitTestOutput(_output);
loggingBuilder
.AddFilter<XunitTestOutputLoggerProvider>(
level => level >= LogLevel.Warning
);
})
.ConfigureServices(services =>
{
services.AddSingleton<Service>();
})
.Build();
var service = host.Services.GetRequiredService<Service>();
// Act
service.Execute();
// Assert
Assert.Collection(lines,
line => Assert.Equal("[info] Service.Execute()", line),
line => Assert.Equal("[warning] Service.Execute()", line)
);
}
在前面的测试代码中,我们创建了一个通用主机并添加了三个提供者:控制台和两个测试提供者——一个将日志记录到列表中,另一个记录到 xUnit 输出。然后,我们告诉系统从 XunitTestOutputLoggerProvider 中过滤掉所有至少不是 Warning 级别的日志(见高亮代码);其他提供者不受该代码的影响。
在代码中,
_output成员是ITestOutputHelper类型的字段。
我们现在知道有两种设置最小日志级别的选项:
-
代码
-
配置
我们可以根据需要调整配置日志策略的方式。代码可以更容易地进行测试,而配置可以在运行时更新,无需重新部署。此外,使用级联模型,该模型允许我们覆盖配置,我们可以使用配置覆盖大多数用例。配置的最大缺点是,在 JSON 文件中编写字符串比编写代码更容易出错(假设你也没有退回到使用字符串)。我通常坚持使用配置来设置这些值,因为它们不经常改变。如果你更喜欢代码,我不知道有任何缺点,这只是个人偏好的问题;配置在某个时刻变成了代码。接下来,让我们看看结构化日志的一个简要示例。
结构化日志
如开头所述,结构化日志可以变得非常重要,并开辟了机会。查询数据结构始终比查询单行文本更灵活。如果没有关于日志的明确指南,无论是文本行还是 JSON 格式的数据结构,这一点尤其正确。为了保持简单,我们利用一个内置的格式化器(以下高亮行所示),将我们的日志条目序列化为 JSON。以下是 Program.cs 文件:
var builder = WebApplication.CreateBuilder(args);
builder.Logging.AddJsonConsole();
var app = builder.Build();
app.MapGet("/", (ILoggerFactory loggerFactory) =>
{
const string category = "root";
var logger = loggerFactory.CreateLogger(category);
logger.LogInformation("You hit the {category} URL!", category);
return "Hello World!";
});
app.Run();
这将控制台转换为记录 JSON。例如,每次我们点击 / 端点时,控制台会显示以下 JSON:
{
"EventId": 0,
"LogLevel": "Information",
"Category": "root",
"Message": "You hit the root URL!",
"State": {
"Message": "You hit the root URL!",
"category": "root",
"{OriginalFormat}": "You hit the {category} URL!"
}
}
没有那个格式化器,通常的输出会是:
info: root[0]
You hit the root URL!
基于这种比较,程序化查询 JSON 日志比查询 stdout 行更加灵活。结构化日志的最大好处是提高了可搜索性。您可以使用预定义的数据结构进行更精确的查询。当然,如果您正在设置生产系统,您可能希望将更多信息附加到这些日志项上,例如请求的相关 ID、可选的当前用户信息、运行代码的服务器名称,以及根据应用程序可能需要更多详细信息。您可能需要比开箱即用的功能更多,以充分利用结构化日志。一些第三方库,如 Serilog,提供了这些附加功能。然而,定义将纯文本发送到日志记录器的方式可能是一个起点。每个项目都应该规定每个功能的需求和深度,包括日志记录。此外,结构化日志是一个更广泛的主题,值得独立研究。尽管如此,我还是想简要地谈谈这个主题,并希望您已经学到了足够多的关于日志记录的知识,以便开始实践。
摘要
在本章中,我们深入探讨了日志记录的概念。我们了解到,日志记录是将消息记录到日志中以供以后使用的一种实践,例如调试错误、跟踪操作和分析使用情况。日志记录是必不可少的,ASP.NET Core 提供了各种方式来记录信息,而无需使用第三方库,同时允许我们使用我们喜欢的日志框架。我们可以自定义日志的编写和分类方式。我们可以使用零个或多个日志提供程序。我们还可以创建自定义日志提供程序。最后,我们可以使用配置或代码来过滤日志以及更多内容。以下是需要记住的默认日志模式:
-
注入一个
ILogger<T>,其中T是注入日志记录器的类的类型。T成为类别。 -
将该日志记录器的引用保存到
private readonly ILogger字段中。 -
在您的函数中使用该日志记录器,使用适当的日志级别记录消息。
与 .NET Framework 相比,日志系统是 .NET Core 的一个很好的补充。它允许我们标准化日志机制,使我们的系统在长期内更容易维护。例如,如果您想使用一个新的第三方库,甚至是一个定制的库,您可以将提供程序加载到您的 Program 中,只要您只依赖于日志抽象,整个系统就会适应并开始使用它,而无需进行任何进一步的更改。这是一个很好的例子,说明了精心设计的抽象可以为系统带来什么。以下是一些关键要点:
-
日志记录是一个横切关注点,影响应用程序的所有层。
-
日志记录包含许多日志条目,这些条目代表程序执行期间运行时发生的事件。
-
日志条目的严重性对于过滤和优先级排序非常重要。
-
严重级别包括 Trace、Debug、Information、Warning、Error 和 Critical。
-
我们可以配置日志系统,使其仅根据每个条目的严重级别记录某些消息。
-
结构化日志可以帮助在日志中保持一致性,并简化搜索。
-
.NET 中的日志系统是基于提供者的,这允许我们自定义默认提供者。
-
我们可以使用
ILogger、ILogger<T>或ILoggerFactory等接口来创建日志条目。
本章以 ASP.NET Core 为中心,结束了本书的第二部分。在接下来的几章中,我们将探讨设计模式,以创建灵活且健壮的组件。
问题
让我们看看几个练习题:
-
我们能否同时将日志条目写入控制台和文件?
-
在生产环境中,我们应该记录跟踪和调试级别的日志条目吗?
-
结构化日志的目的是什么?
-
我们如何在.NET 中创建日志条目?
进一步阅读
这里有一个链接,可以基于我们在本章中学到的内容进行构建:
- [官方文档] 在.NET Core 和 ASP.NET Core 中的日志记录:
adpg.link/MUVG
答案
-
是的,你可以配置你想要的任何数量的提供者。一个可以用于控制台,另一个可以将条目追加到文件中。
-
不,在生产环境中你不应该记录跟踪级别的条目。当你调试问题时,你应该只记录调试级别的条目。
-
结构化日志在所有日志条目中保持一致的结构,这使得搜索和分析日志更加容易。
-
我们可以使用
ILogger、ILogger<T>和ILoggerFactory等接口来创建日志条目。
第十一章:11 种结构模式
在开始之前:加入我们的 Discord 书籍社区
直接向作者本人提供反馈,并在我们的 Discord 服务器上与其他早期读者聊天(在“architecting-aspnet-core-apps-3e”频道下找到“EARLY ACCESS SUBSCRIPTION”)。

本章探讨了来自著名的四人帮(GoF)的四个设计模式。我们使用结构模式以可维护的方式构建和组织复杂对象层次结构。它们允许我们动态地向现有类添加行为,无论我们最初是这样设计系统,还是作为程序生命周期后期因必要性而出现的后续想法。结构模式促进重用性并增强系统的整体灵活性。在本章中,我们将涵盖以下主题:
-
实现装饰者设计模式
-
实现组合设计模式
-
实现适配器设计模式
-
实现外观设计模式
前两个模式帮助我们动态地扩展类并有效地管理复杂对象结构。最后两个模式帮助我们适配接口或将复杂系统用简单接口屏蔽。让我们深入挖掘结构模式的力量!
实现装饰者设计模式
装饰者模式允许我们通过包装一个或多个装饰者对象来动态地向对象添加新功能。这种模式遵循开闭原则,允许我们在运行时向对象添加额外的行为,而不修改其原始代码。这种模式使我们能够将责任分离成多个更小的部分。这是一个简单但强大的模式。在本节中,我们将探讨如何以传统方式实现此模式,以及如何利用名为Scrutor的开源工具来帮助我们创建强大的依赖注入准备好的装饰者。
目标
装饰者的目标是运行时扩展现有对象,而不改变其代码。此外,被装饰的对象应该对装饰过程一无所知,这使得这种方法非常适合需要进化的复杂或长期系统。这种模式适用于所有规模的系统。
我经常使用这种模式以极低的成本增加灵活性,并为程序创建适应性。此外,小型类更容易测试,因此装饰者模式将测试的便利性融入其中,使其值得掌握。
装饰者模式使得将责任封装到多个类中变得更容易,而不是将多个责任打包在一个类中。拥有多个具有单一责任的类使得系统更容易管理。
设计
装饰者类必须实现并使用被装饰类实现的接口。让我们一步一步来看,从非装饰类的设计开始:

图 11.1:表示实现 IComponent 接口的 ComponentA 类的类图
在前面的图中,我们有以下组件:
-
调用
IComponent接口的Operation()方法的客户端。 -
实现
IComponent接口的ComponentA。
这对应以下序列图:

图 11.2:显示消费者调用 ComponentA 类的 Operation 方法的序列图
现在,假设我们想在某些情况下向ComponentA添加行为,在其他情况下则保持初始行为。为此,我们可以选择装饰者模式,并按以下方式实现它:

图 11.3:装饰者类图
我们没有修改ComponentA类,而是创建了DecoratorA,它也实现了IComponent接口。这样,Client对象可以使用DecoratorA的实例而不是ComponentA,并且可以利用新的行为而不影响ComponentA的其他消费者。然后,为了避免重写整个组件,在创建新的DecoratorA实例时(构造函数注入)注入了IComponent接口的实现(例如ComponentA)。这个新实例存储在component字段中,并由Operation()方法使用(隐式使用策略模式)。我们可以这样翻译更新的序列:

图 11.4:装饰者序列图
在前面的图中,客户端不是直接调用ComponentA,而是调用DecoratorA,然后DecoratorA再调用ComponentA。最后,DecoratorA通过调用其私有方法AddBehaviorA()进行一些后处理。
装饰者模式中的任何内容都不会限制我们进行预处理、后处理、用一些逻辑(如
if语句或try-catch)包装被装饰类的调用(在这个例子中是Operation方法),或者所有这些的组合。添加后处理行为的用法只是一个例子。
在我们跳入代码之前,让我们看看装饰者模式有多强大:我们可以链式使用装饰者!由于我们的装饰者依赖于接口(而不是实现),我们可以在DecoratorA内部注入另一个装饰者,比如叫DecoratorB(或者反过来)。然后我们可以创建一个装饰彼此的规则链,从而得到一个非常强大且简单的设计。让我们看看以下表示我们的链式示例的类图:

图 11.5:包括两个装饰者的装饰者类图
在这里,我们创建了DecoratorB类,它看起来与DecoratorA非常相似,但有一个私有的AddBehaviorB()方法而不是AddBehaviorA()。
我们实现装饰器逻辑的方式与模式无关,因此我从图 9.3中排除了
AddBehaviorA()方法,以便只向您展示模式。然而,我在图 9.5中添加了它,以阐明第二个装饰器背后的理念。
让我们看看这个的序列图:

图 11.6:两个嵌套装饰器的序列图
通过这种方式,我们开始看到装饰器的强大之处。在先前的图中,我们可以评估ComponentA的行为被改变两次,而客户端并不知道这一点。所有这些类都对链中的下一个IComponent一无所知。它们甚至不知道自己正在被装饰。它们只是在计划中扮演自己的角色——仅此而已。重要的是要注意,装饰器的力量在于它依赖于接口,而不是实现,这使得它可重用。基于这个事实,我们可以交换DecoratorA和DecoratorB以反转应用新行为的顺序,而不必触及代码本身。我们还可以将相同的装饰器(比如DecoratorC)应用于多个IComponent实现,比如装饰DecoratorA和DecoratorB。装饰器甚至可以装饰自己。现在让我们深入研究一些代码。
项目 - 添加行为
让我们实现前面的示例,以帮助可视化添加一些任意行为的装饰器模式。每个Operation()方法返回一个字符串,然后输出到响应流。它并不复杂,但直观地显示了模式的工作原理。首先,让我们看看IComponent接口:
public interface IComponent
{
string Operation();
}
IComponent接口只声明实现应该有一个返回string的Operation()方法。接下来,让我们看看ComponentA类:
public class ComponentA : IComponent
{
public string Operation()
{
return "Hello from ComponentA";
}
}
ComponentA类的Operation()方法返回一个字面字符串。现在我们已经描述了第一部分,让我们看看消费者:
var builder = WebApplication.CreateBuilder(args);
builder.Services.AddSingleton<IComponent, ComponentA>();
var app = builder.Build();
app.MapGet("/", (IComponent component) => component.Operation());
app.Run();
在上面的Program.cs文件中,我们将ComponentA注册为IComponent的实现,具有单例生命周期。然后,当 HTTP 请求击中/端点时,我们注入一个IComponent实现。然后代理调用Operation()方法并将结果输出到响应。此时,运行应用程序会产生以下响应:
Hello from ComponentA
到目前为止,它相当简单;客户端调用端点,容器将ComponentA类的实例注入到端点代理中,然后端点将Operation方法的结果返回给客户端。接下来,我们添加第一个装饰器。
装饰器 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)的标签包装其结果。现在我们有了装饰器,我们需要告诉 IoC 容器在注入 IComponent 接口时发送 DecoratorA 的实例而不是 ComponentA。DecoratorA 应该装饰 ComponentA。更准确地说,容器应该将 ComponentA 类的实例注入到 DecoratorA 类中。为了实现这一点,我们可以按照以下方式注册它:
builder.Services.AddSingleton<IComponent>(serviceProvider => new DecoratorA(new ComponentA()));
在这里,我们正在告诉 ASP.NET Core 在注入 IComponent 接口时注入一个装饰了 ComponentA 实例的 DecoratorA 实例。当我们运行应用程序时,我们应该在浏览器中看到以下结果:
<DecoratorA>Hello from ComponentA</DecoratorA>
你可能注意到了一些
new关键字,尽管这并不非常优雅,但我们可以在组合根中手动创建新实例,而不会危及我们应用程序的健康。我们将在介绍 Scrutor 后学习如何消除其中的一些。
接下来,让我们创建第二个装饰器。
DecoratorB
既然我们已经有了装饰器,现在是时候创建第二个装饰器来展示链式装饰器的强大功能了。背景:我们需要另一个内容包装器,但又不想修改现有的类。为了实现这一点,我们得出结论,创建第二个装饰器将非常完美,因此我们创建了以下 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 类似,但 XML 标签是 DecoratorB。重要的是,装饰器依赖于并实现了 IComponent 接口,而不依赖于具体类。这就是我们能够装饰任何 IComponent 的灵活性所在,这也是我们能够链式使用装饰器的原因。为了完成这个示例,我们需要像这样更新我们的组合根:
builder.Services.AddSingleton<IComponent>(serviceProvider => new DecoratorB(new DecoratorA(new ComponentA())));
现在,DecoratorB 装饰了 DecoratorA,而 DecoratorA 又装饰了 ComponentA。当运行应用程序时,你会看到以下输出:
<DecoratorB><DecoratorA>Hello from ComponentA</DecoratorA></DecoratorB>
哇!这些装饰器使我们能够修改 ComponentA 的行为,而不影响代码。然而,随着我们在每个依赖项内部实例化多个依赖项,我们的组合根开始变得混乱,这使得我们的应用程序更难维护。此外,代码的可读性也在下降。更进一步,如果装饰器还依赖于其他类,代码的可读性将变得更差。
我们可以使用装饰器来改变对象的行为或状态。我们可以非常富有创意地使用装饰器;例如,你可以创建一个类,它通过 HTTP 查询远程资源,然后使用一个小组件来管理结果的内存缓存,限制往返远程服务器的次数。你可以创建另一个装饰器来监控查询这些资源所需的时间,并将其记录到 Application Insights 中——有如此多的可能性。
接下来,我们消除 new 关键字并清理我们的组合根。
项目 - 使用 Scrutor 的装饰器
这次更新旨在简化我们刚刚创建的系统结构。为了实现这一点,我们使用 Scrutor,这是一个开源库,它允许我们做这件事,以及其他事情。我们首先需要使用 Visual Studio 或 CLI 安装 Scrutor NuGet 包。当使用 CLI 时,运行以下命令:
dotnet add package Scrutor
一旦安装了 Scrutor,我们就可以使用 Decorate 扩展方法在 IServiceCollection 上添加装饰器。通过使用 Scrutor,我们可以更新以下混乱的行:
builder.Services.AddSingleton<IComponent>(serviceProvider => new DecoratorB(new DecoratorA(new ComponentA())))
将其转换为这三行更加优雅的代码:
builder.Services
.AddSingleton<IComponent, ComponentA>()
.Decorate<IComponent, DecoratorA>()
.Decorate<IComponent, DecoratorB>()
;
在前面的代码中,我们将 ComponentA 注册为 IComponent 的实现,具有单例生命周期,就像第一次一样。然后,通过使用 Scrutor,我们告诉 IoC 容器覆盖第一个绑定,并用 DecoratorA 的实例装饰已注册的 IComponent (ComponentA)。然后,我们通过告诉 IoC 容器返回一个实例 DecoratorB 来覆盖第二个绑定,该实例装饰了 IComponent 的最后一个已知绑定(DecoratorA)。结果是和之前一样,但现在代码更加优雅。除了提高了可读性之外,这还让容器创建实例,而不是我们使用 new 关键字,这增加了我们系统的灵活性和稳定性。作为提醒,当请求 IComponent 接口时,IoC 容器提供相当于以下 instance 的功能:
var instance = new DecoratorB(new DecoratorA(new ComponentA()));
为什么我要谈论优雅和灵活性?这个代码只是一个简单的例子,但如果我们向这些类添加其他依赖项,它可能会迅速变成一个复杂的代码块,这可能成为一个维护噩梦,非常难以阅读,并且需要手动管理生命周期。当然,如果系统很简单,你总是可以手动实例化装饰器而不需要加载外部库。
在可能的情况下,保持你的代码简单。使用 Scrutor 是实现这一目标的一种方法。代码的简洁性从长远来看有助于阅读和跟踪,即使对其他人来说也是如此。考虑到总有一天会有人阅读你的代码。
此外,向项目中添加任何外部依赖都应该仔细考虑。记住,你必须保持依赖项更新,所以依赖项太多可能会占用维护时间。库的作者也可能停止维护它,库就会变得过时。库可能会引入破坏性更改,迫使你更新代码。等等。
此外,还需要考虑安全性方面。供应链攻击并不罕见。如果你在一个受监管的地方工作,你可能必须通过网络安全审查过程,等等。
除了这些一般性建议之外,我已经使用了 Scrutor 多年;我发现它非常稳定,并且不记得有任何破坏性更改导致我遇到问题。
为了确保两个程序的行为相同,无论是否有 Scrutor,让我们探索以下集成测试,它运行在两个项目上,确保它们的正确性:
namespace Decorator.IntegrationTests;
//...
[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 请求,并将服务器响应与预期值进行比较。由于两个项目应该有相同的输出,我们在DecoratorPlainStartupTest和DecoratorScrutorStartupTest类中重用这个测试。它们是空的,只将测试路由到正确的程序。以下是一个 Visual Studio 测试资源管理器的示例:

图 11.7:一个显示装饰器集成测试的 Visual Studio 资源管理器截图。
你还可以使用 Scrutor 进行组件扫描(
adpg.link/xvfS),这允许你执行自动依赖注册。这超出了本章的范围,但值得研究。Scrutor 允许你在更复杂的场景中使用内置的 IoC 容器,推迟了替换第三方容器的需求。
结论
装饰器模式是我们工具箱中最简单但最强大的设计模式之一。它在不修改现有类的情况下增强它们。装饰器是一个独立的逻辑块,我们可以用它来创建复杂和细粒度的对象树,以满足我们的需求。我们还探讨了 Scrutor 开源库,以帮助我们注册装饰器到容器中。装饰器模式帮助我们遵循SOLID原则(反之亦然),如下所示:
-
S:装饰器模式建议创建小的类来为其他类添加行为,分离责任,并促进复用。
-
O:装饰器在不修改其他类的情况下为它们添加行为,这实际上就是 OCP 的定义。
-
L:不适用
-
I:遵循 ISP,为你的特定需求创建装饰器应该很容易。然而,如果你的接口过于复杂,实现装饰器模式可能会变得困难。难以创建装饰器是一个很好的指标,表明设计中存在问题——一个代码异味。一个良好分离的接口应该很容易装饰。
-
D:依赖于抽象是装饰器强大功能的关键。
接下来,我们探讨组合模式,它帮助我们以不同于装饰器的方式管理复杂对象的结构。
实现组合设计模式
组合设计模式是另一个 GoF 的结构型设计模式,它帮助我们管理复杂的对象结构。
目标
组合模式背后的目标是创建一个层次化的数据结构,其中你不需要区分组与单个组件,使得消费者对层次结构的遍历和操作变得容易。
你可以将组合模式视为一种构建具有自我管理节点的图或树的方法。
设计
设计很简单;我们有 components 和 composites。两者都实现了一个定义共享操作的公共接口。components 是单个节点,而 composites 是 components 的集合。让我们看看一个图例:

图 11.7:组合类图
在前面的图中,Client 依赖于一个 IComponent 接口,并且对底层实现一无所知——它可能是一个 Component 或 Composite 的实例;这并不重要。然后,我们有两种实现:
-
Component代表单个元素;一个叶节点。 -
Composite代表一组IComponent。Composite对象通过将部分过程委托给其子对象来使用其子对象来管理层次结构的复杂性。
这三个部分组合在一起就形成了组合设计模式。考虑到可以将 Composite 和 Component 类的实例添加为其他 Composite 对象的子对象,我们可以几乎不费吹灰之力地创建复杂、非线性、自我管理的数据结构。
您不仅限于一种组件和一种组合;您可以根据需要创建尽可能多的
IComponent接口的实现。然后,您甚至可以将它们混合匹配以创建一个非线性树。
项目 – BookStore
上下文:我们过去编写了一个程序来支持书店。然而,商店经营得如此之好,我们的小程序已经不够用了。我们虚构的公司现在拥有多个商店。他们希望将这些商店划分为区域并管理书籍集合和单本书。在收集信息和询问他们几分钟之后,我们意识到他们可以有集合的集合、子部分,并考虑创建子商店,因此我们需要一个灵活的设计。我们决定使用组合模式来解决这个问题。以下是我们的类层次结构:

图 11.8:BookStore 项目的组合类层次结构
由于我们的类层次结构复杂,且项目早期阶段的不确定性,我们决定使用工厂来创建我们的类层次结构,展示我们的设计,并让客户验证。以下是高级设计:

图 11.9:BookStore 项目的概要设计
我们决定追求尽可能小的接口以启动项目。由于我们想知道商店任何部分有多少可用项目以及我们正在与哪种类型的组件交互,我们创建了以下接口:
namespace Composite.Models;
public interface IComponent
{
int Count { get; }
string Type { get; }
}
Count 属性使我们能够计算在公司、商店、部分、集合或未来创建的任何其他组合组件下有多少可用项目。Type 属性强制每个组件线性显示其类型。
我们能够创建这样一个最小化界面,因为我们不是在数据结构上执行任何操作,而是在计数元素,然后将它序列化为 JSON。序列化器将为我们处理遍历类层次结构。在另一个上下文中,属性的最小子集可能比这更多。例如,在这种情况下,我们可以在接口中添加一个
Name属性,但书籍的名称就是它的标题,所以我决定不包含它。
接下来,让我们创建我们的复合结构,从Book类(即组件)开始:
namespace Composite.Models;
public class Book : IComponent
{
public Book(string title)
{
Title = title ?? throw new ArgumentNullException(nameof(title));
}
public string Title { get; }
public string Type => "Book";
public int Count { get; } = 1;
}
前面的Book类通过总是返回 1 个计数来实现接口,因为它是一本书,是树中的一个叶节点。Type属性也是硬编码的。作为一个书籍类,它在构造时需要一个标题,并将其存储在Title属性中(不是继承的,并且仅对Book实例可用)。
在实际场景中,我们会有更多的属性,比如 ISBN 和作者,但这样做在这里只会使示例变得混乱。我们不是在设计一个真正的书店,而是在学习复合模式。
接下来,让我们创建我们的复合组件,BookComposite类:
using System.Collections;
using System.Collections.ObjectModel;
namespace Composite.Models;
public abstract class BookComposite : IComponent
{
protected readonly List<IComponent> children = new();
public BookComposite(string name)
{
Name = name ?? throw new ArgumentNullException(nameof(name));
}
public string Name { get; }
public virtual string Type => GetType().Name;
public virtual int Count
=> children.Sum(child => child.Count);
public virtual IEnumerable Children
=> new ReadOnlyCollection<IComponent>(children);
public virtual void Add(IComponent bookComponent)
{
children.Add(bookComponent);
}
public virtual void Remove(IComponent bookComponent)
{
children.Remove(bookComponent);
}
}
BookComposite类实现了以下共享功能:
-
儿童管理(在代码中突出显示)。
-
设置复合对象的
Name属性并强制其继承的类在构造时设置一个名称。 -
自动查找并设置其派生类的
Type名称。 -
计算子项的数量(以及隐含的子项的子项数量)。
-
通过
Children属性公开子项,并确保消费者不能从外部修改集合,通过返回一个ReadOnlyCollection对象来实现。
在
children.Sum(child => child.Count());表达式中使用 LINQ 的Sum()扩展方法,使我们能够替换一个更复杂的for循环和一个累加变量。通过将
virtual修饰符添加到Type属性,允许子类型在它们类型名称不反映应显示在程序中的类型时覆盖该属性。
现在,我们可以开始实现我们复杂复合层次结构中的其他类,并为每个类分配一个责任,展示复合模式是多么灵活。以下类继承自BookComposite类:
-
Corporation类代表拥有多个商店的公司。然而,它不仅限于拥有商店;公司可以拥有其他公司、商店或任何其他IComponent。 -
Store类代表书店。 -
Section类代表书店的一个部分,一个通道或书籍的一个类别。 -
Set类代表一个书籍集合,例如三部曲。
这些可以由任何IComponent组成,这使得这是一个超灵活的数据结构。让我们看看这些BookComposite子类型的代码,从Corporation类开始:
namespace Composite.Models;
public class Corporation : BookComposite
{
public Corporation(string name, string ceo)
: base(name)
{
CEO = ceo;
}
public string CEO { get; }
}
公司向模型贡献了一位首席执行官,因为有人需要管理这个地方。接下来,我们看看 Store 类:
namespace Composite.Models;
public class Store : BookComposite
{
public string Location { get; }
public string Manager { get; }
public Store(string name, string location, string manager)
: base(name)
{
Location = location;
Manager = manager;
}
}
在 BookComposite 成员之上,商店有一个经理和位置。现在,Section 类没有添加任何内容,但我们可以将其用作灵活的组织者:namespace Composite.Models;
public class Section : BookComposite
{
public Section(string name) : base(name) { }
}
最后,Set 类允许通过书籍参数在构造时创建书籍集合:
namespace Composite.Models;
public class Set : BookComposite
{
public Set(string name, params IComponent[] books)
: base(name)
{
foreach (var book in books)
{
Add(book);
}
}
}
在创建实例时组合书籍集合将方便我们稍后组装树。接下来,让我们探索程序的最后部分,它有助于封装数据结构的创建:工厂。
工厂不是复合模式的一部分,但现在我们知道了工厂是什么,我们可以使用一个工厂来封装我们数据结构的创建逻辑并讨论它。
工厂接口看起来如下所示:
public interface ICorporationFactory
{
Corporation Create();
}
ICorporationFactory 接口的默认具体实现是 DefaultCorporationFactory 类。它创建了一个包含部分、子部分、集合和子集的大型非线性数据结构。整个结构是在 DefaultCorporationFactory 类中使用我们的复合模型定义的。由于其大小庞大,让我们从类的骨架及其 Create 方法开始:
using Composite.Models;
namespace Composite.Services;
public class DefaultCorporationFactory : ICorporationFactory
{
public Corporation Create()
{
var corporation = new Corporation(
"Boundless Shelves Corporation",
"Bosmang Kapawu"
);
corporation.Add(CreateTaleTowersStore());
corporation.Add(CreateEpicNexusStore());
return corporation;
}
// ...
}
在先前的 Create 方法中,我们创建了公司,添加了两个商店,然后返回结果。CreateTaleTowersStore 和 CreateEpicNexusStore 方法创建了一个商店,设置了其名称、地址和经理,并为每个商店创建了三个部分:
private IComponent CreateTaleTowersStore()
{
var store = new Store(
"Tale Towers",
"125 Enchantment Street, Storyville, SV 72845",
"Malcolm Reynolds"
);
store.Add(CreateFantasySection());
store.Add(CreateAdventureSection());
store.Add(CreateDramaSection());
return store;
}
private IComponent CreateEpicNexusStore()
{
var store = new Store(
"Epic Nexus",
"369 Parchment Plaza, Novelty, NV 68123",
"Ellen Ripley"
);
store.Add(CreateFictionSection());
store.Add(CreateFantasySection());
store.Add(CreateAdventureSection());
return store;
}
两个商店共享两个部分(有相同的书籍;高亮代码),每个部分都有独特的部分。如果我们查看 CreateFictionSection 方法,它添加了一本虚构的书籍和一个子部分:
private IComponent CreateFictionSection()
{
var section = new Section("Fiction");
section.Add(new Book("Some alien cowboy"));
section.Add(CreateScienceFictionSection());
return section;
}
CreateScienceFictionSection 方法添加了一本虚构的书籍和由三部曲组成的星球大战书籍集合(一个集合的集合):
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;
}
现在,如果我们查看数据结构的一部分,我们有以下内容:

图 11.10:Epic Nexus 商店数据的小说部分
在更大的体系中,整个组织结构,直到部分级别(不包括书籍和集合),看起来如下所示:

图 11.11:没有书籍和集合的复合层次结构
我省略了整个数据结构(包括书籍)的图像发布,因为它太大,难以阅读。请放心,内容本身并不重要,我们正在研究的部分足以理解复合模式为设计带来的灵活性。
在探索过程中,我们可以看到设计是多么灵活。我们可以创建几乎任何我们想要的组织结构。现在,让我们看看 Program.cs 文件并注册我们的依赖项以及一个用于查询数据结构的端点:
using Composite.Services;
var builder = WebApplication.CreateBuilder(args);
builder.Services.AddSingleton<ICorporationFactory, DefaultCorporationFactory>();
var app = builder.Build();
app.MapGet(
"/",
(ICorporationFactory corporationFactory)
=> corporationFactory.Create()
);
app.Run();
之前的代码将创建公司数据结构的工厂与容器以及一个用于提供服务的端点注册。当我们执行代码时,我们得到完整的数据结构或公司。为了简洁起见,以下 JSON 表示虚构部分,不包括书籍:
{
"ceo": "Bosmang Kapawu",
"name": "Boundless Shelves Corporation",
"type": "Corporation",
"count": 43,
"children": [
{
"location": "369 Parchment Plaza, Novelty, NV 68123",
"manager": "Ellen Ripley",
"name": "Epic Nexus",
"type": "Store",
"count": 25,
"children": [
{
"name": "Fiction",
"type": "Section",
"count": 11,
"children": [
{
"name": "Science Fiction",
"type": "Section",
"count": 10,
"children": [
{
"name": "Star Wars",
"type": "Set",
"count": 9,
"children": [
{
"name": "Prequel trilogy",
"type": "Set",
"count": 3,
"children": []
},
{
"name": "Original trilogy",
"type": "Set",
"count": 3,
"children": []
},
{
"name": "Sequel trilogy",
"type": "Set",
"count": 3,
"children": []
}
]
}
]
}
]
}
]
}
]
}
count字段的值反映了总数。在这种情况下,没有书籍,所以计数应该是 0。如果你运行程序并玩转在DefaultCorporationFactory.cs文件中定义的预处理器符号(ADD_BOOKS、ADD_SETS和ONLY_FICTION),你将得到不同的数字。
组合模式允许我们在一个小的方法调用中渲染复杂的数据结构。由于每个组件都自主处理自身,组合模式从消费者那里移除了管理这种复杂性的负担。我鼓励你尝试与现有的数据结构互动,以便理解这个模式。你也可以尝试添加一个Movie类来管理电影;书店必须多样化其活动。你还可以区分电影和书籍,以免顾客混淆。书店可以有实体书和数字书。如果你还在寻找更多,尝试从头开始构建一个新的应用程序,并使用组合模式来创建、管理和显示多级菜单结构或文件系统 API。
结论
组合模式有效地构建、管理和维护复杂的非线性数据结构。它的力量主要在于其自我管理能力。每个节点、组件或组合都负责自己的逻辑,留给组合消费者的工作很少或没有。当然,更复杂的场景会导致更复杂的接口。使用组合模式有助于我们以下方式遵循SOLID原则:
-
S: 它有助于将复杂数据结构中的多个元素划分为小类,以分割责任。
-
O: 通过允许我们“混合匹配”
IComponent接口的不同实现,组合模式使我们能够在不影响其他现有类的情况下扩展数据结构。例如,你可以创建一个新的实现IComponent的类,并立即开始使用它,而无需修改任何其他组件类。 -
L: 无
-
I: 当单个项目实现仅影响集合的操作时,如
Add和Remove方法,组合模式可能会违反 ISP 原则,但在这里我们没有这样做。 -
D: 组合模式的行为者仅依赖于
IComponent,这反转了依赖关系流。
接下来,我们将转向另一种类型的结构模式,它将一个接口适配到另一个接口。
实现适配器设计模式
适配器模式是另一种结构型设计模式,它允许两个不兼容的接口在不修改它们现有代码的情况下一起工作。该模式引入了一个名为 适配器 的包装类,它桥接了接口之间的差距。
目标
当我们想要使用现有的类,但其接口与我们想要使用它的方式不兼容时,适配器设计模式适用。我们不是重构该类,这可能会在现有的代码库中引入错误或错误,甚至可能将更改级联到系统的其他部分,而是可以使用一个 适配器 类使该类的接口与 目标 接口兼容。当我们不能更改 适配者 的代码或不想更改它时,适配器模式非常有用。
设计
你可以将适配器想象成电源插座的通用适配器;你可以通过将其连接到适配器,然后连接到电源插座,将北美设备连接到欧洲插座。适配器设计模式正是如此,但针对的是 API。让我们先看看以下图示:

图 11.12:适配器类图
在前面的图中,我们有以下参与者:
-
ITarget接口包含我们想要(或已经)使用的合约。 -
Adaptee类代表我们想要使用的具体组件,它不符合ITarget。 -
Adapter类将Adaptee类适配到ITarget接口。
实现适配器模式的第二种方法涉及到继承。如果你可以选择组合,那就选择组合,但如果你需要访问 protected 方法或其他 Adaptee 的内部状态,你可以选择继承,如下所示:

图 11.13:继承适配者的适配器类图
参与者相同,但不是通过组合 Adapter 类与 Adaptee 类,而是 Adapter 类从 Adaptee 类继承。这种设计使 Adapter 类同时成为 Adaptee 和 ITarget。让我们探索一下这在代码中是如何实现的。
项目 – 问候器
上下文:我们编写了一个高度复杂的问候系统,我们希望在新的程序中重用它。然而,它的接口与新设计不匹配,我们无法修改它,因为其他系统使用该问候系统。为了解决这个问题,我们决定应用适配器模式。以下是外部问候器(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("ExternalGreeterAdapter");
}
}
在前面的代码中,参与者如下:
-
IGreeter接口代表 目标,是我们必须使用的接口。 -
ExternalGreeter类代表适配器,是包含所有逻辑的外部组件,这些逻辑是由某人编写的并经过测试。该代码可能位于外部程序集或通过 NuGet 包安装。 -
ExternalGreeterAdapter类代表适配器,这是适配器执行其工作的地方。在这种情况下,Greeting方法调用ExternalGreeter类的GreetByName方法,该类实现了问候逻辑。
现在,我们可以调用Greeting方法并获取GreetByName调用的结果。有了这个,我们可以通过ExternalGreeterAdapter类重用现有的逻辑。
我们还可以通过模拟
IGreeter接口来测试IGreeter消费者,而不必处理ExternalGreeterAdapter类。
在这种情况下,“复杂逻辑”相当简单,但我们在这里是为了适配器模式,而不是为了想象中的业务逻辑。现在,让我们看看消费者:
var builder = WebApplication.CreateBuilder(args);
builder.Services.AddSingleton<ExternalGreeter>();
builder.Services.AddSingleton<IGreeter, ExternalGreeterAdapter>();
var app = builder.Build();
app.MapGet("/", (IGreeter greeter) => greeter.Greeting());
app.Run();
在前面的代码中,我们通过将ExternalGreeterAdapter类注册为与IGreeter接口绑定的单例来构建我们的应用程序。我们还通知容器,每当请求时(在这种情况下,我们将其注入到ExternalGreeterAdapter类中)提供ExternalGreeter类的单个实例。然后,消费者(在类图中为客户端)是突出显示的端点,其中IGreeter接口作为参数注入。然后,委托调用注入实例的Greeting方法并将问候消息输出到响应。以下图表示这个系统正在发生的事情:

图 11.14:问候系统序列图
哇!我们几乎不费吹灰之力就将ExternalGreeterAdapter类适配到了IGreeter接口。
结论
适配器模式是另一种提供灵活性的简单模式。通过它,我们可以使用旧的或不符合规范的组件,而无需重写它们。当然,根据目标和适配器接口,你可能需要投入更多或更少的精力来编写适配器类的代码。现在,让我们学习适配器模式如何帮助我们遵循SOLID原则:
-
S:适配器模式只有一个职责:使一个接口与另一个接口协同工作。
-
O:适配器模式允许我们修改适配器的接口,而无需修改其代码。
-
L:关于适配器模式,继承不是一个大问题,因此这个原则再次不适用。如果适配器从适配器继承,目标是改变其接口,而不是其行为,这应该符合 LSP。
-
I:我们可以将适配器(Adapter)类视为 ISP 的促进者,以目标(Target)接口作为最终目的地。适配器模式依赖于目标接口的设计,但并不直接影响它。根据这一原则,我们的主要焦点应该是以遵守 ISP 的方式设计目标接口。
-
D:适配器模式仅引入了目标接口的一个实现。即使适配器依赖于一个具体类,它通过将其适配到目标接口来打破对该外部组件的直接依赖。
接下来,我们将探索本章的最后一个结构型模式,它教授基础概念。
实现外观设计模式
外观模式是一种结构型模式,它简化了对复杂系统的访问。它与适配器模式非常相似,但它在一或多个子系统之间创建了一堵墙(外观)。适配器和外观之间的主要区别在于,外观不是将一个接口适配到另一个接口,而是通过使用该子系统的多个类来简化子系统的使用。
我们可以将同样的想法应用于保护一个或多个程序,但在这个情况下,我们将外观称为网关——更多内容请参阅第十九章,“微服务架构简介”。
外观模式非常实用,并且可以适应多种情况。
目标
外观模式旨在通过提供一个比子系统本身更容易使用的接口来简化一个或多个子系统的使用,从而保护消费者免受这种复杂性。
设计
想象一个拥有众多复杂类的系统。由于耦合、复杂性和代码的可读性和可维护性低,直接在消费代码和这些类之间进行交互可能会变得有问题。外观设计模式通过提供一个统一的接口来访问子系统中的一组 API,从而提供了一种解决方案,使得使用更加容易。外观类包含对复杂子系统对象的引用,并将客户端请求委派给适当的子系统对象。从客户端的角度来看,它只与外观表示的单个简化接口进行交互。幕后,外观与子系统的组件协调以满足客户端的请求。我们可以创建多个表示众多子系统的图表,但让我们保持简单。记住,你可以将以下图表中显示的单个子系统替换为你需要适配的任意数量的子系统:

图 11.15:表示隐藏复杂子系统的外观对象的类图
外观(Façade)在客户端和子系统之间扮演着中介的角色,简化了其使用。让我们通过一个时序图来观察这一过程:

图 11.16:表示 Façade 对象与复杂子系统交互的序列图
在前面的图中,客户端调用一次 Façade,而 Façade 对不同的类进行了多次调用。实现外观有多种方式:
-
不透明外观: 在这种形式中,
Façade类位于子系统内部。子系统中的所有其他类都有一个internal可见性修饰符。这样,只有子系统内部的类可以与其他内部类交互,迫使消费者使用Façade类。 -
透明外观: 在这种形式中,类可以有一个
public修饰符,允许消费者直接使用它们,或者使用Façade类。这样,我们可以在子系统内部或外部创建Façade类。 -
静态外观: 在这种形式中,
Façade类是static的。我们可以将静态外观实现为不透明或透明。
我建议将静态外观作为最后的手段,因为
static元素限制了灵活性并降低了可测试性。
接下来我们看看一些代码。
项目 – 外观
在这个例子中,我们玩以下 C# 项目:
-
OpaqueFacadeSubSystem类库展示了 不透明外观。 -
TransparentFacadeSubSystem类库展示了 透明外观。 -
Facade项目是一个消耗外观的 REST API。它公开了两个端点以访问OpaqueFacadeSubSystem项目,还有两个端点针对TransparentFacadeSubSystem项目。
让我们从类库开始。
为了遵循 SOLID 原则,添加一些代表子系统元素的接口似乎是合适的。在随后的章节中,我们将探讨如何组织我们的抽象以使其更具可重用性,但到目前为止,抽象和实现都在同一个程序集中。
不透明外观
在这个程序集中,只有外观是公开的;所有其他类都是 internal,这意味着它们对外部世界是隐藏的。在大多数情况下,这并不是理想的;隐藏一切使得子系统更不灵活,更难扩展。然而,在某些情况下,您可能希望控制对内部 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类直接与ComponentA、ComponentB和ComponentC耦合。由于子系统本身不可扩展,提取任何internal接口都没有意义。我们本可以这样做以提供某种内部灵活性,但在这个情况下,这样做没有优势。除了这种耦合之外,ComponentA、ComponentB和ComponentC各自定义了两个方法,这些方法返回一个描述其来源的字符串。有了这些代码,我们可以观察正在发生的事情以及最终结果是如何组合的。OpaqueFacade还公开了两个方法,每个方法都使用底层子系统的组件组合不同的消息。这是外观的经典用法;外观以更多或更少的复杂性查询其他对象,然后对结果进行操作,从而减轻调用者了解子系统的负担。由于成员使用internal可见性修饰符,我们无法直接从程序中将依赖关系注册到 IoC 容器中。为了解决这个问题,子系统可以通过添加扩展方法来注册其依赖关系。以下扩展方法对消费应用程序是可访问的:
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;
}
}
上一段代码手动创建了依赖关系,并将绑定添加到IOpaqueFacade接口,以便系统可以使用它。这样,除了接口之外,所有内容都从消费者那里隐藏起来。在探索 REST API 之前,我们来看看透明外观的实现。
透明外观
透明外观是最灵活的外观类型,非常适合利用依赖注入的系统。实现方式与不透明外观类似,但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;
}
}
所有新的元素都已消失,并被简单的依赖注册(在这种情况下是单例生命周期)所取代。这些小小的差异为我们提供了工具,如果我们想的话,可以重新实现子系统的任何部分,正如我们很快就会看到的。
我们可以在透明外观扩展方法中注册绑定,因为类和接口都是
public的。容器需要一个公共构造函数来完成其工作。在不透明门面中,我们必须将
OpaqueFacade类的构造函数定义为internal,因为其参数的类型(ComponentA、ComponentB和ComponentC)是internal,这使得无法利用容器。将不透明门面构造函数的可见性修饰符从internal更改为public将产生一个CS0051 可访问性不一致错误。
除了这些差异之外,透明门面与不透明门面扮演着相同的作用,输出相同的结果。接下来,我们将消费这两个门面。
程序
现在,让我们分析消费者,这是一个将 HTTP 请求转发到门面并返回其响应的 ASP.NET Core 应用程序。第一步是注册依赖项,如下所示:
var builder = WebApplication.CreateBuilder(args);
builder.Services
.AddOpaqueFacadeSubSystem()
.AddTransparentFacadeSubSystem()
;
使用这些扩展方法,应用程序的根目录如此干净,以至于很难知道我们针对 IoC 容器注册了两个子系统。这是一种保持代码组织良好和干净的好方法,尤其是在构建类库时。
现在一切都已经注册,我们需要做的第二件事是将这些 HTTP 请求路由到门面。让我们首先看看代码:
var app = builder.Build();
app.MapGet(
"/opaque/a",
(IOpaqueFacade opaqueFacade)
=> opaqueFacade.ExecuteOperationA()
);
app.MapGet(
"/opaque/b",
(IOpaqueFacade opaqueFacade)
=> opaqueFacade.ExecuteOperationB()
);
app.MapGet(
"/transparent/a",
(ITransparentFacade transparentFacade)
=> transparentFacade.ExecuteOperationA()
);
app.MapGet(
"/transparent/b",
(ITransparentFacade transparentFacade)
=> transparentFacade.ExecuteOperationB()
);
app.Run();
在前面的代码块中,我们定义了四个路由。每个路由使用注入在其委托中的门面将请求调度到门面的一个方法(突出显示的代码)。如果你运行程序并导航到/transparent/a端点,页面应该显示以下内容:
Component A, Operation A
Component A, Operation B
Component B, Operation D
Component C, Operation E
发生的事情位于委托内部。它使用注入的ITransparentFacade服务并调用其ExecuteOperationA()方法,然后将result变量输出到响应流。现在,让我们定义ITransparentFacade是如何组成的:
-
ITransparentFacade是TransparentFacade的一个实例。 -
我们在
TransparentFacade类中注入IComponentA、IComponentB和IComponentC。 -
这些依赖项分别是
ComponentA、ComponentB和ComponentC的实例。
从视觉上看,以下流程发生:

图 11.17:消费者执行 ExecuteOperationA 方法时出现的调用层次结构表示
在前面的图中,我们可以看到门面(facade)所执行的屏蔽作用以及它是如何使消费者的生活变得更简单的:一次调用而不是四次。
使用依赖注入最难的部分之一是其抽象性。如果你不确定所有这些部分是如何组装的,在 Visual Studio 中添加一个断点(比如说,在
var result = transparentFacade.ExecuteOperationA()这一行)并以调试模式运行应用程序。从那里,单步进入每个方法调用。这应该有助于你弄清楚发生了什么。使用调试器查找具体类型及其状态可以帮助找到有关系统或诊断错误的详细信息。要使用单步进入,你可以使用以下按钮或按F11:

图 11.18:Visual Studio 的 Step Into(F11)按钮
调用其他端点会导致类似的结果。作为参考,这里是从其他端点得到的结果。这是 /transparent/b 端点得到的结果:
Component B, Operation C
Component B, Operation D
Component C, Operation F
这是 /opaque/a 端点得到的结果:
Component A, Operation A
Component A, Operation B
Component B, Operation D
Component C, Operation E
这是 /opaque/b 端点得到的结果:
Component B, Operation C
Component B, Operation D
Component C, Operation F
接下来,让我们更新一些结果,而不改变组件的代码。
灵活性在行动
正如讨论的那样,透明外观增加了更多的灵活性。在这里,我们探索这种灵活性在实际中的应用。上下文:我们想要改变 TransparentFacade 类的行为。目前,transparent/b 端点得到的结果如下:
Component B, Operation C
Component B, Operation D
Component C, Operation F
为了展示我们可以扩展和改变子系统而不改变它,让我们将输出更改为以下内容:
Flexibility
Design Pattern
Component C, Operation F
因为 ComponentB 类提供了前两行,我们必须用 IComponentB 接口的新实现来替换它。让我们称这个类为 UpdatedComponentB:
using TransparentFacadeSubSystem.Abstractions;
namespace Facade;
public class UpdatedComponentB : IComponentB
{
public string OperationC() => "Flexibility";
public string OperationD() => "Design Pattern";
}
上述代码正好做了我们想要的事情。然而,我们必须像这样告诉 IoC 容器:
builder.Services
.AddOpaqueFacadeSubSystem()
.AddTransparentFacadeSubSystem()
.AddSingleton<IComponentB, UpdatedComponentB>()
;
如果你运行程序,你应该能看到期望的结果!
第二次添加依赖会使容器解析该依赖,从而覆盖第一个。然而,两个注册都保留在服务集合中;例如,在
IServiceProvider上调用GetServices<IComponentB>()会返回两个依赖。不要混淆GetServices()和GetService()方法(复数与单数);一个返回一个集合,而另一个返回一个单一实例。这个单一实例总是最后注册的那个。
就这样!我们没有修改系统就更新了它。这就是围绕它设计程序时依赖注入能为你做到的。
交替的外观模式
一种替代方案是创建一个介于透明外观和不透明外观之间的混合外观,通过使用public可见性修饰符(所有接口)暴露抽象,同时将实现隐藏在internal可见性修饰符之下。这种混合设计在控制和灵活性之间提供了正确的平衡。另一种替代方案是创建子系统之外的的外观。在之前的例子中,我们在类库内部创建了外观,但这不是强制性的;外观只是一个创建系统与一个或多个子系统之间可访问墙的类。它应该位于你认为合适的位置。创建这样的外部外观特别有用,当你不控制子系统(例如,你只能访问二进制文件)的源代码时。这也可以用来在相同的子系统上创建项目特定的外观,这为你提供了额外的灵活性,而不会使你的子系统因多个外观而变得杂乱,将维护成本从子系统转移到使用它们的客户端应用程序。这一点更像是一个注释而不是替代方案:你不需要为每个子系统创建一个程序集。我这样做是因为它有助于我在例子中解释不同的概念,但你可以在同一个程序集中创建多个子系统。你甚至可以创建一个包含所有子系统、外观和客户端代码(所有都在一个项目中)的单个程序集。
无论是在谈论子系统还是 REST API,分层 API 是创建原子但难以使用的基础功能的一种优秀方式,同时通过外观提供高级 API 来访问它们,从而提升用户体验。
结论
外观模式对于简化用户生活非常有用,它允许我们通过一堵墙隐藏子系统的实现细节。它有多种变体;其中最突出的是:
-
透明外观,通过暴露至少部分子系统来增加灵活性
-
不透明外观,通过隐藏大多数子系统来控制访问
现在,让我们看看透明外观模式如何帮助我们遵循SOLID原则:
-
S:一个设计良好的透明外观通过隐藏过于复杂的子系统或内部实现细节,向其用户提供一组连贯的功能,从而实现这一目的。
-
O:一个设计良好的透明外观及其底层子系统的组件可以在不直接修改的情况下进行扩展,正如我们在灵活性实践部分所看到的。
-
L:N/A
-
I:通过暴露使用不同较小对象实现小型接口的外观,我们可以说这种隔离是在外观和组件层面同时进行的。
-
D:外观模式没有指定任何关于接口的内容,因此开发者必须通过使用其他模式、原则和最佳实践来强制执行此原则。
最后,让我们看看不透明的外观模式如何帮助我们遵循SOLID原则:
-
S:一个设计良好的不透明外观通过提供一组功能一致的服务给其客户端,通过隐藏过于复杂的子系统或内部实现细节来实现这一目的。
-
O:通过隐藏子系统,不透明的外观限制了我们的扩展能力。然而,我们可以实现一个混合外观来帮助解决这个问题。
-
L:N/A
-
I:不透明的外观并不能帮助或减少我们应用 ISP(接口隔离原则)的能力。
-
D:外观模式没有指定任何关于接口的内容,因此开发者必须通过使用其他模式、原则和最佳实践来强制执行此原则。
摘要
在本章中,我们介绍了多个 GoF(设计模式)的基本结构设计模式。它们帮助我们在不修改实际类的情况下从外部扩展我们的系统,通过动态组合我们的对象图,从而实现更高的内聚度。我们首先介绍了装饰者模式,这是一种强大的工具,允许我们在不改变对象原始代码的情况下动态地向对象添加新功能。装饰者也可以链式使用,从而提供更大的灵活性(装饰其他装饰者)。我们了解到这种模式遵循开闭原则,并促进了责任分离到更小、更易于管理的部分。我们还使用了一个名为 Scrutor 的开源工具,通过扩展内置的 ASP.NET Core 依赖注入系统来简化装饰者模式的使用。然后,我们介绍了组合模式,它允许我们以最小的努力创建复杂、非线性、自我管理的数据结构。这种组群和单个组件无法区分的分层数据结构使得层次结构的遍历和处理更加容易。我们使用这种模式来构建具有自我管理节点的图或树。之后,我们介绍了适配器模式,它允许两个不兼容的接口在不修改其代码的情况下协同工作。当我们需要适应我们无法控制、不想改变或无法改变的外部系统的组件时,这种模式非常有用。最后,我们深入探讨了外观模式,它与适配器模式类似,但处于子系统级别。它允许我们在一个或多个子系统前面创建一个墙,简化其使用。它也可以用来隐藏子系统对消费者的实现细节。下一章将探讨两个 GoF 行为设计模式:模板方法和责任链设计模式。
问题
这里有一些复习问题:
-
装饰者模式的主要优势是什么?
-
我们能否用另一个装饰者装饰一个装饰者?
-
组合设计模式的主要目标是什么?
-
我们能否使用适配器模式将旧 API 迁移到新系统,以便在重写之前适配其 API?
-
适配器模式的主要责任是什么?
-
适配器模式和外观模式之间的区别是什么?
-
不透明的外观模式和透明的外观模式的主要区别是什么?
进一步阅读
- 要了解更多关于 Scrutor 的信息,请访问
adpg.link/xvfS
答案
-
装饰器模式允许我们在运行时动态地向对象添加新功能,而无需修改其原始代码,从而提高灵活性、可测试性和可管理性。
-
是的,我们可以通过仅依赖于接口来装饰装饰器,因为它们只是接口的另一种实现,没有更多。
-
组合设计模式旨在通过将单个和组元素视为不可区分的来简化处理复杂结构。
-
是的,我们可以使用适配器。
-
适配器模式的主要责任是将一个接口适配到另一个接口,该接口直接使用时是不兼容的。
-
适配器模式和外观模式几乎相同,但它们应用于不同的场景。适配器模式将一个 API 适配到另一个 API,而外观模式则暴露一个统一或简化的 API,隐藏一个或多个复杂的子系统。
-
不透明的外观模式隐藏了大部分子系统(
内部可见性),控制对其的访问,而透明的外观模式至少暴露了部分子系统(公共可见性),增加了灵活性。
第十二章:12 行为模式
在开始之前:加入我们的 Discord 书社区
直接向作者提供反馈,并在我们的 Discord 服务器上与其他早期读者聊天(在“architecting-aspnet-core-apps-3e”频道下找到“EARLY ACCESS SUBSCRIPTION”)。

本章探讨了来自知名四人帮(GoF)的两种新的设计模式。它们是行为模式,意味着它们有助于简化系统行为管理。通常,我们需要封装一些核心算法,同时允许其他代码片段扩展该实现。这就是模板方法模式发挥作用的地方。有时,我们有一个复杂的过程,包含多个算法,这些算法都适用于一个或多个情况,我们需要以可测试和可扩展的方式组织它们。这就是责任链模式能提供帮助的地方。例如,ASP.NET Core 中间件管道就是一个责任链,其中所有中间件都会检查并处理请求。在本章中,我们将涵盖以下主题:
-
实现模板方法模式
-
实现责任链模式
-
混合模板方法和责任链模式
实现模板方法模式
模板方法是 GoF 行为模式,它使用继承在基类及其子类之间共享代码。这是一个非常强大且简单的设计模式。
目标
模板方法模式的目标是在基类中封装算法的轮廓,同时将算法的某些部分留给子类进行修改,这以低成本增加了灵活性。
设计
首先,我们需要定义一个包含TemplateMethod方法的基类,然后定义一个或多个子操作,这些操作需要由其子类(abstract)实现,或者可以被覆盖(virtual)。使用 UML,它看起来像这样:

图 12.1:表示模板方法模式的类图
这是如何工作的?
-
AbstractClass实现了共享代码:在TemplateMethod方法中的算法。 -
ConcreteClass在其继承的Operation方法中实现算法的特定部分。 -
Client调用TemplateMethod(),它调用子类实现的一个或多个特定算法元素(在本例中是Operation方法)。
我们还可以从
AbstractClass中提取一个接口,以提供更大的灵活性,但这超出了模板方法模式的范围。
让我们现在来看看一些代码,以了解模板方法模式是如何工作的。
项目 – 构建搜索机
让我们从简单的经典示例开始,以展示模板方法模式是如何工作的。上下文:根据集合的不同,我们希望使用不同的搜索算法。对于有序集合,我们希望使用二分搜索,但对于未排序的集合,我们希望使用线性搜索。让我们从消费者开始,它是 Program.cs 文件中的一个 REST 端点,返回 plain/text 结果:
var builder = WebApplication.CreateBuilder(args);
builder.Services
.AddSingleton<SearchMachine>(x
=> new LinearSearchMachine(1, 10, 5, 2, 123, 333, 4))
.AddSingleton<SearchMachine>(x
=> new BinarySearchMachine(1, 2, 3, 4, 5, 6, 7, 8, 9, 10))
;
var app = builder.Build();
app.MapGet("/", (IEnumerable<SearchMachine> searchMachines) =>
{
var sb = new StringBuilder();
var elementsToFind = new int[] { 1, 10, 11 };
foreach (var searchMachine in searchMachines)
{
var typeName = searchMachine.GetType().Name;
var heading = $"Current search machine is {typeName}";
sb.AppendLine("".PadRight(heading.Length, '='));
sb.AppendLine(heading);
foreach (var value in elementsToFind)
{
var index = searchMachine.IndexOf(value);
var wasFound = index.HasValue;
if (wasFound)
{
sb.AppendLine($"The element '{value}' was found at index {index!.Value}.");
}
else
{
sb.AppendLine($"The element '{value}' was not found.");
}
}
}
return sb.ToString();
});
app.Run();
如前几行所强调的,我们将 LinearSearchMachine 和 BinarySearchMachine 配置为两个 SearchMachine 实现。我们使用不同的数字序列初始化每个实例。之后,我们将所有注册的 SearchMachine 服务注入到端点(代码块中突出显示)。该处理程序迭代所有 SearchMachine 实例,在输出 text/plain 结果之前尝试找到 elementsToFind 数组中的所有元素。接下来,让我们探索 SearchMachine 类:
namespace TemplateMethod;
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)
{
if (Values.Length == 0) { return null; }
var result = Find(value);
return result;
}
protected abstract int? Find(int value);
}
SearchMachine 类代表 AbstractClass。它公开了 IndexOf 模板方法,该方法使用由 abstract Find 方法表示的所需钩子(见突出显示的代码)。钩子是必需的,因为每个子类都必须实现该方法,从而使该方法成为一个必需的扩展点(或钩子)。接下来,我们探索我们的第一个 ConcreteClass 实现,即 LinearSearchMachine 类:
namespace TemplateMethod;
public class LinearSearchMachine : SearchMachine
{
public LinearSearchMachine(params int[] values)
: base(values) { }
protected override int? Find(int value)
{
for (var i = 0; i < Values.Length; i++)
{
if (Values[i] == value) { return i; }
}
return null;
}
}
LinearSearchMachine 类是一个表示 SearchMachine 使用的线性搜索实现的 ConcreteClass。它通过其 Find 方法贡献了 IndexOf 算法的一部分。最后,我们继续到 BinarySearchMachine 类:
namespace TemplateMethod;
public class BinarySearchMachine : SearchMachine
{
public BinarySearchMachine(params int[] values)
: base(values.OrderBy(v => v).ToArray()) { }
protected override int? Find(int value)
{
var index = Array.BinarySearch(Values, value);
return index < 0 ? null : index;
}
}
BinarySearchMachine 类是一个表示 SearchMachine 的二分搜索实现的 ConcreteClass。如您所注意到的,我们通过委托给内置的 Array.BinarySearch 方法来跳过了二分搜索算法的实现。感谢 .NET 团队!
二分查找算法需要一个有序集合来工作;因此,在将值传递给基类(
OrderBy)时,构造函数中执行的排序可能是确保数组排序(先决条件/保护)的最不高效方式,但它是一种快速编写且易于阅读的方式来编写它。此外,在我们的情况下,性能并不是问题。如果您必须优化这样的算法以与大数据集一起工作,您可以使用并行性(多线程)来提供帮助。无论如何,运行适当的基准测试以确保您优化了正确的事情,并评估您的实际收益。如果您正在查看基准测试 .NET 代码,请查看 BenchmarkDotNet(
adpg.link/C5E9)。
现在我们已经定义了演员并探讨了代码,让我们看看我们的消费者(Client)中发生了什么:
-
Client使用注册的SearchMachine实例搜索值 1、10 和 11。 -
之后,
Client向用户显示数字是否被找到。
当Find方法找不到值时返回null,通过扩展,IndexOf方法也是如此。运行程序后,我们得到以下输出:
=============================================
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.
前面的输出显示了正在运行的两种算法。两个SearchMachine实现都没有包含值11。它们都包含了值1和10,但位置不同。以下是值的提醒:
new LinearSearchMachine(1, 10, 5, 2, 123, 333, 4)
new BinarySearchMachine(1, 2, 3, 4, 5, 6, 7, 8, 9, 10)
消费者正在迭代与 IoC 容器注册的SearchMachine。基类实现了IndexOf,但将搜索(Find)算法委托给子类。前面的输出显示每个SearchMachine只需通过实现算法的Find部分就能执行预期的任务。就这样!我们已经涵盖了模板方法模式,就像那样简单。当然,我们的算法是微不足道的,但概念仍然存在。为了确保实现的正确性,我还为每个类创建了两项测试。以下是LinearSearchMachine类的测试:
namespace TemplateMethod;
public class LinearSearchMachineTest
{
public class IndexOf
{
[Theory]
[InlineData(1, 0)]
[InlineData(2, 4)]
[InlineData(3, 2)]
[InlineData(7, null)]
public void Should_return_the_expected_result(
int value, int? expectedIndex)
{
// Arrange
var sorter = new LinearSearchMachine(1, 5, 3, 4, 2);
// Act
var index = sorter.IndexOf(value);
// Assert
Assert.Equal(expectedIndex, index);
}
}
}
前面的测试确保通过LinearSearchMachine类的IndexOf方法找到或未找到正确的值。接下来是对BinarySearchMachine类的一个类似测试:
namespace TemplateMethod;
public class BinarySearchMachineTest
{
public class IndexOf
{
[Theory]
[InlineData(1, 0)]
[InlineData(8, 5)]
[InlineData(3, 2)]
[InlineData(7, null)]
public void Should_return_the_expected_result(int value, int? expectedIndex)
{
// Arrange
var sorter = new BinarySearchMachine(1, 2, 3, 4, 5, 8);
// Act
var index = sorter.IndexOf(value);
// Assert
Assert.Equal(expectedIndex, index);
}
}
}
前面的测试确保通过BinarySearchMachine类的IndexOf方法找到或未找到正确的值。
我们可以在基类中添加
virtual方法来创建可选的钩子。这些方法将成为可选的扩展点,子类可以选择实现或不实现。这将允许支持更复杂和更通用的场景。我们在这里不会涵盖这一点,因为它不是模式本身的一部分,即使非常相似。在.NET 基类库(BCL)中有许多例子,比如ComponentBase类的大多数方法(在Microsoft.AspNetCore.Components命名空间中)。例如,当在 Blazor 组件中覆盖OnInitialized方法时,我们利用了一个可选的扩展钩子。基方法不执行任何操作,仅用于扩展目的,允许我们在组件的生命周期中运行代码。您可以在 GitHub 上的官方.NET 仓库中查看ComponentBase类的代码:adpg.link/1WYq。
这结束了我们对另一个简单而强大的设计模式的研究。
结论
模板方法是一种强大且易于实现的设计模式,允许子类在实现(抽象)或覆盖(虚拟)子部分的同时重用算法的骨架。它允许特定实现的类扩展核心算法。它可以减少逻辑的重复,提高可维护性,同时不减少过程中的任何灵活性。在.NET BCL 中有许多例子,我们根据一个真实世界的场景在章节末尾利用了这个模式。现在,让我们看看模板方法模式如何帮助我们遵循SOLID原则:
-
S:模板方法将算法特定的代码部分推送到子类,同时保持核心算法在基类中。这样做通过分配责任来帮助遵循单一职责原则(SRP)。
-
O:通过打开扩展钩子,它打开扩展的模板(允许子类扩展它)并关闭修改(不需要修改基类,因为子类可以扩展它)。
-
L:只要子类是实现并且没有改变基类本身,遵循里氏替换原则(LSP)应该不会有问题。然而,这个原则很棘手,所以有可能打破它;例如,通过抛出新的异常类型或以改变更复杂基类状态的方式改变其行为。
-
I:只要基类实现了可能的最小内聚表面,使用模板方法模式不应该对程序产生负面影响。此外,在类中拥有较小的接口表面减少了违反里氏替换原则(LSP)的机会。
-
D:模板方法模式基于一个抽象,因此只要消费者依赖于这个抽象,它应该有助于符合依赖倒置原则(DIP)。
接下来,我们在混合模板方法和责任链模式之前,转向责任链设计模式,以改进我们的代码。
实现责任链模式
责任链是 GoF 行为模式,通过链式连接类来高效处理复杂场景,同时投入有限的努力。再次强调,目标是将复杂问题分解成多个更小的单元。
目标
责任链模式旨在链式连接多个处理者,每个处理者解决有限数量的问题。如果一个处理者不能解决特定的问题,它将解决方案传递给链中的下一个处理者。
我们通常创建一个默认处理者,作为终端处理者在链的末尾执行逻辑。这样的处理者可以抛出一个异常(例如,
OperationNotHandledException),将问题级联到调用堆栈中的消费者,该消费者知道如何处理和响应它。另一种策略是创建一个终端处理者,执行相反的操作并确保不发生任何事情。
设计
最基本的责任链模式首先通过定义一个处理请求的接口(IHandler)开始。然后我们添加处理一个或多个场景的类(Handler1 和 Handler2):

图 12.2:表示责任链模式的类图
责任链模式与其他许多模式的不同之处在于,没有中央调度器知道处理器;所有处理器都是独立的。消费者接收一个处理器并指示它处理请求。每个处理器决定它是否能够处理请求。如果可以,它就处理它。在两种情况下,它还会评估是否应该将请求转发到链中的下一个处理器。处理器可以以任何顺序执行这两个任务,比如执行一些逻辑,将请求沿链向下传递,然后在请求返回时执行更多逻辑(就像一个管道)。这种模式允许我们将复杂的逻辑分解成多个处理单个职责的部分,从而在过程中提高可测试性、可重用性和可扩展性。由于不存在协调器,每个链元素都是独立的,这导致了一个统一且松散耦合的设计。
在创建责任链时,你可以对处理器进行排序,使得最常请求的处理器靠近链的开始,而最少请求的处理器靠近链的末端。这有助于限制在到达正确的处理器之前,每个请求需要访问的“链链接”数量。
理论已经足够了;让我们看看一些代码。
项目 – 消息解释器
上下文:我们需要创建一个消息应用接收端,其中每个消息都是唯一的,这使得无法创建一个处理所有消息的单一代码。在分析问题后,我们决定构建一个责任链,其中每个处理器可以管理一个单独的消息。这个模式似乎非常完美!
这个项目基于我多年前构建的东西。由于带宽有限,物联网设备正在发送字节(消息)。然后,我们必须在 Web 应用中将这些字节与真实值关联起来。每个消息都有一个固定的头部大小,但有一个可变的消息体大小。头部由基本处理器(模板方法)处理,链中的每个处理器管理不同的消息类型。对于当前示例,我们将其简化为解析字节,但概念是相同的。
对于我们的演示应用,消息就像这样简单:
namespace ChainOfResponsibility;
public record class Message(string Name, string? Payload);
Name 属性用作区分消息的判别器,每个处理器负责对 Payload 属性进行一些操作。
我们不会对有效载荷做任何处理,因为它与模式无关,但从概念上讲,这是应该发生的逻辑。
处理器非常简单,以下是其接口:
namespace ChainOfResponsibility;
public interface IMessageHandler
{
void Handle(Message message);
}
处理器唯一能做的就是处理消息。我们的初始应用可以处理以下消息:
-
AlarmTriggeredHandler类处理AlarmTriggered消息。 -
AlarmPausedHandler类处理AlarmPaused消息。 -
AlarmStoppedHandler类处理AlarmStopped消息。
现实世界的逻辑是,一台机器可以向 REST API 发送一个警报,表明它已经达到某个阈值。然后 REST API 可以将该信息推送到 UI,发送电子邮件、短信等。
被警告的人可以在调查问题时暂停警报,这样其他人就知道警报正在被处理。
最后,一个人可以前往物理设备并停止警报,因为问题已经得到解决。
我们可以对许多更多的子场景进行外推,但这只是大概。
这三个处理程序非常相似,并且共享相当多的逻辑,但我们稍后会解决这个问题。同时,我们有以下处理程序:
namespace ChainOfResponsibility;
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 clever with the Payload
}
else
{
_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 clever with the Payload
}
else
{
_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 clever with the Payload
}
else
{
_next?.Handle(message);
}
}
}
每个处理程序做两件事:
-
它从其构造函数(在代码中高亮显示)接收一个可选的“下一个处理程序”。这创建了一个类似于单链表的链。
-
它只处理它所知的请求,将其他请求委托给链中的下一个处理程序。
让我们使用Program.cs作为责任链(客户端)的消费者,并使用 POST 请求与我们的 REST API 接口并构建消息。以下是我们的 REST API 的第一部分:
var builder = WebApplication.CreateBuilder(args);
builder.Services.AddSingleton<IMessageHandler>(
new AlarmTriggeredHandler(
new AlarmPausedHandler(
new AlarmStoppedHandler())));
在前面的代码中,我们手动创建责任链并将其注册为绑定到IMessageHandler接口的单例。在注册代码中,每个处理程序都是手动注入到前一个构造函数(使用new关键字创建)中的。下面的代码代表了Program.cs文件的第二部分:
var app = builder.Build();
app.MapPost(
"/handle",
(Message message, IMessageHandler messageHandler) =>
{
messageHandler.Handle(message);
return $"Message '{message.Name}' handled successfully.";
});
app.Run();
消费端点可通过/handle URL 访问,并期望其体中包含一个Message对象。然后它使用注入的IMessageHandler接口的实现,并将消息传递给它。如果我们运行ChainOfResponsibility.http文件中的任何 HTTP 请求,我们会得到一个类似以下的成功结果:
Message 'AlarmTriggered' handled successfully.
问题在于,即使我们发送一个无效的消息,消费者也无法知道,所以即使没有处理程序拾取消息,它仍然是有效的。为了处理这种情况,让我们添加一个第四个处理程序(终端处理程序),它通知消费者有关无效请求的信息:
public class DefaultHandler : IMessageHandler
{
public void Handle(Message message)
{
throw new NotSupportedException(
$"Messages named '{message.Name}' are not supported.");
}
}
这个新的终端处理程序会抛出一个异常,通知消费者有关错误。
我们可以创建自定义异常,以便更容易地区分系统和应用程序错误。在这种情况下,抛出一个系统异常就足够了。在现实世界的应用程序中,我建议创建一个自定义异常,它代表链的末端,并包含消费者根据您的用例对其做出反应的相关信息。
接下来,让我们在我们的链中注册它(高亮显示):
builder.Services.AddSingleton<IMessageHandler>(
new AlarmTriggeredHandler(
new AlarmPausedHandler(
new AlarmStoppedHandler(
new DefaultHandler()
))));
如果我们发送一个名为SomeUnhandledMessageName的 POST 请求,端点现在会返回以下异常:
System.NotSupportedException: Messages named 'SomeUnhandledMessageName' are not supported.
at ChainOfResponsibility.DefaultHandler.Handle(Message message) in C12\src\ChainOfResponsibility\DefaultHandler.cs:line 7
at ChainOfResponsibility.AlarmStoppedHandler.Handle(Message message) in C12\src\ChainOfResponsibility\AlarmStoppedHandler.cs:line 19
at ChainOfResponsibility.AlarmPausedHandler.Handle(Message message) in C12\src\ChainOfResponsibility\AlarmPausedHandler.cs:line 19
at ChainOfResponsibility.AlarmTriggeredHandler.Handle(Message message) in C12\src\ChainOfResponsibility\AlarmTriggeredHandler.cs:line 19
at Program.<>c.<<Main>$>b__0_0(Message message, IMessageHandler messageHandler) in C12\src\ChainOfResponsibility\Program.cs:line 22
at lambda_method1(Closure, Object, HttpContext, Object)
at Microsoft.AspNetCore.Http.RequestDelegateFactory.<>c__DisplayClass100_2.<<HandleRequestBodyAndCompileRequestDelegateForJson>b__2>d.MoveNext()
--- End of stack trace from previous location ---
at Microsoft.AspNetCore.Routing.EndpointMiddleware.<Invoke>g__AwaitRequestTask|6_0(Endpoint endpoint, Task requestTask, ILogger logger)
at Microsoft.AspNetCore.Diagnostics.DeveloperExceptionPageMiddlewareImpl.Invoke(HttpContext context)
HEADERS
=======
Host: localhost:10001
Content-Type: application/json
traceparent: 00-5d737fdbb1018d5b7d060b74baf26111-2805f137fe1541af-00
Content-Length: 77
到目前为止,一切顺利,但体验并不好,所以让我们在端点中添加一个 try-catch 块来处理这种情况:
app.MapPost(
"/handle",
(Message message, IMessageHandler messageHandler) =>
{
try
{
messageHandler.Handle(message);
return $"Message '{message.Name}' handled successfully.";
}
catch (NotSupportedException ex)
{
return ex.Message;
}
});
现在,当我们发送一个无效的消息时,API 会温和地返回以下消息给我们:
Messages named 'SomeUnhandledMessageName' are not supported.
当然,当你期望机器消费你的 API 时,你应该生成一个更容易解析的数据结构,比如使用 JSON。
哇,我们已经构建了一个简单的责任链来处理消息。
结论
责任链模式是另一个优秀的 GoF 模式。它将一个大问题分解成更小、更紧密的单元,每个单元只做一项工作:处理其特定的请求。现在,让我们看看责任链模式如何帮助我们遵循SOLID原则:
-
S:责任链模式旨在遵循这个确切的原则:每个类创建一个逻辑单元!
-
O:责任链模式允许在不接触代码的情况下通过改变链的组成在组合根中添加、删除和重新排序处理器。
-
L:N/A
-
I:如果我们创建一个小的接口,责任链模式有助于 ISP。处理器接口不仅限于一个方法;它可以公开多个。
-
D:通过使用处理器接口,链中的任何元素,以及消费者,都不依赖于特定的处理器;它们只依赖于代表链的接口,这有助于反转依赖流。
接下来,让我们使用模板方法和责任链模式来封装我们处理器的重复逻辑。
混合模板方法和责任链模式
本节探讨了两种强大的设计模式的组合:模板方法和责任链模式。正如我们即将探讨的,这两个模式配合得很好。我们使用模板方法模式作为基本结构,提供处理器的蓝图。同时,责任链模式管理处理顺序,确保每个请求都被路由到正确的处理器。当这两个模式协同工作时,它们形成了一个健壮的框架,便于管理,保持秩序,并提高我们系统的适应性。
项目 - 改进的消息解释器
既然我们已经了解了责任链和模板方法模式,现在是时候通过使用模板方法模式将共享逻辑提取到抽象基类中,并为子类提供扩展点来“DRY”我们的处理器了。好的,那么哪些逻辑是重复的?
-
除了终端处理器外,
next处理器注入代码都是相同的。此外,这是我们应该在基类中封装的模式的一个重要部分。 -
逻辑测试当前处理器是否可以处理消息,除了终端处理器外,都是相同的。
让我们创建一个新的基类,该基类实现了模板方法模式以及我们责任链的大部分逻辑:
namespace ImprovedChainOfResponsibility;
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);
}
}
[MemberNotNullWhen(true, nameof(_next))]
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类公开以下扩展点:
-
CanHandle方法测试HandledMessageName是否等于message.Name属性的值。如果子类需要不同的比较逻辑,则可以重写此方法。这是一个可选的钩子。 -
所有子类都必须实现
HandledMessageName属性,这是CanHandle方法的关键驱动因素。这是一个强制性的钩子。 -
所有子类都必须实现
Process方法,允许它们在消息上运行它们的逻辑。这是一个强制性的钩子。
要了解这些钩子是如何发挥作用的,让我们看看三个简化的警报处理器:
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 现在处理责任链的管道。我们保留了DefaultHandler 类的原始状态,因为它位于链的末端,不支持有下一个处理器,也不处理消息。将这两种模式混合在一起创建了一个复杂的信息系统,将责任划分到处理器中。每个消息都有一个处理器,并将链逻辑推入基类。这种系统的美妙之处在于我们不必同时考虑所有消息;我们可以一次专注于一个消息。当处理一种新的消息类型时,我们可以专注于那个特定的消息,实现其处理器,并忘记其他 N 种类型。消费者也可以非常简单,将请求发送到管道中,而不了解责任链,就像魔法一样,正确的处理器将占上风!然而,你是否注意到了这个设计的问题?让我们在下一节中看看。
项目 – 一个最终更细粒度的设计
在最后一个例子中,我们使用了HandledMessageName和CanHandle来决定一个处理器是否可以处理一个请求。这个代码有一个问题:如果子类决定重写CanHandle,然后决定它不再需要HandledMessageName,我们最终会在系统中留下一个持久未使用的属性。
有更糟糕的情况,但我们在谈论组件设计,为什么不将这个系统推向更好的设计呢?
解决这个问题的一种方法是通过创建一个更细粒度的类层次结构,如下所示:

图 12.4:表示实现责任链和模板方法模式的更细粒度项目设计的类图
之前的图表看起来比实际复杂。但让我们先看看我们的重构代码,从新的MessageHandlerBase类开始:
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);
}
}
[MemberNotNullWhen(true, nameof(_next))]
private bool HasNext()
{
return _next != null;
}
protected abstract bool CanHandle(Message message);
protected abstract void Process(Message message);
}
MessageHandlerBase 类通过处理下一个处理器的逻辑并暴露两个钩子(模板方法模式)来管理责任链,允许子类扩展:
-
bool CanHandle(Message message) -
void Process(Message message)
这个类与上一个类类似,但现在 CanHandle 方法是抽象的,我们移除了 HandledMessageName 属性,这导致了更好的责任分离和更好的钩子。接下来,让我们看看 SingleMessageHandlerBase 类,它替换了从 MessageHandlerBase 类中移除的逻辑:
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; }
}
SingleMessageHandlerBase 类继承自 MessageHandlerBase 类,并重写了 CanHandle 方法。它实现了相关的逻辑,并添加了 HandledMessageName 属性,子类必须定义此属性以使 CanHandle 方法生效(一个必需的扩展点)。AlarmPausedHandler、AlarmStoppedHandler 和 AlarmTriggeredHandler 类现在继承自 SingleMessageHandlerBase 而不是 MessageHandlerBase,但其他方面没有变化。以下是代码作为提醒:
namespace FinalChainOfResponsibility;
public class AlarmPausedHandler : SingleMessageHandlerBase
{
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 : SingleMessageHandlerBase
{
protected override string HandledMessageName => "AlarmStopped";
public AlarmStoppedHandler(IMessageHandler? next = null)
: base(next) { }
protected override void Process(Message message)
{
// Do something clever with the Payload
}
}
public class AlarmTriggeredHandler : SingleMessageHandlerBase
{
protected override string HandledMessageName => "AlarmTriggered";
public AlarmTriggeredHandler(IMessageHandler? next = null)
: base(next) { }
protected override void Process(Message message)
{
// Do something clever with the Payload
}
}
这些 SingleMessageHandlerBase 的子类实现了 HandledMessageName 属性,它返回它们可以处理的消息名称,并且它们通过重写 Process 方法来实现处理逻辑,就像之前一样。接下来,我们来看看 MultipleMessageHandlerBase 类,它使它的子类型能够处理多种消息类型:
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; }
}
MultipleMessageHandlerBase 类与 SingleMessageHandlerBase 类执行相同的操作,但它使用字符串数组而不是单个字符串,支持多个处理器名称。DefaultHandler 类没有变化。为了演示目的,让我们添加 SomeMultiHandler 类,该类模拟了一个可以处理 "Foo"、"Bar" 和 "Baz" 消息的消息处理器:
namespace FinalChainOfResponsibility;
public class SomeMultiHandler : MultipleMessageHandlerBase
{
public SomeMultiHandler(IMessageHandler? next = null)
: base(next) { }
protected override string[] HandledMessagesName
=> new[] { "Foo", "Bar", "Baz" };
protected override void Process(Message message)
{
// Do something clever with the Payload
}
}
这个类层次结构可能听起来很复杂,但我们所做的是允许扩展性,而不需要在过程中保留任何不必要的代码,使每个类都只有一个单一的责任:
-
MessageHandlerBase类处理_next。 -
SingleMessageHandlerBase类处理支持单个消息的处理器的CanHandle方法。 -
MultipleMessageHandlerBase类处理支持多个消息的处理器的CanHandle方法。 -
其他类实现了它们自己的
Process方法版本来处理一个或多个消息。
哇!这是另一个示例,展示了模板方法和责任链模式共同工作的强大之处。最后一个示例还强调了 SRP 的重要性,它允许更大的灵活性,同时保持代码的可靠性和可维护性。该设计的另一个优点是顶部的接口。任何不适合类层次结构的东西都可以直接从接口实现,而不是试图从不适用的结构中适应逻辑。《DefaultHandler》类就是这样一个很好的例子。
将代码欺骗成按照你的意愿执行,而不是正确设计系统的那部分,会导致半成品解决方案,这些解决方案难以维护。
结论
将模板方法模式和责任链模式混合使用会导致具有单一职责的更小的类。我们在保持逻辑不在处理程序中时移除了遗留属性。我们甚至将逻辑扩展到更多的用例中。
摘要
在本章中,我们介绍了两个 GoF 行为模式。这些模式可以帮助我们创建灵活且易于维护的系统。正如其名所示,行为模式旨在将应用行为封装成统一的片段。首先,我们了解了模板方法模式,它允许我们在基类中封装算法的轮廓,同时将算法的一些部分留空,以便子类进行修改。然后,子类填补这些空白,并在预定义的位置扩展该算法。这些位置可以是必需的(抽象)或可选的(虚拟)。然后,你学习了责任链模式,它打开了将多个小型处理程序链接成处理链的可能性,将待处理的消息输入到链的起始处(接口),并等待一个或多个处理程序执行与该消息相关的逻辑。
你不必在第一个处理程序处停止链的执行。责任链可以变成一个管道,而不是将一条消息关联到一个处理程序,正如我们所探讨的那样。
最后,利用模板方法模式封装责任链的链接逻辑,使我们得到了一个更简单、更健壮、更灵活、更可测试的实现,没有任何牺牲。这两个设计模式配合得非常好。在下一章中,我们将深入研究操作结果设计模式,以发现管理返回值的高效方法。
问题
让我们看看几个练习问题:
-
模板方法模式的主要目标是什么?
-
责任链模式的主要目标是什么?
-
实现模板方法设计模式时,我们只能添加一个
抽象方法,这是真的吗? -
我们能否将策略模式与模板方法模式结合使用?
-
在责任链中有一个处理程序数量的限制为 32 个,这是真的吗?
-
在责任链中,多个处理程序可以处理同一条消息吗?
-
模板方法模式如何帮助实现责任链模式?
答案
-
模板方法模式在基类中封装算法的轮廓,同时将算法的一些部分留空,以便其子类进行修改。
-
责任链模式将更大的问题划分为小块(处理程序)。每个部分都是自我管理的,而链的存在对其消费者来说是抽象的。
-
错误;你可以创建你需要的任意数量的
抽象(必需)或虚拟(可选)扩展点(钩子)。 -
是的,没有理由不这样做。
-
不,没有比其他代码更大的限制。
-
是的,你可以为每条消息指定一个处理程序,或者为每条消息指定多个处理程序。这取决于你和你自己的需求。
-
它通过将共享逻辑封装到一个或多个基类中,帮助在类之间划分责任。
第十三章:13 理解操作结果设计模式
在开始之前:加入我们的 Discord 书籍社区
直接向作者本人提供反馈,并在我们的 Discord 服务器上与其他早期读者聊天(在“EARLY ACCESS SUBSCRIPTION”下的“architecting-aspnet-core-apps-3e”频道中查找)。

本章探讨了操作结果模式,从简单开始,逐步过渡到更复杂的案例。操作结果旨在将操作的成功或失败传达给调用者。它还允许该操作返回一个值以及一个或多个消息给调用者。想象一下任何你想要显示用户友好错误信息、实现一些小的速度提升,或者甚至轻松且明确地处理失败的系统。操作结果设计模式可以帮助你实现这些目标。使用它的一种方法是处理远程操作的结果,例如在查询远程网络服务之后。此模式建立在面向对象编程的基础概念之上。在本章中,我们逐步迭代和设计不同的可能性。当然,你应该始终根据你的需求来设计最终的设计,因此学习多个选项将帮助你做出正确的选择。
操作结果模式也被称为结果对象模式。我更喜欢操作结果,因为这个名字表明它代表了一个操作的结果,而结果对象有更广泛的意义。尽管如此,两者基本上是相同的。
在本章中,我们将介绍以下主题:
-
操作结果设计模式的基本知识
-
返回值的操作结果设计模式
-
返回错误信息的操作结果设计模式
-
返回具有严重级别信息的操作结果设计模式
-
使用子类和静态工厂方法以更好地隔离成功和失败
操作结果模式
操作结果设计模式可以从非常简单到更复杂。在本节中,我们将探讨使用此模式的不同方法。我们从其最简单形式开始,在此基础上构建,直到我们可以返回消息和值,并将严重级别作为操作的结果添加。
目标
操作结果模式的作用是赋予一个操作(一个方法)返回一个复杂结果(一个对象)的可能性,这允许消费者:
-
[必选] 访问操作的成功指示器(即操作是否成功)。
-
[可选] 如果有,访问操作结果(方法返回值)。
-
[可选] 如果操作失败(错误信息),访问失败的原因。
-
[可选] 访问记录操作结果的其它信息。这可能只是一个消息列表,也可能非常复杂,如多个属性。
这可以更进一步,例如返回失败的严重性或为特定用例添加任何其他相关信息。成功指示器可以是二进制的(true 或 false),或者可能有超过两种状态,如成功、部分成功和失败。
首先关注您的需求,然后运用您的想象力和知识来找到最佳解决方案。软件工程不仅仅是应用别人告诉您的技术。它是一门艺术!区别在于您是在制作软件而不是绘画或木工。而且大多数人甚至看不到任何这种艺术(代码)或即使看到了也不理解。
设计
当操作失败时,依赖抛出异常是很常见的。然而,操作结果模式是当您不想使用异常时,在组件之间传达成功或失败的一种替代方式。其中一个原因可能是消息不是错误,或者处理错误结果是主流程的一部分,而不是catch流程的一部分。一个方法必须返回一个包含在“目标”部分中展示的一个或多个元素的对象,以便有效地使用。作为一个经验法则,返回操作结果的方法不应该抛出异常。这样,消费者只需处理操作结果本身,无需处理其他任何内容。
您可以为特殊情况抛出异常,但在此阶段,这是一个基于明确规范或面对真实问题的判断。例如,一个关键事件发生,如磁盘已满,将是一个有效的异常使用案例,因为它与主流程无关,代码必须向程序的其他部分发出系统故障的警报。
而不是逐一向您展示所有可能的 UML 图,让我们先看看描述此模式最简单形式的基线序列图,然后探索多个更小的示例:

图 13.1:操作结果设计模式的序列图
上述图示表明,一个操作返回一个结果(一个对象),然后调用者处理该结果。以下示例涵盖了我们可以包含在该结果对象中的内容。
项目 - 实现不同的操作结果模式
在这个项目中,一个消费者(REST API)将 HTTP 请求路由到正确的处理器。我们逐个访问这些处理器,以从简单到更复杂的操作结果创建一个渐进式学习流程。这个项目向您展示了多种实现操作结果模式的方法,以帮助您理解它,使其成为您自己的,并在您的项目中按需实现。让我们从 REST API 开始。
消费者
所有示例的消费者是 Program.cs 文件。以下来自 Program.cs 的代码将 HTTP 请求路由到处理器:
app.MapGet("/simplest-form", ...);
app.MapGet("/single-error", ...);
app.MapGet("/single-error-with-value", ...);
app.MapGet("/multiple-errors-with-value", ...);
app.MapGet("/multiple-errors-with-value-and-severity", ...);
app.MapGet("/static-factory-methods", ...);
接下来,我们逐个覆盖每个用例。
操作结果模式的简单形式
以下图表示了操作结果模式的简单形式:

图 13.2:操作结果设计模式的类图
我们可以将这个类图转换为以下代码块:
app.MapGet(
"/simplest-form",
(OperationResult.SimplestForm.Executor executor) =>
{
var result = executor.Operation();
if (result.Succeeded)
{
// Handle the success
return "Operation succeeded";
}
else
{
// Handle the failure
return "Operation failed";
}
}
);
上一段代码处理了 /simplest-form HTTP 请求。高亮显示的代码消费以下操作:
namespace OperationResult.SimplestForm;
public class Executor
{
public OperationResult Operation()
{
// Randomize the success indicator
// This should be real logic
var randomNumber = Random.Shared.Next(100);
var success = randomNumber % 2 == 0;
// Return the operation result
return new OperationResult(success);
}
}
public record class OperationResult(bool Succeeded);
Executor 类包含由 Operation 方法表示的操作。该方法返回 OperationResult 类的一个实例。实现基于随机数。有时它成功,有时它失败。你通常会在这个方法中编写实际的应应用程序逻辑。此外,在实际应用中,该方法应该有一个适当的名称来表示操作,如 PayRegistrationFees 或 CreateConcert。《OperationResult》记录类表示操作的结果。在这种情况下,一个简单的只读布尔值存储在 Succeeded 属性中。
我选择记录类,因为没有理由让结果发生变化。要了解更多关于记录类的信息,请参阅 附录 A。
在这种形式下,Operation 方法返回 bool 和 OperationResult 实例之间的区别很小,但确实存在。通过返回 OperationResult 对象,你可以随着时间的推移扩展返回值,向其中添加属性和方法,而无需更新所有消费者是无法做到这一点的。接下来,我们向结果中添加一个错误消息。
单个错误消息
既然我们知道操作是否成功,我们就想了解出了什么问题。为了做到这一点,我们在 OperationResult 记录类中添加了一个 ErrorMessage 属性。有了这个属性,我们就不再需要设置操作是否成功;我们可以使用 ErrorMessage 属性来计算这一点。这个改进背后的逻辑如下:
-
当没有错误消息时,操作成功。
-
当有错误消息时,操作失败。
实现此逻辑的 OperationResult 记录类看起来如下:
namespace OperationResult.SingleError
public record class OperationResult
{
public bool Succeeded => string.IsNullOrWhiteSpace(ErrorMessage);
public string? ErrorMessage { get; init; }
}
在前面的代码中,我们有以下内容:
-
Succeeded属性检查错误消息。 -
ErrorMessage属性包含在实例化对象时可以设置的错误消息。
执行该操作的执行者看起来相似,但使用新的构造函数,设置错误消息而不是直接设置成功指示器:
namespace OperationResult.SingleError
public class Executor
{
public OperationResult Operation()
{
// Randomize the success indicator
// This should be real logic
var randomNumber = Random.Shared.Next(100);
var success = randomNumber % 2 == 0;
// Return the operation result
return success
? new()
: new() { ErrorMessage = $"Something went wrong with the number '{randomNumber}'." };
}
}
消费代码与上一个示例相同,但在响应输出中写入错误消息而不是通用的失败字符串:
app.MapGet(
"/single-error",
(OperationResult.SingleError.Executor executor) =>
{
var result = executor.Operation();
if (result.Succeeded)
{
// Handle the success
return "Operation succeeded";
}
else
{
// Handle the failure
return result.ErrorMessage;
}
}
);
当查看这个示例时,我们可以开始理解操作结果模式的有用性。它使我们远离了看似过于复杂的布尔值简单成功指示器。接下来,我们添加了在操作成功时设置值的可能性。
添加返回值
现在我们有了失败的原因,我们可能希望操作返回一个值。为了实现这一点,让我们在之前的示例基础上构建,并向OperationResult类添加一个Value属性:
namespace OperationResult.SingleErrorWithValue;
public record class OperationResult
{
public bool Succeeded => string.IsNullOrWhiteSpace(ErrorMessage);
public string? ErrorMessage { get; init; }
public int? Value { get; init; }
}
通过添加一个仅初始化的第二个属性,我们可以在操作成功和失败时设置Value属性。
在实际场景中,错误情况下
Value属性可能是null,因此有一个可空的int属性。
操作也非常相似,但我们在这两种情况下都设置了Value属性,并使用了对象初始化器(高亮行):
namespace OperationResult.SingleErrorWithValue;
public class Executor
{
public OperationResult Operation()
{
// Randomize the success indicator
// This should be real logic
var randomNumber = Random.Shared.Next(100);
var success = randomNumber % 2 == 0;
// Return the operation result
return success
? new() { Value = randomNumber }
: new()
{
ErrorMessage = $"Something went wrong with the number '{randomNumber}'.",
Value = randomNumber,
};
}
}
这样一来,消费者就可以使用Value属性。在我们的例子中,程序在操作成功时显示它:
app.MapGet(
"/single-error-with-value",
(OperationResult.SingleErrorWithValue.Executor executor) =>
{
var result = executor.Operation();
if (result.Succeeded)
{
// Handle the success
return $"Operation succeeded with a value of '{result.Value}'.";
}
else
{
// Handle the failure
return result.ErrorMessage;
}
}
);
前面的代码在操作失败或成功时显示ErrorMessage属性。有了这个,操作结果模式的强大功能继续显现。但我们还没有完成,所以让我们跳到下一个发展阶段。
多个错误消息
现在我们已经到了可以传递Value和ErrorMessage到操作消费者的时候了;那么传递多个错误,比如验证错误怎么办?为了实现这一点,我们可以将我们的ErrorMessage属性从string转换为IEnumerable<string>或其他更适合你需求的集合类型。在这里,我选择了IReadOnlyCollection<string>接口和ImmutableList<string>类,这样我们知道外部行为者不能修改结果:
namespace OperationResult.MultipleErrorsWithValue;
public record class OperationResult
{
public OperationResult()
{
Errors = ImmutableList<string>.Empty;
}
public OperationResult(params string[] errors)
{
Errors = errors.ToImmutableList();
}
public bool Succeeded => !HasErrors();
public int? Value { get; init; }
public IReadOnlyCollection<string> Errors { get; init; }
public bool HasErrors()
{
return Errors?.Count > 0;
}
}
在继续之前,让我们看看前面代码中的新部分:
-
错误现在存储在
ImmutableList<string>对象中,并以IReadOnlyCollection<string>的形式返回。 -
Succeeded属性考虑了一个集合而不是单个消息,并遵循相同的逻辑。 -
HasErrors方法提高了可读性。 -
默认构造函数表示成功状态。
-
接收错误消息参数的构造函数表示失败状态,并填充
Errors属性。
现在操作结果已经更新,操作本身可以保持不变。消费者几乎保持不变(见下面代码中的高亮部分),但我们需要告诉 ASP.NET 如何序列化结果:
app.MapGet(
"/multiple-errors-with-value",
object (OperationResult.MultipleErrorsWithValue.Executor executor)
=> {
var result = executor.Operation();
if (result.Succeeded)
{
// Handle the success
return $"Operation succeeded with a value of '{result.Value}'.";
}
else
{
// Handle the failure
return result.Errors;
}
}
);
我们必须指定该方法返回一个对象(高亮代码),这样 ASP.NET 才能理解我们的委托返回值可以是任何类型。如果没有这个指定,返回类型无法推断,代码将无法编译。这是有道理的,因为函数在一个路径上返回string类型,在另一个路径上返回IReadOnlyCollection<string>类型。在执行过程中,ASP.NET 在输出到客户端之前将IReadOnlyCollection<string> Errors属性序列化为 JSON,以帮助可视化集合。
当操作成功时返回
plain/text字符串,当操作失败时返回application/json数组,这不是一个好的做法。我建议在实际应用中避免这样做。要么返回 JSON,要么返回纯文本。除非根据规范有必要,否则不要在单个端点中混合内容类型。混合内容类型只会创建可避免的复杂性和混淆。此外,对于 API 的消费者来说,始终期望相同的内容类型要容易得多。在设计系统合同时,一致性和统一性通常比不连贯、模糊和变化要好。
我们的操作结果模式实现正在变得越来越好,但仍然缺少一些功能。其中之一是传播非错误消息的可能性,例如信息消息和警告,这是我们接下来要实现的。
添加消息严重性
现在我们操作的结果结构正在形成,让我们更新我们的最后一次迭代以支持消息严重性。首先,我们需要一个严重性指示器。enum 是这类工作的良好候选,但也可能是其他东西。在我们的情况下,我们利用了一个名为 OperationResultSeverity 的 enum。然后我们需要一个消息类来封装消息和严重级别;让我们称这个类为 OperationResultMessage。新的代码看起来是这样的:
namespace OperationResult.WithSeverity;
public record 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 消息。为了确保枚举被序列化为字符串,并使输出更容易阅读和消费,我们必须注册以下转换器:
builder.Services
.Configure<JsonOptions>(o
=> o.SerializerOptions.Converters.Add(
new JsonStringEnumConverter()))
;
然后,我们需要更新 OperationResult 类以使用新的 OperationResultMessage 类。然后我们需要确保操作结果仅在不存在 OperationResultSeverity.Error 时表示成功,从而允许它传输 OperationResultSeverity.Information 和 OperationResultSeverity.Warnings 消息:
namespace OperationResult.WithSeverity;
public record class OperationResult
{
public OperationResult()
{
Messages = ImmutableList<OperationResultMessage>.Empty;
}
public OperationResult(params OperationResultMessage[] messages)
{
Messages = messages.ToImmutableList();
}
public bool Succeeded => !HasErrors();
public int? Value { get; init; }
public ImmutableList<OperationResultMessage> Messages { get; init; }
public bool HasErrors()
{
return FindErrors().Any();
}
private IEnumerable<OperationResultMessage> FindErrors()
=> Messages.Where(x => x.Severity == OperationResultSeverity.Error);
}
突出的行表示更新后的逻辑,该逻辑设置操作的成功状态。操作只有在 Messages 列表中没有错误时才成功。FindErrors 方法返回具有 Error 严重性的消息,而 HasErrors 方法基于该方法的输出做出决定。
HasErrors方法的逻辑可以是任何东西。在这种情况下,这是可行的。
在此基础上,Executor 类也得到了改进。让我们看看这些变化:
namespace OperationResult.WithSeverity;
public class Executor
{
public OperationResult Operation()
{
// Randomize the success indicator
// This should be real logic
var randomNumber = Random.Shared.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 };
}
}
}
在前面的代码中,我们移除了三元运算符。Operation 方法也使用了所有严重级别。
你应该始终致力于编写易于阅读的代码。使用语言特性是可以的,但将语句嵌套在单行上是有局限性的,并且很快就会变得混乱。
在最后一个代码块中,成功和失败都返回两条消息:
-
当操作成功时,该方法返回一个信息和警告消息。
-
当操作失败时,该方法返回一个信息和错误消息。
从消费者的角度来看,我们有一个占位符的 if-else 块,并直接返回操作结果。当然,我们可以在需要了解这些消息的实际应用程序中以不同的方式处理这个问题,但在这个案例中,我们只想看到这些结果,所以这就足够了:
app.MapGet("/multiple-errors-with-value-and-severity", (OperationResult.WithSeverity.Executor executor) =>
{
var result = executor.Operation();
if (result.Succeeded)
{
// Handle the success
}
else
{
// Handle the failure
}
return result;
});
如您所见,它仍然易于使用,但现在增加了更多的灵活性。我们可以对不同的消息类型做一些事情,例如向用户显示它们、重试操作等等。目前,当运行应用程序并调用此端点时,成功的调用返回一个类似以下的 JSON 字符串:
{
"succeeded": true,
"value": 56,
"messages": [
{
"message": "This should be very informative!",
"severity": "Information"
},
{
"message": "Something went wrong, but we will try again later automatically until it works!",
"severity": "Warning"
}
]
}
失败返回一个类似这样的 JSON 字符串:
{
"succeeded": false,
"value": 19,
"messages": [
{
"message": "This should be very informative!",
"severity": "Information"
},
{
"message": "Something went wrong with the number '19'.",
"severity": "Error"
}
]
}
提高这个设计的另一个想法是添加一个 Status 属性,它根据每个消息的严重级别返回一个复杂的成功结果。为了做到这一点,我们可以创建另一个枚举:
public enum OperationStatus { Success, Failure, PartialSuccess }
然后,我们可以通过在 OperationResult 类上创建一个名为 Status 的新属性来访问该值。有了这个属性,消费者可以处理部分成功而无需深入查看消息。我将把这个留给你自己尝试;例如,Status 属性可以替换 Succeeded 属性,或者 Succeeded 属性可以像我们处理错误那样利用 Status 属性。最重要的是定义什么是成功、部分成功和失败。例如,考虑一个数据库事务;一次失败可能导致事务回滚,而在另一种情况下,一次失败可能是可接受的。现在我们已经将我们的简单示例扩展到这个程度,如果我们想使 Value 成为可选的,会发生什么?为了做到这一点,我们可以创建多个包含或多或少信息的操作结果类(属性);让我们尝试一下。
子类和工厂
在这次迭代中,我们保留了所有属性,但使用静态工厂方法实例化 OperationResult 对象。此外,我们在子类中隐藏了某些属性,因此每个结果类型只包含它所需的数据。在这种情况下,OperationResult 类本身只公开了 Succeeded 属性。静态工厂方法不过是一个创建对象的静态方法。它方便且易于使用,但灵活性较低。
我不能强调这一点:在设计
static的时候要小心,否则可能会在以后困扰你;static成员是不可扩展的,并且可能使它们的消费者更难测试。
OperationResultMessage 类和 OperationResultSeverity 枚举保持不变。在下面的代码块中,我们计算操作的成功或失败状态时不考虑严重性。相反,我们创建了一个抽象的 OperationResult 类,并有两个子类:
-
SuccessfulOperationResult类表示成功的操作。 -
FailedOperationResult类表示失败的操作。
接下来的步骤是通过创建两个静态工厂方法来强制使用专门设计的类:
-
静态
Success方法返回一个SuccessfulOperationResult对象。 -
静态
Failure返回一个FailedOperationResult对象。
这种技术将决定操作是否成功的责任从OperationResult类转移到了显式创建预期结果的Operation方法。以下代码块展示了新的OperationResult实现(静态工厂被突出显示):
namespace OperationResult.StaticFactoryMethod;
public abstract record class OperationResult
{
private OperationResult() { }
public abstract bool Succeeded { get; }
public static OperationResult Success(int? value = null)
{
return new SuccessfulOperationResult { Value = value };
}
public static OperationResult Failure(params OperationResultMessage[] errors)
{
return new FailedOperationResult(errors);
}
private record class SuccessfulOperationResult : OperationResult
{
public override bool Succeeded { get; } = true;
public virtual int? Value { get; init; }
}
private record class FailedOperationResult : OperationResult
{
public FailedOperationResult(params OperationResultMessage[] errors)
{
Messages = errors.ToImmutableList();
}
public override bool Succeeded { get; } = false;
public ImmutableList<OperationResultMessage> Messages { get; }
}
}
分析代码后,有几个密切相关的问题:
-
OperationResult类有一个私有构造函数。 -
SuccessfulOperationResult和FailedOperationResult类都嵌套在OperationResult类内部,继承自它,并且是private。
嵌套类是继承自OperationResult类的唯一方式,因为,就像类中的其他成员一样,嵌套类可以访问它们的私有成员,包括构造函数。否则,从OperationResult继承是不可能的。此外,作为私有类,它们只能从OperationResult类内部访问,出于同样的原因,从外部变得不可访问。
从本书开始,我多次重复提到灵活性;但并不是你总是想要灵活性。即使本书的大部分内容是关于提高灵活性,有时你希望控制你暴露的内容以及允许消费者执行的操作,无论是为了保护内部机制(封装)还是为了维护性原因。
例如,允许消费者更改对象的内部状态可能导致意外的行为。另一个例子是在管理库时;公开的 API 越大,引入破坏性变更的机会就越多。尽管如此,过度隐藏元素可能会给消费者带来不良体验;如果你需要某处的东西,其他人最终也可能需要(可能)。
在这种情况下,我们可以使用受保护的构造函数,或者实现一种更花哨的方式来实例化成功和失败实例。然而,我决定利用这个机会向你展示如何在不使用
sealed修饰符的情况下锁定类,使得从外部通过继承扩展变得不可能。我们可以在我们的类中构建机制以允许受控的可扩展性(如模板方法模式),但在这个例子中,让我们将其锁定在紧密的位置!
从这里,唯一缺少的部分是操作本身和操作的消费者。让我们先看看操作:
namespace OperationResult.StaticFactoryMethod;
public class Executor
{
public OperationResult Operation()
{
// Randomize the success indicator
// This should be real logic
var randomNumber = Random.Shared.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);
}
}
}
上一段代码块中突出显示的两行展示了这种新改进的优雅之处。我发现这段代码非常易于阅读,这正是我们的目标。现在我们有两种方法可以清楚地定义使用时的意图:“成功”或“失败”。消费者使用我们在其他示例中看到的相同代码,所以在这里我将省略它。然而,对于成功或失败的操作,输出是不同的。以下是一个成功的输出:
{
"succeeded": true,
"value": 80
}
以下是一个失败的输出:
{
"succeeded": false,
"messages": [
{
"message": "Something went wrong with the number '37'.",
"severity": "Error"
}
]
}
如前两个 JSON 输出所示,每个对象的属性都不同。这两个对象唯一的共有属性是Succeeded属性。请注意,这种类型的类层次结构直接消费起来更困难,因为接口(OperationResult类)具有最小的 API 表面,这在理论上是好的,而每个子类都添加了不同的属性,这些属性对消费者来说是隐藏的。例如,在端点处理代码中直接使用成功操作的Value属性可能会很困难。因此,当我们隐藏属性时,如我们在这里所做的那样,确保这些附加属性是可选的。例如,当通过 HTTP(如本项目所做)将结果发送到另一个系统或发布操作结果作为事件(见第十九章,微服务架构简介,其中我们介绍了事件驱动架构)时,我们可以使用这种技术。不过,学习使用多态操作类将有助于你真正需要它的时候。接下来,让我们看看操作结果模式的优缺点。
优点和缺点
这里是操作结果设计模式的一些优缺点。
优点
它比抛出Exception更明确,因为操作结果类型被明确指定为方法的返回类型。这使得它比知道操作及其依赖可以抛出什么类型的异常更明显。另一个优点是执行速度;返回对象比抛出异常更快。虽然快不了多少,但确实更快。使用操作结果比异常更灵活,并给我们提供了设计灵活性;例如,我们可以管理不同类型的消息,如警告和信息。
缺点
使用操作结果比抛出异常更复杂,因为我们必须手动将其传播到调用栈(即,结果对象由被调用者返回并由调用者处理)。这一点在操作结果必须向上传递多个层级时尤其如此,这表明这种模式可能不是最合适的。很容易暴露出所有场景都不使用的成员,从而创建比所需更大的 API 表面,其中某些部分只在某些情况下使用。但是,与花费无数小时设计完美的系统相比,有时暴露一个int? Value { get; }属性可能是最佳选择。不过,始终尽量将这种表面减少到最低限度,并运用你的想象力和设计技能来克服这些挑战!
摘要
在本章中,我们探讨了多种形式的 Operation Result 模式,从增强的布尔值到包含消息、值和成功指示器的复杂数据结构。我们还探讨了静态工厂和私有构造函数来控制外部访问。此外,在所有这些探索之后,让我们得出结论,围绕 Operation Result 模式几乎有无限的可能性。每个具体用例都应该决定如何实现它。从这里,我坚信你已经有足够的信息来探索这个模式的许多可能性,我强烈鼓励你这样做。Operation Result 模式非常适合构建强类型返回值,这些返回值可以自我管理多个状态(错误和成功)或支持复杂状态(如部分成功)。它也非常适合传输不一定是错误的消息,如信息消息。即使在最简单的形式中,我们也可以利用 Operation Result 模式作为扩展的基础,因为我们可以在一段时间内向结果类添加成员,这对于原始类型(或任何我们无法控制的类型)是不可能的。
由
HttpClient类的方法返回的HttpResponseMessage类是 Operation Result 模式具体实现的优秀示例。它通过ReasonPhrase属性暴露一个单一的消息。它通过StatusCode属性暴露一个复杂的成功状态,并通过其IsSuccessStatusCode属性暴露一个简单的成功指示器。它还通过其他属性包含有关请求和响应的更多信息。
在这一点上,我们通常会探讨Operation Result模式如何帮助我们遵循 SOLID 原则。然而,它过于依赖于实现,所以这里有一些关键点:
-
OperationResult类封装了结果,从其他系统组件(SRP)中提取了这一责任。 -
在多个示例中,我们违反了 ISP 原则,在
Value属性上。这种违规行为的影响很小,我们将其修复作为一个克服这一挑战的例子。 -
我们可以将操作结果与 DTO 进行比较,但它是通过操作(方法)而不是 REST API 端点返回的。从那里,我们可以添加一个抽象或坚持返回一个具体类,但有时使用具体类型可以使系统更容易理解和维护。根据实现方式,这可能会违反不同的原则。
当优势超过这种违规行为的微小影响时,可以接受让它们滑过去。原则是理想,并不适用于每个场景——原则不是法律。
大多数设计决策是在两个不完美的解决方案之间的权衡,因此你必须选择你愿意忍受的缺点以获得优点。
本章总结了第三部分:组件模式,并引出第四部分:应用模式,在那里我们将探讨高级设计模式。
问题
让我们看看几个练习题:
-
在进行异步调用,如 HTTP 请求时,返回操作结果是一个好主意吗?
-
我们使用静态方法实现的模式叫什么名字?
-
返回操作结果比抛出异常更快吗?
-
在什么场景下操作结果模式可能会派上用场?
进一步阅读
这里有一些链接,可以帮助我们巩固本章所学的内容:
-
我博客上一篇关于异常的文章(标题:异常入门指南 | 基础知识):
adpg.link/PpEm -
我博客上一篇关于操作结果的文章(标题:操作结果 | 设计模式):
adpg.link/4o2q
答案
-
是的,异步操作,如 HTTP,是操作结果模式的理想候选者。例如,在 BCL 中,
HttpClient类的Send方法返回的HttpResponseMessage实例就是一个操作结果。 -
我们实现了两个静态工厂方法。
-
是的,返回一个对象比抛出异常稍微快一点。
-
当我们希望将操作的状态及其返回值作为主要消费流程的一部分返回时,操作结果模式非常有用。它非常适合返回描述过程结果的多个属性,并且是可扩展的。
第十四章:14 分层与清洁架构
在开始之前:加入我们的 Discord 书籍社区
直接向作者本人提供反馈,并在我们的 Discord 服务器上与其他早期读者聊天(在“architecting-aspnet-core-apps-3e”频道下找到“EARLY ACCESS SUBSCRIPTION”)。

*** 介绍分层
-
常见层的职责
-
抽象层
-
共享模型
-
清洁架构
-
在现实生活中实现分层
让我们开始吧!
介绍分层
现在我们已经探索了一些设计模式并玩转了 ASP.NET Core,是时候深入分层了。在大多数计算机系统中,都存在分层。为什么?因为它是一种高效地将逻辑单元组织在一起的方法。我们可以从概念上将分层表示为水平软件段,每个段封装一个关注点。
经典分层模型
让我们从检查一个经典的三层应用设计开始:

图 14.1:经典的分层应用设计
表示层代表用户可以与之交互以到达领域的任何用户界面。它可能是一个 ASP.NET Core Web 应用程序。从 WPF 到 WinForms 到 Android,任何东西都可以是一个有效的非 Web 表示层替代方案。领域层代表由业务规则驱动的核心逻辑;这解决了应用程序的问题。领域层也被称为业务逻辑层(BLL)。数据层代表数据与应用程序之间的桥梁。该层可以将数据存储在 SQL Server 数据库中、托管在云中的 NoSQL 数据库中、多个数据源混合或任何适合业务需求的东西。数据层也被称为数据访问层(DAL)和持久层。让我们跳到一个例子。假设用户已经通过认证和授权,当他们在使用这些三层构建的书店应用程序中创建一本书时,会发生以下情况:
-
用户通过向服务器发送
GET请求来请求页面。 -
服务器处理那个
GET请求(表示层),然后将其返回给用户。 -
用户填写表单并向服务器发送
POST请求。 -
服务器处理
POST请求(表示层),然后将其发送到领域层进行处理。 -
领域层执行创建书籍所需的逻辑,然后告诉数据层持久化这些数据。
-
展开到表示层后,服务器将返回适当的响应给用户,这很可能是包含书籍列表和表示操作成功的消息的页面。
按照经典分层架构,一个层只能与堆栈中的下一层通信——表示层与领域层通信,而领域层与数据层通信,依此类推。重要的是每一层都必须是独立和隔离的,以限制紧密耦合。在这个经典分层模型中,每一层应该拥有自己的模型。例如,表示层不应该将其视图模型发送到领域层;那里应该只使用领域对象。反之亦然:由于领域层将其自己的对象返回给表示层,因此表示层不应该将其泄露给消费者,而应该将所需的信息组织成视图模型或DTO。以下是一个视觉示例:

图 14.2:表示层之间相互作用的图示
即使三层可能是最受欢迎的层数,我们也可以创建我们需要的任何数量的层;我们并不局限于三层。让我们从优点开始,考察经典分层结构的优缺点:
-
了解层的目的是很容易理解的。例如,猜测数据层组件在某个地方读取或写入数据是很容易的。
-
它创建了一个围绕单一关注点构建的统一单元。例如,我们的数据层不应该渲染任何用户界面,而应坚持访问数据。
-
它允许我们将层与系统其余部分(其他层)解耦。你可以在对其他层知之甚少的情况下隔离并在该层内工作。例如,假设你被分配优化数据访问层中的查询任务。在这种情况下,你不需要了解最终将数据显示给用户的用户界面。你只需要专注于该元素,优化它,单独测试它,然后发布该层或重新部署应用程序。
-
就像任何其他隔离单元一样,应该能够重用层。例如,我们可以在需要查询相同数据库但用于不同目的的另一个应用程序中重用我们的数据访问层(一个不同的领域层)。
提示
一些层在理论上比其他层更容易重用,可重用性可能增加或减少价值,具体取决于你正在构建的软件。我在实践中从未见过一个层被完整重用,我也很少听说或读到这样的情况——每次通常都以最终不太可重用的情况告终。
根据我的经验,我强烈建议不要在不是精确规范且能为你的应用程序增加价值的情况下过度追求可重用性。限制你的过度工程化努力可以为你和你的雇主节省大量时间和金钱。我们不应忘记,我们的工作是交付价值。
作为一项经验法则,做你需要做的事情,不要多做,但要做好。
好的,现在,让我们看看其缺点:
-
通过将软件水平拆分为层,每个功能都会跨越所有层。这通常会导致层之间的级联更改。例如,如果我们决定向我们的书店数据库添加一个字段,我们就需要更新数据库、访问它的代码(数据层)、业务逻辑(领域层)和用户界面(表示层)。在规格不稳定或预算有限的项目中,这可能会变得痛苦!
-
对于新手来说,实现全栈功能更具挑战性,因为它跨越了所有层。
-
使用分层通常会导致或是由人员之间的职责分离引起的。例如,数据库管理员管理数据层,后端开发者管理领域层,前端开发者管理表示层,导致协调和知识共享问题。
-
由于层直接依赖于其下的层,没有引入抽象层或从表示层引用较低层,依赖注入是不可能的。例如,如果领域层依赖于数据层,修改数据层将需要重写从领域到数据的所有耦合。
-
由于每个层都拥有自己的实体,你添加的层越多,实体的副本就越多,导致轻微的性能损失和更高的维护成本。例如,表示层将一个DTO复制到一个领域对象。然后,领域层将其复制到一个数据对象。最后,数据层将其转换为 SQL 以将其持久化到数据库(例如 SQL Server)。当从数据库读取时,情况也是相反的。
我们将在后面探讨一些克服这些缺点的方法。我强烈建议你不要做我们刚刚探讨的事情。这是一种过时、更基本的分层方式。我们在本章中正在探讨对这个分层系统的多个改进,所以请在得出结论之前继续阅读。我决定从分层开始探索,以防你不得不与那种类型的应用程序一起工作。此外,研究其时间顺序演变、修复一些缺陷和添加选项应该有助于你理解概念,而不仅仅是知道做事的一种方式。理解模式是软件架构的关键,而不仅仅是学习如何应用它们。
分割层
现在我们已经讨论了层,并将它们视为职责的大块水平切片,我们可以通过垂直分割这些大块来更细致地组织我们的应用程序,创建多个较小的层。这可以帮助我们按功能或边界上下文组织应用程序,也可能使我们能够使用相同的构建块组合各种用户界面,这将比重用巨大尺寸的层更容易。以下是这个想法的概念表示:

图 14.3:使用较小的部分共享层组织多个应用程序
我们可以将一个应用程序分割成多个功能(垂直分割)并将每个分割成层(水平分割)。根据之前的图,我们这样命名这些功能:
-
库存管理
-
在线购物
-
其他
因此,我们可以在不带来其他一切的情况下,将在线购物领域和数据层引入我们的购物 Web API。此外,我们还可以将在线购物领域层引入移动应用,并用另一个与 Web API 通信的数据层来替换它。我们还可以将我们的 Web API 作为一个简单的数据访问应用程序使用,同时在其上附加不同的逻辑,而保持购物数据层在下面。我们最终可能会得到以下重新组合的应用程序(这只是可能的结果之一):

图 14.4:使用较小的部分共享层组织多个应用程序
这些只是我们可以在概念上用层做的事情的例子。然而,最重要的不是图是如何布局的,而是你正在构建的应用程序的规范。只有那些规范和良好的分析才能帮助你为那个特定问题创建最佳可能的设计。我在这里使用了一个假设的购物例子,但它可以是任何东西。将巨大的水平切片垂直分割使得每个部分更容易重用和共享。这种改进可以产生有趣的结果,特别是如果你有多个前端应用程序或计划迁移离开单体。
单体应用(或单体)是一个以单个集成块部署的程序,具有低模块化。单体可以采用层或不需要层。人们经常将单体应用与微服务应用进行比较,因为它们是相反的。我们在第十九章“微服务架构简介”中探讨了微服务,在第二十章“模块化单体”中探讨了单体。
层与层与组件
到目前为止,在本章中,我们一直在谈论层,而没有谈论将它们转化为代码。在深入这个主题之前,我想讨论一下层。你可能之前在某个地方见过三层架构这个术语,或者听到人们谈论层和层,可能在同一语境中将它们作为同义词互换。然而,它们并不相同。简而言之:
-
层是物理的
-
层是逻辑的
什么是层级?
我们可以将每个层部署在其自己的机器上。例如,你可以有一个数据库服务器,一个托管你的 Web API 的服务器,其中包含业务逻辑(领域),以及另一个服务器,它托管一个 Angular 应用程序(表示);这些是三个层(三个不同的机器),每个层都可以独立扩展。我们接下来看看层。
什么是层?
另一方面,每个层只是代码的逻辑组织,关注点以分层的方式组织和划分。例如,你可以在 Visual Studio 中创建一个或多个项目,并将你的代码组织成三层。例如,一个 Razor Pages 应用程序依赖于一个业务逻辑层,该层依赖于一个数据访问层。当你部署该应用程序时,所有这些层,包括数据库,都部署在同一台服务器上。这将是一个层级和三个层。当然,如今,你可能在某个地方有一个云数据库,这将为该架构增加第二个层级:应用程序层(其中仍然有三个层)和数据库层。现在我们已经讨论了层和层,让我们看看层与组件之间的区别。
什么是组件?
组件通常被编译成.dll或.exe文件;你可以直接编译和消费它们。在大多数情况下,Visual Studio 解决方案中的每个项目都会被编译成一个组件。你还可以将它们作为 NuGet 包部署,并从nuget.org或你选择的自定义 NuGet 仓库中消费。但是,层与组件或层与层之间没有一对一的关系;组件只是可消费的编译代码单元:一个库或一个程序。此外,你不需要将你的层分割成不同的组件;你可以让三个层都驻留在同一个组件中。这样可能会更容易产生不希望的耦合,因为所有代码都在同一个项目中,但只要有一些严谨性、规则和约定,这也是一个可行的选项。将每个层移动到组件中并不一定能够提高应用程序;每个层或组件内部的代码可能会变得混乱,并与系统的其他部分耦合。请别误会我的意思:你可以为每个层创建一个组件;我甚至在大多数情况下鼓励你这样做,但这并不意味着层之间没有紧密耦合。层只是一个逻辑组织单元,因此每个贡献者的责任是确保层的代码保持健康。此外,拥有多个组件让我们可以将它们部署到一台或多台机器上,可能是不同的机器,从而形成多个层。现在,让我们看看最常见层的职责。
常见层的职责
在本节中,我们将更深入地探讨最常用的层。我们不会对每一层都进行深入挖掘,但概述应该能帮助你理解分层背后的基本思想。
展示
展示层可能是最容易理解的一层,因为它是我们唯一能看到的部分:用户界面。然而,在 REST、OData、GraphQL 或其他类型的网络服务的情况下,展示层也可以是数据合约。展示层是用户用来访问你的程序的部分。例如,一个命令行界面(CLI)程序也可以是一个展示层。你可以在终端中输入命令,CLI 会将它们分发给其领域层,执行所需的企业逻辑。保持展示层可维护的关键是尽可能地将它集中在显示用户界面,尽可能少地包含业务逻辑。接下来,我们将查看领域层,看看这些调用会去哪里。
域
领域层是软件价值所在之处,也是大部分复杂性的集中地。领域层是业务逻辑规则的家。相比于用户界面,领域层更容易销售,因为用户通过表现层连接到领域。然而,重要的是要记住,领域负责解决问题和自动化解决方案;表现层仅将用户的操作链接到领域。我们通常围绕领域模型构建领域层。对此有两种宏观观点:
-
使用丰富模型。
-
使用贫血模型。
你可以利用领域驱动设计(DDD)来构建该模型及其周围的程序。DDD 与丰富模型相辅相成,一个精心设计的模型应该简化程序的维护。进行 DDD 不是强制性的,即使没有它,你也可以达到所需的正确性水平。
另一个困境是将领域模型直接持久化到数据库或使用中间数据模型。我们将在数据部分更详细地讨论这一点。同时,我们来看看思考领域模型的两种主要方式,从丰富的领域模型开始。
丰富的领域模型
一个丰富的领域模型在“最纯粹”的意义上更面向对象,并将领域逻辑封装为模型内部方法的一部分。例如,以下类代表了一个只包含几个属性的Product类的丰富版本:
public class Product
{
public Product(string name, int quantityInStock, int? id = null)
{
Name = name ?? throw new ArgumentNullException(nameof(name));
QuantityInStock = quantityInStock;
Id = id;
}
public int? Id { get; init; }
public string Name { get; init; }
public int QuantityInStock { get; private set; }
public void AddStock(int amount)
{
if (amount == 0) { return; }
if (amount < 0) {
throw new NegativeValueException(amount);
}
QuantityInStock += amount;
}
public void RemoveStock(int amount)
{
if (amount == 0) { return; }
if (amount < 0) {
throw new NegativeValueException(amount);
}
if (amount > QuantityInStock) {
throw new NotEnoughStockException(
QuantityInStock, amount);
}
QuantityInStock -= amount;
}
}
AddStock 和 RemoveStock 方法代表了产品库存中添加和移除库存的领域逻辑。当然,在这种情况下,我们只增加和减少属性值,但在更复杂的模型中,概念是相同的。这种方法的最大的优点是大多数逻辑都内置到模型中,使得模型非常以领域为中心,操作通过模型实体作为方法来实现。此外,它触及了面向对象设计的基本思想,即行为应该是对象的一部分,使它们成为现实生活对应物的虚拟表示。最大的缺点是单个类承担了过多的责任。即使面向对象设计告诉我们将逻辑放入对象中,这并不意味着这总是好主意。如果你的系统对灵活性很重要,将逻辑硬编码到领域模型中可能会阻碍你在不更改代码本身的情况下(尽管仍然可以做到)演变业务规则的能力。如果你的领域是固定和预定义的,丰富的模型可能是你项目的良好选择。这种方法的相对缺点是,将依赖注入到领域模型比其他对象(如服务)更困难。这种缺点减少了灵活性,并增加了创建模型的复杂性。如果你正在构建一个状态型应用程序,其中领域模型可以在内存中比 HTTP 请求的时间更长地存在,丰富的领域模型可能是有用的。其他模式可以帮助你做到这一点,例如模型-视图-视图-模型(MVVM)、模型-视图-演示者(MVP)和模型-视图-更新(MVU)。如果你认为你的应用程序从保持数据和逻辑在一起中受益,那么丰富的领域模型可能是你项目的最佳选择。如果你在实践领域驱动设计(DDD),我可能不需要告诉你丰富的模型是正确的方向。如果没有 DDD 的概念,实现可维护和灵活的丰富模型是具有挑战性的。如果你的程序围绕复杂的领域模型构建,并直接使用对象关系映射器(ORM)将这些类持久化到数据库中,丰富的模型可能是一个好的选择。使用 Cosmos DB、Firebase、MongoDB 或任何其他文档数据库可以使将复杂模型作为单个文档存储比作为表集合更容易(这也适用于贫血型模型)。正如你可能已经注意到的,本节中有许多“如果”,因为我认为没有绝对的答案来决定丰富的模型是否更好,这更多是一个问题,即它是否比整体更好,是否更适合你的具体情况。你还需要考虑你个人的偏好和技能。经验很可能是你最好的盟友,所以我建议编写、编写和编写更多的应用程序来获得那种经验。
贫血型领域模型
一个贫血的领域模型通常不包含方法,而只有获取器和设置器。这样的模型不应包含业务逻辑规则。我们之前提到的Product类将看起来像这样:
public class Product
{
public int? Id { get; set; }
public required string Name { get; set; }
public int QuantityInStock { get; set; }
}
在前面的代码中,类中不再有任何方法,只剩下三个具有公共设置器的属性。我们还可以利用记录类来为组合添加不可变性。至于逻辑,我们必须将其移动到其他类中。一种模式是将逻辑移动到服务层。在这样一个贫血模型之前的服务层将接受输入,修改领域对象,并更新数据库。区别在于服务拥有逻辑而不是丰富的模型。使用贫血模型,将操作与数据分离可以帮助我们为系统增加灵活性。然而,由于外部参与者(服务)正在修改模型而不是模型自我管理,因此强制执行模型在任何给定时间的状态可能具有挑战性。将逻辑封装到更小的单元中使得管理每个单元更容易,并且将那些依赖项注入服务类比注入实体本身更容易。拥有更多更小的代码单元可以使系统对新手来说更加可怕,因为它有更多的移动部件。另一方面,如果系统是围绕良好定义的抽象构建的,那么单独测试每个单元可能更容易。然而,测试可能相当不同。在我们的丰富模型的情况下,我们分别测试规则和持久性。我们称之为持久性无知,这允许我们单独测试业务规则。然后我们可以创建集成测试来覆盖服务层的持久性方面,以及更多针对数据和领域级别的单元和集成测试。使用贫血模型,我们使用服务层级别的集成测试同时测试业务规则和持久性,或者在单元测试中仅测试业务规则,同时模拟持久性部分。由于模型只是一个没有逻辑的数据包,因此那里没有什么可以测试的。总的来说,如果遵循相同的严格领域分析过程,由服务层支持的贫血模型的业务规则应该与丰富的领域模型一样复杂。最大的区别应该在于方法位于哪个类中。贫血模型是适用于无状态系统,如 RESTful API 的好选择。由于你必须为每个请求重新创建模型的状态,因此贫血模型可以为你提供一种独立地重新创建模型较小部分的方法,这些较小的类针对每个用例进行了优化。无状态系统需要比纯面向对象方法更程序化的思维方式,这使得贫血模型成为该领域的绝佳候选者。
我个人非常喜欢服务层背后的贫血模型,但有些人可能不同意我的看法。我建议选择你认为最适合你正在构建的系统的方法,而不是基于别人在另一个系统中做的事情来做某事。
另一个不错的建议是让重构流程从上到下流动到正确的位置。例如,如果你觉得一个方法绑定到一个实体上,没有什么阻止你将那部分逻辑移动到该实体而不是服务类中。如果服务更合适,就将逻辑移动到服务类中。
接下来,让我们回到领域层,并探讨多年来出现的一种模式,即使用服务层来保护领域模型,将领域层分为两个不同的部分。
服务层
服务层保护领域模型并封装领域逻辑。服务层协调与模型或外部资源(如数据库)交互的复杂性。然后,多个组件可以使用服务层,同时对其模型了解有限:

图 14.5:服务层与其他层的关系
前面的图示显示,表示层与服务层进行通信,服务层负责管理领域模型并实现业务逻辑。服务层包含服务,这些是与其他领域对象(如领域模型和数据层)交互的类。我们可以进一步将服务分为两类,领域服务和应用服务:
-
领域服务是我们之前所讨论的服务。它们包含领域逻辑,并允许表示层的消费者读取或写入数据。它们访问和修改领域模型。
-
应用服务,如邮件服务,与领域无关,应该放在其他地方,比如在共享的(为什么要在每个项目中重写邮件服务,对吧?)。
与其他层一样,你的服务层可以公开其自己的模型,保护其消费者免受领域模型(内部)变化的影响。换句话说,服务层应该只公开其契约和接口(关键词:保护)。服务层是一种外观模式。
我们将进一步探讨将贫血类复制到其他贫血类中的最小化方法。
有许多方式可以解释这一层,我将尝试以浓缩的方式尽可能多地展示(从简单到复杂):
-
服务层的类和接口可以是领域层组件的一部分,例如在Services目录中创建。这不太可重用,但它为未来共享服务铺平了道路,而无需最初管理多个项目。它需要严谨,不要依赖于你不应该依赖的东西。
-
服务层可以是一个包含接口和实现的组件。这在可重用性和维护时间之间是一个很好的折衷方案。很可能你永远不会需要两个实现(参见下一点),因为服务与逻辑相关联;它们是领域。你甚至可以隐藏实现,就像我们在第十一章的结构模式中做的不透明外观一样。
-
服务层可以被分为两个组件——一个包含抽象(由消费者引用)和一个包含实现。
-
服务层可以是实际的 Web 服务层(例如 Web API)。
当编写服务代码时,按照惯例,人们通常在服务类后缀加上Service,例如ProductService和InventoryService;接口也是如此(IProductService和IInventoryService)。无论你选择哪种技术,记住服务层包含领域逻辑,并保护领域模型免受直接访问。服务层是一个惊人的补充,它保护并封装了操作贫血领域模型的逻辑。如果它只是一个传递,那么它可能会违背丰富领域模型的目的,但它可以非常有助于处理影响多个领域对象的复杂、非原子业务规则。是否添加服务层的主要决定因素与您项目领域的复杂性相关。越复杂,就越有意义。越简单,就越没有意义。以下是一些建议:
-
在使用贫血模型时,添加一个服务层。
-
为非常复杂的领域添加服务层。
-
不要为低复杂度领域或数据库外观应用添加服务层。
现在,让我们看看数据层。
数据
数据层是持久化代码所在的地方。在大多数程序中,我们需要某种持久化来存储我们的应用程序数据,这通常是数据库。在讨论数据层时,会想到几种模式,包括工作单元和存储库模式,这些模式非常常见。我们在本小节的末尾简要介绍了这两个模式。我们可以直接持久化我们的领域模型,或者创建一个更适合存储的数据模型。例如,多对多关系在面向对象的世界中不是一件事情,而从关系数据库的角度来看则是。你可以将数据模型看作是数据的DTO。数据模型是数据在您的数据存储中的存储方式;也就是说,您如何建模数据或您必须忍受什么。在一个经典的分层项目中,您别无选择,只能有一个数据模型。然而,随着我们继续探索更多选项,我们将探索更好的解决方案。
ORM 是一种将对象转换为数据库语言(如 SQL)的软件。它允许修改数据、查询数据、将数据加载到对象中,等等。
现代数据层通常利用一个 ORM(对象关系映射)如 Entity Framework Core(EF Core),它为我们做了大部分工作,使我们的生活变得更简单。在 EF Core 的情况下,它允许我们在多个提供者之间进行选择,从 SQL Server 到 Cosmos DB,再到内存提供者。EF Core 的好处在于它已经为我们实现了 工作单元 和 仓储 模式,以及其他一些功能。在书中,我们使用内存提供者来减少设置时间并运行集成测试。
如果你之前使用过 EF6 并且对 Entity Framework 感到恐惧,要知道 EF Core 更轻量级、更快,并且更容易测试。不妨再试一次。EF Core 的性能现在也非常高。然而,如果你想要完全控制你的 SQL 代码,可以寻找 Dapper(不要与 Dapr 混淆)。
我不想对这些模式进行过多细节的介绍,但它们的重要性足以值得一个概述。正如所提到的,EF Core 已经实现了这些模式,所以我们不必处理它们。此外,使用这些模式并不总是可取的,可能难以正确实现,并且可能导致数据访问层臃肿,但使用得当的话,它们也可以非常有用。
我已经写了一系列关于仓储模式的文章。请参阅 进一步阅读 部分。
在此期间,让我们至少研究一下它们的目标,以便了解它们的作用,如果出现需要编写此类组件的情况,你知道该往哪里寻找。
仓储模式
仓储模式的目标是允许消费者以面向对象的方式查询数据库。通常,这意味着缓存对象和动态过滤数据。EF Core 通过 DbSet<T> 来表示这个概念,并使用 LINQ 和 IQueryable<T> 接口提供动态过滤。人们也使用术语 repository 来表示 表数据网关模式,这是另一种模式,它模拟一个类,为我们提供访问数据库中单个表的方法,并提供对创建、更新、删除和从该数据库表获取实体的操作。这两种模式都来自 企业应用架构模式,并且被广泛使用。自制的自定义实现通常比仓储模式更多地遵循表数据网关模式。它们基于一个看起来像以下代码的接口,并包含创建、更新、删除和读取实体的方法。它们可以有一个基类实体或没有,在这种情况下,IEntity<TId>。Id 属性也可以是泛型或非泛型:
public interface IRepository<T, TId>
where T : class, IEntity<TId>
{
Task<IEnumerable<T>> AllAsync(CancellationToken cancellationToken);
Task<T?> GetByIdAsync(TId id, CancellationToken cancellationToken);
Task<T> CreateAsync(T entity, CancellationToken cancellationToken);
Task UpdateAsync(T entity, CancellationToken cancellationToken);
Task DeleteAsync(TId id, CancellationToken cancellationToken);
}
public interface IEntity<TId>
{
TId Id { get; }
}
与那些表数据网关相关的一个常见问题是,人们会在接口中添加一个保存方法。只要更新单个实体,这应该没问题。然而,这使得跨多个仓库的事务管理更加困难或依赖于底层实现(破坏抽象)。为了提交或回滚这样的事务,我们可以利用工作单元模式,将保存方法从表数据网关移动到那里。例如,当使用 EF Core 时,我们可以使用DbSet<Product>(db.Products属性)将新产品添加到数据库,如下所示:
db.Products.Add(new Data.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,增加了扩展的过滤能力。当然,使用 EF Core,我们可以查询项目集合,获取所有产品并将它们投影为域对象,如下所示:
_db.Products.Select(p => new Domain.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 的 Repository 模式实现,DbSet<T>。这些例子可能看起来微不足道,但要实现与 EF Core 功能相媲美的自定义仓库需要相当大的努力。EF Core 的工作单元,DbContext类,包含了保存方法来持久化对其所有DbSet<T>属性(仓库)所做的修改。自制的实现通常在仓库本身上具有这样的方法,这使得跨仓库事务的处理更加困难,并导致仓库膨胀,包含大量针对特定操作的方法来处理这些情况。现在我们理解了Repository 模式背后的概念,让我们在回到分层之前,先概述一下工作单元模式。
工作单元模式
工作单元跟踪事务的对象表示。换句话说,它管理一个注册表,记录应该创建、更新和删除的对象。它允许我们在单个事务中组合多个更改(一个数据库调用),相对于每次更改都调用数据库,提供了多个优势。假设我们使用的是关系数据库,这里有两个优势:
-
首先,它可以加快数据访问速度;调用数据库是慢的,所以限制调用和连接的数量可以提高性能。
-
第二,运行事务而不是单个操作允许我们在一个操作失败时回滚所有操作,或者在所有操作都成功时提交整个事务。
EF Core 使用DbContext类及其底层类型(如DatabaseFacade和ChangeTracker类)实现此模式。我们的小型应用程序不需要事务,但概念是相同的。以下是一个使用 EF Core 发生的情况的示例:
var product = _db.Products.Find(productId);
product.QuantityInStock += amount;
_db.SaveChanges();
上述代码执行以下操作:
-
查询数据库以获取单个实体。
-
更改了
QuantityInStock属性的值。 -
将更改持久回数据库。
实际上发生的情况更接近以下内容:
-
我们通过
ProductContext(一个单位工作)请求 EF Core 的单个实体,它公开了DbSet<Product>属性(产品仓库)。在底层,EF Core 执行以下操作:-
查询数据库。
-
缓存实体。
-
跟踪该实体的更改。
-
将其返回给我们。
-
-
我们更改了
QuantityInStock属性的值;EF Core 检测到变化并将对象标记为脏的。 -
我们告诉单位工作持久化它跟踪的更改,将脏产品保存回数据库。
在更复杂的场景中,我们可能会编写以下代码:
_db.Products.Add(newProduct);
_db.Products.Remove(productToDelete);
product.Name = "New product name";
_db.SaveChanges();
在这里,SaveChanges()方法触发保存三个操作,而不是单独发送它们。你可以使用DbContext的Database属性来控制数据库事务(有关更多信息,请参阅进一步阅读部分)。现在我们已经探讨了单位工作模式,我们可以自己实现它。这会给我们的应用程序增加价值吗?可能不会。如果你想构建自定义的单位工作或 EF Core 的包装器,有许多现有资源可以指导你。除非你想进行实验或需要自定义的单位工作和仓库(这是可能的),否则我建议远离这样做。记住:只做程序正确运行所需的事情。
当我说只做需要做的事情时,请不要误解;无序的工程尝试和实验是探索的好方法,我鼓励你这样做。然而,我建议并行进行,这样你就可以创新,学习,甚至可能将知识迁移到你的应用程序中,而不是浪费时间并破坏事物。如果你使用 Git,创建一个实验分支是一个很好的方法。然后,如果你的实验没有成功,你可以删除它,如果它产生了积极的结果,你可以合并分支,或者根据团队的现有政策将其保留为参考。
现在我们已经探讨了仓库和单位工作模式的高级视图以及这些常见层的作用,我们可以继续我们的分层使用之旅。
抽象层
本节通过使用抽象数据层实现来探讨抽象层。这种抽象方式非常有用,并且是向整洁架构迈进的一步。此外,你几乎可以用这种方式抽象任何事物,这不过是应用依赖倒置原则(DIP)。让我们从一些背景信息和问题开始:
-
领域层是逻辑所在的地方。
-
UI将用户链接到领域,暴露了该领域中内置的功能。
-
数据层应该是领域盲目使用的实现细节。
-
数据层包含知道数据存储位置的代码,这应该与领域无关,但领域直接依赖于它。
解决领域和数据持久化实现之间紧密耦合的方案是创建一个额外的抽象层,如下面的图中所示:

图 14.6:用数据(持久化)抽象层替换数据层
新规则:只有接口和数据模型类进入数据抽象层。这个新层现在定义了我们的数据访问 API,除了暴露一系列接口——合同。然后,我们可以根据这个抽象层合同创建一个或多个数据实现,例如使用 EF Core。抽象和实现之间的链接是通过依赖注入完成的。在组合根中定义的绑定解释了表示层和数据实现之间的间接连接。新的依赖树看起来像这样:

图 14.7:层之间的关系
表示层引用数据实现层的唯一目的是创建 DI 绑定。我们需要这些绑定来在创建领域类时注入正确的实现。此外,表示层不得使用数据层的抽象或实现。我创建了一个示例项目,展示了项目与类之间的关系。然而,那个项目会增加许多代码页面,所以我决定不在书中包含它。关于抽象层最重要的东西是层之间的依赖流,而不是代码本身。
项目可在 GitHub 上找到(
adpg.link/s9HX)。
在那个项目中,当消费者请求实现IProductRepository接口的对象时,程序会注入EF.ProductRepository类的实例。在这种情况下,消费类是ProductService,并且只依赖于IProductRepository接口。ProductService类本身并不知道实现细节:它只利用接口。对于只知道IProductService接口的程序加载ProductService类的情况也是一样。以下是该依赖树的视觉表示:

图 14.8:层、类和接口之间的依赖流
在前面的图中,看看依赖是如何汇聚到Data.Abstract层的。依赖树最终结束在那个抽象数据层。应用这一块架构理论,我们通过遵循DIP(依赖倒置原则)来反转数据层的依赖流。我们还切断了直接对 EF Core 的依赖,这使得我们可以实现新的数据层并替换它,而不会影响应用程序的其他部分或更新实现而不影响领域。正如我之前提到的,层之间的替换不应该经常发生,如果可能的话。尽管如此,这是分层演变的一个重要部分,更重要的是,我们可以将这项技术应用于任何层或项目,而不仅仅是数据层,因此理解如何反转依赖流是至关重要的。
要测试 API,您可以使用书中提供的 Postman 集合;访问
adpg.link/postman8或 GitHub (adpg.link/net8) 获取更多信息。
接下来,让我们探索共享和持久化丰富的领域模型。
共享模型
我们已经探讨了严格的分层以及如何应用 DIP,但我们仍然有多个模型。从一层复制模型到另一层的替代方案是在多个层之间共享一个模型,通常作为一个组件。从视觉上看,它看起来像这样:

图 14.9:在所有三层之间共享模型
任何事物都有利弊,所以无论这能为您节省多少时间,随着项目的推进和复杂化,它最终会回来困扰您,并成为痛点。假设您认为共享模型对您的应用程序来说是值得的,那么我建议在表示层使用视图模型或DTOs(数据传输对象)来控制并保持应用程序的输入和输出与模型松散耦合。这种方式保护底层的方式可以表示如下:

图 14.10:在领域和数据层之间共享模型
通过这样做,你最初可以通过在领域和数据层之间共享模型来节省一些时间。通过将共享模型隐藏在表示层之下,你可以在长期运行中避免许多问题,这使得在质量和开发时间之间达到一个良好的折衷。此外,由于你的表示层保护了你的应用程序免受外部世界的影响,你可以重构其他层而不会影响消费者。
这基本上是 Clean Architecture 所做的方式,但表示不同。使用它,模型位于应用程序的中心,并被操作和持久化。虽然层的名称不同,但概念仍然非常相似。关于这一点,稍后还会详细介绍。
视图模型和DTOs是成功程序和开发者理智的关键元素;它们应该为长期项目节省许多麻烦。我们将在第十六章,中介者和 CQRS 设计模式中重新审视并探讨控制输入和输出的概念,其中输入成为命令和查询。同时,让我们将这个概念与抽象层合并。在前一个项目中,数据抽象层拥有数据模型,而领域层拥有领域模型。在这个架构替代方案中,我们在两个层之间共享模型。表示层可以间接使用这个共享模型与领域层进行对话,而不将其暴露在外部。目标是直接持久化领域模型,并跳过从领域到数据层的复制,同时拥有那个打破领域逻辑和持久性之间紧密耦合的数据抽象层。以下是这种表示的视觉表示:

图 14.11:表示共享丰富模型的图
它非常适合丰富模型,但我们也可以为贫血模型做这件事。在丰富领域模型中,你将重建模型的工作委托给 ORM,并立即开始调用其方法。ORM 也会重建贫血模型,但这些类仅包含数据,因此你需要调用其他包含操作这些对象的逻辑的软件部分。在代码示例中,数据抽象层现在只包含数据访问抽象,如存储库,并引用现在作为持久化模型的新的Model项目。从概念上讲,它清理了一些事情:
-
数据抽象层的唯一责任是包含数据访问抽象。
-
领域层的唯一责任是实现领域服务和不属于该丰富模型的逻辑。
-
在贫血模型的情况下,领域层的责任将是封装所有的领域逻辑。
-
Model项目包含实体。
再次提醒,我这里省略了大部分代码,因为它与整体概念无关。如果您认为阅读代码会有帮助,可以查阅并探索 GitHub 上的示例(adpg.link/9F5C)。使用 IDE 浏览代码应该有助于您理解流程,并且与抽象层一样,项目、类和接口之间的依赖关系是关键。尽管如此,这里还是展示了使用该共享模型的 StockService 类,以便您可以查看一些与解释直接相关的代码:
namespace Domain.Services;
public class StockService : IStockService
{
private readonly IProductRepository _repository;
public StockService(IProductRepository repository)
{
_repository = repository ?? throw new ArgumentNullException(nameof(repository));
}
在前面的代码中,我们注入了我们在接下来的两个方法中使用的 IProductRepository 接口的实现。接下来,我们看看 AddStockAsync 方法:
public async Task<int> AddStockAsync(int productId, int amount, CancellationToken cancellationToken)
{
var product = await _repository.FindByIdAsync(productId, cancellationToken);
if (product == null)
{
throw new ProductNotFoundException(productId);
}
product.AddStock(amount);
await _repository.UpdateAsync(product, cancellationToken);
return product.QuantityInStock;
}
前面的代码中开始变得有趣,它执行以下操作:
-
仓库重新创建了包含逻辑的产品(模型)。
-
它验证产品是否存在。
-
它使用该模型并调用
AddStock方法(封装的领域逻辑)。 -
它告诉仓库更新产品。
-
它将更新后的产品的
QuantityInStock返回给服务的消费者。
接下来,我们探索 RemoveStockAsync 方法:
public async Task<int> RemoveStockAsync(int productId, int amount, CancellationToken cancellationToken)
{
var product = await _repository.FindByIdAsync(productId, cancellationToken);
if (product == null)
{
throw new ProductNotFoundException(productId);
}
product.RemoveStock(amount);
await _repository.UpdateAsync(product, cancellationToken);
return product.QuantityInStock;
}
}
我们将 AddStock 方法的相同逻辑应用于 RemoveStock 方法,但它调用 Product.RemoveStock 方法。从 StockService 类中,我们可以看到服务控制对领域模型(产品)的访问,通过抽象数据层获取和更新模型,通过调用其方法来操作模型,并返回领域数据(在这种情况下是一个 int,但可能是一个对象)。
这种设计可以是非常有帮助的,也可以是不受欢迎的。过多的项目依赖于并暴露共享模型可能会导致模型的一部分泄露给消费者,例如暴露不应暴露的属性,暴露整个领域模型作为输出,或者最糟糕的是,将其作为输入并打开可利用的漏洞和意外的错误。
请注意,不要将共享模型暴露给表示层消费者。
将逻辑推入模型并不总是可能或理想的,这就是为什么我们要探索多种类型的领域模型和共享它们的方式。做出良好的设计通常关于选择和决定每个场景使用哪种选项。在灵活性和健壮性之间也需要做出权衡。其余的代码与抽象层项目类似。您可以自由地探索源代码(adpg.link/9F5C)并将其与其他项目进行比较。最好的学习方法是实践,所以请玩转这些示例,添加功能,更新当前功能,删除内容,甚至构建您自己的项目。理解这些概念将帮助您将它们应用于不同的场景,有时会创建出人意料的但效率很高的结构。现在,让我们看看分层结构的最终演变:清洁架构。
清洁架构
现在我们已经讨论了许多分层方法,是时候将它们结合成清洁架构,也称为六边形架构、洋葱架构、端口和适配器等。清洁架构是层的一个进化,是组织层之间关系的一种方式,但与我们刚刚构建的非常相似。清洁架构建议用UI、核心和基础设施来代替展示、领域和数据(或持久化)。正如我们之前看到的,我们可以设计一个包含抽象或实现的层。当实现只依赖于抽象时,这就反转了依赖流。清洁架构强调这样的层,但有自己的组织指导。我们还探讨了将层分割成更小层(或多个项目)的理论概念,从而创建了更容易移植和重用的“破碎层”。清洁架构在基础设施层级别利用了这一概念。关于它的观点和变体可能和它的名称一样多,所以我会尽量保持普遍性,同时保留其本质。通过这样做,如果你对这种架构感兴趣,你可以选择一个资源,按照你喜欢的风格深入挖掘。让我们看看一个类似于我们在网上可以找到的图:

图 14.12:表示最基本清洁架构布局的图
从类似分层图的视角来看,前面的图可能看起来是这样的:

图 14.13:先前清洁架构图的两层视图
根据你选择的方法,你可以将这些层分割成多个其他子层。我们经常看到的一种做法是将核心层分割成实体和用例,如下所示:

图 14.14:广泛应用的清洁架构布局图
由于科技行业的人富有创造力,许多事物都有很多名称,但概念保持不变。从类似分层图的视角来看,那个图可能看起来是这样的:

图 14.15:先前清洁架构图的类似层视图
基础设施层是概念性的,可以代表多个项目,例如包含 EF Core 实现的基础设施组件和一个代表 Web UI 的网站项目。我们还可以将更多项目添加到基础设施层。Clean Architecture 的依赖规则指出,依赖只能指向内层,即从外层到内层。这意味着抽象位于内部,具体实现位于外部。根据前面的层状图,内部对应向下。这意味着一个层可以使用任何直接或间接的依赖,这意味着基础设施可以依赖于用例和实体。Clean Architecture 遵循自本书开始以来我们一直在讨论的所有原则,例如使用抽象解耦我们的实现、依赖反转和关注点分离。这些实现通过依赖注入粘合在抽象之上(这不是强制性的,但有助于)。我总是觉得那些圆形图有点令人困惑,所以这里是我对更新后的、更线性的图示的看法:

图 14.16:Clean Architecture 常见元素的两层视图
现在,让我们使用 Clean Architecture 重新审视我们的分层应用程序,从核心层开始。核心项目包含领域模型、用例(服务)以及满足这些用例所需的接口。我们在这个层中不能访问外部资源:没有数据库调用、磁盘访问或 HTTP 请求。这个层包含暴露此类交互的接口,但实现部分位于基础设施层。表示层被重命名为 Web 并位于外层,与 EF Core 实现一起。Web 项目只依赖于 Core 项目。由于组合根位于此项目,它必须加载 EF Core 实现项目以配置 IoC 容器。以下是表示共享模型与新的 Clean Architecture 项目结构之间关系的图示:

图 14.17:从共享项目到 Clean Architecture 项目结构
在前面的图中,我们将经典分层解决方案的中心合并为一个单独的 Core 项目。
这是 GitHub 上此项目的链接:
adpg.link/rT1P。
大部分代码并不那么相关,因为,再次强调,最重要的方面是项目之间的依赖流和关系。尽管如此,以下是我除了将组件移动到不同的项目之外所做的更改列表:
-
我移除了
ProductService类和IProductService接口,并直接从StockService类(Core项目)和/products端点(Web项目:Program.cs)使用了IProductRepository接口。 -
我移除了
IStockService接口,现在添加和删除股票的端点(Web项目:Program.cs)都直接依赖于StockService类。
你可能会想知道,为什么直接使用IProductRepository接口?由于Web项目(基础设施层)依赖于核心层,我们可以利用内向依赖流。只要功能没有业务逻辑,直接使用存储库是可以接受的。编写空壳和透明服务只会增加无用的复杂性。然而,当业务逻辑开始涉及时,为该场景创建一个服务或任何其他必要的领域实体。不要将业务逻辑打包到你的控制器或最小 API 代表中。我移除了IStockService接口,因为StockService类包含可以原样从基础设施层消费的具体业务规则。我知道我们从本书开始就强调了使用接口,但我经常说原则不是法律。总的来说,没有什么可以抽象的:如果业务规则发生变化,旧规则将不再需要。另一方面,我们本可以保留接口。
我在进一步阅读部分留下了一些链接。
如果你认为这非常适合你、你的团队、你的项目或你的组织,请随意深入了解并采用这种模式。在随后的章节中,我们将探讨一些模式,例如 CQRS、发布-订阅和基于特性的设计,这些模式我们可以与 Clean Architecture 结合使用,以增加灵活性和健壮性。当你的系统规模和复杂性增长时,这些尤其有用。
总结来说,Clean Architecture 是一个经过验证的应用程序构建模式,其本质上是分层的一个演变。许多变体可以帮助你管理用例、实体和基础设施;然而,我们在这里不会涉及这些。如果你寻求组织指导,有许多开源项目可以从 Clean Architecture 开始。
现在我们已经涵盖了所有这些,重要的是要注意,一方面,有理论,另一方面,生活正在向你脸上打。假设你在一家大企业工作。那么,你的雇主可能会投入数十万甚至数百万美元来运行实验,花几个月时间设计每一个小细节,并确保一切完美。即使如此,实现完美甚至可能吗?可能不可能。对于没有那种资本的公司,有时你必须用几千美元构建整个产品,因为它们不是试图转售它们,只是需要构建那个工具。这就是你的架构技能派上用场的时候。你如何以可维护的方式设计最不差的产品,同时满足利益相关者的期望?答案最重要的部分是提前设定期望。此外,永远不要忘记,随着时间的推移,有人需要维护和更改软件;没有软件不会发展;总有东西。
如果你处于必须评估在此背景下产品特性可行性的位置,降低期望可以是一个为不可预见的事情做计划的好方法。超预期比解释为什么你未能达到预期要容易。
让我们深入探讨这个问题,并看看一些可以帮助你的技巧。即使你在一个大型企业工作,你也应该从中得到一些收获。
要成为纯粹主义者,还是不要成为纯粹主义者?
在你的日常工作中,你可能并不总是需要领域层的刚性来在你的数据前建立一堵墙。也许你没有时间或金钱,或者这根本不值得做。获取并展示数据通常已经足够好,特别是对于只是数据库上的用户界面的简单数据驱动应用程序,就像许多内部工具那样。对“要成为纯粹主义者,还是不要成为纯粹主义者?”这个问题的答案是:这取决于!
本节涵盖了分层,但我们还探讨了其他面向特性的模式,因此我建议你继续阅读并探索使用第十七章的技巧,即垂直切片架构,第十八章的请求-端点-响应(REPR),以及第二十章的模块化单体,以在保持设计开销低的同时改进你的设计。
这里有一些例子,这些例子的答案取决于什么,以帮助你:
-
项目;例如:
-
领域密集型或逻辑密集型项目将从领域层中受益,帮助你集中部分以实现更高程度的可重用性和可维护性。
-
数据管理项目往往逻辑较少或没有逻辑。我们通常可以不添加领域层来构建它们,因为领域通常只是从表示层到数据的一个隧道;一个透传层。我们通常可以通过将它们分为两层来简化这些系统:数据和表示。
-
-
你的团队;例如,一个技术能力很强的团队可能会更有效地使用高级概念和模式,并且由于团队中有经验丰富的工程师可以支持他们,新来者的学习曲线应该更容易。这并不意味着技术能力较弱的团队应该降低目标;相反,这可能只是更难或需要更长的时间才能开始。分析每个项目,并找到相应的最佳模式。
-
你的老板;如果你的公司给你和你的团队施加压力,要求在创纪录的时间内交付复杂的应用程序,而没有人告诉你的老板这是不可能的,你可能需要大量削减边缘,并享受许多维护头痛,包括崩溃的系统、痛苦的部署等等。话虽如此,如果这些类型的项目不可避免,我会选择一个非常简单的设计,不追求可重用性——追求低到平均的可测试性和仅能正常工作的代码。
-
你的预算;再次强调,这通常取决于销售应用和功能的人。我看到了一些不可能实现的承诺,但仍然通过大量的努力、额外的时间和削减边缘实现了。当你走这条路时,要记住的是,在某个时候,累积的技术债务将无法偿还,并且只会变得更糟(这适用于所有预算)。
-
目标受众;使用该软件的人会对你的构建方式产生重大影响:询问他们。例如,假设你正在为你的同行开发者构建一个工具。在这种情况下,你可以削减你不会为技术能力较低的用户(如提供命令行工具而不是完整的用户界面)所采取的边缘。另一方面,如果你将你的应用针对多个客户端(网页、移动设备等),隔离你的应用组件并专注于可重用性可能是一个获胜的设计。
-
预期的质量;在构建原型和 SaaS 应用时,你不应该以相同的方式处理问题。对于原型来说,没有测试和不遵循最佳实践是可以接受的,甚至是鼓励的,但我建议对于生产质量的应用则相反。
-
生活中抛给你的其他事情;是的,生活是不可预测的,没有人能在书中涵盖所有可能的场景,所以当你构建下一个软件时,请记住以下几点:
-
不要过度设计你的应用。
-
仅实现你需要的功能,不要更多,按照你不是真的需要它(YAGNI)原则。
*** 使用你的判断力,选择最不糟糕的选项;没有完美的解决方案。**
-
我希望你发现这些指导有帮助,并且它将在你的职业生涯中为你服务。
在数据库之上构建一个外观
数据驱动型程序是我经常在小型企业中看到的一种软件类型。那些公司需要用计算机来支持他们的日常运营,而不是反过来。每个公司都需要内部工具,许多公司昨天就需要它们。原因很简单;每个公司都是独特的。正因为如此,由于它的商业模式、领导层或员工,它也需要独特的工具来帮助其日常运营。这些小型工具通常是数据库上的简单用户界面,控制对数据的访问。在这些情况下,你不需要过度设计的解决方案,只要每个人都清楚这个工具不会发展到它所不是的东西:一个简单的工具。在现实生活中,这个概念对非程序员来说很难解释,因为他们往往认为复杂的使用案例容易实现,而简单的使用案例难以实现。这是正常的;他们只是不知道,我们也都不知道某些事情。在这些情况下,我们工作的很大一部分也是教育人们。向决策者建议小型工具和大型商业应用之间的质量差异。教育和与利益相关者合作使他们意识到情况,并与你一起做出决策,从而提高项目质量,满足每个人的期望。这也可以减少双方的“这不是我的错”综合症。我发现,让客户和决策者沉浸在决策过程中,并让他们跟随开发周期,有助于他们了解程序背后的现实,并使双方保持愉快并更加满意。利益相关者得不到他们想要的东西,并不比你因为无法达到的截止日期而极度紧张更好。话虽如此,我们的教育角色并不局限于决策者。向你的同事传授新工具和技术也是提高你的团队、同事和自己的主要方式之一。解释概念并不总是像听起来那么容易。尽管如此,数据驱动型程序可能难以避免,尤其是如果你在为中小企业工作,所以尽量从中获得最佳效果。如今,随着低代码和无代码解决方案以及所有开源库的出现,你可能会节省很多这种麻烦,但也许不是全部。
记住,总有一天,有人必须维护那些小型工具。想象一下那个人就是你自己,并思考你希望有一些指南或文档来帮助你。我并不是说过度文档化项目,因为文档往往与代码不同步,反而变成了问题而不是解决方案。然而,在项目根目录下有一个简单的
README.md文件,解释如何构建和运行程序以及一些一般性指南,这可能会很有帮助。始终以你自己在阅读文档时的角度来考虑文档。大多数人不喜欢花几个小时阅读文档来理解一些简单的东西,所以请尽量保持简单。
当在数据库上构建一个门面时,你希望保持其简单性。此外,你应该明确指出它不应超出那个角色。构建这种结构的一种方法是将 EF Core 作为你的数据层,并搭建一个 MVC 应用程序作为你的展示层,以保护你的数据库。如果你需要访问控制,可以使用内置的 ASP.NET Core 身份验证和授权机制。然后你可以选择基于角色的或基于策略的访问控制,或者任何对你工具有意义的其他方式,允许你以你需要的方式控制对数据的访问。
保持简单可以帮助你在更短的时间内构建更多工具,让每个人都满意。
从分层角度来看,使用我之前的例子,你最终会拥有两个共享数据模型层:

图 14.18:数据库应用程序设计上的门面式展示层
你可以在这里到处创建一个视图模型来处理更复杂的视图,但关键是要将逻辑的复杂性降到最低。否则,你可能会发现,从头开始重写一个程序有时比试图修复它要快。此外,你也可以使用你拥有的任何其他展示工具和组件。在主应用程序开发期间,使用这个数据驱动架构作为临时应用程序也是一个好办法。它构建所需的时间非常少,用户可以立即访问它。你甚至可以从它那里获得反馈,这让你有机会在它们被实际(未来)应用程序实现之前修复任何错误,就像一个活生生的原型一样。
在这类应用程序中,良好的数据库设计可以走得很远。
并非所有项目都那么简单,但仍然,许多是;关键是确保程序足够好,同时确保你切对了角。在这些类型的应用程序中,展示层可以利用低代码解决方案,例如 Power Apps。
摘要
分层是设计应用程序时最常用的架构技术之一。应用程序通常被分割成多个不同的层,每个层管理单一的责任。最流行的三个层是表示层、领域层和数据层。你不必局限于三个层;你可以将每个层分割成更小的层(或同一概念层内的更小块),从而得到可组合、可管理和可维护的应用程序。此外,你可以创建抽象层来反转依赖流,并将接口与实现分离,正如我们在抽象层部分所看到的。你可以直接持久化领域实体或为数据层创建一个独立的模型。你也可以使用贫血模型(没有逻辑或方法)或丰富的模型(包含与实体相关的逻辑)。你可以将此模型在多个层之间共享,或者让每个层拥有自己的模型。从分层中诞生了 Clean Architecture,它指导我们将应用程序组织成同心层,通常将应用程序分割成用例。让我们看看这种方法如何帮助我们朝着SOLID原则在应用规模上前进:
-
S:分层引导我们水平分割责任,每个层围绕一个单一宏观关注点。分层的主要目标是责任分割。
-
O:抽象层使消费者能够根据提供的实现(具体层)采取不同的行动(改变行为)。
-
L:N/A
-
I:根据特征(或特征的一致组)分割层是一种将系统分割成更小块(接口)的方法。
-
D:抽象层直接导致依赖流反转,而经典分层则朝相反方向进行。
在下一章中,我们将学习如何使用对象映射器和开源工具集中化复制对象(模型)的逻辑,以帮助我们跳过实现,也称为生产性懒惰。
问题
让我们看看几个练习题:
-
当创建分层应用程序时,我们是否必须拥有表示层、领域层和数据层?
-
丰富的领域模型是否比贫血领域模型更好?
-
EF Core 是否实现了仓储和单元工作模式?
-
我们是否需要在数据层使用 ORM?
-
Clean Architecture 中的层能否访问任何内部层?
进一步阅读
这里有一些链接可以帮助你巩固本章所学的内容:
-
Dapper 是由 Stack Overflow 的人制作的.NET 的一个简单而强大的 ORM,如果你喜欢写 SQL,但不喜欢将数据映射到对象,这个 ORM 可能适合你:
adpg.link/pTYs。 -
我在 2017 年写的一篇文章,讨论了仓储模式;即「设计模式:ASP.NET Core Web API、服务和仓储 | 第五部分:仓储、ClanRepository 和集成测试」:
adpg.link/D53Z。 -
Entity Framework Core – 使用事务:
adpg.link/gxwD. -
这里有一些关于 Clean Architecture 的资源:
-
常见网络应用架构(Microsoft Learn):
adpg.link/Pnpn -
Microsoft eShopOnWeb ASP.NET Core 参考应用:
adpg.link/dsw1 -
GitHub—Clean Architecture (Ardalis/Steve Smith)—解决方案模板:
adpg.link/tpPi -
GitHub—Clean Architecture (Jason Taylor)—解决方案模板:
adpg.link/jxX2
-
答案
-
不,你可以根据需要拥有任意多的层,并且可以按自己的意愿命名和组织它们。
-
不,两者都有其位置、优点和缺点。
-
是的。
DbContext是工作单元模式的实现。DbSet<T>是仓储模式的实现。 -
不,你可以以任何你想要的方式查询任何系统。例如,你可以使用 ADO.NET 查询关系数据库,手动使用
DataReader创建对象,使用DataSet跟踪更改,或者做任何满足你需求的事情。尽管如此,ORMs 可以非常方便。 -
是的。一层永远不能访问外层,只能访问内层。****
第十五章:15 个对象映射器、聚合服务和外观
在开始之前:加入我们的 Discord 书籍社区
直接向作者本人提供反馈,并在我们的 Discord 服务器上与其他早期读者聊天(在“architecting-aspnet-core-apps-3e”频道下找到“EARLY ACCESS SUBSCRIPTION”)。

在本章中,我们探讨对象映射。正如我们在上一章中看到的,与层一起工作通常会导致从一层复制模型到另一层。对象映射器解决了这个问题。我们首先手动实现一个对象映射器。然后,通过重新组合映射器到映射服务中,探索聚合服务模式和映射外观模式,我们改进了我们的设计。最后,我们用两个开源工具替换了这项手动工作,这些工具帮助我们生成业务价值而不是编写映射代码。在本章中,我们涵盖了以下主题:
-
对象映射和对象映射器的概述
-
实现一个简单的对象映射器
-
探索过多的依赖代码异味
-
探索聚合服务模式
-
通过利用外观模式实现映射外观
-
使用服务定位器模式在映射器前面创建一个灵活的映射服务
-
使用 AutoMapper 将对象映射到另一个对象,替换我们自制的代码
-
使用 Mapperly 而不是 AutoMapper
对象映射器
什么是对象映射?简单来说,它是将一个对象属性的值复制到另一个对象的属性中的动作。但有时,属性名称不匹配;可能需要将对象层次结构扁平化并转换。正如我们在上一章中看到的,每一层都可以拥有自己的模型,这可能是好事,但这也需要以从一层到另一层复制对象为代价。我们也可以在层之间共享模型,但即使这样,我们通常也需要将一个对象映射到另一个对象。即使只是将你的模型映射到数据传输对象(DTOs),这也是不可避免的。
记住 DTOs 定义了我们的 API 合同。独立的合同类有助于维护系统,让我们选择何时修改它们。如果你跳过这部分,每次你更改模型时,它都会自动更新你的端点合同,可能会破坏一些客户端。此外,如果你直接输入模型,恶意用户可能会尝试绑定他们不应该绑定的属性值,导致潜在的安全问题(称为过度提交或过度提交攻击)。拥有良好的数据交换合同是设计健壮系统的一个关键。
在以前的项目中,映射逻辑是硬编码的,有时是重复的,并为执行映射的类添加了额外的职责。在本章中,我们将映射逻辑提取到对象映射器中,以解决这个问题。
目标
对象映射器的目标是复制一个对象属性值到另一个对象的属性中。它将映射逻辑封装在映射发生的地方之外。映射器还负责在两个对象不遵循相同结构时,将值从原始格式转换为目标格式。
设计
我们可以以多种方式设计对象映射器。以下是最基本的对象映射器设计:

图 15.1:对象映射器的基本设计
在图中,消费者使用IMapper接口将Type1类型的对象映射到Type2类型的对象。这并不是非常可重用,但它说明了概念。通过使用泛型的力量,我们可以将这个简单的设计升级到更可重用的版本:

图 15.2:泛型对象映射器设计
此设计允许我们通过为每个映射规则实现一次IMapper<TSource, TDestination>接口,将任何TSource映射到任何TDestination。一个类也可以实现多个映射规则。例如,我们可以在同一个类中实现Type1到Type2和Type2到Type1的映射(双向映射器)。另一种方式是使用以下设计,创建一个具有单个方法处理应用程序所有映射的IMapper接口:

图 15.3:使用单个 IMapper 作为入口点的对象映射
此最后设计的最大优点是易于使用。我们总是注入一个单一的IMapper,而不是为每种映射类型注入一个IMapper<TSource, TDestination>,这减少了依赖项的数量和消费此类映射器的复杂性。你可以以任何你想象的方式实现对象映射,但关键部分是映射器负责将一个对象映射到另一个对象。映射器应避免复杂的过程,例如从数据库加载数据等。它应该将一个对象的值复制到另一个对象中:仅此而已。考虑一下单一职责原则(SRP):类必须有单一的理由进行更改,并且由于它是一个对象映射器,这个理由应该是对象映射。让我们通过一些代码深入了解每个项目的设计。
项目 – 映射器
此项目是前一章中 Clean Architecture 代码的更新版本。该项目旨在展示将实体映射逻辑封装到映射器类中的设计灵活性,将此逻辑从消费者那里移开。当然,项目再次专注于当前用例,使学习这些主题变得更容易。首先,我们需要一个接口,它位于Core项目中,以便其他项目可以实施它们所需的映射。让我们采用我们看到的第二个设计:
namespace Core.Mappers;
public interface IMapper<TSource, TDestination>
{
TDestination Map(TSource entity);
}
使用这个接口,我们可以先创建数据映射器。但首先,让我们先创建记录类而不是匿名类型来命名端点返回的 DTO。以下是所有 DTO(来自 Program.cs 文件):
// Input stock DTOs
public record class AddStocksCommand(int Amount);
public record class RemoveStocksCommand(int Amount);
// Output stock DTO
public record class StockLevel(int QuantityInStock);
// Output "read all products" DTO
public record class ProductDetails(int Id, string Name, int QuantityInStock);
// Output Exceptions DTO
public record class ProductNotFound(int ProductId, string Message);
public record class NotEnoughStock(int AmountToRemove, int QuantityInStock, string Message);
四个输出 DTO 中的三个需要映射:
-
Product到ProductDetails -
ProductNotFoundException到ProductNotFound -
NotEnoughStockException到NotEnoughStock
为什么不映射
StockLevelDTO?在我们的案例中,当我们添加或移除库存时,StockService返回一个int,因此将原始值如int转换为StockLevel对象不需要对象映射器。此外,创建这样的对象映射器没有任何价值,并且会使代码更复杂。如果服务返回了一个对象,创建一个将对象映射到StockLevel的映射器将更有意义。
让我们从产品映射器开始(来自 Program.cs 文件):
public class ProductMapper : IMapper<Product, ProductDetails>
{
public ProductDetails Map(Product entity)
=> new(entity.Id ?? default, entity.Name, entity.QuantityInStock);
}
上述代码很简单;ProductMapper 类实现了 IMapper<Product, ProductDetails> 接口。Map 方法返回一个 ProductDetails 实例。高亮显示的代码确保 Id 属性不是 null,这不应该发生。我们也可以添加一个保护子句来确保 Id 属性不是 null。总的来说,Map 方法接收一个 Product 作为输入,并输出一个包含相同值的 ProductDetails 实例。然后让我们继续处理异常映射器(来自 Program.cs 文件):
public class ExceptionsMapper : IMapper<ProductNotFoundException, ProductNotFound>, IMapper<NotEnoughStockException, NotEnoughStock>
{
public ProductNotFound Map(ProductNotFoundException exception)
=> new(exception.ProductId, exception.Message);
public NotEnoughStock Map(NotEnoughStockException exception)
=> new(exception.AmountToRemove, exception.QuantityInStock, exception.Message);
}
与 ProductMapper 类相比,ExceptionsMapper 类通过两次实现 IMapper 接口来处理剩余的两个用例。两个 Map 方法处理将异常映射到其 DTO,导致一个类负责将异常映射到 DTO。让我们看看 products 端点(来自 第十四章,分层和整洁架构的 clean-architecture 项目的原始值):
app.MapGet("/products", async (
IProductRepository productRepository,
CancellationToken cancellationToken) =>
{
var products = await productRepository.AllAsync(cancellationToken);
return products.Select(p => new
{
p.Id,
p.Name,
p.QuantityInStock
});
});
在分析代码之前,让我们看看更新后的版本(来自 Program.cs 文件):
app.MapGet("/products", async (
IProductRepository productRepository,
IMapper<Product, ProductDetails> mapper,
CancellationToken cancellationToken) =>
{
var products = await productRepository.AllAsync(cancellationToken);
return products.Select(p => mapper.Map(p));
});
在前面的代码中,请求委托使用映射器替换了复制逻辑(原始代码中的高亮行)。这简化了处理器,将映射责任移动到映射对象中(前面代码中的高亮显示)——这是向 SRP(单一职责原则)迈出的又一步。让我们跳过添加库存端点,因为它与移除库存端点非常相似,但更简单,让我们关注后面的(来自 第十四章,分层和整洁架构的 clean-architecture 项目的原始值):
app.MapPost("/products/{productId:int}/remove-stocks", async (
int productId,
RemoveStocksCommand command,
StockService stockService,
CancellationToken cancellationToken) =>
{
try
{
var quantityInStock = await stockService.RemoveStockAsync(productId, command.Amount, cancellationToken);
var stockLevel = new StockLevel(quantityInStock);
return Results.Ok(stockLevel);
}
catch (NotEnoughStockException ex)
{
return Results.Conflict(new
{
ex.Message,
ex.AmountToRemove,
ex.QuantityInStock
});
}
catch (ProductNotFoundException ex)
{
return Results.NotFound(new
{
ex.Message,
productId,
});
}
});
再次,在分析代码之前,让我们看看更新后的版本(来自 Program.cs 文件):
app.MapPost("/products/{productId:int}/remove-stocks", async (
int productId,
RemoveStocksCommand command,
StockService stockService,
IMapper<ProductNotFoundException, ProductNotFound> notFoundMapper,
IMapper<NotEnoughStockException, NotEnoughStock> notEnoughStockMapper,
CancellationToken cancellationToken) =>
{
try
{
var quantityInStock = await stockService.RemoveStockAsync(productId, command.Amount, cancellationToken);
var stockLevel = new StockLevel(quantityInStock);
return Results.Ok(stockLevel);
}
catch (NotEnoughStockException ex)
{
return Results.Conflict(notEnoughStockMapper.Map(ex));
}
catch (ProductNotFoundException ex)
{
return Results.NotFound(notFoundMapper.Map(ex));
}
});
对于这个请求代理也发生了同样的事情,但我们注入了两个映射器而不是一个。我们将映射逻辑从使用匿名类型内联移动到映射器对象。尽管如此,这里出现了一个代码异味;你能闻到吗?我们将在完成这个项目后进行调查;同时,继续思考注入依赖项的数量。现在,由于代理依赖于封装映射责任的映射器接口,我们必须配置组合根并将映射器实现绑定到IMapper<TSource, TDestination>接口。服务绑定看起来像这样:
.AddSingleton<IMapper<Product, ProductDetails>, ProductMapper>()
.AddSingleton<IMapper<ProductNotFoundException, ProductNotFound>, ExceptionsMapper>()
.AddSingleton<IMapper<NotEnoughStockException, NotEnoughStock>, ExceptionsMapper>()
由于ExceptionsMapper实现了两个接口,我们将它们都绑定到该类上。这是抽象之美之一;移除股票的代理请求需要两个映射器,但即使不知道,它也两次接收了ExceptionsMapper的实例。我们也可以注册这些类,以便相同的实例被注入两次,如下所示:
.AddSingleton<ExceptionsMapper>()
.AddSingleton<IMapper<ProductNotFoundException, ProductNotFound>, ExceptionsMapper>(sp => sp.GetRequiredService<ExceptionsMapper>())
.AddSingleton<IMapper<NotEnoughStockException, NotEnoughStock>, ExceptionsMapper>(sp => sp.GetRequiredService<ExceptionsMapper>())
是的,我故意进行了相同类的双重注册。这证明了我们可以按照我们的意愿组合应用程序,而不会影响消费者。这是通过依赖于抽象而不是实现来实现的,正如依赖倒置原则(DIP——SOLID 中的“D”)所要求的。此外,根据接口隔离原则(ISP——SOLID 中的“I”),将接口划分为小块,使得这种场景成为可能。最后,我们可以利用依赖注入(DI)的力量将这些碎片粘合在一起。
结论
在探索更多替代方案之前,让我们看看对象映射如何帮助我们遵循SOLID原则:
-
S:使用映射器对象有助于将映射类型的责任从消费者中分离出来,使得维护和重构映射逻辑更容易。
-
O:通过注入映射器,我们可以更改映射逻辑,而无需更改消费者代码。
-
L:N/A
-
我:我们探索了不同的设计,这些设计提供了一个小的映射器接口,减少了组件之间的依赖性。
-
D:消费者只依赖于抽象,将实现的绑定移动到组合根,并反转了依赖流。
现在我们已经探讨了如何提取和使用映射器,让我们看看出现的代码异味。
代码异味——过多的依赖
长期来看,使用这种映射可能会变得繁琐,我们很快就会看到将三个或更多映射器注入单个请求代理或控制器的情况。消费者可能已经拥有其他依赖项,导致依赖项增加到四个或更多。这应该会引发以下警示:
- 这个类是否做得太多,承担了太多的责任?
在这种情况下,细粒度的IMapper接口使我们的请求代理充满了对映射器的依赖,这并不理想,也使得我们的代码更难阅读。
最佳解决方案是将异常处理责任从代理或控制器中移除,利用中间件或异常过滤器等。这种策略会将样板代码从端点移除。
由于我们正在讨论映射器,我们将探索更多对象映射概念,以帮助我们解决这个问题,这适用于许多用例。
作为一项经验法则,你希望将依赖项的数量限制在三个或更少。超过这个数量,问问自己这个类是否有问题;它是否承担了太多的责任?拥有超过三个依赖项本身并不是错误的;它只是表明你应该调查设计。如果没有问题,保持 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;
// Constructor injection, other methods, routing attributes, ...
public ProductDetails GetProductById(int id)
{
Product product = ...; // Fetch a product by id
ProductDetails dto = _mapper.EntityToDto.Map(product);
return dto;
}
}
在这个例子中,IProductMappers 聚合可能是有逻辑性的,因为它将 ProductsController 类使用的映射器归入其旗下。它负责将 ProductsController 相关的领域对象映射到 DTO 并反之,而控制器放弃了这一责任。你可以用任何东西创建聚合服务,而不仅仅是映射器。这在依赖注入(DI)密集型应用中是一个相当常见的模式(这也可以指向一些设计缺陷)。现在我们已经探讨了聚合服务模式,让我们来看看如何制作一个映射外观。
模式 – 映射外观
我们已经研究了外观;在这里,我们通过利用该设计模式探索另一种组织我们众多映射器的方法。我们不会像刚才那样做,而是创建一个映射外观来替换我们的聚合服务。使用外观的代码将更加优雅,因为它直接使用 Map 方法而不是属性。外观的责任与聚合相同,但它实现接口而不是将其作为属性公开。让我们看看代码:
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;
// Omitted constructor injection code
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);
}
}
在前面的代码中,ProductMapperService 类通过 IProductMapperService 接口实现 IMapper 接口,并将映射逻辑委托给每个注入的映射器:一个封装多个单个映射器的外观。接下来,我们看看消费外观的 ProductsController:
public class ProductsController : ControllerBase
{
private readonly IProductMapperService _mapper;
// Omitted constructor injection, other methods, routing attributes, ...
public ProductDetails GetProductById(int id)
{
Product product = ...; // Fetch a product by id
ProductDetails dto = _mapper.Map(product);
return dto;
}
}
从消费者角度来看(ProductsController 类),我发现写 _mapper.Map(...) 而不是 _mapper.SomeMapper.Map(...) 更干净。消费者不关心映射器在做什么映射;它只想映射需要映射的内容。如果我们将映射外观与前面例子中的聚合服务进行比较,外观承担了选择映射器的责任,并将其从消费者那里移开。这种设计更好地在类之间分配了责任。这是一个审查外观设计模式的绝佳机会。尽管如此,现在我们已经探讨了多种映射选项并检查了过多依赖的问题,是时候带着我们映射外观的增强版本继续我们的对象映射冒险了。
项目 – 映射服务
目标是简化具有通用接口的映射外观实现。为了实现这一点,我们正在实现如图 图 13.3 所示的图。这里有一个提醒:

图 15.4:使用单个 IMapper 接口进行对象映射
我们不会将接口命名为 IMapper,而是使用 IMappingService 这个名字。这个名字更合适,因为它不是映射任何东西;它是一个服务映射请求到正确映射器的调度器。让我们看看:
namespace Core.Mappers;
public interface IMappingService
{
TDestination Map<TSource, TDestination>(TSource entity);
}
该界面是自我解释的;它将任何 TSource 映射到任何 TDestination。在实现方面,我们正在利用 服务定位器 模式,所以我将类命名为 ServiceLocatorMappingService:
namespace Core.Mappers;
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 类不知道任何映射器;它只是在需要时动态地请求一个。
服务定位器模式不应该成为应用程序代码的一部分。然而,有时它可能很有帮助。例如,我们并不是试图在这个案例中欺骗依赖注入(DI)。相反,我们正在利用它的力量。
当以去除从组合根控制程序组合的可能性的方式来获取依赖项时,使用服务定位器是错误的,这违反了 IoC 原则。
在这种情况下,我们从 IoC 容器中动态加载映射器,限制了容器控制要注入的内容的能力,这是可以接受的,因为它对程序的维护性、灵活性和可靠性几乎没有负面影响。例如,我们可以替换
ServiceLocatorMappingService实现为另一个类,而不会影响IMappingService接口消费者。
现在,我们可以在需要映射的任何地方注入该服务并直接使用它。因为我们已经注册了映射器,所以我们只需要将 IMappingService 绑定到其 ServiceLocatorMappingService 实现并更新消费者。以下是 DI 绑定:
.AddSingleton<IMappingService, ServiceLocatorMappingService>();
如果我们查看移除股票端点的新实现,我们可以看到我们减少了映射器依赖项的数量到一个:
app.MapPost("/products/{productId:int}/remove-stocks", async (
int productId,
RemoveStocksCommand command,
StockService stockService,
IMappingService mapper,
CancellationToken cancellationToken) => {
try
{
var quantityInStock = await stockService.RemoveStockAsync(productId, command.Amount, cancellationToken);
var stockLevel = new StockLevel(quantityInStock);
return Results.Ok(stockLevel);
}
catch (NotEnoughStockException ex)
{
return Results.Conflict(mapper.Map<NotEnoughStockException, NotEnoughStock>(ex));
}
catch (ProductNotFoundException ex)
{
return Results.NotFound(mapper.Map<ProductNotFoundException, ProductNotFound>(ex));
}
});
之前的代码与上一个示例类似,但我们用新的服务(突出显示的行)替换了映射器。就是这样;我们现在有一个通用的映射服务,它将映射委托给我们在 IoC 容器中注册的任何映射器。
即使你不太可能经常手动实现对象映射器,探索和重新审视这些模式和代码异味是非常好的,并将帮助你编写更好的软件。
这并不是我们对象映射探索的终点。我们有两个工具要探索,首先是 AutoMapper,它为我们完成所有的对象映射工作。
项目 – AutoMapper
我们刚刚介绍了实现对象映射的不同方法,但在这里我们利用一个名为 AutoMapper 的开源工具来为我们完成这项工作,而不是自己实现。如果已经有工具做了这件事,为什么还要费心学习所有这些?这样做有几个原因:
-
理解这些概念很重要;你并不总是需要一个完整的工具,比如 AutoMapper。
-
这使我们能够涵盖多个在其他上下文中可以使用并应用于具有不同职责的组件的模式。所以,总的来说,你应该在这次对象映射过程中学习了多种新技术。
-
最后,我们深入研究了将 SOLID 原则应用于编写更好的程序。
AutoMapper 项目也是 Clean Architecture 示例的副本。这个项目和其它项目最大的不同之处在于我们不需要定义任何接口,因为 AutoMapper 提供了一个包含所有所需方法的IMapper接口。要安装 AutoMapper,你可以使用 CLI(dotnet add package AutoMapper)、Visual Studio 的 NuGet 包管理器或手动更新.csproj文件来安装AutoMapper NuGet 包。定义我们的映射器最好的方式是使用 AutoMapper 的配置文件机制。配置文件是一个简单的类,它继承自AutoMapper.Profile并包含一个对象到另一个对象的映射。我们使用配置文件来分组映射器,但在我们的情况下,只有三个映射,我决定创建一个单独的WebProfile类。最后,我们不再手动注册配置文件,而是可以使用AutoMapper.Extensions.Microsoft.DependencyInjection包扫描一个或多个程序集,将所有配置文件加载到 AutoMapper 中。
当安装
AutoMapper.Extensions.Microsoft.DependencyInjection包时,你不需要加载AutoMapper包。
AutoMapper 的内容远不止我们在这里所涵盖的,但网上有足够的资源,包括官方文档,可以帮助你深入了解这个工具。这个项目的目标是进行基本的对象映射。在Web项目中,我们必须创建以下映射:
-
将
Product映射到ProductDetails -
将
NotEnoughStockException映射到NotEnoughStock -
将
ProductNotFoundException映射到ProductNotFound
要做到这一点,我们创建以下WebProfile类(位于Program.cs文件中,但可以存在于任何位置):
using AutoMapper;
public class WebProfile : Profile
{
public WebProfile()
{
CreateMap<Product, ProductDetails>();
CreateMap<NotEnoughStockException, NotEnoughStock>();
CreateMap<ProductNotFoundException, ProductNotFound>();
}
}
AutoMapper 中的配置文件不过是一个在构造函数中创建映射的类。Profile类为你添加了执行此操作所需的方法,例如CreateMap方法。这会做什么?调用CreateMap<Product, ProductDetails>()方法告诉 AutoMapper 注册一个将Product映射到ProductDetails的映射器。其他两个CreateMap调用为其他两个映射执行相同的操作。目前我们只需要这些,因为 AutoMapper 使用约定来映射属性,并且我们的模型和 DTO 类具有相同名称的属性集合。
在前面的例子中,我们在
Core层定义了一些映射器。在这个例子中,我们依赖于一个库,因此考虑依赖流就更加重要。我们仅在Web层映射对象,因此没有必要在Core层放置对 AutoMapper 的依赖。记住,所有层都直接或间接依赖于Core,所以在那一层有对 AutoMapper 的依赖意味着所有层也会依赖它。因此,在这个例子中,我们在
Web层创建了WebProfile类,将对 AutoMapper 的依赖限制在该层。仅让Web层依赖 AutoMapper 允许所有外部层(如果我们添加更多的话)控制它们如何进行对象映射,从而为每一层提供更多的独立性。尽可能限制对象映射也是一个最佳实践。我在章节末尾的“进一步阅读”部分添加了一个链接到 AutoMapper 使用指南。
既然我们已经有一个配置文件,我们需要将其注册到 IoC 容器中,但不必手动操作;我们可以通过使用 AddAutoMapper 扩展方法之一从组合根扫描配置文件,以扫描一个或多个程序集:
builder.Services.AddAutoMapper(typeof(WebProfile).Assembly);
前面的方法接受一个 params Assembly[] assemblies 参数,这意味着我们可以向它传递多个 Assembly 实例。
AddAutoMapper扩展方法来自AutoMapper.Extensions.Microsoft.DependencyInjection包。
由于我们只有一个配置文件在一个程序集中,我们利用这个类通过传递 typeof(WebProfile).Assembly 参数到 AddAutoMapper 方法来访问该程序集。从那里,AutoMapper 在该程序集中扫描配置文件并找到 WebProfile 类。如果有多个,它会注册所有找到的。扫描这种类型的好处是,一旦将 AutoMapper 注册到 IoC 容器中,你可以在任何已注册的程序集中添加配置文件,它们会自动加载;之后无需做任何事情,只需编写有用的代码。扫描程序集也鼓励约定式组合,从长远来看更容易维护。程序集扫描的缺点是,当某些内容未注册时,调试可能很困难,因为注册过程不够明确。现在我们已经创建并注册了配置文件到 IoC 容器中,是时候使用 AutoMapper 了。让我们看看最初创建的三个端点:
app.MapGet("/products", async (
IProductRepository productRepository,
IMapper mapper,
CancellationToken cancellationToken) =>
{
var products = await productRepository.AllAsync(cancellationToken);
return products.Select(p => mapper.Map<Product, ProductDetails>(p));
});
app.MapPost("/products/{productId:int}/add-stocks", async (
int productId,
AddStocksCommand command,
StockService stockService,
IMapper mapper,
CancellationToken cancellationToken) =>
{
try
{
var quantityInStock = await stockService.AddStockAsync(productId, command.Amount, cancellationToken);
var stockLevel = new StockLevel(quantityInStock);
return Results.Ok(stockLevel);
}
catch (ProductNotFoundException ex)
{
return Results.NotFound(mapper.Map<ProductNotFound>(ex));
}
});
app.MapPost("/products/{productId:int}/remove-stocks", async (
int productId,
RemoveStocksCommand command,
StockService stockService,
IMapper mapper,
CancellationToken cancellationToken) =>
{
try
{
var quantityInStock = await stockService.RemoveStockAsync(productId, command.Amount, cancellationToken);
var stockLevel = new StockLevel(quantityInStock);
return Results.Ok(stockLevel);
}
catch (NotEnoughStockException ex)
{
return Results.Conflict(mapper.Map<NotEnoughStock>(ex));
}
catch (ProductNotFoundException ex)
{
return Results.NotFound(mapper.Map<ProductNotFound>(ex));
}
});
前面的代码展示了使用 AutoMapper 与其他选项的相似性。我们注入一个 IMapper 接口,然后使用它来映射实体。与上一个例子中显式指定 TSource 和 TDestination 不同,当使用 AutoMapper 时,我们只需指定 TDestination 泛型参数,从而简化代码的复杂性。
假设您正在使用 EF Core 返回的
IQueryable集合上的 AutoMapper。在这种情况下,您应该使用ProjectTo方法,该方法将 EF 查询的字段数量限制为您需要的那些。在我们的情况下,这不会改变什么,因为我们需要整个实体。这里有一个示例,它从 EF Core 获取所有产品并将它们投影到
ProductDto实例:
public IEnumerable<ProductDto> GetAllProducts()
{
return _mapper.ProjectTo<ProductDto>(_db.Products);
}
在性能方面,这是推荐的方式使用 AutoMapper 与 EF Core。
最后但同样重要的是,我们可以在应用程序启动时断言我们的映射器配置是否有效。这并不识别缺失的映射器,但验证已注册的映射器配置是否正确。推荐的做法是在单元测试中这样做。为了实现这一点,我在末尾添加了以下行,使自动生成的Program类公开:
public partial class Program { }
然后,我创建了一个名为Web.Tests的测试项目,其中包含以下代码:
namespace Web;
public class StartupTest
{
[Fact]
public async Task AutoMapper_configuration_is_valid()
{
// Arrange
await using var application = new AutoMapperAppWebApplication();
var mapper = application.Services.GetRequiredService<IMapper>();
mapper.ConfigurationProvider.AssertConfigurationIsValid();
}
}
internal class AutoMapperAppWebApplication : WebApplicationFactory<Program>{}
在前面的代码中,我们验证了所有的 AutoMapper 映射是否有效。要使测试失败,您可以在WebProfile类的以下行取消注释:
CreateMap<NotEnoughStockException, Product>();
AutoMapperAppWebApplication类存在是为了在存在多个测试用例时集中初始化测试。在测试项目中,我创建了一个第二个测试用例,确保products端点是可到达的。为了使这两个测试一起工作,我们必须更改数据库名称以避免播种冲突,这样每个测试都在自己的数据库上运行。这与我们在Program.cs文件中播种数据库的方式有关,这通常不是我们通常做的事情,除非是开发或概念验证。尽管如此,针对多个数据库的测试可以很有用,以便隔离测试。以下是第二个测试用例和更新的AutoMapperAppWebApplication类,以供您参考:
public class StartupTest
{
[Fact]
public async Task The_products_endpoint_should_be_reachable()
{
await using var application = new AutoMapperAppWebApplication();
using var client = application.CreateClient();
using var response = await client.GetAsync("/products");
response.EnsureSuccessStatusCode();
}
// Omitted AutoMapper_configuration_is_valid method
}
internal class AutoMapperAppWebApplication : WebApplicationFactory<Program>
{
private readonly string _databaseName;
public AutoMapperAppWebApplication([CallerMemberName]string? databaseName = default)
{
_databaseName = databaseName ?? nameof(AutoMapperAppWebApplication);
}
protected override IHost CreateHost(IHostBuilder builder)
{
builder.ConfigureServices(services =>
{
services.AddScoped(sp =>
{
return new DbContextOptionsBuilder<ProductContext>()
.UseInMemoryDatabase(_databaseName)
.UseApplicationServiceProvider(sp)
.Options;
});
});
return base.CreateHost(builder);
}
}
运行测试确保我们的应用程序中的映射正常工作,并且有一个端点是可到达的。我们可以添加更多测试,但这两个测试覆盖了我们代码的大约 50%。
在前面的代码中使用的
CallerMemberNameAttribute是System.Runtime.CompilerServices命名空间的一部分,允许其装饰的成员访问调用它的方法的名称。在这种情况下,databaseName参数接收测试方法名称。
这样就完成了 AutoMapper 项目。此时,你应该开始熟悉对象映射。我建议每次项目需要对象映射时,都评估一下 AutoMapper 是否是合适的工具。如果 AutoMapper 不符合你的需求,你总是可以加载另一个工具或实现自己的映射逻辑。如果映射在太多层级上完成,可能另一种应用程序架构模式会更好,或者需要重新思考。AutoMapper 是基于约定的,并且在我们没有进行任何配置的情况下做了很多工作。它也是基于配置的,通过缓存转换来提高性能。我们还可以创建 类型转换器、值解析器、值转换器等等。AutoMapper 让我们远离编写无聊的映射代码。然而,AutoMapper 已经很老了,功能完善,由于许多项目都在使用它,几乎不可避免。但是,它并不是最快的,这就是为什么我们接下来要探索 Mapperly。
项目 – Mapperly
Mapperly 是一个较新的对象映射库,它利用源生成技术使其运行速度极快。要开始使用,我们必须添加对 Riok.Mapperly NuGet 包的依赖。
源生成器是在 .NET 5 中引入的,允许开发者在编译时生成 C# 代码。
使用 Mapperly 创建对象映射有许多方法,调整映射过程也有许多选项。以下代码示例与其他示例类似,但使用了 Mapperly。我们介绍了以下使用 Mapperly 的方法:
-
注入映射器类。
-
使用静态方法。
-
使用扩展方法。
让我们从注入的映射器开始。首先,类必须是 partial 的,这样源生成器才能扩展它(这就是源生成器的工作方式)。用 [Mapper] 属性(突出显示)装饰这个类。然后,在那个 partial 类中,我们必须创建一个或多个具有我们想要创建的映射器签名的 partial 方法(如 MapToProductDetails 方法),如下所示:
[Mapper]
public partial class ProductMapper
{
public partial ProductDetails MapToProductDetails(Product product);
}
在编译时,代码生成器会创建以下类(我已格式化代码以便于阅读):
public partial class ProductMapper
{
public partial ProductDetails MapToProductDetails(Product product)
{
var target = new ProductDetails(
product.Id ?? throw new ArgumentNullException(nameof(product.Id)),
product.Name,
product.QuantityInStock
);
return target;
}
}
Mapperly 在生成的 partial 类中为我们编写了样板代码,这就是它为什么运行得如此之快。要使用映射器,我们必须将其注册到 IoC 容器中,并将其注入到我们的端点。让我们再次将其设置为单例:
builder.Services.AddSingleton<ProductMapper>();
然后,我们可以这样注入和使用它:
app.MapGet("/products", async (
IProductRepository productRepository,
ProductMapper mapper,
CancellationToken cancellationToken) =>
{
var products = await productRepository.AllAsync(cancellationToken);
return products.Select(p => mapper.MapToProductDetails(p));
});
前一个代码块中突出显示的代码表明我们可以像使用任何其他类一样使用我们的映射器。最大的缺点是,如果我们没有明智地创建它们,我们可能会将许多映射器注入到单个类或端点中。此外,我们必须将所有映射器注册到 IoC 容器中,这会创建大量的样板代码,但使过程更加明确。另一方面,我们可以扫描程序集以查找所有带有[Mapper]属性的类。如果你想为你的映射器提供一个类似于接口的抽象层,你必须自己设计它,因为 Mapperly 只生成映射器。以下是一个示例:
public interface IMapper
{
NotEnoughStock MapToDto(NotEnoughStockException source);
ProductNotFound MapToDto(ProductNotFoundException source);
ProductDetails MapToProductDetails(Product product);
}
[Mapper]
public partial class Mapper : IMapper
{
public partial NotEnoughStock MapToDto(NotEnoughStockException source);
public partial ProductNotFound MapToDto(ProductNotFoundException source);
public partial ProductDetails MapToProductDetails(Product product);
}
前面的代码将所有映射方法集中在一个类和接口下,允许你注入一个类似于 AutoMapper 的接口。在随后的章节中,我们将探讨组织映射器和应用程序代码的方法,这些方法不涉及创建中央映射器类。
要检查生成的代码,你可以在项目文件中的
PropertyGroup标签内添加EmitCompilerGeneratedFiles属性,并将其值设置为true,如下所示:
<PropertyGroup>
<EmitCompilerGeneratedFiles>true</EmitCompilerGeneratedFiles>
</PropertyGroup>
然后,生成的 C#文件将在
obj\Debug\net8.0\generated目录下可用。根据你的配置将net8.0子目录更改为 SDK 版本和Debug。
接下来,我们将探讨如何创建一个静态映射器,其过程非常相似,但我们必须将类和方法都设置为static,如下所示:
[Mapper]
public static partial class ExceptionMapper
{
public static partial ProductNotFound Map(ProductNotFoundException exception);
}
Mapperly 将前面的代码生成以下内容(格式化以提高可读性):
public static partial class ExceptionMapper
{
public static partial ProductNotFound Map(ProductNotFoundException exception)
{
var target = new ProductNotFound(
exception.ProductId,
exception.Message
);
return target;
}
}
一次又一次,代码生成器编写了样板代码。区别在于我们不需要注入任何依赖,因为这是一个静态方法。我们可以这样使用它(我只包括了捕获块,其余代码保持不变):
catch (ProductNotFoundException ex)
{
return Results.NotFound(ExceptionMapper.Map(ex));
}
这非常直接,但会在生成的类及其消费者之间建立强大的联系。如果你的项目可以接受对静态类的硬依赖,你可以使用这些静态方法。我们正在探索的将对象映射的最后一種方法非常相似,但我们是在同一个类中创建一个扩展方法,而不是仅仅一个静态方法。下面是新的方法:
public static partial NotEnoughStock ToDto(this NotEnoughStockException exception);
该方法的生成代码如下(格式化):
public static partial NotEnoughStock ToDto(this NotEnoughStockException exception)
{
var target = new NotEnoughStock(
exception.AmountToRemove,
exception.QuantityInStock,
exception.Message
);
return target;
}
唯一的区别是添加了this关键字,将常规静态方法转换为扩展方法,我们可以这样使用它:
catch (NotEnoughStockException ex)
{
return Results.Conflict(ex.ToDto());
}
扩展方法比静态方法更优雅,但仍然创建了一个类似于静态方法的联系。再次强调,选择如何进行映射完全取决于你。关于 Mapperly 的一个值得注意的事情是,当映射代码不正确或可能不正确时,其分析器会提供信息、警告或错误。消息的严重性是可以配置的。例如,如果我们向ExceptionMapper类添加以下方法,Mapperly 会生成RMG013错误:
public static partial Product NotEnoughStockExceptionToProduct(
NotEnoughStockException exception
);
错误信息:
RMG013 Core.Models.Product has no accessible constructor with mappable arguments
此外,两个异常映射方法会提供关于目标类上不存在属性的信息。以下是一个此类消息的示例:
RMG020 The member TargetSite on the mapping source type Core.ProductNotFoundException is not mapped to any member on the mapping target type ProductNotFound
在这些基础上,我们知道何时某些事情是错误的或可能出错,这保护我们免受配置错误的影响。让我们结束这一章。
摘要
在许多情况下,对象映射是一个不可避免的现实。然而,正如我们在本章中看到的,有几种实现对象映射的方法,这可以从我们应用程序的其他组件中移除这一责任,或者简单地手动内联编码。同时,我们有机会探索聚合服务模式,它为我们提供了一种将多个依赖项集中到一个地方的方法,从而降低了其他类中所需的依赖项数量。这种模式可以帮助解决“过多的依赖项”代码异味,这是一种经验法则,指出我们应该调查具有超过三个依赖项的对象以查找设计缺陷。当将依赖项移动到聚合中时,请确保聚合内部具有内聚性,以避免向程序添加不必要的复杂性,并仅仅移动依赖项。我们还探讨了利用外观模式来实现映射外观,这导致了一个更易于阅读和优雅的映射器。之后,我们实现了一个模仿外观的映射器服务。尽管在用法上不够优雅,但它更加灵活。我们最终探索了 AutoMapper 和 Mapperly,这两个开源工具为我们执行对象映射,为我们提供了许多配置对象映射的选项。在我们探索的过程中,仅使用 AutoMapper 的默认约定就允许我们消除所有的映射代码。在 Mapperly 方面,我们必须使用部分类和方法定义映射器合约,以便其代码生成器为我们实现映射代码。你可以从许多现有的对象映射库中选择,AutoMapper 是其中最古老、最著名同时也是最被憎恨的一个,而 Mapperly 是最新且最快的,但仍然处于起步阶段。希望随着我们越来越多地将各个部分组合在一起,你现在开始看到我在本书开头提到这是架构之旅时的心思。现在我们已经完成了对象映射,下一章我们将探讨中介者和 CQRS 模式。
问题
让我们看看几个练习问题:
-
注入聚合服务而不是多个服务是否真的能提高我们的系统?
-
使用映射器是否真的有助于我们从消费者中提取责任到映射器类中?
-
真的是应该始终使用 AutoMapper 吗?
-
当使用 AutoMapper 时,你应该将你的映射代码封装到配置文件中吗?
-
应该有多少依赖项开始引起你的注意,告诉你你正在向单个类注入过多的依赖项?
进一步阅读
这里有一些链接,可以帮助我们巩固本章所学的内容:
-
如果你想了解更多关于对象映射的信息,我在 2017 年写过一篇关于这个主题的文章,标题为 设计模式:ASP.NET Core Web API、服务和仓储 | 第九部分:NinjaMappingService 和外观模式:
adpg.link/hxYf -
AutoMapper 官方网站:
adpg.link/5AUZ -
AutoMapper 使用指南 是一份优秀的“做/不做”清单,可以帮助你正确使用 AutoMapper,由库的作者编写:
adpg.link/tTKg -
Mapperly(GitHub):
adpg.link/Dwcj
答案
-
是的,聚合服务可以改进系统,但并不一定总是如此。移动依赖关系并不能修复设计缺陷;它只是将这些缺陷移动到其他地方。
-
是的,映射器帮助我们遵循单一职责原则(SRP)。然而,它们并不总是必需的。
-
不,它并不适合每个场景。例如,当映射逻辑变得复杂时,考虑不使用 AutoMapper。过多的映射器也可能意味着应用程序设计本身存在缺陷。
-
是的,使用配置文件来组织你的映射规则是有益的。
-
四个或更多的依赖项应该开始引起注意。再次强调,这只是一个指导原则;将四个或更多的服务注入到类中可能是可接受的。
第十六章:16 中介者和 CQRS 设计模式
在开始之前:加入我们的 Discord 书籍社区
直接向作者本人提供反馈,并在我们的 Discord 服务器上与其他早期读者聊天(在“EARLY ACCESS SUBSCRIPTION”下找到“architecting-aspnet-core-apps-3e”频道)。

本章涵盖了下一章的构建块,下一章将介绍垂直切片架构。我们首先快速概述垂直切片架构,以便您对最终目标有一个概念。然后,我们探讨中介者设计模式,它在我们的应用程序组件之间扮演中间人的角色。这引出了命令查询责任分离(CQRS)模式,该模式描述了如何将我们的逻辑分为命令和查询。最后,我们通过探索 MediatR(中介者设计模式的开源实现)并通过它发送查询和命令来巩固我们的学习,以展示我们迄今为止所学的概念如何在现实世界的应用程序开发中得以实现。在本章中,我们将涵盖以下主题:
-
垂直切片架构的高级概述
-
实现中介者模式
-
实现 CQS 模式
-
代码异味 - 标记接口
-
使用 MediatR 作为中介者
让我们从最终目标开始。
垂直切片架构的高级概述
在开始之前,让我们看看本章和下一章的最终目标。这样,您应该能够更容易地跟随整个章节中向该目标迈进的过程。正如我们在第十四章,“分层和清洁架构”中提到的,一个层根据共享责任将类分组在一起。因此,包含数据访问代码的类是数据访问层(或基础设施)的一部分。人们使用像这样的水平切片在图中表示层:

图 16.1:表示层为水平切片的图
“垂直切片架构”中的“垂直切片”来自这里;一个垂直切片代表每个层创建特定功能的部分。因此,我们不是将应用程序划分为层,而是将其划分为功能。一个功能管理其数据访问代码、领域逻辑,甚至可能是表示代码。关键是松散地将功能彼此耦合,并保持每个功能的组件紧密在一起。在一个分层应用程序中,当我们添加、更新或删除一个功能时,我们必须更改一个或多个层,这往往意味着“所有层”。另一方面,使用垂直切片,我们保持功能隔离,允许我们独立设计它们。从分层的角度来看,这就像将您对软件的思考方式翻转 90°:

图 16.2:表示穿越所有层的垂直切片图
垂直切片架构不强制使用CQRS、中介模式或MediatR,但这些工具和模式结合得非常好,正如我们在下一章中看到的。尽管如此,这些只是你可以使用或更改实现的不同技术中的工具和模式;这并不重要,也不会改变概念。
我们将在第十八章、请求-端点-响应(REPR)和第二十章、模块化单体中探讨构建面向功能应用程序的额外方法。
目标是将功能封装在一起,使用 CQRS 将应用程序划分为请求(命令和查询),并使用 MediatR 作为该 CQRS 管道的中介,使各个部分相互解耦。你现在知道了计划。我们将在后面探索垂直切片架构。同时,让我们从中介设计模式开始。
实现中介模式
中介模式是另一个 GoF 设计模式,它控制对象之间如何交互(使其成为行为模式)。
目标
中介的角色是管理对象(同事)之间的通信。那些同事不应该直接相互通信,而应该使用中介。中介有助于打破这些同事之间的紧密耦合。中介是同事之间传递消息的中间人。
设计
让我们从一些 UML 图开始。从非常高的层面来看,中介模式由一个中介和同事组成:

图 16.3:表示中介模式的类图
当系统中的某个对象想要向一个或多个同事发送消息时,它使用中介。以下是如何工作的一个示例:

图 16.4:中介向同事传递消息的序列图
同事也是如此;如果同事之间需要交流,也必须使用中介,如下面的类图所示:

图 16.5:表示中介模式包括同事协作的类图
在这个图中,ConcreteColleague1 是一个同事,同时也是中介的消费者。例如,那个同事可以使用中介向另一个同事发送消息,如下所示:

图 16.6:表示 colleague1 通过中介与 colleague2 通信的序列图
从中介的角度来看,其实现很可能包含一个用于通信的同事集合,如下所示:

图 16.7:表示简单假设的具体中介实现类的类图
现在我们已经探讨了几个 UML 图,让我们看看一些代码。
项目 – 中介(IMediator)
Mediator 项目由一个使用 Mediator 模式的简化聊天系统组成。让我们从接口开始:
namespace Mediator;
public interface IMediator
{
void Send(Message message);
}
public interface IColleague
{
string Name { get; }
void ReceiveMessage(Message message);
}
public record class Message(IColleague Sender, string Content);
系统由以下部分组成:
-
IMediator接口代表一个可以向同事发送消息的中介。 -
IColleague接口代表一个可以接收消息的同事。它还有一个Name属性,这样我们就可以输出有意义的值。 -
Message类代表由IColleague实现发送的消息。
然后,我们在ConcreteMediator类中实现IMediator接口,将消息广播到所有IColleague实例:
public class ConcreteMediator : IMediator
{
private readonly List<IColleague> _colleagues;
public ConcreteMediator(params IColleague[] colleagues)
{
ArgumentNullException.ThrowIfNull(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接口充当一个展示者,并控制消息的显示方式。《IMessageWriter<TMessage>接口与中介模式无关。尽管如此,它是一种管理ConcreteColleague对象如何输出消息而不与特定目标耦合的方法。以下是代码:
namespace Mediator;
public interface IMessageWriter<Tmessage>
{
void Write(Tmessage message);
}
系统的消费者是一个在MediatorTest类中定义的集成测试。该测试使用聊天系统,并使用IMessageWriter接口的自定义实现来断言输出。让我们首先分析这个测试:
namespace Mediator;
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块的第二部分,我们创建了待测试的主题(中介)并注册了三位同事。在那个Arrange块结束时,我们还定义了测试的预期输出。需要注意的是,我们控制来自TestMessageWriter实现(在MediatorTest类末尾定义)的输出。接下来是Act块:
// Act
mediator.Send(new Message(
Sender: miller,
Content: "Hey everyone!"
));
mediator.Send(new Message(
Sender: orazio,
Content: "What's up Miller?"
));
mediator.Send(new Message(
Sender: fletcher,
Content: "Hey Miller!"
));
在前面的Act块中,我们通过mediator实例发送了三条消息。接下来是Assert块:
// Assert
Assert.Equal(expectedOutput, millerWriter.Output.ToString());
Assert.Equal(expectedOutput, orazioWriter.Output.ToString());
Assert.Equal(expectedOutput, fletcherWriter.Output.ToString());
}
在Assert块中,我们确保所有同事都收到了消息。
private static (TestMessageWriter, ConcreteColleague) CreateConcreteColleague(string name)
{
var messageWriter = new TestMessageWriter();
var concreateColleague = new ConcreteColleague(name, messageWriter);
return (messageWriter, concreateColleague);
}
CreateConcreteColleague方法是一个辅助方法,它封装了同事的创建,使我们能够编写测试Arrange部分中的一行声明。接下来,我们看看IMessageWriter实现:
private class TestMessageWriter : IMessageWriter<Message>
{
public StringBuilder Output { get; } = new StringBuilder();
public void Write(Message message)
{
Output.AppendLine($"[{message.Sender.Name}]: {message.Content}");
}
}
} // Closing the MediatorTest class
最后,TestMessageWriter类将消息写入StringBuilder,这使得断言输出变得容易。如果我们为它构建一个 GUI,我们可以编写一个IMessageWriter<Message>的实现,将其写入该 GUI;在 Web UI 的情况下,它可以使用SignalR或直接写入响应流,例如。总结这个示例:
-
消费者(单元测试)通过中介向同事发送消息。
-
TestMessageWriter类将这些消息写入一个StringBuilder实例。每个同事都有自己的TestMessageWriter类实例。 -
代码断言所有同事都收到了预期的消息。
这个例子说明了中介模式允许我们打破同事之间的直接耦合。消息到达同事,而他们并不知道彼此。同事应该通过中介进行沟通,因此没有中介,中介模式就不完整。让我们实现一个更高级的聊天室来处理这个概念。
项目 – 中介(IChatRoom)
在之前的代码示例中,我们根据中介模式演员命名了类,如图 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 record class ChatMessage(IParticipant Sender, string Content);
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)
{
if (_chatRoom == null)
{
throw new ChatRoomNotJoinedException();
}
_chatRoom.Send(new ChatMessage(this, message));
}
}
public class ChatRoomNotJoinedException : Exception
{
public ChatRoomNotJoinedException()
: base("You must join a chat room before sending a message.")
{ }
}
User类代表我们的默认IParticipant。一个User实例只能在IChatRoom中聊天。程序可以通过调用ChatRoomJoined方法来设置聊天室。当它收到消息时,它将其委托给它的IMessageWriter<ChatMessage>。最后,一个User实例可以通过中介(IChatRoom)发送消息。Send方法抛出ChatRoomNotJoinedException异常,以强制User实例在发送消息之前必须加入聊天室(代码上:_chatRoom字段不能为null)。我们可以根据需要创建Moderator、Administrator、SystemAlerts或任何其他IParticipant实现,但在这个示例中我们没有这样做。我将这个实验留给你。现在让我们看看ChatRoom类(中介):
public class ChatRoom : IChatRoom
{
private readonly List<IParticipant> _participants = new();
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 类更简洁。它允许参与者加入并发送聊天消息给已注册的参与者。当加入 ChatRoom 时,它保留对 IParticipant 的引用,告诉 IParticipant 它已成功加入,然后向所有参与者发送 ChatMessage 宣布新来者。有了这些小块,我们就有了仲裁者的实现。在进入下一节之前,让我们看看 IChatRoom 的 Consumer 实例,这是另一个集成测试。让我们从类的骨架开始:
namespace Mediator;
public class ChatRoomTest
{
[Fact]
public void ChatRoom_participants_should_send_and_receive_messages()
{
// Arrange, Act, Assert blocks here
}
private (TestMessageWriter, User) CreateTestUser(string name)
{
var writer = new TestMessageWriter();
var user = new User(writer, name);
return (writer, user);
}
private class TestMessageWriter : IMessageWriter<ChatMessage>
{
public StringBuilder Output { get; } = new StringBuilder();
public void Write(ChatMessage message)
{
Output.AppendLine($"[{message.Sender.Name}]: {message.Content}");
}
}
}
在前面的代码中,我们有以下几部分:
-
测试用例是一个空占位符,我们即将对其进行查看。
-
CreateTestUser方法有助于简化测试用例的Arrange部分,与之前类似。 -
TestMessageWriter的实现与之前的例子类似,将消息累积在StringBuilder实例中。
作为参考,IMessageWriter 接口与之前的工程相同:
public interface IMessageWriter<TMessage>
{
void Write(TMessage message);
}
现在,让我们探索测试用例,从 Arrange 块开始,我们在其中创建了四个用户及其各自的 TestMessageWriter 实例(名称也是随机生成的):
// Arrange
var (kingChat, king) = CreateTestUser("King");
var (kelleyChat, kelley) = CreateTestUser("Kelley");
var (daveenChat, daveen) = CreateTestUser("Daveen");
var (rutterChat, _) = CreateTestUser("Rutter");
var chatroom = new ChatRoom();
然后,在 Act 块中,我们的测试用户加入 chatroom 实例并发送消息:
// 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");
然后,在 Assert 块中,鲁特没有加入聊天室,所以我们预计没有消息:
// Assert
Assert.Empty(rutterChat.Output.ToString());
由于金是第一个加入频道的,我们预计他会收到所有消息:
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());
凯利是第二个加入聊天室的用户,所以输出包含了几乎所有消息,除了说 [金]: 已加入频道 的那一行:
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());
戴文在金和凯利交换了几句话后加入,所以我们预计对话会短一些:
Assert.Equal(@"[Daveen]: Has joined the channel
[King]: Everything is great, I joined the CrazyChatRoom!
[Daveen]: Hey King!
[King]: Hey Daveen
", daveenChat.Output.ToString());
总结测试用例,我们有四个用户。其中三个在不同时间加入了同一个聊天室并聊了一会儿。由于加入的时间不同,每个人的输出都不同。所有参与者都松散耦合,多亏了中介者模式,这使得我们可以在不影响现有部分的情况下扩展系统。利用中介者模式帮助我们创建可维护的系统;许多小块比处理所有逻辑的大型组件更容易管理和测试。
结论
正如我们在前两个项目中探讨的那样,仲裁者使我们能够解耦系统的组件。仲裁者是同事之间的中间人,它在小型聊天室样本中表现良好,其中每个同事都可以与其他人交谈,而无需知道如何以及他们是谁。现在让我们看看中介者模式如何帮助我们遵循 SOLID 原则:
-
S: 仲裁者从同事那里提取了沟通责任。
-
O: 通过仲裁者传递消息,我们可以创建新的同事并改变现有同事的行为,而不会影响其他人。如果我们需要一个新同事,我们可以通过仲裁者注册一个。
-
L: 无
-
I:中介者模式将系统划分为多个小的接口(
IMediator和IColleague)。 -
D:中介者模式的所有参与者仅依赖于其他接口。如果我们因为需要新的中介行为而需要实现新的中介,我们可以重用现有同事的实现,因为它们依赖于依赖反转。
接下来,我们探讨 CQRS,它允许我们将命令和查询分离,从而使得应用程序更易于维护。毕竟,所有操作都是查询或命令,无论我们如何称呼它们。
实现 CQS 模式
命令-查询分离(CQS)是命令查询责任分离(CQRS)模式的一个子集。以下是两者之间的高层次差异:
-
使用 CQS,我们将操作分为命令和查询。
-
使用 CQRS,我们将这个概念应用到系统级别。我们分离了读取和写入的模型,这可能导致一个分布式系统。
在本章中,我们继续使用 CQS,并在第十八章“微服务架构简介”中处理 CQRS。
目标
目标是将所有操作(或请求)分为两个类别:命令和查询。
-
命令改变应用程序的状态。例如,创建、更新和删除实体都是命令。理论上,命令不应该返回值。在实践中,它们经常这样做,尤其是为了优化目的。
-
查询读取应用程序的状态,但永远不会改变它。例如,读取订单、读取您的订单历史记录和检索您的用户资料都是查询。
将操作分为突变请求(写入/命令)和访问请求(读取/查询)创建了一个清晰的关注点分离,引导我们走向 SRP。
设计
对于这个模式,没有固定的设计,但对我们来说,命令的流程应该如下所示:

图 16.8:表示命令抽象流程的序列图
消费者创建一个命令对象并将其发送到命令处理器,对应用程序应用变更。在这种情况下,我将其称为Entities,但它也可以发送一个 SQL UPDATE命令到数据库或通过 HTTP 进行 Web API 调用;实现细节并不重要。对于查询,概念是相同的,但它返回一个值。非常重要的一点是,查询不能改变应用程序的状态。查询应该只读取数据,如下所示:

图 16.9:表示查询抽象流程的序列图
与命令一样,消费者创建一个查询对象并将其发送到处理器,然后处理器执行一些逻辑以检索和返回所需的数据。我们可以用处理器需要查询数据的东西来替换Entities。说得够多了——让我们看看 CQS 项目。
项目 – CQS
上下文:我们需要构建我们聊天系统的改进版本。旧系统工作得很好,我们需要将其扩展。中介者对我们有所帮助,所以我们保留了这部分,并选择了 CQS 模式来帮助我们进行这个新的、改进的设计。过去,参与者被限制在单个聊天室中,但现在参与者必须能够同时参与多个聊天室。新系统由三个命令和两个查询组成:
-
参与者必须能够加入聊天室。
-
参与者必须能够离开聊天室。
-
参与者必须能够向聊天室发送消息。
-
参与者必须能够获取加入聊天室的所有参与者的列表。
-
参与者必须能够从聊天室检索现有消息。
前三个是命令,最后两个是查询。系统由以下中介者支持,它大量使用了 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>;
}
如果你不太熟悉泛型,这可能会看起来令人畏惧,但那段代码实际上比看起来要简单得多。接下来,ICommand 接口是空的,我们本可以避免这种情况,但它有助于描述我们的意图。ICommandHandler 接口定义了一个类必须实现的合同来处理命令。该接口定义了一个 Handle 方法,它接受命令作为参数。泛型参数 TCommand 表示实现该接口的类可以处理的命令类型。以下是代码:
public interface ICommand { }
public interface ICommandHandler<TCommand>
where TCommand : ICommand
{
void Handle(TCommand command);
}
IQuery<TReturn> 接口与 ICommand 接口类似,但有一个表示查询返回类型的 TReturn 泛型参数。IQueryHandler 接口也非常相似,但它的 Handle 方法接受一个类型为 TQuery 的对象作为参数,并返回一个 TReturn 类型的值。以下是代码:
public interface IQuery<TReturn> { }
public interface IQueryHandler<TQuery, TReturn>
where TQuery : IQuery<TReturn>
{
TReturn Handle(TQuery query);
}
IMediator 接口允许使用其 Register 方法注册命令和查询处理器。它还支持通过其 Send 方法发送命令和查询。然后我们有 ChatMessage 类,它与最后两个示例类似(增加了一个创建日期):
public record class ChatMessage(IParticipant Sender, string Message)
{
public DateTime Date { get; } = DateTime.UtcNow;
}
接下来是更新的 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();
}
在进入命令和聊天本身之前,让我们先看看 Mediator 类:
public class Mediator : IMediator
{
private readonly HandlerDictionary _handlers = new();
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 上找到:adpg.link/2Lsm。
现在来看命令。我把命令和处理程序放在一起,以保持组织性和可读性,但你也可以使用其他方式来组织你的代码。此外,由于这是一个小型项目,所有命令都在同一个文件中,这对于更大的项目来说是不可行的。记住,我们正在玩乐高积木,这一章涵盖了 CQS 零件,但你始终可以使用它们与更大的零件,如 Clean Architecture 或其他类型的架构。
我们将在后续章节中介绍组织命令和查询的方法。
让我们从 JoinChatRoom 功能开始:
public class JoinChatRoom
{
public record class Command(IChatRoom ChatRoom, IParticipant Requester) : ICommand;
public class Handler : ICommandHandler<Command>
{
public void Handle(Command command)
{
command.ChatRoom.Add(command.Requester);
}
}
}
Command 类代表命令本身,一种携带命令数据的数据结构。Handler 类处理这种类型的命令。当执行时,它使用 ChatRoom 和 Requester 属性将指定的 IParticipant 添加到指定的 IChatRoom 中(高亮行)。下一个功能:
public class LeaveChatRoom
{
public record class Command(IChatRoom ChatRoom, IParticipant Requester) : ICommand;
public class Handler : ICommandHandler<Command>
{
public void Handle(Command command)
{
command.ChatRoom.Remove(command.Requester);
}
}
}
那段代码代表了 JoinChatRoom 命令的完全相反,LeaveChatRoom 处理器从指定的 IChatRoom 中移除一个 IParticipant(高亮行)。
以这种方式嵌套类允许重用每个功能的类名
Command和Handler。
到下一个功能:
public class SendChatMessage
{
public record class Command(IChatRoom ChatRoom, ChatMessage Message) : ICommand;
public class Handler : ICommandHandler<Command>
{
public void Handle(Command command)
{
command.ChatRoom.Add(command.Message);
var participants = command.ChatRoom.ListParticipants();
foreach (var participant in participants)
{
participant.NewMessageReceivedFrom(
command.ChatRoom,
command.Message
);
}
}
}
}
与此同时,SendChatMessage 功能处理两件事(高亮行):
-
它将指定的
Message添加到IChatRoom(现在仅是一个跟踪用户和过去消息的数据结构)。 -
它还将指定的
Message发送到加入该IChatRoom的所有IParticipant实例。
我们开始看到许多较小的部分相互交互,以创建一个更发达的系统。但我们还没有完成;让我们看看这两个查询,然后是聊天实现:
public class ListParticipants
{
public record class Query(IChatRoom ChatRoom, IParticipant Requester) : IQuery<IEnumerable<IParticipant>>;
public class Handler : IQueryHandler<Query, IEnumerable<IParticipant>>
{
public IEnumerable<IParticipant> Handle(Query query)
{
return query.ChatRoom.ListParticipants();
}
}
}
ListParticipants 处理器使用指定的 IChatRoom 并返回其参与者(高亮行)。现在,来看最后一个查询:
public class ListMessages
{
public record class Query(IChatRoom ChatRoom, IParticipant Requester) : IQuery<IEnumerable<ChatMessage>>;
public class Handler : IQueryHandler<Query, IEnumerable<ChatMessage>>
{
public IEnumerable<ChatMessage> Handle(Query query)
{
return query.ChatRoom.ListMessages();
}
}
}
ListMessages 处理器使用指定的 IChatRoom 实例返回其消息。
由于所有命令和查询都引用
IParticipant,我们可以强制执行诸如“IParticipant必须在发送消息之前加入频道”之类的规则。我决定省略这些细节以保持代码简单,但如果你想要的话,随时可以添加这些功能。
接下来,让我们看看 ChatRoom 类,它是一个简单的数据结构,用于存储聊天室的状态:
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 类可能是这个系统中最激动人心的部分,因为它大量使用了我们的中介和 CQS:
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)));
}
}
除了 NewMessageReceivedFrom 方法外,Participant 类的每个方法都通过 IMediator 接口发送命令或查询,打破了参与者与系统操作(即命令和查询)之间的紧密耦合。Participant 类也是其底层依赖的简单外观,将大部分工作委托给中介。现在我们已经涵盖了众多小部件,让我们看看它们是如何协同工作的。我将几个具有以下设置代码的测试用例分组在一起:
public class ChatRoomTest
{
private readonly IMediator _mediator = new Mediator();
private readonly TestMessageWriter _reagenMessageWriter = new();
private readonly TestMessageWriter _garnerMessageWriter = new();
private readonly TestMessageWriter _corneliaMessageWriter = new();
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);
}
// Omited test cases and helpers
}
测试程序设置由以下内容组成:
-
一个初始化为
Mediator实例的IMediator字段,使所有同事能够相互交互。 -
两个初始化为
ChatRoom实例的IChatRoom字段。 -
三个未初始化的
IParticipant字段,稍后用Participant实例初始化。 -
三个
TestMessageWriter实例,每个参与者一个。 -
构造函数将所有处理程序注册到
Mediator实例,以便它知道如何处理命令和查询。它还创建了参与者。
参与者的名字再次随机生成。
TestMessageWriter 的实现略有不同,它将数据累积在元组列表 (List<(IChatRoom, ChatMessage)>) 中,以评估参与者发送的内容:
private class TestMessageWriter : IMessageWriter
{
public List<(IChatRoom chatRoom, ChatMessage message)> Output { get; } = new();
public void Write(IChatRoom chatRoom, ChatMessage message)
{
Output.Add((chatRoom, message));
}
}
这里是第一个测试用例:
[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 加入 Room 1,而 Reagen 和 Cornelia 加入 Room 2。然后 Reagen 从 Room 1 请求参与者列表,输出 Reagen 和 Garner。在底层,它通过中介使用命令和查询,打破了同事之间的紧密耦合。以下是一个序列图,展示了当参与者加入聊天室时发生的情况:

图 16.10:表示参与者(p)加入聊天室(c)流程的序列图
-
参与者 (
p) 创建了一个JoinChatRoom命令 (joinCmd)。 -
p通过中介 (m) 发送joinCmd。 -
m查找并将joinCmd分派给其处理程序 (handler)。 -
handler执行逻辑(将p添加到聊天室)。 -
joinCmd之后不再存在;命令是瞬时的。
这意味着 Participant 从不直接与 ChatRoom 或其他参与者交互。当参与者请求聊天室的参与者列表时,发生类似的流程:

图 16.11:表示参与者(p)请求聊天室(c)参与者列表流程的序列图
-
Participant(p) 创建了一个ListParticipants查询 (listQuery)。 -
p通过中介 (m) 发送listQuery。 -
m查找并将查询分派给其处理程序 (handler)。 -
handler执行逻辑(列出聊天室的参与者)。 -
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);
}
);
}
在前面的测试用例中,Reagen 加入房间 1,而 Garner 加入房间 1 和 2。然后 Reagen 向房间 1 发送消息,我们验证 Garner 是否收到了它。《SendMessageTo》工作流程与我们所看到的另一个非常相似,但具有更复杂的命令处理程序:

图 16.12:序列图表示参与者(p)向聊天室(c)发送消息(msg)的流程
从该图中,我们可以观察到我们将逻辑推到了 SendChatMessage 功能的 Handler 类中。所有其他角色都一起工作,彼此之间了解有限或没有了解。这展示了 CQS 与中介一起是如何工作的:
-
消费者(在本例中为参与者)创建一个命令(或一个查询)。
-
消费者通过中介发送该命令。
-
中介将该命令发送给一个或多个处理程序,每个处理程序执行该命令的逻辑部分。
你可以探索其他测试用例,以便熟悉程序和概念。
你可以在 Visual Studio 中调试测试;使用断点结合 进入(F11) 和 跳过(F10) 来探索示例。
我还创建了一个 ChatModerator 实例,当消息包含 badWords 集合中的单词时,在“调解聊天室”中发送消息。该测试用例为每个 SendChatMessage.Command 执行多个处理程序。我将留给你自己探索这些其他测试用例,以免偏离我们的目标。
结论
CQS 和 CQRS 模式建议将程序的操作分为 命令 和 查询。命令会修改数据,而查询会获取数据。我们可以应用 中介 模式,通过 CQS 打破程序各部分之间的紧密耦合,例如发送命令和查询。以这种方式划分程序有助于分离不同的部分,并专注于从消费者通过中介到一个或多个处理程序的命令和查询。命令和查询的数据契约成为程序的主干,减少了对象之间的耦合,并将它们绑定到这些薄数据结构上,从而让中央部分(中介)管理它们之间的链接。另一方面,使用 CQS 时,你可能会发现代码库更令人畏惧,因为存在多个类。它增加了一些复杂性,尤其是对于像这样的小型程序。然而,每种类型都做得更少(具有单一职责),这使得它比具有许多职责的更大规模的类更容易测试。现在让我们看看 CQRS 如何帮助我们遵循 SOLID 原则:
-
S: 将应用程序划分为命令、查询和处理程序,使我们朝着将单一责任封装到不同类中迈进。
-
O: CQS 有助于在不修改现有代码的情况下扩展软件,例如添加处理程序和创建新的命令。
-
L: N/A
-
I: CQS 使创建多个具有清晰区分的命令、查询及其相应处理程序的小接口变得更容易。
-
D: N/A
现在我们已经探讨了 CQRS、CQS 和中介者模式,我们将探讨标记接口。
代码异味 – 标记接口
我们在代码示例中使用了空的ICommand和IQuery<TReturn>接口,使代码更加明确和自我描述。空接口是可能存在问题的标志:一个代码异味。我们称之为标记接口。在我们的情况下,它们有助于识别命令和查询,但它们是空的,没有添加任何内容。我们可以丢弃它们,而不会对我们的系统产生任何影响。另一方面,我们并没有进行魔术般的操作或违反任何原则,所以它们不会造成伤害,而是有助于定义意图。此外,我们可以利用它们使代码更加动态,例如利用依赖注入来注册处理程序。此外,我就是这样设计这些接口的,作为通向下一个项目的桥梁。回到标记接口,这里有两种类型的标记接口,在 C#中是代码异味:
-
元数据
-
依赖标识符
元数据
标记可以用来定义元数据。一个类“实现”了空接口,然后某个消费者稍后对它做了些事情。这可能是一个扫描特定类型的程序集、策略选择或其他事情。与其创建标记接口来添加元数据,不如尝试使用自定义属性。属性背后的想法是为类及其成员添加元数据。另一方面,接口的存在是为了创建一个合同,它们应该定义至少一个成员;空合同就像一张白纸。在现实世界的场景中,你可能需要考虑一种方法与另一种方法的成本。标记实现起来非常便宜,但可能会违反架构原则。如果机制已经实现或由框架支持,属性可能同样便宜实现,但根据场景,可能比标记接口的成本高得多。在做出决定之前,我建议你评估这两种选项的成本。
依赖标识符
如果你需要在特定类中注入一些特定的依赖项,你很可能是违反了控制反转原则。相反,你应该找到一种使用依赖注入实现相同目标的方法,例如通过上下文注入你的依赖项。让我们从以下接口开始:
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实现了从IStrategy继承的IStrategyA。 -
StrategyB实现了继承自IStrategy的IStrategyB。 -
IStrategyA和IStrategyB都是空的标记接口。
现在,消费者需要使用两种策略,因此,而不是从组合根控制依赖项,消费者请求标记:
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.GetRequiredService<Consumer>();
// Assert
Assert.IsType<StrategyA>(consumer.StrategyA);
Assert.IsType<StrategyB>(consumer.StrategyB);
}
两个属性都是预期的类型,但这不是问题。Consumer类通过注入标记 A 和 B 来控制使用哪些依赖项以及何时使用它们,而不是使用两个IStrategy实例。因此,我们不能从组合根控制依赖项树。例如,我们不能将IStrategyA更改为IStrategyB,也不能将IStrategyB更改为IStrategyA,也不能注入两个IStrategyB实例或两个IStrategyA实例,甚至不能创建一个IStrategyC接口来替换IStrategyA或IStrategyB。我们如何解决这个问题呢?让我们从删除我们的标记并注入两个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 Core 构建Consumer实例,如下所示:
// Arrange
var serviceProvider = new ServiceCollection()
.AddSingleton<StrategyA>()
.AddSingleton<StrategyB>()
.AddSingleton(serviceProvider =>
{
var strategyA = serviceProvider.GetRequiredService<StrategyA>();
var strategyB = serviceProvider.GetRequiredService<StrategyB>();
return new Consumer(strategyA, strategyB);
})
.BuildServiceProvider();
// Act
var consumer = serviceProvider.GetRequiredService<Consumer>();
// Assert
Assert.IsType<StrategyA>(consumer.StrategyA);
Assert.IsType<StrategyB>(consumer.StrategyB);
从这一点开始,我们控制程序的组合,我们可以用 B 替换 A 或做任何我们想要的事情,只要实现尊重IStrategy合同。为了总结,使用标记而不是进行上下文注入破坏了控制反转原则,使消费者控制其依赖项。这非常接近使用new关键字来实例化对象。即使使用默认容器,反转依赖项控制也很容易。
如果你需要根据上下文注入依赖项,我在 2020 年启动了一个开源项目来实现这一点。如果需要,多个其他第三方库会添加功能或完全替换默认的 IoC 容器。请参阅进一步阅读部分。
接下来,我们开始本章的最后一部分。它展示了一个开源工具,可以帮助我们构建面向 CQS 的应用程序。
将 MediatR 用作中介
在本节中,我们正在探讨 MediatR,这是一个开源的中介实现。那么什么是 MediatR 呢?让我们从其 GitHub 仓库中制作者的描述开始,它将其定义为:
“.NET 中的简单、不雄心勃勃的中介实现”
MediatR 是一个简单但非常强大的工具,通过消息进行进程内通信。它支持通过命令、查询、通知和事件进行请求/响应流,同步和异步。我们可以使用.NET CLI 安装 NuGet 包:dotnet add package MediatR。现在我已经快速介绍了这个工具,我们将探索迁移我们的清洁架构示例,但将使用 MediatR 来调度StocksController请求到核心用例。我们使用与 CQS 项目中构建的类似模式使用 MediatR。
为什么迁移我们的清洁架构示例?我们使用不同的模型构建相同的项目的主要原因是便于比较。与构建完全不同的项目相比,比较相同功能的更改要容易得多。
在这个情况下使用 MediatR 的优势是什么?它允许我们围绕用例(垂直)而不是服务(水平)组织代码,从而产生更紧密的功能。我们移除了服务层(StockService类)并替换为多个用例(功能)而不是(AddStocks和RemoveStock类)。MediatR 还允许我们通过编程行为扩展的管道。这些可扩展点使我们能够管理横切关注点,例如集中管理请求验证,而不会影响消费者和用例。我们将在第十七章,开始使用垂直切片架构中探讨请求验证。现在让我们跳入代码,看看它是如何工作的。
项目 – 使用 MediatR 的清洁架构
上下文:我们希望通过利用中介者模式和CQS方法,在第十四章,理解分层中构建的清洁架构项目中进一步打破一些耦合。清洁架构解决方案已经足够稳固,但 MediatR 将为后续带来更多好处。唯一的“重大”变化是将StockService替换为两个功能对象,AddStocks和RemoveStocks,我们将在下面进行探讨。首先,我们必须在功能将驻留的Core项目中安装MediatR NuGet 包。此外,它将暂时级联到Web项目,使我们能够将 MediatR 注册到 IoC 容器中。在Program.cs文件中,我们可以这样注册 MediatR:
builder.Services
// Core Layer
.AddMediatR(cfg => cfg.RegisterServicesFromAssemblyContaining<NotEnoughStockException>())
;
那段代码扫描 Core 组件以查找与 MediatR 兼容的组件,并将它们注册到 IoC 容器中。NotEnoughStockException类是核心项目的一部分。
我选择了
NotEnoughStockException类,但我可以选择Core组件中的任何类。有更多的注册选项。
MediatR 公开以下类型的消息(截至版本 12):
-
请求/响应具有一个处理器;非常适合命令和查询。
-
支持多个处理器的通知;非常适合应用发布-订阅模式的基于事件的模型,其中通知表示事件。
-
与请求/响应类似,但通过
IAsyncEnumerable<T>接口流式传输响应的请求/响应流。
我们在第十九章中介绍了微服务架构的发布-订阅模式。
现在我们需要的所有与 MediatR 相关的功能都已经“神奇地”注册了,我们可以查看替换StockService的用例。让我们首先看看更新的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);
if (product == null)
{
throw new ProductNotFoundException(request.ProductId);
}
product.AddStock(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 (product == null)
{
throw new ProductNotFoundException(request.ProductId);
}
product.RemoveStock(request.Amount);
await _productRepository.UpdateAsync(product, cancellationToken);
return product.QuantityInStock;
}
}
}
正如你可能已经在代码中注意到的,我选择了与 CQS 示例相同的模式来构建命令,因此我们有一个包含两个嵌套类的类,每个用例一个类:Command和Handler。当你有一个命令类与其处理器的一对一关系时,这种结构会使代码非常清晰。使用 MediatR 请求/响应模型,命令(或查询)成为一个请求,必须实现IRequest<TResponse>接口。处理器必须实现IRequestHandler<TRequest, TResponse>接口。相反,我们可以为返回void(无)的命令实现IRequest和IRequestHandler<TRequest>接口。
MediatR 还有更多选项,文档足够完整,可以让你自己深入了解。
让我们分析AddStocks用例的结构。以下是作为参考的旧代码:
namespace Core.Services;
public class StockService
{
private readonly IProductRepository _repository;
// Omitted constructor
public async Task<int> AddStockAsync(int productId, int amount, CancellationToken cancellationToken)
{
var product = await _repository.FindByIdAsync(productId, cancellationToken);
if (product == null)
{
throw new ProductNotFoundException(productId);
}
product.AddStock(amount);
await _repository.UpdateAsync(product, cancellationToken);
return product.QuantityInStock;
}
// Omitted RemoveStockAsync method
}
第一个区别是我们将松散的参数(突出显示)移动到了Command类中,它封装了整个请求:
public class Command : IRequest<int>
{
public int ProductId { get; set; }
public int Amount { get; set; }
}
然后,Command类通过实现IRequest<TResponse>接口来指定处理器的预期返回值,其中TResponse是一个int。这样,当我们通过 MediatR 发送请求时,我们就会得到一个类型化的响应。这并不是“纯 CQS”,因为命令处理器返回一个整数,代表更新的QuantityInStock。然而,我们可以称之为优化,因为执行一个命令和一个查询对于这个场景来说可能是过度的(可能导致的数据库调用次数从一次变为两次)。我将跳过RemoveStocks用例,以避免重复,因为它遵循相同的模式。相反,让我们看看这些用例的消费情况。我省略了异常处理,以保持代码的简洁性,并且因为在这个情况下,try/catch块只会给代码增加噪音,并阻碍我们对模式的了解:
app.MapPost("/products/{productId:int}/add-stocks", async (
int productId,
AddStocks.Command command,
IMediator mediator,
CancellationToken cancellationToken) =>
{
command.ProductId = productId;
var quantityInStock = await mediator.Send(command, cancellationToken);
var stockLevel = new StockLevel(quantityInStock);
return Results.Ok(stockLevel);
});
app.MapPost("/products/{productId:int}/remove-stocks", async (
int productId,
RemoveStocks.Command command,
IMediator mediator,
CancellationToken cancellationToken) =>
{
command.ProductId = productId;
var quantityInStock = await mediator.Send(command, cancellationToken);
var stockLevel = new StockLevel(quantityInStock);
return Results.Ok(stockLevel);
});
// Omitted code
public record class StockLevel(int QuantityInStock);
在这两个委托中,我们注入了一个IMediator和一个命令对象(突出显示)。我们还让 ASP.NET Core 注入了一个CancellationToken,并将其传递给 MediatR。模型绑定器将数据从 HTTP 请求加载到我们使用IMediator接口的Send方法(突出显示)发送的对象中。然后我们将结果映射到StockLevel DTO 中,在返回其值和 HTTP 状态码200 OK之前。StockLevel记录类与之前相同。这个例子几乎包含了我们 CQS 示例中的相同代码,但我们使用了 MediatR 而不是手动编写这些部分。
默认模型绑定器无法从多个来源加载数据。因此,我们必须手动注入
productId并将其值分配给command.ProductId属性。即使这两个值都可以从主体中获取,该端点的资源标识符也会变得不那么详尽(URI 中没有productId)。使用 MVC,我们可以创建一个自定义模型绑定器。
使用最少的 API,我们可以创建一个静态的
BindAsync方法来手动进行模型绑定,这不太灵活,并且会将Core程序集与HttpContext紧密耦合。我想我们可能需要等待.NET 9+来获得该领域的改进。我在进一步阅读部分留下了一些与这个主题相关的链接。
结论
使用 MediatR,我们将 CQS 启发的管道和中介者模式的力量打包进了一个 Clean Architecture 应用程序中。我们打破了请求委托和使用情况处理器(之前是一个服务)之间的耦合。一个简单的 DTO,如命令对象,使端点和控制器对处理器一无所知,让 MediatR 成为命令和它们的处理器之间的中间人。因此,处理器可以在不影响端点的情况下进行更改。此外,我们可以通过IRequestPreProcessor、IRequestPostProcessor和IRequestExceptionHandler配置命令和处理器之间的更多交互。这些允许我们通过验证和错误处理等跨切面关注点扩展 MediatR 请求管道。MediatR 帮助我们以与中介者和 CQS 模式结合相同的方式遵循 SOLID 原则。整体设计的唯一缺点,这与 MediatR 无关,是我们使用了命令作为 DTO。我们可以创建自定义 DTO 并将它们映射到命令对象。然而,你将在下一章中了解到,我为什么要使用这种过渡设计。
摘要
在本章中,我们研究了中介者模式,它允许我们切断协作者之间的联系,调解他们之间的通信。然后我们研究了 CQS 模式,它建议将软件行为划分为命令和查询。这两个模式是减少组件之间紧密耦合的工具。之后,我们将 Clean Architecture 项目更新为使用 MediatR,这是一个面向 CQS 的开源通用中介者。还有许多其他可能的用途我们没有探讨,但这仍然是一个很好的开始。这标志着探索打破紧密耦合和将系统划分为更小部分的技术的另一章结束。所有这些构建块都引导我们进入下一章,在那里我们将这些模式和工具组合起来,以探索垂直切片架构。
问题
让我们看看几个练习题:
-
CQS 代表什么,这个设计模式的目的何在?
-
我们能否在同事内部使用中介者来调用另一个同事?
-
在 CQS 中,命令可以返回值吗?
-
MediatR 的费用是多少?
-
想象一个使用标记接口为某些类添加元数据的设计。你认为你应该审查那个设计吗?
进一步阅读
这里有一些链接,可以帮助我们巩固本章所学的内容:
-
MediatR:
adpg.link/ZQap -
要在 MediatR 的 Clean Architecture 项目中避免手动设置
ProductId,你可以使用开源项目HybridModelBinding或阅读关于自定义模型绑定的官方文档,然后实现自己的:
-
在 ASP.NET Core 中进行自定义模型绑定:
adpg.link/65pb -
GitHub 上的 Damian Edward 的 MinimalApis.Extensions 项目:
adpg.link/M6zS
ForEvolve.DependencyInjection是一个开源项目,它增加了对上下文依赖注入的支持以及更多功能:adpg.link/myW8
答案
-
CQS 代表命令-查询分离。这是一种软件设计原则,它将改变对象状态的操作(命令)与返回数据的操作(查询)分开。这有助于最小化副作用并防止程序行为的意外变化。
-
是的,你可以。中介者模式的目标是在同事之间进行通信的调解。
-
在 CQS 的原义中:不,命令不能返回值。其理念是查询读取数据,而命令修改数据。在 CQS 的较宽松意义上,命令可以返回值。例如,没有任何东西阻止创建命令部分或全部返回创建的实体。你总是可以在模块化和性能之间进行权衡。
-
MediatR 是一个免费的开源项目,许可协议为 Apache License 2.0。
-
是的,你应该。使用标记接口添加元数据通常是不正确的。尽管如此,你应该单独分析每个用例,在得出结论之前考虑其优缺点。
第十七章:17 开始使用垂直切片架构
在开始之前:加入我们的 Discord 书籍社区
直接向作者提供反馈,并在我们的 Discord 服务器上与其他早期读者聊天(在“architecting-aspnet-core-apps-3e”频道下找到,属于早期访问订阅)。

本章介绍了垂直切片架构,这是一种有效组织我们的 ASP.NET Core 应用程序的方法。垂直切片架构将元素从多个层移动到面向功能的设计,帮助我们保持代码库的整洁、简单、统一、松散耦合和管理性。垂直切片架构将我们的架构视角转向简化的架构。历史上,我们将功能的逻辑分割到各种层,如 UI、业务逻辑和数据访问。然而,我们通过垂直切片架构创建了功能独立的切片。想象你的应用程序就像一个蛋糕;我们不是水平切割(层),而是垂直切割(功能),每个切片都能独立运行。这种风格改变了我们设计和组织项目、测试策略和编码方法的方式。我们不必担心臃肿的控制器或过于复杂的“上帝对象”;相反,由于功能之间的松散耦合,更改变得更加容易管理。本章将指导你将垂直切片架构应用到你的 ASP.NET Core 应用程序中,详细说明如何使用 CQS、MVC、MediatR、AutoMapper 和 FluentValidation 来处理命令、查询、验证和实体映射,这些我们在前面的章节中已经探讨过。
我们不必使用那些工具来应用架构风格,可以用其他库替换它们,甚至可以自己编写整个堆栈。
到本章结束时,你将了解垂直切片架构及其优势,并应该有信心将这种风格应用到你的下一个项目中。在本章中,我们将涵盖以下主题:
-
反模式 - 大泥球
-
垂直切片架构
-
继续你的旅程:一些技巧和窍门
让我们一步步地穿越垂直切片,一次一个切片地拼凑架构。
反模式 - 大泥球
让我们从一种反模式开始。大泥球反模式描述的是一个设计失败或从未得到适当设计的系统。有时一个系统开始得很好,但由于压力、易变的需求、不可能的截止日期、不良实践或其他原因,会演变成一个大泥球。我们通常将大泥球称为意大利面代码,意思相同。这种反模式意味着一个非常难以维护的代码库,编写糟糕且难以阅读的代码,大量不希望出现的紧密耦合,低内聚性,或者更糟:所有这些都在同一个代码库中。应用本书中涵盖的技术应该能帮助你避免这种反模式。目标是小型、设计良好的组件,这些组件是可测试的。通过自动化测试强制执行。 whenever you can, iteratively (continuous improvement). 应用 SOLID 原则。在开始之前定义你的应用程序模式。考虑实现每个组件和功能的最佳方式;进行研究,并在不确定最佳方法时进行一个或多个概念验证或实验。确保你理解你正在构建的程序的业务需求(这可能是最好的建议)。这些提示应该能帮助你避免创建一个大泥球。构建面向功能的程序是避免创建大泥球的最佳方法之一。让我们开始吧!
垂直切片架构
与将应用程序水平分割(层)不同,垂直切片将所有水平关注点组合在一起,以封装一个功能。以下是一个说明这一点的图示:

图 17.1:表示垂直切片跨越所有层的图
Jimmy Bogard,这种架构的先驱,写了以下内容:
[目标是]最小化切片之间的耦合,最大化切片内的耦合。
这是什么意思?让我们将这句话分成两个不同的点:
-
“最小化切片之间的耦合” (提高可维护性,松耦合)
-
“最大化切片内的耦合” (内聚性)
我们可以将前者视为一个垂直切片不应该依赖于另一个,因此当你修改一个垂直切片时,你不必担心对其他切片的影响,因为耦合是最小的。我们可以将后者视为:而不是在多个层中分散代码,沿途可能存在多余的抽象,让我们重新组合并简化那段代码。这有助于保持垂直切片内部的紧密耦合,以创建一个具有单一目的的代码单元:从头到尾处理功能。然后我们可以将其包装起来,围绕我们试图解决的商业问题构建软件,而不是开发者的关注点(例如数据访问)。现在,从更通用的角度来看,什么是切片?我认为切片是复合层次结构。例如,一个运输经理程序有一个多步骤的创建工作流程、一个列表和一个详情页面。创建流程的每一步都会是一个负责处理其相应逻辑的切片。当组合在一起时,它们构成了“创建切片”,负责创建一个运输(一个更大的切片)。列表和详情页面是另外两个切片。然后,所有这些切片又构成了另一个更大的切片,导致类似这样的情况:

图 17.2:一个显示复杂功能(底部)的顶部部分(顶部)基于它们之间的内聚(垂直)依赖于更大的部分(中间)的从上到下的耦合结构的图
在步骤 1内部存在强耦合,而其他步骤之间的耦合有限;它们作为创建切片的一部分共享一些创建代码。创建、列表和详情也以有限的方式共享一些代码;它们都是运输切片的一部分,并访问或操作相同的实体:一个或多个运输。最后,运输切片与其他功能没有共享代码(或非常少)。
按照我刚才描述的模式,我们有限耦合和最大内聚。缺点是您必须持续设计和重构应用程序,这需要比分层方法更强的设计技能。此外,您必须知道如何从头到尾构建功能,限制任务在人们之间的划分,并将它们集中在每个团队成员身上;每个成员都成为全栈开发者。我们将在章节末尾的继续您的旅程部分重新审视这个例子。
我们将在下面探讨优点和缺点。
优点和缺点是什么?
让我们探讨垂直切片架构的一些优缺点。
优点
优点方面,我们有以下内容:
-
我们减少了功能之间的耦合,这使得在这样一个项目上工作更容易管理。我们只需要考虑一个垂直切片,而不是N层,通过将代码集中在共享关注点上,提高了可维护性。
-
我们可以选择每个垂直切片如何与它们所需的资源交互,而无需考虑其他切片。这增加了灵活性,因为一个切片可以使用 T-SQL,而另一个可以使用 EF Core,例如。
-
我们可以从小处着手,用几行代码开始(在马丁·福勒的《企业应用架构模式》中描述为事务脚本),无需奢华的设计或过度工程。当需要时,我们可以通过重构来改进设计,当模式出现时,这将导致更快的上市时间。
-
每个垂直切片应包含恰好正确数量的代码,以实现正确性——不多也不少。这导致代码库更加健壮(代码少意味着额外的代码更少,维护的代码也更少)。
-
由于每个功能几乎都是独立的,因此新来者更容易在现有系统中找到自己的位置,这导致更快的上手时间。
-
在前面的章节中学习的所有模式和技巧仍然适用。
根据我的经验,功能往往开始时规模较小,随着时间的推移而增长。用户通常在使用软件时发现他们需要什么,随着时间的推移改变需求,这导致软件的变化。事后,我希望我参与过的许多项目都是使用垂直切片架构而不是分层来构建的。
缺点
当然,没有什么是完美的,所以这里有一些缺点:
-
如果你习惯了分层,那么理解垂直切片架构可能需要时间,这将导致一个适应期来学习一种新的思考软件的方式。
-
这是一种“较新的”架构类型,人们不喜欢改变。
另一件事是我通过艰难的方式学到的,那就是接受变化。我认为我没有看到过一个项目是以它应有的方式结束的。当使用软件时,每个人都识别出业务流程中缺失的部分。这导致以下建议:尽可能快地发布软件,并尽快让客户使用软件。由于垂直切片架构可以为客户创造价值,而不是更多或更少的抽象和层,因此这些建议可能更容易实现。让客户尝试分阶段软件是非常困难的;没有客户有时间做这样的事情;他们正忙于经营自己的业务。然而,发布生产就绪的切片可能会导致更快的采用和反馈。
在我的职业生涯初期,每当规格发生变化时,我都会感到沮丧,并认为更好的规划本可以解决这个问题。有时更好的规划确实有所帮助,但有时,客户并不知道如何表达他们的业务流程或需求,只能通过试用应用程序来弄清楚。我的建议是,当规格发生变化时,不要感到沮丧,即使这意味着重写最初花费你数天或更多时间编写的软件部分;这种情况会经常发生。学会接受这一点,并找到使这个过程更容易、更快捷的方法。如果你与客户有联系,找到帮助他们弄清楚需求并减少变更数量的方法。
优点还是缺点?
以下是一些我们可以将其转化为优点的缺点:
-
假设你习惯于在孤岛(如数据库管理员处理数据)中工作。在这种情况下,分配涉及整个功能的任务可能会更具挑战性,但这也可能成为优势,因为你的团队中的每个人都更紧密地合作,从而带来更多的学习和协作,甚至可能形成一个新的跨职能团队——这是非常好的。在团队中有数据专家是很好的;没有人是所有领域的专家。
-
重构:强大的重构技能会大有裨益。随着时间的推移,大多数系统都需要进行一些重构,对于垂直切片架构来说更是如此。这可能是由于需求的变化或技术债务造成的。无论原因如何,如果你不这样做,你可能会最终得到一个一团糟的大泥球。首先,编写隔离的代码,然后重构到模式中是垂直切片架构的关键部分。这是在切片内部保持高度内聚并尽可能降低切片之间耦合的最佳方式之一。这个技巧适用于所有类型的架构,并且有了强大的测试套件来自动验证你的更改,这会更容易实现。
开始重构业务逻辑的一种方法是将逻辑推入领域模型,创建一个丰富的领域模型。你还可以使用其他设计模式和技巧来微调代码,使其更易于维护,例如创建服务或层。一个层不需要跨越所有垂直切片;它只需要跨越其中的一部分。
与其他应用级模式(如分层)相比,垂直切片架构的规则更少,这意味着有更多的选择(垂直切片架构)。你可以在垂直切片内部使用所有设计模式、原则和最佳实践,而无需将这些选择应用到整个应用程序中。
你如何将项目组织成垂直切片架构?遗憾的是,对此没有明确的答案,这取决于在项目上工作的工程师。我们将在下一个项目中探讨一种方法,但你可以根据自己的需要组织项目。然后我们将更深入地探讨重构和组织。
项目 – 垂直切片架构
上下文:我们对分层感到厌倦,并被要求使用垂直切片架构重建我们的小型演示商店。以下是更新后的图示,展示了我们概念上如何组织项目:

图 17.3:表示演示商店项目组织的图示
每个垂直框是一个用例(或切片),而每个水平框是一个横切关注点或共享组件。这是一个小型项目,所以我们共享数据访问代码(DbContext)和Product模型在三个用例之间。这种共享与垂直切片架构无关,但在像这样的小型项目中进一步分割它是困难和没有意义的。在这个项目中,我决定使用 Web API 控制器而不是最小 API,以及使用贫血模型而不是富模型。我们可以使用最小 API、富模型或任何组合。我选择这样做,以便您能一瞥使用控制器的情况,因为您很可能最终会使用它。我们将在下一章回到最小 API。以下是参与者:
-
ProductsController是管理产品的 REST API。 -
StocksController是管理库存的 REST API。 -
AddStocks、RemoveStocks和ListAllProducts是我们从第十四章“分层和清洁架构”以来在我们的项目中复制的相同用例。 -
持久性“层”由一个 EF Core
DbContext组成,该DbContext将Product模型持久化到内存数据库中。
我们可以在我们的垂直切片之上添加其他横切关注点,例如授权、错误管理和日志记录,仅举几例。接下来,让我们看看我们是如何组织这个项目的。
项目组织
这是我们的项目组织方式:
-
Data目录包含与 EF Core 相关的类。 -
Features目录包含功能。每个子文件夹包含其底层功能(垂直切片),包括控制器、异常和其他支持类,这些类是实现功能所需的。 -
每个用例都是独立的,并暴露以下类:
-
Command或Query代表 MediatR 请求。 -
Result是请求的返回值。 -
MapperProfile指导 AutoMapper 如何映射与用例相关的对象(如果有)。 -
Validator包含验证规则,用于验证Command或Query对象(如果有)。 -
Handler包含用例逻辑:如何处理请求。
Models目录包含领域模型。

图 17.4:文件组织的解决方案资源管理器视图
在这个项目中,我们支持使用FluentValidation进行请求验证,这是一个第三方 NuGet 包。我们也可以使用System.ComponentModel.DataAnnotations或任何我们想要的其它验证库。
使用 FluentValidation,我发现将验证保留在我们的垂直切片中但不在我们想要验证的类之外很容易。开箱即用的.NET 验证框架
DataAnnotations则相反,它强迫我们将验证作为实体的元数据包含在内。两者都有优缺点,但 FluentValidation 更容易测试和扩展。
以下代码是Program.cs文件。高亮显示的行表示注册 FluentValidation 并扫描程序集以查找验证器:
var currentAssembly = typeof(Program).Assembly;
var builder = WebApplication.CreateBuilder(args);
builder.Services
// Plumbing/Dependencies
.AddAutoMapper(currentAssembly)
.AddMediatR(o => o.RegisterServicesFromAssembly(currentAssembly))
.AddSingleton(typeof(IPipelineBehavior<,>), typeof(ThrowFluentValidationExceptionBehavior<,>))
// Data
.AddDbContext<ProductContext>(options => options
.UseInMemoryDatabase("ProductContextMemoryDB")
.ConfigureWarnings(builder => builder.Ignore(InMemoryEventId.TransactionIgnoredWarning))
)
// Web/MVC
.AddFluentValidationAutoValidation()
.AddValidatorsFromAssembly(currentAssembly)
.AddControllers()
;
var app = builder.Build();
app.MapControllers();
using (var seedScope = app.Services.CreateScope())
{
var db = seedScope.ServiceProvider.GetRequiredService<ProductContext>();
await ProductSeeder.SeedAsync(db);
}
app.Run();
上一段代码添加了我们在之前章节中探索的绑定,FluentValidation 以及运行应用程序所需的其他组件。高亮显示的行注册了 FluentValidation 并扫描currentAssembly以查找验证器类。验证器本身是每个垂直切片的一部分。现在我们已经了解了项目的组织结构,让我们看看功能。
探索删除库存功能
在本小节中,我们使用与之前示例相同的逻辑来探索RemoveStocks功能,但组织方式不同(即架构风格之间的差异)。由于我们使用贫血产品模型,我们将添加和删除库存的逻辑从Product类移动到了Handler类。接下来,让我们看看代码。我将沿途描述每个嵌套类。示例从包含功能嵌套类的RemoveStocks类开始。这有助于组织功能,并使我们避免了一些关于命名冲突的烦恼。
我们可以使用命名空间,但像 Visual Studio 这样的工具建议添加一个
using语句并删除内联命名空间。如今,它通常会在粘贴代码时自动添加using语句,这在许多情况下很棒,但在这个特定情况下不方便。因此,使用嵌套类解决了这个问题。
这里是RemoveStocks类的骨架:
using AutoMapper;
using FluentValidation;
using MediatR;
using VerticalApp.Data;
using VerticalApp.Models;
namespace VerticalApp.Features.Stocks;
public class RemoveStocks
{
public class Command : IRequest<Result> {/*...*/}
public class Result {/*...*/}
public class MapperProfile : Profile {/*...*/}
public class Validator : AbstractValidator<Command> {/*...*/}
public class Handler : IRequestHandler<Command, Result> {/*...*/}
}
上一段代码展示了RemoveStocks类包含其特定用例所需的所有元素:
-
Command是输入 DTO。 -
Result是输出 DTO。 -
MapperProfile是 AutoMapper 配置文件,它将特定于功能的类映射到非特定于功能的类,反之亦然。 -
Validator在实例到达Handler类(Command类)之前验证输入。 -
Handler封装了用例逻辑。
接下来,我们探索这些嵌套类,从Command类开始,它是用例的输入(请求):
public class Command : IRequest<Result>
{
public int ProductId { get; set; }
public int Amount { get; set; }
}
上一条请求包含了从库存中删除库存并完成操作所需的所有内容。IRequest<TResult>接口告诉 MediatR,Command类是一个请求,应该被路由到其处理程序。Result类是处理程序的返回值,代表用例的输出:
public record class Result(int QuantityInStock);
映射配置文件是可选的,允许封装与用例相关的 AutoMapper 映射。以下MapperProfile类注册了从Product实例到Result实例的映射:
public class MapperProfile : Profile
{
public MapperProfile()
{
CreateMap<Product, Result>();
}
}
validator 类也是可选的,允许在输入(Command)到达处理器之前对其进行验证;在这种情况下,它确保 Amount 值大于零:
public class Validator : AbstractValidator<Command>
{
public Validator()
{
RuleFor(x => x.Amount).GreaterThan(0);
}
}
最后,最重要的部分是 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(new object[] { request.ProductId }, cancellationToken);
if (product == null)
{
throw new ProductNotFoundException(request.ProductId);
}
if (request.Amount > product.QuantityInStock)
{
throw new NotEnoughStockException(product.QuantityInStock, request.Amount);
}
product.QuantityInStock -= request.Amount;
await _db.SaveChangesAsync(cancellationToken);
return _mapper.Map<Result>(product);
}
}
Handler 类实现了 IRequestHandler<Command, Result> 接口,它将 Command、Handler 和 Result 类连接起来。Handle 方法实现了从 第十四章、分层 和 清洁架构 以来相同逻辑的先前实现。现在我们有一个完全功能性的用例,让我们看看将 HTTP 请求转换为 MediatR 管道以执行我们的用例的 StocksController 类的骨架:
using MediatR;
using Microsoft.AspNetCore.Mvc;
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));
}
[HttpPost("add-stocks")]
public async Task<ActionResult<AddStocks.Result>> AddAsync(
int productId,
[FromBody] AddStocks.Command command
) {/*...*/}
[HttpPost("remove-stocks")]
public async Task<ActionResult<RemoveStocks.Result>> RemoveAsync(
int productId,
[FromBody] RemoveStocks.Command command
) {/*...*/}
}
在控制器中,我们在构造函数中注入了一个 IMediator。我们使用构造函数注入是因为这个控制器的所有操作都使用了 IMediator 接口。我们有两个操作,添加和删除股票。以下代码表示删除股票操作方法:
[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
});
}
catch (ProductNotFoundException ex)
{
return NotFound(new
{
ex.Message,
productId,
});
}
}
在前面的代码中,我们从体中读取了 RemoveStocks.Command 实例的内容,操作将 ProductId 属性设置为路由值,并将 command 对象发送到 MediatR 管道。从那里,MediatR 将请求路由到其处理器,在返回该操作的结果并带有 HTTP 200 OK 状态码之前。与前述代码和之前的实现相比的一个区别是我们将 DTOs 移到了垂直切片本身。每个垂直切片定义了其功能的输入、逻辑和输出,如下所示:

图 17.5:表示垂直切片三个主要部分的图
当我们添加输入验证时,我们有以下内容:

图 17.6:表示垂直切片三个主要部分,并添加了验证的图
控制器是 HTTP 和我们的领域之间的一个微薄层,引导 HTTP 请求到 MediatR 管道,并将响应返回到 HTTP。这个微薄的部分代表了 API 的表示,并允许访问领域逻辑;功能。当控制器增长时,这通常是一个迹象,表明功能逻辑的一部分在错误的位置,很可能是导致代码更难测试,因为 HTTP 和其他逻辑变得交织在一起。
我们仍然在控制器代码中保留了
productId的额外行和try/catch块,但我们可以使用自定义模型绑定器和异常过滤器来消除这些。我在本章末尾留下了额外的资源,我们将在下一章深入探讨这一点。
在此基础上,现在向项目中添加新功能变得简单直接。从视觉上看,我们最终得到以下垂直切片(粗体),可能的垂直扩展(正常),以及共享类(斜体):

图 17.7:表示项目和与产品管理相关的可能扩展的图表
图表显示了两个主要区域,产品,和库存的分组。在产品方面,我包含了一个扩展,描述了一个类似 CRUD 的功能组。在我们的小型应用程序中,很难将数据访问部分分成多个DbContext,因此所有切片都使用ProductContext,创建了一个共享的数据访问层。
在其他情况下,当可能时,创建多个
DbContext。这与垂直切片架构无关,但将域划分为更小的边界上下文是一个好的实践。
当功能具有凝聚性并适合于域的同一部分时,考虑将它们分组。接下来,让我们测试我们的应用程序。
测试
对于这个项目,我为每个用例结果编写了一个集成测试,这降低了所需的单元测试数量,同时提高了对系统的信心。为什么?因为我们正在测试功能本身,而不是独立地测试许多抽象的部分。这是灰盒测试。我们也可以添加我们需要的任何数量的单元测试。这种方法帮助我们编写更少但更好的面向功能的测试,减少了需要大量模拟的单元测试的需求。单元测试可以比集成测试更快地验证复杂用例和算法。让我们首先看看StocksTest类的骨架:
namespace VerticalApp.Features.Stocks;
public class StocksTest
{
private static async Task SeederDelegate(ProductContext db)
{
db.Products.RemoveRange(db.Products.ToArray());
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();
}
public class AddStocksTest : StocksTest
{
// omitted test methods
}
public class RemoveStocksTest : StocksTest
{
// omitted test methods
}
public class StocksControllerTest : StocksTest
{
// omitted test methods
}
}
SeedAsync方法从内存测试数据库中删除所有产品并插入两个新的,以便测试方法可以使用可预测的数据集运行。AddStocksTest和RemoveStocksTest类包含它们各自用例的测试方法。StocksControllerTest测试 MVC 部分。让我们探索AddStocksTest类的快乐路径:
[Fact]
public async Task Should_increment_QuantityInStock_by_the_specified_amount()
{
// Arrange
await using var application = new VerticalAppApplication();
await application.SeedAsync(SeederDelegate);
using var requestScope = application.Services.CreateScope();
var mediator = requestScope.ServiceProvider
.GetRequiredService<IMediator>();
// Act
var result = await mediator.Send(new AddStocks.Command
{
ProductId = 4,
Amount = 10
});
// Assert
using var assertScope = application.Services.CreateScope();
var db = assertScope.ServiceProvider
.GetRequiredService<ProductContext>();
var peppers = await db.Products.FindAsync(4);
Assert.NotNull(peppers);
Assert.Equal(20, peppers!.QuantityInStock);
}
在前一个测试用例的安排部分,我们创建了一个应用程序实例,创建了一个作用域来模拟 HTTP 请求,访问 EF Core DbContext,然后获取一个IMediator实例来执行操作。在行为块中,我们通过 MediatR 管道发送一个有效的AddStocks.Command。在断言块中,我们创建一个新的作用域,并从容器中获取ProductContext。使用这个DbContext,我们找到产品,确保它不为空,并验证库存数量是否符合预期。使用新的ProductContext确保我们不会处理任何来自先前操作的缓存项,并且事务已按预期保存。通过这个测试用例,我们知道如果向中介者发出有效命令,该处理程序将被执行,并且成功地将库存属性增加指定数量。
VerticalAppApplication类继承自WebApplicationFactory<TEntryPoint>,创建了一个新的DbContextOptionsBuilder<ProductContext>实例,该实例具有可配置的数据库名称,实现了一个SeedAsync方法,允许对数据库进行初始化,并允许修改应用程序服务。出于简洁的考虑,我省略了代码,但您可以在 GitHub 仓库中查看完整的源代码(adpg.link/mWep)。
现在,我们可以测试 MVC 部分以确保控制器配置正确。在StocksControllerTest类中,AddAsync类包含以下测试方法:
public class AddAsync : StocksControllerTest
{
[Fact]
public async Task Should_send_a_valid_AddStocks_Command_to_the_mediator()
{
// Arrange
var mediatorMock = new Mock<IMediator>();
AddStocks.Command? addStocksCommand = default;
mediatorMock
.Setup(x => x.Send(It.IsAny<AddStocks.Command>(), It.IsAny<CancellationToken>()))
.Callback((IRequest<AddStocks.Result> request, CancellationToken cancellationToken) => addStocksCommand = request as AddStocks.Command)
;
await using var application = new VerticalAppApplication(
afterConfigureServices: services => services
.AddSingleton(mediatorMock.Object)
);
var client = application.CreateClient();
var httpContent = JsonContent.Create(
new { amount = 1 },
options: new JsonSerializerOptions(JsonSerializerDefaults.Web)
);
// Act
var response = await client.PostAsync("/products/5/add-stocks", httpContent);
// Assert
Assert.NotNull(response);
Assert.NotNull(addStocksCommand);
response.EnsureSuccessStatusCode();
mediatorMock.Verify(
x => x.Send(It.IsAny<AddStocks.Command>(), It.IsAny<CancellationToken>()),
Times.Once()
);
Assert.Equal(5, addStocksCommand!.ProductId);
Assert.Equal(1, addStocksCommand!.Amount);
}
}
前一个测试用例中高亮的Arrange块模拟了IMediator,并将传递给addStocksCommand变量的内容保存。我们在Assert块的高亮代码中使用这个值。在创建VerticalAppApplication实例时,我们将模拟注册到容器中,以使用它而不是 MediatR 的一个,从而绕过了默认行为。然后我们创建了一个连接到我们进程内应用程序的HttpClient,并在Act部分构建了一个有效的 HTTP 请求来添加我们 POST 的股票。Assert块代码确保请求成功,验证模拟方法被调用了一次,并确保AddStocks.Command配置正确。从第一个测试中,我们知道 MediatR 部分是正常工作的。有了这个第二个测试,我们知道 HTTP 部分也是正常工作的。现在我们几乎可以确定,有效的添加股票请求将通过这两个测试击中数据库。
我说“几乎确定”,是因为我们的测试是在内存数据库上运行的,这与真实的数据库引擎(例如,它没有关系完整性等)不同。在涉及多个表或确保功能正确性的更复杂的数据库操作中,您可以对接近生产数据库的数据库运行测试。例如,我们可以运行测试以针对 SQL Server 容器,以便在我们的 CI/CD 管道中轻松地启动和销毁数据库。
在测试项目中,我添加了更多测试,涵盖了删除股票和列出所有产品功能,并确保 AutoMapper 配置正确。请随意浏览代码。我这里省略了它们,因为它们变得冗余。目标是探索使用非常少的测试(在这种情况下是两个用于快乐路径的测试)来测试功能几乎端到端,我认为我们已经做到了这一点。
结论
垂直切片项目展示了我们如何在保持对象松散耦合的同时移除抽象。我们还把项目组织成了功能(垂直),而不是层(水平)。我们利用了 CQS、中介者和 MVC 模式。从概念上讲,层仍然存在;例如,控制器是表示层的一部分,但它们不是那样组织的,这使得它们成为功能的一部分。唯一跨越所有功能的依赖是ProductContext类,这是有意义的,因为我们的模型只包含一个类(Product)。例如,我们可以添加一个利用最小 API 而不是控制器的新功能,这是可以接受的,因为每个切片都是独立的。我们可以通过用集成测试测试每个垂直切片来显著减少所需的模拟数量。这也可以显著减少单元测试的数量,测试功能而不是模拟的代码单元。我们应该专注于产生功能和商业价值,而不是查询基础设施或代码背后的细节。我们也不应该忽视技术方面;性能和可维护性也是重要特征,但减少抽象的数量也可以使应用程序更容易维护,当然更容易理解。总的来说,我们探索了一种与现代设计方法相一致的应用程序设计方式,这有助于与敏捷开发保持一致并为客户创造价值。在进入总结之前,让我们看看垂直切片架构如何帮助我们遵循SOLID原则:
-
S: 每个垂直切片(功能)成为一个整体变化的统一单元,导致每个功能的责任分离。基于受 CQS 启发的方案,每个功能将应用程序的复杂性分解为命令和查询,导致多个小块。每个小块处理过程的一部分。例如,我们可以定义一个输入、一个验证器、一个映射配置文件、一个处理器、一个结果、一个 HTTP 桥接器(控制器或端点),以及我们需要的任何更多部分来构建切片。
-
O: 我们可以通过扩展 ASP.NET Core、MVC 或 MediatR 管道来全局增强系统。我们可以根据需要设计功能,包括尊重 OCP。
-
L: N/A
-
I: 通过按领域中心用例的单元组织功能,我们创建了众多针对特定客户端的组件,而不是像层这样的通用元素。
-
D: 切片部分依赖于接口,并通过依赖注入相互连接。此外,通过从系统中移除不太有用的抽象,我们简化了它,使其更易于维护和简洁。许多功能部分紧密相邻使得系统更容易维护并提高了其可发现性。
接下来,我们将探讨一些技巧和流程,以开始处理更大的应用程序。这些是我发现对我有效的方法,也许对您也有效。取您认为有效的东西,其余的则留给别人;我们都是不同的,工作方式也不同。
继续您的旅程:一些技巧和窍门
之前的项目很小。它有一个共享模型,作为数据层,因为它由一个类组成。在构建现实世界的应用程序时,您有不止一个类,所以我会给您一个良好的起点来处理更大的应用程序。想法是尽可能创建小的切片,尽可能限制与其他切片的交互,并将该代码重构为更好的代码。我们不能消除耦合,所以我们需要组织它,关键是将其耦合集中在一个功能内部。以下是一个受 TDD 启发的流程,但不太严格:
-
编写覆盖您功能(输入和输出)的合约。
-
使用这些合约编写一个或多个覆盖您功能的集成测试;以
Query或Command类(IRequest)作为输入,以Result类作为输出。 -
实现
Handler、Validator、MapperProfile以及任何需要编码的其他部分。在此阶段,代码可能是一个巨大的Handler;这并不重要。 -
一旦您的集成测试通过,根据需要拆分您的巨大
Handle方法来重构代码。 -
确保您的测试仍然通过。
在 步骤 2 中,您还可以使用单元测试来测试验证规则。通过单元测试测试多个组合和场景要容易和快得多,而且您不需要为此访问数据库。同样,这也适用于您的系统中与外部资源无关的任何其他部分。在 步骤 4 中,您可能会在功能之间发现重复的逻辑。如果是这样,那么是时候将这部分逻辑封装到其他地方,一个共享的位置。这可能是在模型中创建一个方法、一个服务类,或者任何其他您知道可以解决您逻辑重复问题的模式和技巧。从隔离的功能中提取共享逻辑将帮助您设计应用程序。您希望将共享逻辑推到处理器外部,而不是相反(当然,一旦您有了共享逻辑,您可以根据需要使用它)。在这里,我想强调 共享逻辑,这意味着业务规则。当业务规则发生变化时,所有使用该业务规则的消费者也必须改变他们的行为。避免共享 相似代码,但共享业务规则。记住 DRY 原则。在设计软件时,非常重要的一点是关注功能需求,而不是技术需求。您的客户和用户不关心技术细节;他们想要结果、新功能、错误修复和改进。同时,要警惕技术债务,不要跳过重构步骤,否则您的项目可能会遇到麻烦。这些建议适用于所有类型的架构。另一个建议是尽可能保持所有构成垂直切片的代码的紧密性。您不需要将所有用例类都放在一个文件中,但我发现这样做有帮助。部分类是一种将类拆分为多个文件的方法。如果命名正确,Visual Studio 将将其嵌套在主文件下。例如,Visual Studio 将将 MyFeature.Hander.cs 文件嵌套在 MyFeature.cs 文件下,依此类推。您还可以创建一个文件夹层次结构,其中较深级别共享上一级别的文件。例如,我在一个 MVC 应用程序中实现的一个与运输相关的流程创建过程有多个步骤。因此,我最终得到了一个如下所示的层次结构:

图 17.12:目录和元素的组织层次结构
初始时,我单独编写了所有处理程序。然后我看到了模式的出现,所以我将共享逻辑封装到共享类中。然后我重用了某些高级异常,所以我将它们从Features/Shipments/Create文件夹移动到Features/Shipments文件夹。我还提取了一个服务类来管理多个用例之间的共享逻辑。最终,我只有我需要的代码,没有重复的逻辑,协作者(类、接口)尽可能接近。功能之间的耦合最小,而系统的某些部分协同工作(内聚)。此外,与其他系统部分的耦合非常小。如果我们将这个结果与另一种类型的架构,如分层架构进行比较,我可能需要更多的抽象,例如存储库、服务和之类的东西;垂直切片架构的结果更干净、更简单。关键点在于独立编写处理程序,尽可能好地组织它们,留心共享逻辑和出现的模式,提取并封装该逻辑,并尝试限制用例和切片之间的交互。
摘要
本章概述了垂直切片架构,该架构通过将层旋转 90°来实现。垂直切片架构是关于通过依赖开发者的技能和判断,从方程中去除多余的抽象和规则,以编写最小代码来生成最大价值。在垂直切片架构项目中,重构至关重要;成功或失败很可能取决于它。我们也可以在垂直切片架构中使用任何模式。它相对于分层架构有很多优点,只有少数缺点。在孤岛(水平团队)中工作的团队可能需要重新考虑转向垂直切片架构,并首先创建或旨在创建多功能团队(垂直团队)。我们用命令和查询(受 CQS 启发)替换了低价值的抽象。然后,使用中介者模式(由 MediatR 帮助)将它们路由到相应的Handler。这允许封装业务逻辑并将其与其调用者解耦。这些命令和查询确保每个领域逻辑的每一部分都集中在一个单一的位置。当然,如果你从对问题的强大分析开始,你很可能会领先,就像任何项目一样。没有什么能阻止你在你的切片中构建和使用健壮的领域模型。你拥有的需求越多,初始项目组织就越容易。重复一遍,你了解的所有工程实践仍然适用。下一章通过探索使用最小 API 的请求-端点-响应(REPR)模式,进一步简化了垂直切片架构的概念。
问题
让我们看看几个实践问题:
-
我们可以在垂直切片中使用哪些设计模式?
-
在使用垂直切片架构时,是否必须选择一个单一的 ORM 并坚持使用它,例如数据层?
-
如果你长期不重构代码和不偿还技术债务,可能会发生什么?
-
内聚性是什么意思?
-
紧耦合是什么意思?
进一步阅读
这里有一些链接,可以帮助我们巩固本章所学的内容:
- 对于 UI 实现,你可以看看 Jimmy Bogard 是如何升级 ContosoUniversity 的:
-
使用.NET Core 的 ASP.NET Core 上的 ContosoUniversity:
adpg.link/UXnr -
使用.NET Core 和 Razor Pages 的 ASP.NET Core 上的 ContosoUniversity:
adpg.link/6Lbo
-
FluentValidation:
adpg.link/xXgp -
AutoMapper:
adpg.link/5AUZ -
MediatR:
adpg.link/ZQap
答案
-
你知道的任何可以帮助你实现特性的模式和技巧。这就是垂直切片架构的美丽之处;你受到的限制只有你自己。
-
不,你可以在每个垂直切片内选择最适合的工具;你甚至不需要层。
-
应用程序很可能会变成一个大泥球,维护起来非常困难,这对你的压力水平、产品质量、变更上市时间等都不利。
-
内聚性意味着应该作为一个统一的整体一起工作的元素。
-
紧耦合描述了不能独立改变、直接相互依赖的元素。
第十八章:18 请求-端点-响应 (REPR) 和最小 API
在开始之前:加入我们的 Discord 书籍社区
直接向作者本人提供反馈,并在我们的 Discord 服务器上与其他早期读者聊天(在“architecting-aspnet-core-apps-3e”频道下找到,位于“EARLY ACCESS SUBSCRIPTION”下)。

本章介绍了 请求-端点-响应 (REPR) 模式,该模式建立在垂直切片架构和 CQS 之上。我们继续简化我们的代码库,使其更加易于阅读、维护,并且更少抽象,同时仍然可测试。
我们将 REPR 发音为“reaper”,这比“rer”或“reper”听起来要好得多。我必须感谢 Steve "ardalis" Smith 为这个出色的模式名称。我在 进一步阅读 部分留下了一个链接到他的文章。
我们已经使用了这个模式,可能你并不知道它的名字。现在是时候正式介绍它了,然后组装一个技术栈,使其适用于现实世界的应用。我们构建了这个解决方案,然后在章节中通过探索手动技术、现有工具和开源库来改进它。结果并不完美,但我们还没有完成对这个新的受电子商务启发的解决方案的改进。
这个方法的关键是学习如何思考架构并提高你的设计技能,这样你就有工具来克服现实世界将向你抛出的独特挑战!
在本章中,我们将探讨以下主题:
-
请求-端点-响应 (REPR) 模式
-
项目 – REPR—现实世界的一块
在深入研究更实际的示例之前,让我们先探索这个模式。
请求-端点-响应 (REPR) 模式
请求-端点-响应 (REPR) 模式提供了一种简单的方法,类似于我们在垂直切片架构中探索的方法,它与传统模型-视图-控制器 (MVC) 模式不同。正如我们在 MVC 章节中探讨的那样,REST API 没有视图,因此我们必须扭曲这个概念才能使其工作。由于每个 URL 都是一种描述如何到达端点(执行操作)的方式,而不是控制器,因此 REPR 在 HTTP 上下文中构建 REST API 比 MVC 更为合适。
目标
REPR 的目标是使我们的 REST API 与 HTTP 对齐,并将固有的请求-响应概念作为我们应用程序设计中的第一公民。在此基础上,使用最小 API 的 REPR 模式与垂直切片架构很好地对齐,并有助于构建面向功能的软件而不是层状应用。
设计
REPR 有三个组件:
-
一个包含端点执行工作所需信息的请求,并扮演输入 DTO 的角色。
-
一个包含要执行的业务逻辑的端点处理器,这是这个模式的核心部分。
-
一个端点返回给客户端的响应,并扮演输出 DTO 的角色。
你可以将每个请求视为我们在 CQS 和垂直切片架构章节中探索的查询或命令。以下是一个表示此概念的图表:

图 18.1:表示逻辑流程和 REPR 模式的图。
上述图表应该听起来很熟悉,因为它与我们探索的垂直切片架构相似。然而,我们使用的是请求-处理程序-结果(即 REPR),而不是请求-处理程序-结果。简而言之,一个请求可以是查询或命令,然后它击中执行逻辑的端点,最后返回一个响应。
即使响应体为空,服务器也会返回 HTTP 响应。
让我们通过使用 Minimal API 来探索一个示例。
项目 – SimpleEndpoint
SimpleEndpoint 项目展示了几个简单的功能和模式,用于组织我们的 REPR 功能,而不依赖于外部库。
功能:ShuffleText
第一个功能接收一个字符串作为输入,打乱其内容,然后返回它:
namespace SimpleEndpoint;
public class ShuffleText
{
public record class Request(string Text);
public record class Response(string Text);
public class Endpoint
{
public Response Handle(Request request)
{
var chars = request.Text.ToArray();
Random.Shared.Shuffle(chars);
return new Response(new string(chars));
}
}
}
上述代码利用Random API 打乱request.Text属性,然后返回封装在Response对象中的结果。在执行我们的功能之前,我们必须创建一个最小的 API 映射,并将我们的处理程序注册到容器中。以下是实现此功能的Program.cs类:
using SimpleEndpoint;
var builder = WebApplication.CreateBuilder(args);
builder.Services.AddSingleton<ShuffleText.Endpoint>();
var app = builder.Build();
app.MapGet(
"/shuffle-text/{text}",
([AsParameters] ShuffleText.Request query, ShuffleText.Endpoint endpoint)
=> endpoint.Handle(query)
);
app.Run();
上述代码将ShuffleText.Endpoint注册为单例,这样我们就可以将其注入到委托中。委托利用[AsParameters]属性将路由参数绑定到ShuffleText.Request属性。最后,逻辑很简单;端点委托将请求发送到注入的端点处理程序,并返回结果,ASP.NET Core 将其序列化为 JSON。当我们发送以下 HTTP 请求时:
GET https://localhost:7289/shuffle-text/I%20love%20ASP.NET%20Core
我们得到一些类似以下内容的乱码结果:
{
"text": "eo .e vNrCAT PSElIo"
}
这个模式是我们能从盒子里得到的简单模式之一。接下来,我们将端点本身封装起来。
功能:RandomNumber
此功能在最小值和最大值之间生成多个随机数。第一个模式将代码在Program.cs文件和功能本身之间划分。在这个模式中,我们将端点委托封装到功能中(在这种情况下是同一文件):
namespace SimpleEndpoint;
public class RandomNumber
{
public record class Request(int Amount, int Min, int Max);
public record class Response(IEnumerable<int> Numbers);
public class Handler
{
public Response Handle(Request request)
{
var result = new int[request.Amount];
for (var i = 0; i < request.Amount; i++)
{
result[i] = Random.Shared.Next(request.Min, request.Max);
}
return new Response(result);
}
}
public static Response Endpoint([AsParameters] Request query, Handler handler)
=> handler.Handle(query);
}
上述代码与第一个功能非常相似。然而,我们命名为Endpoint的委托现在是功能类的一部分(突出显示的代码)。包含逻辑的类现在称为Handler而不是Endpoint。这种变化使得整个功能更紧密地聚集在一起。尽管如此,我们仍然需要将依赖项注册到容器中,并在Program.cs文件中将端点映射到我们的委托,如下所示:
builder.Services.AddSingleton<RandomNumber.Handler>();
// ...
app.MapGet(
"/random-number/{Amount}/{Min}/{Max}",
RandomNumber.Endpoint
);
上述代码将请求路由到RandomNumber.Endpoint方法。当我们发送以下 HTTP 请求时:
https://localhost:7289/random-number/5/0/100
我们得到的结果类似于以下内容:
{
"numbers": [
60,
27,
78,
63,
87
]
}
我们将更多特性代码放在一起;然而,我们的代码仍然分为两个文件。让我们探索一种修复这个问题的方法。
特性:UpperCase
此特性将输入文本转换为大写并返回结果。我们的目标是尽可能地将代码集中到 UpperCase 特性类中,以便我们可以从单一位置控制它。为了实现这一点,我们创建了以下扩展方法(突出显示):
namespace SimpleEndpoint;
public static class UpperCase
{
public record class Request(string Text);
public record class Response(string Text);
public class Handler
{
public Response Handle(Request request)
{
return new Response(request.Text.ToUpper());
}
}
public static IServiceCollection AddUpperCase(this IServiceCollection services)
{
return services.AddSingleton<Handler>();
}
public static IEndpointRouteBuilder MapUpperCase(this IEndpointRouteBuilder endpoints)
{
endpoints.MapGet(
"/upper-case/{Text}",
([AsParameters] Request query, Handler handler)
=> handler.Handle(query)
);
return endpoints;
}
}
在上一段代码中,我们更改了以下内容:
-
UpperCase类是静态的,这样我们可以创建扩展方法。将UpperCase类转换为静态类并不会妨碍我们的可维护性,因为我们将其用作组织者,并不实例化它。 -
我们添加了
AddUpperCase方法,它将依赖项注册到容器中。 -
我们添加了
MapUpperCase方法,它本身创建了端点。
在 Program.cs 文件中,我们现在可以像这样注册我们的特性:
builder.Services.AddUpperCase();
// ...
app.MapUpperCase();
上一段代码调用了我们的扩展方法,这些方法将所有相关代码移动到 UpperCase 类中,但与 ASP.NET Core 的连接除外。
我认为这种方法对于无依赖项项目来说既优雅又非常干净。当然,我们可以以百万种不同的方式设计它,使用现有的库来帮助我们,扫描程序集并自动注册我们的特性,等等。
您可以使用此模式构建实际的应用程序。我建议创建一个
AddFeatures和一个MapFeatures扩展方法来注册所有特性,而不是让Program.cs文件杂乱无章,但除了少数最终的组织性触摸之外,这是一个足够健壮的模式。我们将在下一个项目中进一步探讨这一点。
当我们发送以下 HTTP 请求时:
GET https://localhost:7289/upper-case/I%20love%20ASP.NET%20Core
我们收到了以下响应:
{
"text": "I LOVE ASP.NET CORE"
}
现在我们已经探讨了 REPR 以及如何以几种不同的方式封装我们的 REPR 特性,我们几乎准备好探索一个更大的项目了。
结论
使用最小 API、REPR 模式以及无外部依赖项创建基于特性的设计是可能的且简单的。我们以不同的方式组织了我们的项目。每个特性包括一个请求、一个响应和一个附加到端点的处理器。
我们可以将处理器和端点结合起来,使其成为一个三组件模式。我喜欢有一个独立处理器的优点,因为我们可以在非 HTTP 上下文中重用处理器;比如说,我们可以在应用程序前面创建一个 CLI 工具并重用相同的逻辑。这完全取决于我们正在构建的内容。
让我们看看 REPR 模式如何帮助我们遵循 SOLID 原则:
-
S:每个部分都有一个单一的责任,并且所有部分都集中在一个特性下,便于导航,使此模式成为完美的 SRP 协作者。
-
O:使用与我们对
UpperCase特性所采取的类似方法,我们可以更改特性的行为,而不会影响代码库的其余部分。 -
L:N/A
-
I:REPR 模式将特性分为三个更小的接口:请求、端点和响应。
-
D:N/A
现在我们已经熟悉了 REPR 模式,是时候探索一个更大的项目了,包括异常处理和灰盒测试。
项目 – REPR—现实世界的一块
上下文:此项目与之前关于产品和库存的项目略有不同。我们从产品中移除了库存,添加了单价,并创建了一个基础性的购物篮作为电子商务应用程序的基础。库存管理变得如此复杂,以至于我们必须将其提取并单独处理(此处未包含)。通过使用 REPR 模式、Minimal APIs 和我们学到的垂直切片架构,我们分析出应用程序包含两个主要区域:
-
产品目录
-
购物车
对于这个第一个迭代,我们将产品的管理从应用程序中分离出来,只支持以下功能:
-
列出所有产品
-
获取产品的详细信息
对于购物车,我们将其保持到最小。篮子只持久化购物车中商品的 Id 和其数量。篮子不支持任何更高级的使用案例。以下是它支持的运算:
-
将商品添加到购物车
-
获取购物车中的商品
-
从购物车中移除商品
-
更新购物车中商品的数量
目前,购物车还没有意识到产品目录的存在。
我们在 第十九章,微服务架构简介 和 第二十章,模块化单体 中改进了应用程序。
让我们组装我们将构建其上的堆栈。
组装我们的堆栈
我想尽可能保持项目的基础性,使用 Minimal APIs,但,我们不必手动实现每一个关注点。以下是我们将用于构建此项目的工具:
-
以 ASP.NET Core Minimal API 作为我们的骨干。
-
FluentValidation 作为我们的验证框架。
-
FluentValidation.AspNetCore.Http 将 FluentValidation 连接到 Minimal API。
-
Mapperly 是我们的映射框架。
-
ExceptionMapper 帮助我们全局处理异常,将我们的模式转变为错误管理。
-
EF Core(内存中)作为我们的 ORM。
从终端窗口,我们可以使用 CLI 安装包:
dotnet add package FluentValidation.AspNetCore
dotnet add package ForEvolve.ExceptionMapper
dotnet add package ForEvolve.FluentValidation.AspNetCore.Http
dotnet add package Microsoft.EntityFrameworkCore.InMemory
dotnet add package Riok.Mapperly
我们已经了解了这些大部分组件,并将随着时间的推移深入探讨新的组件。同时,让我们探索项目的结构。
分析代码结构
目录结构与我们之前在垂直切片架构中探索的结构非常相似。项目的根目录包含 Program.cs 文件和一个 Features 目录,该目录包含功能或切片。以下图表表示功能:

图 18.2:表示功能分层关系的项目目录结构。
每个区域内的特性共享紧密的联系和一些代码片段(耦合),而这两个区域是完全断开的(松耦合)。Program.cs文件非常轻量,仅用于启动应用程序:
using Web.Features;
var builder = WebApplication.CreateBuilder(args);
builder.AddFeatures();
var app = builder.Build();
app.MapFeatures();
await app.SeedFeaturesAsync();
app.Run();
高亮行是在Features类(位于Features文件夹下)中定义的扩展方法,它将注册依赖项、映射端点和初始化数据库的责任级联到每个区域。以下是类的框架:
using FluentValidation;
using FluentValidation.AspNetCore;
using System.Reflection;
namespace Web.Features;
public static class Features
{
public static IServiceCollection AddFeatures(
this WebApplicationBuilder builder){}
public static IEndpointRouteBuilder MapFeatures(
this IEndpointRouteBuilder endpoints){}
public static async Task SeedFeaturesAsync(
this WebApplication app){}
}
现在我们来探索AddFeatures方法:
public static IServiceCollection AddFeatures(this WebApplicationBuilder builder)
{
// Register fluent validation
builder.AddFluentValidationEndpointFilter();
return builder.Services
.AddFluentValidationAutoValidation()
.AddValidatorsFromAssembly(Assembly.GetExecutingAssembly())
// Add features
.AddProductsFeature()
.AddBasketsFeature()
;
}
AddFeatures方法注册了 FluentValidation 和 Minimal API 过滤器来验证我们的端点(高亮行)。每个切片定义了自己的配置方法,如AddProductsFeature和AddBasketsFeature方法。我们稍后会回到这些方法。同时,让我们探索MapFeatures方法:
public static IEndpointRouteBuilder MapFeatures(this IEndpointRouteBuilder endpoints)
{
var group = endpoints
.MapGroup("/")
.AddFluentValidationFilter();
;
group
.MapProductsFeature()
.MapBasketsFeature()
;
return endpoints;
}
MapFeatures方法创建一个根路由组,向其中添加FluentValidation过滤器,以便验证该组中的所有端点,然后调用MapProductsFeature和MapBasketsFeature方法,将它们的特性映射到该组中。最后,SeedFeaturesAsync方法使用特性扩展方法对数据库进行初始化:
public static async Task SeedFeaturesAsync(this WebApplication app)
{
using var scope = app.Services.CreateScope();
await scope.SeedProductsAsync();
await scope.SeedBasketAsync();
}
在这些构建块就绪后,程序开始运行,添加特性并注册端点。之后,每个特性类别——产品和购物车——级联调用,让每个特性注册其部分。接下来是一些表示从Program.cs文件调用层次关系的图。让我们从AddFeatures方法开始:

图 18.3:AddFeatures 方法的调用层次。
上一张图展示了责任划分,其中每个部分都会聚合其子部分或注册其依赖项。从MapFeatures方法中也有类似的流程:

图 18.4:MapFeatures 方法的调用层次。
最后,SeedFeaturesAsync方法使用类似的方法对内存数据库进行初始化:

图 18.5:SeedFeaturesAsync 方法的调用层次。
这些图展示了入口点(Program.cs)向每个特性发送请求,以便每个部分都能自行处理。
在实际项目中使用实际数据库时,你不想以这种方式初始化数据库。在这种情况下,它之所以可行,是因为每次启动项目时,数据库都是空的,因为它只存在于程序运行期间——它存在于内存中。在现实生活中,有无数种策略可以初始化你的数据源,从执行 SQL 脚本到部署只运行一次的 Docker 容器。
现在我们已经探索了程序的高级视图,是时候深入一个特性并了解它是如何工作的了。
探索购物车
本节探讨了购物车切片的AddItem和FetchItems功能。该切片完全与Products切片解耦,并且不知道产品本身。它只知道如何累积产品标识符和数量,并将这些与客户关联起来。我们稍后解决这个问题。
没有客户功能也没有身份验证,以保持项目简单。
Features/Baskets/Baskets.cs文件的代码为购物车功能提供动力。以下是框架:
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Diagnostics;
namespace Web.Features;
public static partial class Baskets
{
// Baskets.cs
public record class BasketItem(int CustomerId, int ProductId, int Quantity);
public class BasketContext : DbContext {}
public static IServiceCollection AddBasketsFeature(this IServiceCollection services) {}
public static IEndpointRouteBuilder MapBasketsFeature(this IEndpointRouteBuilder endpoints) {}
public static Task SeedBasketsAsync(this IServiceScope scope) {}
// Baskets.AddItem.cs
public partial class AddItem {}
public static IServiceCollection AddAddItem(this IServiceCollection services) {}
public static IEndpointRouteBuilder MapAddItem(this IEndpointRouteBuilder endpoints) {}
// Baskets.FetchItems.cs
public partial class FetchItems {}
public static IServiceCollection AddFetchItems(this IServiceCollection services) {}
public static IEndpointRouteBuilder MapFetchItems(this IEndpointRouteBuilder endpoints) {}
// Baskets.RemoveItem.cs
public partial class RemoveItem {}
public static IServiceCollection AddRemoveItem(this IServiceCollection services) {}
public static IEndpointRouteBuilder MapRemoveItem(this IEndpointRouteBuilder endpoints) {}
// Baskets.UpdateQuantity.cs
public partial class UpdateQuantity {}
public static IServiceCollection AddUpdateQuantity(this IServiceCollection services) {}
public static IEndpointRouteBuilder MapUpdateQuantity(this IEndpointRouteBuilder endpoints) {}
}
前一个代码块中高亮的代码包含BasketItem数据模型和BasketContext EF Core DbContext,所有购物车功能都共享这些。它还包括三个注册和使功能工作的方法(AddBasketsFeature、MapBasketsFeature和SeedBasketsAsync)。其他方法和类被分到几个文件中。我们在本章中探索了其中的一些。
我们使用了
partial修饰符将嵌套类拆分到多个文件中。我们将类设置为static以在其中创建扩展方法。
BasketItem类允许我们将简单的购物车持久化到数据库:
public record class BasketItem(
int CustomerId,
int ProductId,
int Quantity
);
BasketContext类配置了BasketItem类的主键并公开了Items属性(高亮显示):
public class BasketContext : DbContext
{
public BasketContext(DbContextOptions<BasketContext> options)
: base(options) { }
public DbSet<BasketItem> Items => Set<BasketItem>();
protected override void OnModelCreating(ModelBuilder modelBuilder)
{
base.OnModelCreating(modelBuilder);
modelBuilder
.Entity<BasketItem>()
.HasKey(x => new { x.CustomerId, x.ProductId })
;
}
}
AddBasketsFeature方法将每个功能和BasketContext注册到 IoC 容器中:
public static IServiceCollection AddBasketsFeature(this IServiceCollection services)
{
return services
.AddAddItem()
.AddFetchItems()
.AddRemoveItem()
.AddUpdateQuantity()
.AddDbContext<BasketContext>(options => options
.UseInMemoryDatabase("BasketContextMemoryDB")
.ConfigureWarnings(builder => builder.Ignore(InMemoryEventId.TransactionIgnoredWarning))
)
;
}
除了AddDbContext方法外,AddBasketsFeature将依赖项注册委托给每个功能。我们很快就会探索高亮显示的部分。EF Core 代码注册了服务于BasketContext的内存提供程序。接下来,MapBasketsFeature方法映射端点:
public static IEndpointRouteBuilder MapBasketsFeature(this IEndpointRouteBuilder endpoints)
{
var group = endpoints
.MapGroup(nameof(Baskets).ToLower())
.WithTags(nameof(Baskets))
;
group
.MapFetchItems()
.MapAddItem()
.MapUpdateQuantity()
.MapRemoveItem()
;
return endpoints;
}
前面的代码创建了一个名为baskets的组,使其端点可通过/baskets URL 前缀访问。我们还标记了“Baskets”以利用未来的 OpenAPI 生成器。然后该方法使用与AddBasketsFeature方法类似的模式,并将端点映射委托给功能。
你注意到该方法直接返回
endpoints对象了吗?这允许我们链式调用特征映射。在另一种场景中,我们可以返回group对象(RouteGroupBuilder实例)以允许调用者进一步配置该组。我们所构建的始终关于需求和目标。
最后,SeedBasketsAsync方法什么都不做;与Products切片不同,我们在程序启动时不会创建任何购物车。
public static Task SeedBasketsAsync(this IServiceScope scope)
{
return Task.CompletedTask;
}
我们本可以省略前面的方法。我留下它是为了我们在特征之间遵循线性模式。这样的线性模式使得理解和学习变得更加容易。它还允许我们识别可以工作的重复部分,以自动化注册过程。
现在我们已经涵盖了共享部分,让我们向我们的购物车添加数据。
添加项目功能
AddItem 功能的作用是创建一个 BasketItem 对象并将其持久化到数据库中。为了实现这一点,我们利用了 REPR 模式。受到前几章的启发,我们将请求命名为 Command(CQS 模式),使用 Mapperly 添加一个映射对象,并利用 FluentValidation 确保请求有效。以下是 AddItem 类的骨架:
using FluentValidation;
using Microsoft.EntityFrameworkCore;
using Riok.Mapperly.Abstractions;
namespace Web.Features;
public partial class Baskets
{
public partial class AddItem
{
public record class Command(
int CustomerId,
int ProductId,
int Quantity
);
public record class Response(
int ProductId,
int Quantity
);
[Mapper]
public partial class Mapper {}
public class Validator : AbstractValidator<Command> {}
public class Handler {}
}
public static IServiceCollection AddAddItem(this IServiceCollection services) {}
public static IEndpointRouteBuilder MapAddItem(this IEndpointRouteBuilder endpoints) {}
}
上述代码包含该功能所需的所有必要组件:
-
请求(
Command类)。 -
响应(
Response类)。 -
端点(指向
Handler类的MapAddItem方法)。 -
一个由 Mapperly 为我们生成映射代码的映射对象。
-
一个验证器类,确保我们接收到的输入是有效的。
-
AddAddItem方法将其服务注册到 IoC 容器中。
让我们从注册功能服务的 AddAddItem 方法开始:
public static IServiceCollection AddAddItem(this IServiceCollection services)
{
return services
.AddScoped<AddItem.Handler>()
.AddSingleton<AddItem.Mapper>()
;
}
然后 MapAddItem 方法将具有有效 Command 对象的适当 POST 请求路由到 Handler 类:
public static IEndpointRouteBuilder MapAddItem(
this IEndpointRouteBuilder endpoints)
{
endpoints.MapPost(
"/",
async (AddItem.Command command, AddItem.Handler handler, CancellationToken cancellationToken) =>
{
var result = await handler.HandleAsync(
command,
cancellationToken
);
return TypedResults.Created(
$"/products/{result.ProductId}",
result
);
}
);
return endpoints;
}
Command 实例是 BasketItem 类的一个副本,而响应仅返回 ProductId 和 Quantity 属性。下面高亮显示的行表示端点将 Command 对象传递给用例 Handler 类。
我们可以在代理中编写
Handler代码,这将使得对代理进行单元测试变得非常困难。
Handler 类是该功能的粘合剂:
public class Handler
{
private readonly BasketContext _db;
private readonly Mapper _mapper;
public Handler(BasketContext db, Mapper mapper)
{
_db = db ?? throw new ArgumentNullException(nameof(db));
_mapper = mapper ?? throw new ArgumentNullException(nameof(mapper));
}
public async Task<Response> HandleAsync(Command command, CancellationToken cancellationToken)
{
var itemExists = await _db.Items.AnyAsync(
x => x.CustomerId == command.CustomerId && x.ProductId == command.ProductId,
cancellationToken: cancellationToken
);
if (itemExists)
{
throw new DuplicateBasketItemException(command.ProductId);
}
var item = _mapper.Map(command);
_db.Add(item);
await _db.SaveChangesAsync(cancellationToken);
var result = _mapper.Map(item);
return result;
}
}
上述代码包含该功能的业务逻辑,通过确保商品不在购物车中。如果它在,则抛出 DuplicateBasketItemException。否则,它将商品保存到数据库中,然后返回一个 Response 对象。
每个客户(
CustomerId)在其购物车中只能拥有每个产品(ProductId)一次(复合主键),这就是为什么我们要测试这个条件。
处理器利用了 Mapper 类:
[Mapper]
public partial class Mapper
{
public partial BasketItem Map(Command item);
public partial Response Map(BasketItem item);
}
隐式地,使用以下 Validator 类对 Command 对象进行了验证:
public class Validator : AbstractValidator<Command>
{
public Validator()
{
RuleFor(x => x.CustomerId).GreaterThan(0);
RuleFor(x => x.ProductId).GreaterThan(0);
RuleFor(x => x.Quantity).GreaterThan(0);
}
}
作为提醒,在
Features.cs文件中,我们在根路由组上调用AddFluentValidationFilter方法,让FluentValidationEndpointFilter类使用Validator类为我们验证输入。
在此基础上,我们可以发送以下 HTTP 请求:
POST https://localhost:7252/baskets
Content-Type: application/json
{
"customerId": 1,
"productId": 3,
"quantity": 10
}
端点响应如下:
{
"productId": 3,
"quantity": 10
}
并且具有以下 HTTP 头:
Location: /products/3
回顾一下,以下是发生的情况:
-
ASP.NET Core 将请求路由到我们在
MapAddItem方法中注册的代理。 -
验证中间件运行
AddItem.Validator对象对发送到端点的AddItem.Command进行验证。请求是有效的。 -
AddItem.Handler类的HandleAsync方法被执行。 -
假设该商品尚未在客户的购物车中,则将其添加到数据库中。
-
HandleAsync方法将Response对象返回给代理。 -
代理返回一个
201 Create状态码,并将Location头设置为添加的产品 URL。
如前所述的列表所示,过程相当简单;一个请求进来,执行业务逻辑(端点),然后输出响应:REPR。
还有几个其他部分,但它们节省了我们进行对象映射和验证的麻烦。这些部分是可选的;你可以设想自己的堆栈,其中包含更多或更少的部分。
在功能代码之上,我们还有一些测试来评估业务逻辑随时间保持正确。我们将在灰盒测试部分中涵盖这些。同时,让我们看看FetchItems功能。
FetchItems 功能
既然我们已经知道了模式,这个功能应该更快地覆盖。它允许客户端使用以下请求检索指定客户的购物车:
public record class Query(int CustomerId);
客户端期望在响应中收到商品集合:
public record class Response(IEnumerable<Item> Items) : IEnumerable<Item>
{
public IEnumerator<Item> GetEnumerator()
=> Items.GetEnumerator();
IEnumerator IEnumerable.GetEnumerator()
=> ((IEnumerable)Items).GetEnumerator();
}
public record class Item(int ProductId, int Quantity);
由于客户端了解客户信息,它不需要端点返回CustomerId属性,这就是为什么Item类只包含两个BasketItem属性。以下是Mapper和Validator类,在这个阶段应该很容易理解:
[Mapper]
public partial class Mapper
{
public partial Response Map(IQueryable<BasketItem> items);
}
public class Validator : AbstractValidator<Query>
{
public Validator()
{
RuleFor(x => x.CustomerId).GreaterThan(0);
}
}
然后,最后一块管道是AddFetchItems方法,它将功能的服务注册到容器中:
public static IServiceCollection AddFetchItems(this IServiceCollection services)
{
return services
.AddScoped<FetchItems.Handler>()
.AddSingleton<FetchItems.Mapper>()
;
}
现在转到端点本身,将FetchItems.Query对象转发给一个FetchItems.Handler实例:
public static IEndpointRouteBuilder MapFetchItems(this IEndpointRouteBuilder endpoints)
{
endpoints.MapGet(
"/{CustomerId}",
([AsParameters] FetchItems.Query query, FetchItems.Handler handler, CancellationToken cancellationToken)
=> handler.HandleAsync(query, cancellationToken)
);
return endpoints;
}
前面的代码比AddItem功能简单,因为它直接将处理器的响应序列化为 200 OK 状态码,而不进行转换。最后,是Handler类本身:
public class Handler
{
private readonly BasketContext _db;
private readonly Mapper _mapper;
public Handler(BasketContext db, Mapper mapper)
{
_db = db ?? throw new ArgumentNullException(nameof(db));
_mapper = mapper ?? throw new ArgumentNullException(nameof(mapper));
}
public async Task<Response> HandleAsync(Query query, CancellationToken cancellationToken)
{
var items = _db.Items.Where(x => x.CustomerId == query.CustomerId);
await items.LoadAsync(cancellationToken);
var result = _mapper.Map(items);
return result;
}
}
前面的代码从数据库中加载与指定客户关联的所有商品并返回它们。如果没有商品,客户端会收到一个空数组。就这样;我们现在可以发送以下 HTTP 请求并调用端点:
GET https://localhost:7252/baskets/1
假设我们向购物车添加了一个商品,我们应该收到以下类似的响应:
[
{
"productId": 3,
"quantity": 10
}
]
现在我们有一个工作的购物车了!
您可以探索 GitHub 上可用的代码库中的其他功能(
adpg.link/ikAn)。所有功能都有测试并且是可用的。
接下来,我们来看异常处理。
管理异常处理
当产品已经在购物车中时,AddItem功能会抛出DuplicateBasketItemException异常。然而,当这种情况发生时,服务器返回一个类似于以下(部分输出)的错误:
Web.Features.DuplicateBasketItemException: The product '3' is already in your shopping cart.
at Web.Features.Baskets.AddItem.Handler.HandleAsync(Command command, CancellationToken cancellationToken) in C18\REPR\Web\Features\Baskets\Baskets.AddItem.cs:line 57
at Web.Features.Baskets.<>c.<<MapAddItem>b__2_0>d.MoveNext() in C18\REPR\Web\Features\Baskets\Baskets.AddItem.cs:line 82
--- End of stack trace from previous location ---
这个错误很丑陋,对于调用 API 的客户端来说不实用。为了解决这个问题,我们可以在某个地方添加 try-catch 并逐个处理每个异常,或者我们可以使用中间件来捕获异常并规范化它们的输出。逐个管理异常既麻烦又容易出错。另一方面,集中异常管理和将它们视为横切关注点将繁琐的机制转化为一个可以利用的新工具。此外,它确保 API 总是以相同的格式返回错误,无需额外努力。让我们编写一个基本的中间件。
创建异常处理中间件
ASP.NET Core 中的中间件作为管道的一部分执行,可以在端点执行前后运行。当发生异常时,请求将在并行管道中重新执行,允许不同的中间件管理错误流。要创建中间件,我们必须实现一个InvokeAsync方法。最简单的方法是实现IMiddleware接口。您可以将中间件类型添加到默认或异常处理备用管道中。以下代码表示一个基本的异常处理中间件:
using Microsoft.AspNetCore.Diagnostics;
namespace Web;
public class MyExceptionMiddleware : IMiddleware
{
public async Task InvokeAsync(HttpContext context, RequestDelegate next)
{
var exceptionHandlerPathFeature = context.Features
.Get<IExceptionHandlerFeature>() ?? throw new NotSupportedException();
var exception = exceptionHandlerPathFeature.Error;
await context.Response.WriteAsJsonAsync(new
{
Error = exception.Message
});
await next(context);
}
}
中间件获取IExceptionHandlerFeature以访问错误,并输出一个包含错误消息的对象(ASP.NET Core 管理此功能)。如果该功能不可用,中间件将抛出NotSupportedException,这会重新抛出原始异常。
任何备用管道的中间件抛出的异常类型都会重新抛出原始异常。
如果有,高亮显示的代码将执行管道中的下一个中间件。这些管道就像一个责任链,但具有不同的目标。要注册中间件,我们必须首先将其添加到容器中:
builder.Services.AddSingleton<MyExceptionMiddleware>();
然后,我们必须将其注册为异常处理备用管道的一部分:
app.UseExceptionHandler(errorApp =>
{
errorApp.UseMiddleware<MyExceptionMiddleware>();
});
我们还可以注册更多的中间件或直接创建它们,如下所示:
app.UseExceptionHandler(errorApp =>
{
errorApp.Use(async (context, next) =>
{
var exceptionHandlerPathFeature = context.Features
.Get<IExceptionHandlerFeature>() ?? throw new NotSupportedException();
var logger = context.RequestServices
.GetRequiredService<ILoggerFactory>()
.CreateLogger("ExceptionHandler");
var exception = exceptionHandlerPathFeature.Error;
logger.LogWarning(
"An exception occurred: {message}",
exception.Message
);
await next(context);
});
errorApp.UseMiddleware<MyExceptionMiddleware>();
});
可能性是无限的。
现在,如果我们尝试将重复的项目添加到购物车中,我们会收到一个带有以下正文的500 内部服务器错误:
{
"error": "The product \u00273\u0027 is already in your shopping cart."
}
这个响应比以前更优雅,更容易处理。我们还可以在中间件中更改状态码。然而,自定义这个中间件需要很多页面,所以我们利用现有的库。
使用 ExceptionMapper 进行异常处理
ForEvolve.ExceptionMapper包是一个 ASP.NET Core 中间件,允许我们将异常映射到不同的状态码。开箱即用,它提供了许多异常类型以供开始,处理它们,并允许轻松地将自定义异常与状态码映射。默认情况下,该库通过尽可能利用 ASP.NET Core 组件将异常序列化为ProblemDetails对象(基于 RFC 7807),因此我们可以通过自定义 ASP.NET Core 来定制库的某些部分。要开始,在Program.cs文件中,我们必须添加以下行:
// Add the dependencies to the container
builder.AddExceptionMapper();
// Register the middleware
app.UseExceptionMapper();
现在,如果我们尝试将重复的产品添加到购物车中,我们会收到一个带有以下正文的带有409 冲突状态码的响应:
{
"type": "https://tools.ietf.org/html/rfc9110#section-15.5.10",
"title": "The product \u00273\u0027 is already in your shopping cart.",
"status": 409,
"traceId": "00-74bdbaa08064fd97ba1de31802ec6f8f-31ffd9ea8215b706-00",
"debug": {
"type": {
"name": "DuplicateBasketItemException",
"fullName": "Web.Features.DuplicateBasketItemException"
},
"stackTrace": "..."
}
}
这个输出开始看起来像样了!
(高亮显示的)
debug对象仅在开发中或作为可选选项出现。
中间件如何知道它是 409 冲突而不是 500 内部服务器错误?简单!DuplicateBasketItemException继承自来自ForEvolve.ExceptionMapper命名空间(高亮显示)的ConflictException:
using ForEvolve.ExceptionMapper;
namespace Web.Features;
public class DuplicateBasketItemException : ConflictException
{
public DuplicateBasketItemException(int productId)
: base($"The product '{productId}' is already in your shopping cart.")
{
}
}
使用这种设置,我们可以利用异常返回不同状态码的错误。
我已经使用这种方法很多年了,它简化了程序结构和开发者的生活。这个想法是利用异常的强大和简单性。
例如,我们可能希望将 EF Core 错误,DbUpdateException 和 DbUpdateConcurrencyException,也映射到 409 冲突,这样,如果我们忘记捕获数据库错误,中间件会为我们做这件事。为了实现这一点,我们可以这样自定义中间件:
builder.AddExceptionMapper(builder =>
{
builder
.Map<DbUpdateException>()
.ToStatusCode(StatusCodes.Status409Conflict)
;
builder
.Map<DbUpdateConcurrencyException>()
.ToStatusCode(StatusCodes.Status409Conflict)
;
});
在此基础上,如果客户端遇到未处理的 EF Core 异常,服务器将响应如下(为了简洁起见,我省略了堆栈跟踪):
{
"type": "https://tools.ietf.org/html/rfc9110#section-15.5.10",
"title": "Exception of type \u0027Microsoft.EntityFrameworkCore.DbUpdateException\u0027 was thrown.",
"status": 409,
"traceId": "00-74bdbaa08064fd97ba1de31802ec6f8f-a5ac17f17da8d2db-00",
"debug": {
"type": {
"name": "DbUpdateException",
"fullName": "Microsoft.EntityFrameworkCore.DbUpdateException"
},
"stackTrace": "..."
},
"entries": []
}
在实际项目中,出于安全考虑,我建议进一步自定义错误处理以隐藏我们使用 EF Core 的事实。我们必须尽可能少地向恶意行为者提供关于我们系统的信息,以使它们尽可能安全和安全。在这里,我们不会涵盖创建自定义异常处理程序,因为这超出了本章的范围。
如我们所见,注册自定义异常并将它们与状态码关联是很简单的。我们可以用任何自定义异常,或者从现有的异常继承以使其能够进行定制。截至版本 3.0.29,ExceptionMapper 提供以下自定义异常关联:
| 异常类型 | 状态码 |
|---|---|
BadRequestException |
StatusCodes.Status400BadRequest |
ConflictException |
StatusCodes.Status409Conflict |
ForbiddenException |
StatusCodes.Status403Forbidden |
GoneException |
StatusCodes.Status410Gone |
NotFoundException |
StatusCodes.Status404NotFound |
ResourceNotFoundException |
StatusCodes.Status404NotFound |
UnauthorizedException |
StatusCodes.Status401Unauthorized |
GatewayTimeoutException |
StatusCodes.Status504GatewayTimeout |
InternalServerErrorException |
StatusCodes.Status500InternalServerError |
ServiceUnavailableException |
StatusCodes.Status503ServiceUnavailable |
表 18.1:ExceptionMapper 自定义异常关联。
你可以从这些标准异常继承,中间件会像我们对 DuplicateBasketItemException 类所做的那样,将它们与正确的状态码关联。ExceptionMapper 还会自动映射以下 .NET 异常:
-
将
BadHttpRequestException映射到StatusCodes.Status400BadRequest -
NotImplementedException映射到StatusCodes.Status501NotImplemented
在项目中,有三个自定义异常,你可以在 GitHub 上找到它们:
-
继承自
NotFoundException的BasketItemNotFoundException -
继承自
ConflictException的DuplicateBasketItemException -
继承自
NotFoundException的ProductNotFoundException
接下来,我们更深入地探讨这种关于错误传播的思考方式。
利用异常来传播错误
在 ExceptionMapper 中间件到位的情况下,我们可以将异常视为传播错误到客户端的简单工具。我们可以抛出一个现有的异常,如 NotFoundException,或者创建一个具有更精确预配置错误消息的自定义可重用异常。当我们希望服务器返回特定错误时,我们只需做以下操作:
-
创建一个新的异常类型。
-
从 ExceptionMapper 中继承现有类型或在我们的中间件中注册我们的自定义异常。
-
在 REPR 流程中的任何地方抛出我们的自定义异常。
-
让中间件做它的工作。
这里是一个使用 AddItem 端点作为示例的简化流程表示:

图 18.6:使用 ExceptionMapper 的异常流程的简化视图。
这样一来,我们就有了一种简单的方法,可以从 REPR 流程中的任何地方向客户端返回错误。此外,我们的错误格式始终一致。
异常处理模式和 ExceptionMapper 库也与 MVC 一起工作,允许自定义错误格式化过程。
接下来,让我们探索几个测试用例。
灰盒测试
使用垂直切片架构或 REPR 使得编写灰盒测试非常方便。测试项目主要包含使用灰盒哲学的集成测试。由于我们知道正在测试的应用程序的内部工作原理,我们可以操纵来自 EF Core DbContext 对象的数据,这使得我们可以非常快速地编写几乎端到端测试。从这些测试中获得的可信度水平非常高,因为它们测试了整个堆栈,包括 HTTP,而不仅仅是零散的部分,导致每个测试用例的代码覆盖率非常高。当然,集成测试较慢,但并不慢。这取决于你如何创建单元和集成测试的正确平衡。在这种情况下,我专注于灰盒集成测试,这导致了 13 个测试,覆盖了 97.2% 的行和 63.1% 的分支。守卫子句代表了大多数我们不测试的分支。如果我们想提高这些数字,我们可以编写一些单元测试。
我们在 第二章,自动化测试 中探讨了白盒、灰盒和黑盒测试。
让我们先从探索 AddItem 测试开始。
AddItemTest
AddItem 功能是我们探索的第一个用例。我们需要三个测试来覆盖所有场景,但 Handler 类的守卫子句除外。
第一个测试方法
以下灰色盒集成测试确保 HTTP POST 请求将项目添加到数据库中:
[Fact]
public async Task Should_add_the_new_item_to_the_basket()
{
// Arrange
await using var application = new C18WebApplication();
var client = application.CreateClient();
// Act
var response = await client.PostAsJsonAsync(
"/baskets",
new AddItem.Command(4, 1, 22)
);
// Assert the response
Assert.NotNull(response);
Assert.True(response.IsSuccessStatusCode);
var result = await response.Content
.ReadFromJsonAsync<AddItem.Response>();
Assert.NotNull(result);
Assert.Equal(1, result.ProductId);
Assert.Equal(22, result.Quantity);
// Assert the database state
using var seedScope = application.Services.CreateScope();
var db = seedScope.ServiceProvider
.GetRequiredService<BasketContext>();
var dbItem = db.Items.FirstOrDefault(x => x.CustomerId == 4 && x.ProductId == 1);
Assert.NotNull(dbItem);
Assert.Equal(22, dbItem.Quantity);
}
前一个测试用例的 Arrange 块创建了一个测试应用程序和一个 HttpClient。然后,它在 Act 块中将 AddItem.Command 发送到其端点。之后,它将 Assert 块分为两部分:HTTP 响应和数据库本身。第一部分确保端点返回预期的数据。第二部分确保数据库处于正确的状态。
确保数据库处于正确状态是一个好习惯,尤其是在使用 EF Core 或大多数工作单元实现时,因为有人可能会添加一个项目却忘记保存更改,从而导致数据库状态不正确。然而,端点返回的数据将是正确的。
我们可以测试更多或更少的元素。我们可以重构 断言(Assert)块,使其更加优雅。我们能够也应该持续改进所有类型的代码,包括测试。然而,在这种情况下,我想要保留测试方法中的大部分逻辑,以便更容易理解。
同时,保持测试方法尽可能独立也是一个好的实践。这并不意味着提高可读性和将代码封装到辅助类或方法中是错误的;相反。
测试方法中唯一不透明的一部分是 C18WebApplication 类,它继承自 WebApplicationFactory<Program> 类并实现了一些辅助方法以简化测试应用的配置。你可以将其视为 WebApplicationFactory<Program> 类的一个实例。请随意浏览 GitHub 上的代码并探索其内部工作原理。
创建一个
Application类是一个好的重用模式。然而,为每个测试方法创建一个应用程序并不是最高效的,因为每次测试都需要启动整个程序。你可以使用测试固定值(test fixtures)在多个测试之间重用和共享程序实例。然而,请记住,应用程序的状态以及可能的数据库也会在测试之间共享。
将注意力转向第二个测试。
第二个测试方法
此测试确保 Location 标头包含一个有效的 URL。这个测试很重要,因为 Baskets 和 Products 功能是松散耦合的,可以独立更改。以下是代码:
[Fact]
public async Task Should_return_a_valid_product_url()
{
// Arrange
await using var application = new C18WebApplication();
await application.SeedAsync<Products.ProductContext>(async db =>
{
db.Products.RemoveRange(db.Products);
db.Products.Add(new("A test product", 15.22m, 1));
await db.SaveChangesAsync();
});
var client = application.CreateClient();
// Act
var response = await client.PostAsJsonAsync(
"/baskets",
new AddItem.Command(4, 1, 22)
);
// Assert
Assert.NotNull(response);
Assert.Equal(HttpStatusCode.Created, response.StatusCode);
Assert.NotNull(response.Headers.Location);
var productResponse = await client
.GetAsync(response.Headers.Location);
Assert.NotNull(productResponse);
Assert.True(productResponse.IsSuccessStatusCode);
}
前面的测试方法与第一个类似。安排(Arrange)块创建了一个应用程序,初始化数据库,并创建了一个 HttpClient。SeedAsync 方法是 C18WebApplication 类的辅助方法之一。行动(Act)块发送一个请求来创建一个篮子项。断言(Assert)块分为两部分。第一部分确保 HTTP 响应包含一个 Location 标头,并且状态码是 201。第二部分(突出显示)使用 Location 标头发送一个 HTTP 请求来验证 URL 的有效性。这个测试确保如果我们更改 Products.FetchOne 端点的 URL,比如说我们更喜欢 /catalog 而不是 /products,这个测试会提醒我们。我们接下来探索第三个测试案例。
第三个测试方法
最后一个测试方法确保当消费者尝试添加一个现有项目时,端点会返回 409 冲突状态。
[Fact]
public async Task Should_return_a_ProblemDetails_with_a_Conflict_status_code()
{
// Arrange
await using var application = new C18WebApplication();
await application.SeedAsync<BasketContext>(async db =>
{
db.Items.RemoveRange(db.Items);
db.Items.Add(new(
CustomerId: 1,
ProductId: 1,
Quantity: 10
));
await db.SaveChangesAsync();
});
var client = application.CreateClient();
// Act
var response = await client.PostAsJsonAsync(
"/baskets",
new AddItem.Command(
CustomerId: 1,
ProductId: 1,
Quantity: 20
)
);
// Assert the response
Assert.NotNull(response);
Assert.False(response.IsSuccessStatusCode);
Assert.Equal(HttpStatusCode.Conflict, response.StatusCode);
var problem = await response.Content
.ReadFromJsonAsync<ProblemDetails>();
Assert.NotNull(problem);
Assert.Equal("The product \u00271\u0027 is already in your shopping cart.", problem.Title);
// Assert the database state
using var seedScope = application.Services.CreateScope();
var db = seedScope.ServiceProvider
.GetRequiredService<BasketContext>();
var dbItem = db.Items.FirstOrDefault(x => x.CustomerId == 1 && x.ProductId == 1);
Assert.NotNull(dbItem);
Assert.Equal(10, dbItem.Quantity);
}
先前的测试方法与另外两个非常相似。在安排(Arrange)块中,创建了一个测试应用程序,初始化数据库,并创建了一个HttpClient。在行动(Act)块中,使用数据库中的唯一项目发送请求,我们预计这将导致冲突。在断言(Assert)块的第一部分确保端点返回预期的ProblemDetails对象。第二部分验证端点没有更改数据库中的数量。通过这三个测试,我们覆盖了AddItem功能的相应代码。其他测试用例类似,发送 HTTP 请求并验证数据库内容。每个功能之间有一个到三个测试。我们将在下一节探索与UpdateQuantity功能相关的测试。
UpdateQuantityTest
我们没有涵盖UpdateQuantity功能,但其中一个分支是,如果当前数量和新数量相同,端点将不会更新数据。以下是代码片段:
if (item.Quantity != command.Quantity)
{
_db.Items.Update(itemToUpdate);
await _db.SaveChangesAsync(cancellationToken);
}
为了测试这个用例,我们订阅了 EF Core DbContext上的SavedChanges事件,然后确保代码永远不会调用它。这个测试没有使用任何模拟或存根,测试了真实代码。这个测试在众多测试中脱颖而出,所以我考虑在继续之前先探索它。以下是代码:
[Fact]
public async Task Should_not_touch_the_database_when_the_quantity_is_the_same()
{
// Arrange
await using var application = new C18WebApplication();
await application.SeedAsync<BasketContext>(async db =>
{
db.Items.RemoveRange(db.Items.ToArray());
db.Items.Add(new BasketItem(2, 1, 5));
await db.SaveChangesAsync();
});
using var seedScope = application.Services.CreateScope();
var db = seedScope.ServiceProvider
.GetRequiredService<BasketContext>();
var mapper = seedScope.ServiceProvider
.GetRequiredService<UpdateQuantity.Mapper>();
db.SavedChanges += Db_SavedChanges;
var saved = false;
var sut = new UpdateQuantity.Handler(db, mapper);
// Act
var response = await sut.HandleAsync(
new UpdateQuantity.Command(2, 1, 5),
CancellationToken.None
);
// Assert
Assert.NotNull(response);
Assert.False(saved);
void Db_SavedChanges(object? sender, SavedChangesEventArgs e)
{
saved = true;
}
}
现在先前的测试方法应该已经很熟悉了。然而,我们在这里使用了一个不同的模式。在安排(Arrange)块中,我们创建了一个测试应用程序并初始化数据库,但我们没有创建HttpClient。我们使用ServiceProvider来创建依赖项,然后手动实例化UpdateQuantity.Handler类。这允许我们自定义BasketContext实例,以评估端点是否调用了它的SaveChange方法(高亮代码)。在行动(Act)块中,我们直接使用一个命令调用HandleAsync方法,该命令不会触发更新,因为项目数量与我们初始化的数量相同。与其它测试不同,我们并没有发送 HTTP 请求。在断言(Assert)块中,它比我们探索的其他测试要简单,因为我们测试的是方法,而不是 HTTP 响应或数据库。在这种情况下,我们只关心saved变量是true还是false。
这个测试比其他测试快得多,因为它不涉及 HTTP。当调用
WebApplicationFactory<T>对象的CreateClient方法(在本例中是C18WebApplication类)时,它会启动 web 服务器然后创建HttpClient,这有显著的性能开销。当你需要优化你的测试套件时,记得这个技巧。
我们已经完成了;测试可以知道DbContext的SavedChanges方法是否被调用。在进入下一章之前,让我们总结一下我们学到了什么。
摘要
我们深入探讨了请求-端点-响应(REPR)设计模式,并了解到 REPR 遵循网络最基础的模式。客户端向端点发送请求,端点处理它并返回响应。该模式侧重于围绕端点设计后端代码,使其开发更快,更容易在项目中找到方向,并且比 MVC 和层更专注于功能。我们还围绕请求采取了 CQS 方法,使它们成为查询或命令,描述程序中可能发生的一切:读取或写入状态。我们探讨了围绕这种模式组织代码的方法,从实现简单的到更复杂的功能。我们构建了一个技术堆栈,以创建一个利用 REPR 模式和面向功能设计的电子商务 Web 应用程序。我们学习了如何利用中间件来全局处理异常,以及 ExceptionMapper 库如何提供这种能力。我们还使用了灰盒测试,仅用几个测试就覆盖了项目的大部分逻辑。接下来,我们将探索微服务架构。
问题
让我们看看几个练习题:
-
在实现 REPR 模式时,我们必须使用 FluentValidation 和 ExceptionMapper 库吗?
-
REPR 模式的三个组成部分是什么?
-
REPR 模式是否规定我们必须使用嵌套类?
-
为什么灰盒集成测试能提供很大的信心?
-
使用中间件处理异常的一个优点是什么?
进一步阅读
这里有一些链接,可以帮助我们巩固本章所学的内容:
-
FluentValidation:
adpg.link/xXgp -
FluentValidation.AspNetCore.Http:
adpg.link/qsao -
ExceptionMapper:
adpg.link/ESDb -
Mapperly:
adpg.link/Dwcj -
MVC 控制器是恐龙 - 拥抱 API 端点:
adpg.link/NGjm
答案
-
不。REPR 并没有规定如何实现它。你可以创建自己的堆栈或使用裸骨 ASP.NET Core 最小 API,并在项目中手动实现一切。
-
REPR 由请求、端点和响应组成。
-
不。REPR 没有规定任何实现细节。
-
灰盒集成测试因其几乎端到端测试功能而提供了很大的信心,确保从 IoC 容器中的服务到数据库的所有部分都在。
-
使用中间件处理异常允许集中管理异常,将这一责任封装在单个位置。它还提供了统一输出,对所有错误发送客户端以相同格式的响应。它消除了逐个处理每个异常的负担,消除了
try-catch炉边代码。
第十九章:19 微服务架构简介
在开始之前:加入我们的 Discord 书籍社区
直接向作者本人提供反馈,并在我们的 Discord 服务器上与其他早期读者聊天(在“architecting-aspnet-core-apps-3e”频道下找到,位于“EARLY ACCESS SUBSCRIPTION”)。

本章涵盖了微服务架构的一些基本概念。它旨在帮助你开始了解这些原则以及围绕微服务的概念概述,这应该有助于你做出是否采用微服务架构的明智决定。由于微服务架构的规模比我们之前探讨的应用程序规模更大,并且通常涉及复杂的组件或设置,因此本章中 C# 代码非常有限。相反,我解释了这些概念,并列出了你可以利用的开源或商业产品,以将这些模式应用到你的应用程序中。此外,你不需要实现本章中讨论的许多组件,因为正确实现它们可能是一项大量工作,而且它们并不增加业务价值,所以你最好使用现有的实现。关于这一点,本章中还有更多背景信息。垂直切片和清洁架构等单体架构模式仍然值得了解,因为你可以将这些应用到单个微服务上。不用担心——你从本书开始以来所获得的所有知识都不是徒劳的,仍然很有价值。在本章中,我们涵盖了以下主题:
-
什么是微服务?
-
事件驱动架构简介
-
从消息队列开始
-
实现发布-订阅模式
-
介绍网关模式
-
项目 – REPR.BFF – 将 REPR 项目转换为微服务
-
重访 CQRS 模式
-
微服务适配器模式
让我们开始吧!
什么是微服务?
微服务代表的是一个被划分为多个更小应用的应用程序。每个应用,或微服务,与其他应用交互以创建一个可扩展的系统。通常,微服务以容器化或无服务器应用的形式部署到云端。在深入太多细节之前,以下是在构建微服务时需要记住的原则:
-
每个微服务应该是一个业务上的内聚单元。
-
每个微服务应该拥有自己的数据。
-
每个微服务应该独立于其他微服务。
此外,我们迄今为止所学的所有内容——其他软件设计原则——都适用于微服务,但规模不同。例如,你不想微服务之间存在紧密耦合(通过微服务独立性解决),但这种耦合是不可避免的(就像任何代码一样)。有无数种方法可以解决这个问题,例如发布-订阅模式。关于如何设计微服务、如何划分它们、它们应该有多大以及应该放在哪里,没有硬性规则。尽管如此,我仍会为你奠定一些基础,帮助你开始并指导你进入微服务的旅程。
业务内聚单元
一个微服务应该只有一个业务职责。始终以领域为出发点来设计系统,这应该有助于你将应用程序划分为多个部分。如果你了解领域驱动设计(DDD),那么一个微服务很可能会代表一个边界上下文,而这正是我所说的业务内聚单元。基本上,一个业务内聚单元(或边界上下文)是领域中的一个自包含部分,与其他部分的交互有限。即使一个微服务的名字中有微字,将其下的逻辑操作分组比追求微小的规模更为重要。请别误解我的意思;如果你的单元很小,那甚至更好。然而,如果你将一个业务单元拆分成多个更小的部分而不是保持其完整性(破坏内聚),你可能会在你的系统中引入无用的冗余(微服务之间的耦合)。这可能导致性能下降,并使系统更难调试、测试、维护、监控和部署。此外,将一个大微服务拆分成更小的部分比将多个微服务重新组装起来更容易。尝试将 SRP(单一职责原则)应用到你的微服务中:除非你有充分的理由这样做,否则一个微服务应该只有一个改变的理由。
数据所有权
每个微服务应该是其业务单元的真相来源。微服务应通过 API(例如,Web API/HTTP)或另一种机制(例如集成事件)共享其数据。它应该拥有这些数据,而不是在数据库级别直接与其他微服务共享。例如,两个不同的微服务永远不应该访问同一个关系型数据库表。如果第二个微服务需要一些相同的数据,它可以创建自己的缓存,复制数据,或查询数据的所有者,但不能直接访问数据库;永远不要。这种数据所有权概念可能是微服务架构中最关键的部分,并导致微服务独立。在这方面失败很可能会导致大量问题。例如,如果多个微服务可以读取或写入同一个数据库表中的数据,那么每次该表中的数据发生变化时,它们都必须更新以反映这些变化。如果不同的团队管理微服务,那就意味着跨团队协调。如果发生这种情况,每个微服务就不再独立了,这为我们接下来的主题打开了大门。
微服务独立性
到目前为止,我们有了拥有自己数据的业务单元的微服务。这定义了独立性。这种独立性允许系统在最小化或没有对其他微服务产生影响的情况下进行扩展。每个微服务也可以独立扩展,而无需整个系统进行扩展。此外,当业务需求增长时,该领域的每个部分都可以独立发展。此外,您可以更新一个微服务而不会影响其他微服务,甚至可以让一个微服务离线而不会导致整个系统停止。当然,微服务必须相互交互,但它们交互的方式应该定义您的系统运行得有多好。有点像垂直切片架构,您不必局限于使用一组特定的架构模式;您可以独立地为每个微服务做出特定的决策。例如,您可以为两个微服务之间的通信选择不同的方式,而不是为其他两个微服务选择。甚至可以为每个微服务使用不同的编程语言。
我建议对于较小的企业和组织,坚持使用一种或几种编程语言,因为您很可能有更少的开发者,他们有更多的事情要做。根据我的经验,您想要确保在人们离开时业务连续性,并确保您可以替换他们,而不会因为这里那里使用的一些神秘技术(或太多技术)而使船只沉没。
现在我们已经涵盖了基础知识,让我们深入了解微服务如何通过事件驱动架构进行通信的不同方式。
事件驱动架构简介
事件驱动架构(EDA)是一种围绕消费事件流或动态数据而不是消费静态状态的模式。我所说的静态状态是指存储在关系数据库表或其他类型的数据存储(如 NoSQL 文档存储)中的数据。这些数据在中央位置处于休眠状态,等待参与者消费和修改它。在每次修改之间,数据(例如,一条记录)代表一个有限状态。另一方面,动态中的数据正好相反:你消费有序的事件,并确定每个事件带来的状态变化或程序应该触发的事件响应过程。什么是事件?人们经常互换使用事件、消息和命令这些词。让我们尝试澄清这一点:
-
消息是表示某事物的一份数据。
-
消息可以是对象、JSON 字符串、字节或其他系统可以解释的内容。
-
事件是表示过去发生的事情的消息。
-
命令是发送给一个或多个收件人的消息,告诉他们做某事。
-
命令是发送的(过去时态),因此我们也可以将其视为一个事件。
消息通常有一个有效载荷(或主体)、头信息(元数据)以及一种识别它的方式(这可以通过主体或头信息实现)。我们可以使用事件将复杂系统划分为更小的部分,或者让多个系统相互通信而无需创建紧密耦合。这些系统可以是子系统或外部应用程序,例如微服务。就像 REST API 的数据传输对象(DTOs)一样,事件成为将多个系统连接在一起的数据契约(耦合)。在设计事件时,仔细考虑这一点至关重要。当然,我们无法预见未来,所以我们只能尽力第一次就做到完美。我们可以对事件进行版本控制以提高可维护性。EDA 是打破微服务之间紧密耦合的绝佳方式,但需要重新调整你的思维方式来学习这种新的范式。工具正在变得更加成熟,专业知识也比使用更线性的思维方式(如使用点对点通信和关系数据库)更为丰富。然而,这种情况正在慢慢改变,学习它是非常值得的。在继续前进之前,我们可以将事件分类到以下重叠的类别中:
-
领域事件
-
集成事件
-
应用程序事件
-
企业事件
如我们接下来要探讨的,所有类型的事件都发挥着类似的作用,但意图和范围不同。
领域事件
领域事件是基于领域驱动设计(DDD)的一个术语,代表领域中的事件。这个事件可以触发其他逻辑的后续执行。它允许我们将复杂的过程分解成多个较小的过程。领域事件与以领域为中心的设计(如 Clean Architecture)配合得很好,因为我们可以使用它们将复杂的领域对象分解成多个较小的部分。领域事件通常是应用事件。例如,我们可以在应用内部使用 MediatR 来发布领域事件。总结来说,领域事件在保持领域逻辑分离的同时将领域逻辑的各个部分集成在一起,导致松散耦合的组件,每个组件承担一个领域责任(单一责任原则)。
集成事件
集成事件就像领域事件,但将消息传播到外部系统,将多个系统集成在一起同时保持它们的独立性。例如,一个微服务可以发送新用户注册事件消息,其他微服务会做出反应,比如保存用户 ID以启用额外功能或向新用户发送问候邮件。我们使用消息代理或消息队列来发布此类事件。在介绍完应用和企业事件后,我们将探讨这些事件。总结来说,集成事件在保持系统独立的同时将多个系统集成在一起。
应用事件
应用事件是应用内部的事件;这只是范围问题。如果事件是单个进程内部的事件,那么这个事件也是一个领域事件(很可能是)。如果事件跨越了你团队拥有的微服务边界(同一个应用),那么它也是一个集成事件。事件本身并没有不同;它存在的原因及其范围决定了它是否被描述为应用事件。总结来说,应用事件与单个应用相关。
企业事件
企业事件描述的是跨越内部企业边界的事件。这些事件与你的组织结构紧密相连。例如,一个微服务发送一个事件,其他团队,属于其他部门或部门,会消费这个事件。围绕这些事件的治理模式应与仅你团队消费的应用事件不同,并且需要更严格的监管。必须有人考虑谁可以消费这些数据,在什么情况下,更改事件架构(数据合同)的影响、架构所有权、命名约定、数据结构约定等等,否则可能会构建一个不稳定的数据高速公路。
我喜欢将 EDA 视为应用、系统、集成和组织边界之间的中央数据高速公路,在这里事件(数据)以松散耦合的方式在系统之间流动。
它就像一条高速公路,汽车在城市之间流动(没有交通堵塞)。城市并不控制汽车去往何方,而是对游客开放。
总结来说,企业事件是跨越组织边界的集成事件。
结论
在本节对事件驱动架构的概述中,我们定义了事件、消息和命令。事件是过去的快照,消息是数据,命令是建议其他系统采取行动的事件。由于所有消息都是来自过去,称它们为事件是准确的。然后我们将事件组织到几个重叠的类别中,以帮助识别意图。我们可以发送不同目的的事件,但无论是关于设计独立组件还是接触业务的不同部分,事件始终是一个遵守一定格式(模式)的有效负载。这个模式是这些事件消费者之间的数据合约(耦合)。这个数据合约是其中最重要的部分:打破合约,系统就会崩溃。现在,让我们看看事件驱动架构如何帮助我们以云规模遵循SOLID原则:
-
S:系统通过触发和响应事件相互独立。事件本身是把这些系统粘合在一起的内聚力。每个部分都有单一的责任。
-
O:我们可以通过向特定事件添加新的消费者来修改系统的行为,而不会影响其他应用程序。我们还可以触发新的事件来构建新的流程,而不会影响现有应用程序。
-
L:N/A
-
我:不是构建一个单一的系统,EDA(电子设计自动化)允许我们创建多个较小的系统,这些系统通过数据合约(事件)进行集成,而这些合约是系统的消息接口。
-
D:EDA 通过依赖于事件(接口/抽象)而不是直接相互通信,从而实现了系统之间的紧密耦合的解耦,反转了依赖流。
EDA 不仅带来了优势;它也有一些缺点,我们将在本章后续部分进行探讨。接下来,我们将探讨消息队列,然后是发布-订阅模式,这两种与事件交互的方式。
开始使用消息队列
消息队列不过是一个我们用来发送有序消息的队列。队列按照先进先出(FIFO)的原则工作。如果我们的应用程序在一个单独的进程中运行,我们可以使用一个或多个Queue<T>实例在组件之间发送消息,或者使用ConcurrentQueue<T>实例在线程之间发送消息。此外,队列可以由一个独立的程序管理,以分布式的方式发送消息(在应用程序或微服务之间)。分布式消息队列可以添加更多或更少的特性到混合中,特别是对于处理比单个服务器更多级别的故障的云程序。其中一个特性是死信队列,它存储在另一个队列中未通过某些标准的消息。例如,如果目标队列已满,可以将消息发送到死信队列。可以通过将消息放回队列末尾来重新排队这些消息。
注意,重新排队消息会改变消息的顺序。如果顺序在您的应用程序中很重要,请考虑这一点。
存在许多消息队列协议;有些是专有的,而有些是开源的。一些消息队列是基于云的,作为服务使用,例如 Azure Service Bus 和 Amazon Simple Queue Service。其他是开源的,可以部署到云或本地,例如 Apache ActiveMQ。如果您需要按顺序处理消息,并且希望每次只将消息发送给单个接收者,那么消息队列似乎是正确的选择。否则,发布-订阅模式可能更适合您。以下是一个基本示例,说明了我们刚才讨论的内容:

图 19.1:一个将消息入队的发布者与一个出队的订阅者
为了一个更具体的例子,在一个分布式用户注册过程中,当用户注册时,我们可能想要做以下事情:
-
发送确认邮件。
-
处理他们的图片并保存一个或多个缩略图。
-
向他们的应用内邮箱发送欢迎信息。
为了顺序地实现这一点,一个操作接一个操作,我们可以这样做:

图 19.2:用户创建账户后按顺序执行三个操作的流程图
在这种情况下,如果处理缩略图操作期间进程崩溃,用户将不会收到欢迎信息。另一个缺点是,要在处理缩略图和发送欢迎信息步骤之间插入新的操作,我们必须修改发送欢迎信息操作(紧耦合)。如果顺序不重要,我们可以在用户创建后立即,像这样将所有来自认证服务器的消息排队:

图 19.3:认证服务器正在按顺序排队操作,而不同的进程并行执行它们
这个过程更好,但现在认证服务器控制着一旦创建新用户后应该发生什么。在之前的流程中,认证服务器正在排队一个事件,告诉系统有新用户注册。然而,现在,它必须意识到后处理流程,以便按顺序排队每个操作以排队正确的命令。这样做本身并没有错,当你深入研究代码时,它更容易理解,但它会在认证服务器了解外部过程的各个服务之间创建更紧密的耦合。此外,它将太多的责任打包到认证服务器中。
从 SRP(单一责任原则)的角度来看,为什么认证/授权服务器除了认证、授权和管理那些数据之外,还要负责其他任何事情?
如果我们从那里继续并想在两个现有步骤之间添加一个新操作,我们只需修改认证服务器,这比先前的流程更不容易出错。如果我们想两者兼得,我们可以使用发布-订阅模式,我们将在下一节中介绍,并在此基础上继续构建。
结论
如果你需要消息按顺序传递,队列可能是一个合适的工具。我们探索的例子从一开始就注定会失败,但它允许我们探索设计系统背后的思考过程。有时,第一个想法并不是最好的,可以通过探索新的做事方式或学习新技能来改进。对他人想法的开放心态也可能导致更好的解决方案。
有时候,只是大声说出来,我们自己的大脑就能自己解决问题。所以向某人解释问题,看看会发生什么。
消息队列在为高需求场景缓冲消息方面非常出色,在这些场景中,应用程序可能无法处理流量峰值。在这种情况下,消息将被入队,以便应用程序可以以自己的速度赶上,按顺序读取它们。实现分布式消息队列需要大量的知识和努力,并且对于几乎所有场景来说都不值得。大型云提供商如 AWS 和 Azure 提供完全管理的消息队列系统作为服务。您还可以查看ActiveMQ、RabbitMQ或任何高级消息队列协议(AMQP)代理。选择正确的队列系统的一个基本方面是您是否准备好并具备管理自己的分布式消息队列的技能。假设您想加快开发速度,降低基础设施管理成本,并且有足够的资金。在这种情况下,您可以使用至少生产环境的完全管理服务,尤其是如果您预计会有大量消息。另一方面,使用本地或本地实例进行开发或较小规模的用途可能会节省您相当一部分资金。选择具有完全管理云提供的开源系统是实现这两者的好方法:低本地开发成本,以及始终可用的、性能始终如一的高性能云生产服务,服务提供商为您维护。另一个方面是,您的选择应基于需求。有明确的要求,并确保您选择的系统能够满足您的需求。一些服务涵盖了多个用例,如队列和发布-订阅,从而简化了技术堆栈,需要更少的技能。在转向发布-订阅模式之前,让我们看看消息队列如何帮助我们遵循SOLID原则在应用规模上:
-
S: 帮助在应用程序或组件之间集中和分配责任,而无需它们直接相互了解,从而打破紧密耦合。
-
O: 允许我们更改消息生产者或订阅者的行为,而无需对方知道。
-
L: 无
-
I: 每个消息和处理程序都可以小到所需的程度,而每个微服务则间接与其他微服务交互以解决更大的问题。
-
D: 通过不知道其他依赖项(打破微服务之间的紧密耦合),每个微服务只依赖于消息(抽象)而不是具体实现(其他微服务的 API)。
一个缺点是消息入队和消息处理之间的延迟。我们将在后续章节中更详细地讨论延迟和延迟。
实现发布-订阅模式
发布-订阅(Publish-Subscribe)模式(Pub-Sub)类似于我们在使用 MediatR 和在 开始使用消息队列 部分中探索的内容。然而,我们不是向一个处理器(或入队一个消息)发送一个消息,而是向零个或多个订阅者(处理器)发布(发送)一个消息(事件)。此外,发布者对订阅者一无所知;它只发送消息,希望一切顺利(也称为 fire and forget)。
使用消息队列并不意味着你只能限制于只有一个接收者。
我们可以通过 发布-订阅(Publish-Subscribe)模式在进程内或分布式系统中通过 消息代理(message broker)来实现。消息代理负责将消息传递给订阅者。对于微服务和其他分布式系统来说,使用消息代理是最佳选择,因为它们不是在单个进程中运行的。这种模式与其他通信方式相比具有许多优势。例如,我们可以通过重放系统中发生的事件来重新创建数据库的状态,从而实现 事件溯源(event sourcing)模式。关于这一点,我们稍后再详细讨论。设计取决于用于传递消息的技术和系统的配置。例如,你可以使用 MQTT 将消息传递到 物联网(IoT)设备,并配置它们在每个主题上保留最后发送的消息。这样,当设备连接到主题时,它会收到最新的消息。你也可以配置一个保留大量消息历史的 Kafka 代理,当新的系统连接到它时,它会请求所有这些消息。所有这些都取决于你的需求和需求。
MQTT 和 Apache Kafka
如果你想知道 MQTT 是什么,这里是从他们的网站
adpg.link/mqtt上的引用:“MQTT 是一个 OASIS 标准,用于物联网 (IoT) 的消息协议。它被设计为一个极轻量级的发布/订阅消息传输 [...]”
这里引用了 Apache Kafka 网站上的话
adpg.link/kafka:“Apache Kafka 是一个开源的分布式事件流平台 [...]”
我们无法涵盖遵循每个协议的每个系统的每个场景。因此,我将强调 Pub-Sub 设计模式背后的某些共享概念,以便你知道如何开始。然后,你可以深入研究你想要(或需要)使用的特定技术。主题是一种组织事件的方式,一个频道,一个读取或写入特定事件的地点,以便消费者知道在哪里找到它们。正如你可能想象的那样,将所有事件发送到同一个地方就像创建一个只有一个表的数据库关系型数据库:这将是不太理想的,难以管理、使用和演进。为了接收消息,订阅者必须订阅主题(或主题的等效物):

图 19.4:一个订阅者订阅了一个发布/订阅主题
Pub-Sub 模式的第二部分是发布消息,如下所示:

图 19.5:发布者正在向消息代理发送消息。然后代理将此消息转发给N个订阅者,其中N可以是零个或更多
这里许多抽象的细节取决于代理和协议。然而,以下是基于发布-订阅模式的两个主要概念:
-
发布者向主题发布消息。
-
订阅者订阅主题以在消息发布时接收消息。
安全性是一个重要的实现细节,这里没有展示。在大多数系统中,安全性是强制性的;不是每个子系统或设备都应该有权访问所有主题。
发布者和订阅者可以是任何系统的任何部分。例如,许多 Microsoft Azure 服务都是发布者(例如,Blob 存储)。然后,您可以让其他 Azure 服务(例如,Azure Functions)订阅这些事件并对它们做出反应。您还可以在您的应用程序中使用发布-订阅模式——不需要使用云资源;这甚至可以在同一个进程中完成(我们将在下一章中探讨这一点)。发布-订阅模式最显著的优势是打破系统之间的紧密耦合。一个系统发布事件,而其他系统消费它们,而无需系统相互了解。这种松散的耦合导致可伸缩性,其中每个系统可以独立扩展,并且可以使用所需资源并行处理消息。由于系统对彼此一无所知,因此更容易将新流程添加到工作流中。要添加一个对新事件做出反应的新流程,您只需创建一个新的微服务,部署它,开始监听一个或多个事件,并处理它们。不利的一面是,消息代理可能成为应用程序的单点故障,必须适当地配置。还必须考虑每种消息类型最佳的分发策略。策略的一个例子可能是确保关键消息的传递,同时延迟不那么紧急的消息,并在负载激增期间丢弃不重要的消息。如果我们回顾一下之前使用发布-订阅的例子,我们最终得到以下简化的工作流程:

图 19.6:认证服务器正在发布一个表示创建新用户的事件的条目。然后代理将此消息转发给三个订阅者,他们随后并行执行他们的任务
基于这个工作流程,我们打破了认证服务器和注册后流程之间的紧密耦合。认证服务器对工作流程一无所知,各个服务之间也互不关心。此外,如果我们想添加一个新任务,我们只需创建或更新一个订阅正确主题(在这种情况下,是“新用户注册”主题)的微服务。当前系统不支持同步,也不处理流程失败或重试,但这是一个良好的开端,因为我们结合了消息队列示例的优点,而将缺点留在了后面。使用事件代理反转了依赖流。我们探讨的图显示了消息流,但这里发生的是事物依赖方面的变化:

图 19.7:一个表示使用发布-订阅模式反转依赖流的图
现在我们已经探讨了发布-订阅模式,接下来我们看看消息代理,然后深入探讨事件驱动架构(EDA),并利用发布-订阅模式创建一个可重放的持久化事件数据库:事件溯源模式。
消息代理
消息代理是一种程序,它允许我们发送(发布)和接收(订阅)消息。它在规模上扮演着调解者的角色,使得多个应用程序能够相互通信而无需彼此了解(松耦合)。消息代理通常是任何实现发布-订阅模式的基于事件的分布式系统的核心组件。一个应用程序(发布者)将消息发布到主题,而其他应用程序(订阅者)则从这些主题接收消息。主题的概念可能因协议或系统而异,但我知道的所有系统都有一个类似主题的概念,用于将消息路由到正确的位置。例如,您可以使用 Kafka 将消息发布到Devices主题,但使用 MQTT 则发布到devices/abc-123/do-something。您如何命名主题在很大程度上取决于您所使用的系统和您安装的规模。例如,MQTT 是一种轻量级的事件代理,推荐使用路径式命名约定。另一方面,Apache Kafka 是一个功能齐全的事件代理和事件流平台,对主题名称没有固定看法,让您负责这一点。根据您实现的规模,您可以使用实体名称作为主题名称,或者可能需要前缀来识别企业中谁可以与系统的哪个部分交互。由于本章中示例的规模较小,我们坚持使用简单的主题名称,使示例更容易理解。消息代理负责将消息转发给已注册的接收者。这些消息的寿命可能因代理而异,甚至可能因单个消息或主题而异。市面上有多种使用不同协议的消息代理。一些代理是基于云的,如 Azure Event Grid。其他代理轻量级且更适合物联网,如 Eclipse Mosquitto/MQTT。与 MQTT 相比,其他代理更健壮,允许高速数据流,如 Apache Kafka。您应该根据您正在构建的软件的需求来选择使用哪种消息代理。此外,您并不局限于一个代理。没有任何东西阻止您选择一个处理微服务之间对话的消息代理,同时使用另一个处理与外部物联网设备之间对话的代理。如果您在 Azure 上构建系统,希望无服务器化,或者更喜欢为可扩展的 SaaS 组件付费而不投入维护时间,您可以利用 Azure 服务,如 Event Grid、Service Bus 和 Queue Storage。如果您更喜欢开源软件,您可以选择 Apache Kafka,如果您不想管理自己的集群,甚至可以使用 Confluent Cloud 作为服务运行一个完全管理的云实例。
事件溯源模式
现在我们已经探讨了发布-订阅模式,了解了事件是什么,也讨论了事件代理,是时候探索如何回放应用程序的状态了。为了实现这一点,我们可以遵循事件溯源模式。事件溯源背后的理念是存储一系列按时间顺序排列的事件,而不是单个实体,其中这些事件的集合成为真相的来源。这样,每个单独的操作都按正确的顺序保存,有助于处理并发。此外,我们可以回放所有这些事件以生成新应用程序中对象的当前状态,使我们能够更容易地部署新的微服务。除了存储数据之外,如果系统使用事件代理传播它,其他系统可以将其缓存为一种或多种物化视图。
物化视图是为特定目的创建和存储的模型。数据可以来自一个或多个来源,在查询该数据时提高性能。例如,应用程序返回物化视图,而不是查询多个其他系统以获取数据。您可以将物化视图视为一个缓存的实体,微服务将其存储在其自己的数据库中。
事件溯源的一个缺点是数据一致性。当服务将事件添加到存储中时与所有其他服务更新其物化视图之间存在不可避免的延迟。我们称这种现象为最终一致性。
最终一致性意味着数据将在未来的某个时刻变得一致,但不是立即。延迟可能从几毫秒到更长的时间,但目标是尽可能减小这个延迟。
另一个缺点是创建这样一个系统与查询单个数据库的单个应用程序相比的复杂性。像微服务架构一样,事件溯源不仅仅是彩虹和独角兽。它是有代价的:操作复杂性。
在微服务架构中,每个部分都更小,但将它们粘合在一起是有成本的。例如,支持微服务的基础设施比单体(一个应用程序和一个数据库)更复杂。对于事件溯源也是如此;所有应用程序都必须订阅一个或多个事件,缓存数据(物化视图),发布事件,等等。这种操作复杂性代表了复杂性从应用程序代码转移到操作基础设施的转变。换句话说,部署和维护多个微服务和数据库以及与这些外部系统之间可能的不稳定网络通信需要更多的工作,比包含所有代码的单个应用程序要多。单体更简单:要么工作,要么不工作;很少部分工作。
事件溯源的关键方面是将新事件追加到存储中,并且永远不更改现有事件(仅追加)。简而言之,使用 Pub-Sub 模式通信的微服务发布事件,订阅主题,并生成物化视图以服务其客户端。
示例
让我们探讨一下如果我们结合我们刚刚学习的内容可能会发生什么的情况。上下文:我们需要构建一个管理物联网设备的程序。我们首先创建两个微服务:
-
DeviceTwin微服务处理物联网设备孪生的数据(设备的数字表示)。 -
Networking微服务管理物联网设备的网络相关信息(如何到达设备)。
作为视觉参考,最终系统可能看起来如下(我们稍后介绍 DeviceLocation 微服务):

图 19.8:三个微服务使用发布-订阅模式进行通信
这里列出了用户交互和发布的事件:
- 用户在系统中创建了一个名为 Device 1 的孪生体。
DeviceTwin微服务保存数据并发布以下负载的DeviceTwinCreated事件:
{
"id": "some id",
"name": "Device 1",
"other": "properties go here..."
}
同时,Networking 微服务必须知道何时创建设备,因此它订阅了 DeviceTwinCreated 事件。当创建新设备时,Networking 微服务在其数据库中为该设备创建默认网络信息;默认为 unknown。这样,Networking 微服务就知道哪些设备存在或不存在:

图 19.9:表示创建设备孪生及其默认网络信息的流程
- 用户随后更新了该设备的网络信息,并将其设置为
MQTT。Networking微服务保存数据并发布以下负载的NetworkingInfoUpdated事件:
{
"deviceId": "some id",
"type": "MQTT",
"other": "networking properties..."
}
这可以通过以下图表来演示:

图 19.10:表示更新设备网络类型的流程
- 用户将设备的显示名称更改为
Kitchen Thermostat,这更相关。DeviceTwin微服务保存数据并发布以下负载的DeviceTwinUpdated事件。负载使用 JSON 补丁来发布仅有的差异而不是整个对象(有关更多信息,请参阅 进一步阅读 部分):
{
"id": "some id",
"patches": [
{ "op": "replace", "path": "/name", "value": "Kitchen Thermostat" },
]
}
以下图表展示了这一点:

图 19.11:表示用户将设备名称更新为 Kitchen Thermostat 的流程
从那里,假设另一个团队设计并构建了一个新的微服务,该服务组织物理位置的设备。这个新的 DeviceLocation 微服务允许用户在地图上可视化他们的设备位置,例如他们的家中的地图。《DeviceLocation》微服务订阅所有三个事件来管理其物化视图,如下所示:
-
当接收到一个
DeviceTwinCreated事件时,它会保存其唯一标识符和显示名称。 -
当接收到一个
NetworkingInfoUpdated事件时,它会保存通信类型。 -
当接收到一个
DeviceTwinUpdated事件时,它会更新设备的显示名称。
当服务首次部署时,它会从开始处重新播放所有事件(事件溯源);以下是发生的情况:
- 《DeviceLocation》接收到
DeviceTwinCreated事件并为该对象创建以下模型:
{
"device": {
"id": "some id",
"name": "Device 1"
},
"networking": {},
"location": {...}
}
以下图表展示了这一点:

图 19.12:DeviceLocation 微服务重新播放 DeviceTwinCreated 事件以创建其设备孪生的物化视图
- 《DeviceLocation》微服务接收到
NetworkingInfoUpdated事件,该事件将网络类型更新为MQTT,导致以下结果:
{
"device": {
"id": "some id",
"name": "Device 1"
},
"networking": {
"type": "MQTT"
},
"location": {...}
}
以下图表展示了这一点:

图 19.13:DeviceLocation 微服务重新播放 NetworkingInfoUpdated 事件以更新其设备孪生的物化视图
- 《DeviceLocation》微服务接收到
DeviceTwinUpdated事件,更新设备的名称。最终的模型如下所示:
{
"device": {
"id": "some id",
"name": "Kitchen Thermostat"
},
"networking": {
"type": "MQTT"
},
"location": {...}
}
以下图表展示了这一点:

图 19.14:DeviceLocation 微服务重新播放 DeviceTwinUpdated 事件以更新其设备孪生的物化视图
从那里,DeviceLocation 微服务被初始化并准备就绪。用户可以在地图上设置厨房恒温器的位置或继续使用其他微服务。当用户查询 DeviceLocation 微服务以获取 Kitchen Thermostat 的信息时,它显示 物化视图,其中包含所有所需信息,而无需发送外部请求。考虑到这一点,我们可以生成 DeviceLocation 微服务或其他微服务的新实例,它们可以从过去的事件中生成它们的物化视图——所有这些都可以在非常有限或没有其他微服务的知识的情况下完成。在这种类型的架构中,微服务只能了解事件,而不能了解其他微服务。微服务处理事件的方式应该只与该微服务相关,而永远不要与其他微服务相关。这一点同样适用于发布者和订阅者。这个例子说明了事件源模式、集成事件、物化视图、消息代理的使用以及发布-订阅模式。相比之下,使用直接通信(HTTP、gRPC 等)将看起来像这样:

图 19.15:三个微服务直接相互通信
如果我们通过查看第一个图(图 16.7)来比较这两种方法,我们可以看到消息代理扮演着 调解者 的角色,并打破了微服务之间的直接耦合。通过查看前面的图(图 16.14),我们可以看到微服务之间的紧密耦合,其中 DeviceLocation 微服务需要直接与 DeviceTwin 和 Networking 微服务交互,以构建其物化视图的等效物。此外,DeviceLocation 微服务将一次交互转换为三次,因为 Networking 微服务也与其他的 DeviceTwin 微服务进行通信,导致微服务之间的间接紧密耦合,这可能会对性能产生负面影响。假设最终一致性不是一个选项,或者发布-订阅模式不能应用于您的场景,或者应用起来可能过于困难。在这种情况下,微服务可以直接相互调用。它们可以使用 HTTP、gRPC 或任何最适合该特定系统需求的任何其他方式来实现。我不会在本书中涵盖这个主题,但直接调用微服务时需要注意的一点是可能会迅速冒泡的间接调用链。您不希望您的微服务创建一个超级深的调用链,否则您的系统可能会变得非常慢。以下是一个抽象示例,说明可能会发生什么,以说明我的意思:

图 19.16:一个用户调用微服务 A,然后触发一系列后续调用,导致灾难性的性能
根据前面的图示,让我们思考一下失败的情况(以一个为例)。如果微服务 C 离线,整个请求将以错误结束。无论我们采取什么措施来减轻风险,如果微服务 C 无法恢复,系统将保持故障状态;微服务的独立性承诺就此破灭。另一个问题是延迟:对一个单一操作就需要进行十个调用;这需要时间。这种健谈的系统很可能源于错误的领域建模阶段,导致多个微服务共同处理琐碎的任务。现在想象一下图 16.15,但用 500 个微服务代替 6 个。那可能是灾难性的!这种相互依赖的微服务系统被称为死亡之星反模式。我们可以将死亡之星反模式视为一个分布式的大泥球。避免这种陷阱的一种方法是确保边界上下文得到良好的隔离,并且责任得到良好的分配。一个好的领域模型应该允许你避免构建死亡之星,并创建“最正确”的系统。无论你选择哪种类型的架构,如果你没有构建正确的东西,你可能会得到一个大泥球或死亡之星。当然,发布/订阅模式和事件驱动架构可以帮助我们打破微服务之间的紧密耦合,避免此类问题。
结论
发布-订阅模式使用事件来打破应用程序各部分之间的紧密耦合。在微服务架构中,我们可以使用消息代理和集成事件,允许微服务间接地相互通信。现在,不同的部分通过表示事件的(其模式)数据合约而不是彼此耦合,这可能导致灵活性的潜在提升。这种类型架构的一个风险是,通过在不通知它们或没有事件版本控制的情况下发布事件格式的破坏性更改,从而破坏事件的消费者。因此,彻底思考事件模式演变至关重要。大多数系统都会发展,事件也是如此,但因为在发布-订阅模型中,模式是系统之间的粘合剂,所以必须将其视为此类。一些代理,如 Apache Kafka,提供模式存储和其他机制来帮助这些;一些则没有。然后,我们可以利用事件源模式来持久化这些事件,允许新的微服务通过重放过去的事件来填充其数据库。事件存储随后成为这些系统的真相来源。事件源对于跟踪和审计目的也非常有用,因为整个历史都被持久化了。我们还可以重放消息以在任何给定时间点重新创建系统的状态,这使得它在调试目的上非常强大。在开始事件源路径之前,必须考虑事件存储的存储大小需求。事件存储可能会非常大,因为我们一直在从时间开始就保留所有消息,并且可以根据发送的事件数量快速增长。你可以压缩历史记录以减少数据大小,但会丢失部分历史记录。再次强调,你必须根据需求做出决定,并问自己适当的问题。例如,是否可以接受丢失部分历史记录?我们应该保留数据多长时间?如果我们以后需要,是否希望以更便宜的价格存储原始数据?我们甚至需要重放功能吗?我们能否负担得起永远保留所有数据?系统必须遵循的数据保留策略或法规是什么?根据你想要解决的特定业务问题,制定你的问题清单。此建议适用于软件工程的各个方面:首先明确业务问题,然后找到解决问题的方法。这些模式可能很有吸引力,但学习和实施需要时间。像消息队列一样,云提供商提供完全管理的代理作为服务。这些服务可能比构建和维护自己的基础设施更快地开始。如果你喜欢构建服务器,你可以使用开源软件“经济”地构建你的堆栈,或者支付此类软件的托管实例以节省麻烦。与消息队列相同的提示也适用于此处;例如,你可以利用托管服务来处理生产环境,并在开发人员的机器上使用本地版本。Apache Kafka 是最受欢迎的事件代理之一,它提供了高级功能,如事件流。Kafka 提供了部分和完全管理的云服务,如 Confluent Cloud。Redis Pub/Sub 是另一个具有完全管理云服务的开源项目。Redis 也是一个流行的键值存储,适用于分布式缓存场景。其他服务(但不限于)包括 Solace PubSub+、RabbitMQ 和 ActiveMQ。再次建议,将服务与你的需求进行比较,以在你的场景中做出最佳选择。现在,让我们看看发布-订阅模式如何帮助我们遵循SOLID原则在云规模上:
-
S:有助于在应用程序或组件之间集中和划分责任,而它们无需直接了解彼此,从而打破紧密耦合。
-
O:允许我们改变发布者和订阅者的行为,而不会直接影响其他微服务(打破它们之间的紧密耦合)。
-
L:N/A
-
I:每个事件可以小到所需的程度,导致多个较小的通信接口(数据合同)。
-
D:微服务依赖于事件(抽象)而不是具体实现(其他微服务),这打破了它们之间的紧密耦合,并反转了依赖关系流。
正如你可能已经注意到的,发布/订阅(pub-sub)与消息队列非常相似。主要区别在于消息的读取和分发方式:
-
队列:消息一次一个地被拉取,由一个服务消费,然后消失。
-
发布/订阅(Pub-Sub):消息按顺序读取,并发送给所有消费者,而不是像队列那样只发送给一个。
我故意将观察者设计模式排除在这本书之外,因为我们很少在.NET 中使用它。C# 提供了多播事件,这些事件在大多数情况下可以很好地替代观察者模式。如果你不知道观察者模式,不要担心——可能性很大,你永远不会需要它。不过,如果你已经知道观察者模式,这里有一些它与发布/订阅模式之间的区别。
在观察者模式中,主题保持其观察者的列表,从而直接了解它们的存在。具体的观察者也经常了解主题,导致对其他实体的更多了解和更多耦合。
在发布/订阅模式中,发布者不知道订阅者;它只知道消息代理。订阅者也不知道发布者,只知道消息代理。发布者和订阅者仅通过它们发布或接收的消息的数据合同相连接。
我们可以将发布/订阅模式视为观察者模式的分布式演变,或者更确切地说,就像在观察者模式中添加一个中介。
接下来,我们将探讨一些直接通过访问一种新的外观(Façade)——网关——来调用其他微服务的模式。
引入网关模式
当构建面向微服务的系统时,服务的数量随着功能的数量增长;系统越大,微服务的数量就越多。当你考虑一个必须与这种系统交互的用户界面时,这可能会变得繁琐、复杂和低效(从开发和速度的角度来看)。网关可以帮助我们实现以下目标:
-
通过将请求路由到适当的服务来隐藏复杂性。
-
通过聚合响应并将一个外部请求翻译成多个内部请求来隐藏复杂性。
-
通过仅暴露客户端需要的功能子集来隐藏复杂性。
-
将请求翻译成另一种协议。
网关还可以集中管理不同的流程,例如日志记录和缓存请求、验证和授权用户和客户端、实施请求速率限制以及其他类似策略。您可以将网关视为门面,但它不是程序中的一个类,而是一个独立的程序,保护其他程序。网关模式有多种变体,我们在这里探讨了其中许多。无论您需要哪种类型的网关,您都可以自己编写代码,或者利用现有工具来加速开发过程。
请注意,您的自建网关版本 1.0 可能比经过验证的解决方案存在更多缺陷。这个提示不仅适用于网关,也适用于大多数复杂系统。话虽如此,有时没有经过验证的解决方案能完全满足我们的需求,我们必须自己编写代码,这就是真正的乐趣所在!
一个可能帮助到您的开源项目是 Ocelot (adpg.link/UwiY)。它是一个用 C# 编写的 API 网关,支持我们从网关期望的许多功能。您可以使用配置来路由请求,或者编写自定义代码来创建高级路由规则。由于它是开源的,您可以对其进行贡献、分叉,并在必要时探索源代码。如果您想要一个具有长列表功能的托管服务,您可以探索 Azure API Management (adpg.link/8CEX)。它支持安全性、负载均衡、路由等。它还提供了一个服务目录,团队可以在此咨询和管理与内部团队、合作伙伴和客户的 API。我们可以将网关视为提供高级功能的反向代理。网关检索客户端请求的信息,这些信息可能来自一个或多个资源,可能来自一个或多个服务器。反向代理通常只将请求路由到一台服务器。反向代理通常充当负载均衡器。微软发布了一个名为 YARP 的反向代理,用 C# 编写且为开源项目 (adpg.link/YARP)。微软为他们的内部团队构建了它。现在,YARP 是 Azure App Service 的一部分 (adpg.link/7eu4)。如果 YARP 满足您的需求,它似乎是一个足够稳定的投资产品,随着时间的推移将发展和维护。此类服务的显著优势是能够与您的应用程序一起部署,可选地作为容器,允许我们在开发期间本地使用它。现在,让我们探索几种网关类型。
网关路由模式
我们可以通过让网关路由请求到适当的服务来使用这种模式来隐藏我们系统的复杂性。例如,假设我们有两个微服务:一个存储我们的设备数据,另一个管理设备位置。我们想显示特定设备(id=102)的最新已知位置,并显示其名称和型号。为了实现这一点,用户请求网页,然后网页调用两个服务(见以下图示)。DeviceTwin 微服务可通过 service1.domain.com 访问,而 Location 微服务可通过 service2.domain.com 访问。从那里,Web 应用程序必须跟踪这两个服务、它们的域名和它们的操作。随着我们添加更多的微服务,UI 必须处理更多的复杂性。此外,如果我们决定将 service1 改为 device-twins,将 service2 改为 location,我们还需要更新 Web 应用程序。如果只有一个 UI,那还算不错,但如果我们有多个用户界面,每个界面都必须处理这种复杂性。此外,如果我们想在私有网络内隐藏微服务,除非所有用户界面也都是该私有网络的一部分(这会暴露它),否则将不可能实现。以下是表示之前提到的交互的图示:

图 19.17:直接调用两个微服务的 Web 应用程序和移动应用程序
我们可以实施一个网关来为我们解决这些问题进行路由。这样,UI 只需要知道网关,而不是知道通过什么子域名可以访问哪些服务:

图 19.18:通过网关应用程序调用两个微服务的 Web 应用程序和移动应用程序
当然,这会带来一些可能的问题,因为网关成为了一个单点故障。我们可以考虑使用负载均衡器来确保我们有足够的可用性和快速的性能。由于所有请求都通过网关传递,我们可能还需要在某个时候对其进行扩展。我们还应该确保网关支持故障,通过实现不同的弹性模式,如重试和断路器。随着你部署的微服务数量和发送到这些微服务的请求数量的增加,网关另一侧发生错误的可能性也会增加。您还可以使用路由网关重新路由 URI 以创建更易于使用的 URI 模式。您还可以重新路由端口;添加、更新或删除 HTTP 头;等等。让我们探索相同的示例,但使用不同的 URI。让我们假设以下:
| 微服务 | URI |
|---|---|
| API 1(获取设备) | internal.domain.com:8001/{id} |
| API 2(获取设备位置) | internal.domain.com:8002/{id} |
表 19.1:内部微服务 URI 模式。
UI 开发者可能更难记住哪个端口通向哪个微服务以及它在做什么(而且谁能责怪他们呢?)。此外,我们无法像之前那样传输请求(仅路由域名)。我们可以使用网关作为为开发者创建记忆 URI 模式的方式,如下所示:
| 网关 URI | 微服务 URI |
|---|---|
gateway.domain.com/devices/{id} |
internal.domain.com:8001/{id} |
gateway.domain.com/devices/{id}/location |
internal.domain.com:8002/{id} |
表 19.1:易于使用且语义上有意义的记忆 URI 模式。
如我们所见,我们排除了端口,以创建可用的、有意义的、易于记忆的 URI。然而,我们仍然向网关发送两个请求来显示一条信息(设备的地理位置及其名称/型号),这导致我们转向下一个网关模式。
网关聚合模式
我们还可以赋予网关另一个角色,即聚合请求以隐藏其消费者的复杂性。将多个请求聚合为一个请求,使得微服务系统的消费者更容易与之交互;客户端只需要知道一个端点而不是多个端点。此外,它将客户端的冗余从客户端转移到网关,网关更靠近微服务,降低了多次调用的延迟,从而使得请求-响应周期更快。继续我们之前的例子,我们有两个用户界面应用,它们包含一个在识别设备名称/型号之前在地图上显示设备位置的功能。为了实现这一点,它们必须调用设备孪生端点以获取设备的名称和型号以及位置端点以获取其最后已知位置。因此,显示一个小框的两次请求乘以两个用户界面意味着四个请求来维护一个简单的功能。如果我们外推,我们可能会为少量功能管理大量的 HTTP 请求。以下是显示我们当前状态的功能图:

图 19.19:一个通过网关应用调用两个微服务的 Web 应用和移动应用
为了解决这个问题,我们可以应用网关聚合模式来简化我们的用户界面,并将管理这些细节的责任转移到网关。通过应用网关聚合模式,我们最终得到以下简化的流程:

图 19.20:一个网关,它聚合了两个请求的响应,以服务于来自 Web 应用和移动应用的单一请求
在之前的流程中,Web 应用调用网关,网关调用两个 API,然后制作一个结合从 API 获取的两个响应的响应。然后网关将此响应返回给 Web 应用。有了这个,Web 应用与两个 API 松散耦合,而网关充当中间人。仅通过一个 HTTP 请求,Web 应用就拥有了它所需的所有信息,由网关聚合。接下来,让我们探索发生的步骤。以下图表显示 Web 应用发起一个请求(1),而网关发起两个调用(2 和 4)。在图表中,请求是按顺序发送的,但我们也可以并行发送它们以加快速度:

图 19.21:请求发生的顺序
与路由网关一样,聚合网关也可能成为应用程序的瓶颈和单点故障,因此要小心。另一个重要点是网关和内部 API 之间的延迟。如果延迟太高,客户端将等待每个响应。因此,将网关部署在与它交互的微服务附近可能对系统性能至关重要。网关还可以实现缓存以进一步提高性能并使后续请求更快。接下来,我们将探索另一种类型的网关,它创建的是专用网关而不是通用网关。
后端前端模式
后端前端(BFF)模式是网关模式的一种另一种变体。在后端前端模式中,我们不是构建一个通用网关,而是为每个用户界面(与系统交互的每个应用程序)构建一个网关,从而降低复杂性。此外,它允许对暴露哪些端点进行精细控制。它消除了当对应用 A 进行更改时应用 B 可能崩溃的机会。许多优化可以由此模式产生,例如,只发送每个调用所需的必要数据,而不是发送只有少数应用程序使用的数据,从而在过程中节省一些带宽。假设我们的 Web 应用需要显示更多关于设备的详细信息。为了实现这一点,我们需要更改端点并将这些额外信息发送到移动应用。然而,移动应用不需要这些信息,因为它没有足够的空间在屏幕上显示它。接下来是一个更新的图表,用两个网关替换了单个网关,每个前端一个:

图 19.22:两个后端前端网关;一个用于 Web 应用,一个用于移动应用
通过这样做,我们可以为每个前端开发特定的功能,而不会影响其他部分。现在每个网关都保护其特定的前端不受整个系统和其他前端的影响。这是该模式带来的最重要的好处:客户端独立性。再次强调,前端后端模式是一种网关。与其他网关模式的变体一样,它可能成为其前端和单一故障点。好消息是,一个 BFF 网关的故障只会影响单个前端,保护其他前端免受这种停机时间的影响。
混合匹配网关
现在我们已经探讨了网关模式的三个变体,重要的是要注意我们可以混合匹配它们,无论是在代码库级别还是在多个微服务中。例如,可以为单个客户端(前端后端)构建网关,执行简单的路由,并聚合结果。我们也可以将它们混合为不同的应用,例如,通过在更通用的网关前面放置多个前端后端网关来简化这些前端后端网关的开发和维护。请注意,每个跳转都有成本。你在客户端和微服务之间添加的组件越多,这些客户端接收响应所需的时间就越长(延迟)。当然,你可以实施机制来降低这种开销,例如缓存或非 HTTP 协议如 gRPC,但你仍然必须考虑这一点。这适用于所有事情,而不仅仅是网关。以下是一个说明这一点的例子:

图 19.23:网关模式的混合
如你所可能猜到的,通用网关是所有应用的单一故障点,同时,每个前端后端网关也是其特定客户端的故障点。
服务网格是帮助微服务相互通信的替代方案。它是一个位于应用程序之外的一层,代理服务之间的通信。这些代理被注入到每个服务之上,被称为边车。服务网格还可以帮助进行分布式跟踪、仪表化和系统弹性。如果你的系统需要服务间通信,服务网格将是一个绝佳的解决方案。
结论
网关是一种门面,用于保护或简化对一个或多个其他服务的访问。在本节中,我们探讨了以下内容:
-
路由:这是将请求从 A 点转发到 B 点(反向代理)。
-
聚合:这是将多个子请求的结果合并为一个单一响应。
-
前端后端:这是与前端一对一关系使用。
我们可以使用任何微服务模式,包括网关,就像任何其他模式一样,我们可以混合搭配它们。只需考虑它们带来的优势,但也考虑它们的缺点。如果你能接受它们,你就找到了你的解决方案。网关往往成为系统的单点故障,所以这是一个需要考虑的点。另一方面,网关可以在负载均衡器后面同时运行多个实例。此外,我们还必须考虑调用调用另一个服务的服务时增加的延迟,因为这会减慢响应时间。总的来说,网关是一个简化消费微服务的优秀工具。它们还允许在它们后面隐藏微服务拓扑,甚至可能是在私有网络中隔离。它们还可以处理跨领域关注点,如安全性。
强烈建议使用网关作为请求的透传,并避免将业务逻辑编码到网关中;网关只是反向代理。想想单一责任原则:网关是你微服务集群前面的门面。当然,你可以将特定的任务卸载到网关中,比如授权、弹性(例如重试策略)和类似的跨领域关注点,但业务逻辑必须保留在后端的微服务中。
BFF(最佳朋友功能)的作用是简化用户界面,因此鼓励将逻辑从 UI 移动到 BFF。
在大多数情况下,我建议不要使用您亲手打造的网关,而是建议利用现有的产品。有许多开源和云网关可供您在应用程序中使用。使用现有组件可以让您有更多时间来实现解决您程序试图解决的问题的业务规则。当然,也存在基于云的产品,例如 Azure 应用程序网关和 Amazon API 网关。两者都可以通过云产品(如负载均衡器和Web 应用程序防火墙(WAF))进行扩展。例如,Azure 应用程序网关还支持自动扩展、区域冗余,并可以作为Azure Kubernetes 服务(AKS)网关控制器使用(简单来说,它控制着流量到您的微服务集群)。如果您希望对网关有更多控制权或与您的应用程序一起部署,您可以使用一个现有选项,如 Ocelot、YARP 或 Envoy。Ocelot 是一个用.NET 编写的开源生产就绪 API 网关。Ocelot 支持路由、请求聚合、负载均衡、身份验证、授权、速率限制等。它还与 Identity Server 很好地集成。在我看来,Ocelot 最大的优势是您可以自己创建.NET 项目,安装 NuGet 包,配置您的网关,然后像其他 ASP.NET Core 应用程序一样部署它。由于 Ocelot 是用.NET 编写的,如果需要,扩展它或为其项目或其生态系统做出贡献更容易。引用他们的 GitHub README.md文件:“YARP 是一个用于在.NET 中使用 ASP.NET 和.NET 基础设施构建快速代理服务器的反向代理工具包。YARP 的关键区别在于它被设计成易于定制和调整,以匹配每个部署场景的具体需求。”Envoy 是一个“开源边缘和服务代理,专为云原生应用程序设计*”,正如他们的网站所述。Envoy 是一个由 Lyft 最初创建的云原生计算基金会(CNCF)毕业项目。Envoy 被设计为作为与您的应用程序分离的独立进程运行,使其能够与任何编程语言一起工作。Envoy 可以作为网关使用,并通过 TCP/UDP 和 HTTP 过滤器具有可扩展的设计,支持 HTTP/2 和 HTTP/3、gRPC 等。选择哪个产品?如果您正在寻找一个完全托管的服务,请查看您选择的云提供商的产品。如果您正在寻找一个可配置的反向代理或网关,它支持本章中涵盖的模式,请考虑 YARP 或 Ocelot。如果您有 Ocelot 不支持复杂用例,您可以查看 Envoy,这是一个具有许多高级功能的经过验证的产品。请记住,这些只是可以在微服务架构系统中扮演网关角色的几种可能性,并不旨在成为完整列表。现在,让我们看看网关如何帮助我们以云规模遵循SOLID原则:
-
S:网关可以处理路由、聚合和其他类似逻辑,否则这些逻辑将实现在不同的组件或应用程序中。
-
O:我看到了很多处理这个问题的方法,但这里提供两种看法:
-
在外部,网关可以在其消费者不知道的情况下将其子请求重定向到新的 URI,只要其合同不改变。
-
在内部,网关可以从配置中加载其规则,允许它在不更新其代码的情况下进行更改。
-
L:N/A
-
I:由于前端网关为单个前端系统服务,每个前端系统一个合同(接口)会导致多个较小的接口而不是一个大的通用网关。
-
D:我们可以将网关视为一个抽象,隐藏实际的微服务(实现)并反转依赖流。
接下来,我们从第十八章开始构建一个 bff 并演进电子商务应用。
项目 – REPR.BFF
此项目利用后端为前端(BFF)设计模式来降低使用我们在第十八章中创建的REPR 项目的底层 API 的复杂性。bff 端点充当我们探索的几种类型的网关。这种设计使得 API 有两层,所以让我们从这里开始。
层次化 API
从高级架构的角度来看,我们可以利用多个 API 层来分组不同级别的操作粒度。例如,在这个案例中,我们有两层:
-
提供原子基础操作的底层 API。
-
提供特定领域功能的顶层 API。
这里有一个表示这个概念的图(在这种情况下,顶层 API 是 bff,但设计可能有所细微差别):

图 19.24:展示两层架构的示意图。
低层展示原子基础操作,如向购物车添加项目或从购物车中删除项目。这些操作很简单,因此使用起来更复杂。例如,加载用户购物车中的产品需要多个 API 调用,一个用于获取项目和数量,每个项目一个用于获取产品详情,如名称和价格。高层提供特定领域的功能,使用起来更简单,但可能变得更复杂。例如,单个端点可以处理向购物车添加、更新和删除项目,使其对消费者的使用变得简单,但其逻辑实现更复杂。此外,产品团队可能更喜欢购物车而不是购物篮,因此端点的 URL 可以反映这一点。让我们看看优势和劣势。
两层设计的优势
-
关注点分离:此架构将通用功能与特定领域功能分开,促进更干净的代码和模块化。
-
可扩展性:每一层可以根据需求独立扩展。
-
灵活性和可重用性: 低级 API 可以在多个高级功能或应用程序之间重用,从而促进代码的可重用性。
-
优化数据获取: BFF 可以调用多个低级 API,聚合响应,并将必要的数据发送到前端,从而减少有效载荷大小,使前端开发更加简单。
-
易于维护: 我们可以在不触及低级通用 API 的情况下解决特定领域的问题。另一方面,我们可以在较低级别的 API 中修复问题,这将传播到所有领域。
-
定制用户体验: 高级 API 可以专门为特定客户端类型(网页、移动等)定制,确保最佳用户体验。
-
安全性: 针对特定领域的功能可以实现与其上下文相关的额外安全措施,而不会给低级 API 带来不必要的复杂性。
双层设计的缺点
-
增加复杂性: 维护两层引入了额外的部署、监控和管理复杂性。
-
潜在的性能开销: 添加额外的层会引入延迟,尤其是在没有适当优化的情况下。
-
代码重复: 当类似的逻辑在多个高级功能中实现时,可能会出现代码重复。
-
紧密耦合关注点: 低级 API 的更改可能会影响多个特定领域的功能。糟糕的设计可能导致紧密耦合的分布式系统。
-
需要协调: 随着系统的演变,确保低级 API 满足所有高级功能的需求需要在开发团队之间进行更多协调。
-
开发中的开销: 开发者需要考虑两层,这可能会减慢开发过程,尤其是在需要修改两层以实现特定功能或修复问题时。
-
潜在的数据过时: 如果高级功能从低级 API 缓存数据,可能会向用户提供服务过时的数据。
-
增加失败风险: 引入额外的 API 增加了其中之一出现问题或中断的概率。
虽然双层设计可以提供灵活性和优化,但它也引入了额外的复杂性。是否使用这种架构的决定应基于项目的具体需求、预期的规模以及开发和运营团队的能力。我们接下来将查看启动这些 API。
运行微服务
让我们先从探索部署拓扑开始。首先,我们将第十八章 REPR 项目拆分为两个服务:购物车和产品。然后,我们添加一个bff API 作为两个服务的代理,以简化系统的使用。我们本身没有用户界面,但每个项目都有一个http文件来模拟 HTTP 请求。以下是一个表示不同服务之间关系的图:

图 19.25:表示不同服务部署拓扑和关系的图
开始项目最简单且最可扩展的方式是使用 Docker,但这不是必须的;我们也可以手动启动三个项目。使用 Docker 打开了许多可能性,比如使用真实的 SQL Server 在运行之间持久化数据,并添加更多拼图碎片,例如 Redis 缓存或事件代理,仅举几例。让我们先手动启动应用程序。
手动启动项目
我们有三个项目,需要三个终端来启动它们。从章节目录中,您可以执行以下命令,每个终端窗口一组命令,所有项目都应该启动:# 在一个终端
cd REPR.Baskets
dotnet run
# In a second terminal
cd REPR.Products
dotnet run
# In a third terminal
cd REPR.BFF
dotnet run
执行此操作应该可以工作。您可以使用 PROJECT_NAME.http 文件来测试 API。接下来,让我们探索使用 Docker 的第二个选项。
使用 Docker Compose 运行项目
在解决方案文件同一级别,docker-compose.yml、docker-compose.override.yml 和各种 Dockerfile 文件预先配置,以便项目按正确顺序启动。
这里有一个链接,可以帮助您开始使用 Docker:
adpg.link/1zfM
由于 ASP.NET Core 默认使用 HTTPS,我们必须在容器中注册一个开发证书,所以让我们从这里开始。
配置 HTTPS
本节简要探讨了在 Windows 上使用 PowerShell 设置 HTTPS 的方法。如果您使用的是不同的操作系统或说明不起作用,请参阅官方文档:adpg.link/o1tu首先,我们必须生成一个开发证书。在 PowerShell 终端中,运行以下命令:
dotnet dev-certs https -ep "$env:APPDATA\ASP.NET\Https\adpg-net8-chapter-19.pfx" -p devpassword
dotnet dev-certs https --trust
之前的命令创建了一个带有密码 devpassword 的 pfx 文件(您必须提供密码,否则它将无法工作),然后告诉 .NET 信任开发证书。从那里,ASPNETCORE_Kestrel__Certificates__Default__Path 和 ASPNETCORE_Kestrel__Certificates__Default__Password 环境变量在 docker-compose.override.yml 文件中配置,应予以考虑。
如果您更改证书位置或密码,必须更新
docker-compose.override.yml文件。
组合应用程序
现在我们设置了 HTTPS,我们可以使用以下命令构建容器:
docker compose build
我们可以执行以下命令来启动容器:
docker compose up
这应该会启动容器,并为您提供带有每个服务颜色的聚合日志。日志的开头应该看起来像这样:
[+] Running 3/0
✔ Container c19-repr.products-1 Created 0.0s
✔ Container c19-repr.baskets-1 Created 0.0s
✔ Container c19-repr.bff-1 Created 0.0s
Attaching to c19-repr.baskets-1, c19-repr.bff-1, c19-repr.products-1
c19-repr.baskets-1 | info: Microsoft.Hosting.Lifetime[14]
c19-repr.baskets-1 | Now listening on: http://[::]:80
c19-repr.baskets-1 | info: Microsoft.Hosting.Lifetime[14]
c19-repr.baskets-1 | Now listening on: https://[::]:443
...
要停止服务,请按 Ctrl+C。当您想要销毁正在运行的应用程序时,请输入以下命令:
docker compose down
现在,使用 docker compose up,我们的服务应该正在运行。为了确保这一点,让我们试一试。
简单测试服务
项目包含以下服务,每个服务都包含一个你可以利用的http文件,用于使用 Visual Studio 或通过 VS Code 扩展查询服务:
| 服务 | HTTP 文件 | 主机 |
|---|---|---|
REPR.Baskets |
REPR.Baskets.http |
localhost:60280 |
REPR.BFF |
REPR.BFF.http |
localhost:7254 |
REPR.Products |
REPR.Products.http |
localhost:57362 |
表 19.3:每个服务、HTTP 文件、HTTPS 主机名和端口。
我们可以利用每个目录的 HTTP 请求来测试 API。我建议先尝试低级 API,然后是 BFF,这样你可以直接知道它们是否有问题,而不是猜测 BFF(它调用低级 API)出了什么问题。
我使用 VS Code 中的REST 客户端扩展(
adpg.link/UCGv)和 Visual Studio 2022 版本 17.6 或更高版本的内置支持。
这是REPR.Baskets.http文件的一部分:
@Web_HostAddress = https://localhost:60280
@ProductId = 3
@CustomerId = 1
GET {{Web_HostAddress}}/baskets/{{CustomerId}}
###
POST {{Web_HostAddress}}/baskets
Content-Type: application/json
{
"customerId": {{CustomerId}},
"productId": {{ProductId}},
"quantity": 10
}
...
突出的行是请求重复使用的变量。###字符在请求之间充当分隔符。在 VS 或 VS Code 中,你应该在每个请求的顶部看到一个发送请求按钮。执行POST请求后,然后执行GET应该输出以下内容:
HTTP/1.1 200 OK
Content-Type: application/json; charset=utf-8
[
{
"productId": 3,
"quantity": 10
}
]
如果你能够访问一个端点,这意味着服务正在运行。尽管如此,请随意玩转请求,修改它们,并添加更多。
我没有将测试从第十八章迁移过来。自动化我们部署的验证可能是一个很好的练习,让你测试你的测试技能。
在验证了三个服务都在运行之后,我们可以继续并查看 BFF 如何与 Baskets 和 Products 服务进行通信。
使用 Refit 创建类型化 HTTP 客户端
BFF 服务必须与 Baskets 和 Products 服务通信。这些服务是 REST API,因此我们必须利用 HTTP。我们可以利用现成的 ASP.NET Core HttpClient类和IHttpClientFactory接口,然后向下游 API 发送原始 HTTP 请求。另一方面,我们也可以创建一个类型化的客户端,将 HTTP 调用转换为具有启发性的简单方法调用。我们正在探索第二种选择,将 HTTP 调用封装在类型化的客户端中。这个概念很简单:我们为每个服务创建一个接口,并将其操作转换为方法。每个接口都围绕一个服务展开。可选地,我们可以将服务聚合到一个主接口下,注入聚合服务并访问所有子服务。此外,这个中心访问点允许我们将注入的服务数量减少到只有一个,并通过 IntelliSense 提高可发现性。以下是一个表示这个概念的图:

图 19.25:表示通用类型化客户端类层次的 UML 类图。
在前面的图中,IClient 接口由组合而成,并公开了其他类型化的客户端,每个客户端都查询特定的下游 API。在我们的案例中,我们有两个下游服务,所以我们的接口层次结构看起来如下:

图 19.26:表示 BFF 下游类型化客户端类层次的 UML 类图。
实现之后,我们可以在代码中查询下游 API,而无需担心它们的数据合约,因为我们的客户端是强类型的。我们利用开源库 Refit 自动实现接口。
我们可以使用任何其他库或裸骨 ASP.NET Core
HttpClient;这并不重要。我选择 Refit 是为了利用其代码生成器,省去编写样板代码的麻烦,并节省你阅读此类代码的时间。Refit 在 GitHub 上的链接:adpg.link/hneJ。我过去使用过
IHttpClientFactory的内置功能,所以如果你想在项目中减少依赖项的数量,你也可以使用它。以下是一个帮助你开始的链接:adpg.link/HCj7。
Refit 类似于 Mapperly,根据属性生成代码,所以我们只需要定义我们的方法,Refit 就会编写代码。
BFF 项目引用了 Products 和 Baskets 项目以重用它们的 DTO。我可以用很多不同的方式来设计这个架构,包括在一个自己的库中托管类型化客户端,这样我们就可以在多个项目中共享它。我们还可以将 DTO 从 Web 应用程序提取到一个或多个共享项目中,这样我们就不会依赖于 Web 应用程序本身。对于这个演示,没有必要过度设计解决方案。
让我们看看类型化的客户端接口,从 IBasketsClient 接口开始:
using Refit;
using Web.Features;
namespace REPR.BFF;
public interface IBasketsClient
{
[Get("/baskets/{query.CustomerId}")]
Task<IEnumerable<Baskets.FetchItems.Item>> FetchCustomerBasketAsync(
Baskets.FetchItems.Query query,
CancellationToken cancellationToken);
[Post("/baskets")]
Task<Baskets.AddItem.Response> AddProductToCart(
Baskets.AddItem.Command command,
CancellationToken cancellationToken);
[Delete("/baskets/{command.CustomerId}/{command.ProductId}")]
Task<Baskets.RemoveItem.Response> RemoveProductFromCart(
Baskets.RemoveItem.Command command,
CancellationToken cancellationToken);
[Put("/baskets")]
Task<Baskets.UpdateQuantity.Response> UpdateProductQuantity(
Baskets.UpdateQuantity.Command command,
CancellationToken cancellationToken);
}
前面的接口利用 Refit 的属性(突出显示)来向其代码生成器说明要编写的内容。操作本身是自解释的,并且通过 HTTP 传输功能的数据传输对象(DTO)。接下来,我们看看 IProductsClient 接口:
using Refit;
using Web.Features;
namespace REPR.BFF;
public interface IProductsClient
{
[Get("/products/{query.ProductId}")]
Task<Products.FetchOne.Response> FetchProductAsync(
Products.FetchOne.Query query,
CancellationToken cancellationToken);
[Get("/products")]
Task<Products.FetchAll.Response> FetchProductsAsync(
CancellationToken cancellationToken);
}
前面的接口类似于 IBasketsClient,但在 Products API 上创建了一个类型化的桥梁。
生成的代码包含很多无意义的代码,清理起来非常困难,以至于很难使其与学习相关,所以让我们假设这些接口有可工作的实现。
接下来,让我们看看我们的聚合:
public interface IWebClient
{
IBasketsClient Baskets { get; }
IProductsClient Catalog { get; }
}
前面的接口展示了我们让 Refit 为我们生成的两个客户端。其实现方式相当直接:
public class DefaultWebClient : IWebClient
{
public DefaultWebClient(IBasketsClient baskets, IProductsClient catalog)
{
Baskets = baskets ?? throw new ArgumentNullException(nameof(baskets));
Catalog = catalog ?? throw new ArgumentNullException(nameof(catalog));
}
public IBasketsClient Baskets { get; }
public IProductsClient Catalog { get; }
}
前面的默认实现通过构造函数注入自己组合,暴露了两个类型化的客户端。当然,依赖注入意味着我们必须将服务注册到容器中。让我们从一些配置开始。为了使设置代码可参数化并允许 Docker 容器覆盖这些值,我们将服务的基本地址提取到设置文件中,如下所示(appsettings.Development.json):
{
"Downstream": {
"Baskets": {
"BaseAddress": "https://localhost:60280"
},
"Products": {
"BaseAddress": "https://localhost:57362"
}
}
}
前面的代码定义了两个密钥,每个服务一个,然后我们在Program.cs文件中单独加载它们,如下所示:
using Refit;
using REPR.BFF;
using System.Collections.Concurrent;
using System.Net;
var builder = WebApplication.CreateBuilder(args);
var basketsBaseAddress = builder.Configuration
.GetValue<string>("Downstream:Baskets:BaseAddress") ?? throw new NotSupportedException("Cannot start the program without a Baskets base address.");
var productsBaseAddress = builder.Configuration
.GetValue<string>("Downstream:Products:BaseAddress") ?? throw new NotSupportedException("Cannot start the program without a Products base address.");
前面的代码将两个配置加载到变量中。
我们可以利用我们在第九章,“选项、设置和配置”中学到的所有技术,来创建一个更复杂的系统。
接下来,我们这样注册我们的 Refit 客户端:
builder.Services
.AddRefitClient<IBasketsClient>()
.ConfigureHttpClient(c => c.BaseAddress = new Uri(basketsBaseAddress))
;
builder.Services
.AddRefitClient<IProductsClient>()
.ConfigureHttpClient(c => c.BaseAddress = new Uri(productsBaseAddress))
;
在前面的代码中,调用AddRefitClient方法替换了.NET 的AddHttpClient方法,并将我们自动生成的客户端注册到容器中。因为 Refit 注册返回一个IHttpClientBuilder接口,我们可以使用ConfigureHttpClient方法来配置HttpClient,就像配置任何其他类型化的 HTTP 客户端一样。在这种情况下,我们将BaseAddress属性设置为之前加载的设置的值。接下来,我们还必须注册我们的聚合:
builder.Services.AddTransient<IWebClient, DefaultWebClient>();
我选择了一个瞬态状态,因为该服务仅作为其他服务的代理,所以它将根据注册情况提供服务,而不管每次是否是相同的实例。此外,它需要一个瞬态或作用域生命周期,因为 bff 必须管理当前客户是谁,而不是客户端。允许用户为每个请求决定他们想要模仿谁将是一个很大的安全漏洞。
该项目不进行用户身份验证,但我们接下来要探索的服务旨在使这一过程演变,抽象并管理这一责任,这样我们就可以在不影响我们正在编写的代码的情况下添加身份验证。
让我们探索如何管理当前用户。
创建一个为当前客户服务的服务
为了使项目简单,我们目前没有使用任何身份验证或授权中间件,但我们希望 bff 是现实的,并处理谁在查询下游 API。为了实现这一点,让我们创建一个ICurrentCustomerService接口,它将这个功能从消费代码中抽象出来:
public interface ICurrentCustomerService
{
int Id { get; }
}
该接口所做的唯一事情是提供代表当前客户的标识符。由于我们在项目中没有身份验证,让我们实现一个开发版本,它总是返回相同的值:
public class FakeCurrentCustomerService : ICurrentCustomerService
{
public int Id => 1;
}
最后,我们必须在Program.cs类中这样注册它:
builder.Services.AddScoped<ICurrentCustomerService, FakeCurrentCustomerService>();
在这个最后部分,我们已经准备好在我们的 bff 服务中编写一些功能。
在使用身份验证的项目中,您可以将
IHttpContextAccessor接口注入到类中,以访问包含User属性的当前HttpContext对象,该属性允许访问当前用户的ClaimsPrincipal对象,其中应包括当前用户的CustomerId。当然,您必须确保身份验证服务器返回此类声明。在使用之前,您必须使用以下方法注册访问器:builder.Services.AddHttpContextAccessor()。
功能
BFF 服务提供了一个不存在的用户界面,但我们可以想象它需要做什么;它必须:
-
为客户提供产品目录,以便他们可以浏览商店。
-
为渲染产品详情页面提供特定产品。
-
为用户提供其购物车中的商品列表。
-
允许用户通过添加、更新和删除商品来管理他们的购物车。
当然,功能列表可以继续,比如允许用户购买商品,这是电子商务网站最终目标。然而,我们不会走那么远。让我们从目录开始。
获取目录
目录充当路由网关,并将请求转发到Products下游服务。第一个端点通过我们的类型客户端(突出显示)提供整个目录:
app.MapGet(
"api/catalog",
(IWebClient client, CancellationToken cancellationToken)
=> client.Catalog.FetchProductsAsync(cancellationToken)
);
发送以下请求应击中端点:
GET https://localhost:7254/api/catalog
端点应响应如下:
HTTP/1.1 200 OK
Content-Type: application/json; charset=utf-8
{
"products": [
{
"id": 2,
"name": "Apple",
"unitPrice": 0.79
},
{
"id": 1,
"name": "Banana",
"unitPrice": 0.30
},
{
"id": 3,
"name": "Habanero Pepper",
"unitPrice": 0.99
}
]
}
这里是发生情况的视觉表示:

图 19.27:表示 BFF 将请求路由到产品服务的序列图
另一个目录端点非常相似,并且也简单地路由请求到正确的下游服务:
app.MapGet(
"api/catalog/{productId}",
(int productId, IWebClient client, CancellationToken cancellationToken)
=> client.Catalog.FetchProductAsync(new(productId), cancellationToken)
);
发送 HTTP 调用将产生与直接调用相同的结果,因为 BFF 仅作为路由器。我们将在下一部分探索更多令人兴奋的功能。
获取购物车
购物车服务仅存储customerId、productId和quantity属性。然而,购物车页面显示产品名称和价格,但产品服务管理这两个属性。为了克服这个问题,端点充当聚合网关。它在查询购物车之前从产品服务加载所有产品,然后返回聚合结果,从而减轻客户端/UI 管理这种复杂性的负担。以下是主要功能代码:
app.MapGet(
"api/cart",
async (IWebClient client, ICurrentCustomerService currentCustomer, CancellationToken cancellationToken) =>
{
var basket = await client.Baskets.FetchCustomerBasketAsync(
new(currentCustomer.Id),
cancellationToken
);
var result = new ConcurrentBag<BasketProduct>();
await Parallel.ForEachAsync(basket, cancellationToken, async (item, cancellationToken) =>
{
var product = await client.Catalog.FetchProductAsync(
new(item.ProductId),
cancellationToken
);
result.Add(new BasketProduct(
product.Id,
product.Name,
product.UnitPrice,
item.Quantity
));
});
return result;
}
);
上述代码首先从 Baskets 服务获取项目,然后使用 Parallel.ForEachAsync 方法加载产品,最后返回聚合结果。Parallel 类允许我们并行执行多个操作,在这种情况下,多个 HTTP 调用。使用 .NET 实现类似结果的方法有很多,这是其中之一。当 HTTP 调用成功时,它将 BasketProduct 项目添加到 result 集合中。一旦所有操作完成,端点返回 BasketProduct 对象的集合,其中包含用户界面显示购物车所需的所有组合信息。以下是 BasketProduct 类:
public record class BasketProduct(int Id, string Name, decimal UnitPrice, int Quantity)
{
public decimal TotalPrice => UnitPrice * Quantity;
}
此端点的顺序如下(loop 代表 Parallel.ForEachAsync 方法):

图 19.28:表示购物车端点与下游服务 Products 和 Baskets 交互的序列图。
由于对 Products 服务的请求是并行发送的,我们无法预测它们完成的顺序。以下是应用程序日志的摘录,描述了可能发生的情况(我在书中省略了日志代码,但它在 GitHub 上可用):
trce: GetCart[0]
Fetching product '3'.
trce: GetCart[0]
Fetching product '2'.
trce: GetCart[0]
Found product '2'(Apple).
trce: GetCart[0]
Found product '3'(Habanero Pepper).
前面的跟踪显示,我们请求了产品 3 和 2,但收到了倒置的响应(2 和 3)。在并行运行代码时,这种情况是可能的。当我们向 BFF 发送以下请求时:
GET https://localhost:7254/api/cart
BFF 返回的响应类似于以下内容:
HTTP/1.1 200 OK
Content-Type: application/json; charset=utf-8
[
{
"id": 3,
"name": "Habanero Pepper",
"unitPrice": 0.99,
"quantity": 10,
"totalPrice": 9.90
},
{
"id": 2,
"name": "Apple",
"unitPrice": 0.79,
"quantity": 5,
"totalPrice": 3.95
}
]
上述示例展示了聚合结果,简化了客户端(UI)必须实现以显示购物车的逻辑。
由于我们没有对结果进行排序,项目不一定会按相同的顺序出现。作为一个练习,你可以使用现有的某个属性对结果进行排序,或者添加一个属性,当客户将商品添加到购物车时保存该属性,并使用这个新属性对项目进行排序;首先添加的项目将首先显示,依此类推。
让我们转到最后一个端点,并探讨 BFF 如何管理购物车项目。
管理购物车
我们 BFF 的一个主要目标是减少前端复杂性。在检查 Baskets 服务时,我们意识到如果我们只提供原始操作,将会增加一些不必要的复杂性,因此我们决定将所有购物车逻辑封装在一个单独的端点之后。当客户端向 api/cart 端点 POST 时,它:
-
添加一个不存在的商品。
-
更新现有商品的数量。
-
删除数量等于 0 或更少的商品。
使用此端点,客户端无需担心添加或更新。以下是一个简化的序列图,表示此逻辑:

图 19.29:一个序列图,显示了购物车端点的高级算法。
如图中所示,如果数量低于或等于零,我们调用移除端点。否则,我们尝试将项目添加到篮子中。如果端点返回409 冲突,我们尝试更新数量。以下是代码:
app.MapPost(
"api/cart",
async (UpdateCartItem item, IWebClient client, ICurrentCustomerService currentCustomer, CancellationToken cancellationToken) =>
{
if (item.Quantity <= 0)
{
await RemoveItemFromCart(
item,
client,
currentCustomer,
cancellationToken
);
}
else
{
await AddOrUpdateItem(
item,
client,
currentCustomer,
cancellationToken
);
}
return Results.Ok();
}
);
之前的代码遵循相同的模式,但包含之前解释的逻辑。我们接下来探索两个突出显示的方法,首先是RemoveItemFromCart方法:
static async Task RemoveItemFromCart(UpdateCartItem item, IWebClient client, ICurrentCustomerService currentCustomer, CancellationToken cancellationToken)
{
try
{
var result = await client.Baskets.RemoveProductFromCart(
new Web.Features.Baskets.RemoveItem.Command(
currentCustomer.Id,
item.ProductId
),
cancellationToken
);
}
catch (ValidationApiException ex)
{
if (ex.StatusCode != HttpStatusCode.NotFound)
{
throw;
}
}
}
上一段代码中突出显示的代码利用了强类型 HTTP 客户端,并发送了一个移除项目命令到篮子服务。如果项目不在购物车中,代码忽略错误并继续。为什么?因为它不影响业务逻辑或最终用户体验。也许顾客点击了移除或更新按钮两次。然而,代码将任何其他错误传播到客户端。让我们探索AddOrUpdateItem方法的代码:
static async Task AddOrUpdateItem(UpdateCartItem item, IWebClient client, ICurrentCustomerService currentCustomer, CancellationToken cancellationToken)
{
try
{
// Add the product to the cart
var result = await client.Baskets.AddProductToCart(
new Web.Features.Baskets.AddItem.Command(
currentCustomer.Id,
item.ProductId,
item.Quantity
),
cancellationToken
);
}
catch (ValidationApiException ex)
{
if (ex.StatusCode != HttpStatusCode.Conflict)
{
throw;
}
// Update the cart
var result = await client.Baskets.UpdateProductQuantity(
new Web.Features.Baskets.UpdateQuantity.Command(
currentCustomer.Id,
item.ProductId,
item.Quantity
),
cancellationToken
);
}
}
之前的逻辑与其他方法非常相似。它首先将项目添加到购物车中。如果收到409 冲突,它会尝试更新数量。否则,它让异常向上冒泡到堆栈,以便稍后由异常中间件捕获以统一错误消息。有了这段代码,我们可以向api/cart端点发送POST请求以添加、更新和从购物车中删除项目。这三个操作返回一个空的200 OK响应。假设我们的购物车为空,以下请求将10 哈瓦那辣椒(id=3)添加到购物车中:
POST https://localhost:7254/api/cart
Content-Type: application/json
{
"productId": 3,
"quantity": 10
}
以下请求将5 个苹果(id=2)添加到购物车中:
POST https://localhost:7254/api/cart
Content-Type: application/json
{
"productId": 2,
"quantity": 5
}
以下请求将数量更新为20 哈瓦那辣椒(id=3):
POST https://localhost:7254/api/cart
Content-Type: application/json
{
"productId": 3,
"quantity": 20
}
以下请求从购物车中移除了苹果(id=2):
POST https://localhost:7254/api/cart
Content-Type: application/json
{
"productId": 2,
"quantity": 0
}
留下我们在购物车中的20 哈瓦那辣椒(GET https://localhost:7254/api/cart):
[
{
"id": 3,
"name": "Habanero Pepper",
"unitPrice": 0.99,
"quantity": 20,
"totalPrice": 19.80
}
]
之前序列中的请求都采用相同的格式,到达相同的端点但执行不同的操作,这使得前端客户端管理起来非常容易。
如果你希望 UI 单独管理操作或想实现批量更新功能,你可以这样做;这只是一个示例,说明你可以利用 BFF 做什么。
我们现在完成了 BFF 服务。
结论
在本节中,我们学习了如何使用后端为前端(BFF)设计模式来面向微电子商务 Web 应用。我们讨论了分层 API 和双层设计的优缺点。我们使用 Refit 自动生成了强类型 HTTP 客户端,管理了购物车,并从 BFF 获取了目录。我们学习了如何通过实现多个网关模式将领域逻辑从前端移动到后端来使用 BFF 减少复杂性。以下是我们在探索中发现的几个好处:
-
bff 模式可以显著简化前端和后端系统之间的交互。它提供了一个抽象层,可以减少使用低级原子 API 的复杂性。它将通用功能和领域特定功能分离,并促进更干净、更模块化的代码。
-
一个 bff 可以作为网关,将特定请求路由到相关服务,从而减少前端需要执行的工作。它还可以作为聚合网关,将来自各种服务的数据进行汇总,形成统一的响应。这个过程可以通过减少前端的复杂性和前端必须进行的单独调用数量来简化前端开发。它还可以减少前端和后端之间传输的有效负载大小。
-
每个 bff 都是针对特定客户端定制的,优化前端交互。
-
一个 bff 可以在不影响低级 API 或其他应用程序的情况下处理一个域的问题,从而提供更简单的维护。
-
一个 bff 可以实现安全逻辑,例如特定领域导向的认证和授权规则。
尽管有这些好处,使用 bff 也可能增加复杂性并引入潜在的性能开销。使用 bff 与其他模式没有区别,必须权衡并适应项目的具体需求。接下来,我们再次在分布式规模上重新审视 CQRS。
重新审视 CQRS 模式
命令查询责任分离(CQRS)应用了命令查询分离(CQS)原则。与我们在第十四章,中介者和 CQRS 设计模式中看到的内容相比,我们可以通过使用微服务或无服务器计算来进一步推进 CQRS。我们不仅可以在命令和查询之间创建清晰的分离,还可以通过多个微服务和数据源将它们进一步细分。CQS是一个原则,表示一个方法应该要么返回数据,要么修改数据,但不能两者兼有。另一方面,CQRS建议使用一个模型来读取数据,另一个模型来修改数据。无服务器计算是一种云执行模型,其中云提供商管理服务器,并根据使用情况和配置按需分配资源。无服务器资源属于平台即服务(PaaS)提供的产品。让我们再次回到我们的物联网示例。在先前的示例中,我们查询了设备的最后已知位置,但设备更新位置怎么办?这可能意味着每分钟推送许多更新。为了解决这个问题,我们将使用 CQRS 并专注于两个操作:
-
更新设备位置。
-
读取设备的最后已知位置。
简而言之,我们有一个Read Location微服务,一个Write Location微服务,以及两个数据库。请记住,每个微服务应该拥有自己的数据。这样,用户可以通过读取微服务(查询模型)访问最后已知的设备位置,而设备可以准时将其当前位置发送到写入微服务(命令模型)。通过这种方式,我们将读取和写入数据的负载分开,因为这两种操作发生的频率不同:

图 19.30:应用 CQRS 来分割设备位置读取和写入的微服务
在之前说明概念的方案中,读取是查询,写入是命令。一旦在写入数据库中添加了新值,如何更新 Read DB 取决于所使用的技术。在这种类型的架构中,一个基本的事情是,根据 CQRS 模式,命令不应该返回值,从而实现“发射并忘记”场景。有了这个规则,消费者不必等待命令完成就可以做其他事情。
“发射并忘记”并不适用于每个场景;有时,我们需要同步。实现 Saga 模式是解决协调问题的一种方法。
从概念上讲,我们可以通过利用无服务器云基础设施,如 Azure Functions,来实现这个示例。让我们使用一个高级概念性的无服务器设计重新审视这个示例:

图 19.31:使用 Azure 服务管理 CQRS 实现
之前的图示说明了以下内容:
-
设备通过将其发布到Azure Function 1来定期发送其位置。
-
Azure Function 1 然后将
LocationAdded事件发布到事件代理,该代理也是一个事件存储(写入数据库)。 -
现在,所有订阅
LocationAdded事件的订阅者都可以适当地处理该事件,在这种情况下,Azure Function 2。 -
Azure Function 2 在Read DB中更新设备的最后已知位置。
-
任何后续的查询都应该导致读取新的位置。
消息代理也是前面图中的事件存储,但我们可以在其他地方存储事件,例如在 Azure 存储表中、在时间序列数据库中或在 Apache Kafka 集群中。在 Azure 方面,数据存储也可以是 CosmosDB。此外,我出于多个原因抽象了这个组件,包括在 Azure 中发布事件有多种“作为服务”的提供方式,以及使用第三方组件(开源和专有)的多种方式。此外,该示例很好地展示了最终一致性。在步骤 1 和步骤 4 之间,所有最后已知的读取位置都获取了旧值,而系统正在处理新的位置更新(命令)。如果由于某种原因命令处理速度减慢,下一次读取数据库更新之前可能会出现更长的延迟。命令也可以批量处理,导致另一种类型的延迟。无论命令处理发生什么情况,读取数据库始终可用,无论它是否提供最新数据,以及写入系统是否过载。这是此类设计的美丽之处,但实现和维护起来更为复杂。
时间序列数据库针对时间查询和存储数据进行了优化,在这种数据库中,你总是追加新的记录而不更新旧的记录。这种 NoSQL 数据库对于需要大量时间处理的用途,如指标,可能很有用。
再次使用发布-订阅模式来启动另一个场景。假设事件永久保存,前面的示例也可以支持事件溯源。此外,新服务可以订阅LocationAdded事件,而不会影响已部署的代码。例如,我们可以创建一个 SignalR 微服务,将其更新推送到其客户端。这与 CQRS 无关,但它与迄今为止我们所探索的一切都很好地融合在一起,所以这里有一个更新的概念图:

图 19.32:在不影响系统其他部分的情况下添加 SignalR 服务作为新的订阅者
SignalR 微服务可以是自定义代码或 Azure SignalR 服务(由另一个 Azure Function 支持);这无关紧要。在这种设计下,Web 应用可以在读取数据库更新之前知道已发生更改。
使用这种设计,我想说明在采用发布-订阅模型时,将新服务添加到系统中比使用点对点通信更容易。
正如你所见,微服务系统添加了越来越多的组件,这些组件通过一个或多个消息代理间接相互连接。与单个应用程序相比,维护、诊断和调试此类系统更困难;这就是我们之前讨论的操作复杂性。然而,容器可以帮助部署和维护此类系统。从 ASP.NET Core 3.0 开始,ASP.NET Core 团队投入了大量精力进行分布式跟踪。分布式跟踪对于查找从程序到程序(如微服务)流动的事件相关的故障和瓶颈是必要的。如果出现问题,追踪用户的行为以隔离错误、重现它并修复它非常重要。独立组件越多,使这种跟踪变得越困难。这超出了本书的范围,但如果你计划利用微服务,这是一个需要考虑的问题。
优势和潜在风险
本节探讨了使用 CQRS 模式分离数据存储的读和写操作的一些优势和风险。
CQRS 模式的好处
-
可伸缩性: 由于读和写工作负载可以独立扩展,CQRS 可以导致基于分布式云或微服务的应用程序具有更高的可伸缩性。
-
简化和优化模型: 它将读模型(查询责任)和写模型(命令责任)分开,这简化了应用程序开发并可以优化性能。
-
灵活性: 不同的模型增加了可以选择的数量,增加了灵活性。
-
增强性能: CQRS 可以防止不必要的数据库访问,并允许为每个任务选择优化的数据库,从而提高读和写操作的性能。
-
提高效率: 它允许在复杂应用程序上进行并行开发,因为团队可以在应用程序的独立读和写两侧独立工作。
使用 CQRS 模式的潜在风险
-
复杂性: CQRS 增加了系统的复杂性。它可能不是简单的 CRUD 应用程序所必需的,并且可能会不必要地使应用程序过于复杂。因此,建议仅在复杂系统中使用 CQRS,并且当优势超过劣势时才使用。
-
数据一致性: 由于读模型的更新是异步的,这可能会引入读和写之间的最终一致性问题和业务需求不匹配。
-
增加的开发工作量: CQRS 可能意味着由于处理两个独立的模型和更多组件,开发、测试和维护工作量的增加。
-
学习曲线: 该模式有其自己的学习曲线。对 CQRS 模式不熟悉的团队成员需要培训并获得一些经验。
-
同步挑战: 在高数据量情况下,保持读模型和写模型之间的同步可能具有挑战性。
结论
CQRS 有助于划分查询和命令,并有助于封装和独立隔离每一块逻辑。将这个概念与无服务器计算或微服务架构相结合,使我们能够独立扩展读取和写入。我们还可以使用不同的数据库,赋予我们所需的工具,以满足该系统各部分所需的传输速率(例如,频繁的写入和偶尔的读取或反之亦然)。像 Azure 和 AWS 这样的主要云提供商提供无服务器服务来支持此类场景。每个云提供商的文档都应该能帮助你入门。同时,对于 Azure,我们有 Azure Functions、Event Grid、Event Hubs、Service Bus、Cosmos DB 等。Azure 还提供了不同服务之间的绑定,这些服务由事件触发或响应,为你移除了一部分复杂性,但同时也将你锁定在该供应商上。现在,让我们看看 CQRS 如何帮助我们遵循SOLID原则在云规模上:
-
S: 将应用程序划分为更小的读取和写入应用程序(或函数)倾向于将单一责任封装到不同的程序中。
-
O: 将 CQRS 与无服务器计算或微服务相结合,可以帮助我们扩展软件,而无需通过添加、删除或替换应用程序来修改现有代码。
-
L: 无需操作
-
I: CQRS 使我们能够创建多个小型接口(或程序),其中命令和查询之间有明确的区分。
-
D: 无需操作
探索微服务适配器模式
微服务适配器模式允许添加缺失的功能,将一个系统适配到另一个系统,或将现有应用程序迁移到事件驱动架构模型,仅举几个可能性。微服务适配器模式类似于我们在第九章“结构型模式”中介绍的适配器模式,但应用于使用事件驱动架构的微服务系统,而不是创建一个类来适配一个对象到另一个签名。在本节中我们讨论的场景中,以下图中表示的微服务系统也可以被一个独立的应用程序所替代;这个模式适用于各种程序,而不仅仅是微服务,这就是为什么我抽象掉了细节:

图 19.33:后续示例中使用的微服务系统表示
下面是我们接下来要讨论的示例和此模式可能的用法:
-
将现有系统适配到另一个系统。
-
停用遗留应用程序。
-
将事件代理适配到另一个系统。
让我们先从一个独立系统连接到一个事件驱动系统开始。
将现有系统适配到另一个系统
在这种情况下,我们有一个现有系统,我们无法控制其源代码或者不想对其进行更改,并且我们有一个围绕事件驱动架构模型构建的微服务系统。只要我们能访问事件代理,我们也不必控制微服务系统的源代码。以下是一个表示此场景的图示:

图 19.34:一个与事件代理和未连接到微服务的现有系统交互的微服务系统
如前图所示,现有系统与微服务和代理断开连接。为了使现有系统适应微服务系统,我们必须订阅或发布某些事件。让我们看看如何从微服务(订阅代理)读取数据,然后将该数据更新到现有系统中。当我们控制现有系统的代码时,我们可以打开源代码,订阅一个或多个主题,并从那里更改行为。在我们的情况下,我们不想这样做或者不能这样做,因此我们无法直接订阅主题,如下面的图示所示:

图 19.35:连接现有系统到事件驱动型系统的缺失功能
这就是微服务适配器发挥作用并允许我们填补现有系统能力差距的地方。为了添加缺失的环节,我们创建了一个订阅适当事件的微服务,然后在此处应用对现有系统的更改,如下所示:

图 19.36:一个适配器微服务向现有系统添加缺失的功能
如前图所示,适配器微服务获取事件(订阅一个或多个主题),然后使用来自微服务系统的数据在现有系统上执行一些业务逻辑。在这个设计中,新的适配器微服务使我们能够向一个我们无法控制的系统中添加缺失的功能,同时对用户日常活动的影响很小或没有。示例假设现有系统具有某种形式的可扩展机制,如 API。如果系统没有,我们就需要更具创造性来与之接口。例如,微服务系统可能是一个电子商务网站,而现有系统可能是一个遗留的库存管理系统。适配器可以更新遗留系统中的新订单数据。现有系统也可能是你希望当微服务应用程序的用户执行某些操作时(如更改电话号码或地址)进行更新的旧客户关系管理(CRM)系统。可能性几乎是无限的;你创建一个事件驱动系统和你不控制或不想改变的现实系统之间的链接。在这种情况下,微服务适配器使我们能够通过扩展系统而不改变现有部分来遵循开闭原则。主要的缺点是我们正在部署另一个与现有系统直接耦合的微服务,这可能最适合临时解决方案。沿着这个思路,接下来,我们用一个新的应用程序替换遗留应用程序,尽量减少停机时间。
退役遗留应用程序
在这种情况下,我们有一个要退役的遗留应用程序和一个我们想要连接一些现有功能的微服务系统。为了实现这一点,我们可以创建一个或多个适配器,将所有功能和依赖项迁移到新的模型。以下是我们的系统当前状态的表示:

图 19.37:原始遗留应用程序及其依赖项
前面的图显示了两个不同的系统,包括我们想要退役的遗留应用程序。另外两个应用程序,依赖项 A 和 B,直接依赖于遗留应用程序。确切的迁移流程强烈依赖于你的用例。如果你想保留依赖项,我们希望首先迁移它们。为此,我们可以创建一个事件驱动的适配器微服务,像这样打破依赖项和遗留应用程序之间的紧密耦合:

图 19.38:添加一个实现事件驱动流程的微服务适配器,以打破依赖项和遗留应用程序之间的紧密耦合
上一张图表显示了使用事件代理进行通信的Adapter微服务和微服务系统中的其余部分。正如我们在上一个示例中所探讨的,适配器被放置在那里以连接遗留应用和微服务。我们的场景专注于移除遗留应用并迁移其两个依赖项。在这里,我们使用适配器划出了所需的能力,使我们能够将依赖项迁移到事件驱动模型,并打破与遗留应用的紧密耦合。这种迁移可以分多步进行,逐个迁移每个依赖项,我们甚至可以为每个依赖项创建一个适配器。为了简化,我选择只画一个适配器。如果你的依赖项很大或很复杂,你可能需要重新考虑这个选择。一旦我们完成了依赖项的迁移,我们的系统看起来如下:

图 19.39:依赖项现在正在使用事件驱动架构,适配器微服务正在弥合事件和遗留系统之间的差距
在上一张图表中,适配器微服务执行了两个依赖项之前对遗留应用 API 的操作。现在,依赖项正在发布事件而不是使用 API。例如,当DependencyB中发生操作时,它会向代理发布一个事件。适配器微服务接收到该事件,并针对 API 执行原始操作。这样做增加了复杂性,是一种临时状态。有了这种新的架构,我们可以开始将现有功能从遗留应用迁移到新应用,而不会影响依赖项;我们打破了紧密耦合。
从现在开始,我们正在应用Strangler Fig模式,逐步将遗留系统迁移到我们的新架构中。为了简化,你可以将 Strangler Fig 模式视为逐个迁移功能从一个应用到另一个应用。在这种情况下,我们用一个应用替换了另一个应用,但我们也可以使用相同的模式将一个应用拆分成多个更小的应用(如微服务)。
我在“进一步阅读”部分留下了一些链接,以防迁移遗留系统是你正在做的事情,或者只是想了解更多关于该模式的信息。
下面的图表是一个视觉表示,它将我们正在构建的、用于替换遗留应用的现代应用添加进去。那个新的现代应用也可能是一个你正在部署的购买产品;我们正在探讨的概念适用于这两种用例,但具体的步骤与所涉及的技术直接相关。

图 19.40:通过将功能迁移到那个新应用,用于替换旧应用的现代应用开始显现
在前面的图中,我们看到新的现代应用已经出现。每次我们将新功能部署到新应用中,我们都可以将其从适配器中移除,从而在两种模型之间实现优雅的过渡。同时,我们保留旧应用以继续提供尚未迁移的功能。一旦我们想要保留的所有功能都已迁移,我们就可以移除适配器并淘汰旧应用,从而得到以下系统:

图 19.41:在淘汰了旧应用后,新的系统拓扑结构,展示了新的现代应用及其两个松散耦合的依赖项
前面的图示显示了新的系统拓扑,包括一个新现代应用和现在通过事件驱动架构松散耦合的两个原始依赖项。当然,迁移规模越大,就越复杂,耗时也越长,但适配器微服务模式是帮助从一个系统部分或完全迁移到另一个系统的一种方式。就像前面的例子一样,主要优势是添加或删除功能而不会影响其他系统,这使我们能够迁移并打破不同依赖项之间的紧密耦合。缺点是这种临时解决方案增加了复杂性。此外,在迁移步骤中,你很可能会需要按正确顺序部署现代应用和适配器,以确保两个系统不会处理相同的事件两次,从而导致重复更改。例如,将电话号码更新为相同的值两次应该是可以的,因为它会导致相同的最终数据集。然而,创建两个记录而不是一个可能更重要,因为这可能导致数据集中的完整性错误。例如,创建一个在线订单两次而不是一次可能会引起客户不满或内部问题。就这样,我们使用微服务适配器模式淘汰了一个系统,而没有破坏其依赖项。接下来,我们看看一个物联网(IoT)的例子。
将事件代理适配到另一个
在这个场景中,我们正在将一个事件代理适配到另一个。在下面的图中,我们查看两个用例:一个将事件从代理 B 翻译到代理 A(左侧)和另一个将事件从代理 A 翻译到代理 B(右侧)。之后,我们探索一个更具体的例子:

图 19.42:一个适配器微服务,将事件从代理 B 转换为代理 A(左侧)以及从代理 A 转换为代理 B(右侧)
我们可以在前面的图中看到两种可能的流程。左侧的第一种流程允许适配器从代理 B 读取事件并将其发布到代理 A。右侧的第二种流程使适配器能够从代理 A 读取事件并将其发布到代理 B。这些流程使我们能够通过利用微服务适配器模式将事件从一种代理转换到另一种代理。
在图 16.35中,每个流程都有一个适配器。我这样做是为了使两个流程尽可能独立,但适配器可以是单个微服务。
这种模式对于物联网系统非常有用,其中您的微服务在内部使用 Apache Kafka 的完整功能的事件流能力,但使用 MQTT 与连接到系统的低功耗物联网设备进行通信。适配器可以通过将消息从一种协议转换为另一种协议来解决此问题。以下是一个表示完整流程的图,包括设备和微服务:

图 19.43:完整的协议适配器流程,包括设备和微服务
在我们探索事件可能是什么之前,让我们逐步探索这两个流程。左侧的流程允许通过以下顺序从设备获取系统内部的事件:
-
一个设备向 MQTT 代理发布事件。
-
适配器读取该事件。
-
适配器向 Kafka 代理发布类似或不同的事件。
-
零个或多个订阅了事件的微服务对其执行操作。
另一方面,右侧流程允许通过以下顺序将事件从系统中输出到设备:
-
一个微服务向 Kafka 代理发布事件。
-
适配器读取该事件。
-
适配器向 MQTT 代理发布类似或不同的事件。
-
零个或多个订阅了事件的设备对其执行操作。
你不必实现两种流程;适配器可以是双向的(支持两种流程),我们可以有两个单向适配器,支持其中一种流程,或者我们可以允许通信单向流动(仅入或出,但不双向)。选择与你的具体使用案例相关。从设备向微服务发送消息的具体例子(左侧流程)可能是发送其 GPS 位置、状态更新(灯现在亮了)或指示传感器故障的消息。向设备发送消息的具体例子(右侧流程)可能是远程控制扬声器的音量、打开灯或发送确认消息已被接收。在这种情况下,适配器不是一个临时解决方案,而是一个永久性功能。我们可以利用这样的适配器以最小的系统影响创建额外的功能。主要的缺点是部署一个或多个其他微服务,但你的系统和流程可能足够强大,足以在利用这些功能时处理这种额外的复杂性。这种利用微服务适配器的第三种场景是我们的最后一个场景。希望这足以激发你的想象力,利用这个简单而强大的设计模式。
结论
我们探讨了微服务适配器模式,该模式允许我们通过适配一个元素以适应另一个元素来连接系统的两个元素。我们探讨了如何将信息从事件代理推送到不支持此类功能现有系统。我们还探讨了如何利用适配器来打破紧密耦合,将功能迁移到较新的系统,以及无缝退役遗留应用程序。最后,我们通过适配器微服务连接了两个事件代理,允许低功耗的物联网设备在不耗尽电池和避免使用更复杂通信协议带来的复杂性情况下与微服务系统通信。这种模式非常强大,可以以多种方式实现,但一切都取决于具体的使用案例。你可以使用无服务器产品如 Azure 函数、无代码/低代码产品如 Power Automate 或 C#来编写适配器。当然,这些只是几个例子。设计正确系统的关键是明确问题陈述,因为一旦你知道你试图解决的问题,解决方案就会变得清晰。现在,让我们看看微服务适配器模式如何帮助我们以云规模遵循SOLID原则:
-
S:微服务适配器有助于管理长期或短期责任。例如,添加一个在两种协议之间进行转换的适配器或创建一个临时适配器来退役遗留系统。
-
O:你可以利用微服务适配器动态添加或删除功能,而不会对系统造成影响或造成有限的影响。例如,在物联网场景中,我们可以添加对 AMQP 等新协议的支持,而无需更改系统的其余部分。
-
L:N/A
-
我:添加较小的适配器可以使更改更容易且风险更低,比更新大型遗留应用程序更佳。正如我们在遗留系统退役场景中所见,我们还可以利用临时适配器将大型应用程序拆分成更小的部分。
-
D:微服务适配器反转了被适配系统之间的依赖关系流。例如,在遗留系统退役场景中,适配器通过利用事件代理,将两个依赖项到遗留系统的流向反转。
摘要
微服务架构与本书中我们所涵盖的以及我们构建单体应用的方式都不同。我们不是将一个大型应用作为一个整体,而是将其拆分为多个更小的应用,这些应用被称为微服务。微服务必须相互独立;否则,我们将面临与紧密耦合的类相关联的相同问题,但这些问题是在云规模上出现的。我们可以利用发布-订阅设计模式来松散耦合微服务,同时通过事件将它们连接起来。消息代理是负责派发这些消息的程序。我们可以使用事件溯源在任意时间点重新创建应用程序的状态,包括在启动新容器时。我们可以使用应用程序网关来保护客户端免受微服务集群复杂性的影响,并公开仅暴露服务的一部分。我们还探讨了如何基于 CQRS 设计模式来解耦相同实体的读写操作,从而允许我们独立扩展查询和命令。我们还探讨了使用无服务器资源来创建这种类型的系统。最后,我们探讨了微服务适配器模式,该模式允许我们适应两个系统,退役一个遗留应用程序,并连接两个事件代理。这种模式简单但强大,能够在松散耦合的方式下反转两个依赖项之间的依赖关系流。该模式的使用可以是临时的,就像我们在遗留应用程序退役场景中看到的那样,也可以是永久的,就像我们在物联网场景中看到的那样。另一方面,微服务是有代价的,并不打算取代所有现有的东西。对于许多项目来说,从单体开始并随着扩展将其迁移到微服务仍然是一个好主意。从单体开始,并在扩展时迁移到微服务,这也是另一种解决方案。这使我们能够更快地开发应用程序(单体)。与向微服务应用程序添加新功能相比,向单体添加新功能更容易。大多数情况下,错误在单体中比在微服务应用程序中成本更低。您还可以规划您未来的微服务迁移,这将带来两全其美的结果,同时保持操作复杂性较低。例如,我们可以在单体中利用发布-订阅模式通过 MediatR 通知,并在迁移系统到微服务架构时(如果需要的话)将事件派发责任迁移到消息代理。我们在探索如何组织我们的单体在第二十章,模块化单体。我不想你放弃微服务架构,但我想确保你在盲目跳入之前权衡这种系统的利弊。您的团队的技术水平和学习新技术的能力也可能影响跳入微服务之船的成本。DevOps(开发[Dev]和 IT 运营[Ops])或DevSecOps(在 DevOps 中添加安全[Sec]),我们在书中没有涉及,但在构建微服务时是必不可少的。它带来了部署自动化、自动质量检查、自动组合等。没有这些,您的微服务集群将很难部署和维护。当您需要扩展、想要无服务器或在不同团队之间划分责任时,微服务很棒,但请记住运营成本。在下一章中,我们将结合微服务和单体世界。
问题
让我们看看几个练习题:
-
消息队列和发布-订阅模型之间最显著的区别是什么?
-
什么是 事件溯源?
-
应用程序网关可以既是路由网关又是聚合网关吗?
-
真实的 CQRS 是否需要无服务器云基础设施?
-
使用 BFF 设计模式有什么显著优势?
进一步阅读
这里有一些链接,可以帮助你构建本章所学的内容:
-
马丁·福勒的 Event Sourcing 模式:
adpg.link/oY5H -
微软提供的事件溯源模式:
adpg.link/ofG2 -
微软提供的发布-订阅模式:
adpg.link/amcZ -
微软提供的事件驱动架构:
adpg.link/rnck -
microservices.io 上的微服务架构和模式:
adpg.link/41vP -
马丁·福勒提供的微服务架构和模式:
adpg.link/Mw97 -
微服务架构和模式,由微软提供:
adpg.link/s2Uq -
RFC 6902(JSON Patch):
adpg.link/bGGn -
ASP.NET Core web API 中的 JSON Patch:
adpg.link/u6dw
Strangler Fig 应用程序模式:
-
马丁·福勒:
adpg.link/Zi9G
答案
-
消息队列接收到消息并有一个唯一的订阅者将其出队。如果没有东西出队,消息将无限期地留在队列中(FIFO 模式)。发布-订阅模型接收到消息并将其发送到零个或多个订阅者。
-
事件溯源是将系统发生的事件按时间顺序积累的过程,而不是在实体的当前状态中持久化。它允许你通过重放这些事件来重新创建实体的状态。
-
是的,你可以混合使用网关模式(或子模式)。
-
不,如果你想的话,可以在本地部署微应用程序(微服务)。
-
它将通用功能与应用特定功能分离,促进代码的整洁和模块化。它也有助于简化前端。
第二十章:20 模块化单体
在您开始之前:加入我们的 Discord 书友社区
直接向作者本人提供反馈,并在我们的 Discord 服务器上与其他早期读者聊天(在“architecting-aspnet-core-apps-3e”频道下找到“EARLY ACCESS SUBSCRIPTION”)。

在不断演变的软件开发领域中,选择正确的架构就像为建筑奠定基础。架构决定了软件的结构,影响着其可扩展性、可维护性和整体成功。传统的单体架构和微服务长期以来一直是主导范式,各有其优势和挑战。然而,一种新的架构风格正在获得关注——模块化单体。这种方法旨在通过结合单体和微服务的简单性与灵活性,提供两者的最佳结合。它作为一个中间地带,解决了与微服务相关的一些复杂性,使其特别适合小型到中型项目或从传统单体架构过渡的团队。
我在 2017 年写了一篇关于这个主题的文章,标题为《微服务聚合》。我最近看到了“模块化单体”这个名字,觉得它更好。这种架构风格正在获得关注,但它并不完全新颖。模块化单体风格是一种模块化和组织应用程序的方法,降低了我们创建一团糟的可能性。
本章旨在全面了解模块化单体。我们深入探讨其核心原则、优势以及关键组件,并探讨何时以及如何实施这种架构。我们基于我们的纳米电子商务应用,以获得模块化单体在实际应用中的实践经验。此外,我们讨论了它们与其他架构风格的比较,以帮助您为您的下一个项目做出明智的决定。在本章结束时,您应该对模块化单体有一个牢固的理解,了解为什么它们可能是您项目的正确选择,以及如何实施它们。在本章中,我们将涵盖以下主题:
-
什么是模块化单体?
-
模块化单体的优势
-
模块化单体的关键组件
-
实施模块化单体
-
项目—模块化单体
-
向微服务过渡
-
挑战与陷阱
让我们从探讨什么是模块化单体开始。
什么是模块化单体?
模块化单体是一种旨在结合传统单体架构和微服务的最佳特性的架构风格。它将软件应用程序组织成定义良好、松散耦合的模块。每个模块负责特定的业务能力。然而,与微服务不同,所有这些模块都作为一个单一单元部署,就像单体一样。模块化单体的核心原则是:
-
将每个模块视为一个微服务。
-
将应用程序作为单一单元部署。
下面是成功微服务的基本原则,这些原则在第十九章,微服务架构简介中进行了研究:
-
每个微服务应该是一个业务上的统一单元。
-
每个微服务应该拥有自己的数据。
-
每个微服务应该独立于其他微服务。
简而言之,我们得到了两者的最佳结合。然而,了解模块化单体与其他架构风格相比如何,对于做出明智的决定至关重要。
传统单体是什么?
在传统的单体架构中,我们将应用程序构建为一个单一、不可分割的单元。这导致功能紧密耦合在一起,使得更改或扩展特定功能变得困难。这种方法使得创建一个大泥球更容易,尤其是在团队在开发和开发前后的领域建模和分析投入很少努力的情况下。此外,尽管这种方法简单直接,但它缺乏更现代架构的灵活性和可扩展性。
一个单体不一定是不可分割的,然而大多数最终都变成了这样,因为在一个单一应用程序中创建紧密耦合很容易。
让我们接下来看看微服务。
微服务是什么?
相反,微服务架构将模块化推向了极致。每个服务都是一个完全独立的单元,在自己的环境中运行。微服务架构允许高度的可扩展性和灵活性,但代价是增加了操作复杂性。
第十九章,微服务架构简介更深入地探讨了这一主题。
以下章节更深入地探讨了这种新兴的架构风格,从其优势开始。
模块化单体的优势
模块化单体最优秀的一点是它们易于管理。你不必担心像微服务那样有许多移动部件。所有东西都在一个地方,但仍然被分割成模块。这使得我们更容易跟踪事物并与它们一起工作。在模块化单体中,每个模块就像是一个独立的小项目。我们可以测试、修复或改进一个模块,而不会影响其他模块。这很好,因为它让我们一次专注于一件事情,这通过降低工作在该功能上所需的认知负荷来提高生产力。当是时候发布软件时,我们只有一个应用程序需要部署。尽管它有许多模块,但我们把它们当作一个可部署的单元来处理。这使得管理部署变得更加直接,因为我们不需要像处理微服务那样处理多个服务。模块化单体可以为我们节省金钱,因为我们不需要像部署单个单体那样多的资源。正因为如此,我们不需要一个团队来管理和运行复杂的基础设施。我们不必担心服务之间的分布式跟踪,这减少了前期监控成本。这种部署方式在用小型团队开始项目或团队不熟悉微服务架构时非常有用。
即使你是大型团队或大型组织的一部分,或者有微服务架构的经验,模块化单体仍然很有价值。这并不是一种或另一种场景。
正如我们刚才探讨的,模块化单体带来了许多优势:
-
减少了操作复杂性,使得将复杂应用程序作为一个单一单元部署比微服务更容易。
-
它们通过其简单性改善了我们的开发和测试体验,提高了我们的效率。
-
它们比大多数传统的单体应用更容易管理,因为模块被很好地隔离了。
-
它们比微服务更经济高效。
以下部分将探讨构成模块化单体的是什么。
模块化单体的关键组件
关于模块化单体,首先要知道的是,它们由称为模块的不同部分组成。每个模块就像一个迷你应用程序,执行特定的任务。这项任务与特定的业务能力相关。业务能力来自业务领域,旨在针对一组连贯的场景。这样的组在领域驱动设计中被称为边界上下文。将每个模块视为一个微服务或定义良好的领域块。这种分离意味着完成特定任务所需的一切都在一个模块中,这使得我们更容易理解和处理软件,因为我们不需要从一个地方跳到另一个地方来完成事情。因此,如果一个模块需要更新或出现问题,我们可以修复它而不会影响到其他模块。这种隔离对于测试也非常完美,因为我们可以在隔离的情况下测试每个模块,以确保其正常工作。例如,一个模块可能处理购物车,而另一个模块则负责处理运输,但我们将这些模块拼接到一个最终聚合的应用程序中。
我们可以将模块化单体作为一个单一的项目来创建。然而,在.NET 特定的环境中,当每个模块包含一个或多个程序集时,创建模块之间的不必要耦合会更加困难。我们将在即将构建的项目中探讨这一点。
模块聚合器——单体——负责加载和提供模块,就像它们只是一个应用程序一样。尽管每个模块都是独立的,但它们有时仍然需要相互通信。例如,产品目录模块可能需要通知购物车模块,员工已将新产品添加到目录中。有许多方法可以实现这种通信。保持模块之间低耦合水平的一种最佳方法是通过利用事件驱动架构。在松散耦合的基础上,这还打开了诸如通过将一个或多个模块迁移到微服务架构来扩展模块化单体的大门;关于这一点,稍后还会详细介绍。以下是一个表示这些关系的图:

图 20.1:表示模块聚合器、模块和事件代理之间关系的图
既然我们已经探讨了模块化单体的关键组件,下一节将讨论其规划和实现。
实现模块化单体
在构建模块化单体之前进行规划至关重要。我们必须考虑每个模块的功能以及模块如何协同工作。一个好的计划有助于我们避免以后出现问题。选择合适的工具来创建精简的堆栈也是至关重要的。好消息是,我们不需要定义一个大的共享堆栈,因为每个模块都是独立的。就像垂直切片架构中的一个切片一样,每个模块可以确定其模式和数据源。然而,我们必须定义一些共同元素,以成功组装模块化单体。以下是一些考虑事项,以提高模块化单体成功的可能性:
-
模块共享一个 URL 空间。
-
模块共享配置基础设施。
-
模块共享单个依赖注入对象图(一个容器)。
-
模块共享模块间通信的基础设施(事件代理)。
我们可以使用模块名称作为区分符来减轻前两个元素。例如,使用 /{"模块名称"}/{模块空间} URI 空间会产生以下结果(products、baskets 和 customers 是模块):
-
/products -
/products/123 -
/baskets -
/customers
使用模块名称作为配置的最高级键也使得避免冲突变得容易,如 {模块名称}:{键},或者如下面的 JSON(例如来自 appsettings.json 文件):
"{module name}": {
"{key}": "Module configs"
}
我们可以通过以某种方式管理共享代码来减轻最后两个元素。例如,限制共享代码的数量可以减少模块之间冲突的机会。然而,多个模块全局配置 ASP.NET Core 或第三方库可能会导致冲突;将这些配置集中到聚合器中,并将它们视为约定,将有助于减轻大多数问题。跨切面关注点,如异常处理、JSON 序列化、日志记录和安全,是这种方法的理想候选者。最后,共享模块间通信的单一种方式并在聚合器中配置它将有助于减轻通信问题。让我们开始规划项目。
规划项目
规划是任何软件的关键部分。没有计划,你增加构建错误东西的机会。计划并不能保证成功,但它提高了你的机会。过度规划是另一面。它可能导致分析瘫痪,这意味着你可能永远无法交付你的项目,或者它将花费你如此长的时间来交付,以至于它将是一个错误的产品,因为需求在途中发生了变化,而你没有适应。以下是一个规划模块化单体的高级方法。你不必按顺序执行所有步骤。你可以迭代地执行许多步骤,或者多个人或团队甚至可以并行工作:
-
分析和建模领域。
-
识别并设计模块。
-
识别模块之间的交互并设计覆盖这些交互的集成事件。
一旦我们完成规划,我们就可以开发和操作应用程序。以下是一些高级步骤:
-
在隔离状态下构建和测试模块。
-
构建和测试集成一个或多个模块的模块聚合器应用程序。
-
部署、操作和监控单体。
实施模块化单体,就像任何程序一样,是一个逐步的过程。我们规划、构建、测试,然后部署它。每个部分本身都足够简单,当我们把它们拼在一起时,我们得到一个易于维护的系统。即使持续改进应用程序、细化分析和模型随着时间的推移会产生最佳结果,但在开始之前对高级领域——模块——有一个好的理解,以及至少对它们之间交互的模糊看法,将有助于避免错误,并可能避免进一步的重大重构。以下是此过程的通用表示:
∞
图 20.2:模块化单体阶段的敏捷和 DevOps 部分视图
接下来,我们规划我们的纳米电子商务应用程序。
分析领域
在我们继续迭代我们在第十八章和第十九章中构建的纳米电子商务应用程序时,领域分析将会非常简短。此外,我们不会将应用程序扩展到产品和服务车之外,因为应用程序已经太大,无法放入单个章节。当然,这次,我们将其构建为模块化单体。以下是高级实体及其关系:

图 20.3:我们的纳米电子商务应用程序的高级实体及其关系
如图表所示,我们有一个Product实体,它可以从更多细节中受益,如类别,因此有...框。我们还有一个BasketItem实体,我们用它来将人们的购物车保存到数据库中。最后,一个Customer实体代表拥有购物车的人。
尽管我们没有实现
Customer类,但客户概念上通过CustomerId属性存在。
接下来,我们将这个领域子集拆分为模块。
识别模块
现在我们已经确定了实体,是时候将它们映射到模块中。让我们从以下图表开始:

图 20.4:模块(边界上下文)分离和实体关系。
如预期的那样,我们有一个产品目录、购物车和客户管理模块。这里的新颖之处在于实体之间的关系。目录主要管理 Product 实体,但购物车需要了解它才能操作。同样的逻辑也适用于 Customer 实体。在这种情况下,购物车只需要知道每个实体的唯一标识符,但另一个模块可能需要更多信息。基于这个高级视图,我们需要创建三个模块。在我们的案例中,我们继续使用两个模块。在实际应用中,我们会拥有超过三个模块,因为我们还需要管理购买、运输、库存等。让我们看看模块之间的交互。
识别模块之间的交互
根据我们的分析和限于我们正在构建的两个模块,购物车模块需要了解产品。以下是我们的 BasketItem 类:
public record class BasketItem(int CustomerId, int ProductId, int Quantity);
前面的类显示我们只需要了解独特的商品标识符。因此,以事件驱动的思维方式,购物车模块希望在以下情况下得到通知:
-
创建一个产品。
-
一个产品被删除。
通过这两个事件,购物车模块可以管理其产品缓存,并只允许客户将现有商品添加到他们的购物车中。当商品不可用时,它还可以从客户的购物车中移除商品。以下是一个表示这些流程的图表:

图 20.5:目录模块和购物车模块之间的集成事件流。
现在我们已经分析了领域和模块,在构建任何东西之前,让我们定义我们的技术栈。
定义我们的技术栈
我们知道我们正在使用 ASP.NET Core 和 C#。我们继续利用最小化 API,但 MVC 也能实现同样的效果。我们还继续利用 EF Core、ExceptionMapper、FluentValidation 和 Mapperly。但模块和其他项目的共享方面怎么办?让我们看看。
模块结构
我们使用的是灵活且直接的模块结构。你可以以任何你喜欢的方式组织你的项目;这不是一种规定的方法。例如,你可以从其他架构风格中汲取灵感,如 Clean Architecture,或者根据自己的经验、背景和工作环境发明自己的架构。在我们的案例中,我选择了以下目录结构:
-
applications目录包含可部署的应用程序,如聚合器。我们可以在该目录中添加用户界面或其他可部署的应用程序,如第十九章中构建的 BFF。每个应用程序都包含在其自己的子目录中。 -
modules目录包含模块,每个模块都在自己的子目录中。 -
shared目录包含共享项目。
在现实世界的软件中,我们可以扩展这个设置,并添加
infrastructure、docs和pipelines目录来存储我们的基础设施即代码(IaC)、文档和 CI/CD 流水线,这些都与我们的代码相邻。
我喜欢这种由单一代码库启发的结构,因为每个模块和应用程序都是自包含的。例如,聚合器的 API、合约和测试都紧挨在一起:

图 20.6:聚合器的目录和项目层次结构。
模块的组织方式类似:

图 20.7:模块的子目录和目录的项目层次结构。
我保留了 REPR 前缀,因为它基于第十八章的代码,但我稍微改变了代码结构。在这个版本中,我废除了嵌套类,并为每个文件创建了一个类。这遵循了更经典的.NET 约定,并允许我们将 API 合约提取到另一个程序集。如果你记得,在第十九章中,bff 项目引用了两个 API 以重用它们的Query、Command和Response合约。我们通过这个解决方案中的Contracts类库项目解决这个问题。
为什么这是个问题?bff 依赖于所有微服务,包括它们的逻辑和依赖。这是一个引入不必要耦合的配方。此外,由于它继承所有依赖项,它增加了其部署大小和脆弱性表面;更多的依赖和代码意味着恶意行为者找到可利用漏洞的可能性更大。
在 API 合约之上,Contracts项目还包含了集成事件。如果应用程序更大,我们可以将 API 合约和集成事件分开;在这种情况下,我们只有两个集成事件。设计选择必须根据当前项目和上下文来考虑。接下来,让我们探索 URI 空间。
URI 空间
这个应用程序的模块遵循之前讨论的 URI 空间:/{module name}/{module space}。每个模块在其根目录都有一个Constants文件,看起来像这样:
namespace REPR.Baskets;
public sealed class Constants
{
public const string ModuleName = nameof(Baskets);
}
我们在{module name}ModuleExtensions文件中使用ModuleName常量来设置 URI 前缀,并像这样标记端点:
namespace REPR.Baskets;
public static class BasketModuleExtensions
{
public static IEndpointRouteBuilder MapBasketModule(this IEndpointRouteBuilder endpoints)
{
_ = endpoints
.MapGroup(Constants.ModuleName.ToLower())
.WithTags(Constants.ModuleName)
.AddFluentValidationFilter()
// Map endpoints
.MapFetchItems()
.MapAddItem()
.MapUpdateQuantity()
.MapRemoveItem()
;
return endpoints;
}
}
在此基础上,这两个模块都会在正确的 URI 空间中自我注册。
我们可以用许多不同的方式应用这些类型的约定。在这种情况下,我们选择了简单性,这是最容易出现错误的,将责任留给了每个模块。如果我们有更面向框架的思维模式,我们可以创建一个强类型模块合约,它会被自动加载,就像一个
IModule接口。聚合器也可以创建根组并强制执行 URI 空间。
接下来,我们探索数据空间。
数据空间
由于我们遵循微服务架构原则,并且每个模块应该拥有自己的数据,我们必须找到一种确保我们的数据上下文不会冲突的方法。项目使用 EF Core 内存提供程序进行本地开发。对于生产环境,我们计划使用 SQL Server。确保我们的 DbContext 类之间不冲突的一个很好的方法是为每个上下文创建一个数据库架构。每个模块有一个上下文,所以每个模块一个架构。我们不必过度思考;我们可以重用与 URI 相同的想法,并利用模块名称。因此,每个模块将把其表分组在 {模块名称} 架构下,而不是 dbo(SQL Server 的默认架构)。
我们可以在 SQL Server 中为每个架构应用不同的安全规则和权限,因此我们可以通过扩展这个方法来构建一个非常安全的数据库模型。例如,我们可以使用具有最小权限的多个用户,在模块中使用不同的连接字符串等。
在代码中,这样做是通过在每个 DbContext 的 OnModelCreating 方法中设置默认架构名称来体现的。以下是一个 ProductContext 类的例子:
namespace REPR.Products.Data;
public class ProductContext : DbContext
{
public ProductContext(DbContextOptions<ProductContext> options)
: base(options) { }
protected override void OnModelCreating(ModelBuilder modelBuilder)
{
base.OnModelCreating(modelBuilder);
modelBuilder.HasDefaultSchema(Constants.ModuleName.ToLower());
}
public DbSet<Product> Products => Set<Product>();
}
上述代码使所有 ProductContext 的表都成为 products 架构的一部分。然后我们为购物车模块做同样的处理:
namespace REPR.Baskets.Data;
public class BasketContext : DbContext
{
public BasketContext(DbContextOptions<BasketContext> options)
: base(options) { }
public DbSet<BasketItem> Items => Set<BasketItem>();
public DbSet<Product> Products => Set<Product>();
protected override void OnModelCreating(ModelBuilder modelBuilder)
{
base.OnModelCreating(modelBuilder);
modelBuilder.HasDefaultSchema(Constants.ModuleName.ToLower());
modelBuilder
.Entity<BasketItem>()
.HasKey(x => new { x.CustomerId, x.ProductId })
;
}
}
上述代码使所有 BasketContext 的表都成为 baskets 架构的一部分。由于架构的存在,这两个上下文都不会相互阻碍。但是等等!这两个上下文都有一个 Products 表;那么会发生什么呢?目录模块使用 products.products 表,而购物车模块使用 baskets.products 表。不同的架构,不同的表,问题解决!
我们可以将这些概念应用到不仅仅是模块化单体架构上,因为它是一般性的 SQL Server 和 EF Core 知识。
如果你使用的是不提供架构或 NoSQL 数据库的其他关系型数据库引擎,你也必须考虑这一点。每个 NoSQL 数据库都有不同的数据思考方式,在这里不可能全部涵盖。重要的是要找到一个区分器,将模块的数据分开。在最极端的情况下,甚至可以每个模块一个不同的数据库;然而,这会增加应用程序的操作复杂性。接下来,我们探讨消息代理。
消息代理
为了处理目录模块和购物车模块之间的集成事件,我决定选择 MassTransit。引用他们的 GitHub 项目:
MassTransit 是一个免费的、开源的分布式应用程序框架,适用于 .NET。MassTransit 使得创建利用基于消息的、松散耦合的异步通信的应用程序和服务变得容易,从而提高了可用性、可靠性和可伸缩性。
我选择 MassTransit,因为它是一个拥有 5,800 GitHub 星标的流行项目,截至 2023 年支持许多提供者,包括内存中的。此外,它提供了许多超出我们需求的功能。再一次,我们可以使用任何东西。例如,MediatR 也可以完成这项工作。《REPR.API》项目—聚合器—以及模块依赖于以下 NuGet 包来使用 MassTransit:
<PackageReference Include="MassTransit" Version="8.1.0" />
我们的用法非常简单;聚合器像这样注册和配置 MassTransit:
builder.Services.AddMassTransit(x =>
{
x.SetKebabCaseEndpointNameFormatter();
x.UsingInMemory((context, cfg) =>
{
cfg.ConfigureEndpoints(context);
});
x.AddBasketModuleConsumers();
});
突出的行将事件消费者的注册委托给了购物篮模块。AddBasketModuleConsumers方法是BasketModuleExtensions类的一部分,并包含以下代码:
public static void AddBasketModuleConsumers(this IRegistrationConfigurator configurator)
{
configurator.AddConsumers(typeof(ProductEventsConsumers));
}
ProductEventsConsumers类管理两个事件。AddConsumers方法是 MassTransit 的一部分。我们在项目部分探讨了ProductEventsConsumers类。
如果我们有更多模块,我们会在这里注册其他模块的事件消费者。然而,将注册委托给每个模块使其模块化。
接下来,我们编写一些 C#代码,将我们的微服务应用程序转换为模块化单体。
项目—模块化单体
此项目与第十八章和第十九章具有相同的构建块,但我们使用了模块化单体方法。在之前的版本之上,我们利用事件来允许购物篮在允许客户将其添加到购物篮之前验证产品的存在。
完整的源代码可在 GitHub 上找到:
adpg.link/gyds解决方案中的测试项目是空的。它们仅存在于解决方案的组织方面。作为一个练习,你可以将第十八章中的测试迁移过来,并适应这种新的架构风格。
让我们从通信部分开始。
从目录模块发送事件
为了让目录模块能够传达购物篮模块需要的事件,它必须定义以下新操作:
-
创建产品
-
删除产品
在REPR.Products.Contracts项目中,我们必须创建以下 API 契约来支持这两个操作:
namespace REPR.Products.Contracts;
public record class CreateProductCommand(string Name, decimal UnitPrice);
public record class CreateProductResponse(int Id, string Name, decimal UnitPrice);
public record class DeleteProductCommand(int ProductId);
public record class DeleteProductResponse(int Id, string Name, decimal UnitPrice);
到现在为止,API 契约应该已经很熟悉了,并且与之前章节中的契约相似。然后我们需要以下两个事件契约:
namespace REPR.Products.Contracts;
public record class ProductCreated(int Id, string Name, decimal UnitPrice);
public record class ProductDeleted(int Id);
这两个事件类也非常简单,但它们的名称是过去时,因为过去发生了一个事件。所以,模块创建了产品,然后通知其订阅者一个产品已经被创建(过去)。这与我们在第十九章中学习的微服务架构简介完全一样。此外,事件是简单的数据容器,就像 API 契约——一个 DTO 一样。我们如何发送这些事件?让我们看看CreateProductHandler类:
namespace REPR.Products.Features;
public class CreateProductHandler
{
private readonly ProductContext _db;
private readonly CreateProductMapper _mapper;
private readonly IBus _bus;
public CreateProductHandler(ProductContext db, CreateProductMapper mapper, IBus bus)
{
_db = db ?? throw new ArgumentNullException(nameof(db));
_mapper = mapper ?? throw new ArgumentNullException(nameof(mapper));
_bus = bus ?? throw new ArgumentNullException(nameof(bus));
}
public async Task<CreateProductResponse> HandleAsync(CreateProductCommand command, CancellationToken cancellationToken)
{
var product = _mapper.Map(command);
var entry = _db.Products.Add(product);
await _db.SaveChangesAsync(cancellationToken);
var productCreated = _mapper.MapToIntegrationEvent(entry.Entity);
await _bus.Publish(productCreated, CancellationToken.None);
var response = _mapper.MapToResponse(entry.Entity);
return response;
}
}
在前面的代码中,我们在CreateProductHandler类中注入了 MassTransit 的IBus接口,我们还注入了一个对象映射器和 EF Core 的ProductContext。HandleAsync方法执行以下操作:
-
创建产品并将其保存到数据库中。
-
发布一个
ProductCreated事件(高亮代码)。 -
根据新产品返回一个
CreateProductResponse实例。
Publish 方法将事件发送到配置的管道,在我们的案例中是在内存中。这里代码传递了一个 CancellationToken.None 参数,因为我们不希望任何外部力量取消此操作,因为更改已经保存在数据库中。由于映射代码,发布代码在书中可能难以理解。MapToIntegrationEvent 方法将 Product 对象转换为 ProductCreated 实例,因此 productCreated 变量的类型为 ProductCreated。以下是具有突出显示该方法的 Mapperly 映射类:
namespace REPR.Products.Features;
[Mapper]
public partial class CreateProductMapper
{
public partial Product Map(CreateProductCommand product);
public partial ProductCreated MapToIntegrationEvent(Product product);
public partial CreateProductResponse MapToResponse(Product product);
}
DeleteProductHandler 类遵循类似的模式,但发布的是 ProductDeleted 事件。现在,让我们探索篮子模块如何消费这些事件。
从篮子模块消费事件
篮子模块想要缓存现有产品。我们可以用不同的方式实现这一点。在这种情况下,我们创建以下 Product 类并将其持久化到数据库中:
namespace REPR.Baskets.Data;
public record class Product(int Id);
为了使用它,我们必须从 BasketContext 类中公开以下属性:
public DbSet<Product> Products => Set<Product>();
然后,我们可以开始利用这个缓存。首先,我们必须在目录模块创建产品时填充它,在删除产品时移除该产品。ProductEventsConsumers 类处理这两个事件。以下是这个类的框架:
using REPR.Products.Contracts;
namespace REPR.Baskets.Features;
public class ProductEventsConsumers : IConsumer<ProductCreated>, IConsumer<ProductDeleted>
{
private readonly BasketContext _db;
private readonly ILogger _logger;
public ProductEventsConsumers(BasketContext db, ILogger<ProductEventsConsumers> logger)
{
_db = db ?? throw new ArgumentNullException(nameof(db));
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
}
public async Task Consume(ConsumeContext<ProductCreated> context)
{...}
public async Task Consume(ConsumeContext<ProductDeleted> context)
{...}
}
高亮代码表示两个事件处理器。IConsumer<TMessage> 接口包含 Consume 方法。通过使用不同的 TMessage 泛型参数实现接口两次,规定在 ProductEventsConsumers 类中实现两个 Consume 方法。每个方法处理其自己的事件。当产品模块中创建产品时,篮子模块将执行以下方法(为了简洁,我移除了日志代码):
public async Task Consume(ConsumeContext<ProductCreated> context)
{
var product = await _db.Products.FirstOrDefaultAsync(
x => x.Id == context.Message.Id,
cancellationToken: context.CancellationToken
);
if (product is not null)
{
return;
}
_db.Products.Add(new(context.Message.Id));
await _db.SaveChangesAsync();
}
上述代码在产品不存在时将其添加到数据库中。如果它已经存在,则不执行任何操作。
我们可以在此处添加遥测来记录和标记冲突,并探索如何解决它。如果产品已经存在,事件被接收了两次,这可能表明我们的系统存在问题。
这里是当员工创建一个新产品时发生流程的概述:

图 20.8:展示有人向系统中添加产品时操作序列的图表。
让我们回顾一下操作:
-
客户端向 API(聚合应用程序)发送 POST 请求。目录模块接收请求。
-
目录创建产品并将其添加到数据库中。
-
目录随后使用 MassTransit 发布
ProductCreated事件。 -
在篮子模块中,
ProductEventsConsumers类的Consume方法在 MassTransit 对ProductCreated事件的反应中被调用。 -
购物车模块将产品的标识符添加到数据库的物化视图中。
现在我们已经了解了ProductEventsConsumers类如何处理ProductCreated事件,让我们探索ProductDeleted事件(为了简洁,省略了日志代码):
public async Task Consume(ConsumeContext<ProductDeleted> context)
{
var item = await _db.Products.FirstOrDefaultAsync(
x => x.Id == context.Message.Id,
cancellationToken: context.CancellationToken
);
if (item is null)
{
return;
}
// Remove the products from existing baskets
var existingItemInCarts = _db.Items
.Where(x => x.ProductId == context.Message.Id);
var count = existingItemInCarts.Count();
_db.Items.RemoveRange(existingItemInCarts);
// Remove the product from the internal cache
_db.Products.Remove(item);
// Save the changes
await _db.SaveChangesAsync();
}
上述Consume方法从人们的购物车和物化视图中删除产品。如果产品不存在,则该方法不执行任何操作,因为没有要处理的内容。当员工从目录中删除产品并发布ProductDeleted事件时,也会发生类似的流程。购物车模块随后对事件做出反应并更新其缓存(物化视图)。现在我们已经探索了这个项目的这个令人兴奋的部分,让我们来看看聚合器。
在聚合器内部
聚合应用程序就像一个空壳,它加载其他组件并配置跨切面关注点。它引用模块,然后组装并启动应用程序。以下是Program.cs文件的第一部分:
using FluentValidation;
using FluentValidation.AspNetCore;
using MassTransit;
using REPR.API.HttpClient;
using REPR.Baskets;
using REPR.Products;
using System.Reflection;
var builder = WebApplication.CreateBuilder(args);
// Register fluent validation
builder.AddFluentValidationEndpointFilter();
builder.Services
.AddFluentValidationAutoValidation()
.AddValidatorsFromAssemblies(new[] {
Assembly.GetExecutingAssembly(),
Assembly.GetAssembly(typeof(BasketModuleExtensions)),
Assembly.GetAssembly(typeof(ProductsModuleExtensions)),
})
;
builder.AddApiHttpClient();
builder.AddExceptionMapper();
builder
.AddBasketModule()
.AddProductsModule()
;
builder.Services.AddMassTransit(x =>
{
x.SetKebabCaseEndpointNameFormatter();
x.UsingInMemory((context, cfg) =>
{
cfg.ConfigureEndpoints(context);
});
x.AddBasketModuleConsumers();
});
上述代码注册了以下内容:
-
FluentValidation;它还会扫描组件以查找验证器。
-
我们用来初始化数据库的
IWebClient接口(即AddApiHttpClient方法)。 -
异常映射器。
-
模块的依赖关系(高亮显示)
-
MassTransit;它还注册了来自购物车模块的消费者(高亮显示)。
我们将这些依赖项注册到的容器也用于模块,因为聚合器是宿主。这些依赖项在模块之间共享。Program.cs文件的第二部分如下:
var app = builder.Build();
app.UseExceptionMapper();
app
.MapBasketModule()
.MapProductsModule()
;
// Convenience endpoint, seeding the catalog
app.MapGet("/", async (IWebClient client, CancellationToken cancellationToken) =>
{
await client.Catalog.CreateProductAsync(new("Banana", 0.30m), cancellationToken);
await client.Catalog.CreateProductAsync(new("Apple", 0.79m), cancellationToken);
await client.Catalog.CreateProductAsync(new("Habanero Pepper", 0.99m), cancellationToken);
return new
{
Message = "Application started and catalog seeded. Do not refresh this page, or it will reseed the catalog."
};
});
app.Run();
上述代码注册了ExceptionMapper中间件,然后是模块。它还添加了一个初始化端点(高亮显示)。如果你记得,我们之前使用DbContext来初始化数据库。然而,由于购物车模块需要从目录接收事件以构建物化视图,因此通过目录模块初始化数据库更为方便。在这个版本中,程序在客户端访问/端点时初始化数据库。默认情况下,当启动应用程序时,Visual Studio 应该在 URL 中打开浏览器,这将初始化数据库。
通过向
/端点发送 GET 请求来初始化数据库在学术场景中使用内存数据库时非常方便。然而,在生产环境中这可能会造成灾难,因为每次有人访问该端点时都会重新初始化数据库。
让我们接下来探索IWebClient。
探索 REST API HttpClient
在shared目录中,REPR.API.HttpClient项目包含 REST API 客户端代码。代码与上一个项目非常相似,但现在的IProductsClient现在公开了创建和删除方法(高亮显示):
using Refit;
using REPR.Products.Contracts;
namespace REPR.API.HttpClient;
public interface IProductsClient
{
[Get("/products/{query.ProductId}")]
Task<FetchOneProductResponse> FetchProductAsync(
FetchOneProductQuery query,
CancellationToken cancellationToken);
[Get("/products")]
Task<FetchAllProductsResponse> FetchProductsAsync(
CancellationToken cancellationToken);
[Post("/products")]
Task<CreateProductResponse> CreateProductAsync(
CreateProductCommand command,
CancellationToken cancellationToken);
[Delete("/products/{command.ProductId}")]
Task<DeleteProductResponse> DeleteProductAsync(
DeleteProductCommand command,
CancellationToken cancellationToken);
}
在此之上,项目仅引用Contracts项目,限制其依赖性仅限于它需要的类。它不再引用完整的模块。这使得该项目易于重用。例如,我们可以构建另一个项目,如用户界面(UI),然后引用并使用此类型客户端从.NET UI 查询 API(模块化单体)。由于我们为微服务应用程序创建了此客户端,我们有两个基础下游服务 URL——一个用于产品微服务,一个用于购物车微服务。这种细微差别非常适合我们,因为我们可能希望在以后将单体迁移到微服务。与此同时,我们只需将两个键设置为同一主机即可,如下所示appsettings.json文件来自聚合器:
{
"Downstream": {
"Baskets": {
"BaseAddress": "https://localhost:7164/"
},
"Products": {
"BaseAddress": "https://localhost:7164/"
}
}
}
使用这些配置,AddApiHttpClient方法配置了两个具有相同BaseAddress值的HttpClient,如下所示:
using Microsoft.AspNetCore.Builder;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection;
using Refit;
namespace REPR.API.HttpClient;
public static class ApiHttpClientExtensions
{
public static WebApplicationBuilder AddApiHttpClient(this WebApplicationBuilder builder)
{
const string basketsBaseAddressKey = "Downstream:Baskets:BaseAddress";
const string productsBaseAddressKey = "Downstream:Products:BaseAddress";
var basketsBaseAddress = builder.Configuration
.GetValue<string>(basketsBaseAddressKey) ?? throw new BaseAddressNotFoundException(basketsBaseAddressKey);
var productsBaseAddress = builder.Configuration
.GetValue<string>(productsBaseAddressKey) ?? throw new BaseAddressNotFoundException(productsBaseAddressKey);
builder.Services
.AddRefitClient<IBasketsClient>()
.ConfigureHttpClient(c => c.BaseAddress = new Uri(basketsBaseAddress))
;
builder.Services
.AddRefitClient<IProductsClient>()
.ConfigureHttpClient(c => c.BaseAddress = new Uri(productsBaseAddress))
;
builder.Services.AddTransient<IWebClient, DefaultWebClient>();
return builder;
}
}
哇,我们有一个可重用的功能化客户端和一个工作模块化单体。让我们接下来测试一下。
向 API 发送 HTTP 请求
现在我们有一个工作模块化单体,我们可以重用与之前版本相似的 HTTP 请求。在 REPR.API 项目的根目录中,我们可以使用以下内容:
-
API-Products.http文件包含对产品模块的请求。 -
API-Baskets.http文件包含对购物车模块的请求。
与之前版本相比,这个 API 的新功能是当我们尝试将不在目录中的产品添加到购物车时,如下所示请求:
POST https://localhost:7164/baskets
Content-Type: application/json
{
"customerId": 1,
"productId": 5,
"quantity": 99
}
由于产品5不存在,API 返回以下内容:
HTTP/1.1 400 Bad Request
Content-Type: application/problem+json
{
"type": "https://tools.ietf.org/html/rfc9110#section-15.5.1",
"title": "One or more validation errors occurred.",
"status": 400,
"errors": {
"productId": [
"The Product does not exist."
]
}
}
该错误确认验证按预期工作。但我们是如何验证这个的呢?
验证产品的存在性
在添加项目功能中,AddItemValidator类在验证AddItemCommand对象的同时确保产品存在。为了实现这一点,我们利用 FluentValidation 的MustAsync和WithMessage方法。以下是代码:
namespace REPR.Baskets.Features;
public class AddItemValidator : AbstractValidator<AddItemCommand>
{
private readonly BasketContext _db;
public AddItemValidator(BasketContext db)
{
_db = db ?? throw new ArgumentNullException(nameof(db));
RuleFor(x => x.CustomerId).GreaterThan(0);
RuleFor(x => x.ProductId)
.GreaterThan(0)
.MustAsync(ProductExistsAsync)
.WithMessage("The Product does not exist.")
;
RuleFor(x => x.Quantity).GreaterThan(0);
}
private async Task<bool> ProductExistsAsync(int productId, CancellationToken cancellationToken)
{
var product = await _db.Products
.FirstOrDefaultAsync(x => x.Id == productId, cancellationToken);
return product is not null;
}
}
上述代码实现了与之前版本相同的规则,但还调用了从缓存中检索请求产品的ProductExistsAsync方法。如果结果是false,验证失败,消息为“该产品不存在”。以下是此更改的结果流程:
-
客户端调用 API。
-
验证中间件调用验证器。
-
验证器从数据库中检索记录并验证其存在性。
-
如果产品不存在,请求在这里被短路并返回给客户端。否则,它继续到
AddItemHandler类。
使用这些配置,我们覆盖了该项目中所有新的场景。我们还探讨了聚合器如何将模块连接在一起,以及保持我们的模块独立有多容易。接下来,我们将 bff(最佳朋友)带回项目中,并探讨如何将我们的模块化单体过渡到微服务架构。
向微服务过渡
您不必将单体应用转换为微服务;如果单体应用符合您的需求,部署单体应用是可以的。然而,如果您需要的话,您可以使用网关或反向代理来保护聚合器,这样您就可以将模块提取到它们自己的微服务中,并重新路由请求,而不会影响客户端。这也会让您能够逐步将流量转移到新服务,同时保持单体应用在意外故障发生时保持完整。在模块化单体中,聚合器注册了大多数依赖项,因此在迁移时,您还必须迁移这个共享设置。避免代码重复的一种方法就是创建并引用一个包含这些注册的共享程序集。这使得管理依赖项和共享配置变得更容易,但这也使得微服务和聚合器之间产生了耦合。当微服务是同一单体仓库的一部分时,利用这个共享程序集的代码甚至更容易;您可以直接引用项目,而无需管理 NuGet 包。然而,当您更新库时,它会更新所有微服务以及单体应用,从而消除了每个应用的部署独立性。再次强调,考虑保持单体应用完整与部署微服务带来的运营复杂性之间的权衡。在解决方案中,REPR.BFF项目是对第十九章代码的迁移。代码和逻辑几乎相同。它利用了REPR.API.HttpClient项目,并在其appsettings.Development.json文件中配置了下游的基本地址。项目根目录下的REPR.BFF.http文件包含了一些用于测试应用的请求。
代码可在 GitHub 上找到:
adpg.link/bn1F。我建议您尝试使用实时应用程序,这会比再次复制书中的代码得到更好的结果。在 Visual Studio 中,项目之间的关系比在书中写出来更为明显。请随意重构项目、添加测试或添加功能。最好的学习方法是实践!
接下来,让我们看看挑战和陷阱。
挑战和陷阱
认为单体应用仅适用于小型项目或小型团队是一种误解。一个构建良好的单体应用可以走得很远,这正是模块化结构和良好分析带来的结果。模块化单体可以非常强大,并且适用于不同规模的项目。在选择项目的应用程序模式时,考虑每种方法的优缺点是至关重要的。也许云原生、无服务器、微服务应用正是您下一个项目需要的,但也许一个精心设计的模块化单体可以在成本极低的情况下实现相同的表现。为了帮助您做出这些决定,以下是一些潜在的挑战以及如何避免或减轻它们:
-
模块内部过于复杂:一个风险是使模块过于复杂。就像任何代码片段一样,如果模块做得太多,管理模块就会变得困难。此外,软件块越大,最初闪亮的设计开始变得混乱的可能性就越大。我们可以通过保持每个模块专注于领域特定的一部分,并应用 SRP(单一职责原则)来避免这种情况。
-
模块边界定义不明确:如果模块之间的边界不明确,可能会引起问题。例如,如果两个模块做类似的事情,可能会让开发者困惑,导致系统的一部分依赖于错误的模块。另一个例子是当两个本应属于同一边界上下文的模块被分开时。在这种情况下,两个模块之间可能会大量交互,产生冗余,并显著增加它们之间的耦合度。我们可以通过良好的规划、领域分析和确保每个模块有一个特定的任务(SRP)来避免这种情况。
-
扩展性:尽管模块化单体更容易管理,但当它们需要扩展时,它们仍然携带单体的问题。我们必须部署整个应用程序,因此不能独立扩展模块。此外,即使模块拥有自己的数据,它们可能共享一个数据库,这个数据库也必须作为一个整体进行扩展。如果整个系统的扩展是不可能的,我们可以将特定模块迁移到微服务。我们还可以将内存中的服务提取为分布式服务,例如使用分布式缓存而不是内存缓存,利用基于云的事件代理而不是内存代理,或者让计算密集型或数据密集型模块开始使用它们自己的数据库,这样它们就可以部分独立扩展。然而,这些解决方案使得部署和基础设施变得更加复杂,逐渐向复杂的基础设施转变,这抵消了单体的一些好处。
-
最终一致性:在模块间保持数据同步可能具有挑战性。我们可以通过使用事件驱动架构来处理这个问题。使用内存中的消息代理具有低延迟和高保真度(不涉及网络),这是学习处理最终一致性和将单体拆分为微服务(如果需要过渡)的良好第一步。然而,对于生产环境,建议使用更健壮的传输方式,以便系统能够抵抗故障,增加同步延迟。如果你更喜欢去除这种复杂性,则不需要使用事件;一个设计良好的关系型数据库也能做到这一点。
-
过渡到微服务:在事后将应用程序迁移到微服务架构可能是一项重大任务。然而,从事件驱动的模块化单体开始应该会使这个过程不那么痛苦。
虽然模块化单体提供了许多好处,但它们也可能带来挑战。关键是做好规划,保持简单,并随时准备随着程序的发展而适应。
结论
在本章中,我们学习了模块化单体架构风格,它将单体架构的简单性与微服务的灵活性相结合。这种架构风格将软件应用程序组织成独立的、松散耦合的模块,每个模块负责特定的业务能力。与微服务不同,我们将这些模块作为一个单一单元部署,就像单体一样。我们讨论了模块化单体的好处,包括更易于整体管理、良好的开发和测试体验、成本效益以及简化的部署模型。我们了解到,模块化单体由模块、模块聚合器和模块间通信基础设施组成——在这种情况下是事件驱动的。我们学习了在开始开发之前分析领域、设计模块和识别模块间交互可以提高产品的成功机会。我们简要提到了从模块化单体过渡到微服务架构,这涉及到将模块提取为单独的微服务并重新路由请求。我们还强调了了解潜在挑战和陷阱的重要性。这些包括模块复杂性、模块边界定义不明确、扩展限制以及由异步通信模型引起的最终一致性。
问题
让我们看看几个练习题:
-
模块化单体的核心原则是什么?
-
模块化单体有哪些优势?
-
传统单体是什么?
-
模块边界定义不明确真的对模块化单体有益吗?
-
将应用程序过渡到微服务架构是否真的是一项重大任务?
进一步阅读
这里有一个链接,可以基于本章所学的内容进行扩展:
-
微服务聚合(模块化单体):
adpg.link/zznM -
当(模块化)单体是构建软件的更好方式时:
adpg.link/KBGB -
源代码:
adpg.link/gyds
结束只是新的开始
这可能是这本书的结束,但也是你进入软件架构和设计旅程的延续。希望你觉得这是一个对设计模式和如何设计 SOLID 应用程序的新鲜视角。根据你的目标和当前情况,你可能想要更深入地探索一个或多个应用规模的设计模式,开始你的下一个个人项目,开始一项业务,申请一份新工作,或者所有这些。无论你的目标是什么,请记住,设计软件既是技术也是艺术。实现一个功能很少只有一种方式,而是有多种可接受的方式。每个决策都有权衡,经验是你的最佳朋友,所以继续编程,从你的错误中学习,变得更好,并继续前进。精通之路是一个永无止境的持续学习循环。记住,我们都是一无所知地出生的,所以不知道某件事是预期的;我们需要学习。请提问,阅读,实验,学习,并将你的知识与他人分享。向某人解释一个概念是非常有回报的,并加强你自己的学习和知识。现在这本书已经完成,你可能会在我的博客上找到有趣的文章(adpg.link/blog)。在 Discord、Twitter @CarlHugoM (adpg.link/twit)或 LinkedIn (adpg.link/edin)上随意联系我。我希望你觉得这本书既教育性强又易于接近,并且学到了很多东西。祝你事业成功。
答案
-
我们必须将每个模块视为一个微服务,并将应用程序作为一个单一单元——一个单体。
-
几个运动部件使得应用程序更加简单。每个模块都是独立的,使得模块之间松散耦合。其简单的部署模型导致成本效益高且易于部署。
-
传统的单体架构将应用程序构建为一个单一、不可分割的单元,通常会导致功能紧密耦合。
-
错误。定义不明确的模块边界会阻碍应用程序的健康。
-
正确。即使一个精心设计的模块化单体可以帮助,过渡也将是一个旅程。


是关于类型 T 的对象 x 的一个可证明的性质。那么,
对于类型 S 的对象 y 应该是正确的,其中 S 是 T 的子类型。
浙公网安备 33010602011771号