C-8-和--NET--Core3-软件架构实用指南-全-

C#8 和 .NET Core3 软件架构实用指南(全)

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

译者:飞龙

协议:CC BY-NC-SA 4.0

前言

本书涵盖了软件架构中涉及的最常见的设计模式和框架。它通过提供实际的真实场景,讨论何时以及如何使用每个模式。本书还介绍了 DevOps、微服务、持续集成和云计算等技术流程,以便你能够为你的客户提供一流的软件解决方案。

本书将帮助你理解客户希望从你这里得到的产品。它将指导你解决开发过程中可能遇到的最大问题。它还涵盖了你在基于云的环境中管理应用时需要遵循的“做”和“不做”事项。你将了解不同的架构方法,如分层架构、面向服务的架构、微服务和云架构,并了解如何将它们应用于特定的业务需求。最后,你将使用 Azure 在远程环境或云中部署代码。

本书中的所有概念都将通过实际的应用案例进行解释,其中设计原则在创建安全且稳健的应用时起到关键作用。到本书结束时,你将能够开发和交付高度可扩展的企业级应用,以满足最终客户的业务需求。

值得注意的是,本书不仅将涵盖软件架构师在开发 C#和.NET Core 解决方案时应遵循的最佳实践,还将讨论我们需要掌握的所有环境,以便根据最新的趋势开发软件产品。

本书面向的对象

本书面向的是那些渴望成为架构师或希望使用.NET 堆栈构建企业应用的工程师和高级开发者。需要具备 C#和.NET 的经验。

本书涵盖的内容

第一章,理解软件架构的重要性,解释了软件架构的基础知识。本章将为你面对客户需求,并选择合适的工具、模式和框架提供正确的思维方式。

第二章,功能和非功能需求,指导你在应用开发的初期阶段,即收集用户需求并考虑应用必须满足的所有其他约束和目标。

第三章,使用 Azure DevOps 记录需求,描述了记录需求、错误和其他关于你应用的信息的技术。虽然大多数概念是通用的,但本章重点介绍了 Azure DevOps 的使用。

第四章,决定最佳云解决方案,为您提供了云中可用的工具和资源的广泛概述,特别是关于微软 Azure 的内容。在这里,您将学习如何搜索合适的工具和资源,以及如何配置它们以满足您的需求。

第五章,将微服务架构应用于您的企业应用程序,提供了微服务和 Docker 容器的广泛概述。在这里,您将了解基于微服务的架构如何利用云提供的所有机会,您将看到如何使用微服务来实现云中的灵活性、高吞吐量和可靠性。您将学习如何使用容器和 Docker 在您的架构中混合不同的技术,以及如何使您的软件平台独立。

第六章,在 C#中与数据交互 - Entity Framework Core,详细解释了您的应用程序如何借助对象关系映射ORMs)和 Entity Framework Core 3.0 与各种存储引擎进行交互。

第七章,如何在云中选择您的数据存储,描述了云中(特别是在微软 Azure 中)可用的主要存储引擎。在这里,您将学习如何选择最佳的存储引擎以实现所需的读写并行性,以及如何配置它们。

第八章,使用 Azure Functions,描述了计算的无服务器模型以及如何在 Azure 云中使用它。在这里,您将学习如何在需要运行某些计算时分配云资源,从而只支付实际的计算时间。

第九章,设计模式和.NET Core 实现,描述了使用.NET Core 3.0 的常见软件模式。在这里,您将了解模式的重要性以及使用它们的最佳实践。

第十章,理解软件解决方案中的不同领域,描述了现代领域驱动设计软件生产方法论,如何使用它来面对需要多个知识领域的复杂应用程序,以及如何利用基于云和微服务的架构。

第十一章,在 C# 8 中实现代码复用,描述了在您的 C# .NET Core 应用程序中最大化代码复用的模式和最佳实践。

第十二章, 《使用 .NET Core 应用服务架构*》,介绍了服务架构,它使您能够将应用程序的功能作为端点暴露在互联网或私有网络上,以便用户可以通过各种类型的客户端与之交互。在这里,您将学习如何使用 ASP.NET Core 实现服务架构端点,以及如何使用现有的 OpenAPI 包来自动化文档。

第十三章, 《展示 ASP.NET Core MVC》,详细介绍了 ASP.NET Core 框架。在这里,您将学习如何根据第十章《理解软件解决方案中的不同领域》中描述的领域驱动设计原则来实现基于 Model-View-Controller(MVC)模式的 Web 应用程序。

第十四章, 《C# 8 编程的最佳实践*》,描述了在开发 .NET Core 应用程序时应该遵循的最佳实践。

第十五章, 《使用单元测试用例和 TDD 测试您的代码*》,描述了如何测试您的应用程序。在这里,您将学习如何使用 xUnit 测试 .NET Core 应用程序,并看到如何借助测试驱动设计轻松开发并维护满足您规格的代码。

第十六章, 《使用工具编写更好的代码*》,描述了评估软件质量的指标以及如何使用 Visual Studio 中包含的所有工具来测量它们。

第十七章, 《使用 Azure DevOps 部署您的应用程序*》,描述了如何自动化整个部署过程,从在源代码库中创建新版本,经过各种测试和审批步骤,到在真实生产环境中最终部署应用程序。在这里,您将学习如何使用 Azure Pipelines 自动化整个部署过程。

第十八章, 《理解 DevOps 原则*》,描述了 DevOps 软件开发和维护方法的基本原理。在这里,您将学习如何组织您应用程序的持续集成/持续交付周期。

第十九章, 《在 DevOps 中应用 CI 场景的挑战*》,补充了 DevOps 的连续集成场景描述。

第二十章,软件测试自动化,致力于自动验收测试——即验证整个应用程序版本是否符合约定规格的测试。在这里,您将学习如何使用自动化工具模拟用户操作,以及如何与 xUnit 一起使用这些工具编写验收测试。

要充分利用本书

不要忘记安装 Visual Studio Community 2019 或更高版本。

确保你理解 C# .NET 原则。

下载示例代码文件

您可以从 www.packt.com 的账户下载此书的示例代码文件。如果您在其他地方购买了此书,您可以访问 www.packtpub.com/support 并注册,以便将文件直接通过电子邮件发送给您。

您可以通过以下步骤下载代码文件:

  1. www.packt.com 登录或注册。

  2. 选择“支持”选项卡。

  3. 点击“代码下载”。

  4. 在搜索框中输入书名,并遵循屏幕上的说明。

文件下载后,请确保使用最新版本解压缩或提取文件夹,使用以下工具:

  • WinRAR/7-Zip for Windows

  • Zipeg/iZip/UnRarX for Mac

  • 7-Zip/PeaZip for Linux

书籍的代码包也托管在 GitHub 上,地址为 github.com/PacktPublishing/Hands-On-Software-Architecture-with-CSharp-8。如果代码有更新,它将在现有的 GitHub 仓库中更新。

我们还有其他来自我们丰富图书和视频目录的代码包可供选择,地址为 github.com/PacktPublishing/。查看它们!

代码实战

您可以在 bit.ly/2Old2IG 上查看代码实战视频。

下载彩色图像

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

使用的约定

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

CodeInText:表示文本中的代码单词、数据库表名、文件夹名、文件名、文件扩展名、路径名、虚拟 URL、用户输入和 Twitter 昵称。以下是一个示例:“它们在调用 sb.ToString() 获取最终结果时只复制一次。”

代码块设置如下:

[Fact]
public void Test1()
{
    var myInstanceToTest = new ClassToTest();
    Assert.Equal(5,     myInstanceToTest.MethodToTest(1));
}

粗体:表示新术语、重要单词或您在屏幕上看到的单词。例如,菜单或对话框中的单词在文本中显示如下。以下是一个示例:“在解决方案资源管理器中,您可以通过右键单击来选择发布...”。

警告或重要注意事项如下所示。

提示和技巧如下所示。

联系我们

我们始终欢迎读者的反馈。

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

勘误表:尽管我们已经尽最大努力确保内容的准确性,但错误仍然可能发生。如果您在这本书中发现了错误,我们将不胜感激,如果您能向我们报告这一情况。请访问 www.packtpub.com/support/errata,选择您的书籍,点击勘误表提交表单链接,并输入详细信息。

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

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

评论

请留下评论。一旦您阅读并使用了这本书,为何不在您购买书籍的网站上留下评论呢?潜在读者可以查看并使用您的客观意见来做出购买决定,Packt 可以了解您对我们产品的看法,我们的作者也可以看到他们对书籍的反馈。谢谢!

如需了解 Packt 的更多信息,请访问 packt.com.

第一部分:在现实世界应用中转换客户需求

本节包含本书的前三章。目的是确保你理解如何将用户需求转化为对项目成功至关重要的实际架构需求,这涉及到使用 C#和.NET Core 开发企业应用程序时涉及的各个架构方面和设计考虑。

在第一章《理解软件架构的重要性》中,我们将讨论软件架构以及与.NET Core 和 C#相关方面的意义。本章还将讨论分析软件需求以及为可扩展性和健壮性等原则进行设计的重要性。无论你决定在你的项目中采用何种软件开发周期,分析需求都将帮助你坚持项目的目标。没有这一点,你项目的成功将面临风险。本章将展示一些案例,其中对收集到的需求缺乏理解导致了项目失败。此外,在每个案例中,我们还将提供实际建议,以帮助保护你的软件免受所呈现场景的影响。

一旦你理解了收集系统需求的过程,在第二章《功能和非功能需求》中,我们将引导你思考需求对架构设计的影响。可扩展性、性能、多线程、互操作性以及其他主题将被讨论,包括它们的理论和实践。

为了完成第一部分,在第三章《使用 Azure DevOps 记录需求》中,我们将介绍 Azure DevOps,这是微软目前提供的工具,旨在实现遵循 DevOps 哲学原则的应用程序开发生命周期。它提供了各种有助于你记录和组织软件的优良特性,本章的目的就是概述这些特性。

本节包含以下章节:

  • 第一章,《理解软件架构的重要性》

  • 第二章,《功能和非功能需求》

  • 第三章,《使用 Azure DevOps 记录需求》

第一章:理解软件架构的重要性

现在,软件架构是软件行业中最热门的话题之一,毫无疑问,其重要性在未来将会增长。我们构建的解决方案越复杂、越出色,就越需要伟大的软件架构来维护它们。这就是您决定阅读这本书的原因,也是我们决定编写这本书的原因。

当然,撰写关于这个重要话题并不容易,因为它提供了许多替代技术和解决方案。本书的主要目标不仅仅是构建一个详尽无遗且永不停止的技术和解决方案列表,而且还展示如何将各种技术家族联系起来,以及它们如何在实践中影响可维护和可持续解决方案的建设。

随着用户总是需要在他们的应用程序中添加更多新功能,对如何创建实际有效的企业解决方案的关注度不断提高。此外,由于市场变化迅速,需要频繁交付应用程序版本(版本),这增加了拥有复杂软件架构和开发技术的必要性。

本章将涵盖以下主题:

  • 软件开发的历史和软件架构的定义

  • 当前成功企业使用的软件流程

  • 收集需求的过程

到本章结束时,您将能够确切地了解软件架构的使命是什么。您还将了解 Azure 是什么以及如何在平台上创建您的账户。此外,考虑到这是一个入门章节,您将获得软件流程、模型和其他技术的概述,这些技术将使您能够领导您的团队。

技术需求

本章将指导您如何在 Azure 中创建账户,因此不会提供代码。

软件架构是什么?

如果您今天在阅读这本书,您应该感谢那些决定将软件开发视为工程领域的计算机科学家。这发生在上个世纪,更具体地说,是在六十年代末,当时他们提出我们开发软件的方式与建造建筑的方式非常相似。这就是我们为什么有“软件架构”这个名字。就像建筑的设计一样,软件架构师的主要目标是确保软件应用程序得到良好的实现。但良好的实现需要设计一个优秀的解决方案。因此,在一个专业的开发项目中,您必须做以下事情:

  • 定义解决方案的客户需求。

  • 设计一个满足那些需求的优秀解决方案。

  • 实施设计的解决方案。

  • 与您的客户验证解决方案。

  • 在工作环境中交付解决方案。

软件工程将这些活动定义为软件开发生命周期。所有理论上的软件开发过程模型(瀑布、螺旋、增量、敏捷等)都与这个周期有关。无论您使用哪种模型,如果在您的项目过程中不执行之前提到的基本任务,您将无法提供可接受的软件解决方案。

设计优秀解决方案的主要观点与本书的目的完全一致。您必须理解,优秀的现实世界解决方案会带来一些基本约束:

  • 解决方案需要满足用户需求。

  • 解决方案需要按时交付。

  • 解决方案需要遵守项目预算。

  • 解决方案需要提供高质量。

  • 解决方案需要保证安全和有效的未来演变。

优秀的解决方案需要可持续,您必须理解,没有优秀的软件架构就没有可持续的软件。如今,优秀的软件架构既依赖于工具也依赖于环境,以完美地满足用户需求。为了解释这一点,本书将使用微软提供的一些优秀工具:

  • Azure:这是微软的云平台,在这里您将找到它提供的所有组件,用于构建高级软件架构解决方案。

  • Azure DevOps:这是一个应用程序生命周期管理环境,您可以使用最新的软件开发方法来构建解决方案,即 DevOps。

  • C#:这是世界上使用最广泛的编程语言之一。C#在不同的操作系统和环境中的小型设备上运行,直到巨大的服务器。

  • .NET Core:这是一个由微软和.NET 社区在 GitHub 上维护的开源开发平台。

  • ASP.NET Core:这是一个使用.NET Core 开发的开源多平台环境,用于构建 Web 应用程序,并托管在云中或在标准服务器(本地)上。

成为软件架构师意味着理解上述内容以及许多其他技术。本书将引导您踏上旅程,作为在团队中工作的软件架构师,您将使用列出的工具提供最佳解决方案。让我们从创建您的 Azure 账户开始这段旅程。

创建 Azure 账户

Microsoft Azure 是目前市场上最好的云解决方案之一。重要的是要知道,在 Azure 内部,我们将找到一系列组件,这些组件可以帮助我们在 21 世纪解决方案的架构中。

本小节将指导您创建 Azure 账户。如果您已经有了账户,您可以跳过这部分:

  1. 您可以使用此 URL 访问 Azure 门户:azure.microsoft.com。在这里,您将找到一个网站,如下所示。您的母语翻译可能已自动设置:

  1. 一旦你访问了这个门户,就有可能注册。如果你以前从未这样做过,你可以免费注册,这样你就可以使用一些 Azure 功能而无需花费任何费用。

  2. 一旦你完成了表格,你就可以访问 Azure 面板。正如你在下面的屏幕截图中所看到的,面板显示了一个你可以定制的仪表板,以及一个左侧菜单,你可以在这里设置你将在解决方案中使用 Azure 组件。在这本书中,我们将多次回到这个屏幕截图来设置创建现代软件架构巨大机会的组件:

图片

一旦你创建了 Azure 账户,你就准备好了解软件架构师如何利用 Azure 提供的所有机会来指导团队开发软件。然而,重要的是要记住,软件架构师需要了解的不仅仅是具体技术,因为如今,这个角色是由那些被期望定义软件交付方式的人扮演的。软件架构师不仅架构软件的基础,还决定整个软件开发和部署过程是如何进行的。

软件开发过程模型

作为一名软件架构师,了解目前大多数企业中使用的常见开发过程非常重要。软件开发过程定义了团队中的人们如何生产和交付软件。一般来说,这个过程与一种称为软件开发过程模型的软件工程理论相关联。自从软件开发被定义为工程过程以来,已经提出了许多软件开发的过程模型。让我们看看目前常见的那些。

审查传统的软件开发过程模型

在软件工程理论中介绍的一些模型已经被认为是传统的并且相当过时。本书并不旨在涵盖所有这些模型,但在这里,我们将简要解释一些仍在某些公司中使用的模型。

理解瀑布模型原则

在 2019 年的软件架构书中,这个话题可能看起来有些奇怪,但确实,你仍然可能会发现一些公司仍然将最传统的软件开发过程模型作为软件开发指南。这个过程按顺序执行所有基本任务。任何软件开发项目都包括以下步骤:

  • 需求规范

  • 软件设计

  • 编程

  • 测试和交付

让我们来看一下这种表示方法的图示:

图片

瀑布式开发周期

通常,使用瀑布模型会导致软件功能版本交付延迟的问题,以及由于最终产品质量差而引起用户不满。

分析增量模型

增量开发是一种试图克服瀑布模型最大问题的方法:用户只能在项目结束时测试解决方案。这个模型的想法是尽早让用户与解决方案互动,以便他们可以提供有用的反馈,这将有助于软件开发过程中的工作。

然而,在这个模型中,有限的增量数量和项目的官僚主义可能会在开发人员和客户之间造成问题:

增量开发周期(https://en.wikipedia.org/wiki/Incremental_build_model)

增量模型被引入作为瀑布方法的替代方案,它减轻了与客户沟通不足相关的问题。对于大型项目来说,更少的增量仍然是一个问题。此外,在增量方法大规模使用的时候,主要在上世纪末,由于需要大量的文档,许多与项目官僚主义相关的问题被报道。这种情况导致了软件开发行业中一个非常重要的运动的兴起——敏捷

理解敏捷软件开发过程模型

在本世纪初,软件开发被认为是工程领域中最混乱的活动之一。失败的项目数量极高,这一事实证明了需要一种不同的方法来处理软件开发项目所需的灵活性。因此,在 2001 年,敏捷宣言被介绍给世界,从那时起,各种敏捷过程模型被提出。其中一些模型至今仍然非常常见。

请查看此链接了解敏捷宣言:agilemanifesto.org/.

敏捷模型与传统模型之间最大的区别之一是开发人员与客户互动的方式。所有敏捷模型传达的信息是,你越快将软件交付给用户,就越好。这个想法有时会让软件开发人员感到困惑,他们理解为——让我们尝试编码,就这样吧! 然而,敏捷宣言中的一个重要观察结果,很多人在开始使用敏捷时并没有阅读到:

"也就是说,虽然右侧的项目具有价值,但我们更重视左侧的项目。"

– 敏捷宣言,2001 年

软件架构师始终需要记住这一点。敏捷流程并不意味着缺乏纪律。而且,当你使用敏捷流程时,你会明白没有纪律就无法开发出好的软件。另一方面,作为软件架构师,你需要理解“软”意味着灵活性。一个不处理灵活性的软件项目往往会随着时间的推移而失败。

进入 Scrum 模型

Scrum 是一种敏捷的软件开发项目管理模型。该模型源于精益原则,并且无疑是当今软件开发中广泛使用的方法之一。

请查看此链接以获取有关 Scrum 框架的更多信息:docplayer.net/78853722-Scrum-insights-for-practitioners.html

Scrum 的基础是,你有一个需要在每个敏捷周期中讨论的灵活的用户需求列表,这被称为Sprint。Sprint 目标由由产品负责人、Scrum Master 和开发团队组成的 Scrum 团队确定。产品负责人负责确定那个 Sprint 中将交付的内容的优先级。在 Sprint 期间,这个人将帮助团队开发所需的功能。在 Scrum 流程中领导团队的人被称为 Scrum Master。所有的会议和流程都由这个人执行。

重要的是要注意,Scrum 流程不讨论软件应该如何实现以及将进行哪些活动。因此,你必须再次记住本章开头讨论的软件开发基础。这意味着 Scrum 需要与一个流程模型一起实施。DevOps 是一种可能帮助你结合 Scrum 使用软件开发流程模型的方法。我们将在本书的第十八章中讨论这一点,理解 DevOps 原则

将方面聚集起来以设计高质量的软件

太棒了!你刚刚开始了一个软件开发项目。现在,是时候利用你所有的知识来交付最好的软件了。可能,你的下一个问题是——我该如何开始? 好吧,作为一个软件架构师,你将是回答这个问题的那个人。并且请确保你的答案将在你领导的每个软件项目中不断演变:

  1. 定义软件开发流程显然是首先要做的事情。这通常在项目规划过程中完成。

  2. 此外,另一件非常重要的事情是收集软件需求。无论你决定使用哪种软件开发流程,收集真实用户需求都是一项非常困难和持续的工作的一部分。当然,有一些技术可以帮助你完成这项工作。并且请确保收集需求将帮助你发现软件架构的重要方面。

这些两个活动被大多数软件开发专家视为在开发项目旅程结束时取得成功的关键。作为软件架构师,你需要确保它们发生,这样你在引导团队时就不会有问题。

理解需求收集过程

有不同的方式来表示需求。最传统的方法是在分析开始之前,你必须写出一个完美的规范。敏捷方法建议你一旦准备好开始一个开发周期,就需要编写故事。

记住:你并不是为用户编写需求,而是为你和你的团队编写。用户只需要工作完成!

事实上,无论你决定在项目中采用哪种方法,你都必须遵循一些步骤来收集需求。这就是我们所说的需求工程

请查看以下关于需求工程过程的图片以获取更多信息:www.slideshare.net/MohammedRomi/ian-sommerville-software-engineering-9th-edition-ch-4

在这个过程中,你需要确保解决方案是可行的。在某些情况下,可行性分析也是项目规划过程的一部分,在你开始需求收集之前,你已经有了一份完成的可行性报告。所以,让我们检查这个过程的其它部分,这将为你提供大量关于软件架构的重要信息。

练习收集用户需求

有很多方法可以检测用户在特定场景中确切需要什么。一般来说,这可以通过帮助你理解我们所说的用户需求的技巧来完成。这里有一份常见技巧的列表:

  • 想象力的力量:如果你是在你提供解决方案的领域中的专家,你可以利用自己的想象力来发现新的用户需求。头脑风暴可以一起进行,以便一组专家可以定义用户需求。

  • 问卷调查:这个工具对于检测常见且重要的需求很有用,例如用户数量和种类、系统高峰使用时间以及常用的操作系统OS)和网页浏览器。

  • 访谈:访谈用户有助于你作为架构师发现那些可能问卷和你的想象力无法涵盖的用户需求。

  • 观察:没有比和他们一起度过一天更好的方式来理解用户的日常习惯了。

一旦你应用了这些技巧中的一个或多个,你将获得宝贵的信息,即用户的需求。在那个时刻,你将能够分析它们并检测用户和系统需求。

记住:你可以在任何真正需要收集需求的情况下使用这些技术,无论是整个系统还是单个故事。

分析需求

一旦你发现用户需求,就是开始分析需求的时候了。那时,你可以使用以下技术:

  • 原型设计:原型设计对于澄清和具体化系统需求非常有用。今天,我们有许多工具可以帮助你模拟接口。一个非常好的开源工具是Pencil Project。你可以在pencil.evolus.vn/找到更多关于它的信息。

  • 用例:如果你需要详细的文档,统一建模语言(UML)用例模型是一个选择。该模型由详细规范和图表组成。Argo UML 是另一个可以帮助你的开源工具:

当你分析系统的需求时,你将能够确切地澄清用户的真正需求。当你不确定将要解决的真实问题时,这非常有帮助,而且比开始编写系统代码要好得多。现在是时候投资于在不久的将来拥有更好的代码了。

编写规范

分析完成后,将其注册为规范是很重要的。这份文档可以使用传统的需求或用户故事来编写,这些在敏捷项目中很常见。

需求规范代表了用户和团队之间的技术合同。这份文档需要遵循一些基本规则:

  • 所有利益相关者都需要确切了解技术合同中写的内容,即使他们不是技术人员。

  • 文档需要清晰。

  • 你需要分类每个需求。

  • 使用一个简单的功能来代表每个需求。

  • 需要避免歧义和争议。

此外,一些信息可以帮助团队理解他们将要工作的项目的背景。这里有一些关于它的提示:

  • 编写一个介绍章节,以全面了解解决方案。

  • 创建一个术语表,以便更容易理解。

  • 描述解决方案将覆盖的用户类型。

  • 编写功能和非功能需求。

  • 附加文档,帮助用户理解规则。

如果你决定编写用户故事,一个很好的建议是写简短的句子,代表每个用户在系统中的每个时刻,如下所示:

As <user>, I want <feature>, so that <reason>

这种方法将确切地解释为什么那个功能将被实现。除此之外,你将有一个很好的工具来分析更关键的故事,并优先考虑项目的成功。

审查规范

一旦你写好了规格说明,就是时候与利益相关者确认他们是否同意了。这可以通过审查会议进行,或者可以使用协作工具在线完成。

这时,你需要展示你所收集的所有原型、文档和信息。一旦每个人都同意规格说明,你就可以开始研究实施项目这一部分的最佳方式了。

将设计思维作为有用的工具使用

在你作为软件架构师的职业生涯中,你将发现许多项目,你的客户将带来一个已准备好开发的解决方案。一旦你考虑这是正确的解决方案,这将会相当复杂;大多数情况下,将会有架构和功能错误,这将在未来的解决方案中引起问题。有些情况下,问题更严重——当客户不知道问题的最佳解决方案时。设计思维可以帮助我们解决这个问题。

设计思维是一个允许你直接从用户收集数据的过程,专注于实现解决问题的最佳结果。在这个过程中,团队将有机会发现所有将与系统交互的角色。这将对解决方案产生美妙的影响,因为你可以通过关注用户体验来开发软件,这可以产生美妙的结果。

该过程基于以下步骤:

  • 同理心:在这个步骤中,你必须执行实地研究来发现用户的关注点。这是你了解系统用户的地方。这个过程有助于你理解为什么以及为谁开发这款软件。

  • 定义:一旦你了解了用户的关注点,就是时候定义他们的需求以解决它们了。

  • 构思:需求将提供机会来头脑风暴一些可能的解决方案。

  • 原型:这些解决方案可以作为原型来开发,以确认它们是否是好的解决方案。

  • 测试:测试原型将帮助你理解与用户真实需求最相关的原型。

你必须理解的是,设计思维可以是一个发现真实需求的绝佳选择。作为一名软件架构师,你致力于帮助你的团队在正确的时间使用正确的工具。

理解可扩展性、健壮性、安全性和性能的原则

识别需求是一项任务,将帮助你理解你将要开发的软件。然而,作为一名软件架构师,你不必只关注该系统的功能需求。理解非功能需求非常重要,并且是软件架构师的一项原始活动。

我们将在第二章中进一步讨论这个问题,功能和非功能需求,但在此阶段,了解可扩展性、健壮性、安全性和性能的原则需要应用于需求收集过程是很好的。让我们看看每个概念:

  • 可扩展性:作为软件开发者,全球化给你提供了一个机会,让你的解决方案在世界各地运行。这是很棒的,但作为软件架构师,你需要设计一个提供这种可能性的解决方案。可扩展性是指应用程序在必要时能够增加其处理能力,这是由于正在消耗的资源数量。

  • 健壮性:无论你的应用程序有多可扩展,如果不能保证稳定且始终在线的解决方案,你将无法得到任何安宁。对于那种应用程序解决的问题,你任何时候都没有维护的机会,因此健壮性对于关键解决方案来说非常重要。在许多行业中,软件不能停止,当没有人可用时(如夜间、假日等),会有很多常规运行。设计一个健壮的解决方案将给你在软件运行良好的同时生活的自由。

  • 安全:这是在需求阶段之后需要讨论的另一个非常重要的领域。每个人都担心安全问题,并且世界各地都在提出处理这一问题的法律。作为软件架构师,你必须明白,安全性需要通过设计来提供。这是应对当前安全社区讨论的所有需求的唯一方法。

  • 性能:理解你将要开发的系统的过程可能会给你一个很好的想法,了解你需要付出多少努力才能从系统中获得期望的性能。这个话题需要与用户讨论,以确定你在开发阶段将面临的大部分瓶颈。

值得注意的是,所有这些概念都是世界需要的新一代解决方案的要求。当然,区分优秀软件和卓越软件的将是满足项目要求所做的工作量。

一些情况下,需求收集过程影响了系统结果

本章到目前为止讨论的所有信息,如果你想要按照良好的工程原则设计软件,都是有用的。这次讨论与传统或敏捷方法开发无关,而是专注于专业或业余地构建软件。

此外,了解一些由于缺乏你所阅读的活动而给软件项目带来麻烦的情况是有好处的。以下案例旨在描述出了什么问题以及前面的技术如何帮助开发团队解决问题。在大多数情况下,简单的行动可以保证团队和客户之间更好的沟通,这种简单的沟通流程可以将大问题转化为真正的解决方案。

情况 1 – 我的网站打开那个页面太慢了!

性能是你作为软件架构师在职业生涯中会遇到的最大问题之一。任何软件的这一方面之所以如此有问题,是因为我们没有无限的计算资源来解决问题。此外,计算成本仍然很高,尤其是如果你在谈论具有大量同时用户的软件。

你不能通过编写需求来解决性能问题。然而,如果你正确地编写它们,你不会陷入麻烦。这里的想法是,需求必须展示系统的期望性能。一个简单的句子,描述这一点,可以帮助整个参与项目的团队:

非功能性需求:性能 – 该软件的任何网页至少要在 2 秒内响应。

前一句话只是让每个人(用户、测试人员、开发人员、架构师、经理等等)确信任何网页都有一个目标要实现。这是一个好的开始,但还不够。有了这个,一个既适合开发又适合部署应用程序的良好环境非常重要。这正是.NET Core 能大量帮助你之处。特别是如果你在谈论 Web 应用程序,ASP.NET Core 被认为是当今提供解决方案最快的选项之一。

如果你谈论性能,作为软件架构师的你应该考虑以下章节中列出的技术。值得一提的是,ASP.NET Core 将帮助你轻松使用它们,同时还有一些由微软 Azure 提供的平台即服务PaaS)解决方案。

理解缓存

缓存是一种避免消耗时间和通常给出相同结果的查询的绝佳技术。例如,如果你正在从数据库中检索可用的汽车型号,数据库中的汽车数量可能会增加,但它们不会改变。一旦你有一个不断访问汽车型号的应用程序,一个好的做法是将这些信息缓存起来。

重要的是要理解缓存存储在后台,并且该缓存由整个应用程序共享(内存缓存)。在这里需要注意的一个点是,当你正在开发一个可扩展的解决方案时,你可以配置一个分布式缓存来使用 Azure 平台解决它。实际上,ASP.NET Core 提供了这两者,因此你可以选择最适合你需求的一个。

应用异步编程

当您开发 ASP.NET Core 应用程序时,您需要记住这个应用程序需要设计为许多用户同时访问。异步编程让您可以简单地做到这一点,提供了 asyncawait 关键字。

这些关键字背后的基本概念是,async 允许任何方法在调用它的线程之外的不同线程中运行。另一方面,await 允许您在不阻塞调用它的线程的情况下同步异步方法的调用。这种易于开发的模式将使您的应用程序在没有性能瓶颈的情况下运行,并具有更好的响应性。本书将在第二章 功能和非功能需求 中更详细地介绍这一主题。

处理对象分配

避免性能不足的一个非常好的建议是了解垃圾收集器是如何工作的。垃圾收集器是当您完成使用后自动释放内存的引擎。由于垃圾收集器具有复杂性,因此这个主题有一些非常重要的方面。

一些类型的对象不会被垃圾收集器收集。这个列表包括任何与 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");
}

尽管前面的做法对于 I/O 对象是强制性的,但强烈建议您在所有可处置对象上继续这样做。这将有助于垃圾收集器,并确保您的应用程序以正确的内存量运行。

您需要了解的另一个重要方面是垃圾收集器收集将干扰您应用程序性能的对象所花费的时间。因此,避免分配大对象。这可能会在垃圾收集器完成其任务时给您带来麻烦。

获取更好的数据库访问

最常见的性能阿基里斯之踵之一是数据库访问。这仍然是一个大问题,原因是在编写查询或 lambda 表达式从数据库获取信息时缺乏关注。本书将在第六章 在 C# 中与数据交互 – Entity Framework Core 中介绍 Entity Framework Core,但了解选择什么、从数据库中读取哪些正确数据信息以及过滤列和行对于希望提供性能的应用程序至关重要。

好的一点是,与缓存、异步编程和对象分配相关的最佳实践完全适用于数据库环境。这只是一个选择正确模式以获得高性能软件的问题。

情况 2 – 用户的需求没有得到适当的实现

随着技术在各个领域的广泛应用,提供用户所需的确切内容变得越来越困难。也许这句话听起来有些奇怪,但您必须理解,开发者通常学习开发软件,但他们很少学习如何满足特定领域的需求。当然,学习如何开发软件并不容易,但理解特定领域的需求更加困难。如今,软件开发为所有可能的行业提供软件。问题是,开发者,无论是软件架构师还是其他角色,如何才能足够地进化,以提供他们负责领域的软件?

收集软件需求无疑将帮助您完成这项艰巨的任务。此外,编写它们将使您理解并组织系统的架构。有几种方法可以最小化实现与用户真正需求不符的风险:

  • 通过原型设计界面以更快地理解用户界面

  • 设计数据流以检测系统与用户操作之间的差距

  • 定期会议以了解当前需求并确保与增量交付保持一致

再次强调,作为软件架构师,您将需要定义软件的实现方式。大多数情况下,您可能不会亲自编写代码,但您将始终是负责这一点的责任人。因此,一些技术可以用来避免错误的实现:

  • 与开发者一起审查需求,以确保他们理解他们需要开发的内容

  • 代码审查以验证预定义的代码标准

  • 会议以消除障碍

案例 3 – 系统的可用性不符合用户需求

可用性是软件项目成功的关键。软件的展示方式和解决问题的方法可以帮助用户决定是否想要使用它。作为软件架构师,您必须牢记,如今提供具有良好可用性的软件是强制性的。

本书无意涵盖可用性的基本概念。但了解谁将使用该软件是满足正确用户需求的一个好方法。正如本章前面所讨论的,设计思维在这方面可以帮助您很多。

理解用户将帮助您决定软件是否将在网页、手机上运行,甚至是在后台运行。这种理解对于软件架构师来说非常重要,因为如果正确地映射出谁将使用这些元素,系统的元素将得到更好的展示。

另一方面,如果您不关心这一点,您只需交付能够正常工作的软件即可。这可能在短期内是好的,但它不会完全满足使人们要求您设计软件的真实需求。您必须牢记这些选项,并理解好的软件是为了在许多平台和设备上运行而设计的。

您会很高兴地知道 C#是一个令人难以置信的跨平台选项。因此,您可以开发解决方案,在 Linux、Windows、Android 和 iOS 上运行您的应用程序。您可以在大屏幕、平板电脑、手机甚至无人机上运行您的应用程序!您可以将应用程序嵌入到用于自动化的板上或在 HoloLens 中用于混合现实。软件架构师必须思想开放,以便设计出用户真正需要的精确方案。

案例研究 – 检测用户需求

本书的研究案例将带您踏上为名为World Wild Travel ClubWWTravelClub)的旅行社创建软件架构的旅程。本案例研究的目的在于让您理解每一章中解释的理论,并在阅读本书的过程中,开发一个使用 Azure、Azure DevOps、C#、.NET Core、ASP.NET Core 和其他本书将介绍的技术构建的企业应用程序。

案例研究 – 介绍 World Wild Travel Club

World Wild Travel ClubWWTravelClub)是一家旅行社,旨在改变人们关于度假和其他全球旅行的决策方式。为此,他们正在开发一个在线服务,其中每个旅行体验的细节都将由为每个目的地特别挑选的专家俱乐部协助。

该平台的概念是您可以同时成为访问者和目的地专家。您在目的地作为专家参与的越多,您获得的积分就越高。这些积分可以用来兑换人们通过平台在线购买的机票。

客户为该平台带来了以下要求。重要的是要知道,通常情况下,客户不会带着已经准备好的需求来进行开发。这就是为什么需求收集过程如此重要的原因:

  • 普通用户视图:

    • 首页上的促销软件包

    • 搜索软件包

    • 每个软件包的详细信息:

      • 购买软件包

      • 购买包含专家俱乐部的软件包:

        • 对你的经验进行评论

        • 咨询专家

        • 评估专家

    • 注册为普通用户

  • 目的地专家视图:

    • 与普通用户视图相同的视图

    • 回答询问您目的地专业知识的问题

    • 管理您通过回答问题获得的积分:

      • 用积分兑换机票
  • 管理员视图:

    • 管理软件包

    • 管理普通用户

    • 管理目的地专家

为了完成这个案例研究,重要的是要注意,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: 作为用户,我想安全地购买套餐,以便只有 WWTravelClub 会拥有我的信用卡信息。

注意,当你开始编写故事时,可以包括与安全、环境、性能和可扩展性等非功能性要求相关的信息。

然而,当你编写用户故事时,可能需要省略一些系统需求,这些需求需要包含在软件规范中。这些需求可能与法律方面、硬件和软件先决条件有关,甚至可能涉及正确交付系统的注意事项。它们需要被映射和列出,就像用户故事一样。以下列出了 WWTravelClub 系统需求清单。请注意,需求是用将来时写的,因为系统尚不存在:

  • SR_001: 系统将使用 Microsoft Azure 组件来提供所需的可伸缩性。

  • SR_002: 系统将遵守通用数据保护条例GDPR)的要求。

  • SR_003: 系统将在 Windows、Linux、iOS 和 Android 平台上运行。

  • SR_004: 本系统的任何网页至少在 2 秒内响应。

摘要

在本章中,你学习了软件架构师在软件开发团队中的作用。本章还涵盖了软件开发流程模型的基础和需求收集过程。你还有机会了解如何创建你的 Azure 账户,该账户将在本书的案例研究中使用,这在上一节中已经向你介绍。此外,你还学习了功能性和非功能性需求以及如何使用用户故事来创建它们。这些技术无疑将帮助你交付更好的软件项目。

在下一章中,你将有机会了解功能性和非功能性需求对于软件架构的重要性。

问题

  1. 软件架构师需要具备哪些专业知识?

  2. Azure 如何帮助软件架构师?

  3. 软件架构师如何决定在项目中使用最佳的软件开发流程模型?

  4. 软件架构师如何为需求收集做出贡献?

  5. 软件架构师在需求规范中需要检查哪些类型的需求?

  6. 设计思维如何帮助软件架构师在需求收集过程中?

  7. 用户故事如何帮助软件架构师在编写需求的过程中?

  8. 开发高性能软件有哪些好的技术?

  9. 软件架构师如何检查用户需求是否正确实施?

进一步阅读

这里有一些书籍和链接,你可以考虑阅读以获取更多关于本章的信息:

第二章:功能性需求和非功能性需求

一旦您收集了系统需求,就需要考虑它们对架构设计的影响。可伸缩性、性能、多线程、互操作性以及其他主题都需要进行分析,以便我们能够满足用户需求。

本章将涵盖以下主题:

  • 什么是可伸缩性以及它与 Azure 和.NET Core 如何交互?

  • 在性能改进方面,以下是一些编写更好代码的好建议

  • 创建一个安全且有用的多线程软件

  • 软件可用性,即如何设计有效的用户界面

  • .NET Core 和互操作性

技术要求

本章提供的示例将需要 Visual Studio 2019 Community Edition 或 Visual Studio Code。

您可以在此处找到本章的示例代码:github.com/PacktPublishing/Hands-On-Software-Architecture-with-CSharp-8/tree/master/ch02

可伸缩性如何与 Azure 和.NET Core 交互?

对可伸缩性进行简短搜索会返回如下定义:系统在需求增加时仍能良好工作的能力。一旦开发者阅读了这一点,许多人会错误地得出结论,可伸缩性仅仅意味着添加更多硬件以保持事物运行而不停止应用程序

可伸缩性依赖于涉及硬件解决方案的技术。然而,作为一名软件架构师,您必须意识到,优秀的软件将保持可伸缩性在一个可持续的模式中,这意味着一个良好架构的软件可以节省很多钱。因此,这不仅仅是硬件的问题,也是整体软件设计的问题。

在第一章“理解软件架构的重要性”中,当我们讨论软件性能时,我们提出了一些克服不良性能问题的好建议。同样的建议也会帮助您处理可伸缩性。我们投入每个过程中的资源越少,应用程序可以处理的用户就越多。

值得注意的是,Azure 和.NET Core Web 应用可以被配置为处理可伸缩性。让我们在以下小节中查看。

在 Azure 中创建可伸缩的 Web 应用

在 Azure 中创建一个可伸缩的 Web 应用非常简单,为扩展做好准备。您之所以必须这样做,是为了能够在不同季节维护不同数量的用户。用户越多,您需要的硬件就越多。以下步骤将向您展示如何在 Azure 中创建一个可伸缩的 Web 应用程序:

  1. 一旦您登录到您的 Azure 账户,您就可以创建新的资源(Web 应用、数据库、虚拟机等),如下面的截图所示:

  1. 然后,您可以选择 Web 应用。本教程将带您进入以下屏幕:

所需的详细信息如下:

  • 应用名称:如您所见,这是您的网络应用创建后所假设的 URL。该名称经过检查以确保其可用性。

  • 订阅:这是将收取所有应用费用的账户。

  • 资源组:这是您可以定义以组织策略和权限的资源集合。您可以指定新的资源组名称或将网络应用添加到在定义其他资源时指定的组中。

  • 操作系统:这是将托管网络应用的操作系统的名称。对于 ASP.NET Core 项目,可以使用 Windows 和 Linux。

  • 发布:此参数指示网络应用是直接交付还是将使用 Docker 技术发布内容。Docker 将在第五章,将微服务架构应用于您的企业应用中更详细地讨论。

  • 应用服务计划/位置:这是您定义用于处理网络应用和服务器位置的硬件计划的地方。此选择定义了应用程序的可扩展性、性能和成本。

  • 应用洞察:这是用于监控和故障排除网络应用的 Azure 实用工具集。

应用可以从两种概念上不同的方式扩展:

  • 纵向(扩展)

  • 横向(扩展)

两者都可在网络应用设置中找到,如下截图所示:

截图

让我们检查两种扩展类型。

垂直扩展(扩展)

扩展意味着更改将支持您的应用程序的硬件类型。在 Azure 中,您有机会从免费共享硬件开始,并在几个点击中切换到隔离的机器。

选择此选项,您有机会选择更强大的硬件(具有更多 CPU、存储和 RAM 的机器)。以下截图显示了扩展网络应用的用户界面:

截图

横向扩展(扩展)

扩展意味着将所有请求分配给具有相同容量的更多服务器,而不是使用更强大的机器。所有服务器的负载将由 Azure 基础设施自动平衡。以下截图显示了一个由两个简单规则定义的自动扩展策略,该策略由 CPU 使用情况触发:

截图

对所有可用自动扩展规则的完整描述超出了本书的范围。然而,它们相当直观,进一步阅读部分包含指向完整文档的链接。

扩展功能仅在付费服务计划中可用。

使用.NET Core 创建可扩展的网络应用

在所有可用于实现 Web 应用的框架中,ASP.NET Core 确保了良好的性能,同时生产和维护成本较低。ASP.NET Core 的性能与 Node.js 的性能相当,但由于使用了 C#(这是一种强类型和高级纯对象语言)而不是 JavaScript,因此生产和维护成本较低。

以下步骤将指导您通过创建基于 ASP.NET Core 的 Web 应用。所有步骤都非常简单,但一些细节需要特别注意。

首先,在创建 Web 应用时,您可以在 .NET Core 框架和 .NET 框架之间进行选择。请注意,只有 .NET Core 可以在 Windows 和更便宜的 Linux 服务器上运行,而经典 .NET 只能在 Windows 服务器上运行。另一方面,使用经典 .NET,您将能够访问更大的代码库,包括 Microsoft 和第三方包的遗留库。

现在,Microsoft 推荐使用经典 .NET,以防所需的特性在 .NET Core 中不可用,或者当您在不支持 .NET Core 的环境中部署 Web 应用时。在其他任何情况下,您都应该优先选择 .NET Core 框架,因为它允许您做以下事情:

  • 在 Windows、Linux 或 Docker 容器中运行您的 Web 应用

  • 使用微服务设计您的解决方案

  • 拥有高性能和可扩展的系统

容器和微服务将在第五章应用微服务架构到您的企业应用中介绍。在那里,您将更好地了解这些技术的优势。目前,只需说 .NET Core 和微服务是为了性能和可扩展性而设计的,这就是为什么您应该在所有新项目中优先选择 .NET Core。

以下步骤将向您展示如何在 Visual Studio 2019 中使用 .NET Core 3.0 创建 ASP.NET Core Web 应用:

  1. 一旦您选择了 ASP.NET Core Web 应用,您将被引导到一个屏幕,您将需要设置项目名称、位置和解决方案名称:

图片

  1. 之后,您将能够选择要使用的 .NET Core 版本。在撰写本文时,.NET Core 3.0 仍处于 Preview 1 版本。

  2. 现在我们已经添加了基本详情,您可以将您的 Web 应用项目连接到您的 Azure 账户,并发布它。

  3. 在解决方案资源管理器中,如果您在任意位置右键单击,您将有一个“发布...”的选项:

图片

  1. 在您选择“发布...”菜单项后,您将能够连接到您的 Azure 账户,然后选择您希望部署的 Web 应用:

图片

  1. Visual Studio 与 Azure 完全集成。这使您有机会在开发环境中查看在 Azure Portal 中创建的所有资源:

图片

  1. 一旦您确定了发布设置,即您的发布配置文件,当您点击“确定”时,Web 应用将自动发布。

图片

对于发布.NET Core 预览版本,您必须在 Azure 门户的 Web 应用设置面板中添加一个扩展,如图所示:

图片

关于将 ASP.NET Core 3.0 部署到 Azure App Service 的更多信息,请参阅此链接:docs.microsoft.com/en-us/aspnet/core/host-and-deploy/azure-apps/?view=aspnetcore-2.2#deploy-aspnet-core-preview-release-to-azure-app-service

在这里,我们描述了部署 Web 应用的最简单方法。第十七章,使用 Azure DevOps 部署您的应用程序,将向您介绍 Azure DevOps 持续集成/持续交付CI/CD)管道。这个管道是 Azure 工具集的进一步扩展,它自动化了将应用程序部署到生产环境所需的所有步骤,即构建、测试、预部署和部署到生产。

在用 C#编程时需要考虑的性能问题

现在,C#是全球最广泛使用的编程语言之一,因此关于 C#编程的好建议对于设计满足最常见非功能性要求的好架构是基本的。

以下几节提到了一些简单但有效的技巧——相关的代码示例可在本书的 GitHub 仓库中找到。

字符串连接

这是一个经典案例!使用+字符串运算符进行字符串的简单连接可能会导致严重的性能问题,因为每次连接两个字符串时,它们的内容都会被复制到一个新的字符串中。

因此,如果我们连接,比如说,平均长度为 100 的 10 个字符串,第一次操作的成本为 200,第二次操作的成本为200+100=300,第三次操作的成本为 300+100=400,以此类推。不难说服自己,总成本的增长类似于m,其中n是字符串的数量,m是它们的平均长度。对于小的n(比如说,n* < 10),并不太大,但当n达到 100-1,000 的数量级时,它就变得相当大了,实际上对于 10,000-100,000 的数量级是不可接受的。

让我们通过一些测试代码来看看这个问题,这些代码比较了简单的字符串连接与使用StringBuilder类执行相同操作的情况(代码可在本书的 GitHub 仓库中找到):

图片

如果你创建一个StringBuilder类,例如var sb = new System.Text.StringBuilder(),然后使用sb.Append(currString)将每个字符串添加到其中,字符串不会被复制;相反,它们的指针被排队到一个列表中。它们只在调用sb.ToString()以获取最终结果时复制一次。因此,基于StringBuilder的连接成本简单地增长为mn*。

当然,你可能永远不会找到一个像前面那样的函数,它将 10 万个字符串连接起来。然而,你需要认识到类似的代码片段,其中一些 20-100 个字符串的连接,比如在一个同时处理多个请求的 Web 服务器中,可能会造成瓶颈,损害你的性能非功能需求。

异常

总是记住——异常处理需要太多时间!因此,try-catch的使用需要简洁且必要;否则,你将创建大的性能问题。

以下两个示例比较了使用try-catchInt32.TryParse来检查字符串是否可以转换为整数的方法,如下所示:

private static string ParseIntWithTryParse()
{
    string result = String.Empty;
    int value;
    if (Int32.TryParse(result, out 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}";
}

第二个函数看起来并不危险,但它比第一个慢数千倍:

图片

总结一下,异常必须用于处理打破正常控制流程的异常情况,例如,当操作由于某些意外原因必须被中止时,并且必须将控制返回到调用堆栈的几个级别。

多线程环境以获得更好的结果——应该做什么和不应该做什么

如果你想充分利用你正在构建的系统提供的所有硬件,你必须使用多线程。这样,当一个线程正在等待一个操作完成时,它可以离开 CPU 和其他资源,让其他线程使用,而不是浪费 CPU 时间。

另一方面,无论微软如何努力帮助解决这个问题,并行代码并不像吃蛋糕那么简单:它容易出错,难以测试和调试。当你开始考虑使用线程时,作为软件架构师,最重要的要记住的是:你的系统需要它们吗? 非功能性和一些功能性需求肯定会为你回答这个问题。

一旦你确定你需要一个多线程系统,你应该决定哪种技术更合适。这里有几个选项,如下所示:

  • 创建一个System.Threading.Thread的实例:这是在 C#中创建线程的经典方式。线程的生命周期将完全由你控制。当你确定你要做什么时,这是好的,但你需要担心实现中的每一个细节。生成的代码难以构思、调试/测试/维护。因此,为了保持开发成本可接受,这种方法应该仅限于几个基本性能批判性模块。

  • 使用 System.Threading.Tasks.Parallel 和 System.Threading.Tasks.Task 类进行编程:在 .NET Framework 4.0 版本中,你可以使用并行类以更简单的方式启用线程。这很好,因为你不需要担心你创建的线程的生命周期,但它会给你更少的控制权来了解每个线程中发生的事情。

  • 使用异步编程进行开发:这无疑是开发多线程应用程序最容易的方式,因为你不需要关心线程协调,死锁也不可能发生。当一个异步方法调用另一个异步方法时,它会进入休眠模式,以避免在调用任务返回之前浪费资源。这样,异步代码模仿了经典同步代码的行为,同时保持了通用并行编程的大部分性能优势。

总体行为是确定性的,并且不依赖于每个任务完成所需的时间,因此不可能出现不可重现的 bug,生成的代码易于测试/调试/维护。将一个方法定义为异步任务或不是,这是程序员唯一的选择;其他所有事情都由运行时自动处理。你唯一需要关心的是哪些方法应该具有异步行为。

在本书的后续部分,我们将提供一些异步编程的简单示例。有关异步编程及其相关模式的信息,请参阅微软文档中的 基于任务的异步模式 (docs.microsoft.com/en-us/dotnet/standard/asynchronous-programming-patterns/task-based-asynchronous-pattern-tap)。

无论你选择哪种选项,作为软件架构师,你都必须注意一些“应该做”和“不应该做”的事情。以下是一些注意事项:

  • 使用并发集合 (System.Collections.Concurrent):一旦开始一个多线程应用程序,就必须使用这些集合。原因是你的程序可能会从不同的线程管理相同的列表、字典等。使用并发集合是开发线程安全程序的唯一选择。

  • 关注静态变量:不能说在多线程开发中禁止使用静态变量,但你应该注意它们。再次强调,多个线程处理相同的变量可能会引起很多麻烦。如果你用 [ThreadStatic] 属性装饰静态变量,每个线程都会看到该变量的不同副本,从而解决了多个线程竞争同一值的问题。然而,ThreadStatic 变量不能用于跨线程通信,因为一个线程写入的值不能被其他线程读取。

  • 在多线程实现后测试系统性能:线程使你能够充分利用你的硬件,但在某些情况下,编写不良的线程可能会浪费 CPU 时间而什么也不做!类似的情况可能会导致几乎 100%的 CPU 使用率和不可接受的系统减速。在某些情况下,通过在某个线程的主循环中添加简单的Thread.Sleep(1)调用可以减轻或解决问题,但你需要测试这一点。

  • 不要认为多线程很简单:在某些语法实现中,多线程并不像看起来那么简单。在编写多线程应用程序时,你应该考虑诸如用户界面的同步、线程终止和协调等问题。在许多情况下,程序只是因为多线程实现不当而停止正常工作。

  • 不要忘记计划你的系统应该拥有的线程数量:这对于 32 位程序来说非常重要。在 32 位环境中,你可以拥有的线程数量有一个限制。在设计你的系统时,你应该考虑这一点。

  • 不要忘记结束你的线程:如果你没有为每个线程提供正确的终止程序,你可能会遇到内存和处理句柄泄漏的问题。

可用性 - 为什么插入数据需要花费太多时间

可扩展性、性能技巧和多线程是我们可以用来调整机器性能的主要工具。然而,你设计的系统的有效性取决于整个处理管道的整体性能,这包括人类和机器。

作为软件架构师,你无法提高人类的表现,但你可以通过设计一个有效的用户界面UI),即确保与人类快速交互的用户界面来提高人机交互的性能,这反过来意味着以下内容:

  • UI 必须易于学习,以减少学习时间和目标用户学会快速操作之前的时间浪费。如果 UI 更改频繁,以及需要吸引尽可能多用户的公共网站,这个限制是基本的。

  • UI 不得在数据插入时造成任何类型的减速;数据插入速度必须仅限于用户的打字能力,而不是由系统延迟或可以避免的额外手势。

在设计易于学习的用户界面时,以下是一些简单的技巧:

  • 每个输入屏幕必须清楚地说明其目的。

  • 使用用户的语言,而不是开发者的语言。

  • 避免复杂化。设计 UI 时考虑平均情况;更复杂的情况可以通过仅在需要时出现的额外输入来处理。将复杂的屏幕拆分为更多输入步骤。

  • 使用过去的输入来理解用户意图,并通过消息和自动 UI 更改将用户引导到正确的路径;例如,级联下拉菜单。

  • 错误消息不是系统给用户的坏笔记,但它们必须解释如何插入正确的输入。

快速用户界面源于对以下三个要求的有效解决方案:

  • 输入字段必须按照通常填充的顺序放置,并且应该可以使用 TabEnter 键移动到下一个输入。此外,经常保持空白的字段应放置在表单底部。简单来说,填写表单时使用鼠标的操作应尽可能减少。这样,用户手势的数量保持在最低。在 Web 应用程序中,一旦决定了输入字段的最佳放置,就足够使用 tabindex 属性来定义用户使用 Tab 键从输入字段移动到下一个输入字段的正确方式。

  • 系统对用户输入的反应必须尽可能快。特别是,错误(或信息)消息必须在用户离开输入字段时立即出现。实现这一点的最简单方法是将大部分帮助和输入验证逻辑移动到客户端,这样系统反应就不需要通过通信线路和服务器进行。

  • 有效的选择逻辑。选择现有项目应该尽可能简单;例如,在优惠中从数千种产品中选择一个,应该可以通过几个手势完成,而且无需记住确切的产品名称或其条形码。下一小节将分析我们可以使用的提高复杂度以实现快速选择的技术。

设计快速选择逻辑

当所有可能的选择的数量级在 1-50 之间时,通常的下拉菜单就足够了。例如,检查货币选择下拉菜单:

图片

当数量级较高但不到几千时,显示所有以用户输入的字符开头的项目名称的自动完成通常是一个好选择:

图片

类似解决方案可以以低计算成本实现,因为所有主要数据库都可以有效地选择以给定子串开头的字符串。

当名称相当复杂时,在搜索用户输入的字符时,它们应在每个项目字符串内部扩展。这种操作不能使用常规数据库有效地执行,需要专门的数据结构。

最后,当我们搜索由多个单词组成的描述时,需要更复杂的搜索模式。例如,产品描述就是这样。如果所选数据库支持全文搜索,系统可以有效地搜索用户在所有描述中输入的多个单词的出现。

然而,当描述由名称而不是常见单词组成时,用户可能很难记住目标描述中包含的几个确切名称。这种情况在多国公司名称中很常见。在这些情况下,我们需要找到用户输入的字符最佳匹配的算法。必须在每个描述的不同位置搜索用户输入的字符串的子串。一般来说,无法有效地使用基于索引的数据库实现类似算法,但需要将所有描述加载到内存中,并以某种方式与用户输入的字符串进行排名。

这类算法中最著名的算法可能是Levenshtein算法,该算法被大多数拼写检查器用于找到与用户输入的错误拼写最匹配的单词。此算法最小化描述与用户输入的字符串之间的 Levenshtein 距离,即从一个字符串转换到另一个字符串所需的字符删除和添加的最小数量。

Levenshtein 算法效果很好,但计算成本非常高。现在,我们给出一个更快的算法,该算法在搜索描述中的字符发生时效果良好。用户输入的字符不需要在描述中连续出现,但必须按相同的顺序出现。某些字符可能缺失。每个描述都根据缺失的字符以及用户输入的字符发生的相对位置给予一个惩罚。更具体地说,算法使用两个数字对每个描述进行排名:

  • 用户输入的字符在描述中出现的次数:描述中包含的字符越多,其排名越高。

  • 每个描述都根据用户在描述中输入的字符发生之间的总距离给予一个惩罚。

以下截图显示了单词爱尔兰与用户输入的字符串ilad的排名情况:

发生次数为四个,而字符发生之间的总距离为三个。

一旦所有描述都被评分,它们将根据发生次数进行排序。发生次数相同的描述将根据最低的惩罚进行排序。以下是一个实现上述算法的自动完成示例:

以下是对用户输入的字符串进行排名的 C#代码:

public class SmartDictionary<T>
{
    ...
    private Func<T, string> keyAccessor;
    protected class Rater
    {
        public T Item;
        public double Penalty=0;
        public int FoundChars=0;
    }
    ...
    protected Rater RateItem(string search, Rater x)
    {
        var toSearch = search.ToLower();
        var destination = keyAccessor(x.Item).ToLower();
        bool firstMatch = true;
        for (var j = 0; j < toSearch.Length; j++)
        {
            if (destination == string.Empty) return x;
            var currChar = toSearch[j];
            var index = destination.IndexOf(currChar);
            if (index == -1) continue;
            x.FoundChars++;
            if (firstMatch)
            {
                x.Penalty += index;
                firstMatch = false;

            }
            else x.Penalty += index*1000;
            if (index + 1 < destination.Length)
                destination = destination.Substring(index + 1);
            else
                destination = string.Empty;
        }
        return x;
    }
    ...
}

要排名的项目被插入到Rater实例中。然后,通过keyAccessor函数提取其字符串描述。之后,代码计算字符发生和总距离中的发生次数。

完整的类代码,以及一个测试控制台项目,都可以在本书的 GitHub 仓库中找到。

从大量项目中选择

在这里,“巨大”并不是指存储数据所需的空间量,而是指用户记住每个项目功能的困难程度。当必须从 10,000-100,000 个项目中选择一个时,通过在描述中搜索字符出现来找到它的希望就消失了。在这里,用户必须通过一系列类别来引导到正确的项目。

在这种情况下,执行单个选择需要多个用户手势。换句话说,每个选择都需要与多个输入字段进行交互。一旦确定选择不能通过单个输入字段完成,最简单的选项就是级联下拉菜单,即一系列下拉菜单,其选择列表取决于之前下拉菜单中选择的值。

例如,如果用户需要选择世界上任何地方的城镇,我们可能使用第一个下拉菜单来选择国家,一旦选择了国家,我们可能使用这个选择来填充第二个下拉菜单,其中包含所选国家中的所有城镇。一个简单的例子如下:

图片

显然,当需要时,每个下拉菜单都可以用自动完成来替换,因为选项数量很多。

如果通过交叉多个不同的层次结构来做出正确的选择变得不高效,那么级联下拉菜单也会变得低效,我们需要一个过滤器表单,如下所示:

图片

现在,让我们了解 .NET Core 的互操作性。

.NET Core 与 .NET Core 的互操作性奇妙世界

.NET Core 为 Windows 开发者带来了将他们的软件部署到各种平台的能力。作为软件架构师,您需要特别注意这一点。对于 C# 爱好者来说,Linux 和 macOS 已不再是问题——这比那更好——它们确实是向新客户交付的真正好机会。因此,我们需要确保性能和多平台支持,这是几个系统中的两个常见非功能性需求。

在 Windows 中使用 .NET Core 设计的控制台应用程序和 Web 应用程序几乎也与 Linux 和 macOS 完全兼容。这意味着您不需要在这些平台上重新构建应用程序即可运行它。此外,现在许多特定于平台的行为都支持多平台,例如,从 .NET Core 3.0 开始的 System.IO.Ports.SerialPort 类,它在 Linux 上运行。

微软提供了脚本帮助您在 Linux 和 macOS 上安装 .NET Core。您可以在 docs.microsoft.com/en-us/dotnet/core/tools/dotnet-install-script 找到它们。一旦安装了 SDK,您只需像在 Windows 上一样调用 dotnet 即可。

然而,您必须意识到一些与 Linux 和 macOS 系统不完全兼容的功能。例如,在这些操作系统中没有与 Windows 注册表等效的东西,您必须自己开发替代方案。如果需要,加密的 JSON 配置文件是一个不错的选择。

另一个重要点是,Linux 区分大小写,而 Windows 不区分。当您处理文件时,请记住这一点。另一个重要的事情是,Linux 的路径分隔符与 Windows 的分隔符不同。您可以使用 Path.PathSeparator 属性以及所有其他 Path 类方法来确保您的代码实际上是跨平台的。

此外,您还可以通过使用 .NET Core 提供的运行时检查来将您的代码适配到底层操作系统,如下所示:

using System;
using System.Runtime.InteropServices;

namespace CheckOS
{
    class Program
    {
        static void Main()
        {
            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 中创建服务

以下脚本可以用于在 Linux 中封装命令行 .NET Core 应用程序。这个想法是,这个服务就像一个 Windows 服务一样工作。考虑到大多数 Linux 安装仅提供命令行界面,并且在没有用户登录的情况下运行,这可能会非常有用:

  1. 第一步是创建一个将运行命令行应用程序的文件。应用程序的名称是 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
  1. 一旦文件创建完成,您必须将服务文件复制到系统位置。之后,您需要重新加载 systemd 并启用服务,以便在重启时自动启动:
sudo cp sample.service /lib/systemd/system
sudo systemctl daemon-reload 
sudo systemctl enable *sample*
  1. 完成!现在,您可以使用以下命令启动、停止和检查服务。您在命令行应用程序中需要提供的整个输入如下:
# Start the service
sudo systemctl start sample

# View service status
sudo systemctl status sample

# Stop the service
sudo systemctl stop sample 

现在我们已经了解了一些概念,让我们学习如何在我们的用例中实现它们。

书籍用例 - 理解 .NET Core 项目的类型

本书用例的开发将基于各种类型的 .NET Core Visual Studio 项目。本节描述了所有这些项目。让我们在 Visual Studio 文件菜单中选择“新建项目”。在打开的窗口中,所有 .NET Core 项目都将位于左侧菜单的 .NET Core、.NET Standard 和 Cloud 项下:

大多数项目都在 .NET Core 下可用:

在这里,我们有一个控制台项目、一个类库项目以及各种类型的测试项目,每个项目都基于不同的测试框架:xUnit、nUnit 和 MSTest。选择各种测试框架只是个人偏好的问题,因为它们都提供了类似的功能。将测试添加到构成解决方案的每个软件组件中是一种常见做法,允许软件频繁修改而不会危及其可靠性。

测试将在第十五章,使用单元测试用例和 TDD 测试您的代码和第二十章,软件测试自动化中详细讨论。最后,我们已经有了一个在使用.NET Core 创建可扩展的 Web 应用程序子节中描述的 ASP.NET Core 应用程序。在那里,我们定义了一个 ASP.NET MVC 应用程序,但 Visual Studio 还包含基于 RESTful API 和最重要的单页应用程序框架(如 Angular、React、Vue.js 以及基于 WebAssembly 的新 Blazor 框架)的项目模板。其中一些在标准的 Visual Studio 安装中可用;其他则需要安装 SPA 包。

对于每种项目类型,我们可以选择我们想要使用的.NET Core 版本。在.NET Standard 菜单项下,我们只有一个类库项目。.NET Standard 类库基于.NET 标准而不是特定的.NET Core 版本,因此它们与多个.NET Core 版本兼容。例如,基于 2.0 标准的库与所有大于或等于 2.0 的.NET Core 版本以及所有大于 4.6 的.NET Framework 版本兼容。

这种兼容性优势是以牺牲更少可用功能为代价的。然而,不属于标准的特性可以作为对附加库包的引用添加。

最后,在云菜单下,我们还有更多项目类型,但与.NET Core 相关的新项目只有 Service Fabric 应用程序:

图片

这使我们能够定义微服务。基于微服务的架构允许将应用程序拆分为几个独立的微服务。可以创建多个相同的微服务实例,并将它们分布到多台机器上以微调每个应用程序部分的表现。微服务将在第五章,将微服务架构应用于您的企业应用程序中描述。

摘要

描述系统行为的职能需求必须与非职能需求相结合,这些非职能需求限制了系统的性能、可伸缩性、互操作性和可用性。性能需求来自响应时间和系统负载需求。作为软件架构师,您应该确保以最低的成本构建高效的算法,充分利用可用的硬件资源,并通过多线程实现所需的性能。

可伸缩性是指系统适应增加负载的能力。系统可以通过提供更强大的硬件进行垂直扩展,或者通过复制和负载均衡相同的硬件进行水平扩展。通常来说,云(尤其是 Azure)可以帮助我们动态地实施策略,而无需停止您的应用程序。

在多个平台上运行的工具,如 .NET Core,可以确保互操作性,即你的软件能够在不同的目标机器和不同的操作系统(Windows、Linux、macOS、Android 等)上运行。

通过关注输入字段的顺序、项目选择逻辑的有效性以及系统学习的便捷性来确保可用性。

在下一章中,你将学习 Azure DevOps 工具如何帮助我们收集、定义和记录我们的需求。

问题

  1. 系统扩展的两种概念方式是什么?

  2. 你能否从 Visual Studio 自动部署你的 Web 应用到 Azure?

  3. 多线程有什么用途?

  4. 异步模式相较于其他多线程技术的主要优势是什么?

  5. 为什么输入字段的顺序如此重要?

  6. 为什么 .NET Core 的 Path 类对于互操作性如此重要?

  7. .NET 标准类库相较于 .NET Core 类库有什么优势?

  8. 列出各种 .NET Core Visual Studio 项目的类型。

进一步阅读

以下是一些你可能考虑阅读的书籍和链接,以获取更多关于本章的信息:

第三章:使用 Azure DevOps 记录需求

Azure DevOps 是 Visual Studio Team Services 的进化版本,它提供了许多新功能,可以帮助开发者记录和组织他们的软件。本章的目的是介绍由微软提供的这个工具的概述。

本章将涵盖以下主题:

  • 使用您的 Azure 账户创建 Azure DevOps 项目

  • 理解 Azure DevOps 提供的功能

  • 使用 Azure DevOps 组织和管理需求

  • 在 Azure DevOps 中展示用例

技术需求

本章要求您创建一个新的免费 Azure 账户或使用现有的一个。第一章中理解软件架构的重要性创建 Azure 账户部分解释了如何创建一个。

介绍 Azure DevOps

Azure DevOps 是一个微软 软件即服务 (SaaS) 平台,它使您能够向客户持续交付价值。通过在那里创建账户,您将能够轻松地规划项目、安全地存储代码、测试它、将解决方案发布到预发布环境,然后将其发布到实际的生产基础设施。

当然,Azure DevOps 是一个完整的框架,它提供的生态系统为

软件开发目前是可用的。所有涉及软件生产的步骤的自动化确保了现有解决方案的持续增强和改进,以便适应市场需求。

您可以从 Azure 门户开始此过程。如果您不知道如何创建 Azure 门户账户,请参阅第一章,理解软件架构的重要性。创建 Azure DevOps 账户的步骤相当简单:

  1. 选择“创建资源”然后选择“DevOps 项目”:

  1. 一旦开始创建项目的向导,您就可以从几个不同的平台中选择您想要交付的系统。这是 Azure DevOps 的一个巨大优势,因为您不仅限于使用微软的工具和产品,而且可以从市场上所有常见的平台、工具和产品中进行选择:

  1. 可用的选项将取决于第一步中选择的平台。在某些情况下,您可以从以下截图中的几个部署选项中进行选择,该截图在您选择 .NET 平台时出现:

  1. 一旦设置完成,您将能够根据您提供的信息使用项目门户来管理项目。值得一提的是,如果您没有 Azure DevOps 服务,此向导将为您创建一个。Azure DevOps 组织是您可以组织所有您的

    Azure DevOps 项目。整个过程不到 20 分钟:

图片

  1. 之后,您将能够开始规划您的项目。以下截图显示了 Azure DevOps 项目创建完成后出现的页面。在本书的剩余部分,我们将多次回到这个页面,介绍和描述各种确保更快、更有效部署的有用功能:

图片

如前一个截图所示,创建 Azure DevOps 账户并开始开发一流的 DevOps 工具的过程非常简单。值得一提的是,如果您团队中有最多五名开发者,以及任何数量的利益相关者,您可以使用这款出色的工具而不需付费。

使用 Azure DevOps 组织您的工作

DevOps 是一种 持续集成/持续部署 (CI/CD) 方法论,即一套关于如何将持续改进应用于软件应用以及如何将其交付到生产环境中的最佳实践。Azure DevOps 是一款功能强大的工具,其应用范围涵盖了应用初始开发以及随后的 CI/CD 流程中的所有步骤。

Azure DevOps 包含收集需求和组织整个开发流程的工具。您可以通过点击 Azure DevOps 页面中的“看板”菜单来访问它们,将在下一两个小节中更详细地描述:

图片

Azure DevOps 中其他所有功能将在以下小节中简要介绍。它们将在第十五章 使用单元测试用例和 TDD 测试您的代码到第二十章 软件测试自动化中详细讨论。

Azure DevOps 仓库

“仓库”菜单项为您提供访问 Git 仓库的权限,以便放置项目代码:

图片

您可以通过非常简单的方式从 Visual Studio 内部连接到这个仓库:

  1. 启动 Visual Studio 并确保您使用用于定义您的 DevOps 项目(或用于将您添加为团队成员)的相同 Microsoft 账户登录到 Visual Studio。

  2. 如果您的 DevOps 项目仓库为空,请准备一个包含您应用程序中需要的 Visual Studio 项目(开发过程中可以添加更多项目)的 Visual Studio 解决方案。

  3. 选择“团队资源管理器”选项卡,然后点击连接按钮:

图片

  1. 点击 Azure DevOps 的“连接...”链接,您将被引导连接到您的其中一个 Azure DevOps 项目。

  2. 点击“团队资源管理器主页”按钮。现在,您将看到执行 Git 操作以及与其他 Azure DevOps 区域交互的命令:

图片

  1. 如果 Azure DevOps 仓库为空,请通过点击“更改”按钮提交您刚刚创建的解决方案,然后按照后续的指示操作。

  2. 点击“同步”按钮以同步您的本地仓库与远程 Azure DevOps 仓库。如果远程仓库为空且您刚刚创建了一个解决方案,此操作将使用此解决方案初始化远程 Azure DevOps 仓库;否则,此操作将在您的本地机器上下载远程仓库。

  3. 一旦所有团队成员都通过前面的步骤初始化了他们的本地机器仓库和 Azure DevOps 仓库,打开 Visual Studio 就足够了。您在本地仓库中创建的解决方案将出现在 Team Explorer 窗口的底部区域。

  4. 点击窗口以在您的本地机器上打开解决方案。然后,与远程仓库同步以确保您正在修改的代码是最新的。

Team Explorer 菜单允许您执行大多数 Git 命令,启动远程构建(构建按钮)以及与其他 Azure DevOps 区域交互(例如,查看工作项按钮)。

包源

“工件”菜单处理项目使用的软件包。在那里,您可以定义基本上所有类型的包的源,如 NuGet、Node.js 和 Python。一旦进入工件区域,您可以通过点击“+新建源”链接创建多个源,其中每个源可以处理多种类型的包:

图片

如果您选择从公共源连接到包的选项,默认情况下,源会连接到 npmjsnuget.orgpypi.org。然而,您可以通过右上角的菜单进入“源设置”部分的“上游源”标签页来删除/添加包源:

图片

每个源的“连接到源”链接会显示一个窗口,该窗口为每种包类型解释如何执行以下操作:

  1. 将私有包上传到源。这样,每个团队都可以使用其私有包的代码库。

  2. 通过连接到源来使用 Visual Studio 中的其包。您应该将所有项目源添加到您的 Visual Studio 源中,以便也能使用源中上传的私有团队包;否则,您的本地构建将失败。

  3. 管理访问源的凭据:

图片

测试计划

测试计划部分允许您定义您想要使用的测试计划和它们的设置。您可以通过测试计划菜单项访问它:

图片

在这里,您可以定义、执行和跟踪由以下内容组成的测试计划:

  • 手动验收测试

  • 自动单元测试

  • 负载测试

必须在 Visual Studio 解决方案中包含的测试项目中定义自动单元测试,并基于如 NUnit、xUnit 和 MSTest(Visual Studio 为它们都提供了项目模板)等框架。测试计划为您提供在 Azure 上执行这些测试的机会,并定义以下内容:

  • 一系列配置设置

  • 何时执行它们

  • 如何跟踪它们以及在哪里在整体项目文档中报告结果

对于手动测试,您可以定义操作员的完整说明、执行它们的执行环境(例如,操作系统),以及在哪里在项目文档中报告结果。您还可以定义如何执行负载测试、如何衡量结果以及在哪里报告它们。

管道

管道是自动行动计划,它指定了从代码构建到软件部署到生产中的所有步骤。它们可以在管道区域中定义,该区域可通过“管道”菜单项访问:

图片

在那里,您可以定义一个完整的任务管道,包括它们的触发事件,这包括代码构建、启动测试计划以及在测试通过后要执行的操作。

通常,在测试通过后,应用程序会自动部署到一个预发布区域,在那里可以进行 beta 测试。您还可以定义生产中自动部署的准则。这些准则包括但不限于以下内容:

  • 应用程序进行 beta 测试的天数

  • 在 beta 测试期间发现的错误数量以及/或最后代码更改中删除的错误数量

  • 一位或多位经理/团队成员的手动批准

准则决策将取决于公司希望如何管理正在开发的产品。作为软件架构师,您必须理解,当涉及到将代码移至生产时,越安全越好。

在 Azure DevOps 中管理系统需求

Azure DevOps 允许您使用 工作项 来记录系统需求。工作项作为信息存储在您的项目中,可以分配给个人。它们被分类为各种类型,可能包含所需开发工作的度量、状态以及它们所属的开发阶段(迭代)。

实际上,DevOps 方法论作为一种敏捷方法论,由多个迭代组成,整个开发过程被组织为一组冲刺。可用的工作项取决于在创建 Azure DevOps 项目时选择的 工作项流程。以下小节包含了对最常见工作项类型的描述。

Epic 工作项

想象你正在开发一个由各种子系统组成的系统。可能你不会在一个迭代中完成整个系统。因此,我们需要一个跨越几个迭代的总括来封装每个子系统的所有功能。每个史诗工作项代表这些总括中的一个,它可以包含在各个开发迭代中要实现的好几个功能。

在史诗工作项中,你可以定义状态和验收标准,以及开始日期和目标日期。此外,你还可以提供优先级和努力估计。所有这些详细信息都有助于利益相关者跟踪开发过程。这作为一个项目的宏观视图是非常有用的。

史诗不是默认可用的。必须在项目的团队设置页面中启用它们,该页面可以通过点击项目页面左下角的“项目设置”链接并选择“团队设置”来访问:

图片

功能工作项

你在史诗工作项中提供的所有信息也可以放置在功能工作项中。因此,这两种类型工作项之间的区别并不在于它们包含的信息类型,而在于它们的角色和焦点;你的团队将得出结论。史诗可能跨越几个迭代,并且在功能之上,也就是说,每个史诗工作项都与几个子功能工作项相关联,而每个功能工作项必须在单个迭代中实现,并且是单个史诗工作项的一部分。

值得注意的是,所有工作项都有团队讨论的部分。在那里,你可以通过输入@字符(就像在许多论坛/社交应用中一样)在讨论区域找到团队成员。在每个工作项内部,你可以链接和附加各种信息。你还可以在特定部分检查当前工作项的历史记录。

功能工作项是开始记录用户需求的地方。例如,你可以写一个名为访问控制的功能工作项来定义实现系统访问控制所需的所有功能。

产品待办事项/用户故事工作项

在选择工作项流程之后,你将知道这两种工作项中哪一种可用。它们之间有一些细微的差别,但它们的目的基本上是相同的。它们包含详细的需求,这些需求由它们连接的功能工作项描述。更具体地说,每个产品待办事项/用户故事工作项指定了单个功能的需求,该功能是其父功能工作项中描述的行为的一部分。例如,在系统访问控制的功能中,用户维护和登录界面应该是两个不同的用户故事/产品待办事项。这些需求将指导创建其他子工作项:

  • 任务:它们是重要的工作项,描述了为了满足父产品待办事项/用户故事工作项中声明的需求而需要完成的任务。任务工作项可以包含时间估计,这有助于团队容量管理和整体调度。

  • 测试用例:这些项目描述了如何测试需求中描述的功能。

你将为每个产品待办事项/用户故事工作项创建的任务和测试用例的数量将根据你使用的开发和测试场景而有所不同。

书籍用例 – 在 Azure DevOps 中展示用例

本节通过 wwtravelclub 的实际案例来阐明上一节中提出的概念。考虑到第一章中描述的场景,即“理解软件架构的重要性”,我们决定定义三个史诗级工作项,如下所示:

图片

创建这些工作项非常简单:

  1. 在每个工作项内部,链接不同类型的工作项,如下面的截图所示。

  2. 知道工作项之间的连接在软件开发中非常有用是非常重要的。因此,作为一名软件架构师,你必须向你的团队提供这方面的知识,而且更重要的是,你必须激励他们建立这些连接:

图片

  1. 一旦你创建了功能工作项,你将能够将其连接到几个详细说明其规格的产品待办事项工作项。以下截图显示了产品待办事项工作项的详细信息:

图片

  1. 之后,可以为每个产品待办事项工作项创建任务和测试用例工作项。Azure DevOps 提供的用户界面非常有效,因为它允许你跟踪功能链及其之间的关系:

图片

  1. 一旦你完成了产品待办事项和任务工作项的输入,你将能够与你的团队一起规划项目冲刺。计划视图允许你将产品待办事项工作项拖放到每个计划冲刺中:

图片

这些工作项就是这样创建的。一旦你理解了这个机制,你将能够创建和规划任何软件项目。值得一提的是,工具本身并不能解决与团队管理相关的问题。然而,这个工具是激励团队更新项目状态的一个很好的方式,这样你可以清楚地了解项目是如何发展的。

摘要

本章介绍了如何为软件开发项目创建 Azure DevOps 账户,以及如何使用 Azure DevOps 开始管理您的项目。它还简要回顾了所有 Azure DevOps 功能,解释了如何通过 Azure DevOps 主菜单访问它们。本章更详细地描述了如何管理系统需求,以及如何使用各种类型的工作项组织工作,以及如何规划和组织旨在交付具有许多功能的 Epic 解决方案的迭代。

下一章将讨论软件架构的不同模型。我们还将了解在开发基础设施时,如何从复杂的云平台(如 Azure)提供的选项中选择基本提示和标准。

问题

  1. Azure DevOps 仅适用于.NET Core 项目吗?

  2. Azure DevOps 能在特定分支提交后触发自动构建吗?Azure DevOps 能自动触发生产环境的部署吗?

  3. Azure DevOps 中可用的测试计划有哪些?

  4. DevOps 项目可以使用私有 NuGet 包吗?

  5. 我们为什么使用工作项?

  6. Epic 和功能工作项之间的区别是什么?

  7. 任务与产品待办事项/用户故事工作项之间存在什么样的关系?

进一步阅读

这里有一些书籍和链接,您可以考虑阅读,以收集更多关于本章信息:

第二部分:在基于云的环境中进行软件解决方案的架构设计

本节向您介绍了主要现代云平台中包含的工具。重点是 Microsoft Azure,它提供了最灵活和多样化的服务。

第四章,决定最佳云解决方案,是对云和 Azure 的一般介绍。在那里,您可以找到所有相关的云概念,以及 Azure 整体服务的描述,还有示例说明您如何配置云中的资源以满足您的需求。第五章,将微服务架构应用于企业应用,描述了微服务计算模型,这是在云中实现灵活性、高吞吐量和可靠性的最有效方法。在那里,您还可以了解容器和 Docker,这将使您能够在架构中混合不同的技术,并使您的软件平台独立。

第六章,在 C#中使用 Entity Framework Core 与数据交互,以及第七章,如何在云中选择您的数据存储,描述了 Azure 的主要存储服务以及如何使用它们。在那里,您可以学习以下内容:

  • 如何为您的架构中的每个子系统选择最佳的存储解决方案

  • 如何配置存储解决方案以实现所需的读写并行性

  • 如何将这些内容整合到您的软件中

最后,在第八章,使用 Azure Functions中,您将了解所有主要云服务中包含的计算无服务器模型,并了解如何在 Azure 云中使用它。得益于无服务器,您可以在需要时运行计算,而不必预先分配云资源,并且只需为实际的计算时间付费。

本节包括以下章节:

  • 第四章,决定最佳云解决方案

  • 第五章,将微服务架构应用于企业应用

  • 第六章,在 C#中使用 Entity Framework Core 与数据交互

  • 第七章,如何在云中选择您的数据存储

  • 第八章,使用 Azure Functions

第四章:决定最佳云解决方案

为了设计你的应用程序使其基于云,你必须了解不同的架构设计——从最简单的到最复杂的。本章讨论了不同的软件架构模型,并教你如何利用云在解决方案中提供的机会。本章还将讨论我们在开发基础设施时可以考虑的不同类型的云服务,理想的场景是什么,以及我们可以在哪里使用它们。

本章将涵盖以下主题:

  • 基础设施即服务解决方案

  • 平台即服务解决方案

  • 软件即服务解决方案

  • 无服务器解决方案

  • 如何使用混合解决方案以及为什么它们如此有用

技术要求

对于本章的实践内容,你必须创建或使用一个 Azure 账户。我在第一章,理解软件架构的重要性,的创建 Azure 账户部分解释了账户创建过程。

不同的软件部署模型

云解决方案可以采用不同的模型进行部署。你选择如何部署应用程序取决于你合作的团队类型。在拥有基础设施工程师的公司,你可能会发现更多的人在使用基础设施即服务IaaS)。另一方面,在 IT 不是核心业务的公司,你会看到许多软件即服务SaaS)系统。开发者决定使用平台即服务PaaS)选项,或者无服务器部署,因为在这样的场景下他们不需要提供基础设施,这是非常常见的。

作为一名软件架构师,你必须应对这个环境,并确保你在解决方案的初始开发阶段以及维护阶段都在优化成本和工作因素。此外,作为架构师,你必须了解你系统的需求,并努力将这些需求与一流的周边解决方案相连接,以加快交付速度,并使解决方案尽可能接近客户的需求。

基础设施即服务与 Azure 机会

IaaS 是许多不同云服务提供商提供的云服务的第一代。它的定义在许多地方都可以找到,但我们可以将其总结为“你的计算基础设施通过互联网提供”。就像我们在本地数据中心中有服务的虚拟化一样,IaaS 也会为你提供虚拟化组件,如云中的服务器、存储和防火墙。

在 Azure 中,提供了多种以 IaaS 模型提供的服务。其中大部分是付费的,当进行测试时您应该注意这一点。值得一提的是,本书并不旨在详细描述 Azure 提供的所有 IaaS 服务。然而,作为一名软件架构师,您只需了解您将找到以下这样的服务:

  • 虚拟机:Windows Server、Linux、Oracle 和数据分析 - 机器学习

  • 网络:虚拟网络、负载均衡器和 DNS 区域。

  • 存储:文件、表、数据库和 Redis。

执行以下步骤以在 Azure 中创建任何服务:

  1. 您必须找到最适合您需求的服务,然后创建一个资源。以下截图显示了一个 Windows Server 虚拟机的配置过程:

  1. 按照 Azure 提供的向导设置您的虚拟机,然后使用远程桌面协议RDP)连接到它。这种订阅的一个大好奇点是您在几分钟内可以拥有的硬件容量。以下截图展示了这一点:

如果您将本地交付硬件的速度与云速度进行比较,您将意识到在上市时间方面,没有比云更好的选择。例如,截图底部展示的 D64s_v3 机器,拥有 64 个 CPU、256 GB 的 RAM 和 512 GB 的临时存储,这可能是您在本地数据中心中找不到的东西。此外,在某些用例中,这台机器在整个月份中可能只会使用几个小时,因此在本地场景中购买它的理由将是不成立的。这就是为什么云计算如此神奇!

IaaS 中的安全责任

安全责任是了解 IaaS 平台时需要知道的另一件重要事情。许多人认为一旦决定上云,所有的安全都是由提供商完成的。然而,正如以下截图所示,这并不正确:

IaaS 将迫使您从操作系统到应用程序的每个层面都关注安全性。在某些情况下,这是不可避免的,但您必须理解这将增加您的系统成本。

如果您只想将现有的本地结构迁移到云端,IaaS 可以是一个不错的选择。这得益于 Azure 提供的工具以及所有其他服务,从而实现了可扩展性。然而,如果您计划从头开发应用程序,您也应该考虑 Azure 上可用的其他选项。

让我们在下一节检查其中一个最快的系统,那就是 PaaS。

PaaS – 为开发者提供无限机会

如果您正在学习或已经学习过软件架构,您可能会完美理解下一句话的含义:世界在软件开发方面需要高速!如果您同意这一点,您会喜欢 PaaS。

正如您在前面的屏幕截图中看到的,PaaS 允许您仅在接近您业务的角度考虑安全问题:您的数据和应用程序。作为一名开发者,这意味着您不必实施大量配置,以确保您的解决方案安全运行。

此外,安全性处理并不是 PaaS 的唯一优势。作为一名软件架构师,您可以将这些服务作为快速提供更丰富解决方案的机会。上市时间可以肯定地证明许多基于 PaaS 的应用程序的成本是合理的。

现在,Azure 中提供了许多作为 PaaS 提供的服务,而且,这本书的目的并不是列出所有这些服务。然而,其中一些确实需要提及。这个列表还在不断增长,这里的建议是:尽可能多地使用和测试服务!确保您会考虑到这一点,提供更好的设计解决方案。

另一方面,值得一提的是,使用 PaaS 解决方案,您将无法完全控制操作系统。实际上,在许多情况下,您甚至没有连接到它的方法。这大多数时候是好的,但在某些调试情况下,您可能会错过这个功能。好消息是,PaaS 组件每天都在发展,微软最大的担忧之一是使它们广为人知。

以下部分介绍了 Microsoft 为 .NET Core Web 应用程序提供的最常见 PaaS 组件,例如 Azure Web 应用程序和 Azure SQL Server。我们还描述了 Azure 认知服务,这是一个非常强大的 PaaS 平台,展示了在 PaaS 世界中开发是多么美妙。我们将在本书的剩余部分更深入地探讨其中的一些。

Web 应用程序

Web 应用程序是一个 PaaS 选项,您可以使用它来部署您的 Web 应用程序。您可以部署不同类型的应用程序,例如 .NET、.NET Core、Java、PHP、Node JS 和 Python。这种类型的示例在第一章中有所介绍,理解软件架构的重要性

好处在于创建 Web 应用程序不需要任何结构,也不需要 IIS Web 服务器设置。在某些情况下,如果您使用 Linux 来托管您的 .NET Core 应用程序,您甚至根本就没有 IIS。

此外,Web 应用程序有一个计划选项,您无需为使用付费。当然,有一些限制,例如只能运行 32 位应用程序,并且无法启用可伸缩性,但这对原型设计来说可以是一个美好的场景。

Azure SQL Server

想象一下,如果你不需要为部署这个数据库支付大服务器的费用,就能利用 SQL Server 的全部功能来快速部署解决方案会是什么样子。这适用于 Azure SQL Server。使用 Azure SQL Server,你有机会使用 Microsoft SQL Server 来完成你最需要的事情——存储和数据处理。在这种情况下,Azure 负责备份数据库。

Azure SQL Server 甚至提供了自动管理性能的选项。这被称为自动调优。同样,使用 PaaS 组件,你将能够专注于对你业务真正重要的事情:非常快的上市时间。

创建 Azure SQL Server 数据库的步骤相当简单,就像我们之前检查的其他组件一样。然而,有两件事你需要注意:服务器的创建以及你将如何被收费。

当你在 Azure SQL Server 中搜索创建数据库时,你会找到这个向导来帮助你:

图片

正如你所见,你必须创建(至少对于第一个数据库)一个 database.windows.net 服务器,你的数据库将托管在这里。这个服务器将提供你使用当前工具(如 Visual Studio 或 SQL Server Management Studio)访问 SQL Server 数据库所需的所有参数。值得一提的是,你有一系列关于安全性的功能,例如透明加密和 IP 防火墙。

一旦你决定了你的数据库服务器的名称,你将能够选择你的系统将被收取费用的定价层。特别是在 Azure SQL Server 数据库中,有几种不同的定价选项,如以下截图所示。你应该仔细研究每一个,因为根据你的场景,通过优化定价层你可能能节省一些钱:

图片

关于 SQL 配置的更多信息,你可以查看这个链接:azure.microsoft.com/en-us/services/sql-database/.

一旦完成配置,你将能够以与你的 SQL Server 安装在本地时相同的方式连接到这个服务器数据库。你唯一需要关注的是 Azure SQL Server 防火墙的配置,但这设置起来相当简单,也是 PaaS 服务安全性的良好证明。

Azure 认知服务

人工智能AI)是软件架构中最常讨论的话题之一。我们离一个真正伟大的世界只有一步之遥,在那里人工智能将无处不在。为了使这句话成为现实,作为一名软件架构师,你不能把人工智能看作是每次都需要从头开始重新发明的软件。

Azure 认知服务可以帮助您实现这一点。在这个 API 集合中,您将找到开发视觉、知识、语音、搜索和语言解决方案的各种方法。其中一些需要训练才能使事物发生,但这些服务也提供了相应的 API。

从这个场景中可以看出 PaaS 的好处。在本地或 IaaS 环境中准备应用程序时,您需要执行的工作数量是巨大的。在 PaaS 中,您根本无需担心这一点。您完全专注于作为软件架构师真正关心的事情:解决您的业务问题。

在您的 Azure 账户中设置 Azure 认知服务也非常简单:

  1. 首先,您需要像添加任何其他 Azure 组件一样添加认知服务,如下面的截图所示:

示例图片

  1. 一旦完成这些,您将能够使用服务器提供的 API。您将在您创建的服务中找到两个重要功能:端点和访问密钥。它们将在您的代码中用于访问 API:

示例图片

以下代码示例展示了如何使用认知服务来翻译句子。这个翻译服务背后的主要概念是您可以按照服务设置的键和区域发布您想要翻译的句子。以下代码使您能够向服务 API 发送请求:

private static async Task<string> PostAPI(string api, string key, string region,
    string textToTranslate)
{
    string result = String.Empty;
    using (var client = new HttpClient())
    {
      using (var request = new HttpRequestMessage(HttpMethod.Post, api))
      {
        request.Headers.Add("Ocp-Apim-Subscription-Key", key);
        request.Headers.Add("Ocp-Apim-Subscription-Region", region);

        // five seconds for timeout
        client.Timeout = new TimeSpan(0, 0, 5);
        var body = new object[] { new { Text = textToTranslate } };
        var requestBody = JsonConvert.SerializeObject(body); 

        request.Content = new StringContent(requestBody, Encoding.UTF8,
          "application/json");

        var response = await client.SendAsync(request);

        if (response.IsSuccessStatusCode)
          result = await response.Content.ReadAsStringAsync();
      }
    }
    return result;
}

值得注意的是,前面的代码将允许您根据在参数中定义的键和区域将任何文本翻译成任何语言。以下是一个调用前面方法的程序示例:

/// <summary>
/// Check this content at: https://docs.microsoft.com/en-us/azure/cognitive
/// services/translator/reference/v3-0-reference
/// </summary>
static void 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]";

    var translatedSentence = PostAPI(host + route, subscriptionKey, region, 
      "Hello World!").Result;
    Console.WriteLine(translatedSentence);
}

这是一个如何轻松快速地使用此类服务来构建项目的完美示例。此外,这种开发方法非常好,因为您正在使用其他解决方案已经测试并使用过的代码片段。

SaaS – 只需登录即可开始!

软件即服务可能是使用基于云的服务最简单的方式。云服务提供商为他们的最终用户提供了许多解决公司常见问题的良好选项。

这种类型服务的良好例子是 Office 365。这些平台的关键点在于您无需担心应用程序的维护。在您的团队完全专注于开发应用程序的核心业务场景中,这一点尤其方便。例如,如果您的解决方案需要提供良好的报告,您可能可以使用包含在 Office 365 中的 Power BI 来设计它们。

另一个很好的 SaaS 平台例子是 Azure DevOps。作为软件架构师,在 Azure DevOps 或 Visual Studio Team Services (VSTS) 之前,您需要安装和配置 Team Foundation Server(甚至更老的类似工具)以使您的团队能够使用一个共同的仓库和应用生命周期管理工具。

我们过去花费大量时间要么在准备服务器以安装团队基础服务器TFS),要么在升级和维护已安装的 TFS。由于 SaaS Azure DevOps 的简单性,这不再需要。

理解无服务器意味着什么

这个词本身就说明了问题:无服务器解决方案意味着没有服务器的解决方案。但在云架构中这怎么可能呢?这很简单。在这种解决方案中,您不需要担心与服务器相关的任何问题。

您现在可能正在想无服务器只是另一个选项——当然,这是真的,因为这个架构并没有提供一个完整的解决方案。但关键点在于,在无服务器解决方案中,您拥有一个非常快速、简单和敏捷的应用生命周期,因为所有无服务器代码都是无状态的,并且与系统其余部分松散耦合。一些作者将其称为函数即服务FaaS)。

当然,服务器在某处运行。关键点在于您不需要担心这一点,甚至不需要担心可扩展性。这将使您能够完全专注于您的应用程序业务逻辑。再次强调,世界需要快速发展和良好的客户体验。您越关注客户需求,效果就越好!

在第八章“使用 Azure Functions”,您将探索微软在 Azure 中提供的最佳无服务器实现之一——Azure Functions。在那里,我们将关注您如何开发无服务器解决方案,并了解它们的优缺点。

为什么混合应用在很多情况下如此有用?

混合解决方案是那些部分不共享统一架构选择的解决方案;每个部分都做出不同的架构选择。在云中,混合一词主要指将云子系统与本地子系统混合的解决方案。然而,它也可以指将 Web 子系统与特定设备子系统混合。

由于 Azure 可以提供的服务数量以及可以实施的设计架构数量,混合应用可能是本章解决的主要问题的最佳答案,即如何利用云在您的项目中提供的机会。如今,许多当前的项目正从本地解决方案迁移到云架构,并且根据您将交付这些项目的位置,您仍会发现许多关于迁移到云的负面观念。其中大部分与成本、安全和服务的可用性有关。

您需要理解这些先入为主的观念中确实有一些是真实的,但并非人们所想的那样。当然,作为软件架构师,您不能忽视它们。尤其是在开发关键系统时,您必须决定是否所有内容都可以放在云端,或者是否最好将系统的一部分部署在边缘。

移动解决方案可以被视为混合应用程序的典型例子,因为它们将基于 Web 的架构与基于设备的架构相结合,以提供更好的用户体验。有许多场景可以将移动应用程序替换为响应式网站。然而,当涉及到界面质量和性能时,可能响应式网站并不能满足最终用户真正的需求。

在下一节中,我们将讨论本书用例的实际示例。

用例 - 混合应用程序

如果您回到第一章,理解软件架构的重要性,您将找到一个系统需求,描述了我们的 WWTravelClub 示例应用程序应该运行的系统环境:

SR_003:系统应在 Windows、Linux、iOS 和 Android 平台上运行。

初看之下,任何开发者都会回答说:Web 应用程序。然而,iOS 和 Android 平台也需要作为软件架构师引起您的注意。在这种情况下,就像在许多情况下一样,用户体验是项目成功的关键。决策不仅需要由开发速度驱动,而且还需要由提供卓越用户体验所获得的收益驱动。

在这个项目中,软件架构师必须做出的另一个决定与移动应用程序的技术有关,如果他们决定开发一个。同样,这将是混合应用程序和本地应用程序之间的选择,因为在这种情况下,可以使用像 Xamarin 这样的混合解决方案。因此,在移动应用程序方面,您也有选择继续用 C#编写代码的选项。

以下截图展示了我们对 WWTravelClub 架构的第一选择。选择依赖 Azure 组件的决定与成本和维护考虑有关。以下各项将在本书的后续章节中讨论,包括第六章,在 C#中与数据交互 - Entity Framework Core,第七章,如何在云中选择您的数据存储,以及第八章,与 Azure Functions 一起工作,以及选择的原因。目前,只需知道 WWTravelClub 是一个混合应用程序,在移动设备上运行 Xamarin Apps,在服务器端运行.NET Core Web 应用程序即可:

图片

将会有一个 Azure SQL Server 数据库通过 Entity Framework Core 连接到 Web 应用程序,这将在第六章,“在 C# 中与数据交互 - Entity Framework Core”中进行讨论。稍后,在第七章 7,“如何在云中选择您的数据存储”,我们将出于性能和成本考虑添加 NoSQL 数据库。对于图片存储,选择了文件存储。最后,Xamarin 应用程序将通过 Azure Functions 从系统中获取信息。

书籍用例 - 对于这个用例来说,哪个云平台是最好的?

正如你在最后一节的屏幕截图中可以验证的那样,WWTravelClub 架构主要是使用 Azure 提供的 Platform as a Service 和无服务器组件设计的。所有开发工作都将在 Azure DevOps SaaS Microsoft 平台上进行。

在我们 WWTravelClub 的假设场景中,赞助商指出 WWTravelClub 团队中没有人在基础设施方面有专长。这就是为什么软件架构使用了 PaaS 服务。考虑到这个场景和所需的发展速度,这些组件肯定会表现良好。

当我们飞越这本书中讨论的章节和技术时,这个架构将不断变化和演变,而不会受到任何早期选择的限制。这是 Azure 和现代架构设计提供的一个绝佳机会。你可以轻松地根据解决方案的发展更改组件和结构。

摘要

在本章中,你学习了如何在你的解决方案中利用云提供的服务,以及你可以选择的多种选项。

本章介绍了在云结构中交付相同应用程序的不同方式。我们还注意到微软是如何迅速将这些选项提供给其客户的,因为你可以将这些选项在实际应用程序中体验,并选择最适合你需求的选项,因为没有一种在所有情况下都适用的“银弹”。作为软件架构师,你需要分析你的环境和你的团队,然后决定在你的解决方案中实施的最佳云架构。

下一章将专门介绍如何构建由称为微服务的可扩展软件模块组成的灵活架构。

问题

  1. 为什么你应该在你的解决方案中使用 IaaS?

  2. 为什么你应该在你的解决方案中使用 PaaS?

  3. 为什么你应该在你的解决方案中使用 SaaS?

  4. 为什么你应该在你的解决方案中使用无服务器?

  5. 使用 Azure SQL Server 数据库的优势是什么?

  6. 你如何使用 Azure 加速应用程序中的 AI?

  7. 混合架构如何帮助你设计更好的解决方案?

进一步阅读

你可以查看这些网络链接,以决定本章中哪些主题你应该深入研究:

第五章:将微服务架构应用于您的企业应用程序

本章致力于描述基于称为微服务的小模块的高度可扩展架构。微服务架构允许进行细粒度的扩展操作,其中每个单独的模块都可以按需扩展,而不会影响系统的其余部分。此外,它们通过允许每个系统子部分独立于其他部分进行演变和部署,从而允许更好的持续集成/持续部署CI/CD)。

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

  • 什么是微服务?

  • 微服务何时有帮助?

  • .NET Core 如何处理微服务?

  • 管理微服务需要哪些工具?

  • 用例 - 记录微服务

到本章结束时,你将学会如何根据本章的使用案例在 .NET Core 中实现一个微服务。

技术要求

在本章中,你需要以下内容:

  • Visual Studio 2017 或 2019 的免费社区版或更高版本,并安装了所有数据库工具。

  • 一个免费的 Azure 账户。在第一章的“创建 Azure 账户”部分,理解软件架构的重要性,解释了如何创建一个账户。

  • 用于在 Visual Studio 中调试微服务的 Azure Service Fabric 本地模拟器。它是免费的,可以从www.microsoft.com/web/handlers/webpi.ashx?command=getinstallerredirect&appid=MicrosoftAzure-ServiceFabric-CoreSDK下载。为了避免安装问题,请确保您的 Windows 版本是最新的。此外,模拟器使用 PowerShell 高权限级别命令,默认情况下,这些命令被 PowerShell 块。要启用它们,您需要在 Visual Studio 包管理器控制台或任何 PowerShell 控制台中执行以下命令。Visual Studio 或外部 PowerShell 控制台必须以 管理员 身份启动,以下命令才能成功:

Set-ExecutionPolicy -ExecutionPolicy Unrestricted -Force -Scope CurrentUser

什么是微服务?

微服务架构允许每个构成解决方案的模块独立于其他模块进行扩展,以实现最大吞吐量并最小化成本。实际上,扩展整个系统而不是其当前的瓶颈不可避免地会导致资源的巨大浪费,因此对子系统扩展的精细控制对系统的整体成本有重大影响。

然而,微服务不仅仅是可扩展的组件——它们是可以在彼此独立开发、维护和部署的软件构建块。将开发和维护分散到可以独立开发、维护和部署的模块中,可以改善整个系统的 CI/CD 循环(CI/CD 概念在第三章的使用 Azure DevOps 组织工作部分中进行了更详细的解释,使用 Azure DevOps 记录需求)。

CI/CD 的改进归功于微服务的独立性,因为它使得以下成为可能:

  • 在不同类型的硬件上扩展和分发微服务。

  • 由于每个微服务都是独立于其他微服务部署的,因此不存在二进制兼容性或数据库结构兼容性约束。因此,没有必要对组成系统的不同微服务的版本进行对齐。这意味着它们中的每一个都可以根据需要独立演进,而不会受到其他微服务的限制。

  • 将其开发分配给完全独立的较小团队,从而简化工作组织并减少处理大型团队时产生的所有不可避免的协调低效率。

  • 使用更合适的技术和更合适的环境来实现每个微服务,因为每个微服务都是一个独立的部署单元。这意味着选择最适合您需求的工具和最小化开发努力/最大化性能的环境。

  • 由于每个微服务可以使用不同的技术、编程语言、工具和操作系统来实现,企业可以通过将环境与开发者的技能相匹配来利用所有可用的人力资源。例如,未充分利用的 Java 开发者也可以参与 .NET 项目,如果他们使用 Java 实现具有相同所需行为的微服务。

  • 历史子系统可以被嵌入到独立的微服务中,从而使得它们能够与较新的子系统进行协作。这样,公司可以缩短新系统版本的上市时间。此外,通过这种方式,历史系统可以缓慢地向更现代的系统演进,同时对成本和组织的影响是可接受的。

下一个子节将解释微服务概念是如何产生的。然后,我们将继续本介绍性章节,探讨基本微服务设计原则,并分析为什么微服务通常被设计为 Docker 容器。

微服务与模块概念的演进

为了更好地理解微服务的优势以及它们的设计技术,我们必须牢记软件模块化和软件模块的双重特性:

  • 代码模块化指的是一种代码组织方式,它使得我们能够轻松地修改代码块,而不会影响到应用程序的其他部分。这通常通过面向对象设计来实现,其中模块可以通过类来识别。

  • 部署模块化取决于你的部署单元及其属性。最简单的部署单元是可执行文件和库。因此,例如,动态链接库DLL)肯定比静态库更模块化,因为它们在部署之前不需要与主可执行文件链接。

虽然代码模块化的基本概念已经达到稳定状态,但部署模块化的概念仍在不断发展,微服务目前是这一发展路径上的尖端技术。

作为对导致微服务的主要里程碑的简要回顾,我们可以这样说,首先,单体可执行文件被分解为静态库。后来,动态链接库取代了静态库。

当.NET(以及其他类似框架,如 Java)改进了可执行文件和库的模块化时,发生了巨大的变化。实际上,由于.NET 在库首次执行时编译为中间语言,因此它们可以在不同的硬件和不同的操作系统上部署。此外,它们克服了先前 DLL 的一些版本问题,因为任何可执行文件都附带一个与操作系统中安装的相同 DLL 版本不同的 DLL。

然而,.NET 无法接受使用不同版本的共同依赖项的两个引用 DLL——比如说,A 和 B。例如,假设有一个 A 的新版本,它包含许多我们希望使用的新功能,而这些功能反过来又依赖于 B 不支持的新版本的 C。在类似的情况下,我们应该放弃 A 的新版本,因为 C 与 B 的不兼容性。这种困难导致了两个重要的变化:

  • 开发世界从 DLL 和/或单个文件转向了包管理系统,如 NuGet 和 npm,这些系统通过语义版本化自动检查版本兼容性。

  • 面向服务的架构SOA)。部署单元开始以 XML 的形式实现,然后是 RESTful Web 服务。这解决了版本兼容性问题,因为每个 Web 服务都在不同的进程中运行,并且可以使用每个库的最合适版本,而不会与其他 Web 服务造成不兼容的风险。此外,每个 Web 服务暴露的接口是平台无关的,也就是说,Web 服务可以使用任何框架与应用程序连接,并在任何操作系统上运行,因为 Web 服务协议基于普遍接受的标准。SOA 和协议将在第十二章中更详细地讨论,使用.NET Core 应用面向服务的架构

微服务是 SOA 的演进,增加了更多特性和约束,这些特性和约束提高了服务的可扩展性和模块化,从而改善了整体的 CI/CD 周期。有时人们会说,微服务是做得好的 SOA

微服务设计原则

总结一下,微服务架构是一种 SOA,它最大化了独立性和细粒度扩展。既然我们已经阐明了微服务独立性和细粒度扩展的所有优势,以及独立的本质,我们现在可以看看微服务设计原则。

让我们从独立约束产生的原则开始:

  • 设计选择的独立性:每个微服务的架构设计不能依赖于其他微服务实现中做出的设计选择。这个原则使得每个微服务的 CI/CD 周期完全独立,并给我们提供了更多关于如何实现每个微服务的科技选择。这样,我们可以选择最佳的技术来实现每个微服务。

    这个原则的另一个后果是,不同的微服务不能连接到同一个共享存储(数据库或文件系统),因为共享相同的存储也意味着共享所有决定了存储子系统结构(数据库表设计、数据库引擎等)的设计选择。因此,要么微服务有自己的数据存储,要么它根本不存储任何数据,并与负责处理存储的其他微服务进行通信。

    在这里,拥有专用数据存储并不意味着物理数据库分布在微服务自身的进程边界内,而是微服务可以独占访问由外部数据库引擎处理的数据库或数据库表集。实际上,出于性能原因,数据库引擎必须在专用硬件上运行,并使用针对其存储功能进行优化的操作系统和硬件功能。通常,设计选择的独立性通过区分逻辑微服务和物理微服务来以更轻的形式解释。更具体地说,逻辑微服务通过使用相同数据存储但独立负载均衡的多个物理微服务来实现。也就是说,逻辑微服务被设计为一个逻辑统一体,然后拆分为更多的物理微服务以实现更好的负载均衡。

  • 独立于部署环境:微服务可以在不同的硬件节点上扩展,并且不同的微服务可以托管在同一节点上。因此,微服务对操作系统提供的服务的依赖性越小,对其他已安装软件的依赖性越小,它可以在更多的硬件节点上部署。还可以进行更多的节点优化。

    这是微服务通常被容器化并使用 Docker 的原因。容器将在本章的“容器和 Docker”小节中更详细地讨论,但基本上,容器化是一种技术,它允许每个微服务携带其依赖项,以便它可以在任何地方运行。

  • 松散耦合:每个微服务必须与其他所有微服务松散耦合。这个原则有两个方面。一方面,这意味着根据面向对象编程原则,每个微服务暴露的接口不应过于具体,而应尽可能通用。然而,这也意味着微服务之间的通信必须最小化,以减少通信成本,因为微服务不共享相同的地址空间,并且在不同的硬件节点上运行。

  • 无链式请求/响应:当一个请求到达微服务时,它必须不会导致对其他微服务的嵌套请求/响应的递归链,因为类似的链会导致不可接受的响应时间。如果所有微服务的私有数据模型在每次更改时都通过推送通知同步,则可以避免链式请求/响应。换句话说,一旦微服务处理的数据发生变化,这些更改就会发送到所有可能需要它们的微服务,以便它们可以处理请求。这样,每个微服务都可以在其私有数据存储中拥有所有它需要的数据,以处理所有传入的请求,无需请求其他微服务提供它所缺少的数据。

    总之,每个微服务都必须包含它为服务传入请求和确保快速响应所需的所有数据。为了保持其数据模型最新并准备好接收传入请求,微服务必须在数据变化发生时立即通知其数据变化。这些数据变化应通过异步消息进行通信,因为同步嵌套消息会导致不可接受的性能,因为它们会阻塞调用树中所有涉及的线程,直到返回结果。

值得指出的是,我们提到的第一个约束实质上是领域驱动设计的边界上下文原则,我们将在第十章“理解软件解决方案中的不同领域”中详细讨论。在本章中,我们将看到,通常,完整的领域驱动设计方法对每个微服务的更新子系统是有用的。

反过来也成立,即根据边界上下文原则开发出的系统,用微服务架构实现会更好。事实上,一旦一个系统被分解为几个完全独立且松散耦合的部分,由于不同的流量和不同的资源需求,这些不同的部分很可能需要独立扩展。

前述约束是构建可重用 SOA 的一些最佳实践。关于这些最佳实践的更多细节将在第十二章“使用.NET Core 应用服务架构”中给出,但如今,大多数 SOA 最佳实践都由用于实现 Web 服务的工具和框架自动执行。

精细粒度扩展要求微服务足够小,以便隔离定义良好的功能,但这同时也需要一个复杂的基础设施,该基础设施负责自动实例化微服务、在节点上分配实例以及按需扩展它们。这类结构将在本章的“需要哪些工具来管理微服务?”部分进行讨论。

此外,通过异步通信进行通信的分布式微服务的精细粒度扩展需要每个微服务都具有弹性。实际上,指向特定微服务实例的通信可能会因为硬件故障或简单的理由(例如,在负载均衡操作期间目标实例被杀死或移动到另一个节点)而失败。

可以通过指数重试来克服暂时性故障。这就是在每个故障后,我们都会以指数级增加的延迟重试相同的操作,直到达到最大尝试次数。例如,首先,我们会在 10 毫秒后重试,如果这次重试操作导致失败,则会在 20 毫秒后进行新的尝试,然后是 40 毫秒,以此类推。

另一方面,长期故障通常会导致重试操作的爆炸性增长,这可能会以类似于拒绝服务攻击的方式耗尽所有系统资源。因此,通常,指数退避与断路器策略一起使用:在给定数量的失败之后,假设发生长期故障,并通过返回一个立即失败而不尝试通信操作来防止在给定时间内访问资源。

此外,某些子系统由于故障或请求峰值而导致的拥塞不应传播到其他系统部分,以防止整体系统拥塞。隔离舱隔离通过以下方式避免拥塞传播:

  • 只允许最大数量的类似同时出站请求,比如说,10 个。这类似于对线程创建设置上限。

  • 超过之前限制的请求将被排队。

  • 如果达到最大队列长度,任何进一步的请求将导致抛出异常以终止它们。

重试策略可能会使得相同的消息被接收和处理多次,因为发送者没有收到消息已被接收的确认,或者简单地因为超时了操作,而接收者实际上已经接收了消息。解决这个问题的唯一可能方法是设计所有消息,使它们是幂等的,也就是说,以这种方式设计消息,即多次处理相同的消息具有与处理一次相同的效果。

例如,将数据库表字段更新为某个值是一个幂等操作,因为重复一次或两次具有完全相同的效果。然而,增加一个十进制字段不是幂等操作。微服务设计者应努力设计尽可能多的幂等消息的整体应用程序。剩余的非幂等消息必须以下列方式或使用其他类似技术转换为幂等消息:

  • 附加一个时间和一些唯一标识符,以唯一标识每条消息。

  • 将所有已接收的消息存储在一个字典中,该字典按前一点提到的消息附加的唯一标识符进行索引。

  • 拒绝旧消息。

  • 当接收到可能重复的消息时,验证它是否包含在字典中。如果是,那么它已经被处理,因此拒绝它。

  • 由于拒绝旧消息,它们可以定期从字典中删除,以避免其指数增长。

我们将在本章末尾的示例中使用这项技术。

在下一小节中,我们将讨论基于 Docker 的微服务容器化。

容器和 Docker

我们已经讨论了拥有不依赖于运行环境的微服务的优势:更好的硬件使用率,能够混合旧软件与较新的模块,能够混合多个开发堆栈以使用每个模块实现的最佳堆栈,等等。通过在每个微服务上部署所有依赖项到私有虚拟机,可以轻松实现对托管环境的独立性。

然而,启动带有其私有操作系统副本的虚拟机需要很长时间,而微服务必须快速启动和停止以减少负载均衡和故障恢复成本。实际上,新的微服务可能被启动是为了替换有故障的微服务,或者是因为它们从一个硬件节点移动到另一个节点以执行负载均衡。此外,将整个操作系统的副本添加到每个微服务实例中将会造成过度的开销。

幸运的是,微服务可以依赖一种更轻量级的技术:容器。容器是一种轻量级的虚拟机。它们不虚拟化整个机器——它们只是虚拟化了位于操作系统内核之上的操作系统(OS)文件系统级别。它们使用宿主机的操作系统(内核、DLL 和驱动程序)并依赖于操作系统的原生功能来隔离进程和资源,以确保为运行的镜像提供一个隔离的环境。

因此,容器与特定的操作系统相关联,但它们不会遭受在每个容器实例中复制和启动整个操作系统的开销。

在每台主机机器上,容器由一个运行时处理,该运行时负责从 镜像 中创建它们并为每个容器创建一个隔离的环境。最著名的容器运行时是 Docker,它是容器化的事实标准

镜像是文件,指定了每个容器中包含的内容以及哪些容器资源,例如通信端口,要暴露在容器外部。没有任何镜像需要明确指定其全部内容,但它们可以引用其他镜像。这样,通过在现有镜像之上添加新的软件和配置信息来构建镜像。

例如,如果您想将 .NET Core 应用程序作为 Docker 镜像部署,只需将您的软件和文件添加到您的 Docker 镜像中,然后引用一个已经存在的 .NET Core Docker 镜像即可。

为了便于图像引用,图像被分组到注册表中,这些注册表可以是公共的或私有的。它们类似于 NuGet 或 npm 注册表。Docker 提供了一个公共注册表(hub.docker.com/_/registry),在那里您可以找到您可能需要在自己的图像中引用的大多数公共图像。然而,每个公司都可以定义私有注册表。例如,Azure 提供了一个私有容器注册表服务:https://azure.microsoft.com/en-us/services/container-registry/

在实例化每个容器之前,Docker 运行时必须解决所有递归引用。这个繁琐的工作不是每次创建新容器时都执行,因为 Docker 运行时有一个缓存,其中存储了与每个输入镜像相对应的完全组装的镜像,并且这些镜像已经被处理。

由于每个应用程序通常由多个模块组成,这些模块需要在不同的容器中运行,因此 Docker 还允许.yml文件,也称为组合文件,这些文件指定以下信息:

  • 部署哪些镜像。

  • 每个镜像暴露的内部资源必须映射到宿主机的物理资源。例如,Docker 镜像暴露的通信端口必须映射到物理机的端口。

我们将在本章的.NET Core 如何处理微服务?部分分析 Docker 镜像和.yml文件。

Docker 运行时在单台机器上处理镜像和容器,但通常,容器化的微服务是在由多台机器组成的集群上部署和负载均衡的。集群由称为编排器的软件组件处理。编排器将在本章的需要哪些工具来管理微服务?部分进行讨论。

现在我们已经了解了微服务是什么,它们可以解决哪些问题,以及它们的基本设计原则,我们准备分析在系统架构中何时以及如何使用它们。下一节将分析何时应该使用它们。

微服务何时有帮助?

这个问题的答案需要我们理解微服务在现代软件架构中扮演的角色。我们将在接下来的小节中探讨这一点。

分层架构和微服务

企业系统通常以逻辑独立的层组织。第一层是与用户交互的层,称为表示层,而最后一层负责存储/检索数据,称为数据层。请求从表示层发起,穿过所有层直到达到数据层,然后返回,反向穿越所有层直到达到表示层,该层负责将结果展示给用户/客户端。层之间不能跳过

每一层从前一层数据,处理它,并将其传递到下一层。然后,它从下一层接收结果,并将其发送回前一层。此外,抛出的异常不能跨越层——每一层都必须负责拦截所有异常,或者以某种方式解决它们,或者将它们转换为其前一层的语言表达的其他异常。层架构确保了每一层的功能完全独立于其他层的功能。

例如,我们可以更改数据库引擎,而不会影响数据层之上的所有层。同样,我们可以完全更改用户界面,即表示层,而不会影响系统的其余部分。

此外,每一层实现不同类型的系统规范。数据层负责系统必须记住的内容,表示层负责系统-用户交互协议,而中间的所有层实现领域规则,这些规则指定数据必须如何处理(例如,如何计算雇员的工资单)。通常,数据和表示层之间仅由一个领域规则层隔开,称为业务层或应用层。

每一层都着不同的语言:数据层着所选存储引擎的语言,业务层着领域专家的语言,而表示层着用户的语言。因此,当数据和异常从一个层传递到另一个层时,它们必须被翻译成目标层的语言。

如何构建分层架构的详细示例将在第十章的用例 - 日志微服务部分给出,该部分专门介绍软件解决方案中的不同领域,其链接为 Chapter 10。

话虽如此,微服务如何适应分层架构?它们是否足够满足所有层的功能,或者只是某些层的功能?单个微服务能否跨越几个层?

最后一个问题最容易回答:是的!实际上,我们早已指出,微服务应该在其逻辑边界内存储它们所需的数据。因此,存在跨越业务和数据层的微服务。还有一些其他微服务负责封装共享数据,并保持在数据层内。因此,我们可能有业务层微服务、数据层微服务和跨越两个层的微服务。那么,表示层呢?

如果表示层在服务器端实现,它也可以适应微服务架构。单页应用和移动应用在客户端机器上运行表示层,因此它们要么直接连接到业务微服务层,要么更常见的是连接到一个API 网关,该网关公开接口并负责将请求路由到正确的微服务。

在微服务架构中,当表示层是一个网站时,它可以由一组微服务实现。然而,如果它需要重型 Web 服务器和/或重型框架,容器化它们可能不方便。这个决定还必须考虑容器化 Web 服务器时可能发生的性能损失,以及可能需要在 Web 服务器和系统其余部分之间设置硬件防火墙的需求。

ASP.NET Core 是一个轻量级框架,它在轻量级的 Kestrel 网络服务器上运行,因此它可以有效地容器化并用于内部应用程序的微服务。然而,公共高流量网站需要专门的硬件/软件组件,这阻止了它们与其他微服务一起部署。实际上,虽然 Kestrel 对于内部网站是一个可接受的解决方案,但公共网站需要一个更完整的网络服务器,如 IIS。在这种情况下,安全要求更加紧迫,需要专门的硬件/软件组件。

单体网站可以很容易地被拆分成负载均衡的较小子站,而不需要特定的微服务技术,但微服务架构可以将微服务的所有优势带入单个 HTML 页面的构建中。更具体地说,不同的微服务可能负责每个 HTML 页面的不同区域。不幸的是,在撰写本文时,使用现有的.NET 和.NET Core 技术实现这样的类似场景并不容易。

一个实现基于 ASP.NET Core 的微服务的概念证明,这些微服务协同构建每个 HTML 页面,可以在以下位置找到:github.com/Particular/Workshop/tree/master/demos/asp-net-core。这种方法的主要限制是微服务仅合作生成生成 HTML 页面所需的数据,而不是生成实际的 HTML 页面。相反,这由单体网关处理。实际上,在撰写本文时,如 ASP.NET Core MVC 之类的框架不提供任何用于 HTML 生成的分发功能。我们将在第十三章,展示 ASP.NET Core MVC中回到这个例子。

现在我们已经明确了系统哪些部分可以从采用微服务中受益,我们准备好陈述在决定如何采用它们时的规则。

在什么情况下值得考虑微服务架构?

微服务可以提高业务和数据层的实现,但它们的采用有一些成本:

  • 将实例分配到节点并对其进行扩展在云费用或内部基础设施和许可证方面都有成本。

  • 将一个独特的过程拆分成更小的通信过程会增加通信成本和硬件需求,尤其是在微服务被容器化的情况下。

  • 为微服务设计和测试软件需要更多时间,并增加了人力资源成本。特别是,使微服务具有弹性并确保它们能够充分处理所有可能的故障,以及通过集成测试验证这些功能,可能会将开发时间增加一个数量级以上。

那么,微服务何时值得使用它们的成本?是否有必须作为微服务实现的函数?

对于第一个问题的粗略答案是:是的,当应用在流量和/或软件复杂度方面足够大时。实际上,随着应用在复杂性和流量方面的增长,我们建议我们承担与扩展相关的成本,因为这允许进行更多的扩展优化,并且在开发团队方面有更好的处理能力。我们为此付出的成本很快就会超过采用微服务的成本。

因此,如果细粒度扩展对我们应用有意义,并且我们能够估计细粒度扩展和开发带来的节省,我们就可以轻松计算出整体应用吞吐量限制,这使得采用微服务变得方便。

微服务成本也可以通过我们产品/服务的市场价值增加来证明。由于微服务架构允许我们使用针对其使用进行优化的技术来实现每个微服务,因此我们软件中增加的质量可能证明所有或部分微服务成本是合理的。

然而,扩展和技术优化并不是唯一需要考虑的参数。有时,我们被迫采用微服务架构,而无法进行详细的成本分析。

如果负责整个系统 CI/CD 的团队规模过大,这个大团队的组织和协调会带来困难和低效。在这种情况下,转向一个将整个 CI/CD 周期分解为独立部分,这些部分可以由较小的团队负责的架构是可取的。

此外,由于这些开发成本只有通过高请求量才能证明是合理的,我们可能处理着由不同团队开发的独立模块的高流量。因此,扩展优化和减少开发团队之间交互的需要使得采用微服务架构非常方便。

从这个结论中,我们可能得出,如果系统和开发团队增长得太大,有必要将开发团队分成更小的团队,每个团队负责一个高效的边界上下文子系统。在类似的情况下,微服务架构可能是唯一可行的选择。

另一种迫使采用微服务架构的情况是将基于不同技术的较新子部分与旧子系统集成,因为容器化微服务是唯一能够实现旧系统与新子部分之间有效交互以逐步用较新的子部分替换旧子部分的方式。同样,如果我们的团队由具有不同开发栈经验的开发者组成,基于容器化微服务的架构可能成为必需的

在下一节中,我们将分析可用的构建块和工具,以便我们可以实现基于.NET Core 的微服务。

.NET Core 如何处理微服务?

.NET Core 被构想为一个轻量级且快速的跨平台框架,足以实现高效的微服务。特别是,ASP.NET Core 是实现 REST API 的理想工具,用于与微服务通信,因为它可以与轻量级网络服务器如 Kestrel 高效运行,并且自身也是轻量级和模块化的。

整个.NET Core 框架都是基于微服务作为战略部署平台而演化的,它提供了构建高效且轻量级 HTTP 通信的设施和包,以确保服务弹性并处理长时间运行的任务。以下小节将描述我们可以使用的一些不同工具或解决方案来实现基于.NET Core 的微服务架构。

.NET Core 通信设施

微服务需要两种类型的通信渠道:

  • 接收外部请求的通信渠道,可以是直接或通过 API 网关。由于可用的网络服务标准和工具,HTTP 是外部通信的常用协议。.NET Core 的主要 HTTP 通信设施是 ASP.NET Core,因为它是一个轻量级的 HTTP 框架,这使得它非常适合在小型微服务中实现 Web API。我们将在第十二章中详细描述 ASP.NET Core 应用程序,使用.NET Core 应用服务导向架构,该章节专门介绍 HTTP 服务。.NET Core 还提供了一个高效且模块化的 HTTP 客户端解决方案,能够池化和重用重量级的连接对象。此外,HttpClient类将在第十二章中更详细地描述,使用.NET Core 应用服务导向架构

  • 一种不同的通信通道类型,用于向其他微服务推送更新。实际上,我们已提到,由于复杂的阻塞调用树会提高请求延迟到不可接受的水平,因此微服务之间的内部通信不能由一个正在进行的请求触发。因此,在它们被使用之前不应立即请求更新,并且应在状态发生变化时推送。理想情况下,这种通信应该是异步的,以实现可接受的性能。实际上,同步调用会在等待结果时阻塞发送者,从而增加每个微服务的空闲时间。然而,如果通信足够快(低通信延迟和高带宽),仅将请求放入处理队列并返回成功通信确认而不是最终结果的同步通信是可以接受的。发布/订阅通信会更可取,因为在这种情况下,发送者和接收者不需要相互了解,从而增加了微服务的独立性。实际上,所有对某种类型的通信感兴趣的接收者只需注册以接收特定的 事件,而发送者只需发布这些事件。所有连接都是由一个负责排队事件并将它们分发给所有订阅者的服务来完成的。发布/订阅模式将在第九章设计模式和 .NET Core 实现中更详细地描述,以及其他有用的模式。

虽然 .NET Core 没有直接提供帮助异步通信的工具,也没有实现发布/订阅通信的客户端/服务器工具,但 Azure 提供了类似的服务,即 Azure Service Bus。Azure Service Bus 通过 Azure Service Bus 队列处理排队异步通信,并通过 Azure Service Bus 主题处理发布/订阅通信。

一旦你在 Azure 门户上配置了 Azure Service Bus,你就可以通过包含在 Microsoft.Azure.ServiceBus NuGet 包中的客户端连接到它,以发送消息/事件并通过接收消息/事件。

Azure Service Bus 有两种通信类型:基于队列和基于主题。在基于队列的通信中,每个发送者放入队列的消息将由第一个从队列中拉取它的接收者移除。另一方面,基于主题的通信是发布/订阅模式的实现。每个主题都有多个订阅,并且每个发送到主题的消息的不同副本都可以从每个主题订阅中拉取。

设计流程如下:

  1. 定义一个 Azure Service Bus 私有命名空间。

  2. 获取由 Azure 门户创建的根连接字符串,或定义具有较少权限的新连接字符串。

  3. 定义发送者将发送其消息的二进制格式的队列和/或主题。

  4. 对于每个主题,为所有必需的订阅定义名称。

  5. 在基于队列的通信情况下,发送者向队列发送消息,接收者从同一个队列中拉取消息。每条消息只被发送给一个接收者。也就是说,一旦接收者获得对队列的访问权限,它就会读取并移除一条或多条消息。

  6. 在基于主题的通信情况下,每个发送者向一个主题发送消息,而每个接收者从与该主题关联的私有订阅中拉取消息。

还有其他商业替代方案,如 NServiceBus、MassTransit、Brighter 和 ActiveMQ。还有一个免费的开源选项:RabbitMQ。RabbitMQ 可以安装在本地、虚拟机或 Docker 容器中。然后,你可以通过包含在RabbitMQ.Client NuGet 包中的客户端与之连接。

RabbitMQ 的功能与 Azure Service Bus 提供的功能类似,但你必须注意所有实现细节、执行操作的确认等,而 Azure Service Bus 则负责所有底层任务并提供一个更简单的接口。Azure Service Bus 和 RabbitMQ 将与基于发布/订阅的通信一起在第九章,“设计模式和.NET Core 实现”中描述。

如果微服务发布到将在下一节中描述的 Azure Service Fabric,我们可以使用内置的可靠二进制通信。由于通信原语自动使用重试策略,因此通信是弹性的。这种通信是同步的,但这不是一个大限制,因为 Azure Service Fabric 中的微服务具有内置队列;因此,一旦接收者收到消息,它们只需将其放入队列并立即返回,而不会阻塞发送者。

队列中的消息随后由一个单独的线程进行处理。这种内置通信的主要局限性在于它不是基于发布/订阅模式;发送者和接收者必须相互了解。当这不可接受时,你应该使用 Azure Service Bus。我们将在本章的“用例 - 日志微服务”部分学习如何使用 Service Fabric 的内置通信。

弹性任务执行

使用由.NET Foundation 维护的名为 Polly 的.NET Core 库,可以轻松实现弹性通信和一般性的弹性任务执行。Polly 可以通过 Polly NuGet 包获取。

在 Polly 中,你定义策略,然后在该策略的上下文中执行任务,如下所示:

var myPolicy = Policy
  .Handle<HttpRequestException>()
  .Or<OperationCanceledException>()
  .Retry(3);
....
....
myPolicy.Execute(()=>{
    //your code here
});

每个策略的第一部分指定必须处理的异常。然后,您指定在捕获到这些异常之一时执行的操作。在前面的代码中,如果报告失败是由 HttpRequestException 异常或 OperationCanceledException 异常引起的,则 Execute 方法将重试最多三次。

以下是一个指数重试策略的实现:

var erPolicy= Policy
    ...
    //Exceptions to handle here
    .WaitAndRetry(6, 
        retryAttempt => TimeSpan.FromSeconds(Math.Pow(2,
            retryAttempt)));

WaitAndRetry 的第一个参数指定在失败的情况下最多执行六次重试。作为第二个参数传递的 lambda 函数指定在下次尝试之前等待的时间。在具体示例中,这个时间随着尝试次数的指数增长,以 2 的幂次(第一次重试为 2 秒,第二次重试为 4 秒,依此类推)。

以下是一个简单的断路器策略:

var cbPolicy=Policy
    .Handle<SomeExceptionType>()
    .CircuitBreaker(6, TimeSpan.FromMinutes(1));

在连续失败六次后,由于返回异常,任务将无法执行 1 分钟。

以下是 Bulkhead 隔离策略的实现(有关更多信息,请参阅 微服务设计原则 部分):

Policy
  .Bulkhead(10, 15)

Execute 方法中允许最多 10 个并行执行。进一步的任务将被插入到执行队列中。这个队列的容量限制为 15 个任务。如果队列限制被超过,将抛出异常。

为了使 Bulkhead 策略正常工作,以及通常为了使每个策略正常工作,任务执行必须通过相同的策略实例触发;否则,Polly 无法计算特定任务的活跃执行次数。

可以将策略与 Wrap 方法结合使用:

var combinedPolicy = Policy
  .Wrap(erPolicy, cbPolicy);

Polly 提供了更多选项,例如针对返回特定类型的任务的泛型方法、超时策略、任务结果缓存、定义自定义策略的能力等。官方 Polly 文档的链接在 进一步阅读 部分中。

使用泛型托管器

每个微服务可能需要运行几个独立的线程,每个线程对收到的请求执行不同的操作。这些线程需要几个资源,例如数据库连接、通信通道、执行复杂操作的专业模块等。此外,所有处理线程必须在微服务启动时得到适当的初始化,并在微服务由于负载均衡或错误而停止时优雅地停止。

所有这些需求促使 .NET Core 团队构思并实现了 托管服务托管器。托管器为运行多个任务(称为 托管服务)提供了一个适当的环境,并为它们提供资源、通用设置和优雅的启动/停止。

网站托管的概念最初主要是为了实现 ASP.NET Core 网络框架,但从 .NET Core 2.1 开始,托管概念被扩展到所有 .NET 应用程序。与“托管”概念相关的所有功能都包含在 Microsoft.Extensions.Hosting NuGet 包中。

首先,你需要使用流畅接口配置主机,从 HostBuilder 实例开始。此配置的最终步骤是调用 Build 方法,该方法将所有提供的配置信息组装成实际的主机:

var myHost=new HostBuilder()
    //Several chained calls
    //defining Host configuration
    .Build();

主机配置包括定义常用资源,定义文件默认文件夹,从多个来源(JSON 文件、环境变量以及传递给应用程序的任何参数)加载配置参数,并声明所有托管服务。

然后,可以启动主机,这将导致所有托管服务启动:

host.Start();

程序在上一条指令上保持阻塞,直到主机关闭。主机可以通过托管服务之一或通过调用 await host.StopAsync(timeout) 外部关闭。在这里,timeout 是一个时间跨度,定义了等待托管服务优雅停止的最大时间。在此时间之后,如果托管服务尚未终止,则所有托管服务都将被终止。

通常,微服务正在关闭的事实是通过在协调器启动微服务时传递 CancelationToken 来表示的。这发生在微服务托管在 Azure Service Fabric 中时。

在这种情况下,我们不必使用 host.Start(),而是可以使用 RunAsync 方法,并传递我们从协调器那里接收到的 CancelationToken

await host.RunAsync(cancelationToken)

这种关闭方式在 cancelationToken 进入已取消状态时立即触发。默认情况下,主机关闭超时为 5 秒;也就是说,一旦请求关闭,它将等待 5 秒后才退出。这个时间可以在 ConfigureServices 方法中更改,该方法用于声明 托管服务 和其他资源:

var myHost = new HostBuilder()
    .ConfigureServices((hostContext, services) =>
    {
        services.Configure<HostOptions>(option =>
        {
            option.ShutdownTimeout = System.TimeSpan.FromSeconds(10);
        });
        ....
        ....
        //further configuration
    })
    .Build();

然而,增加主机超时并不会增加协调器超时,所以如果主机等待时间过长,整个微服务将被协调器杀死。

托管服务是实现 IHostedService 接口的实现,其唯一的方法是 StartAsync(CancellationToken)StopAsync(CancellationToken)。这两个方法都传递了一个 CancelationToken。在 StartAsync 方法中,CancelationToken 表示请求关闭。StartAsync 方法在执行启动主机所需的所有操作时,会定期检查这个 CancelationToken,如果它被触发,则主机启动过程将被终止。另一方面,在 StopAsync 方法中,CancelationToken 表示关闭超时已过期。

托管服务必须在用于定义主机选项的相同 ConfigureServices 方法中声明,如下所示:

services.AddHostedService<MyHostedService>();

ConfigureServices 中的大多数声明都需要添加以下命名空间:

using Microsoft.Extensions.DependencyInjection;

通常,IHostedService 接口不是直接实现的,而是可以继承自 BackgroundService 抽象类,该类公开了更容易实现的 ExecuteAsync(CancellationToken) 方法,我们可以在其中放置整个服务的逻辑。通过传递 CancellationToken 作为参数来表示关闭信号,这更容易处理。我们将在本章末尾的示例中查看 IHostedService 的实现。

为了允许托管服务关闭宿主,我们需要将其构造函数参数声明为 IApplicationLifetime 接口:

public class MyHostedService: BackgroundService 
{
    private applicationLifetime;
    public MyHostedService(IApplicationLifetime applicationLifetime)
    {
        this.applicationLifetime=applicationLifetime;
    }
    protected Task ExecuteAsync(CancellationToken token) 
    {
        ...
        applicationLifetime.StopApplication();
        ...
    }
}

当托管服务被创建时,它将自动传递一个 IApplicationLifetime 的实现,其 StopApplication 方法将触发宿主关闭。这种实现是自动处理的,但我们可以声明自定义资源,其实例将被自动传递到所有声明它们为参数的宿主服务构造函数。定义这些资源有几种方式:

services.AddTransient<MyResource>();
services.AddTransient<IResourceInterface, MyResource>();
services.AddSingleton<MyResource>();
services.AddSingleton<IResourceInterface, MyResource>();

当我们使用 AddTransient 时,将创建一个不同的实例并将其传递给所有需要该类型实例的构造函数。另一方面,使用 AddSingleton 时,将创建一个唯一的实例并将其传递给所有需要声明类型的构造函数。具有两个泛型类型的重载允许你传递一个接口及其实现该接口的类型。这样,构造函数需要接口,并且与该接口的具体实现解耦。

如果资源构造函数包含参数,它们将以递归方式自动使用在 ConfigureServices 中声明的类型进行实例化。这种与资源交互的模式称为 依赖注入DI),将在第九章 设计模式和 .NET Core 实现 中详细讨论。

HostBuilder 还有一个我们可以用来定义默认文件夹的方法:

.UseContentRoot("c:\\<deault path>")

它还提供了我们可以用来添加日志目标的方法:

.ConfigureLogging((hostContext, configLogging) =>
    {
        configLogging.AddConsole();
        configLogging.AddDebug();
    })

上述示例展示了基于控制台的日志源,但我们可以使用适当的提供程序将日志记录到 Azure 目标。进一步阅读 部分包含了一些可以与在 Azure Service Fabric 中部署的微服务一起工作的 Azure 日志提供程序的链接。一旦配置了日志,你就可以通过在它们的构造函数中添加 ILoggerFactory 参数来启用你的托管服务并记录自定义消息。

最后,HostBuilder 有我们可以用来从各种来源读取配置参数的方法:

.ConfigureHostConfiguration(configHost =>
    {
        configHost.AddJsonFile("settings.json", optional: true);
        configHost.AddEnvironmentVariables(prefix: "PREFIX_");
        configHost.AddCommandLine(args);
    })

在第十三章 展示 ASP.NET Core MVC 中,我们将更详细地解释如何在应用程序内部使用参数,该章节专门介绍 ASP.NET Core。

Visual Studio 对 Docker 的支持

Visual Studio 提供了创建、调试和部署 Docker 镜像的支持。Docker 部署需要我们在开发机器上安装Windows Docker CE,这样我们才能运行 Docker 镜像。下载链接可以在本章开头的技术要求部分找到。在我们开始任何开发活动之前,我们必须确保它已安装并正在运行(当 Docker 运行时,你应该在窗口通知栏中看到 Docker 图标)。

Docker 支持将通过一个简单的 ASP.NET Core MVC 项目进行描述。让我们创建一个。为此,请按照以下步骤操作:

  1. 将项目命名为MvcDockerTest

  2. 为了简单起见,禁用身份验证。

  3. 当你创建项目时,你可以选择添加 Docker 支持,但请不要勾选 Docker 支持复选框。你可以在项目创建后测试如何将 Docker 支持添加到任何项目中。

一旦你的 ASP.NET Core MVC 应用程序已经搭建并运行,在解决方案资源管理器中右键单击其项目图标,选择容器编排器支持 | Docker Compose。这将不仅启用 Docker 镜像的创建,还会创建一个 Docker Compose 项目,这有助于你配置 Docker Compose 文件,以便它们可以同时运行和部署多个 Docker 镜像。实际上,如果你将另一个 MVC 项目添加到解决方案中并为它启用容器编排器支持,新的 Docker 镜像将被添加到同一个 Docker Compose 文件中。

启用 Docker Compose 而不是仅仅docker的优势在于,你可以通过编辑添加到解决方案中的 Docker Compose 文件来手动配置镜像在开发机器上的运行方式,以及 Docker 镜像端口如何映射到外部端口。

如果你的 Docker 运行时已正确安装并正在运行,你应该能够从 Visual Studio 运行 Docker 镜像。

让我们分析由 Visual Studio 创建的 Docker 文件。它是一系列创建镜像的步骤。每个步骤都通过From指令(它是对已存在镜像的引用)帮助丰富现有的镜像。以下是最初的步骤:

FROM microsoft/dotnet:x.x-aspnetcore-runtime AS base
WORKDIR /app
EXPOSE 80
EXPOSE 443

第一步使用了由 Microsoft 在 Docker 公共仓库中发布的microsoft/dotnet:x.x-aspnetcore-runtime ASP.NET Core 运行时(其中x.x是你在项目中选择的 ASP.NET Core 版本)。

WORKDIR命令在将要创建的镜像中创建当前目录下的目录。如果目录尚不存在,它将在镜像中创建。两个EXPOSE命令声明了哪些镜像端口将暴露在镜像外部并映射到实际托管机器。映射的端口在部署阶段决定,可以是 Docker 命令的命令行参数,也可以是在 Docker Compose 文件中。在我们的例子中,有两个端口:一个用于 HTTP(80),另一个用于 HTTPS(443)。

这个中间图像被 Docker 缓存,因为它不依赖于我们在选定的 ASP.NET Core 运行时版本上编写的代码,所以不需要重新计算它。

第二步生成一个不同的镜像,它将不会用于部署。相反,它将用于创建特定于应用程序的文件,这些文件将被部署:

FROM microsoft/dotnet:x.x-sdk 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

FROM build AS publish
RUN dotnet publish MvcDockerTest.csproj -c Release -o /app

此步骤从 ASP.NET SDK 镜像开始,该镜像包含我们不需添加到部署中的部分;这些部分是处理项目代码所需的。在build镜像中创建新的src目录,并将其设置为当前镜像目录。然后,项目文件被复制到/src/MvcDockerTest

RUN命令在镜像上执行操作系统命令。在这种情况下,它调用dotnet运行时,请求它恢复之前复制的项目文件中引用的 NuGet 包。

然后,COPY..命令将整个项目文件树复制到src镜像目录。最后,将项目目录设置为当前目录,并请求dotnet运行时以发布模式构建项目,并将所有输出文件复制到新的/app目录。最后,一个新的名为publish的镜像在输出文件上执行publish命令。

最后一步从我们在第一步中创建的镜像开始,该镜像包含 ASP.NET Core 运行时,并添加了之前步骤中发布的所有文件:

FROM base AS final
WORKDIR /app
COPY --from=publish /app .
ENTRYPOINT ["dotnet", "MvcDockerTest.dll"]

ENTRYPOINT命令指定执行镜像所需的操作系统命令。它接受一个字符串数组。在我们的情况下,它接受dotnet命令及其第一个命令行参数,即我们需要执行的 DLL。

如果我们右键单击我们的项目并单击发布,我们会看到几个选项:

  • 将镜像发布到现有的或新的 Web 应用(由 Visual Studio 自动创建)

  • 发布到多个 Docker 注册表中,包括一个私有的 Azure 容器注册表,如果它还不存在,可以从 Visual Studio 内部创建。

  • 发布到 Azure 虚拟机

Docker Compose 支持允许您运行和发布多容器应用程序,并添加更多镜像,例如可在任何地方使用的容器化数据库。

以下 Docker Compose 文件将两个 ASP.NET Core 应用程序添加到同一个 Docker 镜像中:

version: '3.4'

services:
  mvcdockertest:
    image: ${DOCKER_REGISTRY-}mvcdockertest
    build:
      context: .
      dockerfile: MvcDockerTest/Dockerfile

  mvcdockertest1:
    image: ${DOCKER_REGISTRY-}mvcdockertest1
    build:
      context: .
      dockerfile: MvcDockerTest1/Dockerfile

上述代码引用现有的 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
      - ASPNETCORE_HTTPS_PORT=44355
    ports:
      - "3150:80"
      - "44355:443"
    volumes:
      - ${APPDATA}/Asp.NET/Https:/root/.aspnet/https:ro
      - ${APPDATA}/Microsoft/UserSecrets:/root/.microsoft/usersecrets:ro
  mvcdockertest1:
    environment:
      - ASPNETCORE_ENVIRONMENT=Development
      - ASPNETCORE_URLS=https://+:443;http://+:80
      - ASPNETCORE_HTTPS_PORT=44317
    ports:
      - "3172:80"
      - "44317:443"
    volumes:
      - ${APPDATA}/Asp.NET/Https:/root/.aspnet/https:ro
      - ${APPDATA}/Microsoft/UserSecrets:/root/.microsoft/usersecrets:ro

对于每个镜像,文件定义了一些环境变量,这些变量将在应用程序启动时在镜像中定义,端口映射和一些主机文件。

主机上的文件直接映射到镜像中,所以如果镜像没有映射到包含这些文件的宿主机,镜像将无法正常运行。每个声明包含宿主机中的路径,路径在镜像中的映射方式,以及期望的访问权限。在我们的例子中,使用 volumes 映射了 Visual Studio 和 ASP.NET Core 所使用的自签名 https 证书以及用户密钥(加密设置)。

现在,假设我们想要添加一个容器化的 SQL Server 实例。我们需要像以下指令一样在 docker-compose.ymldocker-compose.override.yml 之间分割:

sql.data:
  image: mssql-server-linux:latest
environment:
- SA_PASSWORD=Pass@word
- ACCEPT_EULA=Y
ports:
- "5433:1433"

在这里,前面的代码指定了 SQL Server 容器的属性,以及 SQL 服务器配置和安装参数。更具体地说,前面的代码包含以下信息:

  • sql.data 是分配给容器的名称。

  • image 指定了从哪里获取镜像。在我们的例子中,镜像包含在公共 Docker 仓库中。

  • environment 指定了 SQL Server 需要的环境变量,即管理员密码和接受 SQL Server 许可证的协议。

  • 如同往常,ports 指定了端口映射。

docker-compose.override.yml 用于在 Visual Studio 内运行镜像。如果你需要为生产环境或测试环境指定参数,可以添加额外的 docker-compose-xxx.override.yml 文件,例如 docker-compose-staging.override.ymldocker-compose-production.override.yml,然后在目标环境中手动启动它们,如下面的代码所示:

docker-compose -f docker-compose.yml -f docker-compose-staging.override.yml

然后,你可以使用以下代码销毁所有容器:

docker-compose -f docker-compose.yml -f docker-compose.test.staging.yml down

虽然 docker-compose 在处理节点集群方面能力有限,但它主要用于测试和开发环境。对于生产环境,需要更复杂的工具,我们将在 需要哪些工具来管理微服务? 这一部分中看到。

Azure 和 Visual Studio 对微服务编排的支持

Visual Studio 为基于 Service Fabric 平台的微服务应用程序提供了一个特定的项目模板,你可以定义各种微服务,配置它们,并将它们部署到 Azure Service Fabric,这是一个微服务编排器。Azure Service Fabric 将在下一节中更详细地描述。

在本节中,我们将描述你可以在 Service Fabric 应用程序中定义的微服务类型。最后一节将提供一个完整的代码示例。如果你想在开发机器上调试微服务,需要安装本章技术要求中列出的 Service Fabric 模拟器。

通过在 Visual Studio 项目类型下拉筛选器中选择 可以找到 Service Fabric 应用程序。一旦选择了项目,你可以从各种服务中进行选择:

图片

所有.NET Core 项目都使用特定于 Azure Service Fabric 的微服务模型。访客可执行文件在现有 Windows 应用程序周围添加一个包装器,将其转换为可以在 Azure Service Fabric 中运行的微服务。容器应用程序允许在 Service Fabric 应用程序中添加任何 Docker 镜像。所有其他选项都构建了一个模板,允许您使用 Service Fabric 特定的模式编写微服务。

一旦您在先前的屏幕截图中选择任何选项并填写所有请求信息,Visual Studio 将创建两个项目:一个包含整体应用程序配置信息的应用程序项目,以及一个包含所选特定服务代码和服务特定配置的项目。如果您想向应用程序添加更多微服务,请右键单击应用程序项目并选择“添加”|“新 Service Fabric 服务”。

如果您在解决方案上右键单击并选择“添加”|“新项目”,将创建一个新的 Service Fabric 应用程序,而不是将新服务添加到已存在的应用程序中。

如果您选择访客可执行文件,您需要提供以下信息:

  • 包含主要可执行文件及其所有必要文件的文件夹。如果您想在项目中创建此文件夹的副本或简单地链接到现有文件夹,则需要此文件夹。

  • 主要可执行文件。

  • 要传递给该可执行文件的命令行参数。

  • 在 Azure 上使用哪个文件夹作为工作文件夹。您希望使用包含主要可执行文件(CodeBase)的文件夹,Azure Service Fabric 将整个微服务打包到的文件夹(CodePackage),或者一个名为Work的新子文件夹。

如果您选择容器,您需要提供以下信息:

  • 您私有 Azure 容器注册表中 Docker 镜像的完整名称。

  • 将用于连接到 Azure 容器注册表的用户名。密码将在为该用户名自动创建的应用程序配置文件中的相同RepositoryCredentials XML 元素中手动指定。

  • 您可以访问服务(主机端口)的端口以及主机端口必须映射到的容器内部端口(容器端口)。容器端口必须是 Docker 文件中公开的相同端口,并用于定义 Docker 镜像。

之后,您可能需要添加进一步的手动配置以确保您的 Docker 应用程序能够正常工作。进一步阅读部分包含指向官方文档的链接,您可以在那里找到更多详细信息。

有五种类型的.NET Core 原生 Service Fabric 服务。Actor 服务模式是一种由 Carl Hewitt 几年前构思的具有偏见的模式。我们在此不讨论它,但进一步阅读部分包含一些链接,提供了更多关于此模式的信息。

剩下的四种模式涉及 ASP.NET Core 作为主要交互协议的使用(或未使用),以及服务是否有内部状态。实际上,Service Fabric 允许微服务使用分布式队列和字典,这些队列和字典对所有声明它们的微服务的实例都是全局可访问的,无论它们运行在哪个硬件节点上(当需要时,它们会被序列化和分布到所有可用的实例中)。

有状态和无状态模板主要在配置方面有所不同。所有原生服务都是指定仅两个方法的类:

protected override IEnumerable<ServiceReplicaListener> CreateServiceReplicaListeners()

protected override async Task RunAsync(CancellationToken cancellationToken)

CreateServiceReplicaListeners方法指定了一个由微服务用来接收消息及其处理这些消息的代码的监听器列表。监听器可以使用任何协议,但它们必须指定相对套接字的实现。

RunAsync包含后台线程的代码,这些线程异步运行由接收到的消息触发的任务。在这里,你可以构建运行多个托管服务的宿主。

ASP.NET Core 模板遵循相同的模式;然而,它们使用基于 ASP.NET Core 的独特监听器和没有RunAsync实现,因为后台任务可以从 ASP.NET Core 内部启动。但是,你可以向由 Visual Studio 创建的CreateServiceReplicaListeners实现返回的监听器数组中添加更多的监听器,还可以添加自定义的RunAsync重写。

在“哪些工具需要用来管理微服务?”这一节中,将提供有关 Service Fabric 原生服务模式的更多详细信息,而在本章的“测试应用程序”部分将提供一个完整的代码示例,该部分专门用于本书的使用案例。

虽然本节介绍了我们可以用来为我们的微服务构建代码的工具,但下一节将描述我们可以用来定义和管理微服务部署的集群的工具。

哪些工具需要用来管理微服务?

在 CI/CD 周期中有效地处理微服务需要私有 Docker 镜像注册库和最先进的微服务编排器,该编排器能够执行以下操作:

  • 在可用的硬件节点上分配和负载均衡微服务

  • 监控服务的健康状态,并在硬件/软件故障发生时替换故障服务

  • 记录和展示分析

  • 允许设计者动态更改需求,例如分配给集群的硬件节点、服务实例数量等

以下小节描述了我们可以用来存储 Docker 镜像和编排微服务的 Azure 设施。

在 Azure 中定义你的私有 Docker 注册库

在 Azure 中定义你的私有 Docker 注册库很容易。只需在 Azure 搜索栏中输入“容器注册库”并选择容器注册库。在出现的页面上,点击添加按钮。

将出现以下形式:

您选择的名称用于组成整体注册表 URI:<name>.azurecr.io。像往常一样,您可以指定订阅、资源组和位置。SKU 下拉菜单允许您从具有不同性能、可用内存和其他一些辅助功能的各个级别中选择。

如果您启用管理员用户,将创建一个管理员用户,其用户名为 <name>,密码由门户自动创建;否则,用户将使用您的 Azure 门户凭据登录。一旦选择了管理员用户,其登录信息将在资源 访问密钥 菜单项下可用。

在 Docker 命令或 Visual Studio 发布表单中提及镜像名称时,必须在名称前加上注册表 URI:<name>.azurecr.io/<my imagename>

如果使用 Visual Studio 创建镜像,则可以按照发布项目后出现的说明进行发布。否则,您必须使用 docker 命令将它们推送到您的注册表。

假设您在其他注册表中拥有该镜像。第一步是将镜像拉到您的本地计算机上:

docker pull other.registry.io/samples/myimage 

如果前一个镜像有多个版本,由于没有指定版本,将拉取最新版本。可以按照以下方式指定镜像版本:

docker pull other.registry.io/samples/myimage:version1.0 

使用以下命令,您应该在本地镜像列表中看到 myimage

docker images

现在,通过输入以下命令并输入您的凭据来登录 Azure:

docker login myregistry.azurecr.io

然后,使用您想要分配的路径在 Azure 注册表中标记镜像:

docker tag myimage myregistry.azurecr.io/testpath/myimage

名称和目标标签都可以有版本 (:<版本名称>)。

最后,推送它:

docker push myregistry.azurecr.io/testpath/myimage

在这种情况下,您可以指定版本;否则,将推送最新版本。

通过执行以下命令,您应该可以在本地镜像列表中看到 myimage

docker rmi myregistry.azurecr.io/testpath/myimage

Azure 服务 Fabric

Azure 服务 Fabric 是主要的 Microsoft 调度器,可以托管 Docker 容器、原生 .NET 应用程序以及称为 可靠服务 的分布式计算模型。我们已经在 Azure 和 Visual Studio 对微服务调度的支持 子部分中解释了如何创建和发布包含这三种类型服务的应用程序。在本节中,我们将解释如何在 Azure 门户中创建 Azure 服务 Fabric 集群,并提供有关 可靠服务 的更多详细信息。有关 可靠服务 的更多实际细节将在 用例 - 记录微服务 部分的示例中提供。

您可以通过在 Azure 搜索栏中输入 Service Fabric 并选择 Service Fabric Cluster 来进入 Azure 的 Service Fabric 部分。将出现一个多步骤向导。以下子部分描述了可用的步骤。

第 1 步:基本信息

以下截图显示了 Azure 服务 Fabric 的创建:

在这里,您可以选择要用于连接远程桌面到所有集群节点的操作系统、资源组、订阅、位置、用户名和密码。您必须选择一个集群名称,该名称将用于组成集群 URI,即 <cluster name>.<location>.cloudapp.azure.com,其中 location 是与您选择的数据中心位置相关联的名称。

第 2 步:集群配置

在第二步中,您可以配置节点数量及其功能:

图片

您可以指定最多三种节点类型。不同节点类型的节点可以独立扩展,节点类型 1,称为主节点类型,是 Azure Service Fabric 运行时服务托管的地方。对于每种节点类型,您可以指定机器类型(耐久性层)、机器尺寸(CPU 和 RAM)以及初始节点数量。

您还可以指定所有将从集群外部可见的端口(自定义端点)。

集群不同节点上托管的服务可以通过任何端口进行通信,因为它们是同一本地网络的一部分。因此,自定义端点必须声明需要接受来自集群外部的流量的端口。在自定义端点中公开的端口是集群的公共接口,可以通过集群 URI 访问,即 <cluster name>.<location>.cloudapp.azure.com。它们的流量会自动重定向到所有由集群负载均衡器打开相同端口的微服务。

要理解启用反向代理选项,我们必须解释如何将通信发送到在它们的生命周期中物理地址发生变化的服务的多个实例。在集群内部,服务通过 URI(如 fabric://<application name>/<service name>)进行标识。也就是说,此名称允许我们访问 <service name> 的几个负载均衡实例之一。然而,这些 URI 不能直接由通信协议使用。相反,它们用于从 Service Fabric 命名服务获取所需资源的物理 URI,以及所有可用的端口和协议。

之后,我们将学习如何使用可靠服务执行此操作。然而,此模型不适用于未设计在 Azure Service Fabric 上运行的 Docker 化服务,因为它们不了解 Service Fabric 特定的命名服务和 API。

因此,Service Fabric 提供了两个额外的选项,我们可以使用它们来标准化 URL,而不是直接与其命名服务交互:

  • DNS:每个服务都可以指定其hostname(也称为其DNS 名称)。DNS 服务负责将其转换为实际的服务 URL。例如,如果一个服务指定了order.processing DNS 名称,并且它在端口80上有一个 HTTP 端点以及/purchase路径,我们可以通过http://order.processing:80/purchase访问此端点。

  • 反向代理:Service Fabric 的反向代理拦截所有被定向到集群地址的调用,并使用命名服务将它们发送到该应用程序内的正确应用程序和服务。由反向代理服务解析的地址具有以下结构:<cluster name>.<location>.cloudapp.azure.com: <port>//<app name>/<service name>/<endpoint path>?PartitionKey=<value> & PartitionKind=value。在这里,分区键用于优化状态、完全可靠的服务,将在本小节的末尾进行解释。这意味着无状态服务缺少先前地址的查询字符串部分。因此,由反向代理解决的典型地址可能类似于myCluster.eastus.cloudapp.azure.com: 80//myapp/myservice/<endpoint path>?PartitionKey=A & PartitionKind=Named。如果从同一集群托管的服务调用前面的端点,我们可以指定localhost而不是完整的集群名称(即,来自同一集群,而不是来自同一节点):localhost: 80//myapp/myservice/<endpoint path>?PartitionKey=A & PartitionKind=Named

默认情况下,DNS 服务已激活,但反向代理未激活。因此,我们必须通过在 Service Fabric 配置的第二步中勾选“启用反向代理”复选框来启用它。

第 3 步:安全配置

提交第二步后,我们将进入一个安全页面:

图片

如果我们选择基本选项,向导将创建一个 X509 证书来保护我们与集群的通信。否则,我们可以从 Azure Key Vault 中选择一个现有的证书。如果您没有密钥保管库,向导将要求您创建一个,以便您可以存储新创建的证书。在证书选项中,找到证书用途选项并选择发布/部署。如果不这样做,您将收到一个错误消息,以及一些指示您如何修复问题的说明。

一旦证书准备就绪,请按照向导的说明将其下载到您的计算机上(双击安装),并在本地计算机上安装它。该证书将用于从您的计算机部署应用程序。具体来说,您需要将以下信息插入 Visual Studio Service Fabric 应用程序的云发布配置文件中(有关更多详细信息,请参阅本章的“用例 - 记录微服务”部分):

<ClusterConnectionParameters 
    ConnectionEndpoint="<cluster name>.<location 
    code>.cloudapp.azure.com:19000"
    X509Credential="true"
    ServerCertThumbprint="<server certificate thumbprint>"
    FindType="FindByThumbprint"
    FindValue="<client certificate thumbprint>"
    StoreLocation="CurrentUser"
    StoreName="My" />

由于客户端(Visual Studio)和服务器使用相同的证书进行身份验证,因此服务器和客户端的指纹相同。证书指纹可以从您的 Azure Key Vault 复制。值得一提的是,您还可以通过在步骤 3中选择自定义选项,添加与主服务器证书一起使用的客户端特定证书。

一旦您提交您的证书,您将看到您的配置摘要。提交您的批准将创建集群。请注意:集群可能在短时间内消耗您的 Azure 免费信用额度,因此当您在测试时,请保持集群开启。之后,您应该将其删除。

正如我们在Azure 和 Visual Studio 对微服务编排的支持子节中提到的,Azure Service Fabric 支持两种类型的可靠服务:无状态和有状态。无状态服务要么不存储永久数据,要么将其存储在外部支持中,例如 Redis 缓存或数据库(有关 Azure 提供的主要存储选项,请参阅第七章,如何在云中选择您的数据存储)。

与此相反,有状态服务使用 Service Fabric 特定的分布式字典和队列。每个分布式数据结构都可以从服务的所有相同副本中访问,但只有一个副本,称为主副本,被允许在其上写入,以避免对这些分布式资源的同步访问,这可能会导致瓶颈。所有其他副本,称为辅助副本,只能从这些分布式数据结构中读取。

您可以通过查看代码从 Azure Service Fabric 运行时接收到的上下文对象来检查副本是否为主副本,但通常您不需要这样做。实际上,当您声明服务端点时,您需要声明那些只读的端点。只读端点应该接收请求,以便它可以从共享数据结构中读取数据。因此,由于只有只读端点被激活在辅助副本上,如果您正确实现它们,则无需进行进一步检查,写入/更新操作应该自动在有状态辅助副本上被阻止。

在有状态服务中,辅助副本在读取操作上启用并行性,因此为了在写入/更新操作上获得并行性,有状态服务被分配不同的数据分区。更具体地说,对于每个有状态服务,Service Fabric 为每个分区创建一个主实例。然后,每个分区可能有几个辅助副本。

分布式数据结构在分区的每个主实例及其辅助副本之间共享。有状态服务可以存储的数据总量根据要存储的数据上生成的分区键的数量来分割。

通常,分区键是整数,属于分配给所有可用分区的给定区间。例如,可以通过在.NET 的GetHashCode()方法上对一个或多个字符串字段进行调用来生成分区键,从而得到整数,然后对这些整数进行处理以获得一个唯一的整数(例如,通过在整数位上执行异或操作)。然后,可以通过取整数除法的余数(例如,除以 1,000 的余数将是一个在 0-999 区间的整数)来将这个整数约束到为分区键选择的整数区间。

假设我们想要四个分区,这些分区将通过 0-999 区间的整数键进行选择。在这里,Service Fabric 将自动创建我们状态化服务的四个主实例,并将以下四个分区键子区间分配给它们:0-249,250-499,500-749,750-999。

在您的代码内部,您需要计算发送到状态化服务的数据的分区键。然后,Service Fabric 的运行时将为您选择正确的首选实例。本章末尾的“用例 - 日志微服务”部分提供了更多关于此以及如何在实践中使用可靠服务的实际细节。

Azure Kubernetes Service (AKS)

Kubernetes 是一个高级开源编排器,您可以在本地安装到您的私有机器集群上。在撰写本文时,它是应用最广泛的编排器,因此 Microsoft 将其作为 Azure Service Fabric 的替代品提供。此外,如果您更喜欢 Azure Service Fabric,您可能被迫使用Azure Kubernetes ServiceAKS),因为一些高级解决方案(例如,一些大数据解决方案)是建立在 Kubernetes 之上的。本节提供了一个关于 AKS 的简要介绍,但更多详细信息可以在官方文档中找到,该文档在“进一步阅读”部分有引用。

要创建 AKS 集群,请在 Azure 搜索中输入AKS,选择 Kubernetes 服务,然后点击“添加”按钮。以下表单将出现:

图片

如同往常,您需要指定订阅、资源组和位置。然后,您可以选择一个唯一的名称(Kubernetes 集群名称)、集群 URI 的前缀(DNS 名称前缀)以及您想要使用的 Kubernetes 版本。为了计算能力,您需要为每个节点选择一个机器模板(节点大小)和节点数量。如果您点击“下一步”,您可以提供安全信息,即一个服务主体,并指定您是否希望启用基于角色的访问控制。在 Azure 中,服务主体是与您可能使用的服务关联的账户,您可以使用它来定义资源访问策略。如果您没有这方面的经验,或者您没有现有的服务主体,可以让向导为您创建一个。

您还可以更改其他设置,但默认值效果良好。

一旦创建了 Kubernetes 集群,您就可以通过 kubectl 命令行工具与之交互。kubectl 集成到 Azure 控制台中,因此您只需激活您的集群凭据。在页面门户的顶部选择 Azure 控制台,然后输入以下命令:

az aks get-credentials --resource-group <resource group> --name <cluster name>

前面的命令下载了自动创建的凭据,以启用您与集群的交互,并配置 Kubernetes CLI 以使用它们。

然后,如果您编写 kubectl get nodes,您应该会得到一个可用的 Kubernetes 节点列表。

可以通过编写一个 .yaml 配置文件(例如 myClusterConfiguration.yaml)并将以下命令输入到集群中来将 Docker 镜像加载到集群中并进行配置:

kubectl apply -f myClusterConfiguration.yaml

您可以通过在 Azure 控制台中输入 nano 来创建和编辑此文件,以启动 nano 编辑器。一旦进入编辑器,您可以从本地文件粘贴内容,然后保存它。

前面的命令部署了应用程序并运行它。可以使用以下命令监控部署状态:

kubectl get service MyDeployment --watch

在这里,MyDeployment 是在 .yaml 文件中分配给部署的名称。

当集群不再需要时,您可以使用以下命令将其删除:

az aks delete --resource-group <resource group> --name <cluster name> --no-wait

您可以通过从资源 Azure 菜单中选择洞察来监控应用程序状态。在这里,您可以应用过滤器并选择您所需的信息。

.yaml 文件的结构与 JSON 文件相同,但它们的语法不同。您有对象和列表,但对象的属性不需要用 {} 包围,列表也不需要用 [] 包围。相反,通过简单地使用空格缩进来声明嵌套对象的内容。空格的数量可以自由选择,但一旦选择,就必须连贯地使用。

列表项可以通过在它们前面加上破折号(-)来与对象属性区分开来。.yaml 文件可以包含多个由包含 --- 字符串的行分隔的章节。通常,您定义一个 Deployment 来描述要部署哪些镜像以及它们必须有多少个副本。每个部署将一组要一起部署到同一节点上的镜像分组在一起,这意味着在任意节点上部署的每个副本都必须安装所有这些镜像。要一起部署的镜像集称为 pod

例如,以下配置部署了一个包含单个镜像的 pod 的两个副本:

apiVersion: apps/v1
kind: Deployment
metadata:
  name: MyDeployment
spec:
  replicas: 2
  selector:
    matchLabels:
      app: MyApplication
  template:
    ...
    ...

初始头声明了要使用的 Kubernetes API 版本和我们将要定义的对象类型(一个部署),并为对象分配一个名称。部署名称可以在以后使用部署编辑命令来修改集群。

template:
    metadata:
      labels:
        app: MyApplication
    spec:
      containers:
      - name: MyContainerName
        image: myregistry.azurecr.io/testpath/myimage
        resources:
          requests:
            cpu: 100m
            memory: 128Mi
          limits:
            cpu: 250m
            memory: 256Mi
        ports:
        - containerPort: 80
        - name: http
        env:
        - name: MyEnvironmetVariable
          value: "MyEnvironmetVariable"

另一方面,模板下的 spec 属性列出了将组成每个 pod 副本的所有容器。

然后,每个容器都有一个名称,并指定用于实例化的 Docker 镜像。接着,它指定所需的平均计算资源及其最大限制。最后,它指定外部暴露的端口。这些端口不会转发到不同的端口,而是直接暴露。此端口设置覆盖了 Docker 文件中的 EXPOSE 设置。

最后,我们可以指定一些要在每个容器内部设置的环境变量。

由于同一服务在不同节点上有多个副本,并且分配服务到节点可能会动态变化,因此在 Pod 之间的内部通信以及内部到外部的通信中存在一个问题。这个问题通过定义提供所有 Pod 实例唯一入口点的服务来解决。可以在同一个.yaml文件中添加服务定义,并用---分隔:

---
apiVersion: v1
kind: Service
metadata:
  name: MyApplication-service
spec:
  ports:
  - port: 8080
    targetPort: 80
    protocol: TCP
    name: http
  selector:
    app: MyApplication

上述定义创建了一个在端口8080上暴露的服务,该服务将所有请求重定向到MyApplication副本的端口80。由该服务提供服务的 Pod 是通过selector属性选择的。服务 IP 在内部可见,但客户端 Pod 不需要知道服务 IP,因为可以通过名称访问服务,就像经典网络中的主机一样。因此,在这种情况下,MyApplication-service:8080就完成了这项工作。

如果我们需要一个公开可访问的 IP,我们需要在spec下的ports之前添加type: LoadBalancer。AKS 将为您选择一个公共 IP。我们可以通过使用kubectl get service MyDeployment --watch监视部署过程来获取所选的公共 IP,直到 IP 被选中。如果我们已经在与 AKS 相同的资源组中购买了 IP 地址,我们可以在服务spec下通过添加clusterIP: <your IP>来指定此 IP 地址。

如果我们在.yaml文件中创建命名空间,Pod 可以被组织到命名空间中:

apiVersion: v1
kind: Namespace  
metadata:   
    name: my-namespace   
    labels:     
        name: my-namespace

然后,您可以通过在其定义元数据中的名称后添加namespace: <your namespace>来在命名空间中定位对象(服务或部署)。同样,您可以通过添加--namespace=<your namespace>选项来在特定命名空间中定位kubectl命令。

下一个用例将提供更多关于定义 Service Fabric 应用程序的详细信息。有关 Kubernetes 集群的更多详细信息,可以在进一步阅读部分找到的参考资料中找到。

用例 - 日志微服务

在本节中,我们将探讨一个基于微服务的系统,该系统记录了我们 WWTravelClub 用例中与各种目的地相关的购买数据。特别是,我们将设计负责计算每个位置每日收入的微服务。在这里,我们假设这些微服务从同一 Azure Service Fabric 应用程序中托管的其他子系统接收数据。更具体地说,每个购买日志消息由位置名称、整体套餐成本以及购买日期和时间组成。

作为第一步,让我们确保我们在本章 技术要求 部分中提到的 Service Fabric 模拟器已经安装并正在您的开发机器上运行。现在,我们需要将其切换,以便它运行 5 个节点

现在,我们可以按照我们在 Azure 和 Visual Studio 对微服务编排的支持 部分中解释的步骤来创建一个名为 PurchaseLogging 的 Service Fabric 项目。选择一个 .NET Core 有状态可靠服务,并将其命名为 LogStore

由 Visual Studio 创建的解决方案由一个 PurchaseLogging 项目组成,它代表整个应用程序,以及一个 LogStore 项目,该项目将包含 PurchaseLogging 应用程序中包含的第一个微服务的实现。

PackageRoot 文件夹下,LogStore 服务和每个可靠服务都包含一个 ServiceManifest.xml 配置文件和一个 Settings.xml 文件夹(位于 Config 子文件夹下)。Settings.xml 文件夹包含一些可以从服务代码中读取的设置。初始文件包含 Service Fabric 运行时所需的预定义设置。让我们添加一个新的设置部分,如下面的代码所示:

<?xml version="1.0" encoding="utf-8" ?>
<Settings  

          > 
  <!-- This is used by the StateManager's replicator. -->
  <Section Name="ReplicatorConfig">
    <Parameter Name="ReplicatorEndpoint" Value="ReplicatorEndpoint" />
  </Section>
  <!-- This is used for securing StateManager's replication traffic. -->
  <Section Name="ReplicatorSecurityConfig" />

  <!-- Below the new Section to add -->

  <Section Name="Timing">
    <Parameter Name="MessageMaxDelaySeconds" Value="" />
  </Section>
</Settings>

我们将使用 MessageMaxDelaySeconds 的值来配置系统组件并确保消息幂等性。设置值是空的,因为大多数设置都被包含在 PurchaseLogging 项目中的整体应用程序设置覆盖了。

ServiceManifest.xml 文件包含一些由 Visual Studio 自动处理的配置标签以及端点列表。由于它们由 Service Fabric 运行时使用,因此已预配置了两个端点。在这里,我们必须添加我们的微服务将监听的端点的配置细节。每个端点定义具有以下格式:

<Endpoint Name="<endpoint name>" PathSuffix="<the path of the endpoint URI>" Protocol="<a protcolo like Tcp, http, https, etc.>" Port="the exposed port" Type="<Internal or Input>"/>

如果 TypeInternal,则端口将在集群的本地网络内部打开;否则,端口也将对外部集群可用。在前一种情况下,我们必须在 Azure Service Fabric 集群的配置中声明该端口,否则集群负载均衡器/防火墙不会将消息转发到它。

公共端口可以直接从集群 URI (<cluster name>.<location code>.cloudapp.azure.com) 访达,因为接口每个集群的负载均衡器将接收到的输入流量转发到它们。

在这个例子中,我们不会定义端点,因为我们打算使用基于远程通信的通信,这已经定义好了,用于所有内部交互,但我们将向您展示如何使用它们。

PurchaseLogging 项目在 services 解决方案资源管理器节点下包含对 LogStore 项目的引用,并包含各种文件夹,其中包含各种 XML 配置文件。更具体地说,我们有以下文件夹:

  • ApplicationPackageRoot,其中包含名为 ApplicationManifest.xml 的整体应用程序清单。此文件包含一些初始参数定义,然后是进一步的配置。参数具有以下格式:
<Parameter Name="<parameter name>" DefaultValue="<parameter definition>" />
  • 一旦定义,参数就可以替换文件其余部分中的任何值。参数值通过在方括号中包围参数名称来引用,如下面的代码所示:
<UniformInt64Partition PartitionCount="[LogStore_PartitionCount]" LowKey="0" HighKey="1000" />

一些参数定义了每个服务的副本和分区数量,并由 Visual Studio 自动创建:

<Parameter Name="LogStore_MinReplicaSetSize" DefaultValue="1" />
<Parameter Name="LogStore_PartitionCount" DefaultValue="2" />
<Parameter Name="LogStore_TargetReplicaSetSize" DefaultValue="1" />

让我们用前面代码中的值替换 Visual Studio 建议的初始值。我们将使用两个分区来向您展示分区是如何工作的,但您可以将此值增加以提高写/更新并行性。LogStore 服务的每个分区不需要多个副本,因为副本可以提高读取操作的性能,而这个服务不是设计来提供读取服务的。因此,您可以选择仅使用一个副本,或者最多两个副本,以使系统冗余并提高对失败的鲁棒性。

上述参数用于定义 LogStore 服务在整体应用程序中的角色。此定义由 Visual Studio 自动生成,在同一文件中,在 Visual Studio 创建的初始定义下方,只是将分区间隔更改为 0-1,000:

<Service Name=LogStore ServicePackageActivationMode="ExclusiveProcess">
    <StatefulService ServiceTypeName="LogStoreType" 
    TargetReplicaSetSize=
    "[LogStore_TargetReplicaSetSize]" 
    MinReplicaSetSize="[LogStore_MinReplicaSetSize]">
        <UniformInt64Partition PartitionCount="
        [LogStore_PartitionCount]" 
        LowKey="0" HighKey="1000" />
    </StatefulService>
</Service>
  • ApplicationParameters 包含对 ApplicationManifest.xml 中定义的参数的可能的覆盖,适用于各种部署环境:云(即实际的 Azure Service Fabric 集群)和具有一个或五个节点的本地仿真器。

  • PublishProfiles 包含发布应用程序所需的设置,这些设置由 ApplicationParameters 文件夹处理的环境所处理。您只需自定义云发布配置文件,使用您在 Azure 集群配置过程中下载的实际 Azure Service Fabric URI 名称,以及您在 Azure 集群配置过程中下载的认证证书:

<ClusterConnectionParameters 
    ConnectionEndpoint="<cluster name>.<location 
    code>.cloudapp.azure.com:19000"
    X509Credential="true"
    ServerCertThumbprint="<server certificate thumbprint>"
    FindType="FindByThumbprint"
    FindValue="<client certificate thumbprint>"
    StoreLocation="CurrentUser"
    StoreName="My" />

为了完成应用程序,需要遵循的剩余步骤已被组织成几个子节。让我们首先看看如何确保消息幂等性。

确保消息幂等性

消息可能会因为故障或由负载均衡引起的小超时而丢失。在这里,我们将使用基于远程通信的预定义通信,在发生故障的情况下自动重试消息。然而,正如我们在 微服务设计原则 子节中解释的那样,这可能会导致相同的消息被接收两次。由于我们正在汇总采购订单的收入,我们必须防止多次汇总相同的采购订单。

为了做到这一点,我们将实现一个包含确保消息副本被丢弃所需工具的库。

让我们在解决方案中添加一个新的.NET Standard 2.0 库项目,命名为IdempotencyTools。现在,我们可以移除 Visual Studio 生成的初始类框架。这个库需要引用与LogStore相同的版本的Microsoft.ServiceFabric.Services NuGet 包,所以让我们验证版本号并将相同的 NuGet 包引用添加到IdempotencyTools项目中。

确保消息幂等的主体工具是IdempotentMessage类:

using System;
using System.Runtime.Serialization;

namespace IdempotencyTools
{
    [DataContract]
    public class IdempotentMessage<T>
    {
        [DataMember]
        public T Value { get; protected set; }
        [DataMember]
        public DateTimeOffset Time { get; protected set; }
        [DataMember]
        public Guid Id { get; protected set; }

        public IdempotentMessage(T originalMessage)
        {
            Value = originalMessage;
            Time = DateTimeOffset.Now;
            Id = Guid.NewGuid();
        }
    }
}

我们添加了DataContractDataMember属性,因为它们是我们将要用于所有内部消息的远程通信序列化器所需的。基本上,退行类是一个包装器,它向传递给其构造函数的消息类实例添加一个Guid和时间标记。

IdempotencyFilter类使用分布式字典来跟踪它已经接收到的消息。为了避免这个字典无限增长,定期删除较旧的条目。太旧以至于在字典中找不到的消息将自动丢弃。

时间间隔条目被保存在字典中,并通过IdempotencyFilter静态工厂方法传递,该方法创建新的过滤器实例,以及创建分布式字典所需的字典名称和IReliableStateManager实例:

public class IdempotencyFilter
{
    protected IReliableDictionary<Guid, DateTimeOffset> dictionary;
    protected int maxDelaySeconds;
    protected DateTimeOffset lastClear;
    protected IReliableStateManager sm;
    protected IdempotencyFilter() { }
    public static async Task<IdempotencyFilter> NewIdempotencyFilter(
        string name, 
        int maxDelaySeconds, 
        IReliableStateManager sm)
    {
        var result = new IdempotencyFilter();
        result.dictionary = await 
            sm.GetOrAddAsync<IReliableDictionary<Guid, DateTimeOffset>> 
            (name);
        result.maxDelaySeconds = maxDelaySeconds;
        result.lastClear = DateTimeOffset.Now;
        result.sm = sm;
        return result;
    }
...
...

字典包含每个消息时间标记,按消息Guid索引,通过调用IReliableStateManager实例的GetOrAddAsync方法创建,该方法带有字典类型和名称。lastClear包含所有旧消息移除的时间。

当收到新消息时,NewMessage方法检查是否必须丢弃该消息。如果必须丢弃该消息,则返回null;否则,将新消息添加到字典中,并返回不带IdempotentMessage包装器的新消息:

public async Task<T> NewMessage<T>(IdempotentMessage<T> message)
{
    DateTimeOffset now = DateTimeOffset.Now;
    if ((now - lastClear).TotalSeconds > 1.5 * maxDelaySeconds)
    {
        await Clear();
    }
    if ((now - message.Time).TotalSeconds > maxDelaySeconds)
        return default(T);
    using (ITransaction tx = this.sm.CreateTransaction())
    {
        ...
        ...
    }
 }

作为第一步,该方法验证是否是清除字典的时候,以及消息是否过于陈旧。然后,它开始一个事务来访问字典。所有分布式字典操作都必须在事务中封装,如下面的代码所示:

using (ITransaction tx = this.sm.CreateTransaction())
{
    var result = await dictionary.TryGetValueAsync(tx, 
    message.Id);
    if (result.HasValue)
    {
        tx.Abort();
        return default(T);
    }
    else
    {
        await dictionary.TryAddAsync(tx, message.Id, message.Time);
        await tx.CommitAsync();
        return message.Value;
    }
}

如果在字典中找到消息Guid,则由于字典不需要更新,方法返回default(T),实际上为null,因为消息不得被处理。否则,将消息条目添加到字典中,并返回未包装的消息。

Clear方法的代码可以在与本书相关的 GitHub 仓库中找到。

交互库

有一些类型必须在所有微服务之间共享。如果内部通信是通过远程或 WCF 实现的,每个微服务都必须公开一个接口,包含其他微服务调用的所有方法。这些接口必须在所有微服务之间共享。此外,对于所有通信接口,实现消息的类也必须在所有微服务(或它们的一些子集)之间共享。因此,所有这些结构都声明在外部库中,这些库被微服务引用。

现在,让我们向我们的解决方案中添加一个新的.NET Standard 2.0 库项目,命名为Interactions。由于这个库必须使用IdempotentMessage泛型类,我们必须将其作为引用添加到IdempotencyTools项目中。我们还需要添加对包含在Microsoft.ServiceFabric.Services.Remoting NuGet 包中的远程通信库的引用,因为所有用于公开微服务远程方法的接口都必须继承自该包中定义的IService接口。

IService是一个空接口,它声明了继承接口的通信角色。Microsoft.ServiceFabric.Services.Remoting NuGet 包的版本必须与在其他项目中声明的Microsoft.ServiceFabric.Services包的版本相匹配。

以下代码显示了需要由LogStore类实现的接口的声明:

using System;
using System.Collections.Generic;
using System.Text;
using System.Threading.Tasks;
using IdempotencyTools;
using Microsoft.ServiceFabric.Services.Remoting;

namespace Interactions
{
    public interface ILogStore: IService
    {
        Task<bool> LogPurchase(IdempotentMessage<PurchaseInfo> 
        idempotentMessage);
    }
}

以下为PurchaseInfo消息类的代码,该代码在ILogStore接口中被引用:

using System;
using System.Collections.Generic;
using System.Runtime.Serialization;
using System.Text;

namespace Interactions
{
    [DataContract]
    public class PurchaseInfo
    {
        [DataMember]
        public string Location { get; set; }
        [DataMember]
        public decimal Cost { get; set; }
        [DataMember]
        public DateTimeOffset Time { get; set; }
    }
}

现在,我们已经准备好实现我们的主要LogStore微服务。

实现通信的接收端

要实现LogStore微服务,我们必须添加对Interaction库的引用,这将自动创建对远程库和对IdempotencyTools项目的引用。然后,LogStore类必须实现ILogStore接口:

internal sealed class LogStore : StatefulService, ILogStore
...
... private IReliableQueue<IdempotentMessage<PurchaseInfo>> LogQueue = null;
public async Task<bool> 
    LogPurchase(IdempotentMessage<PurchaseInfo> idempotentMessage)
{
    if (LogQueue == null) return false;
    using (ITransaction tx = this.StateManager.CreateTransaction())
    {
        await LogQueue.EnqueueAsync(tx, idempotentMessage);
        await tx.CommitAsync();
        return true;
    }
}

一旦服务从远程运行时接收到LogPurchase调用,它将消息放入LogQueue以避免调用者保持阻塞,等待消息处理完成。这样,我们既实现了同步消息传递协议的可靠性(调用者知道消息已被接收),又实现了异步通信典型的异步消息处理的性能优势。

根据LoqQueue作为所有分布式集合的最佳实践,它在RunAsync方法中被创建,因此如果第一个调用在 Azure Service Fabric 运行时调用RunAsync之前到达,LogQueue可能为 null。在这种情况下,该方法返回false以表示服务尚未准备好。否则,将创建一个事务以入队新消息。

然而,如果我们不提供一个返回服务希望激活的所有听众的CreateServiceReplicaListeners()实现,我们的服务将不会收到任何通信。在远程通信的情况下,有一个预定义的方法执行整个任务,所以我们只需调用它:

protected override IEnumerable<ServiceReplicaListener> 
    CreateServiceReplicaListeners()
{
    return this.CreateServiceRemotingReplicaListeners<LogStore>();
}

在这里,CreateServiceRemotingReplicaListeners是在远程通信库中定义的一个扩展方法。它为主副本和辅助副本(用于只读操作)创建监听器。在创建客户端时,我们可以指定其通信是否仅针对主副本,还是也针对辅助副本。

如果您想使用不同的听众,您必须创建一个IEnumerableServiceReplicaListener实例。对于每个听众,您必须使用三个参数调用ServiceReplicaListener构造函数:

  • 一个函数,它接收可靠的服务上下文对象作为输入,并返回ICommunicationListener接口的实现。

  • 听众的名称。当服务拥有多个听众时,这个第二个参数变为必需。

  • 一个布尔值,如果听众必须在辅助副本上激活则为真。

例如,如果我们想添加自定义和 HTTP 监听器,代码可能如下所示:

return new ServiceReplicaListener[]
{
    new ServiceReplicaListener(context => 
    new MyCustomHttpListener(context, "<endpoint name>"),             
    "CustomWriteUpdateListener", true),

    new ServiceReplicaListener(serviceContext =>
    new KestrelCommunicationListener(serviceContext, "<endpoint name>" 
    (url, listener) =>
        {
           ...
        })
        "HttpReadOnlyListener",
    true)
};

MyCustomHttpListenerICommunicationListener的自定义实现,而KestrelCommunicationListener是基于 Kestrel 和 ASP.NET Core 的预定义 HTTP 监听器。以下是完全定义KestrelCommunicationListener监听器的代码:

new ServiceReplicaListener(serviceContext =>
new KestrelCommunicationListener(serviceContext, "<endpoint name>" (url, listener) =>
{
    return new WebHostBuilder()
    .UseKestrel()
    .ConfigureServices(
        services => services
        .AddSingleton<StatefulServiceContext>(serviceContext)
        .AddSingleton<IReliableStateManager>(this.StateManager))

    .UseContentRoot(Directory.GetCurrentDirectory())
    .UseStartup<Startup>()
    .UseServiceFabricIntegration(listener, 

    ServiceFabricIntegrationOptions.UseUniqueServiceUrl)
    .UseUrls(url)
    .Build();
})
"HttpReadOnlyListener",
true)

通常,ICommunicationListener实现在其构造函数中接受节点上下文和一个端点名称,并负责读取ServiceManifest.xml服务中定义的端点数据,以及创建满足其中包含的规范的监听端点。它们在其CommunicationListener.OpenAsync方法中这样做:

public async Task<string> OpenAsync(CancellationToken cancellationToken)
{
    EndpointResourceDescription serviceEndpoint = serviceContext
    .CodePackageActivationContext.GetEndpoint("ServiceEndpoint");
    //create service URI that depend on current Ip 
    (FabricRuntime.GetNodeContext().IPAddressOrFQDN)
    //partition id (serviceContext.PartitionId)
    //and replica id (serviceContext.ReplicaOrInstanceId)
    //open the listener
    return <computedURISchema>;
}

<computedURISchema>是将 IP 地址替换为"+"的 URI。一旦通过OpenAsync返回,它将在服务 Fabric 命名服务中发布,并用于从已部署在其上的集群节点 IP 地址计算实际服务地址。

ICommunicationListener实现还必须有一个Close方法,它必须关闭打开的通信通道,以及一个Abort方法,它必须立即关闭通信通道(即不通知连接的客户端等)。

现在我们已经开启了通信,我们可以实现服务逻辑。

实现服务逻辑

服务逻辑由在 Service Fabric 运行时调用RunAsync时启动的独立线程的任务执行。当只需要实现一个任务时,创建一个IHost并将所有任务设计为IHostedService实现是良好的实践。实际上,IHostedService实现是独立的软件块,更容易进行单元测试。"IHost"和"IHostedService"在使用通用宿主小节中进行了详细讨论。

在本节中,我们将实现将每日收入计算逻辑实现为名为ComputeStatisticsIHostedservice,它使用一个分布式字典,其键是位置名称,值是名为RunningTotal的类的实例。这个类存储当前的运行总计数和正在计算的天:

namespace LogStore
{
    public class RunningTotal
    {
        public DateTime Day { get; set; }
        public decimal Count { get; set; }

        public RunningTotal 
                Update(DateTimeOffset time, decimal value)
        {
            ...
        }
    }
}

这个类有一个Update方法,当接收到新的购买消息时更新实例。首先,将传入的消息时间标准化为通用时间。然后,从这个时间中提取出天部分,并将其与运行总计的当前Day进行比较,如下面的代码所示:

public RunningTotal Update(DateTimeOffset time, decimal value)
        {
            var normalizedTime = time.ToUniversalTime();
            var newDay = new DateTime(normalizedTime.Year, 
                    normalizedTime.Month, normalizedTime.Day);
           ... 
           ...
        }

如果是新的日子,我们假设前一天的总计算已经完成,因此Update方法返回一个新的RunningTotal实例,并重置DayCount以便它可以计算新的一天的总计数。否则,新值将添加到当前的Count中,并且方法返回null,表示这一天的总计数尚未准备好。这种实现可以在以下代码中看到:

public RunningTotal Update(DateTimeOffset time, decimal value)
{
    ...
    ...
    var result = newDay > Day && Day != DateTime.MinValue ? 
    new RunningTotal
    {
        Day=Day,
        Count=Count
    } 
    : null;
    if(newDay > Day) Day = newDay;
    if (result != null) Count = value;
    else Count += value;
    return result;
}

IHostedServiceComputeStatistics实现需要一些参数才能正常工作,如下所示:

  • 包含所有传入消息的队列

  • IReliableStateManager服务,以便它可以创建存储数据的分布式字典

  • ConfigurationPackage服务,以便它可以读取在Settings.xml服务文件中定义的设置,以及可能在应用程序清单中覆盖的设置

在创建ComputeStatistics实例时,必须通过IHost通过依赖注入将前面的参数传递给ComputeStatistics构造函数。我们将在下一小节回到IHost的定义。现在,让我们集中讨论ComputeStatistics构造函数及其字段:

namespace LogStore
{
    public class ComputeStatistics : BackgroundService
    {
        IReliableQueue<IdempotentMessage<PurchaseInfo>> queue;
        IReliableStateManager stateManager;
        ConfigurationPackage configurationPackage;
        public ComputeStatistics(
            IReliableQueue<IdempotentMessage<PurchaseInfo>> queue,
            IReliableStateManager stateManager,
            ConfigurationPackage configurationPackage)
        {
            this.queue = queue;
            this.stateManager = stateManager;
            this.configurationPackage = configurationPackage;
        }

所有构造函数参数都存储在私有字段中,以便在调用ExecuteAsync时使用:

protected async override Task ExecuteAsync(CancellationToken stoppingToken)
{
    bool queueEmpty = false;
    var delayString=configurationPackage.Settings.Sections["Timing"]
        .Parameters["MessageMaxDelaySeconds"].Value;
    var delay = int.Parse(delayString);
    var filter = await IdempotencyFilter.NewIdempotencyFilter(
        "logMessages", delay, stateManager);
    var store = await
        stateManager.GetOrAddAsync<IReliableDictionary<string, RunningTotal>>("partialCount");
....
...

在进入循环之前,ComputeStatistics服务准备了一些结构和参数。它声明队列不为空,以便它可以开始出队消息。然后,它从服务设置中提取MessageMaxDelaySeconds并将其转换为整数。这个参数在Settings.xml文件中留空。现在,是时候在ApplicationManifest.xml中覆盖它并定义其实际值了:

<ServiceManifestImport>
    <ServiceManifestRef ServiceManifestName="LogStorePkg" ServiceManifestVersion="1.0.0" />
    <!--code to add start -->
    <ConfigOverrides>
      <ConfigOverride Name="Config">
        <Settings>
          <Section Name="Timing">
            <Parameter Name="MessageMaxDelaySeconds" Value="[MessageMaxDelaySeconds]" />
          </Section>
        </Settings>
      </ConfigOverride>
    </ConfigOverrides>
    <!--code to add end-->
</ServiceManifestImport>

ServiceManifestImport导入服务清单到应用程序中,并覆盖一些配置。每次其内容以及/或服务定义更改,并且应用程序在 Azure 中重新部署时,其版本号都必须更改,因为版本号的变化告诉服务 Fabric 运行时在集群中要更改什么。版本号也出现在其他配置设置中。它们必须在它们引用的实体更改时更改。

MessageMaxDelaySeconds被传递给幂等性过滤器实例,以及已接收消息字典的名称,以及IReliableStateManager服务实例。最后,用于存储运行总量的主要分布式字典被创建。

之后,服务进入其循环,并在stoppingToken被触发时结束,即当服务 Fabric 运行时指示服务即将停止时:

while (!stoppingToken.IsCancellationRequested)
    {
        while (!queueEmpty && !stoppingToken.IsCancellationRequested)
        {
            RunningTotal total = null;
            using (ITransaction tx = stateManager.CreateTransaction())
            {
                ...
                ... 
                ...
            }
        }
        await Task.Delay(100, stoppingToken);
        queueEmpty = false;
    }
} 

内层循环会一直运行,直到队列不为空,然后退出并等待 100 毫秒,以验证是否有新消息入队:

await Task.Delay(100, stoppingToken);
queueEmpty = false;

以下是为内层循环编写的代码,该循环被包含在一个事务中:

RunningTotal total = null;
using (ITransaction tx = stateManager.CreateTransaction())
{
    var result = await queue.TryDequeueAsync(tx);
    if (!result.HasValue) queueEmpty = true;
    else
    {
        var item = await filter.NewMessage<PurchaseInfo>(result.Value);
        if(item != null)
        {
            var counter = await store.TryGetValueAsync(tx, 
            item.Location);
            //counter update
            ...
        }
        ...
        ...
    }
}

在这里,服务正在尝试出队一个消息。如果队列为空,它将queueEmpty设置为true以退出循环;否则,它将消息通过幂等性过滤器。如果消息通过了这一步,它将使用它来更新消息中引用的位置的运行总数。然而,为了正确操作分布式字典,每次更新条目时都必须用新计数器替换旧计数器。因此,旧计数器被复制到一个新的RunningTotal对象中。如果我们调用Update方法,这个新对象可以更新为新数据:

    //counter update    
    var newCounter = counter.HasValue ? 
    new RunningTotal
    {
        Count=counter.Value.Count,
        Day= counter.Value.Day
    }
    : new RunningTotal();
    total = newCounter.Update(item.Time, item.Cost);
    if (counter.HasValue)
        await store.TryUpdateAsync(tx, item.Location, 
        newCounter, counter.Value);
    else
        await store.TryAddAsync(tx, item.Location, newCounter);

然后,事务被提交,如下面的代码所示:

if(item != null)
{
  ...
  ...
}
await tx.CommitAsync();
if(total != null)
{
    await SendTotal(total, item.Location);
}

Update方法返回完整的计算结果时,即调用total != null方法时:

protected async Task SendTotal(RunningTotal total, string location)
{
   //Empty, actual application would send data to a service 
   //that exposes daily statistics through a public Http endpoint         
}

SendTotal方法将总数发送到公开通过 HTTP 端点暴露所有统计信息的服务。在阅读了第十二章 Applying Service-Oriented Architectures with .NET Core 之后,该章节专门介绍了 Web API,你可能希望实现一个类似的服务,该服务是一个无状态的 ASP.NET Core 微服务,连接到一个数据库。无状态的 ASP.NET Core 服务模板会自动为你创建一个基于 ASP.NET Core 的 HTTP 端点。

然而,由于此服务必须从SendTotal方法接收数据,它还需要基于远程的端点。因此,我们必须创建它们,就像我们为LogStore微服务所做的那样,并将基于远程的端点数组与包含 HTTP 端点的现有数组连接起来。

定义微服务的宿主

现在,我们已经准备好定义微服务的RunAsync方法:

protected override async Task RunAsync(CancellationToken cancellationToken)
{
    // TODO: Replace the following sample code with your own logic 
    // or remove this RunAsync override if it's not needed in your service.
    cancellationToken.ThrowIfCancellationRequested();
    LogQueue = await 
        this.StateManager
        .GetOrAddAsync<IReliableQueue
        <IdempotentMessage<PurchaseInfo>>>("logQueue");
    var configurationPackage = Context
        .CodePackageActivationContext
        .GetConfigurationPackageObject("Config");
    ...
    ...

在这里,该方法验证取消令牌是否被信号,如果是,则抛出异常以终止方法。然后,创建服务队列,并将服务设置保存在configurationPackage中。

之后,我们可以创建IHost服务,正如我们在使用通用宿主子节中解释的那样:

var host = new HostBuilder()
    .ConfigureServices((hostContext, services) =>
    {
        services.AddSingleton(this.StateManager);
        services.AddSingleton(this.LogQueue);
        services.AddSingleton(configurationPackage);
        services.AddHostedService<ComputeStatistics>();
    })
    .Build();
await host.RunAsync(cancellationToken);

ConfigureServices定义了所有可能由IHostedService实现需要的单例实例,因此它们被注入到所有引用其类型的实现构造函数中。然后,AddHostedService声明微服务的唯一IHostedService。一旦构建了IHost,我们就运行它,直到RunAsync取消令牌被信号。当取消令牌被信号时,关闭请求传递给所有IHostedService实现。

与服务通信

由于我们尚未实现整个购买逻辑,我们将实现一个无状态微服务,该服务向LogStore服务发送随机数据。在解决方案资源管理器中右键单击PurchaseLogging项目,并选择添加 | Service Fabric 服务。然后,选择.NET Core 无状态模板,并将新的微服务项目命名为FakeSource

现在,让我们添加对Interaction项目的引用。在继续到服务代码之前,我们需要更新ApplicationManifest.xml中以及所有其他环境特定参数覆盖(云、一个本地集群节点、五个本地集群节点)中新建服务的副本计数。

<Parameter Name="FakeSource_InstanceCount" DefaultValue="2" />

这个模拟服务不需要监听器,其RunAsync方法很简单:

string[] locations = new string[] { "Florence", "London", "New York", "Paris" };

protected override async Task RunAsync(CancellationToken cancellationToken)
{
    Random random = new Random();
    while (true)
    {
        cancellationToken.ThrowIfCancellationRequested();

        PurchaseInfo message = new PurchaseInfo
        {
            Time = DateTimeOffset.Now,
            Location= locations[random.Next(0, locations.Length)],
            Cost= 200m*random.Next(1, 4)
        };
        //Send message to counting microservices 
        ...
        ...

        await Task.Delay(TimeSpan.FromSeconds(1), cancellationToken);
    }
}

在每个循环中,创建一个随机消息并发送到计数微服务。然后,线程休眠一秒钟并开始新的循环。发送创建的消息的代码如下:

//Send message to counting microservices 
var partition = new ServicePartitionKey(Math.Abs(message.Location.GetHashCode()) % 1000);
var client = ServiceProxy.Create<ILogStore>(
    new Uri("fabric:/PurchaseLogging/LogStore"), partition);
try
{
    while (!await client.LogPurchase(new  
    IdempotentMessage<PurchaseInfo>(message)))
    {
        await Task.Delay(TimeSpan.FromMilliseconds(100),   
        cancellationToken);
    }
}
catch
{

}

在这里,从位置字符串计算出一个 0-9,999 区间的键。这个整数传递给ServicePartitionKey构造函数。然后,创建一个服务代理,并将要调用的服务的 URI 和分区键传递。代理使用这些数据向命名服务请求给定分区值的物理 URI。

ServiceProxy.Create还接受一个第三个可选参数,指定代理发送的消息是否也可以路由到二级副本。默认情况下,消息仅路由到主实例。如果消息目标返回false,表示它尚未准备好(记住,当LogStore消息队列尚未创建时,LogPurchase返回false),则在 100 毫秒后尝试相同的传输。

向远程目标发送消息非常简单。然而,其他通信监听器要求发送者手动与命名服务交互以获取物理服务 URI。这可以通过以下代码完成:

ServicePartitionResolver resolver = ServicePartitionResolver.GetDefault();

ResolvedServicePartition partition =     
await resolver.ResolveAsync(new Uri("fabric:/MyApp/MyService"), 
    new ServicePartitionKey(.....), cancellationToken);
//look for a primary service only endpoint
var finalURI= partition.Endpoints.First(p => 
    p.Role == ServiceEndpointRole.StatefulPrimary).Addreess;

此外,在通用通信协议的情况下,我们必须手动使用库(如 Polly)处理失败和重试(有关更多信息,请参阅弹性任务执行子节)。

测试应用程序

为了测试应用程序实际上是否计算了累计购买总额,让我们在ComputeStatistics.cs文件中放置一个断点:

total = newCounter.Update(item.Time, item.Cost);
if (counter.HasValue)...//put breakpoint on this line

每当断点被触发时,查看newCounter的内容以验证所有位置的累计总和是如何变化的。

摘要

在本章中,我们描述了微服务是什么以及它们是如何从模块的概念演变而来的。然后,我们讨论了微服务的优势以及何时值得使用它们,以及它们设计的一般标准。我们还解释了 Docker 容器是什么,并分析了容器与微服务架构之间的紧密联系。

然后,我们通过描述.NET Core 中所有可用的工具来实施更实际的实现,这样我们就可以实现基于微服务的架构。我们还描述了微服务所需的基础设施以及 Azure 集群如何提供 Azure Kubernetes 服务和 Azure Service Fabric。

最后,我们通过实现 Service Fabric 应用程序将这些概念付诸实践。在这里,我们探讨了 Service Fabric 应用程序可以实现的多种方式。

下一章将重点介绍如何使用 ORM 和 Entity Framework Core 与各种类型的数据库交互,同时保持我们的代码与所选数据库引擎的独立性。

问题

  1. 模块概念的双重性质是什么?

  2. 微服务的优势仅仅是扩展优化吗?如果不是,请列出一些其他优势。

  3. Polly 是什么?

  4. ConfigureServices是什么?

  5. Visual Studio 提供了哪些 Docker 支持?

  6. 那么,哪种 Docker 应用程序方法更强大:基于.yml文件的方法还是基于.yaml文件的方法?

  7. 在定义 Azure Service Fabric 集群时必须声明哪些类型的端口?

  8. 为什么需要可靠状态服务的分区?

  9. 我们如何声明远程通信必须由辅助副本处理?对于其他类型的通信呢?

进一步阅读

以下链接是 Azure Service Bus 和 RabbitMQ 这两个事件总线技术的官方文档:

可靠通信/任务的工具 Polly 的文档可以在这里找到:github.com/App-vNext/Polly.

更多关于 Docker 的信息可以在 Docker 的官方网站上找到:docs.docker.com/.

Kubernetes 和.yaml文件的官方文档可以在这里找到:kubernetes.io/docs/home/.

Azure Kubernetes 的官方文档可以在这里找到:docs.microsoft.com/en-US/azure/aks/.

Azure Service Fabric 的官方文档可以在这里找到:docs.microsoft.com/en-US/azure/service-fabric/.

Azure Service Fabric 的可靠服务的官方文档可以在这里找到:docs.microsoft.com/en-us/azure/service-fabric/service-fabric-reliable-services-introduction.

更多关于 Actor 模型的信息可以在这里找到:www.researchgate.NET/publication/234816174_Actors_A_conceptual_foundation_for_concurrent_object-oriented_programming.

可以在 Azure Service Fabric 中实现的 Actor 模型的官方文档可以在这里找到:docs.microsoft.com/en-US/azure/service-fabric/service-fabric-reliable-actors-introduction.

微软还实现了一个独立于 Service Fabric 的高级 actor 模型,这被称为 Orleans 框架。更多关于 Orleans 的信息可以在以下链接中找到:

第六章:在 C# 中与数据交互 - Entity Framework Core

正如我们在第五章中提到的,将微服务架构应用于企业应用程序,软件系统被组织成层,并且每一层通过不依赖于层实现方式的接口与前一层和后一层进行通信。当软件是业务/企业系统时,它通常至少包含三个层:数据层、业务层和表示层。一般来说,每一层提供的接口以及层的实现方式取决于应用程序。

然而,结果证明数据层提供的功能相当标准化,因为它们只是将数据从数据存储子系统映射到对象,反之亦然。这导致了在相当声明式的方式下实现数据层的通用框架的概念。这些工具被称为 对象关系映射ORM)工具,因为它们是基于关系数据库的数据存储子系统。然而,它们与现代非关系存储(如 MongoDB 和 Azure Cosmos DB)也很好地协同工作,因为它们的数据模型比纯关系模型更接近目标对象模型。

本章将涵盖以下主题:

  • 理解 ORM 基础知识

  • 配置 Entity Framework Core

  • Entity Framework Core 迁移

  • 使用 Entity Framework Core 查询和更新数据

  • 部署您的数据层

  • 理解 Entity Framework Core 高级功能 - 全局过滤器

本章描述了 ORM 以及如何对其进行配置,然后重点介绍了 Entity Framework Core,它是 .NET Core 中包含的 ORM。

技术要求

本章需要 Visual Studio 2017 或 2019 免费社区版或更高版本,并安装所有数据库工具。

本章中的所有概念都将通过基于 WWTravelClub 书籍用例的实用示例进行说明。您可以在github.com/PacktPublishing/Hands-On-Software-Architecture-with-CSharp-8找到本章的代码。

理解 ORM 基础知识

ORM 将关系数据库表映射到内存中的对象集合,其中对象属性对应于数据库表字段。来自 C# 的类型,如布尔值、数值类型和字符串,有对应的数据库类型。如果映射的数据库中没有 GUID,而单个字符映射到数据库的单字符字符串,那么像 GUID 这样的类型则映射到它们的等效字符串表示。所有日期和时间类型要么映射到 C# 的 DateTime(当日期/时间不包含时区信息时),要么映射到 DateTimeOffset(当日期/时间也包含显式时区信息时)。任何数据库时间长度都映射到 TimeSpan

由于大多数面向对象语言的字符串属性没有与之关联的长度限制(而数据库字符串字段通常有长度限制),因此在数据库映射配置中考虑了数据库限制。一般来说,当需要指定数据库类型和面向对象语言类型之间的映射选项时,这些选项在映射配置中声明。

整个配置的定义方式取决于具体的 ORM。Entity Framework Core 提供了三种选项:

  • 数据注释(属性属性)

  • 命名约定

  • 基于配置对象和方法的流畅配置接口

虽然流畅式接口可以用来指定任何配置选项,但数据注释和命名约定可以用于其中较小的一部分。

每个 ORM 都适应特定的数据库类型(Oracle、MySQL、SQL Server 等),并使用称为提供程序连接器的数据库特定适配器。Entity Framework Core 为大多数可用的数据库引擎提供了提供程序。

可以在docs.microsoft.com/en-US/ef/core/providers/找到提供程序的完整列表。

适配器对于数据库类型之间的差异、事务处理方式以及 SQL 语言未标准化的所有其他功能都是必要的。

表之间的关系用对象指针表示。例如,在一对多关系中,映射到关系一方的类包含一个集合,该集合填充了关系多方相关的对象。另一方面,映射到关系多方的类有一个简单的属性,该属性填充了关系一方唯一相关的对象。

整个数据库(或其一部分)由一个内存缓存类表示,该类包含映射到数据库表的每个集合的属性。首先,在内存缓存类的实例上执行查询和更新操作,然后与此实例同步数据库。Entity Framework Core 使用的内存缓存类称为DBContext,它还包含映射配置。更具体地说,通过继承DBContext并向所有映射集合和所有必要的配置信息添加,可以获得特定于应用程序的内存缓存类。

总结来说,DBContext子类实例包含与数据库同步的数据库的部分快照,以获取/更新实际数据。

数据库查询是通过在内存缓存类集合上调用方法来执行的查询语言。实际的 SQL 是在同步阶段创建和执行的。例如,Entity Framework Core 在映射到数据库表的集合上执行语言集成查询LINQ)。

通常,LINQ 查询生成IEnumerable实例,即元素在查询结束时创建IEnumerable时并未计算,而是在你实际尝试从IEnumerable检索集合元素时计算。其工作原理如下:

  • DBContext的映射集合开始的 LINQ 查询创建一个名为IQueryable的特定IEnumerable子类。

  • IQueryable 包含发出数据库查询所需的所有信息,但实际的 SQL 是在检索IQueryable的第一个元素时生成和执行的。

  • 因此,在 Entity Framework Core 的情况下,数据库同步是在从最终的IQueryable检索元素时执行的。

  • 通常,每个 Entity Framework 查询都以ToListToArray操作结束,该操作将IQueryable转换为列表或数组,从而在数据库上实际执行查询。

  • 如果预期查询将仅返回单个元素或根本不返回任何元素,我们通常执行一个FirstOrDefault操作,该操作返回一个元素(如果有的话),或者返回null

此外,对 DB 表的新实体进行更新、删除和添加操作是通过模拟在表示数据库表的DBContext集合属性上执行这些操作来完成的。然而,实体只能通过查询将其加载到内存集合中后以这种方式更新或删除。更新查询需要根据需要修改实体的内存表示,而删除查询则需要从其内存映射集合中删除实体的内存表示。在 Entity Framework Core 中,通过调用集合的Remove(entity)方法执行删除操作。

添加新实体没有其他要求。只需将新实体添加到内存集合中即可。在内存集合上执行的各种更新、删除和添加操作实际上是通过显式调用 DB 同步方法传递给数据库的。例如,当你调用DBContext.SaveChanges()方法时,Entity Framework Core 会将DBContext实例上执行的所有更改传递到数据库。

在同步操作期间传递给数据库的更改将在单个事务中执行。此外,对于具有显式事务表示的 ORM,例如 Entity Framework Core,同步操作是在事务的作用域内执行的,因为它使用该事务而不是创建一个新的。

本章的其余部分解释了如何使用 Entity Framework Core,以及一些基于本书 WWTravelClub 用例的示例代码。

配置 Entity Framework Core

由于数据库处理被限制在专用应用程序层内,将 Entity Framework Core (DBContext)定义在单独的库中是一种良好的做法。相应地,我们需要定义一个.NET Core 类库项目。正如我们在第二章,功能和非功能需求部分的书籍用例 - .NET Core 的实际应用,.NET Core 项目的类型中讨论的,我们有两种不同类型的库项目:.NET Standard.NET Core

虽然.NET Core 库与特定的.NET Core 版本相关联,但.NET Standard 2.0 库具有广泛的应用范围,因为它们可以与任何大于 2.0 的.NET 版本以及经典的.NET Framework 一起工作。

然而,Microsoft.EntityFrameworkCore包(我们在数据库层需要它)仅依赖于.NET Standard 2.0。它被设计为与特定的.NET Core 版本一起工作(其版本号与.NET Core 版本相同)。因此,如果我们定义我们的数据库层为.NET Standard 2.0,我们添加作为依赖项的特定Microsoft.EntityFrameworkCore包可能与另一个系统组件中包含的同一库的另一个版本发生冲突,该组件与特定的.NET Core 版本相关联。

由于我们的库不是通用库(它只是特定应用程序的一个组件),我们更倾向于将其绑定到特定的.NET Core 版本,而不是在整个应用程序设计中跟踪其版本依赖关系。因此,让我们选择一个.NET Core 库项目,用于安装在我们机器上的最新.NET Core 版本。我们的.NET Core 库项目可以创建和准备如下:

  1. 打开 Visual Studio,定义一个名为WWTravelClubDB的新解决方案,然后选择适用于最新.NET Core 版本的类库 (.NET Core)。

  2. 我们必须安装所有与 Entity Framework Core 相关的依赖项。安装所有必需依赖项的最简单方法是将我们打算使用的数据库引擎提供者的 NuGet 包添加到项目中——在我们的例子中,是 SQL Server——正如我们在第四章,选择最佳云解决方案中提到的。实际上,任何提供者都会安装所有所需的包,因为它将它们作为依赖项。因此,让我们添加Microsoft.EntityFrameworkCore.SqlServer的最新稳定版本。如果你打算使用多个数据库引擎,你也可以添加其他提供者,因为它们可以并行工作。在本章的后面部分,我们将安装其他包含我们需要的工具的 NuGet 包,然后我们将解释如何安装进一步处理 Entity Framework Core 配置所需的工具。

  3. 让我们将默认的Class1类重命名为MainDBContext。这被自动添加到类库中。

  4. 现在,让我们用以下代码替换其内容:

using System;
using Microsoft.EntityFrameworkCore;

namespace WWTravelClubDB
{
    public class MainDBContext: DbContext
    {
        public MainDBContext(DbContextOptions options)
            : base(options)
        {
        }
        protected override void OnModelCreating(ModelBuilder 
        builder)
        {
        } 
    }
}
  1. 我们从DbContext继承,并且必须将DbContextOptions传递给DBContext构造函数。DbContextOptions包含创建选项,如数据库连接字符串,这些选项取决于目标数据库引擎。

  2. 所有已映射到数据库表中的集合都将作为MainDBContext的属性添加。映射配置将在重写的OnModelCreating方法中定义,该方法通过参数传递的ModelBuilder对象来实现。

下一步是创建所有代表数据库表行的类。这些被称为实体。我们需要为每个想要映射的数据库表创建一个实体类。让我们在项目根目录下创建一个Models文件夹来存放所有这些类。下一个小节将解释如何定义所有必需的实体。

定义数据库实体

数据库设计,就像整个应用程序设计一样,是有组织的迭代过程。让我们假设在第一次迭代中,我们需要一个包含两个数据库表的原型:一个用于所有旅行套餐,另一个用于所有由套餐引用的位置。每个套餐只覆盖一个位置,而单个位置可能被多个套餐覆盖,因此两个表通过一对一的关系连接。

因此,让我们从位置数据库表开始。正如我们在上一节末尾提到的,我们需要一个实体类来表示这个表的行。让我们将实体类命名为Destination

namespace WWTravelClubDB.Models
{
    public class Destination
    {
        public int Id { get; set; }
        public string Name { get; set; }
        public string Country { get; set; }
        public string Description { get; set; }
    }
}

所有数据库字段都必须由可读/写的 C#属性表示。假设每个目的地就像一个城镇或地区,可以通过其名称和所在国家来定义,并且所有相关信息都包含在其Description中。在未来的迭代中,我们可能会添加更多字段。Id是一个自动生成的键。

然而,现在,我们需要添加有关所有字段如何映射到数据库字段的信息。在 Entity Framework Core 中,所有原始类型都由特定于数据库引擎的提供程序自动映射到数据库类型(在我们的情况下是 SQL Server 提供程序)。我们唯一关心的是以下内容:

  • 字符串长度限制:可以通过为每个字符串属性应用适当的MaxLengthMinLength属性来考虑。所有对实体配置有用的属性都包含在System.ComponentModel.DataAnnotationSystem.ComponentModel.DataAnnotations.Schema命名空间中。因此,将它们都添加到所有实体定义中是一个好的实践。

  • 指定哪些字段是必需的,哪些是可选的:默认情况下,所有引用类型(如所有字符串)都被假定为可选的,而所有值类型(如数字和 GUID)都被假定为必需的。如果我们想使一个引用类型成为必需的,那么我们必须用Required属性来装饰它。然而,如果我们想使T值类型属性成为可选的,那么我们必须用T?来替换它。

  • 指定哪个属性代表主键:键可以通过使用 Key 属性来指定。然而,如果没有找到 Key 属性,则将名为 Id 的属性(如果有的话)视为主键。在我们的案例中,不需要 Key 属性。如果主键由多个属性组成,只需将 Key 属性添加到所有这些属性上即可。

由于每个目的地在一对多关系的一侧,它必须包含一个相关包实体的集合;否则,我们无法在我们的 LINQ 查询子句中引用相关实体。

将一切整合,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), Required]
        public string Name { get; set; }
        [MaxLength(128), Required]
        public string Country { get; set; }
        public string Description { get; set; }
        public ICollection<Package> Packages { get; set; }
    }
}

由于 Description 属性没有长度限制,它将使用不定长度的 SQL Server ntext 字段实现。我们可以用类似的方式编写 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), Required]
        public string Name { get; set; }
        [MaxLength(128)]
        public string Description { get; set; }
        public decimal Price { get; set; }
        public int DuratioInDays { get; set; }
        public DateTime? StartValidityDate { get; set; }
        public DateTime? EndValidityDate { get; set; }
        public Destination MyDestination { get; set; }
        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);

关系的两侧通过我们添加到实体的导航属性来指定。HasForeignKey 指定外部键。最后,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 方法所取代。

ModelBuilder builder 对象允许我们使用如下方式指定数据库索引:

builder.Entity<T>()
   .HasIndex(m => m.PropertyName);

多属性索引的定义如下:

builder.Entity<T>()
    .HasIndex("propertyName1", "propertyName2", ...);

如果我们添加所有必要的配置信息,那么我们的 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);

    builder.Entity<Destination>()
        .HasIndex(m => m.Country);

    builder.Entity<Destination>()
        .HasIndex(m => m.Name);

    builder.Entity<Package>()
 .HasIndex(m => m.Name);

    builder.Entity<Package>()
        .HasIndex("StartValidityDate", "EndValidityDate");
} 

一旦配置了 Entity Framework Core,我们就可以使用所有配置信息来创建实际的数据库,并将所有需要的工具放置到位,以便随着应用程序的发展更新数据库的结构。下一节将解释如何操作。

Entity Framework Core 迁移

现在我们已经配置了 Entity Framework 并定义了我们的应用程序特定的 DBContext 子类,我们可以使用 Entity Framework Core 设计工具生成物理数据库并创建 Entity Framework Core 与数据库交互所需的数据库结构快照。

Entity Framework Core 设计工具必须作为 NuGet 包安装在每个需要它们的项目中。有两个等效选项:

  • 在任何 Windows 控制台中工作的工具:这些通过 Microsoft.EntityFrameworkCore.Design NuGet 包提供。所有 Entity Framework Core 命令都在 dotnet ef ...... 格式中,因为它们包含在 .NET Core 命令行应用程序的 ef 命令中。

  • Visual Studio 特定的工具:这些包含在 Microsoft.EntityFrameworkCore.Tools NuGet 包中。由于它们只能从 Visual Studio 内部的包管理器控制台启动,因此不需要 dotnet ef 前缀。

Entity Framework Core 的设计工具在设计/更新过程中使用。该过程如下:

  1. 我们根据需要修改 DBContext 和实体的定义。

  2. 我们启动设计工具,要求 Entity Framework Core 检测和处理我们所做的所有更改。

  3. 一旦启动,设计工具将更新数据库结构快照并生成一个新的迁移,即包含所有我们需要修改物理数据库以反映所有更改的指令的文件。

  4. 我们推出另一个工具来更新数据库,以包含新创建的迁移。

  5. 我们测试新配置的 DB 层,如果需要新的更改,我们将返回到步骤 1

  6. 当数据层准备就绪时,它将在预发布或生产环境中部署,此时所有迁移将再次应用到实际的预发布/生产数据库中。

这在各个软件项目迭代和应用程序的生命周期中重复多次。如果我们操作的是已经存在的数据库,我们需要配置DBContext及其模型以反映我们想要映射的所有表的现有结构。然后,我们使用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 凭据进行连接。你可以自由地更改它以反映你的需求。

现在,我们准备好创建我们的第一个迁移了!让我们开始吧:

  1. 让我们转到包管理控制台,并确保 WWTravelClubDB 被选为我们的默认项目。

  2. 现在,输入Add-Migration initial并按Enter键来执行此命令:

图片

initial是我们给第一个迁移取的名字。所以,一般来说,命令是Add-Migration <迁移名称>。当我们操作现有数据库时,我们必须将-IgnoreChanges选项添加到第一个迁移(仅添加到该迁移)以创建一个空迁移。有关所有命令的引用可以在进一步阅读部分找到。

  1. 如果在创建迁移之后但在将迁移应用到数据库之前,我们意识到我们犯了错误,我们可以使用Remove-Migration命令撤销我们的操作。如果迁移已经应用到数据库,纠正我们的错误的最简单方法是对代码进行所有必要的更改,然后应用另一个迁移。

  2. 一旦执行了Add-Migration命令,我们的项目中就会出现一个新的文件夹:

图片

20190205102637_initial.cs是我们用易于理解的语言表达的迁移。

您可以审查代码以验证一切是否正常,并且您也可以修改迁移内容(只有当您足够专业才能可靠地完成时)。每个迁移都包含一个Up方法和一个Down方法。Up方法表示迁移,而Down方法则撤销其更改。因此,Down方法包含Up方法中包含的所有操作的逆序操作。

20190205102637_initial.Designer.cs是您必须不修改的 Visual Studio 设计器代码,而MainDBContextModelSnapshot.cs是整体数据库结构快照。如果您添加了更多的迁移,新的迁移文件及其设计器对应文件将出现,并且唯一的MainDBContextModelSnapshot.cs数据库结构快照将被更新以反映数据库的整体结构。

您可以在 Windows 控制台中通过输入dotnet ef migrations add initial来发出相同的命令。然而,此命令必须从项目的根目录(而不是从解决方案的根目录)发出。

您可以通过在包管理器控制台中输入Update-Database来将迁移应用到数据库。等效的 Windows 控制台命令是dotnet ef database update。让我们尝试使用这个命令来创建物理数据库!

下一个子节解释了如何创建 Entity Framework 无法自动创建的数据库内容。之后,在下一节中,我们将使用 Entity Framework 的配置以及我们使用dotnet ef database update生成的数据库来创建、查询和更新数据。

理解存储过程和直接 SQL 命令

一些数据库结构不能通过我们之前描述的 Entity Framework Core 命令和声明自动生成。例如,Entity Framework Core 不能自动生成存储过程。如通用 SQL 字符串之类的存储过程可以通过migrationBuilder.Sql("<sql scommand>")方法手动包含在UpDown方法中。

做这件事最安全的方式是添加一个不执行任何配置更改的迁移,这样在创建时迁移是空的。然后,我们可以将必要的 SQL 命令添加到这个迁移的空 Up 方法中,以及它们的逆命令在空 Down 方法中。将所有 SQL 字符串放在资源文件(.resx 文件)的属性中是一种良好的做法。

现在,你已经准备好通过 Entity Framework Core 与数据库进行交互了。

使用 Entity Framework Core 查询和更新数据

为了测试我们的数据库层,我们需要将一个基于与我们的库相同 .NET Core 版本的控制台项目添加到解决方案中。让我们开始吧:

  1. 让我们将新的控制台项目命名为 WWTravelClubDBTest

  2. 现在,我们需要通过在控制台项目的 References 节点右键单击并选择 Add reference 来将我们的数据层作为控制台项目的依赖项添加。

  3. program.cs 文件中的 Main 静态方法中删除内容,并开始编写以下内容:

Console.WriteLine("program start: populate database");
Console.ReadKey();
  1. 然后,在文件顶部添加以下命名空间:
using WWTravelClubDB;
using WWTravelClubDB.Models;
using Microsoft.EntityFrameworkCore;
using System.Linq;

现在我们已经完成了测试项目的准备工作,我们可以尝试查询和数据更新。让我们首先创建一些数据库对象,即一些目的地和包。按照以下步骤操作:

  1. 首先,我们必须使用适当的连接字符串创建我们的 DBContext 子类的一个实例。我们可以使用设计工具使用的相同 LibraryDesignTimeDbContextFactory 类来获取它:
var context = new LibraryDesignTimeDbContextFactory()
    .CreateDbContext(); 
  1. 可以通过简单地将类实例添加到我们 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),
            DuratioInDays=7,
            Price=1000
        },
        new Package
        {
            Name = "Winter in Florence",
            StartValidityDate = new DateTime(2019, 12, 1),
            EndValidityDate = new DateTime(2020, 2, 1),
            DuratioInDays=7,
            Price=500
        }
    }
};
context.Destinations.Add(firstDestination);
context.SaveChanges();
Console.WriteLine(
    "DB populated: first destination id is "+
    firstDestination.Id);
Console.ReadKey();

没有必要指定主键,因为它们是自动生成的,将由数据库填充。实际上,在 SaveChanges() 操作同步我们的上下文与实际数据库后,firstDestination.Id 属性有一个非零值。对于 Package 的主键也是如此。

当我们通过将其插入父实体集合(在我们的例子中是 Packages 集合)来声明一个实体(在我们的例子中是 Package)是另一个实体(在我们的例子中是 Destination)的子实体时,没有必要显式设置其外键(在我们的例子中是 DestinationId),因为 Entity Framework Core 会自动推断它。一旦创建并与 firstDestination 数据库同步,我们可以通过两种不同的方式添加更多包:

  • 创建一个 Package 类实例,将其 DestinationId 外键设置为 firstDestination.Id,并将其添加到 context.Packages

  • 创建一个不需要设置外键的 Package 类实例,然后将其添加到其父 Destination 实例的 Packages 集合中。

后一种选项是唯一的选择,当添加具有其父实体(Destination)的子实体(Package)且父实体具有自动生成的主键时,在这种情况下,外部键在我们执行添加操作时不可用。在大多数其他情况下,前一种选项更简单,因为第二种选项需要将父Destination实体及其Packages集合加载到内存中,即与所有与Destination对象关联的套餐一起(默认情况下,通过查询不会加载连接的实体)。

现在,假设我们想修改佛罗伦萨目的地,并将所有佛罗伦萨套餐的价格提高 10%。我们该如何操作?按照以下步骤了解如何进行:

  1. 首先,我们需要通过查询将实体加载到内存中,修改它,并调用SaveChanges()来同步我们的更改与数据库。如果我们只想修改,比如描述,以下查询就足够了:
var toModify = context.Destinations
    .Where(m => m.Name == "Florence").FirstOrDefault();
  1. 我们需要加载所有默认未加载的相关目的地套餐。这可以通过Include子句完成,如下所示:
var toModify = context.Destinations
    .Where(m => m.Name == "Florence")
    .Include(m => m.Packages)
    .FirstOrDefault();
  1. 之后,我们可以修改描述和套餐价格,如下所示:
toModify.Description = 
  "Florence is a famous historical Italian town";
foreach (var package in toModify.Packages)
   package.Price = package.Price * 1.1m;
context.SaveChanges();

var verifyChanges= context.Destinations
    .Where(m => m.Name == "Florence")
    .FirstOrDefault();

Console.WriteLine(
    "New Florence description: " +
    verifyChanges.Description);
Console.ReadKey();

到目前为止,我们执行了查询,其唯一目的是更新检索到的实体。接下来,我们将解释如何检索将显示给用户以及/或用于复杂业务操作的信息。

将数据返回到表示层

为了保持层之间的分离,并使查询适应每个用例实际需要的数据,数据库实体不会直接发送到表示层。相反,数据被投影到包含用例所需信息的更小的类中。这些类由表示层的调用方法实现。在将数据从一个层移动到另一个层的对象被称为数据传输对象DTOs)。例如,让我们创建一个包含当向用户返回套餐列表时值得显示的摘要信息的 DTO(我们假设如果需要,用户可以通过点击他们感兴趣的套餐来获取更多详细信息):

  1. 让我们在我们的 WWTravelClubDBTest 项目中添加一个 DTO,其中包含需要在套餐列表中显示的所有信息:
namespace WWTravelClubDBTest
{
    public class PackagesListDTO
    {
        public int Id { get; set; }
        public string Name { get; set; }
        public decimal Price { get; set; }
        public int DuratioInDays { get; set; }
        public DateTime? StartValidityDate { get; set; }
        public DateTime? EndValidityDate { get; set; }
        public string DestinationName { get; set; }
        public int DestinationId { get; set; }
        public override string ToString()
        {
            return string.Format("{0}. {1} days in {2}, price: 
            {3}", Name, DuratioInDays, DestinationName, Price);
        }
    }
}

我们不需要在内存中加载实体并将它们的数据复制到 DTO 中,但数据库数据可以直接投影到 DTO 中,这得益于 LINQ 的Select子句。这最小化了与数据库交换的数据量。

  1. 例如,我们可以使用查询来填充我们的 DTOs,该查询检查所有在 8 月 10 日左右可用的套餐:
var period = new DateTime(2019, 8, 10);
var list = context.Packages
    .Where(m => period >= m.StartValidityDate
    && period <= m.EndValidityDate)
    .Select(m => new PackagesListDTO
    {
        StartValidityDate=m.StartValidityDate,
        EndValidityDate=m.EndValidityDate,
        Name=m.Name,
        DuratioInDays=m.DuratioInDays,
        Id=m.Id,
        Price=m.Price,
        DestinationName=m.MyDestination.Name,
        DestinationId = m.DestinationId
    })
    .ToList();
foreach (var result in list)
    Console.WriteLine(result.ToString());
Console.ReadKey();
  1. Select子句中,我们还可以导航到任何相关实体以获取所需的数据。例如,前面的查询导航到相关的Destination实体以获取Package的目的地名称。

  2. 现在,在解决方案资源管理器中右键单击 WWTravelClubDBTest 项目,将其设置为启动项目。然后,运行解决方案。

  3. 程序在每个 Console.ReadKey() 方法处停止,等待您按任意键。这样,您就有时间分析所有代码片段产生的输出,这些代码片段都已添加到 Main 方法中。

现在,我们将学习如何处理无法有效地映射到表示数据库表的内存集合的即时操作的运算。

发出直接 SQL 命令

并非所有数据库操作都可以通过使用 LINQ 查询数据库并在内存中更新实体来高效执行。例如,计数器增加可以通过单个 SQL 指令更高效地执行。此外,如果我们定义了适当的存储过程/SQL 命令,一些操作可以以可接受的性能执行。在这些情况下,我们被迫直接向数据库发出 SQL 命令或从我们的 Entity Framework 代码中调用数据库存储过程。有两种可能性:执行数据库操作但不返回实体的 SQL 语句,以及返回实体的 SQL 语句。

不返回实体的 SQL 命令可以使用 DBContext 方法执行,如下所示:

int DBContext.Database.ExecuteSqlRaw(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 = context.Packages.FromSqlRaw("<some sql>", par1, par2, ...).ToList();

ExecuteSqlRaw 方法中,SQL 字符串和参数是这样工作的。以下是一个简单的示例:

var allPackages =context.Packages.FromSqlRaw(
    "SELECT * FROM Products WHERE Name = {0}",
    myPackageName)

将所有 SQL 字符串放入资源文件中,并将所有 ExecuteSqlRawFromSqlRaw 调用封装在您在 DBContext 子类中定义的公共方法内,是一种良好的实践,这样可以保持您的 Entity Framework Core 数据层内部对特定数据库的依赖。

处理事务

DBContext 实例所做的所有更改都在第一次 SaveChanges 调用中以单个事务传递。然而,有时有必要在同一个事务中包含查询和更新。在这些情况下,我们必须显式处理事务。如果我们将几个实体 Framework Core 命令放入与事务对象关联的 using 块中,那么这些命令可以包含在一个事务中:

using (var dbContextTransaction = context.Database.BeginTransaction())
{
    try{
        ...
        ...
        dbContextTransaction.Commit();
    }
    catch
    {
        dbContextTransaction.Rollback();
    }
}

在前面的代码中,context是我们DBContext子类的一个实例。在using块内部,可以通过调用其RollbackCommit方法来中止和提交事务。任何包含在事务块中的SaveChanges调用都将使用它们已经所在的交易,而不是创建新的交易。

部署你的数据层

当你的数据库层在生产或预发布环境中部署时,通常已经存在一个空数据库,因此你必须应用所有迁移以创建所有数据库对象。这可以通过调用context.Database.Migrate()来完成。Migrate方法应用尚未应用到数据库中的迁移,因此它可以在应用程序的生命周期中安全地多次调用。context是我们DBContext类的一个实例,必须通过一个具有足够权限创建表和执行我们迁移中包含的所有操作的连接字符串来传递。因此,通常,此连接字符串与我们在正常应用程序操作中使用的字符串不同。

在将 Web 应用程序部署到 Azure 的过程中,我们有机会使用我们提供的连接字符串来检查迁移。我们还可以在应用程序启动时通过调用context.Database.Migrate()方法手动检查迁移。这将在第十三章展示 ASP.NET Core MVC 中详细讨论,该章节专门介绍 ASP.NET MVC Web 应用程序。

对于桌面应用程序,我们可以在应用程序安装及其后续更新期间应用迁移。

在第一次应用程序安装和/或后续应用程序更新时,我们可能需要用初始数据填充一些表。对于 Web 应用程序,此操作可以在应用程序启动时执行,而对于桌面应用程序,此操作可以包含在安装过程中。

可以使用 Entity Framework Core 命令填充数据库表。首先,我们需要验证表是否为空,以避免多次添加相同的表行。这可以通过以下代码中的Any() LINQ 方法来完成:

if(!context.Destinations.Any())
{
    //populate here the Destinations table
}

让我们看看 Entity Framework Core 要分享的一些高级功能。

理解 Entity Framework Core 高级功能——全局过滤器

全局过滤器是在 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的文档)。类似的技术在多租户应用程序的实现中非常有用。

摘要

在本章中,我们探讨了 ORM 基础知识的要点以及为什么它们如此有用。然后,我们描述了 Entity Framework Core。特别是,我们讨论了如何使用类注解和其他包含在DBContext子类中的声明和命令来配置数据库映射。

然后,我们讨论了如何使用迁移自动创建和更新物理数据库结构,以及如何通过 Entity Framework Core 查询和传递数据库更新。最后,我们学习了如何通过 Entity Framework Core 传递直接 SQL 命令和事务,以及如何基于 Entity Framework Core 部署数据层。

本章还回顾了最新 Entity Framework Core 版本中引入的一些高级功能。

在下一章中,我们将讨论如何使用 Entity Framework Core 与 NoSQL 数据模型结合,以及云中(特别是 Azure 中)可用的各种存储选项。

问题

  1. Entity Framework Core 如何适应几个不同的数据库引擎?

  2. 在 Entity Framework Core 中如何声明主键?

  3. 在 Entity Framework Core 中如何声明字符串字段的长度?

  4. 在 Entity Framework Core 中如何声明索引?

  5. 在 Entity Framework Core 中如何声明关系?

  6. 两个重要的迁移命令是什么?

  7. 默认情况下,LINQ 查询是否加载相关实体?

  8. 是否可以在一个不是数据库实体的类实例中返回数据库数据?如果是,如何操作?

  9. 迁移如何在生产环境和预览环境中应用?

进一步阅读

第七章:如何在云中选择您的数据存储

与其他云服务一样,Azure 提供了广泛的存储设备。最简单的方法是在云中定义一组可扩展的虚拟机,我们可以在这些虚拟机上实现我们的自定义解决方案。例如,我们可以在云托管的虚拟机上创建一个 SQL Server 集群,以增加可靠性和计算能力。然而,通常,自定义架构并不是最佳解决方案,也没有充分利用云基础设施提供的机遇。

因此,本章将不会讨论此类自定义架构,而将主要关注云中可用的各种 存储即服务SaaS) 提供方案,特别是 Azure。这些方案包括基于普通磁盘空间的可扩展解决方案、关系型数据库、NoSQL 数据库以及如 Redis 这样的内存数据存储。

选择更合适的存储类型不仅基于应用程序的功能需求,还基于性能和扩展需求。实际上,在处理资源时进行扩展会导致性能的线性增长,但扩展存储资源并不一定意味着性能的合理增长。简而言之,无论你复制多少数据存储设备,如果多个请求影响的是同一数据块,它们将始终需要相同的时间来排队访问它!

扩展数据会导致读取操作吞吐量的线性增长,因为每个副本可以服务不同的请求,但这并不意味着写入操作吞吐量会有相同的增长,因为同一数据块的所有副本都必须更新!因此,需要更复杂的技巧来扩展存储设备,并且并非所有存储引擎都能同等程度地扩展。

尤其是在所有场景中,关系型数据库的扩展性都不好。因此,扩展需求和地理分布数据的需求在存储引擎的选择中起着基本作用,以及在 SaaS 提供商的选择中也是如此。

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

  • 理解不同目的的不同存储库

  • 在结构化或 NoSQL 存储之间进行选择

  • Azure Cosmos DB - 管理多大陆数据库的机会

  • 用例 - 存储数据

让我们开始吧:

技术要求

本章要求您具备以下条件:

  • Visual Studio 2017 或 2019 的免费社区版或更高版本,并安装所有数据库工具。

  • 一个免费的 Azure 账户。第一章中的“创建 Azure 账户”小节解释了如何创建一个。

  • 为了获得更好的开发体验,我们建议您还安装 Cosmos DB 的本地模拟器,可在aka.ms/cosmosdb-emulator找到。

理解不同目的的不同存储库

本节描述了最流行的数据存储技术所提供的功能。主要,我们将关注它们能够满足的功能需求。性能和扩展功能将在下一节中分析,该节专门用于比较关系型数据库和 NoSQL 数据库。在 Azure 中,可以通过在所有 Azure 门户页面顶部的搜索栏中输入产品名称来找到各种服务。

以下小节描述了我们可以用于我们的 C#项目的各种数据库类型。

关系型数据库

通常,云提供多种数据库引擎。Azure 提供各种流行的数据库引擎,如 SQL Server(Azure SQL Server)、MySQL 和 Oracle。

关于 Oracle 数据库引擎,Azure 提供可配置的虚拟机,这些虚拟机上安装了各种 Oracle 版本,您可以通过在 Azure 门户搜索栏中输入Oracle后得到的建议轻松验证。Azure 费用不包括 Oracle 许可证;它们仅提供计算时间,因此您必须将许可证带到 Azure。

在 Azure 上使用 MySQL 时,您需要为使用私有服务器实例付费。您产生的费用取决于您拥有的核心数量、需要分配多少内存以及备份保留时间。MySQL 实例是冗余的,您可以选择本地或地理分布式的冗余:

图片

Azure SQL Server 是最灵活的提供方案。在这里,您可以配置每个数据库使用的资源。当您创建数据库时,您可以选择将其放置在现有的服务器实例上或创建一个新的实例。费用基于已预留的数据库内存以及所需的数据库事务单元DTUs)。在这里,一个 DTU 是由参考工作负载确定的 I/O 操作、CPU 使用率和内存使用的线性组合。大致来说,当您增加 DTUs 时,最大数据库性能的增加是线性的:

图片

您还可以通过启用读扩展来配置数据复制。这样,您可以提高读取操作的性能。备份保留时间对于每个服务级别(基本、标准和高级)都是固定的。

如果您选择“是”以回答“是否想使用 SQL 弹性池?”,则数据库将被添加到弹性池中。添加到同一弹性池的数据库将共享其资源,因此未被数据库使用的资源可以在其他数据库的使用高峰期间使用。弹性池可以包含托管在不同服务器实例上的数据库。弹性池是优化资源使用以降低成本的有效方式。

NoSQL 数据库

在 NoSQL 数据库中,关系表被更通用的集合所取代,这些集合可以包含异构的 JSON 对象。也就是说,集合没有预定义的结构和没有预定义的字段长度限制(在字符串的情况下),但可以包含任何类型的对象。与每个集合相关联的唯一结构约束是作为主键的属性名称。

更具体地说,每个集合条目可以包含嵌套对象和嵌套在对象属性中的对象集合,即相关实体,在关系数据库中,这些实体包含在不同的表中并通过外部键连接。在 NoSQL 中,数据库可以嵌套在其父实体中。由于集合条目包含复杂的嵌套对象,而不是像关系数据库那样简单的属性/值对,因此条目不被称为元组或行,而是文档

在同一集合或不同集合中属于同一文档的文档之间不能定义关系和/或外部键约束。如果一个文档在其属性中包含另一个文档的主键,那么它这样做是有风险的。开发者有责任维护并保持这些一致性的引用。

最后,由于 NoSQL 存储相对便宜,整个二进制文件可以作为文档属性的值以 Base64 字符串的形式存储。开发者可以定义规则来决定在集合中索引哪些属性。由于文档是嵌套对象,属性实际上是树路径。通常情况下,默认情况下,所有路径都会被索引,但你也可以指定要索引哪些路径集合和子路径。

NoSQL 数据库可以通过 SQL 的子集或基于 JSON 的语言进行查询,其中查询是 JSON 对象,其路径表示要查询的属性,其值表示已应用于它们的查询约束。

在关系数据库中,可以通过一对多关系使用嵌套子对象的可能性。然而,在使用关系数据库时,我们被迫重新定义所有相关表的精确结构,而 NoSQL 集合不对它们包含的对象施加任何预定义的结构。唯一的约束是每个文档必须为主键属性提供一个唯一的值。因此,当我们的对象结构极其多变时,NoSQL 数据库是唯一的选择。然而,它们通常被选择用于扩展读/写操作的方式,以及更普遍的,在分布式环境中的性能优势。它们的功能将在下一节中讨论,该节将它们与关系数据库进行比较。

图数据模型是完全无结构文档的极端情况。整个数据库是一个图,查询可以在其中添加、更改和删除图文档。

在这种情况下,我们有两种类型的文档:节点和关系。虽然关系有一个定义良好的结构(通过关系连接的节点的主键,加上关系名称),但节点没有任何结构,因为属性及其值是在节点更新操作中一起添加的。图数据模型是为了表示人们及其操作的对象(媒体、帖子等)的特征以及他们在社交应用中的关系而设计的。Gremlin 语言是为了查询图数据模型而专门设计的。我们不会在本章中讨论这个问题,但在进一步阅读部分有相关参考资料。

在本章剩余的部分,我们将详细分析 NoSQL 数据库,这些部分专门用于描述 Azure Cosmos DB 并将其与关系数据库进行比较。

Redis

Redis 是一种基于键值对的分布式并发内存存储,支持分布式队列。它可以作为永久性内存存储,也可以作为数据库数据的 Web 应用程序缓存。或者,它可以渲染内容变化不大的页面。

Redis 还可以用于存储 Web 应用程序的用户会话数据。实际上,ASP.NET MVC、Pages 和 WebForms 支持会话数据以克服 HTTP 协议无状态的事实。更具体地说,用户数据在页面变化之间保持不变,并存储在 Redis 等服务器端存储中,并通过存储在 cookie 中的会话键进行索引。

与云中的 Redis 服务器交互通常基于 REST 接口;也就是说,每个 Redis 资源通过 HTTP GET 在 URI 上访问,命令通过查询字符串传递,而答案以 JSON 格式返回。然而,所有流行的语言中都有提供易于使用的接口的客户端。.NET 和.NET Core 的客户端可以通过StackExchange.Redis NuGet 包获得。StackExchange.Redis客户端的基本操作已在stackexchange.github.io/StackExchange.Redis/Basics中记录,而完整文档可以在stackexchange.github.io/StackExchange.Redis找到。

在 Azure 上定义 Redis 服务器的用户界面相当简单:

图片

价格层级下拉菜单允许我们选择可用的内存/复制选项之一。一个快速入门指南,解释如何使用 Azure Redis 凭据和StackExchange.Redis .NET Core 客户端的 URI,可以在docs.microsoft.com/en-us/azure/azure-cache-for-redis/cache-dotnet-core-quickstart找到。

磁盘内存

所有云都提供可扩展和冗余的通用磁盘内存,您可以用作虚拟机中的虚拟磁盘或作为外部文件存储。Azure 存储帐户 磁盘空间也可以在表和队列中进行结构化。然而,这两个存储选项仅支持向后兼容,因为 Azure NoSQL 数据库比表更好,Azure Redis 比 Azure 存储队列更好:

在本章的剩余部分,我们将重点关注 NoSQL 数据库以及它们与关系数据库的不同之处。接下来,我们将探讨如何在这两者之间进行选择。

在结构化存储或 NoSQL 存储之间进行选择

在上一节中,我们提到当数据几乎没有任何预定义结构时,应该优先考虑 NoSQL 数据库。实际上,非结构化数据可以在关系数据库中表示,因为元组t的可变属性可以放置在一个包含属性名称、属性值和t的外部键的连接表中。然而,问题是性能。事实上,属于单个对象的属性值会散布在可用的内存空间中。在一个小型数据库中,“散布在可用的内存空间中”意味着距离较远但位于同一磁盘上;在一个大型数据库中,这意味着距离较远但位于不同的磁盘单元中;在分布式云环境中,这意味着距离较远,可能位于不同的——甚至地理上分布的——服务器上。

另一方面,NoSQL 数据库不仅将可变属性与其所有者保持接近,而且由于它们允许相关对象嵌套在属性和集合中,它们还将一些相关对象保持接近。

因此,我们可以得出结论,当通常一起访问的表可以存储在内存附近时,关系数据库表现良好。另一方面,NoSQL 数据库自动确保相关数据保持接近,因为每个条目都将其相关的大部分数据作为嵌套对象包含在内。因此,当 NoSQL 数据库分布到不同的内存以及不同的地理上分布的服务器上时,它们的表现更好。

不幸的是,扩展存储写操作的唯一方法是将集合条目根据分片键的值分散到几个服务器上。例如,我们可以将所有以 A 开头的用户名记录放在一个服务器上,以 B 开头的记录放在另一个服务器上,依此类推。这样,不同起始字母的用户名的写操作可以并行执行,确保写吞吐量随着服务器数量的线性增长。

然而,如果一个分片集合与几个其他集合相关联,不能保证相关记录会被放置在同一个服务器上。此外,在不使用集合分片的情况下,将不同的集合放在不同的服务器上可以线性地增加写入吞吐量,直到达到每个服务器单个集合的极限,但这并不能解决在检索或更新通常一起处理的数据时被迫在多个服务器上执行多个操作的问题。

如果必须以事务性方式访问相关分布式对象,并且必须确保结构约束(如外部键约束)不被违反,这个问题将对性能造成灾难性的影响。在这种情况下,所有相关对象必须在事务期间被阻塞,防止其他请求在整个耗时的分布式操作期间访问它们。

NoSQL 数据库不受此问题的影响,并且通过分片以及随之而来的写入扩展输出表现更好。这是因为它们不会将相关数据分布到不同的存储单元,而是将它们存储为同一数据库条目的嵌套对象。

在 NoSQL 数据库设计中,我们总是试图将所有可能一起处理的相关对象放入一个单独的条目中。访问频率较低的相关对象被放置在不同的条目中。由于外部键约束不是自动强制执行的,并且 NoSQL 事务非常灵活,开发者可以在性能和一致性之间选择最佳折衷方案。

值得注意的是,在某些情况下,关系数据库在分片方面表现良好。一个典型的例子是多租户应用程序。在多租户应用程序中,所有条目集合可以被划分为不重叠的集合,称为租户。只有属于同一租户的条目可以相互引用,因此如果所有集合都根据它们的对象租户以相同的方式分片,所有相关记录最终都会落在同一个分片上,即同一个服务器上,并且可以有效地进行导航。

在云中,多租户应用程序并不罕见,因为向几个不同用户提供相同服务的所有应用程序通常都作为多租户应用程序实现,其中每个租户对应一个用户订阅。因此,关系数据库被设计在云中工作,例如 Azure SQL Server,并且通常为多租户应用程序提供分片选项。通常,分片不是云服务,必须使用数据库引擎命令定义。在这里,我们不会描述如何使用 Azure SQL Server 定义分片,但进一步阅读部分包含了一个指向官方 Microsoft 文档的链接。

总之,关系数据库提供了独立于实际存储方式的数据的纯粹逻辑视图,并使用声明性语言来查询和更新它们。这简化了开发和系统维护,但在需要写入扩展的分布式环境中可能会引起性能问题。在 NoSQL 数据库中,您必须手动处理更多关于如何存储数据的细节,以及所有更新和查询操作的一些过程性细节,但这也允许您在需要读取和写入扩展的分布式环境中优化性能。

在下一节中,我们将探讨 Azure Cosmos DB,这是 Azure 的主要 NoSQL 服务。

Azure Cosmos DB – 管理跨大陆数据库的机会

Azure Cosmos DB 是 Azure 的主要 NoSQL 服务。Azure Cosmos DB 拥有自己的接口,它是 SQL 的一个子集,但可以用 MongoDB 接口进行配置。它也可以配置为图数据模型,可以用 Gremlin 查询。Cosmos DB 允许进行复制以实现容错和读取扩展,副本可以地理分布以优化通信性能。此外,您可以指定所有副本放置在哪个数据中心。用户还可以选择启用所有副本的写入功能,以便在写入的地理区域立即可用。通过分片实现写入扩展,用户可以通过定义哪些属性用作分片键来配置它。

您可以通过在 Azure 门户搜索栏中键入 Cosmos DB 并点击添加来定义一个 Cosmos DB 账户。以下页面将出现:

图片

您选择的账户名称在资源 URI 中用作{账户名称}.documents.azure.com。API 下拉菜单允许您选择您喜欢的接口类型(SQL、MongoDB 或 Gremlin)。然后,您可以决定主数据库将放置在哪个数据中心,以及您是否希望启用地理分布复制。一旦启用了地理分布复制,您可以选择您想要使用的副本数量以及它们放置的位置。

最后,多区域写入切换功能允许您在地理分布的副本上启用写入。如果您不这样做,所有写入操作都将路由到主数据中心。

  1. 访问资源:一旦您创建了账户,选择数据探索器以在内部创建数据库和集合:

图片

  1. 创建集合:由于数据库只有名称而没有配置,您可以直接添加集合,然后选择您希望放置它的数据库:

图片

在这里,您可以决定数据库和集合名称以及用于分片(分区键)的属性。由于 NoSQL 条目是对象树,属性名称指定为路径。您还可以添加值必须唯一的属性。然而,唯一性 ID 是在每个分片中检查的,因此此选项仅在特定情况下有用,例如多租户应用程序(其中每个租户都包含在单个分片中)。费用取决于您选择的集合吞吐量。

  1. 将所有资源参数针对您的需求进行定位:吞吐量以每秒请求单位表示,其中每秒请求单位定义为每秒执行 1 KB 读取时的吞吐量。因此,如果您检查“配置数据库吞吐量”选项,所选的吞吐量将共享给整个数据库,而不是作为单个集合保留。

  2. 获取连接信息:通过选择“键”菜单,您将看到连接到您的应用中的 Cosmos DB 账户所需的所有信息:

图片

  1. 连接信息页面:在这里,您将找到账户 URI 和两个连接键,这些键可以互换使用来连接到账户:

图片

此外,还有一些具有只读权限的键。每个键都可以重新生成,并且每个账户都有两个等效的键,以便此操作可以高效地处理;也就是说,当一个键更改时,另一个键保持不变。因此,现有的应用程序可以在升级到新键之前继续使用另一个键。

  1. 选择默认一致性级别:通过选择“默认一致性”,您可以选择要应用于所有集合的默认复制一致性:

图片

此默认值可以在每个集合中覆盖,无论是从数据资源管理器还是通过编程方式。读写操作中的一致性问题是数据复制的结果。更具体地说,如果读取操作在不同的副本上执行,并且这些副本接收到了不同的部分更新,那么各种读取操作的结果可能是不一致的。

以下是可以用的可用一致性级别。这些级别已按从最弱到最强排序:

  • 最终一致性:经过足够的时间后,如果没有进一步的写操作,所有的读取将收敛并应用所有的写操作。

  • 一致前缀:所有写操作都在所有副本上以相同的顺序执行。因此,如果有n个写操作,每个读取都与应用前m个写操作的结果一致,其中m小于或等于n

  • 会话一致性:这与一致性前缀相同,但也保证了每个写入者在其所有后续读取操作中都能看到其自己的写入结果,并且每个读者的后续读取都是一致的(要么是相同的数据库,要么是其更新版本)。

  • 有限不一致性:这与延迟时间Delta或操作次数N相关联。每次读取都会看到在时间Delta之前(或最后N个操作之前)执行的所有写入操作的结果。也就是说,其读取与所有写入的结果收敛,最大时间延迟为Delta(或最大操作延迟为N)。

  • 强一致性:这是结合了Delta = 0的有限不一致性。在这里,每次读取都反映了所有之前的写入操作的结果。

可以获得最强的一致性,但会损害性能。默认情况下,一致性设置为 Session,这是在一致性和性能之间的一种良好折衷。在应用程序中处理较低级别的一致性比较困难,并且通常只有在会话是只读或只写的情况下才可接受。

如果你选择数据探索器中的“缩放和设置”选项,你可以配置要索引哪些路径以及将哪种索引应用于每个路径的每个数据类型。配置由一个 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"
                }
            ]
        }
    ]
},
...

“包含的路径”中的每个条目指定了一个路径模式,例如/subpath1/subpath2/?(设置仅应用于/subpath1/subpath2/属性)或/subpath1/subpath2/*(设置应用于以/subpath1/subpath2/开头的所有路径)。

当设置必须应用于集合属性中包含的子对象时,模式包含[]符号;例如,/subpath1/subpath2/[]/?/subpath1/subpath2/[]/childpath1/?等等。设置指定要应用于每个数据类型(字符串、数字、地理点等)的索引类型。范围索引用于比较操作,而如果需要进行相等比较,则散列索引更有效。

可以指定精度,即所有索引键中使用的最大字符数或数字数。-1表示无限制。对于字符串,-1是可以接受的,而对于数字,应使用有限的精度。另一方面,使用有限的精度与字符串可能会导致意外的行为,因为字符串键会被截断。在散列索引中,精度可能从 1 到 8 不等,而在范围索引中,精度可能从 1 到 100 不等:

    ...
    "excludedPaths": [
        {
            "path": "/\"_etag\"/?"
        }
    ]

excludedPaths中包含的路径根本不会被索引。索引设置也可以通过编程方式指定。

在这里,你有两种连接到 Cosmos DB 的选项:使用你首选编程语言的官方客户端版本,或者使用 Cosmos DB 的 Entity Framework Core 提供器,截至本书编写时,它仍在预览中。在接下来的小节中,我们将探讨这两种选项。然后,我们将通过一个实际示例描述如何使用 Cosmos DB 的 Entity Framework Core 提供器。

Cosmos DB 客户端

.NET Core 的 Cosmos DB 客户端可以通过 Microsoft.Azure.DocumentDB.Core NuGet 包获得。它提供了对所有 Cosmos DB 功能的完全控制,而 Cosmos DB Entity Framework 提供器更容易使用,但隐藏了一些 Cosmos DB 的特性。按照以下步骤通过官方 Cosmos DB .NET Core 客户端与 Cosmos DB 进行交互:

  1. 任何操作都需要创建一个客户端对象:
var client = new DocumentClient(new Uri("service endpoint"), "auth key")
  1. 不要忘记,当不再需要客户端时,必须通过调用其 Dispose 方法(或通过将引用它的代码封装在 using 语句中)来释放客户端。

  2. 然后,你可以使用以下代码获取数据库的引用并创建它(如果它不存在):

Database db = client.CreateDatabaseIfNotExistsAsync(new Database { Id = "MyDatabase" }).Result;
  1. 最后,你可以使用以下代码获取集合的引用或创建它(如果它不存在):
var collection = client.CreateDocumentCollectionIfNotExistsAsync(
    UriFactory.CreateDatabaseUri("MyDatabase"), 
    new DocumentCollection { Id = "MyCollection" }).Result;
  1. 在创建集合期间,你可以传递一个 option 对象,其中你可以指定一致性级别、如何索引属性以及所有其他集合功能。

  2. 然后,你必须定义与你在集合中需要操作的 JSON 文档结构相对应的 .NET 类。你也可以使用 JsonProperty 属性将类属性名称映射到 JSON 名称,如果它们不相等的话。

  3. 一旦你有了所有必要的类,你可以使用客户端方法来添加、更新和写入集合条目,以及客户端 CreateDocumentQuery 方法,它返回一个 IQueryable 值,你可以使用 LINQ 来查询。

当你读取文档,进行一些修改,然后尝试上传你修改后的文档版本时,其他人可能已经修改了相同的文档。通常,只有当没有其他人修改了相同的文档时,你才需要执行更新。这可以通过 _etag 属性来完成,Cosmos DB 会自动将其附加到每个文档上。此属性值在每次更新后都会改变,因此你需要遵循以下步骤:

  1. _etag JSON 属性映射到你的 .NET 类的一个属性上,这样在读取文档时你就能得到它的值。

  2. _etag 属性的原始值作为传递给 ReplaceDocumentAsync 客户端方法的 option 对象中 AccessCondition 属性的值。

  3. 如果 _etag 发生了变化,则调用 ReplaceDocumentAsync,中止操作并返回异常。

此外,还有 MvcControlsToolkit.Business.DocumentDB NuGet 包,它简化并自动化了 Microsoft.Azure.DocumentDB.Core 库所需的所有操作,并克服了 Cosmos DB SQL 的一些限制。进一步阅读部分包含了对 Microsoft.Azure.DocumentDB.CoreMvcControlsToolkit.Business.DocumentDB 的教程的引用。

Cosmos DB 实体框架核心提供程序

Cosmos DB 实体框架核心提供程序包含在 Microsoft.EntityFrameworkCore.Cosmos NuGet 包中。一旦将其添加到项目中,你就可以以与在 第六章 中使用 SQL Server 提供程序时类似的方式进行操作,但在一些方面有所不同。让我们来看看:

  1. 由于 Cosmos DB 数据库没有结构需要更新,因此没有迁移。相反,它有一个确保数据库以及所有必要的集合被创建的方法:
context.Database.EnsureCreated();
  1. DbSet<T>DBContext 属性并不一一对应数据库集合,因为集合可以包含具有不同结构的对象,所以几个 DbSet<T> 属性可以映射到同一个集合。此外,默认情况下,所有 DbSet<T> 属性都映射到唯一的集合,因为这是最经济的选项,但你可以通过以下配置指令显式指定要将某些实体映射到哪个集合来覆盖此默认设置:
builder.Entity<MyEntity>()
     .ToContainer("collection-name");
  1. 实体类上唯一有用的注解是 Key 属性,当主键被称作 Id 时,该属性变得强制使用。

  2. 主键必须是字符串,并且不能自动递增,以避免在分布式环境中的同步问题。可以通过生成 GUID 并将其转换为字符串来确保主键的唯一性。

  3. 当定义实体之间的关系时,你可以指定一个实体或实体集合属于另一个实体,在这种情况下,它将与父实体一起存储。

在下一节中,我们将探讨 Cosmos DB 实体框架提供程序的使用。

用例 - 存储数据

现在我们已经学会了如何使用 NoSQL,我们必须决定 NoSQL 数据库是否适合我们的 WWTravelClub 应用程序。我们需要存储以下数据系列:

  • 关于可用目的地和套餐的信息:对此数据的相关操作主要是读取,因为套餐和目的地不经常改变。然而,它们必须尽可能快地从世界各地访问,以确保用户浏览可用选项时的良好用户体验。因此,一个具有地理分布副本的分布式关系数据库是可能的,但不是必要的,因为套餐可以存储在其目的地内的更便宜的 NoSQL 数据库中。

  • 目的地评论:在这种情况下,分布式写入操作有不可忽视的影响。此外,大多数写入都是添加,因为评论通常不会被更新。添加操作从分片中获得很大好处,并且不会像更新那样引起一致性问题。因此,此数据的最佳选择是一个 NoSQL 集合。

  • 预留:在这种情况下,一致性错误是不可接受的,因为它们可能导致超订。读取和写入有相似的影响,但我们需要可靠的交易和良好的一致性检查。幸运的是,数据可以组织在一个多租户数据库中,其中租户是目的地,因为不同目的地的预订信息完全无关。因此,我们可能使用分片 SQL Azure 数据库实例。

总之,对于第一和第二点中的数据,最佳选择是 Cosmos DB,而对于第三点,最佳选择是 Azure SQL Server。实际应用程序可能需要对所有数据操作及其频率进行更详细的分析。在某些情况下,为各种可能的选项实现原型并在所有这些上执行典型工作负载的性能测试是值得的。

在本节的剩余部分,我们将把我们在第六章,“使用 C#与数据交互 - Entity Framework Core”中查看的 destinations/packages 数据层迁移到 Cosmos DB。

使用 Cosmos DB 实现目的地/包数据库

让我们继续到我们在第六章,“使用 C#与数据交互 – Entity Framework Core”中构建的数据库示例,按照以下步骤将其迁移到 Cosmos DB:

  1. 首先,我们需要复制 WWTravelClubDB 项目,并将 WWTravelClubDBCosmo 设置为新根文件夹。

  2. 打开项目并删除迁移文件夹,因为不再需要迁移了。

  3. 我们需要替换 SQL Server Entity Framework 提供程序与 Cosmos DB 提供程序。为此,转到管理 NuGet 包并卸载 Microsoft.EntityFrameworkCore.SqlServer NuGet 包。然后,安装 Microsoft.EntityFrameworkCore.Cosmos NuGet 包。

  4. 然后,对 DestinationPackage 实体执行以下操作:

    • 删除所有数据注释。

    • [Key] 属性添加到它们的 Id 属性,因为这对于 Cosmos DB 提供程序是强制性的。

    • PackageDestinationId 属性类型以及 PackagesListDTO 类从 int 转换为 string。我们还需要将 Package 中的 DestinationId 外部引用和在 PackagesListDTO 类中的 DestinationId 转换为 string。实际上,对于分布式数据库中的键,最佳选择是从 GUID 生成的字符串,因为当表数据分布在多个服务器之间时,很难维护一个身份计数器。
  1. MainDBContext 文件中,我们需要指定与目的地相关的包必须存储在目的地文档本身中。这可以通过在 OnModelCreating 方法中将 Destination-Package 关系配置替换为以下代码来实现:
builder.Entity<Destination>()
    .OwnsMany(m => m.Packages); 
  1. 在这里,我们必须将 HasMany 替换为 OwnsMany。没有 WithOne 的等效项,因为一旦实体被拥有,它必须只有一个所有者,而 MyDestination 属性包含对父实体的指针的事实可以从其类型中看出。Cosmos DB 也允许使用 HasMany,但在这种情况下,两个实体并不是嵌套在一另一个中。还有用于在实体内部嵌套单个实体的 OwnOne 配置方法。

  2. 实际上,OwnsManyOwnsOne 都适用于关系数据库,但在这个案例中,HasManyHasOne 之间的区别在于,子实体会自动包含在返回其父实体的所有查询中,无需指定 Include LINQ 子句。然而,子实体仍然存储在单独的表中。

  3. 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 datbaseName = "packagesdb";
        public MainDBContext CreateDbContext(params string[] args)
        {
            var builder = new DbContextOptionsBuilder<Main
            DBContext>();

            builder.UseCosmos(endpoint, key, datbaseName);
            return new MainDBContext(builder.Options);
        }
    }
}
  1. 最后,在我们的测试控制台中,我们必须明确使用 GUIDS 创建所有实体主键:
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),
        DuratioInDays=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),
        DuratioInDays=7,
        Price=500
    }
    }
};

在这里,我们调用 context.Database.EnsureCreated() 而不是应用迁移,因为我们只需要创建数据库。一旦数据库和集合被创建,我们就可以从 Azure Portal 调整它们的设置。希望未来版本的 Cosmos DB Entity Framework Core 提供程序将允许我们指定所有集合选项。

  1. 最后,以 context.Packages.Where... 开头的最终查询必须进行修改,因为查询不能从嵌套在其他文档中的实体开始(在我们的情况下,Package 实体)。因此,我们必须从我们的 DBContext 中的唯一根 DbSet<T> 属性开始我们的查询,即 Destinations。我们可以借助 SelectMany 方法从列出外部集合切换到列出所有内部集合,该方法执行所有嵌套 Packages 集合的逻辑合并。然而,由于 CosmosDB SQL 不支持 SelectMany,我们必须使用 AsIenumerable() 在客户端强制模拟 SelectMany,如下面的代码所示:
var list = context.Destinations
    .AsEnumerable() // move computation on the client side
    .SelectMany(m => m.Packages)
    .Where(m => period >= m.StartValidityDate....)
    ...
  1. 查询的其余部分保持不变。如果您现在运行项目,应该看到与 SQL Server 的情况相同的输出(除了主键值之外)。

  2. 执行程序后,前往您的 Cosmos DB 账户。您应该看到以下类似的内容:

图片

包已经按照要求嵌套在其目的地内部,Entity Framework Core 创建了一个与 DBContext 类同名的唯一集合。

如果您想在不用完所有免费 Azure Portal 信用额度的情况下继续实验 Cosmos DB 开发,您可以安装此链接提供的 Cosmos DB 模拟器:aka.ms/cosmosdb-emulator

摘要

在本章中,我们探讨了 Azure 中可用的主要存储选项,并学习了何时使用它们。然后,我们比较了关系型数据库和 NoSQL 数据库。我们指出,关系型数据库提供自动一致性检查和事务隔离,但 NoSQL 数据库更便宜,并提供更好的性能,尤其是在平均负载中分布式写入占很大比例时。

然后,我们介绍了 Azure 的主要 NoSQL 选项 Cosmos DB,并解释了如何配置它以及如何与客户端连接。

最后,我们学习了如何使用 Entity Framework Core 与 Cosmos DB 交互,并查看了一个基于 WWTravelClubDB 用例的实用示例。在这里,我们学习了如何为应用程序中涉及的所有数据家族决定使用关系型数据库还是 NoSQL 数据库。这样,您可以选择确保数据一致性、速度和每个应用程序中数据并行访问之间最佳折衷的数据存储类型。

在下一章中,我们将学习有关 Serverless 和 Azure Functions 的所有内容。

问题

  1. Redis 是否是关系型数据库的有效替代品?

  2. NoSQL 数据库是否是关系型数据库的有效替代品?

  3. 在关系型数据库中,哪种操作更难进行扩展?

  4. NoSQL 数据库的主要弱点是什么?它们的主要优势是什么?

  5. 您能否列出所有 Cosmos DB 的一致性级别?

  6. 我们可以使用自增整数键与 Cosmos DB 一起使用吗?

  7. 哪种 Entity Framework 配置方法用于在相关父文档中存储实体?

  8. 使用 Cosmos DB 是否可以有效地搜索嵌套集合?

进一步阅读

第八章:使用 Azure Functions

正如我们在第四章,“选择最佳云解决方案”中提到的,无服务器架构是提供灵活软件解决方案的最新方式之一。为此,Microsoft Azure 提供了 Azure Functions,这是一种事件驱动的、无服务器的、可扩展的技术,可以加速您的项目开发。本章的主要目标是向您介绍 Azure Functions 以及您在使用它时可以实施的最佳实践。

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

  • 理解 Azure Functions 应用程序

  • 使用 C# 编程 Azure Functions

  • 维护 Azure Functions

  • 用例 - 实现使用 Azure Functions 发送电子邮件

到本章结束时,您将了解如何使用 C# 的 Azure Functions。

技术要求

本章要求您具备以下条件:

  • 安装了所有数据库工具的 Visual Studio 2017 或 2019 免费社区版或更高版本。

  • 一个免费的 Azure 账户。第一章,“理解软件架构的重要性”中的“创建 Azure 账户”部分解释了如何创建一个。

您可以在github.com/PacktPublishing/Hands-On-Software-Architecture-with-CSharp-8/tree/master/ch08找到本章的示例代码。

理解 Azure Functions 应用程序

Azure Functions 应用程序是一个 Azure PaaS,您可以在其中构建代码片段(函数),并将它们连接到您的应用程序,并使用触发器来启动它们。这个概念相当简单 - 您可以使用您喜欢的语言构建一个函数,并决定启动它的触发器。您可以在系统中编写尽可能多的函数。有些情况下,系统完全使用函数编写。

创建必要环境的步骤与创建函数本身需要遵循的步骤一样简单。以下截图显示了您在创建环境时必须决定的参数。在您选择在 Azure 中创建资源并按 Function App 过滤后,您将看到以下屏幕:

图片

在创建环境时,您应该考虑几个关键点。第一个是托管计划,您将在其中运行您的函数。托管计划有两种选择:消费计划和应用程序服务计划。让我们现在来谈谈这些。

消费计划

如果您选择消费计划,您的函数只有在执行时才会浪费资源。这意味着您只有在函数运行时才会被收费。可扩展性和内存资源将由 Azure 自动管理。

在此计划中编写函数时,我们需要注意的超时问题。默认情况下,5 分钟后函数将超时。您可以使用functionTimeout参数更改超时值。最大值为 10 分钟。

当您选择消费计划时,您将被收取的费用将取决于您正在执行的内容、它们的执行时间和它们的内存使用情况。更多信息可以在azure.microsoft.com/en-us/pricing/details/functions/找到。

注意,如果您环境中没有 App Service 并且正在运行低周期性的函数,这可以是一个不错的选择。另一方面,如果您需要连续处理,您可能需要考虑 App Service Plan。

App Service Plan

当您想要创建 Azure Functions 应用时,App Service Plan 是您可以选择的选项之一。以下是由 Microsoft 建议的几个原因,说明为什么您应该使用 App Service Plan 而不是消费计划来维护您的函数:

  • 您可以使用未充分利用的现有 App Service 实例。

  • 函数应用可以持续运行或几乎持续运行。

  • 您需要的 CPU 或内存选项比消费计划提供的更多。

  • 您的代码需要运行超过 10 分钟。

  • 您需要诸如 VNET/VPN 连接等特性。

  • 您希望将您的函数应用运行在 Linux 或自定义镜像上。

在 App Service Plan 的场景中,functionTimeout值根据 Azure Function 运行时版本而变化。然而,该值至少为 30 分钟。

使用 C#编程 Azure Functions

在本节中,您将学习如何创建 Azure Functions。值得一提的是,有几种方法可以使用 C#创建它们。第一种是在 Azure Portal 本身创建函数并开发它们。为此,请按照以下步骤操作:

  1. 从主页进入所有资源,搜索wwtravelclub应用,并点击它。您将看到以下屏幕:

图片

  1. 点击“在门户中创建”选项。在这里,您将被提示选择您想要用于启动执行的触发类型。最常用的触发类型是HTTP 请求定时器触发,如下面的截图所示:

图片

当您决定使用哪个触发器时,您必须为其命名。

  1. 根据您选择的触发器,您将必须安装一些扩展并设置其他参数。例如,HTTP 触发器要求您设置授权级别。有三个选项可供选择,即函数、匿名和管理员,其中我们选择了函数选项,如下面的截图所示:

图片

值得注意的是,这本书并没有涵盖构建函数时所有可用的选项。作为一名软件架构师,您应该了解 Azure 在函数方面为无服务器架构提供了一个良好的服务。这在几种情况下都可能很有用。这已在第四章,决定最佳云解决方案中进行了更详细的讨论。

  1. 结果如下。请注意,Azure 提供了一个编辑器,允许我们运行代码、检查日志并测试我们创建的函数。这是一个用于测试和编写基本函数的好界面:

  1. 然而,如果您想创建更复杂的函数,您可能需要一个更复杂的开发环境,以便更有效地进行编码和调试。这就是 Visual Studio Azure Functions 项目能帮到您的所在。

在 Visual Studio 中,您可以通过访问“新建项目”|“云”菜单来创建一个专门用于 Azure Functions 的项目。

  1. 一旦您提交了项目,Visual Studio 将询问您使用的触发类型以及函数将运行的 Azure 版本:

在撰写本文时,Azure Functions 有两个版本:

  • 在第一个版本中,您可以创建在 .NET Framework 上运行的函数。

  • 在第二个版本中,您可以创建在 .NET Core 上运行的函数。

作为软件架构师,您始终需要考虑代码的可重用性。在这种情况下,您应该注意您将决定在哪个版本的 Azure Functions 项目中构建函数。

默认情况下,生成的代码与您在 Azure Portal 中创建 Azure Functions 时生成的代码类似。发布方法遵循我们在第一章,理解软件架构的重要性中描述的 Web 应用程序发布过程相同的步骤。

列出 Azure Functions 模板

Azure Portal 中有多个模板可供您创建 Azure Functions。您可以选择的模板数量会持续更新。以下只是其中的一些:

  • Blob 存储:您可能希望在文件上传到您的 blob 存储后立即处理该文件。这可能是 Azure 函数的一个很好的用例。

  • Cosmos DB:您可能希望将到达 Cosmos DB 数据库中的数据与处理方法同步。Cosmos DB 在第七章,如何在云中选择您的数据存储中进行了详细讨论。

  • 事件网格:这是一种管理 Azure 事件的好方法。函数可以被触发以管理每个事件。

  • 事件中心:这些可以与 Azure Functions 一起使用来管理每个连接设备到达的数据。

  • HTTP:这个触发器对于构建无服务器 API 和 Web 应用事件非常有用。

  • Microsoft Graph Events:Graph API 允许您提供与 Office 365 相关的功能。例如,使用此触发器,您可以将日历事件连接到函数。

  • 队列存储:您可以使用函数作为服务解决方案来处理队列处理。

  • 服务总线:这是另一种可以触发函数的消息服务。Azure 服务总线将在第九章中更详细地介绍,设计模式和.NET Core 实现

  • 定时器:这通常与函数一起使用,您在这里指定时间触发器,以便可以持续处理系统中的数据。

  • WebHooks:WebHooks 是一种允许您的应用程序避免从 API 中池化数据的技术。您可以将它们连接到函数,以了解您已挂钩的事件是如何被处理的。

维护 Azure Functions

一旦您创建并编程了函数,您就需要监控和维护它。为此,您可以使用各种工具——所有这些工具您都可以在 Azure 门户中找到。这些工具将帮助您解决由于您将能够收集的大量信息而引起的问题。

当您需要监控函数时,第一个选项是使用 Azure 门户中的 Azure Functions 界面内的监控菜单。在那里,您将能够检查所有函数执行情况,包括成功的结果和失败:

等待结果大约需要 5 分钟。网格中显示的日期是协调世界时(UTC)。

同一个界面允许您连接到应用洞察(Application Insights)。这将带您进入一个几乎无限的选择世界,您可以使用这些选择来分析您的函数数据。应用洞察(Application Insights)是目前可用的最佳应用性能管理(APM)系统之一:

除了查询界面之外,您还可以使用 Azure 门户中的洞察界面检查您函数的所有性能问题。在那里,您可以分析和筛选您的解决方案接收到的所有请求,并检查它们的性能和依赖关系。您还可以在您的端点之一发生异常时触发警报:

作为软件架构师,您将在项目中找到这个工具的真正有用的日常助手。值得一提的是,应用洞察(Application Insights)还适用于其他几个 Azure 服务,如 Web 应用和虚拟机。这意味着您可以使用 Azure 提供的出色功能来监控和维护您系统的健康状态。

用例 - 实现 Azure Functions 发送电子邮件

在这里,我们将使用之前描述的 Azure 组件的子集。WWTravelClub 的用例提出了服务的全球实施,并且有可能这个服务需要不同的架构设计来应对我们在第一章中描述的所有性能关键点,即理解软件架构的重要性

如果你回顾一下在第一章中描述的用户故事,即理解软件架构的重要性,你会发现许多需求都与通信相关。正因为如此,在解决方案中通过电子邮件提供一些警报是非常常见的。本章的用例将专注于如何发送电子邮件。架构将是完全无服务器的。

以下图表显示了架构的基本结构。为了给用户提供良好的体验,应用程序发送的所有电子邮件都将异步排队,从而避免系统响应中的高延迟:

注意,没有服务器来管理 Azure Functions 以插入消息到队列存储,也没有服务器来从队列存储获取消息。这正是我们所说的无服务器。值得一提的是,这种架构不仅限于发送电子邮件,还可以用于处理任何 HTTP POST 请求。

现在,我们将学习如何在 API 中设置安全设置,以便只有授权的应用程序可以使用给定的解决方案。

第一步 – 创建 Azure 队列存储

在 Azure Portal 中创建存储非常简单。让我们来学习如何操作:

  1. 首先,你需要创建一个存储账户并设置其名称、安全性和网络,如下面的截图所示:

  1. 一旦你设置了存储账户,你将能够设置一个队列。你只需要提供队列的名称:

  1. 创建的队列将为你提供 Azure Portal 的概览。在那里,你可以找到你的队列 URL 并使用 Storage Explorer:

注意,你还可以使用 Microsoft Azure Storage Explorer (azure.microsoft.com/en-us/features/storage-explorer/) 来连接到此存储:

  1. 现在,你可以开始你的函数式编程,通知队列有电子邮件等待发送。在这里,我们需要使用 HTTP 触发器。请注意,该函数是一个静态类,它异步运行。以下代码正在收集来自 HTTP 触发器的请求数据并将其插入到稍后处理的队列中:
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 YOUR_CONNECTION_STRING = 
        "YOUR_AZURE_STORAGE_ACCOUNT_CONNECTION_STRING_HERE";
        var storageAccount = 
        CloudStorageAccount.Parse(YOUR_CONNECTION_STRING);
        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.");
        return req.CreateResponse(HttpStatusCode.OK, new { success 
        = true }, JsonMediaTypeFormatter.DefaultMediaType);
    }
}
  1. 你可以使用 Postman 这样的工具通过运行 Azure Functions Emulator 来测试你的函数:

  1. 结果将出现在 Microsoft Azure 存储资源管理器和 Azure 门户中。在 Azure 门户中,你可以管理每条消息,对每条消息进行出队,甚至清除队列存储:

图片

  1. 然后,你可以创建第二个函数。这个函数将由进入你的队列的数据触发。值得注意的是,对于 Azure Functions v2,你需要将 Microsoft.Azure.WebJobs.Extensions.Storage 库作为 NuGet 引用添加:

图片

  1. 一旦你在 local.settings.json 中设置了连接字符串,你将能够运行这两个函数,并使用 Postman 测试它们。区别在于,当第二个函数正在运行时,如果你在它的开始处设置断点,你会检查消息是否已发送:

图片

  1. 从这个点开始,发送邮件的方式将取决于你拥有的邮件选项。你可能决定使用代理,或者直接连接到你的电子邮件服务器。

以这种方式创建电子邮件服务有几个优点:

  • 一旦你的服务被编码和测试,你就可以使用它从你的任何应用程序中发送电子邮件。这意味着你的代码始终可以重用。

  • 使用此服务的应用程序不会因为 HTTP 服务的异步优势而停止发送电子邮件。

  • 你不需要对队列进行池化以检查数据是否准备好处理。

最后,队列处理是并发运行的,这在大多数情况下提供了更好的体验。你可以通过在 host.json 中设置一些属性来关闭它。所有这些选项都可以在本章末尾的 进一步阅读 部分找到。

摘要

在本章中,我们探讨了使用无服务器 Azure Functions 开发功能的一些优点。你可以将其用作检查 Azure Functions 中可用的不同类型触发器的指南,以及规划如何监控它们的计划。我们还看到了如何编程和维护 Azure 函数。最后,我们查看了一个连接多个函数以避免数据池化和启用并发处理的架构示例。

在下一章中,我们将分析设计模式的概念,了解为什么它们如此有用,以及一些常见的模式。

问题

  1. 什么是 Azure Functions?

  2. Azure Functions 的编程选项有哪些?

  3. 可以与 Azure Functions 一起使用的计划有哪些?

  4. 如何使用 Visual Studio 部署 Azure Functions?

  5. 你可以使用哪些触发器来开发 Azure Functions?

  6. Azure Functions v1 和 v2 之间的区别是什么?

  7. Application Insights 如何帮助我们维护和监控 Azure Functions?

进一步阅读

如果你想了解更多关于创建 Azure Functions 的信息,请查看以下链接:

第三部分:应用 21 世纪交付的软件设计原则

在本节中,您将了解现代企业架构中使用的的主要模式、最佳实践和框架。所有示例都是在 C# 8 和 .NET Core 3.0 上运行的。

在第九章,设计模式和 .NET Core 实现中,您将了解知名和通用模式以及最佳实践的 .NET Core 实现,而第十一章,在 C# 8 中实现代码复用,描述了增强代码复用的技术和最佳实践。

第十章,理解软件解决方案中的不同领域,描述了现代领域驱动设计软件生产方法论,这将使您充分利用基于云和微服务架构,并应对需要多个知识领域的复杂应用程序。在那里,您将了解分析技术和涉及基于领域驱动设计项目的架构和工具。

第十二章,使用 .NET Core 应用服务导向架构,以及第十三章,展示 ASP.NET Core MVC,描述了构成现代企业应用骨架的 Web 架构。这两章都基于 ASP.NET Core 3.0,这是随 .Net Core 3.0 一起提供的 Web 框架。第十二章,使用 .NET Core 应用服务导向架构,描述了企业系统可以通过在 Web 或私有网络上公开的端点被外部客户端应用程序访问的 Web 架构。第十二章,使用 .NET Core 应用服务导向架构,专注于 ASP.NET Core MVC Web 应用程序,因为它们不需要特定的客户端,因为它们使用浏览器作为客户端。更具体地说,它们通过发送到标准浏览器的 HTML 与用户交互。

本节包括以下章节:

  • 第九章,设计模式和 .NET Core 实现

  • 第十章,理解软件解决方案中的不同领域

  • 第十一章,在 C# 8 中实现代码复用

  • 第十二章,使用 .NET Core 应用服务导向架构

  • 第十三章,展示 ASP.NET Core MVC

第九章:设计模式与.NET Core 实现

设计模式可以被定义为针对你在软件开发过程中遇到的常见问题的现成架构解决方案。它们对于理解.NET Core 架构至关重要,并且在解决我们在设计任何软件时面临的普通问题非常有用。在本章中,我们将探讨一些设计模式的实现。值得一提的是,这本书并没有解释我们可以使用的所有已知模式。这里的重点是解释研究和应用它们的重要性。

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

  • 理解设计模式及其目的

  • 理解.NET Core 中可用的设计模式

在本章结束时,你将了解一些来自 WWTravelClub 的使用案例,你可以使用设计模式来实现。

技术要求

你需要以下内容来完成本章:

  • Visual Studio 2017 或 2019 免费社区版或更高版本,并安装了所有数据库工具。

  • 一个免费的 Azure 账户。第一章的创建 Azure 账户小节,理解软件架构的重要性,解释了如何创建一个。

你可以在github.com/PacktPublishing/Hands-On-Software-Architecture-with-CSharp-8/tree/master/ch09找到本章的示例代码。

理解设计模式及其目的

能够决定系统的设计是一项挑战,与此任务相关的责任是巨大的。作为软件架构师,我们必须始终牢记,诸如高度可重用性、良好性能和良好可维护性等特性是关键。这就是设计模式帮助并加速设计过程的地方。

正如我们之前提到的,设计模式是已经被讨论和定义的解决方案,以便它们可以解决常见的软件架构问题。这种做法在《设计模式 – 可重用面向对象软件的元素》一书发布后变得流行起来,其中四人帮GoF)将这些模式分为三类:

  • 创建

  • 结构

  • 行为

一段时间后,Uncle Bob 向开发者社区介绍了 SOLID 原则,这给了我们有效地将每个系统的函数和数据结构组织到类中的机会。SOLID 原则指出了这些类应该如何连接。值得一提的是,与 GoF 提出的设计模式相比,SOLID 原则并不提供代码配方。相反,它们为你提供了在设计解决方案时应该遵循的基本原则。

随着技术和软件问题的变化,更多的模式被构思出来。云计算的进步带来了一大堆模式,所有这些都可以在docs.microsoft.com/azure/architecture/patterns/找到。

您应该始终考虑它们的原因很简单——作为一个软件架构师,您不能花时间重新发明轮子。然而,使用它们的另一个很好的原因是:您会发现许多模式在.NET Core 中得到了实现。

在接下来的几个小节中,我们将介绍一些最著名的模式。然而,本章的目的是让您知道它们的存在并需要对其进行研究,以便您能够加速并简化您的项目。此外,每个模式都将通过一个 C#代码片段进行展示,这样您就可以轻松地将它们应用到您的项目中。

构建者模式

有时候,您会拥有一个具有不同行为的复杂对象,这是由于其配置造成的。在您使用它的时候设置这个对象,您可能希望将其配置与其使用解耦,使用已经构建的定制配置。这样,您就有您正在构建的实例的不同表示。这就是您应该使用构建者模式的地方。

以下类图显示了本书用例场景中实现的模式。这个设计选择背后的想法是简化 WWTravelClub 中房间描述的方式:

图片

如以下代码所示,这个实现是以一种方式进行的,其中实例的配置不是在主程序中设置的。相反,您只需使用Construct()方法构建对象。这个例子是在模拟 WWTravelClub 中不同房间风格(单个房间和家庭房间)的创建:

using DesignPatternsSample.BuilderSample;
using System;

namespace DesignPatternsSample
{
    class Program
    {
        static void Main()
        {
            #region Builder Sample
            Console.WriteLine("Builder Sample");

            var directorRoom = new DirectorRooms(new SimpleRoomBuilder());
            var simpleRoom = directorRoom.Construct();
            simpleRoom.Describe();

            directorRoom = new DirectorRooms(new FamilyRoomBuilder());
            var familyRoom = directorRoom.Construct();
            familyRoom.Describe();
            #endregion

            Console.ReadKey();
        }
    }
}

这种实现的成果相当简单,但阐明了为什么您需要实现模式的原因:

图片

一旦您有了实现,代码的演进就变得简单和容易。例如,如果您需要构建不同风格的房间,您只需创建相应的具体构建者,然后您就可以使用它。幸运的是,如果您需要增加产品的配置设置,您之前使用的所有具体类都将定义在 Builder 接口中并存储在那里,这样您就可以轻松地更新它们。

工厂模式

工厂模式在您有多个来自同一抽象的对象,并且您在开始编码时不知道哪个需要被创建的情况下非常有用。这意味着您将不得不根据某种配置或根据软件当前所在的位置来创建实例。

例如,让我们看看 WWTravelClub 示例。在这里,有一个用户故事描述了该应用程序将拥有来自世界各地的客户支付他们的旅行费用。然而,在现实世界中,每个国家都有不同的支付服务。支付过程在每个国家都是相似的,但这个系统将提供多个支付服务。简化这种支付实现的一个好方法是使用工厂模式。以下图表显示了其架构实现的初步概念:

注意,由于你有一个描述应用程序支付服务的接口,你可以使用工厂根据可用的服务来更改具体的类:

static void Main()
{
    #region Factory Sample
    var psCreator = new PaymentServiceCreator();
    var brazilianPaymentService = (IPaymentService)psCreator.Factory
        (PaymentServiceCreator.ServicesAvailable.Brazilian);
    brazilianPaymentService.EmailToCharge = "gabriel@sample.com";
    brazilianPaymentService.MoneyToCharge = 178.90f;
    brazilianPaymentService.OptionToCharge =
        FactorySample.Enums.EnumChargingOptions.CreditCard;
    brazilianPaymentService.ProcessCharging();

    var italianPaymentService = (IPaymentService)psCreator.Factory
        (PaymentServiceCreator.ServicesAvailable.Italian);
    italianPaymentService.EmailToCharge = "francesco@sample.com";
    italianPaymentService.MoneyToCharge = 188.70f;
    italianPaymentService.OptionToCharge =
        FactorySample.Enums.EnumChargingOptions.DebitCard;
    italianPaymentService.ProcessCharging();
    #endregion
    Console.ReadKey();
}

再次强调,由于实现了模式,服务的使用已经简化。如果你要在实际应用中使用此代码,你将通过在工厂中定义所需的服务的实例来改变实例的行为。

单例模式

当你在应用程序中实现单例时,你将在整个解决方案中实现该对象的单个实例。这可以被认为是每个应用程序中最常用的模式之一。原因很简单——有许多用例需要某些类只有一个实例。单例通过提供比全局变量更好的解决方案来解决这个问题。

在单例模式中,类负责创建和提供单个对象,该对象将被应用程序使用。换句话说,单例类创建一个单例实例:

要做到这一点,创建的对象是static的,并通过静态属性或方法提供。以下代码实现了单例模式,它有一个Message属性和一个Print()方法:

public sealed class SingletonDemo
{
    #region This is the Singleton definition
    private static SingletonDemo _instance;
    public static SingletonDemo Current
    {
        get
        {
            if (_instance == null)
                _instance = new SingletonDemo();
            return _instance;
        }
    }
    #endregion

    public string Message { get; set; }

    public void Print()
    {
        Console.WriteLine(Message);
    }
}

其用法非常简单——你只需在需要使用单例对象时调用静态属性:

SingletonDemo.Current.Message = "This text will be printed by the singleton.";
SingletonDemo.Current.Print();

你可能使用此模式的一个地方是当你需要以易于从解决方案的任何地方访问的方式提供应用程序配置时。例如,假设你有一些配置参数存储在一个你的应用程序需要在几个决策点查询的表中。而不是直接查询配置表,你可以创建一个单例类来帮助你:

此外,你还需要在这个单例中实现一个缓存,从而提高系统的性能,因为你可以决定系统是否每次需要配置时都检查数据库中的每个配置,或者是否使用缓存。以下截图显示了配置每 5 秒加载的缓存实现。在这种情况下正在读取的参数只是一个随机数:

这对于应用程序的性能来说非常好。此外,在代码的几个地方使用参数更简单,因为你不需要在代码的每个地方都创建配置实例。

代理模式

当你需要提供一个控制对另一个对象访问的对象时,会使用代理模式。你应该这样做的一个最大的原因与被控制对象的创建成本有关。例如,如果被控制的对象创建时间过长或消耗过多内存,可以使用代理来确保对象的大部分只有在需要时才会被创建。

下面的类图显示了从 Room 加载图片的代理模式实现类图,但仅在请求时:

图片

这个代理的客户将请求其创建。在这里,代理将只从真实对象收集基本信息(IdFileNameTags),而不会查询PictureData。当请求PictureData时,代理将加载它:

static void Main()
{
    Console.WriteLine("Proxy Sample");
    var roomPicture = new ProxyRoomPicture();
    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,由于图像数据已经就绪,代理将确保不会重复加载图像。以下截图显示了运行前面代码的结果:

图片

这种技术可以被称为另一个众所周知的模式:延迟加载。实际上,代理模式是实现延迟加载的一种方式。例如,在 Entity Framework Core 2.1 中,如第六章“使用 C#与数据交互 - Entity Framework Core”中讨论的那样,你可以使用代理来开启延迟加载。你可以在docs.microsoft.com/en-us/ef/core/querying/related-data#lazy-loading了解更多信息。

命令模式

在许多情况下,你需要执行一个将影响对象行为的命令。命令模式可以通过封装这种请求在对象中来帮助你处理这个问题。该模式还描述了如何处理请求的撤销/重做支持。

例如,让我们想象在 WWTravelClub 网站上,用户有权限通过指定他们是否喜欢、不喜欢甚至热爱来评估套餐。下面的类图是使用命令模式创建此评分系统的示例:

图片

注意这种模式的工作方式——如果你需要不同的命令,比如 Hate,你不需要更改使用该命令的代码和类。可以通过类似的方式添加 Undo 方法到 Redo 方法。这个完整的代码示例可以在本书的 GitHub 仓库中找到。

发布者/订阅者模式

在所有应用程序中,从对象提供信息到一组其他对象是很常见的。当有大量组件(订阅者)将接收包含由对象(发布者)发送的信息的消息时,发布/订阅模式几乎是强制性的。

这里的概念非常简单易懂,如下面的图示所示:

图片

当你有不定数量的不同可能的订阅者时,将广播信息的组件与消费它的组件解耦是至关重要的。发布/订阅模式为我们做到了这一点。

实现这个模式是复杂的,因为分布式环境不是一项简单任务。因此,建议您考虑现有的技术来实现连接输入通道到输出通道的消息代理,而不是从头开始构建。Azure Service Bus 是这个模式的可靠实现,所以你只需要连接到它。

我们在第五章中提到的 RabbitMQ,将微服务架构应用于您的企业应用,是另一个可以用来实现消息代理的服务,但它是对该模式的低级实现,需要执行几个相关任务,例如重试,以防需要手动编码错误。

依赖注入模式

依赖注入模式被认为是实现依赖倒置原则的好方法。此外,它强制所有其他 SOLID 原则在实现中遵循。正如我们在本章开头讨论的,保持软件结构强大和可靠的一种方法是遵循 Uncle Bob 提出的 SOLID 设计原则。这些可以定义为如下:

  • 单一职责:模块或函数应该只负责单一目的。

  • 开闭原则:软件工件应该对扩展开放,但对修改封闭。

  • 里氏替换:当你用初级对象的上类型定义的另一个组件替换程序中的一个组件时,程序的行为需要保持不变。

  • 接口隔离:创建巨大的接口会在构建具体对象时产生依赖,但这些对系统架构是有害的。

  • 依赖倒置:最灵活的系统是那些对象依赖仅指向抽象的系统。

这个概念很简单。你不需要创建组件所依赖的对象的实例,只需定义它们的依赖关系,声明它们的接口,并通过注入使对象接收功能。

有三种方法来实现依赖注入:

  • 使用类的构造函数来接收对象。

  • 标记一些类属性以接收对象。

  • 定义一个带有注入所有必要组件的方法的接口。

下图展示了依赖注入模式的实现:

图片

除了这个之外,依赖注入还可以与控制反转(Inversion of Control,IoC)容器一起使用。这个容器可以在需要时自动注入依赖。市场上有多款 IoC 容器框架可供选择,但在 .NET Core 中,由于它包含了 Microsoft.Extensions.DependencyInjection 命名空间中解决此问题的库,因此无需使用第三方软件。

这个 IoC 容器负责创建和销毁请求的对象。依赖注入的实现基于构造函数类型。注入组件的生命周期有三种选项:

  • 瞬态(Transient):每次请求对象时都会创建对象。

  • 作用域(Scoped):为应用程序中定义的每个作用域创建对象。在一个 Web 应用中,作用域 与一个 Web 请求相关联。

  • 单例(Singleton):每个对象具有相同的应用程序生命周期,因此单个对象被重用来服务给定类型的所有请求。

你将如何使用这些选项取决于你正在开发的项目中的业务规则。在决定正确的选项时,你需要小心,因为应用程序的行为将根据你注入的对象类型而改变。

理解 .NET Core 中可用的设计模式

正如我们在前面的章节中所发现的,C# 允许我们实现上述任何一种模式。.NET Core 在其 SDK 中提供了许多遵循我们讨论的所有模式的实现,例如 Entity Framework Core 代理懒加载。另一个自 .NET Core 2.1 以来可用的好例子是 .NET Generic Host。

在 第十三章,展示 ASP.NET Core MVC 中,我们将详细介绍 .NET Core 中为 Web 应用提供的托管服务。这个 Web 托管程序有助于我们,因为应用程序的启动和生命周期管理是与它一起设置的。.NET Generic Host 的想法是使这种模式适用于不需要 HTTP 实现的应用程序。使用这个通用宿主,任何 .NET Core 程序都可以有一个 Startup 类,在其中我们可以配置依赖注入引擎。这对于创建多服务应用程序非常有用。

您可以在.NET 通用宿主docs.microsoft.com/en-us/aspnet/core/fundamentals/host/generic-host中找到更多信息,其中包含一些示例代码。本书 GitHub 仓库中提供的代码更简单,但它专注于创建一个可以运行监控服务的控制台应用程序。关于这一点,控制台应用程序的设置方式很出色,其中建造者配置应用程序将提供的服务,以及日志管理的方式。以下代码展示了这一点:

public static void Main()
{
    var host = new HostBuilder()
        .ConfigureServices((hostContext, services) =>
        {
            services.AddHostedService<HostedService>();
            services.AddHostedService<MonitoringService>();
        })
        .ConfigureLogging((hostContext, configLogging) =>
        {
            configLogging.AddConsole();
        })
        .Build();
    host.Run();

    Console.WriteLine("Host has terminated. Press any key to finish the 
    App.");
    Console.ReadKey();
 }

上述代码让我们了解了.NET Core 如何使用设计模式。使用建造者模式,.NET 通用宿主允许您设置将被注入为服务的类。除此之外,建造者模式还帮助您配置一些其他功能,例如日志的显示/存储方式。这种配置允许服务将ILogger<out TCategoryName>对象注入到任何实例中。

摘要

在本章中,我们了解了为什么设计模式有助于提高您所构建的系统部分的维护性和可重用性。我们还探讨了您可以在项目中使用的典型用例和代码片段。最后,我们介绍了.NET 通用宿主,这是.NET 如何使用设计模式来实现代码重用和强制最佳实践的良例。所有这些内容都将帮助您在架构新的软件或维护现有软件时,因为设计模式是针对软件开发中一些现实生活问题的已知解决方案。

在下一章中,我们将介绍领域驱动设计方法。我们还将学习如何使用 SOLID 设计原则,以便我们可以将不同的领域映射到我们的软件解决方案中。

问题

  1. 什么是设计模式?

  2. 设计模式和设计原则之间的区别是什么?

  3. 在什么情况下实现建造者模式是一个好主意?

  4. 在什么情况下实现工厂模式是一个好主意?

  5. 在什么情况下实现单例模式是一个好主意?

  6. 在什么情况下实现代理模式是一个好主意?

  7. 在什么情况下实现命令模式是一个好主意?

  8. 在什么情况下实现发布/订阅模式是一个好主意?

  9. 在什么情况下实现依赖注入模式是一个好主意?

进一步阅读

以下是一些书籍和网站,您可以在其中找到更多关于本章所涵盖内容的信息:

  • 《整洁架构:软件结构和设计的工匠指南》,Martin, Robert C. Pearson Education,2018。

  • 《设计模式:可复用面向对象软件元素》,Erica Gamma 等。Addison-Wesley,1994。

  • 《设计原则与设计模式》,Robert C. Martin,2000。

如果您需要获取有关设计模式和架构原则的更多信息,请查看以下链接:

如果你想更好地理解通用宿主的概念,请点击此链接:

在此链接中有一个关于服务总线消息的非常好的解释:

你可以通过以下链接了解更多关于依赖注入的信息:

第十章:理解软件解决方案中的不同领域

本章致力于一种现代软件开发技术,称为领域驱动设计(DDD),它最初由 Eric Evans 提出。虽然 DDD 已经存在了 15 多年,但由于其解决两个重要问题的能力,它在过去几年取得了巨大的成功:

  • 在没有单个专家对整个领域有深入知识的情况下建模复杂系统。这种知识被分散在几个人之间。

  • 面对由多个开发团队组成的大型项目。一个项目被分成几个团队的原因有很多,最常见的是团队规模以及所有成员拥有不同的技能和/或不同的地点。事实上,经验证明,超过 6-8 人的团队效率不高,而且不同的技能和地点显然会阻止紧密的互动。团队划分阻止了项目所有相关人员之间的紧密互动。

相反,上述两个问题的重要性在过去几年中增长,原因如下:

  • 软件系统始终占据每个组织内部的大量空间,并且变得越来越复杂和地理上分散。

  • 同时,频繁更新的需求增加,以便这些复杂的软件系统能够根据快速变化的市场需求进行适应。

  • 前述问题导致了更复杂的 CI/CD 周期的构想和采用复杂的分布式架构,这些架构可能利用可靠性、高吞吐量、快速更新以及逐步演进旧子系统的能力。是的——我们正在谈论的是我们在第五章,“将微服务架构应用于您的企业应用”中分析过的基于微服务和容器架构。

在这个场景中,通常需要实现具有相关快速 CI/CD 周期的复杂软件系统,这些系统始终需要更多的人来演进和维护。反过来,这导致了开发适合高复杂度领域和多个松散耦合开发团队协作的技术需求。

在本章中,我们将分析与领域驱动设计(DDD)相关的基本原则、优势和常见模式,以及如何在我们的解决方案中使用它们。更具体地说,我们将涵盖以下主题:

  • 软件领域是什么?

  • 理解领域驱动设计

  • 使用 SOLID 原则映射您的领域

  • 用例场景 - 理解用例的应用领域

让我们开始吧。

技术要求

本章需要 Visual Studio 2017 或 2019 免费社区版或更高版本,并安装所有数据库工具。

本章中所有的代码片段都可以在本书相关的 GitHub 仓库中找到,github.com/PacktPublishing/Hands-On-Software-Architecture-with-CSharp-8

软件领域是什么?

如我们在第二章“功能和非功能需求”和第三章“使用 Azure DevOps 记录需求”中讨论的,从领域专家到开发团队的知识转移在软件设计中起着根本的作用。开发者试图与专家沟通,并使用领域专家和利益相关者能够理解的语言来描述他们的解决方案。然而,在组织的各个部分,同一个词可能有不同的含义,而在不同的上下文中,看似相同的概念实体可能有完全不同的形态。

例如,在我们的 WWTravelClub 用例中,订单支付和包裹处理子系统对客户使用完全不同的模型。订单支付通过支付方式、货币、银行账户和信用卡来表征客户,而包裹处理则更关注过去访问和/或购买过的位置和包裹、用户的偏好以及他们的地理位置。此外,虽然订单支付使用我们可能粗略定义为“银行语言”的语言来指代各种概念,但包裹处理则使用典型的旅行社/运营商语言。

应对这些差异的经典方式是使用一个独特的抽象实体称为客户,它映射到两个不同的视图——订单支付视图和包裹处理视图。每个投影操作都从客户抽象实体中提取一些操作和属性,并更改它们的名称。由于领域专家只给我们提供投影视图,我们作为系统设计者的主要任务是创建一个可以解释所有视图的概念模型。以下图表显示了如何处理不同的视图:

图片

经典方法的主要优势是我们有一个独特且一致的数据表示。如果这个概念模型构建成功,所有操作都将有一个正式的定义和目的,整个抽象将是整个组织应该工作方式的合理化,可能突出和纠正错误,并简化一些程序。

然而,这种方法的缺点是什么?首先,工作组织的这种方式可能会对现有的组织产生过度的影响,可能阻止它在一定时间内正确运行,因为使用唯一连贯模型的约束没有提供足够的选项来减轻这种影响。必须消除错误,必须消除重复,并且一切必须完美连贯,以便有一个最小的不可减轻的影响,因为我们减轻影响的唯一方式就是放弃整体连贯性。

在小型组织、软件旨在服务于整体组织的小部分,或者软件自动化的数据流百分比足够小的情况下,这种最小影响是可以接受的。然而,随着软件成为整个地理分布组织的骨干,剧烈的变化变得更加难以接受和不可行。此外,随着软件系统复杂性的增加,还会出现其他几个问题,如下所示:

  • 由于我们不能通过将这些任务分解成更小的松散耦合任务来面对复杂性,因此形成唯一连贯的数据视图变得更加困难。

  • 随着复杂性的增加,需要频繁的系统变更,但更新和维护一个独特的全局模型相当困难。此外,由于系统小部分变更引入的 bug/错误可能会通过唯一共享的模型传播到整个组织。

  • 系统建模必须由几个团队分担,并且只能面对由单独团队处理的松散耦合任务。

  • 需要转向基于微服务架构,这使得唯一数据库的瓶颈更加难以接受。

  • 随着系统的增长,我们需要与更多的领域专家沟通,每位专家都说不同的语言,并且对数据模型有不同的看法。因此,我们需要将我们独特模型的属性和操作翻译成/从更多语言,以便与他们沟通。

  • 随着系统的增长,处理具有数百/数千个字段的记录变得更加低效。这种低效源于数据库引擎在处理具有多个字段的较大记录时效率低下(内存碎片化、太多相关索引的问题等)。然而,主要的不效率发生在 ORM 和业务层,它们被迫在更新操作中处理这些大记录。事实上,虽然查询操作通常只需要从存储引擎检索的几个字段,但更新和业务处理涉及整个实体。

  • 随着数据存储子系统流量的增长,我们需要在所有数据操作中实现读取和更新/写入并行性。正如我们在第七章“如何在云中选择您的数据存储”中讨论的那样,虽然读取并行性可以通过数据复制轻松实现,但写入并行性需要分片,而将一个独特且紧密连接的数据模型分片是困难的。

这些问题是领域驱动设计在过去几年中取得成功的原因,因为它们代表了更复杂的软件系统,这些系统成为了整个组织的支柱。下一节将详细讨论 DDD 的基本原则。

理解领域驱动设计

领域驱动设计是关于构建一个独特的领域模型,并保持所有视图作为独立的模型。因此,整个应用程序领域被分割成更小的领域,每个领域都有一个独立的模型。这些独立的领域被称为边界上下文。每个领域以其专家所使用的语言为特征,并用于命名所有领域概念和操作。因此,每个领域定义了一种由专家和开发团队共同使用的通用语言,称为通用语言。不再需要翻译,如果开发团队以接口作为其代码的基础,领域专家能够理解和验证它们,因为所有操作和属性都是使用专家使用的相同语言表达的。

在这里,我们正在摒弃一个繁琐的唯一抽象模型,但现在我们有了几个需要以某种方式关联的独立模型。DDD 提出将处理所有这些独立模型,即所有边界上下文,如下所示:

  • 每当语言术语的含义发生变化时,我们需要添加边界上下文的边界。例如,在 WWTravelClub 用例中,订单支付和套餐处理属于不同的边界上下文,因为它们对单词客户赋予了不同的含义。

  • 我们需要明确表示边界上下文之间的关系。不同的开发团队可能在不同边界上下文中工作,但每个团队都必须对其正在工作的边界上下文与其他所有模型之间的关系有一个清晰的了解。为此,这种关系在一份独特的文档中表示,并与每个团队共享。

  • 我们需要确保所有边界上下文与持续集成保持一致。会议被组织和简化系统原型被构建,以验证所有边界上下文是否协调一致地发展,也就是说,所有边界上下文都可以集成到期望的应用程序行为中。

下面的图显示了我们在上一节中讨论的 WWTravelClub 示例在采用领域驱动设计(DDD)后的变化:

图片

两个边界上下文的客户实体之间存在关系,而包裹处理边界上下文的购买实体与支付相关。识别映射到各个边界上下文中彼此对应的实体是正式定义表示所有上下文之间可能通信的接口的第一步。

例如,从前面的图中,我们知道支付是在购买之后进行的,因此我们可以推断出支付订单边界上下文必须有一个为特定客户创建支付的操作。在这个领域,如果新客户不存在,则会创建新客户。支付创建操作在购买后立即触发。由于在购买商品后还会触发更多操作,我们可以使用我们在第九章中解释的发布/订阅模式来实现与购买事件相关的所有通信,该模式称为设计模式和.NET Core 实现。这些在领域驱动设计中被称为领域事件。使用事件来实现边界上下文之间的通信非常常见,因为它有助于保持边界上下文松散耦合。

一旦在边界上下文接口中定义的事件或操作的实例跨越了上下文边界,它就会立即被翻译成接收上下文的通用语言。在输入数据开始与其他领域实体交互之前执行这种翻译非常重要,以避免其他领域的通用语言被额外的上下文术语污染。

每个边界上下文实现都必须包含一个数据模型层,该层完全用边界上下文通用语言(类和接口名称以及属性和方法名称)表达,没有任何其他边界上下文通用语言的污染,也没有编程技术内容的污染。这是确保与领域专家良好沟通并确保将领域规则正确翻译成代码,以便领域专家可以轻松验证的必要条件。

当通信语言与目标通用语言之间存在强烈不匹配时,会在接收边界上下文边界处添加一个反腐败层。这个反腐败层的唯一目的是执行语言翻译。

包含所有 Bounded Context 表示、Bounded Context 之间的相互关系和接口定义的文档称为上下文映射。上下文之间的关系包含组织约束,指定了在不同 Bounded Context 上工作的团队之间所需的那种合作类型。这些关系不约束 Bounded Context 接口,但会影响它们在软件 CI/CD 周期中可能的发展方式。它们代表了团队合作模式。最常见的模式如下:

  • 合作伙伴:这是 Eric Evans 最初提出的模式。其想法是两个团队在交付上相互依赖。换句话说,他们共同决定,如果需要,在软件 CI/CD 周期中更改 Bounded Context 的相互通信规范。

  • 客户/供应商开发团队:在这种情况下,一个团队充当客户,另一个团队充当供应商。两个团队定义 Bounded Context 的客户端接口以及一些自动化验收测试来验证它。之后,供应商可以独立工作。当客户的 Bounded Context 是唯一活跃的部分,调用其他 Bounded Context 暴露的接口方法时,这种模式是有效的。对于订单-支付和包裹处理之间的交互,订单-支付作为供应商,因为其功能从属于包裹处理的需求。当这种模式可以应用时,它将两个 Bounded Context 完全解耦。

  • 遵从者:这与客户/供应商类似,但在这个情况下,客户端接受由供应商端强加的接口,没有协商阶段。这种模式对其他模式没有提供任何优势,但有时我们被迫处于模式所描述的情况,因为供应商的 Bounded Context 是在一个无法过多配置/修改的现有产品中实现的,或者因为它是一个我们不希望修改的遗留子系统。

值得指出的是,Bounded Context 的分离只有在结果 Bounded Context 是松散耦合的情况下才是有效的;否则,通过将整个系统分解为子部分而获得的复杂性降低将被协调和通信过程的复杂性所克服。然而,如果 Bounded Context 是以语言标准定义的,即每当通用语言发生变化时,就添加 Bounded Context 边界,那么这实际上应该是这种情况。事实上,不同的语言可能只是由于组织子部分之间松散交互的结果而产生的,因为一个组织要与之互动的子部分越多,它们最终使用的共同语言就越多。

此外,所有人类组织都可以通过演变成松散耦合的子部分来增长,这与复杂软件系统可能仅为了松散耦合子模块的合作而实现的原因相同:这是人类能够应对复杂性的唯一方式。由此,我们可以得出结论,复杂组织/人工系统总是可以被分解成松散耦合的子部分。我们只需要理解如何

除了我们之前提到的基本原理之外,DDD 提供了一些基本原语来描述每个边界上下文,以及一些实现模式。虽然边界上下文原语是 DDD 的一个组成部分,但这些模式只是我们在实现中可以使用的有用启发式方法,因此一旦我们选择采用 DDD,在某些或所有边界上下文中使用这些模式并不是强制性的。

在下一节中,我们将描述原语和模式。

实体和价值对象

DDD 实体代表具有明确定义的标识以及在其上定义的所有操作的领域对象。它们与其他更经典方法中的实体差别不大。此外,DDD 实体是存储层设计的起点。主要区别在于,DDD 更强调它们的面向对象特性,而其他方法主要将它们用作记录,其属性可以在没有太多约束的情况下写入/更新。另一方面,DDD 强制实施强 SOLID 原则,以确保只有某些信息被封装在其中,并且只有某些信息可以从外部访问,哪些操作允许在它们上执行,以及哪些业务级别的验证标准适用于它们。

换句话说,DDD 实体比基于记录的方法中的实体更丰富。在其他方法中,操作实体是在代表业务和/或领域操作的类外部定义的。在 DDD 中,这些操作被移动到实体定义中,作为它们的类方法。这样做的原因是它们提供了更好的模块化,并将相关的软件块保持在同一位置,以便可以轻松维护和测试。

由于同样的原因,业务验证规则被移动到 DDD 实体内部。DDD 实体验证规则是业务级别的规则,因此它们不能与数据库完整性规则或用户输入验证规则混淆。它们通过编码所表示对象必须遵守的约束,有助于实体以某种方式表示领域对象。在 .NET Core 中,可以使用以下列出的技术之一执行业务验证:

  • 在修改实体的所有类方法中调用验证方法

  • 将验证方法连接到所有属性设置器

  • 通过使用自定义验证属性装饰类及其属性,然后在每次修改实体时调用System.ComponentModel.DataAnnotations.Validator类的TryValidateObject静态方法

一旦检测到验证错误,就必须以某种方式处理,也就是说,必须中止当前操作,并将错误报告给适当的错误处理器。处理验证错误的最简单方法是通过抛出异常。这样,两个目的都很容易实现,我们可以选择在哪里拦截和处理它们。不幸的是,正如我们在第二章“在 C#编程时需要考虑的性能问题”部分中讨论的那样,异常意味着巨大的性能损失,因此,通常需要考虑不同的选项。在正常流程控制中处理错误会破坏模块化,因为需要处理错误的代码会散布到导致错误的各个方法栈中,并且代码中会有一个不断变化的条件集。因此,需要更复杂的选择。

异常的一个好替代方案是通知错误处理器在依赖注入引擎中定义的任何错误。由于作用域,在处理每个请求时都会返回相同的服务实例,这样控制整个调用栈执行的处理器就可以在控制流返回时检查可能出现的错误,并适当地处理它们。不幸的是,这种复杂的技巧不能立即中止操作执行或将其返回给控制处理器。因此,开发者被迫添加适当的控制条件来防止操作继续进行。这就是为什么尽管存在性能问题,但仍然建议在这种情况下使用异常。

业务级验证不应与输入验证混淆,输入验证将在第十三章“展示 ASP.NET Core MVC”中更详细地讨论,因为这两种类型的验证有不同的和补充的目的。虽然业务级验证规则编码了领域规则,但输入验证强制执行每个单个输入的格式(字符串长度、电子邮件和 URL 的正确格式等),确保提供了所有必要的输入,强制执行所选的用户-机器交互协议,并提供快速和即时的反馈,引导用户与系统交互。

由于 DDD 实体必须有一个明确定义的身份,它们必须具有作为主键的属性。通常,会覆盖所有 DDD 实体的Object.Equal方法,使得当两个对象具有相同的主键时,它们被认为是相等的。这可以通过让所有实体继承自一个抽象的Entity类来实现,如下面的代码所示:

public abstract class Entity<K>: IEntity<K>
    where K: IEqualityComparer<K>
{

    public virtual K Id { get; protected set; }
    public bool IsTransient()
    {
        return Object.Equals(Id, default(K));
    }
    public override bool Equals(object obj)
    {
        if (obj == null || !(obj is Entity<K>))
            return false;
        if (Object.ReferenceEquals(this, obj))
            return true;
        if (this.GetType() != obj.GetType())
            return false;
        Entity<K> item = (Entity<K>)obj;
        if (item.IsTransient() || this.IsTransient())
            return false;
        else
            return Object.Equals(item.Id, Id);
    }
    int? _requestedHashCode;
    public override int GetHashCode()
    {
        if (!IsTransient())
        {
            if (!_requestedHashCode.HasValue)
                _requestedHashCode = this.Id.GetHashCode() ^ 31;
            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));
        else
            return left.Equals(right);
    }
    public static bool operator !=(Entity<K> left, Entity<K> right)
    {
        return !(left == right);
    }
}

值得指出的是,一旦我们在Entity类中重新定义了Object.Equal方法,我们也可以通过==!=运算符来覆盖它们。

IsTransient谓词在实体最近被创建且尚未记录在持久存储中时返回true,因此其主键尚未定义。

在.NET 中,当覆盖一个类的Object.Equal方法时,也覆盖其Object.GetHashCode方法是一个好的实践,以便类实例可以有效地存储在字典和集合等数据结构中。这就是为什么 Entity 类覆盖了它。

值得注意的是,实现一个定义了Entity<K>的所有属性/方法的IEntity<K>接口。当我们需要在接口后面隐藏数据类时,这个接口非常有用。

另一方面,值对象代表无法用数字或字符串编码的复杂类型。因此,它们没有标识符和主键。它们上没有定义操作,且是不可变的;也就是说,一旦它们被创建,所有字段都可以读取但不能修改。因此,它们通常使用具有受保护的/私有设置器的类进行编码。当所有独立的属性都相等时,两个值对象被认为是相等的(有些属性不是独立的,因为它们只是以不同的方式显示其他属性编码的数据,例如DateTime的 ticks 及其日期和时间字段的表示)。

典型的值对象包括以数字和货币符号表示的成本、以经纬度表示的位置、地址、联系信息等。当存储引擎的接口是 Entity Framework,正如我们在第六章[8c8a9dbc-3bfc-4291-866f-fdd1a62c16ef.xhtml]“C#中的数据交互 - Entity Framework Core”和第七章[77cdecb5-cef4-4b02-80a1-052ad366b9f3.xhtml]“如何在云中选择您的数据存储”中分析的那样,值对象通过OwnsManyOwnsOne关系与使用它们的实体连接。实际上,这样的关系也接受没有定义主键的类。

当存储引擎是 NoSQL 数据库时,值对象存储在使用它们的实体的记录中。另一方面,在关系型数据库的情况下,它们可以与分离的表实现,这些表的主键由 Entity Framework 自动处理,并对开发者隐藏(没有属性被声明为主键)或者,在OwnsOne的情况下,它们被扁平化并添加到使用它们的实体关联的表中。

使用 SOLID 原则映射您的领域

在以下子节中,我们将描述一些与 DDD(领域驱动设计)常用的一些模式。其中一些可以在所有项目中采用,而另一些则只能用于特定的边界上下文。总体思路是将业务层分为两层:

  • 应用层

  • 领域层

在这里,领域层基于通用语言,并操纵 DDD 实体和值对象。DDD 实体和值对象在领域层中定义。整个业务层通过在领域层定义但在数据层实现的接口与使用 Entity Framework 实现的数据层进行通信。这些接口方法传递/返回的数据被称为 DDD 实体(查询及其结果的表示)。领域层没有直接引用实现数据层的库,但领域层接口与其数据层实现之间的连接是在应用层的依赖注入引擎中完成的。从这一点我们可以理解以下内容:

  • 数据层必须实现领域层的接口,并必须创建领域层中定义的 DDD 实体和值对象,因此它引用了领域层。

  • 应用层引用领域层和数据层,但数据层类型的引用仅出现在依赖引擎中,在那里它们与在领域层中定义的相应接口相关联。

因此,领域层包含领域对象的表示、在其上使用的函数、验证约束以及与各种实体的关系。为了增加模块化和解耦,实体之间的通信通常使用事件编码,即使用发布/订阅模式。这意味着实体更新可以触发连接到业务操作的事件。

这种分层架构允许我们更改整个数据层,而不会影响领域层,领域层只依赖于领域规范和语言,不依赖于处理数据的技术细节。

应用层包含所有可能影响多个实体的操作定义以及所有应用程序所需的查询定义。业务操作和查询都使用领域层定义的接口与数据层交互。然而,虽然业务操作通过这些接口操纵和交换实体,但查询则向它们发送查询规范并从它们接收通用的 DTO(数据传输对象)。业务操作可以通过其他层(通常是表示层)或与应用层的通信来调用。业务操作还可以连接到由其他操作修改实体时触发的事件。

因此,应用层在领域层定义的接口上操作,而不是直接与它们的数据层实现交互,这意味着应用层与数据层解耦。更具体地说,数据层对象仅在依赖注入引擎定义中提及。所有其他应用层组件都引用在领域层中定义的接口,依赖注入引擎注入适当的实现。

应用层通过以下一个或多个模式与其他应用程序组件进行通信:

  • 它在通信端点(如 HTTP Web API)上暴露业务操作和查询(见第十二章,使用.NET Core 应用服务架构)。在这种情况下,表示层可能连接到这个端点或连接到其他端点,这些端点反过来又从这些端点和其他端点获取信息。从多个端点收集信息并在唯一端点暴露这些信息的应用程序组件被称为网关。它们可以是定制的,也可以是通用目的的,例如 Ocelot。

  • 它被直接实现表示层的应用程序(如 ASP.NET Core MVC Web 应用程序)作为库引用。

  • 它不是通过端点暴露所有信息,而是将其处理/创建的一些数据通信给其他应用程序组件,这些组件反过来又暴露端点。

在我们描述这些模式之前,我们需要理解聚合的概念。

聚合

到目前为止,我们讨论了实体作为基于 DDD 的业务层处理的单元。然而,几个实体可以被操作并合并成单个实体。一个例子是采购订单及其所有项目。实际上,独立于所属订单处理单个订单项是完全没有意义的。这是因为订单项实际上是订单的子部分,而不是独立的实体。

没有任何交易可以单独影响一个订单项而不影响该订单项所在的订单。想象一下,同一家公司的两个不同的人都在尝试增加水泥的总数量,但一个人增加了 1 型水泥(项目 1)的数量,而另一个人增加了 2 型水泥(项目 2)的数量。如果每个项目都被当作一个独立的实体来处理,那么这两个数量都会增加,这可能会导致一个不连贯的采购订单,因为水泥的总数量会被增加两次。

另一方面,如果整个订单及其所有订单项在每次交易中都被两个人加载和保存,那么其中一个人将覆盖另一个人的更改,因此最终做出更改的人将满足他们的需求。在 Web 应用程序中,不可能在整个用户查看和修改订单的过程中锁定整个采购订单,因此使用乐观并发策略。例如,只需为每个采购订单添加一个版本号,并执行以下操作:

  1. 不打开任何事务的情况下读取订单。

  2. 在保存修改后的订单之前,我们打开一个事务并执行第二次读取。

  3. 如果新检索到的订单的版本号与用户修改的订单的版本号不同,则操作将被中止,因为有人在第一次读取后立即修改了显示给用户的订单。在这种情况下,用户会被告知问题,并且新检索到的订单将再次显示给用户。

  4. 如果版本号没有更改,我们增加版本号,继续保存,并提交事务。

一个采购订单及其所有子部分(其订单项)被称为聚合,而订单实体被称为聚合的根。由于聚合是由通过子部分关系连接的实体层次结构,因此它们总是有根。

由于每个聚合代表一个单独的复杂实体,因此对其进行的所有操作都必须通过一个唯一的接口来暴露。因此,聚合根通常代表整个聚合,对聚合的所有操作都定义为根实体的方法。

当使用聚合模式时,在业务层和数据层之间传输的信息单元被称为聚合、查询和查询结果。因此,聚合取代了单个实体。

那么,我们在第六章交互式数据 C# - Entity Framework Core 和第七章如何在云中选择您的数据存储中看到的 WWTravelClub 位置和包实体怎么办?包是根植于它们相关位置的唯一聚合的一部分吗?不!实际上,位置很少更新,对包所做的更改不会影响其位置以及与同一位置相关联的其他包。

仓库模式和单元工作模式

存储库模式是我们实现领域数据层之间接口的方式。由存储库实现的接口在领域层中定义,而它们的实现则在数据层中定义。这种实现数据层接口的独特之处在于其实体中心性质,意味着每个根聚合应该有一个不同的存储库。每个存储库包含对相关聚合执行的所有保存/创建操作,以及所有对构成聚合的实体执行的查询操作。

由于也存在跨越多个聚合的事务,通常情况下,会应用工作单元模式与存储库模式结合。工作单元模式指出,每个数据层接口(在我们的案例中,每个存储库)都包含一个对工作单元接口的引用,该接口代表当前事务的标识。这意味着具有相同工作单元引用的多个存储库属于同一事务。

这两种模式都可以通过定义一些种子接口来实现:

public interface IUnitOfWork 
{ 
    Task<bool> SaveEntitiesAsync();
    Task StartAsync();
    Task CommitAsync();
    Task RollbackAsync();
}

public interface IRepository<T>: IRepository
{
   IUnitOfWork UnitOfWork { get; }
}

所有存储库接口都继承自IRepository<T>并将T绑定到它们关联的聚合根,而工作单元仅实现IUnitOfWork。当使用 Entity Framework 时,IUnitOfWork通常由DBContext实现,这意味着SaveEntitiesAsync()可以执行其他操作,然后调用DBContextSaveChangeAsync方法,以便将所有挂起的更改通过单个事务保存。如果需要从存储引擎检索数据时启动的更广泛的交易,它必须由负责整个操作的应用程序层处理程序启动和提交/回滚。IRepository<T>从空的IRepisotory接口继承,以帮助自动存储库发现。与本书相关的 GitHub 存储库包含一个RepositoryExtensions类,其AddAllRepositories IServiceCollection扩展方法可以自动发现一个程序集中包含的所有存储库实现,并将它们添加到依赖注入引擎中。

下图是基于存储库和 Unity of Work 模式的存储层/领域层/数据层架构图:

避免直接引用存储库实现的优点在于,如果我们对这些接口进行模拟,则可以轻松地对各个模块进行测试。

DDD 实体和 Entity Framework Core

DDD 要求实体以与我们在第六章中定义实体的方式不同。实际上,Entity Framework 实体是具有几乎无方法的公共属性记录列表,而 DDD 实体应该具有编码领域逻辑、更复杂的验证逻辑和只读属性的方法。虽然可以添加进一步的验证逻辑和方法而不会破坏 Entity Framework 的操作,但添加必须映射到数据库属性之外的只读属性可能会产生必须妥善处理的问题。防止属性映射到数据库相当简单——我们只需要用 NotMapped 属性装饰它们即可。

只读属性存在的问题稍微复杂一些,可以通过三种基本方式解决:

  • 将 DDD 实体定义为不同的类,并在实体返回/传递到存储库方法时在它们之间复制数据。这是最简单的解决方案,但需要您编写一些代码以在两种格式之间转换实体。DDD 实体在领域层定义,而 EF 实体继续在数据层定义。

  • 让 Entity Framework Core 将字段映射到类私有字段,这样您就可以通过编写自定义获取器和/或设置器来决定如何将它们公开为属性。这可以在实体的配置代码中完成,如下所示:

modelBuilder.Entity<MyEntity>()
      .Property("_myPrivatefield");

这种方法的主要缺点是字段以字符串的形式提供,这阻止了任何编译时检查,也阻止了自动重构,从而可能成为错误和维护问题的来源。此外,由于整个配置必须使用 OnModelCreating DBContext 方法的流畅接口执行,因此我们无法使用数据注释来配置属性。在这种情况下,实体定义必须按照 DDD 的规定从领域层移动。

  • 将包含所有公共属性的 Entity Framework 类隐藏在接口后面,该接口在需要时仅暴露属性获取器。接口在领域层定义,而实体继续在数据层定义。

    在这种情况下,存储库必须公开一个 Create 方法,该方法返回接口的实现;否则,高层无法创建新的实例并将其添加到存储引擎中,因为接口不能使用 new 创建。

例如,假设我们想要为第六章中“定义数据库实体”小节中定义的Destination类定义一个 DDD 接口,称为IDestination,并且假设我们希望将IdNameCountry属性公开为只读,因为一旦目的地创建后,它们就不能再修改。在这里,让Destination实现IDestination并在IDestination中将IdNameCountry定义为只读就足够了:

public interface IDestination
{ 
    int Id { get; }
    string Name { get; }   
    string Country { get; }
    string Description { get; set; }
    ...
}

既然我们已经讨论了 DDD 的基本模式和如何为 DDD 的需求适配 Entity Framework,我们可以讨论更高级的 DDD 模式。在下一个小节中,我们将介绍 CQRS 模式。

命令查询责任分离(Command Query Responsibility Segregation,CQRS)模式

在其更一般的形式中,该模式的用法相当简单:使用不同的结构来存储和查询数据。在这里,关于如何存储和更新数据的要求与查询的要求不同。在 DDD 的情况下,存储的单位是聚合,因此增加、删除和更新涉及聚合,而查询通常涉及从几个聚合中提取的属性的更多或更少的复杂转换。

此外,通常我们不会在查询结果上执行业务操作——我们只是使用它们来计算其他数据(平均值、总和等)。因此,虽然更新需要具有完整面向对象语义(方法、验证规则、封装信息等)的实体,但查询结果只需要属性/值对的集合,因此只有公共属性而没有方法的 DTOs 工作得很好。

在其更常见的形式中,该模式可以描述如下:

图片

从这个例子中我们可以得出的主要结论是,查询结果的提取不需要通过实体的构建和聚合的构建,但查询中显示的字段必须从存储引擎中提取并投影到临时的 DTOs 中。如果查询是用 LINQ 实现的,我们需要使用Select子句将必要的属性投影到 DTOs 中:

ctx.MyTable.Where(...)....Select(new MyDto{...}).ToList();

然而,在更复杂的情况下,CQRS 可能以更强的形式实现。具体来说,我们可以使用不同的边界上下文来存储预处理后的查询结果。这种方法在涉及由不同分布式微服务处理的不同边界上下文中存储的数据的查询中很常见。

实际上,另一个选择是一个聚合器微服务,它会查询所有必要的微服务以组装每个查询结果。然而,对其他微服务的递归调用以构建答案可能会导致不可接受的响应时间。此外,将一些预处理提取出来可以确保更好地利用可用资源。这种模式通过将由边界上下文更新引起的更改发送到所有需要它们来计算其预处理的查询结果的微服务来实现。

这种更强大的 CQRS 模式的使用将通常的本地数据库事务转换为复杂且耗时的分布式事务,因为单个查询预处理器微服务中的失败应该使整个事务无效。正如我们在第五章中解释的,将微服务架构应用于您的企业应用,由于性能原因,通常不接受实现分布式事务,因此常见的解决方案是放弃立即的整体一致数据库,并接受整体数据库将在每次更新后最终一致。瞬态故障可以通过我们在第五章中分析的重试策略来解决,将微服务架构应用于您的企业应用,而永久性故障则通过在已提交的本地事务上执行纠正操作来处理,而不是假装实现一个全局分布式的整体事务。

正如我们在第五章中讨论的,将微服务架构应用于您的企业应用,微服务之间的通信通常使用发布/订阅模式来实现以改善微服务分离。

在这一点上,你可能会有以下疑问:

"为什么我们一旦有了所有预处理的查询结果,还需要保留原始数据?我们永远不会用它们来回答查询!"

对此的一些答案如下:

  • 它们是我们可能需要从失败中恢复的真理的来源。

  • 当我们添加新的查询时,我们需要它们来计算新的预处理结果。

  • 我们需要它们来处理新的更新。实际上,处理更新通常需要从数据库中检索一些数据,可能显示给用户,然后进行修改。例如,要修改现有采购订单中的项目,我们需要整个订单,以便我们可以将其显示给用户并计算更改,以便我们可以将其转发给其他微服务。此外,无论何时我们修改或向存储引擎添加数据,都必须验证整个数据库的一致性(唯一键约束、外键约束等)。

在下一节中,我们将描述一个用于处理跨越多个聚合或多个边界上下文的操作的常见模式。

命令处理器和领域事件

为了保持聚合分离,通常,与其他聚合和其他边界上下文之间的交互是通过事件完成的。在计算每个聚合过程期间存储所有事件,而不是立即处理它们,以避免它们干扰正在进行的计算,这是一种良好的实践。这可以通过向本章 实体和价值对象 小节中定义的抽象实体类添加以下代码轻松实现,如下所示:

public List<IEventNotification> DomainEvents { get; private set; }
public void AddDomainEvent(IEventNotification evt)
{
    DomainEvents = DomainEvents ?? new List<IEventNotification>();
    DomainEvents.Add(evt);
}
public void RemoveDomainEvent(IEventNotification evt)
{
    DomainEvents?.Remove(evt);
}

在这里,IEventNotification 是一个空接口,用于标记类作为事件。

事件处理通常在更改存储在存储引擎之前立即执行。因此,在 IUnitOfWork 实现的 SaveEntitiesAsync() 方法中执行事件处理是一个好地方(见 存储库和单元工作模式 小节)。

对事件 T 的订阅可以提供为 IEventHandler<T> 接口的一个实现:

public interface IEventHandler<T>
    where T: IEventNotification
{
    Task HandleAsync(T ev);
}

类似地,业务操作可以通过包含操作所有输入数据的 command 对象来描述,而实现实际操作的代码可以通过 ICommandHandler<T> 接口的实现来提供:

public interface ICommandHandler<T>
    where T: ICommand
{
    Task HandleAsync(T command);
}

在这里,ICommand 是一个空接口,用于标记类作为命令。ICommandHandler<T>IEventHandler<T> 是我们在 第九章 中描述的命令模式的示例,设计模式和 .NET Core 实现

每个 ICommandHandler<T> 都可以在依赖注入引擎中注册,以便需要执行命令 T 的类可以在它们的构造函数中使用 ICommandHandler<T>。这样,我们就将命令(命令类)的抽象定义与其执行方式解耦。

同样的构造不能应用于事件 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);
        }
    }

理念是每个需要触发事件 T 的类都需要一个 EventTrigger<T>,然后将要触发的事件传递给它的 Trigger 方法,该方法反过来调用所有处理器。

然后,我们需要在依赖注入引擎中注册 EventTrigger<T>。一个不错的方法是定义依赖注入扩展,我们可以调用它来声明每个事件,如下所示:

        service.AddEventHandler<MyEventType, MyHandlerType>()

这个 AddEventHandler 扩展必须自动生成 EventTrigger<T> 的 DI 定义,并且必须处理每个类型 T 使用 AddEventHandler 声明的所有处理器。

以下扩展类为我们做了这件事:

public static class EventDIExtensions
{
    private static IDictionary<Type, List<Type>> eventDictionary = 
        new Dictionary<Type, List<Type>>();
    public static IServiceCollection AddEventHandler<T, H>
        (this IServiceCollection service)
        where T : IEventNotification
        where H: class, IEventHandler<T> 
    {
        service.AddScoped<H>();
        List<Type> list = null;
        eventDictionary.TryGetValue(typeof(T), out list);
        if(list == null)
        {
            list = new List<Type>();
            eventDictionary.Add(typeof(T), list);
            service.AddScoped<EventTrigger<T>>(p =>
            {
                var handlers = new List<IEventHandler<T>>();
                foreach(var type in eventDictionary[typeof(T)])
                {
                    handlers.Add(p.GetService(type) as IEventHandler<T>);
                }
                return new EventTrigger<T>(handlers);
            });
        }
        list.Add(typeof(H));

        return service;
    }
    ...
    ...
}

与每个事件 T 相关的所有处理程序的 H 类型记录在字典条目中,该字典条目由事件的 T 类型索引。然后,每个 H 都记录在依赖注入引擎中。

当第一次添加事件 T 的条目时,会创建相应的字典条目(一个 List<Type>)并将相应的 EventTrigger<T> 添加到依赖注入引擎中。EventTrigger<T> 实例是通过传递给 AddSingleton<EventTrigger<T>> 的函数创建的,该函数使用 T 的字典条目来获取所有处理程序类型。然后,使用所有处理程序类型通过 p.GetService(type) 从依赖注入引擎中检索它们的实例。我们可以使用这个操作,因为所有处理程序类型都已注册在依赖注入引擎中。最后,使用所有处理程序列表创建所需的 EventTrigger<T> 实例。

当程序启动时,所有 ICommandHandler<T>IEventHandler<T> 的实现都可以通过反射自动检索并注册。为了帮助自动发现,它们继承自 ICommandHandlerIEventHandler,这两个接口都是空的。本书的 GitHub 仓库中提供的 EventDIExtensions 类包含用于自动发现和注册命令处理程序和事件处理程序的方法。GitHub 仓库还包含一个 IEventMediator 接口及其 EventMediator 接口,其 TriggerEvents(IEnumerable<IEventNotification> events) 方法从依赖注入引擎中检索其参数中接收到的所有与事件关联的处理程序并执行它们。只要将 IEventMediator 注入到类中,就可以触发事件。EventDIExtensions 还包含一个扩展方法,用于发现实现空 IQuery 接口的查询,并将它们添加到依赖注入引擎中。

更为复杂的实现由 MediatR NuGet 包提供。前一小节专门介绍了一种极端的 CQRS 模式实现。

事件溯源

事件溯源是 CQRS 强化形式的一种极端实现。当原始的边界上下文根本不用于检索信息,而仅作为 真相来源 用于新的查询和从故障中恢复时,它非常有用。在这种情况下,我们不是更新数据,而是简单地添加描述已执行操作的 事件删除记录 ID 15,将名称更改为 ID 21 中的 John,等等。这些事件立即发送到所有依赖的边界上下文,在出现故障和/或添加新查询的情况下,我们只需重新处理其中的一些即可。

虽然我们之前描述的所有技术都可以在所有类型的项目中使用,只要进行一些小的修改,但事件溯源需要在采用之前进行深入分析,因为,在几种情况下,它可能比它能解决的问题造成更大的问题。为了了解它误用时可能引起的问题,想象一下我们将它应用于已经由多个用户修改和验证但尚未批准的采购订单。由于采购订单需要在更新/验证之前检索,因此采购订单的边界上下文不仅仅被用作真相来源,所以不应将其应用于事件溯源。如果不是这种情况,那么我们可以将其应用于事件溯源,在这种情况下,我们的代码将被迫在每次更新订单时从记录的事件中重建整个订单。

它的一个使用示例是我们描述在第五章,“将微服务架构应用于您的企业应用”末尾的收入日志系统。单个收入通过事件溯源记录,然后发送到我们在第五章,“将微服务架构应用于您的企业应用”中描述的微服务,该微服务反过来使用它们来预处理未来的查询,即计算每日收入。

在下一节中,我们将学习如何将 DDD 应用于本书的 WWTravelClub 用例。

用例 – 理解用例的领域

从第一章,“理解软件架构的重要性”中案例研究 – WWTravelClub部分列出的需求和第七章,“如何在云中选择您的数据存储”中用例 – 我在哪里存储数据?部分的分析中,我们知道 WWTravelClub 系统由以下部分组成:

  • 可用目的地和套餐的信息。我们在第七章,“如何在云中选择您的数据存储”中实现了该子系统数据层的第一个原型。

  • 预订/购买订单子系统。

  • 与专家/审查子系统的通信。

  • 支付子系统。我们在本章领域驱动设计部分的开始简要分析了该子系统的功能和它与预订购买子系统的关系。

  • 用户账户子系统。

  • 统计报告子系统。

前面的子系统代表不同的边界上下文吗?某些子系统可以被拆分为不同的边界上下文吗?这些问题的答案由每个子系统使用的语言提供:

  • 子系统 1 使用的语言是旅行社的语言。没有客户的概念;只有地点、套餐及其特性。

  • 子系统 2 中使用的语言对所有服务购买都是通用的,例如可用的资源、预订和采购订单。这是一个独立的边界上下文。

  • 子系统 3 中使用的语言与子系统 1 的语言有很多相似之处。然而,也存在典型的社交媒体概念,如评分、聊天、帖子分享、媒体分享等。这个子系统可以分为两部分:一个具有新边界上下文的社交媒体子系统和一个属于子系统 1 边界上下文的可用信息子系统。

  • 正如我们在领域驱动设计部分所指出的,在子系统 4 中,我们使用银行的语言。这个子系统与预订/采购子系统进行通信并执行执行购买所需的任务。从这些观察结果中,我们可以看出它是一个不同的边界上下文,并且与购买/预订系统有客户/供应商关系。

  • 子系统 5 肯定是一个独立的边界上下文(就像几乎所有网络应用一样)。它与所有具有用户或客户概念的边界上下文都有关系,因为用户账户的概念总是映射到这些概念上。但这是如何实现的呢?简单——当前登录的用户被认为是社交媒体边界上下文的社会媒体用户、预订/采购边界上下文的客户以及支付边界上下文的付款人。

  • 查询仅子系统,即 6,使用分析和统计的语言,与其他子系统中使用的语言有很大不同。然而,它几乎与所有边界上下文都有联系,因为它从它们那里获取所有输入。前面的约束迫使我们采用强形式的 CQRS,因此将其视为一个查询仅分离的边界上下文。我们在第五章,将微服务架构应用于您的企业应用中实现了一部分,使用了一个符合强形式 CQRS 的微服务。

总之,列出的每个子系统定义了一个不同的边界上下文,但部分与专家/审查子系统的通信必须包含在关于可用目的地和套餐的信息边界上下文中。

随着分析的继续和原型的实现,一些边界上下文可能会分裂,而另一些边界上下文可能会被添加,但立即开始建模系统并立即开始分析我们拥有的部分信息之间的边界上下文关系是基本的,因为这将推动进一步的调查,并帮助我们定义所需的通信协议和通用语言,以便我们可以与领域专家互动。

以下是该领域映射的基本初步草图:

图片

为了简单起见,我们省略了统计报告边界上下文。在这里,我们假设用户账户社交边界上下文与所有与之通信的其他边界上下文具有一致的关系,因为它们使用现有的软件实现,所以所有其他组件都必须适应它们。

如我们之前提到的,预订支付之间的关系是客户/供应商,因为支付提供用于执行预订任务的服务。所有其他关系都被归类为合作伙伴。大多数边界上下文都有的客户/用户的各种概念由用户账户授权令牌协调,它间接地负责在所有边界上下文之间映射这些概念。

包裹/位置子系统不仅传达了执行预订/购买所需的包裹信息,而且还负责通知待处理的购买订单可能的价格变化。最后,我们可以看到社交互动是从现有的评论或位置开始的,从而与包裹/位置边界上下文建立通信。

摘要

在本章中,我们分析了采用领域驱动设计的主要原因以及它如何满足市场需求。在这里,我们描述了如何识别领域以及如何使用领域图来协调同一应用程序中不同领域的工作团队。然后,我们分析了 DDD 如何通过实体、值对象和聚合来表示数据,并提供建议和代码片段,以便我们可以在实践中实现它们。

我们还介绍了一些与领域驱动设计(DDD)一起使用的典型模式,即存储库和单元工作模式、领域事件模式、CQRS 和事件溯源。然后,我们学习了如何在实践中实现它们。我们还展示了如何使用解耦处理来实现领域事件和命令模式,以便我们可以将填充代码片段添加到现实世界的项目中。

最后,我们利用 DDD 的原则在实践上定义领域,并为本书的 WWTravelClub 用例创建了领域图的第一个草图。

在下一章中,你将学习如何在项目中最大化代码重用。

问题

  1. 什么提供了主要的提示,以便我们可以发现领域边界?

  2. 用于协调独立边界上下文开发的主体工具是什么?

  3. 构成聚合的每个条目是否确实使用其自己的方法与系统的其余部分进行通信?

  4. 为什么只有一个聚合根?

  5. 多少个存储库可以管理一个聚合?

  6. 存储库如何与应用程序层交互?

  7. 为什么需要单元工作模式?

  8. CQRS 轻量级形式的原因是什么?它的最强形式的原因又是什么?

  9. 主要允许我们将命令/领域事件与其处理程序耦合的工具是什么?

  10. 事件源是否可以用来实现任何有界上下文?

进一步阅读

第十一章:在 C# 8 中实现代码重用

代码重用性是软件架构中最重要的主题之一。本章旨在讨论启用代码重用的方法,并了解 .NET Standard 如何朝这个方向努力以解决管理和维护可重用库的问题。

本章将涵盖以下主题:

  • 理解代码重用的原则

  • 与 .NET Standard 合作的优势

  • 创建可重用库

技术要求

本章需要以下内容:

  • 你需要安装所有数据库工具的 Visual Studio 2017 或 2019 免费社区版或更高版本。

  • 免费 Azure 账户:第一章中的“理解软件架构的重要性”部分,创建 Azure 账户,解释了如何创建一个。

  • Azure DevOps 账户:第三章中的“什么是 Azure DevOps”部分,使用 Azure DevOps 记录需求,解释了如何创建一个。

你可以在github.com/PacktPublishing/Hands-On-Software-Architecture-with-CSharp-8/tree/master/ch11找到本章的示例代码。

理解代码重用性原则

你可以始终使用单一的理由来证明代码重用——如果你在其他场景中已经运行良好,你不能浪费宝贵的时间重新造轮子。这就是为什么大多数工程领域都基于可重用性原则。想想你家里的灯开关。

你能想象出使用相同的界面组件可以制作多少个应用程序吗?代码重用的基本原则是相同的。再次强调,这是一个规划良好解决方案的问题,其中一部分可以在以后重用。

在软件工程中,代码重用是能够为软件项目带来一系列优势的技术之一,例如以下内容:

  • 考虑到重用的代码部分已在另一个应用程序中经过测试,因此对软件有信心

  • 由于软件架构师可以专注于解决这类问题,因此他们有更好的使用方式

  • 有可能将市场上已经接受的模式引入项目

  • 由于已经实现了组件,开发速度提高了

  • 维护更容易

这些方面表明,只要可能,就应该进行代码重用。作为软件架构师,确保上述优势并激励你的团队在创建的软件中启用重用是你的责任。

什么是非代码重用?

你必须首先理解的是,代码复用并不意味着从一类复制粘贴代码到另一类。即使这段代码是由另一个团队或项目编写的,这也不表明你正确地运用了复用原则。让我们想象一个场景,我们将在本书的使用案例中找到这个场景,即 WWTravelClub 评估。

在项目场景中,你可能想评估不同类型的主题,例如包、目的地专家、城市、评论等。无论你指的是哪个主题,获取评估平均值的流程都是相同的。因此,你可能希望通过复制粘贴每个评估的代码来启用复用。(不好的)结果可能如下所示:

在前面的图中,计算评估平均值的流程是分散的,这意味着相同的代码将在不同的类中重复。这将造成很多麻烦,尤其是如果相同的做法开始在其他应用程序中发生。例如,如果有一个关于如何计算平均值的新规范,或者如果你只是计算公式中出现了错误,你将不得不在所有代码实例中修复它。

什么是代码复用?

上节中提到的问题的解决方案相当简单。你必须分析你的代码,并选择那些从你的应用程序中解耦的部分。你应该解耦的最大原因与你如何确保这段代码可以在应用程序的其他部分或另一个应用程序中复用有关:

代码的集中化给你,作为一个软件架构师,带来了不同的责任。你必须记住,这段代码的任何错误或不兼容都可能对应用程序的许多部分或不同的应用程序造成损害。另一方面,一旦你测试并运行了这段代码,你将能够无忧无虑地传播其使用。此外,如果你需要进化平均计算过程,你将不得不在一个类中更改代码。

值得注意的是,你使用相同代码的次数越多,这种开发方式就越便宜。需要提到成本,因为通常情况下,可复用软件的概念在开始时成本更高。

将复用引入你的开发周期

如果你理解了复用将带你进入代码实现的另一个层次,你就应该开始思考如何在你的开发周期中实现这种技术。实际上,创建和维护组件库并不容易,因为你将承担的责任以及缺乏支持现有组件搜索的良好工具。

另一方面,有一些事情你可能在每次开始新的开发时都应考虑在软件开发过程中实施:

  • 使用用户库中已实现的组件,选择软件需求规范中需要它们的特性。

  • 识别软件需求规范中作为库组件设计候选者的功能。

  • 修改规范,考虑到这些功能将使用可重用组件来开发。

  • 设计可重用组件,并确保它们具有适当的接口,以便在多个项目中使用。

  • 构建使用新组件库版本的项目架构。

  • 记录组件库版本,以便每个开发者和团队都知道它。

使用-识别-修改-设计-构建 过程是一种技术,你可能每次需要启用软件重用时都应考虑实施。一旦你有了为这个库编写所需的组件,你将需要决定将提供这些组件的技术。

在软件开发的历史中,有许多实现这一目标的方法;其中一些在第五章,“将微服务架构应用于您的企业应用程序”的微服务作为模块概念的演变部分中进行了讨论。

使用 .NET Standard 进行代码重用

.NET 自从第一个版本发布以来已经发展了很多。这种发展不仅与命令数量和性能问题相关,还与支持的平台相关。正如在第一章,“理解软件架构的重要性”中讨论的那样,你可以在数十亿台设备上运行 C# .NET,即使它们运行的是 Linux、Android、macOS 或 iOS。因此,.NET Standard 首次与 .NET Core 1.0 一起宣布,但破坏性变化发生在 .NET Standard 2.0,当时 .NET Framework 4.6、.NET Core 和 Xamarin 都与之兼容。

关键点在于 .NET Standard 不仅是一种 Visual Studio 项目。更重要的是,它是一种对所有 .NET 实现都适用的正式规范。正如你在下表中所见,它涵盖了从 .NET Framework 到 Unity (github.com/dotnet/standard/tree/master/docs/versions) 的所有内容:

.NET Standard 1.0 1.1 1.2 1.3 1.4 1.5 1.6 2.0 2.1
.NET Core 1.0 1.0 1.0 1.0 1.0 1.0 1.0 2.0 3.0
.NET Framework 4.5 4.5 4.5.1 4.6 4.6.1 4.6.1¹ 4.6.1¹ 4.6.1¹ N/A²
单元 4.6 4.6 4.6 4.6 4.6 4.6 4.6 5.4 6.2
Xamarin.iOS 10.0 10.0 10.0 10.0 10.0 10.0 10.0 10.14 12.12
Xamarin.Mac 3.0 3.0 3.0 3.0 3.0 3.0 3.0 3.8 5.12
Xamarin.Android 7.0 7.0 7.0 7.0 7.0 7.0 7.0 8.0 9.3
Unity 2018.1 2018.1 2018.1 2018.1 2018.1 2018.1 2018.1 2018.1 TBD
通用 Windows 平台 8.0 8.0 8.1 10.0 10.0 10.0.16299 10.0.16299 10.0.16299 TBD

前面的表格表明,如果你构建一个与该标准兼容的类库,你将能够在所展示的任何平台上重用它。想想看,如果你计划在所有项目中这样做,你的开发过程会变得多快。

显然,一些组件不包括在 .NET Standard 中,但其发展是持续的。值得一提的是,微软的官方文档指出,版本越高,可用的 API 越多

创建 .NET Standard 库

创建一个与 .NET Standard 兼容的类库相当简单。基本上,你需要在创建库时选择以下项目:

一旦你完成了这部分,你会注意到,一个普通类库和你创建的类库之间的唯一区别是项目文件中定义的目标框架:

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

一旦你的项目加载完毕,你就可以开始编写你打算重用的类。使用这种方法构建可重用类的优点是,你将能够在之前检查的所有项目类型中重用所编写的代码。另一方面,你会发现一些在 .NET Framework 中可用的 API 在这类项目中不存在。你可以通过github.com/dotnet/standard/tree/master/docs/planning/netstandard-2.1来关注标准的未来。

C# 如何处理代码重用?

C# 有许多方法帮助我们处理代码重用。我们之前检查的构建库的能力是其中之一。最重要的是,这种语言是面向对象的。此外,还值得一提的是泛型为 C# 语言带来的便利。本主题将讨论最后两个提到的内容。

面向对象分析

面向对象分析的方法使我们能够以不同的方式重用代码,从继承的便利性到多态的可变性。完全采用面向对象编程将让你也能够实现抽象和封装。

以下截图展示了使用面向对象的方法来简化重用。正如您所看到的,有不同方式来计算评估的等级,考虑到您可以是系统的基本用户或高级用户:

在这个设计中,代码重用有两个方面需要分析。第一个方面是,由于继承为你做了这件事,所以没有必要在每个子类中声明属性。

第二个机会是我们使用多态,为相同的方法启用不同的行为:

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()
    {
        var count = 0;
        double evaluationGrade = 0;
        foreach (var evaluation in content.Evaluations)
        {
            evaluationGrade += evaluation.CalculateGrade();
            count++;
        }
        return evaluationGrade/count;
    }
}

在使用 C# 时,面向对象的采用可以被认为是强制性的。然而,更具体的用法需要学习和实践。作为软件架构师,你应该始终鼓励你的团队学习面向对象的分析。他们的抽象能力越强,代码复用就越容易。

泛型

泛型是在 C# 2.0 版本中引入的,它被认为是一种提高代码复用的方法。它还最大限度地提高了类型安全和性能。

泛型的基本原理是,你可以在接口、类、方法、属性、事件、委托或甚至一个占位符中定义,这个占位符将在稍后某个实体被使用时,用特定的类型替换。这个特性为你提供了不可思议的机会,因为你可以使用相同的代码来运行不同版本的类型,实现泛型。

以下代码是对上一节中介绍的 EvaluationService 的修改。这里的想法是使服务泛化,从其创建时就定义评估的目标:

public class EvaluationService<T> where T: IContentEvaluated

这个声明表明,任何实现了 IContentEvaluaded 接口的类都可以用于这个服务。此外,服务将负责创建评估内容。

以下代码实现了自服务构建以来创建的评估内容。这段代码使用了 System.Reflection 和来自类的泛型定义:

public EvaluationService()
{
    var name = GetTypeOfEvaluation();
    content = (T)Assembly.GetExecutingAssembly().CreateInstance(name);
}

值得注意的是,这段代码将能够工作,因为所有类都在同一个程序集中。这个修改的结果可以在服务的实例创建中进行检查:

var service = new EvaluationService<CityEvaluation>();

好消息是,现在你有一个泛型服务,它将自动实例化所需内容的评估列表对象。值得一提的是,泛型显然需要更多时间用于第一个项目的构建。然而,一旦设计完成,你将拥有一个良好、快速且易于维护的代码。这就是我们所说的复用!

用例 - 代码复用作为快速交付优质和安全的软件的途径

评估 WWTravelClub 内容的解决方案的最终设计可以按以下方式检查。这种方法包括使用本章讨论的许多主题。首先,所有代码都放置在一个 .NET Standard 类库中。这意味着您可以将此代码添加到不同类型的解决方案中,例如 .NET Core 网络应用程序和 Android 和 iOS 平台的 Xamarin 应用程序:

图片

此设计利用了面向对象原则,如继承,因此您不需要在多个类中多次编写可被使用的属性和方法;以及多态,这样您可以在不更改方法名称的情况下更改代码的行为。

最后,该设计通过引入泛型作为可以促进类似类(如我们在 WWTravelClub 中用于评估城市、评论、目的地专家和旅游套餐的类)操作的工具,抽象了内容的概念。

在鼓励代码复用的团队和不鼓励代码复用的团队之间,最大的区别在于向最终用户提供优质软件的速度。当然,开始这种做法并不容易,但请放心,经过一段时间的工作,你将获得良好的结果。

摘要

本章旨在帮助您了解代码复用的优势。它还让您对什么是未正确复用的代码有了概念。本章还介绍了代码复用的方法。

考虑到没有流程的技术无法带您走得更远,本章提出了一种流程来启用代码复用。此流程与使用库中已经完成的组件相关;在软件需求规格说明书中识别可以作为库组件设计的候选功能;根据这些功能修改规格说明;设计可复用的组件;以及使用新的组件库版本构建项目架构。

最后,本章将 .NET Standard 库作为在不同 C# 平台上复用代码的方法,强调了面向对象编程作为复用代码的方式,并介绍了泛型作为简化具有相同特性的对象处理的复杂实现。在下一章中,我们将看到如何使用 .NET Core 应用 服务导向架构SOA)。

值得注意的是,SOA 被认为是复杂环境中实现代码复用的一种方式。

问题

  1. 复制和粘贴可以被认为是代码复用吗?这种方法的有哪些影响?

  2. 如何在不复制和粘贴代码的情况下利用代码复用?

  3. 有没有可以帮助代码复用的流程?

  4. .NET Standard 和 .NET Core 之间的区别是什么?

  5. 创建 .NET Standard 库有哪些优势?

  6. 面向对象分析如何帮助代码复用?

  7. 泛型如何帮助代码复用?

进一步阅读

这些是一些书籍和网站,您可以在其中找到更多关于本章的信息:

第十二章:使用.NET Core 应用 SOA

术语面向服务的架构SOA)指的是一种模块化架构,其中系统组件之间的交互是通过通信实现的。SOA 允许来自不同组织的应用程序自动交换数据和事务,并允许组织在互联网上提供服务。

此外,正如我们在第五章的模块概念的演变 - 微服务部分中讨论的,将微服务架构应用于您的企业应用程序,基于通信的交互解决了由共享相同地址空间的模块组成的复杂系统中不可避免出现的二进制兼容性和版本不匹配问题。此外,使用 SOA,您不需要在所有使用该组件的系统/子系统上部署同一组件的不同副本 - 每个组件只需在一个地方部署即可。这可以是一个单独的服务器,位于单个数据中心的位置集群,或者地理上分布的集群。在这里,您的组件的每个版本都只部署一次,服务器/集群逻辑自动创建所有必要的副本,从而简化了整体的持续集成/持续交付CI/CD)周期。

只要新版本符合向客户端声明的通信接口,就不会出现不兼容性。另一方面,当使用 DLLs/packages 时,如果保持相同的接口,由于库模块可能与其客户端共享的其他 DLLs/packages 的依赖项中可能出现的版本不匹配,可能会出现不兼容性。

在第五章将微服务架构应用于您的企业应用程序中讨论了组织协作服务的集群/网络。在本章中,我们将主要关注每个服务提供的通信接口。更具体地说,我们将讨论以下主题:

  • 理解 SOA 方法的原则

  • .NET Core 如何处理 SOA?

  • 用例 - 暴露 WWTravelClub 包

在本章结束时,您将了解如何通过 ASP.NET Core 服务公开暴露 WWTravelClub 书用例中的数据。

技术要求

本章需要 Visual Studio 2017 或 2019 免费社区版或更高版本,并安装所有数据库工具。

本章中的所有概念都将基于本书的 WWTravelClub 书用例的实践示例进行阐明。您可以在github.com/PacktPublishing/Hands-On-Software-Architecture-with-CSharp-8找到本章的代码。

理解 SOA 方法的原则

就像面向对象架构中的类一样,服务是实现接口的实例,而这些接口又来自系统功能规格。因此,服务 设计的第一步是定义其抽象接口。在这个阶段,你将所有服务操作定义为操作接口方法,这些方法作用于你喜欢的语言类型(C#、Java、C++、JavaScript 等),并决定哪些操作使用同步通信实现,哪些操作使用异步通信实现。

在这个初始阶段定义的接口不一定会在实际的服务实现中使用,它们只是有用的设计工具。一旦我们确定了服务的架构,这些接口通常会被重新定义,以便我们可以根据架构的独特性来适应它们。

值得指出的是,SOA 消息必须保持与方法调用/响应相同类型的语义;也就是说,对消息的反应不能依赖于任何之前接收到的消息。在这里,消息必须相互独立,服务 不得记住 任何之前接收到的消息。

例如,如果消息的目的是创建一个新的数据库条目,这种语义不能随着其他消息的上下文而改变,并且数据库条目的创建方式必须依赖于当前消息的内容,而不是其他之前接收到的消息。因此,客户端不能创建会话,也不能登录到服务,执行一些操作,然后注销。必须在每个消息中重复使用认证令牌。

这种约束的原因是模块化、可测试性和可维护性。实际上,由于会话数据中隐藏的交互,基于会话的服务将很难进行测试和修改。

一旦你确定了一个服务将要实现的用户界面,你必须决定采用哪种通信栈/ SOA 架构。通信栈必须是某个官方或事实上的标准的一部分,以确保你的服务的互操作性。互操作性是 SOA 规定的主要约束:服务必须提供一个不依赖于特定通信库、实现语言或部署平台的通信接口。

一旦你确定了通信栈/架构,你需要将之前的接口适应架构的独特性(有关更多详细信息,请参阅本章的 REST Web 服务 子节)。然后,你必须将这些接口翻译成选定的通信语言。这意味着你必须将所有编程语言类型映射到所选通信语言中可用的类型。

实际的数据转换通常由开发环境使用的 SOA 库自动执行。然而,可能需要一些配置,并且在任何情况下,我们必须意识到在每次通信之前我们的编程语言类型是如何转换的。例如,某些数值类型可能被转换为精度较低或值范围不同的类型。

对于集群外不可访问的微服务,互操作性约束可以以较轻的形式解释,因为它们需要与其他属于同一集群的微服务进行通信。在这种情况下,这意味着通信栈可能是平台特定的,以便提高性能,但它必须是一个事实上的标准,以避免与其他可能随着应用程序的发展而添加到集群中的微服务兼容性问题。

我们讨论的是通信栈而不是通信协议,因为 SOA 通信标准通常定义消息内容的格式,并为嵌入这些消息的特定协议提供不同的可能性。例如,SOAP 协议仅定义了各种消息的基于 XML 的格式,但 SOAP 消息可以通过各种协议传递。通常,用于 SOAP 的最常见协议是 HTTP,但你可能决定跳到 HTTP 级别,并通过 TCP/IP 直接发送 SOAP 消息以获得更好的性能。

你应该采用的通信栈的选择取决于几个因素:

  • 兼容性约束:如果你的服务必须对互联网上的商业客户公开可用,那么你必须遵守最常见的选择,这意味着使用 HTTP 上的 SOAP 或 JSON REST 服务。如果您的客户端不是商业客户端而是物联网IoT)客户端,那么最常见的选择可能会有所不同。在物联网中,不同应用领域使用的协议可能不同。例如,海洋车辆状态数据通常不与Signal K交换。

  • 开发/部署平台:并非所有通信栈都适用于所有开发框架和所有部署平台。例如,我们在第五章的代码示例中使用的.NET 远程通信,专门针对.NET 和 Azure Service Fabric。幸运的是,所有在公共商业服务中使用的最常见通信栈,如基于 SOAP 和 JSON 的 REST 通信,都可在所有主要开发/部署平台上使用。

  • 性能:如果你的系统没有暴露给外界,而是你的微服务集群的私有部分,性能考虑的优先级更高。这就是为什么在第五章的末尾的 Service Fabric 示例中,将微服务架构应用于企业应用程序,我们使用了.NET 远程通信作为内部通信栈。值得注意的是,对于私有服务,你需要关注互操作性,并避免使用自定义通信栈。.NET 远程通信不是一个官方标准,但它是可以接受的,因为它是在 Azure Service Fabric 内部通信的事实标准。

  • 你团队中工具和知识的可用性:在选择可接受的通信栈时,了解团队/组织中工具和知识的可用性具有重要意义。然而,这种限制通常比兼容性约束的优先级低,因为设想一个对团队来说易于实施但几乎无人能使用的系统是没有意义的。

  • 灵活性与可用特性之间的权衡:一些通信解决方案虽然不够完整,但提供了更高的灵活性,而其他解决方案虽然更完整,但提供的灵活性较低。对灵活性的需求在过去的几年中引发了一场从基于 SOAP 的服务转向更灵活的 REST 服务的运动。当我们在本节剩余部分描述 SOAP 和 REST 服务时,这一点将更详细地讨论。

  • 服务描述:当服务必须在互联网上公开时,客户端应用程序需要一个公开可用的服务规范描述,以便设计它们的通信客户端。一些通信栈包括用于描述服务规范的语言和约定。以这种方式公开的形式化服务规范可以被处理,以便自动创建通信客户端。SOAP 更进一步,通过一个包含有关每个 Web 服务可以执行的任务信息的公共 XML 目录,允许服务可发现性。

一旦你选择了希望使用的通信栈,你必须使用你的开发环境中可用的工具以符合所选通信栈的方式实现服务。有时,通信栈的合规性可以通过开发工具自动保证,但有时可能需要一些开发工作。例如,在.NET 世界中,如果你使用 WCF,SOAP 服务的合规性会自动由开发工具保证,而 REST 服务的合规性则落在开发者的责任之下。

SOA 解决方案的一些基本特性如下:

  • 认证:允许客户端认证以访问服务操作。

  • 授权:处理客户端的权限。

  • 安全性:这是如何保持通信安全的方法,即如何防止未经授权的系统读取和/或修改通信内容。通常,加密可以防止未经授权的修改和读取,而电子签名算法仅防止修改。

  • 异常:向客户端返回异常。

  • 消息可靠性:确保在可能的基础设施故障的情况下,消息可靠地到达其目的地。

虽然有时是可取的,但以下功能并不总是必要的:

  • 分布式事务:处理分布式事务的能力,因此当分布式事务失败或被中止时,可以撤销所做的所有更改。

  • 支持发布/订阅模式:如果以及如何支持事件和通知

  • 寻址:如果以及如何支持对其他服务和/或服务/方法的引用

  • 路由:如果以及如何将消息通过服务网络进行路由

本节的其余部分致力于描述 SOAP 和 REST 服务,因为它们是公开其集群/服务器之外的业务服务的既定标准。出于性能原因,微服务使用其他协议,如 .NET Remoting 和 AMQP 进行集群间通信。.NET Remoting 的使用在第五章将微服务架构应用于您的企业应用程序中讨论,而 AMQP 的链接在进一步阅读部分给出。

SOAP 网络服务

简单对象访问协议SOAP)允许单向消息和响应/回答消息。通信可以是同步的,也可以是异步的,但如果底层协议是同步的,例如在 HTTP 的情况下,发送者会收到一个确认消息,表明消息已被接收(但不一定已处理)。当使用异步通信时,发送者必须监听传入的通信。通常,异步通信是通过我们第九章设计模式和 .NET Core 实现中描述的订阅者/发布者模式实现的。

消息表示为称为封装的 XML 文档。每个封装包含一个header(头部)、一个body(主体)和一个fault(错误)元素。body是放置实际消息内容的地方。fault元素包含可能的错误,因此它是通信发生时交换异常的方式。最后,header包含任何丰富协议但不包含域数据的辅助信息。例如,header可能包含一个身份验证令牌,以及/或如果消息已签名,则包含一个签名。

用于发送 XML 封装的底层协议通常是 HTTP,但 SOAP 规范允许使用任何协议,因此我们可以直接使用 TCP/IP 或 SMTP。实际上,更广泛使用的底层协议是 HTTP,因此,如果没有充分的理由选择其他协议,您应该使用 HTTP 以最大限度地提高服务的互操作性。

SOAP 规范包含消息交换的基本内容,而其他辅助功能则在单独的规范文档中描述,称为 WS-*,通常通过在 SOAP header 中添加额外信息来处理。WS-* 规范处理我们之前列出的 SOA 的所有基本和期望的特性。例如,WS-Security 负责安全,包括身份验证、授权和加密/签名;WS-EventingWS-Notification 是实现发布/订阅模式的两种替代方法;WS-ReliableMessaging 关注于在可能出现故障的情况下可靠地传递消息,而 WS-Transaction 关注于分布式事务。

之前的 WS-* 规范并非详尽无遗,但它们是更相关且得到支持的特性。实际上,在各个环境(如 Java 和 .NET)中的实际实现提供了更相关的 WS-* 服务,但没有一个实现支持所有的 WS-* 规范。

在 SOAP 协议中涉及的所有 XML 文档/文档部分都在 XSD 文档中正式定义,这些是特殊的 XML 文档,其内容提供了 XML 结构的描述。此外,如果您的自定义数据结构(面向对象语言中的类和接口)要成为 SOAP 封装的一部分,它们必须被转换为 XSD。

每个 XSD 规范都有一个相关的 namespace,用于标识规范及其物理位置,该位置可以找到它。命名空间和物理位置都是 URI。如果 Web 服务仅从内部网络访问,则位置 URI 不需要公开访问。

服务的整个定义是一个可能包含对其他命名空间的引用的 XSD 规范,即对其他 XSD 文档的引用。简而言之,SOAP 通信的所有消息都必须在 XSD 规范中定义。然后,如果服务器和客户端引用相同的 XSD 规范,它们就可以进行通信。这意味着,例如,每次您向消息添加另一个字段时,您都需要创建一个新的 XSD 规范。之后,您需要通过创建新版本来更新所有引用旧消息定义的 XSD 文件到新消息定义。反过来,这些修改需要为其他 XSD 文件创建其他版本,依此类推。因此,保持与先前行为兼容的简单修改(客户端可以简单地忽略添加的字段)可能会导致版本变化的指数级链式反应。

在过去几年中,处理修改的难度,处理所有WS-*规范配置的复杂性以及性能问题,导致逐渐转向我们将在下一节中描述的更简单的 REST 服务。这一转变始于从 JavaScript 调用的服务,因为实现能够在网页浏览器中高效运行的完整 SOAP 客户端很困难。此外,复杂的 SOAP 机制对于典型在浏览器中运行的客户端的简单需求来说过大,可能导致了开发时间的完全浪费。

大约在 2018 年左右,针对非 JavaScript 客户端的服务开始大规模转向 REST 服务,如今首选的选择是 REST 服务,SOAP 的使用要么是为了与遗留系统兼容,要么是在需要 REST 服务不支持的功能时。一个继续偏好 SOAP 系统的典型应用领域是支付/银行系统,因为这些系统需要由WS-Transaction SOAP 规范提供的交易支持。在 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 服务的类型兼容性规则。让我们设想有几个服务使用一个包含 NameSurnameAddress 字符串字段的 Person 对象:

{
    Name: string,
    Surname: string,
    Address: string
}

如果服务和客户端引用的是前面定义的不同副本,则类型兼容性得到保证。客户端使用具有较少字段的定义也是可以接受的,因为它可以简单地忽略所有其他字段:

{
    Name: string,
    Surname: string,
}

现在,假设一个处理 Persons 数据库的服务,S1,将 Address 字符串替换为一个复杂对象:

{
    Name: string,
    Surname: string,
    Address: 
        {
            Country: string,
            Town: string
            Location: string
        }
}

现在,假设一个服务,S2,从 S1 中获取 Persons 并将其添加到它返回的一些方法上的响应中。在 S1 的破坏性更改之后,它可以调整调用 S1 的通信客户端以适应新格式。然后,它可以在使用 Persons 作为响应之前将新的 Person 格式转换为旧格式。这样,S2 就避免了传播 S1 的破坏性更改。

通常,基于对象形状(嵌套属性的树)而不是对同一正式类型定义的引用来建立类型兼容性,这增加了灵活性和可修改性。我们为此增加的灵活性所付出的代价是,类型兼容性不能通过比较服务器和客户端接口的正式定义自动计算。事实上,在没有明确规范的情况下,每次发布新的服务版本时,开发者都必须验证客户端和服务器共有的所有字段的语义与上一个版本保持不变。REST 服务的背后基本思想是放弃严重性检查和复杂协议,以获得更大的灵活性和简单性,而 SOAP 则恰恰相反。

REST 服务宣言指出,REST 使用原生 HTTP 功能来实现所有必需的服务功能。例如,认证将通过 HTTP 的 Authorization 字段直接执行,加密将通过 HTTPS 实现,异常将通过 HTTP 错误状态码处理,路由和可靠消息将通过 HTTP 协议依赖的机制处理。通过使用 URL 来引用服务、它们的方法和其他资源来实现寻址。

由于 HTTP 是同步协议,因此没有对异步通信的原生支持。也没有对发布者/订阅者模式的原生支持,但两个服务可以通过每个向对方公开一个端点来与发布者/订阅者模式交互。更具体地说,第一个服务公开一个订阅端点,而第二个服务公开一个接收其通知的端点,这些通知通过在订阅期间交换的公共密钥进行授权。这种模式相当常见。GitHub 还允许我们将我们的 REST 服务发送到存储库事件。

REST 服务在实现分布式事务方面没有提供简单选项,这也是为什么支付/银行系统仍然更喜欢 SOAP。幸运的是,大多数应用领域不需要分布式事务确保的强一致性形式。对于它们,更轻量级的一致性形式,如最终一致性,就足够了,并且出于性能原因更受欢迎。请参阅第七章,如何在云中选择您的数据存储,以了解各种类型的一致性讨论。

REST 宣言不仅规定了使用 HTTP 中已预定义的解决方案,还规定了使用类似 WEB 的语义。更具体地说,所有服务操作都必须被视为对由 URL(同一资源可能由多个 URL 标识)标识的资源进行的 CRUD 操作。实际上,REST 是表示状态传输的缩写,意味着每个 URL 都是某种对象的表示。每种类型的服务请求都需要采用适当的 HTTP 动词,如下所示:

  • GET(读取操作):URL 代表由读取操作返回的资源。因此,GET操作模拟指针解引用。在操作成功的情况下,返回一个 200(ok)状态码。

  • POST(创建操作):请求体中包含的 JSON/XML 对象被添加为操作 URL 所表示的对象的新资源。如果新资源立即成功创建,则返回一个 201(created)状态码,以及一个依赖于操作的响应对象。响应对象应包含标识创建资源的最具体 URL。如果创建被延迟到以后的时间,则返回一个 202(accepted)状态码。

  • PUT:请求体中包含的 JSON/XML 对象替换由请求 URL 引用的对象。在操作成功的情况下,返回一个 200(ok)状态码。此操作是幂等的,意味着重复相同的请求两次会产生相同的修改。

  • PATCH: 请求体中包含的 JSON/XML 对象包含了如何修改由请求 URL 引用的对象的说明。由于修改可能是一个数值字段的增量,此操作不是幂等的。在操作成功的情况下,返回 200 (ok) 状态码。

  • DELETE: 删除由请求 URL 引用的资源。在操作成功的情况下,返回 200 (ok) 状态码。

如果资源已从请求 URL 移动到另一个 URL,则返回重定向代码:

  • 301 (永久移动),以及我们可以找到资源的新的 URL

  • 307 (临时移动),以及我们可以找到资源的新的 URL

如果操作失败,返回的状态码取决于失败的类型。以下是一些失败代码的示例:

  • 400 (错误请求):发送给服务器的请求格式不正确。

  • 404 (未找到):当请求 URL 不指向任何已知对象时。

  • 405 (方法不允许):当请求动词不支持由 URL 引用的资源时。

  • 401 (未授权):操作需要认证,但客户端没有提供任何有效的授权头。

  • 403 (禁止):客户端已正确认证,但没有执行操作的权限。

前面的状态码列表并不完整。在 进一步阅读 部分将提供完整的列表引用。

需要强调的是,POST/PUT/PATCH/DELETE 操作可能对其他资源有副作用——通常是这样。否则,将无法编写同时作用于多个资源的操作。

换句话说,HTTP 动词必须符合由请求 URL 引用的操作,但该操作可能影响其他资源。相同的操作可能使用不同的 HTTP 动词在另一个涉及的资源上执行。开发者有责任选择以哪种方式执行相同的操作,以便在服务接口中实现它。

由于 HTTP 动词的副作用,REST 服务能够将这些操作编码为对由 URL 表示的资源进行的 CRUD 操作。

通常,将现有服务迁移到 REST 需要我们在请求 URL 和请求体之间分割各种输入。更具体地说,我们提取那些唯一定义方法执行中涉及的某个对象的输入字段,并使用它们创建一个唯一标识该对象的 URL。然后,我们根据对所选对象执行的操作来决定使用哪个 HTTP 动词。最后,我们将输入的其余部分放在请求体中。

如果我们的服务是以面向对象架构设计的,该架构专注于业务域对象(例如,如第十章所述的 DDD,理解软件解决方案中的不同域),那么所有服务方法的 REST 转换应该相当直接,因为服务应该已经围绕域资源组织。否则,迁移到 REST 可能需要重新定义一些服务接口。

采用完整的 REST 语义的优点是,服务可以在不修改现有操作定义的情况下进行扩展,也可以在修改现有操作定义的情况下进行扩展。实际上,扩展应主要表现为某些对象的一些附加属性以及一些相关操作的附加资源 URL。因此,现有的客户端可以简单地忽略它们。

现在,让我们通过一个银行内部资金转账的简单示例来学习如何在 REST 语言中表达方法。一个银行账户可以表示为一个 URL,如下所示:

https://mybank.com/bankaccounts/{bank account number}

转账可能表示为一个包含表示金额、转账时间、描述和接收资金的账户的对象的 PATCH 请求。该操作修改了 URL 中提到的账户,但也作为 副作用 修改了接收账户。如果账户资金不足,将返回 403(禁止)状态码,以及包含所有错误详情的对象(错误描述、可用资金等)。

然而,由于所有银行操作都记录在账户报表中,因此为与银行账户关联的 银行账户操作 集合创建和添加一个新的转账对象是表示转账的更好方式。在这种情况下,URL 可能如下所示:

https://mybank.com/bankaccounts/{bank account number}/operations

在这里,HTTP 动词是 POST,因为我们正在创建一个新的对象。正文内容完全相同,如果资金不足,将返回 403 状态码。

传输的这两种表示在数据库中引起完全相同的变化。此外,一旦从不同的 URL 和可能不同的请求体中提取了输入,后续的处理方式完全相同。在两种情况下,我们都有完全相同的输入和相同的处理过程——只是两个请求的外部外观不同。

然而,虚拟 操作 集合的引入使我们能够通过几个更多特定于操作集合的方法来扩展服务。值得注意的是,操作集合不需要与数据库表或任何物理对象连接:它存在于 URL 的世界中,为我们创建了一种方便的方式来模拟传输。

REST 服务的使用增加导致需要创建 REST 服务接口的描述,类似于为 SOAP 开发的那些。这个标准被称为OpenAPI。我们将在下一个子节中讨论这个问题。

OpenAPI 标准

OpenAPI 是一个用于描述 REST API 的标准。它目前是第 3 版。整个服务由一个 JSON 端点描述,即一个用 JSON 对象描述服务的端点。这个 JSON 对象有一个通用部分,适用于整个服务,并包含服务的一般特性,如其版本和描述,以及共享定义。

然后,每个服务端点都有一个特定的部分,描述端点 URL 或 URL 格式(如果某些输入包含在 URL 中),所有输入,所有可能的输出类型和状态码,以及所有授权协议。每个端点特定的部分可以引用通用部分中包含的定义。

OpenAPI 语法的描述超出了本书的范围,但在进一步阅读部分提供了参考。各种开发框架通过处理 REST API 代码和提供更多信息来自动生成 OpenAPI 文档,因此您的团队不需要深入了解 OpenAPI 语法。

“.NET Core 如何处理 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 密钥容易在服务器或客户端端受到攻击。

更安全的技巧使用有效的长期共享密钥,只需用户登录即可。然后,登录返回一个短期令牌,该令牌在所有后续请求中用作共享密钥。当短期密钥即将到期时,可以通过调用续订端点来更新它。

整个登录逻辑与基于短期令牌的授权逻辑完全解耦。登录通常基于接收长期凭证并返回短期令牌的登录端点。登录凭证可以是作为登录方法输入的常规用户名-密码对,或者可以转换为由登录端点提供的短期令牌的其他类型的授权令牌。登录还可以通过基于 X.509 证书的各种身份验证协议实现。

最常见的短期令牌类型是所谓的载体令牌。每个载体令牌都编码了有关其持续时间的详细信息以及用于授权目的的声明列表,称为声明。载体令牌由登录操作或续订操作返回。它们的特征是它们与接收它们的客户端或任何其他特定客户端无关。

无论客户端如何获得载体令牌,这都是客户端需要授予其声明的所有隐含权利的全部。只需将载体令牌转移到另一个客户端,就可以赋予该客户端所有由载体令牌声明隐含的权利,因为基于载体令牌的授权不需要身份证明。

因此,一旦客户端获得一个载体令牌,它可以通过将其载体令牌转移给第三方来委托一些操作。通常,当必须使用载体令牌进行委托时,在登录阶段,客户端会指定要包含的声明以限制令牌可以授权的操作。

与 API 密钥身份验证相比,基于载体令牌的身份验证受到标准的约束。特别是,它们必须使用以下Authorization头部:

Authorization: Bearer <bearer token string>

载体令牌可以以多种方式实现。REST 服务通常使用 JWT 令牌,这些令牌是用 JSON 对象的 Base64URL 编码连接起来的。更具体地说,JWT 的创建从 JSON 头部开始,以及一个 JSON 有效负载。JSON 头部指定了令牌的类型及其签名方式,而有效负载由一个包含所有声明作为属性/值对的 JSON 对象组成。以下是一个示例头部:

{
  "alg": "RS256",
  "typ": "JWT"
}

以下是一个示例有效负载:

{
  "iss": "issuerbomain.com"
  "sub": "example",
  "aud": ["S1", "S2"],
  "roles": [
    "ADMIN",
    "USER"
  ],
  "exp": 1512975450,
  "iat": 1512968250230
}

然后,将头部和有效负载进行 BASE64URL 编码,并将相应的字符串连接,如下所示:

<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 发行的主体(即用户)。

  • iatexpnbf:这些分别表示 JWT 发行的时间、过期时间和,如果设置,令牌有效的起始时间。所有时间都以自 1970 年 1 月 1 日午夜 UTC 以来的秒数表示。在这里,所有天都被认为是包含 86,400 秒。

其他声明可能如果用唯一的 URI 表示,则可以定义为公共的;否则,它们被认为是发行者及其已知服务的私有。

.NET Core 是如何处理 SOA 的?

.Net Core 通过 ASP.NET Core 对 REST 服务有出色的支持。在 SOAP 服务方面,经典.NET 使用 WCF 技术处理。在 WCF 中,服务规范通过.NET 接口定义,实际服务代码由实现这些接口的类提供。

端点、底层协议(HTTP 和 TCP/IP)以及任何其他功能都在配置文件中定义。反过来,配置文件可以使用易于使用的配置工具进行编辑。因此,开发者只需提供标准.NET 类中的服务行为,并以声明性方式配置所有服务功能。这样,服务配置与实际服务行为完全解耦,每个服务都可以重新配置,以便能够适应不同的环境,而无需修改其代码。

WCF 技术尚未移植到.NET Core,也没有计划进行完整的移植。相反,微软正在投资于 gRPC,这是谷歌的开源技术。

放弃在.NET Core 中使用 WCF 的主要原因是如下:

  • 正如我们已经讨论过的,在大多数应用领域,SOAP 技术已经被 REST 技术所取代。

  • WCF 技术与 Windows 紧密绑定,因此在.NET Core 中从头开始重新实现所有功能将非常昂贵。由于对经典.NET 的支持将继续,需要 WCF 的用户仍然可以依赖经典.NET。

  • 作为一种一般策略,使用 .NET Core,微软更倾向于投资可以与其他竞争对手共享的开源技术。这就是为什么,而不是投资于 WCF,微软从 .NET Core 3.0 开始提供了 gRPC 实现。

虽然 .NET Core 不支持 SOAP 技术,但它支持 SOAP 客户端。更具体地说,从 2017 版本开始,在 Visual Studio 中创建现有 SOAP 服务的 SOAP 服务代理相当容易(请参阅 第九章,设计模式和 .NET Core 实现,以讨论代理是什么以及代理模式)。在服务的情况下,代理是一个实现服务接口的类,其方法通过调用远程服务的类似方法来完成其工作。

要创建一个服务代理,请在 Visual Studio 中右键单击 已连接的服务 节点,转到解决方案资源管理器,然后选择添加连接服务。然后,在出现的表单中,选择 Microsoft WCF 服务引用提供程序。在这里,您可以指定服务的 URL(其中包含 WSDL 服务描述),您希望添加代理类的命名空间,以及更多内容。在向导的末尾,Visual Studio 自动添加所有必要的 NuGet 包并生成代理类。这足以创建此类的一个实例并调用其方法,以便我们可以与远程 SOAP 服务进行交互。

此外,还有一些第三方,例如 NuGet 包,为 SOAP 服务提供有限的支持,但截至目前,它们并不非常有用,因为这种有限的支持不包括 REST 服务中不可用的功能。

从 .NET Core SDK 开始,Visual Studio 2019 支持 gRPC 项目模板,该模板生成 gRPC 服务器和 gRPC 客户端。在撰写本文时,gRPC 不是一个标准,而只是一个 Google 开源项目。然而,如果微软和谷歌继续投资其中,它可能会成为事实上的标准。gRPC 实现了一个远程过程调用模式,它提供了同步和异步调用。

它的配置方式类似于 WCF 和 .NET 远程处理,正如我们在 第五章 的结尾所描述的,将微服务架构应用于您的企业应用程序。也就是说,服务通过接口定义,其代码在实现这些接口的类中提供,而客户端通过实现相同服务接口的代理与这些服务进行交互。

gRPC 是微服务集群内部通信的良好选择,特别是如果集群不是完全基于 Service Fabric 技术且不能依赖 .NET 远程通信。由于所有主要语言和开发框架都有 gRPC 库,因此它可以在基于 Kubernetes 的集群中使用,也可以在托管其他框架实现的 Docker 镜像的 Service Fabric 集群中使用。

由于 gRPC 对数据的紧凑表示以及使用起来更简单,因此它比 REST 服务协议更高效,因为与协议相关的一切都由开发框架处理。然而,在撰写本文时,它的所有功能都不依赖于已建立的标准,因此不能用于公开端点 – 它只能用于集群内部通信。因此,我们将不会详细介绍 gRPC,但本章的 进一步阅读 部分包含了对 gRPC 的一般介绍及其 .NET Core 实现的参考。

使用 gRPC 非常简单,因为 Visual Studio 的 gRPC 项目模板会自动搭建一切,使得 gRPC 服务及其客户端都能正常工作。开发者只需定义应用程序特定的 C# 服务接口及其实现类。

本节剩余部分致力于介绍 .NET Core 对 REST 服务的服务器端和客户端支持。

ASP.NET Core 简介短文

ASP.NET Core 应用程序是基于我们在第五章 将微服务架构应用于您的企业应用程序使用通用宿主 子节中描述的 Host 概念的 .NET Core 应用程序。每个 ASP.NET 应用程序的 program.cs 文件创建一个宿主,构建它,并使用以下代码运行它:

public class Program
{
    public static void Main(string[] args)
    {
        CreateHostBuilder(args).Build().Run();
    }

    public static IHostBuilder CreateHostBuilder(string[] args) =>
        Host.CreateDefaultBuilder(args)
            .ConfigureWebHostDefaults(webBuilder =>
            {
                webBuilder.UseStartup<Startup>();
            });
}

CreatesDefaultBuilder 设置了一个标准的宿主,而 ConfigureWebHostDefaults 则配置它以处理 HTTP 管道。更具体地说,它执行以下操作:

  • 它设置了 IHostingEnvironment 接口的 ContentRootPath 属性为当前目录。

  • 它从 appsettings.jsonappsettings.[EnvironmentName].json 加载配置信息。一旦加载,JSON 对象属性中包含的配置信息可以使用 ASP.NET Core 选项框架映射到 .NET 对象属性。更具体地说,appsettings.jsonappsettings.[EnvironmentName].json 被合并,appsettings.[EnvironmentName] 文件的环境特定信息覆盖了相应的 appsettings.json 设置。EnvironmentNameASPNETCORE_ENVIRONMENT 环境变量中获取。反过来,当应用程序在 Visual Studio 中运行时,ASPNETCORE_ENVIRONMENTProperties\launchSettings.json 文件中定义。以下截图显示了在 Visual Studio 解决方案资源管理器中可以找到 launchSettings.json 的位置:

截图

launchSettings.json 文件中,您可以定义多个环境,这些环境可以通过 Visual Studio 运行按钮旁边的下拉菜单进行选择 图片。默认情况下,IIS Express 设置将 ASPNETCORE_ENVIRONMENT 设置为 Development。以下是一个典型的 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"
      }
    },
    ...
    ...
    }
  }
}

当应用程序发布时,用于 ASPNETCORE_ENVIRONMENT 的值可以在 Visual Studio 创建后添加到已创建的发布 XML 文件中。此值为 <EnvironmentName>Staging</EnvironmentName>。它也可以在您的 Visual Studio ASP.NET Core 项目文件(.csproj)中指定:

<PropertyGroup> <EnvironmentName>Staging</EnvironmentName></PropertyGroup>.

  • 它配置了主机日志记录,以便它可以写入控制台和调试输出。此设置可以通过进一步的配置进行更改。

  • 它设置/连接一个网络服务器到 ASP.NET Core 管道。

当应用程序在 Linux 上运行时,ASP.NET Core 管道连接到 .NET Core Kestrel 网络服务器。由于 Kestrel 是一个最小化的网络服务器,您需要负责从具有 Kestrel 所不具备的功能的完整网络服务器(如 Apache 或 Nginx)反向代理请求到它。当应用程序在 Windows 上运行时,默认情况下,ConfigureWebHostDefaults 将 ASP.NET Core 管道直接连接到 互联网信息服务IIS)。但是,您也可以在 Windows 上使用 Kestrel,并且可以通过更改 Visual Studio 项目文件的 AspNetCoreHostingModel 设置来反向代理 IIS 请求到 Kestrel,如下所示:

<PropertyGroup>
    ...
    <AspNetCoreHostingModel>OutOfProcess</AspNetCoreHostingModel>
</PropertyGroup>

UseStartup<Startup>() 允许从项目的 Startup.cs 类的方法中获取 Host 服务(参见第五章 [49aed8bb-9a4a-4241-9efc-f53c3f53dd5a.xhtml] 中的 使用通用主机 子节,将微服务架构应用于您的企业应用程序)和 ASP.NET Core 管道的定义。更具体地说,服务在它的 ConfigureServices(IServiceCollection services) 方法中定义,而 ASP.NET Core 管道在 Configure 方法中定义。以下代码显示了使用 API REST 项目生成的标准 Configure 方法框架:

public void Configure(IApplicationBuilder app, 
    IWebHostEnvironment env)
{
    if (env.IsDevelopment())
    {
        app.UseDeveloperExceptionPage();
    }

    app.UseHttpsRedirection();

    app.UseRouting();

    app.UseAuthorization();

    app.UseEndpoints(endpoints =>
    {
        endpoints.MapControllers();
    });
}

管道中的每个模块都由一个 app.Use<something> 方法定义,该方法通常接受一些选项。每个模块处理请求,然后将修改后的请求转发到管道中的下一个模块,或者返回一个 HTTP 响应。当返回 HTTP 响应时,它将按相反的顺序由所有之前的模块处理。

模块按照 app.Use<something> 方法调用中定义的顺序插入到管道中。前面的代码在 ASPNETCORE_ENVIRONMENTDevelopment 时添加错误页面;否则,UseHsts 与客户端协商一个安全协议。最后,UseEndpoints 添加创建实际 HTTP 响应的 MVC 控制器。关于 ASP.NET Core 管道的完整描述将在第十三章 理解 Web 应用程序的表示层 节中给出,展示 ASP.NET Core MVC

在下一小节中,我们将解释 MVC 框架如何让您实现 REST 服务。

使用 ASP.NET Core 实现 REST 服务

在 MVC 框架中,HTTP 请求由称为控制器的类处理。每个请求都映射到控制器公共方法的调用。选定的控制器和控制器方法取决于请求路径的形状,并且由路由规则定义,对于 REST API,通常通过与控制器类及其方法关联的属性提供。

处理 HTTP 请求的控制器方法被称为操作方法。当选择控制器和操作方法时,MVC 框架会创建一个控制器实例来处理请求。控制器构造函数的所有参数都通过依赖注入与在 Startup.cs 类的 ConfigureServices 方法中定义的类型进行解析。

请参阅第五章 将微服务架构应用于您的企业应用程序使用通用宿主 小节 [49aed8bb-9a4a-4241-9efc-f53c3f53dd5a.xhtml],了解如何使用 .NET Core 宿主进行依赖注入,以及第十章 理解软件解决方案的不同领域依赖注入模式 小节 [2a42483c-2193-4bd4-91b4-0fdce94f6ed1.xhtml],了解依赖注入的一般讨论。

以下是一个典型的 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> 属性:HttpPostHttpPatch

我们也可能定义另一个类似的方法:

[HttpGet]
public ... Get()

此方法在 api/values 类型的 GET 请求上调用,即在没有 id 的控制器名称之后的 GET 请求上。

几个操作方法可以具有相同的名称,但每个请求路径只能有一个与之兼容;否则,将抛出异常。换句话说,路由规则和Http<verb>属性必须唯一地定义每个请求应选择哪个控制器及其哪个操作方法。

默认情况下,参数根据以下规则传递到 API 控制器的操作方法中:

  • 简单类型(整数浮点数DateTimes)如果路由规则指定它们为参数,则从请求路径中获取,例如上一个示例中的[HttpGet("{id}")]属性。如果它们在路由规则中未找到,MVC 框架将查找具有相同名称的查询字符串参数。因此,例如,如果我们用[HttpGet]替换[HttpGet("{id}")],MVC 框架将查找类似api/values?id=<一个整数>的内容。

  • 复杂类型是通过格式化程序从请求体中提取的。根据请求的Content-Type头部的值选择正确的格式化程序。如果没有指定Content-Type头部,则使用 JSON 格式化程序。JSON 格式化程序尝试将请求体解析为 JSON 对象,然后尝试将此 JSON 对象转换为.NET Core 复杂类型的实例。如果 JSON 提取或后续转换失败,将抛出异常。默认情况下,仅支持 JSON 输入格式化程序,但您也可以添加一个 XML 格式化程序,当Content-Type指定 XML 内容时可以使用。只需添加Microsoft.AspNetCore.Mvc.Formatters.Xml NuGet 包,并在Startup.csConfigureServices方法中将services.AddMvc()替换为services.AddMvc().AddXmlSerializerFormatters()即可。

您可以通过在参数前添加适当的属性来自定义用于填充操作方法参数的源。以下代码显示了此示例的一些示例:

...MyAcrionMethod(....[FromHeader] string myHeader....) 
// x is taken from a request header named myHeader

...MyAcrionMethod(....[FromServices] MyType x....) 
// x is filled with an istance of MyType through dependency injection

Action方法的返回类型必须是IAsyncResult接口或实现该接口的类型。反过来,IAsyncResult只有一个以下方法:

public Task ExecuteResultAsync (ActionContext context)

此方法由 MVC 框架在正确的时间调用以创建实际响应和响应头。当将ActionContext对象传递到方法中时,它包含整个 HTTP 请求的上下文,包括包含有关原始 HTTP 请求(头、正文和 Cookie)的所有必要信息的请求对象,以及收集正在构建的响应的所有部分的响应对象。

您无需手动创建IAsyncResult的实现,因为ControllerBase已经具有创建IAsyncResult实现的方法,以便生成所有必要的 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>以获得更好的类型检查。

默认情况下,结果对象在响应体中以 JSON 格式序列化。然而,如果已向 MVC 框架处理管道中添加了 XML 格式化程序,如前所述,结果的序列化方式取决于 HTTP 请求的Accept头。更具体地说,如果客户端明确要求使用Accept头以 XML 格式,对象将以 XML 格式序列化;否则,将以 JSON 格式序列化。

作为操作方法的输入传递的复杂对象可以使用验证属性进行验证,如下所示:

public class MyType
{
    [Required]
    public string Name{get; set;}
    ...
    [MaxLength(64)]
    public string Description{get; set;}
}

如果控制器已用[ApiController]属性装饰,并且验证失败,MVC 框架将自动创建一个包含所有检测到的验证错误的 BadRequest 响应,而不执行操作方法。因此,您不需要添加额外的代码来处理验证错误。

操作方法也可以声明为异步方法,如下所示:

public async Task<IActionResult> MyMethod(......)
{
    await MyBusinessObject.MyBusinessMethod();
    ...
}

public async Task<ActionResult<MyType>> MyMethod(......)
{
    ...

本章的使用案例部分将展示控制器/操作方法的实际示例。在下一小节中,我们将解释如何使用 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")]

对于更复杂的声明条件,需要在 Startup.csConfigureServices 方法中定义授权策略,如下所示代码所示:

public void ConfigureServices(IServiceCollection services)
{
    services.AddMvc();
    ...
    services.AddAuthorization(options =>
    {
        options.AddPolicy("Father", policy => 
            policy.RequireAssertion(context =>
                context.User
                    .HasClaim(c =>c.Type == "Married") &&
                context.User
                    .HasClaim(c => c.Type == "HasSon")));

    });
}

之后,你可以使用 [Authorize(Policy = "Father")] 装饰动作方法或控制器。

在使用基于 JWT 的授权之前,必须在 Startup.cs 中进行配置。首先,必须将处理身份验证令牌的中间件添加到在 Configure 方法中定义的 ASP.NET Core 处理管道中,如下所示:

public void Configure(IApplicationBuilder app, IHostingEnvironment env)
{
    ...
    app.UseAuthentication();//authentication middleware
    app.UseMvc();

}

然后,必须在 ConfigureServices 部分配置身份验证服务。在这里,你定义将通过依赖注入注入到身份验证中间件的身份验证选项:

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 验证只需要知道与实际私有签名密钥关联的公钥。Identity Server 4 可以快速创建一个作为身份验证服务器工作的网站。它使用常规的用户名/密码凭据发出 JWT 令牌或转换其他身份验证令牌。如果你使用身份验证服务器,如 Identity Server 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 包,并在 Startup.cs 文件中放置一些代码。更具体地说,我们必须在 Configure 方法中添加一些中间件,如下所示:

app.UseSwagger(); //open api middleware
app.UseAuthentication();
app.UseMvc();

然后,我们必须在 ConfigureServices 方法中添加一些配置选项,如下所示:

services.AddSwaggerGen(c =>
{        
    c.SwaggerDoc("MyServiceName", new Info
    {
        Version = "v1",
        Title = "ToDo API",
        Description = "My service description",
        TermsOfService = "My terms of service",
        Contact = new Contact
        {
            Name = "My Contact Name",
            Email = string.Empty,
            Url = "https://MyContatcUrl.com"
        },
        License = new License
        {
            Name = "My License name",
            Url = "https://MyLicensecUrl.com"
        }
    });
});

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 文档端点,这些注释通常用于生成自动代码文档。以下代码展示了这一过程的几个示例。以下片段展示了如何添加方法描述和参数信息:

//adds a description to the REST method

/// <summary>
/// Deletes a specific TodoItem.
/// </summary>
/// <param name="id"></param> 
[HttpDelete("{id}")]
public IActionResult Delete(long id)

以下片段展示了如何添加使用示例:

//adds an example of usage

/// <summary>
/// Creates an item.
/// </summary>
/// <remarks>
/// Sample request:
///
/// POST /MyItem
/// {
/// "id": 1,
/// "name": "Item1"
/// }
///
/// </remarks>

以下片段展示了如何为每个 HTTP 状态码添加参数描述和返回类型描述:

//Add input parameters and return object descriptions

/// <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。

一旦你有一个工作的 JSON 文档端点,你可以使用以下方法之一自动生成代理类的 C# 或 TypeScript 代码:

  • 可在 github.com/RicoSuter/NSwag/wiki/NSwagStudio 找到的 NSwagStudio Windows 程序。

  • 如果你想要自定义代码生成,可以使用 NSwag.CodeGeneration.CSharpNSwag.CodeGeneration.TypeScript NuGet 包。

  • 如果你想要将代码生成与 Visual Studio 构建操作关联起来,可以使用 NSwag.MSBuild NuGet 包。有关此包的文档可以在 github.com/RicoSuter/NSwag/wiki/MSBuild 找到。

在下一个子节中,你将学习如何从一个 REST API 或从 .NET Core 客户端调用 REST API。

.Net Core HTTP 客户端

System.Net.Http 命名空间中的 HttpClient 类是一个 .NET 标准版 2.0 内置的 HTTP 客户端类。虽然它可以直接在任何需要与 REST 服务交互时使用,但反复创建和释放 HttpClient 实例存在一些问题,如下所示:

  • 它们的创建成本很高。

  • 例如,当 HttpClient 被释放时,在 using 语句中,底层连接不会立即关闭,而是在第一次垃圾回收会话时关闭,这是一个重复创建的过程。释放操作会迅速耗尽操作系统可以处理的连接最大数量。

因此,可以重用单个HttpClient实例,例如单例,或者以某种方式池化HttpClient实例。从.NET Core 2.1 版本开始,引入了HttpClientFactory类来池化 HTTP 客户端。更具体地说,每当需要为HttpClientFactory对象创建新的HttpClient实例时,都会创建一个新的HttpClient。然而,底层的HttpClientMessageHandler实例,创建成本较高,会池化直到其最大生命周期到期。

HttpClientMessageHandler实例必须具有有限的生命周期,因为它们缓存可能随时间变化的 DNS 解析信息。HttpClientMessageHandler的默认生命周期为 2 分钟,但可以被开发者重新定义。

使用HttpClientFactory允许我们自动将所有 HTTP 操作与其他操作一起管道化。例如,我们可以添加 Polly 重试策略来自动处理所有 HTTP 操作的失败。有关 Polly 的介绍,请参阅第五章的弹性任务执行小节,将微服务架构应用于您的企业应用

利用HttpClientFactory类提供的优势的最简单方法是添加Microsoft.Extensions.Http NuGet 包,然后按照以下步骤操作:

  1. 定义一个代理类,例如MyProxy,以与所需的 REST 服务交互。

  2. MyProxy在其构造函数中接受一个HttpClient实例。

  3. 使用构造函数中注入的HttpClient来实现所有必要的操作。

  4. 在您的宿主服务的配置方法中声明您的代理,对于 ASP.NET Core 应用程序,这是Startup.cs类中的ConfigureServices方法,而对于客户端应用程序,这是HostBuilder实例的ConfigureServices方法。在最简单的情况下,声明类似于services.AddHttpClient<MyProxy>()。这将自动将MyProxy添加到可用于依赖注入的服务中,因此您可以轻松地在控制器构造函数中注入它。此外,每次创建MyProxy实例时,HttpClientFactory都会返回一个HttpClient实例,并将其自动注入其构造函数中。

在需要与 REST 服务交互的类的构造函数中,我们可能也需要一个接口,而不是具有特定代理实现的声明:

services.AddHttpClient<IMyProxy, MyProxy>()

Polly 弹性策略(参见第五章的弹性任务执行小节,将微服务架构应用于您的企业应用)可以应用于我们代理类发出的所有 HTTP 调用,如下所示:

var myRetryPolicy = Policy.Handle<HttpRequestException>()
    ...//policy definition
    ...;
services.AddHttpClient<IMyProxy, MyProxy>()
    .AddPolicyHandler(myRetryPolicy );

最后,我们可以预先配置传递给我们的代理的所有HttpClient实例的一些属性,如下所示:

services.AddHttpClient<IMyProxy, MyProxy>(clientFactory =>
{
  clientFactory.DefaultRequestHeaders.Add("Accept", "application/json");
  clientFactory.BaseAddress = new Uri("https://www.myService.com/");
})
 .AddPolicyHandler(myRetryPolicy );

这样,每个传递给代理的客户端都预先配置好,它们需要 JSON 响应,并且必须与特定服务一起工作。一旦定义了基本地址,每个 HTTP 请求都需要指定要调用的服务方法的相对路径。

以下代码显示了如何向服务执行 POST 操作。在这里,我们声明注入到代理构造函数中的 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 实例的类型,这通常取决于状态码。执行解析的代码基于 Newtonsoft.Json NuGet 包的 JsonConvert 类,如下所示:

var result=JsonConvert.DeserializeObject<MyResultClass>(stringResult);

执行 GET 请求类似,但你需要调用 GetAsync 而不是 PostAsJsonAsync,如下所示:

var response = await webClient.GetAsync("my/getmethod/relative/path");

其他 HTTP 动词的使用完全类似。

用例 - 暴露 WWTravelClub 包

在本节中,我们将实现一个 ASP.NET REST 服务,该服务列出给定假期的开始和结束日期可用的所有包。为了教学目的,我们不会根据第十章[2a42483c-2193-4bd4-91b4-0fdce94f6ed1.xhtml]中描述的最佳实践来构建应用程序;相反,我们将简单地使用 LINQ 查询生成结果,该查询将直接放置在控制器操作方法中。一个结构良好的 ASP.NET Core 应用程序将在第十三章[003ee8cb-5995-4364-8772-73d73df29cf8.xhtml]中介绍,展示 ASP.NET Core MVC,该章节专门介绍 MVC 框架。

让我们复制 WWTravelClubDB 解决方案文件夹,并将新文件夹重命名为 WWTravelClubREST。WWTravelClubDB 项目是在第六章[8c8a9dbc-3bfc-4291-866f-fdd1a62c16ef.xhtml]的各个部分中逐步构建的,在 C# 中与数据交互 - Entity Framework Core。让我们打开新的解决方案,并向其中添加一个名为 WWTravelClubREST 的新 ASP.NET Core API 项目(与新的解决方案文件夹同名)。为了简单起见,选择不进行身份验证。右键单击新创建的项目,并选择设置为启动项目,以便在运行解决方案时将其作为默认项目启动。

最后,我们需要将 WWTravelClubDB 项目添加为引用。

ASP.NET Core 项目将配置常量存储在 appsettings.json 文件中。让我们打开这个文件,并将我们为在 WWTravelClubDB 项目中创建的数据库添加到其中,如下所示:

{
    "ConnectionStrings": {
        "DefaultConnection": "Server=
        (localdb)\\mssqllocaldb;Database=wwtravelclub;
        Trusted_Connection=True;MultipleActiveResultSets=true"
    },
    ...
    ...
}

现在,我们必须将 WWTravelClubDB 实体框架数据库上下文添加到 Startup.cs 中的 ConfigureServices 方法中,如下所示:

services.AddDbContext<WWTravelClubDB.MainDBContext>(options =>
     options.UseSqlServer(
            Configuration.GetConnectionString("DefaultConnection"),
            b => b.MigrationsAssembly("WWTravelClubDB")));

将传递给 AddDbContext 的选项对象设置指定了使用 SQL Server,其连接字符串通过 Configuration.GetConnectionString("DefaultConnection") 方法从 appsettings.json 配置文件的 ConnectionStrings 部分提取。b => b.MigrationsAssembly("WWTravelClubDB") 的 lambda 函数声明了包含数据库迁移的程序的名称(参见 第六章,在 C# 中与数据交互 - Entity Framework Core),在我们的例子中,这是由 WWTravelClubDB 项目生成的 DLL。为了使前面的代码能够编译,你应该添加 using Microsoft.EntityFrameworkCore;

由于我们想要用 OpenAPI 文档丰富我们的 REST 服务,因此让我们添加对 Swashbuckle.AspNetCore NuGet 包的引用。对于 .NET 3.0,你必须选择至少版本 5.0 RC-4,所以,如果你在搜索结果中看不到 5.0 版本,请启用 Include prerelease 复选框。现在,我们可以在 ConfigureServices 方法中添加以下非常基本的配置:

services.AddSwaggerGen(c =>
{
    c.SwaggerDoc("WWWTravelClub", new OpenAPIInfo
    {
        Version = "WWWTravelClub 1.0.0",
        Title = "WWWTravelClub",
        Description = "WWWTravelClub Api",
        TermsOfService = null
    });
});

然后,我们可以添加 OpenAPI 端点的中间件以及为我们的 API 文档添加用户界面的功能,如下所示:

app.UseSwagger();
app.UseSwaggerUI(c =>
{
 c.SwaggerEndpoint(
 "/swagger/WWWTravelClub/swagger.json", 
 "WWWTravelClub Api");
});

app.UseEndpoints(endpoints => //preexisting code//
{
     endpoints.MapControllers();
});

现在,我们准备编码我们的服务。让我们删除由 Visual Studio 自动生成的 ValueController。然后,右键单击 Controller 文件夹并选择 Add | Controller。现在,选择一个名为 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} 类型的路径上被调用,其中 startstop 是作为输入传递给 GetPackagesByDateDateTime 参数。ProduceResponseType 属性声明以下内容:

  • 当请求成功时,会返回一个 200 状态码,并且体包含一个包含所需包信息的 PackagesListDTO 类型的 IEnumerable(我们很快将定义它)。

  • 当请求格式不正确时,会返回一个 400 状态码。我们未指定返回的类型,因为不良请求会自动通过 ApiController 属性由 MVC 框架处理。

  • 在出现意外错误的情况下,会返回一个带有空体的 500 状态码。

现在,让我们在新的 DTOs 文件夹中定义 PackagesListDTO 类:

namespace WWTravelClubREST.DTOs
{
    public class PackagesListDTO
    {
        public int Id { get; set; }
        public string Name { get; set; }
        public decimal Price { get; set; }
        public int DuratioInDays { 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 WWTravelClubREST.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,
            DuratioInDays = m.DuratioInDays,
            Id = m.Id,
            Price = m.Price,
            DestinationName = m.MyDestination.Name,
            DestinationId = m.DestinationId
        })
        .ToListAsync();
    return Ok(res);
}
catch
{
    return StatusCode(500);
}

LINQ 查询类似于我们在第六章 [8c8a9dbc-3bfc-4291-866f-fdd1a62c16ef.xhtml] “使用 C# 与数据交互 - Entity Framework Core” 中测试的 WWTravelClubDBTest 项目中的查询。一旦计算结果,它将通过 OK 调用返回。方法代码通过捕获异常并返回 500 状态码来处理内部服务器错误,因为 Bad Requests 在由 ApiController 属性调用控制器方法之前自动处理。

让我们运行解决方案。当浏览器打开时,它无法从我们的 ASP.NET Core 网站接收任何结果。让我们修改浏览器 URL,使其为 https://localhost:<previous port>/swagger。OpenAPI 文档的用户界面将如下所示:

图片

PackagesListDTO 是我们定义的用于列出包的模型,而 ProblemDetails 是用于报告错误请求情况下的模型。通过点击 GET 按钮,我们可以获取更多关于我们的 GET 方法的详细信息,我们还可以对其进行测试,如下面的截图所示:

图片

当插入数据库中受包覆盖的日期时请注意;否则,将返回空列表。前面截图中的那些应该可以工作。

日期必须以正确的 JSON 格式输入;否则,将返回 400 错误请求错误,如下面的代码所示:

{
  "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!

摘要

在本章中,我们介绍了 SOA、其设计原则和其约束。其中,值得记住的是互操作性。

然后,我们关注了商业应用程序中实现公开暴露服务所需互操作性的既定标准。因此,详细讨论了 SOAP 和 REST 服务,以及在过去几年中大多数应用程序领域中发生的从 SOAP 服务到 REST 服务的转变。然后,详细描述了 REST 服务原则、认证/授权及其文档。

最后,我们探讨了 .NET Core 中可用的工具,我们可以使用这些工具来实现和交互服务。我们探讨了各种集群内通信框架,如 .NET 远程和 gRPC,以及 SOAP 和基于 REST 的公共服务的工具。

在这里,我们主要关注 REST 服务。它们的 ASP.NET Core 实现被详细描述,包括我们可以用来认证/授权它们的技巧以及它们的文档。我们还关注了如何实现高效的 .NET Core 代理,以便我们可以与 REST 服务交互。

在下一章中,我们将学习如何在构建 ASP .NET Core MVC 应用程序的同时使用 .NET Core 3.0。

问题

  1. 服务可以使用基于 cookie 的会话吗?

  2. 实现服务时使用自定义通信协议是好的做法吗?为什么或为什么不?

  3. 向 REST 服务发送POST请求会导致删除吗?

  4. JWT 载体令牌中包含多少个点分隔的部分?

  5. 默认情况下,REST 服务操作方法的复杂类型参数是从哪里获取的?

  6. 如何声明控制器为 REST 服务?

  7. ASP.NET Core 服务的哪些主要文档属性?

  8. ASP.NET Core REST 服务路由规则是如何声明的?

  9. 应如何声明代理,以便我们可以利用.NET Core 的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/?view=aspnetcore-3.0.

更多关于 ASP.NET Core 服务的详细信息可在官方文档中找到:docs.microsoft.com/en-US/aspnet/core/web-api/?view=aspnetcore-3.0. 更多关于.NET Core HTTP 客户端的信息可在此处找到:docs.microsoft.com/en-US/aspnet/core/fundamentals/http-requests?view=aspnetcore-3.0.

关于 JWT 令牌认证的更多信息可在此处找到:jwt.io/. 如果您想使用 Identity Serve 4 生成 JWT 令牌,您可以参考其官方文档页面:docs.identityserver.io/en/latest/.

更多关于 OpenAPI 的信息可在swagger.io/docs/specification/about/找到,而关于 Swashbuckle 的更多信息可在其 GitHub 存储库页面上找到:github.com/domaindrivendev/Swashbuckle.

第十三章:展示 ASP.NET Core MVC

在本章中,你将学习如何实现应用程序的表现层。更具体地说,你将学习如何基于 ASP.NET Core MVC 实现一个网络应用程序。

ASP.NET Core 是一个用于实现网络应用程序的 .NET 框架。ASP.NET Core 在前面的章节中已有部分描述,因此本章主要关注 ASP.NET Core MVC。更具体地说,本章的贡献如下:

  • 理解网络应用程序的表现层

  • 理解 ASP.NET Core MVC 结构

  • .NET Core 3.0 为 ASP.NET Core 带来了哪些新特性?

  • 理解 ASP.NET Core MVC 与设计原则之间的联系

  • 用例 - 在 ASP.NET Core MVC 中实现网络应用程序

我们将回顾并进一步详细说明 ASP.NET Core 框架的结构,这部分内容已在第十二章,使用 .NET Core 应用服务导向架构和第四章,决定最佳基于云的解决方案中讨论过。在这里,主要关注的是如何基于所谓的 模型-视图-控制器MVC)架构模式实现基于网络的表示层。

我们还将分析在最新的 ASP.NET Core 3.0 版本中可用的所有新功能,以及包含在 ASP.NET Core MVC 框架中或用于典型 ASP.NET Core MVC 项目的架构模式。其中一些模式已在第九章,设计模式和 .NET Core 实现和第十章,理解软件解决方案中的不同领域中讨论过,而其他一些模式,如 MVC 模式本身,则是新的。

你将学习如何实现一个 ASP.NET Core MVC 应用程序,以及如何使用本章末尾的实用示例来组织整个 Visual Studio 解决方案。这个示例描述了一个完整的 ASP.NET Core MVC 应用程序,用于编辑 WWTravelClub 书籍用例的包。

技术要求

本章需要 Visual Studio 2017 或 2019 免费社区版或更高版本,并安装所有数据库工具。

所有概念都通过基于 WWTravelClub 书籍用例的实用示例进行了阐明。本章的代码可在github.com/PacktPublishing/Hands-On-Software-Architecture-with-CSharp-8找到。

理解网络应用程序的表现层

本章讨论了基于 ASP.NET Core 框架实现网络应用程序表现层的架构。网络应用程序的表现层基于三种技术:

  • 通过 REST 或 SOAP 服务与服务器交换数据的移动或桌面原生应用程序:我们尚未讨论它们,因为它们严格绑定到客户端设备和其操作系统,因此,分析它们(这需要专门的书籍)完全超出了本书的范围。

  • 单页应用程序SPA):这些是基于 HTML 的应用程序,其动态 HTML 在客户端由 JavaScript 或借助 WebAssembly(一种跨浏览器的汇编,可以用作 JavaScript 的高性能替代品)创建。与原生应用程序类似,SPAs 通过 REST 或 SOAP 服务与服务器交换数据,但它们的优势在于独立于设备和其操作系统,因为它们在浏览器中运行。SPA 框架是复杂的话题,需要专门的书籍来讨论,因此本书中无法对其进行描述。一些相关的链接列在进一步阅读部分。

  • 由服务器创建的 HTML 页面,其内容取决于要展示给用户的数据:本章将要讨论的 ASP.NET Core MVC 框架是一个用于创建此类动态 HTML 页面的框架。

本章的其余部分将重点介绍如何在服务器端创建 HTML 页面,以及更具体地,介绍下一节中将要介绍的 ASP.NET Core MVC。

理解 ASP.NET Core MVC 结构

ASP.NET Core 基于在第五章的使用通用主机子节中解释的通用主机概念,将微服务架构应用于企业应用程序。ASP.NET Core 的基本架构在第十二章的ASP.NET Core 简介子节中进行了概述,使用.NET Core 应用服务导向架构

值得提醒读者的是,通过调用IWebHostBuilder接口的.UseStartup<Startup>()方法,主机配置被委托给在Startup.cs文件中定义的Startup类。Startup类的ConfigureServices(IServiceCollection services)方法定义了所有可以通过依赖注入(DI)注入对象构造函数的服务。依赖注入在第五章的使用通用主机子节中进行了详细描述,将微服务架构应用于企业应用程序

相反,Configure(IApplicationBuilder app, IHostingEnvironment env)启动方法定义了所谓的 ASP.NET Core 管道,这在第十二章的使用.NET Core 应用服务导向架构子节中的ASP.NET Core 简介子节中简要描述过,将在下一子节中详细介绍。

ASP.NET Core 管道的工作原理

ASP.NET Core 提供了一组可配置的模块,您可以根据需要组装这些模块。每个模块负责您可能需要或不需要的功能。功能示例包括授权、身份验证、静态文件处理、协议协商、CORS 处理等。

您可以通过将它们插入一个称为 ASP.NET Core 管道 的通用处理框架来组合您需要的所有模块。

更具体地说,ASP.NET Core 请求通过将上下文对象推入 ASP.NET Core 模块的管道进行处理,如下面的图所示:

插入管道的对象是一个包含传入请求全部数据的 HttpContext 实例。更具体地说,HttpContextRequest 属性包含一个 HttpRequest 对象,其属性以结构化的方式表示传入请求。有用于头部、cookies、请求路径、参数、表单字段和请求体的属性。

不同的模块可以通过向 HttpContext 实例的 Response 属性中包含的 HttpResponse 对象写入来共同贡献最终响应的构建。HttpResponse 类与 HttpRequest 类类似,但其属性指的是正在构建的响应。

一些模块可以构建一个中间数据结构,然后由管道中的其他模块使用。通常,这种中间数据可以存储在 HttpContext 对象的 Items 属性中包含的 IDictionary<object, object> 的自定义条目中。然而,有一个预定义的属性 User,它包含有关当前登录用户的信息。登录用户不是自动计算的,而必须由身份验证模块计算。第十二章 的 Applying Service-Oriented Architectures with .NET Core 部分的 ASP.NET Core 服务授权 子部分解释了如何将基于 JWT 令牌的身份验证的标准模块添加到 ASP.NET Core 管道中。

HttpContext 还有一个 Connection 属性,它包含与客户端建立的基础连接的信息,以及一个 WebSockets 属性,它包含与客户端建立的可能基于 WebSocket 的连接的信息。

HttpContext 还有一个 Features 属性,它包含 IDictionary<Type, object>,该属性指定了托管 Web 应用程序的 Web 服务器以及管道模块所支持的功能。功能可以通过 .Set<Type>(Type o) 方法设置,并通过 .Get<Type>() 方法检索。

当所有其他功能由管道模块在处理 HttpContext 时添加时,框架会自动添加 Web 服务器功能。功能不是针对传入请求的特定功能,而仅取决于应用程序托管环境和添加到 ASP.NET Core 管道的模块。

HttpContext 通过其 RequestServices 属性也提供了对依赖注入引擎的访问。你可以通过调用 .RequestService.GetService(Type t) 方法来获取由依赖引擎管理的类型的一个实例。

为处理网络请求而创建的 HttpContext 实例不仅对模块可用,而且通过依赖注入对应用程序代码也可用。只需在自动依赖注入的类(如控制器)的构造函数中插入一个 IHttpContextAccessor 参数,然后访问其 HttpContext 属性即可。

模块是任何具有以下结构的类:

public class CoreMiddleware
{
    private readonly RequestDelegate _next;
    public CoreMiddleware(RequestDelegate next, ILoggerFactory 
    loggerFactory)
    {
        ...
        _next = next;
        ...
    }

    public async Task Invoke(HttpContext context)
    {
        /*
            Insert here the module specific code that processes the 
            HttpContext instance

        */

        await _next.Invoke(context);
        /*
            Insert here other module specific code that processes the 
            HttpContext instance
        */
    }
}

通常,每个模块处理由管道中前一个模块传递的 HttpContext 实例,然后调用 await _next.Invoke(context) 来调用管道中剩余的模块。当所有其他模块完成处理并且客户端的响应已经准备就绪时,每个模块都可以在 _next.Invoke(context) 调用之后的代码中执行进一步的响应后处理。

通过在 Startup.cs 文件的 Configure 方法中调用 UseMiddleware<T> 方法,将模块注册到 ASP.NET Core 管道中,如下所示:

public void Configure(IApplicationBuilder app, IHostingEnvironment env, 
IServiceProvider serviceProvider)
{
    ...
    app.UseMiddleware<MyCustomModule>
    ...
}

当调用 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 类型添加到应用程序依赖注入引擎中。在这些情况下,我们还会定义一个名为 AddMyFunctionalityIServiceCollection 扩展方法,该方法必须在 Startup.cs 文件的 ConfigureServices(IServiceCollection services) 方法中调用。例如,ASP.NET Core MVC 需要调用如下所示的方法:

services.AddControllersWithViews(o =>
{
    //set here MVC options by modifying the o option parameter
} 

如果你不需要更改默认的 MVC 选项,你可以简单地调用 services.AddControllersWithViews()

下一个子节描述了 ASP.NET Core 框架的另一个重要功能,即如何处理应用程序配置数据。

加载配置数据并使用选项框架

当 ASP.NET Core 应用程序启动时,它会从 appsettings.jsonappsettings.[EnvironmentName].json 文件中读取配置信息(如数据库连接字符串),其中 EnvironmentName 是一个字符串值,取决于应用程序部署的位置。EnvironmentName 的典型值如下:

  • Production 用于生产部署。

  • Development 用于开发。

  • 当应用程序在预发布环境中进行测试时,使用 Staging

appsettings.jsonappsettings.[EnvironmentName].json 文件中提取的两个 JSON 树合并成一个唯一的树,其中 [EnvironmentName].json 中包含的值会覆盖 appsettings.json 中相应路径的值。这样,应用程序可以在不同的部署环境中运行不同的配置。特别是,你可以使用不同的数据库连接字符串,因此,每个不同的环境都可以有不同的数据库实例。

[EnvironmentName] 字符串是从操作系统的 ASPNETCORE_ENVIRONMENT 环境变量中获取的。反过来,ASPNETCORE_ENVIRONMENT 可以通过两种方式在 Visual Studio 中自动设置应用程序的部署:

  • 在 Visual Studio 部署期间,Visual Studio 发布向导创建一个 XML 发布配置文件。如果发布向导允许你从下拉列表中选择 ASPNETCORE_ENVIRONMENT,则已完成:

否则,你可以按照以下步骤操作:

    1. 一旦在向导中填写了信息,请保存发布配置文件而不进行发布。
  1. 然后,使用文本编辑器编辑配置文件,并添加一个 XML 属性,例如,<EnvironmentName>Staging</EnvironmentName>。由于在应用程序发布期间可以选择所有已定义的发布配置文件,因此你可以为每个环境定义不同的发布配置文件,然后在每次发布时选择所需的配置文件。

  • 在部署期间设置 ASPNETCORE_ENVIRONMENT 的值也可以通过在应用程序的 Visual Studio ASP.NET Core 项目文件(.csproj)中添加以下代码来指定:
<PropertyGroup> 
    <EnvironmentName>Staging</EnvironmentName>
</PropertyGroup>

在 Visual Studio 中进行开发时,当应用程序运行时,ASPNETCORE_ENVIRONMENT 的值可以在 ASP.NET Core 项目的 Properties\launchSettings.json 文件中指定。launchSettings.json 文件包含几个命名的设置组。这些设置配置了从 Visual Studio 运行时如何启动 Web 应用程序。你可以通过选择位于 Visual Studio 运行按钮旁边的下拉列表中的组名来选择应用一个组的所有设置:

在此下拉列表中的选择将显示在运行按钮中,默认选择为 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 属性下。在那里,你可以选择应用程序托管的位置(IISExpress)、启动浏览器以及一些环境变量的值。

在 ASP.NET Core 管道定义期间,可以通过 IHostingEnvironment 接口测试从 ASPNETCORE_ENVIRONMENT 操作系统环境变量加载的当前环境,因为 IHostingEnvironment 实例被作为参数传递给 Startup.cs 文件的 Configure 方法。IHostingEnvironment 也通过 DI 可用于用户代码的其余部分。

IHostingEnvironment.IsEnvironment(string environmentName) 检查当前 ASPNETCORE_ENVIRONMENT 的值是否为 environmentName。同时,也有针对测试开发(.IsDevelopment())、生产(.IsProduction())和预发布(.IsStaging())的特定快捷方式。IHostingEnvironment 还包含 ASP.NET Core 应用程序的当前根目录(.WebRootPath)和由 Web 服务器直接提供的服务静态文件目录(.ContentRootPath)(CSS、JavaScript、图片等)。

launchSettings.json 和所有发布配置文件都可以在 Visual Studio 探索器中作为属性节点下的子节点访问,如下面的截图所示:

图片

一旦加载了 appsettings.jsonappsettings.[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; }
    }

执行映射的代码必须插入到 Startup.cs 文件中的 ConfigureServices 方法,因为 EmailConfig 实例将通过 DI 获取。所需的代码如下所示:

public Startup(IConfiguration configuration)
{
    Configuration = configuration;
}
....
public void ConfigureServices(IServiceCollection services)
{
    ...
    services.Configure<EmailConfig>(Configuration.GetSection("Email"));
    ..

在前面的设置之后,需要 EmailConfig 数据的类必须声明一个由 DI 引擎提供的 IOptions<EmailConfig> options 参数。EmailConfig 实例包含在 options.Value 中。

下一个子节描述了 ASP.NET Core MVC 应用程序所需的基本 ASP.NET Core 管道模块。

定义 ASP.NET Core MVC 管道

如果你使用 Visual Studio 创建一个新的 ASP.NET Core MVC 项目,Startup.cs 文件的 Configure 方法将创建一个标准管道。在那里,如果需要,你可以添加更多模块或更改现有模块的配置。

Configure 方法的初始代码处理错误并执行基本的 HTTPS 配置:

 if (env.IsDevelopment())
{
    app.UseDeveloperExceptionPage();
    app.UseDatabaseErrorPage();
}
else
{
    app.UseExceptionHandler("/Home/Error");
    app.UseHsts();
}
app.UseHttpsRedirection();

如果有错误,如果应用程序处于开发环境,UseDeveloperExceptionPage 安装的模块会在响应中添加详细的错误报告,而 UseDatabaseErrorPage 安装的模块会处理并添加任何实体框架数据库错误的详细信息到响应中。这些模块是宝贵的调试工具。

如果应用程序不在开发模式下发生错误,UseExceptionHandler 会从它接收的参数路径恢复请求处理,即从/Home/Error。换句话说,它模拟了一个带有/Home/Error路径的新请求。这个请求被推入标准的 MVC 处理流程,直到它达到与/Home/Error路径关联的端点,在那里开发者预计会放置处理错误的自定义代码。

当应用程序不在开发模式下时,UseHstsStrict-Transport-Security头添加到响应中,告知浏览器应用程序必须仅通过 HTTPS 访问。在此声明之后,符合规定的浏览器应自动将任何对应用程序的 HTTP 请求转换为 HTTPS 请求,时间为Strict-Transport-Security头中指定的时间。默认情况下,UseHsts将 30 天指定为头中的时间,但您可以通过在Startup.csConfigureServices方法中添加一个options对象来指定不同的时间和其他头参数:

services.AddHsts(options =>     {         
    ...
    options.MaxAge = TimeSpan.FromDays(60); 
    ...   
});

UseHttpsRedirection 在接收到 HTTP URL 时导致自动重定向到 HTTPS URL,以强制建立安全连接。一旦建立了第一个 HTTPS 安全连接,Strict-Transport-Security头会阻止可能用于执行中间人攻击的后续重定向。

以下代码显示了默认管道的其余部分:

app.UseStaticFiles();
app.UseCookiePolicy();

app.UseRouting();

app.UseAuthentication();
app.UseAuthorization();

...

UseStaticFiles 使得项目wwwroot文件夹中的所有文件(通常是 CSS、JavaScript、图片和字体文件)可以通过它们的实际路径从网络上访问。

UseCookiePolicy 确保只有在用户同意使用 cookie 的情况下,ASP.NET Core 管道才会处理 cookie。对 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实例中,必须使用选项框架手动配置。以下代码片段显示了 Visual Studio 生成的默认配置代码框架,它配置了代码中的CookiePolicyOptions而不是使用配置文件:

services.Configure<CookiePolicyOptions>(options =>
{
    options.CheckConsentNeeded = context => true;
});

UseAuthentication 启用认证方案。默认情况下,它仅启用基于 cookie 的认证,即认证令牌放置在 cookie 中的认证方案。认证 cookie 在用户登录期间创建。

通过在 ConfigureServices 方法中配置一个选项对象,可以启用 cookie 授权选项(如 cookie 名称)和其他认证方案,如下所示:

services.AddAuthentication(o =>
{
    o.DefaultScheme = 
    CookieAuthenticationDefaults.AuthenticationScheme;
})
.AddCookie(o =>
{
    o.Cookie.Name = "my_cookie";
})
.AddJwtBearer(o =>
{
    ...
});

上述代码指定了一个自定义的认证 cookie 名称,并为应用程序中包含的 REST 服务添加了基于 JWT 的认证。AddCookieAddJwtBearer 都有重载,可以在定义认证方案选项的动作之前接受认证方案名称。由于认证方案名称是引用特定认证方案所必需的,因此当它未指定时,将使用默认名称:

  • CookieAuthenticationDefaults.AuthenticationScheme 中包含的标准名称用于 cookie 认证

  • JwtBearerDefaults.AuthenticationScheme 中包含的标准名称用于 JWT 认证

o.DefaultScheme 中传入的名称选择用于填充 HttpContextUser 属性的认证方案。

关于 JWT 认证的更多信息,请参阅第十二章 使用 .NET Core 应用服务架构ASP.NET Core 服务授权 子节,链接。

UseAuthorization 通过 Authorize 属性启用基于授权的授权。可以通过在 ConfigureServices 方法中放置一个 AddAuthorization 方法来配置选项。这些选项允许定义基于声明的授权策略。

关于授权的更多信息,请参阅第十二章 使用 .NET Core 应用服务架构ASP.NET Core 服务授权 子节,链接。

UseRoutingUseEndpoints 处理所谓的 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 实例包含在 IEndpointFeatureEndpoint 属性中。

每个模式都包含一个处理程序,该处理程序应处理所有与模式匹配的请求。当创建 Endpoint 时,此处理程序会被传递给 Endpoint

UseEndpoints 则添加了调用请求端点相关处理程序的中间件。它放置在管道末尾,因为预期处理程序的执行将生成最终的响应。

如以下代码片段所示,模式在 UseRouting 中间件中处理,但它们是在 UseEndpoints 方法中指定的。这种拆分不是必需的,但为了与之前包含没有类似 UseRouting 方法,而是在管道末尾进行唯一调用的 ASP.NET Core 版本保持一致。在新版本中,模式仍然在 UseEndpoints 中定义,它位于管道末尾,但 UseEndpoints 只在应用程序启动时创建一个包含所有模式的数据库结构。然后,这个数据结构由 UseRouting 中间件处理,如下面的代码所示:

app.UseRouting();

app.UseAuthentication();
app.UseAuthorization();

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

});

MapControllerRoute 定义了与 MVC 引擎关联的模式,这些模式将在下一小节中描述。还有其他方法定义了其他类型的模式。例如,.MapHub<MyHub>("/chat") 将路径映射到处理 WebSocket 的中心节点,而 .MapHealthChecks("/health") 将路径映射到返回应用程序健康数据的 ASP.NET Core 组件。您还可以使用 .MapGet 直接将模式映射到自定义处理程序,它拦截 GET 请求,以及 .MapPost,它拦截 POST 请求。以下是一个 MapGet 的示例:

MapGet("hello/{country}", context => 
    context.Response.WriteAsync(
    $"Selected country is {context.GetRouteValue("country")}"));

模式按照它们定义的顺序进行处理,直到找到匹配的模式。由于身份验证/授权中间件放置在路由中间件之后,它可以处理 Endpoint 请求以验证当前用户是否具有执行 Endpoint 处理程序所需的授权。否则,将立即返回 401(未授权)或 403(禁止)响应。只有通过身份验证和授权的请求才会由 UseEndpoints 中间件执行其处理程序。

在 第十二章 中描述的 ASP.NET Core RESTful API,使用 .NET Core 应用服务架构,ASP.NET Core MVC 也使用放置在控制器或控制器方法上的属性来指定授权规则。然而,可以将 AuthorizeAttribute 的实例添加到模式中,以将其授权约束应用于匹配该模式的所有 URL,如下面的示例所示:

endpoints
 .MapHealthChecks("/healthz")
 .RequireAuthorization(new AuthorizeAttribute(){ Roles = "admin", });

上述代码使健康检查路径仅对管理员用户可用。

定义控制器和 ViewModels

UseEndpoints中的各种.MapControllerRoute调用将 URL 模式关联到控制器和这些控制器的方法,其中控制器是继承自Microsoft.AspNetCore.Mvc.Controller类的类。控制器是通过检查应用程序的所有.dll文件来发现的,并添加到 DI 引擎中。这项工作是通过在startup.cs文件的ConfigureServices方法中的调用AddControllersWithViews来完成的。

UseEndpoints添加的管道模块从controller模式变量中获取控制器名称,并从action模式变量中获取要调用的控制器方法名称。由于按照惯例,所有控制器名称都预期以Controller后缀结尾,因此实际的控制器类型名称是通过在controller变量中找到的名称添加此后缀来获得的。例如,如果controller变量中找到的名称是"Home",那么UseEndpoints模块会尝试从 DI 引擎中获取HomeController类型的实例。所有控制器公开的方法都可以通过路由规则进行选择。可以通过使用[NonAction]属性来防止使用控制器公开方法。所有可供路由规则使用的控制器方法都称为操作方法。

MVC 控制器的工作方式与我们在第十二章的Applying Service-Oriented Architectures with .NET Core小节中描述的Implementing REST services with ASP.NET Core中的 API 控制器类似。唯一的区别是 API 控制器预期生成 JSON 或 XML,而 MVC 控制器预期生成 HTML。因此,虽然 API 控制器从ControllerBase类继承,MVC 控制器则从Controller类继承,而Controller类反过来又从ControllerBase类继承并添加了用于 HTML 生成的有用方法,例如调用视图(将在下一小节中描述)和创建重定向响应。

MVC 控制器也可以使用类似于 API 控制器的一种路由技术,即基于控制器和控制器方法属性的路由。这种行为是通过在UseEndpoints中调用.MapDefaultControllerRoute()方法来启用的。如果这个调用放在所有MapControllerRoute调用之前,那么控制器路由在MapControllerRoute模式上具有优先级;否则,情况相反。

我们所看到的所有用于 API 控制器的属性也可以用于 MVC 控制器和操作方法(HttpGetHttpPost...Authorize等)。开发者可以通过从ActionFilter类或其他派生类继承来编写自己的自定义属性。我现在不会详细介绍这一点,但这些详细信息可以在进一步阅读部分中提到的官方文档中找到。

UseEndpoints 模块调用控制器时,由于控制器实例本身由依赖注入(DI)引擎返回,并且由于依赖注入(DI)以递归方式自动填充构造函数参数,因此所有构造函数参数都由依赖注入(DI)引擎填充。

相反,操作方法参数来自以下来源:

  • 请求头

  • 当前请求匹配的模式中的变量

  • 查询字符串参数

  • 表单参数(在 POST 请求的情况下)

  • 依赖注入DI

当使用依赖注入(DI)填充的参数按类型匹配时,所有其他参数都按名称匹配,忽略字母大小写。也就是说,操作方法参数名称必须与标题、查询字符串、表单或模式变量匹配。当参数是复杂类型时,会为每个属性搜索匹配,使用属性名称进行匹配。在嵌套复杂类型的情况下,会为每个路径搜索匹配,并通过将路径中的所有属性名称链接起来并使用点分隔来获取与路径关联的名称。例如,由 Property1Property2、......、Propertyn 组成的路径关联的名称是 Property1.Property2.Property3...Propertyn。通过这种方式获得的名称必须与标题名称、模式变量名称、查询字符串参数名称等匹配。

默认情况下,简单类型参数与模式变量和查询字符串变量进行匹配,而复杂类型参数与表单参数进行匹配。但是,可以通过在此处详细说明的属性前缀更改前面的默认设置:

  • [FromForm] 强制与表单参数进行匹配。

  • [FromHeader] 强制与请求头进行匹配。

  • [FromRoute] 强制与模式变量进行匹配。

  • [FromQuery] 强制与查询字符串变量进行匹配。

  • [FromServices] 强制使用依赖注入(DI)。

在匹配过程中,从所选来源提取的字符串使用当前线程的文化类型转换为操作方法参数。如果转换失败或找不到非可空操作方法参数的匹配项,则整个操作方法调用过程失败,并自动返回 404 响应。例如,在以下示例中,由于 id 参数是简单类型,因此它与查询字符串参数或模式变量进行匹配,而 myclass 属性和嵌套属性由于 MyClass 是复杂类型,因此与表单参数进行匹配。最后,由于 myservice 前缀为 [FromServices] 属性,因此从依赖注入(DI)中获取:

    public class HomeController : Controller
    {
        public IActionResult MyMethod(
            int id, 
            MyClass myclass, 
            [FromServices] MyService myservice)
        {
            ...

如果找不到 id 参数的匹配项,由于整数不可为空,将自动返回 404 响应。相反,如果在依赖注入(DI)容器中找不到 MyService 实例,则抛出异常,因为在这种情况下,失败不取决于错误的请求,而是设计错误。

如果 MVC 控制器被声明为 async,则返回 IActionResult 接口或 Task<IActionResult> 结果。IActionResult 有一个独特的 ExecuteResultAsync(ActionContext) 方法,当框架调用它时,会产生实际的响应。

对于每种不同的 IActionResult,MVC 控制器都有返回它们的方法。最常用的 IActionResultViewResult,它由 View 方法返回:

public IActionResult MyMethod(...)
{
   ...
   return View("myviewName", MyViewModel)
}

ViewResult 是控制器创建 HTML 响应的一种非常常见的方式。更具体地说,控制器与业务/数据层交互,以生成将在 HTML 页面上显示的数据的抽象。这个抽象是一个称为 ViewModel 的对象。ViewModel 作为 View 方法的第二个参数传递,而第一个参数是名为 View 的 HTML 模板的名称,该模板使用 ViewModel 中包含的数据进行实例化。

总结来说,MVC 控制器的处理顺序如下:

  1. 控制器执行一些处理以创建 ViewModel,它是要在 HTML 页面上显示的数据的抽象。

  2. 然后,控制器通过将视图名称和 ViewModel 传递给 View 方法来创建 ViewResult

  3. MVC 框架调用 ViewResult 并使 View 中包含的模板与 ViewModel 中的数据实例化。

  4. 模板实例化的结果写入响应中,并带有适当的头信息。

这样,控制器通过构建 ViewModel 执行 HTML 生成的概念性工作,而视图,即模板,则负责所有图形细节。

在下一小节中更详细地描述了视图,而在本章的 ASP.NET Core MVC 与设计原则之间的联系 部分更详细地讨论了模型(ViewModel)视图控制器模式。最后,在本章的 用例 - ASP.NET Core MVC 中的 Web 应用 部分给出了一个实际示例。

另一个常见的 IActionResultRedirectResult,它创建一个重定向响应,从而强制浏览器移动到特定的 URL。重定向通常在用户成功提交完成先前操作的表单后使用。在这种情况下,通常会将用户重定向到可以选择另一个操作的页面。

返回 RedirectResult 的最简单方法是通过绕过 Redirect 方法的 URL。这是建议用于将 URL 重定向到 web 应用程序外部的 URL 的方法。当 URL 在 web 应用程序内部时,相反,建议使用 RedirectToAction 方法,该方法接受控制器名称、操作方法名称和目标操作方法所需参数。框架使用这些数据来计算导致所需操作方法使用提供的参数调用的 URL。这样,如果在应用程序开发或维护期间更改了路由规则,框架会自动更新新 URL,无需修改代码中所有旧 URL 的出现。以下代码显示了如何调用 RedirectToAction

return RedirectToAction("MyActionName", "MyControllerName",
         new {par1Name=par1VAlue,..parNName=parNValue});

另一个有用的 IActionResultContentResult,可以通过调用 Content 方法来创建。ContentResult 允许您将任何字符串写入响应,并指定其 MIME 类型,如下例所示:

return Content("this is plain text", "text/plain");

最后,File 方法返回 FileResult,它在响应中写入二进制数据。此方法有几个重载,允许指定字节数组、流或文件的路径,以及二进制数据的 MIME 类型。

现在,让我们来描述在 Views 中实际生成的 HTML。

理解 Razor 视图

ASP.NET Core MVC 使用名为 Razor 的语言来定义 Views 中包含的 HTML 模板。Razor 视图是文件,它们在首次使用时、在构建应用程序时或在应用程序发布时编译成 .NET 类。默认情况下,每次构建和发布时的预编译都是启用的,但可以通过向 web 应用程序项目文件中添加以下代码来更改此行为:

<PropertyGroup>
  <TargetFramework>netcoreapp3.0</TargetFramework>
  <!-- add code below -->
  <RazorCompileOnBuild>false</RazorCompileOnBuild>
  <RazorCompileOnPublish>false</RazorCompileOnPublish>
  <!-- end of code to add -->
    ...
</PropertyGroup>

通过在选择 ASP.NET Core 项目后出现的窗口中选择一个 Razor 视图库项目,视图也可以预先编译成视图库。

此外,编译后,视图仍然与其路径相关联,这些路径成为它们的完整名称。每个控制器在 Views 文件夹下都有一个与其同名的关联文件夹,预计将包含该控制器使用的所有视图。以下截图显示了与 HomeController 和其视图关联的文件夹:

截图

前面的截图还显示了共享文件夹,它预计将包含由多个控制器使用的所有视图。控制器通过它们的路径(不带 .cshtml 扩展名)在 View 方法中引用视图。如果路径以 / 开头,则路径被视为相对于应用程序根的相对路径。否则,作为第一次尝试,路径被视为相对于与控制器关联的文件夹的相对路径,如果在那里找不到视图,则视图将在共享文件夹中搜索。

因此,例如,前面截图中的 Privacy.cshtml 视图文件可以在 HomeController 中引用,作为 View("Privacy", MyViewModel)。如果视图的名称与动作方法的名称相同,我们可以简单地写成 View(MyViewModel)

Razor 视图是 HTML 代码与 C# 代码以及一些 Razor 特定语句的混合。它们都以一个包含视图预期接收的 ViewModel 类型的标题开始:

@model MyViewModel 

每个视图也可以包含一些 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";
}

前面的示例表明,只需编写一个 HTML 标签就足以在 C# 区域内创建一个 HTML 区域,依此类推。一旦 HTML 标签关闭,您就又回到了 C# 模式。

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(callType == CallType.WebApi)
{
    ...
}
else
{
 ..
}

HTML 模板可以使用 forforeachwhile Razor 语句实例化多次,如下面的示例所示:

@for(int i=0; i< 10; i++)
{

}

@foreach(var x in myIEnumerable)
{

}

@while(true)
{

}

请不要将前面描述的语句与通常的 C# ifforforeachwhile 语句混淆,因为它们是 Razor 特定的语句,其语法与它们的标准 C# 对应项类似。

Razor 视图可以包含不会生成任何代码的注释。任何包含在@*...*@内的文本都被视为注释,并在页面编译时被移除。下一个主题将描述所有视图中都可用到的属性。

理解 Razor 视图属性

每个视图中都预定义了一些标准变量。最重要的变量是Model,它包含传递给视图的 ViewModel。例如,如果我们向视图传递一个Person模型,那么<span>@Model.Name</span>将显示传递给视图的Person的名称。

ViewData变量包含IDictionary<string, object>,它与调用视图的控制器共享。也就是说,所有控制器也都有一个包含IDictionary<string, object>ViewData属性,并且控制器中设置的每个条目在调用的视图的ViewData变量中也是可用的。ViewData是控制器向其调用的视图传递信息的 ViewModel 的替代方案。

User变量包含当前登录的用户,即当前请求的Http.Context.User属性中包含的相同实例。Url变量包含一个IUrlHelper接口的实例,其方法用于计算应用程序页面的 URL。例如,Url.Action("action", "controller", new {par1=valueOfPar1,...})计算导致控制器中名为controller的动作方法action被调用,并且使用匿名对象作为其参数中指定的所有参数的 URL。

Context变量包含整个请求的HttpContextViewContext变量包含关于视图调用上下文的数据,包括调用视图的动作方法的相关元数据。

下一个主题将描述 Razor 如何增强 HTML 标签语法。

使用 Razor 标签助手

在 ASP.NET Core MVC 中,开发者可以定义所谓的标签助手,这些标签助手可以增强现有的 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>

类似的语法被添加到 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="img/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 对象。

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 casche *@
</cache>

默认情况下,内容会被缓存 20 分钟,但标签具有定义缓存何时过期的属性,例如 expires-on="{datetime}"expires-after="{timespan}"expires-sliding="{timespan}"。在这里,expires-slidingexpires-after 之间的区别在于,在第二个属性中,每次请求内容时都会重置过期时间的计数。vary-by 属性会导致为传递给 vary-by 的每个不同值创建不同的缓存条目。还有如 vary-by-header 等属性——为属性中指定的请求头中假设的每个不同值创建不同的条目,vary-by-cookie 等。

所有 input 标签,即 textareainputselect,都有一个 asp-for 属性,它接受以视图 ViewModel 为根的属性路径作为其值。例如,如果视图有一个 Person ViewModel,我们可能会有如下所示的内容:

<input type="text" asp-for"Address.Town"/>

上述代码的第一个效果是将嵌套属性 Town 的值分配给 input 标签的 value 属性。一般来说,如果值不是字符串,它将使用当前请求文化将其转换为字符串。

然而,它也设置了输入字段的名称为 Address.Town 并将输入字段的 ID 设置为 Address_Town,因为点在标签 ID 中是不允许的。

可以通过在 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 属性(如果有),例如示例中的 Town 属性;否则,使用属性的名称。

如果 spandiv 包含 data-valmsg-for="Address.Town" 错误属性,则有关 Address.Town 输入的验证消息将自动插入该标签内。验证框架在 ASP.NET Core MVC 与设计原则之间的联系 部分中描述。

还可以通过将以下属性添加到 divspan 中来自动创建验证错误摘要:

asp-validation-summary="ValidationSummary.{All, ModelOnly}"

如果属性设置为 ValidationSummary.ModelOnly,则仅在摘要中显示与特定 input 字段不相关的消息,如果值为 ValidationSummary.All,则显示所有错误消息。

asp-items 属性允许通过 IEnumerable<SelectListItem> 指定 select 的选项,其中每个 SelectListItem 包含每个选项的文本和值。SelectListItem 还包含一个可选的 Group 属性,您可以使用它将 select 中显示的选项组织成组。

下一个主题将展示如何重用视图代码。

重用视图代码

ASP.NET Core MVC 包含了多种用于重用视图代码的技术。其中最重要的是布局页面。

在每个网络应用程序中,多个页面共享相同的结构,例如,相同的主菜单或相同的左侧或右侧栏。在 ASP.NET Core 中,这种公共结构被提取到称为布局页面/视图的视图中。

每个视图都可以使用以下代码指定其布局页面:

@{
    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>
...

每个 ViewViewState 都会被复制到其布局页面的 ViewState 中,因此 ViewState 可以用来将信息传递到视图布局页面。通常,它用于将视图标题传递到使用它来组成页面标题头的布局页面,如下所示:

@*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 方法。PartialViewView 具有完全相同的重载和参数。

重新使用视图代码的另一种方式是将几个视图共有的视图片段提取到另一个由所有先前视图调用的视图中。视图可以使用 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类的类。当组件被调用时,框架会寻找一个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"></my-test>

如果组件被名为MyController的控制器视图调用,ViewName将在以下路径中进行搜索:

  • /Views/MyController/Components/MyTest/ViewName

  • /Views/Shared/Components/MyTest/ViewName

现在,让我们看看随着.NET Core 3.0 一起出现的新特性。

.NET Core 3.0 对于 ASP.NET Core 的新特性是什么?

ASP.NET 3.0 引入的主要创新是将路由引擎从 MVC 引擎中分离出来,现在它可用于其他处理器。在之前的版本中,路由和路由是使用app.UseMvc(....)添加到 MVC 处理器的部分;现在已被app.UseRouting()加上UseEndpoints(...)所取代,这不仅可以将请求路由到控制器,还可以路由到其他处理器。

端点和它们关联的处理器现在在UseEndpoints中定义,如下所示:

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

MapControllerRoute将模式与控制器关联,但我们也可以使用类似endpoints.MapHub<ChatHub>("/chat")的东西,它将模式与处理 WebSocket 连接的 hub 关联。在上一节中,我们已经看到模式也可以通过MapPostMapGet与自定义处理器关联。

独立的路由器还允许我们不仅向控制器添加授权,还可以向任何处理器添加授权,如下所示:

MapGet("hello/{country}", context => 
    context.Response.WriteAsync(
    $"Selected country is {context.GetRouteValue("country")}"))
    .RequireAuthorization(new AuthorizeAttribute(){ Roles = "admin" });

在 3.0 版本中,ASP.NET Core 有一个独立的 JSON 格式化程序,不再依赖于第三方 Newtonsoft JSON 序列化程序。然而,如果您有更复杂的需求,您仍然可以选择通过安装Microsoft.AspNetCore.Mvc.NewtonsoftJson NuGet 包并配置控制器来用 Newtonsoft JSON 序列化程序替换最小化的 ASP.NET Core JSON 格式化程序,如下所示:

services.AddControllersWithViews()
    .AddNewtonsoftJson();

在这里,AddNewtonsoftJson还有一个接受 Newtonsoft JSON 序列化程序配置选项的重载:

.AddNewtonsoftJson(options =>
           options.SerializerSettings.ContractResolver =
              new CamelCasePropertyNamesContractResolver());

在之前的版本中,您被迫向 DI 引擎添加控制器和视图。在版本 3 中,我们仍然可以使用 services.AddControllersWithViews 注入控制器和视图,但如果您只打算实现 REST 端点,您也可以使用 AddControllers 添加控制器。

在之前的版本中,ASP.NET Core 有一个自定义的 IWebHostBuilder 实现,它与 HostBuilder 完全独立,而 HostBuilder 是用于配置通用主机的标准 IHostBuilder 实现。以下代码显示了在版本 3.0 之前如何配置 ASP.NET Core 主机:

public class Program
{
    public static void Main(string[] args)
    {
        CreateWebHostBuilder(args).Build().Run();
    }

    public static IWebHostBuilder CreateWebHostBuilder(string[] args) =>
        WebHost.CreateDefaultBuilder(args)
            .UseStartup<Startup>();
}

相反,ASP.NET Core 3.0 使用一个继承自 HostBuilder 的类型,并添加了 IWebHostBuilder 的方法,如下所示:

public class Program
{
    public static void Main(string[] args)
    {
        CreateHostBuilder(args).Build().Run();
    }

    public static IHostBuilder CreateHostBuilder(string[] args) =>
        Host.CreateDefaultBuilder(args)
            .ConfigureWebHostDefaults(webBuilder =>
            {
                webBuilder.UseStartup<Startup>();
            });
}

作为第一步,Host.CreateDefaultBuilder 创建了一个标准的 HostBuilderConfigureWebHostDefaultsHostBuilder 中包含的数据复制到一个派生类中,该类也实现了 IWebHostBuilder,并允许开发者在接收到的动作中配置这个 IWebHostBuilder。一旦配置完成,IWebHostBuilder 就作为 IHostBuilder 返回,这样所有特定于 Web 的事情都保持隐藏。

这样,HostBuilderWebHostBuilder 的发展路径已经合并,IHostBuilder 的新增强和扩展方法也自动对 ASP.NET Core 主机可用。

理解 ASP.NET Core MVC 和设计原则之间的联系

整个 ASP.NET Core 框架都是建立在我们在 第五章,将微服务架构应用于您的企业应用程序,第六章,在 C# 中与数据交互 - Entity Framework Core,第九章,设计模式和 .NET Core 实现,第十章,理解软件解决方案中的不同领域,以及 第十一章,在 C# 8 中实现代码重用 中分析的设计原则和模式之上。

所有功能都通过 DI 提供,以便每个功能都可以替换,而不会影响代码的其他部分。然而,ASP.NET Core 管道模块所需的提供者被分组到选项对象中,而不是单独添加到 DI 引擎中,以符合 SOLID 单一责任原则。

此外,配置数据,而不是从配置文件创建的唯一字典中可用,是通过我们在本章第一部分中描述的选项框架组织到选项对象中的。这是 SOLID 接口隔离原则的应用。

然而,ASP.NET Core 还应用了其他特定于一般关注点分离原则的通用模式,这是单一责任原则的泛化。它们如下所示:

  • 中间件模块架构(ASP.NET Core 管道)

  • 从应用程序代码中分解验证和全球化

  • MVC 模式本身

我们将在接下来的各个子节中分析所有这些内容。

ASP.NET Core 管道优势

ASP.NET Core 管道架构有两个重要优势:

  • 根据单一职责原则,所有在初始请求上执行的不同操作都被分解到不同的模块中。

  • 执行这些不同操作的模块不需要相互调用,因为每个模块都由 ASP.NET Core 框架一次性调用。这样,每个模块的代码就不需要执行与分配给其他模块的责任相关的任何操作。

这确保了功能的最大独立性以及代码的简化。例如,一旦授权和身份验证模块启动,其他模块就无需再担心授权问题。每个控制器代码可以专注于特定于应用程序的业务内容。

服务器端和客户端验证

验证逻辑已经完全从应用程序代码中分解出来,并被限制在验证属性的定义中。开发者只需通过在属性上装饰适当的验证属性来指定应用于每个模型属性的验证规则。

当动作方法参数实例化时,验证规则会自动进行检查。随后,错误和模型中的路径(它们发生的位置)将被记录在 ModelState 控制器属性中包含的字典中。开发者有责任通过检查 ModelState.IsValid 来验证是否存在错误,在这种情况下,开发者必须返回相同的 ViewModel 到相同的视图,以便用户可以纠正所有错误。

错误消息会自动在视图中显示,无需开发者采取任何行动。开发者只需执行以下操作:

  • 在每个将被自动填充可能错误输入字段的旁边添加带有 data-valmsg-for 属性的 spandiv

  • 添加带有 asp-validation-summary 属性的 div,它将被自动填充验证错误摘要。有关更多详细信息,请参阅 标签助手 主题。

只需通过调用带有 partial 标签的 _ValidationScriptsPartial.cshtml 视图来添加一些 JavaScript 引用,就可以在客户端启用相同的验证规则,这样在表单提交到服务器之前,错误就会显示给用户。System.ComponentModel.DataAnnotationsMicrosoft.AspNetCore.Mvc 命名空间中包含一些预定义的验证属性,包括以下属性:

  • Required属性要求用户为其装饰的属性指定一个值。对于所有非空属性(如所有浮点数、整数和小数)自动应用隐式的Required属性,因为它们不能有null值。

  • Range属性限制了数字数量在某个范围内。

  • 它们还包括限制字符串长度的属性。

可以直接在属性中插入自定义错误消息,或者属性可以引用包含它们的资源类型属性。

开发者可以通过提供 C#和 JavaScript 中的验证代码来定义其自定义属性,以进行客户端验证。

可以用其他验证提供者替换基于属性的验证,例如使用流畅接口为每个类型定义验证规则的流畅验证。只需更改包含在可以通过传递给services.AddControllersWithViews方法的操作配置的 MVC 选项对象中的提供者即可。MVC 选项的配置如下所示:

services.AddControllersWithViews(o => {
    ...
    // code that modifies o properties
});

验证框架会自动检查数字和日期输入是否根据所选文化格式良好。

ASP.NET Core 全球化

在多文化应用中,页面必须根据每个用户的语言和文化偏好进行服务。通常,多文化应用可以在几种语言中提供其内容,并且可以处理多种语言的日期和数字格式。实际上,尽管所有支持的语言内容都必须手动生成,但.NET Core 具有在所有文化中格式化和解析日期和数字的本地能力。

例如,一个 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
});

如果用户请求的文化在supportedCulturessupportedUICultures中明确列出,则使用它而不做修改。否则,由于FallBackToParentCulturesFallBackToParentUICulturestrue,将尝试父文化,即例如,如果所需的fr-FR文化在列出的那些中找不到,那么框架将搜索其通用版本,fr。如果这次尝试也失败了,框架将使用在DefaultRequestCulture中指定的文化。

默认情况下,culture中间件通过以下顺序尝试三个提供者来搜索为当前用户选择的文化:

  1. 中间件会查找cultureui-culture查询字符串参数。

  2. 如果前一个步骤失败,中间件会查找名为.AspNetCore.Culture的 cookie,其值预期如下示例:c=en-US|uic=en

  3. 如果前两个步骤都失败了,中间件会查找浏览器发送的Accept-Language请求头,这个可以在浏览器设置中更改,并且最初设置为操作系统的文化。

使用前面的策略,当用户第一次请求应用程序页面时,浏览器文化被采用(在步骤 3中列出的提供者)。然后,如果用户点击带有正确查询字符串参数的语言更改链接,提供者 1 将选择一个新的文化。通常,在点击语言链接后,服务器也会生成一个语言 cookie,通过提供者 2 记住用户的选择。

提供内容本地化的最简单方法是为每种语言提供不同的视图。因此,如果我们想为不同的语言本地化Home.cshtml视图,我们必须提供名为Home.en.cshtmlHome.es.cshtml等视图。如果没有找到特定于ui-culture线程的视图,将选择未本地化的Home.cshtml视图版本。

通过调用AddViewLocalization方法启用视图本地化,如下所示:

services.AddControllersWithViews()
    .AddViewLocalization(LanguageViewLocationExpanderFormat.Suffix)

另一个选项是将简单的字符串或 HTML 片段存储在针对所有支持的语言特定的资源文件中。必须在配置服务部分调用AddLocalization方法来启用资源文件的使用,如下所示:

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 片段添加到视图中:

  • 使用 @inject IViewLocalizer Localizer 在视图中注入 IViewLocalizer

  • 在需要的地方,将视图中的文本替换为对 Localizer 字典的访问,例如 Localizer["myKey"],其中 "myKey" 是资源文件中使用的键。

以下代码显示了 IViewLocalizer 字典的示例:

@{
    ViewData["Title"] = Localizer["HomePageTitle"];
}
<h2>@ViewData["MyTitle"]</h2>

如果本地化失败,因为资源文件中没有找到键,则返回键本身。如果启用了数据注释本地化,则用于数据注释中的字符串(如验证属性)用作资源文件中的键,如下所示:

 services.AddControllersWithViews()
    .AddViewLocalization(LanguageViewLocationExpanderFormat.Suffix)
    .AddDataAnnotationsLocalization();

应用于具有全名 MyWebApplication.ViewModels.Account.RegisterViewModel 的类的数据注释资源文件必须具有以下路径:

<ResourcesPath >/ViewModels/Account/RegisterViewModel.<culture name>.resx

值得指出的是,与 .dll 应用程序名称对应的命名空间的第一部分被替换为 ResourcePath。如果 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,以区别于业务和数据层的模型。

然而,表示层的逻辑是什么?在第一章《理解软件架构的重要性》中,我们了解到软件需求可以通过描述用户与系统之间交互的用例来记录。大致来说,表示层的逻辑包括用例的管理,因此,大致来说,用例映射到控制器,每个用例的每个操作都映射到这些控制器的操作方法。因此,控制器负责管理与用户的交互协议,并在每个操作中涉及的业务处理方面依赖于业务层。

每个操作方法都从用户那里接收数据,执行一些业务处理,并根据处理结果决定向用户展示什么,并将其编码在 ViewModel 中。视图接收 ViewModel,描述要向用户展示的内容,并决定使用哪种图形,即使用哪种 HTML。

将逻辑和图形分离成两个不同组件的优势是什么?主要优势如下:

  • 图形的变化不会影响代码的其他部分,因此您可以在不危及代码其他部分可靠性的情况下尝试各种图形选项来优化与用户的交互。

  • 应用程序可以通过实例化控制器并传递参数进行测试,无需使用在浏览器页面上操作的测试工具。这样,测试更容易实现。此外,它们不依赖于图形的实现方式,因此不需要在图形更改时更新。

  • 在实现控制器的开发人员和实现视图的图形设计师之间分配工作更容易。通常,图形设计师在 Razor 中会遇到困难,所以他们可能只提供一个示例 HTML 页面,开发人员将其转换为在真实数据上操作的 Razor 视图。

现在,让我们看看如何在 ASP.NET Core MVC 中创建 Web 应用。

用例 - 在 ASP.NET Core MVC 中实现 Web 应用

在本节中,我们将以 ASP.NET Core 应用程序的示例,实现 WWTravelClub 书籍用例的行政面板,用于管理目的地和包裹。该应用程序将使用第十章理解软件解决方案中的不同领域中描述的领域驱动设计DDD)方法来实现,因此,对该章节的良好理解是阅读本节的基本先决条件。接下来的小节将描述整体应用程序规范和组织,然后是应用程序的各个部分。

定义应用程序规范

目的地和包已在第六章中描述,在 C#中与数据交互 - Entity Framework Core。在这里,我们将使用完全相同的数据模型,并对其进行必要的修改以适应 DDD 方法。行政面板必须允许包、目的地列表以及对其的 CRUD 操作。为了简化应用程序,这两个列表将非常简单:应用程序将按名称排序显示所有目的地,并按高端有效日期排序显示所有包。

此外,我们假设以下事项:

  • 向用户展示目的地和包的应用程序与行政面板使用的相同数据库。由于只有行政面板应用程序需要修改数据,因此将只有一个写数据库副本和几个只读副本。

  • 价格修改和包删除会立即用于更新用户的购物车。因此,管理应用程序必须发送有关价格变化和包移除的异步通信。我们不会实现整个通信逻辑,但我们会将所有此类事件添加到事件表中,该表应作为发送这些事件到所有相关微服务的并行线程的输入使用。

在这里,我们将提供包管理的完整代码,而大部分目的地管理的代码将留给读者作为练习。完整代码可在与本书相关的 GitHub 存储库的chapter 13文件夹中找到。在本节的剩余部分,我们将描述应用程序的整体组织,并讨论一些相关的代码示例。

定义应用程序架构

应用程序的组织遵循第十章中描述的指南,理解软件解决方案中的不同领域,考虑到 DDD 方法和使用 SOLID 原则来映射您的领域部分。也就是说,应用程序在三个层内组织,每个层都作为不同的项目实现:

  • 存在一个数据层,其中包含存储库实现和描述数据库实体的类。它是一个.NET Core 库项目。然而,由于它需要一些 HTTP 堆栈接口和类,我们必须不仅添加对.NET Core SDK 的引用,还要添加对 ASP.NET Core SDK 的引用。可以按以下方式完成:

    1. 在解决方案资源管理器中右键单击项目图标,然后选择编辑项目文件。

    2. 在编辑窗口中,将 <Project Sdk="Microsoft.NET.Sdk"> 替换为 <Project Sdk="Microsoft.NET.Sdk.web"> 并保存。

    3. 由于在此修改后,Visual Studio 会自动将此库项目转换为应用程序项目,请再次右键单击项目并选择属性。在项目属性窗口中,在“输出类型”下拉列表中将“控制台应用程序”替换为“类库”。

  • 领域层还包含仓储规范,即描述仓储实现和 DDD 聚合的接口。在我们的实现中,我们决定通过隐藏根数据实体的禁止操作/属性来隐藏聚合的实现。因此,例如,Package 数据层类,它是一个聚合根,在领域层有一个相应的 IPackage 接口,隐藏了 Package 实体的所有属性设置器。领域层还包含所有领域事件的定义,而相应的事件处理器在应用层定义。

  • 最后,是应用层,即 ASP.NET Core MVC 应用程序,在这里我们定义 DDD 查询、命令、命令处理器和事件处理器。控制器填充查询对象并执行它们以获取可以传递给视图的 ViewModels。它们通过填充命令对象并执行相关的命令处理器来更新存储。反过来,命令处理器使用来自领域层的 IRepository 接口和 IUnitOfWork 来管理和协调事务。

应用程序使用查询命令分离模式;因此,它使用命令对象来修改存储,并使用查询对象来查询它。

查询的使用和实现都很简单:控制器填充它们的参数,然后调用它们的执行方法。反过来,查询对象有直接的 LINQ 实现,它们使用 Select LINQ 方法直接在控制器视图使用的 ViewModels 上投影结果。你也可以决定将 LINQ 实现隐藏在用于存储更新操作的相同仓储类后面。

然而,由于仓储不知道 ViewModels 的任何信息,这是表示层的内容,在这种情况下,你被迫使用领域层中定义的中间对象(DTOs),然后必须将它们复制到 ViewModels 中。这会使定义新的查询和修改现有查询变得耗时,因为你被迫修改多个类。无论如何,将查询对象隐藏在接口后面是一种良好的实践,这样在测试控制器时可以替换它们的实现。

命令执行过程中涉及的对象和调用链更为复杂,因为它需要构建和修改聚合,并定义多个聚合之间以及聚合与其他应用程序之间的交互。

以下图是存储更新操作执行过程的草图:

图片

  1. 控制器的操作方法接收一个或多个 ViewModels 并执行验证。

  2. 一个或多个包含要应用更改的 ViewModels 被隐藏在领域层定义的接口后面。它们用于填充命令对象的属性。

  3. 在控制器操作方法中通过依赖注入检索与之前命令匹配的命令处理器(通过我们描述的 [FromServices] 参数属性)。然后执行处理器。

  4. 在创建 步骤 3 中讨论的命令处理器时,ASP.NET Core DI 引擎自动注入其构造函数中声明的所有参数。特别是,它注入了执行所有命令处理器事务所需的全部 IRepostory 实现所需的所有参数。命令处理器通过调用其构造函数中接收到的这些 IRepository 实现的方法来构建聚合并修改已构建的聚合来完成其工作。聚合要么代表已存在的实体,要么代表新创建的实体。处理器使用包含在每个 IRepository 中的 IUnitOfWork 接口和数据层返回的并发异常来组织它们的操作作为事务。值得注意的是,每个聚合都有自己的 IRepository,并且更新每个聚合的整个逻辑都定义在聚合本身中,而不是在其关联的 IRepository 中,以保持代码更模块化。

  5. 在幕后,在数据层中,IRepository 实现使用 Entity Framework 来执行其任务。聚合通过领域层中定义的接口背后的根数据实体来实现,而处理事务并将更改传递到数据库的 IUnitOfWork 方法则使用 DBContext 方法实现。换句话说,IUnitOfWork 是通过应用程序的 DBContext 实现的。

  6. 在每个聚合处理过程中生成领域事件,并通过调用它们的 AddDomainEvent 方法将它们添加到聚合本身。然而,它们不会立即触发。通常,它们在所有聚合处理结束后、更改传递到数据库之前触发;但这并不是一个普遍的规则。

  7. 应用程序通过抛出异常来处理错误。一个更有效的方法是在依赖注入引擎中定义一个请求作用域对象,每个应用程序子部分都可以将其错误添加为领域事件。然而,虽然这种方法更有效,但它增加了代码和应用程序开发复杂度。

Visual Studio 解决方案由三个项目组成:

  • 有一个包含领域层的项目名为 PackagesManagementDomain,这是一个标准的 2.0 库。

  • 有一个包含整个数据层的项目名为 PackagesManagementDB,这是一个 .NET Core 3.0 库。

  • 最后,有一个名为 PackagesManagement 的 ASP.NET Core MVC 3.0 项目,该项目包含应用程序和表示层。当你定义此项目时,请选择不启用身份验证,否则用户数据库将直接添加到 ASP.NET Core MVC 项目中,而不是添加到数据库层。我们将在数据层中手动添加用户数据库。

让我们先创建PackagesManagement ASP.NET Core MVC 项目,以便整个解决方案的名称与 ASP.NET Core MVC 项目的名称相同。然后,让我们将其他两个库项目添加到同一个解决方案中。

最后,让 ASP.NET Core MVC 项目引用这两个项目,而PackagesManagementDB引用PackagesManagementDomain。我们建议您定义自己的项目,然后在阅读本节的过程中将本书 GitHub 仓库中的代码复制到它们中。

下一个子节描述了PackagesManagementDomain数据层项目的代码。

定义领域层

一旦将PackagesManagementDomain标准 2.0 库项目添加到解决方案中,让我们在项目根目录中添加一个Tools文件夹。然后,将代码中与第十章相关的所有DomainLayer工具放置在那里。由于该文件夹中的代码使用数据注释并定义 DI 扩展方法,我们还必须添加对System.ComponentModel.AnnotationsMicrosoft.Extensions.DependencyInjection NuGet 包的引用。

然后,我们需要一个包含所有聚合定义的Aggregates文件夹(记住,我们将聚合体实现为接口),即IDestinationIPackageIPackageEvent。在这里,IPackageEvent是与我们将放置要传播到其他应用程序的事件的表相关联的聚合体。

例如,让我们分析IPackage

public interface IPackage : IEntity<int>
{
    void FullUpdate(IPackageFullEditDTO o);
    string Name { get; set; }

    string Description { get;}
    decimal Price { get; set; }
    int DuratioInDays { get; }
    DateTime? StartValidityDate { get;}
    DateTime? EndValidityDate { get; }
    int DestinationId { get; }

} 

它包含与我们在第六章中看到的Package实体的相同属性,在 C#中与数据交互 - Entity Framework Core。唯一的区别如下:

  • 它继承自IEntity<int>,这为聚合体提供了所有基本功能。

  • 它没有Id属性,因为它继承自IEntity<int>

  • 所有属性都是只读的,并且它有一个Update方法,因为所有聚合体只能通过用户域中定义的更新操作(在我们的情况下,是Update方法)进行修改。

现在,让我们也添加一个DTOs文件夹。在这里,我们放置所有用于将更新传递给聚合体的接口。这些接口由用于定义此类更新的应用层 ViewModel 实现。在我们的案例中,它包含IPackageFullEditDTO,我们可以用它来更新现有包。如果您想添加管理目的地的逻辑,您必须为IDestination聚合体定义一个类似的接口。

一个IRepository文件夹包含所有存储库规范,即IDestinationRepositoryIPackageRepositoryIPackageEventRepository。在这里,IPackageEventRepository是与IPackageEvent聚合体相关联的存储库。例如,让我们看看IPackageRepository存储库:

public interface IPackageRepository: 
        IRepository<IPackage>
{
    Task<IPackage> Get(int id);
    IPackage New();
    Task<IPackage> Delete(int id);
}

仓库总是只包含几个方法,因为所有业务逻辑都应该表示为聚合方法,在我们的案例中,就是创建新包、检索现有包和删除现有包的方法。修改现有包的逻辑包含在 IPackageUpdate 方法中。

最后,与所有领域层项目一样,PackagesManagementDomain 包含一个事件文件夹,其中包含所有领域事件定义。在我们的案例中,文件夹名为 Events,包含包删除事件和价格变更事件:

public class PackageDeleteEvent: IEventNotification
{
    public PackageDeleteEvent(int id, long oldVersion)
    {
        PackageId = id;
        OldVersion = oldVersion;
    }
    public int PackageId { get; private set; }
    public long OldVersion { get; private set; }

}
{
    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; private set; }
        public decimal NewPrice { get; private set; }
        public long OldVersion { get; private set; }
        public long NewVersion { get; private set; }
    }
}

当一个聚合将所有更改发送到另一个应用程序时,它必须有一个版本属性。接收更改的应用程序使用此版本属性来按正确顺序应用所有更改。显式版本号是必要的,因为更改是异步发送的,所以它们接收的顺序可能与发送的顺序不同。为此目的,用于在应用程序外部发布更改的事件具有 OldVersion(更改前的版本)和 NewVersion(更改后的版本)属性。与删除事件相关的事件没有 NewVersion,因为实体在被删除后无法存储任何版本。

下一个子节将解释在领域层中定义的所有接口如何在数据层中实现。

定义数据层

数据层项目包含对 Microsoft.AspNetCore.Identity.EntityFrameworkCoreMicrosoft.EntityFrameworkCore.SqlServer NuGet 包的引用,因为我们使用的是与 SQL 服务器结合的 Entity Framework Core。它引用 Microsoft.EntityFrameworkCore.ToolsMicrosoft.EntityFrameworkCore.Design,这是在第六章中 在 C#中使用 Entity Framework Core 与数据交互Entity Framework Core 迁移 部分中生成数据库迁移所必需的。

我们有一个 Models 文件夹,其中包含所有数据库实体。它们与第六章中所述的类似,即 在 C#中使用 Entity Framework Core 与数据交互。唯一的区别如下:

  • 它们继承自 Entity<T>,该类包含所有聚合的基本特征。请注意,从 Entity<T> 继承仅适用于聚合根;所有其他实体必须按照第六章中所述进行定义,即 在 C#中使用 Entity Framework Core 与数据交互。在我们的示例中,所有实体都是聚合根。

  • 它们没有 Id,因为它是从 Entity<T> 继承的。

  • 其中一些具有 EntityVersion 属性,该属性被 [ConcurrencyCheck] 属性装饰。它包含发送所有实体属性更改到其他应用程序所需的实体版本。ConcurrencyCheck 属性用于在更新实体版本时防止并发错误,同时避免事务带来的性能惩罚。

更具体地说,当保存实体更改时,如果带有 ConcurrencyCheck 属性的字段值与实体在内存中加载时读取的值不同,则会抛出一个并发异常,通知调用方法,有人在读取实体之后但在我们尝试保存其更改之前修改了此值。这样,调用方法可以重复整个操作,希望这次在执行过程中没有人会在数据库中写入相同的实体。

值得分析 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;
        DuratioInDays = o.DuratioInDays;
        StartValidityDate = o.StartValidityDate;
        EndValidityDate = o.EndValidityDate;
    }
    [MaxLength(128), Required]
    public string Name { get; set; }
    [MaxLength(128)]
    public string Description { get; set; }
    public decimal Price { get; set; }
    public int DuratioInDays { 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.cs 文件包含数据层数据库上下文定义。它不继承自 DBContext,而是继承自以下预定义的上下文类:

IdentityDbContext<IdentityUser<int>, IdentityRole<int>, int>

此上下文定义了用于身份验证的用户表。在我们的案例中,我们选择了 IdentityUser<T> 标准和 IdentityRole<S> 分别用于用户和角色,并且对于 TS 实体键都使用了整数。然而,我们也可以使用从 IdentityUserIdentityRole 继承的类并添加更多属性。

OnModelCreating 方法中,我们必须调用 base.OnModelCreating(builder) 以应用在 IdentityDbContext 中定义的配置。

MainDBContext 实现了 IUnitOfWork 接口。以下代码展示了所有开始、回滚和提交事务的方法实现:

public async Task StartAsync()
{
    await Database.BeginTransactionAsync();
}

public async Task CommitAsync()
{
    Database.CommitTransaction();
}

public async Task RollbackAsync()
{
    Database.RollbackTransaction();
}

然而,在分布式环境中,命令类很少使用它们,因为直到没有返回并发异常为止重试相同的操作通常比事务确保更好的性能。

值得分析将所有应用于 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 ex;
    }          
}

上述实现只是调用了 SaveChangesAsync DBContext 上下文方法将所有更改保存到数据库,但随后它会拦截所有并发异常,并将所有涉及并发错误的实体从上下文中分离出来。这样,下次命令重试整个失败操作时,它们的更新版本将重新从数据库中加载。

Repositories 文件夹包含所有仓储实现。值得分析 IPackageRepository.Delete 方法的实现:

public async Task<IPackage> Delete(int id)
{
    var model = await Get(id);
    if (model == null) return null;
    context.Packages.Remove(model as Package);
    model.AddDomainEvent(
        new PackageDeleteEvent(
            model.Id, (model as Package).EntityVersion));
    return model;
}

它从数据库中读取实体,并正式将其从 Packages 数据集中删除。这将强制实体在更改保存到数据库时在数据库中被删除。此外,它将 PackageDeleteEvent 添加到聚合事件列表中。

Extensions 文件夹包含 DBExtensions 静态类,它反过来定义了要添加到应用程序 DI 引擎和 ASP.NET Core 管道中的两个扩展方法。一旦添加到管道中,这两个方法将连接数据库层到应用程序层。

AddDbLayerIServiceCollection 扩展接受(作为其输入参数)数据库连接字符串和包含所有迁移的 .dll 文件名。然后,它执行以下操作:

services.AddDbContext<MainDBContext>(options =>
                options.UseSqlServer(connectionString, 
                b => b.MigrationsAssembly(migrationAssembly)));

也就是说,它将数据库上下文添加到依赖注入引擎中,并定义其选项,即它使用 SQL Server、数据库连接字符串以及包含所有迁移的 .dll 文件名。

然后,它执行以下操作:

services.AddIdentity<IdentityUser<int>, IdentityRole<int>>()
                .AddEntityFrameworkStores<MainDBContext>()
                .AddDefaultTokenProviders();

也就是说,它添加并配置了处理基于数据库的身份验证所需的所有类型。特别是,它添加了 UserManagerRoleManager 类型,应用程序层可以使用这些类型来管理用户和角色。AddDefaultTokenProviders 添加了在用户登录时使用数据库中包含的数据创建身份验证令牌的提供者。

最后,它通过调用定义在添加到领域层项目的 DDD 工具中的 AddAllRepositories 方法,发现并添加所有仓库实现到依赖注入引擎中。

UseDBLayer 扩展方法通过调用 context.Database.Migrate() 确保迁移应用到数据库,然后使用一些初始对象填充数据库。在我们的例子中,它使用 RoleManagerUserManager 创建一个管理角色和一个初始管理员。然后,它创建一些示例目的地和包。

要创建迁移,我们必须将上述扩展方法添加到 ASP.NET Core MVC 的 Startup.cs 文件中,如下所示:

public void ConfigureServices(IServiceCollection services)
{
    ...
    services.AddRazorPages();
    services.AddDbLayer(
        Configuration.GetConnectionString("DefaultConnection"),
        "PackagesManagementDB");

___________________________

public void Configure(IApplicationBuilder app, 
    IWebHostEnvironment env, IServiceProvider serviceProvider)
    ...
    app.UseAuthentication();
    app.UseAuthorization();
    ...
    app.UseDBLayer(serviceProvider);
}

请确保已将授权和身份验证模块添加到 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 PackagesManagementDB

前一个命令将构建第一个迁移。我们可以使用 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,
    },
     = new List<CultureInfo>
    {
        ci,
    }
});

然后,让我们创建一个Tools文件夹,并将ApplicationLayer代码放在那里,你可以在与本书相关的 GitHub 仓库的第十章代码中找到它。有了这些工具,我们可以添加代码,自动发现并添加所有查询、命令处理程序和事件处理程序到 DI 引擎中,如下所示:

public void ConfigureServices(IServiceCollection services)
{
    ...
    ...
    services.AddAllQueries(this.GetType().Assembly);
    services.AddAllCommandHandlers(this.GetType().Assembly);
    services.AddAllEventHandlers(this.GetType().Assembly);
}

然后,我们必须添加一个Queries文件夹来放置所有查询及其相关接口。例如,让我们看看列出所有包的查询:

public class PackagesListQuery:IPackagesListQuery
{
    MainDBContext ctx;
    public PackagesListQuery(MainDBContext ctx)
    {
        this.ctx = ctx;
    }
    public async Task<IEnumerable<PackageInfosViewModel>> GetAllPackages()
    {
        return await ctx.Packages.Select(m => new PackageInfosViewModel
        {
            StartValidityDate = m.StartValidityDate,
            EndValidityDate = m.EndValidityDate,
            Name = m.Name,
            DuratioInDays = m.DuratioInDays,
            Id = m.Id,
            Price = m.Price,
            DestinationName = m.MyDestination.Name,
            DestinationId = m.DestinationId
        })
            .OrderByDescending(m=> m.EndValidityDate)
            .ToListAsync();
    }
}

查询对象会自动注入到应用程序数据库上下文中。GetAllPackages方法使用 LINQ 将所有所需信息投影到PackageInfosViewModel中,并按EndValidityDate属性降序排序所有结果。

PackageInfosViewModel放在Models文件夹中,与其他所有 ViewModel 一起。将 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;
            DuratioInDays = o.DuratioInDays;
            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; }
[Display(Name = "package infos")]
[StringLength(128, MinimumLength = 10), Required]
public string Description { get; set; }
[Display(Name = "price")]
[Range(0, 100000)]
public decimal Price { get; set; }
[Display(Name = "duration in days")]
[Range(1, 90)]
public int DuratioInDays { 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文件夹中。分析更新包的命令是值得的:

IPackageRepository repo;
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 = null;
    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)
        {

        }
    }
}

命令操作会重复执行,直到没有并发异常返回。HandleAsync 使用仓储来获取要修改的实体实例。如果实体未找到(已被删除),则命令停止其执行。否则,所有更改都会传递给检索到的聚合。更新后,立即触发聚合中包含的所有事件。特别是,如果价格已更改,则执行与价格更改相关的事件处理器。并发检查确保包版本正确更新(通过将其前一个版本号增加 1)并且价格更改事件传递了正确的版本号。

此外,事件处理器放置在 Handlers 文件夹中。作为一个例子,让我们看看价格更改事件处理器:

public class PackagePriceChangedEventHandler :
    IEventHandler<PackagePriceChangedEvent>
{
    IPackageEventRepository repo;
    public PackagePriceChangedEventHandler(IPackageEventRepository repo)
    {
        this.repo = repo;
    }
    public async Task HandleAsync(PackagePriceChangedEvent ev)
    {
        repo.New(PackageEventType.CostChanged, ev.PackageId, 
            ev.OldVersion, ev.NewVersion, ev.NewPrice);
    }
}

构造函数自动注入了处理所有要发送到其他应用的数据库事件的 IPackageEventRepository 仓储。HandleAsync 的实现简单地调用向此表添加新记录的仓储方法。

IPackageEventRepository 处理的表中的所有记录,可以通过在 DI 引擎中定义的并行任务(例如使用 services.AddHostedService<MyHostedService>();)检索并发送到所有感兴趣的微服务。如第五章的“使用通用宿主”小节中详细说明,第五章,“将微服务架构应用于您的企业应用”。然而,这个并行任务并未在与此章节相关的 GitHub 代码中实现。

下一个小节将描述控制器和视图是如何设计的。

控制器和视图

我们需要向 Visual Studio 自动生成的控制器中添加两个额外的控制器,即 AccountController,它负责用户登录/注销和注册,以及 ManagePackageController 来处理所有与包相关的操作。只需在 Controllers 文件夹上右键单击,然后选择添加 | 控制器。然后,选择控制器名称,并选择空 MVC 控制器以避免 Visual Studio 可能生成您不需要的代码。

为了简单起见,AccountController 只包含登录和注销方法,因此您只需使用初始管理员用户即可登录。然而,您可以添加进一步的动作方法,这些方法使用 UserManager 类来定义、更新和删除用户。UserManager 类可以通过 DI 提供,如下所示:

private readonly UserManager<IdentityUser<int>> _userManager;
private readonly SignInManager<IdentityUser<int>> _signInManager;

public AccountController(
    UserManager<IdentityUser<int>> userManager,
    SignInManager<IdentityUser<int>> signInManager)
{
    _userManager = userManager;
    _signInManager = signInManager;
}

SignInManager 负责登录/注销操作。Logout 动作方法相当简单,如下所示:

[HttpPost]
public async Task<IActionResult> Logout()
{
    await _signInManager.SignOutAsync();
    return RedirectToAction(nameof(HomeController.Index), "Home");
}

它只是调用 signInManager.SignOutAsync 方法,然后将浏览器重定向到主页。为了避免通过点击链接调用它,它被装饰为 HttpPost,因此只能通过表单提交来调用。

登录则需要两个动作方法。第一个是通过 Get 调用的,显示登录表单,用户必须在此处输入用户名和密码。如下所示:

[HttpGet]
public async Task<IActionResult> Login(string returnUrl = null)
{
    ViewData["ReturnUrl"] = returnUrl;
    return View();
}

当授权模块自动将浏览器重定向到登录页面时,它将returnUrl作为其参数接收。这发生在未登录的用户尝试访问受保护页面时。returnUrl存储在传递给登录视图的ViewState字典中。登录视图中的表单将其连同用户名和密码一起提交回控制器,如下所示:

<form asp-route-returnurl="@ViewData["ReturnUrl"]" method="post">
...
</form>

表单提交被具有相同Login名称但带有[HttpPost]属性的同一动作方法拦截,如下所示:

[ValidateAntiForgeryToken]        public async Task<IActionResult> Login(
            LoginViewModel model,
           string returnUrl = null)
        {
            ...

前面的方法接收登录视图使用的Login模型以及returnUrl查询字符串参数。ValidateAntiForgeryToken属性验证一个令牌(称为反伪造令牌),MVC 表单自动将其添加到隐藏字段中,以防止跨站攻击。

作为第一步,动作方法如果用户已经登录,则将其注销:

if (User.Identity.IsAuthenticated)
{
      await _signInManager.SignOutAsync();
      return View(model);
}

否则,它验证是否存在验证错误,在这种情况下,它显示相同的视图,并用 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,如果该值不为空,否则重定向到主页:

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方法,以表格格式显示所有包:

[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属性中。在 ViewModel 中包含IEnumerable是一个好的实践,因此如果需要,可以添加其他属性而无需修改现有的视图代码。结果在 Bootstrap 4 表格中显示,因为 Bootstrap 4 CSS 是由 Visual Studio 自动生成的。

结果如下所示:

新包链接(它看起来像 Bootstrap 4 按钮,但实际上是一个链接)调用控制器Create动作方法,而每行中的删除和编辑链接调用DeleteEdit动作方法,并将该行显示的包 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>
    ...
    ...

值得描述的是HttpGetHttpPostEdit动作方法的代码:

[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);
}

HttpGetEdit方法使用IPackageRepository检索现有包。如果找不到包,这意味着它已被其他用户删除,浏览器再次重定向到列表页面以显示更新后的包列表。否则,聚合传递给由Edit视图渲染的PackageFullEditViewModel ViewModel。

用于渲染包的视图必须渲染包含所有可能的包目标位置的select,因此它需要一个实现以辅助目标选择 HTML 逻辑的IDestinationListQuery查询实例。由于视图的责任是决定如何使用户能够选择目标,因此该查询直接注入到视图中。下面展示了注入查询并使用它的代码:

@inject PackagesManagement.Queries.IDestinationListQuery destinationsQuery
@{
    ViewData["Title"] = "Edit/Create package";
    var allDestinations = 
        await destinationsQuery.AllDestinations();
}

处理视图表单后文的动作方法如下所示:

[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视图中,如下所示:

<li class="nav-item">
    <a class="nav-link text-dark" 
        asp-controller="ManagePackages" 
            asp-action="Index">Manage packages</a>
</li>
@if (User.Identity.IsAuthenticated)
{
    <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>
}

现在,应用程序已准备就绪!您可以运行它,登录,并开始管理包。

摘要

在本章中,我们详细分析了 ASP.NET Core 管道以及组成 ASP.NET Core MVC 应用程序的各种模块,例如身份验证/授权、选项框架和路由。然后,我们描述了控制器和视图如何将请求映射到响应 HTML。我们还分析了 3.0 版本中引入的所有改进。

最后,我们分析了在 ASP.NET Core MVC 框架中实现的所有设计模式,特别是关注点分离原则的重要性以及 ASP.NET Core MVC 如何通过 ASP.NET Core 管道及其验证和全球化模块来实现它。最后,我们更详细地关注了表示层逻辑和图形之间的关注点分离的重要性,以及 MVC 模式如何确保这一点。

下一章将讨论有助于您编写安全、简单且可维护软件的最佳实践。

问题

  1. 您能否列出 Visual Studio 在 ASP.NET Core 项目中生成的所有中间件模块?

  2. ASP.NET Core 管道模块是否需要继承自基类或实现某些接口?

  3. 是否正确,一个标签必须只定义一个标签助手,否则会抛出异常?

  4. 您还记得如何在控制器中测试是否发生了验证错误吗?

  5. 在布局视图中,包含主视图输出的指令被称为什么?

  6. 在布局视图中,如何调用主视图的次要部分?

  7. 控制器如何调用视图?

  8. 默认情况下,全球化模块中安装了多少个提供程序?

  9. ViewModels 是否是控制器与其调用的视图通信的唯一方式?

进一步阅读

更多关于 ASP.NET MVC 框架的详细信息可以在其官方文档中找到,请访问 docs.microsoft.com/en-US/aspnet/core/。更多关于 Razor 语法的信息可以在 docs.microsoft.com/en-us/aspnet/core/razor-pages/?view=aspnetcore-3.0&tabs=visual-studio 查找。

关于本章未讨论的创建自定义标签辅助器的文档可以在 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/

关于构建 Web 应用程序表示层的替代方法,Blazor 的官方文档位于 dotnet.microsoft.com/apps/aspnet/web-apps/client。本书介绍了实现基于现代 JavaScript 的单页应用程序所需的所有技术和工具,是一本很好的入门书籍:www.packtpub.com/application-development/hands-typescript-c-and-net-core-developers,其中描述了 TypeScript、高级 JavaScript 功能、Webpack 以及 Angular SPA 框架。

第四部分:应对不可避免未来演化的编程解决方案

本节将重点关注交付良好代码的必要性。目标是展示一些技术,这些技术将帮助您交付可以持续维护和演化的良好软件。

在第十四章,C# 8 编码的最佳实践中,我们将介绍一些编码最佳实践,以帮助开发者编写安全、简单且易于维护的软件。本章还包括 C#编码的技巧和窍门。

在第十五章,使用单元测试和 TDD 测试您的代码中,我们将介绍代码测试技术和原则,观点是软件开发的一个必要元素是确保应用程序无错误并且满足所有规范。此外,它还将介绍测试驱动开发,这是一种软件开发方法,将单元测试置于核心地位。

然后,在第十六章,使用工具编写更好的代码中,将介绍您在项目中实现良好代码所需的一系列技术和工具。这里的理念是,尽管编码可以被视为一种艺术,但编写可理解的代码无疑是一种哲学,而且有一些工具可以帮助您实现这一点。

本节包括以下章节:

  • 第十四章,C# 8 编码的最佳实践

  • 第十五章,使用单元测试和 TDD 测试您的代码

  • 第十六章,使用工具编写更好的代码

第十四章:C# 8 编码的最佳实践

当你在项目中担任软件架构师时,你有责任定义和/或维护一个编码标准,这将指导团队根据公司的期望进行编程。本章涵盖了帮助你编写安全、简单和可维护软件的一些最佳编码实践。它还包括 C#编码的技巧和窍门。

本章将涵盖以下主题:

  • 你的代码复杂度如何影响性能

  • 使用版本控制系统的必要性

  • 在 C#中编写安全代码

  • .NET core 编码技巧和窍门

  • 书籍使用案例 - 编写代码时的注意事项与禁忌

技术要求

本章需要 Visual Studio 2019 免费社区版或更高版本,并安装所有数据库工具。

你可以在这里找到本章的示例代码:github.com/PacktPublishing/Hands-On-Software-Architecture-with-CSharp-8/tree/master/ch14.

你的代码越复杂,你的编程能力就越差

对于许多人来说,一个好的程序员是编写复杂代码的人。然而,软件开发成熟度的演变意味着对此有不同的思考方式。复杂性并不意味着工作做得好,它意味着代码质量差。一些令人难以置信的科学家和研究人员证实了这一理论,并强调专业代码需要关注时间、高质量,并在预算内完成。

因此,如果你想编写好的代码,你需要关注如何编写它,考虑到你不是唯一一个将来会阅读它的人。这是一个改变你编写代码方式的良好建议。这就是我们将如何讨论本章的每个要点。

如果你对编写良好代码重要性的理解与编写时的简洁性和清晰性理念相一致,你应该看看 Visual Studio 工具代码度量:

代码度量工具将提供度量值,这些度量值将为你提供关于你交付的软件质量的洞察。该工具提供的度量值列在这里,并可在以下链接中找到 docs.microsoft.com/en-us/visualstudio/code-quality/code-metrics-values?view=vs-2019:

  • 可维护性指数

  • 圈复杂度

  • 继承深度

  • 类耦合

  • 代码行数

下一个子节将专注于描述它们在现实生活场景中的有用性。

可维护性指数

这个指数表明了维护代码的难易程度——代码越容易维护,指数就越高(限于 100)。易于维护是保持软件健康的关键点之一。显然,任何软件在未来都需要进行更改,因为变化是不可避免的。因此,如果你有低维护性,考虑重构你的代码。编写专门负责单一职责的类和方法,避免重复代码,并限制每个方法的代码行数,这些都是你可以提高维护性指数的例子。

循环复杂度

《循环复杂度度量》一书的作者是托马斯·J·麦卡贝。他根据可用的代码路径数(图节点)来定义软件函数的复杂度。路径越多,函数越复杂。麦卡贝认为每个函数的复杂度得分必须小于 10。这意味着,如果代码中有更复杂的方法,你必须重构它,将这些代码的部分转换为独立的方法。有一些真实场景中,这种行为很容易被检测到:

  • 循环嵌套循环

  • 连续的多个if-else

  • 在同一方法内部对每个case进行代码处理的switch

例如,看看这个方法处理信用卡交易不同响应的第一版本。正如你可以检查的,循环复杂度大于麦卡贝作为基础的数字。这种情况发生的原因是每个主switch案例中的if-else的数量:

static void Main()
{
    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 "S":
            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;
    }
}

如果你计算这段代码的代码度量,你会发现当涉及到循环复杂度时,结果非常糟糕,就像你在下面的截图中所看到的那样:

代码本身没有意义,但这里的关键是展示你可以做出多少改进来编写更好的代码:

  • switch-case选项可以用Enum来编写。

  • 每个情况的处理都可以在一个特定的方法中完成。

  • switch-case可以用Dictionary<Enum, Method>来替换。

通过使用上述技术重构这段代码,结果是一段更容易理解的代码,就像你在下面的代码片段中看到的主方法一样:

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");
}

完整的代码可以在本章的 GitHub 上找到,展示了如何实现低复杂度的代码。下面的截图显示了根据代码度量得出的这些结果:

正如你在前面的截图中所看到的,重构后复杂度有相当大的降低。这里的关键点是,应用了这些技术后,代码的理解性提高了,复杂度降低了,这证明了循环复杂度的重要性。

继承深度

此指标表示连接到正在分析的那个类的类的数量。你继承的类越多,你的代码就越糟糕。这与类耦合类似,表明改变你的代码有多困难。

例如,以下截图有四个继承的类:

图片

你可以在下面的截图看到,深度更大的类由于有三个其他类可以改变其行为,其指标更差:

图片

继承是面向对象分析的基本原则之一。然而,有时它可能对你的代码不利,因为它可能引起依赖。因此,如果你这样做有意义,考虑使用聚合而不是继承。

类耦合

当你在单个类中连接太多的类时,显然你会得到耦合,这可能导致你的代码维护不良。例如,参见下面的截图。它显示了一个进行了大量聚合的设计。代码本身没有意义:

图片

一旦你计算了前面设计的代码指标,你就会看到调用ExecuteTypeA()ExecuteTypeB()ExecuteTypeC()ProcessData()方法的类耦合实例数等于三个(3):

图片

一些论文指出,类耦合实例的最大数量应该是九(9)。由于聚合比继承是一个更好的实践,使用接口将解决类耦合问题。例如,以下设计相同的代码将给出更好的结果:

图片

注意,在设计中使用界面将允许你在不增加解决方案的类耦合的情况下增加执行类型:

图片

作为一名软件架构师,你必须考虑设计你的解决方案,使其具有比耦合更高的内聚性。文献指出,好的软件具有低耦合和高内聚。这是一个基本的原则,可以指导你到一个更好的架构模型。

代码行数

此指标有助于你理解你正在处理的代码的大小。由于行数并不能表明复杂性,因此无法将代码行数与复杂性联系起来。另一方面,代码行数显示了软件的大小和软件设计。例如,如果你在一个类中有太多的代码行(超过 1,000 行代码—1 KLOC),这表明这是一个糟糕的设计。

使用版本控制系统

你可能会觉得这本书中关于这个话题的内容有点明显,但许多人和公司仍然没有将版本控制系统视为软件开发的基本工具!写这个话题的目的是强迫你理解它。如果你不使用版本控制系统,没有任何架构模型或最佳实践可以拯救软件开发。

在过去几年里,我们一直在享受在线版本控制系统(如 GitHub、BitBucket 和 Azure DevOps)带来的优势。事实上,在你的软件开发生命周期中必须有一个这样的工具,而且没有理由不再使用它,因为大多数提供商都为小型团队提供免费版本。即使是你自己开发,这些工具也有助于跟踪你的更改、管理你的软件版本,并保证你的代码的一致性和完整性。

在团队中处理版本控制系统

当你一个人使用版本控制系统工具时,这很显然。你希望保持你的代码安全。但这类系统肯定是为了解决编写代码时的团队问题而开发的。因此,引入了一些功能,如分支和合并,以保持代码完整性,即使在开发者数量相当大的情况下也是如此。

作为一名软件架构师,你将不得不决定在你的团队中采用哪种分支策略。Azure DevOps 和 GitHub 建议不同的方法来实现这一点,并且在某些场景下它们都是很有用的。

关于 Azure DevOps 团队如何处理这个问题的信息可以在这里找到:devblogs.microsoft.com/devops/release-flow-how-we-do-branching-on-the-vsts-team/。GitHub 在这里描述了其流程:guides.github.com/introduction/flow/。我们不知道哪一个最适合你的需求,但我们确实想让你明白,你需要有一个控制代码的策略。

在 C#中编写安全代码

C#可以被认为是一种设计上安全的编程语言。除非你强制使用,否则不需要指针,并且大多数情况下,内存释放由垃圾回收器管理。即便如此,你也应该注意一些事项,以便从你的代码中获得更好的安全结果。让我们来看看它们。

try-catch

编程中的异常如此频繁,以至于你可能会找到一种方法来管理它们,无论何时发生。try-catch语句就是为了管理这些异常而构建的,它们对于保持代码安全至关重要。有很多情况下应用程序崩溃,原因就是没有使用try-catch。以下代码展示了缺少使用try-catch语句的例子:

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 0;
    }
}

try-catch语句必须始终与日志解决方案相关联,这样你就可以得到系统的响应,指示正确的行为,同时不会导致应用程序崩溃。以下代码展示了带有日志管理的理想try-catch语句:

private static int CodeWithCorrectTryCatch(string textToConvert)
{
    try
    {
        return Convert.ToInt32(textToConvert);
    }
    catch (Exception err)
    {
        Logger.GenerateLog(err);
        return 0;
    }
}

作为一名软件架构师,你应该进行代码审查以修复代码中发现的这类行为。系统的不稳定通常与代码中缺少 try-catch 语句有关。

try-finally 和 using

内存泄漏可以被认为是软件最糟糕的行为之一。它们导致系统不稳定,计算机资源使用不当,以及不希望的应用程序崩溃。C#试图通过垃圾回收器解决这个问题,垃圾回收器会在意识到对象可以被释放时自动释放对象。

与 I/O 交互的对象通常不是由垃圾回收器管理的:文件系统、套接字等。以下代码是FileStream对象使用错误的示例,因为它认为垃圾回收器会释放使用的内存,但实际上不会:

private static void CodeWithIncorrectFileStreamManagement()
{
    FileStream file = new FileStream("C:\\file.txt", FileMode.CreateNew);
    byte[] data = GetFileData();
    file.Write(data, 0, data.Length);
}

此外,垃圾回收器与需要释放的对象交互需要一段时间,有时你可能想自己来做这件事。在这两种情况下,使用try-finallyusing语句是最佳实践:

private static void CodeWithCorrectFileStreamManagementFirstOption()
{
    using (FileStream file = new FileStream("C:\\file.txt", 
    FileMode.CreateNew))
    {
        byte[] data = GetFileData();
        file.Write(data, 0, data.Length);
    }
}

private static void CodeWithCorrectFileStreamManagementSecondOption()
{
    FileStream file = new FileStream("C:\\file.txt", FileMode.CreateNew);
    try
    {
        byte[] data = GetFileData();
        file.Write(data, 0, data.Length);
    }
    finally
    {
        file.Dispose();
    }
}

上述代码展示了如何处理不由垃圾回收器管理的对象。你既有try-finally也有using的实现。作为一名软件架构师,你需要注意这类代码。缺少try-finallyusing语句可能导致软件在运行时出现严重问题。

IDisposable接口

同样,如果你不使用 try-finally/using 语句管理在方法内部创建的对象,那么在未正确实现IDisposable接口的类中创建的对象可能会导致你的应用程序出现内存泄漏。因此,当你有一个处理和创建对象的类时,你应该实现可处置模式,以确保释放它创建的所有资源:

图片

好消息是,Visual Studio 通过在代码中指示并右键单击“快速操作和重构”选项来实现此接口的代码片段,就像你在前面的截图中所看到的那样。一旦你插入了代码,你需要遵循 TODO 指令,以确保正确实现了模式。

.NET Core 编码技巧和窍门

.NET Core 实现了一些有助于我们编写更好代码的良好功能。其中最有用的是依赖注入DI),这在第九章,设计模式和.NET Core 实现中已经讨论过。考虑这一点有几个很好的理由。第一个是因为你不需要担心注入对象的释放,因为你不是它们的创建者。

此外,DI 使你能够注入ILogger,这是一个非常有用的调试异常的工具,这些异常将需要在你的代码中通过 try-catch 语句来管理。此外,使用.NET Core 进行 C#编程必须遵循任何编程语言的通用良好实践。以下列表显示了其中的一些:

  • 类、方法和变量应该有可理解的名称:名称应该解释读者需要知道的一切。不应该需要解释性注释。

  • 方法不能有高复杂度:应该检查方法的圈复杂度,以确保方法不要有太多的代码行。

  • 应避免重复代码:在像 C#这样的高级编程语言中,没有必要存在重复代码。

  • 在使用对象之前应进行检查:由于可能存在 null 对象,代码必须进行 null 类型检查。

  • 应使用常量和枚举器:避免代码中的魔法数字和文本的好方法是将这些信息转换为常量和枚举器,这通常更容易理解。

  • 应避免不安全代码:除非没有其他实现代码的方法,否则应避免使用不安全代码。

  • try-catch 语句不能为空:在没有处理catch区域的try-catch语句中没有理由。

  • try-finally/using 语句应该始终使用:即使对于垃圾回收器将负责处理的对象,也应考虑处理你负责创建的对象。

  • 至少公共方法应该有注释:考虑到公共方法是用于你库外部的那些方法,它们必须被解释以正确使用。

  • switch-case 语句必须有默认处理:由于switch-case语句可能接收到在某些情况下未知的人口变量,默认处理将保证在这种情况下代码不会中断。

作为软件架构师,一个好的做法是为你的开发者提供一个所有程序员都会使用的代码模式,以此来保持代码风格的一致性。你可以将代码模式用作编码检查的清单,这将丰富软件代码的质量。

WWTravelClub – 编写代码的注意事项和禁忌

作为一名软件架构师,你必须定义一个符合你所服务公司需求的代码标准。

在本书的示例项目中(关于 WWTravelClub 项目的更多信息,请参阅第一章,理解软件架构的重要性),情况并无不同。我们决定展示该标准的做法是描述我们在编写示例时遵循的一系列“做”和“不做”的清单。值得一提的是,这个清单是一个很好的开始标准,作为软件架构师,你应该与团队中的开发者讨论这个清单,以便以实际和良好的方式对其进行改进:

  • 务必用英语编写你的代码。

  • 务必遵循 C#编码标准,使用驼峰命名法。

  • 务必使用易于理解的名称编写类、方法和变量。

  • 务必对公共类、方法和属性进行注释。

  • 务必尽可能使用using语句。

  • 务必尽可能使用async实现。

  • 务必在实现非托管代码之前请求授权。

  • 务必在实现线程之前请求授权。

  • 不要编写空的try-catch语句。

  • 不要编写超过 10 个循环复杂度的方法。

  • 不要for/while/do-while/foreach语句中使用breakcontinue

  • 不要使用goto语句。

这些“做”和“不做”的规则很容易遵循,而且,更重要的是,它们将为你的团队产生的代码带来巨大的成果。在第十六章,使用工具编写更好的代码中,我们将讨论帮助你实施这些规则的工具。

摘要

在本章中,我们讨论了一些编写安全代码的重要提示。本章介绍了一个分析代码指标的工具,这样你可以管理你正在开发的软件的复杂性和可维护性。最后,我们提出了一些保证你的软件不会因为内存泄漏和异常而崩溃的好建议。在现实生活中,软件架构师总会被要求解决这类问题。

在下一章中,我们将学习一些单元测试技术、单元测试的原则以及一个专注于 C#测试项目的软件过程模型。

问题

  1. 为什么我们需要关注可维护性?

  2. 循环复杂度是什么?

  3. 列出使用版本控制系统的优点。

  4. try-catchtry-finallytry-catch-finally之间的区别是什么?

  5. 垃圾收集器是什么?

  6. 实现IDisposable接口的重要性是什么?

  7. 当涉及到编码时,我们从.NET Core 中获得了哪些优势?

进一步阅读

这些是一些书籍和网站,你可以在其中找到更多关于本章主题的信息:

第十五章:使用单元测试用例和 TDD 测试您的代码

在开发软件时,确保应用程序无错误并满足所有规范至关重要。这可以通过在开发过程中测试所有模块或在整体应用程序完全或部分实现后进行测试来实现。

由于大多数测试必须在每次修改应用程序时执行,手动执行所有测试不是一个可行的选项。正如本书中解释的那样,现代软件正在不断修改,以适应快速变化的市场需求。本章讨论了交付可靠软件所需的所有测试类型,以及如何组织和自动化它们。

更具体地说,本章涵盖了以下主题:

  • 理解自动化测试及其用法

  • 理解测试驱动开发TDD)的基本原理

  • 使用 TDD 优化软件投资

  • 在 Visual Studio 中定义 C#测试项目

在本章中,我们将了解哪些类型的测试值得实施,以及单元测试是什么。我们将了解可用的不同类型的项目以及如何在其中编写单元测试。到本章结束时,书籍用例将帮助我们自动在 Azure DevOps 中执行应用程序的持续集成/持续交付CI/CD)周期中的测试。

技术要求

本章需要安装所有数据库工具的 2019 免费社区版。它还需要一个免费的 Azure 账户;如果您尚未创建,请参阅第一章,理解软件架构的重要性中的创建 Azure 账户部分。

本章中的所有概念都通过基于 WWTravelClub 书籍用例的实用示例进行了阐明。本章的代码可在以下网址找到:github.com/PacktPublishing/Hands-On-Software-Architecture-with-CSharp-8

理解自动化测试

必须避免在大多数功能完全实现后立即进行应用程序测试,原因如下:

  • 如果一个类或模块设计或实现不当,它可能已经影响了其他模块的实现方式。因此,在此阶段,修复问题可能代价高昂。

  • 需要测试所有可能执行路径的输入组合的数量随着一起测试的模块或类的数量呈指数增长。例如,如果一个类方法A的执行可以采取三条不同的路径,而另一个方法B的执行可以采取四条路径,那么测试AB一起就需要 3 x 4 种不同的输入。一般来说,如果我们一起测试几个模块,需要测试的总路径数是每个模块测试路径数的乘积。相反,如果单独测试模块,所需的输入数量只是测试每个模块所需路径的总和。

  • 如果由 N 个模块组成的聚合测试失败,那么在 N 个模块中定位错误源通常是一个非常耗时的活动。

  • 当测试 N 个模块时,我们必须重新定义涉及 N 个模块的所有测试,即使只是在应用程序的 CI/CD 周期中有一个 N 个模块发生变化。

前面的考虑表明,单独测试每个模块的方法更为方便。不幸的是,一组独立于其上下文验证所有方法的测试是不完整的,因为一些错误可能是由模块之间不正确的交互引起的。

因此,测试被组织成两个阶段:

  • 单元测试:这些测试验证每个模块的所有执行路径都表现正常。它们相当完整,通常覆盖所有可能的路径。这是可行的,因为与整个应用程序的可能执行路径相比,每个方法或模块的可能执行路径并不多。

  • 集成测试:这些测试在软件通过所有单元测试后执行。集成测试验证所有模块是否正确交互以获得预期结果。由于单元测试已经验证了每个模块的所有执行路径都正常工作,因此集成测试不需要完全覆盖。它们需要验证所有交互模式,即各种模块可能合作的所有可能方式。

通常,每个交互模式都与多个测试相关联:一个典型的模式激活,以及一些激活的极端情况。例如,如果一个完整的交互模式接收一个数组作为输入,我们将为典型的数组大小编写一个测试,一个null数组的测试,一个空数组的测试,以及一个非常大的数组的测试。这样我们就可以验证单个模块的设计方式是否与整个交互模式的需求兼容。

在实施上述策略的情况下,如果我们修改单个模块而不改变其公共接口,我们需要更改该模块的单元测试。

如果,相反,更改涉及某些模块的交互方式,那么我们也必须添加新的集成测试或修改现有的测试。然而,通常这并不是一个大问题,因为大多数测试都是单元测试,所以重写大部分集成测试不需要太大的努力。此外,如果应用程序是根据单一职责、开闭原则、里氏替换原则、接口隔离原则、依赖倒置原则SOLID)设计的,那么在单次代码修改后必须更改的集成测试数量应该很小,因为修改应该只影响与修改的方法或类直接交互的几个类。

到目前为止,应该很清楚,单元测试和集成测试在整个软件生命周期中都必须被重用。这就是为什么自动化它们是值得的。单元和集成测试的自动化避免了手动测试执行中可能出现的错误,并节省了时间。几千个自动化测试的整个系列可以在几分钟内验证软件的完整性,在每次对软件进行的小修改之后,从而使得现代软件的 CI/CD 周期中所需的频繁更改成为可能。

随着新错误的发现,会添加新的测试来发现它们,以确保它们不会在未来版本的软件中再次出现。这样,自动化测试总是变得更加可靠,并更好地保护软件免受新更改引入的错误的影响。因此,添加新错误(不是立即被发现)的概率大大降低。

下一节将为我们提供组织和设计自动化单元和集成测试的基础,以及如何在“C# 测试项目”部分中用 C#编写测试的实用细节。

编写自动化(单元和集成)测试

测试不是从头开始编写的;所有软件开发平台都有工具帮助我们编写和运行测试(或其中一些)。一旦选定的测试被执行,所有工具都会显示报告,并提供调试所有失败测试代码的可能性。

更具体地说,所有单元和集成测试框架都由三个重要部分组成:

  • 定义所有测试的设施:它们验证实际结果是否与预期结果相符。通常,一个测试被组织成测试类,其中每个测试调用一个单独的应用程序类或一个单独的类方法。每个测试分为三个阶段:

    1. 测试准备:测试所需的一般环境已经准备就绪。这一阶段并不准备为每种方法单独输入的单个输入,而是仅准备全局环境,例如在类构造函数中注入的对象或数据库表的模拟。通常,相同的准备程序会在多个测试中使用,因此测试准备被提取到专门的模块中。

    2. 测试执行:使用适当的输入调用要测试的方法,并将它们的执行结果与预期结果进行比较,使用如Assert.Equal(x, y)Assert.NotNull(x)等构造。

    3. 清理:整个环境被清理,以避免测试的执行影响其他测试。这一步骤是步骤 1 的逆操作。

  • 模拟设施:虽然集成测试使用所有(或几乎所有)参与对象合作模式的类,但在单元测试中禁止使用其他应用程序类。因此,如果一个正在测试的类,比如A,在其方法M中使用了另一个应用程序类B的方法,该方法是将其构造函数中注入的,那么为了测试M,我们必须注入B的模拟实现。值得注意的是,只有执行某些处理的类在单元测试期间不允许使用另一个类,而纯数据类可以。模拟框架包含定义接口和接口方法的模拟实现的功能,这些方法可以在测试中定义数据。通常,这些模拟实现也能报告所有模拟方法调用的信息。这种模拟实现不需要实际类文件的定义,而是在测试代码中通过调用如new Mock<IMyInterface>()这样的方法在线完成。

  • 执行和报告工具:这是一个基于可视配置的工具,开发者可以使用它来决定要启动哪些测试以及何时启动它们。此外,它还以包含所有成功测试、所有失败测试、每个测试的执行时间以及其他依赖于特定工具及其配置的信息的报告形式显示测试的最终结果。通常,在开发 IDE(如 Visual Studio)中执行的执行和报告工具还提供了在每个失败的测试上启动调试会话的可能性。

由于模拟框架只能创建接口的模拟实现,而不能创建类的模拟实现,因此我们应该在类构造函数和方法中注入接口或纯数据类(不需要模拟),否则类无法进行单元测试。因此,对于每个我们想要注入到另一个类中的合作类,我们必须定义一个相应的接口。

此外,类应该使用在其构造函数或方法中注入的实例,而不是其他类公共静态字段中可用的类实例;否则,在编写测试时可能会忘记隐藏的交互,这可能会使测试的准备步骤复杂化。

下一个部分将描述在软件开发中使用的其他类型的测试。

编写验收和性能测试

接受测试定义了项目利益相关者与开发团队之间的合同。它们被用来验证开发的软件是否确实按照与他们的约定行事。接受测试不仅验证功能规范,还验证软件可用性和用户界面的约束。由于它们的目的还包括展示软件在实际计算机显示器和显示设备上的外观和行为,因此它们永远不会完全自动化,主要由必须由操作员遵循的食谱和验证列表组成。

有时,自动测试的开发是为了验证仅功能规范,但此类测试通常绕过用户界面,直接将测试输入注入到用户界面后面的逻辑中。例如,在 ASP.NET Core MVC 应用程序的情况下,整个网站在一个完整的环境中运行,包括所有需要的存储空间,并填充了测试数据;输入不提供给 HTML 页面,而是直接注入到 ASP.NET Core 控制器中。绕过用户界面的测试被称为皮下测试。ASP.NET Core 提供了各种工具来执行皮下测试,以及自动化与 HTML 页面交互的工具。

在自动测试的情况下,通常更倾向于使用皮下测试,而完整测试则手动执行,以下是一些原因:

  • 没有自动测试可以验证用户界面的外观和可用性。

  • 自动化与用户界面的实际交互是一个非常耗时的工作。

  • 用户界面经常更改以提高其可用性并添加新功能,而且单个应用程序屏幕的微小更改也可能迫使对该屏幕上运行的全部测试进行完全重写。

简而言之,用户界面测试非常昂贵且可重用性低,因此很少值得自动化它们。然而,ASP.NET Core 提供了Microsoft.AspNetCore.Mvc.Testing NuGet 包,用于在测试环境中运行整个网站。使用它与AngleSharp NuGet 包一起,该包将 HTML 页面解析成 DOM 树,您可以用可接受的编程工作量编写自动化全面测试。将在第二十章中详细描述 ASP.NET Core 接受测试,软件测试自动化

性能测试向应用程序施加模拟负载,以查看其是否能够处理典型的生产负载,以发现其负载限制,并定位瓶颈。应用程序部署在一个预演环境中,该环境在硬件资源方面是实际生产环境的副本。然后,创建并应用模拟请求到系统中,并收集响应时间和其他指标。模拟请求批次应与实际生产批次具有相同的组成。如果可用,它们可以从实际生产请求日志中生成。

如果响应时间不满意,将收集其他指标以发现可能的瓶颈(低内存、慢速存储或慢速软件模块)。一旦定位到负责问题的软件组件,就可以在调试器中分析它,以测量典型请求中涉及的各个方法调用的执行时间。

性能测试中的失败可能导致对应用程序所需硬件的重定义,或者对某些软件模块、类或方法的优化。

Azure 和 Visual Studio 都提供了创建模拟负载和报告执行指标的工具。然而,它们已被宣布过时,并将很快停止使用(从写这本书起大约一年),因此我们不会描述它们。作为替代方案,有开源和第三方工具可以使用。其中一些在进一步阅读部分列出。

下一个部分将描述一种将测试置于核心位置的软件开发方法。

理解测试驱动开发(TDD)

测试驱动开发TDD)是一种软件开发方法,它将单元测试置于核心和中心位置。根据这种方法,单元测试是每个类规范的正式化,因此必须在类的代码之前编写。实际上,一个覆盖所有代码路径的完整测试明确定义了代码行为,因此它可以被视为代码的规范。这不是通过某种正式语言定义代码行为的正式规范,而是一种基于行为示例的规范。

测试软件的理想方式是编写整个软件行为的正式规范,并使用一些完全自动化的工具来验证实际产生的软件是否符合这些规范。在过去,一些研究工作被用于定义用于描述代码规范的正式语言,但使用类似语言表达开发者心中的行为是一个非常困难且容易出错的任务。因此,这些尝试很快就被放弃了,转而采用基于示例的方法。当时,主要目的是自动生成代码。如今,自动代码生成已被大量放弃,仅在小应用领域幸存,如设备驱动程序的开发。在这些领域,将行为在正式语言中形式化的努力值得在尝试测试难以重现的并行线程行为时节省的时间。

单元测试最初被构想为以完全独立的方式编码基于示例的规范,作为名为极限编程Extreme Programming)的特定敏捷开发方法的一部分。然而,如今,TDD 可以独立于极限编程使用,并被纳入其他敏捷方法中的强制规定。

虽然毫无疑问,在发现数百个错误后经过优化的单元测试可以作为可靠的代码规范,但开发者是否能够轻松设计出可以直接用作编写代码的可靠规范的单元测试并不明显。实际上,通常情况下,如果随机选择示例,你需要无限或至少是大量的示例来唯一地定义代码的行为。

只有在你理解了所有可能的执行路径之后,才能用可接受数量的示例定义行为。实际上,在这个时候,选择每个执行路径的典型示例就足够了。因此,在完全编写完方法之后编写该方法的单元测试是很容易的:它只需要为现有代码的每个执行路径选择一个典型实例。然而,以这种方式编写单元测试并不能防止执行路径设计本身的错误。例如,它不能防止忘记在调用成员之前测试变量的null值的典型错误。这就是为什么 TDD 建议在编写应用程序代码之前编写单元测试。

我们可以得出结论,在编写单元测试时,开发者必须通过寻找极端情况以及可能添加比严格需要的更多示例来预测所有可能的执行路径。然而,开发者可能在编写应用程序代码时犯错误,他们也可能在设计单元测试时预测所有可能的执行路径时犯错误。

我们发现了 TDD 的主要缺点:单元测试本身可能是错误的。也就是说,不仅应用程序代码,与其相关的 TDD 单元测试也可能与开发者心中的行为不一致。因此,在开始时,单元测试不能被视为软件规范,而更可能是一个可能的错误和不完整的软件行为描述。因此,我们有两个关于我们心中所想行为的描述,即应用程序代码及其在应用程序代码之前编写的 TDD 单元测试。

TDD 之所以有效,是因为在编写测试和编写代码时犯完全相同错误的概率非常低。因此,每当测试失败时,要么是测试中存在错误,要么是应用程序代码中存在错误,反之亦然,如果应用程序代码或测试中存在错误,那么有很高的概率测试会失败。也就是说,TDD 的使用确保了大多数错误都能立即被发现!

使用 TDD 编写类方法或代码块是一个由三个阶段组成的循环:

  • 红色阶段:在这个阶段,开发者设计新的单元测试,这些测试必然会失败,因为此时还没有代码实现他们所描述的行为。

  • 绿色阶段:在这个阶段,开发者编写最少的代码或对现有代码进行必要的最小修改,以通过所有单元测试。

  • 重构阶段:一旦测试通过,代码将被重构以确保良好的代码质量并应用最佳实践和模式。特别是,在这个阶段,某些代码可以被提取到其他方法或其他类中。在这个阶段,我们可能还会发现需要其他单元测试,因为发现了新的执行路径或新的极端情况。

循环会在所有测试通过且没有编写新代码或修改现有代码的情况下立即停止。

有时候,设计初始单元测试非常困难,因为很难想象代码可能的工作方式和它可能采取的执行路径。在这种情况下,你可以通过编写应用程序代码的初始草图来更好地理解要使用的特定算法。在这个初始阶段,我们只需要关注主要执行路径,完全忽略极端情况和输入验证。一旦我们清楚地了解了应该工作的算法背后的主要思想,我们就可以进入标准的三个阶段的 TDD 循环。

在下一节中,我们将列出 Visual Studio 中所有可用的测试项目,并详细描述 xUnit。

定义 C# 测试项目

Visual Studio 包含三种单元测试框架的项目模板,分别是 MSTest、xUnit 和 NUnit。一旦你启动新项目向导,为了可视化所有这些框架的版本,适合 .NET Core C# 应用程序,请将项目类型设置为测试,语言设置为 C#,平台设置为 Linux,因为 .NET Core 项目是唯一可以在 Linux 上部署的项目。

以下截图显示了应出现的选项:

图片

所有的前述项目都自动包含在 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()
    {
        return GetEnumerator();
    }
}
...
...
[Theory]
[ClassData(typeof(Test1Data))]
public void Test1(int testInput, int testOutput)
{
    var myInstanceToTest = new ClassToTest();
    Assert.Equal(testOutput, 
        myInstanceToTest.MethodToTest(testInput);
}

使用ClassData属性指定提供测试数据的类的类型。

还可以从类的静态方法中获取数据,该静态方法返回一个IEnumerable,并使用MemberData属性,如下面的示例所示:

[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>
{
    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 中。因此,它们必须通过安装特定的 NuGet 包来提供。Moq NuGet 包中可用的 Moq 框架是 .NET 和 .NET Core 中最流行的模拟框架。它相当容易使用,本节将简要介绍。

一旦我们安装了 NuGet 包,我们需要在测试文件中添加一个 using Moq 语句。模拟实现很容易定义,如下所示:

  var myMockDependency = new Mock<IMyInterface>();

可以通过 Setup/Return 方法对来定义模拟依赖对特定方法特定输入的行为,如下所示:

myMockDependency.Setup(x=>x.MyMethod(5)).Returns(10);

Return之后,我们可以放置另一个Setup/Return对,该对定义了同一方法的相同输入的不同行为或不同方法的行为。这样,我们可以指定无限数量的输入/输出行为。

而不是使用特定的输入值,我们也可以使用通配符来匹配特定类型,如下所示:

myMockDependency.Setup(x => x.MyMethod(It.IsAny<int>))
                  .Returns(10);

一旦配置了模拟依赖项,我们可以从其Object属性中提取模拟实例,并像使用实际实现一样使用它,如下所示:

var myMockedInstance=myMockDependency.Object;
...
myMockedInstance.MyMethod(10);

然而,模拟方法通常由被测试的代码调用,所以我们只需要提取模拟实例并将其用作测试中的输入。

我们还可以按照以下方式模拟属性和异步方法:

myMockDependency.Setup(x => x.MyProperty)
                  .Returns(42);
...
myMockDependency.Setup(p => p.MyMethodAsync(1))
                    .ReturnsAsync("aasas");
var res=await myMockDependency.Object
    .MyMethodAsync(1);

对于异步方法,Returns必须替换为ReturnsAsync

每个模拟实例都会记录对其方法和属性的调用,因此我们可以在测试中使用这些信息。以下代码展示了示例:

myMockDependency.Verify(x => x.MyMethod(1), Times.AtLeast(2))

前面的语句断言MyMethod方法至少被调用过两次,并带有给定的参数。还有Times.NeverTimes.Once(断言方法只被调用了一次)等更多选项。

到目前为止的 Moq 文档应该涵盖了您测试中可能出现的 99%的需求,但 Moq 还提供了更复杂的选择。进一步阅读部分包含了完整文档的链接。

下一节将展示如何在实践中定义单元测试以及如何在 Visual Studio 和 Azure DevOps 中运行它们,这得益于本书中的用例。

用例 - 在 DevOps Azure 中自动化单元测试

在本节中,我们将向我们在第十三章中构建的示例应用程序中添加一些单元测试项目,即展示 ASP.NET Core MVC。如果您没有,您可以从 GitHub 仓库中与本书相关的第十三章的展示 ASP.NET Core MVC部分下载它。GitHub 仓库中第四章的决定最佳云解决方案部分包含了本节将添加的代码以及所有添加这些代码的说明。

作为第一步,让我们复制解决方案文件夹并命名为PackagesManagementWithTests。然后打开解决方案并将其添加到名为PackagesManagementTest的 xUnit .NET Core C#测试项目中。最后,添加对 ASP.NET Core 项目(PackagesManagement)的引用,因为我们将要对其进行测试,并添加对Moq NuGet 包最新版本的引用,因为我们需要模拟功能。到这一点,我们就准备好编写测试了。

例如,我们将为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,因为我们必须测试的Edit方法是async。在这个测试中,我们不需要模拟对象,因为不会使用注入的对象。因此,作为测试的准备,我们只需要创建一个控制器实例,并且我们必须向ModelState添加一个错误,如下所示:

var controller = new ManagePackagesController();
controller.ModelState
    .AddModelError("Name", "fake error");

然后我们调用该方法,注入ViewModel和一个null命令处理程序作为其参数,因为命令处理程序将不会使用:

var vm = new PackageFullEditViewModel();
var result = await controller.Edit(vm, null);

在验证阶段,我们验证结果是否为ViewResult,并且它包含在控制器中注入的相同模型:

Assert.IsType<ViewResult>(result);
Assert.Equal(vm, (result as 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方法只返回一个完成的TaskTask.Complete)。要测试的方法使用ViewModel和模拟的处理程序调用:

var result = await controller.Edit(vm, 
    commandDependency.Object);

在这种情况下,验证代码如下:

commandDependency.Verify(m => m.HandleAsync(
    It.IsAny<UpdatePackageCommand>()), 
    Times.Once);
Assert.IsType<RedirectToActionResult>(result);
var redirectResult = result as RedirectToActionResult;
Assert.Equal(nameof(ManagePackagesController.Index), 
    redirectResult.ActionName);
Assert.Null(redirectResult.ControllerName);

作为第一步,我们验证命令处理程序确实被调用了一次。更好的验证还应包括检查它是否使用传递给动作方法的ViewModel调用命令。这可以通过从commandDependency.Invocations中提取此信息来完成。我们将将其作为练习。

然后我们验证动作方法返回带有正确动作方法名称的RedirectToActionResult,并且没有指定控制器名称。

一旦所有测试都准备好了,如果测试窗口没有出现在 Visual Studio 的左侧栏中,我们只需从 Visual Studio Test 菜单中选择运行所有测试项。一旦测试窗口出现,进一步的调用可以从该窗口内启动。

如果测试失败,我们可以在其代码中添加一个断点,这样我们就可以通过在测试窗口中右键单击它并选择调试选定的测试来启动一个调试会话。

以下步骤显示了如何将我们的解决方案与 Azure DevOps 存储库连接起来,我们将定义一个 Azure DevOps 管道,该管道构建项目并启动其测试。这样,从那天起,每天开发人员推送更改后,我们都可以启动管道来验证存储库代码是否编译并通过所有测试:

  1. 作为第一步,我们需要一个免费的 DevOps 订阅。如果你还没有,请通过点击此页面的“开始免费”按钮来创建一个:azure.microsoft.com/en-us/services/devops/。在这里,让我们定义一个组织,但在创建项目之前停止,因为我们将在 Visual Studio 内部创建项目。

  2. 确保你已使用 Azure 账户(与创建 DevOps 账户时使用的相同)登录到 Visual Studio。在此阶段,你可以通过右键单击解决方案并选择“配置持续交付到 Azure...”来为你的解决方案创建一个 DevOps 仓库。在出现的窗口中,一个错误消息会告知你你的代码尚未配置任何仓库:

图片

  1. 点击“现在添加到源控制”链接。之后,DevOps 屏幕将出现在 Visual Studio Team Explorer 标签中:

图片

  1. 点击“发布 Git 仓库”按钮后,系统会提示你选择你的 DevOps 组织和仓库名称。成功将代码发布到 DevOps 仓库后,DevOps 屏幕应如下所示:

图片

DevOps 屏幕显示了你的在线 DevOps 项目的链接。未来当你打开你的解决方案时,如果链接没有出现,请点击 DevOps 屏幕上的“连接”按钮或“管理连接”链接( whichever appears)来选择并连接你的项目。

  1. 点击此链接访问在线项目。一旦到达那里,如果你点击左侧菜单中的“Repos”项,你会看到你刚刚发布的仓库。

  2. 现在,点击“管道”菜单项以创建一个用于构建和测试你的项目的 DevOps 管道。在出现的窗口中,点击按钮以创建一个新的管道:

图片

  1. 你将被提示选择你的仓库位置:

图片

  1. 选择“Azure Repos Git”然后选择你的仓库。然后你将被提示关于项目类型的问题:

图片

  1. 选择 ASP.NET Core。将为你自动创建一个用于构建和测试项目的管道。通过将新创建的 .yaml 文件提交到你的仓库来保存它:

图片

  1. 可以通过选择“排队”按钮来运行管道,但由于 DevOps 标准管道是在仓库的 master 分支上触发的,因此每次提交此分支的更改或修改管道时,它都会自动启动。可以通过点击“编辑”按钮来修改管道:

图片

  1. 一旦进入编辑模式,可以通过点击每个步骤上出现的“设置”链接来编辑所有管道步骤。可以按照以下方式添加新的管道步骤:

    1. 在新步骤必须添加的位置写上task:,然后在你输入任务名称时接受出现的建议之一。

    2. 在你编写了一个有效的任务名称后,一个设置链接会出现在新步骤上方,点击它。

    3. 在出现的窗口中插入所需的任务参数并保存。

  2. 为了让我们的测试工作,我们需要指定定位包含测试的所有程序集的标准。在我们的案例中,因为我们有一个包含测试的唯一.dll文件,所以指定其名称就足够了。点击VSTest@2测试任务的设置链接,并将自动建议的测试文件字段的内容替换为以下内容:

**\PackagesManagementTest.dll
!**\*TestAdapter.dll
!**\obj\**
  1. 然后点击“添加”以修改实际的管道内容。一旦你在“保存并运行”对话框中确认了你的更改,管道就会被启动,如果没有错误,测试结果将被计算。在特定构建期间启动的测试结果可以通过在管道历史记录标签页中选择特定的构建,并点击出现的页面上的“测试”标签来分析。在我们的案例中,我们应该看到以下截图类似的内容:

图片

  1. 如果你点击管道页面的分析标签,你会看到关于所有构建的分析,包括关于测试结果的分析:

图片

  1. 点击分析页面的测试区域,我们可以获得所有管道测试结果的详细报告。

摘要

在本章中,我们解释了为什么自动化软件测试是值得的,然后我们关注了单元测试的重要性。我们还列出了所有测试类型及其主要特性,主要关注单元测试。我们分析了 TDD 的优势以及如何在实践中使用它。有了这些知识,你应该能够生产出既可靠又易于修改的软件。

最后,我们分析了适用于.NET Core 项目的所有测试工具,重点关注 xUnit 和 Moq 的描述,并展示了如何在 Visual Studio 和 Azure DevOps 中通过本书用例的实际应用中使用它们。

下一章将探讨如何测试和衡量代码的质量。

问题

  1. 为什么自动化单元测试是值得的?

  2. TDD 能够立即发现大多数错误的主要原因是什么?

  3. [Theory][Fact]属性在 xUnit 中的区别是什么?

  4. 在测试断言中使用了哪个 xUnit 静态类?

  5. 哪些方法允许定义 Moq 模拟的依赖项?

  6. 是否可以使用 Moq 模拟异步方法?如果是,如何?

进一步阅读

尽管章节中包含的 xUnit 文档相当完整,但它并没有包括 xUnit 提供的少数配置选项。完整的 xUnit 文档可在xunit.net/找到。MSTest 和 NUnit 的文档分别可在github.com/microsoft/testfxgithub.com/nunit/docs/wiki/NUnit-Documentation找到。

Moq 的完整文档可在github.com/moq/moq4/wiki/Quickstart找到。

这里有一些针对 Web 应用程序的性能测试框架的链接:

第十六章:使用工具编写更好的代码

正如我们在第十四章中看到的,C# 8 编码的最佳实践,编码可以被认为是一门艺术,但编写可理解的代码更像是哲学。在前述章节中,我们讨论了作为软件架构师,你需要为你的开发者提供的实践。在本章中,我们将描述代码分析的技术和工具,以便你为你的项目编写高质量的代码。

本章将涵盖以下主题:

  • 识别写得好的代码

  • 理解在过程中可以使用哪些工具来使事情变得更简单

  • 一本书的使用案例——在发布应用程序之前实施代码审查

到本章结束时,你将能够定义你打算将哪些工具纳入你的软件开发生命周期以实现代码分析。

技术要求

本章需要 Visual Studio 2017 或 2019 免费社区版或更高版本。您可以在github.com/PacktPublishing/Hands-On-Software-Architecture-with-CSharp-8/tree/master/ch16找到本章的示例代码。

识别写得好的代码

定义代码是否写得好的标准并不容易。在第十四章中描述的最佳实践,C# 8 编码的最佳实践,当然可以指导你作为软件架构师为你的团队定义一个标准。但是,即使有了标准,错误仍然会发生,你可能会在生产代码中才发现它们。仅仅因为代码没有遵循你定义的所有标准就决定在生产中重构代码,这是一个不容易做出的决定,尤其是如果这段代码运行正常的话。有些人认为,写得好的代码是那些在生产中运行良好的代码。然而,这无疑会损害软件的生命周期,因为开发者可能会受到那些非标准代码的启发。

因此,作为软件架构师的你,需要找到方法来预测你定义的编码标准可能不会被应用的情况。幸运的是,如今我们有许多工具选项可以帮助我们完成这项任务。它们被认为是静态代码分析的自动化;这项技术被视为提高开发的软件和帮助开发者的一个巨大机会。

你的开发者将通过代码分析而不断进步的原因是,你在代码审查过程中开始在他们之间传播知识。我们现在拥有的工具具有相同的目的。而且,有了 Roslyn,它们在你编写代码的同时完成这项任务。Roslyn 是.NET 的编译器平台,它使你能够开发一些用于分析代码的工具。这些分析器可以检查样式、质量、设计和其他问题。

例如,看看以下代码。它没有任何意义,但你可以看到有一些错误:

using System;
namespace SampleCodeChapter16
{
    class Program
    {
        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 2019 确实比 Visual Studio 2017 等提供了更多此类目的的工具。

作为软件架构师,您需要处理的一个问题是团队的编码风格。这当然有助于更好地理解代码。例如,如果您转到 Visual Studio 菜单 | 工具 | 选项,您将找到设置不同代码风格模式的方法,并且它甚至将不良编码风格作为“代码风格”选项中的错误指示,如下所示:

图片

例如,前面的屏幕截图表明“避免未使用参数”被视为错误。在此更改之后,本章开头展示的相同代码的编译结果不同,如以下屏幕截图所示:

图片

您可以将您的编码风格配置导出并附加到项目中,这样它就会遵循您定义的规则。

Visual Studio 2019 提供的另一个好工具是分析和代码清理。在这个工具中,您可以设置一些可以从代码中清理出来的代码标准。例如,在以下屏幕截图中,它被设置为删除不必要的代码:

图片

在解决方案资源管理器区域中选择代码清理的方式,正如您在下面的屏幕截图中所见。这个过程将在您所有的代码文件中运行:

图片

解决了代码风格和代码清理工具指示的错误后,我们正在处理的示例代码有一些最小化简化,如下所示:

using System;
namespace SampleCodeChapter16
{
    class Program
    {
        static void Main()
        {
            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 中来启用额外的工具。这些工具可以帮助您提高代码质量,因为其中一些工具是专门用于代码分析的。本节将列出一些免费选项,以便您可以选择最适合您需求的选项。当然,还有其他选项,甚至付费选项。这里的想法不是指出一个特定的工具,而是给您一个它们能力的大致了解。

要安装这些扩展,您需要在 Visual Studio 中找到菜单。这里有一个扩展管理器的屏幕截图:

图片

有许多其他酷炫的扩展可以提高您代码和解决方案的生产力和质量。您可以在该管理器中搜索它们。

在你选择要安装的扩展之后,你需要重新启动 Visual Studio。大多数扩展在安装后都很容易识别,因为它们会修改 IDE 的行为。

应用扩展工具分析代码

尽管在代码样式和代码清理工具之后提供的示例代码比我们在本章开头展示的要好,但它显然远未达到第十四章中讨论的最佳实践,即C# 8 编码最佳实践。在接下来的几节中,你将能够检查三个可以帮助你改进代码的扩展的行为:Microsoft Code Analysis 2019、SonarLint for Visual Studio 2019 和 Code Cracker for Visual Studio 2017。

使用 Microsoft Code Analysis 2019

这个扩展由 Microsoft DevLabs 提供,是对我们过去用来自动化的 FxCop 规则的升级。基本上,它有超过 100 条规则用于在编写代码时检测问题。

例如,只需启用扩展并重新构建我们在本章中使用的这个小示例,代码分析就找到了一个新的问题需要解决,如下面的截图所示:

图片

值得注意的是,我们在第十四章中讨论了空try-catch语句作为反模式的使用。因此,如果能够像这样暴露这类问题,对代码的健康状况将大有裨益。

在 Visual Studio 2019 中应用 SonarLint

SonarLint 是 Sonar Source 社区发起的一个开源项目,旨在你在编码时检测错误和质量问题。它支持 C#、VB .NET、C、C++ 和 JavaScript。这个扩展的伟大之处在于它附带了解决检测到的问题的解释,这就是为什么我们说开发者在使用这些工具的同时学会了如何编写良好的代码。查看以下截图,其中展示了在示例代码中进行的分析:

图片

我们可以验证这个扩展能够指出其他错误,并且正如它们承诺的那样,每个警告都有解释。这不仅仅对于检测问题很有用,而且对于培养开发者的良好编码实践也很有帮助。

将 Code Cracker 用于 Visual Studio 2017,作为编写更好代码的辅助工具

Code Cracker 是另一个使用 Roslyn 分析代码的工具,它是由一些 Microsoft MVP 发起的,也是开源的。根据 Visual Studio Marketplace,使用这个工具的人比使用其他任何工具的人都要多:

图片

值得注意的是,这个工具中检测到了其他工具中没有发现的新规则。一个扩展到另一个扩展之间的代码分析差异的原因可能是编程的规则并不相同。

在分析后检查最终代码

在分析三个扩展之后,我们最终解决了所有提出的问题。我们可以检查最终的代码,如下所示:

using System;
namespace SampleCodeChapter16
{
    static class Program
    {
        static void Main()
        {
            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的默认值已经编程。这种模式在第十四章编码 C# 8 的最佳实践中也进行了讨论,该章节总结说,最佳实践可以很容易地通过使用本章中提到的(或所有)扩展之一(或全部)来遵循。

用例 - 在发布应用程序之前评估 C#代码

在第三章,“使用 Azure DevOps 记录需求”,我们在平台上创建了 WWTravelClub 仓库。正如我们看到的,Azure DevOps 支持持续集成,这非常有用。在本节中,我们将讨论更多关于 DevOps 概念和 Azure DevOps 平台为何如此有用的原因。

目前,我们想要介绍的唯一事情是分析开发者在提交代码后但尚未发布之前代码的可能性。在当今的 SaaS 应用程序生命周期工具领域,这仅得益于我们拥有的一些 SaaS 代码分析平台。用例将使用 Sonar Cloud。

Sonar Cloud 对开源代码是免费的,并且可以分析存储在 GitHub、Bitbucket 和 Azure DevOps 中的代码。注册需要这些平台的一个用户。一旦您登录,您就可以按照以下文章中描述的步骤创建 Azure DevOps 和 Sonar Cloud 之间的连接:sonarcloud.io/documentation/analysis/scan/sonarscanner-for-azure-devops/

通过在 Azure DevOps 项目和 Sonar Cloud 之间建立连接,您将拥有以下所示的构建管道:

截图

值得注意的是,C#项目没有 GUID 编号,而 Sonar Cloud 需要这个编号。您可以使用此链接(www.guidgenerator.com/)轻松生成一个,并将其放置如下截图所示:

截图

一旦您完成构建,代码分析的结果将在 Sonar Cloud 中展示,如下一截图所示。如果您想导航到该项目,可以访问:sonarcloud.io/dashboard?id=WWWTravelClub

截图

此外,到这个时候,已经分析的代码还没有发布。因此,这可以在发布系统之前获得下一步的质量提升非常有用。您可以将这种方法作为在提交代码时自动进行代码分析的参考。

摘要

本章介绍了可以用来应用第十四章中描述的编码最佳实践的工具体现,即《C# 8 编码最佳实践》。我们探讨了 Roslyn 编译器,它允许在开发者编码的同时进行代码分析,并探讨了用例——在发布应用程序之前评估 C#代码,这在使用 Sonar Cloud 的 Azure DevOps 构建过程中实现了代码分析。

一旦将本章所学的一切应用到你的项目中,代码分析将为你提供改进交付给客户代码质量的机会。这是软件架构师的一个非常重要的角色。

在下一章中,我们将使用 Azure DevOps 部署你的应用程序。

问题

  1. 如何描述软件为编写良好的代码?

  2. Roslyn 是什么?

  3. 代码分析是什么?

  4. 代码分析的重要性是什么?

  5. Roslyn 如何帮助进行代码分析?

  6. 什么是 Visual Studio 扩展?

  7. 展示的代码分析扩展工具是什么?

进一步阅读

这些是一些你可以找到本章主题更多信息的网站:

第五部分:持续且高质量地交付软件

本书最后一部分将指导你如何使用 DevOps 原则进行持续集成CI)和持续部署CD)来交付软件。在软件服务领域,最大的玩家在过去的几年中已经改变了软件交付的概念。因此,本节将讨论新兴的新颖和革命性的思维方式。

在第十七章,使用 Azure DevOps 部署您的应用程序中,我们将讨论软件交付的新哲学,以及 Azure DevOps 如何帮助你接受这种新方法,并掌握构建和部署管道原则。

在第十八章,理解 DevOps 原则中,我们将介绍 DevOps 的主要概念,这是大家都在学习和实践的过程。此外,本章还将展示使用 DevOps 开发和交付软件的工具。

然后,在第十九章,在 DevOps 中应用 CI 场景的挑战,重点将转向与持续集成和部署解决方案相关的挑战。目的是确保你了解风险并遵循最佳实践,在使用 CI 时。

第二十章,软件测试自动化专注于软件测试自动化。由于测试是一个不可避免(且重复)的过程,其自动化至关重要。本章将教你如何为项目编写自动化的功能测试用例。

本节包括以下章节:

  • 第十七章,使用 Azure DevOps 部署您的应用程序

  • 第十八章,理解 DevOps 原则

  • 第十九章,在 DevOps 中应用 CI 场景的挑战

  • 第二十章,软件测试自动化

第十七章:使用 Azure DevOps 部署你的应用程序

本章重点介绍所谓的服务设计思维,即牢记你正在设计的软件作为提供给组织/组织部分的服务。这种方法的主要收获是,最高优先级是软件为目标组织带来的价值。此外,你提供的不仅仅是可工作的代码和修复错误的协议,而是一套满足软件所设想的所有需求的解决方案。换句话说,你的工作包括满足这些需求所需的一切,例如监控用户满意度,并在用户需求发生变化时调整软件。

最后,监控软件以揭示问题和新的需求,并快速修改以适应不断变化的需求要容易得多。

服务设计思维严格与软件即服务SaaS)模型相关联,我们在第四章中讨论了该模型,决定最佳基于云的解决方案。事实上,提供基于 Web 服务的解决方案最简单的方法是提供作为服务的 Web 服务使用,而不是销售实现它们的软件。

更具体地说,本章涵盖了以下主题:

  • 理解 SaaS

  • 为服务场景准备解决方案

  • 用例 - 使用 Azure Pipelines 部署我们的包管理应用程序

到本章结束时,你将能够根据服务设计思维原则设计软件,并使用 Azure Pipelines 部署你的应用程序。

技术要求

本章需要 Visual Studio 2017 或 2019 免费社区版或更高版本,并安装所有数据库工具。它需要一个免费的 Azure 账户。如果你还没有创建一个,第一章中“理解软件架构的重要性”的创建 Azure 账户小节解释了如何创建。本章使用与第十五章相同的代码,使用单元测试用例和 TDD 测试你的代码,可在以下链接找到:github.com/PacktPublishing/Hands-On-Software-Architecture-with-CSharp-8

理解 SaaS

将软件作为服务销售/使用与一套更广泛的服务设计思维原则相关联。服务设计思维不仅仅是一种软件开发技术/或软件部署方法,它还影响几个业务领域,即组织与人力资源、软件开发流程,最后是硬件基础设施和软件架构。

在接下来的小节中,我们将简要讨论我们列出的每个业务领域的含义,在最后一个小节中,我们将专注于 SaaS 部署模型。

使你的组织适应服务场景

第一个组织影响来自优化软件对目标组织价值的需要。这需要一个人力资源或团队——负责计划和监控软件在目标组织中的影响——以最大化软件带来的增值。这个战略角色不仅在初始设计阶段需要,在整个应用程序的生命周期中都需要。实际上,这个角色负责保持软件与目标组织不断变化的需求精确匹配。

另一个重要的影响领域是人力资源管理。事实上,由于主要优先考虑的是软件带来的增值,而不是利用现有资源和能力,因此人力资源必须适应项目需求。这意味着一旦需要就获取新资源,并通过新的人力资源和/或对现有资源的适当培训来发展所需的能力。

下一个子节将讨论所有涉及软件开发过程的含义。

在服务场景中开发软件

影响软件开发流程的主要约束是保持软件与组织需求精确匹配的需求。这一需求可以通过基于 CI/CD 方法的任何敏捷方法来满足。对于 CI/CD 的简要回顾,请参阅第三章“使用 Azure DevOps 组织工作”部分,文档化需求,而对于 CI/CD 的详细讨论,请参阅第十七章“使用 Azure DevOps 部署您的应用程序”,该章节完全致力于 CI/CD。值得注意的是,任何设计良好的 CI/CD 周期都应该包括处理用户反馈和用户满意度报告。

此外,为了优化软件带来的增值,组织开发团队(或其一部分)与系统用户紧密接触的阶段是一个好的做法,这样开发者可以更好地理解软件对目标组织的影响。

最后,在编写功能性和非功能性需求时,必须始终牢记软件带来的增值。因此,用考虑为什么如何它们对增值做出贡献的原因来注释用户故事是有用的。收集需求的过程在第二章“功能性和非功能性需求”中讨论。

下一个子节将讨论更多技术影响。

服务场景的技术影响

在服务场景中,硬件基础设施和软件架构都受到以下三个主要原则的限制,这些原则是保持软件与组织需求精确匹配的必然结果,具体如下:

  • 需要监控软件以发现可能由系统故障或软件使用和/或用户需求变化引起的任何问题。这意味着从所有硬件/软件组件中提取健康检查和负载统计信息。用户执行的操作统计信息也能提供有关组织需求变化的良好线索——更具体地说,用户和应用程序在每个操作实例上花费的平均时间以及每单位时间内(日、周或月)执行的操作实例数量。

  • 还需要监控用户满意度。可以通过在每个应用程序屏幕上添加一个链接到易于填写的用户满意度报告页面来获取用户满意度的反馈。

  • 最后,需要快速适应硬件和软件,既要适应每个应用程序模块接收到的流量,也要适应组织需求的变更。这意味着以下内容:

    • 极端关注软件模块化

    • 保持对数据库引擎变化的开放态度,并优先考虑基于 SOA 或微服务架构的解决方案,而不是单体软件

    • 保持对新技术的开放态度

使硬件易于适应意味着允许硬件扩展,这反过来又意味着采用云基础设施、硬件集群或两者兼而有之。同时,保持对云服务供应商变化的开放态度也很重要,这反过来意味着将依赖云平台封装在少数几个软件模块中。

通过选择每个模块实现的最佳技术,可以最大化软件增加的价值,这反过来意味着能够混合不同的技术。这正是基于容器的技术,如 Docker,发挥作用的地方。Docker 和相关技术已在第五章中描述,即将微服务架构应用于您的企业应用

总结来说,我们所列出的所有要求都汇聚于本书中描述的大多数先进技术,例如云服务、可扩展的 Web 应用、分布式/可扩展数据库、Docker、SOA 和微服务架构。

关于如何为服务环境准备软件的更多细节将在下一节中给出,而下一小节将专门讨论 SaaS 应用的优缺点。

采用 SaaS 解决方案

SaaS 解决方案的主要吸引力在于其灵活的支付模式,它提供了以下优势:

  • 您可以避免为了更实惠的月度支付而放弃大额投资。

  • 您可以从一个便宜的系统开始,然后在业务增长时再转向更昂贵的解决方案。

然而,SaaS 解决方案还提供了其他优势,具体如下:

  • 在所有云解决方案中,您可以轻松扩展您的解决方案。

  • 应用程序会自动更新。

  • 由于 SaaS 解决方案是通过公共互联网提供的,因此可以从任何位置访问。

不幸的是,SaaS 的优势是有代价的,因为 SaaS 也存在不可忽视的劣势,具体如下:

  • 您的业务与 SaaS 提供商紧密相连,这可能导致服务被终止或以您不再接受的方式修改。

  • 通常,您无法实施任何类型的定制,只能限制于 SaaS 供应商提供的少数标准选项。然而,有时 SaaS 供应商也提供添加自定义模块的可能性,这些模块可以由他们或您编写。

总结来说,SaaS 解决方案提供了有趣的优势,但也存在一些劣势,因此作为软件架构师,您必须进行详细分析以决定如何采用它们。

下一个部分将解释如何将软件适应用于服务场景。

准备服务场景的解决方案

首先,为服务场景准备解决方案意味着专门为云和/或分布式环境设计它。反过来,这意味着在设计时考虑到可扩展性、容错性和自动故障恢复。

前三点的主要影响是关于如何处理状态。无状态模块实例易于扩展和替换,因此您应仔细规划哪些模块是无状态的,哪些有状态。此外,如第七章“如何在云中选择您的数据存储”中所述,您必须记住写入和读取操作以完全不同的方式扩展。特别是,读取操作可以通过复制更容易扩展,而写入操作在关系型数据库中扩展不佳,通常需要 NoSQL 解决方案。

在分布式环境中,高可扩展性防止了分布式事务和同步操作的使用。因此,数据一致性和容错性只能通过基于异步消息的更复杂技术来实现,如下所示:

  • 一种技术是将所有要发送的消息存储在队列中,以便在发生错误或超时的情况下可以重试异步传输。消息可以在收到确认接收或模块决定终止产生消息的操作时从队列中删除。

  • 另一个问题是处理同一消息被多次接收的可能性,因为超时导致同一消息被多次发送。

  • 如果需要,使用乐观并发和事件溯源等技术来最小化数据库中的并发问题。乐观并发在第十三章的“数据层”子部分中进行了解释,该部分位于“展示 ASP.NET Core MVC”用例的末尾,而事件溯源则在第十章的“使用 SOLID 原则映射您的领域”部分中与其他数据层内容一起进行了描述。

前面的列表中的前两点与第五章的“如何.NET Core 处理微服务?”部分中讨论的其他分布式处理技术一起进行了详细讨论,该部分标题为“将微服务架构应用于您的企业应用程序”。

容错和自动故障恢复要求软件模块实现健康检查接口,云框架可能会调用这些接口以验证模块是否正常工作,或者是否需要将其终止并由另一个实例替换。ASP.NET Core 和所有 Azure 微服务解决方案都提供现成的基本健康检查,因此开发者不需要关心这些。然而,可以通过实现一个简单的接口添加更详细的自定义健康检查。

如果你的目标是可能更改某些应用程序模块的云提供商,那么难度会增加。在这种情况下,必须将云平台依赖封装在仅几个模块中,并且必须丢弃过于严格绑定到特定云平台解决方案。因此,例如,你应该避免使用有状态的/无状态的本地 Service Fabric 服务,因为它们的架构是特定于 Azure Service Fabric 的,所以它们不能移植到不同的云平台。

如果你的应用程序是为服务场景设计的,那么一切都必须自动化:新版本的测试和验证、创建应用程序所需的整个云基础设施,以及在该基础设施上部署应用程序。

所有云平台都提供语言和工具来自动化整个软件 CI/CD 周期,即构建代码、测试代码、触发手动版本批准、创建硬件基础设施以及部署应用程序。

Azure Pipelines 允许自动化所有列出的步骤。在第十五章的“使用单元测试用例和 TDD 测试您的代码”用例中展示了如何使用 Azure Pipelines 自动化包括软件测试在内的所有步骤。下一节的用例将展示如何自动化在 Azure Web 应用平台上的应用程序部署。

自动化在 SaaS 应用程序中扮演着更基本的角色,因为每个新客户的整个新租户的创建必须由客户订阅自动触发。更具体地说,多租户 SaaS 应用程序可以通过三种基本技术实现:

  • 所有客户共享相同的基础设施和数据存储。这个解决方案最容易实施,因为它只需要实现一个标准 Web 应用程序。然而,它仅适用于非常简单的 SaaS 服务,因为对于更复杂的应用程序,始终更难确保存储空间和计算时间在用户之间平均分配。此外,随着数据库变得越来越复杂,始终更难确保不同用户的数据安全隔离。

  • 所有客户共享相同的基础设施,但每个客户都有自己的数据存储。这个选项解决了之前解决方案中所有的数据库问题,并且由于创建一个新的租户只需要创建一个新的数据库,因此它很容易自动化。此解决方案提供了一种简单的方式来定义定价策略,通过将其与存储消耗相关联。

  • 每个客户都有自己的私有基础设施和数据存储。这是最灵活的策略。从用户的角度来看,它的唯一缺点是价格更高。因此,它仅适用于每个用户所需的计算能力达到最低阈值以上。由于必须为每个新客户创建整个基础设施,并在其上部署应用程序的新实例,因此自动化更困难。

无论选择哪三种策略中的哪一种,你都需要有随着消费者增加而扩展你的云资源的能力。

如果你还需要确保你的基础设施创建脚本可以在多个云提供商之间工作,那么一方面,你不能使用过于特定于单个云平台的特性,另一方面,你需要一种独特的、可以翻译成更常见云平台本地语言的、用于创建基础设施的语言。Terraform 和 Ansible 是描述硬件基础设施的两个非常常见的选择。

用例 - 使用 Azure Pipelines 部署我们的包管理应用程序

在本节中,我们将配置一个自动部署到 Azure App Service 平台,用于我们已经在第十五章的用例中定义的 DevOps 项目,即“使用单元测试用例和 TDD 测试你的代码”。Azure DevOps 还可以自动创建一个新的 Web 应用程序,但为了避免配置错误(这可能会消耗掉你所有的免费额度),我们将手动创建它,并让 Azure DevOps 仅部署应用程序。所有必需的步骤都组织成以下各个子节。

创建 Azure Web 应用程序和 Azure 数据库

可以通过以下简单步骤定义 Azure Web App:

  1. 前往 Azure 门户,选择 App Services,然后点击“添加”按钮创建一个新的 Web App。按照以下方式填写所有数据:

图片

  1. 显然,你可以使用你已有的资源组,以及对你来说最方便的区域。对于运行时堆栈,请选择你在 Visual Studio 解决方案中使用的相同的 .NET Core 版本。

  2. 现在,如果你有足够的信用额度,让我们为应用程序创建一个 SQL Server 数据库,并将其命名为 PackagesManagementDatabase。如果你没有足够的信用额度,不用担心——你仍然可以测试应用程序部署,但应用程序在尝试访问数据库时会返回错误。请参考第七章 7.7 的 关系数据库 子节,如何在云中选择你的数据存储,了解如何创建 SQL Server 数据库。

配置你的 Visual Studio 解决方案

一旦你定义了 Azure Web App,你需要按照以下简单步骤配置应用程序以在 Azure 中运行:

  1. 如果你定义了 Azure 数据库,你需要在 Visual Studio 解决方案中使用两个不同的连接字符串,一个用于本地数据库的开发,另一个用于 Web 应用的 Azure 数据库。

  2. 现在,在 Visual Studio 解决方案中打开 appsettings.Development.jsonappsettings.json,如下所示:

图片

  1. 然后,将 appsettings.json 中的整个 ConnectionStrings 节复制到 appsettings.Development.json 中,如下所示:
"ConnectionStrings": {
        "DefaultConnection": "Server=(localdb)....."
},

现在你已经有了开发设置中的本地连接字符串,因此你可以将 appsettings.json 中的 DefaultConnection 改为 Azure 数据库中的一个。

  1. 前往 Azure 门户中的数据库,复制连接字符串,并用你在定义数据库服务器时获得的用户名和密码填充它。

  2. 最后,在本地提交你的更改,然后与远程仓库同步。现在,你的更改已经在 DevOps 管道中,它正在处理这些更改以获取新的构建。

配置 Azure 管道

最后,你可以按照以下步骤配置 Azure 管道,以自动将你的应用程序部署到 Azure:

  1. 通过点击 Visual Studio Team Server 窗口的“连接”选项卡中的“管理连接”链接,将 Visual Studio 与你的 DevOps 项目连接起来。然后,点击 DevOps 链接进入你的在线项目。

  2. 通过在单元测试步骤之后添加一个额外的步骤来修改 PackagesManagementWitTests 构建管道。实际上,我们需要一个步骤来准备所有要部署的文件,并将它们打包成一个 ZIP 文件。

  3. 点击 PackagesManagementWitTests 管道的“编辑”按钮,然后前往文件末尾并写下以下内容:

- task: PublishBuildArtifacts@1
  1. 当新任务上方出现“设置”链接时,点击它以配置新任务:

图片

  1. 接受默认的发布路径,因为它已经与将要部署应用程序的任务路径同步,只需插入工件名称,然后选择“Azure Pipeline”作为位置。一旦保存,管道将开始,新添加的任务应该会成功。

  2. 部署和其他发布工件被添加到不同的管道中,称为发布管道,以将它们与构建相关工件解耦。使用发布管道,您无法编辑.yaml文件,但您将使用图形界面进行操作。

  3. 点击“发布”左侧菜单标签以创建新的发布管道。一旦您点击添加新管道,系统将提示您添加第一个管道阶段的第一项任务。实际上,整个发布管道由不同的阶段组成,每个阶段都包含一系列任务。虽然每个阶段只是一系列任务,但阶段图可以分支,我们可以在每个阶段之后添加几个分支。这样,我们可以部署到不同的平台,每个平台都需要不同的任务。在我们的简单示例中,我们将使用单个阶段。

  4. 选择“部署 Azure 应用服务”任务。一旦添加此任务,系统将提示您填写缺失信息:

图片

  1. 点击错误链接并填写缺失的参数:

图片

  1. 选择您的订阅,然后,如果出现授权按钮,请点击它以授权 Azure Pipelines 访问您的订阅。然后,选择“Windows”作为部署平台,最后,从“应用服务名称”下拉列表中选择您创建的应用服务。任务设置在您编写时自动保存,因此您只需点击“保存”按钮即可保存整个管道。

  2. 现在,我们需要将此管道连接到源工件。点击“添加工件”按钮,然后选择“构建”作为源类型,因为我们需要将新的发布管道与构建管道创建的 ZIP 文件连接。一个设置窗口将出现:

图片:

  1. 从下拉列表中选择我们之前的构建管道,并保持“最新”作为版本。最后,接受在“源别名”中建议的名称。

我们的发布管道已准备就绪,可以直接使用。您刚刚添加的源工件图像在其右上角包含一个触发图标,如下所示:

图片

如果您点击触发图标,您可以选择在新构建可用时自动触发发布管道:

图片

保持其禁用状态;我们可以在完成并手动测试发布管道后启用它。为了准备自动触发,我们需要在应用程序部署之前添加一个人工批准任务。

添加发布的人工批准

由于任务通常由软件代理执行,我们需要在手动工作中嵌入人工批准。让我们按照以下步骤添加:

  1. 点击阶段 1 标题右侧的三个点:

图片

  1. 然后,选择添加一个无代理作业。一旦添加了无代理作业,点击其添加按钮并添加一个“人工干预”任务。以下截图显示了“人工干预”设置:

图片

  1. 在“通知用户”字段中添加操作员的说明并选择你的账户。

  2. 现在,用鼠标拖动整个“无代理作业”,将其放置在应用程序部署任务之前。最终的截图应该是这样的:

图片

  1. 完成!点击左上角的保存按钮保存管道。

现在,一切准备就绪,我们可以创建我们的第一个自动发布。

创建发布

一旦一切准备就绪,可以按照以下步骤准备和部署新版本:

  1. 让我们点击“创建发布”按钮开始创建新版本:

图片

  1. 确认“源别名”是最后一个可用的,添加发布描述,然后点击创建。在短时间内,你应该会收到发布批准的电子邮件。点击其中的链接,进入批准页面:

图片

  1. 点击“恢复/拒绝”按钮然后批准发布。等待部署完成。你应该看到所有任务都成功完成,如下面的截图所示:

图片

你已经运行了第一个成功的发布管道!

在实际项目生活中,发布管道将包含更多任务。事实上,应用程序(在部署到实际生产环境之前)是在预发布环境中部署的,在那里它们进行 beta 测试。因此,在这次首次部署之后,可能会有一些手动测试,手动授权在生产中的部署,以及最终的在生产环境中的部署。

摘要

我们描述了服务设计思维原则和 SaaS 软件部署模型。现在,你应该能够分析这些方法对一个组织的影响,并且应该能够调整现有的软件开发流程和软硬件架构,以利用它们提供的机会。

我们还解释了软件周期自动化、云硬件基础设施配置和应用程序部署的必要性和涉及的技术。

一旦你在最后的使用案例部分实现了示例,你应该能够使用 Azure Pipelines 来自动化基础设施配置和应用程序部署。

下一章将更深入地探讨 DevOps,它与详细讨论在第十九章中的 CI/CD,即在 DevOps 中应用 CI 场景的挑战,在服务场景中起着基本的作用,尤其是在 SaaS 应用程序的维护方面。

问题

  1. 服务设计思维的主要目标是什么?

  2. 服务设计思维是否真的需要充分利用公司现有的所有能力?

  3. 为什么在 SaaS 应用程序的生命周期中,完全自动化是基本的?

  4. 是否可以使用平台无关的语言定义硬件云基础设施?

  5. 在整个应用程序生命周期自动化中,Azure 优先使用的工具是什么?

  6. 如果两个 SaaS 供应商提供相同的软件产品,你应该选择最可靠的还是最便宜的?

  7. 可扩展性是否是服务场景中唯一重要的要求?

进一步阅读

本章的主要参考文献是本书其他章节/部分的引用,并在本章中已给出。在此,我们仅提供 Azure Pipelines 文档的链接:docs.microsoft.com/en-us/azure/devops/pipelines/?view=azure-devops,以及本章中提到的两种基础设施描述语言,Terraform (www.terraform.io/) 和 Ansible (www.ansible.com/)。

第十八章:理解 DevOps 原则

DevOps 是当今每个人都正在学习和实践的过程。但作为一名软件架构师,你需要理解并推广 DevOps,不仅作为一个过程,而且作为一个哲学。本章将涵盖你需要用 DevOps 开发和交付软件的主要概念、原则和工具。

本章将涵盖以下主题:

  • 描述 DevOps 是什么,并查看在 WWTravelClub 项目中如何应用的示例

  • 理解 DevOps 原则和部署阶段以利用部署过程

  • 学习可以与 Azure DevOps 一起使用以改进测试和反馈的 DevOps 工具

与其他章节不同,WWTravelClub 项目将在本章节的主题中介绍,我们将在章节末尾提供一个结论,给你机会了解这一哲学如何得到实施。所有展示 DevOps 原则的截图都来自本书的主要样本,因此你将能够轻松理解 DevOps 原则。

技术要求

本章需要 Visual Studio 2019 社区版或更高版本。你可能还需要一个 Azure DevOps 账户,如第三章中所述,使用 Azure DevOps 记录需求

描述 DevOps

DevOps 一词来源于开发运维的合并,因此这个过程简单地将这些领域的行动统一起来。然而,当你开始更深入地了解它时,你会意识到仅仅连接这两个领域还不足以实现这一哲学的真正目标。

我们也可以说,DevOps 是回答人类当前关于软件交付需求的流程。

唐纳·布朗(Donovan Brown),微软首席 DevOps 经理,对 DevOps 有一个精彩的定义:donovanbrown.com/post/what-is-devops

使用流程、人员和产品持续地向我们的最终用户提供价值——这是 DevOps 哲学的最佳描述。我们需要开发和交付以客户为导向的软件。一旦公司的所有领域都明白关键点是最终用户,作为软件架构师的你,任务就是展示将促进交付过程的技术。

值得注意的是,本书的所有内容都与这一方法相关联。这从来不是知道一堆工具和技术的问题。作为一名软件架构师,你必须理解,这始终是一种将更快地满足最终用户需求的方式,与他们的实际需求相联系。因此,你需要学习 DevOps 原则,这些原则将在本章中讨论。

理解 DevOps 原则

将 DevOps 视为一个哲学,值得提到的是,有一些原则可以确保你的团队能够良好地工作。这些原则是持续集成、持续交付和持续反馈。

更多信息,请访问:azure.microsoft.com/en-us/overview/what-is-devops/

在许多书籍和技术文章中,DevOps 概念用无穷大的符号表示。这个符号代表在软件开发生命周期中必须有一个持续的方法。在这个过程中,你需要规划、构建、持续集成、部署、运营、获取反馈,然后从头开始。这个过程必须是协作的,因为每个人的焦点都是相同的——向最终用户提供价值。与这些原则一起,作为软件架构师,你需要决定最适合这种方法的最佳软件开发流程。我们在第一章中讨论了这些流程,《理解软件架构的重要性》。

定义持续集成

当你开始构建企业级解决方案时,协作是更快完成任务和满足用户需求的关键。正如我们在第十四章中讨论的,《C# 8 最佳实践》中的版本控制系统对于这个过程至关重要,但工具本身并不能完成这项工作,尤其是如果工具配置不当的话。

作为一名软件架构师,持续集成CI)将帮助你有一个具体的软件开发协作方法。当你实施它时,一旦开发者提交代码,主代码就会自动构建和测试。

当你应用它时,好处是可以激励开发者尽可能快地合并他们的更改,以最小化合并冲突。此外,他们可以共享单元测试,这将提高软件的质量。

在 Azure DevOps 中设置 CI 非常简单。在构建管道中,你可以通过编辑配置来找到它的选项,如下面的截图所示:

图片

值得注意的是,如果你有一个包含单元测试和功能测试的解决方案集,一旦你提交代码,它就会自动编译和测试。这将使你的主分支在每次团队提交时都保持稳定和安全。

CI 的关键点是能够更快地识别问题。当你允许代码被他人测试和分析时,你将有机会这样做。DevOps 方法唯一能帮助的是确保这一切尽可能快地发生。

理解 Azure DevOps 中的持续交付和多阶段环境

一旦您的应用程序的每个提交都构建完成,并且这段代码经过单元和功能测试,您可能还希望进行持续部署。这样做不仅仅是配置工具的问题。作为一名软件架构师,您需要确保团队和流程已经准备好进入这一步骤。

持续交付CD)相关的方法需要保证每次新部署时生产环境的安全。为此,需要采用多阶段管道。以下截图显示了为此目的而采用的一些常见阶段的示例:

截图

使用 Azure DevOps 的发布阶段

如您所见,这些阶段是通过 Azure DevOps 发布管道配置的。每个阶段都有自己的目的,这将最终提高交付产品的质量。让我们看看这些阶段:

  • 开发/测试:这个阶段由开发人员和测试人员用来构建新的功能。这个环境肯定会是最容易暴露出 bug 和不完整功能的。

  • 质量保证:这个环境向与开发和测试无关的团队区域提供新功能的简要版本。项目经理、市场营销、供应商和其他人可以使用它作为研究、验证甚至预生产的区域。此外,开发和质量团队可以保证新版本正确部署,考虑到功能和基础设施。

  • 生产:这是客户运行其解决方案的阶段。根据 CD,良好生产环境的目标是尽可能快地更新。频率将根据团队规模而变化,但有一些方法,这个过程每天发生不止一次。

部署您的应用程序采用三个阶段将对解决方案的质量产生影响。此外,它将使团队能够有一个更安全的部署过程,风险更少,产品稳定性更好。这种做法一开始可能看起来有点昂贵,但如果没有它,不良部署的结果通常会比这种投资更昂贵。

除了所有的安全性之外,您还必须考虑多阶段场景。您可以设置管道,只有通过定义的授权才能从一个阶段过渡到另一个阶段:

截图

如前一个截图所示,设置预部署条件相当简单,您可以在下面的截图看到,有多个选项可以自定义授权方法。这为您提供了细化 CD 方法的可能性,以满足您正在处理的项目需求。

以下截图显示了 Azure DevOps 为预部署批准提供的选项。你可以定义可以批准阶段的人员并为他们设置策略,即在进行过程之前重新验证审批者身份。作为软件架构师,你需要确定适合你使用这种方法创建的项目配置:

截图

值得注意的是,尽管这种方法远优于单阶段部署,但 DevOps 管道会指导你,作为软件架构师,进入另一个监控阶段。持续反馈将是一个不可思议的工具,我们将在下一节中讨论这种方法。

定义持续反馈和相关 DevOps 工具

一旦你在上一节描述的部署场景中运行完美的解决方案,反馈对于你的团队理解发布的结果以及版本如何为顾客工作至关重要。为了获取这些反馈,一些工具可以帮助开发者和顾客,将这些人聚集在一起以加速反馈过程。让我们来看看这些工具。

使用 Application Insights 监控你的软件

Application Insights 确实是软件架构师进行持续反馈所需的工具。一旦你将应用程序连接到它,你就开始接收对软件每个请求的反馈。这使得你不仅可以监控请求,还可以监控数据库性能、应用程序可能遭受的错误以及处理时间最长的调用。

显然,你将会有与将此工具集成到你的环境相关的成本,但该工具提供的功能绝对物有所值。此外,你需要了解,由于所有存储数据到 Application Insights 的请求都在一个单独的线程中运行,因此性能成本非常小。以下截图显示了你在环境中创建工具的简易性:

截图

例如,假设你需要分析你应用程序中耗时较长的请求。将 Application Insights 附接到你的 Web 应用程序的过程相当简单,考虑到它可以在你设置 Web 应用程序时立即完成。如果你不确定 Application Insights 是否是你的 Web 应用程序,你可以使用 Azure 门户来查找。导航到 App Services 并查看 Application Insights 设置,如下面的截图所示:

截图

界面将为你提供创建或附加已创建的监控服务到你的 Web 应用程序的机会。值得注意的是,你可以将多个 Web 应用程序连接到同一个 Application Insights 组件。以下截图显示了如何将 Web 应用程序添加到已创建的 Application Insights 资源中:

图片

一旦为您的 Web 应用程序配置了 Application Insights,您将在 App Services 中找到以下屏幕:

图片

一旦连接到您的解决方案,数据收集将持续进行,您将在组件提供的仪表板中看到结果。您可以在配置 Application Insights 的同一位置找到此屏幕,在 Web 应用程序配置中,或者在 Azure 门户中,通过 Application Insights 资源进行导航:

图片

此仪表板为您提供了关于失败请求、服务器响应时间和服务器请求的概览。您还可以开启可用性检查,这将从 Azure 数据中心中的任何一个向您选择的 URL 发送请求。

但 Application Insights 的美丽之处在于它如何深入分析您的系统。例如,在下面的截图中,它正在向您反馈网站上完成的请求数量。您可以通过对处理时间较长或被调用频率较高的请求进行排名来分析它:

图片

考虑到这个视图可以以不同的方式过滤,并且您在 Web 应用程序中接收到的信息是事件发生后立即的,这确实是一个定义持续反馈的工具。这是您可以使用 DevOps 原则实现客户确切需求的最佳方式之一。

Application Insights 是一个技术工具,它完全符合您作为软件架构师在真实分析模型中监控现代应用程序的需求。它是一种基于您正在开发的系统用户行为的持续反馈方法。

使用测试和反馈工具来启用反馈

在持续反馈的过程中,另一个非常有用的工具是微软设计的测试和反馈工具,旨在帮助产品所有者和质量保证用户在分析新功能的过程中。

使用 Azure DevOps,您可以通过在每个工作项内部选择一个选项来向您的团队请求反馈,正如您在以下截图中可以看到的:

图片

一旦收到反馈请求,您可以使用测试和反馈工具来分析和给出正确的反馈给团队。您可以将此工具连接到您的 Azure DevOps 项目,在分析反馈请求时获得更多功能。以下截图显示了如何为测试和反馈工具设置 Azure DevOps 项目 URL。您可以从marketplace.visualstudio.com/items?itemName=ms.vss-exploratorytesting-web下载此工具。

图片

工具相当简单。您可以截图、记录一个过程,甚至做笔记。以下图片显示了您如何在截图内轻松地写消息:

好处在于你可以在会话时间线上记录所有这些分析。正如您可以在下一张截图中所见,您可以在同一个会话中获得更多反馈,这对分析过程是有益的:

一旦您完成了分析并且连接到 Azure DevOps,您将能够报告 bug、创建任务,甚至开始一个新的测试用例:

创建的 bug 的结果可以在 Azure DevOps 的工作项板上进行检查。值得一提的是,您不需要 Azure DevOps 的开发者许可证就可以访问这个环境区域。这使得您,作为一个软件架构师,可以将这个基本且非常有用的工具推广到您正在构建的解决方案的许多关键用户。

以下截图显示了您将工具连接到您的 Azure DevOps 项目后创建的 bug:

拥有这样一个工具以获得项目的好反馈无疑是重要的。但作为一个软件架构师,您可能需要找到最佳解决方案来加速这一过程。书中探讨的工具是这样做的好方法。您可能每次在开发过程中需要实施更多步骤时都会考虑这种方法。

WWTravelClub 项目方法

在本章中,WWTravelClub 项目的截图展示了实现良好的 DevOps 周期所需的步骤。WWTravelClub 团队决定使用 Azure DevOps,因为他们明白这个工具对于整个周期的最佳 DevOps 体验是必不可少的。

需求是用用户故事编写的,这些可以在 Azure DevOps 的工作项部分找到。代码放在 Azure DevOps 项目的仓库中。这两个概念在第三章,使用 Azure DevOps 记录需求中进行了解释。

用于完成任务的管理生命周期是 Scrum,在第一章第一章,理解软件架构的重要性中介绍。这种方法将实现分为 Sprint,这迫使每个周期结束时交付价值。使用我们在本章中学到的持续集成设施,每次团队将开发成果提交到仓库的 master 分支时,代码都会被编译。

一旦代码编译并测试,部署的第一阶段就完成了。第一阶段通常被称为开发/测试,因为您为内部测试启用了它。Application Insights 和测试与反馈都可以用来获取新版本的第一反馈。

如果新版本的测试和反馈通过,那么就是时候进入第二阶段——质量保证。Application Insights 和测试与反馈可以再次使用,但现在是在一个更稳定的环境中。

循环以在生产阶段部署授权结束。这当然是一个艰难的决定,但 DevOps 表明您必须持续这样做,以便从客户那里获得更好的反馈。Application Insights 仍然是一个非常有用的工具,因为您能够监控新版本在生产中的演变,甚至将其与过去版本进行比较。

这里描述的 WWTravelClub 项目方法可以用于许多其他现代应用程序开发生命周期。作为软件架构师,您负责使这一切发生。工具已经准备就绪,这取决于您来确保一切顺利进行!

摘要

在本章中,我们学习了 DevOps 不仅是一系列技术和工具的组合,用于持续交付软件,而且是一种哲学,旨在使您正在开发的项目最终用户能够持续获得价值。

考虑到这种方法,我们看到了持续集成、持续交付和持续反馈对于 DevOps 目的的重要性。我们还看到了 Azure、Azure DevOps 和 Microsoft 工具如何帮助您实现目标。

本章通过使用 WWTravelClub 作为示例,向您介绍了这种方法,在 Azure DevOps 内部实现 CI/CD,并使用 Application Insights 和测试与反馈工具进行技术和功能反馈。在现实生活中,这些工具将使您能够更快地了解您正在开发的系统的当前行为,因为您将对其有持续的反馈。

在下一章中,我们将详细了解持续集成。

问题

  1. 什么是 DevOps?

  2. 什么是持续集成?

  3. 什么是持续交付?

  4. 什么是持续反馈?

  5. 构建和发布管道之间的区别是什么?

  6. 在 DevOps 方法中,Application Insights 的主要目的是什么?

  7. 测试与反馈工具如何帮助 DevOps 的过程?

进一步阅读

这些是一些您可以找到本章涵盖主题更多信息的网站:

第十九章:在 DevOps 中应用 CI 场景的挑战

持续集成(CI)(CI) 是 DevOps 的一步。在前一章中,我们讨论了持续集成(CI)的基础以及 DevOps 如何依赖于它。其实现也在第十八章,理解 DevOps 原则中介绍,但与其他实践章节不同,本章的目的是讨论如何在真实场景中启用持续集成(CI),考虑到你作为软件架构师需要应对的挑战。

本章涵盖的主题如下:

  • 理解持续集成(CI)

  • 理解使用持续集成(CI)时的风险和挑战

  • 理解本章的 WWTravelClub 项目方法

正如前一章所述,在解释本章内容时,将展示 WWTravelClub 的示例,因为所有用于说明持续集成(CI)的屏幕截图都来自它。此外,我们将在本章末尾提供一个结论,以便你能够轻松理解持续集成(CI)的原则。

到本章结束时,你将能够决定是否在你的项目环境中使用持续集成(CI)。此外,你将能够定义成功使用此方法所需的工具。

技术要求

本章需要 Visual Studio 2019 Community Edition 或更高版本。你可能还需要一个 Azure DevOps 账户,如第三章使用 Azure DevOps 记录需求中所述。

理解持续集成(CI)

一旦你开始使用 Azure DevOps 等平台,启用持续集成(CI)在点击相应的选项时将变得非常容易,正如我们在第十八章,理解 DevOps 原则中看到的。因此,技术并不是实施此过程的阿基里斯之踵。

以下截图展示了使用 Azure DevOps 启用持续集成(CI)是多么容易。通过点击构建管道并编辑它,你将能够设置一个触发器,在几次点击后启用持续集成(CI):

图片

事实上,持续集成(CI)将帮助你解决一些问题。例如,它将迫使你测试你的代码,因为你需要更快地提交更改,以便其他开发者可以使用你正在编写的代码。

另一方面,你仅仅通过点击前面的截图并不能启用持续集成(CI)。当然,一旦你完成提交并且代码已经完成,你将能够启动构建的可能性,但这远不能说明你在解决方案中已经有了持续集成(CI)。

作为软件架构师,你需要更加关注它的原因与对 DevOps 的真正理解有关。正如在第十八章理解 DevOps 原则中讨论的,向最终用户提供价值始终是决定和绘制开发生命周期的良好方式。因此,即使开启 CI 很容易,这个功能对最终用户的影响是什么?一旦你有了对这个问题的所有答案,并且你知道如何降低其实施的风险,那么你就可以说你已经实施了一个 CI 流程。

值得注意的是,CI 是一个可以使 DevOps 工作得更好、更快的原则,正如在第十八章理解 DevOps 原则中讨论的。然而,一旦你不确定你的流程是否足够成熟以实现代码的持续交付,DevOps 完全可以没有它。更重要的是,如果你在一个处理其复杂性还不够成熟的团队中开启 CI,你可能会对 DevOps 产生误解,因为你将在部署解决方案时开始承担一些风险。

这就是为什么我们专门用额外的一章来介绍 CI。你需要了解一旦开启 CI,作为软件架构师你将面临的风险和挑战。

使用 CI 时的风险和挑战理解

现在,你可能正在考虑风险和挑战作为避免使用 CI 的一种方式。但为什么我们要避免使用它,如果它可以帮助你更好地进行 DevOps 流程呢?这不是本章的目的。本节的想法是帮助你,作为软件架构师,通过良好的流程和技术来减轻风险,找到更好的方法来克服挑战。

本章将讨论的风险和挑战列表如下:

  • 持续生产部署

  • 生产中的不完整功能

  • 测试中的不稳定解决方案

一旦你有了处理它们的技巧和流程,就没有不使用 CI 的理由。值得一提的是,DevOps 并不依赖于 CI。然而,它确实使 DevOps 工作更加顺畅。现在,让我们来看看它们。

禁用持续生产部署

持续生产部署是一个过程,在提交新代码片段和一些管道步骤之后,你将在生产环境中拥有这段代码。这并非不可能,但确实很难且成本高昂。此外,你需要一个真正成熟的团队。问题是,你将在互联网上找到的大多数演示和示例都会展示 CI 的快速部署路径。CI/CD 的演示看起来非常简单和容易操作!这种简单性可能会让你尽快开始实施。然而,如果你稍微思考一下,直接在生产环境中部署可能会很危险!在一个需要每天 24 小时、每周 7 天都可用解决方案中,这是不切实际的。因此,你需要担心这一点,并考虑不同的解决方案。

第一个方法是使用多阶段场景,如第十八章中所述的理解 DevOps 原则。多阶段场景可以为构建的部署生态系统带来更多安全性。此外,你将获得更多选项来避免错误的生产部署,例如预部署批准:

图片

值得注意的是,你可以构建一个部署管道,其中所有代码和软件结构都将由这个工具更新。然而,如果你有超出这个场景的内容,比如数据库脚本和环境配置,错误的生产发布可能会对最终用户造成损害。此外,何时更新生产环境的决定需要计划,在许多场景中,所有平台用户都需要。在这些情况下,使用变更管理程序需要决定。

因此,将代码部署到生产环境所带来的挑战会让你考虑制定一个部署的时间表。无论是按月、按日,甚至是每次提交后,这都无关紧要。关键点在于你需要创建一个流程和管道,确保只有良好且经过批准的软件处于生产阶段。

不完整的特性

当你的团队中的开发者正在创建新功能或修复错误时,你可能会考虑生成一个分支,以避免使用为持续交付设计的分支。分支可以被视为代码仓库中的一项功能,它允许创建一个独立的开发线,因为它隔离了代码。正如你可以在下面的屏幕截图中所见,使用 Visual Studio 创建分支相当简单:

图片

这似乎是一个好方法,但让我们假设开发者已经认为实现已经准备好部署,并且刚刚将代码合并到主分支。如果因为这个要求被遗漏,这个功能还没有准备好,会发生什么?如果错误导致了不正确的行为,结果可能是一个带有不完整功能或错误修复的发布。

避免在主分支中引入损坏的功能甚至错误的修复措施的好做法是使用拉取请求。拉取请求将让其他团队成员知道你开发的代码已准备好合并。以下截图显示了如何使用 Azure DevOps 创建一个针对你所做的更改的新拉取请求:

图片

一旦创建了拉取请求并定义了审查者,每个审查者都将能够分析代码并决定这些代码是否足够健康,可以放入主分支。以下截图显示了如何通过使用比较工具来分析更改来检查它:

图片

一旦所有审批都完成,你将能够安全地将代码合并到主分支,如下面的截图所示。要合并代码,你需要点击“完成合并”。如果 CI 触发器已启用,如本章前面所示,Azure DevOps 将启动构建管道:

图片

没有办法争论,如果没有这样的流程,主分支将遭受许多糟糕的代码部署,这可能会与 CD 一起造成损害。值得一提的是,代码审查在 CI/CD 场景中是一种优秀的实践,并且被认为是在任何软件中创建良好质量的绝佳实践。

你需要关注这里的挑战是确保只有完整的功能会出现在最终用户面前。你可以使用功能标志原则来解决它,这是一种确保只有准备好的功能呈现给最终用户的技巧。再次强调,我们不是在谈论 CI 作为一个工具,而是在谈论一个需要在每次需要向生产交付代码时定义和使用的流程。

测试的不稳定解决方案

考虑到你已经缓解了本主题中提到的其他两个风险,你可能会发现,在 CI 之后编写出糟糕的代码相当困难。确实,考虑到你正在处理多阶段场景并在推送至第一阶段之前提交拉取请求,之前提出的担忧肯定会降低。

但是,有没有一种方法可以加速发布版本的评估,确保这个新版本已经准备好供利益相关者进行测试?是的,有!从技术上讲,你可以这样做的方式在第十五章的用例中描述,使用单元测试用例和 TDD 测试你的代码,以及第二十章的软件测试自动化中。

正如这两章所讨论的,考虑到实现自动化所需的努力,自动化软件的每一个部分都是不切实际的。此外,在用户界面或业务规则变化很大的场景中,自动化的维护可能更加昂贵。

为了举例说明,让我们看一下以下截图,它显示了当 WWTravelClub 项目启动时 Azure DevOps 创建的单元测试和功能测试:

图片

在第九章“设计模式和.NET Core 实现”中介绍了某些架构模式,如 SOLID,以及一些质量保证方法,如同行评审,这些方法将比软件测试提供更好的结果。

然而,这些方法并没有使自动化实践失效。事实是,所有这些方法在获得稳定解决方案时都将是有用的,尤其是在你运行 CI 场景时。在这种情况下,你能做的最好的事情就是尽可能快地检测错误和不良行为。正如前面所展示的,单元测试和功能测试都将帮助你做到这一点。

单元测试在部署前发现业务逻辑错误时将非常有帮助,尤其是在构建管道期间。例如,在以下截图中,你将找到一个模拟的错误,由于单元测试未通过,该错误取消了构建:

图片

获取这种错误的方法相当简单。你需要编写一些代码,这些代码不会根据单元测试所检查的内容做出响应。一旦你提交了它,考虑到你开启了持续部署的触发器,你的代码将在管道中构建。我们创建的 Azure DevOps 项目向导提供的最后一步是执行单元测试。因此,在代码构建之后,单元测试将会运行。如果代码不再匹配测试,你将得到错误。

同时,以下截图显示了在开发/测试阶段功能测试期间出现的错误。此时,开发/测试环境存在一个被功能测试快速检测到的错误:

图片

但这并不是在 CI/CD 过程中应用功能测试的唯一好处,一旦你用这种方法保护了其他部署阶段。例如,让我们看一下以下来自 Azure DevOps 发布管道界面的截图。如果你查看 Release-9,你会意识到自从这个错误在开发/测试环境发布后发生,多阶段环境将保护部署的其他阶段:

图片

持续集成(CI)过程成功的关键是将它视为加速软件交付的有用工具,并不要忘记团队始终需要向最终用户提供价值。采用这种方法,前面介绍的技术将提供实现团队目标结果的惊人方式。

理解 WWTravelClub 项目的方法

在本章中,WWTravelClub 项目的截图展示了在启用持续集成(CI)的同时获得更安全方法的过程。即使将 WWTravelClub 视为一个假设场景,在构建它时也考虑了一些担忧:

  • 持续集成(CI)已启用,但多阶段场景也已启用。

  • 即使是多阶段场景,拉取请求也是一种保证在第一阶段只展示高质量代码的方式。

  • 要做好拉取请求的工作,需要进行同行评审。

  • 例如,同行评审检查在创建新功能时是否存在功能标志。

  • 同行评审检查在创建新功能期间开发的单元测试和功能测试。

之前提到的步骤不仅适用于 WWTravelClub。作为软件架构师,您需要定义保证安全持续集成(CI)场景的方法。您可以将此作为起点。

摘要

本章介绍了在软件开发生命周期中何时启用持续集成(CI)的重要性,并考虑了您一旦决定在解决方案中实施它,作为软件架构师将面临的潜在风险和挑战。

此外,本章还介绍了一些可以使此过程更容易的解决方案和概念,例如多阶段环境、拉取请求评审、功能标志、同行评审和自动化测试。理解这些技术和流程将使您能够引导项目在 DevOps 场景中的持续集成(CI)方面表现出更安全的行为。

在下一章中,我们将看到软件测试自动化的工作原理。

问题

  1. 什么是持续集成(CI)?

  2. 您可以在没有持续集成(CI)的情况下进行 DevOps 吗?

  3. 在一个不成熟的团队中启用持续集成(CI)有哪些风险?

  4. 多阶段环境如何帮助持续集成(CI)?

  5. 自动化测试如何帮助持续集成(CI)?

  6. 拉取请求如何帮助持续集成(CI)?

  7. 拉取请求是否仅与持续集成(CI)一起工作?

进一步阅读

这些是一些网站,您可以在这些网站上找到关于本章涵盖主题的更多信息:

第二十章:软件测试自动化

在前面的章节中,我们讨论了单元测试和集成测试在软件开发中的重要性,以及它们如何确保代码库的可靠性。我们还讨论了单元和集成测试是所有软件生产阶段的组成部分,并且每次修改代码库时都会运行。

还有其他重要的测试,称为功能/验收测试。它们只在每个冲刺结束时运行,以验证冲刺的输出实际上是否满足与利益相关者商定的规范。

本章专门介绍功能/验收测试以及定义和执行它们的技巧。更具体地说,本章涵盖了以下主题:

  • 理解功能测试的目的

  • 使用单元测试工具自动化 C#中的功能测试

  • 用例 – 自动化功能测试

到本章结束时,你将能够设计手动和自动测试,以验证冲刺产生的代码是否符合其规范。

技术要求

鼓励读者在继续本章之前阅读第十五章,使用单元测试用例和 TDD 测试您的代码

本章需要 Visual Studio 2017 或 2019 免费社区版或更高版本,并安装所有数据库工具。在这里,我们将修改第十五章的代码,使用单元测试用例和 TDD 测试您的代码,该代码可在github.com/PacktPublishing/Hands-On-Software-Architecture-with-CSharp-8/tree/master/ch20找到.

理解功能测试的目的

功能/验收测试使用与单元和集成测试类似的技术,但它们的不同之处在于它们只在每个冲刺结束时运行。它们的基本作用是验证当前版本的整个软件是否符合其规范。这种验证被转化为以下目的的正式流程:

  • 功能测试代表了利益相关者和开发团队之间合同的最重要部分,另一部分是非功能规格的验证。这种合同的形式化方式取决于开发团队和利益相关者之间关系的本质。在供应商-客户关系的案例中,它们成为每个冲刺的供应商-客户商业合同的一部分,并由为客户工作的团队编写。如果测试失败,则冲刺将被拒绝,供应商必须运行一个补充冲刺来解决所有问题。如果没有正式的商业合同,测试的结果通常用于驱动下一个冲刺的规格。然而,在这种情况下,如果失败率很高,冲刺可能被拒绝并需要重新进行。

  • 在每个冲刺结束时运行的正式功能测试可以避免之前冲刺中取得的结果被新代码破坏。

  • 当使用敏捷开发方法时,维护一个最新的功能测试库是获取最终系统规格正式表示的最佳方式,因为在敏捷开发过程中,最终系统的规格并不是在开发开始之前就确定的,而是系统演化的结果。

由于早期阶段的第一轮冲刺的输出可能与最终系统有很大差异,因此不值得花费太多时间编写详细的手动测试和/或自动化测试。因此,你可以限制只添加一些示例到用户故事中,这些示例将同时作为软件开发输入和手动测试。

随着系统功能始终更加稳定,投入时间编写详细和正式的功能测试是值得的。对于每个功能规格,我们必须编写测试来验证它们在极端情况下的正确操作。例如,在一个支付用例中,我们必须编写测试来验证所有可能性:

  • 资金不足

  • 各种数字化错误

  • 卡片过期

  • 错误的凭证和重复的错误凭证

在手动测试的情况下,对于上述每个场景,我们必须提供每个操作中涉及的每个步骤的所有详细信息,以及每个步骤的预期结果。

一个重要的决定是是否要自动化所有或部分验收/功能测试,因为编写模拟与系统用户界面交互的人类操作员的自动化测试非常昂贵。最终的决定取决于测试实现的成本除以预期使用的次数。

在 CI/CD 的情况下,相同的功能测试可以执行多次,但不幸的是,功能/验收测试严格依赖于用户界面的实现方式,而在现代系统中,用户界面经常发生变化。因此,在这种情况下,相同的测试最多只能与完全相同的用户界面执行几次。

为了克服与用户界面相关的所有问题,功能测试可以实施为皮下测试,即作为绕过用户界面的测试。然而,皮下测试由于其本质是不完整的,因为它们无法检测用户界面本身的错误。此外,在 Web 应用的情况下,皮下测试通常还受到其他限制,因为它们绕过了整个 HTTP 协议。在 ASP.NET Core 应用的情况下,这意味着必须绕过整个 ASP.NET Core 管道,并将请求直接传递到 ASP.NET 控制器。因此,身份验证、授权、CORS 以及 ASP.NET Core 管道中其他模块的行为将不会被测试分析。

一个完整的 Web 应用功能自动测试应该执行以下操作:

  1. 在要测试的 URL 上启动一个实际的浏览器。

  2. 等待页面上的任何 JavaScript 完成其执行。

  3. 然后,向浏览器发送模拟人类操作员行为的命令。

  4. 最后,在每次与浏览器的交互之后,自动测试应该等待,以便任何由交互触发的 JavaScript 完成其执行。

虽然存在浏览器自动化工具,但如前所述,使用浏览器自动化实现的测试非常昂贵且难以实现。因此,ASP.NET Core MVC 建议的方法是使用.NET HTTP 客户端向实际的 Web 应用副本发送实际的 HTTP 请求,而不是使用浏览器。一旦 HTTP 客户端收到 HTTP 响应,它就会将其解析为 DOM 树,并验证它是否收到了正确的响应。

与浏览器自动化工具的唯一区别是 HTTP 客户端无法运行任何 JavaScript。然而,可以添加其他测试来测试 JavaScript 代码。这些测试基于特定的 JavaScript 测试工具,例如JasmineKarma

下一个部分将解释如何使用.NET HTTP 客户端自动化 Web 应用的功能测试,而功能测试自动化的实际示例将在最后一部分展示。

使用单元测试工具来自动化 C#中的功能测试。

自动化的功能/验收测试使用与单元和集成测试相同的测试工具。也就是说,这些测试可以嵌入到我们在第十五章中描述的同一 xUnit、NUnit 或 MSTests 项目中,即使用单元测试用例和 TDD 测试您的代码。然而,在这种情况下,我们必须添加能够与用户界面交互和检查的进一步工具。

在本章剩余部分,我们将专注于网络应用程序,因为它们是本书的主要焦点。因此,如果我们正在测试网络 API,我们只需要HTTPClient实例,因为它们可以轻松地与 XML 和 JSON 格式的网络 API 端点进行交互。

在 ASP.NET Core MVC 应用程序返回 HTML 页面的情况下,交互更为复杂,因为我们还需要工具来解析和与 HTML 页面 DOM 树进行交互。AngleSharp NuGet 包是一个很好的解决方案,因为它支持最先进的 HTML 和最小化的 CSS,并为外部提供的 JavaScript 引擎(如 Node.js)提供了扩展点。然而,我们不建议你在测试中包含 JavaScript 和 CSS,因为它们严格绑定到目标浏览器,所以对于它们来说,最佳选项是使用可以在目标浏览器中直接运行的 JavaScript 特定测试工具。

使用HTTPClient类测试网络应用程序有两个基本选项:

  • 一个HTTPClient实例通过互联网/内网与实际的预发布网络应用程序以及所有正在测试软件的 beta 测试人员连接。这种方法的优势在于你正在测试真实的东西,但测试的构思更困难,因为你无法控制每个测试之前应用程序的初始状态。

  • 一个HTTPClient实例连接到一个在每次测试之前都配置、初始化和启动的本地应用程序。这种情况与单元测试场景完全类似。测试结果可以在每个测试的初始状态固定后重现,测试设计更容易,并且实际数据库可以被一个更快、更容易初始化的内存数据库所替代。然而,在这种情况下,你离实际系统的操作还相当远。

一个好的策略是使用第二种方法,即完全控制初始状态,用于测试所有极端情况,然后使用第一种方法测试真实环境中的随机平均情况。

下面的两个部分描述了两种方法。这两种方法的不同之处仅在于你定义测试固定值的方式。

测试预发布应用程序

在这种情况下,你的测试只需要一个HTTPClient实例,因此你必须定义一个高效的固定值,它提供HTTPClient实例,避免出现窗口连接耗尽的风险。我们在第十二章的“.NET Core HTTP 客户端”部分遇到了这个问题,使用.NET Core 应用服务架构。可以通过使用IHTTPClientFactory管理HTTPClient实例并将它们通过依赖注入注入来解决。

一旦我们有一个依赖注入容器,我们可以通过以下代码片段来丰富其处理HTTPClient实例的能力:

services.AddHTTPClient();

在这里,AddHTTPClient 扩展属于 Microsoft.Extensions.DependencyInjection 命名空间,并在 Microsoft.Extensions.HTTP NuGet 包中定义。因此,我们的测试固定项必须创建一个依赖注入容器,必须调用 AddHTTPClient,最后必须构建容器。以下固定类执行此操作(如果您不记得固定类,请参阅 第十五章 的 高级测试准备/测试清理场景 部分,使用单元测试用例和 TDD 测试您的代码,如果您不记得固定类):

public class HTTPClientFixture
{
    public HTTPClientFixture()
    {
        var serviceCollection = new ServiceCollection();
        serviceCollection
            .AddHTTPClient();
         ServiceProvider = serviceCollection.BuildServiceProvider();
    }

    public ServiceProvider ServiceProvider { get; private set; }
}

在前面的定义之后,您的测试应该如下所示:

public class UnitTest1:IClassFixture<HTTPClientFixture>
{
    private ServiceProvider _serviceProvider;

    public UnitTest1(HTTPClientFixture fixture)
    {
        _serviceProvider = fixture.ServiceProvider;
    }

    [Fact]
    public void Test1()
    {
        using (var factory = 
            _serviceProvider.GetService<IHTTPClientFactory>())
        {
            HTTPClient client = factory.CreateClient();
            //use client to interact with application here
        }
    }
}

Test1 中,一旦您获取了 HTTP 客户端,您可以通过发出 HTTP 请求并分析应用程序返回的响应来测试应用程序。有关如何处理服务器返回的响应的更多详细信息将在 用例 部分提供。

下一节解释如何测试在受控环境中运行的应用程序。

测试受控应用程序

在这种情况下,我们在测试应用程序中创建一个 ASP.NET Core 服务器,并使用 HTTPClient 实例对其进行测试。Microsoft.AspNetCore.Mvc.Testing NuGet 包包含我们创建 HTTP 客户端和运行应用程序的服务器所需的所有内容。我们还需要通过引用 Microsoft.AspNetCore.App NuGet 包来引用整个 Web 框架。

最后,我们必须通过以下步骤将测试项目转换为 Web 项目:

  1. 在 Visual Studio 解决方案资源管理器中单击测试项目图标,然后从上下文菜单中选择编辑项目项。

  2. 将根 XML 节点替换为 <Project Sdk="Microsoft.NET.Sdk">,改为 <Project Sdk="Microsoft.NET.Sdk.web">

Microsoft.AspNetCore.Mvc.Testing 包含一个固定类,该类负责启动本地 Web 服务器并为与之交互提供客户端。预定义的固定类是 WebApplicationFactory<T>。泛型 T 参数必须使用您的 Web 项目的 Startup 类进行实例化。

测试看起来像以下类:

public class UnitTest1 
    : IClassFixture<WebApplicationFactory<MyProject.Startup>>
{
    private readonly 
        WebApplicationFactory<RazorPagesProject.Startup> _factory;

    public UnitTest1 (WebApplicationFactory<MyProject.Startup> 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.

    }
    ...
    ---
}

如果您想分析返回页面的 HTML,您还必须引用 AngleSharp NuGet 包。我们将在下一节的示例中看到如何使用它。在这种类型的测试中处理数据库的最简单方法是使用内存数据库,这些数据库更快,并且每当本地服务器关闭和重新启动时都会自动清除。

这可以通过创建一个新的部署环境,例如AutomaticStaging,以及一个针对测试的特定配置文件来完成。在创建了这个新的部署环境之后,前往你的应用程序的Startup类中的ConfigureServices方法,找到添加你的DBContext配置的地方。一旦找到这个地方,就在那里添加一个if语句,如果应用程序正在AutomaticStaging环境中运行,就用类似以下的内容替换你的DBContext配置:

services.AddDbContext<MyDBContext>(options =>  options.UseInMemoryDatabase(databaseName: "MyDatabase"));

作为一种替代方案,你还可以在继承自WebApplicationFactory<T>的自定义固定工具的构造函数中添加所有必要的指令来清除标准数据库。

用例 - 自动化功能测试

在本节中,我们将向第十五章的 ASP.NET Core 测试项目添加一个简单的验收测试,该章节是使用单元测试用例和 TDD 测试你的代码。我们的测试方法基于Microsoft.AspNetCore.Mvc.TestingAngleSharp NuGet 包。请创建整个解决方案的新副本。

作为第一步,我们必须将测试项目转换成一个 Web 项目,通过替换其项目文件根节点的sdk属性来实现,即Sdk="Microsoft.NET.Sdk.web"

测试项目已经引用了位于test目录下的 ASP.NET Core 项目以及所有必需的 xUnit NuGet 包,因此我们只需要添加Microsoft.AspNetCore.Mvc.TestingAngleSharp NuGet 包。

现在,让我们添加一个名为UIExampleTestcs.cs的新类文件。我们需要using语句来引用所有必要的命名空间。更具体地说,我们需要以下内容:

  • using PackagesManagement;: 这是为了引用你的应用程序类所必需的。

  • using Microsoft.AspNetCore.Mvc.Testing;: 这是为了引用客户端和服务器类所必需的。

  • using AngleSharp;using AngleSharp.Html.Parser;: 这些是为了引用AngleSharp类所必需的。

  • System.IO: 这是为了从 HTTP 响应中提取 HTML 所必需的。

  • using Xunit: 这是为了引用所有xUnit类所必需的。

总结起来,整个using块如下:

using PackagesManagement;
using System;
using System.Collections.Generic;
using System.Linq;
using System.Threading.Tasks;
using Xunit;
using Microsoft.AspNetCore.Mvc.Testing;
using AngleSharp;
using AngleSharp.Html.Parser;
using System.IO;

我们将使用之前在测试受控应用程序部分中引入的标准固定工具类,即以下内容:

public class UIExampleTestcs:
         IClassFixture<WebApplicationFactory<Startup>>
{
    private readonly
       WebApplicationFactory<Startup> _factory;
    public UIExampleTestcs(WebApplicationFactory<Startup> factory)
    {
       _factory = factory;
    }
}

现在,我们已经准备好编写主页的测试了!这个测试验证了主页 URL 返回成功的 HTTP 结果,并且主页包含指向包管理页面的链接,这是一个/ManagePackages的相对链接。

理解这一点是基本的,即自动测试不应依赖于 HTML 的细节,而应仅验证逻辑事实,以避免在每次对应用程序 HTML 进行微小修改后频繁更改。这就是为什么我们只验证所需的链接存在,而不对其位置施加约束。

让我们将TestMenu称为主页测试:

[Fact]
public async Task TestMenu()
{
    var client = _factory.CreateClient();
    ...
    ...         
}

每个测试的第一步是创建一个客户端。然后,如果测试需要分析一些 HTML,我们必须准备所谓的AngleSharp浏览上下文:

//Create an angleSharp default configuration
var config = Configuration.Default;

//Create a new context for evaluating webpages 
//with the given config
var context = BrowsingContext.New(config);

配置对象指定了诸如 cookie 处理和其他浏览器相关属性等选项。到目前为止,我们已经准备好要求主页:

var response = await client.GetAsync("/");

作为第一步,我们验证收到的响应包含成功状态码,如下所示:

response.EnsureSuccessStatusCode(); 

前面的方法调用在状态码不成功时抛出异常,从而导致测试失败。HTML 分析需要从响应中提取。以下代码展示了如何简单地进行操作:

var stream = await response.Content.ReadAsStreamAsync();
string source;
using (StreamReader responseReader = new StreamReader(stream))
{
    source = await responseReader.ReadToEndAsync();
} 

ReadAsStreamAsync返回Stream,我们可以使用它来构建StreamReader(一个专门用于读取文本的流),它可以读取整个响应体。

现在,我们必须将提取的 HTML 传递给之前的AngleSharp浏览上下文对象,以便它可以构建 DOM 树。以下代码展示了如何操作:

var document = await context.OpenAsync(req => req.Content(source));

OpenAsync方法使用context中包含的设置执行 DOM 构建活动。构建 DOM 文档的输入由传递给OpenAsync方法的 lambda 函数指定。在我们的情况下,req.Content(...)从传递给Content方法的 HTML 字符串构建 DOM 树,这是客户端收到的响应中包含的 HTML。

一旦获得document对象,我们就可以像在 JavaScript 中使用它一样使用它。特别是,我们可以使用QuerySelector来查找具有所需链接的锚点:

var node = document.QuerySelector("a[href=\"/ManagePackages\"]");

剩下的就是验证node不是 null:

Assert.NotNull(node);

我们做到了!如果你想要分析需要用户登录或其他更复杂场景的页面,你需要启用 HTTP 客户端中的 cookie 和自动 URL 重定向。这样,客户端将表现得像通常的浏览器,它会存储和发送 cookie,并在接收到Redirect HTTP 响应时移动到另一个 URL。这可以通过将选项对象传递给CreateClient方法来实现,如下所示:

var client = _factory.CreateClient(
    new WebApplicationFactoryClientOptions
    {
        AllowAutoRedirect=true,
        HandleCookies=true
    });

使用前面的设置,你的测试可以做到一个普通浏览器能做的所有事情。例如,你可以设计测试,其中 HTTP 客户端登录并访问需要身份验证的页面,因为HandleCookies=true允许客户端存储身份验证 cookie 并在所有后续请求中发送它。

摘要

本章解释了验收/功能测试的重要性,以及如何定义详细的手动测试,以便在每个冲刺的输出上运行。到目前为止,你应该能够定义自动和/或手动测试,以验证在每个冲刺结束时,你的应用程序符合其规范。

然后,本章分析了何时值得自动化某些或所有验收/功能测试,并描述了如何在 ASP.NET Core 应用程序中自动化它们。

一个最终的例子展示了如何使用AngleSharp编写 ASP.NET Core 验收/功能测试,以检查应用程序返回的响应。

问题

  1. 在快速 CI/CD 循环的情况下,自动化用户界面验收测试是否总是值得?

  2. 对 ASP.NET Core 应用程序的皮下测试有什么缺点?

  3. 建议用于编写 ASP.NET Core 验收测试的技术是什么?

  4. 建议如何检查服务器返回的 HTML?

进一步阅读

关于 Microsoft.AspNetCore.Mvc.Testing NuGet 包和 AngleSharp 的更多详细信息可以在它们各自的官方文档中找到:docs.microsoft.com/en-US/aspnet/core/test/integration-tests?view=aspnetcore-3.0anglesharp.github.io/.

对 JavaScript 测试感兴趣的读者可以参考 Jasmine 文档:jasmine.github.io/.

第二十一章:评估

第一章

  1. 软件架构师需要了解任何可以帮助他们更快解决问题并确保他们有更好质量的技术的技术。

  2. Azure 提供了,并且不断演进,许多软件架构师可以在解决方案中实现的功能组件。

  3. 流程模型可以帮助你了解你的团队,你将提供的解决方案类型,以及可用的预算。

  4. 软件架构师会关注任何可能影响性能、安全性、可用性等方面的用户或系统需求。

  5. 所有这些,但非功能性需求需要更多的关注。

  6. 设计思维是一种帮助软件架构师精确定义用户需求的工具。

  7. 当我们想要定义功能性需求时,用户故事是好的。

  8. 缓存、异步编程和正确的对象分配。

  9. 为了检查实现是否正确,软件架构师将其与已经设计和验证过的模型和原型进行比较。

第二章

  1. 垂直和水平。

  2. 是的,你可以自动部署到已定义的 Web 应用程序,或者直接使用 Visual Studio 创建一个新的。

  3. 通过最小化它们闲置的时间来利用可用的硬件资源。

  4. 代码行为是确定的,因此易于调试。不可能出现死锁,执行流程模仿顺序代码的流程,这意味着更容易设计和理解。

  5. 因为正确的顺序可以最小化填写表格所需的动作数量。

  6. 因为它允许以独立于操作系统的方式操作路径文件。

  7. 它可以与多个 .NET Core 版本以及多个经典 .NET 框架版本一起使用。

  8. 控制台、.NET Core 和 .NET 标准类库;ASP.NET Core、测试和微服务。

第三章

  1. 不,它适用于多个平台。

  2. 是的,开发/部署过程中的每一步都可以自动化,包括部署到生产环境。

  3. 自动、手动和负载测试计划。

  4. 是的,他们可以通过 Azure DevOps 源来做到这一点。

  5. 为了管理需求和组织整个开发过程。

  6. 史诗级工作项代表由多个功能组成的高级系统子部分。

  7. 一种父子关系。

第四章

  1. 当你从本地解决方案迁移或拥有基础设施团队时,IaaS 是一个好的选择。

  2. PaaS 是在团队专注于软件开发时,快速且安全交付软件的最佳选择。

  3. 如果你打算交付的解决方案由知名玩家提供,例如 SaaS,你应该考虑使用它。

  4. 当你构建一个新系统,没有专门从事基础设施的人员,并且不想担心可伸缩性时,无服务器绝对是一个选项。

  5. Azure SQL Server 数据库可以在几分钟内启动,之后你将拥有 Microsoft SQL Server 的全部功能。

  6. Azure 提供了一组称为 Azure 认知服务的服务。这些服务提供视觉、语音、语言、搜索和知识方面的解决方案。

  7. 在混合场景中,你有灵活性来决定系统每个部分的最佳解决方案,同时尊重解决方案的路径并将其推向未来。

  8. 为了允许更新/写入并行性。

  9. 传递给 Create 方法的第三个参数,它创建代理实例,允许我们指定通信的允许目标。通常,ServiceReplicaListener 构造函数的第三个参数指定是否在辅助副本上创建监听器。

第五章

  1. 代码模块化和部署模块化。

  2. 不,其他重要优势包括很好地处理开发团队和整个 CI/CD 循环,以及轻松有效地混合异构技术的可能性。

  3. 一个帮助我们实现弹性通信的库。

  4. HostBuilder 方法中可以声明依赖注入和托管服务。

  5. 一旦你在开发机器上安装了 Docker,你就可以开发、调试和部署 Docker 化的 .NET Core 应用程序。你还可以将 Docker 镜像添加到由 Visual Studio 处理的服务 Fabric 应用程序中。

  6. 基于 Kubernetes .yaml 文件的那个。

  7. 面向集群外部的流量,并通过集群的 URI 访问的那个。

第六章

  1. 在数据库依赖提供者的帮助下。

  2. 要么通过调用它们 Id,要么通过使用 Key 属性装饰它们。

  3. 使用 MaxLengthMinLength 属性。

  4. 类似于:builder.Entity<Package>().HasIndex(m => m.Name);.

  5. 类似于 builder.Entity<Destination>()

    .HasMany(m => m.Packages)

    .WithOne(m => m.MyDestination)

    .HasForeignKey(m => m.DestinationId)

    .OnDelete(DeleteBehavior.Cascade);

  6. Add-Migration 和 Update-Database.

  7. 不,但你可以使用 Include LINQ 子句强制包含它们。

  8. 是的,它是,多亏了 Select LINQ 子句。

  9. 通过调用 context.Database.Migrate()

第七章

  1. 不,它是一个内存中的字典,可以用作缓存或满足其他内存存储需求。

  2. 是的,它们是。本章的大部分内容都是致力于解释为什么。

  3. 写操作。

  4. NoSQL 数据库的主要弱点是其一致性和事务,而其主要优势是性能,尤其是在处理分布式写入时。

  5. 最终一致性、一致性前缀、会话、有界不一致性、强一致性。

  6. 不,它们在分布式环境中效率不高。基于 GUID 的字符串表现更好,因为它们的唯一性是自动的,不需要同步操作。

  7. OwnsManyOwnsOne.

  8. 是的,它们可以。一旦你使用了 SelectMany,索引就可以用来搜索嵌套对象。

第八章

  1. Azure Functions 是一个 Azure PaaS 组件,允许你实现 FaaS 解决方案。

  2. 你可以使用不同的语言编写 Azure Functions,例如 C#、F#和 Node。你还可以使用 Azure Portal 和 Visual Studio VS Code 创建函数。

  3. Azure Functions 有两种计划选项。第一种计划是消费计划,你将根据使用的数量付费。第二种计划是 App Service 计划,你将与函数的需求共享 App Service 资源。

  4. 在 Visual Studio 中部署函数的过程与 Web 应用部署相同。

  5. 我们可以触发 Azure Functions 的方式有很多,例如使用 Blob 存储、Cosmos DB、Event Grid、Event Hubs、HTTP、Microsoft Graph 事件、队列存储、服务总线、定时器和 Webhooks。

  6. Azure Functions v1 需要.NET Framework 引擎,而 v2 需要.NET Core。

  7. 每个 Azure Function 的执行都可以通过 Application Insights 进行监控。在这里,你可以检查处理所需的时间、资源使用情况、每个函数调用中发生的错误和异常。

第九章

  1. 设计模式是解决软件开发中常见问题的良好解决方案。

  2. 虽然设计模式为你提供了我们在开发中面临的典型问题的代码实现,但设计原则有助于你在实现软件架构时选择最佳选项。

  3. 建造者模式将帮助你生成复杂的对象,而无需在你要使用它们的类中定义它们。

  4. 工厂模式在有多种来自同一抽象的对象且不知道在开始编码时需要创建哪个对象的情况下非常有用。

  5. 当你需要一个在软件执行期间只有一个实例的类时,单例模式非常有用。

  6. 当你需要提供一个控制对另一个对象访问的对象时,使用代理模式。

  7. 当你需要执行将影响对象行为的命令时,使用命令模式。

  8. 当你需要向一组其他对象提供有关对象的信息时,发布/订阅模式非常有用。

  9. 如果你想实现依赖倒置原则,DI 模式非常有用。

第十章

  1. 专家使用的语言变化以及词语含义的变化。

  2. 领域映射。

  3. 不;整个通信都通过实体进行,即聚合根。

  4. 因为聚合体代表部分-部分层次结构。

  5. 只有一个,因为仓库是聚合中心。

  6. 应用层操作仓库接口。仓库实现注册在依赖注入引擎中。

  7. 为了在多个聚合体上协调单事务操作。

  8. 更新和查询的规范通常相当不同,尤其是在简单的 CRUD 系统中。其最强形式的原因主要是查询响应时间的优化。

  9. 依赖注入。

  10. 不;必须进行严重的影响分析,以便我们可以采用它。

第十一章

  1. 不,因为这种方法会有很多重复代码,这会在维护时造成困难。

  2. 代码重用的最佳方法是创建库。

  3. 是的。你可以在你之前创建的库中找到已经创建的组件,然后通过创建可以在未来重用的新组件来增加这些库。

  4. .NET 标准是一个规范,它允许.NET 的不同框架之间实现兼容性,从.NET Framework 到 Unity。.NET Core 是.NET 的一种实现,并且是开源的。

  5. 通过创建.NET 标准库,你将能够在不同的.NET 实现中使用它,例如.NET Core、.NET Framework 和 Xamarin。

  6. 你可以使用面向对象的原则(继承、封装、抽象和多态)来启用代码重用。

  7. 泛型是一种复杂的实现,通过定义一个占位符来简化具有相同特性的对象在编译时被替换为特定类型的方式。

第十二章

  1. 不,因为这会违反服务对请求的反应必须依赖于请求本身,而不是之前与客户端交换的其他消息/请求的原则。

  2. 不,因为这会违反互操作性约束。

  3. 是的,它可以。POST 的主要操作必须是创建,但删除可以作为副作用执行。

  4. 三,即头和体的 Base64 编码加上签名。

  5. 从请求体中。

  6. 使用ApiController属性。

  7. ProducesResponseType属性。

  8. 使用RouteHttp<verb>属性。

  9. 类似于services.AddHttpClient<MyProxy>()

第十三章

  1. 开发者错误页面和开发者数据库错误页面、生产错误页面、主机、HTTPS 重定向、路由、身份验证和授权以及端点调用者。

  2. 不。

  3. 错误。同一个标签上可以调用多个标签助手。

  4. ModelState.IsValid

  5. @RenderBody()

  6. 我们可以使用@RenderSection("Scripts", required: false)

  7. 我们可以使用return View("viewname", ViewModel)

  8. 三。

  9. 不;还有ViewState字典。

第十四章

  1. 可维护性为你提供了快速交付你设计的软件的机会。它还允许你轻松修复错误。

  2. 圈复杂度是一个检测方法节点数量的指标。数字越高,影响越差。

  3. 版本控制系统将保证源代码的完整性,并为你提供分析每个修改历史的机会。

  4. Try-catch 是控制由你编写的代码引发的异常的一种方式。Try-finally 是一种保证即使在 try 块内部有异常,finally 块也会执行其过程的方式。当你想在同一块代码中解决这两种情况时,可以使用 try-catch-finally。

  5. 垃圾回收器是 .NET Core/.NET Framework 系统的一种,它监视你的应用程序并检测你不再使用的对象。它清理这些对象以释放内存。

  6. IDisposable 接口对于需要程序员进行清理的对象实例化的类来说很重要,因为垃圾回收器无法清理它们。

  7. .NET Core 以一种可以保证更安全代码的方式封装了一些设计模式,例如依赖注入和 Builder。

第十五章

  1. 因为大多数测试在软件发生任何更改后都必须重新进行。

  2. 因为在单元测试及其相关应用程序代码中发生完全相同错误的概率非常低。

  3. 当测试方法定义了多个测试时使用 [Theory],而当测试方法只定义了一个测试时使用 [Fact]

  4. Assert

  5. SetupReturnsReturnsAsync

  6. 是的;使用 ReturnAsync

第十六章

  1. 编写良好的代码是任何熟练掌握该编程语言的人都可以处理、修改和发展的代码。

  2. Roslyn 是用于在 Visual Studio 内部进行代码分析的 .NET 编译器。

  3. 代码分析是一种在编译前检测不良实践的实践,它考虑了代码的编写方式。

  4. 代码分析可以发现即使在表面上看起来很好的软件中也会发生的问题,例如内存泄漏和不良编程实践。

  5. Roslyn 可以用于代码分析。

  6. Visual Studio 扩展是在 Visual Studio 内部运行的编程工具。这些工具可以在某些情况下帮助你,在这些情况下 Visual Studio IDE 没有为你提供适当的功能。

  7. 微软代码分析、SonarLint 和 Code Cracker。

第十七章

  1. 为了最大化软件为目标组织提供的价值。

  2. 不;它需要获取所有能够最大化软件增值所需的能力。

  3. 因为当新用户订阅时,其租户必须自动创建,并且因为新的软件更新必须分发到所有客户的基础设施中。

  4. 是的;Terraform 是一个例子。

  5. Azure 管道。

  6. 你的业务依赖于 SaaS 供应商,因此其可靠性是基本的。

  7. 不;可扩展性同容错性和自动故障恢复一样重要。

第十八章

  1. DevOps 是一种持续向最终用户提供价值的途径。为了成功做到这一点,必须进行持续集成、持续交付和持续反馈。

  2. 持续集成允许你在每次提交更改时检查你交付的软件的质量。你可以通过在 Azure DevOps 中启用此功能来完成此操作。

  3. 持续交付允许你在确信所有质量检查都通过了你设计的测试后部署解决方案。Azure DevOps 通过提供相关工具来帮助你完成此操作。

  4. 持续反馈是采用 DevOps 生命周期中的工具,以便在性能、可用性以及你正在开发的应用的其他方面快速获得反馈。

  5. 构建管道将允许你运行构建和测试应用程序的任务,而发布管道将为你提供定义应用程序在每个场景中如何部署的机会。

  6. 应用洞察(Application Insights)是监控你已部署的系统健康状况的有用工具,这使得它成为一款出色的持续反馈工具。

  7. 测试与反馈(Test & Feedback)是一个工具,允许利益相关者分析你正在开发的软件,并能够与 Azure DevOps 连接以打开任务甚至错误。

第十九章

  1. 这是一种确保每次提交到代码仓库的代码都经过构建和测试的方法。

  2. 是的,你可以先独立设置 DevOps,然后稍后启用持续交付。你的团队和流程需要准备好并关注这一变化的发生。

  3. 所有这些风险都可能对你的生产环境造成损害。例如,你可能有一个尚未准备好的功能却已经部署了,你可能在客户不利的时刻造成服务中断,或者你可能因为错误的修复而遭受不良的副作用。

  4. 多阶段环境可以保护生产环境免受不良发布的影响。

  5. 自动化测试可以预测预览场景中的错误和不良行为。

  6. 拉取请求允许在主分支提交之前进行代码审查。

  7. 不;拉取请求(pull requests)可以帮助你在任何有 Git 作为源控制的发展方法中。

第二十章

  1. 不;这取决于用户界面的复杂性和变化频率。

  2. ASP.NET Core 管道不会执行,但输入会直接传递到控制器。

  3. 使用 Microsoft.AspNetCore.Mvc.Testing NuGet 包。

  4. 使用 AngleSharp NuGet 包。

posted @ 2025-10-22 10:25  绝不原创的飞龙  阅读(2)  评论(0)    收藏  举报