原文:
zh.annas-archive.org/md5/b71782931b1ec965a99239b2cd142e50译者:飞龙
前言
.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 是以下五个原则的缩写:
- 单一职责原则(SRP):实体或软件模块应该只有一个职责。你应该避免将多个职责赋予一个实体。
图 1.1 – SRP
- 开闭原则(OCP):实体应该设计成这样,即它们对扩展是开放的,但对修改是封闭的。这意味着可以避免现有行为的回归测试;只需测试扩展即可。
图 1.2 – OCP
- 里氏替换原则(LSP):父类或基类实例应该可以用其派生类或子类型实例替换,而不会改变程序的合理性。
图 1.3 – LSP
- 接口隔离原则(ISP):而不是一个通用的一个大接口,你应该计划多个、场景特定的接口,以实现更好的解耦和变更管理:
图 1.4 – ISP
- 依赖倒置原则(DIP):你应该避免对具体实现有任何直接依赖。高层模块和低层模块不应直接相互依赖。相反,两者应尽可能依赖于抽象。抽象不应依赖于细节,细节应依赖于抽象。
图 1.5 – DIP
不要重复自己(DRY)
使用 DRY 原则,系统应该设计成这样,即一个功能或模式的实现不应该在多个地方重复。这会导致维护开销,因为需求的变化会导致多个地方需要修改。如果你不小心在一个地方没有进行必要的更新,系统的行为将变得不一致。相反,该功能应该被封装成一个包,并在所有地方重用。在数据库的情况下,你应该考虑使用数据规范化来减少冗余。
图 1.6 – DRY
这种策略有助于减少冗余并促进重用。这个原则也有助于组织文化,鼓励更多的协作。
简单就是最好(KISS)
使用 KISS 原则,系统应该尽可能简单地进行设计,避免复杂的设计、算法、新技术以及更多。你应该专注于利用正确的面向对象概念和重用经过验证的模式和原则。只有当它是必要的并且增加了实现的价值时,才包括新或不简单的事物。
当你保持简单时,你将能够更好地做到以下事情:
-
在设计/开发过程中避免错误。
-
保持列车运行(总有一个团队负责维护系统,即使他们最初并没有开发系统)。
-
阅读并理解你的系统代码(你的系统代码需要对新来的人或未来将使用它的人可理解)。
-
做得更好,减少错误的风险进行变更管理。
通过这些,我们完成了对常见设计原则的入门介绍;我们学习了 SOLID、DRY 和 KISS。在下一节中,我们将通过现实世界的例子来探讨一些常见的设计模式,以帮助您理解原则和模式之间的区别以及何时使用哪种模式——这是良好设计和架构所必需的技能。
设计模式
在遵循面向对象(OOP)范式的设计原则时,你可能会看到相同的结构和模式反复出现。这些重复的结构和技术是解决常见问题的既定解决方案,被称为设计模式。经过验证的设计模式易于重用、实现、更改和测试。著名的书籍《设计模式:可重用面向对象软件的元素》,包含了被称为四人帮(GOF)的设计模式,被认为是模式的圣经。
我们可以将 GOF 模式分类如下:
-
创造:有助于创建对象
-
结构:有助于处理对象的组成
-
行为:有助于定义对象之间的交互和分配责任
让我们通过一些现实生活中的例子来看看这些模式。
创建型设计模式
让我们来看看以下表格中的一些创建型设计模式及其相关示例:
表 1.1 – 创建型设计模式
结构设计模式
以下表格包含了一些结构设计模式的示例:
表 1.2 – 结构设计模式
行为设计模式
以下表格包含了一些行为设计模式的示例:
表 1.3 – 行为设计模式
有时候,你可能会被所有这些模式在表格中的内容所淹没。但事实上,任何设计只要不违反基本原理,都是好的设计。我们可以使用的一个经验法则是回归基本,在设计上,原则是基础。
图 1.7 – 模式与原则
通过这一点,我们完成了对常见设计原则和模式的入门介绍。到现在,你应该对不同的原则和模式、它们的使用场景以及构建优秀解决方案所需的内容有了很好的理解。现在,让我们花些时间来了解常见的企业架构。
理解常见的企业架构
在设计企业应用程序时,通常遵循一些原则和架构。首先,任何架构的目标都是以尽可能低的成本支持业务需求(成本是时间和资源)。企业希望软件能够使其业务更高效,而不是成为瓶颈。在当今世界,可用性、可靠性和性能是任何系统的三个关键绩效指标。
在本节中,首先,我们将探讨单体架构的问题,然后我们将看到如何通过使用广泛采用和经过验证的企业应用程序开发架构来避免这些问题。
考虑一个经典的单体电子商务网站应用,如下面的图所示,所有业务提供者和功能都在一个应用程序中,数据存储在传统的 SQL 数据库中:
图 1.8 – 单体应用
单体架构在 15-20 年前被广泛采用,但随着系统增长和业务需求随时间扩展,软件工程团队出现了许多问题。让我们看看这种方法的常见问题。
单体应用的常见问题
让我们来看看扩展性问题:
-
在单体应用中,横向扩展的唯一方式是通过向系统中添加更多计算资源。这导致更高的运营成本和未优化的资源利用率。有时,由于资源需求冲突,扩展变得不可能。
-
由于所有功能大多使用单一存储,存在锁导致高延迟的可能性,同时也会存在单一存储实例可扩展的物理限制。
这里列出了一些与可用性、可靠性和性能相关的问题:
-
任何系统中的更改都要求重新部署所有组件,导致停机时间和低可用性。
-
任何非持久状态,如存储在 Web 应用程序中的会话,在每次部署后都会丢失。这将导致所有由用户触发的流程被放弃。
-
任何模块中的错误,如内存泄漏或安全漏洞,都会使所有模块变得脆弱,并有可能影响整个系统。
-
由于模块内部高度耦合和资源共享的特性,资源总会存在未被优化的使用,导致系统中的高延迟。
最后,让我们看看这对业务和工程团队的影响:
-
变化的影响难以量化,需要广泛的测试。因此,它减缓了向生产交付的速度。即使是微小的变化,也需要重新部署整个系统。
-
在一个高度耦合的系统中,跨团队交付任何功能都会有物理限制。
-
新场景,如移动应用、聊天机器人和分析引擎,需要更多的努力,因为没有独立的可重用组件或服务。
-
持续部署几乎是不可能的。
让我们通过采用一些经过验证的原则/架构来尝试解决这些常见问题。
关注点分离/单一职责架构
软件应根据其执行的工作类型划分为组件或模块,其中每个模块或组件都拥有整个软件的单一职责。组件之间的交互通过接口或消息系统进行。让我们看看 n 层架构和微服务架构以及如何处理关注点分离。
N 层架构
N 层架构将系统的应用划分为三个(或 n)层:
-
表示(称为 UX 层、UI 层或工作表面)
-
业务(称为业务规则层或服务层)
-
数据(称为数据存储和访问层)
图 1.9 – N 层架构
这些层可以分别拥有/管理/部署。例如,多个表示层,如网页、移动和机器人层,可以利用相同的企业和数据层。
微服务架构
微服务架构由小型、松散耦合、独立和自治的服务组成。让我们看看它们的优点:
-
服务可以独立部署和扩展。一个服务的问题只会产生局部影响,只需部署受影响的服务即可修复。没有共享技术或框架的强制要求。
-
服务通过定义良好的 API 或如 Azure 服务总线之类的消息系统相互通信。
图 1.10 – 微服务架构
如前图所示,一个服务可以由独立的团队拥有并拥有自己的周期。服务负责管理自己的数据存储。对于需要较低延迟的场景,可以通过引入缓存或高性能 NoSQL 存储进行优化。
无状态服务架构
服务不应有任何状态。状态和数据应独立于服务进行管理,即通过外部数据存储,如分布式缓存或数据库。通过将状态委托给外部,服务将拥有资源以高可靠性服务更多请求。以下图示展示了左侧的状态服务示例。在这里,状态通过内存缓存或会话提供者在每个服务中维护,而右侧所示的无状态服务则在外部管理状态和数据。
图 1.11 – 有状态(左侧)与无状态(右侧)
不应启用会话亲和性,因为它会导致会话粘性问题,并阻止您获得负载均衡、可扩展性和流量分布的好处。
事件驱动架构
事件驱动架构的主要特点如下:
-
在事件驱动架构中,模块之间的通信,通常称为发布者-订阅者通信,主要是异步的,并通过事件实现。生产者和消费者彼此完全解耦。事件的结构是他们之间交换的唯一合约。
-
同一个事件可以有多个消费者负责它们特定的操作;理想情况下,它们甚至不会意识到彼此的存在。生产者可以持续推送事件,无需担心消费者的可用性。
-
发布者通过消息基础设施,如队列或服务总线,发布事件。一旦事件被发布,消息基础设施就负责将事件发送给合格的订阅者。
图 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 的需求待办事项
应用程序的技术需求
在了解了业务需求后,现在让我们来探讨技术需求:
-
电子商务应用程序应具有高可用性,即在任何 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 – 电子商务应用的三层架构
关注点分离/单一职责原则在每个层级都得到了妥善处理。包含用户界面的表示层与包含业务逻辑的服务层分离。这又与包含数据存储的数据访问层分离。
高级组件对它们所消耗的低级组件不知情。数据访问层对消耗它的服务不知情,而服务对消耗它们的 UX 层也不知情。
每个服务都是根据其应执行的业务逻辑和功能进行分离的。
封装在架构层面得到了妥善处理,并且在开发过程中也应该得到妥善处理。架构中的每个组件将通过定义良好的接口和合同与其他组件进行交互。我们应该能够替换图中的任何组件,而无需担心其内部实现以及它是否遵守合同。
这里的松散耦合架构也有助于加快开发速度和更快地将产品部署到市场。多个团队可以并行工作,独立地开发各自的组件。他们在开始时共享集成测试的合同和进度表,一旦内部实现和单元测试完成,他们就可以开始集成测试。
参考以下图:
图 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 – 电子商务应用程序的解决方案结构
在这里,我们通过为 UX、服务、数据、核心和测试分别设置单独的文件夹结构和项目来实现关注点的分离。
摘要
在本章中,我们学习了常见的原则,如 SOLID、DRY 和 KISS。我们还通过实际案例研究了各种设计模式。然后,我们探讨了不同的企业架构,确定了我们将要构建的电子商务应用程序的需求,并将所学知识应用于我们的电子商务应用程序的架构。现在,当你设计任何应用程序时,你可以应用在这里学到的知识。
在下一章中,我们将学习.NET 6 Core 和标准。
问题
- 什么是 LSP?
a. 基类实例应该可以替换为派生类实例。
b. 派生类实例应该可以替换为基类实例。
c. 为通用泛型设计,使其能够与任何数据类型一起工作。
答案:a
- 什么是 SRP?
a. 而不是使用一个通用的庞大接口,应该计划多个针对特定场景的接口,以实现更好的解耦和变更管理。
b. 你应该避免直接依赖具体实现。相反,你应该尽可能依赖抽象。
c. 实体应该只有一个责任。你应该避免赋予一个实体多个责任。
d. 实体应该以这种方式设计,以便它们易于扩展但难以修改。
答案:c
- 什么是 OCP?
a. 实体应该易于修改但难以扩展。
b. 实体应该易于扩展但难以修改。
c. 实体应该易于组合但难以扩展。
d. 实体应该易于抽象但难以继承。
答案:b
- 哪个模式用于使两个不兼容的接口协同工作?
a. 代理模式
b. 桥接模式
c. 迭代器模式
d. 适配器模式
答案:d
- 哪个原则确保服务可以独立部署和扩展,并且一个服务的问题将产生局部影响,这可以通过仅重新部署受影响的服务来解决?
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 Framework(EF)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 的可视化
.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.String和System.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。
-
在 Windows 机器上,从
visualstudio.microsoft.com下载并安装 Visual Studio 17.0 或更高版本。 -
在安装选项中,从 工作负载,您可以选择 ASP.NET 和 Web 用于 Web/API 应用程序、Azure 开发、使用 .NET 进行 iOS、Android、Windows 的移动开发以及用于 Windows 应用程序的 .NET 桌面开发,如下面的截图所示:
图 2.2 – Visual Studio 安装,工作负载选择
-
确认选择并继续完成安装。这将安装 Visual Studio 和 .NET 6 SDK 到您的机器上。
注意
Azure 开发工作负载包括用于开发和支持针对 Azure 服务的应用程序的 SDK 和工具。它包括容器开发、Azure 资源管理器、Azure 云服务、Service Fabric、Azure 数据湖和流分析、快照调试器等工具。
或者,您也可以执行以下步骤来设置它:
-
从
dotnet.microsoft.com/download/dotnet/6.0下载并安装 .NET 6 SDK for Windows、macOS 和 Linux。.NET Core 支持并行执行,因此我们可以在开发机上安装多个版本的 .NET Core SDK。 -
从命令提示符运行
dotnet --version命令以验证安装的版本,如下面的截图所示:
图 2.3 – dotnet 命令的命令行输出
- 可选地,您可以从
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 命令
让我们创建一个简单的控制台应用程序,并使用.NET CLI 运行它:
注意
要执行以下步骤,作为先决条件,您应该在您的机器上安装.NET SDK。您可以从 https://dotnet.microsoft.com/download/dotnet/6.0 下载并安装它。
-
在命令提示符中,运行以下命令以创建一个名为
HelloWorld的项目控制台应用程序:dotnet new console --name HelloWorld -
此命令将基于
console应用程序模板创建一个名为HelloWorld的新项目。请参考以下屏幕截图:
图 2.4 – 新控制台应用程序的命令行输出
-
运行以下命令来构建和运行应用程序:
dotnet run --project ./HelloWorld/HelloWorld.csproj -
之前的命令将构建并运行应用程序,并将输出打印到命令窗口,如下所示:
图 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 实现
例如,如果你开发一个针对 .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 应用程序的服务:
-
Azure App Service 支持轻松部署和运行 ASP.NET Core 6 应用程序。Azure App Service 提供了在 Linux 或 Windows 平台上使用 x86 或 x64 处理器架构托管 .NET 6 应用程序的机会。更多信息,请参阅
docs.microsoft.com/en-in/azure/app-service/overview。 -
Azure Functions 支持部署和运行基于 .NET 6 的无服务器函数。您可以在 Linux 或 Windows 上托管 Functions 应用程序。更多信息,请参阅
docs.microsoft.com/en-us/azure/azure-functions/functions-overview。 -
Docker .NET 6 应用程序在 Docker 容器上运行。您可以在 Docker 容器上构建独立部署、高度可扩展的微服务。官方 .NET Core Docker 镜像可在
hub.docker.com/_/microsoft-dotnet获取,支持不同组合的 .NET(SDK 或运行时)和操作系统。许多 Azure 服务支持 Docker 容器,包括 Azure App Service、Azure Service Fabric、Azure Batch、Azure Container Instances 和 Azure Kubernetes Service(AKS)。更多信息,请参阅 https://docs.microsoft.com/en-us/dotnet/core/docker/introduction。
使用 .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 的新特性。
问题
- .NET Core 是以下哪个?
a. 开源
b. 跨平台
c. 免费
d. 所有以上选项
答案:d
- .NET Standard 2.0 库由以下哪个支持?
a. .NET Framework 4.6.1 或更高版本
b. .NET Core 2.0 或更高版本
c. .NET 6
d. Mono 5.4+ 或更高版本
e. 所有以上选项
答案:e
- 运行 CLI 命令所必需的 .NET CLI 驱动程序是以下哪个?
a. net
b. core
c. dotnet
d. none
答案:c
- .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# 中,我们发现一些框架命名空间,如 System 和 System.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 类,但为了更好的可读性,建议明确指定 class 或 struct。
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 对象中解构字段。
当我们运行代码时,我们看到如下截图所示的输出:
图 3.1 – 记录结构体示例的输出
当我们查看输出时,我们观察到 ToString 被重写以包含实例的内容。正如预期的那样,两个实例的哈希码相同,因为字段值相同。在常规结构体中,对象的哈希码是基于第一个非空字段生成的。在 record 结构体中,GetHashCode 方法也被重写以包含所有字段以生成哈希码。
record 结构体综合了 IEquatable<T> 接口的实现。它还实现了 == 和 != 操作符。常规结构体默认没有实现这些操作符。常规结构体从 ValueType 继承了 Equals 方法,该方法使用反射来进行相等性检查。因此,record 结构体中的综合相等性检查性能更优。record 结构体还综合了 Deconstruct 方法来填充对象外的字段。如果你仔细查看解构代码,你会注意到变量的混合声明。我们在解构过程中声明了 lastName,而在前面的语句中声明了 firstName。这种变量的混合声明仅在 C#10 及以上版本中是可能的。
当我们在反汇编工具(如 ILSpy 或 Reflector)中反汇编代码时,我们看到生成的代码如下所示:
图 3.2 – 员工类生成的代码
注意
你可以从 marketplace.visualstudio.com/items?itemName=SharpDevelopTeam.ILSpy 安装 ILSpy。
如果我们仔细查看 Employee 类型的定义,我们可以看到 C# 编译器为 record struct 类型合成的所有管道。从这一点,我们可以理解 record 结构体基本上是一个实现了 IEquatable 接口并重写了 GetHashCode 和 ToString 方法的结构体。您可以重写 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}");
如果合适,推断的类型将使用 Func 或 Action 委托。否则,编译器将合成一个委托类型,例如,如果 Lambda 表达式接受 ref 类型,如下面的代码片段所示:
Var SayWelcome = (ref string name) => Console.WriteLine($"Welcome {name}");
之前表达式的合成类型将是一个匿名委托类型。
编译器将尝试根据表达式推断返回类型。有时,可能无法推断类型。如果无法推断类型信息,我们将得到编译错误。
Lambda 表达式的返回类型
在编译器无法推断返回类型的情况下,我们可以在 C# 10 中显式指定。考虑以下代码片段,其中我们定义了从 Person 记录类继承的 Employee 和 Manager 记录类:
public record class Person();
public record class Employee() : Person();
public record class Manager() : Person();
var createExpression = (bool condition) => condition ? new Employee() : new Manager();
上一段代码片段中的 createExpression 术语根据传入的条件创建 Employee 或 Manager 类型的实例。在这种情况下,编译器无法推断返回类型,这将导致编译错误。从 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} 位置将被分别传递的 Greeting 和 Message 格式项填充。为了使其更友好和易于阅读,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 是通过传递两个参数构建的,即插值字符串字面部分中的字符数和要填充的字符串中的位置数。通过调用 AppendLiteral 或 AppendFormatted 来分别追加字面量或格式化字符串。随着插值字符串处理器的引入,之前讨论的问题得到了解决。
对于用 C# 早期版本编写的相同插值字符串代码,在 C# 10 中将会有性能提升。我们还可以构建我们自己的自定义插值字符串处理器,这在数据不打算用作字符串或条件执行对目标方法来说是逻辑上的合适选择的情况下可能很有用。
在本节中,我们学习了插值字符串的改进,它使我们比早期版本有更好的性能。在下一节中,让我们学习扩展属性模式。
扩展属性模式
模式匹配是一种检查对象值或具有完整或部分匹配到序列的属性值的方法。这在 C# 中以 if…else 和 switch…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");
在前面的代码中,我们正在使用扩展属性模式验证 Location 的 Country 属性。
在本节中,我们学习了关于扩展属性模式的内容。让我们在下一节中学习 caller 参数属性的新增内容。
添加到调用者参数属性
C# 5 首次引入了 caller 参数属性。它们是 CallerMemberName、CallerFilePath 和 CallerLineNumber。这些属性使得编译器在生成的代码中填充方法参数。它们在各种场景中使用,例如在 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 参数属性 CallerFilePath 和 CallerLineNumber 分别填充调用方法的文件路径和行号。
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 如何简化使用 implicit 和 global 使用指令编写的代码。我们了解了 record 结构体以及它们与 C# 9 中引入的 record 类的比较。我们还学习了 Lambda 表达式、表达式类型推断以及显式指定表达式返回类型的改进。我们还看到了字符串插值的性能提升。我们还学习了如何使用 CallerArgumentExpression 属性构建抛出辅助器。
通过本章,我们获得了利用 C# 10 新特性的技能,这些特性将在接下来的章节中构建的企业电子商务应用中使用。除此之外,还有一些其他的小增强。您可以参考 C# 语言文档以了解更多信息:docs.microsoft.com/en-us/dotnet/csharp/whats-new/csharp-10。在实现我们的电子商务应用的不同功能的同时,我们将在这本书中突出显示 C# 10 和 .NET 6 的新特性。
在接下来的部分,我们将学习构成我们电子商务应用程序构建块的一些横切关注点。
问题
阅读本章后,你应该能够回答以下问题:
- 正确或错误?记录结构体是可变的:
a. 正确
b. 错误
答案:a
- 你应该使用哪个关键字来使记录结构体不可变?
a. 记录结构体默认是不可变的。
b. readonly.
c. finally.
d. sealed.
答案:b
- 正确或错误?编译器将在所有场景中推断类型表达式:
a. 正确
b. 错误
答案:b
- 在哪个版本的 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 – 并发与并行与异步
让我们逐一解释每个术语:
-
并行性:这涉及到同时独立执行多个任务,例如在多个餐厅柜台同时下单的例子中。在企业应用中,并行性意味着在多核 CPU 中同时执行多个线程/任务。然而,单核 CPU 也通过超线程支持并行性,这通常涉及将单个核心逻辑上划分为多个核心,例如启用超线程的双核 CPU,它表现得像一个四核——也就是说,四个核心。
-
并发性:这涉及到同时执行许多任务,例如在我们之前的例子中,在排队等待餐厅柜台的同时回复电子邮件,或者厨师为一道菜调味并加热另一道菜的锅。在企业应用中,并发性涉及多个线程共享一个核心,并且根据它们的时间片,执行任务和进行上下文切换。
-
异步性:异步编程是一种依赖于异步执行任务的技术,而不是在等待时阻塞当前线程。在我们的例子中,异步性是等待你的凭证被叫到你去取餐柜台,而厨师正在准备你的食物。但是当你等待的时候,你已经离开了点餐柜台,从而允许其他订单被下单。这就像一个异步执行的任务,在等待 I/O 任务(例如,等待数据库调用的数据)时释放资源。异步性的美妙之处在于,任务要么并行执行,要么并发执行,这完全由框架抽象化,从开发者那里屏蔽了。这使得开发者可以将他们的开发努力集中在应用程序的业务逻辑上,而不是管理任务。我们将在“任务与并行”部分看到这一点。
-
CLR ThreadPool。在多核/多处理器系统中,多线程有助于通过在不同的核心中执行新创建的线程来实现并行性。
现在我们已经了解了并行编程中的关键术语,让我们继续探讨如何在 .NET Core 中创建线程以及 ThreadPool 的作用。
消除线程、懒加载初始化和 ThreadPool 的神秘感
线程是操作系统中最小的单位,它在处理器中执行指令。进程是一个更大的执行容器,进程内的线程是使用处理器时间和执行指令的最小单位。要记住的关键点是,每当你的代码需要在进程中执行时,它应该被分配到一个线程上。每个处理器一次只能执行一条指令;这就是为什么在单核系统中,在任何时刻,只有一个线程正在执行。有一些调度算法用于将处理器时间分配给线程。线程通常有一个堆栈(用于跟踪执行历史),一些寄存器用于存储各种变量,以及计数器用于保存需要执行的指令。
快速查看任务管理器将提供有关物理和逻辑核心数量的详细信息,导航到资源监视器将告诉我们每个核心的 CPU 使用情况。以下图显示了启用超线程的四核 CPU 的详细信息,该 CPU 在任何时刻可以并行执行八个线程:
![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();
在前面的代码中,FetchDataFromAPI和LoadFileFromDisk是将在新线程上运行的方法。
提示
在任何时刻,每个核心上只会有一个线程在执行——也就是说,只有一个线程被分配了 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线程的线程调用QueueUserWorkItem或ThreadPool类的类似方法 -
通过 TPL 进行调用
当在ThreadPool中创建新线程时,它维护自己的本地队列,该队列检查全局队列并以 FIFO(先进先出)的方式出队工作项;然而,如果在此线程上执行的代码创建了另一个线程,例如子线程,那么它将被排队到本地队列而不是全局队列。
工作线程本地队列中操作的执行顺序始终是ThreadPool,其中n是ThreadPool中的线程数——即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.Enter和Monitor.Exit来获取,实际上,使用锁的编译器内部将线程转换为Monitor.Enter和Monitor.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 – 同步构造比较
到目前为止,我们已经涵盖了以下内容:
-
使用
Thread和ThreadPool类及其限制的多线程方式 -
懒初始化的重要性以及它在多线程环境中的帮助
-
.NET 中可用的各种同步构造
当我们创建一些横切组件时,我们将在后面的章节中使用这些概念。
在下一节中,我们将看到如何通过任务和 TPL 的使用来克服 Thread 和 ThreadPool 的限制。
介绍任务和并行
我们知道异步编程有助于我们的应用程序扩展并更好地响应,因此实现异步应用程序不应给开发者带来额外的开销。Thread 和 ThreadPool 虽然有助于实现异步性,但增加了许多开销,并带来了限制。
因此,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.Tasks 和 System.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 类的 GetAsync 和 PostAsync,都有接受取消令牌的重载。对于后一种情况(中止任务),决策基于将要运行的操作是否支持取消。假设它支持取消,我们可以将取消令牌传递给方法,并在方法调用内部检查取消令牌的 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 通过 TaskCompletionSource 的 Task 属性进行跟踪,我们创建了另一个任务来调用我们的长时间运行的操作(不支持取消令牌支持的操作),并且哪个任务先完成,我们就返回哪个任务。
当然,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以创建一个强制父-子关系的延续任务,并强制父任务仅在子任务完成后才完成执行。
与WhenAll和WhenAny类似,ContinueWith也有类似的兄弟方法,如下所示:
-
Task.Factory.ContinueWhenAll:这个方法接受多个任务引用作为参数,并在所有任务完成时创建一个延续。 -
Task.Factory.ContinueWhenAny:这个方法接受多个任务引用作为参数,并在其中一个引用的任务完成时创建一个延续。
理解任务延续对于理解 async-await 的底层工作原理至关重要,我们将在本章后面讨论这一点。
同步上下文
SynchronizationContext是System.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 提供了TaskScheduler和SynchronizationContext类的复杂派生类型。然而,它们在 async-await 中扮演着重要角色,并有助于快速调试任何与死锁相关的问题。
注意,查看TaskScheduler和SynchronizationContext的内部工作原理超出了本书的范围,留作练习探索。
实现数据并行化
数据并行化主要涉及将源集合分割成多个并行可执行的任务,这些任务并行执行相同的操作。使用 TPL(Task Parallel Library),这可以通过Parallel静态类来实现,该类公开了For和ForEach等方法,并具有多个重载以处理此类执行。
假设你有一个包含一百万个数字的集合,你需要找出其中的素数。数据并行化在这里可以派上用场,因为集合可以被分割成范围,并评估其中的素数。通常的并行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.For和Parallel.ForEach实现数据并行化的关键优势如下列出:
-
适用于取消循环;它们在常规的
for循环中的break操作类似。在Parallel.For中,这是通过将ParallelStateOptions传递给委托并调用ParallelStateOptions.Break来支持的。当某个任务遇到Break时,ParallelStateOptions类的LowestBreakIteration属性被设置,所有并行任务将迭代直到达到这个数字。ParallelLoopResult是Parallel.For和Parallel.ForEach的返回类型,它有一个IsCompleted属性,表示循环是否提前执行。 -
它们还支持通过
ParallelStateOptions.Stop立即停止循环。此外,Parallel.For和Parallel.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
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返回,并且所有迭代的执行会立即停止。当然,如果异常在委托内部被吞没而没有抛出,则执行可以继续。 -
还有各种其他扩展方法,例如
AsSequential和AsOrdered,这些可以在单个 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 的异步方法示例
在前面的图中,控制流如下(使用图中的数字标签):
-
应用程序从
Main方法开始执行。由于Main前缀为async方法,它被转换为一个实现状态机的类型。执行会继续,直到在await DownloadFileAsync处遇到await,然后线程返回给调用者。 -
在返回调用者之前,对
DownloadFileAsync方法的调用被存储在一个Task对象中,并且Task对象的引用也被保留。Main方法的剩余代码被包装在这个任务的延续中。 -
ThreadPool线程将开始执行DownloadFileAsync方法,并重复相同的步骤——即,将方法转换为实现状态机的类型,直到遇到await,然后返回引用的任务;剩余的代码将移动到该任务的后续操作中。 -
当
DownloadDataTaskAsync方法完成时,任务后续操作被触发,并将执行剩余的代码。 -
该过程会重复进行,直到具有
DownloadFileAsync引用的任务完成并执行其后续操作,在这种情况下,后续操作是Console.WriteLine("File downloaded!!"),然后应用程序退出。
在一个大致的高级层面上,代码将转换为如下所示:
图 4.6 – 转换后的示例异步方法
虽然这只是一个对异步/await 底层工作原理的过度简化,但我们可以看到编译器做了大量的工作,包括生成一个实现状态机的类型,并使用回调的状态继续执行。
我们已经看到编写异步方法是多么简单,我们将在本书的整个过程中在我们的企业应用程序中编写许多这样的方法。然而,async-await 并非万能药;它不是每个应用程序问题的答案。我们需要验证某些因素才能使用 async-await。让我们看看使用 async-await 的原则是什么。
注意
如果存在SynchronizationContext,前面的代码会有所不同。例如,在 Windows Forms 或 WPF 应用程序中,使用SynchronizationContext的Post方法或TaskScheduler.FromCurrentSynchronizationContext将后续操作发布到当前SynchronizationContext。根据标准命名约定,为了可读性,异步方法后缀为async,但从语法上讲,这不是必需的。
使用 async-await 的原则
随着我们开始使用 async-await,有一些推荐的做法可以使应用程序充分利用异步原则。例如,对于嵌套调用,我们应该一直使用 async-await;不要使用.Result等。以下是一些有助于有效使用 async-await 的指导原则。
一直使用 async-await
使用 async-await 实现的异步方法应该从 async-await 方法中触发,以便正确地等待。如果我们尝试使用任务的Result方法或Wait方法从同步方法中调用异步方法,可能会导致死锁。
让我们看看以下来自 WPF 应用程序的代码片段,该应用程序在按钮点击时从网络上下载文件。然而,我们不是等待异步方法的调用,而是使用Task的Result方法:
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); 之后的代码永远不会执行,原因如下:
-
一旦遇到
await,Task引用对象将通过GetAwaiter方法捕获SynchronizationContext在TaskAwaitable中。 -
一旦
async操作完成,该await的后续操作需要在SynchronizationContext上执行(通过SynchronizationContext.Post)。 -
然而,
SynchronizationContext已经被阻塞,因为按钮点击时对task.Result的调用是在同一个SynchronizationContext上,并且正在等待DownloadDataTaskAsync完成,因此它导致了死锁。
因此,永远不要阻塞 async 方法;最佳实践是将 async 完全实现。所以,在先前的代码中,你需要将调用 await DownloadFileAsync(以及按钮点击时的 async void – await 需要一个带有 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
总是确保使用 Task 或 Task<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 的原因相同——那就是,由于 Action 是 void 返回类型的委托,对 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
因此,需要能够使用 yield 与 async 方法一起,并且循环遍历集合以异步调用应用程序。这就是 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 中可用的各种数据结构,这些数据结构可以支持同步/线程安全,而无需编写任何额外的代码。
使用并发集合实现并行化
集合类是最常用的类型之一,用于封装、检索和修改相关数据的枚举集合。Dictionary、list、queue和array是一些常用的集合类型,但它们不是线程安全的。如果你一次只从一个线程访问它们,这些集合是好的。
在现实世界中,环境将是多线程的,为了使其线程安全,你必须实现各种同步构造,如前文所述。为了解决这个问题,Microsoft 提出了并发集合类,例如 ConcurrentQueue、ConcurrentBag、ConcurrentDictionary 和 ConcurrentStack,它们是线程安全的,因为它们内部实现了同步。让我们在以下章节中详细探讨它们。
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 示例输出
你现在可能认为我们可以添加一个锁来管理线程同步,以避免在多线程场景中发生这种异常。我在代码中修改和枚举字典的地方添加了一个锁来同步线程。以下是更新的代码片段:
-
首先,我们有
Taskt1作为向字典添加操作的客户端的一个操作: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); } }); -
然后,我们有
Taskt2作为另一个客户端从字典读取的第二个操作: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); } } }); -
现在,我们同时执行了两个任务:
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);
}
});
-
然后,我们还有
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); } 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 – 生产者-消费者并发集合
所有这三个集合都实现了IProducerConsumerCollection<T>接口,最重要的方法是TryAdd和TryTake,如下所示:
// 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 消费。但是有两个问题:
-
生产者以自己的速率生产,消费者以自己的速率消费,并且没有同步。
-
在
taskt2中,消费者进行了连续的不定轮询,这对性能和 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");
}
}
});
现在,生产者和消费者线程可以并发访问。
在代码中需要考虑以下几点:
-
指定的边界为
5:BlockingCollection<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
在阻塞集合中,可能会出现消费者需要与多个集合一起工作并取或添加项目的情况。TakeFromAny 和 AddToAny 方法将帮助你在这种情况下。你可以在这里进一步了解这两个方法:
摘要
总结来说,编写和维护干净的异步代码是困难的。然而,随着 .NET 和 C# 中可用的各种构造,开发者现在可以以更少的框架开销编写异步代码,并更多地关注业务需求。
在本章中,我们介绍了使用 TPL、async-await 和并发集合编写可扩展异步代码的各种方法,我们还介绍了 .NET 中线程和 ThreadPool 的基础知识,以了解框架内部结构并为企业应用程序编写更干净的代码。现在,我们对多线程以及如何在多线程环境中保护共享数据有了更深入的了解。我们学习了如何创建任务和实现使用 async-await 的异步函数,最后,我们学习了 .NET Core 中可用的并发集合及其在各种并发场景中的应用。
在下一章中,我们将探讨 .NET 6 中的依赖注入及其在松耦合企业应用程序中的各种底层类中发挥的重要作用。
问题
- 在多线程环境中,以下哪个数据结构应该用来保护数据不被覆盖/损坏?
a. async-await.
b. 任务。
c. 同步结构,如锁。
d. 数据永远不会损坏。
答案:a
- 如果你有一个从 REST API 获取数据的 WPF 应用程序,以下哪个应该实现以获得更好的响应性?
a. 并发集合
b. Parallel.For
c. async-await 用于 REST API 调用
答案:c
- 以下哪个应该传递以取消任务?
a. CancellationToken
b. ConcurrentDictionary
c. SemaphoreSlim
答案:a
- 以下哪个是使用 async-await 且不返回任何内容的异步方法的推荐返回类型?
a. async void
b. async Task
c. async book
d. async Task<bool>
答案:b
1088



被折叠的 条评论
为什么被折叠?



