C-10-和--NET6-企业级应用开发-全-

C#10 和 .NET6 企业级应用开发(全)

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

译者:飞龙

协议:CC BY-NC-SA 4.0

前言

.NET 6 是一个开源、免费的全栈框架,用于编写针对任何平台的应用程序。该框架为你提供了轻松编写应用程序的机会,包括云应用。作为软件开发者,我们肩负着构建复杂企业应用程序的责任。在这本书中,我们将学习使用 C# 10 和.NET 6 构建企业应用程序的各种高级架构和概念。

这本书可以作为在.NET 6 中构建企业应用程序时的参考。它包含了对基本概念的逐步解释、实际示例和自我评估问题,你将深入了解并接触到构建专业企业应用程序所需的.NET 6 的每一个重要组件。

这本书面向的对象

这本书面向的是已经熟悉.NET 经典或.NET Core 和 C#的中级到高级开发者。

这本书涵盖的内容

第一章设计和架构企业应用程序,首先讨论了常用的企业架构和设计模式,然后介绍如何将企业应用程序设计为包含 UI 层、服务层和数据库的三层应用程序。

第二章介绍.NET 6 Core 和 Standard,讨论了与.NET 6 一起发布的 C# 10 的新特性。

第三章介绍 C# 10,从我们的认知出发,运行时是代码运行的地方。在本章中,你将了解.NET 6 运行时组件的核心和高级概念。

第四章线程和异步操作,帮助你详细了解线程、线程池、任务以及 async/await,以及.NET 如何让你构建异步应用程序。

第五章.NET 6 中的依赖注入,帮助我们理解依赖注入是什么以及为什么每个开发者都在涌向它。我们将学习.NET 6 中依赖注入的工作方式,并列出其他可用的选项。

第六章.NET 6 中的配置,教你如何配置.NET 6 并在你的应用程序中使用配置和设置。你还将学习如何扩展.NET 6 配置以定义自己的部分、处理程序、提供者等。

第七章.NET 6 中的日志记录,讨论了.NET 6 中的事件和日志 API。我们还将深入了解使用 Azure 和 Azure 组件进行日志记录,并学习如何进行结构化日志记录。

第八章关于缓存的全部知识,讨论了.NET 6 中可用的缓存组件以及最佳行业模式和惯例。

第九章在.NET 6 中使用数据,讨论了两种可能的数据提供程序:SQL 和关系数据库管理系统(RDBMS)等数据库。我们还将从高层次上讨论如何使用.NET 6 来存储和处理 NoSQL 数据库。本章将讨论.NET Core 与文件、文件夹、驱动器、数据库和内存的接口。

第十章创建 ASP.NET Core 6 Web API,通过使用 ASP.NET 6 Web API 模板来开发我们的企业应用程序的服务层。

第十一章创建 ASP.NET Core 6 Web 应用程序,通过使用 ASP.NET 6 MVC Web 应用程序模板和 Blazor 来开发我们的企业应用程序的 Web 层。

第十二章理解身份验证,讨论了行业中最常见的身份验证模式以及您如何使用.NET 6 来实现它们。我们还将介绍如何实现自定义身份验证。

第十三章在.NET 6 中实现授权,讨论了不同的授权方法以及 ASP.NET 6 如何让您处理它。

第十四章健康和诊断,讨论了监控应用程序健康的重要性,为.NET 应用程序构建HealthCheck API,以及 Azure 应用程序用于捕获遥测信息和诊断问题。

第十五章测试,讨论了测试的重要性。测试是开发的重要组成部分,没有适当的测试,任何应用程序都不能发布,因此我们还将讨论如何对我们的代码进行单元测试。我们还将学习如何衡量应用程序的性能。

第十六章在 Azure 中部署应用程序,讨论了在 Azure 中部署应用程序。我们将把我们的代码提交到我们选择的源代码控制,然后 CI/CD 管道将启动并在 Azure 中部署应用程序。

为了充分利用这本书

您需要在您的系统上安装.NET 6 SDK;所有代码示例都已在 Windows 操作系统上的 Visual Studio 2022/Visual Studio Code 上进行了测试。建议您拥有一个活动的 Azure 订阅,以便进一步部署企业应用程序。您可以在azure.microsoft.com/en-in/free/创建一个免费账户。

如果您使用的是这本书的数字版,我们建议您亲自输入代码或从书的 GitHub 仓库(下一节中有一个链接)获取代码。这样做将帮助您避免与代码复制和粘贴相关的任何潜在错误。

下载示例代码文件

您可以从 GitHub 下载本书的示例代码文件github.com/PacktPublishing/Enterprise-Application-Development-with-C-10-and-.NET-6-Second-Edition。如果代码有更新,它将在 GitHub 仓库中更新。

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

下载彩色图像

我们还提供了一份包含本书中使用的截图和图表的彩色图像的 PDF 文件。您可以从这里下载:static.packt-cdn.com/downloads/9781803232973_ColorImages.pdf

使用的约定

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

文本中的代码: 表示文本中的代码单词、数据库表名、文件夹名、文件名、文件扩展名、路径名、虚拟 URL、用户输入和 Twitter 昵称。以下是一个示例:"WriteMinimalPlainText将仅输出健康检查服务的总体状态。"

代码块按以下方式设置:

app.UseEndpoints(endpoints =>
{
   endpoints.MapControllerRoute(
        name: "default",
        pattern: "{controller=Products}/{action=Index}/{id?}");

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

app.UseEndpoints(endpoints =>
{
        "{controller=Products}/{action=Index}/{id?}");
    endpoints.MapHealthChecks("/health");
});

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

dotnet new classlib -o MyLibrary

粗体: 表示新术语、重要单词或您在屏幕上看到的单词。例如,菜单或对话框中的单词以粗体显示。以下是一个示例:“让我们在用户点击产品详情页面上的添加到购物车按钮时添加自定义事件跟踪。”

小贴士或重要提示

看起来像这样。

联系我们

欢迎读者反馈

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

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

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

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

分享您的想法

一旦您阅读了《使用 C# 10 和 .NET 6 开发企业级应用程序》,我们非常期待听到您的想法!请点击此处直接访问此书的亚马逊评论页面并分享您的反馈。

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

第一部分:基础知识

在本部分,我们将学习 C# 10 的高级概念和 .NET 6 的基本组件。

本部分包括以下章节:

  • 第一章**,设计和架构企业应用程序

  • 第二章**,介绍 .NET 6 核心和标准

  • 第三章**,介绍 C# 10

第一章:第一章:设计和架构企业应用程序

企业应用程序是为解决企业组织的大规模和复杂问题而设计的软件解决方案。它们为 IT、政府、教育和公共部门的企业客户提供从订单到履行的能力。它们使企业能够通过产品采购、支付处理、自动计费和客户管理等功能,以数字化方式转型其业务。在企业应用程序方面,集成数量相当高,用户数量也非常高,因为通常这些应用程序针对的是全球受众。

为了确保企业系统保持高度可靠、高度可用和高度性能,正确的设计和架构至关重要。设计和架构是任何优秀软件的基础。它们构成了软件开发生命周期的基石;因此,首先获得正确的设计,以避免后续的任何返工,这一点非常重要,因为根据所需的变化,返工可能会非常昂贵。所以,你需要一个灵活、可扩展、可扩展和可维护的设计和架构。

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

  • 常见设计原则和模式的入门指南

  • 理解常见的企业架构

  • 识别企业应用程序需求(业务和技术)

  • 架构企业应用程序

  • 企业应用程序的解决方案结构

到本章结束时,你将能够开始设计和架构企业应用程序。

常见设计原则和模式的入门指南

世界上每一块软件都至少解决了一个现实世界的问题。随着时间的推移,事物会发生变化,包括我们对任何特定软件的期望。为了管理这种变化并处理软件的各个方面,工程师们已经开发了几种编程范式、框架、工具、技术、流程和原则。这些经过时间考验的原则和模式已成为工程师构建高质量软件的指南星。

原则是设计时遵循的高级抽象指南。它们适用于使用的任何编程语言。它们不提供实现指南。

模式是针对重复出现的问题的经过验证、可重用的解决方案的低级具体实现指南。首先,让我们从设计原则开始。

设计原则

如果技术被广泛接受、实践并被证明在任何行业中都有用,那么它们就会成为原则。这些原则成为使软件设计更易于理解、灵活和可维护的解决方案。在本节中,我们将介绍 SOLID、KISS 和 DRY 设计原则。

SOLID

SOLID 原则是美国软件工程师和讲师罗伯特·C·马丁(Robert C. Martin)推广的许多原则的一个子集。这些原则已经成为面向对象(OOP)世界中的事实标准原则,并已成为其他方法和范式的核心哲学的一部分。

SOLID 是以下五个原则的缩写:

  1. 单一职责原则SRP):实体或软件模块应该只有一个职责。你应该避免将多个职责赋予一个实体。

图 1.1 – SRP

图 1.1 – B18507

图 1.1 – SRP

  1. 开闭原则OCP):实体应该设计成这样,即它们对扩展是开放的,但对修改是封闭的。这意味着可以避免现有行为的回归测试;只需测试扩展即可。

图 1.2 – OCP

图 1.2 – B18507

图 1.2 – OCP

  1. 里氏替换原则LSP):父类或基类实例应该可以用其派生类或子类型实例替换,而不会改变程序的合理性。

图 1.3 – LSP

图 1.3 – B18507

图 1.3 – LSP

  1. 接口隔离原则ISP):而不是一个通用的一个大接口,你应该计划多个、场景特定的接口,以实现更好的解耦和变更管理:

图 1.4 – ISP

图 1.4 – B18507

图 1.4 – ISP

  1. 依赖倒置原则DIP):你应该避免对具体实现有任何直接依赖。高层模块和低层模块不应直接相互依赖。相反,两者应尽可能依赖于抽象。抽象不应依赖于细节,细节应依赖于抽象。

图 1.5 – DIP

图 1.5 – DIP

图 1.5 – DIP

不要重复自己(DRY)

使用 DRY 原则,系统应该设计成这样,即一个功能或模式的实现不应该在多个地方重复。这会导致维护开销,因为需求的变化会导致多个地方需要修改。如果你不小心在一个地方没有进行必要的更新,系统的行为将变得不一致。相反,该功能应该被封装成一个包,并在所有地方重用。在数据库的情况下,你应该考虑使用数据规范化来减少冗余。

图 1.6 – DRY

图 1.6 – B18507

图 1.6 – DRY

这种策略有助于减少冗余并促进重用。这个原则也有助于组织文化,鼓励更多的协作。

简单就是最好(KISS)

使用 KISS 原则,系统应该尽可能简单地进行设计,避免复杂的设计、算法、新技术以及更多。你应该专注于利用正确的面向对象概念和重用经过验证的模式和原则。只有当它是必要的并且增加了实现的价值时,才包括新或不简单的事物。

当你保持简单时,你将能够更好地做到以下事情:

  • 在设计/开发过程中避免错误。

  • 保持列车运行(总有一个团队负责维护系统,即使他们最初并没有开发系统)。

  • 阅读并理解你的系统代码(你的系统代码需要对新来的人或未来将使用它的人可理解)。

  • 做得更好,减少错误的风险进行变更管理。

通过这些,我们完成了对常见设计原则的入门介绍;我们学习了 SOLID、DRY 和 KISS。在下一节中,我们将通过现实世界的例子来探讨一些常见的设计模式,以帮助您理解原则和模式之间的区别以及何时使用哪种模式——这是良好设计和架构所必需的技能。

设计模式

在遵循面向对象(OOP)范式的设计原则时,你可能会看到相同的结构和模式反复出现。这些重复的结构和技术是解决常见问题的既定解决方案,被称为设计模式。经过验证的设计模式易于重用、实现、更改和测试。著名的书籍《设计模式:可重用面向对象软件的元素》,包含了被称为四人帮GOF)的设计模式,被认为是模式的圣经。

我们可以将 GOF 模式分类如下:

  • 创造:有助于创建对象

  • 结构:有助于处理对象的组成

  • 行为:有助于定义对象之间的交互和分配责任

让我们通过一些现实生活中的例子来看看这些模式。

创建型设计模式

让我们来看看以下表格中的一些创建型设计模式及其相关示例:

表 1.1 – 创建型设计模式

表 1.1 – 创建型设计模式

结构设计模式

以下表格包含了一些结构设计模式的示例:

表 1.2 – 结构设计模式

表 1.2 – 结构设计模式

行为设计模式

以下表格包含了一些行为设计模式的示例:

表 1.3 – 行为设计模式

表 1.3 – 行为设计模式

有时候,你可能会被所有这些模式在表格中的内容所淹没。但事实上,任何设计只要不违反基本原理,都是好的设计。我们可以使用的一个经验法则是回归基本,在设计上,原则是基础。

图 1.7 – 模式与原则

图 1.7 – 模式与原则

通过这一点,我们完成了对常见设计原则和模式的入门介绍。到现在,你应该对不同的原则和模式、它们的使用场景以及构建优秀解决方案所需的内容有了很好的理解。现在,让我们花些时间来了解常见的企业架构。

理解常见的企业架构

在设计企业应用程序时,通常遵循一些原则和架构。首先,任何架构的目标都是以尽可能低的成本支持业务需求(成本是时间和资源)。企业希望软件能够使其业务更高效,而不是成为瓶颈。在当今世界,可用性、可靠性和性能是任何系统的三个关键绩效指标。

在本节中,首先,我们将探讨单体架构的问题,然后我们将看到如何通过使用广泛采用和经过验证的企业应用程序开发架构来避免这些问题。

考虑一个经典的单体电子商务网站应用,如下面的图所示,所有业务提供者和功能都在一个应用程序中,数据存储在传统的 SQL 数据库中:

图 1.8 – 单体应用

图 1.8 – 单体应用

单体架构在 15-20 年前被广泛采用,但随着系统增长和业务需求随时间扩展,软件工程团队出现了许多问题。让我们看看这种方法的常见问题。

单体应用的常见问题

让我们来看看扩展性问题:

  • 在单体应用中,横向扩展的唯一方式是通过向系统中添加更多计算资源。这导致更高的运营成本和未优化的资源利用率。有时,由于资源需求冲突,扩展变得不可能。

  • 由于所有功能大多使用单一存储,存在锁导致高延迟的可能性,同时也会存在单一存储实例可扩展的物理限制。

这里列出了一些与可用性、可靠性和性能相关的问题:

  • 任何系统中的更改都要求重新部署所有组件,导致停机时间和低可用性。

  • 任何非持久状态,如存储在 Web 应用程序中的会话,在每次部署后都会丢失。这将导致所有由用户触发的流程被放弃。

  • 任何模块中的错误,如内存泄漏或安全漏洞,都会使所有模块变得脆弱,并有可能影响整个系统。

  • 由于模块内部高度耦合和资源共享的特性,资源总会存在未被优化的使用,导致系统中的高延迟。

最后,让我们看看这对业务和工程团队的影响:

  • 变化的影响难以量化,需要广泛的测试。因此,它减缓了向生产交付的速度。即使是微小的变化,也需要重新部署整个系统。

  • 在一个高度耦合的系统中,跨团队交付任何功能都会有物理限制。

  • 新场景,如移动应用、聊天机器人和分析引擎,需要更多的努力,因为没有独立的可重用组件或服务。

  • 持续部署几乎是不可能的。

让我们通过采用一些经过验证的原则/架构来尝试解决这些常见问题。

关注点分离/单一职责架构

软件应根据其执行的工作类型划分为组件或模块,其中每个模块或组件都拥有整个软件的单一职责。组件之间的交互通过接口或消息系统进行。让我们看看 n 层架构和微服务架构以及如何处理关注点分离。

N 层架构

N 层架构将系统的应用划分为三个(或 n)层:

  • 表示(称为 UX 层、UI 层或工作表面)

  • 业务(称为业务规则层或服务层)

  • 数据(称为数据存储和访问层)

![图 1.9 – N 层架构图片 1.9

图 1.9 – N 层架构

这些层可以分别拥有/管理/部署。例如,多个表示层,如网页、移动和机器人层,可以利用相同的企业和数据层。

微服务架构

微服务架构由小型、松散耦合、独立和自治的服务组成。让我们看看它们的优点:

  • 服务可以独立部署和扩展。一个服务的问题只会产生局部影响,只需部署受影响的服务即可修复。没有共享技术或框架的强制要求。

  • 服务通过定义良好的 API 或如 Azure 服务总线之类的消息系统相互通信。

![图 1.10 – 微服务架构图片 1.10

图 1.10 – 微服务架构

如前图所示,一个服务可以由独立的团队拥有并拥有自己的周期。服务负责管理自己的数据存储。对于需要较低延迟的场景,可以通过引入缓存或高性能 NoSQL 存储进行优化。

无状态服务架构

服务不应有任何状态。状态和数据应独立于服务进行管理,即通过外部数据存储,如分布式缓存或数据库。通过将状态委托给外部,服务将拥有资源以高可靠性服务更多请求。以下图示展示了左侧的状态服务示例。在这里,状态通过内存缓存或会话提供者在每个服务中维护,而右侧所示的无状态服务则在外部管理状态和数据。

图 1.11 – 有状态(左侧)与无状态(右侧)

图 1.11 – 有状态(左侧)与无状态(右侧)

不应启用会话亲和性,因为它会导致会话粘性问题,并阻止您获得负载均衡、可扩展性和流量分布的好处。

事件驱动架构

事件驱动架构的主要特点如下:

  • 在事件驱动架构中,模块之间的通信,通常称为发布者-订阅者通信,主要是异步的,并通过事件实现。生产者和消费者彼此完全解耦。事件的结构是他们之间交换的唯一合约。

  • 同一个事件可以有多个消费者负责它们特定的操作;理想情况下,它们甚至不会意识到彼此的存在。生产者可以持续推送事件,无需担心消费者的可用性。

  • 发布者通过消息基础设施,如队列或服务总线,发布事件。一旦事件被发布,消息基础设施就负责将事件发送给合格的订阅者。

图 1.12 – 事件驱动架构

图 1.12 – 事件驱动架构

这种架构最适合本质上是异步的场景。例如,长时间运行的操作可以排队处理。客户端可能会轮询状态,甚至充当事件的订阅者。

弹性架构

随着组件之间通信的增加,故障的可能性也会增加。系统应该设计成能够从任何类型的故障中恢复。我们将介绍一些构建容错系统的方法,该系统在出现故障时可以自我修复。

如果你熟悉 Azure,你会知道应用程序、服务和数据应该在全球至少两个 Azure 区域中进行复制,以应对计划内的停机时间和计划外瞬态或永久性故障,如下面的截图所示。在这些场景中,选择 Azure App Service 托管 Web 应用程序、使用 REST API 以及选择全球分布式数据库服务,如 Azure Cosmos DB,是明智的。选择 Azure 配对区域将有助于业务连续性和灾难恢复BCDR),因为如果多个区域出现故障,至少每个配对中的一个区域将优先用于恢复。

![图 1.13 – 弹性架构

![img/Figure_1.13_B18507.jpg]

图 1.13 – 弹性架构

现在,让我们看看如何处理不同类型的故障。

瞬态故障可能发生在任何类型的通信或服务中。你需要有一个从瞬态故障中恢复的策略,如下所示:

  • 识别操作和瞬态故障的类型。然后,确定适当的重试次数和间隔。

  • 避免如有限重试次数的无限重试机制或断路器等反模式。

如果故障不是瞬态的,你应该通过选择以下一些选项来优雅地响应故障:

  • 故障转移

  • 补偿任何失败的操作

  • 限制/阻止不良客户端/参与者

  • 在出现故障的情况下,使用领导者选举来选择领导者

在这里,遥测发挥着重要作用;你应该有自定义指标来监控任何组件的健康状况。当发生自定义事件或特定指标达到某个阈值时,可以发出警报。

通过这样,我们完成了对常见企业架构的覆盖。接下来,我们将通过之前学到的设计原则和常见架构的视角,探讨企业应用程序的需求及其不同的架构。

识别企业应用程序需求(商业和技术)

在接下来的几章中,我们将构建一个可工作的电子商务应用程序。它将是一个三层应用程序,包括 UI 层、服务层和数据库。让我们看看这个电子商务应用程序的需求。

解决方案需求是在产品中实现并提供的功能,以解决一个问题或实现一个目标。

商业需求仅仅是最终客户的需求。在 IT 领域,商业通常指的是客户。这些需求从各个利益相关者那里收集,并作为每个人偏好的单一真实来源进行记录。最终,这成为待完成的工作的待办事项和范围。

技术需求是一个系统应该实施的技术相关方面,例如可靠性、可用性、性能和 BCDR。这些也被称为服务质量QoS)需求。

让我们将电子商务应用程序的典型业务需求分解为以下类别:史诗功能用户故事

应用程序的业务需求

以下是从 Azure DevOps 中获取的屏幕截图,显示了业务需求待办事项的摘要。您可以看到我们应用程序中预期的不同功能以及用户故事。

图 1.14 – 来自 Azure DevOps 的需求待办事项

img/Figure_1.14_B18507.jpg

图 1.14 – 来自 Azure DevOps 的需求待办事项

应用程序的技术需求

在了解了业务需求后,现在让我们来探讨技术需求:

  • 电子商务应用程序应具有高可用性,即在任何 24 小时周期内,99.99% 的时间内可用。

  • 电子商务应用程序应具有高可靠性,即在任何 24 小时周期内,99.99% 的时间内是可靠的。

  • 电子商务应用程序应具有高度性能,即在任何 24 小时周期内,95% 的操作应少于或等于 3 秒。

  • 电子商务应用程序应具有高度可扩展性:它应根据不同的负载自动进行扩展/缩减。

  • 电子商务应用程序应具有监控和警报:在发生任何系统故障的情况下,应向支持工程师发送警报。

以下是已识别的电子商务应用程序的技术方面和需求:

前端

  • 使用 ASP.Net 6.0 的 Web 应用程序(电子商务)

核心组件

  • C# 10.0 和 .Net 6.0 中的日志记录/缓存/配置

中间层

  • Azure API 网关用于实现身份验证

  • 通过 ASP.NET 6.0 Web API 实现的用户管理服务以添加/删除用户

  • 通过 ASP.NET 6.0 Web API 实现的产品和定价服务,从数据存储中获取产品

  • 通过 ASP.NET 6.0 Web API 实现的领域数据服务,以获取领域数据,例如国家数据

  • 通过 ASP.NET 6.0 Web API 实现的支付服务以完成支付

  • 通过 ASP.NET 6.0 Web API 实现的订单处理服务,用于提交和搜索订单

  • 通过 ASP.NET 6.0 Web API 实现的发票处理服务以生成发票

  • 通过 ASP.NET 6.0 Web API 实现的通知服务,用于发送电子邮件等通知

数据层

  • 通过 ASP.NET 6.0 Web API 实现的数据访问服务,用于与 Azure Cosmos DB 通信以读取/写入数据

  • Entity Framework Core 用于访问数据

Azure Stack

  • Azure Cosmos DB 作为后端数据存储

  • Azure Service Bus 用于异步消息处理

  • Azure App Service 用于托管 Web 应用程序和 Web API

  • Azure Traffic Manager 用于高可用性和响应性

  • Azure Application Insights 用于诊断和遥测

  • Azure 配对区域以提高容错能力

  • Azure 资源组用于创建 Azure 资源管理器ARM)模板并将它们部署到 Azure 订阅

  • Azure Pipelines 用于持续集成和持续部署CI/CD

我们现在已经完成了企业应用的需求。接下来,我们将探讨如何架构一个企业应用。

架构企业应用

以下架构图展示了我们将要构建的内容。在架构和开发应用时,我们需要牢记我们在本章中看到的所有的设计原则、模式和需求。以下图显示了我们的电子商务企业应用的建议架构:

图 1.15 – 电子商务应用的三层架构

图 1.15 – 电子商务应用的三层架构

关注点分离/单一职责原则在每个层级都得到了妥善处理。包含用户界面的表示层与包含业务逻辑的服务层分离。这又与包含数据存储的数据访问层分离。

高级组件对它们所消耗的低级组件不知情。数据访问层对消耗它的服务不知情,而服务对消耗它们的 UX 层也不知情。

每个服务都是根据其应执行的业务逻辑和功能进行分离的。

封装在架构层面得到了妥善处理,并且在开发过程中也应该得到妥善处理。架构中的每个组件将通过定义良好的接口和合同与其他组件进行交互。我们应该能够替换图中的任何组件,而无需担心其内部实现以及它是否遵守合同。

这里的松散耦合架构也有助于加快开发速度和更快地将产品部署到市场。多个团队可以并行工作,独立地开发各自的组件。他们在开始时共享集成测试的合同和进度表,一旦内部实现和单元测试完成,他们就可以开始集成测试。

参考以下图:

图 1.16 – 电子商务应用的三层架构,突出显示的章节

图 1.16 – 电子商务应用的三层架构,突出显示的章节

从前面的图中,我们可以识别出我们将要构建的电子商务应用的不同部分将在哪些章节中介绍。它们可以解释如下:

  • 创建 ASP.NET 网络应用(我们的电子商务门户)将在第十一章 创建 ASP.NET Core 6 网络应用 中介绍。

  • 认证将在第十二章 理解认证 中介绍。

  • 订单处理服务和发票处理服务是生成订单和开票的两个核心服务。它们将是电子商务应用的核心,因为它们负责收入。在第十章“创建 ASP.NET Core 6 Web API”中,我们将介绍如何创建 ASP.NET Core web API,而跨切面关注点将在第五章“.NET 6 中的依赖注入”、第六章“.NET 6 中的配置”和第七章“.NET 6 中的日志记录”中分别介绍。通过重用核心组件和跨切面关注点而不是重复实现,我们将遵循 DRY 原则。

  • 缓存作为产品定价服务的一部分将在第八章“所有关于缓存的知识”中进行介绍。缓存将帮助我们提高系统的性能和可扩展性,因为频繁访问的数据可以在内存中提供临时副本。

  • 数据存储、访问和提供者的数量将在第九章“在 .NET 6 中处理数据”的数据访问层部分进行介绍。我们采用的架构,其中数据和对其的访问与应用程序的其余部分分离,使我们能够更好地维护。我们选择 Azure Cosmos DB 来弹性地、独立地扩展任何数量的 Azure 区域的全局吞吐量和存储。此外,它默认安全且适用于企业。

这就结束了我们对构建企业应用程序的讨论。接下来,我们将查看企业应用程序的解决方案结构。

应用程序的解决方案结构

为了保持简单,我们将为所有项目使用单个解决方案,如下面的截图所示。当解决方案中的项目数量激增并导致维护问题时,也可以考虑为 UI、共享组件和 Web API 使用单独的解决方案的方法。下面的截图显示了我们的应用程序的解决方案结构:

![Figure 1.17 – 电子商务应用程序的解决方案结构Figure 1.17_B18507.jpg

Figure 1.17 – 电子商务应用程序的解决方案结构

在这里,我们通过为 UX、服务、数据、核心和测试分别设置单独的文件夹结构和项目来实现关注点的分离。

摘要

在本章中,我们学习了常见的原则,如 SOLID、DRY 和 KISS。我们还通过实际案例研究了各种设计模式。然后,我们探讨了不同的企业架构,确定了我们将要构建的电子商务应用程序的需求,并将所学知识应用于我们的电子商务应用程序的架构。现在,当你设计任何应用程序时,你可以应用在这里学到的知识。

在下一章中,我们将学习.NET 6 Core 和标准。

问题

  1. 什么是 LSP?

a. 基类实例应该可以替换为派生类实例。

b. 派生类实例应该可以替换为基类实例。

c. 为通用泛型设计,使其能够与任何数据类型一起工作。

答案:a

  1. 什么是 SRP?

a. 而不是使用一个通用的庞大接口,应该计划多个针对特定场景的接口,以实现更好的解耦和变更管理。

b. 你应该避免直接依赖具体实现。相反,你应该尽可能依赖抽象。

c. 实体应该只有一个责任。你应该避免赋予一个实体多个责任。

d. 实体应该以这种方式设计,以便它们易于扩展但难以修改。

答案:c

  1. 什么是 OCP?

a. 实体应该易于修改但难以扩展。

b. 实体应该易于扩展但难以修改。

c. 实体应该易于组合但难以扩展。

d. 实体应该易于抽象但难以继承。

答案:b

  1. 哪个模式用于使两个不兼容的接口协同工作?

a. 代理模式

b. 桥接模式

c. 迭代器模式

d. 适配器模式

答案:d

  1. 哪个原则确保服务可以独立部署和扩展,并且一个服务的问题将产生局部影响,这可以通过仅重新部署受影响的服务来解决?

a. 领域驱动设计原则

b. 单一职责原则

c. 无状态服务原则

d. 弹性原则

答案:b

第二章:第二章: 介绍 .NET 6 核心和标准

.NET 是一个开发者平台,它提供了用于构建多种不同类型应用程序的库和工具,例如 Web、桌面、移动、游戏、物联网IoT)和云应用程序。使用 .NET,我们可以开发针对许多操作系统的应用程序,包括 Windows、macOS、Linux、Android、iOS 等,并且它支持 x86、x64、ARM32 和 ARM64 等处理器架构。

.NET 还支持使用多种编程语言进行应用程序开发,例如 C#、Visual Basic 和 F#,使用流行的 集成开发环境IDE)如 Visual Studio、Visual Studio Code 和 Visual Studio for Mac。

在 .NET 5 之后,.NET 6 现在是一个主要版本,包括 C# 10 和 F# 6,添加了许多语言特性,并包含了许多性能改进。

本章涵盖了以下主题:

  • 介绍 .NET 6

  • 理解 .NET 6 的核心组件

  • 设置开发环境

  • 理解 CLI

  • 什么是 .NET 标准?

  • 理解 .NET 6 的跨平台和云应用程序支持

本章将帮助我们了解包含在 .NET 中用于开发应用程序的一些核心组件、库和工具。

技术要求

需要一台 Windows、Linux 或 Mac 机器,并从 dotnet.microsoft.com/download/dotnet/6.0 安装相应的 SDK。

介绍 .NET 6

2002 年,Microsoft 发布了第一个版本的 .NET Framework,这是一个用于开发 Web 和桌面应用程序的开发平台。.NET Framework 提供了许多服务,包括托管代码执行、通过基类库提供的大量 API、内存管理、公共类型系统、语言互操作性以及 ADO.NET、ASP.NET、WCF、WinForms 和 Windows 表现框架WPF)等开发框架。最初,它作为一个单独的安装程序发布,但后来被集成并随 Windows 操作系统一起发货。.NET Framework 4.8 是 .NET Framework 的最新版本。

2014 年,Microsoft 宣布了一个名为 .NET Core 的开源、跨平台的 .NET 实现。.NET Core 是从头开始构建的,使其成为跨平台,目前可在 Linux、macOS 和 Windows 上使用。.NET Core 快速且模块化,提供侧边支持,这样我们就可以在同一台机器上运行不同版本的 .NET Core,而不会影响其他应用程序。

.NET 6 是一个开源、跨平台的 .NET 实现,您可以使用它构建可在 Windows、macOS 和 Linux 操作系统上运行的应用程序。使用 .NET 6,您可以构建 Microsoft 统一的平台,用于开发浏览器、云、桌面、物联网和移动应用程序,以便使用相同的 .NET 库并轻松共享代码。

要了解 .NET 6 中的新功能,请访问 docs.microsoft.com/en-us/dotnet/core/whats-new/dotnet-6

注意

.NET 6 是一个 长期支持 (LTS) 版本;从一般可用日期起支持 3 年。建议迁移,尤其是将 .NET 5 应用迁移到 .NET 6。更多详情,请访问 dotnet.microsoft.com/en-us/platform/support/policy/dotnet-core

接下来,让我们了解 .NET 的核心特性。

理解核心特性

以下是一些 .NET 的核心特性,我们将更深入地了解:

  • 开源:.NET 是一个免费(包括商业用途在内无许可费用)且开源的开发者平台,为 Linux、macOS 和 Windows 提供了许多开发工具。其源代码由微软和 .NET 社区在 GitHub 上维护。您可以在 github.com/dotnet/core/blob/master/Documentation/core-repos.md 访问 .NET 仓库。

  • 跨平台:.NET 应用程序可以在许多操作系统上运行,包括 Linux、macOS、Android、iOS、tvOS、watchOS 和 Windows。它们也可以在多种处理器架构上保持一致运行,例如 x86、x64、ARM32 和 ARM64。

使用 .NET,我们可以构建以下类型的应用程序:

![Table 2.1 – Application types]

![Table_2.1.jpg]

表 2.1 – 应用程序类型

  • 编程语言:.NET 支持多种编程语言。用一种语言编写的代码在其他语言中也是可访问的。以下表格显示了支持的语言:

![Table 2.2 – Supported Languages]

![Table_2.2.jpg]

表 2.2 – 支持的语言

  • IDE:.NET 支持多个 IDE。让我们了解每一个:

a. Visual Studio 是一个功能丰富的 IDE,可在 Windows 平台上用于构建、调试和发布 .NET 应用程序。它有三个版本:社区版、专业版和企业版。Visual Studio 2022 社区版对学生、个人开发者和为开源项目做出贡献的组织是免费的。

b. Visual Studio for Mac 是免费的,并且适用于 macOS。它可以用来使用 .NET 开发跨平台的应用程序和游戏,适用于 iOS、Android 和网页。

c. Visual Studio Code 是一个免费、开源、轻量级但功能强大的代码编辑器,可在 Windows、macOS 和 Linux 上使用。它内置了对 JavaScript、TypeScript 和 Node.js 的支持,并且通过扩展,您可以添加对许多流行编程语言的支持。

d. Codespaces 是一个由 Visual Studio Code 驱动并由 GitHub 托管的云开发环境,用于开发 .NET 应用程序。

  • 部署模型:.NET 支持两种部署模式:

a. 自包含:当.NET 应用程序以自包含模式发布时,发布的工件包含.NET 运行时、库以及应用程序及其依赖项。自包含应用程序是特定平台的,目标机器不需要安装.NET 运行时。机器使用与应用程序一起提供的.NET 运行时来运行应用程序。

b. 框架依赖:当.NET 应用程序以框架依赖模式发布时,发布的工件仅包含应用程序及其依赖项。必须在目标机器上安装.NET 运行时才能运行应用程序。

接下来,让我们了解.NET 提供的应用程序框架。

理解应用程序框架

.NET 通过提供许多应用程序框架简化了应用程序开发。每个应用程序框架都包含一组用于开发特定应用程序的库。让我们详细了解每个框架:

  • ASP.NET Core:这是一个开源且跨平台的应用程序开发框架,允许您构建现代、基于云、互联网连接的应用程序,例如 Web、IoT 和 API 应用程序。ASP.NET Core 建立在.NET Core 之上,因此您可以在 Linux、macOS 和 Windows 等平台上构建和运行。

  • Blazor:这是一个应用程序框架,使用 C#而不是 JavaScript 构建交互式客户端 Web UI。Blazor 应用程序可以重用服务器端的代码和库,并在浏览器中使用 WebAssembly 运行,或者使用 SignalR 在服务器上处理客户端 UI 事件。

  • WPF:这是一个 UI 框架,允许您为 Windows 创建桌面应用程序。WPF 使用可扩展应用程序标记语言XAML),这是一种用于应用程序开发的声明性模型。

  • Entity FrameworkEF)Core:这是一个开源、跨平台、轻量级的对象关系映射ORM)框架,用于使用.NET 对象与数据库交互。它支持 LINQ 查询、更改跟踪和模式迁移。它支持 SQL Server、SQL Azure、SQLite、Azure Cosmos DB、MySQL 等流行数据库。

  • 语言集成查询LINQ):这为.NET 编程语言添加了查询功能。LINQ 允许您使用相同的 API 从数据库、XML、内存中的数组以及集合中查询数据。

  • .NET MAUI:.NET 多平台应用程序 UI 是一个跨平台框架,使用 C#和 XAML 创建原生移动和桌面应用程序。使用.NET MAUI,您可以使用相同的代码库开发针对 Android、iOS、macOS 和 Windows 的应用程序。

    注意

    .NET MAUI 目前处于预览阶段,不建议用于生产环境。有关更多信息,您可以参考docs.microsoft.com/en-us/dotnet/maui/what-is-maui

在下一节中,让我们了解.NET 的核心组件。

理解.NET 的核心组件

.NET 有两个主要组件:运行时和基础类库。运行时包括垃圾回收器(GC)和即时编译器(JIT),它管理 .NET 应用程序的执行以及基础类库(BCLs),也称为运行时库或框架库,它们包含 .NET 应用程序的基本构建块。

.NET SDK 可在 dotnet.microsoft.com/download/dotnet/6.0 下载。它包含一组用于开发和运行 .NET 应用程序的库和工具。您可以选择安装 SDK 或 .NET 运行时。要开发 .NET 应用程序,您应该在开发机上安装 SDK,并安装 .NET 运行时来运行 .NET 应用程序。.NET 运行时包含在 .NET SDK 中,因此如果您已经安装了 .NET SDK,则无需单独安装 .NET 运行时。

![图 2.1 – .NET SDK 的可视化图片 2.1

图 2.1 – .NET SDK 的可视化

.NET SDK 包含以下组件:

  • 公共语言运行时(CLR):CLR 执行代码并管理内存分配。当 .NET 应用程序编译时,它产生一个中间语言(IL)。CLR 使用 JIT 编译器将编译后的代码转换为处理器能理解的机器代码。它是一个跨平台的运行时,可在 Windows、Linux 和 macOS 上使用。

  • 内存管理:GC 管理 .NET 应用程序的内存分配和释放。对于每个新创建的对象,内存都在托管堆中分配,当没有足够的可用空间时,GC 会检查托管堆中的对象,并在它们不再在应用程序中使用时移除它们。有关更多信息,您可以参考 docs.microsoft.com/en-us/dotnet/standard/garbage-collection

  • JIT:当 .NET 代码编译时,它被转换为 IL。IL 是平台和语言无关的,因此当运行时运行应用程序时,JIT 将 IL 转换为处理器能理解的机器代码。

  • 公共类型系统:它定义了在 CLR 中如何定义、使用和管理类型。它使跨语言集成成为可能,并确保类型安全。

  • System.StringSystem.Boolean,如 List<T>Dictionary<Tkey, Tvalue> 这样的集合,以及执行 I/O 操作、HTTP、序列化等实用函数。它简化了 .NET 应用程序的开发。

  • Roslyn 编译器:Roslyn 是一个开源的 C# 和 Visual Basic 编译器,具有丰富的代码分析 API。它允许使用与 Visual Studio 相同的 API 构建代码分析工具。

  • MSBuild:这是一个用于构建 .NET 应用程序的工具。Visual Studio 使用 MSBuild 构建 .NET 应用程序。

  • NuGet:这是一个开源的包管理工具,您可以使用它创建、发布和重用代码。NuGet 包包含编译后的代码、其依赖文件以及包含包版本号信息的清单。

在下一节中,让我们了解如何设置开发环境以创建和运行 .NET 应用程序。

设置开发环境

设置开发环境非常简单。您需要 .NET SDK 来构建和运行 .NET 应用程序。可选地,您可以选择安装支持 .NET 应用程序开发的 IDE。您需要执行以下步骤来在您的机器上设置 .NET SDK:

注意

.NET 6 由 Visual Studio 2022 和 Visual Studio 2022 for Mac 支持。它不支持在更早版本的 Visual Studio 上运行。Visual Studio Community Edition 对个人开发者、课堂学习和在组织内部署研究或开源项目的不限用户是免费的。它提供了与专业版相同的功能,但为了获得高级功能,如高级调试和诊断工具、测试工具等,您需要拥有企业版。要比较功能,您可以访问 visualstudio.microsoft.com/vs/compare

  1. 在 Windows 机器上,从 visualstudio.microsoft.com 下载并安装 Visual Studio 17.0 或更高版本。

  2. 在安装选项中,从 工作负载,您可以选择 ASP.NET 和 Web 用于 Web/API 应用程序、Azure 开发、使用 .NET 进行 iOS、Android、Windows 的移动开发以及用于 Windows 应用程序的 .NET 桌面开发,如下面的截图所示:

![图 2.2 – Visual Studio 安装,工作负载选择图片

图 2.2 – Visual Studio 安装,工作负载选择

  1. 确认选择并继续完成安装。这将安装 Visual Studio 和 .NET 6 SDK 到您的机器上。

    注意

    Azure 开发工作负载包括用于开发和支持针对 Azure 服务的应用程序的 SDK 和工具。它包括容器开发、Azure 资源管理器、Azure 云服务、Service Fabric、Azure 数据湖和流分析、快照调试器等工具。

或者,您也可以执行以下步骤来设置它:

  1. dotnet.microsoft.com/download/dotnet/6.0 下载并安装 .NET 6 SDK for Windows、macOS 和 Linux。.NET Core 支持并行执行,因此我们可以在开发机上安装多个版本的 .NET Core SDK。

  2. 从命令提示符运行 dotnet --version 命令以验证安装的版本,如下面的截图所示:

![图 2.3 – dotnet 命令的命令行输出图片

图 2.3 – dotnet 命令的命令行输出

  1. 可选地,您可以从code.visualstudio.com下载并安装 Visual Studio Code,以使用它来开发.NET 应用程序。

现在我们已经了解了如何设置.NET 的开发环境,在下一节中,让我们了解.NET CLI 是什么以及它是如何帮助从命令行创建、构建和运行.NET 应用程序的。

要设置一个电子商务应用程序,您可以参考github.com/PacktPublishing/Enterprise-Application-Development-with-C-10-and-.NET-6-Second-Edition/blob/main/README.md

理解 CLI

.NET CLI 是一个跨平台的命令行界面工具,可用于开发、构建、运行和发布.NET 应用程序。它包含在.NET SDK 中。

CLI 命令结构包含命令驱动程序dotnet),命令命令-参数选项,这是大多数 CLI 操作的一个常见模式。请参考以下命令模式:

driver command <command-arguments> <options>

例如,以下命令创建一个新的控制台应用程序。dotnet是驱动程序,new是命令,console是一个作为参数的模板名称:

dotnet new console

以下表格说明了几个命令以及 CLI 支持的命令的简要描述:

表 2.3 – CLI 命令

表 2.3 – CLI 命令

让我们创建一个简单的控制台应用程序,并使用.NET CLI 运行它:

注意

要执行以下步骤,作为先决条件,您应该在您的机器上安装.NET SDK。您可以从 https://dotnet.microsoft.com/download/dotnet/6.0 下载并安装它。

  1. 在命令提示符中,运行以下命令以创建一个名为HelloWorld的项目控制台应用程序:

    dotnet new console --name HelloWorld
    
  2. 此命令将基于console应用程序模板创建一个名为HelloWorld的新项目。请参考以下屏幕截图:

图 2.4 – 新控制台应用程序的命令行输出

图 2.4 – 新控制台应用程序的命令行输出

  1. 运行以下命令来构建和运行应用程序:

    dotnet run --project ./HelloWorld/HelloWorld.csproj
    
  2. 之前的命令将构建并运行应用程序,并将输出打印到命令窗口,如下所示:

图 2.5 – 控制台应用程序运行时的命令行输出

图 2.5 – 控制台应用程序运行时的命令行输出

在前面的步骤中,我们创建了一个新的控制台应用程序,并使用.NET CLI 运行了它。

global.json 概述

在开发机器上,如果global.json文件中安装了多个.NET SDK,您可以定义用于运行.NET CLI 命令的.NET SDK 版本。通常,如果没有定义global.json文件,则使用 SDK 的最新版本,但您可以通过定义global.json来覆盖此行为。

运行以下命令将在当前目录中创建一个 global.json 文件。根据您的需求,您可以选择要配置的版本:

dotnet new globaljson --sdk-version 2.1.811

以下是一个通过运行前面的命令创建的示例 global.json 文件:

{
  "sdk": {
    "version": "2.1.811"
  }
}

在这里,global.json 已配置为使用 .NET SDK 版本 2.1.8.11。.NET CLI 使用此 SDK 版本来构建和运行应用程序。

更多关于 .NET CLI 的信息,您可以参考 docs.microsoft.com/en-us/dotnet/core/tools

在下一节中,让我们了解什么是 .NET Standard。

什么是 .NET Standard?

.NET Standard 是一组适用于多个 .NET 实现的 API 规范。每个新的 .NET Standard 版本都会添加新的 API。每个 .NET 实现都针对特定的 .NET Standard 版本,并可以访问该 .NET Standard 版本支持的所有 API。

针对 .NET Standard 构建的库可以在使用支持这些 .NET Standard 版本的 .NET 实现构建的应用程序中使用。因此,在构建库时,针对更高版本的 .NET Standard 允许使用更多的 API,但只能用于使用支持它的 .NET 实现版本构建的应用程序。

以下截图列出了支持 .NET Standard 2.0 的各种 .NET 实现版本:

图 2.6 – 支持 .NET Standard 2.0 的 .NET 实现

图 2.6 – 支持 .NET Standard 2.0 的 .NET 实现

例如,如果你开发一个针对 .NET Standard 2.0 的库,它可以访问超过 32,000 个 API,但它支持的 .NET 实现版本较少。如果你想让你的库能够被最多的 .NET 实现访问,那么请选择最低可能的 .NET Standard 版本,但这样你可能需要在可用的 API 上做出妥协。

让我们了解何时使用 .NET 6 和 .NET Standard。

理解 .NET 6 和 .NET Standard 的使用

.NET Standard 使得在不同 .NET 实现之间共享代码变得容易,但 .NET 6 提供了一种更好的共享代码和运行在多个平台上的方法。.NET 6 统一了 API 以支持桌面、Web、云、移动和跨平台控制台应用程序。

.NET 6 实现了 .NET Standard 2.1,因此你的现有代码,如果针对 .NET Standard,可以在 .NET 6 上运行;除非你想访问新的运行时功能、语言功能或 API,否则你不需要更改目标框架标记符TFM)。你可以多目标到 .NET Standard 和 .NET 6,这样你就可以访问新功能并让你的代码可供其他 .NET 实现使用。

如果你正在构建需要与 .NET Framework 一起工作的可重用库,那么请将它们针对 .NET Standard 2.0。如果你不需要支持 .NET Framework,那么你可以针对 .NET Standard 2.1 或 .NET 6。建议针对 .NET 6 以获取对新的 API、运行时和语言特性的访问。

使用 .NET CLI,运行以下命令创建一个新的类库:

dotnet new classlib -o MyLibrary

它创建了一个以 .net6.0 或开发机上可用的最新 SDK 为目标框架的类库项目。

如果你检查 MyLibrary\MyLibrary.csproj 文件,它应该看起来如下面的片段所示。你会注意到目标框架被设置为 net6.0

<Project Sdk="Microsoft.NET.Sdk">
  <PropertyGroup>
    <TargetFramework>net6.0</TargetFramework>
     <ImplicitUsings>enable</ImplicitUsings>
     <Nullable>enable</Nullable>
  </PropertyGroup>
</Project>

你可以在使用 .NET CLI 创建类库时强制使用特定的目标框架版本。以下命令创建了一个针对 .NET Standard 2.0 的类库:

dotnet new classlib -o MyLibrary -f netstandard2.0

如果你检查 MyLibrary\MyLibrary.csproj 文件,它看起来如下面的片段所示,其中目标框架是 netstandard2.0

<Project Sdk="Microsoft.NET.Sdk">
  <PropertyGroup>
    <TargetFramework>netstandard2.0</TargetFramework>
  </PropertyGroup>
</Project>

如果你创建了一个针对 .NET Standard 2.0 的库,它也可以在针对 .NET Core 以及 .NET Framework 构建的应用程序中访问。

可选地,你可以针对多个框架;例如,在以下代码片段中,库项目被配置为针对 .NET 6.0 和 .NET Standard 2.0:

<Project Sdk="Microsoft.NET.Sdk">
  <PropertyGroup>
    <TargetFrameworks>net6.0;netstandard2.0
      </TargetFrameworks>
  </PropertyGroup>
</Project>

当你配置你的应用程序以支持多个框架并构建项目时,你会注意到它为每个目标框架版本创建了工件。请参考以下截图:

![图 2.7 – 针对多个框架的构建工件

![img/Figure_2.7_B18507.jpg]

图 2.7 – 针对多个框架的构建工件

让我们在这里总结一下信息:

  • 使用 .NET Standard 2.0 在 .NET Framework 和所有其他平台之间共享代码。

  • 使用 .NET Standard 2.1 在 Mono、Xamarin 和 .NET Core 3.x 之间共享代码。

  • 从现在开始使用 .NET 6 进行代码共享。

在下一节中,让我们了解 .NET 6 的跨平台能力和云应用程序支持。

理解 .NET 6 的跨平台和云应用程序支持

.NET 有许多实现。每个实现包含运行时、库、应用程序框架(可选)和开发工具。有四个 .NET 实现:

  • .NET Framework

  • .NET 6

  • 通用 Windows 平台 (UWP)

  • Mono

而所有这些实现共有的 API 规范集合是 .NET Standard。

多个 .NET 实现使你能够创建针对许多操作系统的 .NET 应用程序。你可以为以下操作系统构建 .NET 应用程序:

![表 2.4 – .NET 实现

![img/Table_2.4.jpg]

表 2.4 – .NET 实现

让我们更深入地了解 .NET 实现:

  • .NET Framework 是 .NET 的初始实现。使用 .NET Framework,您可以开发针对 Windows、WPF、Web 应用程序以及 Web 和 WCF 服务,目标操作系统为 Windows。.NET Framework 4.5 及以上版本实现了 .NET Standard,因此针对 .NET Standard 构建的库可以在 .NET Framework 应用程序中使用。

    注意事项

    .NET Framework 4.8 是 .NET Framework 的最后一个版本,未来将不再发布新版本。Microsoft 将继续将其包含在 Windows 中,并提供安全性和错误修复支持。对于新开发,建议使用 .NET 6 或更高版本。

  • UWP 是 .NET 的一个实现,您可以使用它来构建支持触控的 Windows 应用程序,这些应用程序可以在 PC、平板电脑、Xbox 等设备上运行。

  • Mono 是 .NET 的一个实现。它是一个小型运行时,为 Xamarin 提供动力,用于开发原生 Android、macOS、iOS、tvOS 和 watchOS 应用程序。它实现了 .NET Standard,因此针对 .NET Standard 构建的库可以在使用 Mono 构建的应用程序中使用。

  • .NET 6 是 .NET 的开源、跨平台实现,您可以使用它来构建控制台、Web、桌面和云应用程序,这些应用程序可以在 Windows、macOS 和 Linux 操作系统上运行。.NET 6 现在是 .NET 的主要实现,它基于单个代码库,具有统一的功能和 API 集合,这些可以由 .NET 应用程序使用。

.NET 6 SDK,包括库和工具,还包含多个运行时,包括 .NET 运行时、ASP.NET Core 运行时和 .NET 桌面运行时。要运行 .NET 6 应用程序,您可以选择安装 .NET 6 SDK 或相应的平台和特定工作负载的运行时:

  • .NET 运行时 仅包含运行控制台应用程序所需的组件。要运行 Web 或桌面应用程序,您需要单独安装 ASP.NET Core 运行时和 .NET 桌面运行时。.NET 运行时可在 Linux、macOS 和 Windows 上使用,并支持 x86、x64、ARM32 和 ARM64 处理器架构。

  • ASP.NET Core 运行时 允许您运行 Web/服务器应用程序,并且可在 Linux、macOS 和 Windows 上为 x86、x64、ARM32 和 ARM64 处理器架构提供支持。

  • .NET 桌面运行时 允许您在 Windows 上运行基于 Windows/WPF 的桌面应用程序。它适用于 x86 和 x64 处理器架构。

.NET 运行时在多个平台上的可用性使得 .NET 6 具有跨平台性。在目标机器上,您只需安装所需的工作负载的运行时,然后运行应用程序即可。

现在,让我们探索 Azure 提供的运行 .NET 6 的服务。

云支持

.NET 6 获得了包括 Azure、Google Cloud 和 AWS 在内的主流云服务提供商的支持。让我们了解一些可以在 Azure 中运行 .NET 6 应用程序的服务:

使用 .NET 6,我们可以开发企业级服务器应用程序或高度可扩展的微服务,这些服务可以在云中运行。我们可以为 iOS、Android 和 Windows 操作系统开发移动应用程序。.NET 代码和项目文件看起来很相似,开发者可以重用技能或代码来开发针对不同平台的不同类型的应用程序。

摘要

在本章中,我们学习了 .NET 是什么以及其核心功能。我们了解了 .NET 提供的应用程序框架及其支持的不同的部署模型。接下来,我们学习了 .NET 提供的核心组件、工具和库,并学习了如何在机器上设置开发环境。

我们还研究了 .NET CLI,并使用 .NET CLI 创建了一个示例应用程序。接下来,我们学习了 .NET Standard 是什么以及何时使用 .NET 6 和 .NET Standard,然后通过讨论各种 .NET 实现、.NET 6 跨平台支持和云支持来结束本章。

在下一章中,我们将学习 C# 10.0 的新特性。

问题

  1. .NET Core 是以下哪个?

a. 开源

b. 跨平台

c. 免费

d. 所有以上选项

答案:d

  1. .NET Standard 2.0 库由以下哪个支持?

a. .NET Framework 4.6.1 或更高版本

b. .NET Core 2.0 或更高版本

c. .NET 6

d. Mono 5.4+ 或更高版本

e. 所有以上选项

答案:e

  1. 运行 CLI 命令所必需的 .NET CLI 驱动程序是以下哪个?

a. net

b. core

c. dotnet

d. none

答案:c

  1. .NET SDK 包含以下哪些内容?

a. The .NET CLI

b. BCL

c. 运行时

d. 所有以上选项

答案:d

进一步阅读

要了解更多关于 .NET 6 的信息,您可以参考 https://docs.microsoft.com/en-us/dotnet/core/introduction。

第三章:第三章:介绍 C# 10

C# 是一种优雅且类型安全的面向对象编程语言,它允许开发者构建各种安全且健壮的应用程序,这些应用程序在 .NET 生态系统中运行,并在 GitHub 发布的流行编程语言排行榜上名列前茅。

C# 最初由微软的 Anders Hejlsberg 开发,作为 .NET 创新计划的一部分。自 2002 年 1 月首次发布以来,该语言一直持续添加新功能,以提高性能和生产力。

C# 10 与 .NET 6 一起发布,带来了一些酷炫的新语言特性,以及对早期版本中发布特性的增强,从而提高了开发者的生产力。在本章中,我们将探讨一些新的 C# 语言特性:

  • 使用指令的简化

  • 记录结构体

  • Lambda 表达式的改进

  • 插值字符串的增强

  • 扩展属性模式

  • 调用者参数属性的添加

到本章结束时,你将熟悉 C# 10 的主要新增功能。此外,本章将帮助我们提升技能,以便用 C# 构建我们的下一个企业级应用程序。

技术要求

为了理解本章的概念,你需要以下内容:

  • 配备 .NET 6.0 运行的 Visual Studio 2022 版本 17.0 社区版

  • 对 Microsoft .NET 的基本理解

本章使用的代码可以在 github.com/PacktPublishing/Enterprise-Application-Development-with-C-10-and-.NET-6-Second-Edition/tree/main/Chapter03 找到。

使用指令的简化

顶级语句是 C# 9.0 中引入的新特性,它使得开发者能够轻松移除仪式性代码。Visual Studio 2022 中的项目模板采用了 C# 中引入的语言变化,如顶级语句。例如,如果你创建一个 Console 应用程序,你将看到 Program.cs 文件包含以下代码片段:

// See https://aka.ms/new-console-template for more //information
Console.WriteLine("Hello, World!");

前面的代码包含了一个没有仪式性代码(如类定义和 main 方法)的 console 应用程序模板。通过删除冗余代码,这简化了我们能够编写的行数。

C# 10 中引入的 implicit 使用指令和 global 使用指令的概念减少了每个 CS 文件中 using 语句的重复。

全局使用指令

使用全局 using 指令,我们不需要在所有 .cs 文件中重复 namespace using 语句。global 关键字用于标记全局 using 指令,如下面的代码片段所示:

global using System.Threading;

在前面的代码中,我们将 System.Threading 标记为 global。现在,我们可以在 .cs 文件的开头使用 using 指令来引用 System.Threading 下的类型。

我们还可以创建对命名空间的 global 别名,以解决命名空间冲突,如下面的代码片段所示:

global using SRS = System.Runtime.Serialization;

通过定义这一点,我们可以使用别名 SRS 来引用 System.Runtime.Serialization 下定义的所有类。我们还可以定义一个全局 using static 指令,如下所示:

global using static System.Console;

因此,我们可以直接使用 System.Console 类中定义的所有 static 函数,而无需引用类名。例如,要向控制台写入一行,我们只需调用 WriteLine 方法,而无需引用 Console 类名,如下所示:

WriteLine("Hello C# 10");

我们可以在项目的任何 .cs 文件中指定全局 using 指令。唯一的约束是它们应该出现在任何常规文件作用域的 using 指令之前。开发者中常见的做法是创建一个名为 GlobalUsings.cs.cs 文件,并在该文件中添加全局 using 指令。这样,当我们需要添加或删除全局 using 指令时,可以限制更改仅限于单个文件。全局 using 指令的作用域是当前项目的工作单元。

隐式 using 指令

在 C# 中,我们发现一些框架命名空间,如 SystemSystem.Linq,几乎存在于所有类中。在 C# 10 中,这些常用命名空间被隐式添加为全局 using 指令。隐式添加的命名空间基于项目目标 SDK,并在以下位置进行文档说明:https://docs.microsoft.com/en-us/dotnet/core/project-sdk/overview#implicit-using-directives。

此外,如果我们希望将任何其他命名空间包含在这些隐式指令中或从预定义的命名空间中删除任何命名空间,我们可以通过向 .csproj 文件中添加 ItemGroup 来实现,如下所示:

<ItemGroup>
  <Using Include="System.Threading" />
  <Using Remove="System.IO" />
</ItemGroup>

在前面的代码片段中,我们包括了 System.Threading 并从隐式 using 指令中移除了 System.IO

要完全移除隐式 using 指令,我们可以在 .csproj 文件中取消选中 ImplicitUsings 标志,如下所示:

<ImplicitUsings>disable</ImplicitUsings>

using 指令的简化是朝着移除冗余的仪式代码并使 .cs 文件中的内容简洁的又一步。

在下一节中,我们将探讨 C# 10 中引入的 record 结构体。

记录结构体

C# 9 中引入的 record types 提供了类型声明,用于创建具有合成方法(用于检查相等性和 ToString)的不可变引用类型。C# 10 带来了 record structs。在本节中,我们将了解 record struct 是什么以及它与 record class 的区别。

我们使用 record 关键字来声明 record class,同样使用相同的 record 关键字来声明 record struct,如下面的代码所示:

public record struct Employee(string Name);

注意

在 C# 9 中,要声明一个 record 类,我们不需要显式使用 class 关键字。我们只需指定 record 来声明它,如下所示:public record Shape(string Name);

仅使用 record 关键字将继续在 C# 10 中工作以声明 record 类,但为了更好的可读性,建议明确指定 classstruct

record struct 提供了与 record class 类似的好处,例如以下内容:

  • 简化的声明语法

  • 值相等性

  • 引用语义

  • 解构

  • 有意义的 ToString 输出

让我们通过创建一个示例 Console 应用程序并定义一个 EmployeeRecord 记录结构体来理解这些内容。将以下代码添加到 Program.cs 文件中,该文件使用前面代码片段中定义的 EmployeeRecord 记录结构体:

using static System.Console;
 public record struct EmployeeRecord(string Name);
Employee employee1 = new EmployeeRecord("Suneel", "Kunani");
Employee employee2 = new EmployeeRecord("Suneel", "Kunani");
WriteLine(employee1.ToString());
WriteLine($"HashCode of s1 is :{ employee1.GetHashCode()}");
WriteLine($"HashCode of s2 is :{ employee2.GetHashCode()}");
WriteLine($"Is s1 equals s2 : { employee1 == employee2}");
//deconstruct the fields from the employee object
string firstName;
 (firstname, var lastname) = employee1;
Console.WriteLine($"firstname: {firstname}, lastname:{lastname}");

在前面的代码中,我们使用简化的声明语法创建了两个 EmployeeRecord 记录结构体实例,并使用名称字段值,然后打印实例对象的哈希码,并检查相等性。在这里,我们还在从 employee 对象中解构字段。

当我们运行代码时,我们看到如下截图所示的输出:

![Figure 3.1 – 记录结构体示例的输出Figure 3.1 – 图 3.1

图 3.1 – 记录结构体示例的输出

当我们查看输出时,我们观察到 ToString 被重写以包含实例的内容。正如预期的那样,两个实例的哈希码相同,因为字段值相同。在常规结构体中,对象的哈希码是基于第一个非空字段生成的。在 record 结构体中,GetHashCode 方法也被重写以包含所有字段以生成哈希码。

record 结构体综合了 IEquatable<T> 接口的实现。它还实现了 ==!= 操作符。常规结构体默认没有实现这些操作符。常规结构体从 ValueType 继承了 Equals 方法,该方法使用反射来进行相等性检查。因此,record 结构体中的综合相等性检查性能更优。record 结构体还综合了 Deconstruct 方法来填充对象外的字段。如果你仔细查看解构代码,你会注意到变量的混合声明。我们在解构过程中声明了 lastName,而在前面的语句中声明了 firstName。这种变量的混合声明仅在 C#10 及以上版本中是可能的。

当我们在反汇编工具(如 ILSpy 或 Reflector)中反汇编代码时,我们看到生成的代码如下所示:

![Figure 3.2 – 员工类生成的代码Figure 3.2 – 图 3.2

图 3.2 – 员工类生成的代码

注意

你可以从 marketplace.visualstudio.com/items?itemName=SharpDevelopTeam.ILSpy 安装 ILSpy。

如果我们仔细查看 Employee 类型的定义,我们可以看到 C# 编译器为 record struct 类型合成的所有管道。从这一点,我们可以理解 record 结构体基本上是一个实现了 IEquatable 接口并重写了 GetHashCodeToString 方法的结构体。您可以重写 ToString 方法以创建记录类型的自定义字符串表示形式。从 C# 10 开始,您还可以将 ToString 重写标记为 sealed,这可以防止编译器合成 ToString 方法或派生类型重写它。在基记录类型中密封 ToString 方法确保了所有派生类型的字符串表示形式的一致性。编译器还提供了 Deconstruct 方法,它用于将 record 结构体分解为其组件属性。与 record 类不同,record 结构体是可变的。要使 record 结构体不可变,我们可以在声明中添加 readonly 修饰符:

public readonly record struct Employee(string Name);

要更改 readonly record 结构体的字段,我们可以使用如下所示的运算符,就像使用 record 类一样:

Employee employee2 = employee1 with { LastName = string.Empty };

在本节中,我们学习了 C# 10 中引入的 record 结构体以及它与 record 类和常规结构体的比较。在下一节中,我们将学习 Lambda 表达式的改进。

Lambda 表达式的改进

Lambda 表达式是一种表示匿名方法的方式。它允许我们内联定义方法实现。

可以从任何 Lambda 表达式创建委托类型。Lambda 表达式的参数类型和返回值类型决定了它可以转换成的委托类型。如果 Lambda 表达式不返回值,则可以将其更改为 Action 委托类型;否则,它可以转换为 Func 委托类型之一。在本节中,我们将学习 C# 10 带给 Lambda 表达式的改进。

推断表达式类型

如果参数的类型是显式的并且可以推断出返回类型,则 C# 语言编译器现在将推断表达式类型。例如,考虑以下代码片段,其中我们定义了一个 Lambda 表达式来查找给定整数的平方:

Var Square = (int x) => x * x;

在前面的代码中,参数 x 的类型指定为 int,返回类型从表达式中推断为 int。如果我们将鼠标悬停在 Visual Studio 中的 var 上,我们可以看到 Square Lambda 表达式的推断类型,如下一张截图所示,它使用 Func 委托:

![图 3.3 – Square 表达式的推断类型]

图片

图 3.3 – Square 表达式的推断类型

对于这里显示的代码,编译器将使用 Action 委托,因为表达式的返回类型是 void

var SayHello = (string name) => Console.WriteLine($"Hello {name}");

如果合适,推断的类型将使用 FuncAction 委托。否则,编译器将合成一个委托类型,例如,如果 Lambda 表达式接受 ref 类型,如下面的代码片段所示:

Var SayWelcome = (ref string name) => Console.WriteLine($"Welcome {name}");

之前表达式的合成类型将是一个匿名委托类型。

编译器将尝试根据表达式推断返回类型。有时,可能无法推断类型。如果无法推断类型信息,我们将得到编译错误。

Lambda 表达式的返回类型

在编译器无法推断返回类型的情况下,我们可以在 C# 10 中显式指定。考虑以下代码片段,其中我们定义了从 Person 记录类继承的 EmployeeManager 记录类:

public record class Person();
public record class Employee() : Person();
public record class Manager() : Person();
var createExpression = (bool condition) => condition ? new Employee() : new Manager();

上一段代码片段中的 createExpression 术语根据传入的条件创建 EmployeeManager 类型的实例。在这种情况下,编译器无法推断返回类型,这将导致编译错误。从 C#10 开始,我们可以显式指定 Lambda 表达式的返回类型,如下面的代码所示:

var createEmployee = Person (bool hasReportees) => condition ? new Manager() : new Employee();
// Create the Person object based on condition
var manager = createEmployee(true);

上一段代码推断的表达式类型是 Func<bool, Person>

向 Lambda 表达式添加属性

从 C# 10 开始,我们可以向 Lambda 表达式及其参数和返回类型添加属性。以下代码片段定义了一个 Lambda 表达式,用于检索给定 ID 的员工:

var GetEmployeeById =  [Authorize] Employee ([FromRoute]int id) => { return new Employee { }; };

GetEmployeeeById 表达式具有 [Authorize] 属性,而 id 参数带有 [FromRoute] 属性。

Lambda 表达式上的属性在调用时没有任何效果,因为调用是通过底层委托类型进行的。Lambda 表达式上定义的属性可以通过反射发现。

ASP.NET 6.0 中引入的最小 API 是这些改进背后的驱动力之一。我们将在 第十章 中看到它的用法,即 创建一个 ASP.NET Core 6 Web API

在本节中,我们学习了 Lambda 表达式的改进;在下一节中,我们将看到对插值字符串的改进。

对插值字符串的改进

几乎每个应用程序都会有一些文本处理的需求。在 .NET 中,有许多字符串操作的方法可用,例如 string 原始类型、StringBuilder、类型上的 ToString 覆盖、字符串连接以及 string.Format,后者提供了从复合格式字符串构建字符串的功能。String.Format 接收一个格式字符串和格式项作为输入,并生成如以下代码所示的格式化字符串:

string message = string.Format("{0}, {1}!", Greeting, Message);

在之前的代码中,格式字符串中的 {0}{1} 位置将被分别传递的 GreetingMessage 格式项填充。为了使其更友好和易于阅读,C# 6 添加了一种新的语言语法,称为 插值字符串,如下面的代码片段所示:

string Greeting = "Hello";
string Language = "C#";
int version = 10;
string message = $"{Greeting}, {Language}!";
string messageWithVersion = $"{Greeting}, {Language} {version}!";

当使用插值字符串语法时,.NET 编译器生成最适合插值字符串以产生相同结果的代码。

使用反汇编器,如 ILSpy 或 SharpLab,查看之前代码片段生成的代码;它将类似于以下代码片段:

String text = "Hello";
string text2 = "C#";
int num = 10;
string text3 = text + ", " + text2 + "!";
string text4 = string.Format("{0}, {1} {2}!", text, text2, num);

注意

sharplab.io/ 是一个 .NET 代码沙盒,它显示了代码编译的中间结果。

对于 message 插值字符串,代码是通过连接生成的。对于第二个字符串 messageWithVersion,其中涉及非字符串字面量,生成的代码使用 string.Format

编译器做了它打算做的事情,但它有几个问题,其中代码是通过使用 string.Format 生成的:

  • 编译器解析了插值字符串以生成使用 string.Format 的代码。相同的字符串也必须由 .NET 运行时解析以找到字面量位置。

  • string.Format 方法中字面量的参数类型是 Object。因此,在 string.Format 中使用的任何值类型都会涉及装箱。

  • string.Format 的重载最多接受三个参数。超过三个参数由接受 params object[] 的重载提供支持。因此,超过三个参数需要实例化一个数组。

  • 由于 string.Format 只接受 Object 类型,因此我们不能使用 ref struct 类型,如 Span<T>ReadOnlySpan<char>

  • 由于 ToString 是在捕获的表达式上被调用的,因此会创建多个临时字符串。

这里提到的所有缺点都将通过 C# 10 生成一系列追加到字符串构建器的代码来解决。对于之前讨论的相同代码,如果你查看 C# 10 中生成的代码,它将使用 DefaultInterpolatedStringHandler,如下面的代码片段所示:

string Greeting = "Hello";
string Language = "C#";
int version = 10;
string message = Greeting + ", " + Language + "!";
DefaultInterpolatedStringHandler defaultInterpolatedStringHandler = new DefaultInterpolatedStringHandler(4, 3);
defaultInterpolatedStringHandler.AppendFormatted(Greeting);
defaultInterpolatedStringHandler.AppendLiteral(", ");
defaultInterpolatedStringHandler.AppendFormatted(Language);
defaultInterpolatedStringHandler.AppendLiteral(" ");
defaultInterpolatedStringHandler.AppendFormatted(version);
defaultInterpolatedStringHandler.AppendLiteral("!");
string messageWithVersion = defaultInterpolatedStringHandler.ToStringAndClear();

对于插值字符串,C# 10 编译器现在使用 DefaultInterpolatedStringHandler 而不是 string.Format。在之前生成的代码中,DefaultInterpolatedStringHandler 是通过传递两个参数构建的,即插值字符串字面部分中的字符数和要填充的字符串中的位置数。通过调用 AppendLiteralAppendFormatted 来分别追加字面量或格式化字符串。随着插值字符串处理器的引入,之前讨论的问题得到了解决。

对于用 C# 早期版本编写的相同插值字符串代码,在 C# 10 中将会有性能提升。我们还可以构建我们自己的自定义插值字符串处理器,这在数据不打算用作字符串或条件执行对目标方法来说是逻辑上的合适选择的情况下可能很有用。

在本节中,我们学习了插值字符串的改进,它使我们比早期版本有更好的性能。在下一节中,让我们学习扩展属性模式。

扩展属性模式

模式匹配是一种检查对象值或具有完整或部分匹配到序列的属性值的方法。这在 C# 中以 if…elseswitch…case 语句的形式得到支持。在现代语言中,尤其是在像 F# 这样的函数式编程语言中,有对模式匹配的高级支持。从 C# 7.0 开始,引入了新的模式匹配概念。模式匹配提供了一种不同的方式来表达条件,以使代码更具可读性。自 C# 7 引入以来,模式匹配在每次主要版本发布时都得到了扩展。

在本节中,让我们学习 C# 10 中引入的扩展属性模式。

考虑以下代码片段:

Product product = new Product
{
    Name ="Men's Shirt",
    Price =10.0m,
    Location = new Address
    {
        Country ="USA",
        State ="NY"
    }
};

在这个代码片段中,我们有一个 Product 类型的对象,它包含产品的产地位置。在 C# 10 之前,如果我们想检查这个产品的原产国是否为美国,我们会做类似于以下代码片段的事情:

if (product is Product { Location: { Country: "USA" } })
    Console.WriteLine("USA"); 

在 C# 10 中,我们可以访问扩展属性以使其更具可读性,如下面的代码片段所示:

if (product is Product { Location.Country : "USA"  })
    Console.WriteLine("USA");

在前面的代码中,我们正在使用扩展属性模式验证 LocationCountry 属性。

在本节中,我们学习了关于扩展属性模式的内容。让我们在下一节中学习 caller 参数属性的新增内容。

添加到调用者参数属性

C# 5 首次引入了 caller 参数属性。它们是 CallerMemberNameCallerFilePathCallerLineNumber。这些属性使得编译器在生成的代码中填充方法参数。它们在各种场景中使用,例如在 MVVM 模式下触发 OnNotifyPropertyChanged 事件时填充更多的调试跟踪数据。例如,考虑以下代码片段,它定义了一个 Gift 模型:

public class Gift : INotifyPropertyChanged
{
    private string _description;
    public string Description
    {
        get
        {
            return _description;
        }
        set
        {
            _description = value;
            OnPropertyRaised();
        }
    }
    public event PropertyChangedEventHandler 
      PropertyChanged;
    private void OnPropertyRaised([CallerMemberName] string 
      propertyname="")
    {
        if (PropertyChanged != null)
        {
            PropertyChanged(this, new 
              PropertyChangedEventArgs(propertyname));
        }
    }
} 

在前面的 Gift 类定义中,每当 Description 属性的设置器被调用时,都会调用 OnPropertyChanged 方法。在 OnPropertyChanged 方法实现中,我们有一个带有 CallerMemberName 属性的 propertyName 参数。这将使编译器生成如下所示的设置器:

public string Description
{
    get { return _description;     }
    set
    {
        _description = value;
        OnPropertyRaised("Description");
    }
}

在这段生成的代码中,编译器自动填充了 OnProperyChanged 的参数,即属性名 Description。这对于开发者来说是一个方便的功能,有助于编写无错误的代码。其他两个 caller 参数属性 CallerFilePathCallerLineNumber 分别填充调用方法的文件路径和行号。

CallerArgumentExpression 是 C# 10 中新增的功能之一。正如其名所示,该属性使编译器自动填充参数表达式。让我们构建一个简单的参数验证辅助类,该类对传递的参数执行 null 检查。考虑以下 ArgumentValidation 类的实现,它实现了一个辅助方法,如果参数值为 null,则抛出 ArgumentException

public static class ArgumentValidation
{
    public static void ThrowIfNull<T>(T value,
    [CallerArgumentExpression("value")] string expression = 
      null) where T : class
    {
        if (value == null)
            Throw(expression);
    }
    private static void Throw(string expression)
        => throw new ArgumentException($"Argument 
           {expression} must not be null");
} 

ThrowIfNull 方法中,我们执行 null 检查,并使用从 CallerArgumentExpression 中选择的参数名抛出 ArgumentException。我们可以使用前面的辅助类对传递给方法的参数执行 null 检查。例如,考虑以下方法,该方法将传入的产品添加到购物车中:

public async Task<ProductDetailsViewModel> AddProductAsync (ProductDetailsViewModel product)
{
    ArgumentValidation.ThrowIfNull(product);
    // Implementation to add the product to cart
}

在此方法中,我们使用 ArgumentValidation 辅助类来检查 product 参数的 null 条件。调用 ThrowIfNull 辅助方法的生成代码将是 ArgumentValidation.ThrowIfNull(product, "product");

编译器自动填充了字符串参数中的参数名。调用参数将在我们想要向跟踪添加更多详细信息的地方很有用,这将有助于解决问题。

摘要

在本章中,我们学习了 C# 10 版本中语言特性的主要新增功能。我们看到了 C# 10 如何简化使用 implicitglobal 使用指令编写的代码。我们了解了 record 结构体以及它们与 C# 9 中引入的 record 类的比较。我们还学习了 Lambda 表达式、表达式类型推断以及显式指定表达式返回类型的改进。我们还看到了字符串插值的性能提升。我们还学习了如何使用 CallerArgumentExpression 属性构建抛出辅助器。

通过本章,我们获得了利用 C# 10 新特性的技能,这些特性将在接下来的章节中构建的企业电子商务应用中使用。除此之外,还有一些其他的小增强。您可以参考 C# 语言文档以了解更多信息:docs.microsoft.com/en-us/dotnet/csharp/whats-new/csharp-10。在实现我们的电子商务应用的不同功能的同时,我们将在这本书中突出显示 C# 10 和 .NET 6 的新特性。

在接下来的部分,我们将学习构成我们电子商务应用程序构建块的一些横切关注点。

问题

阅读本章后,你应该能够回答以下问题:

  1. 正确或错误?记录结构体是可变的:

a. 正确

b. 错误

答案:a

  1. 你应该使用哪个关键字来使记录结构体不可变?

a. 记录结构体默认是不可变的。

b. readonly.

c. finally.

d. sealed.

答案:b

  1. 正确或错误?编译器将在所有场景中推断类型表达式:

a. 正确

b. 错误

答案:b

  1. 在哪个版本的 C# 中首次引入了调用者参数属性?

a. C# 9

b. C# 8

c. C# 5

d. C# 7

答案:c

第二部分:横切关注点

目前我们有一个企业应用的骨架结构。在填充这个骨架的业务和技术功能时,我们会遇到很多将在各层之间使用的代码和结构。这些有时被称为横切关注点。横切关注点的例子包括线程、集合、日志记录、缓存、配置、网络和依赖注入。在本部分,我们将从.NET 6 和企业应用的角度快速回顾这些基础知识。在每个这些章节中,我们将为横切关注点之一开发一个类库,并将其与 UI 和服务层集成。

本部分包括以下章节:

  • 第四章**,线程和异步操作

  • 第五章**,.NET 6 中的依赖注入

  • 第六章**,.NET 6 中的配置

  • 第七章**,.NET 6 中的日志记录

  • 第八章**,关于缓存的全部知识

第四章:第四章:线程和异步操作

到目前为止,我们已经探讨了各种设计原则、模式、.NET 6 的新特性以及我们将在这本书中使用的架构指南。在本章中,我们将看到如何在构建企业应用时利用异步编程。

对于任何 Web 应用来说,关键指标之一是 可伸缩性 —— 即通过扩展来减少处理请求所需的时间,增加服务器可以处理的请求数量,以及在不增加加载时间的情况下,应用程序可以同时服务的用户数量。对于移动/桌面应用,扩展可以提高应用的响应速度,使用户能够在不冻结屏幕的情况下执行各种操作。

正确使用异步编程技术和并行结构可以在提高这些指标方面产生奇迹,而在 C#中,最好的方法是任务并行库TPL)、async-await 的简化语法,通过它可以编写干净的异步代码。

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

  • 理解术语

  • 阐明线程、延迟初始化和 ThreadPool

  • 理解锁、信号量和 SemaphoreSlim

  • 介绍任务和并行

  • 介绍 async-await

  • 使用并发集合实现并行处理

技术要求

您需要了解.NET Core、C#和 LINQ 的基础知识。本章的代码示例可以在此处找到:github.com/PacktPublishing/Enterprise-Application-Development-with-C-10-and-.NET-6-Second-Edition/tree/main/Chapter04

有关代码的几个说明可以在此处找到:github.com/PacktPublishing/Enterprise-Application-Development-with-C-10-and-.NET-6-Second-Edition/tree/main/Enterprise%20Application

理解术语

在我们深入探讨线程和异步操作的技术细节之前,让我们用一个现实世界的例子来构建一个类比,将现实生活中的多任务处理与并行编程联系起来。想象一下,你在餐厅排队等待点餐,在排队等待的时候回复了一封电子邮件。然后,在点餐后等待食物送达的时候,你接了一个电话。在餐厅里,有多个柜台在接收订单,厨师在订单被接收的同时准备食物。

当你在排队等待时,你并发地回复了电子邮件。同样,当你下单时,餐厅在许多其他柜台并行地接收订单。厨师在接收订单的同时并行地烹饪。此外,你被给了个凭证去取餐柜台;然而,根据你食物的准备时间,在你之后的订单可能会在你之前到达取餐柜台。

当谈到并行编程时,一些关键术语会多次出现。这些术语在以下图中表示:

![Figure 4.1 – 并发与并行与异步Figure 4.1_B18507.jpg

Figure 4.1 – 并发与并行与异步

让我们逐一解释每个术语:

  • 并行性:这涉及到同时独立执行多个任务,例如在多个餐厅柜台同时下单的例子中。在企业应用中,并行性意味着在多核 CPU 中同时执行多个线程/任务。然而,单核 CPU 也通过超线程支持并行性,这通常涉及将单个核心逻辑上划分为多个核心,例如启用超线程的双核 CPU,它表现得像一个四核——也就是说,四个核心。

  • 并发性:这涉及到同时执行许多任务,例如在我们之前的例子中,在排队等待餐厅柜台的同时回复电子邮件,或者厨师为一道菜调味并加热另一道菜的锅。在企业应用中,并发性涉及多个线程共享一个核心,并且根据它们的时间片,执行任务和进行上下文切换。

  • 异步性:异步编程是一种依赖于异步执行任务的技术,而不是在等待时阻塞当前线程。在我们的例子中,异步性是等待你的凭证被叫到你去取餐柜台,而厨师正在准备你的食物。但是当你等待的时候,你已经离开了点餐柜台,从而允许其他订单被下单。这就像一个异步执行的任务,在等待 I/O 任务(例如,等待数据库调用的数据)时释放资源。异步性的美妙之处在于,任务要么并行执行,要么并发执行,这完全由框架抽象化,从开发者那里屏蔽了。这使得开发者可以将他们的开发努力集中在应用程序的业务逻辑上,而不是管理任务。我们将在“任务与并行”部分看到这一点。

  • CLR ThreadPool。在多核/多处理器系统中,多线程有助于通过在不同的核心中执行新创建的线程来实现并行性。

现在我们已经了解了并行编程中的关键术语,让我们继续探讨如何在 .NET Core 中创建线程以及 ThreadPool 的作用。

消除线程、懒加载初始化和 ThreadPool 的神秘感

线程是操作系统中最小的单位,它在处理器中执行指令。进程是一个更大的执行容器,进程内的线程是使用处理器时间和执行指令的最小单位。要记住的关键点是,每当你的代码需要在进程中执行时,它应该被分配到一个线程上。每个处理器一次只能执行一条指令;这就是为什么在单核系统中,在任何时刻,只有一个线程正在执行。有一些调度算法用于将处理器时间分配给线程。线程通常有一个堆栈(用于跟踪执行历史),一些寄存器用于存储各种变量,以及计数器用于保存需要执行的指令。

快速查看任务管理器将提供有关物理和逻辑核心数量的详细信息,导航到资源监视器将告诉我们每个核心的 CPU 使用情况。以下图显示了启用超线程的四核 CPU 的详细信息,该 CPU 在任何时刻可以并行执行八个线程:

![Figure 4.2 – 任务管理器和资源监视器Figure_4.2_B18507.jpg

![Figure 4.2 – 任务管理器和资源监视器

.NET Core 的典型应用程序在启动时只有一个线程,可以通过手动创建来添加更多线程。以下几节将简要介绍如何进行此操作。

使用 System.Threading.Thread

我们可以通过创建System.Threading.Thread的实例并传递一个方法委托来创建新的线程。以下是一个简单的示例,模拟从 API 检索数据并从磁盘加载文件:

Thread loadFileFromDisk = new Thread(LoadFileFromDisk);
void LoadFileFromDisk(object? obj)
{
    Thread.Sleep(2000);
    Console.WriteLine("data returned from API");
}
loadFileFromDisk.Start();
Thread fetchDataFromAPI = new Thread(FetchDataFromAPI);
void FetchDataFromAPI(object? obj)
{
    Thread.Sleep(2000);
    Console.WriteLine("File loaded from disk");
}
fetchDataFromAPI.Start("https://dummy/v1/api"); //Parameterized method
Console.ReadLine();

在前面的代码中,FetchDataFromAPILoadFileFromDisk是将在新线程上运行的方法。

提示

在任何时刻,每个核心上只会有一个线程在执行——也就是说,只有一个线程被分配了 CPU 时间。因此,为了实现并发,当被分配 CPU 时间的线程空闲或队列中出现高优先级线程时(也可能有其他原因,例如线程正在等待同步对象或达到分配的 CPU 时间),操作系统会进行上下文切换。

由于被切换出的线程尚未完成其工作,在某个时刻,它将再次被分配 CPU 时间。因此,操作系统需要保存线程的状态(其堆栈、寄存器等),并在线程被分配 CPU 时间时再次检索它。上下文切换通常非常昂贵,是性能改进的关键领域之一。

可以在docs.microsoft.com/en-us/dotnet/api/system.threading.thread?view=net-6.0进一步查看Thread类的所有属性和方法。

尽管管理线程带来了对执行方式的更多控制优势,但也伴随着以下开销:

  • 管理线程的生命周期,例如创建线程、回收它们和上下文切换。

  • 实现线程执行的进度跟踪/报告等概念。此外,取消操作相当复杂,并且支持有限。

  • 需要适当地处理线程上的异常;否则,它们可能导致应用程序崩溃。

  • 调试、测试和代码维护可能会变得有些复杂,并且如果不正确处理,有时会导致性能问题。

这就是ThreadPool发挥作用的地方,将在下一节中进行讨论。

ThreadPool

可以通过使用由.NET Core 管理的线程池来创建线程,这更广为人知的是 CLR ThreadPool。CLR ThreadPool是一组工作线程,它们与 CLR 一起加载到您的应用程序中,并负责线程生命周期,包括回收线程、创建线程和支持更好的上下文切换。System.Threading.ThreadPool类中的各种 API 可以消费 CLR ThreadPool。具体来说,对于在某个线程上调度操作,有QueueUserWorkItem方法,它接受需要调度的方法的委托。在之前的代码中,让我们将创建新线程的代码替换为以下代码,这意味着应用程序将使用ThreadPool

ThreadPool.QueueUserWorkItem(FetchDataFromAPI);

如其名所示,ThreadPool类的QueueUserWorkItem确实使用了队列,任何应该在ThreadPool线程上执行的计算代码都会被排队,然后出队——即以先进先出FIFO)的方式分配给工作线程。

ThreadPool的设计方式是它有一个全局队列,我们在执行以下操作时将项目排队到其中:

  • 使用不属于ThreadPool线程的线程调用QueueUserWorkItemThreadPool类的类似方法

  • 通过 TPL 进行调用

当在ThreadPool中创建新线程时,它维护自己的本地队列,该队列检查全局队列并以 FIFO(先进先出)的方式出队工作项;然而,如果在此线程上执行的代码创建了另一个线程,例如子线程,那么它将被排队到本地队列而不是全局队列。

工作线程本地队列中操作的执行顺序始终是ThreadPool,其中nThreadPool中的线程数——即n个本地队列——而1指的是全局队列。

以下图中展示了ThreadPool的高级表示:

![Figure 4.3 – ThreadPool high-level representation]

![img/Figure_4.3_B18507.jpg]

图 4.3 – ThreadPool的高级表示

除了QueueUserWorkItem之外,ThreadPool类还有许多其他属性/方法可用,例如这些:

  • SetMinThreads: 用于设置程序启动时ThreadPool将拥有的最小工作线程和异步 I/O 线程数

  • SetMaxThreads: 用于设置ThreadPool将拥有的最大工作线程和异步 I/O 线程数,之后,新的请求将被排队

可以进一步查看ThreadPool类的所有属性和方法,请参阅docs.microsoft.com/en-us/dotnet/api/system.threading.threadpool?view=net-6.0

虽然通过ThreadPool线程的QueueUserWorkItem编写多线程代码简化了线程的生命周期管理,但它也有自己的局限性:

  • 我们无法从在ThreadPool线程上安排的工作中获得响应,因此代理的返回类型是 void。

  • 跟踪在ThreadPool线程上安排的工作的进度并不容易,因此像进度报告这样的功能并不容易实现。

  • 它并不适合长时间运行请求。

  • ThreadPool线程始终是后台线程;因此,与前台线程不同,如果进程关闭,它不会等待ThreadPool线程完成其工作。

由于QueueUserWorkItem存在局限性,ThreadPool线程也可以通过 TPL(我们将用于我们的企业应用程序,并在本章后面介绍)来消耗。在.NET Core 中,TPL 是实现并发/并行化的首选方法,因为它克服了我们迄今为止看到的所有局限性,并最终有助于实现允许您的应用程序扩展和响应的目标。

懒加载

类的懒加载是一种模式,其中对象的创建被推迟到第一次使用时。这种模式基于以下前提:只要类的属性没有被使用,初始化对象就没有优势。因此,这延迟了对象的创建,并最终减少了应用程序的内存占用,提高了性能。一个例子是在即将从数据库检索数据时创建数据库连接对象。懒加载非常适合持有大量数据且创建成本可能很高的类。例如,一个用于加载电子商务应用程序中所有产品的类可以在需要列出产品时进行懒加载。

如下所示,此类的一个典型实现限制了在构造函数中初始化属性,并且有一个或多个填充类属性的方法:

        public class ImageFile
        {
            string fileName;
            object loadImage;
            public ImageFile(string fileName)
            {
                this.fileName = fileName;
            }
            public object GetImage()
            {
                if (loadImage == null)
                {
                    loadImage = File.ReadAllText(fileName);
                }
                return loadImage;
            }
        }

假设这是一个用于从磁盘加载图像的类,在构造函数中加载图像是没有用的,因为只有在调用GetImage方法之前无法使用它。因此,懒初始化模式建议,而不是在构造函数中初始化loadImage对象,它应该在GetImage中初始化,这意味着图像只有在需要时才被加载到内存中。这也可以通过属性来实现,如下所示:

        object loadImage;
        public object LoadImage
        {
            get
            {
                if (loadImage == null)
                {
                    loadImage = File.ReadAllText(fileName);
                }
                return loadImage;
            }
        }

正如你所见,这通常是通过缓存对象来完成的,也被称为LoadImage方法或属性,它会导致多次调用磁盘。因此,这里需要通过锁或其他机制进行同步,这显然会增加维护开销,并且类的实现可能会变得更加复杂。

因此,尽管我们可以实现自己的懒加载模式,但在 C#中,我们有System.Lazy类来处理这种实现。使用System.Lazy类的一个关键优点是它是线程安全的。

System.Lazy类提供了多个构造函数来实现懒初始化。以下是我们可以使用的两种最常见方式:

  • 将类围绕Lazy包装,并使用该对象的Value方法来检索数据。这通常用于在构造函数中有初始化逻辑的类。以下是一些示例代码:

            public class ImageFile
            {
                string fileName;
                public object LoadImage { get; set; }
                public ImageFile(string fileName)
                {
                    this.fileName = fileName;
                    this.LoadImage = $"File {fileName}
                     loaded from disk";
                }
            }
    

在初始化这个类时,我们将使用System.Lazy类的泛型类型,并将ImageFile类作为其类型,以及ImageFile对象作为委托:

        Lazy<ImageFile> imageFile = new
         Lazy<ImageFile>(() => new ImageFile("test"));
        var image = imageFile.Value.LoadImage;

在这里,如果你在ImageFile类的构造函数中设置断点,它只有在调用System.Lazy类的Value方法时才会被触发。

  • 对于具有加载各种参数的方法的类,我们可以将方法传递给Lazy类作为委托。以下是将之前示例代码中的文件检索逻辑移动到单独方法的示例:

            public class ImageFile
            {
                string fileName;
                public object LoadImage { get; set; }
                public ImageFile(string fileName)
                {
                    this.fileName = fileName;
                }
                public object LoadImageFromDisk()
                {
                    this.LoadImage = $"File
                     {this.fileName} loaded from disk";
                    return LoadImage;
                }
            }
    

在初始化这个类时,我们将一个 Lambda 传递给泛型委托,然后将该泛型委托传递给初始化System.Lazy类的对象,如下面的代码所示:

        Func<object> imageFile = new Func<object>(()
         => { var obj = new ImageFile("test");
        return obj.LoadImageFromDisk(); });
        Lazy<object> lazyImage = new
         Lazy<object>(imageFile);
        var image = lazyImage.Value;

注意

C#中的 func 是一种委托类型,它接受零个或多个参数并返回一个值。更多详细信息可以在这里找到:docs.microsoft.com/en-us/dotnet/api/system.func-1?view=net-6.0

这两种方式都会延迟对象的初始化,直到调用Value方法时。

注意

我们需要注意的一个重要事项是,尽管Lazy对象是线程安全的,但通过值创建的对象并不是线程安全的。因此,在这种情况下,lazyImage是线程安全的,但image不是。因此,在多线程环境中,它需要被同步。

通常,懒初始化非常适合缓存类和单例类,并且可以进一步扩展用于初始化成本高昂的对象。

可以在docs.microsoft.com/en-us/dotnet/api/system.lazy-1?view=net-6.0进一步查看Lazy类的所有属性。

虽然可以通过将底层对象包装在System.Lazy类中来实现懒加载,但在.NET 中也有LazyInitializer静态类可用,可以通过其EnsureInitialized方法进行懒加载。

如 MSDN 文档中提到的,它有几个构造函数docs.microsoft.com/en-us/dotnet/api/system.threading.lazyinitializer.ensureinitialized?view=net-6.0

然而,其理念是相同的,即它期望一个对象和一个函数来填充该对象。以之前的例子来说,如果我们必须使用LazyInitializer.EnsureInitialized进行懒加载,我们需要将对象的实例和创建实际对象的 Lambda 表达式传递给LazyInitializer.EnsureInitialized,如下面的代码所示:

        object image = null;
        LazyInitializer.EnsureInitialized(ref image, () =>
            {
                var obj = new ImageFile("test");
                return obj.LoadImageFromDisk();
            });

在这里,我们传递了两个参数——一个是持有image类属性值的对象,另一个是创建image类对象并返回图像的函数。因此,这就像调用System.Lazy属性的Value属性一样简单,而不需要初始化对象的额外开销。

显然,使用LazyInitializer进行懒加载的一个小优势是,没有创建额外的对象,这意味着更小的内存占用。另一方面,System.Lazy提供了更易读的代码。因此,如果有明确的空间优化,请选择LazyInitializer;否则,为了获得更干净、更易读的代码,请使用System.Lazy

理解锁、信号量和 SemaphoreSlim

在前面的章节中,我们看到了如何使用.NET 中的各种 API 来实现并行性。然而,当我们这样做时,我们需要对共享变量进行额外的注意。让我们考虑本书中构建的企业电子商务应用程序。想想购买商品的流程。比如说有两个用户计划购买一个产品,但只有一个商品可用。假设两个用户都将商品添加到购物车,第一个用户下订单,而订单正在通过支付网关处理时,第二个用户也尝试下订单。

在这种情况下,第二级应该失败(假设第一级成功),因为书的数量现在是零;这只会发生在对线程间的数量应用了适当的同步时。此外,如果第一级在支付网关失败或第一个用户取消他们的交易,第二级应该通过。所以,我们在这里说的是,数量应该在处理第一级时锁定,并且只有在订单完成后(成功或失败)才释放。在我们深入了解处理机制之前,让我们快速回顾一下什么是临界区。

临界区和线程安全

临界区是应用程序中读取/写入由多个线程使用的变量的部分。我们可以把它们看作是跨应用程序使用的全局变量,在不同的地方或同一时间被修改。在多线程场景中,在任何给定时间点,只应允许一个线程修改这样的变量,并且只应允许一个线程进入临界区。

如果你的应用程序中没有这样的变量/部分,它可以被认为是线程安全的。因此,始终建议识别应用程序中不是线程安全的变量,并相应地处理它们。为了保护临界区免受非线程安全变量的访问,有各种称为同步原语同步构造的结构可用,它们主要分为两大类:

  • 锁定构造:这些允许一个线程进入临界区以保护对共享资源的访问,其他所有线程都等待获取锁的线程释放锁。

  • 信号构造:这些允许一个线程通过信号资源可用性来进入临界区,例如在生产者-消费者模型中,生产者锁定资源,而消费者等待信号而不是轮询。

让我们在下一节讨论几个同步原语。

介绍锁

是一个基本类,它允许你在多线程代码中实现同步,其中锁块内的任何变量只能由一个线程访问。在锁中,获取锁的线程需要释放锁,直到那时,任何其他尝试进入锁的线程都将进入等待状态。一个简单的锁可以像以下代码所示那样创建:

            object locker = new object();
            lock (locker)
            {
                quantity--;
            }

首先执行此代码的线程将获取锁,并在代码块完成后释放它。锁也可以使用Monitor.EnterMonitor.Exit来获取,实际上,使用锁的编译器内部将线程转换为Monitor.EnterMonitor.Exit。以下是一些关于锁的重要点:

  • 由于它们的线程亲和性,它们应该始终用于引用类型。

  • 它们在性能上非常昂贵,因为它们在允许线程进入临界区之前会暂停想要进入的线程,这会增加一些延迟。

  • 在获取锁时进行双重检查也是一种良好的实践,类似于在单例实现中执行的方式。

锁确实存在一些问题:

  • 你需要在修改或枚举共享数据/对象的地方加锁。在应用程序中很容易错过临界区,因为临界区更是一个逻辑术语。编译器不会标记它,如果临界区周围没有锁的话。

  • 如果处理不当,可能会导致死锁。

  • 可扩展性是一个问题,因为一次只有一个线程可以访问锁,而其他所有线程必须等待。

    注意

    另一个重要的概念称为原子性。一个操作只有在没有方法读取变量的中间状态或向变量写入中间状态的情况下才是原子的。例如,如果一个整数的值正在从二修改为六,那么任何读取这个整数值的线程只会看到二或六;没有任何线程会看到整数只部分更新的线程的中间状态。任何线程安全的代码自动保证原子性。

    使用稍后章节中描述的并发集合,而不是锁,因为并发集合内部处理锁定临界区。

互斥锁(仅限 Windows)

System.Threading.Mutex 类,任何想要进入临界区的线程都需要调用 WaitOne 方法。释放互斥锁通过 ReleaseMutex 方法实现;因此,我们基本上创建一个 System.Threading.Mutex 类的实例,并分别调用 WaitOne/ReleaseMutex 来进入/退出临界区。关于互斥锁的几个重要点如下:

  • 互斥锁具有线程亲和性,因此调用 WaitOne 的线程需要调用 ReleaseMutex

  • System.Threading.Mutex 类有一个构造函数,它接受互斥锁的名称,这允许通过传递给构造函数的名称在进程间共享。

介绍信号量和 SemaphoreSlim

信号量是一种非独占锁,它通过允许多个线程进入临界区来支持同步。然而,与独占锁不同,信号量用于需要限制对资源池访问的场景——例如,允许应用程序和数据库之间固定数量连接的数据库连接池。

回到我们在电子商务应用程序中购买产品的例子,如果产品的可用数量是 10,这意味着 10 个人可以将此商品添加到他们的购物车并下订单。如果有 11 个并发订单,应该允许 10 个用户下订单,而第 11 个用户应该被挂起,直到前 10 个订单完成。

在 .NET 中,可以通过创建 System.Threading.Semaphore 类的实例并传递两个参数来创建信号量:

  • 激活请求的初始数量

  • 允许并发请求的总数

这是一个简单的代码片段,用于创建信号量:

Semaphore quantity = new Semaphore(0, 10);

在这种情况下,0 表示没有请求获取共享资源,并且允许最多 10 个并发请求。要获取共享资源,我们需要调用 WaitOne(),要释放资源,我们需要调用 Release() 方法。

要创建信号量,.NET 中还有一个轻量级的类可用,那就是 SemaphoreSlim,这是精简版,它通常依赖于一个称为 SemaphoreSlim 的概念,它使用一个运行几微秒的小循环,这样就不必经历阻塞、上下文切换和内部内核转换(信号量使用 Windows 内核信号量来锁定资源)的高成本过程。最终,如果共享资源仍然需要被锁定,SemaphoreSlim 会回退到锁定。

创建 SemaphoreSlim 实例几乎与信号量相同;唯一的区别是对于锁定,它有 WaitAsync 而不是 WaitOne。还有一个 CurrentCount 可用,它告诉我们获取了多少个锁。

关于信号量和 SemaphoreSlim 的以下关键事实:

  • 由于信号量用于访问资源池,因此信号量和 SemaphoreSlim 没有线程亲和性,任何线程都可以释放资源。

  • .NET Core 中的 Semaphore 类支持命名信号量。命名信号量可以用于跨进程锁定资源;然而,SemaphoreSlim 类不支持命名信号量。

  • Semaphore 不同,SemaphoreSlim 类支持异步方法和取消,这意味着它可以很好地与 async-await 方法一起使用。async-await 关键字有助于编写非阻塞的异步方法,并在本章的 介绍 async-await 部分中进行了介绍。

选择合适的同步构造

还有其他信号构造需要覆盖;以下表格提供了它们的使用的高级视图以及它们的实际应用示例:

![Table_4.1 – 同步构造比较

![Table_4.1_a.jpg]![Table_4.1 – 同步构造比较

![Table_4.1_b.jpg]

表 4.1 – 同步构造比较

到目前为止,我们已经涵盖了以下内容:

  • 使用 ThreadThreadPool 类及其限制的多线程方式

  • 懒初始化的重要性以及它在多线程环境中的帮助

  • .NET 中可用的各种同步构造

当我们创建一些横切组件时,我们将在后面的章节中使用这些概念。

在下一节中,我们将看到如何通过任务和 TPL 的使用来克服 ThreadThreadPool 的限制。

介绍任务和并行

我们知道异步编程有助于我们的应用程序扩展并更好地响应,因此实现异步应用程序不应给开发者带来额外的开销。ThreadThreadPool 虽然有助于实现异步性,但增加了许多开销,并带来了限制。

因此,Microsoft 提出了任务,这使得开发异步应用程序变得更加容易。事实上,.NET 6 中大多数新的 API 只支持异步编程方式——例如,通用 Windows 平台UWP)甚至没有提供创建线程的任务 API。因此,理解任务和 TPL 对于能够使用 C# 编写异步程序至关重要。

在本节中,我们将深入探讨这些主题,稍后我们将看到如何将 C# 的 async-await 关键字与 TPL 结合起来简化异步编程。

Task 和 TPL 简介

异步编程背后的思想是,没有任何线程应该等待一个操作——也就是说,框架应该具有将操作包装到某种抽象中的能力,然后在操作完成后恢复,而不阻塞任何线程。这种抽象就是 Task 类,它通过 System.Threading.Tasks 暴露出来,并有助于在 .NET 中编写异步代码。

Task 类简化了任何等待操作的包装,无论是从数据库检索的数据、从磁盘加载到内存中的文件,还是任何高度 CPU 密集型操作,并且如果需要,它简化了在单独的线程上运行的操作。它具有以下重要特性:

  • Task 通过其泛型类型 Task<T> 支持在操作完成后从操作中返回值。

  • Task 负责在 ThreadPool 上调度线程、划分操作,并相应地调度来自 ThreadPool 的多个线程,同时抽象出执行这些操作的复杂性。

  • 完成报告支持通过 CancellationToken 取消操作,并通过 IProgress 报告进度。

  • Task 支持创建子任务并管理子任务与父任务之间的关系。

  • 异常会传播到调用应用程序,即使是多级父/子任务也是如此。

  • 最重要的是,Task 支持异步-等待(async-await),这有助于在任务中的操作完成后,在调用应用程序/方法中恢复处理。

TPL 是由 .NET 在 System.Threading.TasksSystem.Threading 中提供的一组 API,它提供了创建和管理任务的方法。可以通过创建 System.Threading.Tasks.Task 类的实例并传递需要在任务上执行的一块代码来创建任务。我们可以以多种方式创建任务:

  • 您可以创建 Task 类的实例并传递一个 Lambda 表达式。在此方法中,它需要显式启动,如下面的代码所示:

                Task dataTask = new Task(() =>
                 FetchDataFromAPI("https://foo.com/api"));
                dataTask.Start();
    
  • 任务也可以使用Task.Run创建,如下面的代码所示,它支持创建和启动任务而不需要显式调用Start()

    Task dataTask = Task.Run(() => FetchDataFromAPI ("https://foo.com/api"));
    
  • 创建任务的另一种方式是使用Task.Factory.StartNew

    Task dataTask = Task.Factory.StartNew(() => FetchDataFromAPI("https://foo.com/api"));
    

在所有这些方法中,都使用ThreadPool线程来运行FetchDataFromAPI方法,并通过返回给调用者的dataTask对象进行引用,以跟踪操作的完成或异常。

由于这个任务将在ThreadPool线程上异步执行,并且所有ThreadPool线程都是后台线程,应用程序不会等待FetchDataFromAPI方法完成。TPL 提供了一个Wait方法来等待任务的完成,例如dataTask.Wait()。以下是一个使用任务的简单控制台应用程序的代码片段:

Task t = Task.Factory.StartNew(() =>
             FetchDataFromAPI("https://foo.com"));
t.Wait();
void FetchDataFromAPI(string apiURL)
{
     Thread.Sleep(2000);
     Console.WriteLine("data returned from API");
}

在这个代码片段中,我们使用了 Lambda 表达式。然而,它也可以是一个委托或无参数方法的动作委托,因此以下内容也可以用来创建任务:

Task t = Task.Factory.StartNew(delegate { FetchDataFromAPI("https://foo.com");});

无论哪种方式,你都会收到Task对象的引用并相应地处理它。如果一个方法返回一个值,那么我们可以使用Task类的泛型版本,并使用Result方法从Task中检索数据。例如,如果FetchDataFromAPI返回一个字符串,我们可以使用Task<String>,如下面的代码片段所示:

            Task<string> t =
             Task.Factory.StartNew<string>(()
             => FetchDataFromAPI(""));
            t.Wait();
            Console.WriteLine(t.Result);

这些方法中的每一个都接受各种附加参数,以下是一些重要的参数:

  • 使用CancellationToken类的对象进行取消,该对象是通过CancellationTokenSource类生成的。

  • 通过TaskCreationOptions枚举控制任务创建和执行的行为。

  • 自定义实现TaskScheduler以控制任务的排队方式。

TaskCreationOptions是 TPL 中的一个枚举,它告诉TaskScheduler我们正在创建什么类型的任务。例如,我们可以创建一个长时间运行的任务,如下所示:

Task<string> t = Task.Factory.StartNew<string>(() => FetchDataFromAPI(""), TaskCreationOptions.LongRunning);

尽管这并不能保证输出更快,但它更像是对调度器的一种提示,使其进行自我优化。例如,如果调度器看到有长时间运行的任务正在被调度,它可以启动更多的线程。这个枚举的所有选项都可以在docs.microsoft.com/en-us/dotnet/api/system.threading.tasks.taskcreationoptions?view=net-6.0找到。

Task还支持通过创建和传递所有任务作为参数到以下方法的同时等待多个任务:

  • WaitAll:等待所有任务的完成并阻塞当前线程。不推荐用于应用程序开发。

  • WhenAll:等待所有任务的完成而不阻塞当前线程。通常与async-await一起使用。推荐用于应用程序开发。

  • WaitAny:等待其中一个任务的完成并阻塞当前线程直到完成。不推荐用于应用程序开发。

  • WhenAny:等待其中一个任务的完成,而不阻塞当前线程。通常与 async-await 一起使用。不建议用于应用程序开发。

与线程不同,任务具有全面的异常处理支持。让我们在下一节中看看这一点。

处理任务异常

在任务中的异常处理就像在任务周围写一个 try 块,然后捕获异常,这些异常通常被封装在 AggregateException 中,如下面的代码片段所示:

            try
            {
                Task<string> t =
                 Task.Factory.StartNew<string>(()
                 => FetchDataFromAPI(""));
                t.Wait();
            }
            catch (AggregateException agex)
            {
                //Handle exception
                Console.WriteLine(
                  agex.InnerException.Message);
            }

在前面的代码中,agex.InnerException 将给出实际的异常,因为我们正在等待单个任务。然而,如果我们正在等待多个任务,那么将是 InnerExceptions 集合,我们可以遍历它。此外,它还包含一个 Handle 回调方法,可以在 catch 块中订阅,一旦触发,回调将包含有关异常的信息。

如前所述的代码所示,为了使任务传播异常,我们需要调用 Wait 方法或其他一些阻塞构造,如 WhenAll 来触发 catch 块。然而,在底层,任何对 Task 的异常实际上都保存在 Task 类的 Exception 属性中,它是 AggregateException 类型,可以观察任务中的任何底层异常。

此外,如果一个任务是附加子任务或嵌套任务的父任务,或者如果你正在等待多个任务,可能会抛出多个异常。为了将所有异常传播回调用线程,Task 基础设施将它们封装在 AggregateException 实例中。

更多关于异常处理的详细信息可以在 docs.microsoft.com/en-us/dotnet/standard/parallel-programming/exception-handling-task-parallel-library 找到。

实现任务取消

.NET 提供了两个主要的类来支持任务的取消:

  • CancellationTokenSource:一个创建取消令牌并支持通过 Cancel 方法取消令牌的类

  • CancellationToken:一个监听取消的结构,如果任务被取消,则触发通知

对于取消任务,有两种类型的取消:

  • 另一个情况是任务被错误地执行并需要立即取消

  • 另一个情况是任务已经开始,但需要在中途停止(中止)

对于前者,我们可以创建一个支持取消的任务。我们使用 TPL API 并将取消令牌传递给构造函数,如果任务需要取消,就调用 CancellationTokenSource 类的 Cancel 方法,如下面的代码片段所示:

            cts = new CancellationTokenSource();
            CancellationToken token = cts.Token;
            Task dataFromAPI = Task.Factory.StartNew(()
             => FetchDataFromAPI(new List<string> {
                "https://foo.com",
                "https://foo1.com",}), token);
            cts.Cancel();

所有支持异步调用的 .NET Core API,如 HttpClient 类的 GetAsyncPostAsync,都有接受取消令牌的重载。对于后一种情况(中止任务),决策基于将要运行的操作是否支持取消。假设它支持取消,我们可以将取消令牌传递给方法,并在方法调用内部检查取消令牌的 IsCancellationRequested 属性,并相应地处理它。

让我们创建一个简单的控制台应用程序,创建一个支持取消的任务。在这里,我们创建了一个 FetchDataFromAPI 方法,该方法接受一个 URL 列表并从这些 URL 获取数据。此方法还支持使用 CancellationToken 进行取消。在实现中,我们遍历 URL 列表,直到请求取消或循环完成所有迭代:

        static string FetchDataFromAPI(List<string>
         apiURL, CancellationToken token)
        {
            Console.WriteLine("Task started");
            int counter = 0;
            foreach (string url in apiURL)
            {
                if (token.IsCancellationRequested)
                {
                    throw new TaskCanceledException($"data
                     from API returned up to iteration
                       {counter}");
                    //throw new 
                    //OperationCanceledException($"data 
                    //from API returned up to iteration 
                    //{counter}"); 
                    // Alternate exception with same result
                    //break; // To handle manually
                }
                Thread.Sleep(1000);
                Console.WriteLine($"data retrieved from
                 {url} for iteration {counter}");
                counter++;
            }
            return $"data from API returned up to iteration
             {counter}";
        }

现在,从主方法中调用 FetchDataFromAPI,使用四个 URL 的列表,如下所示。在这里,我们使用 CancellationTokenSource 类的 Token 属性创建 CancellationToken,并将其传递给 FetchDataFromAPI 方法。我们模拟了 3 秒后的取消,以便在获取第四个 URL 之前取消 FetchDataFromAPI

CancellationTokenSource cts = new CancellationTokenSource();
CancellationToken token = cts.Token;
Task<string> dataFromAPI;
try
{
    dataFromAPI = Task.Factory.StartNew<string>(() =>
     FetchDataFromAPI(new List<string> {
    "https://foo.com","https://foo1.com","https://foo2.com"
      ,"https://foo3.com", "https://foo4.com", }, token));
    Thread.Sleep(3000);
    cts.Cancel(); //Trigger cancel notification to 
                  //cancellation token
    dataFromAPI.Wait(); // Wait for task completion
    Console.WriteLine(dataFromAPI.Result); // If task is 
      //completed display message accordingly
}
catch (AggregateException agex)
{// Handle exception}

一旦运行此代码,我们可以看到三个 URL 的输出,然后是一个异常/中断(基于 FetchDataFromAPI 方法中注释掉的哪一行)。

在前面的示例中,我们使用 for 循环和 Thread.Sleep 模拟了长时间运行的代码块,取消了任务,并相应地处理了代码。然而,可能存在一种情况,长时间运行的代码块可能不支持取消。

在那些情况下,我们必须编写一个接受取消令牌的包装方法,并在包装器内部调用长时间运行的操作;然后在主方法中,我们调用包装器代码。以下代码片段显示了一个使用 TaskCompletionSource 的包装方法,这是 TPL 中的另一个类。它用于通过类中可用的 Task 属性将非任务异步方法(包括基于异步方法的那些)转换为任务。在这种情况下,我们将取消令牌传递给 TaskCompletionSource,以便其 Task 得到相应的更新:

        static Task<string>
         FetchDataFromAPIWithCancellation(List<string>
         apiURL, CancellationToken cancellationToken)
        {
            var tcs = new TaskCompletionSource<string>();
            tcs.TrySetCanceled(cancellationToken);
            // calling overload of long running operation 
            // that doesn't support cancellation token
            var dataFromAPI = Task.Factory.StartNew(() =>
             FetchDataFromAPI(apiURL));
            // Wait for the first task to complete
            var outputTask = Task.WhenAny(dataFromAPI,
             tcs.Task);
            return outputTask.Result;
        }

在这种情况下,CancellationToken 通过 TaskCompletionSourceTask 属性进行跟踪,我们创建了另一个任务来调用我们的长时间运行的操作(不支持取消令牌支持的操作),并且哪个任务先完成,我们就返回哪个任务。

当然,Main 方法需要更新为调用包装器,如下所示(其余代码保持不变):

            dataFromAPI = Task.Factory.StartNew(() =>
             FetchDataFromAPIWithCancellation(new
             List<string>
                {
                        "https://foo.com",
                        "https://foo1.com",
                        "https://foo2.com",
                        "https://foo3.com",
                        "https://foo4.com",
                    }, token)).Result;

这不会取消底层方法,但仍然允许应用程序在底层操作完成之前退出。

任务取消是一种非常有用的机制,有助于减少不必要的处理,无论是尚未开始的任务还是已经开始但需要停止/中止的任务。因此,.NET 中的所有异步 API 都支持取消。

实现延续

在企业应用程序中,大多数情况下,需要创建多个任务,构建任务层次结构,创建依赖任务,或创建任务之间的子/父关系。任务延续可以用来定义这样的子任务/子任务。它就像 JavaScript 的承诺一样工作,并支持将任务链式连接到多个级别。就像承诺一样,层次结构中的后续任务在第一个任务之后执行,并且这可以进一步链式连接到多个级别。

实现任务延续有多种方法,但最常见的方法是使用Task类的ContinueWith方法,如下面的示例所示:

Task.Factory.StartNew(() => Task1(1)) // 1+2 = 3
                .ContinueWith(a => Task2(a.Result)) // 3*2 = 6
                    .ContinueWith(b => Task3(b.Result))// 6-2=4
                        .ContinueWith(c => Console.WriteLine(c.Result));
Console.ReadLine();
static int Task1(int a) => a + 2;
static int Task2(int a) => a * 2;
static int Task3(int a) => a - 2;

如您所猜测的,这里的输出将是4,并且每个任务都在前一个任务的执行完成后执行。

ContinueWith接受一个重要的枚举TaskContinuationOptions,它支持不同条件下的延续。例如,我们可以将TaskContinuationOptions.OnlyOnFaulted作为参数传递,以创建一个在前面任务中发生异常时执行的延续任务,或者传递TaskContinuationOptions.AttachedToParent以创建一个强制父-子关系的延续任务,并强制父任务仅在子任务完成后才完成执行。

WhenAllWhenAny类似,ContinueWith也有类似的兄弟方法,如下所示:

  • Task.Factory.ContinueWhenAll:这个方法接受多个任务引用作为参数,并在所有任务完成时创建一个延续。

  • Task.Factory.ContinueWhenAny:这个方法接受多个任务引用作为参数,并在其中一个引用的任务完成时创建一个延续。

理解任务延续对于理解 async-await 的底层工作原理至关重要,我们将在本章后面讨论这一点。

同步上下文

SynchronizationContextSystem.Threading中可用的一个抽象类,它有助于线程之间的通信。例如,从并行任务更新 UI 元素需要线程重新加入 UI 线程并继续执行。SynchronizationContext主要通过这个类的Post方法提供这种抽象,该方法接受一个在稍后阶段执行的委托。因此,在前面的示例中,如果需要更新 UI 元素,就需要获取 UI 线程的SynchronizationContext,调用其Post方法,并传递必要的更新 UI 元素的数据。

由于SynchronizationContext是一个抽象类,因此有各种派生类型——例如,Windows Forms 有WindowsFormsSynchronizationContext,而 WPF 有DispatcherSynchronizationContext

SynchronizationContext的主要优势在于它是一个抽象,这有助于无论Post方法的重写实现如何,都可以排队一个委托。

TaskScheduler

当我们使用前面描述的各种方法创建任务时,我们看到了任务会在ThreadPool线程上被调度,但问题是谁或什么负责调度。System.Threading.Tasks.TaskScheduler是 TPL 中可用的类,负责在ThreadPool线程上排队和执行任务委托。

当然,这是一个抽象类,框架提供了两个派生类:

  • ThreadPoolTaskScheduler

  • SynchronizationContextScheduler

TaskScheduler公开了一个Default属性,默认设置为ThreadPoolTaskScheduler。因此,默认情况下,所有任务都安排到ThreadPool线程上;然而,GUI 应用程序通常使用SynchronizationContextScheduler,以便任务可以成功返回并更新 UI 元素。

.NET Core 提供了TaskSchedulerSynchronizationContext类的复杂派生类型。然而,它们在 async-await 中扮演着重要角色,并有助于快速调试任何与死锁相关的问题。

注意,查看TaskSchedulerSynchronizationContext的内部工作原理超出了本书的范围,留作练习探索。

实现数据并行化

数据并行化主要涉及将源集合分割成多个并行可执行的任务,这些任务并行执行相同的操作。使用 TPL(Task Parallel Library),这可以通过Parallel静态类来实现,该类公开了ForForEach等方法,并具有多个重载以处理此类执行。

假设你有一个包含一百万个数字的集合,你需要找出其中的素数。数据并行化在这里可以派上用场,因为集合可以被分割成范围,并评估其中的素数。通常的并行for循环可以写成如下片段:

            List<int> numbers = Enumerable.Range(1,
             100000).ToList();
            Parallel.For(numbers.First(), numbers.Last(), x
             => CalculatePrime(x));

然而,一个更现实的例子可能是一个图像处理应用程序,它需要处理图像中的每个像素,并将每个像素的亮度降低五点。此类操作可以从数据并行化中受益匪浅,因为每个像素与其他像素独立,因此可以并行处理。

类似地,Parallel静态类中有一个ForEach方法,可以如下使用:

Parallel.ForEach(numbers, x => CalculatePrime(x));

使用Parallel.ForParallel.ForEach实现数据并行化的关键优势如下列出:

  • 适用于取消循环;它们在常规的for循环中的break操作类似。在Parallel.For中,这是通过将ParallelStateOptions传递给委托并调用ParallelStateOptions.Break来支持的。当某个任务遇到Break时,ParallelStateOptions类的LowestBreakIteration属性被设置,所有并行任务将迭代直到达到这个数字。ParallelLoopResultParallel.ForParallel.ForEach的返回类型,它有一个IsCompleted属性,表示循环是否提前执行。

  • 它们还支持通过ParallelStateOptions.Stop立即停止循环。此外,Parallel.ForParallel.ForEach的一些构造函数接受取消令牌,也可以用来模拟ParallelStateOptions.Stop;然而,循环应该被包裹在一个try…catch块中,因为会抛出OperationCanceledException

  • 如果某个任务抛出异常,所有任务将完成它们的当前迭代,然后停止处理。与任务类似,会抛出AggregateException

  • 通过传递ParallelOptions并设置MaxDegreeOfParallelism来支持并行度,这将控制任务可以并行执行的核心数。

  • 支持通过范围分区或块分区对源集合进行自定义分区。

  • 支持作用域为线程或分区的线程安全局部变量。

  • 支持嵌套的Parallel.For循环,它们的同步会自动处理,而不会引入任何手动同步。

  • 如果每个迭代使用一个共享变量,则需要显式实现同步。因此,为了最大限度地利用数据并行性,应将其用于每个迭代可以独立执行且不依赖于共享资源的操作。

    小贴士

    数据并行性应谨慎使用,因为有时会被误用。这就像把 40 个任务分给 4 个人。如果组织这项工作(分割和合并)给 4 个人做比完成 40 个任务的整体工作还要多,那么数据并行性不是正确的选择。有关进一步阅读,请参阅docs.microsoft.com/en-us/dotnet/standard/parallel-programming/data-parallelism-task-parallel-library

使用 Parallel LINQ (PLINQ)

PLINQ 是 LINQ 的并行实现;这是一组在ParallelEnumerable类中可用的 API,它使 LINQ 查询的并行执行成为可能。使 LINQ 查询并行运行的最简单方法是在 LINQ 查询中嵌入AsParallel方法。请参见以下代码片段,它调用一个计算 1 到 1,000 之间素数的方法:

List<int> numbers = Enumerable.Range(1, 1000).ToList();
var resultList = numbers.AsParallel().Where(I => CalculatePrime
(i)).ToList();

使用 LINQ 查询语法,这将是如下所示:

var primeNumbers = (from i in numbers.AsParallel()where CalculatePrime(i) select i).ToList();

内部,此查询被分割成多个更小的查询,这些查询在每个处理器上并行执行,从而加快了查询速度。分区源需要合并回主线程,以便结果(输出集合)可以遍历以进行进一步的处理/显示。

让我们创建一个控制台应用程序,使用 PLINQ 和 Parallel.For 打印给定范围内的所有素数。添加以下方法,该方法接受一个数字,如果它是素数则返回 true,否则返回 false

bool CalculatePrime(int num)
{
    bool isDivisible = false;
    for (int i = 2; i <= num / 2; i++)
    {
        if (num % i == 0)
        {
            isDivisible = true;
            break;
        }
    }
    if (!isDivisible && num != 1)
        return true;
    else
        return false;
}

现在,在主方法中,添加以下代码,它创建了一个列表,包含我们将使用 PLINQ 遍历的前 100 个数字,然后将其传递给 CalculatePrime 方法;然后,我们将最终使用 Parallel.ForEach 显示素数列表:

List<int> numbers = Enumerable.Range(1, 100).ToList();
try
{
       var primeNumbers = (from number in 
       numbers.AsParallel() where CalculatePrime(number) == 
       true select number).ToList();
  Parallel.ForEach(primeNumbers, (primeNumber) =>
  {
    Console.WriteLine(primeNumber);
  });
}
catch (AggregateException ex)
{
  Console.WriteLine(ex.InnerException.Message);
}

此示例的输出将是一个素数列表;然而,你可以看到输出将不会是按升序排列的素数,而是随机顺序,因为 CalculatePrime 方法是并行调用多个数字的。

下面的代码内部工作原理图如下:

图 4.4 – PLINQ 和 Parallel.ForEach

图 4.4 – PLINQ 和 Parallel.ForEach

PLINQ 还提供了一个方法,用于处理每个分区/线程的结果,而无需使用 ForAll 将结果合并到调用线程中,前面的代码可以进一步优化如下:

            (from i in numbers.AsParallel()
             where CalculatePrime(i) == true
             select i).ForAll((primeNumber) =>
               Console.WriteLine(primeNumber));

提示

在 LINQ/PLINQ 中进行实验的最佳工具之一是 LINQPad;我建议您从 www.linqpad.net/Download.aspx 下载它。

对于 PLINQ,以下是一些重要事项需要记住:

  • 可以通过使用 WithMergeOption 方法并传递 ParallelMergeOperation 枚举的适当值来配置将结果合并到主线程。

  • 与其他并行扩展一样,任何异常都作为 AggregateException 返回,并且所有迭代的执行会立即停止。当然,如果异常在委托内部被吞没而没有抛出,则执行可以继续。

  • 还有各种其他扩展方法,例如 AsSequentialAsOrdered,这些可以在单个 LINQ 查询中组合使用。例如,基于此,AsSequential 可以与 AsParallel 结合使用,以便某些分区可以顺序执行,而其他分区可以并行执行。

  • 支持使用 WithCancellation 方法进行取消。

  • 通过 WithDegreeOfParallelism 支持并行度。

数据并行和 PLINQ 提供了许多 API,可以用来快速启用代码的并行执行,而无需向应用程序逻辑添加任何额外的开销。然而,它们之间有一个细微的差别,如前所述,因此应根据不同情况进行不同的使用。

提示

PLINQ 和 TPL 一起构成了并行扩展。

在本节中,我们在许多地方使用了 Thread.Sleep,但这主要是为了模拟长时间运行的操作;然而,在产品环境中使用此方法是不推荐的。

在下一节中,我们将看到如何将任务与 async-await 结合使用,并在企业应用程序中使用 async-await。

介绍 async-await

到目前为止,我们已经讨论了使用任务编写异步代码以及 TPL 如何简化任务创建和管理。然而,任务主要依赖于延续、回调或事件在任务完成后继续执行。

在企业应用程序中,管理此类代码会非常困难;如果任务链太多,任何运行时异常都很难调试。这就是 C# 的 async-await 发挥作用的地方,它是 C# 5.0 中引入的一种语言特性,简化了异步代码的编写,使其更易于阅读和维护,改进了异常处理,并使调试变得容易。因此,让我们深入了解 async-await。

async 是 C# 中的一个关键字,用作修饰符,当它附加到任何方法(或 Lambda)之前时,会将该方法转换为状态机,使方法能够在其主体中使用 await 关键字。

await 是 C# 中的一个关键字,用作运算符,其后跟一个返回可等待对象的表达式(通常是任务)。await 只能在具有 async 修饰符的方法内部使用,一旦调用者遇到 await 语句,控制权就会返回,并且操作会继续;在 await 之后,任务通过延续来完成。

基于任务的异步模式

使用 async 修饰符,然后在任务(或任何公开 GetAwaiter() 的自定义可等待类型)包装的异步操作上使用 await。简单来说,这种模式涉及使用单个具有 async 修饰符并返回任务的方法来表示异步操作;任何异步操作都进一步使用 await 进行等待。以下是一个示例代码片段,它异步地下载文件,这是使用 TAP 实现的:

图 4.5 – 使用 async-await 的异步方法示例

图 4.5 – 使用 async-await 的异步方法示例

在前面的图中,控制流如下(使用图中的数字标签):

  1. 应用程序从 Main 方法开始执行。由于 Main 前缀为 async 方法,它被转换为一个实现状态机的类型。执行会继续,直到在 await DownloadFileAsync 处遇到 await,然后线程返回给调用者。

  2. 在返回调用者之前,对 DownloadFileAsync 方法的调用被存储在一个 Task 对象中,并且 Task 对象的引用也被保留。Main 方法的剩余代码被包装在这个任务的延续中。

  3. ThreadPool线程将开始执行DownloadFileAsync方法,并重复相同的步骤——即,将方法转换为实现状态机的类型,直到遇到await,然后返回引用的任务;剩余的代码将移动到该任务的后续操作中。

  4. DownloadDataTaskAsync方法完成时,任务后续操作被触发,并将执行剩余的代码。

  5. 该过程会重复进行,直到具有DownloadFileAsync引用的任务完成并执行其后续操作,在这种情况下,后续操作是Console.WriteLine("File downloaded!!"),然后应用程序退出。

在一个大致的高级层面上,代码将转换为如下所示:

![图 4.6 – 转换后的示例异步方法图 4.6_B18507.jpg

图 4.6 – 转换后的示例异步方法

虽然这只是一个对异步/await 底层工作原理的过度简化,但我们可以看到编译器做了大量的工作,包括生成一个实现状态机的类型,并使用回调的状态继续执行。

我们已经看到编写异步方法是多么简单,我们将在本书的整个过程中在我们的企业应用程序中编写许多这样的方法。然而,async-await 并非万能药;它不是每个应用程序问题的答案。我们需要验证某些因素才能使用 async-await。让我们看看使用 async-await 的原则是什么。

注意

如果存在SynchronizationContext,前面的代码会有所不同。例如,在 Windows Forms 或 WPF 应用程序中,使用SynchronizationContextPost方法或TaskScheduler.FromCurrentSynchronizationContext将后续操作发布到当前SynchronizationContext。根据标准命名约定,为了可读性,异步方法后缀为async,但从语法上讲,这不是必需的。

使用 async-await 的原则

随着我们开始使用 async-await,有一些推荐的做法可以使应用程序充分利用异步原则。例如,对于嵌套调用,我们应该一直使用 async-await;不要使用.Result等。以下是一些有助于有效使用 async-await 的指导原则。

一直使用 async-await

使用 async-await 实现的异步方法应该从 async-await 方法中触发,以便正确地等待。如果我们尝试使用任务的Result方法或Wait方法从同步方法中调用异步方法,可能会导致死锁。

让我们看看以下来自 WPF 应用程序的代码片段,该应用程序在按钮点击时从网络上下载文件。然而,我们不是等待异步方法的调用,而是使用TaskResult方法:

        private void Button_Click(object sender,
         RoutedEventArgs e)
        {
            var task = 
            DownloadFileAsync("https://github.com/Ravindra-
            a/largefile/blob/master/README.md", @$"{System.IO.Directory.GetCurrentDirectory()}\download.txt");
            bool fileDownload = task.Result; // Or 
                            //task.GetAwaiter().GetResult()
            if (fileDownload)
            {
                MessageBox.Show("file downloaded");
            }
        }
        private async Task<bool> DownloadFileAsync(string
         url, string path)
        {
            // Create a new web client object
            using WebClient webClient = new WebClient();
            // Add user-agent header to avoid forbidden 
            // errors.
            webClient.Headers.Add("user-agent",
              "Mozilla/5.0 (Windows NT 10.0; WOW64)");
            byte[] data = await
              webClient.DownloadDataTaskAsync(url);
            // Write data in file.
            Using var fileStream = File.OpenWrite(path);
            {
                await fileStream.WriteAsync(data, 0,
                 data.Length);
            }
            return true;
        }

在此方法中,await webClient.DownloadDataTaskAsync(url); 之后的代码永远不会执行,原因如下:

  • 一旦遇到 awaitTask 引用对象将通过 GetAwaiter 方法捕获 SynchronizationContextTaskAwaitable 中。

  • 一旦 async 操作完成,该 await 的后续操作需要在 SynchronizationContext 上执行(通过 SynchronizationContext.Post)。

  • 然而,SynchronizationContext 已经被阻塞,因为按钮点击时对 task.Result 的调用是在同一个 SynchronizationContext 上,并且正在等待 DownloadDataTaskAsync 完成,因此它导致了死锁。

因此,永远不要阻塞 async 方法;最佳实践是将 async 完全实现。所以,在先前的代码中,你需要将调用 await DownloadFileAsync(以及按钮点击时的 async voidawait 需要一个带有 async 修饰符的方法)进行更改。

注意

在 ASP.NET Core 6 应用程序中,相同的代码运行良好,不会导致死锁,因为 ASP.NET Core 6 没有使用 SynchronizationContext,并且后续操作在 ThreadPool 线程上执行,而不涉及任何请求上下文;然而,即使在 ASP.NET Core 6 中,也不建议阻塞异步调用。

ConfigureAwait

在先前的讨论中,由于我们有端到端的应用程序代码,因此更容易找到死锁的原因。然而,如果我们正在开发一个库,该库包含可以在 WPF、ASP.NET Core 6 或 .NET Framework 应用程序中使用的异步方法,我们需要确保库中的异步代码不会导致死锁,即使调用者可能通过同步方法(GetAwaiter().GetResult())消费库方法。

在这种情况下,Task 提供了一个名为 ConfigureAwait 的方法,它接受一个布尔值,当为 true 时,将使用调用者的原始上下文,当为 false 时,将在 await 之后继续操作,而不依赖于原始上下文。用通俗易懂的话来说,await 之后的任何代码都将独立执行,不受发起请求的上下文状态的约束。

使用 ConfigureAwait(false),特别是如果你正在实现库方法,因为它将避免在原始上下文中运行后续操作。对于库方法,必须使用 ConfigureAwait(false),因为它们不应该依赖于调用者/原始上下文来执行后续操作。例如,以下代码不会导致死锁:

        private void Button_Click(object sender, RoutedEventArgs e)
        {
            string output = GetAsync().Result; //Blocking 
              //code, ideally should cause deadlock.
            MessageBox.Show(output);
        }
        //  Library code        
        public async Task<string> GetAsync()
        {
            var uri = new Uri("http://www.google.com");
            return await new HttpClient().
             GetStringAsync(uri).ConfigureAwait(false);
        }

默认情况下,每个 await 表达式都有 ConfigureAwait(true),因此建议尽可能显式地调用 ConfigureAwait(false)。除了避免死锁之外,ConfigureAwait(false) 还可以提高性能,因为没有对原始上下文的序列化。

这引出了一个问题,即是否存在需要使用 ConfigureAwait(true) 的场景。答案是,确实存在一些场景,其中正在构建一个自定义的 SynchronizationContext,该上下文需要被回调使用,这时建议使用 ConfigureAwait(true),或者至少不要使用 ConfigureAwait(false),因为任何任务的默认行为都是与 ConfigureAwait(true) 相同的。

CPU 密集型与 I/O 密集型

总是使用 async-await 来处理 I/O 密集型工作,使用 TPL 来处理 CPU 密集型工作以实现异步。例如,数据库调用、网络调用和文件系统调用等 I/O 操作可以封装在 async-await 异步方法中。然而,像计算 π 这样的 CPU 密集型操作最好使用 TPL 来处理。

回到我们之前的讨论,异步编程的想法是释放 ThreadPool 线程而不是等待操作的完成。当我们将出站调用表示为任务并使用 async-await 时,这可以非常容易地实现。

然而,对于 CPU 密集型操作,ThreadPool 线程将继续在工作者线程上执行指令(因为它是一个 CPU 密集型操作,需要 CPU 时间),显然不能释放该线程。这意味着将 CPU 密集型操作封装在 async-await 中不会带来任何好处,并且与同步运行相同。因此,处理 CPU 密集型操作的一个更好的方法是使用 TPL。

这并不意味着我们遇到 CPU 密集型方法时就会停止使用 async-await。推荐的方式是仍然使用 async-await 来管理 CPU 密集型操作,同时使用 TPL,并且不要打破我们使用 async-await 的第一条原则。

这里是一个使用 async-await 来管理 CPU 密集型工作的简单代码片段:

        private async Task CPUIOResult()
        {
            var doExpensiveCalculationTask = Task.Run(() => 
              DoExpensiveCalculation()); //Call a method 
              //that does CPU intense operation        
           var downloadFileAsyncTask = DownloadFileAsync();
            await Task.WhenAll(doExpensiveCalculationTask,
             downloadFileAsyncTask);
        }
private async Task DownloadFileAsync(string url, string path)
        {
            // Implementation
        }
        private float DoExpensiveCalculation()
        {
            //Implementation
        }

如前述代码所示,仍然可以使用 async-await 和 TPL 的组合来管理 CPU 密集型工作;这取决于开发者评估所有可能的选项并相应地编写代码。

避免使用 async void

总是确保使用 TaskTask<T> 作为使用 async-await 实现的异步方法的返回类型,而不是 void,如果方法预期不会返回任何内容。原因是 Task 是一个复杂的抽象,为我们处理了许多事情,例如异常处理和任务完成状态。然而,如果一个异步方法有 async void 返回类型,它就像是一个“点火后忘掉”的方法,任何调用此方法的调用者都无法知道操作的状态,即使有异常发生。

这是因为在 async void 方法内部,一旦遇到 await 表达式,调用就会返回给调用者,没有任何关于 Task 的引用,因此没有引用可以抛出异常。对于像 WPF 这样的 UI 应用程序,async void 方法上的任何异常都会导致应用程序崩溃;然而,对于 async void 事件处理器来说,这是一个例外。

async void 方法的另一个缺点是无法编写单元测试并正确断言。因此,始终建议使用 async Task 异常作为顶级事件处理器(顶级事件在这里是关键),因为顶级事件,如按钮点击或鼠标点击,更多的是单向信号,并且在异步代码中与它们的同步对应物没有不同的使用方式。

async Lambda 的情况下,也需要考虑相同的因素,我们需要避免将它们作为参数传递给接受 Action 类型的参数的方法。以下是一个示例:

long elapsedTime = AsyncLambda(async() =>
{
    await Task.Delay(1000);
});
Console.WriteLine(elapsedTime);
Console.ReadLine();
static long AsyncLambda(Action a)
{
    Stopwatch sw = new Stopwatch();
    sw.Start();
    for (int i = 0; i < 10; i++)
    {
        a();
    }
    return sw.ElapsedMilliseconds;
}

在这里,预期 elapsedTime 的值将在 10,000 左右。然而,它接近 100 的原因相同——那就是,由于 Actionvoid 返回类型的委托,对 AsyncLambda 的调用会立即返回到 Main 方法(就像任何 async void 方法一样)。这可以通过如下修改 AsyncLambda 来修复(或者只需将参数更改为 Func<Task> 并相应地处理 a() 的等待),然后强制调用者一路使用 async

        async static Task<long> AsyncLambda(Func<Task> a)
        {
            Stopwatch sw = new Stopwatch();
            sw.Start();
            for (int i = 0; i < 10; i++)
            {
                await a();
            }
            return sw.ElapsedMilliseconds;
        }

注意——如果你的应用程序中有接受 Action 类型参数的方法,建议你有一个接受 Func<Task>Func<Task<T>> 的重载。幸运的是,C# 编译器自动处理这一点,并且始终调用带有 Func<Task> 参数的重载。

小贴士

使用 Visual Studio 2022 异常助手功能来调试框架代码重新抛出的 async 异常。

使用 IAsyncEnumerable 的异步流

我们都知道 foreach 用于遍历 IEnumerable<T>IEnumerator<T>。让我们看看以下代码,其中我们从数据库中检索所有员工 ID 并遍历每个员工以打印他们的 ID:

        static async Task Main(string[] args)
        {
            var employeeTotal = await
             GetEmployeeIDAsync(5);
            foreach (int i in employeeTotal)
            {
                Console.WriteLine(i);
            }
        }

GetEmployeeIDAsync 的实现如下:

        static async Task<IEnumerable<int>>
         GetEmployeeIDAsync(int input)
        {
            int id = 0;
            List<int> tempID = new List<int>();
            for (int i = 0; i < input; i++) //Some async DB 
              //iterator method like ReadNextAsync
            {
                await Task.Delay(1000); // simulate async
                id += i; // Hypothetically calculation
                tempID.Add(id);
            }
            return tempID;
        }

在这里,你可以看到我们必须使用一个临时列表,直到我们从数据库中接收到所有记录,然后最终返回这个列表。然而,如果我们的方法中有一个迭代器,C# 中的 yield 就是一个明显的选择,因为它有助于立即返回结果并避免使用临时变量。现在,假设你使用了 yield,如下面的代码所示:

yield return id;

编译时你会收到以下错误:

The body of 'Program.GetEmployeeIDAsync(int)' cannot be an iterator block because 'Task<IEnumerable<int>>' is not an iterator interface type

因此,需要能够使用 yieldasync 方法一起,并且循环遍历集合以异步调用应用程序。这就是 C# 8.0 通过 IAsyncEnumerable 提出异步流的原因,它主要允许你立即返回数据并异步消费集合。因此,前面的代码可以修改如下:

await foreach (int i in GetEmployeeIDAsync(5))
    {
        Console.WriteLine(i);
    }       
static async IAsyncEnumerable<int>
 GetEmployeeIDAsync(int input)
{
    int id = 0;
    List<int> tempID = new List<int>();
    for (int i = 0; i < input; i++)
    {
        await Task.Delay(1000);
        id += i; // Hypothetically calculation
        yield return id;
    }
}

因此,在这里你可以看到一旦一个方法开始返回,IAsyncEnumerable 循环可以异步迭代,这在许多情况下有助于编写更干净的代码。

线程池饥饿

假设你有一个包含异步代码的应用程序。然而,你注意到在高负载期间,请求的响应时间会急剧增加。你进一步研究这个问题,但你的服务器的 CPU 并没有完全利用,你的进程的内存也没有很高,而且这也不是数据库成为瓶颈的情况。在这种情况下,你的应用程序可能正在导致所谓的ThreadPool饥饿。

ThreadPool饥饿是一种状态,其中新线程不断被添加以服务并发请求,最终达到一个点,ThreadPool无法添加更多线程,请求开始看到延迟的响应时间,在最坏的情况下甚至开始失败。即使ThreadPool可以以每秒一两个线程的速度添加线程,新的请求也可能以更高的速率到来(例如,在假日季节网络应用程序的突发负载期间)。因此,响应时间显著增加。这种情况发生的原因有很多,这里列出了一些:

  • 消耗更多线程以加快长时间运行的 CPU 密集型工作

  • sync方法中使用GetAwaiter().GetResult()调用async方法

  • 错误使用同步原语,例如一个线程长时间持有锁,而其他线程等待获取它

在所有前面的点中,共同点是阻塞代码;因此,即使是短暂的阻塞代码,如Thread.Sleep,或者像GetAwaiter().GetResult()这样的操作,或者尝试为 CPU 密集型项分配更多线程,都会增加ThreadPool中的线程数量,并最终导致饥饿。

可以使用PerfView等工具进一步诊断ThreadPool饥饿,例如捕获 200 秒的跟踪,并验证你进程中线程的增长情况。如果在高峰负载期间看到你的线程以快速的速度增长,那么可能存在饥饿的情况。

防止ThreadPool饥饿的最佳方法是在整个应用程序中使用async-await,并且永远不要阻塞任何async调用。此外,限制新创建的操作的节流也可以帮助,因为它限制了一次可以排队多少项。

在本节中,我们讨论了两个重要的结构,async-await和 TPL,当它们结合使用时,可以简化异步代码的编写。在下一节中,我们将学习.NET 6 中可用的各种数据结构,这些数据结构可以支持同步/线程安全,而无需编写任何额外的代码。

使用并发集合实现并行化

集合类是最常用的类型之一,用于封装、检索和修改相关数据的枚举集合。Dictionarylistqueuearray是一些常用的集合类型,但它们不是线程安全的。如果你一次只从一个线程访问它们,这些集合是好的。

在现实世界中,环境将是多线程的,为了使其线程安全,你必须实现各种同步构造,如前文所述。为了解决这个问题,Microsoft 提出了并发集合类,例如 ConcurrentQueueConcurrentBagConcurrentDictionaryConcurrentStack,它们是线程安全的,因为它们内部实现了同步。让我们在以下章节中详细探讨它们。

ConcurrentDictionary

让我们使用字典来模拟一个多线程环境。将 t1 任务视为一个客户端向字典添加操作的第一个操作,将 t2 任务视为另一个客户端从字典读取的第二个操作。

我们在每个任务中添加 Thread.Sleep 来模拟现实场景,确保在这个例子中一个任务不会在另一个任务完成之前完成。让我们考虑一个具有以下代码片段的示例控制台应用程序:

// Task t1 as one operation from a client who is adding to the dictionary.
Dictionary<int, string> employeeDictionary = new Dictionary<int, string>();            
            Task t1 = Task.Factory.StartNew(() =>
            {
                for (int i = 0; i < 100; ++i)
                {
                    employeeDictionary.TryAdd(i, "Employee"
                     + i.ToString());
                    Thread.Sleep(100);
                }
            });

这是从另一个客户端读取字典的第二个操作 Task t2

            Task t2 = Task.Factory.StartNew(() =>
            {
                Thread.Sleep(500);
                foreach (var item in employeeDictionary)
                {
                    Console.WriteLine(item.Key + "-" +
                      item.Value);
                    Thread.Sleep(100);
                }
            });

现在,两个任务同时执行,如下所示:

try
            {
                Task.WaitAll(t1, t2); // Not recommended to 
                  //use in production application.
            }
            catch (AggregateException ex)
            {
                Console.WriteLine(ex.Flatten().Message);
            }
            Console.ReadLine();

当你运行这个程序时,你会得到以下异常,指出你不能同时修改和枚举集合:

表 4.2 – ConcurrentDictionary 示例输出

表 4.2 – ConcurrentDictionary 示例输出

你现在可能认为我们可以添加一个锁来管理线程同步,以避免在多线程场景中发生这种异常。我在代码中修改和枚举字典的地方添加了一个锁来同步线程。以下是更新的代码片段:

  1. 首先,我们有 Task t1 作为向字典添加操作的客户端的一个操作:

    Dictionary<int, string> employeeDictionary = new Dictionary<int, string>();            
                Task t1 = Task.Factory.StartNew(() =>
                {
                    for (int i = 0; i < 100; ++i)
                    {
                        //Lock the shared data
                        lock (syncObject)
                        {
                            employeeDictionary.TryAdd(i,
                              "Employee" + i.ToString());
                        }
                        Thread.Sleep(100);
    
                    }
                });
    
  2. 然后,我们有 Task t2 作为另一个客户端从字典读取的第二个操作:

                Task t2 = Task.Factory.StartNew(() =>
                {
                    Thread.Sleep(500);
                    //Lock the shared data
                    lock (syncObject)
                    {
                        foreach (var item in
                         employeeDictionary)
                        {
                            Console.WriteLine(item.Key + 
                              "-" + item.Value);
                            Thread.Sleep(100);
                        }
                    }
                });
    
  3. 现在,我们同时执行了两个任务:

    try
                {
                    Task.WaitAll(t1, t2); // Not 
                      //recommended to use in production 
                      //application.
                }
                catch (AggregateException ex)
                {
                    Console.WriteLine(ex.Flatten()
                      .Message);
                }
                Console.ReadLine();
    

当你运行这段代码时,你不会看到任何异常。然而,正如之前提到的,锁有一些问题,因此这段代码可以使用并发集合重写。它们内部使用多线程同步技术,有助于扩展,防止数据损坏,并避免所有与锁相关的问题。

我们可以使用 ConcurrentDictionary 重写我们的代码,它位于 System.Collections.Concurrent 命名空间中。在示例代码中将 Dictionary 替换为 ConcurrentDictionary。你也可以删除对 System.Collections.Generic 命名空间的引用,因为现在不再使用 Dictionary。此外,删除所有锁。更新的代码如下,其中我们将 Dictionary 替换为 ConcurrentDictionary 并删除锁:

我们有 Task t1 作为向字典添加操作的客户端的一个操作,并且与并发集合一起不需要显式的锁:

ConcurrentDictionary<int, string> employeeDictionary = new ConcurrentDictionary<int, string>();
            Task t1 = Task.Factory.StartNew(() =>
            {
                for (int i = 0; i < 100; ++i)
                {
                    employeeDictionary.TryAdd(i, 
                      "Employee"
                      + i.ToString());
                    Thread.Sleep(100);

                }
            });
  1. 然后,我们还有Task t2作为来自另一个客户端的第二个操作,该客户端正在从字典中读取,并且在使用并发集合时不需要显式锁:

                Task t2 = Task.Factory.StartNew(() =>
                {
                    Thread.Sleep(500);
                    foreach (var item in 
                      employeeDictionary)
                    {
                        Console.WriteLine(item.Key + "-" +
                           item.Value);
                        Thread.Sleep(100);
                    }
                });
    
  2. 现在,这两个任务同时执行:

    try
                {
                    Task.WaitAll(t1, t2);
                }
                catch (AggregateException ex) // You will 
                  //not get Exception
                {
                    Console.WriteLine(ex.Flatten()
                      .Message);
                }
                Console.ReadLine();
    

当你现在运行程序时,你将不会遇到任何异常,因为所有操作在ConcurrentDictionary中都是线程安全且原子的。随着项目的扩大,开发者无需为实现锁和维持锁而承担任何开销。以下是一些关于如ConcurrentDictionary之类的并发集合的注意事项,你需要牢记:

  • 如果两个线程调用AddOrUpdate,无法保证哪个工厂委托将被调用,甚至无法保证如果工厂委托生成了一个项,该项是否会被存储在字典中。

  • 通过GetEnumerator调用获得的枚举器不是一个快照,枚举过程中可能会对其进行修改(这不会引发任何异常)。

  • 键和值属性是对应集合的快照,可能不对应实际的字典状态。

我们已经详细地研究了ConcurrentDictionary;让我们在下一节中看看其他并发集合。

生产者-消费者并发集合

在生产者-消费者并发集合中,一个或多个线程可以生产任务(例如,向队列、栈或包中添加),一个或多个其他线程可以从同一个集合(队列、栈或包)中消费任务。

我们在上一个章节中看到的ConcurrentDictionary是一个通用集合类,你可以添加你想要的项并指定你想要读取的项。其他并发集合是为特定问题设计的:

  • ConcurrentQueue适用于需要 FIFO(先进先出)的场景。

  • ConcurrentStack适用于需要 LIFO(后进先出)的场景。

  • ConcurrentBag适用于需要同一线程生产并消费存储在包中的数据,且顺序不重要的场景。

这三个集合也被称为生产者-消费者集合,其中一个或多个线程可以生产任务并从同一个集合中消费任务,如下面的图所示:

图 4.7 – 生产者-消费者并发集合

图 4.7 – 生产者-消费者并发集合

所有这三个集合都实现了IProducerConsumerCollection<T>接口,最重要的方法是TryAddTryTake,如下所示:

// Returns: true if the object was added successfully; otherwise, false.        
bool TryAdd(T item);
// Returns true if an object was removed and returned successfully; otherwise, false.
bool TryTake([MaybeNullWhen(false)] out T item);

让我们以一个生产者-消费者为例,并使用ConcurrentQueue来模拟它:

  • 生产者:向网络服务发送请求的客户端和将请求存储在队列中的服务器。

  • 消费者:一个工作线程从队列中拉取请求并处理它。

实现如下所示:

//Producer: Client sending request to web service and server storing the request in queue.
ConcurrentQueue<string> concurrentQueue = new ConcurrentQueue<string>();            
            Task t1 = Task.Factory.StartNew(() =>
            {
                for (int i = 0; i < 10; ++i)
                {
                    concurrentQueue.Enqueue("Web request " 
                      + i);
                    Console.WriteLine("Sending "+ "Web 
                      request " + i);
                    Thread.Sleep(100);
                }
            });

现在,我们有Consumer,其中Worker线程从队列中拉取请求并处理它:

            Task t2 = Task.Factory.StartNew(() =>
            {
                while (true)
                {
                    if (concurrentQueue.TryDequeue(out
                     string request))
                    {
                        Console.WriteLine("Processing "+
                         request);
                    }
                    else
                    {
                        Console.WriteLine("No request");
                    }
                }
            });

生产者和消费者任务同时成功执行。等待所有提供的任务在指定的毫秒数内完成执行。请参考以下代码片段:

try
            {                
                Task.WaitAll(new Task[] { t1, t2 }, 1000);
            }
            catch (AggregateException ex) // No exception
            {
                Console.WriteLine(ex.Flatten().Message);
            }

这是根据微软的方法定义:

  • concurrentqueue.Enqueue:这会将一个对象添加到 ConcurrentQueue<T> 的末尾。

  • concurrentqueue.TryDequeue:这尝试从 ConcurrentQueue 的开头移除并返回对象。

当你运行程序时,你可以看到 task t1 正在生成请求,而 task t2 正在轮询然后消费请求。我们将在稍后深入了解。我们还提到,这些类实现了 IProducerConsumerCollection<T>,因此我们将对之前的代码进行三项更改:

  • ConcurrentQueue<string> 替换为 IProducerConsumerCollection<string>

  • concurrentqueue.Enqueue 替换为 concurrentqueue.TryAdd

  • concurrentQueue.TryDequeue 替换为 concurrentQueue.TryTake

这就是代码现在的样子:

IProducerConsumerCollection<string> concurrentQueue = new ConcurrentQueue<string>();
//Removed code for brevity.
Task t1 = Task.Factory.StartNew(() =>
            {
                for (int i = 0; i < 10; ++i)
                {
                    concurrentQueue.TryAdd("Web request " + 
                      i);
//Removed code for brevity.
Task t2 = Task.Factory.StartNew(() =>
            {
                while (true)
                {
                    if (concurrentQueue.TryTake(out string
                     request))
//Removed code for brevity.

现在,继续运行程序。你可以看到 task t1 正在生成请求,而 task t2 正在轮询然后消费请求。你可以看到 task t1 生成的所有 10 个请求都被 task t2 消费。但是有两个问题:

  • 生产者以自己的速率生产,消费者以自己的速率消费,并且没有同步。

  • task t2 中,消费者进行了连续的不定轮询,这对性能和 CPU 使用率不利,正如我们通过 concurrentqueue.TryTake 看到的。

这就是 BlockingCollection<T> 发挥作用的地方。

BlockingCollection<T>

BlockingCollection<T> 支持边界和阻塞。边界允许你为集合指定一个最大容量。控制集合的最大大小有助于防止生产线程比消费线程提前太多。多个生产线程可以并发地向 BlockingCollection<T> 添加项目,直到集合达到其最大大小,之后它们将被阻塞,直到消费者移除一个项目。

类似地,多个消费线程可以并发地从阻塞集合中移除项目,直到集合变为空,之后它们将被阻塞,直到生产者添加一个项目。当没有更多项目添加时,生产线程可以调用 CompleteAdding 方法,并指示它已完成添加。这将帮助消费者监控 IsCompleted 属性,以了解当集合为空时不再添加更多项目。

当你创建一个 BlockingCollection<T> 类时,除了边界容量外,你还可以根据场景指定要使用的并发集合类型。默认情况下,当你不指定类型时,BlockingCollection<T> 的集合类型是 ConcurrentQueue<T>

这里是一个示例代码片段:

BlockingCollection<string> blockingCollection = new BlockingCollection<string>(new ConcurrentQueue<string>(),5);    
            Task t1 = Task.Factory.StartNew(() =>
            {
                for (int i = 0; i < 10; ++i)
                {
                    blockingCollection.TryAdd("Web request
                     " + i);
                    Console.WriteLine("Sending " + "Web
                      request " + i);
                    Thread.Sleep(100);
                }
                blockingCollection.CompleteAdding();
            });

然后,具有 Worker 线程的消费线程从队列中拉取项目并处理它:

            Task t2 = Task.Factory.StartNew(() =>
            {
                while (!blockingCollection.IsCompleted)
                {
                    if (blockingCollection.TryTake(out
                     string request,100))
                    {
                        Console.WriteLine("Processing " +
                         request);
                    }
                    else
                    {
                        Console.WriteLine("No request");
                    }
                }
            });

现在,生产者和消费者线程可以并发访问。

在代码中需要考虑以下几点:

  • 指定的边界为 5BlockingCollection<string> blockingCollection = new BlockingCollection<string>(new ConcurrentQueue<string>(),5);

  • 当不再添加更多项目时,生产者线程调用 CompleteAdding 方法以指示它已完成添加:blockingCollection.CompleteAdding();

  • 消费者监控 IsCompleted 属性以确定当集合为空时不再添加更多项目:while (!blockingCollection.IsCompleted)

  • 尝试在指定时间内从 BlockingCollection<T> 中移除一个项目——例如,我选择了 100 毫秒:if (blockingCollection.TryTake(out string request, 100))

这就是阻塞集合的力量。生产者和消费者都是解耦的,它们可以由不同的团队独立编码,在运行时,它们使用阻塞并发集合相互共享数据。此外,同时,通过边界容量控制流量,以确保生产者不会比消费者领先太多。

注意

除了我们看到的 TryTake 方法之外,你还可以使用 foreach 循环从阻塞集合中移除项目。你可以在这里了解相关信息:

docs.microsoft.com/en-us/dotnet/standard/collections/thread-safe/how-to-use-foreach-to-remove

在阻塞集合中,可能会出现消费者需要与多个集合一起工作并取或添加项目的情况。TakeFromAnyAddToAny 方法将帮助你在这种情况下。你可以在这里进一步了解这两个方法:

docs.microsoft.com/en-us/dotnet/api/system.collections.concurrent.blockingcollection-1.takefromany?view=net-6.0

docs.microsoft.com/en-us/dotnet/api/system.collections.concurrent.blockingcollection-1.addtoany?view=net-6.0

摘要

总结来说,编写和维护干净的异步代码是困难的。然而,随着 .NET 和 C# 中可用的各种构造,开发者现在可以以更少的框架开销编写异步代码,并更多地关注业务需求。

在本章中,我们介绍了使用 TPL、async-await 和并发集合编写可扩展异步代码的各种方法,我们还介绍了 .NET 中线程和 ThreadPool 的基础知识,以了解框架内部结构并为企业应用程序编写更干净的代码。现在,我们对多线程以及如何在多线程环境中保护共享数据有了更深入的了解。我们学习了如何创建任务和实现使用 async-await 的异步函数,最后,我们学习了 .NET Core 中可用的并发集合及其在各种并发场景中的应用。

在下一章中,我们将探讨 .NET 6 中的依赖注入及其在松耦合企业应用程序中的各种底层类中发挥的重要作用。

问题

  1. 在多线程环境中,以下哪个数据结构应该用来保护数据不被覆盖/损坏?

a. async-await.

b. 任务。

c. 同步结构,如锁。

d. 数据永远不会损坏。

答案:a

  1. 如果你有一个从 REST API 获取数据的 WPF 应用程序,以下哪个应该实现以获得更好的响应性?

a. 并发集合

b. Parallel.For

c. async-await 用于 REST API 调用

答案:c

  1. 以下哪个应该传递以取消任务?

a. CancellationToken

b. ConcurrentDictionary

c. SemaphoreSlim

答案:a

  1. 以下哪个是使用 async-await 且不返回任何内容的异步方法的推荐返回类型?

a. async void

b. async Task

c. async book

d. async Task<bool>

答案:b

进一步阅读

第五章:第五章:.NET 6 中的依赖注入

企业应用程序可能面临的一个大问题是将不同元素连接起来并管理它们的生命周期的复杂性。为了解决这个问题,我们使用 控制反转IoC)原则,该原则建议移除对象之间的依赖关系。通过委派控制流,IoC 使程序可扩展并增加了模块化。事件、回调委托、观察者模式和 依赖注入DI)是实现 IoC 的几种方法。

在本章中,我们将学习以下内容:

  • 什么是 DI?

  • ASP.NET Core 6 中的 DI

  • 管理应用程序服务

  • 使用第三方容器

到本章结束时,你将很好地了解 DI 以及它在 .NET 6 应用程序中的利用方式,ASP.NET Core 6 中提供的范围类型,以及如何在项目中利用它们。

技术要求

本章使用的代码可以在以下位置找到:github.com/PacktPublishing/Enterprise-Application-Development-with-C-10-and-.NET-6-Second-Edition/tree/main/Chapter05

什么是 DI?

DI 是一种技术,其中对象接收它所依赖的对象。DI 模式实现了作为 单一职责原则、开闭原则、里氏替换原则、接口隔离原则和依赖倒置原则SOLID)设计原则一部分的 DI 原则,如 第一章设计和架构企业应用程序 中所述。使用 DI,代码将更易于维护、阅读、测试和扩展。

DI 是帮助实现更易于维护代码的最知名方法之一。DI 涉及三个实体,如下面的图所示:

图 5.1 – DI 关系

图 5.1 – DI 关系

IOrderRepository 负责处理 Order 实体。.NET IoC 容器(IOrderRepository (OrderController(客户端))。

IoC 容器(也称为 DI 容器)是一个用于实现自动 DI 的框架。在 图 5.1 中,这被称为 注入器。它负责创建或引用依赖关系并将其注入到 客户端

现在我们已经了解了什么是 DI,让我们来了解 DI 的类型。

DI 类型

服务可以以多种方式注入到依赖项中。根据服务注入客户端对象的方式,DI 被分为三种类型,如下所述:

  • IWeatherProvider 依赖项通过构造函数参数注入:

        public class WeatherService
        {
            private readonly IweatherProvider
                weatherProvider;
            public WeatherService(IWeatherProvider
                weatherProvider)
                    => this.weatherProvider =
                        weatherProvider;
            public WeatherForecast GetForecast(string
                location) =>
                this.weatherProvider.
                    GetForecastOfLocation (location);   
        }
    

在前面的示例中,WeatherService 依赖于 IWeatherProvider,它通过构造函数参数注入。

注意

对于 WeatherProvider 服务的实现,请参考 GitHub 上的示例代码,该代码可在以下链接找到:github.com/PacktPublishing/Enterprise-Application-Development-with-C-10-and-.NET-6-Second-Edition/blob/main/Chapter05/DITypes/Service/WeatherProvider.cs

在初始化 WeatherService2 时,IWeatherProvider 依赖项未设置。它是在对象初始化后通过 WeatherProvider 属性设置的:

    public class WeatherService2
    {
        private IWeatherProvider _weatherProvider;
        public IWeatherProvider WeatherProvider
        {
            get => _weatherProvider == null ?
                        throw new
                            InvalidOperationException(
                            "WeatherService is not
                            initialized")
                    : _weatherProvider;
            set => _weatherProvider = value;
        }
        public WeatherForecast GetForecast(string
            location) =>
            this.WeatherProvider.
                GetForecastOfLocation(location);
    }
  • IWeatherProvider 依赖项作为所需的方法参数进行注入。

在以下代码片段中,IWeatherProvider 服务通过 GetForecast 方法注入到 WeatherService 中:

    public class WeatherService
    {
       public WeatherForecast GetForecast(
           string location, IWeatherProvider
           weatherProvider)
        {
            if(weatherProvider == null)
            {
                throw new ArgumentNullException(
                    nameof(weatherProvider));
            }
            return weatherProvider.
                GetForecastOfLocation (location);
        }
    }

以下是一些建议,可以帮助选择依赖注入的类型:

  • 当类有依赖项且没有这个依赖项功能无法工作时,请使用构造函数注入。

  • 当依赖项在类的多个函数中使用时,请使用构造函数注入。

  • 当依赖项在类实例化后可以更改时,请使用属性注入。

  • 当依赖项的实现随着每次调用而改变时,请使用方法注入。

在大多数情况下,构造函数注入将被用于干净且解耦的代码,但根据需要,我们还将利用方法和属性注入技术。

我们现在已经学习了依赖注入的概念。让我们深入了解 .NET 6 提供的依赖注入实现。

ASP.NET Core 6 中的依赖注入

.NET 6 内置了 IoC 容器框架,这简化了依赖注入。这包括 Microsoft.Extensions.DependencyInjection NuGet 包和 ASP.NET Core 6 框架本身严重依赖于它。为了支持依赖注入,容器需要对对象/服务支持三个基本操作,如下所述:

  • 注册:容器应提供注册依赖项的机制。这将有助于将正确的类型映射到类,以便它可以创建正确的依赖项实例。

  • 解析:容器应通过创建依赖对象并将其注入到依赖实例中来解析依赖项。IoC 容器通过传递所有必需的依赖项来管理已注册对象的形成。

  • 释放:容器负责管理通过它创建的依赖项的生命周期。

在 .NET 6 中,以下术语被使用:

  • 服务:指由容器管理的依赖项

  • IConfigurationILoggerFactoryIWebHostEnvironment

  • 我们在上一节中使用的 IWeatherProvider 服务

为了使应用程序启动,ASP.NET Core 6 框架注入了一些依赖项,这些依赖项被称为 WebApplicationBuilder 注入所需的框架服务,如 IConfigurationIWebHostEnvironment。当您尝试打印已注册的服务时,如下代码片段所示(参考 github.com/PacktPublishing/Enterprise-Application-Development-with-C-10-and-.NET-6-Second-Edition/blob/main/Chapter05/DITypes/Program.cs#L16 中的代码),我们可以列出已注册的框架服务:

foreach(var i in builder.Services.AsEnumerable())
{
    Console.WriteLine($"{i.Lifetime} - {i.ServiceType.ToString()}");
}

在 ASP.NET Core 6.0 中,IWebHostEnvironment 框架服务可通过 builder.Environment 属性获取。同样,配置可通过 builder.Configuration 属性获取。

ASP.NET Core 6 运行时实例化所有必需的框架服务并将它们注册到 IoC 容器中。从 ASP.NET Core 6 开始,它们可通过 Program.cs 中的 WebApplicationWebApplicationBuilder 的属性访问。这些框架服务可以通过我们在上一节中讨论的任何 DI 类型注入到控制器和其他服务中。

应用程序服务是由开发人员注入到容器中的服务。这些服务将使用 WebApplicationBuilderServices 属性进行注册。以下代码片段展示了如何将 IWeatherProvider 应用程序服务注册到容器中:

builder.Services.AddScoped<IWeatherProvider, WeatherProvider>();

在下一节中,我们将了解这些服务的生命周期以及它们是如何被管理的。

注意

请参阅第十章创建 ASP.NET Core 6 Web API,了解 Program.cs 文件中的代码。

理解服务生命周期

当您使用指定的生命周期注册服务时,容器将根据指定的生命周期自动销毁对象。在 Microsoft DI 容器中,可以使用以下三种类型的生命周期:

  • AddTransient 扩展方法用于注册此生命周期,如下代码片段所示:

    public static IServiceCollection AddTransient(this
     IServiceCollection services, Type serviceType);
    

    注意

    临时生命周期通常用于无状态、轻量级的服务。

  • ServiceProvider 在应用程序关闭时被销毁。使用 AddSingleton 扩展方法注册此生命周期,如下代码片段所示:

    public static IServiceCollection AddSingleton(this
     IServiceCollection services, Type serviceType);
    
  • DbContext 使用作用域生命周期进行注册。使用 AddScoped 扩展方法注册到作用域生命周期范围,如下代码片段所示:

    public static IServiceCollection AddScoped(this
     IServiceCollection services, Type serviceType);
    

在应用程序开发中,需要明智地选择生命周期类型。一个服务不应依赖于生命周期比其短的服务;例如,注册为单例的服务不应依赖于注册为瞬时的服务。以下表格显示了哪些生命周期可以安全地依赖于哪些其他生命周期范围:

![图 5.1 – DI 关系](https://github.com/OpenDocCN/freelearn-csharp-zh/raw/master/docs/entp-appdev-csp10-dn6/img/Figure_5.1 – DI relationship)

表 5.1 – 表格结构

表 5.1 – 生命周期依赖

作为开发者,你不需要担心范围验证。内置的范围验证在 ASP.NET Core 6 中完成,当环境设置为 InvalidOperationException 时抛出。这可以通过在注册 ServiceProvider 时为所有环境配置启用 ValidateScopes 选项来显式打开。在此代码片段中,当创建主机构建器时,ValidateScopes 设置为 true 以打开范围验证:

builder.Host.UseDefaultServiceProvider(opt => { opt.ValidateScopes = true; }); 

让我们创建一个 ASP.NET Core 6 Web 应用程序来了解服务生命周期。我们将创建不同的服务并将它们注册为单例、作用域和瞬态生命周期范围,并观察它们的行为。按照以下步骤进行:

  1. 创建一个新的 ASP.NET Core Web 应用程序(DISampleWeb)。

  2. 创建一个名为 Services 的新项目文件夹,并添加三个类:ScopedServiceSingletonServiceTransientService。添加以下代码(所有这些服务都将相同,其中没有任何实际代码;我们只是根据它们的名称将它们注册为不同的生命周期范围):(所有这些服务都将相同,其中没有任何实际代码;我们只是根据它们的名称将它们注册为不同的生命周期范围)

    public interface IScopedService {     }
    public class ScopedService : IScopedService {    }
    
  3. SingletonService.cs:此类将使用单例生命周期范围进行注册,如下代码片段所示:

    public interface ISingletonService   {    }
    public class SingletonService : ISingletonService {  }
    
  4. TransientService.cs:此类将使用瞬态生命周期范围进行注册,如下代码片段所示:

    public interface ITransientService   {    }
    public class TransientService : ITransientService{   }
    
  5. 现在,在 Program.cs 中使用 IServiceCollection 注册这些服务,如下所示:

    //Register as Scoped
    builder.Services.AddScoped<IScopedService,ScopedService>();
    //Register as Singleton
    builder.Services.AddSingleton<ISingletonService,SingletonService>();
    //Register as Transient
    builder.Services.AddTransient<ITransientService,TransientService>();
    

服务描述符集合 IServiceCollection 通过 WebApplicationBuilderServices 属性公开。

  1. 现在,在 Models 文件夹下添加 HomeViewModel 模型类,该类将用于显示从先前注册的服务检索到的数据。以下代码片段说明了如何进行此操作:

    public class HomeViewModel
    {
            public int Singleton { get; set; }
            public int Scoped { get; set; }
            public int Scoped2 { get; internal set; }
            public int Transient { get; set; }
            public int Transient2 { get; internal set; }
    }
    

由于我们已使用 ASP.NET Core 6 IoC 容器注册了 ScopedServiceSingletonServiceTransientService,我们将通过构造函数注入获取这些服务。

  1. 现在,我们将添加代码以在 HomeControllerViews 中获取这些服务,以在主页上显示从这些对象检索到的数据。修改主页控制器以获取两个 ScopedServiceTransientService 的实例,并使用服务对象的哈希码设置 ViewModel。解决方案结构如下截图所示:

![图 5.2 – 解决方案结构](https://github.com/OpenDocCN/freelearn-csharp-zh/raw/master/docs/entp-appdev-csp10-dn6/img/Figure_5.2 – Solution structure)

图 5.2 – 解决方案结构

图 5.2 – 解决方案结构

注意

GetHashCode 方法返回对象的哈希码。这将根据实例而变化。

  1. 修改HomeController的构造函数以接受已注册的服务,并定义私有字段以引用服务实例,如下所示:

    private readonly ILogger<HomeController> _logger;
    private readonly IScopedService scopedService;
    private readonly IScopedService scopedService2;
    private readonly ISingletonService singletonService;
    private readonly ITransientService transientService;
    private readonly ITransientService transientService2;
    public HomeController(ILogger<HomeController> logger,
    IScopedService scopedService,
    IScopedService scopedService2,
    ISingletonService singletonService,
    ITransientService transientService,
    ITransientService transientService2)
    {
         this._logger = logger;
         this.scopedService = scopedService;
         this.scopedService2 = scopedService2;
         this.singletonService = singletonService;
         this.transientService = transientService;
         this.transientService2 = transientService2;
    }
    
  2. 现在,修改HomeController下的Index方法,设置HomeViewModel,如下所示:

    public IActionResult Index()
    {
          var viewModel = new HomeViewModel
         {
             Scoped = scopedService.GetHashCode(),
             Scoped2 = scopedService2.GetHashCode(),
             Singleton = singletonService.GetHashCode(),
             Transient = transientService.GetHashCode(),
             Transient2 = transientService2.GetHashCode(),
          };
          return View(viewModel);
    }
    
  3. 接下来,修改~/Views/Home文件夹下的Index.cshtml,以在页面上显示HomeViewModel,如下所示:

    @model HomeViewModel
    @{
        ViewData["Title"] = "Home Page";
    }
    <h2 class="text-success">Singleton.</h2>
    <p>
            <strong>ID:</strong> <code>@Model.Singleton
    </code>
    </p>
    <h2 class="text-success">Scoped instance 1</h2>
    <p>
            <strong>ID:</strong> <code>@Model.Scoped</code>
    </p>
    <h2 class="text-success">Scoped instance 2</h2>
    <p>
            <strong>ID:</strong> <code>@Model.Scoped2</code>
    </p>
    <h2 class="text-success">Transient instance 1</h2>
    <p>
            <strong>ID:</strong> <code>@Model.Transient</code>
    </p>
    <h2 class="text-success">Transient instance 2</h2>
    <p>
            <strong>ID:</strong> <code>@Model.Transient2</code>
    </p>
    
  4. 现在,运行应用程序。你会看到如下输出:

图 5.3 – 第一次运行的示例输出

图 5.3 – 第一次运行的示例输出

如果我们观察输出,ScopedService是相同的。这是因为对于每个请求作用域,只为IScopedService创建一个对象。请注意,当你运行代码时,ID 可能不同,因为它们是在运行时生成的。

临时服务的 ID 对于两个服务都是不同的。正如我们所学的,这是因为每次对 IoC 容器的请求都会创建一个新的实例。

  1. 现在,再次刷新页面。你会看到类似如下输出:

图 5.4 – 第二次运行的示例输出

图 5.4 – 第二次运行的示例输出

如果我们比较图 5.3 和图 5.4 中的输出,我们会注意到SingletonService的 ID 没有改变——这是因为应用程序的生命周期中,对于单例对象只创建一个对象。到目前为止,我们已经看到了如何根据注册来管理服务生命周期。了解何时销毁对象也同样重要。在下一节中,我们将学习关于服务销毁的内容。

服务销毁

如我们在本章前面所学,对象的销毁是 IoC 容器框架的责任。容器会调用实现IDisposable接口的服务的Dispose方法。由容器创建的服务不应被开发者显式销毁。同样,开发者负责销毁他们创建的实例。

考虑以下代码片段,其中SingletonService实例以单例作用域注册:

var _disposableSingletonService= new DisposableSingletonService();
// Registering an instance of a class with singleton lifetime
builder.Services.AddSingleton<IDisposableSingletonService>(_disposableSingletonService);

注意

对于DisposableSingletonService的简单实现,请参考以下链接中的代码:github.com/PacktPublishing/Enterprise-Application-Development-with-C-10-and-.NET-6-Second-Edition/blob/main/Chapter05/DISampleWeb/Services/DisposableSingletonService.cs

在前面的代码片段中,我们创建了一个 DisposableSingletonService 的对象,并将其注册到 IoC 容器中。服务实例不是由容器创建的。在这种情况下,IoC 容器不会释放该对象;开发者有责任释放它。我们可以在 IHostApplicationLifetimeApplicationStopping 事件触发时释放对象,该事件通过 WebApplicationLifetime 属性公开,如下面的代码片段所示:

app.Lifetime.ApplicationStopping.Register(() => {
    _disposableSingletonService.Dispose();
});

在前面的代码片段中,IHostApplicationLifetime 由运行时注入到 WebApplication 中。此接口允许消费者接收 ApplicationStartedApplicationStoppedApplicationStopping 应用程序生命周期事件的通知。为了释放单例对象,我们将通过注册到 ApplicationStopping 生命周期事件来调用 Dispose() 方法。

.NET 6 中 DI 的新增功能是对作用域的 IAsyncDisposable 支持。在 IServiceProvider 中添加了一个新的 CreateAsyncScope 扩展方法来支持异步服务作用域的创建,并添加了一个 AsyncServiceScope 包装器,它实现了 IAsyncDisposable。以下代码展示了如何异步地释放作用域:

// Refer AsyncDisposableScope sample code for the implementation
await using (var scope = provider.CreateAsyncScope())
{
    var foo = scope.ServiceProvider.GetRequiredService<IWeatherProviderAsync>();
} 

从现在开始,如果你在手动创建作用域的地方使用依赖 CreateAsyncScope

注意

请参考以下 Microsoft 文档以了解更多关于 DI 指南的信息:

docs.microsoft.com/en-us/dotnet/core/extensions/dependency-injection-guidelines

到目前为止,我们已经探讨了服务生命周期以及它们在 .NET 6 中的释放方式。在下一节中,我们将学习如何管理应用程序服务。

管理应用程序服务

在 ASP.NET Core 6 中,当请求被 MvcMiddleware 接收到时,会使用路由来选择控制器和操作方法。IControllerActivator 会创建控制器的实例,并从依赖注入容器中加载构造函数参数。

理解服务生命周期 部分,我们看到了应用程序服务是如何注册的以及它们的生命周期是如何管理的。在示例中,服务是通过构造函数注入的,这被称为构造函数注入。在本节中,我们将了解如何实现方法注入,并探讨在 ASP.NET Core 6 的 IoC 容器中应用程序服务可以如何注册和访问。

通过方法注入访问已注册的服务

在前面的章节中,我们看到了依赖服务是如何注入到控制器构造函数中,并且引用被存储在一个局部字段中,用于调用依赖的方法/应用程序编程接口API)。

有时,我们不想在控制器的所有操作中都有依赖服务可用。在这种情况下,可以通过方法注入来注入服务。这通过创建一个带有[FromServices]属性的参数来完成,如下面的示例所示:

public IActionResult Index([FromServices] ISingletonService singletonService2)
{
}

ASP.NET Core 6 中引入的最小 API 允许我们在路由处理程序中请求 DI 服务,而无需显式使用[FromServices]属性,如下所示:

app.MapGet("/", (ISingletonService service) => service.DoAction());

你可能想知道运行时如何区分注入的服务和其他参数。为了实现这一点,.NET6 引入了一个新的IServiceProviderIsService接口,它有助于识别给定的服务类型已注册在 DI 容器中,而不需要创建其实例。

在下一节中,我们将看到同一服务类型的多个实例的注册以及如何访问它们。

注册多个实例

对于给定的接口,我们可以使用 IoC 容器注册多个实现。

注意

如果为同一服务类型注册了多个实现,则最后一个注册将优先于所有之前的注册。

考虑以下服务注册,其中IWeatherForecastService服务注册了两个实现——WeatherForecastServiceWeatherForecastServiceV2

services.AddScoped<IWeatherForecastService, WeatherForecastService>();
services.AddScoped<IWeatherForecastService, WeatherForecastServiceV2>();

现在,当从控制器请求IWeatherForecastService的实例时,将提供WeatherForecastServiceV2的实例,如下面的代码片段所示:

private readonly IWeatherForecastService weatherForecastService;
public WeatherForecastController(ILogger<WeatherForecastController> logger, IWeatherForecastService weatherForecastService)
{
      _logger = logger;
      this.weatherForecastService = weatherForecastService;
}

在前面的示例中,可能看起来WeatherForecastV2的注册覆盖了WeatherForecastService的先前注册。然而,ASP.NET Core 6 的 IoC 容器将包含所有IWeatherForecastService的注册。要获取所有注册,请按如下方式获取服务作为IEnumerable

private readonly IEnumerable<IWeatherForecastService> weatherForecastServices;
public WeatherForecastController(
ILogger<WeatherForecastController> logger, IEnumerable<IWeatherForecastService> weatherForecastServices)
{
   _logger = logger;
   this.weatherForecastServices = weatherForecastServices;
}

这在执行WebApplicationBuilderServices属性等场景中可能很有用。因此,在未来,当添加或删除现有规则时,更改将仅限于向Services属性注入新的服务。

使用 TryAdd

在本节中,我们将了解如何避免意外覆盖已注册的服务。

TryAdd扩展方法仅在不存在相同服务的注册时注册服务。TryAdd扩展方法对所有生命周期范围(TryAddScopedTryAddSingletonTryAddTransient)都可用。

如以下代码片段所示,使用服务注册时,当请求IWeatherForecastService时,IoC 容器提供WeatherForecastService,而不是WeatherForecastServiceV2

services.AddScoped<IWeatherForecastService, WeatherForecastService>();
services.TryAddScoped<IWeatherForecastService, WeatherForecastServiceV2>();

为了克服可能因重复注册而产生的副作用,始终建议使用TryAdd扩展方法来注册服务。

现在,让我们看看如何替换已注册的服务。

替换现有注册

ASP.NET Core 6 IoC 容器提供了一种替换现有注册的方法。在下面的示例中,IWeatherForecastService 首先使用 WeatherForecastService 进行注册。然后它被替换为 WeatherForecastServiceV2

builder.Services.TryAddScoped<IWeatherForecastService, WeatherForecastService>();
builder.Services.Replace(ServiceDescriptor.Scoped<IWeatherForecastService, WeatherForecastServiceV2>());

WeatherForecastServiceV2Replace 实例一样,一个实现被提供给 WeatherForecastController 构造函数。在下面的代码片段中,与 注册多个实例 部分不同,我们将在 weatherForecastService 构造函数变量中只看到一个对象:

public WeatherForecastController(ILogger<WeatherForecastController> logger, IEnumerable<IWeatherForecastService> weatherForecastService)
{
      _logger = logger;
      this.weatherForecastService = weatherForecastService;
}

到目前为止,在本节中,我们已经学习了如何使用 IoC 容器注册和替换服务。有时我们可能需要删除当前的注册。考虑这样一个场景,你希望利用库中的服务和注册,但你没有访问其源代码的权限。如果你重新实现了该库的一些接口并将它们重新注册到容器中,你可能会看到一些意外的行为。在下一节中,我们将了解如何删除已注册的服务。

删除现有注册

要删除现有注册,ASP.Net Core 6 IoC 容器提供了 Remove 扩展方法。你可以使用 RemoveAll 方法删除与某个服务相关的所有注册,如下面的代码片段所示:

services.RemoveAll<IWeatherForecastService>();

在下面的代码片段中,Remove 方法从容器中移除了 WeatherForecastService 实现的注册:

//Removes the first registration of IWeatherForecastService           
Builder.Services.Remove(ServiceDescriptor.Scoped<IWeatherForecastService, WeatherForecastService>());

到目前为止,我们已经看到了如何处理复杂的服务,但当涉及到泛型开放类型时,注册每个构造的泛型类型将会变得困难。在下一节中,我们将学习如何处理泛型开放类型服务。

注意

要了解更多关于泛型类型的信息,你可以参考以下网站:docs.microsoft.com/en-us/dotnet/csharp/language-reference/language-specification/types

注册泛型

本节将介绍如何使用依赖注入处理泛型类型服务。

对于泛型类型,为每种正在使用的实现类型注册服务是没有意义的。ASP.NET Core 6 IoC 容器提供了一种简化泛型类型注册的方法。框架本身已经提供的一个此类类型是 ILogger,如下面的代码片段所示:

Builder.Services.TryAdd(ServiceDescriptor.Singleton(typeof(ILogger<>), typeof(Logger<>))); 

注意

为了方便参考,你可以访问以下链接:github.com/dotnet/runtime/blob/main/src/libraries/Microsoft.Extensions.Logging/src/LoggingBuilderExtensions.cs

泛型的一个用例是与数据访问层一起使用的泛型仓储模式。

在我们拥有的所有注册信息中,ConfigureServices 方法可能会变得很大,以至于不再可读。下一节将帮助你了解如何解决这个问题。

代码可读性的扩展方法

ASP.NET Core 6 框架遵循的使代码看起来更易读的模式是创建一个具有逻辑分组的服务注册扩展方法。以下代码尝试使用扩展方法对通知相关服务进行分组和注册。一般做法是使用 Microsoft.Extensions.DependencyInjection 命名空间来定义服务注册扩展方法。这将使开发者只需使用 Microsoft.Extensions.DependencyInjection 命名空间就能使用所有与依赖注入相关的功能。

在下面的代码片段中,使用 AddNotificationServices 注册了与通知相关的服务:

namespace Microsoft.Extensions.DependencyInjection
{
public static class NotificationServicesServiceCollectionExtension
{
   public static IServiceCollection AddNotificationServices(this IServiceCollection services)
  {
       services.TryAddScoped<INotificationService, EmailNotificationService>();
       services.TryAddScoped<INotificationService, SMSNotificationService>();
        return services;
   }
}

现在扩展方法已经创建,我们可以使用 AddNotificationServices 方法在 ConfigureServices 下注册通知服务。这将使 ConfigureServices 更易于阅读。代码如下所示:

builder.Services.AddNotificationServices();

我们已经看到了如何将服务注入到控制器和其他类中。在下一节中,我们将学习如何将服务注入到视图中。

Razor Pages 中的依赖注入

MVC 中视图的目的是显示数据。大多数情况下,在视图中显示的数据是从控制器传递过来的。考虑到 关注点分离SoC)原则,建议从控制器传递所有必要的数据,但可能存在我们想要从诸如本地化和遥测服务之类的页面查看特定服务的情况。使用由 Razor 视图支持的依赖注入,我们可以将这些服务注入到视图中。

要了解如何将服务注入到视图中,让我们修改在前几章中创建的 DISampleWeb 应用程序。我们将修改 DISampleWeb 应用程序,以便在设置飞行标志的情况下在主页上显示额外内容。将 isFlightOn 配置添加到 appsettings.json 中,如下面的代码片段所示:

{
  "AllowedHosts": "*",
  "isFlightOn": "true"
}

现在,修改 Home 下的索引视图以显示 Flight 下的内容,如下面的代码片段所示:

@using Microsoft.Extensions.Configuration
@inject Iconfiguration Configuration
@{
   string isFlightOn = Configuration["isFlightOn"];
   if (string.Equals(isFlightOn, "true", StringComparison.OrdinalIgnoreCase))
   {
       <h1>
        <strong>Flight content</strong>
       </h1>
   }
}

在这里,提供读取配置文件功能的 IConfiguration 服务通过 @inject 关键字注入到 Razor 视图中。注入的配置服务用于获取配置并基于设置显示额外内容。我们可以使用 @inject 关键字将任何已注册到 IServiceCollection 的服务注入到 Razor 视图中。

到目前为止,我们已经看到了如何利用 .NET 6 内置的 IoC 容器。在下一节中,我们将学习如何利用第三方容器。

使用第三方容器

虽然内置容器对于大多数场景来说已经足够,但 .NET 6 提供了一种与第三方容器集成的途径,如果需要的话可以加以利用。

让我们更详细地看看框架是如何连接服务的。当 Startup 类在 Program.cs 中与 HostBuilder 注册时,.NET 框架使用反射来识别并调用 ConfigureConfigureServices 方法。

下面是从 ASP.NET Core 6 的StartupLoader类的LoadMethods方法中摘录的一段代码(请参阅github.com/dotnet/aspnetcore/blob/main/src/Hosting/Hosting/src/Internal/StartupLoader.cs中的代码):

public static StartupMethods LoadMethods(IServiceProvider hostingServiceProvider, [DynamicallyAccessedMembers(StartupLinkerOptions.Accessibility)] Type startupType, string environmentName, object? instance = null)
{ 
    var configureMethod = FindConfigureDelegate(startupType, environmentName);
    var servicesMethod = FindConfigureServicesDelegate(startupType, environmentName);
    var configureContainerMethod = FindConfigureContainerDelegate(startupType, environmentName);
     -----------------------
}

从前面的代码片段中,我们可以看到前两个方法,FindConfigureDelegateFindConfigureServicesDelegate,是为了找到ConfigureConfigureServices方法。

最后一行是用于ConfigureContainer的。我们可以在Startup类中定义一个ConfigureContainer方法来配置服务到第三方容器中。

下面是可用于 ASP.NET Core 6 的一些流行的 DI 框架:

虽然这些框架之间存在一些差异,但通常功能是相同的。大多数情况下,开发者的体验决定了框架的选择。

在下一节中,我们将看看如何利用 Autofac 第三方 IoC 容器。

Autofac IoC 容器

Autofac 是开发者社区中最受欢迎的 IoC 容器之一。与其他任何 IoC 容器一样,它管理类之间的依赖关系,以便随着应用程序复杂性和规模的增加,应用程序仍然易于更改。让我们学习如何使用 Autofac 注册我们在本章前面使用过的相同WeatherProvider服务。按照以下步骤进行:

  1. 使用 ASP.NET Core Web API 模板创建一个新的项目,并将其命名为AutofacSample

  2. Autofac.Extensions.DependencyInjection NuGet 包引用添加到AutofacSample项目中,如图所示:

Figure 5.5 – 添加 Autofac.Extensions.DependencyInjection NuGet 包

Figure 5.5 – 添加 Autofac.Extensions.DependencyInjection NuGet 包

  1. 我们需要将AutofacServiceProviderFactoryConfigureHostBuilder注册,以便运行时使用 Autofac IoC 容器。在Program.cs中,注册 Autofac SP 工厂,如下面的代码片段所示:

    builder.Host.UseServiceProviderFactory(new AutofacServiceProviderFactory());
    
  2. 现在,让我们将我们在DI 类型部分使用的IWeatherProvider服务注册到 Autofac 容器中。在Program.cs中,通过WebApplicationBuilderConfigureHostBuilder属性的ConfigureContainer方法将IWeatherProviderWeatherProvider实现注册,如下所示:

    builder.Host.ConfigureContainer<ContainerBuilder>(builder =>
    {
        builder.RegisterType<WeatherProvider>()
                        .As<IWeatherProvider>();
    });
    
  3. 与默认.NET IoC 容器类似,我们将IWeatherForecast服务注入到WeatherForecastController控制器中,如下面的代码片段所示:

    public class WeatherForecastController : ControllerBase
    {
            private readonly ILogger<WeatherForecastController> _logger;
            private readonly IWeatherProvider weatherProvider;
            public WeatherForecastController( ILogger<WeatherForecastController> logger,
    IWeatherProvider weatherProvider)
            {
                _logger = logger;
                this.weatherProvider = weatherProvider;
            }
            [HttpGet]
            public IEnumerable<WeatherForecast> Get()
            {
                return weatherProvider.GetForecast();
            }
    }
    

现在,当你运行项目并导航到https://localhost:7184/WeatherForecast 统一资源标识符 (URI)时,你将在浏览器中看到以下输出:

![图 5.6 – 容器的最终输出图片

图 5.6 – 容器的最终输出

在上一个示例中,我们看到了使用第三方 Autofac IoC 容器代替.NET 6 提供的默认容器。

摘要

本章向您介绍了 DI 的概念,这有助于编写松散耦合、更易于测试和更易于阅读的代码。本章涵盖了 DI 的类型以及它们如何在 ASP.NET Core 6 中得到支持。我们还看到了如何使用不同类型的注册来管理对象的生命周期。本章还向您介绍了一些流行的第三方 IoC 容器,以进一步探索。我们将使用本章学到的概念来构建我们的电子商务应用程序。在第十五章测试中,我们还将看到 DI 如何帮助提高可测试性。

第一章中建议的,在关注点分离/单一责任架构部分,我们总是尝试通过接口注册服务。这将有助于在任何时候更改具体实现,而不会更改客户端实现。

在下一章中,我们将学习如何配置.NET 6 并了解不同的配置,同时学习如何构建自定义配置。

问题

  1. 以下哪个不是框架服务?

a. IConfiguration

b. IApplicationBuilder

c. IWeatherService

d. IWebHostEnvironment

答案:c

  1. 真或假:DI 是实现 IoC 的一种机制。

a. 真的

b. 假的

答案:a

  1. 真或假:注入的服务可以依赖于生命周期比其短的服务。

a. 真的

b. 假的

答案:b

  1. 以下哪个不是 ASP.NET Core 6 IoC 容器的有效生命周期范围?

a. 作用域

b. 单例

c. 持久

d. 动态

答案:d

第六章:第六章:.NET 6 中的配置

.NET 6 中的配置包括默认设置以及应用程序的运行时设置;配置是一个非常强大的功能。我们可以更新设置,如功能标志以启用或禁用功能、依赖服务端点、数据库连接字符串、日志级别等,并在不重新编译的情况下控制应用程序的运行时行为。

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

  • 理解配置

  • 利用内置配置提供程序

  • 构建自定义配置提供程序

到本章结束时,你将很好地掌握配置概念、配置提供程序以及如何在项目中利用它们,以及能够识别适合你应用程序的配置和配置源。

技术要求

你需要对 .NET 和 Azure 有基本的了解。本章的代码可以在以下位置找到:https://github.com/PacktPublishing/Enterprise-Application-Development-with-C-10-and-.NET-6-Second-Edition/tree/main/Chapter06。

理解配置

配置通常存储为 Program.cs(),你将获得 .NET 6 提供的默认配置。此外,你可以配置不同的内置和自定义配置源,并在需要时使用不同的配置提供程序读取它们:

图 6.1 – 应用和配置

图 6.1 – 应用和配置

上述图示显示了应用程序、配置提供程序和配置文件之间的高级关系。应用程序使用配置提供程序从配置源读取配置;配置可以是环境特定的。Env A 可能是你的开发环境,而 Env B 可能是你的生产环境。在运行时,应用程序将根据其运行时的上下文和环境读取正确的配置。

在下一节中,我们将了解默认配置的工作原理以及如何从 appsettings.json 文件中添加和读取配置。

默认配置

要了解默认配置的工作原理,让我们创建一个新的 .NET 6 Web API,将项目名称设置为 TestConfiguration,并打开 Program.cs 文件。以下是从 Program.cs 文件中的代码片段:

var builder = WebApplication.CreateBuilder(args);
// Add services to the container.
builder.Services.AddControllers();
builder.Services.AddEndpointsApiExplorer();
builder.Services.AddSwaggerGen();
var app = builder.Build();
// Configure the HTTP request pipeline.
if (app.Environment.IsDevelopment())
{
    app.UseSwagger();
    app.UseSwaggerUI();
}
app.UseHttpsRedirection();
app.UseAuthorization();
app.MapControllers();
app.Run();

从前面的代码中,我们看到 WebApplication.CreateBuilder 负责为应用程序提供默认配置。

配置的加载顺序如下:

  1. MemoryConfigurationProvider:此提供程序从内存集合中加载配置,作为配置键值对。

  2. ChainedConfigurationProvider: 这添加主机配置并将其设置为第一个来源。有关主机配置的更多详细信息,您可以参考此链接:docs.microsoft.com/en-us/aspnet/core/fundamentals/configuration/?view=aspnetcore-6.0

  3. JsonConfigurationProvider: 这将从 appsettings.json 文件中加载配置。

  4. JsonConfigurationProvider: 这将从 appsettings.Environment.json 文件中加载配置;在 appsettings.Environment.json 中的 Environment 可以设置为开发、测试或生产。

  5. EnvironmentVariablesConfigurationProvider: 这将加载环境变量配置。

如本节开头所述,配置在来源中指定为键值对。后来添加的配置提供程序(按顺序)覆盖了之前的键值对设置。例如,如果您在 MemoryConfigurationProviderJsonConfigurationProvider 中都有 DbConnectionString 键,则 JsonConfigurationProvider 中的 DbConnectionString 键的值将覆盖 MemoryConfigurationProvider 的键值对设置。

当您调试 Program.cs 代码时,您可以看到由 CreateDefaultBuilder 提供的默认配置被注入到配置中,如下所示:

图 6.2 – 默认配置来源

图 6.2 – 默认配置来源

在下一节中,我们将看看如何添加我们应用程序所需的配置。

添加配置

如前一小节所示,有多种配置来源可用。在现实世界的项目中,appsettings.json 文件是最广泛使用的,用于添加应用程序所需的配置,除非它是秘密信息,不能以纯文本形式存储。

让我们看看在需要配置的几个常见场景:

  • 如果我们需要一个 ApplicationInsights 仪表化密钥来添加应用程序遥测,这可以是配置的一部分

  • 如果我们有需要调用的依赖服务,这些服务可以是配置的一部分

这些配置可能因环境而异(开发环境和生产环境中的值不同)。

您可以将以下配置添加到 appsettings.json 文件中,以便在发生更改时直接更新它,并在无需重新编译和部署的情况下开始使用它:

"ApplicationInsights": {
    "InstrumentationKey": "<Your instrumentation key>"
  }
"ApiConfigs": {
    "Service 1": {
      "Name": "<Your dependent service name 1>",
      "BaseUri": "<Service base uri>",
      "HttpTimeOutInSeconds": "<Time out value in
        seconds>",      
      "ApiURLs": [
        {
          "EndpointName": "<End point 1>"
        },
        {
          "EndpointName": "<End point 2>"
        }
      ]
    },
    "Service 2": {
      "Name": "<Your dependent service name 2>",
      "BaseUri": "<Service base uri>",
      "HttpTimeOutInSeconds": "<Time out value in
       seconds>",      
      "ApiURLs": [
        {
          "EndpointName": "<End point 1>"
        },
        {
          "EndpointName": "<End point 2>"
        }
      ]
    }
}

从前面的代码中,我们看到我们为 ApplicationInsights 仪表化密钥添加了一个键值对,其中键是 InstrumentationKey 字符串,值是应用程序需要用于在 ApplicationInsights 中仪表化遥测的实际仪表化密钥。在 ApiConfigs 部分,我们以分层顺序添加了多个键值对,包括调用我们的依赖服务所需的配置。

在下一节中,我们将看到如何读取我们已添加的配置。

读取配置

我们已经看到了如何将配置添加到 appsettings.json。在本节中,我们将看到如何使用不同的选项在我们的项目中读取它们。

Program.cs 中由 WebApplication.CreateBuilder 提供的 builder.Configuration 对象实现了 Microsoft.Extensions.Configuration.IConfiguration 类型,您有以下选项可用于读取 IConfiguration

         // Summary:
        //     Gets or sets a configuration value.
        // Parameters:
        //   key:
        //     The configuration key.
        // Returns:
        //     The configuration value.
        string this[string key] { get; set; }
        // Summary:
        //Gets the immediate descendant configuration sub-
        //sections.
        // Returns:
        //     The configuration sub-sections.
        IEnumerable<IConfigurationSection> GetChildren();
        // Summary:
        //     Gets a configuration sub-section with the 
        //     specified key.
        // Parameters:
        //   key:
        //     The key of the configuration section.
        // Returns:
        //The Microsoft.Extensions.Configuration
        //.IConfigurationSection.
        // Remarks:
        //     This method will never return null. If 
         // no matching sub-section is found with
        //     the specified key, an empty
        //Microsoft.Extensions.Configuration.IConfiguration
        //     Section will be returned.
        IConfigurationSection GetSection(string key);

让我们看看如何利用 Iconfiguration 中的这些选项来读取我们在上一节中添加的配置,添加配置

要从 appsettings.json 中读取 ApplicationInsights 仪表化密钥,我们可以在 Program.cs 中使用以下代码使用 string this[string key] { get; set; } 选项:

builder.Configuration["ApplicationInsights:InstrumentationKey"];

要读取 ApiConfigs,我们可以使用以下代码。我们可以使用分隔符在配置键中读取层次化配置:

builder.Configuration["ApiConfigs:Service 1:Name"];

注意

使用分隔符这种方式读取容易出错且难以维护。首选的方法是使用 ASP.NET Core 提供的 options 模式。而不是逐个读取每个键/设置值,选项模式使用类,这也会为您提供对相关设置的强类型访问。

当配置设置通过场景隔离到强类型类中时,应用程序遵循两个重要的设计原则:

  • 接口隔离原则ISP)或封装原则

  • 关注点分离

使用 ISP 或封装,您通过一个定义良好的接口或契约读取配置,并且只依赖于您需要的配置设置。此外,如果有一个非常大的配置文件,这将有助于关注点分离,因为应用程序的不同部分不会依赖于相同的配置,从而允许它们解耦。让我们看看我们如何利用代码中的选项模式。

您可以创建以下 ApiConfigApiUrl 类并将它们添加到您的项目中:

public class ApiConfig
{      
    public string Name { get; set; }
    public string BaseUri { get; set; }
    public int HttpTimeOutInSeconds { get; set; }
    public List<ApiUrl> ApiUrls { get; set; }
}
public class ApiUrl
{        
    public string EndpointName { get; set; }      
}

Program.cs 中添加以下代码以使用 GetSection 方法读取配置,然后调用 Bind 以将配置绑定到我们已有的强类型类:

List<ApiConfig> apiConfigs = new List<ApiConfig>();
builder.Configuration.GetSection("ApiConfigs").Bind(apiConfigs);

GetSection 将会读取 appsettings.json 中指定的特定部分。Bind 将尝试通过匹配属性名到配置键来将给定的对象实例绑定到配置值。GetSection(string sectionName) 如果请求的部分不存在,将返回 null。在实际的程序中,请确保您添加了空值检查。

在本节中,我们看到了如何通过使用配置 API 添加和读取 appsettings.json 中的数据。我还提到我们应该使用 appsettings.json 用于纯文本,而不是用于机密。在下一节中,我们将探讨内置配置提供程序以及如何使用 Azure Key Vault 配置提供程序添加和读取机密。

利用内置配置提供程序

除了 appsettings.json 之外,还有多个配置源可用,.NET 6 提供了多个内置配置提供程序来读取它们。以下是为 .NET 6 可用的内置提供程序:

  • Azure Key Vault 配置提供程序从 Azure Key Vault 中读取配置。

  • 文件配置提供程序从 INI、JSON 和 XML 文件中读取配置。

  • 命令行配置提供程序从命令行参数中读取配置。

  • 环境变量配置提供程序从环境变量中读取配置。

  • 内存配置提供程序从内存集合中读取配置。

  • Azure 应用配置提供程序从 Azure 应用配置中读取配置。

  • 按文件密钥配置提供程序从目录的文件中读取配置。

让我们看看如何利用 Azure Key Vault 配置提供程序和文件配置提供程序,因为与其它提供程序相比,它们更为重要且更广泛地被使用。

注意

您可以使用以下链接了解我们在此处未详细介绍的其它配置提供程序:https://docs.microsoft.com/en-us/dotnet/core/extensions/configuration-providers。

Azure Key Vault 配置提供程序

Azure Key Vault 是一种基于云的服务,它提供了一个集中的配置源,用于安全地存储密码、证书、API 密钥和其他机密。这有助于确保我们的应用程序安全并符合安全漏洞的合规性。让我们看看如何创建一个密钥库,向其中添加一个机密,并使用 Azure Key Vault 配置提供程序从应用程序中访问它。

创建密钥库并添加机密

在本节中,我们将使用 Azure Cloud Shell 创建一个密钥库并添加一个机密。Azure Cloud Shell 是基于浏览器的,可以用来管理 Azure 资源。以下是需要您采取的步骤列表:

  1. 使用 portal.azure.com 登录到 Azure 门户。在门户页面上选择云 Shell 图标:

图 6.3 – Azure 云 Shell

图 6.3 – Azure 云 Shell

  1. 您将获得选择 BashPowerShell 的选项。选择 PowerShell。您可以在任何时候更改外壳:

图 6.4 – Azure 云 Shell 选项 – PowerShell 和 Bash

图 6.4 – Azure 云 Shell 选项 – PowerShell 和 Bash

  1. 使用以下命令创建一个资源组:

    az group create --name "{RESOURCE GROUP NAME}" --location {LOCATION}
    

我为这个演示实际运行的命令如下:

az group create --name "ConfigurationDemoVaultRG" --location "East US"

{RESOURCE GROUP NAME} 代表新资源组的资源组名称,而{LOCATION}代表 Azure 区域(数据中心)。

  1. 使用以下命令在资源组中创建密钥保管库:

    az keyvault create --name {KEY VAULT NAME} --resource-group "{RESOURCE GROUP NAME}" --location {LOCATION}
    

这里是我在此演示中实际运行的命令:

az keyvault create --name "TestKeyVaultForConfig" --resource-group "ConfigurationDemoVaultRG" --location "East US"

{KEY VAULT NAME} 是新密钥保管库的唯一名称。

{RESOURCE GROUP NAME} 是在上一步骤中创建的新资源组的资源组名称。

{LOCATION} 是 Azure 区域(数据中心)。

  1. 使用以下命令在密钥保管库中以名称-值对的形式创建密钥:

    az keyvault secret set --vault-name {KEY VAULT NAME} --name "SecretName" --value "SecretValue"
    

这里是我在此演示中实际运行的命令:

az keyvault secret set --vault-name "TestKeyVaultForConfig" --name "TestKey" --value "TestValue"

{KEY VAULT NAME} 与您在上一步骤中创建的密钥保管库名称相同。

SecretName 是您的密钥名称。

SecretValue 是您的密钥值。

我们现在已成功创建了一个名为TestKeyVaultForConfig的密钥保管库,并使用 Azure Cloud Shell 添加了一个密钥为TestKey、值为TestValue的密钥:

![图 6.5 – Azure 密钥保管库密钥图 6.5 – 图 6.5_B18507.jpg

图 6.5 – Azure 密钥保管库密钥

您也可以使用 Azure 命令行界面CLI)创建和管理 Azure 资源。您可以在以下位置了解更多关于 Azure CLI 的信息:https://docs.microsoft.com/en-us/cli/azure/?view=azure-cli-latest。

在下一节中,我们将看到如何让我们的应用程序访问密钥保管库。

授予应用程序访问密钥保管库的权限

在本节中,让我们看看我们的TestConfiguration Web API 如何通过以下步骤访问密钥保管库:

  1. Azure Active DirectoryAAD)中注册TestConfiguration应用程序并创建一个身份。使用 https://portal.azure.com 登录 Azure 门户。

  2. 导航到Azure Active Directory | 应用程序注册。点击新建注册

![图 6.6 – AAD 新应用程序注册图 6.6 – 图 6.6_B18507.jpg

图 6.6 – AAD 新应用程序注册

  1. 填写默认值并点击注册,如图下所示截图,并记下应用程序(客户端)ID值。稍后访问密钥保管库时需要使用此值:

![图 6.7 – AAD 注册完成图 6.7 – 图 6.7_B18507.jpg

图 6.7 – AAD 注册完成

  1. 点击证书和密钥1) | 新建客户端密钥2),输入描述3)值,然后点击添加4),如图下所示截图。记下在新客户端密钥下显示的AppClientSecret值,这是应用程序在请求令牌时用来证明其身份的:

![图 6.8 – 为其身份创建 AAD 新应用程序密钥图 6.8 – 图 6.8_B18507.jpg

图 6.8 – 为其身份创建 AAD 新应用程序密钥

  1. 使用访问策略授予应用程序访问密钥保管库的权限。搜索您刚刚创建的密钥保管库并选择它:

![图 6.9 – 密钥保管库搜索图 6.9 – 图 6.9_B18507.jpg

图 6.9 – 密钥保管库搜索

  1. 在密钥保管库属性中,选择 设置 下的 访问策略,然后点击 添加访问策略

![Figure 6.10 – Key Vault 访问策略

![img/Figure_6.10_B18507.jpg]

Figure 6.10 – Key Vault 访问策略

  1. 选择主体 字段中,搜索您的应用程序并选择您的应用程序访问 Key Vault 所需的权限,然后点击 添加

![Figure 6.11 – 添加访问策略

![img/Figure_6.11_B18507.jpg]

Figure 6.11 – 添加访问策略

  1. 添加策略后,您必须保存它。这将完成授予您的应用程序访问 Key Vault 的过程。

我们现在已授予我们的应用程序访问 Key Vault 的权限。在下一节中,我们将看到如何使用 Azure Key Vault 配置提供程序从我们的应用程序访问 Key Vault。

利用 Azure Key Vault 配置提供程序

在本节中,我们将对我们的应用程序进行配置和代码更改,以利用 Azure Key Vault 配置提供程序并从 Key Vault 获取密钥,如下所示:

![Figure 6.12 – 开发期间访问 Key Vault

![img/Figure_6.12_B18507.jpg]

Figure 6.12 – 开发期间访问 Key Vault

以下是要更改的列表:

  1. 将密钥保管库名称、从 Figure 6.7 记下的 AppClientId 值和从 AAD 的 Figure 6.8 记下的 AppClientSecret 值添加到您的 TestConfiguration Web API 的 appsettings.json 文件中:

![Figure 6.13 – appsettings.json 中的 Key Vault 部分

![img/Figure_6.13_B18507.jpg]

Figure 6.13 – appsettings.json 中的 Key Vault 部分

  1. 安装以下 NuGet 包:

    • Microsoft.Azure.KeyVault

    • Microsoft.Extensions.Configuration.AzureKeyVault

    • Microsoft.Azure.Services.AppAuthentication

  2. Program.cs 更新为利用 Azure Key Vault 配置提供程序来使用您的密钥保管库。以下代码将 Azure Key Vault 添加为另一个配置源,并使用 Azure Key Vault 配置提供程序获取所有配置:

    using TestConfiguration;
    var builder = WebApplication.CreateBuilder(args);
    // Add services to the container.
    builder.Services.AddControllers();
    //Removed code for brevity
    builder.Configuration.AddAzureKeyVault($"https://{builder.Configuration["KeyVault:Name"]}.vault.azure.net/",
    builder.Configuration["KeyVault:AppClientId"],
    builder.Configuration["KeyVault:AppClientSecret"]);
    var app = builder.Build();
    //Removed code for brevity 
    
  3. WeatherForecastController.cs 更新为从 Key Vault 读取密钥,如下所示:

    [ApiController]
    [Route("[controller]")]
    public class WeatherForecastController : ControllerBase
    {
        private readonly ILogger<WeatherForecastController> _logger;
        private readonly IConfiguration _configuration;
        public WeatherForecastController(ILogger<Weather
    ForecastController> logger, IConfiguration configuration)
        {
            _logger = logger;
            _configuration = configuration;
        }  
        [HttpGet]
        public IEnumerable<string> Get()
        {
            return new string[] { "TestKey", 
             _configuration["TestKey"] };
        }      
    }
    

按照此处共享的代码示例包含所有引用。您可以运行应用程序并查看结果:

![Figure 6.14 – Key Vault 结果

![img/Figure_6.14_B18507.jpg]

Figure 6.14 – Key Vault 结果

应用程序将能够使用 Azure Key Vault 配置提供程序访问密钥保管库并获取密钥。这非常简单,因为所有繁重的工作都由 .NET 6 完成,我们只需要安装 NuGet 包并添加几行代码。然而,你现在可能正在思考 AppClientIdAppClientSecret 是如何添加到 appsettings.json 配置文件中,以及这种方式并不十分安全。你完全正确。

我们可以通过两种方式解决这个问题:

您的应用程序可以使用其身份通过支持 AAD 认证的服务进行身份验证,例如 Azure Key Vault,这将帮助我们从代码中移除凭证:

图 6.15 – 应用程序部署后生产环境中访问密钥保管库

图 6.15 – 应用程序部署后生产环境中访问密钥保管库

注意

这是我们将应用于已部署到生产环境的应用程序的最佳实践。在代码中管理凭证是一个常见的挑战,保持凭证的安全和保密是一个重要的安全要求。Azure 资源在 Azure Active Directory (AAD) 中的托管身份有助于解决这个挑战。托管身份为 Azure 服务提供在 AAD 中自动管理的身份。您可以使用此身份对支持 AAD 认证的任何服务进行身份验证,包括密钥保管库,而无需在您的代码中包含任何凭证。

您可以在此处了解更多关于托管身份的信息:https://docs.microsoft.com/en-us/azure/active-directory/managed-identities-azure-resources/overview。

在本节中,我们学习了如何创建密钥保管库,如何将机密添加到密钥保管库,如何将我们的TestConfiguration Web API 注册到 Azure Active Directory (AAD),如何创建机密或身份,如何让TestConfiguration Web API 获取对密钥保管库的访问权限,以及如何使用 Azure Key Vault 配置提供程序从我们的代码中访问密钥保管库。您还可以通过使用 Visual Studio 连接服务将密钥保管库添加到您的 Web 应用程序中,具体操作请参阅 https://docs.microsoft.com/en-us/azure/key-vault/general/vs-key-vault-add-connected-service:

图 6.16 – Azure Key Vault 作为连接服务

图 6.16 – Azure Key Vault 作为连接服务

在下一节中,我们将了解如何利用文件配置提供程序。

文件配置提供程序

文件配置提供程序帮助我们从文件系统中加载配置。JSON 配置提供程序和 XML 配置提供程序分别从文件配置提供程序类继承,用于从 JSON 文件和 XML 文件中读取键值对配置。让我们看看如何将它们作为CreateHostBuilder的一部分添加到配置源中。

JSON 配置提供程序

可以使用以下代码在Program.cs中配置 JSON 配置提供程序:

//Removed code for brevity
builder.Configuration.AddJsonFile("AdditionalConfig.json",
                optional: true,
                reloadOnChange: true); 
//Removed code for brevity               

在这种情况下,JSON 配置提供者将加载 AdditionalConfig.json 文件,AddJsonFile 方法的三个参数为我们提供了指定文件名、文件是否可选以及文件在文件被修改时是否必须重新加载的选项。

以下是一个 AdditionalConfig.json 示例文件:

{  "TestKeyFromAdditionalConfigJSON":"TestValueFromAdditional ConfigJSON"}

然后,我们将更新 WeatherForecastController.cs 以从加载自 AdditionalConfig.json 配置文件的配置中读取键值对,如下所示:

//Removed code for brevity  
    [HttpGet]
    public Ienumerable<string> Get()
    {
        return new string[] { 
" TestKeyFromAdditionalConfigJSON", 
          _configuration["TestKeyFromAdditionalConfigJSON"] };
    }      
//Removed code for brevity

您可以运行应用程序并查看结果。应用程序将能够访问 AdditionalConfig.json 文件并读取配置。在下一节中,我们将探讨 XML 配置提供者。

XML 配置提供者

我们将在项目中添加一个名为 AdditionalXMLConfig.xml 的新文件和所需的配置。然后,可以使用以下代码在 Program.cs 中配置 XML 配置提供者,以从我们添加的文件中读取:

//Removed code for brevity
builder.Configuration.AddXmlFile("AdditionalXMLConfig.xml",
                optional: true,
                reloadOnChange: true);
//Removed code for brevity

在这种情况下,XML 配置提供者将加载 AdditionalXMLConfig.xml 文件,三个参数为我们提供了指定 XML 文件、文件是否可选以及文件在发生任何更改时是否必须重新加载的选项。

以下是一个 AdditionalXMLConfig.xml 示例文件:

<?xml version="1.0" encoding="utf-8" ?>
<configuration>
  <TestKeyFromAdditionalXMLConfig>TestValueFrom
AdditionalXMLConfig</TestKeyFromAdditionalXMLConfig>
</configuration>

接下来,我们将更新 WeatherForecastController.cs 以从 AdditionalXMLConfig.xml 加载的配置中读取键值对,如下所示:

   [HttpGet]
    public Ienumerable<string> Get()
    {
        return new string[] { 
"TestKeyFromAdditionalXMLConfig", 
          _configuration["TestKeyFromAdditionalXMLConfig"] };
    }      

您可以运行应用程序并查看结果。应用程序将能够访问 AdditionalXMLConfig.xml 并读取配置。在 .NET 6 中,JSON 配置文件和 JSON 配置提供者可用,因此您不需要 XML 配置文件和 XML 配置提供者。话虽如此,我们刚才讨论的内容是针对那些喜欢 XML 文件和开闭标签的人来说的,例如。

在下一节中,我们将探讨为什么需要自定义配置提供者以及如何构建一个。

构建自定义配置提供者

在上一节中,我们讨论了 .NET 6 中的内置或现有配置提供者。存在一些场景,许多系统在数据库中维护应用程序配置设置。这些设置可以由管理员通过门户管理,或者由支持工程师通过运行数据库脚本来创建/更新/删除应用程序配置设置。

.NET 6 并没有内置的提供者来从数据库中读取配置。让我们看看如何通过以下步骤构建一个自定义配置提供者来从数据库中读取:

  • 实现配置源:为了创建配置提供者的实例

  • 实现配置提供者:为了从适当的源加载配置

  • 实现配置扩展:为了将配置源添加到配置构建器中

让我们从配置源开始。

配置源

配置源的责任是创建配置提供者的一个实例并将其返回到源。它需要继承自IConfigurationSource接口,这要求我们实现ConfigurationProvider Build(IConfigurationBuilder builder)方法。

Build方法实现中,我们需要创建自定义配置提供者的一个实例并返回它。还应该有构建构建器所需的参数。在这种情况下,因为我们正在构建自定义 SQL 配置提供者,所以重要的参数是连接字符串和 SQL 查询。以下代码片段展示了SqlConfigurationSource类的一个示例实现:

public class SqlConfigurationSource : IConfigurationSource
    {
        public string ConnectionString { get; set; }
        public string Query { get; set; }
        public SqlConfigurationSource(string
          connectionString, string query)
        {
            ConnectionString = connectionString;
            Query = query;
        }
        public IConfigurationProvider
         Build(IConfigurationBuilder builder)
        {
            return new SqlConfigurationProvider(this);
        }
    }  

如您所见,实现这个方法非常简单且容易。您获取构建提供者所需的参数,然后创建提供者的新实例,然后返回这些参数。让我们看看如何在下一节中构建一个 SQL 配置提供者。

配置提供者

配置提供者的责任是从适当的位置加载所需的配置并返回相同的配置。它需要继承自IConfigurationProvider接口,这要求我们实现Load()方法。配置提供者类可以继承自ConfigurationProvider基类,因为它已经实现了IConfigurationProvider接口中的所有方法。这将帮助我们节省时间,因为我们不需要实现未使用的方法,而只需实现Load方法即可。

Load方法实现中,我们需要有从源获取配置数据的逻辑。在这种情况下,我们将执行一个查询从 SQL 存储中获取数据。以下代码片段展示了SqlConfigurationProvider类的一个示例实现:

public class SqlConfigurationProvider : ConfigurationProvider
    {
        public SqlConfigurationSource Source { get; }
        public SqlConfigurationProvider
         (SqlConfigurationSource source)
        {
            Source = source;
        }
        public override void Load()
        {
            try
            {    
                // create a connection object  
                SqlConnection sqlConnection = new
                 SqlConnection(Source.ConnectionString);
                // Create a command object  
                SqlCommand sqlCommand = new
                 SqlCommand(Source.Query, sqlConnection);
                sqlConnection.Open();
                // Call ExecuteReader to return a 
                // DataReader  
                SqlDataReader salDataReader =
                 sqlCommand.ExecuteReader();
                while (salDataReader.Read())
                {
                    Data.Add(salDataReader.GetString(0),
                     salDataReader.GetString(1));
                }
                salDataReader.Close();
                sqlCommand.Dispose();
                sqlConnection.Close();
            }            
        }
    }

让我们看看如何在下一节中构建配置扩展。

配置扩展

与其他提供者一样,我们可以使用扩展方法将配置源添加到配置构建器中。

注意

扩展方法是静态方法,您可以在不修改或重新编译原始类的情况下向现有类中添加方法。

以下代码片段展示了在配置构建器中SqlConfigurationExtensions类的一个示例实现:

public static class SqlConfigurationExtensions
    {
        public static IConfigurationBuilder
         AddSql(this IConfigurationBuilder
         configuration, string connectionString,
         string query)
        {
            configuration.Add(new
             SqlConfigurationSource(connectionString,
             query));
            return configuration;
        }
    }

扩展方法将减少我们应用程序启动时的代码量。

我们可以在Program.cs中添加引导代码,就像我们为其他配置提供者添加的那样,如下所示:

builder.Configuration.AddSql("Connectionstring","Query"); 

以下截图显示了数据库中的一些示例配置设置。您可以在config.AddSql()中传递适当的连接字符串和 SQL 查询,并从数据库加载以下配置。SQL 查询可能是一个简单的select语句,用于读取所有键值对,就像以下截图所示:

![图 6.17 – 数据库配置设置图片

图 6.17 – 数据库配置设置

按照以下方式更新 WeatherForecastController.cs 以从 SQL 配置提供程序加载的配置中读取键值对:

    [HttpGet]
    public IEnumerable<string> Get()
    {
        return new string[] { "TestSqlKey", 
         _configuration["TestSqlKey"] };
    }      
}

你可以运行应用程序并查看结果。应用程序将能够访问 SQL 配置并读取配置。

这只是一个自定义配置提供程序的例子。你可能能够想到不同的场景,在这些场景中你会构建其他不同的自定义配置提供程序,例如从 CSV 文件中读取或从 JSON 或 XML 文件中读取加密值并解密它们。

摘要

在本章中,我们看到了 .NET 6 中配置的工作方式,如何向应用程序提供默认配置,如何以分层顺序添加键值对配置,如何读取配置,如何利用 Azure Key Vault 配置提供程序和文件配置提供程序,以及如何构建自定义配置提供程序以从 SQL 数据库中读取。你现在拥有了根据具体需求在项目中实现不同配置所需的知识。

在下一章中,我们将学习关于日志以及它在 .NET 6 中的工作方式。

问题

在阅读本章后,你应该能够回答以下问题:

  1. 在 .NET 6 中,负责提供应用程序默认配置的是什么?

a. CreateDefaultBuilder

b. ChainedConfigurationProvider

c. JsonConfigurationProvider

d. 所有上述选项

答案:a

  1. 以下哪个说法是不正确的?

a. Azure Key Vault 配置提供程序从 Azure Key Vault 读取配置。

b. 文件配置提供程序从 INI、JSON 和 XML 文件中读取配置。

c. 命令行配置提供程序从数据库中读取配置。

d. 内存配置提供程序从内存集合中读取配置。

答案:c

  1. 用于在运行时访问配置并通过依赖注入注入的接口是什么?

a. IConfig

b. IConfiguration

c. IConfigurationSource

d. IConfigurationProvider

答案:b

  1. 在生产中存储密钥推荐使用哪个提供程序/源?

a. 从 appsettings.json 中的 JSON

b. 从 XML 文件中的 FileConfiguration

c. 从 AzureKeyVaultAzureKeyVaultProvider

d. 从命令行来的命令行配置提供程序

答案:c

进一步阅读

第七章:第七章:.NET 6 中的日志记录

日志记录可以帮助你在运行时记录应用程序的不同数据的行为,你可以控制你想记录什么以及你想在哪里记录它。一旦你的功能开发完成,你可以在开发 PC 上彻底进行单元测试,然后在测试环境中进行彻底的集成测试,然后部署到生产环境中,最后对许多用户开放。与开发机器相比,你的应用程序运行的上下文(如服务器、数据和负载)在测试环境和生产环境中是不同的,你可能会在测试和生产环境的最初几天遇到意外问题。

在这里,日志记录在记录端到端流程中不同组件执行其功能并相互交互时的运行时发生的事情中起着非常重要的作用。有了日志信息,我们可以调试生产问题并构建非常有用的见解。我们将学习日志最佳实践和可用的不同日志提供程序,例如 Azure App Service 日志记录和 Application Insights 日志记录,并将构建一个可重复使用的日志库,该库可用于不同的项目。

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

  • 良好日志记录的特点

  • 理解可用的日志提供程序

  • 与 Azure App Service 一起工作

  • Application Insights 中的实时遥测

  • 创建.NET 6 日志类库

到本章结束时,你将对日志记录有一个很好的了解,以及一些可以在部署时应用的 Azure App Service 和 Application Insights 的平台级概念。

技术要求

需要具备对 Microsoft .NET 和 Azure 的基本了解。

本章的代码可以在以下位置找到:github.com/PacktPublishing/Enterprise-Application-Development-with-C-10-and-.NET-6-Second-Edition/tree/main/Chapter07.

良好日志记录的特点

日志记录已实现,但日志中的信息对构建见解或调试生产问题没有用。你见过多少次这个问题?

这就是最佳实践发挥作用的地方,在你的应用程序中实现良好的日志记录。良好日志记录的一些特点如下:

  • 它不应影响实际的应用程序性能。

  • 它应该是准确和完整的。

  • 它应该用于数据分析和学习应用程序的使用情况,例如并发用户、峰值负载时间和最/最少使用的功能。

  • 它应该帮助我们重现报告的问题,以进行根本原因分析,并最小化无法重现的实例。

  • 它应该是分布式的,并且易于每个人(开发人员、产品所有者和支持人员)访问。

  • 它不应包含受保护或敏感信息,个人身份信息PII),或重复或不必要的日志。

除了这些,它还应捕获以下关键信息的一些内容:

  • 关联 ID:用于在日志存储中搜索的问题的唯一标识符。

  • 日志级别:信息、警告和错误等。

  • 时间戳:日志条目的时间(始终使用一个同意的标准格式,例如 UTC 或服务器时间,不要混合使用)。

  • 消息:要记录的消息。这可能是一个信息或自定义错误消息,实际的异常消息,或自定义和实际错误消息的组合。

  • 机器/服务器/实例名称:负载均衡器中可能有多个服务器。这将帮助我们找到日志发生的服务器。

  • 程序集:日志发生的位置的程序集名称。

你想记录什么?这就是日志级别指导介入的地方:

表 7.1

表 7.1

日志级别是可配置的,并基于指定的级别;它将从指定的级别启用到所有更高级别。例如,如果您在配置中指定日志级别为信息,则所有来自信息警告错误致命的日志消息都将被记录,而调试跟踪消息将不会记录,如下表所示。如果没有指定日志级别,则默认为信息级别:

表 7.2

表 7.2

你想在何处记录?这就是日志提供程序介入的地方。让我们在下一节中看看它们。

理解可用的日志提供程序

.NET 6 支持多个内置日志提供程序以及几个第三方日志提供程序。这些提供程序公开的 API 帮助将日志输出写入不同的来源,例如提供程序支持的文件或事件日志。您的代码还可以启用多个提供程序,这在您从一个提供程序迁移到另一个提供程序时是一个非常常见的场景,您可以保留旧的,监控新的,一旦您满意,您就可以退役旧的提供程序。让我们详细讨论这两种类型的提供程序。

内置日志提供程序

所有内置日志提供程序都支持在 Microsoft.Extensions.Logging 命名空间中。让我们看看其中的一些:

表 7.3表 7.3

表 7.3

第三方日志提供程序

虽然 .NET 6 提供了几个强大的内置日志提供程序,但它也支持第三方日志提供程序。让我们来看看它们:

表 7.4

表 7.4

在简要了解了多个内置和第三方提供程序之后,让我们在下一节中深入探讨 Azure App Service 和 Application Insights。

使用 Azure App Service

基础设施即服务IaaS)托管模型中,您对机器上安装的操作系统和软件拥有完全控制权。这与我们许多人习惯的本地部署非常相似。您可以通过远程桌面访问服务器,查看 IIS 日志、Windows 事件查看器或文件。当您迁移到 平台即服务PaaS)托管模型时,Azure 会完全负责管理实例。这有助于节省大量时间,因为您的工程师不必花费时间管理服务器,以保持操作系统、基础设施和安全更新的最新状态。

在本节中,我们将了解如何在将应用程序部署到 Azure App Service 计划(Microsoft 的重要 PaaS 提供之一)时进行广泛的日志记录和监控。

在 Azure App Service 中启用应用程序日志记录

要启用应用程序日志记录,您需要执行以下步骤:

  1. 使用 dotnet add <.csproj>  package <Nuget package> -v <Version number> 命令在您的现有 .NET 6 项目中添加 AzureAppServices 包,如下所示:

![Figure 7.1 – Installing a package from the CLI

![img/Figure_7.1_B18507.jpg]

图 7.1 – 从 CLI 安装包

您可以从 docs.microsoft.com/en-us/dotnet/core/tools/dotnet 获取有关 .NET CLI 命令的更多详细信息。

您也可以右键单击 Microsoft.Extensions.Logging.AzureAppServices 包,并按以下截图所示进行安装:

![Figure 7.2 – 从 IDE 安装包

![img/Figure_7.2_B18507.jpg]

图 7.2 – 从 IDE 安装包

  1. 在您的 .NET 6 应用程序的 Program.cs 文件中,在 CreateHostBuilder 方法中添加以下突出显示的代码:

    //Removed code for brevity
    builder.Logging.AddAzureWebAppDiagnostics();
    //Removed code for brevity               
    

CreateHostBuilder 为我们正在开发的程序执行默认配置。让我们在这里添加一个日志配置,这也会动态注入 _logger 对象(对象创建使用 依赖注入DI)进行,如第五章.NET 6 中的依赖注入所述)。

  1. 添加日志记录:将以下突出显示的日志代码添加到任何控制器中的方法中,通常位于核心逻辑处,以测试日志记录是否正常工作:

    [ApiController]
    [Route("[controller]")]
    public class WeatherForecastController : ControllerBase
     {
            private static readonly string[] Summaries = new[]
            {
                "Freezing", "Bracing", "Chilly", "Cool", "Mild", "Warm", "Balmy", "Hot", "Sweltering", "Scorching"
            };
            private readonly ILogger<WeatherForecast Controller> _logger;
            public WeatherForecastController(ILogger<WeatherForecast Controller> logger)
            {
                _logger = logger;
            }
            [HttpGet]
            public IEnumerable<WeatherForecast> Get()
            {
                _logger.LogInformation("Logging Information for testing");
                _logger.LogWarning("Logging Warning for testing");
                _logger.LogError("Logging Error for testing");
                var rng = new Random();
                return Enumerable.Range(1, 5).Select(index => new WeatherForecast
                {
                    Date = DateTime.Now.AddDays(index),
                    TemperatureC = rng.Next(-20, 55),
                    Summary = Summaries[rng.Next(Summaries.Length)]
                })
                .ToArray();
            }
    }
    
  2. TestAppServiceForLoggingDemo。有关如何发布的更多信息,请参阅docs.microsoft.com/en-us/visualstudio/deployment/quickstart-deploy-to-azure?view=vs-2022。对于此示例,我们使用了基于 Windows 的 App Service 计划。

  3. 启用日志记录:转到 Azure 门户 | 您的订阅 | 资源组 | 应用服务(它在那里部署),然后在 监控 下选择 应用服务日志。您可以看到不同的日志选项,如下所示:

![Figure 7.3 – App Service 日志默认状态

![img/Figure_7.3_B18507.jpg]

图 7.3 – 应用服务日志默认状态

默认情况下,所有日志选项都已关闭,如前一个截图所示。让我们看看这些选项有哪些:

  • 应用程序日志(文件系统):将应用程序的日志消息写入 Web 服务器的本地文件系统。一旦您打开它,这将启用 12 小时,之后将自动禁用。因此,此选项用于临时调试目的。

  • 应用程序日志(Blob):将应用程序的日志消息写入 Blob 存储,以在配置的保留期内进行持久日志记录。Blob 中的日志记录用于长期调试目的。您需要一个 Blob 存储容器来写入日志。您可以在docs.microsoft.com/en-us/azure/storage/blobs/storage-blobs-introduction上了解更多关于 Blob 存储容器的信息。一旦您选择开启,您将获得创建新存储账户或搜索现有存储账户以写入日志的选项。点击+ 存储账户并指定一个名称以创建新账户,如下截图所示:

图 7.4 – 存储账户配置

图 7.4 – 存储账户配置

  • Web 服务器日志:IIS 日志记录在服务器上,提供诊断信息,如 HTTP 方法、资源 URI、客户端 IP、客户端端口、用户代理和响应代码。您可以将日志存储在 Blob 存储或文件系统中。在保留期(天数)中,您可以配置日志应保留的天数。

  • 服务器返回的400响应,这可以帮助您确定服务器为什么返回此错误。

    注意

    由于安全原因,在生产环境中,我们不向客户端发送详细的错误页面,但每当应用程序发生 HTTP 代码400或更高的错误时,应用服务都可以将此错误保存到文件系统中。

  • 失败的请求跟踪:关于失败请求的详细信息,包括 IIS 跟踪等。对于每个失败的请求,都会生成一个包含 XML 日志文件和用于查看日志文件的 XSL 样式的文件夹。

以下截图显示了您打开并启用所有日志选项时的外观:

图 7.5 – 应用服务日志启用

图 7.5 – 应用服务日志启用

  1. 您可以通过浏览托管在应用服务上的网站并导航到执行日志记录的控制器所在的页面来验证我们已启用的任何日志选项。例如,让我们通过访问配置了日志选项之一的 Blob 存储来检查应用程序日志(Blob),如下截图所示:

图 7.6 – 从 Blob 存储获取的应用服务日志

图 7.6 – 从 Blob 存储获取的应用服务日志

您还可以通过导航到监控下的日志流,从添加测试日志的控制器实时查看日志:

图 7.7 – 应用服务日志在日志流中

图 7.7 – 应用服务日志在日志流中

我们看到了如何在 Azure 应用服务中启用不同的日志并验证了应用程序日志(Blob)日志流中的日志。在下一节中,我们将看到如何进行监控和设置警报。

使用度量值进行监控

您可以使用 Azure Monitor 中的度量值来监控您的应用服务计划和应用程序服务。

导航到您的应用服务计划并查看概述,如以下截图所示。您可以看到 CPU、内存、数据输入、数据输出等标准图表:

图 7.8 – 应用服务计划概述

图 7.8 – 应用服务计划概述

现在,点击任何图表,例如,CPU 百分比图表。您将看到以下截图所示的视图(默认持续时间是 1 小时):

图 7.9 – 应用服务度量值概述

图 7.9 – 应用服务度量值概述

我在之前截图所示的图表中突出显示了三个重要部分。让我们来讨论它们:

  • 本地时间:当您点击本地时间时,您将看到以下截图所示的选项。您可以更改此图表应表示的时间范围值:

图 7.10 – 应用服务度量值时间范围

图 7.10 – 应用服务度量值时间范围

  • 添加度量值:当您点击添加度量值时,您将看到以下截图所示的选项。您可以选择图表要显示的度量值:

图 7.11 – 应用服务 – 添加度量值

图 7.11 – 应用服务 – 添加度量值

  • 固定到仪表板:您可以通过点击固定到仪表板将图表添加到仪表板,以便您登录 Azure 门户时可以看到更新。

当您点击左侧门户菜单时,您可以看到仪表板,您可以点击它以查看所有已固定的仪表板:

图 7.12 – 左侧门户菜单仪表板选项

图 7.12 – 仪表板左侧门户菜单选项

在下一节中,让我们看看如何在 Azure 应用洞察中启用实时遥测。

Azure 应用洞察的实时遥测

Application Insights 是 Microsoft Azure 为开发人员和 DevOps 专业人员提供的最佳遥测服务之一,作为一个可扩展的应用程序性能管理(APM)服务,用于以下目的:

  • 实时监控您的应用程序。

  • 自动检测性能异常。

  • 包含强大的分析工具以帮助您诊断问题。

  • 了解用户如何使用您的应用程序。

  • 帮助您持续改进性能和可用性。

Microsoft.Extensions.Logging.ApplicationInsights 作为 Microsoft.ApplicationInsights.AspNetCore 的依赖项被包含。Microsoft.ApplicationInsights.AspNetCore 包用于 ASP.NET Core 应用程序中的遥测,当您使用此包时,您不需要安装 Microsoft.Extensions.Logging.ApplicationInsights

如下图所示,您可以在应用程序中安装此包以启用并写入遥测:

![图 7.13 – 应用洞察遥测的仪器化图片

图 7.13 – 应用洞察遥测的仪器化

注意

这不会影响您的应用程序性能。对应用洞察的调用是非阻塞的,并且以批量形式在单独的线程中发送。

在应用洞察中启用应用程序日志记录

使用应用洞察启用应用程序日志记录的步骤如下:

  1. 使用 Install-Package <Package name> -version <Version number> 命令安装 Microsoft.ApplicationInsights.AspNetCore 包:

![图 7.14 - 从包管理控制台安装包图片

图 7.14 - 从包管理控制台安装包

  1. appsettings.json 以确保所有遥测数据都写入您的 Azure 应用洞察资源。如果您没有 Azure 应用洞察资源,请继续创建一个,然后将其添加到 appsettings.json

    {
      "Logging": {
        "LogLevel": {
          "Default": "Information",
          "Microsoft": "Warning",
          "Microsoft.Hosting.Lifetime": "Information"
        }
      },
      "Telemetry": {
        "InstrumentationKey": "Your AppInsights Instrumentation Key "
      }
    
  2. Program.cs 文件中,添加高亮代码:

    string InstrumentationKey = builder.Configuration["Telemetry:InstrumentationKey"];
    // The following line enables Application Insights telemetry collection.
    builder.Services.AddApplicationInsightsTelemetry(InstrumentationKey);         
    

现在,您可以构建和运行应用程序。默认情况下,您将获得大量的遥测数据。

导航到 应用洞察 | 概览,您可以看到任何失败的请求、服务器响应时间和服务器请求,如图下截图所示:

![图 7.15 – 应用洞察概览图片

图 7.15 – 应用洞察概览

您可以导航到 应用洞察 | 实时指标 以获取实时性能计数器,如图下所示:

![图 7.16 – 应用洞察实时指标图片

图 7.16 - 应用洞察实时指标

您可以导航到 应用洞察 | 指标 以获取不同的指标和图表,如图下所示:

![图 7.17 – 应用洞察指标图片

图 7.17 – 应用洞察指标

您可以导航到 应用洞察 | 性能 以分析操作持续时间、依赖项响应时间等,如图下所示:

![图 7.18 – 应用洞察性能图片

图 7.18 – 应用洞察性能

您可以导航到 应用洞察 | 失败 并分析操作、失败的请求、失败的依赖项、前三个响应代码、异常类型和依赖项失败,如图下所示:

![图 7.19 – 应用洞察失败图片

图 7.19 – 应用洞察失败

我们已经看到了开箱即用的遥测和警报。我们如何添加信息、错误或警告的日志?您可以使用日志记录器(对象创建是通过 DI 实现的,这在 第五章.NET 6 中的依赖注入)中已经介绍过)。DI 是通过前述步骤中看到的第二个(应用程序设置配置)和第三个(启用 Application Insights 遥测)步骤启用的。为了测试目的,为了查看它是否正常工作,您可以将以下代码添加到您的控制器中并运行应用程序:

[HttpGet]
        public IEnumerable<WeatherForecast> Get()
        {
    //Removed code for brevity
            _logger.LogWarning("Logging Warning for
               testing");
            _logger.LogError("Logging Error for testing");
            //Removed code for brevity

您可以导航到 Application Insights | 日志 并检查跟踪,在那里您可以查看已记录的警告和错误,如图所示:

图 7.20 – Application Insights 日志

图 7.20 – Application Insights 日志

图 7.20 – Application Insights 日志

Application Insights 非常简单易用,是一个非常强大的日志提供程序。我们看到了它提供的丰富遥测数据,并添加了我们自己的日志。在下一节中,我们将开发一个自定义日志类库。.NET 6 中提供的默认日志记录器对于您的应用程序遥测来说已经足够了。如果您需要记录默认在 .NET 6 中提供的自定义指标和事件,您可以利用以下自定义日志记录器库。

创建 .NET 6 日志类库

我们将创建一个支持 Application Insights 日志记录并可扩展以支持其他来源日志记录的类库(DLL)。为此,请执行以下步骤:

  1. 创建一个新的名为 Logger 的 .NET 6 类库。

  2. 安装 Microsoft.ApplicationInsights 包。

  3. 创建一个名为 ICustomLogger.cs 的新类,并添加以下代码:

    using System;
    using System.Collections.Generic;
    namespace Logger
    {
        public interface ICustomLogger
        {
            void Dependency(string dependencyTypeName,
             string dependencyName, string data,
             DateTimeOffset startTime, TimeSpan duration,
             bool success);
            void Error(string message, IDictionary<string,
              string> properties = null);
            void Event(string eventName,
            IDictionary<string, string> properties = null,
            IDictionary<string, double> metrics = null);
            void Metric(string name, long value,
             IDictionary<string, string> properties =
             null);
            void Exception(Exception exception,
             IDictionary<string, string> properties =
             null);
            void Information(string message,
             IDictionary<string, string> properties =
             null);
            void Request(string name, DateTimeOffset
             startTime, TimeSpan duration, string
             responseCode, bool success);
            void Verbose(string message,
             IDictionary<string, string> properties =
             null);
            void Warning(string message,
             IDictionary<string, string> properties =
             null);
        }
    }
    
  4. 创建一个名为 AiLogger.cs 的新类,并添加以下代码以记录自定义事件和指标:

    • 命名空间和构造函数:

      using Microsoft.ApplicationInsights;
      using Microsoft.ApplicationInsights.DataContracts;
      using System;
      using System.Collections.Generic;
      namespace Logger
      {
          public class AiLogger : ICustomLogger
          {
              private TelemetryClient client;
      
              public AiLogger(TelemetryClient client)
              {
                  if (client is null)
                  {
                      throw new ArgumentNullException(nameof(client));
                  }
                  this.client = client;
              }
      
    • 代码用于记录警告、错误和异常:

              public void Warning(string message, IDictionary<string, string> properties = null)
              {
                  this.client.TrackTrace(message, SeverityLevel.Warning, properties);
              }
              public void Error(string message, IDictionary<string, string> properties = null)
              {
                  this.client.TrackTrace(message, SeverityLevel.Error, properties);
              }
              public void Exception(Exception exception, IDictionary<string, string> properties = null)
              {
                  this.client.TrackException(exception, properties);
              }        
      
    • 代码用于记录自定义事件、指标、信息、请求和依赖:

      public void Event(string eventName, IDictionary<string, string> properties = null, IDictionary<string, double> metrics = null)
              {
                  this.client.TrackEvent(eventName, properties, metrics);
              }
              public void Metric(string name, long value, IDictionary<string, string> properties = null)
              {
                  this.client.TrackMetric(name, value, properties);
              }
              public void Information(string message, IDictionary<string, string> properties = null)
              {
                  this.client.TrackTrace(message, SeverityLevel.Information, properties);
              }
              public void Request(string name, DateTimeOffset startTime, TimeSpan duration, string responseCode, bool success)
              {
                  this.client.TrackRequest(name, startTime, duration, responseCode, success);
              }        
          }
      public void Dependency(string dependencyTypeName, string dependencyName, string data, DateTimeOffset startTime, TimeSpan duration, bool success)
              {            this.client.TrackDependency(dependencyTypeName, dependencyName, data, startTime, duration, success);
              }
      

AiLogger 使用 TelemetryClient 类,该类将遥测数据发送到 Azure Application Insights。

  1. 构建库,您的自定义 .NET 6 日志记录器就绪,可以消费项目中的事件。

在接下来的章节中,我们将作为企业应用程序开发的一部分使用日志记录库。在本章提供的示例中,您可以看到我们如何动态地将此自定义日志记录器注入到 LoggerDemoService 项目中。

摘要

在本章中,我们学习了良好日志记录的特点,可用的不同日志提供程序,例如 Azure App Service 日志提供程序和 Azure Application Insights 日志提供程序,以及如何创建可重用的日志记录器库。

您现在拥有了必要的日志记录知识,这将帮助您在项目中实现可重用的记录器或扩展当前的记录器,以适当的日志级别和关键信息来调试问题并在生产数据上构建分析。

在下一章中,我们将学习如何在 .NET 6 应用程序中缓存数据的各种技术,以及可以与 .NET 应用程序集成的各种缓存组件和平台。

问题

  1. 哪些日志会在执行流程因失败而停止时突出显示?这些应该表明当前活动中的失败,而不是应用程序范围内的失败:

a. 警告

b. 错误

c. 严重

d. 信息

答案:b

  1. 对于在任何地方运行的应用程序和组件,包括 Azure、AWS、您自己的本地服务器或移动平台,用于日志记录可以依赖什么?

a. 应用洞察

b. Azure App Service

c. EventLog

d. Serilog

答案:a

  1. Azure App Service 中可用的日志记录选项有哪些?

a. 应用程序日志(文件系统)应用程序日志(Blob

b. Web 服务器日志详细错误消息

c. 失败的请求跟踪

d. 所有以上选项

答案:d

  1. 应用洞察是一个可扩展的 APM 服务,可以执行以下哪些操作?

a. 监控您的实时应用程序。

b. 自动检测性能异常。

c. 包含强大的分析工具以帮助您诊断问题。

d. 所有以上选项。

答案:d

第八章:第八章:关于缓存你需要知道的一切

缓存是帮助企业应用扩展并提高响应时间的关键系统设计模式之一。任何 Web 应用通常都涉及从数据存储中读取和写入数据,这些数据存储通常是关系型数据库,如 SQL Server,或 NoSQL 数据库,如 Cosmos DB。然而,对于每个请求从数据库中读取数据并不高效,尤其是当数据没有变化时。这是因为数据库通常将数据持久化到磁盘,从磁盘加载数据并将其发送回浏览器客户端(或移动/桌面应用程序中的设备)或用户的操作是成本高昂的。这就是缓存发挥作用的地方。

缓存存储可以用作检索数据的主要来源,只有当所需数据不在缓存中时才回退到原始数据存储,从而为消费应用程序提供更快的响应。当以这种方式使用缓存时,我们还需要确保当原始数据存储中的数据更新时,缓存中的数据会过期/刷新。

在本章中,我们将学习在.NET 6 应用程序中缓存数据的各种技术,以及可以与.NET 6 应用程序集成的各种缓存组件和平台。我们将涵盖以下主题:

  • 缓存简介

  • 理解缓存组件

  • 缓存平台

  • 使用分布式缓存设计缓存抽象层

技术要求

需要基本了解.NET Core、C#、Azure 和.NET CLI。本章的代码可以在此处找到:github.com/PacktPublishing/Enterprise-Application-Development-with-C-10-and-.NET-6-Second-Edition/tree/main/Chapter08

代码示例的说明可以在此处找到:github.com/PacktPublishing/Enterprise-Application-Development-with-C-10-and-.NET-6-Second-Edition/tree/main/Enterprise%20Application

缓存简介

有多种方法可以提高应用程序的性能,缓存是企业在应用中使用的关键技术之一。缓存就像一个临时数据存储,具有有限的大小和有限的数据,但与原始数据源相比,数据访问速度要快得多,通常只存储数据的一个子集,即最常使用且不经常变化的数据。

缓存存储可能只是计算机的 RAM,该 RAM 在执行过程中被进程使用,或者它可能是像 Redis 这样的东西,它使用内存和磁盘来存储数据。这里的关键是它通常位于比原始存储层访问时间更低的硬件上。

缓存可以在架构的每一层实现,以便从最接近用户的那一层检索数据。例如,在任何 Web 应用中,当我们输入 URL 并在浏览器中按下Enter键时,它会通过加载 Web 应用所涉及的各个 Web 组件,从浏览器、代理和 DNS 开始,到 Web 服务器和数据库。缓存是可以在所有这些层应用的东西。

如果数据在浏览器中缓存,它可以立即加载。如果没有在用户最近的那一层找到数据,它可以回退到更高一层,从而减少共享多个用户的更高层(如应用服务器和数据库层)的负载。

下图以高层次描述了这一讨论,其中请求在各个层之间流动,并且只有在缓存中没有数据(用虚线表示)时才会移动到更高层:

图 8.1 – 请求流程中的缓存层

图 8.1 – 请求流程中的缓存层

让我们讨论一下应用架构中可以缓存数据的一些这些层。

客户端缓存

常请求的数据可以在客户端缓存,以避免不必要的往返服务器。例如,微软的 Outlook 应用从服务器下载最新的电子邮件,并在客户端保留副本,然后定期同步新邮件。如果需要搜索尚未下载的电子邮件,它将返回服务器。

同样,浏览器可以根据某些头部信息缓存来自 Web 应用的多种资源和响应,并且对相同资源的后续请求将从浏览器缓存中加载。例如,所有 JavaScript 文件、图像文件和 CSS 通常会在浏览器中缓存一定时间。此外,通过发送适当的响应头部,API 的响应也可以被缓存。这被称为HTTP 缓存响应缓存,将在后面的章节中详细讨论。

内容分发网络(CDN)

内容分发网络CDN)是一组全球分布的服务器,通常用于提供静态内容,如 HTML、CSS 和视频。每当应用请求一个资源时,如果启用了 CDN,系统将首先尝试从物理上最接近用户的 CDN 服务器加载该资源。然而,如果该资源不在 CDN 服务器上,它将从服务器检索并缓存到 CDN 中,以服务于后续请求。Netflix 就是这样一个很好的例子,它严重依赖其定制的 CDN 来向用户交付内容。

微软还提供了 Azure CDN,主要用于服务静态内容。此外,微软的 CDN 还提供了与 Azure 存储集成的选项,我们将在我们的电子商务应用中使用它来服务各种产品图片。类似地,AWS 有 Amazon Cloudfront,而 Google Cloud 则在其各自的云存储服务中提供 Cloud CDN。

Web 服务器缓存

虽然 CDN 对于静态内容非常出色,但它们在从应用服务器刷新数据方面会带来额外的成本和维护开销。为了克服这些限制,应用可以使用 Web 服务器或反向代理来服务静态内容。一个轻量级的 NGINX 服务器就是这样一个例子,它可以用来服务静态内容。

Web 服务器也可以缓存动态内容,例如来自应用服务器的 API 响应。当配置为反向代理时,如 NGINX 或 IIS 这样的 Web 服务器可以进一步用于缓存动态内容,从而通过从其缓存中服务请求来减轻应用服务器的负载。

注意

NGINX 是一个开源解决方案,主要以其 Web 服务器功能而闻名;然而,它也可以用作反向代理、负载均衡等。欲了解更多信息,请参阅www.nginx.com/

数据库缓存

越来越多的数据库服务器会缓存查询的某些组件;例如,SQL Server 通常有缓存执行计划,并且还有一个数据缓冲区用于缓存,MongoDB 则将最近查询的数据保存在内存中以实现快速检索。因此,调整这些设置以改善应用性能是很好的。

注意

数据库缓存并不能保证相同查询的后续执行不会消耗 CPU 资源;也就是说,它并不是真正免费的。在后续请求中,相同的查询执行速度会更快。

应用缓存

应用缓存可以通过在应用服务器内部缓存从存储层检索到的数据来实现。这通常以下两种方式完成:

  • 存储在应用服务器的内存中,也称为内存缓存

  • 存储在外部存储中,如 Redis 或 Memcached,其访问速度比底层原始数据存储更快

应用缓存通常涉及在应用逻辑中集成额外的代码以缓存数据。因此,每当请求数据时,应用首先会在缓存中查找。但如果缓存中没有,应用将回退到原始数据存储,如数据库。通常,应用缓存的大小与原始数据存储相比有限,因此,应用缓存平台将采用各种算法,如最近最少使用LRU)或最少使用频率LFU)来清理缓存中存储的数据。我们将在缓存平台部分讨论更多关于缓存平台的内容。

对于应用程序缓存来说,另一个需要考虑的重要点是数据失效,即数据需要多频繁地过期或与原始数据源同步。因此,需要考虑诸如缓存过期以及用原始数据存储更新缓存的各种策略(读透,写透)。我们将在缓存访问模式部分讨论更多的缓存失效/刷新策略。

理解缓存的组件

在我们了解.NET 6 应用程序中可用的各种可能的缓存存储/平台之前,我们需要了解.NET 6 中可用的各种缓存组件以及如何在企业应用程序中使用它们。在这个过程中,我们还将涵盖各种缓存淘汰策略和技术,以保持缓存与原始数据存储同步。

响应缓存

响应缓存是 HTTP 支持的一种缓存技术,用于缓存使用 HTTP 或 HTTPS 发出的请求的响应,无论是在客户端(例如,浏览器)还是中间代理服务器上。从实现的角度来看,这通过在请求和响应中设置适当的Cache-Control头值来控制。一个典型的Cache-Control头将如下所示:

Cache-Control:public,max-age=10

在这种情况下,如果响应中存在该头,服务器正在告诉客户端/代理(公开)客户端可以缓存响应 10 秒(max-age=10)。然而,客户端仍然可以覆盖它并缓存更短的时间;也就是说,如果请求和响应都设置了缓存头,则缓存持续时间将是两者中的最小值。

除了max-age之外,根据 HTTP 规范(tools.ietf.org/html/rfc7234#section-5.2),Cache-Control还可以包含以下值:

  • 公开:响应可以缓存在任何地方 – 客户端/服务器/中间代理服务器。

  • 私有:响应可以存储在特定用户处,但不能存储在共享缓存服务器上;例如,它可以存储在客户端浏览器或应用服务器中。

  • No-cache:响应不能被缓存。

在响应缓存中起作用的其它头包括以下内容:

  • Age:这是一个响应头,表示对象在缓存(代理/浏览器)中存在的时间长度。接受的值是一个整数,表示秒数。

  • Vary设置为user-agent值,每个user-agent的响应将唯一缓存。

下面的截图显示了 Postman 中一个示例请求相关的缓存响应头:

图 8.2 – 带有 Cache-Control 和 Vary 头的示例响应

图 8.2 – 带有 Cache-Control 和 Vary 头的示例响应

下面的序列图显示了使用 ASP.NET Core 6 构建的具有启用响应缓存中间件的示例 API:

图 8.3 – 响应缓存序列图

图 8.3 – 响应缓存序列图

在创建新的 ASP.NET Core 6 MVC/Web API 应用程序或使用现有的 ASP.NET Core 6 MVC/Web API 应用程序后,要配置响应缓存,需要以下代码更改:

  1. Program.cs中添加builder.Services.AddResponseCaching(),并使用app.UseResponseCaching()添加所需的中间件。此中间件包含缓存数据的所需逻辑。确保在app.UseEndpoints之前注入此中间件。

  2. 通过自定义中间件或使用ResponseCache属性来处理响应以设置缓存头。

    注意

    在使用 CORS 中间件时,必须先调用UseCors然后调用UseResponseCaching。有关此顺序的更多信息,请参阅github.com/dotnet/AspNetCore.Docs/blob/master/aspnetcore/fundamentals/middleware/index.md

ResponseCache属性可用于整个控制器或控制器中的特定方法,并接受以下键属性:

  • Duration:一个数值,用于设置响应头中的max-age

  • ResponseCacheLocation:一个枚举,接受三个值 - AnyClientNone - 并进一步设置Cache-Control头为publicprivateno-store

  • VaryByHeader:一个字符串,用于控制基于特定头的缓存行为

  • VaryByQueryKeys:一个字符串数组,接受基于其缓存数据的关键值

带有ResponseCache属性的一个典型方法看起来像这样:

        [HttpGet]
        [ResponseCache(Duration = 500, VaryByHeader = 
          "user-agent", Location = 
          ResponseCacheLocation.Any, VaryByQueryKeys = 
          new[] { "Id" })]
        public async Task<IActionResult> 
          Get([FromQuery]int Id = 0)

此方法将基于唯一的user-agent头和Id值缓存500秒。如果这些值中的任何一个发生变化,则从服务器提供响应,否则从缓存中间件提供。

如您在此处所见,我们需要为每个控制器/方法前缀添加ResponseCache属性。因此,如果应用程序有多个控制器/方法,这可能是一个维护开销,因为为了更改数据缓存的方式(例如更改Duration值),我们需要在控制器/方法级别应用更改,这正是缓存配置文件发挥作用的地方。

因此,我们不必单独设置属性,我们可以将它们分组,并在Program.cs中给它们一个名称,该名称可以在ResponseCache属性中使用。因此,对于前面的属性,我们可以在Program.cs中通过添加以下代码创建一个缓存配置文件:

            builder.Services.AddControllers(options =>
            {
                options.CacheProfiles.Add("Default", 
                  new CacheProfile {
                    Duration = 500,
                    VaryByHeader = "user-agent",
                    Location = ResponseCacheLocation.Any,
                    VaryByQueryKeys = new[] { "Id" } });
            });

在控制器上,使用CacheProfileName调用此缓存配置文件:

[ResponseCache(CacheProfileName = "Default")]

对于 MVC 应用程序,可以在services.AddControllersWithViews()中配置CacheProfile

分布式缓存

正如我们所知,在分布式系统中,数据存储被分散在多个服务器上。同样,分布式缓存是传统缓存的扩展,其中缓存数据存储在网络的多个服务器上。在我们深入了解分布式缓存之前,这里快速回顾一下CAP 定理

  • C 代表一致性,意味着数据在所有节点上都是一致的,每个节点都有相同的数据副本。

  • A 代表可用性,意味着系统是可用的,一个节点的故障不会导致系统崩溃。

  • P 代表分区容错,意味着即使节点之间的通信中断,系统也不会崩溃。

根据 CAP 定理,任何分布式系统只能实现上述两个原则中的两个,并且由于分布式系统必须是分区容错的(P),我们只能实现数据的一致性(C)或数据的高可用性(A)。

因此,分布式缓存是一种缓存策略,其中数据存储在应用服务器之外的多台服务器/节点/分片中。由于数据分布在多台服务器上,如果一台服务器宕机,另一台服务器可以作为备份来检索数据。

例如,如果我们的系统想要缓存国家、州和城市,并且在一个分布式缓存系统中有三台缓存服务器,假设其中一台缓存服务器将缓存国家,另一台缓存州,还有一台缓存城市(当然,在实际应用中,数据的分割方式要复杂得多)。

此外,每台服务器还将作为一个或多个实体的备份。因此,从高层次来看,一种分布式缓存系统看起来如下所示:

图 8.4 – 分布式缓存高级表示

图 8.4 – 分布式缓存高级表示

如您所见,在读取数据时,数据是从主服务器读取的,如果主服务器不可用,缓存系统将回退到辅助服务器。同样,对于写入操作,只有在数据被写入主服务器和辅助服务器之后,写入操作才算完成。在此操作完成之前,读取操作可能会被阻塞,从而影响系统的可用性。对于写入操作,另一种策略可以是后台同步,这将导致数据最终一致性,因此在同步完成之前,数据的一致性可能会受到影响。回到 CAP 定理,大多数分布式缓存系统属于 CP 或 AP 类别。

以下是一些与.NET 6 应用程序集成的分布式缓存提供商:

  • Redis Cache

  • Memcached

  • Couchbase

  • SQL Server

  • NCache

这可以进一步扩展到任何集群编排平台,例如,Terracotta,它负责管理各种节点,并将数据分发到所有节点。

尽管分布式缓存有很多好处,但与单服务器缓存或进程内缓存相比,分布式缓存的一个可能缺点可能是由于可能的额外跳转和序列化/反序列化而引入的延迟。因此,如果应用严重依赖于缓存数据,设计可以考虑内存缓存和分布式缓存的组合。然而,大多数场景都可以通过集成一个良好实现的分布式缓存系统(如 Redis)来覆盖,我们将在本章后面讨论这一点。

Cache access patterns

一旦对象数据被缓存,就需要一个设计来处理缓存的刷新。可以实现多种缓存访问模式来处理这种情况。以下是一些关键模式:

  • Cache-aside pattern

  • Read-through/write-through

  • Refresh-ahead

  • Write-behind

让我们逐一详细讨论。

Cache-aside pattern

如其名所示,在缓存-旁路模式中,缓存存储与数据存储一起保留。在这个模式中,应用程序代码会检查缓存存储中的数据可用性。如果缓存存储中没有数据,则从底层数据存储中检索数据,并在缓存中更新。后续请求将再次查询缓存中的数据,如果数据在缓存中可用,则将从缓存中提供服务。缓存-旁路模式依赖于懒加载的概念,这在第四章中讨论过,线程和异步操作,并且当数据首次被访问时填充;对同一实体的后续请求将加载自缓存。

在更新原始数据存储中的数据时,应该处理缓存存储中数据的过期。

以下是这个模式的优势:

  • 与下一节中介绍的读取/写入模式相比,实现简化。由于缓存不是应用中的主要数据源,我们不需要额外的类来同步缓存存储与数据存储。

  • 由于它依赖于懒加载原则,缓存仅在访问任何数据至少一次时才会被填充。

然而,这个模式也有一些缺点:

  • 这可能导致缓存未命中次数增加的可能性。缓存未命中应该始终保持在最小,因为它们会由于额外的跳转而引入应用的延迟。

  • 如果在数据更新期间错过缓存过期,可能会导致缓存中的数据过时。这可能发生在数据由没有缓存系统信息的后台/外部进程更新时。

一种减轻过期问题的方法是为每个实体设置生存时间TTL),这样对象在一段时间后就会自动过期。然而,在监控数据刷新率后,需要仔细评估 TTL 持续时间。在缓存旁路模式的情况下,另一种常见做法是在应用程序启动时预先填充缓存存储,因为这有助于减少缓存未命中次数。大多数企业应用程序通常使用缓存旁路模式实现缓存层,并用主数据而不是事务数据预先填充它。

读取/写入

在读取/写入中,应用程序直接从缓存存储读取/写入数据;也就是说,应用程序将其用作主存储,缓存层负责在缓存中加载数据,并负责将任何从缓存存储更新的数据写回原始存储。

当应用程序想要读取一个实体时,它将直接从缓存存储请求它。如果该实体存在于缓存中,则返回响应。然而,如果它不在缓存中,缓存层将从原始数据存储请求它,该数据存储在缓存中更新以供将来使用,然后从缓存层返回响应。

更新实体时,以下步骤发生:

  1. 首先更新到缓存中。

  2. 缓存层将其写回原始数据存储。

这种类型系统的优点如下:

  • 在原始数据存储(通常是一个数据库)上显著减少负载,因为在大多数企业应用程序中,所有调用都会从缓存提供服务,除了从缓存层到数据存储的调用。

  • 简化的应用程序代码,因为它只与一个存储进行交互,而与缓存旁路模式不同,后者不仅与缓存存储交互,还与应用程序代码内的数据存储交互。

这种模式的几个缺点如下:

  • 需要一个额外的机制来同步缓存和数据存储之间的数据。

  • 缓存更新变得有些棘手,因为它涉及到刷新缓存存储的额外复杂性。

预刷新

预刷新策略允许您异步地将数据加载到缓存存储中;也就是说,在这个设计中,应用程序仍然直接与缓存存储通信。然而,缓存层会在缓存中的数据过期之前定期刷新数据。对于最近访问过的条目,刷新是异步发生的,并在它们过期之前从原始存储异步刷新。这样,如果任何缓存项已过期,应用程序中就不会有任何延迟。

写入后

在写入后策略中,数据首先更新到缓存存储,然后异步更新回数据存储,这与写入策略不同,在写入策略中,数据会立即更新到数据存储。这种策略的一个关键优点是降低了延迟。然而,由于数据是异步更新的(写入数据存储和缓存存储是两个不同的交易),因此应该实现回滚机制,以防万一出现故障。

通常,与写入策略相比,实现起来要复杂得多,因为需要额外的处理来避免在异步更新期间发生数据丢失,但如果需要将缓存存储作为主要数据源,这仍然是一个很好的集成模式。

到目前为止讨论的所有模式都可以在以下图中以高层次可视化:

图 8.5 – 缓存模式

图 8.5 – 缓存模式

到目前为止,我们已经看到了各种缓存模式和策略。在下一节中,我们将讨论各种缓存提供程序及其与 .NET 6 应用程序的集成。

缓存平台

.NET 6 支持多个缓存平台。以下是一些常用的缓存平台:

  • w3wp.exe

  • 分布式缓存:数据存储在多个服务器上。可以与 .NET 6 应用程序集成的数据存储包括 SQL Server、Redis 和 NCache。

内存缓存

要配置内存缓存,在创建新的 ASP.NET Core 6 MVC/Web API 应用程序后,或者对于现有的 ASP.NET Core 6 MVC/Web API 应用程序,需要以下代码更改:

  1. Program.cs 中添加 builder.Services.AddMemoryCache()MemoryCache 类是 .NET 6 中 IMemoryCache 的内置实现,可以在任何 C# 类中通过 IMemoryCache 使用。它通过构造函数注入进行实例化。对象创建是通过 IMemoryCache 进行的,并使用构造函数注入创建 MemoryCache 的实例。

  2. MemoryCache 提供了许多方法,但其中一些重要的是 Get(获取键的值)、Set(插入键及其值)和 Remove(移除键(缓存过期))。

  3. 在创建缓存对象(使用 Set 或其他方法)时,可以使用 MemoryCacheEntryOptions 对内存缓存进行各种参数的配置。以下属性是支持的:

a. SetAbsoluteExpiration: 缓存的绝对有效时间,即缓存有效的确切日期和时间(DateTime)。

b. SetSlidingExpiration: 缓存条目在缓存中变为非活动状态后的无效时间。例如,5 秒的滑动过期值将等待缓存条目非活动 5 秒。滑动过期值应始终小于绝对过期值,因为无论滑动过期值如何,缓存都会在达到绝对过期时间后过期。

c. SetPriority:在执行缓存淘汰时,缓存大小是有限的(可以使用 SetPriority 通过 CacheItemPriority 枚举设置缓存条目的优先级。其默认值是 CacheItemPriority.Normal)。

按照前面的步骤集成的简单 Web API 控制器,带有内存缓存集成,将如下所示:

    public class WeatherForecastController : ControllerBase
    {
        private IMemoryCache cache;
        public WeatherForecastController(IMemoryCache 
          cache)
        {
            this.cache = cache;
        }
        [HttpGet]
        public IActionResult Get()
        {            
            DateTime? cacheEntry;            
            if (!cache.TryGetValue("Weather", 
              out cacheEntry))
            {
                cacheEntry = DateTime.Now;
                var cacheEntryOptions = new 
                  MemoryCacheEntryOptions()
                    .SetSlidingExpiration(
                     TimeSpan.FromSeconds(50))
                    .SetAbsoluteExpiration(
                     TimeSpan.FromSeconds(100))
                    .SetPriority(
                     CacheItemPriority.NeverRemove);
                cache.Set("Weather", cacheEntry, 
                  cacheEntryOptions);
            }
            cache.TryGetValue("Weather", out cacheEntry);
            var rng = new Random();
            return Ok(from temp in Enumerable.Range(1, 5)
                   select new
                   {
                       Date = cacheEntry,
                       TemperatureC = rng.Next(-20, 55),
                       Summary = "Rainy day"
                   });
        }
    }

如您所见,此代码是自我解释的,并使用内存缓存提供了一个 API。

MemoryCache 中可用的一种附加方法是使用 RegisterPostEvictionCallback 集成回调方法。这是 MemoryCacheEntryOptions 中的一个扩展方法,它接受一个 PostEvictionDelegate 委托,并在缓存条目过期时触发回调。PostEvictionDelegate 的签名如下:

public delegate void PostEvictionDelegate(object key, object value, EvictionReason reason, object state);

因此,这意味着我们传递给 RegisterPostEvictionCallback 的回调应该遵循相同的签名,如您所见,所有输入参数都是自我解释的。因此,让我们添加一个回调方法并更新 cacheEntryOptions,如下所示:

private void EvictionCallback(object key, object value, EvictionReason reason, object state)
{
      Debug.WriteLine(reason);        
}
cacheEntryOptions.RegisterPostEvictionCallback(EvictionCallback);

天气控制器的代码映射如下截图所示:

图 8.6 – 天气控制器代码映射

图 8.6 – 天气控制器代码映射

一旦我们运行此代码,我们就可以看到在 50 秒的绝对过期后对控制器的任何后续调用都将触发回调,并将原因记录为 Expiration。一旦部署到 AppService,回调将自动触发。只有出于调试目的,我们才需要再次调用。

分布式缓存

在讨论了内存缓存之后,让我们继续探讨其他可以配置为分布式缓存的缓存平台。分布式缓存,就像分布式存储系统一样,将缓存数据分布到多个服务器上,主要是为了支持扩展。在本节中,我们将查看不同类型的分布式缓存,从 SQL 开始。

SQL

分布式缓存可以使用各种存储实现,其中之一是 SQL Server。使用 SQL Server 进行分布式缓存的第一步是创建存储缓存条目的所需 SQL 表。将 SQL 作为分布式缓存存储的整个设置涉及以下步骤:

  1. 在管理员提示符中打开命令行并运行以下命令以全局安装 dotnet-sql-cache 包:

    dotnet tool install ––global dotnet-sql-cache
    

这就是它的样子:

图 8.7 – 使用 .NET CLI 安装 sql-cache 包

图 8.7 – 使用 .NET CLI 安装 sql-cache 包

  1. 创建所需的数据库(本地或使用 Azure SQL)并运行以下命令以创建存储缓存数据的表:

    dotnet sql-cache create "Data Source=.;Initial Catalog=DistributedCache;Integrated Security=true;" dbo cache
    

在这个命令中,我们传递数据库的连接字符串(在本地运行时相应更新)作为参数之一,另一个是表的名称(cache 是前一个片段中表的名称)。

图 8.8 – 为分布式缓存创建 SQL 表

图 8.8 – 为分布式缓存创建 SQL 表

  1. 一旦命令成功运行,如果我们打开 SSMS 中的 SQL 服务器,我们将看到如下所示的表,其中包含用于优化的列和索引:

图 8.9 – SSMS 中 SQL 分布式缓存的缓存表

图 8.9 – SSMS 中 SQL 分布式缓存的缓存表

  1. 创建一个 Web API 应用程序并安装 NuGet 包 Microsoft.Extensions.Caching.SqlServer(通过 包管理控制台PMC)或使用 .NET CLI)。

  2. Program.cs 中添加以下代码:

    builder.Services.AddDistributedSqlServerCache(options =>
    {
        options.ConnectionString = "Data Source=.;Initial 
          Catalog=DistributedCache;Integrated 
          Security=true;";
        options.SchemaName = "dbo";
        options.TableName = "Cache";
    });
    
  3. 要将数据插入缓存,我们需要使用 IDistributedCache,该对象将通过构造函数注入创建。因此,清理 WeatherForecastController(在创建 ASP.NET Core 6 Web API 项目期间创建的默认控制器)中的所有代码,并添加以下代码(一个具有 Get 方法的 Web API 控制器):

        public class WeatherForecastController : ControllerBase
        {
            private readonly IDistributedCache 
              distributedCache;
            public WeatherForecastController(
              IDistributedCache distributedCache)
            {
                this.distributedCache = distributedCache;
            }
        }
    
  4. 添加以下 Get 方法,它使用 distributedCache 并将数据保存到缓存存储(在这种情况下为 SQL):

    [HttpGet]
    public IActionResult Get()
            {
                DateTime? cacheEntry;
                if (distributedCache.Get("Weather") == 
                  null)
                {
                    cacheEntry = DateTime.Now;
                    var cacheEntryOptions = new 
                      DistributedCacheEntryOptions()
                        .SetSlidingExpiration(TimeSpan
                        .FromSeconds(50))
                        .SetAbsoluteExpiration(TimeSpan
                        .FromSeconds(100));
                    distributedCache.SetString("Weather", 
                      cacheEntry.ToString(), 
                      cacheEntryOptions);
                }
                var cachedDate = 
                  distributedCache.GetString("Weather");
                var rng = new Random();
                return Ok(from temp in Enumerable.Range(1, 
                  5)
                          select new
                          {
                              Date = cachedDate,
                              TemperatureC = rng.Next(-20, 
                                55),
                              Summary = "Rainy day"
                          });
            }
    
  5. 运行应用程序后,我们可以看到缓存条目正被存储在 SQL 数据库中,如下面的屏幕截图所示:

图 8.10 – SQL 分布式缓存的缓存表

如您所见,代码与 MemoryCache 代码非常相似,只是我们在这里使用 IDistributedCache 来读写缓存数据,并使用 DistributedCacheEntryOptions 在创建缓存条目时设置额外的属性。

使用 SQL Server 作为分布式缓存存储的一些建议如下:

  • 如果现有应用程序不支持 Redis 等存储,可以选择 SQL Server。例如,仅与 SQL Server 集成的本地应用程序可以轻松扩展 SQL Server 以用于缓存目的。

  • 缓存数据库应与应用程序数据库不同,因为使用相同的数据库可能会导致瓶颈并违背使用缓存的目的。

  • SQL Server 的 IDistributedCache 内置实现是 SqlServerCache,它不支持为缓存表序列化不同的模式。任何自定义都必须通过在自定义类中实现 IDistributedCache 来手动覆盖。

到目前为止,我们已经看到了使用 SQL Server 进行内存缓存和分布式缓存的示例。在下一节中,我们将看到如何在 .NET 6 应用程序中使用 Redis(推荐的存储之一,也是广泛用于缓存的存储)进行分布式缓存。

Redis

Redis 是一个内存数据存储,用于各种目的,如数据库、缓存存储,甚至作为消息代理。Redis 支持的核心数据结构是键值对,其中值可以是简单的字符串,也可以是自定义的复杂数据类型(嵌套类)。Redis 与内存数据集一起工作,并且可以根据需要将数据持久化到磁盘。Redis 还内部支持 Redis 集群的复制和自动分区。由于所有这些功能都是开箱即用的,因此它是一个理想的分布式缓存存储。

Azure 提供了一个名为 Azure Cache for Redis 的 Redis 服务器托管实例,就像任何其他 PaaS 服务一样,由 Microsoft 管理。这允许应用程序开发者直接集成它,并将维护、扩展和升级 Redis 服务器的基础设施开销留给 Microsoft。Azure Cache for Redis 可用于分布式缓存,并且可以使用以下步骤轻松集成到 .NET 6 应用程序中:

  1. 首先,根据 docs.microsoft.com/en-in/azure/azure-cache-for-redis/quickstart-create-redis 中的说明创建 Azure Cache for Redis 的实例。导航到 访问密钥 并复制 主连接字符串 下的值,如图所示:

图 8.11 – Azure Cache for Redis 的缓存键

图 8.11 – Azure Cache for Redis 的缓存键

  1. 创建一个 ASP.NET Core 6 Web API 应用程序并安装 NuGet 包 Microsoft.Extensions.Caching.StackExchangeRedis

  2. Program.cs 中添加以下代码:

                builder.Services.AddStackExchangeRedisCache(
                    options =>
                    {
                        options.Configuration = 
                          "<Connection string copied in 
                            step 1>";
                    });
    
  3. 使用与上一节 SQL 中所示相同的代码更新默认的 WeatherForecastController 控制器。

  4. 运行应用程序后,我们可以看到数据在缓存中存储了 10 秒。在 50 秒内对 API 的任何调用都将从缓存中检索数据。

  5. Azure Cache for Redis 还附带一个控制台,允许我们使用 Redis CLI 命令查询 Redis 服务器。可以通过导航到 Redis 实例的左侧菜单概览来在 Azure 门户中找到该控制台。查询 Weather 键将给出以下截图所示的结果:

图 8.12 – Redis 控制台

图 8.12 – Redis 控制台

如果我们选择 Azure Cache for Redis 的 Premium 层,它还支持多个分片以支持更高的数据量,并支持高可用性的地理复制。

  1. 此外,为了从缓存存储中添加/删除键,还有 GetAsyncSetAsync 方法,可以用来存储更复杂的数据类型或任何非字符串类型。然而,这些方法返回/接受 Task<byte[]>,因此应用程序需要处理序列化/反序列化,这在可重用的缓存库中可以看到。

Redis 是企业应用程序中最受欢迎的缓存存储,在我们的电子商务应用程序中,我们将使用 Azure Cache for Redis 作为我们的缓存存储。有关 Azure Cache for Redis 的更多信息,可以在docs.microsoft.com/en-in/azure/azure-cache-for-redis/找到。

其他提供者

如您所见,.NET 6 应用程序中的分布式缓存是由IDistributedCache驱动的,并且Program类中的缓存存储配置将根据注入的存储实现进行相应配置。此外,.NET 6 还内置了两个其他提供者:

  • IDistributedCache。NCache 可以像 Redis 或 SQL 一样集成。然而,NCache 服务器需要在本地进行配置以用于开发,并且可以使用 IaaS 中的虚拟机或 PaaS 中的应用服务进行配置。

  • AddDistributedMemoryCache): 这是IDistributedCache的另一个内置实现,可以类似地使用。它可以用于单元测试。由于它不是分布式缓存并且使用进程内存,因此不建议在多个应用程序服务器场景中使用。AddMemoryCache(IMemoryCache)AddDistributedMemoryCache(IDistributedCache)之间的唯一区别是后者需要序列化以存储复杂数据。因此,如果存在无法序列化且需要缓存的类型,请使用IMemoryCache,否则请使用IDistributedCache

在企业应用程序中,IDistributedCache可以处理所有缓存层实现,对于开发/测试环境使用内存缓存,对于生产环境使用 Redis 将是一个理想的选择。如果您的应用程序托管在单个服务器上,您可以使用内存缓存,但在生产应用程序中这种情况非常罕见,因此最推荐使用分布式缓存。

因此,基于我们讨论的所有原则和模式,我们将设计一个用于电子商务应用程序的缓存抽象层,这将在下一节中讨论。

使用分布式缓存设计缓存抽象层

在企业应用程序中,在底层缓存实现之上有一个包装类总是很好的,因为它抽象了缓存的核心理念,也可以用作一个包含应用程序范围内默认缓存条目选项的单个类。

我们将实现一个缓存包装类,其底层存储为 Azure Cache for Redis,使用IDistributedCache实现。这是一个.NET Standard 2.1 类库;该库的源代码位于Packt.Ecommerce.Caching项目中。任何想要缓存数据的类都应该使用构造函数注入IDistributedCacheService,并可以调用以下各种方法:

  • AddOrUpdateCacheAsync<T>: 异步添加或更新类型为T的缓存条目

  • AddOrUpdateCacheStringAsync:异步添加或更新字符串类型的缓存条目

  • GetCacheAsync<T>:异步获取类型为 T 的缓存条目

  • GetCacheStringAsync:异步获取字符串类型的缓存条目

  • RefreshCacheAsync:异步刷新缓存条目

  • RemoveCacheAsync:异步移除缓存条目

DistributedCacheService 是继承自 IDistributedCacheService 并实现所有上述方法的包装类。此外,在这个类中配置了 IDistributedCacheDistributedCacheEntryOptions 以使用分布式缓存。

对于序列化和反序列化,我们将使用 System.Text.Json,一个自定义的 IEntitySerializer 接口,以及使用以下方法创建的 EntitySerializer 类:

  • SerializeEntityAsync<T>:异步将指定的对象序列化为字节数组

  • DeserializeEntityAsync<T>:异步反序列化指定的流

IEntitySerializer 的实现通过构造函数注入注入到 DistributedCacheService 类中,并用于序列化和反序列化。

注意

请参阅 序列化性能比较 文章,该文章讨论了各种序列化程序的基准测试。您可以在 maxondev.com/serialization-performance-comparison-c-net-formats-frameworks-xmldatacontractserializer-xmlserializer-binaryformatter-json-newtonsoft-servicestack-text/ 找到它。

DistributedCacheServiceEntitySerializer 的实现遵循在 第四章线程和异步操作 中讨论的所有异步原则,以及在第 第六章**,.NET 6 中的配置中解释的配置。

最后,在 API/MVC 应用程序中,执行以下步骤:

  1. 安装 NuGet 包 Microsoft.Extensions.Caching.StackExchangeRedis

  2. 通过将以下代码片段添加到 Program.cs 来配置缓存:

    if (this.Configuration.GetValue<bool>("AppSettings:UseRedis"))
    {
        builder.Services.AddStackExchangeRedisCache(
          options =>
        {
            options.Configuration = this.Configuration
              .GetConnectionString("Redis");
        });
    }
    else
    {
        services.AddDistributedMemoryCache();
    }
    
  3. 从配置的角度来看,向 appsettings.json 中添加了两个属性,如下所示:

      "ConnectionStrings": {
        //removed other values for brevity
        "Redis": "" //Azure Cache for Redis connection 
                    //string.
      },
      "AppSettings": {
        //removed other values for brevity
        "UseRedis": false //Flag to fallback to in memory 
        //distributed caching, usually false for local 
        //development.
      },
    

任何想要缓存数据的类都需要添加对 Packt.Ecommerce.Caching 项目的引用,并注入 IDistributedCacheService,然后可以调用上述方法来读取/更新/插入缓存存储中的数据。以下是一个使用缓存服务的代码片段:

   public class Country
    {
      public int Id { get; set; }
      public string Name { get; set; }
    }
        public async Task<Country> GetCountryAsync()
        {
            var country = await 
            this.cacheService.GetAsync<Country>("Country"); // cacheservice is of Type IDistributedCacheService and is 
// injected using constructor injection.
            if (country == null)
            {
                country = await 
                  this.countryRepository.GetCountryAsync(); // Retrieving data from database using Repository pattern.
                if (country != null)
                {
                    await this.cacheService
                     .AddOrUpdateAsync<Country>("Country", 
                     country, TimeSpan.FromMinutes(5));
                }
            }
            return country;
        }

在这里,我们使用缓存旁路模式,首先检查缓存存储中的 Country 键。如果找到,则从函数中返回它,否则从数据库中检索它并将其插入缓存,然后从函数中返回。我们将在 第十章创建 ASP.NET Core 6 Web API 中大量使用缓存服务。

正如你所看到的,我们已经使用了之前章节中讨论的一些模式。在下一节中还将讨论一些额外的考虑因素,这些因素在设计企业应用中的缓存层时需要牢记。

缓存考虑因素

在企业应用中,拥有一个缓存层对于提高性能和可扩展性至关重要。因此,在开发缓存层时需要考虑以下因素:

  • 如果我们正在构建一个新的应用程序,那么可以使用IDistributedCache实现作为起点,使用 Azure Cache for Redis,因为它可以很容易地通过几行代码集成到应用程序中。然而,这需要评估相应的成本。

  • 对于一个现有的项目,当前的基础设施起着至关重要的作用,如果已经使用 SQL Server 作为数据存储,那么 SQL 可以成为默认的选择。然而,对 SQL 与 Redis 的性能进行基准测试是个好主意,并据此做出相应的决策。

  • 在底层缓存存储上有一个包装类是一个好的方法,因为它将应用程序与缓存存储解耦,并使得代码在缓存存储未来发生变化时更加灵活且易于维护。

  • IMemoryCacheIDistributedCache的方法不是线程安全的。例如,假设一个线程从缓存中查询一个键,发现它不在缓存中,然后回退到原始数据存储。当从原始存储检索数据时,如果另一个线程查询相同的键,它不会等待第一个线程完成从数据库的读取。第二个线程也将回退到数据库。因此,需要显式处理线程安全性,可能是在包装类中。

  • 除了应用缓存之外,还应实现响应缓存以实现进一步的优化。

  • If-None-Match请求头,如果匹配,服务器返回304(无变化),客户端可以重用数据的缓存版本。对于 ETag,服务器端没有内置的实现,因此可以使用过滤器或中间件来实现服务器端逻辑。

  • 尽管我们在实现中使用了 JSON 序列化,但还有其他格式,如 BSON 或协议缓冲区,应该评估用于序列化和反序列化。

正如任何其他用于缓存的开发中的应用组件一样,没有一种适合所有情况的解决方案。因此,应该评估前面提到的点,并相应地实施适当的缓存解决方案。

摘要

在本章中,我们学习了各种缓存技术、模式和它们在提高应用程序性能方面的好处。此外,我们还学习了 HTTP 缓存,如何将响应缓存集成到 API 响应中,以及各种可用的缓存提供者和它们与.NET 6 应用程序的集成。我们还学习了如何使用IDistributedCache实现分布式缓存,并构建了一个缓存抽象层,该层将在后续章节中用于缓存需求。我们在学习过程中了解的一些关键信息和技能包括为什么和何时需要缓存,以及如何在.NET 6 应用程序中实现缓存。

在下一章中,我们将探讨.NET 6 中的各种数据存储和提供者及其与.NET 6 应用程序的集成。

问题

  1. 以下哪个Cache-Control头部的值允许响应存储在任何服务器(客户端/服务器/代理)中?

a. 私有

b. 公共

c. No-cache

答案:b

  1. 在多应用服务器场景中,我们应该选择以下哪个缓存平台?

a. 分布式缓存

b. 内存缓存

答案:a

  1. 判断对错:在缓存旁路模式中,数据首先更新在缓存存储中,然后是在底层数据存储中。

a. 正确

b. 错误

答案:b

  1. 以下哪个缓存最适合存储静态文件和图像文件并支持地理复制?

a. 网络服务器缓存

b. 应用程序缓存

c. 内容分发网络(CDN)

答案:c

进一步阅读

你可以在此处了解更多有关缓存的信息:

第三部分:开发企业应用程序

在本部分中,我们将开发应用程序的不同层。我们将从数据层开始,然后开发 API 和 Web 层。在开发过程中,我们将集成在第二部分中开发的用于跨领域关注的库。

本部分包括以下章节:

  • 第九章**,在 .NET 6 中处理数据

  • 第十章**,创建 ASP.NET Core 6 Web API

  • 第十一章**,创建 ASP.NET Core 6 Web 应用程序

第九章:第九章:在 .NET 6 中处理数据

任何应用程序的一个基本组件是将数据持久化到永久数据存储的能力;在选择合适的持久化存储时进行一些前瞻性思考可以帮助系统在未来更好地扩展。

任何应用程序中常见的操作之一是登录系统,执行一些读取/更新操作,注销,然后稍后再回来查看是否保留了更改。数据库在持久化这些通常称为 用户事务 的操作中发挥着重要作用。除了事务数据外,为了监控和调试目的,应用程序可能还需要存储日志数据和审计数据,例如谁修改了日期。设计任何此类应用程序的一个重要步骤是理解需求并相应地设计数据库。选择/设计数据库时,还必须考虑各种数据保留要求和任何数据保护政策,例如 通用数据保护条例GDPR)。

一个应用程序可以有多个数据提供者,例如结构化查询语言(SQL)数据提供者、NoSQL 数据提供者和文件数据提供者。在本章中,我们将讨论可用于 .NET 6 中存储和数据处理的多种数据提供者。我们将涵盖以下主题:

  • 数据简介

  • 磁盘、文件和目录

  • SQL、Azure Cosmos DB 和 Azure Storage

  • 使用 EF Core 进行操作

  • 使用 Azure Cosmos DB 设计数据访问服务

技术要求

需要基本了解 .NET Core、C#、Azure 和 .NET 命令行界面CLI)。

本章的代码文件可以在以下链接找到:github.com/PacktPublishing/Enterprise-Application-Development-with-C-10-and-.NET-6-Second-Edition/tree/main/Chapter08.

代码的说明可以在这里找到:github.com/PacktPublishing/Enterprise-Application-Development-with-C-10-and-.NET-6-Second-Edition/tree/main/Enterprise%20Application.

数据简介

任何网络应用程序,无论是内容管理系统、社交网络平台还是电子商务应用程序,都需要将数据持久化到永久存储中,以便用户可以按需检索、消费和处理数据。在第八章,“所有你需要知道的缓存知识”中,我们讨论了使用缓存存储;然而,缓存存储是临时存储,数据仍然需要持久化在永久存储中。因此,我们需要一个不仅支持对各种实体进行创建/读取/更新/删除CRUD)操作,而且支持高可用性并在出现故障时恢复任何数据的存储,即灾难恢复。

优秀系统设计的关键标准之一是在系统设计的早期阶段就设计数据模型。数据模型应尝试定义系统运行和实体间交互所需的所有可能的实体。在系统设计早期定义数据模型有助于确定如何管理数据以及可以使用哪种数据存储,以及决定各种复制/分区策略。

在接下来的章节中,将解释两种常见的数据存储分类。

关系型数据库管理系统(RDBMS)

关系型数据库将数据存储在表中。每个实体被定义为一张或多张表,数据库通过多张表来定义。将表分割成多张表的过程称为规范化。各种表之间的关系通过外键约束来定义。实体的属性定义为列,相同类型的多个实体存储为行。一些常用的关系型数据库包括 Microsoft SQL Server、MySQL、PostgreSQL 和 Oracle。

一个典型的用于存储员工信息的关系型数据库可能有一个employee表,定义员工的各项属性,如姓名、员工 ID 等,以及以员工 ID 作为主键的列。多个员工以单独的行存储在这个表中。员工的任何属性都可以进一步规范化到单独的表中;例如,员工的工程项目可以存储在单独的表中(因为可能有多个项目),比如employeeproject,并且可以通过员工 ID 与employee表链接,如下面的图所示:

图 9.1 – 员工实体关系图

图 9.1 – 员工实体关系图

以下是一些关系型数据库的关键特性:

  • 关系型数据库使用 SQL 进行查询。

  • 表通常具有明确的模式和约束,并且不太可能发生变化。

  • 所有交易都具有原子性/一致性/隔离性/持久性ACID)属性,因此维护数据完整性和一致性。

  • 由于数据已规范化,冗余最小化。

  • 关系型数据库通常支持垂直扩展,即向上扩展(它们确实支持复制,但与 NoSQL 数据库中的复制相比,这是一个昂贵的操作)。

NoSQL

另一种类型的数据存储是 NoSQL 数据库,它们以非结构化格式存储数据,其中数据不需要有预定义的模式。最常见的是,数据以键值对的形式存储(例如在 Redis 中),以文档的形式存储(例如在 MongoDB 和 CouchDB 中),或使用图结构存储为图(例如在 Neo4j 中)。

如果我们将相同的员工示例持久化到 NoSQL 数据库中,例如 MongoDB,我们最终会在一个名为employee的集合中存储它,每个文档存储员工的全部属性,如下所示:

{
  "employee": [
    {
      "employeeid": 1,
      "name": "Ravindra",
      "salary": 100,
      "Projects": [
        {
          "id": 1,
                  "name": "project1",
        },
        {
                     "id": 2,
                  "name": "project2",
        }
      ]
    }
  ]
}

以下是一些 NoSQL 数据库的关键特性:

  • 实体不一定需要支持固定的模式,并且在任何时间点都可以添加额外的属性。

  • 它们非常适合非结构化数据,例如,在共享出行应用中存储位置。

  • 它们可以以比关系型数据库低得多的成本轻松支持水平扩展。

  • 数据高度冗余;然而,这提供了显著的性能提升,因为数据可以随时使用,而无需在表之间执行连接操作。

Azure Cosmos DB 是我们将在我们的电子商务应用程序中用作数据存储的一种云管理的 NoSQL 数据库。

让我们在下一节中详细查看各种存储选项。

SQL、Azure Cosmos DB 和 Azure Storage

之前,我们讨论了将数据存储更广泛地分类为 RDBMS 和 NoSQL。在本节中,让我们深入了解 Microsoft 生态系统中的一些数据提供者及其与.NET 6 的集成。提供者的种类繁多,包括 SQL、Azure Cosmos DB 和 Azure Storage,数据提供者的选择完全由应用程序需求驱动。然而,在现实生活中,应用程序需求变化很大,因此关键是使用业务层和用户界面UI)来抽象数据框架实现,这有助于根据需要演进设计。有了这个,让我们在下一节中看看我们的第一个数据提供者,SQL。

SQL Server

在 RDBMS 市场中占主导地位的一个数据库是微软的 SQL Server,通常被称为 SQL Server,它使用 SQL 与数据库交互。SQL Server 支持所有基于 RDBMS 的实体,如表、视图、存储过程和索引,并且主要在 Windows 环境中运行。然而,从 SQL Server 2017 开始,它支持 Windows 和 Linux 环境。

SQL Server 的主要组件是其数据库引擎,负责处理查询和管理文件中的数据。除了数据库引擎之外,SQL Server 还附带各种数据管理工具,如下所示:

  • SQL Server 管理工具SSMS):用于连接到 SQL Server 并执行创建数据库、监控数据库、查询数据库和备份数据库等操作

  • SQL Server 集成服务SSIS):用于数据集成和转换

  • SQL Server 分析服务SSAS):用于数据分析

  • SQL Server 报表服务SSRS):用于报表和可视化

要在本地计算机上配置 SQL Server,我们需要安装 SQL Server 的一个版本,该版本安装数据库引擎和一个或多个先前组件。安装通常涉及下载安装程序并通过图形用户界面GUI)或命令行进行安装。有关安装的更多详细信息,请参阅docs.microsoft.com/en-us/sql/database-engine/install-windows/install-sql-server?view=sql-server-ver15

小贴士

SQL Server 还有其他版本,如开发者版和 Express 版,这些版本轻量级且免费,可以从www.microsoft.com/en-in/sql-server/sql-server-downloads下载。

虽然 SQL Server 在本地环境中已被广泛使用,但管理数据库、升级等始终存在开销,这就是微软推出了 Azure SQL 的原因,它是一个完全托管的平台即服务PaaS)组件,在相同的数据库引擎上运行,就像本地 SQL Server 一样。

Azure SQL 提供了以下变体:

  • Azure SQL 数据库(单数据库):这是一个托管数据库服务器,允许您创建一个完全隔离的数据库,并拥有专用资源。

  • Azure SQL 数据库(弹性池):弹性池允许您在单个服务器上运行多个单数据库,这些数据库位于预定义的资源池中(就 CPU、内存和输入/输出I/O)而言)。这对于拥有多个数据库且使用量高低不一的企业来说非常理想。在这种情况下使用弹性池的优势在于,需要更多 CPU 使用的数据库可以在需求高峰时使用它,在需求低时释放它。使用弹性池的理想情况是,有一组数据库,其消耗量不可预测。任何时候,如果您看到数据库持续消耗相同的一组资源,它可以从弹性池移动到一个单独的数据库,反之亦然。

  • Azure SQL 托管实例:此模型提供了一种无缝迁移本地 SQL 基础设施到 Azure SQL 的方法,无需重新架构本地应用程序,并允许您利用平台即服务(PaaS)。这对于拥有庞大的本地数据库基础设施并需要迁移到云而无需太多运营开销的应用程序来说非常理想。

  • 虚拟机上的 SQL Server (Windows/Linux):SQL 虚拟机属于 基础设施即服务IaaS)类别,与本地 SQL Server 非常相似,只是虚拟机位于 Azure 而不是您的本地网络中。

    小贴士

    建议安装 SSMS 以执行 SQL Server(本地或云)的各种操作,因为它支持所有数据库操作。还有 Azure Data Studio,它轻量级,可以连接到本地或云 SQL Server,并且可以从 docs.microsoft.com/en-us/sql/azure-data-studio/download-azure-data-studio?view=sql-server-ver15 下载。

从 .NET 6 应用程序的角度来看,连接到 Azure SQL 与连接到本地 SQL Server 相同。您可以使用 ADO.NET,我们通过 System.Data.SqlClient 导入它,然后使用 SqlConnection 对象连接到 SQL;然后,使用 SqlCommand 对象执行 SQL 查询,并使用 SQLReader 类返回值。除此之外,我们还可以使用 对象关系映射ORM)例如 Entity Framework CoreEF Core)来与 Azure SQL 一起工作,这在 使用 EF Core 部分中进行了讨论。

因此,在本节中,我们简要介绍了 Azure SQL。然而,我建议在这里查看 Azure SQL 的所有功能:docs.microsoft.com/en-us/azure/azure-sql/。更多示例请参阅 github.com/microsoft/sql-server-samples

因此,让我们继续了解 Azure Cosmos DB,这是我们电子商务应用将用作持久存储的数据库。

Azure Cosmos DB

Azure Cosmos DB 是一个完全托管的(PaaS)NoSQL、全球分布式的、高度可扩展的数据库。Azure Cosmos DB 的一个关键特性是其多模型特性,这有助于使用不同的 API 模型,如 SQL、MongoDB 和 Gremlin,以不同的格式传递数据,例如 JSON 和 BSON。开发者可以根据自己的舒适度使用 API 来查询数据库。例如,SQL 开发者可以继续使用 SQL 查询语法查询数据库,MongoDB 开发者可以继续使用 MongoDB 语法查询数据库,等等。在底层,Azure Cosmos DB 以称为 原子记录序列ARS)的格式存储数据库,并根据在创建数据库时选择的模式暴露数据作为 API。

Azure Cosmos DB 的另一个重要特性是其能够自动索引所有数据,而不管使用的是哪种 API 模型。所有这些操作都无需开发者额外创建索引,从而实现数据的快速检索。

Azure Cosmos DB 支持以下 API 来执行数据库操作,我们在创建数据库时选择这些 API:

  • SELECT * FROM product WHERE product.Name = 'Mastering enterprise application development Book'

  • db.product.find({"Name": 'Mastering enterprise application development Book'})。就像 MongoDB 一样,数据以 BSON 表示。

  • Gremlin(图)API:此 API 支持使用 Gremlin 语言查询和遍历以图格式表示的数据。这在数据可以表示为图形式并通过其关系进行查询的情况下非常理想。一个典型的例子是一个推荐引擎,它可以建立两个实体之间的关系并提出推荐。

此外,还有 Cassandra API,它使用Cassandra 查询语言CQL)在数据库上操作,然后是 Table API,它可以用作构建在 Azure 表存储之上的应用程序的数据存储。

如您所见,有相当多的 API,并且还有更多正在添加。选择正确的 API 完全取决于应用程序需求;然而,以下几点可以用来缩小选择范围:

  • 如果是新的应用程序,请选择核心(SQL)API。

  • 如果它是一个基于 NoSQL 的现有应用程序,请根据底层数据存储选择相关的 API。例如,如果现有数据库是 MongoDB,请选择 Mongo API 等。

  • 对于处理特定场景,例如建立数据之间的关系,请使用 Gremlin API。

对于我们的企业应用程序,由于我们是从头开始构建此应用程序的,因此我们将选择核心(SQL)API 作为与 Azure Cosmos DB 交互的 API。

让我们从创建一个简单的控制台应用程序开始,并在 Azure Cosmos DB 上执行一些操作,我们稍后将在构建我们的数据访问服务时重用这些概念:

  1. 首先,我们需要有一个 Azure Cosmos DB 账户,因此登录到 Azure 门户,点击创建资源,然后选择数据库 | Azure Cosmos DB

  2. 这将打开创建 Azure Cosmos DB 账户页面。填写以下截图所示的详细信息,然后点击审查 + 创建。这是我们选择要选择的 API 的页面,在我们的情况下是核心(SQL)API:

图 9.2 – 创建 Azure Cosmos DB 账户页面

图片

图 9.2 – 创建 Azure Cosmos DB 账户页面

  1. 账户创建完成后,导航到Azure Cosmos DB 账户 | 密钥。复制URIPRIMARY KEY值。

  2. 打开命令行,使用以下命令创建控制台应用程序:

    dotnet new console --framework net6.0 --name EcommerceSample
    
  3. 导航到 EcommerceSample 文件夹,并使用以下命令安装 Azure Cosmos DB SDK:

    dotnet add package Microsoft.Azure.Cosmos -s https://api.nuget.org/v3/index.json
    
  4. 在此阶段,我们可以将文件夹在 VS Code 中打开。一旦我们在 VS Code 中打开文件夹,它将看起来如下所示:

图 9.3 – VS Code 中的 EcommerceSample

图片

图 9.3 – VS Code 中的 EcommerceSample

  1. 打开 Program.cs 文件,并将以下静态变量添加到 Program 类中,这些变量将保存 步骤 3 中复制的 URI主键 值:

    string Uri = "YOUR URI HERE ";
    string PrimaryKey = "YOUR PRIMARY KEY HERE";
    
  2. 现在,让我们添加代码来创建 CosmosClient 类的实例,并使用它来创建 Azure Cosmos DB 数据库。随后,此对象将被用于与我们的 Azure Cosmos DB 数据库进行通信。由于 CosmosClient 实现了 IDisposable 接口,我们将在 using 块内创建它,以便对象可以在 using 块之后自动释放。一旦运行此代码并导航到 Ecommerce,就会创建数据库。由于我们使用 Core (SQL) API 创建了 Azure Cosmos DB 账户,因此此数据库将支持使用 SQL 语法进行查询:

    using (CosmosClient cosmosClient = new CosmosClient(Uri,
     PrimaryKey))
    {
     DatabaseResponse createDatabaseResponse
    = await cosmosClient.CreateDatabaseIfNotExistsAsync
    ("ECommerce");
     Database database = createDatabaseResponse.Database;
    }
    
  3. 现在,让我们在 createDatabaseResponse 之后添加代码来创建一个类似于 SQL 表的容器。由于我们正在使用 CreateDatabaseIfNotExistsAsync 来创建数据库,运行相同的代码不会引发任何异常:

    var containerProperties = new ContainerProperties
    ("Products", "/Name");
    var createContainerResponse = await 
    database.CreateContainerIfNotExistsAsync(
    containerProperties, 10000); 
    var productContainer = createContainerResponse.
    Container;
    

一旦运行此代码,我们可以在 Azure 门户中看到在 Ecommerce 数据库下创建了一个名为 Products 的容器:

图 9.4 – 产品容器

图 9.4 – 产品容器

容器是 Azure Cosmos DB 中的一个单元,它在多个区域中进行水平分区和复制。在前面的代码中,我们在创建容器时传递了 ContainerProperties,你可以看到其中一个值是 Name,这实际上就是一个分区键。

分区是 Azure Cosmos DB 的一个关键特性,它根据分区键将容器内的数据分割成多个逻辑分区,即具有相同分区键的所有项都属于同一个逻辑分区。使用分区键,Azure Cosmos DB 实现了数据库的水平扩展,因此满足了应用程序的可扩展性和性能需求。

选择分区键是一个关键的设计决策,因为它将极大地帮助数据库进行扩展并提高性能。此外,分区键不能更改,必须在创建容器时定义。在选择分区键时,以下几点可以牢记在心:

  • 它应该具有最大数量的唯一值;唯一值的数量越多,分区效果越好。例如,如果我们正在创建一个用于产品的容器,产品 ID 或名称可以作为分区键,因为这两个属性可以唯一标识大多数产品。在底层,如果选择产品名称作为分区键,并且内部有 100 个产品,那么在 Azure Cosmos DB 中将表示为 100 个逻辑容器。在这里,产品类别也可以作为分区键,但在将其作为分区键选择之前,我们需要评估样本数据并根据需求做出决定。

  • 如果没有明显的唯一选择,我们可以选择在过滤查询中最常用的字段,所以基本上是一个在where子句中经常使用的列。

    小贴士

    在实际应用中,创建 Azure Cosmos DB 账户应使用 ARM 模板或使用 Terraform 实现,这样模板可以轻松地与持续部署CD)集成。

基于此,让我们向我们的产品容器添加一些数据并查询它:

  1. 我们将根据以下示例 JSON 添加此实体。根据产品类别,可能会有不同的属性。

例如,如果产品类别是Books,则AuthorsFormat等字段中会有值;然而,如果类别是Clothing,则SizeColor等字段会有值。这种模式可以在我们的电子商务应用程序中重用:

{
  "Id": "Book.1",
  "Name": "Mastering enterprise application
      development Book",
  "Category": "Books",
  "Price": 100,
  "Quantity": 100,
  "CreatedDate": "20-02-2020T00:00:00Z",
  "ImageUrls": [],
  "Rating": [
    {"Stars": 5, "Percentage": 95},
    {"Stars": 4, "Percentage": 5}
  ],
  "Format": ["PDF","Hard Cover"],
  "Authors": ["Rishabh Verma","Neha Shrivastava",
      "Ravindra Akela","Bhupesh Guptha"],
  "Size": [],
  "Color": []
}
  1. 现在,让我们创建Product。在 Azure Cosmos DB 的 Core (SQL) API 中,任何实体的必填字段之一是id字段,它类似于主键。因此,对于我们的父模型来说,定义id字段是必要的。这些类看起来如下所示:

    public class Rating{
        public int Stars { get; set; }
        public int Percentage { get; set; }
    }
    public class Product{
        [JsonProperty(PropertyName = "id")]
        public string ProductId { get; set; }
        public string Name { get; set; }
        public string Category { get; set; }
        public int Price { get; set; }
        public int Quantity { get; set; }
        public DateTime CreatedDate { get; set; }
        public List<string>  ImageUrls { get; set; }
        public List<Rating> Rating { get; set; }
        public List<string> Format { get; set; }
        public List<string> Authors { get; set; }
        public List<int> Size { get; set; }
        public List<string> Color { get; set; }
    }
    
  2. 现在,让我们创建以下Product类的对象并将其插入到数据库中:

    Product book = new Product()
    {
        ProductId = "Book.1", Category = "Books", Price =
        100,
        Name = "Mastering enterprise application
        development Book",                    
        Rating = new List<Rating>() { new Rating { Stars =
        5, Percentage = 95 }, new Rating { Stars = 4,
        Percentage = 5 } },
        Format = new List<string>() { "PDF", "Hard Cover" 
        },
        Authors = new List<string>() { "Suneel", "Arun", 
          "Ravindra", "Bhupesh" }
    };
    
  3. 现在,我们将使用productContainer对象调用CreateItemAsync方法,如下面的代码片段所示。(还有其他从数据库检索记录的方法,其中一种将在下一点中展示。)我们还应该确保没有已经存在具有相同ProductId值的对象:

    try
    {
        // Check if item it exists.  
        ItemResponse<Product> productBookResponse = await 
          productContainer.ReadItemAsync<Product>(
          book.ProductId, new PartitionKey(book.Name));
    }
    catch (CosmosException ex) when (ex.StatusCode == System.Net.HttpStatusCode.NotFound)
    {
        ItemResponse<Product> productBookResponse = await 
          productContainer.CreateItemAsync<Product>(book, 
          new PartitionKey(book.Name));
        Console.WriteLine($"Created item 
          {productBookResponse.Resource.ProductId}");
    }
    

一旦我们运行此代码,数据应该会被插入到Ecommerce数据库下的Products容器中。

  1. 如果我们想要以不同于前一点提到的方式查询此记录,我们可以使用以下代码来查询数据库。正如您所看到的,语法与从 SQL 数据库查询数据非常相似:

    string getAllProductsByBooksCAtegory = "SELECT * FROM p WHERE p.Category = 'Books'";
    QueryDefinition query = new QueryDefinition(getAllProductsByBooksCAtegory);
    FeedIterator<Product> iterator = productContainer.GetItemQueryIterator<Product>(query);
    while (iterator.HasMoreResults)
    {
        FeedResponse<Product> result = await 
          iterator.ReadNextAsync();
        foreach (Product product in result)
        {
            Console.WriteLine($"Book retrived –
            {product.Name}");
        }
    }
    

同样,ContainerClass提供了可用于各种 CRUD 操作的所有相关方法。所有这些 API 都可以在以下位置找到:docs.microsoft.com/en-us/dotnet/api/microsoft.azure.cosmos.container?view=azure-dotnet

在这个基础上,我们将设计电子商务应用程序所需的数据模型和相关数据服务层,以便由各种 API 使用。到目前为止,我们已经看到了 SQL 和 NoSQL 提供商。让我们看看我们还有哪些其他选项来持久化数据。

Azure 存储

Azure Storage 是一个高度可用和可扩展的数据存储,支持以各种格式存储数据,包括文件。主要来说,Azure Storage 支持以下四种类型的数据:

  • Azure Table:一种支持持久化无模式数据的 NoSQL 实现。

  • Azure Blob:Blob 是适合于需要上传、下载或流式传输大量文件的未结构化数据。

  • Azure 队列:这允许您以任何可序列化的格式排队消息,然后由服务进行处理。队列非常适合具有大量服务间通信的场景,并作为消息的持久层。

  • Azure 文件/Azure 磁盘:一个文件数据存储,非常适合基于本地文件 API 构建的系统。

以下是一些使 Azure 存储成为应用程序开发重要组件的几个要点:

  • 高可用性:存储在 Azure 存储中的数据提供了数据中心/区域之间的复制支持,这进一步确保了某个区域的硬件故障不会导致数据丢失。

  • 性能:开箱即用的 CDN 集成支持,有助于从更靠近用户的位置(边缘服务器)缓存和加载数据(尤其是静态文件),从而进一步提高性能。此外,存储类型可以升级到高级存储,利用 SSD 进一步加快磁盘 I/O 并提高性能。

  • 完全托管:硬件由 Azure 完全管理,包括任何更新/维护。

  • 安全性:所有存储在磁盘上的数据都进行了加密,Azure 存储中对数据的访问进一步支持私有、公共和匿名模式。

  • 按量付费:与所有其他 Azure 服务一样,Azure 存储也支持基于数据量/操作的按量付费模式。

Azure 存储帐户

让我们创建一个简单的控制台应用程序,将文件上传到 Blob 并从 Blob 下载文件。要与 Azure 存储服务通信,先决条件是创建一个 Azure 存储帐户,该帐户提供对所有 Azure 存储服务的访问,并通过一个唯一的命名空间让我们能够通过 HTTP/HTTPS 访问 Azure 存储中存储的数据。要创建 Azure 存储帐户,请执行以下步骤:

  1. 登录 Azure 门户,点击创建资源,然后选择存储帐户。这将打开创建存储帐户页面。填写以下屏幕截图中显示的详细信息,然后点击审查 + 创建

图 9.5 – 创建 Azure 存储帐户

图 9.5 – 创建 Azure 存储帐户

对于标准层,有两个重要属性帐户类型复制。对于帐户类型,我们有以下可能的值:

  • StorageV2(通用 v2):帐户类型的最新版本,提供对所有存储类型的访问,例如文件、块和队列。这对于新创建的存储帐户来说更可取。

  • 存储(通用 v1):帐户类型的较旧版本,提供对所有存储类型的访问,例如文件、块和队列。

  • BlobStorage:仅支持 blob 存储的帐户类型。

另一个是复制,它支持在数据中心/区域之间复制存储数据。可能的值在以下屏幕截图中显示:

图 9.6 – Azure 存储账户中的复制选项

图 9.6 – Azure 存储账户中的复制选项

  1. 一旦创建账户,导航到存储账户 | 密钥。复制连接字符串值。

  2. 创建一个新的.NET 6 控制台应用程序并安装Azure.Storage.Blobs NuGet 包。

  3. 要将内容上传到 Azure 存储,我们首先需要创建一个容器。我们将使用Azure.Storage.Blobs.BlobContainerClient类及其CreateIfNotExistsAsync方法来创建容器(如果不存在)。有了这个,更新Program类,如下面的代码片段所示:

       string connectionString = «CONNECTION_STRING";
       string containerName = «fileuploadsample";
       string blobFileName = «sample.png";
       // Upload file to blob            
       BlobContainerClient containerClient = new 
       BlobContainerClient(connectionString, 
       containerName);
       await containerClient.CreateIfNotExistsAsync(
         PublicAccessType.None);//Making blob private.
    
  4. 接下来,我们需要将文件上传到容器,我们将使用Azure.Storage.Blobs.BlobClient,它接受连接字符串、容器名称和 blob 名称作为输入参数。对于此示例,我们将上传一个本地的sample.png文件到 blob,我们将使用FileStream类读取它,并将其传递给Azure.Storage.Blobs.BlobClient类的UploadAsync方法。在Main方法中容器创建后添加以下代码片段:

    BlobClient blobClient = new BlobClient(connectionString, 
    containerName, blobFileName);
    using FileStream fileStream = File.OpenRead(blobFileName); // blobFileName is relative path of the file.
    await blobClient.UploadAsync(fileStream, true);
    fileStream.Close();
    Console.WriteLine(blobClient.Uri.ToString());
    

在此阶段运行示例将文件上传到 blob,并在命令行中显示 blob URL。然而,如果我们尝试访问 URL,由于创建的 blob 是私有的,它将无法访问。要访问私有 blob,我们需要生成一个Main方法:

BlobSasBuilder sasBuilder = new BlobSasBuilder()
{
    BlobContainerName = containerClient.Name,
    Resource = "b", // c for container
    BlobName = blobClient.Name
};
sasBuilder.ExpiresOn = DateTimeOffset.UtcNow.AddHours(1); // Setting expiry time of the SAS link to 1 hour
sasBuilder.SetPermissions(BlobContainerSasPermissions.Read);
if (blobClient.CanGenerateSasUri)
{
    Uri blobSasUri = 
      blobClient.GenerateSasUri(sasBuilder);
    Console.WriteLine(blobSasUri.ToString());
}
Console.ReadLine();

在这里,我们使用Azure.Storage.Sas.BlobSasBuilder类来配置各种参数,例如权限和过期时间,以生成上传文件的 SAS URI。最后,前面代码的输出如下所示:

图 9.7 – Blob 上传输出和存储资源管理器

图 9.7 – Blob 上传输出和存储资源管理器

这是一个使用 Azure Storage 进行文件上传的小示例。这可以进一步扩展为一个 API,最终可用于文件上传和下载场景。对于我们的电子商务应用程序,我们将使用 Azure Blob 来存储产品的图片。

注意

关于 Azure 存储的更高级概念和示例,请参考以下链接:

docs.microsoft.com/en-us/azure/storage/common/storage-account-overview

github.com/Azure/azure-sdk-for-net/tree/master/sdk/storage/Azure.Storage.Blobs/samples

在本节中,我们讨论了.NET 6 中可用的各种数据提供者。然而,一个简化数据持久化的重要库是 EF。让我们看看如何在.NET 6 应用程序中集成 EF。

使用 EF Core

EF Core 是一个 ORM,它被推荐用于任何使用关系数据库作为数据存储的 ASP.NET Core 6 应用程序。之前,我们看到了在 ADO.NET 中,我们必须创建ConnectionCommandReader对象。EF 通过提供抽象并允许开发者编写应用程序代码来简化这个过程,并且像任何其他 ORM 一样,EF 帮助开发者使用对象模型范式在数据库上执行各种操作。

配置 EF Core 就像安装所需的 NuGet 包,在Program类中注入所需的服务,然后在需要的地方使用它们。在这个过程中,需要定义的一个关键类是数据库上下文,它需要继承Microsoft.EntityFrameworkCore.DbContext类。让我们看看我们如何做到这一点,以及剩余的 EF Core 配置。

配置和查询

EF Core 中的DbContext类包含我们应用程序与数据库通信所需的所有抽象,因此集成 EF Core 的关键设置之一是定义我们的应用程序特定上下文类。这个类将主要以DbSet类型的公共属性的形式持有所有 SQL 表/视图,如下面的代码所示:

public virtual DbSet<Employee> Employees { get; set; }

在这里,Employee是代表我们数据库中表的 POCO 类。应用程序上下文类应该有一个参数化构造函数,它接受DbContextOptionsDbContextOptions<T>并将其传递给基类。

让我们基于 Razor Pages 和 SQLite 创建一个简单的 Web 应用程序,并使用 EF Core 读取数据。对于这个示例,我们将使用 SQLite 作为数据存储,选择一个简单的员工数据库,其中包含以下数据模型:

图 9.8 – 员工数据库模型

图 9.8 – 员工数据库模型

如果你之前没有使用过 Razor Pages,不要担心;它是一个基于页面的框架,可以用于在 ASP.NET Core 6 中构建数据驱动应用程序,并在第十一章中介绍,即创建 ASP.NET Core 6 Web 应用程序

现在,按照以下步骤创建我们的应用程序:

  1. 使用以下命令从命令行创建一个新的 Razor Pages 应用程序,这将创建一个名为EmployeeEF的新 Razor Pages 应用程序:

    dotnet new webapp --framework net6.0 --name EmployeeEF
    
  2. 导航到EmployeeEF文件夹,并在 Visual Studio Code 中打开它,然后安装以下 NuGet 包:

    • Microsoft.EntityFrameworkCore.Sqlite

    • Microsoft.EntityFrameworkCore.Design

前一个包是 SQLite 的 EF Core 提供程序,后一个包用于使用 EF Core 迁移创建基于 C# POCOs 的数据库。

  1. 现在,添加Models文件夹,并按照以下方式添加必要的 POCO 类。这些类代表来自图 9.8的数据库模式:

        public class Address
        {
         public int AddressId { get; set; }
         public int EmployeeId { get; set; }
         public string City { get; set; }
         public Employee Employee { get; set; }
        }
        public class Employee
        {
         public int EmployeeId { get; set; }
         public string Name { get; set; }
         public string Email { get; set; }
         public ICollection<Address> Address { get; set; }
        }   
    
  2. 在这里,数据库表中的所有列都表示为具有相关数据类型的属性。对于如外键这样的关系,会在子类型中创建一个属性(称为 ICollection),同时在父类类型中创建另一个属性。例如,在前面的代码中,这通过 public Icollection<Address> Addressespublic Employee Employee 属性表示,这些属性定义了 EmployeeAddress 表之间的外键约束。任何名为 ID<class name>ID (EmployeeID) 的属性都会自动被认为是主键。可以在 OnModelCreating 期间使用 Fluent API 或在 System.ComponentModel.DataAnnotations 中使用注解进一步定义约束。有关模型创建的更多示例和详细信息,请参阅 docs.microsoft.com/en-us/ef/core/modeling

  3. 添加一个继承自 Microsoft.EntityFrameworkCore.DbContext 的类,并将其命名为 EmployeeContext。添加以下代码以定义我们的数据库上下文:

        public class EmployeeContext : DbContext
        {
            public DbSet<Employee> Employees { get; set;}
            public DbSet<Address> Addresses { get; set;}
             public EmployeeContext (DbContextOptions
             <EmployeeContext> options)
                : base(options)
            {}
            protected override void OnModelCreating
            (ModelBuilder modelBuilder)
            {
                modelBuilder.Entity<Employee>().ToTable
                ("Employee");
                modelBuilder.Entity<Address>().ToTable
                ("Address");
            }
        }
    
  4. appsettings.json 中添加连接字符串。由于我们使用 SQLite,指定数据源中的文件名应该足够。但是,这会根据提供程序而变化:

      "ConnectionStrings": {
        "EmployeeContext": "Data Source=Employee.db"
      }
    
  5. 现在,在 Program 类中注入数据库上下文类,以便在整个应用程序中可用。在这里,我们还传递连接字符串并配置任何其他选项,例如重试策略和查询记录:

    builder.Services.AddDbContext<EmployeeContext>(options =>
    { 
     options.UseSqlite(builder.Configuration.GetConnectionString("EmployeeContext"));
    });
    

我们几乎完成了 EF Core 的设置。因此,现在让我们创建一些可以用于填充数据库的示例数据。

  1. 为了这个目的,我们将在我们的数据库上下文中创建一个扩展方法并在启动时调用它。创建一个名为 DbContextExtension 的静态类,并向其中添加以下代码。此代码只是向数据库中添加一些记录:

        public static void SeedData(this EmployeeContext
        context)
        {
            SeedEmployees(context);            
        }
        private static void SeedEmployees(EmployeeContext
        context)
        {
            if (context.Employees.Any())
            {
                return;
            }
            var employees = new Employee[]
            {
                new Employee{EmployeeId = 1, Name =
                "Sample1", Email="Sample@sample.com"},
                new Employee{EmployeeId = 2, Name =
                "Sample2", Email="Sample2@sample.com"},
                new Employee{EmployeeId = 3, Name =
                "Sample3", Email="Sample3@sample.com"}
             };
            context.Employees.AddRange(employees);
            var adresses = new Address[]
            {
             new Address{AddressId = 1, City = "City1",
             EmployeeId = 1},
             new Address{AddressId = 2, City = "City2",
             EmployeeId = 1},
             new Address{AddressId = 3, City = "City1",
             EmployeeId = 2},
            };
            context.Addresses.AddRange(adresses);
            context.SaveChanges();
        }
    
  2. 打开 Program 类并添加以下代码,该代码在应用程序启动时填充数据。由于这是开发环境,我们可以检查环境是否为开发环境并添加它。由于我们在插入之前检查员工表中的内容,因此应用程序的多次运行不会覆盖数据:

    using (var serviceScope = ((IApplicationBuilder)app).ApplicationServices?.GetService<IServiceScopeFactory>()?.CreateScope())
        {
          using (var context = 
            serviceScope?.ServiceProvider
            .GetRequiredService<EmployeeContext>())
          {
            context?.SeedData();
          }
        }
    
  3. 现在,在 VS Code 终端中运行 dotnet build 以修复任何构建错误。为了从我们的模型生成数据库并填充数据库,我们需要在本地或全局安装 dotnet-ef 并在 VS Code 终端中运行迁移命令,如下所示,这将生成 Migrations 文件夹,然后是 Employee.db 文件,这是我们的 SQLite 数据库:

    dotnet tool install --global dotnet-ef --ignore-failed-sources //Installing dotnet ef.
    dotnet ef migrations add InitialCreate //Generate DB migrations.
    dotnet ef database update //Update database.
    
  4. 现在,要读取 Employee 表,导航到 Index.cshtml.cs 并粘贴以下代码。在这里,我们注入 EmployeeContext 并从员工表中读取数据:

    public class IndexModel : PageModel
        {
            private readonly EmployeeContext context;
            public IndexModel(EmployeeContext context)
            {
                this.context = context;
            }
             public Ilist<Employee> Employees { get; set; 
             }
             public async Task OnGetAsync()
            {
                this.Employees = await this.context.
                Employees.Include(x => x.Address).
                AsNoTracking().ToListAsync();
            }
        }
    
  5. 使用以下代码更新 Index.cshtml,该代码遍历 IndexModelEmployees 属性中填充的员工记录并显示它们:

    <table class="table">
    <tbody>
        @foreach (var item in Model.Employees)
        {<tr>
                <td>@Html.DisplayFor(modelItem =>
                item.EmployeeId)</td>
                <td>@Html.DisplayFor(modelItem =>
                item.Name)</td>
                <td>@Html.DisplayFor(modelItem =>
                item.Email)</td>
                <td>
                    @foreach (var address in item.Address)
                    {
                        @Html.DisplayFor(modelItem =>
                        address.City) @Html.DisplayName("
                         ")
                    }
                </td>
            </tr>
        }
    </tbody>
    </table>
    

一旦运行此代码,我们可以在浏览器中看到以下输出:

图 9.9 – 员工应用输出

图 9.9 – 员工应用输出

类似地,DbContext 类中还有其他可用方法,例如 Add()Remove()Find(),用于执行各种 CRUD 操作,以及 FromSqlRaw() 方法用于执行原始 SQL 查询或存储过程。

这是一个非常简单的例子,其主要目的是展示 EF Core 在实际应用中的能力。我们可以使用存储库模式,其中包含所有 CRUD 方法的通用存储库和特定存储库来执行表上的专用查询。此外,可以使用工作单元模式进行事务处理。

代码优先与数据库优先

在前面的示例中,我们创建了新的 POCOs 并从它们中生成了一个数据库;这种从 POCOs 生成数据库的风格被称为 代码优先方法。正如定义所暗示的,我们首先定义了 POCOs,然后生成了数据库。

然而,很多时候,尤其是在迁移场景中或者有专门的数据库团队的情况下,我们需要从数据库表中生成 POCOs。EF Core 通过 数据库优先方法 支持这种场景,其中模型和应用程序数据库上下文类是从现有数据库生成的。

从数据库模型生成 POCOs 的这个过程被称为 Scaffold-DbContext 命令,它接受各种参数,例如数据库连接字符串和应用程序数据库上下文类的名称,然后生成所有必需的类,用于 EF Core。

其余的配置与代码优先方法保持一致。一个带有各种参数的示例脚手架命令如下所示:

Scaffold-DbContext "Data Source=.;Initial Catalog=Employee.DB;Trusted_Connection=True;" Microsoft.EntityFrameworkCore.SqlServer -Namespace Api.Data.Models -ContextNamespaceApi.Data -ContextDir Api.Data/Abstraction -Context EmployeeContext -Force

在这个命令中,我们正在读取一个数据库,Employee.DB,在 Namespace Api.Data.Models 内生成所有模型,在 Api.Data/Abstraction 内生成上下文,并将上下文命名为 EmployeeContext。在数据库优先方法中,类之间的关系使用 Fluent API 定义,而不是使用注解。

这里有一点需要注意,每次我们运行这个命令时,所有的 POCOs 都会被覆盖,包括应用程序上下文类。其次,这个命令生成一个包含 protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder) 方法的上下文类。只有当上下文类需要维护连接字符串和其他 EF Core 选项时,这个方法才是必需的。然而,在大多数实际应用中,连接字符串保存在 appsettings.json 中,EF Core 在 Program 类中配置,因此这个方法可以被删除。

这意味着每次我们使用 Scaffold 时都涉及清理,为了避免任何自定义,更好的方法是创建一个用于我们的应用程序数据库上下文的局部类,并在那里进行所有自定义,例如添加特定的存储过程模型或定义任何应用程序特定的约束。这样,每次我们 Scaffold 应用程序时,自定义都不会被覆盖,这仍然允许我们从数据库自动生成类。

选择数据库优先方法或代码优先方法是完全由开发团队决定的,因为两种方法都有优点和缺点,并且没有一种方法具有另一种方法所不具备的特定功能。

注意

Scaffold-DbContext 支持多个参数;例如,您可以为生成 POCOs 的架构指定一个模式。有关进一步阅读,请参阅 docs.microsoft.com/en-us/ef/core/managing-schemas/scaffolding?tabs=dotnet-core-cli

基于这种理解,让我们创建将在下一节中用于我们企业应用程序的数据访问服务。

使用 Azure Cosmos DB 设计数据访问服务

由于 NoSQL 数据库都是关于快速访问和高可扩展性,因此 NoSQL 的架构是非规范化的,因此数据冗余的可能性很高。让我们将我们从 第一章设计和架构企业应用程序,的需求映射到各种实体。以下图显示了架构中各种服务的快速回顾:

图 9.10 – 电子商务应用程序中的服务

](https://github.com/OpenDocCN/freelearn-csharp-zh/raw/master/docs/entp-appdev-csp10-dn6/img/Figure_9.10_18507.jpg)

图 9.10 – 电子商务应用程序中的服务

为了便于理解,在转向 POCOs 之前,我们将以 JSON 格式表示实体:

  • Email 字段用作分区键:

    {
      "Id": "1",
      "Name": "John",
      "Email": "John@xyz.com",
      "Address":[{"Address1":"Gachibowli","City":
          "Hyderabad","Country":"India"}],
      "PhoneNumber":12345
    }
    
  • Name 字段用作分区键。

  • Id 字段用作分区键:

    {
      "Id": "1",
      "UserId": "1",
      "Products": [{"Id":"1","Name":
          "T-Shirt","Quantity": 1,"Price": 10}],
      "OrderStatus" : "Processed",
      "OrderPlacedDate" : "20-02-2020T00:00:00Z",
      "ShippingAddress": {"Address1":"Gachibowli",
          "City":"Hyderabad","Country":"India"},
      "TrackingId": 1,
      "DeliveryDate":"28-02-2020T00:00:00Z"
    }
    
  • Id 字段用作分区键:

    {
      "Id": "1",
      "OrderId": "1",
      "PaymentMode": "Credit Card",
      "ShippingAddress": {"Address1":"Gachibowli",
         "City":"Hyderabad","Country":"India"},
      "SoldBy": {"SellerName": "Seller1",  "Email":
         "seller@ecommerce.com", "Phone": "98765432"},  
      "Products": [{"Id":"1", "Name": "T-Shirt", 
         "Quantity": 1, "Price": 10}]
    }
    

下面的截图显示了 ProductOrder 的组合:

图 9.11 – 电子商务数据库模型的 Product 和 Order 架构

图 9.11 – 电子商务数据库模型的 Product 和 Order 架构

如您所见,所有 1:N 关系都通过将子项作为数组嵌入来处理。同样,InvoiceUser 实体架构如图下截图所示:

图 9.12 – 电子商务数据库模型的 Invoice 和 User 架构

](https://github.com/OpenDocCN/freelearn-csharp-zh/raw/master/docs/entp-appdev-csp10-dn6/img/Figure_9.12_18507.jpg)

图 9.12 – 电子商务数据库模型的 Invoice 和 User 架构

在我们的企业应用程序中,github.com/PacktPublishing/Enterprise-Application-Development-with-C-10-and-.NET-6-Second-Edition/tree/main/Enterprise%20Application,我们将有一个服务与 Azure Cosmos DB 数据库进行交互。此服务包含以下三个项目,接下来将进行解释:

  • Packt.Ecommerce.Data.Models

  • Packt.Ecommerce.DataStore

  • Packt.Ecommerce.DataAccess

第一个项目是Packt.Ecommerce.Data.Models,这是一个.NET Standard 2.1 库,包含我们与数据库通信的所有 POCOs。如前所述,所有 POCOs 都将有一个共同的id属性和前一小节中 JSON 模式中描述的其他属性。

小贴士

如果有示例 JSON,我们可以在 C#类生成工具中使用 JSON。

Packt.Ecommerce.DataStore是一个.NET Standard 2.1 库,是存储库层,包含一个通用存储库和特定实体的存储库。此项目中的一个重要类是BaseRepository,它具有以下方法,并且每个方法都调用CosmosClient类的相应方法:

  • GetAsync(string filterCriteria): 此方法根据filterCriteria从容器中获取记录。如果filterCriteria为空,则检索该容器中的所有记录。

  • GetByIdAsync(string id, string partitionKey): 此方法通过 ID 和分区键从容器中检索任何记录。

  • AddAsync(Tentity entity, string partitionKey): 此方法允许我们将一条记录插入到容器中。

  • ModifyAsync(Tentity entity, string partitionKey): 此方法允许我们在容器中UPSERT(如果记录存在则修改,否则插入)一条记录。

  • RemoveAsync(string id, string partitionKey): 此方法允许从容器中删除一条记录。

由于在 Azure Cosmos DB 中,每个记录都由 ID 和分区键的组合唯一标识,因此所有这些方法都接受一个分区键和id。由于这是一个通用存储库,类的签名如下,这允许我们传递任何应用程序的 POCO 并对其对应的容器执行 CRUD 操作:

public class BaseRepository<TEntity> : IBaseRepository<TEntity>
where TEntity : class

所有这些方法都需要一个Microsoft.Azure.Cosmos.Continer的对象,我们创建一个readonly私有成员,并在类的构造函数中初始化,如下所示:

        private readonly Container container;
        public BaseRepository(CosmosClient cosmosClient,
        string databaseName, string containerName)
        {
            if (cosmosClient == null)
            {
                throw new Exception("Cosmos client is
                 null");
            }
            this.container = cosmosClient.GetContainer
            (databaseName, containerName);
        }

现在,CosmosClient将通过依赖注入集成到系统中,并在static类中进行配置。作为最佳实践,建议在整个应用程序的生命周期内只使用一个CosmosClient实例,以便更好地重用连接,因此我们将在我们的 ASP.NET Core 6 依赖注入容器中将它配置为单例。我们稍后会讨论这个问题。

回到存储库层,BaseRepository 在以下具体类中额外继承,每个存储库代表一个相应的容器:

  • ProductRepository

  • UserRepository

  • OrderRepository

  • InvoiceRepository

ProductRepository 为例,它将具有以下实现,其中我们通过 Ioptions 模式传递 CosmosClient 的单例实例和额外的属性:

    public class ProductRepository :
    BaseRepository<Product>, IProductRepository
    {
        private readonly IOptions<DatabaseSettingsOptions>
        databaseSettings;
        public ProductRepository(CosmosClient,
        IOptions<DatabaseSettingsOptions>
        databaseSettingsOption)
            : base(cosmosClient, databaseSettingsOption.
              Value.DataBaseName, "Products")
        {
            this.databaseSettings = databaseSettingsOption;
        }
    }

所有其他存储库都将遵循类似的架构。每个存储库都将实现自己的接口以支持依赖注入。

注意

这些存储库将随着我们应用程序实现的进展而发展和演变。

下一个项目是 Packt.Ecommerce.DataAccess,这是一个针对 .NET 6 的 Web API 项目,它将主要包含所有控制器以公开我们的存储库。每个存储库将与相应的控制器进行1:1映射。例如,将会有 ProductsController 以 REST API 的形式公开 ProductRepository 方法。所有控制器都将使用构造函数注入来实例化它们对应的存储库。在 Packt.Ecommerce.DataAccess 中一个重要的事情是 Azure Cosmos DB 数据库的配置。各种控制器的设计将与 Packt.Ecommerce.Product Web API 的设计非常相似,这在第十章中讨论,创建 ASP.NET Core 6 Web API

首先,我们将在 appsettings.json 中有一个相应的部分,如下所示:

  "CosmosDB": {
    "DataBaseName": "Ecommerce",
    "AccountEndPoint": "",
    "AuthKey": ""
  }

注意

对于本地开发环境,我们将使用管理用户密钥,如这里所述:docs.microsoft.com/en-us/aspnet/core/security/app-secrets?view=aspnetcore-6.0。我们将设置以下值:

{

  "CosmosDB:AccountEndPoint": "", //Cosmos DB End Point

  "CosmosDB:AuthKey": "" //Cosmos DB Auth key

}

然而,一旦服务部署完成,它应该使用 Azure Key Vault,如第六章中所述,在 .NET 6 中进行配置

我们将定义一个扩展类来保存依赖注入映射。以下是其片段:

    public static class RepositoryExtensions
    {
        public static IServiceCollection
        AddRepositories(this IServiceCollection services)
        {
            services.AddScoped<IproductRepository,
            ProductRepository>();
            return services;
        }
    }

类似地,所有存储库都将进行映射。然后,我们将在 Program 类中配置此设置,包括 Azure Cosmos DB 配置,通过添加以下代码:

builder.Services.AddOptions();
builder.Services.Configure<DatabaseSettingsOptions>(builder.Configuration.GetSection("CosmosDB"));
string accountEndPoint = builder.Configuration.GetValue<string>("CosmosDB:AccountEndPoint");
string authKey = builder.Configuration.GetValue<string>("CosmosDB:AuthKey");
builder.Services.AddSingleton(s => new CosmosClient(accountEndPoint, authKey));
builder.Services.AddRepositories();

一旦完成配置,此服务就准备好在其他服务中使用,例如 ProductsOrdersInvoice。此库将具有执行各种实体 CRUD 操作的所有必要的 REST API。

这标志着创建了一个执行各种实体 CRUD 操作的数据访问服务的完成,并且所有操作都作为 API 公开。这个服务将从我们将在第十章“创建 ASP.NET Core 6 Web API”中开发的其它所有服务中调用。

摘要

在本章中,我们了解了 .NET 6 中可用的各种持久化选项,从与文件和目录一起工作的 API 到如 Microsoft SQL Server 和 Azure Cosmos DB 这样的数据库。

我们还了解了对象关系映射(ORM)及其重要性,以及在使用 Microsoft SQL Server 时如何使用 EF Core 来构建持久化层。在这个过程中,我们使用 Azure Cosmos DB SDK 为我们的电子商务应用程序构建了一个数据访问层。一些关键收获是我们对 SQL 与 NoSQL 之间的设计决策,以及我们如何通过应用逻辑和 UI 层来抽象数据层,这将帮助您构建可扩展的企业应用程序。

在下一章中,我们将探讨 RESTful API 的基础和 ASP.NET Core 6 Web API 的内部结构,并进一步为电子商务应用程序构建各种 RESTful 服务。

问题

  1. 假设你正在将现有的 Web 应用程序迁移到使用 EF Core,但是数据库模式没有变化,现有的数据库可以直接使用。使用 EF Core 的首选模式是什么?

a. Database-first

b. Code-first

c. Both

答案:a

  1. 如果我们正在为我们的电子商务应用程序构建一个推荐系统,并且我们正在使用 Azure Cosmos DB,那么在这种情况下哪种 API 是最佳推荐?

a. The Core (SQL) API

b. The Mongo API

c. The Cassandra API

d. The Gremlin (graph) API

答案:d

  1. 我在基于 SQL API 的数据库中创建了一个容器来存储用户配置文件信息,并将 Email 定义为分区键。我的系统有 100 个唯一的电子邮件。我的容器将有多少个逻辑分区?

a. 1.

b. 0.

c. 100.

d. Azure Cosmos DB 不支持逻辑分区。

答案:c

进一步阅读

以下是一些链接,可以帮助您进一步了解本章的主题:

第十章:第十章:创建 ASP.NET Core 6 Web API

近年来,Web 服务已成为 Web 应用程序开发的重要组成部分。随着需求的不断变化和商业复杂性的增加,松散耦合 Web 应用程序开发中涉及的各个组件/层非常重要,而与应用程序的核心业务逻辑解耦的方案则更为出色。这就是使用 RESTful 方法(其中 REST 代表 REpresentational State Transfer)的 Web 服务的简单性帮助我们开发可扩展的 Web 应用程序的地方。

在本章中,我们将学习如何使用 ASP.NET Core Web 应用程序编程接口API)构建 RESTful 服务,并且在这个过程中,我们将为我们的电子商务应用程序构建所有必需的 API。

我们将详细介绍以下主题:

  • REST 简介

  • 理解 ASP.NET Core 6 Web API 的内部结构

  • 使用控制器和操作处理请求

  • 与数据层的集成

  • 理解 Google 远程过程调用gRPC

技术要求

对于本章,你需要具备基本的 C#、.NET Core、Web API、超文本传输协议HTTP)、Azure、依赖注入DI)、Postman 以及 .NET 命令行界面CLI)知识。

本章的代码可以在以下位置找到:github.com/PacktPublishing/Enterprise-Application-Development-with-C-10-and-.NET-6-Second-Edition/tree/main/Chapter10/TestApi

更多代码示例,请参考以下链接:github.com/PacktPublishing/Enterprise-Application-Development-with-C-10-and-.NET-6-Second-Edition/tree/main/Enterprise%20Application

REST 简介

REST 是构建 Web 服务的架构指南。主要来说,它定义了一组在设计 Web 服务时可以遵循的约束。REST 方法的关键原则之一建议 API 应该围绕资源设计,并且应该是媒体和协议无关的。API 的底层实现与使用 API 的客户端是独立的。

以我们的电子商务应用程序为例,假设我们在 UI 中使用产品搜索字段搜索产品。应该有一个为产品创建的 API,在这里,产品只是电子商务应用程序上下文中的一个资源。对产品实体执行 GET 操作:

GET http://ecommerce.packt.com/products

API 的响应应该独立于调用 API 的客户端——也就是说,在这种情况下,我们正在使用浏览器在产品搜索页面上加载产品列表。然而,相同的 API 可以在移动应用程序中消费,而无需任何更改。其次,在这种情况下,为了内部检索产品信息,应用程序可能使用一个或多个物理数据存储;然而,这种复杂性被隐藏在客户端应用程序中,API 作为单个业务实体——产品——暴露给客户端。尽管 REST 原则并没有规定必须使用 HTTP 协议,但大多数 RESTful 服务都是建立在 HTTP 之上的。这里概述了基于 HTTP 的 RESTful API 的一些关键设计原则/约束/规则:

  • 识别系统的业务实体,并围绕这些资源设计 API。在我们的电子商务应用程序中,我们所有的 API 都将围绕产品、订单、支付和用户等资源进行设计。

  • RESTful API 应该有一个统一的接口,有助于使它们独立于客户端。由于所有 API 都需要面向资源,每个资源都由 URI 唯一标识;此外,对资源的各种操作由 HTTP 动词(如 GETPOSTPUTPATCHDELETE)唯一标识。例如,使用 GET (http://ecommerce.packt.com/products/1) 应该用来检索一个产品。同样,使用 DELETE (http://ecommerce.packt.com/products/1) 应该用来删除一个产品。

  • 由于 HTTP 是无状态的,REST 对 RESTful API 规定了一系列内容。这意味着 API 应该是原子的,并在同一调用中完成请求的处理。任何后续请求,即使是来自同一客户端(相同的 Internet ProtocolIP)地址),都被视为新的请求。例如,如果 API 接受身份验证令牌,它应该为每个请求接受身份验证。无状态的一个主要优点是服务器最终可以实现的可扩展性,因为客户端可以向任何可用的服务器发出 API 调用,并仍然收到相同的响应。

  • 除了发送回响应之外,API 应该利用 HTTP 状态码和响应头来向客户端发送任何额外的信息。例如,如果响应可以被缓存,API 应该向客户端发送相关的响应头,以便它可以被缓存。1xx 表示信息,2xx 表示成功,3xx 表示重定向,4xx 表示客户端错误,5xx 表示服务器错误。

  • API 应该提供关于资源的信息,使得客户端能够轻松地发现它,而无需任何与资源相关的先验信息——也就是说,应该遵循超媒体作为应用状态引擎HATEOAS)的原则。例如,如果有一个创建产品的 API,一旦产品创建成功,API 应该返回该资源的 URI,以便客户端可以用来稍后检索该产品。

有关检索所有产品列表(GET /products)和获取每个产品详细信息的信息,请参阅以下响应:

{
"Products": [
{
"Id": "1",
"Name": "Men's T-Shirt",
"Category": "Clothing"
"Uri": "http://ecommerce.packt.com/products/1"
}
{
"Id": "2",
"Name": "Mastering enterprise application development Book",
"Category": "books"
"Uri": "http://ecommerce.packt.com/products/2"
}
]
}

上述示例是实施HATEOAS原则的一种方式,但它可以设计得更加描述性,例如包含有关接受的 HTTP 动词和关系的响应信息。

REST 成熟度模型

这些是 API 应该遵循的各种指南,以便使其成为 RESTful API。然而,并非所有原则都需要遵循才能使其完美地符合 RESTful;更重要的是,API 应该实现业务目标,而不是 100%符合 REST 规范。RESTful API 设计专家 Leonard Richardson 提出了以下模型来分类 API 的成熟度:

  • 执行所有操作的 URI 属于这一类别。一个例子是基于简单对象访问协议SOAP)的 Web 服务,它有一个单一的 URI,所有操作都是基于 SOAP 信封进行隔离的。

  • 级别 1—资源:所有资源都是 URI 驱动的,每个资源都有专门的 URI 模式,这些 API 属于此成熟度模型。

  • 使用相同 URI 但不同 HTTP 动词进行GETDELETE操作属于此成熟度模型。大多数企业应用 RESTful API 都属于这一类别。

  • 级别 3—HATEOAS:设计时包含所有附加发现信息(资源的 URI;资源支持的各种操作)的 API 属于此成熟度模型。很少有 API 符合此成熟度级别;然而,如前所述,重要的是我们的 API 应该实现业务目标,并且尽可能符合 RESTful 原则,而不是 100%符合但未实现业务目标。

下图说明了 Richardson 成熟度模型:

![图 10.1 – Richardson 的成熟度模型

![img/Figure_10.1_B18507.jpg]

图 10.1 – Richardson 的成熟度模型

到目前为止,我们已经讨论了 REST 架构的各种原则。在下一节中,让我们深入了解使用 ASP.NET Core Web API,我们将在我们的电子商务应用程序中创建各种 RESTful 服务。

理解 ASP.NET Core 6 Web API 的内部结构

ASP.NET Core是一个在.NET Core 之上运行的统一框架,用于开发 Web 应用程序(MVC/Razor)、RESTful 服务(Web API)以及最近基于 Web Assembly 的客户端应用程序(Blazor 应用程序)。ASP.NET Core 应用程序的基本设计基于模型-视图-控制器MVC)模式,将代码分为三个主要类别,如下所示:

  • 模型:这是一个简单的普通 CLR 对象POCO)类,用于存储数据并在应用程序的各个层之间传递数据。层包括在仓库类和服务类之间传递数据,或者在与客户端服务器之间来回传递信息。模型主要表示资源状态或应用程序的领域模型,并包含您请求的信息。

例如,如果我们想存储用户配置文件信息,这可以通过UserInformation POCO 类表示,并可以包含所有配置文件信息。这将进一步用于在仓库和服务类之间传递,也可以在发送回客户端之前将其序列化为JavaScript 对象表示法JSON)/可扩展标记语言XML)。在企业应用程序中,在与数据层集成部分创建我们的电子商务应用程序模型时,我们将遇到不同类型的模型。

  • 视图:这些是表示 UI 的页面。我们从控制器检索的所有模型都绑定到视图上的各种超文本标记语言HTML)控件,并向用户展示。视图在 MVC/Razor 应用程序中通常是常见的;对于 Web API 应用程序,过程以将模型序列化为响应结束。

  • 使用Microsoft.AspNetCore.Mvc.ControllerBase类来定义控制器。

因此,在用 ASP.NET Core 开发的 Web 应用程序中,每当客户端(浏览器、移动应用等类似来源)发起请求时,它将通过 ASP.NET Core 请求管道,到达与数据存储交互的控制器,以填充模型/视图模型并将它们以 JSON/XML 形式作为响应发送回去,或者发送到视图中以进一步绑定响应并向用户展示。

如您所见,存在一个清晰的关注点分离SOC),其中控制器不了解任何 UI 方面,并在当前上下文中执行业务逻辑,并通过模型进行响应;另一方面,视图接收模型并使用它们在 HTML 页面上向用户展示。这种 SOC 有助于轻松地进行单元测试,以及根据需要维护和扩展应用程序。MVC 模式不仅适用于 Web 应用程序,还可以用于任何需要 SOC 的应用程序。

由于本章的重点是构建 RESTful 服务,因此在本章中我们将关注 ASP.NET Core Web API,并在第十一章 创建 ASP.NET Core 6 Web 应用程序中讨论 ASP.NET MVC 和 Razor Pages。

为了开发 RESTful 服务,有许多框架可供选择,但选择在.NET 6 上使用 ASP.NET Core 有以下一些优势:

  • 跨平台支持:与 ASP.NET 不同,它曾经是.NET Framework 的一部分(与 Windows 操作系统耦合),ASP.NET Core 现在是应用程序的一部分,从而消除了平台依赖性,使其与所有平台兼容。

  • 高度可定制的请求管道: 使用中间件和支持注入各种开箱即用的模块,例如日志和配置。

  • IISHTTP.sys

    注意

    默认情况下,Kestrel 是 ASP.NET Core 模板中使用的 HTTP 服务器;然而,这可以根据需要覆盖。

  • 强大的工具支持: 这包括Visual Studio CodeVS Code)、Visual Studio 和 DOTNET CLI,以及项目模板,这意味着开发者可以以非常少的设置开始实现业务逻辑。

  • 开源: 最后,整个框架已经开源,可在github.com/aspnet/AspNetCore找到。

因此,我们现在知道为什么我们选择 ASP.NET Core 作为开发 RESTful 服务的框架。现在让我们看看一些关键组件,这些组件有助于请求的执行,并使用以下命令创建一个示例 Web API:

dotnet new webapi -o TestApi

在成功执行上述命令后,让我们导航到TestApi文件夹,并在 VS Code 中打开它,查看生成的各种文件,如下面的截图所示:

![Figure 10.2 – 在 VS Code 中测试 Web API 项目

![img/Figure_10.2_B18507.jpg]

Figure 10.2 – 在 VS Code 中测试 Web API 项目

在这里,你可以看到用于启动应用的Program类,以及用于运行 Web API 项目的设置文件,如appsettings.json,还有WeatherForecast,这是一个在控制器类中使用的模型类。接下来几节将逐一检查TESTAPI的各个组件。

Program

Program类用于在 ASP.NET Core 6 中启动 Web API 项目。接下来几步将查看这个类执行的活动:

  1. Program类是我们 Web API 的入口点,它告诉 ASP.NET Core 每当有人执行 Web API 项目时开始执行。主要来说,这是一个用于启动应用的类。与 ASP.NET Core 的早期版本应用不同,默认情况下我们没有Startup类——也就是说,Program类包含了我们所需的一切——为了保持代码最小化,我们进一步依赖于 C# 10 的顶级语句和全局 using 语句。

  2. 由于这是入口点,我们需要确保所有组件,如 Web 服务器、路由和配置,都得到初始化和加载,这正是WebApplication类的CreateBuilder方法所帮助实现的。它主要创建一个WebApplicationBuilder对象,该对象可用于配置 HTTP 请求管道。

  3. WebApplicationBuilder 继承自 IApplicationBuilder 接口,这实际上只是一个封装了这些组件的对象,例如默认的 Kestrel HTTP 服务器,所有中间件组件,以及任何额外的服务——例如日志记录——这些服务被注入。最后,调用 Build() 方法来运行操作并初始化 Host 对象。调用 Run() 方法来保持 Host 对象的运行。

既然我们已经加载了包含所有默认组件的 Host 对象,并且它正在运行,让我们检查我们是否可以注入额外的 ASP.NET Core 类/应用程序特定的类(存储库、服务、选项)和中间件。考虑以下要点:

  • WebApplicationBuilder 对象用于注入任何 ASP.NET Core 提供的服务,以便应用程序可以使用这些服务。以下代码片段展示了企业应用程序可以注入的一些常见服务:

    var builder = WebApplication.CreateBuilder(args);
    builder.Services.AddAuthentication() // To enable 
    //authentication.
    builder.Services.AddControllers(); // To enable 
    //controllers like web API.
    builder.Services.AddControllersWithViews(); // To 
    //enable controller with views.
    builder.Services.AddDistributedMemoryCache(); // To enable distributed caching.
    // App insights.
    string appinsightsInstrumentationKey = builder.Configuration.GetValue<string>("ApplicationSettings:InstrumentationKey");
    builder.Services.AddApplicationInsightsTelemetry(appInsightInstrumentKey); // To enable application insights 
    //telemetry.
    

除了 ASP.NET Core 提供的服务之外,我们还可以注入任何特定于我们应用程序的自定义服务——例如,ProductService 可以映射到 IProductService 并在整个应用程序中可用。主要来说,这是我们可以使用来将任何内容集成到依赖注入容器中的地方,如 第五章 中所述,.NET 6 中的依赖注入

此外,所有服务,包括 ASP.NET Core 服务和自定义服务,都可以集成到应用程序中,并作为 IServiceCollection 的扩展方法可用。

  • 接下来,我们有一个 WebApplication 类的对象,可以用来集成所有需要应用到请求管道的中间件。此对象主要控制应用程序如何响应 HTTP 请求——即应用程序应该如何响应异常,如何响应静态文件,或者如何进行 URI 路由。所有这些都可以使用此对象进行配置。

此外,任何对请求管道的特定处理——例如调用自定义中间件或添加特定的响应头,甚至定义特定的端点——都可以使用 WebApplication 对象注入。所以,除了我们之前看到的之外,以下代码片段展示了使用 WebApplication 对象可以集成的几个常见额外配置:

var app = builder.Build();
// Endpoint that responds to /subscribe route.
app.UseEndpoints(endpoints =>
{
endpoints.MapGet("/subscribe", async context =>
{   
  await context.Response.WriteAsync("subscribed");
});
});
// removing any unwanted headers.
app.Use(async (context, next) =>
{
context.Response.Headers.Remove("X-Powered-By");
context.Response.Headers.Remove("Server");
await next().ConfigureAwait(false);
});

在这里,app.UseEndpoints 正在配置一个匹配 /subscribe URI 的响应。app.UseEndPoints 与路由规则协同工作,并在 使用控制器和操作处理请求 部分中解释,而 app.Use 则用于添加内联中间件。在这种情况下,我们正在从响应中移除 X-Powered-ByServer 响应头。

由于 ASP.NET Core 6 支持使用 Program 类,因此可以单独使用它来构建一个完全工作的 API;然而,对于企业应用程序来说,为了便于维护和更好的可读性,最好将 API 与控制器分离,因此在我们的企业应用程序中,我们将使用由 MapControllers 中间件支持的 API,并通过调用 app.MapControllers() 来配置。

总结来说,Program 类在启动应用程序和根据需要自定义应用程序服务和 HTTP 请求/响应管道方面发挥着至关重要的作用。

现在我们来看看中间件如何帮助定制 HTTP 请求/响应管道。

注意

ASP.NET Core 6 仍然支持使用 Startup 类,并且可以直接使用 ConfigureConfigureServices 方法。

理解中间件

我们已经提到了中间件一段时间了,现在让我们了解中间件是什么,以及我们如何构建一个中间件并在我们的企业应用程序中使用它。中间件是拦截传入请求、对请求执行一些处理并将它们传递给下一个中间件或按需跳过的类。中间件是双向的,因此所有中间件都会拦截请求和响应。假设一个 API 获取产品信息,在这个过程中,它会通过各种中间件。以图形形式表示,看起来可能像这样:

Figure 10.3 – Middleware processing

img/Figure_10.3_B18507.jpg

图 10.3 – 中间件处理

每个中间件都有一个 Microsoft.AspNetCore.Http.RequestDelegate 的实例。因此,使用这个实例,中间件会调用下一个中间件。所以,流通常会按照你希望中间件在请求上执行的处理逻辑来处理请求,然后调用 RequestDelegate 将请求传递给管道中的下一个中间件。

如果我们从制造业中找一个类比,那就像制造过程中的装配线,部件从工作站到工作站添加/修改,直到最终产品生产出来。在前面的图中,让我们将每个中间件视为一个工作站,因此它将经历以下步骤:

注意

下面每个中间件的解释只是为了我们理解而做的假设性解释;这些中间件的内部工作原理与这里所解释的略有不同。更多详情可以在这里找到:docs.microsoft.com/en-us/dotnet/api/microsoft.aspnetcore.builder?view=aspnetcore-3.1&viewFallbackFrom=aspnetcore-6.0

  1. UseHttpsRedirection:一个 HTTP 请求到达 GET/Products,并检查协议。如果请求是通过 HTTP,则通过 HTTP 状态码发送重定向;如果请求是通过 HTTPS,则将其传递给下一个中间件。

  2. UseStaticFiles: 如果请求是针对静态文件(通常基于扩展名检测——多用途媒体类型(MIME)类型),这个中间件会处理请求并发送响应,或者将请求传递给下一个中间件。正如你所看到的,如果请求是针对静态文件,那么整个管道的其他部分甚至都不会被执行,因为这个中间件可以处理完整的请求,从而减少服务器上任何不需要的处理负载,并减少响应时间。这个过程也被称为短路,这是每个中间件都可以支持的。

  3. UseRouting: 请求将进一步检查,并识别可以处理该请求的控制器/操作。如果没有匹配项,这个中间件通常会以404 HTTP 状态码响应。

  4. UseAuthorization: 在这里,如果控制器/操作需要可供认证用户使用,那么这个中间件将查找头中的任何有效令牌并相应地响应。

一旦控制器从服务/存储库中获取数据,响应将通过相同的中间件以相反的顺序处理——即首先UseAuthorization,然后是UseHttpsRedirection——并根据需要处理响应。

如前所述,所有中间件都是通过Program类安装的,并使用WebApplication类的对象进行配置。中间件执行的顺序将精确地遵循在Program类中配置的方式。

带着这种理解,让我们创建一个中间件,它将用于处理我们电子商务应用程序的 RESTful 服务的异常,这样我们就不需要在代码中添加try…catch块,而是在请求管道的开始处安装一个中间件,然后捕获任何异常。

构建自定义中间件

由于中间件将在所有 RESTful 服务中重用,我们将中间件添加到Middlewares文件夹中的Packt.Ecommerce.Common项目。

让我们首先创建一个 Poco 类,它代表错误情况下的响应。这个模型通常将包含一个错误消息,一个位于Packt.Ecommerce.Common项目的Models文件夹中的ExceptionResponse,并向其中添加以下代码:

public class ExceptionResponse
{
    public string ErrorMessage { get; set; }
    public string CorrelationIdentifier { get; set; }
    public string InnerException { get; set; }
}

现在,创建另一个 Poco 类,它可以保存发送内部异常行为的配置。这个类将使用Options模式进行填充,该模式在第六章中讨论过,在.NET 6 中的配置。因为它只需要保存一个设置,所以它将有一个属性。在Options文件夹中添加一个名为ApplicationSettings的类文件,然后向其中添加以下代码:

public class ApplicationSettings
{
    public bool IncludeExceptionStackInResponse { get; set; }
}

这个类将进一步扩展,以包含所有 API 中通用的任何配置。

导航到Middlewares文件夹,创建一个名为ErrorHandlingMiddleware的类。正如我们讨论的那样,任何中间件中的关键属性之一是RequestDelegate类型的属性。此外,我们将添加一个ILogger属性以将异常记录到我们的日志提供程序,最后,我们将添加一个bool类型的includeExceptionDetailsInResponse属性来保存一个控制是否屏蔽内部异常的标志。有了这个,ErrorHandlingMiddleware类将看起来如下:

public class ErrorHandlingMiddleware
{
private readonly RequestDelegate requestDelegate;
private readonly ILogger logger;
private readonly bool includeExceptionDetailsInResponse;
}

添加一个参数化构造函数,其中我们注入RequestDelegateILogger以用于我们的日志提供程序,以及IOptions<ApplicationSettings>以用于配置,并将它们分配给之前创建的属性。在这里,我们再次依赖于 ASP.NET Core 的构造函数注入来实例化相应的对象。有了这个,ErrorHandlingMiddleWare的构造函数将看起来如下:

public ErrorHandlingMiddleware(RequestDelegate, ILogger<ErrorHandlingMiddleware> logger, IOptions<ApplicationSettings> applicationSettings)
{
    NotNullValidator.ThrowIfNull(applicationSettings, 
      nameof(applicationSettings));
    this.requestDelegate = requestDelegate;
    this.logger = logger;
    this.includeExceptionDetailsInResponse = applicationSettings.Value.IncludeExceptionStackInResponse;
}

最后,添加一个InvokeAsync方法,该方法将包含处理请求的逻辑,然后使用RequestDelegate调用下一个中间件。由于这是一个作为我们逻辑一部分的异常处理中间件,我们所有要做的就是将请求包裹在一个try…catch块中。在catch块中,我们将使用ILogger将其记录到相应的日志提供程序,并最终发送一个对象ExceptionResponse作为响应。有了这个,InvokeAsync将看起来如下:

public async Task InvokeAsync(HttpContext context)
{
    try
    {
        if (this.requestDelegate != null)
        {
            // invoking next middleware.
           this.requestDelegate.Invoke(context)
             .ConfigureAwait(false);
        }
    }
    catch (Exception innerException)
    {
        this.logger.LogCritical(1001, innerException, 
          "Exception captured in error handling 
           middleware"); // logging.
        ExceptionResponse currentException = new 
          ExceptionResponse()
        {
            ErrorMessage = Constants.ErrorMiddlewareLog, 
         // Exception captured in error handling middleware
            CorrelationIdentifier = 
              System.Diagnostics.Activity.Current?.RootId,
        };
        if (this.includeExceptionDetailsInResponse)
        {
            currentException.InnerException = 
              $"{innerException.Message} 
                {innerException.StackTrace}";
        }
        context.Response.StatusCode = 
          StatusCodes.Status500InternalServerError;
        context.Response.ContentType = "application/json";
  await context.Response.WriteAsync(JsonSerializer.Serialize(innerException)).ConfigureAwait(false);
    }
}

现在,我们可以使用以下代码将此中间件注入到Program类中:

app.UseMiddleware<GlobalExceptionHandlingMiddleware>();

由于这是一个异常处理器,建议在Program类中尽早配置它,以便捕获所有后续中间件中的任何异常。此外,我们需要确保将ApplicationSettings类映射到配置,因此将以下代码添加到Program类中:

Builder.Services.Configure<ApplicationSettings>(this.Configuration.GetSection("ApplicationSettings"));

将相关部分添加到appsettings.json中,如下所示:

"ApplicationSettings": {
    "IncludeExceptionStackInResponse": true
  }

现在,如果我们的任何 API 中发生错误,响应将类似于以下代码片段所示:

{
"ErrorMessage": "Exception captured in error handling middleware",
"CorrelationIdentifier": "03410a51b0475843936943d3ae04240c ",
"InnerException": "No connection could be made because the target machine actively refused it.    at System.Net.Http.ConnectHelper.ConnectAsync(String host, Int32 port, CancellationToken cancellationToken)\r\n   at System.Net.Http.HttpConnectionPool.ConnectAsync(HttpRequestMessage request, Boolean allowHttp2, CancellationToken cancellationToken)\r\n   at System.Net.Http.HttpConnectionPool.CreateHttp11ConnectionAsync(HttpRequestMessage request, CancellationToken cancellationToken)\r\n   at System.Net.Http.HttpConnectionPool.GetHttpConnectionAsync(HttpRequestMessage request, CancellationToken cancellationToken)\r\n   at System.Net.Http.HttpConnectionPool.SendWithRetryAsync(HttpRequestMessage request, Boolean doRequestAuth, CancellationToken cancellationToken)\r\n   at System.Net.Http.RedirectHandler.SendAsync(HttpRequestMessage request, CancellationToken cancellationToken)\r\n   at System.Net.Http.DiagnosticsHandler.SendAsync(HttpRequestMessage request, CancellationToken cancellationToken)\r\n   at Microsoft.Extensions.Http.Logging.LoggingHttpMessageHandler.SendAsync(HttpRequestMessage request, CancellationToken cancellationToken)\r\n   at Microsoft.Extensions.Http.Logging.LoggingScopeHttpMessageHandler.SendAsync(HttpRequestMessage request, CancellationToken cancellationToken)\r\n   at System.Net.Http.HttpClient.FinishSendAsyncBuffered(Task`1 sendTask, HttpRequestMessage request, CancellationTokenSource cts, Boolean disposeCts, CancellationToken callerToken, Int64 timeoutTime)\r\n   at Packt.Ecommerce.Product.Services.ProductsService.GetProductsAsync(String filterCriteria) in src\\platform-apis\\services\\Packt.Ecommerce.Product\\Services\\ProductsService.cs:line 82\r\n   at Packt.Ecommerce.Product.Controllers.ProductsController.GetProductsAsync(String filterCriteria) in src\\platform-apis\\services\\Packt.Ecommerce.Product\\Controllers\\ProductsController.cs:line 46\r\n   at Microsoft.AspNetCore.Mvc.Infrastructure.ActionMethodExecutor.TaskOfIActionResultExecutor.Execute(IActionResultTypeMapper mapper, ObjectMethodExecutor executor, Object controller, Object[] arguments)\r\n   at Microsoft.AspNetCore.Mvc.Infrastructure.ControllerActionInvoker.<InvokeActionMethodAsync>g__Logged|12_1(ControllerActionInvoker invoker)\r\n   at Microsoft.AspNetCore.Mvc.Infrastructure.ControllerActionInvoker.<InvokeNextActionFilterAsync>g__Awaited|10_0(ControllerActionInvoker invoker, Task lastTask, State next, Scope scope, Object state, Boolean isCompleted)\r\n   at Microsoft.AspNetCore.Mvc.Infrastructure.ControllerActionInvoker.Rethrow(ActionExecutedContextSealed context)\r\n   at Microsoft.AspNetCore.Mvc.Infrastructure.ControllerActionInvoker.Next(State& next, Scope& scope, Object& state, Boolean& isCompleted)\r\n   at Microsoft.AspNetCore.Mvc.Infrastructure.ControllerActionInvoker.<InvokeInnerFilterAsync>g__Awaited|13_0(ControllerActionInvoker invoker, Task lastTask, State next, Scope scope, Object state, Boolean isCompleted)\r\n   at Microsoft.AspNetCore.Mvc.Infrastructure.ResourceInvoker.<InvokeFilterPipelineAsync>g__Awaited|19_0(ResourceInvoker invoker, Task lastTask, State next, Scope scope, Object state, Boolean isCompleted)\r\n   at Microsoft.AspNetCore.Mvc.Infrastructure.ResourceInvoker.<InvokeAsync>g__Logged|17_1(ResourceInvoker invoker)\r\n   at Microsoft.AspNetCore.Routing.EndpointMiddleware.<Invoke>g__AwaitRequestTask|6_0(Endpoint endpoint, Task requestTask, ILogger logger)\r\n   at Microsoft.AspNetCore.Authorization.AuthorizationMiddleware.Invoke(HttpContext context)\r\n   at Packt.Ecommerce.Common.Middlewares.ErrorHandlingMiddleware.InvokeAsync(HttpContext context) in src\\platform-apis\\core\\Packt.Ecommerce.Common\\Middlewares\\ErrorHandlingMiddleware.cs:line 65"
}

从前面的代码片段中,我们可以获取CorrelationIdentifier,即03410a51b0475843936943d3ae04240c,在我们的日志提供程序Application Insights中搜索该值,我们可以确定有关异常的更多信息,如图下所示:

Figure 10.4 – Tracing CorrelationIdentifier in Application Insights

Figure 10.4 – Tracing CorrelationIdentifier in Application Insights

图 10.4 – 在 Application Insights 中跟踪 CorrelationIdentifier

CorrelationIdentifier在生产环境中非常有用,尤其是在没有内部异常的情况下。

这就结束了我们对中间件的讨论。在下一节中,我们将探讨控制器操作是什么,以及它们如何帮助处理请求。

使用控制器和操作处理请求

控制器是处理请求的基本块,用于使用 ASP.NET Core Web API 设计 RESTful 服务。这些是包含处理请求逻辑的主要类,包括从数据库检索数据、将记录插入数据库等。控制器是我们定义处理请求方法的类。这些方法通常包括验证输入、与数据存储通信、应用业务逻辑(在企业应用程序中,控制器还将调用服务类),最后使用 HTTP 协议以 JSON/XML 格式序列化响应并发送回客户端。

所有这些包含处理请求逻辑的方法都称为操作。HTTP 服务器接收到的所有请求都通过路由引擎交给操作方法。然而,路由引擎根据可以在请求管道中定义的某些规则将请求转移到操作。这些规则就是我们定义的路由。让我们看看如何将处理请求的 URI 映射到控制器中的特定操作。

理解 ASP.NET Core 路由

到目前为止,我们已经看到任何 HTTP 请求都会通过中间件,最终交给控制器或 configure 方法中定义的端点,但谁负责将请求交给控制器/端点,ASP.NET Core 如何知道触发哪个控制器和控制器内的哪个方法?这正是路由引擎的作用,这也是在添加以下中间件时注入的:

app.UseRouting();
app.UseEndpoints(endpoints =>
{
     endpoints.MapControllers();
});

在这里,app.UseRouting() 注入 Microsoft.AspNetCore.Routing.EndpointRoutingMiddleware,它用于根据 URI 进行所有路由决策。这个中间件的主要任务是设置 Microsoft.AspNetCore.Http.Endpoint 方法的实例,该实例包含特定 URI 需要执行的操作的值。

例如,如果我们试图根据产品 ID 获取产品详情,并且有一个具有 GetProductById 方法的控制器来满足这个请求,当我们对 api/products/1 URI 进行 API 调用时,在 EndpointRoutingMiddleware 后的中间件中设置断点会显示有一个 Endpoint 类的实例可用,其中包含与 URI 匹配的操作信息以及应该执行的操作。我们可以在以下屏幕截图中看到这一点:

图 10.5 – 路由中间件

图 10.5 – 路由中间件

如果没有匹配的控制器/操作,这个对象将是 null。内部,EndpointRoutingMiddleware 使用 URI、查询字符串参数、HTTP 动词和请求头来找到正确的匹配项。

一旦确定了正确的操作方法,app.UseEndPoints的任务就是将控制权交给由Endpoint对象指定的操作方法并执行它。UseEndPoints注入Microsoft.AspNetCore.Routing.EndpointMiddleware以执行满足请求的适当方法。填充适当的EndPoint对象的一个重要方面是UseEndPoints内部配置的各种 URI,这些可以通过 ASP.NET Core 中可用的静态扩展方法实现。例如,如果我们只想配置控制器,我们可以使用MapControllers扩展方法,这些方法为UseRouting匹配的所有操作添加端点。如果我们正在构建 RESTful API,建议使用MapControllers扩展方法。然而,对于以下扩展,有许多这样的扩展方法被广泛使用:

  • MapGet/MapPost:这些是扩展方法,可以匹配特定的GET/POST动词模式并执行请求。它们接受两个参数,一个是 URI 的模式,另一个是当模式匹配时可以用来执行的请求委托。例如,以下代码可以用来匹配/aboutus路由并返回文本欢迎使用默认产品路由

    endpoints.MapGet("/aboutus", async context =>
    {
    await context.Response.WriteAsync("Welcome to default products route");
    });
    
  • MapRazorPages:这个扩展方法在如果我们使用 Razor Pages 并且需要根据路由路由到适当的页面时使用。

  • MapControllerRoute:这个扩展方法可以用来匹配具有特定模式的控制器;例如,以下代码可以在 ASP.NET Core MVC 模板中看到,它根据模式匹配方法:

    endpoints.MapControllerRoute(
    name: "default",
    pattern: "{controller=Home}/{action=Index}/{id?}");
    

请求 URI 基于正斜杠(/)进行分割,并与控制器、操作方法和 ID 进行匹配。因此,如果您想匹配控制器中的方法,您需要在 URI 中传递控制器名称(ASP.NET Core 会自动添加controller关键字后缀)和方法名称。

可选地,可以将 ID 作为参数传递给该方法。例如,如果我在ProductsController中有GetProducts,您将使用绝对 URI products/GetProducts 来调用它。这种路由方式被称为传统路由,非常适合基于 UI 的 Web 应用程序,因此在 ASP.NET Core MVC 模板中可以看到。

这就结束了我们对路由基础知识的讨论;根据应用需求,ASP.NET Core 中有许多这样的扩展方法可以集成到请求管道中。现在,让我们看看基于属性的路由,这是一种推荐用于使用 ASP.NET Core 构建的 RESTful 服务的路由技术。

注意

路由的另一个重要方面,就像任何其他中间件序列一样,是注入非常重要,并且应该在UseEndpoints之前调用UseRouting

基于属性的路由

对于 RESTful 服务,传统的路由违反了一些 REST 原则,特别是指出操作方法对实体执行的操作应基于 HTTP 动词的原则;因此,理想情况下,为了获取产品,URI 应该是GET api/products

这就是基于属性的路由发挥作用的地方,其中路由是通过在控制器级别或操作方法级别使用属性来定义的,或者两者都使用。这是通过使用Microsoft.AspNetCore.Mvc.Route属性实现的,它接受一个字符串值作为输入参数,并用于映射控制器和操作。让我们以ProductsController为例,它具有以下代码:

[Route("api/[controller]")]
[ApiController]
public class ProductsController : ControllerBase
{
    [HttpGet]
    [Route("{id}")]
    public IActionResult GetProductById(int id)
    {
        return Ok($"Product {id}");
    }
    [HttpGet]
    public IActionResult GetProducts()
    {
        return Ok("Products");
    }
}

在这里,在控制器级别的Route属性中,我们传递的值是api/[controller],这意味着任何匹配api/products的 URI 都将映射到这个控制器,其中products是控制器的名称。在方括号中使用controller关键字是一种特定的方式,告诉 ASP.NET Core 自动将控制器名称映射到路由。

然而,如果您想坚持特定的名称,而不管控制器名称如何,则可以使用不带方括号的名称。作为最佳实践,建议将控制器名称与路由解耦。因此,对于我们的电子商务应用程序,我们将在路由中使用精确值——也就是说,ProductsController将具有[Route("api/products")]的路由前缀。

Route属性也可以添加到操作方法中,并可以用来唯一地识别特定的方法。在这里,我们也在传递一个可以用来识别方法的字符串。例如,[Route("GetProductById/{id}")]将与api/products/GetProductById/1 URI 相匹配,并且花括号内的值是一个动态值,可以作为参数传递给操作方法并与参数名称匹配。

这意味着在先前的代码中,有一个 ID 参数,并且花括号内的值也应该命名为ID,这样 ASP.NET Core 才能将 URI 中的值映射到method参数。因此,对于api/products/1 URI,如果路由属性看起来像这样:[Route("{id}")],则GetProductById方法中的 ID 参数将具有值为1

最后,HTTP 动词由如[HttpGet]之类的属性表示,它将被用来将 URI 中的 HTTP 动词映射到方法。以下表格显示了各种示例和可能的匹配,假设ProductsController具有[Route("api/products")]

表 10.1

表 10.1

如您所见,方法名称在这里是不重要的,因此除非在Route属性中指定,否则它不是 URI 匹配的一部分。

注意

一个重要的方面是,Web API 支持从请求中的各个位置读取参数,无论是请求体、头部、查询字符串还是 URI。以下文档涵盖了可用的各种选项:docs.microsoft.com/en-us/aspnet/core/web-api/?view=aspnetcore-6.0#binding-source-parameter-inference.

ASP.NET Core 中整个 API 路由的总结可以表示如下:

图 10.6 – ASP.NET Core API 路由

图 10.6 – ASP.NET Core API 路由

基于属性的路由更符合 RESTful 风格,我们将在我们的电子商务服务中采用这种路由方式。现在,让我们看看 ASP.NET Core 中可用的各种辅助类,这些类可以帮助简化 RESTful 服务的构建。

小贴士

路由中的 {id} 表达式被称为 路由约束,ASP.NET Core 提供了一系列这样的路由约束,也可以在这里找到:docs.microsoft.com/en-us/aspnet/core/fundamentals/routing?view=aspnetcore-6.0#route-constraint-reference.

ControllerBase 类、ApiController 属性和 ActionResult 类

如果我们回顾之前创建的任何控制器,你可以看到所有控制器都是继承自 ControllerBase 类。在 ASP.NET Core 中,ControllerBase 是一个抽象类,它提供了各种辅助方法,有助于处理请求和响应。例如,如果我想发送 HTTP 状态码 400(错误请求),ControllerBase 中有一个 BadRequest 辅助方法可以用来发送 HTTP 状态码 400;否则,我们必须手动创建一个对象并填充它为 HTTP 状态码 400ControllerBase 类中有许多这样的辅助方法,它们是开箱即用的;然而,并非每个 API 控制器都需要从 ControllerBase 类继承。这里列出了 ControllerBase 类中的所有辅助方法:docs.microsoft.com/en-us/dotnet/api/microsoft.aspnetcore.mvc.controllerbase?view=aspnetcore-3.1&viewFallbackFrom=aspnetcore-6.0.

这引出了关于我们的控制器方法返回类型应该是什么的讨论,因为任何 API 在一般情况下都可能有至少两种可能的响应,如下所示:

  • 带有 2xx 状态码的成功响应,可能响应资源或资源列表

  • 带有 4xx 状态码的验证失败案例

为了处理此类场景,我们需要创建一个泛型类型,可以用来发送不同的响应类型,这就是 ASP.NET Core 的IActionResultActionResult类型发挥作用的地方,为我们提供了针对各种场景的派生响应类型。以下是IActionResult支持的一些重要响应类型:

  • OkObjectResult: 这是一个将 HTTP 状态码设置为200并将包含资源详细信息的资源添加到响应正文中的响应类型。对于所有响应资源或资源列表的 API 来说,这种类型非常理想——例如获取产品。

  • NotFoundResult: 这是一个将 HTTP 状态码设置为404且正文为空的响应类型。如果特定资源未找到,则可以使用它。然而,在资源未找到的情况下,我们将使用NoContentResult204),因为404也将用于 API 未找到的情况。

  • BadRequestResult: 这是一个将 HTTP 状态码设置为400并在响应正文中包含错误消息的响应类型。这对于任何验证失败来说非常理想。

  • CreatedAtActionResult: 这是一个将 HTTP 状态码设置为201并可以将新创建的资源 URI 添加到响应中的响应类型。这对于创建资源的 API 来说非常理想。

所有这些响应类型都继承自IActionResultControllerBase类中提供了创建这些对象的方法;因此,IActionResultControllerBase结合将解决大多数业务需求,这就是我们所有 API 控制器方法的返回类型。

在 ASP.NET Core 中,另一个非常有用的类是ApiController类,它可以作为属性添加到控制器类或程序集,并为我们的控制器添加以下行为:

  • 它禁用了传统路由,并强制使用基于属性的路由。

  • 它会自动验证模型,因此我们不需要在每个方法中显式调用ModelState.IsValid。在插入/更新方法的情况下,这种行为非常有用。

  • 它简化了从正文/路由/头部/查询字符串到参数的自动映射。这意味着我们不需要指定 API 参数是否将作为正文或路由的一部分。例如,在下面的代码片段中,我们不需要显式说明 ID 参数将作为路由的一部分,因为ApiController自动使用名为[FromRoute]的某种机制:

    [Route("{id}")]
    public IActionResult GetProductById(int id)
    {
      return Ok($"Product {id}");
    }
    
  • 类似地,在下面的代码片段中,ApiController将根据推断规则自动添加[FromBody]

    public IActionResult CreateProduct(Product product)
    {
    //
    }
    
  • ApiController添加的其他一些行为包括推断请求内容为 multipart/form 数据以及更详细的错误响应,具体请参阅tools.ietf.org/html/rfc7807.

因此,总的来说,ControllerBaseApiControllerActionResult提供了各种辅助方法和行为,从而为开发者提供了编写 RESTful API 所需的所有工具,并允许他们在使用 ASP.NET Core 编写 API 时专注于业务逻辑。

在这个基础上,让我们在下一节设计我们电子商务应用的各个 API。

与数据层的集成

我们 API 的响应可能看起来像或不像我们的领域模型。相反,它们的结构可能类似于 UI 或视图需要绑定的字段;因此,建议创建一组独立的 POCO(Plain Old CLR Object)类,这些类与我们的 UI 集成。这些 POCO 被称为数据传输对象DTOs)。

在本节中,我们将实现我们的 DTOs(数据传输对象)的领域逻辑,并将其与数据层集成,同时使用第八章中讨论的缓存服务,即关于缓存的全部知识,采用 Cache-Aside 模式进行集成,然后——最终——使用控制器和操作实现所需的 RESTful API。在这个过程中,我们将使用HTTPClient工厂进行服务间的通信,并使用AutoMapper库将领域模型映射到 DTOs。

我们将选择一个作为Packt.Ecommerce.Product一部分的产品服务,这是一个使用.NET 6 的 Web API 项目,并详细讨论其实现。在本节结束时,我们将实现以下屏幕截图中所突出的项目:

![Figure 10.7 – Product service and DTOs

![img/Figure_10.7_B18507.png]

图 10.7 – 产品服务和 DTOs

在所有 RESTful 服务中,都有一个类似的实现,根据需要对业务逻辑进行轻微修改,但以下各种服务的高级实现保持不变:

  • Packt.Ecommerce.DataAccess

  • Packt.Ecommerce.Invoice

  • Packt.Ecommerce.Order

  • Packt.Ecommerce.Payment

  • Packt.Ecommerce.UserManagement

首先,我们将在appsettings.json中有一个相应的部分,如下所示:

    "ApplicationSettings": {
    "UseRedisCache": false, // For in-memory
    "IncludeExceptionStackInResponse": true,
    "DataStoreEndpoint": "",
    "InstrumentationKey": ""
  },
  "ConnectionStrings": {
    "Redis": ""
  }

对于本地开发环境,我们将使用管理用户密钥(如docs.microsoft.com/en-us/aspnet/core/security/app-secrets?view=aspnetcore-6.0&tabs=windows中所述)并设置以下值。然而,一旦服务部署,它将使用 Azure Key Vault,如第六章中所述,在.NET 6 中的配置

{
  "ApplicationSettings:InstrumentationKey": "", //relevant 
                                                //key
  "ConnectionStrings:Redis": "" //connection string
}

让我们先为产品 API 创建 DTOs。

创建 DTOs

在产品服务方面,关键要求是提供搜索产品、查看与产品相关的额外详细信息,然后进行购买的能力。由于产品列表可能包含有限的信息,让我们创建一个 POCO(所有 DTO 都在Packt.Ecommerce.DTO.Models项目中创建)并命名为ProductListViewModel。这个类将包含我们希望在产品列表页面上显示的所有属性,并且应该看起来像这样:

public class ProductListViewModel
{
        [JsonProperty(PropertyName = "id")]
        public string Id { get; set; }
        public string Name { get; set; }
        public int Price { get; set; }
        public Uri ImageUrl { get; set; }
        public double AverageRating { get; set; }
}

如您所见,这些是在任何电子商务应用程序上通常显示的最小字段。因此,我们将采用这些字段,但想法是随着应用程序的发展而扩展。在这里,IdName属性是重要的属性,因为它们将被用于在用户想要检索有关产品的所有进一步详细信息时查询数据库。我们使用JsonProperty(PropertyName = "id")属性来注释Id属性,以确保在序列化和反序列化过程中属性名称保持为Id。这很重要,因为在我们 Cosmos DB 实例中,我们使用Id作为大多数容器的主键。现在让我们创建另一个 POCO,它表示产品的详细信息,如下面的代码片段所示:

public class ProductDetailsViewModel
{
        [Required]
        public string Id { get; set; }
        [Required]
        public string Name { get; set; }
        [Required]
        public string Category { get; set; }
        [Required]
        [Range(0, 9999)]
        public int Price { get; set; }
        [Required]
        [Range(0, 999, ErrorMessage = "Large quantity, 
         please reach out to support to process request.")]
        public int Quantity { get; set; }
        public DateTime CreatedDate { get; set; }
        public List<string> ImageUrls { get; set; }
        public List<RatingViewModel> Rating { get; set; }
        public List<string> Format { get; set; }
        public List<string> Authors { get; set; }
        public List<int> Size { get; set; }
        public List<string> Color { get; set; }
        public string Etag { get; set; }
}
public class RatingViewModel
{
        public int Stars { get; set; }
        public int Percentage { get; set; }
}

因此,在这个 DTO 中,除了IdName之外,另一个重要的属性是Etag,它将被用于实体跟踪,以避免在实体上并发覆盖。例如,如果有两个用户访问一个产品,并且用户 A 在用户 B 之前更新它,使用Etag,我们可以阻止用户 B 覆盖用户 A 的更改,并强制用户 B 在更新之前获取产品的最新副本。《AddProductAsync(ProductDetailsViewModel product)》方法在github.com/PacktPublishing/Enterprise-Application-Development-with-C-10-and-.NET-6-Second-Edition/tree/main/Enterprise%20Application/src/platform-apis/services/Packt.Ecommerce.Product/Controllers遵循此模式。

另一个重要方面是我们正在使用 ASP.NET Core 内置的验证属性在我们的模型上定义所有约束。主要,我们将使用[Required]属性和任何相关属性,如docs.microsoft.com/en-us/aspnet/core/mvc/models/validation?view=aspnetcore-6.0#built-in-attributes

所有 DTO 都将作为Packt.Ecommerce.DTO.Models项目的一部分,因为它们将在我们的 ASP.NET MVC 应用程序中重用,该应用程序将用于构建我们电子商务应用程序的 UI。现在,让我们看看Products服务所需的合同。

服务类合同

Packt.Ecommerce.Product 中添加一个 Contracts 文件夹,并创建一个产品服务类的合同/接口,我们将参考我们的需求并根据需要定义方法。最初,它将包含所有基于该接口在产品上执行 创建、读取、更新和删除CRUD)操作的方法,如下所示:

public interface IProductService
    {
        Task<IEnumerable<ProductListViewModel>> 
          GetProductsAsync(string filterCriteria = null);
        Task<ProductDetailsViewModel> 
          GetProductByIdAsync(string productId, 
            string productName);
        Task<ProductDetailsViewModel> 
          AddProductAsync(ProductDetailsViewModel product);
        Task<HttpResponseMessage> 
          UpdateProductAsync(ProductDetailsViewModel 
            product);
        Task<HttpResponseMessage> DeleteProductAsync(
          string productId, string productName);
    }

在这里,你可以看到我们在所有方法中返回 Task,从而坚持我们在 第四章线程和异步操作 中讨论的异步方法。

使用 AutoMapper 的映射类

下一步,我们需要一种将领域模型转换为 DTO 的方法,这里我们将使用一个名为 AutoMapper 的知名库(请参阅 docs.automapper.org/en/stable/Getting-started.html 获取更多详细信息)来配置并添加以下包:

  • Automapper

  • AutoMapper.Extensions.Microsoft.DependencyInjection

要配置 AutoMapper,我们需要定义一个继承自 AutoMapper.Profile 的类,然后定义各种领域模型和 DTO 之间的映射。让我们在 Packt.Ecommerce.Product 项目中添加一个 AutoMapperProfile 类,如下所示:

    public class AutoMapperProfile : Profile
    {
        public AutoMapperProfile()
        {
        }
    }

AutoMapper 包含许多内置的映射方法,其中之一是 CreateMap,它接受源和目标类,并根据相同的属性名称进行映射。任何没有相同名称的属性都可以使用 ForMember 方法手动映射。由于 ProductDetailsViewModel 与我们的领域模型有一对一的映射,因此 CreateMap 对于它们的映射应该是足够的。对于 ProductListViewModel,我们有一个额外的字段 AverageRating,我们希望计算特定产品的所有评分的平均值。为了简化,我们将使用来自 LinqAverage 方法,并将其映射到平均评分。为了模块化,我们将将其放在一个单独的方法 MapEntity 中,如下所示:

private void MapEntity()
{
            this.CreateMap<Data.Models.Product, 
              DTO.Models.ProductDetailsViewModel>();
            this.CreateMap<Data.Models.Rating, 
              DTO.Models.RatingViewModel>();
            this.CreateMap<Data.Models.Product, 
              DTO.Models.ProductListViewModel>()
                .ForMember(x => x.AverageRating, o => 
                  o.MapFrom(a => a.Rating != null ? 
                  a.Rating.Average(y => y.Stars) : 0));
}

现在,修改构造函数以调用此方法。有关完整实现,请参阅 github.com/PacktPublishing/Enterprise-Application-Development-with-C-10-and-.NET-6-Second-Edition/blob/main/Enterprise%20Application/src/platform-apis/services/Packt.Ecommerce.Product/AutoMapperProfile.cs

设置 AutoMapper 的最后一步是将它注入为服务之一,我们将使用 Program 类的 WebApplicationBuilder 对象,使用以下代码行:

Builder.Services.AddAutoMapper(typeof(AutoMapperProfile));

如前所述,这将把 AutoMapper 库注入到我们的 API 中,然后这将允许我们将 AutoMapper 注入到各种服务和控制器中。现在,让我们看看 HttpClient 工厂的配置,该工厂用于调用数据访问服务。

服务间调用的 HttpClient 工厂

要检索数据,我们必须调用定义在 Packt.Ecommerce.DataAccess 中的数据访问服务公开的 API。为此,我们需要一个能够有效使用可用套接字的弹性库,允许我们定义断路器以及重试/超时策略。IHttpClientFactory 对于此类场景非常理想。

注意

HttpClient 中的一个常见问题是潜在的 SocketException 错误,这发生在 HttpClient 在连接到多个服务时将 HttpClient 作为静态/单例使用——这有其自身的开销。这些问题在 softwareengineering.stackexchange.com/questions/330364/should-we-create-a-new-single-instance-of-httpclient-for-all-requests 中进行了总结,现在这些都可以通过 IhttpClientFactory 解决。

要配置 IHttpClientFactory,请执行以下步骤:

  1. 安装 Microsoft.Extensions.Http

  2. 我们将使用类型化客户端来配置 IHttpClientFactory,因此添加一个 Services 文件夹和一个 ProductsService 类,并从 IProductService 继承。目前,请保持实现为空。现在,在 Program 类中使用以下代码映射 IProductServiceProductsService

    builder.Services.AddHttpClient<IProductService, ProductsService>()
           .SetHandlerLifetime(TimeSpan.FromMinutes(5))
           .AddPolicyHandler(RetryPolicy()) // Retry 
                                            // policy.
           .AddPolicyHandler(CircuitBreakerPolicy()); 
           // Circuit breakerpolicy
    

在这里,我们为 ProductsService 使用的 HttpClient 定义了 5 分钟的超时,并额外配置了重试和断路器的策略。

实现断路器策略

为了定义这些策略,我们将使用一个名为 Polly 的库(有关官方文档,请参阅github.com/App-vNext/Polly),它提供了开箱即用的弹性和错误处理能力。安装 Microsoft.Extensions.Http.Polly 包,然后向定义我们断路器策略的 Program 类中添加以下静态方法:

static IAsyncPolicy<HttpResponseMessage> CircuitBreakerPolicy()
{
    return HttpPolicyExtensions
        .HandleTransientHttpError()
        .CircuitBreakerAsync(5, TimeSpan.FromSeconds(30));
}

在这里,我们表示如果 30 秒内有 5 次失败,则会打开电路。断路器有助于避免在无法通过重试修复的严重故障时进行不必要的 HTTP 调用。

实现重试策略

现在,让我们添加我们的重试策略,它比在指定时间范围内退出的标准重试更智能。因此,我们定义了一个将在五次重试和 HTTP 服务调用上产生影响的政策,并且每次重试都会以 2 的幂次方的时间差。代码如下所示:

为了在时间变化方面添加一些随机性,我们将使用 C# 的 Random 类生成一个随机数并将其添加到时间间隔中。这种随机生成将如下所示:

private static IAsyncPolicy<HttpResponseMessage> RetryPolicy()
{
    Random random = new Random();
    var retryPolicy = HttpPolicyExtensions
        .HandleTransientHttpError()
        .OrResult(msg => msg.StatusCode == 
          System.Net.HttpStatusCode.NotFound)
        .WaitAndRetryAsync(
        5,
        retry => TimeSpan.FromSeconds(Math.Pow(2, retry))
         + TimeSpan.FromMilliseconds(random.Next(0, 100)));
    return retryPolicy;
}

在这里,retry 是一个整数,每次重试时都会增加一。为此,在 Program 类中添加一个具有前面逻辑的静态方法。

这完成了我们的 HTTPClient 工厂配置,ProductsService 可以使用构造函数注入来实例化 IHttpClientFactory,然后可以进一步用来创建 HttpClient

在所有这些配置完成后,我们现在可以实施我们的服务类。让我们在下一节中查看它。

在服务类中实现

现在我们来实现 ProductsService,首先定义我们已构建的各种属性,并使用构造函数注入来实例化它们,如下面的代码块所示:

private readonly IOptions<ApplicationSettings> applicationSettings;
private readonly HttpClient httpClient;
private readonly IMapper autoMapper;
private readonly IDistributedCacheService cacheService;
public ProductsService(IHttpClientFactory httpClientFactory, IOptions<ApplicationSettings> applicationSettings, IMapper autoMapper, IDistributedCacheService cacheService)
{
    NotNullValidator.ThrowIfNull(applicationSettings, 
      nameof(applicationSettings));
    IHttpClientFactory httpclientFactory = 
      httpClientFactory;
    this.applicationSettings = applicationSettings;
    this.httpClient = httpclientFactory.CreateClient();
    this.autoMapper = autoMapper;
    this.cacheService = cacheService;
}

我们的所有服务都将使用我们在本章中定义的相同的异常处理中间件,因此在服务之间的调用过程中,如果另一个服务出现故障,响应将属于 ExceptionResponse 类型。因此,让我们创建一个私有方法,以反序列化 ExceptionResponse 类并相应地抛出异常。这是必需的,因为在使用 IsSuccessStatusCodeStatusCode 属性时,HttpClient 会表示成功或失败,所以如果出现异常,我们需要检查 IsSuccessStatusCode 并重新抛出它。让我们将此方法命名为 ThrowServiceToServiceErrors 并参考以下代码片段:

private async Task ThrowServiceToServiceErrors(HttpResponseMessage response)
{
    var exceptionReponse = await response.Content.ReadFromJsonAsync<ExceptionResponse>().ConfigureAwait(false);
    throw new Exception(exceptionReponse.InnerException);
}

现在我们来实现 GetProductsAsync 方法,在这个方法中,我们将使用 CacheService 从缓存中检索数据,如果缓存中没有数据,我们将使用 HttpClient 调用数据访问服务,并将 Product 领域模型映射到 DTO,然后异步返回。代码将如下所示:

public async Task<IEnumerable<ProductListViewModel>> GetProductsAsync(string filterCriteria = null)
{
    var products = await this.cacheService
      .GetCacheAsync<IEnumerable<Packt.Ecommerce
      .Data.Models.Product>>($"products{filterCriteria}")
      .ConfigureAwait(false);
    if (products == null)
    {
        using var productRequest = new 
          HttpRequestMessage(HttpMethod.Get, 
          $"{this.applicationSettings.Value
           .DataStoreEndpoint}api/products
           ?filterCriteria={filterCriteria}");
        var productResponse = await this.httpClient
         .SendAsync(productRequest).ConfigureAwait(false);
        if (!productResponse.IsSuccessStatusCode)
        {
            await this.ThrowServiceToServiceErrors(
              productResponse).ConfigureAwait(false);
        }
        products = await productResponse.Content
          .ReadFromJsonAsync<IEnumerable<Packt
          .Ecommerce.Data.Models.Product>>()
          .ConfigureAwait(false);
        if (products.Any())
        {
            await this.cacheService.AddOrUpdateCacheAsync
              <IEnumerable<Packt.Ecommerce.Data.Models
             .Product>>($"products{filterCriteria}", 
             products).ConfigureAwait(false);
        }
    }
    var productList = this.autoMapper.Map<List
      <ProductListViewModel>>(products);
    return productList;
}

我们将遵循类似的模式并实现 AddProductAsyncUpdateProductAsyncGetProductByIdAsyncDeleteProductAsync。这些方法中的唯一区别将是使用相关的 HttpClient 方法并相应地处理它们。现在我们已经实现了服务,让我们来实现我们的控制器。

在控制器中实现操作方法

让我们先将在上一节中创建的服务注入到 ASP.NET Core 6 DI 容器中,这样我们就可以使用构造函数注入来创建 ProductsService 的对象。我们将在 Program 类中使用以下代码来完成此操作:

builder.Services.AddScoped<IProductService, ProductsService>();

同时,确保所有必需的框架组件(如 ApplicationSettingsCacheServiceAutoMapper)都已配置。

Controllers 文件夹中添加一个控制器,命名为 ProductsController,默认路由为 api/products,然后添加一个 IProductService 属性,并使用构造函数注入。控制器应实现五个操作方法,每个方法调用一个服务方法,并使用本章中讨论的各种现成辅助方法和属性,如 The ControllerBase class, the ApiController attribute, and the ActionResult class 部分。获取特定产品和创建新产品的代码块如下所示:

[HttpGet]
[Route("{id}")]
public async Task<IActionResult> GetProductById(string id, [FromQuery][Required]string name)
{
    // FromQuery supports reading parameters from query 
    // string, here the value of the query string parameter 
    // 'name' will be mapped to name parameter.
    var product = await 
      this.productService.GetProductByIdAsync(id, 
      name).ConfigureAwait(false);
    if (product != null)
    {
        return this.Ok(product);
    }
    else
    {
        return this.NoContent();
    }
}
[HttpPost]
public async Task<IActionResult> AddProductAsync(ProductDetailsViewModel product)
{
    // Product null check is to avoid null attribute 
    // validation error.
    if (product == null || product.Etag != null)
    {
        return this.BadRequest();
    }
    var result = await this.productService
      .AddProductAsync(product).ConfigureAwait(false);
    return this.CreatedAtAction(nameof(
      this.GetProductById), new { id = result.Id, name = 
      result.Name }, result); // HATEOS principle
}

方法实现是显而易见的,并完全基于本章 Handling requests using controllers and actions 部分讨论的基本原理。同样,我们将通过调用相应的服务方法并返回相关的 ActionResult 来实现所有其他方法(DeleteUpdate 和获取所有产品的 Get)。这样,我们将拥有以下表格中显示的 API 来处理与产品实体相关的各种场景:

Table 10.2

表 10.2

小贴士

与 API 相关的另一个常见场景是拥有支持文件上传/下载的 API。上传场景是通过将 IFormFile 作为 API 的输入参数来实现的。这会将上传的文件序列化,并可以将其保存到服务器上。同样,对于文件下载,FileContentResult 也是可用的,可以将文件流式传输到任何客户端。这留给你作为一项活动来进一步探索。

对于测试 API,我们将使用 Postman (www.postman.com/downloads/)。所有 Postman 集合都可以在 Solution Items 文件夹中的 Mastering enterprise application development Book.postman_collection.json 文件下找到。一旦安装了 Postman,导入集合的步骤如下:

  1. 打开 Postman,然后点击 文件

  2. 点击 Mastering enterprise application development Book.postman_collection.json 文件,然后点击 导入

成功导入后,Postman 的 集合 菜单中将显示该集合,如下截图所示:

Figure 10.8 – Collections in Postman

图 10.8 – Postman 中的集合

这完成了我们的 Products RESTful 服务实现。本节开头提到的其他所有服务都是以类似的方式实现的,其中每个服务都是一个独立的 Web API 项目,并处理该实体的相关领域逻辑。

理解 gRPC

根据 grpc.io,gRPC 是一个高性能、开源的通用 RPC 框架。最初由 Google 开发,gRPC 使用 HTTP/2 进行传输,并使用 协议缓冲区protobuf)作为接口描述语言。gRPC 是基于合同的二进制通信系统,并且可在多个生态系统中使用。以下来自 gRPC 官方文档(https://grpc.io)的图表展示了使用 gRPC 的客户端-服务器交互:

图 10.9 – gRPC 客户端-服务器交互

图 10.9 – gRPC 客户端-服务器交互

与许多分布式系统一样,gRPC 基于定义服务并指定具有可远程调用的方法的接口以及合同的想法。在 gRPC 中,服务器实现接口并运行 gRPC 服务器以处理客户端调用。客户端方面有存根,它提供了与服务器定义相同的接口。客户端以调用任何其他本地对象上的方法相同的方式调用存根以在服务器上调用方法。

默认情况下,数据合同使用 .proto 扩展。在 protobuf 中,数据以字段中包含的信息的逻辑记录结构化。在接下来的章节中,我们将学习如何在 Visual Studio 中为 .NET 6 应用程序定义 protobuf。

注意

请参阅官方文档了解有关 gRPC 的更多信息:grpc.io。要了解更多关于 protobuf 的信息,请参阅 developers.google.com/protocol-buffers/docs/overview

由于 gRPC 的 protobuf 与高性能、语言无关的实现和减少的网络使用相关联,许多团队正在探索在其构建微服务的努力中使用 gRPC。

在下一节中,我们将学习如何在 .NET 6 中构建 gRPC 服务器和客户端。

在 .NET 中构建 gRPC 服务器

在 .NET Core 3.0 首次出现后,gRPC 已成为 .NET 生态系统中的第一公民。现在在 .NET 中提供了完全托管的 gRPC 实现。使用 Visual Studio 2022 和 .NET 6,我们可以轻松地创建 gRPC 服务器和客户端应用程序。让我们使用 Visual Studio 中的 gRPC 服务模板创建一个 gRPC 服务,如下面的截图所示,并将其命名为 gRPCDemoService

图 10.10 – gRPC Visual Studio 2022 项目模板

图 10.10 – gRPC Visual Studio 2022 项目模板

这将创建一个名为 GreetService 的示例 gRPC 服务解决方案。现在让我们了解使用模板创建的解决方案。创建的解决方案将引用 Grpc.AspNetCore 包。这将包含托管 gRPC 服务所需的库以及 .proto 文件的代码生成器。此解决方案将在 Protos 解决方案文件夹下创建一个 GreetService 的 proto 文件。以下代码定义了 Greeter 服务:

service Greeter {
  // Sends a greeting
  rpc SayHello (HelloRequest) returns (HelloReply);
}

Greeter 服务只有一个名为 SayHello 的方法,它接受输入参数 HelloRequest 并返回 HelloReply 类型的消息。HelloRequestHelloReply 消息在同一个 proto 文件中定义,如下面的代码片段所示:

message HelloRequest {
  string name = 1;
}
message HelloReply {
  string message = 1;
}

HelloRequest 有一个名为 name 的字段,HelloReply 有一个名为 message 的字段。字段旁边的数字显示字段在缓冲区中的序号位置。proto 文件使用 Protobuf 编译器编译,以生成包含所有管道的存根类。我们可以从 proto 文件的属性中指定要生成的存根类的类型。由于这是一个服务器,它将配置设置为 仅服务器

现在,让我们看看 GreetService 的实现。它将如下所示:

public class GreeterService : Greeter.GreeterBase
{
    private readonly ILogger<GreeterService> _logger;
    public GreeterService(ILogger<GreeterService> logger)
    {
        _logger = logger;
    }
    public override Task<HelloReply> SayHello(
      HelloRequest request, ServerCallContext context)
    {
        return Task.FromResult(new HelloReply
        {
            Message = "Hello " + request.Name
        });
    }
}

GreetService 继承自由 protobuf 编译器生成的 Greeter.GreeterBaseSayHello 方法被重写以提供实现,通过构造 HelloReply 来返回给调用者,正如在 proto 文件中定义的那样。

要在 .NET 6 应用程序中公开 gRPC 服务,需要通过在 Program 类中调用 AddGrpc 将所有必需的 gRPC 服务添加到服务集合中。通过调用 MapGrpcService 来公开 GreeterService gRPC 服务,如下面的代码片段所示:

app.MapGrpcService<GreeterService>();

这就是要在 .NET 6 应用程序中公开 gRPC 服务所需的一切。在下一节中,我们将实现一个 .NET 6 客户端来消费 GreeterService

在 .NET 中构建 gRPC 客户端

如本节开头所述的 理解 gRPC,.NET 6 为构建 gRPC 客户端提供了非常好的工具。在本节中,我们将在控制台应用程序中构建一个 gRPC 客户端。以下是您需要遵循的步骤来完成此操作:

  1. 创建一个名为 gRPCDemoClient 的 .NET 6 控制台应用程序。

  2. 现在,右键单击项目,然后点击 添加 | 服务引用… 菜单项。这将打开 连接服务 选项卡,如下面的屏幕截图所示:

图 10.11 – gRPC 连接服务选项卡

图 10.11 – gRPC 连接服务选项卡

  1. 添加服务引用 对话框中选择 gRPC,然后点击 下一步

  2. greet.proto 文件中的 gRPCDemoService,然后点击 Client 存根类:

图 10.12 – 添加 gRPC 服务引用

图 10.12 – 添加 gRPC 服务引用

这也将添加所需的 Google.ProtobufGrpc.Net.ClientFactoryGrpc.Tools NuGet 包到项目中。

  1. 现在,将以下代码添加到 gRPCDemoClient 项目的 Program 类中:

        var channel = GrpcChannel.ForAddress("https://localhost:5001");
        var client = new Greeter.GreeterClient(channel);
        HelloReply response = await client.SayHelloAsync(new HelloRequest { Name="Suneel" });
        Console.WriteLine(response.Message);
    

在此代码片段中,我们正在创建一个指向 gRPCDemoService 端点的 gRPC 通道,并通过传递 gRPC 通道来实例化 Greeter.GreeterClient,这是一个 gRPCDemoService 的存根。

  1. 现在,要调用该服务,我们只需通过传递HelloRequest消息在存根上调用SayHelloAsync方法。这个调用将从服务返回HelloReply

到目前为止,我们已经创建了一个简单的 gRPC 服务和该服务的控制台客户端。在下一节中,我们将学习关于grpcurl的内容,它是一个通用的客户端,用于测试 gRPC 服务。

测试 gRPC 服务

要测试或调用 REST 服务,我们使用 Postman 或 Fiddler 等工具。grpcurl是一个命令行实用程序,帮助我们与 gRPC 服务交互。使用grpcurl,我们可以测试 gRPC 服务而无需构建客户端应用程序。grpcurl可以从github.com/fullstorydev/grpcurl下载。

一旦下载了grpcurl,我们可以使用以下命令调用GreeterService

grpcurl -d "{\"name\": \"World\"}" localhost:5001 greet.Greeter/SayHello

注意

目前,gRPC 应用程序只能托管在 Azure App Service 和Internet Information ServicesIIS)中,因此我们没有在托管在 Azure App Service 上的演示电子商务应用中使用 gRPC。然而,在本章的演示中,有一个电子商务应用的版本,其中根据 ID 获取产品作为自托管服务中的 gRPC 端点公开。

摘要

在本章中,我们介绍了 REST 的基本原则,并为我们的电子商务应用设计了企业级 RESTful 服务。

在这个过程中,我们掌握了 ASP.NET Core 6 Web API 的各种 Web API 内部机制,包括路由和示例中间件,并熟悉了测试我们服务的工具,同时学习了如何使用控制器及其操作来处理请求,我们还学习了如何构建。我们还看到了如何在.NET 6 中创建和测试基本的 gRPC 客户端和服务器应用程序。你现在应该能够自信地使用 ASP.NET Core 6 Web API 构建 RESTful 服务。

在下一章中,我们将学习 ASP.NET MVC 的基础知识,使用 ASP.NET MVC 构建我们的 UI 层,并将其与我们的 API 集成。

问题

  1. 以下哪个 HTTP 动词建议用于创建资源?

a. GET

b. POST

c. DELETE

d. PUT

答案:b

  1. 以下哪个 HTTP 状态码代表无内容

a. 200

b. 201

c. 202

d. 204

答案:d

  1. 以下哪个中间件用于配置路由?

a. UseDeveloperExceptionPage()

b. UseHttpsRedirection()

c. UseRouting()

d. UseAuthorization()

答案:c

  1. 如果一个控制器被[ApiController]属性注释,我需要在每个操作方法中显式调用ModelState.IsValid吗?

a. 是的——模型验证不是ApiController属性的一部分,因此你需要在每个操作方法中调用ModelState.Valid

b. 不需要——模型验证作为ApiController属性的一部分处理,因此对于所有操作项自动触发ModelState.Valid

答案:b

进一步阅读

第十一章:第十一章:创建 ASP.NET Core 6 网络应用程序

到目前为止,我们已经构建了应用程序的所有核心组件,如数据访问层和服务层,所有这些组件主要是服务器端组件,也称为后端组件

在本章中,我们将构建我们的电子商务应用程序的呈现层/用户界面(UI),也称为客户端组件。UI 是应用程序的界面;一个好的呈现层不仅有助于保持用户对应用程序的参与度,而且鼓励用户返回应用程序。这在企业应用程序中尤为重要,一个好的呈现层有助于用户轻松地浏览应用程序,并帮助他们轻松地执行依赖于应用程序的日常活动。

我们将专注于理解 ASP.NET Core MVC,并使用 ASP.NET Core MVC 开发一个网络应用程序。主要涵盖以下主题:

  • 前端网页开发简介

  • 将 API 与服务层集成

  • 创建控制器和操作

  • 使用 ASP.NET Core MVC 创建用户界面

  • 理解 Blazor

技术要求

对于本章,你需要具备基本的 C#、.NET Core、HTML 和 CSS 知识。本章的代码示例可以在这里找到:github.com/PacktPublishing/Enterprise-Application-Development-with-C-10-and-.NET-6-Second-Edition/tree/main/Chapter11/RazorSample.

你可以在这里找到更多代码示例:github.com/PacktPublishing/Enterprise-Application-Development-with-C-10-and-.NET-6-Second-Edition/tree/main/Enterprise%20Application.

前端网页开发简介

呈现层主要是指浏览器可以渲染并显示给用户的代码。每当一个页面在浏览器中加载时,它会创建一个包含各种元素(如文本框和标签)的层次结构,这些元素都存在于页面上。这个层次结构被称为文档对象模型DOM)。

一个好的前端主要在于能够根据需要操作 DOM,并且有许多技术/库支持使用网络事实上的语言 JavaScript 动态地操作 DOM 和加载数据。无论是简化 JavaScript 使用的 jQuery,还是支持完整客户端渲染的完整客户端框架,如 Angular、React 或 Vue,或者是 ASP.NET Core 框架,如 ASP.NET Core MVC、Razor Pages 或 Blazor,它们都归结为处理网络的三个主要构建块:HTML、CSS 和 JavaScript。让我们来看看这三个构建块:

  • 超文本标记语言HTML):正如全称所述,HTML 是一种浏览器可以理解和显示内容的标记语言。它主要包含一系列称为 HTML 元素 的标签,允许开发者定义页面的结构。例如,如果你想创建一个需要允许用户输入他们的名字和姓氏的表单,可以使用输入 HTML 元素来定义它。

  • 层叠样式表CSS):表示层全部关于以吸引用户的方式展示数据,并确保无论用户尝试在哪种设备/分辨率上加载应用程序,应用程序都是可用的。这正是 CSS 在定义浏览器上内容显示方面发挥关键作用的地方。它控制各种事情,如页面的样式、应用程序的主题和调色板,更重要的是,它使它们具有响应性,这样用户在使用应用程序时,无论是在移动设备还是桌面设备上,都能获得相同的体验。

现代网络开发的优点是我们不需要从头开始编写一切,许多库都可以直接选择并用于应用程序中。我们将在 使用 ASP.NET Core MVC 创建 UI 部分中使用这样一个库,其解释如下。

  • JavaScript: JavaScript 是一种脚本语言,有助于执行各种高级动态操作,例如,验证在表单中输入的文本或类似启用/禁用 HTML 元素条件性或从 API 获取数据等操作。JavaScript 为网页提供了更多功能,并添加了许多开发者可以用来在客户端执行高级操作的编程特性。就像 HTML 和 CSS 一样,所有浏览器都能理解 JavaScript,它构成了表示层的一个重要部分。所有这些组件都可以相互链接,如下面的图所示:

图 11.1 – HTML、CSS 和 JavaScript

图 11.1 – HTML、CSS 和 JavaScript

注意

HTML、CSS 和 JavaScript 互相依存,在开发客户端/前端应用程序中发挥着重要作用,需要专门的书籍来全面解释。一些相关链接可以在 进一步阅读 部分找到。

现在我们已经了解了 HTML、CSS 和 JavaScript 的重要性,我们需要知道如何使用它们来构建网页应用程序的表示层,以便它能够支持具有不同分辨率的多个浏览器,并且能够管理状态(HTTP 是无状态的)。

一种技术可能是创建所有 HTML 页面并在 Web 服务器上托管它们;然而,虽然这对于静态站点工作得很好,并且也涉及从头开始构建一切,但如果我们希望内容更加动态并且想要丰富的 UI,我们需要使用能够动态生成 HTML 页面并提供与后端无缝交互支持的技术。让我们在下一节中看看可以用来生成动态 HTML 的各种技术。

Razor 语法

在我们开始理解 ASP.NET Core 提供的各种可能框架之前,让我们首先了解什么是 Razor 语法。它是一种标记语法,用于将服务器端组件嵌入到 HTML 中。我们可以使用 Razor 语法绑定任何动态数据以进行显示,或者将其从视图/页面发送回服务器以进行进一步处理。Razor 语法主要编写在 Razor 文件/Razor 视图页面上,这些文件不过是 C# 用来生成动态 HTML 的文件。它们带有 .cshtml 扩展名并支持 Razor 语法。Razor 语法由一个名为 视图引擎 的引擎处理,默认视图引擎被称为 Razor 引擎

要嵌入 Razor 语法,我们通常使用 @,这告诉 Razor 引擎解析并生成 HTML。@ 后可以跟任何 C# 内置方法来生成 HTML。例如,<b>@DateTime.Now</b> 可以用于在 Razor 视图/页面上显示当前日期和时间。除此之外,就像 C# 一样,Razor 语法也支持代码块、控制结构和变量等。以下图展示了通过 Razor 引擎的一些示例 Razor 语法:

图 11.2 – Razor 语法

图 11.2 – Razor 语法

Razor 语法还支持定义 HTML 控件;例如,要定义一个文本框,我们可以使用以下 Razor 语法:

<input asp-for=' FirstName ' />

上述代码被称为 input 标记助手,Razor 语法负责处理称为 指令 标记助手的绑定数据到 HTML 控件并生成丰富、动态的 HTML。让我们简要讨论一下:

  • 指令:在底层,每个 Razor 视图/页面都由 Razor 引擎解析,并使用一个 C# 类来生成动态 HTML,然后将其发送回浏览器。指令可以用来控制这个类的行为,进而控制生成的动态 HTML。

例如,@using 指令可以用于在 Razor 视图/页面上包含任何命名空间,或者 @code 指令可以用于包含任何 C# 成员。

最常用的指令之一是 @model,它允许你将模型绑定到视图,这有助于验证视图的类型以及帮助 Intellisense。将视图绑定到特定类/模型的过程称为 强类型 视图。在我们的电子商务应用程序中,我们将对所有视图进行强类型处理,这将在 使用 ASP.NET Core MVC 创建 UI 部分中看到。

  • 标签助手:如果你在 ASP.NET Core 之前使用过 ASP.NET MVC,你可能会遇到 HTML 助手,这些是帮助绑定数据和生成 HTML 控件的类。

然而,在 ASP.NET Core 中,我们有标签助手,它们帮助我们将数据绑定到 HTML 控件。与 HTML 助手相比,标签助手的优点是它们使用与 HTML 相同的语法,并为标准 HTML 控件分配了额外的属性,这些属性可以由动态数据生成。例如,要生成一个 HTML 文本框控件,通常我们会编写以下代码:

<input type='text' id='Name' name='Name' value=' Mastering enterprise application development Book'>

使用标签助手,这将被重写为以下代码,其中 @Name 是视图强类型关联的模型属性:

<input type='text' asp-for='@Name'>

因此,正如你所看到的,这完全是关于编写 HTML,但利用 Razor 标记来生成动态 HTML。ASP.NET Core 内置了许多标签助手,更多关于它们的信息可以在以下链接中找到:docs.microsoft.com/en-us/aspnet/core/mvc/views/tag-helpers/built-in/?view=aspnetcore-6.0

不需要了解/记住每个标签助手,我们将在开发应用程序 UI 时使用此参考文档。

注意

由于 Razor 语法是标记,因此不需要了解所有语法。以下链接可以用作 Razor 语法的参考:docs.microsoft.com/en-us/aspnet/core/mvc/views/razor?view=aspnetcore-6.0

因此,让我们来看看 ASP.NET Core 以及其他常见框架中用于开发表示层的各种选项。

探索 Razor Pages

Razor Pages 是使用 ASP.NET Core 实现网络应用程序的默认方式。Razor Pages 依赖于拥有一个可以直接处理请求的 Razor 文件的概念,以及与该 Razor 文件相关联的可选 C# 文件,用于任何额外的处理。可以使用以下命令创建一个典型的 Razor 应用程序:

dotnet new webapp --framework net6.0 -o RazorSample 

正如你所看到的,项目包含 Razor 页面及其对应的 C# 文件。在打开任何 Razor 视图时,我们会看到一个名为 @page 的指令,它有助于浏览页面。例如,/index 将被路由到 index.cshtml。重要的是,所有 Razor 页面都必须在页面顶部包含 @page 指令,并且放置在 Pages 文件夹中,因为 ASP.NET Core 运行时会在此文件夹中查找所有 Razor 页面。

可以通过使用另一个名为 @model 的指令将 Razor 页面进一步关联到一个 C# 类,也称为 PageModel 类。以下是 index.cshtml 页面的代码:

@page
@model IndexModel
@{
    ViewData["Title"] = "Home page";
}
<form method="post">
    <div class="text-center">
        <select asp-for="WeekDaySelected" 
          asp-items="Model.WeekDay"></select>
        <button type=Submit name="Submit">Submit</button>
        <br>
        <h3>@ViewData["Message"]</h3>
    </div>
</form>

PageModel 类不过是一个可以具有特定 GETPOST 调用方法的 C# 类,以便从 API 等动态获取 Razor 页面上的数据。这个类需要继承自 Microsoft.AspNetCore.Mvc.RazorPages.PageModel,并且是一个标准的 C# 类。index.cshtmlPageModel,它是 index.cshtml.cs 的一部分,如下代码所示:

public class IndexModel : PageModel
{
    public IndexModel(ILogger<IndexModel> logger)
    {
    }
    public List<SelectListItem> WeekDay { get; set; }
    public void OnGet()
    {
        this.WeekDay = new List<SelectListItem>();
        this.WeekDay.Add(new SelectListItem
                              {
                                  Value = 'Monday',
                                  Text =  'Monday'
                              });
        this.WeekDay.Add(new SelectListItem
                              {
                                  Value = 'Tuesday',
                                  Text =  'Tuesday'
                              });                                  
    }
}    

在这里,你可以看到我们正在通过 OnGet 方法填充在 Razor 页面上使用的数据,这同样也被称为 PageModel 处理器,可以用于初始化 Razor 页面。像 OnGet 一样,我们可以添加一个 OnPost 处理器,它可以用于将数据从 Razor 页面提交回 PageModel 并进一步处理。

如果 OnPost 方法满足以下两个条件,它将自动绑定 PageModel 类中的所有属性:

  • 属性被注解为 BindProperty 属性。

  • Razor 页面有一个与属性同名的 HTML 控件。

例如,如果我们想要绑定前面代码中 select 控件的值,我们需要首先在 PageModel 类中添加一个属性,如下代码所示:

[BindProperty]
public string WeekDaySelected { get; set; }

然后,使用 select 控件的属性名,如这里所示,Razor Pages 将自动将选中的值绑定到这个属性:

<select asp-for='WeekDaySelected' asp-items='Model.WeekDay'></select>

我们可以使用异步命名约定为 OnGetOnPost 方法命名,如果我们在使用异步编程,它们可以命名为 OnGetAsync/OnPostAsync

Razor Pages 还支持根据动词调用方法。方法名的模式应遵循 OnPost[handler]/OnPost[handler]Async 规范,其中 [handler] 是设置在任何标签助手 asp-page-handler 属性上的值。

例如,以下代码将调用对应 PageModel 类中的 OnPostDelete/OnPostDeleteAsync 方法:

<input type='submit' asp-page-handler='Delete' value='Delete' />s

对于服务配置部分,Razor Pages 可以通过在 Program 类中使用 AddRazorPages 方法来配置,通过将服务添加到 ASP.NET Core 的 MapRazorPages 中间件,并在 Program 类中注入,如下代码所示。这样做是为了使所有 Razor 页面都可以使用页面名称进行请求:

app.MapRazorPages();

这完成了简单的 Razor 页面应用程序设置;我们在 第九章在 .NET 6 中使用数据,中看到了另一个示例,它使用了 Razor Pages 从数据库中检索数据,使用了 Entity Framework CoreEF Core)。

Razor Pages 是在 ASP.NET Core 中开发 Web 应用程序的最简单形式;然而,对于一种更结构化的开发 Web 应用程序的形式,可以处理复杂功能,我们可以选择 ASP.NET Core MVC。让我们在下一节中探索使用 ASP.NET Core MVC 开发 Web 应用程序。

探索 ASP.NET Core MVC 网站

如其名所示,ASP.NET Core MVC 基于第十章创建 ASP.NET Core 6 Web API中讨论的 MVC 模式,是 ASP.NET Core 中用于构建 Web 应用程序的框架。我们在第十章创建 ASP.NET Core 6 Web API中看到,ASP.NET Core Web API 也使用 MVC 模式;然而,ASP.NET Core MVC 还支持用于显示数据的视图。底层设计模式是相同的,其中我们有一个模型来存储数据,一个控制器来传输数据,以及视图来渲染和显示数据。

ASP.NET Core MVC 支持在第十章创建 ASP.NET Core 6 Web API中讨论的所有功能,例如路由、依赖注入、模型绑定和模型验证,并使用与 Web API 相同的启动技术,即使用 Program 类。就像 Web API 一样,.NET 6 应用程序服务和中间件在 Program 类中进行配置。

与 MVC 的一个主要区别是还需要加载视图,因此,我们不是在 Program 类中使用 AddControllers,而是需要使用 AddControllersWithViews。以下图示了一个示例:

![Figure 11.3 – MVC 请求生命周期img/Figure_11.3_B18507.jpg

Figure 11.3 – MVC 请求生命周期

AddControllersWithViews 主要负责加载视图和处理控制器发送的数据,但最重要的是,它负责配置用于在视图中处理 Razor 语法并生成动态 HTML 的 Razor 引擎服务。

在 ASP.NET Core MVC 中,控制器操作需要根据 URL 中传递的动作名称进行路由,因此在路由部分,我们不是调用 MapController,而是配置 MapControllerRoute 并向其传递一个模式。因此,UseEndpoints 中间件中的默认路由配置看起来如下代码片段所示:

app.UseEndpoints(endpoints =>
{
endpoints.MapControllerRoute(
name: 'default',
pattern: '{controller=Products}/{action=Index}/{id?}');
});

在这个模式中,我们正在告诉中间件,URL 的第一部分应该是 controller 名称,后面跟着动作名称和可选的 id 参数。如果 URL 中没有传递任何内容,则默认路由是 ProductsControllerIndex 动作方法。因此,这主要是我们在第十章创建 ASP.NET Core 6 Web API中讨论的基于约定的路由。

就像 Razor Pages 一样,ASP.NET Core MVC 应用程序中的视图支持 Razor 语法,并允许强类型视图;也就是说,一个视图可以绑定到一个模型进行类型检查,并且模型属性可以与具有编译时智能感知支持的 HTML 控件相关联。

由于 ASP.NET Core MVC 为应用程序提供了更多的结构,因此我们将使用 ASP.NET Core MVC 进行我们的表示层开发,这将在后续章节中详细讨论。

注意

总是有一个关于选择前端开发技术的常见问题。以下链接提供了一些关于这个主题的建议:docs.microsoft.com/en-us/dotnet/architecture/modern-web-apps-azure/choose-between-traditional-web-and-single-page-apps。在选择前端技术之前,应该评估所有优点和缺点,因为没有一种适合所有情况的要求。

在这个基础上,让我们继续到下一节,我们将开始将到目前为止开发的后端 API 与我们的表示层集成。

将 API 与服务层集成

在本节中,我们将开发 Packt.Ecommerce.Web ASP.NET Core MVC 应用程序,该应用程序是通过添加 ASP.NET Core web application(Model-View-Controller) 模板创建的。由于我们已经为表示层开发了各种所需的 API,因此我们首先将构建一个用于与这些 API 通信的包装类。

这是一个用于与各种 API 通信的单个包装类,因此让我们为这个类创建一个合约。为了简单起见,我们将要求限制到我们电子商务应用中最重要的工作流程,如下所示:

  • 登录页面检索系统中的所有产品,并允许用户搜索/过滤产品。

  • 查看产品的详细信息,将它们添加到购物车中,并能够添加更多产品到购物车。

  • 完成订单并查看发票。

为了采用更结构化的方法,我们将将各种类和接口分离到不同的文件夹中。让我们在以下步骤中看看如何操作:

  1. 首先,让我们向 Packt.Ecommerce.Web 项目添加一个名为 Contracts 的文件夹,并添加一个名为 IECommerceService 的接口。这个接口将包含以下方法:

    // Method to retrieve all products and filter.
    Task<IEnumerable<ProductListViewModel>> GetProductsAsync(string filterCriteria = null);
    // Method to get details of specific product.
    Task<ProductDetailsViewModel> GetProductByIdAsync(string productId, string productName);
    // Method to create and order, this method is primarily used to create a cart which is nothing but an order with order status as 'Cart'.
    Task<OrderDetailsViewModel> CreateOrUpdateOrder(OrderDetailsViewModel order);
    // Method to retrieve order by ID, also used to retrieve cart/order before checkout.
    Task<OrderDetailsViewModel> GetOrderByIdAsync(string orderId);
            Task<InvoiceDetailsViewModel> GetInvoiceByIdAsync(string invoiceId);
    // Method to submit cart and create invoice.
    Task<InvoiceDetailsViewModel> SubmitOrder(OrderDetailsViewModel order);
    // Method to retrieve invoice details by Id.
    Task<InvoiceDetailsViewModel> GetInvoiceByIdAsync(string invoiceId);
    
  2. 现在,让我们添加一个名为 Services 的文件夹,并添加一个名为 EcommerceService 的类。这个类将继承 IECommerceService 并实现所有方法。

  3. 由于我们需要调用各种 API,我们需要使用 Packt.Ecommerce.Common.Options.ApplicationSettings 通过 options 模式来使用它。

Program 类将为我们的 MVC 应用程序配置以下服务:

  • AddControllersWithViews: 这将为 ASP.NET Core MVC 注入使用控制器和视图所需的服务。

  • ApplicationSettings: 这将使用以下代码使用 IOptions 模式配置 ApplicationSettings 类:

    builder.Services.Configure<ApplicationSettings>(this.Configuration.GetSection('ApplicationSettings'));
    
  • AddHttpClient: 这将注入 System.Net.Http.IHttpClientFactory 和相关类,这将允许我们创建一个 HttpClient 对象。此外,我们将配置重试策略和断路器策略,如在第 第十章 中所述,创建 ASP.NET Core 6 Web API

  • 使用 .NET Core DI 容器将 EcommerceService 映射到 IECommerceService

  1. 使用以下代码配置应用洞察:

    string appinsightsInstrumentationKey = this.Configuration.GetValue<string>('AppSettings:InstrumentationKey');
    if (!string.IsNullOrWhiteSpace(appinsightsInstrumentationKey))
                {
                    builder.Services.AddLogging(logging =>
                    {                                                       logging.AddApplicationInsights(
                      appinsightsInstrumentationKey);
                    });                
                       builder.Services
                       .AddApplicationInsightsTelemetry 
                       (appinsightsInstrumentationKey);
                }
    

接下来,关于中间件,我们将使用Program类注入以下中间件,除了默认的路由中间件之外:

  • UseStatusCodePagesWithReExecute: 这个中间件用于将请求重定向到除500错误代码外的自定义页面。在下一节中,我们将在ProductController中添加一个方法,该方法将被执行并基于错误代码加载相关视图。这个中间件接受一个字符串作为输入参数,这实际上是在出现错误时应执行的路由,并且为了传递错误代码,它允许使用占位符{0}。因此,中间件配置看起来如下所示:

    app.UseStatusCodePagesWithReExecute('/Products/Error/{0}');
    
  • 错误处理:至于表示层,与 API 不同,我们需要将用户重定向到自定义页面,在运行时失败的情况下,该页面包含相关信息,例如用户友好的错误消息和可以用于稍后检索实际失败的相关的日志 ID。然而,在开发环境中,我们可以显示完整的错误以及堆栈。因此,我们将配置两个中间件,如下面的代码所示:

    {
    if (env.IsDevelopment())
        app.UseDeveloperExceptionPage();
    }
    else
    {
        app.UseExceptionHandler('/Products/Error/500');
    }
    

在这里,我们可以看到,对于开发环境,我们使用的是UseDeveloperExceptionPage中间件,它将加载完整的异常堆栈跟踪,而对于非开发环境,我们使用的是UseExceptionHandler中间件,它接受需要执行的错误操作方法的路径。此外,在这里,我们不需要我们的自定义错误处理中间件,因为 ASP.NET Core 中间件负责将详细的错误记录到日志提供程序,在我们的例子中是应用洞察。

  • UseStaticFiles: 为了允许各种静态文件,如 CSS、JavaScript、图像以及任何其他静态文件,我们不需要通过整个请求管道,这就是这个中间件发挥作用的地方,它允许服务静态文件并支持为静态文件短路其余的管道。

回到EcommerceService类,让我们首先定义这个类的局部变量和构造函数,它将使用以下代码注入HTTPClient工厂和ApplicationSettings

private readonly HttpClient httpClient;
private readonly ApplicationSettings applicationSettings;
public ECommerceService(IHttpClientFactory httpClientFactory, IOptions<ApplicationSettings> applicationSettings)
{
NotNullValidator.ThrowIfNull(applicationSettings, nameof(applicationSettings));
IHttpClientFactory httpclientFactory = httpClientFactory;
this.httpClient = httpclientFactory.CreateClient();
this.applicationSettings = applicationSettings.Value;
}

现在,为了按照我们的IECommerceService接口实现方法,我们将使用以下步骤来处理 Get API:

图 11.4 – API 的 Get 调用

图 11.4 – API 的 Get 调用

根据前面图中的步骤,GetProductsAsync的实现,主要用于检索用于着陆页的产品并在进行产品搜索时应用任何过滤器,将如下所示:

public async Task<IEnumerable<ProductListViewModel>> GetProductsAsync(string filterCriteria = null)
{
    IEnumerable<ProductListViewModel> products = new List<ProductListViewModel>();
    using var productRequest = new 
      HttpRequestMessage(HttpMethod.Get, 
        $'{this.applicationSettings.ProductsApiEndpoint}
        ?filterCriteria={filterCriteria}');
    var productResponse = await this.httpClient.SendAsync(
      productRequest).ConfigureAwait(false);
    if (!productResponse.IsSuccessStatusCode)
    {        await this.ThrowServiceToServiceErrors(
               productResponse).ConfigureAwait(false);
    }
    if (productResponse.StatusCode != 
      System.Net.HttpStatusCode.NoContent)
    {
        products = await productResponse.Content
          .ReadFromJsonAsync<Ienumerable
           <ProductListViewModel>>().ConfigureAwait(false);
    }
    return products;
}

对于POST/PUT API,我们将有类似的步骤,但略有修改,如下面的图所示:

图 11.5 – API 调用后的情况

图 11.5 – API 的 Post 调用

基于此,CreateOrUpdateOrder的策略实现,主要用于创建购物车,其代码如下所示:

public async Task<OrderDetailsViewModel> CreateOrUpdateOrder(OrderDetailsViewModel order)
{
    NotNullValidator.ThrowIfNull(order, nameof(order));
    using var orderRequest = new 
      StringContent(JsonSerializer.Serialize(order), 
      Encoding.UTF8, ContentType);
    var orderResponse = await this.httpClient.PostAsync(new 
      Uri($'{this.applicationSettings.OrdersApiEndpoint}'), 
      orderRequest).ConfigureAwait(false);
    if (!orderResponse.IsSuccessStatusCode)
    {
        await this.ThrowServiceToServiceErrors(
          orderResponse).ConfigureAwait(false);
    }
    var createdOrder = await orderResponse.Content
      .ReadFromJsonAsync<OrderDetailsViewModel>()
      .ConfigureAwait(false);
    return createdOrder;
}

同样,我们将使用前面提到的一种策略和相关的 API 端点来实现GetProductByIdAsyncGetOrderByIdAsyncGetInvoiceByIdAsyncSubmitOrder

现在,让我们创建将与EcommerceService通信并加载相关视图的控制器和操作方法。

创建控制器和操作

我们已经看到,路由负责将请求 URI 映射到控制器中的操作方法,因此让我们进一步了解操作方法是如何加载相应视图的。正如您所注意到的,ASP.NET Core MVC 项目中的所有视图都是Views文件夹的一部分,当操作方法执行完成后,它只需简单地查找Views/<ControllerName>/<Action>.cshtml

例如,将操作方法映射到Products/Index路由将加载Views/Products/Index.cshtml视图。这是通过在每个操作方法结束时调用Microsoft.AspNetCore.Mvc.Controller.View方法来处理的。

除了可以覆盖此行为并按需路由到不同视图的额外重载和辅助方法外,我们将在讨论这些辅助方法之前,就像 Web API 一样,MVC 控制器中的每个操作方法也可以返回IActionResult,这意味着我们可以利用辅助方法来重定向到视图。在 ASP.NET Core MVC 中,每个控制器都继承自基类Microsoft.AspNetCore.Mvc.Controller,该类包含一些辅助方法,并通过以下Microsoft.AspNetCore.Mvc.Controller类中的辅助方法来处理通过操作方法加载视图:

  • View:此方法具有多种重载,主要根据控制器名称从Views文件夹下的文件夹加载视图。例如,在ProductsController中调用此方法可以加载Views/Products文件夹中的任何.cshtml文件。此外,它还可以接受视图名称,如果需要,可以加载,并支持通过强类型化视图来传递可以由视图检索的对象。

  • RedirectToAction:尽管View方法处理了大多数场景,但仍然会有需要在同一控制器或另一个控制器中调用另一个操作方法的场景,这就是RedirectToAction发挥作用的地方。此方法具有多种重载,允许我们指定操作方法、控制器方法以及操作方法可以接收作为路由值的对象。

简而言之,为了加载视图并从控制器传递数据,我们将通过View方法传递相应的模型,并在需要时使用RedirectToAction来调用另一个操作方法。

现在,问题是如何处理数据检索(GET调用)与数据提交(POST调用),在 ASP.NET Core MVC 中,所有动作方法都支持使用HttpGetHttpPost属性来标记 HTTP 动词。以下是一些可以用来标记方法的规则:

  • 如果我们想要检索数据,则动作方法应使用HttpGet进行标记。

  • 如果我们想要向动作方法提交数据,则应使用HttpPost进行标记,并将相关对象作为动作方法的输入参数。

通常,需要从控制器向视图发送数据的函数应使用[HttpGet]进行标记,而需要从视图接收数据以进一步提交到数据库的函数应使用[HttpPost]进行标记。

现在,让我们继续添加所需的控制器并实现它们。当我们添加Packt.Ecommerce.Web时,它将创建一个包含默认创建的HomeControllerControllers文件夹,我们需要将其删除。然后,我们需要通过右键单击Controllers文件夹,然后选择ProductsControllerCartControllerOrdersController来添加三个控制器。

所有这些控制器都将具有以下两个共同属性,一个用于日志记录,另一个用于调用EcommerceService的方法。它们将通过构造函数注入进一步初始化,如下所示:

private readonly ILogger<ProductsController> logger;
private readonly IECommerceService eCommerceService;

让我们现在讨论每个控制器中定义的内容:

  • ProductsController:此控制器将包含一个public async Task<IActionResult> Index(string searchString, string category)动作方法,用于加载列出所有产品的默认视图,并进一步支持过滤。将会有另一个方法,public async Task<IActionResult> Details(string productId, string productName),它接受产品的 ID 和名称,并加载指定产品的详细信息。由于这两个方法都用于检索,因此它们将使用[HttpGet]进行标记。此外,此控制器将具有之前讨论过的Error方法。由于它可以从UseStatusCodePagesWithReExecute中间件接收错误代码作为输入参数,因此我们将有简单的逻辑来相应地加载视图:

    [Route('/Products/Error/{code:int}')]
    public IActionResult Error(int code)
    {
        if (code == 404)
        {
            return 
              this.View('~/Views/Shared/NotFound.cshtml');
        }
        else
        {
            return 
             this.View('~/Views/Shared/Error.cshtml', new 
             ErrorViewModel { CorrelationId = 
             Activity.Current?.RootId ?? 
             this.HttpContext.TraceIdentifier });
        }
    }
    
  • CartController:此控制器包含一个public async Task<IActionResult> Index(ProductListViewModel product)动作方法,用于将产品添加到购物车中。我们将创建一个订单,并将订单状态设置为'Cart',因为这需要接收数据并将其进一步传递到 API,该 API 将被标记为[HttpPost]。为了简化,这里将其留为匿名,但可以限制对已登录用户的访问。一旦创建订单,此方法将使用RedirectToAction辅助方法,并将重定向到控制器内的public async Task<IActionResult> Index(string orderId)动作方法,该方法进一步加载包含所有产品的购物车和结账表单。此方法也可以直接导航到购物车。

  • OrdersController: 这是流程中的最后一个控制器,其中包含一个public async Task<IActionResult> Create(OrderDetailsViewModel order)动作方法,用于在填写支付详情后提交订单。此方法将订单状态更新为Submitted,然后为订单创建发票,最后重定向到另一个动作方法public async Task<IActionResult> Index(string invoiceId),该方法加载订单的最终发票并完成交易。

下面的图表示了控制器之间完成购物流程的方法之间的流程:

图 11.6 – 控制器动作方法之间的流程

图 11.6 – 控制器动作方法之间的流程

基于这些知识,让我们在下一节中设计视图。

使用 ASP.NET Core MVC 创建 UI

到目前为止,我们已经定义了一个服务来与后端 API 通信,并进一步定义了将使用模型将数据传递给视图的控制器。现在,让我们构建各种视图,这些视图将渲染数据并向用户展示。

首先,让我们看看参与渲染视图的各种组件:

  • Views文件夹:所有视图都是这个文件夹的一部分,每个控制器特定的视图都由一个子文件夹分隔,最后,每个动作方法都由一个.cshtml文件表示。

要添加一个视图,我们可以在动作方法上右键单击并选择添加视图,这将自动创建一个(如果尚未存在)以控制器命名的文件夹,并添加视图。此外,在执行此操作时,我们可以指定视图将绑定到的模型。

  • Layout页面:在 Web 应用程序中,我们通常有一个跨应用共用的部分,例如带有菜单或左侧导航的页眉。为了使我们的页面具有模块化结构并避免重复,ASP.Net Core MVC 提供了一个名为_Layout.cshtml的布局页面,它是Views/Shared文件夹的一部分。这个页面可以用作我们 MVC 项目中所有视图的父页面。一个典型的布局页面如下所示:

    <!DOCTYPE html>
    <html lang='en'>
    <head>
        <meta charset='utf-8'>
        <meta name='viewport' content='width=device-width, 
         initial-scale=1'>
        <meta http-equiv='x-ua-compatible' 
         content='ie=edge'>
        <title>Ecommerce Packt</title>
    </head>
    <body class='hold-transition sidebar-mini layout-top-nav'>    
            <!-- Navbar -->        
            <!-- Main content -->        
             @RenderBody()
    </body>
    </html>
    

在这里,你可以看到它允许我们定义应用程序的骨架布局,然后最后,有一个名为@RenderBody()的 Razor 方法,它实际上加载了子视图。要指定任何视图中的布局页面,我们可以使用以下语法,它将_Layout.cshtml添加为视图的父页面:

@{
    Layout = '~/Views/Shared/__Layout.cshtml';
}

然而,没有必要在所有视图中重复此代码,这正是_ViewStart.cshtml发挥作用的地方。让我们看看它是如何帮助我们在视图之间重用一些代码的:

  • _ViewStart.cshtml:这是一个通用的视图,位于 Views 文件夹下,并由 Razor 引擎执行需要在视图代码之前执行的任何代码。因此,通常情况下,它用于定义布局页面,所以前面的代码可以添加到这个文件中,以便它在整个应用程序中应用。

  • _ViewImports.cshtml:这是另一个可以用于在应用程序中导入任何公共指令或命名空间的页面。就像 _ViewStart 一样,它也位于根文件夹下;然而,_ViewStart_ViewImport 可以在一个(或多个)文件夹中,并且它们是按照从根视图文件夹中的开始,到任何子文件夹中的低级别文件夹的顺序执行的。要启用使用 Application Insights 的客户端遥测,我们按照以下代码注入 JavaScriptSnippet。我们在 第五章 中学习了如何将依赖服务注入到视图中,在 .NET 6 中的依赖注入。在以下代码中,JavaScriptSnippet 被注入到视图中:

        @inject Microsoft.ApplicationInsights.AspNetCore.JavaScriptSnippet JavaScriptSnippet
    
  • wwwroot:这是应用程序的根文件夹,所有静态资源,如 JavaScript、CSS 以及任何图像文件,都放置在这里。这里还可以存放我们希望在应用程序中使用的任何 HTML 插件。由于我们已经在应用程序中配置了 UseStaticFiles 中间件,因此文件夹中的内容可以直接提供服务,无需任何处理。ASP.NET Core MVC 的默认模板根据类型对文件夹进行了隔离;例如,所有 JavaScript 文件都放置在 js 文件夹中,CSS 文件放置在 css 文件夹中,等等。我们将坚持使用这种文件夹结构来构建我们的应用程序。

    注意

    通过右键单击操作方法并使用内置模板自动生成视图的过程称为 scaffolding,如果您对 Razor 语法不熟悉,可以使用它。然而,使用 scaffolding 或手动将其放置在相应的文件夹中并强类型化,会产生相同的行为。

设置 AdminLTE、布局页面和视图

要在整个应用程序中保持相同的视觉效果和感觉,一个重要的事情是选择正确的样式框架。这样做不仅提供了统一的布局,还简化了响应式设计,有助于在不同分辨率下正确渲染页面。我们为 Packt.Ecommerce.Web 使用的 ASP.NET Core MVC 项目模板默认包含 Bootstrap 作为其样式框架。我们将进一步扩展到名为 AdminLTE 的主题,它包含一些有趣的布局和仪表板,可以插入到我们的表示层中。

让我们执行以下步骤以将 AdminLTE 集成到我们的应用程序中:

  1. 从这里下载 AdminLTE 的最新版本:github.com/ColorlibHQ/AdminLTE/releases

  2. 提取之前下载的 ZIP 文件,并导航到AdminLTE-3.0.5\dist\css。复制adminlte.min.css,并将其粘贴到Packt.Ecommerce.Webwwwroot/css文件夹内。

  3. 导航到AdminLTE-3.0.5\dist\js。复制adminlte.min.js,并将其粘贴到Packt.Ecommerce.Webwwwroot/js文件夹内。

  4. 导航到AdminLTE-3.0.5\dist\img。复制所需的图片,并将它们粘贴到Packt.Ecommerce.Webwwwroot/img文件夹内。

  5. 复制AdminLTE-3.0.5\plugins文件夹,并将其粘贴到Packt.Ecommerce.Webwwwroot文件夹内。

更多关于AdminLTE的信息可以在adminlte.io/docs/2.4/installation找到。

现在,导航到Views/_Layout.cshtml页面,删除所有现有代码,并用Packt.Ecommerce.Web\Views\Shared_Layout.cshtml中的代码替换它。从高层次上讲,布局分为以下几部分:

  • 在左侧页眉中的导航到主页

  • 在页眉中添加一个搜索框,并在中间添加一个带有搜索类别的下拉菜单

  • 在右侧页眉中的购物车

  • 一个用于显示导航的面包屑路径

  • 一个用于使用@RenderBody()渲染子视图的部分

完成集成AdminLTE模板所需的一些其他关键事项如下:

  • <head>标签中添加以下样式定义:

    <link rel='stylesheet' href='~/plugins/fontawesome-free/css/all.min.css'>
    <link rel='stylesheet' href='~/css/adminlte.min.css'>
    
  • <body>标签的末尾之前添加以下 JavaScript 文件:

    <!-- REQUIRED SCRIPTS (Order shouldn't matter)-->
    <!-- jQuery -->
    <script src='~/plugins/jquery/jquery.min.js'></script>
    <!-- Bootstrap 4 -->
    <script src='~/plugins/bootstrap/js/bootstrap.bundle.min.js'></script>
    <!-- AdminLTE App -->
    <script src='~/js/adminlte.min.js'></script>
    

通过这种方式,我们已经将AdminLTE主题集成到我们的应用程序中。要使用 Application Insights 启用客户端遥测所需的 JavaScript,请在_Layout.cshtmlhead标签内添加以下代码:

    @Html.Raw(JavaScriptSnippet.FullScript)

之前的代码注入了发送遥测数据所需的 JavaScript,以及仪表化密钥。与服务器端或客户端不同,仪表化密钥是公开的。任何人都可以从浏览器开发者工具中看到仪表化密钥。但是,这就是客户端遥测的设置方式。此时,这种风险是恶意用户或攻击者可以通过仪表化密钥的只写访问权限推送不希望的数据。如果您希望使客户端遥测更安全,您可以从您的服务中公开一个安全的 REST API,并从那里记录遥测事件。您将在第十四章中了解更多关于 Application Insights 功能的信息,健康和诊断

现在,应用程序布局已准备就绪。让我们现在继续定义应用程序中的各种视图。

创建 Products/Index 视图

此视图将用于列出我们电子商务应用程序上所有可用的产品,并且使用IEnumerable<Packt.Ecommerce.DTO.Models.ProductListViewModel>模型进行强类型化。它使用ProductsControllerIndex操作方法检索数据。

在此视图中,我们将使用简单的 Razor @foreach (var item in Model) 循环,并为每个产品显示产品的图片、名称和价格。以下截图展示了此视图的一个示例:

图 11.7 – 产品视图

图 11.7 – 产品视图

在这里,您可以看到有一个搜索栏和一个来自布局页面的分类下拉菜单。点击产品图片将导航到 Products/Details 视图。为了支持此导航,我们将使用 AnchorTagHelper 并将产品 ID 和名称传递给 ProductsControllerDetails 动作方法,以便进一步在 Products/Details 视图中加载产品的详细信息。

创建 Products/Details 视图

此视图将根据从 Products/Index 视图传递的产品 ID 和名称加载产品的详细信息。我们将使用以下示例页面:adminlte.io/themes/dev/AdminLTE/pages/examples/e_commerce.html

此页面将使用 Packt.Ecommerce.DTO.Models.ProductDetailsViewModel 进行强类型定义,并显示产品的所有详细信息。以下截图展示了此页面的一个示例:

图 11.8 – 产品详情视图

图 11.8 – 产品详情视图

如您所见,有一个 'Cart',这将调用 CartControllerIndex 动作方法以创建购物车。

要将数据传递回动作方法,我们将借助 FormTagHelper,它允许我们将页面包裹在一个 HTML 表单中,并使用以下代码指定页面可以提交到的动作和控制器:

<form asp-action='Index' asp-controller='Cart'>

使用此代码,一旦提交为 Submit 类型,页面就会提交到 CartControllerIndex 动作方法,以便进一步将其保存到数据库。然而,我们仍然需要将产品详情传递回 Index 动作方法,为此,我们将借助 InputTagHelper 并为所有需要传递回动作方法的值创建隐藏字段。

这里最重要的是隐藏变量的名称应与模型中属性的名称匹配,因此我们将在表单内添加以下代码,以将产品值传递回控制器:

<input asp-for='Id' type='hidden'>
<input asp-for='Name' type='hidden'>
<input asp-for='Price' type='hidden'>
<input asp-for='ImageUrls[0]' type='hidden'>

ASP.NET Core MVC 的模型绑定系统读取这些值,并为 CartControllerIndex 方法创建所需的产品对象,然后进一步调用后端系统以创建订单。

创建购物车/索引视图

此视图将加载购物车详情,并将有一个填写所有详细信息并完成订单的结账表单。在此,我们可以导航回主页以添加更多产品或完成订单。

此视图使用 Packt.Ecommerce.DTO.Models.OrderDetailsViewModel 进行强类型化,并使用 OrdersControllerIndex 动作方法加载数据。在这里,我们使用了来自 getbootstrap.com/docs/4.5/examples/checkout/ 的 Bootstrap 结账表单示例。

此表单利用模型验证和 HTML 属性对必填字段进行验证,我们借助 ASP.NET Core MVC 标签辅助器和一些 HTML 辅助器来渲染表单。以下代码展示了具有模型验证的示例属性:

public class AddressViewModel
{
        [Required(ErrorMessage = 'Address is required')]
        public string Address1 { get; set; }      
        [Required(ErrorMessage = 'City is required')]
        public string City { get; set; }
        [Required(ErrorMessage = 'Country is required')]
        public string Country { get; set; }
}

此模型用于 github.com/PacktPublishing/Enterprise-Application-Development-with-C-10-and-.NET-6-Second-Edition/blob/main/Enterprise%20Application/src/platform-apis/data/Packt.Ecommerce.DTO.Models/OrderDetailsViewModel.cs 并在下单时触发必要的验证。

由于此表单也需要提交,整个表单被包裹在 FormTagHelper 中,如下面的代码所示:

<form asp-action='Create' asp-controller='Orders'>

要在 UI 上显示这些验证,请将以下脚本添加到 _layout.cshtml 中,在添加了所有其他脚本之后:

<script src='~/lib/jquery-validation/dist/jquery.validate.min.js'></script>
<script src='~/lib/jquery-validation-unobtrusive/jquery.validate.unobtrusive.min.js'></script>

要显示错误消息,我们可以使用验证消息标签辅助器,如下面的代码片段所示。在服务器端,这可以通过 ModelState.IsValid 进一步评估:

<input asp-for='ShippingAddress.Address1' class='form-control' placeholder='1234 Main St'/>
<span asp-validation-for='ShippingAddress.Address1' class='text-danger'></span>

此页面的示例截图如下:

图 11.9 – 购物车和结账页面

图 11.9 – 购物车和结账页面

我们将使用 InputTagHelper 作为隐藏字段和文本框,以将任何附加信息传递回动作方法。文本框的好处是,如果文本框的 id 属性与属性名称匹配,那么数据将自动传递回动作方法,ASP.NET Core MVC 的模型绑定系统将负责将其映射到所需的对象,在这种情况下,是 Packt.Ecommerce.DTO.Models.OrderDetailsViewModel 类型的对象,最终提交订单、生成发票并重定向到 Orders/Index 动作方法。

注意

在前面的屏幕截图中,尽管在生产应用中我们有一个包含支付信息的结账表单,但我们将与第三方支付网关集成,通常,整个表单都位于应用程序的支付网关侧,出于各种安全原因。razorpay.com/docs/payment-gateway/server-integration/dot-net/stripe.com/docs/api 是这类第三方提供商的几个例子,它们有助于支付网关集成。

创建 Orders/Index 视图

最后,我们将有一个查看订单发票的视图,这是一个简单的只读视图,显示从 OrdersControllerIndex 动作方法发送的发票信息。以下截图显示了此页面的一个示例:

图 11.10 – 最终发票

图 11.10 – 最终发票

这完成了各种视图的集成,如您所见,我们已经将视图限制在电子商务应用程序中最重要的流程中。然而,您可以使用相同的原理进一步添加更多功能。

理解 Blazor

Blazor 是从 .NET Core 3.1 开始可用的新框架,用于开发应用程序的前端层。它是 MVC 和 Razor Pages 的替代方案之一,并且应用程序模型非常接近 SPA;然而,我们可以用 C# 和 Razor 语法编写逻辑,而不是 JavaScript。

所有的 Blazor 编写代码都放在一个名为 .Razor 的地方,用于表示应用程序;无论是整个网页还是一个小对话框弹出窗口,所有内容都在 Blazor 应用程序中作为一个组件创建。一个典型的 Razor 组件如下代码片段所示:

@page '/counter'
<h1>Counter</h1>
<p>Current count: @currentCount</p>
<button class='btn btn-primary' @onclick='IncrementCount'>Click me</button>
@code {
    private int currentCount = 0;
    private void IncrementCount()
    {        currentCount++;    }
}

在此代码中,我们创建了一个页面,该页面在按钮点击时增加计数器,点击事件的逻辑由 C# 代码处理,该代码更新 HTML 中的值。此页面可以通过 /counter 相对 URL 访问。

Blazor 与其他 MVC/Razor Pages 之间的主要区别在于,与请求-响应模型不同,其中每个请求都发送到服务器,并将 HTML 发送回浏览器,Blazor 将所有组件打包(就像 SPA 一样)并在客户端加载。当应用程序首次请求时,任何后续对服务器的调用都是为了检索/提交任何 API 数据或更新 DOM。Blazor 支持以下两种托管模型:

  • Blazor WebAssemblyWASM):WASM 是可以在现代浏览器上运行的低级指令,这有助于在浏览器上运行用高级语言(如 C#)编写的代码,而无需任何附加插件。Blazor WASM 托管模型利用 WASM 提供的开放网络标准,在浏览器上的沙盒环境中运行任何 Blazor WASM 应用程序的 C# 代码。在较高层次上,所有 Blazor 组件都编译成 .NET 程序集,并下载到浏览器,WASM 加载 .NET Core 运行时和所有程序集。它进一步使用 JavaScript 互操作来刷新 DOM;唯一的服务器调用将是任何后端 API。架构如下所示:

图 11.11 – Blazor WASM 托管

图 11.11 – Blazor WASM 托管

  • blazor.server.js 并使用 SignalR 接收所有 DOM 更新,这意味着每个用户交互都会有一个服务器调用(尽管非常轻量)。架构如下所示:

图 11.12 – Blazor Server 托管

图 11.12 – Blazor Server 托管

.NET 6 为两种托管模型提供完整的工具支持,包括它们自己的项目模板,并且各有优缺点,这些将在以下内容中进一步解释:docs.microsoft.com/en-us/aspnet/core/blazor/hosting-models?view=aspnetcore-6.0

现在让我们按照以下步骤使用 Blazor Server 应用程序创建一个前端应用程序,这允许我们为我们的电子商务应用程序添加/修改产品详情:

  1. 将名为 Packt.Ecommerce.Blazorweb 的新 Blazor Server 应用程序添加到企业解决方案中,并将 Products.razorAddProduct.RazorEditProduct.razor Razor 组件添加到 Pages 文件夹。

  2. 此项目包含 Program 类,它与其他任何 ASP.NET Core 应用程序完全相同,只是增加了一些额外的 Blazor 服务。_Host.cshtml 是应用程序的根,应用程序的初始调用由该页面接收,并以 HTML 进行响应。此页面进一步引用 blazor.server.js 脚本文件以建立 SignalR 连接。另一个重要组件是 App.Razor 组件,它负责基于 URL 的路由。在 Blazor 中,任何需要映射到特定 URL 的组件都将在其开头包含 @page 指令,该指令指定应用程序的相对 URL。App.Razor 拦截 URL 并将它们路由到指定的组件。所有 Razor 组件都是 Pages 文件夹的一部分,而 Data 文件夹包含用于 FetchData.razor 组件的示例模型和服务。

  3. 让我们在 NavMenu.razor 中添加以下代码以将 Products 导航添加到左侧菜单。在这个阶段,如果你运行应用程序,你应该能够看到带有 Products 导航的左侧菜单;然而,它不会导航到任何页面:

    <li class='nav-item px-3'>
    <NavLink class='nav-link' href='products'>
      <span class='oi oi-list-rich' aria-
       hidden='true'></span> Products
    </NavLink>
    </li>
    
  4. 由于我们将从 API 获取数据,我们需要将 HTTPClient 注入到我们的 Program 类中,就像在 ASP.NET Core 应用程序中所做的那样。因此,将以下代码添加到 Program 类中:

    builder.Services.AddHttpClient("Products", client =>
    {
        client.BaseAddress = new Uri(builder.Configuration["ApplicationSettings:ProductsApiEndpoint"]);
    });
    
  5. 将以下 ApplicationSettings:ProductsApiEndpoint 设置添加到 appsettings.json

    'ApplicationSettings': {
        'ProductsApiEndpoint': 
        'https://localhost:7256/api/products/'
      },
    
  6. 由于我们将要绑定 products 数据,所以让我们将 Packt.Ecommerce.DTO.Models 添加为 Packt.Ecommerce.Blazorweb 项目的引用。在 Pages 文件夹中,将以下代码添加到 Products.razor 页面的 @code 块内,我们在其中使用 IHttpClientFactory 创建一个 HttpClient 对象,该对象将在下一步注入,并在 OnInitializedAsync 方法中检索 products 数据:

    private List<ProductListViewModel> products;
    protected override async Task OnInitializedAsync()
        {
            var client = Factory.CreateClient('Products');
            var result = await 
              client.GetAsync('').ConfigureAwait(false);
            result.EnsureSuccessStatusCode();
            products = new List<ProductListViewModel>();
            products = await 
              result.Content.ReadFromJsonAsync
              <List<ProductListViewModel>>()
              .ConfigureAwait(false);
        }
    
  7. 接下来,在Products.Razor页面的开头添加以下代码(在@code块外部)。在这里,我们通过@page指令设置此组件的相对路由为/products。接下来,我们注入IHttpClientFactory和其他所需的命名空间,然后添加渲染产品列表的 HTML 部分。如您所见,它是 HTML 和 Razor 语法的混合体:

    @page '/products'
    @inject IHttpClientFactory Factory
    @using System.Net.Http.Json;@using Packt.Ecommerce.DTO.Models;
    <h1>Products</h1>
    <div>    <a class='btn btn-info' href='addproduct'><i class='oi oi-plus'></i> Add Product</a> </div>
    @if (products == null)
    { <p><em>Loading...</em></p> }
    else { <table class='table'><thead><tr>
                    <th>Id</th><th>Name</th>
                    <th>Price</th><th>Quantity</th>
                    <th>ImageUrls</th><th></th>
                </tr></thead><tbody>
                @foreach (var product in products)
                {<tr>
                       <td>@product.Id</td>
                        <td>@product.Name</td>
                        <td>@product.Price</td>
                        <td>@product.Quantity</td>
                        <td><img 
                             src='@product.ImageUrls[0]' 
                             class='product-image w-10 
                             col-3' alt='Product' /></td>
                        <td><a class='btn btn-info' 
                              href='editproduct/
                              @product.Id/@product.Name'>
                              <i class='oi oi-pencil'>
                              </i></a></td></tr>
                }
            </tbody></table> }
    

在这一点上,如果您运行应用程序,应该会看到以下截图所示的输出:

图 11.13 – 产品列表 Blazor UI

图 11.13 – 产品列表 Blazor UI

  1. 接下来,让我们创建Add/Edit页面,我们将使用 Blazor 表单。表单中可用的一些重要工具/组件如下。

Blazor 表单是使用 Blazor 中称为EditForm的内置模板创建的,并且可以直接使用模型属性绑定到任何 C#对象。一个典型的EditForm如下面的代码片段所示。在这里,我们定义在表单提交时调用OnSubmit方法。让我们将其添加到AddProduct.razor中:

<EditForm Model='@product' OnSubmit='@OnSubmit'>
</EditForm>

在这里,product是我们想要使用的模型对象,在我们的案例中是Packt.Ecommerce.DTO.Models.ProductDetailsViewModel。要将数据绑定到任何控件,我们可以使用 HTML 和 Razor 语法的组合,如下面的代码所示。在这里,我们将产品对象的Name属性绑定到文本框,同样,将Category属性绑定到下拉列表。一旦你在文本框中输入任何值或在下拉列表中选择一个值,它就会自动出现在这些属性中,以便将其传递回任何后端 API 或数据库。让我们以类似的方式将所有必需的属性添加到 HTML 元素中:

<InputText id='category' @bind-Value='product.Name'></InputText>
<InputSelect @bind-Value='product.Category'>
<option selected disabled value='-1'> Choose Category</option>
<option value='Clothing'>Clothing</option>
<option value='Books'>Books</option>
</InputSelect>

Blazor 表单支持使用数据注解进行数据验证,因此任何我们想要绑定到 UI 的模型都可以有数据注解,Blazor 会自动将这些验证应用到绑定的控件上。要应用验证,我们添加DataAnnotationsValidator组件,并可以使用ValidationSummary组件来显示所有验证失败的摘要。更多详细信息请参阅docs.microsoft.com/en-us/aspnet/core/blazor/forms-validation?view=aspnetcore-6.0。我们还可以在控件级别使用ValidationMessage组件,如下面的代码片段所示:

<DataAnnotationsValidator />
<ValidationSummary />
<InputNumber id='quantity' @bind-Value='product.Quantity'></InputNumber>
<ValidationMessage For='@(() => product.Quantity)' />
  1. code组件中,添加一个ProductDetailsViewModel对象,并将其命名为 product,即如EditFormModel属性中定义的那样,并进一步实现OnSubmit方法。

AddProduct.RazorEditProduct.Razor的完整代码可以在 GitHub 仓库中找到,一旦运行应用程序,我们就可以看到以下页面:

图 11.14 – 添加产品 Blazor UI

图 11.14 – 添加产品 Blazor UI

这是一个使用 Blazor 构建前端的基本示例,它执行列表、创建和更新操作。然而,Blazor 中还有许多概念可以进一步探索,请参阅docs.microsoft.com/en-us/aspnet/core/blazor/?view=aspnetcore-6.0

摘要

在本章中,我们了解了表示层和 UI 设计的各个方面。此外,我们还学习了使用 ASP.NET Core MVC 和 Razor Pages 开发表示层的各种技能,最后,我们使用 ASP.NET Core MVC 和 Blazor 为我们的企业应用程序实现了表示层。

使用这些技能,你应该能够使用 ASP.NET Core MVC、Razor Pages 和 Blazor 构建表示层,并将其与后端 API 集成。

在下一章中,我们将看到如何将身份验证集成到我们系统的各个应用层中。

问题

  1. 以下哪个是推荐用于定义在整个 Web 应用程序中需要出现的左侧导航的页面?

a. _ViewStart.cshtml

b. _ViewImports.cshtml

c. _Layout.cshtml

d. Error.cshtml

答案:c

  1. 以下哪个页面可以用来配置整个应用程序的 Layout 页面?

a. _ViewStart.cshtml

b. _ViewImports.cshtml

c. _Layout.cshtml

d. Error.cshtml

答案:a

  1. 以下哪个特殊字符用于在 .cshtml 页面中编写 Razor 语法?

a. @

b. #

c. <% %>

d. 以上皆非

答案:a

  1. 在以下 Razor 页面应用程序的标签助手代码中,哪个方法会在按钮点击时被调用?

    <input type='submit' asp-page-handler='Delete' value='Delete' />
    

a. OnGet()

b. onDelete()

c. OnPostDelete()

d. OnDeleteAsync()

答案:c

进一步阅读

第四部分:安全

本部分讨论编程的安全方面,包括身份验证、授权和加密,并解释在任何企业应用程序中,Web 层和服务层都需要如何进行安全保护。

本部分包括以下章节:

  • 第十二章**,理解身份验证

  • 第十三章**,在.NET 6 中实现授权

第十二章:第十二章:理解身份验证

到目前为止,我们已经构建了我们电子商务应用的用户界面UI)和服务层。在本章中,我们将学习如何对其进行安全保护。我们的电子商务应用应该能够唯一地识别一个用户并对该用户的请求做出响应。建立用户身份的一个常用模式是提供用户名和密码。然后,这些信息将与存储在数据库或应用中的用户配置文件数据进行比较。如果匹配,将生成并存储一个包含用户身份的 cookie 或 token 在客户端的浏览器中,以便在后续请求中,将 cookie/token 发送到服务器并验证以服务请求。

身份验证是一个识别访问您应用程序受保护区域的用户或程序的过程。例如,在我们的电子商务应用中,用户可以浏览显示的不同页面和产品。然而,要下订单或查看过去的订单,用户需要提供用户名和密码来识别自己。如果用户是新的,他们应该创建这些信息以继续。

在本章中,我们将学习 ASP.NET Core 提供的与身份验证相关的功能,并了解实现身份验证的各种方法。在本章中,我们将涵盖以下主题:

  • 理解身份验证的元素

  • ASP.NET Core Identity 简介

  • 理解 OAuth 2.0

  • Azure Active Directory(Azure AD)简介

  • Windows 身份验证简介

  • 理解保护客户端和服务器应用程序的最佳实践

技术要求

对于本章,您需要具备 Azure、Entity FrameworkEF)、Azure AD B2C 和一个具有贡献者角色的活动 Azure 订阅的基本知识。如果您没有,您可以在 azure.microsoft.com/en-in/free/ 上注册一个免费账户。Visual Studio 2022 用于说明一些示例。您可以从 visualstudio.microsoft.com 下载它。

理解 .NET 6 中身份验证的元素

ASP.NET Core 中的身份验证由身份验证中间件处理,它使用注册的身份验证处理程序执行身份验证。已注册的身份验证处理程序及其相关配置称为身份验证方案

以下列表描述了身份验证框架的核心元素:

  • Program.cs。它们包含一个身份验证处理程序,并具有配置此处理程序的选择。您可以注册多个身份验证方案以进行身份验证、挑战和禁止操作。或者,您可以在您配置的授权策略中指定身份验证方案。以下是一个注册 OpenIdConnect 身份验证方案的示例代码:

    services.AddAuthentication(OpenIdConnectDefaults.AuthenticationScheme)
    .AddMicrosoftIdentityWebApp(this.Configuration.GetSection("AzureAdB2C"));
    

在前面的代码片段中,认证服务被注册为使用与 Microsoft 身份平台的OpenIdConnect认证方案。此外,配置文件中AzureAdB2C部分指定的必要设置被用于初始化认证选项。

本章的Azure AD 简介部分将涵盖有关OpenIdConnectAzureAdB2C的更多详细信息。

  • 认证处理程序: 认证处理程序负责认证用户。根据认证方案,它们要么构建一个认证票据(通常,这是一个带有用户身份的令牌/cookie),要么在认证失败时拒绝请求。

  • 认证: 此方法负责构建带有用户身份的认证票据。例如,一个 cookie 认证方案会构建一个 cookie,而一个JavaScript 对象表示法JSONWeb 令牌JWT)承载方案会构建一个令牌。

  • 挑战: 当一个未经身份验证的用户请求需要认证的资源时,此方法由授权调用。根据配置的方案,用户随后会被要求进行认证。

  • 禁止: 当一个经过身份验证的用户尝试访问他们无权访问的资源时,此方法由授权调用。

现在,让我们了解如何使用 ASP.NET Core Identity 框架添加认证。

ASP.NET Core Identity 简介

ASP.NET Core Identity 是一个基于成员的系统,它为您的应用程序提供了轻松添加登录和用户管理功能的方法。它提供 UI 和应用程序编程接口API)来创建新用户账户、提供电子邮件确认、管理用户配置文件数据、管理密码(如更改或重置密码)、执行登录、注销等,并启用多因素认证MFA)。此外,它允许您与外部登录提供者(如 Microsoft Account、Google、Facebook、Twitter 以及许多其他社交网站)集成。这样,用户可以使用他们现有的账户进行注册,而不是必须创建新的账户,从而增强用户体验。

默认情况下,ASP.NET Core Identity 使用 EF Code-First 方法在 SQL Server 数据库中存储用户信息,如用户名、密码等。此外,它允许您自定义表/列名称,并捕获额外的用户数据,例如用户的出生日期、电话号码等。您还可以自定义它以将数据保存到不同的持久存储中,例如 Azure Table Storage 或 NoSQL 数据库。它还提供了一个 API 来自定义密码散列、密码验证等。

在下一节中,我们将学习如何创建一个简单的 Web 应用程序并将其配置为使用 ASP.NET Core Identity 进行认证。

示例实现

在 Visual Studio 2022 中,创建一个新的项目,选择 ASP.NET Core Web 应用程序 模板,提供你的项目详细信息以继续,并更改 认证类型。你将找到以下选项供你选择:

  • :如果你不需要你的应用程序进行认证,请选择此选项。

  • 个人账户:如果你使用本地存储或 SQL 数据库来管理用户身份,请选择此选项。

  • Microsoft Identity Platform:如果你希望对 Azure AD 或 Azure AD B2C 进行用户认证,请选择此选项。

  • Windows:如果你的应用程序仅可在内网上使用,请选择此选项。

对于这个示例实现,我们将使用本地存储来保存用户数据。选择 个人账户,然后点击 创建 来创建项目,如图所示:

![图 12.1 – 认证类型

![图 12.1 – 图 12.1_B18507.jpg]

图 12.1 – 认证类型

或者,你可以使用 dotnet SQLite 作为数据库存储,如下所示:

dotnet new webapp --auth Individual -o AuthSample

要将 SQL 数据库配置为存储,请运行以下命令,确保应用迁移以在数据库中创建必要的表:

dotnet new webapp --auth Individual -uld -o AuthSample

现在,运行以下命令来构建和运行应用程序:

dotnet run --project ./AuthSample/AuthSample.csproj

你应该会看到以下类似的输出:

![图 12.2 – 参考的 dotnet run 命令输出

![图 12.2 – 参考的 dotnet run 命令输出

图 12.2 – 参考的 dotnet run 命令输出

在前面的屏幕截图中,注意控制台日志和应用程序可访问的 统一资源定位符URL)及其端口号。

现在应用程序已经启动并运行,请在浏览器中打开 URL 并点击 注册。提供所需详细信息,然后点击 注册 按钮。你可能会在第一次尝试时看到以下错误信息:

![图 12.3 – 由于缺少迁移导致的运行时异常

![图 12.3 – 图 12.3_B18507.jpg]

图 12.3 – 由于缺少迁移导致的运行时异常

你可以点击 Update-Database 来应用迁移并重新运行应用程序。现在,你应该能够注册和登录到应用程序。接下来,让我们检查为我们创建的项目结构。

依赖包 下,你会注意到以下 NuGet 包:

  • Microsoft.AspNetCore.Identity.UI: 这是一个 Razor 类库,它包含了整个身份 UI,你可以通过浏览器进行导航——例如,/Identity/Account/Register/Identity/Account/Login

  • Microsoft.AspNetCore.Identity.EntityFrameworkCore: 这是由 ASP.NET Core Identity 用于与数据库存储交互的。

  • Microsoft.EntityFrameworkCore.SqlServer: 这是一个用于与 SQLDB 交互的库。

包可以在以下屏幕截图中看到:

![图 12.4 – AuthSample 项目的解决方案资源管理器视图

![图 12.4 – 图 12.4_B18507.jpg]

图 12.4 – AuthSample 项目的解决方案资源管理器视图

现在,让我们检查 Program.cs 的代码。

以下代码注册了启用身份验证功能的身份验证中间件:

app.UseAuthentication();

ApplicationDbContext通过提供包含在appsettings.json中指定的sql database连接字符串的options配置作为依赖服务进行注册,如下所示:

 var connectionString = builder.Configuration.GetConnectionString("DefaultConnection");
builder.Services.AddDbContext<ApplicationDbContext>(options => options.UseSqlServer(connectionString));
services.AddDefaultIdentity<IdentityUser>(options =>
options.SignIn.RequireConfirmedAccount = true)
.AddEntityFrameworkStores<ApplicationDbContext>();

AddDefaultIdentity方法注册了生成 UI 并使用IdentityUser作为模型配置默认身份系统的服务。

ASP.NET Core Identity 允许我们配置多个身份选项以满足我们的需求——例如,以下代码允许我们禁用电子邮件确认,配置密码要求,并设置锁定超时设置:

services.AddDefaultIdentity<IdentityUser>(options =>
{
  options.SignIn.RequireConfirmedAccount = false;
  options.Password.RequireDigit = true;
  options.Password.RequireNonAlphanumeric = true;
  options.Password.RequireUppercase = true;
  options.Password.RequiredLength = 8;
  options.Lockout.DefaultLockoutTimeSpan =
  TimeSpan.FromMinutes(5);
  options.Lockout.MaxFailedAccessAttempts = 5;
})
.AddEntityFrameworkStores<ApplicationDbContext>();

更多详情,请参阅docs.microsoft.com/en-us/aspnet/core/security/authentication/identity?view=aspnetcore-6.0

框架

为了进一步自定义 UI 和其他设置,您可以选择性地添加 Razor 类库中包含的源代码。然后,您可以修改生成的源代码以满足您的需求。要创建框架,在解决方案资源管理器中,右键单击项目 | 添加 | 新建框架项 | 身份 | 添加

这将打开一个窗口,您可以在其中选择要覆盖的文件,如下面的截图所示:

图 12.5 – 覆盖身份模块的对话框

图 12.5 – 覆盖身份模块的对话框

您可以选择覆盖所有文件或仅选择您想要自定义的文件。选择您的数据上下文类,然后单击Identity文件夹——Razor 和相应的 C#文件都将被添加。以下截图说明了基于选择添加的文件:

图 12.6 – AuthSample 项目的解决方案资源管理器视图

图 12.6 – AuthSample 项目的解决方案资源管理器视图

更多有关自定义的详情,请参阅docs.microsoft.com/en-us/aspnet/core/security/authentication/scaffold-identity?view=aspnetcore-6.0

现在,让我们了解如何将 ASP.NET Core 应用程序与外部登录提供者集成。

与外部登录提供者集成

在本节中,我们将学习如何将 ASP.NET Core 应用程序与外部登录提供者集成,例如 Microsoft Account、Google、Facebook、Twitter 等。此外,我们还将探讨如何使用 OAuth 2.0 流程进行身份验证,以便用户可以使用他们现有的凭据注册并访问我们的应用程序。将 ASP.NET Core 应用程序与任何外部登录提供者集成的常见模式如下:

  1. 获取用于从各自的开发者门户访问 OAuth API 进行身份验证的凭据(通常是客户端 ID 和密钥)。

  2. 在应用程序设置或用户密钥中配置凭据。

  3. 接下来,我们需要在添加中间件支持时将相应的 NuGet 包添加到项目中,以使用 OpenID 和 OAuth 2.0 流程。

  4. Program.cs中,添加AddAuthentication方法以注册身份验证中间件。

配置 Google

要将 Google 配置为外部登录提供者,你需要执行以下步骤:

  1. developers.google.com/identity/sign-in/web/sign-in创建 OAuth 凭据。

  2. 在用户密钥中配置凭据。你可以使用dotnet CLI 将密钥添加到你的项目中,如下所示:

    dotnet user-secrets set "Authentication:Google:ClientId" "<client-id>"
    dotnet user-secrets set "Authentication:Google:ClientSecret" "<client-secret>"
    
  3. Microsoft.AspNetCore.Authentication.Google NuGet 包添加到你的项目中,并将以下代码添加到Program.cs

    services.AddAuthentication()
            .AddGoogle(options =>
            {
                IConfigurationSection googleAuthNSection =
                   Configuration.GetSection
                  ("Authentication:Google");
                options.ClientId =
                 googleAuthNSection["ClientId"];
                options.ClientSecret =
                 googleAuthNSection["ClientSecret"];
            });
    
  4. 类似地,你可以添加多个提供者。

要了解如何与其他流行的外部身份验证提供者集成,你可以参考docs.microsoft.com/en-us/aspnet/core/security/authentication/social/?view=aspnetcore-6.0

完成前面的步骤后,你应该能够使用 Google 凭据登录到你的应用程序。这结束了本节关于在应用程序中使用 ASP.NET Core Identity 与外部登录提供者进行身份验证的内容。在下一节中,让我们看看 OAuth 是什么。

理解 OAuth 2.0

OAuth 2.0 是一种现代且行业标准的安全 Web API 协议。它通过为 Web 应用、单页应用、移动应用等提供特定的授权流程来简化流程,以便访问受保护的 API。

让我们考虑一个用例,其中你想要构建一个网络门户,用户可以从他们喜欢的应用程序(如 Instagram、Facebook 或其他第三方应用程序)同步和查看照片/视频。你的应用程序应该能够代表用户从第三方应用程序请求数据。一种方法涉及存储与每个第三方应用程序相关的用户凭据,并且你的应用程序代表用户发送或请求数据。

这种方法可能导致许多问题。以下是如何概述:

  • 你需要设计你的应用程序以安全地存储用户凭据。

  • 用户可能不习惯他们的凭据被第三方应用程序在你的应用程序中共享和存储。

  • 如果用户更改了他们的凭据,它们需要在你应用程序中更新。

  • 在安全漏洞的情况下,欺诈者可以无限制地访问第三方应用程序中用户的数据。这可能导致潜在的收入和声誉损失。

OAuth 2.0 可以通过解决所有这些关注点来处理所有上述用例。让我们看看它是如何做到这一点的,如下所示:

  1. 用户登录到您的应用程序。为了同步图片/视频,用户将被重定向到第三方应用程序,并需要使用其凭据登录。

  2. OAuth 2.0 审查并批准应用程序获取资源的请求。

  3. 用户带着授权码被重定向回您的应用程序。

  4. 为了同步图片/视频,您的应用程序可以通过交换授权码并使用该令牌调用第三方应用程序的 API 来获取一个令牌。

  5. 对于每个请求,第三方应用程序验证令牌并相应地响应。

在 OAuth 流程中,涉及四个方面:客户端资源所有者授权服务器资源服务器。请参考以下截图:

图 12.7 – OAuth2 流程

图 12.7 – OAuth2 流程

从前面的截图,我们可以看到以下内容:

  • 客户端:这指的是从授权服务器获取令牌并代表资源所有者向资源服务器发出请求的应用程序。

  • 资源所有者:这是一个拥有资源/数据并能够授予客户端访问权限的实体。

  • 授权服务器:这验证资源所有者并向客户端发行令牌。

  • 资源服务器:这是托管资源或与资源所有者相关的数据的服务器,使用载体令牌进行验证,并对来自客户端的请求进行响应或拒绝。

令牌

授权服务器验证用户并提供了 ID 令牌、访问令牌和刷新令牌,这些令牌被原生/网络应用程序用于访问受保护的服务。让我们更深入地了解它们:

  • 访问令牌:这是授权服务器作为 OAuth 流程的一部分发行的,通常以 JWT 格式;一个包含发行者、用户、范围、过期时间等信息的基础 64 编码的 JSON 对象。

  • 刷新令牌:这是授权服务器与访问令牌一起发行的,客户端应用程序在访问令牌过期之前使用它来请求新的访问令牌。

  • ID 令牌:这是授权服务器作为 OpenID Connect 流程的一部分发行的,可以用来验证用户。

    注意

    OpenID Connect 是建立在 OAuth2 之上的一个身份验证协议。它可以用来验证身份验证服务器上的用户身份。

授权类型

OAuth 2.0 定义了客户端获取令牌以访问受保护资源的方法——这些被称为授权。它定义了五种授权类型:授权码流程、隐式流程、代表流程、客户端凭据流程和设备授权流程。它们在此概述:

  • 授权码流:这种流适用于 Web、移动和单页应用程序,其中您的应用程序需要从另一个服务器获取数据。授权码流从客户端将用户重定向到授权服务器进行身份验证开始。如果成功,用户会同意客户端所需的权限,并被带有授权码重定向回客户端。

在这里,客户端的身份是通过授权服务器配置的重定向统一资源标识符URI)进行验证的。接下来,客户端通过传递授权码来请求访问令牌,并作为回报,获得访问令牌、刷新令牌和过期日期。客户端可以使用访问令牌来调用 Web API。由于访问令牌的寿命较短,在它们过期之前,客户端应通过传递访问令牌和刷新令牌来请求新的访问令牌。

  • 隐式流:这是一个适合单页、基于 JavaScript 的应用程序的简化代码流版本。在隐式流中,授权服务器不是发出授权码,而是只发出访问令牌。在这里,客户端身份未经验证,因为没有必要指定重定向 URL。

  • 代表流:这种流最适合客户端调用 Web API(例如,A)的情况,该 API 反过来需要调用另一个 API(例如,B)。流程是这样的:用户将带有令牌的请求发送到 A;A 通过提供 A 的令牌和凭据(如 A 的客户 ID 和客户密钥)从授权服务器请求 B 的令牌。一旦它获得了 B 的令牌,它就在 B 上调用 API。

  • 客户端凭据流:这种流用于需要服务器到服务器交互的情况(例如,A 到 B,其中 A 通过其凭据(通常是客户 ID 和客户密钥)获取令牌以与 B 交互,然后使用获得的令牌调用 API)。此请求在 A 的上下文中运行,而不是在用户的上下文中。应授予 A 执行必要操作所需的权限。

  • 设备授权流:这种流用于用户需要在没有浏览器的情况下登录设备的情况,例如智能电视、物联网设备或打印机。用户访问移动或 PC 上的网页进行身份验证,并输入设备上显示的代码以获取令牌并刷新设备的令牌以连接。

现在我们已经了解了 OAuth 是什么,在下一节中,让我们了解 Azure AD 是什么,如何将其集成到我们的电子商务应用程序中,以及如何将其用作我们的身份服务器。

Azure AD 简介

Azure AD 是来自 Microsoft 的身份和访问管理IAM)云服务。它为内部和外部用户提供了一个单一的身份存储库,因此您可以配置应用程序使用 Azure AD 进行身份验证。您可以将本地 Windows AD 同步到 Azure AD;因此,您可以为您的用户提供单点登录SSO)体验。

用户可以使用他们的工作或学校凭证或个人 Microsoft 账户(如Outlook.com、Xbox 和 Skype)登录。它还允许您原生地添加或删除用户、创建组、进行自助密码重置、启用 Azure MFA 以及更多功能。

使用 Azure AD B2C,您可以自定义用户注册、登录和管理其个人资料的方式。此外,它还允许您的客户使用他们现有的社交凭证,如 Facebook 和 Google,来登录并访问您的应用程序和 API。

Azure AD 符合行业标准协议,如OpenID Connect,也称为OIDCOAuth 2.0。OIDC 是在 OAuth 2.0 协议之上构建的身份层,用于验证和检索用户的个人资料信息。OAuth 2.0 用于授权,通过不同的流程(如隐式授权流程、代表流程、客户端凭证流程、代码流程和设备授权流程)获取对 HTTP 服务的访问权限。

Web 应用程序中典型的身份验证流程如下:

  1. 用户尝试访问应用程序的安全内容(例如,我的订单)。

  2. 如果用户未进行身份验证,则会被重定向到 Azure AD 登录页面。

  3. 一旦用户提交了他们的凭证,Azure AD 就会进行验证,并将令牌发送回 Web 应用程序。

  4. 一个 cookie 被保存在用户的浏览器中,并显示用户请求的页面。

  5. 在后续请求中,会发送一个 cookie 到服务器,用于验证用户。

Azure AD B2C允许您的客户使用他们首选的社交、企业或本地身份来访问您的应用程序或 API。它可以扩展到每天数百万用户和数十亿次身份验证。

让我们尝试将我们的电子商务应用程序与 Azure AD B2C 集成。在高层面上,我们需要执行以下步骤来集成:

  1. 创建 Azure AD B2C 租户。

  2. 注册应用程序。

  3. 添加身份提供者。

  4. 创建用户流程。

  5. 更新应用程序代码以进行集成。

    注意

    作为先决条件,您应该有一个活跃的 Azure 订阅,并具有贡献者角色。如果您还没有,您可以在azure.microsoft.com/en-in/free/注册一个免费账户。

Azure AD B2C 的设置

使用 Azure AD B2C 作为身份服务将允许我们的电子商务用户注册、创建他们自己的凭证或使用他们现有的社交凭证,如 Facebook 或 Google。让我们看看我们需要执行以下步骤来配置 Azure AD B2C 作为我们的电子商务应用程序的身份服务:

  1. 登录到 Azure 门户,确保您位于包含您的订阅的同一目录中。

  2. 主页 上,点击 创建资源 并搜索 B2C。然后,从选项列表中选择 Azure Active Directory B2C

  3. 选择 创建新的 Azure AD B2C 租户,如图下截图所示:

![图 12.8 – Azure AD B2C图 12.8 – B18507.jpg

图 12.8 – Azure AD B2C

  1. 提供所需的详细信息,然后点击 审查 + 创建。然后,完成以下字段:

组织名称:这是您的 B2C 租户的名称。

内部域名:这是您的租户的内部域名。

国家/地区:选择您的租户应部署的国家或地区。

订阅资源组:提供订阅和资源组详细信息。

这些字段在以下截图中显示:

![图 12.9 – 新的 Azure AD B2C 配置部分图 12.9 – B18507.jpg

图 12.9 – 新的 Azure AD B2C 配置部分

  1. 审查您的详细信息,然后点击 创建。创建新租户可能需要几分钟。一旦创建完成,您将在通知部分看到确认消息。在 通知 弹出窗口中,点击租户名称以导航到新创建的租户,如图下截图所示:

![图 12.10 – Azure AD B2C 服务创建确认图 12.10 – B18507.jpg

图 12.10 – Azure AD B2C 服务创建确认

  1. 在以下截图中,请注意 订阅状态 显示为 无订阅。另外,一条警告信息指出您应该 将订阅链接到您的租户。您可以点击链接进行修复,否则可以跳转到 步骤 9 继续配置 Azure AD:

![图 12.11 – 显示未链接订阅的警告信息图 12.11 – B18507.jpg

图 12.11 – 显示未链接订阅的警告信息

  1. 链接将打开与 步骤 3 中相同的屏幕。这次,点击 将现有 Azure AD B2C 租户链接到我的 Azure 订阅 以继续,如图下截图所示:

![图 12.12 – 将 Azure AD B2C 租户链接到订阅图 12.12 – B18507.jpg

图 12.12 – 将 Azure AD B2C 租户链接到订阅

  1. 从下拉列表中选择您的 B2C 租户订阅,提供一个 资源组 值,然后点击 创建 以链接订阅和租户,如图下截图所示:

![图 12.13 – 订阅选择图 12.13 – B18507.jpg

图 12.13 – 订阅选择

  1. 您可以通过在 B2C 租户概览部分选择 打开 B2C 租户 链接来导航到您的 B2C 租户,继续配置的下一步,如下所示:

    • 您需要将应用程序注册到 Azure AD B2C 租户中,才能将其用作身份服务。

    • 您需要选择用户可以用来登录您应用的标识提供者。

    • 选择用户流程以定义用户注册或登录的体验,如下所示截图:

![图 12.14 – 配置 Azure AD B2C 的三个步骤img/Figure_12.14_B18507.jpg

图 12.14 – 配置 Azure AD B2C 的三个步骤

  1. 管理 下,点击 应用程序注册 并提供以下必要详细信息。然后,点击 注册 以创建 AD 应用程序:

名称:显示应用程序的显示名称。

支持的账户类型:选择 任何标识提供者或组织目录中的账户,这样我们就可以允许用户使用他们现有的凭据进行注册或登录。

重定向 URI:您需要提供用户在成功认证后将被重定向到的应用程序的 URL。目前,我们可以将其留空。

权限:选择 授予 openid 和 offline_access 权限的管理员同意

以下截图说明了这些字段:

![图 12.15 – 注册新的 Azure AD 应用程序img/Figure_12.15_B18507.jpg

图 12.15 – 注册新的 Azure AD 应用程序

注意

为了本地设置和调试,我们可以使用 localhost 进行配置。这需要替换为您应用程序托管位置的 URL。

  1. 现在,让我们在 管理 下的 标识提供者 中进行选择以进行配置,如下所示:

本地账户:此选项允许用户以传统的用户名和密码方式在我们的应用程序中注册和登录。以下截图说明了这一点:

![图 12.16 – 选择标识提供者img/Figure_12.16_B18507.jpg

图 12.16 – 选择标识提供者

  1. 让我们配置 Google 作为我们应用程序的标识提供者。您可以通过访问 docs.microsoft.com/en-in/azure/active-directory-b2c/identity-provider-google 中的步骤来获取客户端 ID 和密钥。您需要提供的详细信息如下所示:

![图 12.17 – Google:新的 OAuth 客户端img/Figure_12.17_B18507.jpg

图 12.17 – Google:新的 OAuth 客户端

在您提供所需详细信息并选择保存后,将生成客户端 ID 和密钥,如下所示:

![图 12.18 – Google OAuth 客户端img/Figure_12.18_B18507.jpg

图 12.18 – Google OAuth 客户端

  1. 一旦您创建了 认证客户端,请从 标识提供者 中点击 Google,然后提供 客户端 ID客户端密钥 值以完成配置。请参考以下截图:

![图 12.19 – 标识提供者配置img/Figure_12.19_B18507.jpg

图 12.19 – 标识提供者配置

  1. 让我们配置 Facebook 作为我们电子商务应用程序的另一个身份提供者。您可以按照 docs.microsoft.com/en-in/azure/active-directory-b2c/identity-provider-facebook 中概述的步骤进行操作。

  2. 一旦您创建了 客户端身份验证 设置,请点击 Facebook身份提供者 中,然后提供 客户端 ID客户端密钥 值以完成配置。请参阅 图 12.19 了解概述。

  3. 现在,让我们配置用户流程。用户流程允许您为您的用户配置和自定义身份验证体验。您可以在您的租户中配置多个流程并在您的应用程序中使用它们。用户流程允许您添加多因素认证(MFA)并自定义在注册时从用户那里捕获的信息——例如,他们的名字、国家、邮政编码,以及可选的,您是否希望将他们添加到声明中。您还可以自定义用户界面以获得更好的用户体验。要创建一个流程,请点击 策略 下的 用户流程 并选择一个流程类型,如图下截图所示:图 12.20 – 新用户流程

图 12.20 – 新用户流程

  1. 提供必要的详细信息,然后点击 创建 保存:

名称:这是您流程的唯一名称。

身份提供者:选择您的身份提供者。

可选地,您可以选择额外的用户属性,如 名称邮政编码 等,如图下截图所示:

图 12.21 – 用户流程配置

图 12.21 – 用户流程配置

您可以在 用户属性和令牌声明 部分选择额外的属性,如图下截图所示:

图 12.22 – 额外的属性和声明配置

图 12.22 – 额外的属性和声明配置

  1. 类似地,我们也应该设置密码重置策略。这对于本地账户是必需的。要创建一个,请在 创建用户流程 下选择 密码重置 并提供必要的详细信息。您可以参考 图 12.20

  2. 完成了 Azure AD B2C 的最小必要设置后,我们准备测试流程。选择创建的用户流程并点击 运行用户流程。您可以看到为您创建的注册和登录页面,如图下截图所示:

图 12.23 – 登录和注册屏幕

图 12.23 – 登录和注册屏幕

让我们看看在 Packt.Ecommerce.Web 中我们需要进行哪些更改以集成 Azure AD。

将我们的电子商务应用程序与 Azure AD B2C 集成

我们将在 Web 应用程序上配置身份验证以使用 Azure AD B2C。让我们对我们的应用程序进行必要的更改以集成到 B2C 租户,如下所示:

  1. 将以下两个 NuGet 包添加到我们的Packt.Ecommerce.Web项目中:

Microsoft.Identity.Web: 这是集成到 Azure AD 所需的主要包。

Microsoft.Identity.Web.UI: 此包生成登录和注销的 UI。在Program.cs中,我们需要使用OpenIdConnect方案以及Azure AD B2C配置添加一个身份验证服务,如下所示:

services.AddAuthentication(OpenIdConnectDefaults.AuthenticationScheme).AddMicrosoftIdentityWebApp(Configuration.GetSection("AzureAdB2C"));
services.AddRazorPages().AddMicrosoftIdentityUI();
  1. Configure方法中,在app.UseAuthorization()方法之前添加以下代码:

    app.UseAuthentication();
    
  2. 我们需要将AzureAdB2C添加到appsettings.json中,如下所示:

实例: https://<domain>.b2clogin.com/tfp。将<domain>替换为您在创建 B2C 租户时选择的名称。

客户端 ID: 这是您在设置 Azure AD B2C 时创建的 AD 应用程序 ID。

: <domain>.onmicrosoft.com。在此处,将<domain>替换为您在创建 B2C 租户时选择的域名。

更新SignUpSignInPolicyIdResetPasswordPolicyId,如下所示:

"AzureAdB2C": {
    "Instance": 
    "https://packtecommerce.b2clogin.com/tfp/",
    "ClientId": "1ae40a96-60d7-4641-bb81-
      bc3a47aad36d",
    "Domain": "packtecommerce.onmicrosoft.com",
    "SignedOutCallbackPath": "/signout/B2C_1_susi",
    "SignUpSignInPolicyId": "B2C_1_packt_commerce",
    "ResetPasswordPolicyId": "B2C_1_password_reset",
    "EditProfilePolicyId": "",
    "CallbackPath": "/signin-oidc"
  }
  1. 您可以为控制器或操作方法添加一个[Authorize]属性——例如,您可以将它添加到OrdersController.cs中的OrdersController,以强制用户进行身份验证才能访问Orders信息。

  2. 最后一步是更新回复 URI。为此,请导航到您的租户中的AD 应用程序。在管理下的身份验证部分更新回复 URI,并设置隐式授权权限。

回复 URI 是用户在成功认证后将被重定向到的应用程序的 URL。为了设置应用程序并在本地进行调试,我们可以配置 localhost URL,但一旦您将应用程序部署到服务器,您将需要更新服务器的 URL。

隐式授权下,选择访问令牌ID 令牌,这是我们的 ASP.NET Core 应用程序所需的,如图下所示:

图 12.24 – 回复 URL 配置

图 12.24 – 回复 URL 配置

现在,运行您的应用程序并尝试访问订单页面。您将被重定向到登录和注册页面,如图图 12.23所示。这标志着我们的电子商务应用程序与 Azure AD B2C 的集成完成。

Azure AD 提供了许多更多选项和自定义功能以满足您的需求。有关更多详细信息,您可以查看docs.microsoft.com/en-in/azure/active-directory-b2c

注意

您可以使用 Duende Identity Server 来设置自己的身份服务器。这使用 OpenID Connect 和 OAuth 2.0 框架来建立身份。它通过 NuGet 提供,并且可以轻松集成到 ASP.NET Core 应用程序中。有关更多详细信息,您可以参考docs.duendesoftware.com/identityserver/v6

在下一节中,我们将看看如何使用 Windows 身份验证。

Windows 身份验证简介

ASP.NET Core 应用程序可以被配置为使用 Windows 身份验证,其中用户将使用其 Windows 凭据进行身份验证。当您的应用程序托管在 Windows 服务器上且您的应用程序仅限于内部网络时,Windows 身份验证是最佳选择。在本节中,我们将学习如何在 ASP.NET Core 应用程序中使用 Windows 身份验证。

在 Visual Studio 中,选择 --auth Windows 参数以使用 Windows 身份验证创建新的网络应用程序,如下所示:

dotnet new webapp --auth Windows -o WinAuthSample

如果您打开 launchSettings.json,您会注意到 WindowsAuthentication 设置为 true,而 anonymousAuthentication 设置为 false,如下面的代码片段所示。此设置仅在运行 Internet Information Services ExpressIIS Express)的应用程序时适用:

"iisSettings": {
    "windowsAuthentication": true,
    "anonymousAuthentication": false,
    "iisExpress": {
      "applicationUrl": "http://localhost:21368",
      "sslPort": 44384
    }
  }

当您在 IIS 上托管应用程序时,需要在 web.config 中将 WindowsAuthentication 配置为 true。默认情况下,web.config 不会添加到 .NET Core 网络应用程序中,因此您需要添加它并进行必要的更改,如下面的代码片段所示:

<location path="." inheritInChildApplications="false">
    <system.webServer>
      <security>
        <authentication>
          <anonymousAuthentication enabled="false"/>
          <windowsAuthentication enabled="true"/>
        </authentication>
      </security>
    </system.webServer>
</location>

上述配置使每个端点都变得安全。即使我们在每个控制器或操作上设置 AllowAnonymous,也不会产生影响。如果您想使任何端点匿名访问,需要将 anonymousAuthentication 设置为 true,并设置您想要使其安全的端点的 Authorize。此外,您还需要将身份验证服务与方案 Windows 注册,如下所示:

services.AddAuthentication(IISDefaults.AuthenticationScheme)

这就是您需要在应用程序中启用 Windows 身份验证所需要做的全部。有关更多详细信息,您可以参考 docs.microsoft.com/en-us/aspnet/core/security/authentication/windowsauth?view=aspnetcore-6.0

在下一节中,我们将探讨一些确保客户端和服务器应用程序安全的最佳实践。

理解确保客户端和服务器应用程序安全性的最佳实践

有几个最佳实践建议用于确保您的网络应用程序的安全性。.NET Core 和 Azure 服务使确保其采用变得容易。以下是一些您可以考虑的关键点:

  • 为网络应用程序强制执行 HTTPS。使用 UseHttpsRedirection 中间件将请求从 HTTP 重定向到 HTTPS。

  • 使用基于 OAuth 2.0 和 OIDC 的现代身份验证框架来保护您的网络或 API 应用程序。

  • 如果您使用的是 Microsoft 身份平台,请使用如 MSAL.js 和 MSAL.NET 这样的开源库来获取或更新令牌。

  • 配置强密码要求,并在连续登录失败尝试的情况下锁定账户——例如,连续五次失败尝试。这可以防止暴力攻击。

  • 为特权账户如后台办公室管理员、后台办公室员工账户等启用多因素认证(MFA)。

  • 配置会话超时,在注销时使会话无效,并清除 cookies。

  • 在所有受保护端点和客户端上强制执行授权。

  • 将密钥/密码存储在安全位置,例如密钥库中。

  • 如果您正在使用 Azure AD,请分别注册每个逻辑/环境特定的应用程序。

  • 不要以纯文本形式存储敏感信息。

  • 确保适当的异常处理。

  • 对上传的文件进行安全/恶意软件扫描。

  • 防止跨站脚本攻击——始终对用户输入数据进行 HTML 编码。

  • 通过参数化 SQL 查询和使用存储过程来防止 SQL 注入攻击。

  • 防止跨站请求伪造攻击——在操作、控制器或全局上使用ValidateAntiForgeryToken过滤器。

  • 在中间件中使用此策略强制执行跨源请求CORS)。

虽然提供的一些最佳实践和指导是好的起点,但您需要始终考虑应用程序的上下文,并持续评估和增强您的应用程序,以解决安全漏洞和威胁。

摘要

在本章中,我们了解了什么是身份验证以及 ASP.NET Core 中身份验证的关键要素。我们探讨了 ASP.NET Core 框架提供的不同选项,并学习了如何使用 ASP.NET Core Identity 快速将身份验证添加到您的应用程序中。我们讨论了 OAuth 2.0 和授权流程,并了解了它们在您需要身份验证和连接到多个 API 服务时如何简化事情。

此外,我们还探讨了将 Azure AD 配置为您的身份服务,在您的应用程序中使用外部身份验证提供程序,如 Google 或 Facebook,以及在 ASP.NET Core 应用程序中使用 Windows 身份验证。我们通过讨论在开发服务器端和客户端应用程序时应该遵循的一些最佳实践来结束本章。

在下一章中,我们将了解授权是什么以及它是如何帮助控制对您资源的访问的。

问题

  1. 从 JWT 中可以提取哪些信息?

a. 发行者

b. 过期

c. 范围

d. 主题

e. 以上所有

答案:e

  1. 推荐用于单页应用的 OAuth 授权流程是什么?

a. 客户端凭据流程

b. 隐式流程

c. 代码授权流程

d. 代表流程

答案:b 和 c

  1. 集成 Azure AD 所需的最小 NuGet 包是什么?

a. Microsoft.AspNetCore.Identity

b. Microsoft.Identity.Web.UI

c. Microsoft.AspNetCore.Identity.UI

d. Microsoft.Identity.Web

答案:d

进一步阅读

要了解更多关于身份验证的信息,您可以参考以下资源:

第十三章:第十三章: 在.NET 6 中实现授权

构建安全应用程序的一个重要方面是确保用户只能访问他们需要的资源。在现实生活中,当你入住酒店时,前台员工会验证你的身份证和信用卡,并分配一张房卡以进入你的房间。根据你选择的房型,你可能享有一些特权,例如进入休息室、游泳池或健身房等。在这里,验证你的身份证和信用卡以及分配房卡被称为认证,而允许你访问各种资源被称为授权。所以,为了进一步解释,使用房卡,我们无法识别你是谁,但可以确定你能做什么。

授权是一种机制,通过它你可以确定用户可以做什么,并授予或拒绝对应用程序资源的访问权限。例如,我们电子商务应用程序的用户应该能够浏览产品、将它们添加到购物车并结账购买,而只有管理员或后台办公室用户应该能够添加或更新产品信息、更新产品价格以及批准或拒绝订单。

在本章中,我们将学习什么是授权,以及使用 ASP.NET Core 框架实现授权的各种方法。本章涵盖了以下主题:

  • 理解.NET 6 中的授权

  • 简单授权

  • 基于角色的授权

  • 基于声明(Claims)的授权

  • 基于策略的授权

  • 自定义授权

  • 客户端和服务器应用程序中的授权

技术要求

对于本章,你需要具备 Azure、Azure AD B2C、C#、.NET Core 和 Visual Studio 2022 的基本知识。

回到一些基础知识

在我们深入更多细节之前,让我们了解认证和授权之间的区别。

认证和授权看起来可能相似,并且可以互换使用,但本质上它们是不同的。以下表格说明了这些差异:

表 13.1

表 13.1

注意

请参阅第十二章理解认证,以获取更多关于 ASP.NET 6 中认证工作原理的详细信息。

总结一下,认证和授权是相辅相成的。授权只有在用户的身份确定之后才会生效,当用户尝试访问受保护资源时,授权会触发认证挑战。在本章的后续部分,我们将了解如何在 ASP.NET 6 应用程序中实现授权。

理解授权

ASP.NET Core 中的授权由 中间件 处理。当你的应用程序收到来自未经身份验证用户的第一个请求到受保护资源时,中间件将触发身份验证挑战,根据身份验证方案,用户要么被重定向到登录页面,要么访问被禁止。一旦在身份验证后确定了用户的身份,授权中间件会检查用户是否可以访问该资源。在后续请求中,授权中间件使用用户的身份来确定是否允许访问。

要在项目中配置授权中间件,你需要在 Program.cs 中调用 UseAuthorization()。在注册授权中间件之前,必须先注册身份验证中间件,因为授权只能在确定用户身份后执行。请参考以下代码:

var builder = WebApplication.CreateBuilder(args);
..
var app = builder.Build();
app.UseAuthentication();
app.UseAuthorization();
app.UseEndpoints(endpoints =>
{
endpoints.MapControllerRoute(
    name: "default",
    pattern: "{controller=Home}/{action=Index}/{id?}");
    endpoints.MapRazorPages();
});

在前面的代码块中,你会注意到 app.UseAuthorization()app.UseAuthentication() 之后和 app.UseEndpoints() 之前被调用。

ASP.NET 6 提供了简单、基于角色和声明的授权模型以及丰富的基于策略的模型。在接下来的章节中,我们将学习更多关于这些模型的具体细节。

简单授权

在 ASP.NET Core 中,授权是通过 AuthorizationAttribute 配置的。你可以在控制器、操作或 Razor 页面上应用 [Authorize] 属性。当你添加这个属性时,对该组件的访问权限将仅限于经过身份验证的用户。请参考以下代码块:

public class HomeController : Controller
{
[Authorize]
public IActionResult Index()
{
    return View();
}
public IActionResult Privacy()
{
    return View();
}
}

在前面的代码中,你会注意到 [Authorize] 属性被添加到了 Index 操作上。当用户尝试从浏览器访问 /Home/Index 时,中间件会检查用户是否已经经过身份验证。如果没有,用户将被重定向到登录页面。

如果我们将 [Authorize] 属性添加到一个控制器中,那么该控制器下所有操作的访问权限将仅限于经过身份验证的用户。在下面的代码中,你会注意到 [Authorize] 属性被添加到了 HomeController 中,使得其下的所有操作都变得安全:

[Authorize]
public class HomeController : Controller
{
public IActionResult Index()
{
    return View();
}
[AllowAnonymous]
public IActionResult Privacy()
{
    return View();
}
}

有时,你可能希望允许应用程序的某些区域对任何用户都开放;例如,登录或重置密码页面应该对所有用户开放,无论用户是否经过身份验证。为了满足这样的要求,你可以在控制器或操作上添加 [AllowAnonymous] 属性,使它们对未经身份验证的用户也开放。

在前面的代码中,你会注意到 [AllowAnonymous] 属性被添加到了 Privacy 操作上,尽管控制器上已经有了 [Authorize] 属性。这个要求被操作方法上的 [AllowAnonymous] 属性覆盖,因此 Privacy 操作对所有用户都是可访问的。

注意

[AllowAnonymous] 属性会覆盖所有授权配置。如果你在一个控制器上设置了 [AllowAnonymous],那么在它下面的任何操作方法上设置 [Authorize] 属性将没有任何影响。在这种情况下,操作方法上的 Authorize 属性将被完全忽略。

到目前为止,我们已经看到了如何保护控制器或操作方法。在下一节中,我们将看到如何在 ASP.NET Core 应用程序中全局启用授权。

全局启用授权

到目前为止,我们已经看到了如何使用 [Authorize] 属性来保护控制器或操作方法。在大型项目中,为每个控制器或操作设置 authorize 属性是不可持续的;你可能会错过为新添加的控制器或操作方法配置它,这可能导致安全漏洞。

ASP.NET Core 允许你通过在应用程序中添加回退策略来全局启用授权。你可以在 Program.cs 中定义回退策略。回退策略将应用于所有未定义显式授权要求的请求:

var builder = WebApplication.CreateBuilder(args);
builder.Services.AddAuthorization(options =>
{
options.FallbackPolicy = new AuthorizationPolicyBuilder()
        .RequireAuthenticatedUser()
        .Build();
});

在全局范围内添加策略强制用户进行身份验证才能访问应用程序中的任何操作方法。这个选项很有用,因为你不需要为应用程序中的每个控制器/操作指定 [Authorize] 属性。

你仍然可以在控制器或操作方法上设置 [AllowAnonymous] 属性来覆盖默认行为,使其匿名访问。

现在我们已经了解了如何实现简单的授权,在下一节中,让我们了解基于角色的授权是什么以及它是如何简化实现的。

基于角色的授权

在你的应用程序的某些区域仅对特定用户可用是很常见的。而不是在用户级别授予访问权限,通常的做法是将用户分组到角色中,并授予角色访问权限。让我们考虑一个典型的电子商务应用程序,其中 用户 可以下订单,支持 员工可以查看、更新或取消订单并解决用户查询,而 管理员 角色则批准或拒绝订单,管理库存等。

基于角色的授权可以解决这样的需求。当你创建一个用户时,你可以将其分配到一个或多个角色中,当我们配置 [Authorize] 属性时,我们可以将一个或多个角色名称传递给 Authorize 属性的 Roles 属性。

以下代码限制了 Admin 控制器下所有操作方法的访问权限,仅限于属于 Admin 角色的用户:

[Authorize(Roles ="Admin")]
public class AdminController : Controller
{
public IActionResult Index()
{
    return View();
}
}

类似地,你可以在 Authorize 属性的 Roles 属性中指定以逗号分隔的角色名称,这样属于配置的任一角色的用户都将能够访问该控制器下的操作方法。

在以下代码中,你会注意到 User,Support 被用作 [Authorize] 属性 Roles 属性的值;属于 UserSupport 角色的用户可以访问 OrdersController 的操作方法:

[Authorize(Roles ="User,Support")]
public class OrdersController : Controller
{
public IActionResult Index()
{
  return View();
}
}

您也可以指定多个授权属性。如果您这样做,用户必须是所有指定角色的成员才能访问它。

在以下代码中,对 InventoryController 配置了多个 [Authorize] 属性,用于 InventoryManagerAdmin 角色。要访问 Inventory 控制器,用户必须具有 InventoryManagerAdmin 角色:

[Authorize(Roles ="InventoryManager")]
[Authorize(Roles ="Admin")]
public class InventoryController : Controller
{
public IActionResult Index()
{
  return View();
}
[Authorize(Roles ="Admin")]
public IActionResult Approve()
{
  return View();
}
}

您可以通过指定授权属性进一步限制对 Inventory 控制器下操作方法的访问。在前面的代码中,用户必须具有 InventoryManagerAdmin 角色才能访问 Approve 操作。

通过编程方式,如果您想检查用户是否属于某个角色,您可以使用 ClaimsPrincipleIsInRole 方法。在以下示例中,您会注意到 User.IsInRole 接受 roleName,并根据用户的角色返回 truefalse

public ActionResult Index()
{
if (User.IsInRole("Admin"))
{
    // Handle your logic
}
return View();
}

到目前为止,我们已经看到了如何通过在授权属性中指定角色名称来通过指定角色名称来保护控制器或操作。在下一节中,我们将看到如何使用基于策略的角色授权将这些配置集中在一个地方。

基于策略的角色授权

我们也可以在 Program.cs 中将角色要求定义为策略。这种方法非常有用,因为您可以在一个地方创建和管理基于角色的访问要求,并使用策略名称而不是角色名称来控制访问。为了定义基于策略的角色授权,我们需要在 Program.cs 中将一个或多个角色要求注册为授权策略,并将策略名称提供给 Authorize 属性的 Policy 属性。

在以下代码中,通过添加具有 Admin 角色的要求创建 AdminAccessPolicy

var builder = WebApplication.CreateBuilder(args);
builder.Services.AddAuthorization(options =>
{
    options.AddPolicy("AdminAccessPolicy",
        policy => policy.RequireRole("Admin"));
});

在您的控制器中,您可以指定要应用的政策如下,并且对 AdminController 的访问被限制为具有 Admin 角色的用户:

[Authorize(Policy ="AdminAccessPolicy")]
public class AdminController : Controller
{
public IActionResult Index()
{
    return View();
}
}

在定义策略时,您可以指定多个角色。当使用该策略授权用户时,属于任何角色的用户都可以访问资源。例如,以下代码将允许具有 UserSupport 角色的用户访问资源:

options.AddPolicy("OrderAccessPolicy",
        policy => policy.RequireRole("User","Support"));

您可以在控制器或操作方法上使用带有 Authorize 属性的 OrderAccessPolicy 策略来控制访问。

现在我们已经了解了如何使用基于角色的授权,在下一节中,我们将创建一个简单的应用程序并将其配置为使用基于角色的授权。

实现基于角色的授权

让我们创建一个示例应用程序,使用 ASP.NET Core Identity 实现基于角色的授权:

  1. 创建一个新的 ASP.NET Core 项目。您可以使用以下 dotnet Individual 账户作为 Authentication 模式,并使用 SQLite 作为数据库存储:

    dotnet new mvc --auth Individual -o AuthSample
    
  2. 你需要在 Program.cs 中调用 AddRoles<IdentityRole>() 来启用角色服务。你可以参考以下代码来启用它。你还会注意到 RequireConfirmedAccount 被设置为 false。这对于本示例是必需的,因为我们以编程方式创建用户:

    {
    var builder = WebApplication.CreateBuilder(args);
    var connectionString = builder.Configuration.GetConnectionString("DefaultConnection");
    builder.Services.AddDbContext<ApplicationDbContext>(options =>
        options.UseSqlite(connectionString));
    builder.Services.AddDatabaseDeveloperPageExceptionFilter();
    builder.Services.AddDefaultIdentity<IdentityUser>(options =>
        options.SignIn.RequireConfirmedAccount = false)
        .AddRoles<IdentityRole>()
        .AddEntityFrameworkStores<ApplicationDbContext>();
    builder.Services.AddControllersWithViews();   
    }
    
  3. 接下来,我们需要创建角色和用户。为此,我们将在 Program.cs 中添加两个方法,SetupRolesSetupUsers。我们可以使用 RoleManagerUserManager 服务来创建角色和用户。在以下代码中,我们创建了三个角色。使用 IServiceProvider,我们获取 roleManager 服务的实例,然后我们使用 RoleExistsAsyncCreateAsync 方法来创建它:

    //Add this method to Program.cs
    async Task SetupRoles(IServiceProvider serviceProvider)
    {
    var rolemanager = serviceProvider
        .GetRequiredService<RoleManager<IdentityRole>>();
    string[] roles = { "Admin", "Support", "User" };
    foreach (var role in roles)
    {
        var roleExist = await rolemanager.RoleExistsAsync(role);
        if (!roleExist)
        {
            await rolemanager.CreateAsync(new 
              IdentityRole(role));
        }
       }
    }
    
  4. 同样,我们使用 userManager 服务创建用户并分配一个角色。在以下代码中,我们创建了两个用户 – admin@abc.com,分配了 admin 角色,以及 support@abc.com,分配了 support 角色:

    //Add this method to Program.cs
    async Task SetupUsers(IServiceProvider serviceProvider)
    {
    var userManager = serviceProvider
        .GetRequiredService<UserManager<IdentityUser>>();
    var adminUser = await userManager.FindByEmailAsync("admin@abc.com");
    if (adminUser == null)
    {
        var newAdminUser = new IdentityUser
        {
            UserName = "admin@abc.com",
            Email = "admin@abc.com",
        };
    var result = await userManager
        .CreateAsync(newAdminUser, "Password@123");
    if (result.Succeeded)
        await userManager.AddToRoleAsync(newAdminUser, 
          "Admin");
    }
    var supportUser = await userManager
        .FindByEmailAsync("support@abc.com");
    if (supportUser == null)
    {
        var newSupportUser = new IdentityUser
        {
            UserName = "support@abc.com",
            Email = "support@abc.com",
        };
    var result = await userManager
        .CreateAsync(newSupportUser, "Password@123");
    if (result.Succeeded)
        await userManager.AddToRoleAsync(newSupportUser, 
          "Support");
    }
    }
    
  5. 要调用这两个方法,我们需要一个 IServiceProvider 的实例。以下代码获取 IServiceProvider 的实例来设置角色和用户数据:

    //
    //
    var app = builder.Build();
    using (var scope = app.Services.CreateScope())
    {
        var services = scope.ServiceProvider;
        await SetupRoles(services);
        await SetupUsers(services);
    }
    
  6. Home 控制器内部,添加以下代码。为了简化实现,我们正在使用 Index 视图。在实际场景中,你需要返回为相应操作方法创建的视图:

    [Authorize(Roles = "Admin")]
    public IActionResult Admin()
    {
    return View("Index");
    }
    [Authorize(Roles = "Support")]
    public IActionResult Support()
    {
    return View("Index");
    }
    
  7. 可选地,我们可以在 Layout.cshtml 中添加逻辑来根据登录用户的角色显示导航链接。以下示例使用 IsInRole 来检查用户的角色并显示一个链接:

    <li class="nav-item">
    <a class="nav-link text-dark" asp-area="" asp-controller="Home" asp-action="Index">Home</a>
    </li>
    @if (User.IsInRole("Admin"))
    {
    <li class="nav-item">
        <a class="nav-link text-dark" asp-area="" asp-controller="Home" asp-action="Admin">Admin</a>
    </li>
    }
    @if (User.IsInRole("Support"))
    {
    <li class="nav-item">
        <a class="nav-link text-dark" asp-area="" asp-controller="Home" asp-action="Support">Support</a>
    </li>
    }
    

通过前面的步骤,示例实现完成,你可以运行应用程序来查看其工作方式。

运行应用程序,使用 admin@abc.com 登录,你会注意到 管理员 菜单项是可见的,而 支持 是隐藏的:

图 13.1 – 管理员用户登录视图

图 13.1 – 管理员用户登录视图

当你使用 support@abc.com 登录时,你会注意到 支持 菜单项是可见的,而 管理员 项是隐藏的:

图 13.2 – 支持用户登录视图

图 13.2 – 支持用户登录视图

在下一节中,我们将看到如何使用声明进行授权。

基于声明的授权

声明是在身份验证成功后与身份相关联的关键值对。声明可以是出生日期、性别或邮政编码等。一个或多个声明可以分配给一个用户。基于声明的授权使用声明的值来确定是否可以授予对资源的访问权限。你可以使用两种方法来验证声明;一种方法只是检查声明是否存在,另一种方法是检查声明是否存在并具有特定的值。

要使用基于声明的授权,我们需要在 Program.cs 中注册一个策略。你需要传递一个声明名称和可选值到 RequireClaim 方法来注册。例如,以下代码注册了 PremiumContentPolicy 并要求 PremiumUser 声明:

var builder = WebApplication.CreateBuilder(args);
builder.Services.AddAuthorization(options =>
{
    options.AddPolicy("PremiumContentPolicy",
        policy => policy.RequireClaim("PremiumUser"));
});

在以下代码中,PremiumContentPolicy授权策略被用于PremiumContentController。它检查用户声明中是否存在PremiumUser声明来授权用户的请求;它不关心声明中的值是什么:

[Authorize(Policy ="PremiumContentPolicy")]
public class PremiumContentController : Controller
{
public IActionResult Index()
{
        return View();
}
}

你也可以在定义声明时指定一个值列表。它们将被验证以授予对资源的访问权限。例如,根据以下代码,如果用户具有Country声明,其值为USUKIN,则用户请求被授权:

var builder = WebApplication.CreateBuilder(args);
builder.Services.AddAuthorization(options =>
{
    options.AddPolicy("ExpressShippingPolicy",
        policy => policy.RequireClaim(ClaimTypes.Country, 
          "US", "UK", "IN"));
});

在程序上,如果你想检查一个用户是否有声明,你可以通过指定一个匹配条件来使用ClaimsPrincipalHasClaim方法。

要获取一个声明值,你可以使用FindFirst方法。以下代码展示了示例:

@if (User.HasClaim(x => x.Type == "PremiumUser"))
{
    <h1>Yay, you are Premium User!!!, @User.FindFirst(x => 
        x.Type == ClaimTypes.Country)?.Value</h1>
}

如在实现基于角色的授权部分所见,在向应用程序添加用户时,你也可以使用UserManager服务向用户添加一个声明。在以下代码中,你会注意到AddClaimAsync方法与IdentityUserClaim一起被调用:

var user = await userManager.FindByEmailAsync("user@abc.com");
if (user == null)
{
var newUser = new IdentityUser
{
    UserName = "user@abc.com",
    Email = "user@abc.com",
};
var result = await userManager.CreateAsync(newUser, "Password@123");
if (result.Succeeded)
{
await userManager
    .AddToRoleAsync(newUser, "User");
await userManager
    .AddClaimAsync(newUser, new Claim("PremiumUser", 
       "true"));
await userManager
    .AddClaimAsync(newUser, new Claim(ClaimTypes.Country, 
       "US"));
await userManager
                .AddClaimAsync(newUser, new Claim(ClaimTypes.DateOfBirth, "1-5-2003"));
}
}

在前面的代码中,你会注意到创建了两个声明,并使用AddClaimAsync方法与用户关联。在下一节中,我们将看到如何使用基于策略的授权。

基于策略的授权

基于策略的授权允许你编写自己的逻辑来处理满足你需求的授权需求。例如,你有一个验证用户年龄的需求,并且只有当用户年龄超过 14 岁时才授权下单。你可以使用基于策略的授权模型来处理这样的需求。

要配置基于策略的授权,我们需要定义一个需求和处理器,然后使用需求注册策略。让我们了解这些组件:

  • 策略是通过一个或多个需求定义的。

  • 需求是一组数据参数的集合,政策使用这些参数来评估用户的身份。

  • 处理器负责评估需求中的数据与上下文之间的数据,并确定是否可以授予访问权限。

在下一节中,我们将看到如何创建一个需求和处理器,并注册一个授权策略。

需求

要创建一个需求,你需要实现IAuthorizationRequirement接口。这是一个标记接口,所以你不需要实现任何成员。例如,以下代码创建了一个MinimumAgeRequirement,其中MinimumAge是一个数据参数:

public class MinimumAgeRequirement: IAuthorizationRequirement
{
public int MinimumAge { get; set; }
public MinimumAgeRequirement(int minimumAge)
{
    this.MinimumAge = minimumAge;
}
}

需求处理器

需求处理器封装了允许或拒绝请求的逻辑。它们使用需求属性与AuthorizationHandlerContext进行比较以确定访问权限。处理器可以继承Authorizationhandler<TRequirement>,其中TRequirementIauthorizationRequirement类型,或者实现IAuthorizationHandler

在以下示例中,通过从 AuthorizationHandler 继承并使用 MinimumAgeRequirement 作为 TRequirement 来创建 MinimumAgeAuthorizationHandler。我们需要重写 HandleRequirementAsync 来编写自定义授权逻辑,其中用户的年龄是从 DateOfBirth 声明中计算出来的。如果用户的年龄大于或等于 MinimumAge,我们调用 context.Succeed 以授予访问权限。如果声明不存在或不符合年龄标准,则禁止访问:

public class MinimumAgeAuthorizationHandler
: AuthorizationHandler<MinimumAgeRequirement>
{
protected override Task HandleRequirementAsync(
AuthorizationHandlerContext context,
MinimumAgeRequirement requirement)
{
    if (context.User.HasClaim(
        c => c.Type == ClaimTypes.DateOfBirth))
    {
        var dateOfBirth = Convert.ToDateTime(
            context.User.FindFirst(x =>
            x.Type == ClaimTypes.DateOfBirth).Value);
        var age = DateTime.Today.Year - dateOfBirth.Year;
        if (dateOfBirth > DateTime.Today.AddYears(-age)) 
          age--;
        if (age >= requirement.MinimumAge)
        {
            context.Succeed(requirement);
        }
       else
        {
            context.Fail();
        }
    }
            return Task.CompletedTask;
}
}

要标记一个需求为成功,您需要通过传递一个需求作为参数来调用 context.Succeed。您不需要处理失败,因为可能还有另一个处理程序会成功处理相同的需求。如果您想禁止请求,可以调用 context.Fail

注意

必须在 Program.cs 中为服务集合注册处理程序。

注册策略

Program.cs 中,使用名称和需求注册策略。在定义策略时,您可以注册一个或多个需求。

在以下示例中,通过调用 policy.Requirements.Add() 并传递 MinimumAgeRequirement 的新实例来创建一个包含需求的策略。您还会注意到 MinimumAgeAuthorizationHandler 被添加到服务集合中,具有单例作用域:

var builder = WebApplication.CreateBuilder(args);
builder.Services.AddAuthorization(options =>
{
options.AddPolicy("Over14", policy =>
    policy.Requirements.Add(new 
      MinimumAgeRequirement(14)));
});
builder.Services.AddSingleton<IAuthorizationHandler,
    MinimumAgeAuthorizationHandler>();

然后,我们可以在控制器或操作上配置授权策略,根据用户的年龄来限制访问:

[Authorize(Policy ="Over14")]
public class OrdersController : Controller
{
public IActionResult Index()
{
        return View();
}
}

如果我们注册了一个包含多个需求的策略,那么所有需求都必须得到满足才能成功授权。

在下一节中,我们将学习如何进一步自定义授权。

自定义授权

在上一节中,我们学习了如何使用基于策略的授权并实现自定义逻辑来处理授权需求。但,并不总是可以在 Program.cs 中像那样注册授权策略。在本节中,我们将看到如何使用 IAuthorizationPolicyProvider 在您的应用程序中动态构建策略配置。

IAuthorizationPolicyProvider 接口有三个方法需要实现:

  • GetDefaultPolicyAsync:此方法返回要使用的默认授权策略。

  • GetFallbackPolicyAsync:此方法返回回退授权策略。当没有定义明确的授权需求时使用。

  • GetPolicyAsync:此方法用于构建并返回提供的策略名称的授权策略。

让我们来看一个例子,您可能想要根据不同的年龄标准授权对多个控制器/操作的请求,比如 Over14Over18Over21Over60 等。实现它的一个方法是将所有这些需求注册为策略并在您的控制器或操作中使用它们。但是,使用这种方法,代码的可维护性较低,在具有许多策略的大型应用程序中不可持续。让我们看看我们如何利用授权策略提供者。

我们需要创建一个实现 IAuthorizationPolicyProvider 的类,并实现 GetPolicy 和其他方法。

在以下示例中,MinimumAgePolicyProvider 类实现了 GetPolicyAsync 方法。此方法的输入是策略名称。由于我们的策略名称类似于 Over14Over18,我们可以使用字符串函数从中提取年龄,并使用所需的年龄初始化一个要求,并将其注册为新的策略:

public class MinimumAgePolicyProvider : IAuthorizationPolicyProvider
{
        const string PREFIX = "Over";
        public Task<AuthorizationPolicy?> 
          GetPolicyAsync(string policyName)
        {
            if (policyName.StartsWith(PREFIX, 
              StringComparison.OrdinalIgnoreCase) &&
            int.TryParse(policyName.Substring(
              PREFIX.Length), out var age))
            {
                var policy = new 
                  AuthorizationPolicyBuilder();
                policy.AddRequirements(new 
                  MinimumAgeRequirement(age));
                return Task.FromResult 
                  <AuthorizationPolicy?>(policy.Build());
            }
            return 
              Task.FromResult<AuthorizationPolicy?>(null);
        }
}

注意

对于 MinimumAgeRequirement 的实现,请参阅 基于策略的授权 部分。

ASP.NET Core 只使用一个 IAuthorizationPolicyProvider 实例。因此,您应该自定义一个 DefaultFallback 授权策略,或者使用备用提供程序。

在以下代码中,您将看到 MinimumAgePolicyProvider 类中 GetDefaultPolicyAsyncGetFallbackPolicyAsync 方法的示例实现。

AuthorizationOptions 注入到构造函数中,并用于初始化 DefaultAuthorizationPolicyProviderBackupPolicyProvider 对象用于实现 GetDefaultPolicyAsyncGetFallbackPolicyAsync 方法:

public MinimumAgePolicyProvider(IOptions<AuthorizationOptions> options)
{
this.BackupPolicyProvider =
    new DefaultAuthorizationPolicyProvider(options);
}
Private DefaultAuthorizationPolicyProvider BackupPolicyProvider { get; }
public Task<AuthorizationPolicy> GetDefaultPolicyAsync()
=> this.BackupPolicyProvider.GetDefaultPolicyAsync();
public Task<AuthorizationPolicy?> GetFallbackPolicyAsync()
=> this.BackupPolicyProvider.GetFallbackPolicyAsync();

这就完成了 MinimumAgePolicyProvider 的实现。现在您可以在控制器或操作方法上使用授权策略。在以下代码中,您会注意到使用了两个策略,一个在控制器上使用 Over14,另一个在 Index 操作方法上使用 Over18

[Authorize(Policy ="Over14")]
public class OrdersController : Controller
{
    [Authorize(Policy ="Over18")]
public IActionResult Index()
{
        return View();
}
}

年龄超过 14 岁的用户将能够访问 OrdersController 下的任何操作方法,而年龄超过 18 岁的用户只能访问 Index 操作。

在下一节中,我们将学习如何创建和使用自定义授权属性。

自定义授权属性

在前面的示例中,一个包含年龄的策略名称作为字符串传递,但这种方式代码不够整洁。如果能够将 age 作为参数传递给授权属性会更好。为此,您需要创建一个继承自 AuthorizeAttribute 类的自定义授权属性。

在以下示例代码中,AuthorizeAgeOverAttribute 类继承自 AuthorizeAttribute 类。该类的构造函数接受 age 作为输入。在设置器中,我们通过连接 PREFIXAge 来构造并设置一个策略名称:

public class AuthorizeAgeOverAttribute : AuthorizeAttribute
{
const string PREFIX = "Over";
public AuthorizeAgeOverAttribute(int age) => Age = age;
public int Age
{
    get
            {
                if 
            (int.TryParse(Policy.Substring(PREFIX.Length), 
            out var age))
                {
                    return age;
                }
                return default(int);
            }
            set
            {
                Policy = $"{PREFIX}{value.ToString()}";
            }
        }
}

要使用 AuthorizeAgeOver 属性,我们必须在 Program.cs 中注册 AuthorizationHandlerAuthorizationPolicyProvider 服务。在以下代码中,MinimumAgeAuthorizationHandlerMinimumAgePolicyProvider 类型分别注册为 Singleton 用于 IAuthorizationHandlerIauthorizationPolicyProvider

builder.Services.AddSingleton<IAuthorizationHandler,
        MinimumAgeAuthorizationHandler>();
builder.Services.AddSingleton<IAuthorizationPolicyProvider,
        MinimumAgePolicyProvider>();

现在自定义属性实现完成后,我们可以在控制器或操作方法上使用它。在以下示例中,您可以看到一个示例实现,其中年龄作为参数传递给我们的自定义授权属性 AuthorizeAgeOver

[AuthorizeAgeOver(14)]
public class OrdersController : Controller
{
[AuthorizeAgeOver(18)]
public IActionResult Index()
{
        return View();
}
}

在下一节中,我们将学习如何在 Azure AD 应用程序中配置角色并使用基于角色的身份验证。

客户端和服务器应用程序的授权

在前面的章节中,我们学习了如何使用Azure Active DirectoryAAD)作为身份服务来验证用户,但为了使用基于角色的授权,我们需要在 Azure AD 中进行一些配置更改。在本节中,我们将了解如何在 Azure AD 应用程序中启用和创建自定义角色,并在我们的电子商务应用程序中授权用户。

当用户登录到应用程序时,Azure AD 会将分配的角色和声明添加到用户的身份中。

前提条件

您应该已经设置了 Azure AD 和 AD 应用程序。如果没有,您可以参考第十二章Azure Active Directory 简介部分,了解身份验证以进行设置。

让我们看看在 Azure AD 应用程序中启用角色需要执行的步骤:

  1. 在 Azure 门户中,导航到您的Active Directory租户。

  2. 在左侧菜单下,在管理中,选择应用注册

图 13.3 – Azure AD 应用程序

图 13.3 – Azure AD 应用程序

  1. 应用注册页面搜索并选择您的 AD 应用程序。请参考以下截图:

图 13.4 – Azure AD 应用程序

图 13.4 – Azure AD 应用程序

  1. 点击左侧菜单中的清单进行编辑,如图中所示。

图 13.5 – 编辑清单

图 13.5 – 编辑清单

  1. 定位appRoles以配置多个角色。请参考以下代码以添加角色:

    {
    "allowedMemberTypes": [
            "User"
        ],
        "description": "Admin Users",
        "displayName": "Admin",
        "id": "6ef9b400-0219-463c-a542-5f4693c4e286",
        "isEnabled": true,
        "lang": null,
        "origin": "Application",
        "value": "Admin"
    }
    

您需要为displayNamevaluedescriptionid提供值。id的值是Guid,并且对于您添加的每个角色都必须是唯一的。同样,对于value,您需要提供在代码中引用的角色名称,并且它应该是唯一的。

  1. 保存清单以完成它。

保存包含所需详细信息的清单将启用 Azure AD 应用程序中的自定义角色。在下一节中,我们将学习如何将这些自定义角色分配给用户。

为用户分配角色

下一步是为用户分配角色。用户角色的分配可以通过 Azure 门户或使用 Graph API 编程方式完成。在本节中,我们将使用 Azure 门户来分配角色,同样也可以使用 Graph API 实现。更多信息,请参阅docs.microsoft.com/en-us/graph/azuread-identity-access-management-concept-overview

  1. 在 Azure 门户中,导航到Azure Active Directory租户。

  2. 点击左侧菜单中的企业应用程序并搜索并选择您的 AD 应用程序。

  3. 前往管理 | 用户和组 | 添加用户

  4. 搜索并选择用户,然后点击确定

  5. 点击选择角色以选择您想要分配的角色。

  6. 点击分配以保存选择。

您可以继续这些步骤将角色分配给多个用户。

要保护控制器或操作,您可以在角色一起添加 Authorize 属性。在以下代码中,Admin 控制器仅对具有 Admin 角色的用户可访问:

[Authorize(Roles ="Admin")]
public class AdminController : Controller
{
public IActionResult Index()
{
    return View();
}
}

到目前为止,我们学习了如何在 Azure AD 中启用角色并使用基于角色的模型进行授权。在下一节中,我们将看到如何使用视图中的用户身份访问角色和声明。

视图中的用户身份

用户声明原则可用于视图,根据需要有条件地显示或隐藏数据。例如,以下代码检查用户身份的 IsAuthenticated 属性以确定用户是否已认证。如果用户未认证,则显示 Sign in 链接;否则,显示带有 Sign out 链接的用户名:

<ul class="navbar-nav">
    @if (User.Identity.IsAuthenticated)
    {
        //// HTML code goes here
    }
    else
    {
        ////
    }
</ul>

类似地,我们可以使用 IsInRoleHasClaim 并编写我们的逻辑来向用户显示内容或从用户隐藏内容:

@if (User.HasClaim(x => x.Type == "PremiumUser"))
{
    <h1>Yay, you are Premium User!!!, @User.FindFirst(x => x.Type == ClaimTypes.Country)?.Value</h1>
}

更多详细信息,请参阅docs.microsoft.com/en-us/azure/active-directory/develop/howto-add-app-roles-in-azure-ad-apps

摘要

在本章中,我们学习了什么是授权以及使用 ASP.NET Core 框架的不同实现方式。我们学习了如何使用简单、声明性的基于角色和声明的模型限制或匿名允许用户访问资源,我们还学习了如何使用丰富的基于策略的授权模型实现自定义逻辑以授权用户请求。

我们学习了如何使用授权策略提供程序动态添加授权策略,构建自定义授权属性。我们还学习了如何在 Azure AD 中配置自定义角色并在 ASP.NET Core 应用程序中使用它们。根据您的授权需求,您可以使用一个或多个授权模型来保护您的应用程序。

在下一章中,我们将学习如何监控 ASP.NET Core 应用程序的健康状况和性能。

问题

阅读本章后,您应该能够回答以下问题:

  1. 以下哪个是确定授权是否成功的主要服务?

a. IAuthorizationHandler

b. IAuthorizationRequirement

c. IAuthorizationService

d. IAuthorizationPolicyProvider

答案:c

  1. 在以下代码中,对 Support 动作的访问仅限于 Support 角色:

    [AllowAnonymous]
    public class HomeController : Controller
    {
          public IactionResult Index()
    {
        return View();
    }
    [Authorize(Roles ="Support")]
    public IactionResult Support()
    {
            return View();
    }
    }
    

a. 正确

b. 错误

答案:b

进一步阅读

要了解更多关于授权的信息,您可以参考docs.microsoft.com/en-us/aspnet/core/security/authorization/introduction?view=aspnetcore-6.0

第五部分:健康检查、单元测试、部署和诊断

就像对我们一样,任何企业应用程序的健康状况都应该是容易检查的,而且在出现任何异常的情况下,我们应该提前得到通知,这样我们就可以采取纠正措施,而不会导致停机。在本部分中,我们将将新的.NET 6 健康和性能检查 API 集成到我们的应用程序中,并对我们的应用程序进行单元测试。然后我们将学习如何以现代 DevOps 的方式部署我们的应用程序,并了解如何在生产中监控、诊断和排除应用程序的故障。

本部分包括以下章节:

  • 第十四章**,健康和诊断

  • 第十五章**,测试

  • 第十六章**,在 Azure 中部署应用程序

第十四章:第十四章:健康和诊断

现代软件应用程序已经演变成复杂和动态,并且具有分布式特性。对这些应用程序的需求是能够全天候在任何设备上工作。为了实现这一点,重要的是要知道我们的应用程序始终可用并响应请求。客户体验将在服务未来的发展和组织的收入中扮演重要角色。

一旦应用程序上线,监控应用程序的健康状况至关重要。定期监控应用程序健康将帮助我们主动检测任何故障,并在它们造成更多损害之前解决它们。应用程序监控现在已成为日常运营的一部分。为了诊断在线应用程序上的任何故障,我们需要拥有正确的遥测和诊断工具。我们捕获的遥测数据也将帮助我们识别那些用户没有直接看到或报告的问题。

让我们了解应用程序健康监控以及.NET 6 提供了哪些功能。

本章我们将学习以下主题:

  • 介绍健康检查

  • ASP.NET Core 6 中的健康检查 API

  • 使用 Application Insights 监控应用程序

  • 执行远程调试

到本章结束时,您将能够熟练构建.NET 6 应用程序的健康检查 API,并使用 Azure Application Insights 来捕获遥测信息和诊断问题。

技术要求

为了完成本章中的任务,您需要以下软件:

  • 安装了 Azure 开发工作负载的 Visual Studio 2022 Community Edition(某些部分需要企业版)

  • Azure 订阅

预期您对 Microsoft .NET 有基本了解,以及如何在 Azure 中创建资源。

本章中使用的代码可以在github.com/PacktPublishing/Enterprise-Application-Development-with-C-10-and-.NET-6-Second-Edition/tree/main/Enterprise%20Application找到。

介绍健康检查

健康检查是对应用程序的全面审查,帮助我们了解应用程序的当前状态,并使用可见指标采取纠正措施。健康检查由应用程序作为 HTTP 端点公开。健康检查端点用作某些编排器和负载均衡器的健康探测,以将流量从失败的节点路由出去。健康检查用于监控应用程序依赖项,如数据库、外部服务和缓存服务。

在下一节中,我们将学习如何在 ASP.NET Core 6 中构建健康检查 API 的支持。

ASP.NET Core 6 中的健康检查 API

ASP.NET Core 6 有一个内置的中间件(通过Microsoft.Extensions.Diagnostics.HealthChecks NuGet 包提供),用于报告作为 HTTP 端点公开的应用程序组件的健康状态。这个中间件使得集成数据库、外部系统和其他依赖项的健康检查变得非常简单。它也是可扩展的,因此我们可以创建我们自己的自定义健康检查。

在下一节中,我们将向我们的Ecommerce门户添加一个健康检查端点。

添加健康检查端点

在本节中,我们将向我们的Packt.Ecommerce.Web应用程序添加一个健康检查端点:

  1. 为了添加一个健康检查端点,我们首先需要将Microsoft.Extensions.Diagnostics.HealthChecks NuGet 包引用添加到Packt.Ecommerce.Web项目中,如下面的截图所示:

![Figure 14.1 – NuGet 引用,Microsoft.Extensions.Diagnostics.HealthChecksimg/Figure_14.1_B18507.jpg

图 14.1 – NuGet 引用,Microsoft.Extensions.Diagnostics.HealthChecks

现在,我们需要将HealthCheckService注册到依赖容器中。Microsoft.Extensions.Diagnostics.HealthChecks包定义了AddHealthChecks扩展方法,用于将HealthCheckService添加到容器中。我们可以从Program.cs文件中调用AddHealthChecks方法来添加DefaultHealthCheckService模块:

// Removed code for brevity.
// Add health check services to the container.
builder.Services.AddHealthChecks();
  1. 让我们继续配置健康检查端点到我们的 Web 应用程序。使用MapHealthChecks方法映射健康端点,如下面的代码所示,在Program.cs文件中。这将添加健康检查端点路由到应用程序。这将内部配置HealthCheckResponseWriters.WriteMinimalPlainText框架方法以输出响应。WriteMinimalPlainText将仅输出健康检查服务的总体状态:

    // Removed code for brevity.
    app.UseEndpoints(endpoints =>
    {
        endpoints.MapControllerRoute(
             name: "default",
             pattern: 
            "{controller=Products}/{action=Index}/{id?}");
        endpoints.MapHealthChecks("/health");
    });
    
  2. 运行应用程序并浏览到<<Application URL>>/health URL。您将看到以下输出:

![Figure 14.2 – 健康检查端点响应img/Figure_14.2_B18507.jpg

图 14.2 – 健康检查端点响应

我们添加的健康端点提供了关于服务可用性的基本信息。在下一节中,我们将看到如何监控依赖服务的状态。

监控依赖 URI

企业应用程序依赖于多个其他组件,例如数据库和包括KeyVault在内的 Azure 组件,以及其他微服务(例如我们的Ecommerce网站)依赖于订单服务、产品服务等。这些服务可能属于同一组织内的其他团队,或者在某些情况下,它们可能是外部服务。通常,监控依赖服务是一个好主意。我们可以利用AspNetCore.HealthChecks.Uris NuGet 包来监控依赖服务的可用性。

让我们继续增强我们的健康端点以监控产品和订单服务:

  1. 将 NuGet 包引用添加到AspNetCore.HealthChecks.Uris。现在,修改健康检查注册以注册产品和订单服务,如下面的代码片段所示:

    // Add health check services to the container.
    services.AddHealthChecks()
    .AddUrlGroup(new Uri(this.Configuration.GetValue<string>("ApplicationSettings:ProductsApiEndpoint ")), name: "Product Service")
    .AddUrlGroup(new Uri(this.Configuration.GetValue<string>("ApplicationSettings:OrdersApiEndpoint ")), name: "Order Service");
    

健康检查中间件还提供了关于单个健康检查状态的详细信息。

  1. 现在我们修改我们的健康检查中间件,以输出如下代码所示的详细信息:

    // Removed code for brevity.
    app.UseEndpoints(endpoints =>
    {
       endpoints.MapControllerRoute(
            name: "default",
            pattern: "{controller=Products}/{action=Index}/{id?}");
    endpoints.MapHealthChecks("/health", new 
              HealthCheckOptions
            {
              ResponseWriter = async (context, report) =>
              {
    context.Response.ContentType = 
                   "application/json";
                 var response = new
          {
             Status = report.Status.ToString(),
             HealthChecks = report.Entries.Select(x => new
             {
                Component = x.Key,
                Status = x.Value.Status.ToString(),
                Description = x.Value.Description,
              }),
              HealthCheckDuration = report.TotalDuration,
          };
          await context.Response.WriteAsync(
            JsonConvert.SerializeObject(response))
            .ConfigureAwait(false);
        },
       });
    });
    

在此代码中,健康检查中间件被重写,通过提供HealthCheckOptionsResponseWriter来将其状态、健康检查持续时间、组件名称和描述作为其响应写入。

  1. 现在,如果我们运行项目并导航到健康检查 API,我们应该看到以下输出:

图 14.3 – 健康检查端点响应状态

图 14.3 – 健康检查端点响应状态

我们已经学习了如何自定义健康检查端点的响应,以及如何利用第三方库来监控依赖 URI 的状态。如果您希望集成通过 Entity Framework Core 使用的数据库的检查,可以利用Microsoft.Extensions.Diagnostics.HealthChecks.EntityFrameworkCore库。有关使用此库的更多信息,请参阅docs.microsoft.com/en-us/aspnet/core/host-and-deploy/health-checks?view=aspnetcore-6.0#entity-framework-core-dbcontext-probe

可以在github.com/Xabaril/AspNetCore.Diagnostics.HealthChecks找到针对不同服务的更广泛的健康检查包集合。在下一节中,我们将学习如何构建自定义健康检查。

构建自定义健康检查

ASP.NET Core 6 中的健康检查中间件是可扩展的,这意味着它允许我们扩展并创建自定义健康检查。我们将通过构建进程监控器来学习如何构建和使用自定义健康检查。在某些场景中,可能需要监控机器上运行的特定进程。如果进程(例如,反恶意软件服务)没有运行,或者如果第三方 SaaS 服务的许可证即将到期/已到期,我们可能会将它们标记为健康问题。

让我们在Packt.Ecommerce.Common项目中开始创建ProcessMonitor健康检查:

  1. Packt.Ecommerce.Common中添加一个名为HealthCheck的项目文件夹,并添加两个类,ProcessMonitorProcessMonitorHealthCheckBuilderExtensions,如下面的截图所示:

图 14.4 – 添加自定义健康检查后的项目结构

图 14.4 – 添加自定义健康检查后的项目结构

自定义HealthCheck中间件需要 NuGet 引用为Microsoft.Extensions.Diagnostics.HealthChecks

  1. ASP.NET Core 6 中的自定义健康检查应该实现IHealthCheck接口。该接口定义了当请求到达健康端点 API 时将被调用的CheckHealthAsync方法。

  2. 按照以下代码实现ProcessMonitorHealthCheck类:

    public class ProcessMonitorHealthCheck : IHealthCheck
    {
       private readonly string processName;
       public ProcessMonitorHealthCheck(string 
         processName) => this.processName = processName;
       public Task<HealthCheckResult> 
         CheckHealthAsync(HealthCheckContext context, 
         CancellationToken cancellationToken = default)
        {
            Process[] pname = 
             Process.GetProcessesByName(this.processName);
            if (pname.Length == 0)
            {
                return Task.FromResult(new 
                  HealthCheckResult(context.Registration
                 .FailureStatus, description: $"Process 
                  with the name {this.processName} is not 
                  running."));
            }
            else
            {
                return Task.FromResult(
                  HealthCheckResult.Healthy());
            }
        }
    }
    

CheckHealthAsync方法中,根据processName中指定的名称获取进程列表。如果没有这样的进程,则返回健康检查失败,否则,返回状态为失败。

  1. 现在我们有了自定义健康检查中间件,让我们添加一个扩展方法来注册。修改ProcessMonitorHealthCheckBuilderExtensions类,如下面的代码片段所示:

    public static class ProcessMonitorHealthCheckBuilderExtensions
        {
            public static IHealthChecksBuilder 
              AddProcessMonitorHealthCheck(
                this IHealthChecksBuilder builder,
                string processName = default,
                string name = default,
                HealthStatus? failureStatus = default,
                IEnumerable<string> tags = default)
            {
                return builder.Add(new 
                  HealthCheckRegistration(
                   name ?? "ProcessMonitor",
                   sp => new 
                   ProcessMonitorHealthCheck(processName),
                   failureStatus,
                   tags));
            }
        }
    

这是一个针对IHealthCheckBuilder的扩展方法。我们可以看到,在代码片段中添加ProcessMonitorHealthCheck会将它注册到容器中。

  1. 现在我们来使用我们构建的自定义健康检查。在下面的代码中,我们为notepad注册了ProcessMonitorHealthCheck健康检查:

    // Add health check services to the container.
    Builder.Services.AddHealthChecks()
    .AddUrlGroup(new Uri(this.Configuration.GetValue<string>("ApplicationSettings :ProductsApiEndpoint")), name: "Product Service")
    .AddUrlGroup(new Uri(this.Configuration.GetValue<string>("ApplicationSettings :OrdersApiEndpoint")), name: "Order Service")
    .AddProcessMonitorHealthCheck("notepad", name: "Notepad monitor");
    
  2. 现在,当你运行应用程序并导航到健康检查 API 时,如果你机器上正在运行notepad.exe,你将看到图 14.5中显示的输出:

图 14.5 – 健康检查端点的响应

图 14.5 – 健康检查端点的响应

我们可以在我们的健康检查端点上启用跨源资源共享CORS)、授权和主机限制。有关详细信息,请参阅docs.microsoft.com/en-us/aspnet/core/host-and-deploy/health-checks?view=aspnetcore-6.0

在某些场景中,根据它们探测的应用程序状态,健康检查 API 被分为两种类型。它们如下:

  • 准备就绪探针:这些指示应用程序正在正常运行,但尚未准备好接收请求。

  • 存活探针:这些指示应用程序是否已崩溃并需要重启。

准备就绪探针和存活探针都用于控制应用程序的健康状态。失败的准备就绪探针将阻止应用程序处理流量,而失败的存活探针将重启节点。我们在 Kubernetes 等托管环境中使用准备就绪和存活探针。

我们已经学习了如何将健康检查 API 添加到 ASP.NET Core 6 应用程序中。在下一节中,我们将学习 Azure Application Insights 以及它是如何帮助监控应用程序的。

使用 Application Insights 监控应用程序

监控应用程序对于为最终用户提供一流体验至关重要。在当前这个超级快速数字市场的时代,应用程序监控对于推动业务投资回报率和保持竞争优势至关重要。我们应该关注的参数包括页面/API 性能、最常使用的页面/API、应用程序错误和系统健康等。当系统出现异常时,应设置警报,以便我们可以纠正它并最小化对用户的影响。

您已经在 第七章在 .NET 6 中进行日志记录 中介绍了将 Application Insights 集成到应用程序及其关键功能。让我们在 Azure 门户中打开 Application Insights 并了解其不同的功能。在概览仪表板上,除了 Azure 订阅、位置和仪器密钥外,我们还可以看到以下关键指标:

图 14.6 – Application Insights 仪表板

图 14.6 – Application Insights 仪表板

失败的请求 图显示了在所选时间段内失败的请求数量。这是我们应关注的指标;许多失败表示系统不稳定。服务器响应时间 表示服务器对调用的大致响应时间。如果响应时间过高,更多的用户将看到应用程序响应滞后,这可能会导致用户沮丧,我们可能会因此失去用户。

服务器请求 图表示对应用程序的总调用次数;这将给我们展示系统使用模式。可用性 图表示应用程序的运行时间。在本章后面配置的可用性测试将显示 可用性 图。通过点击每个图表,我们可以获取与相应指标相关的更多详细信息,包括请求和异常详情。我们可以更改持续时间来查看所选间隔的图表。

概览仪表板上的图表显示最近的指标。在希望了解过去特定时间点的系统工作情况时,这可能很有用。

在下一节中,我们将了解 Application Insights 的一些最重要的功能,包括实时指标、遥测事件和远程调试功能。

实时指标

默认情况下启用了实时指标。实时指标以 1 秒的延迟捕获,与按时间聚合的分析指标不同。只有当实时指标面板打开时,才会流式传输实时指标的数据。收集的数据仅在图表上存在。在实时指标监控期间,所有事件都从服务器传输,并且不会被采样。如果应用程序部署在 Web 农场中,我们还可以根据服务器过滤事件。

Live Metrics 展示了各种图表,例如入站和出站请求,以及内存和 CPU 利用率的整体健康状况。在右侧面板中,我们可以看到捕获的遥测数据,它将列出请求、依赖调用和异常。Live Metrics 在我们需要通过观察失败率和性能来评估已发布到生产环境的修复方案时被利用。我们也会在运行负载测试时监控这些数据,以查看负载对系统的影响。

对于像我们的 Ecommerce 应用这样的应用程序,了解用户如何使用应用程序、最常用的功能和用户如何遍历应用程序非常重要。在下一节中,我们将学习如何在 Application Insights 中进行使用分析。

使用 Application Insights 进行使用分析

第十一章创建 ASP.NET Core 6 Web 应用程序 中,你学习了如何将 Application Insights 与视图集成。当 Application Insights 与视图集成时,它可以帮助我们深入了解人们如何使用应用程序。Application Insights 下的 使用 部分的 用户 刀片提供了有关使用应用程序的用户数量的详细信息。用户通过存储在浏览器 cookie 中的匿名 ID 来识别。请注意,使用不同浏览器和机器的单一个人被计为多个用户。会话事件 刀片分别表示用户活动的会话以及某些页面或功能被使用的频率。你还可以根据你在 第七章在 .NET 6 中记录日志 中了解的自定义事件生成关于用户、会话和事件的报告。

在使用分析中,还有一个有趣的工具叫做 用户流程用户流程 工具可视化用户如何在不同页面和应用程序功能之间导航。用户流程提供了在用户会话期间给定事件之前和之后发生的事件。图 14.7 展示了给定时间点的用户流程。这告诉我们,从主页开始,用户主要导航到 产品详情 页面或 账户登录 页面:

图 14.7 – 我们电商应用的用户流程

图 14.7 – 我们电商应用的用户流程

让我们添加一些自定义事件,并查看用户流程与这些自定义事件之间的关系。在 Packt.Ecommerce.Web 应用程序的 OrderControllerCreate 动作方法中添加一个自定义事件,如下面的代码片段所示。这将跟踪用户在 购物车 页面上点击 下单 按钮时发生的自定义事件:

this.telemetry.TrackEvent("Create Order");

同样,让我们添加一个自定义事件跟踪,当用户在 产品详情 页面上点击 加入购物车 按钮时。为此,添加以下代码片段:

this.telemetry.TrackEvent("Add Item To Cart");

添加自定义事件后,用户流程将显示与这些事件相关的应用程序的不同活动。用户流程是一个方便的工具,可以了解有多少用户正在离开页面以及他们在页面上点击了什么。请参考章节末尾的进一步阅读部分提供的 Azure 应用洞察文档,以了解更多关于使用分析的其他有趣功能,包括群体、漏斗和保留。

当有足够的遥测事件时,你可以使用一个名为智能检测的应用洞察功能,它能够自动检测系统中的异常并向我们发出警报。在下一节中,我们将学习关于智能检测的内容。

智能检测

智能检测不需要任何配置或代码更改。它基于从系统中捕获的遥测数据工作。警报将在系统的智能检测选项卡下显示,并且这些警报将发送给具有监控读取器监控贡献者角色的用户。我们可以在设置选项下为这些警报配置额外的收件人。一些智能检测规则包括页面加载时间慢服务器响应时间慢每日数据量异常增加依赖项数量下降

对于应用程序,我们需要监控的一个重要方面是可用性。在下一节中,我们将学习如何利用应用洞察来监控应用程序的可用性。

应用程序可用性

在应用洞察中,我们可以为任何从互联网可访问的httphttps端点设置可用性测试。这不需要对我们的应用程序代码进行任何更改。我们可以为可用性测试配置健康检查端点(<App Root URL>/health)

要配置可用性测试,请转到 Azure 门户中的应用洞察资源,并执行以下步骤:

  1. 调查菜单下选择可用性,如图所示:

![图 14.8 – 应用洞察的可用性部分

![img/Figure_14.8_B18507.jpg]

图 14.8 – 应用洞察的可用性部分

  1. 点击添加测试以添加可用性测试,如前一张截图所示。

  2. Commerce 可用性测试中,选择<<App root url>>/health。将其他选项保留在默认值,然后点击创建

  3. 一旦测试配置完成,应用洞察将从所有配置的区域每 5 分钟调用一次配置的 URL。我们可以如下查看可用性测试结果:

![图 14.9 – 可用性测试结果

![img/Figure_14.9_B18507.jpg]

图 14.9 – 可用性测试结果

  1. 在创建测试时选择的默认区域为巴西南部东亚日本东部东南亚英国南部。我们可以添加或删除将在其上运行可用性测试的任何区域。建议至少配置五个区域。

  2. 如果我们想在以后的时间添加新的区域,我们可以编辑可用性测试并选择新的区域(例如,西欧),如下面的屏幕截图所示,然后点击保存

![图 14.10 – 编辑可用性测试区域图 14.10 – 示例图片

![图 14.10 – 编辑可用性测试区域

我们还可以在 Application Insights 中将多步骤 Web 测试配置为可用性测试。

备注

您可以使用以下文档来帮助您配置多步骤 Web 测试:docs.microsoft.com/en-us/azure/azure-monitor/app/availability-multistep

Application Insights 提供了一个非常好的工具来查询捕获的遥测事件。在下一节中,我们将了解 Application Insights 中的搜索功能。

搜索

Application Insights 中的搜索功能有助于探索遥测事件,如请求、页面视图和异常。我们还可以查询我们在应用程序中编码的跟踪。搜索可以从概览选项卡或从调查选项卡的搜索选项中打开:

![图 14.11 – 搜索结果图 14.11 – 示例图片

![图 14.11 – 搜索结果使用事务搜索功能,我们可以根据时间和事件类型过滤显示的遥测事件。我们还可以根据它们的属性进行过滤。通过点击特定事件,我们可以查看事件的全部属性以及事件的遥测信息。要查看状态代码为500的请求,请根据响应代码过滤事件,如下所示:![图 14.12 – 过滤搜索结果图 14.12 – 示例图片

![图 14.12 – 过滤搜索结果一旦我们应用了过滤器,在搜索结果中,我们只会看到响应代码为 500 的请求,如下面的屏幕截图所示:![图 14.13 – 过滤后的搜索结果![图 14.13 – 过滤后的搜索结果图 14.13 – 过滤后的搜索结果要了解更多关于导致失败的原因,请点击事件。点击事件将显示相关遥测的详细信息,如下面的屏幕截图所示:![图 14.14 – 端到端事务详情图 14.14 – 示例图片

图 14.14 – 端到端事务详情

我们甚至可以通过点击异常来进一步深入。这将显示诸如方法名称和堆栈跟踪等详细信息,这将帮助我们确定失败的原因。

使用 Application Insights,我们可以对捕获的遥测数据编写自定义查询,以获得更有意义的见解。在下一节中,我们将学习如何编写查询。

日志

要对捕获的遥测数据进行查询,请按照以下步骤导航:

  1. 前往Application Insights | 监控 | 日志。这将显示日志页面,其中包含我们可以运行的示例查询:

![图 14.15 – Application Insights 日志图 14.15 – 示例图片

图 14.15 – Application Insights 日志

  1. 在建议的样本查询中选择请求计数趋势。这将为我们生成一个查询并运行它。一旦运行完成,我们将看到结果和图表被填充,如下面的截图所示:

![图 14.16 – 日志搜索结果图片

图 14.16 – 日志搜索结果

在 Application Insights 中捕获的遥测数据进入不同的表,涵盖请求、异常、依赖项、跟踪和页面视图。这里生成的查询汇总了请求表中的遥测数据,并渲染了一个时间图表,其中时间轴被分成 30 分钟。

我们根据需求选择时间范围。我们甚至可以在查询中指定时间范围,而不是从菜单选项中选择。这里创建的查询可以保存并在以后重新运行。这里还有一个配置警报的选项,我们曾在第七章中了解到,即.NET 6 中的日志记录。这里编写查询使用的语言是 Kusto。

注意

有关 Kusto 查询语言的更多信息,请参阅以下文档:docs.microsoft.com/en-us/azure/data-explorer/kusto/concepts/

Kusto 基于关系数据库结构。使用 Kusto 查询语言,我们可以编写复杂的分析查询。Kusto 支持按组聚合、计算列和连接函数。

让我们再举一个例子,我们想要识别每个客户城市的 95 百分位服务响应时间。这个查询将如下编写:

requests
| summarize 95percentile=percentile(duration, 0.95) by client_City
| render barchart

在前面的查询中,我们使用percentile函数来识别 95 百分位并按区域进行汇总。结果以条形图的形式呈现。

对于前面的查询,我们看到以下图表:

![图 14.17 – Kusto 百分位摘要结果图片

图 14.17 – Kusto 百分位摘要结果

从渲染的图表中,我们可以推断出来自钦奈的请求响应时间比来自西姆纳巴德的请求响应时间更快。

现在,让我们找到任何导致请求失败的异常,并按请求和异常类型进行汇总。为了得到这个结果,我们将requests表与exceptions表连接,并根据请求名称和异常类型进行汇总,如下面的查询所示:

requests
| join kind= inner (
exceptions
) on operation_Id
| project requestName = name, exceptionType = type
| summarize count=sum(1)  by requestName, exceptionType

如果我们运行这个查询,我们会得到以下截图所示的按请求名称和异常类型汇总的结果:

![图 14.18 – Kusto 失败的请求异常图片

图 14.18 – Kusto 失败的请求异常

搜索是 Application Insights 的强大功能,用于诊断和修复生产站点的故障。建议点击 Application Insights 的不同功能并探索它们。

当您创建 Application Insights 资源时,将创建一个日志分析工作区,该工作区将持久保存通过 Application Insights 捕获的遥测数据。使用日志分析工作区以及应用程序度量,我们还可以查询和监控与 Azure 资源相关的关键指标,例如在 Cosmos DB 中消耗的 RU/s。本节中运行的所有查询都是在日志分析工作区中执行的。我们可以在 Azure 门户中创建仪表板,并将所有与我们要跟踪的应用程序关键指标相关的图表固定在仪表板上。

注意

参考 Azure 文档以了解更多关于日志分析的信息:docs.microsoft.com/en-us/azure/azure-monitor/logs/log-analytics-overview

在本节中,我们学习了如何使用 Azure Monitor 监控在 Azure 中部署的应用程序。为了更好地分析和调试任何生产故障,我们可能想了解特定错误发生时应用程序的状态。在下一节中,我们将学习 Application Insights 的快照调试器功能如何使我们实现这一点。

配置快照调试器

快照调试器监控我们应用程序的异常遥测。它自动收集应用程序中发生的顶级异常的快照,包括当前源代码的状态和变量。

注意

快照调试器功能仅在 Visual Studio 的企业版中可用。

现在让我们继续配置我们的Ecommerce应用的快照调试器:

  1. Microsoft.ApplicationInsights.SnapshotCollector NuGet 包添加到Packt.Ecommerce.Web项目中。

  2. 将以下using语句添加到Startup.cs中:

    using Microsoft.ApplicationInsights.SnapshotCollector;
    
  3. 通过在ConfigureServices方法中添加以下行,将快照收集器添加到您的服务中:

    services.AddApplicationInsightsTelemetry(this.Configuration["ApplicationInsights:InstrumentationKey"]);
    builder.Services.AddSnapshotCollector((configuration) => this.Configuration.Bind(nameof(SnapshotCollectorConfiguration), configuration));
    
  4. 要模拟失败,请将以下代码添加到EcommerceService类的GetProductsAsync方法中。如果存在任何产品,此代码将引发错误:

    public async Task<IEnumerable<ProductListViewModel>> GetProductsAsync(string filterCriteria = null)
    {        
                // Code removed for brevity
                if (products.Any())
                {
                    throw new InvalidOperationException();
                }
                return products;
    }
    
  5. 现在,让我们继续运行应用程序。我们在主页上看到一个错误。刷新页面,因为调试快照是为至少发生两次的错误而设计的。

  6. 现在,打开 Application Insights 中的搜索选项卡。按异常事件类型进行筛选:

![图 14.19 – 异常遥测图片

图 14.19 – 异常遥测

  1. 点击异常进入详情页面。在详情页面中,我们看到已经为异常创建了调试快照,如以下截图所示:

![图 14.20 – 调试快照图片

图 14.20 – 调试快照

  1. 点击调试快照图标。这将带我们到调试快照页面:

![图 14.21 – 调试快照窗口图片

图 14.21 – 调试快照窗口

  1. 要查看调试快照,需要应用程序洞察快照调试器角色。由于调试状态可能包含敏感信息,因此默认情况下不会添加此角色。单击添加应用程序洞察快照调试器角色按钮。这将向当前登录用户添加该角色。

  2. 一旦角色添加完成,我们就可以在页面上看到调试快照的详细信息,以及一个下载快照的按钮:

图 14.22 – 下载调试快照

图 14.22 – 下载调试快照

  1. 单击 diagsession。在 Visual Studio 中打开下载的 diagsession 文件:

图 14.23 – Visual Studio 中的调试快照视图

图 14.23 – Visual Studio 中的调试快照视图

  1. 现在,单击 InvalidOperationException

图 14.24 – Visual Studio 中的调试快照

图 14.24 – Visual Studio 中的调试快照

在此调试会话中,我们可能需要将监视器添加到局部变量和类变量,以了解它们的状态,这将有助于调试。

注意

请参阅以下文档以了解有关快照调试器配置的更多信息:docs.microsoft.com/en-us/azure/azure-monitor/app/snapshot-debugger-vm

随着应用程序的增长并与多个其他服务集成,将面临在生产环境中调试和解决出现的问题的挑战。在某些情况下,我们无法在预生产环境中重现它们。通过我们捕获的遥测数据和 Application Insights 提供的工具,我们将能够分析问题并解决问题。快照调试器是一个强大的工具,用于调试关键问题。Application Insights 收集遥测数据并通过后台进程批量发送。使用 Application Insights 对我们应用程序的影响很小。

可能存在我们需要调试一个运行中的应用程序的情况。使用 Visual Studio,我们可以将调试器附加到远程运行的应用程序以进行调试。在下一节中,我们将学习如何实现这一点。

执行远程调试

在本节中,我们将学习如何将调试器附加到我们在 Azure App Service 中部署的应用程序。使用 Visual Studio 提供的工具,远程调试应用程序很容易。在 Azure App Service 中部署应用程序的内容在 第十六章在 Azure 中部署应用程序 中介绍。我们可以通过执行以下操作将调试器附加到已部署的服务:

  1. 通过右键单击Packt.Ecommerce.Web项目并从上下文菜单中选择发布,或通过设置构建|发布****Packt.Ecommerce.Web菜单项来启动发布窗口。

  2. 通过在发布向导中选择 Azure App Service 资源,为 Packt.Ecommerce.Web 创建一个 发布 配置文件。

  3. 创建 发布 配置文件后,您可以通过从 托管 选项中选择 附加调试器 来将调试器附加到在 Azure App Service 中运行的应用程序实例,如下面的屏幕截图所示:

图 14.25 – Visual Studio 的发布窗口

图 14.25 – Visual Studio 的发布窗口

  1. 一旦调试器连接,应用程序将在 Azure App Service 中通过浏览器打开。我们可以在 Visual Studio 中添加断点,并像在本地开发环境中一样调试应用程序。为了有效地调试,我们需要将应用程序的调试版本部署到 Azure App Service。

虽然这是远程部署应用程序的强大功能,但在将调试器附加到生产实例时应格外小心,因为我们将会看到实时客户数据。我们可以将调试器附加到 Azure App Service 的预发布槽位以调试和修复问题,然后从那里交换预发布槽位以将修复推广到生产。本章未涵盖 Application Insights 和 Azure Monitor 的许多其他重要功能。强烈建议在 Azure 文档中进一步探索它们。

摘要

本章向您介绍了使用 Application Insights 对应用程序进行健康检查和诊断问题的概念。我们学习了如何构建健康检查 API 并将健康检查模块添加到我们的 Ecommerce 应用程序中,这将帮助我们监控应用程序的健康状况。本章还介绍了 Azure Application Insights 的关键特性,它是一个强大的工具,用于捕获遥测数据和诊断问题。

我们学习了如何使用智能检测功能检测 Application Insights 中的异常和警报。我们还了解了快照和远程调试,这些有助于在生产环境中运行的实时应用程序中解决问题。

在下一章中,我们将学习不同的测试方法,以确保在部署到生产环境之前应用程序的质量。

问题

在阅读本章后,我们应该能够回答以下问题:

  1. 一旦应用程序部署到生产环境,定期监控应用程序并不那么重要。对还是错?

a. 正确

b. 错误

答案:b

  1. 自定义健康检查模块应该实现哪个接口?

a. IHealth

b. IApplicationBuilder

c. IHealthCheck

d. IWebHostEnvironment

答案:c

  1. 在 Application Insights 中显示实时指标数据的延迟是多少?

a. 1 分钟

b. 1 秒

c. 10 秒

d. 5 秒

答案:b

  1. 在 Application Insights 日志中编写查询所使用的查询语言是什么?

a. SQL

b. C#

c. JavaScript

d. Kusto

答案:d

进一步阅读

第十五章:第十五章:测试

任何应用程序的成功取决于用户使用它的难易程度。任何软件产品的寿命直接取决于产品的质量。

测试是软件开发生命周期SDLC)的一个重要方面,并确保产品满足客户的要求和质量要求。测试同样重要,因为随着我们进入 SDLC 的后期阶段,修复错误的成本会增加。

在本章中,我们将学习不同类型的测试以及 Visual Studio 为测试提供的工具,以及查看我们可以使用的第三方工具,以确保我们在.NET 6 中构建的产品质量。

在本章中,我们将学习以下内容:

  • 测试类型

  • 单元测试

  • 功能测试

  • 理解负载测试的重要性

到本章结束时,您将了解确保产品质量所需的一切知识。

技术要求

您将需要 Visual Studio 2022 社区版。(某些部分需要企业版。)

除了 Visual Studio,您还需要 JMeter,可以从这里下载:jmeter.apache.org/download_jmeter.cgi。您还需要对 Microsoft .NET 有基本的了解。

介绍测试

软件测试是一种检查应用程序是否按预期执行的方法。这些期望可能与功能、响应性或软件在运行时消耗的资源有关。

根据执行方式,软件测试可以大致分为以下两类:

  • 手动测试:在手动测试中,测试人员通过使用测试的应用程序手动执行测试用例,并验证预期的结果。手动测试比替代方案需要更多的努力。

  • 自动化测试:自动化测试由专门的自动化测试软件执行。此自动化软件在测试的应用程序中运行于特定环境中,并验证预期的输出。自动化测试可以节省大量时间和人力。在某些情况下,实现 100%的自动化可能需要付出很多努力,并且以相对较低的投资回报率ROI)来维护自动化。

根据对测试应用程序内部信息(如代码流和依赖模块集成)的了解,软件测试也可以广泛地按以下方式进行分类:

  • 黑盒测试:在黑盒测试中,负责测试的个人没有关于系统内部的信息。这里的重点是系统的行为。

  • 白盒测试:在白盒测试中,测试人员了解系统的内部结构、设计和实现。白盒测试的重点是测试实现中存在的替代路径。

在软件测试中,我们验证应用程序的不同方面。软件测试还有以下变体,基于它验证的应用程序方面以及它使用的工具或框架:

  • 单元测试:单元测试关注应用程序的最小单元。在这里,我们验证单个类或函数。这通常在开发阶段完成。

  • 功能测试:这通常被称为集成测试。其主要目的是确保应用程序按要求执行。

  • 回归测试:回归测试确保任何最近的变化都没有对应用程序性能产生不利影响,并且现有功能没有受到任何变化的影响。在回归测试中,根据应用程序中引入的变化,执行所有或部分功能测试用例。

  • 烟雾测试:在每次部署后进行烟雾测试,以确保应用程序稳定且准备就绪。这也被称为构建验证测试BVT)。

  • 负载测试:负载测试用于确定系统的整体有效性。在负载测试期间,我们模拟集成系统上的预期负载。

  • 压力测试:在压力测试中,我们将系统推到预期容量或负载之外。这有助于我们识别系统中的瓶颈和故障点。性能测试是用于压力测试和负载测试的通用术语。

  • 安全测试:安全测试是为了确保应用程序的完美执行。在安全测试中,我们专注于评估安全方面的各种元素,如完整性、机密性和真实性等。

  • 可访问性测试:可访问性测试旨在确定不同能力的人是否能够使用应用程序。

现在我们已经看到了不同类型的测试,在接下来的章节中,我们将详细介绍单元测试、功能测试和负载测试,因为它们对于确保应用程序的稳定性至关重要。

注意

要深入了解安全方面,请尝试使用静态代码分析工具进行安全测试:docs.microsoft.com/en-us/azure/security/develop/security-code-analysis-overview。更多关于可访问性的信息可以在这里找到:accessibilityinsights.io/

性能测试、可访问性测试和安全测试是我们执行以评估应用程序非功能性方面的测试,例如性能、可用性、可靠性、安全性和可访问性。

现在,让我们看看如何为我们电子商务应用程序执行单元测试。

单元测试

单元测试是测试应用程序最小隔离单元的一种方式。它是软件开发中的重要步骤,有助于早期隔离问题。

单元测试对我们构建的软件质量有直接影响。建议在编写任何方法后立即编写单元测试。如果我们遵循测试驱动开发TDD)的方法论,我们首先编写测试用例,然后继续实现功能。

在下一节中,我们将学习如何在 Visual Studio 中创建单元测试并运行它们。

Visual Studio 中的单元测试

我们选择使用 Visual Studio,因为它具有创建和管理测试用例的强大工具。

使用 Visual Studio,我们可以创建、调试和运行单元测试用例。我们还可以检查已执行的测试的代码覆盖率。此外,它还具有实时单元测试功能,在修改代码的同时运行单元测试,并将结果实时显示。

我们将在接下来的章节中探讨所有这些功能。

创建和运行单元测试

让我们继续创建一个单元测试项目,以对Packt.ECommerce.Order项目进行单元测试。

执行以下步骤以创建单元测试用例:

  1. Tests文件夹中添加一个新的项目,并将其命名为Packt.ECommerce.Order.UnitTest

![Figure 15.1 – Visual Studio xUnit 测试项目

![img/Figure_15.1_B18507.jpg]

图 15.1 – Visual Studio xUnit 测试项目

  1. Packt.ECommerce.Order项目添加到新创建的测试项目中。

  2. 向测试项目添加一个新类,并将其命名为OrdersControllerTest。我们将在这个类中添加所有与OrdersController相关的测试用例。

  3. 现在,让我们添加一个简单的测试来测试OrdersController控制器的构造函数。我们将进行的测试是断言成功创建OrderController控制器。现在让我们添加测试,如下面的代码所示:

    [Fact]
    public async Task Create_Object_OfType_OrderController ()
    {
    OrdersController testObject = new 
            OrdersController(null);
          Assert.NotNull(testObject);
    }
    

Create_Object_OfType_OrderController测试方法被赋予Fact属性;这是xUnit框架发现测试方法所必需的。在这里,我们通过检查创建的对象的null条件来进行断言。

  1. Visual Studio 提供测试资源管理器来管理和运行测试。让我们通过转到测试 | 测试资源管理器来打开它。

  2. 构建解决方案以在测试资源管理器中查看测试。

  3. 在通过右键单击并从上下文菜单中选择运行创建的OrderController_Constructor测试中:

![Figure 15.2 – 从测试资源管理器窗口的测试运行上下文菜单

![img/Figure_15.2_B18507.jpg]

图 15.2 – 从测试资源管理器窗口的测试运行上下文菜单

  1. 一旦测试执行完毕,我们可以在右侧窗格中看到测试结果。从结果中,我们可以看到测试已成功执行,如下所示:

![Figure 15.3 – 从测试资源管理器获取的测试结果

![img/Figure_15.3_B18507.jpg]

图 15.3 – 从测试资源管理器获取的测试结果

我们已经在 Visual Studio 中创建并执行了一个简单的测试。在下一节中,我们将学习如何模拟OrdersController的依赖项以验证功能。

使用 Moq 模拟依赖项

通常,被测试的方法会调用其他外部方法或服务,我们称之为依赖项。为了确保被测试方法的函数性,我们通过为依赖项创建模拟对象来隔离依赖项的行为。

在一个应用程序中,类可能依赖于其他类;例如,我们的OrdersController类依赖于OrderService。在测试OrdersController时,我们应该隔离OrderService的行为。

为了理解模拟,让我们为OrdersControllerGetOrdersAsync操作方法创建单元测试。

让我们看看我们正在为其编写单元测试用例的GetOrderById方法:

//This is the GetOrderById action method in OrdersController.cs
[HttpGet]
[Route("{id}")]
public async Task<IActionResult> GetOrderById(string id)
{
     var order = await 
       this.orderService.GetOrderByIdAsync(id)
       .ConfigureAwait(false);
     if (order != null)
     {
          return this.Ok(order);
     }
     else
     {
          return this.NotFound();
     }
}

在这个方法中,调用orderServiceGetOrderByIdAsync以根据传入的id实例获取订单。控制器操作将返回从OrderService检索到的订单id;否则,返回NotFound操作。

正如我们所看到的,代码流有两个路径:

  • 一条路径是当订单存在时。

  • 另一条路径是当订单不存在时。

通过单元测试,我们应该能够覆盖这两个路径。所以,现在出现的问题是,我们如何模拟这两个情况?

我们想要在这里模拟OrderService的响应。为了模拟OrderService的响应,我们可以利用Moq库。为了利用 Moq,我们需要将Moq包的 NuGet 引用添加到Packt.ECommerce.Order.UnitTest测试项目中。

让我们在OrdersControllerTest类中添加测试方法,如下所示,以测试OrdersControllerGetOrderById方法,以验证当OrderService返回订单对象的情况:

[TestMethod]
public async Task When_GetOrdersAsync_with_ExistingOrder_receive_OkObjectResult()
{
     var stub = new Mock<IOrderService>();
    stub.Setup(x => x.GetOrderByIdAsync(
It.IsAny<string>())).Returns(Task.FromResult(new 
      OrderDetailsViewModel { Id = "1" }));
    OrdersController testObject = new 
      OrdersController(stub.Object);
    var order = await 
      testObject.GetOrderById("1").ConfigureAwait(false);
    Assert.IsType<OkObjectResult>(order, 
      typeof(OkObjectResult));
}

从代码中,我们可以观察到以下内容:

  • 由于IOrderService通过控制器注入注入到OrderController中,我们可以向OrderController注入一个模拟的OrderService,这将帮助我们通过改变模拟对象的行为来测试OrderController的所有代码路径。

  • 我们利用Mock类为IOrderService创建一个存根(也称为模拟),并覆盖前面的代码中的GetOrderByIdAsync行为。

  • 我们为IOrderService接口创建一个Mock对象实例,并通过在Mock对象上调用Setup方法来设置GetOrderByIdAsync的行为。

  • GetOrderByIdAsync方法被模拟,使得对于它接收到的任何参数值,mock对象将返回具有Id1OrderDetailsViewModel对象。

  • 由于我们通过构造函数注入将模拟对象注入到OrderService中,因此每当调用IOrderService中的任何方法时,调用将转到IOrderService的模拟实现。

  • 最后,我们通过验证从OrderController返回到OkObjectResult的结果类型来断言测试结果。

现在,让我们添加一个测试用例来验证当订单不存在时接收NotFound结果的行为,如下所示:

[TestMethod]
public async Task When_GetOrdersAsync_with_No_ExistingOrder_receive_NotFoundResult()
{
     var stub = new Mock<IOrderService>();
stub.Setup(x => 
     x.GetOrderByIdAsync(It.IsAny<string>()))
    .Returns(Task.FromResult<OrderDetailsViewModel>(null));
     OrdersController testObject = new 
       OrdersController(stub.Object);
     var order = await testObject
       .GetOrderById("1").ConfigureAwait(false);
Assert.IsType<NotFoundResult>(order, 
       typeof(NotFoundResult));
}

在这个测试用例中,我们通过从OrderService存根返回一个null值来模拟订单不存在的行为。这将使OrdersControllerGetOrderById操作方法返回NotFoundResult,并在测试用例中进行验证。

注意

OrderService类依赖于IHttpClientFactoryIOptionsMapperDistributedCacheService。因此,为了为这个添加单元测试,我们应该模拟它们所有。您可以在 GitHub 代码示例的OrderServiceTest测试类中的When_GetOrderByIdAsync_with_ExistingOrder_receive_Order测试方法中查看更多详细信息。

在本节中,我们看到了如何利用xUnit框架创建单元测试。还有几个其他测试框架可用于在.NET 中创建单元测试。这里值得提到的两个框架是 MSTest 和 NUnit。尽管这些框架在测试执行方式上存在一些差异,但所有这些框架都非常出色,并提供诸如模拟和并行执行等功能。由于其简单性和可扩展性,xUnit 相对于竞争框架有一定的优势。我们还可以使用 xUnit 中的Theory编写数据驱动测试,如下面的代码片段所示:

[Theory]
[InlineData(999, 19.98)]
[InlineData(2000, 100)]
public void When_ComputeTotalDiscount_with_OrderTotalAmount(double number, double expectedResult)
{
<<Code removed for brevity>>
   OrdersService testObject = new 
     OrdersService(httpClientFactory, mockOptions, mapper, 
     mockCacheService.Object);
   var result = testObject.ComputeTotalDiscount(number);
   Assert.Equal(result, expectedResult);
}

在前面的代码片段中,测试方法通过InlineData属性传递的测试数据执行。

在单元测试中,我们的目标是通过对依赖类的行为进行模拟来测试一个特定的类。如果我们与其他依赖类一起测试这些类,我们称之为集成测试。我们在各种级别编写集成测试:在特定模块或程序集的级别,在微服务级别,或在整个应用级别。

现在我们已经为我们的电子商务解决方案添加了单元测试用例,在下一节中,我们将检查这些测试的代码覆盖率。

代码覆盖率

代码覆盖率是衡量我们的测试用例覆盖了多少代码的一个指标。Visual Studio 提供了一个工具来查找单元测试的代码覆盖率。我们可以运行测试 | 分析代码覆盖率来对所有测试进行操作,如下所示:

![图 15.4 – 文本探索器中的“分析代码覆盖率”上下文选项]

](https://github.com/OpenDocCN/freelearn-csharp-zh/raw/master/docs/entp-appdev-csp10-dn6/img/Figure_15.4_B18507.jpg)

图 15.4 – 文本探索器中的“分析代码覆盖率”上下文选项

这也可以从测试资源管理器上下文菜单中完成。

注意

分析代码覆盖率功能仅在 Visual Studio 的企业版中可用。如果您使用的是社区版,您可以使用 Visual Studio 免费扩展程序,marketplace.visualstudio.com/items?itemName=FortuneNgwenya.FineCodeCoverage,来查看代码覆盖率结果。

这将运行所有测试用例并识别任何未测试的代码块。我们可以在以下代码覆盖率结果窗口中查看代码覆盖率结果:

![图 15.5 – Visual Studio 代码覆盖率结果窗口]

图 15.5_B18507

图 15.5 – Visual Studio Code 覆盖率结果窗口

GetOrderByIdAsync,该方法的代码覆盖率是GetOrdersAsync0.00%,因为我们没有测试用例来测试它。代码覆盖率为我们提供了关于我们的单元测试有效性的良好指示。

建议为解决方案中的所有类创建单元测试用例。通过添加单元测试来验证所有类和功能,将会有更高比例的代码被单元测试用例覆盖。随着代码覆盖率的提高,我们将在开发过程中对解决方案进行更改时能够更早地捕获更多错误。我们应该在提交更改之前确保所有测试用例都通过。在下一章,第十六章在 Azure 中部署应用程序,我们将学习如何将运行测试用例与 Azure DevOps 管道集成。

到目前为止,我们已经通过模拟依赖关系和编写单元测试用例来测试单个模块或类。在集成和部署整个解决方案后测试功能也很重要。在下一节中,我们将学习如何为我们电子商务应用执行功能测试。

小贴士

Visual Studio 的代码指标和代码分析工具对于确保我们编写的代码的可维护性和可读性非常有用。您可以在以下位置找到有关代码指标的信息:docs.microsoft.com/en-us/visualstudio/code-quality/code-metrics-values?view=vs-2022

对于代码分析,请访问此处:docs.microsoft.com/en-us/dotnet/fundamentals/code-analysis/overview

功能测试

在功能测试中,我们验证我们构建的应用是否符合功能需求。功能测试通过提供一些输入并断言应用的响应或输出来进行。在进行功能测试时,我们将应用视为一个整体;我们不是验证单个内部组件。

功能测试可以分为三个任务:

  1. 识别待测试系统的功能

  2. 确定具有预期输出的输入

  3. 执行这些测试以评估系统是否按预期响应

功能测试的执行可以通过在应用上执行测试步骤手动进行,或者我们可以使用工具来自动化它们。通过自动化功能测试,可以显著缩短应用的上市时间。

在下一节中,我们将学习如何自动化功能测试用例。

自动化功能测试用例

手动执行功能测试用例在应用程序测试中仍然相关。然而,考虑到更短的部署周期和客户对快速获取新功能的期望,手动测试可能会非常耗时且效率低下,尤其是在早期识别缺陷方面。使用自动化,我们可以获得新的效率,加速测试过程,并提高软件质量。有多种工具和框架可用于自动化功能测试用例。

在本节中,我们将了解最受欢迎的自动化框架 Selenium。让我们开始:

  1. 首先,让我们创建一个名为 MSTest 的项目,并将其命名为 Packt.ECommerce.FunctionalTest

  2. 向此项目添加 Selenium.WebDriverSelenium.WebDriver.ChromeDriverWebDriverManager NuGet 包。这些包是我们运行 Selenium 测试所必需的。

  3. 让我们从一项简单的测试开始,以验证我们电子商务应用程序的标题。为此,创建一个名为 HomePageTest 的测试类和一个名为 When_Application_Launched_Title_Should_be_ECommerce_Packt 的测试方法,就像我们在 单元测试 部分所做的那样,如下面的代码所示:

    [TestClass]
    public class HomePageTest
    {
       [TestMethod]
        public void When_Application_Launched_Title
          _Should_be_ECommerce_Packt()
    {
    }
    }
    
  4. 要执行我们的功能测试,我们应该启动一个浏览器并使用该浏览器导航到电子商务应用程序。MSTest 框架提供了一个特殊函数来执行测试所需的初始化和清理操作。我们将创建一个 Chrome 网络驱动程序来执行功能测试。

让我们继续添加初始化和清理方法,如下面的代码所示:

[TestClass]
public class HomePageTest
{
     ChromeDriver _webDriver = null;
     [TestInitialize]
public void InitializeWebDriver()
     {
            var d = new DriverManager();
            d.SetUpDriver(new ChromeConfig());
            _webDriver = new ChromeDriver();
     }
     [TestMethod]
     public void When_Application_Launched_Title
       _Should_be_ECommerce_Packt()
     {
     }
     [TestCleanup]
     public void WebDriverCleanup()
     {
            _webDriver.Quit();
     }
}

在前面的代码中,InitializeDriver 方法被赋予 TestInitialize 属性,以通知框架这是一个测试初始化方法。在测试初始化中,我们创建 ChromeDriver 并初始化类变量。测试用例完成后,我们应该关闭浏览器实例;我们通过在 WebDriverCleanup 方法中调用 Quit 方法来完成此操作。为了通知测试框架这是一个清理方法,它应该被赋予 TestCleanup 属性。

  1. 现在,让我们添加测试用例以导航到电子商务应用程序并验证标题,如下面的代码所示:

    [TestMethod]
    public void When_Application_Launched_Title_Should_be_ECommerce_Packt()
    {
         _webDriver.Navigate().GoToUrl("https://localhost:
           44365/");
    Assert.AreEqual("Ecommerce Packt", 
           _webDriver.Title);
    }
    

在我们的 Chrome 网络驱动程序上调用 GoToUrl 以导航到电子商务应用程序。一旦导航成功,我们可以通过断言网络驱动程序的 Title 属性来验证页面标题。

  1. When_Application_Launched_Title_Should_be_ECommerce_Pact 测试用例运行测试用例,并选择 运行。这将打开 Chrome 浏览器并导航到指定的电子商务 URL,然后断言页面标题。测试用例执行完毕后,浏览器将被关闭。我们可以在 测试资源管理器 中看到结果,如下面的截图所示:

图 15.6 – 创建测试项目后的解决方案结构

图 15.6 – 创建测试项目后的解决方案结构

  1. 现在,我们将扩展功能测试以验证搜索功能。为了测试这个功能,我们应该在搜索框中输入文本并点击搜索按钮。然后,检查结果以查看返回的测试结果是否仅针对搜索的产品。

  2. 让我们通过添加When_Searched_For_Item测试方法来自动化测试用例,如下面的代码所示:

    [TestMethod]
    public void When_Searched_For_Item()
    {
          _webDriver.Navigate().GoToUrl("https://localhost
            :44365/");
          var searchTextBox = 
          _webDriver.FindElement(By.Name("SearchString"));
           searchTextBox.SendKeys("Orange Shirt");
           _webDriver.FindElement(By.Name("searchButton"))
             .Click();
           var items = 
            _webDriver.FindElements(By.ClassName("product-
            description"));
           var invaidProductCout = items.Where(e => e.Text 
            != "Orange Shirt").Count();
           Assert.AreEqual(0, invaidProductCout);
    }
    

在这个测试用例中,在导航到主页后,在search字符串中输入搜索文本。

Selenium 使得编写功能测试变得非常简单。我们应该尝试自动化所有功能测试用例,例如用户管理、将产品添加到购物车和下订单。当所有功能测试用例都自动化后,我们将处于更好的位置来测试和验证新版本的功能,并保持我们应用程序的质量。还有其他功能测试工具可用,例如 QTP 和 Visual Studio Coded UI 测试。

我们已经了解了功能测试,它验证了应用程序的功能。同样重要的是评估应用程序对特定负载的响应能力。在下一节中,我们将学习如何在我们的电子商务应用程序上执行性能测试。我们可以利用自动化的功能测试用例来执行 BVT 或回归测试。

注意

请参阅文档以了解更多关于 Selenium 测试的信息:www.selenium.dev/documentation/en/

压力测试

用户期望应用程序能够快速响应用户的操作。任何响应缓慢都会导致用户沮丧,最终我们可能会失去他们。即使应用程序在正常负载下运行良好,我们也应该知道我们的应用程序在需求突然增加时的表现,并为此做好准备。

压力测试的主要目标不是寻找错误,而是消除应用程序的性能瓶颈。进行压力测试是为了向利益相关者提供有关其应用程序速度、可扩展性和稳定性的信息。在下一节中,我们将学习如何使用 JMeter 进行压力测试。

使用 JMeter 进行压力测试

JMeter 是由 Apache 软件基金会构建的开源测试工具。它是用于执行压力测试的最受欢迎的工具之一。JMeter 可以通过创建虚拟并发用户来模拟对应用程序的重负载。

让我们继续创建我们的电子商务应用程序的 JMeter 压力测试。

要了解如何使用 JMeter 进行压力测试,我们将创建一个包含两个主页和产品搜索页面的测试。尝试以下步骤来创建压力测试:

  1. 从下载位置启动 Apache JMeter。我们将看到如下窗口:

![Figure 15.7 – Apache JMeter]

![img/Figure_15.7_B18507.jpg]

Figure 15.7 – Apache JMeter

  1. 通过在左侧面板的测试计划上右键单击并选择添加 | 线程(用户) | 线程组,添加一个线程组。线程组定义了将执行测试用例的用户池。有了它,我们可以配置模拟的用户数量、启动所有用户的时长以及执行测试的次数。

  2. 让我们命名线程组为Load and Query Products并将用户数量设置为30。设置5秒,如下截图所示:

![Figure 15.8 – Adding a thread group in Apache JMeter

![img/Image87474.jpg]

图 15.8 – 在 Apache JMeter 中添加线程组

这将在5秒内模拟30个用户负载。使用线程组,我们还可以控制测试应该运行多少次。

  1. 要添加测试请求,右键单击线程组并选择添加 | 采样器 | HTTP 请求

让我们设置httpslocalhost44365(本地运行的电子商务门户的端口号)。将此测试命名为Home Page,如下截图所示:

![Figure 15.9 – Adding the Home Page HTTP request in JMeter

![img/Image87486.jpg]

图 15.9 – 在 JMeter 中添加主页面的 HTTP 请求

让我们再添加一个 HTTP 请求采样器来获取特定产品的详细信息。对于此请求,将productId查询参数设置为Cloth.3,将productName设置为Orange%20Shirt,如下截图所示:

![Figure 15.10 – Adding the Product Details page HTTP request in JMeter

![img/Figure_15.10_B18507.jpg]

图 15.10 – 在 JMeter 中添加产品详情页面的 HTTP 请求

  1. 通过点击ECommerce保存此测试计划。

  2. 要查看结果,我们应该为此测试添加一个监听器。右键单击测试组并选择添加 | 监听器 | 以表格形式查看结果

  3. 一旦添加了监听器,就可以通过选择运行 | 开始来运行测试。

  4. 测试运行完成后,你将看到如下截图所示的结果。这将给我们每个请求的响应时间:

![Figure 15.11 – Test results table in JMeter

![img/Figure_15.11_B18507.jpg]

图 15.11 – JMeter 中的测试结果表格

JMeter 提供了多种监听器来查看结果,例如摘要报告图形结果,它们将以另一种方式展示测试结果。我们可以轻松地使用 JMeter 配置不同类型的采样器,以及配置使用不同 HTTP 方法和动态测试的请求,其中请求依赖于另一个 API 的响应。一旦测试计划在 JMeter 中,我们可以利用 JMeter 命令行工具从多个数据中心运行它,以模拟地理分布的负载并汇总结果。

JMeter 提供的灵活性,以及其广泛的文档,使其成为最常用的性能测试工具。JMeter 还可以用于执行功能测试。

我们可以使用 Azure 负载测试服务来生成高负载,使用我们在本节中创建的 JMeter 测试。Azure 负载测试抽象化了执行 JMeter 脚本和负载测试应用程序所需的基础设施。Azure 负载测试收集基于 Azure 的应用程序的确切资源数据,以帮助您识别 Azure 应用程序组件中的性能瓶颈。

注意

在撰写本书时,Azure 负载测试处于预览阶段。有关负载测试的更多详细信息,请参阅 Azure 文档中的docs.microsoft.com/en-us/azure/load-testing/overview-what-is-azure-load-testing。建议使用预期负载的一倍半到两倍进行负载测试。在运行性能测试后,建议使用Application Insights来分析请求的服务器响应时间,API 在负载条件下的响应依赖性,以及更重要的是,测试进行过程中发生的任何故障。

小贴士

建议使用 Azure DevOps 管道运行自动化测试。使用文档了解如何将测试与 Azure DevOps 管道集成:

Selenium:docs.microsoft.com/en-us/azure/devops/pipelines/test/continuous-test-selenium?view=azure-devops

JMeter 测试:github.com/Azure-Samples/jmeter-aci-terraform

摘要

在本章中,我们探讨了软件开发的一个重要方面:测试。我们学习了不同类型的测试以及我们在 SDLC 中应该使用它们的阶段。

我们学习了单元测试的概念以及如何通过使用Moq框架模拟依赖项来关注特定的调用进行测试。我们还介绍了使用 Selenium 创建自动化功能测试,以在将电子商务应用程序发布到生产之前测试其功能。

最后,我们学习了 JMeter,这是进行负载测试最常用的工具。下一章将专注于在 Azure 中部署应用程序。

问题

  1. 正误判断?我们只有在应用程序开发完成后才开始考虑测试。

a. 正确

b. 错误

答案:b

  1. 以下哪一项是软件测试的一种类型?

a. 安全测试

b. 功能测试

c. 可访问性测试

d. 所有上述选项

答案:d

  1. 正误判断?单元测试的更高代码覆盖率百分比有助于缩短上市时间。

a. 正确

b. 错误

答案:a

第十六章:第十六章:在 Azure 中部署应用程序

部署是一系列我们执行的活动,以使软件应用程序可供使用。一般方法是从代码开始,然后构建、测试并将其部署到目标系统。根据应用程序类型和业务需求,你采取的代码部署方法可能会有所不同。它可能像将目标系统关闭,用新版本替换现有代码,然后重新启动系统那样简单;或者,它可能涉及其他更复杂的方法,如蓝绿部署,其中你将代码部署到一个与生产环境相同的预发布环境,运行测试,然后将流量重定向到预发布环境以使其达到生产状态。

现代软件开发采用敏捷和 DevOps 来缩短开发周期,并频繁且可靠地交付新功能、更新和错误,以向客户提供更多价值。为此,你需要一套工具来规划、协作、开发、测试、部署和监控。

在本章中,我们将学习 Azure DevOps 是什么,以及它提供的用于快速可靠交付的工具。

本章涵盖了以下主题:

  • 介绍 Azure DevOps

  • 理解 CI 管道

  • 理解 CD 管道

  • 部署 ASP.NET 6 应用程序

技术要求

对于本章,你需要具备 Azure、Visual Studio 2022、Git 的基本知识,以及一个具有贡献者角色的活跃 Azure 订阅。如果你没有,你可以在azure.microsoft.com/en-in/free注册一个免费账户。

本章的代码可以在以下位置找到:https://github.com/PacktPublishing/Enterprise-Application-Development-with-C-10-and-.NET-6-Second-Edition/tree/main/Chapter16。

介绍 Azure DevOps

要将产品理念付诸实践,无论团队规模大小,你都需要一种高效的方式来规划你的工作,在团队内部进行协作,并构建、测试和部署。Azure DevOps 可以帮助你解决这些挑战,并提供各种服务和工具以助你成功。Azure DevOps 服务可以通过网页或从流行的开发 IDE,如 Visual Studio、Visual Studio Code 和 Eclipse 访问。Azure DevOps 服务既可在云端使用,也可在本地使用 Azure DevOps Server。

Azure DevOps 提供以下服务:

  • 看板:提供一套工具,使用 Scrum 和 Kanban 方法来规划和工作、缺陷和问题的跟踪

  • 仓库:提供源代码管理,使用 Git 或团队基础版本控制TFVC)来管理你的代码

  • 管道:提供一系列服务以支持持续集成CI)和持续交付CD

  • 测试计划:提供一套测试管理工具,以推动应用程序的质量,并实现端到端的可追溯性

  • 工件:允许你从公共和私有源共享包,以及与 CI 和 CD 管道集成

除了这些服务之外,Azure DevOps 还帮助您管理团队的知识库,管理仪表板,使用小部件共享进度和趋势,并配置通知。它还允许您添加或开发自定义扩展,并与流行的第三方服务(如 Campfire、Slack 和 Trello)集成。

Azure DevOps 服务提供免费和付费订阅。要注册免费账户,请按照docs.microsoft.com/en-us/azure/devops/user-guide/sign-up-invite-teammates?view=azure-devops中概述的步骤操作。

以下是从一个示例项目中截取的主屏幕截图:

图 16.1 – Azure DevOps 主页

图 16.1 – Azure DevOps 主页

让我们详细了解 Azure DevOps 及其每个服务。

看板

看板帮助您为项目定义一个流程并跟踪工作。当您在 Azure DevOps 中创建一个新的项目时,您可以选择一个流程模板,如敏捷、基本、Scrum 或能力成熟度模型集成CMMI)流程。流程模板决定了您可以在项目中使用的工项类型和工作流程。工项帮助您跟踪工作,而工作流程帮助您跟踪工项的进度。以下图显示了工项的层次结构以及 Scrum 流程模板的工作流程:

图 16.2 – 工作项层次结构及 Scrum 流程的工作流程

图 16.2 – 工作项层次结构及 Scrum 流程的工作流程

要进一步自定义或定义您的工作流程和工项类型,您可以选择基于之前提到的流程模板创建自己的流程模板。

让我们更深入地了解工作项和工作流程。

工作项

工作项帮助您跟踪项目中的功能、需求和错误。您可以在层次结构中对需求进行分组。通常,我们从称为史诗的高级需求开始,它可以进一步分解为功能产品待办事项。产品待办事项是优先级较高、分配给团队成员并在冲刺中交付的可交付成果。任务是为待办事项和错误创建的,以跟踪对产品待办事项的缺陷。

协作功能允许您通过在工作项上讨论或提问来在团队内部进行沟通。您可以提及团队成员或链接另一个工作项,并随时查看所有操作或讨论的历史记录。您还可以选择关注工作项,以便在它更新时收到警报。

工作流程

工作流可以帮助您审查项目的进度和健康状况。例如,产品待办事项项以状态创建。一旦它被产品负责人审查和批准,它就被移动到已批准,然后它被优先排序并分配给冲刺中的团队成员,并移动到已承诺,当它完成时,它被移动到完成。工作流可以帮助您跟踪项目的健康状况。

您可以使用 Kanban 板查看所有工作项的状态,并使用拖放功能轻松地将工作项移动到不同的状态。以下截图展示了由不同状态的工作项组成的 Kanban 板:

图 16.3 – Kanban 看板

图 16.3 – Kanban 看板

注意

如果您创建自己的流程模板,您可以自定义工作项或创建新的工作项,并自定义或定义工作流以满足您的业务需求。

要了解有关流程模板及其差异的更多信息,您可以参考docs.microsoft.com/en-us/azure/devops/boards/get-started/what-is-azure-boards?view=azure-devops&tabs=scrum-process#work-item-types

接下来,让我们更深入地了解代码库。

代码库

代码库提供版本控制工具,您可以使用这些工具来管理您的代码。版本控制系统允许您跟踪团队对代码所做的更改。它为每个更改创建快照,您可以在任何时间审查它,并在需要时回滚到它。Azure DevOps 提供GitTFVC作为您的版本控制系统。

Git 是目前最广泛使用的版本控制系统,并且越来越成为版本控制系统的标准。Git 是一个分布式版本控制系统,其中包含版本控制系统的本地副本,您可以在本地查看历史记录或提交更改,即使您离线,一旦连接到网络,它将同步到服务器。然而,TFVC 是一个集中式版本控制系统,每个文件在开发机器上只有一个版本,历史记录保存在服务器上。有关 Git 的更多信息,您可以参考docs.microsoft.com/en-in/azure/devops/repos/git/?view=azure-devops,有关 TFVC,您可以参考docs.microsoft.com/en-in/azure/devops/repos/tfvc/?view=azure-devops

以下为代码库的关键服务:

  • mainmaster,您可以从它创建另一个分支。这样,您可以隔离您的更改以进行功能开发或错误修复。您可以创建任意数量的分支,与团队成员共享,提交您的更改,并安全地将它们合并回master

  • 分支策略帮助您在开发过程中保护分支。当您在分支上启用分支策略时,任何更改都必须通过提交请求进行,这样您就可以进行审查、提供反馈和批准更改。作为分支策略,您可以配置所需的最小批准者数量,检查链接的工作项和评论解决情况,并强制构建成功以完成提交请求。

以下屏幕截图展示了在分支上定义的策略:

图 16.4 – 分支策略

图 16.4 – 分支策略

在这里,创建了一个策略来验证在代码合并到分支之前构建。

  • 提交请求允许您审查代码、添加评论,并确保在代码合并到分支之前得到解决。根据配置的分支策略,您可以添加强制审查员来审查和批准更改。您可以将工作项关联到提交请求以启用更改的可追溯性。以下屏幕截图展示了示例提交请求:

图 16.5 – 提交请求

图 16.5 – 提交请求

提交请求有一个标题和描述,用户可以审查文件并比较它们与之前的版本,检查构建状态和链接的工作项,并进行批准。

接下来,让我们了解管道。

管道

管道允许您配置、构建、测试并将代码部署到任何目标系统。使用管道,您可以为代码的持续集成和持续部署启用一致性和质量交付。您可以使用针对使用流行语言(如.NET、Java、JavaScript、Node.js、PHP 和 C++)构建的许多应用程序类型的管道,并将它们部署到云或本地服务器。您可以使用 YAML 文件或基于 UI 的经典编辑器定义管道。

CI 自动化构建和测试您的项目以确保质量和一致性。CI 可以配置为按计划运行或当新代码合并到您的分支时运行,或者两者都运行。CI 生成由 CD 管道用于部署到目标系统的工件。

CD 使您能够自动将代码部署到目标系统并运行测试。CD 可以配置为按计划运行。

接下来,让我们更深入地了解测试计划。

测试计划

Azure DevOps 提供了一套工具来提高您项目中的质量。它提供基于浏览器的测试管理解决方案,具有所有手动和探索性测试所需的功能。它提供了在测试套件测试计划下组织测试用例的能力,您可以使用它们来跟踪功能或发布的质量。以下是对这些功能的解释:

  • 测试用例用于验证应用程序的各个部分。它们包含测试步骤,您可以使用它们来断言需求。您可以通过将其导入测试套件或测试计划来重用测试用例。

  • 测试套件是一组执行以验证功能或组件的测试用例。您可以创建静态测试套件、基于需求的套件和基于查询的套件。

  • 测试计划是一组测试套件或测试用例,用于跟踪每个发布迭代的每个迭代的质量。

接下来,让我们更深入地了解工件。

工件

工件使得在团队之间共享代码变得容易。您可以从公共和私有源轻松创建和共享 Maven、npm 或 NuGet 包源,它们在 CI 和 CD 流水线中易于使用。工件基于标准打包格式,可以轻松集成到开发 IDE 中,如 Visual Studio,作为包源。

Azure DevOps 使团队内部能够协调和协作,并帮助您以高质量一致地交付项目。使用 CI 和 CD,您可以自动化代码的构建和部署。

在下一节中,让我们了解 CI 流水线。

理解 CI 流水线

CI 是一种实践,您在其中自动化代码的构建和测试。在 Azure DevOps 中,您可以创建流水线并将它们配置为在代码合并到您的目标(master/main)分支时自动触发,按计划运行,或两者兼而有之。您可以选择使用 YAML 文件或基于 UI 的经典编辑器创建流水线。

下图说明了从开发者的机器到云的代码典型流程:

Figure 16.6 – Typical flow of code

img/Figure_16.6_B18507.jpg

图 16.6 – 代码的典型流程

从前面的截图,我们可以看到以下内容:

  1. 开发者使用 Visual Studio、Visual Studio Code 或 Visual Studio for Mac 等开发工具来编写代码。

  2. 代码更改被移动到仓库中。

  3. CI 流水线被触发,验证构建,运行测试,并发布工件。CD 流水线被触发,并将代码部署到目标系统。

  4. 开发者使用 Application Insights 来持续监控和改进应用程序。

    注意

    YAML(代表YAML Ain't Markup Language)是定义流水线的首选方式。它提供了与经典编辑器相同的功能。您可以将这些文件检查到仓库中,并像管理任何其他源文件一样管理它们。有关更多详细信息,您可以参考docs.microsoft.com/en-us/azure/devops/pipelines/yaml-schema?view=azure-devops&tabs=schema%2Cparameter-schema

让我们了解流水线的核心组件和流程。

理解流水线的流程和组件

流水线是一组定义,用于执行构建和测试代码的操作。流水线定义包含触发器变量阶段作业步骤任务。当我们运行流水线时,它会执行流水线定义中定义的任务。以下各节中,我们将了解这些组件的每个部分。

触发器

流水线的 trigger 部分。

在以下代码片段中,流水线被配置为在代码推送到 master 分支或 releases 文件夹下的任何分支时触发。可选地,我们还可以在流水线中指定路径过滤器,以便仅在代码更改满足路径条件时触发:

trigger:
  branches:
    include:
    - master
    - releases/*
  paths:
    include:
    - web
    exclude:
    - docs/README.md

您还可以配置流水线根据计划自动运行。在以下代码片段中,流水线被配置为每天上午 9:30 运行。计划使用 cron 表达式指定,并且您可以指定多个计划。如果将 always 设置为 true,即使代码没有更改,也会触发构建:

schedules:
- cron: "30 9 * * *"
  displayName: Daily build
  branches:
    include:
    - master
  always: false

变量

变量可以通过赋予值并在流水线中的多个位置重用来定义。您可以在根目录、阶段或作业中定义变量。在流水线中可以使用三种不同类型的变量 – 用户定义的变量、系统变量和环境变量:

variables:
 buildConfiguration: 'Release'
. . . .
. . . .
- task: DotNetCoreCLI@2
  displayName: Publish
  inputs:
   command: 'publish'
   publishWebProjects: false
   projects: '**/*HelloWorld.csproj'
   arguments: '--configuration $(BuildConfiguration) --output $(build.artifactstagingdirectory)/web'

在前面的代码片段中,buildConfiguration 变量被定义为 Release 值,并在任务的 arguments 部分中使用。build.artifactstagingdirectory 系统变量包含工件目录的位置。

阶段

阶段是一组默认按顺序运行的作业。您也可以指定前一个阶段执行状态的条件,或添加审批检查以控制阶段何时运行。

以下是一个具有多个阶段的示例流水线定义:

stages:
- stage: Build
  jobs:
  - job: build
    steps:
    - script: echo building code
- stage: Test
  jobs:
  - job: windows
    steps:
    - script: echo running tests on windows
  - job: linux
    steps:
    - script: echo running tests on Linux
- stage: Deploy
  dependsOn: Test
  jobs:
  - job: deploy
    steps:
    - script: echo deploying code

在前面的示例中,配置了三个阶段,它们按顺序依次运行。Test 阶段包含两个可以并行运行的作业,而 Deploy 阶段依赖于 Test 阶段。

以下是对前面示例的流水线执行摘要的截图,您可以通过点击每个阶段来查看日志:

图 16.7 – 流水线运行摘要

图 16.7 – 流水线运行摘要

作业

testNull 变量:

variables:
- name: testNull
  value: ''
jobs:
  - job: BuildJob
    steps:
    - script: echo Building!
    condition: eq('${{ variables.testNull }}', '')

在前面的代码中,作业被配置为仅在 testNull 为空时运行。

步骤和任务

步骤是您流水线中的任务组。这些可能是构建您的代码、运行测试或发布工件。每个步骤都在代理上执行,并可以访问流水线工作区。

DotNetCoreCLI@2 任务构建 csproj

- task: DotNetCoreCLI@2
  displayName: build
  inputs:
   command: 'build'
   projects: '**/*.csproj'
   arguments: '--configuration $(BuildConfiguration)'

要了解更多关于流水线的知识,您可以参考 docs.microsoft.com/en-in/azure/devops/pipelines/create-first-pipeline?view=azure-devops&tabs=java%2Ctfs-2018-2%2Cbrowser

在下一节中,让我们更深入地了解 CD 流水线。

理解 CD 流水线

CD 是一个自动化将代码部署到目标环境的流程。CD 管道使用 CI 管道产生的工件并部署到一个或多个环境。像 CI 管道一样,我们可以使用 YAML 文件或经典编辑器来定义 CD 管道。您可以为前一个阶段的执行状态指定条件或添加批准检查以部署,这在生产部署中是一个非常常见的场景。

您还可以配置运行自动化的 UI 测试以在部署后进行合理性检查。根据合理性检查的结果,您可以配置它自动将代码提升到更高环境。

在任何时间点,如果某个阶段的部署失败,我们可以从之前的版本重新部署代码。根据项目设置下配置的保留策略,Azure DevOps 会保留构建工件,以便可以轻松地在任何时间部署任何版本的代码。如果您在部署后发现应用程序有任何问题,您可以轻松地找到最后一个已知的好版本,并部署代码以最小化业务影响。

让我们在下一节中更深入地了解这个内容。

持续部署与 CD 对比

持续部署是在将新代码合并到您的存储库时自动部署到目标系统,而 CD 使应用程序在任何时间都可以部署到目标系统。Azure DevOps 提供多阶段管道;您可以通过配置具有阶段的管道来实现这一点。

持续部署通常配置在较低的环境,如开发或测试,而对于较高环境,如预发布或生产,您应该考虑 CD,以便您可以在较低环境中验证更改并批准将代码部署到较高环境。

以下屏幕截图展示了多阶段管道,其中自动构建和发布到开发环境,并在测试阶段等待批准。在这种情况下,将代码发布到测试需要批准:

图 16.8 – 待批准的多阶段管道

图 16.8 – 待批准的多阶段管道

要了解如何配置 Azure 管道中的批准和检查的更多信息,您可以参考docs.microsoft.com/en-in/azure/devops/pipelines/process/approvals?view=azure-devops&tabs=check-pass

要查看管道运行的详细信息,您可以点击任何阶段来查看该运行的日志。日志帮助我们排查部署失败的问题。以下屏幕截图展示了管道运行的日志:

图 16.9 – 管道执行细节

图 16.9 – 管道执行细节

在前面的屏幕截图中,您会注意到您可以查看管道中配置的阶段、作业和任务,并且您可以点击任务来查看日志。

在下一节中,我们将学习如何创建一个构建和部署应用程序的管道。

部署 ASP.NET 6 应用程序

到目前为止,在本章中,我们已经探讨了 Azure DevOps,了解了它提供的工具和服务,然后学习了 CI 和 CD 管道。在本节中,我们将学习如何创建 Azure DevOps 项目,克隆存储库,将代码推送到存储库,并创建 CI 和 CD 管道以将代码部署到 Azure App Service。

注意

在部署示例应用程序之前,请务必检查 技术要求 部分,以确保您已设置好所有内容。

您可以按照以下步骤部署 ASP.NET 6 应用程序到 Azure:

  1. 登录到您的 Azure DevOps 账户。如果您还没有 Azure DevOps 账户,可以按照docs.microsoft.com/en-us/azure/devops/user-guide/sign-up-invite-teammates?view=azure-devops中给出的步骤创建一个;您也可以按照以下步骤进行操作:

  2. 在 Azure DevOps 的主页上,为您的项目提供一个名称,例如 HelloWorld,然后对于 版本控制,选择 Git,对于 工作项流程,您可以选择 敏捷

图 16.10 – 新的 Azure DevOps 项目

图 16.10_B18507.jpg

图 16.10 – 新的 Azure DevOps 项目

  1. 现在,让我们创建一个服务连接,我们将在管道中使用它来连接和部署代码到 Azure App Service。

从左侧菜单导航到 项目设置 | 服务连接 | 创建服务连接 | Azure 资源管理器 | 服务主体(自动)

图 16.11 – 新的服务主体

图 16.11 – 新的服务主体

图 16.11 – 新的服务主体

服务主体使管道能够连接到您的 Azure 订阅以管理资源或将您的代码部署到 Azure 服务。

  1. 选择一个订阅并为创建服务连接的连接提供一个名称。Azure DevOps 使用此服务连接来连接 Azure 资源并部署代码:

图 16.12 – 新的服务主体

图 16.12_B18507.jpg

图 16.12 – 新的服务主体

  1. 一旦创建项目,您应该会看到一个类似于以下页面的页面。从左侧菜单中,在 仓库 下选择 分支

图 16.13 – Azure DevOps 主页 | 仓库屏幕

图 16.13_B18507.jpg

图 16.13 – Azure DevOps 主页 | 仓库屏幕

  1. 复制以下链接,我们将使用它来克隆存储库到本地计算机:

图 16.14 – 克隆仓库

图 16.14_B18507.jpg

图 16.14 – 克隆仓库

  1. 要将存储库克隆到您的系统,请打开命令提示符并导航到您想要克隆代码的文件夹,然后运行以下命令。

<organization> 替换为您的 Azure DevOps 组织:

git clone https://<organization>@dev.azure.com/<organization>/HelloWorld/_git/HelloWorld
  1. 由于我们的存储库是新的且为空,我们需要向其中添加代码。以下 dotnet CLI 命令可以帮助我们创建一个 ASP.NET 6 应用程序和一个 xUnit 项目,创建一个解决方案文件,并将一个 Web 和测试项目添加到其中。按顺序运行每个命令以继续:

    dotnet new mvc --auth Individual -o HelloWorld
    dotnet new xunit -o HelloWorld.Tests
    dotnet new sln
    dotnet sln add HelloWorld/HelloWorld.csproj
    dotnet sln add HelloWorld.Tests/HelloWorld.Tests.csproj
    
  2. 运行以下命令来构建代码并运行测试以验证是否一切正常:

    dotnet build
    dotnet test
    

现在我们已经测试了代码,接下来让我们看看如何为使用代码创建 CI/CD 管道。

创建 CI/CD 管道

在运行测试后,我们需要查看 CI/CD 管道是如何创建的。执行以下步骤:

  1. 接下来,我们需要创建一个 CI/CD 的管道。你可以使用在 github.com/PacktPublishing/Enterprise-Application-Development-with-C-10-and-.NET-6-Second-Edition/blob/master/Chapter16/Pipelines/HelloWorld/azure-ci-pipeline.yml 可用的代码,并将其保存到存储库的根目录中。让我们称它为 azure-ci-pipeline.yml

此管道被配置为在新代码合并到 main 分支时触发。

  1. 它被配置为有三个阶段——构建、开发和测试——其中构建阶段被配置为构建代码、运行单元测试和发布工件。开发和测试阶段被配置为将代码部署到 Azure App Service。

  2. 依赖关系在开发和测试阶段进行配置,其中开发阶段依赖于构建,测试依赖于开发阶段。

让我们检查这个 YAML 文件的一些重要部分。

以下片段包含一个定义变量的部分:

trigger:
- main
variables:
  BuildConfiguration: 'Release'
  buildPlatform: 'Any CPU'
  solution: '**/*.sln'
  azureSubscription: 'HelloWorld-Con' # replace this
  # with your service connection name to connect Azure 
  #subscription
  devAppServiceName: 'webSitejtrb7psidvozs' # replace 
  #this with your app service name
  testAppServiceName: 'webSitejtrb8psidvozs' # replace 
  #this with your app service name

你会注意到在 YAML 文件中声明了三个变量。在保存文件之前,请提供适当的值:

  • azureSubscription:提供你的服务连接名称。

  • devAppServiceName:提供开发部署的应用服务名称。

  • testAppServiceName:提供测试部署的应用服务名称。

要构建代码,我们使用 DotNetCoreCLI@2 任务并配置 commandprojects 和可选的 arguments

- task: DotNetCoreCLI@2
  displayName: Build
  inputs:
    command: 'build'
    projects: '**/*.csproj'

command 被配置为 build,路径设置为 csproj 以构建 projects 中的代码。此任务运行 .NET CLI 命令,因此我们也可以使用其他 .NET CLI 命令配置此任务,例如 runtestpublishrestore 等。

  1. 要发布代码,使用 PublishBuildArtifacts@1 任务。它配置了 PathtoPublishArtifactNamepublishLocation

    - task: PublishBuildArtifacts@1
      inputs:
        PathtoPublish: 
          '$(Build.ArtifactStagingDirectory)/web'
        ArtifactName: 'drop'
        publishLocation: 'Container'
    

PathtoPublish 被配置为工件目录的位置,其中包含可用的构建工件,ArtifactNamedroppublishLocationContainer 以将工件发布到 Azure Pipelines。或者,我们也可以将 publishLocation 配置为 FileShare

以下代码片段执行所需的操作以部署代码:

- download: current
  artifact: drop
- task: AzureWebApp@1
  displayName: 'Azure App Service Deploy: website'
  inputs:
    azureSubscription: '$(azureSubscription)'
    appType: 'webApp'
    appName: '$(devAppServiceName)'
    package: '$(Pipeline.Workspace)/drop/*.zip'
    deploymentMethod: 'auto'

在部署作业中,第一步是下载工件,并且工件的名称应该与在 PublishBuildArtifacts@1 任务中配置的名称相同,在这种情况下,是 drop

AzureWebApp@1 任务用于将工件部署到 Azure App Service。所需的参数包括 azureSubscriptionappTypeappNamepackagedeploymentMethod(作为 auto)。

现在工件已经准备好了,我们可以看到代码是如何被提交的,以及代码更改是如何被推送到远程仓库的。

推送代码

现在代码和流水线都已经准备好了,下一步是将这些更改提交并推送到 Azure DevOps 仓库:

  1. 在命令提示符中,运行以下命令以在本地提交更改并将它们推送到 Azure DevOps:

    git add .
    git commit -m "Initial Commit"
    git push
    
  2. 在 Azure DevOps 中,导航到 流水线 并点击 创建流水线 来创建一个新的流水线:

![图 16.15 – 新建流水线图片

图 16.15 – 新建流水线

  1. 要配置流水线,我们需要执行四个步骤。选择你的仓库所在的云服务,选择仓库,配置流水线,然后保存。对于此实现,选择 Azure Repos Git 以继续,然后选择你的仓库:

![图 16.16 – 源代码选择图片

图 16.16 – 源代码选择

  1. 配置 选项卡中,选择 现有 Azure 流水线 YAML 文件 以继续:

![图 16.17 – 配置流水线图片

图 16.17 – 配置流水线

  1. 选择我们在仓库中保存的流水线文件,然后点击 继续,然后点击 运行 来触发流水线:

![图 16.18 – YAML 文件选择图片

图 16.18 – YAML 文件选择

  1. 这将打开一个页面,我们可以看到流水线的状态。以下截图是从流水线运行中获取的。你会注意到已经创建了三个阶段:

![图 16.19 – 流水线运行摘要图片

图 16.19 – 流水线运行摘要

在构建阶段,你会注意到有两个正在进行的作业。

开发阶段和测试阶段正在等待构建完成。

可选地,你可以在 Azure App Service 上启用部署槽位,并配置流水线以将代码部署到非生产部署槽位,例如,预生产。一旦你检查了已部署代码的合理性,你可以将 生产 槽位与 预生产 槽位交换。交换是瞬时的,并且没有任何停机时间,你可以将最新的更改提供给用户。如果你发现任何问题,你可以交换回先前的槽位以回到最后一个已知的好版本。有关更多信息,你可以参考 docs.microsoft.com/en-us/azure/app-service/deploy-staging-slots

  1. 流水线执行完成后,从左侧菜单导航到 流水线 下的 环境。你会注意到已经创建了开发和测试环境:

![图 16.20 – 环境图片

图 16.20 – 环境

  1. 点击 测试 阶段,在更多操作选择中,选择 审批和检查 以继续:

图 16.21 – 批准和检查

图 16.21 – 批准和检查

  1. 您将找到许多可供选择的功能,例如批准分支控制工作日等:

图 16.22 – 添加检查

图 16.22 – 添加检查

  1. 选择批准以继续,它将打开一个对话框,我们可以选择用户/组作为审批者。提供必要的详细信息并点击创建

图 16.23 – 添加批准

图 16.23 – 添加批准

  1. 重新运行管道以测试更改。您将注意到管道在测试阶段等待执行:

图 16.24 – 具有挂起批准的多阶段管道

图 16.24 – 具有挂起批准的多阶段管道

  1. 点击审查,这将打开一个对话框以批准或拒绝。点击批准以完成部署:

图 16.25 – 批准或拒绝

图 16.25 – 批准或拒绝

总结来说,在本节中,我们从在 Azure DevOps 中创建新项目开始,然后克隆仓库到本地系统,使用.NET CLI 创建了一个简单的 ASP.NET Core 应用程序,创建了一个 YAML 管道来构建、测试和发布工件并将它们部署到 Azure App Service,并将代码提交并推送到仓库。接下来,我们通过在仓库中选择 YAML 文件创建了一个新的 CI/CD 管道,并触发了管道。在环境中,我们配置了批准检查并触发了管道以查看其工作情况。

摘要

在本章中,我们了解了 Azure DevOps 是什么,以及它提供的工具和服务。我们了解了服务如看板、代码库、管道、测试计划和工件如何帮助我们高效地执行项目。

接下来,我们探讨了 CI 和 CD 管道及其核心组件。我们还学习了它们如何帮助我们自动化代码的构建和部署。通过学习如何创建 ASP.NET 6 应用程序以及使用 CI 和 CD 管道构建和部署到 Azure App Service 的管道,我们结束了本章。

我希望这本书能帮助您提高.NET 技能,并激励您尝试构建更多其应用。您可以通过参考章节的笔记和进一步阅读部分来探索更多主题。

对于企业应用,我们还涵盖了典型电子商务应用的快乐路径场景,并且可以根据在第一章中定义的需求进一步扩展,即设计和架构企业应用。有示例扩展端到端流程的认证/授权,使用 API 网关进行服务间通信和认证,并实现通知服务,以便您了解更多。

我们祝愿您在 C#和.NET 项目中一切顺利。祝您学习愉快!

问题

  1. 持续部署与 CD 有何不同?

a. CD 与数据库协同工作,而持续部署支持 Web 应用程序。

b. 持续部署每次都发布到环境,而 CD 在任何一次都发布到环境。

c. 持续部署需要云服务,而 CD 与本地服务器协同工作。

d. 持续部署在任何一次都发布到环境,而 CD 每次都发布到环境。

答案:b

  1. 持续部署方法的特点是什么?(选择两个)

a. 专注于缩短周期时间

b. 少量复杂发布

c. 基于资源的流程管理

d. 自我管理和响应性团队

答案:a 和 d

  1. 哪个组件提供了对提交的应用代码更改质量的首次反馈?

a. 自动部署

b. 自动配置

c. 自动构建

d. 自动测试

答案:c

进一步阅读

想了解更多关于 Azure DevOps 的信息,您可以参考docs.microsoft.com/en-in/azure/devops/user-guide/services?view=azure-devops,以及关于管道,您可以参考docs.microsoft.com/en-in/azure/devops/pipelines/get-started/pipelines-get-started?view=azure-devops.

Packt.com

订阅我们的在线数字图书馆,全面访问超过 7,000 本书和视频,以及行业领先的工具,帮助您规划个人发展并推进您的职业生涯。如需更多信息,请访问我们的网站。

第十七章:为什么订阅?

  • 通过来自 4,000 多位行业专业人士的实用电子书和视频,节省更多时间学习,更多时间编码

  • 通过为您量身定制的技能计划提高您的学习效果

  • 每月免费获得一本电子书或视频

  • 完全可搜索,便于轻松访问关键信息

  • 复制粘贴、打印和收藏内容

你知道 Packt 提供每本书的电子书版本,包括 PDF 和 ePub 文件吗?您可以在 packt.com 升级到电子书版本,并且作为印刷书客户,您有权获得电子书副本的折扣。如需了解更多详情,请联系我们 customercare@packtpub.com。

www.packt.com,您还可以阅读一系列免费的技术文章,注册各种免费通讯,并享受 Packt 书籍和电子书的独家折扣和优惠。

您可能还会喜欢的其他书籍

如果您喜欢这本书,您可能还会对 Packt 的这些其他书籍感兴趣:

使用 Windows App SDK 和 WinUI 现代化您的 Windows 应用程序

Matteo Pagani, Marc Plogas

ISBN: 9781803235660

  • 理解 Windows App SDK 和 WinUI 的关键概念

  • 通过创建新应用程序或增强现有应用程序来集成新功能

  • 通过采用 Fluent Design 和新的交互模式,如触摸和手写笔,改进您的应用程序 UI

  • 使用通知更有效地与您的用户互动

  • 使用 Windows App SDK 将您的应用程序与 Windows 生态系统集成

  • 使用 WinML 通过人工智能提升您的任务

  • 使用 MSIX 在 LOB 和面向客户的场景中部署您的应用程序

使用 C# 10 和 .NET 6 的软件架构 - 第三版

Gabriel Baptista, Francesco Abbruzzese

ISBN: 9781803235257

  • 使用经过验证的技术克服现实世界的架构挑战

  • 应用分层架构等架构方法

  • 利用容器等工具有效地管理微服务

  • 快速掌握 Azure 功能,以提供全球解决方案

  • 使用 C# 10 编程和维护 Azure Functions

  • 理解何时最好使用测试驱动开发 (TDD)

  • 在现代架构中使用 ASP.NET Core 实现微服务

  • 用人工智能丰富您的应用程序

  • 获取 DevOps 原则的最佳实践,以启用 CI/CD 环境

Packt 正在寻找像您这样的作者

如果您有兴趣成为 Packt 的作者,请访问 authors.packtpub.com 并今天申请。我们已与成千上万的开发者和技术专业人士合作,就像您一样,帮助他们将见解分享给全球科技社区。您可以提交一般申请,申请我们正在招募作者的特定热门话题,或者提交您自己的想法。

分享您的想法

现在您已经完成了《使用 C# 10 和 .NET 6 开发企业级应用程序》,我们非常想听听您的想法!如果您在亚马逊购买了这本书,请点击此处直接跳转到该书的亚马逊评论页面,分享您的反馈或在该购买网站上留下评论。

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

您可能还会喜欢的其他书籍

您可能还会喜欢的其他书籍

posted @ 2025-10-22 10:33  绝不原创的飞龙  阅读(1)  评论(0)    收藏  举报