C-12-和--NET8-软件架构-全-
C#12 和 .NET8 软件架构(全)
原文:
zh.annas-archive.org/md5/2f40284e13cd7ef3a43a49539fd67e9f
译者:飞龙
前言
本书涵盖了现代基于云和分布式软件架构中最常见的模式和框架。它通过提供实际、真实世界的场景,讨论何时以及如何使用每个模式。
本书还介绍了 DevOps、微服务、Kubernetes、持续集成和云计算等技术流程,以便您能够开发并交付最佳级别的软件解决方案给客户。
本书将帮助您理解客户希望您提供的产品。它将指导您在开发过程中解决可能遇到的最大问题。它还涵盖了在基于云的环境中管理应用程序时需要遵循的“做”和“不做”事项。您将了解不同的架构方法,例如分层架构、洋葱架构、面向服务的架构、微服务、单页应用程序和云架构,并了解如何将它们应用于特定的业务需求。
最后,您将使用 Azure 在远程环境或云中部署代码。
本书中的所有概念都将通过实际、实用的案例研究来解释,其中设计原则在创建安全且稳健的应用程序时起着至关重要的作用。到本书结束时,您将能够开发和交付高度可扩展且安全的企业级应用程序,以满足最终客户的业务需求。
值得一提的是,本书不仅将涵盖软件架构师在开发 C#和.NET Core 解决方案时应遵循的最佳实践,还将讨论您需要掌握的所有环境,以便根据最新趋势(如 Kubernetes、ASP .NET Core 和 Blazor)开发软件产品。
这第四版在代码、细节程度和解释方面都得到了改进,并适应了 C# 12 和.NET 8 带来的新机遇。
此外,我们还添加了大量全新的内容,例如一个专门针对案例研究的章节,以及一个针对 Kubernetes 的.NET 开发的章节,作为案例研究的扩展,因为我们使用案例研究的见解来构建这一章节。
本书面向的对象
本书面向希望成为架构师或希望使用.NET 框架构建企业应用的工程师和高级开发者。它也适用于任何希望提高基于.NET 和 C#的企业解决方案相关知识的软件架构师。值得注意的是,需要具备 C#和.NET 的经验。
本书涵盖的内容
第一章,理解软件架构的重要性,解释了软件架构的基础知识。这一章将帮助您培养正确的思维方式来面对客户需求,然后选择正确的工具、模式和框架。
第二章,非功能性需求,指导您在应用程序开发的重要阶段,即收集和考虑应用程序必须满足的所有约束和目标,例如可伸缩性、可用性、弹性、性能、多线程、互操作性和安全性。
第三章,管理需求,描述了管理需求、错误和其他有关您应用程序的信息的技术。虽然大多数概念是通用的,但本章重点介绍了 Azure DevOps 和 GitHub 的使用。
第四章,使用 C# 12 的编码最佳实践,描述了在用 C# 12 开发.NET 8 应用程序时应遵循的最佳实践,包括评估软件质量的指标以及如何借助 Visual Studio 中包含的所有工具来衡量它们。在这里,您还将学习如何使用功能测试自动验证整个应用程序的某个版本是否符合约定的功能规范。
第五章,在 C# 12 中实现代码重用,描述了在您的.NET 8 应用程序中最大化代码重用模式的最佳实践。它还讨论了代码重构的重要性。
第六章,设计模式和.NET 8 实现,描述了常见的软件模式,并提供了.NET 8 的示例。在这里,您将了解模式的重要性以及使用它们的最佳实践。
第七章,理解软件解决方案中的不同领域,描述了现代领域驱动设计软件生产方法论以及相关的模式和架构。在这里,您还将学习如何使用它来应对需要多个知识领域的复杂应用程序,以及如何利用基于云和微服务架构的优势。
第八章,理解 DevOps 原则和 CI/CD,描述了软件开发和演变的 DevOps 基础。在这里,您将学习如何组织您应用程序的持续集成/持续交付周期,讨论实现这一场景的机会和困难。它还描述了如何自动化整个部署过程,从在源存储库中创建新版本,通过各种测试和审批步骤,到在实际生产环境中部署应用程序。在这里,您将学习如何使用 Azure Pipelines 和 GitHub Actions 来自动化整个部署过程。
第九章,测试您的企业应用程序,描述了如何测试您的应用程序,包括必须在开发生命周期中包含的各种测试类型以及测试驱动开发方法。在这里,您还将学习如何使用 xUnit 测试.NET Core 应用程序,并了解如何借助测试驱动设计轻松开发和维护满足您规格的代码。
在这里,您还将学习如何使用功能测试自动验证整个应用程序的某个版本是否符合约定的功能规范。
第十章,选择最佳云解决方案,为你提供了云中可用的工具和资源的广泛概述,特别是关于 Microsoft Azure。在这里,你将了解如何搜索合适的工具和资源,以及如何配置它们以满足你的需求。
第十一章,将微服务架构应用于企业应用程序,提供了对微服务和 Docker 容器的广泛概述。在这里,你将了解基于微服务的架构如何利用云提供的所有机会,以及如何使用微服务在云中实现灵活性、高吞吐量和可靠性。你还将学习如何使用容器和 Docker 在你的架构中混合不同的技术,以及如何使你的软件平台独立。
第十二章,在云中选择最佳数据存储方案,描述了云中和 Microsoft Azure 中可用的主要存储引擎。在这里,你将了解如何选择最佳的存储引擎以实现所需的读写并行性,如何配置它们,以及如何从你的 C#代码中与它们交互。
第十三章,使用 C#与数据交互 – Entity Framework Core,详细解释了你的应用程序如何借助对象关系映射(ORMs)以及特别是 Entity Framework Core 8.0 的帮助与各种存储引擎进行交互。
第十四章,使用.NET 实现微服务,描述了如何在实践中使用.NET 实现微服务以及如何设计微服务之间的通信。在这里,你还将了解如何在你.NET 项目中使用 gRPC 通信协议和 RabbitMQ 消息代理。
第十五章,使用.NET 应用服务导向架构,描述了服务导向架构,它使你能够将应用程序的功能作为 Web 或私有网络上的端点暴露出来,以便用户可以通过各种类型的客户端与之交互。在这里,你将了解如何使用 ASP.NET Core 和 gRPC 实现服务导向架构端点,以及如何使用现有的 OpenAPI 包来自动文档化它们。
第十六章,使用无服务器计算 – Azure Functions,描述了计算的无服务器模型以及如何在 Azure 云中使用它。在这里,你将了解如何仅在需要运行某些计算时分配云资源,从而只需为实际的计算时间付费。
第十七章,介绍 ASP.NET Core,详细描述了 ASP.NET Core 框架。在这里,你还将了解如何根据模型-视图-控制器(MVC)模式实现基于 Web 的应用程序。
第十八章,使用 ASP.NET Core 实现前端微服务,专注于前端微服务,即那些在应用程序外部与世界交互的微服务。在这里,你将详细了解如何基于 ASP.NET Core 实现前端微服务。
第十九章,客户端框架:Blazor,介绍了用于实现表示层的各种客户端技术。本章重点介绍了基于浏览器的 Blazor WebAssembly 和基于.NET MAUI 的本地 Blazor,并详细描述了它们。在这里,你将学习如何使用 C#实现单页应用和本地应用。
第二十章,Kubernetes,描述了 Kubernetes,这是微服务编排的事实标准。在这里,你将在 Kubernetes 上打包和部署微服务应用。你将学习如何与 Azure Kubernetes Service 交互,以及如何使用 Minikube 在你的开发机器上模拟 Kubernetes 集群。
第二十一章,案例研究,专注于书籍旅行社案例研究,展示了在实现基于微服务的企业应用中,如何将书中学习到的技术和架构模式付诸实践。
第二十二章,案例研究扩展:为 Kubernetes 开发.NET 微服务,将第二十一章,案例研究中探索的.NET 微服务的实际实施见解与第二十章,Kubernetes中介绍的 Kubernetes 基础知识相结合。
答案包含了所有章节末尾可以找到的问题的答案。
附录:人工智能与机器学习是一个仅在网络上提供的章节,包含人工智能和机器学习的介绍。第一部分总结了基本原理和技术,而第二部分则通过描述 Azure Machine Learning Studio 和基于 ML .NET 的简单示例来将这些原理和技术付诸实践。
您可以通过以下链接阅读附录:static.packt-cdn.com/downloads/9781805127659_Appendix.pdf
要充分利用这本书
-
不要忘记安装 Visual Studio Community 2022 或更高版本。
-
如果想要更深入地理解任何章节的内容,请随时跳转到第二十一章,案例研究中建议的部分。
-
同样,在深入第二十一章,案例研究的任何部分之前,请先回顾相应建议章节中讨论的理论。
下载示例代码文件
本书代码包托管在 GitHub 上,网址为github.com/PacktPublishing/Software-Architecture-with-C-Sharp-12-and-.NET-8-4E
。我们还有其他来自我们丰富图书和视频目录的代码包,可在github.com/PacktPublishing/
找到。查看它们吧!
下载彩色图像
我们还提供了一份包含本书中使用的截图/图表彩色图像的 PDF 文件。您可以从这里下载:packt.link/gbp/9781805127659
。
使用的约定
本书使用了多种文本约定。
CodeInText
:表示文本中的代码单词、数据库表名、文件夹名、文件名、文件扩展名、路径名、虚拟 URL、用户输入和 Twitter 昵称。例如:“将下载的WebStorm-10*.dmg
磁盘映像文件作为系统中的另一个磁盘挂载。”
代码块应如下设置:
private static string ParseIntWithTryParse()
{
string result = string.Empty;
if (int.TryParse(result, out var value))
result = value.ToString();
else
result = "There is no int value";
return $"Final result: {result}";
}
当我们希望将您的注意力引到代码块中的特定部分时,相关的行或项目将以粗体显示:
**private****static** string ParseIntWithException()
{
string result = string.Empty;
**try**
{
result = Convert.ToInt32(result).ToString();
}
**catch** (Exception)
{
result = "There is no int value";
}
return **$"Final result:** **{result}****"****;**
任何命令行输入或输出都应如下编写:
sudo cp sample.service /lib/systemd/system
sudo systemctl daemon-reload
sudo systemctl enable sample
粗体:表示新术语、重要单词或屏幕上看到的单词。例如,菜单或对话框中的单词在文本中显示如下。例如:“从管理面板中选择系统信息。”
警告或重要注意事项如下所示。
技巧和窍门如下所示。
联系我们
我们始终欢迎读者的反馈。
一般反馈:请发送电子邮件至feedback@packtpub.com
,并在邮件主题中提及书籍标题。如果您对本书的任何方面有疑问,请发送电子邮件至questions@packtpub.com
。
勘误表:尽管我们已经尽最大努力确保内容的准确性,但错误仍然可能发生。如果您在这本书中发现了错误,如果您能向我们报告,我们将不胜感激。请访问www.packtpub.com/submit-errata
,搜索这本书,点击提交勘误,并填写表格。
盗版:如果您在互联网上以任何形式发现我们作品的非法副本,如果您能提供位置地址或网站名称,我们将不胜感激。请通过链接与我们联系copyright@packtpub.com
。
如果您有兴趣成为作者:如果您在某个领域有专业知识,并且您有兴趣撰写或为书籍做出贡献,请访问authors.packtpub.com
。
分享您的想法
一旦您阅读了《软件架构:C# 12 和.NET 88,第四版》,我们很乐意听听您的想法!请点击此处直接访问此书的亚马逊评论页面并分享您的反馈。
您的评论对我们和科技社区都很重要,并将帮助我们确保我们提供高质量的内容。
下载此书的免费 PDF 副本
感谢您购买此书!
您喜欢在路上阅读,但无法携带您的印刷书籍到处走?
您的电子书购买是否与您选择的设备不兼容?
别担心,现在每购买一本 Packt 书籍,您都可以免费获得该书的 DRM 免费 PDF 版本。
在任何地方、任何设备上阅读。直接从您最喜欢的技术书籍中搜索、复制和粘贴代码到您的应用程序中。
优惠不会就此停止,您还可以获得独家折扣、时事通讯和每日免费内容的每日电子邮件。
按照以下简单步骤获取好处:
- 扫描下面的二维码或访问以下链接。
packt.link/free-ebook/9781805127659
-
提交您的购买证明。
-
就这样!我们将直接将您的免费 PDF 和其他优惠发送到您的电子邮件。
第一章:理解软件架构的重要性
我们于 2018 年开始编写这本书。自第一版出版以来已有五年,为满足客户需求而创建的企业应用程序(EAs)的软件架构的重要性日益增长。此外,技术本身也在以难以跟上速度发展,因此,新的架构机会不断涌现。因此,我们不断强调,我们构建的解决方案越复杂、越神奇,我们就越需要伟大的软件架构来构建和维护它们。
我们确信这就是您决定阅读这本书新版本的原因,这也是我们决定编写它的原因。这不仅仅是因为.NET 8 与.NET 6 的不同,因为还有其他采用这种方法的令人难以置信的书籍。真正目的是向社区提供一本能够支持开发人员和软件架构师在设计解决方案时做出困难决策的组件选择的书。因此,在本版中,我们重新构思了呈现所有内容的方式。
在阅读本版章节时,您会发现您将获得理解在设计企业应用程序时不可避免的基础和科技主题的支持,这些主题使用.NET 8、C#和云计算。大多数示例将使用 Microsoft Azure,但我们始终以不使您局限于特定云平台的方式呈现此内容。
重要的是提醒您,撰写关于这个重要主题并非易事,它提供了如此多的替代技术和解决方案。本书的主要目标不是构建一个详尽无遗且永不结束的技术和解决方案列表,而是展示各种技术家族之间的关系,以及它们在实际中如何影响构建可维护和可持续的解决方案。我们希望你们都能享受这次新的旅程!
具体来说,在第一章 理解软件架构的重要性中,我们将讨论为什么我们需要持续关注创建有效的企业解决方案的需求不断增加;用户总是需要他们应用程序中的更多新功能。此外,由于市场变化迅速,需要交付频繁的应用程序版本(版本),这增加了我们拥有复杂软件架构和开发技术的义务。
本章将涵盖以下主题:
-
软件架构是什么
-
一些可能对您作为软件架构师有帮助的软件开发过程模型
-
收集设计高质量软件所需正确信息的过程
-
帮助开发过程的设计技术
-
影响系统结果的需求案例
对于这个新版本,我们还将重新制定我们展示本书案例研究的方式。你将在书的最后一章找到一个单独的章节,那里将很容易理解其实施的整个目的。
本书的研究案例将带你通过为名为World Wild Travel Club(WWTravelClub)的旅行社创建软件架构的过程。这个案例研究的目的帮助你理解每一章中解释的理论,并提供一个如何使用 Azure、Azure DevOps、GitHub、C# 12、.NET 8、ASP.NET Core 和其他本书将介绍的技术开发企业应用的例子。
到本章结束时,你将确切了解软件架构的使命。你还将学会什么是 Azure 以及如何在平台上创建账户。你还将获得软件过程、模型和其他技术的概述,这些技术将使你能够领导你的团队。
软件架构是什么?
你今天之所以能阅读这本书,要归功于那些决定将软件开发视为一个工程领域的计算机科学家。这发生在上个世纪,更具体地说,是在六十年代末,当时他们提出我们开发软件的方式与建造建筑的方式非常相似。这就是为什么我们称之为软件架构。正如建筑师根据设计来设计建筑并监督其建造一样,软件架构师的主要目标是确保软件应用得到良好的实施;良好的实施需要设计一个优秀的架构解决方案。
在一个专业开发项目中,你必须做以下事情:
-
定义解决方案的客户需求。
-
设计一个优秀的解决方案以满足那些要求。
-
实施设计的解决方案。
-
测试解决方案的实施。
-
与客户验证解决方案。
-
在工作环境中交付解决方案。
-
在之后维护该解决方案。
软件工程将这些活动定义为软件开发生命周期的基本要素。所有理论上的软件开发过程模型(瀑布、螺旋、增量、敏捷等)都与这个周期有关。无论你使用哪种模型,如果你不在项目初期执行基本任务,你就不会提供可接受的软件解决方案。
关于设计优秀解决方案的主要观点是本书目的的基础。你必须理解,优秀的现实世界解决方案会带来一些基本约束:
-
解决方案需要满足用户需求。
-
解决方案需要按时交付。
-
解决方案需要遵守项目预算。
-
解决方案需要提供高质量。
-
解决方案需要保证安全和有效的未来演变。
优秀的解决方案需要是可持续的,你必须明白,没有优秀的软件架构就没有可持续的软件。如今,优秀的软件架构既依赖于现代工具,也依赖于现代环境,以完美满足用户需求。
因此,本书将使用微软提供的一些优秀工具。我们决定始终遵循长期支持(LTS)版本编写本书,这就是为什么我们现在正在使用.NET 8 的所有示例。这是作为软件开发统一平台的第二个 LTS 版本,这为我们创造了创建出色解决方案的绝佳机会。
图 1.1:.NET 支持
.NET 8 与 C# 12 一同发布。考虑到.NET 针对众多平台和设备的目标,C#现在已成为世界上使用最广泛的编程语言之一,它可以在从小型设备到大型服务器等各种操作系统(OSs)和环境上运行。
本书还将使用Microsoft Azure,这是微软的云平台,在那里你可以找到公司提供的所有组件,用于构建高级软件架构解决方案。
值得注意的是,使用.NET 8 与 Azure 的结合只是作者选择的一个选项。.NET 也可以通过其他云服务提供商正常工作,Azure 也能很好地处理其他编码框架。
要成为一名软件架构师,你需要熟悉这些技术,以及许多其他技术。这本书将引导你踏上旅程,作为团队中的软件架构师,你将学习如何使用这些工具提供最佳解决方案。让我们从创建你的 Azure 账户开始这段旅程。
创建 Azure 账户
Microsoft Azure 是目前市场上最好的云解决方案之一。重要的是要知道,在 Azure 内部,我们将找到一组可以帮助我们定义 21 世纪解决方案架构的组件。
如果你想以紧凑、易于消化的方式检查 Azure 的当前状态、结构和更新,只需访问由 Alexey Polkovnikov 开发的azurecharts.com/
,你可以在其中学习、评估,甚至只是享受这个 Azure 百科全书中所描述的数十个 Azure 组件。
本小节将指导你创建 Azure 账户。如果你已经有了账户,你可以跳过这部分。
-
首先,访问
azure.microsoft.com
。在那里,你可以找到开始订阅所需的信息。通常情况下,语言翻译会自动设置。 -
一旦您访问了这个门户,就有可能进行注册。如果您以前从未这样做过,有一个免费开始的选项,这样您就可以使用一些 Azure 功能而无需花费任何费用。请查看
azure.microsoft.com/free/
上的免费计划选项。 -
创建免费账户的过程相当简单,您将通过一个表格进行指导,该表格要求您拥有Microsoft 账户或GitHub 账户。
-
在此过程中,您还将被要求提供信用卡号码以验证您的身份,并防止垃圾邮件和机器人。然而,除非您升级账户,否则您不会收费。
-
要完成作业,您需要接受订阅协议、提供详细信息以及隐私声明。
-
一旦您完成填写表格,您就可以访问 Azure 门户。正如您在下面的屏幕截图中所见,面板显示了一个您可以定制的仪表板,以及一个左侧菜单,您可以在其中设置您将在解决方案中使用 Azure 组件。在这本书的整个过程中,我们将回到这个屏幕来设置帮助我们创建现代软件架构所需的组件。要找到下一页,只需选择汉堡菜单图标并点击所有服务:
图 1.2:Azure 门户
一旦您创建了 Azure 账户,您就准备好了解软件架构师如何利用 Azure 提供的一切机会来领导团队开发软件。然而,重要的是要记住,软件架构师需要超越技术本身,因为他们需要定义软件的交付方式。
今天,软件架构师不仅设计软件的基础,还决定整个软件开发和部署过程是如何进行的。下一节将涵盖世界上一些最广泛使用的软件开发范例。我们将从描述社区所指的传统软件工程开始。然后,我们将介绍如今改变了我们构建软件方式的敏捷模型。
软件开发流程模型
作为一名软件架构师,了解目前大多数企业中常用的某些常见开发流程是很重要的。软件开发流程定义了团队中的人们如何生产和交付软件。一般来说,这个过程与一种称为软件开发流程模型的软件工程理论相关。自从软件开发首次被定义为一种工程过程以来,已经提出了许多软件开发流程模型。让我们回顾一下传统的软件模型,然后看看目前常见的敏捷模型。
回顾传统的软件开发流程模型
软件工程理论中介绍的一些模型已经被认为是传统且过时的。本书并不旨在涵盖所有这些模型,但在这里,我们将简要解释一些仍在某些公司中使用的模型——瀑布和增量模型。
理解瀑布模型原则。
这个主题在 2023 年的软件架构书中可能看起来很奇怪,但确实,你仍然可能找到一些公司,其中最传统的软件过程模型仍然是软件开发指南。这个过程按顺序执行所有基本任务。任何软件开发项目都包括以下步骤:
-
需求:创建产品需求文档,它是软件开发过程的基础。
-
设计:根据需求开发软件架构。
-
实现:软件被编程。
-
验证:在应用程序中进行测试。
-
维护:在交付后,周期再次开始。
让我们看看这个的图示表示:
图 1.3:瀑布开发周期(en.wikipedia.org/wiki/Waterfall_model
)
通常,使用瀑布模型会导致问题,如软件功能版本交付延迟,以及由于期望与最终交付产品之间的距离而导致用户不满。此外,根据我的经验,只有在开发完成后才开始应用程序测试总是感觉非常紧张。
分析增量模型。
增量开发是一种试图克服瀑布模型最大问题的方法:用户只能在项目结束时测试解决方案。遵循此方法模型的理念是尽可能早地让用户与解决方案互动,以便他们可以提供有用的反馈,这将有助于软件开发过程中的开发。
图 1.4:增量开发周期(en.wikipedia.org/wiki/Incremental_build_model
)
在前面的图片中展示的增量模型被引入作为瀑布方法的替代方案。该模型的理念是为每个增量运行一系列与软件开发相关的实践(沟通、规划、建模、构建和部署)。尽管它减轻了与客户沟通不足相关的问题,但对于大型项目来说,增量仍然太长,因此更少的增量仍然是一个问题。
当增量方法被大规模使用时——主要在上世纪末——由于需要大量的文档,报告了许多与项目官僚主义相关的问题。这种笨拙的场景导致了软件开发行业中一个非常重要的运动的兴起——敏捷。
理解敏捷软件开发流程模型
在本世纪初,软件开发被认为是工程中最混乱的活动之一。失败的软件项目比例极高,这一事实证明了需要一种不同的方法来处理软件开发项目所需的灵活性。
2001 年,敏捷宣言被介绍给世界,从那时起,各种敏捷流程模型被提出。其中一些一直存活至今,并且仍然非常普遍。
敏捷宣言已被翻译成 60 多种语言。您可以在agilemanifesto.org/
查看。
敏捷模型与传统模型之间最大的区别是开发者与客户互动的方式。所有敏捷模型背后的信息是,你越快将软件交付给用户,就越好。这种想法有时会让软件开发者感到困惑,他们将其理解为——让我们尝试编码,这就是全部,朋友们!
然而,敏捷宣言的一个重要观察结果,很多人在开始使用敏捷时并没有阅读:
图 1.5:敏捷软件开发宣言
软件架构师始终需要记住这一点。敏捷流程并不意味着缺乏纪律。此外,当你使用敏捷流程时,你会很快明白,没有纪律就无法开发出好的软件。另一方面,作为一名软件架构师,你需要理解“软”意味着灵活性。一个拒绝灵活性的软件项目往往会随着时间的推移而自我毁灭。
敏捷背后的 12 项原则是这个灵活方法的基础:
-
持续交付有价值的软件以满足客户需求必须是任何开发者的最高优先级。
-
需要理解需求变更是一个让客户更具竞争力的机会。
-
使用每周的时间尺度交付软件。
-
软件团队必须由业务人员和开发者组成。
-
软件团队需要被信任,并且应该拥有正确的环境来完成项目。
-
与软件团队的最佳沟通方式是面对面。
-
当软件真正在生产中运行时,您可以看到最伟大的软件团队成就。
-
当敏捷交付可持续开发时,它才能正常工作。
-
你在技术和良好设计上的投资越多,你就越敏捷。
-
简单性是至关重要的。
-
团队越自我组织,交付的质量就越好。
-
软件团队倾向于不时地改善其行为,分析和调整其流程。
即使在敏捷宣言发布 20 年后,其重要性和与当前软件团队需求的联系依然存在。当然,有许多公司还没有完全接受这种方法,但作为软件架构师,你应该将其视为一个转变实践和进化你所工作的团队的机会。
有许多技术和模型被敏捷方法引入到软件社区中。接下来的小节将讨论精益软件开发、极限编程和Scrum,以便您作为软件架构师可以决定您可能使用哪些来改进您的软件交付。
精益软件开发
在敏捷宣言之后,精益软件开发方法被引入社区,作为汽车工程中一个知名运动的适应,即丰田的汽车制造模式。精益制造方法即使在资源有限的情况下也能提供高质量。
玛丽和汤姆·波彭迪克为软件开发制定了七个精益原则,这些原则与敏捷以及本世纪许多公司的方法真正相连,以下列出:
-
消除浪费:你可能认为任何会干扰客户真实需求交付的东西都是浪费。
-
在过程中建立质量:一个想要保证质量的组织需要在流程的最初就推广它,而不仅仅是在代码测试时才考虑。
-
创建知识:所有取得卓越成就的公司都通过有纪律的实验、记录知识并确保知识在整个组织中传播来生成新知识。
-
推迟承诺:在尽可能晚的时刻做出决策,而不损害项目。
-
快速交付:你交付软件越快,你消除浪费的机会就越多。使用时间频率竞争的公司相对于其竞争对手有显著的优势。
-
尊重人员:为团队设定合理的目标,并制定指导他们自我组织日常工作的计划,这是尊重你一起工作的人。
-
优化整体:精益公司改善价值循环;从它接收新需求的那一刻起,到它交付软件的那一刻。
遵循精益原则有助于团队或公司提高交付给客户的功能质量。它还减少了客户不会使用的功能所花费的时间。在精益中,决定对客户重要的功能指导团队交付有意义的软件,这正是敏捷宣言旨在在软件团队中推广的。
极限编程
在敏捷宣言发布之前,一些设计文档的参与者,特别是 Kent Beck,向世界介绍了软件开发的方法论——极限编程(XP)。
XP 基于简洁、沟通、反馈、尊重和勇气这些价值观。根据贝克关于这个主题的第二本书,它后来被认为是一种编程领域的社交变革。它无疑促进了开发流程的巨大变化。
XP 声明每个团队都应该只做被要求做的事情,每天面对面沟通,尽早展示软件以获取反馈,尊重团队每个成员的专业知识,并勇于讲述关于进度和估计的真实情况,将团队的工作视为整体。
XP 还提供了一套规则。如果团队发现某些事情没有正常运作,他们可以更改这些规则,但始终维护方法论的价值是非常重要的。
这些规则分为规划、管理、设计、编码和测试。唐·韦尔斯在 www.extremeprogramming.org/
上绘制了 XP 的图。尽管许多公司和专家强烈批评了该方法论的一些想法,但仍有许多良好的实践至今仍在使用:
-
使用用户故事编写软件需求:用户故事被认为是一种敏捷的方法来描述用户需求,以及验收测试,这些测试用于确保正确实施。
-
将软件划分为迭代并交付小版本:除了瀑布模型之外,所有软件开发方法都实施了迭代的实践。交付更快版本的事实降低了未能满足客户期望的风险。
-
避免加班并保证可持续的速度:尽管这可能是软件架构师可能遇到的最困难的任务之一,但加班表明在过程中某些事情没有正常运作。
-
保持简单:在开发解决方案时,试图预测客户可能希望拥有的功能是很常见的。这种方法增加了开发的复杂性和将解决方案推向市场的时间。另一种方法将导致成本高昂,并且可能是在你开发的系统中使用率低的功能。
-
重构:持续重构代码的方法是好的,因为它使你的软件能够进化,并确保由于你使用的开发平台的技术变化而真正必要的改进。
-
始终让客户可用:如果你遵循 XP(极限编程),你应该在团队内部有一个专家客户。这当然是一件很难做到的事情,但这种方法的主要思想是确保客户参与开发的所有部分。作为另一个额外的好处,让客户靠近你的团队意味着他们了解团队面临的困难和专业知识,这有助于增进双方之间的信任。
-
持续集成:这种实践是当前 DevOps 方法的基础之一。你个人代码仓库和主代码仓库之间的差异越小,越好。
-
先编写单元测试:单元测试是一种编程特定代码以测试项目单个单元(类/方法)的方法。这在当前的开发方法论中被称为测试驱动开发(TDD)。这里的目的是确保每个业务规则都有自己的单元测试用例。
-
代码必须按照既定标准编写:确定编码标准的需求与这样一个观点相关联,即无论哪个开发者负责项目的特定部分,代码都必须编写得让任何开发者都能理解。
-
结对编程:结对编程是在软件项目的每一分钟都难以实现的方法之一,但这种方法本身——一个程序员编写代码,另一个程序员积极观察并提供评论、批评和建议——在关键场景中非常有用。
-
验收测试:采用验收测试来满足用户故事是确保新发布的软件版本不会损害其当前需求的好方法。更好的选择是将这些验收测试自动化。
值得注意的是,许多这些规则今天被认为是在不同的软件开发方法论中至关重要的实践,包括 DevOps 和 Scrum。我们将在本书的第八章,理解 DevOps 原则和 CI/CD中讨论 DevOps。现在让我们深入了解 Scrum 模型。
进入 Scrum 模型
Scrum 是一种敏捷的软件开发项目管理模型。该模型源于精益原则,并且是目前软件开发中更广泛使用的方法之一。
请查看此链接以获取有关 Scrum 框架的更多信息:www.scrum.org/
.
如下图中所示,Scrum 的基础是您有一个灵活的用户需求清单(产品待办事项),需要在每个敏捷周期中讨论,这被称为冲刺。冲刺目标(冲刺待办事项)由Scrum 团队确定,该团队由产品负责人、Scrum 大师和开发团队组成。产品负责人负责确定那个冲刺中将交付的内容。在冲刺期间,这个人将帮助团队开发所需的功能。在 Scrum 流程中领导团队的人被称为 Scrum 大师。所有的会议和流程都由这个人执行。
图 1.6:Scrum 流程
通常会将 Scrum 与另一种名为看板的敏捷技术一起应用,看板也是由丰田为制造汽车而开发的,通常用于软件维护。看板的主要目的是通过一个可视化系统确保每个人都了解正在开发的产品的情况。著名的看板板是一个实现这一点的绝佳方式,在那里你定义团队必须做什么,他们在做什么,以及已经完成的事情。
需要注意的是,Scrum 流程并没有讨论软件应该如何实现,也没有说明哪些活动将会被执行。再次强调,你必须记住软件开发的基础,这是在本章开头讨论过的;Scrum 需要与一个流程模型一起实施。DevOps 是可能帮助你结合 Scrum 使用软件开发流程模型的方法之一。查看第八章,理解 DevOps 原则和 CI/CD,以更好地理解这一点。
在整个公司范围内扩展敏捷
今天,在实践敏捷并取得良好进展的公司中相当普遍,考虑到前几节中介绍的技术成果。Scrum、看板和 XP 的混合使用,以及软件开发过程成熟度的演变,为公司带来了良好的结果,我们生活在一个软件开发是商业成功关键策略之一的世界。
一些公司自然需要增加团队的数量,但在这一过程中,重要的问题是如何在不失去敏捷性的情况下进行演变。你可以确信,这个问题可能会被作为一个软件架构师的你提出。你可能会在SAFe® – 扩展敏捷框架中找到对这个问题的良好答案:
SAFe® for LeanEnterprises 是一个知识库,包含经过验证的、集成的原则、实践和能力,用于通过精益、敏捷和 DevOps 实现业务敏捷性。”
– 迪恩·莱夫林韦尔,创始人。
© 扩展敏捷,Inc.
基于对齐、内置质量、透明度和项目执行的核心理念,该框架为在拥有一个或多个价值流的公司中交付具有所需敏捷性的产品提供了详细的路径。其原则使敏捷和增量交付、系统思维、快速经济决策以及围绕价值组织成为可能。
作为一名软件架构师,你可能会在系统团队、敏捷发布列车中的系统架构师,甚至在公司中的企业架构师等职位上找到成长的机会。当然,这需要大量的学习和投入,但这就是你在大型公司中会遇到的架构。
就像在这本书中你将找到的每一个框架、技术或模型一样,向你们介绍 SAFe 的目的并不是涵盖内容的每一个细节。你可以在他们的网站上找到优秀的资料和培训。但作为一名软件架构师,了解如何扩大公司规模可能是你工具箱中的一项宝贵知识!既然你已经知道了,那么让我们回到设计高质量软件的阶段,讨论如何收集正确信息来设计它。
收集设计高质量软件的正确信息
太棒了!你刚刚开始一个软件开发项目。现在,是时候运用你所有的知识来交付最好的软件了。你第一个问题可能就是——我该如何开始? 好吧,作为一名软件架构师,你将是回答这个问题的那个人。而且你可以确信,你的答案会随着你领导的每一个软件项目而不断演变。
定义软件开发流程是第一项任务。这通常在项目规划过程中完成,或者可能在它开始之前发生。
另一个非常重要的任务是收集软件需求。无论你决定使用哪种软件开发流程,收集真实用户需求都是一项困难和持续的工作的一部分。当然,有技术可以帮助你完成这项工作,你可以确信,收集需求将帮助你定义软件架构的重要方面。
这两个任务被大多数软件开发专家视为开发项目旅程结束时的成功关键。作为一名软件架构师,你需要使它们成为可能,这样你就可以在引导你的团队的同时,尽可能避免可能出现的问题。
理解需求收集过程
表示需求的方式有很多。最传统的方法是在分析开始之前,你必须写出一个完美的规范。敏捷方法建议,你需要在准备好开始一个开发周期时立即编写用户故事。
记住,你不仅仅是为用户编写需求;你也是为你的团队和你自己编写它们。
事实是,无论你决定在项目中采用哪种方法,你都需要遵循一些步骤来收集需求。这就是我们所说的需求工程过程。
图 1.7:需求工程过程
在这个过程中,你需要确保解决方案是可行的。在某些情况下,可行性分析也是项目规划过程的一部分,在你开始需求收集时,你将已经完成了可行性报告。因此,让我们检查这个过程的其它部分,这将为你提供大量关于软件架构的重要信息。
检测精确的用户需求
有很多方法可以检测特定场景中用户的确切需求。这个过程被称为收集。一般来说,这可以通过帮助你理解我们所说的用户需求的技术来完成。这里有一个常见技术的列表:
-
想象力的力量:如果你在你提供解决方案的领域是专家,你可以使用自己的想象力来找到新的用户需求。头脑风暴可以协作进行,以便一组专家可以定义用户的需求。
-
问卷调查:这个工具对于检测常见且重要的需求很有用,例如用户数量和种类、系统高峰使用时间以及最常用的操作系统和网页浏览器。
-
访谈:与用户访谈可以帮助你作为架构师检测到问卷调查和你的想象力可能无法涵盖的用户需求。
-
观察:没有比观察用户一天更好的方式来理解用户的日常习惯了。
一旦你应用了其中一种或多种技术,你将拥有关于用户需求的大量有价值的信息。
记住,你可以在任何需要收集需求的情况下使用这些技术,无论是针对整个系统还是针对单个故事。
在那一刻,你将能够开始分析这些用户需求并检测用户和系统需求。让我们看看如何在下一节中这样做。
分析需求
当你检测到用户需求后,是时候开始分析需求了。为此,你可以使用以下技术:
-
原型设计:原型设计对于阐明和具体化系统需求非常出色。今天,我们有许多工具可以帮助你模拟界面。一个不错的开源工具是Pencil Project。你可以在
pencil.evolus.vn/
找到更多关于它的信息。Figma(www.figma.com/
)也是一个很好的原型设计工具,并且他们提供了一套免费的启动包。 -
用例:如果您需要详细的文档,可以使用统一建模语言(UML)用例模型。该模型由详细规范和图表组成。Lucidchart([
www.lucidchart.com/
](https://www.lucidchart.com/))是另一个可以帮助您的工具。您可以在图 1.8中看到创建的模型:
图 1.8:用例图示例
当您分析系统需求时,您将能够确切地了解用户的需求。当您不确定需要解决的真正问题时,这很有帮助,而且比开始编程系统并寄希望于最好的结果要好得多。在需求分析上投入的时间是以后编写更好代码的时间。
编写规范
在您完成分析后,将注册为规范非常重要。规范文档可以使用传统的需求或用户故事编写,后者在敏捷项目中常用。
需求规范代表了用户和团队之间的技术合同。这份文档需要遵循一些基本规则:
-
所有利益相关者都需要确切了解技术合同中写的内容,即使他们不是技术人员。
-
文档需要清晰。
-
您需要分类每个需求。
-
使用简单将来时来表示每个需求:
-
不良示例:一个普通用户自行注册。
-
良好示例:一个普通用户应自行注册。
-
-
避免歧义和争议。
-
一些额外的信息可以帮助团队理解他们将要工作的项目背景。以下是一些关于如何添加有用信息的提示:
-
编写一个介绍章节,以全面了解解决方案。
-
创建一个术语表以简化理解。
-
描述该解决方案将覆盖的用户类型。
-
-
编写功能性和非功能性需求:
功能性需求很容易理解,因为它们确切地描述了软件将做什么。另一方面,非功能性需求决定了与软件相关的限制,这意味着可扩展性、健壮性、安全性和性能。我们将在下一节中介绍这些方面。
-
附加有助于用户理解规则的文档。
如果您决定编写用户故事,一个很好的建议是写简短的句子,代表系统中的每个时刻以及每个用户,如下所示:
作为<用户>,我想要<功能>,以便<原因>
这种方法将确切地解释为什么将实现该功能。它也是帮助您分析最重要的故事并优先考虑项目成功的良好工具。它们还可以用于告知应构建的自动化验收测试。
理解可扩展性、健壮性、安全性和性能的原则
识别需求是一项任务,将帮助你理解你将要开发的软件。然而,作为软件架构师,你必须注意的不仅仅是该系统的功能性需求。理解非功能性需求很重要,这是软件架构师最早的活动之一。
我们将在第二章“非功能性需求”中更详细地探讨这个问题,但在此阶段,重要的是要知道可扩展性、健壮性、安全性和性能的原则需要应用于需求收集过程。让我们看看每个概念:
-
可扩展性:互联网为你提供了一个拥有全球大量用户的解决方案的机会。这是非常棒的,但作为软件架构师,你需要设计一个能够提供这种可能性的解决方案。可扩展性是指应用程序在必要时能够增加其处理能力,这是由于正在消耗的资源数量。
-
健壮性:无论你的应用程序有多大的可扩展性,如果它不能保证一个稳定且始终在线的解决方案,你将无法得到任何安宁。健壮性对于关键解决方案非常重要,在这些解决方案中,由于应用程序解决的问题类型,你无法在任何时候进行维护。在许多行业中,软件不能停止,当没有人可用时(如夜间、节假日等),会有很多程序运行。设计一个可靠的解决方案将使你能够在软件平稳运行的同时享受生活。
-
安全性:这是在需求阶段之后需要讨论的另一个非常重要的领域。每个人都担心安全问题,世界上不同地区的不同法律都在处理这个问题。作为软件架构师,你必须理解安全性需要通过设计来提供。这是应对目前安全社区讨论的所有需求的唯一方法。
-
性能:理解你将要开发的系统的过程可能会给你一个很好的想法,了解你需要做些什么才能从系统中获得期望的性能。这个话题需要与用户讨论,以确定你在开发阶段将面临的大部分瓶颈。
值得注意的是,所有这些概念都是世界所需的新一代解决方案的要求。区分优秀软件和卓越软件的是满足项目要求所付出的工作量。
审查规格说明
一旦你写好了规格说明,就需要与利益相关者确认他们是否同意。这可以通过审查会议来完成,或者可以使用协作工具在线完成。
这时,你需要展示你所收集的所有原型、文档和信息。一旦所有人都同意规格说明,你就可以开始研究实施项目这一部分的最佳方式了。
值得注意的是,你可以使用这里描述的过程来处理整个软件或其一部分。
使用设计技术作为有用的工具
定义一个解决方案并不容易。确定要使用哪种技术也很困难。确实,在你作为软件架构师的职业生涯中,你将发现许多项目,你的客户将带来一个准备开发的解决方案。如果你认为这个解决方案是正确的解决方案,那么这可能会变得相当复杂;大多数情况下,将会有架构和功能错误,这将在未来导致解决方案中的问题。
有一些情况下问题更为严重——当客户不知道问题的最佳解决方案时。一些设计技术可以帮助我们,我们在这里将介绍其中的两种:设计思维和设计冲刺。
你必须理解的是,这些技术可以是一个发现真实需求的绝佳选择。作为一名软件架构师,你致力于帮助你的团队在正确的时间使用正确的工具,这些工具可能是确保项目成功的正确选择。
设计思维
设计思维是一个允许你直接从用户收集数据的过程,专注于实现最佳结果以解决问题。在这个过程中,团队将有机会发现所有将与系统互动的角色。这将对解决方案产生美妙的影响,因为你可以通过关注用户体验来开发软件,这可以产生惊人的结果。
该过程基于以下步骤:
-
同理心: 在这一步,你必须执行实地研究以发现用户的需求。这是你了解系统用户的地方。这个过程有助于你理解为什么以及为谁开发这款软件。
-
定义: 一旦你了解了用户的需求,就是时候定义他们的需求以解决问题了。
-
构思: 需求将提供机会来头脑风暴一些可能的解决方案。
-
原型: 这些解决方案可以作为原型来开发,以确认它们是否是好的解决方案。
-
测试: 测试原型将帮助你理解与用户真实需求最相关的原型。
这种技术的重点是加速辨别正确产品和考虑最小可行产品(MVP)的过程。当然,原型过程将帮助利益相关者理解最终产品,同时让团队参与提供最佳解决方案。
设计冲刺
设计冲刺是一个在五天冲刺中通过设计解决关键业务问题的过程。这项技术由谷歌提出,它允许你在寻找构建和推出市场解决方案时快速测试并从想法中学习。
该过程涉及专家花费一周时间解决手头的问题,在一个为此目的准备的作战室里。这一周看起来是这样的:
-
星期一:这一天的重点是确定冲刺的目标,并将挑战映射到实现目标上。
-
星期二:在理解了冲刺目标后,参与者开始绘制可能解决问题的解决方案草图。现在是时候找到客户来测试即将提供的新解决方案了。
-
星期三:这是团队需要决定最有可能解决问题的解决方案的时候。团队必须将这些解决方案绘制成故事板,为原型准备计划。
-
星期四:现在是时候将故事板上计划的想法原型化了。
-
星期五:在完成原型后,团队向客户展示它,通过获取他们对设计的反应来学习。
正如你所见,在这两种技术中,从客户那里收集反应的加速来自于将你的团队想法具体化为对最终用户更直观的东西的原型。
常见的情况,需求收集过程会影响系统结果
本章中讨论的所有信息,如果你想要按照良好的工程原则设计软件,都是非常有用的。我们并不主张传统的或敏捷的开发方法,而是强调以专业的方式构建软件。
了解一些案例也是好的,在这些案例中,未能执行你所阅读的活动可能会给软件项目带来一些麻烦。以下案例旨在描述可能出错的情况,以及前述技术如何帮助开发团队解决相关的问题。
在大多数情况下,非常简单的行动可以保证团队和客户之间更好的沟通,这种简单的沟通流程可以将大问题转化为真正的解决方案。让我们考察三个常见的案例,这些案例中需求收集可能会影响软件性能、功能和可用性。
案例 1 – 我的网站打开那个页面太慢了!
性能是你作为软件架构师在职业生涯中将要处理的最大问题之一。任何软件的这一方面之所以如此有问题,是因为我们没有无限的计算资源来解决问题。计算成本仍然很高,尤其是如果你在谈论具有大量同时用户的软件。
你不能通过编写需求来解决性能问题。然而,如果你正确地编写它们,你也不会陷入麻烦。这里的想法是,需求必须展示系统的预期性能。一个简单的句子可以帮助整个项目团队(用户、测试人员、开发人员、架构师、经理等):
非功能性需求:性能 – 任何该软件的网页都应在至少 2 秒内响应,即使有 1,000 个用户同时访问它。
前面的句子只是让每个人(用户、测试人员、开发人员、架构师、经理等)知道任何网页都有一个目标要实现。这是一个好的开始,但还不够。一个良好的开发和部署应用程序的环境也很重要。这正是 .NET 8 可以大量帮助你的地方;特别是如果你在谈论 Web 应用程序,ASP.NET Core 被认为是当今提供解决方案最快的选项之一。
在谈到性能时,作为软件架构师,你应该考虑使用以下章节中列出的技术,并结合特定的测试来保证这一非功能性需求。还需要提到的是,ASP.NET Core 将帮助你轻松使用它们,以及一些由微软 Azure 提供的 平台即服务(PaaS)解决方案。
理解后端缓存
缓存是一种避免耗时和冗余查询的绝佳技术。例如,如果你是从数据库中获取汽车型号,数据库中的汽车数量可能会增加,但型号本身不会改变。一旦你有一个不断访问汽车型号的应用程序,一个好的做法是将这些信息缓存起来。
理解这一点很重要:缓存存储在后端,并且该缓存由整个应用程序共享(内存缓存)。需要注意的是,当你在开发可扩展的解决方案时,你可以使用 Azure 平台配置一个 分布式缓存。实际上,ASP.NET 提供了内存缓存和分布式缓存,因此你可以选择最适合你需求的一种。第二章,非功能性需求,涵盖了 Azure 平台的可扩展性方面。
还需要提到的是,缓存也可能发生在前端、通往服务器的代理、CDN 等地方。
应用异步编程
当你开发 ASP.NET 应用程序时,你需要记住你的应用程序需要设计为能够被许多用户同时访问。异步编程通过提供 async
和 await
关键字,让你可以简单地做到这一点。
这些关键字背后的基本概念是,async
允许任何方法异步运行。另一方面,await
允许你在不阻塞调用它的线程的情况下同步调用异步方法。这种易于开发的模式将使你的应用程序运行得更加流畅,没有性能瓶颈,并带来更好的响应性。本书将在 第二章,非功能性需求 中更详细地介绍这一主题。
处理对象分配
避免性能不佳的一个非常好的建议是了解垃圾回收器(GC)的工作原理。GC 是当你完成使用它时自动释放内存的引擎。由于 GC 的复杂性,这个主题有一些非常重要的方面。
如果你没有正确处理这些对象,某些类型的对象不会被 GC 收集。这些对象包括与 I/O 交互的对象,例如文件和流。如果你没有正确使用 C# 语法来创建和销毁这类对象,你将会有内存泄漏,这会降低应用程序的性能。
与 I/O 对象交互的不正确方式是:
System.IO.StreamWriter file = new System.IO.StreamWriter(@"C:\sample.txt");
file.WriteLine("Just writing a simple line");
与 I/O 对象交互的正确方式是:
using System.IO.StreamWriter file = new System.IO.StreamWriter(@"C:\sample.txt");
file.WriteLine("Just writing a simple line");
值得注意的是,这种正确的方法也确保文件被正确写入(它调用 FileStream.Flush()
来优雅地释放其资源)。在不正确的示例中,内容甚至可能没有被写入文件。尽管前面的做法对于 I/O 对象是强制性的,但强烈建议你在所有可处置对象上继续这样做。实际上,在你的解决方案中使用代码分析器,将警告视为错误,将防止你意外犯下这些错误!这将有助于 GC,并确保你的应用程序以正确的内存量运行。根据对象类型,这里的错误可能会累积,最终可能导致更大规模的不良后果,例如端口/连接耗尽。
你需要了解的另一个重要方面是,GC 收集对象所花费的时间将干扰应用程序的性能。因此,避免分配大对象,并小心处理事件处理和弱引用;否则,它可能导致你总是等待 GC 完成其任务。
提高数据库访问效率
最常见的性能阿基里斯之踵之一是数据库访问。这仍然是一个大问题,原因是在编写查询或 lambda 表达式从数据库获取信息时缺乏关注。本书将在 第十三章,在 C# 中与数据交互 – Entity Framework Core 中介绍 Entity Framework Core,但了解选择什么以及从数据库中读取正确的数据信息非常重要。对于想要提高性能的应用程序来说,过滤列和行是必不可少的。
好消息是,与缓存、异步编程和对象分配相关的最佳实践完全适用于数据库环境。这只是一个选择正确模式以获得性能更好的软件的问题。
案例 2 – 用户的需求没有得到适当实现
技术在广泛领域的应用越多,交付用户所需的确切内容就越困难。也许这句话听起来有些奇怪,但你必须理解,开发者通常研究如何开发软件,但他们很少研究如何满足特定领域的需求。当然,学习如何开发软件并不容易,但理解特定领域的特定需求更加困难。如今,软件开发将软件交付给所有类型的行业。这里的问题是开发者,无论是否是软件架构师,如何才能足够进化,以交付他们负责领域的软件?
收集软件需求将帮助你在这项艰巨的任务中;编写它们将帮助你理解和组织系统的架构。有几种方法可以最小化实现与用户真正需求不符的风险:
-
通过原型设计快速理解用户界面。
-
设计数据流以检测系统与用户操作之间的差距。
-
举行频繁会议以保持对用户当前需求的最新了解,并与增量交付保持一致。
再次强调,作为一名软件架构师,你将不得不定义软件将如何实现。大多数时候,你不会是编写它的人,但你将始终是负责这一点的。因此,一些技术可以用来避免错误的实现:
-
开发者将审查需求,以确保他们理解他们需要开发的内容。
-
通过代码审查来验证预定义的代码标准。我们将在第四章,C# 编码最佳实践 12中介绍这一点。
-
举行会议以消除障碍。
记住,确保实现符合用户需求是你的责任。使用你能用到的每一个工具。
案例 3 – 系统的可用性不符合用户需求
用户体验是软件项目成功的关键。软件的展示方式和解决问题的方法将决定用户是否愿意使用它。作为一名软件架构师,你必须牢记,如今交付具有良好用户体验的软件是强制性的。
这本书不打算涵盖用户体验的基本概念,但了解谁将使用该软件是满足用户体验需求的好方法。正如本章前面所讨论的,设计思维在这方面可以帮助你很多。
理解用户将帮助你决定软件是否将在网页、手机上运行,甚至是在后台。这种理解对软件架构师来说非常重要,因为如果你正确地映射了谁将使用这些元素,系统的元素将得到更好的展示。
另一方面,如果你不关心这一点,你将只会交付出能工作的软件。这可能在短期内是好的,但它不会完全满足让人请你设计软件的真实需求。你必须牢记这些选项,并理解好的软件是设计在许多平台和设备上运行的软件。
你会很高兴地知道.NET 8 是一个令人难以置信的跨平台选项。因此,你可以开发在 Linux、Windows、Android 和 iOS 上运行的应用程序解决方案。你可以在大屏幕、平板电脑、手机甚至无人机上运行你的应用程序!你可以在自动化板或 HoloLens 的混合现实设备上嵌入应用程序。软件架构师必须思想开放,以设计出用户真正需要的解决方案。
摘要
在本章中,你学习了软件架构师在软件开发团队中的目的。本章还涵盖了软件开发过程模型的基础和需求收集过程。你还有机会了解如何创建你的 Azure 账户,该账户将在本书的案例研究中使用。此外,你还学习了功能性和非功能性需求以及如何使用用户故事来创建它们。这些技术将帮助你交付更好的软件项目。
在下一章中,你将有机会了解功能性和非功能性需求对软件架构的重要性。
问题
-
软件架构师需要具备哪些专业知识?
-
Azure 如何帮助软件架构师?
-
软件架构师如何决定在项目中使用哪种最佳软件开发过程模型?
-
软件架构师如何贡献于需求收集?
-
软件架构师在需求规范中需要检查哪些类型的需求?
-
设计思维和设计冲刺如何帮助软件架构师在收集需求的过程中?
-
用户故事如何帮助软件架构师在编写需求的过程中?
-
开发高性能软件有哪些好的技术?
-
软件架构师如何检查用户需求是否正确实现?
进一步阅读
在这里,我们列出了一些你可能考虑使用的书籍和链接,以获取有关本章涵盖主题的更多信息。
-
关于 Azure 的信息,请查看以下内容:
-
更多关于.NET 8 的信息可以在这里找到:
-
软件开发过程模型链接列在这里:
-
在这里您可以找到 SAFe®信息:
在 Discord 上了解更多信息
要加入这本书的 Discord 社区——您可以在这里分享反馈、向作者提问,并了解新版本——请扫描下面的二维码:
packt.link/SoftwareArchitectureCSharp12Dotnet8
第二章:非功能性需求
一旦收集了系统需求,就需要考虑它们对架构设计的影响。可扩展性、可用性、弹性、性能、多线程、互操作性、安全性和其他方面都需要进行分析,以便我们能够满足用户需求。我们将这些方面称为非功能性需求。
本章我们将涵盖以下主题:
-
使用 Azure 和 .NET 8 实现可扩展性、可用性和弹性
-
在 C# 编程时需要考虑的性能问题
-
软件可用性:如何设计有效的用户界面
-
与 .NET 8 的互操作性
-
通过设计实现安全性
讨论非功能性要求的主要目的是,它们与软件架构师高度相关:尽管它们在功能方面对软件工作的实现并不那么重要,但它们在比较优秀软件和劣质软件时可以产生很大的差异。
技术要求
本章提供的示例需要安装了 .NET 8 SDK 的 Visual Studio 2022 Community Edition。
你可以在 github.com/PacktPublishing/Software-Architecture-with-C-Sharp-12-and-.NET-8-4E
找到本章的示例代码。
使用 Azure 和 .NET 8 实现可扩展性、可用性和弹性
在线搜索“可扩展性”的定义,返回的结果类似于“系统在需求增加时保持良好工作状态的能力。”当开发者根据这个定义行事时,许多人错误地得出结论,认为可扩展性仅仅意味着添加更多硬件以保持事物运行而不停止他们的应用程序。
可扩展性在一定程度上依赖于硬件解决方案。然而,作为一名软件架构师,你需要意识到,优秀的软件将保持可扩展性在一个可持续的模式中,这意味着良好的架构软件可以节省大量资金。因此,可扩展性不仅仅是硬件问题,也是整体软件设计的问题。这里的要点是,系统的运行成本也应该在架构决策中作为一个因素考虑。
在 第一章 理解软件架构的重要性 中,当我们讨论软件性能时,我们提出了一些克服性能问题的好建议。同样的建议也会帮助你实现可扩展性。我们投入每个过程中的资源越少,应用程序就能处理更多的用户。
虽然可扩展性很重要,但云计算应用程序必须设计成能够在系统故障的情况下工作。每次你确保你的应用程序能够在不向最终用户暴露故障的情况下从故障中恢复,你就是在创建一个具有弹性的应用程序。
您可以在docs.microsoft.com/en-us/azure/architecture/framework/resiliency/reliability-patterns#resiliency
找到云架构弹性模式。
在云场景中,弹性之所以特别重要,是因为提供给您的基础设施可能需要一点时间来管理更新、重置甚至硬件升级。您也更可能需要与多个系统协作,并且在与它们通信时可能会出现暂时性错误。这就是为什么弹性这一非功能性需求在近年来得到了更高的关注。
当您能够在系统中启用高可用性时,拥有可扩展和弹性解决方案的可能性变得更加令人兴奋。本书中提出的所有方法都将帮助您设计具有良好可用性的解决方案,但在某些情况下,您将需要设计特定的替代方案以实现您的特定目标。
值得注意的是,Azure 和.NET 8 Web 应用可以配置以实现这些非功能性需求。让我们在接下来的小节中查看这些内容。
在 Azure 中创建可扩展的 Web 应用
在 Azure 中创建一个可扩展的 Web 应用很简单。您必须这样做的原因是能够在不同季节维护不同数量的用户。用户越多,您需要的硬件就越多。让我们向您展示如何在 Azure 中创建一个可扩展的 Web 应用。
一旦您登录到您的 Azure 账户,您将能够创建新的资源(Web 应用、数据库、虚拟机等),如下面的截图所示:
图 2.1:Microsoft Azure – 创建资源
然后,您可以选择常用选项中的 App 服务,或者甚至将其输入到在市场搜索文本框中。然后,您可以选择创建Web 应用。此操作将带您进入以下屏幕:
图 2.2:Microsoft Azure – 创建 Web 应用
所需的项目详情如下:
-
订阅:这是将收取所有应用程序费用的账户。
-
资源组:这是您可以定义以组织策略和权限的资源集合。您可以选择指定新的资源组名称或将 Web 应用添加到在定义其他资源时指定的组中。
除了这些,实例详情如下:
-
名称:正如您所看到的,Web 应用的名称是在创建后您的解决方案将采用的 URL。名称必须是全局唯一的,并且会进行检查以确保其可用性。
-
发布:此参数指示 Web 应用是否直接交付、静态 Web 应用,还是将使用 Docker 技术发布内容。Docker 将在第十一章“将微服务架构应用于您的企业应用”中更详细地讨论。如果您选择 Docker 容器发布,您将能够配置镜像源、访问类型和镜像以及标签信息,以便将其部署到 Web 应用中。
-
运行时堆栈:此选项仅在您决定直接交付代码时才可用。您可以定义.NET、Go、Java、Node.js、PHP、Python 和 Ruby 的堆栈。
-
操作系统:这是定义将托管 Web 应用的 OS 的选项。在最新版本中,Windows 和 Linux 都可以用于.NET 项目。
-
区域:您可以考虑将应用程序部署到何处;Azure 在全球有多个不同的数据中心。
-
定价计划:这是您定义用于处理 Web 应用的服务器硬件和区域的地方。此选择定义了应用程序的可扩展性、性能和成本。
-
区域冗余:从高级定价计划开始,您将能够激活区域冗余,这将提高解决方案的可用性。
-
部署:您可以定义负责持续部署应用的 GitHub 仓库。
-
网络:您可以根据应用程序的要求和提案选择应用程序的网络行为。
-
监控:这是一个用于监控和故障排除 Web 应用的 Azure 工具集。在本节中,您可以启用应用程序洞察。始终建议您为解决方案的不同组件保持相同的区域,因为这将在数据中心之间流量交换方面节省成本。
一旦您创建了您的 Web 应用,此应用可以通过两种概念上不同的方式进行扩展:垂直扩展(向上扩展)和水平扩展(向外扩展)。这两种扩展方式都在 Web 应用设置中提供,如下面的截图所示:
图 2.3:Web 应用的扩展选项
让我们来看看两种扩展类型。
垂直扩展(向上扩展)
向上扩展意味着更改将托管您的应用程序的硬件规格。在 Azure 中,您有机会从免费、共享的硬件开始,只需几点击即可迁移到隔离的机器。以下截图显示了向上扩展 Web 应用的用户界面:
图 2.4:垂直扩展选项
通过选择提供的选项之一,您可以选择更强大的硬件(具有更多 CPU、存储和 RAM 的机器)。监控您的应用程序及其 App Service 计划将指导您如何决定运行解决方案的最佳基础设施。它还将提供关键见解,例如可能的 CPU、内存和 I/O 瓶颈。
水平扩展(扩展)
扩展意味着在更多服务器之间分配请求,并使用相同的容量,而不是使用更强大的机器。所有服务器的负载将由 Azure 基础设施自动平衡。当整体负载可能在将来发生显著变化时,建议采用这种解决方案,因为水平扩展可以自动适应给定的负载。以下截图显示了由两个简单的规则定义的自动 扩展 策略,这些规则由 CPU 使用情况触发:
图 2.5:水平扩展示例
值得强调的是,你可以选择使用硬编码的实例计数或实现自动扩展/缩小的规则。
所有可用的自动扩展规则的完整描述超出了本书的范围。然而,它们相当直观,进一步阅读部分包含指向完整文档的链接。
扩展功能仅在付费服务计划中可用。
通常,水平扩展是一种即使在多个同时访问的情况下也能保证应用程序可用性的方法。当然,它的使用并不是保持系统可用的唯一方法,但它确实有帮助。
使用 .NET 8 创建可扩展的 Web 应用
在所有用于实现 Web 应用的框架中,使用 .NET 8 中的 ASP.NET Core 运行时运行 Web 应用确保了良好的性能,同时生产和维护成本较低。C#(一种强类型和高级通用语言)与在 ASP.NET Core 中实现的持续性能改进的结合,使这一选项成为企业开发中最佳选择之一。
本节中的步骤将指导你创建一个基于 ASP.NET Core Runtime 8 的 Web 应用。所有步骤都非常简单,但一些细节需要特别注意。
值得注意的是,.NET 8 给你提供了为任何平台开发的机会——桌面(WPF、Windows Forms 和 UWP)、Web(ASP.NET)、云(Azure)、移动(Xamarin)、游戏(Unity)、物联网(ARM32 和 ARM64)或人工智能(ML.NET 和 .NET for Apache Spark)。因此,从现在起,建议只使用 .NET 8。在这种情况下,你可以将你的 Web 应用运行在 Windows 服务器或更便宜的 Linux 服务器上。
现在,Microsoft 推荐使用经典 .NET,以防所需的特性在 .NET Core/5+ 中不可用,或者你正在将你的 Web 应用部署到一个不支持 .NET Core 的环境中。在任何其他情况下,你应该优先选择 .NET Core/5+,因为它允许你做以下事情:
-
在 Windows、Linux、macOS 或 Docker 容器中运行你的 Web 应用
-
使用微服务设计你的解决方案
-
拥有高性能和可扩展的系统
容器和微服务将在第十一章,将微服务架构应用于您的企业应用程序中介绍。在那里,您将更好地了解这些技术的优势。目前,只需说 .NET 8 和微服务是为了性能和可扩展性而设计的就足够了,这就是为什么您应该在所有新项目中首选 .NET 8。此外,.NET 8 由微软保证为长期支持版本,这意味着三年内提供补丁和免费支持。
图 2.6:.NET 8 支持策略
以下过程将向您展示如何在 Visual Studio 2022 中使用 .NET 8 创建 ASP.NET Core Web 应用程序:
-
当您启动 VS 2022 时,您将能够点击创建新项目。
-
一旦您选择了ASP.NET Core Web 应用程序,您将被引导到一个屏幕,您将需要设置项目名称、位置和解决方案名称:
图 2.7:创建 ASP.NET Core Web 应用程序
-
之后,您将能够选择要使用的 .NET 版本。选择.NET 8.0以获得最先进和最新的平台。
-
点击创建以创建您的 ASP.NET Core 8 Web 应用程序。
-
现在您已经添加了基本详情,您可以将您的 Web 应用程序项目连接到您的 Azure 账户并发布它。
-
如果您在解决方案资源管理器中右键单击创建的项目,您有发布的选项。
-
您将找到不同的目标,用于确定发布您的 Web 应用程序的位置。选择Azure作为目标:
图 2.8:将应用程序发布到 Azure 的目标
-
然后,您将能够决定具体的发布目标。选择此演示的Azure App Service (Windows)。
-
您可能需要在此处定义您的 Microsoft 账户凭据。这是因为 Visual Studio 和 Azure 之间有完全集成。这为您提供了一个机会,在您的开发环境中查看 Azure 门户中创建的所有资源。
图 2.9:Visual Studio 和 Azure 之间的集成
- 如果您想使用 Visual Studio 创建新的 Web 应用程序,请确保在 App Service 创建过程中选择定价的免费大小层,这样就不会产生任何费用:
图 2.10:创建新的托管计划
-
使用 Visual Studio 部署的常用方法是选择发布配置文件,这将生成一个
.pubxml
文件,这是一个 Visual Studio 发布配置文件。在这种情况下,您目前有两种部署模式。第一个,框架依赖型,将需要一个配置了目标框架的 Web 应用程序。第二个,自包含型,由于框架的二进制文件将与应用程序一起发布,因此不需要此功能。一旦文件创建并选择了选项,您只需点击发布按钮,过程就会开始:
图 2.11:发布配置文件 Web 部署
-
值得注意的是,为了以框架依赖模式发布 ASP.NET 预览版本,你必须在 Azure 门户中的 Web 应用设置面板中添加一个扩展,如图下所示。然而,考虑到你正在使用预览版本和 Windows 应用,建议使用自包含模式:
图 2.12:在 Azure App Service 中添加扩展
关于将 ASP.NET Core 8.0 部署到 Azure App Service 的更多信息,请参阅以下链接:learn.microsoft.com/en-us/aspnet/core/host-and-deploy/azure-apps/?view=aspnetcore-8.0&tabs=visual-studio
。
虽然使用 Visual Studio 2022 发布可能被认为是演示的好选择,但在现实世界中,几乎不可能保持使用它的发布策略。因此,你可能考虑使用基于 GitHub Actions 的 CI/CD 流程,该流程会自动在推送到 GitHub 仓库的代码上启用部署。值得注意的是,你必须连接到 GitHub 仓库才能访问这个新功能。让我们使用这个新功能进行演示。我们将在第八章,理解 DevOps 原则和 CI/CD 中更深入地讨论它。
图 2.13:使用 GitHub actions 部署 Web 应用
-
一旦你推送了你的代码,你可以转到 GitHub Actions 面板并选择你想要部署 Web 应用的方式。
-
对于这个第二个演示,你可以选择将.NET Core 应用部署到 Azure Web 应用。使用此选项,将创建一个包含连接你的代码到 Web 应用所需所有指令的 YAML 文件!
图 2.14:用于部署应用程序的 YAML 文件
根据你想要部署的 Web 应用,你可能需要不同的脚本。这些脚本在
github.com/Azure/webapps-deploy
上有文档说明。 -
一旦你设置了正确的脚本,你将能够检查 GitHub Action 的执行情况:
图 2.15:GitHub Actions 标签页
在这里,我们描述了两种部署 Web 应用的方法。在第八章,理解 DevOps 原则和 CI/CD*中,我们将进一步探讨持续集成/持续交付(CI/CD)策略,以确保将应用程序部署到生产所需的全部步骤,即构建、测试、部署到预发布环境和部署到生产环境。
既然你已经学会了一种让 Web 应用在 Azure 上运行的好方法,使用 Visual Studio 作为有用的工具,了解一些可能导致创建解决方案时遇到困难的功能问题就变得至关重要。
在 C#编程时需要考虑的性能问题
现在,C# 是世界上使用最广泛的编程语言之一,因此了解 C# 编程的最佳实践对于设计满足最常见非功能性要求的好架构是基本的。
以下几节将提到一些简单但有效的技巧——相关的代码示例可在本书的 GitHub 仓库中找到。值得一提的是,.NET 基金会已经开发了一个用于基准测试的库,称为 BenchmarkDotNet。你可能发现它在你的场景中很有用。请查看 benchmarkdotnet.org/
。
字符串连接
这是一个经典案例!使用 +
字符串运算符进行字符串的简单连接可能会导致严重的性能问题,因为每次两个字符串连接时,它们的内 容都会被复制到一个新的字符串中。
因此,如果我们连接,例如,平均长度为 100 的 10 个字符串,第一次操作的成本为 200,第二次操作的成本为 200+100=300
,第三次操作的成本为 300+100=400
,依此类推。不难相信,总成本随着 m*n
² 增长,其中 n
是字符串的数量,m
是它们的平均长度。对于小的 n
(例如,n < 10
),n
² 不会太大,但当 n
达到 100-1,000 的量级时,它就变得相当大了,对于 10,000-100,000 的量级则不可接受。
让我们通过一些测试代码来看看这一点,这些代码比较了简单的连接操作与使用 StringBuilder
类(代码可在本书的 GitHub 仓库中找到)执行相同操作的情况:
图 2.16:连接测试代码结果
如果你创建一个 StringBuilder
类,例如 var sb = new System.Text.StringBuilder()
,然后使用 sb.Append(currString)
将每个字符串添加到其中,字符串不会被复制;相反,它们的指针会被排队到一个列表中。当调用 sb.ToString()
获取最终结果时,它们只会在最终字符串中复制一次。因此,基于 StringBuilder
的连接成本简单地随着 m*n
增长。
当然,你可能永远不会找到一个像前面那样的函数,它会连接 100,000 个字符串。然而,你需要认识到这些代码片段,比如在处理多个并发请求的 Web 服务器中,连接 20-100 个字符串可能会造成瓶颈,损害你的性能非功能性要求。
异常
总是记住,异常比正常代码流程慢得多!因此,try-catch
的使用需要简洁且必要;否则,你将遇到大的性能问题。
以下两个示例比较了使用 try-catch
和 Int32.TryParse
来检查字符串是否可以转换为整数的方法,如下所示:
private static string ParseIntWithTryParse()
{
string result = string.Empty;
if (int.TryParse(result, out var value))
result = value.ToString();
else
result = "There is no int value";
return $"Final result: {result}";
}
private static string ParseIntWithException()
{
string result = string.Empty;
try
{
result = Convert.ToInt32(result).ToString();
}
catch (Exception)
{
result = "There is no int value";
}
return $"Final result: {result}";
}
第二个函数看起来并不危险,但它比第一个慢数千倍:
图 2.17:异常测试代码结果
总结来说,必须使用异常来处理破坏正常控制流程的异常情况,例如,当操作因某些意外原因必须中止时,并且必须将控制权返回到调用堆栈的几个级别。
多线程环境以获得更好的结果——应该做和不应该做的事情
如果你想要充分利用你正在构建的系统提供的所有硬件,你必须使用多线程。这样,当一个线程正在等待某个操作完成时,应用程序可以将 CPU 交给其他线程,而不是浪费 CPU 时间。
另一方面,无论微软如何努力帮助解决这个问题,并行代码并不像吃一块蛋糕那么简单:它容易出错,难以测试和调试。作为一个软件架构师,当你开始考虑使用线程时,最重要的事情要记住的是你的系统是否需要它们? 非功能性和一些功能性需求会为你回答这个问题。
一旦你确定你需要一个多线程系统,你应该决定哪种技术更适合。这里有几个选项,如下:
-
创建
System.Threading.Thread
的实例:这是在 C# 中创建线程的经典方式。整个线程生命周期将完全由你掌控。当你确信你要做什么时,这很好,但你需要关注实现中的每一个细节。生成的代码难以构思、调试/测试/维护。因此,为了保持开发成本在可接受范围内,这种方法应该仅限于少数基本、性能关键模块。 -
使用
System.Threading.ThreadPool
管理线程:你可以通过使用ThreadPool
类来简化这种实现。特别是如果你打算开发一个将会有许多线程被执行的解决方案,这可能是一个不错的选择。值得一提的是,.NET 线程池在 .NET 6 中已被重新实现为一个 C# 类,这将带来新的实验或定制可能性。 -
使用
System.Threading.Tasks.Parallel
类进行编程:自 .NET Framework 4.0 以来,你可以使用并行类以更简单的方式启用线程。这很好,因为你不需要担心你创建的线程的生命周期,但它会给你更少的控制权来了解每个线程中发生的事情。 -
使用异步编程进行开发:这无疑是开发多线程应用程序最容易的方式,因为编译器承担了大部分工作。根据你调用异步方法的方式,创建的
Task
可能会与调用它的Thread
并行运行,或者甚至保持该Thread
等待,而不为创建的Task
挂起。这样,异步代码模拟了经典同步代码的行为,同时保持了通用并行编程的大部分性能优势:-
总体行为是确定的,并且不依赖于每个任务完成所需的时间,因此非可复现的 bug 出现的可能性较小,生成的代码易于测试/调试/维护。将方法定义为异步任务或不是,是程序员唯一的选择;其他一切由运行时自动处理。你应该关注的是哪些方法应该具有异步行为。值得一提的是,将方法定义为
async
并不意味着它将在单独的线程上执行。你可以在一个优秀的示例中找到有用的信息:docs.microsoft.com/en-us/dotnet/csharp/programming-guide/concepts/async/
。 -
在本书的后面部分,我们将提供一些异步编程的简单示例。有关异步编程及其相关模式的信息,请参阅微软文档中的基于任务的异步模式 (
docs.microsoft.com/en-us/dotnet/standard/asynchronous-programming-patterns/task-based-asynchronous-pattern-tap
)。TAP是EAP(基于事件的异步模式)的演变,而EAP又是APM(异步编程模型模式)的后继者。
-
无论你选择哪种选项,作为软件架构师,你必须注意一些“应该做”和“不应该做”的事情。以下是一些注意事项:
-
请务必使用并发集合 (
System.Collections.Concurrent
): 一旦你开始一个多线程应用程序,你就必须使用这些集合。原因是你的程序可能会从不同的线程管理相同的列表、字典等。使用并发集合是开发线程安全程序最方便的选项。 -
不要忽视静态变量:不能说在多线程开发中禁止使用静态变量,但你应该注意它们。再次强调,多个线程处理相同的变量可能会引起很多麻烦。如果你用
[ThreadStatic]
属性装饰静态变量,每个线程都会看到该变量的不同副本,从而解决了多个线程竞争同一值的问题。然而,ThreadStatic
变量不能用于跨线程通信,因为一个线程写入的值不能被其他线程读取。在异步编程中,AsyncLocal<T>
是执行类似操作的选项。 -
在多线程实现后测试系统性能:线程让你能够充分利用你的硬件,但在某些情况下,编写不良的线程可能会浪费 CPU 时间而无所作为!类似的情况可能会导致几乎 100%的 CPU 使用率和不可接受的系统减速。在某些情况下,通过在某个线程的主循环中添加一个简单的
Thread.Sleep(1)
调用,可以减轻或解决这些问题,以防止它们浪费过多的 CPU 时间,但你需要测试这一点。这种实现的用例是一个在后台运行许多线程的 Windows 服务。 -
不要认为多线程很简单:多线程并不像某些语法实现中看起来那么简单。在编写多线程应用程序时,你应该考虑诸如用户界面的同步、线程终止和协调等问题。在许多情况下,程序只是因为多线程实现不当而停止正常工作。
-
不要忘记规划你的系统应该拥有的线程数量:这对于 32 位程序尤为重要。在任何环境中,你可以拥有的线程数量都有限制。在设计系统时,你应该考虑这一点。
-
不要忘记结束你的线程:如果你没有为每个线程提供正确的终止程序,你可能会在内存和处理泄漏方面遇到麻烦。
可扩展性、性能提示和多线程是我们用来调整机器性能的主要工具。然而,你设计的系统的有效性取决于整个处理管道的整体性能,这包括人类和机器。因此,在下一节中,我们将讨论如何设计有效的用户界面。
软件可用性:如何设计有效的用户界面
作为一名软件架构师,你无法提高人类的能力,但你可以通过设计一个有效的用户界面(UI),即确保与人类快速交互的界面来提高人机交互的性能,这反过来意味着以下内容:
-
用户界面必须易于学习,以减少目标用户学习如何操作所需的时间。如果用户界面经常更改,或者对于需要吸引尽可能多用户的公共网站来说,这个限制是基本的。
-
用户界面不得在数据插入时造成任何类型的减速;数据输入速度必须仅限于用户的打字能力,而不是由系统延迟或可以避免的额外手势。
-
今天,我们必须考虑我们解决方案的可访问性方面,因为这样做可以让我们包括更多的用户。
值得注意的是,市场上我们有用户体验专家。作为一名软件架构师,你必须决定他们在项目的成功中何时是必不可少的。以下是在设计易于学习的用户界面时的一些简单提示:
-
每个输入屏幕必须清楚地说明其目的。
-
使用用户的语言,而不是开发者的语言。
-
避免复杂化。以平均情况为设计 UI 的出发点;更复杂的情况可以通过仅在需要时出现的额外输入来处理。将复杂的屏幕拆分为更多的输入步骤。
-
使用过去的输入来理解用户的意图,并通过消息和自动用户界面更改将用户引向正确的路径,例如,级联下拉菜单。
-
错误消息不是系统给那些做错事的用户的坏评注,但它们必须解释如何插入正确的输入。
快速的用户界面源于对以下三个要求的有效解决方案:
-
输入字段必须按照它们通常填充的顺序排列,并且应该可以使用Tab或Enter键跳转到下一个输入。此外,经常保持空白的字段应放置在表单的底部。简单来说,填写表格时使用鼠标的操作应该最小化。这样,用户的手势数量保持在最低。在 Web 应用程序中,一旦决定了输入字段的最佳位置,就足够使用
tabindex
属性来定义用户使用Tab键从一个输入字段移动到下一个输入字段的正确方式。 -
系统对用户输入的反应必须尽可能快。错误消息(或信息性消息)必须在用户离开输入字段时立即出现。实现这一点的最简单方法是将大部分帮助和输入验证逻辑移动到客户端,这样系统反应就不需要通过通信线路和服务器。
-
有效的选择逻辑:选择现有项目应该尽可能简单;例如,在一份包含数千种产品的优惠中选择一个,应该可以通过几个手势完成,而且不需要记住确切的商品名称或其条形码。下一小节将分析我们可以使用的技巧来降低复杂性,以实现快速选择。
在第十九章,客户端框架:Blazor 中,我们将讨论这项微软技术如何帮助我们解决使用 C#代码在前端构建基于 Web 应用程序的挑战。
设计快速选择逻辑
当所有可能的选项的数量级在 1-50 之间时,通常的下拉菜单就足够了。例如,看看这个货币选择下拉菜单:
图 2.18:简单下拉菜单
当数量级较高但不到几千时,显示以用户输入的字符开头的所有项目名称的自动完成通常是一个不错的选择:
图 2.19:复杂下拉菜单
由于所有主要数据库都可以有效地选择以给定子串开头的字符串,因此可以通过低计算成本实现类似的解决方案。
当名称相当复杂时,在搜索用户输入的字符时,它们应该在每个项目字符串内部进行扩展。这种操作不能使用常规数据库有效地执行,需要专门的数据结构,同时我们也不能忘记在输入时可能出现的防抖动方面的问题,这可能会影响性能。
最后,当我们搜索由多个单词组成的描述时,需要更复杂的搜索模式。例如,产品描述就是这样。如果所选数据库支持全文搜索,系统可以有效地在所有描述中搜索用户输入的多个单词的出现。
然而,当描述由名称而不是常用词组成时,用户可能很难记住目标描述中包含的几个确切名称。例如,多国公司名称就是这样。在这些情况下,我们需要找到用户输入的字符最佳匹配的算法。必须在每个描述的不同位置搜索用户输入的字符串的子串。一般来说,基于索引的数据库无法有效地实现类似的算法,而需要将所有描述加载到内存中,并以某种方式对用户输入的字符串进行排序。
这类算法中最著名的算法可能是Levenshtein算法,该算法被大多数拼写检查器用来找到与用户输入的错误单词最匹配的单词。该算法最小化描述与用户输入的字符串之间的 Levenshtein 距离,即转换一个字符串为另一个字符串所需的最小字符删除和添加次数。
Levenshtein 算法效果很好,但计算成本非常高。在这里,我们使用了一种更快的算法,它适用于在描述中搜索字符出现。用户输入的字符不需要在描述中连续出现,但必须按相同的顺序出现。某些字符可能缺失。每个描述都会根据缺失的字符和用户输入的字符与其他字符出现距离的远近给予一个惩罚。
更具体地说,算法使用两个数字对每个描述进行排名:
-
用户输入的字符在描述中出现的次数:描述中包含的字符越多,其排名越高。
-
每个描述都会根据用户在描述中输入的字符出现的总距离给予一个惩罚。
以下截图显示了单词爱尔兰与用户输入的字符串ilad的排名对比:
图 2.20:Levenshtein 使用示例
出现次数为四次,而字符出现之间的总距离为三次。
一旦所有描述都被评分,它们将根据出现次数进行排序。出现次数相同的描述将根据最低的惩罚进行排序。以下是一个实现上述算法的自动完成示例:
图 2.21:Levenshtein 算法 UI 体验
完整的类代码,以及测试控制台项目,可在本书的 GitHub 仓库中找到。
从大量项目中选择
在这里,巨大并不是指存储数据所需的空间量,而是指用户记住每个项目特征的难度。当必须从超过 10,000-100,000 个项目中选择一个项目时,通过在描述中搜索字符出现来找到它的希望就渺茫了。在这里,用户必须通过一系列的分类层次结构被引导到正确的项目。
在这种情况下,需要几个用户手势才能完成单个选择。换句话说,每个选择都需要与多个输入字段进行交互。一旦确定选择不能通过单个输入字段完成,最简单的选项就是级联下拉菜单,即依赖于前一个下拉菜单中选择的值的下拉菜单链。
例如,如果用户需要在世界任何地方选择一个城镇,我们可能使用第一个下拉菜单来选择国家,一旦国家被选中,我们可能使用这个选择来填充第二个下拉菜单,其中包含所选国家的所有城镇。一个简单的例子如下:
图 2.22:级联下拉菜单示例
显然,当有大量选项时,每个下拉菜单在需要时都可以被自动完成功能所替代。
如果通过交叉多个不同的层次结构来做出正确的选择变得效率低下,那么级联下拉菜单也会变得不高效,我们需要一个筛选表单,如下所示:
图 2.23:筛选表单示例
现在,让我们了解与.NET 6 的互操作性。
与.NET 8 的互操作性
自从.NET Core 以来,微软为 C#开发者带来了将他们的软件部署到各种平台的能力。作为软件架构师,您需要关注这一点,考虑到为 Linux 和 macOS 开发是一个向客户交付新特性的绝佳机会。因此,我们需要确保性能和多平台支持,这是许多系统常见的非功能性需求。
在 Windows 上使用.NET 8 设计的控制台应用程序和 Web 应用程序几乎与 Linux 和 macOS 完全兼容。这意味着您不需要为这些平台重新构建应用程序。
微软提供了帮助您在 Linux 和 macOS 上安装.NET 的脚本。您可以在docs.microsoft.com/dotnet/core/tools/dotnet-install-script
找到它们。一旦安装了 SDK,您只需像在 Windows 上一样调用dotnet
即可。
然而,您必须意识到一些与 Linux 和 macOS 系统不完全兼容的功能。例如,这些操作系统中没有 Windows 注册表的等价物,您必须自己开发替代方案。如果需要,加密的 JSON 配置文件是一个不错的选择。
另一个重要点是,Linux 是区分大小写的,而 Windows 不是。当您处理文件时,请记住这一点。另一件重要的事情是,Linux 的路径分隔符与 Windows 的不同。您可以使用Path.PathSeparator
字段和所有其他Path
类成员来确保您的代码是跨平台的。在某些情况下,Environment.NewLine
也可能很有用。
此外,您还可以通过使用.NET 8 提供的运行时检查来适应底层操作系统,如下所示:
using System.Runtime.InteropServices;
if (RuntimeInformation.IsOSPlatform(OSPlatform.Windows))
Console.WriteLine("Here you have Windows World!");
else if(RuntimeInformation.IsOSPlatform(OSPlatform.Linux))
Console.WriteLine("Here you have Linux World!");
else if (RuntimeInformation.IsOSPlatform(OSPlatform.OSX))
Console.WriteLine("Here you have macOS World!");
提示 - 在 Linux 中创建服务
当您在 Windows 上开发时,有一些创建服务的好方法,但如果我们为 Linux 平台开发,我们如何获得相同的结果?以下脚本可以用来封装 Linux 中的命令行.NET 8 应用程序。这个想法是,这个服务就像 Windows 服务一样工作。考虑到大多数 Linux 安装都是仅命令行且无需用户登录即可运行,这可能会很有用:
-
第一步是创建一个运行命令行应用程序的文件。应用程序的名称是
app.dll
,它安装在appfolder
中。该应用程序将每 5,000 毫秒检查一次。这个服务是在 CentOS 7 系统上创建的。使用 Linux 终端,您可以输入以下内容:cat >sample.service<<EOF [Unit] Description=Your Linux Service After=network.target [Service] ExecStart=/usr/bin/dotnet $(pwd)/appfolder/app.dll 5000 Restart=on-failure [Install] WantedBy=multi-user.target EOF
-
一旦文件创建完成,你必须将服务文件复制到系统位置。之后,你必须重新加载系统并启用服务,以便在重启时重新启动:
sudo cp sample.service /lib/systemd/system sudo systemctl daemon-reload sudo systemctl enable sample
-
完成!现在,你可以使用以下命令开始、停止和检查服务。你需要在命令行应用程序中提供的整个输入如下:
# Start the service sudo systemctl start sample # View service status sudo systemctl status sample # Stop the service sudo systemctl stop sample
现在我们已经了解了一些概念,让我们学习如何在我们的用例中实现它们。
通过设计实现安全
就像我们在本书的这一部分所看到的那样,我们用于开发软件的机会和技术是令人难以置信的。如果你加上下一章中关于云计算的所有信息,你会发现机会只会增加,同时维护这个计算环境的复杂性也会增加。
作为一名软件架构师,你必须明白,这些机会伴随着许多责任。近年来,世界发生了很大的变化。21 世纪的第二个十年需要大量的技术。应用、社交媒体、工业 4.0、大数据和人工智能不再是未来的目标,而是你将在日常工作中领导和处理的当前项目。然而,当我们进入本世纪的第三个十年时,在网络安全方面需要更多的关注。
现在世界正在规范管理个人数据的公司。例如,通用数据保护条例(GDPR)不仅对欧洲领土是强制性的,而且对全世界都是强制性的;它改变了软件开发的方式。有许多与 GDPR 类似的倡议必须添加到你的技术和法规术语表中,考虑到你设计的软件将受到它们的影响。
在设计新应用程序时,设计安全必须成为你关注的领域之一。这个主题非常庞大,本书不会完全涵盖,但作为一名软件架构师,你必须理解在团队中拥有信息安全管理专家的必要性,以确保避免网络攻击并维护你设计的服务的机密性、隐私性、完整性、真实性和可用性。
当谈到保护你的 ASP.NET Core 应用程序时,值得提到的是,该框架有许多功能可以帮助你完成这项任务。例如,它包括身份验证和授权模式。在 OWASP 作弊表系列中,你可以了解许多其他的.NET 实践。
开放网络应用安全项目®(OWASP)是一个非营利性基金会,致力于提高软件的安全性。请访问owasp.org/
了解更多信息。
ASP.NET Core 还提供了帮助我们的 GDPR 功能。基本上,有一些 API 和模板可以指导你实施策略声明和 cookie 使用同意。
实现安全架构的实践列表
以下与安全相关的实践列表当然不能涵盖这个主题的全部内容。然而,这些实践将帮助您作为软件架构师,探索一些与此主题相关的解决方案。
认证
为您的 Web 应用程序定义认证方法。如今有许多认证选项可供选择,从 ASP.NET Core Identity 到外部提供者认证方法,如 Facebook 或 Google。作为软件架构师,您必须考虑应用程序的目标受众。如果您选择走这条路,使用 Azure Active Directory(AD)作为起点也是一个不错的选择。
您可能会发现设计与 Azure AD 相关的认证很有用,这是一个用于管理您所在公司 Active Directory 的组件。在某些场景下,这种替代方案相当不错,尤其是对于内部使用。Azure 目前提供 Active Directory 用于B2B(企业对企业)或B2C(企业对消费者)的使用。
根据您构建的解决方案的场景,您可能需要实现多因素认证(MFA)。这种模式的想法是在允许解决方案使用之前,至少要求用户提供两种身份证明形式。值得一提的是,Azure AD 为您简化了这一过程。
您可能会发现使用Microsoft 身份平台作为基础来实现认证到您的平台很有用。在这种情况下,使用Microsoft 认证库(MSAL)将极大地简化您的工作。通过阅读其文档了解如何实现,请参阅learn.microsoft.com/en-us/azure/active-directory/develop/msal-overview
。
不要忘记您必须为提供的 API 确定认证方法。JSON Web Token 是一个相当好的模式,并且其使用是完全跨平台的。
您必须确定在您的 Web 应用程序中将使用的授权模型。有四种模型选项:
-
简单的,其中您只需在类或方法中使用
[Authorize]
属性。 -
基于角色的,其中您可以声明访问您正在开发的
Controller
的Roles
。 -
基于声明的,其中您可以在认证过程中定义必须接收的值,以指示用户已授权。
-
基于策略的,其中在该
Controller
中已建立策略来定义访问权限。
您还可以通过定义[AllowAnonymous]
属性,将类中的控制器或方法定义为对任何用户完全可访问。请确保这种实现不会导致您正在设计的系统中出现任何漏洞。
您决定使用的模型将精确定义每个用户在应用程序中能做什么。
敏感数据
在设计过程中,作为软件架构师,你将不得不决定存储的数据中哪些部分是敏感的,并且需要对其进行保护。通过连接到 Azure,你的 Web 应用将能够在 Azure 存储和 Azure 密钥保管库等组件中存储受保护的数据。Azure 中的存储将在第十二章,选择您的云数据存储中进行讨论。
强烈建议检查你的解决方案将需要处理的数据保护框架,考虑到它将被放置的位置。
值得注意的是,Azure 密钥保管库用于保护你的应用程序可能拥有的机密。当你有这种需求时,请考虑使用此解决方案。
网络安全
在没有启用 HTTPS 协议的情况下部署生产解决方案是完全不可接受的。Azure Web 应用和 ASP.NET Core 解决方案提供了各种选项,不仅可以使用,还可以强制执行此安全协议的使用。
有许多已知的攻击和恶意模式,例如跨站请求伪造、开放重定向和跨站脚本。ASP.NET Core 提供了 API 来解决这些问题。你需要找到对你解决方案有用的那些。
良好的编程实践,例如通过在查询中使用参数来避免 SQL 注入,是实现另一个重要目标的方法。
你可以在docs.microsoft.com/en-us/azure/architecture/patterns/category/security
找到云架构安全模式。
最后,值得一提的是,安全性需要使用洋葱方法来处理,这意味着需要实施许多安全层。你必须已经确定了一项政策,以确保有一个访问数据的流程,包括对使用你正在开发的系统的个人进行物理访问。此外,你还必须开发灾难恢复解决方案,以防系统遭到攻击。灾难恢复解决方案将取决于你的云解决方案。我们将在第十章,决定最佳云解决方案中对此进行讨论。
摘要
描述系统行为的职能需求必须与非职能需求相辅相成,这些非职能需求限制了系统的性能、可伸缩性、可用性、弹性、互操作性、可用性和安全性。
性能需求来源于响应时间和系统负载需求。作为一名软件架构师,你应该确保以最低的成本获得所需性能,构建高效的算法,并充分利用可用的硬件资源,通过多线程实现。
可伸缩性是指系统适应增加负载的能力。系统可以通过提供更强大的硬件进行垂直扩展,或者通过复制和负载均衡相同的硬件进行水平扩展,从而提高可用性。通常来说,云和 Azure 可以帮助我们动态地实施策略,无需停止你的应用程序。
在多个平台上运行的工具,如 .NET 8,可以确保互操作性,即你的软件能够在不同的目标机器和不同的操作系统(Windows、Linux、macOS、Android 等)上运行。
通过注意输入字段顺序、项目选择逻辑的有效性以及系统学习的难易程度,可以确保可用性。
此外,你的解决方案越复杂,它应该具有更好的弹性。弹性的概念不是保证解决方案不会失败,而是保证当软件的每个部分失败时,解决方案都有定义好的行动。
作为软件架构师,你必须从设计的开始就考虑安全性。遵循指南来确定正确的模式,并在团队中拥有安全专家是遵守所有当前法规的最佳方式。
在下一章中,你将学习 Azure DevOps 和 GitHub 如何帮助我们收集、定义和记录我们的需求。
问题
-
规模化系统的两种概念方式是什么?
-
你能否从 Visual Studio 自动部署你的 Web 应用到 Azure?
-
多线程有什么用途?
-
异步模式相较于其他多线程技术的主要优势是什么?
-
为什么输入字段的顺序如此重要?
-
为什么
Path
类对于互操作性如此重要? -
.NET 标准类库的优势是什么?
-
列出最常用的 .NET Visual Studio 项目类型。
进一步阅读
以下是一些你可能想要阅读的书籍和链接,以获取更多与本章相关的内容:
-
云计算模型
-
并行编程
-
.NET 性能改进
-
安全方面
-
服务一致性方面
-
Dotnet 支持
在 Discord 上了解更多
要加入本书的 Discord 社区——在那里您可以分享反馈、向作者提问,并了解新版本发布——请扫描下面的二维码:
packt.link/SoftwareArchitectureCSharp12Dotnet8
第三章:管理需求
根据 第一章 和 第二章 中所涵盖的内容,软件开发的第一步将指导您创建一个软件项目。当涉及到软件项目时,最大的挑战是如何以让每个参与者都能理解所需的方式组织它们。最好的办法是组织其需求。但不仅如此,将这些需求与代码仓库连接起来将为每个人提供一个快速且更协作的项目视图,该项目正在被工作。多年来,Microsoft 投资于帮助我们完成这项工作的工具。Team Foundation Server 和 Visual Studio Team Services 就是其中的例子。今天,我们有两种很好的方法可以在此章节中讨论:Azure DevOps 和 GitHub。Azure DevOps 是 Visual Studio Team Services 的发展,它提供了一系列新功能,可以帮助开发者文档化和组织他们的软件。GitHub 以在线代码仓库而闻名,但自从 Microsoft 收购它以来,许多用于应用程序生命周期管理的优秀工具已经集成其中。因此,我们现在可以找到许多不同的使用 GitHub 的方法。
本章将涵盖以下主题:
-
使用您的 Azure 账户创建 DevOps 项目
-
理解 Azure DevOps 和 GitHub 提供的功能以组织您的项目
-
使用 Azure DevOps 和 GitHub 组织和管理需求
本章的前两节总结了这些工具提供的所有功能,而其余部分则专注于文档化需求和支持整体开发流程的工具。第一和第二节中介绍的大部分功能将在其他章节中更详细地分析。
技术要求
本章要求您创建一个新的免费 Azure 账户或使用现有的账户。在 第一章,理解软件架构的重要性 中的 创建 Azure 账户 部分解释了如何创建账户。
介绍 Azure DevOps
Azure DevOps 是一个 Microsoft 软件即服务 (SaaS) 平台,它使您能够向客户持续交付价值。通过在那里创建账户,您将能够轻松地规划项目、安全地存储代码、测试它、将解决方案发布到预发布环境,然后将其发布到实际的生产基础设施。
自动化软件生产中涉及的所有步骤确保现有解决方案的持续增强和改进,以适应市场需求。
您可以通过访问 dev.azure.com
开始此过程。在那里,您可以使用新账户注册或甚至使用您的 GitHub 账户登录。一旦您获得访问权限,您将被要求创建一个组织,如图下所示。
图 3.1:创建 Azure DevOps 组织
组织创建后,您将能够创建一个新的项目,如下面的屏幕截图所示。
图 3.2:使用 Azure DevOps 创建项目
需要指出的是,只要您遵守 Azure DevOps 的行为准则,就可以使用公开项目。一旦您定义了项目的名称和可见性,您将在几秒钟内创建项目。
图 3.3:已创建的 Azure DevOps 项目
DevOps 本身将在第八章,理解 DevOps 原则和 CI/CD中详细讨论,但您需要将其理解为一个专注于向客户交付价值的哲学。它是人员、流程和产品的结合,其中持续集成和持续部署(CI/CD)方法用于将持续改进应用于交付到生产环境中的软件应用程序。Azure DevOps 是一个功能强大的工具,其应用范围涵盖了应用程序初始开发和随后的 CI/CD 流程中的所有步骤。
Azure DevOps 包含收集需求和组织整个开发过程的工具。您可以通过点击 Azure DevOps 页面上的Boards菜单选项来访问它们。
图 3.4:Azure DevOps Boards 菜单
Azure DevOps 中所有其他功能将在以下小节中简要介绍。它们将在其他章节中详细讨论。更具体地说,DevOps 原则和 CI/CD 将在第八章,理解 DevOps 原则和 CI/CD中讨论,而构建/测试管道可以在第九章,测试您的企业应用程序中查看。
在 Azure DevOps 中管理系统需求
Azure DevOps 使您能够使用工作项来记录系统需求。工作项以信息块的形式存储在您的项目中,可以分配给个人。它们被分类为各种类型,可能包含所需开发工作的度量、状态以及它们所属的开发阶段(迭代)。主要来说,它们是需要完成以交付产品或服务的任务或行动。
DevOps 通常与敏捷方法结合使用,因此 Azure DevOps 使用迭代,整个开发过程被组织为一组冲刺。可用的工项取决于您在创建 Azure DevOps 项目时选择的工作项流程。
您可以在 Azure DevOps 中检查项目类型,请参阅learn.microsoft.com/en-us/azure/devops/boards/work-items/guidance/choose-process
。
以下小节包含了对在选择了敏捷或Scrum工作项流程时出现的最常见工作项类型的描述(默认为敏捷)。
经典工作项
假设您正在开发一个由各种子系统组成的系统。您可能不会在一个迭代中完成整个系统。因此,我们需要一个跨越多个迭代的伞形结构来封装每个子系统的所有功能。每个史诗工作项代表这些伞形结构中的一个,它可以包含在各个开发迭代中实现的多项功能。
在史诗工作项中,您可以定义状态和验收标准,以及开始日期和目标日期。除此之外,您还可以提供优先级和努力估计。所有这些详细信息都有助于利益相关者跟踪开发过程。这对于项目的宏观视角是有用的。
功能工作项
您在史诗工作项中提供的所有信息也可以放置在功能工作项中。因此,这两种类型的工作项之间的区别并不在于它们包含的信息类型,而在于它们各自的角色以及您的团队通过完成这些工作项希望实现的目标。功能是一个可交付的软件组件。史诗可能跨越多个迭代,并且在功能之上;也就是说,每个史诗工作项都链接到多个子功能,而每个功能工作项通常在几个冲刺中实现,并作为单个史诗工作项的一部分。
值得注意的是,所有工作项都有团队讨论的章节。在那里,您可以通过输入@
字符(如在许多论坛/社交媒体应用程序中)来找到讨论区域中的团队成员。在每个工作项内部,您可以链接和附加各种信息。您还可以在特定章节中检查当前工作项的历史记录。
功能工作项是开始记录用户需求的地方。例如,您可以写一个名为访问控制的功能工作项来定义实现系统访问控制所需的所有完整功能。
产品待办事项/用户故事工作项
这些工作项中哪些可用取决于所选的工作项流程。它们之间有一些细微的差别,但它们的目的基本上是相同的。它们包含由与之相连的功能工作项描述的详细需求。更具体地说,每个产品待办事项/用户故事工作项指定了其父功能工作项中描述的行为的一部分功能的详细需求。
例如,在一个系统访问控制的功能工作项中,用户维护和登录界面的维护应该是两个不同的用户故事/产品待办事项。这些需求将指导创建其他子工作项:
-
任务:这些是描述为了满足在父产品待办事项/用户故事工作项中声明的需求而需要完成的任务的必要工作项。任务工作项可以包含时间估计,这有助于团队容量管理和整体调度。
-
测试用例:这些条目描述了如何测试需求中描述的功能。
考虑到你将在项目中有很多工作项,可视化它们并不是一件容易的任务。因此,你可能需要考虑工作项可视化来简化你的视图。在marketplace.visualstudio.com/items?itemName=ms-devlabs.WorkItemVisualization
查看。
你将为每个产品待办事项/用户故事工作项创建的任务和测试用例数量将根据你使用的开发和测试场景而有所不同。
Azure DevOps 仓库
没有代码就没有软件,根据软件需求将要实现的代码需要放置在软件仓库中。Repos菜单项让你可以访问一个默认的 Git 仓库,你可以在这里放置你的项目代码:
图 3.5:Azure DevOps Repos 菜单
通过点击文件项,你进入默认仓库的初始页面。它是空的,并包含如何连接到此默认仓库的说明。
你可以通过页面顶部的下拉菜单添加更多仓库:
图 3.6:添加新的仓库
所有创建的仓库都可以通过相同的下拉菜单访问。
如前图所示,每个仓库的初始页面包含仓库地址和生成特定于仓库凭据的按钮,因此你可以使用你喜欢的 Git 工具连接到你的 DevOps 仓库。然而,你也可以通过一个非常简单的方式在 Visual Studio 内部连接:
-
启动 Visual Studio 并确保你使用用于定义你的 DevOps 项目(或用于添加你为团队成员)的同一 Microsoft 账户登录。
-
如果Git 更改窗口没有打开,请通过 Visual Studio 顶部的菜单转到视图 -> Git 更改来使其出现。
图 3.7:Git 更改窗口
-
在 Git 更改窗口中点击创建 Git 仓库...按钮。
-
在打开的窗口中,选择现有远程并插入你创建的 DevOps 仓库的 URL。然后,点击创建并推送:
图 3.8:连接到远程 DevOps 仓库
一旦连接到你的 DevOps 远程仓库,Git 更改窗口将显示几个 Git 命令:
图 3.9:Git 更改窗口
当你有要提交的更改时,你可以在窗口顶部的文本框中插入一条消息,并通过点击提交所有按钮在本地提交它们,或者你可以点击此按钮旁边的下拉菜单来访问更多选项:
图 3.10:提交选项
您可以提交并推送或提交并同步,但您也可以暂存您的更改。Git 更改窗口右上角的三个箭头分别触发获取、拉取和推送。同时,窗口顶部的下拉菜单负责处理分支的操作:
图 3.11:分支操作
包订阅
加速软件开发的一个好方法是重用满足用户需求的代码组件。通常,这些组件被放入包中。我们将在第五章,在 C#中实现代码重用中进一步讨论这个问题。工件菜单处理项目使用的或创建的软件包。在那里,您可以定义基本上所有类型的包的订阅,包括 NuGet、Node.js 和 Python。由于商业项目也使用私有包,因此需要私有订阅,所以您需要一个地方来存放它们。此外,构建过程中产生的包也放在这些订阅中,以便其他有它们作为依赖项的模块可以立即使用它们。
一旦进入工件区域,您可以通过点击+ 创建订阅按钮创建多个订阅,每个订阅可以处理多种类型的包,如图3.12所示。
图 3.12:订阅创建
如果您选择从公共源连接到包的选项,默认情况下,订阅会连接到npmjs
、nuget.org
、pypi.org
和Maven
。但是,您可以去订阅设置页面上的搜索上游源选项卡,删除或添加包源。设置页面可以通过点击订阅页面右上角的设置图标访问。
新创建的订阅的页面截图如下:
图 3.13:订阅页面
每个订阅的连接到订阅按钮会显示一个窗口,解释如何连接到每种包类型的订阅。
图 3.14:订阅连接信息
对于 NuGet 包,您应该将所有项目订阅添加到 Visual Studio 项目或解决方案的nuget.config
文件中,以便本地机器也能使用它们;否则,您的本地构建将失败。
测试计划
测试计划部分允许您定义您想要使用的测试计划和它们的设置。测试将在第九章,测试您的企业应用程序中详细讨论,但在这里,我们想总结 Azure DevOps 提供的测试机会。测试相关的操作和设置可以通过测试计划菜单项访问。
图 3.15:测试计划菜单
在这里,您可以定义、执行和跟踪以下组成的测试计划:
-
手动验收测试
-
自动单元测试
-
负载测试
必须在 Visual Studio 解决方案中包含的测试项目中定义自动单元测试,并基于 NUnit、xUnit 和 MSTest 等框架(.NET SDK 为所有这些框架都提供了项目模板,因此您可以在 Visual Studio 中找到它们)。测试计划让您有机会在 Azure 上执行这些测试,并定义以下内容:
-
几个配置设置
-
何时执行它们
-
如何跟踪它们以及在哪里将结果报告到整体项目文档中
对于手动测试,你可以在项目文档中为操作员定义完整的指令,包括执行它们的环境(例如,操作系统)以及报告结果的位置。你还可以定义如何执行负载测试以及如何测量结果。
管道
管道是自动行动计划,它指定从代码构建到软件部署到生产中的所有步骤。它们可以在管道区域定义,该区域可通过管道菜单项访问:
图 3.16:管道菜单
在那里,你可以定义一个完整的任务管道,包括它们的触发事件,这些事件包括代码构建、启动测试计划以及在测试通过后要做什么。
通常,在测试通过后,应用程序会自动部署到预发布区域,在那里可以进行 beta 测试。您还可以定义自动部署到生产的标准。这些标准包括但不限于以下内容:
-
应用程序 beta 测试期间的天数
-
在 beta 测试期间发现的错误数量以及/或通过最后一段代码更改删除的错误
-
一位或多位经理/团队成员的手动批准
标准决策将取决于公司想要如何管理正在开发的产品。作为软件架构师,您必须理解,当涉及到将代码移至生产时,越安全越好。
使用方法
如您从前面的屏幕截图中所见,创建 Azure DevOps 账户的过程极其简单。值得一提的是,如果您团队中不超过五名开发者,以及任何数量的利益相关者,您就可以免费开始使用这个出色的工具。然而,值得一提的是,对于利益相关者角色的用户数量没有限制。
介绍 GitHub 项目
考虑到所展示的 Azure DevOps 的所有好处,你现在可能想知道为什么我们需要探索另一个工具。原因很简单:GitHub 在多年间已经成为开源世界的主要工具。因此,我们将在那里找到许多改变我们交付软件方式的倡议和项目。
GitHub 的主要目标是管理代码并确保其用户能够以协作的方式创建解决方案。为此,该平台提供了版本控制、拉取请求、代码审查、问题跟踪和 CI/CD 等功能。
然而,如果没有一个支持它们的平台,设计出伟大的项目是不可能的。这就是为什么我们推出了 GitHub 项目,这是一个提供灵活、可适应的工具,用于在 GitHub 上规划和工作跟踪的倡议。
您可以在docs.github.com/en/issues/planning-and-tracking-with-projects/learning-about-projects/about-projects
了解更多关于 GitHub 项目的信息。
在 GitHub 上设置项目相当简单。一旦您登录到平台,您可能会在用户菜单中找到项目。
图 3.17:访问 GitHub 项目
在那里,您将找到创建新项目的选项。当您决定创建它时,您会发现 GitHub 已经提供了不同的模板。
图 3.18:在 GitHub 上创建项目
项目需要链接到一个仓库。您可以通过访问您仓库中的项目选项卡来完成此操作。在那里,您还可以创建一个新的项目。
图 3.19:将 GitHub 仓库连接到项目
值得注意的是,GitHub 比 Azure DevOps 更灵活,因此您会发现像 Azure DevOps 上展示的工具,但名称/定义不同。例如,您需要定义的任何任务都将被称为问题。每个问题都可以放置在一个里程碑中,这是一个定义功能或用户故事的好方法,因为它将描述一个有截止日期的成就。
图 3.20:在 GitHub 仓库中设置里程碑
您可以在docs.github.com/en/issues/tracking-your-work-with-issues/about-issues
了解更多关于使用问题的信息,以及在docs.github.com/en/issues/using-labels-and-milestones-to-track-work/about-milestones
了解更多关于里程碑的信息。
GitHub 项目中的想法是为每个将要使用的信息定义有用的信息,这些信息在问题中进行记录。默认情况下,只需要标题、分配者和状态。但您可以向它们添加标签、关联的拉取请求、审阅者、仓库和里程碑。您还可以定义自定义字段,如迭代。为此,您需要访问项目设置。
图 3.21:为 GitHub 项目创建自定义字段
在 GitHub 项目中,您还可以设置不同的视图,例如表格、看板或路线图。特别是表格视图,它提供了一种快速高效的方法,将包括您创建的自定义字段在内的项目相关信息输入到项目中。一旦您完成了设计阶段,您就可以为项目计划中的每个项目创建问题。
图 3.22:GitHub 项目表格视图
看板视图,结合与迭代相关的过滤器,是进行每日会议的完美方式,因为您将确切地看到项目正在发生什么。
图 3.23:GitHub 看板视图
最后,路线图视图将为您提供整个项目随时间发展的视角。
图 3.24:GitHub 路线图视图
如您所见,使用 GitHub 项目,您将获得与我们使用 Azure DevOps 时几乎相同的结果。
摘要
本章介绍了如何为软件开发项目创建 Azure DevOps 或 GitHub 账户,以及如何使用 Azure DevOps 或 GitHub 项目开始管理您的项目。
它还简要回顾了 Azure DevOps 的所有功能,解释了如何通过 Azure DevOps 主菜单访问它们。
最后,它展示了 GitHub 项目视图选项,用于规划和管理工作。
本章还更详细地描述了如何管理系统需求,以及如何组织与各种类型的工作项或问题相关的必要工作,以及如何规划和组织冲刺,以交付具有许多功能的史诗级解决方案。
在项目中是否使用 Azure DevOps 或 GitHub 的决定将根据团队的专业知识和项目本身的目标而有所不同。如果您正在设计开源解决方案,GitHub 将绝对是一个更好的选择。另一方面,如果您正在设计企业解决方案并且需要一切连接在一起,Azure DevOps 是一个实现这一目标的绝佳工具。
下一章将讨论编写代码时的重要方法。
问题
-
Azure DevOps 是否仅适用于 .NET 项目?
-
Azure DevOps 中有哪些可用的测试计划?
-
DevOps 项目可以使用私有 NuGet 包吗?
-
我们为什么使用工作项?
-
Epic 工作项与功能工作项之间的区别是什么?
-
任务与产品待办事项/用户故事工作项之间存在什么样的关系?
-
GitHub 项目如何有用?
-
哪个选项更好:Azure DevOps 还是 GitHub?
在 Discord 上了解更多信息
要加入本书的 Discord 社区——在那里您可以分享反馈、向作者提问以及了解新版本——请扫描下面的二维码:
packt.link/SoftwareArchitectureCSharp12Dotnet8
第四章:C# 12 编码的最佳实践
当你是项目中的软件架构师时,你的责任是定义和/或维护一个编码标准,这将指导团队根据公司的期望进行编程。本章涵盖了编码的一些 最佳实践,这将帮助像你这样的开发者编写安全、简单和可维护的软件。它还包括在 C# 中编码的技巧和窍门。虽然编码可以被认为是一种艺术,但编写可理解的代码更接近于一种哲学。在本章中,我们还讨论了你作为软件架构师需要传播给开发者的实践,包括代码分析的技术和工具,以便你为项目拥有编写良好的代码。
本章将涵盖以下主题:
-
你的代码复杂性如何影响性能
-
使用版本控制系统的必要性
-
在 C# 中编写安全的代码
-
.NET 8 编码的技巧和窍门
-
识别编写良好的代码
C# 12 与 .NET 8 一起发布。然而,这里介绍的做法可以应用于许多版本的 .NET,因为它们涉及 C# 编程的基础。到本章结束时,你将能够定义你打算将哪些工具纳入你的软件开发生命周期以促进代码分析。
技术要求
本章至少需要 Visual Studio 2022 的免费 Community Edition。
你的代码越简单,你作为程序员的水平就越高
对于许多人来说,一个好的程序员是编写复杂代码的人。然而,软件开发成熟度的演变意味着对此有不同的思考方式。复杂性并不意味着工作做得好;它意味着代码质量差。一些令人难以置信的科学家和研究人员已经证实了这一理论,并强调专业代码需要关注时间、高质量和预算。
即使你手头有一个复杂的场景,如果你减少了歧义并澄清了你所编写的代码的过程,特别是通过使用好的方法名和变量名,这有助于使你的代码“自文档化”并尊重 SOLID 原则(Single Responsibility,Open/Close,Liskov Substitution,Interface Segregation,和 Dependency Inversion),你将把复杂性转化为简单的代码。
因此,如果你想编写好的代码,你需要关注如何编写它,考虑到你不会是唯一一个以后会阅读它的人。这是一个改变你编写代码方式的良好建议。这就是我们将如何讨论本章的每个要点。
如果你对编写良好代码重要性的理解与编写时的简洁性和清晰性理念一致,你应该看看名为 Code Metrics 的 Visual Studio 工具:
图 4.1:在 Visual Studio 中计算代码度量
代码度量工具将提供度量指标,这些指标将为你提供关于你交付的软件质量的洞察。该工具提供的度量指标可以在以下链接中找到:docs.microsoft.com/en-us/visualstudio/code-quality/code-metrics-values
。
一旦你运行了代码度量分析,你将需要解释每个展示的度量指标。以下小节将重点描述可维护性指数、圈复杂度、继承深度、类耦合度和代码行数在现实生活中的某些场景中是如何有用的。
可维护性指数
可维护性指数表示一个从 0 到 100 的数字,它表示维护代码的难易程度——代码越容易维护,指数越高。易于维护是保持软件健康的关键点之一。显然,任何软件在未来都需要更改,因为变化是不可避免的。
因此,如果你当前的代码可维护性指数较低,考虑重构代码以提高可维护性指数。编写专门负责单一职责的类和方法、避免重复代码以及限制每个方法的代码行数都是提高可维护性指数的例子。
圈复杂度
圈复杂度度量的创造者是托马斯·J·麦卡贝。他根据可用的代码路径数(图节点)来定义软件函数的复杂度。路径越多,函数越复杂。麦卡贝认为每个函数的复杂度得分必须小于 10。这意味着如果代码有更复杂的方法,你必须重构它,将代码的部分转换为单独的方法。有一些实际场景中这种行为的检测很容易:
-
循环嵌套
-
大量的连续
if-else
语句 -
在同一方法内部对每个情况执行代码处理的
switch
语句
例如,看看这个方法的第一版,用于处理信用卡交易的不同响应。正如你所看到的,圈复杂度大于麦卡贝作为基础的数字。这种情况发生的原因是主开关的每个情况中都有大量的if-else
语句:
/// <summary>
/// This code is being used just for explaining the concept of
/// cyclomatic complexity.
/// It makes no sense at all. Please Calculate Code Metrics for
/// understanding
/// </summary>
private static void CyclomaticComplexitySample()
{
var billingMode = GetBillingMode();
var messageResponse = ProcessCreditCardMethod();
switch (messageResponse)
{
case "A":
if (billingMode == "M1")
Console.WriteLine($"Billing Mode {billingMode} for " +
$"Message Response {messageResponse}");
else
Console.WriteLine($"Billing Mode {billingMode} for " +
$"Message Response {messageResponse}");
break;
case "B":
if (billingMode == "M2")
Console.WriteLine($"Billing Mode {billingMode} for " +
$"Message Response {messageResponse}");
else
Console.WriteLine($"Billing Mode {billingMode} for " +
$"Message Response {messageResponse}");
break;
case "C":
if (billingMode == "M3")
Console.WriteLine($"Billing Mode {billingMode} for " +
$"Message Response {messageResponse}");
else
Console.WriteLine($"Billing Mode {billingMode} for " +
$"Message Response {messageResponse}");
break;
case "D":
if (billingMode == "M4")
Console.WriteLine($"Billing Mode {billingMode} for " +
$"Message Response {messageResponse}");
else
Console.WriteLine($"Billing Mode {billingMode} for " +
$"Message Response {messageResponse}");
break;
case "E":
if (billingMode == "M5")
Console.WriteLine($"Billing Mode {billingMode} for " +
$"Message Response {messageResponse}");
else
Console.WriteLine($"Billing Mode {billingMode} for " +
$"Message Response {messageResponse}");
break;
case "F":
if (billingMode == "M6")
Console.WriteLine($"Billing Mode {billingMode} for " +
$"Message Response {messageResponse}");
else
Console.WriteLine($"Billing Mode {billingMode} for " +
$"Message Response {messageResponse}");
break;
case "G":
if (billingMode == "M7")
Console.WriteLine($"Billing Mode {billingMode} for " +
$"Message Response {messageResponse}");
else
Console.WriteLine($"Billing Mode {billingMode} for " +
$"Message Response {messageResponse}");
break;
case "H":
if (billingMode == "M8")
Console.WriteLine($"Billing Mode {billingMode} for " +
$"Message Response {messageResponse}");
else
Console.WriteLine($"Billing Mode {billingMode} for " +
$"Message Response {messageResponse}");
break;
default:
Console.WriteLine("The result of processing is unknown");
break;
}
}
如果你计算这段代码的代码度量,你将发现圈复杂度方面会有一个不良的结果,如以下截图所示。圈复杂度数值超过 10 表明代码难以阅读,开发者可能在未来代码更改中难以维护它。
图 4.2:高圈复杂度
需要强调的是,本例中代码的目的并不是重点。这里的关键是要展示可以如何改进代码以编写更好的代码:
-
switch-case
中的选项可以用Enum
来编写。 -
每个
case
处理可以完成:-
在一个特定的方法中。
-
在一个特定的类中,从超类继承操作,使用多态概念。
-
在一个特定的类中,实现一个接口来定义一个合同。
-
-
switch-case
可以用Dictionary<Enum, Method>
替换,或者使用switch
表达式。
通过使用前面提到的技术重构此代码,结果是代码更容易理解,如以下主方法代码片段所示:
static void Main()
{
var billingMode = GetBillingMode();
var messageResponse = ProcessCreditCardMethod();
Dictionary<CreditCardProcessingResult, CheckResultMethod>
methodsForCheckingResult = GetMethodsForCheckingResult();
if (methodsForCheckingResult.ContainsKey(messageResponse))
methodsForCheckingResultmessageResponse;
else
Console.WriteLine("The result of processing is unknown");
}
自从 C# 8.0 以来,可以使用 switch
表达式使代码更加简洁!
static void Main()
{
var billingMode = GetBillingMode();
var messageResponse = ProcessCreditCardMethod();
CheckResult(messageResponse, billingMode);
}
private static CreditCardProcessingResult CheckResult(CreditCardProcessingResult messageResponse, BillingMode billingMode) => messageResponse switch
{
CreditCardProcessingResult.ResultA => CheckResultA(billingMode, messageResponse),
CreditCardProcessingResult.ResultB => CheckResultB(billingMode, messageResponse),
CreditCardProcessingResult.ResultC => CheckResultC(billingMode, messageResponse),
CreditCardProcessingResult.ResultD => CheckResultD(billingMode, messageResponse),
CreditCardProcessingResult.ResultE => CheckResultE(billingMode, messageResponse),
CreditCardProcessingResult.ResultF => CheckResultF(billingMode, messageResponse),
CreditCardProcessingResult.ResultG => CheckResultG(billingMode, messageResponse),
CreditCardProcessingResult.Succeed => CheckResultSucceed(billingMode, messageResponse),
_ => throw new ArgumentOutOfRangeException(nameof(messageResponse), $"Not expected value: {messageResponse}"),
};
完整的代码可以在本章的 GitHub 仓库中找到,网址为 github.com/PacktPublishing/Software-Architecture-with-C-Sharp-12-and-.NET-8-4E
/tree/main/ch04,展示了如何实现低复杂度代码。以下截图显示了根据代码指标的结果:
图 4.3:重构后的循环复杂度降低
如前一个截图所示,重构后复杂性有相当大的降低。在 第五章,在 C# 12 中实现代码重用,我们将讨论重构对于代码重用的重要性。我们在这里这样做的原因是相同的——我们想要消除重复。重要的是要记住,当你重构代码时,你正在以更好的方式编写代码,同时尊重此代码将处理输入和输出数据。
这里关键点是,通过应用的技术,我们对代码的理解增加了,复杂度指数降低了,从而证明了循环复杂度的重要性。
继承深度
这个指标表示正在分析的那个类继承的类数量。继承的类越多,这个指标就越差。这就像类耦合,表明在不影响其他类的情况下更改这个类的代码有多困难,这忽略了 SOLID 原则中提出的开放/关闭原则。例如,以下截图显示了四个继承的类:
图 4.4:继承深度示例
你可以在以下截图看到,最深的类在考虑有三个其他类可以改变其行为的情况下,具有最差的指标:
图 4.5:继承深度指标
继承是基本面向对象分析原则之一。然而,有时它可能会对你的代码造成不利影响,因为它可能会引起依赖。所以,如果这样做有意义,考虑使用组合或聚合而不是继承,正如我们将在下一节中解释的那样。
类耦合
当你在单个类中连接太多的类时,显然你会得到紧密耦合,改变一个参与者会导致其他参与者产生意外的后果。当然,这可能会造成你代码的糟糕维护,导致需要花费更多时间来尝试交付一个优秀的解决方案。例如,参考图 4.6。它显示了一个进行了大量聚合的设计。代码本身没有意义:
图 4.6:类耦合示例
一旦你计算了前一个设计的代码度量,你会发现调用ExecuteTypeA()
、ExecuteTypeB()
和ExecuteTypeC()
的ProcessData()
方法的类耦合实例数等于三个:
图 4.7:类耦合度量
微软建议类耦合实例的最大数量应该是九个,如learn.microsoft.com/en-us/visualstudio/code-quality/code-metrics-class-coupling?view=vs-2022
所示。
由于组合/聚合比继承是一种更好的实践,并且你将解耦从你的类编写的代码,使用接口将解决类耦合问题。例如,以下设计中的相同代码将给出更好的结果。尽管在这个例子中接口不是严格必需的,但其使用使我们能够为其他执行类型的解决方案进行演化,而不会影响已经编写的类,因为你没有使用继承来解决问题。
图 4.8:减少类耦合
注意,在设计中使用接口将允许你在不增加解决方案的类耦合的情况下增加执行类型:
图 4.9:应用聚合后的类耦合结果
作为一名软件架构师,你必须设计你的解决方案,使其具有比耦合更高的内聚性。文献指出,好的软件具有低耦合和高内聚。在软件开发中,高内聚表示每个类都有其方法和数据,并且它们之间有良好的关系。相反,低耦合表示类之间没有紧密和直接的连接。这是一个基本原理,可以指导你到一个更好的架构模型。
代码行数
这个指标有助于你理解你正在处理的代码的大小。由于行数并不能直接反映复杂性,因此无法将代码行数与复杂性联系起来。相反,代码行数确实可以显示软件的大小和软件设计。例如,如果一个类中有太多的代码行(超过 1,000 行代码 – 1 KLOC),这表明它是一个糟糕的设计。此外,如果一个类有太多的方法,这显然违反了 SOLID 设计原则中的单一职责原则。
在 Visual Studio 2022 中,这个指标被分为源代码行数和可执行代码行数。前者表示确切的总源代码行数,包括空白行。相反,后者估计的是可执行代码的行数。
作为一名软件架构师,你的目标是向程序员提供一系列最佳实践,使他们能够提高开发优质软件的技术。确保他们了解在代码中未能实现良好指标结果的直接影响。上述指标无疑是实现这一目标的良好开端。但让我们看看使用版本控制系统如何能区分业余和专业的软件开发。
使用版本控制系统
你可能会觉得这个话题有点明显,但许多人和企业仍然不认为版本控制系统是软件开发的一个基本工具。版本控制系统不被视为优先事项的常见原因,尤其是在某些情况下,是认为它们对于单独编码项目或学习目的来说是不必要的。你可能认为版本控制系统仅适用于公司内部的团队。提出这个问题的目的是让你理解我们的观点。如果你不使用版本控制系统,就没有任何架构模型或最佳实践能够拯救软件开发。
在过去几年里,我们一直在享受在线版本控制系统带来的优势,例如 Azure DevOps、GitHub 和 Bitbucket。事实上,在你的软件开发生命周期中必须有一个版本控制系统,而且没有理由不再使用它,因为大多数提供商为小型团队提供免费版本。即使你独自开发,这些工具也有助于跟踪你的更改、管理你的软件版本,并保证代码的一致性和完整性。
团队中处理版本控制系统
当你独自一人使用版本控制系统工具的原因很明显。你希望确保你的代码安全。然而,这种系统是为了解决编写代码时的团队问题而开发的。因此,引入了一些功能,如分支和合并,以保持代码完整性,即使在开发者数量相当大的情况下也是如此。
作为一名软件架构师,你将不得不决定在你的团队中实施哪种分支策略。微软和 GitHub 建议了不同的方法来实现这一点,并且两者在某些场景中都很有用。
关于微软团队如何处理 DevOps 的信息可以在这里找到:learn.microsoft.com/en-us/devops/develop/how-microsoft-develops-devops
。本文中提出的分支策略描述了一种为每个发布创建分支的方法。他们将这称为 发布流程。这里的主要区别是主分支不会持续部署到生产环境。
相反,GitHub 在其guides.github.com/introduction/flow/
中描述了其流程。这个过程被称为 GitHub flow,可以被定义为一个轻量级的、基于分支的工作流程,其中每个开发都在一个特定的分支中进行,一旦创建了拉取请求以供反馈,协作者就会对其进行审查。一旦拉取请求被批准,新代码就会被合并到主分支,这样你就可以删除之前创建的开发分支。
这取决于你的选择;选择最适合你需求的一个,但我们确实希望你明白你需要有一个控制你代码的策略。在 第八章,理解 DevOps 原则和 CI/CD 中,我们将更详细地讨论这一点。但现在,让我们看看如何使用 C#编写安全代码,以便你可以制定一份最佳实践清单与你的开发者分享。
在 C#中编写安全代码
从设计上讲,C# 可以被认为是一种安全的编程语言。除非你强制使用,否则不需要指针,并且大多数情况下,内存释放由垃圾回收器管理。即便如此,也应该注意一些事项,以便你能从代码中获得更好的、更安全的成果。让我们看看一些常见的做法,以确保在 C#中编写安全代码。
try-catch
编程中的异常如此频繁,你应该有一种方法来管理它们,无论何时发生。try-catch
语句被构建来管理异常,这对于保持代码安全非常重要。当它们发生时,要小心,因为它们可能会引起性能问题,正如我们在 第二章,非功能性需求 中讨论的那样。
有很多情况下应用程序崩溃,而其原因是没有使用 try-catch
。以下代码展示了缺少 try-catch
语句使用的一个例子。值得注意的是,这只是一个理解未正确处理的异常抛出概念的一个例子。考虑使用 int.TryParse(textToConvert, out int result)
来处理解析失败的情况:
private static int CodeWithNoTryCatch(string textToConvert)
{
return Convert.ToInt32(textToConvert);
}
相反,错误的 try-catch
使用也可能对你的代码造成损害,尤其是因为你将看不到该代码的正确行为,并可能误解提供的结果。
以下代码展示了空try-catch
语句的例子:
private static int CodeWithEmptyTryCatch(string textToConvert)
{
try
{
return Convert.ToInt32(textToConvert);
}
catch
{
return default;
}
}
try-catch
语句必须与日志解决方案连接,以便你可以从系统中获得响应,指示正确的行为,同时不会导致应用程序崩溃。以下代码展示了带有日志管理的理想try-catch
语句。值得注意的是,在可能的情况下,应该捕获特定的异常,因为捕获通用异常会隐藏意外的异常:
private static int CodeWithCorrectTryCatch(string textToConvert)
{
try
{
return Convert.ToInt32(textToConvert);
}
catch (FormatException err)
{
Logger.GenerateLog(err);
return 0;
}
}
非常重要的是要注意,从计算的角度来看,异常是昂贵的。无论你是抛出它们来指示错误还是捕获它们来管理错误,都需要大量的计算处理。因此,依赖高级异常处理器而不是试图在各个地方处理所有事情是常见且更可取的,因为代码可能难以推理,尤其是在不知道在代码的这一部分如何处理异常的情况下,你可能会再次将其抛给更高层次的处理器。你应该优先处理可以在其中采取有意义操作的异常。
然而,也值得提到的是,传递给最终用户的异常错误可能会造成软件质量差的印象。作为一名软件架构师,你应该进行代码审查,以定义代码的最佳行为。系统中的不稳定性,如意外的崩溃和高内存使用,通常与代码中缺少try-catch
语句有关。
try-finally 和 using
内存泄漏可以被认为是软件行为中最糟糕的一种。它们会导致不稳定、计算机资源使用不当和应用程序崩溃。C#通过垃圾回收器来尝试解决这个问题,垃圾回收器会在意识到对象可以被释放时自动从内存中释放对象。垃圾回收器的触发机制在learn.microsoft.com/en-us/dotnet/standard/garbage-collection/fundamentals
中有详细解释。
与 I/O 交互的对象通常不是由垃圾回收器管理的:文件系统、套接字等。以下代码是FileStream
对象使用不当的例子,因为它认为垃圾回收器会释放使用的内存,但实际上不会:
private static void CodeWithIncorrectFileStreamManagement()
{
FileStream file = new ("C:\\file.txt", FileMode.CreateNew);
byte[] data = GetFileData();
file.Write(data, 0, data.Length);
}
此外,垃圾回收器与需要释放的对象交互需要一段时间,有时你可能想自己来做这件事。在这两种情况下,使用try-finally
或using
语句是最佳实践:
private static void CorrectFileStreamManagementFirstOption()
{
FileStream file = new ("C:\\file.txt",FileMode.CreateNew);
try
{
byte[] data = GetFileData();
file.Write(data, 0, data.Length);
}
finally
{
file.Dispose();
}
}
private static void CorrectFileStreamManagementSecondOption()
{
using (FileStream file = new ("C:\\file.txt", FileMode.CreateNew))
{
byte[] data = GetFileData();
file.Write(data, 0, data.Length);
}
}
private static void CorrectFileStreamManagementThirdOption()
{
using FileStream file = new ("C:\\file.txt", FileMode.CreateNew);
byte[] data = GetFileData();
file.Write(data, 0, data.Length);
}
上述代码展示了如何处理垃圾回收器未管理的对象。try-finally
和 using
都已实现。作为一名软件架构师,你需要注意这类代码。缺少 try-finally
或 using
语句可能会在运行时对软件行为造成巨大损害。值得一提的是,使用代码分析工具,如 Sonar Lint 和 代码分析,将自动提醒你这些问题。
IDisposable 接口
就像如果你不使用 try-finally
/using
语句来管理方法内部创建的对象,你将遇到麻烦一样,在未正确实现 IDisposable
接口的情况下创建的对象可能会导致你的应用程序出现内存泄漏。因此,当你有一个处理和创建对象的类时,你应该实现 Disposable
模式来保证释放由该类创建的所有资源:
图 4.10:IDisposable 接口实现
your code, and then you right-click on the Quick Actions and Refactoring options, as you can see in the preceding screenshot.
一旦你插入了代码,你需要遵循待办指令,以确保正确实现了模式。
作为一名软件架构师,你不仅需要对系统中定义的架构负责,还要对系统的运行性能负责。内存泄漏和性能不佳通常是由与 try-catch
策略相关的错误、缺乏 try-finally/using
以及 IDisposable
的错误或未实现引起的。所以请确保你的团队知道如何处理这些技术。
既然我们已经介绍了一些关于如何在 C# 中编写安全代码的重要信息,那么获取一些关于这种编程语言的编程技巧和窍门将是非常有用的。让我们在本章的下一个主题中这样做。
.NET 8 编程技巧与窍门
.NET 8 实现了一些有助于我们编写更好代码的良好功能。其中最有用的一个就是依赖注入(DI),它将在第六章,设计模式和 .NET 8 实现中讨论。考虑这一点有几个很好的理由。第一个是,如果你是注入对象的创建者,你只需担心注入对象的释放。
此外,DI 使你能够注入 ILogger
,这是一个用于调试需要由 try-catch
语句管理的异常的有用工具。此外,使用 .NET 8 在 C# 中编程必须遵循任何编程语言的通用良好实践。以下列表显示了其中的一些:
-
类、方法和变量应该有可理解的名字:名字应该解释读者需要知道的一切。除非这些声明是公开的,否则不应需要解释性注释。
-
方法不应具有高复杂度级别:应检查循环复杂度,以确保方法中代码行数不要过多。
-
成员必须具有正确的可见性:作为面向对象编程语言,C#通过不同的可见性关键字支持封装。C# 9 引入了只读设置器,因此您可以使用
init
属性/索引访问器而不是set
,在对象构造后定义这些成员为只读。 -
应避免重复代码:在像 C#这样的高级编程语言中,没有必要存在重复代码。
-
在使用对象之前应检查对象:由于可能存在 null 对象,代码必须进行 null 类型检查。值得一提的是,自从 C# 8 以来,我们有了可空引用类型来避免与可空对象相关的错误。您可以参考
docs.microsoft.com/en-us/dotnet/csharp/nullable-references
了解更多关于可空引用类型的信息。 -
应使用常量和枚举器:避免在代码中使用魔法数字和文本的好方法是将此信息转换为常量和枚举器,这些通常更容易理解。
-
应避免使用不安全代码:不安全代码使您能够在 C#中处理指针。除非没有其他方法来实现解决方案,否则应避免使用不安全代码。
-
try-catch 语句不能为空:在没有处理
catch
区域的try-catch
语句中很少有使用理由。此外,捕获的异常应尽可能具体,而不仅仅是“异常”,以避免吞咽意外的异常。 -
如果创建了可释放的对象,则应释放它们:即使垃圾回收器会处理已释放的对象,也应考虑释放自己负责创建的对象。
-
至少公共方法应该有注释:考虑到公共方法是用于您库外部的,它们必须解释其正确的外部使用。
-
switch-case 语句必须具有默认处理:由于
switch-case
语句可能接收到某些情况下未知的人口变量,默认处理将保证在这种情况下代码不会中断。
作为软件架构师,您可能考虑为您的开发人员提供一个代码模式,该模式将用于保持团队代码风格的一致性。您还可以将此代码模式用作编码检查的清单,这将丰富软件代码质量。
识别编写良好的代码
识别代码是否编写得很好并不容易。到目前为止所描述的最佳实践当然可以指导你作为一个软件架构师为你的团队定义一个标准。然而,即使有了标准,错误仍然会发生,你可能会在生产代码中才发现它们。仅仅因为代码没有遵循你定义的所有标准就决定在生产中重构代码,这是一个不容易做出的决定,尤其是如果相关的代码运行正常的话。有些人认为,编写得好的代码就是那些在生产中运行良好的代码。然而,这肯定会对软件的生命周期造成损害,因为开发者可能会受到那些非标准代码的影响。
因此,作为一个软件架构师,你需要找到方法来强制执行你定义的编码标准的遵守。幸运的是,如今,我们有许多工具可以帮助我们完成这项任务。它们被称为静态代码分析工具,使用它们提供了改进开发和团队编程知识的大好机会。
开发者会随着代码分析而发展,原因是在代码审查过程中,你开始在他们之间传播知识。我们现在拥有的工具具有相同的目的。更好的是,使用 Roslyn,它们在编写代码的同时完成这项任务。Roslyn 是.NET 的编译器平台,它使你能够开发一些用于分析代码的工具。这些分析器可以检查风格、质量、设计和其他问题。
例如,看看下面的代码。它没有任何意义,但你仍然可以看到其中有一些错误:
using System;
static void Main(string[] args)
{
try
{
int variableUnused = 10;
int variable = 10;
if (variable == 10)
{
Console.WriteLine("variable equals 10");
}
else
{
switch (variable)
{
case 0:
Console.WriteLine("variable equals 0");
break;
}
}
}
catch
{
}
}
这段代码的目的是向您展示一些工具的强大功能,这些工具可以帮助您改进交付的代码。让我们在下一节中逐一研究它们,包括如何设置它们。
理解和应用可以评估 C#代码的工具
Visual Studio 中代码分析的发展是持续的。这意味着 Visual Studio 2022 肯定比 Visual Studio 2019 有更多用于此目的的工具,依此类推。
你(作为一个软件架构师)需要处理的一个问题是团队的编码风格。这当然有助于更好地理解代码。例如,如果你去Visual Studio 菜单,然后工具 -> 选项,接着在左侧菜单中文本编辑 -> C#,你会找到处理不同代码风格模式的方法,而且不良的编码风格甚至在代码风格选项中被标记为错误,如下所示:
图 4.11:代码风格选项
上一张截图中的更改避免未使用参数被认为是一个错误。
在这次更改之后,本章开头展示的代码的编译结果不同,如下面的截图所示:
图 4.12:代码风格结果
您可以将您的编码风格配置导出并附加到您的项目上,这样它就会遵循您定义的规则。
Visual Studio 2022 提供的另一个好工具是分析和代码清理。使用此工具,您可以设置一些代码标准来清理您的代码。例如,在下面的屏幕截图中,它被设置为删除不必要的代码:
图 4.13:配置代码清理
运行代码清理的方式是通过在解决方案资源管理器区域中,在您想要运行它的项目上右键单击来选择它。之后,此过程将在您所有的代码文件中运行:
图 4.14:运行代码清理
在解决代码风格和代码清理工具指示的错误之后,我们正在处理的示例代码进行了一些最小化简化,如下所示:
using System;
try
{
int variable = 10;
if (variable == 10)
{
Console.WriteLine("variable equals 10");
}
else
{
switch (variable)
{
case 0:
Console.WriteLine("variable equals 0");
break;
}
}
}
catch
{
}
值得注意的是,前面的代码还有许多需要解决的改进。Visual Studio 通过安装扩展到 IDE 中,使您能够为 IDE 添加额外的工具。这些工具可以帮助您提高代码质量,因为其中一些工具是为了执行代码分析而构建的。本节将列出一些免费选项,以便您可以选择最适合您需求的选项。当然,还有其他选项,甚至付费选项。这里的想法不是指出一个特定的工具,而是给您一个它们能力的概念。
要安装这些扩展,您需要在 Visual Studio 2022 中找到扩展菜单。以下是管理扩展选项的屏幕截图:
图 4.15:Visual Studio 2022 中的扩展
有许多其他优秀的扩展可以提升您代码和解决方案的生产力和质量。您可以在本管理器中搜索它们。
在您选择了将要安装的扩展之后,您需要重新启动 Visual Studio。大多数扩展在安装后都很容易识别,因为它们会修改 IDE 的行为。然而,它们需要在每个开发环境中进行设置。为了解决这个问题,Visual Studio 引入了将分析器作为 NuGet 包包含的选项,这样所有与项目合作的开发者都将对他们的代码进行分析。
将扩展工具应用于分析代码
尽管经过代码风格和代码清理工具处理后的示例代码比章节开头我们展示的代码要好,但它显然与迄今为止讨论的最佳实践相去甚远。
因此,Microsoft 将分析器分为三个组:
-
如前所述的代码风格
-
已包含在.NET 5+项目中的代码质量分析器
-
可以作为 NuGet 包或 Visual Studio 扩展安装的第三方分析器
您可以在 docs.microsoft.com/en-us/visualstudio/code-quality/roslyn-analyzers-overview
找到源代码分析概述。
让我们研究一下这些第三方分析器如何有用,以 SonarAnalyzer 包作为参考。
应用 SonarAnalyzer
SonarAnalyzer 是 Sonar Source 社区发起的一个开源项目,旨在在编码时检测错误和质量问题。它支持 C#、VB.NET、C、C++ 和 JavaScript。他们还提供了一个名为 SonarLint 的扩展。这个扩展的伟大之处在于它提供了解决检测到的问题的解释,这就是为什么我们认为开发者在使用这些工具时可以学会如何编写良好的代码。
此扩展可以指出错误,而且更好的是,每个警告都有解释。这对于发现问题以及培训开发者良好的编码实践都很有用。
在 Visual Studio 2022 中,SonarLint 扩展可用。除此之外,您还可以使用 NuGet 包,如下面的截图所示:
图 4.16:SonarAnalyzer.CSharp NuGet 包
产生的结果与使用 SonarLint 扩展得到的结果相同,但这个选项的好处是,任何需要为这个项目编码的开发者都会得到他们的代码分析。
图 4.17:SonarAnalyzer.CSharp 分析结果
作为软件架构师,您将始终需要关注并采取行动,以确保项目使用相同的代码标准统一,因此 NuGet 选项可能有助于实现这一目标。
分析后的最终代码检查
在分析了两种选项之后,我们最终解决了原始代码中的所有问题。以下是最终代码:
using System;
try
{
int variable = 10;
if (variable == 10)
{
Console.WriteLine("variable equals 10");
}
else
{
switch (variable)
{
case 0:
Console.WriteLine("variable equals 0");
break;
default:
Console.WriteLine("Unknown behavior");
break;
}
}
}
catch (Exception err)
{
Console.WriteLine(err);
}
如您所见,前面的代码不仅更容易理解,而且更安全,能够考虑不同的编程路径,因为已经为 switch-case
编程了默认值。这个模式在本章中已经讨论过,这让我们得出一个愉快的结论,即最佳实践可以通过使用本章讨论的(或所有)选项之一(或全部)来轻松遵循。
摘要
在本章中,我们讨论了一些编写安全代码的重要提示。本章介绍了一个分析代码指标的工具,以便您可以管理您开发的软件的复杂性和可维护性。最后,我们提出了一些保证您的软件不会因内存泄漏和异常而崩溃的好建议。在现实生活中,软件架构师总会被要求解决这类问题。
本章还推荐了一些可以用来应用我们讨论的编码最佳实践的工具体。我们探讨了 Roslyn 编译器,它允许在开发者编码时进行代码分析。
你将在第二十一章“案例研究”中找到一个在发布应用程序之前评估 C# 代码的方法,该方法在 Azure DevOps 构建过程中使用 SonarCloud 进行代码分析。
当你将本章所学的内容应用到你的项目中时,你会发现代码分析将为你提供改进交付给客户代码质量的机会。这是你作为软件架构师角色中非常重要的一个部分。
在下一章中,你将学习关于代码重用的知识,这是一种保证项目质量和速度的惊人技术!
问题
-
为什么我们需要关注可维护性?
-
什么是循环复杂度?
-
列出使用版本控制系统的优势。
-
什么是垃圾回收器?
-
实现 IDisposable 接口的重要性是什么?
-
使用 .NET 8 我们在编码方面获得了哪些优势?
-
什么使得一段软件代码可以被描述为编写得很好?
-
什么是 Roslyn?
-
什么是代码分析?
-
代码分析的重要性是什么?
-
Roslyn 如何帮助进行代码分析?
-
什么是 Visual Studio 扩展?
-
可用于代码分析的可扩展工具有哪些?
进一步阅读
这些是一些书籍和网站,你可以在那里找到更多关于本章主题的信息:
-
《代码整洁之道》:敏捷软件开发工艺手册*,马丁,罗伯特·C·皮尔森教育,2012 年。
-
*《嵌入式系统设计艺术》,杰克·G·甘斯勒著。Elsevier,1999 年。
-
*《重构》,马丁·福勒著。Addison-Wesley,2018 年。
-
*《复杂性度量》,托马斯·J·麦卡贝著。IEEE 软件工程杂志,第 2 卷第 4 期,1976 年,第 308-320 页 (
dblp.uni-trier.de/db/journals/tse/tse2.html
)。 -
代码度量信息:
-
版本控制系统:
-
代码分支技术:
-
日志基础:
-
CSharp 有什么新内容?
-
源代码分析器:
在 Discord 上了解更多信息
要加入这本书的 Discord 社区——在那里您可以分享反馈、向作者提问,并了解新版本——请扫描下面的二维码:
packt.link/SoftwareArchitectureCSharp12Dotnet8
第五章:在 C# 12 中实现代码重用
代码重用性是软件架构中最重要的话题之一。本章旨在讨论实现代码重用的方法,以及帮助你理解.NET 8 如何解决管理和维护可重用库的问题。
本章将涵盖以下主题:
-
理解代码重用的原则
-
与.NET 8 合作的优势
-
使用.NET 8 创建可重用库
尽管代码重用是一种例外做法,但作为一名软件架构师,你必须意识到在处理的具体场景中何时这是重要的。许多优秀的软件架构师认为,由于试图使事物可重用,尽管它们通常是单次使用或理解不够充分,导致不必要的复杂性和开发过程缓慢,这导致了大量的过度设计。
技术要求
对于本章,你需要安装免费 Visual Studio 2022 Community Edition 或更高版本,以及所有数据库工具。
理解代码重用的原则
你可以始终用来证明代码重用合理性的唯一理由是——如果你在其他场景中已经运行良好,你就不能浪费宝贵的时间去重新发明轮子。这就是为什么大多数工程领域都基于可重用性原则,如模块化、标准化、抽象和文档。想想你家里的开关。你只能更换它们,而不必修改你房子的其他部分,因为它是基于一个标准建造的,根据规范抽象出开关的概念,从而提供了模块化。
你能想象使用相同的界面组件可以制作出多少应用吗?代码重用的基本原则是一样的。再次强调,这关乎于规划一个良好的解决方案,以便其中一部分可以在以后重用。
在软件工程中,代码重用是能够给软件项目带来一系列优势的技术之一,例如以下这些:
-
由于重用的代码部分已经在另一个应用程序中经过测试,因此对软件有信心。
-
软件架构师和高级团队有更高效的利用方式,因为他们可以专注于解决这类问题。
-
有可能将市场上已经接受的模式引入项目中。
-
由于已经实现了组件,开发速度会提高。
-
维护起来更简单。
这些方面表明,代码复用应该尽可能地进行。然而,创建可复用组件的初始成本会更高。这就是为什么你需要专注于在你能认识到这段代码将来真的会被复用的情况下创建它,或者在你试图复用尚未作为组件创建的代码的情况下。作为软件架构师,确保利用前面的优势是你的责任,而且更重要的是,激励你的团队在创建的软件中启用复用。
在下一节中,我们将讨论什么可以被认为是代码复用,什么不可以。讨论的主要目的是帮助你定义一个代码复用策略,这将改变你团队的效率。
代码复用不是什么
你必须理解的第一件事是,代码复用并不意味着从一个类复制粘贴代码到另一个类。即使这段代码是由另一个团队或项目编写的,这也不表明你正确地运用了复用原则。让我们想象一个场景,我们将在本书的使用案例中找到这个场景,即WWTravelClub评估。
在这个项目场景中,你可能想要评估不同类型的主题,例如包、目的地专家、城市、评论等等。无论你指的是哪个主题,获取评估平均值的流程都是相同的。正因为如此,你可能希望通过复制粘贴每个评估的代码来启用复用。结果可能如下所示:
图 5.1:糟糕的实现 – 这里没有代码复用
在前面的图中,计算评估平均值的流程是分散的,这意味着相同的代码将在不同的类中重复。这将造成很多麻烦,尤其是在其他应用程序中也使用了相同的方法。例如,如果有一个新的规范涵盖了如何计算平均值,或者即使只是计算公式中出现了错误,你也必须修复所有代码实例。如果你忘记在所有地方更新它,你可能会得到不一致的实现。
在下一节中,我们将重新组织这段代码,以尊重一些你作为软件架构师应该遵循的原则,以避免我们在这里提到的问题。
代码复用是什么
解决上一节中提到的问题的方案相当简单:你必须分析你的代码,并选择其中那些可以从你的应用程序中解耦的部分。
你应该解耦的最有力的理由与你如何确保这段代码可以在应用程序的其他部分或其他应用程序中复用有关。这正是 Andrew Hunt 和 David Thomas 提出的 DRY 原则(不要重复自己):
图 5.2:专注于代码重用的实现
代码的集中化给你这样的软件架构师带来了不同的责任。你必须记住,如果这段代码中存在错误或问题,它可能会在应用程序的许多部分甚至使用它的其他应用程序中引起问题。另一方面,一旦你测试并运行了这段代码,你将能够无忧无虑地在新的项目中再次重用这段代码。此外,让我们记住我们在这里要实现的使用案例:你可能想要评估不同类型的主题,例如包、目的地专家、城市、评论等等。无论你指的是哪个主题,获取评估平均值的流程都是相同的。如果你需要进化平均计算过程呢?在设计现在提供的方案中,你将不得不在一个类中更改代码。考虑到我们已经学到的知识,我们还可以:
-
创建一个基类,实现方法的逻辑。
-
从新创建的基类继承所有其他类,并最终增强/修改方法的行为。
-
将继承转换为关联(如前一章所述)。
值得注意的是,你使用相同代码的次数越多,这种开发就越便宜。尽管最初开发可重用代码可能看起来成本更高,但随着使用次数的增加,开发过程在时间上变得越来越经济高效。需要提到成本,因为通常情况下,可重用软件的概念在开始时成本更高。
开发生命周期中的可重用性
如果你理解代码的可重用性将带你进入另一个层次的编码,改善你编写和使用代码的方式,那么是时候考虑如何使这项技术在你的开发生命周期中变得可用。
实际上,由于你将承担的责任以及缺乏支持现有组件搜索的良好工具,创建和维护组件库并不容易。
另一方面,有一些实践,你可能在每次启动新的软件开发时都考虑实施:
-
使用用户库中已经实现的组件,选择在软件需求规范中需要它们的特性。
-
识别软件需求规范中可以作为库组件设计的特性。
-
修改规范,考虑到这些特性将使用可重用组件开发。
-
设计可重用组件,并确保它们具有适用于许多项目的适当接口。
-
构建使用新组件库版本的项目架构。
-
记录组件库版本,以便每个开发者和团队都知道。
使用-识别-修改-设计-构建 过程是一种你可能每次需要启用软件重用时都考虑实施的技术。一旦你有了为这个库编写所需的组件,你将需要决定提供这些组件的技术。
在软件开发的历史中,有许多方法可以实现代码重用,从 动态链接库 (DLLs) 到微服务,正如我们将在第十一章 将微服务架构应用于企业应用程序 中的 微服务和模块概念的演变 节所讨论的。该节中解释的方法可以由你,作为软件架构师,用来实施此策略以加速软件开发。现在,让我们看看 .NET 8 如何帮助我们实现这一点。
使用 .NET 8 进行代码重用
.NET 自第一版以来已经发展了很多。这种演变不仅与命令数量和性能问题有关,还与支持的平台有关。正如我们在第一章 理解软件架构的重要性 中所讨论的,你可以在运行 Linux、Android、macOS 或 iOS 的数十亿台设备上运行 C# .NET。因此,.NET Standard 首次与 .NET Core 1.0 一起宣布,但随着 .NET Standard 2.0 的推出,.NET Framework 4.7.2、.NET Core 和 Xamarin 与其兼容,.NET Standard 变得尤为重要。
关键点在于 .NET Standard 不仅仅是一个 Visual Studio 项目。更重要的是,它是一个对所有 .NET 实现都适用的正式规范。正如你可以在下表中所见,由微软推荐的 .NET Standard 2.0 覆盖了 .NET 中的所有内容。你可以在 docs.microsoft.com/en-us/dotnet/standard/net-standard
找到完整的 .NET Standard 概览。
.NET 实现 | 版本支持 |
---|---|
.NET 和 .NET Core | 2.0, 2.1, 2.2, 3.0, 3.1, 5.0, 6.0, 7.0, 8.0 |
.NET Framework 1 | 4.6.1 2, 4.6.2, 4.7, 4.7.1, 4.7.2, 4.8, 4.8.1 |
Mono | 5.4, 6.4 |
Xamarin.iOS | 10.14, 12.16 |
Xamarin.Mac | 3.8, 5.16 |
Xamarin.Android | 8.0, 10.0 |
通用 Windows 平台 | 10.0.16299, 待定 |
Unity | 2018.1 |
表 5.1: .NET Standard 2.0 支持
这表明,如果你构建一个与该标准兼容的类库,你将能够在所展示的任何平台上重用它。想想如果你在所有项目中都这样做,你的开发过程会变得多快。
显然,一些组件不包括在 .NET Standard 中,但其演变是持续的。值得一提的是,微软的官方文档指出,版本越高,可用的 API 越多。
为了让所有平台都使用单个框架的倡议,我们来到了.NET 5。微软指出,从.NET 5.0 开始,框架将在任何地方运行。作为软件架构师的你可能会问,.NET Standard 将会怎样?
这个问题的答案在 Immo Landwerth 的 dotnet 博客中得到了很好的解释:devblogs.microsoft.com/dotnet/the-future-of-net-standard/
。基本的答案是,.NET 5.0(以及未来的版本)需要被视为未来共享代码的基础。考虑到.NET 8 是一个LTS(长期支持)版本,我们现在可以理解这个框架是分享新应用程序代码的最佳选择。
考虑到这个场景,现在是时候检查如何创建可重用类库了。因此,让我们进入下一个主题。
创建可重用类库
如果你想要创建可以被多个应用程序使用的有用功能,你需要创建一个类库项目。因此,使用.NET 创建类库是重用代码的最佳方式。创建类库相当简单。基本上,在创建库时,你需要选择以下项目:
图 5.3:创建类库
一旦你完成了这部分,你会注意到项目文件会保留关于目标框架标识符(TFM)的信息。TFM 的想法是定义将可用于库的 API 集合。你可以在docs.microsoft.com/en-us/dotnet/standard/frameworks
找到可用的 TFM 列表:
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net8.0</TargetFramework>
<Nullable>enable</Nullable>
</PropertyGroup>
</Project>
一旦你的项目加载完毕,你就可以开始编写你打算重用的类。使用这种方法构建可重用类的优点是,你将能够在之前检查的所有项目类型中重用所编写的代码。另一方面,你会发现一些在.NET Framework 中可用的 API,在这个类型的项目中并不存在。
考虑到你已经选择了正确的项目类型来创建可重用代码,让我们在下一节中看看 C#是如何处理代码重用的。
C#是如何处理代码重用的?
考虑到你正在使用 C#.NET 编写类库,有许多方法中C#帮助我们处理代码重用。我们之前所做的那样构建库的能力是其中之一。其中最重要的一个事实是,这种语言是面向对象的。除此之外,还值得提到泛型为 C#语言带来的便利。本节将讨论面向对象分析和泛型原则。
面向对象分析
面向对象分析方法使我们能够以不同的方式重用代码,从继承的便利性到多态的可变性。完全采用面向对象编程将使您能够实现抽象和封装。
重要的是要提到,在第四章,C# 编码最佳实践 12中,我们讨论了继承如何导致代码复杂化。尽管以下示例展示了一种有效的代码重用方法,但在实际应用中,考虑使用组合而非继承。
下图展示了使用面向对象方法如何使重用变得更加容易。正如您所看到的,考虑到您可以是该示例系统的基本用户或高级用户,计算评估成绩的方法有多种:
图 5.4:面向对象案例分析
在此设计中,代码重用有两个方面需要分析。第一个方面是,由于继承会为您完成,因此不需要在每个子类中声明属性。
第二个方面是使用多态的机会,它允许同一方法有不同的行为:
public class PrimeUsersEvaluation : Evaluation
{
/// <summary>
/// The business rule implemented here indicates that grades
/// that came from prime users have 20% of increase
/// </summary>
/// <returns>the final grade from a prime user</returns>
public override double CalculateGrade()
{
return Grade * 1.2;
}
}
在前面的代码中,您可以看到多态原则的应用,其中高级用户的评估计算将增加 20%。现在,看看调用同一类继承的不同对象是多么容易。由于集合内容实现了相同的接口 IContentEvaluated
,它也可以有基本用户和高级用户:
public class EvaluationService
{
public IContentEvaluated Content { get; set; }
/// <summary>
/// No matter the Evaluation, the calculation will always get
/// values from the method CalculateGrade
/// </summary>
/// <returns>The average of the grade from
/// Evaluations
/// </returns>
public double CalculateEvaluationAverage()
{
return Content.Evaluations
.Select(x => x.CalculateGrade())
.Average();
}
}
在使用 C# 时,考虑面向对象的使用是强制性的。然而,更具体的用法需要学习和实践。作为软件架构师,您应该始终鼓励您的团队学习面向对象分析。他们越抽象的能力,代码重用就越容易。
泛型
泛型在 C# 2.0 版本中引入,被认为是一种提高代码重用的方法。它还最大限度地提高了类型安全和性能。
泛型的基本原理是,您可以在接口、类、方法、属性、事件或甚至委托中定义一个占位符,该占位符将在使用前述实体之一时被替换为特定类型。由于您可以使用相同的代码运行不同版本的泛型类型,因此您利用此功能的机会是难以置信的。
以下代码是对上一节中介绍的 EvaluationService
的修改。这里的想法是使服务通用化,从而在创建时就定义评估的目标:
public class EvaluationService<T> where T: IContentEvaluated, new()
此声明表明,任何实现 IContentEvaluated
接口的类都可以用于此服务。新的约束表明此类必须有一个公共的无参数默认构造函数。
该服务将负责创建评估内容:
public EvaluationService()
{
var name = GetTypeOfEvaluation();
content = new T();
}
值得注意的是,这段代码之所以能工作,是因为所有类都在同一个程序集里。这次修改的结果可以在服务的实例创建中检查:
var service = new EvaluationService<CityEvaluation>();
好消息是,现在您有一个通用的服务,它将自动实例化您需要的list
对象,并包含内容评估。值得一提的是,泛型显然需要更多时间来构建第一个项目。然而,一旦设计完成,您将拥有良好、快速且易于维护的代码。这就是我们所说的重用!
如果代码不可重用怎么办?
实际上,任何代码都可以是可重用的。关键点在于,您打算重用的代码必须编写良好,并遵循良好的重用模式。有几个原因说明为什么代码应该被视为不可重用:
-
代码之前未经过测试:在重用代码之前,确保它能够正常工作是一个很好的方法。
-
代码重复:如果您有重复的代码,您需要找到它被使用的每个地方,以确保只有一个版本的代码被重用。如果您发现代码的不同版本被重复,您需要定义最佳版本作为可重用版本,同时您还需要重新测试所有替换的重复代码,以确保软件的功能保持不变。
-
代码过于复杂,难以理解:在许多地方被重用的代码需要以简洁的方式编写,以便易于理解。
-
代码有紧密耦合:这是关于在构建单独的类库时,是使用组合还是继承的讨论。通常,具有接口的类比可以继承的基类更容易重用。
在任何这些情况下,考虑重构策略都可以是一个很好的方法。当您重构代码时,您会以更好的方式编写它,保证代码标准,减少复杂性,并尊重此代码将处理的数据输入和输出。这使您能够在适当的时候对代码进行更全面和成本更低的更改。马丁·福勒在他的 2018 年出版的《重构》一书中,指出了您应该考虑重构的一些原因:
-
它改善了软件设计:随着团队的专业技能不断提高,设计将变得更好。更好的软件设计不仅会带来更快的编码速度,还会给我们带来在更短的时间内处理更多任务的机会。
-
它使软件更容易理解:无论我们是在谈论初级还是高级开发者,好的软件需要让团队中的每个开发者都能理解。
-
它帮助我们找到错误:在重构代码的同时,您正在审查代码。在这个过程中,您可能会发现一些可能没有编写好的业务规则,因此您可能会发现错误。然而,不要忘记重构的基础是保持行为,所以请确保这是修复问题的正确时机。
-
这使我们的程序更快:重构的结果将是能够使未来开发更快的代码。
在重构时,我们可以通过遵循以下步骤来保证良好的结果并最小化在过程中发生的错误:
-
务必确保有一套测试来保证正确的处理:这套测试将消除破坏代码的恐惧。
-
消除重复:重构是消除代码重复的好机会。
-
最小化复杂性:鉴于一个目标是使代码更易于理解,遵循第四章中提到的编程最佳实践,即 《C# 编程最佳实践 12》,将有助于你减少代码的复杂性。
-
清理设计:重构是重新组织库的设计的好时机。不要忘记更新它们。这可以是一种消除错误和安全问题的极好方式。
作为软件架构师,你将收到来自你团队许多重构的要求。重构的激励必须是持续的,但你必须提醒你的团队,如果不遵循前面的步骤进行重构,可能会存在风险,一旦在过程中可能引起错误。因此,确保重构以既能够快速编程又能减少因重构过程引起的不必要错误的影响,从而提供真正的商业价值,这是你的责任。
我有我的库。我该如何推广它们?
一旦你付出了所有必要的努力来保证你拥有许多项目中可以重用的良好库,你将发现当启用可重用性时会出现另一个困难的情况:让程序员知道你有现成的库可供重用并不那么容易。
记录库有一些简单的方法。正如我们在讨论开发生命周期时提到的,记录是帮助开发者注意他们拥有的库的好方法。在接下来的小节中,我们将提到两个记录可重用代码的例子。
使用 DocFX 记录 .NET 库
DocFX 是使用代码中的注释来记录库的好选择。通过简单地添加 docfx.console
NuGet 包,这个工具允许你创建一个任务,一旦你的库构建完成就会运行:
图 5.5:docfx.console NuGet 库
这个编译的输出是一个包含你的代码文档的时尚静态网站:
![图片 B19820_05_06.png]
图 5.6:DocFx 结果
这个网站很有用,因为你可以将文档分发给你的团队,以便他们可以搜索你拥有的库。你可以检查输出的自定义设置,并在 dotnet.github.io/docfx/
找到更多关于它的信息。
使用 Swagger 记录 Web API
毫无疑问,Web API 是促进和推动代码复用的技术之一。因此,确保良好的文档,更重要的是,尊重标准,是良好的实践,这表明你了解提供可重用 API 的方法。为此,我们有了 Swagger,它遵循 OpenAPI 规范。
OpenAPI 规范被认为是描述现代 API 的标准。在 ASP.NET Core Web API 中,用于记录 API 的最广泛使用的工具之一是 Swashbuckle.AspNetCore
。
使用 Swashbuckle.AspNetCore
库的好处在于,你可以为你的 Web API 设置 Swagger UI 查看器,这是一种良好的、图形化的方式来分发 API。
我们将在第十五章“使用 .NET 应用服务架构”中学习如何在 ASP.NET Core Web API 中使用这个库。在此之前,了解这份文档不仅可以帮助你的团队,还可以帮助任何可能使用你正在开发的 API 的开发者,这一点非常重要。
摘要
本章旨在帮助你了解代码复用的优势。它还让你对哪些代码不适合复用有了概念。本章还介绍了代码复用和重构的方法。
考虑到没有流程的技术无法带你走得更远,我们提出了一种帮助实现代码复用的流程。这个流程与使用库中已经完成的组件相关,识别软件需求规格说明中可以作为库组件设计的候选功能,根据这些功能修改规格,设计可重用组件,并使用新的组件库版本构建项目架构。
在本章的结尾,我们介绍了 .NET Standard 库作为在不同 C# 平台上复用代码的方法,指出 .NET 8 和新版本允许跨平台复用代码。本章还强化了在复用代码时面向对象编程的原则,并介绍了泛型作为一种复杂的实现方式,以简化具有相同特性的对象的处理。
问题
-
可以将复制粘贴视为代码复用吗?这种方法的有哪些影响?
-
如何在不复制粘贴的情况下复用代码?
-
有没有可以帮助代码复用的流程?
-
.NET Standard 和 .NET Core 之间的区别是什么?
-
创建 .NET Standard 库有哪些优势?
-
面向对象分析如何帮助代码复用?
-
泛型如何帮助代码复用?
-
.NET Standard 将会被 .NET 6 取代吗?
-
与重构相关的挑战有哪些?
进一步阅读
以下是一些书籍和网站,你可以在那里找到本章涵盖主题的更多信息:
-
《敏捷软件开发工艺手册:Clean Code》,作者:马丁,罗伯特·C·皮尔逊教育,2012 年。
-
《整洁架构:软件结构与设计的工匠指南》 由 Martin,Robert C. Pearson Education 著,2018 年。
-
《设计模式:可复用面向对象软件元素》 由 Eric Gamma [等] 著,Addison-Wesley,1994 年。
-
《设计原则与设计模式》 由 Robert C. Martin 著,2000 年。
-
《重构》 由 Martin Fowler 著,2018 年。
-
如果您需要更多关于 .NET Standard 的信息:
-
使用泛型概念进行编程的绝佳指南:
docs.microsoft.com/pt-br/dotnet/csharp/programming-guide/generics/
-
一些可能有助于您编写库和 API 文档的链接:
在 Discord 上了解更多
要加入这本书的 Discord 社区——在那里您可以分享反馈、向作者提问,并了解新版本——请扫描下面的二维码:
packt.link/SoftwareArchitectureCSharp12Dotnet8
第六章:设计模式与 .NET 8 实现
设计模式可以被定义为针对你在软件开发过程中遇到的常见问题的现成架构解决方案。它们对于理解 .NET 架构至关重要,并且对于解决我们在设计任何软件时面临的普通问题非常有用。在本章中,我们将探讨一些设计模式的实现。值得一提的是,这本书并没有解释我们可以使用的所有已知模式。这里的重点是解释研究和应用它们的重要性。
在本章中,我们将涵盖以下主题:
-
理解设计模式及其目的
-
理解 .NET 中可用的设计模式
到本章结束时,你将了解一些可以使用设计模式实现的使用案例。
技术要求
要完成这一章,你需要免费的 Visual Studio 2022 Community Edition 或更高版本。
你可以在github.com/PacktPublishing/Software-Architecture-with-C-Sharp-12-and-.NET-8-4E
/tree/main/ch06 找到本章的示例代码。
理解设计模式及其目的
决定系统的设计是一项挑战,与之相关的责任也非常重大。作为软件架构师,我们必须始终牢记,诸如高度可重用性、良好性能和良好可维护性等特性对于提供良好的解决方案至关重要。这就是设计模式发挥作用并加速设计过程的地方。
正如我们之前提到的,设计模式是已经被讨论和定义的解决方案,以便它们可以解决常见的软件架构问题。这种做法在《设计模式——可重用面向对象软件的元素》一书发布后变得流行起来,其中四人帮(GoF)将这些模式分为三种类型:创建型、结构型和行为型。
有一点时间后,Uncle Bob 将 SOLID 原则引入了开发者社区,给了我们有效地组织每个系统的函数和数据结构的机会。SOLID 设计原则指出了软件组件应该如何设计和连接。值得一提的是,与 GoF 提出的设计模式相比,SOLID 原则并不提供代码食谱。相反,它们在你设计解决方案时提供了遵循的基本原则,以保持软件结构的强大和可靠。它们可以被定义为如下:
-
单一职责:一个模块或函数应该只负责单一目的。
-
开闭原则:一个软件工件应该对扩展开放,但对修改封闭。
-
Liskov 替换原则:当你将程序的一个组件替换为另一个由原始对象的超类型定义的组件时,程序的行为需要保持不变。
-
接口隔离:根据你创建接口的方式,你将促进那些在构建它们的实际对象时可能不会发生的依赖,从而损害系统架构。
-
依赖倒置:最灵活的系统是那些对象依赖仅指向抽象的系统。
随着技术和软件问题的变化,更多的模式被构思出来。云计算的进步带来了一大批这样的模式,其中一些可以在 docs.microsoft.com/en-us/azure/architecture/patterns
找到。内容被组织成三个不同的挑战领域:数据管理、设计和实现、以及消息传递。其中许多在本书的章节中都有描述,如下面的列表所示:
-
异步请求-响应:在讨论 Azure Durable Functions 时,这种模式在 第十六章,使用无服务器 - Azure Functions 中被提出;用于 异步 HTTP API 的编排函数解决了这个模式。
-
隔离舱隔离:在讨论 第十一章,将微服务架构应用于您的企业应用 中的微服务设计原则时,提出了这个模式。
-
缓存旁路:缓存在 第一章,理解软件架构的重要性 中被提出。它还展示了与 Azure Redis 一起使用的用法,在 第十二章,在云中选择您的数据存储 中也有介绍。
-
断路器:实现此策略的方法在 第十一章,将微服务架构应用于您的企业应用 中被提出。
-
命令查询责任分离(CQRS):CQRS 在 第七章,理解软件解决方案的不同领域 中被描述。
-
发布者/订阅者:这个模式将在下面的子节中解释,并在 第七章,理解软件解决方案的不同领域 中进行讨论。
-
重试:第十一章,将微服务架构应用于您的企业应用 中的 弹性任务执行 子节展示了如何使用 Polly,这是一个用于应用重试的通用框架。
-
基于队列的负载均衡:在 第十六章,使用无服务器 – Azure Functions 中提出的场景使用了一个充当任务和服务之间缓冲区的队列。
新模式出现的原因与我们开发新解决方案时面临的挑战有关。今天,在交付云解决方案时,我们必须处理可用性、数据管理、消息传递、监控、性能、可伸缩性、弹性和安全性等方面。
您应该始终考虑在开发中使用设计模式的原因非常简单——作为一个软件架构师,您不能花时间重新发明轮子。然而,使用和理解它们还有一个很好的理由——您会发现许多这些模式已经在 .NET 中实现了。
在接下来的几个小节中,我们将介绍一些最著名的模式。本章的目的是让您知道它们的存在,并需要研究它们,以便您能够加速并简化您的项目。此外,每个模式都将通过一个 C# 代码片段来展示,这样您就可以在项目中实现它,同时始终记住我们谈论的是样本,而不是准备生产的代码。
建造者模式
有时候,您将有一个具有不同行为的复杂对象,这是由于其配置造成的。在您使用它时设置此对象,您可能希望将其配置与其使用解耦,使用已经构建的定制配置。这样,您就有您正在构建的实例的不同表示。这就是您应该使用建造者模式的地方。
下面的类图展示了本书用例中实现的一个场景的模式,该场景在第二十一章,案例研究中呈现。这种设计选择背后的想法是简化 WWTravelClub 中房间的描述方式。
Room
类中实现的 Fluent API 使我们能够更简单地构建每个在各个建造者(SimpleRoomBuilder
和 FamilyRoomBuilder
)中定义的房间类型。
图 6.1:建造者模式
implemented in a way where the configurations of the instances are not set in the main program. Instead, you just build the objects using the Build() method. This example simulates the creation of different room styles (a single room and a family room) in WWTravelClub:
using DesignPatternsSample.BuilderSample;
using System;
namespace DesignPatternsSample
{
class Program
{
static void Main()
{
#region Builder Sample
Console.WriteLine("Builder Sample");
var simpleRoom = new SimpleRoomBuilder().Build();
simpleRoom.Describe();
var familyRoom = new FamilyRoomBuilder().Build();
familyRoom.Describe();
#endregion
Console.ReadKey();
}
}
}
这种实现的成果相当简单,但阐明了您需要实现模式的原因:
图 6.2:建造者模式示例结果
一旦有了实现,代码的演进就变得简单。例如,如果您需要构建不同风格的房间,您只需为该类型的房间创建一个新的建造者,您就可以使用它。
这种实现变得相当简单的原因与链式方法的用法有关,正如我们在 Room
类中看到的那样:
public class Room
{
private readonly string _name;
private bool wiFiFreeOfCharge;
private int numberOfBeds;
private bool balconyAvailable;
public Room(string name)
{
_name = name;
}
public Room WithBalcony()
{
balconyAvailable = true;
return this;
}
public Room WithBed(int numberOfBeds)
{
this.numberOfBeds = numberOfBeds;
return this;
}
public Room WithWiFi()
{
wiFiFreeOfCharge = true;
return this;
}
...
}
幸运的是,如果您需要添加产品的配置设置,您之前使用的所有具体类都将定义在 Builder 接口中,并存储在那里,这样您可以轻松地更新它们。
我们将在理解 .NET 中可用的设计模式部分看到 .NET 中 Builder 模式的一个很好的实现。在那里,您将能够理解如何使用 HostBuilder
实现了 Generic Host。
工厂模式
工厂模式在有多种来自同一抽象的对象且只在运行时知道需要创建哪个对象的情况下很有用。这意味着你将根据某种配置或根据软件当前所在的位置来创建实例。
例如,让我们看看 WWTravelClub 示例。在这里,有一个用户故事描述了该应用程序将拥有来自世界各地的客户支付他们的旅行费用。然而,在现实世界中,每个国家都有不同的支付服务可用。每个国家的支付过程相似,但这个系统将提供多个支付服务。简化这种支付实现的一个好方法是通过使用工厂模式。
以下图示展示了其架构实现的初步概念:
图 6.3:工厂模式
注意,由于你有一个描述应用程序支付服务的接口,你可以使用工厂模式根据可用的服务来更改具体的类:
static void Main()
{
#region Factory Sample
// In this sample, we will use the Factory Method Pattern
// to create a Payment Service to charge a Brazilian
// customer
ProcessCharging(
PaymentServiceFactory.ServicesAvailable.Brazilian,
"gabriel@sample.com",
178.90m,
EnumChargingOptions.CreditCard);
// In this sample, we will use the Factory Method Pattern
// to create a Payment Service to charge an Italian
// customer
ProcessCharging(
PaymentServiceFactory.ServicesAvailable.Italian,
"francesco@sample.com",
188.70m,
EnumChargingOptions.DebitCard);
#endregion
Console.ReadKey();
}
private static void ProcessCharging
(PaymentServiceFactory.ServicesAvailable serviceToCharge,
string emailToCharge, decimal moneyToCharge,
EnumChargingOptions optionToCharge)
{
PaymentServiceFactory factory = new PaymentServiceFactory();
IPaymentService service = factory.Create(serviceToCharge);
service.EmailToCharge = emailToCharge;
service.MoneyToCharge = moneyToCharge;
service.OptionToCharge = optionToCharge;
service.ProcessCharging();
}
再次强调,由于实现了模式,服务的使用已经简化。如果你需要在现实世界的应用程序中使用此代码,你将通过在工厂模式中定义所需的服务的实例来更改实例的行为。
单例模式
当你在应用程序中实现单例时,你将在整个解决方案中实现该对象的单一实例。这是每个应用程序中最常用的模式之一。原因很简单——有许多用例需要某些类只有一个实例。单例通过提供比全局变量更好的解决方案来解决这个问题。
在单例模式中,类负责创建并传递一个将被应用程序使用的单一对象。换句话说,单例类创建一个单一实例:
图 6.4:单例模式
要做到这一点,创建的对象是静态
的,并通过静态属性或方法传递。??=
运算符如果其右操作数的值为 null,则将其值赋给左操作数。
以下代码实现了单例模式,它有一个Message
属性和一个Print()
方法:
public sealed class SingletonDemo
{
#region This is the Singleton definition
private static SingletonDemo _instance;
public static SingletonDemo Current => _instance ??= new
SingletonDemo();
#endregion
public string Message { get; set; }
public void Print()
{
Console.WriteLine(Message);
}
}
其用法很简单——每次你需要使用单例对象时,只需调用静态属性即可:
SingletonDemo.Current.Message = "This text will be printed by " +
"the singleton. Be careful with concurrency.";
SingletonDemo.Current.Print();
请注意,根据定义的使用方式,示例可能存在并发问题!请参阅第二章,非功能性需求,了解更多关于并发和多线程的信息。
你可以使用这种模式的情况之一是,当你需要以易于从解决方案的任何位置访问的方式提供应用程序配置时。例如,假设你有一些存储在表中且应用程序需要在几个决策点查询的配置参数。尽管我们有像appsettings.json
或web.config
这样的标准解决方案,其中缓存是开箱即用的,但你可能想使用这个自定义解决方案。在这种情况下,你不必直接查询配置表,可以创建一个 Singleton 类来帮助你:
图 6.5:Singleton 模式使用
此外,你还需要在这个 Singleton 中实现一个缓存,从而提高系统的性能,因为你可以决定系统是否每次需要时都会检查数据库中的每个配置,或者是否使用缓存。以下截图显示了缓存的实现,其中配置每 5 秒加载一次。在这种情况下读取的参数只是一个随机数:
图 6.6:Singleton 模式内的缓存实现
这对应用程序的性能大有裨益。此外,在代码的多个地方使用参数更简单,因为你不必在代码的每个地方都创建配置实例。
值得注意的是,由于.NET 中的依赖注入实现,Singleton 模式的使用已经变得不那么常见,因为你可以设置依赖注入来处理你的 Singleton 对象。我们将在本章后面的部分介绍.NET 中的依赖注入。
代理模式
当你需要提供一个控制对另一个对象访问的对象时,会使用代理模式。你应该这样做的一个最大的原因是与被控制对象的创建成本相关。例如,如果被控制的对象创建时间过长或消耗过多内存,可以使用代理来确保对象的大部分只有在需要时才会创建。
以下类图是一个代理模式实现,用于从Room(参见图 6.1)加载图片,但仅在请求时:
图 6.7:代理模式实现
这个代理的客户将请求其创建。在这里,代理将只从真实对象收集基本信息(Id
、FileName
和Tags
),而不会查询PictureData
。
只有在第一次调用中请求 PictureData
时,代理才会加载它,正如我们可以通过消息“现在图片已加载!”来确认。之后,在第二次图片数据请求中,代理将不会再次加载 PictureData
,这会导致更好的性能,正如我们在 图 6.8 中可以看到:
static void Main()
{
Console.WriteLine("Proxy Sample");
ExecuteProxySample(new ProxyRoomPicture());
}
private static void ExecuteProxySample(IRoomPicture roomPicture)
{
Console.WriteLine($"Picture Id: {roomPicture.Id}");
Console.WriteLine($"Picture FileName: {roomPicture.FileName}");
Console.WriteLine($"Tags: {string.Join(";", roomPicture.Tags)}");
Console.WriteLine($"1st call: Picture Data");
Console.WriteLine($"Image: {roomPicture.PictureData}");
Console.WriteLine($"2nd call: Picture Data");
Console.WriteLine($"Image: {roomPicture.PictureData}");
}
如果再次请求 PictureData
,由于图像数据已经就绪,代理将保证不会重复重新加载图像。以下截图显示了运行前面代码的结果:
图 6.8:代理模式结果
这种技术也可以被称为 延迟加载。实际上,代理模式是实现延迟加载的一种方式。实现延迟加载的另一种方法是使用 Lazy<T>
类型。例如,在 Entity Framework Core 中,如 第十三章 中所讨论的,在 C# 中与数据交互 – Entity Framework Core,你可以通过代理启用延迟加载。你可以在 docs.microsoft.com/en-us/ef/core/querying/related-data#lazy-loading
上了解更多信息。
命令模式
有许多情况下,你需要执行一个将影响对象行为的 命令。命令模式可以通过封装这种请求为一个对象来帮助你处理这种情况。该模式还描述了如何处理请求的撤销/重做支持。
还有一个名为 Memento 的设计模式,它也有实现撤销动作的目的。然而,命令模式侧重于将请求封装为对象,而 Memento 侧重于捕获和外部化对象的内部状态,以便稍后恢复。
例如,让我们想象在 WWTravelClub 网站上,用户可以通过指定他们是否喜欢、不喜欢,甚至热爱他们的体验来评估套餐。
以下类图是一个示例,说明如何使用命令模式创建此评分系统:
图 6.9:命令模式
注意这个模式的工作方式——如果你需要一个不同的命令,比如 Hate
,你只需基于 ICommand
接口实现一个新的类。你不需要更改其他动作类的代码。Package
类中的更改不会影响已实现的命令。除此之外,如果你想实现 Redo
方法,它可以用类似的方式添加到 Undo
方法中。这个完整的代码示例可以在本书的 GitHub 仓库中找到。
还可能有助于提到,ASP.NET Core MVC 使用命令模式来处理其 IActionResult
层次。第七章 中描述的业务操作,理解软件解决方案中的不同领域,将使用此模式来执行业务规则。
发布者/订阅者模式
在所有应用程序中,从对象向一组其他对象提供信息是很常见的。当有大量组件(订阅者)将接收包含由对象(发布者)发送的信息的消息时,发布者/订阅者模式几乎是强制性的。
这里的概念非常简单易懂,如下面的图所示:
图 6.10:发布者/订阅者示例案例
当你有不定数量的不同可能的订阅者时,将广播信息的组件与消费它的组件解耦是至关重要的。发布者/订阅者模式为我们做到了这一点。
实现这个模式很复杂,因为分布式环境不是一项简单任务。因此,建议您考虑使用现有的技术来实现连接输入通道和输出通道的消息代理,而不是从头开始构建。Azure Service Bus 是一个可靠的基础设施组件,您可以在其中找到提供的这个模式,所以您需要做的只是连接到它。
在第十一章,将微服务架构应用于您的企业应用程序和第十四章,使用.NET 实现微服务中提到的 RabbitMQ,是另一个可以用来实现消息代理的服务,但它是一个更底层的基础设施,需要手动实现几个功能,如错误重试。
依赖注入模式
依赖注入模式被认为是实现依赖倒置原则的好方法,指导您实现这个 SOLID 原则。
这个概念非常简单。您不需要创建组件所依赖的对象的实例,只需定义它们的依赖关系,声明它们的接口,并通过注入使对象能够接收。
有三种执行依赖注入的方法:
-
使用类的构造函数接收对象
-
标记一些类属性以接收对象
-
定义一个带有注入所有必要组件的方法的接口
下面的图展示了使用类构造函数接收对象的依赖注入模式的实现。在这种情况下,它接收UserAddress
和DestinationAddress
类,它们可以完全不同,但都实现了IAddress
接口,这使得距离计算器能够工作:
图 6.11:依赖注入模式
除了上述三种方法之外,依赖注入还可以与控制反转(IoC)容器一起使用。这个容器使得在需要时自动注入依赖项成为可能。市场上有多款 IoC 容器框架可用,但使用.NET 8,在大多数情况下,不需要使用第三方软件,因为它包含了一组在Microsoft.Extensions.DependencyInjection
命名空间中解决此问题的库。
此 IoC 容器负责创建和销毁请求的对象。依赖注入的实现基于构造函数类型。注入组件的生命周期有三种选择:
-
瞬态:每次请求时都会创建对象。
-
作用域内:对象是为应用程序中定义的每个作用域创建的。在一个 Web 应用程序中,作用域与一个 Web 请求相关联,除非你创建了一个自定义作用域。一个可以使用自定义作用域的好例子是多租户应用程序。在这种情况下,你可能希望按租户管理依赖项。
-
单例:每个对象都有相同的应用程序生命周期,因此单个对象被重用来服务给定类型的所有请求。如果你的对象包含状态,除非它是线程安全的,否则不应使用此对象。记得参考第二章“非功能性需求”,以了解更多关于多线程的信息。
你使用这些选项的方式取决于你正在开发的项目中的业务规则。这同样也是一个关于如何注册应用程序服务的问题。你需要小心地决定正确的选项,因为应用程序的行为将根据你注入的对象类型而改变。
理解.NET 中可用的设计模式
正如我们在前面的章节中所发现的,C#允许我们实现任何模式。.NET 在其 SDK 中提供了许多遵循我们讨论的所有模式的实现,例如 Entity Framework Core 代理懒加载。另一个自.NET Core 2.1 以来就可用的好例子是.NET Generic Host,它并不直接实现特定的模式,而是结合了许多模式,为.NET 应用程序提供了一个灵活且可扩展的主机环境。
在第十七章“展示 ASP.NET Core MVC”中,我们将详细介绍.NET 8 中可用的 Web 应用程序的托管情况。这个 Web 宿主帮助我们,因为应用程序的启动和生命周期管理都是与其一起设置的。.NET Generic Host 的思路是使这种方式适用于不需要 HTTP 实现的应用程序。使用这个 Generic Host,任何.NET 程序都可以有一个启动类,在那里我们可以配置依赖注入引擎。这对于创建多服务应用程序很有用,复合模式可以作为其基础。
你可以在docs.microsoft.com/en-us/aspnet/core/fundamentals/host/generic-host
了解更多关于.NET 通用宿主的信息,其中包含一些示例代码,并且这是微软的当前推荐。
GitHub 仓库中提供的代码很简单,但它专注于创建一个可以运行监控服务的控制台应用程序。令人兴奋的是,控制台应用程序的设置方式,其中构建器配置了应用程序将提供的服务以及日志管理的方式。
这在以下代码中有所体现:
public static void Main()
{
CreateHostBuilder().Build().Run();
Console.WriteLine("Host has terminated. Press any key to finish the App.");
Console.ReadKey();
}
public static IHostBuilder CreateHostBuilder() =>
Host.CreateDefaultBuilder()
.ConfigureServices((hostContext, services) =>
{
services.AddHostedService<HostedService>();
services.AddHostedService<MonitoringService>();
})
.ConfigureLogging((hostContext, configLogging) =>
{
configLogging.AddConsole();
});
上述代码让我们了解了.NET 是如何使用设计模式的。使用 Builder 模式,.NET 通用宿主允许你设置将被注入为服务的类。除此之外,Builder 模式还帮助你配置一些其他功能,例如日志的显示/存储方式。这种配置允许服务将ILogger<outTCategoryName>
对象注入到任何实例中。
其他使用通用宿主的例子包括工作服务,这在第十四章,使用.NET 实现微服务中有所描述,以及 Blazor 的通用宿主,这在第十九章,客户端框架:Blazor中有所描述。
摘要
在本章中,我们了解了为什么设计模式有助于提高你正在构建的系统部分的维护性和可重用性。我们还探讨了你在项目中可以考虑的一些典型用例和代码示例,始终记住它们需要进化以达到专业交付。最后,我们介绍了.NET 通用宿主,这是.NET 如何使用设计模式来实现代码重用和强制最佳实践的良例。
所有这些内容都将帮助你进行新的软件架构或维护现有系统,因为设计模式是软件开发中一些现实生活问题的已知解决方案。
在第七章,理解软件解决方案的不同领域中,我们将介绍领域驱动设计方法。我们还将学习如何使用 SOLID 设计原则,以便我们可以将不同的领域映射到我们的软件解决方案中。
问题
-
什么是设计模式?
-
设计模式和设计原则之间的区别是什么?
-
在什么情况下实现 Builder 模式是一个好主意?
-
在什么情况下实现工厂模式是一个好主意?
-
在什么情况下实现单例模式是一个好主意?
-
在什么情况下实现代理模式是一个好主意?
-
在什么情况下实现命令模式是一个好主意?
-
在什么情况下实现发布/订阅模式是一个好主意?
-
在什么情况下实现依赖注入模式是一个好主意?
进一步阅读
以下是一些书籍和网站,你可以从中了解更多关于本章所涵盖内容的信息:
-
Clean Architecture:软件结构和设计的工匠指南,作者:罗伯特·C·马丁,Pearson Education,2018 年。
-
设计模式:可复用面向对象软件的元素,Erich Gamma 等著,Addison-Wesley,1994。
-
设计原则与设计模式,Martin, Robert C.,2000。
-
如果你需要获取更多关于设计模式和架构原则的信息,请查看以下链接:
-
如果你想检查特定的云设计模式,你可以在以下位置找到它们:
-
如果你想更好地理解通用宿主(Generic Host)的概念,请点击以下链接:
-
在此链接中有一个关于服务总线(Service Bus)消息的非常好的解释:
-
你可以通过查看以下链接来了解更多关于依赖注入(dependency injection)的信息:
在 Discord 上了解更多
要加入这本书的 Discord 社区——在那里你可以分享反馈、向作者提问,并了解新版本——请扫描下面的二维码:
packt.link/SoftwareArchitectureCSharp12Dotnet8
第七章:理解软件解决方案中的不同领域
本章致力于一种现代软件开发技术,称为领域驱动设计(DDD),它最初由埃里克·埃文斯(Eric Evans)提出(参见《领域驱动设计*》:www.amazon.com/exec/obidos/ASIN/0321125215/domainlanguag-20
)。虽然 DDD 已经存在了 15 多年,但由于其解决两个重要问题的能力,它在过去几年取得了巨大的成功。
主要问题是对涉及多个知识领域的复杂系统进行建模。没有哪个专家对整个领域有深入的了解;这种知识被分散在几个人身上。第二个问题是每个专家都使用其专业领域的特定语言,因此为了专家和开发团队之间有效的沟通,对象、接口和方法必须模仿领域专家的语言。这意味着构成应用程序的不同模块必须为每个专业领域使用不同的词汇。因此,应用程序必须分割成反映不同知识领域的模块,并且处理不同知识领域的模块之间的接口必须精心设计,以执行必要的翻译。
DDD 通过将整个 CI/CD 周期分割成独立的部分,分配给不同的团队来解决这一问题。这样,每个团队只需与该领域的专家互动,就可以专注于特定的知识领域。
正是因为这个原因,领域驱动设计(DDD)的演变与微服务和 DevOps 的演变交织在一起。多亏了 DDD,大型项目可以被分割成几个开发团队,每个团队拥有不同的知识领域。项目被分割成几个团队的原因有很多,其中最常见的是团队规模以及所有成员拥有不同的技能和/或位于不同的地点。实际上,经验证明,超过 6-8 人的团队并不有效,显然,不同的技能和地点会阻碍紧密的互动。
相反,上述两个问题的重视程度在过去几年中有所增加。软件系统始终占据每个组织内部的大量空间,并且它们变得越来越复杂和地理上分散。
同时,对频繁更新的需求也在增加,以便这些复杂的软件系统可以适应快速变化的市场需求。
由于软件系统日益复杂和频繁更新的需求,我们现在面临一个常见的场景,即使用具有相关快速 CI/CD 周期的复杂软件系统,这通常需要更多的人来演进和维护它们。反过来,这创造了对适合高复杂度领域和多个松散耦合的开发团队协作的技术需求。
在本章中,我们将分析与领域驱动设计(DDD)相关的基本原则、优势和常见模式,以及如何在我们的解决方案中使用它们。
更具体地说,我们将涵盖以下主题:
-
软件领域是什么?
-
理解领域驱动设计(DDD)
-
常见的 DDD 模式和架构
让我们开始吧。
随着你阅读本书的第二部分,你可能会发现回顾这一特定章节是有益的。对这些概念进行更深入的理解可以提供新的见解并增强你的整体体验。当你以后遇到相关想法时,随时可以回到这一章节作为参考资料。
技术需求
本章需要免费安装所有数据库工具的 Visual Studio 2022 Community Edition 或更高版本。
本章中所有的代码片段都可以在本书相关的 GitHub 仓库中找到:github.com/PacktPublishing/Software-Architecture-with-C-Sharp-12-and-.NET-8-4E
。
软件领域是什么?
如同我们在第二章,非功能性需求,和第三章,需求管理中讨论的那样,从领域专家到开发团队的知识转移在软件设计中起着根本的作用。开发者试图用领域专家和利益相关者也能理解的语言与专家沟通并描述他们的解决方案。然而,在组织的各个部分,同一个词可能有不同的含义,而在不同的背景下,看似相同的概念实体可能有完全不同的形态。
例如,在我们的 WWTravelClub 用例中,订单支付和包裹处理子系统为顾客使用完全不同的模型。订单支付子系统通过支付方式、货币、银行账户和信用卡来表征顾客,而包裹处理则更关注访问过的地点和/或购买过的包裹、用户的偏好以及他们的地理位置。此外,虽然订单支付使用我们可能粗略定义为银行语言的语言来指代各种概念,但包裹处理使用的是典型的旅行社/运营商语言。
应对这些差异的经典方式是使用一个独特的抽象实体,称为客户,它映射到两个不同的视图——订单-支付视图和包裹处理视图。每个投影操作都会从客户抽象实体中提取一些操作和属性,并更改它们的名称。由于领域专家只给我们提供投影视图,因此作为系统设计师,我们的主要任务是创建一个概念模型,可以解释所有这些视图。以下图表显示了如何处理不同的视图:
图 7.1:创建一个独特的模型
经典方法的主要优势是我们有一个独特且一致的数据域表示。如果这个概念模型构建成功,所有操作都将有一个正式的定义和目的,整个抽象将是对整个组织工作方式的合理化,可能突出和纠正错误,并简化一些程序。
然而,这种方法的缺点是什么?
当软件旨在服务于整个组织的一个小部分,或者当软件自动化的数据流比例足够小的时候,新单体数据模型的快速采用在小型组织中可能产生可接受的影响。然而,如果软件成为复杂、地理上分布的组织的主干,那么急剧的变化变得不可接受和不可行。一方面,大型结构化公司需要快速应对市场变化,但另一方面,由于组织复杂性,只有渐进式变化是可行的。因此,为了适应市场,它们组织和信息系统所需的变化必须是渐进的。反过来,只有当旧数据模型可以与新数据模型共存,并且组织的各个组成部分被允许以自己的速度改变——也就是说,如果组织的各个部分可以独立于其他部分发展时,这种渐进式过渡才是可能的。
此外,随着软件系统复杂性的增加,其他几个问题使得经典架构的独特数据模型难以维护:
-
一致性问题:由于我们无法在将这些任务分解成更小、松散耦合的任务时保留复杂性,因此达到唯一一致的数据视图变得更加困难。
-
更新困难:随着复杂性的增加,需要频繁的系统更改,但更新和维护一个独特的全局模型相当困难。此外,由于系统小部分更改引入的 bug/错误可能会通过唯一共享的模型传播到整个组织。
-
团队组织问题:系统建模必须在几个团队之间分配,并且只能将松散耦合的任务分配给不同的团队;如果两个任务强耦合,它们需要分配给同一个团队。
-
并行性问题:正如我们将在第十一章,将微服务架构应用于您的企业应用程序中更详细地讨论的那样,转向基于微服务的架构通常使得单一数据库的瓶颈不可接受。
-
语言问题:随着系统的增长,我们需要与更多的领域专家进行沟通,每位专家说不同的语言,并且对数据模型有不同的看法。因此,我们需要将我们独特模型的特点和操作翻译成更多语言,以便与他们沟通。
随着系统的增长,处理具有数百/数千个字段且投影到较小视图的记录变得越来越低效。这种低效源于数据库引擎处理具有多个字段的较大记录的方式不高效(内存碎片化、太多相关索引的问题等)。然而,主要的不效率发生在对象关系映射(ORMs)和业务层,它们被迫在其更新操作中处理这些大记录。实际上,虽然查询操作通常只需要从存储引擎检索的几个字段,但更新和业务处理涉及整个实体。ORMs 在第十三章,在 C#中使用 Entity Framework Core 与数据交互中详细描述。
随着数据存储子系统中的流量增长,我们需要在所有数据操作中实现读取和更新/写入并行性。正如我们将在第十二章,在云中选择您的数据存储中发现的那样,虽然读取并行性可以通过数据复制轻松实现,但写入并行性需要分片;也就是说,将数据库记录分布在几个分布式数据库中,而分片一个唯一、紧密连接的数据模型是困难的。
这些问题是领域驱动设计(DDD)在过去几年中取得成功的原因,因为它们代表了更复杂的软件系统,这些系统成为了整个组织的支柱。DDD 的基本原则将在下一节中详细讨论。
理解领域驱动设计(DDD)
根据 DDD,我们不应该构建一个独特的领域模型,该模型在每个应用程序子系统中的不同视图中投影。相反,整个应用程序领域被分割成更小的领域,每个领域都有自己的数据模型。这些独立的领域被称为边界上下文。每个边界上下文的特点是专家使用的语言,并用于命名所有领域概念和操作。
因此,每个边界上下文定义了一种由专家和开发团队共同使用的通用语言,称为通用语言。不再需要翻译,如果开发团队使用 C#接口作为其代码的基础,领域专家能够理解和验证它们,因为所有操作和属性都使用专家使用的相同语言表达。
在这里,我们正在摒弃一个繁琐的唯一抽象模型,但现在我们有了几个需要以某种方式关联的独立模型。DDD 建议我们如下处理所有这些独立模型(即所有边界上下文):
-
每当语言术语的含义发生变化时,我们需要添加边界上下文的边界。例如,在 WWTravelClub 用例中,订单-付款和包处理属于不同的边界上下文,因为它们给“客户”这个词赋予了不同的含义。
-
我们需要显式地表示边界上下文之间的关系。不同的开发团队可能在不同边界上下文中工作,但每个团队都必须对其正在工作的边界上下文与其他所有模型之间的关系有一个清晰的了解。因此,这些关系在一个独特的文档中表示,并与每个团队共享。
-
我们需要确保所有边界上下文与 CI 保持一致。会议被组织和简化系统原型被构建,以便验证所有边界上下文是否协调一致地发展——也就是说,所有边界上下文都可以集成到期望的应用行为中。
下一个图显示了我们在上一节中讨论的 WWTravelClub 示例在采用 DDD 后的变化:
图 7.2:DDD 边界上下文之间的关系
每个边界上下文的客户实体之间存在关系,而包处理边界上下文的购买实体与付款相关。在各个边界上下文中识别映射到彼此的实体是正式定义表示上下文之间所有可能通信的接口的第一步。
例如,在上一个图中,由于付款是在购买之后进行的,我们可以推断出订单-付款的边界上下文必须有一个为特定客户创建付款的操作。在这个领域,如果新客户不存在,则会创建新客户。
付款创建操作是在购买后立即触发的。由于在购买商品后还会触发更多操作,我们可以使用我们在第六章“设计模式和.NET 8 实现”中解释的发布/订阅模式来实现与购买事件相关的所有通信。在 DDD 中,这些被称为领域事件。使用事件来实现边界上下文之间的通信非常常见,因为它有助于保持边界上下文松散耦合。
一旦在边界上下文接口中定义的事件或操作的实例跨越了边界,它立即被翻译成接收上下文的通用语言。在输入数据开始与其他领域实体交互之前执行这种翻译是很重要的,以防止接收领域通用语言被额外的上下文术语污染。通常,不充分的翻译会导致领域专家抱怨“奇怪的词语”。
每个边界上下文实现必须包含一个完全用边界上下文通用语言(类和接口名称以及属性和方法名称)表达的领域模型,没有任何其他边界上下文通用语言的污染,也没有技术编程内容的污染。这是确保与领域专家良好沟通并确保将领域规则正确翻译成代码,以便领域专家可以轻松验证的必要条件。
当通信语言与目标通用语言之间存在强烈不匹配时,会在接收边界上下文边界添加一个反腐败层。这个反腐败层的唯一目的是执行语言翻译。
边界上下文之间的关系
包含所有边界上下文表示、边界上下文相互关系和接口定义的文档称为上下文图。上下文之间的关系包含组织约束,指定在不同边界上下文上工作的团队之间所需合作类型。这些关系不约束边界上下文接口,但会影响它们在软件 CI/CD 周期中的演变方式。它们代表了团队合作模式。
最常见的模式如下:
-
合作伙伴:这是埃里克·埃文斯最初提出的模式。其想法是两个团队在交付上相互依赖。换句话说,他们在软件开发生命周期中共同决定边界上下文的相互通信规范。
-
客户/供应商开发团队:在这种情况下,一个团队充当客户,另一个团队充当供应商。在初步阶段,两个团队定义边界上下文客户侧的接口和一些自动化验收测试来验证它。之后,供应商可以独立工作。
当客户的边界上下文是唯一的活动部分,调用其他边界上下文公开的接口方法时,这种模式是有效的。这对于订单-支付和包装处理上下文之间的交互来说是足够的,其中订单-支付作为供应商,因为它的功能从属于包装处理的需求。当这种模式可以应用时,它将两个边界上下文的实现和维护完全解耦。
-
从众者:这与客户/供应商模式类似,但在此情况下,客户方接受由供应商方强加的接口,而没有初步的谈判阶段。这种模式对其他模式没有提供任何优势,但有时我们被迫处于该模式所描述的情况,因为供应商的边界上下文是在一个无法过多配置/修改的现有产品中实现的,或者因为它是一个我们不希望修改的遗留子系统。
值得指出的是,边界上下文的分离只有在结果边界上下文松散耦合时才是有效的;否则,通过将整个系统分解成部分而获得的复杂性降低将被协调和通信过程的复杂性所淹没。
然而,如果边界上下文是根据语言标准定义的——也就是说,每当通用语言发生变化时,就添加边界上下文边界——这实际上应该是这种情况。事实上,由于组织各个部分的松散交互,可能会出现不同的语言,因为每个部分内部紧密交互的程度越高,与其他部分的松散交互越少,每个部分最终定义和使用的内部语言就越多,这与其他部分使用的语言不同。
此外,由于所有的人类组织只是通过演变成为松散耦合的子部分而成长,同样,复杂的软件系统也可以像松散耦合的子模块的合作一样实现:这是人类能够应对复杂性的唯一方式。从这个意义上说,我们可以得出结论,复杂组织/人工系统总是可以被分解成松散耦合的子部分。我们只需要理解“如何”做到这一点。
除了我们之前提到的基本原理之外,领域驱动设计(DDD)提供了一些基本原语来描述每个边界上下文,以及一些实现模式。虽然边界上下文原语是 DDD 的一个组成部分,但这些模式只是我们在实现中可以使用的有用启发式方法,因此,一旦我们选择采用 DDD,在某些或所有边界上下文中使用这些模式并不是强制性的。
在下一节中,我们将描述边界上下文原语,而各种模式将在本章的剩余部分进行描述。
实体
DDD 实体代表具有明确身份的领域对象,以及定义在其上的所有操作。它们与其他更经典的方法的实体差异不大。
主要区别在于,DDD 强调实体的面向对象特性,而其他方法主要将它们用作记录,其属性可以在没有太多约束的情况下写入/更新。
另一方面,DDD 强制实施强 SOLID 原则,以确保只有某些信息被封装在它们内部,并且只有某些信息可以从外部访问,以规定允许对它们执行的操作,并设置适用于它们的业务级验证标准。
换句话说,DDD 实体比基于记录的方法的实体更丰富。
在基于记录的方法中,操作实体是在代表业务和/或领域操作的类中定义的,这些操作在实体外部。在 DDD 中,这些操作被移动到实体定义中,作为它们的类方法。这样做的原因是这种方法提供了更好的模块化,并将相关的软件块保持在同一位置,以便可以轻松维护和测试。
关于基于记录的方法和 DDD 方法的区别的更多细节将在本章的存储库模式小节中给出。
同样出于这个原因,针对每个实体的特定业务验证规则被移动到 DDD 实体内部。DDD 实体验证规则是业务级规则,因此它们不应与数据库完整性规则或用户输入验证规则混淆。它们通过编码所表示对象必须遵守的约束,有助于实体表示领域对象。
例如,在网页上提供实体属性“送货地址”可能是强制性的(用户输入验证规则),尽管通常属性不是强制性的(没有相应的业务验证规则)。事实上,“送货地址”属性只有在需要发货时才是强制性的,所以如果网页的上下文是关于发货的,那么“送货地址”必须在该特定网页中作为用户输入是强制性的,但不是作为一般业务规则。
用户输入验证将在第十七章中更详细地讨论,展示 ASP.NET Core,它将实际展示用户输入验证和业务验证具有不同的和补充的目的。虽然业务级验证规则编码了领域规则,但输入验证规则强制执行每个单个输入的格式(字符串长度、正确的电子邮件和 URL 格式等),确保提供了所有必要的输入,强制执行所选的用户机器交互协议,并提供快速和即时的反馈,引导用户与系统交互。
值得注意的是,并非所有业务验证规则都可以编码在 DDD 实体内部。不特定于单个 DDD 实体但涉及多个实体交互的业务规则必须编码在处理和协调实体间交互的软件模块中。我们将在本章后面更详细地讨论协调实体交互的软件模块。
下一个子节将提供更多关于实体级验证规则的详细信息。
.NET 中的实体级验证
在.NET 中,可以使用以下技术之一执行业务验证:
-
在所有修改实体的类方法中调用验证方法。
-
将验证方法连接到所有属性设置器。
-
使用自定义验证属性装饰类及其属性,并在每次修改实体时调用
System.ComponentModel.DataAnnotations.Validator
类的TryValidateObject
静态方法。.NET 的System.ComponentModel.DataAnnotations
命名空间包含预定义的验证属性(见learn.microsoft.com/en-us/aspnet/core/mvc/models/validation?view=aspnetcore-8.0#built-in-attributes
),但开发者也可以通过从System.ComponentModel.DataAnnotations.ValidationAttribute
抽象类继承来定义自定义验证属性(见makolyte.com/aspnetcore-create-a-custom-model-validation-attribute/
)。
关于验证属性的更多细节将在第十七章“展示 ASP.NET Core”中给出。
一旦检测到验证错误,就必须以某种方式处理;也就是说,必须中止当前操作,并将错误报告给适当的错误处理器。处理验证错误的最简单方法是通过抛出异常。这样,两个目的都很容易实现,我们可以选择在哪里拦截和处理它们。不幸的是,正如我们在第二章“非功能性需求”的“在 C#编程时需要考虑的性能问题”部分中讨论的那样,异常意味着巨大的性能损失,并且必须仅用于处理“异常情况”,因此,通常需要考虑不同的选项。在正常流程中处理错误会破坏模块化,因为需要在导致错误的整个方法堆栈中传播处理错误所需的代码,并且在整个代码中都有一个无限的条件集。因此,需要更复杂的选择。
异常的一个好替代方案是将错误通知给每个处理请求的独特错误处理器。例如,它可以在依赖注入引擎中实现为一个作用域服务。由于作用域,在处理每个请求时返回相同的服务实例,这样控制整个调用堆栈执行的处理器可以在控制流返回时检查可能出现的错误,并适当地处理它们。不幸的是,这种复杂的技巧不能自动中止操作的执行并立即返回到调用堆栈中最合适的控制处理器。
这就是为什么尽管存在性能问题,异常通常用于这种情况。另一种选择是使用结果对象,在所有方法调用中通知调用者操作的成功。然而,结果对象有其缺点:它们在调用堆栈中涉及的方法之间引入了更多的耦合,因此在软件维护期间,每次更改可能都需要修改多个方法。
.NET 中的 DDD 实体
由于 DDD 实体必须有一个明确定义的身份,它们必须具有作为主键的属性。通常,所有 DDD 实体都会覆盖Object.Equals
方法,使得当两个对象具有相同的主键时,它们被认为是相等的。这可以通过让所有实体继承自一个抽象的Entity
类来实现,如下面的代码所示:
public abstract class Entity<K>: IEntity<K>
{
public virtual K Id { get; protected set; }
public bool IsTransient()
{
return Object.Equals(Id, default(K));
}
public override bool Equals(object obj)
{
return obj is Entity<K> entity &&
Equals(entity);
}
public bool Equals(IEntity<K>? other)
{
if (other == null ||
other.IsTransient() || this.IsTransient())
return false;
return Object.Equals(Id, other.Id);
}
int? _requestedHashCode;
public override int GetHashCode()
{
if (!IsTransient())
{
if (!_requestedHashCode.HasValue)
_requestedHashCode = HashCode.Combine(Id);
return _requestedHashCode.Value;
}
else
return base.GetHashCode();
}
public static bool operator ==(Entity<K> left, Entity<K> right)
{
if (Object.Equals(left, null))
return Object.Equals(right, null);
return left.Equals(right);
}
public static bool operator !=(Entity<K> left, Entity<K> right)
{
return !(left == right);
}
}
在代码中注意以下事项:
-
值得实现一个
IEntity<K>
接口,该接口定义了Entity<K>
的所有属性/方法。当我们需要在接口后面隐藏数据类时,这个接口很有用。 -
IsTransient
谓词在实体最近被创建且尚未记录在永久存储中时返回true
,因此其主键仍然是未定义的。 -
在.NET 中,当您覆盖一个类的
Object.Equals
方法时,也覆盖其Object.GetHashCode
方法是一个好的实践,这样类实例可以高效地存储在字典和集合等数据结构中。这就是为什么Entity
类覆盖了它。 -
值得指出的是,一旦我们在
Entity
类中重新定义了Object.Equals
方法,我们也可以覆盖==
和!=
运算符。
实体不是 DDD 的唯一数据成分。领域建模还要求具有无唯一标识的数据。这就是为什么提出了值对象。
值对象
与实体不同,值对象代表无法用数字或字符串编码的复杂类型。因此,它们没有标识符和主键。它们上没有定义操作,且是不可变的;也就是说,一旦它们被创建,所有字段都可以读取但不能修改。因此,它们通常使用具有受保护的/私有设置器的属性的类进行编码。
当所有独立属性都相等时,两个值对象被认为是相等的。有些属性不是独立的,因为它们只是以其他属性以不同的方式编码的数据的展示,例如DateTime
的刻度和其日期和时间字段的表示。
由于所有record
类型自动重写Equals
方法以执行属性逐个比较,因此值对象可以用 C# 12 的record
类型轻松实现。通过适当地定义它们的属性,记录类型也可以被定义为不可变的;一旦不可变对象被初始化,唯一改变其值的方法是创建一个新的实例。以下是如何修改record
的一个示例:
var modifiedAddress = myAddress with {Street = "new street"}
下面是如何定义record
的一个示例:
public record Address
{
public string Country {get; init;}
public string Town {get; init;}
public string Street {get; init;}
}
init
关键字使得record
类型属性不可变,因为它意味着它们只能被初始化。
如果我们通过构造函数传递所有属性而不是使用初始化器,前面的定义可以简化如下:
public record Address(string Country, string Town, string Street) ;
典型的值对象包括以数字和货币符号表示的成本、以经纬度表示的位置、地址和联系信息。
实体和值对象通常在.NET 应用程序中与数据存储交互的方式在第十三章中解释,即在 C#中使用 Entity Framework Core 与数据交互。
聚合
到目前为止,我们讨论了实体作为由基于 DDD 的业务层处理的单元。然而,几个实体可以被操作并合并成单个实体。一个例子是采购订单及其所有项目。实际上,独立于所属订单处理单个订单项是完全不合理的。这是因为订单项实际上是订单的子部分,而不是独立的实体。
没有任何交易可以单独影响一个订单项而不影响该订单项所在的订单。想象一下,同一家公司的两个不同的人试图增加水泥的总数量,但一个人增加了 1 型水泥(项目 1)的数量,而另一个人增加了 2 型水泥(项目 2)的数量。如果每个项目都被当作一个独立的实体来处理,这两个数量都会增加,这可能会导致一个不连贯的采购订单,因为水泥的总数量会被增加两次。
另一方面,如果整个订单及其所有订单项由两个人在每次交易中一起加载和保存,那么其中一个人将覆盖另一个人的更改,因此最后做出更改的人将设置他们的要求。
一个采购订单及其所有子部分(其订单项)被称为聚合,而订单实体被称为聚合的根。由于聚合是由子部分关系连接的实体层次结构,因此它们总是有根。
由于每个聚合代表一个单独的复杂实体,因此对其进行的所有操作都必须通过一个唯一的接口来暴露。因此,聚合根通常代表整个聚合,对聚合的所有操作都定义为根实体的方法。
当使用聚合模式时,在业务层和数据层之间传输的信息单元被称为聚合、查询和查询结果。因此,聚合取代了单个实体。
简而言之,聚合是存储信息的内存表示,需要作为一个单一对象来处理。作为一个基于面向对象范式的内存表示,它们充分利用了面向对象编程的所有好处。
领域事件
领域事件是 DDD 的主要通信成分。虽然 DDD 不对边界上下文之间通信的方式施加约束,但基于第六章、设计模式和 .NET 8 实现 中描述的发布/订阅模式进行的通信,最大化了边界上下文之间的独立性。每个边界上下文发布所有可能引起其他边界上下文兴趣的信息,感兴趣的边界上下文进行订阅。这样,发布者不需要了解每个订阅者及其工作方式,只需以通用格式发布其工作的结果。更多实现细节将在本章的 命令处理器和领域事件 子节中给出。
常见的 DDD 模式和架构
在本节和随后的章节中,我们将描述一些与 DDD 常用的模式和架构。其中一些可以在所有项目中采用,而其他一些则只能用于某些边界上下文。
在我们开始之前,我们应该注意,从概念上讲,每个应用程序的功能可以划分为三个组:
-
处理与用户的交互
-
执行与业务相关的处理
-
与存储引擎交互
上述每个组都使用不同的语言和技术。第一个组使用目标用户的语言和用户界面技术,第二个组使用领域专家的语言,专注于应用领域建模,第三个组则使用与数据库相关的语言和技术。
我们将要查看的每个架构都以不同的方式组织这些功能。我们将从经典的分层架构开始,因为它更容易理解,然后我们将描述更复杂的洋葱架构。
经典分层架构
经典分层架构将三个功能组组织为三个松散耦合的类/接口集合,称为 层,依次排列:
-
序列中的第一层负责用户交互,被称为 表示层。
-
序列中的第二层是执行与业务相关的处理的那一层,被称为业务层。
-
第三层是专门用于数据库交互的层,被称为数据层。
每一层可以直接与其前一层和后一层进行通信,如图下所示:
图 7.3:经典层架构
每个层调用都可以将数据传递给下一层的公共对象的方法,并接收返回的结果数据。
表示层不仅处理图形,还处理整个用户-机器交互协议。在其交互协议中,表示层使用业务层方法向用户展示数据或更新应用程序状态。
反过来,业务层使用数据层方法从数据存储中检索所有需要准备用户答案的数据,并更新应用程序状态。
每一层都向其前一层提供一个定义良好的接口,同时隐藏所有实现细节。
层架构促进了模块化,因为每一层不依赖于其前一层的实现方式,并避免了每种层使用的语言污染其他层使用的语言的可能性。
然而,在经典层架构中交换的数据是记录类型对象,没有包含任何处理逻辑的方法,因为整个处理逻辑都包含在组成三个层的对象和方法中。
由于经典层架构中使用的记录类型对象与 DDD 领域对象非常不同,DDD 领域对象是丰富的对象,其方法中包含了大部分业务逻辑,因此经典层架构与 DDD 不匹配。这就是为什么提出了对经典层架构的改进,称为洋葱架构。
洋葱架构
在洋葱架构中,层遵循不同的规则,并以稍微不同的方式定义。有:
-
最外层负责处理与应用程序环境的所有交互——即用户界面、测试软件以及与操作系统和数据存储的交互。
-
应用层
-
领域层
在这里,领域层是基于通用语言的经典数据层的抽象。它是定义 DDD 实体和值对象以及检索和保存它们操作抽象的地方。为了更好的模块化,所有或某些领域层类可以被设置为内部类,并隐藏在公共接口后面。
相反,应用层定义了使用领域层公共接口(公共接口和公共类)来获取 DDD 实体和值对象,并操纵它们以实现应用业务逻辑的操作。由于它通过一个完全独立于最外层的 API 公开其功能,DDD 应用层被称为应用服务层。这样,例如,任何用户界面层和测试套件都调用完全相同的方法来与应用逻辑交互。
最外层包含用户界面、功能测试套件(如果有)以及与托管应用程序的基础设施的应用程序接口。
基础设施代表了应用程序运行的环境,包括操作系统、任何设备、文件系统服务、云服务和数据库。基础设施接口放置在最外层,以确保没有其他洋葱层依赖于它。这最大化了可用性和可修改性。
基础设施层包含适应其环境的所有驱动程序。基础设施资源通过这些驱动程序与应用程序通信,而驱动程序则通过与其相关的接口将基础设施资源暴露给所有应用程序层,这些接口与在依赖注入引擎中实现它们的驱动程序相关联。这样,将应用程序适应不同的环境只需更改驱动程序即可。
下面是洋葱架构的草图:
图 7.4:洋葱架构
每个环代表一个层。从最外层向内是应用服务层,从应用服务层向内是领域层,它包含了在边界上下文中涉及到的实体的表示。
应用服务层和领域层都可以分为子层,并且所有层/子层都必须遵守以下规则:每一层只能依赖于内部层。
例如,领域层可以分成领域模型和领域服务,其中领域模型位于领域服务之下。领域模型层包含代表所有领域对象的类和接口,而领域服务层包含所谓的存储库,这些存储库在本章的存储库模式和工作单元模式部分中进行了说明。
正如我们将在本章后面看到的那样,通常通过在单独的库中定义并在域层中实现的接口与域层进行交互。因此,域层必须引用包含所有域层接口的库,因为它必须实现这些接口,而应用层则是每个域层接口通过应用层依赖注入引擎的记录与其实现连接的地方。更具体地说,应用层引用的唯一数据层对象是这些仅在依赖注入引擎中引用的接口实现。
实现下一内层定义的接口的外层是洋葱架构中常用的模式。
每个应用层操作都需要从依赖引擎中获取它需要的接口,使用它们来获取 DDD 实体和值对象,操作它们,并通过相同的接口可能保存它们。
下面是一个图表,展示了本节中讨论的三个层之间的关系:
图 7.5:层之间的关系
因此,域层包含域对象的表示、在它们上使用的函数、验证约束以及域层与各种实体之间的关系。为了增加模块化和解耦,实体之间的通信通常使用事件编码——即使用发布者/订阅者模式。这意味着实体更新可以触发已连接到业务操作的事件,并且这些事件作用于其他实体。
这种分层架构使我们能够在不影响域层的情况下更改整个数据层,域层仅依赖于域规范和语言,而不依赖于数据处理的详细技术细节。
应用层包含所有可能影响多个实体的操作定义以及所有应用程序需要的查询定义。业务操作和查询都使用在域层中定义的接口与数据层进行交互。
然而,虽然业务操作通过这些接口操纵和交换实体,但查询向它们发送查询规范并从它们那里接收通用的数据传输对象(DTOs)。实际上,查询的目的只是向用户展示数据,而不是对它们进行操作;因此,查询操作不需要包含所有方法、属性和验证规则的完整实体,而只需要属性元组。
业务操作可以通过其他层(通常是表示层)或通过通信操作来调用。
总结来说,应用层在域层定义的接口上操作,而不是直接与它们的数据层实现交互,这意味着应用层与数据层解耦。更具体地说,数据层对象仅在依赖注入引擎定义中提及。所有其他应用层组件都引用在域层中定义的接口,而依赖注入引擎注入适当的实现。
应用层通过以下一个或多个模式与其他应用组件通信:
-
它在一个通信端点上公开业务操作和查询,例如 HTTP Web API(见第十五章,使用.NET 应用服务架构)。在这种情况下,表示层可以连接到该端点或连接到其他端点,这些端点反过来又从该端点和其他端点获取信息。从多个端点收集信息并在唯一端点公开这些信息的应用组件称为网关。它们可以是定制的,也可以是通用目的的,例如 Ocelot。
-
它被应用程序作为库引用,该应用程序直接实现表示层,例如 ASP.NET Core MVC Web 应用程序。
-
它不会通过端点公开所有信息,而是将处理/创建的一些数据通信给其他应用组件,而这些组件反过来又公开端点。这种通信通常使用发布者/订阅者模式来实现,以增加模块化。
仓储模式
仓储模式是一种以实体为中心的方法来定义域层接口:每个实体——或者更好,每个聚合——都有自己的仓储接口,该接口定义了如何检索和创建它,并定义了涉及聚合中实体的所有查询。每个仓储接口的实现称为仓储。由于,如聚合子节所述,聚合代表了在每次数据操作中考虑的最小粒度,因此仓储与聚合相关联,而不是与实体相关联。
使用仓储模式,每个操作都有一个易于找到的地方来定义它:操作所操作的聚合的接口,或者,在查询的情况下,包含查询根实体的聚合。
仓储模式最初是为经典层架构及其记录样式的对象而设计的。然后,它被修改以与丰富的 DDD 实体/聚合一起工作。
经典存储库包含处理记录对象所需的所有方法——即修改、创建和删除方法,因为记录对象没有修改方法。而适应 DDD 的存储库则只包含创建和删除方法,因为所有修改每个聚合的方法都被定义为聚合方法。此外,基于经典存储库和记录对象的应用程序没有唯一代表每个领域聚合的记录对象,而是有多个记录对象,每个对象包含对整体领域聚合的不同视图。因此,经典存储库为多个不同的记录对象提供了修改方法。
经典和 DDD 适应的存储库都有方法检索要返回给用户的数据。在两种情况下,这些数据都由记录对象表示,因为聚合仅在领域实体必须修改或创建时才构建。
下面的图示总结了经典和 DDD 适应的存储库模式之间的差异。
图 7.6:经典和 DDD 适应的存储库模式
工作单元模式
虽然将事务限制在单个聚合设计边界内是首选的,但有时应用层事务可能跨越多个聚合,因此可能使用多个不同的存储库接口。
例如,购买旅行同时涉及以下实体的修改:
-
酒店/旅行可用场所
-
客户购物篮
这两个操作必须在单个事务中完成,因为要么它们都成功,要么它们都必须失败。因此,我们需要一种方式在单个事务中执行对多个实体/聚合的操作,同时根据面向对象编程的最佳实践保持涉及实体的方法/代码解耦。
工作单元模式是一种解决方案,它保持了领域层与底层领域层实现的独立性。它指出,每个存储库接口还必须包含对工作单元接口的引用,该接口代表当前事务的标识。这意味着具有相同工作单元引用的多个存储库属于同一事务。
工作单元模式可以与聚合和记录对象一起使用。
存储库和工作单元模式都可以通过定义一些种子接口来实现。
public interface IUnitOfWork
{
Task<bool> SaveEntitiesAsync();
Task StartAsync();
Task CommitAsync();
Task RollbackAsync();
}
public interface IRepository<T>: IRepository
{
IUnitOfWork UnitOfWork { get; }
}
所有存储库接口都继承自 IRepository<T>
并将 T
绑定到它们关联的聚合根或实体,而工作单元(Unit of Work)仅实现 IUnitOfWork
。当调用 SaveEntitiesAsync()
时,所有对聚合或记录类对象进行的挂起修改、删除和创建都在存储引擎中作为一个事务保存。如果需要更广泛的交易,该交易在从存储引擎检索某些数据时启动,必须由应用程序层处理程序启动并提交/回滚,该处理程序通过 IUnitOfWork
的 StartAsync
、CommitAsync
和 RollbackAsync
方法负责整个操作。IRepository<T>
从一个空的 IRepository
接口继承,以帮助自动存储库发现。
与本书相关的 GitHub 存储库包含一个 RepositoryExtensions
类,该类的 AddAllRepositories
IServiceCollection
扩展方法自动发现一个程序集中包含的所有存储库实现,并将它们添加到依赖注入引擎中。
经典存储库模式与 DDD 聚合对比
到目前为止讨论的 DDD 模式,如聚合和 DDD 适配的存储库,确保了模块化和可修改性,并且由于整个领域聚合被加载到内存中,它们还防止了由于错误的局部更新而产生的各种类型的错误。然而,每次添加新功能都相当繁琐,因为它通常涉及复杂建模活动、整个聚合的重构或创建,以及定义几个类。
一方面,记录类对象更容易定义,我们可以为不同的用途定义不同的类。因此,添加新的功能只需定义一个独立的存储库方法,可能还需要定义一个新的记录类。
假设我们只需要维护旅行描述和特性。这是一个非常简单的领域,我们只需要执行 CRUD 操作——即创建和删除旅行以及修改它们的特性。在这种情况下,将整个聚合加载到内存中并作为唯一实体类的方法执行所有操作没有优势。
另一方面,使用经典的存储库模式,我们可以仅加载要修改的旅行的特性,例如在网页上的营销优化描述、在其他页面上的价格(在这些页面上,用户是具有决定价格权力的管理员)等等。这样,每个操作都使用一个特定且针对该操作优化的不同对象。
假设我们现在正在为工业应用设计一个复杂的资源分配软件。每个实体的所有属性都受复杂业务规则的约束,而且几个实体也受几个复杂业务规则的约束。因此,对少数几个属性的局部更新会产生影响,这些影响会传播到整个实体和其他相关实体。
在这种情况下,每个经典的仓储方法都不得不考虑每个属性变化的所有可能后果,从而导致复杂的乱糟糟的代码,并在不同的仓储方法中多次重新编码相同的操作。
在这种情况下,DDD 方法表现得更好。我们将涉及的完整聚合体加载到内存中,并让它们的方法在面向对象最佳实践的辅助下处理业务规则复杂性。每个聚合体只需要编码它所代表的真实世界对象的行为了,无需关心对其他实体的影响。
总结来说,当一个边界上下文非常简单,意味着只有少数实体以及它们之间的一些交互和一些不同的更新操作时,毫无疑问,经典的仓储模式更加方便。另一方面,当存在许多实体或复杂实体,并且添加了许多不同的更新操作时,仓储会变成一团糟的代码,有多个部分重叠的方法、代码重复,以及没有易于理解的交互规则。
在第二十一章,案例研究的一个前端微服务部分给出了一个展示 DDD 适配的仓储模式的完整示例,而在同一章节的使用客户端技术部分给出了一个展示经典仓储模式的完整示例。
此外,正如已经指出的那样,由多个记录投影的领域聚合体执行的局部更新可能会由于不同用户同时进行的修改而导致错误。
因此,当由于复杂的更新模式,类似错误的概率变得很高时,使用经典的仓储模式是非常危险的。
因此,再次强调,复杂性是我们使用 DDD 模式的主要驱动力。此外,需要集中触发用于同步边界上下文数据存储的领域事件(回顾之前的理解领域驱动设计部分),这迫使整个聚合体加载到内存中,并使用 DDD 模式。因此,当一个边界上下文需要在复杂情况下触发多个领域事件时,我们不能使用更简单的经典仓储模式。
现在我们已经讨论了 DDD 的基本模式,我们可以讨论一些更高级的 DDD 模式。在下一节中,我们将介绍 CQRS 模式。
命令查询责任分离(CQRS)模式
在其一般形式中,使用这个模式相当简单:使用不同的结构来存储/更新和查询数据。在这里,关于如何存储和更新数据的要求与查询的要求不同。这意味着查询和存储/更新操作的领域层和应用服务必须以完全不同的方式设计。
在 DDD 的情况下,存储的单位是聚合,因此添加、删除和更新涉及聚合。另一方面,与存储/更新不同,查询不执行业务操作,而是涉及从几个聚合(投影、求和、平均值等)中提取的属性的转换。
因此,虽然更新需要包含业务逻辑和约束(方法、验证规则、封装的信息等)的实体,但查询结果只需要属性/值对的集合,因此只有公共属性而没有方法的 DTOs 工作得很好。
在其常见形式中,该模式可以描述如下:
图 7.7:命令和查询处理
其中:
-
中间的框(处理器和存储库接口)代表操作。
-
最左侧和最右侧的框代表数据。
-
箭头简单地表示数据的方向。
从这个角度来看,查询结果的提取不需要通过实体的构建和聚合,但查询中显示的字段必须从存储引擎中提取并投影到临时的 DTOs 中。
然而,在更复杂的情况下,CQRS 可能以更强的形式实现。具体来说,我们可以使用不同的边界上下文来存储预处理的查询结果。
实际上,另一种选择是一个聚合器微服务,它查询所有必要的微服务以组装每个查询结果。然而,对其他微服务的递归调用以构建答案可能会导致不可接受的响应时间。此外,提取一些预处理确保更好地使用可用资源。
该模式如下实现:
-
查询处理委托给分离和专业的组件。
-
每个查询处理组件为其必须处理的每个查询使用一个数据库表。在那里,它存储查询将返回的所有字段。这意味着查询不是在每次请求时计算,而是在特定的数据库表中预先计算并存储。显然,具有子集合的查询需要额外的表,每个子集合一个。
-
所有处理更新的组件都将所有更改转发给感兴趣的查询处理组件。记录是分版本的,以便接收更改的查询处理组件可以按照正确的顺序将其应用于其查询处理表。实际上,由于通信是异步的以提高性能,更改的接收顺序可能不会与发送顺序相同。
-
接收到的每个查询处理微服务的更改在等待更改应用时被缓存。每当一个更改的版本号紧跟最后一个已应用的更改时,它就被应用到正确的查询处理表中。
值得注意的是,我们上面提到的“软件组件”作为独立进程在不同的机器上运行,被称为微服务。它们将在第十一章,将微服务架构应用于您的企业应用中详细讨论。
这种更强大的 CQRS 模式的使用将典型的本地数据库事务转换为复杂且耗时的分布式事务,因为单个查询预处理微服务中的失败应该使整个事务无效。正如我们将在第十一章,将微服务架构应用于您的企业应用中讨论的那样,出于性能原因,通常不接受实现分布式事务,有时甚至不支持,因此常见的解决方案是放弃数据库立即整体一致性的想法,并接受整体数据库将在每次更新后最终一致。
可以通过重试策略解决瞬态故障,我们将在第十一章,将微服务架构应用于您的企业应用中讨论这一点,而永久性故障则通过在已提交的本地事务上执行纠正操作来处理,而不是假装实现一个整体的全局分布式事务。
在这一点上,你可能会有以下疑问:
“为什么我们一旦有了所有预处理的查询结果,还需要保留原始数据?我们永远不会用它来回答查询!”
这个问题的部分答案如下:
-
它们是我们可能需要从失败中恢复的真相来源。
-
当我们添加新的查询时,我们需要它们来计算新的预处理结果。
-
我们需要它们来处理新的更新。实际上,处理更新通常需要从数据库中检索一些数据,可能展示给用户,然后进行修改。
例如,要修改现有采购订单中的项目,我们需要整个订单,以便我们可以展示给用户并计算更改,以便我们可以将其转发到其他微服务。此外,无论何时我们在存储引擎中修改或添加数据,我们都必须验证整个数据库的一致性(唯一键约束、外键约束等)。
下一个子节将专门讨论 CQRS 模式的极端实现。
事件溯源
事件溯源是 CQRS 更强形式的一种更高级的实现。当原始的边界上下文是真相来源——即用于从故障中恢复和进行软件维护时,它非常有用。在这种情况下,我们不是更新数据,而是简单地添加描述已执行操作的事件,例如删除记录 ID 15,将 ID 21 中的名称更改为 John,等等。这些事件立即发送到所有依赖的边界上下文,在出现故障和/或添加新查询的情况下,我们只需要重新处理其中的一些。出于性能考虑,除了表示所有更改的事件外,当前状态也被维护;否则,每次需要时,都需要重新计算,回放所有事件。此外,通常在每进行,比如说,N次更改之后,将完整状态缓存起来。这样,如果发生崩溃或任何类型的故障,只需要回放少量事件。
如果事件是幂等的——也就是说,处理相同的事件多次与处理一次具有相同的效果,那么回放事件不会引起问题。
正如我们将在第十一章中讨论的,将微服务架构应用于您的企业应用程序,幂等性是微服务通过事件通信的标准要求。
在下一节中,我们将描述一个用于处理跨越多个聚合和多个边界上下文的操作的常见模式。
命令处理器和聚合事件
根据命令模式,每个应用领域操作都由一个所谓的命令处理器处理。由于每个命令处理器编码了一个单一的应用领域操作,因此其所有操作都必须在同一个事务中完成,因为操作必须作为一个整体成功或失败。命令处理器通过调用聚合和存储库方法来完成其工作。然而,某些操作可能由聚合内部的状态变化触发,因此不能由命令处理器调用,而必须依赖于某种形式的直接聚合到聚合的通信。
为了保持聚合分离,通常,聚合之间以及与其他边界上下文之间的通信是通过事件完成的。我们已经在领域事件子节中讨论了用于与其他边界上下文通信的领域事件。然而,聚合之间的直接通信也可以利用发布/订阅模式来保持代码更模块化和易于修改。在处理每个聚合时,将触发的事件全部存储起来,而不是立即执行它们,以防止事件执行干扰正在进行的聚合处理,这是一种良好的实践。这可以通过向本章实体子节中定义的抽象Entity
类添加以下代码轻松实现,如下所示:
public List<IEventNotification> DomainEvents { get; private set; }
public void AddDomainEvent(IEventNotification evt)
{
DomainEvents ??= new List<IEventNotification>();
DomainEvents.Add(evt);
}
public void RemoveDomainEvent(IEventNotification evt)
{
DomainEvents?.Remove(evt);
}
在这里,IEventNotification
是一个空接口,用于标记作为事件的类。
事件处理通常在更改存储在存储引擎之前立即执行。因此,执行事件处理的好地方是在命令处理程序调用每个 IUnitOfWork
实现的 SaveEntitiesAsync()
方法之前(参见 Repository 模式 子节)。同样,如果事件处理程序可以创建其他事件,它们必须在处理完所有聚合之后处理它们。
事件 T
的订阅可以提供为 IEventHandler<T>
接口的实现:
public interface IEventHandler<T>: IEventHandler
where T: IEventNotification
{
Task HandleAsync(T ev);
}
类似地,命令模式通过一个 command
对象实现,该对象包含应用程序域操作的所有输入数据,而实现实际操作的实际代码可以通过一个实现 ICommandHandler<T>
接口的命令处理程序提供:
public interface ICommandHandler<T>: ICommandHandler
where T: ICommand
{
Task HandleAsync(T command);
}
在这里,ICommand
是一个空接口,用于标记类为命令。ICommandHandler<T>
和 IEventHandler<T>
是我们在 第六章,设计模式和 .NET 8 实现 中描述的 命令模式 的示例。
每个 ICommandHandler<T>
都可以在依赖注入引擎中注册,这样需要执行命令 T
的类就可以在其构造函数中使用 ICommandHandler<T>
。这样,我们就将命令的抽象定义(实现 ICommand
的类)与其执行方式解耦。
同样的构造方法不能应用于事件 T
和它们的 IEventHandler<T>
,因为当一个事件被触发时,我们需要检索几个 IEventHandler<T>
的实例,而不仅仅是其中一个。我们需要这样做,因为每个事件可能有多个订阅。然而,几行代码就可以轻松解决这个问题。首先,我们需要定义一个类,它为给定的事件类型托管所有处理程序:
public class EventTrigger<T>
where T: IEventNotification
{
private IEnumerable<IEventHandler<T>> handlers;
public EventTrigger(IEnumerable<IEventHandler<T>> handlers)
{
this.handlers = handlers;
}
public async Task Trigger(T ev)
{
foreach (var handler in handlers)
await handler.HandleAsync(ev);
}
}
在 EventTrigger<T>
构造函数中声明一个 handlers
IEnumerable<IEventHandler<T>>
参数,允许 .NET 依赖注入引擎将依赖注入容器中可用的所有 IEventHandler<T>
实现传递给这个 handlers
参数。
想法是,每个需要触发事件 T
的类都需要 EventTrigger<T>
,然后将要触发的事件传递给它的 Trigger
方法,该方法反过来调用所有处理程序。
然后,我们需要在依赖注入引擎中注册 EventTrigger<T>
。一个好主意是定义我们可以调用的依赖注入扩展,以声明每个事件,如下所示:
service.AddEventHandler<MyEventType, MyHandlerType>()
这个 AddEventHandler
扩展必须自动生成 EventTrigger<T>
的依赖注入定义,并且必须处理每个类型 T
声明为 AddEventHandler
的所有处理程序。
以下扩展类为我们做了这件事:
public static class EventDIExtensions
{
public static IServiceCollection AddEventHandler<T, H>
(this IServiceCollection services)
where T : IEventNotification
where H: class, IEventHandler<T>
{
services.AddScoped<H>();
services.TryAddScoped(typeof(EventTrigger<>));
return services;
}
...
...
}
传递给 AddEventHandler
的 H
类型被记录在依赖注入引擎中,当第一次调用 AddEventHandler
时,EventTrigger<T>
也会添加到依赖注入引擎中。然后,当依赖注入引擎需要 EventTrigger<T>
实例时,所有添加到依赖注入引擎中的 IEventHandler<T>
类型都会被创建、收集并传递给 EventTrigger(IEnumerable<IEventHandler<T>> handlers)
构造函数。
当程序启动时,所有 ICommandHandler<T>
和 IEventHandler<T>
的实现都可以通过反射自动检索并注册。为了帮助自动发现,它们继承自 ICommandHandler
和 IEventHandler
,这两个接口都是空的。
《这本书的 GitHub 仓库》中提供的 EventDIExtensions
类包含用于自动发现和注册命令处理程序和事件处理程序的方法。GitHub 仓库还包含一个 IEventMediator
接口及其 EventMediator
实现,其 TriggerEvents(IEnumerable<IEventNotification> events)
方法从依赖注入引擎检索与其参数中接收的事件关联的所有处理程序并执行它们。只要将 IEventMediator
注入到类中,就可以触发事件。EventDIExtensions
还包含一个扩展方法,用于发现实现空 IQuery
接口的所有查询并将它们添加到依赖注入引擎。
MediatR
NuGet 包提供了一个更复杂的实现。
摘要
在本章中,我们分析了采用 DDD 的主要原因以及为什么以及如何满足市场需求。我们描述了如何识别领域以及如何使用领域图协调同一应用程序不同领域工作的团队。然后,我们分析了 DDD 如何使用实体、值对象和聚合来表示数据,并提供建议和代码片段,以便我们可以在实践中实现它们。
我们还描述了在基于 DDD 的项目中经常使用的洋葱架构,并将其与经典分层架构进行了比较。
我们还介绍了一些与 DDD 一起使用的典型模式——即仓库和单元工作模式、领域事件模式、CQRS 和事件溯源。然后,我们学习了如何在实践中实现它们。我们还展示了如何使用解耦处理实现领域事件和命令模式,以便我们可以将代码片段添加到现实世界的项目中。
问题
-
什么提供了主要的提示,以便我们可以发现领域边界?
-
用于协调独立边界上下文开发的主要工具是什么?
-
是否每个组成聚合的条目都使用自己的方法与系统的其余部分进行通信?
-
为什么只有一个聚合根?
-
一个聚合可以管理多少个仓库?
-
仓库如何与应用层交互?
-
为什么需要工作单元模式?
-
CQRS 轻量级形式的原因是什么?其最强形式的原因又是什么?
-
主要允许我们将命令/领域事件与其处理程序耦合的工具是什么?
-
事件溯源是否可以用来实现任何有界上下文?
进一步阅读
-
Eric Evans, 领域驱动设计:
www.amazon.com/exec/obidos/ASIN/0321125215/domainlanguag-20
-
更多关于 DDD 的资源可以在以下链接找到:
domainlanguage.com/ddd/
-
可以在这里找到对 CQRS 设计原则的详细讨论:
udidahan.com/2009/12/09/clarified-cqrs/
-
更多关于 MediatR 的信息可以在 MediatR 的 GitHub 仓库中找到:
github.com/jbogard/MediatR
-
在以下由 Martin Fowler 撰写的博客文章中可以看到事件溯源的良好描述及其示例:
martinfowler.com/eaaDev/EventSourcing.html
在 Discord 上了解更多
要加入这本书的 Discord 社区——在那里您可以分享反馈、向作者提问,并了解新版本——请扫描下面的二维码:
packt.link/SoftwareArchitectureCSharp12Dotnet8
第八章:理解 DevOps 原则和 CI/CD
尽管许多人将 DevOps 定义为一种流程,但当你与它一起工作时,你会更好地理解它是一种哲学。本章将涵盖你需要开发和使用 DevOps 来交付软件的主要概念、原则和工具。
通过考虑 DevOps 哲学,本章将重点关注服务设计思维,即牢记你设计的软件是提供给组织或组织的一部分的服务。这种方法的核心理念是,最高优先级是软件为目标组织带来的价值。此外,你不仅提供可工作的代码和修复错误的协议,还提供满足软件构思的所有需求的解决方案。换句话说,你的工作包括满足这些需求所需的一切,例如监控用户满意度,并在用户需求因问题或新要求而变化时快速调整软件。
服务设计思维与软件即服务(SaaS)模型紧密相连,这在第十章“选择最佳云解决方案”中有讨论。事实上,提供基于 Web 服务的解决方案最简单的方法是提供 Web 服务的使用作为一项服务,而不是销售实现它们的软件。
持续集成(CI)和持续交付(CD)有时被列为 DevOps 的先决条件。因此,本章的目的也是讨论如何在实际场景中启用 CI/CD,考虑到作为软件架构师,你需要应对的挑战。
本章将涵盖以下主题:
-
理解 DevOps 原则:CI、CD 和持续反馈
-
理解如何使用 Azure DevOps 和 GitHub 实现 DevOps
-
理解使用 CI/CD 时的风险和挑战
在第二十一章“案例研究”中介绍的WWTravelClub
项目案例将在这些主题中讨论,这将为你提供了解 DevOps 哲学如何实施的机会。所有展示 DevOps 原则的截图都来自本书的主要用例,因此你将能够轻松理解 DevOps 原则。
到本章结束时,你将能够根据服务设计思维原则设计软件,并使用 Azure Pipelines 部署你的应用程序。你将能够决定是否在你的项目环境中使用 CI/CD。此外,你将能够定义成功使用此方法所需的工具。
技术要求
本章需要 Visual Studio 2022 Community Edition 或更高版本,并安装所有 Azure 工具。你可能还需要一个 Azure DevOps 账户,如第三章管理需求中所述。还需要一个免费的 Azure 账户。如果你还没有创建一个,第一章理解软件架构的重要性中的创建 Azure 账户小节解释了如何创建。本章使用与第九章测试你的企业应用相同的代码。
描述 DevOps
DevOps 是一个由单词开发(Development)和运维(Operations)组合而成的术语,DevOps 过程简单地将这两个领域的行动统一起来。然而,当你开始更深入地了解它时,你会意识到仅仅连接这两个领域是不够的,无法实现这种哲学的真正目标。
我们也可以说,DevOps 是满足人们对软件交付当前需求的过程。
Donovan Brown 对 DevOps 有一个精彩的定义:DevOps 是将人员、流程和产品结合在一起,以实现向最终用户持续交付价值的过程 (donovanbrown.com/post/what-is-devops
)。
使用流程、人员和产品持续地向我们的最终用户交付价值:这是对 DevOps 哲学的最佳描述。我们需要开发和交付面向客户的软件。一旦公司的所有领域都明白关键点是最终用户,作为软件架构师的你,任务就是展示将促进交付过程的技术。
本书中的所有内容都与这种方法相关联。这从来不是仅仅知道一堆工具和技术的问题。作为一名软件架构师,你必须明白,总有办法轻松地将更快解决方案带给最终用户,并与他们的实际需求相联系。因此,你需要学习 DevOps 原则,这些原则将在本章中讨论。
理解 DevOps 原则
将 DevOps 视为一种哲学,有一些原则使得这个过程在你的团队中能够良好地运作。这些原则是持续集成(CI)、持续部署(CD)和持续反馈。
微软有一个专门的网页来定义 DevOps 概述、文化、实践、工具及其与云的关系。请在此查看:azure.microsoft.com/en-us/overview/what-is-devops/
。
在许多书籍和技术文章中,DevOps 被表示为无限符号。这代表了在软件开发生命周期中持续方法的必要性。在这个过程中,你需要计划、构建、持续集成、部署、运营、获取反馈,然后从头开始。这个过程必须是协作的,因为每个人都有相同的焦点——为最终用户提供价值。与这些原则一起,“作为一名软件架构师”,你需要决定最适合这种方法的最佳软件开发流程。我们在第一章,理解软件架构的重要性中讨论了这些流程。
CI
当你开始构建企业级解决方案时,协作是更快完成任务和满足用户需求的关键。正如我们在第四章,C#编码最佳实践 12中讨论的那样,版本控制系统对于这个过程至关重要,但这些工具并不能自行完成任务,尤其是如果它们没有良好配置的话。
作为一名软件架构师,你必须知道持续集成(CI)将帮助你采用具体的软件开发协作方法。当你实施它时,一旦开发者提交代码,主代码就会根据项目中的单元测试和功能测试自动编译和测试。
当你应用 CI 时,好处是你可以激励开发者尽可能快地合并他们的更改,以最小化合并冲突。他们还可以共享单元测试,这将提高软件的质量。这将使你的主分支在每次团队提交后都稳定且安全。
CI 的关键点是能够更快地识别问题。当你允许代码被他人测试和分析时,你将有机会这样做。DevOps 方法唯一能帮助的就是确保这一切尽可能快地发生。
CD
一旦你的应用程序的每个提交都构建完成,并且这段代码已经通过单元测试和功能测试进行测试,你可能还想要启用持续交付(CD)。这样做不仅仅是配置工具的问题。作为一名软件架构师,你需要确保团队和流程已经准备好进入这一步。
持续交付的方法需要保证在每次新部署时生产环境的安全。为此,采用多阶段管道是一个很好的实践。
下面的屏幕截图展示了使用本书用例WWTravelClub作为演示的常见阶段的方法:
图 8.1:使用 Azure DevOps 的发布阶段
正如你所见,这些阶段是使用 Azure DevOps 发布管道配置的,这将在稍后解释。每个阶段都有自己的目的,这最终会影响交付产品的质量。让我们看看这些阶段:
-
开发/测试:这个阶段由开发人员和测试人员用来构建新功能。这个环境肯定会是暴露于错误和不完整功能最多的环境。
-
预发布环境:这个环境向与开发和测试无关的团队区域提供新功能的一个简短版本。项目经理、市场营销、供应商和其他人可以使用它作为研究、验证甚至预生产的区域。此外,质量保证团队可以确保新发布正确部署,考虑到功能和基础设施。
-
生产环境:这是客户运行解决方案的阶段。根据 CD 的观点,一个良好的生产环境的目标是尽可能快地更新。频率将根据团队规模而变化,但有一些方法,这个过程一天内会发生多次以上。
采用这三个部署应用阶段将积极影响解决方案的质量。它还将使团队能够拥有更安全的部署流程,风险更少,产品稳定性更好。这种做法一开始可能看起来有点昂贵,但如果没有它,不良部署的结果通常会比这个投资更昂贵。
使用 CI/CD 时的风险和挑战
既然我们已经了解了 CI/CD 的有用性,那么考虑在实施过程中可能遇到的风险和挑战将是个不错的选择。本节的目标是帮助你们作为软件架构师,通过良好的流程和技术来降低风险,找到克服挑战的更好方法。
本节将讨论的风险和挑战列表如下:
-
持续生产部署
-
生产环境中的不完整功能
-
测试中的不稳定解决方案
一旦你有了处理它们的技术和流程,就没有理由不使用 CI/CD。
记住,DevOps 并不依赖于 CI/CD。你可以使用一个基于人类操作的过程,其中代码集成和软件部署都是人工完成的。然而,CI/CD 确实可以使 DevOps 的工作更加顺畅。
现在,让我们来看看它们。
禁用持续生产部署
持续生产部署是一个过程,在该过程中,在提交新代码片段和一些管道步骤之后,您将在生产环境中拥有这段代码。这并非不可能,但很难且成本高昂。此外,您需要拥有良好建立、复杂的流程,以及一个经验丰富、专业知识丰富的团队来实现它。问题是,您在网上找到的大多数演示和示例都会展示 CI/CD 的快速部署路径。CI/CD 的演示使其看起来如此简单!这种简单性可能会让您认为应该尽快实施它。然而,如果您再思考一下,如果直接部署到生产中,这种场景可能会很危险!对于需要每天 24 小时、每周 7 天都可用的问题解决方案来说,这是不切实际的。因此,您需要担心这一点,并考虑不同的解决方案。
第一个方法是使用多阶段场景,正如我们之前所描述的。多阶段场景可以为您正在构建的部署生态系统带来更多安全性。此外,您将获得更多选项来避免错误地将部署部署到生产环境中,例如预部署审批。
您可以构建一个部署管道,其中所有代码和软件结构都将由这个工具更新。然而,如果您有这个场景之外的东西,比如数据库脚本和环境配置,错误的发布到生产中可能会对最终用户造成损害。此外,何时更新生产环境需要提前规划,在许多情况下,所有平台用户都需要被告知即将到来的变更。值得一提的是,在这些难以决定的案例中,使用基于信息技术基础设施库(ITIL)或 ISO 20000 的变更管理程序是个好主意。
因此,将代码交付到生产的挑战将使您考虑一个计划来执行它。无论是按月、按日,甚至每次提交,这都无关紧要。关键点在于您需要创建一个过程和管道,以确保只有良好且经过批准的软件处于生产阶段。然而,值得注意的是,您部署的时间越长,它们就越可怕,因为之前部署的版本和新的版本之间的偏差会更大,并且会有更多的更改一次性推出。您能更频繁地部署,那就越好。
不完整的功能
当您的团队中的开发人员正在创建新功能或修复错误时,您可能会考虑创建一个分支,这意味着他们可以避免使用为 CD 设计的分支。分支可以被视为代码库中的一项功能,它允许创建一个独立的开发线路,因为它可以隔离代码。
正如您在下面的屏幕截图中所见,使用 Visual Studio 为wwtravelclub创建分支相当简单:
图 8.2:在 Visual Studio 中创建分支
这似乎是一个好的方法,但让我们假设开发者已经认为实现准备就绪可以部署,并且刚刚将代码合并到主分支,尽管这也被认为是一种不好的做法。如果因为这个要求被遗漏,这个功能还没有准备好怎么办?如果这个错误导致了不正确的行为呢?结果可能是带有不完整功能或错误修复的发布。
避免在主分支中出现损坏的功能甚至错误的修复,一个好的做法是使用拉取请求(PRs)。PRs 将让其他团队成员知道你开发的代码已经准备好合并。以下截图显示了如何使用 Azure DevOps WWTravelClub 存储库为你所做的更改创建一个新的拉取请求。
图 8.3:创建 PR
一旦创建了 PR 并定义了审阅者,每个审阅者都将能够分析代码并决定它是否足够健康,可以放入主分支。
以下截图显示了使用比较工具分析WWTravelClub代码更改以检查代码的方法:
图 8.4:分析 PR
一旦所有审批完成,你将能够安全地将代码合并到主分支,正如你在下面的截图中所见。要合并代码,你需要点击完成合并。重要的是要提到,你也可以在 Visual Studio 中这样做,它有一个更好的用户界面。如果 WWTravelClub 项目启用了 CI 触发器,正如我们将在本章中展示的,Azure DevOps 将启动一个构建管道:
图 8.5:合并 PR
没有这样的流程,主分支中很可能会有很多糟糕的代码,并且这些代码部署到那里可能会与 CD 一起造成损害。代码审查在 CI/CD 场景中是一种优秀的实践,并且通常被认为是一种创建高质量软件的绝佳实践。
你需要关注这里的挑战是确保只有完整的功能会出现在你的最终用户面前。你可以使用功能标志原则来解决这个问题,这是一种确保只有准备就绪的功能呈现给最终用户的技巧。
在功能标志或功能切换技术中,你必须创建一个解决方案,在每个功能中都有可能在设置中测试它,以查看它是否启用。根据这一点,所有功能都将向用户展示。
值得注意的是,为了在一个环境中控制功能可用性,功能标志比使用分支/PRs 要安全得多。两者都有其位置,但 PRs 是关于在 CI 阶段控制代码质量,而功能标志是关于在 CD 阶段控制功能可用性。
再次强调,我们不是在谈论 CI/CD 作为一个工具,而是在谈论一个过程,每次你需要为生产交付代码时都需要定义和使用。
一个不稳定的测试解决方案
如果你已经缓解了本节中提到的其他两种风险,你可能会发现 CI/CD 之后出现糟糕的代码是不常见的。确实,如果你在推送到最后阶段之前与多阶段场景和 PRs 一起工作,之前提出的担忧肯定会减少。但即使你应用了我们后面将要讨论的所有建议,不稳定代码的风险,尤其是在业务逻辑规则方面,仍然存在。
但有没有一种方法可以在确保新版本已准备好供利益相关者测试的同时加速发布评估?是的,有!从技术上讲,你可以这样做的方法是通过自动化单元和功能测试。这种技术在第九章“测试您的企业应用程序”中进行了更详细的解释。
然而,值得指出的是,考虑到实现这一目标所需的努力,自动化的每一个部分都是不切实际的。此外,在用户界面或业务规则变化很大的场景中,自动化的维护可能更加昂贵。尽管这是一个艰难的决定,但作为一个软件架构师,你必须始终鼓励使用自动化测试。
为了举例说明,让我们看一下以下屏幕截图,它显示了由 Azure DevOps 项目模板创建的 WWTravelClub 的单元和功能测试样本:
图 8.6:单元和功能测试项目
在第六章中介绍了一些架构模式,如 SOLID,以及一些质量保证方法,如同行评审,这些方法可以比软件测试提供更好的结果。然而,这些方法并不否定自动化实践。事实是,所有这些都将有助于获得稳定的解决方案,尤其是在你运行 CI 场景时。在这种情况下,你能做的最好的事情就是尽可能快地检测错误和不正确的行为。正如之前所展示的,单元测试和功能测试都将帮助你做到这一点。
单元测试将在部署前,在构建管道期间帮助你大量发现业务逻辑错误。例如,在以下来自 WWTravelClub 构建过程的屏幕截图,你会找到一个模拟错误,它取消了构建,因为单元测试没有通过:
由于可能存在滥用风险,Azure 上的一些免费服务可能会被停用。然而,您可以通过提交请求来选择重新激活这些服务。
图 8.7:单元测试结果
获取这个错误的方法相当简单。您需要编写一些代码,这些代码不会根据单元测试进行检查。一旦您提交它,假设您已经开启了 CD 触发器,您将在管道中构建代码。因此,在代码构建之后,单元测试将会运行。如果代码不再匹配测试,您将得到一个错误。
以下截图显示了 WWTravelClub 项目在开发/测试阶段的功能测试中出现的错误。在这个例子中,开发/测试环境有一个错误,功能测试迅速检测到了这个错误:
图 8.8:功能测试结果
但在 CI/CD 过程中应用功能测试的好处不止于此。让我们看看以下从 Azure DevOps 的发布管道界面中的截图。如果您查看Release-9,您会意识到,由于这个错误是在开发/测试环境发布之后发生的,多阶段环境将保护错误部署的其他阶段,特别是 WWTravelClub 的生产阶段:
图 8.9:多阶段环境保护
CI 过程中成功的关键点是将其视为一个有用的工具,以加速软件的交付,并且不要忘记团队始终需要为其最终用户提供价值。采用这种方法,前面提出的技巧将提供实现团队目标结果的惊人方式。
持续反馈
一旦您拥有在前一节描述的部署场景中运行完美的解决方案,反馈对于您的团队理解发布结果以及版本如何为顾客工作至关重要。为了获取这些反馈,有一些工具可以帮助开发者和顾客,将他们聚集在一起以加速反馈过程。
持续反馈的主要目的是让开发者能够获取有关在生产环境中运行的应用程序的信息,使团队能够改进部署的环境基础设施,同时,检测出可以在源代码和用户界面中进行的改进。
便于 DevOps 实施的工具
考虑到 DevOps 是一种哲学,有许多工具可以帮助您实施它。以下主题将介绍在 Microsoft 环境中使用的一些最常用的工具。
Azure DevOps
一旦您开始使用像 Azure DevOps 这样的平台,在点击相应的选项时开启 CI/CD 将变得容易。所以技术并不是实施这一过程的阿喀琉斯之踵。
以下截图显示了使用 Azure DevOps WWTravelClub 管道开启 CI/CD 的示例,非常简单。通过点击管道并编辑它,您将能够设置一个触发器,在几次点击后启用 CI/CD:
图 8.10:开启 CI 触发器
CI/CD 将帮助您解决一些问题。例如,它将迫使您测试您的代码,因为您需要更快地提交更改,以便其他开发者可以使用您正在编写的代码。
相反,您不会仅仅通过在 Azure DevOps 中开启 CI 构建来执行 CI/CD。当然,您会在提交完成并且代码完整后立即开启构建的可能性,但这远不能说明您在解决方案中已经有了 CI/CD。
作为软件架构师,您需要对此多加关注的原因与对 DevOps 的真正理解有关。向最终用户提供价值始终是决定开发生命周期如何运作的好方法。因此,即使开启 CI/CD 很简单,但这一功能对您的最终用户真正产生的业务影响是什么?一旦您对这个问题的所有答案都弄清楚了,并且您知道如何降低其实施的风险,那么您就可以说您已经实施了一个 CI/CD 流程。
CI/CD 是一个原则,它将使 DevOps 工作得更好、更快。然而,如果您的流程还不够成熟以启用代码的持续交付,DevOps 可以没有它而生存。
此外,如果您在一个不够成熟以处理其复杂性的团队中开启 CI/CD,您可能会在部署解决方案时产生对 DevOps 的误解,因为您将开始承担一些风险。关键是 CI/CD 不是 DevOps 的先决条件。
当您启用了 CI/CD,您可以在 DevOps 中使事情变得更快。然而,您可以在没有它的情况下实践 DevOps。
部署和其他发布工件被添加到不同的管道中,这些管道被称为发布管道,以将它们与构建相关工件解耦。使用发布管道,您不能编辑.yaml
文件,但您将使用图形界面进行操作,如下所示:
-
点击左侧菜单的发布选项卡以创建一个新的发布管道。一旦点击添加新管道,系统会提示您添加第一个管道阶段的第一个任务。实际上,整个发布管道由不同的阶段组成,每个阶段都包含一系列任务。虽然每个阶段只是一系列任务,但阶段图可以分支,我们可以在每个阶段之后添加几个分支。这样,我们可以部署到需要不同任务的不同平台。在我们的简单示例中,我们将使用单个阶段。
-
选择Azure App Service 部署任务。一旦添加此任务,系统会提示您填写缺失的信息。
-
选择您的订阅,然后,如果出现授权按钮,点击它以授权 Azure Pipelines 访问您的订阅。然后,选择Windows作为部署平台,并最后从应用服务名称下拉列表中选择您创建的应用服务。在您编写任务设置时,任务设置会自动保存,因此您只需点击保存按钮即可保存整个管道。
-
现在,我们需要将此管道连接到一个源工件。点击添加工件按钮,然后选择构建作为源类型,因为我们需要将新的发布管道与构建管道创建的 ZIP 文件连接起来。会出现一个设置窗口:
图 8.11:定义要发布的工件
-
从下拉列表中选择您之前的构建管道,并将版本保留为最新。接受源别名下建议的名称。
-
我们的发布管道已准备就绪,可以直接使用。您刚刚添加的源工件图像在其右上角有一个触发图标,如下所示:
图 8.12:准备发布的工件
- 如果您点击触发图标,您可以选择在新构建可用时自动触发发布管道:
图 8.13:启用持续部署触发器
- 保持禁用状态;我们可以在完成并手动测试发布管道后启用它。
如我们之前提到的,为了准备自动触发,在应用程序部署之前,我们需要添加一个人工审批任务。让我们按照以下步骤添加它:
- 点击阶段 1标题右侧的三个点:
图 8.14:将人工审批添加到阶段
-
然后,选择添加无代理作业。一旦添加了无代理作业,点击添加按钮并添加一个手动干预任务。以下截图显示了手动干预设置:
图 8.15:配置阶段的审批
添加操作员的说明,并在通知用户字段中选择您的账户。
-
现在,使用鼠标拖动整个无代理作业,并将其放置在应用程序部署任务之前。它应该看起来像这样:
图 8.16:设置人工审批部署任务列表
- 完成!点击左上角的保存按钮以保存管道。
现在,一切准备就绪,可以创建我们的第一个自动发布版本。为此,可以按照以下步骤准备和部署新版本:
- 点击创建发布按钮以开始创建新版本,如下面的截图所示:
图 8.17:创建新版本
- 确认源别名是最后一个可用的,添加发布描述,然后点击创建。在短时间内,你应该会收到一个用于发布审批的电子邮件。点击其中的链接并转到审批页面:
图 8.18:批准发布
- 点击批准按钮以批准发布。等待部署完成。你应该看到所有任务都成功完成,如下面的截图所示:
图 8.19:已部署的发布
- 你已经成功运行了第一个发布管道!
在实际项目中,发布管道将包含更多任务。实际上,应用程序(在部署到实际生产环境之前)是在预发布环境中部署的,在那里它们进行 beta 测试。因此,在这次首次部署之后,可能会进行一些手动测试,手动授权生产部署,以及最终的生产部署。
考虑到多阶段场景,你可以设置管道,使得只有通过定义的授权才能从一个阶段移动到另一个阶段:
图 8.20:定义预部署条件
如前一个截图所示,设置预部署条件非常简单,并且有多种选项可以自定义授权方法。这允许你细化持续交付方法,正好满足你正在处理的项目需求。
下面的截图显示了 Azure DevOps 提供的预部署审批选项。你可以定义可以批准阶段的个人,并为他们设置策略,即在进行过程之前重新验证审批者身份。作为软件架构师,你需要确定适合你使用这种方法创建的项目配置:
图 8.21:预部署审批选项
值得注意的是,尽管这种方法比单阶段部署要好得多,但 DevOps 管道会引导你,作为软件架构师,进入另一个监控阶段。稍后将要介绍的 App Insights 是这一阶段的强大工具。
GitHub
自从 GitHub 被微软收购以来,许多功能已经演变,并提供了新的选项,增强了这个强大工具的能力。这些集成可以通过 Azure 门户进行探索,尤其是 GitHub Actions。
GitHub Actions是一套帮助自动化软件开发工具的工具。它能够在任何平台上启用快速的 CI/CD 服务,使用 YAML 文件来定义其工作流程。您可以将 GitHub Actions 视为微软提出的一种新方法,作为 Azure DevOps Pipelines 的替代品。您可以使用 GitHub Actions 自动化任何 GitHub 事件,GitHub Marketplace 上有数千个动作可供选择:
图 8.22:GitHub Actions
通过 GitHub Actions 界面创建构建.NET Web 应用程序的工作流程相当简单。如您在先前的屏幕截图中所见,已经创建了一些工作流程来帮助我们。
下面的 YAML 是通过在.NET下的配置选项中点击设置此工作流程选项生成的:
name: .NET
on:
push:
branches: [ main ]
pull_request:
branches: [ main ]
jobs:
build:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v2
- name: Setup .NET
uses: actions/setup-dotnet@v1
with:
dotnet-version: 8.0.x
- name: Restore dependencies
run: dotnet restore
- name: Build
run: dotnet build --no-restore
- name: Test
run: dotnet test --no-build --verbosity normal
通过以下所做的调整,它可以构建专门为这一章创建的应用程序:
name: .NET 8 Chapter 08
on:
push:
branches:
- main
env:
DOTNET_CORE_VERSION: 8.0.x
jobs:
build:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v2
- name: Setup .NET 8
uses: actions/setup-dotnet@v1
with:
include-prerelease: True
dotnet-version: ${{ env.DOTNET_CORE_VERSION }}
- name: Install dependencies
run: dotnet restore ./ch08
- name: Build
run: dotnet build ./ch08 --configuration Release --no-restore
- name: Test
run: dotnet test ./ch08 --no-restore --verbosity normal
如您所见,一旦脚本更新,就可以检查工作流程的结果。如果您想的话,也可以启用 CD。这只是一个定义正确脚本的问题:
图 8.23:使用 GitHub Actions 进行简单应用程序编译
微软提供了专门涵盖 Azure 和 GitHub 集成的文档。请查看docs.microsoft.com/en-us/azure/developer/github
。
作为一名软件架构师,您需要了解哪种工具最适合您的开发团队。Azure DevOps 提供了一个出色的环境来启用 CI/CD,GitHub 也是如此。关键点是,无论您选择哪种选项,一旦启用 CI/CD,您都将面临风险和挑战。让我们在下一个主题中探讨这些问题。
应用程序洞察
应用程序洞察是任何软件架构师都必须拥有的功能,以便对他们的解决方案进行持续反馈。应用程序洞察是Azure Monitor的一部分,这是一个更广泛的监控功能套件,还包括警报、仪表板和工作簿。一旦您将应用程序连接到它,您就开始接收对每个软件请求的反馈。这使得您不仅可以监控请求,还可以监控数据库性能、应用程序可能遇到的错误以及处理时间最长的调用。
显然,您将会有与这个工具集成到您的环境中的相关成本,但这个工具提供的功能将是值得的。值得注意的是,对于简单的应用程序,这甚至可能是免费的,因为您只需为导入的数据付费,而对于这部分数据,有一个免费配额。除了财务成本之外,您还需要了解,由于所有存储在应用程序洞察中的请求数据都在一个单独的线程中运行,因此性能成本非常小。
几个服务,如应用服务、函数等,将为您提供在初始创建过程中添加应用程序洞察的选项,因此您可能已经在阅读这本书的过程中创建了它。
以下截图显示了您如何轻松地在环境中创建一个工具:
图 8.24:在 Azure 中创建应用程序洞察资源
例如,假设您需要分析应用程序中耗时较长的请求。将应用程序洞察附加到您的 Web 应用的过程相当简单:您可以在设置时立即完成。如果您不确定应用程序洞察是否已为您的 Web 应用配置,您可以使用 Azure 门户进行查找。导航到应用服务并查看应用程序洞察设置,如下面的截图所示:
图 8.25:在应用服务中启用应用程序洞察
界面将为您提供创建或附加已创建的监控服务到您的 Web 应用的机会。您可以将多个 Web 应用连接到同一个应用程序洞察组件。
以下截图显示了如何将一个 Web 应用添加到已创建的应用程序洞察资源中:
图 8.26:在应用服务中启用应用洞察
一旦您为您的 Web 应用配置了应用程序洞察,您将在 App Services 中看到以下屏幕:
图 8.27:应用服务中的应用洞察
一旦它连接到您的解决方案,数据收集将连续进行,您将在组件提供的仪表板上看到结果。您可以在两个地方找到这个屏幕:
-
在您配置应用程序洞察的同一位置,在 Web 应用门户内
-
在 Azure 门户中,导航到应用程序洞察资源后
图 8.28:应用洞察在实际中的应用
此仪表板为您提供了关于失败请求、服务器响应时间和服务器请求的概览。您还可以开启可用性检查,这将从 Azure 的任何数据中心向您选择的 URL 发送请求。
Application Insights 的美在于它如何深入分析你的系统。例如,在下面的截图中,它提供了关于网站请求数量的反馈。你可以通过按处理时间较长或调用频率较高的请求对反馈进行分析。
图 8.29:使用 Application Insights 分析应用程序性能
考虑到这个视图可以以不同的方式过滤,并且你可以在你的网络应用程序中立即收到信息,这确实是一个定义持续反馈的工具。这是你可以使用 DevOps 原则来实现客户确切需求的最佳方式之一。
测试和反馈
在持续反馈的过程中,另一个有用的工具是测试和反馈工具,由微软设计,旨在帮助产品所有者和质量保证用户分析新功能的过程。
使用 Azure DevOps,你可以通过在每个工作项内部选择一个选项来向你的团队请求反馈,如下面的截图所示:
图 8.30:使用 Azure DevOps 请求反馈
一旦有人收到反馈请求,他们可以使用测试和反馈工具来分析和向团队提供正确的反馈。他们可以将工具连接到你的 Azure DevOps 项目,在分析反馈请求的同时提供更多功能。
你可以从marketplace.visualstudio.com/items?itemName=ms.vss-exploratorytesting-web
下载此工具。
这个工具是一个需要在使用前安装的网页浏览器扩展。下面的截图显示了如何为测试和反馈工具设置 Azure DevOps 项目 URL:
图 8.31:将测试和反馈连接到 Azure DevOps 组织
工具非常简单。你可以截图、记录一个过程,甚至做笔记。下面的截图显示了如何在截图内轻松地写消息:
图 8.32:使用测试和反馈工具提供反馈
好处在于你可以在会话时间线中记录所有这些分析。正如你在下一个截图中所看到的,你可以在同一个会话中拥有多个反馈项,这对分析过程很有帮助:
图 8.33:使用测试和反馈工具提供反馈
一旦你完成了分析并且连接到 Azure DevOps,你将能够报告一个错误(如下面的截图所示),创建一个任务,甚至开始一个新的测试案例:
图 8.34:在 Azure DevOps 中打开一个错误
可以在 Azure DevOps 的工作项板上检查创建的错误的后果。值得一提的是,你不需要 Azure DevOps 开发者许可证就可以访问这个环境区域。这使得你,作为软件架构师,能够将这个基本而有用的工具与尽可能多的解决方案关键用户共享。
以下截图显示了将工具连接到你的 Azure DevOps 项目后创建的错误:
图 8.35:Azure DevOps 中新的报告错误
拥有这样一个工具来获取你项目的好反馈是很重要的。但是,作为一名软件架构师,你可能需要找到最佳解决方案来加速反馈过程。
摘要
在本章中,我们了解到 DevOps 不仅是一系列技术和工具的组合,用于持续交付软件,而且是一种哲学,旨在使你正在开发的项目的最终用户能够持续获得价值。
考虑到这种方法,我们看到了 CI/CD 和持续反馈对于 DevOps 目标的重要性。我们还看到了 Azure、Azure DevOps、GitHub 和 Microsoft 工具如何帮助你实现目标。
本章还涵盖了在软件开发生命周期中何时可以启用 CI/CD 的重要性,考虑到一旦你决定将其用于你的解决方案,作为软件架构师你将面临的风险和挑战。
此外,本章介绍了一些可以使此过程更简单的解决方案和概念,例如多阶段环境、PR 审查、功能标志、同行评审和自动化测试。理解这些技术和流程将使你能够在 DevOps 场景中引导你的项目朝着更安全的行为发展,进行 CI/CD。
我们还描述了服务设计思维原则。现在,你应该能够分析这些方法对于一个组织的影响,你应该能够调整现有的软件开发流程和硬件/软件架构,以利用它们提供的机会。
我们还解释了软件周期自动化、云硬件基础设施配置和应用程序部署的自动化需求和技术。
一旦你实现了所展示的示例,你应该能够使用 Azure Pipelines 来自动化基础设施配置和应用程序部署。本章以 WWTravelClub 为例阐述了这种方法,在 Azure DevOps 内部实现 CI/CD,并使用 Application Insights 和测试与反馈工具进行技术和功能反馈。在现实生活中,这些工具将使你能够更快地了解你正在开发的系统的当前行为,因为你将对其有持续的反馈。
在下一章中,我们将看到软件测试自动化的工作方式。
问题
-
什么是 DevOps?
-
什么是持续集成(CI)?
-
什么是持续交付(CD)?
-
没有 CI/CD 可以实施 DevOps 吗?
-
在一个不成熟的团队中启用 CI/CD 有哪些风险?
-
多阶段环境如何帮助 CI/CD?
-
自动化测试如何帮助 CI/CD?
-
PRs 如何帮助 CI/CD?
-
PRs 是否仅与 CI/CD 一起工作?
-
什么是持续反馈?
-
构建和发布管道之间的区别是什么?
-
在 DevOps 方法中,Application Insights 的主要目的是什么?
-
测试和反馈工具如何帮助 DevOps 流程?
-
服务设计思维的主要目标是什么?
-
适用于整个应用程序生命周期自动化的首选 Azure 工具是什么?
在 Discord 上了解更多
要加入这本书的 Discord 社区——在那里您可以分享反馈、向作者提问,并了解新版本——请扫描下面的二维码:
packt.link/SoftwareArchitectureCSharp12Dotnet8
第九章:测试您的企业应用程序
在开发软件时,确保应用程序尽可能没有错误并满足所有要求是至关重要的。这可以通过在开发过程中测试所有模块或在整体应用程序完全或部分实现后进行测试来实现。在今天的敏捷和 DevOps 驱动的软件开发环境中,这种需求变得越来越迫切,因为在开发过程的每个阶段集成测试对于可靠软件的持续交付至关重要。
虽然本章涵盖的大部分关键概念适用于广泛的应用程序和环境,但本章重点介绍 C#和.NET 环境中企业级应用程序的必要测试策略。
手动执行所有测试不是一个可行的选择,因为大多数测试必须在每次修改应用程序时执行,正如本书中所述,现代软件是持续修改以适应快速变化市场的需求。因此,在今天的快速开发环境中,自动化测试是必不可少的。
本章讨论了交付可靠软件所需的最常见测试类型以及如何组织和自动化它们。更具体地说,本章涵盖了以下主题:
-
理解单元测试和集成测试及其用法,它们是确保软件可靠性和稳定性的主要工具
-
理解测试驱动开发(TDD)的基本原理以及它如何以及为什么可以显著降低未发现错误的概率
-
功能测试,这是强制执行软件规范的主要工具
-
在 Visual Studio 中定义 C#测试特定项目以充分利用.NET 生态系统中可用的测试工具
-
自动化 C#中的功能测试
本章不仅将教你不同类型的测试及其实现方法,还将教你如何有效地将这些技术应用于你的.NET 软件架构师角色,以构建健壮、可扩展的企业应用程序。
技术要求
本章需要 Visual Studio 2022 免费社区版或更高版本,并安装所有数据库工具。本章的代码可在github.com/PacktPublishing/Software-Architecture-with-C-Sharp-12-and-.NET-8-4E
找到。
理解单元测试和集成测试
测试是软件开发的一个基本组成部分,因为它验证软件是否无错误以及是否符合约定的规范。在应用程序的大部分功能完全实现后立即进行应用程序测试的做法必须避免,以下是一些原因:
-
如果一个类或模块设计或实现不当,它可能已经影响了其他模块的实现方式。因此,在这个阶段,修复问题可能代价非常高。
-
需要测试所有可能执行路径的输入组合数量会随着测试的模块或类的数量呈指数增长。例如,如果一个类方法
A
的执行可以采取三条不同的路径,而另一个方法B
的执行可以采取四条路径,那么同时测试A
和B
就需要 3 x 4 种不同的输入。一般来说,如果我们一起测试几个模块,需要测试的总路径数是每个模块测试路径数的乘积。如果模块分别测试,所需的输入数量只是测试每个模块所需路径的总和。这就是为什么所谓的单元测试会在每个类设计完成后立即以详尽的方式分别验证每个类方法。之后,可以通过接受的小数量所谓的 集成测试 来验证整体行为的正确性,因为集成测试只需要验证各种类的交互模式,而不需要分析所有方法的执行路径。 -
如果一个由 N 个模块组成的聚合体测试失败,那么在 N 个模块中定位错误源头通常是一个非常耗时的活动。
-
当一起测试 N 个模块时,我们必须重新定义涉及 N 个模块的所有测试,即使应用程序生命周期中只有一个 N 个模块发生变化。
这些考虑表明,我们需要尽快分别测试每个类方法,并且还需要测试正确的模块集成。
正因如此,测试被组织成三个阶段,如下所示:
-
单元测试:这些测试验证每个方法的所有或几乎所有执行路径的行为是否正常。它们应该没有外部依赖,例如存储和数据库,并且应该相当完整;也就是说,它们应该覆盖大多数可能的路径。这通常在可接受的时间成本内是可行的,因为与整个应用程序的可能执行路径相比,每个方法可能的执行路径并不多。
-
集成测试:这些测试在软件通过所有单元测试后执行。集成测试验证所有模块是否能够正确交互以获得预期结果。由于单元测试已经验证了每个模块的所有执行路径都正常工作,因此集成测试不需要完全覆盖。它们需要验证尽可能多的交互模式,即各种模块可能合作的各种方式。
-
验收测试:这些测试在每个冲刺的末尾以及/或发布应用程序之前执行。它们验证冲刺输出或最终应用程序是否满足功能性和非功能性要求。验证功能性要求的测试称为功能测试,而验证性能要求的测试称为性能测试。
通常,每个交互模式都与多个测试相关联:一个典型的模式激活和一些激活的极端情况。例如,如果一个完整的交互模式接收一个数组作为输入,我们将为典型大小的数组编写一个测试,一个null
数组的测试,一个空数组的测试,以及一个非常大的数组的测试。这样,我们验证单个模块的设计方式是否与整个交互模式的需求兼容。值得注意的是,在我们的数组示例中,null
、0
、1
和许多是等价类,它们以有效的方式代表了数组值的整个宇宙。
在实施上述策略的情况下,如果我们修改单个模块而不改变其公共接口,我们需要更改该模块的单元测试。
如果,相反,更改涉及某些模块交互的方式,那么我们也必须添加新的集成测试或修改现有的测试。然而,通常这并不是一个大问题,因为大多数测试都是单元测试,所以重写大部分集成测试并不需要太多的努力。此外,如果应用程序是根据单一职责、开闭原则、里氏替换原则、接口隔离原则或依赖倒置原则(SOLID)设计的,那么在单次代码修改后必须更改的集成测试数量应该很小,因为修改应该只影响直接与修改的方法或类交互的几个类。
自动化单元和集成测试
到这一点,应该很清楚,单元测试和集成测试在整个软件生命周期中都将被重用。这就是为什么自动化它们值得。单元和集成测试的自动化避免了手动测试执行可能出现的错误,并节省了时间。数千个自动化测试的整个系列可以在几分钟内验证软件的完整性,从而使得现代软件的 CI/CD 周期中需要的频繁更改成为可能。
随着新错误的发现,会添加新的测试来发现它们,以确保它们不会在未来版本的软件中再次出现。这样,自动化测试始终变得更加可靠,并使软件免受新更改导致的错误的影响。因此,添加新错误(未立即发现)的概率大大降低。
下一个子节将为我们提供组织和设计自动化单元和集成测试的基础,以及在“在 Visual Studio 中定义 C# 测试项目”部分中如何编写测试的实用细节。
编写自动化(单元和集成)测试
测试不是从头开始编写的;所有软件开发平台都有工具帮助我们编写测试并运行它们(或其中一些)。一旦选定的测试被执行,这些工具通常会显示报告并提供调试所有失败测试代码的可能性。
更具体地说,总的来说,所有单元和集成测试框架都由三个重要的部分组成:
-
定义所有测试的设施: 它们验证实际结果是否与预期结果相符。通常,一个测试被组织成测试类,其中每个测试调用测试单个应用程序类或单个类方法。每个测试分为三个阶段:
-
测试准备(安排****)**: 准备测试所需的一般环境。此阶段仅准备测试的全局环境,例如注入到类构造函数中的对象或数据库表的模拟;它不准备我们将要测试的每个方法的个别输入。通常,相同的准备程序在多个测试中使用,因此测试准备被提取到专门的模块中。
-
测试执行(执行/断言****): 要测试的方法使用适当的输入(执行)被调用,并且它们的执行结果通过诸如
Assert.Equal(x,y)
和Assert.NotNull(x)
等构造与预期结果(断言**)进行比较。 -
清理: 整个环境被清理,以避免测试的执行影响其他测试。这一步是 步骤 1 的逆操作。
-
-
模拟设施: 虽然集成测试使用所有(或几乎所有)参与对象合作模式的类,但在单元测试中,应避免使用其他应用程序类,因为它们的目的是测试每个独立的方法。因此,如果正在测试的类,例如
A
,在其方法M
中使用另一个应用程序类B
的方法,该类通过其构造函数注入,为了测试M
,我们必须注入B
的模拟实现。值得注意的是,只有执行某些处理的类不能被正在单元测试的其他类使用,而纯数据类可以。模拟框架包含定义接口及其方法实现的设施,这些实现可以由测试设计者定义的数据。通常,模拟实现也能够报告所有模拟方法调用的信息。这样的模拟实现不需要定义实际的类文件,而是在测试代码中通过调用如new Mock<IMyInterface>()
等方法在线完成。 -
执行和报告工具:这是一个基于视觉配置的工具,开发者可以使用它来决定要启动哪些测试以及何时启动它们。此外,它还以包含所有成功测试、所有失败测试、每个测试的执行时间以及其他依赖于特定工具及其配置的信息的报告形式显示测试的最终结果。通常,在开发 IDE(如 Visual Studio)中执行的和报告的工具还提供了在每个失败的测试上启动调试会话的可能性。
由于只有接口允许完全模拟所有方法,因此如果我们想在单元测试中模拟所有依赖项,我们应该在类构造函数和方法中注入接口或纯数据类(不需要模拟)。因此,对于每个我们想要注入到另一个类中并想要模拟的协作类,我们必须定义一个相应的接口。
此外,类应该使用在它们的构造函数或方法中注入的实例,而不是其他类公共静态字段中可用的类实例;否则,单元测试的结果可能看起来不是确定的,因为这些静态值可能在测试期间没有正确设置。
下一个部分将重点介绍功能和性能测试。
接受测试:编写功能和性能测试
接受测试定义了项目利益相关者和开发团队之间的合同。它们被用来验证开发的软件实际上表现如预期。接受测试不仅验证功能规范,还验证软件可用性和用户界面(UI)的约束。由于它们还有展示软件在真实计算机监视器和显示器上如何显示和表现的目的,因此它们永远不会完全自动化,主要由必须由操作员遵循的食谱和验证列表组成。
有时,自动功能测试被开发来仅验证功能需求,但其中一些测试也可能绕过 UI,直接将测试输入注入到用户界面后面的逻辑中。例如,在 ASP.NET Core MVC 应用程序的情况下(见第十七章,展示 ASP.NET Core),整个网站可以在一个包含所有必要存储并填充测试数据的完整环境中运行。输入不是提供给 HTML 页面,而是直接注入到 ASP.NET Core 控制器中。绕过用户界面的测试被称为皮下测试。ASP.NET Core 提供各种工具来执行皮下测试。
如果可能的话,大多数自动接受测试应该定义为皮下测试,以下是一些原因:
-
自动化与用户界面的实际交互是一个非常耗时的工作。
-
用户界面经常被修改以提升其可用性并添加新功能,单个应用程序屏幕上的微小更改可能迫使对该屏幕上运行的全部测试进行完全重写。
简而言之,用户界面测试成本很高且可重用性低,因此通常使用辅助测试来完全遵守规范,而完整的测试则涉及整个用户界面,仅用于测试更常见和/或更容易出错的情况。
测试用户界面的常见方法是在手动测试期间使用回放工具,如 Selenium IDE (www.selenium.dev/selenium-ide/
),这样每个手动测试都可以自动重复。这样的工具自动生成的代码可能不够健壮,无法抵抗 HTML 中的非平凡变化。Selenium IDE 的回放代码试图通过尝试多个选择器来识别每个 HTML 元素,以抵抗 HTML 变化,但这仅有助于小范围的变化。因此,通常,回放代码只能用于应用程序 UI 中不受变化影响的那些部分。
Selenium 软件也可以通过编程方式使用,即通过在代码中直接描述用户界面测试。Selenium 将在 使用 C# 自动化功能测试 部分中更详细地讨论,而功能测试通常在 功能测试 部分中讨论。
性能测试通过对应用程序施加模拟负载来查看其是否能够处理典型的生产负载,发现其负载限制,并定位瓶颈。应用程序部署在一个临时环境中,该环境在硬件资源方面是实际生产环境的副本。
然后,创建并应用模拟请求到系统中,并收集响应时间和其他指标。模拟请求批次应与实际生产批次具有相同的组成。如果可用,它们可以从实际生产请求日志中生成。
如果响应时间不令人满意,则会收集其他指标以发现可能的瓶颈(内存低、存储慢或软件模块慢)。一旦定位到,负责问题的软件组件就可以在调试器中进行分析,以测量典型请求中涉及的各个方法调用的执行时间。
性能测试中的失败可能导致重新定义应用程序所需的硬件,或者对某些软件模块、类或方法进行优化。
Azure 和 Visual Studio 都提供创建模拟负载和报告执行指标的工具。然而,它们已被宣布为过时,并将被停止使用,因此我们不会描述它们。作为替代,有开源和第三方工具可以使用。其中一些在 进一步阅读 部分中列出。
下一节将描述一种在测试中扮演核心角色的软件开发方法。
理解测试驱动开发的基本原理
测试驱动开发(TDD)是一种软件开发方法,其中单元测试扮演着核心角色。根据这种方法,单元测试是每个类规范的正式化,因此它们必须在类的代码之前编写。实际上,一个覆盖所有代码路径的完整测试明确定义了代码行为,因此它可以被视为代码的规范。这不是通过某种正式语言定义代码行为的正式规范,而是一种基于行为示例的规范。
测试软件的理想方式是编写整个软件行为的正式规范,并使用一些完全自动化的工具来验证实际产生的软件是否符合这些规范。在过去,一些研究工作被投入到定义用于描述代码规范的正式语言中,但用类似的语言表达开发者心中的行为是非常困难和容易出错的。因此,这些尝试很快就被基于示例的方法所取代。当时,主要目的是自动生成代码。
现在,自动代码生成已被大量放弃,仅在少数应用领域幸存,例如设备驱动程序的创建。在这些领域,用正式语言形式化行为所花费的努力值得在尝试测试难以复现的并行线程行为时节省的时间。
单元测试最初被构想为一种方式,以完全独立的方式将基于示例的规范编码为特定敏捷开发方法(极限编程)的一部分。然而,由于 TDD 被证明在防止错误方面非常有效,如今,TDD 被独立于极限编程使用,并作为其他敏捷方法中的强制性规定。
TDD 的实践证明,精心设计的初始单元测试足以确保软件达到可接受的稳定性水平,尽管通常初始测试并不是代码的“完美”规范。然而,无疑的事实是,在发现数百个错误后经过细化的单元测试作为可靠且实质上完美的代码规范。
精心设计的单元测试不能基于随机输入,因为你可能需要无限或至少是大量的示例来以这种方式明确地定义代码的行为。然而,如果你考虑到了所有可能的执行路径,行为可以用可接受的输入数量来定义。实际上,在这个点上,选择每个执行路径的典型示例就足够了。
那就是为什么在方法完全编码之后编写该方法的单元测试很容易:它只需要为现有代码的每个执行路径选择一个典型实例。然而,以这种方式编写单元测试并不能防止执行路径设计本身的错误。
因此,在方法完全编码之前必须编写单元测试,但在编写单元测试时,开发者必须通过寻找极端情况并可能添加比严格需要的更多示例来预测所有执行路径。
例如,在编写排序数组的代码时,我们可能会在编写任何有用的方法代码之前考虑所有可能预测的极端情况,即:
-
一个空数组
-
一个空数组
-
一个只有一个元素的数组
-
一个只有几个元素的数组
-
一个包含几个元素的数组
-
一个已经排序的数组
-
一个部分排序的数组
-
一个极度无序的数组
在算法的第一版编写并通过上述所有测试之后,通过分析所有执行路径可能会发现可能导致不同执行路径的其他输入。如果这种情况发生,我们将为发现的每个不同执行路径添加一个新的单元测试。
例如,在数组排序方法的情况下,假设我们使用像归并排序这样的分而治之算法,该算法递归地将数组分成两半,以递归地将问题简化为一个更简单的问题。当然,处理长度为偶数或奇数的数组的方式将不同,因此我们必须至少添加两个新的测试,一个用于偶数长度的数组,另一个用于奇数长度的数组。
然而,由于开发者在编写应用程序代码时可能会犯错,他们在设计单元测试时预测所有可能的执行路径时也可能犯错!
看起来我们已经识别了 TDD 的一个可能的缺点:单元测试本身可能是错误的。也就是说,不仅应用程序代码,而且与其相关的 TDD 单元测试可能与开发者的预期行为不一致。因此,在开始时,单元测试不能被视为软件规范,而是一种可能不正确和不完整的软件行为描述。
因此,我们有两种关于我们预期行为的行为描述:应用程序代码本身以及在其之前编写的 TDD 单元测试。然而,这不是问题,因为概率论帮助我们!
TDD 在实践中能够很好地工作的事实是,在编写测试和编写代码时犯完全相同错误的概率非常低。因此,每当测试失败时,测试或应用程序代码中就存在错误,反之亦然,如果应用程序代码或测试中存在错误,那么测试失败的概率非常高。也就是说,使用 TDD 确保大多数错误都能立即被发现!
现在我们已经了解了 TDD 如何有效地防止错误,并且已经学会了如何选择单元测试的输入,我们可以转向基于 TDD 的代码编写过程的描述。
使用 TDD 编写一个类方法或一段代码,比如用 TDD 找到整数数组的最大值,是一个由三个阶段组成的循环:
-
红色阶段:在这个阶段,开发者编写一个空方法,比如
MaximumGrade
,它抛出NotImplementedException
,并为这个方法编写新的单元测试。这些测试必然会失败,因为在这个时候,没有任何代码实现了它们所描述的行为:public int MaximumGrade(int[] grades) { throw new NotImplementedException(); }
-
绿色阶段:在这个阶段,开发者编写最少的代码或对现有代码进行最少的修改,以确保所有单元测试通过。比如说,我们用空数组、包含
0
个元素的数组、只有一个元素的数组和包含多个元素的数组来测试MaximumGrade
;通过所有测试的代码可能如下所示:int MaximumGrade(int[] grades) { if(grades == null) return 0; int result= 0; for(int i = 0; i < grades.Length; i++) { if (grades[i]> result) result= grades[i]; } return result; }
-
重构阶段:一旦测试通过,代码就会重构以确保良好的代码质量、应用最佳实践和模式。特别是,在这个阶段,某些代码可以被提取到其他方法或其他类中。在这个阶段,我们可能会发现需要其他单元测试,因为发现了新的执行路径或新的极端情况。在
MaximumGrade
的情况下,在这个阶段,我们可能会注意到以下情况:-
当数组为
null
时,而不是返回0
,定义一个新的异常并抛出它会更好。 -
foreach
比for
更高效、更易读。 -
如果所有数字都是负数怎么办?我们必须为负数数组创建一个新的测试:
int MaximumGrade(int[] grades) { if(grades == null) throw new ArgumentException(); int result= 0; foreach(int grade in grades) { if (grade > result) result= grade; } return result; }
-
一旦所有测试通过,而无需编写新代码或修改现有代码,循环就会停止。
当我们重复红色阶段时,由于我们的初始化不足,新添加的针对负数数组的测试将会失败:
int result=0;
因此,我们需要将其替换为:
int result= int.MinValue;
到这一点,所有测试再次通过,我们再次进入绿色阶段。没有必要进行进一步的重构,所以重构阶段不会修改我们的代码,这意味着我们可以退出测试循环:我们完成了!
有时候,设计初始单元测试非常困难,因为很难想象代码可能如何工作以及它可能采取的执行路径。在这种情况下,你可以通过编写方法代码的初始草图来更好地理解要使用的特定算法。在这个初始阶段,我们只需要关注主要的执行路径,完全忽略极端情况和输入验证。一旦我们清楚地了解了应该工作的算法背后的主要思想,我们就可以进入标准的三个阶段的 TDD 循环。
下一个部分将详细讨论功能测试。
功能测试
这些测试使用与单元测试和集成测试相同的技巧和工具,但它们的不同之处在于它们只在每个冲刺的结束时运行。它们的基本作用是验证整个软件的当前版本是否符合其规范。
由于功能测试也涉及到用户界面(UI),因此它们需要额外的工具来模拟用户在 UI 中的行为。需要额外工具的挑战不仅仅是 UI 带来的,因为 UI 也经常发生频繁和重大的变化。因此,我们不应该设计依赖于 UI 图形细节的测试,否则我们可能被迫在每次 UI 更改时完全重写所有测试。我们将在“在 C#中自动化功能测试”部分讨论这两个工具以及优化 UI 测试的最佳实践。
无论如何,值得注意的是,有时放弃某些与 UI 相关的功能的自动化测试,转而进行手动测试会更好,因为 UI 代码的短暂生命周期并不能证明时间投入是合理的。
不论是自动的还是手动的,功能测试都必须是一个正式的过程,用于以下目的:
-
功能测试代表了利益相关者和开发团队之间合同的最重要部分,另一部分是对非功能规范的验证。这个合同如何正式化取决于开发团队和利益相关者之间关系的性质。
-
在供应商-客户关系的案例中,功能测试成为每个冲刺的供应商-客户业务合同的一部分,由为顾客工作的团队编写。如果测试失败,则该冲刺被拒绝,供应商必须运行一个补充冲刺来解决所有问题。
-
如果由于开发团队和利益相关者属于同一家公司而没有供应商-客户业务关系,那么就没有商业合同。在这种情况下,利益相关者与团队一起编写一个内部文档,以正式化冲刺的需求。如果测试失败,通常不会拒绝冲刺,但测试结果将用于驱动下一个冲刺的规范。当然,如果失败率很高,冲刺可能会被拒绝并需要重做。
-
在每个冲刺结束时运行的正式化功能测试可以防止之前冲刺中取得的结果被新代码破坏。
-
当使用敏捷开发方法时,维护一个最新的功能测试库是获取最终系统规范正式表示的最佳方式,因为在敏捷开发过程中,最终系统的规范不是在开发开始之前就确定的,而是系统演化的结果。敏捷开发和冲刺将在第一章,“理解软件架构的重要性”中详细讨论。
由于在早期阶段,第一个冲刺的输出可能与最终系统有很大差异,因此不值得花太多时间编写详细的手动测试和/或自动化测试。因此,您可以将用户故事限制为仅几个示例,这些示例将同时用作软件开发输入和手动测试。
随着系统功能的日益稳定,值得花时间编写详细和正式的功能测试。对于每个功能规范,我们必须编写测试来验证其在极端情况下的操作。例如,在现金取款用例中,我们必须编写测试来验证所有可能性:
-
资金不足
-
卡片过期
-
错误的凭证
-
重复错误的凭证
以下图表概述了整个流程及其所有可能的结果:
图 9.1:取款示例
用户插入他们的卡,卡可能被接受或拒绝,因为已经过期。然后,用户尝试他们的 PIN 码,可能会出现错误,所以他们可能会重复输入 PIN 码,直到成功或达到最大尝试次数。最后,用户输入取款金额,可能会得到钱或“资金不足”错误。
在手动测试的情况下,对于上述所有场景,我们必须给出每个操作中涉及的每个步骤的所有细节,并且对于每个步骤,预期的结果。
一个重要的决定是您是否想自动化所有或部分功能测试,因为编写模拟与系统 UI 交互的人类操作员的自动化测试非常昂贵。最终的决定取决于测试实现的成本除以预期的使用次数。
在 CI/CD 的情况下,相同的功能测试可以执行多次,但不幸的是,功能测试严格绑定到 UI 的实现方式,而在现代系统中,UI 经常更改。因此,在这种情况下,测试最多只能与完全相同的 UI 执行几次。
为了克服与 UI 相关的多数问题,一些功能测试可以实施为皮下测试。皮下测试是一种特殊类型的功能测试,旨在绕过应用程序的 UI 层。这些测试不是像用户一样通过 UI 与应用程序交互,而是直接与应用程序的下层逻辑交互。例如,在一个 ASP.NET Core 应用程序中,皮下测试可能会直接调用控制器的方法——处理传入请求的应用程序部分——而不是通过浏览器发送这些请求的过程。这种方法有助于我们专注于测试应用程序的核心功能,而不必处理 UI 的复杂性。
在第二十一章的用例中,案例研究,我们将看到在实践中如何为 ASP.NET Core 应用程序设计皮下测试。
不幸的是,皮下测试无法验证所有可能的实现错误,因为它们无法检测 UI 本身的错误。此外,在 Web 应用程序的情况下,皮下测试通常还受到其他限制,因为它们绕过了整个 HTTP 协议。
特别是,在描述第十七章的介绍 ASP.NET Core时,如果我们直接调用控制器操作方法,我们将绕过整个 ASP.NET Core 管道,该管道在将请求传递给正确的操作方法之前处理每个请求。因此,身份验证、授权、CORS 以及其他中间件在 ASP.NET Core 管道中的行为将不会被测试分析。
一个完整的自动化功能测试应该做以下几件事情:
-
在要测试的 URL 上启动一个实际的浏览器。
-
等待,以便页面上的任何 JavaScript 完成其执行。
-
然后,向浏览器发送模拟人类操作员行为的命令。
-
最后,在与浏览器交互之后,自动测试应该等待,以便由交互触发的任何 JavaScript 完成其执行。
这些测试可以使用像Selenium这样的浏览器自动化工具执行,这将在本章的使用 C#自动化功能测试部分中讨论。
值得指出的是,并非所有用户界面测试都可以自动化,因为没有任何自动测试可以验证用户界面的外观及其可用性。
随着我们探索了功能测试和皮下测试的复杂性,我们已经清楚地认识到,全面的测试策略必须涵盖测试的执行方式,以及它们的构思和传达方式。这使我们来到了行为驱动开发(BDD),这是一种基于功能测试原则的方法,通过强调业务价值和客户端行为来构建。BDD 提供了一种结构化的方法来创建与用户需求和业务目标更紧密对齐的测试。
行为驱动开发(BDD)
BDD 遵循我们已描述的 TDD 规则,但主要关注业务价值和客户端行为。
我们讨论了单元测试的优势如下:“在用两种完全不同的方式描述一个行为时,即用代码和用示例,我们可能会犯完全相同的错误,因此错误被发现的概率接近 100%。”
BDD 使用相同的方法,但 TDD 中使用的示例必须不依赖于功能可能实现的具体方式。也就是说,示例必须尽可能接近纯规范。这样,我们可以确保测试不会影响功能实现的方式,反之亦然;我们在编写规范时不受纯技术设施或约束的影响,主要关注用户需求。
此外,测试应使用利益相关者能够理解的语言。出于这些原因,测试使用 Given-When-Then 语法进行描述。以下是一个示例:
Given the first number is 50
And the second number is 70
When the two numbers are added
Then the result should be 120
Given
、And
、When
和 Then
是关键字,而其余文本只是包含示例数据的自然语言。
Given-When-Then 正式语言被称为 Gherkin。它可以手动或使用工具集(如 Cucumber cucumber.io/
)或针对 .NET 项目的 SpecFlow 等工具进行转换。SpecFlow 是一个可以从 Visual Studio 扩展菜单安装的 Visual Studio 扩展。一旦安装,它将添加一种新的测试项目类型,即 SpecFlow 项目。
在 SpecFlow 中,Given-When-Then 测试是在 .feature
文件中定义的,而描述中包含的自然语言子句则被转换成所谓的步骤文件中的代码。步骤文件是自动创建的,但其中的代码必须由开发者编写。以下是将 "Given the first number is 50"
子句转换成代码的方法:
[Given("the first number is (.*)")]
public void GivenTheFirstNumberIs(int number)
{
_calculator.FirstNumber = number;
}
方法顶部的属性是由 SpecFlow 自动创建的,但用于从自然语言子句中提取数据的 (.*) 正则表达式必须由开发者编写。
_calculator
是一个必须由开发者创建的变量,它包含一个用于测试的类:
private readonly Calculator _calculator = new Calculator();
一旦完全定义,SpecFlow 测试将通过利用 .NET 支持的底层测试框架来运行。在创建 SpecFlow 项目时指定要使用的底层测试框架。
虽然 BDD 和 Gherkin 语法可以在单元、集成和功能测试中使用,但将测试用自然语言编写并转换为代码的努力只对功能测试来说是值得的,因为功能测试必须易于被利益相关者理解。
在编写单元测试时,BDD 规则中测试独立于实现的规则提高了测试的质量和生命周期(较少的测试依赖于实现。因此,它们需要更新的频率较低)。然而,请记住,我们正在单元测试的类本身是实施选择的结果,因此过度关注测试独立性可能会导致时间的浪费。
在描述了测试背后的理论之后,我们准备转向 C# 中的实际实现。在下一节中,我们将列出 Visual Studio 中所有可用的测试项目,并详细描述 xUnit。
在 Visual Studio 中定义 C# 测试项目
.NET SDK 包含三种类型的单元测试框架的项目模板:MSTest、xUnit 和 NUnit。当在 Visual Studio 中启动新项目向导时,如果您想查看适用于 .NET C# 应用程序的这些测试框架的兼容版本,请将 项目类型 设置为 测试,将 语言 设置为 C#,将 平台 设置为 Linux。此配置将允许您识别并选择适合您项目的 MSTest、xUnit 和 NUnit 的适当版本。
下面的截图显示了应该出现的选项:
图 9.2:添加测试项目
所有前面的项目都自动包含用于在 Visual Studio 测试用户界面(Visual Studio 测试运行器)中运行所有测试的 NuGet 包。然而,它们不包含任何用于模拟接口的功能,因此您需要添加包含流行模拟框架的 Moq
NuGet 包。
所有这些测试项目都必须包含对要测试的项目的引用。
在下一小节中,我们将描述 xUnit,但所有三个框架都非常相似,主要区别在于断言方法的名称以及用于装饰各种测试类和方法的属性的名称。
使用 xUnit 测试框架
在 xUnit 中,测试是带有 [Fact]
或 [Theory]
属性的装饰方法。测试由测试运行器自动发现,并在用户界面中列出所有测试,以便用户可以运行所有测试或仅运行其中的一部分。
在运行每个测试之前,都会创建测试类的新的实例,因此类构造函数中包含的 测试准备 代码会在类的每个测试之前执行。如果您还需要 清理代码,则测试类必须实现 IDisposable
接口,以便清理代码可以包含在 IDisposable.Dispose
方法中。
测试代码调用要测试的方法,然后使用 Assert
静态类中的方法测试结果,例如 Assert.NotNull(x)
、Assert.Equal(x, y)
和 Assert.NotEmpty(IEnumerable x)
。一些方法验证是否抛出特定类型的异常,例如:
Assert.Throws<MyException>(() => {/* test code */ ...}).
当断言失败时,会抛出异常。如果测试代码或断言抛出未捕获的异常,则测试失败。
以下是一个定义单个测试的方法的示例:
[Fact]
public void Test1()
{
var myInstanceToTest = new ClassToTest();
Assert.Equal(5, myInstanceToTest.MethodToTest(1));
}
当一个方法仅定义一个测试时,使用 [Fact]
属性,而当同一个方法定义了多个测试,每个测试针对不同的数据元组时,使用 [Theory]
属性。数据元组可以通过几种方式指定,并作为方法参数注入到测试中。
可以修改前面的代码以测试 MethodToTest
的多个输入,如下所示:
[Theory]
[InlineData(1, 5)]
[InlineData(3, 10)]
[InlineData(5, 20)]
public void Test1(int testInput, int testOutput)
{
var myInstanceToTest = new ClassToTest();
Assert.Equal(testOutput,
myInstanceToTest.MethodToTest(testInput));
}
每个InlineData
属性指定了一个要注入到方法参数中的元组。由于只需简单常量数据可以作为属性参数包含,xUnit 还允许您从实现IEnumerable
的类中获取所有数据元组,如下面的示例所示:
public class Test1Data: IEnumerable<object[]>
{
public IEnumerator<object[]> GetEnumerator()
{
yield return new object[] { 1, 5};
yield return new object[] { 3, 10 };
yield return new object[] { 5, 20 };
}
IEnumerator IEnumerable.GetEnumerator()=>GetEnumerator();
}
...
[Theory]
[ClassData(typeof(Test1Data))]
public void Test1(int testInput, int testOutput)
{
var myInstanceToTest = new ClassToTest();
Assert.Equal(testOutput,
myInstanceToTest.MethodToTest(testInput));
}
提供测试数据的类类型是通过ClassData
属性指定的。
还可以使用MemberData
属性从类的静态方法中获取数据,该静态方法返回IEnumerable
,如下面的示例所示:
[Theory]
[MemberData(nameof(MyStaticClass.Data),
MemberType= typeof(MyStaticClass))]
public void Test1(int testInput, int testOutput)
{
...
MemberData
属性将方法名称作为第一个参数传递,将MemberType
命名参数中的类类型。如果静态方法是同一测试类的一部分,则可以省略MemberType
参数。
下一个子节将展示如何处理一些高级的测试准备和清理场景。
高级测试准备和清理场景
有时,准备代码包含非常耗时的操作,例如,在不需要在每次测试之前重复的数据库上打开连接,但可以在同一类中包含的所有测试之前执行一次。在 xUnit 中,这种测试准备代码不能包含在测试类构造函数中;由于在每次单个测试之前都会创建测试类的不同实例,因此必须将其提取到单独的类中,称为固定类。
如果我们还需要相应的清理代码,则固定类必须实现IDisposable
。在其他测试框架中,如 NUnit,测试类实例仅创建一次,因此不需要将固定代码提取到其他类中。然而,像NUnit这样的测试框架,在每次测试之前不创建新实例,可能会因为测试方法之间的不期望交互而出现错误。
以下是一个 xUnit 固定类示例,该类打开和关闭数据库连接:
public class DatabaseFixture : IDisposable
{
public DatabaseFixture()
{
Db = new SqlConnection("MyConnectionString");
}
public void Dispose()
{
Db.Close()
}
public SqlConnection Db { get; private set; }
}
由于固定类实例仅在所有与固定类关联的测试执行之前创建一次,并且在测试之后立即销毁,因此数据库连接仅在创建固定类时创建一次,并在销毁固定对象后立即销毁。
通过让测试类实现空的IClassFixture<T>
接口,将固定类与每个测试类关联,如下所示:
public class MyTestsClass : IClassFixture<DatabaseFixture>
{
private readonly DatabaseFixture fixture;
public MyDatabaseTests(DatabaseFixture fixture)
{
this.fixture = fixture;
}
...
}
固定类实例会自动注入到测试类构造函数中,以便使固定测试准备中计算的所有数据对测试可用。这样,例如,在我们的上一个示例中,我们可以获取数据库连接实例,以便类中的所有测试方法都可以使用它。
如果我们想在测试类集合中的所有测试上执行一些测试准备代码,而不是单个测试类,我们必须将固定类与代表测试类集合的空类关联起来,如下所示:
[CollectionDefinition("My Database collection")]
public class DatabaseCollection : ICollectionFixture<DatabaseFixture>
{
// this class is empty, since it is just a placeholder
}
CollectionDefinition
属性声明了集合的名称,而IClassFixture<T>
接口已被ICollectionFixture<T>
所取代。
然后,我们通过将集合名称应用于Collection
属性来声明测试类属于之前定义的集合,如下所示:
[Collection("My Database collection")]
public class MyTestsClass
{
DatabaseFixture fixture;
public MyDatabaseTests(DatabaseFixture fixture)
{
this.fixture = fixture;
}
...
}
Collection
属性声明了要使用哪个集合,而测试类构造函数中的DataBaseFixture
参数提供了一个实际的固定类实例,因此它可以在所有类测试中使用。
现在我们已经看到了如何利用固定类来在多个测试之间共享设置和清理代码,从而提高我们的测试组织和效率,我们将注意力转向测试工具箱中的另一个强大技术。下一节介绍了Moq
框架的使用,这是一个工具,它允许我们通过模拟来模拟测试中复杂依赖项的行为。这种方法对于隔离我们正在测试的组件并在受控条件下验证其行为至关重要,而无需实际实现其依赖项。
使用 Moq 进行接口模拟
模拟是一种在单元测试中用来隔离类对其他类的依赖的技术,这样我们就可以将每个测试失败归因于正在测试的类。通过创建每个依赖项的模拟或假版本,类与其依赖项被隔离。
本节中列出的任何测试框架都没有包含模拟功能,因为它们都没有包含在 xUnit 中,所以我们必须添加另一个提供模拟功能的库。Moq
框架是.NET 中流行的工具,使得模拟过程变得非常简单。
让我们探讨如何使用Moq
来创建模拟并有效地设置我们的测试。在这里,我们将讨论如何使用Moq
来创建模拟类。一个实际且完整的示例,展示了如何在实践中使用Moq
进行测试,可以在第二十一章的案例研究部分的测试 WWTravelClub 应用程序部分中找到。
作为第一步,我们需要安装Moq
NuGet 包。然后,我们需要在我们的测试文件中添加一个using Moq
语句。模拟实现可以轻松定义如下:
var myMockDependency = new Mock<IMyInterface>();
可以使用Setup/Return
方法对来定义模拟依赖项对特定方法特定输入的行为,如下所示:
myMockDependency.Setup(x=>x.MyMethod(5)).Returns(10);
我们可以为同一方法添加多个Setup/Return
指令。这样,我们可以指定无限数量的输入/输出行为。
除了特定的输入值之外,我们还可以使用通配符来匹配特定类型,如下所示:
myMockDependency.Setup(x => x.MyMethod(It.IsAny<int>()))
.Returns(10);
我们将5
替换为其类型,int
。
一旦我们配置了模拟依赖项,我们可以从其 Object
属性中提取模拟实例,并像使用实际实现一样使用它,如下所示:
var myMockedInstance=myMockDependency.Object;
...
myMockedInstance.MyMethod(10);
然而,模拟方法通常是由被测试的代码调用的,所以我们只需要提取模拟实例并将其用作测试的输入。
我们还可以模拟属性和异步方法,如下所示:
myMockDependency.Setup(x => x.MyProperty)
.Returns(42);
...
myMockDependency.Setup(x => x.MyMethodAsync(1))
.ReturnsAsync("aasas");
var res=await myMockDependency.Object
.MyMethodAsync(1);
对于 async
方法,必须用 ReturnsAsync
替换 Returns
。
每个模拟实例都会记录对其方法和属性的调用,因此我们可以在测试中使用这些信息。以下代码显示了一个示例:
myMockDependency.Verify(x => x.MyMethod(1), Times.AtLeast(2));
上述语句断言 MyMethod
至少被调用过两次带有给定的参数。还有 Times.Never
和 Times.Once
(它断言方法只被调用了一次),以及其他选项。
到目前为止总结的 Moq
文档应该覆盖你测试中可能出现的 99%的需求,但 Moq
也提供了更复杂的选择。进一步阅读部分包含了完整文档的链接。
第二十一章的案例研究中的测试 WWTravelClub 应用程序部分展示了在复杂示例中 Moq
的实际用法。
在探索了 Moq
的功能以及它是如何通过有效的模拟实现来增强我们的单元测试之后,我们现在将注意力转向 C# 应用程序测试的不同方面——在 ASP.NET Core 应用程序中自动化功能测试。在接下来的部分,我们将深入了解各种测试工具和技术,包括我们刚刚讨论的一些,它们是如何被应用于确保我们的 ASP.NET Core 应用程序按预期工作,无论是独立运行还是与其他系统和接口集成。
使用 C# 自动化功能测试
自动化功能测试使用与单元和集成测试相同的测试工具。也就是说,这些测试可以嵌入到我们在上一节中描述的相同的 xUnit、NUnit 或 MSTest 项目中。然而,在这种情况下,我们必须添加可以与 UI 交互和检查的工具。
在本章的剩余部分,我们将专注于网络应用程序,因为它们是本书的主要焦点。因此,如果我们正在测试网络 API,我们只需要 HttpClient
实例,因为它们可以轻松地与 XML 和 JSON 格式的网络 API 端点交互。
对于返回 HTML 页面的应用程序,交互更为复杂,因为我们还需要解析和与 HTML 页面 DOM 树交互的工具。
Selenium 工具集是一个很好的解决方案,因为它为所有主流浏览器提供了模拟用户交互的驱动程序,并可以编程访问浏览器 DOM。
使用 HttpClient
类测试网络应用程序有两个基本选项:
-
预发布应用: 一个
HttpClient
实例通过互联网/内网与实际的 预发布 网络应用连接,同时与其他所有进行软件测试的人类一起。这种方法的优点是你可以测试 真实内容,但由于在每次测试之前无法控制应用的初始状态,因此测试的构思更为困难。 -
受控应用: 一个
HttpClient
实例连接到一个在每次单个测试之前配置、初始化和启动的本地应用。这种情况与单元测试场景完全类似。测试结果可重复,每次测试前的初始状态是固定的,测试设计更容易,实际数据库可以被一个更快且更容易初始化的内存数据库所替代。然而,在这种情况下,你离实际系统的操作还相当远。
一个好的策略是使用 受控应用,其中你可以完全控制初始状态,用于测试极端情况,然后使用 预发布应用 来测试真实内容上的随机平均情况。
下面的两个子节描述了这两种方法。这两种方法的不同之处仅在于你定义测试固定器的方式。
测试预发布应用
在预发布应用的情况下,你的测试只需要一个可以发出 HTTP 请求的类,在 .NET 中是 HttpClient
类。只需定义一个高效的固定器,提供所需的 HttpClient
实例,避免耗尽操作系统连接的风险。可以通过 IHttpClientFactory
接口实现底层操作系统连接的高效管理。
只需向用于测试的依赖注入容器中添加一个 HttpClient
管理工厂即可:
services.AddHttpClient(),
其中 AddHttpClient
扩展属于 Microsoft.Extensions.DependencyInjection
命名空间,并在 Microsoft.Extensions.Http
NuGet 包中定义。因此,我们的测试固定器必须创建一个依赖注入容器,调用 AddHttpClient
扩展方法,并最终构建容器。以下固定器类执行这项工作:
public class HttpClientFixture
{
public HttpClientFixture()
{
var serviceCollection = new ServiceCollection();
serviceCollection
.AddHttpClient();
ServiceProvider = serviceCollection.BuildServiceProvider();
}
public ServiceProvider ServiceProvider { get; private set; }
}
在前面的定义之后,你的测试应该看起来如下:
public class MyUnitTestClass:IClassFixture<HttpClientFixture>
{
private readonly ServiceProvider _serviceProvider;
public UnitTest1(HttpClientFixture fixture)
{
_serviceProvider = fixture.ServiceProvider;
}
[Fact]
public void MyTest()
{
var factory =
_serviceProvider.GetService<IHttpClientFactory>())
HttpClient client = factory.CreateClient();
}
}
在 Test1
中,一旦你获取了一个 HTTP 客户端,你可以通过发出 HTTP 请求并分析应用返回的响应来测试应用。
当 HTTP 端点返回数据时,例如,以 JSON 格式返回,可以通过数据序列化/反序列化器将其转换为 .NET 数据,然后与预期数据进行比较,上述方法就足够了。
下一个子节描述了如何测试返回 HTML 的端点。
使用 Selenium 测试预发布应用
大多数返回 HTML 的端点要么手动测试,要么使用回放工具,如 Selenium IDE,在各个浏览器上进行测试。回放工具记录在真实浏览器上执行的所有用户操作,并生成代码,通过浏览器驱动程序帮助重复相同的操作序列。
然而,回放工具生成的代码对每个页面的 DOM 结构过于敏感,因此,大多数测试在用户界面相关更改后都必须进行替换。
因此,创建重要且稳定的 UI 测试代码最好是手动进行,这样对 DOM 变化更加稳健。
为了这个目的,Selenium 工具集包含了 Selenium.WebDriver
NuGet 包以及每个你想要采用的浏览器的驱动程序,例如,例如,用于 Chrome 浏览器的 Selenium.WebDriver.ChromeDriver
NuGet 包。
基于 Selenium WebDriver 的手动测试看起来像这样:
using OpenQA.Selenium;
using OpenQA.Selenium.ChromeDriver;
public class MyUnitTestClass:IClassFixture<HttpClientFixture>
{
[Fact]
public void MyTest()
{
Using (IWebDriver driver = new ChromeDriver())
{
driver.Navigate().GoToUrl("https://localhost:5001/mypage");
//use driver to interact with the loaded page here
...
var title = driver.Title;
Assert().Equal("My Application – My Page", title);
...
var submitButton =
driver.FindElement(By.ClassName("confirm-changes"));
submitButton.Clikck();
...
}
}
}
一旦加载了要测试的页面,driver
就被用来探索页面内容并与页面元素交互,例如按钮、链接和输入字段。如前述代码所示,与浏览器交互的语法相当简单直观。driver.FindElement
可以通过 CSS 类名、通过 ID 以及通过通用的 CSS 选择器来查找元素。
使用 HTML 元素添加描述其角色的 CSS 类是一个构建稳健 UI 测试的好技术。
下一小节将解释如何测试在受控环境中运行的应用程序。
测试受控的应用程序
在这种情况下,我们在测试应用程序中创建一个 ASP.NET Core 服务器,并用 HttpClient
实例对其进行测试。Microsoft.AspNetCore.Mvc.Testing
NuGet 包包含了我们需要创建 HTTP 客户端和运行应用程序的服务的所有内容。
Microsoft.AspNetCore.Mvc.Testing
包含一个 fixture 类,它负责启动本地 web 服务器并提供一个客户端与之交互。预定义的 fixture 类是 WebApplicationFactory<T>
。泛型 T
参数必须用你的 web 项目的 Program
类实例化,即 web 应用程序的入口点。
测试看起来像以下类:
public class MynitTest
: IClassFixture<WebApplicationFactory<MyProject.Program>>
{
private readonly
WebApplicationFactory<MyProject.Program> _factory;
public UnitTest1 (WebApplicationFactory<MyProject.Program> factory)
{
_factory = factory;
}
[Theory]
[InlineData("/")]
[InlineData("/Index")]
[InlineData("/About")]
....
public async Task MustReturnOK(string url)
{
var client = _factory.CreateClient();
// here both client and server are ready
var response = await client.GetAsync(url);
//get the response
response.EnsureSuccessStatusCode();
// verify we got a success return code.
}
...
---
}
Program
类必须存在并且必须定义为公共的。否则,WebApplicationFactory
将没有启动应用程序的入口点。因此,如果 Program.cs
中的代码没有被包含在一个公共类中,就像 Visual Studio 默认生成的项目模板那样,你必须将 C# 编译自动生成的内部 Program
类转换为公共类。这可以通过在 Program.cs
文件末尾添加以下代码轻松实现:
public partial class Program { }
如果你想要分析返回页面的 HTML,你也必须引用 Selenium NuGet 包,如前一小节所示。我们将在下一节中看到如何使用它们。
在此类测试中处理数据库的最简单方法是将它们替换为内存数据库,这些数据库更快,并且每当本地服务器关闭和重启时都会自动清除。
不幸的是,内存数据库与实际使用的数据库并不完全兼容,因此某些测试可能会失败。因此,至少一些测试可能需要实际数据库。
在使用实际数据库进行测试时,我们还必须在继承自WebApplicationFactory<T>
的自定义固定构造函数中添加所有必要的指令来清除或从头创建一个标准数据库。请注意,删除所有数据库数据并不像看起来那么简单,因为存在完整性约束。您有多种选择,但没有一种是所有情况的最佳选择:
-
删除整个数据库,并使用 SQL 脚本或 Entity Framework Core 迁移重新创建它,这些将在第十三章“在 C#中与数据交互 - Entity Framework Core”中进行分析。这总是可行的,但速度较慢,需要具有高权限的数据库用户。
-
将测试数据库包含在 Docker 镜像中,并在每次新的测试中重新创建一个新的容器(Docker 将在第十一章“将微服务架构应用于您的企业应用”中讨论)。这比从头开始重新创建新数据库要快,但仍然较慢。
-
禁用数据库约束,然后按任何顺序清除所有表。这种技术有时不起作用,需要具有高权限的数据库用户。
-
按照正确的顺序删除所有数据,从而不违反所有数据库约束。如果在数据库增长的同时保持所有表的有序删除列表,并且向数据库添加表,这并不困难。这个删除列表是一个有用的资源,您还可以使用它来解决数据库更新操作中的问题,以及在生产数据库维护期间删除旧条目。不幸的是,这种方法在罕见的情况下也会失败,例如,有一个外键引用自身的表这种循环依赖的情况。
我更喜欢方法 4,只有在由于循环依赖导致的困难罕见情况下才回退到方法 3。
使用 Selenium IDE 记录测试
Selenium 工具集包含用于记录和回放浏览器测试的浏览器扩展。这些被称为 Selenium IDE。您可以从www.selenium.dev/selenium-ide/
下载 Chrome 和 Firefox 的扩展,而 Microsoft Edge 的扩展可以从microsoftedge.microsoft.com/addons/detail/selenium-ide/ajdpfmkffanmkhejnopjppegokpogffp
获取。
在 Chrome 中,一旦安装,可以通过点击扩展图标并从出现的菜单中选择 Selenium 扩展来运行 Selenium IDE,如下面的截图所示:
图 9.3:运行 Selenium IDE
您将收到提示,要求您执行的操作,例如创建新项目、访问现有项目等。
当您创建新项目时,您将收到项目名称的提示。此时,Selenium IDE 将打开,您可以将应用程序 URL 插入其中。
可以通过点击测试列表旁边的加号按钮来创建新的测试。
为了录制测试,请选择测试名称,然后点击录制图标。将打开一个新的浏览器窗口,其中包含应用程序 URL。从这一点开始,您在应用程序上执行的所有操作及其结果都将被记录。
您可以通过右键单击页面元素并从 Selenium IDE > 断言子菜单中选择适当的操作来对页面内容进行断言。例如,如果您在文本元素上选择 Selenium IDE > 断言 > 文本 命令,文本值将被存储。当测试执行时,同一文本元素的内容将与之前存储的值进行比较,如果两个值不同,则测试将失败。
一旦执行了所有期望的操作和断言,请返回 Selenium IDE 窗口,点击停止录制按钮,并保存项目。
Selenium IDE 提供了运行所选测试或所有测试的选项。
摘要
在本章中,我们解释了为什么自动化软件测试是值得的,然后我们关注了单元测试的重要性。我们还列出了各种测试类型及其主要功能,主要关注单元测试和功能测试。我们分析了 TDD 的优势以及如何在实践中使用它。有了这些知识,您应该能够生产出既可靠又易于修改的软件。
然后,本章分析了何时值得自动化某些或所有功能测试,并描述了如何在 ASP.NET
Core 应用程序中自动化它们。
最后,我们分析了适用于 .NET 项目的各种主要测试工具,重点关注 xUnit、Moq
、Microsoft.AspNetCore.Mvc.Testing
和 Selenium,并展示了如何借助本书的使用案例在实践中使用它们。
第二十一章,案例研究,将本章中描述的所有测试概念应用于本书的案例研究。
问题
-
为什么自动化单元测试是值得的?
-
TDD 能够立即发现大多数错误的主要原因是什么?
-
[Theory]
和[Fact]
属性的 xUnit 之间有什么区别? -
哪个 xUnit 静态类用于测试断言?
-
哪些方法允许定义
Moq
模拟的依赖项? -
是否可以使用
Moq
模拟async
方法?如果是,该如何操作? -
在快速 CI/CD 循环的情况下,是否总是值得自动化 UI 功能测试?
-
对于 ASP.NET Core 应用程序,皮下测试的缺点是什么?
-
建议使用什么技术来编写代码驱动的 ASP.NET Core 功能测试?
-
建议如何检查服务器返回的 HTML?
进一步阅读
-
尽管本章包含的 xUnit 文档相当完整,但它不包括 xUnit 提供的一些配置选项。完整的 xUnit 文档可在
xunit.net/
找到。MSTest 和 NUnit 的文档分别可在github.com/microsoft/testfx
和docs.nunit.org/
找到。 -
要了解更多关于 BDD 和 SpecFlow 的信息,请参考 Cucumber 官方网站
cucumber.io/
和 SpecFlow 文档docs.specflow.org/
。 -
完整的 Moq 文档可在
github.com/moq/moq4/wiki/Quickstart
找到。 -
这里有一些关于网络应用程序性能测试框架的链接:
-
更多关于
Microsoft.AspNetCore.Mvc.Testing NuGet
包的详细信息可在官方文档docs.microsoft.com/en-US/aspnet/core/test/integration-tests
中找到。 -
更多关于 Selenium IDE 的信息可在官方网站
www.selenium.dev/selenium-ide/
找到。 -
更多关于 Selenium WebDriver 的信息可在官方网站
www.selenium.dev/documentation/webdriver/
找到。
在 Discord 上了解更多信息
要加入本书的 Discord 社区——在那里您可以分享反馈、向作者提问以及了解新版本——请扫描下面的二维码:
packt.link/SoftwareArchitectureCSharp12Dotnet8
第十章:选择最佳云解决方案
当你设计应用程序使其成为云基础时,你必须了解不同的架构设计——从最简单的到最复杂的。事实上,云计算技术不仅能够实现成本优化,而且还能显著缩短将你的解决方案推向市场的时间。此外,它还能有效提高应用程序容错和扩展的能力。然而,为了优化成本、速度、弹性和可扩展性,通常需要在约束和灵活性方面做出权衡,因此你必须为你的需求选择正确的折衷方案。
本章讨论了不同的软件架构模型,并教你如何利用云提供的机遇来优化你的解决方案。本章还将讨论我们在开发基础设施时可以考虑的不同类型的云服务,理想的场景是什么,以及我们可以在哪里使用它们。
本章将涵盖以下主题:
-
基础设施即服务解决方案
-
平台即服务解决方案
-
软件即服务解决方案
-
无服务器解决方案
-
如何使用混合解决方案以及为什么它们如此有用
值得注意的是,在这些选项之间做出的选择取决于项目场景的不同方面,例如对灵活性和/或高度定制化解决方案的需求与简单性、低维护成本和低上市时间之间的权衡。这些权衡将在整章中讨论。
技术要求
对于本章的实践内容,你必须创建或使用一个 Azure 账户。我们可以在第一章,理解软件架构的重要性,的创建 Azure 账户部分找到创建 Azure 账户的详细步骤。
不同的软件部署模型
让我们从基础设施即服务(IaaS),云计算的基础层开始我们的探索。IaaS 提供了一种灵活且可扩展的基础设施,这对于有特定硬件要求或从本地解决方案过渡到云的企业至关重要。
在拥有基础设施工程师的公司中,你可能会发现更多的人在使用IaaS。另一方面,在 IT 不是核心业务的公司中,你会找到一大堆软件即服务(SaaS)系统。对于开发者来说,选择使用平台即服务(PaaS)选项或无服务器模式是很常见的,因为他们在这种情况下不需要提供基础设施。
作为软件架构师,你必须应对这种环境,并确保你在解决方案的初始开发阶段以及维护阶段都在优化成本和工作因素。此外,作为一名架构师,你必须了解你系统的需求,并努力将这些需求与一流的周边解决方案相连接,以加快交付速度,并使解决方案尽可能接近客户的规格。
IaaS 和 Azure 机遇
IaaS 是许多不同云服务提供商提供的云服务的第一代。它的定义在许多地方都可以找到,但我们可以将其总结为“你的计算基础设施通过互联网交付,托管在你不管理的某个地方。”就像我们在本地数据中心中虚拟化服务一样,IaaS 也会为你提供虚拟化组件,如服务器、存储和防火墙,在云端。
简而言之,你需要的所有硬件都托管在云端,并由云服务提供商维护,而不是托管在你的私有数据中心中。这包括作为虚拟机提供的服务器,可能连接到私有网络,磁盘存储,以及防火墙。你不需要购买硬件,只需为其使用付费,并且可以在零成本的情况下扩展到更强大的配置。
你可以像它托管在你的私有数据中心一样访问你的云硬件,但硬件维护由云服务提供商负责。此外,随着你的应用程序流量增加,你可以逐步扩展你的硬件,无需安装和购买成本,也无需将数据迁移到新的硬件。扩展是通过简单的配置指令完成的。
在众多云服务提供商中,Azure 提供了最全面和多样化的服务,因此你可以轻松找到与任何硬件需求完美匹配的方案。大多数 Azure IaaS 解决方案都是付费的,在测试时请注意这一点。值得一提的是,本书并不旨在详细描述 Azure 提供的所有 IaaS 服务。然而,作为一名软件架构师,你需要了解你将找到以下这样的服务:
-
虚拟机:Windows Server、Linux、Oracle、数据科学和机器学习
-
网络:虚拟网络、负载均衡器和 DNS 区域
-
存储:文件、表格、数据库和 Redis
显然,这些并不是 IaaS 模型中可用的唯一选项,因此第一步是查看 Azure 中可用的服务选项。要在 Azure 中创建任何服务,你必须找到最适合你需求的服务,然后创建一个资源。以下截图显示了正在配置的 Windows Server 虚拟机。
图 10.1:在 Azure 中创建虚拟机
按照 Azure 提供的向导设置您的虚拟机后,您将能够通过使用远程桌面协议(RDP)连接到它。下一个截图展示了您部署虚拟机时可以选择的一些硬件选项。考虑我们可用的各种硬件选项来部署虚拟机是非常有趣的,尤其是在考虑到这些选项只需点击选择按钮即可访问时。
图 10.2:Azure 中可用的虚拟机大小
如果您将本地交付硬件的速度与云交付的速度进行比较,您将意识到在上市时间方面,没有比云更好的选择。例如,有 64 个 CPU、256 GB 的 RAM 和 512 GB 临时存储的机器。这可能是在本地数据中心中找不到的,例如,如果您有一个临时的工作负载,或者如果您有一个需要这种计算能力的新业务想法,这将需要很长时间才能交付。此外,在临时工作负载场景中,这台机器将利用率不足,因此在本地场景中购买它的理由将是不成立的。
当您希望对基础设施拥有完全控制权,同时还能享受云计算的优势,如冗余和可扩展性时,可以采用 IaaS。然而,IaaS 解决方案的上市时间与本地解决方案相似。因此,在短期内方便快速启动,可以考虑先从允许快速启动的选项开始,并考虑长期目标转向 IaaS。
IaaS 中的安全责任
安全性是所有硬件解决方案的一个基本方面,因为黑客攻击可能会造成服务中断以及重要商业数据的泄露或丢失。
安全责任是了解 IaaS 平台时需要知道的另一件重要事情。许多人认为,一旦决定上云,所有的安全工作都由提供商完成。然而,这并不正确,如下面的截图所示:
图 10.3:云计算中的安全管理
IaaS 将迫使您从操作系统到应用程序都负责安全。在某些情况下,这是不可避免的,但您必须理解这将增加您的系统成本。
如果您只想将现有的本地结构迁移到云端,IaaS 可以是一个不错的选择。这得益于 Azure 提供的工具以及所有其他服务,从而实现了可扩展性。然而,如果您计划从头开发应用程序,您也应该考虑 Azure 上可用的其他选项。
将我们的重点转向PaaS,我们进入了一个以软件开发速度和效率为中心的领域。PaaS 提供了一个环境,企业可以在其中开发、运行和管理应用程序,而无需构建和维护基础设施的复杂性。
PaaS – 开发者的机会世界
如果你正在学习或已经学习过软件架构,你可能会完美理解下一句话的含义。当涉及到软件开发时,世界对速度的要求非常高!这种对速度的需求正是PaaS变得无价的地方,它提供了快速开发和部署的能力。
如前一张截图所示,PaaS 允许你仅在更接近你业务的角度考虑安全问题:你的数据和应用程序。对于开发者来说,这意味着无需实施一大堆配置来确保解决方案的安全运行。
安全处理不是 PaaS 的唯一优势。作为一名软件架构师,你可以将这些服务视为快速交付更丰富解决方案的机会。上市时间可以肯定地证明在 PaaS 基础上运行的应用程序的成本。
PaaS 解决方案让你摆脱了管理解决方案部署中所有细节的烦恼,同时不会对 PaaS 供应商产生过度依赖。实际上,你的软件不需要过多依赖所选的 PaaS,小的依赖可以在适当设计的驱动程序中隔离。换句话说,如果软件设计有基于驱动程序的架构,如我们在第七章“理解软件解决方案的不同领域”的层和洋葱架构部分中描述的洋葱架构,那么迁移到不同的 PaaS 解决方案或 IaaS 解决方案总是很容易的。因此,当计划长期迁移到 IaaS 时,PaaS 可能是一个减少上市时间的良好选择。
深入探讨 PaaS,让我们来看看 Azure 如何通过其多样化的服务来实现这一模型。Azure 的 PaaS 服务旨在满足各种开发需求,从 Web 应用程序到复杂的数据处理。
现在,Azure 中提供了许多作为 PaaS 提供的服务,可用的服务种类不断扩展,反映了开发者和企业不断变化的需求。
再次强调,本书的目的不是列出所有这些服务。然而,其中一些确实需要提及。这个列表还在不断增长,这里的建议是尽可能多地使用和测试这些服务!确保你将这个想法放在心上,交付更好的设计方案。
另一方面,值得一提的是,使用 PaaS 解决方案,你将不会完全控制操作系统。实际上,在许多情况下,你甚至不需要连接到它的方式。这大多数时候是可以接受的,但在某些调试情况下,你可能会错过这个功能。好消息是,PaaS 组件每天都在进化,微软最大的关注点之一就是让它们广为人知。
以下小节介绍了 Microsoft 为 .NET 网络应用程序提供的最常见 PaaS 组件,即 Azure Web Apps 和 Azure SQL Server。我们还描述了 Azure 认知服务,这是一个非常强大的 PaaS 平台,展示了在 PaaS 世界中开发的奇妙之处。我们将在本书的剩余部分更深入地探讨其中的一些内容。
在众多 PaaS 服务中,网络应用程序因其多功能性和易用性而脱颖而出。Azure 的网络应用程序服务简化了各种类型应用程序的部署,展示了 PaaS 解决方案的实际性和可访问性。
网络应用程序
网络应用程序是一个你可以用来部署你的网络应用程序的 PaaS 选项。你可以部署不同类型的应用程序,例如 .NET、.NET Core、Java、PHP、Node.js 和 Python。这种例子在 第一章,理解软件架构的重要性 中有所展示。
好处在于创建网络应用程序不需要任何结构或/和 IIS 网络服务器设置。在某些情况下,如果你使用 Linux 来托管你的 .NET 应用程序,你甚至没有 IIS。网络应用程序在托管选项上的灵活性,例如不需要 IIS 进行基于 Linux 的部署,强调了它们对各种开发和部署环境的适应性。
此外,网络应用程序有一个计划选项,你无需为使用付费。当然,有一些限制,例如只能运行 32 位应用程序,并且无法启用可伸缩性。这个免费层非常适合学习和初始应用程序原型设计,提供基本功能而不需要财务承诺。
SQL 数据库
想象一下,如果你不需要为部署这个数据库支付大服务器的费用,而只需拥有完整的 SQL 服务器功能,你可以多快地部署一个解决方案。这适用于 SQL 数据库。使用它们,你可以根据需要使用 Microsoft SQL Server – 存储和数据处理。在这种情况下,Azure 负责备份数据库。
在这里,我们将简要讨论将数据库放在云中的优势。数据库在 第十二章,在云中选择你的数据存储 和 第十三章,使用 C# 与数据交互 – Entity Framework Core 中有详细讨论。
假设你正在开发一个新的业务应用程序,最初你的数据存储和流量需求相当低,但你需要像数据复制这样的高级功能来保护你的数据,并为地理上分布的用户提供高性能。如果没有 Azure SQL 数据库,你就不得不购买多个 SQL 许可证,因为 SQL Server 免费版不涵盖类似场景。
使用 Azure SQL 数据库,你无需支付昂贵的许可证费用,只需为最初的小型存储和流量需求付费。
SQL 数据库甚至为你提供了自行管理性能的选项。这被称为自动调优——也就是说,你的流量需求会自动扩展,以保持响应时间在可接受范围内,随着请求的增加。这意味着你的成本可能会自动增加,但你也可以定义最大支出限制。
再次强调,使用 PaaS 组件,你将能够专注于对你业务重要的事情:非常快的上市时间。
创建用于测试的 SQL 数据库的步骤相当简单,就像我们看到的其他组件一样。然而,有两件事你需要注意:服务器的创建以及你将如何被收费。实际上,理解各种配置及其成本对于找到应用程序和最大成本需求之间最佳权衡是至关重要的。
当你创建资源时,你可以搜索SQL Database
,你将找到这个向导来帮助你:
图 10.4:在 Azure 中创建 SQL 数据库
SQL 数据库依赖于一个 SQL 服务器来托管它。因此,正如你所见,你必须创建(至少对于第一个数据库)一个database.windows.net
服务器,你的数据库将托管在这个服务器上。这个服务器将提供你使用当前工具(如 Visual Studio、SQL Server Management Studio 和 Azure Data Studio)访问 SQL 服务器数据库所需的所有参数。值得一提的是,你有一系列安全特性,例如透明数据加密和 IP 防火墙。
一旦你决定了数据库服务器的名称,你将能够选择你的系统将被收取费用的定价层。特别是在 SQL 数据库中,有几种不同的定价选项,如以下截图所示。你应该仔细研究每一个,因为根据你的场景,通过优化定价层你可以节省金钱:
图 10.5:配置 Azure SQL 数据库定价层
关于 SQL 配置的更多信息,你可以使用这个链接:azure.microsoft.com/en-us/services/sql-database/
。
关于数据库解决方案的更多细节将在第十二章,在云中选择你的数据存储和第十三章,使用 C#与数据交互 - Entity Framework Core中讨论。
一旦你完成了配置,你将能够以与你的 SQL 服务器在本地安装时相同的方式连接到这个服务器数据库。你必须注意的唯一细节是 Azure SQL 服务器防火墙的配置,但这设置起来相当简单,并且是 PaaS 服务安全性的良好证明。
Azure 认知服务
人工智能(AI)是软件架构中最常讨论的话题之一。我们离一个真正伟大的世界只有一步之遥,在那里人工智能将无处不在。
例如,人工智能在自动帮助中心、数据分析和基于自然语言的用户界面等领域越来越重要。人工智能为这些领域增加的经济价值巨大,无论是在服务成本降低还是在由于优化决策而增加的利润方面。
为了实现这一点,作为一个软件架构师,你不能总是把人工智能看作是需要从头开始发明的软件。
Azure 认知服务可以帮助你实现这一点。这些 API 使开发者能够轻松创建高级功能,如应用中的语音识别或客户服务工具中的语言翻译。
PaaS 的优势可以从这个场景中明显看出。开发内部人工智能解决方案的成本将需要巨大的投资和难以找到的技能。相反,使用 Azure 认知服务的 PaaS,你无需担心人工智能技术。相反,你可以专注于作为软件架构师真正关心的事情:解决你的业务问题。
在你的 Azure 账户中设置 Azure 认知服务也非常简单。首先,你需要像添加任何其他 Azure 组件一样添加认知服务。你可以选择一个特定的认知服务或一个多服务账户,这将使你能够访问所有认知服务。在下面的屏幕截图中,我们选择了一个多服务账户:
图 10.6:在 Azure 中创建认知服务 API 账户
一旦完成这些,你将能够使用服务器提供的 API。你将在你创建的服务中找到两个重要功能:端点和访问密钥。它们将在你的代码中用于访问 API。
以下代码示例展示了如何使用认知服务 API 翻译句子。这个翻译服务背后的主要概念是,你可以根据服务设置的关键和区域发送你想要翻译的句子。以下代码使你能够向服务 API 发送请求:
private static async Task<string> CSTranslate(string api, string key, string region, string textToTranslate)
{
using var client = new HttpClient();
client.DefaultRequestHeaders.Add("Ocp-Apim-Subscription-Key", key);
client.DefaultRequestHeaders.Add("Ocp-Apim-Subscription-Region", region);
client.Timeout = TimeSpan.FromSeconds(5);
var body = new[] { new { Text = textToTranslate } };
var requestBody = JsonConvert.SerializeObject(body);
var content = new StringContent(requestBody, Encoding.UTF8, "application/json");
var response = await client.PostAsync(api, content);
response.EnsureSuccessStatusCode();
return await response.Content.ReadAsStringAsync();
}
值得注意的是,前面的代码将允许你向任何语言翻译任何文本的请求,只要你将其定义在参数中。以下是一个调用先前方法的程序:
static async Task Main()
{
var host = "https://api.cognitive.microsofttranslator.com";
var route = "/translate?api-version=3.0&to=es";
var subscriptionKey = "[YOUR KEY HERE]";
var region = "[YOUR REGION HERE]";
if (subscriptionKey == "[YOUR KEY HERE]")
{
Console.WriteLine("Please, enter your key: ");
subscriptionKey = Console.ReadLine();
}
if (region == "[YOUR REGION HERE]")
{
Console.WriteLine("Please, enter your region: ");
region = Console.ReadLine();
}
var translatedSentence = await PostAPI(host + route, subscriptionKey, region, "Hello World!");
Console.WriteLine(translatedSentence);
Console.WriteLine("Press any key to continue...");
Console.ReadKey();
}
更多信息,请访问docs.microsoft.com/en-us/azure/cognitive-services/translator/reference/v3-0-languages
。
这是一个完美的例子,说明了你可以多么容易和快速地使用此类服务来构建你的项目。这种易用性不仅加速了项目开发,还开辟了新的创新和增强用户体验的可能性。
SaaS——只需登录即可开始!
SaaS 可能是使用基于云的服务最简单的方式。您需要的应用程序已经在云上可用,您只需进行配置和使用!云服务提供商提供了许多针对其终端用户解决常见问题的优秀选项。从电子邮件服务到全面的企业管理工具,SaaS 提供了一系列的应用程序,您可以根据各种业务需求进行定制。
Office 365 是这类服务的良好例子。这些平台的关键点是您无需担心应用程序的维护。这在您的团队完全专注于开发应用程序的核心业务的情况下尤其方便。例如,如果您的解决方案需要提供良好的报告,也许您可以使用 Power BI(包含在 Office 365 中)来设计它们。因此,将 Office 365 集成到您的解决方案中,可以无缝地进行数据分析和管理报告,立即提高您业务运营的整体效率和生产力。
SaaS 解决方案可以通过多种方式进行定制。最强大的技术是添加自定义插件。例如,您可以通过添加公共存储库中可用的各种插件或开发您自己的自定义插件来定制您的 Office 365 租户。
还值得一提的是,如果您决定投资于自定义插件,您已经拥有了,比如说,90%的应用程序运行稳定,因此,初始投资和市场时间都大大减少。
总结来说,一方面,SaaS 提供即时的复杂和经过验证的解决方案,但另一方面,SaaS 提供的定制选项非常有限。那么,您如何决定是否使用 SaaS 解决方案?
-
评估现有解决方案:首先,您需要验证是否已经存在符合您需求的 SaaS。
-
考虑定制和集成:然后,您需要验证所选选项是否可以适应您的需求,以及适应它们所需的投入。
-
分析成本影响:在此阶段,您必须分析您将被迫接受的不可避免妥协的影响,以及每个候选 SaaS 采用的整体成本,这包括它对您组织整体成本的影响。
-
审查安全和合规性:您还需要验证 SaaS 使用的用户/安全模型是否与您的需求兼容,以及通常情况下,该应用程序是否满足您组织所需的所有约束。
-
评估供应商的可靠性和支持:对 SaaS 供应商的强烈依赖对你的最终决策是一个关键因素。记住,一旦将 SaaS 解决方案纳入你的业务,可能很难转移到不同的解决方案——也就是说,供应商的合同权力可能会随着时间的推移而上升到不可接受的水平。出于同样的原因,不可靠的供应商(例如,一家年轻初创公司)或无法提供充分产品支持的供应商可能会造成不可接受的损害。
-
规划可扩展性和未来增长:你还必须考虑你组织的未来。因此,你也必须验证候选选项是否可扩展,并且能否适应你不断增长的需求和流量。
-
测试解决方案:最后,你需要在一个预演环境中实际测试所选的解决方案,以验证你的整体分析和预测是否正确。
另一个很好的 SaaS 平台例子是 Azure DevOps。作为一名软件架构师,在 Azure DevOps 之前,你需要为你的团队安装和配置团队基础服务器(TFS)(或者甚至更老的工具,如 Microsoft Visual SourceSafe),以便他们可以使用一个共同的存储库和应用生命周期管理工具。
我们过去花费大量时间要么在准备服务器以安装 TFS,要么在升级和持续维护已安装的 TFS。由于 SaaS Azure DevOps 的简单性,这不再需要。
理解无服务器意味着什么
无服务器解决方案是一种解决方案,其重点不在于代码运行的位置。即使在“无服务器”解决方案中,也始终存在服务器。问题是,你不知道或不在乎你的代码在哪个服务器上执行。无服务器的主要优势是,你没有任何固定月度成本,并且只需为你的流量付费。
然而,还有其他优势。例如,在无服务器解决方案中,你拥有非常快速、简单和敏捷的应用生命周期,因为几乎所有无服务器代码都是无状态的,并且与系统其他部分松散耦合。一些作者将此称为函数即服务(FaaS)。
当然,服务器在某处运行。这里的关键点是,你不需要为此或甚至可扩展性而担心。这将使你能够完全专注于你的应用程序的业务逻辑。再次强调,世界需要快速开发和良好的客户体验。你越关注客户需求,就越好!
FaaS 提供了最短的市场进入时间和最低的成本阈值,以及巨大的可扩展性可能性。因此,在难以预测应用程序工作量或当应用程序工作量有高且难以预测的波动时,它们非常方便。
假设,例如,你正在寻找一个解决方案来连接你的应用进行大量电子邮件发送。如果你的应用还处理营销活动,可能会有流量峰值,这些峰值可能是平均流量的 100 倍。FaaS 解决方案可能能够轻松覆盖这些巨大的峰值,而不会影响它们之外的正常成本。
相反,在类似情况下,如果你在平均期间不需要,只是在营销活动高峰期间需要,PaaS 或 IaaS 会迫使你升级到更高的价格层。
如果工作负载变得更加稳定,并且更容易确定可靠的下限和上限,由于在更高工作负载上的成本更高,FaaS 就变得不那么方便了。例如,亚马逊 Prime 最近公布了其通过从 FaaS 切换到 IaaS 而节省的费用。
不幸的是,从 FaaS 迁移到 PaaS 或 IaaS 并不容易,因为 FaaS 代码严格绑定到所选的 FaaS 解决方案。
通过避免过度碎片化的 FaaS 代码(由小型函数组成),而是使用少量触发执行更大传统软件模块的函数,可以部分缓解这个问题。这些软件模块被封装在 FaaS 解决方案中。这样,迁移到不同的 FaaS,或到 IaaS/PaaS,只需要重写几个 FaaS 主函数,而保持它们调用的更大软件模块不变。不幸的是,唤醒不活跃函数的性能惩罚与每个函数内使用复杂框架的使用不相容,因此碎片化是不可避免的。
根据我的经验,FaaS 在以下情况下证明是有用的:在预计高流量但低复杂性的应用早期阶段,或者对于像我们之前的邮件发送应用这样的低复杂度应用,这些应用专门设计来处理高且难以预测的峰值。事实上,在这些应用中,几乎完全重写代码是可以接受的,因为与几乎立即上市和有效处理大且难以预测的流量峰值相比,代码开发的投入较低。
在第十六章与无服务器一起工作 - Azure Functions中,你将探索微软在 Azure 中提供的最好的无服务器实现之一——Azure Functions。在那里,我们将关注你如何开发无服务器解决方案,并了解它们的优缺点。
下面的子节将沿几个轴比较 IaaS、PaaS、SaaS 和 FaaS。
比较 IaaS、PaaS、SaaS 和 FaaS
在前面的章节中,我们描述了所有主要云提供商提供的各种解决方案,包括它们的优缺点。下表总结了它们各自在五个轴上提供的主要优点,为每个轴分配了 1(较差)到 4(非常好)的评分。
上市时间 | 定制可能性 | 维护工作量 | 可扩展性 | 成本 | |
---|---|---|---|---|---|
IaaS | 1 | 4 | 1 | 1 | 4 |
PaaS | 2 | 3 | 2 | 2 | 3 |
SaaS | 4 | 1 | 4 | 取决于应用 | 1 |
FaaS | 3 | 2 | 3 | 4 | 2 |
为什么混合应用程序在许多情况下如此有用?
在其一般意义上,混合一词意味着其部分不共享统一架构选择的东西;每个部分都做出不同的架构选择。然而,在云解决方案的情况下,混合一词主要指的是混合云子系统与本地子系统的解决方案。然而,它也可以指混合 Web 子系统与特定设备子系统,如移动设备或其他运行代码的任何设备。
由于 Azure 可以提供的服务数量以及可以实施的设计架构数量,混合应用程序可能是本章解决的主要问题的最佳答案:如何在你的项目中利用云提供的机会。
现在,许多当前的项目正从本地解决方案迁移到云架构,而且无论你打算在哪里交付这些项目,你仍然会找到许多关于迁移到云端的错误先入为主的观念。其中大部分与成本、安全和服务的可用性有关。
你需要理解,这些先入为主的观念中确实有一些是真实的,但并非人们所想的那样。当然,作为软件架构师,你不能忽视它们。特别是在开发关键系统时,你必须决定是否可以将一切放在云端,或者是否最好将系统的一部分部署在边缘。因此,理解这些因素对于导航混合领域至关重要,这确保了你根据具体需求平衡成本、安全和可用性。
值得一提的一个现实生活中的例子可能有助于阐明混合解决方案的需求。最近,我们提供的 SaaS 解决方案的几位客户对我们无法将预留的和业务关键文档迁移到云端表示反对。我们的解决方案是为我们的 SaaS 配备了文件处理驱动程序,能够从位于客户私有内部网络中的内部文档服务器检索文件。
完全不同类型的混合解决方案是边缘计算范式。根据这种范式,系统的一部分必须部署在需要它们的位置附近的机器或设备上。这有助于减少响应时间和带宽。
移动解决方案可以被认为是混合应用程序的另一个经典例子,因为它们将基于 Web 的架构与基于设备的架构相结合,以提供更好的用户体验。有许多场景可以替换移动应用程序为响应式网站。然而,当涉及到界面质量和性能时,可能响应式网站无法满足最终用户真正需要的。
摘要
在本章中,你学习了如何利用云服务在你的解决方案中发挥优势,以及你可以选择的多种选项。
本章介绍了在基于云的结构中交付相同应用程序的不同方法。我们还注意到,微软正在迅速将这些选项提供给客户,因为您可以在实际应用程序中体验所有这些选项,并选择最适合您需求的一个,因为没有一种在所有情况下都适用的万能药。作为一名软件架构师,您需要分析您的环境和团队,然后决定在您的解决方案中实施的最佳云架构。
下一章将专门介绍构建一个由小型、可扩展的软件模块(称为微服务)组成的灵活架构。
问题
-
为什么你应该在你的解决方案中使用 IaaS?
-
为什么你应该在你的解决方案中使用 PaaS?
-
为什么你应该在你的解决方案中使用 SaaS?
-
为什么你应该在你的解决方案中使用无服务器架构?
-
使用 Azure SQL Server 数据库的优势是什么?
-
如何使用 Azure 加速您应用程序中的 AI?
-
混合架构如何帮助您设计更好的解决方案?
进一步阅读
您可以通过以下网络链接深入了解本章涵盖的主题:
-
www.packtpub.com/application-development/xamarin-cross-platform-application-development
-
www.packtpub.com/virtualization-and-cloud/learning-azure-functions
-
azure.microsoft.com/en-us/services/virtual-machines/data-science-virtual-machines/
-
docs.microsoft.com/azure/sql-database/sql-database-automatic-tuning
-
azure.microsoft.com/en-us/overview/what-is-serverless-computing/
-
www.packtpub.com/virtualization-and-cloud/professional-azure-sql-database-administration
在 Discord 上了解更多
要加入这本书的 Discord 社区——在那里您可以分享反馈、向作者提问,并了解新版本——请扫描下面的二维码:
packt.link/SoftwareArchitectureCSharp12Dotnet8
第十一章:将微服务架构应用于您的企业应用
本章致力于描述基于称为微服务的小模块的高度可扩展架构。微服务架构允许进行细粒度的扩展操作,其中每个单独的模块都可以按需扩展,而不会影响系统的其余部分。此外,它们通过允许每个系统子部分独立于其他部分进行演变和部署,从而提供了更好的持续集成/持续部署(CI/CD)。
在本章中,我们将涵盖以下主题:
-
什么是微服务?
-
在什么情况下微服务有帮助?
-
.NET 如何处理微服务?
-
管理微服务需要哪些工具?
到本章结束时,你将学会如何在.NET 中实现单个微服务。“第二十章,Kubernetes”也解释了如何部署、调试和管理基于微服务的整个应用。“第十四章,使用.NET 实现微服务”和“第十八章,使用 ASP.NET Core 实现前端微服务”是使用.NET 实现微服务的实际应用的逐步指南。
技术要求
本章的代码可在github.com/PacktPublishing/Software-Architecture-with-C-Sharp-12-and-.NET-8-4E
找到。
在本章中,你需要以下内容:
-
安装了所有数据库工具的 Visual Studio 2022 免费社区版或更高版本。
-
一个免费的 Azure 账户。在第一章“理解软件架构的重要性”中的“创建 Azure 账户”部分解释了如何创建一个。
-
如果你想在 Visual Studio 中调试 Docker 容器化的微服务,请使用Windows Docker 桌面版(
www.docker.com/products/docker-desktop
)。
相反,Windows Docker 桌面版至少需要安装了Windows 子系统(WSL)或Windows 容器的 Windows 10。
WSL允许 Docker 容器在 Linux 虚拟机上运行,可以按照以下方式安装(也请参阅learn.microsoft.com/en-us/windows/wsl/install
):
-
在 Windows 10/11 的搜索栏中输入
powershell
。 -
当Windows PowerShell作为搜索结果出现时,点击以管理员身份运行。
-
在出现的 Windows PowerShell 管理控制台中,运行命令
wsl --install
。
Windows 容器允许 Docker 容器直接在 Windows 上运行,但至少需要 Windows 专业版。它们可以按照以下方式安装:
-
在 Windows 10/11 的搜索栏中输入
Windows 功能
。 -
搜索结果将建议运行面板以启用/禁用 Windows 功能。
-
点击它,在打开的窗口中,选择容器。
什么是微服务?
微服务本质上是由小而独立的单元组成的大型软件应用程序,每个单元都有其特定的角色和功能。将软件应用程序拆分为独立的微服务允许构成解决方案的每个模块独立于其他模块进行扩展,以实现最大吞吐量并最小化成本。实际上,对整个系统而不是其当前瓶颈进行扩展不可避免地会导致资源的显著浪费,因此对子系统扩展的精细控制对系统的整体成本有相当大的影响。
然而,微服务不仅仅是可扩展的组件——它们是可以独立开发、维护和部署的软件构建块。将开发和维护分配给可以独立开发、维护和部署的模块可以提高整体系统的 CI/CD 周期(CI/CD 在第八章,理解 DevOps 原则和 CI/CD中进行了详细描述)。
CI/CD 的改进归因于微服务的独立性,因为它能够实现以下功能:
-
在不同类型的硬件上扩展和分发微服务。
-
由于每个微服务都是独立于其他微服务部署的,因此不存在二进制兼容性或数据库结构兼容性约束。因此,没有必要对组成系统的不同微服务的版本进行对齐。这意味着它们中的每一个都可以根据需要发展,而不会受到其他微服务的限制。
然而,必须注意通信协议和消息的选择及其版本,这些版本必须由所有参与的微服务支持。应优先考虑广泛支持且便于与旧版本消息向后兼容的协议。
-
将其开发分配给完全独立的较小团队,从而简化工作组织并减少处理大型团队时出现的所有不可避免的协调低效率。
-
使用更合适的技术和更合适的环境来实现每个微服务,因为每个微服务都是一个独立的部署单元。这意味着选择最适合您需求的工具和最小化开发努力/或最大化性能的环境。
-
由于每个微服务都可以使用不同的技术、编程语言、工具和操作系统来实现,企业可以通过将环境与开发者的能力相匹配来利用所有可用的人力资源。例如,所有可用的 Java 和.NET 开发者可以合作在同一应用程序中,从而利用所有可用资源。
-
可以将遗留子系统嵌入到独立的微服务中,从而使其能够与较新的子系统合作。这样,公司可以缩短推出新系统版本的时间。此外,通过这种方式,遗留系统可以缓慢地向更现代的系统发展,对成本和组织的影响是可以接受的。
下一个子节将解释微服务概念是如何产生的。然后,我们将通过探讨基本微服务设计原则和分析为什么微服务通常被设计为 Docker 容器来继续本介绍性章节。
微服务和模块概念的演变
为了更好地理解微服务的优势以及它们的设计技术,我们必须牢记软件模块化和软件模块的双重性质:
-
代码模块化指的是一种代码组织方式,使我们能够轻松修改代码块而不会影响应用程序的其余部分。它通常通过面向对象设计来实现,其中模块可以通过类来识别。
-
部署模块化取决于你的部署单元是什么以及它们具有哪些属性。最简单的部署单元是可执行文件和库。因此,例如,动态链接库(DLLs)肯定比静态库更模块化,因为它们在部署之前不需要与主可执行文件链接。
虽然代码模块化的基本概念已经达到稳定状态,但部署模块化的概念仍在不断发展,微服务目前在这一发展路径上处于最前沿。
作为对通向微服务之路上的主要里程碑的简要回顾,我们可以这样说,首先,单体可执行文件被分解为静态库。后来,DLLs 取代了静态库。
当.NET(以及其他类似框架,如 Java)改进了可执行文件和库的模块化时,发生了巨大的变化。实际上,由于.NET 是在库第一次执行时编译的中间语言中部署的,因此它们可以在不同的硬件和不同的操作系统上部署。此外,它们克服了先前 DLLs 的一些版本问题,因为任何可执行文件都可以携带一个与操作系统上安装的相同 DLL 版本不同的 DLL。
然而,.NET 不能接受使用不同版本的共同依赖项(例如,C)的两个引用 DLLs – 假设,A和B。例如,假设有一个带有许多新功能的新版本A,我们希望使用它,它反过来又依赖于一个不支持B的新版本C。在这种情况下,我们应该放弃A的新版本,因为C与B的不兼容性。这种困难导致了两个重要的变化:
-
包:开发世界从使用单个 DLLs 和/或单个文件作为部署单元转变为使用由 DLLs 和元数据组成的包作为部署单元。包由包管理系统(如 NuGet 和 npm)处理,这些系统使用包元数据通过语义版本化自动检查版本兼容性。
-
面向服务架构(SOA):部署单元最初以基于 SOAP 的 Web 服务实现,后来过渡到 RESTful Web 服务。这解决了版本兼容性问题,因为每个 Web 服务都在不同的进程中运行,并且可以使用每个库的最合适版本,而不会与其他 Web 服务造成不兼容的风险。此外,每个 Web 服务暴露的接口是平台无关的;也就是说,Web 服务可以使用任何框架与应用程序连接,并在任何操作系统上运行,因为 Web 服务协议基于普遍接受的标准。SOA 和协议将在第十五章《使用.NET 应用面向服务架构》中更详细地讨论。
微服务是 SOA(面向服务架构)的演进,它增加了更多特性和约束,从而提高了服务的可扩展性和模块化,进而优化了整体的 CI/CD(持续集成/持续部署)周期。有时人们会说,微服务是做得好的 SOA。此外,正如我们将在下一节中看到的,微服务与第七章中描述的 DDD(领域驱动设计)方法紧密相连,即《理解软件解决方案中的不同领域》。
总结来说,微服务架构是一种最大化独立性和细粒度扩展的 SOA。现在我们已经阐明了微服务独立性和细粒度扩展的所有优势,以及独立的本质,我们现在可以查看微服务设计原则。
微服务设计原则
在本节中,你将了解微服务的基本设计原则。这些原则是设计每个微服务的代码和架构,以及设计整个应用程序架构的基础。
让我们从由独立性约束产生的原则开始。我们将分别在每个子节中讨论它们。
设计选择的独立性
一个基本的设计原则是“设计选择的独立性”,可以表述如下:
每个微服务的设计不应依赖于其他微服务实现中做出的设计选择。
这一原则使得每个微服务的 CI/CD 周期完全独立,并为我们提供了更多关于如何实现每个微服务的科技选择。这样,我们可以选择最佳的技术来实现每个微服务。
这一原则的另一个后果是,不同的微服务不能连接到相同的共享存储(数据库或文件系统),因为共享相同的存储也意味着共享所有决定存储子系统(数据库表设计、数据库引擎等)结构的设计选择。因此,要么微服务有自己的数据存储,要么它根本不存储任何数据,并与负责处理存储的其他微服务进行通信。
专用数据存储可以通过在微服务边界内物理包含数据库服务或使用微服务具有独家访问权限的外部数据库来实现。这两种设计选择都是可接受的。然而,通常采用外部数据库,因为出于性能原因,数据库引擎在专用硬件上以及具有针对其存储功能优化的操作系统和硬件特性上运行得更好。
通常,设计选择的独立性通过区分逻辑和物理微服务以较轻的形式被解释。更具体地说,逻辑微服务是将应用程序拆分为逻辑独立模块的结果。如果应用程序使用领域驱动设计(DDD)方法设计,则逻辑微服务对应于 DDD 边界上下文,我们在第七章“理解软件解决方案中的不同领域”中详细讨论了这一点。
反过来,每个逻辑微服务可能被拆分为各种物理微服务,这些微服务使用相同的数据存储,但独立负载均衡以实现更好的负载均衡。
例如,在案例研究中,旅行支付由第二十一章“案例研究”中“理解 WWTravelClub 应用程序的领域”部分中描述的支付边界上下文处理,这产生了一个独特的逻辑微服务。然而,其实际实现需要两个主要子模块:
-
一个客户信用卡验证和授权模块,负责处理所有信用卡验证
-
一个用户信用管理模块,处理用户已经购买的信用、平台中已经加载的卡信息以及新的信用卡信息加载
由于信用卡验证和授权的过程可能非常耗时,因此将上述两个子模块作为独立的物理微服务实现是方便的,这样它们就可以分别进行负载均衡。
独立于部署环境
在负载均衡过程中,微服务可以从非常繁忙的硬件节点移动到更空闲的节点。然而,每个微服务对目标硬件节点上其他软件/文件的依赖性限制了可能的节点。
因此,我们减少微服务依赖项越多,我们就越有自由将它们从繁忙的节点移动到空闲的节点,实现更好的负载均衡,并利用可用的硬件节点。
这是微服务通常被容器化并使用 Docker 的原因。容器将在本章的“容器和 Docker”小节中更详细地讨论,但基本上,容器化是一种技术,允许每个微服务携带其依赖项,以便它可以在任何地方运行。然而,这并不是必须的,因为在某些应用程序中,可能验证所有微服务的所有依赖项要求都可以很容易地由所有可用的节点满足。
当我们探索微服务在其容器化环境中的操作时,另一个关键架构原则开始发挥作用——松耦合的概念。
松耦合
每个微服务都必须与其他所有微服务松散耦合。这个原则有两个方面。一方面,这意味着根据面向对象编程原则,每个微服务暴露的接口不应过于具体,而应尽可能通用。然而,这也意味着微服务之间的通信必须最小化,以降低通信成本,因为微服务不共享相同的地址空间,并且运行在不同的硬件节点上。
例如,假设我们正在使用微服务架构实现一个分布式网络视频游戏。每个微服务可能负责不同的功能,如碰撞、可见性、用户输入处理等。一些模块,如碰撞和可见性模块,必须知道整个游戏状态,例如用户头像的位置、每个头像的状态,以及游戏中每个反应对象的状态(例如障碍物、由头像射出的子弹等)。因此,要么所有对整个游戏状态有硬依赖的模块都合并成一个唯一的微服务,要么我们必须找到一种高效的方法,通过仅几次消息交换在它们之间共享整个游戏状态。
这两种选择都有优点和缺点,并且实际上被现实世界的视频游戏所采用。较少的消息可能会引起暂时的不一致,但将太多模块合并成一个唯一的微服务可能会影响整体游戏性能,使得游戏对用户来说可能显得“太慢”。
这种最小化服务间通信的概念自然引导我们考虑另一个方面:在微服务架构中避免链式请求/响应。
无链式请求/响应
当一个请求到达微服务时,它不能导致对其他微服务的嵌套请求/响应链,因为类似的链会导致无法接受的反应时间。
例如,假设微服务 A 向微服务 B 发起请求并等待 B 的回答,然后 B 再对 C 做同样的事情,C 再对 D 做同样的事情,以此类推。结果,A 在整个请求首先传播到 B,然后到 C,再到 D 的过程中都处于阻塞状态,等待其回答。然后,回答从 D 传播回 C,再从 C 传播回 B,最后到达 A。也就是说,四个请求传播时间总和等于其他四个回答传播时间,得到 A 的整体等待时间。这样,用户等待从应用程序获得回答的时间可能会很容易变得无法接受。
如果所有微服务的私有数据模型在每次变化时都与推送事件同步,则可以避免链式请求/响应。换句话说,一旦微服务处理的数据发生变化,这些变化就会发送到所有可能需要它们的微服务,以便它们可以服务其请求。这样,每个微服务都可以在其私有数据存储中拥有它服务所有传入请求所需的所有数据,无需请求其他微服务提供它所缺少的数据。
图 11.1显示了更新是如何在一旦产生就发送到所有感兴趣的微服务的,以及每个微服务是如何在本地数据库中组合所有接收到的更新的。这样,每个查询微服务都有它需要在其本地数据库中回答查询的所有数据。
图 11.1:推送事件
总结来说,每个微服务必须包含它服务传入请求所需的所有数据,并确保快速响应。为了保持其数据模型最新并准备好处理传入请求,微服务必须在数据变化发生时立即通知其数据变化。这些数据变化应通过异步消息进行通信,因为同步嵌套消息会导致不可接受的性能,因为它们会阻塞调用树中所有涉及的线程,直到返回结果。
值得指出的是,设计选择独立性原则实质上是 DDD 的边界上下文原则,我们在第七章“理解软件解决方案中的不同领域”中详细讨论了这一点。在本章中,我们看到了,通常,完整的 DDD 方法对每个微服务的更新子系统是有用的。
一般而言,所有根据边界上下文原则开发的系统都更适合使用微服务架构来实现,这并非易事。事实上,一旦一个系统被分解为几个完全独立且松散耦合的部分,由于不同的流量和不同的资源需求,这些不同的部分很可能需要独立扩展。
在上述约束条件下,我们还必须添加一些构建可重用 SOA 的最佳实践。关于这些最佳实践的更多细节将在第十五章“使用.NET 应用服务导向架构”中给出,但如今,大多数 SOA 最佳实践都由用于实现 Web 服务的工具和框架自动执行。
精细粒度扩展是微服务架构的关键方面,涉及多个关键的软件和基础设施要求:
-
首先,微服务必须足够小,以便隔离定义良好的功能。
-
我们还需要一个复杂的基础设施,负责自动实例化微服务和在不同硬件计算资源上分配实例,通常称为节点。
-
相同的基础设施必须负责扩展微服务并在可用节点上对它们进行负载均衡。
这些结构将在本章的需要哪些工具来管理微服务?部分中介绍,并在第二十章Kubernetes中详细讨论。
此外,对于通过异步通信进行通信的分布式微服务的细粒度扩展,需要每个微服务都具有弹性。实际上,指向特定微服务实例的通信可能会因为硬件故障或简单的原因(例如,在负载均衡操作期间目标实例被杀死或移动到另一个节点)而失败。
可以通过指数重试克服临时故障。这就是在每个失败之后,我们都会以指数级增加的延迟重试相同的操作,直到达到最大尝试次数。例如,首先,我们会在 10 毫秒后重试,如果这次重试操作导致失败,那么会在 20 毫秒后进行新的尝试,然后是 40 毫秒,以此类推。
另一方面,长期失败往往会导致重试操作的爆炸性增长,这可能会以类似于拒绝服务攻击的方式耗尽所有系统资源。因此,通常,指数重试会与熔断策略一起使用:在给定数量的失败之后,假设发生了长期失败,并通过立即返回失败而不尝试通信操作来防止在给定时间内访问资源。
同样重要的是,由于故障或请求峰值导致的某些子系统的拥塞不应传播到其他系统部分,以防止整体系统拥塞。隔离舱隔离通过以下方式防止拥塞传播:
-
只允许有最大数量的类似同时出站请求;比如说,10 个。这类似于对线程创建设置上限。
-
超过先前界限的请求将被排队。
-
如果达到最大队列长度,任何进一步的请求都会引发异常以终止它们。
本章的弹性任务执行子节中描述了指数重试、熔断和隔离舱隔离的.NET 实际实现。
重试策略可能会导致相同的消息被接收和处理多次,因为发送者没有收到消息已被接收的确认,或者简单地因为操作超时而接收者实际上已经接收了消息。解决这个问题的唯一可能方法是设计所有消息,使它们是幂等的——也就是说,以这种方式设计消息,即多次处理相同的消息与处理一次的效果相同。
将数据库表字段更新为某个值,例如,这是一个幂等操作,因为重复一次或两次会产生完全相同的效果。然而,增加一个十进制字段并不是幂等操作。微服务设计者应该努力设计尽可能多的幂等消息的整体应用程序。
幂等消息也是一种消息,如果处理两次,不会引起故障。例如,修改旅行价格的消息是幂等的,因为我们再次处理它时,只是将价格设置回之前的价格。然而,旨在添加新的旅行预订的消息不是幂等的,因为我们处理两次时,会添加两个旅行预订而不是一个。
剩余的非幂等消息必须以下述方式或使用其他类似技术转换为幂等:
-
为每个消息附加时间和一些唯一标识符,以唯一标识每个消息。
-
将所有已接收的消息存储在一个字典中,该字典按前一点提到的消息的唯一标识符进行索引。
-
拒绝旧消息。
-
当收到可能重复的消息时,验证它是否包含在字典中。如果是,那么它已经被处理,因此拒绝它。
-
由于拒绝旧消息,它们可以定期从字典中删除,以防止其指数级增长。
在第十四章,使用.NET 实现微服务中,我们将实际应用这项技术,并更详细地讨论通信和协调问题。
值得指出的是,一些消息代理,如 Azure Service Bus,提供了实现之前描述的技术的基础设施。然而,接收者必须始终能够识别重复消息,因为由于确认接收的超时,消息可能会被重新发送。Azure Service Bus 在.NET 通信设施小节中讨论。
在下一小节中,我们将讨论基于 Docker 的微服务容器化。
容器和 Docker
我们已经讨论了拥有不依赖于其运行环境的微服务的优势;微服务可以在繁忙的节点和空闲节点之间移动,不受限制,从而实现更好的负载均衡,进而更好地利用可用硬件。
然而,如果我们需要将遗留软件与较新的模块混合,为了使用每个模块实现的最佳堆栈,我们需要混合几个开发堆栈,等等,我们面临的问题是各种微服务有不同的硬件/软件先决条件。在这些情况下,可以通过在每个私有虚拟机上部署每个微服务及其所有依赖项来恢复每个微服务对其托管环境的独立性。
然而,启动带有其私有操作系统副本的虚拟机需要花费大量时间,而微服务必须快速启动和停止以减少负载均衡和故障恢复成本。实际上,新的微服务可能被启动是为了替换故障的微服务,或者因为它们从一个硬件节点移动到另一个节点以执行负载均衡。此外,将整个操作系统的副本添加到每个微服务实例中将会造成过度的开销。
幸运的是,微服务可以依赖一种更轻量级的技术:容器。容器提供了一种轻量级、高效的虚拟化形式。与传统虚拟机不同,虚拟机虚拟化整个机器,包括操作系统,而容器在操作系统文件系统级别进行虚拟化,位于宿主操作系统内核之上。它们使用宿主机的操作系统(内核、DLLs 和驱动程序)并使用操作系统的原生功能来隔离进程和资源,为它们运行的镜像创建一个隔离的环境。
因此,容器与特定的操作系统相关联,但它们不会遭受在每个容器实例中复制和启动整个操作系统的开销。
在每台主机上,容器由一个运行时处理,负责从镜像创建它们并为每个容器创建一个隔离的环境。最受欢迎的容器镜像格式是 Docker,它是容器镜像的事实标准。
镜像包含创建每个容器所需的文件,并指定哪些容器资源,如通信端口,需要暴露在容器外部。然而,它们不需要显式包含所有相关文件,因为它们可以是分层的。这样,每个镜像都是通过在另一个现有镜像之上添加新文件和配置信息来构建的,该现有镜像是从新定义的镜像内部引用的。
例如,如果你想将.NET 应用程序作为 Docker 镜像部署,只需将你的软件和文件添加到你的 Docker 镜像中,然后引用一个已经存在的.NET Docker 镜像即可。
为了便于引用镜像,镜像被分组到注册表中,这些注册表可以是公共的或私有的。它们类似于 NuGet 或 npm 注册表。Docker 提供了一个公共注册表(hub.docker.com/_/registry
),在那里你可以找到大多数你可能需要在自己的镜像中引用的公共镜像。然而,每个公司都可以定义私有注册表。例如,微软提供了 Azure Container Registry,在那里你可以定义你的私有容器注册表服务:azure.microsoft.com/en-us/services/container-registry/
。在那里,你还可以找到大多数你可能需要在代码中引用的.NET 相关镜像。
在实例化每个容器之前,Docker 运行时必须解决所有递归引用。由于 Docker 运行时有一个缓存,其中存储了与每个输入镜像相对应的完整组装的镜像,并且已经处理过,因此这项繁琐的工作不是每次创建新容器时都执行。
由于每个应用程序通常由多个模块组成,这些模块需要在不同的容器中运行,因此一个名为Docker Compose的工具还允许使用称为组合文件的.yml
文件,这些文件指定以下信息:
-
部署哪些镜像。
-
每个镜像暴露的内部资源必须映射到宿主机的物理资源。例如,Docker 镜像暴露的通信端口必须映射到物理机的端口。
我们将在本章的“.NET 如何处理微服务?”部分中分析 Docker 镜像和.yml
文件。
Docker 运行时在单台机器上处理镜像和容器,但通常,容器化的微服务是在由多台机器组成的集群上部署和负载均衡的。集群由称为编排器的软件组件来处理。编排器将在本章的“需要哪些工具来管理微服务?”部分中介绍,并在第二十章“Kubernetes”中详细描述。
现在我们已经了解了微服务是什么,它们可以解决哪些问题,以及它们的基本设计原则,我们准备分析在系统架构中何时以及如何使用它们。下一节将分析我们应该何时使用它们。
微服务何时有帮助?
这个问题的答案需要我们理解微服务在现代软件架构中扮演的角色。我们将在以下两个子节中探讨这一点:
-
层次架构和微服务
-
在什么情况下考虑微服务架构是有价值的?
让我们从对层次架构和微服务的详细分析开始。
层次架构和微服务
如在第七章“理解软件解决方案中的不同领域”中讨论的那样,企业系统通常组织在逻辑独立的层中。最外层是与用户交互的层,称为表示层(在洋葱架构中,最外层还包含驱动程序和测试套件),而最内层(洋葱架构中的最内层)负责处理应用程序的永久数据,称为数据层(洋葱架构中的领域层)。请求起源于表示层,穿过所有层直到达到数据层(然后返回,反向穿越所有层直到再次达到最外层)。
在经典分层架构的情况下(洋葱架构与第七章理解软件解决方案中的不同领域中讨论的有所不同),每一层从前一层数据,处理它,并将其传递到下一层。然后,它从下一层接收结果并将其发送回前一层。此外,抛出的异常不能跨越层——每一层都必须负责拦截所有异常,或者以某种方式解决它们,或者将它们转换成以前一层的语言表达的其他异常。分层架构确保了每一层的功能完全独立于所有其他层的功能。
例如,我们可以更改对象关系映射(ORM)软件,该软件作为数据库的接口,而不会影响数据层之上的所有层(ORM 软件在第十三章,使用 C#与数据交互 – Entity Framework Core中讨论)。同样,我们可以完全更改用户界面(即表示层),而不会影响系统的其余部分。
此外,每一层实现不同类型的系统规范。数据层负责系统必须记住的内容,表示层负责系统与用户交互的协议,而中间的所有层实现领域规则,这些规则指定了数据必须如何处理(例如,如何计算员工的工资)。通常,数据和表示层仅由一个领域规则层隔开,称为业务层或应用层。
图 11.2:经典架构的层
每一层说着不同的语言:数据层说着实体之间关系的语言,业务层说着领域专家的语言,表示层说着用户的语言。因此,当数据和异常从一个层传递到另一个层时,它们必须被翻译成目标层的语言。
话虽如此,微服务如何适应分层架构?它们是否适合所有层的功能,或者只是某些层的功能?单个微服务能否跨越多个层?
最后一个问题最容易回答:是的!事实上,我们之前已经提到,微服务应该在其逻辑边界内存储它们所需的数据。因此,确实存在跨越业务和数据层的微服务。
然而,由于我们提到每个逻辑微服务可以由几个物理微服务实现,纯粹是为了负载均衡的原因,一个微服务可能负责封装另一个微服务使用的、可能仍然局限于数据层的数据。
此外,我们还提到,虽然每个微服务都必须有自己的专用存储,但它也可以使用外部存储引擎。下面的图示展示了这一点:
图 11.3:外部或内部存储
值得注意的是,存储引擎本身可以作为一个物理微服务集实现,这些微服务与任何逻辑微服务无关,因为它们可能被认为是基础设施的一部分。
这种情况适用于基于分布式 Redis 内存缓存的存储引擎,其中我们利用基础设施提供的微服务设施来实现可扩展的单主/多只读副本,或者复杂的多主/多只读副本,这些副本在内存存储中分布。Redis 和 Redis Cloud 服务在第十二章的选择云中的数据存储部分中描述,而多主/多只读副本架构在第二十章的Kubernetes中描述。下面的图示展示了基于微服务的多主/多只读副本存储引擎的工作原理。
图 11.4:多主/多只读副本存储引擎
每个主节点都与其关联的只读副本相关联。存储更新只需传递给那些将数据复制到所有关联的只读副本的主节点。
每个主节点负责存储空间的一部分,例如,所有以“A”开头的所有产品,等等。这样,负载在所有主节点之间得到平衡。
因此,我们可能有业务层微服务、数据层微服务和跨越这两层的微服务。那么,展示层呢?
展示层
如果它在服务器端实现,这一层也可以适应微服务架构——也就是说,如果与用户交互的整个图形都是在服务器端构建的,而不是在用户客户端机器(移动设备、桌面等)上。
当有直接与用户交互的微服务时,我们称之为展示层的服务器端实现,因为 HTML 和/或用户界面的所有元素都是由前端创建的,前端将响应发送给用户。
这类微服务被称为前端微服务,而那些不与用户交互执行后台工作的微服务被称为工作微服务。下面的图示总结了前端/工作组织结构。
图 11.5:前端和工作微服务
当 HTML 和/或用户界面的所有元素在用户机器上生成时,我们称之为表示层的客户端实现。所谓的单页应用程序和移动应用程序在客户端机器上运行表示层,并通过由专用微服务公开的通信接口与应用程序交互。这些专用微服务与 图 11.5 中描述的前端微服务完全类似,被称为 API 网关,以强调它们公开公共 API 以连接客户端设备与整个微服务基础设施的作用。此外,API 网关与工作微服务的交互方式与前端微服务完全类似。
单页应用程序和移动/桌面客户端应用程序在 第十九章,客户端框架:Blazor 中讨论。
在微服务架构中,当表示层是一个网站时,它可以由一组多个微服务实现。然而,如果它需要重型网络服务器和/或重型框架,容器化它们可能不方便。这个决定还必须考虑容器化网络服务器时可能发生的性能损失以及在网络服务器和系统其余部分之间可能需要硬件防火墙的可能性。
ASP.NET Core 是一个轻量级框架,它在 Kestrel 网络服务器上运行,因此可以高效地容器化,并直接用于工作微服务。在 第十四章,使用 .NET 实现微服务 中详细描述了在实现工作微服务中使用 ASP.NET Core 的方法。
相反,前端和/或高流量网站有更紧迫的安全性和负载均衡需求,这些需求可以通过功能齐全的网络服务器得到满足。因此,基于微服务的架构通常提供专门组件来处理与外部世界的接口。例如,在 第二十章,Kubernetes 中,我们将看到在像 Kubernetes 集群这样的微服务专用基础设施中,这个角色由所谓的 入口 扮演。这些是与微服务基础设施接口的完整功能网络服务器。由于与微服务基础设施的集成,整个网络服务器流量自动路由到感兴趣的微服务。更多细节将在 第二十章,Kubernetes 中给出。下面的图表显示了入口的作用。
图 11.6:基于负载均衡网络服务器的入口
单体网站可以很容易地分解成负载均衡的小型子站点,而不需要特定的微服务技术,但微服务架构可以将微服务的所有优势带入单个 HTML 页面的构建中。更具体地说,不同的微服务可能负责每个 HTML 页面的不同区域。在构建应用页面 HTML 以及通常在构建任何类型的用户界面时协作的微服务被称为微前端。
当 HTML 在服务器端创建时,各种微前端创建 HTML 代码块,这些代码块可以在服务器端或直接在浏览器中组合。
相反,如果 HTML 直接在客户端创建,每个微前端都会向客户端提供不同的代码块。这些代码块在客户端机器上运行,每个都负责不同的页面/页面区域。我们将在第十八章,使用 ASP.NET Core 实现前端微服务中更多地讨论这种类型的微前端。
现在我们已经明确了系统哪些部分可以从采用微服务中受益,我们准备好陈述在决定如何采用它们时的规则。
在什么情况下值得考虑使用微服务架构?
微服务可以提高业务和数据层的实现,但它们的采用有一些成本:
-
将实例分配到节点并对其进行扩展在云费用或内部基础设施和许可证方面会产生成本。
-
将一个独特的流程拆分成更小的通信流程会增加通信成本和硬件需求,尤其是如果微服务是容器化的。
-
为微服务设计和测试软件需要更多时间,并增加工程成本,包括时间和复杂性。特别是,使微服务具有弹性并确保它们能够适当地处理所有可能的故障,以及通过集成测试验证这些功能,可以将开发时间增加一个数量级以上(即大约 10 倍)。
因此,微服务何时值得使用它们的成本?是否有必须作为微服务实现的函数?
对于第二个问题的粗略回答是:当应用在流量和/或软件复杂性方面足够大时。实际上,随着应用复杂性的增加和流量的增加,我们建议我们承担与扩展它相关的成本,因为这允许进行更多的扩展优化,并且在开发团队方面有更好的处理。我们为此付出的代价很快就会超过采用微服务的成本。
因此,如果细粒度扩展对我们应用来说是有意义的,并且如果我们可以估计细粒度扩展和开发带来的节省,我们就可以轻松计算出整体应用吞吐量限制,这使得采用微服务变得方便。
微服务成本也可以通过我们产品/服务的市场价值增加来证明。由于微服务架构允许我们使用针对其使用进行优化的技术来实现每个微服务,因此我们软件中增加的质量可能可以证明所有或部分微服务成本是合理的。
然而,扩展和技术优化并不是唯一需要考虑的参数。有时,我们被迫采用微服务架构,而无法进行详细的成本分析。
如果负责整个系统 CI/CD 的团队规模变得过大,这个大团队的组织和协调将导致困难和低效率。在这种情况下,转向一种将整个 CI/CD 周期分解为独立部分,这些部分可以由更小的团队来处理的架构是可取的。
此外,由于这些开发成本只有在高请求量的情况下才是合理的,我们可能正在处理由不同团队开发的独立模块的高流量。因此,进行扩展优化和减少开发团队之间的交互需求使得采用微服务架构变得非常方便。
从这个角度来看,如果我们系统和开发团队规模变得过大,有必要将开发团队拆分成更小的团队,每个团队负责一个高效的边界上下文子系统。在类似的情况下,微服务架构可能是唯一可行的选择。
迫使采用微服务架构的另一种情况是将基于不同技术的较新子部分与旧子系统集成,因为容器化微服务是实现旧系统与新子部分之间有效交互的唯一方式,以便逐步用较新的子部分替换旧子部分。同样,如果我们的团队由具有不同开发栈经验的开发者组成,基于容器化微服务的架构可能成为一项必需。
在下一节中,我们将分析可用于实现基于.NET 的微服务的构建块和工具。
.NET 是如何处理微服务的?
新的.NET,它是由.NET Core 演变而来的,被构想为一个轻量级且快速的跨平台框架,足以实现高效的微服务。特别是,ASP.NET Core 是实现与微服务通信的文本 REST 和二进制 gRPC API 的理想工具,因为它可以与轻量级 Web 服务器如 Kestrel 高效运行,并且它本身也是轻量级和模块化的。
整个 .NET 堆栈是在以微服务作为战略部署平台的前提下演进的,并提供了构建高效且轻量级的 HTTP 和 gRPC 通信的设施和包,以确保服务的弹性并处理长时间运行的任务。以下小节将描述一些我们可以用来实现基于 .NET 的微服务架构的不同工具或解决方案。
.NET 通信设施
微服务需要两种类型的通信通道。
第一个通信通道接收外部请求,无论是直接还是通过 API 网关。由于可用的 Web 服务标准和工具,HTTP 是外部通信的常用协议。.NET 的主要 HTTP/gRPC 通信设施是 ASP.NET Core,因为它是一个轻量级的 HTTP/gRPC 框架,这使得它非常适合在小型微服务中实现 Web API。我们将在第十五章,使用 .NET 应用服务导向架构中详细描述 ASP.NET REST API 应用,我们将在第十四章,使用 .NET 实现微服务中描述 gRPC 服务。.NET 还提供了一种高效且模块化的 HTTP 客户端解决方案,能够池化和重用重连接对象。此外,HttpClient
类将在第十五章中更详细地描述。
第二个通道是一种不同的通信通道,用于向其他微服务推送更新。实际上,我们之前已经提到,由于对其他微服务的复杂阻塞调用树会增加请求延迟到不可接受的水平,因此不能通过正在进行的请求来触发微服务内部的通信。因此,在它们被使用之前不应立即请求更新,而应该在状态发生变化时推送。理想情况下,这种通信应该是异步的,以实现可接受的性能。实际上,同步调用会在等待结果时阻塞发送方,从而增加每个微服务的空闲时间。然而,如果通信足够快(低通信延迟和高带宽),仅将请求放入处理队列并返回成功通信的确认而不是最终结果的同步通信是可以接受的。发布/订阅通信会更受欢迎,因为在这种情况下,发送方和接收方不需要相互了解,从而增加了微服务的独立性。实际上,所有对某种特定通信类型感兴趣的接收者只需注册以接收特定的事件,而发送方只需发布这些事件。所有连接的配置都是由一个负责排队事件并将它们分发给所有订阅者的服务来完成的。发布/订阅模式在第六章,设计模式和 .NET 8 实现中进行了描述,以及其他有用的模式。
虽然 .NET 没有直接提供可能有助于异步通信的工具,或者实现发布/订阅通信的客户端/服务器工具,但 Azure 提供了一个类似的服务,即 Azure 服务总线 (docs.microsoft.com/en-us/azure/service-bus-messaging/
)。Azure 服务总线通过 Azure 服务总线 队列 处理排队异步通信,并通过 Azure 服务总线 主题 处理发布/订阅通信。
一旦你在 Azure 门户上配置了 Azure 服务总线,你就可以通过包含在 Microsoft.Azure.ServiceBus
NuGet 包中的客户端连接到它,以便发送消息/事件并通过接收消息/事件。
Azure 服务总线有两种通信类型:基于队列和基于主题。在基于队列的通信中,发送者放入队列的每条消息都由第一个从队列中拉取它的接收者从队列中移除。另一方面,基于主题的通信是发布/订阅模式的实现。每个主题都有多个订阅,并且可以从中每个主题订阅中拉取发送到主题的每条消息的不同副本。
设计流程如下:
-
定义一个 Azure 服务总线私有命名空间。
-
获取由 Azure 门户创建的根连接字符串,或定义具有较少权限的新连接字符串。
-
定义队列和/或主题,发送者将用二进制格式发送他们的消息。
-
对于每个主题,定义所有必需的订阅名称。
-
在基于队列的通信情况下,发送者将消息发送到队列,接收者从同一个队列中拉取消息。每条消息都交付给一个接收者。也就是说,一旦接收者获得对队列的访问权限,它就会读取并移除一条或几条消息。
-
在基于主题的通信情况下,每个发送者将消息发送到主题,而每个接收者从与其相关联的主题的私有订阅中拉取消息。
此外,还有其他商业和免费的开放源代码替代方案,如 NServiceBus (particular.net/nservicebus
)、MassTransit (masstransit-project.com/
) 和 Brighter (www.goparamore.io/
)。它们通过高级功能增强了现有的代理(如 Azure 服务总线本身)。
此外,还有一个完全独立的选项,可以在本地平台使用:RabbitMQ。它是免费和开源的,可以安装在本地、虚拟机或 Docker 容器中。然后,你可以通过包含在 RabbitMQ.Client
NuGet 包中的客户端连接到它。
RabbitMQ 的功能与 Azure Service Bus 提供的功能类似,但您必须注意更多的实现细节,如序列化、可靠消息和错误处理,而 Azure Service Bus 则负责所有底层操作并提供一个更简单的接口。然而,有一些客户端在 RabbitMQ 之上构建了更强大的抽象,例如 EasyNetQ。Azure Service Bus 和 RabbitMQ 使用的基于发布/订阅的通信模式在第六章,设计模式和.NET 8 实现中进行了描述。RabbitMQ 将在第十四章,使用.NET 实现微服务中更详细地介绍。
弹性任务执行
使用名为 Polly 的.NET 库可以轻松实现弹性通信和弹性任务执行,该库的项目是.NET 基金会的成员。Polly 可以通过Polly
NuGet 包获得。
在 Polly 中,您定义策略,然后在策略的上下文中执行任务,如下所示:
var myPolicy = Policy
.Handle<HttpRequestException>()
.Or<OperationCanceledException>()
.RetryAsync(3);
....
....
await myPolicy.ExecuteAsync(()=>{
//your code here
});
每项政策的第一个部分指定了必须处理的异常。然后,您指定在捕获到这些异常之一时应该执行的操作。在上面的代码中,如果报告失败是由HttpRequestException
异常或OperationCanceledException
异常引起的,则Execute
方法会重试最多三次。
以下是实现指数重试策略的示例:
var retryPolicy= Policy
...
//Exceptions to handle here
.WaitAndRetryAsync(6,
retryAttempt => TimeSpan.FromSeconds(Math.Pow(2,
retryAttempt)));
WaitAndRetryAsync
的第一个参数指定在失败事件中最多执行六次重试。作为第二个参数传递的 lambda 函数指定在下次尝试之前等待的时间。在这个特定的例子中,这个时间随着尝试次数的指数增长,以 2 的幂次(第一次重试为 2 秒,第二次重试为 4 秒,依此类推)。
以下是一个简单的断路器策略:
var breakerPolicy =Policy
.Handle<SomeExceptionType>()
.CircuitBreakerAsync (6, TimeSpan.FromMinutes(1));
在六次失败后,由于返回了异常,任务将无法执行一分钟。
以下是实现隔离舱隔离策略(有关更多信息,请参阅微服务设计原则部分):
Policy
.BulkheadAsync(10, 15)
Execute
方法允许最多 10 个并行执行。进一步的任务将插入到执行队列中。这个队列的容量为 15 个任务。如果队列限制被超过,则会抛出异常。
为了使隔离舱隔离策略正常工作,以及在一般情况下,为了使每个策略正常工作,必须通过相同的策略实例触发任务执行;否则,Polly 无法计算特定任务的活动执行次数。
可以使用Wrap
方法组合策略:
var combinedPolicy = Policy
.Wrap(retryPolicy, breakerPolicy);
Polly 还提供了一些其他选项,例如针对返回特定类型的任务的通用方法、超时策略、任务结果缓存、定义自定义策略的能力等等。此外,还可以将 Polly 配置为任何 ASP.NET Core 和.NET 应用程序依赖注入部分中的HttpClient
定义的一部分。这样,定义健壮的客户端就变得相当直接了。
Polly 的官方文档可以在其 GitHub 仓库中找到:github.com/App-vNext/Polly
。
Polly 的实际用法在第二十一章的案例研究部分具有 ASP.NET Core 的 Worker 微服务中解释。
工具如 Polly 提供的弹性和健壮性是微服务架构的关键组成部分,尤其是在管理复杂任务和流程时。
这使我们来到了微服务的另一个基本方面:通用宿主的实现。
使用通用宿主
每个微服务可能需要运行多个独立的线程,每个线程对收到的请求执行不同的操作。这些线程需要多种资源,例如数据库连接、通信通道、执行复杂操作的专业模块等等。此外,所有处理线程必须在微服务启动时得到适当的初始化,并在微服务由于负载均衡或错误而停止时优雅地停止。
所有这些需求促使.NET 团队构思并实现了托管服务和宿主。宿主为运行多个任务(称为托管服务)提供了一个适当的环境,并为它们提供资源、通用设置以及优雅的启动/停止。
网络宿主的概念最初是为了实现 ASP.NET Core 网络框架而构思的,但从.NET Core 2.1 版本开始,宿主概念被扩展到了所有.NET 应用程序。
在撰写本书时,任何 ASP.NET Core、Blazor 和 Worker Service 项目都会自动为您创建一个Host
。测试.NET Host 功能的简单方法是在项目中选择服务 -> 工作服务项目。
图 11.7:在 Visual Studio 中创建 Worker Service 项目
与Host
概念相关的所有功能都包含在Microsoft.Extensions.Hosting
NuGet 包中。
Program.cs
包含一些使用流畅接口配置宿主的框架代码,从Host
类的CreateDefaultBuilder
方法开始。此配置的最终步骤是调用Build
方法,该方法将所有我们提供的配置信息组装成实际的宿主:
...
var myHost=Host.CreateDefaultBuilder(args)
.ConfigureServices(services =>
{
//some configuration
...
})
.Build();
...
宿主配置包括定义通用资源、定义文件的默认文件夹、从多个来源(JSON 文件、环境变量以及传递给应用程序的任何参数)加载配置参数,以及声明所有托管服务。
值得指出的是,ASP.NET Core 和 Blazor 项目使用的方法包括对 Host
的预配置,其中包括之前列出的几个任务。
然后,启动宿主,这将导致所有托管服务启动:
await host.RunAsync();
程序会阻塞在先前的指令上,直到宿主关闭。当操作系统终止进程时,宿主会自动关闭。然而,宿主也可以通过托管服务之一或通过外部调用 await host.StopAsync(timeout)
来手动关闭。在这里,timeout
是一个定义等待托管服务优雅停止的最大时间的时间跨度。在此之后,如果托管服务尚未终止,则所有托管服务都将被中止。我们将在本小节稍后解释托管服务如何关闭宿主。
当线程包含 host.RunAsync
并从另一个线程而不是 Program.cs
中启动时,可以通过传递给 RunAsync
的 cancellationToken
来表示宿主线程正在关闭:
await host.RunAsync(cancellationToken)
当另一个线程通过 cancellationToken
进入取消状态时,这种关闭方式立即被触发。
默认情况下,宿主有 5 秒的关闭超时;也就是说,一旦请求关闭,它将等待 5 秒后才退出。这个时间可以在 ConfigureServices
方法中更改,该方法用于声明 托管服务 和其他资源:
var myHost = Host.CreateDefaultBuilder(args)
.ConfigureServices(services =>
{
services.Configure<HostOptions>(option =>
{
option.ShutdownTimeout = System.TimeSpan.FromSeconds(10);
});
....
....
//further configuration
})
.Build();
然而,增加宿主超时不会增加协调器超时,所以如果宿主等待时间过长,整个微服务将被协调器杀死。
如果没有明确传递取消令牌给 Run
或 RunAsync
,则会自动生成一个取消令牌,并且当操作系统通知应用程序它将要杀死它时,该取消令牌会自动被触发。这个取消令牌被传递给所有托管服务,以给它们提供优雅停止的机会。
托管服务是 IHostedService
接口的实现,它只包含 StartAsync(cancellationToken)
和 StopAsync(cancellationToken)
这两个方法。
这两种方法都传递了一个 cancellationToken
。StartAsync
方法中的 cancellationToken
表示请求关闭。StartAsync
方法在执行启动宿主所需的所有操作时,会定期检查这个 cancellationToken
,如果它被触发,则宿主启动过程将被终止。另一方面,StopAsync
方法中的 cancellationToken
表示关闭超时已过期。
托管服务可以在用于定义宿主选项的相同 ConfigureServices
方法中声明,如下所示:
services.AddHostedService<MyHostedService>();
ConfigureServices
内部的大多数声明都需要添加以下命名空间:
using Microsoft.Extensions.DependencyInjection;
通常,IHostedService
接口不是直接实现的,但可以继承自BackgroundService
抽象类,该类公开了更容易实现的ExecuteAsync(CancellationToken)
方法,我们可以在其中放置服务的全部逻辑。通过传递cancellationToken
作为参数来发出关闭信号,这更容易处理。我们将在第十四章,使用.NET 实现微服务中更详细地查看IHostedService
的实现。
要允许托管服务关闭整个宿主,我们需要将其构造函数参数声明为IApplicationLifetime
接口:
public class MyHostedService: BackgroundService
{
private readonly IHostApplicationLifetime _applicationLifetime;
public MyHostedService(IHostApplicationLifetime applicationLifetime)
{
_applicationLifetime=applicationLifetime;
}
protected Task ExecuteAsync(CancellationToken token)
{
...
_applicationLifetime.StopApplication();
...
}
}
当托管服务被创建时,它会自动传递一个IHostApplicationLifetime
的实现,其StopApplication
方法将触发宿主关闭。这种实现是自动处理的,但我们可以声明自定义资源,其实例将被自动传递到所有声明它们为参数的宿主服务构造函数。因此,假设我们定义了一个这样的构造函数:
Public MyClass(MyResource x, IResourceInterface1 y)
{
...
}
定义前面构造函数所需资源的方法有几种:
services.AddTransient<MyResource>();
services.AddTransient<IResourceInterface1, MyResource1>();
services.AddSingleton<MyResource>();
services.AddSingleton<IResourceInterface1, MyResource1>();
当我们使用AddTransient
时,会创建一个不同的实例并将其传递到所有需要该类型实例的构造函数。另一方面,使用AddSingleton
时,会创建一个唯一的实例并将其传递到所有需要声明类型的构造函数。具有两个泛型类型重载的版本允许你传递一个接口及其实现该接口的类型。这样,构造函数需要接口,并且与该接口的具体实现解耦。
如果资源构造函数包含参数,它们将自动以递归方式使用在ConfigureServices
中声明的类型进行实例化。这种交互模式称为依赖注入(DI),已经在第六章,设计模式和.NET 8 实现中详细讨论过。
IHostBuilder
还有一个我们可以用来定义默认文件夹的方法——即用于解析所有.NET 方法中提到的所有相对路径的文件夹:
.UseContentRoot("c:\\<deault path>")
它还提供了我们可以用来添加日志目标的方法:
.ConfigureLogging((hostContext, configLogging) =>
{
configLogging.AddConsole();
configLogging.AddDebug();
})
之前的示例展示了基于控制台的日志源,但我们也可以使用足够的提供者将日志记录到 Azure 目标。进一步阅读部分包含了一些可以与已部署在 Azure 中的微服务一起工作的 Azure 日志提供者的链接。一旦你配置了日志,你就可以通过在它们的构造函数中添加一个ILogger<T>
参数来启用你的托管服务并记录自定义消息。ILogger<T>
有用于以多个严重级别记录消息的方法:跟踪(Trace)、调试(最低)、信息、警告、错误、关键和空(最高)。反过来,应用程序配置指定了实际输出日志消息所需的最低严重级别。所有通过严重性过滤器的消息都会同时发送到所有配置的目标。
类型 T
的唯一目的是通过其全名来分类消息。
开发者可以在配置文件中指定最小严重性级别。对于 T
的每种类型,我们可能有不同的严重性级别。
例如:
{
"Logging": {
"LogLevel": {
"Default": "Information",
"Microsoft.AspNetCore": "Warning"
}
}
}
在上述配置文件中,默认严重性级别是“Information
”,但所有名称以“Microsoft.AspNetCore
”开头的类型都具有“Warning
”严重性级别。
最后,IHostBuilder
有我们可以用来从各种来源读取配置参数的方法:
.ConfigureAppConfiguration(configHost =>
{
configHost.AddJsonFile("settings.json", optional: true);
configHost.AddEnvironmentVariables(prefix: "PREFIX_");
configHost.AddCommandLine(args);
})
将在 第十七章,展示 ASP.NET Core 中更详细地解释如何在应用程序内部使用配置流中定义的参数,该章节专门介绍 ASP.NET。
当我们从 ASP.NET Core 的具体性过渡到更广泛的应用程序部署和环境设置领域时,一个重要的工具就派上用场了——Visual Studio Docker 支持。
Visual Studio 对 Docker 的支持
Visual Studio 提供了创建、调试和部署 Docker 镜像的支持。Docker 部署需要我们在开发机上安装 Docker Desktop for Windows,这样我们才能运行 Docker 镜像。
下载链接可以在本章开头的 Technical requirements 部分找到。在我们开始任何开发活动之前,我们必须确保它已安装并正在运行(当 Docker 运行时正在运行时,你应该在窗口通知栏中看到 Docker 图标)。
将使用一个简单的 ASP.NET Core MVC 项目来描述 Docker 支持。让我们创建一个。为此,请按照以下步骤操作:
-
将项目命名为
MvcDockerTest
。 -
为了简单起见,如果尚未禁用,请禁用身份验证。
-
当你创建项目时,你会得到添加 Docker 支持的选项,但请不要勾选 Docker support 复选框。项目创建后,你可以测试如何将 Docker 支持添加到任何项目中。
-
一旦你的 ASP.NET MVC 应用程序已经搭建并运行,在 Solution Explorer 中右键单击其项目图标,选择 Add,然后选择 Container Orchestrator Support | Docker Compose。如果你安装了 WSL 和 Windows Containers,将出现一个选择 Linux 和 Windows 的对话框。如果没有安装 WSL 和 Windows Containers,则如果只安装了 WSL,将自动选择 Linux;如果只安装了 Windows Containers,则选择 Windows。
-
如果你安装了 WSL,请选择 Linux,因为当 WSL 可用时,它是 Docker 服务器默认使用的。
启用 Docker Compose 而不是仅启用 Docker 的优点是,你可以手动配置如何在开发机上运行镜像,以及如何通过编辑添加到解决方案中的 Docker Compose 文件将 Docker 镜像端口映射到外部端口。
如果你的 Docker 运行时已经正确安装并正在运行,你应该能够从 Visual Studio 运行 Docker 镜像。请尝试一下!
现在我们已经探讨了如何配置和运行 Docker 镜像,让我们更深入地了解这些镜像的结构和组成。理解由 Visual Studio 创建的 Docker 文件是掌握它如何编排这些镜像的创建和管理的关键。
分析 Docker 文件
让我们分析由 Visual Studio 创建的 Docker 文件。它是一系列创建镜像的步骤。每个步骤都通过From
指令(它是对已存在镜像的引用)的帮助,在现有镜像上添加一些其他内容。以下是第一步:
FROM mcr.microsoft.com/dotnet/aspnet:x.x AS base
WORKDIR /app
EXPOSE 80
EXPOSE 443
第一步使用了由 Microsoft 在 Docker 公共仓库中发布的mcr.microsoft.com/dotnet/aspnet:x.x
ASP.NET (Core)运行时版本(其中x.x
是在项目中选择的 ASP.NET (Core)版本)。
WORKDIR
命令在即将创建的镜像中创建一个目录。两个EXPOSE
命令声明了哪些端口将暴露在镜像外部并映射到实际托管机的端口。映射的端口在部署阶段决定,要么作为 Docker 命令的命令行参数,要么在 Docker Compose 文件中。在我们的例子中,有两个端口:一个用于 HTTP(80),另一个用于 HTTPS(443)。
这个中间镜像被 Docker 缓存,因为它不需要重新计算,因为它不依赖于我们编写的代码,而只依赖于所选的 ASP.NET (Core)运行时版本。
第二步生成一个不同的镜像,它不会用于部署。相反,它将用于创建将部署的应用程序特定的文件:
FROM mcr.microsoft.com/dotnet/core/sdk:x.x AS build
WORKDIR /src
COPY ["MvcDockerTest/MvcDockerTest.csproj", "MvcDockerTest/"]
RUN dotnet restore MvcDockerTest/MvcDockerTest.csproj
COPY . .
WORKDIR /src/MvcDockerTest
RUN dotnet build MvcDockerTest.csproj -c Release -o /app/build
FROM build AS publish
RUN dotnet publish MvcDockerTest.csproj -c Release -o /app/publish
这个步骤从 ASP.NET SDK 镜像开始,其中包含我们不添加到部署中的部分;这些部分是处理项目代码所需的。在build
镜像中创建了一个新的src
目录,并将其设置为当前镜像目录。然后,项目文件被复制到/src/MvcDockerTest
。
RUN
命令在镜像上执行操作系统命令。在这种情况下,它调用dotnet
运行时,请求它恢复之前复制的项目文件中引用的 NuGet 包。
然后,COPY..
命令将整个项目文件树复制到src
镜像目录。最后,将项目目录设置为当前目录,并请求dotnet
运行时以发布模式构建项目,并将所有输出文件复制到新的/app/build
目录。最后,在名为publish
的新镜像中执行dotnet publish
任务,将发布的二进制文件输出到/app/publish
。
最后一步从第一步中创建的镜像开始,该镜像包含 ASP.NET (Core)运行时,并添加了上一步中发布的所有文件:
FROM base AS final
WORKDIR /app
COPY --from=publish /app/publish .
ENTRYPOINT ["dotnet", "MvcDockerTest.dll"]
ENTRYPOINT
命令指定执行镜像所需的操作系统命令。它接受一个字符串数组。在我们的例子中,它接受 dotnet
命令及其第一个命令行参数——即我们需要执行的 DLL。处理完这些之后,现在让我们发布我们的这个小项目!
发布项目
如果我们右键单击我们的项目并点击 发布,我们会看到几个选项:
-
将镜像发布到现有的或新的 Web 应用程序(由 Visual Studio 自动创建)
-
发布到多个 Docker 仓库之一,包括 Azure 容器注册表中的私有仓库,如果它还不存在,可以在 Visual Studio 中创建
Docker Compose 支持允许您运行和发布一个多容器应用程序,并添加更多图像,例如一个可在任何地方使用的容器化数据库。
以下 Docker Compose 文件指示 Docker 服务器运行两个容器化的 ASP.NET 应用程序:
version: '3.4'
services:
mvcdockertest:
image: ${DOCKER_REGISTRY-}mvcdockertest
build:
context: .
dockerfile: MvcDockerTest/Dockerfile
mvcdockertest1:
image: ${DOCKER_REGISTRY-}mvcdockertest1
build:
context: .
dockerfile: MvcDockerTest1/Dockerfile
您可以通过将另一个名为 MvcDockerTest 1 的 ASP.NET Core MVC 应用程序添加到解决方案中,并启用其上的 docker-compose 来将另一个 ASP.NET Core MVC 应用程序添加到我们之前的 docker-compose 文件中。然而,您必须注意,新创建的项目文件夹被放置在与 MvcDockerTest 相同的解决方案文件夹中。
上述代码引用了现有的 Docker 文件。任何依赖于环境的信息都放置在 docker-compose.override.yml
文件中,当应用程序从 Visual Studio 启动时,该文件将与 docker-compose.yml
文件合并:
version: '3.4'
services:
mvcdockertest:
environment:
- ASPNETCORE_ENVIRONMENT=Development
- ASPNETCORE_URLS=https://+:443;http://+:80
ports:
- "80"
- "443"
volumes:
-${APPDATA}/Microsoft/UserSecrets:/root/.microsoft/usersecrets:ro
- ${APPDATA}/ASP.NET/Https:/root/.aspnet/https:ro
mvcdockertest1:
environment:
- ASPNETCORE_ENVIRONMENT=Development
- ASPNETCORE_URLS=https://+:443;http://+:80
ports:
- "80"
- "443"
volumes:
- ${APPDATA}/Microsoft/UserSecrets:/root/.microsoft/usersecrets:ro
- ${APPDATA}/ASP.NET/Https:/root/.aspnet/https:ro
对于每个镜像,文件指定了一些环境变量(这些变量将在应用程序启动时在镜像中定义),端口映射和一些主机文件。
主机中的文件直接映射到镜像中。每个声明包含主机中的路径,路径如何在镜像中映射,以及所需的访问权限。在我们的例子中,使用 volumes
将用于应用程序所有加密需求的应用程序机器密钥和 Visual Studio 使用的自签名 HTTPS 证书映射。
当您在 Visual Studio 中启动应用程序时,只会打开浏览器窗口并显示 MvcDockerTest 应用程序。然而,两个应用程序都被启动了,所以您只需要发现 MvcDockerTest1 在哪个端口上运行,并打开另一个浏览器窗口。您可以通过在 Visual Studio 的容器选项卡中点击 MvcDockerTest1 并查看其 HTTPS 主机端口(60072)来发现端口,如图下所示:
图 11.8:发现 MvcDockerTest1 主机端口
现在,假设我们想要添加一个容器化的 SQL Server 实例。我们需要像以下这样的指令,这些指令分布在 docker-compose.yml
和 docker-compose.override.yml
之间:
sql.data:
image: mcr.microsoft.com/mssql/server:2022-latest
environment:
- SA_PASSWORD=Pass@word
- ACCEPT_EULA=Y
- MSSQL_PID=Express
ports:
- "5433:1433"
在这里,前面的代码指定了 SQL Server 容器的属性,以及 SQL Server 配置和安装参数。更具体地说,前面的代码包含以下信息:
-
sql.data
是分配给容器的名称。 -
image
指定了从哪里获取镜像。在我们的例子中,镜像包含在公共 Docker 仓库中。 -
environment
指定了 SQL Server 所需的环境变量——即管理员密码、接受 SQL Server 许可证以及 SQL Server 版本。 -
如同往常,
ports
指定端口映射。 -
docker-compose.override.yml
用于在 Visual Studio 内运行镜像。
如果你需要为生产环境或测试环境指定参数,你可以添加额外的 docker-compose-xxx.override.yml
文件,例如 docker-compose-staging.override.yml
和 docker-compose-production.override.yml
,然后在目标环境中手动启动它们,如下面的代码所示:
docker-compose -f docker-compose.yml -f docker-compose-staging.override.yml up
然后,你可以使用以下代码销毁所有容器:
docker-compose -f docker-compose.yml -f docker-compose.staging.yml down
虽然 docker-compose
在处理节点集群方面功能有限,但它主要用于测试和开发环境。对于生产环境,需要更复杂的工具,称为编排器。生产环境的事实标准是 Kubernetes,它将在第二十章“Kubernetes”中详细分析。
Azure 和 Visual Studio 对微服务编排的支持
Visual Studio 提供了用于调试单个微服务的扩展,同时它与其他在 Kubernetes 中部署的微服务进行通信。
此外,还有用于在开发机器上测试和调试多个通信微服务以及仅用最小配置信息自动将它们部署到 Azure Kubernetes Service 的工具。
所有针对 Kubernetes 的 Visual Studio 工具以及使用 Visual Studio 开发 Kubernetes 的整个过程将在第二十二章“为 Kubernetes 开发 .NET 微服务”中的实际示例中描述。
从 Visual Studio 的 Kubernetes 功能讲起,让我们深入了解所有像 Kubernetes 这样的微服务编排器通常提供的关键工具。
管理微服务需要哪些工具?
在 CI/CD 循环中有效地处理微服务需要私有 Docker 镜像仓库和先进的微服务编排器,后者能够执行以下操作:
-
在可用的硬件节点上分配和负载均衡微服务
-
监控服务的健康状态,并在硬件/软件故障发生时替换故障服务
-
记录和展示分析结果
-
允许设计者动态更改需求,例如分配给集群的硬件节点、服务实例数量等
下一个子节描述了我们可以使用的 Azure 设施来存储 Docker 镜像。Azure 中可用的微服务编排器在专门的章节中描述——即第二十章,Kubernetes。
在了解了微服务编排提供的基本功能之后,现在让我们将注意力转向 Azure 如何简化这些过程,从设置私有 Docker 注册表开始。
在 Azure 中定义您的私有 Docker 注册表
在 Azure 中定义您的私有 Docker 注册表很容易。只需在 Azure 搜索栏中输入Container registries
并选择Container registries。在出现的页面上,点击创建按钮。
将出现以下表单:
图 11.9:创建 Azure 私有 Docker 注册表
您选择的名称用于组成整体注册表 URI:<name>.azurecr.io
。通常,您可以指定订阅、资源组和位置。SKU下拉菜单允许您从具有不同性能、可用内存和其他一些辅助功能的各个级别中选择。
在 Docker 命令或 Visual Studio 发布表单中提及镜像名称时,您必须使用注册表 URI 作为前缀:<name>.azurecr.io/<my imagename>
。
如果使用 Visual Studio 创建镜像,则可以按照发布项目时出现的说明进行发布。否则,您必须使用 Docker 命令将它们推送到您的注册表。
使用与 Azure 注册表交互的 Docker 命令的最简单方法是安装 Azure CLI 到您的计算机上。从aka.ms/installazurecliwindows
下载安装程序并执行它。一旦 Azure CLI 已安装,您就可以从 Windows 命令提示符或 PowerShell 中使用az
命令。为了连接到您的 Azure 账户,您必须执行以下login
命令:
az login
此命令应启动您的默认浏览器,并引导您完成手动登录过程。
一旦登录到您的 Azure 账户,您可以通过输入以下命令登录到您的私有注册表:
az acr login --name {registryname}
现在,假设您在另一个注册表中有一个 Docker 镜像。作为第一步,让我们在本地计算机上拉取该镜像:
docker pull other.registry.io/samples/myimage
如果前面的镜像有多个版本,由于没有指定版本,将拉取最新版本。可以按以下方式指定镜像版本:
docker pull other.registry.io/samples/myimage:version1.0
使用以下命令,您应该在本地镜像列表中看到myimage
:
docker images
然后,使用您想要分配到 Azure 注册表的路径标记镜像:
docker tag myimage myregistry.azurecr.io/testpath/myimage
名称和目标标签都可能包含版本(:<版本名称>
)。
最后,使用以下命令将其推送到您的注册表:
docker push myregistry.azurecr.io/testpath/myimage
在这种情况下,您可以指定一个版本;否则,将推送最新版本。
通过执行以下命令,您可以从本地计算机中删除镜像:
docker rmi myregistry.azurecr.io/testpath/myimage
摘要
在本章中,我们描述了微服务是什么以及它们是如何从模块的概念演变而来的。然后,我们讨论了微服务的优势以及何时值得使用它们,以及它们设计的一般标准。我们还解释了 Docker 容器是什么,并分析了容器与微服务架构之间的紧密联系。
然后,我们通过描述 .NET 中可用的所有工具来实施更实际的实现,这样我们就可以实现基于微服务的架构。我们还描述了微服务所需的架构以及 Azure 如何提供容器注册中心和容器编排器。
本章只是对微服务的一般介绍。接下来的章节将更详细地讨论这里介绍的大多数主题,同时展示实际的实现技术和代码示例。
这本书关于基础的第一部分到此结束。下一章,在云中选择您的数据存储,开始本书的第二部分,该部分专注于特定技术。
问题
-
模块概念的双重性质是什么?
-
微服务的优势仅仅是扩展优化吗?如果不是,请列出一些其他优势。
-
什么是 Polly?
-
Visual Studio 提供了哪些 Docker 支持?
-
什么是编排器,Azure 上有哪些可用的编排器?
-
为什么基于发布者/订阅者的通信在微服务中如此重要?
-
什么是 RabbitMQ?
-
为什么幂等消息如此重要?
进一步阅读
以下链接是 Azure 服务总线、RabbitMQ 和其他事件总线技术的官方文档:
-
Azure 服务总线:
docs.microsoft.com/en-us/azure/service-bus-messaging/
-
NServiceBus:
particular.net/nservicebus
-
MassTransit:
masstransit-project.com/
-
Brighter:
www.goparamore.io/
-
RabbitMQ:
www.rabbitmq.com/getstarted.html
-
EasyNetQ:
easynetq.com/
-
以下也是 Polly 和 Docker 的链接:
-
可靠通信/任务的工具 Polly 的文档可以在这里找到:
github.com/App-vNext/Polly
-
更多关于 Docker 的信息可以在 Docker 的官方网站上找到:
docs.docker.com/
-
在 Discord 上了解更多
要加入本书的 Discord 社区——在那里您可以分享反馈、向作者提问以及了解新版本——请扫描下面的二维码:
packt.link/SoftwareArchitectureCSharp12Dotnet8
第十二章:在云中选择您的数据存储
Azure,像其他云一样,提供了一系列的存储服务。我们可能考虑的第一种方法是在云中定义一组可扩展的虚拟机,我们可以在这些虚拟机上实现我们的自定义解决方案。例如,我们可以在云托管的虚拟机上创建一个 SQL Server 集群,以增加可靠性和计算能力。然而,通常,自定义架构不是最佳解决方案,也没有充分利用云基础设施的机会。可扩展性、快速设置、关注业务和安全性是在 Azure 上决定您的数据存储时可能考虑的一些标准。为了帮助您,许多平台即服务(PaaS)的数据存储选项可以是一个很好的解决方案。
因此,本章将不会讨论此类自定义架构,而将主要关注云和 Azure 中可用的各种 PaaS 存储服务。这些服务包括基于普通磁盘空间的可扩展解决方案、关系型数据库、NoSQL 数据库以及如 Redis 这样的内存数据存储。
选择更合适的存储类型不仅基于应用程序的功能需求,还基于性能和扩展需求。实际上,在处理资源进行扩展时会导致性能线性增长,但扩展存储资源并不一定意味着性能的合理增长。简而言之,无论您复制多少数据存储设备,如果多个请求影响相同的数据块,它们将始终排队相同的时间来访问它!
扩展数据会导致读取操作吞吐量线性增长,因为每个副本可以服务不同的请求,但并不意味着写入操作吞吐量有相同的增长,因为同一数据块的所有副本都必须更新!因此,需要更复杂的技术来扩展存储设备,并且并非所有存储引擎的扩展性都相同。
关系型数据库在所有场景下扩展性都不好。因此,扩展需求和地理分布数据的需求在存储引擎的选择以及 SaaS 服务的选择中起着根本的作用。
在本章中,我们将涵盖以下主题:
-
理解不同目的的不同存储库
-
在 SQL 和 NoSQL 文档型数据库之间进行选择
-
Azure Cosmos DB – 管理跨大陆数据库的机会
让我们开始吧!
技术要求
本章要求您具备以下条件:
-
Visual Studio 2022 免费社区版或更高版本,并安装所有数据库工具组件。
-
一个免费的 Azure 账户。第一章,“理解软件架构的重要性”中的创建 Azure 账户小节解释了如何创建一个。
-
为了获得更好的开发体验,我们建议您还安装 Cosmos DB 的本地模拟器,该模拟器可在
aka.ms/cosmosdb-emulator
找到。 -
您可以在
github.com/PacktPublishing/Software-Architecture-with-C-Sharp-12-and-.NET-8-4E
找到该章节的示例代码。
理解不同目的的不同存储库
本节描述了最流行的数据存储技术提供的功能。我们将主要关注它们能够满足的功能需求。性能和扩展功能将在下一节中分析,该节专门用于比较关系型数据库和 NoSQL 数据库。
在 Azure 中,您可以通过在所有 Azure 门户页面顶部的搜索栏中输入产品名称来找到各种产品。
以下小节描述了我们可以用于我们的 C# 项目的各种数据库类型。
关系型数据库
这些数据库是最常见和被研究的数据存储类型。它们保证高服务水平并存储了无法衡量的数据量。数十个应用程序被设计用于存储此类数据库中的数据,我们可以在银行、商店、工业等领域找到它们。当您在关系型数据库中存储数据时,基本原则是定义您将在其中保存的实体和属性,并定义这些实体之间正确的关联关系。
几十年来,关系型数据库是设计优秀项目的唯一想象选项。世界上许多大公司都建立了自己的数据库管理系统。Oracle、MySQL 和 MS SQL Server 被许多人列为可以信赖来存储数据的数据库。
通常,云提供多种数据库引擎。Azure 提供了各种流行的数据库引擎,例如 Oracle、MySQL、PostgreSQL 和 SQL Server(Azure SQL)。
关于 Oracle 数据库引擎,Azure 提供了预装了各种 Oracle 版本的配置虚拟机,您可以通过在 Azure 门户搜索栏中输入Oracle
后得到的建议轻松验证。Azure 费用不包括 Oracle 许可证;它们仅包括计算时间,因此您必须将许可证带到 Azure。
在 Azure 上使用 MySQL,您需要为使用私有服务器实例付费。您产生的费用取决于您拥有的核心数量、必须分配多少内存以及备份保留时间。
MySQL 实例是冗余的,您可以选择本地或地理分布式的冗余:
图 12.1:在 Azure 上创建 MySQL 服务器
Azure SQL 数据库是 Azure 上最早可用的 PaaS 选项之一,因此近年来它发展了很多,这使得它成为最灵活的提供之一。今天,它还包括一种无服务器定价模型,您将按每秒使用的计算量计费。在这里,您可以配置每个数据库使用的资源。当您创建数据库时,您可以选择将其放置在现有的服务器实例上或创建一个新的实例。
在定义解决方案时,您可以选择几种定价选项,Azure 会不断增加这些选项以确保您能够处理云中的数据。基本上,它们因您所需的计算能力而有所不同。
例如,在数据库事务单元(DTUs)模型中,费用基于已预留的数据库存储容量以及由参考工作负载确定的线性组合的 I/O 操作、CPU 使用率和内存使用率。考虑到理解 DTUs 精确计算方法的难度,Azure 还提供了基于核心(vCore)的模型,您在这里拥有灵活性、控制和透明度,可以单独监控资源消耗。
大概来说,当您增加 DTUs(数据库事务单元)时,数据库的最大性能会线性增加。
您可以在learn.microsoft.com/en-us/azure/azure-sql/database/purchasing-models?view=azuresql
找到有关您购买 Azure SQL 数据库的选项的详细信息。
图 12.2:创建 Azure SQL 数据库
您还可以通过启用读取扩展来配置数据复制。这样,您可以提高读取操作的性能。备份保留期对于每个提供级别(基本、标准和高级)是固定的。
如果您选择是以是否要使用 SQL 弹性池?,则数据库将被添加到弹性池中。添加到同一弹性池的数据库将共享其资源,因此未被数据库使用的资源可以在其他数据库使用 CPU 峰值期间使用。值得一提的是,弹性池只能包含托管在同一服务器实例上的数据库。弹性池是优化资源使用以降低成本的有效方式。
NoSQL 数据库
关系型数据库给软件架构师带来的最大挑战之一与我们如何处理数据库结构模式变化有关。本世纪初所需的变化敏捷性带来了使用一种名为 NoSQL 的新数据库风格的机会。接下来的子主题将介绍几种类型的 NoSQL 数据库。
文档型数据库
最常见的数据库类型,其中包含键和复杂数据,被称为文档。
例如,在 NoSQL 文档型数据库中,关系型表被更通用的集合所取代,这些集合可以包含异构的 JSON 对象。也就是说,集合没有预定义的结构,也没有预定义的字段长度限制(在字符串的情况下),但可以包含任何类型的对象。与每个集合相关联的唯一结构约束是作为主键的属性名称。
更具体地说,每个集合条目可以包含嵌套对象和嵌套在对象属性中的对象集合,即相关实体,在关系型数据库中,这些实体包含在不同的表中并通过外部键连接。在 NoSQL 中,数据库可以嵌套在其父实体中。由于集合条目包含复杂的嵌套对象,而不是像关系型数据库那样简单的属性/值对,因此条目不被称为元组或行,而是文档。
在同一集合或不同集合的文档之间不能定义任何关系和/或外部键约束。如果一个文档在其属性中包含另一个文档的主键,那么它这样做是承担风险的。开发者负责维护和保持这些引用的一致性。这是你必须分析的一个权衡。如果你设计的是一个通常依赖于关系型数据库的系统,那么你将无法实现 NoSQL 的优势,同时你也在牺牲数据完整性和冗余。然而,如果你有一个需要灵活性的场景,你必须考虑将 NoSQL 作为一个选项。
最后,NoSQL 存储相当便宜。你可以将大量数据作为 Base64 字符串属性存储。开发者可以定义规则来决定在集合中索引哪些属性。由于文档是嵌套对象,属性是树路径。值得一提的是,你可以指定哪些路径集合和子路径被索引。
几乎所有的 NoSQL 数据库都使用 SQL 的子集或基于 JSON 的语言进行查询,其中查询是 JSON 对象,其路径表示要查询的属性,其值表示已应用于它们的查询约束。
在关系型数据库中,可以通过一对多关系使用嵌套子对象的可能性。然而,在使用关系型数据库时,我们被迫重新定义所有相关表的确切结构,而 NoSQL 集合不对它们包含的对象施加任何预定义的结构。唯一的约束是每个文档必须为主键属性提供一个唯一的值。
诚然,今天我们可以在像 Azure SQL 这样的关系数据库中定义 JSON 列,因此我们可以在定义我们的数据模型时采取混合方法,部分有模式,其他部分则灵活。然而,当我们的对象结构极其多变时,如社交媒体和物联网(IoT)解决方案,NoSQL 数据库仍然是最佳选择。
然而,它们通常是因为它们在扩展读写操作和更普遍地,在分布式环境中的性能优势而被选择的。它们与关系数据库相比的性能特性将在下一节中讨论。
图数据库
社交媒体网站倾向于使用这种类型的数据库,因为数据是以图的形式存储的。
图数据模型是完全非结构化文档的极端情况。整个数据库是一个图,查询可以在其中添加、更改和删除图文档。
在这种情况下,我们有两种类型的文档:节点和关系。虽然关系有一个定义良好的结构(通过关系连接的节点的主键,加上关系名称),但节点完全没有结构,因为属性及其值在节点更新操作中一起添加。
对于你作为软件架构师来说,决定这种在图数据库中展示的结构是否最适合你正在设计的系统的用例是很重要的,始终记住这样的决定可能会使系统比所需的更复杂。
图数据模型是为了表示人们及其操作的对象(媒体、帖子等)的特征以及他们在社交应用中的关系而设计的。Gremlin 语言是为了查询图数据模型而专门设计的。我们将在本章中不讨论这一点,但在进一步阅读部分有相关参考。
关键值数据库
这是一个有用的数据库,用于实现缓存,因为你可以存储键值对。Redis 是它的一个很好的例子,将在本章中详细说明。
宽列存储数据库
这是一种数据库,其中每行的相同列可以存储不同的数据。
在本章剩余的部分,我们将详细分析 NoSQL 数据库,这些部分专门用于描述 Azure Cosmos DB 并将其与关系数据库进行比较。
Redis
Redis 是一种基于键值对的分布式内存存储,支持分布式队列。它可以作为永久性内存存储和数据库数据的 Web 应用程序缓存使用。或者,它可以用作预渲染内容的缓存。
Redis 也可以用来存储 Web 应用程序的用户会话数据。这最初是用 Microsoft SQL Server 完成的,但由于内存数据库提供的性能,Redis 现在是最佳替代品。
实际上,ASP.NET Core 支持会话数据以克服 HTTP 协议无状态的事实。更具体地说,用户数据在页面变化之间保持,存储在 Redis 等服务器端存储中,并通过存储在 cookie 中的会话键进行索引。
与云中的 Redis 服务器交互通常基于提供易于使用界面的客户端实现。.NET 客户端通过StackExchange.Redis
NuGet 包提供。StackExchange.Redis
客户端的基本操作已在stackexchange.github.io/StackExchange.Redis/Basics
中记录,而完整文档可在stackexchange.github.io/StackExchange.Redis
找到。
在 Azure 上定义 Redis 服务器的用户界面相当简单:
图 12.3:创建 Redis 缓存
定价层下拉菜单允许我们选择可用的内存/复制选项之一。有关如何使用 Azure Redis 凭据和StackExchange.Redis
.NET 客户端的快速入门指南,请参阅docs.microsoft.com/en-us/azure/azure-cache-for-redis/cache-dotnet-core-quickstart
。
Azure 存储账户
所有云都提供可扩展和冗余的通用磁盘内存,你可以将其用作虚拟机中的虚拟磁盘或作为外部文件存储。Azure 存储账户磁盘空间也可以在表和队列中进行结构化。如果你需要廉价的 blob 存储,请考虑使用此选项。然而,正如我们之前提到的,还有更复杂的选择。根据你的场景,Azure NoSQL 数据库比表是一个更好的选择,而 Azure Redis 比 Azure 存储队列是一个更好的选择。
图 12.4:创建存储账户
在本章的其余部分,我们将重点关注 NoSQL 数据库以及它们与关系数据库的不同之处。接下来,我们将探讨如何选择其中之一。
在 SQL 和 NoSQL 面向文档的数据库之间进行选择
作为软件架构师,你可能需要考虑 SQL 和 NoSQL 数据库的一些方面,以决定最适合你的存储选项。在许多情况下,两者都将被需要。这里的关键点无疑将是你的数据组织程度以及数据库将变得多大。
在上一节中,我们提到,作为软件架构师,当数据几乎没有任何预定义结构时,你应该优先考虑使用 NoSQL 面向文档的数据库。它们不仅将可变属性与其所有者保持紧密,而且由于允许相关对象嵌套在属性和集合中,它们还将一些相关对象保持在一起。
非结构化数据可以通过将元组(t
)的变量属性放置在一个包含属性名称、属性值和t
的外部键的连接表中,在关系数据库中表示。然而,在这种场景下的问题是性能。实际上,属于单个对象的属性值会散布在可用的内存空间中。在一个小型数据库中,“散布在可用的内存空间中”意味着距离较远但位于同一磁盘上;在一个大型数据库中,意味着距离较远但位于不同的磁盘单元中;在分布式云环境中,意味着距离较远但位于不同——并且可能是地理上分布的——服务器上。
另一方面,在 NoSQL 文档型数据库设计中,我们总是试图将所有可能一起处理的关联对象放入单个条目中。访问频率较低的相关对象被放置在不同的条目中。由于外部键约束不是自动执行的,并且 NoSQL 事务非常灵活,开发者可以在性能和一致性之间选择最佳折衷方案。
重要的是要提到,今天,我们可以在关系数据库中将非结构化数据作为具有 JSON(或 XML)类型的列存储。这种方法允许在关系数据库中实现通常在文档数据库中实现的模式,例如通过在 JSON 列中插入完整对象来避免连接。然而,考虑到它是为此目的而设计的,采用 NoSQL 文档型数据库可以被认为是最佳选择。
因此,我们可以得出结论,当通常一起访问的表可以存储在一起时,关系数据库表现良好。另一方面,NoSQL 文档型数据库自动确保相关数据保持在一起,因为每个条目都将其相关的大部分数据作为嵌套对象包含在内。因此,当 NoSQL 文档型数据库分布到不同的内存和地理上分布的服务器时,它们的表现更好。
不幸的是,扩展存储写操作的唯一方法是根据分片键的值将集合条目分散到几个服务器上。例如,我们可以将所有以A开头的用户名记录放在一个服务器上,以B开头的用户名记录放在另一个服务器上,依此类推。这样,不同起始字母的用户名的写操作可以并行执行,确保写吞吐量随着服务器数量的线性增长。
然而,如果一个分片集合与几个其他集合相关联,不能保证相关记录会被放置在同一个服务器上。此外,在不使用集合分片的情况下,将不同的集合放在不同的服务器上,可以线性地增加写入吞吐量,直到达到每个服务器单个集合的限制,但这并不能解决被迫在多个服务器上执行多个操作以检索或更新通常一起处理的数据的问题。
如果必须以事务方式访问相关分布式对象,并且/或者必须确保结构约束(如外键约束)不被违反,这个问题在关系数据库中会对性能造成灾难性的影响。在这种情况下,所有相关对象必须在事务期间被阻塞,防止其他请求在整个耗时的分布式操作期间访问它们。
NoSQL 文档型数据库不会受到这个问题的影响,并且通过分片以及随之而来的写入扩展输出表现更好。这是因为它们不会将相关数据分布到不同的存储单元,而是将它们存储为同一数据库条目中的嵌套对象。另一方面,它们也面临着不同的问题,例如默认不支持事务。
值得注意的是,在某些情况下,关系数据库通过分片表现良好。一个典型的例子是多租户应用程序。在多租户应用程序中,所有条目集合可以被划分为非重叠的集合,称为租户。只有属于同一租户的条目可以相互引用,因此如果所有集合都根据它们的对象租户以相同的方式分片,所有相关记录最终都会落在同一个分片中,即同一个服务器上,并且可以有效地进行导航。
在本章中,我们没有讨论如何使用 Azure SQL 定义分片。如果您想了解更多信息,请参阅官方文档链接:docs.microsoft.com/en-us/azure/sql-database/sql-database-elastic-scale-introduction
.
在云中,多租户应用程序并不罕见,因为向多个不同用户提供相同服务的所有应用程序通常都作为多租户应用程序实现,其中每个租户对应一个用户订阅。因此,关系数据库,如 Azure SQL Server,旨在在云中工作,通常为多租户应用程序提供分片选项。通常,分片不是一个云服务,必须使用数据库引擎命令来定义。在这里,我们不会描述如何使用 Azure SQL Server 定义分片,但“进一步阅读”部分包含了一个指向官方微软文档的链接。以下表格展示了每种数据库方法的优缺点:
主题 | SQL | NoSQL 文档型数据库 |
---|---|---|
架构 | 在结构良好的模式中易于处理。今天,可以通过设计具有 JSON/XML 列的混合解决方案来存储非结构化数据。 | 当数据几乎没有任何预定义结构时更受欢迎。 |
性能 | 通常在分布式环境中性能较差。 | 通常在读取和写入分布式数据时性能良好。 |
语言 | 使用声明性语言查询和更新数据,标准。 | 使用过程性语言查询和更新操作。 |
一致性 | 强制使用外键。 | 弱,留给开发者的决定。所有可能一起处理到单个条目中的相关对象。 |
事务 | 支持。 | 默认情况下,不支持。 |
缩放 | 垂直升级硬件。 | 水平扩展数据分片。 |
表 12.1:每种数据库方法的优缺点
总之,关系数据库提供了独立于存储方式的数据的纯粹逻辑视图,并使用声明性语言查询和更新它们。这简化了开发和系统维护,但在需要写入扩展的分布式环境中可能会引起性能问题。值得注意的是,第十三章中介绍的 Entity Framework 等工具有助于在对象和关系数据之间架起桥梁,使关系数据库的开发更加直观。
在 NoSQL 文档型数据库中,您必须手动处理有关如何存储数据的更多细节,以及所有更新和查询操作的一些过程性细节,但这允许您在需要读取和写入扩展的分布式环境中优化性能。另一方面,处理 NoSQL 数据,尤其是涉及 JSON 或 XML 等反序列化格式时,可能会很棘手。这通常需要仔细映射以确保数据完整性,这可能既具有挑战性又容易出错。在下一节中,我们将探讨 Azure Cosmos DB,这是 Azure 的主要 NoSQL 产品,幸运的是,它可以与 Entity Framework 集成,以获得更流畅的开发体验。
Azure Cosmos DB – 管理跨大陆数据库的机会
Azure Cosmos DB 是 Azure 的主要 NoSQL 产品。Azure Cosmos DB 拥有自己独特的接口,它是 SQL 的子集,但可以配置为使用 MongoDB 接口、Table API 或 Cassandra API。它还可以配置为图数据模型,可以使用 Gremlin 进行查询。
您可以在官方文档中找到有关 Cosmos DB 的更多详细信息:docs.microsoft.com/en-us/azure/cosmos-db/
.
Cosmos DB 允许进行复制以实现容错和读取扩展,副本可以地理分布以优化通信性能。此外,您可以指定所有副本放置在哪个数据中心。用户还可以选择启用所有副本的写入功能,以便在数据写入的地理位置立即可用。通过分片实现写入扩展,用户可以通过定义哪些属性用作分片键来配置。
创建 Azure Cosmos DB 帐户
您可以通过在 Azure 门户搜索栏中键入 Cosmos DB
并点击创建来定义 Cosmos DB 帐户。以下页面将出现:
图 12.5:创建 Azure Cosmos DB 帐户
例如,如果您选择核心(SQL)选项,您选择的帐户名称在资源 URI 中用作 {account_name}.documents.azure.com
。然后,您可以决定主数据库将放置在哪个位置以及容量模式。您可以在docs.microsoft.com/en-us/azure/cosmos-db/throughput-serverless
上查看有关可用容量模式的更多信息。
微软不断改进其许多 Azure 服务。了解任何 Azure 组件新功能的最佳方式是定期检查其文档。
在全局分布选项卡上,多区域写入切换按钮允许您在地理分布的副本上启用写入。如果您不这样做,所有写入操作都将路由到主位置。最后,您还可以在创建过程中定义网络连接性、备份策略和加密。
创建 Azure Cosmos DB 容器
一旦您创建了您的 Azure Cosmos DB – Core SQL 帐户,选择数据资源管理器以在它们内部创建数据库和容器。容器是配置吞吐量和存储的可扩展性单元,当您通过配置吞吐量容量模式决定时可用。
由于数据库仅有一个名称而没有配置,您可以直接添加容器,然后将数据库放置在您希望的位置:
图 12.6:在 Azure Cosmos DB 中添加容器
在这里,您可以决定数据库和容器的名称以及用于分片的属性(分区键)。由于 NoSQL 条目是对象树,属性名称指定为路径。您还可以添加值必须唯一的属性。
然而,ID 的唯一性是在每个分区内部进行检查的,因此此选项仅在特定情况下有用,例如多租户应用程序(其中每个租户都包含在单个分片中)。费用取决于您选择的集合吞吐量。
这就是您需要将所有资源参数针对您的需求进行定位的地方。吞吐量以每秒请求数量表示,其中每秒请求数量定义为每秒执行 1 KB 读取时的吞吐量。因此,如果您检查配置数据库吞吐量选项,所选的吞吐量是整个数据库共享的,而不是作为单个集合保留。
访问 Azure Cosmos DB
在创建 Azure Cosmos 容器之后,您将能够访问数据。要获取连接信息,您可以选择密钥菜单。在那里,您将看到连接到您的 Cosmos DB 账户所需的所有信息。连接信息页面将为您提供账户 URI 和两个连接密钥,这些密钥可以互换使用以连接到账户。
图 12.7:连接信息页面
还有一些具有只读权限的密钥。每个密钥都可以重新生成,每个账户都有两个等效的密钥,就像许多其他 Azure 组件一样。这种方法使得操作可以高效地处理;也就是说,当一个密钥更改时,另一个密钥会被保留。因此,现有的应用程序可以在升级到新密钥之前继续使用另一个密钥。
定义数据库一致性
考虑到您处于分布式数据库的上下文中,Azure Cosmos DB 允许您定义默认的读取一致性级别。通过在您的 Cosmos DB 账户主菜单中选择默认一致性,您可以选择应用于所有容器的默认复制一致性。
此默认设置可以在每个容器中覆盖,无论是通过数据资源管理器还是通过编程方式。读写操作中的不一致性问题是由数据复制引起的后果。更具体地说,如果读取操作在不同的副本上执行,而这些副本接收到了不同的部分更新,那么各种读取操作的结果可能是不连贯的。
以下是可以用的一致性级别。这些已按从最弱到最强的顺序排列:
-
最终一致性:经过足够的时间后,如果没有进一步的写入操作,所有读取将收敛并应用所有写入。写入的顺序也不保证,所以在写入正在处理时,您也可能读取到之前读取的较早版本。
-
一致性前缀:所有写入都在所有副本上以相同的顺序执行。因此,如果有
n
个写入操作,每个读取都与应用前m
个写入的结果一致,其中m
小于或等于n
。 -
会话:这与一致性前缀相同,但还保证每个写者在其所有后续读取操作中看到其自己的写入结果,并且每个读者的后续读取是一致的(要么是相同的数据库,要么是其更新版本)。
-
有界陈旧性:这与延迟时间
Delta
或多个操作N
相关联。每次读取都会看到在时间Delta
之前(或最后N
个操作之前)执行的所有写操作的结果。也就是说,其读取与所有写操作的结果收敛,最大时间延迟为Delta
(或最大操作延迟为N
)。 -
强一致性:这是有界陈旧性与
Delta = 0
的结合。在这里,每次读取都反映了所有之前的写操作的结果。
可以以牺牲性能为代价获得最强的一致性。默认情况下,一致性设置为会话,这是在一致性和性能之间的一种良好折衷。在应用程序中处理较低级别的一致性很困难,并且通常只有在会话是只读或只写的情况下才可接受。
如果你选择数据库容器中的数据探索器菜单中的设置选项,你可以配置要索引哪些路径以及将哪种索引应用于每个路径的数据类型。该配置由一个 JSON 对象组成。让我们分析其各种属性:
{
"indexingMode": "consistent",
"automatic": true,
...
如果你将indexingMode
设置为none
而不是consistent
,则不会生成索引,并且可以将集合用作由集合的主键索引的键值字典。在这种情况下,不会生成二级索引,因此无法有效地搜索主键。
当automatic
设置为true
时,所有文档属性都会自动索引:
{
...
"includedPaths": [
{
"path": "/*",
"indexes": [
{
"kind": "Range",
"dataType": "Number",
"precision": -1
},
{
"kind": "Range",
"dataType": "String",
"precision": -1
},
{
"kind": "Spatial",
"dataType": "Point"
}
]
}
]
},
...
includedPaths
中的每个条目指定了一个路径模式,例如/subpath1/subpath2/?
(设置仅应用于/subpath1/subpath2/property
)或/subpath1/subpath2/*
(设置应用于所有以/subpath1/subpath2/
开头的路径)。
当设置必须应用于集合属性中包含的子对象时,模式包含[]
符号;例如,/subpath1/subpath2/[]/?
,/subpath1/subpath2/[]/childpath1/?
等等。设置指定要应用于每个数据类型(字符串、数字、地理点等)的索引类型。范围索引用于比较操作,而如果需要进行等价比较,散列索引则更有效。
可以指定一个精度,即所有索引键中使用的最大字符数或数字数。-1
表示最大精度,并且总是推荐使用:
...
"excludedPaths": [
{
"path": "/\"_etag\"/?"
}
]
包含在excludedPaths
中的路径根本不会被索引。索引设置也可以通过编程方式指定。
在这里,你有两种连接到 Cosmos DB 的选项:使用适用于你首选编程语言的官方客户端或使用 Cosmos DB 的 Entity Framework Core 提供程序。在接下来的小节中,我们将探讨这两种选项。然后,我们将通过一个实际示例描述如何使用 Cosmos DB 的 Entity Framework Core 提供程序。
Cosmos DB 客户端
.NET 8 的 Cosmos DB 客户端可通过Microsoft.Azure.Cosmos
NuGet 包获得。它提供了对所有 Cosmos DB 功能的完全控制,而 Cosmos DB Entity Framework 提供程序更容易使用,但隐藏了一些 Cosmos DB 的特性。按照以下步骤通过.NET 8 的官方 Cosmos DB 客户端与 Cosmos DB 交互:
以下代码示例展示了使用客户端组件创建数据库和容器。任何操作都需要创建一个客户端对象。不要忘记,当不再需要客户端时,必须通过调用其Dispose
方法(或通过将引用它的代码放在using
语句中)来释放客户端:
public static async Task CreateCosmosDB()
{
using var cosmosClient = new CosmosClient(endpoint, key);
Database database = await
cosmosClient.CreateDatabaseIfNotExistsAsync(databaseId);
ContainerProperties cp = new ContainerProperties(containerId,
"/DestinationName");
Container container = await database.CreateContainerIfNotExistsAsync(cp);
await AddItemsToContainerAsync(container);
}
在集合创建期间,你可以传递一个ContainerProperties
对象,其中你可以指定一致性级别、如何索引属性以及所有其他集合功能。
然后,你必须定义与你要在集合中操作的 JSON 文档结构相对应的.NET 类。你也可以使用JsonProperty
属性将类属性名称映射到 JSON 名称,如果它们不相等的话:
public class Destination
{
[JsonPropertyName("id")]
public string Id { get; set; }
public string DestinationName { get; set; }
public string Country { get; set; }
public string Description { get; set; }
public Package[] Packages { get; set; }
}
NoSQL 意味着“不仅 SQL”,因此也可以映射属性。NoSQL 的伟大之处在于,你必须在不会损害你连接的文档中的其他属性或信息的情况下映射这些属性。
一旦你有所有必要的类,你可以使用客户端方法ReadItemAsync
、CreateItemAsync
和DeleteItemAsync
。你还可以使用接受 SQL 命令的QueryDefinition
对象来查询数据。你可以在docs.microsoft.com/en-us/azure/cosmos-db/sql-api-get-started
找到这个库的完整介绍。
Cosmos DB Entity Framework Core 提供程序
Cosmos DB 的 Entity Framework Core 提供程序包含在Microsoft.EntityFrameworkCore.Cosmos
NuGet 包中。一旦将其添加到项目中,你可以按照与你在第十三章,“使用 C#与数据交互 – Entity Framework Core”中使用 SQL Server 提供程序类似的方式进行操作,但有一些不同之处。让我们看看:
-
由于 Cosmos DB 数据库没有结构需要更新,因此没有迁移。相反,它们有一个确保数据库以及所有必要的集合被创建的方法:
context.Database.EnsureCreated();
-
默认情况下,
DBContext
中的DbSet<T>
属性映射到一个唯一的容器,因为这是最经济的选项。你可以通过以下配置指令显式指定你想要将某些实体映射到哪个容器来覆盖此默认设置:builder.Entity<MyEntity>() .ToContainer("collection-name");
-
实体类上唯一的实用注解是
Key
属性,当主键不命名为Id
时,它成为强制性的。 -
主键必须是字符串,并且不能自动递增,以避免在分布式环境中的同步问题。可以通过生成 GUID 并将其转换为字符串来确保主键的唯一性。
-
在定义实体之间的关系时,你可以指定一个实体或一组实体属于另一个实体,在这种情况下,它将与父实体一起存储。
我们将在第二十一章的“如何在云中选择你的数据存储”部分中查看 Cosmos DB 的 Entity Framework 提供者的使用情况,案例研究。
摘要
在本章中,我们探讨了 Azure 中可用的主要存储选项,并学习了何时使用它们。然后,我们比较了关系型数据库和非关系型数据库。我们指出,关系型数据库提供自动一致性检查和事务隔离,但非关系型数据库更便宜,并且提供更好的性能,尤其是在分布式写入占平均工作负载很大比例的情况下。
然后,我们描述了 Azure 的主要非关系型选项 Cosmos DB,并解释了如何配置它以及如何与客户端连接。
最后,我们学习了如何使用 Entity Framework Core 与 Cosmos DB 交互。在这里,我们学习了如何决定在应用程序涉及的所有数据家族中,是使用关系型数据库还是非关系型数据库。因此,你可以选择确保在您的每个应用程序中数据一致性、速度和并行访问数据之间取得最佳折衷的数据存储类型。
在下一章中,我们将学习如何使用 C#和 Entity Framework Core 与数据交互。
问题
-
Redis 是关系型数据库的有效替代品吗?
-
非关系型数据库是关系型数据库的有效替代品吗?
-
在关系型数据库中,哪种操作更难进行扩展?
-
非关系型数据库的主要弱点是什么?它们的主要优势是什么?
-
你能列出所有 Cosmos DB 的一致性级别吗?
-
我们可以使用自动递增的整数键与 Cosmos DB 一起使用吗?
-
哪种 Entity Framework 配置方法用于在相关的父文档中存储实体?
-
使用 Cosmos DB 能否有效地搜索嵌套集合?
进一步阅读
-
以下是对 Gremlin 语言的引用,该语言由 Cosmos DB 支持:
tinkerpop.apache.org/docs/current/reference/#graph-traversal-steps
。 -
以下是对 Cosmos DB 图数据模型的简要描述:
docs.microsoft.com/en-us/azure/cosmos-db/graph-introduction
。 -
如何使用 Cosmos DB 的官方 .NET 客户端的详细信息可以在
docs.microsoft.com/en-us/azure/cosmos-db/sql-api-dotnetcore-get-started
找到。关于我们在本章中提到的MvcControlsToolkit.Business.DocumentDB
NuGet 包的良好介绍是 DNCMagazine 第 34 期中的 Fast Azure Cosmos DB Development with the DocumentDB Package 文章。这可以从www.dotnetcurry.com/microsoft-azure/aspnet-core-cosmos-db-documentdb
下载。
在 Discord 上了解更多信息
要加入这本书的 Discord 社区——在那里您可以分享反馈、向作者提问,并了解新书发布——请扫描下面的二维码:
packt.link/SoftwareArchitectureCSharp12Dotnet8
第十三章:在 C#中与数据交互 - Entity Framework Core
正如我们在第七章中提到的,理解软件解决方案中的不同领域,软件系统被组织成层,通过接口和类进行通信,这些接口和类不依赖于每个层的实现特性。当软件是商业/企业系统时,它通常至少包含三个层:数据层、业务层和表示层,如果软件基于经典层架构(参见第七章的经典层架构部分)。
如果应用程序基于洋葱架构,最外层包含表示逻辑、驱动程序和测试逻辑,那么就有一个应用层,最后是一个领域层(参见第七章的洋葱架构部分)。虽然,在洋葱架构中,层被定义的方式略有不同,但洋葱架构的三个层的功能基本上与经典层架构的三个层相同。
然而,尽管所有可能的架构选择之间都有所不同,但经验证明,处理数据所需的主要功能相当标准化。
更具体地说,在第七章中描述的所有架构中,数据处理层的主要目的是将数据从数据存储子系统映射到对象,反之亦然。
在经典数据层的情况下,这些对象是没有方法的普通对象,而在领域层的情况下,它们是具有实现应用程序领域逻辑的方法的丰富对象。相反,数据层在与其普通对象关联的仓储类中实现应用程序的领域逻辑(参见第七章的仓储和单元工作模式部分)。
这导致了通用框架的概念,以大量声明性方式实现数据层。这些工具被称为对象关系映射(ORM)工具,因为它们是基于关系数据库的数据存储子系统。然而,它们也与现代非关系存储(如 MongoDB 和 Azure Cosmos DB)很好地协同工作,因为它们的数据模型比纯关系模型更接近目标对象模型。
ORMs 通过将映射数据到对象以及反之亦然的负担分离出来,从而改进并简化了整个开发过程,因此开发者可以专注于业务领域的特性。
在本章中,我们将涵盖以下主题:
-
理解 ORM 基础
-
配置 Entity Framework Core
-
Entity Framework Core 迁移
-
编译模型
-
使用 Entity Framework Core 查询和更新数据
-
部署你的数据层
-
理解 Entity Framework Core 高级功能
本章将描述 ORM 及其配置方法,然后重点关注包含在.NET 8 中的 ORM:Entity Framework Core。
在深入研究 ORM 基础知识之前,让我们看看遵循本章中实际示例所需的技术要求。
技术要求
本章需要免费 Visual Studio 2022 Community Edition 或更高版本,并安装所有数据库工具。
本章中的所有概念都将通过实际示例进行阐明。您可以在github.com/PacktPublishing/Software-Architecture-with-C-Sharp-12-and-.NET-8-4E
找到本章的代码。
理解 ORM 基础知识
ORM 将关系数据库表映射到内存中的对象集合,其中对象属性对应于数据库表列。来自 C#的类型,如布尔值、数值类型和字符串,都有对应的数据库类型。如果映射的数据库中没有 GUID,则 GUID 类型将映射到其等效的字符串表示形式。所有日期和时间类型都映射到 C#的DateTime
,当日期/时间不包含时区信息时;映射到DateTimeOffset
,当日期/时间包含显式时区信息时;映射到DateOnly
,当类型仅包含日期信息时;或映射到TimeOnly
,当类型仅包含时间信息时。任何数据库时间持续时间都映射到TimeSpan
。最后,单字符不应映射到数据库字段。
由于大多数面向对象语言的字符串属性没有与之关联的长度限制(而数据库字符串字段通常有长度限制),因此在数据库映射配置中考虑了数据库限制。一般来说,当需要在数据库类型和面向对象语言类型之间指定选项时,这些选项在映射配置中声明。
整个配置的定义方式取决于具体的 ORM。Entity Framework Core 提供了三种选项:
-
数据注释(属性属性)
-
命名约定
-
基于配置对象和方法的流畅配置接口
虽然流畅式接口可以用来指定任何配置选项,但数据注释和命名约定可以用于其中较小的一部分。
个人而言,我更喜欢使用流畅式接口来设置大多数设置。我只使用命名约定来指定具有 ID 属性名的主体键,因为我发现依赖命名约定来设置更复杂的设置非常危险。实际上,在命名约定上没有编译时检查,因此重工程操作可能会错误地更改或破坏一些 ORM 设置。
我主要使用数据注释来指定属性可能值的约束,例如值的最大长度或属性是必需的且不能为 null 的事实。实际上,这些约束限制了每个属性中指定的类型,因此将它们放置在它们应用的属性旁边可以增加代码的可读性。
通过使用流畅接口更好地分组和组织所有其他设置,可以提高代码的可读性和可维护性。
每个 ORM 都通过称为提供程序或连接器的数据库特定适配器来适应特定的数据库类型(Oracle、MySQL、SQL Server 等)。Entity Framework Core 为大多数可用的数据库引擎提供了提供程序。
提供程序的全列表可以在docs.microsoft.com/en-US/ef/core/providers/
找到。
适配器对于数据库类型之间的差异、事务处理方式以及 SQL 语言未标准化的所有其他功能都是必要的。
表之间的关系通过对象指针来表示。例如,在一对多关系中,映射到关系一端的类包含一个集合,该集合填充了关系多端的相关对象。另一方面,映射到关系多端的类有一个简单的属性,该属性填充了与关系一端唯一相关联的对象。
在一对一关系的情况下,两个类都有一个填充了伴随对象的属性,而在多对多关系的情况下,两个类都包含一个填充了相关对象的集合。
整个数据库(或只是其中的一部分)由一个内存缓存类表示,该类包含每个映射的数据库表的集合。首先,在内存缓存类的实例上执行查询和更新操作,然后该实例与数据库同步。
Entity Framework Core 使用的内存缓存类称为DbContext
,它还包含映射配置。
开发者可以通过从它继承并在重写的方法中添加他们的数据库映射指令来自定义 Entity Framework Core 提供的DbContext
类。
总结来说,DbContext
子类实例包含与数据库同步的 DB 的部分快照,以获取/更新实际数据。
使用内存缓存类的集合上的方法调用执行数据库查询。实际的 SQL 是在同步阶段创建和执行的。例如,Entity Framework Core 在映射到数据库表的集合上执行语言集成查询(LINQ)查询。
通常,LINQ 查询生成IEnumerable
实例,即元素在查询结束时并未计算,而是在实际尝试从其中检索集合元素时才进行计算。这被称为延迟评估或延迟执行。其工作原理如下:
-
从
DbContext
的映射集合开始的 LINQ 查询创建了一个名为IQueryable
的特定子类型IEnumerable
。 -
IQueryable
包含发出数据库查询所需的所有信息,但实际的 SQL 是在检索IQueryable
的第一个元素时生成和执行的。 -
通常,每个 Entity Framework 查询都以
ToListAsync
或ToArrayAsync
操作结束,该操作将IQueryable
转换为列表或数组,从而在数据库上实际执行查询。 -
如果预期查询将返回单个元素或没有任何元素,我们通常执行一个
SingleOrDefaultAsync
操作,该操作返回一个元素(如果有),或null
。当预期有多个结果但只需要一个时,也可以使用此操作。在这种情况下,我们可能还会使用LastOrDefaultAsync
。 -
如果我们只需要计算总结果,例如,为了合理组织分页信息,我们可以使用
CountAsync()
。
此外,对 DB 表中的新实体进行更新、删除以及添加操作是通过模拟在表示数据库表的DbContext
集合属性上执行这些操作来完成的。然而,实体只能通过在内存集合中通过查询加载后以这种方式进行更新或删除。通常,更新查询需要根据需要修改实体的内存表示,而删除查询则需要从其内存映射集合中删除实体的内存表示。在 Entity Framework Core 中,删除操作是通过调用集合的Remove(entity)
方法来执行的。
添加新实体没有其他要求。只需将新实体添加到内存集合中即可。对各种内存集合进行的更新、删除和添加操作实际上是通过显式调用 DB 同步方法传递到数据库中的。
例如,当你调用DbContext.SaveChangesAsync()
方法时,Entity Framework Core 会将DbContext
实例上执行的所有更改传递到数据库中。
在同步操作期间传递到数据库中的更改是在单个事务中执行的。此外,对于具有显式事务表示的 ORM,如 Entity Framework Core,如果同步操作在事务的作用域内执行,则它将使用该事务而不是创建一个新事务。
本章的剩余部分解释了如何使用 Entity Framework Core,以及一些基于本书 WWTravelClub 用例的示例代码。
配置 Entity Framework Core
由于,正如在第七章,理解软件解决方案中的不同领域 中详细说明的,数据库处理被限制在专用应用程序层内,因此将 Entity Framework Core (DbContext
) 定义在单独的库中是一种良好的做法。因此,我们需要定义一个 .NET 类库项目。
我们有两种不同类型的库项目:.NET Standard 和 .NET Core。请参阅第五章,在 C# 12 中实现代码重用,以了解各种库的讨论。
由于 .NET 库与特定的 .NET Core 版本相关联,.NET Standard 2.0 库具有广泛的应用范围,因为它们可以与任何大于 2.0 的 .NET 版本以及旧版的 .NET Framework 4.7 及以上版本一起工作。
由于我们的库不是通用库(它只是特定 .NET 8 应用程序的一个组件),我们不必选择 .NET Standard 库项目,而可以直接选择 .NET 8 库。我们的 .NET 8 库项目可以创建和准备如下:
-
打开 Visual Studio,点击 创建新项目 然后选择 类库。
-
将新项目命名为
WWTravelClubDB
并接受整个 Visual Studio 解决方案使用相同的名称。 -
在接下来的窗口中,选择 .NET 8 作为目标框架。
-
我们必须安装所有与 Entity Framework Core 相关的依赖项。安装所有必要依赖项的最简单方法是将我们打算使用的数据库引擎(在我们的例子中是 SQL Server)的 NuGet 包添加进来——正如我们在第十章,选择最佳云解决方案 中提到的。
-
实际上,任何提供程序都会安装所有必需的包,因为它将它们作为依赖项。所以,让我们添加最新稳定的
Microsoft.EntityFrameworkCore.SqlServer
版本。如果您打算使用多个数据库引擎,您也可以添加其他提供程序,因为它们可以并行工作。在本章的后面部分,我们将安装其他包含我们需要的工具的 NuGet 包来处理我们的 Entity Framework Core 配置。 -
让我们将默认的
Class1
类重命名为MainDbContext
。Class1
类会自动添加到类库中。现在,让我们用以下代码替换其内容:using System; using Microsoft.EntityFrameworkCore; namespace WWTravelClubDB { public class MainDbContext: DbContext { public MainDbContext(DbContextOptions options) : base(options) { } protected override void OnModelCreating(ModelBuilder builder) { } } }
-
我们从
DbContext
继承,并将DbContextOptions
传递给DbContext
构造函数。DbContextOptions
包含创建选项,例如数据库连接字符串,这取决于目标数据库引擎。 -
所有已映射到数据库表的集合都将作为
MainDbContext
的属性添加。映射配置将在重写的OnModelCreating
方法中定义,该方法通过参数传递的ModelBuilder
对象进行帮助。
下一步是创建代表表的所有类。这些被称为实体。我们需要为每个我们想要映射的数据库表创建一个实体类。让我们在项目根目录中创建一个Models
文件夹来存放它们。下一小节将解释如何定义所有必需的实体。
定义数据库实体
数据库设计,就像整个应用程序设计一样,是按迭代组织的(参见第一章,理解软件架构的重要性)。让我们假设在第一次迭代中,我们需要一个包含两个数据库表的原型:一个用于所有旅行套餐,另一个用于所有由套餐引用的位置。每个套餐只覆盖一个位置,而单个位置可能被多个套餐覆盖,因此这两个表通过一对一的关系连接。
因此,让我们从位置数据库表开始。正如我们在上一节末尾提到的,我们需要一个实体类来表示这个表的行。让我们称这个实体类为Destination
:
namespace WWTravelClubDB.Models
{
public class Destination
{
public int Id { get; set; }
public required string Name { get; set; }
public required string Country { get; set; }
public string? Description { get; set; }
}
}
在上面的代码中,所有数据库字段都必须由可读/写的 C#属性表示。由于Name
和Country
属性都是必需的,但我们没有定义构造函数,所以我们添加了required
关键字来指示编译器,每当创建一个未初始化它们的实例时,都会发出错误信号。
假设每个目的地就像一个城镇或地区,可以通过其名称和所在国家来定义,并且所有相关信息都包含在其Description
中。在未来迭代中,我们可能会添加更多字段。Id
是一个自动生成的键。
然而,现在,我们需要添加有关所有字段如何映射到数据库字段的信息。在 Entity Framework Core 中,所有原始类型都由特定于数据库引擎的提供程序自动映射到数据库类型(在我们的情况下,是 SQL Server 提供程序)。
我们唯一关心的是以下几点:
-
字符串长度限制:可以通过为每个字符串属性应用适当的
MaxLength
和MinLength
属性来考虑。所有对实体配置有用的属性都包含在System.ComponentModel.DataAnnotations
和System.ComponentModel.DataAnnotations.Schema
命名空间中。因此,将它们都添加到所有实体定义中是一个好的实践。 -
指定哪些字段是必需的,哪些是可选的:如果项目没有使用新的可空引用类型功能,则默认情况下,所有引用类型(例如所有字符串)都被假定为可选的,而所有值类型(例如数字和 GUID)都被假定为必需的。如果我们想使引用类型成为必需的,那么我们必须用
Required
属性来装饰它。另一方面,如果我们想使T
类型属性成为可选的,而T
是值类型,或者可空引用类型功能已开启,那么我们必须将T
替换为T?
。默认情况下,.NET 8 项目已启用新的可空引用类型功能。 -
指定哪个属性代表主键:可以通过用
Key
属性装饰一个属性来指定键。然而,如果没有找到Key
属性,则将名为Id
的属性(如果有的话)视为主键。在我们的情况下,不需要Key
属性。
由于每个目标都在一对一关系的一侧,它必须包含相关包实体的集合;否则,在 LINQ 查询的子句中引用相关实体将会很困难。这个集合在我们的 LINQ 查询中将扮演基本角色,并且将由 Entity Framework Core 填充。然而,正如我们将在本章后面看到的那样,在大多数数据库更新操作中必须忽略它。因此,为了避免编译器警告,最佳选项是将它们分配给 null 不可忽略的假默认值:null!
。
将所有内容组合起来,Destination
类的最终版本如下:
using System.Collections.Generic;
using System.ComponentModel.DataAnnotations;
using System.ComponentModel.DataAnnotations.Schema;
namespace WWTravelClubDB.Models
{
public class Destination
{
public int Id { get; set; }
[MaxLength(128)]
Public required string Name { get; set; }
[MaxLength(128)]
Public required string Country { get; set; }
public string? Description { get; set; }
public ICollection<Package> Packages { get; set; } = null!
}
}
由于Description
属性没有长度限制,它将使用不定长度的 SQL Server nvarchar(MAX)
字段实现。我们可以以类似的方式编写Package
类的代码:
using System;
using System.ComponentModel.DataAnnotations;
using System.ComponentModel.DataAnnotations.Schema;
namespace WWTravelClubDB.Models
{
public class Package
{
public int Id { get; set; }
[MaxLength(128)]
Public required string Name { get; set; }
[MaxLength(128)]
public string? Description { get; set; }
public decimal Price { get; set; }
public int DurationInDays { get; set; }
public DateTime? StartValidityDate { get; set; }
public DateTime? EndValidityDate { get; set; }
public Destination MyDestination { get; set; } = null!
public int DestinationId { get; set; }
}
}
每个包都有持续的天数,以及可选的开始和结束日期,这些日期是包报价有效的。MyDestination
通过它们与Destination
实体之间的一对多关系将包与其目的地连接起来,而DestinationId
是同一关系的外部键。
虽然指定外部键不是必需的,但这是一个好习惯,因为这是指定关系某些属性的唯一方式。例如,在我们的情况下,由于DestinationId
是int
(非可空类型),因此它是必需的。
因此,这里的关系是一对多,而不是(0,1)到多。将DestinationId
定义为int?
而不是int
将使一对多关系变成(0,1)到多关系。此外,正如我们将在本章后面看到的那样,有一个外键的显式表示大大简化了更新操作和一些查询。
在下一节中,我们将解释如何定义表示数据库表的内存集合。
定义映射的集合
一旦我们定义了所有表示数据库行的面向对象实体,我们需要定义表示数据库表的内存集合。正如我们在理解 ORM 基础部分中提到的,所有数据库操作都映射到这些集合的操作(本章的使用 Entity Framework Core 查询和更新数据部分解释了这一点)。对于每个实体T
,我们只需要在DbContext
中添加一个DbSet<T>
集合属性就足够了。通常,这些属性的名称是通过将实体名称复数化得到的。因此,我们需要向我们的MainDbContext
添加以下两个属性:
public DbSet<Package> Packages { get; set; }
public DbSet<Destination> Destinations { get; set; }
到目前为止,我们已经将数据库内容转换成了属性、类和数据注释。然而,Entity Framework 需要进一步的信息来与数据库交互。下一小节将解释如何提供这些信息。
完成映射配置
我们在实体定义中无法指定的映射配置信息必须通过基于流畅接口的配置代码添加。添加此类配置信息的最简单方法是通过使用OnModelCreating DbContext
方法。与实体T
相关的每一项配置信息都从builder.Entity<T>()
开始,然后通过调用一个指定此类约束的方法继续。进一步嵌套的调用指定约束的进一步属性。例如,我们的多对一关系可以配置如下:
builder.Entity<Destination>()
.HasMany(m => m.Packages)
.WithOne(m => m.MyDestination)
.HasForeignKey(m => m.DestinationId)
.OnDelete(DeleteBehavior.Cascade);
关系的两侧通过我们添加到实体的导航属性来指定。ForeignKey
指定外部键。最后,OnDelete
指定在删除目标时对包的处理。在我们的例子中,它执行与该目标相关的所有包的级联删除。
同样的配置也可以从关系的另一端开始定义,即从builder.Entity<Package>()
开始。显然,开发者必须只选择这两种选项之一:
builder.Entity<Package>()
.HasOne(m => m.MyDestination)
.WithMany(m => m.Packages)
.HasForeignKey(m => m.DestinationId)
.OnDelete(DeleteBehavior.Cascade);
唯一的区别是,由于我们从关系的另一端开始,之前的HasMany-WithOne
方法被HasOne-WithMany
方法所取代。在这里,我们也可以选择每个十进制属性在映射的数据库字段中的表示精度。默认情况下,十进制数由 18 位数字和 2 位小数表示。您可以使用类似以下内容为每个属性更改此设置:
...
.Property(m => m.Price)
.HasPrecision(10, 3);
ModelBuilder builder
对象允许我们使用如下方式指定数据库索引:
builder.Entity<T>()
.HasIndex(m => m.PropertyName);
多属性索引的定义如下:
builder.Entity<T>()
.HasIndex("propertyName1", "propertyName2", ...);
从版本 5 开始,索引也可以通过应用于类的属性来定义。以下是一个单属性索引的例子:
[Index(nameof(Property), IsUnique = true)]
public class MyClass
{
public int Id { get; set; }
[MaxLength(128)]
public string Property { get; set; }
}
以下是一个多属性索引的例子:
[Index(nameof(Property1), nameof(Property2), IsUnique = false)]
public class MyComplexIndexClass
{
public int Id { get; set; }
[MaxLength(64)]
public string Property1 { get; set; }
[MaxLength(64)]
public string Property2 { get; set; }
}
针对实体的特定配置选项也可以分组到单独的配置类中,每个实体一个:
internal class DestinationConfiguration :
IEntityTypeConfiguration<Destination>
{
public void Configure(EntityTypeBuilder<Destination> builder)
{
builder
.HasIndex(m => m.Country);
builder
.HasIndex(m => m.Name);
...
}
}
这些类必须实现IEntityTypeConfiguration<>
接口,该接口的唯一方法是Configure
。然后,可以使用类级别属性声明配置类:
[EntityTypeConfiguration(typeof(DestinationConfiguration))]
public class Destination
{
...
配置类也可以从上下文类的OnModelCreating
方法中调用:
new DestinationConfiguration()
.Configure(builder.Entity<Destination>());
还可以通过以下方式添加在程序集中定义的所有配置信息:
builder.ApplyConfigurationsFromAssembly(typeof(MainDbContext).Assembly);
如果我们添加所有必要的配置信息,那么我们的OnModelCreating
方法将如下所示:
protected override void OnModelCreating(ModelBuilder builder)
{
builder.Entity<Destination>()
.HasMany(m => m.Packages)
.WithOne(m => m.MyDestination)
.HasForeignKey(m => m.DestinationId)
.OnDelete(DeleteBehavior.Cascade);
new DestinationConfiguration()
.Configure(builder.Entity<Destination>());
new PackageConfiguration()
.Configure(builder.Entity<Package>());
}
与两个配置类一起:
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Metadata.Builders;
namespace WWTravelClubDB.Models
{
internal class DestinationConfiguration :
IEntityTypeConfiguration<Destination>
{
public void Configure(EntityTypeBuilder<Destination> builder)
{
builder
.HasIndex(m => m.Country);
builder
.HasIndex(m => m.Name);
}
}
}
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Metadata.Builders;
namespace WWTravelClubDB.Models
{
internal class PackageConfiguration : IEntityTypeConfiguration<Package>
{
public void Configure(EntityTypeBuilder<Package> builder)
{
builder
.HasIndex(m => m.Name);
builder
.HasIndex(nameof(Package.StartValidityDate),
nameof(Package.EndValidityDate));
}
}
}
我更喜欢在上下文类中定义仅通用的配置和关系。使用数据注释仅用于限制属性值(最大和最小长度、必填字段等)也很方便。这样,实体不依赖于特定 ORM 的使用,如果需要,可以将其导出在数据层之外。
其他实体特定配置的最佳位置是配置类。我也避免使用EntityTypeConfiguration
属性,并在上下文类内部调用实体配置类,因为这个属性将实体绑定到特定的 ORM。
之前的示例展示了单向多对多的关系,但 Entity Framework Core 8 也支持多对多关系:
modelBuilder
.Entity<Teacher>()
.HasMany(e => e.Classrooms)
.WithMany(e => e.Teachers)
在前面的例子中,连接实体和数据库连接表是自动创建的,但你也可以指定一个现有的实体作为连接实体。在先前的例子中,连接实体可能是教师在每个教室教授的课程:
modelBuilder
.Entity<Teacher>()
.HasMany(e => e.Classrooms)
.WithMany(e => e.Teachers)
.UsingEntity<Course>(
b => b.HasOne(e => e.Teacher).WithMany()
.HasForeignKey(e => e.TeacherId),
b => b.HasOne(e => e.Classroom).WithMany()
.HasForeignKey(e => e.ClassroomId));
一旦配置了 Entity Framework Core,我们就可以使用我们拥有的所有配置信息来创建实际的数据库,并将所有需要的工具放置到位,以便随着应用程序的发展更新数据库的结构。下一节将解释如何操作。
Entity Framework Core 迁移
现在我们已经配置了 Entity Framework 并定义了我们的应用程序特定的DbContext
子类,我们可以使用 Entity Framework Core 设计工具生成物理数据库并创建实体框架核心与数据库交互所需的数据库结构快照。
Entity Framework Core 设计工具必须作为 NuGet 包安装在每个需要它们的项目中。有两种等效选项:
-
在任何操作系统控制台中工作的工具:这些工具可以通过
Microsoft.EntityFrameworkCore.Design
NuGet 包获取。所有 Entity Framework Core 命令都采用dotnet ef ......
格式,因为它们包含在ef
命令行.NET Core 应用程序中。 -
特定于 Visual Studio 包管理器控制台的工具:这些工具包含在
Microsoft.EntityFrameworkCore.Tools
NuGet 包中。它们不需要dotnet ef
前缀,因为它们只能从 Visual Studio 中的包管理器控制台启动。
Entity Framework Core 的设计工具用于设计/更新过程中。该过程如下:
-
我们根据需要修改
DbContext
和实体定义。 -
我们启动设计工具,要求 Entity Framework Core 检测和处理我们所做的所有更改。
-
一旦发布,设计工具将更新数据库结构快照并生成一个新的迁移,即一个包含所有我们需要修改物理数据库以反映我们所做所有更改的指令的文件。
-
我们启动另一个工具来更新数据库,使用新创建的迁移。
-
我们测试新配置的数据库层,如果需要新的更改,我们回到步骤 1。
-
当数据层准备就绪时,它将在预发布或生产环境中部署,此时所有迁移将再次应用到实际的预发布/生产数据库中。
这在各种软件项目迭代和应用程序的生命周期中重复多次。
如果我们在一个已经存在的数据库上操作,我们需要配置DbContext
及其模型以反映我们想要映射的所有表的现有结构。这可以通过Scaffold-DbContext
命令自动完成(有关更多详细信息,请参阅learn.microsoft.com/en-us/ef/core/managing-schemas/scaffolding/?tabs=vs
)。
由 .NET 生成的所有类都是部分类,因此用户可以通过向具有相同名称的部分类添加新方法来丰富它们,而无需修改构建的类。
然后,如果我们想开始使用迁移而不是继续直接修改数据库,我们可以使用IgnoreChanges
选项调用设计工具,以便它们生成一个空迁移。此外,这个空迁移必须传递到物理数据库,以便它可以同步与物理数据库关联的数据库结构版本与数据库快照中记录的版本。这个版本很重要,因为它决定了哪些迁移必须应用到数据库以及哪些已经应用。
然而,开发者也可以选择继续手动修改数据库,并在每次更改后重复构建操作。
整个设计过程需要一个测试/设计数据库,如果我们操作现有的数据库,这个测试/设计数据库的结构必须反映实际数据库——至少在我们想要映射的表方面。为了使设计工具能够与数据库交互,我们必须定义它们传递给DbContext
构造函数的DbContextOptions
选项。这些选项在设计时很重要,因为它们包含测试/设计数据库的连接字符串。如果我们创建一个实现IDesignTimeDbContextFactory<T>
接口的类,其中T
是我们的DbContext
子类,设计工具就可以了解我们的DbContextOptions
选项:
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Design;
namespace WWTravelClubDB
{
public class LibraryDesignTimeDbContextFactory
: IDesignTimeDbContextFactory<MainDbContext>
{
private const string connectionString =
@"Server=(localdb)\mssqllocaldb;Database=wwtravelclub;
Trusted_Connection=True;MultipleActiveResultSets=true";
public MainDbContext CreateDbContext(params string[] args)
{
var builder = new DbContextOptionsBuilder<MainDbContext>();
builder.UseSqlServer(connectionString);
return new MainDbContext(builder.Options);
}
}
}
connectionString
将由 Entity Framework 用于在开发机器上安装的本地 SQL Server 实例中创建一个新的数据库,并使用 Windows 凭据进行连接。您可以根据需要自由更改它。
现在,我们已经准备好创建我们的第一个迁移了!让我们开始吧:
-
让我们转到包管理器控制台并确保WWTravelClubDB被选为我们的默认项目。
-
现在,输入
Add-Migration initial
并按Enter键执行此命令。在执行此命令之前,请验证您已添加了Microsoft.EntityFrameworkCore.Tools
NuGet 包;否则,您可能会收到“未识别的命令”错误:图 13.1:添加第一个迁移
initial
是我们给第一个迁移起的名字。所以,一般来说,命令是Add-Migration <迁移名称>
。当我们操作现有数据库时,我们必须将-IgnoreChanges
选项添加到第一个迁移(仅针对该迁移)以创建一个空迁移。有关所有命令的引用可以在进一步阅读部分找到。 -
在创建迁移之后但在将迁移应用到数据库之前,如果我们意识到自己犯了一些错误,我们可以使用
Remove-Migration
命令撤销我们的操作。如果迁移已经被应用到数据库中,纠正我们错误的最简单方法是对代码进行所有必要的修改,然后应用另一个迁移。 -
一旦执行了
Add-Migration
命令,我们的项目中就会出现一个新的文件夹:
图 13.2:Add-Migration 命令创建的文件
20210924143018_initial.cs
是我们用易于理解的语言表达的迁移。
您可以检查代码以验证一切是否正常。您还可以修改迁移内容(只有当您足够专业才能可靠地完成时),或者简单地使用in
Remove-Migration Remove-Migration
命令撤销迁移,这是我们发现错误时建议的进行方式。
每个迁移都包含一个Up
方法和一个Down
方法。
Up
方法表示迁移,而Down
方法则撤销其更改。相应地,Down
方法包含Up
方法中包含的所有操作的逆序操作。
20210924143018_initial.Designer.cs
是 Visual Studio 设计器代码,您必须不要修改它,而MainDbContextModelSnapshot.cs
是整体数据库结构快照。如果您添加了进一步的迁移,新的迁移文件及其设计器对应文件将出现,并且唯一的MainDbContextModelSnapshot.cs
数据库结构快照将被更新以反映数据库的整体结构。
同样的命令可以通过在操作系统控制台中输入 dotnet ef migrations add initial
来发出。此命令必须在项目的根目录内(而不是在解决方案的根目录内)发出。
然而,如果使用 dotnet tool install --global dotnet-ef
全局安装了 Microsoft.EntityFrameworkCore.Design
,那么我们可以在将其添加到项目后通过输入 dotnet add package –-project <project path> Microsoft.EntityFrameworkCore.Design
在项目中使用它。在这种情况下,可以通过指定 --project <project path>
选项从任何文件夹中发出命令。
可以通过在包管理器控制台中输入 Update-Database
来将迁移应用到数据库。等效的控制台命令是 dotnet ef database update
。让我们尝试使用此命令来创建物理数据库!
下一个子节解释了如何创建实体框架无法自动创建的数据库内容。在那之后,在下一节中,我们将使用实体框架的配置和通过 dotnet ef database update
生成的数据库来创建、查询和更新数据。
理解存储过程和直接 SQL 命令
一些数据库结构,例如存储过程,无法通过我们之前描述的实体框架核心命令和声明自动生成。例如,可以将通用 SQL 字符串通过 migrationBuilder.Sql("<sql command>")
方法手动包含在 Up
和 Down
方法中。
做这件事最安全的方法是添加一个迁移,而不进行任何配置更改,这样在创建迁移时它就是空的。然后,我们可以将必要的 SQL 命令添加到这个迁移的空 Up
方法中,以及它们的逆命令在空 Down
方法中。将所有 SQL 字符串放在资源文件的属性(.resx
文件)中是一种良好的做法。
在以下情况下,存储过程应该替换 Entity Framework
命令:
-
当我们需要手动进行 SQL 优化以提高某些操作的性能时。
-
当
Entity Framework
不支持我们需要的 SQL 操作时。一个典型的例子是在所有预订操作(航空旅行、酒店等)中发生的数值字段的增加或减少。实际上,我们可能用数据库读取、内存中的增加/减少,最后在同一个事务范围内进行数据库更新来替换增加/减少操作。然而,这可能对性能来说过于冗余。
在开始与我们的数据库交互之前,我们可以执行一个进一步的可选步骤:模型优化。
编译后的模型
从版本 6 开始,Entity Framework Core 引入了创建预编译数据结构的功能,这可以通过将数据层项目编译在一起来提高 Entity Framework Core 的性能,对于具有数百个实体的模型,性能可以提高约 10 倍(有关更多详细信息,请参阅进一步阅读部分的参考)。此步骤是通过生成一些代码来完成的,这些代码一旦编译,就可以创建我们的上下文类可以使用以提高性能的数据结构。
建议在验证系统出现减速后以及非常简单的查询后使用预编译。换句话说,最好是先不使用预编译,然后在由于 EF 基础设施引起的减速情况下可能添加它。
代码是通过我们已安装的Microsoft.EntityFrameworkCore.Tool
NuGet 包提供的Optimize-DbContext
命令生成的。该命令接受放置代码的文件夹名称和放置所有类的命名空间。在我们的情况下,让我们选择Optimization
文件夹和WWTravelClubDB.Optimization
命名空间:
Optimize-DbContext -Context MainDBContext -OutputDir Optimization -Namespace WWTravelClubDB.Optimization
在这里,必须将–Context
参数传递给我们的上下文类名。Optimization
文件夹会自动创建并填充类。
优化代码依赖于 ORM 配置,因此每次创建新的迁移时,都必须重复执行Optimize-DbContext
命令。
通过在创建上下文类实例时将我们的优化模型根作为选项传递,可以启用优化。让我们打开LibraryDesignTimeDbContextFactory.cs
文件并添加以下行:
builder.UseSqlServer(connectionString);
//Line to add. Add it after that the optimization model has been created
builder.UseModel(Optimization.MainDbContextModel.Instance);
return new MainDbContext(builder.Options);
现在,你已准备好通过 Entity Framework Core 与数据库进行交互。
使用 Entity Framework Core 查询和更新数据
为了测试我们的数据库层,我们需要将一个基于与我们的库相同.NET Core 版本的控制台项目添加到解决方案中。让我们开始吧:
-
让我们将新的控制台项目命名为
WWTravelClubDBTest
。 -
现在,我们需要通过右键点击控制台项目的依赖项节点并选择添加项目引用,将我们的数据层作为控制台项目的依赖项添加。
-
删除
Program.cs
文件的内容,并开始编写以下内容:Console.WriteLine("program start: populate database, press a key to continue"); Console.ReadKey();
-
然后,在文件顶部添加以下命名空间:
using WWTravelClubDB; using WWTravelClubDB.Models; using Microsoft.EntityFrameworkCore; using WWTravelClubDBTest;
现在我们已经完成了测试项目的准备工作,我们可以对查询和数据更新进行实验。让我们首先创建一些数据库对象,即一些目的地和包。按照以下步骤操作:
-
首先,我们必须使用适当的连接字符串创建我们的
DbContext
子类的一个实例。我们可以使用设计工具使用的相同的LibraryDesignTimeDbContextFactory
类来获取它:var context = new LibraryDesignTimeDbContextFactory() .CreateDbContext();
-
通过简单地将类实例添加到我们的
DbContext
子类的映射集合中,可以创建新行。如果一个Destination
实例与包相关联,我们可以简单地将其添加到其Packages
属性中:var firstDestination= new Destination { Name = "Florence", Country = "Italy", Packages = new List<Package>() { new Package { Name = "Summer in Florence", StartValidityDate = new DateTime(2019, 6, 1), EndValidityDate = new DateTime(2019, 10, 1), DurationInDays=7, Price=1000 }, new Package { Name = "Winter in Florence", StartValidityDate = new DateTime(2019, 12, 1), EndValidityDate = new DateTime(2020, 2, 1), DurationInDays=7, Price=500 } } }; context.Destinations.Add(firstDestination); await context.SaveChangesAsync(); Console.WriteLine( $"DB populated: first destination id is {firstDestination.Id}"); Console.ReadKey();
没有必要指定主键,因为它们是自动生成的,将由数据库填充。实际上,在 SaveChangesAsync()
操作将我们的上下文与实际数据库同步后,firstDestination.Id
属性有一个非零值。对于 Package
的主键也是如此。对于所有整数类型,键自动生成是默认行为。
当我们通过将其插入父实体集合(在我们的例子中,是 Packages
集合)来声明一个实体(在我们的例子中,是 Package
)是另一个实体(在我们的例子中,是 Destination
)的子实体时,不需要显式设置其外部键(在我们的例子中,是 DestinationId
),因为 Entity Framework Core 会自动推断它。一旦创建并与 firstDestination
数据库同步,我们可以通过两种不同的方式添加更多的包:
-
创建一个
Package
类实例,将其DestinationId
外部键设置为firstDestination.Id
,然后将其添加到context.Packages
-
创建一个
Package
类实例,无需设置其外部键,然后将其添加到其父Destination
实例的Packages
集合中
当两个实体不属于同一聚合时,应首选后者方法,因为在这种情况下,在操作之前必须将整个聚合加载到内存中,以确保所有业务规则都得到正确应用,并防止由于对同一聚合的不同部分同时操作而引起的矛盾(参见 第七章,理解软件解决方案的不同领域 中的 聚合 子节)。
此外,当子实体(Package
)与其父实体(Destination
)一起添加,并且父实体有一个自动生成的主键时,后者选项是唯一可能的选择,因为在这种情况下,外部键在我们执行添加操作时是不可用的。
在大多数其他情况下,前者选项更简单,因为后者选项需要将父 Destination
实体及其 Packages
集合(即与 Destination
对象关联的所有包)加载到内存中,也就是说,在操作之前必须确保整个聚合加载到内存中,以确保所有业务规则都得到正确应用,并防止由于对同一聚合的不同部分同时操作而引起的矛盾(参见 第七章,理解软件解决方案的不同领域 中的 聚合 子节)。
现在,假设我们想要修改 Florence
目的地,并将所有 Florence
包的价格增加 10%。我们该如何操作?按照以下步骤了解如何操作:
-
首先,注释掉所有用于填充数据库的先前指令,同时保留创建
DbContext
的指令:Console.WriteLine("program start: populate database, press a key to continue"); Console.ReadKey(); var context = new LibraryDesignTimeDbContextFactory() .CreateDbContext(); //var firstDestination = new Destination //{ // Name = "Florence", // Country = "Italy", // Packages = new List<Package>() // { // new Package // { // Name = "Summer in Florence", // StartValidityDate = new DateTime(2019, 6, 1), // EndValidityDate = new DateTime(2019, 10, 1), // DurationInDays=7, // Price=1000 // }, // new Package // { // Name = "Winter in Florence", // StartValidityDate = new DateTime(2019, 12, 1), // EndValidityDate = new DateTime(2020, 2, 1), // DurationInDays=7, // Price=500 // } // } //}; //context.Destinations.Add(firstDestination); //await context.SaveChangesAsync(); //Console.WriteLine( // $"DB populated: first destination id is {firstDestination.Id}"); //Console.ReadKey();
-
然后,我们需要通过查询将实体加载到内存中,修改它,并调用
await SaveChangesAsync()
以将我们的更改与数据库同步。 -
如果我们只想修改其描述,如下的查询就足够了:
var toModify = await context.Destinations .Where(m => m.Name == "Florence").FirstOrDefaultAsync();
-
我们需要加载所有默认未加载的相关目的地包。这可以通过以下
Include
子句完成:var toModify = await context.Destinations .Where(m => m.Name == "Florence") .Include(m => m.Packages) .FirstOrDefaultAsync();
-
之后,我们可以修改描述和包价格,如下所示:
toModify.Description = "Florence is a famous historical Italian town"; foreach (var package in toModify.Packages) package.Price = package.Price * 1.1m; await context.SaveChangesAsync(); var verifyChanges= await context.Destinations .Where(m => m.Name == "Florence") .FirstOrDefaultAsync(); Console.WriteLine( $"New Florence description: {verifyChanges.Description}"); Console.ReadKey();
如果使用Include
方法包含的实体本身包含我们想要包含的嵌套集合,我们可以使用ThenInclude
,如下所示:
.Include(m => m.NestedCollection)
.ThenInclude(m => m.NestedNestedCollection)
由于 Entity Framework 始终尝试将每个 LINQ 转换为单个 SQL 查询,有时生成的查询可能过于复杂和缓慢。在这种情况下,从版本 5 开始,我们可以允许 Entity Framework 将 LINQ 查询拆分为多个 SQL 查询,如下所示:
.AsSplitQuery().Include(m => m.NestedCollection)
.ThenInclude(m => m.NestedNestedCollection)
通过检查 LINQ 查询生成的 SQL,可以使用ToQueryString
方法来解决问题:
var mySQL = myLinQQuery.ToQueryString ();
从版本 5 开始,包含的嵌套集合也可以使用Where
进行过滤,如下所示:
.Include(m => m.Packages.Where(l-> l.Price < x))
到目前为止,我们执行了查询,其唯一目的是更新检索到的实体。接下来,我们将解释如何检索将显示给用户和/或用于复杂业务操作的信息。
将数据返回到表示层
为了保持层之间的分离并使查询适应每个用例实际需要的数据,DB 实体不会以原样发送到表示层。相反,数据被投影到包含所需信息的较小类中,这些类由表示层的caller
方法实现。在层之间移动数据的对象被称为数据传输对象(DTOs)。
例如,让我们创建一个 DTO,其中包含在向用户返回包列表时值得显示的摘要信息(我们假设如果需要,用户可以通过点击他们感兴趣的包来获取更多详细信息):
-
让我们在
WWTravelClubDBTest
项目中添加一个 DTO,其中包含需要在包列表中显示的所有信息:namespace WWTravelClubDBTest { public record PackagesListDTO { public int Id { get; init; } public required string Name { get; init; } public decimal Price { get; init; } public int DurationInDays { get; init; } public DateTime? StartValidityDate { get; init; } public DateTime? EndValidityDate { get; init; } public required string DestinationName { get; init; } public int DestinationId { get; init; } public override string ToString() { return string.Format("{0}. {1} days in {2}, price: {3}", Name, DurationInDays, DestinationName, Price); } } }
-
我们不需要在内存中加载实体然后将它们的数据复制到 DTO 中,但数据库数据可以直接投影到 DTO 中,这得益于 LINQ 的
Select
子句。这最小化了与数据库交换的数据量。 -
例如,我们可以使用一个查询来填充我们的 DTO,该查询检查所有 8 月 10 日左右可用的包:
var period = new DateTime(2019, 8, 10); var list = await context.Packages .Where(m => period >= m.StartValidityDate && period <= m.EndValidityDate) .Select(m => new PackagesListDTO { StartValidityDate=m.StartValidityDate, EndValidityDate=m.EndValidityDate, Name=m.Name, DurationInDays=m.DurationInDays, Id=m.Id, Price=m.Price, DestinationName=m.MyDestination.Name, DestinationId = m.DestinationId }) .ToListAsync(); foreach (var result in list) Console.WriteLine(result.ToString()); Console.ReadKey();
-
在
Select
子句中,我们还可以导航到任何相关实体以获取所需的数据。例如,前面的查询导航到相关的Destination
实体以获取Package
目的地的名称。 -
程序在每个
Console.ReadKey()
方法处停止,等待您按任意键。这样,您就有时间分析所有添加到Main
方法的代码片段所产生的输出。 -
现在,在解决方案资源管理器中右键单击
WWTravelClubDBTest
项目,将其设置为启动项目。然后,运行解决方案。
现在,我们将学习如何处理无法有效地映射到表示数据库表的内存集合中直接操作的运算。
直接发出 SQL 命令
并非所有数据库操作都可以通过使用 LINQ 查询数据库并在内存中更新实体来高效执行。例如,计数器增加可以通过单个 SQL 指令更高效地执行。此外,如果我们定义了适当的存储过程/SQL 命令,一些操作可以以可接受的性能执行。在这些情况下,我们被迫直接向数据库发出 SQL 命令或从我们的 Entity Framework 代码中调用数据库存储过程。有两种可能性:执行数据库操作但不返回实体的 SQL 语句,以及返回实体的 SQL 语句。
不返回实体的 SQL 命令可以使用以下方式通过 DbContext
方法执行:
Task<int> DbContext.Database.ExecuteSqlRawAsync(string sql, params object[] parameters)
参数可以在字符串中以 {0}, {1}, ..., {n}
的形式引用。每个 {m}
都用包含在 parameters
数组中 m
索引处的对象填充,该对象从 .NET 类型转换为相应的 SQL 类型。该方法返回受影响的行数。
返回实体集合的 SQL 命令必须通过与那些实体关联的映射集合的 FromSqlRaw
方法发出:
context.<mapped collection>.FromSqlRaw(string sql, params object[] parameters)
因此,例如,一个返回 Package
实例的命令看起来可能如下所示:
var results = await context.Packages.FromSqlRaw("<some sql>", par1, par2, ...).ToListAsync();
在 ExecuteSqlRaw
方法中,SQL 字符串和参数是这样工作的。以下是一个简单的示例:
var allPackages = await context.Packages.FromSqlRaw(
"SELECT * FROM Products WHERE Name = {0}",
myPackageName).ToListAsync();
我们还可以使用字符串插值:
var allPackages = await context.Packages.FromSqlRaw(
$"SELECT * FROM Products WHERE Name = {myPackageName}").ToListAsync();
如果 SQL 查询要返回的对象不是添加到 DB 上下文中的集合所表示的映射对象,例如 context.Packages
,我们可以使用 .NET 7 中添加的新方法:
IQueryable<TResult> DbContext.Database.SqlQueryRaw<TResult> (string sql, params object[] parameters)
SqlQueryRaw
是 DbContext
对象的 Database
属性的一个方法,它接受要返回的对象的类作为泛型参数 (TResult
)。然而,在这种情况下,Entity Framework Core 能够将数据库返回的 SQL 元组转换为对象,前提是元组中的列名与 TResult
中的属性名相同。可以通过使用 [Column("<column name")]
属性装饰这些属性来克服某些属性中的名称不匹配问题,这些属性必须映射到它们必须从中映射的列的名称。这也可以使用流畅的 API 配置实现:.HasColumnName(<column name")
。
将所有 SQL 字符串放入资源文件中,并将所有 ExecuteSqlRawAsync
和 FromSqlRaw
调用封装在您在 DbContext
子类中定义的公共方法内,这是一种良好的实践,以保持对特定数据库的依赖性在您的基于 Entity Framework Core 的数据层内部。
处理事务
对DbContext
实例所做的所有更改都在第一次SaveChangesAsync
调用时作为一个单一的事务传递。然而,有时有必要在同一个事务中包含查询和更新。在这些情况下,我们必须显式处理事务。如果我们将几个 Entity Framework Core 命令放在与事务对象关联的using
块内部,那么这些命令可以包含在一个事务中:
using (var dbContextTransaction = context.Database.BeginTransaction())
try{
...
...
dbContextTransaction.Commit();
}
catch
{
dbContextTransaction.Rollback();
}
在前面的代码中,context
是我们DbContext
子类的实例。在using
块内部,可以通过调用其Rollback
和Commit
方法来中止和提交事务。任何包含在事务块中的SaveChanges
调用都将使用它们已经所在的同一个事务,而不是创建新的事务。
部署数据层
当数据库层在生产或预发布环境中部署时,通常已经存在一个空数据库,因此你必须应用所有迁移以创建所有数据库对象。这可以通过调用context.Database.Migrate()
来完成。Migrate
方法应用尚未应用到数据库中的迁移,因此它可以在应用程序的生命周期中安全地多次调用。
context
是我们DbContext
类的实例,必须通过一个具有足够权限创建表和执行我们迁移中包含的所有操作的连接字符串来传递。因此,通常,这个连接字符串与我们在正常应用程序操作中使用的字符串不同。
在将 Web 应用程序部署到 Azure 的过程中,我们有权限使用我们提供的连接字符串来检查迁移。我们还可以在应用程序启动时通过调用context.Database.Migrate()
方法手动检查迁移。这将在第十八章“使用 ASP.NET Core 实现前端微服务”中详细讨论,该章节专门介绍基于 ASP.NET Core MVC 的前端应用程序。
在某些生产环境中,由于负责部署的人员没有足够的权限创建新的数据库和/或创建和修改表,因此无法在应用程序部署期间应用迁移。在这种情况下,我们必须将迁移转换为 SQL 命令,并将它们传递给数据库管理员。数据库管理员在确认这些迁移不会损坏现有数据库和数据后,将它们应用。
我们可以使用Script-Migration
命令(见learn.microsoft.com/en-us/ef/core/cli/powershell
)将给定时间间隔内的所有迁移转换为 SQL:
Script-Migration -From <initial migration> -To <final migration> -Output <name of output file>
对于桌面应用程序,我们可以在应用程序安装及其后续更新期间应用迁移。
在第一次应用程序安装和/或后续应用程序更新时,我们可能需要用初始数据填充一些表。对于 Web 应用程序,此操作可以在应用程序启动时执行,而对于桌面应用程序,此操作可以包含在安装过程中。
数据库表可以使用 Entity Framework Core 命令进行填充。首先,我们需要验证表是否为空,以避免多次添加相同的表行。这可以通过以下代码中的 Any()
LINQ 方法完成:
if(!context.Destinations.Any())
{
//populate here the Destinations table
}
让我们看看 Entity Framework Core 有哪些高级功能可以分享。
数据和领域层如何与其他层通信
如在第七章“理解软件解决方案中的不同领域”中所述,经典层架构使用普通对象和存储库与其他层进行通信。
因此,定义 Entity Framework Core 配置的实体可以直接用于与其他层通信,因为它们只是按照普通对象的要求定义的具有公共属性的记录列表。
领域层和洋葱架构的情况稍微复杂一些,因为在这种情况下,领域层通过具有表示应用程序领域规则的方法的丰富对象与应用程序层进行通信。因此,通常,应用程序的其余部分无法访问所有领域层对象的属性,而是被迫通过它们自己的方法来修改它们,以强制执行领域规则。
换句话说,Entity Framework 实体是具有几乎无方法的记录列表的公共属性,而 DDD 实体应该具有编码领域逻辑、更复杂的验证逻辑和只读属性的方法。虽然可以添加进一步的验证逻辑和方法而不会破坏 Entity Framework 的操作,但添加必须映射到数据库属性之外的只读属性可能会引起必须适当处理的问题。防止属性映射到数据库相当简单——我们只需要用 NotMapped
属性装饰它们,或者使用流畅的 API,如 .Ignore(e => e.PropertyName).
只读属性存在的问题稍微复杂一些,可以通过三种基本方法解决:
-
将 Entity Framework 实体映射到不同的类:将 DDD 实体定义为不同的类,并在实体返回/传递到存储库方法时,在它们之间复制数据。这是一个最简单的解决方案,但需要编写一些代码以便在两种格式之间转换实体。DDD 实体在领域层中定义,而 Entity Framework 实体继续在数据层中定义。这是一个更干净的解决方案,但会在代码编写和维护方面造成非微不足道的开销。当您有包含多个复杂方法的复杂聚合时,我建议使用它。
-
将表字段映射到私有属性:让 Entity Framework Core 将字段映射到私有类字段,这样你就可以通过编写自定义的 getter 和/或 setter 来决定如何将它们暴露给属性。只需给出
_<属性名>
名称或_<驼峰式属性名>
名称给这些私有字段,Entity Framework 就会使用它们而不是它们关联的属性。在这种情况下,领域层中定义的 DDD 实体也被用作数据层实体。这种方法的缺点主要是我们无法使用数据注释来配置每个属性,因为 DDD 实体不能依赖于底层数据层的实现方式。因此,我们必须在
OnModelCreating
DbContext
方法中,或者在关联每个实体的配置类中配置所有数据库映射(参见 第十三章,在 C#中使用 Entity Framework Core 与数据交互)。在我看来,这两种选项的可读性都不如数据注释,所以我并不喜欢这种技术,但其他专业人士却在使用它。 -
通过接口隐藏 Entity Framework 实体:将每个具有所有公共属性的 Entity Framework 实体隐藏在接口后面,该接口在需要时仅公开属性 getter。实体被定义为
internal
,这样外部层就可以通过它们实现的接口来访问它们。这样,我们可以强制使用实现业务逻辑规则的方法来修改实体属性。此外,DbContext
也被定义为internal
,因此可以通过它实现的IUnitOfWork
接口从外部层访问。接口可以在不同的库中定义,以更好地与外部层解耦。从洋葱架构的角度来看,定义所有接口的库是 Entity Framework 层外的一层。像往常一样,接口与其实现都在依赖注入引擎中耦合。当存在多个简单实体时,这是我更喜欢的解决方案。
假设我们想要为Destination
实体定义一个 DDD 接口,称为IDestination
,并且假设我们希望将Id
、Name
和Country
属性公开为只读,因为一旦创建了一个目的地,它就不能再修改了。在这里,让Destination
实现IDestination
并在IDestination
中将Id
、Name
、Country
和Description
定义为只读就足够了:
public interface IDestination
{
int Id { get; }
string Name { get; }
string Country { get; }
string Description { get; set; }
...
}
DDD 与洋葱架构之间的另一个区别是,在经典数据层中,所有对数据的操作和查询都作为仓库方法公开,而在领域层中,仓库方法仅编码创建、删除和查询,而修改操作由丰富的领域层类的方法执行。
领域层实现的完整示例在 第十八章,使用 ASP.NET Core 实现前端微服务 中描述。
理解 Entity Framework Core 高级功能
值得注意的是,Entity Framework 的高级特性之一是全球过滤器,它们是在 2017 年底引入的。它们使诸如软删除和多租户表等技术成为可能,这些技术被多个用户共享,其中每个用户只是 看到 自己的记录。
全局过滤器是通过 modelBuilder
对象定义的,该对象在 DbContext
的 OnModelCreating
方法中可用。此方法的语法如下:
modelBuilder.Entity<MyEntity>().HasQueryFilter(m => <define filter condition here>);
例如,如果我们向我们的 Package
类添加一个 IsDeleted
属性,我们可能可以通过定义以下过滤器来软删除一个 Package
而不将其从数据库中删除:
modelBuilder.Entity<Package>().HasQueryFilter(m => !m.IsDeleted);
然而,过滤器包含 DbContext
属性。因此,例如,如果我们向我们的 DbContext
子类(其值在创建 DbContext
实例时设置)添加一个 CurrentUserID
属性,那么我们可以向所有引用用户 ID 的实体添加如下过滤器:
modelBuilder.Entity<Document>().HasQueryFilter(m => m.UserId == CurrentUserId);
在前面的过滤器设置后,当前登录的用户只能访问他们拥有的文档(具有其 UserId
的那些文档)。类似的技术在多租户应用程序的实现中非常有用。
另一个值得提到的有趣特性是将实体映射到不可更新的数据库查询,该特性是在版本 5 中引入的。
当你定义一个实体时,你可以明确地定义映射的数据库表名称或映射的可更新视图名称:
modelBuilder.Entity<MyEntity1>().ToTable("MyTable");
modelBuilder.Entity<MyEntity2>().ToView("MyView");
当一个实体映射到视图时,数据库迁移不会生成任何表,因此数据库视图必须由开发者手动定义。
如果我们想要映射实体的视图不可更新,LINQ 不能使用它来传递更新到数据库。在这种情况下,我们可以同时将相同的实体映射到视图和表中:
modelBuilder.Entity<MyEntity>().ToTable("MyTable").ToView("MyView");
Entity Framework 将使用视图进行查询,使用表进行更新。这在我们要创建数据库表的较新版本,但希望所有查询都从旧版本表中获取数据时很有用。在这种情况下,我们可能定义一个视图,该视图从旧表和新表中获取数据,但只将所有更新传递到新表中。
设置属性默认值的选项也很有趣。这可以通过指定 ConfigureConventions
DbContext
方法的覆盖来实现。例如,我们可以为所有十进制属性设置默认精度:
protected override void ConfigureConventions(
ModelConfigurationBuilder configurationBuilder)
{
configurationBuilder.Properties<decimal>()
.HavePrecision(10, 3);
...
}
为所有字符串属性指定默认最大长度也很有用:
protected override void ConfigureConventions(
ModelConfigurationBuilder configurationBuilder)
{
configurationBuilder.Properties<decimal>()
.HavePrecision(10, 3);
configurationBuilder.Properties<string>()
.HaveMaxLength(128);
...
}
另一个在 Entity Framework Core 的最新版本中逐渐引入的有趣特性是映射 JSON 数据库列。特别是,可以查询单个 JSON 列中包含的 JSON 数据,以及将 JSON 对象映射到 .NET 类型。
假设有一个必须映射到数据库表的 Author
类,它有一个包含复杂对象的 Contact
属性,并且假设我们希望将此对象存储在 Author
表的数据库 JSON 列中。我们可以使用 OwnsOne
配置方法来完成此操作,如下所示:
modelBuilder.Entity<Author>().OwnsOne(
author => author.Contact, ownedNavigationBuilder =>
{
ownedNavigationBuilder.ToJson();
});
在此配置之后,可以像标准导航属性一样查询 contact
对象:
context.Authors
.Where(m => m.Contact.Email == searchEmail).FirstOrDefaultAsync();
我们还可以递归地映射 contact
对象中包含的子对象,例如地址集合,但在此情况下,我们必须使用 OwnsMany
:
modelBuilder.Entity<Author>().OwnsOne(
author => author.Contact, ownedNavigationBuilder =>
{
ownedNavigationBuilder.ToJson();
ownedNavigationBuilder.OwnsMany(
contactDetails => contactDetails.Addresses);
});
之后,可以使用常规 LINQ 语法查询嵌套的 Addresses JSON 集合:
context.Authors
.Where(m => m.Contact.Addresses.Any()).FirstOrDefaultAsync();
上述查询返回所有具有非空地址列表的作者。
摘要
在本章中,我们探讨了 ORM 基础的要点以及为什么它们如此有用。然后,我们描述了 Entity Framework Core。特别是,我们讨论了如何使用类注解和其他包含在 DbContext
子类以及与每个实体关联的配置类中的声明和命令来配置数据库映射。
然后,我们讨论了如何创建数据结构以改进 ORM 性能,以及如何通过迁移自动创建和更新物理数据库,以及如何通过 Entity Framework Core 查询和传递数据库更新。最后,我们学习了如何通过 Entity Framework Core 传递直接 SQL 命令和事务,以及如何基于 Entity Framework Core 部署数据层。
本章还回顾了在最新的 Entity Framework Core 版本中引入的一些高级功能。
在下一章中,我们将继续探讨微服务编排器,并学习如何在 Kubernetes 上部署和管理微服务。
问题
-
Entity Framework Core 如何适应几个不同的数据库引擎?
-
在 Entity Framework Core 中如何声明主键?
-
在 Entity Framework Core 中如何声明字符串字段的长度?
-
在 Entity Framework Core 中如何声明索引?
-
在 Entity Framework Core 中如何声明关系?
-
两个重要的迁移命令是什么?
-
默认情况下,相关实体是否由 LINQ 查询加载?
-
是否有可能在不是数据库实体的类实例中返回数据库数据?如果是,如何操作?
-
生产环境和预览环境中如何应用迁移?
进一步阅读
-
关于迁移命令的更多详细信息可以在
docs.microsoft.com/en-US/ef/core/miscellaneous/cli/index
以及其中的其他链接中找到 -
关于 Entity Framework Core 编译模型的更多详细信息可以在官方 Microsoft 文档中找到:
docs.microsoft.com/en-us/ef/core/what-is-new/ef-core-6.0/whatsnew#compiled-models
-
关于 Entity Framework Core 的更多详细信息可以在官方 Microsoft 文档中找到:
docs.microsoft.com/en-us/ef/core/
-
复杂 LINQ 查询的详尽示例集可以在这里找到:
learn.microsoft.com/en-us/samples/dotnet/try-samples/101-linq-samples/
在 Discord 上了解更多
要加入这本书的 Discord 社区——在那里您可以分享反馈、向作者提问以及了解新书发布——请扫描下面的二维码:
packt.link/SoftwareArchitectureCSharp12Dotnet8
第十四章:使用.NET 实现微服务
在第十一章“将微服务架构应用于您的企业应用程序”中,您学习了微服务的理论和基本概念。在本章中,您将学习如何将这些通用概念和工具应用于.NET 中实现微服务。这样,您将有一个实际的理解,了解高级架构决策如何转化为具体的.NET 代码。
本章的重点是工作微服务;也就是说,这些微服务不是您应用程序公共接口的一部分。其他类型的微服务将在其他章节中集中讨论。工作微服务处理与特定用户不相关的作业。它们以某种方式准备将被前端微服务使用的数据,以满足所有用户请求。它们是每个应用程序的装配线,因此它们的设计优先级是通信和本地处理的效率,以及确保数据一致性和容错性的协议。
在学习了如何在第十七章“展示 ASP.NET Core”中实现表示层的技术之后,您将在第十八章“使用 ASP.NET Core 实现前端微服务”中学习如何实现前端服务。其他实现表示层的技术在第十九章“客户端框架:Blazor”中描述,而实现公共 API 的技术将在第十五章“使用.NET 应用服务导向架构”中讨论。
本章解释了.NET 微服务的结构,并讨论了交换消息和序列化.NET 数据结构的选项。特别是,我们将实践在第十一章“将微服务架构应用于您的企业应用程序”中分析的大量概念,例如通用宿主的概念、确保可靠通信的技术(指数重试等)、正向通信和消息的幂等性。
更具体地说,在本章中,您将学习以下主题:
-
通信和数据序列化
-
使用 ASP.NET Core 实现工作微服务
-
使用.NET 工作服务和消息代理实现微服务
每个问题都将进行深入讨论,读者可以在第二十一章“案例研究”的“带有 ASP.NET core 的工作微服务”部分中找到更多实际细节,该部分详细描述了三个完整的工作微服务实现。
本章的第一部分讨论了微服务通信中出现的协调和排队问题,以及如何通过消息代理或自定义永久队列来解决这些问题。这些主题在设计高效和容错通信时是基本的,而高效的通信和低响应时间是确保整体应用程序行为一致性和响应时间低的基本要求。
剩余部分包括使用两种不同技术实现相同微服务的示例。第二部分的技术基于 gRPC 的 ASP.NET Core 实现和基于 SQL Server 的永久队列。值得注意的是,这种技术需要 HTTP/2。第三部分的示例展示了如何使用消息代理和两种序列化技术。
消息代理对于以下三个主要原因至关重要:
-
它们处理了大部分通信开销。
-
它们支持发布/订阅模式,这促进了通信微服务的独立性。
-
所有主要云服务提供商都提供消息代理服务。示例使用RabbitMQ,但 RabbitMQ 可以被 Azure 提供的Azure Service Bus消息代理所替代。本章还解释了不同序列化技术的优缺点。
技术要求
本章需要免费 Visual Studio 2022 Community 版或更高版本,并安装所有数据库工具。本章的代码可在github.com/PacktPublishing/Software-Architecture-with-C-Sharp-12-and-.NET-8-4E
找到。
尝试消息代理还需要安装 RabbitMQ 消息代理(版本 3.9 或更高),这反过来又需要先安装 64 位版本的 Erlang。适用于 RabbitMQ 3.9 的适当 Erlang 版本可以从github.com/erlang/otp/releases/download/OTP-24.0.6/otp_win64_24.0.6.exe
下载。RabbitMQ Windows 安装程序可以从github.com/rabbitmq/rabbitmq-server/releases/download/v3.9.5/rabbitmq-server-3.9.5.exe
下载。我们建议您从管理员账户启动这两个安装。
在第二十一章“案例研究”的“具有 ASP.NET core 的工人微服务”和“基于 RabbitMQ 的工人微服务”部分中,有三个完整的工人微服务示例。每个示例都展示了本章中描述的技术,并展示了在案例研究应用程序中收集旅行统计数据的替代方法。
通信和数据序列化
如第十一章“微服务设计原则”小节中所述,在“将微服务架构应用于您的企业应用程序”中,对基于微服务的应用程序的请求不能导致递归微服务调用链的长时间链。
实际上,每次调用都会向实际处理时间添加等待时间和通信时间,从而导致总体响应时间达到不可接受的水平,如下面的图所示。
图 14.1:阻塞 RPC 调用树
消息 1-6 是由对 A 微服务的请求触发的,并且按顺序发送,因此它们的处理时间总和等于响应时间。此外,一旦发送,微服务 A 发出的消息 1 将保持阻塞状态,直到收到最后一条消息(6);也就是说,它在整个递归通信过程的整个生命周期中保持阻塞。
微服务 B 两次被阻塞,等待其对请求的响应。第一次是在 2-3 沟通期间,第二次是在 4-5 沟通期间。总的来说,远程过程调用 (RPC) 涉及高响应时间,以及微服务计算时间的浪费。
正因如此,微服务避免阻塞递归 RPC,而更倾向于从过程调用树叶子开始的数据驱动方法。简单来说,树节点,而不是等待父节点的请求,每次其私有数据发生变化时,都会将预处理的发送给所有可能的调用者,如图所示。
图 14.2:数据驱动异步通信
在一般情况下,基于数据驱动方法的微服务预先处理数据,并将其发送给可能感兴趣的其他服务。这样,每个微服务已经包含了可以立即用于响应用户请求的预计算数据,无需进行进一步的需求特定通信。
标记为 1 的通信是在 C/D 微服务的数据发生变化时触发的,并且可能并行发生。此外,一旦发送通信,每个微服务都可以返回其工作,无需等待响应。最后,当请求到达微服务 A 时,A 已经拥有了构建响应所需的所有数据,无需与其他微服务交互。
更具体地说,随着新数据的可用,每个微服务完成其工作后,通过异步通信将结果发送给所有感兴趣的微服务;也就是说,微服务不会等待任何接收方的确认。由于每次通信通常都会触发接收方的一系列其他通信,因此异步通信是必需的,我们不能像在 RPC 的情况下那样,直到从所有树叶节点收到最终确认之前,阻塞整个微服务树。
然而,尽管数据驱动方法是高流量微服务的唯一可接受选项,但它的实现更为困难。特别是,缺乏确认会导致复杂的协调问题,从而增加了应用程序开发和测试的时间。我们将在本章后面讨论如何面对协调问题。
这里只需陈述以下经验法则。
对于所有高流量工作微服务,使用数据驱动方法,但对于请求-响应周期较短且请求量低到中等的微服务,则使用 RPC(远程过程调用)。
因此,实际应用中通常会结合高效的短路径 RPC 调用和数据驱动方法。
另一个值得注意的点是通信安全。当需要安全通信且通信依赖于底层 TCP/IP 连接时,我们可以简单地使用 TLS/SSL(与 HTTPS 连接相同的协议)。然而,在这种情况下,由于我们讨论的是服务器之间的通信,因此没有实际的客户端,通常两个通信微服务会使用私钥证书相互认证,然后就加密协议和密钥达成一致,以保护它们的 TLS/SSL 通信。.NET 提供了所有基于服务器相互认证进行 TLS/SSL 连接协商的工具。
然而,当所有微服务都属于同一个私有网络时,通常只保护这个私有网络内的通信,而不保护所有内部网络通信,这样做既可以简化整体通信策略的设计,也可以节省加密的性能成本。
应用程序间通信的另一种典型优化是使用二进制序列化,它生成更短的消息,需要更少的带宽和处理时间。实际上,例如,在二进制序列化中,表示一个整数在对象中只需要大约 4 个字节,即基本上与在计算机内存中存储它所需的字节数相同(存在一点协议处理元数据的开销),而将相同的整数作为文本表示则需要每个数字一个字节,再加上字段名所需的字节。
二进制序列化将在下一小节中详细讨论。然后,也将专门分析基于 RPC 和数据驱动的异步通信。
高效且灵活的二进制序列化
序列化是将数据转换为可以在通信通道上发送或存储在文件中的过程。因此,数据序列化的方式对将要发送的数据量有重大影响。
.NET 生态系统包含几个快速的平台特定二进制序列化器,它们能够以非常低的计算成本生成紧凑的短消息。在使用.NET 工作服务和消息代理实现微服务这一章节中,我们将测试其中最快的,即Binaron包。
不幸的是,高效的二进制序列化器存在一些众所周知的问题:
-
由于性能最优秀的二进制序列化器与特定平台绑定,因此它们不可互操作。因此,例如,Java 二进制格式与.NET 二进制格式不兼容。这种限制在您的应用程序微服务异构且使用不同技术时会导致问题。
-
如果使用相同的平台特定、内存中的二进制格式,向/从对象添加/删除属性会破坏兼容性。因此,使用旧版本类的微服务无法反序列化使用相同类的新版本创建的数据。这种限制在微服务的 CI/CD 周期之间创建了依赖关系,因为当微服务为了满足新要求而更改时,会导致与其通信的所有其他微服务的递归更改。
在第十五章“使用.NET 应用面向服务的架构”中,我们将看到 JSON 格式被广泛采用,因为它避免了这两个问题,因为它不绑定到任何特定的语言/运行时,并且可以简单地忽略添加的属性,而删除的属性则通过分配默认值来处理。
ProtoBuf二进制格式是为了确保二进制格式具有与 JSON 序列化/反序列化相同的优势而设计的。
ProtoBuf 通过定义抽象的基本类型及其二进制表示来实现互操作性。然后,每个框架负责将其本地类型转换为/从这些类型转换。基本类型组合成称为消息的复杂结构,代表类。
通过为每个属性分配一个唯一的整数编号,确保了同一消息不同版本之间的兼容性。这样,当一条消息反序列化为一个对象时,只需在序列化数据中搜索标记给定消息版本属性的整数即可进行反序列化。如果在序列化数据中找不到属性编号,则使用关联属性的默认值。因此,ProtoBuf 消息具有与 JSON 对象相同的序列化/反序列化优势。简而言之,如果开发者不更改每个属性关联的编号,就可以确保不同版本之间的兼容性。开发者还可以删除属性,但不应将这些编号分配给新的属性,因为这些编号的目的是命名(使用非常短的名字)所有字段。接收者使用这些整数名称来恢复消息。
还有其他与 ProtoBuf 类似的序列化建议。其中一些也确保了更好的性能,但截至目前,由 Google 创建的 ProtoBuf 是互操作二进制通信的事实标准。
ProtoBuf 消息定义在.proto
文件中,然后通过特定语言的工具编译成目标语言的代码。接下来的部分将描述 ProtoBuf 数据描述语言。
ProtoBuf 语言
每个 .proto
文件以 ProtoBuf 版本的声明开始。目前,最高可用的版本是 proto3
:
syntax = "proto3";
由于 .NET SDK 会从 ProtoBuf 定义中生成类,因此,如果目标语言是 .NET,你可以指定生成所有与文件中定义的 ProtoBuf 类型对应的 .NET 类的命名空间:
option csharp_namespace = "FakeSource";
然后,你可以通过一个或多个 import
声明导入其他 .proto
文件中包含的定义:
import "google/protobuf/timestamp.proto";
上述定义导入了 TimeStamp
类型的定义,该类型编码了 .NET
的 DateTime
和 DateTimeOffset
类型。TimeStamp
不是一个 ProtoBuf 简单类型,而是在标准 ProtoBuf 类型库中定义的。
最后,我们可以将所有消息定义范围到一个 package
中,以避免名称冲突。ProtoBuf 包具有与 .NET 命名空间相同的角色,但在 .NET 代码生成过程中不会自动转换为 .NET 命名空间,因为 .NET 命名空间是通过 option C#
声明指定的:
package purchase;
每个 .proto
文件可以包含多个消息定义。以下是一个示例消息:
message PurchaseMessage {
string id = 1;
google.protobuf.Timestamp time = 2;
string location = 3;
int32 cost =4;
google.protobuf.Timestamp purchaseTime = 5;
}
每个属性通过属性类型、属性名称以及与该属性关联的唯一整数来指定。属性名称必须使用驼峰式命名法,但在 .NET 代码生成过程中会转换为帕斯卡式命名法。
如果创建 PurchaseMessage
的新版本,可以通过不重用分配给旧版本属性的唯一整数,以及仅删除未使用的属性来保持与过去版本的兼容性,如下例所示:
message PurchaseMessage {
string id = 1;
int32 cost =4;
google.protobuf.Timestamp purchaseTime = 5;
Reseller reseller = 7;
}
PurchaseMessage
的新版本不包含属性 2
和 3
,但它包含新的 reseller
属性,标记为新的 7
整数。Reseller
类型由另一个消息定义,该消息可以位于相同的 .proto
文件或导入的文件中。
显然,只有不使用已删除属性的客户端才能保持兼容性,而直接受影响的客户端必须进行更新。
集合通过在集合元素类型名称前加 repeated
关键字来表示:
message PurchaseMessage {
...
repeated Product products = 3;
...
}
重复数据被转换为实现 IList<T>
的 Google.Protobuf.Collections.RepeatedField<T>
.NET 类型。
字典用 map<T1, T2>
类型表示:
message PurchaseMessage {
...
map<string, int> attributes = 3;
...
}
消息可以嵌套在其他消息中,在这种情况下,它们在代码生成期间生成其他 .NET 类定义的类:
message PurchaseMessage {
message Product {
int32 id = 1;
string name = 2;
uint32 cost = 3;
uint32 quantity = 4;
}
...
repeated Product products = 3;
...
}
我们还可以定义直接转换为 .NET enum
类型的 enum
类型:
enum ColorType {
RED = 0;
GREEN = 1;
...
}
还可以定义具有条件内容的消息。这对于发送响应或错误信息很有用:
message Payload {
...
}
message Error {
...
}
message ResponseMessage {
one of result {
Error error = 1;
Person payload = 2;
}
}
一旦微服务接收到 ResponseMessage
类型的 .NET 对象,它可以按以下方式处理:
ResponseMessage response = ...;
switch (response.ResultCase)
{
case ResponseMessage.ResultOneofCase.Payload:
HandlePayload(response. Payload);
break;
case ResponseMessage.ResultOneofCase.Error:
HandleError(response.Error);
break;
default:
throw new ArgumentException();
}
下表总结了所有 ProtoBuf 简单类型及其等效的 .NET 类型:
.NET 类型 | Protobuf 类型 |
---|---|
double |
double |
float |
float |
string |
string |
bool |
bool |
ByteString |
bytes |
int |
int32, sint32, sfixed32 |
uint |
uint32, fixed32 |
long |
int64, sint64, sfixed64 |
ulong |
uint64, fixed64 |
表 14.1:将 Protobuf 简单类型映射到 .NET 等价类型
ByteString
.NET 类型定义在 Google.Protobuf
NuGet 包中包含的 Google.Protobuf
命名空间中。它可以使用其 .ToByteArray()
方法转换为 byte[]
。一个 byte[]
对象可以使用 ByteString.CopyFrom(byte[] data)
静态方法转换为 ByteString
。
int32
, sint32
, 和 sfixed32
编码 .NET 的 int
。现在,当整数更有可能为负数时,sint32
更方便,而当整数可能包含超过 28 位时,sfixed32
类型更方便。类似的考虑也适用于 uint32
和 fixed32
。
相同的标准适用于 64 位整数,但在此情况下,sfixed64
和 fixed64
方便性的阈值是 56 位。
ProtoBuf 简单类型不可为空。这意味着它们不能有空值,如果没有为它们分配值,它们将采用默认值。string
的默认值是一个空 string
,而 ByteString
的默认值是一个空的 ByteString
。
如果需要可空类型,必须包含预定义的 .proto
文件:
import "google/protobuf/wrappers.proto"
下面是一个详细说明 .NET 可空简单类型与 ProtoBuf 可空包装器之间对应关系的表格:
.NET 类型 | ProtoBuf 类型 |
---|---|
double? |
google.protobuf.DoubleValue |
float? |
google.protobuf.FloatValue |
string? |
google.protobuf.StringValue |
bool? |
google.protobuf.BoolValue |
ByteString? |
google.protobuf.BytesValue |
int? |
google.protobuf.Int32Value |
uint? |
google.protobuf.UInt32Value |
long? |
google.protobuf.Int64Value |
ulong? |
google.protobuf.UInt64Value |
表 14.2
DateTime
, DateTimeOffset
, 和 TimeSpan
在 ProtoBuf 中没有直接等价类型,但 Google.Protobuf.WellKnownTypes
命名空间包含在 Google.Protobuf
NuGet 包中,它包含 Timestamp
类型,该类型将 DateTime
和 DateTimeOffset
映射到/从,以及 Duration
类型,该类型将 TimeSpan
映射到/从。映射与 ByteString
的映射完全类似。因此,例如,可以通过 Duration.FromTimeSpan
静态方法从 TimeSpan
获取 Duration
,而通过调用其 .ToTimeSpan
实例方法将 Duration
转换为 TimeSpan
。
.proto
文件中使用 Duration
和 Timestamp
的示例如下:
syntax = "proto3"
import "google/protobuf/duration.proto";
import "google/protobuf/timestamp.proto";
message PurchaseMessage {
string id = 1;
google.protobuf.Timestamp time = 2;
string location = 3;
int32 cost =4;
google.protobuf.Timestamp purchaseTime = 5;
google.protobuf.Duration travelDuration = 6;
}
请注意,使用时需要导入预定义的 .proto
文件。
目前,没有 .NET decimal
类型的等价类型,但可能会在下一个版本中引入。然而,可以使用两个整数来编码小数,一个用于整数部分,另一个用于小数部分,如下面的消息所示:
message ProtoDecimal {
// Whole units part of the decimal
int64 units = 1;
// Nano units of the decimal (10^-9)
// Must be same sign as units
sfixed32 nanos = 2;
}
我们可以向 .NET 的 decimal
添加隐式转换,通过一个与另一个从 .proto
文件自动生成的部分类结合的部分类来实现:
public partial class DecimalValue
{
private const decimal nanoFactor = 1000000000m;
public static implicit operator decimal(ProtoDecimal pDecimal)
{
return pDecimal.Units + pDecimal.Nanos / nanoFactor;
}
public static implicit operator ProtoDecimal (decimal value)
{
return new ProtoDecimal
{
Units = decimal.ToInt32(value),
Nanos= decimal.ToInt32((value - Units) * nanoFactor),
};
}
}
我们几乎完全描述了 ProtoBuf 数据描述语言。唯一缺少的主题是可变/未知类型的表示,这些类型很少使用。然而,进一步阅读部分包含了一个指向官方文档的链接。下一节解释了如何序列化和反序列化消息。
ProtoBuf 序列化
对象树可以按照以下方式序列化:
using Google.Protobuf;
...
PurchaseMessage purchase = ....
byte[]? body = null;
using (var stream = new MemoryStream())
{
purchase.WriteTo(stream);
stream.Flush();
body = stream.ToArray();
}
WriteTo
方法需要一个流,因此创建一个内存流。之后,我们使用 ToArray
从流中创建一个字节数组,这确保在尝试提取字节数组之前,流缓冲区实际上已写入流中:
Byte[] body = ...
PurchaseMessage? message = null;
using (var stream = new MemoryStream(body))
{
message = PurchaseMessage.Parser.ParseFrom(stream);
}
这里,由于 ParseFrom
也需要一个流,我们从消息字节生成一个流。
下一小节描述了在微服务中使用 RPC 的用法。
高效且灵活的 RPC
如果满足以下条件,可以在某些应用程序微服务中采用 RPC 方法并获得良好的结果:
-
递归调用的链非常短,通常只有一个没有递归调用的调用。
-
低通信延迟和高通道带宽。这种条件由在同一数据中心内高速以太网上进行的数据中心内部通信满足。
-
数据以快速且非常高效的格式序列化。任何高效的二进制序列化器都能满足这一条件。
当 RPC 仅用于排队请求并接收请求已正确排队的确认,而不等待任何处理结果时,应避免使用长链递归调用。在这种情况下,RPC 用于模拟带有接收确认的异步通信。如果情况如此,只要所有通信的微服务都是同一 局域网(LAN)的一部分,其中所有计算机都通过高速物理连接连接,所有这些条件都很容易满足。
相反,当通信的微服务在地理上分布,并作为 WAN 而不是 LAN 的一部分时,对于高流量微服务来说,等待 RPC 消息接收确认可能是不可以接受的。在这种情况下,最好依赖于支持完全异步通信的消息代理。
gRPC 协议将 ProtoBuf 的所有优点带到了 RPC,因为默认情况下,它基于 ProtoBuf。gRPC/ProtoBuf 是一个在 HTTP/2 连接上工作的二进制协议。值得注意的是,gRPC 不能与小于 2 的 HTTP 版本一起工作。在本章的剩余部分,我们将始终假设 gRPC 使用 ProtoBuf。
gRPC 使用 .proto
文件,但与数据一起,gRPC .proto
文件还定义了带有其 RPC 方法的服务。以下是一个服务定义:
service Counter {
// Accepts a counting request
rpc Count (CountingRequest) returns (CountingReply);
//Get current count for a given time slot
rpc GetCount (TimeSlotRequest) returns (TimeSlotDataReply);
}
每个服务通过service
关键字引入,而每个方法通过rpc
关键字引入。每个服务指定一个输入消息和一个输出消息。如果这两个消息中的任何一个为空,我们可以使用预定义的google.protobuf.Empty
消息,如下所示:
...
import "google/protobuf/empty.proto";
...
service Counter {
...
rpc AllSlots(google.protobuf.Empty) return (AllDataReply);
}
.proto
文件可以用来生成服务器端代码和客户端代码。在客户端代码中,每个服务被转换为一个具有相同方法的代理类。每个代理方法会自动调用远程服务方法并返回其结果。
相反,服务器端代码将每个服务转换为一个抽象类,其虚拟方法对应于服务中声明的方法。开发者负责从该抽象类继承并提供所有服务方法的实现。
下面是如何从类似类继承的示例:
public class CounterService: Counter.CounterBase
{
...
public override async Task<CountingReply> Count(
CountingRequest request, ServerCallContext context)
{
CountingReply reply =...
return reply;
}
}
每个方法都接收输入消息和上下文对象。由于 gRPC 服务使用 ASP.NET Core 基础设施,上下文对象通过context.GetHttpContext()
提供了对请求HttpContext
的访问。
ASP.NET Core 应用程序可以通过builder.Services.AddGrpc()
启用 gRPC,并通过声明每个服务,例如app.MapGrpcService<CounterService>();
来声明每个服务。
在讨论第二十一章“案例研究”的“具有 ASP.NET core 的工作微服务”部分中的示例时,将给出关于 gRPC 服务器和客户端的更多详细信息。
服务可以作为输入接收并返回连续的数据流,其中客户端和服务器之间建立了一个长期连接。然而,在微服务中流的使用并不常见,因为微服务是短暂的进程,经常被编排器关闭并从一个处理节点移动到另一个处理节点,因此长期连接难以维护。
下面是一个接受并返回流的服务示例:
service StreamExample {
rpc Echo (stream MyMessage) returns (stream MyMessage);
}
每个输入流作为参数传递给.NET 方法的实现。如果方法返回一个流,则.NET 方法的实现必须返回一个Task
,并且输出流也作为参数传递给方法:
public override async Task Echo(
IAsyncStreamReader<MyMessage> requestStream
IServerStreamWriter<MyMessage> responseStream,
ServerCallContext context){
...
While(...)
{
bool inputFinished = !await requestStream.MoveNext();
var current = requestStream.Current;
...
await responseStream.WriteAsync(result);
}
}
在客户端,当代理方法在没有等待的情况下被调用时,返回的call
对象中既可用输入流,也可用输出流,如下所示:
var call = client.Echo();
...
await call.RequestStream.WriteAsync(...);
...
bool inputFinished = ! await call.ResponseStream.MoveNext();
var current = call.ResponseS.Current;
...
call.RequestStream.CompleteAsync();
CompleteAsync()
方法关闭请求流,声明输入已完成。
在第二十一章“案例研究”的“具有 ASP.NET core 的工作微服务”部分中给出了关于客户端使用的更多示例细节,而“进一步阅读”部分包含了一个指向.NET gRPC 官方文档的链接。
下一个子节将描述如何实现数据驱动的异步通信。
可靠的数据驱动异步通信
非阻塞通信必须依赖于非易失性队列,以解耦发送线程和接收线程。解耦可以通过每个通信路径上的一个队列实现,但有时额外的队列可以提高性能并增加 CPU 使用率。队列可以放置在三个地方:在发送消息的微服务内、在接收消息的微服务内,或者使用称为消息代理的专用服务在两个微服务之外。通过使用称为消息代理的专用队列服务。
Azure Service Bus,我们在第十一章的“.NET 通信设施”小节中描述过,是提供队列服务和发布/订阅通信的消息代理,类似于大多数消息代理。在本章中,我们还将描述RabbitMQ,它提供与Azure Service Bus 主题广泛相似的队列服务和发布/订阅通信。由于使用本地 RabbitMQ 实例的代码更容易调试,因此在开发过程中通常更方便使用 RabbitMQ,然后迁移到 Azure Service Bus。
队列解耦了发送者和接收者,但并不保证消息不会丢失。在第十一章的“弹性任务执行”部分中,我们已经讨论了实现可靠通信的策略。在这里,我们给出更多关于使用确认和超时防止消息丢失的实用细节:
-
队列必须存储在持久存储上;否则,如果控制它们的进程崩溃或关闭,其内容可能会丢失。
-
如果在超时时间内没有收到确认消息已成功插入队列的消息,则源假定消息已丢失并重试操作。
-
当从队列中提取消息时,它将保持阻塞状态,对其他消费者不可用。如果在超时时间内收到确认消息已成功处理的消息,则从队列中删除该消息;否则,它将被解除阻塞并再次可供其他消费者使用。
所有确认都可以异步处理,除了通信路径的第一个队列中的插入操作。实际上,如果发送代码不保持阻塞等待确认,而是转移到进一步处理,并且消息丢失,则无法重新发送消息,因为无法从任何其他队列中获取该消息。
因此,有时使用消息代理的微服务也具有内部队列。更具体地说,主微服务线程生成所有消息并将它们存储在可以由数据库表实现的本地队列中。另一个线程负责从该队列中提取消息并将它们发送到消息代理。
从本地队列中移除的消息将被阻塞,并且只有在从消息代理收到异步确认后才会被移除。这种技术在第十八章,使用 ASP.NET Core 实现前端微服务的示例中使用。本地队列的主要优势是,由于与其他线程/进程的并发性较低(不要忘记每个微服务都应该有一个私有数据库/永久存储),因此来自本地队列的确认通常更快,所以阻塞时间更可接受。
在每个接收器内部使用队列是消息代理的一个可行替代方案。私有队列的主要优势在于处理队列的过程不会被多个微服务共享,因此所有队列操作都更快。特别是,每次插入的确认都是立即的,因此发送者可以使用阻塞的 RPC 调用来发送消息。然而,这个简单的解决方案有以下缺点:
-
无法实现发布/订阅模式。
-
只有一个微服务实例可以从队列中提取消息。因此,微服务无法进行水平扩展。可以通过增加处理器核心的数量以及使用并行线程处理队列消息来实现有限的垂直扩展。
可以使用 gRPC 和 ASP.NET Core 有效地实现类似的方法如下:
-
发送者将消息发送到 gRPC 方法。
-
gRPC 方法只是将消息入队并立即向发送者返回确认。
-
ASP.NET Core 托管进程负责从队列中提取消息并将它们传递给多个并行线程。
-
当消息传递给一个线程时,它会保持阻塞和不可访问。只有在线程确认消息已成功处理后,它才会被移除。如果线程报告失败,相应的消息将被解锁,以便传递给另一个线程。
ASP.NET Core 线程负责必要的输入并行性。可以通过使用负载均衡器和多个 Web 服务器来实现一些水平并行性。然而,在这种情况下,要么所有 Web 服务器使用相同的数据库,从而增加队列的并发性,要么我们使用多个分片数据库。
这种方法在使用 ASP.NET core 实现工作微服务的示例中描述得更为详细。正如我们将看到的,它易于实现并确保良好的响应时间,但由于其有限的可扩展性,它仅适用于小型或中型应用,且流量较低到中等。
如果由于某种原因,在队列中插入或处理由队列提取的消息所需的时间超过了超时时间,则操作会重复,导致同一消息被处理两次。因此,消息必须是一致的,这意味着一次或多次处理它们必须产生相同的效果。记录更新和删除本质上是一致的,因为多次更新或删除不会改变结果,但记录添加则不是。
一致性可以通过为消息分配一个唯一的标识符,然后存储已处理消息的标识符来实现。这样,当发现其标识符已存在于已处理消息的标识符中时,可以丢弃每个传入的消息。在本章的所有示例中,我们将使用这种技术。
队列、确认和消息重发确保单个微服务的请求被安全处理,但如何处理涉及多个协作微服务的请求呢?我们将在下一小节中找到答案。在那里,我们将解释分布式微服务如何通过仅使用异步通信来协调和达成一致,以产生一致的行为。
分布式事务
每个人都知道什么是数据库事务:多个记录依次被修改,但如果单个操作失败,则在终止事务之前,所有之前的修改也会被撤销。也就是说,要么所有操作都成功,要么它们同时失败。
分布式事务执行相同的工作,但在这个情况下,记录不是单个数据库的一部分,而是分布在与多个协作微服务关联的数据库中。
到目前为止所描述的可靠的数据驱动通信技术是解决更复杂合作问题的基石。
假设一个用户操作触发了多个相关微服务的处理和存储。只有当所有涉及的处理/存储操作都成功时,用户操作才能被认为是成功完成的。
此外,如果单个处理/存储操作由于某些基本原因而失败,重试失败的操作也没有帮助。例如,考虑一个购买操作失败,因为用户没有足够的资金来完成支付:唯一的出路是撤销所有已执行的操作。
通常,类似的情况必须以事务方式处理;要么所有操作都执行,要么没有任何操作执行。跨越多个微服务的交易被称为分布式事务。在理论上,分布式事务可以通过以下两阶段协议来处理:
-
在第一阶段,所有操作都在本地事务的范围内执行,每个操作都在本地事务的范围内(例如,在各个微服务数据库事务的范围内)。然后,每个操作的成功或失败被返回给事务协调器。
-
在第二阶段,事务协调器通知所有微服务整体操作的成功或失败。在失败的情况下,所有本地事务都会回滚;否则,它们会被提交。
当使用异步消息时,确认可能会在相当长的时间后到达,并且可能与在相同资源上执行的其他事务交织在一起。因此,在可能耗时的分布式事务期间,所有本地资源都被本地事务阻塞是不可接受的。
因此,微服务事务使用saga模式:所有本地操作都在不打开本地事务的情况下执行,并且在失败的情况下,它们通过其他操作来补偿初始操作。
撤销数据库插入相当容易,因为只需要移除添加的项目即可,但撤销修改和删除则相当困难,需要存储额外的信息。解决这个问题的通用方法是存储一个表中的记录,这些记录代表所有数据库更改。这些记录可以用来计算补偿操作,或者从参考数据库状态开始恢复之前的数据库状态。我们已经在“第七章,理解软件解决方案中的不同领域”的“事件溯源”部分讨论了这种存储技术。为了回顾,简单地说,每个数据库不仅存储实际状态,还存储到某个之前时间点的所有更改的历史。
当撤销一个 saga 事务时,如果其他 saga 事务依赖于已撤销的更改,我们也必须撤销它们。例如,假设一个已接受的采购订单依赖于用户在电子商务平台上上传的资金。那么,如果资金上传事务被撤销,采购订单也必须被撤销。
为了避免在撤销 saga 事务时出现类似的连锁反应,通常只有在它们依赖于在某个安全间隔之前发生的变化时才接受新的交易。因此,例如,上传的资金只有在大约 5-10 分钟后才可用,因为交易在超过 5-10 分钟后被撤销的可能性非常小。
Saga 事务可能使用两种基本技术:
-
编排:在事务开始时,创建一个编排组件,负责向所有涉及的微服务发送必要的消息并接收它们的成功/失败消息。这种技术易于实现,但会在涉及微服务的软件生命周期之间创建依赖关系,因为编排器必须依赖于参与叙事的所有微服务的详细信息。此外,由于编排器成为瓶颈,这种技术可能性能较差。
-
编排:事务没有集中控制,但每个微服务都是由不同的发送微服务调用的,并将接收到的成功/失败消息转发给其他通信邻居。编排克服了编排的缺点,但实现和测试更困难。
下表总结了两种技术的优缺点。
代码可维护性 | 设计和调试的困难 | |
---|---|---|
编排 | 好 | 相当高 |
编排 | 低 | 没有特别的困难 |
表 14.3:编排和编排的优缺点
使用 ASP.NET Core 实现工作微服务
为了避免阻塞调用者的同步请求太长时间,基于 ASP.NET Core 的解决方案需要实现一个内部队列,其中可以存储所有接收到的消息。这样,当收到消息时,它立即入队而不处理它,以便可以立即返回“已接收”响应。
因此,应用层需要一个处理队列的存储库接口。以下是此接口的可能定义:
public interface IMessageQueue
{
public Task<IList<QueueItem>> Top(int n);
public Task Dequeue(IEnumerable<QueueItem> items);
public Task Enqueue(QueueItem item);
}
Where:
-
QueueItem
是一个包含所有请求信息的类 -
Enqueue
向队列中添加一条新消息 -
Top
返回前n
个队列项,但不从队列中移除它们 -
Dequeue
从队列中移除前n
条消息
前述接口的实际实现可以基于数据库表或其他任何存储介质。
应用层可以使用一个 ASP.NET Core gRPC 项目来实现,该项目为您组织所有 gRPC 东西:
图 14.3:创建 gRPC 服务器项目
实际的请求处理是由一个在 ASP.NET Core 管道并行运行的工人托管服务执行的。它是在第十一章“将微服务架构应用于您的企业应用”中“使用通用宿主”部分讨论的托管服务中实现的。值得回忆的是,托管服务是实现依赖注入引擎中定义的 IHostedService
接口的实现,如下所示:
builder.Services.AddHostedService<MyHostedService>();
由于它们是在依赖注入引擎中定义的,它们在其构造函数中自动注入为服务。托管服务用于执行独立于应用程序其余部分的并行线程。通常,它们不是通过直接实现 IHostedService
接口来定义的,而是通过继承抽象的 BackgroundService
类并重写其 Task ExecuteAsync(CancellationToken token)
抽象方法来定义。
ExecuteAsync
方法通常包含一个无限循环,只有当应用程序关闭时才会退出。这个无限循环定义了工作托管服务的行为,该服务会重复执行某个任务。在我们的情况下,要重复的任务是从队列中持续提取和处理 N
个项目。
下面是我们托管服务的一个可能的实现:
public class ProcessPurchases : BackgroundService
{
IServiceProvider services;
public ProcessPurchases(IServiceProvider services)
{
this.services = services;
}
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
{
bool queueEmpty = false;
while (!stoppingToken.IsCancellationRequested)
{
while (!queueEmpty && !stoppingToken.IsCancellationRequested)
{
...
}
await Task.Delay(100, stoppingToken);
queueEmpty = false;
}
}
}
类构造函数没有注入它需要的特定服务,而是注入了 IServiceProvider
,它可以用来获取依赖注入引擎中定义的任何服务。选择这个方案的原因是它将启动多个线程(每个线程对应从队列中提取的 N
条消息中的一个),通常情况下,不同的线程不能共享同一个服务实例。
问题是由具有会话作用域的服务引起的。通常,这些服务没有被设计成线程安全的,因为在整个 ASP.NET Core 请求中使用的单个会话作用域实例永远不会在并行线程之间共享。然而,我们不会从通常的 ASP.NET Core 管道内部使用我们的服务,而是从由我们的托管服务启动的并行线程内部使用。因此,我们需要为每个并行线程提供一个不同的会话作用域。
因此,正确的处理方式是使用 IServiceProvider
创建每个必要的范围,然后使用每个范围为每个并行线程获取不同的实例。
内部 while
循环会一直运行,直到队列变为空,然后工作线程会休眠 100 毫秒,然后再次尝试内部循环,以查看在此期间是否有新的消息到达队列。
当应用程序关闭时,stoppingToken
CancellationToken
被触发,两个循环退出,这样整个 ExecuteAsync
方法就会退出,工作线程就会死亡。
下面是内部循环的内容:
using (var scope = services.CreateScope())
{
IMessageQueue queue = scope.ServiceProvider.GetRequiredService<IMessageQueue>();
var toProcess = await queue.Top(10);
if (toProcess.Count > 0)
{
Task<QueueItem?>[] tasks = new Task<QueueItem?>[toProcess.Count];
for (int i = 0; i < tasks.Length; i++)
{
tasks[i] = toExecute();
}
await Task.WhenAll(tasks);
await queue.Dequeue(tasks.Select(m => m.Result)
.Where(m => m != null).OfType<QueueItem>());
}
else queueEmpty = true;
}
由于我们需要一个唯一的 IMessageQueue
实例来操作队列,所以会话作用域包围了整个代码。
代码尝试从队列中提取 N
条消息。如果没有找到消息,queueEmpty
被设置为 true
,这样内部循环就会退出;否则,一个 for
循环为每个提取的请求创建一个单独的任务,并将其插入到 tasks
数组中。
然后,Task.WhenAll
等待所有任务完成。最后,queue.Dequeue
从队列中移除所有从任务返回的非空请求。由于只有在处理成功的情况下才会返回非空请求,因此 queue.Dequeue
只移除成功处理的请求。
toExecute
方法执行实际请求处理,这取决于特定应用程序。
在第二十一章“案例研究”的“A worker microservice with ASP.NET Core”部分中,描述了一个包含所有细节和其实践实施步骤的完整示例。该示例的完整源代码包含在本书 GitHub 仓库中与该章节相关联的文件夹中。
下一节将向您展示如何修改此示例的代码以使用基于RabbitMQ消息代理的队列通信。
使用.NET 工作服务和消息代理实现微服务
本节解释了使用消息代理而不是与内部队列的 gRPC 通信所需的修改。这种解决方案通常更难测试和设计,但允许更好的水平扩展。
代码中使用的消息代理是RabbitMQ。然而,我们也可以使用 GitHub 仓库中与本书相关联的代码将其替换为Azure Service Bus。下一小节将解释如何在您的开发机器上安装 RabbitMQ。我们使用 RabbitMQ 是为了给读者提供安装和学习的可能性,因为 Azure Service Bus 需要更少的配置并且立即可以使用。在实际的生产系统中,人们可能会选择 RabbitMQ,这样您就不会绑定到特定的云提供商,因为虽然 Azure Service Bus 仅在 Azure 上可用,但 RabbitMQ 可以安装在任何云或本地环境中。
安装 RabbitMQ
在安装RabbitMQ之前,您需要从“技术要求”部分提供的链接安装Erlang。只需下载并从管理员账户执行安装程序即可。之后,您可以从“技术要求”部分提供的链接下载并安装 RabbitMQ。
如果安装成功,您应该在您的机器的 Windows 服务中找到一个名为RabbitMQ的服务。如果您找不到它或它没有运行,请重新启动您的计算机。
可以从命令提示符向 RabbitMQ 发出管理命令,您可以在RabbitMQ Server Windows 菜单文件夹中找到它。
您还可以启用基于 Web 的管理 UI。让我们打开 RabbitMQ 命令提示符并输入以下命令:
rabbitmq-plugins enable rabbitmq_management
然后,转到http://localhost:15672
。您将被提示输入用户名和密码。最初,它们都被设置为guest。从那里,您可以检查所有活动连接和通道以及所有已创建的通信队列。队列页面包含所有已定义的队列。通过单击每个队列,您将转到特定队列的页面,在那里您可以检查队列内容并对特定队列执行各种操作。
下一小节包含了对 RabbitMQ 功能的简要调查。
RabbitMQ 基础知识
RabbitMQ 原生支持 AMQP 异步消息协议,这是最常用的异步协议之一,另一个是具有特定发布/订阅语法的 MQTT。
可以通过插件添加对 MQTT 的支持,但 RabbitMQ 提供了在 AMQP 之上轻松实现发布/订阅模式的工具。此外,RabbitMQ 提供了一些工具来支持可伸缩性、灾难恢复和冗余,因此它满足了在云和微服务环境中成为一流演员的所有要求。更具体地说,它支持类似于大多数 SQL 和 NoSQL 数据库的数据复制,并且还支持基于复杂和灵活技术的多个服务器之间的协作。由于篇幅限制,在本节中,我们仅将描述 RabbitMQ 的基本操作,但读者可以在 RabbitMQ 官方网站的教程和文档中找到更多详细信息:www.rabbitmq.com/
。
由于 RabbitMQ 的消息必须是字节数组,因此 RabbitMQ 的消息必须以二进制格式准备。因此,在发送之前,我们需要使用二进制格式化程序序列化.NET 对象。在本节中的示例中,我们将测试 ProtoBuf 序列化程序和一个称为Binaron的快速.NET 特定序列化程序。如果不同团队使用不同框架实现的微服务之间存在兼容性问题,或者存在遗留微服务,则也可能使用 JSON 序列化程序以确保更好的兼容性。值得回忆的是,JSON 通常具有更好的兼容性但效率较低,而二进制格式则兼容性较差。ProtoBuf 试图通过定义一种通用的二进制语言来解决二进制兼容性问题,但它不是一个官方标准,而是一种事实上的标准。
消息不是直接发送到队列,而是发送到其他被称为交换机的实体,这些实体将消息路由到队列。交换机是 AMQP 特定的概念,是 RabbitMQ 配置复杂通信协议(如发布/订阅协议)的方式。
图 14.4:RabbitMQ 交换机
为了充分定义交换机的路由策略,我们可以实现几种模式。更具体地说:
-
当我们使用默认交换机时,消息将发送到单个队列,我们可以实现异步直接调用。
-
当我们使用
fanout
交换机时,交换机会将消息发送到所有订阅该交换机的队列。这样,我们可以实现发布/订阅模式。 -
此外,还有一个
topic
交换机,它通过允许使用通配符字符匹配多个事件来增强发布/订阅模式。然而,在企业级微服务中通常不需要它。
我们的示例将仅描述直接调用,但进一步阅读部分包含一个链接到 RabbitMQ 教程,其中展示了发布/订阅实现的示例。
下一个部分解释了如何修改上一部分的代码以使用基于 RabbitMQ 的直接通信。
将内部队列替换为 RabbitMQ
如前所述,在高流量 WAN 网络中,接收同步接收消息确认也有不可接受的性能影响,因此不能使用任何 RPC 协议。此外,通常微服务使用发布/订阅模式来实现最佳的解耦。在这些情况下,使用消息代理是必需的。最后,让所有队列都由一个唯一的可扩展代理处理,可以独立且容易地扩展通信资源。这种能力对于优化由数百或数千个微服务组成的应用程序的性能是基本的。
总结来说,有些情况下,消息代理是必需的,或者至少是最好的选择。因此,在本节中,我们将展示如何通过将先前项目转换为使用 RabbitMQ 而不是 gRPC 和内部队列来使用它们。
不幸的是,这种转换需要完全重构微服务项目。我们可以保留业务和数据层,但我们需要从 ASP.NET Core 项目迁移到一个名为Worker Service的不同项目模板。
因此,让我们将 ASP.NET Core 项目替换为Worker Service项目:
![img/B19820_14_05.png]
图 14.5:ASP.NET Core 中 gRPC 服务器项目的设置过程
我们不再需要 gRPC 服务,但我们需要 ProtoBuf,因为 RabbitMQ 与二进制消息一起工作。
Worker Service 项目自动生成一个托管服务(托管服务在第十一章“将微服务架构应用于您的企业应用程序”的使用通用宿主部分中进行了详细讨论)。然而,这个托管服务的ExecuteAsync
方法必须略有不同:
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
{
while (!stoppingToken.IsCancellationRequested)
{
try
{
var factory = new ConnectionFactory() { HostName = "localhost" };
using (var connection = factory.CreateConnection())
using (var channel = connection.CreateModel())
{
channel.QueueDeclare(queue: "purchase_queue",
durable: true,
exclusive: false,
autoDelete: false,
arguments: null);
channel.BasicQos(prefetchSize: 0, prefetchCount: 1, global: false);
var consumer = new EventingBasicConsumer(channel);
consumer.Received += async (sender, ea) =>
{
// Message received even handler
...
};
channel.BasicConsume(queue: "purchase_queue",
autoAck: false,
consumer: consumer);
await Task.Delay(1000, stoppingToken);
}
}
catch { }
}
}
在主循环中,如果抛出异常,它会被空的catch
拦截。由于两个using
语句都留下来了,所以连接和通道都被释放了。因此,在异常之后,会执行一个新的循环,创建一个新的新鲜连接和一个新的通道。
拦截异常是几个基本原因:
-
首先,它避免了微服务的崩溃。
-
它使所有参与与 RabbitMQ 服务器通信的客户端对象能够完全重置,这些对象可能因错误而损坏。
-
它允许错误记录,在实际的生产环境中,这是基本的监控工具。为了简单起见,错误记录在代码片段中没有显示。
在using
语句的主体中,我们确保我们的队列存在,然后将prefetch
设置为1
。
确保队列存在,并在它们不存在时创建它们,比依赖于单个微服务来创建它们更好,因为这种方法避免了引入复杂的依赖关系和维护挑战,这些挑战与微服务运行的顺序有关。
将prefetch
设置为1
会导致每个服务器一次只提取一条消息,这确保了所有服务器之间负载的公平分配。然而,当每个服务器运行多个相等的并行线程来处理传入的消息时,将prefetch
设置为1
可能不太方便,因为它牺牲了线程使用优化以换取消息在服务器之间公平分配。因此,可能成功处理后续消息(在第一条之后)的线程可能会保持空闲,从而可能浪费每个服务器机器上可用的处理器核心。
然后,我们定义一个消息接收
事件处理器。BasicConsume
启动实际的消息接收。当autoAck
设置为false
时,从队列中读取消息后,消息不会被移除,而是被阻塞,因此它对从同一队列读取的其他服务器不可用。当向 RabbitMQ 发送确认消息,表明消息已被成功处理时,消息实际上被移除。我们还可以发送一个失败确认,在这种情况下,消息被解除阻塞并再次可用于处理。
如果没有收到确认,消息将保持阻塞,直到连接和通道被释放。
由于BasicConsume
是非阻塞的,其后的Task.Delay
会阻塞,直到取消令牌被信号。在任何情况下,1 秒后Task.Delay
解除阻塞,连接和通道都被替换为新的。这防止了未确认的消息永远被阻塞。
将像BasicConsume
这样的指令设置为非阻塞,可以防止线程在等待事件时浪费处理器核心。相反,线程通过像Task.Delay
这样的指令进入睡眠模式,从而释放其所有资源及其分配的处理器核心,这样,它就可以分配给另一个等待空闲核心以执行其执行的并行线程。
让我们继续看“消息接收”事件内部的代码。这是实际消息处理发生的地方。
作为第一步,代码会验证应用程序是否正在关闭,如果是的话,它会释放通道和连接,并返回而不执行任何进一步的操作:
if (stoppingToken.IsCancellationRequested)
{
channel.Close();
connection.Close();
return;
}
然后,创建一个会话作用域以访问所有会话作用域的依赖注入服务:
using (var scope = services.CreateScope())
{
try
{
// actual application dependent message processing
...
}
catch {
((EventingBasicConsumer)sender).Model.BasicNack(ea.DeliveryTag, false, true);
}
}
如果在消息处理过程中抛出异常,会向 RabbitMQ 发送一个Nack
消息,通知它消息处理失败。ea.DeliveryTag
是一个唯一标识消息的标签。第二个参数设置为false
通知 RabbitMQ,Nack
只是为了由ea.DeliveryTag
标识的消息,而不涉及等待此服务器确认的所有其他消息。最后,将最后一个参数设置为true
请求 RabbitMQ 重新队列处理失败的消息。
在try
块内部,进行的是实际的消息处理。其第一步是消息反序列化:
var body = ea.Body.ToArray();
MyMessageClass? message = null;
using (var stream = new MemoryStream(body))
{
message = PurchaseMessage.Parser.ParseFrom(stream);
}
之后,是实际的应用依赖的消息处理。如果这个处理失败,我们必须发送Nack
;否则,我们必须发送Ack
:
if(success)
((EventingBasicConsumer)sender).Model
.BasicAck(ea.DeliveryTag, false);
else
((EventingBasicConsumer)sender).Model
.BasicNack(ea.DeliveryTag, false, true);
完整示例的完整代码位于本书 GitHub 仓库中ch15
文件夹的GrpcMicroServiceRabbitProto
子文件夹中。该示例的详细描述在第二十一章,案例研究中的基于 RabbitMQ 的工作微服务部分。
下一章描述了面向服务的架构以及如何使用 ASP.NET Core 实现它。
摘要
在本章中,我们分析了高效内部微服务通信的各种选项。我们解释了互操作性和确保与先前消息版本兼容的二进制序列化的重要性,并详细描述了 ProtoBuf。
我们分析了 RPC 通信的局限性以及为什么数据驱动通信必须优先考虑。然后,我们关注了如何实现可靠的异步通信和高效的分布式事务。
在描述了可靠异步通信的概念问题和技术之后,我们探讨了两种架构。第一种是基于 gRPC、ASP.NET Core 和内部队列的,第二种是基于像 RabbitMQ 和.NET 工作服务的消息代理。
本章通过实际示例解释了如何实现所讨论的所有通信协议以及.NET 中可用的实现工作微服务的架构选项。
问题
-
为什么队列在微服务通信中如此重要?
-
我们如何重新调用另一个
.proto
文件? -
我们如何在 ProtoBuf 语言中表示
TimeSpan
? -
ProtoBuf 和 gRPC 相较于其他二进制选项有哪些优势?
-
使用消息代理而不是内部队列有哪些优势?
-
为什么使用阻塞的 gRPC 调用来在接收队列中入队消息是可以接受的?
-
我们如何在.NET 项目文件中启用
.proto
文件代码生成? -
我该如何使用官方.NET 客户端在 RabbitMQ 通道上发送消息?
-
你如何确保使用官方.NET 客户端在 RabbitMQ 通道上发送的消息能够安全地保存在磁盘上?
进一步阅读
-
官方的.NET ProtoBuf 文档可以在这里找到:
docs.microsoft.com/it-it/aspnet/core/grpc/protobuf?view=aspnetcore-6.0
。 -
.NET 关于 gRPC 的文档可以在这里找到:
docs.microsoft.com/it-it/aspnet/core/grpc/?view=aspnetcore-6.0
。 -
官方 Google 关于整个 ProtoBuf 语言的文档在这里:
developers.google.com/protocol-buffers/docs/proto3
。 -
RabbitMQ 的完整教程可以在这里找到:
www.rabbitmq.com/tutorials/tutorial-two-dotnet.html
. -
RabbitMQ 的完整文档在这里:
www.rabbitmq.com/
.
在 Discord 上了解更多
要加入这本书的 Discord 社区——在那里您可以分享反馈、向作者提问,并了解新版本——请扫描下面的二维码:
packt.link/SoftwareArchitectureCSharp12Dotnet8
第十五章:使用.NET 应用服务导向架构
服务导向架构(SOA)这个术语指的是一种模块化架构,其中系统组件之间的交互是通过通信实现的。这种方法已经发展多年,现在已成为互联网上所有系统间通信的基础。SOA 允许来自不同组织的应用程序自动交换数据和事务。除此之外,它还允许组织在互联网上提供服务。例如,在银行应用程序中,SOA 可以允许账户管理、交易处理和客户支持等独立服务无缝通信。更重要的是,它还可以使供应商直接访问客户支持。
此外,正如我们在第十一章中讨论的,将微服务架构应用于企业应用程序,基于通信的交互解决了由共享相同地址空间的模块组成的复杂系统中不可避免出现的二进制兼容性和版本不匹配问题。此外,使用 SOA 及其通信模式,你不需要在所有使用它的各种系统/子系统中部署同一组件的不同副本——每个组件只需在一个地方部署即可,即使它们是用不同的编程语言编写的,这也简化了持续集成/持续交付(CI/CD)的整体周期。
如果更新的版本符合客户端声明的外部通信接口,则不会出现不兼容性。例如,如果你有一个后端服务,该服务根据特定的规则和销售数据的输入来计算税费,如果特定的规则发生变化,但销售数据没有变化,你将能够更新服务而无需更改客户端中的应用程序。另一方面,使用 DLLs/包时,即使保持相同的接口,由于库模块可能与其客户端共有的其他 DLLs/包的依赖项可能存在版本不匹配,因此可能会出现不兼容性。
在第十一章将微服务架构应用于企业应用程序中讨论了组织协作服务的集群/网络。在本章中,我们将重点关注全球范围内使用的两个主要通信接口。更具体地说,我们将讨论以下主题:
-
理解 SOA 方法的原则
-
SOAP 和 REST Web 服务
-
.NET 8 如何处理 SOA?
到本章结束时,你将了解如何通过 ASP.NET Core 服务公开暴露应用程序中的数据。
技术要求
本章需要安装所有数据库工具的 Visual Studio 2022 免费社区版或更高版本。
本章中的所有概念都将通过基于 WWTravelClub 书籍用例的实用示例进行阐明,该用例位于第二十一章,案例研究。您可以在github.com/PacktPublishing/Software-Architecture-with-C-Sharp-12-and-.NET-8-4E
找到本章的代码。
理解 SOA 方法的原则
就像面向对象架构中的类一样,服务是实现接口的实现,而这些接口又来自系统的功能规范。因此,服务设计的第一步是定义其抽象接口。在这个初始阶段,您可能会有两种方法:
-
将所有服务操作定义为操作您喜欢的语言(C#、Java、C++、JavaScript 等)类型的接口方法,并决定哪些操作使用同步通信实现,哪些操作使用异步通信实现。
-
首先以互操作格式创建合同。在这种方法中,您可以使用定义文件,例如 OpenAPI、Protobuf、WSDL 和 AsyncAPI 的模式,而不必接触将用于开发服务的编程语言,使用一些工具来帮助。
在这个初始阶段定义的接口不一定会在实际的服务实现中使用,它们只是有用的设计工具。一旦我们决定了服务的架构,这些接口通常会被重新定义,以便我们可以根据架构的特定性来调整它们。
值得指出的是,SOA 消息必须保持与方法调用/响应相同的语义。此外,SOA 遵循无状态开发;也就是说,对消息的反应必须不依赖于任何之前收到的消息,因为服务器不会保存先前请求的信息,这意味着消息必须相互独立。
例如,如果消息的目的是创建一个新的数据库条目,这种语义必须不因其他消息的上下文而改变,并且数据库条目的创建方式必须依赖于当前消息的内容,而不是其他之前收到的消息。因此,客户端不能创建会话,也不能登录到服务,执行一些操作,然后注销。必须在每条消息中重复使用身份验证令牌。
这种约束的原因是模块化、可测试性和可维护性。事实上,由于会话数据中隐藏的交互,基于会话的服务将非常难以测试和修改。
一旦您决定了一个服务将要实现的服务接口,您必须决定采用哪种通信堆栈/SOA。通信堆栈必须是某些官方或事实上的标准的一部分,以确保服务的互操作性。
互操作性是 SOA 规定的最主要约束:服务必须提供一个不依赖于特定库、实现语言或部署平台的通信接口。
考虑到你已经决定了通信栈/架构,你需要将之前的接口适应架构的特定性(有关更多详细信息,请参阅本章的REST Web 服务小节)。然后,你必须将这些接口转换为所选的通信语言。这意味着你必须将所有编程语言类型映射到所选通信语言中可用的类型。
数据的实际翻译通常由开发环境使用的 SOA 库自动执行。然而,可能需要一些配置,在任何情况下,我们必须了解在每次通信之前我们的编程语言类型是如何转换的。例如,某些数值类型可能被转换为精度较低或具有不同值范围的类型。例如,在.NET 8 中,你应该意识到浮点数值类型之间有所不同:float (~6-9 位数字),double (~15-17 位数字)和decimal (~28-29 位数字)。你可以考虑使用字符串变量作为替代方案,以降低在传输数值类型时的精度风险。
对于无法访问其集群之外的微服务,互操作性约束可以以较轻的形式解释,因为这些微服务需要与其他属于同一集群的微服务进行通信。在这种情况下,这意味着通信栈可能是平台特定的,以便提高性能,但它必须是标准的,以避免与其他微服务兼容性问题,这些微服务可能会随着应用程序的发展而添加到集群中。
我们讨论的是通信栈而不是通信协议,因为 SOA 通信标准通常定义消息内容的格式,并为嵌入这些消息的特定协议提供不同的可能性。例如,REST 服务通常基于 JSON 消息在 HTTP/HTTPS 上运行,而 SOAP 协议仅定义了基于 XML 的各种消息格式,但 SOAP 消息可以通过各种协议传递。通常,用于 SOAP 的最常见协议也是 HTTP,但你可能决定跳到 HTTP 级别,并通过 TCP/IP 直接发送 SOAP 消息以获得更好的性能。
你应该采用的通信栈的选择取决于以下描述的几个因素。当涉及到访问数据时,通信栈可能是强制性的,由提供商决定,但你在提供服务时也应关注这些因素:
-
兼容性约束:如果你的服务必须向商业客户公开在互联网上,那么你必须遵守最常见的选择,这意味着使用基于 HTTP 或 REST 服务的 SOAP。如果您的客户不是商业客户而是物联网(IoT)客户,那么最常见的选择可能会有所不同。在物联网内部,不同应用领域使用的协议也可能不同。例如,海洋车辆状态数据通常使用Signal K进行交换。尽管这个协议过于具体,这里仅作为示例,但作为一名软件架构师,你必须理解你可能会在特定领域遇到这种标准。
-
开发/部署平台:并非所有通信堆栈都可在所有开发框架和所有部署平台上使用,但幸运的是,所有在公共商业服务中使用的最常见通信堆栈,如基于 SOAP 和 JSON 的 REST 通信,都可在所有主要开发/部署平台上使用。
-
性能:如果您的系统没有暴露给外界,而是您微服务集群的私有部分,那么性能考虑的优先级更高。在这种情况下,我们将在本章稍后讨论的 gRPC 可以被视为一个好的选择。
-
团队/组织中工具和知识的可用性:在考虑选择可接受的通信堆栈时,了解您的团队/组织中工具的可用性很重要。
然而,这种类型的约束总是比兼容性约束的优先级低,因为设计一个对您的团队来说容易实现但对几乎没有人有用的系统是没有意义的。
-
灵活性与可用功能:一些通信解决方案虽然不够完整,但提供了更高的灵活性,而其他解决方案虽然更完整,但提供的灵活性较低。对灵活性的需求在过去的几年中引发了一场从基于 SOAP 的服务到更灵活的 REST 服务的运动。当我们在本节剩余部分描述 SOAP 和 REST 服务时,这一点将更详细地讨论。
-
服务描述:当服务必须在互联网上公开时,客户端应用程序需要公开的服务规范描述来设计它们的通信客户端。一些通信堆栈包括用于描述服务规范的编程语言和约定。以这种方式公开的形式化服务规范可以被处理,以便自动创建通信客户端。SOAP 更进一步,通过包含有关每个 Web 服务可以执行的任务信息的公共 XML 目录,允许服务可发现性。
一旦您选择了希望使用的通信栈,您就必须使用开发环境中可用的工具以符合所选通信栈的方式实现服务。有时,开发工具会自动确保通信栈的合规性,但有时可能需要一些开发工作。例如,在 .NET 世界中,如果您使用 WCF,开发工具会自动确保 SOAP 服务的合规性,而 REST 服务的合规性则落在开发者的责任之下,尽管从 .NET 5 开始,Swagger 提供了对 OpenAPI 标准的自动支持。以下是一些 SOA 解决方案的基本功能:
-
身份验证:允许客户端进行身份验证以访问服务操作。
-
授权:处理客户端的权限。
-
安全:这是如何保持通信安全,即如何防止未经授权的系统读取和/或修改通信内容。通常,加密可以防止未经授权的修改和读取,而电子签名算法可以防止仅修改。
-
异常:向客户端返回异常。
-
消息可靠性:确保在可能的基础设施故障的情况下,消息可靠地到达其目的地。
虽然有时是可取的,但以下功能并不总是必要的:
-
分布式事务:处理分布式事务的能力,因此当分布式事务失败或被中止时,可以撤销您所做的所有更改。
-
支持发布/订阅模式:是否以及如何支持事件和通知。
-
寻址:是否以及如何支持对其他服务和/或方法的引用。
-
路由:是否以及如何将消息通过服务网络进行路由。
本节的剩余部分将简要描述 SOAP 服务。然而,重点将是 REST 服务,因为今天它们是公开其集群/服务器之外的商业服务的事实标准。出于性能原因,微服务使用其他协议,这些协议在第十一章、将微服务架构应用于您的企业应用、第十四章、使用 .NET 实现微服务中进行了讨论。对于集群间通信,使用高级消息队列协议(AMQP),并在进一步阅读部分提供了链接。
SOAP 网络服务
简单对象访问协议(SOAP)允许单向消息和请求/回复消息。通信可以是同步的,也可以是异步的,如第一章“理解软件架构的重要性”和第二章“非功能性需求”中所述,但如果底层协议是同步的,例如在 HTTP 的情况下,发送者会收到一个确认消息,表明消息已被接收(但不一定已处理)。当使用异步通信时,发送者必须监听传入的通信。通常,异步通信使用发布/订阅模式实现,我们在第六章“设计模式和.NET 8 实现”中描述了该模式。
消息表示为称为信封的 XML 文档。每个信封包含header
(头部)、body
(主体)和fault
(错误)元素。body
是放置实际消息内容的地方。fault
元素包含可能的错误,因此它是通信发生时交换异常的方式。最后,header
包含任何丰富协议但不包含域数据的辅助信息。例如,header
可能包含一个身份验证令牌和/或签名,如果消息被签名的话。
您可以在www.w3.org/2003/05/soap-envelope/
找到 SOAP 信封的默认命名空间。
用于发送 XML 信封的底层协议通常是 HTTP,因为这是互联网的协议,但 SOAP 规范允许使用任何协议,因此我们可以直接使用 TCP/IP 或 SMTP。事实上,更广泛使用的底层协议是 HTTP,所以如果没有充分的理由选择其他协议,应该使用 HTTP 以最大化服务的互操作性。
SOAP 规范
SOAP 规范包含消息交换的基本内容,而其他辅助功能则在单独的规范文档中描述,称为WS-
*
,通常通过在 SOAP 头部添加额外信息来处理。WS-*
规范处理我们之前列出的 SOA 的基本和期望功能。以下是一些例子:
WS-* | 主要目标 |
---|---|
WS-Security |
负责安全,包括身份验证、授权和加密/签名 |
WS-Eventing / WS-Notification |
两种实现发布/订阅模式的替代方法 |
WS-ReliableMessaging |
关注在可能出现故障的情况下消息的可靠传递 |
WS-Transaction |
关注分布式事务 |
表 15.1:关键 WS-*规范及其在基于 SOAP 的 SOA 中的主要目标总结
之前的 WS-*
规范并不全面,但它们是更相关和支持的功能。实际上,在各个环境(如 Java 和 .NET)中的实际实现提供了更相关的 WS-*
服务,但没有一种实现支持所有 WS-*
规范。
所有参与 SOAP 协议的 XML 文档/文档部分都在 XSD 文档中正式定义,如下例所示,这些是特殊的 XML 文档,其内容提供了 XML 结构的描述。
<?xml version="1.0"?>
<xs:schema id="sample" targetNamespace="http://tempuri.org/sample.xsd" elementFormDefault="qualified" xmlns="http://tempuri.org/sample.xsd" xmlns:xs="http://www.w3.org/2001/XMLSchema">
<xs:element name='mySample'>
<xs:complexType>
<xs:simpleContent>
<xs:extension base='xs:decimal'>
<xs:attribute name='sizing' type='xs:string' />
</xs:extension>
</xs:simpleContent>
</xs:complexType>
</xs:element>
</xs:schema>
此外,如果您的自定义数据结构(面向对象语言中的类和接口)要成为 SOAP 封装的一部分,它们必须被转换为 XSD。
每个 XSD 规范都有一个相关的 namespace
,用于标识规范及其物理位置,该位置可以找到。命名空间和物理位置都是 URI。如果 Web 服务仅从内部网络访问,则位置 URI 不需要公开可访问。
服务整体定义是一个 XSD 规范,可能包含对其他命名空间的引用,即对其他 XSD 文档的引用。简单来说,所有通过 SOAP 通信的消息都必须在 XSD 规范中定义。然后,如果服务器和客户端引用相同的 XSD 规范,它们就可以进行通信。这意味着,例如,每次向消息添加另一个字段时,都需要创建一个新的 XSD 规范。之后,需要通过创建新版本来更新所有引用旧消息定义的 XSD 文件,以指向新消息定义。反过来,这些修改需要为其他 XSD 文件创建其他版本,依此类推。因此,保持与先前行为兼容的简单修改(客户端可以简单地忽略添加的字段)可能导致版本变化的指数级链式反应。
与标准相关的问题
在过去几年中,处理修改的难度,以及处理所有 WS-*
规范的配置复杂性和性能问题,导致逐渐转向我们将在后续章节中描述的更简单的 REST 服务。这种转变始于由于实现能够在浏览器中高效运行的完整 SOAP 客户端的困难,而调用 JavaScript 中的服务。此外,复杂的 SOAP 机制对于在浏览器中运行的典型客户端的简单需求来说过大,可能导致了开发时间的完全浪费。
因此,针对非 JavaScript 客户端的服务开始大规模转向 REST 服务,如今,首选的选择是 REST 服务,SOAP 仅用于与遗留系统兼容或当需要 REST 服务不支持的功能时。
今天,我们可以考虑使用 JSON 进行数据传输的 REST 服务,这是全球最常用的方法。安全性、支持事务的设计模式、性能以及甚至文档等方面在过去的几年里都有所改进,因此这无疑是现在应用 SOA 的最佳选择。让我们在下一个主题中看看 REST 网络服务。
REST 网络服务
REST 服务最初是为了避免在简单情况下(例如从网页的 JavaScript 代码调用服务)使用 SOAP 的复杂机制而设计的。然后,它们逐渐成为复杂系统的首选选择。REST 服务使用 HTTP 以 JSON 或较少情况下以 XML 格式交换数据。简单来说,它们用 HTTP 体替换 SOAP 体,用 HTTP 头替换 SOAP 头,用 HTTP 响应代码替换错误元素,并提供了关于所执行操作的其他辅助信息。
REST 服务成功的主要原因在于 HTTP 已经提供了 SOAP 的大部分功能,这意味着我们可以避免在 HTTP 之上构建 SOAP 层。此外,整个 HTTP 机制比 SOAP 简单:编程更简单,配置更简单,实现效率更高。
此外,REST 服务对客户端的约束较少。服务器和客户端之间的类型兼容性符合更灵活的 JavaScript 类型兼容性模型,因为 JSON 是 JavaScript 的一个子集。此外,当使用 XML 代替 JSON 时,它保持相同的 JavaScript 类型兼容性规则。不需要指定 XML 命名空间。
当使用 JSON 和 XML 时,如果服务器在保持所有其他字段与先前客户端兼容的相同语义的同时添加了一些更多字段,它们可以简单地忽略这些新字段。相应地,对 REST 服务定义所做的更改只需在导致服务器实际不兼容行为的破坏性更改的情况下传播给先前客户端。
此外,变化很可能是自我限制的,不会导致指数级的变化链,因为类型兼容性不需要在唯一共享的位置定义特定类型的引用,只需确保类型形状兼容即可。
服务类型兼容性规则
让我们通过一个例子来明确 REST 服务类型兼容性规则。想象一下,几个服务使用一个包含Name
、Surname
和Address
字符串字段的Person
对象。这个对象由S1提供服务:
{
Name: string,
Surname: string,
Address: string
}
如果服务和客户端引用的是先前定义的不同副本,则类型兼容性得到保证。客户端使用具有较少字段的定义也是可以接受的,因为它可以简单地忽略所有其他字段:
{
Name: string,
Surname: string,
}
你只能在“自己的”代码中使用具有较少字段的定义。尝试在没有预期字段的情况下将信息发送回服务器可能会导致问题。
现在,想象一下这样的场景:你有一个S2服务,它从S1服务中获取Person
对象并将它们添加到其某些方法返回的响应中。假设处理Person
对象的S1服务将Address
字符串替换为一个复杂对象:
{
Name: string,
Surname: string,
Address:
{
Country: string,
Town: string,
Location: string
}
}
在破坏性变更之后,S2服务将不得不调整其调用S1服务的通信客户端,以适应新的格式。然后,它可以将新的Person
格式转换为旧格式,在响应中使用Person
对象之前。这样,S2服务就可以避免传播S1的破坏性变更。
通常,基于对象形状(嵌套属性的树)而不是对同一正式类型定义的引用来建立类型兼容性,可以增加灵活性和可修改性。我们为此增加的灵活性所付出的代价是,类型兼容性不能通过比较服务器和客户端接口的正式定义来自动计算。事实上,在没有统一规范的情况下,每次发布服务的新版本时,开发者都必须验证客户端和服务器共有的所有字段的语义与上一个版本保持不变。
REST 服务背后的基本思想是放弃严格的检查和复杂的协议,以获得更大的灵活性和简单性,而 SOAP 则恰恰相反。
REST 和原生 HTTP 功能
REST 服务宣言指出,REST 使用原生 HTTP 功能来实现所有所需的服务功能。例如,认证将通过 HTTP 的Authorization
字段直接执行,加密将通过 HTTPS 实现,异常将通过 HTTP 错误状态码处理,而路由和可靠消息传递将由 HTTP 协议依赖的机制处理。通过使用 URL 来引用服务、其方法和其他资源来实现寻址。
由于 HTTP 是同步协议,因此没有原生对异步通信的支持。也没有原生对发布者/订阅者模式的支持,但两个服务可以通过各自向对方暴露端点来交互使用发布者/订阅者模式。更具体地说,第一个服务暴露一个订阅端点,而第二个服务暴露一个接收其通知的端点,这些通知通过在订阅期间交换的通用密钥进行授权。这种模式相当常见。GitHub 还允许我们将仓库事件发送到我们的 REST 服务。
REST 服务在实现分布式事务时没有提供简单的选项,因为 HTTP 是无状态的。然而,像在 第十四章,使用 .NET 实现微服务 中描述的 SAGA 模式,以及在 第七章,理解软件解决方案中的不同领域 中描述的事件溯源,在过去几年中极大地帮助解决了这个难题。此外,幸运的是,大多数应用领域不需要分布式事务确保的强一致性形式。对于它们,更轻量级的一致性形式,如 最终一致性,就足够了,并且出于性能原因更受欢迎。请参阅 第十二章,在云中选择您的数据存储,以了解各种一致性类型的讨论。
REST 宣言不仅规定了使用 HTTP 中已可用的预定义解决方案,还规定了使用类似网络的语义。一般来说,服务操作可以被视为 CRUD 操作,但不仅限于它们在由 URL 标识的资源上(同一资源可能由多个 URL 标识)。实际上,REST 是 Representational State Transfer 的缩写,意味着每个 URL 都是某种实体的表示。作为最佳实践,每种类型的服务请求都需要采用适当的 HTTP 动词,并按以下方式返回状态码:
-
GET
(读取操作): URL 表示由读取操作返回的资源。因此,GET
操作模拟指针解引用。在操作成功的情况下,返回 200 (OK) 状态码。 -
POST
(创建操作): 请求体中包含的 JSON/XML 对象作为新资源添加到由操作 URL 表示的对象中。如果新资源立即成功创建,则返回 201 (已创建) 状态码,以及一个依赖于操作和指示从何处检索创建的资源响应对象。响应对象应包含标识创建资源的最具体 URL。如果创建被延迟到以后的时间,则返回 202 (已接受) 状态码。 -
PUT
(编辑操作): 请求体中包含的 JSON/XML 对象替换了由请求 URL 引用的对象。在操作成功的情况下,返回 200 (OK) 状态码。此操作是幂等的,意味着重复相同的请求两次会导致相同的修改。204 (无内容) 也是一个可能的返回值。 -
PATCH
: 请求体中包含的 JSON/XML 对象包含如何修改由请求 URL 引用的对象的说明。由于修改可能是一个数值字段的增量,此操作不是幂等的。在操作成功的情况下,返回 200 (OK) 状态码。 -
DELETE
: 删除由请求 URL 引用的资源。在操作成功的情况下,返回 200 (OK) 状态码。
如果资源已从请求 URL 移动到另一个 URL,则返回重定向代码:
-
301
(永久移动),以及我们可以找到资源的新的 URL -
307
(临时移动),以及我们可以找到资源的新的 URL
如果操作失败,则返回一个依赖于失败类型的状态码。以下是一些失败代码的示例:
-
400
(请求错误):发送到服务器的请求格式不正确。 -
404
(未找到):当请求 URL 不指向任何已知对象时。 -
405
(方法不允许):当请求动词不受 URL 引用的资源支持时。 -
401
(未授权):操作需要身份验证,但客户端未提供任何有效的授权头。 -
403
(禁止):客户端正确认证,但没有权利执行操作。 -
409
(冲突):由于与服务器当前状态存在冲突,操作失败。 -
412
(先决条件失败):由于某些先决条件未满足,操作失败。 -
422
(不可处理的内容):请求格式良好,但其中存在语义错误。
上述状态码列表并不完整。在进一步阅读部分将提供完整的列表。
需要强调的是,POST
/PUT
/PATCH
/DELETE
操作可能——并且通常——对其他资源有副作用。否则,将无法编码同时作用于多个资源的操作。
换句话说,HTTP 动词必须符合由请求 URL 引用并执行的操作,但该操作可能影响其他资源。相同的操作可能使用不同的 HTTP 动词在涉及的其他资源上执行。选择以哪种方式执行相同的操作以在服务接口中实现它是开发者的责任。
由于 HTTP 动词的副作用,REST 服务可以将所有这些操作编码为对由 URL 表示的资源进行的 CRUD 操作。
通常,将现有服务迁移到 REST 需要我们在请求 URL 和请求体之间分割各种输入。更具体地说,我们提取那些唯一定义方法执行中涉及的某个对象的输入字段,并使用它们来创建一个唯一标识该对象的 URL。然后,我们根据对所选对象执行的操作来决定使用哪个 HTTP 动词。最后,我们将输入的其余部分放在请求体中。
如果我们的服务是以面向对象架构设计的,该架构专注于业务域对象(例如,如第七章,理解软件解决方案中的不同域中描述的 DDD),那么所有服务方法的 REST 转换应该相当直接,因为服务应该已经围绕域资源组织。否则,迁移到 REST 可能需要重新定义一些服务接口。
采用完整的 REST 语义具有这样的优势:服务可以在不修改现有操作定义的情况下进行扩展,也可以在修改现有操作定义的情况下进行扩展。实际上,扩展应主要表现为某些对象的新属性以及一些相关操作的新资源 URL。因此,现有的客户端可以简单地忽略它们。
REST 语言中方法的示例
现在,让我们通过一个简单的银行内部转账示例来学习如何在 REST 语言中表达方法。我们将在这里介绍两种方法。
在第一种情况下,一个银行账户可以如下通过 URL 表示:
https://mybank.com/accounts/{银行账户号}
如果我们想象一个银行转账,我们可以将其表示为一个PATCH
请求,其体包含一个对象,该对象具有表示金额、转账时间、描述以及接收资金的账户的属性。
该操作修改了 URL 中提到的账户以及接收账户作为副作用。如果账户没有足够的钱,则返回409
(冲突)状态码,以及包含所有错误详情的对象(错误描述、可用资金等)。
然而,由于所有银行操作都记录在账户报表中,因此为与银行账户关联的“银行账户操作”集合创建和添加一个新的转账对象来表示转账是一种更好的方式。在这种第二种方法中,URL 可能如下所示:
https://mybank.com/accounts/{银行账户号}/transactions
在这里,由于我们正在创建一个新的对象,所以 HTTP 动词是POST
。正文内容相同,如果资金不足,则返回422
状态码。
两种传输表示都会在数据库中引起相同的变化。此外,一旦从不同的 URL 和可能不同的请求体中提取了输入,后续的处理就是相同的。在两种情况下,我们都有相同的输入和相同的处理——只是两个请求的外部表现不同。
然而,虚拟交易集合的引入使我们能够通过几个更多交易集合特定的方法来扩展服务。值得注意的是,交易集合不需要与数据库表或任何物理对象相关联:它存在于 URL 的世界中,为我们提供了一个方便的方式来模拟转账。
REST 服务的使用增加导致需要创建 REST 服务接口的描述,就像为 SOAP 开发的那些一样。这个标准被称为OpenAPI。我们将在下一个子节中讨论这个问题。
OpenAPI 标准
OpenAPI 是一个用于描述 REST API 的全球性规范。OpenAPI 倡议成立于 2015 年 11 月,作为一个在 Linux 基金会下的开源项目,由 SmartBear、Google、IBM 和 Microsoft 等公司支持。该规范目前版本为 3.1。整个服务通过一个 JSON 或 YAML 端点进行描述,即一个使用 JSON 对象描述服务的端点。这个 JSON 对象有一个通用部分,适用于整个服务,并包含服务的一般特性,如其版本和描述,以及共享定义。
您可以在github.com/OAI/OpenAPI-Specification/
找到 OpenAPI 规范示例。
然后,每个服务端点都有一个特定部分,描述端点 URL 或 URL 格式(如果 URL 中包含一些输入),所有输入,所有可能的输出类型和状态码,以及所有授权协议。每个端点特定部分可以引用通用部分中包含的定义。
本书中不包含 OpenAPI 语法的完整描述,但您可以在互联网上找到可视化编辑器,这些编辑器可以帮助您澄清之前提到的规范。SmartBear 公司提供了一个很好的例子,该公司是这项倡议的发起者之一,它被称为 Swagger Editor。在在线工具的测试版中,您可以使用 OpenAPI 3.1.0 版本加载一个示例。这有助于公司在决定 API 的编程语言之前就创建 API 合同。
不同的开发框架通过处理 REST API 代码自动生成 OpenAPI 文档,并且开发者提供了更多信息,因此您的团队不需要深入了解 OpenAPI 语法。本章节中我们将介绍的Swashbuckle.AspNetCore
NuGet 包就是一个例子。
“.NET 8 如何处理 SOA?”这一节解释了如何在 ASP.NET Core REST API 项目中自动生成 OpenAPI 文档,而第二十一章,案例研究中提出的用例将提供一个其实际应用的例子。
我们将通过讨论如何在 REST 服务中处理认证和授权来结束本小节。
REST 服务授权和认证
由于 REST 服务是无状态的,当需要认证时,客户端必须在每个请求中发送一个认证令牌。该令牌通常放置在 HTTP 授权头部,但这取决于您所使用的认证协议类型。最简单的认证方式是通过显式传输共享密钥。这可以通过以下代码实现:
Authorization: Api-Key <string known by both server and client>
共享密钥被称为 API 密钥。由于在撰写本文时,尚无关于如何发送它的标准,因此 API 密钥也可以通过其他头部发送,如下面的代码所示:
X-API-Key: <string known by both server and client>
值得注意的是,基于 API 密钥的认证需要 HTTPS 来防止共享密钥被盗。API 密钥非常易于使用,但它们不传达有关用户授权的信息,因此当客户端允许的操作相当标准且没有复杂的授权模式时,可以采用它们。此外,当在请求中交换时,API 密钥容易受到服务器或客户端端的攻击。一种常见的缓解方法是创建一个“服务帐户”用户,并限制其授权仅限于所需权限,并在与 API 交互时使用该特定帐户的 API 密钥。
如果您需要一个更复杂的认证服务,您可以考虑使用 OAuth 2.0 协议。例如,当您实现“使用[某些特定的社交媒体]登录”时,您可能正在使用此协议。当然,为了使用它,您必须定义一个认证服务提供商。
更安全的技巧使用有效的长期共享密钥,只需用户登录即可。然后,登录返回一个短期令牌,该令牌在所有后续请求中用作共享密钥。当短期密钥即将到期时,可以通过调用续订端点来更新它。
整个逻辑与基于短期令牌的授权逻辑完全解耦。登录通常基于接收长期凭证并返回短期令牌的登录端点。登录凭证可以是作为登录方法输入的常规用户名-密码对,也可以是其他类型的授权令牌,这些令牌被转换为由登录端点提供的短期令牌。登录还可以通过基于 X.509 证书的各种认证协议实现。
最常见的短期令牌类型是所谓的持证人令牌。每个持证人令牌都编码了有关其持续时间的详细信息以及一系列称为声明的断言列表,这些声明可用于授权目的。持证人令牌由登录操作或续订操作返回。它们的特征是它们不与接收它们的客户端或任何其他特定客户端绑定,而是识别客户端,该客户端可以简单地在其调用中使用它们。
无论客户端如何获得持证人令牌,这都是客户端需要获得的所有内容,包括其声明中隐含的所有权利。只需将持证人令牌转移到另一个客户端,就可以赋予该客户端由所有持证人令牌声明隐含的所有权利,因为基于持证人令牌的授权不需要身份证明。
因此,一旦客户端获得持证人令牌,它就可以通过将其持证人令牌转移到第三方来委托一些操作。通常,当必须使用持证人令牌进行委托时,在登录阶段,客户端指定要包含的声明以限制令牌可以授权的操作。
与 API 密钥认证相比,基于令牌的认证受到标准的约束。它们必须使用以下Authorization
头:
Authorization: Bearer <bearer token string>
携带令牌可以以多种方式实现。REST 服务通常使用 JWT,这些 JWT 是具有 Base64 URL 编码的 JSON 对象的字符串。更具体地说,JWT 的创建从 JSON 头开始,以及一个 JSON 有效载荷。JSON 头指定了令牌的类型及其签名方式,而有效载荷由一个包含所有声明作为属性/值对的 JSON 对象组成。以下是一个示例头:
{
"alg": "RS256",
"typ": "JWT"
}
以下是一个示例有效载荷:
{
"iss": "wwtravelclub.com"
"sub": "example",
"aud": ["S1", "S2"],
"roles": [
"ADMIN",
"USER"
],
"exp": 1512975450,
"iat": 1512968250230
}
然后,将头和有效载荷进行 Base64 URL 编码,并按以下方式连接相应的字符串:
<header BASE64 string>.<payload base64 string>
然后使用头中指定的算法对前面的字符串进行签名,在我们的例子中是RSA + SHA256
,然后将签名字符串按以下方式与原始字符串连接:
<header BASE64 string>.<payload base64 string>.<signature string>
前面的代码是最终的令牌字符串。可以使用对称签名代替 RSA,但在此情况下,JWT 发行者和所有使用它进行授权的服务必须共享一个共同的秘密,而使用 RSA 时,JWT 发行者的私钥不需要与任何人共享,因为可以使用发行者的公钥来验证签名。
一些有效载荷属性是标准的,如下所示:
-
iss
:JWT 的发行者。 -
aud
:受众,即可以使用令牌进行授权的服务和/或操作。如果一个服务没有在这个列表中看到其标识符,它应该拒绝该令牌。 -
sub
:一个字符串,用于标识 JWT 发行的主体(即用户)。 -
iat
、exp
和nbf
:这些分别代表 JWT 的发行时间、过期时间以及如果设置了,则代表令牌有效的起始时间。所有时间都是以 1970 年 1 月 1 日午夜 UTC 为起点的秒数表示。在这里,所有天都被认为是包含 86,400 秒。
如果我们用唯一的 URI 来表示其他声明,那么这些声明可以定义为公开的;否则,它们被认为是发行者及其已知服务的私有信息。
API 版本控制
考虑到这样一个自然场景,即你的应用程序中 API 的数量将会增加,而且,更重要的是,业务逻辑显然会演变,作为一个软件架构师,你必须决定你将如何对 API 进行版本控制,以确保你的服务和消费这些服务的客户端之间的兼容性。
重要的是要提到,为此有几种版本控制选项:
-
URI:它包括在 URI 中定义 API 的版本,例如,
https://wwtravelclub.com/v1/trips
。 -
参数:你可以在请求中定义一个参数来定义版本,例如,
https://wwtravelclub.com/trips?version=2
。 -
媒体类型:在这种情况下,所需的 API 版本将通过 HTTP
Accept
头呈现。 -
自定义请求头:类似于媒体类型版本控制技术,但在此情况下,HTTP 头将由您自定义。
前两种方案是最常用的,但这里的重要一点是,你必须认为版本控制技术的实现至关重要。
.NET 8 如何处理 SOA?
WCF 技术尚未移植到 .NET 5+,并且没有计划进行完整的移植。部分源代码已被捐赠,并由此启动了一个开源项目。您可以在 github.com/CoreWCF/CoreWCF
找到关于此项目的更多信息。相反,微软正在投资于 Google 的开源技术 gRPC。此外,.NET 8 通过 ASP.NET Core 对 REST 服务提供了出色的支持。
微软开发了一个工具来帮助您将 WCF 应用程序迁移到最新的 .NET。您可以在 devblogs.microsoft.com/dotnet/migration-wcf-to-corewcf-upgrade-assistant/
找到它。
放弃 WCF 的主要原因是以下几方面:
-
正如我们已经讨论过的,在大多数应用领域,SOAP 技术已被 REST 技术取代。
-
WCF 技术与 Windows 紧密相关,因此从头开始重新实现其所有功能在 .NET 5+ 中将非常昂贵。由于对全 .NET 的支持将继续,需要 WCF 的用户仍然可以依赖它。
-
作为一种一般策略,从 .NET 5+ 开始,微软更倾向于投资于可以与其他竞争对手共享的开源技术。这就是为什么微软没有投资于 WCF,而是从 .NET Core 3.0 开始提供了 gRPC 实现。
下一个子节将涵盖 Visual Studio 为我们提到的每种技术提供的支持。
SOAP 客户端支持
在 WCF 中,服务规范是通过 .NET 接口定义的,实际的服务代码是在实现这些接口的类中提供的。端点、底层协议(HTTP 和 TCP/IP)以及任何其他功能都在一个配置文件中定义。反过来,配置文件可以使用一个易于使用的配置工具进行编辑。因此,开发者只需提供作为标准 .NET 类的服务行为,并以声明性方式配置所有服务功能。这样,服务配置与实际服务行为完全解耦,每个服务都可以重新配置,以便能够适应不同的环境,而无需修改其代码。
虽然.NET 8 不支持 SOAP 技术来创建新服务,但它支持在存在许多 SOAP 服务作为遗留技术的情况下使用 SOAP 客户端。更具体地说,在 Visual Studio 中为现有的 SOAP 服务创建 SOAP 服务代理非常简单(请参阅第六章,设计模式和.NET 8 实现,以讨论代理是什么以及代理模式)。
在服务的情况下,代理是一个实现服务接口的类,其方法通过调用远程服务的类似方法来完成其工作。
要创建服务代理,在解决方案资源管理器中右键单击项目中的依赖项,然后选择添加连接的服务。然后,在出现的表单中,选择Microsoft WCF 服务引用提供程序。在那里,您可以指定服务的 URL(其中包含 WSDL 服务描述),您希望添加代理类的命名空间,以及更多。在向导结束时,Visual Studio 自动添加所有必要的 NuGet 包并构建代理类。这足以创建此类的一个实例并调用其方法,以便我们可以与远程 SOAP 服务交互。
此外,还有一些第三方工具,例如 NuGet 包,为 SOAP 服务提供有限的支持,但当前它们并不非常实用,因为这种有限的支持不包括 REST 服务中不可用的功能。
gRPC 支持
.NET SDK 支持 gRPC 项目模板,该模板可以构建一个 gRPC 服务器和一个 gRPC 客户端。gRPC 实现了一种远程过程调用模式,提供同步和异步调用,从而减少了客户端和服务器之间消息的流量。
使用 gRPC 非常简单,因为 Visual Studio 的 gRPC 项目模板自动构建了一切,使得 gRPC 服务和其客户端可以正常工作。开发者只需定义特定于应用程序的 C#服务接口及其实现类。
对于配置,服务是通过 Protobuf 文件中编写的接口定义的,并且它们的代码由实现这些接口的 C#类提供,而客户端通过实现相同服务接口的代理与这些服务交互。
gRPC 是微服务集群内部通信的良好选择。由于所有主要语言和开发框架都有 gRPC 库,因此它可以在基于 Kubernetes 的集群中使用。此外,由于 gRPC 具有更紧凑的数据表示方式,并且由于其使用起来更简单,因为与协议相关的一切都由开发框架处理,因此 gRPC 比 REST 服务协议更高效。
因此,我们新增了一章专门讨论这种实现,即第十四章“使用.NET 实现微服务”,您可以在docs.microsoft.com/en-us/aspnet/core/tutorials/grpc/grpc-start?view=aspnetcore-8.0
查看有关技术的详细信息。
本节剩余部分将致力于介绍.NET 对 REST 服务的服务器和客户端支持。
ASP.NET Core 简介简述
ASP.NET Core 应用程序是基于我们在第十一章“将微服务架构应用于您的企业应用程序”的“使用通用宿主”子节中描述的Host概念的.NET 应用程序。
使用 C# 12 和.NET 8,创建 ASP.NET Core 应用程序的模板有所改变。主要目的是简化我们的设置方式。每个 ASP.NET 应用程序的Program.cs
文件现在创建一个宿主,构建它,并运行它,不再需要Startup
类,如下面的代码所示:
var builder = WebApplication.CreateBuilder(args);
// Add services to the container.
builder.Services.AddControllers();
// Learn more about configuring Swagger/OpenAPI at https://aka.ms/aspnetcore/swashbuckle
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();
Environment
变量来自ASPNETCORE_ENVIRONMENT
环境变量。反过来,当应用程序在 Visual Studio 的解决方案资源管理器中运行时,它定义在Properties\launchSettings.json
文件中。在这个文件中,你可以定义几个环境,可以通过 Visual Studio 运行按钮旁边的下拉菜单选择,IIS Express。默认情况下,IIS Express设置将ASPNETCORE_ENVIRONMENT
设置为Development
。以下是一个典型的launchSettings.json
文件:
{
"iisSettings": {
"windowsAuthentication": false,
"anonymousAuthentication": true,
"iisExpress": {
"applicationUrl": "http://localhost:48638",
"sslPort": 44367
}
},
"profiles": {
"http": {
"commandName": "Project",
"dotnetRunMessages": true,
"launchBrowser": true,
"launchUrl": "swagger",
"applicationUrl": "http://localhost:5085",
"environmentVariables": {
"ASPNETCORE_ENVIRONMENT": "Development"
}
},
"https": {
"commandName": "Project",
"dotnetRunMessages": true,
"launchBrowser": true,
"launchUrl": "swagger",
"applicationUrl": "https://localhost:7214;http://localhost:5085",
"environmentVariables": {
"ASPNETCORE_ENVIRONMENT": "Development"
}
},
"IIS Express": {
"commandName": "IISExpress",
"launchBrowser": true,
"launchUrl": "swagger",
"environmentVariables": {
"ASPNETCORE_ENVIRONMENT": "Development"
}
}
}
}
当应用程序发布时,用于ASPNETCORE_ENVIRONMENT
的值可以在 Visual Studio 创建后添加到发布的 XML 文件中。此值为<EnvironmentName>Staging</EnvironmentName>
。它也可以在您的 Visual Studio ASP.NET Core 项目文件(.csproj
)中指定:
<PropertyGroup>
<EnvironmentName>Staging</EnvironmentName>
</PropertyGroup>
管道中的每个中间件都由一个app.Use<something>
方法定义,它通常接受一些选项。每个都会处理请求,然后将修改后的请求转发到管道中的下一个,或者返回一个 HTTP 响应。当返回 HTTP 响应时,它将按相反顺序处理所有之前的中间件。
模块按照app.Use<something>
方法定义的顺序插入到管道中。前面的代码在ASPNETCORE_ENVIRONMENT
为Development
时添加一个错误页面。关于 ASP.NET Core 管道的完整描述将在第十七章“理解 Web 应用程序的表现层”部分中给出。
在下一小节中,我们将解释 MVC 框架如何让您实现 REST 服务。
使用 ASP.NET Core 实现 REST 服务
今天,我们可以保证 MVC 和 Web API 的使用已经得到巩固。在 MVC 框架中,HTTP 请求由称为控制器的类处理。每个请求都映射到控制器公共方法的调用。所选控制器及其控制器方法取决于请求路径的形状,它们由路由规则定义,对于 REST API,这些规则通常通过与 Controller
类及其方法关联的属性提供。
ASP.NET Core 6 引入了最小 API 以简化使用 C# 实现 API 的机制。您可以在docs.microsoft.com/en-us/aspnet/core/fundamentals/minimal-apis
找到关于它的良好解释。
处理 HTTP 请求的 Controller
方法称为操作方法。当控制器和操作方法被选中时,MVC 框架创建一个控制器实例来处理请求。控制器构造函数的所有参数都通过依赖注入解决。
请参阅第十一章,将微服务架构应用于您的企业应用程序中的使用通用宿主子节,了解如何使用 .NET 宿主进行依赖注入的描述,以及第六章,设计模式和 .NET 8 实现中的依赖注入模式子节,了解依赖注入的一般讨论。
以下是一个典型的 REST API 控制器及其控制器方法定义:
[Route("api/[controller]")]
[ApiController]
public class ValuesController : ControllerBase
{
// GET api/values/5
[HttpGet("{id}")]
public ActionResult<string> Get(int id)
{
...
[ApiController]
属性声明该控制器是一个 REST API 控制器。[Route("api/[controller]")]
声明控制器必须在以 api/<controller name>
开头的路径上被选中。控制器的名称是控制器类名称,不带 Controller
后缀。这比硬编码控制器名称更节省重构时间。因此,在这种情况下,我们有 api/values
。
[HttpGet("{id}")]
声明该方法必须在 api/values/<id>
类型的 GET 请求上被调用,其中 id
必须是一个作为方法调用参数传递的数字。这可以通过 Get(int id)
实现。对于每个 HTTP 动词,也存在一个 Http<verb>
属性:HttpPost
和 HttpPatch
。
我们还可以定义另一个方法,如下所示:
[HttpGet]
public ... Get()
此方法在 api/values
类型的 GET
请求上被调用,即在没有控制器名称后跟 id
的 GET
请求上。
几个操作方法可以具有相同的名称,但每个请求路径只能有一个与之兼容;否则,会抛出异常。换句话说,路由规则和 Http<verb>
属性必须唯一地定义每个请求应选择哪个控制器及其哪个操作方法。
默认情况下,参数根据以下规则传递给 API 控制器的操作方法。
简单类型(整数
、浮点数
和 DateTimes
)如果路由规则指定它们为参数,则从请求路径中获取,例如上一个示例的 [HttpGet("{id}")]
属性。如果它们在路由规则中找不到,ASP.NET Core 框架将寻找具有相同名称的查询字符串参数。因此,如果我们用 [HttpGet("{id}")]
替换 [HttpGet]
,ASP.NET Core 框架将寻找类似 api/values?id=<id type>
或 api/values/{id}
的内容。
复杂类型通过格式化程序从请求体中提取。根据请求的 Content-Type
头部的值选择正确的格式化程序。如果没有指定 Content-Type
头部,则使用 JSON 格式化程序。JSON 格式化程序尝试将请求体解析为 JSON 对象,然后尝试将此 JSON 对象转换为 .NET 复杂类型的实例。如果 JSON 提取或后续转换失败,则抛出异常。如 第二章,非功能性需求 中所述,由于异常的计算成本远高于正常代码流,因此请小心处理异常。
如果不可避免地出现异常,请考虑使用 第四章,C# 编码最佳实践 12 中描述的日志记录建议。默认情况下,仅支持 JSON 输入格式化程序,但您也可以添加一个 XML 格式化程序,当 Content-Type
指定 XML 内容时可以使用。
您可以通过在参数前加上适当的属性来自定义用于填充动作方法参数的源。以下代码显示了此示例的一些示例:
...MyActionMethod(....[FromHeader] string myHeader....)
// x is taken from a request header named myHeader
...MyActionMethod(....[FromServices] MyType x....)
// x is filled with an instance of MyType through dependency injection
动作方法的返回类型可以是 IActionResult
接口、实现该接口的类型或 DTO。反过来,IActionResult
只有以下方法:
Task ExecuteResultAsync(ActionContext context);
这种方法由 MVC 框架在正确的时间调用以创建实际的响应和响应头。当传递给方法时,ActionContext
对象包含整个 HTTP 请求的上下文,其中包括一个包含有关原始 HTTP 请求(头部、主体和 Cookie)所有必要信息的请求对象,以及一个收集正在构建的响应所有片段的响应对象。
您不必手动创建 IActionResult
的实现,因为 ControllerBase
已经有创建 IActionResult
实现的方法,以便生成所有必要的 HTTP 响应。以下是一些这些方法:
-
OK
:这返回一个 200 状态码,以及一个可选的结果对象。它用作return OK()
或return OK(myResult)
。 -
BadRequest
:这返回一个 400 状态码,以及一个可选的响应对象。 -
Created(string uri, object o)
: 这返回一个 201 状态码,以及一个结果对象和创建资源的 URI。 -
Accepted
:这返回一个 202 状态结果,以及一个可选的结果对象和资源 URI。 -
Unauthorized
:这返回一个 401 状态结果,以及一个可选的结果对象。 -
Forbid
:这返回一个 403 状态结果,以及一个可选的失败权限列表。 -
StatusCode(int statusCode, object o = null)
:这返回一个自定义状态码,以及一个可选的结果对象。
动作方法可以直接使用return myObject
返回结果对象。这相当于返回OK(myObject)
。
当所有结果路径返回相同类型的对象,例如MyType
,动作方法可以声明为返回ActionResult<MyType>
。你也可以返回类似NotFound
的响应,但无疑,这种方法可以得到更好的类型检查。
默认情况下,结果对象在响应体中以 JSON 格式序列化。然而,如果已将 XML 格式化程序添加到 ASP.NET Core 框架处理管道中,如前所述,结果的序列化方式取决于 HTTP 请求的Accept
头。更具体地说,如果客户端明确要求使用Accept
头以 XML 格式,对象将以 XML 格式序列化;否则,将以 JSON 格式序列化。
将作为输入传递给动作方法的复杂对象可以使用以下验证属性进行验证:
public record MyType
{
[Required]
public string Name{get; set;}
...
[MaxLength(64)]
public string Description{get; set;}
}
如果控制器已用[ApiController]
属性装饰,并且验证失败,ASP.NET Core 框架会自动创建一个包含所有检测到的验证错误的BadRequest
响应,而不执行动作方法。因此,你不需要添加额外的代码来处理验证错误。
动作方法也可以声明为async
方法,如下所示:
public async Task<IActionResult>MyMethod(......)
{
await MyBusinessObject.MyBusinessMethod();
...
}
public async Task<ActionResult<MyType>>MyMethod(......)
{
...
实际的控制器/动作方法示例将在用例 - 暴露 WWTravelClub 包中展示,该用例在第二十一章,案例研究中介绍。在下一个小节中,我们将解释如何使用 JWT 处理授权和身份验证。
ASP.NET Core 服务授权
当使用 JWT 时,授权基于 JWT 中包含的声明。任何动作方法中的所有令牌声明都可以通过User.Claims
控制器属性访问。由于User.Claims
是IEnumerable<Claim>
,它可以使用 LINQ 进行处理,以验证声明上的复杂条件。
如果基于角色声明进行授权,你可以简单地使用User.IsInRole
函数,如下面的代码所示:
If(User.IsInRole("Administrators") || User.IsInRole("SuperUsers"))
{
...
}
else return Forbid();
然而,通常不会在动作方法内部检查权限,而是由 MVC 框架根据装饰整个控制器或单个动作方法的授权属性自动检查。如果动作方法或整个控制器被[Authorize]
装饰,则只有当请求具有有效的身份验证令牌时,才能访问动作方法,这意味着我们不需要对令牌声明进行检查。也可以使用以下代码检查令牌是否包含一组角色:
[Authorize(Roles = "Administrators,SuperUsers")]
对于更复杂的声明条件,需要在构建应用程序时在 Program.cs
中定义授权策略,如下面的代码所示:
var builder = WebApplication.CreateBuilder(args);
// Add services to the container.
builder.Services.AddControllers();
...
builder.Services.AddAuthorization(options =>
{
options.AddPolicy("CanDrive", policy =>
policy.RequireAssertion(context =>
context.User.HasClaim(c => c.Type == "HasDrivingLicense")));
});
之后,您可以对动作方法或控制器使用 [Authorize(Policy = "Father")]
装饰器。
在使用基于 JWT 的授权之前,您必须在 Program.cs
中进行配置。首先,您必须添加处理 ASP.NET Core 中身份验证令牌的中间件,如下所示:
var app = builder.Build();
...
app.UseAuthorization();
app.MapControllers();
app.Run();
然后,您必须配置身份验证服务。在那里,您定义将通过依赖注入注入到身份验证中间件的身份验证选项:
var builder = WebApplication.CreateBuilder(args);
// Add services to the container.
builder.Services.AddControllers();
...
builder.Services.AddAuthentication(JwtBearerDefaults.AuthenticationScheme)
.AddJwtBearer(options => {
options.TokenValidationParameters =
new TokenValidationParameters
{
ValidateIssuer = true,
ValidateAudience = true,
ValidateLifetime = true,
ValidateIssuerSigningKey = true,
ValidIssuer = "My.Issuer",
ValidAudience = "This.Website.Audience",
IssuerSigningKey = new
SymmetricSecurityKey(Encoding.ASCII.GetBytes("MySecret"))
};
});
上述代码为身份验证方案提供了一个名称,即默认名称。然后,它指定 JWT 身份验证选项。通常,我们要求身份验证中间件验证 JWT 是否未过期(ValidateLifetime = true
),它是否有正确的发行者和受众(参见本章的 REST 服务授权和身份验证 部分),以及其签名是否有效。
上述示例使用从字符串生成的对称签名密钥。这意味着相同的密钥用于签名和验证签名。如果 JWT 由使用它们的同一网站创建,这是一个可接受的选择,但如果有一个独特的 JWT 发行者控制对多个 Web API 站点的访问,则这不是一个可接受的选择。
在这里,我们应该使用非对称密钥(通常是 RsaSecurityKey
),因此 JWT 验证只需要知道与实际私有签名密钥关联的公钥。可以使用 IdentityServer 4 快速创建一个作为身份验证服务器的网站。它可以使用常规的用户名/密码凭据发出 JWT 或转换其他身份验证令牌。如果您使用像 IdentityServer 4 这样的身份验证服务器,则不需要指定 IssuerSigningKey
选项,因为授权中间件能够自动从授权服务器检索所需的公钥。
只需提供身份验证服务器 URL 即可,如下所示:
.AddJwtBearer(options => {
options.Authority = "https://www.MyAuthorizationserver.com";
options.TokenValidationParameters =...
...
另一方面,如果您决定在您的 Web API 站点发出 JWT,您可以定义一个 Login
动作方法,该方法接受一个包含用户名和密码的对象,并且依赖于数据库信息,使用类似于以下代码的方式构建 JWT:
var claims = new List<Claim>
{
new Claim(...),
new Claim(...) ,
...
};
var token = new JwtSecurityToken(
issuer: "MyIssuer",
audience: ...,
claims: claims,
expires: DateTime.UtcNow.AddMinutes(expiryInMinutes),
signingCredentials:
new SymmetricSecurityKey(Encoding.ASCII.GetBytes("MySecret"));
return OK(new JwtSecurityTokenHandler().WriteToken(token));
在这里,JwtSecurityTokenHandler().WriteToken(token)
从包含在 JwtSecurityToken
实例中的令牌属性生成实际的令牌字符串。
在下一个子节中,我们将学习如何通过 OpenAPI 文档端点增强我们的 Web API,以便可以自动生成与我们的服务通信的代理类。
ASP.NET Core 对 OpenAPI 的支持
填写 OpenAPI JSON 文档所需的大部分信息可以通过反射从 Web API 控制器中提取,即输入类型和来源(路径、请求体和头部)以及端点路径(这些可以从路由规则中提取)。返回输出类型和状态码通常难以计算,因为它们可以动态生成。
因此,MVC 框架提供了 ProducesResponseType
属性,以便我们可以声明可能的返回类型——状态码对。只需用尽可能多的 ProducesResponseType
属性装饰每个操作方法,即可能的类型,也就是可能的状态码对,如下所示:
[HttpGet("{id}")]
[ProducesResponseType(typeof(MyReturnType), StatusCodes.Status200OK)]
[ProducesResponseType(typeof(MyErrorReturnType), StatusCodes.Status404NotFound)]
public IActionResult GetById(int id)...
如果路径上没有返回任何对象,我们只需声明状态码如下:
[ProducesResponseType(StatusCodes.Status403Forbidden)]
我们也可以在所有路径返回相同类型且该类型已在操作方法返回类型中指定为 ActionResult<CommonReturnType>
时,仅指定状态码。
一旦所有操作方法都已记录,要生成 JSON 端点的任何实际文档,我们必须安装 Swashbuckle.AspNetCore
NuGet 包并在 Program.cs
文件中放置一些代码:
在 .NET 5+ 中,您可以通过在创建项目时勾选 OpenAPI 支持 来自动包含它。
var builder = WebApplication.CreateBuilder(args);
...
//open api middleware
builder.Services.AddSwaggerGen(c =>
{
c.SwaggerDoc("v1", new() { Title = "WWTravelClubREST60", Version = "v1" });
});
var app = builder.Build();
...
app.UseSwagger();
app.UseSwaggerUI(c => c.SwaggerEndpoint("/swagger/v1/swagger.json", "WWTravelClubREST60 v1"));
...
app.Run();
SwaggerDoc
方法的第一个参数是文档端点名称。默认情况下,文档端点可通过 <webroot>//swagger/<endpoint name>/swagger.json
路径访问,但可以通过多种方式更改。Info
类中包含的其他信息是自解释的。
我们可以添加多个 SwaggerDoc
调用来定义多个文档端点。然而,默认情况下,所有文档端点都将包含相同的文档,其中包括项目中包含的所有 REST 服务的描述。此默认值可以通过在 services.AddSwaggerGen(c => {...})
中调用 c.DocInclusionPredicate(Func<string, ApiDescription> predicate)
方法来更改。
DocInclusionPredicate
必须传递一个函数,该函数接收一个 JSON 文档名称和一个操作方法描述,并且如果操作方法的文档必须包含在该 JSON 文档中,则必须返回 true
。
要声明您的 REST API 需要 JWT,您必须在 services.AddSwaggerGen(c => {...})
内部添加以下代码:
var security = new Dictionary<string, IEnumerable<string>>
{
{"Bearer", new string[] { }},
};
c.AddSecurityDefinition("Bearer", new ApiKeyScheme
{
Description = "JWT Authorization header using the Bearer scheme.
Example: \"Authorization: Bearer {token}\"",
Name = "Authorization",
In = "header",
Type = "apiKey"
});
c.AddSecurityRequirement(security);
您可以通过从三斜杠注释中提取信息来丰富 JSON 文档端点,这些注释通常用于生成自动代码文档。以下代码展示了这一点的示例。以下代码片段展示了我们如何添加方法描述和参数信息:
/// <summary>
/// Deletes a specific TodoItem.
/// </summary>
/// <param name="id">id to delete</param>
[HttpDelete("{id}")]
public IActionResultDelete(long id)
we can add an example of usage:
/// <summary>
/// Creates an item.
/// </summary>
/// <remarks>
/// Sample request:
///
/// POST /MyItem
/// {
/// "id": 1,
/// "name": "Item1"
/// }
///
/// </remarks>
以下代码片段展示了我们如何为每个 HTTP 状态码添加参数描述和返回类型描述:
/// <param name="item">item to be created</param>
/// <returns>A newly created TodoItem</returns>
/// <response code="201">Returns the newly created item</response>
/// <response code="400">If the item is null</response>
要启用从三斜杠注释中提取,我们必须通过将以下代码添加到我们的项目文件(.csproj
)中来启用代码文档创建:
<PropertyGroup>
<GenerateDocumentationFile>true</GenerateDocumentationFile>
<NoWarn>$(NoWarn);1591</NoWarn>
</PropertyGroup>
然后,我们必须在 services.AddSwaggerGen(c => {...})
内启用代码文档处理,通过添加以下代码:
var xmlFile = $"{Assembly.GetExecutingAssembly().GetName().Name}.xml";
var xmlPath = Path.Combine(AppContext.BaseDirectory, xmlFile);
c.IncludeXmlComments(xmlPath);
一旦我们的文档端点准备就绪,我们可以添加一些包含在同一个 Swashbuckle.AspNetCore
NuGet 包中的中间件,以生成一个友好的用户界面,我们可以在其上测试我们的 REST API:
app.UseSwaggerUI(c =>
{
c.SwaggerEndpoint("/swagger/<documentation name>/swagger.json", "
<api name that appears in dropdown>");
});
如果你拥有多个文档端点,你需要为每个端点添加一个 SwaggerEndpoint
调用。我们将使用此接口来测试书中用例中定义的 REST API,该用例在 第二十一章,案例研究 中介绍。在那里你还将了解到如何使用 Postman,一个用于构建和使用的 API 平台。
一旦你有了工作的 JSON 文档端点,你可以使用以下方法之一自动生成代理类的 C# 或 TypeScript 代码,该代理类在 第六章,设计模式和 .NET 8 实现 中介绍:
-
可在
github.com/RicoSuter/NSwag/wiki/NSwagStudio
找到的 NSwagStudio Windows 程序。 -
如果你想要自定义代码生成,可以使用
NSwag.CodeGeneration.CSharp
或NSwag.CodeGeneration.TypeScript
NuGet 包。 -
如果你想要将代码生成与 Visual Studio 构建操作关联起来,可以使用
NSwag.MSBuild
NuGet 包。有关此包的文档可以在github.com/RicoSuter/NSwag/wiki/NSwag.MSBuild
找到。
在下一小节中,你将学习如何从一个 REST API 或从 .NET 客户端调用 REST API。
.NET HTTP 客户端
System.Net.Http
命名空间中的 HttpClient
类是一个 .NET Standard 2.0 内置的 HTTP 客户端类。虽然它可以直接在任何需要与 REST 服务交互时使用,但反复创建和释放 HttpClient
实例存在一些问题,如下所述:
-
它们的创建成本很高。
-
当
HttpClient
被释放时,例如在 using 语句中,底层的连接不会立即关闭,而是在第一次垃圾回收会话时关闭。因此,重复的创建和释放操作会迅速耗尽操作系统可以处理的连接最大数量。
因此,可以重用单个 HttpClient
实例,例如 Singleton,或者以某种方式池化 HttpClient
实例。从 .NET Core 2.1 版本开始,引入了 HttpClientFactory
类来池化 HTTP 客户端。更具体地说,每当需要为 HttpClientFactory
对象创建新的 HttpClient
实例时,就会创建一个新的 HttpClient
。然而,底层的 HttpClientMessageHandler
实例,创建成本较高,会被池化直到其最大生命周期到期。
HttpClientMessageHandler
实例必须有一个有限的生命周期,因为它们缓存了可能随时间变化的 DNS 解析信息。HttpClientMessageHandler
的默认生命周期为 2 分钟,但可以被开发者重新定义。
使用HttpClientFactory
允许我们自动将所有 HTTP 操作与其他操作进行管道化。例如,我们可以添加 Polly 重试策略来自动处理所有 HTTP 操作的所有失败。有关 Polly 的介绍,请参阅第五章的实现 C# 12 中的代码重用的弹性任务执行子节。
利用HttpClientFactory
类提供的优势的最简单方法是添加Microsoft.Extensions.Http
NuGet 包,然后按照以下步骤操作:
-
定义一个代理类,例如
MyProxy
,以与所需的 REST 服务交互。 -
让
MyProxy
在其构造函数中接受一个HttpClient
实例。 -
使用构造函数中注入的
HttpClient
来实现所有必要的操作。 -
在宿主的服务配置方法中声明你的代理,在 ASP.NET Core 应用程序的情况下,这个方法位于
Program.cs
类中。在最简单的情况下,声明类似于builder.Services.AddHttpClient<MyProxy>()
。
这样,MyProxy
将自动添加到可用于依赖注入的服务中,因此你可以轻松地在控制器构造函数中注入它。此外,每次创建MyProxy
的实例时,HttpClientFactory
都会返回一个HttpClient
实例,并将其自动注入到其构造函数中。
在需要与 REST 服务交互的类构造函数中,我们可能也需要一个接口,而不是具有类型声明的特定代理实现:
builder.Services.AddHttpClient<IMyProxy, MyProxy>()
这样,每个传递给代理的客户端都会预先配置,以便它需要一个 JSON 响应,并且必须与特定服务一起工作。一旦定义了基本地址,每个 HTTP 请求都需要指定要调用的服务方法的相对路径。
以下代码展示了如何向服务执行POST
操作。这需要额外的包System.Net.Http.Json
,因为使用了PostAsJsonAsync
。在这里,我们声明注入到代理构造函数中的HttpClient
已经被存储在webClient
私有字段中:
//Add a bearer token to authenticate the call
webClient.DefaultRequestHeaders.Add("Authorization", "Bearer " + token);
...
//Call service method with a POST verb and get response
var response = await webClient.PostAsJsonAsync<MyPostModel>("my/method/relative/path",
new MyPostModel
{
//fill model here
...
});
//extract response status code
var status = response.StatusCode;
...
//extract body content from response
string stringResult = await response.Content.ReadAsStringAsync();
如果你使用 Polly,你不需要拦截和处理通信错误,因为这个任务由 Polly 执行。首先,你需要验证状态码以决定下一步要做什么。然后,你可以解析响应体中包含的 JSON 字符串,以获取一个.NET 类型的实例,这通常取决于状态码。执行解析的代码基于System.Text.Json
NuGet 包的JsonSerializer
类,如下所示:
var result =
JsonSerializer.Deserialize<MyResultClass>(stringResult);
执行GET
请求类似,但需要调用GetAsync
而不是PostAsJsonAsync
,如下所示。其他 HTTP 动词的使用完全类似:
var response =
await webClient.GetAsync("my/getmethod/relative/path");
如您从本节中看到的那样,访问 HTTP API 非常简单,需要实现一些 .NET 6 库。自 .NET Core 以来,Microsoft 一直在努力改进框架的这一部分的性能和简单性。您需要确保自己了解他们持续实施的文档和设施。
摘要
在本章中,我们介绍了 SOA、其设计原则和其约束。其中,互操作性值得记住。
然后,我们关注了适用于业务应用的标准,这些标准实现了公开服务所需的互操作性。因此,详细讨论了 SOAP 和 REST 服务,以及在过去几年中大多数应用领域发生的从 SOAP 服务到 REST 服务的转变。然后,更详细地描述了 REST 服务原则、认证/授权和文档。
最后,我们探讨了 .NET 8 中可用的工具,我们可以使用这些工具来实现和交互服务。我们探讨了用于集群内通信的各种框架,如 .NET 远程过程调用和 gRPC,以及基于 SOAP 和 REST 的公共服务的工具。
在这里,我们主要关注 REST 服务。它们的 ASP.NET Core 实现被详细描述,包括我们可以用来认证/授权它们的技巧以及它们的文档。我们还关注了如何实现高效的 .NET 代理,以便我们可以与 REST 服务交互。
在下一章中,我们将学习如何使用 .NET 8 实现 ASP.NET Core 微服务。
问题
-
服务可以使用基于 cookie 的会话吗?
-
实现一个具有自定义通信协议的服务是否是好的实践?为什么或为什么不?
-
向 REST 服务发送的
POST
请求会导致删除吗? -
JWT 持有者令牌中包含多少个点分隔的部分?
-
默认情况下,REST 服务操作方法的复杂类型参数是从哪里获取的?
-
如何声明控制器作为 REST 服务?
-
ASP.NET Core 服务的最主要文档属性是什么?
-
ASP.NET Core REST 服务路由规则是如何声明的?
-
如何声明代理,以便我们可以利用 .NET
HttpClientFactory
类的功能?
进一步阅读
-
本章主要关注更常用的 REST 服务。如果您对 SOAP 服务感兴趣,可以从关于 SOAP 规范的维基百科页面开始了解:
en.wikipedia.org/wiki/List_of_web_service_specifications
。另一方面,如果您对实现 SOAP 服务的 Microsoft .NET WCF 技术感兴趣,可以在此处参考 WCF 的官方文档:docs.microsoft.com/en-us/dotnet/framework/wcf/
. -
本章提到了 AMQP 协议作为集群内部通信的选项,但没有对其进行描述。关于此协议的详细信息可在 AMQP 的官方网站找到:
www.amqp.org/
. -
关于 gRPC 的更多信息可在 Google gRPC 的官方网站找到:
grpc.io/
. 关于 Visual Studio gRPC 项目模板的更多信息请在此处查看:docs.microsoft.com/en-US/aspnet/core/grpc/
. 您还可以查看 gRPC-Web,详情请访问:devblogs.microsoft.com/aspnet/grpc-web-for-net-now-available/
. -
关于 ASP.NET Core 服务的更多详细信息可在官方文档中找到:
docs.microsoft.com/en-US/aspnet/core/web-api/
. 关于 .NET HTTP 客户端的更多信息请在此处查看:docs.microsoft.com/en-US/aspnet/core/fundamentals/http-requests
. -
最小 API 的描述可在
docs.microsoft.com/en-us/aspnet/core/fundamentals/minimal-apis
找到。 -
关于 JWT 验证的更多信息请在此处查看:
jwt.io/
. 如果您想使用 IdentityServer 生成 JWT,可以参考其官方文档页面:docs.duendesoftware.com/identityserver/v7
. -
关于 OpenAPI 的更多信息可在
swagger.io/docs/specification/about/
找到,而关于 Swashbuckle 的更多信息可在其 GitHub 仓库页面找到:github.com/domaindrivendev/Swashbuckle
.
留下评论!
喜欢这本书吗?通过留下亚马逊评论来帮助像您这样的读者。扫描下面的二维码以获取 20% 的折扣码。
限时优惠
第十六章:与无服务器架构一起工作 – Azure Functions
在第十章,选择最佳云解决方案中,我们探讨了不同云架构的基本原理和战略优势,而无服务器可以被认为是提供灵活云解决方案的最新的方式之一。我们深入探讨了无服务器系统如何提供可扩展性、成本效益和敏捷性——这些是驱动当今软件架构决策的关键因素。
在这个基础上,本章将进一步深入探讨无服务器架构的一个关键组件:Azure Functions。
Azure Functions 作为微软提供的无服务器架构行动的典范组件而脱颖而出。它提供了一种灵活的事件驱动方法,能够无缝集成到 .NET 生态系统,使其成为旨在构建高效、可扩展和响应式应用的架构师和开发者的首选。
我们将深入探讨 Azure Functions 的复杂性,强调其在复杂企业环境中的应用。本章将为您提供利用 Azure Functions 构建稳健应用架构的实用见解,讨论最佳实践、设计模式和针对 .NET 堆栈的特定高级功能。
本章提供了对 Azure Functions 的全面理解,侧重于提高您对该平台的技术掌握。我们深入探讨了 Azure Functions 的具体细节,包括其设置、使用 C# 进行编程以及各种托管计划,如消费、高级和应用程序服务计划。到本章结束时,您将全面了解 Azure Functions,并具备在项目中有效部署、维护和优化其函数的知识。
本章将涵盖以下主题:
-
理解 Azure Functions 应用
-
使用 C# 编程 Azure Functions
-
维护 Azure Functions
到本章结束时,您将了解如何使用 C# 中的 Azure Functions 加快您的开发周期。
技术要求
本章要求您具备以下条件:
-
免费的 Visual Studio 2022 Community Edition 或,更好的是,安装了所有 Azure 工具的版本。
-
一个免费的 Azure 账户。第一章,理解软件架构的重要性中的创建 Azure 账户部分解释了如何创建一个。
理解 Azure Functions 应用
Azure Functions 应用是一个 Azure PaaS(平台即服务)应用,您可以在其中构建代码片段(函数),将它们连接到您的应用程序,并使用触发器来启动它们。这个概念相当简单——您使用您喜欢的语言构建一个函数,并决定启动它的触发器。您可以在系统中编写尽可能多的函数。有些情况下,系统完全是用函数编写的。
创建必要环境的步骤与创建函数本身需要遵循的步骤一样简单。下面的截图显示了你在创建环境时必须决定的参数。在你选择 Azure 中的“创建资源”并按“函数应用”筛选后,点击“创建”按钮,你会看到以下屏幕:
图 16.1:创建 Azure 函数应用
在创建你的 Azure 函数环境时,有几个关键点你应该考虑。运行函数的可能性、编程语言选项和发布样式都会随着时间的推移而增加。我们最重要的配置之一是托管计划,这是你将运行函数的地方。托管计划有三个选项:消费型(无服务器)、高级和应用程序服务计划。让我们来谈谈它们。
消费型计划
如果你选择消费型计划,你的函数只有在执行时才会消耗资源。这意味着你只有在函数运行时才会被收费。可扩展性和内存资源将由 Azure 自动管理。这正是我们所说的无服务器。
在这个计划中编写函数时,我们需要注意的一个问题是超时。默认情况下,5 分钟后函数将超时。你可以通过在 host.json
文件中使用 functionTimeout
参数来更改超时值。最大值是 10 分钟。
当你选择消费型计划时,你的收费将取决于你执行的内容、执行时间和内存使用情况。更多关于这方面的信息可以在azure.microsoft.com/en-us/pricing/details/functions/
找到。
注意,当你环境中没有应用程序服务,并且以低周期性运行函数时,这可以是一个不错的选择。另一方面,如果你需要持续处理,你可能想要考虑使用高级计划或应用程序服务计划。下面我们来了解一下。
高级计划
根据你使用函数的目的,特别是如果它们需要持续或几乎持续运行,或者某些函数执行时间超过 10 分钟,你可能想要考虑使用高级计划。此外,你可能需要将你的函数连接到 VNET/VPN 环境,在这种情况下,你将被迫运行在这个计划中。
你可能还需要比消费型计划提供的更多 CPU 或内存选项。高级计划为你提供了一核心、两核心和四核心实例选项。
值得注意的是,即使你有无限的时间来运行你的函数,如果你决定使用 HTTP 触发函数,响应请求的最大允许时间是 230 秒。这个限制的原因与 Azure 负载均衡器的默认空闲超时值有关。
在这种情况下,您可能需要重新设计您的解决方案,以符合微软设定的最佳实践(docs.microsoft.com/en-us/azure/azure-functions/functions-best-practices
)。
虽然高级计划是一个很好的替代方案,但如果您想优化您的应用服务实例的使用,最佳选项是应用服务计划。让我们来看看它。
应用服务计划
应用服务计划是在您想要创建 Azure 函数应用时可以选择的选项之一。以下是一些原因(由微软建议),为什么您应该使用应用服务计划而不是消耗计划来维护您的函数:
-
您可以使用未充分利用的现有应用服务实例。
-
如果您想的话,可以在自定义镜像上运行您的函数应用。
在应用服务计划场景中,functionTimeout
的值根据 Azure 函数运行时版本而变化。然而,该值至少需要 30 分钟。
您可以在docs.microsoft.com/en-us/azure/azure-functions/functions-scale#timeout
找到每个消耗计划中超时时间的表格比较。
现在我们对 Azure 函数应用及其在无服务器架构中的作用有了基础的了解,让我们探索如何将这些概念付诸实践。在接下来的部分,我们将深入探讨使用 C# 编程 Azure 函数,将理论知识转化为实际应用。
使用 C# 编程 Azure 函数
在本节中,您将学习如何创建 Azure 函数。值得一提的是,有几种方法可以使用 C# 创建它们。第一种是通过在 Azure 门户本身中创建和开发这些函数。为此,让我们假设您已经创建了一个与本章开头截图中的配置相似的 Azure 函数应用。
通过选择创建的资源并导航到函数菜单,您将能够向此环境添加新的函数,如下面的截图所示:
图 16.2:添加函数
在这里,您需要决定您想要使用的触发器类型来启动执行。最常用的有HTTP 触发器和定时器触发器。第一个允许创建一个 HTTP API,该 API 将触发函数。第二个意味着函数将由您设置的定时器触发。
当你决定使用哪个触发器时,你必须为函数命名。根据你选择的触发器,你可能需要设置一些参数。例如,HTTP 触发器需要你设置一个授权级别。有三个选项可供选择:函数、匿名和管理员。函数选项需要特定的密钥来访问每个 HTTP 触发器,而匿名则不需要任何东西。对于管理员选项,使用的密钥是主密钥,它与函数应用一起创建。
图 16.3:配置 HTTP 函数
重要的是要注意,这本书并没有涵盖构建函数时所有可用的选项。作为一名软件架构师,你应该了解 Azure 在函数方面为无服务器架构提供了一个良好的服务。这在几种情况下都可能很有用。这已在第十章,选择最佳云解决方案中进行了更详细的讨论。
结果如下。请注意,Azure 提供了一个编辑器,允许我们运行代码、检查日志和测试我们创建的函数。这是一个测试和编码基本函数的好界面:
图 16.4:HTTP 函数环境
然而,如果你想创建更复杂的函数,你可能需要一个更复杂的开发环境,以便更有效地进行编码和调试。这就是 Visual Studio Azure Functions 项目能帮到你的地方。此外,使用 Visual Studio 来执行函数的开发将使你朝着为函数使用源代码控制和 CI/CD 的方向发展。
在 Visual Studio 中,你可以通过访问创建新项目来创建一个专门用于 Azure Functions 的项目:
图 16.5:在 Visual Studio 2022 中创建 Azure Functions 项目
创建完项目后,Visual Studio 将询问你使用的触发器类型以及函数将运行的 Azure 版本。值得一提的是,在某些情况下,在创建函数应用时需要存储账户,例如管理触发器和记录执行。
图 16.6:创建新的 Azure Functions 应用程序
值得注意的是,Azure Functions 支持不同的平台和编程语言。在撰写本文时,Azure Functions 提供了两个可用的运行时版本,并提供了支持。第一个版本(v1)与.NET Framework 4.8 兼容。v2 和 v3 版本不再受支持,因此对于.NET 8,你应该使用版本 4(v4)。
你可以始终在learn.microsoft.com/en-us/azure/azure-functions/functions-versions
上检查有关它的最新信息。
作为一名软件架构师,你必须考虑到代码的可重用性。在这种情况下,你应该注意你决定用于构建函数的 Azure Functions 的版本。然而,一旦运行时达到通用可用状态,始终建议你使用最新版本的运行时。
默认情况下,生成的代码与你在 Azure 门户中创建 Azure Functions 时生成的代码类似:
using System.Net;
using Google.Protobuf.WellKnownTypes;
using Microsoft.AspNetCore.WebUtilities;
using Microsoft.Azure.Functions.Worker;
using Microsoft.Azure.Functions.Worker.Http;
using Microsoft.Extensions.Logging;
namespace FunctionAppSampleIsolated
{
public class AzureFunctionHttpSampleNet8
{
private readonly ILogger _logger;
public AzureFunctionHttpSampleNet8(ILoggerFactory loggerFactory)
{
_logger = loggerFactory.CreateLogger<AzureFunctionHttpSampleNet8>();
}
[Function("AzureFunctionHttpSampleNet8")]
public HttpResponseData Run([HttpTrigger(AuthorizationLevel.Function, "get", "post")] HttpRequestData req)
{
_logger.LogInformation("C# HTTP trigger function processed a request.");
string responseMessage = "This HTTP triggered function executed successfully. Pass a name in the query string or in the request body for a personalized response.";
var queryDictionary = QueryHelpers.ParseQuery(req.Url.Query);
if (queryDictionary.ContainsKey("name"))
responseMessage = $"Hello, {queryDictionary["name"]}. This HTTP triggered function executed successfully.";
var response = req.CreateResponse(HttpStatusCode.OK);
response.Headers.Add("Content-Type", "text/plain; charset=utf-8");
response.WriteString(responseMessage);
return response;
}
}
}
既然你已经了解了使用 C# 创建 Azure Functions 的基础知识,那么了解可用于 Azure Functions 的触发器模板数量也同样重要。让我们来看看吧。
列出 Azure Functions 模板
Azure 门户中有几个模板可供你创建 Azure Functions,你可以选择的模板数量会持续更新。以下只是其中的一些:
-
Blob 触发器:你可能希望在文件上传到你的 Blob 存储后立即处理某个文件。这可能是 Azure Functions 的一个很好的用例。
-
Cosmos DB 触发器:你可能希望将到达 Cosmos DB 数据库的数据与处理方法同步。Cosmos DB 在 第十二章,选择你的云数据存储 中有详细的讨论。
-
事件网格触发器:这是一种管理 Azure 事件的好方法。函数可以被触发以管理每个事件。
-
事件中心触发器:使用这个触发器,你可以构建与任何向 Azure 事件中心发送数据的系统链接的函数。
-
HTTP 触发器:这个触发器对于构建无服务器 API 和 Web 应用程序事件非常有用。
-
IoT Hub 触发器:当你的应用程序通过 IoT Hub 连接到设备时,你可以在接收到设备的新事件时使用此触发器。
-
队列触发器:你可以使用函数即服务解决方案来处理队列处理。
-
服务总线队列触发器:这是另一种可以作为函数触发器的消息服务。Azure Service Bus 在 第十一章,将微服务架构应用于企业应用程序 中有更详细的介绍。
-
定时器触发器:这是与函数一起常用的触发器,你可以在这里指定时间触发器,以便可以持续处理来自你系统的数据。
你可以在 docs.microsoft.com/en-us/azure/azure-functions/functions-triggers-bindings
找到适用于 Azure Functions 的所有触发器和绑定列表。
在对如何使用 C# 编程 Azure Functions 有了一个稳固的理解之后,现在让我们确保它们的长期性和性能。在接下来的部分中,我们将揭示有效管理和监控你的无服务器架构所需的最佳实践和工具。
维护 Azure Functions
一旦您创建并编程了您的函数,您就需要监控和维护它。为此,您可以使用各种工具,所有这些工具您都可以在 Azure 门户中找到。这些工具将帮助您解决由于您将能够收集的大量信息而引起的问题。
当您需要监控您的函数时,第一个选项是在 Azure 门户中 Azure Functions 界面内的监控菜单中使用。在那里,您将能够检查所有函数执行情况,包括成功结果和失败:
图 16.7:监控一个函数
结果大约需要 5 分钟才能可用。网格中显示的日期是 UTC 时间。
点击在 Application Insights 中运行查询,相同的界面允许您连接到这个工具。这将带您进入一个几乎无限选项的世界,您可以使用这些选项来分析您的函数数据。Application Insights 是应用性能管理(APM)的一个优秀选择:
图 16.8:使用 Application Insights 进行监控
除了查询界面之外,您还可以使用 Azure 门户中的 Application Insights 界面检查您函数的所有性能问题。在那里,您可以分析和筛选您的解决方案接收到的所有请求,并检查它们的性能和依赖关系。当您的端点之一发生异常时,您还可以触发警报。
您可以通过在 Azure 门户中选择您的函数资源并搜索Application Insights来找到此资源。
图 16.9:使用 Application Insights 实时指标进行监控
作为一名软件架构师,您将在项目中找到这个工具是一个很好的日常助手。请记住,Application Insights 在多个其他 Azure 服务上运行,例如 Web Apps 和虚拟机。这意味着您可以使用 Azure 提供的出色功能来监控和维护您系统的健康。
Azure Durable Functions
如果您决定深入研究无服务器技术的使用,您可以考虑 Azure Durable Functions 作为设计编排场景的一个好选择。Azure Durable Functions 允许我们编写有状态的流程,管理幕后状态。为此,您将需要编写一个编排函数,这基本上是一个定义您想要运行的流程的流程。您可能还需要一些实体函数来启用对小块状态的读取。
以下是一些可以使用此解决方案的应用模式;然而,重要的是要记住,它并不适合所有应用:
-
函数链式调用:当您需要按特定顺序执行一系列函数时。
-
异步 HTTP API:一种解决与外部客户端的长运行操作的好方法,你将有机会由于编排函数而获取状态 API。一旦你在 Visual Studio 中创建编排函数,就会有这个模式的示例代码。
-
扇出/扇入:在并行运行多个函数并等待它们完成以在聚合函数中得出结论的能力。
-
监控器:一种在不使用计时器触发器的情况下监控进程的方式,允许配置间隔来监控多个进程实例。
-
人工交互:即使在需要人工交互的情况下自动化流程,但你需要监控一段时间后的响应。
-
聚合器:一种将一段时间内的事件数据汇总到单个实体的方式。
当涉及到定价时,我们应该记住 Azure Durable Functions 的计费方式与常见的 Azure Functions 相同。你必须考虑的唯一问题是编排函数可能会在整个编排生命周期中重放多次,并且你将为每次重放付费。
一旦我们了解了 Azure Durable Functions 所提供的可能性,评估 Azure Functions 的路线图也同样重要。我们之所以不断撰写关于这一点的内容,是因为自其创建以来 Azure Functions 经历了变化。考虑到基于 Azure Functions 的无服务器应用程序可能是你解决方案的核心,这是一个需要讨论的重要话题。现在让我们来看看。
Azure Functions 路线图
自 2016 年推出以来,Azure Functions 的结构已经发生了变化。使用该工具的人数和与 .NET 相关的变化导致了一些兼容性问题,这促使微软找到了一种新的方法来交付函数的部署。这种新方法被称为隔离进程模型,它自 .NET 5 起就可用。还重要的是要提到,目前支持的 Azure Functions 可用运行时版本是 v1 和 v4。
根据当前的路线图,使用隔离进程模型是运行 .NET 8 和未来版本中 Azure Functions 的唯一方式。有一个计划为 .NET 8 提供进程内模型,但尚未确定日期。
图 16.10:Azure Functions 路线图
作为一名软件架构师,你必须关注提供的路线图,以便你可以为你的解决方案选择最佳的实现方式。
当使用隔离进程模型实现 Azure 函数时,你将能够访问 Program.cs
文件中函数的启动。这意味着你将需要配置和创建你函数的实例:
var host = new HostBuilder()
.ConfigureFunctionsWorkerDefaults()
.ConfigureServices(services =>
{
services.AddApplicationInsightsTelemetryWorkerService();
services.ConfigureFunctionsApplicationInsights();
})
.Build();
值得注意的是,为了这样做,你需要Microsoft.Azure.Functions.Worker.Extensions
包。你可以在docs.microsoft.com/en-us/azure/azure-functions/dotnet-isolated-process-guide
找到一个非常好的指南。
使用无服务器和 Azure Functions 的决定
即使在本章中介绍了这些好处,也总是有人会问为什么要在更大的 Web 应用程序中将其作为一部分而不是使用无服务器函数。
如果你只考虑 HTTP 触发函数,这个问题就更加难以回答,因为你可以创建一个 Web API 应用程序,通常可以解决这个场景中的问题。
然而,有些用例中,Azure Function 确实是最佳选择。让我们列出它们,以帮助你在你的场景中做出决定:
-
当你需要执行周期性任务时:Azure Functions 的Timer Trigger无疑是一个很好的选择。使用 Cron 表达式,你可以设置不同的周期来让函数运行。你可以在
learn.microsoft.com/en-us/azure/azure-functions/functions-bindings-timer
了解更多详细信息。 -
当你想在某些数据变化后执行任务时:通过使用Blob Trigger、Queue Trigger或CosmosDB Trigger等触发器,你可以监控数据变化,并随后根据这些变化执行特定的任务,这在某些场景中可能很有用。关于如何使用它的一个很好的例子可以在
learn.microsoft.com/en-us/azure/azure-functions/functions-create-cosmos-db-triggered-function
找到。 -
当你想在设备或另一个系统上发生某些事件后执行任务时:同样,无需池化数据,你可以使用Event Grid Trigger、Event Hub Trigger、IoT Hub Trigger或Service Bus Queue Trigger来跟踪事件,并使用它提供的信息启动任务。你可以在
learn.microsoft.com/en-us/azure/azure-functions/functions-bindings-event-hubs-trigger
了解更多相关信息。
为了使这个决定更容易,在第二十一章的案例研究中,你将找到实现以下架构的完整教程。
为了给用户提供良好的体验,应用程序发送的所有电子邮件都将异步排队,从而防止系统响应出现重大延迟。
图 16.11:发送电子邮件的架构设计
这个设计的伟大之处在于,尽管你有一个由不同组件组成的解决方案,但你无法识别一些计算特性,比如使用的内存量、为该过程设计的 CPU 数量,甚至保证解决方案质量的存储需求。这就是我们所说的无服务器在本质意义上的含义——一个关注点不在代码运行位置的解决方案。
摘要
在本章中,我们探讨了使用无服务器 Azure Functions 开发功能的一些优点。你可以将其作为指南来检查 Azure Functions 中可用的不同触发器类型,并规划如何监控它们。我们还看到了如何编程和维护 Azure Functions。
在下一章中,我们将讨论与 ASP.NET Core MVC 相关的最新新闻。
问题
-
Azure Functions 是什么?
-
Azure Functions 的编程选项有哪些?
-
可以与 Azure Functions 一起使用的计划有哪些?
-
如何使用 Visual Studio 部署 Azure Functions?
-
你可以使用哪些触发器来开发 Azure Functions?
-
Azure Functions v1、v2、v3 和 v4 之间的区别是什么?
-
Application Insights 如何帮助我们维护和监控 Azure Functions?
-
Azure Durable Functions 是什么?
进一步阅读
如果你想了解更多关于创建 Azure 函数的信息,请查看以下链接:
-
Azure Functions 的扩展和托管:
docs.microsoft.com/en-us/azure/azure-functions/functions-scale
-
《.NET 开发者的 Microsoft Azure [视频],作者 Trevoir Williams:
www.packtpub.com/product/microsoft-azure-for-net-developers-video/9781835465059
-
《Azure Serverless Computing Cookbook - 第三版,作者 Praveen Kumar Sreeram:
www.packtpub.com/product/azure-serverless-computing-cookbook-third-edition/9781800206601
-
Azure Functions 运行时概述:
docs.microsoft.com/en-us/azure/azure-functions/functions-versions
-
Azure Event Grid 概述:
docs.microsoft.com/en-us/azure/event-grid/
-
Azure Functions 的定时器触发器:
docs.microsoft.com/en-us/azure/azure-functions/functions-bindings-timer
-
书籍《Azure for Architects》,作者 Ritesh Modi 中的Application Insights部分:
subscription.packtpub.com/book/virtualization_and_cloud/9781788397391/12/ch12lvl1sec95/application-insights
-
《Azure Serverless Computing Cookbook》一书中关于使用 Application Insights 监控 Azure Functions的部分,作者 Praveen Kumar Sreeram:
subscription.packtpub.com/book/virtualization_and_cloud/9781788390828/6/06lvl1sec34/monitoring-azure-functions-using-application-insights
-
使用 .NET 开始使用 Azure 队列存储:
docs.microsoft.com/en-us/azure/storage/queues/storage-dotnet-how-to-use-queues
-
Azure Functions 触发器和绑定概念:
docs.microsoft.com/en-us/azure/azure-functions/functions-triggers-bindings
-
Azure Functions 的 Azure 队列存储绑定:
docs.microsoft.com/en-us/azure/azure-functions/functions-bindings-storage-queue
-
用于本地 Azure 存储开发的 Azure 模拟器:
docs.microsoft.com/en-us/azure/storage/common/storage-use-azurite
-
Azure Durable Functions:
docs.microsoft.com/en-us/azure/azure-functions/durable/
在 Discord 上了解更多
要加入这本书的 Discord 社区——在那里您可以分享反馈,向作者提问,并了解新版本——请扫描下面的二维码:
packt.link/SoftwareArchitectureCSharp12Dotnet8
第十七章:展示 ASP.NET Core
在本章中,你将学习如何实现 Web 应用程序和基于 Web 的表示层。更具体地说,你将了解 ASP.NET Core 以及如何基于 ASP.NET Core MVC 实现 Web 应用程序表示层。
ASP.NET Core 是一种用于实现 Web 应用程序的 .NET 框架。ASP.NET Core Web API 在之前的章节中部分描述过,因此本章将主要关注 ASP.NET Core 的总体情况以及 ASP.NET Core MVC。更具体地说,本章将涵盖以下主题:
-
理解 Web 应用程序的表示层
-
理解 ASP.NET Core 的基础知识
-
理解 ASP.NET Core MVC 如何创建响应 HTML
-
理解 ASP.NET Core MVC 与设计原则之间的联系
我们将回顾并提供关于之前章节中部分讨论的 ASP.NET Core 框架结构的进一步细节。在这里,主要关注的是如何根据所谓的模型-视图-控制器(MVC)架构模式实现基于 Web 的表示层。
我们还将分析如何使用 ASP.NET Core MVC 的 Razor 模板语言创建服务器端 HTML。
每个概念都通过代码示例进行解释,并且第十八章,使用 ASP.NET Core 实现前端微服务,专门描述了使用 ASP.NET Core MVC 实现的前端微服务。为了了解如何将本章和下一章中讨论的通用原则付诸实践,请参阅第二十一章,案例研究中的前端微服务部分。
技术要求
本章需要免费 Visual Studio 2022 Community 版本,理想情况下安装了所有数据库工具。
理解 Web 应用程序的表示层
本章讨论了基于 ASP.NET Core 框架实现 Web 应用程序表示层的架构。Web 应用程序的表示层基于三种技术:
-
通过 REST 或 SOAP 服务与服务器交换数据的移动或桌面原生应用程序:我们将在第十九章,客户端框架:Blazor中讨论桌面应用程序。
-
单页应用程序(SPAs):这些是基于 HTML 的应用程序,其动态 HTML 在客户端创建,无论是使用 JavaScript 还是借助 WebAssembly(一种跨浏览器的组件,可以用作 JavaScript 的高性能替代品)。与原生应用程序一样,SPAs 通过基于 HTTP 的 API 与服务器交换数据,但它们的优势在于独立于设备和其操作系统,因为它们在浏览器中运行。第十九章,客户端框架:Blazor描述了基于 WebAssembly 的 Blazor SPA 框架,因为它本身基于在 WebAssembly 中编译的 .NET 运行时。
-
由服务器创建的 HTML 页面,其内容取决于要展示给用户的数据:本章将要讨论的 ASP.NET Core MVC 框架是一个用于创建此类动态 HTML 页面的框架。
本章的其余部分重点介绍如何在服务器端创建 HTML 页面,更具体地说,是关于 ASP.NET Core MVC。
理解 ASP.NET Core 的基础知识
ASP.NET Core 基于通用宿主的概念,如第十一章的 使用通用宿主 子节和 将微服务架构应用于企业应用程序 中所述。ASP.NET Core 的基本架构在第十五章的 使用 .NET 应用服务导向架构 子节的 *ASP.NET Core 简介中概述。
值得记住的是,主机配置主要由向主机构建器实例的 Services
属性添加服务组成,该实例的类型实现了 IServiceCollection
接口,即通过 Dependency Injection
(DI
) 引擎的 Services
属性:
var builder = WebApplication.CreateBuilder(args);
// Add services to the container.
builder.Services.AddTransient<IMyService, MyService>();
...
// Add services to the container through extension methods.
builder.Services.AddControllersWithViews();
builder.Services.AddAllQueries(typeof(ManagePackagesController).Assembly);
...
...
var app = builder.Build();
由 builder.Services
实现的 IServiceCollection
接口定义了可以通过 DI 注入到对象构造函数中的所有服务。
服务可以通过直接在 Program.cs
中调用 AddTransient
和 AddSingleton
的各种重载来定义,或者通过将这些调用分组到一些 IServiceCollection
扩展方法中,然后在 Program.cs
中调用这些方法。.NET 中处理服务的方式在第十一章的 使用通用宿主 部分中有详细解释。这里值得指出的是,除了单例和瞬态服务之外,ASP.NET Core 还支持另一种服务生命周期,即会话生命周期,这是由 ASP.NET Core 应用程序服务的一个单个 Web 请求的生命周期。会话作用域的服务通过 AddScoped
重载声明,这与 AddTransient
和 AddSingleton
重载完全类似。
会话作用域的服务对于存储特定于单个请求且必须由多个应用程序组件在整个请求中使用的请求数据非常有用。.NET 会话作用域服务的典型示例是 Entity Framework Core 的 DBContexts
。实际上,对请求中涉及的各个聚合体执行的所有操作都必须使用相同的请求特定 DBContext
,以便所有更改都可以通过单个事务和唯一的 SaveChanges
操作保存到数据库中。
会话作用域的 DBContext
s 和其他服务的实际应用在 第十八章,使用 ASP.NET Core 实现前端微服务 中描述得更加详细。
通常,所有应用程序都通过主机构建器定义大部分应用程序配置,以便在用 var app = builder.Build()
构建主机后,您需要调用 app.Run()
或 await app.RunAsync()
来启动应用程序。
ASP.NET Core 主机在构建完成后执行另一个配置步骤;它定义了所谓的 ASP.NET Core HTTP 请求处理管道,这将在下一小节中更详细地描述。
ASP.NET Core 中间件
ASP.NET Core 包含一个名为 Kestrel 的内部 Web 服务器,它仅具备基本的 Web 服务器功能。因此,在像物联网应用或工作微服务这样的简单应用中,我们可以避免使用像 IIS、Apache 或 NGINX 这样的完全可选的复杂 Web 服务器的开销。
如果使用 ASP.NET Core 实现前端微服务/应用的表示层或经典网站,Kestrel 可以与所有主要 Web 服务器接口,这些服务器将它们的请求代理到 Kestrel。
在版本 8 中,Kestrel 默认支持所有协议,包括 HTTP/3 及其之前的所有版本。
反过来,Kestrel 将所有请求传递给一组可配置的模块,您可以根据需要组装这些模块。每个模块负责您可能需要或不需要的功能。此类功能的例子包括授权、身份验证、静态文件处理、协议协商和 CORS 处理。由于大多数模块都对传入请求和最终响应进行转换,因此这些模块通常被称为 中间件。
您可以通过将它们插入到一个称为 ASP.NET Core 管道 的通用处理框架中来组装您需要的所有 中间件 模块。
更具体地说,ASP.NET Core 请求通过将上下文对象推送到 ASP.NET Core 模块的管道中进行处理,如下面的图所示:
图 17.1:ASP.NET Core 管道
插入到管道中的对象是一个 HttpContext
实例,它包含传入请求的数据。更具体地说,HttpContext
的 Request
属性包含一个 HttpRequest
对象,其属性以结构化的方式表示传入请求。这些属性包括头部、Cookies、请求路径、参数、表单字段和请求体。
各种模块可以贡献于构建最终响应,该响应写入 HttpResponse
对象中,该对象包含在 HttpContext
实例的 Response
属性中。HttpResponse
类与 HttpRequest
类类似,但其属性指的是正在构建的响应。
一些模块可以构建一个中间数据结构,然后由管道中的其他模块使用。通常,这种中间数据可以存储在 HttpContext
对象的 Items
属性中包含的 IDictionary<object, object>
的自定义条目中。然而,有一个预定义的属性 User
,它包含有关当前登录用户的信息。登录用户不是自动计算的,因此必须由认证模块计算。第十五章,使用 .NET 应用服务导向架构的ASP.NET Core 服务授权小节解释了如何将基于 JWT 的标准模块添加到 ASP.NET Core 管道中。
HttpContext
还有一个 Connection
属性,它包含与客户端建立的基础连接的信息,以及一个 WebSockets
属性,它包含与客户端建立的可能基于 WebSocket 的连接的信息。
HttpContext
还有一个 Features
属性,它包含 IDictionary<Type, object>
,该属性指定了托管 web 应用的 web 服务器和管道所支持的功能。功能可以通过 .Set<TFeature>(TFeature o)
方法设置,并通过 .Get<TFeature>()
方法检索。
框架自动添加 Web 服务器功能,而所有其他功能则在处理 HttpContext
时由管道模块添加。
HttpContext
还通过其 RequestServices
属性为我们提供了对 DI 引擎的访问。你可以通过调用 .RequestServices.GetService(Type t)
方法或更好的 .GetRequiredService<TService>()
扩展方法(它基于前者)来获取由依赖引擎管理的类型实例。
然而,正如我们将在本章的剩余部分看到的那样,由 DI 引擎管理的所有类型通常都是自动注入到构造函数中的,因此这些方法仅在构建自定义 中间件 或其他 ASP.NET Core 引擎的自定义化时使用。
为处理 web 请求而创建的 HttpContext
实例不仅对模块可用,而且通过 DI 对应用程序代码也可用。只需将 IHttpContextAccessor
参数插入到自动依赖注入的类的构造函数中,然后访问其 HttpContext
属性即可。所有继承自 Controller
或 ControllerBase
(见本节后面的内容)的控制器都公开一个包含请求 HttpContext
的 HttpContext
属性。
中间件模块是任何具有以下结构的类:
public class CoreMiddleware
{
private readonly RequestDelegate _next;
public CoreMiddleware(RequestDelegate next, ILoggerFactory
loggerFactory)
{
...
_next = next;
...
}
public async Task InvokeAsync(HttpContext context)
{
/*
Insert here the module specific code that processes the
HttpContext instance before it is passed to the next
module.
*/
await _next.Invoke(context);
/*
Insert here other module specific code that processes the
HttpContext instance, after all modules that follow this
module finished their processing.
*/
}
}
还可以直接将 InvokeAsync
作为 lambda 表达式传递给 app.Use
,如下所示:
app.Use(async (context, next) =>
{
...
await next(context);
});
通常,每个中间件处理由管道中上一个模块传递的 HttpContext
实例,然后调用 await _next.Invoke(context)
来调用管道剩余部分的模块。当其他模块完成处理并且客户端的响应已经准备就绪时,每个模块都可以在 _next.Invoke(context)
调用之后的代码中执行进一步的响应后处理。
通过调用构建宿主的 UseMiddleware<T>
方法将模块注册到 ASP.NET Core 管道中,如下所示:
var app = builder.Build();
...
app.UseMiddleware<MyCustomModule>
...
app.Run();
当调用 UseMiddleware
时,中间件模块以相同的顺序插入到管道中。由于添加到应用程序中的每个功能可能需要几个模块以及其他操作,除了添加模块之外,你通常定义一个如以下代码所示的 IApplicationBuilder
扩展,例如 UseMyFunctionality
。
public static class MyMiddlewareExtensions
{
public static IApplicationBuilder UseMyFunctionality(this
IApplicationBuilder builder,...)
{
//other code
...
builder.UseMiddleware<MyModule1>();
builder.UseMiddleware<MyModule2>();
...
//Other code
...
return builder;
}
}
之后,可以通过调用 app.UseMyFunctionality(...)
将整个功能添加到应用程序中。例如,可以通过调用 app.UseEndpoints(....)
将 ASP.NET Core MVC 功能添加到 ASP.NET Core 管道中。
通常,通过每个 app.Use...
添加的功能需要将一些 .NET 类型添加到应用程序的依赖注入引擎中。在这些情况下,我们也会定义一个名为 AddMyFunctionality
的 IServiceCollection
扩展,它必须在 Program.cs
中的 builder.Services
上调用。
例如,ASP.NET Core MVC 需要如下调用:
builder.Services.AddControllersWithViews(o =>
{
//set here MVC options by modifying the o option parameter
}
如果你不需要更改默认的 MVC 选项,你可以简单地调用 builder.Services.AddControllersWithViews()
。
下一个子节描述了 ASP.NET Core 框架的另一个重要特性——即如何处理应用程序配置数据。
加载配置数据并使用选项框架
理解 ASP.NET Core 应用程序如何处理配置对于有效设置应用程序至关重要。在默认的 .NET 模板中,ASP.NET Core 应用程序启动时会从 appsettings.json
和 appsettings.[EnvironmentName].json
文件中读取配置信息(例如数据库连接字符串),其中 EnvironmentName
是一个字符串值,它取决于应用程序部署的位置。
EnvironmentName
字符串的典型值如下:
-
对于生产部署使用
Production
-
Development
在开发期间使用 -
当应用程序在预发布环境中进行测试时使用
Staging
从appsettings.json
和appsettings.[EnvironmentName].json
文件中提取的两个 JSON 树被合并成一个唯一的树,其中[EnvironmentName].json
中包含的值会覆盖appsettings.json
中相应路径的值。这样,应用程序可以在不同的部署环境中运行不同的配置。特别是,你可以在每个不同的环境中使用不同的数据库连接字符串和数据库实例。
图 17.2:配置文件合并
配置信息也可以从其他来源传递。鉴于空间有限,我们在此列出所有其他可能性,而不对其进行讨论:
-
XML 文件
-
.ini
文件 -
操作系统环境变量。变量名是以
ASPNETCORE_
字符串为前缀的设置名称,而变量值是设置值。 -
调用应用程序的
dotnet
命令的命令行参数。 -
键值对的内存集合
在我看来,JSON 格式是最实用和可读的,但 JSON、XML 和.ini
在本质上等效,选择它们只是个人偏好的问题。
在内存中,键值对的集合提供了从数据库中获取数据的可能性,因此它们是那些在应用程序运行时需要由管理员更改的参数的有用选项。
最后,当应用程序无法轻松访问磁盘存储时,命令行参数和环境变量是好的选择——例如,在 Kubernetes 集群中运行的部署情况。实际上,环境变量可以作为 Kubernetes .yaml
文件中的参数传递(参见第二十章“Kubernetes”中的副本集和部署部分)。它们也是传递敏感数据的可接受选择,这些数据不适合以纯文本格式存储在文件中。
从版本 8 开始,ASP.NET Core 允许你将 Kestrel HTTP 和 HTTPS 监听端口设置为配置变量。更具体地说,HTTP_PORTS
包含所有 Kestrel HTTP 监听端口的分号分隔列表,而HTTPS_PORTS
包含所有 HTTPs 监听端口的分号分隔列表,其默认值是通常的 HTTP 和 HTTPs 端口,即80
和443
。
[EnvironmentName]
字符串本身是从ENVIRONMENT
配置设置中获取的。显然,由于它需要决定使用哪个配置文件,因此它不能包含在配置文件中,所以它必须从操作系统的ASPNETCORE_ENVIRONMENT
环境变量或启动应用程序时使用的dotnet
命令的参数中获取。
当应用程序部署到 IIS 而不是作为独立程序启动时,ASPNETCORE_ENVIRONMENT
不能通过dotnet
命令行传递。
在这种情况下,它可以在 IIS 应用程序设置中设置。这可以在应用程序部署后完成,通过点击配置编辑器然后选择 system.webServer/aspNetCore
部分。在打开的窗口中,选择 environmentVariables
,然后添加 ASPNETCORE_ENVIRONMENT
变量及其值。
图 17.3:在 IIS 中更改 ASPNETCORE_ENVIRONMENT
然而,当应用程序被修改并重新部署时,设置会重置为其默认值,即 Production
,并且必须再次设置。
更好的选择是修改 Visual Studio 中的发布配置文件,如下所示:
-
在 Visual Studio 部署期间,Visual Studio 的 发布 向导创建一个 XML 发布配置文件。一旦选择了首选的部署类型(Azure、Web Deploy、文件夹等),在发布之前,你可以通过在出现的窗口中选择 更多操作 下拉菜单中的 编辑 来编辑发布设置。
-
一旦你的发布文件设置正确,在 Visual Studio 解决方案资源管理器中,打开你刚刚使用 Visual Studio 向导准备好的配置文件。配置文件保存在项目文件夹的
Properties/PublishProfiles/<profile name>.pubxml
路径下。 -
然后,使用文本编辑器编辑配置文件,并添加一个 XML 属性,例如
<EnvironmentName>Staging</EnvironmentName>
。由于所有已定义的发布配置文件都可以在应用程序发布期间选择,你可以为每个环境定义不同的发布配置文件,然后,你可以在每次发布时选择所需的配置文件。
在部署期间必须设置的 ASPNETCORE_ENVIRONMENT
的值也可以在应用程序的 Visual Studio ASP.NET Core 项目文件(.csproj
)中指定,通过添加以下代码:
<PropertyGroup>
<EnvironmentName>Staging</EnvironmentName>
</PropertyGroup>
这是进行 ASPNETCORE_ENVIRONMENT
的最简单方法,但不是最模块化的,因为我们被迫在发布到不同环境之前更改应用程序代码。
在发布配置文件或项目文件中指定环境,仅适用于基于 Visual Studio 和 Web 服务器之间直接通信的部署类型,因为在其他部署类型中,Visual Studio 无法通知 Web 服务器如何设置 ASPNETCORE_ENVIRONMENT
或在应用程序启动时如何传递环境。在撰写本文时,描述的技术仅适用于 Web Deploy 或在 Azure 上发布。
在 Visual Studio 中的开发期间,当应用程序运行时,ASPNETCORE_ENVIRONMENT
的值可以在 ASP.NET Core 项目的 Properties\launchSettings.json
文件中指定。launchSettings.json
文件包含几个命名的设置组。这些设置配置了从 Visual Studio 运行 Web 应用程序的方式。你可以通过选择 Visual Studio 运行按钮旁边的下拉列表中的组名来选择应用一个组的所有设置:
图 17.4:启动设置组的选择
您从下拉列表中的选择将在运行按钮上可见,默认选择为 IIS Express。
考虑一个开发环境设置,如图中典型的 launchSettings.json
文件所示:
{
"iisSettings": {
"windowsAuthentication": false,
"anonymousAuthentication": true,
"iisExpress": {
"applicationUrl": "http://localhost:2575",
"sslPort": 44393
}
},
"profiles": {
"IIS Express": {
"commandName": "IISExpress",
"launchBrowser": true,
"environmentVariables": {
"ASPNETCORE_ENVIRONMENT": "Development"
}
},
...
...
}
}
}
设置的命名组位于 profiles
属性下。在那里,您可以选择应用程序的托管位置(IIS Express
)、启动浏览器的位置以及一些环境变量的值。
当前从 ASPNETCORE_ENVIRONMENT
操作系统环境变量加载的环境可用,在 ASP.NET Core 管道定义期间的 app.Environment
属性中。
app.Environment.IsEnvironment(string environmentName)
检查 ASPNETCORE_ENVIRONMENT
的当前值是否为 environmentName
。还有针对测试开发(.IsDevelopment()
)、生产(.IsProduction()
)和预发布(.IsStaging()
)的特定快捷方式。app.Environment
属性还包含 ASP.NET Core 应用程序当前根目录(.WebRootPath
)和由 Web 服务器按原样提供的静态文件目录(.ContentRootPath
)(CSS、JavaScript、图像等)。
launchSettings.json
和所有发布配置文件都可以在 Visual Studio 探索器中的 属性 节点下作为子节点访问,如下面的截图所示:
图 17.5:启动设置文件
了解如何将合并的配置设置映射到 .NET 对象对于在 ASP.NET Core 应用程序中有效管理数据至关重要。
一旦加载了 appsettings.json
和 appsettings.[EnvironmentName].json
,合并后的配置树可以映射到 .NET 对象的属性。例如,假设我们有一个 appsettings
文件的 Email
部分,其中包含连接到电子邮件服务器所需的所有信息,如下所示:
{
"ConnectionStrings": {
"DefaultConnection": "...."
},
"Logging": {
"LogLevel": {
"Default": "Warning"
}
},
"Email": {
"FromName": "MyName",
"FromAddress": "info@MyDomain.com",
"LocalDomain": "smtps.MyDomain.com",
"MailServerAddress": "smtps.MyDomain.com",
"MailServerPort": "465",
"UserId": "info@MyDomain.com",
"UserPassword": "mypassword"
}
然后,整个 Email
部分可以被映射到以下类的实例:
public class EmailConfig
{
public String FromName { get; set; }
public String FromAddress { get; set; }
public String LocalDomain { get; set; }
public String MailServerAddress { get; set; }
public String MailServerPort { get; set; }
public String UserId { get; set; }
public String UserPassword { get; set; }
}
执行映射的代码必须在主机构建阶段插入,因为 EmailConfig
实例将通过依赖注入 (DI) 提供。所需的代码如下所示:
Var builder = WebApplication.CreateBuilder(args);
....
builder.Services.Configure<EmailConfig>(Configuration.GetSection("Email"));
..
一旦配置了前面的设置,需要 EmailConfig
数据的类必须声明一个由 DI 引擎提供的 IOptions<EmailConfig> options
构造函数参数。EmailConfig
实例包含在 options.Value
中。
值得注意的是,选项类的属性可以应用于我们将用于 ViewModels 的相同验证属性(见 服务器端和客户端验证 子节)。
下一个子节描述了 ASP.NET Core MVC 应用程序所需的基本 ASP.NET Core 管道模块。
定义 ASP.NET Core 管道
理解 ASP.NET Core 管道对于自定义应用程序行为至关重要。当你在 Visual Studio 中创建一个新的 ASP.NET Core MVC 项目时,Program.cs
文件中会创建一个标准管道。在那里,如果需要,你可以添加更多的中间件或更改现有中间件的配置。
初始管道定义代码处理错误并执行基本的 HTTPS 配置:
if (app.Environment.IsDevelopment())
{
}
else //this is not part of the project template, but it is worth adding it
{
app.UseDeveloperExceptionPage();
}
app.UseHttpsRedirection();
如果存在错误,并且应用程序处于开发环境,则由 UseDeveloperExceptionPage
安装的模块会将详细的错误报告添加到响应中。此模块是一个宝贵的调试工具。
如果应用程序不在开发模式下发生错误,UseExceptionHandler
会从它接收的参数路径恢复请求处理,即从 /Home/Error
。换句话说,它模拟了一个新的带有 /Home/Error
路径的请求。此请求被推入标准 MVC 处理,直到它达到与 /Home/Error
路径关联的端点,在那里开发者预计会放置处理错误的自定义代码。
当应用程序不在开发模式下时,UseHsts
将 Strict-Transport-Security
标头添加到响应中,这会通知浏览器应用程序必须仅通过 HTTPS 访问。在此声明之后,符合规定的浏览器应自动将应用程序的任何 HTTP 请求转换为 Strict-Transport-Security
标头中指定时间的 HTTPS 请求。默认情况下,UseHsts
将 30 天指定为标头中的时间,但你可以通过传递一个配置 options
对象的 lambda 表达式给 UseHsts
来指定不同的时间和其他标头参数:
builder.Services.AddHsts(options => {
...
options.MaxAge = TimeSpan.FromDays(60);
...
});
UseHttpsRedirection
在收到 HTTP URL 时导致自动重定向到 HTTPS URL,以强制建立安全连接。一旦建立了第一个 HTTPS 安全连接,Strict-Transport-Security
标头将防止未来可能用于执行中间人攻击的重定向。
以下代码显示了默认管道的剩余部分:
app.UseStaticFiles();
// not in the default template but needed in all countries of the European Union
app.UseCookiePolicy();
app.UseRouting();
app.UseAuthentication();
app.UseAuthorization();
...
UseStaticFiles
使项目 wwwroot
文件夹中包含的所有文件(通常是 CSS、JavaScript、图像和字体文件)可以通过其实际路径从网络访问。
UseCookiePolicy
已从 .NET 5-8 模板中移除,但您仍然可以手动添加它。它确保只有在用户同意使用 cookie 的情况下,cookie 才会被 ASP.NET Core 管道处理。对 cookie 使用同意是通过一个同意 cookie 来给出的;也就是说,只有在请求 cookie 中找到此同意 cookie 时,才会启用 cookie 处理。此 cookie 必须由 JavaScript 在用户点击同意按钮时创建。包含同意 cookie 的名称及其内容的整个字符串可以从 HttpContext.Features
中检索,如下面的代码片段所示:
var consentFeature = context.Features.Get<ITrackingConsentFeature>();
var showBanner = !consentFeature?.CanTrack ?? false;
var cookieString = consentFeature?.CreateConsentCookie();
只有在需要同意且尚未提供的情况下,CanTrack
才为 true
。当检测到同意 cookie 时,CanTrack
被设置为 false
。这样,只有当需要同意且尚未提供时,showBanner
才为 true
。因此,它告诉我们是否需要向用户请求同意。
CookiePolicyOptions in the code, instead of using the configuration file:
builder.Services.Configure<CookiePolicyOptions>(options =>
{
options.CheckConsentNeeded = context => true;
});
UseAuthentication
启用认证方案,并且仅在创建项目时选择认证方案时出现。更具体地说,这个中间件解码授权令牌(授权 cookie、bearer 令牌等),并使用其中包含的信息构建一个放置在 HttpContext.User
属性中的 ClaimsPrincipal
对象。
可以通过在主机构建阶段配置选项对象来启用特定的认证方案,如下所示:
builder.Services.AddAuthentication(o =>
{
o.DefaultScheme =
CookieAuthenticationDefaults.AuthenticationScheme;
})
.AddCookie(o =>
{
o.Cookie.Name = "my_cookie";
})
.AddJwtBearer(o =>
{
...
});
上一段代码指定了一个自定义的认证 cookie 名称,并为应用程序中包含的 REST 服务添加了基于 JWT 的认证。AddCookie
和 AddJwtBearer
都有重载,可以接受在操作之前认证方案的名称,这是您定义认证方案选项的地方。由于认证方案名称对于引用特定认证方案是必要的,因此当它未指定时,将使用默认名称:
-
CookieAuthenticationDefaults.AuthenticationScheme
中包含的用于 cookie 认证的规范名称 -
JwtBearerDefaults.AuthenticationScheme
中包含的用于 JWT 认证的规范名称
传递给 o.DefaultScheme
的名称用于选择认证方案,用于填充 HttpContext
的 User
属性。与 DefaultScheme
一起,其他属性也允许更高级的定制。
关于 JWT 认证的更多信息,请参阅第十五章,使用 .NET 应用服务导向架构中的 ASP.NET Core 服务授权子节。
如果您只是指定 builder.Services.AddAuthentication()
,则假定使用默认参数的基于 cookie 的认证。
UseAuthorization
允许基于 Authorize
属性进行授权。可以通过在主机构建阶段添加 builder.Services.AddAuthorization
来配置选项。这些选项允许您定义基于声明的授权策略。
UseRouting
和 UseEndpoints
处理所谓的 ASP.NET Core 端点。端点是对处理特定 URL 类别的处理程序的抽象。这些 URL 通过模式转换为 Endpoint
实例。当模式与 URL 匹配时,会创建一个 Endpoint
实例,并填充模式名称和从 URL 中提取的数据。这是通过将 URL 部分与模式命名的部分匹配实现的。这可以在以下代码片段中看到:
Request path: /UnitedStates/NewYork
Pattern: Name="location", match="/{Country}/{Town}"
Endpoint: DisplayName="Location", Country="UnitedStates", Town="NewYork"
UseRouting
添加了一个模块,该模块处理请求路径以获取请求 Endpoint
实例,并将其添加到 HttpContext.Features
字典中的 IEndpointFeature
类型下。实际的 Endpoint
实例包含在 IEndpointFeature
的 Endpoint
属性中。
每个模式还包含应处理所有匹配该模式的请求的处理程序。当创建 Endpoint
时,将此处理程序传递给 Endpoint
。
另一方面,UseEndpoints
添加了执行由 UseRouting
逻辑确定的路由的中间件。它放置在管道的末尾,因为其中间件生成最终响应。将路由逻辑拆分为两个单独的中间件模块使得授权中间件可以位于它们之间,并根据匹配的端点,决定是否将请求传递给 UseEndpoints
中间件进行正常执行,或者立即返回 401 (Unauthorized
)/403 (Forbidden
) 响应。
UseAuthorization
必须始终放在 UseAuthentication
和 UseRouting
之后,因为它需要由 UseAuthentication
填充的 HttpContext.User
以及 UseRouting
选定的处理程序,以便验证用户是否有权访问所选请求处理程序。
UseRouting middleware, but they are listed in the UseEndpoints method. While it might appear strange that URL patterns are not defined directly in the middleware that uses them, this was done mainly for coherence with the previous ASP.NET Core versions. In fact, previous versions contained no method analogous to UseRouting and, instead, some unique middleware at the end of the pipeline. In the new version, patterns are still defined at the end of the pipeline for coherence with previous versions, but now, UseEndpoints just creates a data structure containing all patterns when the application starts. Then, this data structure is processed by the UseRouting middleware, as shown in the following code:
app.UseRouting();
app.UseAuthentication();
app.UseAuthorization();
app.MapControllerRoute(
name: "default",
pattern: "{controller=Home}/{action=Index}/{id?}");
app.MapControllerRoute
是以下内容的快捷方式:
app.UseEndpoints(endpoints =>
{
endpoints.MapControllerRoute(
name: "default",
pattern: "{controller=Home}/{action=Index}/{id?}");
});
此快捷方式是在 .NET 6.0 版本中引入的。
MapControllerRoute
定义了与 MVC 引擎关联的模式,这将在下一小节中描述。其他方法定义了其他类型的模式。例如,.MapHub<MyHub>("/chat")
将路径映射到处理 SignalR 的 hub,SignalR 是建立在 WebSocket
之上的抽象,而 .MapHealthChecks("/health")
将路径映射到返回应用程序健康数据的 ASP.NET Core 组件。
您还可以使用 .MapGet
直接将模式映射到自定义处理程序,它拦截 GET 请求,以及 .MapPost
拦截 POST 请求。这被称为 路由到代码。以下是一个 MapGet
的示例:
MapGet("hello/{country}", context =>
context.Response.WriteAsync(
$"Selected country is {context.GetRouteValue("country")}"));
我们也可以直接编写 app.MapGet(...)
,因为 MapGet
、MapPost
等都有快捷方式。
所有这些快捷方式,连同新功能,都被命名为 Minimal API。它们为更简单的应用程序提供了一种精简的方法,这对于考虑性能优化和 API 设计的架构师来说相关,尤其是在物联网和微服务场景中。
此外,MapGet
、MapPost
等类似功能已经得到增强,现在它们具有重载,其 lambda 表达式可以直接将结果返回以添加到响应中,无需调用 context.Response.WriteAsync
。如果结果不是字符串,它将自动转换为 JSON,并将响应 Content-Type
设置为 application/json
。对于更复杂的需求,Minimal APIs 可以使用 Results
类的静态方法,该类支持 ASP.NET Core 控制器支持的所有返回类型。以下是一个 Results
类使用的示例:
app.MapGet("/web-site-conditions", () =>
Results.File("Contracts/WebSiteConditions.pdf"));
模式按照它们定义的顺序进行处理,直到找到匹配的模式。由于身份验证/授权中间件放置在路由中间件之后,它可以处理Endpoint
请求以验证当前用户是否有执行Endpoint
处理器的必要授权。
否则,将立即返回 401(未授权)或 403(禁止)响应。只有通过身份验证和授权的请求才会由UseEndpoints
中间件执行其处理器。
最小 API 支持我们在第十五章使用.NET 应用服务架构中描述的 OpenAPI 元数据的自动生成。它们还支持在应用程序发布时的即时编译(AOT)。这样,应用程序会立即在目标 CPU 语言中编译,节省了即时编译所需的时间。
此外,由于 AOT 在发布时运行,它可以执行更好的代码优化,特别是它可以修剪 DLL 中未使用的代码。通常,AOT 不被基于控制器的应用程序支持,因为它们更多地使用了反射。
因此,最小 API 针对的是简单且快速的应用程序,运行在小型设备上,如物联网应用程序,在这些应用中,一方面速度和减小应用程序大小是基本的,另一方面,通过控制器结构化代码的好处是可以忽略不计。我们不会详细描述最小 API,因为这本书主要针对商业和企业应用程序。
值得注意的是,在最新的.NET 版本中,添加了一个新的ASP.NET Core API项目,它基于最小 API 生成应用程序。
如第十五章中描述的 ASP.NET Core RESTful API 一样,ASP.NET Core MVC 也使用放置在控制器或控制器方法上的属性来指定授权规则。然而,也可以将AuthorizeAttribute
的实例添加到一个模式中,以便将其授权约束应用于所有匹配该模式的 URL,如下面的示例所示:
endpoints
.MapHealthChecks("/health")
.RequireAuthorization(new AuthorizeAttribute(){ Roles = "admin", });
之前的代码使健康检查路径仅对管理员用户可用。
值得注意的是.UseCors()
中间件,它使应用程序能够处理 CORS 策略。我们将在第十九章“客户端框架:Blazor”的与服务器通信部分中讨论它。
在描述了 ASP.NET Core 框架的基本结构之后,我们现在可以继续探讨更多与 MVC 相关的特性。下一小节将介绍控制器,并解释它们如何通过 ViewModel 与 UI 组件(称为视图)交互。
定义控制器和 ViewModel
在 ASP.NET Core MVC 中,控制器和 ViewModel 是处理请求、展示数据和处理整个用户-应用程序交互的核心。让我们首先了解如何将特定路径发出的请求传递到控制器。
.MapControllerRoute
调用将 URL 模式与控制器及其方法关联起来,其中控制器是从 Microsoft.AspNetCore.Mvc.Controller
类继承的类。控制器通过检查应用程序的所有 .dll
文件被发现,并添加到依赖注入引擎中。这项工作是通过在 Program.cs
文件中调用 builder.Services.AddControllersWithViews
来完成的。
由 UseEndpoints
添加的管道模块从 controller
模式变量中获取控制器名称,并从 action
模式变量中获取要调用的控制器方法名称。由于按照惯例,所有控制器名称都期望以 Controller
后缀结尾,因此实际控制器类型名称是通过在 controller
变量中找到的名称添加此后缀来获得的。因此,例如,如果在 controller
中找到的名称是 Home
,那么 UseEndpoints
模块会尝试从依赖注入引擎中获取 HomeController
类型的实例。所有可由路由规则选择的控制器公共方法都可以称为操作方法。可以通过使用 [NonAction]
属性来阻止使用控制器公共方法。所有可供路由规则使用的控制器方法都称为操作方法。
MVC 控制器的工作方式类似于我们在 第十五章,使用 .NET 应用服务架构 中 实现 REST 服务 的子节中描述的 API 控制器。唯一的区别是 API 控制器预计会产生 JSON 或 XML,而 MVC 控制器预计会产生 HTML。因此,虽然 API 控制器从 ControllerBase
类继承,但 MVC 控制器从 Controller
类继承,该类反过来又从 ControllerBase
类继承并添加了用于 HTML 生成的方法,如下一节所述,以及创建重定向响应。
MVC 控制器也可以使用类似于 API 控制器的一种路由技术,即基于控制器和控制器方法属性的路由。这种行为是通过在 Program.cs
文件中的管道定义代码中调用 app.MapControllers()
方法来启用的。如果这个调用放在所有其他 app.MapControllerRoute
调用之前,那么控制器路由比 MapControllerRoute
模式具有优先级;否则,情况相反。
MapControllerRoute
的优点是可以在一个地方决定整个应用程序使用的所有路径。这样,您可以通过在单个地方更改几行代码来优化所有应用程序路径,以便搜索引擎优化或简单地为了更好的用户导航。因此,MapControllerRoute
几乎总是用于 MVC 应用程序。然而,MapControllerRoute
很少与 REST API 一起使用,因为 REST API 的优先级是避免路径和控制器之间的关联变化,因为这些变化可能会阻止现有客户端正常工作。
我们已经看到的 API 控制器属性也可以用于 MVC 控制器和动作方法(HttpGet
、HttpPost
……、Authorize
等)。开发者可以通过从ActionFilter
类或其他派生类继承来编写自己的自定义属性。我现在不会详细介绍这一点,但这些细节可以在官方文档中找到,该文档在进一步阅读部分有提及。
当UseEndpoints
模块调用控制器时,所有构造函数参数都由 DI 引擎填充,因为控制器实例本身是由 DI 引擎返回的,并且 DI 会递归地自动用 DI 填充构造函数参数。
动作方法从它们的参数中获取输入和服务,因此理解这些参数如何由 ASP.NET Core 填充至关重要。它们来自以下来源:
-
请求头
-
当前请求匹配的模式中的变量
-
查询字符串参数
-
表单参数(在 POST 请求的情况下)
-
请求正文
-
依赖注入(DI),在需要处理请求的服务的情况下
当使用依赖注入(DI)填充的参数按类型匹配时,所有其他参数都按名称匹配,忽略字母大小写。也就是说,动作方法参数名称必须与标题、查询字符串、表单或模式变量匹配。反过来,模式变量通过将模式与请求路径匹配来填充。
当参数是复杂类型时,其行为取决于来源。
如果来源是请求正文,则选择一个适合请求Content-Type
的格式化器。格式化器是能够从它们的文本表示形式构建复杂实体的软件模块。默认情况下,请求正文被视为选择Content-Types
(如application/json
和二进制 MIME 类型)的来源,因为每种这样的 MIME 类型都需要一个特定的反序列化算法,该算法针对它本身。
如果来源不是请求正文,则使用名为模型绑定的算法来填充所有复杂对象的公共属性。
模型绑定算法在每个属性中搜索匹配项(记住,只有属性会被映射;字段不会被映射),使用属性名称进行匹配。在嵌套复杂类型的情况下,会为每个嵌套属性的路径搜索匹配项,通过连接路径中的所有属性名称并使用点分隔来获得与路径关联的名称。例如,名称为Property1.Property2.Property3…Propertyn
的参数与以下嵌套对象属性路径中的Propertyn
属性映射:Property1
、Property2
、......、Propertyn
。
通过这种方式获得的名字必须与头名称、模式变量名称、查询字符串参数名称等匹配。例如,包含复杂Address
对象的OfficeAddress
属性将生成如OfficeAddress.Country
和OfficeAddress.Town
之类的名称。
模型绑定算法还可以填充集合和字典,但由于篇幅限制,我们无法描述这些情况。然而,在进一步阅读部分包含了一个链接,指向 Phil Haack 的一篇优秀的文章,详细解释了这些情况。
默认情况下,简单类型参数与模式变量和查询字符串变量匹配,而复杂类型参数则与表单参数或请求体(取决于它们的 MIME 类型)匹配。然而,可以通过在参数前添加属性来更改前面的默认设置,具体细节请参考此处:
-
[FromForm]
强制与表单参数匹配 -
[FromBody]
强制从请求体中提取数据 -
[FromHeader]
强制与请求头匹配 -
[FromRoute]
强制与模式变量匹配 -
[FromQuery]
强制与查询字符串变量匹配 -
[FromServices]
强制使用依赖注入(DI)
值得指出的是,ASP.NET Core 的 7 和 8 版本增强了最小 API,以支持控制器操作方法的基本相同的参数绑定,以及上述所有参数属性。
在匹配过程中,从所选源提取的字符串将使用当前线程的文化转换为操作方法参数的类型。如果转换失败或找不到必需的操作方法参数的匹配项,则整个操作方法调用过程失败,并自动返回 404 响应。例如,在以下示例中,由于id
参数是简单类型,因此它与查询字符串参数或模式变量匹配,而myclass
属性和嵌套属性则与表单参数匹配,因为MyClass
是复杂类型。最后,从 DI 中获取myservice
,因为它前面带有[FromServices]
属性:
public class HomeController : Controller
{
public IActionResult MyMethod(
int id,
MyClass myclass,
[FromServices] MyService myservice)
{
...
如果找不到id
参数的匹配项,并且如果id
参数在MapControllerRoute
模式中被声明为必需,则会自动返回 404 响应,因为模式匹配失败。通常,当参数必须匹配非空单类型时,会将参数声明为非可选。相反,如果在 DI 容器中找不到MyService
实例,则会抛出异常,因为在这种情况下,失败并不取决于错误的请求,而是设计错误。
如果 MVC 控制器被声明为async
,则返回IActionResult
接口或Task<IActionResult>
结果。IActionResult
定义了一个具有ExecuteResultAsync(ActionContext)
签名的独特方法,当框架调用该方法时,会产生实际的响应。
对于每个不同的 IActionResult
,MVC 控制器都有返回它们的方法。最常用的 IActionResult
是 ViewResult
,它由 View
方法返回:
public IActionResult MyMethod(...)
{
...
return View("myviewName", MyViewModel)
}
ViewResult
是控制器创建 HTML 响应的一种非常常见的方式。更具体地说,控制器与业务/数据层交互,以生成将在 HTML 页面上显示的数据的抽象。这个抽象是一个名为 ViewModel 的对象。ViewModel 作为 View
方法的第二个参数传递,而第一个参数是名为 View
的 HTML 模板的名称,该模板使用 ViewModel 中包含的数据实例化。
总结一下,MVC 控制器的处理顺序如下:
-
控制器执行一些处理以创建 ViewModel,这是要在 HTML 页面上显示的数据的抽象。
-
然后,控制器通过将视图名称和 ViewModel 传递给
View
方法来创建ViewResult
。 -
MVC 框架调用
ViewResult
并导致包含在视图中的模板使用 ViewModel 中的数据实例化。 -
模板实例化的结果将带有适当的头信息写入响应中。
这样,控制器通过构建 ViewModel 执行 HTML 生成的概念性工作,而视图(即模板)则负责所有展示细节。
在下一小节中将对视图进行更详细的描述,而模型(ViewModel)视图控制器模式将在本章的 理解 ASP.NET Core MVC 与设计原则之间的联系 部分进行更详细的讨论。最后,将在 第十八章,使用 ASP.NET Core 实现前端微服务 中提供一个实际示例。
另一个常见的 IActionResult
是 RedirectResult
,它创建一个重定向响应,从而强制浏览器移动到特定的 URL。重定向通常在用户成功提交一个完成先前操作的表单后使用。在这种情况下,通常将用户重定向到可以选择另一个操作的页面。
返回 RedirectResult
的最简单方式是通过传递一个 URL 给 Redirect
方法。这是建议用于将用户重定向到 web 应用程序外部的 URL 的方法。另一方面,当 URL 在 web 应用程序内部时,建议使用 RedirectToAction
方法,该方法接受控制器的名称、操作方法的名称以及目标操作方法所需的参数。此方法有几个重载,其中每个重载省略了上述参数的一部分。特别是,如果定义的 URL 由同一控制器处理,则可以省略控制器名称。
框架使用这些数据来计算一个 URL,该 URL 会调用具有提供参数的期望操作方法。这样,如果在应用程序的开发或维护期间更改了路由规则,框架会自动更新新的 URL,无需修改代码中所有旧 URL 的出现。
以下代码显示了如何调用 RedirectToAction
:
return RedirectToAction("MyActionName", "MyControllerName",
new {par1Name=par1Value,..parNName=parNValue});
另一个有用的 IActionResult
是 ContentResult
,可以通过调用 Content
方法创建。ContentResult
允许你将任何字符串写入响应并指定其 MIME 类型,如下面的示例所示:
return Content("this is plain text", "text/plain");
最后,File
方法返回 FileResult
,它在响应中写入二进制数据。此方法有几个重载,允许指定字节数组、流或文件的路径,以及二进制数据的 MIME 类型。
现在,让我们继续描述如何在视图中生成实际的 HTML。
理解 ASP.NET Core MVC 如何创建响应 HTML
Razor 视图
ASP.NET Core MVC 使用名为 Razor 的语言来定义视图中的 HTML 模板。Razor 视图是文件,首次使用时、应用程序构建时或应用程序发布时会被编译成 .NET 类。默认情况下,每次构建和发布时都会启用预编译,但你也可以启用运行时编译,以便在视图部署后进行修改。此选项可以通过在 Visual Studio 中创建项目时勾选 启用 Razor 运行时编译 复选框来启用。你还可以通过向 Web 应用程序项目文件中添加以下代码来禁用每次构建和发布时的编译:
<PropertyGroup>
<TargetFramework> net8.0 </TargetFramework>
<!-- add code below -->
<RazorCompileOnBuild>false</RazorCompileOnBuild>
<RazorCompileOnPublish>false</RazorCompileOnPublish>
<!-- end of code to add -->
...
</PropertyGroup>
如果你在选择 ASP.NET Core 项目后出现的窗口中选择 Razor 视图库项目,视图也可以预先编译成视图库。
此外,编译后,视图与其路径保持关联,这些路径成为它们的完整名称。每个控制器在 Views 文件夹下都有一个与其同名的关联文件夹,该文件夹预计将包含该控制器使用的所有视图。
以下截图显示了与可能的 HomeController
及其视图关联的文件夹:
图 17.6:与控制器和共享文件夹关联的视图文件夹
前面的截图还显示了共享文件夹,该文件夹预计将包含多个控制器使用的所有视图或部分视图。控制器通过不带.cshtml
扩展名的路径在View
方法中引用视图。如果路径以/
开头,则路径被视为相对于应用程序根目录的相对路径。否则,作为第一次尝试,路径被视为相对于与控制器关联的文件夹的相对路径。如果在那里找不到视图,则将在共享文件夹中搜索视图。
因此,例如,前面截图中的Privacy.cshtml
视图文件可以在HomeController
内部通过View("Privacy", MyViewModel)
进行引用。如果视图的名称与动作方法的名称相同,我们可以简单地写View(MyViewModel)
。
Razor 视图是 HTML 代码与 C#代码的混合,还有一些 Razor 特定的语句。它们通常以包含视图预期接收的 ViewModel 类型的标题开始:
@model MyViewModel
这个声明可以省略,但在这种情况下,视图将不会针对特定类型,我们也不能在 Razor 代码中使用模型属性名称。
每个视图也可能包含一些using
语句,其效果与标准代码文件中的using
语句相同:
@model MyViewModel
@using MyApplication.Models
在特殊文件_ViewImports.cshtml
中声明的@using
语句——即在Views
文件夹的根目录中——将自动应用于所有视图。
每个视图也可以在其标题中要求 DI 引擎的实例类型,其语法如下:
@model MyViewModel
@using MyApplication.Models
@inject IViewLocalizer Localizer
前面的代码需要一个IViewLocalizer
接口的实例,并将其放置在Localizer
变量中。视图的其余部分是 C#代码、HTML 和 Razor 控制流程语句的混合。视图的每个区域可以是 HTML 模式或 C#模式。在 HTML 模式下处于视图区域的代码被解释为 HTML,而在 C#模式下处于视图区域的代码被解释为 C#。
下一个主题解释了控制语句的 Razor 流程。
学习控制语句的 Razor 流程
如果你想要在 HTML 区域中编写一些 C#代码,你可以创建一个带有控制语句@{..}
的 Razor 流程的 C#区域,如下所示:
@{
//place C# code here
var myVar = 5;
...
<div>
<!-- here you are in HTML mode again -->
...
</div>
//after the HTML block you are still in C# mode
var x = "my string";
}
前面的示例表明,在 C#区域内创建 HTML 区域并递归地创建其他 HTML 区域,只需编写一个 HTML 标签就足够了。一旦 HTML 标签关闭,你又将回到 C#模式。
如果我们需要创建一个 HTML 区域,但又不想用 HTML 标签将其包围,我们可以使用 Razor 语法提供的虚拟<text>
标签:
<text>
<!-- here you entered HTML mode without adding an enclosing
HTML tag -->
...
</text>
C#代码不会产生 HTML,而 HTML 代码将按照它出现的顺序添加到响应中。你可以在 HTML 模式下通过在 C#表达式前加@
来添加用 C#代码计算出的文本。如果表达式复杂,即由属性链和方法的调用组成,则必须用括号括起来。以下代码显示了几个示例:
<span>Current date is: </span>
<span>@DateTime.Today.ToString("d")</span>
...
<p>
User name is: @($"{myName} {mySurname}")
</p>
...
<input type="submit" value="@myUserMessage" />
@
本身可以通过输入两次来转义——@@
。
类型会根据当前的文化设置转换为字符串(有关如何设置每个请求的文化,请参阅理解 ASP.NET Core MVC 与设计原则之间的联系部分以获取详细信息)。此外,字符串会自动进行 HTML 编码以避免<
和>
符号,这些符号可能会干扰视图的 HTML。
可以使用@HTML.Raw
函数防止 HTML 编码,如下所示:
@HTML.Raw(myDynamicHtml)
在 HTML 区域中,可以使用@if
Razor 语句选择替代 HTML:
@if(myUser.IsRegistered)
{
//this is a C# code area
var x=5;
...
<p>
<!-- This is an HTML area -->
</p>
//this is a C# code area again
}
else if(myUser.IsNew)
{
...
}
else
{
..
}
如前述代码所示,Razor 控制流语句的每个块的开始都在 C#模式下,并且保持这种模式,直到遇到第一个 HTML 开放标签,然后开始 HTML 模式。在相应的 HTML 关闭标签之后,将恢复 C#模式。
HTML 模板可以使用for
、foreach
、while
和do
Razor 语句实例化多次,如下面的示例所示:
@for(int i=0; i< 10; i++)
{
}
@foreach(var x in myIEnumerable)
{
}
@while(true)
{
}
@do
{
}
while(true)
Razor 视图可以包含不会生成任何代码的注释。任何包含在@*...*@
内的文本都被视为注释,并在页面编译时被移除。在对控制器及其操作机制有良好理解的基础上,我们现在转向 ASP.NET Core MVC 如何使用 Razor 视图生成 HTML 响应。
理解 Razor 视图属性
每个视图中都预定义了一些标准变量。最重要的变量是Model
,它包含传递给视图的 ViewModel。例如,如果我们向视图传递一个Person
模型,那么<span>@Model.Name</span>
将显示传递给视图的Person
模型的名称。
ViewData
变量包含IDictionary<string, object>
,它与调用视图的控制器共享;也就是说,所有控制器也都有一个包含IDictionary<string, object>
的ViewData
属性,并且控制器中设置的每个条目在调用视图的ViewData
变量中也是可用的。ViewData
是控制器对 ViewModel 的替代方案,允许将信息传递给其调用的视图。值得一提的是,ViewData
字典也可以通过ViewBag
属性作为动态对象访问。这意味着动态ViewBag
属性映射到ViewData
字符串索引,而它们的值映射到对应索引的ViewData
条目。使用ViewData
或ViewBag
只是个人偏好的问题;两者之间没有优势。
通常,ViewData
用于存储辅助数据,例如用于填充 HTML Select 的值-字符串对。例如,假设 ViewModel 模型包含用户可以通过从 HTML Select 中选择不同的城镇来更改的TownId
和TownName
属性。在这种情况下,操作方法可能会将"AllTowns"
条目填充到ViewData
中,包含所有可能的城镇 ID 和城镇名称对:
ViewData[=["AllTowns"]= await townsRepo.GetAll();
...
return View(new AddressViewModel{...});
控制器和视图也包含一个 TempData
字典,其条目在连续的两个请求之间被记住。由于空间有限,我们无法讨论其属性及其用法,但感兴趣的读者可以参考官方的 Microsoft 文档:
learn.microsoft.com/en-us/aspnet/core/fundamentals/app-state?view=aspnetcore-8.0#tempdata
User
视图变量包含当前登录的用户,即当前请求的 Http.Context.User
属性中包含的相同实例。Url
变量包含 IUrlHelper
接口的实例,其方法用于计算应用程序页面的 URL。例如,Url.Action("action", "controller", new {par1=valueOfPar1,...})
计算导致 controller
的 action
方法被调用的 URL,其中所有参数都在传递给它的匿名对象中指定。
Context
变量包含整个请求的 HttpContext
。ViewContext
变量包含有关视图调用上下文的数据,包括调用视图的动作方法元数据。
下一个主题描述了 Razor 视图如何增强 HTML 标签语法。
使用 Razor 标签助手
在 ASP.NET Core MVC 中,标签助手是增强 HTML 标签功能性的强大工具。更具体地说,标签助手要么通过新的标签属性增强现有的 HTML 标签,要么定义全新的标签。
当 Razor 视图被编译时,任何标签都会与现有的标签助手进行匹配。当找到匹配项时,源标签会被标签助手创建的 HTML 所替换。可以为同一标签定义多个标签助手。它们会按照可以配置的优先级属性关联的顺序执行。
对于同一标签定义的所有标签助手可以在处理每个标签实例时进行协作。这是因为它们被传递了一个共享的数据结构,每个标签助手都可以在其中应用贡献。通常,最后调用的标签助手会处理这个共享数据结构以生成输出 HTML。
标签助手是继承自 TagHelper
类的类。本主题不讨论如何创建新的标签助手,但它介绍了随 ASP.NET Core MVC 一起提供的预定义主要标签助手。有关如何定义标签助手的完整指南可在官方文档中找到,该文档在 进一步阅读 部分中引用。
要使用标签助手,你必须使用如下声明声明包含标签助手的 .dll
文件:
@addTagHelper *, Dll.Complete.Name
如果你只想使用 .dll
文件中定义的一个标签助手,你必须将 *
替换为标签名称。
前面的声明可以放置在每个使用库中定义的标签辅助器的视图中,或者最终放置在Views
文件夹根目录下的_ViewImports.cshtml
文件中。默认情况下,_ViewImports.cshtml
会添加所有预定义的 ASP.NET Core MVC 标签辅助器,如下所示:
@addTagHelper *, Microsoft.AspNetCore.Mvc.TagHelpers
锚点标签通过自动计算 URL 和调用带有给定参数的特定操作方法来增强,如下所示:
<a asp-controller="{controller name}"
asp-action="{action method name}"
asp-route-{action method parameter1}="value1"
...
asp-route-{action method parametern}="valuen">
put anchor text here
</a>
下面是它使用的一个示例:
<a asp-controller="Home" asp-action="Index">
Back Home
</a>
创建的 HTML 如下所示:
<a href="Home/Index">
Back Home
</a>
可能看起来使用标签辅助器几乎没有优势。然而,这并不正确!优势在于,每当路由规则发生变化时,标签辅助器会自动更新它生成的href
以符合新的路由规则。
类似的语法被添加到form
标签中:
<form asp-controller="{controller name}"
asp-action="{action method name}"
asp-route-{action method parameter1}="value1"
...
asp-route-{action method parametern}="valuen"
...
>
...
script
标签通过添加允许我们在下载失败时回退到不同源的属性来增强。典型用法是从某些云服务下载脚本以优化浏览器缓存,并在失败时回退到脚本的本地副本。以下代码使用回退技术下载bootstrap
JavaScript 文件:
<script src="https://stackpath.bootstrapcdn.com/
bootstrap/4.3.1/js/bootstrap.bundle.min.js"
asp-fallback-src="img/bootstrap.bundle.min.js"
asp-fallback-test="window.jQuery && window.jQuery.fn && window.jQuery.fn.modal"
crossorigin="anonymous"
integrity="sha384-xrRywqdh3PHs8keKZN+8zzc5TX0GRTLCcmivcbNJWm2rs5C8PRhcEn3czEjhAO9o">
</script>
asp-fallback-test
包含一个 JavaScript 测试,用于验证下载是否成功。在上面的示例中,测试验证是否已创建 JavaScript 对象。
所有接受src
属性的 HTML 标签,即img
和script
标签,都可以添加一个设置为true
的asp-append-version
属性集。将asp-append-version
属性设置为true
不会改变img
和script
标签的语法;它只是在src
查询字符串中添加一个哈希值,以防止每次图像或script
文件更改时都进行缓存。以下是一个示例:
<img src="img/myImage.png" asp-append-version="true">
它被渲染为:
<img src="img/myImage.png?v=kM_dqr9NVtnMdsM2MUgdskVVFD">
传递给v
查询参数的哈希值是从图像文件的内容计算出来的,因此每当图像发生变化时,它都会改变,从而防止浏览器渲染旧的缓存图像副本。
~/
符号不是img
标签辅助器的特定功能,而是一个 Razor 原生功能,您可以在 Razor 文件的任何标签中的所有链接中使用。它代表应用程序根目录。它不等于代表域名根目录的 HTML /
符号,因为 ASP.NET Core 应用程序也可以放置在域名的子文件夹中。因此,~/
仅在应用程序放置在域名根目录时才翻译为/
;否则,它翻译为/{应用程序子文件夹名称}/
。
可以使用environment
标签来选择不同环境(开发、测试和生成)的 HTML。它的典型用法是在开发期间选择 JavaScript 文件的调试版本,如下例所示:
<environment include="Development">
@*development version of JavaScript files*@
</environment>
<environment exclude="Development">
@*development version of JavaScript files *@
</environment>
此外,还有一个cache
标签,它将内容缓存在内存中以优化渲染速度:
<cache>
@* heavy to compute content to cache *@
</cache>
默认情况下,内容缓存为 20 分钟,但必须在缓存过期时定义某些属性,例如expires-on="{datetime}"
、expires-after="{timespan}"
和expires-sliding="{timespan}"
。在这里,expires-sliding
和expires-after
之间的区别在于,在第二个属性中,每次请求内容时,过期时间计数都会重置。vary-by
属性会导致为传递给vary-by
的不同值创建不同的缓存条目。还有如vary-by-header
之类的属性,它为vary-by-cookie
属性中指定的请求头中假设的不同值创建不同的条目,等等。
所有input
标签——即textarea
、input
和select
——都有一个asp-for
属性,它接受以视图的 ViewModel 为根的属性路径作为其值。例如,如果视图有一个Person
ViewModel,我们可能会有以下内容:
<input type="text" asp-for"Address.Town"/>
上述代码首先将嵌套属性Town
的值分配给input
标签的value
属性。一般来说,如果值不是字符串,它将使用当前请求文化将其转换为字符串。
然而,它也将输入字段的名称设置为Address.Town
,并将输入字段的id
设置为Address_Town
。这是因为不允许在标签ids
中使用点。
可以通过在ViewData.TemplateInfo.HtmlFieldPrefix
中指定前缀来向这些标准名称添加前缀。例如,如果前面的属性设置为MyPerson
,则名称变为MyPerson.Address.Town
。
如果表单提交给一个参数包含与Person
类相同的动作方法,分配给input
字段的Address.Town
名称将导致该参数的Town
属性被input
字段填充。一般来说,input
字段中包含的字符串将根据当前请求文化转换为与之匹配的属性类型。总结来说,input
字段的名称是以这种方式创建的,以便在 HTML 页面提交时,可以在动作方法中恢复完整的Person
模型。
同样的asp-for
属性可以在label
标签中使用,以使标签引用具有相同asp-for
值的输入字段。
以下是一个input/label
对的示例:
<label asp-for="Address.Town"></label
<input type="text" asp-for="Address.Town"/>
当没有文本插入到标签中时,标签中显示的文本将来自装饰属性的Display
属性(如果有;否则,使用属性的名称)。
如果span
或div
包含一个asp-validation-for="Address.Town"
错误属性,那么关于Address.Town
输入的验证消息将自动插入到该标签内部。验证框架将在理解 ASP.NET Core MVC 与设计原则之间的联系部分进行描述。
还可以通过添加一个属性来自动创建验证错误摘要,该属性跟在 div
或 span
之后:
asp-validation-summary="ValidationSummary.{All, ModelOnly}"
如果属性设置为 ValidationSummary.ModelOnly
,则仅在摘要中显示与特定 input
字段不相关的消息,而如果值为 ValidationSummary.All
,则将显示所有错误消息。
可以将 asp-items
属性应用于任何 select
标签以自动生成所有 select
选项。它必须传递一个 IEnumerable<SelectListItem>
,其中每个 SelectListItem
包含一个选项的文本和值。SelectListItem
还包含一个可选的 Group
属性,您可以使用它将显示在 select
中的选项组织成组。
下面是如何使用 asp-items
的一个示例:
...
@{
var choices = new List<SelectListItem>
{
new SelectListItem {Value="value1", Text="text1", Group="group1"},
new SelectListItem {Value="value2", Text="text2", Group="group1"}
...
new SelectListItem {..., Group="group2"}
...
}
}
<select asp-for="MyProperty" asp-items="choices">
<option value="">Select a value</option>
</select>
...
当添加时,选项标签会放在由 asp-items
生成的标签之前。
下一个主题将展示如何重用视图代码。
重用视图代码
ASP.NET Core MVC 包含几种重用视图代码的技术,其中最重要的是布局页面。
在每个网络应用程序中,几个页面共享相同的结构,例如相同的主菜单或相同的左侧或右侧栏。在 ASP.NET Core 中,这种常见结构被提取到称为布局页面/视图的视图中。
图 17.7:使用布局页面
每个视图可以使用以下代码指定用作其布局页面的视图:
@{
Layout = "_MyLayout";
}
如果未指定布局页面,则使用位于 Views
文件夹中的 _ViewStart.cshtml
文件中定义的默认布局页面。_ViewStart.cshtml
的默认内容如下:
@{
Layout = "_Layout";
}
因此,由 Visual Studio 生成的文件中的默认布局页面是 _Layout.cshtml
,它包含在 Shared
文件夹中。
布局页面包含与其所有子页面共享的 HTML、HTML 页面页眉以及指向 CSS 和 JavaScript 文件的页面引用。每个视图生成的 HTML 都放置在其布局页面内,其中布局页面调用 @RenderBody()
方法,如下例所示:
...
<main role="main" class="pb-3">
...
@RenderBody()
...
</main>
...
每个 View
的 ViewData
都会被复制到其布局页面的 ViewData
中,因此 ViewData
可以用来向视图布局页面传递信息。通常,它用于传递视图标题到布局页面,然后布局页面使用它来组成页面的标题头部,如下所示:
@*In the view *@
@{
ViewData["Title"] = "Home Page";
}
@*In the layout view*@
<head>
<meta charset="utf-8" />
...
<title>@ViewData["Title"] - My web application</title>
...
虽然每个视图生成的主要内容都放置在其布局页面的一个单独区域中,但每个布局页面也可以定义几个放置在不同区域的部分,其中每个视图可以放置进一步的次要内容。
例如,假设一个布局页面定义了一个 Scripts
部分,如下所示:
...
<script src="img/site.js" asp-append-version="true"></script>
@RenderSection("Scripts", required: false)
...
然后,视图可以使用之前定义的部分来传递一些视图特定的 JavaScript 引用,如下所示:
.....
@section scripts{
<script src="img/pageSpecificJavaScript.min.js"></script>
}
.....
如果预期操作方法返回 HTML 以响应对 AJAX 的调用,则它必须生成一个 HTML 片段而不是整个 HTML 页面。因此,在这种情况下,不得使用布局页面。这是通过在控制器操作方法中调用 PartialView
方法而不是 View
方法来实现的。PartialView
和 View
具有完全相同的重载和参数。
另一种重用视图代码的方法是将几个视图共有的视图片段提取到另一个由所有先前视图调用的视图中。一个视图可以使用 partial
标签调用另一个视图,如下所示:
<partial name="_viewname" for="ModelProperty.NestedProperty"/>
上述代码调用 _viewname
并将其传递给包含在 Model.ModelProperty.NestedProperty
中的对象作为其 ViewModel
。当一个视图通过 partial
标签被调用时,不使用布局页面,因为期望被调用视图返回一个 HTML 片段。
被调用视图的 ViewData.TemplateInfo.HtmlFieldPrefix
属性被设置为 ModelProperty.NestedProperty
字符串。这样,在 _viewname.cshtml
中渲染的可能输入字段将具有与它们直接由调用视图渲染时相同的名称。
除了通过调用视图(ViewModel)的属性来指定 _viewname
的 ViewModel 之外,您还可以通过将 for
替换为 model
,直接传递包含在变量中或由 C# 表达式返回的对象,从而直接传递一个对象。如下例所示:
<partial name="_viewname" model="new MyModel{...})" />
在这种情况下,被调用视图的 ViewData.TemplateInfo.HtmlFieldPrefix
属性保持其默认值,即空字符串。
视图还可以调用比另一个视图更复杂的东西,即另一个控制器方法,该方法反过来渲染一个视图。设计为被视图调用的控制器称为 视图组件。以下代码是组件调用的示例:
<vc:[view-component-name] par1="par1 value" par2="parameter2 value"> </vc:[view-component-name]>
参数名称必须与视图组件方法中使用的名称匹配。然而,组件的名称和参数名称都必须转换为 kebab case;也就是说,如果原始名称中的所有字符都是大写,则必须将所有字符转换为小写,尽管名称的第一个字母必须由一个 -
前缀。例如,MyParam
必须转换为 my-param
。
实际上,视图组件可以是继承自 ViewComponent
类的类,是带有 [ViewComponent]
特性的类,或者其名称以 ViewComponent
后缀结尾的类。当组件被调用时,框架会寻找 Invoke
方法或 InvokeAsync
方法,并将组件调用中定义的所有参数传递给它。如果方法定义为 async
,则必须使用 InvokeAsync
;否则,我们必须使用 Invoke
。
以下代码是一个视图组件定义的示例:
public class MyTestViewComponent : ViewComponent
{
public async Task<IViewComponentResult> InvokeAsync(
int par1, bool par2)
{
var model= ....
return View("ViewName", model);
}
}
必须使用如下所示的调用来调用先前定义的组件:
<vc:my-test par1="10" par2="true"></vc:y-test>
如果组件是通过名为 MyController
的控制器视图调用的,则 ViewName
在以下路径中搜索:
-
/Views/MyController/Components/MyTest/ViewName
-
/Views/Shared/Components/MyTest/ViewName
理解 ASP.NET Core MVC 与设计原则之间的联系
整个 ASP.NET Core 框架都是建立在我们在 第十一章、将微服务架构应用于企业应用程序、第十三章、在 C# 中与数据交互 – Entity Framework Core、第六章、设计模式和 .NET 8 实现、第七章、理解软件解决方案中的不同领域 和 第五章、在 C# 12 中实现代码重用性 中分析的设计原则和模式之上的。
此外,所有框架功能都是通过依赖注入(DI)提供的,这样每个功能都可以被定制的对应物所替代,而不会影响代码的其他部分。此外,这些提供者不是单独添加到 DI 引擎中;相反,它们被分组到选项对象的集合属性中(参见 加载配置数据和使用选项框架 子节),以提高可维护性,并符合关注点分离原则,这是单一责任原则的泛化。实际上,提供者添加到其集合中的顺序很重要,因为它们将以与集合中相同的顺序进行处理。此外,提供者的效果还取决于属于同一集合的其他提供者,因此有时仅仅替换一个提供者或添加一个新的提供者是不够的,还需要移除/替换其他提供者以消除其副作用。
分组在集合中的提供者示例包括所有模型绑定器、验证提供者和数据注释提供者。
此外,配置数据不是从配置文件创建的唯一字典中可用,而是通过我们在本章第一部分中描述的选项框架组织成选项对象。这也是 SOLID 接口分离原则的应用。
然而,ASP.NET Core 还应用了其他特定于一般关注点分离原则的通用模式,该原则(如前所述)是单一责任原则的泛化。它们如下:
-
中间件模块架构(ASP.NET Core 管道)
-
从应用程序代码中提取验证和全球化
-
MVC 模式本身
我们将在接下来的各个子节中分析这些内容。
ASP.NET Core 管道的优势
ASP.NET Core 管道架构有两个重要优势:
-
根据单一责任原则,将初始请求上执行的所有不同操作分解到不同的模块中。
-
执行这些不同操作的模块不需要相互调用,因为每个模块都由 ASP.NET Core 框架一次性调用。这样,每个模块的代码就不需要执行与分配给其他模块的责任相关的任何操作。
这确保了功能之间的最大独立性,并简化了代码。例如,一旦授权和认证模块激活,其他模块就无需再担心授权问题。每个控制器代码块可以专注于特定应用的业务逻辑。
服务器端和客户端验证
验证逻辑已完全从应用程序代码中提取出来,并限制在验证属性的定义中。开发者只需指定应用于每个模型属性的验证规则,通过装饰属性以适当的验证属性即可。
当动作方法参数实例化时,验证规则会自动进行检查。随后,错误和模型中的路径(它们发生的位置)将被记录在包含在 ModelState
控制器属性中的字典中。开发者有责任通过检查 ModelState.IsValid
来验证是否存在错误,在这种情况下,开发者必须返回相同的 ViewModel 到相同的视图,以便用户可以纠正任何错误。
错误消息会自动在视图中显示,无需开发者进行任何操作。开发者只需执行以下操作:
-
在每个输入字段旁边添加带有
asp-validation-for
属性的span
或div
,该属性将被自动填充可能的错误。 -
添加带有
asp-validation-summary
属性的div
,该属性将被自动填充验证错误摘要。有关更多详细信息,请参阅 使用 Razor 标签辅助器 部分。
只需通过调用带有 partial
标签的 _ValidationScriptsPartial.cshtml
视图来添加一些 JavaScript 引用,即可在客户端启用相同的验证规则,以便在表单提交到服务器之前向用户显示错误。预定义的验证属性包含在 System.ComponentModel.DataAnnotations
和 Microsoft.AspNetCore.Mvc
命名空间中,包括以下属性:
-
Required
属性要求用户为其装饰的属性指定一个值。对于所有非可空属性,如所有浮点数、整数和小数,会自动应用隐式的Required
属性,因为它们不能有null
值。 -
Range
属性限制了数值在特定范围内。 -
它们还包括限制字符串长度的属性。
可以直接将自定义错误消息插入到属性中,或者属性可以引用包含它们的资源类型属性。
开发者可以通过提供 C#和 JavaScript 中的验证代码来定义自己的自定义属性,以实现客户端验证。自定义验证属性的定义在本篇文章中讨论:blogs.msdn.microsoft.com/mvpawardprogram/2017/01/03/asp-net-core-mvc/
。
可以用其他验证提供者替换基于属性的验证,例如FluentValidation库,该库使用流畅接口为每个类型定义验证规则。只需更改 MVC 选项对象中包含的提供者即可。这可以通过传递给builder.Services.AddControllersWithViews
方法的操作来配置。
可以按如下方式配置 MVC 选项:
builder.Services.AddControllersWithViews(o => {
...
// code that modifies o properties
});
验证框架会自动检查数字和日期输入是否根据所选文化正确格式化。
ASP.NET Core 全球化
在多文化应用程序中,页面必须根据每个用户的语言和文化偏好提供服务。通常,多文化应用程序可以在几种语言中提供其内容,并且可以在更多语言中处理日期和数字格式。实际上,尽管所有支持的语言内容都必须手动生成,但.NET 具有在所有文化中格式化和解析日期和数字的本地能力。
例如,一个 Web 应用程序可能不支持所有基于英语的文化(en)的唯一内容,但它可能支持所有已知的基于英语的文化在数字和日期格式方面(en-US、en-GB、en-CA 等)。
.NET 线程中数字和日期使用的是Thread.CurrentThread.CurrentCulture
属性中指定的文化。因此,通过将此属性设置为new CultureInfo("en-CA")
,数字和日期将根据加拿大格式进行格式化/解析。相反,Thread.CurrentThread.CurrentUICulture
决定资源文件的文化;也就是说,它选择每个资源文件或视图的文化特定版本。因此,多文化应用程序需要设置与请求线程相关的两个文化,并将多语言内容组织到语言相关的资源文件和/或视图中。
根据关注点分离原则,用于根据用户偏好设置请求文化的整个逻辑被提取到一个特定的 ASP.NET Core 管道模块中。要配置此模块,作为第一步,我们设置支持的日期/数字文化,如下例所示:
var supportedCultures = new[]
{
new CultureInfo("en-AU"),
new CultureInfo("en-GB"),
new CultureInfo("en"),
new CultureInfo("es-MX"),
new CultureInfo("es"),
new CultureInfo("fr-CA"),
new CultureInfo("fr"),
new CultureInfo("it-CH"),
new CultureInfo("it")
};
然后,我们设置内容支持的语言。通常,会选择一种不特定于任何国家的语言版本,以保持翻译数量尽可能少,如下所示:
var supportedUICultures = new[]
{
new CultureInfo("en"),
new CultureInfo("es"),
new CultureInfo("fr"),
new CultureInfo("it")
};
然后,我们将文化中间件添加到管道中,如下所示:
app.UseRequestLocalization(new RequestLocalizationOptions
{
DefaultRequestCulture = new RequestCulture("en", "en"),
// Formatting numbers, dates, etc.
SupportedCultures = supportedCultures,
// UI strings that we have localized.
SupportedUICultures = supportedUICultures,
FallBackToParentCultures = true,
FallBackToParentUICultures = true
});
如果用户请求的文化在 supportedCultures
或 supportedUICultures
列表中明确找到,则使用它而不做修改。否则,由于 FallBackToParentCultures
和 FallBackToParentUICultures
为 true
,将尝试父文化;例如,如果所需的 fr-FR
文化没有在列出的那些中找到,那么框架将搜索其通用版本,fr
。如果这次尝试也失败,框架将使用 DefaultRequestCulture
中指定的文化。
默认情况下,culture
中间件搜索为当前用户选择的 culture,并尝试以下顺序中的三个提供者:
-
中间件查找
culture
和ui-culture
查询字符串参数。 -
如果前面的步骤失败,中间件会查找名为
.AspNetCore.Culture
的 cookie,其值预期如下示例所示:c=en-US|uic=en
。 -
如果前两个步骤都失败,中间件会查找浏览器发送的
Accept-Language
请求头,这可以在浏览器设置中更改,并且最初设置为操作系统文化。
使用前面的策略,当用户第一次请求应用程序页面时,浏览器文化被采用(在 步骤 3 中列出的提供者)。然后,如果用户点击带有正确查询字符串参数的语言更改链接,提供者 1 会选择一个新的文化。通常,一旦点击了语言链接,服务器也会生成一个语言 cookie,通过提供者 2 来记住用户的选择。
提供内容本地化的最简单方法是为每种语言提供不同的视图。因此,如果我们想为不同的语言本地化 Home.cshtml
视图,我们必须提供名为 Home.en.cshtml
、Home.es.cshtml
等的视图。如果没有找到特定于 ui-culture
线程的视图,则选择未本地化的 Home.cshtml
视图版本。
必须通过调用 AddViewLocalization
方法来启用视图本地化,如下所示:
builder.Services.AddControllersWithViews()
.AddViewLocalization(LanguageViewLocationExpanderFormat.Suffix)
另一个选项是将简单的字符串或 HTML 片段存储在针对所有支持的语言特定的资源文件中。必须在配置服务部分调用 AddLocalization
方法来启用资源文件的用法,如下所示:
builder.Services.AddLocalization(options =>
options.ResourcesPath = "Resources");
ResourcesPath
是所有资源文件将被放置的根文件夹。如果没有指定,则假定为一个空字符串,资源文件将被放置在 Web 应用程序根目录下。特定视图(例如,/Views/Home/Index.cshtml
视图)的资源文件必须具有如下路径:
<ResourcesPath >/Views/Home/Index.<culture name>.resx
因此,如果 ResourcesPath
为空,资源必须具有 /Views/Home/Index.<culture name>.resx
路径;也就是说,它们必须放置在视图相同的文件夹中。
一旦添加了与视图相关联的所有资源文件的关键值对,就可以将本地化的 HTML 片段添加到视图中,如下所示:
-
将
IViewLocalizer
注入视图,使用@inject IViewLocalizer Localizer
-
在需要的地方,将
View
中的文本替换为对Localizer
字典的访问,例如Localizer
[“myKey
”],其中 “myKey
” 是在资源文件中使用的键。
以下代码展示了 IViewLocalizer
字典的一个示例:
@{
ViewData["Title"] = Localizer["HomePageTitle"];
}
<h2>@ViewData["MyTitle"]</h2>
如果本地化失败是因为在资源文件中找不到键,则返回键本身。如果启用了数据注解本地化,则用于数据注解的字符串(如验证属性)用作资源文件中的键,如下所示:
builder.Services.AddControllersWithViews()
.AddViewLocalization(LanguageViewLocationExpanderFormat.Suffix)
.AddDataAnnotationsLocalization();
应用于具有全名 MyWebApplication.ViewModels.Account.RegisterViewModel
的类的数据注解资源文件必须具有以下路径:
{ResourcesPath}/ViewModels/Account/RegisterViewModel.{culture name}.resx
值得指出的是,与 .dll
应用程序名称对应的命名空间的第一部分被替换为 ResourcesPath
。如果 ResourcesPath
为空,并且你使用由 Visual Studio 创建的默认命名空间,那么资源文件必须放置在与它们关联的类所在的同一文件夹中。
通过将每个资源文件组与一个类型关联,例如 MyType
,然后在控制器或其他可以注入依赖的地方注入 IHtmlLocalizer<MyType>
用于 HTML 片段或 IStringLocalizer<MyType>
用于需要 HTML 编码的字符串,可以本地化字符串和 HTML 片段。
它们的用法与 IViewLocalizer
的用法相同。与数据注解的情况一样,计算与 MyType
关联的资源文件路径。如果你希望为整个应用程序使用一组独特的资源文件,一个常见的做法是将 Startup
类用作参考类型(IStringLocalizer<Startup>
和 IHtmlLocalizer<Startup>
)。另一个常见的做法是为各种资源文件组创建各种空类,用作参考类型。
现在我们已经学会了如何在我们的 ASP.NET Core 项目中管理全球化,在下一个子节中,我们将描述 ASP.NET Core MVC 用来强制实现 关注点分离 的更重要的模式:MVC 模式本身。
MVC 模式
MVC 是一种用于实现 Web 应用程序表示层的模式。基本思想是在表示层的逻辑和其图形之间应用 关注点分离。逻辑由控制器处理,而图形被分解到视图中。控制器和视图通过模型进行通信,通常称为 ViewModel,以区分业务和数据层的模型。
值得指出的是,MVC 模式的原始定义直接提出了使用领域模型而不是 ViewModel 的建议,但随后,大多数 MVC Web 框架开始使用 ViewModel 的概念,因为指定在视图中渲染的信息只需要对原始领域模型进行投影(可能以不同的方式组织一些模型数据)以及通常还需要额外的数据,例如,例如分页器每页的项目数,以及所选类型输入所需的项目。
然而,表示层的逻辑是什么?在第一章,理解软件架构的重要性中,我们了解到软件需求可以通过用例来记录,这些用例描述了用户与系统之间的交互。
大致来说,表示层的逻辑包括用例的管理;因此,大致上,用例映射到控制器,每个用例的每个操作映射到这些控制器的动作方法。因此,控制器负责管理与用户的交互协议,并在每个操作中涉及的业务处理方面依赖于业务层。
每个动作方法都从用户那里接收数据,执行一些业务处理,并根据处理结果决定向用户展示什么,并将其编码在 ViewModel 中。视图接收 ViewModel,描述了要向用户展示的内容,并决定使用哪种图形,即使用哪种 HTML。
将逻辑和用户界面分离成两个不同的组件有哪些优点?主要优点如下:
-
图形的变化不会影响代码的其他部分,因此您可以尝试各种用户界面元素以优化与用户的交互,而不会危及代码其他部分的可靠性。
-
应用程序可以通过实例化控制器并传递参数来测试,无需使用在浏览器页面上操作的测试工具。这样,测试更容易实现。此外,它们不依赖于图形的实现方式,因此不需要在图形更改时更新。
-
将工作分配给实现控制器的开发人员和实现视图的图形设计师之间更容易。通常,图形设计师在 Razor 方面有困难,所以他们可能只提供一个示例 HTML 页面,开发人员将其转换为操作实际数据的 Razor 视图。
关于如何将上述讨论的一般原则付诸实践,请参阅第二十一章,案例研究中的前端微服务部分,但最好先阅读第十八章,使用 ASP.NET Core 实现前端微服务。在那里,我们将探讨如何使用 ASP.NET Core MVC 创建前端微服务。
摘要
在本章中,我们详细分析了 ASP.NET Core 管道以及构成 ASP.NET Core MVC 应用程序的各种模块,例如身份验证/授权、选项框架和路由。然后,我们描述了控制器和视图如何将请求映射到响应 HTML。我们还分析了最新版本中引入的所有改进。
最后,我们分析了 ASP.NET Core MVC 框架中实现的所有设计模式,特别是关注了关注点分离原则的重要性以及 ASP.NET Core MVC 如何在 ASP.NET Core 管道中实现它,以及在它的验证和全球化模块中如何实现。我们更详细地关注了在表示层逻辑和图形之间的关注点分离的重要性,以及 MVC 模式如何确保这一点。
你可以在下一章中找到一个完整的 ASP.NET Core MVC 使用示例,该章涉及前端微服务,并描述了一个完整的微服务,其表示层使用 ASP.NET Core MVC。
问题
-
你能列出 Visual Studio 在 ASP.NET Core 项目中构建的所有中间件模块吗?
-
ASP.NET Core 管道模块是否需要继承基类或实现某个接口?
-
一个标签是否必须只定义一个标签助手,否则会抛出异常?
-
你还记得如何在控制器中测试是否发生了验证错误吗?
-
布局视图中包含主视图输出的指令是什么?
-
在布局视图中,如何调用主视图的次要部分?
-
控制器是如何调用视图的?
-
默认情况下,全球化模块中安装了多少个提供程序?
-
ViewModels 是否是控制器与其调用的视图通信的唯一方式?
进一步阅读
-
更多关于 ASP.NET Core 和 ASP.NET Core MVC 框架的详细信息可以在其官方文档
docs.microsoft.com/en-US/aspnet/core/
中找到。 -
关于 Razor 语法的更多细节可以在
docs.microsoft.com/en-US/aspnet/core/razor-pages/?tabs=visual-studio
找到。 -
如何对集合和字典进行模型绑定,在 Phil Haack 的这篇优秀文章中有详细解释:
haacked.com/archive/2008/10/23/model-binding-to-a-list.aspx
. -
关于本章未讨论的自定义标签助手的创建文档可以在
docs.microsoft.com/en-US/aspnet/core/mvc/views/tag-helpers/authoring
找到。 -
关于创建自定义控制器属性的文档可以在
docs.microsoft.com/en-US/aspnet/core/mvc/controllers/filters
找到。 -
本文讨论了自定义验证属性的定义:
blogs.msdn.microsoft.com/mvpawardprogram/2017/01/03/asp-net-core-mvc/
在 Discord 上了解更多信息
要加入这本书的 Discord 社区——在那里您可以分享反馈、向作者提问以及了解新版本——请扫描下面的二维码:
packt.link/SoftwareArchitectureCSharp12Dotnet8
第十八章:使用 ASP.NET Core 实现前端微服务
第十四章,使用 .NET 实现微服务描述了在 .NET 中实现微服务的一般技术,但主要关注工作微服务,即执行后台作业而不与应用程序外部的任何东西通信的微服务。
与应用程序外部世界通信的微服务带来了其他问题,需要进一步的技术。
更具体地说,与人类用户通信的微服务必须实现表示层,而公开 API 的微服务必须遵守既定的标准,并且最好有文档。此外,针对单页应用(SPAs)的 Web API 必须符合浏览器策略;也就是说,它们必须位于与 SPA 下载相同的域上,或者它们必须配置 CORS 策略。我们将在第十九章,客户端框架:Blazor中看到如何解决 CORS 和浏览器策略引起的问题。
值得一提的还有任何表示层带来的所有挑战,即确保与用户快速有效的交互,以及在不陷入面条代码的情况下,使用可维护的代码管理与用户的交互状态。在第二章,非功能性需求中讨论了通用可用性问题及其解决方案。我们将在本章和第十九章,客户端框架:Blazor中讨论更多与技术相关的可用性和状态管理问题。
最后,所有前端微服务都必须实施坚实的安全策略,以防御黑客攻击。一些技术对前端和 Web API 都适用,并且由所有主要 Web 服务器自动处理,例如针对路径穿越攻击和拒绝服务的对策。而另一些技术则特定于 HTML 页面,例如伪造。在第二十一章,案例研究中讨论了 ASP.NET Core MVC 对伪造的防御。
在第十五章,使用 .NET 应用服务导向架构中描述了实现公开自文档化 Web API 的技术,而在第十七章,展示 ASP.NET Core中涵盖了实现基于服务器的表示层的技术,而在第十九章,客户端框架:Blazor中将介绍实现基于客户端的表示层的技术。此外,在第十一章,将微服务架构应用于您的企业应用、第七章,理解软件解决方案中的不同领域和第十四章,使用 .NET 实现微服务中涵盖了实现微服务的通用技术。
因此,在本章中,在简短介绍前端微服务的特定概念和技术部分之后,我们将向您展示如何将这些概念和技术结合在一起,在前端微服务的实际实现中。更具体地说,我们将讨论各种实现选项以及如何构建前端微服务的整个层结构。一个完整的示例,展示了前端微服务的所有层在实际中是如何协同工作的,可以在第二十一章案例研究的前端微服务部分中找到。
更具体地说,本章涵盖了以下主题:
-
前端和微前端
-
定义域层接口
-
定义域层实现
-
定义应用层
-
定义控制器
我们将使用洋葱架构和第七章中描述的理解软件解决方案中的不同域的模式。
技术要求
本章需要免费 Visual Studio 2022 Community 版或更高版本,并安装所有数据库工具。
为了阐明本章中的概念,所需的代码示例将来自基于WWTravelClub
用例的实际示例应用程序。完整的示例应用程序在第二十一章案例研究的前端微服务部分中详细描述。其代码可在github.com/PacktPublishing/Software-Architecture-with-C-Sharp-12-and-.NET-8-4E
找到。
前端和微前端
前端微服务的主要特点是它们需要一个强大的 Web 服务器,能够优化所有请求/响应处理并确保所需的安全级别。此外,高流量应用程序还需要一个负载均衡器。
强大 Web 服务器(如 IIS、Apache 和 NGINX)提供的服务示例:
-
限制对某些文件类型和目录的访问,以防止访问私有文件和防止远程文件执行;即通过 Web 请求执行服务器命令/脚本。
-
阻止可能导致访问不受欢迎的文件或目录(路径遍历攻击)的危险请求。
-
阻止超过可自定义长度的请求,因为它们可能造成服务拒绝。
-
记录和 IP 地址阻止以发现和对比黑客攻击。
-
将请求重定向到与每个 URL 关联的应用程序。
-
队列请求并将它们分配给可用的线程。这种能力对于性能优化是基本的,因为与可用的处理器核心相比执行过多的请求可能会导致不可接受的性能。
-
确保运行在相同进程但不同线程上的应用程序之间的隔离,以及更多。
如果前端服务托管在 Kubernetes 集群上,可以通过Ingress提供适当的 Web 服务器和负载均衡。
否则,Azure App Service(请参阅进一步阅读部分)可能是一个不错的选择,因为它提供了可扩展的负载均衡级别、出色的安全性、监控服务等等。
前端微服务不需要直接与应用程序外部进行接口交互。实际上,在微前端架构中,没有唯一的客户端,但前端的作用被分散到几个微服务中。在这些架构中,通常,将流量引导到正确的客户端和/或合并多个响应为一个唯一响应的作用由一个负载均衡的接口前端承担,它承担着确保正确安全级别的负担。
使用微前端的原因与其他微服务相同。我们已在第十一章,将微服务架构应用于您的企业应用程序中详细讨论了它们,但在此重复一些更重要的事项:
-
通过仅扩展需要更多资源的微服务来优化硬件资源的利用率
-
每个微服务都有独立的软件生命周期,因此每个微服务可以独立于其他微服务发展,以满足用户需求,并且每个微服务开发团队可以独立于其他团队工作。
微前端架构在 HTML 网站(如 ASP.NET Core MVC 网站)和 Web API 方面使用相当不同的技术。实际上,“微前端”一词仅用于 HTML 网站/单页应用,而面向外部的 Web API 被称为公共 Web API。我们将在两个专门的子节中描述公共 Web API 和 HTML 微前端及其使用的技术,从公共 Web API 开始。
公共 Web API
在网络 API 的情况下,所有微服务都通过一个独特的负载均衡软件组件,称为API 网关,在客户端和各个 API 服务之间进行访问。API 网关的基本作用是从一个唯一的域名使整个 API 可访问,以避免浏览器独特的域名策略问题,并简化所有 API 服务的使用。
图 18.1:API 网关
然而,API 网关提供了集中其他对所有 API 服务都通用的功能的机会,例如:
-
身份验证,即验证和解码每个请求附带的身份验证令牌(请勿将身份验证与登录混淆)。
-
缓存,即根据可配置的缓存策略进行响应缓存。
-
翻译,即调整客户端看到的界面以适应各种 API 方法的实际签名。这样,每个 API 可以更改其界面而不会影响现有客户端。
-
版本控制,即将每个请求定向到每个 API 服务的兼容版本。
-
文档,即提供独特的文档端点。
API 网关一直在不断发展,吸收并提供越来越多的功能,从而产生了所谓的 API 管理系统,现在它们自动化并处理了处理公共 Web API 的大部分负担。
Azure,像所有云服务一样,提供良好的 API 管理服务。你可以在这里找到更多关于它的信息:azure.microsoft.com/en-us/services/api-management/#overview
。
值得一提的是 Ocelot (docs.microsoft.com/en-us/dotnet/architecture/microservices/multi-container-microservice-net-applications/implement-api-gateways-ith-ocelot
),这是一个用于轻松创建自定义 API 网关的库。你可以用它来填写配置文件,或者作为完全自定义 API 网关的基础。
现在,我们准备讨论 HTML 微前端,它也带来了将多个 HTML 片段组合成独特 HTML 页面的挑战。
HTML 微前端
几个 HTML 微前端可以通过为每个提供不同的一组网页来在同一应用程序中协作。在这种情况下,协调它们只需要指向其他微前端的链接和一个共同的登录方式,这样用户在移动到不同的微前端时就不需要每次都登录。
然而,非常常见的情况是,几个微前端通过提供各种页面区域来共同构建同一个页面。在这种情况下,一个软件组件必须承担将各个部分组装成独特页面的负担。
将多个 HTML 片段组合成单个 HTML 页面的主要困难是提供给用户一致的用户体验,从而避免以用户难以理解的方式组装所有页面区域。另一个问题是避免每次新内容到达浏览器时连续的页面翻页和重组。在接下来的内容中,我们将讨论这些问题的解决方案。
在经典 Web 应用程序构建服务器端 HTML 的情况下,界面应用程序提供页面布局,然后调用各种微前端来填充不同的布局区域。
图 18.2:服务器端微前端
在这种情况下,由于整个 HTML 页面是在服务器端组装的,并且只有在准备就绪时才发送到浏览器,因此不存在浏览器页面翻页和重组的问题。然而,我们为此优势付出了整个页面组装过程中所需的内存消耗的代价。
要使用的布局以及需要调用哪些涉及到的微前端,都是通过根据规则处理请求 URL 来获得的,这些规则要么硬编码在代码中,要么更好,存储在一个或多个配置源中(在最简单的情况下,一个唯一的配置文件)。使用预定义的页面模式确保一致性和良好的用户体验,但我们在可维护性方面付出了代价,因为我们需要在提供页面区域的任何单个微前端发生任何非平凡变化时更新和测试接口应用程序。
在单页应用(SPA)的情况下,组装过程发生在客户端,即浏览器中:
-
核心应用程序提供初始 HTML 页面。
-
核心应用程序从每个微前端下载一个 JavaScript 文件。这些 JavaScript 文件中的每一个都是一个微 SPA,用于创建页面区域。
-
核心应用程序基于当前 URL 决定传递给每个微 SPA 的 URL,然后将每个微 SPA 生成的 HTML 放置在正确的位置。
图 18.3:客户端微前端
各种微 SPA 之间不会相互干扰,因为它们各自运行在独立的 JavaScript 范围内。因此,例如,我们可以混合使用不同和不兼容版本的 Angular 和/或 React 实现的微 SPA。
微前端也可以使用 WebAssembly 框架,如 Blazor(见第十九章,客户端框架:Blazor)来实现,这些框架运行 .NET 代码。然而,在这种情况下,各种微 SPA 并不在单独的环境中运行,因此它们必须基于兼容的 .NET 版本。
SPA 微前端与基于服务器的微前端具有相同的维护成本,并且由于页面是动态创建的,当新内容或数据到达浏览器时,存在浏览器页面翻页和重组问题。这个问题可以通过为浏览器页面的每个内容区域预分配固定大小的 HTML 标签来解决。例如,我们可能预分配一个 300 像素 X 300 像素的区域来显示天气预报和一些图片或动画,而实际内容正在加载。
在下一节中,我们将介绍基于 ASP.NET Core MVC 构建前端微服务的架构方案。
定义应用程序架构
应用程序将使用第七章“理解软件解决方案中的不同领域”和第十三章“在 C# 中与数据交互 - Entity Framework Core”中描述的 领域驱动设计(DDD)方法和相关模式来实现,因此对那些章节内容的良好理解是阅读本章的基本先决条件。
应用程序基于 DDD 方法组织,并使用 SOLID 原则来映射您的领域部分。也就是说,应用程序被组织成三个层次,每个层次都作为不同的项目实现:
-
领域层包含存储库的实现和描述数据库实体的类。它是一个 .NET 库项目。然而,由于它需要一些接口,如
IServiceCollection
,这些接口在Microsoft.NET.Sdk.web
中定义,并且由于DBContext
层必须从身份框架继承,以便也能处理应用程序的认证和授权数据库表,我们必须添加对 .NET SDK 的引用,同时也必须添加对 ASP.NET Core SDK 的引用。然而,实现自定义用户管理也是常见的。 -
此外,还有一个领域层抽象,它包含存储库规范;即描述存储库实现和 DDD 聚合的接口。在我们的实现中,我们决定通过隐藏根数据实体的禁止操作/属性来实施聚合,正如在 第十三章,与 C# 中的数据交互 部分的 如何数据层和领域层与其他层通信 节中讨论的那样。因此,例如,
Package
数据层类,它是一个聚合根,在领域层抽象中有一个相应的IPackage
接口,隐藏了Package
实体的所有属性设置器。领域层抽象还包含所有领域事件的定义,而将订阅这些事件的处理器定义在应用层。 -
最后,是应用层——即 ASP.NET Core MVC 应用程序(ASP.NET Core MVC 在 第十七章,展示 ASP.NET Core 中进行了讨论)——在这里我们定义 DDD 查询、命令、命令处理器和事件处理器。控制器填充查询对象并执行它们以获取可以传递给视图的 ViewModels。它们通过填充命令对象并执行相关的命令处理器来更新存储。反过来,命令处理器使用来自领域层的
IRepository
接口和IUnitOfWork
实例来管理和协调事务。
应用程序使用 命令查询责任分离(CQRS)模式;因此,它使用命令对象来修改存储,并使用查询对象来查询它。CQRS 在 第七章,理解软件解决方案中的不同领域 的 命令查询责任分离(CQRS)模式 子节中进行了描述。
查询的使用和实现都很简单:控制器填充它们的参数,然后调用它们的执行方法。反过来,查询对象有直接的 LINQ 实现,使用 Select
LINQ 方法直接将结果投影到控制器视图使用的 ViewModels 上。你也可以决定在用于存储更新操作的相同存储库类后面隐藏 LINQ 实现,但这样会将简单查询的定义和修改变成非常耗时的工作。
在任何情况下,将查询对象封装在接口之后可能是有益的,这样在测试控制器时可以替换它们的实现为模拟实现。
然而,涉及命令执行的物体和调用链更为复杂。这是因为它需要构建和修改聚合体,以及定义多个聚合体之间以及聚合体与其他应用程序之间的交互,这需要通过领域事件提供。
下面的图示是存储更新操作执行过程的草图。圆圈表示在各个层之间交换的数据,而矩形表示处理这些数据的程序。此外,虚线箭头连接接口及其实现类型:
图 18.4:命令执行的示意图
下面是动作通过 图 18.4 的步骤列表:
-
控制器的动作方法接收一个或多个 ViewModels 并执行验证。
-
一个或多个包含要应用更改的 ViewModels 被隐藏在领域层中定义的接口 (
IMyUpdate
) 之后。它们用于填充命令对象的属性。这些接口必须在领域层中定义,因为它们将被用作在那里定义的仓库聚合方法中的参数。 -
在控制器动作方法中通过 依赖注入 (DI) 获取与先前命令匹配的命令处理器。然后执行处理器。在其执行过程中,处理器与各种仓库接口方法和它们返回的聚合体进行交互。
-
在创建第 3 步中讨论的命令处理器时,ASP.NET Core DI 引擎会自动注入其构造函数中声明的所有参数。特别是,它会注入执行所有命令处理器事务所需的全部
IRepository
实现。命令处理器通过调用其构造函数中接收到的这些IRepository
实现的方法来构建聚合体并修改已构建的聚合体来完成其工作。聚合体要么代表已存在的实体,要么代表新创建的实体。处理器使用包含在每个IRepository
中的IUnitOfWork
接口以及数据层返回的并发异常来组织其操作为事务。值得注意的是,每个聚合体都有自己的IRepository
,并且更新每个聚合体的全部逻辑都定义在聚合体本身中,而不是其关联的IRepository
中,以保持代码更加模块化。 -
在幕后,在数据层中,
IRepository
实现使用 Entity Framework 来执行其工作。聚合体由领域层中定义的接口背后的根数据实体实现,而处理事务并将更改传递到数据库的IUnitOfWork
方法是用DbContext
方法实现的。换句话说,IUnitOfWork
是用应用程序的DbContext
实现的。 -
领域事件在每个聚合处理过程中生成,并通过调用其
AddDomainEvent
方法将其添加到聚合本身中。然而,它们不会立即触发。通常,它们会在所有聚合的处理结束时触发,并在更改传递到数据库之前;然而,这并不是一个普遍的规则。 -
应用程序通过抛出异常来处理错误。一个更有效的方法是在依赖引擎中定义一个请求作用域对象,其中每个应用程序子部分都可以将其错误添加为领域事件。然而,虽然这种方法更有效,但它增加了代码和应用程序开发时间的复杂性。
Visual Studio 解决方案由三个项目组成:
-
有一个包含领域层抽象的项目,这是一个.NET Standard 2.1 库。当一个库不使用特定于.NET 版本的特性和 NuGet 包时,将其实现为.NET Standard 库是一个好的实践,因为这样,当应用程序移动到新的.NET 版本时,它不需要修改。
-
有一个包含整个数据层的项目,这是一个基于 Entity Framework 的.NET 8.0 库。
-
最后,还有一个包含应用程序和表示层的 ASP.NET Core MVC 8.0 项目。当你定义此项目时,选择无身份验证;否则,用户数据库将直接添加到 ASP.NET Core MVC 项目中,而不是数据层。我们将手动在数据层中添加用户数据库。
在接下来的部分中,我们将描述构成迄今为止所描述架构的每一层的实现,首先是领域层抽象。
定义领域层接口
一旦将PackagesManagementDomain
Standard 2.1 库项目添加到解决方案中,我们将在项目根目录中添加一个Tools
文件夹。然后,我们将所有与ch7
相关的代码中包含的DomainLayer
工具放置到该文件夹中。由于该文件夹中的代码使用数据注释并定义了 DI 扩展方法,我们还必须添加对System.ComponentModel.Annotations
和Microsoft.Extensions.DependencyInjection.Abstration
NuGet 包的引用。
然后,我们需要一个包含所有聚合定义的Aggregates
文件夹(正如已经说过的,我们将实现为接口)。
下面是一个聚合定义的示例:
public interface IPackage : IEntity<int>
{
void FullUpdate(IPackageFullEditDTO packageDTO);
string Name { get; set; }
string Description { get;}
decimal Price { get; set; }
int DurationInDays { get; }
DateTime? StartValidityDate { get;}
DateTime? EndValidityDate { get; }
int DestinationId { get; }
}
它包含与我们在第十三章中看到的Package
实体相同的属性,即交互式数据在 C# – Entity Framework Core。唯一的区别如下:
-
它继承自
IEntity<int>
,这为聚合提供了所有基本功能。 -
它没有
Id
属性,因为它继承自IEntity<int>
。 -
所有属性都是只读的,并且它有一个
FullUpdate
方法,因为所有聚合只能通过用户领域(在我们的情况下,是FullUpdate
方法)中定义的更新操作进行修改。
现在,让我们也添加一个DTOs
文件夹。在这里,我们放置所有用于将更新传递给聚合的接口。这些接口由用于定义此类更新的应用层 ViewModel 实现。在我们的案例中,它包含IPackageFullEditDTO
,我们可以用它来更新现有包。
一个IRepositories
文件夹包含所有存储库规范;以下是一个示例存储库接口:
public interface IPackageRepository:
IRepository<IPackage>
{
Task<IPackage> Get(int id);
IPackage New();
Task<IPackage> Delete(int id);
}
存储库总是只包含几个方法,因为所有业务逻辑都应该表示为聚合方法——在我们的案例中,只是创建新包、检索现有包和删除现有包的方法。修改现有包的逻辑包含在IPackage
的FullUpdate
方法中。
最后,我们还有一个包含所有域事件定义的事件文件夹。我们可以将此文件夹命名为Events
。每当聚合的更改对其他聚合或微服务有影响时,都会触发事件。它们是实现聚合和微服务之间弱交互的一种方式,同时保持聚合代码相互独立。
通过使用事件,我们可以使每个聚合的代码在涉及相同数据库事务的其他聚合的代码中保持高度独立:每个聚合生成可能引起其他聚合兴趣的事件,例如旅游包聚合中的价格变化,所有依赖于这个价格的其他聚合都订阅了这个事件,以便它们可以一致地更新它们的数据。这样,当在系统维护期间添加一个依赖于旅游包价格的新聚合时,我们不需要修改旅游包聚合。
当事件可能也引起其他微服务的兴趣时,该事件也会传递给消息代理,这使得事件也对其他微服务中的代码可用进行订阅。消息代理在第十四章,使用.NET 实现微服务中进行了讨论。
下面是一个事件定义的示例:
public class PackagePriceChangedEvent: IEventNotification
{
public PackagePriceChangedEvent(int id, decimal price,
long oldVersion, long newVersion)
{
PackageId = id;
NewPrice = price;
OldVersion = oldVersion;
NewVersion = newVersion;
}
public int PackageId { get; }
public decimal NewPrice { get; }
public long OldVersion { get; }
public long NewVersion { get; }
}
当一个聚合将所有更改发送到另一个微服务时,它应该有一个版本属性。接收更改的微服务使用这个版本属性来按正确顺序应用所有更改。显式的版本号是必要的,因为更改是异步发送的,所以它们接收的顺序可能与发送的顺序不同。为此,用于在应用程序外部发布更改的事件具有OldVersion
(更改前的版本)和NewVersion
(更改后的版本)属性。与删除事件相关的事件没有NewVersion
属性,因为实体在被删除后无法存储任何版本。
下一个子节解释了在域层定义的所有接口如何在数据层实现。
定义域层实现
领域层实现包含了在领域层接口中定义的所有仓储接口和聚合接口的实现。在 .NET 8 的情况下,它使用 Entity Framework Core 实体来实现聚合。在领域层的实际实现和应用层之间添加领域层接口可以将应用层从 EF 和实体特定细节中解耦。此外,它符合洋葱架构,而洋葱架构反过来又是构建微服务的一个建议方式。
领域层实现项目应包含对 Microsoft.AspNetCore.Identity.EntityFrameworkCore
和 Microsoft.EntityFrameworkCore.SqlServer
NuGet 包的引用,因为我们正在使用与 SQL Server 的 Entity Framework Core。它引用了 Microsoft.EntityFrameworkCore.Tools
和 Microsoft.EntityFrameworkCore.Design
,这是在 第十三章 的 Entity Framework Core 迁移 部分中解释的,在 C# 中与数据交互 – Entity Framework Core。
我们应该有一个包含所有数据库实体的 Models
文件夹。它们与 第十三章 中的类似。唯一的区别如下:
-
它们继承自
Entity<T>
,其中包含所有聚合的基本功能。请注意,从Entity<T>
继承仅适用于聚合根;所有其他实体都必须按照 第十三章 中所述的方式定义。 -
由于它继承自
Entity<T>
,它们没有Id
属性。 -
其中一些可能具有用
[ConcurrencyCheck]
属性装饰的EntityVersion
属性。它包含发送更改到其他微服务所需的实体版本。ConcurrencyCheck
属性用于防止在更新实体版本时发生并发错误。这防止了因事务而导致的性能惩罚。
更具体地说,当保存实体更改时,如果带有 ConcurrencyCheck
属性的字段值与实体在内存中加载时读取的值不同,则会抛出一个并发异常,以通知调用方法,在读取实体之后但在我们尝试保存其更改之前,有人修改了此值。这样,调用方法可以重复整个操作,希望这次在执行过程中没有人会在数据库中写入相同的实体。
值得分析一个示例实体:
public class Package: Entity<int>, IPackage
{
public void FullUpdate(IPackageFullEditDTO o)
{
if (IsTransient())
{
Id = o.Id;
DestinationId = o.DestinationId;
}
else
{
if (o.Price != this.Price)
this.AddDomainEvent(new PackagePriceChangedEvent(
Id, o.Price, EntityVersion, EntityVersion+1));
}
Name = o.Name;
Description = o.Description;
Price = o.Price;
DurationInDays = o.DurationInDays;
StartValidityDate = o.StartValidityDate;
EndValidityDate = o.EndValidityDate;
}
[MaxLength(128)]
public string Name { get; set; }
[MaxLength(128)]
public string? Description { get; set; }
public decimal Price { get; set; }
public int DurationInDays { get; set; }
public DateTime? StartValidityDate { get; set; }
public DateTime? EndValidityDate { get; set; }
public Destination MyDestination { get; set; }
[ConcurrencyCheck]
public long EntityVersion{ get; set; }
public int DestinationId { get; set; }
}
FullUpdate
方法是更新 IPackage
聚合的唯一方式。当价格发生变化时,它会向事件列表中添加一个 PackagePriceChangedEvent
。
MainDBContext
实现了 IUnitOfWork
接口。以下代码展示了开始、回滚和提交事务的所有方法的实现:
public async Task StartAsync()
{
await Database.BeginTransactionAsync();
}
public Task CommitAsync()
{
Database.CommitTransaction();
return Task.CompletedTask;
}
public Task RollbackAsync()
{
Database.RollbackTransaction();
return Task.CompletedTask;
}
然而,在分布式环境中,命令类很少使用它们。事实上,就像分布式事务一样,本地数据库阻塞事务也被避免,因为它们可能会长时间阻塞数据库资源,这与基于微服务应用程序典型的高流量最大化不相符。
很可能,如前所述,所有数据库都支持将某些行字段标记为 并发检查。在 Entity Framework Core 中,这是通过用 ConcurrencyCheck
属性装饰对应字段的实体属性来完成的。
并发检查检测在执行事务 A 时,另一个事务 B 对记录的干扰。这样,我们可以在不阻塞任何数据库记录或表的情况下执行事务 A,并且如果检测到干扰,我们将终止事务 A 并重试,直到在没有干扰的情况下成功为止。如果事务非常快,这种技术效果很好,因此干扰也很少。
更具体地说,如果在事务 A 中,更新操作指定的并发检查值与正在更新的记录中存储的值不同,则更新将被终止,并抛出并发异常。其理由是另一个事务 B 修改了并发检查,从而干扰了 A 正在执行的操作。
因此,将所有应用于 DbContext
的更改传递到数据库的方法会检查并发异常:
public async Task<bool> SaveEntitiesAsync()
{
try
{
return await SaveChangesAsync() > 0;
}
catch (DbUpdateConcurrencyException ex)
{
foreach (var entry in ex.Entries)
{
entry.State = EntityState.Detached;
}
throw;
}
}
之前的实现只是调用 SaveChangesAsync DbContext
上下文方法,将所有更改保存到数据库中,然后拦截所有并发异常,并将所有涉及并发错误的实体从上下文中分离。这样,下次命令重试整个失败的操作时,它们的更新版本将重新从数据库中加载。
换句话说,当更新失败是因为事务 B 的干扰时,我们允许干扰事务 B 完成其过程。然后,EF 自动重新加载所有由 B 修改且包含 B 修改的并发检查值的实体。这样,当操作重试时,如果没有其他事务干扰,则并发检查将不会出现冲突。
并发检查的实际用法在 第二十一章 的 案例研究 中的 前端微服务 示例中详细说明。
所有仓储实现都定义在 Repositories
文件夹中,以确保更好的可维护性。
最后,所有仓储都被自动发现并添加到应用程序 DI 引擎中,调用定义在域层项目中添加的 DDD 工具中的 AddAllRepositories
方法。有关如何在应用程序启动时确保调用此方法的更多详细信息,请参阅 第二十一章,案例研究 中 前端微服务 部分的详细描述。
定义应用层
应用层包含所有业务操作的定义。这些业务操作使用用户提供的数据来修改领域层抽象聚合,例如旅游套餐。当当前用户请求中涉及的所有业务操作都已执行完毕后,执行IUnitOfWork.SaveEntitiesAsync()
操作以将所有更改保存到数据库。
作为第一步,为了简单起见,让我们通过向 ASP.NET Core 管道添加以下代码将应用程序文化冻结为en-US
:
app.UseAuthorization();
// Code to add: configure the Localization middleware
var ci = new CultureInfo("en-US");
app.UseRequestLocalization(new RequestLocalizationOptions
{
DefaultRequestCulture = new RequestCulture(ci),
SupportedCultures = new List<CultureInfo>
{
ci,
},
SupportedUICultures = new List<CultureInfo>
{
ci,
}
});
作为第二步,我们可以创建一个Tools
文件夹来放置ApplicationLayer
代码,这些代码可以在与本书相关的 GitHub 仓库的ch7
代码中找到。有了这些工具,我们可以在Program.cs
中添加代码,自动发现并添加所有查询、命令处理器和事件处理器到 DI 引擎,如下所示:
...
...
builder.Services.AddAllQueries(this.GetType().Assembly);
builder.Services.AddAllCommandHandlers(this.GetType().Assembly);
builder.Services.AddAllEventHandlers(this.GetType().Assembly);
然后,我们必须添加一个Queries
文件夹来放置所有查询及其相关接口。作为一个例子,让我们看看列出所有包的查询:
public class PackagesListQuery:IPackagesListQuery
{
private readonly MainDbContext ctx;
public PackagesListQuery(MainDbContext ctx)
{
this.ctx = ctx;
}
public async Task<IReadOnlyCollection<PackageInfosViewModel>> GetAllPackages()
{
return await ctx.Packages.Select(m => new PackageInfosViewModel
{
StartValidityDate = m.StartValidityDate,
...
})
.OrderByDescending(m=> m.EndValidityDate)
.ToListAsync();
}
}
查询对象会自动注入到应用数据库上下文中。GetAllPackages
方法使用 LINQ 将所有所需信息投影到PackageInfosViewModel
中,并按EndValidityDate
属性降序排序所有结果。
Commands
文件夹包含所有命令。作为一个例子,让我们看看用于修改包的命令:
public class UpdatePackageCommand: ICommand
{
public UpdatePackageCommand(IPackageFullEditDTO updates)
{
Updates = updates;
}
public IPackageFullEditDTO Updates { get; private set; }
}
命令处理器可以放置在Handlers
文件夹中。分析更新包的命令是很有价值的:
private readonly IPackageRepository repo;
private readonly IEventMediator mediator;
public UpdatePackageCommandHandler(IPackageRepository repo, IEventMediator mediator)
{
this.repo = repo;
this.mediator = mediator;
}
构造函数已自动注入了IPackageRepository
仓库和一个IEventMediator
实例,用于触发事件处理器。以下代码还展示了标准HandleAsync
命令处理器方法的实现:
public async Task HandleAsync(UpdatePackageCommand command)
{
bool done = false;
IPackage model;
while (!done)
{
try
{
model = await repo.Get(command.Updates.Id);
if (model == null) return;
model.FullUpdate(command.Updates);
await mediator.TriggerEvents(model.DomainEvents);
await repo.UnitOfWork.SaveEntitiesAsync();
done = true;
}
catch (DbUpdateConcurrencyException)
{
// add some logging here
}
}
}
命令操作会重复执行,直到没有并发异常返回。HandleAsync
使用仓库获取要修改的实体实例。如果实体未找到(已被删除),则命令停止其执行。否则,所有更改都传递给检索到的聚合。更新后,立即触发聚合中包含的所有事件。特别是,如果价格已更改,则执行与价格变更相关的事件处理器。在Package
实体的EntityVersion
属性上使用[ConcurrencyCheck]
属性声明并发检查确保包版本正确更新(通过将其前一个版本号增加 1),以及将正确的版本号传递给价格变更事件。
此外,事件处理器也放置在Handlers
文件夹中。作为一个例子,让我们看看价格变更事件处理器:
public class PackagePriceChangedEventHandler :
IEventHandler<PackagePriceChangedEvent>
{
private readonly IPackageEventRepository repo;
public PackagePriceChangedEventHandler(IPackageEventRepository repo)
{
this.repo = repo;
}
public Task HandleAsync(PackagePriceChangedEvent ev)
{
repo.New(PackageEventType.CostChanged, ev.PackageId,
ev.OldVersion, ev.NewVersion, ev.NewPrice);
return Task.CompletedTask;
}
}
构造函数已自动注入了IPackageEventRepository
仓库,该仓库处理数据库表以及发送到其他应用的所有事件。HandleAsync
的实现简单调用仓库方法,向该表添加新记录。
表中的所有记录都由IPackageEventRepository
处理,可以通过 DI 引擎中定义的并行任务(例如builder.Services.AddHostedService<MyHostedService>();
)检索并发送到所有感兴趣的微服务,如第十一章中“使用通用宿主”小节中详细说明的。然而,这个并行任务并未在本书的 GitHub 代码中实现。
值得回忆的是,事件的使用促进了代码解耦,当事件跨越微服务边界时,它们实现了微服务之间的高效异步通信,这提高了性能并最大化了硬件资源的使用。
下一小节将描述如何定义控制器。
定义控制器
每个控制器都通过其操作方法与在分析阶段出现的用例进行交互。操作方法通过从依赖注入引擎中要求命令处理程序和查询接口来完成其工作。
下面是一个如何要求和使用查询对象的示例:
[HttpGet]
public async Task<IActionResult> Index(
[FromServices] IPackagesListQuery query)
{
var results = await query.GetAllPackages();
var vm = new PackagesListViewModel { Items = results };
return View(vm);
}
下面是一个命令处理程序使用的示例:
public async Task<IActionResult> Edit(
PackageFullEditViewModel vm,
[FromServices] ICommandHandler<UpdatePackageCommand> command)
{
if (ModelState.IsValid)
{
await command.HandleAsync(new UpdatePackageCommand(vm));
return RedirectToAction(
nameof(ManagePackagesController.Index));
}
else
return View(vm);
}
ICommandHandler<UpdatePackageCommand>
从 DI 中检索与UpdatePackageCommand
命令关联的命令处理程序。
如果ModelState
有效,则创建UpdatePackageCommand
,并调用其关联的处理程序;否则,将视图再次显示给用户,以便他们纠正所有错误。
摘要
在本章中,我们分析了前端微服务的特性以及实现它们的技巧。
然后,我们将本章和前几章学到的技术结合起来,在完整的前端微服务实现中应用。
我们使用了一个洋葱架构,其中包含数据层和领域层抽象,并将每个实现作为一个独立的项目。应用层和表示层在同一个 ASP.NET Core MVC 项目中实现。
该微服务使用了 CQRS 模式,并使用一个基于数据库表的队列来存储发送给其他微服务的事件。
下一章将解释如何使用基于客户端的技术实现表示层。我们将使用 Blazor 作为示例客户端框架。
问题
-
前端和 API 网关之间的区别是什么?
-
为什么所有前端和 API 网关都应该使用一个健壮的 Web 服务器?
-
为什么应该避免复杂的阻塞数据库事务?
-
并发技术何时能确保更好的性能?
-
使用领域事件实现不同聚合之间的交互有什么优势?
进一步阅读
由于本章只是将其他章节(主要是 第七章,理解软件解决方案中的不同领域,第十一章,将微服务架构应用于您的企业应用,以及 第十三章,在 C# 中与数据交互 – Entity Framework Core)中解释的概念付诸实践,因此这里我们将仅包括一些关于如何使用 API 网关的链接以及关于在示例中提到的 MediatR 库的更多信息:
-
Ocelot GitHub 仓库:
github.com/ThreeMammals/Ocelot
-
如何使用 Ocelot 实现您的 API 网关:
docs.microsoft.com/en-us/dotnet/architecture/microservices/multi-container-microservice-net-applications/implement-api-gateways-with-ocelot
-
Azure API 管理:
azure.microsoft.com/en-us/services/api-management/#overview
-
更多关于 MediatR 的信息可以在 MediatR 的 GitHub 仓库中找到:
github.com/jbogard/MediatR
留下评价!
喜欢这本书吗?通过留下亚马逊评价来帮助像你这样的读者。扫描下面的二维码以获取 20% 的折扣码。
限时优惠
第十九章:客户端框架:Blazor
在本章中,你将学习如何基于客户端技术实现表示层。基于服务器技术的应用程序,如 ASP.NET Core MVC,在服务器上运行所有应用程序层,因此也在服务器上创建 HTML,以编码整个 UI。相反,基于客户端技术的应用程序在客户端机器(移动设备、桌面计算机、笔记本电脑等)上运行整个表示层,因此仅与服务器交互以交换与 Web API 的数据。
换句话说,在基于客户端技术的应用程序中,整个 UI 都是由在用户设备上运行的代码创建的,它也控制着整个用户-应用程序交互。相反,业务层和领域层在服务器机器上运行,以防止用户通过破解其设备上运行的代码来违反业务规则和授权策略。
相反,基于客户端技术的应用程序可以分为单页应用程序,它们受益于 Web 标准,或者作为原生应用程序,它们与特定的操作系统和特定设备特性的优势相关联。
单页应用程序基于 JavaScript 或 WebAssembly,并在任何浏览器中运行。作为一个单页应用程序的例子,我们将分析 Blazor WebAssembly 框架
Blazor WebAssembly 应用程序是用 C# 开发的,并使用我们在第十七章“展示 ASP.NET Core”中已经分析过的许多技术,例如依赖注入和 Razor。因此,我们强烈建议在阅读本章之前学习第十七章“展示 ASP.NET Core”和第十八章“使用 ASP.NET Core 实现前端微服务”。
作为原生技术的例子,我们将分析原生 .NET MAUI Blazor,它与 Blazor WebAssembly 完全类似,但不是使用浏览器 WebAssembly,而是使用 .NET,它即时编译成目标设备的程序集。不受浏览器限制,.NET MAUI Blazor 可以通过适当的 .NET 库访问所有设备功能。
更具体地说,在本章中,你将学习以下主题:
-
各种客户端技术的比较
-
Blazor WebAssembly 架构
-
Blazor 页面和组件
-
Blazor 表单和验证
-
Blazor 的高级功能,例如 JavaScript 互操作性、全球化、身份验证等
-
Blazor WebAssembly 的第三方工具
-
.NET MAUI Blazor
虽然也存在服务器端 Blazor,它像 ASP.NET Core MVC 一样在服务器上运行,但本章仅讨论 Blazor WebAssembly 和 .NET MAUI Blazor,因为本章的主要重点是客户端技术。此外,服务器端 Blazor 无法提供与其他服务器端技术(如我们在第十七章“展示 ASP.NET Core”中分析过的 ASP.NET Core MVC)相当的性能。
第一部分总结了并比较了各种类型的客户端技术,而本章的其余部分详细讨论了 Blazor WebAssembly 和.NET MAUI Blazor。
技术要求
本章需要免费 Visual Studio 2022 Community 版或更高版本,并安装所有数据库工具。
所有概念都通过基于 WWTravelClub 书籍用例的简单示例应用程序进行了阐明,这些用例可以在第二十一章案例研究的使用客户端技术部分找到。
本章的代码可在github.com/PacktPublishing/Software-Architecture-with-C-Sharp-12-and-.NET-8-4E
找到。
客户端技术类型比较
本节讨论了各种类型的客户端技术:
-
单页应用程序,在浏览器中运行,但受到所有浏览器限制
-
渐进式应用程序,在浏览器中运行但可以像常规应用程序一样安装,并且可以克服一些浏览器限制(在用户授权后)
-
原生应用程序,与特定设备/操作系统绑定,但可以充分利用所有设备/操作系统功能
-
跨平台技术,类似于原生应用程序,可以充分利用所有设备功能,但与多种设备/操作系统兼容
单页应用程序
近几十年来,网络开发之所以增长迅速,有许多原因,但最基本的一个原因是能够同时将任何新版本的应用程序部署给许多用户。此外,所有浏览器自动执行的安全策略鼓励了网络应用程序的使用和传播。
因此,这里的一个好问题是,为什么现在不使用网络开发?最好的答案可能是缺乏连接性。作为一名软件架构师,你需要对此保持警惕,正如我们在第二章非功能性需求中讨论的那样。
有时候,问题不仅仅是关于是否有连接性。有时候,一个大问题是稳定性,别忘了你将在网络应用一旦进入世界后遇到的不预期的场景中遇到的困难。
例如,在 WWTravelClub 案例中,有一个用户故事说:“作为一个普通用户,我想在主页上查看促销套餐,这样我可以轻松找到我的下一个假期。”乍一看,你可能会确定网络应用是唯一的选择,尤其是因为还有一个系统要求说:“系统将在 Windows、Linux、iOS 和 Android 平台上运行。”
然而,想象一下,一个用户浏览了很多目的地和套餐以找到他们理想的假期,当他们准备预订时,突然,由于网络连接问题,网络应用程序崩溃了。在这种情况下,用户将失去他们所有的浏览努力,并且一旦网络连接恢复,他们被迫重新从零开始搜索套餐。只需一个能够在网络连接问题时保存其状态而不是崩溃的应用程序,就可以解决这个问题。这样,用户就可以在恢复网络连接后立即完成任务,而不会浪费他们在网络连接问题之前已经投入的努力。
因此,也许对于解决方案的一些部分,一个已经下载了一些数据的原生应用会是一个更好的选择。然而,这个问题也可以通过一种特定的现代网络应用,即进步式应用来解决,我们将在下一小节中分析。
进步式应用
进步式网络应用是运行在浏览器中的单页应用,但可以像原生应用一样安装。此外,它们还可以离线运行。
进步式应用是所有主流浏览器都支持的新网络标准。如果你需要所有网络应用的优势,但同时也需要像原生应用一样离线工作的能力,进步式网络应用是你正确的选择。
然而,请注意,进步式网络应用无法保证与原生应用相同的性能和灵活性。
Blazor WebAssembly,正如我们将在本章下一节中描述的,支持进步式网络应用。当你创建 Blazor WebAssembly 项目时,请勾选出现的进步式网络应用复选框。这就是创建 Blazor 进步式网络应用所需的所有操作。
如果你将你的 Blazor 应用程序作为进步式应用程序发布,你就可以克服初始下载时间(4-8 秒)的问题,这是 Blazor 的主要缺点。
虽然进步式网络应用是已安装的应用程序,但它们会自动更新到最新版本,因为每次它们启动时,它们都会检查是否有更新的版本可用,如果有,它们会在运行前自动下载。也就是说,它们具有自动运行最新可用版本的优点,这是所有经典网络应用的特征。
在开发过程中,无法选择安装 Blazor 进步式网络应用,因为这会干扰常规的修改测试周期。因此,你需要发布你的应用程序以测试其进步式应用的特性。
值得指出的是,渐进式应用必须组织起来,以充分利用它们离线工作的能力。因此,在多次通信错误之后,应用应该在建立成功连接之前,将所有要发送到服务器的数据保存到浏览器的本地存储中。
还有可能在集中式服务中维护整个应用状态,这样用户在退出应用之前可以将它序列化并保存到本地存储中。这样,当应用离线时,要发送到服务器的数据不会丢失,因为它们保留在已保存到磁盘的应用状态中。
当渐进式网络应用无法满足你的要求,因为它无法使用你需要的特定设备功能,但你必须支持多个不同的设备时,你应该考虑跨平台原生应用,这些应用可以完全访问所有设备功能,但仍然可以支持多个硬件/软件平台。我们将在下一小节中讨论它们。
本地应用
原生开发可以被认为是 UI 开发的起点。当最初将软件生产任务分配给另一个人/公司时,并没有在具有不同硬件的机器之间共享代码的概念。
这是关于为什么本地应用有良好性能的第一个好答案。我们不能忘记,本地应用之所以运行得更好,仅仅是因为它们靠近硬件,大多数情况下直接连接到操作系统,或者通过使用框架,如.NET。请注意;我们不仅谈论原生移动应用,还在讨论在 Windows、Linux、Mac、Android 或任何其他可以运行应用的操作系统上交付的应用。
考虑到这种场景,最大的问题是——我什么时候必须使用本地应用?有些情况下,这样做是个好主意:
-
没有必要在不同的平台上部署。
-
与硬件有巨大的连接。
-
网页客户端提供的性能是不可接受的。
-
应用需要设备资源,而这些资源无法通过浏览器访问,因为浏览器的安全策略。
-
应用将运行的地方存在连接问题。
-
最坏的情况是你需要同时拥有两样东西:比网页客户端更好的性能和不同的平台。在这种情况下,你可能不得不交付两个应用程序,而你设计这个解决方案的后端方式将对于减少开发和维护成本至关重要。
在没有概念验证(POC)的情况下,决定是否开发本地应用可能会很困难。作为一名软件架构师,你应该推荐这种类型的 POC。
你可以使用 C#开发的本地应用示例包括经典的 Windows Forms 和 Windows Presentation Foundation,它们是针对 Windows 操作系统的。此外,还有 Xamarin,这是一个允许开发可在 Android 和 iOS 上发布的应用的平台。
跨平台应用
尽管性能可能是一个难以实现的要求,但在许多场景中,由于解决方案的简单性,这并不是应用的致命弱点。考虑到 WWTravelClub,虽然拥有之前提到的离线体验会有所帮助,但性能本身并不是最难以实现的一个。
在这些场景中,跨平台技术是完全有意义的。其中,值得提及的是 Xamarin.Forms 和新的.NET MAUI,它们可以发布到 Android、iOS 和 Windows 平台。
推荐的跨平台应用选择是.NET MAUI。然而,目前 MAUI 支持 Windows 和所有主要移动平台,但不支持 Linux。Uno Platform(platform.uno/
)也支持 Linux 以及所有主要移动平台,但它不是由微软维护的微软产品。无论如何,它可以作为 Visual Studio 扩展下载。
在本章中,我们不会分析 Uno 或.NET MAUI 提供的所有选项,而只是.NET MAUI Blazor,因为它与 Blazor WebAssembly 非常相似。因此,学习 Blazor 使我们能够开发单页应用、渐进式应用和跨平台应用。
本章的.NET MAUI Blazor部分描述了.NET MAUI Blazor,而下一节将介绍 Blazor WebAssembly 架构的基础。
Blazor WebAssembly 架构
Blazor WebAssembly 利用新的 WebAssembly 浏览器功能,在浏览器中执行.NET 运行时。这样,它使所有开发者能够在任何 WebAssembly 兼容的浏览器中实现能够运行的应用程序,并使用整个.NET 代码库和生态系统。WebAssembly 被构想为 JavaScript 的高性能替代品。它是一个能够在浏览器中运行并遵守与 JavaScript 代码相同限制的汇编。这意味着 WebAssembly 代码,就像 JavaScript 代码一样,在具有非常有限访问所有机器资源的独立执行环境中运行。
WebAssembly 与过去的类似选项(如 Flash 和 Silverlight)不同,因为它是一个官方的 W3C 标准。更具体地说,它于 2019 年 12 月 5 日成为官方标准,因此预计它将有一个漫长的生命周期。事实上,所有主流浏览器都已经支持它。
然而,WebAssembly 不仅仅带来了性能;它还创造了在浏览器中运行与现代化和高级面向对象语言(如 C++(直接编译)、Java(字节码)和 C# (.NET))相关的整个代码库的机会。
目前,微软提供了两个在 WebAssembly 上运行 .NET 的框架,即 Blazor WebAssembly 和 Unity WebAssembly,后者是 Unity 3D 图形框架的 WebAssembly 版本。Unity WebAssembly 的主要目的是实现运行在浏览器中的在线视频游戏,而 Blazor WebAssembly 是一个 单页应用程序 框架,它使用 .NET 而不是 JavaScript 或 TypeScript。
在 WebAssembly 之前,运行在浏览器中的表示层只能用 JavaScript 实现,这带来了与在非严格类型语言中实现的大代码库维护相关的问题。然而,我们必须考虑,一方面,TypeScript 的使用部分解决了 JavaScript 的严格类型缺乏问题,另一方面,.NET 带来了使用不同 .NET 版本实现的模块的二进制兼容性问题。
无论如何,使用 Blazor C#,开发者现在可以用他们最喜欢的语言实现复杂的应用程序,同时享受 C# 编译器和 Visual Studio 为这种语言提供的所有便利。
此外,使用 Blazor,所有 .NET 开发者都可以使用 .NET 框架的全部功能,唯一的限制是由浏览器安全策略强加的,用于实现运行在浏览器中并与服务器端运行的所有其他层共享库和类的表示层。
下面的子节描述了所有 Blazor 架构。第一个子节探讨了单页应用程序的一般概念,并描述了 Blazor 的特性。
什么是单页应用程序?
单页应用程序(SPA)是一个基于 HTML 的应用程序,其中 HTML 通过在浏览器中运行的代码进行更改,而不是向服务器发出新的请求并从头开始渲染新的 HTML 页面。SPA 可以通过用新的 HTML 替换完整的页面区域来模拟多页体验。
SPA 框架是专门设计用于实现 SPAs 的框架。在 WebAssembly 之前,所有 SPA 框架都基于 JavaScript。最著名的基于 JavaScript 的 SPA 框架是 Angular、React.js 和 Vue.js。
所有 SPA 框架都提供将数据转换为 HTML 以显示给用户的方法,并依赖于一个名为 router 的模块来模拟页面变化。通常,数据填充 HTML 模板的占位符,并选择渲染模板的哪些部分(if-like
结构)以及渲染它的次数(for-like
结构)。
Blazor 模板语言是 Razor,我们在 第十七章,展示 ASP.NET Core 中进行了描述。
为了提高模块化,代码被组织成组件,这些组件是一种虚拟的 HTML 标签,一旦渲染,就会生成实际的 HTML 标记。像 HTML 标签一样,组件有属性,通常称为参数,以及自定义事件。开发者需要确保每个组件使用其参数创建适当的 HTML,并确保它生成足够的事件。组件可以以分层的方式嵌套在其他组件内部。
组件可以与应用程序网络域中的 URL 关联,在这种情况下,它们被称为页面。这些 URL 可以在常规 HTML 链接中使用,并且跟随它们会导致页面通过框架服务(称为路由器)上传到应用程序区域。
一些 SPA 框架还提供预定义的依赖注入引擎,以确保组件一侧和运行在浏览器中的通用服务以及业务代码之间的更好分离。在本节列出的框架中,只有 Blazor 和 Angular 提供开箱即用的依赖注入引擎。
为了减少整体应用程序文件大小,基于 JavaScript 的 SPA 框架通常将所有 JavaScript 代码编译成几个 JavaScript 文件,然后执行所谓的摇树(tree-shaking),即删除所有未使用的代码。这种技术合理地减少了应用程序的加载时间。
目前,Blazor 将主应用程序引用的所有 DLL 分开保存,并对每个 DLL 分别执行摇树(tree-shaking)操作。
下一个子节描述了 Blazor 架构。我们挑战你创建一个名为 BlazorReview
的 Blazor WebAssembly 项目,这样你就可以检查本章中解释的代码和结构。为此,在创建新项目时选择 Blazor WebAssembly Standalone Application 选项。确保选择 Individual Accounts 作为身份验证类型,并确保勾选 Include sample pages 复选框,如图下所示。
图 19.1:创建 BlazorReview 应用程序
如果你启动应用程序,应用程序将正常工作,但如果你尝试登录,将会出现以下错误信息:“尝试登录时发生错误:‘网络错误’。” 这是因为你需要配置一个身份提供者认证用户。默认情况下,应用程序配置为使用基于 OAuth 的身份提供者网络应用程序。你只需在配置文件中添加提供者配置数据。我们将在 身份验证和授权 部分更详细地讨论这个问题。
加载和启动应用程序
Blazor WebAssembly 应用程序的文件夹结构始终包括一个 index.html
静态 HTML 页面。在我们的 BlazorReview
项目中,index.html
位于 BlazorReview->wwwroot->index.html
。这个页面是 Blazor 应用程序创建其 HTML 的容器。它包含一个带有 viewport meta
声明的 HTML 头部,标题以及用于应用程序整体样式的 CSS。Visual Studio 默认项目模板添加了一个特定于应用程序的 CSS 文件和 Bootstrap CSS,具有中性风格。您可以用自定义样式或完全不同的 CSS 框架替换默认的 Bootstrap CSS。
主体包含以下代码:
<body>
<div id="app">
<svg class="loading-progress">
<circle r="40%" cx="50%" cy="50%" />
<circle r="40%" cx="50%" cy="50%" />
</svg>
<div id="loading-progress-text"></div>
</div>
<div id="blazor-error-ui">
An unhandled error has occurred.
<a href="" class="reload">Reload</a>
<a class="dismiss"> / </a>
</div>
<script
src="img/AuthenticationService.js">
</script>
<script src="img/blazor.webassembly.js"></script>
</body>
初始的 div
元素,其 app
标识符是应用程序放置其生成的代码的位置。放置在此 div
内部的任何标记都将仅在 Blazor 应用程序加载和启动时显示,然后将被应用程序生成的 HTML 替换。默认情况下,它包含一个 svg
图像和显示加载进度的文本,这些都被负责加载框架的 JavaScript 代码控制。加载动画基于位于 css/app.css
主应用程序 CSS 文件中的 CSS 动画。然而,您可以替换默认内容和 CSS 动画。
第二个 div
通常不可见,仅在 Blazor 拦截未处理的异常时才会出现。
blazor.webassembly.js
包含 Blazor 框架的 JavaScript 部分。除了其他功能外,它负责下载 .NET 运行时以及所有应用程序 DLL。更具体地说,blazor.webassembly.js
下载 blazor.boot.json
文件,该文件列出了所有应用程序文件及其哈希值。
然后,blazor.webassembly.js
下载此文件中列出的所有资源并验证它们的哈希值。所有由 blazor.webassembly.js
下载的资源都是在应用程序构建或发布时创建的。定期加载 blazor.webassembly.js
会更新 --blazor-load-percentage
和 --blazor-load-percentage-text
CSS 变量,分别以数值格式和文本格式显示加载百分比。
当项目启用身份验证时,会添加 AuthenticationService.js
,并负责 Blazor 使用的 OpenID Connect
协议,以利用其他身份提供者,获取 bearer 令牌,这些令牌是客户端通过 Web API 与服务器交互时首选的认证凭证。
在本章稍后的 身份验证和授权 子节中更详细地讨论了身份验证,而 bearer 令牌在 第十五章 的 REST 服务授权和身份验证 部分中讨论。
Blazor 应用程序的入口点位于 BlazorReview->Program.cs
文件中。此文件不包含类,只包含在应用程序启动时必须执行的代码:
var builder = WebAssemblyHostBuilder.CreateDefault(args);
builder.RootComponents.Add<App>("#app");
builder.RootComponents.Add< HeadOutlet>("head::after");
// Services added to the application
// Dependency Injection engine declared with statements like:
// builder.Services.Add...
await builder.Build().RunAsync();
实际上,新的 Blazor WebAssembly 项目模板利用了从 .NET 7 开始引入的这种新方法来定义应用程序的入口点。
WebAssemblyHostBuilder
是创建 WebAssemblyHost
的构建器,它是第十一章 将微服务架构应用于您的企业应用程序 中 使用通用主机 子部分讨论的通用主机的 WebAssembly 特定实现(您被鼓励回顾该子部分)。第一个构建器配置指令声明了 Blazor 根组件(App
),它将包含整个组件树,并指定在 index.html
页面的哪个 HTML 标签中放置它(#app
)。更具体地说,RootComponents.Add
添加了一个托管服务,负责处理整个 Blazor 组件树。我们可以通过多次调用 RootComponents.Add
来在同一个 HTML 页面上运行多个 Blazor WebAssembly 用户界面,每次调用时使用不同的 HTML 标签引用。
默认情况下,仅添加了一个名为 HeadOutlet
的根组件,并将其放置在 HTML Head
标签之后。它用于动态更改 index.html
的标题(浏览器标签中显示的文本)。有关 HeadOutlet
组件的更多信息,请参阅 从 Blazor 组件修改 HTML 内容 子部分。
builder.Services
包含了向 Blazor 应用程序依赖引擎添加服务的所有常用方法和扩展方法:AddScoped
、AddTransient
、AddSingleton
等。就像在 ASP.NET Core MVC 应用程序中(第十七章 展示 ASP.NET Core 和第十八章 使用 ASP.NET Core 实现前端微服务),服务是实现业务逻辑和存储共享状态的优选位置。虽然在 ASP.NET Core MVC 中服务通常传递给控制器,但在 Blazor WebAssembly 中,它们被注入到组件中。
Visual Studio 默认生成的项目包含两个服务,一个用于与服务器通信,另一个用于处理基于 OAuth 的身份验证。我们将在本章后面讨论这两个服务。
下一个子部分将解释根 App
组件如何模拟页面变化。
路由
主构建代码引用的根 App
类定义在 BlazorReview ->App.razor
文件中。App
是一个 Blazor 组件,就像所有 Blazor 组件一样,它在一个以 .razor
扩展名命名的文件中定义,并使用带有组件符号的 Razor 语法,即具有类似 HTML 标签的其他 Blazor 组件。它包含处理应用程序页面的全部逻辑:
<CascadingAuthenticationState>
<Router AppAssembly="@typeof(App).Assembly">
<Found Context="routeData">
<AuthorizeRouteView RouteData="@routeData"
DefaultLayout="@typeof(MainLayout)">
<NotAuthorized>
@*Template that specifies what to show
when user is not authorized *@
</NotAuthorized>
</AuthorizeRouteView>
<FocusOnNavigate RouteData="@routeData" Selector="h1">
</Found>
<NotFound Layout="@typeof(MainLayout)">
<PageTitle>Not found<PageTitle>
<LayoutView Layout="@typeof(MainLayout)">
<p>Sorry, there's nothing at this address.</p>
</LayoutView>
</NotFound>
</Router>
</CascadingAuthenticationState>
前述代码中的所有标签都代表组件或特定的组件参数,称为模板。组件将在本章中详细讨论。目前,可以将它们想象成一种我们可以用 C# 和 Razor 代码定义的自定义 HTML 标签。模板则是接受 Razor 标记为值的参数。模板将在本节稍后的 模板和级联参数 子节中讨论。
CascadingAuthenticationState
组件仅具有将认证和授权信息传递到其内部的所有组件树组件的功能。Blazor 项目模板仅在创建项目时选择添加授权时才生成它。
Router
组件是实际的应用程序路由器。它扫描通过 AppAssembly
参数传入的程序集,寻找包含路由信息的组件,即可以作为页面的组件。在 Blazor 项目模板中,Router
组件接收包含 App
组件类的程序集,即主应用程序。其他程序集中的页面可以通过 AdditionalAssemblies
参数添加,该参数接受程序集的 IEnumerable
。
之后,路由器拦截通过代码或通过指向应用程序基础地址内地址的常规 <a>
HTML 标签执行的任何页面更改。可以通过从依赖注入中获取 NavigationManager
实例来通过代码处理导航。
Router
组件有两个模板,一个用于找到请求的 URI 对应的页面时的情况(Found
),另一个用于未找到页面时的情况(NotFound
)。当应用程序使用授权时,Found
模板由 AuthorizeRouteView
组件组成,这进一步区分用户是否有权访问所选页面。当应用程序不使用授权时,Found
模板由 RouteView
组件组成:
<RouteView RouteData="@routeData" DefaultLayout="@typeof(MainLayout)" />
RouteView
接收所选页面,并在由 DefaultLayout
参数指定的布局页面内渲染它。这种指定作为默认值,因为每个页面都可以通过指定不同的布局页面来覆盖它。在 Blazor 项目模板中,默认布局页面位于 BlazorReview->Layout->MainLayout.razor
文件中。
Blazor 布局页面与 第十七章 中描述的 重用视图代码 子节中的 ASP.NET Core MVC 布局页面类似,唯一的区别在于添加页面标记的位置是用 @Body
指定的:
<article class="content px-4">
@Body
</article>
如果应用程序使用授权,AuthorizeRouteView
与 RouteView
的工作方式相同,但它还允许指定用户未授权时的模板:
<NotAuthorized>
@if (!context.User.Identity.IsAuthenticated)
{
<RedirectToLogin />
}
else
{
<p role ="alert">You are not authorized to access this resource.</p>
}
</NotAuthorized>
如果用户未认证,RedirectToLogin
组件将使用 NavigationManager
实例移动到登录逻辑页面;否则,它通知用户他们没有足够的权限访问所选页面。
Blazor WebAssembly 还允许懒加载程序集以减少初始应用程序加载时间,但在这里我们不会讨论它,因为篇幅有限。进一步阅读 部分包含了对官方 Blazor 文档的引用。
正如我们将在本章后面更详细地讨论的那样,PageTitle
组件允许开发者设置浏览器标签中显示的页面标题。而 FocusOnNavigate
组件则设置 HTML 上的焦点在满足其 Selector
参数中 CSS 选择器的第一个 HTML 元素上,页面导航后立即进行。
Blazor 页面和组件
在本节中,你将学习 Blazor 组件的基础知识,包括如何构建组件、组件的结构、如何将事件附加到 HTML 标签上、如何指定组件的特性,以及如何在组件内部使用其他组件。我们将内容分为几个子部分:
-
组件结构
-
模板和级联参数
-
错误处理
-
事件
-
绑定
-
Blazor 如何更新 HTML
-
组件生命周期
组件结构
组件是所有主要客户端框架的核心。它们是构建模块化 UI 的关键成分,其部分易于修改和重用。简而言之,它们是类的图形对应物。实际上,就像类一样,它们允许封装和代码组织。此外,组件架构允许正式定义有效的 UI 更新算法,正如我们将在本章的 Blazor 如何更新 HTML 部分中看到的那样。
组件定义在扩展名为 .razor
的文件中。一旦编译,它们就变成了继承自 ComponentBase
的类。和所有其他 Visual Studio 项目元素一样,Blazor 组件可以通过 添加新项 菜单访问。通常,用作页面的组件定义在 Pages
文件夹中,或者其子文件夹中,而其他组件则组织在不同的文件夹中。默认的 Blazor 项目将所有非页面组件添加到 Shared
文件夹中,但你可以以不同的方式组织它们。
默认情况下,页面被分配一个与它们所在文件夹路径相对应的命名空间。因此,例如,在我们的示例项目中,所有位于 BlazorReview->Pages
路径中的页面都被分配到 BlazorReview.Pages
命名空间。
然而,你可以通过在文件顶部的声明区域放置一个 @namespace
声明来更改此默认命名空间。此区域还可以包含其他重要声明。以下是一个显示所有声明的示例:
@page "/counter"
@layout MyCustomLayout
@namespace BlazorReview.Pages
@using Microsoft.AspNetCore.Authorization
@implements MyInterface
@inherits MyParentComponent
@typeparam T
@attribute [Authorize]
@inject NavigationManager navigation
前两个指令仅适用于必须作为页面工作的组件,而所有其他组件都可以出现在任何组件中。以下是每个声明的详细解释:
-
当指定
@layout
指令时,它将使用另一个组件覆盖默认布局页面。 -
@page
指令定义了页面在应用程序基本 URL 中的路径(路由)。因此,例如,如果我们的应用程序运行在https://localhost:5001
,那么这个页面的 URL 将是https://localhost:5001/counter
。页面路由也可以包含参数,例如在这个例子中:/orderitem/{customer}/{order}
。参数名称必须与组件公开定义的参数属性匹配。匹配不区分大小写。参数将在本小节稍后进行解释。 -
实例化每个参数的字符串被转换为参数类型,如果转换失败,则会抛出异常。可以通过为每个参数关联一个类型来防止这种行为,在这种情况下,如果转换到指定类型失败,则与页面 URL 的匹配也会失败。仅支持基本类型:
/orderitem/{customer:int}/{order:int}
。默认情况下,参数是必需的;也就是说,如果找不到它们,匹配将失败,并且路由器将尝试其他页面。然而,你可以通过在参数后附加一个问号来使参数可选:/orderitem/{customer?:int}/{order?:int}
。如果一个可选参数没有被指定,则使用其类型的默认值。 -
从版本 6 开始,参数也可以从查询字符串中提取:
/orderitem/{customer:int}?order=123
。 -
@namespace
覆盖了组件的默认命名空间,而@using
等同于常用的 C#using
。在特殊文件夹{project folder}->_Imports.razor
中声明的@using
会自动应用于所有组件。 -
@inherits
声明该组件是另一个组件的子类,而@implements
声明它实现了接口。 -
如果组件是泛型类,则使用
@typeparam
并声明泛型参数的名称。@typeparam
还允许使用与类中相同的语法指定泛型约束:@typeparam T where T: class
。 -
@attribute
声明应用于组件类的任何属性。属性级别的属性直接应用于代码区域中定义的属性,因此不需要特殊标记。应用于用作页面的组件类的[Authorize]
属性阻止未经授权的用户访问页面。它的工作方式与在 ASP.NET Core MVC 中应用于控制器或操作方法时完全相同。 -
最后,
@inject
指令需要依赖注入引擎的类型实例,并将其插入在类型名称后面的字段中;在上一个例子中,在navigation
参数中。
组件文件的中部包含将由组件使用 Razor 标记渲染的 HTML,并可能调用子组件。
文件的底部部分被 @code
构造包围,包含实现组件的类的字段、属性和方法:
@code{
...
private string myField="0";
[Parameter]
public int Quantity {get; set;}=0;
private void IncrementQuantity ()
{
Quantity++;
}
private void DecrementQuantity ()
{
Quantity--;
if (Quantity<0) Quantity=0;
}
...
}
装饰有 [Parameter]
特性的公共属性作为组件参数工作;也就是说,当组件被实例化为另一个组件时,它们用于将值传递给装饰的属性,并且这些值在 HTML 标记中传递给 HTML 元素:
<OrderItem Quantity ="2" Id="123"/>
值也可以通过页面路由参数或与属性名匹配的查询字符串参数传递给组件参数,匹配时不区分大小写:
OrderItem/{id}/{quantity}
OrderItem/{id}?quantity = 123
然而,只有当属性也装饰有 SupplyParameterFromQuery
特性时,才会启用与查询字符串参数的匹配:
[Parameter]
[SupplyParameterFromQuery]
public int Quantity {get; set;}=0;
组件参数还可以接受复杂类型和函数:
<modal title='() => "Test title" ' ...../>
如果组件是泛型的,它们必须为使用 typeparam
声明的每个泛型参数传递类型值:
<myGeneric T= "string"...../>
然而,编译器通常能够从其他参数的类型推断泛型类型。
最后,在 @code
指令中封装的代码也可以声明在具有相同名称和命名空间的局部类中:
public partial class Counter
{
[Parameter]
public int CurrentCounter {get; set;}=0;
...
...
}
通常,这些局部类在组件所在的同一文件夹中声明,文件名与组件的文件名相同,但添加了 .cs
后缀。例如,与 counter.razor
组件关联的局部类将是 counter.razor.cs
。
每个组件也可能有一个相关的 CSS 文件,其名称必须是组件文件名加上 .css
后缀。例如,与 counter.razor
组件关联的 CSS 文件将是 counter.razor.css
。此文件中包含的 CSS 只应用于组件,对页面的其余部分没有影响。这被称为 CSS 隔离,目前它是通过向所有组件 HTML 根添加一个唯一属性来实现的。然后,组件 CSS 文件的全部选择器都被限制在这个属性上,这样它们就不能影响其他 HTML。
当然,我们也可以使用全局应用 CSS,实际上,Blazor 模板为此创建了 wwwroot/css/app.css
文件。
当 Blazor 应用程序打包时,无论是在构建过程中还是在发布过程中,所有隔离的 CSS 都会被处理并放置在一个名为 <assembly name>.Client.styles.css
的唯一 CSS 文件中。这就是为什么我们的 BlazorReview
应用的 index.html
页面包含了以下 CSS 引用:
<head>
...
<link href="BlazorReview.Client.styles.css" rel="stylesheet" />
</head>
值得注意的是,隔离的 CSS 也可以通过纯 CSS 技巧或使用 Sass 语言获得,Sass 编译成 CSS。
当组件使用 [Parameter(CaptureUnmatchedValues = true)]
装饰 IDictionary<string, object>
参数时,所有插入到标签中的不匹配参数(即没有匹配组件属性的参数)都作为键值对添加到 IDictionary
中。
此功能提供了一种简单的方法将参数转发到 HTML 元素或其他包含在组件标记中的子组件。例如,如果我们有一个 Detail
组件,它显示传递给其 Value
参数的对象的详细视图,我们可以使用此功能将所有常规 HTML 属性转发到组件的根 HTML 标签,如下例所示:
<div @attributes="AdditionalAttributes">
...
</div>
@code{
[Parameter(CaptureUnmatchedValues = true)]
public Dictionary<string, object>
AdditionalAttributes { get; set; }
[Parameter]
Public T Value {get; set;}
}
这样,通常添加到组件标签的 HTML 属性(例如,class
),都会转发到组件的根 div
并用于样式化组件:
<Detail Value="myObject" class="my-css-class"/>
下一个子节解释了如何将标记生成函数传递给组件。
模板和级联参数
Blazor 通过构建一个称为 渲染树 的数据结构来工作,该结构在 UI 发生变化时更新。在每次更改时,Blazor 定位必须渲染的 HTML 部分,并使用 渲染树 中包含的信息来更新它。
RenderFragment
代理定义了一个函数,该函数能够向 渲染树 的特定位置添加更多标记。还有 RenderFragment<T>
,它接受一个额外的参数,您可以使用它来驱动标记生成。例如,您可以将 Customer
对象传递给 RenderFragment<T>
,以便它可以渲染特定客户的全部数据。
您可以使用 C# 代码定义 RenderFragment
或 RenderFragment<T>
,但最简单的方法是在您的组件中使用 Razor 标记定义它。Razor 编译器将为您生成适当的 C# 代码:
RenderFragment myRenderFragment = @<p>The time is @DateTime.Now.</p>;
RenderFragment<Customer> customerRenderFragment =
(item) => @<p>Customer name is @item.Name.</p>;
添加标记的位置信息通过 RenderTreeBuilder
参数传递,该参数作为参数接收。您可以通过如下示例所示的方式在组件 Razor 标记中使用 RenderFragment
:
RenderFragment myRenderFragment = ...
...
<div>
...
@myRenderFragment
...
</div>
...
调用 RenderFragment
的位置定义了它将添加标记的位置,因为组件编译器能够生成正确的 RenderTreeBuilder
参数传递给它。RenderFragment<T>
代理调用如下所示:
Customer myCustomer = ...
...
<div>
...
@myRenderFragment(myCustomer)
...
</div>
...
作为函数,渲染片段可以像所有其他类型一样传递给组件参数。然而,Blazor 有一种特定的语法,使其更容易同时定义和传递渲染片段到组件:模板 语法。首先,在您的组件中定义参数:
[Parameter]
Public RenderFragment<Customer>CustomerTemplate {get; set;}
[Parameter]
Public RenderFragment Title {get; set;}
然后,当您调用客户时,您可以执行以下操作:
<Detail>
<Title>
<h5>This is a title</h5>
</Title>
<CustomerTemplate Context=customer>
<p>Customer name is @customer.Name.</p>
</CustomerTemplate>
</Detail>
每个 RenderFragment
参数都由与参数同名的标签表示。您可以在其中放置定义 RenderFragment
的标记。对于具有参数的 CustomerTemplate
,Context
关键字在标记中定义参数名称。在我们的示例中,选择的参数名称是 customer
。
当一个组件只有一个渲染片段参数,如果它被命名为ChildContent
,模板标记可以直接包裹在组件的开头和结尾标签之间:
[Parameter]
Public RenderFragment<Customer> ChildContent {get; set;}
……………
……………
<IHaveJustOneRenderFragment Context=customer>
<p>Customer name is @customer.Name.</p>
</IHaveJustOneRenderFragment>
为了熟悉组件模板,让我们修改Pages->Weather.razor
页面,使其不再使用foreach
,而是使用Repeater
组件。
让我们在Layout
文件夹上右键单击,选择添加然后Razor 组件,并添加一个新的Repeater.razor
组件。然后,用以下代码替换现有的代码:
@typeparam T
@foreach(var item in Values)
{
@ChildContent(item)
}
@code {
[Parameter]
public RenderFragment<T> ChildContent { get; set; }
[Parameter]
public IEnumerable<T> Values { get; set; }
}
组件使用泛型参数定义,以便它可以与任何IEnumerable
一起使用。现在让我们用以下内容替换Weather.razor
组件的tbody
中的标记:
<Repeater Values="forecasts" Context="forecast">
<tr>
<td>@forecast.Date.ToShortDateString()</td>
<td>@forecast.TemperatureC</td>
<td>@forecast.TemperatureF</td>
<td>@forecast.Summary</td>
</tr>
</Repeater>
由于Repeater
组件只有一个模板,并且我们将其命名为ChildContent
,因此我们可以直接在组件的开头和结尾标签内放置模板标记。运行它并验证页面是否正常工作。你已经学会了如何使用模板,以及放置在组件内部标记定义了一个模板。
一个重要的预定义模板化 Blazor 组件是CascadingValue
组件。它以不改变的方式渲染其内部放置的内容,但将类型实例传递给所有其子组件:
<CascadingValue Value="new MyOptionsInstance{...}">
……
</CascadingValue>
所有放置在CascadingValue
标签内及其所有子组件现在可以捕获通过CascadingValue
参数传入的MyOptionsInstance
实例。只要组件声明一个与MyOptionsInstance
兼容类型的公共或私有属性,并用CascadingParameter
属性装饰它就足够了:
[CascadingParameter]
private MyOptionsInstance options {get; set;}
匹配是通过类型兼容性进行的。如果有与兼容类型的其他级联参数的歧义,我们可以指定CascadingValue
组件的Name
可选参数,并将相同的名称传递给CascadingParameter
属性:[CascadingParameter("myUnique name")]
。
CascadingValue
标签还有一个IsFixed
参数,出于性能原因,尽可能应该将其设置为true
。实际上,传播级联值对于传递选项和设置非常有用,但它有很高的计算成本。
当IsFixed
设置为true
时,传播操作仅执行一次,即在涉及的内容首次渲染时执行,之后在内容的生命周期内不再尝试更新级联值。因此,IsFixed
可以在内容的生命周期内级联对象的指针没有改变的情况下使用。
级联值的例子是我们遇到过的CascadingAuthenticationState
组件,它在路由子节中将身份验证和授权信息级联到所有渲染的组件。
错误处理
默认情况下,当组件发生错误时,异常被.NET 运行时拦截,它自动使index.html
中包含的错误代码可见:
<div id="blazor-error-ui">
An unhandled error has occurred.
<a href="" class="reload">Reload</a>
<a class="dismiss"> / </a>
</div>
然而,组件错误可以被拦截并在 ErrorBoundary
组件内部本地处理。下面,对前一小节中的 Repeater
示例代码进行了修改,以本地处理可能发生在每一行的错误:
<Repeater Values="forecasts" Context="forecast">
<tr>
<ErrorBoundary>
<ChildContent>
<td>@forecast.Date.ToShortDateString()</td>
<td>@forecast.TemperatureC</td>
<td>@forecast.TemperatureF</td>
<td>@forecast.Summary</td>
</ChildContent>
<ErrorContent>
<td colspan="4" class="my-error">Nothing to see here. Sorry!</td>
</ErrorContent>
</ErrorBoundary>
</tr>
</Repeater>
标准代码放置在 ChildContent
模板中,而 ErrorContent
模板在出现错误时显示。
事件
HTML 标签和 Blazor 组件都使用属性/参数来获取输入。HTML 标签通过事件将输出提供给页面的其余部分,而 Blazor 允许将 C# 函数附加到 HTML on{事件名称}
属性。语法在 Pages->Counter.razor
组件中显示:
<p role="status">Current count: @currentCount</p>
<button class="btn btn-primary" @onclick="IncrementCount">Click me</button>
@code {
private int currentCount = 0;
private void IncrementCount()
{
currentCount++;
}
}
函数也可以作为 lambda 表达式内联传递。此外,它接受 C# 中通常的 event
参数。进一步阅读 部分包含一个链接到 Blazor 官方文档页面,该页面列出了所有支持的事件及其参数。
由于 Blazor 组件被设计为 HTML 元素的增强版本——增加了功能——它们也可以有事件。然而,与标准 HTML 元素不同,Blazor 组件中这些事件的功能和实现都是由开发者定义的。
Blazor 事件还使组件能够返回输出。组件事件定义为类型为 EventCallBack
或 EventCallBack<T>
的参数。EventCallBack
是没有参数的组件事件类型,而 EventCallBack<T>
是具有类型为 T
的参数的组件事件类型。为了触发一个事件,比如 MyEvent
,组件调用:
await MyEvent.InvokeAsync()
或者
await MyIntEvent.InvokeAsync(arg)
这些调用执行绑定到事件的处理程序,如果没有绑定处理程序则不执行任何操作。
一旦定义,组件事件可以像 HTML 元素事件一样使用,唯一的区别是无需在事件名称前加上 @
前缀,因为在 HTML 事件中 @
用于区分具有相同名称的 HTML 属性和 Blazor 添加的参数:
[Parameter]
publicEventCallback MyEvent {get; set;}
[Parameter]
publicEventCallback<int> MyIntEvent {get; set;}
...
...
<ExampleComponent
MyEvent="() => ..."
MyIntEvent = "(i) =>..." />
实际上,HTML 元素事件也是 EventCallBack<T>
事件。这就是为什么这两种事件类型的行为完全相同。EventCallBack
和 EventCallBack<T>
都是结构体,而不是委托,因为它们包含一个委托,以及一个指向必须通知事件已触发的实体的指针。正式来说,这个实体由 Microsoft.AspNetCore.Components.IHandleEvent
接口表示。不用说,所有组件都实现了这个接口。通知告知 IHandleEvent
发生了状态变化。状态变化在 Blazor 更新页面 HTML 的方式中起着基本的作用。我们将在下一小节中详细分析它们。
对于 HTML 元素,Blazor 还提供了通过向指定事件的属性添加 :preventDefault
和 :stopPropagation
指令来停止事件默认操作和事件冒泡的可能性,如下例所示:
@onkeypress="KeyHandler" @onkeypress:preventDefault="true"
@onkeypress:stopPropagation ="true"
绑定
通常,组件参数的值必须与外部变量、属性或字段保持同步。这种同步的典型应用是在输入组件或 HTML 标签中编辑的对象属性。每当用户更改输入值时,对象属性必须一致地更新,反之亦然。对象属性值必须在组件渲染后立即复制到组件中,以便用户可以编辑它。
相似的场景由参数-事件对处理。更具体地说,从一方面,属性被复制到输入组件的参数中。从另一方面,每次输入值发生变化时,都会触发一个更新属性的组件事件。这样,属性和输入值保持同步。
这种场景非常常见且有用,因此 Blazor 有特定的语法来同时定义事件并将属性值复制到参数中。这种简化的语法要求事件与参与交互的参数具有相同的名称,但后面加上Changed
后缀。
例如,假设一个组件有一个Value
参数。那么,相应的事件必须是ValueChanged
。此外,每次用户更改组件值时,组件必须通过调用await ValueChanged.InvokeAsync(arg)
来调用ValueChanged
事件。有了这个,就可以使用以下语法将名为MyObject.MyProperty
的属性与Value
属性同步:
<MyComponent @bind-Value="MyObject.MyProperty"/>
上述语法称为绑定
。Blazor 会自动处理将更新MyObject.MyProperty
属性的事件处理器附加到ValueChanged
事件。
HTML 元素的绑定以类似的方式工作,但由于开发者不能决定参数和事件的名称,因此必须使用稍微不同的约定。首先,在绑定中不需要指定参数名称,因为它始终是 HTML 输入的value
属性。因此,绑定简单地写成@bind="object.MyProperty"
。默认情况下,对象属性在change
事件上更新,但您可以通过添加@bind-event: @bind-event="oninput"
属性来指定不同的事件。
此外,HTML 输入的绑定尝试自动将输入字符串转换为目标类型。如果转换失败,输入将恢复到其初始值。这种行为相当原始,因为在出现错误的情况下,不会向用户提供错误消息,并且文化设置没有得到适当的考虑(HTML5 输入使用不变的文化,但文本输入必须使用当前文化)。我们建议仅将输入绑定到字符串目标类型。Blazor 有专门处理日期和数字的组件,当目标类型不是字符串时应该使用这些组件。我们将在Blazor 表单和验证部分描述这些组件。
为了熟悉事件,让我们编写一个组件,当用户点击确认按钮时同步输入型文本的内容。让我们右键点击Layout
文件夹,添加一个新的ConfirmedText.razor
组件。然后替换其代码如下:
<input type="text" @bind="Value" @attributes="AdditionalAttributes"/>
<button class="btn btn-secondary" @onclick="Confirmed">@ButtonText</button>
@code {
[Parameter(CaptureUnmatchedValues = true)]
public Dictionary<string, object> AdditionalAttributes { get; set; }
[Parameter]
public string Value {get; set;}
[Parameter]
public EventCallback<string> ValueChanged { get; set; }
[Parameter]
public string ButtonText { get; set; }
async Task Confirmed()
{
await ValueChanged.InvokeAsync(Value);
}
}
ConfirmedText
组件利用按钮点击事件来触发ValueChanged
事件。此外,组件使用@bind
来同步其Value
参数与 HTML 输入。值得注意的是,组件使用CaptureUnmatchedValues
将应用于其标签的所有 HTML 属性转发到 HTML 输入。这样,ConfirmedText
组件的用户可以通过简单地向组件标签添加class
和/或style
属性来对输入字段进行样式化。
现在,让我们在Pages->Index.razor
页面中使用这个组件,将以下代码放置在Home.razor
的末尾:
<ConfirmedText @bind-Value="textValue" ButtonText="Confirm" />
<p>
Confirmed value is: @textValue
</p>
@code{
private string textValue = null;
}
如果你运行项目并操作输入及其确认按钮,你会看到每次点击确认按钮时,不仅输入值被复制到textValue
页面属性中,而且组件后面的段落内容也会连贯地更新。
我们通过@bind-Value
显式地将textValue
与组件同步,但谁负责保持textValue
与段落内容的一致性?答案可以在下一个子节中找到。
Blazor 如何更新 HTML
当我们使用类似@model.property
的方式在 Razor 标记中写入变量、属性或字段的值时,Blazor 不仅会在组件渲染时渲染变量、属性或字段的实际值,还会尝试在每次该值发生变化时更新 HTML,这个过程称为变更检测。变更检测是所有主要 SPA 框架的一个特性,但 Blazor 实现它的方式非常简单且优雅。
基本思想是,一旦所有 HTML 都已渲染,变化可能仅由于事件内部执行代码引起。这就是为什么EventCallBack
和EventCallBack<T>
包含对IHandleEvent
的引用。当组件将处理程序绑定到事件时,Razor 编译器创建一个EventCallBack
或EventCallBack<T>
,将绑定到事件的函数及其定义函数的组件(IHandleEvent
)传递给结构构造函数。
一旦处理程序的代码执行完毕,Blazor 运行时会通知IHandleEvent
可能已更改。实际上,处理程序代码只能更改处理程序定义的组件中变量、属性或字段的值。反过来,这会触发以组件为根的变更检测过程。Blazor 验证组件 Razor 标记中哪些变量、属性或字段已更改,并更新相关的 HTML。
如果更改的变量、属性或字段是另一个组件的输入参数,则该组件生成的 HTML 可能也需要更新。因此,从该组件根生的另一个更改检测过程被递归触发。
之前概述的算法只有在满足以下条件时才能发现所有相关更改:
-
事件处理器中不会引用属于其他组件的数据结构。
-
组件的所有输入都通过其参数传入,而不是通过方法调用或其他公共成员。
当由于前一个条件之一失败而未检测到更改时,开发者必须手动声明组件的可能更改。这可以通过调用StateHasChanged()
组件方法来完成。由于此调用可能会导致页面 HTML 的变化,其执行不能异步进行,而必须在 HTML 页面的 UI 线程中排队。这是通过将要执行的功能传递给InvokeAsync
组件方法来完成的。
总结来说,要执行的指令是await InvokeAsync(StateHasChanged)
。
下一个子节通过分析组件的生命周期及其相关生命周期方法来结束对组件的描述。
组件生命周期
每个组件生命周期事件都有一个相关的方法。一些方法既有同步版本也有异步版本,一些方法只有异步版本,还有一些方法只有同步版本。
组件的生命周期从将传递给组件的参数复制到相关组件属性开始。您可以通过重写以下方法来自定义此步骤:
public override async Task SetParametersAsync(ParameterView parameters)
{
await ...
await base.SetParametersAsync(parameters);
}
通常,自定义包括修改额外的数据结构,因此会调用基方法以执行在相关属性中复制参数的默认操作。
之后,还有与两个方法关联的组件初始化:
protected override void OnInitialized()
{
...
}
protected override async Task OnInitializedAsync()
{
await ...
}
它们在组件生命周期中只调用一次,在组件被创建并添加到渲染树之后立即调用。请在此处放置任何初始化代码,而不是在组件构造函数中,因为这将提高组件的可测试性。这是因为在那里,所有参数都已设置,未来的 Blazor 版本可能会池化和重用组件实例。
如果初始化代码订阅了一些事件或执行在组件销毁时需要清理的操作,则实现IDisposable
,并将所有清理代码放在其Dispose
方法中。每当组件实现IDisposable
时,Blazor 都会在销毁它之前调用其Dispose
方法。
在组件初始化之后,并且每次组件参数发生变化时,都会调用以下两个方法:
protected override async Task OnParametersSetAsync()
{
await ...
}
protected override void OnParametersSet()
{
...
}
它们是更新依赖于组件参数值的数组的正确位置。
之后,组件将被渲染或重新渲染。您可以通过覆盖ShouldRender
方法来防止组件在更新后重新渲染:
protected override bool ShouldRender()
{
...
}
只有当你确定组件的 HTML 代码将改变时,才重新渲染组件,这是一种在组件库实现中使用的先进优化技术。
组件渲染阶段还涉及到其子组件的调用。因此,只有当所有子组件都完成它们的渲染后,组件渲染才被认为是完整的。当渲染完成后,以下方法会被调用:
protected override void OnAfterRender(bool firstRender)
{
if (firstRender)
{
}
...
}
protected override async Task OnAfterRenderAsync(bool firstRender)
{
if (firstRender)
{
await...
...
}
await ...
}
由于在调用上述方法时,所有组件的 HTML 都已更新,所有子组件都已执行它们的所有生命周期方法,因此上述方法是执行以下操作的正确位置:
-
调用操作生成的 HTML 的 JavaScript 函数。JavaScript 调用在 JavaScript 互操作性子部分中描述。
-
处理附加到参数或由子组件级联的参数的信息。实际上,类似于标签页的组件和其他组件可能需要在根组件中注册它们的一些子部分,因此根组件通常级联一个数据结构,其中一些子组件可以注册。在
AfterRender
和AfterRenderAsync
中编写的代码可以依赖于所有部分都已完成注册的事实。
下一个部分描述了 Blazor 工具用于收集用户输入。
Blazor 表单和验证
与所有主要的 SPA 框架类似,Blazor 提供了特定的工具来处理用户输入,同时通过错误消息和即时视觉提示向用户提供有效的反馈。
在经典的 HTML 网站上,HTML 表单用于收集输入、验证它并将其发送到服务器。在客户端框架中,提交表单时不会将数据发送到服务器,但表单保留了它们的验证目的。更具体地说,它们充当验证单元,即作为必须一起验证的输入的容器,因为它们属于一个独特任务。因此,当点击提交按钮时,会执行整体验证,并通过事件通知结果。这样,开发者可以定义在出现错误时该做什么,以及当用户成功完成输入时应采取哪些行动。
值得注意的是,在客户端执行的验证并不保证数据完整性,因为恶意用户可以轻易地破解所有客户端验证规则,因为他们可以完全访问他们浏览器中的客户端代码。客户端验证的目的只是向用户提供即时反馈。
因此,验证步骤必须在服务器端重复执行,以确保数据完整性。
服务器端和客户端验证可以使用在 Blazor 客户端和服务器之间共享的相同代码进行。实际上,ASP.NET Core REST API 和 Blazor 都支持基于验证属性的验证,因此只需将带有验证属性的 ViewModels 放在一个由两个项目引用的库中,就可以在 Blazor 和服务器项目之间共享相同的 ViewModels。ASP.NET Core 验证在 第十七章,展示 ASP.NET Core 的 服务器端和客户端验证 部分中讨论。
整个工具集被称为 Blazor 表单,它由一个名为 EditForm
的表单组件、各种输入组件、数据注释验证器、验证错误摘要和验证错误标签组成。
EditForm
通过在表单内部级联的 EditContext
类的实例来管理所有输入组件的状态。协调来自输入组件和数据注释验证器与该 EditContext
实例的交互。验证摘要和错误消息标签不参与协调,但会注册到某些 EditContext
事件以了解错误。
EditForm
必须传递一个对象,其属性必须在 Model
参数中渲染。值得注意的是,绑定到嵌套属性的输入组件不会被验证,因此 EditForm
必须传递一个扁平化的 ViewModel。EditForm
创建一个新的 EditContext
实例,将其在 Model
参数中接收到的对象传递给其构造函数,并级联它以便它可以与表单内容交互。
您也可以直接在 EditForm
的 EditContext
参数中传递一个自定义的 EditContext
实例,而不是在 Model
参数中传递对象,在这种情况下,EditForm
将使用您的自定义副本而不是创建一个新实例。通常,您这样做是为了订阅 EditContext
的 OnValidationStateChanged
和 OnFieldChanged
事件。
当 EditForm
与一个 提交 按钮一起提交且没有错误时,表单将调用其 OnValidSubmit
回调,您可以在其中放置使用和处理用户输入的代码。如果存在验证错误,它们将被显示,并且表单将调用其 OnInvalidSubmit
回调。
每个输入的状态都反映在自动添加到它们的某些 CSS 类中,即 valid
、invalid
和 modified
。您可以使用这些类为用户提供适当的视觉反馈。默认的 Blazor 模板已经为它们提供了某些 CSS。
下面是一个典型的表单:
<EditForm Model="FixedInteger"OnValidSubmit="@HandleValidSubmit" >
<DataAnnotationsValidator />
<ValidationSummary />
<div class="form-group">
<label for="integerfixed">Integer value</label>
<InputNumber @bind-Value="FixedInteger.Value"
id="integerfixed" class="form-control" />
<ValidationMessage For="@(() => FixedInteger.Value)" />
</div>
<button type="submit" class="btn btn-primary"> Submit</button>
</EditForm>
标签是一个标准的 HTML 标签,而InputNumber
是 Blazor 特定的用于数字属性的组件。ValidationMessage
是仅在验证错误发生时出现的错误标签。默认情况下,它使用validation-message
CSS 类进行渲染。与错误消息关联的属性通过不带参数的 lambda 表达式传递到for
参数中,如示例所示。
DataAnnotationsValidator
组件添加了基于通常的.NET 验证属性(如RangeAttribute
、RequiredAttribute
等)的验证。您还可以通过从ValidationAttribute
类继承来编写自定义验证属性。
您可以在验证属性中提供自定义错误消息。如果它们包含一个{0}
占位符,则该占位符将被在DisplayAttribute
中声明的属性显示名称填充,如果找到了,否则将使用属性名称。
与InputNumber
组件一起,Blazor 还支持用于string
属性的InputText
组件、用于在 HTMLtextarea
中编辑的string
属性的InputTextArea
组件、用于bool
属性的InputCheckbox
组件以及将DateTime
和DateTimeOffset
渲染为日期的InputDate
组件。它们的工作方式与InputNumber
组件完全相同。没有组件可用于其他 HTML5 输入类型。特别是,没有组件可用于渲染时间或日期和时间,或用于使用range
小部件渲染数字。
如果您需要作为 Blazor 输入表单组件不可用的 HTML5 输入,您可以选择自行实现它们或使用支持它们的第三方库。
您可以通过从InputBase<TValue>
类继承并重写BuildRenderTree
、FormatValueAsString
和TryParseValueFromString
方法来实现渲染时间或日期和时间。InputNumber
组件的源代码展示了如何做到这一点:github.com/dotnet/aspnetcore/blob/main/src/Components/Web/src/Forms/InputNumber.cs
。您还可以使用Blazor WebAssembly 第三方工具部分中描述的第三方库。
Blazor 还有一个用于渲染select
的特定组件,其工作方式如下面的示例所示:
<InputSelect @bind-Value="order.ProductColor">
<option value="">Select a color ...</option>
<option value="Red">Red</option>
<option value="Blue">Blue</option>
<option value="White">White</option>
</InputSelect>
从.NET 6 开始,InputSelect
也可以绑定到IEnnumerable<T>
属性,在这种情况下,它被渲染为多选。
您还可以通过使用InputRadioGroup
和InputRadio
组件来使用单选组渲染枚举,如下面的示例所示:
<InputRadioGroup Name="color" @bind-Value="order.Color">
<InputRadio Name="color" Value="AllColors.Red" /> Red<br>
<InputRadio Name="color" Value="AllColors.Blue" /> Blue<br>
<InputRadio Name="color" Value="AllColors.White" /> White<br>
</InputRadioGroup>
最后,Blazor 还提供了一个InputFile
组件以及处理和上传文件的全部工具。这里不会详细介绍,但进一步阅读部分包含了指向官方文档的链接。
下一节描述了用于修改宿主页面<head>
标签的 Blazor 工具。
从 Blazor 组件修改 HTML 内容
由于整个组件树都放置在 index.html
主页的 body
中,组件标记无法直接访问 index.html
主页的 <head>
标签。当开发者想要将浏览器标签中显示的标题适配到实际显示的 Blazor 页面时,修改 <head>
标签的内容是必要的。实际上,这个标题包含在 <head>
标签中:
<head>
<title>This is the title shown in the browser tab</title>
...
</head>
由于这个原因,.NET 6 版本的 Blazor 引入了从 Blazor 组件内部修改宿主页面 <head>
标签的特定结构。
首先,我们必须通知 Blazor 应用程序如何访问 <head>
标签内容。这通过 Program.cs
中与指定 Blazor 应用程序根相同的技巧来完成:
var builder = WebAssemblyHostBuilder.CreateDefault(args);
builder.RootComponents.Add<App>("#app");
// The line below adds support for modifying
// the <head> tag content
builder.RootComponents.Add<HeadOutlet>("head::after");
...
之后,每个组件都可以通过在 PageTitle
组件实例中指定新的字符串来替换 HTML 标题:
<PageTitle>This string replaces the page title</PageTitle>
此外,每个组件还可以通过将其放置在 HeadContent
实例中来向 <head>
标签追加其他 HTML 内容:
<HeadContent>
<meta name="description" content="This is a page description">
</HeadContent>
在本节中,你学习了编写一个不与服务器交换数据的简单 Blazor 应用程序所需的所有内容。下一节将分析一些高级功能,并使你能够与服务器交互以处理身份验证、授权等。
Blazor 高级功能
本节提供了关于各种 Blazor 高级功能的简短描述,并按子节组织:
-
对组件和 HTML 元素的引用
-
JavaScript 互操作性
-
全球化和本地化
-
身份验证和授权
-
与服务器的通信
-
AOT 编译
由于空间有限,我们无法提供每个功能的全部细节,但详细内容在 进一步阅读 部分的链接中有所涵盖。我们首先介绍如何引用在 Razor 标记中定义的组件和 HTML 元素。
对组件和 HTML 元素的引用
有时,我们可能需要一个组件的引用来调用其某些方法。例如,对于实现模态窗口的组件来说就是这样:
<Modal @ref="myModal">
...
</Modal>
...
<button type="button" class="btn btn-primary"
@onclick="() => myModal.Show()">
Open modal
</button>
...
@code{
private Modal myModal {get; set;}
...
}
如前例所示,引用是通过 @ref
指令捕获的。相同的 @ref
指令也可以用来捕获 HTML 元素的引用。HTML 引用具有 ElementReference
类型,通常用于在 HTML 元素上调用 JavaScript 函数,如下一小节所述。
JavaScript 互操作性
由于 Blazor 并没有将所有 JavaScript 功能暴露给 C# 代码,并且利用庞大的 JavaScript 代码库很方便,有时需要调用 JavaScript 函数。Blazor 通过 IJSRuntime
接口允许这样做,该接口可以通过依赖注入将到组件中。
一旦我们有了 IJSRuntime
实例,就可以像下面这样调用返回值的 JavaScript 函数:
T result = await jsRuntime.InvokeAsync<T>(
"<name of JavaScript function or method>", arg1, arg2....);
不返回任何参数的函数可以像下面这样调用:
await jsRuntime.InvokeAsync(
"<name of JavaScript function or method>", arg1, arg2....);
参数可以是基本类型或可以序列化为 JSON 的对象,而 JavaScript 函数的名称是一个字符串,可以包含表示访问属性、子属性和方法名的点,例如 "myJavaScriptObject.myProperty.myMethod"
字符串。
因此,例如,我们可以使用以下代码在浏览器本地存储中保存一个字符串:
await jsRuntime
.InvokeVoidAsync("window.localStorage.setItem",
myLocalStorageKey, myStringToSave);
参数也可以是使用 @ref
指令捕获的 ElementReference
实例,在这种情况下,它们在 JavaScript 端作为 HTML 元素接收。
被调用的 JavaScript 函数必须在 Index.html
文件中定义,或者是在 Index.html
中引用的 JavaScript 文件中定义。
如果你正在使用 Razor 库项目编写组件库,JavaScript 文件可以与 CSS 文件一起作为资源嵌入到 DLL 库中。你只需在项目根目录中添加一个 wwwroot
文件夹,并将所需的 CSS 和 JavaScript 文件放置在该文件夹或其子文件夹中。之后,这些文件可以按照以下方式引用:
_content/<dll name>/<file path relative to wwwroot>
因此,如果文件名为 myJsFile.js
,DLL 名称是 MyCompany.MyLibrary
,并且文件放置在 wwwroot
内的 js
文件夹中,那么它的引用将是:
_content/MyCompany.MyLibrary/js/myJsFile.js
值得指出的是,我们在这章前面提到的所有添加到组件(CSS 隔离)中的 CSS 文件都被编译成一个唯一的 CSS 文件,该文件作为 DLL 资源添加。此文件必须在 index.html
页面上引用,如下所示:
<assembly name>.Client.styles.css
如果你的 JavaScript 文件以 ES6 模块的形式组织,你可以避免在 Index.html
中引用它们,并可以直接加载这些模块,如下所示:
// _content/MyCompany.MyLibrary/js/myJsFile.js JavaScript file
export function myFunction ()
{
...
}
...
//C# code
var module = await jsRuntime.InvokeAsync<JSObjectReference>(
"import", "./_content/MyCompany.MyLibrary/js/myJsFile.js");
...
T res= await module.InvokeAsync<T>("myFunction")
此外,还可以通过以下步骤从 JavaScript 代码中调用 C# 对象的实例方法:
-
假设 C# 方法名为
MyMethod
。使用[JSInvokable]
属性装饰MyMethod
方法。 -
将 C# 对象封装在
DotNetObjectReference
实例中,并通过 JavaScript 调用将其传递给 JavaScript:var objRef = DotNetObjectReference.Create(myObjectInstance); //pass objRef to JavaScript .... //dispose the DotNetObjectReference objRef.Dispose()
-
在 JavaScript 端,假设 C# 对象存储在一个名为
dotnetObject
的变量中。然后,我们只需调用:dotnetObject.invokeMethodAsync("<dll name>", "MyMethod", arg1, ...). then(result => {...})
Awesome Blazor 项目(github.com/AdrienTorris/awesome-blazor
)列出了许多使用 JavaScript 的互操作性来构建知名 JavaScript 库的 .NET 包装器的开源项目。在那里,你可以找到 3D 图形 JavaScript 库的包装器、绘图 JavaScript 库等。
下一个部分将解释如何处理内容和数字/日期本地化。
全球化和本地化
一旦 Blazor 应用程序启动,应用程序文化和应用程序 UI 文化都将设置为浏览器文化。然而,开发者可以通过将选定的文化赋值给CultureInfo.DefaultThreadCurrentCulture
和CultureInfo.DefaultThreadCurrentUICulture
来更改这两个设置。通常,应用程序允许用户选择其支持的文化之一,或者如果浏览器文化被支持,则接受浏览器文化;否则,将回退到支持的文化。实际上,只支持合理数量的文化是可能的,因为所有应用程序字符串都必须翻译成所有支持的文化。
如果应用程序必须支持单一文化,可以在构建宿主之后但在运行宿主之前,在program.cs
中将此文化一次性设置。
这可以通过将await builder.Build().RunAsync()
替换为以下内容来完成:
var host = builder.Build();
…
CultureInfo.DefaultThreadCurrentCulture = new CultureInfo(…);
CultureInfo.DefaultThreadCurrentUICulture = new CultureInfo(…);
…
await host.RunAsync();
如果应用程序必须支持多种语言,代码类似,但必须从浏览器本地存储中获取文化信息,用户必须在某些应用程序选项页中提供存储该信息的选项。
一旦设置了CurrentCulture
,日期和数字将自动根据所选文化的约定进行格式化。对于 UI 文化,开发者必须手动提供包含所有支持文化中所有应用程序字符串的翻译的资源文件。Blazor 使用相同的本地化/全球化技术,因此请参阅第十七章“展示 ASP.NET Core”中的ASP.NET Core 全球化部分以获取更多详细信息。
使用资源文件有两种方式。使用第一种选项,您创建一个资源文件,例如myResource.resx
,然后添加所有特定语言的文件:myResource.it.resx
、myResource.pt.resx
等。在这种情况下,Visual Studio 创建一个名为myResource
的静态类,其静态属性是每个资源文件的键。这些属性将自动包含与当前 UI 文化对应的本地化字符串。您可以在任何地方使用这些静态属性,并且可以使用由资源类型和资源名称组成的对来设置验证属性或其他属性的ErrorMessageResourceType
和ErrorMessageResourceName
属性。这样,属性将使用自动本地化的字符串。
使用第二种选项,您只需添加特定语言的资源文件(例如myResource.it.resx
、myResource.pt.resx
等)。在这种情况下,Visual Studio 不会为资源文件创建任何关联的类,您可以使用资源文件,就像在 ASP.NET Core MVC 视图中使用它们一样,与注入到组件中的IStringLocalizer
和IStringLocalizer<T>
一起使用(参见第十七章“展示 ASP.NET Core”中的ASP.NET Core 全球化部分)。
认证和授权
在 路由 子节中,我们讨论了 CascadingAuthenticationState
和 AuthorizeRouteView
组件如何防止未经授权的用户访问带有 [Authorize]
属性保护的页面。让我们更深入地探讨页面授权的工作原理。
在 .NET 应用程序中,身份验证和授权信息通常包含在 ClaimsPrincipal
实例中。在服务器应用程序中,当用户登录时构建此实例,从数据库中获取所需信息。在 Blazor WebAssembly 中,此类信息必须由某个远程服务器提供,该服务器还负责处理 SPA 身份验证。由于有几种方法可以为 Blazor WebAssembly 应用程序提供身份验证和授权,Blazor 定义了 AuthenticationStateProvider
抽象。
身份验证和授权提供程序继承自 AuthenticationStateProvider
抽象类,并重写其 GetAuthenticationStateAsync
方法,该方法返回 Task<AuthenticationState>
,其中 AuthenticationState
包含身份验证和授权信息。实际上,AuthenticationState
只包含一个具有 ClaimsPrincipal
的 User
属性。
一旦我们定义了 AuthenticationStateProvider
的具体实现,我们必须在应用程序的 Program.cs
文件中的依赖引擎容器中注册它:
builder.services.AddScoped<AuthenticationStateProvider,
MyAuthStateProvider>();
在描述了 Blazor 如何使用由已注册的 AuthenticationStateProvider
提供的身份验证和授权信息之后,我们将回到 Blazor 提供的预定义 AuthenticationStateProvider
实现。
CascadingAuthenticationState
组件调用已注册的 AuthenticationStateProvider
的 GetAuthenticationStateAsync
方法,并将返回的 Task<AuthenticationState>
进行级联。您可以在组件中定义以下 [CascadingParameter]
来拦截此级联值:
[CascadingParameter]
private Task<AuthenticationState> myAuthenticationStateTask { get; set; }
……
ClaimsPrincipal user = (await myAuthenticationStateTask).User;
然而,Blazor 应用程序通常使用 AuthorizeRouteView
和 AuthorizeView
组件来控制用户对内容的访问。
AuthorizeRouteView
阻止用户在不满足页面 [Authorize]
属性规定的情况下访问页面;否则,将渲染 NotAuthorized
模板的内容。AuthorizeRouteView
还有一个 Authorizing
模板,在检索用户信息时显示。
AuthorizeView
可以在组件中使用,以仅向授权用户显示其包含的标记。它包含与 [Authorize]
属性相同的 Roles
和 Policy
参数,您可以使用这些参数来指定用户必须满足的约束条件才能访问内容:
<AuthorizeView Roles="Admin,SuperUser">
//authorized content
</AuthorizeView>
AuthorizeView
还可以指定 NotAuthorized
和 Authorizing
模板:
<AuthorizeView>
<Authorized>
...
</Authorized>
<Authorizing>
...
</Authorizing>
<NotAuthorized>
...
</NotAuthorized>
</AuthorizeView>
如果在创建 Blazor WebAssembly 项目时添加了授权,则将以下方法调用添加到应用程序依赖引擎中:
builder.Services.AddOidcAuthentication(options =>
{
// Configure your authentication provider options here.
// For more information, see https://aka.ms/blazor-standalone-auth
builder.Configuration.Bind("Local", options.ProviderOptions);
});
此方法添加了一个 AuthenticationStateProvider
,它从 OAuth 提供程序的认证 cookie 中提取用户信息。使用 OAuth 协议与 OAuth 提供程序的交互是通过我们在此章的 加载和启动应用程序 子节中看到的 AuthenticationService.js
JavaScript 文件来完成的。OAuth 提供程序端点以携带令牌的形式返回用户信息,然后这些令牌也可以用来认证与服务器 Web API 的通信。携带令牌在 第十五章 的 REST 服务授权和认证 和 ASP.NET Core 服务授权 部分中进行了详细描述。Blazor WebAssembly 通信将在下一子节中描述。
之前的代码从配置文件中获取 OAuth 参数,该配置文件必须添加为 wwwroot/appsettings.json
:
{
"Local": {
"Authority": "{AUTHORITY}",
"ClientId": "{CLIENT ID}"
}
}
如果您使用 Google 作为认证提供程序,AUTHORITY
是 https://accounts.google.com/
,而 CLIENT ID
是您在将 Blazor 应用程序注册到 Google 开发者计划时收到的客户端 ID。
Google 需要一些额外的参数,即认证过程完成后返回的返回 URL 以及注销后返回的页面:
{
"Local": {
"Authority": "https://accounts.google.com/",
"ClientId": "2...7-e...q.apps.googleusercontent.com",
"PostLogoutRedirectUri": "https://localhost:5001/authentication/logout-callback",
"RedirectUri": "https://localhost:5001/authentication/login-callback",
"ResponseType": "id_token"
}
}
其中 https://localhost:5001
必须替换为 Blazor 应用的实际域名。
在任何认证之前或认证失败时,会创建一个未认证的 ClaimsPrincipal
。这样,当用户尝试访问由 [Authorize]
属性保护的页面时,AuthorizeRouteView
组件会调用 RedirectToLogin
组件,后者反过来导航到 Authentication.razor
页面,并通过其 action
路由参数传递登录请求:
@page "/authentication/{action}"
@using Microsoft.AspNetCore.Components.WebAssembly.Authentication
<RemoteAuthenticatorView Action="@Action" />
@code{
[Parameter] public string Action { get; set; }
}
RemoteAuthenticatorView
充当与常规 ASP.NET Core 用户登录/注册系统的接口,并且每当它收到要执行的操作时,它将用户从 Blazor 应用程序重定向到适当的 ASP.NET Core 服务器页面(登录、注册、注销和用户资料)。
一旦用户登录,他们将被重定向到导致登录请求的 Blazor 应用程序页面。重定向 URL 是由 BlazorReview.Layout->RedirectToLogin.razor
组件计算的,它从中提取它并将其传递给 RemoteAuthenticatorView
组件。这次,AuthenticationStateProvider
能够从登录操作创建的认证 cookie 中获取用户信息。
您可以为您的 Blazor 应用程序设计一个自定义的 OAuth 提供程序,或者设计一种完全自定义的方式,通过内部登录 Blazor 页面获取携带令牌,而无需离开 Blazor 应用程序。在后一种情况下,您必须提供一个 AuthenticationStateProvider
的自定义实现。有关这些高级自定义场景的更多详细信息,请参阅 进一步阅读 部分的官方文档参考。
在学习了如何使用外部 OAuth 提供者进行身份验证以及如何在 Blazor 应用程序中处理授权之后,我们准备好学习如何交换数据和如何使用 REST API 进行身份验证。
下一个子节描述了 Blazor WebAssembly 特定的 HttpClient
类及其相关类型的实现。
与服务器的通信
Blazor WebAssembly 支持与 第十五章 中 使用 .NET 应用服务架构 部分的 .NET HTTP 客户端 中描述的相同的 .NET HttpClient
和 HttpClientFactory
类。然而,由于浏览器的通信限制,它们的实现不同,并且依赖于浏览器的 fetch API。
事实上,出于安全原因,所有浏览器都不允许直接打开通用的 TCP/IP 连接,但强制所有服务器通信通过 Ajax 或通过 fetch API。
这样,当您尝试与浏览器下载的 SPA 所在域不同的 URL 进行通信时,浏览器会自动切换到 CORS 协议,从而通知服务器通信是由下载自不同域的浏览器应用程序启动的,并且可能是一个钓鱼网站。
服务器反过来只接受来自预先在其代码中列出的知名域的 CORS 通信。这样,服务器可以确信请求不会来自钓鱼网站。
在 第十五章,使用 .NET 应用服务架构 中,我们分析了如何利用 HttpClientFactory
来定义类型化客户端。您也可以使用完全相同的语法在 Blazor 中定义类型化客户端。
然而,由于经过身份验证的 Blazor 应用程序需要在每个请求中将身份验证过程中创建的载体令牌发送到应用程序服务器,因此通常定义一个命名客户端,如下所示:
builder.Services.AddHttpClient("BlazorReview.ServerAPI", client =>
client.BaseAddress = new Uri("https://<web api URL>"))
.AddHttpMessageHandler<BaseAddressAuthorizationMessageHandler>();
AddHttpMessageHandler
添加了一个 DelegatingHandler
,即 DelegatingHandler
抽象类的子类。DelegatingHandler
的实现通过重写其 SendAsync
方法来处理每个请求和每个相关响应:
protected override async Task<HttpResponseMessage> SendAsync(
HttpRequestMessage request,
CancellationToken cancellationToken)
{
//modify request
...
HttpResponseMessage= response = await base.SendAsync(
request, cancellationToken);
//modify response
...
return response;
}
如果这个载体令牌已过期或根本找不到,它将尝试通过使用用户手动使用 OAuth 提供者登录时收到的身份验证 cookie 来获取一个新的载体令牌。这样,它可以在不请求新的手动登录的情况下获得一个新的载体令牌。如果这次尝试也失败,将抛出 AccessTokenNotAvailableException
异常。通常,这个异常会被捕获并用于通过调用其 Redirect
方法来触发对登录页面的重定向,如下所示:
try
{
//server call here
}
catch (AccessTokenNotAvailableException exception)
{
exception.Redirect();
}
如果 Blazor 应用程序和 Web API 都部署在同一域的不同子文件夹中,Blazor 请求将不会使用 CORS 协议,因此它们会被服务器自动接受。否则,ASP.NET Core 服务器必须启用 CORS 请求,并且必须将 Blazor 应用程序 URL 列在允许的 CORS 域中,例如:
builder.Services.AddCors(o => {
o.AddDefaultPolicy(pbuilder =>
{
pbuilder.AllowAnyMethod();
pbuilder.WithHeaders(HeaderNames.ContentType, HeaderNames.Authorization);
pbuilder.WithOrigins(https://<Blazor client url>, …, https://<Another client url>);
});
});
然后,你必须在 ASP.NET Core 管道中放置 app.UseCors()
中间件。
在 ReviewBlazor
应用程序的 Weather
页面上显示的示例数据是从同一 Blazor 应用程序的 wwwroot/sample-data
文件夹中的静态文件下载的,因此发出的是正常、非 CORS 请求,不需要携带令牌来请求。因此,Weather
页面使用在 Program.cs
中定义的默认 HttpClient
:
builder.Services.AddScoped(sp => new HttpClient { BaseAddress = new Uri(builder.HostEnvironment.BaseAddress) });
下一个子节将解释如何使用 Blazor AOT 编译来提高计算密集型应用程序的性能。
AOT 编译
一旦在浏览器中上传,.NET 程序集在其第一次执行时不会像其他平台那样进行 JIT 编译。相反,它们由一个非常快速的解释器进行解释。只有 .NET 运行时是预编译并直接上传到浏览器中的 WebAssembly。
JIT 编译被避免,因为这会显著增加应用程序的启动时间,而由于应用程序下载大小(约 10 MB)已经相当高,这个启动时间已经很高了。反过来,下载大小之所以高,是因为任何 Blazor 应用程序都需要 .NET 库才能正常运行。
为了减少下载大小,在发布模式下的编译过程中,Blazor .NET 程序集会进行树摇操作,以删除所有未使用的类型和方法。然而,尽管有这种树摇操作,典型的下载大小仍然相当高。通过 .NET 运行时的默认缓存,可以实现良好的下载改进,将下载大小减少到 2-4 MB。然而,当第一次访问 Blazor 应用程序时,下载大小仍然很高。
从 .NET 6 开始,Blazor 提供了 JIT 编译的替代方案:提前编译(AOT)。使用 AOT,所有应用程序程序集在应用程序发布期间编译成一个唯一的 WebAssembly 文件。
AOT 编译非常慢,可能需要 10-30 分钟,即使是小型应用程序也是如此。另一方面,它只需在应用程序发布时执行一次,因此编译时间不会影响应用程序的启动时间。
不幸的是,AOT 编译将下载大小增加了一倍多,因为编译后的代码比源 .NET 代码更冗长。因此,AOT 应仅用于性能关键的应用程序,这些应用程序可以以更高的启动时间为代价来换取更好的性能。
.NET WebAssembly AOT 编译需要一个额外的构建工具,必须将其作为可选的 .NET SDK 工作负载安装才能使用。第一次安装,可以使用以下 shell 命令:
dotnet workload install wasm-tools
相反,当安装新的.NET 版本时,我们只需运行以下命令来更新之前安装的所有工作负载:
dotnet workload update
一旦安装了 AOT 工作负载,就可以通过将 <RunAOTCompilation>true</RunAOTCompilation>
声明添加到 Blazor 项目文件中,按如下所示启用每个项目的 AOT 编译:
<Project Sdk="Microsoft.NET.Sdk.BlazorWebAssembly">
<PropertyGroup>
<TargetFramework>net8.0</TargetFramework>
<RunAOTCompilation>true</RunAOTCompilation>
</PropertyGroup>
...
下一个部分简要讨论了一些最相关的第三方工具和库,它们完善了 Blazor 的官方功能,并有助于提高 Blazor 项目中的生产力。
Blazor WebAssembly 的第三方工具
尽管 Blazor 是一个年轻的产品,但其第三方工具和产品生态系统已经相当丰富。在开源、免费产品中,值得提一下的是 Blazorise 项目 (github.com/stsrki/Blazorise
),它包含各种免费的基本 Blazor 组件(输入、标签页、模态框等),可以使用各种 CSS 框架进行样式化,如 Bootstrap 和 Material。它还包含一个简单的可编辑网格和一个简单的树视图。
值得一提的还有 BlazorStrap (github.com/chanan/BlazorStrap
),它包含了所有 Bootstrap 4 组件和小部件的纯 Blazor 实现。
在所有商业产品中,值得提一下的是 Blazor Controls Toolkit (blazorct.azurewebsites.net/
),这是一个用于实现商业应用的完整工具集。它包含所有输入类型及其回退,以防浏览器不支持;所有 Bootstrap 组件;其他基本组件;一个完整、高级的拖放框架;以及高级可定制和可编辑的组件,如详细视图、详细列表、网格和树重复器(树视图的泛化)。所有组件都基于一个复杂的元数据表示系统,使用数据注释和内联 Razor 声明,使用户能够以声明性方式设计标记。
此外,它还包含额外的复杂验证属性、撤销用户输入的工具、计算更改并发送到服务器的工具、基于 OData 协议的复杂客户端和服务器端查询工具,以及维护和保存整个应用程序状态的工具。
值得一提的是 bUnit 开源项目 (github.com/egil/bUnit
),它提供了测试 Blazor 组件所需的所有工具。
Awesome Blazor 项目 (github.com/AdrienTorris/awesome-blazor
) 列出了数千个开源和商业 Blazor 资源,例如教程、文章、库和示例项目。
基于 WWTravelClub 书籍用例的 Blazor WebAssembly 应用程序的完整示例可以在 第二十一章,案例研究 的 使用客户端技术 部分找到。下一个部分将解释如何使用 Blazor 实现跨平台应用程序。实际代码包含在书籍 GitHub 仓库中与该章节相关的文件夹中。
.NET MAUI Blazor
.NET MAUI 是微软推荐的实现跨平台应用程序的选择。实际上,.NET MAUI 应用程序可以即时编译为所有 Windows、Android、iOS 和其他基于 Linux 的设备。.NET MAUI 包含所有目标设备的通用抽象,同时通过提供包含特定平台特性的平台特定库来利用每个设备的独特性。
我们将不会详细描述 .NET MAUI,但在对 .NET MAUI 简短介绍之后,我们将专注于 .NET MAUI Blazor。这样,通过学习 Blazor,你将能够开发单页应用程序、渐进式应用程序和跨平台应用程序。
什么是 .NET MAUI?
.NET MAUI 扩展了 Xamarin.Forms 从 Android 和 iOS 到 Windows 和 macOS 的跨平台能力。因此,.NET MAUI 是一个用于使用 C# 创建原生移动和桌面应用程序的跨平台框架。
.NET MAUI 的基础是 Xamarin.Forms。实际上,微软提供了一份指南,用于将原始的 Xamarin.Forms 应用程序迁移到 .NET MAUI,如下链接所示:docs.microsoft.com/en-us/dotnet/maui/get-started/migrate
。然而,.NET MAUI 是为了成为任何原生/桌面应用程序开发的新一代框架而设计的:
图 19.2:.NET MAUI 高级架构
Xamarin.Forms 和 .NET MAUI 之间存在一个特定的区别。在 Xamarin.Forms 中,为任何我们希望在应用程序中发布的设备都有一个特定的原生项目,而在 .NET MAUI 中,这种方法基于一个针对多个平台的项目。
下一个部分将解释如何使用 .NET MAUI 将 Blazor 应用程序作为原生应用程序运行。
使用 Blazor 开发原生应用程序
当你安装 Visual Studio 2022 时,.NET MAUI 并不是默认安装的,但你需要选择 Visual Studio 安装程序中的 .NET MAUI 工作负载。因此,如果你在 Visual Studio 安装中启动新项目时看不到 MAUI 项目,你需要运行 Visual Studio 安装程序并修改你的现有安装,添加 .NET MAUI 工作负载。
一旦安装了 MAUI 工作负载,在项目创建向导中,你应该能够选择 C#/所有平台/MAUI,然后选择 .NET MAUI Blazor 混合应用程序,如图所示:
图 19.3:创建一个 .NET MAUI Blazor 应用程序
创建一个名为 MAUIBlazorReview
的 MAUI Blazor 项目。MAUI Blazor 应用程序包含通常的 Layout
和 Pages
文件夹,你可以在这里放置你的 Blazor 组件和页面,但它们被放置在 Components
文件夹内部。它还包含一个包含通常的 index.html
页面以及你可能需要的所有 CSS 和 JavaScript 的 wwwroot
文件夹。最后,它还包含一个通常的 _Imports.razor
页面,你可以在这里放置所有默认的 using
语句。
MAUI Blazor 应用程序还使用可以放置在 Shared
文件夹中的布局页面,并且可以引用包含 CSS 组件和 JavaScript 文件的 Razor 库。
图 19.4:.NET MAUI Blazor 项目
唯一的区别是,服务不是在 Program.cs
文件中声明,而是在 MauiProgram.cs
文件中与特定于 MAUI 的代码一起声明:
public static class MauiProgram
{
public static MauiApp CreateMauiApp()
{
var builder = MauiApp.CreateBuilder();
builder
.UseMauiApp<App>()
.ConfigureFonts(fonts =>
{
fonts.AddFont("OpenSans-Regular.ttf", "OpenSansRegular");
});
builder.Services.AddMauiBlazorWebView();
#if DEBUG
builder.Services.AddBlazorWebViewDeveloperTools();
builder.Logging.AddDebug();
#endif
//this is a service
builder.Services.AddSingleton<WeatherForecastService>();
return builder.Build();
}
}
在使用 HttpClient
类与服务器通信时必须注意。Blazor 网络应用程序可能会使用相对于它们下载域的 URL,而 MAUI Blazor 应用程序必须使用绝对 URL,因为它们与任何默认 URL 无关。因此,添加了一个类似于以下内容的 HttpClient
定义到服务中:
builder.Services.AddScoped(sp => new HttpClient
{ BaseAddress = new Uri("https://localhost:7215") });
Platforms
文件夹包含每个应用程序支持的平台的子文件夹,每个子文件夹包含特定于平台的代码。
图 19.5:平台文件夹
支持的文件夹可以通过编辑 MAUIBlazorReview.csproj
文件的 TargetFrameworks
元素进行更改:
<TargetFrameworks>net8.0-android;net8.0-ios;net8.0-maccatalyst</TargetFrameworks>
你可以使用绿色 Visual Studio 运行按钮旁边的下拉菜单选择运行应用程序的平台。在 Windows 机器上,你可以选择仅 Windows 和 Android。如果你选择 Android,则会启动一个 Android 设备模拟器。
为了在 Windows 机器上调试 iOS/Mac 平台,你需要将 iOS/Mac 设备连接到你的计算机。
当你构建项目时,构建过程可能会要求你安装一个 Android SDK 版本。如果是这种情况,请按照错误消息中的简单说明操作。
摘要
在本章中,你学习了客户端技术。特别是,你学习了什么是单页应用程序(SPA)以及如何基于 Blazor WebAssembly 框架构建一个。本章首先描述了 Blazor WebAssembly 架构,然后解释了如何与 Blazor 组件交换输入/输出以及绑定的概念。
在解释了 Blazor 的一般原则之后,本章重点介绍了如何在提供用户足够的错误反馈和视觉线索的同时获取用户输入。然后,本章简要介绍了高级功能,例如 JavaScript 互操作性、全球化、授权认证和客户端/服务器通信。
最后,最后一节解释了如何使用 Blazor 实现基于 Microsoft MAUI 的跨平台应用程序,以及如何将 Blazor WebAssembly 项目转换为 .NET MAUI Blazor 项目。
基于 WWTravelClub 书籍用例的 Blazor 应用程序的完整示例可以在第二十一章 案例研究 的 使用客户端技术 部分找到。
问题
-
什么是 WebAssembly?
-
什么是 SPA?
-
Blazor
router
组件的用途是什么? -
什么是 Blazor 页面?
-
@namespace
指令的用途是什么? -
什么是
EditContext
? -
初始化组件的正确位置在哪里?
-
处理用户输入的正确位置在哪里?
-
IJSRuntime
接口的用途是什么? -
@ref
的用途是什么?
进一步阅读
-
Blazor 官方文档可在
docs.microsoft.com/en-US/aspnet/core/blazor
查看。 -
惰性加载程序集的描述在
docs.microsoft.com/en-US/aspnet/core/blazor/webassembly-lazy-load-assemblies
。 -
Blazor 支持的所有 HTML 事件及其事件参数列表可在
docs.microsoft.com/en-US/aspnet/core/blazor/components/event-handling#event-arguments-1
查看。 -
Blazor 支持与 ASP.NET MVC 相同的验证属性,除了
RemoteAttribute
:docs.microsoft.com/en-us/aspnet/core/mvc/models/validation#built-in-attributes
。 -
InputFile
组件的描述及其使用方法可在此处找到:docs.microsoft.com/en-US/aspnet/core/blazor/file-uploads
。 -
更多关于 Blazor 本地化和全球化的详细信息请在此处查看:
docs.microsoft.com/en-US/aspnet/core/blazor/globalization-localization
。 -
更多关于 Blazor 身份验证及其所有相关 URL 的详细信息请在此处查看:
docs.microsoft.com/en-US/aspnet/core/blazor/security/webassembly/
。 -
Blazorise 项目:
github.com/stsrki/Blazorise
。 -
BlazorStrap:
github.com/chanan/BlazorStrap
。 -
Blazor 控件工具包:
blazorct.azurewebsites.net/
。 -
bUnit:
github.com/egil/bUnit
。 -
Awesome Blazor 项目:
github.com/AdrienTorris/awesome-blazor
。
在 Discord 上了解更多
要加入此书的 Discord 社区——您可以在此处分享反馈、向作者提问以及了解新版本——请扫描下面的二维码:
packt.link/SoftwareArchitectureCSharp12Dotnet8
第二十章:Kubernetes
本章致力于描述 Kubernetes 容器编排器及其在 Azure 中的实现,称为 Azure Kubernetes 服务(AKS)。我们在第十一章“将微服务架构应用于您的企业应用”的“哪些工具需要用于管理微服务?”部分讨论了编排器的重要性和处理任务。在这里,值得回顾的是 Kubernetes 是编排器的既定标准。
我们还将展示如何在你的本地机器上安装和使用 minikube,这是一个单节点 Kubernetes 模拟器,你可以用它来尝试本章中的所有示例,也可以测试你自己的应用程序。模拟器在避免在实际基于云的 Kubernetes 集群上浪费太多钱,并为每个开发者提供不同的 Kubernetes 集群方面都很有用。
本章解释了 Kubernetes 的基本概念,然后重点介绍如何与 Kubernetes 集群交互以及如何部署 Kubernetes 应用程序。所有概念都通过简单的示例进行实践。我们建议在阅读本章之前先阅读第十一章“将微服务架构应用于您的企业应用”,因为我们将在本章中使用前面章节中解释的概念。
更具体地说,在本章中,我们将涵盖以下主题:
-
Kubernetes 基础
-
与 Azure Kubernetes 集群交互
-
高级 Kubernetes 概念
到本章结束时,你将学会如何使用 Azure Kubernetes 服务(AKS)实现和部署一个完整的解决方案。
技术要求
在本章中,你需要以下内容:
-
Visual Studio 2022 免费社区版或更高版本,已安装所有数据库工具,或任何其他
.yaml
文件编辑器,例如 Visual Studio Code。 -
一个免费的 Azure 账户。第一章“理解软件架构的重要性”中的“创建 Azure 账户”部分解释了如何创建一个。
-
可选的 minikube 安装。安装说明将在本章的“使用 minikube”部分提供。
本章的代码可在 github.com/PacktPublishing/Software-Architecture-with-C-Sharp-12-and-.NET-8-4E
找到。
Kubernetes 基础
Kubernetes 是一种用于管理在计算机网络上运行的分布式应用程序的高级开源软件。Kubernetes 可以用于您的私有机器的集群,或者您可以使用所有主要云提供商的硬件可扩展 Kubernetes 产品。这种软件被称为编排器,因为它动态地将微服务分配给可用的硬件资源,以最大化性能。此外,像 Kubernetes 这样的编排器为它们在机器之间移动的微服务提供稳定的虚拟地址,从而改变它们的物理地址。在撰写本文时,Kubernetes 是最广泛使用的编排器,是集群编排的事实标准,可以与广泛的工具和应用程序生态系统一起使用。虽然 Kubernetes 不绑定到特定的语言或框架,但它是在基于微服务的 .NET 分布式应用程序中管理硬件资源和通信的基本工具。本节介绍了 Kubernetes 的基本概念和实体。
Kubernetes 集群是运行 Kubernetes 编排器的虚拟机集群。
图 20.1:配备 Kubernetes 的计算机网络
通常,Kubernetes 安装在称为主节点的特定机器上,而所有其他计算机仅运行一个连接到主节点上运行的软件的接口软件。
组成集群的虚拟机被称为节点。我们可以在 Kubernetes 上部署的最小软件单元不是一个单独的应用程序,而是一组容器化应用程序的集合,称为Pod。虽然 Kubernetes 支持各种类型的容器,但最常用的容器类型是 Docker,这在我们的第十一章将微服务架构应用于您的企业应用程序中进行了分析,因此我们将在这里仅限于讨论 Docker。Pod 是 Docker 图像的集合,每个图像都包含您的 .NET 微服务或使用其他技术实现的微服务。
更具体地说,Pod 是在应用程序的整体生命周期中必须放置在同一节点上的 Docker 图像集合。它们可以被移动到其他节点,但必须一起移动。这意味着它们可以通过 localhost 端口轻松通信。然而,不同 Pod 之间的通信更为复杂,因为 Pod 的 IP 地址是短暂的资源,因为 Pod 没有固定的运行节点,而是由编排器从一个节点移动到另一个节点。此外,Pod 可能会被复制以提高性能,因此,通常将消息发送到特定的 Pod 没有意义;相反,我们将它发送到同一 Pod 的任何相同副本。
集群节点和 Pod 由主节点管理,主节点通过 API 服务器与集群管理员通信,如下所示:
图 20.2:Kubernetes 集群
调度器根据管理员的约束将 Pod 分配到节点,同时控制器管理器将多个守护进程分组,这些守护进程监控集群的实际状态并尝试将其移动到通过 API 服务器声明的期望状态。有几个控制器用于 Kubernetes 资源,从 Pod 副本到通信设施。实际上,每个资源在应用程序运行期间都有一些需要保持的目标目标,控制器会验证这些目标是否真正实现,如果没有实现,可能会触发纠正措施,例如将运行速度过慢的一些 Pod 移动到更少拥挤的节点。
kubelet 管理每个非主节点与主节点之间的交互。
在 Kubernetes 中,Pod 之间的通信由称为服务的资源处理,这些服务由 Kubernetes 基础设施分配虚拟地址,并将它们的通信转发到一组相同的 Pod。简而言之,服务是 Kubernetes 为 Pod 副本集分配一致虚拟地址的方式。
Kubernetes 的所有实体都可以分配名为标签的键值对,这些标签通过模式匹配机制来引用。更具体地说,选择器通过列出它们必须具有的标签来选择 Kubernetes 实体。
因此,例如,所有从同一服务接收流量的 Pod 都会通过指定它们在服务定义中必须具有的标签来选择。
服务路由其流量到所有连接 Pod 的方式取决于 Pod 的组织方式。无状态 Pod 组织在所谓的ReplicaSets
中。ReplicaSets
为整个组分配一个唯一的虚拟地址,流量在组中的所有 Pod 之间平均分配。
状态化 Kubernetes Pod 副本组织成所谓的StatefulSets
。StatefulSets
使用分片来分割所有 Pod 之间的流量。因此,Kubernetes 服务为它们连接到的StatefulSet
中的每个 Pod 分配不同的名称。这些名称看起来如下:basename-0.<base URL>
,basename-1.<base URL>
,...,basename-n.<base URL>
。这样,消息分片就可以轻松完成如下:
-
每次必须向由N个副本组成的
StatefulSet
发送消息时,你会在0
和N-1
之间计算一个哈希值,比如说X
。 -
在基础名称后添加后缀
X
以获取集群地址,例如basename-x.<base URL>
。 -
将消息发送到
basename-x.<base URL>
集群地址。
Kubernetes 没有预定义的存储设施,你不能使用节点磁盘存储,因为 Pod 会在可用节点之间移动,所以必须使用分片云数据库或其他类型的云存储来提供长期存储。虽然每个 StatefulSet 中的 Pod 都可以使用常规连接字符串技术访问分片云数据库,但 Kubernetes 提供了一种技术来抽象外部 Kubernetes 集群环境提供的类似磁盘的云存储。我们将在高级 Kubernetes 概念部分描述这种存储。
在这个简短的介绍中提到的所有 Kubernetes 实体都可以在 .yaml
文件中定义,一旦部署到 Kubernetes 集群,就会在文件中定义的所有实体被创建。接下来的子节描述 .yaml
文件,而之后的子节将详细描述迄今为止提到的所有基本 Kubernetes 对象,并解释如何在 .yaml
文件中定义它们。其他 Kubernetes 对象将在本章的其余部分进行描述。
.yaml 文件
开发者使用一种名为 YAML 的语言来描述集群的期望配置和 Kubernetes 对象的结构,并将它们打包在具有 .yaml
扩展名的文件中。
.yaml
文件,类似于 JSON 文件,可以用一种易于阅读的方式描述嵌套对象和集合,但它们使用的是不同的语法。您有对象和列表,但对象的属性不是用 {}
包围,列表也不是用 []
包围。相反,通过简单地使用空格缩进来声明嵌套对象。空格的数量可以自由选择,但一旦选择,就必须始终如一地使用。
列表项可以通过在它们前面加上破折号 (-
) 来与对象属性区分开来。
下面是一个涉及嵌套对象和集合的示例:
Name: John
Surname: Smith
Spouse:
Name: Mary
Surname: Smith
Addresses:
- Type: home
Country: England
Town: London
Street: My home street
- Type: office
Country: England
Town: London
Street: My home street
前面的 Person
对象有一个嵌套的 Spouse
对象和一个嵌套的地址集合。
在 JSON 中的相同示例将是:
{
Name: John
Surname: Smith
Spouse:
{
Name: Mary
Surname: Smith
}
Addresses:
[
{
Type: home
Country: England
Town: London
Street: My home street
},
{
Type: office
Country: England
Town: London
Street: My home street
}
]
}
如您所见,语法更易于阅读,因为它避免了括号的冗余。
.yaml
文件可以包含多个部分,每个部分定义不同的实体,它们之间由包含 ---
字符串的行分隔。注释前有一个 #
符号,必须在每行注释中重复。
每个部分都以 Kubernetes API 组和版本的声明开始。实际上,并非所有对象都属于同一个 API 组。对于属于 core
API 组的对象,我们只需指定 API 版本即可,如下例所示:
apiVersion: v1
而属于不同 API 组的对象也必须指定 API 名称,如下例所示:
apiVersion: apps/v1
在下一个子节中,我们将分析建立在它们之上的 ReplicaSets 和 Deployments。
ReplicaSets 和 Deployments
Kubernetes 应用程序最重要的构建块是 ReplicaSet,即 Pod 被复制 N 次。然而,通常您会使用一个更复杂的对象,它是建立在 ReplicaSet 之上的——Deployment。Deployment 不仅创建 ReplicaSet,还监控它们以确保副本数量在硬件故障和其他可能涉及 ReplicaSet 的事件发生时保持恒定。换句话说,它们是定义 ReplicaSet 和 Pod 的声明性方式。
复制相同的函数,从而复制相同的 Pod,是优化性能的最简单操作:我们为相同的 Pod 创建的副本越多,就必须为该 Pod 编码的功能提供更多的硬件资源和线程。因此,当我们发现某个功能成为系统中的瓶颈时,我们可能只需增加编码该功能的 Pod 副本的数量。
每个部署都有一个名称(metadata->name
),一个指定所需副本数的属性(spec->replicas
),一个键值对(spec -> selector-> matchLabels
),它选择要监控的 Pod,以及一个模板(spec->template
),它指定如何构建 Pod 副本:
apiVersion: apps/v1
kind: Deployment
metadata:
name: my-deployment-name
namespace: my-namespace #this is optional
spec:
replicas: 3
selector:
matchLabels:
my-pod-label-name: my-pod-label-value
...
template:
...
namespace
是可选的,如果没有提供,则假定一个名为default
的命名空间。命名空间是保持 Kubernetes 集群中对象分离的一种方式。例如,一个集群可以托管两个完全独立的应用程序的对象,每个应用程序都放置在单独的namespace
中,以防止可能的重名冲突。简而言之,Kubernetes 命名空间与.NET 命名空间具有相同的目的:防止重名冲突。
在模板内部缩进的是要复制的 Pod 的定义。复杂的对象,如部署,也可以包含其他类型的模板,例如,外部环境所需的类似磁盘的内存模板。我们将在高级 Kubernetes 概念部分详细讨论这一点。
相应地,Pod 模板包含一个metadata
部分,其中包含用于选择 Pod 的标签,以及一个spec
部分,其中包含所有容器的列表:
metadata:
labels:
my-pod-label-name: my-pod-label-value
...
spec:
containers:
...
- name: my-container-name
image: <Docker imagename>
resources:
requests:
cpu: 100m
memory: 128Mi
limits:
cpu: 250m
memory: 256Mi
ports:
- containerPort: 6379
env:
- name: env-name
value: env-value
...
每个容器都有一个名称,并且必须指定用于创建容器的 Docker 镜像的名称。如果 Docker 镜像不在公共 Docker 注册表中,名称必须是一个 URI,它还包括存储库的位置。
然后,容器必须指定它们在resources->requests
对象中需要的内存和 CPU 资源才能被创建。只有当这些资源当前可用时,才会创建 Pod 副本。相反,resources->limits
对象指定了容器副本可以使用的最大资源。如果在容器执行期间超过了这些限制,将采取行动来限制它们。更具体地说,如果 CPU 限制被超过,容器将被节流(其执行停止以恢复其 CPU 消耗),而如果内存限制被超过,容器将被重启。containerPort
必须是容器暴露的端口。在这里,我们还可以指定其他信息,例如使用的协议。
CPU 时间以毫芯为单位表示;1,000
毫芯表示100%
的 CPU 时间,而内存以兆字节(1Mi
=
1,024*1,024 bytes
)或其他单位表示。env
列出所有传递给容器的操作系统环境变量及其值。
容器和 Pod 模板都可以包含其他字段,例如定义虚拟文件属性和定义返回容器就绪状态和健康状态的命令的属性。我们将在高级 Kubernetes 概念部分中分析这些字段。
以下子部分描述了旨在存储状态信息的 Pod 集。
StatefulSets
StatefulSet 与 ReplicaSet 非常相似,但 ReplicaSet 中的 Pod 是不可区分的处理器,它们通过负载均衡策略并行贡献相同的工作负载,而 StatefulSet 中的 Pod 具有唯一的标识,只能通过分片来贡献相同的工作负载。这是因为 StatefulSet 是为了存储信息而设计的,信息不能并行存储,只能通过分片在几个存储之间分割。
由于同样的原因,每个 Pod 实例始终与其所需的任何虚拟磁盘空间保持关联(参见高级 Kubernetes 概念部分),这样每个 Pod 实例就负责写入特定的存储。
此外,StatefulSets 的 Pod 实例还附加了序号。它们根据这些序号按顺序启动,并按相反的顺序停止。如果 StatefulSet 包含N个副本,这些数字从0
到N-1
。此外,每个实例都有一个独特的名称,通过将模板中指定的 Pod 名称与实例序号链接在一起获得,如下所示 – <pod name>-<instance ordinal>
。因此,实例名称将类似于mypodname-0
、mypodname-1
等。正如我们将在服务子部分中看到的那样,实例名称用于为所有实例构建唯一的集群网络 URI,以便其他 Pod 可以与 StateFulSet Pod 的特定实例通信。
由于 StatefulSet 中的 Pod 具有内存,每个 Pod 只能处理包含在其内的数据可以处理的需求。因此,为了利用 StatefulSet 中的多个 Pod,我们必须在易于计算的子集中共享整个数据空间。这种技术称为分片。例如,处理客户的 StatefulSet 的 Pod 可以根据其首字母分配不同的客户名称集。一个可以处理所有以 A-C 字母开头的客户,另一个可以处理以 D-F 字母开头的名称,依此类推。
这里是一个典型的 StatefulSet 定义:
apiVersion: apps/v1
kind: StatefulSet
metadata:
name: my-stateful-set-name
spec:
selector:
matchLabels:
my-pod-label-name: my-pod-label-value
...
serviceName: "my-service-name"
replicas: 3
template:
...
模板部分与 Deployments 相同。StatefulSets 与 Deployments 之间的主要区别是serviceName
字段。它指定了必须与 StatefulSet 连接的服务名称,以为所有 Pod 实例提供唯一的网络地址。我们将在服务子部分中更详细地讨论这个问题。此外,通常 StatefulSets 使用某种形式的存储。我们将在高级 Kubernetes 概念部分中详细讨论这一点。
还值得一提的是,可以通过指定spec->podManagementPolicy
属性的显式Parallel
值来更改 StatefulSets 创建和停止策略的默认顺序(默认值是OrderedReady
)。
下表总结了 StatefulSets 和 ReplicaSets 之间的差异:
特性 | StatefulSets | ReplicaSets |
---|---|---|
整个集合的唯一地址 | No. 集合中的每个 Pod 都有一个不同的地址,并负责处理不同类型的请求。 | Yes. ReplicaSet 中的 Pod 是不可区分的,因此每个请求可以由其中的任何一个 Pod 处理。 |
应用生命周期内可以增加副本数量 | No. 由于每个 Pod 负责特定类型的请求并具有唯一的地址,因此我们无法添加更多的 Pod。 | Yes. 由于 Pod 是不可区分的,更多的 Pod 不会引起问题,但只会提高整个集合的性能。 |
Pods 可以在其中存储永久数据 | Yes, they are designed for this. Requests are issued to Pods with the sharding technique. | No, because they are designed to be indistinguishable, and storing a specific datum in a specific Pod would make a Pod different from the others in the set. |
表 20.1:StatefulSets 与 ReplicaSets 对比
下一个子节将描述如何为 ReplicaSets 和 StatefulSets 提供稳定的网络地址。
服务
由于 Pod 实例可以在节点之间移动,它们没有附加到它们上的稳定 IP 地址。服务负责为整个 ReplicaSet 分配一个唯一且稳定的虚拟地址,并对所有实例的流量进行负载均衡。服务不是在集群中创建的软件对象,而只是实现其功能所需的各种设置和活动的抽象。
服务在协议栈的第 4 层工作,因此它们理解 TCP 等协议,但它们无法执行 HTTP 特定的操作/转换,例如确保安全的 HTTPS 连接。因此,如果您需要在 Kubernetes 集群上安装 HTTPS 证书,则需要一个能够与协议栈第 7 层交互的更复杂对象。Ingress
对象就是为了这个目的而设计的。我们将在下一个子节中讨论这一点。
服务还负责为 StatefulSet 的每个实例分配一个唯一的虚拟地址。实际上,存在各种类型的服务;一些是为 ReplicaSets 设计的,而另一些是为 StatefulSets 设计的。
被分配了唯一集群内部 IP 地址的ClusterIP
服务类型。它通过标签模式匹配指定它连接到的 ReplicaSets 或 Deployments。它使用 Kubernetes 基础设施维护的表来在它连接的所有 Pod 实例之间负载均衡它接收到的流量。
因此,其他 Pods 可以通过与此服务交互来与连接到服务的 Pods 进行通信,该服务被分配了稳定的网络名称 <service name>.<service namespace>.svc.cluster.local
。由于它们只是分配了本地 IP 地址,因此 ClusterIP
服务无法从 Kubernetes 集群外部访问。
对于不与 Kubernetes 集群外部进行通信的 Deployments 和 ReplicaSets,ClusterIP 是通常的通信选择。
这里是典型 ClusterIP
服务的定义:
apiVersion: v1
kind: Service
metadata:
name: my-service
namespace: my-namespace
spec:
selector:
my-selector-label: my-selector-value
...
ports:
- name: http
protocol: TCP
port: 80
targetPort: 9376
- name: https
protocol: TCP
port: 443
targetPort: 9377
每个服务可以在多个端口上工作,并将任何端口 (port
) 路由到容器暴露的端口 (targetPort
)。然而,port = targetPort
的情况非常常见。端口可以命名,但这些名称是可选的。此外,协议的指定也是可选的;当未明确指定时,允许所有受支持的四层协议。spec->selector
属性指定了所有用于选择要路由通信的服务的 Pods 的名称/值对。
由于 ClusterIP
服务无法从 Kubernetes 集群外部访问,我们需要其他服务类型来在公共 IP 地址上暴露 Kubernetes 应用程序。
NodePort
类型服务是将 Pods 暴露给外部世界的最简单方式。为了实现 NodePort
服务,在 Kubernetes 集群的每个节点上打开相同的端口 x
,并且每个节点将接收到的此端口上的流量路由到一个新创建的 ClusterIP
服务。
反过来,ClusterIP
服务将其流量路由到服务所选的所有 Pods:
图 20.3:NodePort 服务
因此,您可以通过任何集群节点的公共 IP 地址上的端口 x
进行通信,以访问连接到 NodePort
服务的 Pods。当然,整个过程是完全自动的,并且对开发者隐藏,开发者的唯一关注点是获取端口号 x
,以便知道如何转发外部流量。
NodePort
服务的定义与 ClusterIP
服务的定义类似,唯一的区别是它们为 spec->type
属性指定了 NodePort
的值:
...
spec:
type: NodePort
selector:
...
默认情况下,为每个由 Service
指定的 targetPort
自动选择范围在 30000-32767 之间的节点端口 x
。对于 NodePort
服务,与每个 targetPort
相关的 port
属性是无效的,因为所有流量都通过选定的节点端口 x
传递,并且按照惯例,将其设置为与 targetPort
相同的值。
开发者还可以通过 nodePort
属性直接设置 NodePort x
:
...
ports:
- name: http
protocol: TCP
port: 80
targetPort: 80
nodePort: 30007
- name: https
protocol: TCP
port: 443
targetPort: 443
nodePort: 30020
...
当 Kubernetes 集群托管在云上时,将一些 Pods 暴露给外部世界的更方便的方式是通过 LoadBalancer
服务,在这种情况下,Kubernetes 集群通过所选云提供商的第四层负载均衡器暴露给外部世界。
负载均衡器(LoadBalancer)通常是用于部署(Deployments)和副本集(ReplicaSets)的通信选择,这些部署和副本集在其 Kubernetes 集群外部进行通信,但不需要高级 HTTP 功能。
LoadBalancer
服务的定义与 ClusterIp
服务的定义类似,唯一的区别是必须将 spec->type
属性设置为 LoadBalancer
:
...
spec:
type: LoadBalancer
selector:
...
如果没有添加进一步的指定,将随机分配一个动态公共 IP。然而,如果需要特定的公共 IP 地址,可以通过在 spec->loadBalancerIP
属性中指定它来将其设置为集群负载均衡器的公共 IP 地址:
...
spec:
type: LoadBalancer
loadBalancerIP: <your public ip>
selector:
...
在 Azure Kubernetes 服务(AKS)中,你还必须在注释中指定分配 IP 地址的资源组:
apiVersion: v1
kind: Service
metadata:
annotations:
service.beta.kubernetes.io/azure-load-balancer-resource-group: <IP resource group name>
name: my-service-name
...
在 AKS 中,你可以保持使用动态 IP 地址,但你也可以获得一个公共静态域名,类型为 <my-service-label>.<location>.cloudapp.azure.com
,其中 <location>
是你为资源选择的地理位置标签。《my-service-label》是一个你验证过的标签,使得上述域名唯一。《my-service-label》必须在你的服务注释中声明,如下所示:
apiVersion: v1
kind: Service
metadata:
annotations:
service.beta.kubernetes.io/azure-dns-label-name: <my-service-label>
name: my-service-name
...
StatefulSets
不需要任何负载均衡,因为每个 Pod 实例都有自己的标识,但确实需要为每个 Pod 实例提供一个唯一的 URL 地址。这个唯一的 URL 由所谓的 无头服务 提供。无头服务的定义与 ClusterIP
服务类似,唯一的区别是它们将 spec->clusterIP
属性设置为 none
:
...
spec:
clusterIP: none
selector:
...
所有由无头服务处理的 StatefulSets
必须在其 spec->serviceName
属性中放置服务名称,正如在 StatefulSets 子节中已经说明的那样。
无头服务为它所处理的每个 StatefulSets
Pod 实例提供的唯一名称是 <unique pod name>.<service name>.<namespace>.svc.cluster.local
。
服务只理解低级协议,如 TCP/IP,但大多数 Web 应用程序都位于更复杂的 HTTP 协议上。这就是为什么 Kubernetes 提供了在服务之上构建的更高层次的实体,称为 Ingress。
入口对于所有需要 HTTP 支持的基于 Web 的应用程序的实施至关重要。此外,由于目前大量应用程序都是 Web 应用程序,入口对于所有微服务应用程序来说都是 必需的。特别是,所有基于 ASP.NET Core 的微服务都需要它们,我们将在本书的剩余部分讨论这一点。
下一个子节描述了这些内容,并解释了如何通过第 7 层协议负载均衡器公开一组 Pod,这可以用来访问典型的 HTTP 服务,而不是通过 LoadBalancer
服务。
入口(Ingresses)
入口(Ingresses)的概念是为了使运行在 Kubernetes 集群中的每个应用程序都能够暴露基于 HTTP 的接口。这对于任何编排器来说都是一个基本要求,因为如今所有微服务应用程序都是 Web 应用程序,它们通过基于 HTTP 的协议与客户端进行交互。此外,入口必须非常高效,因为所有与 Kubernetes 集群的通信都将通过它们进行。
因此,入口提供了由高级和高效的 Web 服务器提供的所有典型服务。它们提供以下服务:
-
HTTPS 终结。它们接受 HTTPS 连接,并以 HTTP 格式将它们路由到集群中的任何服务。
-
基于名称的虚拟主机。它们将多个域名与同一 IP 地址关联,并将每个域名或
<domain>/<path prefix>
路由到不同的集群服务。 -
负载均衡。
入口是部署(Deployments)和副本集(ReplicaSets)的常规通信选择,这些部署和副本集需要与 Kubernetes 集群外部进行通信,并且需要高级 HTTP 功能。
由于从头开始重写高级 Web 服务器的所有功能基本上是不可能的,入口依赖于现有的 Web 服务器来提供服务。更具体地说,Kubernetes 提供了添加一个名为入口控制器(Ingress Controllers)的接口模块的可能性,以将每个 Kubernetes 集群与现有的 Web 服务器(如 NGINX 和 Apache)连接起来。
入口控制器是自定义的 Kubernetes 对象,必须在集群中安装。它们处理 Kubernetes 与现有的 Web 服务器软件之间的接口,这可以是外部 Web 服务器,也可以是 Ingress 控制器安装的一部分 Web 服务器。
我们将在 高级 Kubernetes 概念 部分中描述基于 NGINX Web 服务器软件的入口控制器的安装,作为 Helm 使用的示例。然而,所有主要的 Web 服务器都有入口控制器。进一步阅读 部分包含有关如何安装一个与外部 Azure 应用程序网关接口的入口控制器的信息。
HTTPS 终结和基于名称的虚拟主机(有关这些术语的解释请参阅本小节开头)可以在入口定义中配置,方式独立于所选的入口控制器,而负载均衡的实现方式则取决于所选的具体入口控制器及其配置。一些入口控制器配置数据可以通过入口定义的 metadata-> annotations
字段传递。
基于名称的虚拟主机在入口定义的 spec-> rules
部分中定义:
...
spec:
...
rules:
- host: *.mydomain.com
http:
paths:
- path: /
pathType: Prefix
backend:
service:
name: my-service-name
port:
number: 80
- host: my-subdomain.anotherdomain.com
...
每个规则指定一个可选的主机名,它可以包含 *
通配符。如果没有提供主机名,则规则匹配所有主机名。对于每个规则,我们可以指定几个路径,每个路径都重定向到不同的服务/端口对,其中服务通过其名称引用。匹配每个 path
的方式取决于 pathType
的值;如果此值为 Prefix
,则指定的 path
必须是任何匹配路径的前缀。否则,如果此值为 Exact
,则匹配必须是精确的。匹配区分大小写。
在特定主机名上指定 HTTPS 终止是通过将其与一个编码在 Kubernetes 机密中的证书相关联来实现的:
...
spec:
...
tls:
- hosts:
- www.mydomain.com
secretName: my-certificate1
- my-subdomain.anotherdomain.com
secretName: my-certificate2
...
HTTPS 证书可以在 letsencrypt.org/
免费获得。该流程在网站上有所解释,但基本上,与所有证书颁发机构一样,您提供一个密钥,他们根据该密钥返回证书。还可以安装一个 证书管理器,它会自动安装和续订证书。密钥/证书对如何在 Kubernetes 机密字符串中编码在 高级 Kubernetes 概念 部分中详细说明。
整个 Ingress 定义如下:
apiVersion: networking.k8s.io/v1
kind: Ingress
metadata:
name: my-example-ingress
namespace: my-namespace
spec:
tls:
...
rules:
...
在这里,namespace
是可选的,如果没有指定,则默认为 default
。
在下一节中,我们将通过定义一个 Azure Kubernetes 集群并部署一个简单的应用程序来实践这里解释的一些概念。
与 Kubernetes 集群交互
在本节中,我们将解释如何创建一个 Azure Kubernetes 集群,以及如何在您的本地机器上安装 minikube,一个 Kubernetes 模拟器。所有示例都可以在 Azure Kubernetes 和您的本地 minikube 实例上运行。
创建 Azure Kubernetes 集群
要创建一个 AKS 集群,请执行以下操作:
-
在 Azure 搜索框中输入
AKS
。 -
选择 Kubernetes 服务。
-
然后点击 创建 按钮。
之后,将出现以下表单:
图 20.4:创建 Kubernetes 集群
值得注意的是,您只需将鼠标悬停在任何 上即可获得帮助。
如同往常,您需要指定一个订阅、资源组和区域。然后,您可以选择一个独特的名称(Kubernetes 集群名称)以及您希望使用的 Kubernetes 版本。对于计算能力,您需要为每个节点选择一个机器模板(节点大小)和节点数量。而对于实际应用,建议至少选择三个节点,但为了节省我们的免费 Azure 信用额度,我们这次只选择两个节点。
此外,默认的虚拟机也应设置为便宜的型号,因此点击 更改大小 并选择 DS2 v2。最后,将 缩放方法 设置为 手动 以防止节点数量自动更改,这可能会快速消耗您的免费 Azure 信用额度。
可用区域设置允许你将你的节点分散在几个地理区域,以提高容错能力。默认为三个区域。请将其更改为两个区域,因为我们只有两个节点。
实施上述更改后,你应该看到以下设置:
图 20.5:选择的设置
现在,你可以通过点击审查 + 创建按钮来创建你的集群。应该会出现一个审查页面。确认并创建集群。
如果你点击下一步而不是审查 + 创建,你也可以定义其他节点类型,然后你可以提供安全信息,即服务主体,并指定你是否希望启用基于角色的访问控制。在 Azure 中,服务主体是与你可能使用的服务关联的账户,你可以使用它来定义资源访问策略。你还可以更改默认的网络设置和其他设置。
部署可能需要一点时间(10-20 分钟)。在这段时间之后,你将拥有你的第一个 Kubernetes 集群!在章节结束时,当集群不再需要时,请务必删除它,以避免浪费你的免费 Azure 信用额度。
在下一个子节中,你将学习如何在你的本地机器上安装和使用 minikube,这是一个单节点 Kubernetes 模拟器。
使用 minikube
安装 minikube 最简单的方法是使用官方安装页面中可找到的 Windows 安装程序:minikube.sigs.k8s.io/docs/start/
。
在安装过程中,你将提示选择要使用的虚拟化工具。如果你已经安装了 Docker Desktop 和 WSL,请指定 Docker。
如果你使用的是不同的操作系统,请遵循默认选项。
Docker Desktop 的安装说明在第十一章,将微服务架构应用于你的企业应用程序的技术要求中解释。请注意,WSL 和 Docker Desktop 都必须安装,并且 Docker 必须配置为默认使用 Linux 容器。
一旦安装了 minikube,你必须将其二进制文件添加到你的计算机PATH
中。最简单的方法是打开 PowerShell 控制台并运行以下命令:
$oldPath=[Environment]::GetEnvironmentVariable('Path', [EnvironmentVariableTarget]::Machine)
if ($oldPath.Split(';') -inotcontains 'C:\minikube'){ '
[Environment]::SetEnvironmentVariable('Path', $('{0};C:\minikube' -f $oldPath), [EnvironmentVariableTarget]::Machine) '
}
一旦安装,你可以使用以下命令运行你的集群:
minikube start
当你完成与集群的工作后,可以使用以下命令停止它:
minikube stop
在下一个子节中,你将学习如何通过 Kubernetes 的官方客户端 kubectl 与你的 minikube 实例或 Azure 集群进行交互。
使用 kubectl
一旦创建了你的 Azure Kubernetes 集群,你就可以通过 Azure Cloud Shell 与之交互。点击 Azure 门户页面右上角的控制台图标。以下截图显示了 Azure Shell 图标:
图 20.6:Azure Shell 图标
当提示时,选择Bash Shell。然后你将被提示创建一个存储账户,因此请确认并创建它。
我们将使用这个 shell 来与我们的集群交互。在 shell 的顶部有一个文件图标,我们将用它来上传我们的.yaml
文件:
图 20.7:如何在 Azure Cloud Shell 中上传文件
也可以下载一个名为 Azure CLI 的客户端并将其安装到你的本地机器上(见docs.microsoft.com/en-US/cli/azure/install-azure-cli
),但在此情况下,你还需要安装所有与 Kubernetes 集群交互所需的工具(kubectl 和 Helm),这些工具在 Azure Cloud Shell 中预先安装。
一旦你创建了一个 Kubernetes 集群,你就可以通过kubectl
命令行工具与之交互。kubectl
集成到 Azure Cloud Shell 中,所以你只需要激活你的集群凭据来使用它。你可以使用以下 Azure Cloud Shell 命令来完成此操作:
az aks get-credentials --resource-group <resource group> --name <cluster name>
上述命令将自动创建的凭据存储在/.kube/config
配置文件中,以启用你与集群的交互。从现在开始,你可以无需进一步认证即可输入你的kubectl
命令。
如果你需要与你的本地 minikube 集群交互,你需要本地安装kubectl
,但 minikube 会自动为你安装。
为了使用自动安装的kubectl
,所有kubectl
命令都必须以minikube
命令开头,并且kubectl
后面必须跟有--。例如,如果你想运行以下命令:
kubectl get all
然后你需要编写以下内容:
minkube kubectl -- get all
在本章的剩余部分,我们将编写在真实的 Kubernetes 集群(如 Azure Kubernetes)上工作的命令。因此,当使用 minikube 时,请记住在你的命令中将kubectl
替换为minikube kubectl
--。
如果你输入kubectl get nodes
命令,你会得到所有 Kubernetes 节点的列表。通常,kubectl get <对象类型>
会列出给定类型的所有对象。你可以用它来与nodes
、pods
、statefulset
等一起使用。kubectl get all
会显示你集群中创建的所有对象的列表。如果你还添加了特定对象的名称,你将只得到关于该特定对象的信息,如下所示:
kubectl get <object type><object name>
如果你添加了--watch
选项,对象列表将不断更新,因此你可以看到所有选定对象的状态随时间变化。你可以通过按Ctrl + C来离开监视状态。
以下命令显示特定对象的详细报告:
kubectl describe <object name>
.yaml
文件中描述的所有对象,例如myClusterConfiguration.yaml
,都可以使用以下命令创建:
kubectl create -f myClusterConfiguration.yaml
然后,如果你修改了.yaml
文件,你可以使用apply
命令将所有修改反映到你的集群中,如下所示:
kubectl apply -f myClusterConfiguration.yaml
apply
与 create
执行相同的任务,但如果资源已存在,apply
将覆盖它,而 create
将以错误消息退出。
您可以通过将相同的文件传递给 delete
命令来销毁使用 .yaml
文件创建的所有对象,如下所示:
kubectl delete -f myClusterConfiguration.yaml
delete
命令也可以传递一个对象类型和该类型对象名称的列表,以销毁这些对象,如下面的示例所示:
kubectl delete deployment deployment1 deployment2...
前面的 kubectl
命令应该足以满足大多数实际需求。对于更多详细信息,进一步阅读 部分包含了对官方文档的链接。
在下一个子节中,我们将使用 kubectl create
来安装一个简单的演示应用程序。
部署演示 Guestbook 应用程序
Guestbook 应用程序是官方 Kubernetes 文档中使用的演示应用程序。我们将使用它作为 Kubernetes 应用程序的示例,因为其 Docker 镜像可在公共 Docker 仓库中找到,因此我们不需要编写软件。
Guestbook 应用程序存储访问酒店或餐厅的客户意见。
它由一个用户界面和一个基于 Redis 的内存数据库组成。此外,更新被发送到 Redis 数据库的主副本,该主副本自动复制为 N 个只读 Redis 副本。
图 20.8:Guestbook 应用程序的架构
UI 应用程序可以作为 Deployment 在 Kubernetes 中部署,因为它是无状态的。
Redis 主存储以单个 pod Deployment
的形式部署。我们不能使用 N 个 pod 的 Deployment
来实现它,因为我们需要分片以并行化更新。然而,我们可能已经使用了一个 StatefulSet
,为每个不同的主 Pod 分配不同的数据分片。但是,由于这是你的第一个 Kubernetes 练习,并且由于写入操作不应占主导地位,在单个餐厅/酒店的实际情况中,单个主数据库应该足够了。
由于所有从副本都包含相同的数据,因此它们是不可区分的,也可以使用 Deployment
来实现。
整个应用程序由三个 .yaml
文件组成,您可以在与本书相关的 GitHub 仓库中找到这些文件(github.com/PacktPublishing/Software-Architecture-with-C-Sharp-12-and-.NET-8-4E
)。
这里是包含在 redis-master.yaml
文件中的基于 Redis 的主存储代码:
apiVersion: apps/v1
kind: Deployment
metadata:
name: redis-master
labels:
app: redis
spec:
selector:
matchLabels:
app: redis
role: master
tier: backend
replicas: 1
template:
metadata:
labels:
app: redis
role: master
tier: backend
spec:
containers:
- name: master
image: docker.io/redis:6.0.5
resources:
requests:
cpu: 100m
memory: 100Mi
ports:
- containerPort: 6379
---
apiVersion: v1
kind: Service
metadata:
name: redis-leader
labels:
app: redis
role: master
tier: backend
spec:
ports:
- port: 6379
targetPort: 6379
selector:
app: redis
role: master
tier: backend
该文件由两个对象定义组成,由仅包含 ---
的行分隔,即 .yaml
文件的对象定义分隔符。通常,将相关的对象,如与其关联的服务一起的 Deployment,放在同一个文件中,通过 ---
对象分隔符号来分隔,以提高代码可读性。
第一个对象是一个具有单个副本的Deployment
,第二个对象是一个ClusterIP
服务,它将Deployment
在内部redis-leader.default.svc.cluster.local
网络地址的6379
端口上暴露。部署 Pod 模板定义了三个app
、role
和tier
标签及其值,这些值用于服务selector
定义中,以将服务与Deployment
中定义的唯一 Pod 连接。
让我们将redis-master.yaml
文件上传到 Azure Cloud Shell,然后使用以下命令在集群中部署它:
kubectl create -f redis-master.yaml
一旦操作完成,你可以使用kubectl get all
来检查集群的内容。
从机存储在redis-slave.yaml
文件中定义,并且以相同的方式创建,唯一的区别是这次我们有两个副本,以及不同的 Docker 镜像。完整的代码在本书相关的 GitHub 仓库中。
让我们也上传此文件,并使用以下命令部署它:
kubectl create -f redis-slave.yaml
UI 层的代码包含在frontend.yaml
文件中。Deployment
有三个副本和不同的服务类型。让我们使用以下命令上传并部署此文件:
kubectl create -f frontend.yaml
分析frontend.yaml
文件中的服务代码是值得的:
apiVersion: v1
kind: Service
metadata:
name: frontend
labels:
app: guestbook
tier: frontend
spec:
type: LoadBalancer
ports:
- port: 80
selector:
app: guestbook
tier: frontend
再次强调,完整的代码在本书相关的 GitHub 仓库中。
此服务为LoadBalancer
类型。由于此 Pod 是应用程序与 Kubernetes 集群外部的接口,其服务必须有一个固定的 IP,并且必须进行负载均衡。因此,我们必须使用LoadBalancer
服务,因为这是唯一满足这些要求的服务类型。(有关更多信息,请参阅本章的服务部分。)
如果你使用 Azure Kubernetes 或任何其他云 Kubernetes 服务,为了获取分配给服务的公网 IP 地址,然后到应用程序,请使用以下命令:
kubectl get service
前面的命令应显示所有已安装服务的详细信息。你应该在列表的EXTERNAL-IP
列中找到公网 IP。如果你只看到<none>
值,请重复此命令,直到公网 IP 地址分配给负载均衡器。
如果几分钟内没有分配 IP,请检查任何服务描述中是否有错误或警告。如果没有,请使用以下命令检查所有部署是否实际上正在运行:
kubectl get deployments
如果你在 minikube 上,可以通过以下命令访问LoadBalancer
服务:
minikube service <service name>
因此,在我们的情况下:
minikube service frontend
此命令应自动打开浏览器。
一旦获取到 IP 地址,使用浏览器导航到该地址。现在应该会显示应用程序的主页!
如果页面没有出现,请通过以下命令检查是否有任何服务出现错误:
kubectl get service
如果不是这样,也请使用以下命令验证所有部署是否处于运行状态:
kubectl get deployments
如果您发现问题,请检查 .yaml
文件中的错误,更正它们,然后使用以下命令更新文件中定义的对象:
kubectl update -f <file name>
一旦您完成对应用程序的实验,请确保使用以下命令将应用程序从集群中移除,以避免浪费您的免费 Azure 信用额度(公共 IP 地址需要付费):
kubectl delete deployment frontend redis-master redis-slave
kubectl delete service frontend redis-leader redis-follower
在下一节中,我们将分析其他重要的 Kubernetes 功能。
高级 Kubernetes 概念
在本节中,我们将讨论其他重要的 Kubernetes 功能,包括如何将永久存储分配给 StatefulSets;如何存储密码、连接字符串或证书等机密信息;容器如何通知 Kubernetes 其健康状态;以及如何使用 Helm 处理复杂的 Kubernetes 包。所有这些主题都组织在专门的子节中。我们将从永久存储的问题开始。
需要永久存储
由于 Pod 会在节点之间移动,因此它们不能存储在它们运行的当前节点提供的磁盘存储上,否则一旦它们被移动到不同的节点,就会丢失该存储。这给我们留下了两个选择:
-
使用外部数据库:借助数据库,ReplicaSets 也可以存储信息。然而,如果我们需要在写入/更新操作方面获得更好的性能,我们应该使用基于非 SQL 引擎的分布式分片数据库,例如 Cosmos DB 或 MongoDB(见 第十二章,在云中选择您的数据存储)。在这种情况下,为了最大限度地利用表分片,我们需要 StatefulSets,其中每个 Pod 实例负责不同的表分片。
-
使用云存储:由于不受物理集群节点的限制,云存储可以永久关联到 StatefulSets 的特定 Pod 实例。云存储在 第十二章 的 Redis 和 Azure 存储帐户 部分中讨论。
由于访问外部数据库不需要任何特定的 Kubernetes 技巧,只需使用常规的连接字符串即可完成,因此我们将专注于云存储。
Kubernetes 提供了一个称为 PersistentVolumeClaim (PVC)的存储抽象,它与底层存储提供商无关。更具体地说,PVC 是请求分配,这些请求要么与预定义的资源匹配,要么动态分配。当 Kubernetes 集群位于云中时,通常,您使用由云提供商安装的动态提供者执行的动态分配。有关云存储的更多信息,请参阅 第十二章。
云提供商,如 Azure,提供具有不同性能和不同成本的不同的存储类别。此外,PVC 还可以指定 accessMode
,可以是:
-
ReadWriteOnce
:卷可以被单个 Pod 以读写方式挂载。 -
ReadOnlyMany
:卷可以被多个 Pod 以只读方式挂载。 -
ReadWriteMany
:卷可以被多个 Pod 以读写方式挂载。
可以在 StatefulSets 中添加到特定的spec->volumeClaimTemplates
对象中:
volumeClaimTemplates:
- metadata:
name: my-claim-template-name
spec:
resources:
request:
storage: 5Gi
volumeMode: Filesystem
accessModes:
- ReadWriteOnce
storageClassName: my-optional-storage-class
storage
属性包含存储需求。volumeMode
设置为Filesystem
是标准设置,意味着存储将以文件路径的形式可用。另一个可能的值是Block
,它将内存分配为未格式化
。storageClassName
必须设置为云提供商提供的现有存储类。如果省略,则假定默认存储类。
可以使用以下命令列出所有可用的存储类:
kubectl get storageclass
一旦volumeClaimTemplates
定义了如何创建持久存储,那么每个容器必须指定在spec->containers->volumeMounts
属性中将该持久存储附加到哪个文件路径:
...
volumeMounts
- name: my-claim-template-name
mountPath: /my/requested/storage
readOnly: false
...
在这里,name
必须与 PVC 给出的名称相匹配。
下一个子节将展示如何使用 Kubernetes 密钥。
Kubernetes 密钥
一些数据,如密码和连接字符串,不能公开,需要通过某种加密来保护。Kubernetes 通过称为密钥的特定对象处理需要加密的私有敏感数据。密钥是一组加密以保护它们的键值对。可以通过将每个值放入一个文件中,然后调用以下kubectl
命令来创建:
kubectl create secret generic my-secret-name \
--from-file=./secret1.bin \
--from-file=./secret2.bin
在这种情况下,文件名成为键,文件的内容成为值。
当值是字符串时,可以在kubectl
命令中直接指定,如下所示:
kubectl create secret generic dev-db-secret \
--from-literal=username=devuser \
--from-literal=password='$dsd_weew1'
在这种情况下,键和值依次列出,由=
字符分隔。在先前的例子中,实际密码被单引号包围以转义通常用于构建强密码的特殊字符,如$
。
一旦定义,密钥就可以在 Pod(Deployment 或 StatefulSet 模板)的spec->volume
属性中引用,如下所示:
...
volumes:
- name: my-volume-with-secrets
secret:
secretName: my-secret-name
...
之后,每个容器可以指定在spec->containers->volumeMounts
属性中挂载它们的路径:
...
volumeMounts:
- name: my-volume-with-secrets
mountPath: "/my/secrets"
readOnly: true
...
在前面的例子中,每个密钥被视为一个与密钥同名文件。文件的内容是加密的密值,使用 base64 编码。因此,读取每个文件的代码必须解码其内容(在.NET 中,Convert.FromBase64
将完成这项工作)。
当密钥包含字符串时,也可以在spec->containers->env
对象中作为环境变量传递:
env:
- name: SECRET_USERNAME
valueFrom:
secretKeyRef:
name: dev-db-secret
key: username
- name: SECRET_PASSWORD
valueFrom:
secretKeyRef:
name: dev-db-secret
key: password
在这里,name
属性必须与密钥的名称匹配。当容器托管 ASP.NET Core 应用程序时,将密钥作为环境变量传递非常方便,在这种情况下,环境变量都立即在配置对象中可用(参见第十七章“展示 ASP.NET Core”中的加载配置数据和使用选项框架部分)。
秘密也可以使用以下 kubectl
命令来编码 HTTPS 证书的密钥/证书对:
kubectl create secret tls test-tls --key="tls.key" --cert="tls.crt"
以这种方式定义的秘密可以用来在 Ingress 中启用 HTTPS 终止。您可以通过将秘密名称放置在 Ingress 的 spec->tls->hosts->secretName
属性中来做到这一点。
活跃性和就绪性检查
Kubernetes 自动监控所有容器以确保它们仍然存活,并且它们保持其资源消耗在 spec->containers->resources->limits
对象中声明的限制内。当某些条件被违反时,容器可能会被节流、重启,或者整个 Pod 实例在不同的节点上重启。Kubernetes 如何知道容器处于健康状态?虽然它可以使用操作系统来检查节点的健康状态,但它没有适用于所有容器的通用检查。
因此,容器本身必须通知 Kubernetes 它们的健康状况,否则 Kubernetes 无法验证它们。容器可以通过两种方式通知 Kubernetes 它们的健康状况:要么声明一个返回其健康状况的控制台命令,要么声明一个提供相同信息的端点。
这两个声明都包含在 spec-> containers-> livenessProbe
对象中。控制台命令检查声明如下:
...
livenessProbe:
exec:
command:
- cat
- /tmp/healthy
initialDelaySeconds: 10
periodSeconds: 5
...
如果 command
返回 0
,则容器被认为是健康的。在先前的例子中,容器中运行的软件将它的健康状况记录在 /tmp/healthy
文件中,因此 cat /tmp/healthy
命令返回它。PeriodSeconds
是检查之间的时间,而 initialDelaySeconds
是在执行第一次检查之前的初始延迟。初始延迟总是必要的,以便给容器启动留出时间。
端点检查相当类似:
...
livenessProbe:
exec:
httpGet:
path: /healthz
port: 8080
httpHeaders:
- name: Custom-Health-Header
value: container-is-ok
initialDelaySeconds: 10
periodSeconds: 5
...
如果 HTTP 响应包含声明的带有声明的值的头,则测试成功。您也可以使用纯 TCP 检查,如下所示:
...
livenessProbe:
exec:
tcpSocket:
port: 8080
initialDelaySeconds: 10
periodSeconds: 5
...
在这种情况下,如果 Kubernetes 能够在声明的端口上打开到容器的 TCP 套接字,则检查成功。
类似地,一旦容器安装完成,其就绪性通过就绪性检查进行监控。就绪性检查的定义方式与活跃性检查类似,唯一的区别是 livenessProbe
被替换为 readinessProbe
。
下一个子节将解释如何自动扩展 Deployment。
自动扩展
我们不必手动修改 Deployment 中的副本数量以适应负载的减少或增加,我们可以让 Kubernetes 自己决定保持声明的资源消耗恒定所需的副本数量。因此,例如,如果我们声明目标为 10% 的 CPU 消耗,那么当每个副本的平均资源消耗超过此限制时,将创建一个新的副本。如果平均 CPU 消耗低于此限制,则销毁一个副本。用于监控副本的典型资源是 CPU 消耗,但我们也可以使用内存消耗。
在实际的高流量生产系统中,自动扩展是必须的,因为它是快速适应负载变化系统的唯一方式。
通过定义HorizontalPodAutoscaler
对象来实现自动扩展。以下是一个HorizontalPodAutoscaler
定义的示例:
apiVersion: autoscaling/v2beta1
kind: HorizontalPodAutoscaler
metadata:
name: my-autoscaler
spec:
scaleTargetRef:
apiVersion: extensions/v1beta1
kind: Deployment
name: my-deployment-name
minReplicas: 1
maxReplicas: 10
metrics:
- type: Resource
resource:
name: cpu
targetAverageUtilization: 25
spec-> scaleTargetRef->name
指定了要自动扩展的 Deployment 的名称,而targetAverageUtilization
指定了目标资源(在我们的情况下,cpu
)的百分比使用率(在我们的情况下,25%)。
下一个子节简要介绍了 Helm 包管理器和 Helm 图表,并解释了如何在 Kubernetes 集群上安装 Helm 图表。还提供了一个如何安装 Ingress Controller 的示例。
Helm – 安装 Ingress Controller
Helm 图表是组织复杂 Kubernetes 应用程序安装的方式,这些应用程序包含多个.yaml
文件。Helm 图表是一组组织成文件夹和子文件夹的.yaml
文件。以下是从官方文档中摘取的典型 Helm 图表文件夹结构:
图 20.9:Helm 图表的文件夹结构
特定于应用程序的.yaml
文件放置在顶层的templates
目录中,而charts
目录可能包含其他用作辅助库的 Helm 图表。顶层Chart.yaml
文件包含有关包(名称和描述)的一般信息,以及应用程序版本和 Helm 图表版本。以下是一个典型的示例:
apiVersion: v2
name: myhelmdemo
description: My Helm chart
type: application
version: 1.3.0
appVersion: 1.2.0
在这里,type
可以是application
或library
。只有application
图表可以被部署,而library
图表是用于开发其他图表的工具。library
图表放置在其他 Helm 图表的charts
文件夹中。
为了配置每个特定的应用程序安装,Helm 图表.yaml
文件包含在安装 Helm 图表时指定的变量。此外,Helm 图表还提供了一种简单的模板语言,允许在某些条件满足的情况下仅包含一些声明,这些条件依赖于输入变量。顶层values.yaml
文件声明了输入变量的默认值,这意味着开发者只需指定那些需要与默认值不同的变量即可。我们不会描述 Helm 图表模板语言,因为它过于广泛,但您可以在进一步阅读部分中提到的官方 Helm 文档中找到它。
Helm 图表通常以类似于 Docker 镜像的方式组织在公共或私有存储库中。有一个 Helm 客户端,您可以使用它从远程存储库下载包并在 Kubernetes 集群中安装图表。Helm 客户端在 Azure Cloud Shell 中立即可用,因此您可以在不安装它的情况下开始使用 Helm 为您的 Azure Kubernetes 集群。
在使用其软件包之前,必须先添加远程仓库,如下例所示:
helm repo add <my-repo-local-name> https://charts.helm.sh/stable
上述命令使远程仓库的软件包可用,并为该远程仓库指定一个本地名称。之后,可以使用如下命令安装远程仓库中的任何软件包:
helm install <instance name><my-repo-local-name>/<package name> -n <namespace>
在这里,<namespace>
是安装应用程序的命名空间。像往常一样,如果没有提供,则假定使用 default
命名空间。<instance name>
是你为安装的应用程序指定的名称。你需要这个名称来使用以下命令获取安装应用程序的信息:
helm status <instance name>
你也可以使用以下命令获取使用 Helm 安装的所有应用程序的信息:
helm ls
删除集群中的应用程序也需要应用程序名称,如下述命令所示:
helm delete <instance name>
当我们安装应用程序时,我们还可以提供一个包含所有要覆盖的变量值的 .yaml
文件。我们还可以指定 Helm 图表的特定版本,否则将使用最新版本。以下是一个同时覆盖版本和值的示例:
helm install <instance name><my-repo-local-name>/<package name> -f values.yaml --version <version>
最后,可以使用 --set
选项直接提供值覆盖,如下所示:
...--set <variable1>=<value1>,<variable2>=<value2>...
我们也可以使用 upgrade
命令升级现有的安装,如下所示:
helm upgrade <instance name><my-repo-local-name>/<package name>...
可以使用 upgrade
命令通过 --f
选项或 --set
选项指定新的值覆盖,并通过 --version
指定新版本。
让我们使用 Helm 为 Guestbook 演示应用程序提供 Ingress。更具体地说,我们将使用 Helm 安装基于 Nginx 的 Ingress 控制器。需要遵循的详细步骤如下:
-
添加远程仓库:
helm repo add gcharts https://charts.helm.sh/stable
-
安装 Ingress 控制器:
helm install ingress gcharts/nginx-ingress
-
安装完成后,如果你输入
kubectl get service
,应该能在已安装的服务中看到已安装的 Ingress 控制器的条目。该条目应包含一个公网 IP。请记下此 IP,因为这将作为应用程序的公网 IP。 -
打开
frontend.yaml
文件,删除type: LoadBalancer
行。保存并上传到 Azure Cloud Shell。我们将前端应用程序的服务类型从LoadBalancer
改为ClusterIP
(默认)。此服务将连接到你即将定义的新 Ingress。 -
根据 部署演示 Guestbook 应用程序 子节中的详细说明,使用
kubectl
部署redis-master.yaml
、redis-slave.yaml
和frontend.yaml
。创建一个frontend-ingress.yaml
文件,并将以下代码放入其中:apiVersion: extensions/v1beta1 kind: Ingress metadata: name: simple-frontend-ingress spec: rules: - http: paths: - path:/ backend: serviceName: frontend servicePort: 80
-
将
frontend-ingress.yaml
文件上传到 Azure Cloud Shell,并使用以下命令部署它:kubectl apply -f frontend-ingress.yaml
-
打开浏览器,导航到你在 步骤 3 中记下的公网 IP。在那里,你应该能看到应用程序正在运行。
在 步骤 3 分配给 Ingress-Controller
的公共 IP 地址也列在 Azure 的 Azure 公共 IP 地址 部分。你可以在 Azure 搜索框中搜索此部分来找到它。一旦进入此部分,你应该能看到列出的此 IP 地址。在那里,你还可以将其分配为类型为 <你选择的名字>.<你的 Azure 区域>.cloudeapp.com
的主机名。
我们建议学习 letsencrypt.org/
上的文档,了解如何要求证书、为应用程序的公共 IP 分配主机名,然后使用此主机名从 letsencrypt.org/
获取免费的 HTTPS 证书。不幸的是,我们无法提供更多细节,因为要求证书的流程过于复杂。一旦你获得证书,你可以使用以下命令从它生成一个密钥:
kubectl create secret tls guestbook-tls --key="tls.key" --cert="tls.crt"
然后,你可以通过添加以下 spec->tls
部分到 frontend-ingress.yaml Ingress
中,将前面的密钥添加到你的 frontend-ingress.yaml Ingress
:
...
spec:
...
tls:
- hosts:
- <chosen name>.<your Azure region>.cloudeapp.com
secretName: guestbook-tls
在更正后,将文件上传到你的 Azure Cloud Shell 实例,并使用以下内容更新之前的 Ingress 定义:
kubectl apply –f frontend-ingress.yaml
到目前为止,你应该能够使用 HTTPS 访问 Guestbook 应用程序。
当你完成实验后,请不要忘记从你的集群中删除所有内容,以避免浪费你的免费 Azure 信用额度。你可以通过以下命令来完成此操作:
kubectl delete –f frontend-ingress.yaml
kubectl delete –f frontend.yaml
kubectl delete –f redis-slave.yaml
kubectl delete –f redis-master.yaml
helm delete ingress
摘要
在本章中,我们描述了 Kubernetes 的基本概念和对象,然后解释了如何创建 AKS 集群。我们还展示了如何部署应用程序,以及如何使用简单的演示应用程序监控和检查集群的状态。
本章还描述了更多在实用应用程序中具有基本角色的 Kubernetes 高级功能,包括如何为在 Kubernetes 上运行的容器提供持久存储,如何通知 Kubernetes 容器的健康状态,以及如何提供高级 HTTP 服务,例如 HTTPS 和基于名称的虚拟主机。
最后,我们回顾了如何使用 Helm 安装复杂的应用程序,并对 Helm 及 Helm 命令进行了简要介绍。
接下来,我们将介绍本书的案例研究。
问题
-
为什么需要服务?
-
为什么需要 Ingress?
-
为什么需要 Helm?
-
在同一个
.yaml
文件中定义多个 Kubernetes 对象是否可能?如果是,该如何操作? -
Kubernetes 如何检测容器故障?
-
为什么需要持久卷声明?
-
ReplicaSet 和 StatefulSet 之间有什么区别?
进一步阅读
-
扩展本章所学知识的好书如下:
www.packtpub.com/product/hands-on-kubernetes-on-azure-second-edition/9781800209671
。 -
Kubernetes 和
.yaml
文件的官方文档可以在这里找到:kubernetes.io/docs/home/
。 -
关于 Helm 和 Helm 图表的更多信息可以在官方文档中找到。这是一份非常优秀的文档,包含了一些很好的教程:
helm.sh/
. -
Azure Kubernetes 的官方文档可以在此处找到:
docs.microsoft.com/en-US/azure/aks/
. -
基于 Azure Application Gateway 的入口控制器的官方文档可在以下链接找到:
github.com/Azure/application-gateway-kubernetes-ingress
. -
入口证书的发布和续订可以自动化,具体说明请参阅此处:
docs.microsoft.com/azure/application-gateway/ingress-controller-letsencrypt-certificate-application-gateway
。虽然该流程指定了基于 Azure Application Gateway 的入口控制器,但它适用于任何入口控制器。
留下您的评价!
喜欢这本书吗?通过留下亚马逊评价来帮助像您这样的读者。扫描下面的二维码以获取 20% 的折扣码。
*限时优惠
第二十一章:案例研究
如前几章所述,对于这个新版本,我们重新构思了本书的案例研究——世界野生旅行俱乐部(WWTravelClub)。这个案例研究将带你了解为旅行社创建软件架构的过程。
本案例研究的目的是不是为了提供一个现成的生产应用,而是帮助你理解每一章中解释的理论,并提供一个如何使用 Azure、Azure DevOps、C# 12、.NET 8、ASP.NET Core 以及本书中介绍的所有其他技术开发企业应用的示例。
让我们从描述我们的案例研究应用开始。然后,我们将逐步过渡到正式规范。
介绍世界野生旅行俱乐部
WWTravelClub 是一家旨在全球范围内革新度假规划和旅行体验的旅行社。为此,他们正在开发一个在线服务,其中每个旅行的方面都经过精心策划,并由一支专门针对特定目的地的专家团队支持。
这个平台的概念是你可以同时成为访客和目的地专家。你作为目的地专家参与的越多,你获得的积分就越多。这些积分可以用来兑换人们通过平台在线购买的门票。
WWTravelClub 项目的负责人为该平台带来了以下要求列表:
-
常见用户视图:
-
首页上的促销包
-
获得推荐
-
搜索包
-
每个包的详细信息:
-
购买包
-
购买包含专家俱乐部的包
-
评论你的体验
-
向专家提问
-
评估专家
-
注册为普通用户
-
-
-
目的地专家视图:
-
与常见用户视图相同的视图
-
回答询问你目的地专业知识的问题
-
管理你通过回答问题获得的积分
-
用积分兑换门票
-
-
管理员视图:
-
管理包
-
管理普通用户
-
管理目的地专家
-
除了平台上要求的功能外,重要的是要注意,WWTravelClub 计划为每个包配备超过 100 位目的地专家,并将提供全球约 1,000 种不同的包。
重要的是要知道,通常,客户不会为开发准备好的要求。这就是为什么收集需求的过程如此重要,如第一章,理解软件架构的重要性中所述。这个过程将把上述列表转换成用户需求和系统需求。让我们看看在下一节中这将如何运作。
用户需求和系统需求
如第一章,理解软件架构的重要性中所述,为了总结用户需求,你可以使用用户故事模式。我们在这里采用了这种方法,以便你可以阅读以下针对 WWTravelClub 的用户故事:
-
US_001
: 作为一名普通用户,我想在主页上查看促销套餐,以便我能轻松找到我的下一个假期。 -
US_002
: 作为一名普通用户,我想搜索主页上找不到的套餐,以便我能探索其他旅行机会。 -
US_003
: 作为一名普通用户,我想查看套餐的详细信息,以便我能决定购买哪个套餐。 -
US_004
: 作为一名普通用户,我想注册自己,以便我能开始购买套餐。 -
US_005
: 作为一名注册用户,我想处理支付,以便我能购买套餐。 -
US_006
: 作为一名注册用户,我想购买包含专家推荐套餐,以便我能拥有独特的旅行体验。 -
US_007
: 作为一名注册用户,我想请求一位专家,以便我能了解旅行中最佳的活动。 -
US_008
: 作为一名注册用户,我想对我的体验进行评论,以便我能对我的旅行提供反馈。 -
US_009
: 作为一名注册用户,我想评价帮助过我的专家,以便我能与他人分享他们多么出色。 -
US_010
: 作为一名注册用户,我想注册为目的地专家,以便我能帮助前往我所在城市旅行的人。 -
US_011
: 作为一名专家用户,我想回答关于我所在城市的问题,以便我能获得未来可兑换的积分。 -
US_012
: 作为一名专家用户,我想用积分兑换机票,以便我能环游世界。 -
US_013
: 作为一名管理员用户,我想管理套餐,以便用户能有机会享受精彩的旅行。 -
US_014
: 作为一名管理员用户,我想管理注册用户,以便 WWTravelClub 能保证良好的服务质量。 -
US_015
: 作为一名管理员用户,我想管理专家用户,以便所有关于我们目的地的问题都能得到解答。 -
US_016
: 作为一名管理员用户,我想提供世界上超过 1,000 个套餐,以便不同国家能体验 WWTravelClub 的服务。 -
US_017
: 作为首席执行官,我想让超过 1,000 名用户同时访问网站,以便业务能够有效扩展。 -
US_018
: 作为一名用户,我想用我的母语访问 WWTravelClub,以便我能轻松理解提供的套餐。 -
US_019
: 作为一名用户,我想在 Chrome、Firefox 和 Edge 网络浏览器中访问 WWTravelClub,以便我能使用我偏好的网络浏览器。 -
US_020
: 作为一名用户,我想知道我的信用卡信息是安全存储的,以便我能安全购买套餐。 -
US_021
: 作为一名用户,我想根据来自我所在城市的人的建议获得一个不错的旅游地点推荐,以便我能了解适合我风格的新地方。
注意,在编写故事时,可以包含与安全、环境、性能和可扩展性等非功能性要求相关的信息。
然而,在编写用户故事时,可能省略了一些系统要求,这些要求需要包含在软件规范中。这些要求可能与法律方面、硬件和软件先决条件有关,甚至可能涉及正确交付系统的注意事项。我们在第二章,非功能性需求中讨论了它们。因此,非功能性需求需要映射和列出,以及用户故事。以下列出了 WWTravelClub 的系统要求。请注意,要求是用将来时态写的,因为系统尚不存在:
-
SR_001
:系统应使用云计算组件来提供所需的可伸缩性。 -
SR_002
:系统应遵守通用数据保护条例(GDPR)的要求。 -
SR_003
:在任何情况下,当 1000 个用户同时访问该系统时,任何网页的响应时间至少应为 2 秒。
列出这些用户故事和系统要求的想法是为了帮助你从架构的角度思考,了解平台开发可能有多复杂。
现在我们有了平台用例列表,是时候开始选择在 WWTravelClub 中将使用的.NET 项目类型了。让我们在下一节中检查它们。
WWTravelClub 使用的.NET 项目的主要类型
本书用例的开发将基于各种类型的.NET Core Visual Studio 项目。本节描述了所有这些项目。让我们在 Visual Studio 的文件菜单中选择新建项目。
例如,你可以在搜索引擎中输入以下内容来过滤.NET Core项目类型:
图 21.1:在 Visual Studio 中搜索.NET Core 项目类型
在那里,你可以找到常见的 C#项目(控制台、类库、Windows Forms 和 WPF),以及各种类型的测试项目,每个项目基于不同的测试框架:xUnit、NUnit 和 MSTest。选择各种测试框架只是个人喜好问题,因为它们都提供了类似的功能。将测试添加到构成解决方案的每一块软件中是一种常见做法,这允许软件频繁修改而不会危及其可靠性。
你还可能想在.NET Standard下定义你的类库项目,这在第五章,实现 C# 12 中的代码重用中已有讨论。这些类库基于标准,使它们与多个.NET 版本兼容。例如,基于 2.0 标准的库与所有大于或等于 2.0 的.NET Core 版本、所有大于 5 的.NET 版本以及所有大于 4.6 的.NET Framework 版本兼容。这种兼容性优势是以牺牲更少的功能为代价的。
除了将项目类型过滤到云中,我们还有更多项目类型。其中一些将使我们能够定义微服务。基于微服务的架构允许将应用程序拆分为几个独立的微服务。可以创建相同微服务的多个实例,并将它们分布到多台机器上以微调每个应用程序部分的表现。如果您想了解微服务,我们已在以下章节中讨论了它们:
-
第十一章,将微服务架构应用于您的企业应用
-
第十四章,使用.NET 实现微服务
-
第二十章,Kubernetes
在第二章,非功能性需求中,我们在使用.NET 8 创建可扩展的 Web 应用子节中描述了一个 ASP.NET Core 应用程序项目。在那里,我们定义了一个 ASP.NET Core 应用程序,但 Visual Studio 还包含基于 RESTful API 和最重要的单页应用框架(如 Angular、React、Vue.js 以及基于 WebAssembly 的 Blazor 框架)的项目模板,这些框架在第十九章客户端框架:Blazor中进行了讨论。其中一些在标准的 Visual Studio 安装中可用,而其他则需要安装一个名为ASP.NET 和 Web 开发的工作负载的 SPA 包。
最后,在第九章测试您的企业应用中详细讨论了测试项目。我们建议您作为软件架构师,通过创建可能帮助您定义最佳项目类型的概念验证来尝试 Visual Studio 中所有可用的模板。
现在我们已经检查了这些不同的项目类型,让我们看看 Azure DevOps 以及它如何有助于管理 WWTravelClub 的需求。
使用 Azure DevOps 管理 WWTravelClub 的需求
如在第三章管理需求中讨论的,对于软件开发项目的一个重要步骤是团队将如何组织从用户需求映射过来的用户故事。在那里,正如在在 Azure DevOps 中管理系统需求部分中描述的,Azure DevOps 使您能够使用工作项来记录系统需求,这些工作项主要是需要完成以交付产品或服务的任务或行动。
还需要记住,可用的工项取决于您在创建 Azure DevOps 项目时选择的工项流程。
考虑到为 WWTravelClub 描述的场景,我们决定使用敏捷流程,并定义了以下三个史诗级工作项:
图 21.2:用户案例史诗
这些工作项的创建相当简单:
-
在每个工作项内部,我们将不同类型的工作项相互关联,正如您在图 21.3中可以看到的那样。
-
在软件开发过程中确定工作项之间的联系非常重要。因此,作为一名软件架构师,您必须向您的团队提供这些知识,而且更重要的是,您必须激励他们建立这些联系:
图 21.3:定义一个特性链接到选定的史诗
- 一旦您创建了一个特性工作项,您将能够将其连接到多个详细说明其规格的用户故事工作项。以下截图显示了详细信息:
图 21.4:产品待办事项工作项
- 之后,可以为每个用户故事工作项创建任务和测试用例工作项。Azure DevOps 提供的用户界面非常有效,因为它允许您跟踪功能链及其之间的关系。
图 21.5:看板视图
- 考虑到我们以 Scrum 作为敏捷过程的基础,一旦您完成了用户故事和任务工作项的输入,您将能够与您的团队一起规划项目冲刺。计划视图允许您将用户故事工作项拖放到每个计划好的冲刺/迭代中:
图 21.6:待办事项视图
- 通过点击右侧的特定冲刺,您将只看到分配给该冲刺的工作项。每个冲刺页面相当类似于待办事项页面,但包含更多标签页,例如,您可以定义冲刺周期和团队容量。
图 21.7:规划视图
左侧的冲刺菜单也非常有用,它允许每个用户立即跳转到他们参与的所有项目的当前冲刺。
这些工作项就是这样创建的。一旦您理解了这个机制,您将能够创建和规划任何软件项目。值得一提的是,工具本身并不能解决与团队管理相关的问题。然而,这个工具是激励团队更新项目状态的一个很好的方式,这样您就可以保持对项目进度的清晰视角。
既然我们已经定义了如何管理 WWTravelClub 的需求,那么是时候开始考虑开发者将遵循的代码标准了。让我们在下一节中查看。
WWTravelClub 的代码标准 – 编写代码时的注意事项和禁忌
在第四章,C# 12 编码最佳实践中,我们了解到,作为一名软件架构师,您必须定义一个符合您所在公司需求的代码标准。
在本书的示例项目中,这一点并无不同。我们决定通过描述一系列“应该做”和“不应该做”的标准来展示这一标准。我们在编写示例时遵循了这个列表。值得一提的是,这个列表是一个很好的开始标准,并且作为一个软件架构师,你应该与团队中的开发者讨论这个列表,以便以实际和良好的方式发展它。
还重要的是要记住,在第四章“C# 编码最佳实践 12”的“理解和应用可以评估 C# 代码的工具”部分,我们讨论了一些可以帮助你为团队定义 coding style 的好工具*。
此外,以下语句旨在阐明团队成员之间的沟通,并提高你正在开发的软件的性能和维护性。
你可能会认为下面的列表在书中是浪费空间的,因为我们有很好的 C# 社区编码标准,无需强制执行标准。然而,没有它,你怎么能保证可维护性呢?如果定义编码标准不是必要的,我们就不会有那么多维护问题。所以,让我们检查为 WWTravelClub 项目定义的“应该做”和“不应该做”的列表:
-
用英语编写你的代码。
-
对于由多个单词组成的公共成员、类型和命名空间名称,始终使用 PascalCasing。
-
对于参数名称,始终使用 camelCasing。
-
用可理解的名字编写类、方法和变量。
-
对公共类、方法和属性进行注释。
-
在可能的情况下,始终使用
using
语句。 -
在可能的情况下,始终使用
async
实现。 -
不要编写空的
try-catch
语句。 -
不要编写复杂度得分超过 10 的方法。
-
不要在
for/while/do-while/foreach
语句中使用break
和continue
。
这些“应该做”和“不应该做”的规则很容易遵循,而且比这更好,它们将为你的团队产生的代码带来很好的结果。值得一提的是,这些 DOs 和 DON’Ts 只是一个指南,而不是每个团队都必须遵循的硬性规则。在发送给团队成员之前,可以根据团队的具体需求进行调整。作为一个软件架构师,确保所有团队成员遵循相同的 DOs 和 DON’Ts 是至关重要的。
考虑到我们现在已经定义了编码标准,让我们学习如何将 SonarCloud 作为代码分析的工具来帮助我们。
将 SonarCloud 应用到 WWTravelClub API 上
现在我们已经创建了 WWTravelClub 仓库,我们可以提高代码质量,正如在第四章“C# 编码最佳实践 12”中讨论的那样。正如我们在那一章中看到的,Azure DevOps 允许持续集成,这很有用。在本节中,我们将讨论更多关于 DevOps 概念和 Azure DevOps 平台为何如此有用的原因。
目前,我们想介绍的唯一一件事是,在开发人员提交代码但尚未发布之前分析代码的可能性。如今,在 SaaS 应用程序生命周期工具的 SaaS 世界中,这仅可能是因为我们有一些 SaaS 代码分析平台。此用例将使用 SonarCloud。
SonarCloud 是 Sonar 提供的 SaaS 版本。还值得注意的是,SonarCloud 特别容易进行自托管;这样,敏感的安全信息可以保存在企业内部。对于开源代码是免费的,并且可以分析存储在 GitHub、Bitbucket 和 Azure DevOps 中的代码。用户需要在这些平台上进行注册。一旦登录,假设您的代码存储在 Azure DevOps 中,您就可以遵循以下文章中描述的步骤,在您的 Azure DevOps 和 SonarCloud 之间创建连接:docs.sonarcloud.io/
。
Sonar 还提供了一种自托管版本,这在 SonarCloud 无法使用的情况下可能很有用。请访问www.sonarsource.com/
获取更多详情。
在设置 Azure DevOps 项目与 SonarCloud 之间的连接后,您将拥有以下类似的构建管道:
图 21.8:Azure 构建管道中的 SonarCloud 配置
值得注意的是,C#项目没有 GUID 编号,而 SonarCloud 需要这个编号。您可以使用此链接轻松生成一个:www.guidgenerator.com/
,或者使用 Visual Studio 中的创建 GUID工具(工具 -> 创建 GUID)。GUID 需要放置如下截图所示:
图 21.9:SonarCloud 项目 GUID
一旦完成构建,代码分析的结果将在 SonarCloud 中展示,如下面的截图所示。如果您想导航到该项目,可以访问sonarcloud.io/summary/overall?id=gabrielbaptista_wwtravelclub-4th
:
图 21.10:SonarCloud 结果
此外,到这个时候,所分析的代码尚未发布,因此这可以在发布系统之前获取下一阶段的质量。您可以将这种方法作为在提交代码时自动进行代码分析的参考。
考虑到我们已经实现了一种持续评估代码质量的方法,现在是时候设计可重用软件了。让我们在下一节中看看这个问题。
将代码重用作为快速交付良好且安全软件的方法
如同我们在第五章,第十二部分“在 C#中实现代码重用”中检查的那样,加速优质软件交付的一个好方法是创建可重用组件。下面图中可以查看为评估 WWTravelClub 内容而设计的最终解决方案。这种方法包括使用本章讨论的许多主题。首先,所有代码都放置在一个.NET 8 类库中。这意味着你可以将此代码添加到不同类型的解决方案中,例如 ASP.NET Core Web 应用和 Android 和 iOS 平台的 Xamarin 应用:
图 21.11:WWTravelClub 重用方法
此设计利用了面向对象原则,如继承,因此你不需要在许多类中多次编写可以重复使用的属性和方法。设计还利用了多态原则,这样你可以在不更改方法名称的情况下更改代码的行为。
最后,设计通过引入泛型作为工具来抽象内容的概念,以简化类似 WWTravelClub 中存在的类似类的操作,以评估有关城市、评论、目的地专家和旅行套餐的内容。
鼓励代码重用的团队与不鼓励的团队之间的主要区别是向最终用户提供优质软件的速度。当然,开始这种方法并不容易,但请放心,经过一段时间的工作后,你将获得良好的结果。
既然我们已经讨论了使用面向对象原则实现代码重用的可能性,那么我们再来看看使用由领域驱动设计(DDD)创建的领域来组织应用程序如何?我们将在下一节中检查。
理解 WWTravelClub 应用程序的领域
在本节中,我们将对 WWTravelClub 系统进行 DDD 分析,试图识别所有其领域(也称为边界上下文),即由专家使用不同语言定义的子系统。一旦识别出来,每个领域可能被分配给不同的开发团队,并产生不同的微服务。
从介绍世界野生旅行俱乐部和用户需求和系统需求部分列出的需求中,我们知道 WWTravelClub 系统由以下部分组成:
-
可用目的地和套餐的信息。
-
预订/购买订单子系统。
-
与专家/审查子系统的通信。
-
支付子系统。我们在第七章开头,在理解 DDD部分简要分析了该子系统的功能和它与预订购买子系统的关系。
-
用户账户子系统。
-
统计报告子系统。
前面的子系统代表不同的有界上下文吗?某些子系统可以被分割成不同的有界上下文吗?这些问题的答案由每个子系统使用的语言给出:
-
子系统 1 使用的语言是旅行社的语言。这里没有客户的概念,只有地点、套餐及其特点。
-
子系统 2 使用的语言是所有服务购买中共同的,例如可用的资源、预订和采购订单。这是一个独立的有界上下文。
-
子系统 3 使用的语言与子系统 1 的语言有很多共同之处。然而,也存在典型的社交媒体概念,如评分、聊天、帖子分享、媒体分享等。这个子系统可以分为两部分:一个拥有新有界上下文的社交媒体子系统和一个属于子系统 1 有界上下文的一部分的可用信息子系统。
-
正如我们在第七章的理解 DDD部分所指出的,在子系统 4 中,我们使用的是银行的语言。这个子系统与预订/采购子系统通信并执行执行购买所需的任务。从这些观察中,我们可以看出它是一个不同的有界上下文,并且与购买/预订系统有客户/供应商关系。
-
子系统 5 是一个独立的有界上下文(就像几乎所有的 Web 应用一样)。它与所有具有用户或客户概念的有界上下文有关,因为用户账户的概念总是映射到这些概念上。你可能想知道这是如何实现的。好吧,这很简单——当前登录的用户被认为是社交媒体有界上下文的社会媒体用户、预订/采购有界上下文的客户以及支付有界上下文的付款人。
-
仅查询的子系统(即 6 号)使用的是分析和统计的语言,与其他子系统的语言有很大不同。然而,它几乎与所有有界上下文都有联系,因为它从它们那里获取所有输入。前面的约束迫使我们采用强 CQRS 形式,因此将其视为仅查询分离的有界上下文。
总之,列出的每个子系统定义了一个不同的有界上下文,但与专家/审查子系统通信的部分必须包含在可用的目的地信息以及套餐的有界上下文中。
随着分析的继续和原型的实现,一些边界上下文可能会分裂,而另一些可能会被添加,但立即开始建模系统并立即开始分析边界上下文之间的关系是至关重要的,因为我们所拥有的部分信息将推动进一步的调查,并帮助我们定义所需的通信协议和通用语言,以便我们可以与领域专家互动。
以下是对领域映射的基本初步草图:
图 21.12:WWTravelClub 领域映射图。细黑箭头表示边界上下文之间的数据交换,而粗蓝箭头表示边界上下文之间的关系(从众者、客户/供应商等)
为了简化,我们省略了统计报告边界上下文。我们只是说它收集每个包每日购买的统计数据。
在这里,我们假设用户账户和社交边界上下文与所有与之通信的其他边界上下文具有从众者关系,因为它们使用现有的软件实现,所以所有其他组件必须适应它们。
如我们之前提到的,预订和支付之间的关系是客户/供应商,因为支付提供用于执行预订任务的服务。所有其他关系都被归类为合作伙伴。大多数边界上下文都有的客户/用户的各种概念由用户账户的授权令牌箭头协调,它间接地负责在所有边界上下文之间映射这些概念。
包/位置子系统向预订子系统传达以下信息:
-
选择的包信息,包含执行预订/购买所需的包信息
-
价格变动,负责通知待处理的购买订单可能的定价变动
社交互动始于用户提供的现有评论(包/位置和社交之间的评论箭头)并由包/位置到社交的位置/评论信息通信支持。
最后,预订通过价格/代码/描述箭头将购买代码、描述和价格传达给支付。
在确定了我们的应用程序的边界上下文后,我们处于组织应用程序 DevOps 周期的位置。
WWTravelClub 的 DevOps 方法
在第八章,理解 DevOps 原则和 CI/CD中,WWTravelClub 项目的截图展示了实施良好 DevOps 周期所需的步骤。WWTravelClub 团队决定使用 Azure DevOps,因为他们明白这个工具对于在整个周期中获得最佳的 DevOps 体验至关重要。实际上,它似乎是 GitHub 提供的工具中最完整的,因为它涵盖了从需求收集到在预生产和生产部署的整个 CI/CD 周期。此外,所有团队成员都已经非常熟悉它。
需求是用用户故事编写的,可以在 Azure DevOps 的工作项部分找到。代码放置在 Azure DevOps 项目的仓库中。这两个概念在第三章,管理需求中都有解释。
用于完成工作的管理生命周期是 Scrum,在第一章理解软件架构的重要性中介绍。这种方法将实施分为冲刺,这迫使每个周期结束时交付价值。使用我们在本章中学到的 CI 设施,每次团队将新代码合并到仓库的 master 分支时,代码都会被编译。
一旦代码编译并测试,部署的第一阶段就完成了。第一阶段通常被称为开发/测试,因为你是为了内部测试而启用它的。Application Insights 和测试与反馈都可以用来获取对新版本的第一反馈。
如果新版本的测试和反馈通过,那么就是时候进入第二阶段,即质量保证。Application Insights 和测试与反馈可以再次使用,但现在是在一个更稳定的环境中。
循环以在生产阶段部署的授权结束。这当然是一个艰难的决定,但 DevOps 表明你必须持续这样做,以便你能从客户那里获得更好的反馈。Application Insights 仍然是一个有用的工具,因为你可以在生产中监控新版本的演变,甚至将其与过去的版本进行比较。
这里描述的 WWTravelClub 项目方法可以用于许多其他现代应用开发生命周期。作为一名软件架构师,你必须监督这个过程。工具已经准备好了,而正确地使用它们取决于你!
因此,即使将 WWTravelClub 视为一个假设场景,在构建它时也考虑了一些担忧:
-
CI 已启用,但多阶段场景也已启用。
-
即使是多阶段场景,PR(Pull Request)也是一种保证在第一阶段只展示高质量代码的方式。
-
要在 PR(Pull Request)中做好工作,需要进行同行评审。
-
同行评审检查,例如,在创建新功能时是否存在功能标志。
-
同行评审检查了在创建新功能过程中开发的单元测试和功能测试。
上述步骤不仅适用于 WWTravelClub。作为软件架构师,您需要定义保证安全 CI/CD 场景的方法。您可以以此作为起点。值得注意的是,在 Azure DevOps 和 GitHub 中,我们可以完全禁用对 master 分支的推送,从而强制使用 PR 来合并 master 分支上的修改。
在下一节中,我们将从实际代码开始,展示如何从各种数据存储选项中进行选择。
如何在云中选择您的数据存储
在第十二章,选择云中的数据存储中,我们学习了如何使用 NoSQL。现在我们必须决定 NoSQL 数据库是否足够用于我们的书籍用例 WWTravelClub 应用。我们需要存储以下数据家族:
-
关于可用目的地和包的信息:这些数据的相关操作主要是读取,因为包和目的地不经常改变。然而,它们必须尽可能快地从世界各地访问,以确保用户浏览可用选项时的良好用户体验。因此,一个具有地理分布副本的分布式关系数据库是可能的,但不是必要的,因为包可以存储在其目的地中,使用更便宜的 NoSQL 数据库。
-
目的地评论:在这种情况下,分布式写入操作有不可忽视的影响。此外,大多数写入都是添加,因为评论通常不会被更新。添加操作从分片中受益很大,并且不会像更新那样引起一致性问题。因此,此数据最佳选项是一个 NoSQL 集合。
-
预订:在这种情况下,一致性错误是不可接受的,因为它们可能导致超订。读取和写入有相似的影响,但我们需要可靠的交易和良好的一致性检查。幸运的是,数据可以组织在一个多租户数据库中,其中租户是目的地,因为不同目的地的预订信息完全无关。因此,我们可以使用分片 Azure SQL 数据库实例。
最后,对于第一和第二点中的数据,最佳选项是 Cosmos DB,而对于第三点,最佳选项是 Azure SQL Server。实际应用可能需要对所有数据操作及其频率进行更详细的分析。在某些情况下,为各种可能的选项实现原型并进行典型工作负载的性能测试是值得的。
在本节的剩余部分,我们将迁移我们在第十三章,使用 C#与数据交互 – Entity Framework Core中查看的 destinations/packages 数据层到 Cosmos DB。
使用 Cosmos DB 实现目的地/包数据库
让我们继续到我们在第十三章,使用 C#与数据交互 – Entity Framework Core中构建的数据库示例,并按照以下步骤使用 Cosmos DB 实现此数据库:
-
首先,我们需要复制 WWTravelClubDB 项目并重命名其根文件夹为
WWTravelClubDBCosmo
。 -
打开项目并删除
Migrations
文件夹,因为不再需要迁移。 -
我们需要将 SQL Server Entity Framework 提供程序替换为 Cosmos DB 提供程序。为此,请转到 管理 NuGet 包 并卸载
Microsoft.EntityFrameworkCore.SqlServer
NuGet 包。然后,安装Microsoft.EntityFrameworkCore.Cosmos
NuGet 包。 -
然后,对
Destination
和Package
实体执行以下操作:-
移除所有数据注释。
-
由于这是 Cosmos DB 提供程序所必需的,因此需要将它们的
Id
属性添加[Key]
属性。 -
将
Package
和Destination
以及PackagesListDTO
类的Id
属性类型从int
转换为string
。我们还需要将Package
和PackagesListDTO
类中的DestinationId
外部引用转换为string
。实际上,对于分布式数据库中的键,最佳选择是从 GUID 生成的字符串,因为当表数据分布在多个服务器之间时,维护身份计数器很难。
-
-
在
MainDBContext
文件中,我们需要指定与目的地相关的包必须存储在目的地文档本身内。这可以通过在OnModelCreating
方法中替换 Destination-Package 关系配置来实现以下代码:builder.Entity<Destination>() .OwnsMany(m =>m.Packages);
-
在这里,我们必须将
HasMany
替换为OwnsMany
。没有WithOne
的等效项,因为一旦实体被拥有,它必须只有一个所有者,并且MyDestination
属性包含对父实体的指针的事实可以从其类型中明显看出。Cosmos DB 也允许使用HasMany
,但在这个情况下,两个实体不是嵌套在一起的。还有一个OwnOne
配置方法用于在实体内部嵌套单个实体。 -
在关系数据库中,
OwnsMany
和OwnsOne
都是可用的,但在这个情况下,HasMany
和HasOne
的区别在于子实体会自动包含在返回其父实体的所有查询中,无需指定一个Include
LINQ 子句。然而,子实体仍然存储在单独的表中。 -
LibraryDesignTimeDbContextFactory
必须修改为使用 Cosmos DB 连接数据,如下面的代码所示:using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore.Design; namespace WWTravelClubDB { public class LibraryDesignTimeDbContextFactory : IDesignTimeDbContextFactory<MainDBContext> { private const string endpoint = "<your account endpoint>"; private const string key = "<your account key>"; private const string databaseName = "packagesdb"; public MainDBContext CreateDbContext(params string[] args) { var builder = new DbContextOptionsBuilder<MainDBContext>(); builder.UseCosmos(endpoint, key, databaseName); return new MainDBContext(builder.Options); } } }
-
最后,在我们的测试控制台中,我们必须显式地使用 GUID 创建所有实体主键:
var context = new LibraryDesignTimeDbContextFactory() .CreateDbContext(); context.Database.EnsureCreated(); var firstDestination = new Destination { Id = Guid.NewGuid().ToString(), Name = "Florence", Country = "Italy", Packages = new List<Package>() { new Package { Id=Guid.NewGuid().ToString(), Name = "Summer in Florence", StartValidityDate = new DateTime(2019, 6, 1), EndValidityDate = new DateTime(2019, 10, 1), DurationInDays=7, Price=1000 }, new Package { Id=Guid.NewGuid().ToString(), Name = "Winter in Florence", StartValidityDate = new DateTime(2019, 12, 1), EndValidityDate = new DateTime(2020, 2, 1), DurationInDays=7, Price=500 } } };
-
在这里,我们调用
context.Database.EnsureCreated()
而不是应用迁移,因为我们只需要创建数据库。一旦数据库和集合创建完成,我们就可以从 Azure 门户调整它们的设置。希望 Cosmos DB Entity Framework Core 提供程序的将来版本将允许我们指定所有集合选项。 -
最后,必须修改最终的查询(以
context.Packages.Where...
开始),因为查询不能以嵌套在其他文档中的实体(在我们的情况下,Packages
实体)开头。因此,我们必须从我们的DBContext
中具有唯一根DbSet<T>
属性开始我们的查询,即Destinations
。我们可以借助SelectMany
方法从列出外部集合转换为列出所有内部集合,该方法执行所有嵌套Packages
集合的逻辑合并。然而,由于 Cosmos DB SQL 不支持SelectMany
,我们必须使用AsEnumerable()
在客户端强制模拟SelectMany
,如下面的代码所示:var list = context.Destinations .AsEnumerable() // move computation on the client side .SelectMany(m =>m.Packages) .Where(m => period >= m.StartValidityDate....) ...
-
查询的其余部分保持不变。如果您现在运行项目,应该看到与 SQL Server 的情况相同的输出(除了主键值)。
执行程序后,转到您的 Cosmos DB 账户。您应该看到如下内容:
图 21.13:执行结果
包已经按照要求嵌套在其目的地中,并且 Entity Framework Core 创建了一个与 DBContext
类同名的唯一集合。
如果你想在不用完所有免费的 Azure 门户信用额度的情况下继续进行 Cosmos DB 开发实验,你可以安装 Cosmos DB 模拟器,该模拟器可在以下链接中找到:aka.ms/cosmosdb-emulator
。
在学习了如何选择最佳数据存储选项之后,我们就可以开始编写我们的第一个微服务了。
一个基于 ASP.NET Core 的工作微服务
在本节中,我们将向您展示如何实现一个通过 gRPC 和基于数据库表的内部队列接收通信的微服务。第一个小节简要描述了微服务的规范和整体架构。我们鼓励您回顾 第十四章,使用 .NET 实现微服务,其中包含本例背后的所有理论。
规范和架构
我们示例微服务需要计算所有购买活动的每日总和。根据数据驱动方法,我们假设所有每日总和都是通过接收新购买活动完成时立即发送的消息预先计算的。微服务的目的是维护一个所有购买活动和所有每日总和的数据库,这些数据可以被管理员用户查询。我们将仅实现填充两个数据库表所需的功能。
本节中描述的实现基于一个托管 gRPC 服务的 ASP.NET Core 应用程序。gRPC 服务简单地填充一个消息队列并立即返回,以避免发送者在整个计算过程中被阻塞。
实际的处理是由与应用程序宿主关联的依赖注入引擎中声明的 ASP.NET Core 托管服务执行的。工作宿主服务执行一个无限循环,从中提取 N
条消息并传递给 N
个并行线程进行处理。
图 21.14:gRPC 微服务架构
当从队列中取出 N
条消息时,它们不会立即被删除,而是简单地标记为提取时间。由于只有在消息的最后提取时间足够远(比如说,时间 T
)时才能从队列中提取消息,因此在处理期间,没有其他工作线程可以再次提取它们。当消息处理成功完成后,消息将从队列中删除。如果处理失败,则不对消息采取任何操作,因此消息将保持在队列中,直到 T
间隔过期,然后可以被工作线程再次取走。
微服务可以通过增加处理器核心和线程数 N
来垂直扩展。它也可以通过使用负载均衡器来水平扩展,将负载分割成几个相同的 ASP.NET Core 应用程序副本。这种类型的水平扩展增加了可以接收消息的线程数和工作线程数,但由于所有 ASP.NET Core 应用程序共享同一个数据库,因此它受限于数据库性能。
数据库层是在一个单独的 DLL(动态链接库)中实现的,所有功能都抽象在两个接口中,一个用于与队列交互,另一个用于将新的购买记录添加到数据库中。
下一个子节简要描述了数据库层。由于示例的主要重点是微服务架构和通信技术,我们不会给出所有细节。然而,完整的代码可以在与本书相关的 GitHub 仓库的 ch15/GrpcMicroService
文件夹中找到。
在定义了整体架构之后,让我们从存储层代码开始。
存储层
存储层基于数据库。它使用 Entity Framework Core,并基于三个实体及其关联的表:
-
一个表示队列项的
QueueItem
实体 -
一个表示单个购买的
Purchase
实体 -
一个表示给定一天内所有购买总额的
DayTotal
实体
以下是对操作队列的接口定义:
public interface IMessageQueue
{
public Task<IList<QueueItem>> Top(int n);
public Task Dequeue(IEnumerable<QueueItem> items);
public Task Enqueue(QueueItem item);
}
Top
从队列中提取 N
条消息以传递给最多 N
个不同的线程。Enqueue
向队列中添加一条新消息。最后,Dequeue
从队列中移除已成功处理的项目。
更新购买数据的接口定义如下所示:
public interface IDayStatistics
{
Task<decimal> DayTotal(DateTimeOffset day);
Task<QueueItem?> Add(QueueItem model);
}
Add
向数据库添加一条新的购买记录。如果添加成功,则返回输入队列项,否则返回 null
。DayTotal
是一个查询方法,返回单日总额。
应用层通过这两个接口与数据库层进行通信,通过三个数据库实体,通过 IUnitOfWork
接口(正如在第十三章的 如何数据层和领域层与其他层通信 部分中解释的,在 C# 中与数据交互 – Entity Framework Core 抽象了 DbContext
),以及通过如下类似的依赖注入扩展方法:
public static class StorageExtensions
{
public static IServiceCollection AddStorage(this IServiceCollection services,
string connectionString)
{
services.AddDbContext<IUnitOfWork,MainDbContext>(options =>
options.UseSqlServer(connectionString, b =>
b.MigrationsAssembly("GrpcMicroServiceStore")));
services.AddScoped<IMessageQueue, MessageQueue>();
services.AddScoped<IDayStatistics, DayStatistics>();
return services;
}
}
此方法将在应用层依赖注入定义中被调用,它接收数据库连接字符串作为输入,并添加我们之前定义的两个接口抽象化的 DbContext
。
该数据库项目,名为 GrpcMicroServiceStore
,位于与本书相关的 GitHub 仓库的 ch15/GrpcMicroService
文件夹中。它已经包含了所有必要的数据库迁移,因此你可以按照以下步骤创建所需的数据库:
-
在 Visual Studio 包管理器控制台 中,选择 GrpcMicroServiceStore 项目。
-
在 Visual Studio 解决方案资源管理器 中,右键单击 GrpcMicroServiceStore 项目并将其设置为启动项目。
-
在 Visual Studio 包管理器控制台 中,执行
Update-Database
命令。
在拥有一个工作存储层之后,我们可以继续进行微服务应用层的开发。
应用层
应用层是一个名为 GrpcMicroService
的 ASP.NET Core gRPC 服务 项目。当项目由 Visual Studio 模板化时,它在其 Protos
文件夹中包含一个 .proto
文件。需要删除此文件并替换为名为 counting.proto
的文件,其内容必须如下:
syntax = "proto3";
option csharp_namespace = "GrpcMicroService";
import "google/protobuf/timestamp.proto";
package counting;
service Counter {
// Accepts a counting request
rpc Count (CountingRequest) returns (CountingReply);
}
message CountingRequest {
string id = 1;
google.protobuf.Timestamp time = 2;
string location = 3;
sint32 cost =4;
google.protobuf.Timestamp purchaseTime = 5;
}.
message CountingReply {}
上述代码定义了带有其输入和输出消息的 gRPC 服务以及放置它们的 .NET 命名空间。我们导入 google/protobuf/timestamp.proto
预定义的 .proto
文件,因为我们需要 TimeStamp
类型。请求包含购买数据,请求消息创建时的 time
,以及一个唯一的消息 id
,该 id
用于强制消息幂等性。
在数据库层,IDayStatistics.Add
方法的实现使用此 id
来验证是否已经处理了具有相同 id
的购买,如果是,则立即返回:
bool processed = await ctx.Purchases.AnyAsync(m => m.Id == model.MessageId);
if (processed) return model;
通过替换现有的 protobuf
XML 标签来启用此文件的自动代码生成:
<Protobuf Include="Protos\counting.proto" GrpcServices="Server" />
将 Grpc
属性设置为 "Server"
启用服务器端代码生成。
在 Services
项目文件夹中,Visual Studio 预定义的 gRPC 服务模板必须替换为名为 CounterService.cs
的文件,其内容如下:
using Grpc.Core;
using GrpcMicroServiceStore;
namespace GrpcMicroService.Services;
public class CounterService: Counter.CounterBase
{
private readonly IMessageQueue queue;
public CounterService(IMessageQueue queue)
{
If (queue == null) throw new ArgumentNullException(nameof(queue));
this.queue = queue;
}
public override async Task<CountingReply> Count(CountingRequest request,
ServerCallContext context)
{
await queue.Enqueue(new GrpcMicroServiceStore.Models.QueueItem
{
Cost = request.Cost,
MessageId = Guid.Parse(request.Id),
Location = request.Location,
PurchaseTime = request.PurchaseTime.ToDateTimeOffset(),
Time = request.Time.ToDateTimeOffset()
});
return new CountingReply { };
}
}
实际接收购买消息的服务继承自代码生成器从counting.proto
文件创建的Counter.CounterBase
抽象类。它通过依赖注入接收数据库层接口IMessageQueue
,并重写从Counter.CounterBase
继承的抽象Count
方法。然后,Count
使用IMessageQueue
将每个接收到的消息入队。
在编译之前,还需要进行几个其他步骤:
-
我们必须添加对数据库层
GrpcMicroServiceStore
项目的引用。 -
我们必须将数据库连接字符串添加到
appsettings.json
设置文件中:"ConnectionStrings": { "DefaultConnection": "Server=(localdb)\\mssqllocaldb;Database=grpcmicroservice;Trusted_Connection=True;MultipleActiveResultSets=true" }
-
我们必须通过调用
AddStorage
数据库层扩展方法将所有必要的数据库层接口添加到依赖注入中:builder.Services.AddStorage( builder.Configuration.GetConnectionString("DefaultConnection"));
-
在
Program.cs
中,我们必须删除 Visual Studio 生成的 gRPC 服务的声明,并将其替换为:app.MapGrpcService<CounterService>();
到目前为止,编译应该成功。完成应用层基础设施后,我们可以转向执行实际队列处理的主托管服务。
处理队列中的请求
实际请求处理由一个与 ASP.NET Core 管道并行运行的 worker-hosted 服务执行。它是在第十一章的“使用通用宿主”部分中讨论的主托管服务实现的。值得回忆的是,主托管服务是依赖注入引擎中定义的IHostedService
接口的实现,如下所示:
builder.Services.AddHostedService<MyHostedService>();
我们已经在《第十四章,使用.NET 实现微服务》的“使用 ASP.NET Core 实现工作微服务”部分描述了如何实现基于 ASP.NET Core 的工作微服务的托管服务。
下面,我们重复整个代码,包括我们示例中特有的所有细节。主托管服务定义在HostedServices
文件夹中的ProcessPurchases.cs
文件中:
using GrpcMicroServiceStore;
using GrpcMicroServiceStore.Models;
namespace GrpcMicroService.HostedServices;
public class ProcessPurchases : BackgroundService
{
IServiceProvider services;
public ProcessPurchases(IServiceProvider services)
{
this.services = services;
}
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
{
bool queueEmpty = false;
while (!stoppingToken.IsCancellationRequested)
{
while (!queueEmpty && !stoppingToken.IsCancellationRequested)
{
...
}
await Task.Delay(100, stoppingToken);
queueEmpty = false;
}
}
}
下面是内部循环的内容:
using (var scope = services.CreateScope())
{
IMessageQueue queue = scope.ServiceProvider.GetRequiredService<IMessageQueue>();
var toProcess = await queue.Top(10);
if (toProcess.Count > 0)
{
Task<QueueItem?>[] tasks = new Task<QueueItem?>[toProcess.Count];
for (int i = 0; i < tasks.Length; i++)
{
var toExecute = ...
tasks[i] = toExecute();
}
await Task.WhenAll(tasks);
await queue.Dequeue(tasks.Select(m => m.Result)
.Where(m => m != null).OfType<QueueItem>());
}
else queueEmpty = true;
}
上述代码已在《第十四章,使用.NET 实现微服务》的“使用 ASP.NET Core 实现工作微服务”部分中解释过。因此,在这里,我们将仅分析我们示例中特有的toExecute
lambda 表达式:
var toExecute = async () =>
{
using (var sc = services.CreateScope())
{
IDayStatistics statistics = sc.ServiceProvider.GetRequiredService<IDayStatistics>();
return await statistics.Add(toProcess[i]);
}
};
每个任务创建一个不同的会话作用域,以便它可以拥有IDayStatistics
的私有副本,然后使用statistics.Add
处理其请求。
就这些了!现在我们需要一个购买数据的来源来测试我们的代码。在下一小节中,我们将创建一个模拟微服务,该微服务随机生成购买数据并将其传递给Counter
gRPC
服务。
使用模拟购买请求生成器测试 GrpcMicroservice 项目
让我们实现另一个微服务,该微服务使用随机生成的请求为之前的微服务提供数据。对于不基于 ASP.NET Core 的工作服务,合适的模板是 Worker Service 项目模板。这个项目模板自动生成一个包含一个唯一托管服务 Worker
的宿主。我们把这个项目命名为 FakeSource
。为了启用 gRPC 客户端使用,我们必须添加以下 NuGet 包:Google.Protobuf
、Grpc.NET.Client
和 Grpc.Tools
。
然后,我们必须添加与之前项目相同的 counting.proto
文件。然而,这次,我们必须在 FakeSource
项目文件中放置以下代码以要求客户端代码生成:
<ItemGroup>
<Protobuf Include="..\GrpcMicroService\Protos\counting.proto" GrpcServices="Client">
<Link>Protos\counting.proto</Link>
</Protobuf>
</ItemGroup>
将 GrpcServices
属性设置为 Client
的操作使得客户端代码生成而不是服务器代码生成成为可能。由于我们将 GrpcMicroService
项目的相同 counting.proto
文件作为链接而不是复制到新项目中,因此出现了 link
标签。
托管服务是用通常的无尽循环定义的:
using Grpc.Net.Client;
using GrpcMicroService;
using Google.Protobuf.WellKnownTypes;
namespace FakeSource;
public class Worker : BackgroundService
{
private readonly string[] locations = new string[]
{ "Florence", "London", "New York", "Paris" };
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
{
Random random = new Random();
while (!stoppingToken.IsCancellationRequested)
{
try
{
...
await Task.Delay(2000, stoppingToken);
}
catch (OperationCanceledException)
{
return;
}
catch { }
}
}
}
locations
数组包含将被随机选择的地点。一旦 ExecuteAsync
方法开始执行,它就会创建一个用于所有随机生成的 Random
实例。
每个循环都被包含在一个 try
/catch
中;如果生成了 OperationCanceledException
,则方法退出,因为当应用程序正在关闭且线程被终止时,会创建类似的异常。在遇到其他异常的情况下,代码会尝试通过简单地移动到下一个循环来恢复。在实际的生产应用程序中,最后的 catch
应该包含记录拦截到的异常和/或更好的恢复策略的指令。在下一个示例中,我们将看到更复杂的异常处理,这对于实际的生产应用程序是足够的。
在 try
块内部,代码创建一个购买消息,将其发送到 Counter
服务,然后休眠 2 秒。
下面是发送请求的代码:
var purchaseDay = new DateTimeOffset(DateTime.UtcNow.Date, TimeSpan.Zero);
//randomize a little bit purchase day
purchaseDay = purchaseDay.AddDays(random.Next(0, 3) - 1);
//message time
var now = DateTimeOffset.UtcNow;
//add random location
var location = locations[random.Next(0, locations.Length)];
var messageId = Guid.NewGuid().ToString();
//add random cost
int cost = 200 * random.Next(1, 4);
//send message
using var channel = GrpcChannel.ForAddress("http://localhost:5000");
var client = new Counter.CounterClient(channel);
//since this is a fake random source
//in case of errors we simply do nothing.
//An actual client should use Polly
//to define retry policies
try
{
await client.CountAsync(new CountingRequest
{
Id = messageId,
Location = location,
PurchaseTime = Timestamp.FromDateTimeOffset(purchaseDay),
Time = Timestamp.FromDateTimeOffset(now),
Cost = cost
});
}
catch {}
代码只是用随机数据准备消息;然后,它为 gRPC 服务器地址创建一个通信通道,并将其传递给 Counter
服务代理的构造函数。最后,在代理上调用 Count
方法。调用被包含在 try
/catch
中,如果发生错误,错误会被简单地忽略,因为我们只是发送随机数据。相反,实际的生产应用程序应该使用 Polly 来使用预定义的策略重试通信。Polly 在第十一章,将微服务架构应用于您的企业应用程序的 弹性任务执行 部分中进行了描述。在下一节中,我们将向您展示如何使用 Polly。
到此为止!现在到了测试一切的时候了。右键单击解决方案,选择 设置启动项目,然后将 FakeSource
和 GrpcMicroService
都设置为启动。这样,当解决方案运行时,这两个项目将同时启动。
启动 Visual Studio,然后让两个进程运行几分钟,然后转到 SQL Server 对象资源管理器 并查找名为 grpcmicroservice
的数据库。如果 SQL Server 对象资源管理器 窗口在 Visual Studio 左侧菜单中不可用,请转到顶部 窗口 菜单并选择它。
一旦找到数据库,显示 DayTotals
和 Purchases
表的内容。您应该看到所有计算出的每日总和以及所有已处理购买。
您还可以通过打开 HostedServices/ProcessPurchases.cs
文件并在 queue.Top(10)
和 await
queue.Dequeue(...)
指令上设置断点来检查服务器项目中发生的情况。
您还可以将 FakeSource
移动到不同的 Visual Studio 解决方案中,这样您就可以同时在不同的 Visual Studio 实例中运行几个 FakeSource
的副本。也可以双击 FakeSource
项目,这将提供保存包含仅对 FakeSource
项目引用的新 Visual Studio 解决方案选项。
完整代码位于书籍 GitHub 仓库的 ch15
文件夹中的 GrpcMicroService
子文件夹中。下一节将向您展示如何使用 RabbitMQ 消息代理以队列通信方式解决相同的问题。
基于 RabbitMQ 的工作微服务
本节解释了使用消息代理而不是 gRPC 通信与内部队列所需的修改。这种解决方案通常更难测试和设计,但允许更好的水平扩展,并且几乎无需额外成本即可启用额外功能,因为这些功能由消息代理本身提供。
我们假设 RabbitMQ 已经安装并适当准备,如 第十四章,使用 .NET 实现微服务 中的 安装 RabbitMQ 核心部分 所述。
首先,必须将 ASP.NET Core 项目替换为另一个 Worker Service 项目。此外,该项目必须将其连接字符串添加到配置文件中,并调用 AddStorage
扩展方法以将所有数据库服务添加到依赖注入引擎中。以下是 Program.cs
文件的全部内容:
using GrpcMicroService.HostedServices;
using GrpcMicroServiceStore;
IHost host = Host.CreateDefaultBuilder(args)
.ConfigureServices((hostContext, services) =>
{
services.AddStorage(hostContext.Configuration
.GetConnectionString("DefaultConnection"));
services.AddHostedService<ProcessPurchases>();
})
.Build();
await host.RunAsync();
我们不再需要 gRPC 服务和代理,只需要 ProtoBuf 用于二进制消息,因此 FakeSource
进程和 GrpcMicroService
项目都必须添加 Google.Protobuf
和 Grpc.Tools
NuGet 包。两个项目都需要以下 messages.proto
文件,它仅定义了购买消息:
syntax = "proto3";
option csharp_namespace = "GrpcMicroService";
import "google/protobuf/timestamp.proto";
package purchase;
message PurchaseMessage {
string id = 1;
google.protobuf.Timestamp time = 2;
string location = 3;
int32 cost =4;
google.protobuf.Timestamp purchaseTime = 5;
}
在两个项目中都启用了消息类的自动生成,它们的项目文件中包含相同的 XML 声明:
<ItemGroup>
<Protobuf Include="Protos\messages.proto" GrpcServices="Client" />
</ItemGroup>
两个项目都需要指定 Client
代码生成,因为不需要创建任何服务。
要与 RabbitMQ 服务器通信,两个项目都必须添加 RabbitMQ.Client
NuGet 包。
最后,FakeSource
还添加了 Polly
NuGet
包,因为我们将会使用 Polly 来定义可靠的通信策略。
客户端项目的ExecuteAsync
方法略有不同:
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
{
Random random = new Random();
var factory = new ConnectionFactory{ HostName = "localhost" };
IConnection? connection =null;
IModel? channel = null;
try
{
while (!stoppingToken.IsCancellationRequested)
{
...
}
}
finally
{
if (connection != null)
{
channel.Dispose();
connection.Dispose();
channel = null;
connection = null;
}
}
}
通信需要创建一个连接工厂,然后连接工厂生成一个连接,连接生成一个通道。连接工厂在主循环外部创建,因为它可以被多次重用,并且不会被通信错误所无效化。
对于连接和通道,在主循环外部,我们只需定义变量和它们放置的位置,因为它们在通信异常的情况下会被无效化,所以我们必须在每次异常后从零开始释放它们并重新创建它们。
主循环被包含在try
/finally
中,以确保在离开方法之前任何通道/连接对都被释放。
在主循环内部,作为第一步,我们创建购买消息:
var purchaseDay = DateTime.UtcNow.Date;
//randomize a little bit purchase day
purchaseDay = purchaseDay.AddDays(random.Next(0, 3) – 1);
var purchase = new PurchaseMessage
{
//message time
PurchaseTime = Timestamp.FromDateTime(purchaseDay),
Time = Timestamp.FromDateTime(DateTime.UtcNow),
Id = Guid.NewGuid().ToString(),
//add random location
Location = locations[random.Next(0, locations.Length)],
//add random cost
Cost = 200 * random.Next(1, 4)
};
然后,消息被序列化:
byte[]? body = null;
using (var stream = new MemoryStream())
{
purchase.WriteTo(stream);
stream.Flush();
body = stream.ToArray();
}
在执行通信之前,我们定义一个 Polly 策略:
var policy = Policy
.Handle<Exception>()
.WaitAndRetry(6,
retryAttempt => TimeSpan.FromSeconds(Math.Pow(2,
retryAttempt)));
上述策略是指数重试,在异常的情况下,等待的时间呈指数增长。所以,如果尝试了六次,那么第二次尝试是在 2 秒后,第三次是在 4 秒后,第四次是在 8 秒后,依此类推。如果所有尝试都失败,异常会被重新抛出,导致消息丢失。如果消息不能丢失很重要,我们可以将这种策略与断路器策略(见第十一章中的Resilient task execution,将微服务架构应用于您的企业应用程序)相结合。
一旦我们定义了重试策略,我们就可以在这个策略的上下文中执行所有通信步骤:
policy.Execute(() =>
{
try
{
if(connection == null || channel == null)
{
connection = factory.CreateConnection();
channel = connection.CreateModel();
channel.ConfirmSelect();
}
//actual communication here
...
...
}
catch
{
channel.Dispose();
connection.Dispose();
channel = null;
connection = null;
throw;
}
如果没有有效的连接或通道,则创建它们。channel.ConfirmSelect()
声明我们需要确认消息已被安全接收并存储在磁盘上。如果在抛出异常的情况下,通道和连接都会被释放,因为它们可能已被异常损坏。这样,下一次通信尝试将使用新的通信和一个新的通道。释放后,异常被重新抛出,以便它可以由 Polly 策略处理。
最后,以下是实际的通信步骤:
-
首先,如果队列尚不存在,则创建它。队列被创建为
durable
;也就是说,它必须存储在磁盘上,并且不是exclusive
,这样多个服务器可以并行地从队列中提取消息:channel.QueueDeclare(queue: "purchase_queue", durable: true, exclusive: false, autoDelete: false, arguments: null);
-
然后,每个消息都被声明为持久化;也就是说,它必须存储在磁盘上:
var properties = channel.CreateBasicProperties(); properties.Persistent = true;
-
最后,消息通过默认交换发送,将其发送到特定的命名队列:
channel.BasicPublish(exchange: "", routingKey: "purchase_queue", basicProperties: properties, body: body);
-
作为最后一步,我们等待消息安全地存储在磁盘上:
channel.WaitForConfirmsOrDie(new TimeSpan(0, 0, 5));
如果在指定的超时时间内没有收到确认,则会抛出一个异常,触发 Polly 重试策略。当从本地数据库队列中取出消息时,我们也可以使用非阻塞确认来触发从本地队列中移除消息。
服务器托管进程的 ExecuteAsync
方法在 HostedServices/ProcessPurchase.cs
文件中定义:
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
{
while (!stoppingToken.IsCancellationRequested)
{
try
{
var factory = new ConnectionFactory() { HostName = "localhost" };
using (var connection = factory.CreateConnection())
using (var channel = connection.CreateModel())
{
channel.QueueDeclare(queue: "purchase_queue",
durable: true,
exclusive: false,
autoDelete: false,
arguments: null);
channel.BasicQos(prefetchSize: 0, prefetchCount: 1, global: false);
var consumer = new EventingBasicConsumer(channel);
consumer.Received += async (sender, ea) =>
{
// Message received even handler
...
};
channel.BasicConsume(queue: "purchase_queue",
autoAck: false,
consumer: consumer);
await Task.Delay(1000, stoppingToken);
}
}
catch { }
}
}
在主循环内部,如果抛出异常,它会被空的 catch
捕获。由于两个 using
语句都留在了那里,所以连接和通道都会被销毁。因此,在异常之后,会执行一个新的循环,创建一个新的新鲜连接和一个新的通道。
在 using
语句体内,我们确保我们的队列存在,然后将 prefetch
设置为 1
。这意味着每个服务器一次只能提取一条消息,这确保了所有服务器之间负载的公平分配。然而,当服务器基于多个并行线程时,将 prefetch
设置为 1
可能不太方便,因为它牺牲了线程使用优化以换取服务器之间的公平分配。因此,可能存在可以成功处理后续消息(在第一条之后)的线程而处于空闲状态。
然后,我们定义一个 消息接收
事件处理器。BasicConsume
开始实际的消息接收。将 autoAck
设置为 false
,当从队列中读取消息时,它不会被移除,而是被阻塞,因此它对从同一队列读取的其他服务器不可用。实际上,消息是在向 RabbitMQ 发送成功处理确认后移除的。我们还可以发送一个失败确认,在这种情况下,消息会被解除阻塞并再次可用于处理。
如果没有收到确认,消息会一直阻塞,直到连接和通道被销毁。
BasicConsume
是非阻塞的,因此在其后的 Task.Delay
会阻塞直到取消令牌被信号。在任何情况下,1 秒后,Task.Delay
会解除阻塞,并且连接和通道都会被替换为新的。这防止了未确认的消息永远处于阻塞状态。
让我们继续查看 消息接收 事件内部的代码。这是实际消息处理发生的地方。
作为第一步,代码会验证应用程序是否正在关闭,如果是,则销毁通道和连接并返回,不执行任何进一步的操作:
if (stoppingToken.IsCancellationRequested)
{
channel.Close();
connection.Close();
return;
}
然后,创建一个会话作用域以访问所有会话作用域的依赖注入服务:
using (var scope = services.CreateScope())
{
try
{
// actual message processing
...
}
catch {
((EventingBasicConsumer)sender).Model.BasicNack(ea.DeliveryTag, false, true);
}
}
当消息处理过程中抛出异常时,会向 RabbitMQ 发送一个 Nack
消息,通知它消息处理失败。ea.DeliveryTag
是一个唯一标识消息的标签。第二个参数设置为 false
通知 RabbitMQ,Nack
只是对由 ea.DeliveryTag
标识的消息而言,并不涉及所有其他等待从该服务器确认的消息。最后,最后一个参数设置为 true
请求 RabbitMQ 重新入队处理失败的消息。
在 try
块内部,我们获取一个 IDayStatistics
实例:
IDayStatistics statistics = scope.ServiceProvider
.GetRequiredService<IDayStatistics>();
然后,我们将消息体反序列化以获取一个 PurchaseMessage
实例并将其添加到数据库中:
var body = ea.Body.ToArray();
PurchaseMessage? message = null;
using (var stream = new MemoryStream(body))
{
message = PurchaseMessage.Parser.ParseFrom(stream);
}
var res = await statistics.Add(new Purchase {
Cost= message.Cost,
Id= Guid.Parse(message.Id),
Location = message.Location,
Time = new DateTimeOffset(message.Time.ToDateTime(), TimeSpan.Zero),
PurchaseTime = new DateTimeOffset(message.PurchaseTime.ToDateTime(), TimeSpan.Zero)
});
如果操作失败,Add
操作返回 null
,因此我们必须发送一个 Nack
;否则,我们必须发送一个 Ack
:
if(res != null)
((EventingBasicConsumer)sender).Model
.BasicAck(ea.DeliveryTag, false);
else
((EventingBasicConsumer)sender).Model
.BasicNack(ea.DeliveryTag, false, true);
就这些了!完整的代码位于本书 GitHub 仓库中 ch15
文件夹的 GrpcMicroServiceRabbitProto
子文件夹中。您可以通过将客户端和服务器项目都设置为启动项目并运行解决方案来测试代码。1-2 分钟后,数据库应该填充了新的购买和新的每日总计。在预发布/生产环境中,您可以运行客户端和服务器的好几份副本。
GitHub 仓库 ch15
文件夹中的 GrpcMicroServiceRabbit
子文件夹包含相同应用程序的另一个版本,该版本使用 Binaron NuGet 包进行序列化。它比 ProtoBuf 快,但作为 .NET 特定的,它不具有互操作性。此外,它没有便于消息版本化的功能。当性能至关重要,而版本化和互操作性不是优先事项时,它很有用。
Binaron 版本的不同之处在于它没有 .proto
文件或其他 ProtoBuf 内容,但它明确定义了一个 PurchaseMessage
.NET 类。此外,ProtoBuf 序列化和反序列化指令被以下内容替换:
byte[]? body = null;
using (var stream = new MemoryStream())
{
BinaronConvert.Serialize(purchase, stream);
stream.Flush();
body = stream.ToArray();
}
与以下内容一起:
PurchaseMessage? message = null;
using (var stream = new MemoryStream(body))
{
message = BinaronConvert.Deserialize<PurchaseMessage>(stream);
}.
现在我们已经创建了一个连接到消息代理的微服务,学习如何使用 Web API 暴露 WWTravelClub 的包也同样重要。让我们在下一节中看看这个内容。
使用 Web API 暴露 WWTravelClub 包
在本节中,我们将实现一个 ASP.NET REST 服务,该服务列出给定假期开始和结束日期可用的所有包。为了教学目的,我们不会根据我们之前描述的最佳实践来构建应用程序;相反,我们将简单地使用 LINQ 查询生成结果,并将该查询直接放置在控制器操作方法中。一个结构良好的 ASP.NET Core 应用程序已在 第十八章,使用 ASP.NET Core 实现前端微服务 中介绍。
让我们复制 WWTravelClubDB
解决方案文件夹,并将新文件夹重命名为 WWTravelClubWebAPI80
。WWTravelClubDB
项目是在 第十三章,使用 C# 与数据交互 – Entity Framework Core 的各个部分中逐步构建的。让我们打开新的解决方案,并向其中添加一个名为 WWTravelClubWebAPI80
的新 ASP.NET Core API 项目(与新的解决方案文件夹同名)。为了简单起见,选择 无身份验证。右键单击新创建的项目,并选择 设置为启动项目,使其成为在运行解决方案时启动的默认项目。
最后,我们需要将 WWTravelClubDB 项目添加为引用。
ASP.NET Core 项目将配置常量存储在 appsettings.json
文件中。让我们打开这个文件,并将我们为 WWTravelClubDB 项目创建的数据库连接字符串添加到其中,如下所示:
{
"ConnectionStrings": {
"DefaultConnection": "Server=
(localdb)\\mssqllocaldb;Database=wwtravelclub;
Trusted_Connection=True;MultipleActiveResultSets=true"
},
...
...
}
现在,我们必须将 WWTravelClubDB 实体框架数据库上下文添加到 Program.cs
中,如下所示:
builder.Services.AddDbContext<WWTravelClubDB.MainDBContext>(options =>
options.UseSqlServer(
builder.Configuration.GetConnectionString("DefaultConnection"),
b =>b.MigrationsAssembly("WWTravelClubDB")))
传递给 AddDbContext
的选项对象设置指定了使用 SQL Server,其连接字符串是从 appsettings.json
配置文件的 ConnectionStrings
部分提取的,使用 Configuration.GetConnectionString("DefaultConnection")
方法。b =>b.MigrationsAssembly("WWTravelClubDB")
lambda 函数声明了包含数据库迁移的程序的名称(参见 第十三章,在 C# 中与数据交互 – Entity Framework Core),在我们的情况下,是由 WWTravelClubDB 项目生成的 DLL。为了使前面的代码能够编译,你应该添加 Microsoft.EntityFrameworkCore
命名空间。
由于我们希望用 OpenAPI 文档丰富我们的 REST 服务,让我们添加对 Swashbuckle.AspNetCore
NuGet 包的引用。现在,我们可以在 Program.cs
中添加以下非常基本的配置:
var builder = WebApplication.CreateBuilder(args);
...
builder.Services.AddSwaggerGen(c =>
{
c.SwaggerDoc("v2", new() { Title = "WWTravelClub REST API - .NET 8", Version = "v2" });
});
var app = builder.Build();
...
app.UseSwagger();
app.UseSwaggerUI(c => c.SwaggerEndpoint("/swagger/v2/swagger.json", "WWTravelClub REST API - .NET 8"));
...
app.Run();
现在,我们已经准备好编码我们的服务。让我们删除由 Visual Studio 自动生成的 WeatherForecastController
。然后,右键单击 Controllers
文件夹并选择 添加 | 控制器。现在,选择一个名为 PackagesController
的空 API 控制器。首先,让我们按以下方式修改代码:
[Route("api/packages")]
[ApiController]
public class PackagesController : ControllerBase
{
[HttpGet("bydate/{start}/{stop}")]
[ProducesResponseType(typeof(IEnumerable<PackagesListDTO>), 200)]
[ProducesResponseType(400)]
[ProducesResponseType(500)]
public async Task<IActionResult> GetPackagesByDate(
[FromServices] WWTravelClubDB.MainDBContext ctx,
DateTime start, DateTime stop)
{
}
}
Route
属性声明我们的服务的基本路径将是 api/packages
。我们实现的唯一操作方法是 GetPackagesByDate
,它在 HttpGet
请求上被调用,路径类型为 bydate/{start}/{stop}
,其中 start
和 stop
是作为输入传递给 GetPackagesByDate
的 DateTime
参数。ProduceResponseType
属性声明以下内容:
-
当请求成功时,返回 200 状态码,并且正文包含一个
IEnumerable
的PackagesListDTO
类型(我们很快将定义),其中包含所需的包信息。 -
当请求格式不正确时,返回 400 状态码。我们未指定返回的类型,因为不良请求会自动通过
ApiController
属性由 ASP.NET Core 框架处理。 -
在出现意外错误的情况下,返回 500 状态码,并带有异常错误信息。
现在,让我们在新的 DTOs
文件夹中定义 PackagesListDTO
类:
namespace WWTravelClubWebAPI80.DTOs;
public record PackagesListDTO
{
public int Id { get; set; }
public string Name { get; set; }
public decimal Price { get; set; }
public int DurationInDays { get; set; }
public DateTime? StartValidityDate { get; set; }
public DateTime? EndValidityDate { get; set; }
public string DestinationName { get; set; }
public int DestinationId { get; set; }
}
最后,让我们将以下 using
子句添加到我们的控制器代码中,以便我们可以轻松地引用我们的 DTO 和 Entity Framework LINQ 方法:
using Microsoft.EntityFrameworkCore;
using WWTravelClubWebAPI80.DTOs;
现在,我们已经准备好用以下代码填充 GetPackagesByDate
方法的正文。
try
{
var res = await ctx.Packages
.Where(m => start >= m.StartValidityDate
&& stop <= m.EndValidityDate)
.Select(m => new PackagesListDTO
{
StartValidityDate = m.StartValidityDate,
EndValidityDate = m.EndValidityDate,
Name = m.Name,
DurationInDays = m.DurationInDays,
Id = m.Id,
Price = m.Price,
DestinationName = m.MyDestination.Name,
DestinationId = m.DestinationId
})
.ToListAsync();
return Ok(res);
}
catch (Exception err)
{
return StatusCode(500, err.ToString());
}
重要的是要记住,我们只关注使用 Swashbuckle.AspNetCore
NuGet 包公开的 API 的结果展示。在 Controller
类中使用 DbContext
不是一个好的做法,作为软件架构师,你可能需要为你的应用程序定义最佳的建筑设计(多层、六边形、洋葱、清洁、DDD 等)。
LINQ 查询类似于我们在第十三章“与 C#中的数据交互 - Entity Framework Core”中测试的WWTravelClubDBTest
项目中的查询。一旦结果被计算出来,它就会通过一个OK
调用返回。该方法代码通过捕获异常并返回 500 状态码来处理内部服务器错误,因为不良请求是在ApiController
属性调用Controller
方法之前自动处理的。
让我们运行解决方案。当浏览器打开时,它无法从我们的 ASP.NET Core 网站接收任何结果。让我们修改浏览器 URL,使其为https://localhost:<previous port>/swagger
。值得一提的是,你也可以配置你的本地设置文件,使其自动启动并转到 Swagger URL,或者让 Swagger 在根目录下运行。
OpenAPI 文档的用户界面将如下所示:
图 21.15:Swagger 输出
PackagesListDTO是我们定义的用于列出包的模型,而ProblemDetails是用于在发生错误请求时报告错误的模型。通过点击GET按钮,我们可以获取更多关于我们的GET
方法的信息,我们还可以测试它,如下面的截图所示:
图 21.16:GET 方法详情
当插入数据库中由包覆盖的日期时,请注意;否则,将返回一个空列表。前面截图中的那些应该可以工作。
日期必须以正确的 JSON 格式输入;否则,会返回一个 400 Bad Request 错误,如下面的代码所示:
{
"errors": {
"start": [
"The value '2019' is not valid."
]
},
"title": "One or more validation errors occurred.",
"status": 400,
"traceId": "80000008-0000-f900-b63f-84710c7967bb"
}
如果你插入正确的输入参数,Swagger UI 将以 JSON 格式返回满足查询的包。
就这样!你已经使用 OpenAPI 文档实现了你的第一个 API!现在让我们看看使用 Azure Functions 实现无服务器解决方案有多简单。
实现 Azure Functions 以发送电子邮件
在这里,我们将使用 Azure 组件的一个子集。WWTravelClub 的使用案例提出了全球实施该服务,并且有可能这个服务需要不同的架构设计来实现我们在第一章“理解软件架构的重要性”中描述的所有关键性能点。
如果你回顾本章中描述的用户故事,你会发现许多需求都与沟通相关。正因为如此,在解决方案中提供一些由电子邮件发出的警报是很常见的。本实现将专注于如何发送电子邮件。该架构将是完全无服务器的。使用这种架构的好处如下所述。
下面的图示显示了该架构的基本结构。为了给用户提供良好的体验,应用程序发送的所有电子邮件都将异步排队,从而防止系统响应出现重大延迟:
图 21.17:发送电子邮件的架构设计
基本上,当用户执行任何需要发送警报的操作(1)时,警报将被发布在发送电子邮件请求函数(2)中,该函数将请求存储在 Azure 队列存储(3)中。因此,对于用户来说,警报已经在此时执行,他们可以继续工作。然而,由于我们有一个队列,无论发送多少警报,它们都将由发送电子邮件函数处理,该函数在请求一发出就会触发(4),尊重处理请求所需的时间,但保证接收者会收到警报(5)。请注意,没有专门的服务器来管理从 Azure 队列存储中入队或出队消息的 Azure 函数。这正是我们所说的无服务器,如第十六章“使用无服务器 - Azure 函数”中所述。值得一提的是,这种架构不仅限于发送电子邮件——它还可以用于处理任何 HTTP POST
请求。
现在,我们将分三步学习如何在 API 中设置安全措施,以确保只有授权的应用程序可以使用给定的解决方案。
第一步——创建 Azure 队列存储
在 Azure 门户中创建存储相当简单。让我们来学习如何操作。首先,您需要通过点击 Azure 门户主页上的创建资源并搜索存储帐户来创建一个存储帐户。然后,您将能够设置其基本信息,例如存储帐户名称和位置。如以下截图所示,您也可以在此向导中检查有关网络和数据保护的信息。这些设置有默认值,我们将在演示中介绍:
图 21.18:创建 Azure 存储帐户
一旦您设置了存储帐户,您就可以设置一个队列。您可以通过点击存储帐户中的概览链接并选择队列服务选项,或者通过存储帐户菜单选择队列来找到此选项。然后,您将找到一个添加队列的选项(+ 队列),您只需提供其名称:
图 21.19:定义队列以监控电子邮件
创建的队列将为您展示 Azure 门户的概览。在那里,您将找到队列的 URL 并能够使用存储资源管理器:
图 21.20:队列已创建
注意,您还可以使用 Microsoft Azure Storage Explorer 连接到此存储(azure.microsoft.com/en-us/features/storage-explorer/
):
图 21.21:使用 Microsoft Azure Storage Explorer 监控队列
如果您没有连接到 Azure 门户,此工具特别有用。让我们进入第二步,在那里我们将创建接收发送电子邮件请求的函数。
第二步 — 创建发送电子邮件的函数
现在,您可以开始认真编程了,通知队列有一封电子邮件等待发送。在这里,我们需要使用 HTTP 触发器。请注意,该函数是一个静态类,它异步运行。以下代码是在 Visual Studio 中编写的,它收集来自 HTTP 触发器的请求数据并将其插入到稍后处理的队列中。值得一提的是,环境变量 EmailQueueConnectionString
在函数应用设置中设置,并包含 Azure 队列存储连接字符串提供的信息。
下面是从本书 GitHub 仓库中可用的函数代码片段:
public static class SendEmail
{
[FunctionName(nameof(SendEmail))]
public static async Task<HttpResponseMessage>RunAsync( [HttpTrigger(AuthorizationLevel.Function, "post")] HttpRequestMessage req, ILogger log)
{
var requestData = await req.Content.ReadAsStringAsync();
var connectionString = Environment.GetEnvironmentVariable("EmailQueueConnectionString");
var storageAccount = CloudStorageAccount.Parse(connectionString);
var queueClient = storageAccount.CreateCloudQueueClient();
var messageQueue = queueClient.GetQueueReference("email");
var message = new CloudQueueMessage(requestData);
await messageQueue.AddMessageAsync(message);
log.LogInformation("HTTP trigger from SendEmail function processed a request.");
var responseObj = new { success = true };
return new HttpResponseMessage(HttpStatusCode.OK)
{
Content = new StringContent(JsonConvert.SerializeObject(responseObj), Encoding.UTF8, "application/json"),
};
}
}
在某些场景中,您可能尝试通过使用队列输出绑定来避免前面代码中指示的队列设置。有关详细信息,请参阅docs.microsoft.com/en-us/azure/azure-functions/functions-bindings-storage-queue-output?tabs=csharp
。
您可以使用 Postman 等工具测试您的函数。在此之前,您只需在 Visual Studio 中运行应用程序,这将启动 Azure Functions Core Tools 和其模拟器:
图 21.22:Postman 函数测试
结果将出现在 Microsoft Azure Storage Explorer 和 Azure 门户中。在 Azure 门户中,您可以管理每条消息并解除每条消息的队列,甚至可以清除队列存储:
图 21.23:HTTP 触发器和队列存储测试
为了完成这个主题,让我们进入最终的第三步,在那里我们将创建处理发送电子邮件请求的函数。
第三步 — 创建队列触发函数
然后,您可以通过右键单击项目并选择 添加 -> 新建 Azure Function 来创建第二个函数。这个函数将由进入队列的数据触发。值得一提的是,对于 Azure Functions v4,您将自动添加 Microsoft.Azure.WebJobs.Extensions.Storage
库作为 NuGet 引用:
图 21.24:创建队列触发器
一旦你在local.settings.json
内部设置了连接字符串,你将能够运行这两个函数并使用 Postman 测试它们。区别在于,当第二个函数运行时,如果你在它的开始处设置断点,你将能够检查消息是否已发送:
图 21.25:在 Visual Studio 2022 中触发的队列
从这个点开始,发送电子邮件的方式将取决于你拥有的电子邮件选项。你可能决定使用代理或直接连接到你的电子邮件服务器。
以这种方式创建电子邮件服务有几个优点:
-
一旦你的服务被编码和测试,你就可以使用它从你的任何应用程序发送电子邮件。这意味着你的代码可以始终重用。
-
使用此服务的应用程序不会因为 HTTP 服务异步发布的优势而停止发送电子邮件。
-
你不需要将队列池化来检查数据是否准备好处理。
最后,队列处理过程是并发运行的,这在大多数情况下提供了更好的体验。你可以通过在host.json
中设置一些属性来关闭它。所有这些选项都可以在本章末尾的进一步阅读部分找到。
在本案例研究的这部分,我们检查了一个连接多个函数以避免池化数据并启用并发处理的架构示例。我们通过这个演示看到了无服务器架构和事件驱动架构之间是多么的契合。
现在,让我们稍微改变一下主题,讨论如何实现一个前端微服务。
前端微服务
在本节中,我们将以第十八章,使用 ASP.NET Core 实现前端微服务中描述的 ASP.NET Core MVC 前端微服务为例,实现WWTravelClub
用例的目的地和包的管理控制台。该应用程序将使用第七章,理解软件解决方案中的不同领域中描述的 DDD 方法和相关模式进行实现。因此,对那章有良好的理解是阅读本章的基本先决条件。接下来的小节将描述整体应用程序规范和组织。示例的完整代码可以在与本书相关的 GitHub 仓库的ch19
文件夹中找到。
如同往常,让我们首先明确我们的前端微服务规范。
定义应用程序规范
目的地和套餐在第十三章,C#中的数据交互 – Entity Framework Core中进行了描述。在这里,我们将使用相同的数据模型,并对其进行必要的修改以适应 DDD 方法。管理面板必须允许套餐、目的地列表以及对其的 CRUD 操作。为了简化应用程序,这两个列表将非常简单:应用程序将按名称排序显示所有目的地,而所有套餐将按有效期排序。
此外,我们做出以下假设:
-
向用户展示目的地和套餐的应用程序与行政面板使用的数据库相同。由于只有行政面板应用程序需要修改数据,因此将只有一个写数据库副本和几个只读副本。
-
价格修改和套餐删除会立即更新用户的购物车。因此,行政应用程序必须发送有关价格变化和套餐移除的异步通信。我们不会在这里实现所有通信逻辑,但我们会将所有此类事件添加到一个事件表中,该表应作为输入提供给一个负责将这些事件发送到所有相关微服务的并行线程。
在这里,我们将提供仅用于套餐管理的完整代码;大部分目的地管理的代码被设计成你的练习。完整的代码可在与本书相关的 GitHub 存储库的ch16
文件夹中找到。在本节的剩余部分,我们将描述应用程序的整体组织并讨论一些相关的代码示例。我们从一个对应用程序架构的整体描述开始。
定义应用程序架构
应用程序的组织基于第七章,理解软件解决方案中的不同领域中描述的指南,同时考虑 DDD 方法和相关模式。也就是说,应用程序被组织成三个层次,每个层次都作为不同的项目实现:
-
有一个领域实现层,其中包含存储库的实现和描述数据库实体的类。它是一个.NET 库项目。然而,由于它需要一些接口,如
IServiceCollection
,这些接口在Microsoft.NET.Sdk.web
中定义,并且由于DBContext
层必须从身份框架继承以便也能处理应用程序的认证和授权数据库表,我们必须添加对.NET SDK 的引用,同时也需要添加对 ASP.NET Core SDK 的引用。这可以通过以下方式完成:-
在解决方案资源管理器中的项目图标上右键单击,然后选择编辑项目文件,或者直接双击项目名称。
-
在编辑窗口中,添加:
<ItemGroup> <FrameworkReference Include="Microsoft.AspNetCore.App" /> </ItemGroup>
-
此外,还有一个领域层抽象,其中包含存储库规范——即描述存储库实现和 DDD 聚合的接口。在我们的实现中,我们决定通过隐藏根数据实体的禁止操作/属性来隐藏聚合的实现。因此,例如,Package
实体类,它是一个聚合根,在领域层抽象中有一个相应的IPackage
接口,隐藏了Package
实体中所有的属性设置器。领域层抽象还包含所有领域事件的定义,而将订阅这些事件的处理程序定义在应用层。IPackage
还有一个相关的IPackageRepository
存储库接口。
所有存储库接口都继承自空的IRepository
接口。
这样,它们将其声明为一个存储库接口,所有存储库接口都可以通过反射自动发现,并与其实现一起添加到依赖注入引擎中。
最后,是应用层——即 ASP.NET Core MVC 应用程序——在这里我们定义 DDD 查询、命令、命令处理程序和事件处理程序。控制器填充查询对象并执行它们以获取可以传递给视图的 ViewModel。他们通过填充命令对象并执行相关的命令处理程序来更新存储。反过来,命令处理程序使用IRepository
接口(即继承自空IRepository
接口的接口)和来自领域层的IUnitOfWork
实例来管理和协调事务。
值得注意的是,在更复杂的微服务中,应用层可能作为一个单独的库项目来实现,它将只包含 DDD 查询、命令、命令处理程序和事件处理程序。而 MVC 项目将只包含控制器、UI 和依赖注入。
该应用程序使用命令查询责任分离(CQRS)模式;因此,它使用命令对象来修改存储,并使用查询对象来查询它。
查询的使用和实现都很简单:控制器填充它们的参数,然后调用它们的执行方法。反过来,查询对象有直接的 LINQ 实现,可以直接使用Select
LINQ 方法将结果投影到控制器视图使用的 ViewModel 上。你也可以选择将 LINQ 实现隐藏在用于存储更新操作的相同存储库类后面,但这样做会将简单查询的定义和修改变成非常耗时的工作。
在任何情况下,将查询对象封装在接口后面可能是有益的,这样在测试控制器时,它们的实现可以被模拟实现所替代。
然而,执行命令所涉及的对象和调用链更为复杂。这是因为它需要提供构建和修改聚合(以及定义多个聚合之间以及聚合与其他应用程序之间的交互通过领域事件)的操作。
以下图表是存储更新操作执行方式的草图。圆圈是各层之间交换的数据,而矩形是处理它们的程序。此外,虚线箭头连接接口及其实现它们的类型:
图 21.26:命令执行图
下面是图 21.26中动作流程的步骤列表:
-
控制器的动作方法接收一个或多个 ViewModel 并执行验证。
-
包含要应用更改的一个或多个 ViewModel 隐藏在领域层中定义的接口(
IMyUpdate
)后面。它们用于填充命令对象的属性。由于这些接口将作为在该层定义的存储库聚合方法的参数使用,因此它们必须在领域层中定义。 -
通过依赖注入(DI)在控制器动作方法中检索与先前命令匹配的命令处理器(通过我们在定义控制器和视图子节中描述的
[FromServices]
参数属性)。然后,执行处理器。在其执行过程中,处理器与各种存储库接口方法和它们返回的聚合进行交互。 -
在创建步骤 3中讨论的命令处理器时,ASP.NET Core DI 引擎自动注入其构造函数中声明的所有参数。特别是,它注入执行所有命令处理器事务所需的全部存储库实现。命令处理器通过调用其构造函数中接收到的这些
IRepository
实现的方法来构建聚合并修改构建的聚合来完成其工作。聚合要么代表已存在的实体,要么代表新创建的实体。处理器使用包含在每个存储库接口中的IUnitOfWork
接口以及数据层返回的并发异常来组织其操作作为事务。值得注意的是,每个聚合都有自己的存储库实现,并且更新每个聚合的整体逻辑是在聚合本身中定义的,而不是在其关联的存储库实现中,以保持代码的模块化。 -
在幕后,在领域层实现中,存储库实现使用 Entity Framework 来执行其工作。聚合通过领域层中定义的接口隐藏的根数据实体来实现,而处理事务并将更改传递到数据库的
IUnitOfWork
方法则使用DbContext
方法实现。换句话说,IUnitOfWork
是用应用程序的DbContext
实现的。 -
领域事件在每个聚合过程中生成,并通过调用它们的
AddDomainEvent
方法添加到聚合本身中。然而,它们不会立即触发。通常,它们在所有聚合处理结束后、更改传递到数据库之前触发;然而,这并不是一个普遍的规则。 -
应用程序通过抛出异常来处理错误。
一种更有效的方法是在依赖引擎中定义一个请求作用域的对象,其中每个应用程序子部分都可以将其错误添加为领域事件。然而,尽管这种方法更有效,但它增加了代码和应用程序开发时间的复杂性。
Visual Studio 解决方案由三个项目组成:
-
有一个包含领域层抽象的项目,名为
PackagesManagementDomain
,它是一个 .NET Standard 2.1 库。当一个库不使用特定于 .NET 版本的功能或 NuGet 包时,将其实现为 .NET Standard 库是一个好习惯,因为这样,当应用程序迁移到较新的 .NET 版本时,它不需要进行修改。 -
有一个包含整个领域层实现的项目,名为
PackagesManagementDB
,它是一个 .NET 8.0 库。 -
最后,还有一个名为
PackagesManagement
的 ASP.NET Core MVC 8.0 项目,它包含应用程序和表示层。当您定义此项目时,请选择无身份验证;否则,用户数据库将直接添加到 ASP.NET Core MVC 项目中,而不是数据库层。我们将在数据层手动添加用户数据库。
让我们先创建 PackagesManagement
ASP.NET Core MVC 项目,以便整个解决方案的名称与 ASP.NET Core MVC 项目的名称相同。然后,我们将添加其他两个库项目到同一个解决方案中。
最后,让 ASP.NET Core MVC 项目同时引用这两个项目,同时 PackagesManagementDB
引用 PackagesManagementDomain
。我们建议您定义自己的项目,然后在阅读本节时将本书 GitHub 仓库中的代码复制到这些项目中。
下一个子节描述了 PackagesManagementDomain
领域层抽象项目的代码。
定义领域层抽象
一旦将 PackagesManagementDomain
标准版 2.1 库项目添加到解决方案中,我们将在项目根目录中添加一个 Tools
文件夹。然后,我们将所有与 ch11
相关的 DomainLayer
工具放置在这个文件夹中。由于这个文件夹中的代码使用了数据注释并定义了 DI 扩展方法,我们还必须添加对 System.ComponentModel.Annotations
和 Microsoft.Extensions.DependencyInjection.Abstration
NuGet 包的引用。
然后,我们需要一个包含所有聚合定义的Aggregates
文件夹(记住,我们将聚合实现为接口)——即IDestination
、IPackage
和IPackageEvent
。在这里,IPackageEvent
是与我们将事件传播到其他应用的表关联的聚合。
例如,让我们分析一下IPackage
:
public interface IPackage : IEntity<int>
{
void FullUpdate(IPackageFullEditDTO packageDTO);
string Name { get; set; } = null!;
string Description { get;} = null!;
decimal Price { get; set; }
int DurationInDays { get; }
DateTime? StartValidityDate { get;}
DateTime? EndValidityDate { get; }
int DestinationId { get; }
}
它包含与我们在第十三章中看到的Package
实体相同的属性,交互 C#中的数据 - Entity Framework Core。唯一的区别如下:
-
它继承自
IEntity<int>
,这为聚合提供了所有基本功能。 -
它没有
Id
属性,因为它继承自IEntity<int>
。 -
所有属性都是只读的,并且它有一个
FullUpdate
方法,因为所有聚合只能通过用户领域(在我们的案例中是FullUpdate
方法)中定义的更新操作进行修改。
现在,让我们也添加一个DTOs
文件夹。在这里,我们放置所有用于将更新传递给聚合的接口。这些接口由定义此类更新的应用层 ViewModel 实现。在我们的案例中,它包含IPackageFullEditDTO
,我们可以用它来更新现有包。如果您想添加管理目标地的逻辑,您必须为IDestination
聚合定义一个类似的接口。
一个IRepositories
文件夹包含所有仓库规范——即IDestinationRepository
、IPackageRepository
和IPackageEventRepository
。在这里,IPackageEventRepository
是与IPackageEvent
聚合关联的仓库。例如,让我们看一下IPackageRepository
仓库:
public interface IPackageRepository:
IRepository<IPackage>
{
Task<IPackage?> GetAsync(int id);
IPackage New();
Task<IPackage?> Delete(int id);
}
仓库通常只包含几个方法,因为所有业务逻辑都应该表示为聚合方法——在我们的案例中,就是创建新包、检索现有包和删除现有包的方法。修改现有包的逻辑包含在IPackage
的FullUpdate
方法中。
最后,就像所有领域层项目一样,PackagesManagementDomain
包含一个包含所有领域事件定义的Events
文件夹。在我们的案例中,文件夹命名为Events
,包含包删除事件和价格更改事件:
public class PackageDeleteEvent: IEventNotification
{
public PackageDeleteEvent(int id, long oldVersion)
{
PackageId = id;
OldVersion = oldVersion;
}
public int PackageId { get; }
public long OldVersion { get; }
}
public class PackagePriceChangedEvent: IEventNotification
{
public PackagePriceChangedEvent(int id, decimal price,
long oldVersion, long newVersion)
{
PackageId = id;
NewPrice = price;
OldVersion = oldVersion;
NewVersion = newVersion;
}
public int PackageId { get; }
public decimal NewPrice { get; }
public long OldVersion { get; }
public long NewVersion { get; }
}
当一个聚合将所有更改发送到另一个应用时,它应该有一个版本属性。接收更改的应用使用这个版本属性来按正确顺序应用所有更改。显式的版本号是必要的,因为更改是异步发送的,所以它们接收的顺序可能与发送的顺序不同。为此,用于在应用外部发布更改的事件既有OldVersion
(更改前的版本)和NewVersion
(更改后的版本)属性。与删除事件相关的事件没有NewVersion
属性,因为实体在被删除后无法存储任何版本。
关于如何使用和处理版本信息以恢复传入消息的正确顺序的更多细节,请参阅本章的 使用 ASP.NET Core 的工作微服务 部分。
下一个子节将解释在领域层抽象中定义的所有接口是如何在领域层实现中实现的。
定义领域层实现
数据层项目包含对 Microsoft.AspNetCore.Identity.EntityFrameworkCore
和 Microsoft.EntityFrameworkCore.SqlServer
NuGet 包的引用,因为我们使用的是与 SQL Server 的 Entity Framework Core。它引用 Microsoft.EntityFrameworkCore.Tools
和 Microsoft.EntityFrameworkCore.Design
,这些是生成数据库迁移所需的,如 第十三章,在 C# 中与数据交互 - Entity Framework Core 中的 Entity Framework Core 迁移 部分所述。
我们有一个 Models
文件夹,其中包含所有数据库实体。它们与 第十三章,在 C# 中与数据交互 - Entity Framework Core 中的类似。唯一的区别如下:
-
它们继承自
Entity<T>
,其中包含聚合的所有基本功能。请注意,从Entity<T>
继承仅适用于聚合根;所有其他实体都必须按照 第七章,理解软件解决方案中的不同领域 中所述进行定义。在我们的示例中,所有实体都是聚合根。 -
由于它从
Entity<T>
继承而来,它们没有Id
。 -
其中一些具有带有
[ConcurrencyCheck]
特性的EntityVersion
属性。它包含实体版本,这对于将所有实体更改传播到其他应用程序至关重要。ConcurrencyCheck
特性是防止在更新实体版本时出现并发错误的必要条件。这防止了因事务而导致的性能损失。
更具体地说,当保存实体更改时,如果带有 ConcurrencyCheck
特性的字段的值与在实体加载到内存时读取的值不同,则会抛出一个并发异常,以通知调用方法,在读取实体之后但在我们尝试保存其更改之前,有人修改了此值。这样,调用方法可以重复整个操作,希望这次在执行过程中没有人会在数据库中写入相同的实体。
ConcurrencyCheck
属性的唯一替代方案将是:
-
开始一个事务。
-
读取感兴趣的聚合。
-
增加其
EntityVersion
属性。 -
更新聚合。
-
保存所有更改。
-
关闭事务。
事务持续时间将是不可以接受的,因为事务应该保持各种数据库命令的时间——即从初始读取到最后更新——从而防止其他请求在太长时间内访问涉及的表/记录。
相反,通过使用ConcurrencyCheck
属性,当聚合保存到数据库时,我们只打开一个非常短的单命令事务:
-
阅读感兴趣的聚合。
-
增加实体版本
EntityVersion
属性的值。 -
更新聚合。
-
使用快速的单命令事务保存所有更改。
值得分析Package
实体的代码:
public class Package: Entity<int>, IPackage
{
public void FullUpdate(IPackageFullEditDTO o)
{
if (IsTransient())
{
Id = o.Id;
DestinationId = o.DestinationId;
}
else
{
if (o.Price != this.Price)
this.AddDomainEvent(new PackagePriceChangedEvent(
Id, o.Price, EntityVersion, EntityVersion+1));
}
Name = o.Name;
Description = o.Description;
Price = o.Price;
DurationInDays = o.DurationInDays;
StartValidityDate = o.StartValidityDate;
EndValidityDate = o.EndValidityDate;
}
[MaxLength(128)]
public string Name { get; set; }= null!;
[MaxLength(128)]
public string? Description { get; set; }
public decimal Price { get; set; }
public int DurationInDays { get; set; }
public DateTime? StartValidityDate { get; set; }
public DateTime? EndValidityDate { get; set; }
public Destination MyDestination { get; set; }= null!;
[ConcurrencyCheck]
public long EntityVersion{ get; set; }
public int DestinationId { get; set; }
}
FullUpdate
方法是更新IPackage
聚合的唯一方式。当价格变化时,将PackagePriceChangedEvent
添加到实体事件列表中。
MainDBContext.cs
文件包含数据库上下文定义。它不继承自DbContext
,而是继承自以下预定义的上下文类:
IdentityDbContext<IdentityUser<int>, IdentityRole<int>, int>
此上下文定义了用于身份验证的用户所需表。在我们的案例中,我们选择了IdentityUser<T>
标准以及IdentityRole<S>
用于用户和角色,并且对于T
和S
实体键都使用了整数。然而,我们也可以使用继承自IdentityUser
和IdentityRole
的类,然后添加更多属性。
在OnModelCreating
方法中,我们必须调用base.OnModelCreating(builder)
以便应用在IdentityDbContext
中定义的配置。
MainDBContext
实现了IUnitOfWork
。以下代码显示了开始、回滚和提交事务的所有方法的实现:
public async Task StartAsync()
{
await Database.BeginTransactionAsync();
}
public Task CommitAsync()
{
Database.CommitTransaction();
return Task.CompletedTask;
}
public Task RollbackAsync()
{
Database.RollbackTransaction();
return Task.CompletedTask;
}
然而,在分布式环境中,它们很少被命令类使用。这是因为重复执行相同的操作直到没有返回并发异常通常比事务有更好的性能。
值得分析将所有应用到DbContext
的更改传递到数据库的方法的实现:
public async Task<bool> SaveEntitiesAsync()
{
try
{
return await SaveChangesAsync() > 0;
}
catch (DbUpdateConcurrencyException ex)
{
foreach (var entry in ex.Entries)
{
entry.State = EntityState.Detached;
}
throw;
}
}
上述实现只是调用SaveChangesAsync
DbContext
上下文方法,该方法将所有更改保存到数据库,但随后它拦截所有并发异常,并将所有涉及并发错误的实体从上下文中分离。这样,下次命令重试整个失败操作时,它们的更新版本将重新从数据库中加载。
Repositories
文件夹包含所有仓库实现。值得分析IPackageRepository.Delete
方法的实现:
public async Task<IPackage?> Delete(int id)
{
var model = await GetAsync(id);
if (model is not Package package) return null;
context.Packages.Remove(package);
model.AddDomainEvent(
new PackageDeleteEvent(
model.Id, package.EntityVersion));
return model;
}
它从数据库中读取实体,并正式将其从Packages
数据集中删除。这将迫使实体在更改保存到数据库时从数据库中删除。此外,它将PackageDeleteEvent
添加到聚合事件列表中。
Extensions
文件夹包含DBExtensions
静态类,它反过来定义了两个扩展方法,分别用于添加到应用程序 DI 引擎和 ASP.NET Core 管道中。一旦添加到管道中,这两个方法将数据库层与应用程序层连接起来。
AddDbLayer
的IServiceCollection
扩展接受(作为其输入参数)数据库连接字符串和包含所有迁移的.dll
文件的名称。然后,它执行以下操作:
services.AddDbContext<MainDbContext>(options =>
options.UseSqlServer(connectionString,
b => b.MigrationsAssembly(migrationAssembly)));
即,它将数据库上下文添加到 DI 引擎中,并定义其选项——即它使用 SQL Server、数据库连接字符串以及包含所有迁移的 .dll
文件名称。
然后,它执行以下操作:
services.AddIdentity<IdentityUser<int>, IdentityRole<int>>()
.AddEntityFrameworkStores<MainDbContext>()
.AddDefaultTokenProviders();
即,它添加并配置了处理基于数据库的认证和授权所需的所有类型。它添加了 UserManager
,应用程序层可以使用它来管理用户。AddDefaultTokenProviders
添加了在用户登录时使用数据库中包含的数据创建认证令牌的提供者。
最后,它通过调用定义在添加到领域层项目的 DDD 工具中的 AddAllRepositories
方法,发现并添加所有仓库实现到 DI 引擎中。
UseDBLayer
扩展方法通过调用 context.Database.Migrate()
确保迁移应用到数据库中,然后它用一些初始对象填充数据库。在我们的例子中,它使用 RoleManager
和 UserManager
分别创建一个管理角色和初始管理员。然后,它创建一些示例目的地和包。
context.Database.Migrate()
对于快速设置和更新预发布和测试环境非常有用。在生产环境中部署时,如果我们没有创建新数据库或修改其结构的凭据,我们还可以使用迁移工具从迁移中生成一个 SQL 脚本。然后,在应用之前,应由负责维护数据库的人员检查此脚本,并最终使用其凭据应用。
要创建迁移,我们必须将前面提到的扩展方法添加到 ASP.NET Core MVC 的 Program.cs
文件中,如下所示:
...
builder.Services.AddRazorPages();
builder.Services.AddDbLayer(
builder.Configuration.GetConnectionString("DefaultConnection"),
"PackagesManagementDB");
...
app.UseAuthentication();
app.UseAuthorization();
...
请确保已按正确顺序将授权和身份验证中间件添加到 ASP.NET Core 管道中;否则,身份验证/授权引擎将无法工作。
然后,我们必须将连接字符串添加到 appsettings.json
文件中,如下所示:
{
"ConnectionStrings": {
"DefaultConnection": "Server=(localdb)\\mssqllocaldb;Database=package-management;Trusted_Connection=True;MultipleActiveResultSets=true"
},
...
}
最后,让我们将 Microsoft.EntityFrameworkCore.Design
添加到 ASP.NET Core 项目中。
现在,让我们打开 Visual Studio 的包管理器控制台,将 PackageManagementDB
设置为默认项目,然后运行以下命令:
Add-Migration Initial -Project PackageManagementDB
上述命令将生成第一个迁移。我们可以使用 Update-Database
命令将其应用到数据库中。请注意,如果您从 GitHub 复制项目,由于迁移已经创建,因此不需要生成迁移,但您仍然需要更新数据库。
在下一个子节中,我们将定义包含操作聚合体的业务逻辑的应用程序层。
定义应用程序层
作为第一步,为了简单起见,让我们通过向 ASP.NET Core 管道中添加以下代码将应用程序文化冻结为 en-US
:
app.UseAuthorization();
// Code to add: configure the Localization middleware
var ci = new CultureInfo("en-US");
app.UseRequestLocalization(new RequestLocalizationOptions
{
DefaultRequestCulture = new RequestCulture(ci),
SupportedCultures = new List<CultureInfo>
{
ci,
},
SupportedUICultures = new List<CultureInfo>
{
ci,
}
});
然后,让我们创建一个 Tools
文件夹,并将 ApplicationLayer
代码放在那里,这些代码可以在与本书相关的 GitHub 仓库的 ch11
代码中找到。有了这些工具,我们可以添加代码,自动发现并添加所有查询、命令处理器和事件处理器到 DI 引擎中,如下所示:
...
...
builder.Services.AddAllQueries(this.GetType().Assembly);
builder.Services.AddAllCommandHandlers(this.GetType().Assembly);
builder.Services.AddAllEventHandlers(this.GetType().Assembly);
然后,我们必须添加一个 Queries
文件夹来放置所有查询及其相关接口。例如,让我们看看列出所有包的查询:
public class PackagesListQuery(MainDbContext ctx) :IPackagesListQuery
{
public async Task<IReadOnlyCollection<PackageInfosViewModel>> GetAllPackages()
{
return await ctx.Packages.Select(m => new PackageInfosViewModel
{
StartValidityDate = m.StartValidityDate,
EndValidityDate = m.EndValidityDate,
Name = m.Name,
DurationInDays = m.DurationInDays,
Id = m.Id,
Price = m.Price,
DestinationName = m.MyDestination.Name,
DestinationId = m.DestinationId
})
.OrderByDescending(m=> m.EndValidityDate)
.ToListAsync();
}
}
查询对象自动注入到应用程序 DB 上下文中。GetAllPackages
方法使用 LINQ 将所有必需的信息投影到 PackageInfosViewModel
中,并按 EndValidityDate
属性降序排序所有结果。
涉及多个属性的投影是耗时且容易出错的;这就是为什么有映射库可以自动使用命名约定和配置设置来生成这些投影。映射库还有助于在对象之间复制数据,例如,例如,从 ViewModel 到 DTO,反之亦然。
在所有映射软件中,至少值得提一下 AutoMapper (www.nuget.org/packages/AutoMapper
)。
PackageInfosViewModel
与所有其他 ViewModel 一起放在 Models
文件夹中。将 ViewModel 按控制器定义不同的文件夹进行组织是一种常见的做法。值得分析用于编辑包的 ViewModel:
public class PackageFullEditViewModel: IPackageFullEditDTO
{
public PackageFullEditViewModel() { }
public PackageFullEditViewModel(IPackage o)
{
Id = o.Id;
DestinationId = o.DestinationId;
Name = o.Name;
Description = o.Description;
Price = o.Price;
DurationInDays = o.DurationInDays;
StartValidityDate = o.StartValidityDate;
EndValidityDate = o.EndValidityDate;
}
...
...
它有一个接受一个 IPackage
聚合的构造函数。这样,包数据就被复制到用于填充编辑视图的 ViewModel 中。它实现了在领域层定义的 IPackageFullEditDTO
DTO 接口。这样,它可以直接用于向领域层发送 IPackage
更新。
所有属性都包含客户端和服务器端验证引擎自动使用的验证属性。每个属性都包含一个 Display
属性,它定义了用于编辑属性的输入字段的标签。将字段标签放在 ViewModel 中比直接放在视图中更好,因为这样,相同的名称会自动在所有使用相同 ViewModel 的视图中使用。以下代码块列出了所有属性:
public int Id { get; set; }
[StringLength(128, MinimumLength = 5), Required]
[Display(Name = "name")]
public string Name { get; set; }= null!;
[Display(Name = "package infos")]
[StringLength(128, MinimumLength = 10), Required]
public string Description { get; set; }= null!;
[Display(Name = "price")]
[Range(0, 100000)]
public decimal Price { get; set; }
[Display(Name = "duration in days")]
[Range(1, 90)]
public int DurationInDays { get; set; }
[Display(Name = "available from"), Required]
public DateTime? StartValidityDate { get; set; }
[Display(Name = "available to"), Required]
public DateTime? EndValidityDate { get; set; }
[Display(Name = "destination")]
public int DestinationId { get; set; }
Commands
文件夹包含所有命令。例如,让我们看看用于修改包的命令:
public class UpdatePackageCommand: ICommand
{
public UpdatePackageCommand(IPackageFullEditDTO updates)
{
Updates = updates;
}
public IPackageFullEditDTO Updates { get; private set; }
}
它的构造函数必须使用 IPackageFullEditDTO
DTO 接口的实现来调用,在我们的例子中,是之前描述的编辑 ViewModel。命令处理器放在 Handlers
文件夹中。值得分析更新包的命令:
public class UpdatePackageCommandHandler(IPackageRepository repo, IEventMediator mediator)
它的主要构造函数已自动注入 IPackageRepository
仓库和一个 IEventMediator
实例,用于触发事件处理器。以下代码还显示了标准 HandleAsync
命令处理器方法的实现:
public async Task HandleAsync(UpdatePackageCommand command)
{
bool done = false;
IPackage model;
while (!done)
{
try
{
model = await repo.GetAsync(command.Updates.Id);
if (model == null) return;
model.FullUpdate(command.Updates);
await mediator.TriggerEvents(model.DomainEvents);
await repo.UnitOfWork.SaveEntitiesAsync();
done = true;
}
catch (DbUpdateConcurrencyException)
{
// add some logging here
}
}
}
HandleAsync
使用仓库来获取要修改的实体实例。如果实体未找到(已被删除),则停止执行命令。否则,所有更改都传递给检索到的聚合。更新后,立即触发聚合中包含的所有事件。特别是,如果价格已更改,则执行与价格变更相关的事件处理器。在 Package
实体的 EntityVersion
属性上声明的 [ConcurrencyCheck]
属性确保正确更新包版本(通过将其前一个版本号增加 1),以及将正确的版本号传递给价格变更事件。
此外,事件处理器放在 Handlers
文件夹中。以下是一个示例,让我们看看价格变更事件处理器:
public class PackagePriceChangedEventHandler( IPackageEventRepository repo) :
IEventHandler<PackagePriceChangedEvent>
{
public Task HandleAsync(PackagePriceChangedEvent ev)
{
repo.New(PackageEventType.CostChanged, ev.PackageId,
ev.OldVersion, ev.NewVersion, ev.NewPrice);
return Task.CompletedTask;
}
}
主要构造函数已自动注入 IPackageEventRepository
仓库,该仓库处理数据库表以及要发送到其他应用程序的所有事件。HandleAsync
实现简单地调用仓库方法,将新的 IPackageEvent
添加到要发送到其他微服务的队列中。
IPackageEvent
记录应由上述队列提取,并通过一个并行任务发送给所有感兴趣的微服务,但与该部分相关的 GitHub 代码中尚未实现这一点。它可以作为一个托管服务(从而继承自 BackgroundService
类)来实现,然后通过调用例如 builder.Services.AddHostedService<MyHostedService>()
来将其添加到 DI 引擎中,具体细节请参考第十一章 将微服务架构应用于您的企业应用程序 中的 使用通用宿主 子节。
我们几乎完成了!只是缺少表示层,在基于 MVC 的应用程序中,这包括控制器和视图。下一个子节定义了我们微服务所需的控制器和视图。
定义控制器和视图
我们需要向 Visual Studio 自动生成的控制器中添加两个额外的控制器——即 AccountController
,负责用户登录/注销和注册,以及 ManagePackageController
,处理所有与包相关的操作。只需在 Controllers
文件夹上右键单击,然后选择 添加 | 控制器。然后,选择控制器名称,并选择空 MVC 控制器以避免 Visual Studio 生成您不需要的代码。
图 21.27:添加 AccountController
值得指出的是,如果在一个创建 MVC 项目时选择自动添加身份验证,Visual Studio 可以自动生成所有用于管理用户的 UI。然而,生成的代码不尊重任何层或洋葱架构:它将所有内容插入到 MVC 项目中。这就是我们决定手动操作的原因。
为了简单起见,我们的AccountController
实现仅包含登录和注销方法,因此您只需使用初始管理员用户即可登录。然而,您可以添加进一步的动作方法,这些方法使用UserManager
类来定义、更新和删除用户。
图 21.28:登录、注销和身份验证
UserManager
类可以通过 DI 提供,如下所示:
public AccountController(
UserManager<IdentityUser<int>> userManager,
SignInManager<IdentityUser<int>> signInManager) : Controller
SignInManager
负责登录/注销操作。Logout
动作方法相当简单,如下所示(有关 ASP.NET Core 中身份验证的更多信息,请参阅第十七章“展示 ASP.NET Core”中的定义 ASP.NET Core 管道部分):
[HttpPost]
public async Task<IActionResult> Logout()
{
await signInManager.SignOutAsync();
return RedirectToAction(nameof(HomeController.Index), "Home");
}
它只是调用signInManager.SignOutAsync
方法,然后将浏览器重定向到主页。为了避免通过点击链接调用它,它被装饰了HttpPost
,因此只能通过表单提交来调用。
所有导致修改的请求绝不能使用GET
动词;否则,有人可能会错误地通过点击链接或在浏览器中输入错误的 URL 来触发这些操作。由 GET 触发的动作也可能被钓鱼网站利用,该网站可能会充分“伪装”一个触发危险 GET 动作的链接。GET 动词应仅用于检索信息。
另一方面,登录需要两个动作方法。第一个是通过GET
调用的,显示登录表单,用户必须在此处输入用户名和密码。如下所示:
[HttpGet]
public async Task<IActionResult> Login(string? returnUrl = null)
{
// Clear the existing external cookie
//to ensure a clean login process
await HttpContext
.SignOutAsync(IdentityConstants.ExternalScheme);
ViewData["ReturnUrl"] = returnUrl;
return View();
}
当浏览器被授权模块自动重定向到登录页面时,它接收returnUrl
作为其参数。这种情况发生在未登录的用户尝试访问受保护页面时。returnUrl
存储在传递给登录视图的ViewState
字典中。
登录视图中的表单在提交时将其连同用户名和密码一起传递给控制器,如下所示:
<form asp-route-returnurl="@ViewData["ReturnUrl"]" method="post">
...
</form>
表单提交被具有相同Login
名称但带有[HttpPost]
属性的 action 方法拦截,如下所示:
[ValidateAntiForgeryToken]
public async Task<IActionResult> Login(
LoginViewModel model,
string? returnUrl = null)
{
...
前面的方法接收登录视图使用的Login
模型,以及returnUrl
查询字符串参数。ValidateAntiForgeryToken
属性验证一个令牌(称为反伪造令牌),这是 MVC 表单自动完成的。然后将其添加到隐藏字段中,以防止 XSRF/CSRF 攻击。
伪造攻击利用存储在受害者浏览器中的身份验证 cookie 来向 Web 应用程序提交合法的已认证请求。他们通过诱导用户点击钓鱼网站上的按钮来实现这一点,该按钮导致向目标 Web 应用程序提交。由于一旦表单提交,浏览器就会自动发送目标 URL 的身份验证 cookie,因此欺诈请求被接受。对此类攻击有两种防御措施:
-
身份验证 cookie 被定义为同源——也就是说,只有在 GET 请求的情况下才从其他域发送。因此,当表单从钓鱼网站提交到目标应用程序时,它们不会被发送。
-
在表单中添加了反伪造令牌。在这种情况下,如果与提交的表单一起发送身份验证 cookie,应用程序会理解请求来自不同的网站,并由于缺少有效的反伪造令牌而阻止它。
作为第一步,如果用户已经登录,操作方法会注销用户:
if (User.Identity.IsAuthenticated)
{
await signInManager.SignOutAsync();
}
否则,它会验证是否存在验证错误,如果是的话,它会显示同一个视图,并用 ViewModel 的数据填充,让用户纠正错误:
if (ModelState.IsValid)
{
...
}
else
// If we got this far, something failed, redisplay form
return View(model);
如果模型有效,signInManager
用于登录用户:
var result = await signInManager.PasswordSignInAsync(
model.UserName,
model.Password, model.RememberMe,
lockoutOnFailure: false);
如果操作返回的结果是成功的,操作方法会在 returnUrl
不为空的情况下将浏览器重定向到 returnUrl
;否则,它将浏览器重定向到主页:
if (result.Succeeded)
{
if (!string.IsNullOrEmpty(returnUrl))
return LocalRedirect(returnUrl);
else
return RedirectToAction(nameof(HomeController.Index), "Home");
}
else
{
ModelState.AddModelError(string.Empty,
"wrong username or password");
return View(model);
}
如果登录失败,它会在 ModelState
中添加一个错误,并显示相同的表单让用户再次尝试。
ManagePackagesController
包含一个 Index
方法,该方法以表格格式显示所有包(有关控制器、视图以及一般 MVC 模式的更多详细信息,请参阅第十七章“展示 ASP.NET Core”中的 MVC 模式 部分):
[HttpGet]
public async Task<IActionResult> Index(
[FromServices] IPackagesListQuery query)
{
var results = await query.GetAllPackages();
var vm = new PackagesListViewModel { Items = results };
return View(vm);
}
查询对象通过依赖注入(DI)注入到操作方法中。然后,操作方法调用它并将结果 IEnumerable
插入到 PackagesListViewModel
实例的 Items
属性中。在 ViewModels 中包含 IEnumerable
而不是直接传递给视图是一种良好的实践,这样,如果需要,可以添加其他属性而无需修改现有的视图代码。
此外,如果 IEnumerable
是只读的,则将 ViewModels 的可枚举属性定义为 IReadOnlyCollection<T>
;如果可枚举可以修改或涉及模型绑定,则定义为 IList<T>
。实际上,ICollection<T>
有一个 Count
属性,这在渲染视图中的 ViewModels 时可能非常有用,而 IList<T>
也具有索引器,这对于成功进行模型绑定是必要的(参见 Phil Haack 的这篇帖子:haacked.com/archive/2008/10/23/model-binding-to-a-list.aspx
)。只有在需要 IEnumerable<T>
的典型延迟评估时才应首选 IEnumerable<T>
。
结果显示在 Bootstrap 表格中,因为 Bootstrap CSS 是由 Visual Studio 自动构建的。Bootstrap 是一个好的 CSS 框架选择,因为它相当简单且可扩展,并且与任何特定公司无关,而是由一个独立团队处理。
结果如下所示:
图 21.29:应用程序包处理页面
新包链接(它的形状类似于Bootstrap按钮,但它是一个链接)调用控制器Create
动作方法,而每一行中的删除和编辑链接分别调用Delete
和Edit
动作方法,并将显示在行中的包的 ID 传递给它们。
这里是两个行链接的实现:
@foreach(var package in Model.Items)
{
<tr>
<td>
<a asp-controller="ManagePackages"
asp-action="@nameof(ManagePackagesController.Delete)"
asp-route-id="@package.Id">
delete
</a>
</td>
<td>
<a asp-controller="ManagePackages"
asp-action="@nameof(ManagePackagesController.Edit)"
asp-route-id="@package.Id">
edit
</a>
</td>
...
...
值得描述的是HttpGet
和HttpPost
的Edit
动作方法的代码:
[HttpGet]
public async Task<IActionResult> Edit(
int id,
[FromServices] IPackageRepository repo)
{
if (id == 0) return RedirectToAction(
nameof(ManagePackagesController.Index));
var aggregate = await repo.Get(id);
if (aggregate == null) return RedirectToAction(
nameof(ManagePackagesController.Index));
var vm = new PackageFullEditViewModel(aggregate);
return View(vm);
}
HttpGet
的Edit
方法使用IPackageRepository
检索现有包。如果找不到包,这意味着它已被其他用户删除,浏览器将再次重定向到列表页面以显示更新的包列表。否则,聚合传递给PackageFullEditViewModel
视图模型,该模型由Edit
视图渲染。
用于渲染包的视图必须渲染一个包含所有可能的包目的地的 HTML select
,因此它需要一个IDestinationListQuery
查询的实例,该查询是为了辅助目的选择 HTML 逻辑而实现的。该查询直接注入到视图中,因为它是由视图负责决定如何使用户能够选择目的地。注入查询并使用它的代码如下所示:
@inject PackagesManagement.Queries.IDestinationListQuery destinationsQuery
@{
ViewData["Title"] = "Edit/Create package";
var allDestinations =
await destinationsQuery.AllDestinations();
}
处理视图表单 POST 的动作方法如下所示:
[HttpPost]
public async Task<IActionResult> Edit(
PackageFullEditViewModel vm,
[FromServices] ICommandHandler<UpdatePackageCommand> command)
{
if (ModelState.IsValid)
{
await command.HandleAsync(new UpdatePackageCommand(vm));
return RedirectToAction(
nameof(ManagePackagesController.Index));
}
else
return View(vm);
}
如果ModelState
有效,则创建UpdatePackageCommand
并调用其相关处理程序;否则,视图将再次显示给用户,以便他们能够纠正所有错误。
必须将指向包列表页面和登录页面的新链接添加到主菜单中,该菜单位于_Layout
视图中,如下所示:
@if (User.Identity.IsAuthenticated)
{
<li class="nav-item">
<a class="nav-link text-dark"
asp-controller="ManagePackages"
asp-action="Index">Manage packages</a>
</li>
<li class="nav-item">
<a class="nav-link text-dark"
href="javascript:document.getElementById('logoutForm').submit()">
Logout
</a>
</li>
}
else
{
<li class="nav-item">
<a class="nav-link text-dark"
asp-controller="Account" asp-action="Login">Login</a>
</li>
}
logoutForm
是一个空表单,它的唯一目的是向Logout
动作方法发送一个 POST 请求。它已被添加到页面的末尾,如下所示:
@if (User.Identity.IsAuthenticated)
{
<form asp-area="" asp-controller="Account"
asp-action="Logout" method="post"
id="logoutForm" ></form>
}
现在,应用程序已经准备好了!你可以运行它,登录,并开始管理包。
在本节中,我们学习了如何使用服务器端技术实现表示层,现在我们只需要学习如何使用客户端技术实现它。我们将在下一节中这样做。
使用客户端技术
在本节中,我们将实现一个针对 WWTravelClub 书籍用例的包搜索应用程序。下一小节解释了如何利用我们在本章前一小节中实现的 MVC 应用程序的领域层和数据层来设置解决方案。
准备解决方案
我们将修改 PackagesManagement
项目,以节省编码时间并展示如何将基于服务器端 MVC 技术的解决方案转换为基于 Blazor 客户端技术的解决方案。
首先,复制我们在上一节创建的 PackagesManagement
解决方案文件夹,并将其重命名为 PackagesManagementBlazor
。
要打开解决方案,右键单击网页项目(命名为 PackagesManagement
的项目)并移除它(选择 Remove
菜单项)。然后,转到解决方案文件夹并删除整个网页项目文件夹(命名为 PackagesManagement
)。
现在,右键单击解决方案并选择 添加新项目。添加一个名为 PackagesManagementBlazor.Client
的新 Blazor WebAssembly 独立应用程序项目。对于身份验证类型选择 None,配置为 https,并 包含示例页面。由于我们将要实现的位置搜索功能必须对未注册用户也可用,所以我们不需要身份验证。
现在,添加一个 PackagesManagementBlazor.Server
ASP.NET Core Web API 项目。对于身份验证类型选择 None,配置为 https,启用 OpenAPI 支持,和 使用控制器。
最后,添加一个 PackagesManagementBlazor.Shared
类库项目,删除 Visual Studio 创建的默认 Class1
类,并将此项目添加到 PackagesManagementBlazor.Client
和 PackagesManagementBlazor.Server
中作为引用。
服务器项目需要引用域实现(PackagesManagementDB
)和域抽象(PackagesManagementDomain
)项目,所以请将它们添加为引用。
PackagesManagementBlazor.Client
和 PackagesManagementBlazor.Server
必须同时启动,所以通过右键单击解决方案,选择 配置启动项目,并将它们定义为启动项目。
现在,启动解决方案以验证一切是否正常工作。应该打开两个浏览器窗口,一个用于测试 REST API 项目,另一个包含一个 Blazor 应用程序。
请注意 Blazor 应用程序的 URL(在我的情况下是 https://localhost:7027/
),因为我们将会用到它。
Blazor 网站将与运行在不同域上的 REST API 网站交换数据(域由主机名和端口号共同标识)。来自运行在不同域上的浏览器的 REST API 调用是潜在钓鱼攻击的线索;因此,接收服务器只接受来自知名域的请求(这样,它们可以确信这些请求不是来自钓鱼网站)。
另一方面,当浏览器应用程序尝试与不同的 URL 进行通信时,浏览器会检测到它,并以称为 CORS 的不同协议发出调用。因此,一旦它检测到 CORS 协议,接收服务器就会理解它正在处理来自不同网站的请求,并且只有在其他域已注册所谓的 CORS 策略时才会服务该请求。
在 ASP.NET Core 中,CORS 策略通过 Program.cs
中的 builder.Services.AddCors
扩展方法注册。
因此,在我们的情况下,我们需要在 PackagesManagementBlazor.Server
网页应用程序中为 PackagesManagementBlazor.Client
网页应用程序的域注册 CORS 策略,因为 PackagesManagementBlazor.Client
的所有 REST API 调用都将发送到 PackagesManagementBlazor.Server
。
因此,让我们打开 PackagesManagementBlazor.Server
项目的 Program.cs
,并通过添加以下内容来启用 CORS:
builder.Services.AddCors(o => {
o.AddDefaultPolicy(pbuilder =>
{
pbuilder.AllowAnyMethod();
pbuilder.WithHeaders(HeaderNames.ContentType, HeaderNames.Authorization);
pbuilder.WithOrigins("https://localhost:7027/");
});
});
此外,在 app.UseAuthorization();
之前立即添加 app.UseCors();
到 ASP.NET Core 管道中。
现在,我们必须启用 Blazor 应用程序与该服务器进行通信。再次启动解决方案,并注意 REST API URL(在我的情况下,为 https://localhost:7269/
),然后替换 Blazor 应用程序的 Program.cs
文件中 HttpClient
配置中的 URL:
builder.Services.AddScoped(sp => new HttpClient {
BaseAddress = new Uri("https://localhost:7269/")});
让我们也把旧网页项目的相同连接字符串复制到 PackagesManagementBlazor.Server
的 appsettings.json
文件中:
"ConnectionStrings": {
"DefaultConnection": "Server=(localdb)\\mssqllocaldb;Database=package-management;Trusted_Connection=True;MultipleActiveResultSets=true"
},
这样,我们可以重用我们创建的数据库。我们还需要添加与旧网页项目相同的 DDD 工具。在项目根目录中添加一个名为 Tools
的文件夹,并将 GitHub 仓库中与书籍相关的 ch07
-> ApplicationLayer
文件夹的内容复制到那里。
为了完成解决方案设置,我们只需将 PackagesManagementBlazor.Server
与领域层连接起来,在 Program.cs
文件中服务配置代码的末尾添加以下代码:
builder.services.AddDbLayer(Configuration
.GetConnectionString("DefaultConnection"),
"PackagesManagementDB");
这是我们添加到旧网页项目中的相同方法。最后,我们还可以添加 AddAllQueries
扩展方法,该方法可以查找网页项目中的所有查询:
builder.services.AddAllQueries(this.GetType().Assembly);
由于这是一个仅查询的应用程序,我们不需要其他自动发现工具。
由于 Entity Framework Core 8 中存在一个错误,您还需要在服务器项目中更改一个设置,以防止使用 .NET 文化。您必须将 InvariantGlobalization
项目设置更改为 false
:
<InvariantGlobalization>false</InvariantGlobalization>
到目前为止,我们已经完全配置了项目。我们只需实现服务器端代码和实现我们的包搜索的客户端代码。
下一个子节解释了如何设计服务器端 REST API。
实现所需的 ASP.NET Core REST API
作为第一步,让我们定义服务器和客户端应用程序之间通信所使用的 ViewModels。它们必须在由两个应用程序引用的 PackagesManagementBlazor.Shared
项目中定义。
让我们从 PackageInfosViewModel
ViewModel 开始,它将是 Blazor 应用程序用来与服务器端 REST API 交换包信息的数据结构:
using System;
namespace PackagesManagementBlazor.Shared
{
public class PackageInfosViewModel
{
public int Id { get; set; }
public required string Name { get; set; }
public decimal Price { get; set; }
public int DurationInDays { get; set; }
public DateTime? StartValidityDate { get; set; }
public DateTime? EndValidityDate { get; set; }
public required string DestinationName { get; set; }
public int DestinationId { get; set; }
public override string ToString()
{
return $"{Name}. {DurationInDays} days in {DestinationName}, price: {Price}";
}
}
}
然后,添加封装所有包的 ViewModel 以返回到 Blazor 应用程序:
using System.Collections.Generic;
namespace PackagesManagementBlazor.Shared
{
public class PackagesListViewModel
{
Public required ReadOnlyCollection<PackageInfosViewModel>
Items { get; set; }
}
}
现在,我们也可以添加我们的按位置搜索包的查询。让我们在 PackagesManagementBlazor.Server
项目的根目录中添加一个 Queries
文件夹,然后添加定义我们的查询的接口,IPackagesListByLocationQuery
:
using DDD.ApplicationLayer;
using PackagesManagementBlazor.Shared;
using System.Collections.Generic;
using System.Threading.Tasks;
namespace PackagesManagementBlazor.Server.Queries
{
public interface IPackagesListByLocationQuery: IQuery
{
Task<ReadOnlyCollection<PackageInfosViewModel>>
GetPackagesOf(string location);
}
}
最后,让我们也添加查询实现:
public class PackagesListByLocationQuery(MainDbContext ctx):IPackagesListByLocationQuery
{
public async Task<ReadOnlyCollection<PackageInfosViewModel>>
GetPackagesOfAsync(string location)
{
Return new ReadOnlyCollection<PackageInfosViewModel>
(await ctx.Packages
.Where(m => m.MyDestination.Name.StartsWith(location))
.Select(m => new PackageInfosViewModel
{
StartValidityDate = m.StartValidityDate,
EndValidityDate = m.EndValidityDate,
Name = m.Name,
DurationInDays = m.DurationInDays,
Id = m.Id,
Price = m.Price,
DestinationName = m.MyDestination.Name,
DestinationId = m.DestinationId
})
.OrderByDescending(m=> m.EndValidityDate)
.ToListAsync());
}
}
我们终于准备好定义我们的 PackagesController
:
using Microsoft.AspNetCore.Mvc;
using PackagesManagementBlazor.Server.Queries;
using PackagesManagementBlazor.Shared;
using System.Threading.Tasks;
namespace PackagesManagementBlazor.Server.Controllers
{
[Route("[controller]")]
[ApiController]
public class PackagesController : ControllerBase
{
// GET api/<PackagesController>/Flor
[HttpGet("{location}")]
public async Task<PackagesListViewModel>
GetAsync(string location,
[FromServices] IPackagesListByLocationQuery query )
{
return new PackagesListViewModel
{
Items = await query.GetPackagesOf(location)
};
}
}
}
服务器端代码已完成!让我们继续定义与服务器通信的 Blazor 服务。
在服务中实现业务逻辑
让我们在 PackagesManagementBlazor.Client
项目中添加一个 ViewModels
和一个 Services
文件夹。我们需要的多数 ViewModel 都在 PackagesManagementBlazor.Shared 项目中定义。我们只需要一个用于搜索表单的 ViewModel。让我们将其添加到 ViewModels
文件夹中:
using System.ComponentModel.DataAnnotations;
namespace PackagesManagementBlazor.Client.ViewModels
{
public class SearchViewModel
{
[Required]
public string? Location { get; set; }
}
}
让我们把我们的服务命名为 PackagesClient
,并将其添加到 Services
文件夹中:
namespace PackagesManagementBlazor.Client.Services
{
public class PackagesClient
{
private HttpClient client;
public PackagesClient(HttpClient client)
{
this.client = client;
}
public async Task<IEnumerable<PackageInfosViewModel>>
GetByLocationAsync(string location)
{
var result =
await client.GetFromJsonAsync<PackagesListViewModel>
("Packages/" + Uri.EscapeDataString(location));
return result.Items;
}
}
}
代码很简单!Uri.EscapeDataString
方法将参数进行 URL 编码,以便它可以安全地附加到 URL 上。
最后,让我们在依赖注入中注册服务:
builder.Services.AddScoped<PackagesClient>();
值得注意的是,在一个商业应用程序中,我们应该通过一个 IPackagesClient
接口注册服务,以便在测试中对其进行模拟(.AddScoped<IPackagesClient, PackagesClient>()
)。
一切准备就绪,我们只需要构建用户界面。
实现用户界面
作为第一步,让我们删除我们不需要的应用程序页面——即 Pages->Counter.razor
和 Pages->Weather.razor
。同时,我们也从 Shared
-> NavMenu.razor
的侧菜单中移除它们的链接。
我们将把代码放在 Pages
-> Home.razor
页面中。让我们用以下代码替换这个页面的代码:
@using PackagesManagementBlazor.Client.ViewModels
@using PackagesManagementBlazor.Shared
@using PackagesManagementBlazor.Client.Services
@inject PackagesClient client
@page "/"
<h1>Search packages by location</h1>
<EditForm Model="search"
OnValidSubmit="Search">
<DataAnnotationsValidator />
<div class="form-group">
<label for="integerfixed">Insert location starting chars</label>
<InputText @bind-Value="search.Location" />
<ValidationMessage For="@(() => search.Location)" />
</div>
<button type="submit" class="btn btn-primary">
Search
</button>
</EditForm>
@code{
SearchViewModel search { get; set; } = new SearchViewModel();
async Task Search()
{
...
}
}
上述代码添加了需要的 @using
语句,将我们的 PackagesClient
服务注入到页面中,并定义了搜索表单。当表单成功提交时,它将调用 Search
回调,我们将在这里放置检索所有结果的代码。
是时候添加显示所有结果和完成 @code
块的逻辑了。以下代码必须立即放置在搜索表单之后:
@if (packages != null)
{
...
}
else if (loading)
{
<p><em>Loading...</em></p>
}
@code{
SearchViewModel search { get; set; } = new SearchViewModel();
private IEnumerable<PackageInfosViewModel> packages;
bool loading;
async Task Search()
{
packages = null;
loading = true;
await InvokeAsync(StateHasChanged);
packages = await client.GetByLocationAsync(search.Location);
loading = false;
}
}
在 if
块中省略的代码负责渲染包含所有结果的表格。我们将在注释完前面的代码后展示它。
在使用 PackagesClient
服务检索结果之前,我们删除所有之前的结果,并设置 loading
字段,以便 Razor 代码选择替换之前表格的加载消息的 else if
路径。一旦我们设置了这些变量,我们就必须调用 StateHasChanged
来触发更改检测并刷新页面。在检索到所有结果并且回调返回之后,不需要再次调用 StateHasChanged
,因为回调的终止本身就会触发更改检测并导致所需的页面刷新。
这里是渲染包含所有结果的表的代码:
<div class="table-responsive">
<table class="table">
<thead>
<tr>
<th scope="col">Destination</th>
<th scope="col">Name</th>
<th scope="col">Duration/days</th>
<th scope="col">Price</th>
<th scope="col">Available from</th>
<th scope="col">Available to</th>
</tr>
</thead>
<tbody>
@foreach (var package in packages)
{
<tr>
<td>
@package.DestinationName
</td>
<td>
@package.Name
</td>
<td>
@package.DurationInDays
</td>
<td>
@package.Price
</td>
<td>
@(package.StartValidityDate.HasValue ?
package.StartValidityDate.Value.ToString("d")
:
String.Empty)
</td>
<td>
@(package.EndValidityDate.HasValue ?
package.EndValidityDate.Value.ToString("d")
:
String.Empty)
</td>
</tr>
}
</tbody>
</table>
</div>
运行项目并输入 Florence 的初始字母。由于我们在前面的章节中(见第十三章 使用 Entity Framework Core 查询和更新数据部分,在 C# 中与数据交互 – Entity Framework Core),将 Florence 作为同一数据库中的位置插入(),应该会显示一些结果。如果您插入了不同的数据,请尝试使用不同的起始字母。
通常,与基于 Web 的客户端一起,所有应用程序也提供移动原生应用程序,以在可能较慢的移动设备上获得更好的性能。因此,让我们也设计一个移动原生客户端应用程序!
添加 Blazor MAUI 版本
在本节中,我们解释如何将 Blazor MAUI 版本添加到上一个解决方案的应用程序中。
首先,在解决方案资源管理器中右键单击解决方案图标,并将新项目添加到解决方案中。选择一个 MAUI Blazor 应用程序,并将其命名为 PackagesManagementMAUIBlazor
。
打开 PackagesManagementMAUIBlazor
项目文件,并移除您不想支持的 所有平台(我移除了 Android、iOS 和 Mac Catalyst,只保留了 Windows):
<TargetFrameworks> Condition="$([MSBuild]::IsOSPlatform('windows'))">$(TargetFrameworks);net8.0-windows10.0.19041.0 </TargetFrameworks>
然后,添加对 PackagesManagementBlazor.Shared
项目的引用。
现在,右键单击客户端 WebAssembly 项目并选择在文件资源管理器中打开文件夹。然后,将 ViewModels
和 Services
文件夹复制,并将它们粘贴在 Visual Studio 解决方案资源管理器中新建的 PackagesManagementMAUIBlazor
节点下。
然后,将这些文件夹中包含的文件的名空间分别更改为 PackagesManagementMAUIBlazor.ViewModels
和 PackagesManagementMAUIBlazor.Services
。
现在,将新创建的项目中的 Shared
和 Pages
文件夹的内容替换为客户端 Blazor WebAssembly 项目的 Layout
和 Pages
文件夹的内容。
编辑新复制的 Home.razor
文件,并将其标题替换为:
@using PackagesManagementMAUIBlazor.Services
@using PackagesManagementBlazor.Shared
@using PackagesManagementMAUIBlazor.ViewModels
@inject PackagesClient client
@page "/"
最后,将 WebAssembly 项目的相同 HttpClient
和 PackagesClient
配置添加到 MAUIProgram.cs
:
builder.Services.AddScoped(sp => new HttpClient
{ BaseAddress = new Uri("https://localhost:7269/") });
builder.Services.AddScoped<PackagesClient>();
使用此方法,您必须将 PackagesManagementMAUIBlazor
添加到项目中以开始。右键单击解决方案并选择配置启动项目。
在打开的窗口中,选择新建的 MAUI Blazor 项目。
现在,您可以启动应用程序。应该打开三个窗口(两个浏览器窗口和一个 Windows 窗口),但是两个客户端项目窗口应该显示完全相同的应用程序。
在讨论了我们的 WWTravelClub 应用程序的所有组件之后,我们只需要描述如何测试它。
测试 WWTravelClub 应用程序
在本节中,我们将向本章前端微服务部分描述的PackagesManagement
前端微服务添加一些单元和功能测试项目。如果您还没有它,您可以从与本书相关的 GitHub 仓库的ch19
文件夹中的部分下载它。值得注意的是,在实际项目中,单元测试集通过集成测试得到增强,而验收测试将包括不仅功能测试,还包括各种类型的性能测试。
在继续本节之前,我们鼓励您回顾第九章,测试您的企业应用程序。
作为第一步,让我们创建解决方案文件夹的新副本,并将其命名为PackagesManagementWithTests
。然后,打开解决方案并将其添加到名为PackagesManagementTest
的 xUnit .NET C#测试项目中。最后,添加对 ASP.NET Core 项目(PackagesManagement
)的引用,因为我们将会对其进行测试,并添加对最新版本的Moq
NuGet
包的引用,因为我们需要模拟功能。
值得记住的是Moq
是一个模拟库,而模拟的目的是通过用完全受测试代码控制的模拟类替换实际类来解耦类之间的依赖关系。这样,每个类都可以独立于它引用的其他类的行为进行单元测试。有关Moq
的更多详细信息,请参阅第九章,测试您的企业应用程序。
到目前为止,我们已经准备好编写我们的测试。
例如,我们将为ManagePackagesController
控制器中带有[HttpPost]
装饰的Edit
方法编写单元测试,如下所示:
[HttpPost]
public async Task<IActionResult> Edit(
PackageFullEditViewModel vm,
[FromServices] ICommandHandler<UpdatePackageCommand> command)
{
if (ModelState.IsValid)
{
await command.HandleAsync(
new UpdatePackageCommand(vm));
return RedirectToAction(
nameof(ManagePackagesController.Index));
}
else
return View(vm);
}
在编写我们的测试方法之前,让我们将自动包含在测试项目中的测试类重命名为ManagePackagesControllerTests
。
第一个测试验证了如果ModelState
中存在错误,动作方法将渲染一个与它接收的参数相同的视图,以便用户可以纠正所有错误。我们需要测试所有可能性。让我们删除现有的测试方法,并编写一个空的DeletePostValidationFailedTest
方法,如下所示:
[Fact]
public async Task DeletePostValidationFailedTest()
{
}
该方法必须是async
,并且返回类型必须是Task
,因为我们必须测试的Edit
方法本身就是async
。在这个测试中,我们不需要模拟对象,因为没有注入的对象会被使用。因此,作为测试的准备,我们只需要创建一个控制器实例,并且我们必须按照以下方式向ModelState
添加一个错误:
var controller = new ManagePackagesController();
controller.ModelState
.AddModelError("Name", "fake error");
然后,我们调用该方法,注入ViewModel
和一个null
命令处理器作为其参数,因为命令处理器将不会被使用:
var vm = new PackageFullEditViewModel();
var commandDependency =
new Mock<ICommandHandler<UpdatePackageCommand>>();
var result = await controller.Edit(vm, commandDependency.Object);
在验证阶段,我们验证结果是否为ViewResult
,并且它包含注入到控制器中的相同模型:
var viewResult = Assert.IsType<ViewResult>(result);
Assert.Equal(vm, viewResult.Model);
现在,我们还需要一个测试来验证如果没有错误,命令处理器会被调用,然后浏览器会被重定向到Index
控制器动作方法。我们调用DeletePostSuccessTest
方法:
[Fact]
public async Task DeletePostSuccessTest()
{
}
这次,准备代码必须包括命令处理器模拟的准备,如下所示:
var controller = new ManagePackagesController();
var commandDependency =
new Mock<ICommandHandler<UpdatePackageCommand>>();
commandDependency
.Setup(m =>
m.HandleAsync(It.IsAny<UpdatePackageCommand>()))
.Returns(Task.CompletedTask);
var vm = new PackageFullEditViewModel();
由于HandleAsync
方法不返回任何async
值,我们不能使用ReturnsAsync
,但我们必须使用Returns
方法仅返回一个完成的Task (Task.Complete)
。要测试的方法是用ViewModel
和模拟的处理程序一起调用的:
var result = await controller.Edit(vm,
commandDependency.Object);
在这种情况下,验证代码如下:
commandDependency.Verify(m => m.HandleAsync(
It.IsAny<UpdatePackageCommand>()),
Times.Once);
var redirectResult = Assert.IsType<RedirectToActionResult>(result);
Assert.Equal(nameof(ManagePackagesController.Index),
redirectResult.ActionName);
Assert.Null(redirectResult.ControllerName);
作为第一步,我们验证命令处理器实际上被调用了一次。更好的验证还应包括检查它是否使用传递给动作方法的ViewModel
调用的命令。我们将将其作为一个练习来处理。
然后我们验证动作方法返回带有正确动作方法名称的RedirectToActionResult
,并且没有指定控制器名称。
一旦所有测试都准备好了,如果测试窗口没有出现在 Visual Studio 的左侧栏中,我们只需从 Visual Studio 的测试菜单中选择运行所有测试项。一旦测试窗口出现,进一步的调用可以从该窗口内部启动。
如果测试失败,我们可以在其代码中添加一个断点,这样我们就可以通过在测试窗口中右键单击它并选择调试选定的测试来启动对其的调试会话。值得记住的是,失败不一定依赖于测试代码中的错误,也可能依赖于测试代码本身的错误。
在下一个子节中,我们将展示如何将我们的代码上传到共享的 Azure DevOps 仓库,以及如何使用 Azure DevOps 管道自动化我们的测试。
连接到 Azure DevOps 仓库
测试在应用程序 CI/CD 周期中扮演着基本角色,特别是在 CI 中。它们必须在至少每次修改应用程序仓库的 master 分支时执行,以验证更改不会引入错误。
以下步骤展示了如何将我们的解决方案连接到 Azure DevOps 仓库,在那里我们将定义一个 Azure DevOps 管道,该管道构建项目,并在每次构建时启动我们在PackagesManagementTest
项目中定义的所有单元测试。
然而,我们在PackagesManagementFTest
项目中定义的功能测试必须在发布冲刺之前执行。因此,它们必须放在不同的管道中,该管道负责交付应用程序。
这样,每天在所有开发者推送他们的更改后,我们都可以启动管道来验证仓库代码是否编译并通过所有单元测试:
-
作为第一步,我们需要一个免费的 DevOps 订阅。如果您还没有,请通过点击此页面的开始免费按钮创建一个:
azure.microsoft.com/en-us/services/devops/
。在这里,让我们按照向导定义一个组织,然后是一个项目。 -
在项目页面上,选择文件菜单,点击仓库菜单项,然后复制仓库 URL:
图 21.30:复制仓库 URL
- 确保您使用 Azure 账户(与创建 DevOps 账户时使用的相同账户)登录到 Visual Studio。在Git 变更选项卡中,点击创建 Git 仓库...按钮:
图 21.31:打开连接窗口
- 在打开的窗口中,从左侧菜单选择现有远程,并复制粘贴远程仓库 URL:
图 21.32:连接窗口
- 然后,点击创建并推送按钮,等待 Visual Studio 左下角准备图标被勾选:
图 21.33:操作完成
-
到目前为止,仓库已在本地创建,与所选远程仓库连接,并且所有更改都已提交并推送到远程仓库。
-
现在,点击管道菜单项以创建一个 DevOps 管道来构建和测试您的项目。在出现的窗口中,点击按钮创建一个新的管道:
图 21.34:管道页面
- 您将被提示选择您的仓库位置:
图 21.35:仓库选择
- 选择Azure Repos Git然后选择您的仓库。然后,您将被提示关于项目的性质:
图 21.36:管道配置
- 选择ASP.NET Core。将为您自动创建一个构建和测试项目的管道。通过将新创建的
.yaml
文件提交到您的仓库来保存它:
图 21.37:管道属性
- 可以通过选择队列按钮来运行管道,但由于由 DevOps 标准化的管道在仓库的主分支上有触发器,因此每次提交此分支的更改以及每次修改管道时,它都会自动启动。可以通过点击编辑按钮来修改管道:
图 21.38:管道代码
-
一旦进入编辑模式,可以通过点击每个步骤上出现的设置链接来编辑所有管道步骤。可以按照以下方式添加新的管道步骤:
-
在新步骤必须添加的位置写上
- task:
,然后在输入任务名称时接受出现的建议之一。 -
一旦你写了一个有效的任务名称,一个设置链接就会出现在新步骤上方。点击它。
-
在出现的窗口中插入所需的任务参数,然后保存。
-
-
为了让我们的测试工作,我们需要指定定位包含测试的所有程序集的准则。在我们的情况下,由于我们只需要执行
PackagesManagementTest.dll
中包含的测试,而不是PackagesManagementFTest.dll
中包含的测试,我们必须指定确切的.ddl
名称。点击
VSTest@2
测试任务的设置链接,并将自动建议的测试文件字段的内容替换为以下内容:**\PackagesManagementTest.dll !**\*TestAdapter.dll !**\obj\**
-
然后,点击添加以修改实际的管道内容。一旦你在保存并运行对话框中确认了你的更改,管道就会被启动,如果没有错误,测试结果就会被计算。在特定构建期间启动的测试结果可以通过在管道运行选项卡中选择特定的构建,并点击出现的页面上的测试选项卡来分析。在我们的情况下,我们应该看到以下截图类似的内容:
图 21.39:测试结果
总结起来,我们创建了一个新的 Azure DevOps 仓库,将解决方案发布到新仓库,然后创建了一个在每次构建后执行我们的测试的构建管道。一旦我们保存它,构建管道就会被执行,并且每次有人向主分支提交更改时,它都会被执行。
摘要
本章的主要目的是提供一些可以用于开始为企业交付软件解决方案挑战的实现片段。尽管我们没有展示 WWTravelClub 应用程序的完整实现,但我们相信这里提供的微服务和代码可以帮助你在你的专业项目中;这符合我们理解的软件架构师的主要目的——帮助团队开发满足用户需求的软件。
留下评论!
喜欢这本书吗?通过留下亚马逊评论来帮助像你这样的读者。扫描下面的二维码以获取 20%的折扣码。
限时优惠
第二十二章:案例研究扩展:为 Kubernetes 开发 .NET 微服务
在本章中,我们将结合第二十一章 案例研究 中探讨的 .NET 微服务的实际实施见解,以及第二十章 Kubernetes 中介绍的 Kubernetes 的基础知识。我们的重点是准备 .NET 代码以无缝集成到 Kubernetes 中,涵盖整个开发周期——从编码到调试,甚至包括部署后的故障排除。
我们将引导您通过设置一个针对 Kubernetes 优化的开发工作站的过程,学习使用 Docker 打包代码的细节,并了解如何组织代码库以确保在各种环境中(如 Docker Desktop、本地 minikube 安装以及生产或预发布 Kubernetes 集群)无故障执行。
此外,本章还深入探讨了远程调试的细微差别,为您提供高效故障排除和调试应用程序所需的技能。在这里,您将学习如何准备每个开发工作站的配置,如何使用 Docker 打包代码,以及如何组织代码,使其可以立即在开发者的 Docker Desktop、开发者的本地 Minikube 安装以及生产/预发布 Kubernetes 集群上运行,无需修改。
到本章结束时,您将掌握生产或预发布环境中应用程序的远程调试技术,从而实现快速问题解决和系统可靠性。
更具体地说,您将学习以下主题:
-
.NET Kubernetes 开发所需的工具
-
组织开发流程
-
在 Minikube 中运行您的应用程序
-
Kubernetes 应用程序的远程调试
所有概念都将通过从 第二十一章,案例研究 中选取的先前示例进行解释,我们将对其进行修改以适应 Kubernetes 执行。
您将改编第二十一章 案例研究 中的 GrpcMicroService
微服务,亲眼看到真实世界应用程序如何过渡到 Kubernetes。
要充分利用本章内容,请加强您对 Docker 和 Kubernetes 的理解,这些内容在第十一章 将微服务架构应用于您的企业应用程序 和第二十章 Kubernetes 中有所阐述,它们构成了本章讨论的高级实践的基石。
技术要求
本章需要 Visual Studio 2022 免费社区版或更高版本,并安装所有数据库工具。
您还需要以下内容:
-
WSL(Windows Subsystem for Linux)和 Docker Desktop for Windows。关于如何安装这两个软件的详细说明见第十一章 将微服务架构应用于您的企业应用程序 中的 技术要求 部分。
-
指定 Docker 作为虚拟化工具的 Minikube 安装。Minikube 安装在 第二十章,Kubernetes 中的 使用 Minikube 部分进行了描述。
-
允许 TCP/IP 连接的 SQL Server 数据库。你不能使用随 Visual Studio 安装一起提供的 SQL Server Express LocalDB,因为它不允许 TCP/IP 连接。因此,你需要一个完整的 SQL Express 安装或一个 Azure SQL Server 数据库。关于如何满足这一要求的更多细节将在本章的 .NET Kubernetes 开发所需工具 部分中给出。
本章的所有代码都可以在本书关联的 GitHub 仓库中找到:github.com/PacktPublishing/Software-Architecture-with-C-Sharp-12-and-.NET-8-4E
。
.NET Kubernetes 开发所需工具
每个单独的微服务都可以使用你在 第九章,测试你的企业应用程序 中学到的技术独立于其应用程序的其余部分进行单元测试和调试。你不需要将其打包在 Docker 镜像中来做这件事。
然而,对整个应用程序或其部分进行调试和执行集成测试需要所有涉及的微服务进行交互,并打包成最终应用程序的形式。
你可以使用预发布环境来测试你的应用程序。在部署到预发布环境之前,确保你的应用程序在开发环境中的稳定性,以避免耗时的故障排除,因为预发布环境没有开发环境中所有可用的设施。否则,解决在预发布环境中发现的所有常见错误和崩溃可能意味着不可接受的时间成本。
因此,在将应用程序部署到预发布环境之前,最好先达到良好的应用程序稳定性。此外,为了更容易、更高效地进行调试-修复循环,最好所有微服务都在单个开发机器上运行。这就是为什么每个开发工作站都必须配备 Docker 和 Minikube。
此外,开发机器必须能够模拟微服务之间以及服务与其他存储媒体(如数据库)之间的所有通信。
很可能,Minikube 可以运行并模拟在真实 Kubernetes 集群中发生的所有通信,包括它在单个开发机器上运行时。
我们还可以在将它们加载到 Minikube 之前,让所有涉及的 Docker 镜像之间进行通信,因为 Docker Desktop 允许创建本地 Docker 镜像可以访问的虚拟网络。
最后,Docker 和 Minikube 虚拟网络自动包括托管它们的开发机器,因此我们可以将存储服务(如磁盘卷和数据库)放置在开发机器本身上。
图 22.1:Minikube 和 Docker 网络结构
然而,Docker 和 Kubernetes 的复杂虚拟网络功能不足以确保高效的开发和调试环境,还需要进一步的工具。
以下是我们需要解决的所有问题以及如何解决这些问题,以配置有效的开发调试环境:
-
默认情况下,Visual Studio 安装的是 SQL Server Express LocalDB 而不是 SQL Server Express,而 SQL Server Express LocalDB 无法通过实际或虚拟网络进行通信。因此,我们需要 SQL Server Express 的安装或外部数据库。
-
由于 Kubernetes 节点仅具有由 Kubernetes 引擎本身处理的虚拟地址,因此 Visual Studio 调试器可以通过 Kubernetes 引擎的 REST API 将其附加到正在运行的微服务。在撰写本文时,Visual Studio 可用的最佳工具是 Bridge to Kubernetes,它反过来使用 kubectl 与任何 Kubernetes 集群的 API 进行交互,包括 Minikube。不幸的是,我们无法使用在 Minikube 主机虚拟机上运行的 Kubectl 安装,就像我们在 第二十章,Kubernetes 中所做的那样,但我们需要一个直接在开发机器上运行的安装。
我们将在两个专门的子节中描述如何安装和配置上述提到的所有工具。
安装和配置 SQL Server Express
如果您有权访问在您的开发机器上运行的 SQL Server 实例,您可以使用该实例。否则,您可以根据 第十二章,在云中选择您的数据存储 中的说明在 Azure 中创建 SQL Server 数据库,或者安装 SQL Server Express 的本地实例:
-
首先,从
www.microsoft.com/en-US/download/details.aspx?id=104781
下载 SQL Server 安装程序。 -
您可以在 SQL Server Express 和 SQL Server Express Advanced 之间自由选择,但请选择一个包含 SQL Server Management Studio 和 SQL Server 管理控制台 的完整安装。
-
选择将 SQL Server 作为默认实例安装到您的计算机上(安装过程中的默认选项)。
安装完成后,您必须运行 SQL Server 管理控制台(只需在 Windows 搜索框中键入此名称)以启用基于 TCP/IP 的连接。为了正确配置 SQL Server,请遵循以下所有步骤。
-
一旦进入 SQL Server 管理控制台,展开 SQL Server 网络配置 节点。
-
选择 <您的实例名称> 的协议。
-
在右侧详细窗格中,您应该看到所有可用的通信协议。
-
右键单击 TCP/IP 并选择 启用。
-
现在,TCP/IP 已启用,但使用动态端口。为了强制使用固定端口,右键单击相同的 TCP/IP 节点并选择 属性。
图 22.2:强制使用静态 IP 地址
-
选择 IP 地址 选项卡。
-
您应该看到几个 IP 地址。这些都是与您的计算机关联的 IP 地址,每个地址都会执行下一步。
-
从TCP 动态端口中移除0,并保持此字段为空,然后在TCP 端口字段中写入
1433
。 -
完成后,点击确定按钮。
-
现在,你需要重新启动 SQL Server 服务。在左侧窗格中选择SQL Server 服务。
-
最后,在右侧详细窗格中,右键单击SQL Server <你的实例名称>并选择重启。
-
安装完成后,SQL Server 仅启用了 Windows 身份验证。为了在非 Windows 网络上使用实例,你必须启用基于用户名的身份验证并定义至少一个管理员用户。这是一个必要的步骤,因为 Windows 身份验证在 Docker 网络上和 Kubernetes 上都不会工作。
-
你可以在 SQL Server Management Studio 中完成此操作。一旦 SQL Server Management Studio 打开,它会提示你连接的实例和身份验证信息。你刚刚安装的数据库的实例名称应该是类似于
<计算机名称>\SQLEXPRESS
的名称;选择它,并选择Windows 身份验证,如图下所示:
图 22.3:使用 SQL Server Management Studio 连接
一旦与数据库连接,你可以按照以下步骤启用基于用户名的身份验证:
-
在对象资源管理器中右键单击你的服务器图标并选择属性。
-
在打开的窗口中,在左侧窗格中选择安全。
-
选择SQL Server 和 Windows 身份验证模式,如图下所示:
图 22.4:启用 SQL Server 身份验证
为了使你的更改生效,你必须重新启动 SQL Server。你可以通过在对象资源管理器中右键单击你的服务器图标并选择重启来完成此操作。
现在,你需要按照以下步骤定义至少一个用户:
-
在对象资源管理器中展开你的服务器图标下的安全文件夹。
-
右键单击登录名文件夹并选择新建登录名。
-
在打开的窗口中,输入一个用户名。
-
选择SQL Server 身份验证,输入一个密码,并在确认密码字段中重新输入相同的密码,如图下所示:
图 22.5:定义用户名和密码
- 最后,在服务器角色上右键单击并启用sysadmin角色,以赋予新用户所有权限。
现在你已经完成了!现在,你的 SQL Server 实例既可以由 Docker 使用,也可以由 Minikube 使用。
下一个子节解释了如何为在 Minikube 或任何其他 Kubernetes 集群上运行的应用程序配置 Visual Studio 进行调试。
使用 Bridge to Kubernetes 启用 Kubernetes 应用程序调试
由于在 Kubernetes 上运行的微服务没有固定的 IP 地址和端口,只有 Kubernetes 在运行时解决的虚拟地址,我们无法直接将 Visual Studio 调试器附加到任何正在运行的微服务。这就是为什么我们需要像 Bridge to Kubernetes 这样的软件,它与 Kubernetes API 交互以启用调试。
Kubernetes 桥接器是一个易于安装的 Visual Studio 扩展,但它要求在你的开发机器上安装 Kubectl,这带来了一定的挑战,因为 Kubectl 没有直接的 Windows 安装程序。在本小节中,我们将指导你完成安装 Bridge to Kubernetes 和 Kubectl 的过程,克服后者缺乏直接 Windows 安装程序的问题。
Kubernetes 桥接器通过 Kubectl 与 Kubernetes API 交互,从而实现 Kubernetes 应用程序的调试。然而,它不是一个调试驱动程序或调试扩展。它做的是一项完全不同的工作;它要求你选择在 Kubernetes 集群中运行的服务,并将与此服务的所有通信重定向到本地运行的 Visual Studio POD 副本,而不是实际的集群 POD。
图 22.6:Kubernetes 桥接器的工作原理
因此,开发者调试的是 POD 代码的本地副本,但与原始 POD 完全相同的动态 Kubernetes 环境中。这样,你就有了一个通常本地调试会话提供的所有设施,同时在你调试时,你的代码与你需要修复的实际 Kubernetes 集群进行交互。
Kubernetes 桥接器不仅与 Minikube 一起工作;它与任何 Kubernetes 集群一起工作。因此,你可以用它来在开发机器上调试整个应用程序,也可以用于调试预发布应用程序或生产应用程序。
由于你只调试本地代码而不是已部署的代码,因此你不必在调试模式下编译应用程序来调试它。你可以部署应用程序,并使用你想要的任何编译优化,无需关心可能的调试需求;只需确保你想要调试的 POD 的本地副本已以调试模式编译即可。
你将在“Kubernetes 应用程序的远程调试”部分学习如何在实际中使用 Kubernetes 桥接器。本节的其余部分将解释安装 Bridge to Kubernetes 到开发机器上所需的所有步骤。
首先,你需要安装 Kubectl。最简单的方法是使用 Chocolatey 包管理器。
Chocolatey 是一个类似于 NuGet 的包管理器。同样,它由一个包含所有包的公共仓库和一个你必须安装到你的机器上的客户端组成,以便与公共仓库交互。
如果你还没有安装 Chocolatey,你可以从 PowerShell 提示符安装它,如下所示:
-
在 Windows 搜索框中搜索PowerShell。
-
右键单击 PowerShell 链接,并选择以管理员身份执行。
-
最后,执行官方 Chocolatey 页面上的 PowerShell 命令:
chocolatey.org/install#individual
。
以下是为你的方便而重复的 PowerShell 命令:
Set-ExecutionPolicy Bypass -Scope Process -Force; [System.Net.ServicePointManager]::SecurityProtocol = [System.Net.ServicePointManager]::SecurityProtocol -bor 3072; iex ((New-Object System.Net.WebClient).DownloadString('https://community.chocolatey.org/install.ps1'))
安装完成后,运行 choco -?
以验证安装成功并且 Chocolatey 用户界面工作正常。
安装 Chocolatey 后,安装 Kubectl 非常简单;只需以管理员身份打开 Windows 命令提示符,并输入以下内容:
choco install kubernetes-cli
你可以通过输入 kubectl version –client
来检查一切是否正常工作。
Kubectl 应配置为访问特定集群,但当你使用 minikube start
启动 Minikube 时,Minikube 会自动将其配置为访问本地 Minikube 集群,因此你不需要担心 Kubectl 的配置。
现在,你可以按照以下步骤安装 Bridge to Kubernetes:
-
打开 Visual Studio 并选择 扩展 -> 管理扩展。
-
搜索
Bridge to Kubernetes
。 -
选择它并安装。
现在就到这里!现在,你的开发机器已经准备好进行 .NET Kubernetes 开发了。下一节将详细介绍开发过程,并解释如何修改现有项目以同时使用本地 Docker 安装和任何 Kubernetes 集群。
组织开发过程
由于 Visual Studio 和其他 IDE 对 Docker 提供良好的支持,并与 Docker Desktop 有很好的集成,因此在大多数开发时间内,最佳选项是仅使用 Docker 化的镜像进行工作,而不在 Minicube 内运行它们。
事实上,正如我们很快就会看到的,一旦我们将 Docker 支持添加到我们的项目中,点击运行 Visual Studio 按钮就足够了,这样就可以启动所有 Docker 化的微服务,并使它们能够通过 Docker 网络进行通信。相反,在 Minikube 中运行我们的应用程序需要几个手动步骤,并且需要一些时间来在 Minikube 上加载 Docker 镜像以及创建所有必要的 Kubernetes 对象。
在 Visual Studio 中这样做非常简单。只需为你的解决方案中的所有微服务项目添加 Docker 支持,并在解决方案运行时选择同时启动多个项目的选项。然后,当你的解决方案运行时,Visual Studio 将自动执行所有必要的任务,即:
-
编译和链接所有代码。
-
构建所有微服务的 Docker 镜像。
-
将 Docker 镜像插入 Docker Desktop 本地仓库。
-
同时启动所有 Docker 镜像。
-
将调试器附加到所有启动的 Docker 镜像。
你只需要通过定义一个 Docker Desktop 虚拟网络来关注微服务之间的通信。
我们将在下一小节中通过一个简单的示例解释开发过程的全部细节。
重新审视 gRPC 工作微服务
在与 第十四章,使用 .NET 实现微服务 和在第二十一章,案例研究 中描述的代码中,有一个名为 GrpcMicroService
的解决方案。该解决方案由两个微服务组成。第一个微服务通过生成随机数据来模拟购买,而第二个微服务使用这些数据来计算存储在数据库中的统计数据。整个代码在书籍相关的 GitHub 仓库的 ch15->GrpcMicroService
文件夹中可用。
让我们复制整个 GrpcMicroService
文件夹,并将其命名为 GrpcMicroServiceDocker
。
下面的步骤描述了需要对 Docker 进行所有必要的修改,以启用所有微服务。
将 Docker 支持添加到 GrpcMicroServiceDocker
在 Visual Studio 中打开 GrpcMicroServiceDocker
解决方案。该解决方案包含两个微服务,分别称为 FakeSource
和 GrpcMicroservice
。最后一个项目只是 GrpcMicroservice
项目的数据层。
图 22.7:GrpcMicroServiceDocker 解决方案
解决方案已经配置为在运行时启动两个微服务。在其他情况下,你可能需要通过右键单击解决方案节点并选择 设置启动项目… 来配置多个项目启动。
将 Docker 支持添加到两个微服务中非常简单。在 Visual Studio 中右键单击每个微服务项目。导航到 添加,然后选择 Docker 支持。如果提示,选择你的 Docker 环境的操作系统。如果你使用 Minikube,你必须选择 Linux。
所有必要的 Docker 文件都由 Visual Studio 自动创建和配置。就这样了!
现在,我们需要将数据库移动到新安装的 SQL Server 实例。
将 GrpcMicroServiceDocker 移动到 SQL Server Express
你需要更改所有连接字符串并配置将在运行时使用的字符串,以便它可以在 Docker 镜像内部使用。
首先,让我们更改 GrpcMicroServiceStore-> LibraryDesignTimeDbContextFactory.cs
中面的连接字符串。新字符串应该类似于以下内容:
@"Server=<your machine name>\<your instance name>;Database=grpcmicroservice;Trusted_Connection=True;Trust Server Certificate=True;MultipleActiveResultSets=true"
其中实例名称应该是 SQLEXPRESS
。你可以直接从 Visual Studio 中获取上述连接字符串,如下所示:
-
打开 SQL Server 对象资源管理器窗口。
-
右键单击 SQL Server 节点并选择 添加 SQL Server。
-
在打开的窗口中,Visual Studio 应该列出所有可用的 SQL Server 实例。选择新安装的 SQL Server 实例。
-
选择 Windows 身份验证并连接。
-
在 SQL Server 节点下方应该出现一个新的服务器图标。选择它。
-
在 Visual Studio 属性选项卡中,你应该能看到所有数据库连接属性。取 通用-> 连接字符串 的值。
现在,你必须运行所有迁移以在新的 SQL Server 实例中重新创建数据库。像往常一样,右键单击库项目并将其定义为启动项目。然后,在 Visual Studio 包管理器控制台 默认项目 中,选择 GrpcMicroServiceStore
并发出 Update-Database
命令。
新数据库创建后,将两个微服务作为同时启动项目恢复。
最后,更新 GrpcMicroService -> appsettings.json
中的运行时连接字符串。如果新安装的 SQL Server 实例已被定义为机器上的默认实例,以下连接字符串应该可以工作:
Server=host. Docker.internal;Database=grpcmicroservice;User Id=<your user name>;Password=<your user password>;Trust Server Certificate=True;MultipleActiveResultSets=true"
其中 host.docker.internal
是 Docker Desktop 镜像用于与主机通信的 URL。如果你的 SQL Server 不是机器的默认实例,你必须将 host.docker.internal
替换为 host.docker.internal\<your instance name>
。
如果,相反,你正在使用外部数据库,你可以使用其标准的连接字符串,无需修改。
使用 Docker 虚拟网络启用微服务之间的通信
在 Docker Desktop 中创建 Docker 虚拟网络非常简单;只需打开 Windows 控制台并运行以下命令:
docker network create test-net,
其中 test-net
是虚拟网络名称。一旦我们在从镜像创建容器实例时定义了网络,我们就可以指定启动的容器必须连接到我们的网络及其主机名,如下所示:
docker run --rm --net test-net --name grpcmicroservice <microservice image name>,
在这里,rm
选项指定容器停止运行时必须被销毁,--net test-net
指定连接创建容器的网络,而 -name grpcmicroservice
是创建的容器名称,它也将作为其在网络中的主机名。
我们需要将 test-net
虚拟网络中添加的容器仅限于必须作为服务器的容器——在我们的例子中,是 GrpcMicroService
微服务。
由于 Visual Studio 在启动解决方案时自动发出所有必要的 run
Docker 命令,我们只需要指定要添加到 Visual Studio 原始命令的选项。它们必须在每个微服务项目文件中使用 DockerfileRunArguments
参数进行指定。以下是如何修改 GrpcMicroService
微服务项目文件的方法,这是唯一作为服务器运行的微服务:
<PropertyGroup>
<TargetFramework>net8.0</TargetFramework>
<Nullable>enable</Nullable>
<UserSecretsId>b4f03ff2-033c-4d5e-a33b-65f26786b052</UserSecretsId>
<DockerDefaultTargetOS>Linux</DockerDefaultTargetOS>
**<****DockerfileRunArguments****>**
**--rm --net test-net --name grpcmicroservice**
**</****DockerfileRunArguments****>**
</PropertyGroup>
对于 FakeSource
项目不需要进行修改,因为它必须不作为服务器运行。
现在,grpcmicroservice
主机名必须由 FakeSource
用于与 GrpcMicroService
微服务进行通信。
因此,我们必须将 FakeSource->Worker.cs
文件中的 URL 替换为 http://grpcmicroservice:8080
,如下面的代码片段所示:
…
using var channel = GrpcChannel.ForAddress("http://grpcmicroservice:8080");
var client = new Counter.CounterClient(channel);
…
其中我们使用 8080
默认的 Kestrel http
端口与微服务进行通信。因此,我们需要在 GrpcMicroService -> Program.cs
中的 Kestrel 选项中强制 Kestrel 监听 5000
端口,通过替换下面的代码:
builder.WebHost.ConfigureKestrel(options =>
{
options.ListenLocalhost(5000, o =>
o.Protocols = Microsoft.AspNetCore.Server.Kestrel.Core.HttpProtocols.Http2);
});
现在,我们已经准备好运行我们的项目。为了确保两个微服务都使用 Docker 启动,请将每个微服务作为单个启动项目选中,然后在下图所示的运行解决方案 Visual Studio 按钮旁边的选择框中选择 Docker,如图所示:
图 22.8:选择 Docker 执行
之后,你可以恢复同时启动两个微服务。Visual Studio 将使用 Docker 启动它们。
现在,我们可以启动解决方案。为了验证服务器是否正确接收购买信息,在下面的 if
块中 GrpcMicroService->HostedServices-> ProcessPurchases.cs
文件内放置一个断点:
if (toProcess.Count > 0)
{
…
}
事实上,GrpcMicroService
只有在输入队列中找到某些内容时才会进入那个块。
你还可以检查 dbo.Purchases
数据库表的 内容,以验证它是否填充了购买统计信息。你可以通过在 SQL Server Object Explorer 中右键单击表并选择 查看数据 来完成它。
在理解了如何使用 Docker 网络测试我们的应用程序之后,我们现在必须了解何时以及如何使用 Minikube 进行测试。
何时使用 Minikube 测试应用程序
在应用程序开发中涉及的调试-修复周期的大部分工作都可以使用 Docker 虚拟网络完成。
Docker 网络通常运行良好,不会出现问题。因此,如果你遇到通信问题,它们可能是因为拼写错误的服务 URL。因此,请仔细检查所有调用微服务的 URL,这些微服务没有收到通信。
有时,我们需要以下原因使用 Minikube 测试应用程序:
-
无论是 ReplicaSets 还是 StatefulSets,都可以使用 Docker 和 Visual Studio 进行测试,但我们每个都限于一个 POD。
-
我们还必须测试
.yaml
Kubernetes 配置文件,该文件可能包含更复杂的对象,如入口、持久存储、机密和其他复杂配置。 -
你可能需要将你的微服务与其他团队开发的模块集成。
因此,每个开发者应该花大部分时间测试几个相互之间有强烈交互的微服务,但有时,他们应该尝试与 Minikube 进行更广泛的集成。这可以在工作日结束时提交代码之前,或者在敏捷应用程序开发过程的开发迭代即将结束时进行。
在学习了如何使用 Minikube 测试应用程序的时间和方式之后,我们必须学习如何在 Minikube 上加载和运行我们的应用程序。
在 Minikube 上运行你的应用程序
当 Visual Studio 使用 Docker 运行您的微服务时,它创建包含 Visual Studio 调试器所需信息的特殊图像,并具有 dev
版本名称。这些特殊图像只能从 Visual Studio 运行,如果您尝试手动启动它们,将会出错。出于同样的原因,您不能在 Minikube 中使用它们。
因此,在 Minikube 中运行您的微服务的第一步是创建不同的“标准”图像。您可以通过在 Visual Studio 解决方案资源管理器中右键单击 FakeSource
和 GrpcMicroService
Docker 文件,并选择构建 Docker 图像来完成此操作。
这样,您将创建一个 grpcmicroservice
和一个 fakesource
图像,它们都具有 latest
版本名称,如下面的图像所示:
图 22.9:创建 Minikube 准备好的 Docker 图像
作为下一步,您必须启动 Minikube:
minikube start
现在,您必须使用以下命令在 Minikube 图像缓存中加载您的 Docker 图像:
minikube image load fakesource:latest
minikube image load grpcmicroservice:latest
您可以通过列出 Minikube 中加载的所有图像来验证您的图像是否已正确加载:
minikube image ls
现在,我们需要定义一个包含两个部署和一个将通信转发到作为唯一服务器微服务的 grpcmicroservice
的 .yaml
Kubernetes 配置文件。让我们称它为 minikubedeploy.yaml
。
grpcmicroservice
部署的定义很简单:
apiVersion: apps/v1
kind: Deployment
metadata:
name: grpcmicroservice
labels:
app: statistics
spec:
selector:
matchLabels:
app: statistics
role: worker
replicas: 1
template:
metadata:
labels:
app: statistics
role: worker
spec:
containers:
- name: grpcmicroservice
image: grpcmicroservice:latest
imagePullPolicy: Never
resources:
requests:
cpu: 10m
memory: 10Mi
env:
- name: ASPNETCORE_HTTP_PORTS
value: "8080"
ports:
- containerPort: 8080
name: http
上面的代码只需要一个副本,但您可以尝试使用两个或三个副本。ASPNETCORE_HTTP_PORTS
环境变量是一个标准的 ASP.NET 设置,它通知 Kestrel 在哪个 HTTP 端口上监听。
imagePullPolicy: Never
设置指定了 Kubernetes 集群内的图像缓存策略。它阻止 Minikube 尝试从原始源下载图像的新版本到其缓存中。我们需要此设置,因为我们没有包含我们的图像的“原始源”,因为我们直接使用 minikube image load
命令将图像上传到 Minikube 缓存。
当共享图像库中没有图像,但图像直接从 Docker Desktop 本地库上传到 Minikube 缓存时,您必须始终指定此设置。相反,共享图像不需要手动上传到 Minikube 缓存,只需在 Kubernetes .yaml
文件中使用它们的完整 URL 进行引用即可。
所有其他设置都很标准。
fakesource
部署的定义完全类似,但不包含有关容器端口的任何信息,因为此微服务不作为服务器:
apiVersion: apps/v1
kind: Deployment
metadata:
name: fakesource
labels:
app: sale
spec:
selector:
matchLabels:
app: sales
role: source
replicas: 1
template:
metadata:
labels:
app: sales
role: source
spec:
containers:
- name: fakesource
image: fakesource:latest
imagePullPolicy: Never
resources:
requests:
cpu: 10m
memory: 10Mi
将通信转发到 grpcmicroservice
的服务定义相当标准:
apiVersion: v1
kind: Service
metadata:
name: grpcmicroservice
labels:
app: contract
role: worker
spec:
ports:
- port: 8080
name: http
protocol: TCP
targetPort: 8080
selector:
app: statistics
role: worker
您必须注意的只有必须在一切设置中保持一致的端口号以及服务名称,因为它们将被用于所有通信到 grpcmicroservice
的 URL 中。
如果服务名称与 Docker 虚拟网络中的主机名匹配,URL 将在 Kubernetes 和 Docker 虚拟网络中都有效。因此,你不需要修改任何代码或配置来适应在 Docker 虚拟网络中运行的代码以适应 Minikube 或任何其他 Kubernetes 集群。
整个 minkubedeploy.yaml
文件可在与本书相关的 GitHub 仓库的 ch22
文件夹中找到。
现在,让我们在包含 minkubedeploy.yaml
文件的文件夹中打开一个 Windows 命令提示符,执行以下命令,将在 Minikube 集群中加载应用程序配置:
kubectl create -f minkubedeploy.yaml
然后,执行 kubectl get deployment
命令以验证所有部署是否已正确定义并正在运行。
你可以通过检查 dbo.Purchases
数据库表中的数据来验证应用程序是否正常运行,通过在 SQL Server 对象资源管理器中右键单击 dbo.Purchases
表并选择 查看数据:
图 22.10:dbo.Purchases 表
每次你点击表格刷新按钮时,你应该会看到数据库表中添加了新行。如果经过几次刷新后新行仍未出现,你的微服务可能遇到了一些通信问题,或者数据计算之前抛出了某些异常。
你可以通过调试应用程序来发现问题。下一节将解释如何借助 Bridge 到 Kubernetes 详细验证应用程序中发生的情况。请勿删除使用 minkubedeploy.yaml
创建的所有 Kubernetes 对象,因为我们需要运行中的应用程序来附加 Bridge 到 Kubernetes。
远程调试 Kubernetes 应用程序
作为最后一步,我们将使用 Bridge 到 Kubernetes 调试 GrpcMicroService
。让我们将 GrpcMicroService
设置为起始项目,并将项目启动从 Docker 更改为 Bridge 到 Kubernetes,如图所示:
图 22.11:使用 Bridge 到 Kubernetes 调试 GrpcMicroService
让我们在 GrpcMicroService->HostedServices-> ProcessPurchases.cs
文件中的 if
块内放置一个断点,如下所示:
if (toProcess.Count > 0)
{
…
}
然后,开始调试。一旦你点击运行按钮,就会弹出一个窗口,提示你配置 Bridge 到 Kubernetes:
图 22.12:配置 Bridge 到 Kubernetes
如果上面的窗口没有打开,或者你看不到任何 Minikube 节点,Kubectl
可能未为 Minikube 正常工作或配置。尝试执行一个 Kubectl
命令,如 kubectl get all
。如果你遇到任何问题,尝试使用 minikube stop
停止 Minikube,然后使用 minikube start
重新启动。
Bridge to Kubernetes 提示我们选择一个命名空间——在我们的例子中是default
——然后在该命名空间中选择一个特定的服务——在我们的例子中是grpmicroservice
。所有对该服务的通信都将转发到运行在我们开发机器上的GrpcMicroService
代码。让我们设置配置窗口,如上图所示。一旦你提交 Bridge to Kubernetes 的配置,调试将自动开始。在短时间内,断点将被触发,我们本地微服务的副本将开始与在 Minikube 中运行的其余代码进行交互!
在完成调试后,请将项目启动恢复到Docker,并恢复两个微服务的同时启动,以便你可以在 Docker 虚拟网络上继续工作。
在完成与 Minikube 的工作后,你需要使用以下命令删除由minkubedeploy.yaml
创建的所有对象:
kubectl delete -f minkubedeploy.yaml
重要的是,一旦不再需要资源,就应立即释放它们;否则,它们将继续浪费 CPU 时间和内存,如果你不断添加更多应用程序,迟早会在你的开发机器上遇到性能问题。
如果你想要释放 Minikube 磁盘空间,你也可以使用以下命令删除之前加载到 Minikube 缓存中的微服务镜像:
minikube image rm fakesource:latest
minikube image rm grpcmicroservice:latest
最后,你需要使用以下命令停止 Minikube:
minikube stop
摘要
在本章中,我们解释了如何为.NET Kubernetes 开发准备开发工作站以及如何组织代码测试和错误修复周期。
我们还解释了如何定义 Docker 虚拟网络以确保开发期间微服务之间的通信,以及主机名和 Kubernetes 服务的命名约定,使得相同的代码可以在 Docker 虚拟网络、Minikube 以及任何其他 Kubernetes 集群上运行。
最后,我们解释了在 Minikube 中运行应用程序所需的全部步骤以及如何使用 Bridge to Kubernetes 进行测试。
我们现在已经完成了在这本书中的旅程,这是一段多么精彩的旅程啊!
这本书充满了许多新颖和具有挑战性的想法,它必定会成为你在软件架构师旅程中的良师益友。
这些学习将不仅赋予你创造创新解决方案的能力,还将支持你在软件项目动态世界中的成长。我们真诚地希望,你享受这次冒险的程度与我们为你创建最新版本的程度一样。
问题
-
为什么 Visual Studio 附带的 SQL Server 安装不能用于 Kubernetes 开发?
-
什么是 Bridge to Kubernetes?
-
Bridge to Kubernetes 是否仅与 Minikube 一起工作?
-
你如何加载 Minikube 镜像缓存?
-
我们如何将 Minikube 定义为 Kubectl 默认集群?
进一步阅读
本章中的大部分参考资料与之前在 第十一章,将微服务架构应用于您的企业应用 和 第十四章,使用 .NET 实现微服务 中列出的相同。在此,值得添加关于 Kubernetes 之桥的官方文档链接:learn.microsoft.com/en-us/visualstudio/bridge/
在 Discord 上了解更多
要加入这本书的 Discord 社区——在那里您可以分享反馈、向作者提问,并了解新书发布——请扫描下面的二维码:
packt.link/SoftwareArchitectureCSharp12Dotnet8
第二十三章:答案
第一章
-
软件架构师需要了解任何可以帮助他们更快解决问题的技术,并确保他们能够创建更高品质的软件。
-
Azure 通过提供可伸缩的云基础设施、多样化的开发工具和服务,帮助软件架构师高效地构建、部署和管理企业应用程序,同时提供强大的安全性和合规性支持。
-
最佳的软件开发流程模型取决于您拥有的项目类型、团队和预算。作为软件架构师,您需要考虑所有这些变量,并理解不同的流程模型,以便能够满足环境的需要。
-
软件架构师会关注任何可能影响性能、安全性、可用性、系统架构、结构、组织等方面的用户或系统需求。
-
软件架构师应验证功能性和非功能性需求,特别关注需求规范中的系统架构、性能、可伸缩性、安全性、可维护性和兼容性。
-
设计思维和设计冲刺帮助软件架构师深入理解用户需求,并快速原型设计解决方案,确保收集到的需求与用户期望和项目目标紧密一致。
-
当我们想要定义功能需求时,用户故事是好的。它们可以快速编写,通常不仅提供所需的功能,还提供解决方案的验收标准。
-
开发高性能软件的有效技术包括优化代码、使用高效算法、利用并行处理和实施有效的内存管理。
-
为了检查实现是否正确,软件架构师将其与已设计和验证的模型和原型进行比较。
第二章
-
纵向和横向。
-
是的,您可以通过 Visual Studio 自动部署到已定义的 Web 应用程序,或者直接创建一个新的。
-
通过最小化它们闲置的时间来利用可用的硬件资源。
-
代码行为是确定的,因此易于调试。执行流程模仿顺序代码的流程,这意味着它更容易设计和理解。
-
因为正确的顺序可以最小化填写表格所需的手势数量。
-
因为它允许以独立于操作系统的方式操作路径文件。
-
它可以与多个 .NET Core 版本以及多个版本的经典 .NET 框架一起使用。
-
控制台、.NET Core、.NET (5+) 和 .NET Standard 类库;ASP.NET Core、测试项目、微服务以及更多。
第三章
-
不,它适用于多个平台。
-
自动、手动和负载测试计划。
-
是的,他们可以通过 Azure DevOps 源来做到。
-
为了管理需求和组织整个开发过程。
-
史诗级工作项代表由多个功能组成的更高级别的系统子部分。
-
一种父子关系。
-
他们可以使用我们在 Azure DevOps 中拥有的相同概念来组织项目。
-
这两种选项都是运行敏捷项目的优秀工具。最佳选项取决于你团队的经验。
第四章
-
可维护性为你提供了快速交付你设计的软件的机会。它还允许你轻松修复错误。
-
圈复杂度是基于控制流图的代码复杂度度量,它检测方法中的节点数量。数字越高,影响越差。
-
版本控制系统将保证你的源代码的完整性,为你提供分析每个修改历史的机会。它提供了分支代码开发、合并不同分支以及更多可能性的可能性。
-
垃圾回收器是 .NET Core、.NET (5+) 和 .NET Framework 中的一种系统,它监视你的应用程序并检测你不再使用的对象。它销毁这些对象以释放内存。
-
IDisposable
接口之所以重要,首先是因为它是一个确定清理的好模式。其次,它是在实例化需要由程序员销毁的对象的类中必需的,因为垃圾回收器无法销毁它们。 -
.NET 8 以一种可以保证更安全、更可靠的代码的方式,在其某些库中封装了一些关键设计模式,例如依赖注入和构建器模式。
-
编写良好的代码是任何熟练掌握该编程语言的人都可以处理、修改和演化的代码。
-
Roslyn 是用于在 Visual Studio 内部进行代码分析的 .NET 编译器。
-
代码分析是一种考虑代码编写方式的实践,旨在在编译前检测不良做法。
-
代码分析可以发现即使在表面上看起来很好的软件中也会出现的问题,例如内存泄漏和不良编程实践。
-
Roslyn 是一个提供 API 的引擎,该 API 使分析器能够检查你的代码的风格、质量、可维护性、设计和其他问题。这是在设计时间完成的,因此你可以在编译代码之前检查错误。
-
Visual Studio 扩展是在 Visual Studio 内部运行的编程工具。这些工具在某些情况下可以帮助你,因为 Visual Studio IDE 没有为你提供相应的功能。
-
SonarLint
和SonarAnalyzer.CSharp
NuGet 包。
第五章
-
复制粘贴是一种不充分的代码重用形式,因为它会导致代码重复,使维护和更新更加困难。实际上,没有安全的方法可以发现所有重复项,然后修改它们就非常困难。
-
代码重用的最佳方法包括创建库、使用泛型、面向对象继承等。
-
是的。你可以在你之前创建的库中找到已经创建的组件,然后通过创建可以在未来重用的新组件来增加这些库。
-
.NET 标准是一个允许在不同.NET 框架之间实现兼容性的规范,从.NET 框架到 Unity。.NET Core 是.NET 的一种实现,并且是开源的。
-
通过创建.NET 标准库,你将能够在不同的.NET 实现中使用它,例如.NET Core、.NET、.NET 框架和 Xamarin。
-
你可以使用面向对象的原则(例如,继承、封装、抽象和多态)来启用代码重用。
-
泛型是一种复杂的实现,通过定义一个占位符来简化具有相同特性的对象的处理,该占位符将在编译时被具体类型替换。
-
这个问题的答案在.NET 博客上由 Immo Landwerth 进行了很好的解释:
devblogs.microsoft.com/dotnet/the-future-of-net-standard/
。基本的回答是,.NET 版本 5 及以上需要被视为未来共享代码的基础。 -
与重构相关的挑战包括确保更改不会引入错误,维护代码的功能和性能,以及在不改变软件组件的外部行为的情况下提高可读性和可维护性。
第六章
-
设计模式是解决软件开发中常见问题的良好解决方案。
-
虽然设计模式为你提供了我们在开发中遇到的典型问题的代码实现,但设计原则有助于你在实现软件架构时选择最佳选项。
-
建造者模式将帮助你生成复杂的对象,而无需在你要使用它们的类中定义它们。
-
工厂模式在存在多种来自同一抽象的物体,并且不知道在编译时需要创建哪一个的情况下非常有用。
-
单例模式在软件执行期间需要只有一个实例的类时非常有用。
-
代理模式在需要提供一个控制对另一个对象访问的对象时使用。
-
命令模式在需要执行将影响对象行为的命令时使用。
-
发布/订阅模式在需要向一组其他对象提供有关对象的信息时非常有用。
-
依赖注入模式如果想要实现控制反转原则非常有用。你不需要创建组件所依赖的对象的实例,只需定义它们的依赖关系,声明它们的接口,并启用通过注入接收对象的功能。通常,你可以通过使用类的构造函数接收对象,标记一些类属性以接收对象,或者定义一个带有注入所有必要组件的方法的接口来实现这一点。
第七章
-
专家使用的语言的变化以及词语含义的变化。
-
上下文映射是协调单独边界上下文开发的主要工具,有助于定义和管理不同上下文之间的交互。
-
不;整个通信都通过实体进行,即聚合根。
-
在部分-子部分层次结构中有一个单一的聚合根,以确保聚合中所有元素的一致性并强制执行规则,作为外部交互的主要入口点。
-
只有一个,因为存储库是聚合中心化的。
-
应用层操作存储库接口。存储库实现注册在依赖注入引擎中。
-
为了协调单个事务中多个聚合的操作。
-
更新和查询的规范通常相当不同,尤其是在简单的 CRUD 系统中。其最强形式的原因主要是查询响应时间的优化。
-
依赖注入。
-
不;必须进行严重的影响分析,以便我们可以采用它。
第八章
-
DevOps 是一种持续向最终用户提供价值的做法。为了成功实现这一点,必须进行持续集成、持续交付和持续反馈。
-
持续集成 (CI) 允许你在每次提交更改时检查你交付的软件的质量。你可以在 Azure DevOps 中通过启用此功能来实现这一点。
-
持续交付 (CD) 允许你在确信所有质量检查都通过了你设计的测试后部署解决方案。Azure DevOps 通过提供相关工具来帮助你实现这一点。
-
是的,你可以单独拥有 DevOps,然后稍后启用 CI。你还可以在 CD 未启用的情况下启用 CI。你的团队和流程需要准备好并关注这一点才能实现。
-
你可能会将 CI 与 CD 流程混淆,这可能会对你的生产环境造成损害。在最坏的情况下,例如,一个尚未准备好的功能可能会被部署,导致在客户不便的时间造成中断,或者你甚至可能因为错误的修复而遭受不良的副作用。
-
当完全自动化的构建和部署管道到位时,多阶段环境可以保护生产环境免受不良发布的影响。
-
自动化测试可以预测预览场景中的错误和不良行为。
-
拉取请求允许在主分支提交之前进行代码审查。
-
不;拉取请求可以帮助你在任何具有 Git 作为源代码控制的发展方法中。
-
持续反馈是采用 DevOps 生命周期中的工具,以便在性能、可用性以及你正在开发的应用程序的其它方面快速获得反馈。
-
构建管道将允许你运行任务来构建和测试你的应用程序,而发布管道将为你提供定义应用程序在每个场景中如何部署的机会。
-
Application Insights 是一个有助于监控已部署系统健康状况的有用工具,这使得它成为一个出色的持续反馈工具。
-
测试和反馈是一个允许利益相关者分析你正在开发的软件的工具,并允许与 Azure DevOps 连接以打开任务甚至错误。
-
服务设计思维的主要目标是通过对服务在满足用户需求方面的可用性、效率和效果进行优化,从而提升用户体验和满意度。
-
Azure DevOps 是一个在软件开发中可以自动化整个应用程序生命周期的工具。然而,许多软件架构师倾向于同时使用 GitHub 来实现这一点,而且微软在过去几年中对该平台进行了大量开发。
第九章
-
因为大多数测试在软件发生任何更改后都必须重新进行。
-
因为在单元测试及其相关应用程序代码中发生相同错误的概率非常低。
-
当测试方法定义了多个测试时使用
[理论]
,而当测试方法只定义了一个测试时使用[事实]
。 -
断言。
-
Setup
、Returns
和ReturnsAsync
。 -
是的,使用
ReturnAsync
。 -
不;这取决于用户界面的复杂性和其变更的频率。
-
ASP.NET Core 管道不会执行,但输入会直接传递到控制器。
-
使用
Microsoft.AspNetCore.Mvc.Testing
NuGet 包。 -
使用
AngleSharp
NuGet 包。
第十章
-
当你从本地解决方案迁移或拥有基础设施团队时,IaaS 是一个好选项。
-
PaaS 是在团队专注于软件开发时,快速和安全交付软件的最佳选项。
-
如果你打算交付的解决方案由知名玩家提供,例如 SaaS,你应该考虑使用它。
-
当你构建一个新系统,且没有专门从事基础设施的人员,或者你不想担心其扩展性时,无服务器架构是一个选项。
-
Azure SQL 数据库可以在几分钟内启动,之后你将拥有 Microsoft SQL Server 的全部功能。此外,Microsoft 将处理数据库服务器基础设施。
-
Azure 提供了一套称为 Azure 认知服务的服务。这些服务提供视觉、语音、语言、搜索和知识方面的解决方案。
-
在混合场景中,你有灵活性来决定系统每个部分的最佳解决方案,同时尊重未来解决方案的开发路径。
第十一章
-
代码模块化和部署模块化。
-
不。其他重要优势包括很好地处理开发团队和整个 CI/CD 循环,以及轻松有效地混合异构技术的可能性。
-
一个帮助我们实现弹性通信的库。
-
一旦你在开发机器上安装了 Docker,你就可以开发、调试和部署 Docker 化的 .NET 应用程序。你还可以将 Docker 镜像添加到由 Visual Studio 处理的 Service Fabric 应用程序中。
-
Orchestrators 是管理微服务和微服务集群中节点的软件。Azure 支持两个相关的 Orchestrators:Azure Kubernetes Service 和 Azure Service Fabric。
-
因为它解耦了通信中发生的参与者。
-
一个消息代理。它负责服务之间的通信和事件。
-
由于发送者在超时之前没有收到接收确认,因此可能会多次接收到相同的信息。因此,接收一条信息一次或多次的效果必须相同。
第十二章
-
Redis 是一种基于键值对的分布式内存存储,支持分布式排队。它最著名的用途是分布式缓存,但它可以用作关系数据库的替代品,因为它能够将数据持久化到磁盘。
-
是的,它们是这样的。本章的大部分内容都是致力于解释为什么。
-
写操作。
-
NoSQL 数据库的主要弱点是它们的一致性和事务,而它们的主要优势是性能,尤其是在处理分布式写入时。
-
最终一致性、一致性前缀、会话、有界不一致性和强一致性。
-
不,它们在分布式环境中效率不高。基于 GUID 的字符串表现更好,因为它们的唯一性是自动的,不需要同步操作。
-
OwnsMany
和OwnsOne
。 -
是的,它们可以。一旦你使用了
SelectMany
,索引就可以用来搜索嵌套对象。
第十三章
-
通过数据库依赖提供者。
-
要么通过将它们命名为
Id
,要么通过使用Key
属性来装饰它们。这也可以通过流畅配置方法来完成。 -
使用
MaxLength
和MinLength
属性,或者使用它们的等效流畅配置方法。 -
类似于
builder.Entity<Package>().HasIndex(m => m.Name);
。 -
类似以下内容:
builder.Entity<Destination>() .HasMany(m => m.Packages) .WithOne(m => m.MyDestination) .HasForeignKey(m => m.DestinationId) .OnDelete(DeleteBehavior.Cascade);
-
在包管理器控制台中的
Add-Migration
和Update-Database
,或者在操作系统控制台中的dotnet ef migrations add
和dotnet ef database update
。 -
不,但你可以通过使用
Include
LINQ
子句或配置你的DbContext
时使用UseLazyLoadingProxies
选项强制包含它们。使用 Include 时,相关实体与主实体一起加载,而使用UseLazyLoadingProxies
时,相关实体是延迟加载的;也就是说,它们在需要时才加载。 -
是的,它是,多亏了
Select LINQ
子句。 -
通过调用
context.Database.Migrate()
。
第十四章
-
因为使用队列是避免耗时阻塞调用的唯一方法。
-
使用
import
声明。 -
使用标准的
Duration
消息。 -
版本兼容性和互操作性,同时保持良好的性能。
-
更好的水平可伸缩性,以及支持发布/订阅模式。
-
有两个原因。操作非常快,并且在通信路径的第一个队列中的插入必须是必要的阻塞操作。至少需要一个阻塞操作来将消息存储在某种永久存储(通常是队列)中,以便在持续处理可能因某些原因失败时可以恢复。
-
使用以下 XML 代码:
<ItemGroup> <Protobuf Include="Protos\file1.proto" GrpcServices="Server/Client" /> <Protobuf Include="Protos\file2.proto" GrpcServices="Server/Client" /> ... </ItemGroup>
其中Protos\file1.proto
和Protos\file2.proto
必须替换为我们项目中ProtoBuf
文件的实际路径。此外,GrpcServices
属性必须设置为"Server"
或"Client"
,具体取决于proto
文件是否描述了一个服务器。
-
使用
channel.BasicPublish(…)
。 -
使用
channel.WaitForConfirmsOrDie(timeout)
。
第十五章
-
不,因为这会违反服务对请求的反应必须依赖于请求本身,而不是之前与客户端交换的其他消息/请求的原则。
-
不,使用自定义通信协议实现服务通常不是好的实践,因为这会损害互操作性,增加开发和维护的复杂性,并将服务孤立于广泛使用的标准和工具。标准协议如 HTTP/REST、
gRPC
等因其广泛的支持、易于集成和社区支持而更受欢迎。 -
是的,它可以。POST 的主要操作必须是创建,但删除可以作为副作用执行。要使用的 HTTP 动词由 URL 中命名的虚拟表确定(在我们的例子中是一个 POST,因为操作是添加),但可以在 URL 中未提及的其他虚拟表上执行其他操作。
-
三个;它们是头部的基础 64 编码、正文的基础 64 编码和签名。
-
从请求体中。
-
ApiController
属性设置了一些默认行为,有助于实现 REST 服务。 -
ProducesResponseType
属性。 -
当使用 API 控制器时,它们通过
Route
和Http<verb>
属性进行声明。当使用最小 API 时,它们在MapGet
、MapPost
、…Map{Http verb}
的第一个参数中进行声明。 -
通过在宿主配置的依赖注入部分添加类似
builder.Services.AddHttpClient<MyProxy>()
的内容。
第十六章
-
Azure Functions 是 Azure PaaS 组件,允许您实现 FaaS 解决方案。
-
您可以使用不同的语言编写 Azure Functions,例如 C#、F#、PHP、Python 和 Node.js。您还可以使用 Azure 门户和 Visual Studio Code 创建函数。可以通过使用自定义处理程序来使用其他堆栈:
docs.microsoft.com/en-au/azure/azure-functions/functions-custom-handlers
。 -
Azure Functions 中有两种计划选项。第一种计划是消费计划,您将根据使用的量付费。第二种计划是 App Service 计划,您将与函数的需求共享 App Service 资源。
-
在 Visual Studio 中部署函数的过程与 Web 应用程序部署相同。
-
我们可以以多种方式触发 Azure Functions,例如使用 Blob 存储、Cosmos DB、Event Grid、Event Hubs、HTTP、Microsoft Graph Events、队列存储、Service Bus、定时器和 Webhooks。
-
Azure Functions v1 需要.NET Framework 引擎,而 v2 需要.NET Core 2.2,v3 需要.NET Core 3.1 和.NET 5-6。v4 是.NET 6、7 和 8 的当前版本。
-
每个 Azure 函数的执行都可以通过 Application Insights 进行监控。在这里,您可以检查处理所需的时间、资源使用情况、每个函数调用中发生的错误和异常。
-
它们是让我们能够编写有状态工作流、管理幕后状态的函数。
第十七章
-
Visual Studio 在 ASP.NET Core 项目中典型地构建的中间件模块包括开发者异常页面、请求路由、静态文件服务、HTTPS 重定向、身份验证和授权。
-
不。
-
错误。可以在同一个标签上调用多个标签助手。
-
ModelState.IsValid
. -
@RenderBody()
. -
我们可以使用
@RenderSection("Scripts", required: false)
. -
我们可以使用
return View("viewname", ViewModel)
. -
ASP.NET Core 包含三个提供程序,但它们必须进行配置。如果需要不同的提供程序,它们必须由开发人员实现并添加到提供程序列表中。
-
不,
ViewModels
不是控制器与 ASP.NET Core 中的视图通信的唯一方式。控制器还可以使用ViewData
、ViewData
和TempData
将数据传递给视图。
第十八章
-
API 网关作为 API 微服务的接口,而前端负责构建网页的 HTML。
-
强大的 Web 服务器优化整个请求/响应处理,并确保所需的安全级别。
-
因为它们对性能有影响,通常对于高流量的微服务来说是不可接受的。
-
当事务非常快且事务之间碰撞的概率很低时。
-
聚合和命令方法的更好解耦。
第十九章
-
这是一个 W3C 标准:在符合 W3C 标准的浏览器中运行的虚拟机的组装。
-
一个 Web UI,其中动态 HTML 在浏览器本身中创建。
-
根据当前浏览器 URL 选择页面。
-
一个附加了路由的 Blazor 组件。因此,Blazor 路由器可以选中它。
-
定义 Blazor 组件类的.NET 命名空间。
-
一个本地服务,负责存储和处理所有与表单相关的信息,例如验证错误和 HTML 输入的变化。
-
要么是
OnInitialized
要么是OnInitializedAsync
。 -
回调和服务。
-
Blazor 与 JavaScript 交互的方式。
-
获取组件或 HTML 元素实例的引用。
第二十章
-
需要服务来将通信调度到 Pods,因为 Pod 没有稳定的 IP 地址。
-
Kubernetes 提供了更高层次的实体,称为 Ingress,它建立在服务之上,赋予集群所有由 Web 服务器提供的先进功能,例如将集群外部的 HTTP/HTTPS URL 路由到集群内部的内部服务 URL。
-
Helm 图表是一种组织复杂 Kubernetes 应用程序模板化和安装的方式,这些应用程序包含多个
.yaml
文件。 -
是的,使用
---
分隔符。 -
Kubernetes 通过检查容器的健康和可用性来检测容器故障,这些检查是通过存活和就绪探针完成的,确保它们按预期运行。
-
因为 Pods 没有稳定的地点,不能依赖于它们当前运行节点的存储。
-
StatefulSet
假定具有状态并通过分片实现写/更新并行性,而ReplicaSet
没有状态,因此无法区分,可以通过分割负载来实现并行性。
第二十一章
-
这是因为它不支持 TCP/IP 通信。
-
Visual Studio 推荐的工具用于调试 Kubernetes 应用程序。
-
不,它与任何已配置为本地 Kubectl 安装中默认集群的 Kubernetes 集群兼容。
-
使用类似
minikube
image
load
grpcmicroservice:latest
这样的命令。 -
只需启动 Minikube,Minikube 的启动过程就会为您完成。
在 Discord 上了解更多信息
要加入这本书的 Discord 社区——在那里您可以分享反馈、向作者提问以及了解新版本——请扫描下面的二维码:
packt.link/SoftwareArchitectureCSharp12Dotnet8